MVCC如何让数据库实现高并发?揭秘多版本并发控制的底层逻辑
MVVC的实现依赖于:行记录隐藏的列、ReadView、undolog
1. 基本概念
1.1. 行记录隐藏的列
- ROW_ID:隐藏的自增id,如果没用设置主键或者唯一非空索引,MySQL会自动生成一个
- TRX_ID:最后修改当前记录的事务id(每开启一个事务,就会从数据库获取一个事务id,这个id是自增的,可以以此判断事务的时间顺序)
- ROLL_PTR:undolog回滚指针,存放着指向上一次记录的旧数据
1.2. ReadView

ReadView中的几个重要字段:
- creator_trx_id:创建ReadView的事务的事务id
- m_ids:创建ReadView时,当前数据库中“活跃且未提交”的事务id列表
- min_trx_id:创建ReadView时,当前数据库中“活跃且未提交”的事务中最小的事务id
- max_trx_id:创建ReadView时,当前数据库中“活跃且未提交”的事务中最大的事务id


ReadView的一些可见性规则:
- 如果记录中的trx_id小于ReadView中的min_trx_id,表示访问的记录已经在创建快照之前启动事务生成的,数据可见
- 如果记录中的trx_id大于等于ReadView中的max_trx_id,表示访问的记录在创建快照之后才启动事务生成的,数据不可见
- 如果记录中的trx_id介于min_trx_id和max_trx_id之间
-
- 如果trx_id在m_ids中不存在,表示事务已经提交,数据可见
- 如果trx_id在m_ids中存在,表示事务尚未提交,数据不可见
1.3. undolog
MVVC使用过的快照会被存储到undolog中,通过回滚指针将一个个历史执行记录形成一个版本链
2. 快照读与当前读
2.1. 快照读
- 定义:快照读基于MVVC机制,读取的是历史数据(即ReadView创建时查询到的数据),而非最新数据
- 特点:
-
- 非阻塞:普通的
SELECT语句属于快照读,不会阻塞其他读写操作 - 数据一致性:只要事务未提交,读取的都是ReadView创建时查询到的数据,即使其他事务已经提交修改,读取的也是历史数据
- 非阻塞:普通的
- 使用场景:
-
- RR级别:执行第一条
SELECT语句时创建一个ReadView快照,后续查询相同记录都读取历史数据 - RC级别:执行每一条
SELECT语句时创建一个ReadView快照
- RR级别:执行第一条
2.2. 当前读
- 定义:当前读读取的是最新的实时数据,并对记录加锁,其他事务无法进行修改
- 特点:
-
- 锁机制:通过锁机制(共享锁、排他锁)保证数据的一致性
- 数据实时性:获取最新的实时数据
- 使用场景:
-
- RR级别:使用Next-Key Lock解决幻读问题
- Serializable:每一条普通的
SELECT语句强制采取当前读
| 对比项 | 快照读 | 当前读 |
|---|---|---|
| 数据版本 | 历史版本(事务启动时的快照) | 最新已提交版本 |
| 加锁 | 不加锁 | 加共享锁(S锁)或排他锁(X锁) |
| 隔离级别 | RR、RC(部分支持) | RR、Serializable(强制使用) |
| 操作类型 | 普通SELECT | SELECT ... FOR UPDATE、UPDATE、DELETE等 |
| 幻读处理 | 依赖MVCC历史版本,可能部分规避 | 通过Next-Key Lock完全防止 |
| 性能影响 | 低开销(无需锁) | 高开销(锁竞争) |
3. 可重复读是如何工作的?
假设先启动事务A(事务id为51),再启动事务B(事务id为52)
两个事务的ReadView创建和假设数据库中某条记录如下:

3.1. 接着我们来执行下面一系列操作
- 事务B读取id为1账户内的balance余额 , 为100w
- 事务A修改id为1账户内的balance余额为200w , 但未提交
- 事务B再次读取id为1账户内的balance余额 , 为100w
- 事务A提交
- 事务B再次读取id为1账户内的balance余额 , 为100w
3.2. 接下来具体分析一下每个步骤
结合图来理解

- 事务B读取id为1账户内的balance余额:
-
- 事务B查询数据库内记录的隐藏列trx_id为50,比min_trx_id(即51)小 , 证明在创建事务B的快照时就已经提交,所以直接读取到100w
- 事务A修改id为1账户内的balance余额为200w , 但未提交 :
-
- 事务A修改数据库中的数据
- 同时也修改了该记录隐藏列中的trx_id为事务A的事务id(即51)
- 同时将roll_pointer指向修改前的旧数据
- 事务B再次读取id为1账户内的balance余额 :
-
- 事务B再次查询该记录时 , 发现隐藏列中的trx_id为51 , 介于min_trx_id(即51)和max_trx_id(即52)之间
- 接着判断是否存在于m_ids中 , 发现存在 , 则表示事务尚未提交就创建了事务B的快照 , 其修改对于事务B不可见
- 所以事务B顺着roll_pointer对应的版本链读取到最近一条对于事务B可见的记录(也就是trx_id为50的记录),获取记录为100w
-
事务A提交
-
事务B再次读取id为1账户内的balance余额
-
- 过程和3类似
4. 读提交是如何工作的?
我们同样先后启动两个A,B两个事务
4.1. 接下来执行具体操作:
- 事务B读取id为1账户内的balance余额 , 为100w
- 事务A修改id为1账户内的balance余额为200w , 但未提交
- 事务B再次读取id为1账户内的balance余额 , 为100w
- 事务A提交
- 事务B再次读取id为1账户内的balance余额 , 为200w
4.2. 接下来还是具体分析过程
同样结合图来理解:

- 事务B读取id为1账户内的balance余额
-
- 事务B查询数据库内记录的隐藏列trx_id为50,比min_trx_id(即51)小 , 证明在创建事务B的快照时就已经提交,所以直接读取到100w
- 事务A修改id为1账户内的balance余额为200w , 但未提交
-
- 事务A修改数据库中的数据
- 同时也修改了该记录隐藏列中的trx_id为事务A的事务id(即51)
- 同时将roll_pointer指向修改前的旧数据
- 事务B再次读取id为1账户内的balance余额
-
- 事务B再次查询该记录时 , 发现隐藏列中的trx_id为51 , 介于min_trx_id(即51)和max_trx_id(即52)之间
- 接着判断是否存在于m_ids中 , 发现存在 , 则表示事务尚未提交就创建了事务B的快照 , 其修改对于事务B不可见
- 所以事务B顺着roll_pointer对应的版本链读取到最近一条对于事务B可见的记录(也就是trx_id为50的记录),获取记录为100w
- 事务A提交

- 事务B再次读取id为1账户内的balance余额
- 所以事务B顺着roll_pointer对应的版本链读取到最近一条对于事务B可见的记录(也就是trx_id为50的记录),获取记录为100w
- 事务A提交

- 事务B再次读取id为1账户内的balance余额
-
- 事务B第三次查询该记录时 , 发现隐藏列中的trx_id为51 , 小于min_trx_id (即52),表示该记录对于事务B可见,所以直接获取trx_id为51的记录,返回200w


被折叠的 条评论
为什么被折叠?



