责任链模式中异常、短路和补偿应该怎么处理?
简化版
责任链里要区分业务短路、系统异常和需要补偿的副作用。业务拒绝应返回明确结果,系统异常应统一捕获和上报,已经执行过副作用的处理器要有补偿策略或放到事务边界内,不能把所有情况都用异常或 return false 混在一起。
详细版
责任链的异常处理难点在于链上有多个节点,每个节点可能校验、修改上下文、调用外部服务或产生副作用。校验失败通常是业务短路,比如鉴权失败、风控拒绝;数据库连接失败、远程服务超时才是系统异常;库存冻结、优惠券锁定后失败则需要补偿或事务回滚。
面试回答时可以说:先定义统一结果模型,比如 PASS/STOP/ERROR;再由链容器统一捕获异常和记录节点;对有副作用的处理器,要么放到数据库事务里,要么实现 compensate,并按执行记录逆序补偿。不要让每个处理器各自吞异常,也不要把业务拒绝当系统异常打满报警。
完整版教学
一、先把短路和异常分清楚
责任链中“停止继续执行”不一定是异常。比如未登录、参数非法、风控拒绝,都是可预期业务结果,应该返回明确的 STOP。真正异常是系统无法正常完成处理,比如数据库不可用、缓存连接失败、远程服务超时。两者如果混在一起,监控会失真,调用方也不知道该提示用户还是重试。
PASS: 当前节点通过,继续下一个
STOP: 业务上拒绝或拦截,不再继续
ERROR: 系统异常,链路失败,需要告警或重试
举个数字例子:一天 10000 次下单里,风控拒绝 300 次是正常业务;如果这 300 次都抛异常并进入错误监控,错误率会被误报为 3%。真正需要报警的是数据库异常 5 次,这类错误率 0.05% 才是系统健康信号。
易错点:短路是链路语义,异常是故障语义,二者不能用同一个布尔值糊在一起。
二、用统一结果模型表达链路决策
处理器最好返回统一结果,至少包含状态、错误码、信息和是否继续。这样链容器可以统一判断下一步,不需要猜每个处理器的私有约定。比如 RiskHandler 返回 STOP(RISK_REJECT),链容器知道要停止并返回业务拒绝。
record ChainResult(Status status, String code, String message) {
static ChainResult pass() {
return new ChainResult(Status.PASS, "OK", "pass");
}
static ChainResult stop(String code, String message) {
return new ChainResult(Status.STOP, code, message);
}
static ChainResult error(String code, String message) {
return new ChainResult(Status.ERROR, code, message);
}
}
| 状态 | 是否继续 | 是否告警 | 示例 |
|---|---|---|---|
PASS | 是 | 否 | 参数校验通过 |
STOP | 否 | 通常否 | 风控拒绝 |
ERROR | 否 | 通常是 | DB 连接失败 |
三、链容器统一捕获异常更稳定
如果每个处理器都自己 try-catch,很容易出现有的吞异常、有的包装异常、有的重复打日志。更稳的做法是链容器统一包住每次处理器调用,记录当前处理器名称、耗时、异常类型,然后转成统一错误结果或继续抛给上层。
class HandlerChain {
ChainResult invoke(OrderContext context, int index) {
if (index >= handlers.size()) {
return ChainResult.pass();
}
Handler handler = handlers.get(index);
long start = System.nanoTime();
try {
ChainResult result = handler.handle(context, () -> invoke(context, index + 1));
observe(handler.name(), result, start);
return result;
} catch (Exception ex) {
observeError(handler.name(), ex, start);
return ChainResult.error("CHAIN_ERROR", handler.name() + " failed");
}
}
}
这里不是说处理器内部永远不能捕获异常。处理器可以捕获它能恢复的异常,比如缓存读取失败后降级到数据库;但无法恢复的异常应交给链容器统一处理。这样链路错误出口只有一个。
四、有副作用的处理器要考虑事务边界
责任链前半段最好是纯校验或只读查询,后半段才放副作用操作。比如 Auth -> Param -> Risk 只读,FreezeStock -> LockCoupon -> CreateOrder 有副作用。如果副作用节点排得太靠前,后面失败时补偿成本会增加。
推荐:
Auth -> Param -> Risk -> FreezeStock -> LockCoupon -> CreateOrder
不推荐:
FreezeStock -> Auth -> Risk -> CreateOrder
如果所有副作用都在同一个数据库事务内,可以依赖事务回滚。但很多真实链路会调用外部系统,比如库存服务、优惠券服务、支付渠道,这些操作不在一个本地事务里,就必须考虑补偿。责任链本身不自动提供事务能力。
五、补偿要按已执行节点逆序进行
如果链上执行了 FreezeStock 和 LockCoupon,随后 CreateOrder 失败,补偿顺序通常应该反过来:先释放优惠券,再释放库存。因为后执行的动作可能依赖先执行动作的状态,逆序补偿能更接近栈式资源释放模型。
正向执行:
FreezeStock -> LockCoupon -> CreateOrder(fail)
逆序补偿:
UnlockCoupon -> UnfreezeStock
interface CompensableHandler extends Handler {
void compensate(OrderContext context);
}
链容器可以维护 executedHandlers 列表,只把已经成功执行并声明可补偿的处理器放进去。失败时逆序调用 compensate。补偿本身也可能失败,所以要记录补偿日志、支持重试,并保证补偿接口幂等。
六、补偿不是万能,能前置校验就前置校验
补偿会增加复杂度。比如库存冻结后再发现参数非法,就需要释放库存;但参数校验本来可以放在冻结之前,这个补偿完全可以避免。设计责任链顺序时,要把便宜、确定、无副作用的校验放前面,把昂贵、有副作用、不可逆的动作放后面。
| 节点类型 | 推荐位置 | 原因 |
|---|---|---|
| 参数校验 | 前面 | 便宜且无副作用 |
| 权限校验 | 前面 | 失败概率高,能早停 |
| 风控查询 | 中间 | 可能依赖用户和参数 |
| 库存冻结 | 后面 | 有副作用 |
| 发消息 | 最后或事务后 | 常需异步和幂等 |
假设参数非法占 8%,未登录占 5%,库存不足占 2%。把参数和登录放前面,可以让 13% 的请求在副作用前停止,显著减少补偿压力。
七、异常策略要和重试策略配套
链路异常不是都能重试。参数错误重试 100 次也不会成功;网络超时可能适合重试;库存冻结成功但响应超时则需要先查状态再决定是否补偿。责任链结果模型最好能表达错误类型,让上层或任务框架决定重试策略。
PARAM_INVALID -> 不重试
RISK_REJECT -> 不重试
REMOTE_TIMEOUT -> 可重试或查状态
DB_DOWN -> 延迟重试并告警
COMPENSATION_FAILED -> 进入补偿任务表
在面试中,能说出“业务拒绝不重试、系统异常按类型重试、有副作用操作必须幂等和查状态”,会比只说 try-catch-finally 更接近工程实践。
八、常见误区与追问
- 误区:责任链失败就统一抛异常。 业务拒绝是预期结果,应该用明确状态表达,避免污染错误监控。
- 误区:每个处理器自己处理异常更灵活。 分散处理会导致日志、错误码和告警策略不一致,链容器应统一兜底。
- 误区:有事务就不用考虑补偿。 本地事务只能覆盖同库操作,跨服务副作用仍然需要补偿、查状态和幂等。
- 追问:补偿为什么要逆序? 后执行动作可能依赖前置动作,逆序更符合资源释放和状态回退逻辑。
- 追问:短路后后置逻辑还执行吗? 取决于链的模型,过滤器式链可能执行外层 after,纯处理链通常直接停止;必须在契约中写清楚。
- 追问:补偿失败怎么办? 记录补偿任务表,保证补偿接口幂等,交给定时任务或消息重试继续处理。
九、加强记忆
责任链异常治理记住“三分法”:业务短路用 STOP,系统故障用 ERROR,副作用失败用补偿或事务回滚。再记住一个顺序原则:便宜无副作用的校验放前面,昂贵有副作用的动作放后面,补偿按成功执行节点逆序做。这样回答既能讲模式,也能讲上线后的稳定性。