库存不足返回 500,网关重试把库存打成了负数

格 格达软件 2026-10-18 0 次阅读 AI 生成

上周排查一个库存被扣成负数的问题。根因不是并发,也不是事务隔离级别,而是下单接口在库存不足时返回了 500。

客户端 SDK 里躺着一行 if (status >= 500) retry(3),接入层又配了一次重试,两层叠起来,用户点一次按钮最多能打到后端 9 次。

这篇文章只讲一件事:4xx 和 5xx 这条线该划在哪,划错之后会以什么形式反噬你。

事故代码长什么样

// ❌ 所有异常一律 500
app.post('/orders', (req, res) => {
  createOrder(req.body)
    .then(id => res.status(201).json({ id }))
    .catch(e => res.status(500).json({ message: e.message }));
});

三类完全不同的错误被压成了一个信号:

  • 用户传的 quantity 是 -1
  • 库存只剩 2 件,用户要 3 件
  • 数据库连接池打满

前两类重试一万次结果都一样,第三类过两秒真有救。你把它们都标成 500,等于对所有中间件喊「这是我的锅,重试吧」。

接入层确实听了。Nginx 加一行就够:

# 默认只对连接层错误重试;显式加上状态码后,5xx 会换一台后端再打一次
proxy_next_upstream error timeout http_502 http_503 http_504;

重试在这里还有个隐性代价:createOrder 可能不幂等。订单行写完了、优惠券核销了,最后扣库存失败返回 500,重试会再写一条订单。库存变负只是最显眼的那个症状,对账的时候你会发现更难看的东西。

分界线:同一个请求原样再发一次,有没有可能成功

我一般用一句话判断:原样重发一次有可能成功,就是 5xx(或者 429);必然还是同样的结果,就是 4xx。

再叠一层责任归属:4xx 是「你的请求有问题」,5xx 是「我的服务有问题」。这条线一旦划下去,会连带决定三件事:

  1. 谁被叫醒。5xx 触发 on-call,4xx 不触发。
  2. 可用性 SLI 怎么算。Google SRE 的口径是 availability = 非 5xx 请求数 / 总请求数,4xx 不计入坏事件。你要是把「库存不足」算成 500,可用性看板会被一次大促打穿,而且看板上看不出任何真实故障。
  3. 谁的重试逻辑被触发。SDK、网关、gRPC、axios 拦截器,每一个都按状态码做决策。

429 是个特殊位:它既不是「你的请求写错了」,也不是「我挂了」,而是「你发得太快了,等一下就行」。所以它带 Retry-After,也应该被重试——但它属于 4xx 家族,这也是为什么大多数 SLI 公式要单独把 429 拎出来讨论。

反过来的告警也要有:4xx 占比突然从 0.5% 涨到 30%,通常意味着你刚发的客户端版本有 bug,或者有人在刷接口。这跟「服务挂了」不是同一类事故,别塞进同一个告警通道。

逐个挑:这几个状态码最容易选错

场景 状态码 客户端该做什么 要不要重试
请求体不是合法 JSON 400 修 bug 否
字段类型对但语义不通(数量 -1、结束时间早于开始时间) 422 或 400 提示用户 否
没带 token / token 过期 401 + WWW-Authenticate 刷新 token 或登录 刷新后重试一次
token 有效但没权限 403 提示无权限 否
路径不存在 404 修 bug 否
路径存在、方法不对 405 + Allow 头 修 bug 否
资源当前状态不允许(库存不足、订单已支付) 409 读一次资源状态再决定 视情况
幂等键重复,原始请求已完成 200 / 201 + 原始响应 直接用返回结果 否
同一个幂等键、不同的请求体 422 修 bug 否
触发限流 429 + Retry-After 退避重试 是
服务端故障 500 退避重试 是
上游返回了垃圾响应 502 退避重试 是(配幂等键)
上游超时 504 退避重试 是(配幂等键)

