你的错误处理只是安慰剂

打开任何一个前端项目,全局搜索 `catch`,你会看到什么?一大片空荡荡的 catch 块,偶尔跳出几个 `console.error`,再偶尔冒出一行 `message.error('系统异常')`。后端项目也好不到哪去,try-catch 包着业务逻辑,catch 里面的处理方式跟前端如出一辙——记个日志,打个错误码,然后呢?然后什么都没有。

我把这种写法叫"安慰剂错误处理"。它的作用不是解决问题,而是让写代码的人觉得自己处理了问题。就好像感冒的时候吃维C,你做了点什么,但那点什么跟治愈没有关系。

更残酷的事实是:大部分错误处理非但没用,还在制造新的问题。

 

try-catch 不是错误处理,是错误藏匿

 

先说一个很多人不愿意承认的事:try-catch 是所有错误处理手段里最廉价的那一种。它的成本最低,所以它的价值也最低。

我见过太多这种代码:一个函数内部包了三层 try-catch,每一层都把异常吞掉,最外层返回一个 `{ success: false, message: '操作失败' }`。调用方拿到这个返回值,判断 `success` 为 false,然后弹个 toast —— "操作失败"。用户看到这四个字,内心毫无波澜,因为他已经见过一千次了。

问题出在哪?try-catch 把错误信息一层层剥离,等到它真正到达需要做决策的地方,只剩下一个模糊的"失败了"。就像一场传话游戏,开头是"数据库连接超时导致事务回滚",经过三层 catch 的过滤,传到用户耳朵里变成了"系统异常"。每一步都有人在"处理"错误,但每一步都在丢失信息。

更可怕的是 catch 块本身的副作用。当你在 catch 里把异常吞掉了,上层调用者完全不知道出过问题。它以为一切正常,继续往下执行,然后在某个遥远的地方产生一个完全不相关的 bug。这时候你去排查,根本找不到根因,因为错误的现场早就被第一个 catch 销毁了。

所以我说,不加思考的 try-ccatch 不是错误处理,是错误藏匿。你把问题藏起来了,假装它不存在,但它会在你最不设防的时候跳出来咬你一口。

 

那些年我们写过的错误处理模板

 

说到错误处理模板,BFF 层简直重灾区。讲一个我观察到的普遍模式:Node.js 中间层,每个接口函数都长这样——

```javascript
async function getUserInfo(req, res) {
try {
const data = await userService.getInfo(req.userId);
res.json({ code: 0, data });
} catch (e) {
logger.error('getUserInfo failed', e);
res.json({ code: -1, message: '获取用户信息失败' });
}
}
```

一个接口这样写没毛病。十个接口呢?二十个呢?你复制了二十遍 try-catch,catch 里的逻辑几乎一模一样:记日志、返错误码、展示错误信息。这不是处理错误,这是在批量生产安慰剂。

真正值得想的问题是:这段 catch 到底在处理什么错误?是下游超时?是数据格式不对?是权限校验失败?这三种错误的处理方式应该完全不同——超时要重试,格式不对要告警,权限失败要跳登录。但在模板化的 catch 里,它们殊途同归,全部变成了 `code: -1`。

前端也好不到哪去。axios 拦截器里做了一层统一错误处理,组件里又做一层,页面级再包一层。三层错误处理,每层的逻辑都是"弹个提示"。用户在同一个操作上看到三条重复的错误消息,这体验比不处理还糟糕。

统一错误处理没有错,错的是"统一"二字偷换了概念——你统一的是错误展示,不是错误处理。展示可以统一,处理逻辑怎么可能统一?不同类型的错误需要不同的响应策略,这不是模板能解决的事。

 

错误的分类学

 

聊到这,有必要说说错误到底分几类。我自己的分法比较粗暴,就三种:

可恢复错误。网络抖动导致的请求失败,限流触发的暂时不可用,缓存过期后的 miss。这类错误的共同特征是:重试大概率能好。处理策略也很简单——重试,带指数退避的那种。但现实中,我见过太多系统对网络错误直接放弃,用户看到的就是一个冰冷的"加载失败",手动刷新一下就好了。你连重试都不给?这不叫错误处理,这叫甩锅给用户。

可降级错误。推荐接口挂了,页面可以展示默认列表;评论加载失败,文章本身不该受影响;个性化配置拿不到,用默认配置顶上。这类错误的核心思路是:主流程不能断。但很多项目的做法是——任何一个接口失败,整个页面白屏或弹 error boundary。用户只是要看一篇文章,评论区接口超时关他什么事?

不可恢复错误。数据库主库挂了,配置中心连不上,核心鉴权服务宕机。这类错误没辙,该告警告警,该降级降级到了极致就展示一个"系统维护中"。但注意,不可恢复不等于不需要精细化处理。数据库主库挂了和从库延迟 5 秒是两回事,前者你可以考虑读从库顶上,后者你可能只需要延长超时时间。把所有严重错误一股脑归到"系统异常",是你偷懒,不是错误真的不可恢复。

这三种分类最关键的区别在于:可恢复和可降级错误,用户根本不应该感知到。你处理了,就应该是"无感"的。用户感知到了错误,要么是错误确实不可恢复,要么是你的处理方式不对。

现实是,大量本应无感的错误被处理成了需要用户感知的错误,因为这些系统从设计之初就只考虑了 happy path。

 

只设计 happy path 的人,不配谈错误处理

 

这话可能有点重,但事实如此。

绝大多数项目的架构设计,接口文档,技术方案,全都是围绕正常流程写的。用户注册登录浏览购买退出一气呵成,每个环节都画得明明白白。但你问一句"如果推荐服务挂了首页怎么展示",往往得到的是沉默。

