深入解析Mysql数据库并发:从读写机制到多版本控制
系列文章目录
文章目录
- 系列文章目录
- 前言
- 一、数据库并发的三种场景
- 二、读-写
- 2.1 三个隐藏字段
- 2.2 多版本控制(mvcc)
- 2.3 Read View
- 2.3.1 Read View 的核心作用
- 2.3.2 Read View 的本质:可见性判断的“规则集合”
- 2.3.3 简化后的 Read View 关键结构(理解核心逻辑)
- 2.4 整体流程
- 2.5 RC和RR的区别
前言
本篇文章作为拓展内容,是对MySQL事务隔离性的更深入理解,建议在过上篇文章的基础上阅读。
Mysql数据库事务全解析:概念、操作与隔离级别
一、数据库并发的三种场景
通过上篇的学习,可以知道数据库并发场景可分为以下三类,其冲突风险和处理需求各不相同:
-
读-读并发
多个事务同时读取同一份数据,不存在任何冲突或安全问题,无需额外的并发控制机制。因为读取操作不会修改数据,彼此间不会相互干扰。 -
读-写并发
一个事务读取数据,同时另一个事务修改该数据,存在线程安全风险,可能破坏事务隔离性,引发下列问题:- 脏读(读取到未提交的修改)
- 不可重复读(同一事务内多次读取结果不一致)
- 幻读(同一条件下多次读取的记录数量不同) 需通过隔离级别和锁机制控制可见性,避免此类问题。
-
写-写并发
多个事务同时修改相同数据,存在线程安全风险,可能导致更新丢失(后一个事务的修改覆盖前一个事务的修改,导致部分更新结果丢失)。需通过锁机制(如行锁、间隙锁)保证修改的原子性和顺序性,防止冲突。
读-读:并不会产生并发问题,所以接下来不做介绍
二、读-写
通过上篇的学习,我们可以知道对于读-写产生的一系列问题,并不是通过加锁解决的,那么它是如何办到的呢?
在MySQL中采用了,多版本并发控制( MVCC )的技术来解决读-写冲突的无锁并发控制。
在正式理解mvcc
前我们首先需要理解三个前提知识:
- 3个记录隐藏字段
- undo 日志
- Read View
2.1 三个隐藏字段
在 MySQL 的 InnoDB 存储引擎中,创建表时会自动为每条记录添加以下隐藏字段(无需手动定义):
示例(简化):
name | age | DB_TRX_ID(创建事务ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | 11 | 1 | 指向 undo log 空值(无历史版本) |
- DB_TRX_ID(6字节)
作用:记录最近修改(包括插入、更新)该记录的事务ID。每个事务启动时,InnoDB 会分配一个唯一的事务ID,通过此字段可追踪记录的修改来源。
理解事务ID可以结合文件描述符理解,当有事务产生时,MySQL 会为这个事务分配一个事务ID,ID值是递增的,越小代表事务产生的越早。
-
DB_ROLL_PTR(7字节)
作用:回滚指针,指向该记录的上一个历史版本。这些历史版本存储在undo log
(回滚日志)中,用于事务回滚或 MVCC(多版本并发控制)的一致性读。我们介绍过,MySQL服务启动时会开辟一块内存,当作缓冲区,而在这块缓冲区中有一段叫做
undo log
的空间:
当不断的有事务对数据修改时,就可以形成一条版本链,而回滚指针可以帮助我们快速定位旧版本,完成回滚操作。
补充:当该记录被事务ID=11的事务更新后,DB_TRX_ID 会变为11,DB_ROLL_PTR 指向更新前的版本 -
DB_ROW_ID(6字节)
作用:隐含的自增ID(隐藏主键)。若表未定义主键或非空唯一键,InnoDB 会以该字段作为聚簇索引的键,保证每条记录有唯一标识。
这一字段我们很早自建就介绍了
补充说明:
- 此外,还存在一个删除标记(delete flag) 隐藏字段:记录被删除或更新时,并非物理删除,而是通过此字段标记为“已删除”(如:0表示删除、1表示有效)。历史版本仍保留在
undo log
中,直至被清理。
这里不想细讲,但是有一件事情得说清楚, MySQL 将来是以服务进程的方式,在内存中运行。我们之前所讲的所有机制:索引,事务,隔离性,日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区中,保存相关数据,完成各种判断操作。然后在合适的时候,将相关数据刷新到磁盘当中的.所以,我们这里理解undo log,简单理解成,就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。
2.2 多版本控制(mvcc)
MVCC(多版本并发控制)的核心机制是:为事务分配单向增长的事务ID,为数据的每一次修改保存一个版本,且版本与事务ID关联;读操作仅读取事务开始前的数据库快照。
基于这一机制,MVCC为数据库解决了以下关键问题:
-
提升并发性能:
并发读写时,读操作无需阻塞写操作,写操作也无需阻塞读操作,通过多版本隔离实现无锁并发,提高数据库的读写效率。 -
解决事务隔离问题:
可有效避免脏读、不可重复读、幻读等隔离性问题——读操作基于快照,不受其他未提交事务的修改影响,保证事务内数据的一致性。
我们通过一个实例模拟 MVCC 的工作过程:
假设有一个事务10(ID为10,仅为区分),需对 student
表中的一条记录执行修改操作(将 name='张三'
改为 name='李四'
),过程如下:
- 加锁:事务10因要修改数据,首先对该记录施加行锁,防止其他事务同时修改。
- 版本备份(写时拷贝):修改前,先将该记录的当前版本完整拷贝到
undo log
(回滚日志)中,形成一条副本数据(作为历史版本)。 - 修改原始记录:
- 直接更新原始记录的
name
字段为'李四'
; - 同步更新原始记录的隐藏字段:
DB_TRX_ID
设为当前事务ID(10),标记该记录最后由事务10修改;DB_ROLL_PTR
写入undo log
中副本数据的地址,使其指向该历史版本(表示“当前版本的上一版本是副本”)。
- 直接更新原始记录的
- 提交与释放锁:事务10执行
commit
提交后,释放行锁,修改正式生效。
接着前面的场景,现在有一个事务11(ID为11),对 student
表中同一条记录(即事务10修改过的那条 name='李四'
的记录)执行修改操作:将 age=28
改为 age=38
,过程如下:
- 加锁:事务11因要修改该记录,需先获取该记录的行锁(若事务10未释放锁,事务11会等待锁释放)。
- 版本备份(写时拷贝):修改前,将该记录的当前版本(即事务10修改后的版本)再次拷贝到
undo log
中,新增一条副本数据。此时,undo log
中的版本链采用头插方式更新——新副本作为最新的历史版本,排在链的前端。 - 修改原始记录:
- 直接更新原始记录的
age
字段为38
; - 同步更新隐藏字段:
DB_TRX_ID
设为当前事务ID(11),标记该记录最后由事务11修改;DB_ROLL_PTR
指向undo log
中刚新增的副本数据地址,即“当前版本的上一版本是事务10修改后的版本”。
- 直接更新原始记录的
- 提交与释放锁:事务11执行
commit
提交后,释放行锁,修改生效。
此时,undo log
中已形成一条版本链:事务11修改后的当前版本 → 指向事务10修改后的版本(副本1)→ 指向事务10修改前的原始版本(副本0),通过 DB_ROLL_PTR
依次串联:
如果是
insert
呢?因为insert
是插入,也就是之前没有数据,那么insert
也就没有历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了。
总结一下,也就是我们可以理解成,update
和delete
可以形成版本链,insert
暂时不考虑
select读取,是读取最新的版本呢?还是读取历史版本?
当前读:读取最新的记录,就是当前读。增删改,都叫做当前读,select也有可能当前读,比如:select lock in sharemode(共享锁), select for update (这个好理解,我们后面不讨论)
快照读:读取历史版本(一般而言),就叫做快照读。(这个我们后面重点讨论)
这段内容是在解释 MVCC(多版本并发控制)解决并发读写冲突、提升效率的核心逻辑,可以拆成两部分理解:
“当前读 + 加锁”的问题(无 MVCC 时的困境)
当多个事务同时修改数据(当前读,即读取并修改最新版本)时,数据库需要用行锁/表锁保证数据一致性:
比如事务 A 要修改某条记录,会先给记录加锁;此时其他事务(不管是修改还是想读“最新版”)都得等锁释放,相当于串行执行,效率很低。
MVCC 的“快照读”优势(解决串行化问题)
MVCC 引入 “快照读” 机制:
当查询需要读数据时,不强制读取“最新加锁版本”,而是可以读 历史版本(这些版本存在undo log
里,是修改前的副本); 读历史版本时不需要加锁,多个查询可以同时读不同版本的历史数据,实现 并行执行,避免了“读等待写锁”的串行化问题。
虽然写操作(当前读)仍需要加锁保证一致性,但读操作(快照读)通过访问
undo log
的历史版本,绕开了写锁的阻塞;
这样就实现了 “写-写互斥(加锁保证一致性),读-写并行(快照读无锁)”,提升并发效率——这就是 MVCC 的核心价值。
简单说:MVCC 让“读”不用等“写”的锁,用历史版本实现并行读,解决了纯加锁串行化的低效问题。
“当前读/快照读”的开关: 隔离级别 ,事务的隔离级别,决定了 SELECT
是“当前读”还是“快照读” 。
比如:
- 读未提交(Read Uncommitted)、可重复读(Repeatable Read,MySQL InnoDB 默认)等不同隔离级别下,
SELECT
的行为不同——有的强制读最新版本(当前读,需加锁),有的则允许读历史版本(快照读,无锁)。
“隔离级别”存在的意义:解决事务交织执行时,数据的可见性问题
事务本身是原子性的(BEGIN
→CRUD
→COMMIT
是一个完整周期 ),但实际运行时,多个事务的“执行阶段(执行前、执行中、执行后)会相互交织:
- 事务 A 执行
UPDATE
时,事务 B 可能正在执行SELECT
; - 事务 C 刚
BEGIN
,事务 D 可能已经COMMIT
…
为了让 “有先后顺序的事务,能看到「符合逻辑的正确数据」” ,就需要一套规则定义:不同事务在执行中相互交织时,彼此该“看到什么、看不到什么” ——这就是 “事务隔离性” 的本质,而 “隔离级别”就是这套规则的具体细化(比如读未提交、读已提交、可重复读、串行化)。
隔离级别是为了在“多事务并发交织执行”的场景下,精准控制事务间的可见性:让每个事务既不被其他事务的干扰破坏逻辑,又能尽可能并行执行(提升效率)。而“当前读/快照读”的选择,正是隔离级别落地的具体体现(不同隔离级别下,
SELECT
策略不同)。
如何保证,不同的事务,看到不同的内容呢?也就是如何如何实现隔离级别?
2.3 Read View
2.3.1 Read View 的核心作用
Read View(读视图)是事务执行「快照读」时生成的关键结构 :
- 当事务触发快照读(比如可重复读隔离级别的
SELECT
),MySQL 会在“读的瞬间”生成一个 数据库当前状态的“快照视图” ; - 这个视图会记录并维护“系统当前活跃事务的 ID”(事务 ID 是全局递增的,新事务 ID 更大 )。
活跃事务:当前并发未提交的事务
2.3.2 Read View 的本质:可见性判断的“规则集合”
在 MySQL 源码中,Read View 对应一个 类(数据结构) ,核心职责是 “判断某条记录的版本对当前事务是否可见” :
- 执行快照读时,对目标记录生成 Read View,相当于一套“可见性规则”;
- 通过对比记录的事务 ID(或其
undo log
历史版本的事务 ID )与 Read View 的规则,决定返回“当前记录”还是“undo log
里的历史版本”。
2.3.3 简化后的 Read View 关键结构(理解核心逻辑)
实际源码更复杂,我们聚焦关键字段的作用:
class ReadView {
private:// 高水位:事务 ID ≥ 此值 → 对当前事务“不可见”//ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(也没
有写错)trx_id_t m_low_limit_id; // 低水位:事务 ID < 此值 → 对当前事务“可见”//记录m_ids列表中事务ID最小的ID(没有写错)trx_id_t m_up_limit_id; // 创建该 Read View 的“当前事务 ID”trx_id_t m_creator_trx_id; // 生成视图时,系统中“活跃事务的 ID 列表”ids_t m_ids; // (辅助清理)标记无需保留的 undo log 版本,小于此值的可被 purgetrx_id_t m_low_limit_no; // 标记视图是否已关闭(生命周期管理)bool m_closed;
};
核心逻辑:
- 用
m_low_limit_id
(高水位)和m_up_limit_id
(低水位)划定“可见事务 ID 的范围”; - 结合
m_creator_trx_id
(当前事务 ID)和m_ids
(活跃事务 ID 列表),判断一条记录的版本是否对当前事务可见——如果可见,直接读当前记录;如果不可见,去undo log
找历史版本再判断。
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID 。那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的 DB_TRX_ID 。所以现在的问题就是,当前快照读,应不应该读到当前版本记录。
下面为是否可见的比较规则
如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即可以看到。上面的 readview 是当你进行select的时候,会自动形成。
2.4 整体流程
以可重复读为背景
- 事务4:修改name(张三) 变成name(李四)
- 当 事务2 对某行数据执行了 快照读 ,数据库为该行数据生成一个 Read View 读视图
//事务2的 Read View
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以此时m_ids
表中没有事务4的ID,即,事务4相对于事务2可见。
我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id,low_limit_id和活跃事务ID列表(trx_list) 进行比较,判断当前事务2能看到该记录的版本。
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
//结论
故,事务4的更改,应该看到。所以事务2能读到的最新数据记录是事务4所提交
的版本,而事务4提交的版本也是全局角度上最新的版本
2.5 RC和RR的区别
我们之说过,执行快照读时版本是否当前可见,是由隔离级别决定的,那么它是如何实现的呢?
不同隔离级别对Read View的创建规则是不同的。
-
核心区别:Read View生成时机不同
这是RC(读已提交)和RR(可重复读)隔离级别下快照读结果差异的本质原因。 -
RR级别下的Read View机制
- 同一事务中,对某条记录的第一次快照读会创建快照及Read View,记录当前系统中所有活跃的其他事务。
- 此后该事务内的所有快照读,均复用同一个Read View。
- 因此,若当前事务在其他事务提交更新前已执行过快照读,后续其他事务的修改对其不可见(保证重复读一致性)。
- 可见性规则:早于Read View创建的事务所做的修改可见;Read View记录的活跃事务的修改不可见。
-
RC级别下的Read View机制
- 同一事务中,每次执行快照读都会新生成快照及Read View(获取最新的活跃事务状态)。
- 因此,其他事务提交的更新会被当前事务的快照读感知到(导致不可重复读)。
-
隔离性表现差异的根源
- RC因“每次快照读生成新Read View”,导致同一事务中多次快照读可能看到不同结果(不可重复读)。
- RR因“同一事务内复用首个Read View”,保证同一事务中多次快照读结果一致(可重复读)。
大家可以测试一下