个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <
文章目录
-
- 一、每一行都藏着一个版本链
- 二、ReadView:一次快照的"名册"
- [三、4 条可见性规则](#三、4 条可见性规则)
- [四、RC 和 RR 的真正差别只有一句话](#四、RC 和 RR 的真正差别只有一句话)
- [五、6 个能跑出来的小实验](#五、6 个能跑出来的小实验)
- [六、RR 不等于没有幻读](#六、RR 不等于没有幻读)
- [七、长事务为什么危险:undo 不能清理](#七、长事务为什么危险:undo 不能清理)
- 小结
先复现一个让人怀疑自己学错了的现象。在 MySQL 的 InnoDB 存储引擎里,account 表中有这样一行 balance = 100,两个会话按顺序操作:
sql
-- 会话 B(先跑)
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT; -- B 已经提交了
-- 会话 A(在 B 提交前就 BEGIN 过)
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- ... 这里 B 提交了 ...
SELECT balance FROM account WHERE id = 1; -- 还是 100 ?!
B 明明提交了,A 却像没看见一样。这不是缓存、不是连接池、也不是主从延迟------这是 MVCC(多版本并发控制)在按规则办事 :A 的第二次查询用的是事务开始时生成的快照,而不是"当前最新的数据"。
要真正理解它,关键不是背"RR 可重复读、RC 读已提交"这两句话,而是搞清三件事:版本链怎么形成、ReadView 里存了什么、可见性怎么判断。
一、每一行都藏着一个版本链
InnoDB 的每行记录除了你的字段,还有两个隐藏列:
| 隐藏列 | 含义 |
|---|---|
DB_TRX_ID |
最后一次修改这行的事务 id(6 字节) |
DB_ROLL_PTR |
回滚指针,指向 undo log 里这行的上一个版本 |
DB_ROW_ID |
没有主键时自动生成的隐藏主键(本篇不涉及) |
每次 UPDATE 并不是"原地改掉",而是:写入一条 undo 记录保存旧值 → 修改当前行 → 把这一行的 DB_TRX_ID 改成自己的事务 id → DB_ROLL_PTR 指向那条 undo。这些旧版本会一直躺在数据库的 undo 表空间里,谁需要谁去顺着链取。
我们假设 id=1 这行的初始版本由事务 30 写入,balance = 100。事务 40 把它改成 300,事务 50 又改成 500,版本链长这样:
text
当前行: balance=500 DB_TRX_ID=50 ──┐
↓ roll_ptr
undo: balance=300 DB_TRX_ID=40 ──┐
↓
undo: balance=100 DB_TRX_ID=30 ──→ NULL
所以"能不能读到某个值"这个问题,被转换成了:从当前行出发,顺着这条链往回找,哪一版对我可见?
二、ReadView:一次快照的"名册"
快照读(普通 SELECT)在需要时会生成一个 ReadView,它其实就是当时活跃事务的一张快照名册,四个字段:
| 字段 | 含义 |
|---|---|
m_ids |
生成 ReadView 时,**还活着(未提交)**的事务 id 列表 |
min_trx_id |
m_ids 里的最小值 |
max_trx_id |
下一个将要分配的事务 id(注意:不是 m_ids 的最大值) |
creator_trx_id |
创建这个 ReadView 的事务自己的 id |
以开头那个场景为例:A 先 BEGIN(拿到事务 id 40),B 拿到 50 并改了数据、提交了。如果 A 在整个事务一开始就生成了 ReadView,那么 m_ids = [40, 50]、min_trx_id = 40、max_trx_id = 51、creator_trx_id = 40。
三、4 条可见性规则
拿到一行数据后,按下面的顺序判断(伪代码就是 InnoDB 的真实逻辑):
text
对版本链上的某一版,记它的 DB_TRX_ID 为 trx:
1) trx == creator_trx_id → 可见(这是我自己改的)
2) trx < min_trx_id → 可见(改它的那个事务早就提交了)
3) trx >= max_trx_id → 不可见(它在我生成快照之后才开始)
4) min_trx_id <= trx < max_trx_id:
trx 在 m_ids 里 → 不可见(我生成快照时它还没提交)
trx 不在 m_ids 里 → 可见(我生成快照时它已提交)
不可见时:顺着 DB_ROLL_PTR 找到上一版,重新从第 1 条开始判断;
一直找到可见版本,或者链走完(说明这行对我"不存在")。
回到开头的例子:A 看到的当前行 DB_TRX_ID = 50。规则 4 命中------50 在 m_ids 里(生成快照时 B 还没提交),不可见 ;顺着指针回到上一版 DB_TRX_ID = 30,30 < min_trx_id(40),规则 2 命中,可见,读到 100。B 提交与否,对 A 的这个 ReadView 毫无影响。
四、RC 和 RR 的真正差别只有一句话
这两级事务隔离级别的实现差别,不在于判断规则,而在于生成 ReadView 的时机:
| 隔离级别 | ReadView 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每次 SELECT 都重新生成 |
别人一提交就能看见 |
| REPEATABLE READ | 事务里第一次 SELECT 时生成,之后一直复用 |
整个事务看到同一个快照 |
这也解释了另一个常见疑问:为什么在 RR 下,事务里第一条 SELECT 之前别人提交的数据是能看见的------因为ReadView 那时候还没生成,等你第一次查询时,名册是按当时状态拍的。
sql
-- 想自己验证"RR 只在第一次 SELECT 时拍快照",按这个顺序敲
SET SESSION transaction_isolation = 'REPEATABLE-READ';
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 这里才生成 ReadView
-- 另一个会话 UPDATE + COMMIT
SELECT balance FROM account WHERE id = 1; -- 仍是旧值
COMMIT;
五、6 个能跑出来的小实验
| # | 操作 | 结果 | 说明 |
|---|---|---|---|
| 1 | RR 下 A 读、B 更新并提交、A 再读 | 两次相同 | 快照复用,符合直觉预期 |
| 2 | 同上但隔离级别是 RC | 第二次看到新值 | 每次 SELECT 重新拍快照 |
| 3 | A 自己 UPDATE 后再 SELECT |
能看到自己的修改 | 规则 1:trx == creator_trx_id |
| 4 | A 读、B 更新(不提交)、A 再读 | 仍是旧值 | B 在 m_ids 里,且脏读被阻断 |
| 5 | A SELECT ... FOR UPDATE |
看到 B 已提交的新值 | 当前读,不走 ReadView |
| 6 | A 快照读期间大量写入 | A 看不到任何新行 | 幻读在这条路径上被抑制 |
第 5 条是最容易踩的坑:SELECT ... FOR UPDATE、UPDATE、DELETE 都是当前读,它们读的是最新已提交版本,还会加锁。所以你会看到同一个事务里:
sql
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100(快照读)
SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- 500(当前读,同一事务里数值跳了)
顺便说一下 UPDATE 的"半一致性读":更新时如果发现最新版本被别的事务锁着,会等锁;等到了就读取最新版本再做判断,所以 UPDATE ... WHERE balance = 100 在 RR 下也可能改到"你以为不存在"的行。
六、RR 不等于没有幻读
很多人把"RR 解决了幻读"当成定理。准确说法是:RR 下快照读不会幻读,当前读仍可能幻读。
sql
-- 会话 A
BEGIN;
SELECT COUNT(*) FROM t_order WHERE user_id = 10; -- 0,快照里确实没有
-- 会话 B 插入 (10, ...) 并提交
SELECT COUNT(*) FROM t_order WHERE user_id = 10; -- 仍然是 0(快照读)
UPDATE t_order SET status = 1 WHERE user_id = 10; -- 影响 1 行!当前读看到了新行
SELECT COUNT(*) FROM t_order WHERE user_id = 10; -- 现在是 1(自己的修改对自己可见)
同一个事务里 COUNT 从 0 变成 1,这就是幻读。真正挡住插入的是间隙锁(见另一篇),不是 MVCC。
七、长事务为什么危险:undo 不能清理
版本链是给"还活着的 ReadView"服务的。只要还有一个事务没提交,InnoDB 就必须保留它可能需要的旧版本,于是 purge 线程清不掉 undo。
判断标准是 history list length:
sql
SHOW ENGINE INNODB STATUS\G -- 搜 History list length
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs,
trx_rows_locked, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
当 History list length 持续上涨、undo 表空间不断变大、查询越来越慢时,十有八九是某处挂着长事务(常见来源:事务里发了 MQ、调了 RPC、@Transactional 包住了整个批量任务、或者连接池里一个 BEGIN 后忘了提交)。注意:只读的长事务同样会拖住 purge,因为它持有的 ReadView 决定了旧版本的下限。
小结
- 每行数据通过
DB_TRX_ID+DB_ROLL_PTR串成版本链,UPDATE不改历史,只追加新版本。 - ReadView 是"活跃事务名册",4 条规则决定某一版能不能被我看见;看不见就顺着链往回退。
- RC 与 RR 的差别只在生成 ReadView 的时机:每条 SELECT vs 事务首次 SELECT。
- 快照读走 MVCC,当前读(
FOR UPDATE/UPDATE/DELETE)走最新版本 + 加锁,两者在同一事务里数值可以不一致。 - 长事务会把 undo 按住不放,监控
History list length比监控慢查询更早发现问题。
理解到这一层,"为什么读不到刚提交的数据"就不再是玄学,而是一条能画出来的判断链。