唯一索引是幂等写入最可靠的防线,需组合业务id与固定状态字段(如order_id+status)设计,避免可变字段参与;更新型幂等应使用insert...on duplicate key update配合状态跃迁校验,禁用select+insert/update链路。

唯一索引是幂等写入最可靠的防线
MySQL 本身不提供“事务幂等性”原语,靠的是设计层约束。唯一索引(UNIQUE KEY)是最直接、最不可绕过的手段——它让重复插入直接报错 1062 Duplicate entry,而不是静默覆盖或产生脏数据。
常见错误是只对业务 ID(如 order_id)建唯一索引,却忽略状态变更场景:比如同一笔订单多次调用“支付成功”,你希望只有第一次生效,后续都失败或忽略。这时单靠 order_id 不够,得组合状态字段,例如:
ALTER TABLE orders ADD UNIQUE KEY uk_order_id_status (order_id, status);但注意:这个组合索引只在
status 值固定时才有效(比如只允许插入 status = 'created'),否则多个不同状态会共存,失去幂等控制力。
实操建议:
- 优先为「首次创建」动作设计唯一约束,例如
(user_id, event_type, event_id),确保同事件不重复落库 - 避免对可变字段(如
updated_at、version)参与唯一索引,否则无法拦截重复请求 - 如果业务要求“最终只能有一个有效状态”,更适合用
INSERT ... ON DUPLICATE KEY UPDATE配合状态位判断,而不是依赖唯一索引拦住所有重复
ON DUPLICATE KEY UPDATE + 状态位才是安全的更新型幂等
当操作涉及“可能已存在、需更新状态”(如支付回调、消息重试),INSERT ... ON DUPLICATE KEY UPDATE 是比先 SELECT 再 UPDATE 更原子、更高效的选择。但关键在于:不能无条件更新,必须校验当前状态是否允许变更。
典型翻车现场:用 ON DUPLICATE KEY UPDATE status = 'paid',结果把已退款的订单又刷回“已支付”。这就是没加状态守门员。
实操建议:
- 在
UPDATE子句里用CASE WHEN或函数限制状态跃迁,例如:INSERT INTO orders (id, status) VALUES (123, 'paid') ON DUPLICATE KEY UPDATE status = CASE WHEN status = 'created' THEN 'paid' ELSE status END;
- 利用
VALUES()函数对比新旧值,避免误覆盖:updated_at = IF(status != VALUES(status), NOW(), updated_at) - 注意 MySQL 5.7+ 对
ON DUPLICATE KEY UPDATE中子查询和某些函数的支持限制,别在UPDATE里写(SELECT ...)
为什么不要在应用层做 SELECT + INSERT/UPDATE 判断
看似直观的“先查是否存在,再决定插或更”,在并发下根本不可靠。两个请求几乎同时执行 SELECT,都得到“不存在”,然后都执行 INSERT,唯一索引会崩一个,但另一个已写入——这已经破坏了幂等性前提。
更糟的是,有人用 SELECT ... FOR UPDATE 想加锁解决,结果锁范围不对(比如只锁了主键,但唯一索引是联合的),或者事务没包住整个逻辑,导致锁失效。
实操建议:
- 放弃“查再做”的链路,所有幂等写入统一走
INSERT ... ON DUPLICATE KEY UPDATE或带冲突处理的REPLACE INTO(慎用,会隐式删再插) - 如果真要用
SELECT ... FOR UPDATE,必须确保查询条件命中唯一索引的最左前缀,且事务隔离级别至少是READ COMMITTED - 别依赖应用层缓存(如 Redis)做“存在性预判”,缓存穿透或过期窗口会导致漏判
状态位字段设计不当会让幂等逻辑失效
很多团队加了个 is_processed TINYINT(1) 字段,以为设成 1 就万事大吉。问题在于:这个字段没参与任何唯一约束,也没在 ON DUPLICATE KEY UPDATE 里做状态跃迁校验,结果就是并发写入时,多个事务都看到 is_processed = 0,全都更新成功。
真正有效的状态位,必须和唯一索引或冲突更新逻辑绑定。比如用枚举值 status ENUM('pending', 'processing', 'done', 'failed'),再配合唯一索引 (task_id, status) 或严格的状态机更新逻辑。
实操建议:
- 状态字段尽量用
ENUM或TINYINT明确取值范围,避免字符串拼写差异导致判断失效 - 不要用布尔型字段做幂等开关,它无法表达“正在处理中”这种中间态,容易引发竞态
- 如果业务有复杂状态流转,建议在数据库外维护一张状态迁移表(
from_status → to_status),并在UPDATE时 JOIN 校验,而不是硬编码在 SQL 里
最难绷的其实是边界:你以为加了唯一索引就稳了,结果发现业务 ID 是客户端生成的 UUID,而 MySQL 的 CHAR(36) 默认排序规则对大小写不敏感,abc 和 ABC 被当成同一个值;或者用了 utf8mb4_unicode_ci,连 ß 和 ss 都被等价处理。这种细节不压测根本暴露不出来。











