Skip to content

分布式幂等:请求、消息、任务与状态机全过程

幂等是指同一次业务意图重复执行,最终只产生一次业务效果,并能向重复调用方返回与第一次相容的结果。它不是禁止重复请求,而是承认网络重试、MQ 至少一次投递、第三方回调、任务重跑和补偿重复都会发生,然后用业务唯一键、原子约束、状态机和结果记录保证正确性。

本章以 JDK 8 和关系数据库为基线。Redis、MQ、TCC、Saga 等方案会说明边界,但数据库事实源仍是关键业务的最终约束位置。

一、学习目标

学完后应能:

  1. 区分重复请求、并发请求、重试、消息重复和业务重复。
  2. 设计稳定、可分域、可校验参数的幂等键。
  3. 解释为什么“先查再写”不能防并发。
  4. 使用唯一约束原子争抢首次处理权。
  5. 设计 PROCESSING、SUCCEEDED、FAILED 等幂等状态和租约恢复。
  6. 将幂等记录与业务副作用放入正确事务边界。
  7. 对重复请求复用第一次成功结果,而不是只返回“请勿重复提交”。
  8. 处理相同幂等键但请求参数不同的冲突。
  9. 设计 HTTP、支付回调、MQ 消费、定时任务、TCC 与 Saga 的幂等。
  10. 理解 Redis SETNX、分布式锁和 MQ Exactly Once 的适用边界。
  11. 排查幂等记录卡死、重复扣款、结果丢失、去重表膨胀和热点键。

二、幂等到底要求什么

假设客户端使用 Idempotency-Key=order:create:R1001 创建订单:

text
第一次请求:创建订单 O9001,返回成功
第二次请求:不再创建订单,返回 O9001 的已有结果
第三次请求:仍不产生新订单

幂等强调业务效果一致,不一定要求每次 HTTP 响应字节完全相同。首次可能返回 201,重复请求可能按契约返回 200 或相同 201;关键是不能生成第二张订单,也不能把第一次已成功结果误报为失败。

天然看似幂等的 HTTP 方法也要看实现:GET 若顺便扣次数、DELETE 若触发重复退款、PUT 若每次发送通知,都可能有非幂等副作用。HTTP 方法语义不能替代业务约束。

三、重复从哪里来

来源典型过程风险
用户重复点击两个请求几乎同时到达两张订单、两次提交
客户端超时重试服务已提交但响应丢失第二次重复写
Gateway/HTTP Client 重试中间层认为请求失败原始流量被放大
MQ 至少一次投递业务成功但 ACK/Offset 失败消息再次消费
支付平台重复通知对方直到收到成功响应才停止重复入账、重复发货
Rebalance/消费者宕机已处理但进度未提交同一消息换实例处理
定时任务重跑上次部分成功或人工补跑重复生成账单
TCC/Saga 重试Confirm、Cancel、补偿重复调用重复释放或重复扣减
CDC 重放位点恢复、故障切换ES、缓存、报表重复更新

前端按钮置灰只能降低用户重复点击,无法解决后端、网络和中间件产生的重复。

四、超时为什么让幂等成为必需

mermaid
flowchart TD
    A["调用方发送创建支付请求,业务号 P100"] --> B["支付服务收到请求"]
    B --> C["本地事务创建支付单并提交"]
    C --> D["响应在网络中丢失"]
    D --> E["调用方超时,不知道是否成功"]
    E --> F["使用同一业务号重试或查询"]
    F --> G["服务端返回第一次支付单结果"]

如果调用方每次重试都生成新业务号,服务端无法判断是同一意图还是两次真实支付。幂等键必须在“一次业务意图”开始时生成,并在所有网络重试中保持不变。

五、幂等键怎样设计

一个可靠幂等键至少考虑:

text
租户/用户作用域 + 业务操作 + 稳定业务号 + 协议版本

示例:

