隔离的实现主要有读写锁和MVCC(Multi-Version Concurrency Control)多版本并发处理方式。
数据库并发的场景有三种:
读-读 :不存在任何问题,也不需要并发控制
读-写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
写-写 :有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失。
1.读写锁
最简单直接的的事务隔离实现方式,每次读操作需要获取一个S锁(共享锁),每次写操作需要获取一个X锁(排它锁)。共享锁之间不会产生互斥,共享锁和写锁之间、以及写锁与写锁之间会产生互斥。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。当产生锁竞争时,需要等待其中一个操作释放锁后,另一个操作才能获取到锁。
当然读写锁也并不仅向上面说的那么简单,它会根据隔离级别灵活调整锁的粒度和持有时间,例如:
- 串行化级别:会严格使用读写锁,且锁的范围可能扩大到表或范围(如范围锁),强制事务串行执行(读加 S 锁,写加 X 锁,且锁会持有到事务结束)。
较低隔离级别(如读已提交):可能仅在写操作时加 X 锁,且 X 锁在写操作完成后立即释放(而非事务结束);读操作不加锁或仅加临时 S 锁(读完即释放),从而允许更高并发,但可能出现不可重复读。
2.MVCC
MVCC的存在使得数据库读取数据时通过一种类似快照的方式将数据保存下来,为数据保存多个历史版本,让读写操作互不阻塞。
理解 MVCC 需要知道三个前提知识:
- 3个记录隐藏字段
- undo log
- Read View
2.1.三个记录的隐藏字段
DB_TRX_ID(Database Transaction ID):6 byte,记录最后一次修改(修改/插入 )该数据的事务ID
DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本(指向回滚段(Undo Log)中该数据的历史版本)
DB_ROW_ID:6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID产生一个聚簇索引
实际还有一个删除flag隐藏字段,但并不是直接从磁盘上删掉这条记录,而是通过修改这个隐藏的删除标记来实现逻辑删除。
当事务执行DELETE时,并不会直接移除记录,而是:
- 生成一条新的版本记录(类似更新操作)
- 将新记录的DB_DEL_FLAG设为1
- 更新DB_TRX_ID为当前事务 ID
- DB_ROLL_PTR指向删除前的旧版本(未删除状态)
查询时如何处理删除标记:
- 当事务通过 Read View 筛选版本时,会同时检查DB_DEL_FLAG:
- 如果标记为0(未删除),且事务可见,则返回该版本
- 如果标记为1(已删除),则跳过该版本,继续遍历更早的版本
- 若所有版本都被标记为删除,则查询结果中不返回该记录
2.2.undo 日志
undo log就是我们通常说的回滚日志,undo log存放数据的历史版本,也可以叫数据的快照,当数据被修改时,旧版本会被存入 Undo Log,通过 DB_ROLL_PTR 形成版本链。
(1)当一个事务要提交修改时:
1.会用排它锁锁定该行
2.将该行修改前的值Copy到undo log segment(回滚段)。
3.修改当前行的值,将该行的回滚指针指向undo log中修改前的行。
MySQL 将来是以服务进程的方式,在内存中运行。我们之前所讲的所有机制:索引,事务,隔离性,日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区中,保存相关数据,完成各种判断操作,然后在合适的时候,将相关数据刷新到磁盘当中的。
所以,我们这里理解undo log,简单理解成,就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。
(2)对于 insert
因为 insert 是插入,也就是之前没有数据,那么 insert 也就没有历史版本。但是一般为了回滚操作,insert 的数据也是要被放入 undo log 中,如果当前事务 commit 了,那么这个 undo log 的历史 insert 记录就可以被清空了。
(3)对于 select
select 不会对数据做任何修改,所以,为 select 维护多版本,没有意义。不过,此时有个问题, 就是: select读取,是读取最新的版本呢?还是读取历史版本(快照读)? 在多个事务同时删改查的时候,都是当前读,是要加锁的。
但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即 MVCC的意义所在。
那么,是什么决定了,select是当前读,还是快照读呢?隔离级别!那为什么要有隔离级别呢?
事务都是原子的。所以,无论如何,事务总有先有后。
但是事务从begin->CURD->commit,是有一个阶段的。也就是事务有执行前,执行中,执行后的阶段,不管怎么启动多个事务,总是有先有后的。
那么多个事务在执行中,CURD操作是会交织在一起的。那么,为了保证事务的“有先有后”,是不是应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。
先来的事务,到底应不应该看到后来的事务所做的修改呢?
2.3.Read View
Read View就是事务进行 快照读 操作的时候生产的 读视图 (Read View),在该事务执行的快照读的那一 刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被 分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
在 MySQL InnoDB 的默认隔离级别(可重复读)下,事务启动后第一次执行查询时创建 Read View(快照);而在 “读已提交” 隔离级别下,事务内每次查询都会重新创建 Read View,关键在于Read View 与隔离级别的绑定。
(1)活跃事务
活跃事务指的是已经启动,无论是否执行操作,在未提交或回滚前,它都属于活跃事务。
(2)事务创建过程
- 查看当前所有的未提交并活跃的事务,存储在数组 m_ids 中
- 选取未提交并活跃的事务中最小的XID,记录在快照的xmin中,所有 XID 小于 xmin 的事务,一定是已经提交的。
- 选取未提交事务中最大的XID,记录快照在xmax中,所有 XID 大于等于 xmax 的事务,一定是在当前事务启动后才创建的。
并且read view结构体中还记录了creator_trx_id(创建该ReadView的事务ID)、m_closed(标记视图是否被关闭)等内容。
ReadView是版本可见性的 “判断过滤器”,决定事务能看到哪个版本。,即通过ReadView可以知道:哪些事务的提交结果对当前事务可见,哪些事务的提交结果对当前事务不可见

