MYSQL 8 · INNODB · MVCC · READ VIEW · RECORD / GAP / NEXT-KEY LOCK
读的人看照片,写的人排队拿钥匙
InnoDB 让读和写尽量互不打扰。普通 SELECT 不加锁,看的是某一刻拍下的“照片”,这叫 MVCC(多版本并发控制)。改数据的人要先拿到这一行的锁,同一行同一时刻只能一个人改。
这里的“读”指普通 SELECT。SELECT … FOR UPDATE、FOR SHARE、UPDATE、DELETE、INSERT 都要拿锁,算“写”那一边。
DB_TRX_ID · DB_ROLL_PTR每一行背后藏着两样东西:最后是哪个事务改的,旧版本在哪。
每一行都有,SELECT 看不到undo log改之前的样子都留着,串成一条版本链。
没人要看了才清掉Read View记下拍照那一刻哪些事务还没提交,决定版本链上哪一个你看得见。
RC 每条 SELECT 拍一次,RR 只拍一次Record · Gap · Next-Key加在索引上,写和写之间靠它排队。
提交或回滚才放每一行背后:隐藏列和版本链
InnoDB 改一行时,不会把旧值直接抹掉。旧值先写进 undo 日志,新行上记下“是我改的”和“旧版本在哪”。改几次,就串出一条链。
DB_TRX_ID = 201DB_TRX_ID = 200DB_TRX_ID = 90后台 purge 线程已经清掉DB_TRX_ID · 6 字节最后一次插入或修改这一行的事务 ID。事务 ID 只增不减,越大越晚。
DB_ROLL_PTR · 7 字节回滚指针,指向 undo 日志里的上一个版本。回滚时用它恢复,MVCC 读旧版本时也顺着它找。
DB_ROW_ID · 6 字节只有表没有主键、也没有非空唯一键时才有,InnoDB 用它自己造一个聚簇索引。
照片怎么决定你看得见哪个版本
普通 SELECT 第一次执行时,InnoDB 给你拍一张照片(Read View)。照片上只记四个数,然后拿它去比版本链上每个版本的 DB_TRX_ID。
m_ids:拍照时还没提交的事务。min_trx_id:名单里最小的。max_trx_id:下一个要分配的事务 ID。creator_trx_id:自己的事务 ID,只读过、没改过数据时是 0。
- 等于自己的 ID:自己改的,看得见
- 小于 min_trx_id:拍照前就提交了,看得见
- 大于等于 max_trx_id:拍照后才开始,看不见
- 在 m_ids 名单里:拍照时还没提交,看不见;不在名单里就看得见
看不见,就顺着 roll_ptr 找上一个版本,再比一次,直到找到看得见的那个。
读已提交:每条 SELECT 重新拍,所以能看到别人刚提交的数据。
可重复读(MySQL 默认):第一条 SELECT 拍的照片用到事务结束,同样的查询结果不变。
RR 的照片在第一条 SELECT 时才拍,不是在 BEGIN 时。想在开始时就拍,用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
MVCC 实验台
两个事务同时碰账户 1 的余额。一步步走,看版本链怎么长、照片怎么拍、A 每次读到什么。切换 RC 和 RR,对比第 4 步。
快照读和当前读
同一个事务里,有的语句看照片,有的语句看最新数据。实验台第 5 步的“读到 100,扣完却是 40”就是这么来的。
快照读:看照片
SELECT …- 按照片规则走版本链,可能读到旧版本
- 不加锁,不会被别人的锁挡住
当前读:看最新,并加锁
SELECT … FOR UPDATESELECT … FOR SHAREUPDATEDELETEINSERT- 读最新提交的版本,不管照片
- 读到的行要加锁,有人锁着就排队
could not serialize access due to concurrent update,要求应用重试;MySQL 则读最新值继续改。另外 PostgreSQL 把旧版本留在表里(靠 VACUUM 清),也没有间隙锁。| 隔离级别 | 普通 SELECT 看到什么 | 当前读加什么锁 | 幻读(多出来的行) |
|---|---|---|---|
| READ UNCOMMITTED | 最新版本,连没提交的也看得到 | 只锁行 | 有 |
| READ COMMITTED | 每条 SELECT 新拍一张照片 | 只锁行,不锁间隙 | 有 |
| REPEATABLE READ(默认) | 第一条 SELECT 拍的照片,用到事务结束 | 临键锁:锁行,也锁行前面的间隙 | 快照读看不到;当前读靠间隙锁挡住插入 |
| SERIALIZABLE | 关掉自动提交时,普通 SELECT 也变成 FOR SHARE,要加锁 | 同 RR | 没有 |
RR 下也不是完全没有幻读:先快照读看到 3 行,别人插入并提交了 1 行,再用 UPDATE 改“所有行”,会改到 4 行,之后快照读也能看到这第 4 行(自己改过的,看得见)。Spring 里 @Transactional(isolation = Isolation.READ_COMMITTED) 只改这一个事务的级别,结束后 Spring 会把连接改回去(Spring 第 02 篇)。
锁有哪几种
行锁有四种,区别在于锁的是一条记录,还是两条记录之间的空隙。下面的小图里,方块是索引上的记录,虚线框是记录之间的间隙。
InnoDB 的锁加在索引上,不是加在“行”上。WHERE 条件用得上索引,就只锁扫到的那几条;用不上索引,就只能把主键索引从头扫到尾,扫到一条锁一条,RR 下连间隙一起锁,等于锁了整张表。
Record Lock · X,REC_NOT_GAP
只锁住 10 这一条,别人不能改它、删它。
Gap Lock · X,GAP
锁住 10 和 15 之间的空隙,只挡插入。间隙锁之间互不冲突,两个事务可以同时锁同一个间隙。
Next-Key Lock · X
记录 + 它前面的间隙,写作 (5, 10]。RR 下当前读默认加的就是它。
Insert Intention
INSERT 前在间隙里打的招呼。碰上别人的间隙锁就得等;两个插入只要位置不同,互不影响。
FOR SHARE 加 S,FOR UPDATE、UPDATE、DELETE 加 X。
加行锁前,先在表上挂个牌子“表里有人锁了行”。它只是给要锁整张表的人(比如 LOCK TABLES … WRITE)看的,IX 和 IX 互不冲突,不会让行锁之间多排队。
增删改查会自动拿 MDL 读锁,用到事务结束;ALTER TABLE 要 MDL 写锁,得等前面所有事务结束。自增锁最多锁到语句结束,8.0 默认只是一瞬间的轻量锁。
锁地图:一条语句锁了哪些地方
选一条语句,看它在两棵索引上锁了哪些记录和间隙,再看另一个事务的插入和修改会不会被挡住。以 MySQL 8.0.18 之后为准,右边是 performance_schema.data_locks 里能查到的样子。
示例表:t(id 主键, c 普通索引 idx_c, name 没有索引),五行数据 (5,5,张三) (10,10,李四) (15,15,王五) (20,20,赵六) (25,25,孙七)
另一个事务这时来做这些事:
等锁和死锁
拿不到锁就等。两个事务互相等对方手里的锁,就是死锁:InnoDB 发现后立刻挑一个回滚,让另一个继续。
交叉加锁
UPDATE … WHERE id = 1拿到 1 的锁
2UPDATE … WHERE id = 2拿到 2 的锁
3UPDATE … WHERE id = 2等 B 放开 2
4UPDATE … WHERE id = 1等 A 放开 1:死锁,B 被回滚
解法:大家按同一个顺序加锁,比如批量更新前按主键排好序。
先查后插(RR 下很常见)
SELECT … WHERE id = 12 FOR UPDATE没这行,拿到间隙锁 (10,15)
2SELECT … WHERE id = 12 FOR UPDATE间隙锁不冲突,也拿到
3INSERT … id = 12被 B 的间隙锁挡住
4INSERT … id = 12被 A 的间隙锁挡住:死锁
解法:不要先查再插,直接 INSERT,靠唯一键冲突判断“已经有了”;或者这类业务用 RC。
死锁,InnoDB 回滚改动行数少的那个事务。默认开着死锁检测(innodb_deadlock_detect),立刻就报。
不是死锁,只是等太久:默认等 50 秒(innodb_lock_wait_timeout)。默认只回滚这一条语句,事务还开着。
在 Spring 里,这两个错误都会变成 PessimisticLockingFailureException 的子类,可以统一捕获后重试整个事务。
-- 谁拿着什么锁
SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks;
-- 谁在等谁,被谁挡住
SELECT * FROM sys.innodb_lock_waits;
-- 开着没提交的事务,最老的排前面
SELECT trx_id, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started;
-- 最近一次死锁的现场:看 LATEST DETECTED DEADLOCK 那一段
SHOW ENGINE INNODB STATUS;
-- 想把每次死锁都记进错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;
MySQL 5.7 和 8.0
MVCC 和锁的规则两边一样。变化在查看工具、新语法和 undo 的存放位置。JDK 8 还是 17 不影响这些,影响的是驱动。
MySQL 5.7
Boot 2 常见- 看锁用
information_schema.INNODB_LOCKS,只显示有人在等的锁 - 加共享锁写
LOCK IN SHARE MODE - undo 默认放在系统表空间 ibdata1,长事务把它撑大后不会缩回去
innodb_autoinc_lock_mode默认 1
MySQL 8.0
Boot 3 常见- 看锁改用
performance_schema.data_locks,持有的锁全看得到;老的 INNODB_LOCKS 删了 - 新增
FOR SHARE、NOWAIT、SKIP LOCKED,多个消费者抢任务时用 SKIP LOCKED 跳过别人锁着的行 - undo 默认放在独立的 undo 表空间,可以自动截断
innodb_autoinc_lock_mode默认 2(binlog 默认改成 ROW 格式了)- 8.0.18 起,范围查询顺带多锁一条范围外记录的情况,大部分修掉了
mysql:mysql-connector-java,Boot 3 只认新坐标 com.mysql:mysql-connector-j。MySQL 8 把变量 tx_isolation 改名成 transaction_isolation,老的 5.1 驱动连 8.0 会报 Unknown system variable 'tx_isolation'。常踩的坑
锁的问题,多半是锁得太多、锁得太久,或者加锁顺序乱了。
WHERE 里的列没有索引,RR 下扫到的每一行和间隙都锁上了。给条件列建索引,用 EXPLAIN 确认走了索引。
字符串列用数字去比,比如 WHERE phone = 13800000000,MySQL 要把每一行转成数字再比,索引用不上。参数类型要和列一致。
先 FOR UPDATE 查一条不存在的记录再插入,两个请求拿到同一个间隙锁,互相挡住对方的插入。直接 INSERT,靠唯一键判断。
两个事务按不同顺序更新同几行。更新前按主键排序,大家用同一个顺序加锁。
重复键冲突时,等着的事务会在那条记录上加 S 锁。第一个事务一回滚,后面几个都拿着 S 锁再要 X 锁,互相卡住。用 INSERT … ON DUPLICATE KEY UPDATE 或在应用层去重。
有事务拿着行锁很久不提交,多半是事务里调了外部接口。用 sys.innodb_lock_waits 找到挡路的那个(数据库第 01 篇讲了怎么缩短事务)。
先 SELECT 读到 100,在 Java 里算出 90,再 SET balance = 90,把别人刚扣的覆盖掉了。改成 SET balance = balance - 10 WHERE id = ? AND balance >= 10 并检查影响行数,或者先 FOR UPDATE,或者加 version 字段做乐观锁。
RC 只是没有间隙锁,行锁一样有,两个人改同一行照样排队。RC 换来的是更少的锁冲突和死锁,代价是同一事务里两次读可能不一样。
有个事务开了几个小时没提交,它的照片还要看旧版本,purge 一直清不掉。SHOW ENGINE INNODB STATUS 里的 History list length 会越来越大。查 innodb_trx 找出它。
ALTER TABLE 在等一个没提交的事务放开 MDL,排在它后面的所有查询都显示 Waiting for table metadata lock。改表前先查长事务,并把 lock_wait_timeout 设短一点(默认一年)。