text
tenant-10:order:create:R1001:v1
hospital-H01:collect:batch:T900-B20260718:v1
payment-provider-A:callback:trade-7788
consumer-group-search:order-created:event-991
设计点原因
稳定网络重试不能重新生成
有作用域不同租户相同业务号不能互相冲突
包含操作创建、取消不能共用一个键
长度受控可建唯一索引,避免超长 Header 直接入库
不含敏感明文日志、索引和监控可能暴露键
可校验参数相同键不同请求体必须识别为冲突

traceId 不等于幂等键。Trace 可能每次重试都变化,用于追踪一次网络尝试;幂等键标识跨多次尝试的同一业务意图。

六、为什么要保存请求摘要

客户端可能错误地复用同一个幂等键:

text
第一次:key=R100,amount=100
第二次:key=R100,amount=1000

若服务端只看 key,第二次会返回第一次结果,客户端却以为 1000 元请求已执行。应对影响语义的字段做规范化后计算摘要,例如 SHA-256,并保存 request_hash

规范化要保证字段顺序、数字格式、空值和无关字段处理一致。不要直接对原始 JSON 文本哈希,因为相同语义的字段顺序或空格不同会得到不同摘要。

重复请求处理:

  • key 相同且 hash 相同:视为同一业务意图。
  • key 相同但 hash 不同:返回 409 Conflict 或明确业务冲突码。

摘要用于检测冲突,不用于密码存储或签名认证;安全用途还要 HMAC/数字签名。

七、幂等记录状态模型

mermaid
flowchart TD
    A["不存在"] --> B["PROCESSING 处理中"]
    B --> C["SUCCEEDED 已成功"]
    B --> D["FAILED_RETRYABLE 可重试失败"]
    B --> E["FAILED_FINAL 最终失败"]
    D --> B
    B --> F["租约过期,恢复任务确认真实状态"]
    F --> B
    F --> C
    F --> E

建议字段:

字段作用
scope租户、操作或消费者组作用域
idem_key业务幂等键
request_hash检测同键不同参数
statusPROCESSING/SUCCEEDED/FAILED_*
owner_token识别当前处理者,防旧处理者覆盖新结果
lease_until处理中租约,支持宕机恢复
result_code第一次业务结果码
result_body必要的响应快照或结果引用
resource_id已创建订单/支付单 ID
retry_count恢复或重试次数
created_at/updated_at排查和清理依据
version乐观锁和并发状态转换

不要永远只保存一个 processed=true。系统无法区分正在处理、已经成功、可重试失败和永久失败,也无法复用第一次结果。

八、幂等表 SQL 设计

MySQL 示例:

sql
create table t_idempotency_record (
    id bigint not null auto_increment,
    scope varchar(64) not null,
    idem_key varchar(128) not null,
    request_hash char(64) not null,
    status varchar(32) not null,
    owner_token varchar(64) not null,
    lease_until datetime(3) null,
    result_code varchar(64) null,
    result_body text null,
    resource_id varchar(128) null,
    retry_count int not null default 0,
    version int not null default 0,
    created_at datetime(3) not null,
    updated_at datetime(3) not null,
    primary key (id),
    unique key uk_scope_idem (scope, idem_key),
    key idx_status_lease (status, lease_until),
    key idx_created_at (created_at)
) engine=InnoDB default charset=utf8mb4;

唯一约束是并发正确性的最终防线。应用中的 exists 查询只能作为优化或读取状态,不能代替唯一约束。

result_body 不应无界保存巨大响应或敏感信息。可以只保存 resource_id 和稳定结果字段,重复请求再从事实表查询。

九、“先查再写”为什么错误

错误代码:

java
if (!repository.exists(key)) {
    executeBusiness();
    repository.insert(key);
}

并发时:

mermaid
flowchart TD
    A["线程A查询:不存在"] --> C["两个线程都进入业务"]
    B["线程B查询:不存在"] --> C
    C --> D["业务副作用执行两次"]
    D --> E["最后插入记录时才发生唯一键冲突"]

