重试幂等与补偿设计

本文系统讲解工作流里的重试与幂等设计,回答哪些错误该重试、退避与抖动怎么配、幂等键怎么设计、至少一次投递下如何不重复扣款。覆盖可重试与不可重试错误分类、指数退避与重试预算、幂等键与去重表、至少一次与恰好一次、租约与心跳、死信队列、补偿的顺序与幂等、并发限流与故障注入,并给出可运行配置与代码。

引言

工作流引擎与消息中间件都提供「至少一次」的投递语义,这意味着任何一步都可能被执行多次。业务代码如果假设「只会执行一次」,就会在最不经意的时刻产生重复扣款、重复发货、重复发券。

重试与幂等是同一枚硬币的两面:重试是「允许失败后再次尝试」的机制,幂等是「重复执行不产生额外效果」的保证。只做重试不做幂等,是把故障从「一次失败」放大成「多次副作用」;只做幂等不做重试,是放弃了系统在瞬时故障下自愈的能力。

实践中还有一个经常被忽略的维度:重试本身可能造成雪崩。下游服务过载时,所有上游同时重试会把它彻底打垮,形成「重试风暴」。所以重试策略里必须包含退避、抖动与预算,这三者是保护下游的关键。

本文按「先分类、再策略、后实现」的顺序展开:先讲错误分类(哪些能重试),再讲退避与抖动、重试预算,然后深入幂等键设计与四种幂等实现,接着讲租约、死信、补偿顺序这些工程细节,最后讲并发限流与故障注入。想先看流程侧的上下文,可以从 工作流引擎全景与选型 开始。

目录

  1. 重试的本质与代价
  2. 错误分类:可重试与不可重试
  3. 退避策略与抖动
  4. 重试预算与熔断
  5. 幂等键的设计
  6. 幂等的四种实现方式
  7. 去重表与唯一索引
  8. 至少一次与恰好一次的真相
  9. 租约、心跳与任务超时
  10. 死信队列与人工处理
  11. 补偿的顺序与幂等
  12. 并发控制与限流
  13. 超时设置的分层
  14. 重试的可观测
  15. 故障注入与压测
  16. 落地路线图
  17. 权衡取舍
  18. 常见坑清单
  19. 小结

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. 常见坑清单

  1. 重试时重新生成幂等键(用 UUID),去重完全失效,重复扣款。
  2. 用黑名单做错误分类,未知异常被重试十次,把下游打垮。
  3. 重试不加抖动,下游恢复瞬间被同步重试的流量再次打垮。
  4. 超时配置内层大于外层,内层的错误处理逻辑永远不会执行。
  5. 重试次数设成 10 次以上,成功率提升有限但延迟与流量放大明显。
  6. 熔断打开后仍然重试,每次重试都立即失败,浪费资源与日志。
  7. 长任务不续租,租约过期后任务被重复执行。
  8. 去重表的 status 只有成功状态,无法处理「首次请求执行中」的并发场景。
  9. 死信队列没有告警,消息静默堆积,等于数据丢失。
  10. 补偿动作本身不幂等,重试补偿造成二次退款;只测正常路径,重试代码从未被执行过。

19. 小结

重试与幂等的设计可以归结为四句话:只对可自愈的错误重试;用退避、抖动与预算控制流量放大;用幂等键保证副作用唯一;用死信队列兜住最终失败。这四条做到了,系统在瞬时故障下的自愈能力会显著提升,而不会把故障放大。

工程上的优先级是:先做幂等(这是正确性问题),再做重试策略(这是可用性问题),最后做熔断与预算(这是稳定性问题)。顺序不能颠倒,因为没有幂等的重试是在制造事故。

如果重试需要跨越很长时间(比如几小时或几天),同步重试不再合适,应该交给工作流引擎,参考 Temporal 与持久化执行 ;如果重试伴随的是跨服务的回滚,则应该结合 Saga 与分布式事务补偿 一起设计。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数