拿到锁后业务失败应该如何补偿?
简化版
拿到锁只保证临界区互斥,不保证业务成功或自动回滚。失败后应先用本地事务保存业务状态与补偿任务,再按幂等状态机重试、反向补偿或人工对账;释放锁前必须把“接下来由谁恢复”持久化。
详细版
把操作拆成可提交步骤并定义每步的成功事实、幂等键与补偿动作。数据库内的修改优先放进同一本地事务;跨服务步骤使用 Saga、事务消息或 Outbox 推进,失败状态落库后再释放锁,不能只在内存捕获异常。
补偿不是简单执行相反 SQL:退款、库存释放等都要校验当前状态和业务边界。重试采用退避、次数上限与死信队列,永久失败转人工;监控失败阶段、补偿积压、最大滞留时间和最终对账差异。
完整版教学
一、锁与事务解决不同问题
锁回答“此刻谁可以进入”,事务回答“一组写入如何原子提交”,补偿回答“跨边界部分成功后怎样收敛”。持锁期间调用三个系统,第二步失败时,第一步已经提交的事实不会因释放锁而消失。设计必须先画出提交点,而非把锁当成万能回滚器。
易错点:释放锁不是撤销业务;真正的恢复依据必须在锁消失前持久化。
二、先定义状态机与成功事实
以订单履约为例,可定义 INIT -> RESERVED -> PAID -> DONE,失败进入 CANCELING 或 MANUAL。每一步只允许从指定前态迁移,并记录 requestId、版本和外部流水号。这样进程重启后能从状态继续,而不是根据异常消息猜测做到了哪一步。
INIT --reserve--> RESERVED --charge--> PAID --confirm--> DONE
| | |
+------cancel-------+------refund------+--> CANCELED
三、锁内应该持久化什么
同库的业务写与补偿任务可放在一个事务:业务状态更新成功时,同时插入 Outbox/补偿记录。若事务回滚,两者都不存在;若提交成功,即使进程随后宕机,后台扫描器仍能继续。不要在提交后只向内存线程池投递补偿任务。
| 失败位置 | 已有事实 | 恢复方式 |
|---|---|---|
| 本地事务前 | 无 | 直接失败 |
| 本地事务后、远程调用前 | 状态+任务 | 异步继续 |
| 远程超时 | 结果未知 | 查询后幂等重试 |
| 补偿永久失败 | 待处理记录 | 人工对账 |
四、补偿为什么必须幂等
消费者可能重复投递,扫描器也可能与人工按钮同时触发。补偿使用原业务 requestId 派生唯一键,并以条件更新推进状态;退款接口传原支付流水与退款单号,库存释放限定 RESERVED -> RELEASED。重复执行应返回相同结果而非重复产生副作用。
UPDATE order_task SET state='CANCELED', version=version+1
WHERE id=:id AND state='CANCELING' AND version=:version;
五、超时未知不能直接反向操作
远程调用超时只说明没有收到响应,不代表对方没有成功。若支付请求其实成功,立即释放库存可能造成已付款无货;应先用幂等键查询对方结果,再决定前向重试还是补偿。没有查询接口时,要以对账文件或回调作为最终事实来源。
例如调用超时 3 秒,先查询 2 次、间隔 1 秒;仍未知则标记 UNKNOWN,冻结后续相冲突动作并进入延迟核对,而不是武断判失败。
六、重试、人工与观测边界
瞬时网络错误可指数退避重试,参数错误和业务拒绝不应重试。设最大次数和总时间预算,例如 1、2、4、8 秒四次后进入死信;人工处理必须沿同一状态机和幂等接口执行。监控按失败步骤分组,并展示补偿队列长度、最老任务年龄与对账差异。
锁释放异常也不能覆盖原业务异常。使用 finally 尝试安全释放并记录 owner 校验结果,但恢复流程以持久状态为准。
七、常见误区与追问
- 误区:拿到锁后发生异常,数据会自动回滚。 锁没有事务语义。
- 误区:补偿就是执行一条相反 SQL。 外部副作用和状态变化可能不可逆。
- 误区:远程超时等于调用失败。 它属于结果未知,必须查询或对账。
- 误区:补偿任务放内存线程池就够了。 进程宕机会同时丢失任务。
- 追问:何时释放锁? 至少在业务状态和后续恢复任务可靠提交之后。
- 追问:补偿一直失败怎么办? 有界重试后转死信与人工,并保留完整审计。
- 追问:人工重跑如何防重复? 复用幂等键、条件状态迁移和唯一约束。
八、加强记忆
按“锁管并发、事务管本地、状态机管进度、补偿管跨系统”记忆。拿锁后先识别提交点,把业务状态与恢复任务原子落库;超时先查事实,所有重试和补偿都走幂等状态迁移,最后靠死信、人工和对账保证失败最终可见、可收敛。