问题是副作用已经发生,最后的唯一键异常来不及撤销外部扣款、HTTP 调用或已发送消息。正确顺序是先通过唯一约束原子获得处理权,再执行业务。

十、原子抢占处理权

一种 MySQL 模式:每次请求生成 owner_token,并尝试插入;发生唯一键时不覆盖原 owner。

sql
insert into t_idempotency_record (
    scope, idem_key, request_hash, status,
    owner_token, lease_until, created_at, updated_at
) values (
    ?, ?, ?, 'PROCESSING', ?,
    date_add(now(3), interval 30 second), now(3), now(3)
)
on duplicate key update idem_key = idem_key;

随后读取记录并比较 owner_token:

  • owner_token 等于当前请求:当前请求拥有处理权。
  • owner_token 不同:这是重复请求,按已有状态处理。

不要仅依赖 affectedRows 判断,因为不同驱动和 CLIENT_FOUND_ROWS 设置可能影响 ON DUPLICATE KEY UPDATE 的行数语义。比较随机 owner_token 更明确。

也可使用数据库特定的 INSERT ... ON CONFLICT DO NOTHING、唯一约束异常或独立抢占事务,但必须明确异常是否把当前事务标记为 rollback-only。

十一、模式一:幂等记录与业务在同一本地事务

适合业务数据和幂等表位于同一数据库、业务执行时间较短的场景。

mermaid
flowchart TD
    A["开启本地事务"] --> B["原子插入幂等记录"]
    B --> C{"是否获得owner_token"}
    C -- "否" --> D["读取已有状态和结果"]
    C -- "是" --> E["写业务事实表"]
    E --> F["幂等记录更新SUCCEEDED和结果"]
    F --> G["同一事务提交"]
    E --> H["异常"]
    H --> I["业务和幂等记录一起回滚"]

优点:不会出现业务提交、幂等状态未提交的本地不一致;失败回滚后可再次抢占。

代价:并发重复请求可能等待首个事务释放唯一索引;长事务会增加锁和连接占用。事务中不要调用不受本地事务控制的慢远程系统。

十二、模式二:独立抢占 + PROCESSING 租约

适合业务较长、需要跨进程恢复或无法让整个处理处于一个数据库事务的场景。

mermaid
flowchart TD
    A["短事务创建PROCESSING和lease_until"] --> B["提交抢占记录"]
    B --> C["执行较长业务"]
    C --> D["业务成功"]
    D --> E["短事务记录SUCCEEDED和结果"]
    C --> F["进程宕机或超时"]
    F --> G["租约过期扫描"]
    G --> H["先查询事实源确认业务状态"]
    H --> I["更新成功、重试或转人工"]

此模式存在业务完成与幂等状态更新之间的失败窗口:业务已成功,但进程在写 SUCCEEDED 前宕机。因此恢复任务不能看到 PROCESSING 超时就直接重做,必须先查询订单、支付流水等事实源。

owner_token 或 fencing token 可防止旧处理者在暂停恢复后覆盖新处理者结果:更新时带条件 where owner_token=? and status='PROCESSING'

十三、重复请求遇到不同状态怎么返回

已有状态推荐处理
SUCCEEDED返回第一次 resource_id 和结果
PROCESSING 且租约有效返回处理中、202,或短暂等待结果;不要再次执行
PROCESSING 但租约过期进入恢复确认,不要直接抢占并重复副作用
FAILED_RETRYABLE按版本和 owner_token 原子抢占重试
FAILED_FINAL返回第一次最终失败结果,除非业务明确允许新意图
key 相同但 hash 不同返回 409 幂等键参数冲突

重复调用方最需要的是“第一次到底创建了哪个资源”,而不是一句“重复提交”。结果复用能让超时重试真正可用。

十四、HTTP 创建接口幂等流程

