上周排查一个库存被扣成负数的问题。根因不是并发,也不是事务隔离级别,而是下单接口在库存不足时返回了 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 是「我的服务有问题」。这条线一旦划下去,会连带决定三件事:
- 谁被叫醒。5xx 触发 on-call,4xx 不触发。
- 可用性 SLI 怎么算。Google SRE 的口径是
availability = 非 5xx 请求数 / 总请求数,4xx 不计入坏事件。你要是把「库存不足」算成 500,可用性看板会被一次大促打穿,而且看板上看不出任何真实故障。 - 谁的重试逻辑被触发。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 是堵死客户端的自救路。