同一个事务里同一条 SELECT 跑两次,结果不一样;或者在一个"读已提交"的库里,别人提交的数据忽然出现在你事务的中间。这类问题的答案通常不在锁上,而在可见性判断:InnoDB 到底把哪个版本的行交给你。MVCC 不是"缓存了旧数据",而是一套按事务号逐版本判断的规则,规则读懂了,隔离级别的所有怪现象都能推导出来。
按"隔离级别 → MVCC 组成 → ReadView 规则 → 快照读与当前读 → RC/RR 差异 → 长事务"拆开。
1. 四种隔离级别要解决的三件事
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
READ UNCOMMITTED |
可能 | 可能 | 可能 | 读到未提交版本,基本不用 |
READ COMMITTED |
不会 | 可能 | 可能 | 每次读都看最新已提交快照 |
REPEATABLE READ |
不会 | 不会 | 标准允许 | InnoDB 默认,且额外避免了大部分幻读 |
SERIALIZABLE |
不会 | 不会 | 不会 | 普通 SELECT 隐式加共享锁,并发骤降 |
| 异常 | 定义 | 典型表现 |
|---|---|---|
| 脏读 | 读到别人未提交的修改 | 对方回滚了,你读到的是从未存在的数据 |
| 不可重复读 | 同一事务内两次读同一行结果不同 | 第二次看到别人的 UPDATE 已提交 |
| 幻读 | 同一条件下两次读行数不同 | 两次 COUNT(*) 不一致 |
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
2. InnoDB 的 RR 与标准定义不一样
标准里的 REPEATABLE READ 允许幻读,MySQL 的 RR 在多数场景下不发生,原因是两条路各堵一次:
| 读法 | 幻读的堵法 |
|---|---|
快照读(普通 SELECT) |
新插入的行事务号大于 ReadView 的 max_trx_id,直接判不可见 |
当前读(FOR UPDATE、UPDATE…) |
临键锁锁住区间,其他事务插不进去 |
所以"MySQL RR 没有幻读"这句话要加两个限定:只对走索引的当前读成立;唯一索引等值命中的当前读甚至只加记录锁,不加间隙锁——这时区间并没有被锁住,只是你查的那一行不存在,插入的位置被"不存在的行"保护不到。边界感就来自这里。
3. MVCC 的三个组成
| 组成 | 是什么 | 关键点 |
|---|---|---|
| 隐藏字段 | 每行都有 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID |
前者记录最后修改它的事务号,后者指向 undo 中的旧版本 |
| undo log 版本链 | 一条行的所有历史版本串成链 | 快照读沿链往回找,直到找到可见版本 |
| ReadView | 事务对"此刻哪些事务还活跃"的快照 | 决定某个版本的 DB_TRX_ID 该不该被看见 |
SHOW ENGINE INNODB STATUS\G -- TRANSACTIONS 段可见 History list length
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified
FROM information_schema.INNODB_TRX\G
DB_TRX_ID 与 DB_ROLL_PTR 的字节数(按 MySQL 8.0.x 文档核对:6 字节与 7 字节)决定了版本链指针的寻址方式,不必记死,但要记住一件事:版本链不是免费的,链越长,快照读回退的次数越多。
4. ReadView 的四个字段与完整判断规则
| 字段 | 含义 |
|---|---|
creator_trx_id |
创建这个 ReadView 的事务号 |
m_ids |
创建瞬间仍活跃(未提交)的事务号列表 |
min_trx_id |
m_ids 中的最小值 |
max_trx_id |
下一个待分配的事务号(注意不是 m_ids 的最大值) |
拿到一行的 DB_TRX_ID,按顺序判断:
| 顺序 | 条件 | 结论 |
|---|---|---|
| 1 | trx_id == creator_trx_id |
自己改的,可见 |
| 2 | trx_id < min_trx_id |
创建 ReadView 前已提交,可见 |
| 3 | trx_id >= max_trx_id |
创建之后才开启的事务,不可见 |
| 4 | 落在 [min_trx_id, max_trx_id) 且存在于 m_ids |
当时还没提交,不可见 |
| 5 | 落在区间内但不在 m_ids |
当时已提交,可见 |
不可见时沿 DB_ROLL_PTR 取上一版本,重新走这五步,直到找到一个可见版本或链到头(链头通常是"不存在",即这一行对本事务还不存在)。
max_trx_id 是最容易记错的一个:它是"下一个要发的号",所以判断用的是 >=;m_ids 的最大值只用于参考。写错了这条,幻读的解释就全拧了。
5. 快照读与当前读的分界
| 语句 | 读法 | 加的锁 |
|---|---|---|
SELECT |
快照读 | 无 |
SELECT ... LOCK IN SHARE MODE |
当前读 | 共享临键锁 |
SELECT ... FOR UPDATE |
当前读 | 排他临键锁 |
UPDATE / DELETE |
当前读(先读后改) | 排他记录锁 + 间隙锁 |
INSERT |
当前读 | 插入意向锁 |
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1; -- 快照读
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 当前读
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
分界很干净:只要语句会改数据,或者要求"读到最新的并锁住它",就是当前读。当前读一律绕过 ReadView,直接读最新已提交版本,然后按需加锁。所以"事务里先 SELECT 再 FOR UPDATE 两次结果不同"是正常现象,不是 BUG。
6. RR 与 RC 的根本差异:ReadView 什么时候建
| 隔离级别 | ReadView 建立时机 | 直接后果 |
|---|---|---|
REPEATABLE READ |
事务内第一次快照读时建立,之后复用 | 整个事务看到同一个时间点,不可重复读消失 |
READ COMMITTED |
每次快照读都重建 | 每次都能看到最新已提交数据,不可重复读出现 |
-- 会话 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 100
-- 会话 B 此时把余额改成 200 并提交
SELECT balance FROM accounts WHERE id = 1; -- RR: 100;RC: 200
COMMIT;
同样的脚本把隔离级别换掉,第二次读的结果就变了:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1; -- 100
-- 会话 B 提交 200
SELECT balance FROM accounts WHERE id = 1; -- 200
COMMIT;
在 RC 下同一条语句两次结果不同不是异常,正是它的语义。要注意 RC 下"每条语句一个新快照"会让 UPDATE 的 WHERE 条件和随后的赋值可能落在不同快照上,这也是 RC 被广泛用于减少间隙锁的同时,要求 SQL 更克制的原因。
还有一种常被忽略的代价:一致性读并不便宜。RR 下事务越久,读到老版本的概率越高,而每个老版本都要沿 DB_ROLL_PTR 回退一次。回退发生在 server 层与引擎层之间,靠 row_search_mvcc 完成,单次开销不大,但一旦链长到几十上百个版本,同一条 SELECT 的成本会明显偏离 EXPLAIN 的预估。
7. 长事务为什么危险
| 现象 | 机制 | 后果 |
|---|---|---|
| undo 不能清理 | purge 线程只回收"没有 ReadView 还需要"的版本 | 回滚段与 ibdata/undo 表空间持续膨胀 |
| 版本链变长 | 每个新版本都挂在链上 | 快照读回退次数增加,读变慢 |
| 一致性读成本上升 | 读到的是很老的版本,要沿链回溯 | 单个查询的 CPU 与 IO 上升 |
| 主从延迟 | 大事务在从库串行回放 | 从库 Seconds_Behind_Master 上涨 |
SELECT trx_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
INNODB_TRX 里那些 age_sec 上千秒、trx_query 为 NULL 的事务最值得警惕——通常是应用开了事务却没提交,连接还挂在池里。这类事务本身什么都没做,但足以让 purge 停在那里,把磁盘慢慢吃掉。
8. 排错:五种"读到的东西不对"
| 现象 | 原因 | 处理 |
|---|---|---|
| 同一事务两次读结果不同 | 实际跑在 RC 下 | 确认 @@transaction_isolation 与会话级设置 |
| 事务里读不到别人刚提交的数据 | RR 快照固定,这是预期行为 | 需要最新值就用当前读 |
FOR UPDATE 报锁等待 |
当前读绕过快照,真去抢锁 | 缩短事务;确认加锁顺序一致 |
| undo 表空间只涨不缩 | 有长事务/未提交事务挡住 purge | 查 INNODB_TRX,杀掉并修应用 |
| 明明没改数据却拿到旧值 | 版本链回退到了更早的可读版本 | 用当前读验证;检查是否有超长事务 |
9. 边界:MVCC 管不到的地方
| 场景 | MVCC 是否解决 | 说明 |
|---|---|---|
| 读到最新值并锁定 | 不解决 | 必须用当前读 |
| 防止两事务同时改同一行 | 不解决 | 靠行锁 |
| 防止范围内插入新行 | 部分解决 | 靠临键锁,且要走到索引 |
| 跨库/跨实例一致性快照 | 不解决 | 需要全局事务或业务补偿 |
| 缓存与数据库一致 | 不解决 | MVCC 只作用于 InnoDB 内部读取 |
一句话收束:快照读看不见"我之外"的写入,当前读看得见所有已提交写入并顺手加锁;RC 与 RR 的差别只有一条——ReadView 什么时候建。
10. 自测
- ReadView 里
max_trx_id指的是什么?为什么判断用它而不是m_ids的最大值? - 一行数据的
trx_id落在m_ids区间外但在[min_trx_id, max_trx_id)内,可见吗? - RR 下"快照读不幻读"和"当前读不幻读",机制分别是什么?
- 为什么同一个事务里先
SELECT再SELECT ... FOR UPDATE会看到不同结果? - 哪些语句属于当前读?
- 长事务是怎么把磁盘吃满的?