☰
错误码还是异常?按场景选型与跨层转换的工程实践
2026/10/23 11:13:34 网站建设 项目流程

错误码还是异常,这个问题我在不同项目里反复纠结过好几轮。早些年写服务端接口,团队里有个不成文的规矩——所有函数都返回一个包含 code、message、data 的结构体,调用方逐层判断 code 是否为零。后来换了个项目,代码评审时有人看到返回值里带错误码直接打回,理由是"异常机制就是干这个的,你这是在用 C 的思维写业务代码"。两次经历都没错,但两次我都花了大量时间去适配团队的约定,而不是去思考哪种方式更适合当前场景。

这篇文章想聊的就是这个:返回错误码和抛出异常不是信仰之争,而是场景之争。我会把两种方式各自的适用边界、混用时的坑、跨层调用怎么转换、以及我在实际项目里踩过的具体问题拆开讲。适合正在设计接口规范的后端开发、写 SDK 需要对外暴露 API 的工程师,也适合刚入行还在纠结"到底该 return 还是 throw"的朋友。读完你至少能判断:手上这个函数,该用哪种错误传递方式,以及为什么。

1. 先把两种方式的本质差异摆清楚

很多人讨论这个问题时,一上来就比"哪个更好",但连两者在运行时到底发生了什么都没对齐。我先把机制层面的差异讲透,后面所有的选型判断都建立在这个基础上。

1.1 错误码是一次普通的返回值传递

返回错误码的本质,是把"出错了"这个信息编码成一个普通值,沿着正常的函数返回路径往上传。调用方拿到这个值后,需要主动判断、主动处理。它没有任何运行时层面的特殊机制,就是一次赋值、一次比较。

