错误处理的谎言:你以为在兜底,其实在埋雷

打开任何一个前端项目的代码,搜索 `catch`,你大概率会看到这样的画面:

```javascript
try {
await fetchData();
} catch (e) {
console.error(e);
message.error('操作失败,请稍后重试');
}
```

后端也好不到哪去:

```javascript
try {
await processOrder(orderId);
} catch (err) {
logger.error('订单处理失败', { orderId, error: err.message });
throw new BusinessException('系统异常,请重试');
}
```

这两段代码有一个共同的特质——它们让你觉得错误被"处理"了。日志打了,提示也弹了,异常也抛了,一切看起来都很体面。

但你仔细想想:用户看到"操作失败,请稍后重试"之后能做什么?重试?然后呢——再失败一次?运维看到一条"订单处理失败"的日志,能定位到什么?是库存扣减超时,还是支付回调丢失,还是风控拦截?

绝大部分的"错误处理"代码,做的不是处理,是藏尸。

 

三个弥天大谎

 

我观察到大部分项目的错误处理,建立在三个谎言之上。

谎言一:"我处理了这个错误"

不,你没有。你只是让这个错误不再抛出了。try-catch 不是错误处理,try-catch 是错误拦截。拦截和处理之间差了一整个决策链:这个错误意味着什么?系统现在处于什么状态?哪些操作需要回滚?用户下一步能做什么?

一个 catch 块里面只有 console.error 和 toast 提示,那不叫处理,那叫打扫现场。

谎言二:"这个错误不会影响系统状态"

这是最危险的假设。在很多业务场景里,一个操作抛异常时,它的副作用已经发生了一半。你调了一个支付接口,扣款的请求发出去了,但确认回调超时了——你 catch 住这个超时异常,给用户提示"支付失败",实际上钱已经扣了。

在金融业务里,这种半成功状态是最常见、也最致命的问题。我曾经见过一个场景:用户购买理财产品的流程中,后端扣款成功了,但前端拿到的响应超时,catch 块里显示了"购买失败"的错误提示。用户又点了一次,又扣了一笔。等到对账的时候才发现,一笔购买变成了两笔扣款。

错误处理最大的盲区,不是没有 catch,是 catch 的时候已经来不及了。

谎言三:"上游会看到有意义的错误信息"

在全链路系统里,错误信息经过的每一层都会做一次"翻译",而这种翻译几乎总是有损的。

后端服务抛出 `InventoryLockTimeoutException`,BFF 层 catch 住,转成 `BusinessException("系统繁忙")`,前端拿到后变成 `message.error('操作失败')`。传了三层,信息衰减了三次。到用户那里,所有错误长得一模一样。

更要命的是反方向:前端看到一个"操作失败",报给后端排查,后端说"我这没有错误日志啊"——因为错误在 BFF 层被吞掉了,根本没传到后端服务。链路越长,错误越难追踪。

 

BFF:错误的消失层

 

全栈架构里,BFF 层被赋予了"胶水层"的美名。它聚合接口,转换数据,适配前端——当然,也"统一"错误处理。

这恰恰是问题所在。

BFF 的错误处理逻辑通常长这样:catch 住下游所有异常,包装成统一的错误码和错误消息,再返回给前端。听起来很合理,对吧?统一出口,统一格式,前端也好处理。

但这个"统一"本身就是一个幻觉。下游的错误是多样的:有网络超时,有数据不一致,有限流降级,有参数校验失败,有权限不足。把这些本质不同的错误塞进同一个 `code: 500` 的壳子里,前端拿到后除了弹一个通用 toast,什么也做不了。

更糟的情况是 BFF 层自作聪明的"容错"。下游接口超时了?返回个空数据,前端不就不报错了嘛。这种做法在低风险场景也许无伤大雅,但在金融场景里简直是灾难——用户的账户余额查不到,你给他展示 0?还是展示上一次的缓存值?无论哪种,都可能引发客诉甚至资损。

BFF 层最大的价值是做数据聚合和格式适配,不是做错误翻译。错误翻译的每一次"简化",都在消耗排查问题的信息量。与其统一错误格式,不如把错误的上下文完整地传递出去,让真正能处理错误的那一层来决策。

 

错误传播的电话游戏

 

微服务架构下,错误的传播像极了小时候玩的"传话游戏"——信息从一个人传到下一个人,每传一次就失真一次。

服务 A 调用服务 B 超时了,A 给 C 返回一个 `ServiceUnavailableException`,C 把它包装成 `BusinessException` 抛给 BFF,BFF 再给前端一个通用的 500 错误。四层传递之后,最初那个超时的原因——也许是 B 的数据库连接池耗尽了,也许是 B 在做 GC——已经完全丢失了。

每个层都在做它认为"正确"的事:服务 A 不应该暴露内部实现细节,C 不应该把底层异常透传到上层,BFF 应该给前端友好的提示。但合在一起的结果是:出了一个线上问题,从日志里只能看到成千上万条"系统异常",真正的根因需要跨四个服务翻十几分钟的日志才能找到。

这不是某个人写错了代码,这是架构的系统性问题。每个层都在"处理"错误,合在一起反而谁也没处理。

那怎么办?有几种实践我觉得是有效的:

错误分类传播,而不是错误格式统一。 网络错误(超时、连接拒绝)、业务错误(余额不足、库存不够)、系统错误(OOM、磁盘满)是三种本质不同的东西,不应该被塞进同一个错误码体系里。给前端传递的应该是错误类别,而不是错误码。前端需要知道"这是网络问题,可以重试"还是"这是业务限制,重试也没用",而不是"code 500"。