下面几个是重灾区。

库存不足用 409,不是 400

400 的语义是「请求本身有问题」。用户要买 3 件、库存剩 2 件,这个请求在语法和语义上都没毛病,只是跟当前的资源状态冲突了。409 的语义正好是「请求与目标资源的当前状态冲突」,而且它隐含一个动作:去读一下资源状态再决定。

所以 409 的响应体里应该带上能让客户端做决策的信息:

throw conflict('insufficient-stock', '库存不足', `SKU ${sku} 仅剩 ${available} 件`, {
  sku,
  requested: quantity,
  available,   // 客户端拿到这个数就能把数量改小重试,或者直接提示用户
});

只返回一句「库存不足」的 409,和返回 400 没本质区别,白白浪费了这个状态码。

422 和 400:选哪个都行,但别混着用

422 在 RFC 9110 里的正式名字从 Unprocessable Entity 改成了 Unprocessable Content,含义是「请求格式没问题,但内容处理不了」。

我的建议:同一个 API 里只用一种。全用 400 完全合法,很多大厂 API 就这么干。用 422 的价值在于让客户端能区分「字段格式错了,红框标在表单上」和「整个请求我根本解析不了,可能是版本不匹配」。

混着用的后果是客户端要写两套分支,而且第二套分支永远没人测。

401 和 403 搞反,会写出死循环

这是我在 code review 里见得最多的一对:

  • 401 必须带 WWW-Authenticate 响应头,这是 RFC 强制要求,很多客户端库没这个头就不走刷新逻辑。
  • 401 的语义是「你没证明你是谁」,客户端合理的反应是:刷新 token,然后重试一次。
  • 403 的语义是「我知道你是谁,但你不能干这个」,客户端不该刷新 token。

如果权限不足返回 401,客户端的刷新逻辑会被触发,刷新完还是没权限,再刷新……一个死循环,把认证服务打挂。反过来,token 过期返回 403,客户端以为用户没权限,直接把用户踹到「无权限」页面,用户一脸懵。

408、502、504 别乱用

408 Request Timeout 是「服务端等客户端把请求发完,等超时了」,实际业务里几乎用不到。我见过有人把「调下游 RPC 超时」返回成 408,这是错的——超时的对象搞反了。下游超时是 504(网关视角)或者 502(上游连接断了 / 响应不合法)。

501 Not Implemented 是「服务器不支持这个 HTTP 方法」,不是「这个功能我们还没做」。功能没上线就用 404 或者干脆别暴露路由,别用 501。

402 只在支付场景用

402 Payment Required 在 RFC 里一直是 reserved 状态。Stripe 拿它表示「卡被拒了」,用得挺自洽。你的业务如果不是支付,别用,用 409 加一个明确的 type 更清楚。

把上面这些落成代码

一个能直接跑的 Express + TypeScript 骨架。错误类型定义:

// errors.ts
export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly type: string,        // 机器可读的稳定标识,别用中文,改它等于改 API
    readonly title: string,
    readonly detail?: string,
    readonly extra?: Record<string, unknown>,
    readonly headers?: Record<string, string>,
  ) {
    super(title);
  }
}

export const badRequest = (type: string, title: string, detail?: string, extra?: Record<string, unknown>) =>
  new ApiError(400, type, title, detail, extra);

export const notFound = (type: string, title: string, detail?: string) =>
  new ApiError(404, type, title, detail);

export const conflict = (type: string, title: string, detail?: string, extra?: Record<string, unknown>) =>
  new ApiError(409, type, title, detail, extra);

export const tooManyRequests = (retryAfterSec: number) =>
  new ApiError(429, 'rate-limited', 'Too Many Requests', undefined, undefined, {
    'Retry-After': String(retryAfterSec),
  });

统一错误处理器,用 RFC 9457 的 application/problem+json:

// error-handler.ts
import type { ErrorRequestHandler } from 'express';

