错误处理与请求追踪
后端的错误处理,常见的失败形态有这几种:
- 接口出错时,把数据库报错和代码堆栈原样返回给了前端,暴露了表名和服务器路径;
- 不管什么原因都返回 500,前端只能提示“系统繁忙”;
- 用户截图报错,开发在日志里翻了半小时,找不到是哪次请求;
- 下游服务短暂故障,各个调用方疯狂重试,把它彻底压垮了。
这篇讲清楚错误该怎么分类、怎么返回、怎么记录,以及怎么从一次失败一路追到根因。
读完这篇你会
- 按责任给错误分类,返回稳定的错误体
- 用全局错误中间件统一处理错误,不泄露内部细节
- 用请求 ID 把前端、网关、后端、数据库的日志串起来
1. 原理:错误信息有两类读者
可以想想去医院看病:病人拿到的是诊断结论和下一步建议(“需要复查”“按时吃药”);完整的化验数据、影像、医生的判断过程,都记录在病历里,供医生查阅。
错误信息也一样:
- 给调用方的(前端、App、第三方):稳定的错误码、能看懂的提示、可以采取的下一步、一个请求 ID;
- 给开发者的(日志):完整的堆栈、请求参数、用户、上下游调用情况。
两者通过请求 ID 关联:调用方报告请求 ID,开发者用它找到完整的“病历”。
2. 按责任给错误分类
| 类型 | 例子 | HTTP 状态码 | 返回给调用方什么 |
|---|---|---|---|
| 输入问题 | 缺少必填字段、格式错误 | 400 / 422 | 具体哪个字段、怎么改 |
| 身份问题 | 未登录、登录过期 | 401 | 需要重新登录 |
| 权限问题 | 无权执行这个操作 | 403 | 没有权限 |
| 资源状态 | 不存在、已被修改、重复创建 | 404 / 409 | 资源当前的状态,是否需要刷新 |
| 限流 | 请求过于频繁 | 429 | 多久后可以重试 |
| 依赖故障 | 数据库、第三方超时 | 502 / 503 / 504 | 稍后重试 |
| 程序缺陷 | 未处理的异常、代码 bug | 500 | 只给请求 ID,不给细节 |
前五类是预期内的错误,是业务正常的一部分;后两类是预期外的错误,需要告警和修复。分清楚这一点,日志和告警才不会被大量的“用户输错了密码”淹没。
3. 统一的错误体
json
{
"code": "ORDER_ALREADY_PAID",
"message": "订单已支付,请勿重复操作",
"fields": {},
"requestId": "req_7f3a9c2e"
}code:给程序判断的稳定标识,用英文常量,不要让前端依赖message文案做判断;message:给用户看的提示;fields:表单字段级的错误;requestId:用来查日志。
4. 全局错误中间件:只在一个地方转换错误
业务代码只管抛出有类型的错误,由统一的中间件把它们转换成响应。以 Express 为例:
js
class AppError extends Error {
constructor(status, code, message, fields) {
super(message)
this.status = status
this.code = code
this.fields = fields
}
}
// 业务代码里
if (order.status === 'paid') {
throw new AppError(409, 'ORDER_ALREADY_PAID', '订单已支付,请勿重复操作')
}
// 注册在所有路由之后的错误处理中间件
// req.log 和 req.id 由日志中间件(如 pino-http)提供,请求 ID 的生成见第 6 节
app.use((err, req, res, next) => {
if (err instanceof AppError) {
// 预期内的错误:记一条简短日志即可
req.log.info({ code: err.code }, 'business error')
return res.status(err.status).json({
code: err.code,
message: err.message,
fields: err.fields ?? {},
requestId: req.id
})
}
// 预期外的错误:完整记录,对外只给请求 ID
req.log.error({ err }, 'unhandled error')
res.status(500).json({
code: 'INTERNAL_ERROR',
message: '服务暂时出现问题,请稍后重试',
requestId: req.id
})
})5. 坑一:把堆栈和数据库报错返回给了用户
现象:接口出错时,响应里包含了完整的 SQL 语句、表名、服务器文件路径,甚至数据库连接地址。
原因:开发时为了方便调试,把 err.message 和 err.stack 直接返回了,上线时忘了去掉;或者框架默认的错误页在生产环境开启了调试模式。
风险:攻击者可以据此了解数据库结构、框架版本、目录结构,为下一步攻击收集信息。
修复:预期外的错误,对外只返回固定的提示和请求 ID;详细信息只写进日志。检查生产环境的框架配置,确保关闭了调试模式。
6. 坑二:前端报错,后端日志里找不到
现象:用户截图反馈“提交失败”,开发按时间去日志里搜,同一秒有上百条记录,分不清哪条是这个用户的。
修复:让同一个请求 ID 贯穿整个调用链:
text
前端 ──X-Request-Id: req_7f3a──► 网关 ──► 订单服务 ──► 库存服务
│ │ │
所有日志都带上 requestId=req_7f3a,响应头和错误体也返回它- 入口处:如果请求头里已经带了请求 ID 就沿用,否则生成一个;
- 记录日志时,每一条都带上这个 ID;
- 调用下游服务时,通过请求头把它传下去;
- 响应头和错误体里返回这个 ID,前端在错误提示里展示“错误编号”,用户反馈时就能提供。
在 Node.js 里,可以用 AsyncLocalStorage 保存当前请求的上下文,这样即使在很深的函数调用里,也不用一层层传参就能拿到请求 ID:
js
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'
export const requestContext = new AsyncLocalStorage()
app.use((req, res, next) => {
const requestId = req.get('X-Request-Id') ?? randomUUID()
res.set('X-Request-Id', requestId)
requestContext.run({ requestId }, next) // 之后这次请求里的任何代码都能读取
})
export function currentRequestId() {
return requestContext.getStore()?.requestId
}7. 坑三:日志里只有一句“error”
现象:日志里有大量 Error: something went wrong,没有用户、没有参数、没有堆栈,根本无从查起。
修复:使用结构化日志(JSON 格式),每条日志带上足够的上下文,并且不要记录敏感信息:
js
logger.error({
requestId,
userId: actor.id,
orderId,
action: 'order.pay',
err // 包含 message 和 stack
}, '支付失败')密码、token、身份证号、银行卡号这类字段,要么不记录,要么脱敏后再记录。
8. 坑四:下游一出问题,重试把它彻底压垮
现象:支付服务出现几秒钟的抖动,所有调用方立刻开始重试,每个请求重试 3 次,请求量瞬间变成了 4 倍。支付服务本来很快能恢复,结果被这波重试彻底压垮了,故障持续了半小时。这叫重试风暴。
修复:
- 限制重试次数,并且每次重试的间隔逐渐变长(指数退避),再加上随机抖动,避免所有调用方在同一时刻一起重试;
- 只重试幂等的操作,或者带上幂等键,否则可能重复扣款;
- 下游持续失败时,暂时停止调用、快速失败(熔断),给它恢复的时间。
js
async function withRetry(fn, { retries = 3, baseDelay = 200 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
} catch (err) {
if (attempt >= retries || !isRetryable(err)) throw err
const delay = baseDelay * 2 ** attempt * (0.5 + Math.random()) // 指数退避 + 随机抖动
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}isRetryable 只对超时、网络错误、503 这类“暂时性”的失败返回 true;400、401、409 这类重试也不会成功的错误,直接失败。
9. 坑五:后台任务失败了,没有人知道
现象:每晚的对账任务已经连续失败了一周,直到财务发现数据对不上才被注意到。
原因:后台任务里的错误只写进了日志,没有告警,也没有记录任务状态。
修复:后台任务要记录每次执行的状态(成功、失败、重试中);超过重试次数的任务进入“死信”列表等待人工处理;关键任务失败时发送告警。
排查思路
- 拿到请求 ID:从用户截图、前端错误上报或响应头里获取,在日志系统里搜索它,按时间顺序看完整的处理过程。
- 看错误分布:按
code统计错误数量和趋势。某个错误码突然增多,通常和刚刚的发布或依赖变化有关。 - 区分预期和预期外:预期内的错误(如参数错误)大量增加,可能是前端改出了 bug;预期外的错误(500)增加,说明服务端代码或依赖有问题。
- 依赖故障:查看对下游的调用耗时和失败率,确认是不是自己的重试放大了问题。
检查清单
- [ ] 错误按责任分类,使用对应的 HTTP 状态码
- [ ] 错误体包含
code、message、fields、requestId,前端不依赖文案判断 - [ ] 有全局错误中间件,预期外错误对外不泄露细节
- [ ] 请求 ID 贯穿网关、服务、下游调用和日志,并返回给客户端
- [ ] 使用结构化日志,敏感信息不写入日志
- [ ] 重试有次数上限、指数退避和随机抖动,只重试幂等操作
- [ ] 后台任务的失败有记录、有告警
延伸阅读
- 错误处理与异常:前端如何展示和上报错误
- 日志、指标与可观测性:日志之外的排查手段
- 并发与限流:熔断与限流