mermaid
flowchart TD
    A["客户端生成业务幂等键"] --> B["POST携带Idempotency-Key"]
    B --> C["服务端认证并校验请求"]
    C --> D["计算规范化request_hash"]
    D --> E["原子抢占幂等记录"]
    E --> F{"记录状态"}
    F -- "首次处理" --> G["创建业务资源"]
    G --> H["记录结果并提交"]
    F -- "已成功" --> I["返回第一次结果"]
    F -- "处理中" --> J["返回202或稍后查询"]
    F -- "参数冲突" --> K["返回409"]

安全顺序:先认证和基本校验,再消耗幂等存储,防止匿名攻击者制造大量 key;但与业务副作用相关的最终唯一约束仍要在事务中保证。

不要允许客户端无限长度 key;限制字符集和长度,并按租户做容量配额和限流。

十五、支付回调幂等

回调处理顺序:

  1. 验证 HTTPS 来源能力之外的数字签名/HMAC。
  2. 检查商户号、订单号、金额、币种和事件类型。
  3. 使用支付平台、商户号、交易号组成幂等键。
  4. 在本地事务中写支付流水唯一约束。
  5. 条件更新订单状态,例如 WAIT_PAY → PAID。
  6. 同事务写 Outbox 事件。
  7. 提交后返回对方要求的成功确认。
sql
update t_order
set status = 'PAID', paid_at = now(3)
where order_no = ?
  and status = 'WAIT_PAY';

影响 0 行不应立刻报系统错误:查询订单,若已经 PAID 且交易号一致,就是幂等成功;若已 CLOSED、金额不一致或绑定其他交易号,则是状态冲突,需要告警和人工处理。

先做签名和金额校验再返回幂等成功,否则攻击者可能复用一个真实交易号伪造不同金额请求。

十六、MQ 消费幂等

重复原因包括:业务成功后 ACK 丢失、Offset 未提交、消费者宕机、Broker 重投、Rebalance 和人工重放。

mermaid
flowchart TD
    A["消费者收到事件"] --> B["验证消息格式和业务键"]
    B --> C["业务键/事件ID原子写消费日志"]
    C --> D{"是否首次"}
    D -- "否且成功" --> E["跳过副作用并ACK"]
    D -- "是" --> F["业务写入与消费日志同事务"]
    F --> G["事务提交"]
    G --> H["提交ACK或Offset"]
    H --> I["ACK失败可能再次投递,但业务已幂等"]

消费去重键优先使用稳定业务事件 ID,并包含 consumer group/handler version 作用域。只用 Broker messageId 有时不够:生产者重试或业务重发可能生成不同 Broker ID,却代表同一业务事件。

Kafka 生产者幂等和 Kafka 事务主要约束 Kafka 内部生产链路;若消费者还写 MySQL、调用 HTTP 或发短信,不能仅凭 enable.idempotence=true 声称端到端 Exactly Once。外部副作用仍需业务幂等或事务协调。

十七、消费日志和业务是否同事务

错误顺序一:先记“已消费”,再执行业务。业务失败后重投会被跳过,消息永久丢失。

错误顺序二:先提交业务,再单独记“已消费”。中间宕机会在重投时再次执行业务。

若消费日志和业务表在同一数据库,应放入一个本地事务;若副作用是外部 HTTP、短信或其他数据库,需要将其建模为带唯一业务号的幂等下游调用、Outbox 后续事件或 Saga/TCC 步骤,不能靠一张本地去重表覆盖所有资源。

十八、状态机幂等

对订单、支付、退款等有状态业务,条件更新比“消息 ID 去重”更接近业务正确性:

sql
update t_refund
set status = 'SUCCEEDED',
    provider_refund_no = ?,
    version = version + 1
where refund_no = ?
  and status = 'PROCESSING'
  and version = ?;

重复成功通知第二次影响 0 行。处理器查询当前状态:若已 SUCCEEDED 且结果一致,幂等返回;若状态是 CANCELLED 或交易号不一致,识别为冲突。

