一句话总结
MySQL InnoDB 通过 MVCC(多版本并发控制)实现非锁定读,核心依赖 undo log 版本链 + ReadView。每行数据有隐藏列 trx_id(最近修改的事务ID)和 roll_pointer(指向 undo log),ReadView 根据事务启动时的活跃事务列表判断版本可见性。MVCC 只在 RC(读已提交)和 RR(可重复读)隔离级别下生效,RR 级别下同一个事务始终使用同一个 ReadView。
初级理解
事务 ACID 特性
| 特性 | 含义 | InnoDB 实现方式 |
| A 原子性 | 事务要么全做,要么全不做 | undo log(回滚日志) |
| C 一致性 | 事务前后数据满足约束 | 由 AID 共同保证 |
| I 隔离性 | 并发事务互不干扰 | MVCC + 锁 |
| D 持久性 | 提交后数据永久保存 | redo log(重做日志) |
四种隔离级别与并发问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
| READ UNCOMMITTED | ❌ 可能 | ❌ 可能 | ❌ 可能 |
| READ COMMITTED | ✅ 解决 | ❌ 可能 | ❌ 可能 |
| REPEATABLE READ(默认) | ✅ 解决 | ✅ 解决 | ⚠️ 部分解决 |
| SERIALIZABLE | ✅ 解决 | ✅ 解决 | ✅ 解决 |
# 查看当前隔离级别
SELECT @@transaction_isolation; # MySQL 8.0
SELECT @@tx_isolation; # MySQL 5.7
# 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
一句话总结:MVCC 是 InnoDB 实现非锁定读的核心机制,通过版本链 + ReadView 让读不阻塞写、写不阻塞读。
中级深入
undo log 版本链
每行数据有两个隐藏列:
DB_TRX_ID(6字节):最近修改这行数据的事务 ID
DB_ROLL_PTR(7字节):回滚指针,指向 undo log 中的旧版本
每次更新都会生成一条 undo log,roll_pointer 串联成版本链。
# 版本链示意(事务 ID 递增)
# 初始:trx_id=100, name='张三', roll_ptr → null
# 事务200 UPDATE name='李四':
# 当前行:trx_id=200, name='李四', roll_ptr → undo(trx_id=100, name='张三')
# 事务300 UPDATE name='王五':
# 当前行:trx_id=300, name='王五', roll_ptr → undo(trx_id=200, name='李四')
# → undo(trx_id=100, name='张三')
# 版本链:当前行 → undo200 → undo100 → null
# 事务根据 ReadView 沿版本链找到可见版本
ReadView — 可见性判断的核心
ReadView 包含四个关键信息:
| 字段 | 含义 |
| m_ids | 生成 ReadView 时,当前系统中活跃的(未提交的)事务 ID 列表 |
| min_trx_id | m_ids 中的最小值 |
| max_trx_id | 生成 ReadView 时,系统下一个要分配的事务 ID(即最大事务ID+1) |
| creator_trx_id | 生成该 ReadView 的事务 ID |
判断版本链中某个版本(trx_id)是否可见的规则:
# ReadView 可见性判断规则
def is_visible(trx_id, readview):
# 1. trx_id == creator_trx_id → 可见(自己修改的)
if trx_id == readview.creator_trx_id:
return True
# 2. trx_id < min_trx_id → 可见(修改它的事务在 ReadView 创建前已提交)
if trx_id < readview.min_trx_id:
return True
# 3. trx_id >= max_trx_id → 不可见(修改它的事务在 ReadView 创建后才开始)
if trx_id >= readview.max_trx_id:
return False
# 4. min_trx_id <= trx_id < max_trx_id:
# 如果 trx_id 在 m_ids 中 → 不可见(事务还未提交)
# 如果 trx_id 不在 m_ids 中 → 可见(事务已提交)
if trx_id in readview.m_ids:
return False
return True
RC vs RR — ReadView 生成时机不同
| 隔离级别 | ReadView 生成时机 | 效果 |
| READ COMMITTED | 每次 SELECT 都生成新的 ReadView | 能读到其他事务已提交的修改(不可重复读) |
| REPEATABLE READ | 事务第一次 SELECT 时生成,之后复用 | 始终读到事务开始时的数据(可重复读) |
# RR 级别下可重复读示例
# 事务A(RR级别)
BEGIN;
SELECT name FROM users WHERE id = 1; # 生成 ReadView,读到 '张三'
# 此时事务B UPDATE users SET name='李四' WHERE id=1; COMMIT;
SELECT name FROM users WHERE id = 1; # 复用 ReadView,仍读到 '张三'
COMMIT;
# RC 级别下不可重复读示例
# 事务A(RC级别)
BEGIN;
SELECT name FROM users WHERE id = 1; # 生成 ReadView1,读到 '张三'
# 事务B UPDATE users SET name='李四' WHERE id=1; COMMIT;
SELECT name FROM users WHERE id = 1; # 生成 ReadView2,读到 '李四'
COMMIT;
中级要点:MVCC = undo log 版本链 + ReadView 可见性判断;RC 每次 SELECT 新 ReadView,RR 复用同一个 ReadView。
高级拓展
快照读 vs 当前读
| 类型 | SQL 语句 | 读取方式 |
| 快照读 | 普通 SELECT | 读 MVCC 版本链中的可见版本(不加锁) |
| 当前读 | SELECT ... FOR UPDATE / LOCK IN SHARE MODE | 读最新版本 + 加锁 |
| 当前读 | UPDATE / DELETE / INSERT | 读最新版本 + 加锁 |
# 快照读(MVCC,不加锁)
SELECT * FROM users WHERE id = 1;
# 当前读(加锁,读最新版本)
SELECT * FROM users WHERE id = 1 FOR UPDATE; # 加 X 锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; # 加 S 锁
RR 级别下幻读如何解决?
MVCC 的快照读解决了部分幻读(SELECT 不会读到其他事务插入的数据),但当前读仍可能幻读:
# RR 级别幻读场景
# 事务A
BEGIN;
SELECT * FROM users WHERE age > 20; # 快照读,3条
# 事务B INSERT INTO users VALUES(4, '王五', 25); COMMIT;
SELECT * FROM users WHERE age > 20; # 快照读,还是3条(MVCC解决)
SELECT * FROM users WHERE age > 20 FOR UPDATE; # 当前读,4条!(幻读)
COMMIT;
# InnoDB 通过 Next-Key Lock(行锁+间隙锁)解决当前读的幻读
# SELECT ... FOR UPDATE 会锁住 age>20 的所有行 + 间隙
undo log 的清理
undo log 不会一直保留,当没有事务需要访问旧版本时,由 purge 线程清理。长事务会导致 undo log 堆积,占用大量磁盘空间。
# 查看 undo log 相关状态
SHOW ENGINE INNODB STATUS\G
# 查看长事务
SELECT * FROM information_schema.innodb_trx
WHERE trx_started < DATE_SUB(NOW(), INTERVAL 60 SECOND);
# 查看 undo 表空间大小
SELECT TABLESPACE_NAME, FILE_SIZE/1024/1024 AS size_mb
FROM information_schema.FILES
WHERE TABLESPACE_NAME LIKE 'undo%';
实战场景
场景:排查长事务导致 undo log 堆积
# 1. 查看当前活跃事务
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
# 2. 找到持续时间超过 60 秒的事务
SELECT * FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
# 3. 找到对应的连接并 KILL
SELECT CONCAT('KILL ', trx_mysql_thread_id, ';')
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
# 4. 预防:设置事务超时
SET SESSION innodb_lock_wait_timeout = 10; # 锁等待超时 10 秒
SET SESSION lock_wait_timeout = 10; # 元数据锁等待超时
面试模拟
面试官:MVCC 是如何实现可重复读的?
你:核心是 ReadView 的生成时机。RR 级别下,事务第一次 SELECT 时生成一个 ReadView,之后所有 SELECT 都复用这个 ReadView。ReadView 记录了当时活跃的事务列表,沿 undo log 版本链判断每个版本的可见性:如果版本的事务ID在活跃列表中(未提交),就不可见,继续往前找。这样即使其他事务提交了修改,本事务也读不到,实现了可重复读。
面试官:RC 和 RR 在 MVCC 实现上有什么区别?
你:区别在于 ReadView 的生成时机。RC 每次 SELECT 都生成新的 ReadView,所以能读到其他事务已提交的修改(不可重复读)。RR 只在第一次 SELECT 时生成 ReadView,之后复用,所以始终读到事务开始时的数据(可重复读)。
面试官:MVCC 能完全解决幻读吗?
你:不能完全解决。MVCC 的快照读(普通 SELECT)不会幻读,但当前读(SELECT FOR UPDATE)仍可能幻读。InnoDB 通过 Next-Key Lock(行锁+间隙锁)来解决当前读的幻读问题。