仅加UNIQUE索引不够,因并发插入相同值抛异常时业务可能已部分执行,且异常后需手动查状态判断,易误判;INSERT...ON DUPLICATE KEY UPDATE或SELECT...FOR UPDATE配合事务才能可靠实现幂等。

MySQL 用唯一键约束 + 事务能实现分布式幂等性,但必须配合 INSERT ... ON DUPLICATE KEY UPDATE 或显式 SELECT ... FOR UPDATE,否则仅靠 INSERT + 唯一索引会抛异常、无法控制业务逻辑分支。
为什么只加 UNIQUE 索引还不够
单纯在表上加 UNIQUE 约束(比如对 request_id 字段),在并发插入相同值时确实会触发 DuplicateKeyException,但问题在于:
- 异常发生时,业务逻辑可能已部分执行(如扣款已发消息、库存已预占),
INSERT失败 ≠ 整个操作回滚 - 捕获异常后,你得自己查一遍当前状态再决定是返回成功还是重试,容易漏判或误判
- Spring 的
@Transactional默认不会捕获并回滚检查型异常,MySQLIntegrityConstraintViolationException是运行时异常,虽会触发回滚,但无法优雅返回“已处理”语义
INSERT ... ON DUPLICATE KEY UPDATE 是最简可行方案
适用于“插入即幂等”,且业务允许用更新覆盖的场景(如写日志、存快照、刷新 token):
- 必须确保冲突字段有
UNIQUE或PRIMARY KEY约束 -
UPDATE子句里不能写计算逻辑(如counter = counter + 1),否则重复请求会叠加而非幂等 - 若需区分“首次插入”和“重复忽略”,可在
UPDATE中设标记字段,例如:updated_at = NOW(), is_repeated = 1 - 示例:
INSERT INTO idempotent_log (request_id, payload, created_at) VALUES ('abc-123', '{"amount":100}', NOW()) ON DUPLICATE KEY UPDATE is_repeated = 1, updated_at = NOW();
SELECT ... FOR UPDATE 配合事务更可控
适合需要复杂校验、状态流转或带副作用的业务(如支付、下单),但要求:
-
SELECT必须走主键或唯一索引,否则升级为表锁,性能雪崩 - 整个流程必须包在同一个数据库事务中(
@Transactional),且隔离级别至少是REPEATABLE READ - 不能先
SELECT再开事务,否则锁不生效;也不能在事务外查完再进事务,会丢锁 - 典型流程:
BEGIN; SELECT * FROM idempotent_record WHERE request_id = 'abc-123' FOR UPDATE; -- 若查到,直接 COMMIT 并返回 success; -- 若没查到,INSERT 一条,并继续执行业务逻辑; COMMIT;
防重表设计与常见踩坑点
单独建一张 idempotent_record 表比在业务表加字段更正交,但要注意:
- 表结构至少含:
request_id(VARCHAR(64)或BINARY(16))、status(TINYINT,如 0=processing, 1=success, 2=failed)、created_at、updated_at -
request_id必须建UNIQUE索引,否则FOR UPDATE无法精准锁定行 - 别忘了加过期机制:长期堆积的记录会拖慢查询,建议加
expire_at字段 + 定时清理任务 - 不要用
SELECT COUNT(*)判断是否存在——它不加锁,高并发下必然穿透
真正难的不是写对那几行 SQL,而是把幂等键的生成、传递、存储、清理串成闭环;一旦 request_id 在网关层被丢弃、在 Feign 调用中未透传、或消费 Kafka 消息时未提取 header,整套机制就形同虚设。


















