在MySQL的日常使用中,我们几乎每天都在和事务隔离、并发读写打交道。当多个事务同时操作同一行数据时,为什么有的场景会出现脏读、不可重复读,有的场景却能保证数据一致性?为什么InnoDB能在高并发场景下保持远超其他存储引擎的读写性能?这一切的核心,都离不开InnoDB的多版本并发控制机制——MVCC。
很多开发者对MVCC的认知停留在“读写不互斥”的表层,却对其底层的undo log版本链、Read View可见性规则、不同隔离级别下的行为差异一知半解,甚至被大量错误的博客内容误导,最终在生产环境中遇到数据一致性问题、长事务导致的磁盘爆满等故障时无从下手。
一、MVCC的核心本质:解决什么问题?
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。其核心思想是:通过维护数据行的多个历史版本,让不同事务的读写操作互不阻塞,在保证数据一致性的前提下,最大化提升数据库的并发性能。
在传统的悲观锁机制中,为了避免并发读写带来的数据不一致问题,会采用“读加共享锁、写加排他锁”的模式,这就导致读写操作之间会互相阻塞,高并发场景下性能急剧下降。
而MVCC彻底打破了这个限制:
- 写操作:不会直接覆盖原数据,而是生成一个新的版本,保留历史版本链
- 读操作:通过一致性视图Read View,读取符合可见性规则的历史版本,无需加锁
这就实现了读写无互斥,读不会阻塞写,写也不会阻塞读,这也是InnoDB能成为MySQL默认存储引擎的核心原因之一。
同时需要明确:InnoDB的MVCC仅在READ COMMITTED(读已提交)和REPEATABLE READ(可重复读)两个隔离级别下生效。READ UNCOMMITTED(读未提交)总是读取最新的数据行版本,无需MVCC;SERIALIZABLE(串行化)则对所有读操作加共享锁,完全靠锁机制保证一致性,也不使用MVCC。
二、MVCC的基石1:undo log与行版本链
MVCC的核心是多版本,而多版本的载体,就是undo log。很多人对undo log的认知仅停留在“事务回滚日志”,但它更是MVCC版本链的核心载体,没有undo log,就没有MVCC。
2.1 InnoDB行记录的隐藏列
要理解undo log的版本链,首先要搞清楚InnoDB聚簇索引行记录的隐藏结构。我们创建的每一行数据,除了我们定义的字段,InnoDB会默认添加3个隐藏列:
| 隐藏列名 | 长度 | 核心作用 |
| DB_TRX_ID | 6字节 | 最后一次修改该行记录的事务ID,事务ID是全局递增的,唯一标识一个读写事务 |
| DB_ROLL_PTR | 7字节 | 回滚指针,指向该行记录对应的undo log日志,用于构建版本链和事务回滚 |
| DB_ROW_ID | 6字节 | 隐藏主键,当表没有定义主键、也没有唯一非空索引时,InnoDB会自动生成 |
这里有两个关键的认知点必须明确:
- 事务ID的分配时机:InnoDB只会给执行了修改操作(INSERT/UPDATE/DELETE)的读写事务分配唯一的、全局递增的事务ID。纯只读事务(只有SELECT语句)不会分配事务ID,其事务ID默认为0。
- DELETE操作的本质:InnoDB的DELETE操作并不是物理删除数据行,而是通过修改行记录的delete flag标记位,将其标记为已删除,真正的物理删除会由后台的purge线程在确认没有任何事务需要该版本时执行。
2.2 undo log的类型与生命周期
undo log是逻辑日志,记录的是数据行的修改逻辑,而非物理页的修改。根据操作类型,undo log分为两类:
- insert undo log:由INSERT操作生成。INSERT操作插入的记录,只有当前事务能看到,其他事务不会访问该记录的版本,因此事务提交后,对应的insert undo log可以被直接删除,无需purge线程处理。
- update undo log:由UPDATE/DELETE操作生成。这类undo log不仅用于事务回滚,还需要为MVCC的一致性读提供历史版本,因此事务提交后不能直接删除,必须等到没有任何事务的Read View需要引用该历史版本时,才会由purge线程统一清理。
2.3 版本链的生成过程
每一次对数据行的修改,都会生成一条对应的undo log,通过行记录的DB_ROLL_PTR回滚指针,将所有历史版本串联起来,形成一条单向的版本链。
我们通过一个完整的SQL示例,直观展示版本链的生成过程:
-- 创建测试表
CREATE TABLE `user` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` VARCHAR(32) NOT NULL COMMENT '姓名',
`age` INT NOT NULL COMMENT '年龄',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 插入初始数据,事务ID=3
INSERT INTO `user` (`id`, `name`, `age`) VALUES (1, '张三', 20);
此时,行记录的隐藏列与undo log如下:
- DB_TRX_ID=3
- DB_ROLL_PTR=null
- 版本链只有初始版本,无历史undo log
接下来,执行第一次UPDATE操作,事务ID=5:
-- 事务ID=5
UPDATE `user` SET `age`=21 WHERE `id`=1;
此时,InnoDB会做两件事:
- 生成一条update undo log,记录修改前的版本(age=20),该undo log的DB_TRX_ID=3
- 修改当前行记录的age=21,DB_TRX_ID=5,DB_ROLL_PTR指向刚生成的undo log
然后,执行第二次UPDATE操作,事务ID=8:
-- 事务ID=8
UPDATE `user` SET `age`=22 WHERE `id`=1;
同样,生成新的undo log,记录age=21的版本,DB_TRX_ID=5,当前行的DB_TRX_ID=8,DB_ROLL_PTR指向新的undo log,版本链进一步延伸。
最终形成的版本链结构如下:
这条版本链,就是MVCC实现一致性读的基础。当不同事务执行SELECT操作时,会通过Read View的可见性规则,从版本链中找到自己能看到的那个版本,实现不同事务看到不同的数据版本,且完全无需加锁。
三、MVCC的基石2:Read View一致性视图与可见性规则
如果说undo log版本链是MVCC的“数据载体”,那么Read View就是MVCC的“规则核心”。Read View是事务执行一致性读时生成的一个数据快照,定义了当前事务能看到哪些数据版本,不能看到哪些数据版本。
3.1 Read View的核心字段
Read View的结构由4个核心字段组成,这4个字段是版本可见性判断的唯一依据,这里必须纠正行业内普遍存在的错误认知,所有字段定义均来自MySQL 8.0 InnoDB源码:
| 字段名 | 核心含义 |
| m_ids | 生成Read View时,当前数据库中所有活跃的读写事务ID集合(已启动但未提交) |
| min_trx_id | m_ids集合中的最小事务ID,即当前未提交事务的最小ID |
| max_trx_id | 生成Read View时,数据库全局下一个要分配的事务ID |
| creator_trx_id | 生成当前Read View的事务的ID,只读事务的creator_trx_id为0 |
这里最核心的错误纠正:max_trx_id不是活跃事务的最大值,而是全局下一个要分配的事务ID。比如当前活跃的读写事务ID是2、3、5,那么下一个要分配的事务ID是6,max_trx_id=6,而非5。这个错误认知会直接导致可见性判断规则完全错误,必须牢记。
3.2 版本可见性判断规则
有了Read View和版本链,InnoDB会按照固定的规则,从版本链的最新版本开始,依次判断每个版本是否对当前事务可见,直到找到第一个符合规则的版本为止。
完整的可见性判断规则如下,优先级从高到低:
- 规则1:如果当前版本的DB_TRX_ID == creator_trx_id → 可见。当前事务自己修改的版本,自己当然可以看到。
- 规则2:如果当前版本的DB_TRX_ID < min_trx_id → 可见。该版本对应的事务,在当前Read View生成之前就已经提交,对当前事务可见。
- 规则3:如果当前版本的DB_TRX_ID >= max_trx_id → 不可见。该版本对应的事务,是在当前Read View生成之后才启动的,对当前事务不可见,需要回溯到上一个版本。
- 规则4:如果当前版本的DB_TRX_ID在min_trx_id和max_trx_id之间,判断DB_TRX_ID是否在m_ids集合中:
- 如果在m_ids中 → 不可见。该事务在Read View生成时还处于活跃状态,未提交,修改的内容不可见。
- 如果不在m_ids中 → 可见。该事务在Read View生成时已经提交,修改的内容可见。
整个判断流程可以用如下流程图清晰展示:
我们用一个简单的示例验证这个规则: 假设当前Read View的字段如下:
- m_ids = [5,8]
- min_trx_id = 5
- max_trx_id = 10
- creator_trx_id = 0(只读事务)
对于版本链中的三个版本:
- 版本1:DB_TRX_ID=8 → 处于min和max之间,且在m_ids中 → 不可见
- 版本2:DB_TRX_ID=5 → 处于min和max之间,且在m_ids中 → 不可见
- 版本3:DB_TRX_ID=3 → 小于min_trx_id=5 → 可见
最终当前事务会读取到DB_TRX_ID=3的版本,完全符合规则。
3.3 Read View的生成时机:隔离级别差异的核心
Read View的生成时机,直接决定了MVCC在不同隔离级别下的行为差异,也是READ COMMITTED和REPEATABLE READ两个隔离级别的核心区别。
InnoDB对两个隔离级别的Read View生成时机做了完全不同的定义:
- READ COMMITTED(读已提交):事务内每执行一次SELECT语句,都会生成一个全新的Read View。
- REPEATABLE READ(可重复读):事务内第一次执行SELECT语句时,生成一个Read View,整个事务生命周期内复用这个Read View。
这个差异,直接导致了两个隔离级别的一致性能力完全不同:
- RC级别:每次SELECT都用最新的Read View,能看到其他事务已经提交的修改,因此会出现不可重复读。
- RR级别:整个事务复用同一个Read View,后续的SELECT都用同一个规则判断可见性,因此不会看到其他事务提交的修改,解决了不可重复读。
生成时机的差异可以用如下流程图展示:
四、不同隔离级别下MVCC的行为差异实战
前面讲完了核心原理,现在我们通过可复现的SQL示例,直观展示不同隔离级别下MVCC的行为差异,所有示例均可在MySQL 8.0中直接执行。
首先,我们先初始化测试数据:
-- 重置测试表
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` VARCHAR(32) NOT NULL COMMENT '姓名',
`age` INT NOT NULL COMMENT '年龄',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 插入初始数据
INSERT INTO `user` (`id`, `name`, `age`) VALUES (1, '张三', 20);
4.1 READ UNCOMMITTED:无MVCC,存在脏读
READ UNCOMMITTED级别下,InnoDB不使用MVCC,每次SELECT都会直接读取数据行的最新版本,无论对应的事务是否提交,因此会出现脏读。
示例执行流程(两个会话并行执行,严格按照时间点顺序):
| 时间点 | 会话A(读未提交) | 会话B(读未提交) |
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; BEGIN; |
| T2 | SELECT * FROM user WHERE id=1; -- 结果:age=20 | |
| T3 | UPDATE user SET age=21 WHERE id=1; -- 未提交 | |
| T4 | SELECT * FROM user WHERE id=1; -- 结果:age=21(脏读,读取到未提交的数据) | |
| T5 | ROLLBACK; | |
| T6 | SELECT * FROM user WHERE id=1; -- 结果:age=20 | COMMIT; |
| T7 | COMMIT; |
可以看到,会话A在T4时刻读取到了会话B未提交的修改,出现了脏读,这是生产环境绝对禁止使用该隔离级别的核心原因。
4.2 READ COMMITTED:每次SELECT生成新Read View,解决脏读,存在不可重复读
RC级别下,每次SELECT都会生成新的Read View,因此只能看到已经提交的事务修改,解决了脏读,但因为每次Read View都是最新的,会出现不可重复读。
示例执行流程:
| 时间点 | 会话A(读已提交) | 会话B(读已提交) |
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; |
| T2 | SELECT * FROM user WHERE id=1; -- 结果:age=20 | |
| T3 | UPDATE user SET age=21 WHERE id=1; -- 未提交 | |
| T4 | SELECT * FROM user WHERE id=1; -- 结果:age=20(脏读解决,未提交的修改不可见) | |
| T5 | COMMIT; | |
| T6 | SELECT * FROM user WHERE id=1; -- 结果:age=21(不可重复读,新的Read View看到了已提交的修改) | |
| T7 | COMMIT; |
核心原理:
- T2时刻,会话A第一次SELECT生成Read View,此时会话B的事务还未启动,m_ids为空,min_trx_id=当前最大事务ID+1,因此只能看到初始版本age=20。
- T4时刻,会话B的事务ID在Read View的m_ids中,因此修改的版本不可见,还是读取age=20。
- T6时刻,会话A再次SELECT,生成全新的Read View,此时会话B的事务已经提交,不在m_ids中,因此能看到age=21的版本,出现不可重复读。
4.3 REPEATABLE READ:复用Read View,解决不可重复读与快照读幻读
RR是InnoDB的默认隔离级别,事务内第一次SELECT生成Read View后,整个事务复用,因此解决了不可重复读,同时配合MVCC解决了快照读的幻读问题。
首先演示不可重复读的解决:
| 时间点 | 会话A(可重复读) | 会话B(可重复读) |
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; |
| T2 | SELECT * FROM user WHERE id=1; -- 结果:age=20 | |
| T3 | UPDATE user SET age=21 WHERE id=1; COMMIT; | |
| T4 | SELECT * FROM user WHERE id=1; -- 结果:age=20(可重复读,复用第一次的Read View,看不到已提交的修改) | |
| T5 | COMMIT; |
核心原理:
- T2时刻,会话A第一次SELECT生成Read View,整个事务生命周期内复用这个Read View。
- T3时刻,会话B的事务提交,但其事务ID在会话A的Read View的m_ids中,因此根据可见性规则,修改的版本不可见。
- T4时刻,会话A再次SELECT,还是用同一个Read View,因此还是读取到age=20的版本,实现了可重复读。
接下来演示幻读的解决: 很多人认为MVCC不能解决幻读,这是一个典型的认知误区。InnoDB的RR级别下,快照读的幻读完全由MVCC解决,当前读的幻读由Next-Key Lock解决。
示例执行流程:
| 时间点 | 会话A(可重复读) | 会话B(可重复读) |
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN; |
| T2 | SELECT * FROM user WHERE age BETWEEN 20 AND 30; -- 结果:1条记录(id=1,age=20) | |
| T3 | INSERT INTO user (name, age) VALUES ('李四', 25); COMMIT; | |
| T4 | SELECT * FROM user WHERE age BETWEEN 20 AND 30; -- 结果:1条记录(无幻读,MVCC解决) | |
| T5 | SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE; -- 当前读,结果:2条记录 | |
| T6 | COMMIT; |
核心原理:
- T2时刻,会话A第一次快照读生成Read View,整个事务复用。
- T3时刻,会话B插入新数据并提交,但其事务ID在会话A的Read View的m_ids中,因此不可见。
- T4时刻,会话A再次快照读,复用同一个Read View,因此还是只看到1条记录,没有出现幻读。
- T5时刻,当前读(FOR UPDATE)会读取最新的版本,加Next-Key Lock,因此会看到2条记录,当前读的幻读需要靠锁机制解决。
4.4 SERIALIZABLE:无MVCC,完全串行化
SERIALIZABLE级别下,InnoDB不使用MVCC,所有的SELECT语句都会自动加上FOR SHARE共享锁,读写操作互相阻塞,完全串行化执行,彻底解决了脏读、不可重复读、幻读,但并发性能极差,仅适用于对数据一致性要求极高、并发量极低的场景。
五、MVCC的常见认知误区纠正
行业内关于MVCC的错误认知非常多,这里我们逐一纠正,确保你对MVCC的理解100%准确。
误区1:MVCC完全解决了幻读
纠正:MVCC仅解决了快照读的幻读问题。对于当前读(SELECT ... FOR UPDATE/LOCK IN SHARE MODE、INSERT、UPDATE、DELETE),MVCC无法解决幻读,InnoDB是通过Next-Key Lock(临键锁)来解决当前读的幻读问题的。
误区2:Read View的max_trx_id是活跃事务的最大值
纠正:max_trx_id是生成Read View时,数据库全局下一个要分配的事务ID,而非活跃事务的最大值。这个错误会直接导致可见性判断规则完全错误,是最常见的认知误区。
误区3:事务开启(BEGIN)时就会分配事务ID
纠正:InnoDB只会给执行了修改操作的读写事务分配事务ID,纯只读事务不会分配事务ID。且事务ID的分配时机是事务第一次执行修改操作时,而非BEGIN执行时。
误区4:undo log和redo log都是用来回滚的
纠正:undo log是逻辑日志,用于事务回滚和MVCC版本链的构建;redo log是物理日志,用于数据库崩溃恢复,保证事务的持久性。两者的作用、存储结构、生命周期完全不同,不能混为一谈。
误区5:长事务不影响数据库性能
纠正:长事务是MVCC的天敌。长事务会长期持有Read View,导致对应的undo log无法被purge线程清理,不仅会占用大量的磁盘空间,还会导致版本链越来越长,后续的SELECT查询需要遍历更多的版本,查询性能急剧下降。生产环境中必须严格避免长事务。
六、MVCC实战代码演示
我们通过Spring Boot + MyBatis-Plus的实战代码,演示不同隔离级别下MVCC的行为差异。
6.1 项目核心依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.jam</groupId>
<artifactId>mvcc-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mvcc-demo</name>
<description>MySQL MVCC测试项目</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<fastjson2.version>2.0.52</fastjson2.version>
<guava.version>33.1.0-jre</guava.version>
<springdoc.version>2.5.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
6.2 实体类定义
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 用户实体类
* @author ken
*/
@Data
@TableName("user")
@Schema(description = "用户实体")
public class User implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
@Schema(description = "主键ID", example = "1")
private Integer id;
@Schema(description = "用户姓名", example = "张三")
private String name;
@Schema(description = "用户年龄", example = "20")
private Integer age;
}
6.3 Mapper接口
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.User;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户Mapper接口
* @author ken
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
6.4 服务层接口与实现
package com.jam.demo.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.User;
/**
* 用户服务接口
* @author ken
*/
public interface UserService extends IService<User> {
/**
* 测试RC隔离级别下的MVCC行为
* @param userId 用户ID
* @return 用户信息
*/
User testRcIsolationLevel(Integer userId);
/**
* 测试RR隔离级别下的MVCC行为
* @param userId 用户ID
* @return 用户信息
*/
User testRrIsolationLevel(Integer userId);
}
package com.jam.demo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.entity.User;
import com.jam.demo.mapper.UserMapper;
import com.jam.demo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.ObjectUtils;
import jakarta.annotation.Resource;
/**
* 用户服务实现类
* @author ken
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Resource
private PlatformTransactionManager transactionManager;
@Resource
private UserMapper userMapper;
@Override
public User testRcIsolationLevel(Integer userId) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
User user1;
User user2;
try {
user1 = getUserById(userId);
log.info("RC隔离级别-第一次查询结果:{}", user1);
Thread.sleep(10000);
user2 = getUserById(userId);
log.info("RC隔离级别-第二次查询结果:{}", user2);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
log.error("RC隔离级别测试异常", e);
throw new RuntimeException(e);
}
return user2;
}
@Override
public User testRrIsolationLevel(Integer userId) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
User user1;
User user2;
try {
user1 = getUserById(userId);
log.info("RR隔离级别-第一次查询结果:{}", user1);
Thread.sleep(10000);
user2 = getUserById(userId);
log.info("RR隔离级别-第二次查询结果:{}", user2);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
log.error("RR隔离级别测试异常", e);
throw new RuntimeException(e);
}
return user2;
}
/**
* 根据用户ID查询用户信息
* @param userId 用户ID
* @return 用户实体
*/
private User getUserById(Integer userId) {
if (ObjectUtils.isEmpty(userId)) {
throw new IllegalArgumentException("用户ID不能为空");
}
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<User>()
.eq(User::getId, userId);
return userMapper.selectOne(queryWrapper);
}
}
6.5 接口层定义
package com.jam.demo.controller;
import com.jam.demo.entity.User;
import com.jam.demo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.annotation.Resource;
/**
* MVCC测试控制器
* @author ken
*/
@RestController
@RequestMapping("/mvcc")
@Tag(name = "MVCC测试接口", description = "测试不同隔离级别下MVCC的行为")
public class MvccTestController {
@Resource
private UserService userService;
@GetMapping("/test/rc/{userId}")
@Operation(summary = "测试RC隔离级别", description = "测试READ COMMITTED隔离级别下MVCC的不可重复读现象")
public User testRcIsolation(
@Parameter(description = "用户ID", example = "1") @PathVariable Integer userId) {
return userService.testRcIsolationLevel(userId);
}
@GetMapping("/test/rr/{userId}")
@Operation(summary = "测试RR隔离级别", description = "测试REPEATABLE READ隔离级别下MVCC的可重复读能力")
public User testRrIsolation(
@Parameter(description = "用户ID", example = "1") @PathVariable Integer userId) {
return userService.testRrIsolationLevel(userId);
}
}
测试方法:
- 启动Spring Boot项目,访问Swagger地址:http://localhost:8080/swagger-ui.html
- 调用/mvcc/test/rc/1接口,在接口等待的10秒内,手动执行UPDATE user SET age=21 WHERE id=1;并提交,观察日志输出,两次查询结果不一致,出现不可重复读。
- 调用/mvcc/test/rr/1接口,同样在等待的10秒内执行UPDATE并提交,观察日志输出,两次查询结果一致,实现了可重复读。
七、MVCC的生产最佳实践
基于MVCC的底层原理,我们总结了生产环境中的核心最佳实践,帮助你规避常见的坑,提升数据库性能与稳定性。
- 严格控制长事务长事务是MVCC的最大天敌,会导致undo log无法清理,版本链过长,查询性能下降,磁盘空间占用飙升。生产环境中必须避免在事务中执行耗时的非数据库操作,禁止在事务中等待人工操作,合理设置autocommit,避免非显式事务导致的长事务。
- 合理选择事务隔离级别对于高并发写、对一致性要求不极致的场景,推荐使用RC隔离级别。RC级别下,undo log的purge更及时,版本链更短,查询性能更好,同时binlog的row格式也能避免主从不一致问题。对于对数据一致性要求高的场景,使用InnoDB默认的RR隔离级别即可。
- 避免大事务批量修改数据大批量的UPDATE/DELETE操作会生成大量的undo log,不仅会导致事务执行时间过长,还会导致版本链急剧膨胀,影响其他事务的查询性能。生产环境中,大批量修改操作建议拆分成多个小事务分批执行。
- 正确使用索引,避免全表扫描全表扫描会导致InnoDB扫描大量的数据行,遍历每个数据行的版本链,查询性能急剧下降。同时,全表扫描还会导致锁范围过大,影响并发性能。必须为查询条件建立合适的索引。
- 定期监控undo log的使用情况生产环境中需要定期监控undo log的大小、增长速度,以及长事务的运行情况。当发现undo log持续增长时,及时定位并处理长事务,避免磁盘空间被占满。
总结
MVCC是InnoDB的核心灵魂,也是MySQL能支撑高并发场景的核心能力。本文从行记录的隐藏列出发,逐层拆解了undo log版本链的生成、Read View的可见性规则、不同隔离级别下的行为差异,纠正了行业内普遍存在的认知误区,配合可复现的SQL示例与Java实战代码,完整还原了MVCC的底层实现原理。