根本原因是SELECT+UPDATE非原子操作:两个事务基于同一快照读取旧值、各自计算后覆盖写入,导致丢失更新;InnoDB行锁仅保障写互斥,不保证“读-算-写”整体原子性。

根本原因不是代码写错了,而是两个事务基于同一份“过期快照”各自计算并覆盖写入。
为什么 SELECT + UPDATE 模式在并发下必然丢更新
这是最典型的竞态条件:应用层先 SELECT stock FROM goods WHERE id = 1,拿到值 100;再判断是否 > 0,然后执行 UPDATE goods SET stock = 99 WHERE id = 1。问题在于,两次操作之间没有锁或版本保护——事务 A 和 B 可能在毫秒级内先后完成 SELECT,都看到 100,都算出要设为 99,最终只有一条 UPDATE 生效(InnoDB 行锁只保证“写不冲突”,不保证“读-算-写”原子性)。
- 该模式在 RC 或 RR 隔离级别下都会发生,因为
SELECT是快照读(MVCC),不加锁,也不阻塞其他事务读 - 哪怕加了
SELECT ... FOR UPDATE,如果没走索引(比如 WHERE 条件没命中主键),会升级成表锁,反而引发雪崩 - ORM 自动封装的“先查后更”逻辑(如 Django 的
get()+save())默认也不带锁,极易中招
UPDATE 语句本身不解决丢失更新
UPDATE 确实会对目标行加 X 锁,但它的原子性仅限于“单次赋值”,不包含“读取旧值 → 计算新值 → 写入”这个完整业务逻辑。例如:UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0 看似安全,但它仍存在风险:
- WHERE 中的
stock > 0是在加锁前求值的——如果两个事务同时执行,都通过校验,接着都执行减法,结果仍是扣一次变成 -1(超卖) - 该语句在 RR 隔离级别下不会阻止幻读,但在库存场景中主要风险是“条件判断与更新非原子”
- MySQL 不支持类似 PostgreSQL 的
UPDATE ... RETURNING,无法在一条语句里返回影响前的值供业务校验
为什么提高隔离级别到 SERIALIZABLE 也不推荐
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE 理论上能串行化所有读写,但实际代价极高:
- 所有
SELECT都隐式转为SELECT ... LOCK IN SHARE MODE,读操作也要加锁,吞吐量断崖下跌 - 在秒杀类场景中,大量请求卡在锁等待队列,触发
lock_wait_timeout后报错Lock wait timeout exceeded - 它不能替代业务层对“库存是否充足”的显式判断,只是把竞争从应用层推给了 InnoDB,问题本质未变
真正起作用的是锁+校验的组合动作
可靠方案必须满足两个条件:一是让读和写绑定在同一把锁下,二是确保更新生效才认为业务成功。常见做法有:
- 悲观锁:用
SELECT ... FOR UPDATE,且 WHERE 必须走主键或唯一索引(例如WHERE id = 1),之后立刻UPDATE,并在事务提交前完成库存判断 - 乐观锁:给表加
version或updated_at字段,UPDATE ... WHERE id = ? AND version = ?,严格检查mysql_affected_rows()是否为 1,失败则重试(最多 3 次) - 原子表达式:直接用
UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock >= 1,靠 MySQL 自身的行锁+条件判断完成“检查-扣减”原子化,但需确认返回影响行数是否为 1
最容易被忽略的一点:无论选哪种方案,UPDATE 语句里的 WHERE 条件必须能命中索引,否则锁不住行,等于裸奔。


















