引言
工作流引擎与消息中间件都提供「至少一次」的投递语义,这意味着任何一步都可能被执行多次。业务代码如果假设「只会执行一次」,就会在最不经意的时刻产生重复扣款、重复发货、重复发券。
重试与幂等是同一枚硬币的两面:重试是「允许失败后再次尝试」的机制,幂等是「重复执行不产生额外效果」的保证。只做重试不做幂等,是把故障从「一次失败」放大成「多次副作用」;只做幂等不做重试,是放弃了系统在瞬时故障下自愈的能力。
实践中还有一个经常被忽略的维度:重试本身可能造成雪崩。下游服务过载时,所有上游同时重试会把它彻底打垮,形成「重试风暴」。所以重试策略里必须包含退避、抖动与预算,这三者是保护下游的关键。
本文按「先分类、再策略、后实现」的顺序展开:先讲错误分类(哪些能重试),再讲退避与抖动、重试预算,然后深入幂等键设计与四种幂等实现,接着讲租约、死信、补偿顺序这些工程细节,最后讲并发限流与故障注入。想先看流程侧的上下文,可以从 工作流引擎全景与选型 开始。
目录
- 重试的本质与代价
- 错误分类:可重试与不可重试
- 退避策略与抖动
- 重试预算与熔断
- 幂等键的设计
- 幂等的四种实现方式
- 去重表与唯一索引
- 至少一次与恰好一次的真相
- 租约、心跳与任务超时
- 死信队列与人工处理
- 补偿的顺序与幂等
- 并发控制与限流
- 超时设置的分层
- 重试的可观测
- 故障注入与压测
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 重试的本质与代价
重试的本质是「用时间换成功率」:一次失败可能是网络抖动、下游瞬时过载、连接池耗尽,等一会儿再试往往就成功了。它的代价有四类:
- 延迟增加:重试意味着响应时间变成「首次耗时 + 退避时间 + 重试耗时」。
- 放大流量:下游故障时,重试会让请求量成倍增长。
- 副作用重复:没有幂等保护时,重试会产生重复的业务效果。
- 资源占用:重试中的请求占用连接与线程,可能拖垮调用方自己。
这四类代价决定了重试策略的设计要点:只对「可能自愈的错误」重试,用退避与抖动控制流量放大,用幂等保证副作用只发生一次,用超时上限控制资源占用。
一个常见误区是「重试次数越多越可靠」。实际上重试次数超过 3 到 5 次后,成功率提升微乎其微,而流量放大与延迟增加是确定的。真正需要「重试到成功」的场景(比如对账、发货通知),应该用异步任务或工作流引擎来做,而不是在同步调用链里重试。
2. 错误分类:可重试与不可重试
错误分类是重试策略的第一步,也是最容易做错的一步。
| 类别 | 例子 | 是否重试 |
|---|---|---|
| 网络瞬断 | 连接重置、DNS 超时 | 是 |
| 下游过载 | 429、503、熔断打开 | 是(必须退避) |
| 下游超时 | 读超时、写超时 | 视情况(可能有副作用) |
| 资源耗尽 | 连接池满、线程池满 | 是(但要限流) |
| 参数错误 | 400、字段非法 | 否 |
| 业务拒绝 | 余额不足、黑名单 | 否 |
| 权限错误 | 401、403 | 否 |
| 编程错误 | NPE、类型转换失败 | 否(重试也必失败) |
public class RetryPolicy {
private static final Set<String> RETRYABLE = Set.of(
"ConnectException", "SocketTimeoutException", "HttpClientErrorException$TooManyRequests",
"HttpServerErrorException$ServiceUnavailable", "HttpServerErrorException$BadGateway"
);
public boolean shouldRetry(Throwable t) {
// 业务异常一律不重试
if (t instanceof BusinessException) return false;
return RETRYABLE.contains(t.getClass().getSimpleName());
}
}
用「白名单」而不是「黑名单」是关键的工程决策:默认不重试,只有明确知道可重试的错误才重试。黑名单(「除了这几种都重试」)会在遇到未知错误时无限重试,把问题放大。
超时错误要特别小心:超时意味着「不知道对方有没有执行」,重试可能造成重复副作用。这类错误的重试必须配合幂等键,或者改成「先查询后重试」(查询对方是否已处理)。
3. 退避策略与抖动
退避(Backoff)决定「等多久再试」,抖动(Jitter)决定「这个等待时间是否加随机扰动」。
retry:
max_attempts: 5
initial_interval: 1s
max_interval: 30s
multiplier: 2.0 # 指数退避:1s, 2s, 4s, 8s, 16s
jitter: 0.5 # 抖动系数:实际等待 = 基准 * (1 - 0.5*rand)
non_retryable:
- InvalidArgumentException
- InsufficientBalanceException
三种常见退避模式:
- 固定间隔:每次都等 1 秒。简单,但重试同步率高,容易形成脉冲。
- 指数退避:1、2、4、8、16 秒。快速重试前几次,后面拉长,兼顾成功率与压力。
- 指数退避 + 抖动:在指数退避基础上加随机扰动,打散重试的同步性。
抖动的价值在故障场景下最明显:假设下游挂了 10 秒,1000 个客户端如果都用「固定 1 秒重试」,它们会在同一时刻同时重试,把刚恢复的下游再次打垮。加上抖动后,重试被打散在一个时间窗口内。
抖动的实现方式有几种(AWS 的《Exponential Backoff and Jitter》里有详细分析),最实用的是「全抖动」:等待时间 = random(0, min(max_interval, initial * multiplier^attempt))。
import random, time
def backoff_sleep(attempt: int, initial=1.0, multiplier=2.0, cap=30.0):
base = min(cap, initial * (multiplier ** attempt))
time.sleep(random.uniform(0, base)) # 全抖动
4. 重试预算与熔断
重试预算(Retry Budget)限制「重试请求占总请求的比例」,防止重试放大成雪崩。常见的做法是「重试请求不超过总请求的 10%」:
public class RetryBudget {
private final double maxRatio; // 例如 0.1
private final AtomicLong total = new AtomicLong();
private final AtomicLong retries = new AtomicLong();
public boolean allowRetry() {
long t = total.get();
if (t < 100) return true; // 样本太少时不限制
return (double) retries.get() / t < maxRatio;
}
}
熔断(Circuit Breaker)是重试的上游保护:当失败率超过阈值时,直接拒绝请求(快速失败),而不是让每个请求都去重试。三者配合的顺序是「熔断判断 → 调用 → 失败 → 判断是否可重试 → 判断重试预算 → 退避 → 重试」。
circuit_breaker:
failure_rate_threshold: 50 # 失败率超过 50% 打开
slow_call_rate_threshold: 80 # 慢调用比例超过 80% 打开
sliding_window_size: 100 # 统计窗口
minimum_number_of_calls: 20 # 窗口内至少 20 次调用才判断
wait_duration_in_open_state: 30s # 打开后 30 秒进入半开
permitted_calls_in_half_open: 5 # 半开状态允许 5 次探测
熔断打开时不应该重试(重试必然失败且浪费资源),这一点在很多实现里被忽略。
5. 幂等键的设计
幂等键(Idempotency Key)是「区分两次请求是否代表同一个业务意图」的标识。设计原则是「稳定、唯一、可复现」:
好的幂等键:
order-1001:charge:v1 # 业务单号 + 操作 + 版本
payment:20261007T120000:1001 # 业务域 + 时间窗 + 单号
workflow-run-id + activity-id # 引擎级幂等键
坏的幂等键:
UUID.randomUUID() # 重试时变了,去重失效
System.currentTimeMillis() # 不唯一
requestId(由客户端每次重新生成) # 重试时变了
幂等键的生成责任要明确:如果调用方是外部系统(比如支付网关的回调),幂等键通常由对方提供;如果是内部调用,应该由调用方生成并传递,因为只有调用方知道「这两次请求是不是同一个意图」。
public void charge(String orderId, BigDecimal amount, String idempotencyKey) {
// 幂等键由调用方传入,重试时必须复用同一个
paymentClient.charge(ChargeRequest.builder()
.orderId(orderId).amount(amount)
.idempotencyKey(idempotencyKey)
.build());
}
一个实用的约定是「幂等键 = 业务唯一标识 + 操作名」。比如订单支付操作的幂等键是 orderId + ":charge",这样天然满足「同一个订单的支付只发生一次」。
6. 幂等的四种实现方式
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 唯一索引 | 数据库约束挡住重复插入 | 创建类操作(下单、发券) |
| 状态前置条件 | 只允许从特定状态转移 | 状态变更类(支付、发货) |
| 去重表 | 记录已处理的幂等键 | 通用,尤其适合无状态操作 |
| 业务语义幂等 | 操作本身可重复(SET 而非 ADD) | 覆盖写、置位类操作 |
-- 方式一:唯一索引
INSERT INTO coupon_grant (idem_key, user_id, coupon_id)
VALUES ('order-1001:grant', 42, 7)
ON CONFLICT (idem_key) DO NOTHING;
-- 影响行数为 0 表示已发放,直接返回成功
-- 方式二:状态前置条件
UPDATE orders SET status = 'PAID'
WHERE id = 'order-1001' AND status = 'PENDING';
-- 影响行数为 0 表示已支付或状态不符
-- 方式三:去重表
INSERT INTO idempotency_record (idem_key, result, created_at)
VALUES ('order-1001:charge', '{"txId":"T123"}', NOW())
ON CONFLICT (idem_key) DO NOTHING;
方式四(业务语义幂等)最优雅但适用范围窄:「把订单状态设为已支付」是幂等的,「给用户加 100 积分」不是(需要改成「设置积分为 X」或者带幂等键的「加一次」)。
实践中推荐组合使用:创建类操作用唯一索引,状态变更用前置条件,无状态的外部调用用去重表。
7. 去重表与唯一索引
去重表的设计有四个要点:
CREATE TABLE idempotency_record (
idem_key VARCHAR(191) NOT NULL, -- 注意长度,索引长度限制
biz_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL, -- PROCESSING / SUCCESS / FAILED
result JSON NULL, -- 成功时缓存结果,供重复请求返回
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (idem_key),
KEY idx_expires (expires_at)
);
第一,result 字段让重复请求能返回与首次相同的结果,而不是返回「已处理」这类模糊信息。这对调用方很重要:它需要拿到业务结果(比如支付流水号)。
第二,status = PROCESSING 用于处理「首次请求还在执行中,第二次请求就来了」的情况。此时第二次请求应该返回「处理中」并让调用方稍后重试,而不是并发执行。
第三,必须有清理策略。去重表会无限增长,要按 expires_at 定期删除。保留期通常是「业务上可能重试的最长时间」的 2 到 3 倍,比如 7 天到 30 天。
第四,idem_key 的长度要控制在索引限制内(MySQL InnoDB 单列索引前缀 767 或 3072 字节,取决于配置)。用哈希(比如 SHA-256 后取前 32 位十六进制)可以统一长度,但要评估碰撞概率。
8. 至少一次与恰好一次的真相
分布式系统里的「恰好一次」(Exactly Once)通常是个营销词。准确的表述是:
- 至少一次投递 + 幂等消费 = 恰好一次的效果。这是绝大多数系统的实际实现。
- 真正的恰好一次需要「投递与消费在同一事务里」,只有特定场景能实现(比如 Kafka 的事务性消费写 Kafka)。
Kafka 事务能保证的:消费 offset 与生产消息在同一事务提交
Kafka 事务不能保证的:你的业务数据库写入也在同一事务里
所以「Kafka 事务 + 写 MySQL」仍然需要幂等
理解这一点能避免很多设计错误。当有人问「这个系统是不是恰好一次」时,正确的回答是「投递是至少一次,副作用靠幂等键保证唯一」。
工作流引擎里的情况类似:Temporal 保证 Workflow 逻辑的「恰好一次」(事件历史层面),但 Activity 是至少一次;Camunda 的外部任务在锁超时后会被重新投递,也是至少一次。
9. 租约、心跳与任务超时
外部任务模式与消息消费都涉及「租约」(Lease):Worker 拿到任务后有一段独占时间,超时未完成则任务被释放给其他 Worker。
curl -X POST "http://localhost:8080/engine-rest/external-task/fetchAndLock" \
-d '{"workerId":"w1","maxTasks":5,"asyncResponseTimeout":30000,
"topics":[{"topicName":"charge","lockDuration":60000}]}'
lockDuration=60000 表示租约 60 秒。如果任务执行超过 60 秒,租约过期,任务会被其他 Worker 拉走并再次执行。所以:
- 任务执行时间必须小于租约时间,否则必然重复执行。
- 长任务必须发送心跳(
extendLock)续租。 - 所有任务处理必须幂等,因为租约过期是正常现象。
// 长任务:定期续租
ScheduledExecutorService renewer = Executors.newSingleThreadScheduledExecutor();
renewer.scheduleAtFixedRate(
() -> client.extendLock(taskId, "w1", Duration.ofSeconds(60)),
30, 30, TimeUnit.SECONDS);
心跳与租约的组合还有一个陷阱:网络分区时 Worker 可能「以为自己还持有租约」而继续执行,同时任务已经被别人执行了。所以幂等键不能依赖「我持有租约」这个假设。
10. 死信队列与人工处理
重试耗尽后,任务应该进入死信队列(DLQ),而不是被丢弃或无限重试。
# Kafka 消费者配置
consumer:
max_attempts: 5
backoff: exponential
dead_letter_topic: orders-dlq
dead_letter_headers:
- original-topic
- original-partition
- original-offset
- exception-class
- exception-message
- first-failed-at
- attempt-count
死信消息要带足够的元数据,否则排查时无法定位原始上下文。至少要包含:原始主题与分区、失败次数、首次失败时间、异常类型与消息。
死信的处理流程应该是「可重放、可丢弃、可告警」:
CREATE TABLE dead_letter_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source VARCHAR(64) NOT NULL, -- 来自哪个消费者
payload JSON NOT NULL,
error_class VARCHAR(255) NOT NULL,
error_message TEXT NULL,
attempts INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
关键原则:死信必须有告警,且告警要有人认领。一个无人查看的死信队列等于数据丢失,只是丢失得比较晚。
11. 补偿的顺序与幂等
补偿动作在 Saga 与分布式事务补偿 里有完整讨论,这里只强调与重试相关的两点。
第一,补偿动作本身也必须幂等且可重试。补偿失败的场景比正向操作更多(因为补偿往往发生在系统已经出问题的时候),所以补偿的重试策略应该比正向操作更宽松(更多次数、更长退避)。
第二,补偿的顺序不能依赖重试的顺序。如果补偿 A 和补偿 B 都重试,它们的执行顺序可能因为重试时间不同而变化。设计上应该保证「补偿 A 与补偿 B 无依赖」,或者用编排器串行执行。
// 补偿串行执行,每个补偿独立重试
for (int i = completed; i >= 0; i--) {
Step s = steps.get(i);
retryTemplate.execute(ctx -> {
s.compensate();
return null;
}, ctx -> {
deadLetterRepo.save(new CompensationTask(s, ctx.getLastThrowable()));
return null; // 不抛出,继续下一个补偿
});
}
补偿失败要记录到死信表并继续后续补偿,而不是中断整个回滚流程。
12. 并发控制与限流
重试与并发控制经常冲突:重试增加了瞬时并发,可能把已经过载的下游彻底压垮。
// 信号量限制并发,避免重试把下游打垮
Semaphore permits = new Semaphore(20);
public Result callWithLimit(Request req) {
if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
throw new RejectedException("下游繁忙,稍后重试");
}
try {
return doCall(req);
} finally {
permits.release();
}
}
除了信号量,还有几种限流手段配合使用:令牌桶(限制速率)、并发数上限(限制同时执行数)、队列长度上限(超过则拒绝而不是排队)、以及按下游分组的隔离(不同下游互不影响)。
Airflow 里的「池」(Pool)就是这种并发控制的实现,参考 Airflow DAG 调度体系 里的池与并发控制。工作流引擎里通常用任务队列的 Worker 并发数来控制。
13. 超时设置的分层
超时是重试的前提:没有超时,请求会永远挂着,不会触发重试。超时设置要分层,且内层小于外层:
客户端超时 30s
└─ 网关超时 25s
└─ 服务超时 20s
└─ 数据库超时 5s
└─ 下游调用超时 10s
内层超时必须小于外层,否则外层的超时先触发,内层的错误处理逻辑(比如回滚)根本不会执行。这是最常见的配置错误之一。
# 一个常见的错误配置
gateway:
timeout: 5s
service:
timeout: 10s # 比网关还长,永远没机会执行
超时值的选择要基于 P99 延迟而不是平均值。如果平均延迟 100ms 但 P99 是 2s,超时设 500ms 会让 5% 的请求失败并重试,反而放大流量。
14. 重试的可观测
重试的观测要覆盖四个指标:
retry_attempts_total{operation, attempt} # 每次重试的次数分布
retry_exhausted_total{operation} # 重试耗尽的次数(关键告警)
retry_success_after_retry_total{operation} # 重试后成功的次数(反映重试价值)
operation_duration_seconds{operation, retried} # 重试请求的延迟分布
其中 retry_exhausted_total 是最重要的告警指标,它直接对应「业务失败了」。retry_success_after_retry_total 反映重试的价值:如果这个数字长期接近 0,说明重试的都是不可自愈的错误,重试策略需要调整。
除了指标,日志里要记录每次重试的「尝试序号、错误类型、等待时间」,并且用同一个关联 ID 串起来。这样排查时能看到完整的重试链路。关联 ID 与链路追踪的设计见 工作流可观测与调试 。
15. 故障注入与压测
重试与幂等的代码路径在正常流量下不会被触发,所以必须主动测试。三种手段:
- 单元测试:用 Mock 让下游抛出可重试错误,断言「重试了 N 次」与「幂等键相同」。
- 集成测试:用 Toxiproxy 之类的工具注入延迟与连接重置。
- 混沌工程:在生产环境按比例注入故障,验证系统的整体行为。
@Test
void 重试时复用同一个幂等键() {
List<String> keys = new ArrayList<>();
when(paymentClient.charge(any())).thenAnswer(inv -> {
ChargeRequest req = inv.getArgument(0);
keys.add(req.getIdempotencyKey());
if (keys.size() < 3) throw new SocketTimeoutException("timeout");
return new PaymentResult("T123");
});
service.chargeWithRetry("order-1001", new BigDecimal("100"));
assertThat(keys).hasSize(3);
assertThat(keys).containsOnly("order-1001:charge"); // 三次键相同
}
最后一个断言是这类测试的核心:它验证了「重试时幂等键不变」。这个 bug 在代码评审时很难发现(幂等键通常是内部生成的),只有测试能挡住。
混沌工程的实践可以参考 混沌工程 。
16. 落地路线图
- 第 1 周:梳理所有跨服务调用,标注「是否可重试」与「是否幂等」,找出没有幂等保护的写操作。
- 第 2 周:为所有写操作加上幂等键与去重表,写「重试时键不变」的单元测试。
- 第 3 周:配置重试策略(错误分类、退避、抖动、预算)与熔断。
- 第 4 周:接入死信队列与告警,做一次故障注入演练。
第一周的梳理不要跳过。它常常能发现「三个服务都在重试,但只有一个有幂等保护」这类问题,而这类问题是生产事故的常见根因。
17. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 白名单错误分类 | 默认安全,不会误重试 | 新错误类型需要显式加入 |
| 黑名单错误分类 | 覆盖面广 | 未知错误被无限重试,风险高 |
| 指数退避 + 抖动 | 打散重试,保护下游 | 延迟增加,长尾更明显 |
| 固定间隔重试 | 简单、可预测 | 同步重试形成脉冲 |
| 重试预算 | 防止雪崩 | 高失败率时放弃重试,成功率下降 |
| 唯一索引幂等 | 数据库兜底,最可靠 | 只适用于插入类操作 |
| 去重表幂等 | 通用,可缓存结果 | 需要清理策略与额外写入 |
| 同步重试 | 实现简单 | 阻塞调用方,延迟累积 |
| 异步重试(队列/引擎) | 不阻塞,可长退避 | 需要额外的队列或引擎 |
18. 常见坑清单
- 重试时重新生成幂等键(用 UUID),去重完全失效,重复扣款。
- 用黑名单做错误分类,未知异常被重试十次,把下游打垮。
- 重试不加抖动,下游恢复瞬间被同步重试的流量再次打垮。
- 超时配置内层大于外层,内层的错误处理逻辑永远不会执行。
- 重试次数设成 10 次以上,成功率提升有限但延迟与流量放大明显。
- 熔断打开后仍然重试,每次重试都立即失败,浪费资源与日志。
- 长任务不续租,租约过期后任务被重复执行。
- 去重表的 status 只有成功状态,无法处理「首次请求执行中」的并发场景。
- 死信队列没有告警,消息静默堆积,等于数据丢失。
- 补偿动作本身不幂等,重试补偿造成二次退款;只测正常路径,重试代码从未被执行过。
19. 小结
重试与幂等的设计可以归结为四句话:只对可自愈的错误重试;用退避、抖动与预算控制流量放大;用幂等键保证副作用唯一;用死信队列兜住最终失败。这四条做到了,系统在瞬时故障下的自愈能力会显著提升,而不会把故障放大。
工程上的优先级是:先做幂等(这是正确性问题),再做重试策略(这是可用性问题),最后做熔断与预算(这是稳定性问题)。顺序不能颠倒,因为没有幂等的重试是在制造事故。
如果重试需要跨越很长时间(比如几小时或几天),同步重试不再合适,应该交给工作流引擎,参考 Temporal 与持久化执行 ;如果重试伴随的是跨服务的回滚,则应该结合 Saga 与分布式事务补偿 一起设计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。