← 返回题目列表

责任链处理器有副作用时如何保证幂等?

高频 困难 第 14 / 27 题 更新于 2026/08/02
责任链模式幂等副作用重试分布式

简化版

责任链中有副作用的处理器要通过业务幂等键、状态机、唯一约束、执行记录和补偿机制保证幂等。校验类节点尽量无副作用,冻结库存、锁券、发消息这类节点要能识别重复执行,避免重试或链路重复调用导致重复扣减、重复发放或重复通知。

详细版

责任链本身只负责组织处理器,不自动保证幂等。只读校验处理器重复执行通常没问题,但副作用处理器重复执行可能造成严重后果。比如 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至少去重

一个实用原则是:链路前半段尽量纯校验,后半段才产生副作用。这样大量非法请求会在副作用前短路,幂等压力更小。

三、业务幂等键是第一道防线

副作用处理器需要一个稳定的业务幂等键,比如 requestIdorderNomessageIduserId + 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 和去重,补偿也要可重试且幂等。责任链负责把节点串起来,幂等负责让节点重复执行时不把业务状态弄坏。