export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  if (res.headersSent) return next(err);

  if (err instanceof ApiError) {
    // 4xx 用 warn,采样输出,不打堆栈——它大概率是同一个客户端 bug 在反复触发
    req.log?.warn({ type: err.type, path: req.path }, 'api_error');

    res.status(err.status)
      .type('application/problem+json')
      .set(err.headers ?? {})
      .json({
        type: `https://errors.example.com/${err.type}`,
        title: err.title,
        status: err.status,
        detail: err.detail,
        instance: req.originalUrl,
        trace_id: req.id,
        ...err.extra,
      });
    return;
  }

  // 关键:没被显式分类的异常一律 500,绝不能兜底成 4xx
  req.log?.error({ err }, 'unhandled_error');
  res.status(500).type('application/problem+json').json({
    type: 'https://errors.example.com/internal',
    title: 'Internal Server Error',
    status: 500,
    instance: req.originalUrl,
    trace_id: req.id,
  });
};

下单接口,按「格式校验 → 语义校验 → 幂等 → 状态冲突」的顺序:

app.post('/orders', async (req, res) => {
  // 1) 结构校验:解析不了就是 400
  const parsed = CreateOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    throw badRequest('invalid-body', '请求体格式错误', undefined, {
      // 只回前 10 条,防止一个大 body 造出 200KB 的错误响应
      errors: parsed.error.issues.slice(0, 10).map(i => ({
        path: i.path.join('.'),
        message: i.message,
      })),
    });
  }
  const { sku, quantity } = parsed.data;

  // 2) 语义校验:类型对了但组合起来没意义,422
  if (quantity <= 0) {
    throw new ApiError(422, 'invalid-quantity', '数量必须为正', `收到的 quantity=${quantity}`);
  }

  // 3) 幂等键:缺失是调用方 bug,400
  const key = req.header('Idempotency-Key');
  if (!key) throw badRequest('idempotency-key-required', '缺少 Idempotency-Key 请求头');

  // 4) 重复请求回放原响应,而不是返回 409
  const cached = await redis.get(`idem:${key}`);
  if (cached) {
    const { status, body } = JSON.parse(cached);
    return res.status(status).set('Idempotent-Replayed', 'true').json(body);
  }

  // 5) 同 key 并发进来,抢占锁,抢不到的让客户端等
  const locked = await redis.set(`idem-lock:${key}`, '1', 'EX', 30, 'NX');
  if (!locked) {
    throw conflict('request-in-flight', '相同幂等键的请求正在处理中', '稍后重试即可');
  }

  try {
    // 6) 扣库存走条件更新,一条 SQL 同时完成判断和扣减
    const { rowCount } = await db.query(
      'UPDATE stock SET qty = qty - $1 WHERE sku = $2 AND qty >= $1',
      [quantity, sku],
    );

    if (rowCount === 0) {
      const { rows } = await db.query('SELECT qty FROM stock WHERE sku = $1', [sku]);
      if (rows.length === 0) throw notFound('sku-not-found', 'SKU 不存在', sku);
      // 请求合法,和当前状态冲突 → 409,并告诉客户端还剩多少
      throw conflict('insufficient-stock', '库存不足', `SKU ${sku} 仅剩 ${rows[0].qty} 件`, {
        sku, requested: quantity, available: rows[0].qty,
      });
    }

    const order = await createOrder({ sku, quantity });
    const body = { id: order.id, status: order.status };
    // 成功结果缓存 24h,重放时原样返回
    await redis.set(`idem:${key}`, JSON.stringify({ status: 201, body }), 'EX', 86400);
    res.status(201).json(body);
  } catch (e) {
    // 4xx 也缓存:同一个 key 重试应该拿到同样的错误,而不是重新执行一遍
    if (e instanceof ApiError && e.status < 500) {
      await redis.set(`idem:${key}`, JSON.stringify({
        status: e.status,
        body: { type: e.type, title: e.title, detail: e.detail, ...e.extra },
      }), 'EX', 86400);
    }
    // 5xx 不缓存,让调用方真的能重试
    throw e;
  } finally {
    // 锁必须在这里释放,否则用户要一直等到 TTL 过期才能重试
    await redis.del(`idem-lock:${key}`);
  }
});

