同一个下单请求被处理两次,多出一个订单、库存扣两次。排查结论常是"网关加个锁就好",但锁只解决同时到达,解决不了隔五分钟再来一次。真正要回答的是:这次调用产生哪些副作用,每个落在哪一层,就在那层做幂等。
幂等不是给接口加开关,是逐个副作用做设计。
1. 判定标准与"重复请求"不是一件事
| 概念 | 定义 | 对策 |
|---|---|---|
| 幂等 | 执行一次与执行 N 次,最终状态相同 | 逐个副作用设计 |
| 重复请求 | 同一业务意图被投递多次 | 幂等键 + 唯一约束 |
| 并发请求 | 多个请求时间窗重叠 | 锁或条件更新 |
| 重放 | 历史请求被重新提交,间隔可能很长 | 幂等键有效期足够长 |
四行要分开,对策不同。把"并发"与"重复"混为一谈,就会写出"加锁 3 秒"这类只挡瞬时的方案。
还有一条容易忽略:"状态相同"不等于"副作用次数相同"。扣库存两次又回补两次,库存一样但流水翻倍,对账就错。判定要同时看两者。
2. 先列副作用,再决定加在哪一层
| 副作用 | 所在层 | 幂等手段 | 不做会怎样 |
|---|---|---|---|
| 插入业务记录 | DAO | 唯一索引 | 重复订单 |
| 更新状态 | DAO | WHERE status=旧值 |
状态被回退 |
| 扣减库存 | DAO / 库存服务 | 唯一流水 + 条件更新 | 超卖、负库存 |
| 发 MQ 消息 | 应用层 | 去重键 / 消费端幂等 | 下游重复处理 |
| 调外部接口 | 集成层 | 透传幂等键 | 重复扣款、重复发短信 |
| 生成文件、发邮件 | 应用层 | 幂等表记已完成 | 重复发送 |
| 记流水、审计 | DAO | 唯一索引 | 流水翻倍 |
第二、四行是重灾区。状态更新用"先查后改"会在并发下回退;发消息若不解决"事务成功但消息没出去或发重了",就得下游兜。幂等责任永远在产生副作用那一侧。
3. 五种实现与适用面
| 方案 | 机制 | 适用 | 代价 |
|---|---|---|---|
| 唯一索引 / 去重表 | DB 约束拒绝重复插入 | 创建类、有业务键 | 要捕获冲突并转成成功 |
| 状态机前置校验 | 只有合法前置状态才变更 | 有状态流转 | 并发下有窗口 |
| 幂等键 + 结果缓存 | 命中键直接返回上次结果 | 对外接口、重试频繁 | 要存储与过期策略 |
| 分布式锁 | 同键串行化 | 短时并发 | 只防并发、不防重放 |
| 乐观锁版本号 | version 匹配才更新 |
更新类操作 | 冲突重试,ABA |
CREATE TABLE t_pay_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_no VARCHAR(64) NOT NULL,
order_id BIGINT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_biz_no (biz_no)
);
// 冲突即成功:走到这里说明已经处理过
try {
payRecordMapper.insert(record);
} catch (DuplicateKeyException e) {
return payRecordMapper.selectByBizNo(record.getBizNo());
}
捕获必须显式写:不捕,重复请求变 500;捕获后当成功返回才是幂等语义。异常类型看驱动,有时抛 DataIntegrityViolationException,catch 的父类要选对。
// 幂等键 + 结果缓存:两阶段写入,"处理中"不能被当成"已完成"
String key = "idem:" + idemKey;
if (Boolean.FALSE.equals(redis.opsForValue().setIfAbsent(key, "PROCESSING", TTL))) {
if ("PROCESSING".equals(redis.opsForValue().get(key))) {
throw new ConflictException("in progress"); // 让调用方稍后重试
}
return Result.fromJson(redis.opsForValue().get(key + ":result"));
}
try {
Result r = action.get();
redis.opsForValue().set(key + ":result", r.toJson(), TTL);
redis.opsForValue().set(key, "DONE", TTL);
return r;
} catch (RuntimeException e) {
redis.delete(key); // 失败必须释放
throw e;
}
PROCESSING 不能省。只占位不写中间态,就会出现"第一个请求还在跑,第二个读到空值直接返回 null"。
// 分布式锁:只防并发
Boolean ok = redis.opsForValue().setIfAbsent("lock:order:" + orderId, token, Duration.ofSeconds(10));
if (Boolean.FALSE.equals(ok)) throw new ConflictException("in progress");
try { doCreateOrder(orderId); } // 内部仍须唯一索引兜底
finally { unlockIfTokenMatch("lock:order:" + orderId, token); }
释放必须校验 token,否则误删别人的锁。锁只覆盖 10 秒,挡重放靠幂等键有效期。
4. 幂等键的设计
| 要素 | 选择 | 影响 |
|---|---|---|
| 组成 | 业务唯一键 / 客户端 UUID / 组合 | 业务键可重算,UUID 只能透传 |
| 生成方 | 客户端(Idempotency-Key 头) |
服务端要校验格式与归属 |
| 服务端(由业务参数派生) | 更可靠,要求业务键稳定 | |
| 有效期 | ≥ 重试窗口 + MQ 重投窗口 | 太短等于没做 |
| 存储 | Redis → 幂等表 → 唯一索引 | 可靠性递增,成本递增 |
// 服务端派生:同一业务意图必定得到同一个键
return DigestUtils.sha256Hex(String.join("|",
req.getUserId() + "", req.getSkuId() + "", req.getQuantity() + "",
req.getClientOrderNo())); // 客户端单号是稳定锚点
最高频的错误是把时间戳放进键:userId + timestamp,每次请求都不同,键永不重复,幂等等于没做。用每次新生成的 requestId 当键也是同一个坑。
有效期要算清:MQ 最长重投 24 小时、人工补数据可能隔天重放,1 小时缓存会把后两类漏掉。
5. 幂等与事务
| 情形 | 幂等表位置 | 做法 |
|---|---|---|
| 同库 | 与业务表同库 | 同一事务,一起回滚 |
| 跨库 | 各自库内 | 业务库记状态 + 补偿 |
| 幂等表在 Redis | 与 DB 不同源 | 只能减少重复,不能保证唯一 |
| 调外部接口 | 无事务 | 透传幂等键,或本地记"已调用" |
@Transactional
public void handle(PayCallback cb) {
payRecordMapper.insertIgnore(cb.toRecord()); // 幂等记录与业务变更同事务
orderMapper.markPaid(cb.getOrderId());
stockMapper.deduct(cb.getSkuId(), cb.getQty());
}
INSERT IGNORE 比捕获异常好:异常会把事务标成 rollback-only,Spring 里表现为 UnexpectedRollbackException,排查很痛。坚持用异常,就得把幂等写入放进独立事务。
第三行是边界:Redis 只能降低重复概率,唯一性必须靠 DB 约束。
6. 接口层与消费层分开做
| 层 | 重复来源 | 幂等依据 | 不能复用对方的原因 |
|---|---|---|---|
| 接口层 | 双击、网关重试、超时重发 | 请求头 / 业务键 | 没有位点概念 |
| 消费层 | 至少一次投递、rebalance、位点回退 | 业务键 + 事件类型 | 没有 HTTP 请求头 |
两层的键长得像但生命周期不同:接口层按用户重试窗口(分钟到小时),消费层要覆盖重投与位点回退(小时到天)。
@KafkaListener(topics = "order-paid", groupId = "settle")
public void onMessage(String payload) {
OrderPaidEvent e = Json.decode(payload, OrderPaidEvent.class);
String key = e.getOrderId() + ":SETTLE"; // 事件类型进键,避免跨事件串味
if (idemRepo.exists(key)) return; // 已处理
settleService.settle(e); // 内部另有唯一索引兜底
idemRepo.mark(key, Duration.ofDays(7));
}
顺序是先处理再标记。反过来在崩溃时会永久跳过这条消息,变成漏处理——比重复严重。
7. 部分成功:中途失败怎么重试
四步流程"扣库存 → 建订单 → 发券 → 发消息",第三步失败,重试时不能把前两步再做一遍。
| 做法 | 机制 | 代价 |
|---|---|---|
| 步骤表 + 唯一索引 | (biz_key, step) 唯一,每步成功即落记录 |
多一张表、多一次写 |
| 状态机断点续跑 | 状态字段标记走到哪步 | 状态组合会膨胀 |
| 全包一个事务 | 同库可行 | 跨服务不成立 |
| 补偿(Saga) | 每步有反向操作 | 补偿本身也要幂等 |
CREATE TABLE t_idem_step (
biz_key VARCHAR(64) NOT NULL,
step VARCHAR(32) NOT NULL,
done_at DATETIME NOT NULL,
PRIMARY KEY (biz_key, step)
);
private void runStep(String bizKey, String step, Runnable action) {
if (stepMapper.tryInsert(bizKey, step) == 0) return; // 该步已完成,跳过
action.run();
}
重试时从第一步跑起,已完成的秒过,只有失败那步真正执行。代价是"步骤"要提前设计。
8. 观测
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 幂等命中次数 | 重复请求被拦下的次数 | 长期为 0 |
| 重复率 | 命中 / 总请求 | 突增说明重试风暴 |
| 唯一键冲突数 | DuplicateKeyException 计数 |
与重复率不吻合 |
| 冲突分布 | 按业务类型统计 | 集中在某接口 |
meterRegistry.counter("idem.hit", "biz", bizType).increment();
"长期为 0"最值得警惕:要么上游从不重试,要么幂等键每次都不同,后者等于没做。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 同一订单出现两条 | 只在入口加锁,DB 无唯一键 | 补唯一索引,锁只作辅助 |
| 幂等一直不命中 | 键里含时间戳或随机 UUID | 改用业务键派生 |
| 重试后数据缺失 | 先标记后处理 | 改成先处理再标记 |
| 幂等表有记录但业务没执行 | 幂等写入在独立事务,业务回滚了 | 同事务,或用 insertIgnore |
| 缓存过期后重复 | TTL 小于重试窗口 | 延长 TTL 覆盖最大重放间隔 |
| 锁被误删 | 释放未校验 token | 比对 token 后删除 |
UnexpectedRollbackException |
用异常做幂等判断污染事务 | 换 insertIgnore |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 查询类接口 | 天然幂等 | 无副作用 |
| 创建类、有业务键 | 唯一索引 | 最可靠、成本最低 |
| 无天然唯一键 | 客户端幂等键 | 服务端无从派生 |
| 跨服务多步 | 步骤表 + 补偿 | 单事务不成立 |
| 热点秒杀 | 唯一索引 + 前置限流 | 锁在热点上成瓶颈 |
| 内部批处理重跑 | 幂等键 + 结果表 | 重跑是常态 |
一句话:幂等的位置由副作用的位置决定。写在 DAO 的约束管创建,写在消费端的去重管消息;在入口加一把锁,只挡住并发,重放照样穿透。
11. 自测
- "状态相同"为什么不足以判定幂等?
- 分布式锁为什么挡不住隔天重放?
- 幂等键里含时间戳会导致什么后果?
- 为什么建议
insertIgnore而不是捕获唯一键异常? - 消费端为什么必须"先处理再标记"?
- 步骤表方案在重试时是怎么跳过已完成步骤的?