状态机还能防乱序:先收到退款成功,再收到旧的退款处理中事件,旧事件不能让状态倒退。

十九、乐观锁与幂等的关系

乐观锁通过 version 防止两个更新基于同一个旧版本同时成功。它解决并发覆盖,但不自动保存第一次结果,也不自动识别同一业务意图。

sql
update t_inventory
set available = available - ?,
    version = version + 1
where sku_id = ?
  and available >= ?
  and version = ?;

库存扣减还需要 reservation/requestNo 唯一键,才能让相同请求重试返回同一预占结果。乐观锁和幂等键常组合使用。

二十、Redis SETNX 能做什么、不能做什么

text
SET idem:{scope}:{key} ownerToken NX PX 30000

它适合短时间防抖、降低数据库热点、控制同一 key 的并发进入。但不能单独作为资金、订单等永久幂等事实:

  • TTL 到期后重复请求会重新进入。
  • Redis 故障、故障转移和数据淘汰可能丢键。
  • 业务成功与 Redis 状态不在同一事务。
  • 进程暂停超过 TTL 后旧请求仍可能继续执行。
  • 只存占位键无法复用业务结果。

释放或更新必须校验 ownerToken,并用 Lua 保证原子性。关键业务仍要靠数据库唯一约束、状态机和事实表兜底。

二十一、分布式锁不等于幂等

锁解决“同一时刻尽量只有一个执行者”,幂等解决“即使执行多次,业务结果仍正确”。锁可能过期、持有者暂停、会话失效或网络分区;旧持有者恢复后仍可能写入。

关键写入可使用 fencing token:每次获得租约时获取单调递增序号,资源端只接受大于已见 token 的写。即使旧持有者恢复,其较小 token 也会被拒绝。

即使有锁,仍应有唯一索引、状态条件和幂等结果。

二十二、定时任务和批处理幂等

任务幂等键应表达处理单元:

text
jobName + businessDate + shard + sourceId + batchNo

不要只用调度执行 ID:人工补跑会生成新执行 ID,却处理同一业务日期。

批任务应:

  1. 按稳定游标或业务主键分页。
  2. 每个处理单元独立记录状态。
  3. 写入使用 upsert、唯一约束或条件更新。
  4. 支持从 checkpoint 恢复。
  5. 失败记录可重试,最终失败进入异常表和人工处理。
  6. 多实例调度仍保留业务幂等,调度锁不是最终保障。

二十三、TCC 与 Saga 为什么每一步都要幂等

TCC 的 Confirm/Cancel 会因协调者重试而重复调用。分支事务表需要唯一 (xid, branch_id, action),并处理:

  • Confirm 重复:第一次确认后再次确认直接成功。
  • Cancel 重复:释放一次后再次取消直接成功。
  • 空回滚:Try 未执行却先收到 Cancel,记录取消状态。
  • 悬挂:Cancel 已发生后迟到的 Try 必须拒绝。

Saga 正向步骤和补偿步骤同样可能重复。补偿不是数据库 rollback,而是新的业务动作;例如“取消发券”必须按原发券流水幂等,不能每次补偿都再扣一张券。

二十四、去重记录保存多久

保存期限由业务重复窗口决定:

场景保存依据
HTTP 创建接口客户端最大重试期、资源生命周期和审计要求
支付回调支付平台通知周期、财务审计和对账周期
MQ 消费Topic 保留期、最大重放范围、业务审计期
定时任务可补跑历史范围
永久业务唯一号直接保留在业务事实表唯一索引中

清理太早会让旧重复请求重新执行;永不清理独立幂等表会持续膨胀。可按时间分区、归档、冷热分层,并保留业务表上的永久唯一约束。

二十五、热点幂等键和拒绝服务风险