验证一下:

# 400:请求体是空的
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/orders \
  -H 'content-type: application/json' -d '{}'

# 400:缺幂等键
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/orders \
  -H 'content-type: application/json' -d '{"sku":"A1","quantity":1}'

# 409:库存不足,响应体里带 available
curl -s -X POST localhost:3000/orders \
  -H 'content-type: application/json' \
  -H 'Idempotency-Key: demo-1' \
  -d '{"sku":"A1","quantity":999}' | jq

# 重放:同一个 key 再发一次,应该拿到相同结果,响应头带 Idempotent-Replayed
curl -si -X POST localhost:3000/orders \
  -H 'content-type: application/json' \
  -H 'Idempotency-Key: demo-1' \
  -d '{"sku":"A1","quantity":999}' | head -20

客户端重试的写法:

async function request(url: string, init: RequestInit, maxRetry = 4) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init);

    // 只有 5xx 和 429 值得重试,其余 4xx 一律直接返回给上层
    const retriable = res.status >= 500 || res.status === 429;
    if (!retriable || attempt >= maxRetry) return res;

    const retryAfter = Number(res.headers.get('Retry-After'));
    const base = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 2 ** attempt * 200;

    // 必须加 jitter:否则 1000 个客户端会在同一毫秒一起回来,第二轮限流更狠
    const delay = base * (0.5 + Math.random());
    await new Promise(r => setTimeout(r, delay));
  }
}

六个真踩过的坑

1. 把服务端异常 catch 成 400,监控直接失明

// ❌ 看起来很安全,其实是把故障藏起来了
app.post('/orders', async (req, res) => {
  try {
    const id = await createOrder(req.body);
    res.status(201).json({ id });
  } catch (e) {
    res.status(400).json({ message: (e as Error).message });
  }
});

数据库连不上、Redis 超时、下游返回 502,全部变成 400。监控上看到的是「4xx 飙升」,值班的人第一反应是「客户端又发版了」,真正的原因半小时后才被翻出来。顺带还泄露了 SQL 报错内容。

规则很简单:catch 里只捕获你明确知道怎么分类的异常,其余全部往上抛,由错误处理器的默认分支回 500。

2. 404 被 CDN 缓存,用户看不到刚创建的资源

订单创建成功,前端立刻跳详情页,GET /orders/123 拿到 404——因为还没同步到只读库。这本身没什么,前端重试一下就好。但如果这个 404 被 CDN 缓存了 60 秒,用户接下来一分钟都在刷 404。

修法是在路由上明确禁止缓存:

app.use('/orders', (req, res, next) => {
  // 用户私有数据,任何中间层都别缓存
  res.set('Cache-Control', 'no-store');
  next();
});

顺便说一句,404 和 410 在 HTTP 语义里都是可缓存的,别以为只有 200 会被缓存。

3. 4xx 打全量堆栈日志

一个被刷的接口,每秒 8000 个 400,每个都打一条带堆栈的 JSON 日志,日志盘十分钟写满,然后整个服务跟着挂。4xx 是唯一一类会被攻击者无限放大的错误,日志必须采样。

我一般这么配:4xx 用 warn 级别,按 type 做采样,同一个 type 每分钟最多打 10 条;5xx 全量打,带 trace_id。

4. 业务异常把熔断器打开了

这个坑特别隐蔽。支付服务里调银行网关,用 resilience4j 做熔断,配置是「滑动窗口 50 次,失败率超过 50% 就打开」。用户连续输错 50 次卡号(这是正常业务,银行返回「余额不足」),熔断器啪一下开了,接下来所有正常用户都付不了款。