我看过不少接口设计文档,返回值只定义了成功的数据结构,错误码就一个 `-1` 搞定。甚至有人理直气壮地说"错误处理后面再说"。后面?后面就是永远不会说的意思。

只设计 happy path 的后果是,错误处理变成了一种"弥补"——在运行时兜住正常流程没考虑到的情况。这时候你已经丧失了主动权,只能在 catch 块里猜:这个错误是什么意思?用户处于什么状态?我该怎么做?在 catch 里做决策,跟在急诊室做体检一样,来得太晚了。

正确的做法是把错误流当作正常流的一部分来设计。设计接口的时候,同时定义错误语义——这个接口可能出什么错,每种错误对应的降级策略是什么,重试策略是什么,超时时间多长。设计页面的时候,同时定义容错边界——哪些模块可以降级,哪些数据可以缺失,哪些失败需要阻断流程。

这不是什么高深的方法论,这就是在写代码之前多想一步。但"多想一步"恰恰是大部分错误处理缺失的根源——大家都在忙着实现 happy path,错误处理永远是待办事项里最低优先级的那一项。

 

全链路错误传播的断裂

 

前面说的都是单点问题,更深层的问题是跨层的错误传播。

一个典型的全栈链路:前端发请求 → BFF 层转发 → 后端服务处理 → 数据库查询。这条链路上,错误信息的传播几乎必然是递减的。数据库层面有完整的错误码和堆栈,经过了后端服务的 catch 变成了业务错误码,再经过 BFF 层的 try-catch 变成了 `message: '系统异常'`,最后到前端手里只剩一个 toast 提示。

每经过一层,错误信息的保真度就打一次折。这不是哪一层的问题,是架构层面的问题——没有统一的错误模型。

什么叫统一的错误模型?不是说所有错误用同一个 code,而是错误在跨层传播时有约定的结构:错误类型(网络/业务/权限/系统)、错误级别(可重试/可降级/致命)、原始信息(debug 用)、用户侧信息(展示用)、恢复策略(重试/降级/阻断)。这些信息不应该是每一层自己凭感觉定义的,而应该在项目初期就约定好,贯穿整条链路。

金融业务对这一点特别敏感。一笔交易失败,错误码不一样含义完全不同——余额不足和系统超时是两个性质的事,前者不该重试,后者必须重试。搞混了,轻则用户投诉,重则资损。在金融系统里,错误处理不是防御性编程,是核心业务逻辑的一部分。

 

那什么才算真正的错误处理?

 

说了这么多什么不是错误处理,该聊聊什么才是了。

第一,错误处理从设计开始,不是从 catch 开始。在设计阶段就把可能的失败场景列出来,定义每种场景的处理策略。这不是过度设计,这是基本工程素养。你写代码的时候要考虑边界条件,为什么设计系统的时候就不考虑边界场景?

第二,区分"处理"和"传递"。有些错误你当前层处理不了,那就原封不动往上抛,别在中间自作主张吞掉或转换。转换错误信息是必要的,但要在信息不丢失的前提下进行。原始错误码、堆栈、上下文参数,这些东西对排查问题至关重要,千万别为了"接口简洁"把它们丢了。

第三,给错误加上语义。一个 error 对象只有 message 是不够的。它应该携带足够的信息让调用方做出决策:能不能重试?需不需要降级?是不是需要通知用户?这些属性不是事后加的,而是定义错误类型的时候就确定的。

第四,可观测。错误发生了,你不知道,那跟没处理有什么区别?告警、指标、日志三位一体——错误率超阈值要告警,错误分布要有 metric 看板,错误细节要有结构化日志。但请注意,这三个是递进关系,不是并列关系。没有结构化日志的告警就是噪音,没有 metric 趋势的日志就是数据垃圾。

第五,也是最反直觉的一点:有时候最好的错误处理就是让程序崩溃。一个无法恢复的状态错误,一个数据不一致的信号,强行"处理"它只会掩盖问题。crash early,让你在开发阶段就暴露问题,比在线上默默产生脏数据要好一万倍。Node.js 里 uncaughtException 的默认行为是进程退出,很多人觉得这太粗暴,又是 process.on 又是 domain 模块地去捕获。其实这个默认行为恰恰是对的——进程状态已经不确定了,继续运行的风险远大于重启。

 

错误处理是架构能力的试金石

 

最后说点更本质的。一个系统的错误处理水平,直接反映了它的架构成熟度。

初级项目,错误处理靠 try-catch,目标是不崩溃。中级项目,错误处理靠统一拦截,目标是体验一致。高级项目,错误处理是架构的一部分,目标是降级无感、恢复自动、问题可溯。

你所做的错误处理处于哪个层级,本质上不是技术问题,是你对系统理解深度的问题。你理解系统到什么程度,错误处理才能做到什么程度。一个对业务场景一知半解的人,写出来的 catch 块只能是一行 `console.error`——因为他不知道还能做什么。

反过来说,当一个系统的错误处理做得足够好,用户几乎感知不到错误的存在,不是因为错误没发生,而是因为每一个错误都被正确地分类、传播、处理了。这才是错误处理的终极目标:不是让用户看到"我们处理了这个错误",而是让用户永远不需要知道出过错误。

回过头看你的项目,搜索一下 `catch (e)` ,数一数有多少个 catch 块里面是空的或只有一句日志。这个数字,就是你的系统里隐藏的定时炸弹的数量。

安慰剂不能治病,只能推迟发现病情的时间。在错误处理这件事上,推迟意味着线上故障发现得更晚,排查花的时间更长,用户受影响的面更大。

别再写安慰剂了。

You voted 4. Total votes: 24

添加新评论