function divide(a, b) { if (b === 0) { return { code: 1001, message: "除数不能为零", data: null }; } return { code: 0, message: "ok", data: a / b }; } const result = divide(10, 0); if (result.code !== 0) { // 调用方必须主动检查,不检查就会拿着 null 继续往下跑 console.error(result.message); }

这段代码的关键点在于:如果调用方忘了判断 code,程序不会崩溃,而是带着错误数据继续执行。这是错误码最大的特点,也是它最大的风险来源。错误被"静默"了,直到下游某个地方数据异常才暴露,排查链路会拉得很长。

从性能角度看,错误码几乎没有额外开销。没有栈展开,没有异常对象构造,就是一次对象创建和一次整数比较。在高频调用的热路径上,这个差异是实打实的。

1.2 异常是一次非局部的控制流跳转

抛出异常则完全不同。当throw执行时,运行时会在调用栈上逐层向上查找匹配的catch,找到之前,中间所有的函数调用都会被"跳过"。这是一种非局部的控制流转移,正常返回路径被完全绕开。

function divide(a, b) { if (b === 0) { throw new Error("除数不能为零"); } return a / b; } try { const result = divide(10, 0); // 这行不会执行 console.log(result); } catch (e) { console.error(e.message); }

异常的核心价值在于:它无法被忽略。只要没有 catch,异常就会一路冒泡到进程顶层,程序直接终止并打印堆栈。这意味着错误不可能被静默吞掉,问题会立刻暴露。代价是栈展开有开销,构造异常对象(尤其是带完整堆栈的)成本不低,而且控制流变得不直观——你读代码时不能只顺着行号往下看,得时刻想着"这里可能跳走"。

1.3 一张表看清两者的取舍维度

我把关键差异整理成表,方便对照。这张表不是让你背,而是让你在具体场景里能快速定位到"我到底在意哪个维度"。

维度返回错误码抛出异常
是否可被忽略可以,风险高不可以,强制处理
运行时开销极低栈展开 + 对象构造,较高
控制流可读性线性,直观非线性,需追踪 catch
错误信息丰富度取决于结构设计天然携带堆栈
跨语言/跨进程友好度好,易序列化差,异常难跨边界
对调用方的约束弱,靠自觉强,编译器/运行时兜底
适合的错误性质预期内的业务分支非预期的异常状况

这张表里我最想强调的是最后一行——错误的性质。这是选型的第一判断依据,比性能、比可读性都更根本。预期内的、调用方理应处理的业务分支,用错误码更自然;非预期的、调用方通常无力恢复的系统状况,用异常更合适。后面几节会反复回到这个判断标准。

2. 业务分支用错误码,系统故障用异常

这条分界线是我这些年总结下来最实用的一条。它不绝对,但能覆盖八成以上的日常决策。关键在于区分"预期内"和"非预期"。

2.1 什么叫"预期内的业务分支"

用户登录时密码输错、下单时库存不足、转账时余额不够——这些都不是"程序出 bug 了",而是业务规则正常运转的结果。系统本来就该预料到这些情况会发生,调用方也理应针对每种情况给出不同的处理逻辑。

这类场景我强烈建议用错误码(或者等价的显式结果类型)。原因很直接:调用方需要根据不同的错误类型走不同的分支,这本身就是业务逻辑的一部分,用返回值表达最自然。

function withdraw(account, amount) { if (amount <= 0) { return { code: "INVALID_AMOUNT", message: "金额必须大于零" }; } if (account.balance < amount) { return { code: "INSUFFICIENT_BALANCE", message: "余额不足" }; } account.balance -= amount; return { code: "OK", message: "success", data: account.balance }; }

调用方拿到INSUFFICIENT_BALANCE时,可以弹窗提示用户充值;拿到INVALID_AMOUNT时,可能是前端校验漏了,记个日志。这些分支都是明确的、可枚举的,用错误码表达,代码读起来就是一条直线,谁都能看懂。

反过来,如果这里用异常,调用方就得写一堆catch来区分异常类型,而且异常类型和业务分支的对应关系是隐式的,新人接手时很难一眼看全"这个函数到底可能抛哪几种错"。

2.2 什么叫"非预期的系统故障"

数据库连接突然断了、下游服务超时、配置文件格式损坏、内存溢出——这些不是业务规则的一部分,而是系统运行中出了状况。调用方通常无力针对每种情况做精细处理,能做的往往只是"记录日志、返回一个通用错误、让请求失败"。

这类场景用异常更合适。因为它强制中断当前流程,避免带着损坏的状态继续往下跑,同时堆栈信息能帮你快速定位问题源头。

function loadConfig(path) { const raw = fs.readFileSync(path, "utf-8"); // 文件不存在会直接抛异常 const config = JSON.parse(raw); // 格式错误也会抛异常 return config; }

这里我没有对readFileSync和JSON.parse做任何错误码包装,因为它们抛出的异常就是"非预期故障"的典型代表。上层统一用一个全局的错误处理中间件兜住,记录堆栈、返回 500,就够了。如果硬要把这些包装成错误码,反而会让代码里塞满无意义的判断,而且丢失了堆栈信息。

2.3 边界模糊时怎么判断

现实里总有一些不好归类的场景。我的判断方法是问自己三个问题:

  1. 调用方有没有能力、有没有必要针对这个错误做不同处理?如果调用方只会统一记日志然后失败,那它更像系统故障,用异常。
  2. 这个错误是不是业务规则明确规定的?如果是产品文档里写着的规则(比如"单笔转账上限五万"),那是业务分支,用错误码。
  3. 忽略这个错误会不会导致数据损坏?如果会,倾向用异常强制中断;如果只是返回一个提示,用错误码。

举个模糊的例子:调用第三方支付接口超时。这算业务分支还是系统故障?我的处理是——超时本身用异常(或至少是可重试的错误标识),但"支付被拒绝"这种明确返回的结果用错误码。因为超时是基础设施层面的不确定状态,而"被拒绝"是对方业务系统的明确答复。这个区分在对接支付、物流这类外部服务时特别重要,混了会导致重试逻辑写错。

3. 跨层调用时的错误转换,才是真正容易翻车的地方

单看一个函数,选错误码还是异常都不难。真正让我踩坑的,是错误在多层架构里传递时的转换。Controller 层、Service 层、Repository 层各自对错误的表达习惯不一样,转换没做好,错误信息就会在某一层被吞掉或者被扭曲。

3.1 分层架构里的错误表达惯例

大多数后端项目会形成这样的默契:最底层的 Repository 或 DAO 层,直接让数据库驱动的异常往上抛,或者包装成统一的数据访问异常;Service 层负责把底层异常翻译成业务语义;Controller 层负责把业务错误转成 HTTP 状态码和响应体。

问题出在 Service 层。我见过太多项目在这一层把异常catch住之后,return null或者return false,然后 Controller 拿到 null 也不知道发生了什么,只能返回一个笼统的"操作失败"。错误信息在这一层彻底丢失。

// 反面示例:Service 层吞掉异常 function getUser(id) { try { return userRepo.findById(id); } catch (e) { return null; // 错误信息全丢了,上层无法区分"用户不存在"和"数据库挂了" } }

这段代码的问题在于,它把两种性质完全不同的情况——"查无此人"(业务分支)和"数据库连接失败"(系统故障)——压成了同一个null。上层拿到 null 后,既不知道该提示用户"用户不存在",也不知道该触发告警。这就是典型的错误转换失败。

3.2 一个可复用的转换模式

我的做法是在 Service 层建立明确的转换规则:底层异常要么原样往上抛(系统故障),要么转换成带业务语义的错误码或领域异常(业务分支),绝不允许压成 null 或 false。

function getUser(id) { let user; try { user = userRepo.findById(id); } catch (e) { // 数据库层面的故障,包装后继续往上抛,保留原始堆栈 throw new DataAccessError("查询用户失败", { cause: e }); } if (!user) { // 查无此人,是明确的业务分支,用错误码表达 return { code: "USER_NOT_FOUND", message: "用户不存在" }; } return { code: "OK", data: user }; }

这里的关键设计是cause字段——把原始异常挂在包装异常上,这样堆栈信息不会丢,排查时还能看到根因。很多语言的异常体系都支持 cause 链,用起来很值。Controller 层再根据返回的是错误码还是抛出的异常,分别映射成 4xx 和 5xx,语义就清晰了。

3.3 转换时最容易丢的三样东西

我在代码评审里反复看到错误转换时丢失关键信息,总结下来主要是三样:

  • 原始堆栈:包装异常时忘了带 cause,导致只能看到"查询失败",看不到底层到底是超时还是语法错误。
  • 错误上下文:比如查询用户失败时,没把用户 id 带进错误信息,事后排查只能靠猜。
  • 错误分类:把可重试的错误和不可重试的错误混在一起,导致重试逻辑要么疯狂重试注定失败的请求,要么该重试的没重试。

提示:包装异常时,养成"带 cause、带关键参数、带错误分类"的习惯。这三样东西在事后排查时能省下大量时间,尤其是线上问题只能靠日志还原现场的时候。

4. 我踩过的几个具体坑,以及怎么绕过去

理论和原则讲完了,这一节全是实战。这几个坑都是我在真实项目里踩过的,有的还导致了线上事故,写出来给后来人提个醒。

4.1 坑一:错误码在异步回调里被静默吞掉

早年在做一个任务调度模块,任务执行函数返回错误码,调度器根据错误码决定是否重试。逻辑看着没问题,但上线后发现有些任务失败了却显示成功。排查了半天,发现是任务函数内部调了一个异步回调,回调里的错误码根本没被返回出去——外层函数早就返回了"成功"。

// 问题代码:异步回调里的错误码丢失 function runTask(task) { doAsyncWork(task, (err, result) => { if (err) { return { code: "TASK_FAILED" }; // 这个 return 只返回给了回调,外层拿不到 } // ... }); return { code: "OK" }; // 外层永远返回成功 }

这个坑的根因是错误码依赖"返回值"这条路径,而异步回调的返回值和外层函数的返回值是两条完全独立的路径。异常在这种情况下反而更安全,因为异常会沿着异步的调用链冒泡(前提是异步框架支持),或者至少能被全局的未捕获异常处理器抓到。

修复方式是把整个任务函数改成 Promise 或 async 风格,让错误通过 reject 传递,而不是靠回调里的 return。这个教训让我在后来的项目里定了个规矩:凡是涉及异步的错误传递,优先用异常/Promise reject,慎用错误码。

4.2 坑二:用异常做流程控制,性能被打穿

另一个项目里,有个同事把"用户是否已注册"这种判断也用异常表达——查不到就抛UserNotFoundException,上层 catch 住当作"未注册"处理。功能上没毛病,但那个接口 QPS 一高,CPU 就飙上去了。用性能分析工具一看,大量时间花在构造异常对象和填充堆栈上。

异常对象的构造,尤其是堆栈的采集,成本比很多人想象的高。在每秒几万次调用的热路径上,如果其中相当一部分是"正常的未注册"情况,那这些异常构造就是纯浪费。后来改成返回一个Optional或带标志位的结果对象,性能立刻回落。

这个坑的教训是:异常适合"罕见"的非预期情况,不适合高频出现的正常分支。"用户未注册"在注册引导场景里是高频正常情况,用异常就是杀鸡用牛刀,而且牛还会反抗。

4.3 坑三:跨进程边界传异常,序列化直接失败

做微服务拆分时,有个服务的方法签名里声明了会抛业务异常,调用方通过 RPC 框架调用。本地测试一切正常,联调时却报序列化错误——异常对象里带了不可序列化的字段(比如数据库连接对象),跨进程传输时直接炸了。

异常天生不适合跨进程边界。它携带的堆栈、cause 链、自定义字段,在序列化时都是麻烦。跨服务调用时,正确的做法是在边界处把异常转换成明确的错误码或错误响应结构,让对方根据错误码处理,而不是指望异常能原样传过去。

// RPC 边界处的转换 function handleRpcCall(req) { try { const data = service.process(req); return { code: "OK", data }; } catch (e) { // 在边界处把异常转成可序列化的错误响应 return { code: e.businessCode || "INTERNAL_ERROR", message: e.message, // 注意:不传堆栈,堆栈留在本服务日志里 }; } }

这个模式我现在每个跨服务接口都会用。边界内用异常,边界外用错误码,转换点就在 RPC 的入口和出口,职责非常清晰。

4.4 坑四:错误码没有统一规范,各写各的

最后一个坑比较"组织性"。早期项目里,A 模块用数字错误码(1001、1002),B 模块用字符串("NOT_FOUND"),C 模块干脆返回布尔值加一个 message 字段。结果前端对接时,光错误处理就写了一堆适配代码,而且经常出现"这个 code 到底代表什么"的扯皮。

后来我们定了一套统一规范:错误码用"模块前缀 + 数字"的形式(如USER_1001),每个错误码在文档里有明确含义和对应的 HTTP 状态码,所有对外接口的错误响应结构统一为{ code, message, data }。规范定下来之后,前端只需要写一套错误处理逻辑,对接效率提升非常明显。

错误码规范项反例正例
格式1001 / "not found" / falseUSER_1001
含义文档靠口口相传统一错误码表
HTTP 映射全部返回 200按语义映射 4xx/5xx
响应结构各模块自定义统一 { code, message, data }

5. 混合使用时的几条硬规矩

现实项目里,纯错误码或纯异常都很少见,大多是混用。混用本身没问题,但必须有规矩,否则就会变成"有的地方抛异常、有的地方返回码、调用方两头都得防"的混乱局面。这几条规矩是我在多个项目里验证过、能落地的。

5.1 规矩一:同一个函数内不混用两种方式

一个函数要么用错误码表达所有可预期的失败,要么用异常表达所有失败,不要一半返回码一半抛异常。混用会让调用方无所适从——它既得判断返回值,又得包 try-catch,代码丑陋且容易漏。

如果确实有"业务分支 + 系统故障"两类错误,我的处理是:业务分支用返回值,系统故障让它自然往上抛(不 catch),这样调用方只需要处理返回值里的业务分支,系统故障交给全局兜底。这不算混用,因为两类错误走的是两条清晰的、互不交叉的路径。

5.2 规矩二:在架构边界处做一次彻底转换

前面提过 RPC 边界的转换,其实所有架构边界都适用:HTTP 接口边界、消息队列的生产消费边界、定时任务的执行边界。在这些边界上,把内部的异常统一转换成对外的错误码结构,把外部的错误码统一转换成内部的异常或结果类型。

这样做的好处是,边界内部可以自由选择表达方式,边界外部看到的是稳定、统一的契约。新人接手时,只需要理解边界处的转换规则,不用去追每个内部函数的错误处理习惯。

5.3 规矩三:错误码必须有文档,异常必须有分类

错误码是"约定",约定不写下来就等于没有。每个对外暴露的错误码,都该在文档里写清楚:什么情况下返回、调用方应该怎么处理、对应什么 HTTP 状态码。异常则要有清晰的类型层次,比如BusinessException和SystemException分开,让 catch 的时候能按大类处理,而不是靠匹配 message 字符串。

注意:靠匹配异常 message 字符串来判断错误类型,是我见过最脆弱的写法。message 一改,判断就失效,而且多语言环境下 message 可能被翻译。永远用类型或错误码来判断,不要用文本。

5.4 规矩四:日志只在一处记录

错误在传递过程中被多层重复记录,是日志噪音的主要来源。我的规矩是:错误在最终被处理(返回给用户或触发告警)的那一层记录一次完整日志,中间层只做转换和补充上下文,不重复记录。这样一条错误在日志里只出现一次,排查时不会被重复条目干扰。

如果中间层确实需要记录(比如为了追踪调用链),那就记 debug 级别,并且带上"已向上传递"的标记,避免和最终的错误日志混淆。

6. 落到具体语言和框架时的差异

前面讲的偏语言无关,但实际写代码时,语言和框架的特性会显著影响选择。这一节聊几个常见环境下的实际考量。

6.1 静态类型语言里的结果类型

在支持泛型和模式匹配的语言里(比如一些现代静态类型语言),用"结果类型"(Result/Either)表达错误是很优雅的方案。它把"成功值"和"错误值"放进同一个类型,编译器强制你处理两种情况,既保留了错误码的显式性,又获得了类似异常的强制性。

这种方案的好处是错误处理变成类型系统的一部分,漏处理会编译报错。代价是写起来比异常啰嗦,尤其是错误需要层层往上传递时,得写不少转换代码(不过很多语言提供了语法糖来缓解)。我的经验是:核心业务逻辑用结果类型,外围的基础设施代码用异常,两者在边界处转换。

6.2 动态类型语言里的约定优先

动态类型语言没有编译期检查,错误码是否被处理全靠约定和测试。这种环境下,我更倾向于用异常处理系统故障(因为至少运行时会强制中断),用错误码处理业务分支,并且靠完善的单元测试来保证每个错误分支都被覆盖。

同时,动态语言里要特别注意"假值"陷阱。比如返回0或空字符串表示成功,调用方用if (!result)判断就会误判。所以错误码的"成功值"一定要选一个不会被误判的值,通常用明确的code === 0或code === "OK"来判断,而不是依赖真假值。

6.3 前端调用后端时的错误处理

前端对接后端接口时,其实面对的是"错误码"这一种方式(HTTP 状态码 + 响应体里的业务码),因为异常没法跨网络传。前端要做的,是把 HTTP 层的错误(网络失败、5xx)和业务层的错误(4xx 里的业务码)分开处理:前者通常提示"网络异常,请重试",后者根据具体业务码给出针对性提示。

我见过前端把所有非 2xx 都统一弹"操作失败"的写法,用户体验很差。正确的做法是维护一张业务码到提示文案的映射表,让每种业务错误都有明确的用户提示。这张表最好由后端提供,前端只负责展示,避免两边对错误码的理解不一致。

7. 我的最终选择:按错误性质分层,按边界转换

绕了一大圈,回到标题的问题——是返回错误码还是抛出异常?我的答案是:看错误的性质,看所在的层次,看跨越的边界。三者结合,选择就清晰了。

预期内的业务分支,用错误码或结果类型,让调用方显式处理,代码读起来是直线;非预期的系统故障,用异常,强制中断、保留堆栈、交给全局兜底。在架构边界处做一次彻底转换,边界内自由,边界外统一。同一个函数不混用,错误码有文档,异常有分类,日志只记一处。

这套组合我在最近几个项目里一直用,代码评审时关于"该 return 还是该 throw"的争论明显少了,因为大家有了共同的判断标准,而不是各凭喜好。新人上手时,只要理解了"业务分支 vs 系统故障"这条主线,大部分场景都能自己判断对。

最后分享一个我个人的小习惯:每次设计一个新接口时,我会先在纸上列出这个接口所有可能的失败情况,然后逐个标注"预期内还是非预期"。标完之后,错误处理方案基本就自动浮现了——预期内的那些,设计成错误码;非预期的那些,交给异常。这个动作花不了几分钟,但能避免后期大量的返工和扯皮。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询