幂等重试与补偿
1. 这是什么
幂等、重试和补偿是分布式系统中保证业务安全和稳定的重要机制。
它们通常不是单独存在,而是一起出现。
一句话理解:
- 幂等解决“重复来一次会不会出事”
- 重试解决“失败后要不要再试一次”
- 补偿解决“前面做了一半,现在怎么把系统拉回正确状态”
2. 为什么重要
系统跨服务、跨网络、跨中间件时,失败是常态。
如果没有这三种意识,就会很容易出现:
- 重复扣款
- 重复发货
- 消息重放导致业务多次执行
- 一个步骤成功、另一个步骤失败后数据半吊子
所以它们不是“高级补充知识”,而是分布式系统的日常基础能力。
3. 先建立直觉:这三件事解决的是不同问题
很多人会把它们混成一件事。
先用最简单的方式区分:
| 能力 | 解决的问题 |
|---|---|
| 幂等 | 同一请求重复执行是否仍然安全 |
| 重试 | 临时失败后是否还值得再尝试 |
| 补偿 | 不能原子完成的一串动作,失败后怎么回滚业务影响 |
这张表很重要,因为:
- 有重试没幂等,会把问题放大
- 有幂等没补偿,复杂流程出错后仍可能状态不一致
4. 核心内容
4.1 什么是幂等
幂等可以先这样理解:
- 同一个请求执行一次和执行多次,结果语义一致
典型场景:
- 重复支付回调
- 消息重复消费
- 前端重复点击提交
常见落点包括:
- 业务唯一键
- 请求唯一号
- 幂等表
- 状态机约束
4.2 为什么重试不是无脑重放
重试适合的是:
- 临时性失败
- 可恢复失败
例如:
- 网络抖动
- 短时超时
- 临时不可用
不适合的情况:
- 参数非法
- 业务约束失败
- 明确不可恢复错误
所以重试必须有边界,例如:
- 最大次数
- 超时时间
- 退避策略
4.3 补偿是在解决什么问题
当一个业务流程不能用数据库事务一次包住时,补偿就变得很重要。
例如:
- 扣减余额成功
- 下游发货失败
这时就需要考虑:
- 是否要把余额加回去
- 是否要发补偿消息
所以补偿不是“技术回滚”,而是:
- 业务语义上的纠正动作
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 IdempotentRetryCompensateDemo7.3 你应该观察到什么
输出应包含这些关键信息:
text
firstProcessResult=success
duplicateProcessResult=duplicate-ignored
successStatus=DONE
successRetryAttempts=3
failProcessResult=compensated
failStatus=CANCELED
compensateCalled=true
failRetryAttempts=37.4 每一行在验证什么
duplicateProcessResult=duplicate-ignored:说明相同请求号不会重复执行业务successRetryAttempts=3:说明远程调用失败后进行了有限重试failProcessResult=compensated:说明当流程最终无法成功时,进入了补偿路径failStatus=CANCELED:说明补偿的本质是把业务状态纠正回安全状态compensateCalled=true:说明补偿动作确实被触发了
8. 练习建议
下面这些练习做完,这一章会更扎实:
- 设计一个防重复提交方案
- 设计一个消息重复消费仍然安全的流程
- 用状态机思路梳理一次补偿流程
- 总结哪些错误适合重试,哪些错误不该重试
9. 自测问题
- 幂等为什么是分布式系统里的高频要求?
- 重试策略为什么必须有边界?
- 补偿机制适合解决什么类型的问题?
- 为什么说“有重试没幂等”反而更危险?
- 最终一致性为什么通常离不开幂等和补偿?
10. 自测核对要点
如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:
- 幂等解决重复执行安全问题
- 重试只适合部分临时失败场景,而且必须有边界
- 补偿用于无法原子完成的分布式流程兜底
- 状态机和业务唯一键是幂等设计的重要抓手
- 最终一致性通常依赖幂等、重试、补偿共同配合