攻击者或异常客户端重复使用同一 key,可能让大量请求等待同一唯一索引、行锁或 Redis key。应配合:

  • 认证后再创建幂等记录。
  • 按用户/租户限流。
  • PROCESSING 状态快速返回 202,而非所有请求阻塞。
  • 缓存已成功结果,但数据库仍为事实源。
  • 限制 key 长度、字符集和每日数量。
  • 监控重复率、处理中时长和冲突率。

二十六、JDK 8 并发结果复用 Demo

下面用 ConcurrentHashMap + FutureTask 模拟单进程原子抢占:20 个并发请求使用同一 key,只有一个线程执行业务,其余线程等待并复用同一个结果。它用于理解算法,不是分布式持久化方案;进程重启、跨实例和清理仍需数据库/Redis 设计。

java
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class IdempotencyConcurrentDemo {

    public static void main(String[] args) throws Exception {
        IdempotencyExecutor executor = new IdempotencyExecutor();
        ExecutorService pool = Executors.newFixedThreadPool(20);
        CountDownLatch ready = new CountDownLatch(20);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(20);
        Set<String> results = Collections.synchronizedSet(
                new HashSet<String>());

        for (int i = 0; i < 20; i++) {
            pool.submit(() -> {
                ready.countDown();
                try {
                    start.await();
                    results.add(executor.execute(
                            "order:create:R1001",
                            () -> executor.createOrder()));
                } catch (Exception exception) {
                    throw new RuntimeException(exception);
                } finally {
                    done.countDown();
                }
            });
        }

        ready.await();
        start.countDown();
        done.await();
        pool.shutdown();
        pool.awaitTermination(3, TimeUnit.SECONDS);

        System.out.println("businessExecutions="
                + executor.businessExecutions.get());
        System.out.println("distinctResults=" + results.size());
        System.out.println("result=" + results.iterator().next());
    }

    static class IdempotencyExecutor {
        private final ConcurrentHashMap<String, FutureTask<String>> tasks =
                new ConcurrentHashMap<String, FutureTask<String>>();
        private final AtomicInteger businessExecutions =
                new AtomicInteger();

        String execute(String key, Callable<String> business)
                throws Exception {
            FutureTask<String> newTask =
                    new FutureTask<String>(business);
            FutureTask<String> existing = tasks.putIfAbsent(key, newTask);
            FutureTask<String> selected =
                    existing == null ? newTask : existing;
            if (existing == null) {
                newTask.run();
            }
            return selected.get();
        }

        String createOrder() throws Exception {
            int execution = businessExecutions.incrementAndGet();
            Thread.sleep(100);
            return "order=O9001,execution=" + execution;
        }
    }
}

预期输出:

text
businessExecutions=1
distinctResults=1
result=order=O9001,execution=1

这个模型有意保留成功 Future 以复用结果。生产系统要将结果持久化并按业务窗口归档;失败 Future 是否删除、转为 FAILED_RETRYABLE 还是永久保存,要依据错误类型和业务契约决定,不能无条件删除后重试。

二十七、失败结果怎样处理

失败是否可复用/重试
参数非法同 key 同参数重复应返回相同最终失败
余额不足取决于业务是否允许稍后用同意图重试;通常要明确新意图或状态
数据库死锁可在幂等保护下有限重试
下游超时先查询下游事实状态,不能直接重做
系统未知异常标记待确认,进入恢复,不应伪装失败后立即重复副作用

错误分类必须进入协议设计。将所有 Exception 都删除幂等记录会让永久业务错误无限执行,也会让“不确定是否成功”的操作重复。

二十八、生产监控指标

建议监控:

  • 首次请求数。
  • 重复命中数和重复率。
  • 同 key 不同 hash 冲突数。
  • PROCESSING 数量和最长处理时间。
  • 租约过期恢复数。
  • SUCCEEDED/FAILED_RETRYABLE/FAILED_FINAL 数量。
  • 唯一键冲突、行锁等待和死锁。
  • 幂等表增长、分区大小和清理延迟。
  • 按接口/业务类型聚合,不能把 idem_key 作为 Metrics Tag。