3.MVCC模拟
以下内容基于 MySQL InnoDB 的 “可重复读”(Repeatable Read,RR)隔离级别。
一、完整流程:从初始数据到多版本链
1. 初始状态:无历史版本,仅当前数据
创建 student 表并插入初始数据 (name='张三', age=28) 后,InnoDB 会自动为该记录添加隐藏列,此时无历史版本,状态如下:
| 数据类型 | name | age | DB_TRX_ID(隐藏列) | DB_ROLL_PTR(隐藏列) | DB_DEL_FLAG(隐藏列) |
|---|---|---|---|---|---|
| 当前最新记录 | 张三 | 28 | 0(初始插入事务 ID,假设为 0) | NULL(无历史版本) | 0(未删除) |
| Undo Log 内容 | - | - | - | - | - |
2. 第一次修改:(假如是)事务 10 更新 name 为 “李四”(生成版本 V1)
事务 10 执行更新操作,遵循 “写时拷贝” 和 “版本链构建” 逻辑,步骤如下:
(1)加锁:事务 10 启动,对 student 表的当前记录加行锁,防止并发修改。
(2)写 Undo Log:将当前记录(name='张三', age=28)完整拷贝到 Undo Log,作为历史版本 V1。
(3)更新当前记录:
- 修改业务字段:name 从 “张三” 改为 “李四”。
- 更新隐藏列:
- DB_TRX_ID 设为 10(标记该版本由事务 10 生成)。
- DB_ROLL_PTR 指向 Undo Log 中版本 V1 的地址(建立版本链指针)。
- DB_DEL_FLAG 保持 0(未删除)。
(4)提交释放锁:事务 10 提交,释放行锁,当前版本链初步形成。
此时数据与 Undo Log 状态:
| 数据类型 | name | age | DB_TRX_ID | DB_ROLL_PTR | DB_DEL_FLAG |
|---|---|---|---|---|---|
| 当前最新记录 | 李四 | 28 | 10 | 指向 Undo Log 的 V1 | 0 |
| Undo Log(V1) | 张三 | 28 | 0 | NULL | 0 |
3. 第二次修改:事务 11 更新 age 为 38(生成版本 V2)
事务 11 执行更新操作,基于上一步的 “最新记录” 继续构建版本链,步骤如下:
(1)加锁:事务 11 启动,对当前最新记录(name='李四', age=28)加行锁。
(2)写 Undo Log:将当前最新记录(事务 10 提交后的版本)拷贝到 Undo Log,作为历史版本 V2(采用 “头插” 方式,让 V2 的指针指向更早的 V1,保持版本链顺序)。
(3)更新当前记录:
- 修改业务字段:age 从 28 改为 38。
- 更新隐藏列:
- DB_TRX_ID 设为 11(标记该版本由事务 11 生成)。
- DB_ROLL_PTR 指向 Undo Log 中版本 V2 的地址。
- DB_DEL_FLAG 保持 0(未删除)。
(4)提交释放锁:事务 11 提交,释放行锁,最终形成完整的 “多版本链”。
最终数据与 Undo Log 状态(版本链:当前记录→V2→V1):
| 数据类型 | name | age | DB_TRX_ID | DB_ROLL_PTR | DB_DEL_FLAG |
|---|---|---|---|---|---|
| 当前最新记录 | 李四 | 38 | 11 | 指向 Undo Log 的 V2 | 0 |
| Undo Log(V2) | 李四 | 28 | 10 | 指向 Undo Log 的 V1 | 0 |
| Undo Log(V1) | 张三 | 28 | 0 | NULL | 0 |
二、读取交互:不同事务如何通过 Read View 读取对应版本
基于上述最终版本链,假设此时有两个新事务(事务 12、事务 13)执行查询,看 MVCC 如何通过 Read View 筛选可见版本。
场景 1:事务 12(读操作)在事务 11 提交后启动
(1)生成 Read View:事务 12 启动后第一次查询时,生成 Read View,此时无活跃事务,因此:
- m_ids(活跃事务 ID 列表):[](空)
- min_trx_id(活跃事务最小 ID):12(无活跃事务时,取当前事务 ID)
- max_trx_id(下一个待分配 ID):13
(2)筛选版本:从当前最新记录(DB_TRX_ID=11)开始判断:
- DB_TRX_ID(11) < min_trx_id(12)→ 事务 11 已提交,版本可见。
(3)查询结果:返回当前最新记录 (name='李四', age=38)。
场景 2:事务 12(读操作)先启动,但是事务 13 先提交
(1)生成 Read View:事务 12 启动后第一次查询时,生成 Read View,此时无活跃事务(事务 11 已提交,事务 13 未启动),因此:
- m_ids(活跃事务 ID 列表):[](空)
- min_trx_id(活跃事务最小 ID):13(无活跃事务时,取 “下一个待分配事务 ID”,符合事务 ID 自增规则)
- max_trx_id(下一个待分配 ID):13
(2)事务 13 启动并提交:事务 13(ID=13)启动后执行
UPDATE student SET age=48 WHERE name='李四';
流程如下:① 对当前记录(age=38,DB_TRX_ID=11)加行锁;② 将该记录拷贝到 Undo Log 生成版本 V3(DB_TRX_ID=11,DB_ROLL_PTR 指向 V2);③ 更新当前记录为 age=48,DB_TRX_ID=13,DB_ROLL_PTR 指向 V3;④ 执行 COMMIT 释放锁,当前记录 DB_TRX_ID 变为 13。
(3)筛选版本:事务 12 复用启动时生成的 Read View,从当前最新记录(DB_TRX_ID=13)开始判断:
- DB_TRX_ID(13)= min_trx_id(13),且不在 m_ids([])中→ 事务 13 是事务 12 启动后才启动的事务,版本不可见;
- 沿 DB_ROLL_PTR 遍历到 Undo Log 中的 V3(DB_TRX_ID=11),判断 11 < min_trx_id(13)→ 事务 11 已提交,版本可见;
(4)查询结果:返回 Undo Log 中 V3 版本的数据 (name='李四', age=38)(符合 “可重复读”,看不到事务 13 提交的 age=48 版本)。
三、核心逻辑总结
- 版本链构建:每次事务修改数据,都会将 “修改前的版本” 写入 Undo Log,当前记录通过 DB_ROLL_PTR 指向历史版本,形成 “当前→Vn→…→V1” 的链式结构。
- Read View 筛选:读事务启动时生成快照,通过DB_TRX_ID 与 min_trx_id/max_trx_id/m_ids 对比,从版本链头部(最新版本)依次遍历,找到第一个可见版本。
- 隔离级保障:“可重复读” 通过 “一次生成快照、全程复用”,确保同一事务内多次查询看到相同版本;而 “读已提交” 通过 “每次查询重新生成快照”,能看到最新提交的版本。
2201

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



