分布式幂等:请求、消息、任务与状态机全过程
幂等是指同一次业务意图重复执行,最终只产生一次业务效果,并能向重复调用方返回与第一次相容的结果。它不是禁止重复请求,而是承认网络重试、MQ 至少一次投递、第三方回调、任务重跑和补偿重复都会发生,然后用业务唯一键、原子约束、状态机和结果记录保证正确性。
本章以 JDK 8 和关系数据库为基线。Redis、MQ、TCC、Saga 等方案会说明边界,但数据库事实源仍是关键业务的最终约束位置。
一、学习目标
学完后应能:
- 区分重复请求、并发请求、重试、消息重复和业务重复。
- 设计稳定、可分域、可校验参数的幂等键。
- 解释为什么“先查再写”不能防并发。
- 使用唯一约束原子争抢首次处理权。
- 设计 PROCESSING、SUCCEEDED、FAILED 等幂等状态和租约恢复。
- 将幂等记录与业务副作用放入正确事务边界。
- 对重复请求复用第一次成功结果,而不是只返回“请勿重复提交”。
- 处理相同幂等键但请求参数不同的冲突。
- 设计 HTTP、支付回调、MQ 消费、定时任务、TCC 与 Saga 的幂等。
- 理解 Redis SETNX、分布式锁和 MQ Exactly Once 的适用边界。
- 排查幂等记录卡死、重复扣款、结果丢失、去重表膨胀和热点键。
二、幂等到底要求什么
假设客户端使用 Idempotency-Key=order:create:R1001 创建订单:
第一次请求:创建订单 O9001,返回成功
第二次请求:不再创建订单,返回 O9001 的已有结果
第三次请求:仍不产生新订单幂等强调业务效果一致,不一定要求每次 HTTP 响应字节完全相同。首次可能返回 201,重复请求可能按契约返回 200 或相同 201;关键是不能生成第二张订单,也不能把第一次已成功结果误报为失败。
天然看似幂等的 HTTP 方法也要看实现:GET 若顺便扣次数、DELETE 若触发重复退款、PUT 若每次发送通知,都可能有非幂等副作用。HTTP 方法语义不能替代业务约束。
三、重复从哪里来
| 来源 | 典型过程 | 风险 |
|---|---|---|
| 用户重复点击 | 两个请求几乎同时到达 | 两张订单、两次提交 |
| 客户端超时重试 | 服务已提交但响应丢失 | 第二次重复写 |
| Gateway/HTTP Client 重试 | 中间层认为请求失败 | 原始流量被放大 |
| MQ 至少一次投递 | 业务成功但 ACK/Offset 失败 | 消息再次消费 |
| 支付平台重复通知 | 对方直到收到成功响应才停止 | 重复入账、重复发货 |
| Rebalance/消费者宕机 | 已处理但进度未提交 | 同一消息换实例处理 |
| 定时任务重跑 | 上次部分成功或人工补跑 | 重复生成账单 |
| TCC/Saga 重试 | Confirm、Cancel、补偿重复调用 | 重复释放或重复扣减 |
| CDC 重放 | 位点恢复、故障切换 | ES、缓存、报表重复更新 |
前端按钮置灰只能降低用户重复点击,无法解决后端、网络和中间件产生的重复。
四、超时为什么让幂等成为必需
flowchart TD
A["调用方发送创建支付请求,业务号 P100"] --> B["支付服务收到请求"]
B --> C["本地事务创建支付单并提交"]
C --> D["响应在网络中丢失"]
D --> E["调用方超时,不知道是否成功"]
E --> F["使用同一业务号重试或查询"]
F --> G["服务端返回第一次支付单结果"]如果调用方每次重试都生成新业务号,服务端无法判断是同一意图还是两次真实支付。幂等键必须在“一次业务意图”开始时生成,并在所有网络重试中保持不变。
五、幂等键怎样设计
一个可靠幂等键至少考虑:
租户/用户作用域 + 业务操作 + 稳定业务号 + 协议版本示例:
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 可能每次重试都变化,用于追踪一次网络尝试;幂等键标识跨多次尝试的同一业务意图。
六、为什么要保存请求摘要
客户端可能错误地复用同一个幂等键:
第一次:key=R100,amount=100
第二次:key=R100,amount=1000若服务端只看 key,第二次会返回第一次结果,客户端却以为 1000 元请求已执行。应对影响语义的字段做规范化后计算摘要,例如 SHA-256,并保存 request_hash。
规范化要保证字段顺序、数字格式、空值和无关字段处理一致。不要直接对原始 JSON 文本哈希,因为相同语义的字段顺序或空格不同会得到不同摘要。
重复请求处理:
- key 相同且 hash 相同:视为同一业务意图。
- key 相同但 hash 不同:返回 409 Conflict 或明确业务冲突码。
摘要用于检测冲突,不用于密码存储或签名认证;安全用途还要 HMAC/数字签名。
七、幂等记录状态模型
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 | 检测同键不同参数 |
| status | PROCESSING/SUCCEEDED/FAILED_* |
| owner_token | 识别当前处理者,防旧处理者覆盖新结果 |
| lease_until | 处理中租约,支持宕机恢复 |
| result_code | 第一次业务结果码 |
| result_body | 必要的响应快照或结果引用 |
| resource_id | 已创建订单/支付单 ID |
| retry_count | 恢复或重试次数 |
| created_at/updated_at | 排查和清理依据 |
| version | 乐观锁和并发状态转换 |
不要永远只保存一个 processed=true。系统无法区分正在处理、已经成功、可重试失败和永久失败,也无法复用第一次结果。
八、幂等表 SQL 设计
MySQL 示例:
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 和稳定结果字段,重复请求再从事实表查询。
九、“先查再写”为什么错误
错误代码:
if (!repository.exists(key)) {
executeBusiness();
repository.insert(key);
}并发时:
flowchart TD
A["线程A查询:不存在"] --> C["两个线程都进入业务"]
B["线程B查询:不存在"] --> C
C --> D["业务副作用执行两次"]
D --> E["最后插入记录时才发生唯一键冲突"]问题是副作用已经发生,最后的唯一键异常来不及撤销外部扣款、HTTP 调用或已发送消息。正确顺序是先通过唯一约束原子获得处理权,再执行业务。
十、原子抢占处理权
一种 MySQL 模式:每次请求生成 owner_token,并尝试插入;发生唯一键时不覆盖原 owner。
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。
十一、模式一:幂等记录与业务在同一本地事务
适合业务数据和幂等表位于同一数据库、业务执行时间较短的场景。
flowchart TD
A["开启本地事务"] --> B["原子插入幂等记录"]
B --> C{"是否获得owner_token"}
C -- "否" --> D["读取已有状态和结果"]
C -- "是" --> E["写业务事实表"]
E --> F["幂等记录更新SUCCEEDED和结果"]
F --> G["同一事务提交"]
E --> H["异常"]
H --> I["业务和幂等记录一起回滚"]优点:不会出现业务提交、幂等状态未提交的本地不一致;失败回滚后可再次抢占。
代价:并发重复请求可能等待首个事务释放唯一索引;长事务会增加锁和连接占用。事务中不要调用不受本地事务控制的慢远程系统。
十二、模式二:独立抢占 + PROCESSING 租约
适合业务较长、需要跨进程恢复或无法让整个处理处于一个数据库事务的场景。
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 创建接口幂等流程
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;限制字符集和长度,并按租户做容量配额和限流。
十五、支付回调幂等
回调处理顺序:
- 验证 HTTPS 来源能力之外的数字签名/HMAC。
- 检查商户号、订单号、金额、币种和事件类型。
- 使用支付平台、商户号、交易号组成幂等键。
- 在本地事务中写支付流水唯一约束。
- 条件更新订单状态,例如 WAIT_PAY → PAID。
- 同事务写 Outbox 事件。
- 提交后返回对方要求的成功确认。
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 和人工重放。
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 去重”更接近业务正确性:
update t_refund
set status = 'SUCCEEDED',
provider_refund_no = ?,
version = version + 1
where refund_no = ?
and status = 'PROCESSING'
and version = ?;重复成功通知第二次影响 0 行。处理器查询当前状态:若已 SUCCEEDED 且结果一致,幂等返回;若状态是 CANCELLED 或交易号不一致,识别为冲突。
状态机还能防乱序:先收到退款成功,再收到旧的退款处理中事件,旧事件不能让状态倒退。
十九、乐观锁与幂等的关系
乐观锁通过 version 防止两个更新基于同一个旧版本同时成功。它解决并发覆盖,但不自动保存第一次结果,也不自动识别同一业务意图。
update t_inventory
set available = available - ?,
version = version + 1
where sku_id = ?
and available >= ?
and version = ?;库存扣减还需要 reservation/requestNo 唯一键,才能让相同请求重试返回同一预占结果。乐观锁和幂等键常组合使用。
二十、Redis SETNX 能做什么、不能做什么
SET idem:{scope}:{key} ownerToken NX PX 30000它适合短时间防抖、降低数据库热点、控制同一 key 的并发进入。但不能单独作为资金、订单等永久幂等事实:
- TTL 到期后重复请求会重新进入。
- Redis 故障、故障转移和数据淘汰可能丢键。
- 业务成功与 Redis 状态不在同一事务。
- 进程暂停超过 TTL 后旧请求仍可能继续执行。
- 只存占位键无法复用业务结果。
释放或更新必须校验 ownerToken,并用 Lua 保证原子性。关键业务仍要靠数据库唯一约束、状态机和事实表兜底。
二十一、分布式锁不等于幂等
锁解决“同一时刻尽量只有一个执行者”,幂等解决“即使执行多次,业务结果仍正确”。锁可能过期、持有者暂停、会话失效或网络分区;旧持有者恢复后仍可能写入。
关键写入可使用 fencing token:每次获得租约时获取单调递增序号,资源端只接受大于已见 token 的写。即使旧持有者恢复,其较小 token 也会被拒绝。
即使有锁,仍应有唯一索引、状态条件和幂等结果。
二十二、定时任务和批处理幂等
任务幂等键应表达处理单元:
jobName + businessDate + shard + sourceId + batchNo不要只用调度执行 ID:人工补跑会生成新执行 ID,却处理同一业务日期。
批任务应:
- 按稳定游标或业务主键分页。
- 每个处理单元独立记录状态。
- 写入使用 upsert、唯一约束或条件更新。
- 支持从 checkpoint 恢复。
- 失败记录可重试,最终失败进入异常表和人工处理。
- 多实例调度仍保留业务幂等,调度锁不是最终保障。
二十三、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 设计。
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;
}
}
}预期输出:
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,而是一套协议:稳定业务键、请求摘要、唯一约束原子抢占、明确状态机、事务边界、首次结果复用、处理中租约、事实源恢复、记录保留和监控告警。只有把并发、宕机、响应丢失、消息重放和参数冲突都纳入设计,才真正能让重试变得安全。