修法是让熔断器只统计真正的技术故障:

resilience4j:
  circuitbreaker:
    instances:
      bankGateway:
        slidingWindowSize: 50
        failureRateThreshold: 50
        ignoreExceptions:
          # 上游明确返回 4xx 时抛的异常,不算故障
          - com.example.gateway.BankDeclinedException

同样的道理适用于所有按异常率统计的组件:重试中间件、降级开关、SLO 计算。业务异常必须被排除。

5. 429 退避没有 jitter

Retry-After: 1 加上 1000 个并发客户端,结果就是 1 秒后 1000 个请求同时到达,触发第二轮更狠的限流。这个问题在重试逻辑里出现了二十年,但还是有人写 setTimeout(retry, 1000)。上面那段 request 里的 base * (0.5 + Math.random()) 就是为了这个。

6. 幂等键占位锁没删

用 SETNX 占住幂等键,业务失败后忘了 del,结果用户重试永远拿到 409「请求处理中」,直到 TTL 过期。锁一定要放 finally,TTL 要短(30 秒量级),失败结果缓存 TTL 可以长(24 小时)。

还有个方向性的问题:4xx 要不要缓存进幂等结果? 要。同一个 key 反复重试,如果每次都重新执行一遍失败逻辑,等于把一次客户端 bug 放大成 N 次服务端调用。5xx 则坚决不缓存,因为重试可能真的会成功。

什么时候 200 + body 才是对的

前面说了这么多 4xx,但确实有些场景用 200 更合适。

批量操作部分成功。一次导入 1000 条,998 成功、2 条失败。返回 400 会让客户端以为整批都没进去,返回 207 又因为客户端支持太差而踩坑。用 200 加结构化结果最稳:

{
  "succeeded": 998,
  "failed": 2,
  "errors": [
    { "index": 17, "code": "invalid-phone", "message": "手机号格式错误" },
    { "index": 903, "code": "duplicate", "message": "已存在" }
  ]
}

注意这里 HTTP 层表达的是「你的请求我完整处理完了」,业务层表达的是「部分失败」。两件事,分开说。

异步任务。请求被接受但结果还没出来,返回 202 Accepted + Location: /jobs/abc,让客户端去轮询。这时候不该返回 4xx,因为你根本还不知道结果。

业务规则需要大量可操作信息。这时候不用退化成 200,用 422 + errors 数组就行。很多人有个误解,以为 4xx 的响应体只能放一句话,其实没有这个限制,problem+json 允许你加任意扩展字段。

反过来,绝对不该用 200 的场景:参数校验失败、鉴权失败、资源不存在。这些答案在 HTTP 层就是确定的,藏进 body 只有一个后果——所有中间件都得为你的接口写特例。

上线前过一遍这张单子

  • 每一处 throw 都能回答:这个请求原样再发一次,有没有可能成功?
  • 错误处理器的默认分支是 500,不是 400。
  • 401 带 WWW-Authenticate,403 不带;客户端刷新 token 的逻辑只挂在 401 上。
  • 5xx 响应体里没有堆栈、SQL、内网 IP、上游服务名。
  • type 字段是稳定的机器可读字符串,改了要当作 breaking change 发版。
  • 所有 409 都在 body 里带上当前资源状态,让客户端能做决策。
  • 429 和 503 带 Retry-After,客户端重试带 jitter。
  • 幂等键的锁在 finally 里释放,4xx 结果缓存、5xx 结果不缓存。
  • 熔断器、重试中间件、CDN 缓存、日志采样这四条链路,各自过一遍你的状态码。
  • 监控里 4xx 和 5xx 分两个指标、两个阈值、两个告警通道。

最后一句:4xx 和 5xx 不是严重程度的区别,是「谁能修好它」的区别。 把客户端永远修不好的错误标成 4xx 是甩锅,把一个重试就能好的错误标成 4xx 是堵死客户端的自救路。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。