保留错误链,不要截断。 Java 里的异常链机制(cause chain)是一个好设计,Node.js 里也应该把原始错误作为 cause 传入,而不是只取 message。`new BusinessError('订单创建失败', { cause: originalError })` 比 `new BusinessError('系统异常')` 有用一万倍。

在架构边界处理错误,而不是在每一行代码里。 这点我后面会展开说。

 

金融场景:处理和恢复是两回事

 

在一般业务里,错误处理的目标是"别崩,给个提示"。在金融场景里,这个标准远远不够。

金融业务对错误的容忍度不是"用户重试一下就好",而是"任何时刻,系统的资金状态必须可验证、可对账、可追溯"。一个错误发生了,重要的不是展示什么提示,而是系统现在到底处于什么状态。

我见过太多 fintech 项目,错误处理看起来很完善——异常捕获全覆盖,错误提示也很友好,监控告警也配齐了。但一做资金对账,发现一堆"悬浮"状态:扣了款但订单没创建成功了、理财产品买了但持仓没更新、退款发了但账户余额没回来。这些不是 bug,是"处理"了错误但没有"恢复"状态的后果。

错误处理和错误恢复是两件完全不同的事:

- 错误处理是面向用户的——出了问题,告诉用户发生了什么,让界面保持可用。
- 错误恢复是面向系统的——出了问题,保证系统的数据一致性,让后台状态回到可验证的起点。

大部分代码只做了前者。catch 块里弹个 toast 是错误处理,但扣了的钱怎么办?创建了一半的订单怎么办?触发了但没完成的回调怎么办?这些才是错误恢复要回答的问题。

在金融系统里,错误恢复靠的不是 try-catch,靠的是幂等设计、对账机制、补偿事务和状态机。你可以 catch 住所有异常,但没有这四样东西,你的系统只是在"假装处理"错误。

 

ErrorBoundary 的启示

 

React 的 ErrorBoundary 是一个被严重低估的概念模型。它做的事情很简单:在组件树的某个层级截获渲染错误,阻止它向上冒泡导致整个应用崩溃,然后展示一个降级 UI。

但 ErrorBoundary 真正有价值的地方不在它的实现,在它的哲学——错误应该在有意义的边界被捕获,而不是在每一行可能出错的代码处。

把这个思想扩展到全栈架构:

- 网络层的错误(超时、DNS 解析失败、连接拒绝)应该在网络层或请求层处理——重试、熔断、降级。
- 业务层的错误(余额不足、商品下架、权限不足)应该在业务层处理——给用户明确的提示,引导下一步操作。
- 系统层的错误(OOM、磁盘满、进程崩溃)应该在系统层处理——告警、重启、故障转移。
- 数据一致性错误(扣款成功但订单失败)应该在数据层处理——补偿、对账、人工介入。

每一层只处理自己能理解、能决策的错误,其他的要么传播到能处理的层,要么在边界做有意义的降级。

这个思路和常见的 try-catch-everywhere 模式完全相反。大多数开发者的直觉是在每一层 catch 住所有异常,"防止错误泄露"。但错误处理的价值不在于"能不能 catch 住",在于"catch 住之后能不能做正确的决策"。如果你这一层做不了决策,catch 住也没用——不如让它传播到能做决策的那一层去。

这不是说不要写 try-catch,而是说 try-catch 的位置应该由架构边界决定,不是由"怕出错"的心理决定。

 

重新理解错误类型

 

说到底,大部分错误处理失败的根源是:没有区分错误类型。

我习惯把系统里的错误分成三类,每类的处理策略完全不同:

可重试错误(Transient Error):网络超时、限流降级、临时不可用。这类错误的最优策略不是报错,是自动重试——带指数退避的那种。用户甚至不需要知道发生了错误。但很多项目的做法是第一次失败就直接弹 toast,让用户来重试,把本该系统解决的事推给了人。

业务约束错误(Business Constraint):余额不足、权限不够、库存已售罄。这类错误不需要重试,需要的是精准的业务反馈——告诉用户具体哪个条件不满足,而不是"操作失败"。但大部分项目的 catch 块把这类错误和网络超时混在一起,统一返回 500。

系统故障错误(System Failure):数据库连接断开、OOM、磁盘满。这类错误在应用层几乎无法处理,最优策略是快速失败、告警、让运维介入。但很多项目在这类错误上也弹个"请稍后重试",用户重试一百次结果都一样。

把这三类错误分清楚,你就知道哪该重试、哪该精准提示、哪该放弃。分不清楚,所有错误长得都一样,所有处理策略也都一样——弹个 toast 然后祈祷。

 

最后说几句

 

错误处理是那种所有人都觉得自己会写,但几乎没人写对的代码。原因不是开发者水平不行,是这门语言和这些框架给了我们一个错觉——try-catch 语法太简单了,简单到让人以为错误处理本身也很简单。

其实错误处理是最吃架构能力的部分。它要求你理解整个请求链路的状态变化,要求你分清哪些错误是暂时的、哪些是永久的,要求你在每一个架构边界做出正确的决策:是重试、是降级、是传播、还是放弃。

下次写 catch 块的时候,不妨问自己三个问题:这个错误发生之后,系统处于什么状态?我这一层能做什么有意义的恢复操作?如果我不能恢复,是不是应该让能恢复的那一层知道?

如果你对这三个问题都没有好答案,那你的 catch 块大概率不是在处理错误,是在埋雷。只是这颗雷什么时候爆、在哪爆,你不知道而已。

Total votes: 1

添加新评论