用两个并发事务制造 MySQL InnoDB 死锁,练习读死锁日志和调整加锁顺序

演练目标

环境准备

安装 Docker,本机 3307 端口空闲。实验使用独立容器和 lab 数据库,不影响现有 MySQL。

docker run -d --name lab-mysql \
  -e MYSQL_ROOT_PASSWORD=labpass \
  -e MYSQL_DATABASE=lab \
  -p 3307:3306 \
  mysql:8.0 --innodb-print-all-deadlocks=ON
until docker exec lab-mysql mysqladmin ping -uroot -plabpass --silent; do sleep 2; done
docker exec -i lab-mysql mysql -uroot -plabpass lab <<'SQL'
CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL);
INSERT INTO accounts VALUES (1, 100), (2, 100);
SQL

innodb_print_all_deadlocks=ON 会把每次死锁写入错误日志,避免只看到最后一次。生产环境也建议开启,代价是日志量增加。

注入故障

两个事务以相反顺序更新同一批行,并用 SLEEP 保证持锁时间重叠:

docker exec lab-mysql mysql -uroot -plabpass lab -e \
  "BEGIN; UPDATE accounts SET balance=balance+1 WHERE id=1; SELECT SLEEP(2); UPDATE accounts SET balance=balance+1 WHERE id=2; COMMIT;" \
  > /tmp/lab-mysql-a.log 2>&1 &
sleep 0.5
docker exec lab-mysql mysql -uroot -plabpass lab -e \
  "BEGIN; UPDATE accounts SET balance=balance+1 WHERE id=2; SELECT SLEEP(2); UPDATE accounts SET balance=balance+1 WHERE id=1; COMMIT;" \
  > /tmp/lab-mysql-b.log 2>&1 &
wait
cat /tmp/lab-mysql-a.log /tmp/lab-mysql-b.log

其中一个事务应报 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction,另一个事务提交成功。InnoDB 会自动选择一个事务作为牺牲者回滚。

观察现象

docker exec lab-mysql mysql -uroot -plabpass -e 'SHOW ENGINE INNODB STATUS\G' \
  | sed -n '/LATEST DETECTED DEADLOCK/,/TRANSACTIONS/p'
docker logs lab-mysql 2>&1 | grep -A40 'LATEST DETECTED DEADLOCK' | tail -45
docker exec lab-mysql mysql -uroot -plabpass lab \
  -e 'SELECT * FROM accounts ORDER BY id;'

预期死锁段中能看到两个事务、各自持有的行锁和等待的锁,以及 WE ROLL BACK TRANSACTION 标记;accounts 中只有一个事务的更新生效。失败事务的 COMMIT 不会执行,连接关闭后自动回滚。

判断与定位

  1. LATEST DETECTED DEADLOCK 会列出两个事务的 SQL、持有锁和等待锁,按“谁持有、谁等待”画出等待环即可确认。
  2. 死锁是双方互相等待,InnoDB 会立即选一个回滚;锁等待超时(1205)是单向等待超过 innodb_lock_wait_timeout,不会自动产生死锁日志。
  3. 用 performance_schema.data_locks 和 data_waits 可以在事务仍在运行时观察实时锁;事务结束后这些视图会清空。
  4. 容易误判:单个会话按顺序执行 SQL 永远不会复现死锁,必须有两个并发事务且加锁顺序不一致。另一个误判是认为死锁一定是数据库 bug;大多数死锁来自业务加锁顺序、缺失索引或长事务。
  5. SHOW ENGINE INNODB STATUS 只保留最近一次死锁,历史死锁要靠 innodb_print_all_deadlocks 的日志。

修复

统一加锁顺序,按主键从小到大更新,并缩短事务:

docker exec lab-mysql mysql -uroot -plabpass lab -e \
  "BEGIN; UPDATE accounts SET balance=balance+1 WHERE id=1; UPDATE accounts SET balance=balance+1 WHERE id=2; COMMIT;" &
docker exec lab-mysql mysql -uroot -plabpass lab -e \
  "BEGIN; UPDATE accounts SET balance=balance+1 WHERE id=1; UPDATE accounts SET balance=balance+1 WHERE id=2; COMMIT;" &
wait

两个事务按相同顺序加锁时,后到者会在第一把锁上等待,不会形成环,两个事务最终都会提交。生产修复还包括:给条件列补索引减少锁范围、把外部调用移出事务、捕获 1213 后退避重试并保证幂等。注意 SHOW ENGINE INNODB STATUS 仍保留上一次死锁段,不能用它判断是否复发,应看日志中是否有新增记录。

清理与回滚

docker rm -f lab-mysql
rm -f /tmp/lab-mysql-a.log /tmp/lab-mysql-b.log

容器删除后数据库数据一并消失。若需要保留现场,可先 docker cp lab-mysql:/var/lib/mysql ./lab-mysql-data 再删除。

复盘

延伸阅读

读完自测

  1. 为什么单会话执行同样的 SQL 不会触发死锁?
  2. ERROR 1213 和 ERROR 1205 的关键区别是什么?
  3. 开启 innodb_print_all_deadlocks 有什么代价?
答案
  1. 死锁需要两个或多个事务互相持有对方需要的锁,单会话不会形成等待环。
  2. 1213 是检测到死锁后立即回滚一个事务;1205 是锁等待超过超时时间后失败。
  3. 每次死锁都会写入错误日志,便于历史分析,但会增加日志量和磁盘占用。