错误处理这件事,几乎所有项目都在做,但绝大多数团队从来没有坐下来认真讨论过一个根本问题:当一段代码失败时,我们到底希望它用什么方式把"失败"这件事告诉上一级?我见过太多反面案例:有的模块把异常吞得干干净净,线上查问题只能靠猜;有的接口设计成"成功时返回对象、失败时返回 -1",调用方写出来的代码比业务逻辑还绕;还有的系统里异常满天飞,光看日志就铺了整整一屏,真正有用的 root cause 却被包装在十几层异常里找不到。今年我在整理一套内部培训笔记时,特意把"P07 错误模型:数据、异常与正常返回"单独拆出来讲,因为这些问题背后其实是一个统一的话题:错误也是一种数据流,它同样需要建模、需要设计、需要被认真对待。
这篇文章写给三类人:刚入门不久、被各种异常报错折磨的初学开发者,想知道"数组越界异常""非法参数异常""数据库连接异常"背后有什么共同规律;正在做后端服务,纠结应该全面拥抱"返回错误值"还是继续用异常机制的老手;以及那些正在设计公共库、SDK 或内部框架的接口设计者——错误模型其实就是你的接口契约里最容易被忽略的那一半。
1. 错误模型到底在回答什么问题:一段代码失败后如何被"看见"
1.1 失败一直被三种方式承载,但很少有人把它们放到一起看
翻开任何一门语言的生态,失败无非通过三种方式从被调用方流向调用方:
- 错误码:调用函数返回一个约定的整数或枚举,比如
return -1、return 404、return ERROR_TIMEOUT。调用方拿到这个值后自己判断下一步做什么。 - 特殊返回值:不一定是一个专门的错误码,也可能是
null、空集合、空字符串、NaN这类"一看就不正常"的值。很多动态语言里的函数在失败时习惯返回null。 - 异常:函数在失败点主动抛出异常对象,由运行时的异常处理机制沿调用栈向上传播,直到被某个
try/catch捕获。
这三条路同时存在于几乎所有大型系统里。Java 里你既可以用Optional表示"可能没有结果",也可以throw new IllegalArgumentException;Go 里你写if err != nil,本质上就是在走"返回值承载错误"的路径;Python 则更极端,文件读取、网络请求、JSON 解析,几乎所有 I/O 操作失败时都会直接抛异常。
但大多数开发者只把这三者当成了"语言特性"或"习惯写法",没有意识到它们其实构成了一个系统的错误模型——这个模型决定了一行代码失败之后,失败信息会在代码库里以什么路径移动、经过哪些模块的处理、最后沉淀成什么样的日志和监控指标。错误模型一旦混乱,最典型的症状就是:你在某个模块里 catch 住了整个Exception,打了行 log 就继续往下走;上游完全不知道刚才发生了什么,等用户投诉之后才发现数据已经错了几个小时。
1.2 错误不是 bug 的附带品,而是运行时数据流的一部分
把"错误"当成数据流来理解,是我觉得这一节里最重要的观念转换。正常情况下,函数之间的调用会传递业务数据:订单对象、用户信息、计算结果。但当函数失败时,它其实也需要产生一份数据——只不过这份数据描述的是"为什么失败、在哪个环节失败、失败点有哪些上下文"。这份数据同样需要被接收、被解读、被传递,甚至在很多场景下需要被结构化地聚合起来做统计。
想明白这一点,很多看似"程序员的个人偏好"的争论就不那么玄了。你选择返回错误码还是抛异常,本质上是在回答三个问题:
- 调用方是否必须处理这次失败?(强迫处理 vs 允许忽略)
- 失败现场携带多少上下文信息?(一个整数 vs 一个对象 vs 一段堆栈)
- 失败是否允许跨越多个抽象层级自动传播?(逐层手动传递 vs 自动沿栈上升)
后面两章我会分别沿着"正常返回"和"异常"这两条路线细讲,把各自原理、典型形态、使用边界说透。
2. 正常返回:让错误以数据的形式参与业务逻辑
2.1 从"错误码"到"结果对象":返回值承载错误的设计谱系
正常返回这条路线,从简单到复杂可以排成一条谱系:
- 错误码整数:C 语言时代最常见的套路。函数返回 0 表示成功,非 0 表示某种错误类型。优点是极轻量、零分配;缺点是只能承载一个编号,几乎带不出任何上下文。同一个错误码在不同函数里含义可能完全不同,遇到新错误往往只能含糊地回一个 -1。
- 错误码 + 出参:用返回值表示错误,用指针/引用参数把实际数据带回调用方。很多 C/C++ 系统调用都这么设计。它解决了"既要返回错误码又要返回数据"的问题,但用起来别扭,稍不留神就漏掉错误检查。
- Optional/Maybe 类型:Java 的
Optional、C++17 的std::optional、Rust 的Option。这类类型把"有值"和"没有值"建模成两种状态,强制调用方显式解包。它适合表达"结果可能缺失",但不适合表达"缺失的具体原因"。 - Result/错误对象:Rust 的
Result<T, E>、Go 的(value, error)、Kotlin 的Result。错误被建模成一个独立对象,可以携带错误消息、内部错误链、错误码、堆栈上下文。这是目前"正常返回"路线里信息密度最高、最工程化的形态。
把这几种形式放到一张表里看,各自的特点就非常清楚:
| 形态 | 携带的上下文 | 是否强制处理 | 跨层传递成本 | 适用场景 |
|---|---|---|---|---|
| 整数错误码 | 单个编号 | 否(容易漏) | 低 | 底层系统调用、协议状态机 |
| 错误码+出参 | 编号 + 数据 | 是(接口约束) | 中 | C/C++ 库接口 |
| Optional | 有无两种状态 | 是(解包) | 低 | 查询可能无结果 |
| Result/错误对象 | 结构化错误信息 | 是(通常显式) | 较高 | 业务层、服务层 |
2.2 以"数据解析"为例:返回值方案的取舍
假设我们要写一个读取配置文件的函数。输入是文件路径,输出是解析后的配置对象。使用正常返回的典型写法:
# Python 伪代码:返回 (错误对象, 数据) def load_config(path: str): try: with open(path, 'r') as f: raw = f.read() except OSError as e: return ParseError(f"cannot open file: {e}"), None # 数据格式校验 if not raw.startswith("{"): return ParseError("config is not a JSON object"), None return None, json.loads(raw)调用方:
err, cfg = load_config("/etc/myapp/config.json") if err is not None: print(f"[config] load failed: {err}") # 降级到默认配置,或者退出 cfg = default_config()这段代码初看很啰嗦——每一层调用都要判断err is not None。但它有几个明显优势:第一,失败路径和正常路径的数据结构完全一致,都是普通返回值,可以在函数间随意传递;第二,调用方可以根据自己的策略灵活处理——继续传播、降级、忽略、记录日志,全部都是显式的;第三,错误对象天然可以序列化,跨线程、跨进程传递都没有问题。
我在数据相关的项目中尤其发现这一点好用。比如做数据处理管道时,经常要对一批 DataFrame 做清洗,其中某几行数据格式非法。如果用异常表达,一遇到坏数据整批任务就中断了;如果用返回错误对象的方式,每个清洗函数都可以返回(清洗结果, 错误列表),最后统一汇总这一批数据里有多少条脏数据、各自错在哪。这样错误就真正变成了数据的一部分,可以统计、可视化、甚至作为告警指标。日常工作里很多人抱怨"DataFrame 异常数据处理"麻烦,本质上是没有给脏数据一个正经的"错误模型"。
2.3 正常返回的边界在哪里:错误传播链路变长后的成本
但"正常返回"不是万能药。随着调用链变长,它的代价会变得非常扎眼。比如服务 A 调用服务 B,B 返回一个错误对象,A 要把这个错误再传给上游 C——如果你没有设计好"错误包装"的方法,每一层都要写一段重复代码把底层错误转译成上层能理解的错误对象,几十层调用下来,代码里全是if err != nil的判断,阅读体验很痛苦。
更麻烦的是,在 Java 这类"半数操作都可能失败"的语言里,如果你把每个潜在失败点都改成返回Optional或Result,会导致函数签名被污染:原本是Config load(Path p)变成Result<Config> load(Path p),所有调用方都要解包。而且像"JVM 内存耗尽"这种事你是没法用返回值表达的——虚拟机已经无能为力了,你根本执行不到 return 语句。这就是异常机制真正发挥作用的场景。
3. 异常机制:一场被默认开启的"栈上传播"
3.1 异常的底层工作方式,决定了它适合什么样的问题
异常和返回值最本质的差别是什么?异常不是"被返回"的,而是"被抛出"之后由运行时系统沿调用栈自动向上传播的。这个特性带来两个结果。
一是调用方可以不处理异常,异常会继续往上飘,直到某个try/catch接住它。这种"自动传播"让中间层代码减少了很多错误判断代码——你可以专注写业务逻辑,不用每个函数都处理底层失败。Java 的受检异常(checked exception)之所以让人又爱又恨,就是因为它在编译期强制你面对"中间层必须声明 throws 或 catch"的现实。
二是异常是中断性的。抛出异常时,当前函数剩下的代码不再执行,栈上的局部变量逐层释放,只有 catch 到异常的地方才能接住。这意味着异常特别适合表达"这件事不该继续做下去了"的情况——比如非法参数、数组越界、连接断开。这些错误一旦发生,代码走完也是错的结果,不如停下来让上层决定怎么办。
受检异常与运行时异常的设计意图也源于此。Java 把"可预见的、调用方应该处理的失败"设计成受检异常,编译器强制你 catch 或声明 throws;把"程序 bug、不应该正常出现"的情况设计成运行时异常,比如NullPointerException。我刚开始学 Java 时觉得受检异常很烦,写了十年代码之后反而认为,这个区分恰恰是错误模型里最有价值的一笔:它提前划清了"调用方有责任处理"和"调用方通常无能为力"的边界。
3.2 那些被搜烂了的异常:数组越界、非法参数、数据库连接问题在说什么
搜索引擎里高频出现的"数组越界异常""非法参数异常""数据库连接异常",本质上都是同一类问题的标准解法:错误发生时,异常对象带着类名、消息和堆栈,把定位信息一并抛出。它们各自的定位其实很不一样:
- 数组/集合越界(
IndexOutOfBoundsException、ArrayIndexOutOfBoundsException):索引超出合法区间,通常是逻辑 bug。正确姿势是让单元测试阶段尽早暴露,而不是在业务代码里 catch 住包容它——因为这通常意味着程序状态已经错乱了。 - 非法参数异常(
IllegalArgumentException):方法收到的参数不合法,比如需要正数却传了负数。这类异常应当在方法入口处显式检查并抛出,不要等到几百行之后才隐式失败。这是一个典型的"防御性编程"场景,应该把失败点提前到边界。 - 数据库异常(
SQLException、连接超时、建表异常):这里比较微妙。数据库连接失败往往不是代码 bug,而是资源与环境问题。很多团队犯的错误是把底层异常包装成RuntimeException后不保留原始 cause,导致排查时根本看不出是连接池问题还是代理层问题。我见过"建表异常"排了一整天,最后发现是数据库账号没有 DDL 权限——但日志里只有一句create table failed。
这几类异常有个共同特点:异常对象本身携带了足够多的上下文。所以工程上对它们的原则不是"全都不处理",而是分门别类地决定哪些是严重故障(要告警、要重试、要降级)、哪些是程序 bug(要尽早暴露、让开发修复)、哪些是防御性检查(要在入口拦截)。这一层想清楚了,你就不会被各种异常报错带着走。
3.3 异常使用中最常见的三个坏习惯
我在大量项目里看到异常机制被用废,核心是三个习惯:
吞异常。
catch (Exception e) { }里什么都不做。比直接吞掉更隐蔽的是"打了日志就算处理"——如果日志级别是 DEBUG,或者它在无人关注的模块里,这种处理跟没处理没有任何区别。吞异常意味着错误数据流在这里断掉了,上层完全无法感知。用异常做控制流。比如用异常来退出循环、用抛异常做参数校验的"快捷方式"。异常栈的构建非常昂贵(需要采集线程上下文、类名、方法调用链),在频繁路径上用异常做分支,性能可以差一个数量级。我在一些从 Java 转过来的人写的 Python 代码里见过这个反模式,尤其严重。
破坏异常链。在 catch 里要上抛新异常时,不把原始异常作为 cause 传进去。这会导致最经典的排查困境:你只知道"查询用户异常",但不知道是数据库连接失败还是 SQL 写错。凡是线上查错查了半天最后发现是"包装时丢了 cause"的案例,基本都是这个原因。解决办法就一句话:
throw new BizException("query user failed", e),把e传给 cause。
4. 把数据与异常组合起来:分层错误模型的落地实战
4.1 领域层用返回值,边界层用异常:一条经常被验证的边界
聊到这里,其实答案已经浮出水面了:正常的业务预期之外的分支,先用返回值表达;系统级故障、环境异常、程序 bug,再用异常表达。放到一套分层系统里,这句话可以落成一个非常具体的约定:
- 领域层/服务层:业务规则校验失败、资源未找到、状态机不允许转移……这些都可以用返回的错误对象表达。调用方需要根据错误内容做不同的业务决策:重试、回滚、还是提示用户。错误必须显式可见。
- 端口层/适配器层:数据库连不上、消息队列拒绝连接、外部 API 超时、磁盘写满——这些是资源/环境类故障,调用链任何一层都无法安全地"继续走",适合抛异常,并且必须保留原始 cause。
- 网关/控制器层:这里往往是唯一允许把异常翻译成外部响应(HTTP 状态码、错误响应体)的地方。它只做翻译,不做复杂的业务处理。
用一句话记忆就是:业务分支走返回值,系统故障抛异常。这正是标题里"数据、异常与正常返回"三个词的关系——"正常返回"是数据流的一部分,"异常"是控制流的中断,"数据"则贯穿两者。数据绑定、数据校验、状态判断这些场景天然适合返回值;而资源不可用、非法状态、编程错误则天然适合异常。
4.2 错误模型与可观测性:日志、监控与错误码怎么联动
错误模型设计的成败,最后一公里看的是运维和监控。很多团队的错误模型在设计时只考虑了代码层面,上线之后才发现日志没法聚合、告警没法定位,一样白搭。我给出三个常用原则:
- 每个错误对象/异常必须能映射到一个稳定的错误码。错误码不是给用户看的文案,而是给监控系统用的稳定标识。比如
E_USER_NOT_FOUND、E_CONFIG_INVALID。线上告警、错误率统计都用这个码聚合,不要依赖异常类名去 grep。 - 日志里必须同时有错误码、错误消息、调用上下文、原始 cause。很多团队日志里只有一行
operation failed,这对定位毫无帮助。至少要带上操作名、关键参数、错误码。 - 对关键路径上的错误做结构化记录。比如输出 JSON 结构化日志:
{"error_code": "E_DB_TIMEOUT", "layer": "adapter", "operation": "save_order"}。这样日志平台就可以按字段聚合,做错误率趋势,甚至挂到可视化大屏上。我自己做的内部工具里,错误码聚合图永远是排第一的,比平均响应时间有用得多。
4.3 几种主流语言环境下的落地建议
不同语言对错误模型的支持不同,落地时要因地制宜:
- Java/Spring:业务层方法可以返回领域结果对象(比如
Result<T>或自定义ErrorCode枚举),Controller 层用@ControllerAdvice统一处理异常翻译。受检异常用在"调用方确实能处理"的场景;框架内部异常尽量保留 cause 后抛运行时异常。编译期就能暴露的问题是受检异常的强项,不要因为嫌麻烦就全转成运行时异常。 - Go:没有异常机制,只能走返回值路线。强烈建议用
fmt.Errorf("... %w", err)包装错误来保留原始错误链,并定义哨兵错误(sentinel error)。同时要克制住"到处打印日志"的冲动——正确的做法是每一层用%w包一层上下文,最后在顶层统一输出一次完整错误链。 - Python:异常是主流的 I/O 错误表达方式,但在业务校验场景应该返回可序列化的错误对象或抛出带错误码的自定义业务异常(
BizError)。尤其注意:asyncio环境中不要把异常吞在协程里,要确保异常能传到事件循环的异常处理器里。 - C++:新项目建议用
std::expected(C++23)统一失败分支;使用异常时注意保持noexcept边界,避免异常跨模块传播(比如跨越 DLL 边界时,异常类型可能无法正确匹配)。
5. 我在实战里反复踩过之后总结出的几条铁律
5.1 一次线上事故复盘:错误模型在哪一环断了
我之前负责过一个订单服务,线上突然出现一批订单状态没有更新。排查的时候发现:底层数据库连接池偶发超时,适配器层抛出了SQLTimeoutException;领域层没有捕获,异常一路传到 Controller 被某个兜底 catch 住,打了一行WARN: operation failed就返回了。整个调用链上的异常信息在最后一层被丢掉了,没有错误码、没有 cause、没有重试。结果就是:用户看起来单子创建成功了,但后台状态一直卡在"待支付"。
复盘时我们画了一条错误传播路径,发现至少三个环节的模型设计有问题:适配器层没有把底层异常包装成带有稳定错误码的领域异常;领域层没有预料到"数据库超时"是一种可重试的临时故障;Controller 层的兜底 catch 没有把错记到监控系统。后来改动并不复杂:给临时故障定义了独立错误码,领域层显式处理重试逻辑,Controller 层对未处理异常统一走告警。这个案例让我确信,错误模型不是纯理论,它是系统可靠性的一部分。
5.2 把错误模型写进团队的接口约定
最后聊几句实操层面的体会。
第一,把错误模型写进接口文档里。接口文档通常只写参数、返回值、异常,很少写"什么条件下返回错误对象、什么条件下抛异常、错误码的取值范围"。这导致团队里每个人对同一接口的错误处理方式都不一样。我在定义接口阶段就会明说:这个接口的失败用 Result 返回,那个接口的失败用异常表达,调用的前提条件是什么。
第二,宁可多写几行if err != nil,也不要吞异常。我见过太多系统上线后出问题定位时,才发现某个模块把异常吞了三天。在错误处理这件事上,显式的啰嗦远好过隐式的优雅。团队 review 代码时我会专门看:catch 之后有没有做有意义的事?错误对象有没有显式处理?异常有没有保留 cause?
第三,每次线上事故后都复盘错误模型。不要只问"哪个代码写错了",一定要问"哪一层错误模型设计导致我们没有及时看见错误"。很多事故最后都可以归因到错误模型:要么错误在调用链里被吞了,要么错误码不稳定导致告警误报,要么异常 cause 被丢了导致排查多花两小时。
我把这套"错误模型"的设计思路放进培训课程里之后,最明显的变化是团队 review 代码时开始有人主动问"这个失败路径的返回类型是什么、错误码是什么、会不会被调用方忽略"——这些问题比"这段代码能不能再优化一下"重要得多。掌握错误模型,相当于给系统装上了会说话的仪表盘,而不是等故障发生时再满地找证据。