异常模型与错误边界:让失败由正确的层负责
凌晨告警显示库存服务超时,日志平台却同时出现四份堆栈:HTTP 客户端记录一次,仓储层包装后再记一次,应用服务换成业务异常又记一次,接口层最后再记一次。更麻烦的是客户端、仓储和任务调度各重试三次,一笔请求最多打出 27 次下游调用。大家能看到很多“执行失败”,却找不到最初超时发生在哪,也说不清数据库到底回滚没有。
这不是异常类起得不好,而是错误所有权失控。异常一方面保存失败证据,另一方面让正常控制流停止并沿调用栈寻找处理者。哪一层捕获、翻译、重试、回滚或记录,必须由它能做出的正确动作决定。下面就从这次下游超时出发,沿调用栈一直追到最外责任边界。
先找会改变系统状态的那一层
判断捕获位置时,只看哪一层真正能改变系统行为:谁拥有错误,cause 与 suppressed 怎样保留现场,中断如何传递取消,失败又怎样映射到重试、事务、日志和外部协议。checked / unchecked 需要解释到足以支撑接口设计和排障,但异常继承树本身不是主线。
沿调用栈取证前先分清四个基础动作
理解调用栈、资源关闭、线程中断及本地事务,并能使用 JDK 17 或更高版本运行仓库示例。版本与编译目标的治理先阅读Java 长期版本基线。
先沿调用栈找出谁真正拥有这个错误
数据库驱动最先知道的是“连接超时”或“约束冲突”,却不知道库存预占应不应该重试;仓储适配器知道这是存储暂时不可用,却看不见整笔下单是否幂等;应用服务知道业务操作和事务边界;HTTP、消息消费或批任务入口才知道最终应该返回、重投还是退出。先把这些能力摆清楚,捕获位置就不再靠习惯决定。
图中驱动层只报告它能够证明的事实。适配器跨过实现边界时,把厂商异常翻译成稳定的存储失败语义,同时保留 cause。应用层根据完整用例判断冲突、拒绝、取消或暂时不可用,最外层再映射为 HTTP 响应、消息确认策略或批任务退出码。只有新类型能让上层采取不同动作时,包装才有价值。若每层只是把“超时”换个类名再抛,cause 链会越来越长,决策信息却没有增加。
所以 catch 之前先问一个很具体的问题:这一层能恢复到正确状态、翻译成稳定语义、完成清理、补充不可替代的安全上下文,还是在责任边界记录并退出?能做其中一件才捕获;什么也做不了就继续传播。这个问题比“这里是否方便打日志”更能约束代码。
checked exception 可强迫直接调用者面对可预期失败,但也会穿透多层接口;unchecked 可减少签名噪声,却需要清晰文档和边界测试。选择标准不是“业务用哪一种”,而是直接调用方能否在当前抽象层采取不同且正确的动作。Error 等严重运行时问题不应被常规业务代码捕获后伪装成功继续运行。
checked 和 unchecked 先解决编译期义务,不替业务做恢复决定
Java 语言把可抛出的对象限制为 Throwable 及其子类,并从编译器视角区分 checked exception、RuntimeException 及其子类、Error 及其子类。后两类是 unchecked;其他异常通常是 checked。编译器对可能抛出 checked exception 的表达式做异常分析,要求当前方法捕获它,或在 throws 中声明到调用边界。多分支、泛型 throws、lambda 和精确重抛会影响分析结果,但机制目标一致:让一部分失败义务进入静态接口。
这套分类不等价于业务恢复分类。checked 不保证可恢复,unchecked 也不代表应该终止进程;网络 I/O 可能是 checked,却可能因请求幂等性不同而允许或禁止重试;参数契约违反通常是 unchecked,却可能在 HTTP 边界稳定映射为客户端错误。架构层应把“编译器是否强制声明”和“系统如何处置失败”作为两个维度,否则会试图用异常继承树承载所有恢复策略。
throws 也是兼容性契约。为公共接口新增 checked exception 会迫使调用者修改或重新编译,抽象层直接暴露厂商 checked 类型还会锁定实现。适配器可把驱动异常翻译为少量稳定失败族,但不应把所有异常统一包成一个无差别 ApplicationException;后者虽然稳定,却抹掉了调用方能否重试、冲突处理或拒绝请求所需的信息。
真正抛出后,JVM 怎样沿异常表展开调用栈
编译后的方法包含异常处理器表,记录受保护字节码区间、处理器入口和可捕获类型。执行 athrow 后,JVM 先在当前方法查找兼容 handler;找不到便退出当前栈帧,继续向调用者查找,直到命中处理器或线程以未捕获异常结束。这就是“栈展开”:抛出点之后的正常指令不会继续,沿途 finally / 编译后的清理逻辑仍可能执行。
catch 匹配依据运行时异常类型,不依据消息文本。宽泛 catch 放在责任边界之外会截断控制流,还可能把原本应终止任务的取消或严重错误纳入普通恢复分支。字节码异常表也说明异常不是跨进程协议:进入 HTTP、消息或 RPC 边界后,堆栈展开已经结束,必须显式映射为协议状态,再由远端建立自己的失败对象。
异常对象通常在创建时采集栈轨迹,因此用异常表达高频正常分支会产生分配和栈遍历成本。但架构优化不能先删除证据。只有通过 JFR 或基准证明异常创建位于热点,并确认该失败不会越过诊断边界时,才考虑改变控制流或减少内部栈采集;公共边界仍要保留足够的因果与关联信息。
先修复日志里已经丢失的三类证据
回到开头那四份堆栈。仓储层需要把驱动异常翻译成稳定语义,但不能只复制原消息。下面这段代码的目的,就是让上层看到 OrderExecutionException 时仍能沿 cause 回到最初的存储超时:
try {
repository.reserve(orderId);
} catch (StorageTimeoutException e) {
throw new OrderExecutionException("inventory unavailable", e);
}运行到 catch 后,新异常成为当前层的稳定失败,e 则通过构造器进入 getCause()。如果改成 throw new X(e.getMessage()),程序仍然会失败,看上去也有一条错误消息,但根因类型、原始栈和厂商诊断全部断掉。消息只用于人读,程序决策应依赖稳定错误码或类型;消息也不能拼入密码、令牌、完整 SQL 或用户隐私。
Throwable 的 cause 表达“当前失败由哪个更底层失败导致”,不是任意附件。包装构造器应在创建时固定 cause,避免后续代码改变因果关系;排障工具要遍历链并防御循环或超深链。跨异步队列、进程或持久化边界时,Java 对象本身不会自然传播,应提取稳定错误码、阶段、相关 ID 和安全诊断摘要,不能序列化整棵异常对象作为长期协议。
cause 修好后,日志里仍可能缺少第二现场。比如 SQL 执行先超时,连接归还时又失败。如果关闭异常覆盖了业务异常,团队会把事故误判为连接池问题;如果关闭异常完全丢掉,又解释不了为什么超时后连接数还在上涨。try-with-resources 的处理方式是保留业务失败为主异常,把后续 close() 失败放进 suppressed。日志与错误采集链必须展示两者。
suppressed 与 cause 表达不同关系:cause 是因果链,suppressed 是“为了保留主失败而没有作为主异常抛出”的并行失败。try-with-resources 按资源声明的逆序关闭;若业务体成功而关闭失败,关闭异常可成为主失败;若业务体已失败,后续一个或多个关闭异常附着在它的 suppressed 列表。旧式 finally 若直接抛出关闭异常,可能覆盖真正的业务失败,这正是自动资源管理必须进入测试门禁的原因。
自定义 AutoCloseable 还要定义清理后的状态。close() 应尽量幂等,优先释放已获得的底层资源,再报告关闭错误;否则调用方即使看到了 suppressed,也无法判断资源是否仍被占用。连接池泄漏、文件落盘失败等事故中,cause 告诉我们业务为何失败,suppressed 往往告诉我们为什么失败之后系统还持续恶化。
为了亲眼看到三种证据怎样保存,完整源码位于 examples/backend-development/java/exceptions/ErrorBoundaryDemo.java。进入 examples/backend-development/java/exceptions/,把示例编译到独立输出目录,再依次运行正常和两个反向模式:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out ErrorBoundaryDemo.java
java -cp out example.ErrorBoundaryDemo
java -cp out example.ErrorBoundaryDemo lost-cause
java -cp out example.ErrorBoundaryDemo swallow-interrupt正常模式应依次看到 primary=body failed suppressed=close failed、映射异常的 cause=StorageFailure,以及 restored-interrupt=true。lost-cause 模式会打印 cause=null,说明复制消息无法保存因果;swallow-interrupt 会打印 interrupt-after-catch=false,说明任务虽然结束,取消信号却被底层代码消费掉了。两个反向模式都能“正常跑完”,这也说明测试不能只断言进程退出。
如果还想确认 JVM 看到的控制流,再执行 javap -c -v -classpath out example.ErrorBoundaryDemo。输出中的 Exception table 会列出受保护区间、目标 handler 和捕获类型,可以把源码的 try/catch/finally 与字节码处理器对应起来。它适合解释“为什么这个 catch 没命中”,线上排障仍要结合完整异常链和资源状态,不能只盯着字节码偏移。
InterruptedException 表达协作取消,不是普通业务失败。方法能声明它时优先直接传播;不能声明但决定终止工作时,应先 Thread.currentThread().interrupt() 恢复中断位,再包装退出。吞掉中断会让超时、优雅停机和父任务取消失效;恢复中断后也不能继续无限重试同一阻塞操作。
中断是线程上的协作信号,不是强制杀死。调用 interrupt() 设置中断状态;部分阻塞操作检测到它后抛出 InterruptedException,并通常清除状态,所以捕获方如果不能继续声明,就必须恢复状态让上层仍能观察取消。Thread.interrupted() 会读取并清除状态,isInterrupted() 只读取;在工具方法中误用前者可能把信号消费掉。
不是所有 I/O 或本地计算都会因中断立即停止,因此任务代码还要在合理检查点响应状态,并确保取消路径可释放资源。拥有任务生命周期的边界决定是否取消子任务、等待收敛以及超时后隔离;底层库不应把中断翻译成“库存不足”或自动重试。对于虚拟线程,中断仍是重要取消机制,不能因为阻塞成本变低就忽略取消协议。
优雅停机测试应创建真实阻塞点,发出取消或关闭信号,并在有上界时间内断言任务退出、资源释放、中断证据仍在。只在开发机按 Ctrl+C 观察进程结束,无法证明线程池中的 worker 没有吞掉信号。
走到系统边界后,再决定失败要变成什么协议
公共边界应暴露稳定失败族、错误码和调用方动作,而不是数据库驱动、HTTP 客户端或类名。HTTP 可能映射状态码,消息消费者决定重试、死信或确认,批任务决定退出码;它们共享内部 cause,却不共享呈现协议。
错误映射还要区分预期业务拒绝与系统故障。前者通常不需要高等级告警和完整堆栈,后者需要关联 ID、关键阶段及根因链。外部响应只给安全描述,内部日志也应最小化敏感字段并限制访问和保留周期。
遇到“库存状态不允许”这类业务拒绝,调用方应修正请求或结束流程,通常没有重试价值,日志也不需要为每次拒绝打印完整堆栈。乐观锁版本变化或唯一约束竞争则不同:调用方可能重新读取状态后再次决策,但次数必须有上界,不能把确定性冲突当暂时网络故障。
超时、限流和短暂不可用只有在操作幂等或有去重保护、总时间预算仍充足时,才可能进入退避重试;认证失败和契约不兼容不会靠等待恢复,应直接阻断并告警。中断、截止时间和调用方撤销表达的是取消,正确动作是停止子任务并清理,而不是包装成依赖故障继续重试。至于 OOM、链接错误或已知状态损坏,业务层更不能捕获后承诺实例继续正确服务,应让健康检查和进程监督接管。
这里尤其要盯住超时。客户端没有收到响应,不代表服务端没有产生副作用;库存可能已经预占,只是响应丢在网络上。没有幂等键或结果查询能力时,自动重试可能重复扣款或重复创建订单。所以协议边界要明确传递稳定的可重试语义,网关不能只看到 5xx 就一律重放。
同一次失败,只能有一个重试、事务和日志所有者
现在可以解释开头的 27 次调用了。异常类型只能说明失败事实,不能单独证明适合重试。让一次失败进入重试之前,负责这一动作的边界必须能证明它暂时可恢复,操作幂等或有去重保护,剩余时间仍在总超时预算内,退避与抖动不会形成同步风暴,而且调用方还需要这个结果。认证失败、参数错误、确定性约束冲突和中断通常都过不了这些条件。
重试应由能看见完整操作与幂等语义的单一边界负责。客户端、DAO 和任务各重试三次,最坏会放大为 27 次下游调用。团队必须登记重试所有者、最大次数、退避、总预算和幂等依据,并用故障注入验证放大倍数。
抛异常也不自动等于事务回滚。框架对 checked / unchecked 的默认回滚策略可能不同,捕获后返回假成功还可能触发提交。事务测试必须证明目标异常离开事务方法后,数据库是否回滚、是否标记 rollback-only、外部副作用是否已经发生。远程副作用不能由 Java 异常撤销,应通过幂等键、outbox、状态机或补偿流程治理;长时间远程重试也不应包在本地数据库事务中。
日志由最外责任边界完整记录一次。中间层只翻译或附加结构化上下文,不重复打印同一堆栈。最终记录应包含稳定错误码、关联 ID、阶段、总尝试次数以及完整 cause / suppressed 链;指标使用低基数失败族,不能把异常消息或业务 ID 当标签。对重复故障可以限频,但必须保留计数和代表样本。
线上再出故障时,沿现象一步一步往回查
日志只剩“执行失败”,看不到最初超时
先找到第一处跨层包装,检查构造器是否传入 cause;再看日志调用是否把 throwable 作为异常参数,而不是只格式化 getMessage()。如果失败跨过线程池或消息队列,还要确认传递的是稳定错误信息和关联 ID,而不只是字符串。修复顺序是先恢复因果链,再补安全上下文。不要让每一层都打印一遍堆栈,那只会把同一现场复制四份。
主异常看起来正常,连接占用却持续增长
先确认连接、流和临时文件是否都进入 try-with-resources,再查看关闭顺序和主异常的 getSuppressed()。将关闭失败样本与连接池等待指标、线程 dump 放到同一时间线上,就能判断 SQL 失败后归还连接是否又失败。若采集系统不展示 suppressed,先修采集链。单纯扩大连接池只会延后耗尽,第二现场仍然存在。
发布停止后,后台任务迟迟不退出
从收到停机信号的时间点开始查,定位任务卡在哪个阻塞点。随后搜索空 catch、捕获 InterruptedException 后没有恢复状态,以及恢复状态后仍继续循环的代码。修复后不能只手工重启观察一次,要在测试里发出取消信号,在明确期限内断言任务退出、子任务被取消、资源释放,证明中断确实从生命周期所有者传播到了执行线程。
下游只是短暂抖动,请求量却突然翻倍
先不要继续加限流,逐层统计 HTTP 客户端、仓储、任务调度和消息系统各尝试了几次,再计算最坏乘积。接着检查每层是否重新开始完整超时、有没有退避抖动,以及重复请求靠什么幂等。最终只保留一个能看见完整操作的重试所有者,其余层只传播可分类失败。用故障注入再次测总调用数,才能证明放大链已经拆掉。
测试要证明失败后的系统状态
异常测试不应只断言“抛了某类型”。跨层翻译要断言稳定类型/错误码、cause 类型与安全消息;资源测试同时制造业务失败和关闭失败,断言主异常与 suppressed 顺序;取消测试从另一个线程发出中断,在期限内断言任务退出且中断状态按契约传播。
事务集成测试应在真实事务管理配置下触发 checked、unchecked 和捕获后返回等路径,随后从独立连接验证数据是否提交。重试测试使用确定性故障桩或故障注入记录总调用数、退避时间和截止预算,并覆盖“下游已执行但响应丢失”的不确定结果。协议测试验证内部异常不会把堆栈、类名、SQL 或隐私字段暴露给外部。
未捕获异常也要测试:后台 worker 退出后是否被监督器发现,关键线程消失是否使健康检查失败,进程级严重错误是否会被宽泛 catch 伪装为可用。评审真正要看的,是失败以后系统处在什么状态,而不是单元测试覆盖了多少异常分支。
错误治理可以落成一张所有权与验收表,避免每层都以“为了排障”为理由重复处理:
| 动作 | 唯一所有者 | 自动化证据 | 通过条件 |
|---|---|---|---|
| 异常翻译 | 跨实现边界的适配器 | 类型、错误码、cause 断言 | 厂商类型不外泄,原始 cause 可追溯 |
| 资源清理 | 获得资源的生命周期边界 | 主异常与 suppressed 顺序 | 业务失败不被关闭失败覆盖,资源最终释放 |
| 中断/取消 | 创建任务或并发范围的边界 | 有上界的退出测试 | 截止时间内退出,无遗留子任务,信号未被吞掉 |
| 重试 | 看得见幂等与总预算的边界 | 故障注入与总调用计数 | 实际尝试数不超过登记上限,超时预算不被逐层重置 |
| 完整日志 | 最外责任边界 | 同一关联 ID 的堆栈事件数 | 完整 cause/suppressed 只记录一次,其余层只增加结构化上下文 |
| 事务结果 | 事务边界 | 独立连接读取最终状态 | 提交/回滚与失败分类一致,远程副作用有幂等或补偿状态 |
重试放大尤其要计算而不是凭感觉:若三层分别最多尝试 a、b、c 次,最坏下游调用数是 a × b × c。开头的三个“三次”得到 27 次。治理目标不是统一规定只能重试一次,而是让乘积退化为单一所有者的上限,并在故障注入中观测到相同结果。
异步边界会切断自然栈展开
任务提交到线程池、CompletableFuture、消息队列或响应式流后,生产者调用栈通常已经结束。消费端失败不会沿原调用栈自动回到提交者,往往被包装为 ExecutionException / CompletionException,交给回调,或只进入未捕获异常处理器。设计异步 API 时必须同时定义成功值、失败通道、取消传播和无人消费失败的监督策略,不能只返回一个“稍后完成”的句柄。
包装异步失败仍应保留 cause,但原始提交栈与执行栈是两段证据。关联 ID、任务类型和提交阶段应在安全上下文中显式传递;不要为了得到连续堆栈而长期保存巨大请求对象。批量并发任务还要决定“一个子任务失败是否取消兄弟任务、等待全部结果还是收集部分成功”,这个所有权属于创建并发范围的边界,而不是任一底层任务。
消息消费比线程池多一个持久化协议。处理异常后是否确认、重投、延迟还是进入死信,必须由失败分类和消费幂等性共同决定。若业务拒绝也无限重投,会形成毒消息循环;若暂时故障直接确认,会永久丢失工作;若在数据库提交前确认,进程崩溃会造成消息已消费而状态未落地。异常只是输入,最终一致性仍靠确认时机、事务消息/outbox、去重和人工接管状态表达。
结构化并发或虚拟线程能让任务生命周期更清晰、阻塞成本更低,但不会替团队自动决定错误策略。并发范围仍要定义首个失败、截止时间、取消兄弟任务和聚合多个失败的规则;多个子任务同时失败时,必须选定主失败并保留其余证据。无论使用何种并发 API,测试都应证明失败后没有遗留任务继续写数据或占用资源。
把错误所有权落实到团队代码
公共接口先定义少量稳定失败族、错误码和调用方动作,厂商异常只留在适配器内部。每一次包装都在测试中断言 cause,资源失败同时断言 suppressed,采集链必须能把两者完整展示。评审 catch 时不问“有没有处理异常”,而是要求作者说明这里负责恢复、翻译、清理、观测还是退出;空 catch、返回假成功和常规业务捕获 Throwable 后继续都无法通过。
取消和重试也要找到明确所有者。中断能够直接传播就直接传播,不能声明时恢复中断位并终止当前任务;重试则登记幂等依据、次数、退避抖动和总超时预算。事务回滚由集成测试证明,远程副作用通过幂等、outbox 或补偿表达,不能期待 Java 异常跨系统撤销。
最后把观测收在最外责任边界:完整堆栈只记录一次,消息经过脱敏,指标保持低基数,重复故障即使限频也保留计数。后台任务发生未捕获异常后,监督器和健康检查必须能发现关键 worker 已经消失。这样需求阶段定义失败语义,编码阶段保留证据,测试阶段证明状态,发布和维护阶段才能据此恢复,而不是在事故中临时猜测。
异常语义不确定时查这些入口
JLS 25 第 11 章:Exceptions:异常种类、checked 分析与运行时处理。Java SE 25 Throwable:cause、suppressed 和栈轨迹。
Java SE 25 AutoCloseable:资源关闭契约。Java SE 25 InterruptedException:阻塞等待与中断语义。
Java 教程:try-with-resources:主异常与 suppressed 的官方示例。
结论
回到开头那次超时事故,修复不应该是再加一个统一异常。驱动异常由适配器保留 cause 并翻译,应用服务拥有事务和重试判断,最外边界只记录一次完整现场并映射协议;关闭失败留在 suppressed,中断沿任务生命周期继续传播。这样下一次下游再抖动,日志里只有一条可追根因的失败,调用次数也不会从 1 放大到 27。错误所有权不是命名规范,而是故障发生后系统还能否保持真实和可控的关键。
