责任链处理器有副作用时如何保证幂等?
简化版
责任链中有副作用的处理器要通过业务幂等键、状态机、唯一约束、执行记录和补偿机制保证幂等。校验类节点尽量无副作用,冻结库存、锁券、发消息这类节点要能识别重复执行,避免重试或链路重复调用导致重复扣减、重复发放或重复通知。
详细版
责任链本身只负责组织处理器,不自动保证幂等。只读校验处理器重复执行通常没问题,但副作用处理器重复执行可能造成严重后果。比如 FreezeStockHandler 重复执行会多冻结库存,SendMessageHandler 重复执行会发多条消息,CreateOrderHandler 重复执行会创建两笔订单。
工程上通常做 5 件事:第一,请求带业务幂等键;第二,副作用节点写执行记录;第三,数据库层加唯一约束;第四,处理器按状态机推进;第五,异常和重试时先查状态再执行。面试回答时要强调幂等属于处理器契约和业务状态设计,不是责任链自动提供的能力。
完整版教学
一、为什么责任链会放大幂等问题
责任链把一个请求拆成多个节点,节点越多,重复执行的可能性越高。比如请求超时后调用方重试一次,整条链会再跑一遍;链容器异常重试某个节点,也可能让中间节点执行两次;消息消费场景下,同一消息可能被投递多次。
第一次:
Auth -> Risk -> FreezeStock(success) -> CreateOrder(timeout)
重试:
Auth -> Risk -> FreezeStock(again?) -> CreateOrder(again?)
如果 FreezeStock 没有幂等,重试会多冻结一次库存。假设订单需要 2 件商品,重复冻结后就变成 4 件,库存数据直接错。责任链让流程更清晰,但副作用节点的幂等必须单独设计。
易错点:责任链解决职责拆分,不解决重复请求、重复投递和副作用幂等。
二、先区分无副作用节点和有副作用节点
不是所有处理器都需要同等级幂等。参数校验、权限校验、风控查询通常是无副作用或弱副作用;库存冻结、优惠券锁定、订单创建、消息发送是强副作用。设计链路时应该把副作用节点标出来。
| 节点 | 是否有副作用 | 幂等要求 |
|---|---|---|
AuthHandler | 通常无 | 可重复执行 |
ParamHandler | 无 | 可重复执行 |
RiskHandler | 可能写风控日志 | 日志可去重 |
FreezeStockHandler | 有 | 必须幂等 |
CreateOrderHandler | 有 | 必须幂等 |
SendMessageHandler | 有 | 至少去重 |
一个实用原则是:链路前半段尽量纯校验,后半段才产生副作用。这样大量非法请求会在副作用前短路,幂等压力更小。
三、业务幂等键是第一道防线
副作用处理器需要一个稳定的业务幂等键,比如 requestId、orderNo、messageId、userId + skuId + submitToken。这个键必须在重试时保持不变,否则系统无法识别“这是同一次业务请求”。
record OrderContext(
String requestId,
String userId,
String skuId,
int count
) {}
幂等键示例:
order_submit:{requestId}
stock_freeze:{requestId}:{skuId}
coupon_lock:{requestId}:{couponId}
如果第一次请求的 requestId=abc,重试仍然是 abc,处理器就能查到执行记录并返回已有结果。如果每次重试都生成新 ID,幂等机制就会失效。
四、唯一约束和执行记录要兜住并发
单靠内存判断不可靠,因为多个请求可能并发到达,或者服务有多个实例。关键副作用要用数据库唯一约束、Redis 原子操作或业务状态机兜住。比如订单表对 request_id 加唯一索引,库存冻结记录对 request_id + sku_id 加唯一索引。
create unique index uk_order_request on orders(request_id);
create unique index uk_stock_freeze on stock_freeze(request_id, sku_id);
并发请求 2 个:
请求 A insert request_id=abc 成功
请求 B insert request_id=abc 触发唯一约束 -> 查询已有结果 -> 返回
这种设计比“先查再插”更稳,因为先查再插有竞态。唯一约束把并发冲突交给存储层原子保证,再由代码处理重复键异常。
五、处理器要按状态机推进
有副作用的链路通常不只是成功和失败两个状态。比如订单提交可以有 INIT -> STOCK_FROZEN -> COUPON_LOCKED -> ORDER_CREATED -> DONE。重试时先查当前状态,再从缺失的下一步继续执行,而不是从头盲跑。
INIT
-> STOCK_FROZEN
-> COUPON_LOCKED
-> ORDER_CREATED
-> DONE
如果第一次执行到 COUPON_LOCKED 后超时,第二次重试查询状态发现库存已冻结、优惠券已锁定,就应该从 CreateOrder 继续,而不是再次冻结库存和锁券。状态机让重试变成“推进未完成步骤”,而不是“重复整条链”。
六、发消息类副作用要用 outbox 或消息去重
发消息是责任链里常见副作用,比如订单创建后发送通知。直接在处理器里调用 MQ 发送,可能出现数据库提交成功但消息发送失败,或者发送成功但本地返回超时后重复发送。更稳的做法是 outbox:本地事务里写业务数据和消息表,再由后台任务投递消息。
本地事务:
创建订单 + 写 outbox(message_id=orderNo:eventType)
异步投递:
扫描 outbox -> 发送 MQ -> 标记已发送
消费者:
按 message_id 去重消费
这样即使链路重试,message_id 唯一约束也能防止重复消息记录。消费者再按 message_id 去重,形成发送端和消费端双重幂等。
七、幂等还要配合补偿和观测
幂等保证重复执行不产生额外副作用,但不代表失败后不用处理。比如库存冻结成功、优惠券锁定失败,需要释放库存;释放库存本身也要幂等。每个副作用处理器最好都有执行记录和补偿记录,便于排查。
| 记录类型 | 关键字段 | 用途 |
|---|---|---|
| 执行记录 | requestId, handler, status | 判断是否重复执行 |
| 业务记录 | orderNo, status | 状态机推进 |
| 补偿记录 | requestId, compensateStatus | 失败后重试补偿 |
| 观测日志 | traceId, handler, decision | 排查链路 |
假设补偿任务失败 3 次,就要能在监控里看到 compensation_failed_total 增加,并通过 requestId 找到对应链路执行记录。
八、常见误区与追问
- 误区:处理器无状态就天然幂等。 处理器对象无状态只解决线程安全,不代表它调用的数据库、库存、消息没有副作用。
- 误区:加分布式锁就解决幂等。 锁只能减少并发窗口,不能替代唯一约束、状态机和重复请求结果查询。
- 误区:失败后整条链重跑最简单。 对有副作用节点的链路,重跑必须先查状态,否则会重复执行副作用。
- 追问:幂等键怎么选? 选择能代表同一次业务意图且重试不变的键,如请求号、订单号、消息 ID。
- 追问:唯一约束冲突后怎么办? 查询已有业务结果并返回,或者根据状态继续推进未完成步骤。
- 追问:发消息重复怎么处理? 发送端用 outbox 和唯一消息 ID,消费端按消息 ID 做去重。
九、加强记忆
责任链副作用幂等记住“键、表、状态、消息、补偿”:稳定幂等键识别同一次请求,唯一约束和执行记录兜住并发,状态机让重试从正确位置继续,消息用 outbox 和去重,补偿也要可重试且幂等。责任链负责把节点串起来,幂等负责让节点重复执行时不把业务状态弄坏。