根本原因是SELECT与INSERT/UPDATE之间存在竞态窗口:两个事务同时读取旧值并写入,后者覆盖前者;即使REPEATABLE READ隔离级别也无法避免,因SELECT默认不加锁。

为什么 SELECT + INSERT 或 UPDATE 在并发下会丢数据?
根本原因不是 MySQL 不够快,而是「查」和「写」之间存在竞态窗口:两个事务几乎同时 SELECT 到同一行不存在或旧值,各自按逻辑计算后执行 INSERT 或 UPDATE,结果后者覆盖前者。哪怕隔离级别设为 REPEATABLE READ,也无法阻止这个窗口——因为 SELECT 本身不加锁(除非显式加锁),它只读快照。
典型表现包括:
-
Duplicate entry 'xxx' for key 'PRIMARY'(双写主键冲突) - 余额从 100 → 90 → 再 → 90(两次扣款只生效一次)
- 库存显示“-1”或“超卖”
INSERT ... ON DUPLICATE KEY UPDATE 能用但有陷阱
它把「判断+插入/更新」压进一条语句,在数据库层原子执行,确实能避免多数覆盖问题。但它不是万能解药,关键看你怎么写 UPDATE 子句:
- ✅ 安全写法:
updated_at = NOW(), status = 'processed'—— 直接赋值,幂等 - ❌ 危险写法:
counter = counter + 1—— 并发时仍可能重复累加(因counter读取的是语句开始时的快照值) - ⚠️ 注意字段约束:
ON DUPLICATE KEY UPDATE只触发在唯一索引冲突时;如果业务逻辑依赖非唯一字段判断,它完全不生效
真正可靠的方案是让数据库做决策,而不是应用层猜
把控制权交还给 MySQL,靠锁或原子表达式堵住竞态窗口:
- 用
SELECT ... FOR UPDATE显式加行锁:必须在事务内、且确保查询能命中索引(否则升级为表锁)。注意:它锁的是「查到的行」,没查到则不锁——所以必须保证 WHERE 条件能走索引 - 改用
INSERT ... SELECT ... WHERE NOT EXISTS:例如INSERT INTO t (id, val) SELECT 123, 'x' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM t WHERE id = 123)。在REPEATABLE READ下,该语句自带间隙锁(gap lock),可防幻读干扰 - 对计数类场景,优先用
INSERT ... ON DUPLICATE KEY UPDATE counter = counter + 1配合唯一索引;若不行,改用UPDATE t SET counter = counter + 1 WHERE id = 123—— 直接更新,不依赖先查
别忽略存储引擎和索引设计这个前提
再好的 SQL 逻辑,撞上错误的基础配置也会失效:
- MyISAM 引擎不支持行级锁,
SELECT ... FOR UPDATE会退化成表锁,高并发下直接卡死 —— 必须用 InnoDB - 唯一约束缺失:没有
UNIQUE KEY或PRIMARY KEY,ON DUPLICATE KEY UPDATE和INSERT ... SELECT ... WHERE NOT EXISTS都失去依据 - 索引失效:WHERE 条件未走索引,
SELECT ... FOR UPDATE锁整张表;NOT EXISTS子查询变全表扫描,性能崩盘
并发覆盖问题的根子不在代码行数,而在「谁在何时以何种粒度持有哪段数据的控制权」——这个权,得由数据库在事务边界内明确分配,而不是靠应用层靠概率躲过竞态。


















