错误模型设计实战:统一返回体、异常处理与错误码规范
发布时间:2026/10/1 8:15:24来源:尧图网络
聊到错误模型绕不开三个词数据、异常、正常返回。很多项目表面上功能齐全一上线就原形毕露问题大多出在“出错之后返回值怎么约定”上。有的接口返回 null有的直接抛异常前端要 try-catch 一层还要再判断 code偶尔堆栈信息还直接暴露给用户。这套东西如果没设计好线上每次故障都像一次考古。这篇内容围绕错误模型怎么落地展开核心解决三件事正常返回该长什么样、异常什么时候该抛、错误码怎么设计。适合刚接手项目的新人也适合正在重构老接口的负责人。1. 错误模型到底是什么先想清楚三条路1.1 三类出口的语义边界我见过太多团队在评审接口时只讨论“参数对不上怎么办”从来没人先说清楚“系统到底有几条出口”。实际上一个系统的对外输出只有三类正常返回的数据、主动抛出的异常、以及给调用方看的错误码/错误响应。这三类东西语义完全不同。正常返回处理的是“符合预期的业务结果”比如查订单列表查到 100 条是返回查到 0 条也是返回。异常处理的是“当前流程无法继续执行”的情况比如数据库连接断了、磁盘满了、代码里有个 bug 导致空指针。错误码处理的是“调用方需要自行判断和处理的业务失败”比如参数不合法、订单不存在、余额不足。一个简单判断标准如果“用户查不到订单”也算一种业务结果那它应该走正常返回而不是抛异常。如果“用户提交了一个非法的日期格式”这是调用方的问题应该返回业务错误码。如果“数据库连接池被耗尽”这是系统内部故障应该抛异常并触发告警。很多老项目的通病就是这三条路没分开。查询结果为空时返回 null同事调用时忘了判空就 NPE于是又在外层包了一个 try-catch把空值问题伪装成系统异常。最后整个代码库里全是 catch 块没人敢删也没人看得懂。1.2 为什么“异常”不能当“返回”用有些同学喜欢用异常表达一切错误甚至在业务正常分支里也主动 throw。我在代码 review 里经常见到这样的写法查订单查不到就 throw new OrderNotFoundException然后由全局异常处理器转成“订单不存在”返回给前端。这么写看起来省事但代价很高。第一异常构造本身要抓取堆栈比普通 return 慢几个数量级虽然大多数业务系统不差这点性能但高频接口会受影响。第二异常的语义是“流程被打断”如果用它传递预期业务结果代码会被 try-catch 包得面目全非后来的人根本分不清哪些 catch 是处理真正的故障哪些只是在兜一个本可以走 return 的分支。第三异常日志会被大量无效噪声淹没告警失去意义。真正应该抛异常的场景是“你无法在当前代码层面恢复必须让上层决定怎么处理”。比如连接数据库失败底层不知道是该重试还是该降级只能往上抛再比如代码里数组越界这是程序员写错了应该尽早抛出、快速失败而不是把越界当成一种“返回结果”静静吞掉。1.3 关于热词里那些“异常”从现象看本质最近我整理了一批和异常相关的实际问题表面看都是报错但背后的分类完全不同。比如终端进程启动失败、注册表异常、驱动工作异常代码 31这类属于环境健康问题不是业务问题错误模型里要单独归类通常走系统异常加人工介入处理。数组越界异常、非法参数异常这类属于编码问题大多发生在调用方没有做前置校验或者索引计算有 bug应该 fail-fast让问题在开发阶段就暴露。获取首页数据失败伺服器错误 502这类是外部依赖问题可能是上游服务挂了错误模型要考虑重试、熔断、超时并明确告知调用方“这次失败是暂时的可以稍后请求”。DataFrame 异常数据处理、Flink JDBC 连接器异常这类是数据链路问题要么是数据结构不合法要么是连接资源占满处理方式完全不同。看到一条报错先别急着修先问一句它应该走哪条出口分类清楚了处理方案自然就出来了。2. 数据、异常与正常返回错误模型设计的关键决策2.1 正常返回的自我修养null、空集合与 Optional正常返回这条路上最容易埋雷的就是 null。Java 里一个接口返回 List如果查不到数据就返回 null调用方如果用 for 循环直接遍历立刻 NPE。这种 NPE 极难查因为堆栈里只有一行 “NullPointerException”根本看不出是哪个列表为空。我的原则很简单集合查询结果永远返回空集合不要返回 null。条件查询可能查不到单条记录时用 Optional 包装或者明确返回 null 并在字段注释里写清楚。Pandas 里也类似一个 DataFrame 为空它仍然有 columns 和 dtype你在空 DataFrame 上做过滤、聚合都不会崩。这恰恰说明“空数据”和“无数据”是两个概念好的数据模型会让空状态也变得可操作。真正要避免的是“一层层传 null”。比如 methodA 调 methodBmethodB 内部判断某个字段为 null 就返回 nullmethodA 又拿着这个 null 去调 methodCmethodC 再返回 null。这种 null 传播会把错误模型搅浑最后所有判断都变成“if (xxx ! null) 才继续”代码可读性极差。正确的做法是在边界处就把空值语义定义清楚。查询结果没有数据那就返回一个明确的结构列表为空也好、Optional.empty 也好不要让 null 在内部代码里到处飞。2.2 错误码设计从“散装”到“有结构”错误码不是简单的“1 成功、0 失败”而是要形成一套稳定、可扩展、可读的编码体系。我见过最乱的项目错误码散落在各个服务里有的用字符串 “SUCCESS”有的用数字 200有的用 -1前后端对不上出了问题只能全文搜索。比较通用的做法是分段编码。比如五位数字错误码前两位表示微服务编号或业务域编号中间两位表示错误类型后一位表示具体错误项。像 20000 表示通用成功20001 表示参数为空20002 表示参数格式错误30001 表示订单不存在30002 表示订单状态不允许操作。这样的好处是看到错误码就能快速定位到是哪个域、哪类错误。错误码一旦定下来就不要轻易改语义。尤其不要用同一个 code 表示两种完全不同的含义否则存量接口消费方会集体踩坑。每次新增错误码都要记录下来放到一个公共枚举或者错误码清单里注释写清楚触发条件和返回给用户的文案。2.3 异常体系系统异常、业务异常、断言异常的区别异常体系建议分三层。业务异常代码里叫 BizException表示预期内的业务规则失败比如余额不足、订单已取消。这类异常在 controller 层被捕获后转换成对应的业务错误码返回给前端不需要打完整堆栈只需要记一条 warn 日志。系统异常代码里叫 SystemException表示非预期故障比如数据库连接超时、Redis 连接失败、外部接口调用超时。这类异常需要转成通用系统错误码同时必须打 error 日志并触发告警因为这是需要值班人员马上介入的。断言异常比如 IllegalArgumentException、IndexOutOfBoundsException属于编程错误理论上不应该暴露给用户。全局异常处理器里要兜住它们返回通用参数错误或系统错误但日志级别建议设为 error方便开发尽快发现。我见过一个很实用的扩展在自定义异常里加时间戳或 traceId 字段。热词里有“java 自定义异常时间戳方便快递定位”这个思路我很推荐。当一个异常在日志系统里被捞出来时单独一个异常对象只能说明“哪里炸了”加上时间戳和 traceId才能快速还原“哪个请求、在哪个时刻、经过哪些服务炸了”。3. 从零落地一套错误模型以 Java 场景为例3.1 统一返回体 Result 的定义与泛型设计动手落地时第一步是定义一个统一返回体。几乎所有现代 Web 项目都会做一个 Result 包装把业务数据塞进 data 字段旁边再放 code 和 message。public class ResultT { private int code; private String message; private T data; private String traceId; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(ErrorCode.SUCCESS.getCode()); result.setMessage(ErrorCode.SUCCESS.getMessage()); result.setData(data); return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } // 省略 getter/setter }为什么 code、message、data、traceId 缺一不可code 给程序判断message 给人看data 放真正的业务数据traceId 串起全链路日志。没有 traceId 的统一返回体排查问题时每一条日志都要靠时间模糊匹配效率极低。还有一个设计细节容易被忽略成功码要全局统一。有些人喜欢用 HTTP 200 表示成功有些人用 0有些人用 00000。我建议团队商定一个值后写进公共文档前后端都按这个值判断。不要出现“接口返回 HTTP 200但 body 里的 code 是 5001”这种双层状态会让调用方不知道到底以谁为准。3.2 全局异常处理器的实现与自动兜底有了统一返回体还需要一个全局异常处理器把异常自动转换成 Result避免每个 controller 自己写 try-catch。用 Spring Boot 的话RestControllerAdvice 是最常用的方案RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { log.warn(业务异常: code{}, msg{}, e.getCode(), e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValidException(MethodArgumentNotValidException e) { String msg e.getBindingResult().getFieldError().getDefaultMessage(); return Result.error(ErrorCode.PARAM_INVALID.getCode(), msg); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e, HttpServletRequest request) { log.error(系统异常: uri{}, traceId{}, request.getRequestURI(), TraceIdUtil.getTraceId(), e); return Result.error(ErrorCode.SYSTEM_ERROR.getCode(), ErrorCode.SYSTEM_ERROR.getMessage()); } }这个兜底 Exception 是最关键的一层。它保证任何没有被框架识别的异常都能转换成统一返回体并且不会把异常堆栈直接暴露给前端。调试时可以临时打印堆栈到响应里但线上必须关掉。这里有一个经验兜底异常处理里日志要打出请求路径、traceId 和完整堆栈而返回给用户的 message 只能是类似“系统繁忙请稍后重试”的通用文案。否则用户会把内部方法名、SQL 片段、甚至文件路径全部截图发群里既难看又不安全。3.3 业务异常与错误码枚举的联动业务异常类的设计建议直接内置一个 ErrorCode 字段public class BizException extends RuntimeException { private final ErrorCode errorCode; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode errorCode; } public ErrorCode getErrorCode() { return errorCode; } }这样业务代码里只写一句if (order null) { throw new BizException(ErrorCode.ORDER_NOT_FOUND); }全局异常处理器拿到 BizException 后直接从 errorCode 里取 code 和 message。链路从抛出到前端展示全程走同一套错误码谁都不会传错。错误码枚举示例public enum ErrorCode { SUCCESS(0, 成功), PARAM_INVALID(20001, 参数不合法), ORDER_NOT_FOUND(30001, 订单不存在), ORDER_STATUS_ERROR(30002, 当前订单状态不允许该操作), SYSTEM_ERROR(50000, 系统繁忙请稍后重试); private final int code; private final String message; // 构造器和 getter 省略 }很多人纠结业务异常是继承 RuntimeException 还是 Exception。我建议继承 RuntimeException原因很简单不强制调用方 catch避免“checked exception 污染”。业务异常本质上不是让调用方恢复的而是让上层统一处理的用 unchecked 最省事。3.4 日志打印与时间戳注入的一个实践热词里提到“自定义异常时间戳方便快速定位”这确实是我在线上踩过坑之后才加的设计。早期我们的异常对象只有 message日志系统里同一个错误会出现几千条每条都长得一模一样根本不知道哪个用户、哪个请求触发的。后来我做了两件事。第一在 Result 和异常里都加 traceId第二用日志框架的 MDC 把 traceId 自动打印到每一条日志里。public class TraceIdUtil { private static final String TRACE_ID traceId; public static String getTraceId() { return MDC.get(TRACE_ID); } public static void setTraceId(String traceId) { MDC.put(TRACE_ID, traceId); } }在请求入口的 Filter 里public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { String traceId UUID.randomUUID().toString().replace(-, ); TraceIdUtil.setTraceId(traceId); try { chain.doFilter(req, res); } finally { TraceIdUtil.clearTraceId(); } } }日志配置里这样写pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId}] [%thread] %-5level %logger{36} - %msg%n/pattern这样每一条日志都会自动带上 traceId。线上出问题时从前端报错拿到的 traceId 到日志平台一搜整条请求链路的所有日志全部串起来再配合异常对象里的时间戳字段定位效率能提升一个量级。3.5 移植到 Python/Pandas 场景时的变体这套思想不只在 Java 里成立。热词里出现“python 结构化数据”“DataFrame 异常数据处理”Python 项目同样需要错误模型。Python 里没有统一返回体这个强制约束很多脚本随手 return None导致下游处理时到处判 None。我的习惯是对外提供数据结构时尽量返回空集合而不是 None确实可能缺失的单值用Optional[Any]明确标注自定义异常类至少继承 Exception 或 RuntimeError并在异常里带上错误码。Pandas 处理脏数据时反而要尽量少用异常。比如读取一列数据里面有缺失值、非法格式直接用pd.isna、pd.to_numeric(errorscoerce)这类方法把数据规范化而不是用 try-except 一层层包。异常应该留给真正无法继续执行的故障比如文件路径不存在、表结构对不上而不是每个单元格的脏数据。4. 常见问题与排查技巧实录4.1 问题速查表从热词中拎出来的典型错误下面这段内容很多真实报错案例可以直接归类到错误模型里去我在表格里给出常见现象、归类和建议。报错/现象属于哪一类处理建议Java 数组越界异常编码缺陷检查索引计算和边界条件在入口处做长度校验fail-fast非法参数异常 IllegalArgumentException调用方传参错误在接口入口做参数校验用校验错误码返回不要让校验异常穿透到业务层获取首页数据失败 502外部依赖故障设置超时、重试、熔断返回通用服务不可用错误码并记录最后一次请求的 traceIdFlink JDBC 连接器异常资源型系统异常检查连接池配置、数据库最大连接数增加健康检查和自动重连DataFrame 异常数据处理数据质量不可控用数据清洗方法处理空值而不是把脏数据问题提升为异常终端进程启动失败无法启动 conpty本机环境异常检查开发环境依赖和终端配置与业务错误模型无关单独走环境修复流程驱动工作异常代码 31环境驱动问题重装或更新驱动属于运维操作不是接口返回问题每条报错在动手处理前先确定它是“业务失败”“系统故障”还是“环境问题”再决定它在错误模型里对应哪个出口。很多排查白白浪费一个小时就是因为把系统故障当成业务参数问题在查。4.2 错误处理中的“吞异常”与“重复包装”问题我见过最伤的代码不是没处理异常而是把异常吞了。try { orderService.createOrder(orderDTO); } catch (Exception e) { // 什么也不做 }这种代码上线后线上会出现一个非常诡异的现象订单没生成但接口返回成功。用户没收到任何提示运维也看不到错误日志只能靠用户投诉才后知后觉。吞异常的危害比直接抛异常大得多因为它把系统故障伪装成了正常流程。另一种问题是重复包装。有的同事 catch 到异常后又 new 了一个通用 Exception 抛出去把原始堆栈覆盖掉。catch (Exception e) { throw new RuntimeException(创建订单失败); }这样日志里只有一句“创建订单失败”没有原始根因。正确做法是把原始异常作为 cause 传进去catch (Exception e) { throw new BizException(ErrorCode.ORDER_CREATE_FAILED, e); }如果在自定义异常里没有接收 cause 的构造器就加上去。排查线上问题时“根因异常”和“业务包装信息”缺一不可。4.3 调试技巧没有日志就没有真相最后分享一个排查链路。线上某个接口报错我先不看代码直接去日志平台做三件事。第一拿前端或网关返回的 traceId 去搜日志把请求入口、服务内部调用、外部依赖调用全链路捞出来。第二看错误日志的级别和时间如果业务异常是 warn系统异常是 error一眼就能判断故障严重度。第三看异常堆栈里最底层那个 cause而不是最上层那个通用 message。很多时候真正原因是数据库死锁被上层包装成了“操作失败”不看 cause 根本猜不到。热词里提到“graphlib 分析异常原因”这个思路放在分布式系统里也很好用把服务间的依赖关系画成一张有向图异常沿着调用链反向追溯。A 服务报错往往是因为 B 服务超时B 服务超时又是因为 C 服务负载过高。错误模型如果在一开始就统一了 traceId这张图的全链路日志就能自动串起来分析异常原因就像沿着边在图上走一样顺畅。我在实际项目中还有一个习惯每个服务都要有一个“异常统计面板”按错误码、异常类、traceId 出现次数聚合。哪类错误突然暴增说明某个模块正在出问题不用等用户投诉就能提前告警。这套机制依赖的底层还是那个最朴素的错误模型数据该走数据通道异常该走异常通道错误码该走错误码通道三件事绝不混在一起。最后再分享一个小细节新项目启动的第一天先把 Result、错误码枚举、全局异常处理、traceId 过滤器这四个文件建好哪怕业务代码一行都没写。这四样东西就是整个系统错误处理的骨架。后期每加一个接口每接一个前端页面都是在这个骨架上生长。我见过太多项目上线一年后才想起来补统一错误模型结果所有接口都已经写成了乱七八糟的 try-catch 和 null 返回改起来伤筋动骨。错误模型的成本不是写代码的成本而是决定“三条路分别怎么走”的思考成本这步想清楚后面全是顺水推舟。
网站建设高端定制企业官网