首页 / 资讯中心 / 文章详情

错误码还是异常?按场景选型与跨层转换的工程实践

错误码还是异常?按场景选型与跨层转换的工程实践 ★ FEATURED ARTICLE
错误码还是异常这个问题我在不同项目里反复纠结过好几轮。早些年写服务端接口团队里有个不成文的规矩——所有函数都返回一个包含 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 边界模糊时怎么判断现实里总有一些不好归类的场景。我的判断方法是问自己三个问题调用方有没有能力、有没有必要针对这个错误做不同处理如果调用方只会统一记日志然后失败那它更像系统故障用异常。这个错误是不是业务规则明确规定的如果是产品文档里写着的规则比如单笔转账上限五万那是业务分支用错误码。忽略这个错误会不会导致数据损坏如果会倾向用异常强制中断如果只是返回一个提示用错误码。举个模糊的例子调用第三方支付接口超时。这算业务分支还是系统故障我的处理是——超时本身用异常或至少是可重试的错误标识但支付被拒绝这种明确返回的结果用错误码。因为超时是基础设施层面的不确定状态而被拒绝是对方业务系统的明确答复。这个区分在对接支付、物流这类外部服务时特别重要混了会导致重试逻辑写错。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、1002B 模块用字符串NOT_FOUNDC 模块干脆返回布尔值加一个 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 系统故障这条主线大部分场景都能自己判断对。最后分享一个我个人的小习惯每次设计一个新接口时我会先在纸上列出这个接口所有可能的失败情况然后逐个标注预期内还是非预期。标完之后错误处理方案基本就自动浮现了——预期内的那些设计成错误码非预期的那些交给异常。这个动作花不了几分钟但能避免后期大量的返工和扯皮。
阅读完成 · 觉得有帮助?
咨询建站