MVCC(多版本并发控制):是 MySQL InnoDB 实现并发读的底层机制;
事务隔离级别:是数据库定义的,事务之间可见性的规则标准。
InnoDB 使用 MVCC + 锁 共同实现 4 种隔离级别。
一、MVCC 核心原理(InnoDB)
MVCC 的核心思想:
每行数据保存多个版本,读的时候读快照版本,写的时候新建版本,不加锁,实现读写不阻塞。
快照,本质就是某一个时间点数据库的数据逻辑镜像。
它并不是把整份数据复制一份存起来,而是依靠Read View + undo log 版本链拼接出来的虚拟视图。
1. 隐藏列(每条记录自带)
DB_TRX_ID:最近修改这条记录的事务 ID(新增 / 更新时写入)DB_ROLL_PTR:回滚指针,指向undo log里的旧版本数据,形成版本链DB_ROW_ID:隐藏主键,没有主键时使用
undo log作用:保存数据修改前的旧版本,用于回滚 + 构建快照读。
undo log(回滚日志):逻辑日志,记录的是数据变更的逻辑内容,不是磁盘上的物理字节。
undo log 记录的是反向操作:
比如执行
update t set name='b' where id=1;undo log 里存的是:
id=1,name原来的值是'a'当事务回滚时,直接把旧值写回去。
它不关心数据页在磁盘上长什么样,只关心行数据的逻辑变更,所以叫逻辑日志。
2. Read View(读视图,MVCC 核心)
Read View:
快照的「可见性规则」,记录生成快照瞬间所有活跃事务 ID,用来判断版本能不能看见。
Read View 四个核心字段
- m_ids:当前系统里所有活跃未提交事务 ID 集合(开启了,但还没 commit 的事务)
- min_trx_id:
m_ids里面最小的事务 ID- max_trx_id:下一个将要分配的事务 ID(不是 m_ids 最大值,是全局事务 ID 计数器)
- creator_trx_id:创建这个 Read View 的当前事务 ID
可见性判断规则(判断某行记录的
trx_id)记录行上的
trx_id:创建这行版本的事务 ID
- 如果
trx_id < min_trx_id:可见,事务早已提交- 如果
trx_id >= max_trx_id:不可见,这个事务是创建 Read View 之后才开启的- 如果
min_trx_id <= trx_id < max_trx_id:
- 若
trx_id在m_ids集合中:不可见(事务还没提交)- 不在
m_ids:可见(事务已经提交)如果当前版本不可见,就顺着 undo log 往前找历史版本,重复判断,直到找到可见版本,或者没有历史版本(返回空)。
快照读(Snapshot Read)
不加锁的普通 SELECT 查询,就是快照读。
读取的是快照版本的数据,依靠 MVCC(Read View + undo log 版本链)实现,不加行锁。
核心特点
- 不加锁:读写不阻塞。写操作修改最新行,读操作去读 undo log 里的历史版本,互不阻塞。
- 读到的不是当前最新数据,而是快照可见版本。
- 底层依赖:
Read View+ undo log 版本链。什么时候生成 Read View?
- RR(可重复读,InnoDB 默认隔离级别):事务中第一次快照读的时候生成 1 个 Read View,整个事务复用这一个。
✅ 所以 RR 可以解决幻读(MVCC 层面),因为整个事务快照不变。
- RC(读已提交):每一次快照读,都会重新生成一个新的 Read View。
✅ 所以 RC 可以读到别的事务已提交的数据,无法防止幻读。
⚠️ 重点区分: RC:每次 select 都新建 ReadView RR:事务第一次 select 才创建 ReadView,后续 select 复用同一个
总结
Read View 就是快照读的一个时间快照,记录生成快照那一刻所有活跃事务,用来过滤 undo log 版本链,决定哪一行旧数据能被当前事务读到。
事务执行快照读(普通 select)时生成一个 Read View,用来判断这条记录的版本对当前事务是否可见。 Read View 4 个字段:
m_ids:当前活跃未提交事务 ID 集合min_trx_id:m_ids 里最小事务 IDmax_trx_id:下一个将要分配的事务 IDcreator_trx_id:当前事务自己的 ID
可见性判断规则(重点)
拿到记录的DB_TRX_ID和 Read View 对比:
DB_TRX_ID < min_trx_id:事务已经提交 →可见DB_TRX_ID >= max_trx_id:事务在 Read View 之后开启 →不可见min_trx_id <= DB_TRX_ID < max_trx_id:在活跃区间- 如果
DB_TRX_id在m_ids里:事务还没提交 →不可见 - 不在 m_ids:已经提交 →可见
- 如果
如果当前版本不可见,顺着
roll_ptr去 undo log 找版本链上更早版本继续判断。
- 数据最新版本(物理行):磁盘上当前这条记录,DB_TRX_ID 是最后修改它的事务,这是当前版本,
update/delete/select ... for update(当前读)就读这个。- 快照版本:事务在某个时间点生成 Read View 之后,根据这个视图规则,沿着 undo log 版本链,找到对当前事务可见的那一条历史版本,就是快照版本。
⚠️ 重点:快照版本不会复制一份新数据到磁盘,只是复用 undo log 里旧记录,靠版本链跳转读取,所以开销很小。
3. 快照读 vs 当前读
- 快照读(普通 SELECT):走 MVCC,读取快照版本,不加行锁,读写不阻塞。
select * from table; - 当前读(加锁读):读取最新已提交版本,会加行锁,不走 MVCC 快照。
select ... for update / lock in share mode; update / delete / insert
❗ MVCC 解决的是快照读的并发问题,当前读靠锁。
二、SQL 标准 4 种隔离级别 + InnoDB 实现方式
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现方案 |
|---|---|---|---|---|
| 读未提交 Read Uncommitted | ✅存在 | ✅存在 | ✅存在 | 不使用 MVCC,直接读最新数据 |
| 读已提交 Read Committed(RC) | ❌解决 | ✅存在 | ✅存在 | 每次快照读,都生成新 Read View |
| 可重复读 Repeatable Read(RR,MySQL 默认) | ❌解决 | ❌解决 | ⚠️ 快照读解决;当前读仍存在,靠间隙锁部分抑制 | 事务第一次快照读时生成 Read View,整个事务复用这一个 Read View |
| 串行化 Serializable | ❌解决 | ❌解决 | ❌解决 | 关闭 MVCC,所有 select 自动转为当前读,加共享锁,完全串行 |
逐个拆解
1. 读未提交 RU
事务可以读到其他事务未提交的数据。 InnoDB 这个级别不使用 MVCC,直接读最新行数据。几乎不会使用。
2. 读已提交 RC
- 只能读到已经提交的数据,杜绝脏读
- 每次 select 快照读都会生成全新 Read View
现象:同一个事务,先后两次 select,中间别的事务提交修改,两次读到不一样数据 →不可重复读RC 没有间隙锁,所以幻读问题也存在。
3. 可重复读 RR(MySQL InnoDB 默认隔离级别)
核心:事务第一次快照读的时候创建 Read View,后续快照读全程复用这一个视图所以同一个事务多次快照读,看到的数据和第一次一致,解决不可重复读。
幻读说明(面试高频) 幻读:一个事务内,同样的查询,第二次查出多 / 少了新插入的数据。
- 快照读:MVCC 快照里不包含新插入记录,快照读看不到幻读
- 当前读(for update/update):MVCC 快照无效,会读到新插入行,仍然存在幻读InnoDB RR 通过间隙锁 + 临键锁,阻止其他事务插入,抑制幻读(不是彻底消除)
RC vs RR MVCC 最大区别一句话: RC:每次 select 新建 ReadView;RR:事务第一次 select 才创建 ReadView,复用。
4. 串行化 Serializable
最高隔离级别,MVCC 失效。 普通select自动等价于select ... lock in share mode,全部变成加锁当前读,读写互相阻塞,完全串行执行,性能最差。脏读、不可重复读、幻读全部解决。
三、经典现象举例(RR)
事务 A(RR)
begin; select * from user where id=1; -- 第一次快照读,生成ReadView,version=100 -- 此时事务B开启,修改id=1并commit select * from user where id=1; -- 复用旧ReadView,仍然读到version=100,不会读到B提交的数据 → 可重复读同样场景,RC 下:第二次 select 会新建 ReadView,读到事务 B 提交后的新数据,出现不可重复读。
四、引入 Seata AT 之后,数据库隔离级别推荐是什么?
✅生产推荐:MySQL 使用 RC(读已提交)
口述版: Seata AT 模式官方推荐数据库隔离级别为 RC。 原因:
- Seata AT 的核心原理:一阶段执行业务 SQL,生成 undo_log 快照,提交本地事务;二阶段提交直接删 undo_log,回滚时通过 undo_log 恢复数据。
- 如果用 RR(可重复读):MySQL RR 是基于 MVCC 快照。事务开启时就拿到快照。
- 一阶段本地事务提交了,但其他事务读到的还是旧快照;
- 此时如果发生二阶段回滚,通过 undo_log 恢复数据,别的事务的 MVCC 快照看不到回滚后的数据,会读到脏数据,出现分布式脏读。
- RC 下,每次查询都会获取最新快照,一旦二阶段回滚完成,其他事务马上读到最新数据,规避上面这个分布式脏读问题。
- 补充:RR 不是不能用,只是坑多,不推荐生产;Seata 本身不会改变 MySQL 底层隔离级别,隔离级别是数据库层面配置,Seata 只是协调分布式事务。
一句话记忆:Seata AT 推荐 RC,避免 RR 的 MVCC 快照长时间持有,造成分布式脏读
注意区分:
- MySQL 隔离级别:控制库内事务可见性
- Seata 全局事务隔离级别:是 Seata 自己的概念,有
读未提交、读已提交,和数据库隔离级别不是一回事。 Seata 全局隔离级别默认是读未提交,可以配置为读已提交,但性能会下降。
五、引入 Seata 之后,MVCC 会带来什么问题?
重点讲AT 模式下 MySQL MVCC 带来的 2 个核心问题(面试高频)
问题 1:RR 隔离级别下的分布式脏读(最重要)
场景:全局事务 G1,AT 模式
- G1 一阶段:本地事务执行 update,写入 undo_log,本地事务提交(此时数据库数据已经改了,undo_log 保留)
- 此时全局事务还没二阶段提交,属于全局未提交状态
- 另一个全局事务 G2,MySQL 隔离级别 RR:G2 事务开启时拿到 MVCC 旧快照
- G2 查询这行,拿到的是快照里旧数据(G1 修改前的数据)
- 这时 G1 发生回滚,二阶段通过 undo_log 把数据恢复回去
- G2 继续查询,仍然是它事务一开始的快照,读到的还是旧数据。
现象:全局事务已经回滚,但 G2 读到了被回滚之前的数据,分布式脏读。
根源:RR 的 MVCC 快照是事务启动时固定,不受全局事务二阶段回滚影响。
解决:数据库改成 RC,RC 每次查询重新拿最新快照,回滚完成后查询就能看到最新值。
问题 2:undo_log 快照 和 MySQL MVCC 快照不一致问题
Seata 的 undo_log 是业务 SQL 执行前手动记录的数据快照,是 Seata 自己的快照;MySQL MVCC 是数据库自动维护的 undo 版本链,两套快照体系。
- RC 模式:每次读最新版本,业务查询走 MySQL 最新数据,和 Seata undo_log 的时序更容易对齐;
- RR 模式:MySQL 读的是 MVCC 快照,有可能读到和 Seata undo_log 不一致的数据版本,增加数据一致性风险。
延伸坑:RC 下也有小问题
RC 虽然解决上面脏读,但 RC 本身会出现不可重复读。
在 Seata 长全局事务场景,同一个分支事务内多次查询,可以读到其他已提交事务的数据,业务代码需要考虑这个特性。
三、扩展
Q1:Seata 全局隔离级别和 MySQL 隔离级别区别?
MySQL 隔离级别:单机数据库事务可见性,依赖 MVCC、锁。
Seata 全局隔离级别:分布式事务之间的数据可见性,Seata AT 默认全局读未提交。
如果设置 Seata 全局读已提交:Seata 会加锁,其他全局事务不能读到当前全局事务一阶段修改但未二阶段提交的数据,代价是性能下降,并发降低。
Q2:Seata TCC 模式也要 RC 吗?
TCC 没有 undo_log,不需要依赖 MySQL undo 快照,TCC 对数据库隔离级别没有强制要求,RC/RR 都可以。上面的 MVCC 脏读问题主要是 AT 模式特有。
Q3:为什么 AT 一阶段本地事务要提交?
AT 一阶段:业务 SQL + 写入 undo_log 在同一个本地事务,然后提交本地事务。 本地提交,释放数据库锁,提升并发;但数据已经落库,只是 undo_log 保留,等待二阶段决定提交 / 回滚。这也是为什么会出现中间状态,需要处理可见性问题。
Q4:Seata AT 默认全局读未提交,但本地是 RC?
数据库本地隔离级别(MySQL):RC(Read Committed)
Seata AT【全局事务隔离级别】默认:Read Uncommitted(全局读未提交)
这两个不是一个维度。
原理:
AT 模式一阶段:
- 分支本地事务直接提交到数据库(本地 RC,别的普通 SQL 能读到这个分支修改的数据)
- 但是全局事务还没提交,TC 上的全局锁还没释放,随时可能二阶段回滚,undo_log 会把数据改回去
👉 现象: 别的普通
select可以读到分支已经本地提交,但全局还没提交的数据。 这就是 Seata 文档说的全局脏读,也就是全局层面的读未提交。数据库 RC 只保证:看不到同一个数据库本地事务未提交的数据;
RC 管不了「本地已经提交,但分布式全局事务还没提交,后续可能回滚」这个场景。
举例子:
- 全局事务 G1,调用分支 B1 更新余额,B1 本地事务提交(MySQL RC,别的 SQL 能读到 B1 修改)
- 但是 G1 还没全局提交,随时会触发回滚,undo_log 恢复旧数据
- 此时另一个普通 select 读到了 B1 修改的值:读到了全局未提交事务的数据,全局脏读。
ex:
G1 全局事务,分支 B1 扣余额,B1 本地事务已经提交(DB 层面可见),但 G1 全局还没提交。
此时业务查询读到扣减后的余额;
如果后续 G1 发生回滚,undo_log 把余额恢复回去。
👉结果: 查询在一段时间内读到了一个最终会被撤销的数据。
Q5 : 如何解决读到全局未提交事务的数据这个问题?
答案:
升级成【全局读已提交(Global Read Committed)】
想要全局 RC,两种写法:
- SQL 写
SELECT ... FOR UPDATESeata 会代理这条 SQL,去 TC 校验全局锁;如果数据被别的全局事务持有全局锁,就阻塞重试,直到拿到全局锁(代表对方全局事务已经完成),才返回数据。保证读到的数据一定是全局已提交的。- 注解
@GlobalLock+ select for update,效果一样。⚠️ 只有
select for update会被 Seata RM 代理、检查全局锁;普通 select 不会拦截,会出现全局脏读。