结论先行:任何"先 SELECT 判断存在 → 不存在再 INSERT"的幂等实现,在并发下都不是幂等的,只是把失败从"必然"推迟到"偶发"。这篇文章把机制讲透(InnoDB 间隙锁 + Duplicate Key 检查),给可复现的 DDL、SQL、Java 片段,并重点讲一个最容易被低估的数字——幂等窗口该开多长。
一、现象:先查后写跑了一年,每天还是有 12–30 笔重复入账
先摆数字。我们一组支付回调服务,日均 40 万笔,8 个应用实例、MySQL 8.0.32、Redis 7.2。幂等实现长这样:
// ❌ 看似正确,实则必翻车
public void onPayCallback(Callback cb) {
if (dedupDao.existsByBizNo(cb.getBizNo())) {
return; // 已处理过
}
orderDao.markPaid(cb); // 业务写
dedupDao.insert(cb.getBizNo(), cb); // 落幂等表
}
财务对账时拉出 3 个月的 paid_log 流水,发现同一笔订单号(bizNo)出现 2 条 PAID 记录的有 417 条,分布在 14 天里,每天 12–30 笔。监控上完全正常——因为重复发生在**两个并发请求都读到"不存在"**的那 50ms 窗口里,应用日志里两条都是 first insert。
一句话根因:"先查后写"不是原子操作,两个并发实例都能通过 SELECT 这道门。
二、原理:InnoDB 唯一索引为什么会在并发下"救你",但救的方式不优雅
很多人第一反应是"给幂等表加唯一索引不就行了"——对,但光加唯一索引不够,还要理解它怎么救。
InnoDB 在 INSERT 到带唯一索引的表时,会做 Duplicate Key 检查:对唯一索引上已有记录加 S 锁,对间隙加间隙锁,检查完再改 X 锁写入。这意味着:
- 两个并发 INSERT 同一个 bizNo 时,后到的那个会拿 Duplicate Key 错误(1062),而不是静默重复——这是唯一索引兜底的本质;
- 但更糟的是可能死锁:A 事务在间隙锁上阻塞,B 事务持有 S 锁等待 A 释放 X 锁,形成 AB-BA 环路。InnoDB 会 kill 掉其中一个(默认
innodb_deadlock_detect=ON),被 kill 的事务整个回滚——包括你的业务写。
所以正确的姿势是:不要"先查后写 + 唯一索引"两段式,直接"先写(INSERT ... ON DUPLICATE KEY UPDATE)+ 唯一索引"一段式。让数据库的并发控制替你判重,而不是应用层判。
三、具体做法:一段 DDL + 一条 SQL + 一个 Java 片段,可直接抄
3.1 DDL:幂等表要和业务表同库
CREATE TABLE idempotent_record (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
biz_key VARCHAR(128) NOT NULL COMMENT '幂等键,如 pay:20240615:TXN-8821',
request_hash CHAR(64) NOT NULL COMMENT '请求体 SHA-256,用于区分重放 vs 冲突',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=处理中, 1=成功, 2=失败',
response JSON NULL COMMENT '成功时存响应快照,重放时直接返回',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_biz_key (biz_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键:idempotent_record 必须和 orders 表同库,这样业务写和幂等记录可以走本地事务,不需要分布式事务。如果幂等表放 Redis,你就回到了"幂等键的生命周期和一台会丢数据的中间件绑定"的老路。
3.2 一条 SQL 替代"先查后写"
-- 原子判重 + 写入,并发下只有一个能赢
INSERT INTO idempotent_record (biz_key, request_hash, status, response, created_at, updated_at)
VALUES (
#{bizKey},
#{requestHash},
0,
NULL,
NOW(3),
NOW(3)
)
ON DUPLICATE KEY UPDATE
status = status, -- 故意不更新,让旧记录原样保留
updated_at = updated_at;
这条 SQL 的 affectedRows 是判重信号:
- 1 = 新插入,本次是第一次处理,继续走业务写;
- 0 = 已存在,本次是重放,直接读旧
response返回给调用方。
业务侧:
public PayResult onPayCallback(Callback cb) {
String bizKey = "pay:" + cb.getDate() + ":" + cb.getTxnId();
String reqHash = sha256(cb.getBody());
int affected = idempotentDao.tryInsert(bizKey, reqHash); // 上面那条 SQL
if (affected == 0) {
IdempotentRecord rec = idempotentDao.selectByBizKey(bizKey);
if (rec.getStatus() == 1) {
return rec.getResponse(); // 重放:直接返回旧结果
}
if (rec.getStatus() == 0) {
throw new StillProcessingException(bizKey); // 第一次还在处理中
}
// status == 2:上次失败了,允许本次重试(按业务语义决定是否放行)
}
// affected == 1:本次持有"唯一写权",走业务
try (Tx tx = db.tx()) {
orderDao.markPaid(cb);
idempotentDao.markSuccess(bizKey, buildResponse(cb));
tx.commit();
} catch (Exception e) {
idempotentDao.markFailed(bizKey); // 标记失败,允许下次重试
throw e;
}
return buildResponse(cb);
}
3.3 幂等窗口:最容易被低估的数字
窗口该开多长? 答案是:客户端超时 + 客户端最大重试延迟 + 服务端最坏处理时间,三者相加再留 2 倍余量。
举个具体例子:
- 网关侧客户端超时:5s;
- 客户端重试策略:最多 2 次,退避 200ms / 800ms(最坏延迟 1s);
- 服务端最坏处理时间(含 DB 写入 + 下游调用):3s;
- 合计上限:5 + 1 + 3 = 9s,留 2 倍余量 → 窗口 ≥ 18s。
反例:很多团队把窗口写成 300s("5 分钟应该够了"),结果客户端第一次请求还在处理中(服务端处理 3s + 网关排队 2s = 5s),客户端超时后重试,第二次带着同一个 bizKey 进来——窗口内被判为"重放",但第一次其实还没完成,返回的 response 是空的,客户端拿到空响应直接报错。正确做法是区分三种状态:
| 状态 | 含义 | 调用方行为 |
|---|---|---|
| status=0(处理中) | 第一次还在跑 | 返回 429 Too Many Requests + Retry-After: 2,不是 409 |
| status=1(成功) | 已完成 | 返回 200 + 旧 response(重放语义) |
| status=2(失败) | 上次崩了 | 按业务语义决定:允许重试(返回 200 走新流程)或拒绝(返回 409) |
同一个 bizKey 但 request_hash 不同,是"冲突"不是"重放"——调用方换了请求体想复用同一个 key,应该直接 409,而不是静默覆盖。这就是为什么 DDL 里有 request_hash 字段。
四、踩过的坑(按翻车频率排序)
- Redis SETNX 做幂等,TTL 300s。Redis 主从切换丢了 key,第二次请求带同一个 bizKey 进来 SETNX 成功 → 重复入账。我们的教训:幂等记录的持久性必须和业务数据的持久性对齐,Redis 只适合做"热点加速",不适合做"唯一事实源"。
- 先查后写 + 唯一索引,并发下 1062 被 catch 后吞掉。正确做法是
INSERT ... ON DUPLICATE KEY UPDATE一条 SQL 搞定,不要两段式。 - 幂等表和业务表分库。第一次请求处理中,业务写成功、幂等记录写入失败(网络抖动),客户端重试 → 业务写两次。同库本地事务可以兜住,跨库分布式事务兜不住。
- 窗口开太短。客户端超时 5s + 重试 1s + 服务端 3s = 9s,你窗口只开 5s,结果第一次还在处理中第二次就被判重放,返回空 response。窗口至少 18s。
- 把"处理中"当成"重放"返回 200。调用方拿到空 response 以为成功了,实际第一次还在跑。应该返回 429 + Retry-After,让调用方稍后再试。
五、结论与可复用清单
把幂等设计从"加个唯一索引"升级成四件套,上线前逐条过:
- 幂等键:稳定(同一业务事件重放时不变)、不可预测(用 CSPRNG,别用自增/时间戳)、带业务前缀(如
pay:20240615:TXN-8821); - 唯一索引兜底:幂等表
UNIQUE KEY (biz_key),用INSERT ... ON DUPLICATE KEY UPDATE一条 SQL 判重,不要先查后写; - 同库事务:幂等表和业务表同库,本地事务保证原子性;
- 窗口 ≥ 客户端超时 + 最大重试延迟 + 服务端最坏处理时间,再留 2 倍余量;
- 三态语义:处理中(429 + Retry-After)/ 成功(200 + 旧 response)/ 失败(按业务决定 200 或 409);
- 监控:
affectedRows=0的比例就是"重放率",正常应 < 0.1%,突增说明客户端重试策略有问题; - 契约:把幂等键格式、窗口长度、三态语义写进 OpenAPI 的
x-idempotency扩展字段,上下游接入阶段对齐,比事后排查便宜 100 倍。
参考:@林砚舟 在《超时与重试》里提到的"写接口不重试,要重试必须带幂等键"这条原则,本文正是它的落地细节;@陈知远-112 在《唯一索引下的并发插入为何死锁》里详细拆解了 InnoDB 间隙锁 + Duplicate Key 检查的死锁环路,是本文第二节的机制基础。