MySQL 并发控制:把 MVCC、锁等待和死锁放进同一个例子
用两个会话复现快照读与更新冲突,再从等待关系定位死锁,并设计事务级重试。
Read in English →工程学习与设计笔记。示例使用合成场景;适用版本与参考资料见正文。
适用范围:MySQL 8.0 / InnoDB。示例使用显式事务,默认讨论 REPEATABLE READ;实验只应在独立测试库运行。
“有 MVCC,所以读写不冲突”只说对了一部分。普通一致性读、锁定读和修改语句使用的机制不同;把它们混在一起,最容易写出单线程正确、并发时超卖或长时间阻塞的代码。
一个快照不负责替你保护业务约束
建立一张极小的测试表:
CREATE TABLE inventory (
sku_id BIGINT PRIMARY KEY,
available INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO inventory VALUES (1, 10), (2, 10);
会话 A 开启事务并普通查询 sku_id=1,会话 B 修改库存并提交;A 再次执行普通查询。在 REPEATABLE READ 下,A 通常仍从同一个一致性快照读到旧值;在 READ COMMITTED 下,每次一致性读建立新的快照。事务自己的修改又有可见性例外,不能把快照想成整张数据库的静态副本。规则见 一致性非锁定读
。
如果业务流程是“先读库存,再在应用里减一并覆盖写回”,两个请求都可能从相同旧值出发。对单行扣减,更清晰的实现是让条件和修改处于同一条语句:
UPDATE inventory
SET available = available - 1
WHERE sku_id = 1 AND available > 0;
应用检查影响行数:一行代表扣减成功,零行代表没有可扣减记录。涉及订单、额度等多表约束时,这条 SQL 仍需要放进完整事务,并为重复业务请求设计唯一约束或幂等记录。
“锁住一行”仍要看访问路径
InnoDB 的记录锁作用在索引记录上;范围访问还可能涉及间隙锁和 next-key lock。唯一索引的唯一值查找,与非唯一条件的范围扫描,不能套用同一套锁范围判断。具体机制见 InnoDB Locking 。
因此,排查“明明更新不同业务对象,为什么互相等待”时,我会先拿到实际 SQL、参数、隔离级别和执行计划。缺少合适索引,可能让一个很窄的业务条件对应更宽的扫描与锁定范围。也要检查事务中是否夹着网络调用,导致锁已经拿到,却一直等外部服务返回。
用两个会话理解死锁
依次执行下面的操作,最后一步会形成环路:
| 顺序 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN; | BEGIN; |
| 2 | 更新 sku_id=1 | 更新 sku_id=2 |
| 3 | 更新 sku_id=2,等待 B | 暂不操作 |
| 4 | 仍等待 | 更新 sku_id=1,等待 A |
默认启用死锁检测时,InnoDB 会选择一个事务回滚,另一个才有机会继续。死锁与普通锁等待超时不是同一事件;尤其不能假设锁等待超时一定回滚整个事务。发生错误后,应用必须按驱动与事务管理器的语义结束当前事务,再决定是否重试。
复现后检查:
SHOW ENGINE INNODB STATUS;
SELECT * FROM performance_schema.data_lock_waits;
前者用于查看最近的死锁信息,后者帮助观察当前等待关系。等待解除后表里可能已没有那条关系,因此还需要保留发生时的 SQL、事务 ID 和时间。官方字段定义见 data_lock_waits 。
重试必须是事务级设计
降低死锁概率最直接的办法,是让不同入口以一致顺序获取资源。例如一次扣多个 SKU,先排序再更新;缩短事务,提前完成不依赖锁的计算;为筛选条件建立合适索引。这些做法无法承诺零死锁,官方也要求应用能够处理事务重试,参见 死锁处理建议 。
重试对象应是完整业务事务。若只重试失败的最后一条 SQL,前面已回滚的修改不会自动恢复。重试次数要有限,等待加入随机退避,并在总时限耗尽时返回明确错误。事务外发送消息、调用支付等副作用,需要幂等键或事务消息方案,不能跟着数据库事务盲目重放。
验收时不仅记录“成功率上升”,还要确认库存不为负、订单与扣减一一对应、失败请求无残留事务。并发控制的结果应由业务不变量证明,而不是由某个锁名证明。