1.为什么需要MVCC
在并发场景下,读写操作会面临严重的冲突问题:
1.读操作如果遇到写操作,要么“读到未提交的脏数据”,要么“被写操作阻塞(等待锁释放)”;
2.写操作如果遇到读操作,要么“覆盖读操作需要的数据”,要么“被读操作阻塞”;
MVCC通过“多版本”解决了这个问题:写操作会生成新的数据版本,读操作读取旧的版本(不影响写),两者不干扰。
2.什么是MVCC
MySQL的MVCC(Multi-Version Concurrency Control,多版本并发控制)是InnoDB存储引擎实现高并发读写的核心操作。核心思想:为数据库中的每条记录维护多个版本,通过“版本链”和“可间性判断规则”,让不同实物在并发访问时,能够看到符合自己隔离级别的数据版本,从而避免读写冲突(读不阻塞写,写不阻塞读),同事保证事务隔离性。
3.MVCC的核心组成
MVCC的实现依赖三个关键组件:隐藏列(版本标识),Undo Log(版本存储),Read View(可见性判断)。
3.1 隐藏列:记录的“版本身份证”
InnoDB为表中的每条记录添加了三个隐藏字段,用于记录标识的版本信息:
- DB_TRX_ID:记录最后一次修改该记录的事务ID(6字节)。每次事务对记录执行insert/update/delete时,都会将自己的事务ID写入该字段。
- DB_ROLL_PTR:回滚指针(7字节)。指向该记录的“上个版本”在Undo Log中的位置(通过它可以串联所有的历史版本,形成“版本链”)。
- DB_ROW_ID:记录的唯一标识(6字节)。如果表没有定义主键或唯一索引,InnoDB会用它作为默认聚簇索引(一般用不到,可忽略)。
3.2 Undo Log:版本的“历史记录馆”
Undo Log(回滚日志)是InnoDB用于存储“记录旧版本”的空间。当视为修改记录时,旧版本的数据不会被直接删除,而是被写入Undo Log,供后序“回滚”或“其他事务读取”使用。
与隐藏列相结合形成“版本链”,记录的“版本链”形成的过程如下:
- 初始插入一条数据时,DB_TRX_ID是插入事务的ID,DB_ROLL_PTR为null(无历史版本,新数据无历史版本)。
- 当事务A(事务ID=110)修改该记录时,InnoDB会先将旧版本数据写入Undo Log,然后更新记录的DB_TRX_ID=110,并将DB_ROLL_PTR指向Undo Log中的旧版本。
- 之后事务B(事务ID=220)再次修改该记录时,会将“事务A修改后的版本”写入Undo Log,更新DB_TRX_ID=220,DB_ROLL_PTR指向事务A版本再Undo Log的位置。
- 最终,通过DB_ROLL_PTR串联的Undo Log记录,形成了改记录的“版本链”(最新版本在表中,历史版本在Undo Log中)。
一条记录的版本链结构(简化):
当前记录(表中):data:(name='HajiHang',age=21)|DB_TRX_ID=220|DB_ROLL_PTR——>Undo Log中的版本1
Undo Log:
Undo Log中的版本1(事务A修改后的版本):
data:(name='HajiHang',age=20)|DB_TRX_ID=110|DB_ROLL_PTR——>Undo Log中的版本0
Undo Log中的版本0(初始插入版本):
data:(name='HajiHang',age=19)|DB_TRX_ID=55|DB_ROLL_PTR——>null
3.3 Read View:版本的“可见性过滤器”
有了版本链后,事务如何判断“哪个版本的数据对自己可见”?这就需要Read View(读试图),它是一个“可见性判断规则集合”,用于确定当前事务能够看到版本链中的那个版本。
Read View包含4个核心变量(生成时确定):
- m_ids:生成Read View时,当前所有“活跃事务”(已启动但未提交)的事务ID集合(无序)
- min_trx_id:m_ids中的最小事务ID(当前活跃事务中最早启动的那个)
- max_trx_id:生成Read View时,“下一个将要分配的事务ID”(并非m_ids中的最大值,而是一个预分配的自增ID)
- creator_trx_id:当前生成Read View的事务自己的ID
4.可见性判断规则(核心)
设记录的版本对应的事务ID为trx_id,根据trx_id和Read View的变量对比:
- 如果trx_id == creator_trx_id:该版本是当前事务自己修改的,可见(自己改的自己当然可以看到)
- 如果trx_id < min_trx_id:该版本对应的事务在“当前Read View生成前”就已提交(因为它的ID比所有活跃事务的最小ID还小),可见。
- 如果trx_id >= max_trx_id:该版本对应的事务在“当前Read View生成后”才启动(ID超过预分配的最大ID),不可见(还没提交,或刚启动)。
- 如果min_trx_id <=trx_id <=max_trx_id:
- 若trx_id在m_ids(活跃事务集合)中:说明该事务在Read View生成时还没提交,不可见;
- 若trx_id不在m_ids中:说明该事务在Read View生成前已提交,可见;
5.MVCC与隔离级别的关系
MVCC的核心作用是实现隔离级别(ACID中的“I”隔离性)。InnoDB通过“Read View的生成时机”和“版本链”,在不同隔离级别下表现出不同的行为:
隔离级别 | MVCC行为(Read View生成时机) | 解决的问题 |
Read Uncommitted(读未提交) | 不使用MVCC(直接读取最新的版本) | 可能读到未提交的脏数据(脏读) |
Read Committed(读已提交) | 每次执行select时,重新生成Read View | 避免脏读(只看到已提交的版本);但是可能出现“不可重复读”(两次查询Read View不同) |
Repeatable Read(可重复读) | 仅在事务第一次select生成Read View,之后复用 | 避免不可重复读(两次查询用同一个Read View,版本一致);但可能出现“幻读”(通过间隙锁解决) |
Serializable(串行化) | 不依赖MVCC,直接通过加锁(读加共享锁,写加排它锁)实现 | 完全串行化,无并发问题,性能低 |
6.补充:不同隔离级别涉及到的并发问题
不同隔离级别会产生不同的并发问题,核心问题包括脏读,不可重复读,幻读。
- 脏读指的是一个事务读取到另一个为提交事务修改的数据。(事务A在修改name并未提交,事务B读取到了name,后事务A又进行修改并提交/或直接回滚,导入事务B读取到了一个临时,无效的数据)
- 不可重复读指的是同一事务内,多次读取同一数据,结果因其他已提交事务的修改而不同。(事务A第一次读取name='HajiHang',事务B执行了修改操作将name改成了Hajimi,事务A再次读取name发现不是HajiHang了)
- 幻读指的是同一事务内,多次执行相同的查询操作(通常是范围查询)时,结果集行数因其他一提交事务的插入/删除而变化(事务A查询score=90的人发现有5条数据,之后事务B插入一个90的数据,事务A再次查询score=90发现结果集变为6条)