Skip to content

错误处理与请求追踪 ​

后端的错误处理,常见的失败形态有这几种:

  • 接口出错时,把数据库报错和代码堆栈原样返回给了前端,暴露了表名和服务器路径;
  • 不管什么原因都返回 500,前端只能提示“系统繁忙”;
  • 用户截图报错,开发在日志里翻了半小时,找不到是哪次请求;
  • 下游服务短暂故障,各个调用方疯狂重试,把它彻底压垮了。

这篇讲清楚错误该怎么分类、怎么返回、怎么记录,以及怎么从一次失败一路追到根因。

读完这篇你会

  • 按责任给错误分类,返回稳定的错误体
  • 用全局错误中间件统一处理错误,不泄露内部细节
  • 用请求 ID 把前端、网关、后端、数据库的日志串起来

1. 原理:错误信息有两类读者 ​

可以想想去医院看病:病人拿到的是诊断结论和下一步建议(“需要复查”“按时吃药”);完整的化验数据、影像、医生的判断过程,都记录在病历里,供医生查阅。

错误信息也一样:

  • 给调用方的(前端、App、第三方):稳定的错误码、能看懂的提示、可以采取的下一步、一个请求 ID;
  • 给开发者的(日志):完整的堆栈、请求参数、用户、上下游调用情况。

两者通过请求 ID 关联:调用方报告请求 ID,开发者用它找到完整的“病历”。

2. 按责任给错误分类 ​

类型例子HTTP 状态码返回给调用方什么
输入问题缺少必填字段、格式错误400 / 422具体哪个字段、怎么改
身份问题未登录、登录过期401需要重新登录
权限问题无权执行这个操作403没有权限
资源状态不存在、已被修改、重复创建404 / 409资源当前的状态,是否需要刷新
限流请求过于频繁429多久后可以重试
依赖故障数据库、第三方超时502 / 503 / 504稍后重试
程序缺陷未处理的异常、代码 bug500只给请求 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,响应头和错误体也返回它
  1. 入口处:如果请求头里已经带了请求 ID 就沿用,否则生成一个;
  2. 记录日志时,每一条都带上这个 ID;
  3. 调用下游服务时,通过请求头把它传下去;
  4. 响应头和错误体里返回这个 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. 坑五:后台任务失败了,没有人知道 ​

现象:每晚的对账任务已经连续失败了一周,直到财务发现数据对不上才被注意到。

原因:后台任务里的错误只写进了日志,没有告警,也没有记录任务状态。

修复:后台任务要记录每次执行的状态(成功、失败、重试中);超过重试次数的任务进入“死信”列表等待人工处理;关键任务失败时发送告警。

排查思路 ​

  1. 拿到请求 ID:从用户截图、前端错误上报或响应头里获取,在日志系统里搜索它,按时间顺序看完整的处理过程。
  2. 看错误分布:按 code 统计错误数量和趋势。某个错误码突然增多,通常和刚刚的发布或依赖变化有关。
  3. 区分预期和预期外:预期内的错误(如参数错误)大量增加,可能是前端改出了 bug;预期外的错误(500)增加,说明服务端代码或依赖有问题。
  4. 依赖故障:查看对下游的调用耗时和失败率,确认是不是自己的重试放大了问题。

检查清单 ​

  • [ ] 错误按责任分类,使用对应的 HTTP 状态码
  • [ ] 错误体包含 code、message、fields、requestId,前端不依赖文案判断
  • [ ] 有全局错误中间件,预期外错误对外不泄露细节
  • [ ] 请求 ID 贯穿网关、服务、下游调用和日志,并返回给客户端
  • [ ] 使用结构化日志,敏感信息不写入日志
  • [ ] 重试有次数上限、指数退避和随机抖动,只重试幂等操作
  • [ ] 后台任务的失败有记录、有告警

延伸阅读 ​

为复用而记录,为理解而整理。