全局异常处理:业务异常与系统异常如何一招收割
你负责的后端,最怕的是什么?不是某个接口写错,而是 某个接口崩了,前端拿到一把堆栈,用户看到一屏看不懂的英文。这篇聊的是全局异常处理 - 怎么把项目里所有会抛出的异常,分门别类地截住、转成用户看得懂的统一错误响应,并且安全边界守着别把内部细节给前端。重点是 分清楚两种异常 生产环境怎么藏细节 日志怎么留全 。
一、先分清楚:你的异常其实有两种
写多之后你会发现,大多数后端异常能粗暴分成两类,它们的 处理态度 完全相反:
| 类型 | 是什么 | 例子 | 该不该给用户看到 |
|---|---|---|---|
| 业务异常 | 意料之中的、可以预见的 不符合条件 | 参数缺失、记录不存在、没有权限、余额不足 | 应该!要给出用户能看懂的中文提示 |
| 系统异常 | 意料之外的运行错误、bug | 数据库连接断了、空指针、类型错误、第三方超时 | 不应该给明文堆栈,但要在日志里留全 |
它们的分界线,就是 是否预先可预期 。 业务异常是 合理的原因失败 ,系统异常是 我们代码/依赖出问题了 。能不能把这两个分开,决定了一个错误处理机制的 下限 。
二、两种异常要怎么表达
比较标准的做法是:给业务异常做一个专门的类,带上 给用户看的信息 / 状态码 / 期望的 HTTP 状态 ,系统异常则统统是普通 Error。
// 一个简单的业务异常类
class BizError extends Error {
constructor(message = '操作失败', code = 400, httpCode = 200) {
super(message);
this.name = 'BizError';
this.code = code; // 业务码(放 body 里的)
this.httpCode = httpCode; // 期望的 HTTP 状态,默认 200(码都在 body)
}
}关键在设计上让 业务异常 好认 - 有没有 user-friendly 的中文 message、携带的 code 。而系统异常不自定义,就是原生 Error (或框架抛的)。
请求进来,业务里只要 throw new CustomError('该记录不存在') 就够了,不用再关心响应长啥样。
三、全局异常中间件:唯一的 出口
不管业务还是系统异常,最终都要在同一个地方被截住,统一转成统一 JSON。这个 出口 就是全局异常中间件。它的职责是:
- 包住整条链路;
- 抓到异常就判断类型;
- 业务异常 → 给统一错误 JSON,带上用户提示;
- 系统异常 → 记日志(完整堆栈),给前端一个不暴露内部的 通用错误 响应。
伪代码:
module.exports = () => async (ctx, next) => {
try {
await next();
} catch (err) {
if (err instanceof BusinessError) {
// - 业务异常 -
ctx.status = err.httpCode || 200;
ctx.body = {
code: err.code || 400,
message: err.message, // 这是想给用户看的
success: false,
data: '',
};
return;
}
// - 系统异常 -
ctx.logger.error('[uncaught]', err); // 完整堆栈进日志
if (ctx.app.config.env === 'prod') {
// 生产:藏细节,只给笼统提示
ctx.status = 500;
ctx.body = { code: 500, message: '服务器开小差了,请稍后再试', success: false, data: '' };
} else {
// 开发:反而给一点线索(但不建议,仍应看日志)
ctx.status = 500;
ctx.body = { code: 500, message: err.message || '服务器错误', success: false, data: '' };
}
}
};这段代码里藏着两个安全要点,值得深刻读:
instanceof BusinessError判断 :这是把业务异常和系统异常分开的唯一依据,所以前面的异常类是不是 能被认出来 就无比重要 - 别用普通的new Error到处 throw。env === 'prod'时隐藏内部错误信息 :这是安全底线。生产和开发返回给前端的信息完全不一样。真正给内部开发者看的是日志里的完整堆栈,而不是面向用户的 HTTP 响应。
四、生产环境为什么要 藏着掖着
很多人不理解 为什么不直接把错误堆栈给前端 ?
- 堆栈里会泄露 内部路径、数据库库名、代码结构、依赖版本 ,全是攻击者爱收集的素材。
- 用户根本看不懂一堆英文堆栈,给他看只会徒增客服量、竭尽体验。
- 内部排查根本不靠 让前端打印堆栈 ,靠的是 服务端日志 - 所以该做的是写日志,不是把堆栈推给前端。
一句话: 面向用户的响应要 友好且少泄露 ,面向开发者的详细 Stack 请写进日志。 这两个出口一旦对错(把堆栈甩给前端、日志却几乎不落),就是安全事故的雏形。
五、中间件顺序:要包在最外层
全局异常中间件的原理是 包住后续」,所以它在中间件链里的位置很重要 - 尽量靠前注册,让它把后面的链路都包进去 。
想象一下错误发生在鉴权中间件里(token 过期),如果异常中间件挂得比它晚,异常就只被 下一层 的 try/catch 处理,而没进到装出统一 JSON 的那层。
所以,一个典型的中间件顺序:
请求进来
└─ 全局异常处理 (第 1 个/最外层,负责包住一切)
└─ 鉴权中间件 (token 校验)
└─ 日志/其他
└─ Controller/Service/Model顺序错,统一格式就 漏 了:某些中间件自己手写 ctx.status=401; ctx.body=... ,格式跟全局不统一。要想 真统一 ,就得让异常中间件当 outer/inner,把一层层全包住。
六、日志:异常处理的另一半
异常处理不只是 响应给前端的那部分 ,还欠 写日志的这一半 。系统异常至少记:
- 完整
err.stack(必须); - 出事的接口路径、方法、query/body(脱敏后);
- 当前操作人(若有);
- 发生时间。
在日志框架里,通常 logger.error(context, err) 一次搞定。注意 别把敏感字段(密码、token)打全 ,可以先脱敏。
七、 吞异常 是最需要警惕的反面模式
新手最常见的坑不是 没处理 ,而是 过度吞异常 :
try {
await insertUser(data);
} catch (e) {
// 什么都没做 😱
}这种 抓了又不处理,也不日志 的模式,会把所有隐藏在深处的系统问题悄悄杀于无形 - 上下两行代码看着没问题,可数据没写进去、用户没注册上,你却浑然不知。 异常可以 不宕机 ,但不能 不留痕 。 就算你决定 这里失败了也不崩 ,也要至少留下一行日志,让问题可被找到。
八、常见问题:业务异常怎么区分参数校验失败与权限不足
常常会遇到 要同时支持参数错误、无权限、记录不存在 的场景。用一个码就能区分:
throw new BusinessError('缺少必要参数', 400); // 参数
throw new BusinessError('无访问权限', 403); // 权限
throw new BusinessError('记录不存在', 404); // 不存在code 语义约定下来,前端就能针对不同码做不同事(401 就跳登录、404 就给提示……)。这比 全部给个 400 要友好得多。
九、检查清单
全局异常处理不复杂,但对齐这几点基本安全落地:
- [ ] 做了一个能被
instanceof认出的业务异常类; - [ ] 全局异常中间件是 最外层 ,包住所有链路;
- [ ] 业务异常 → 转统一错误 JSON,带为用户准备的提示;
- [ ] 系统异常 → 完整堆栈进日志;生产响应不泄露内部细节;
- [ ] 任何 try/catch 都不允许 空吞 - 至少留一行日志;
- [ ] 403/404/400 等业务码与前端约定好,前端按码差异化处理。
到这里,你把 统一响应格式 和 全局异常 两手都握住了。下一篇我们离开异常,去看数据库的迁移 - 改结构这件让很多后端 心跳加速 的事,怎么通过迁移做得安全、可回滚、不心虚。






