一场深夜死锁
凌晨两点,老王的库存扣减服务报出 Deadlock found when trying to get lock,QPS 一掉,订单堆积。查下来两个事务的写法都很「无害」,却互相掐死了。这篇先把 MySQL 的锁体系铺开,再复盘这场事故——死锁不可怕,可怕的是看不懂案发现场。
三层锁:全局、表、行
| 锁 | 命令 / 场景 | 备注 |
|---|---|---|
| 全局锁 | FLUSH TABLES WITH READ LOCK,全库只读 | 逻辑备份用;InnoDB 场景建议改用 mysqldump --single-transaction(第 21 篇) |
| 表级锁 | 显式表锁、MDL 元数据锁、意向锁、AUTO-INC 锁 | MDL 最常坑人:长事务 + DDL 互堵(第 19 篇展开) |
| 行级锁 | 记录锁、间隙锁、next-key lock | InnoDB 实现,本文主角 |
行锁的三件套
- 记录锁(Record Lock):锁住索引上的一条记录。
- 间隙锁(Gap Lock):锁住两条记录之间的开区间,RR 级别防幻读的武器——别人往这个区间插不进数据。间隙锁之间不冲突(都挡插入,不挡读)。
- next-key lock:记录锁 + 前面的间隙(前开后闭区间),RR 下当前读的默认加锁单位。等值查询命中唯一索引时退化成纯记录锁。
先立一个最容易翻车的认知:行锁是加在索引上的。WHERE 条件没走索引,InnoDB 没法精确定位,只能对扫过的所有记录加锁(近似锁全表)——所以「更新条件必须走索引」不只是性能问题,是并发安全问题。
快照读与当前读
MVCC 管的是快照读(普通 SELECT,不加锁,读历史版本)。但 SELECT ... FOR UPDATE、FOR SHARE、以及所有 update / delete 是当前读:必须读最新版本并加锁。RR 防幻读的完整答案是:快照读靠 MVCC,当前读靠 next-key lock。
事故复盘:库存扣减死锁
简化后的案发代码,事务 A 和事务 B 几乎同时执行( goods_id 均不在索引上):
-- 事务 A -- 事务 B
BEGIN; BEGIN;
UPDATE stock SET num = num - 1
WHERE goods_id = 1001;
UPDATE stock SET num = num - 1
WHERE goods_id = 1002;
INSERT INTO stock_log (goods_id, num)
VALUES (1001, -1);
INSERT INTO stock_log (goods_id, num)
VALUES (1002, -1);
-- A 的 insert 等待 B 持有的主键间隙
-- B 的 insert 等待 A 持有的主键间隙 → 死锁两条 UPDATE 因为没走索引,各扫全表并锁住途中所有 next-key 区间,随后两个 INSERT 都撞进对方持有的间隙,形成环路。MySQL 的死锁检测(innodb_deadlock_detect 默认开)会选代价小的事务回滚,报 Deadlock。案发现场用这条命令回看:
SHOW ENGINE INNODB STATUS; -- 看 LATEST DETECTED DEADLOCK 段
-- 两个事务分别持有(HOLDS THE LOCK)与等待(WAITING FOR)哪把锁,一目了然
SET GLOBAL innodb_print_all_deadlocks = ON; -- 把所有死锁记进错误日志死锁治理四板斧
- 让更新走索引:锁范围从「扫过的一切」缩到目标行,本案的第一修复。
- 固定加锁顺序:所有事务按同一顺序访问资源(比如按 goods_id 升序),环路就不成立。
- 事务要小:锁持有时间短,冲突窗口小;别在事务里做 RPC、慢查询。
- 兜底重试:捕获死锁异常(错误码 1213)自动重试一次——死锁被回滚的一方没有污染,重试是安全的。
改完索引 + 排序加锁后,老王的死锁率归零。其实 Redis 系列第 8 篇的分布式锁之争里那句「单点不能拍板」,在 MySQL 锁这里有了新注脚:数据库的锁单机自洽,一旦上了主从,另一本账(binlog)的顺序就变得致命——下一篇讲 binlog 与两阶段提交。
咖啡凉了,记得趁热喝。
评论 (0)