Skip to content

幂等重试与补偿

1. 这是什么

幂等、重试和补偿是分布式系统中保证业务安全和稳定的重要机制。
它们通常不是单独存在,而是一起出现。

一句话理解:

  • 幂等解决“重复来一次会不会出事”
  • 重试解决“失败后要不要再试一次”
  • 补偿解决“前面做了一半,现在怎么把系统拉回正确状态”

2. 为什么重要

系统跨服务、跨网络、跨中间件时,失败是常态。
如果没有这三种意识,就会很容易出现:

  • 重复扣款
  • 重复发货
  • 消息重放导致业务多次执行
  • 一个步骤成功、另一个步骤失败后数据半吊子

所以它们不是“高级补充知识”,而是分布式系统的日常基础能力。

3. 先建立直觉:这三件事解决的是不同问题

很多人会把它们混成一件事。
先用最简单的方式区分:

能力解决的问题
幂等同一请求重复执行是否仍然安全
重试临时失败后是否还值得再尝试
补偿不能原子完成的一串动作,失败后怎么回滚业务影响

这张表很重要,因为:

  • 有重试没幂等,会把问题放大
  • 有幂等没补偿,复杂流程出错后仍可能状态不一致

4. 核心内容

4.1 什么是幂等

幂等可以先这样理解:

  • 同一个请求执行一次和执行多次,结果语义一致

典型场景:

  • 重复支付回调
  • 消息重复消费
  • 前端重复点击提交

常见落点包括:

  • 业务唯一键
  • 请求唯一号
  • 幂等表
  • 状态机约束

4.2 为什么重试不是无脑重放

重试适合的是:

  • 临时性失败
  • 可恢复失败

例如:

  • 网络抖动
  • 短时超时
  • 临时不可用

不适合的情况:

  • 参数非法
  • 业务约束失败
  • 明确不可恢复错误

所以重试必须有边界,例如:

  • 最大次数
  • 超时时间
  • 退避策略

4.3 补偿是在解决什么问题

当一个业务流程不能用数据库事务一次包住时,补偿就变得很重要。
例如:

  1. 扣减余额成功
  2. 下游发货失败

这时就需要考虑:

  • 是否要把余额加回去
  • 是否要发补偿消息

所以补偿不是“技术回滚”,而是:

  • 业务语义上的纠正动作

4.4 最终一致性怎么理解

很多跨系统流程很难做到强一致。
现实中更常见的目标是:

  • 最终一致性

也就是:

  • 某个时间点可能暂时不一致
  • 但通过重试、补偿、异步处理,最终会收敛到正确状态

4.5 状态机为什么很重要

很多幂等和补偿问题,最后都会落回状态机设计。

例如订单状态:

  • CREATED
  • PAID
  • DELIVERED
  • CANCELED

如果状态转移规则足够清楚,就能有效避免:

  • 重复执行
  • 非法回退
  • 补偿混乱

5. 学习重点

这一章最重要的是掌握:

  • 重试不是无脑再来一次
  • 幂等通常要落到业务唯一标识或状态机
  • 补偿是无法原子完成流程时的业务兜底
  • 最终一致性依赖重试、幂等、补偿一起工作

6. 常见问题

6.1 有重试没幂等

这会让本来只是临时失败的问题,变成重复执行的事故。

6.2 把所有失败都立即重试

有些错误重试 100 次也没意义,还会放大系统压力。

6.3 没有补偿手段就设计跨系统流程

这类设计在出问题时往往只能人工修数据。

7. 动手验证

这一节我用纯 Java 做一个最小实验,把三件事放到同一段代码里观察。

7.1 准备一个可运行示例

新建文件 IdempotentRetryCompensateDemo.java,内容如下:

java
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class IdempotentRetryCompensateDemo {
    static class OrderService {
        private final Set<String> processedRequestIds = new HashSet<>();
        private final Map<String, String> orderStatus = new HashMap<>();
        private boolean compensateCalled = false;
        private int remoteAttempt = 0;

        String process(String requestId, String orderId) {
            if (processedRequestIds.contains(requestId)) {
                return "duplicate-ignored";
            }

            processedRequestIds.add(requestId);
            orderStatus.put(orderId, "PAID");

            boolean remoteSuccess = retryRemoteCall(3);
            if (!remoteSuccess) {
                compensate(orderId);
                return "compensated";
            }

            orderStatus.put(orderId, "DONE");
            return "success";
        }

        private boolean retryRemoteCall(int maxAttempts) {
            for (int i = 1; i <= maxAttempts; i++) {
                remoteAttempt++;
                if (remoteAttempt >= 3) {
                    return true;
                }
            }
            return false;
        }

        private void compensate(String orderId) {
            compensateCalled = true;
            orderStatus.put(orderId, "CANCELED");
        }

        String processWithAlwaysFail(String requestId, String orderId) {
            if (processedRequestIds.contains(requestId)) {
                return "duplicate-ignored";
            }

            processedRequestIds.add(requestId);
            orderStatus.put(orderId, "PAID");

            boolean remoteSuccess = false;
            for (int i = 0; i < 3; i++) {
                remoteAttempt++;
            }
            if (!remoteSuccess) {
                compensate(orderId);
                return "compensated";
            }
            return "success";
        }

        String getStatus(String orderId) {
            return orderStatus.get(orderId);
        }

        boolean isCompensateCalled() {
            return compensateCalled;
        }

        int getRemoteAttempt() {
            return remoteAttempt;
        }
    }

    public static void main(String[] args) {
        OrderService successCase = new OrderService();
        String first = successCase.process("req-1", "order-1");
        String duplicate = successCase.process("req-1", "order-1");
        System.out.println("firstProcessResult=" + first);
        System.out.println("duplicateProcessResult=" + duplicate);
        System.out.println("successStatus=" + successCase.getStatus("order-1"));
        System.out.println("successRetryAttempts=" + successCase.getRemoteAttempt());

        OrderService failCase = new OrderService();
        String failResult = failCase.processWithAlwaysFail("req-2", "order-2");
        System.out.println("failProcessResult=" + failResult);
        System.out.println("failStatus=" + failCase.getStatus("order-2"));
        System.out.println("compensateCalled=" + failCase.isCompensateCalled());
        System.out.println("failRetryAttempts=" + failCase.getRemoteAttempt());
    }
}

7.2 编译并运行

bash
javac IdempotentRetryCompensateDemo.java
java IdempotentRetryCompensateDemo

7.3 你应该观察到什么

输出应包含这些关键信息:

text
firstProcessResult=success
duplicateProcessResult=duplicate-ignored
successStatus=DONE
successRetryAttempts=3
failProcessResult=compensated
failStatus=CANCELED
compensateCalled=true
failRetryAttempts=3

7.4 每一行在验证什么

  • duplicateProcessResult=duplicate-ignored:说明相同请求号不会重复执行业务
  • successRetryAttempts=3:说明远程调用失败后进行了有限重试
  • failProcessResult=compensated:说明当流程最终无法成功时,进入了补偿路径
  • failStatus=CANCELED:说明补偿的本质是把业务状态纠正回安全状态
  • compensateCalled=true:说明补偿动作确实被触发了

8. 练习建议

下面这些练习做完,这一章会更扎实:

  • 设计一个防重复提交方案
  • 设计一个消息重复消费仍然安全的流程
  • 用状态机思路梳理一次补偿流程
  • 总结哪些错误适合重试,哪些错误不该重试

9. 自测问题

  • 幂等为什么是分布式系统里的高频要求?
  • 重试策略为什么必须有边界?
  • 补偿机制适合解决什么类型的问题?
  • 为什么说“有重试没幂等”反而更危险?
  • 最终一致性为什么通常离不开幂等和补偿?

10. 自测核对要点

如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:

  • 幂等解决重复执行安全问题
  • 重试只适合部分临时失败场景,而且必须有边界
  • 补偿用于无法原子完成的分布式流程兜底
  • 状态机和业务唯一键是幂等设计的重要抓手
  • 最终一致性通常依赖幂等、重试、补偿共同配合