幂等键属于高基数字段,应进入受控日志和 Trace 属性,不能成为 Prometheus 标签。

二十九、生产排查 Runbook

29.1 出现重复订单或重复扣款

从业务唯一号反查所有请求 trace、幂等记录、事实表、MQ 消息和下游流水。确认幂等键是否稳定、唯一索引是否存在、是否先执行副作用后抢占、是否有另一路入口绕过幂等。

29.2 大量记录卡在 PROCESSING

检查实例宕机、线程池、下游超时、事务锁和租约更新时间。对过期记录先查询事实源,再决定补记成功、重新抢占或人工处理;禁止批量改成失败后直接重跑。

29.3 同一 key 返回错误资源

检查作用域是否缺少租户/操作、客户端是否复用 key、request_hash 是否覆盖关键字段、结果缓存是否串租户。这可能是严重越权或数据泄露问题。

29.4 去重表增长过快

按业务类型、日期和状态统计,检查攻击流量、客户端每次尝试都生成新 key、清理任务失败、MQ 重放。采用分区归档,但不能删除仍处于最大重放窗口内的记录。

29.5 唯一索引等待很高

确认是否存在热点 key、大量重复请求阻塞、首个业务事务过长。PROCESSING 可快速返回,缩短本地事务,并在入口限流;不要简单删除唯一索引换性能。

三十、常见误区

误区后果
前端按钮置灰就是幂等后端重试、MQ 和回调仍会重复
先查再插并发窗口让两个请求都执行
有唯一索引就结束重复请求拿不到第一次结果,外部副作用可能已执行
Redis SETNX 永久保证TTL、故障转移和业务提交窗口会破坏假设
分布式锁替代幂等锁失效后旧执行者仍可能写
用 traceId 当幂等键每次重试 traceId 可能不同
相同 key 不校验参数客户端复用 key 时返回错误业务结果
PROCESSING 过期直接重做第一次业务可能已成功,只是状态未更新
MQ 开启生产者幂等就端到端一次外部数据库、HTTP、短信仍会重复
所有失败都删除记录不确定结果和永久错误被无限重试

三十一、方案选型

场景首选约束辅助能力
创建订单业务 requestNo 唯一索引 + 结果复用Redis 防抖、入口限流
支付回调第三方交易号唯一流水 + 状态机签名、金额校验、Outbox
MQ 消费写同库消费日志与业务同事务ACK 后置、重试死信
MQ 调外部系统下游幂等业务号或 Outbox/Saga消费状态、补偿对账
库存预占reservationNo 唯一 + 条件更新乐观锁、TCC 分支表
定时任务业务日期/分片/对象唯一checkpoint、任务锁
缓存重建短期互斥Redis/ZK 锁,数据库事实源兜底
高频短时重复Redis SETNX/结果缓存数据库永久约束

三十二、面试标准回答

幂等是同一业务意图重复执行时只产生一次业务效果,并能返回第一次相容结果。首先设计稳定且有租户和操作作用域的幂等键,并保存请求摘要防止同键不同参数;然后用数据库唯一约束或原子操作抢占处理权,不能先查再写。短事务可把幂等记录、业务写入和成功结果放在同一本地事务;长流程使用 PROCESSING、owner_token 和 lease,超时恢复时先查询事实源,避免业务已成功却重复执行。MQ 消费要让消费日志和同库业务同事务,状态型业务再用条件更新和乐观锁。Redis SETNX 和分布式锁只适合防抖和互斥,不能替代事实表唯一约束、状态机和结果复用。

三十三、关联知识点

本章小结

生产级幂等不是一个 if exists return,而是一套协议:稳定业务键、请求摘要、唯一约束原子抢占、明确状态机、事务边界、首次结果复用、处理中租约、事实源恢复、记录保留和监控告警。只有把并发、宕机、响应丢失、消息重放和参数冲突都纳入设计,才真正能让重试变得安全。