错误处理的两种失败方式
大多数项目在错误处理上会走向两个极端:
- 全部吞掉:到处
try/catch,catch 里打一行日志就当没事,结果数据错了半年才被发现 - 全部抛出:底层任何异常都往上冒,用户看到一个 500 页面,也不知道自己能做什么
两种都错在同一个地方:没有区分错误的类型。
先分两类:可预期与不可预期
| 类型 | 例子 | 该怎么处理 |
|---|---|---|
| 可预期 | 参数不合法、余额不足、文件不存在 | 转成明确的业务提示,返回给调用方 |
| 不可预期 | 数据库连接断了、第三方接口超时、空指针 | 记录上下文并上报,往上冒 |
可预期错误是业务流程的一部分,比如「用户名已被占用」完全正常,不该当成异常事故。
不可预期错误是环境或代码缺陷,这类错误不应该被静默处理,因为它需要人来修。
什么时候抛
满足以下任一条,就该抛:
- 当前层无法做出合理决策(比如不知道该退回什么默认值)
- 继续执行会导致数据不一致
- 这是编程错误,而不是运行环境问题
ts
function withdraw(account: Account, amount: number) {
if (amount <= 0) {
// 调用方传错了,属于编程错误,直接抛
throw new Error("提现金额必须大于 0");
}
if (account.balance < amount) {
// 业务上完全可能发生,抛一个可识别的业务异常
throw new InsufficientBalanceError(account.id, amount);
}
account.balance -= amount;
}
关键点是用不同的错误类型区分这两类。上层就能据此决定是提示用户还是报警。
什么时候返回错误值
当「找不到」是一种正常结果时,不要抛异常,返回空值:
ts
// 查询没有结果不是异常,是正常业务结果
async function findUserByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({ where: { email } });
}
判断标准是:调用方有没有可能不把它当错误。如果调用方经常需要「查一下有没有」,那它就不该抛。
每一层该承担什么
分层的核心原则:底层提供信息,中层补充上下文,顶层决定呈现方式。
text
数据访问层 → 只报告「操作失败 + 原始错误」
业务逻辑层 → 补充「在做什么业务时失败 + 关键参数」
接口层 → 决定返回什么状态码、什么文案,是否记录告警
一个常见的错误做法是底层就已经把错误转成了用户文案。这样中间层想加日志都拿不到原始信息,排查时只能靠猜。
错误信息写给谁看
错误信息有两种读者,必须分开:
| 读者 | 需要什么 | 例子 |
|---|---|---|
| 用户 | 能做什么 | 手机号格式不正确,请检查后重试 |
| 开发者 | 定位所需的一切 | createOrder 失败,userId=42,商品 9001 库存不足 |
把开发者信息直接展示给用户,既没用又泄露内部结构;把用户文案写进日志,排查时又什么都没留下。所以要在接口层做一次转换。
不要吞掉错误
这是最危险的反模式:
js
try {
await syncData();
} catch (e) {
// 什么都不做,或者只 console.log
}
静默失败会让系统处于「看起来正常但数据不对」的状态,这种状态比直接崩溃更难排查。
如果确实要忽略某个错误,必须满足两个条件:明确知道它无害,并且在代码里写清楚为什么可以忽略。
js
try {
await cache.del(key);
} catch {
// 缓存删除失败不影响正确性,最坏情况是等到 TTL 自然过期
}
兜底放在最外层
整个应用应该有一个统一的错误出口,通常由框架的异常过滤器承担。它的职责是:
- 记录完整堆栈与请求上下文
- 按错误类型映射状态码
- 对外只返回安全的信息
有了这一层,业务代码里就不需要到处写 try/catch 了,只处理真正需要决策的地方。
小结
错误处理的关键不是「多写 try」,而是分类:可预期的转成业务结果,不可预期的记录并上报。
记住三个原则:底层报事实、中层加上下文、顶层管呈现。做到这三点,排查问题时你会感谢当初的自己。
#编程基础#错误处理