击穿 MySQL InnoDB MVCC 底层:从 undo log、Read View 到隔离级别的全链路深度拆解

在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会自动生成

这里有两个关键的认知点必须明确:

  1. 事务ID的分配时机:InnoDB只会给执行了修改操作(INSERT/UPDATE/DELETE)的读写事务分配唯一的、全局递增的事务ID。纯只读事务(只有SELECT语句)不会分配事务ID,其事务ID默认为0。
  2. DELETE操作的本质:InnoDB的DELETE操作并不是物理删除数据行,而是通过修改行记录的delete flag标记位,将其标记为已删除,真正的物理删除会由后台的purge线程在确认没有任何事务需要该版本时执行。

2.2 undo log的类型与生命周期

undo log是逻辑日志,记录的是数据行的修改逻辑,而非物理页的修改。根据操作类型,undo log分为两类:

  1. insert undo log:由INSERT操作生成。INSERT操作插入的记录,只有当前事务能看到,其他事务不会访问该记录的版本,因此事务提交后,对应的insert undo log可以被直接删除,无需purge线程处理。
  2. update undo log:由UPDATE/DELETE操作生成。这类undo log不仅用于事务回滚,还需要为MVCC的一致性读提供历史版本,因此事务提交后不能直接删除,必须等到没有任何事务的Read View需要引用该历史版本时,才会由purge线程统一清理。

2.3 版本链的生成过程

每一次对数据行的修改,都会生成一条对应的undo log,通过行记录的DB_ROLL_PTR回滚指针,将所有历史版本串联起来,形成一条单向的版本链。

我们通过一个完整的SQL示例,直观展示版本链的生成过程:

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:

sql 复制代码
-- 事务ID=5
UPDATE `user` SET `age`=21 WHERE `id`=1;

此时,InnoDB会做两件事:

  1. 生成一条update undo log,记录修改前的版本(age=20),该undo log的DB_TRX_ID=3
  2. 修改当前行记录的age=21,DB_TRX_ID=5,DB_ROLL_PTR指向刚生成的undo log

然后,执行第二次UPDATE操作,事务ID=8:

sql 复制代码
-- 事务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. 规则1:如果当前版本的DB_TRX_ID == creator_trx_id → 可见。当前事务自己修改的版本,自己当然可以看到。

  2. 规则2:如果当前版本的DB_TRX_ID < min_trx_id → 可见。该版本对应的事务,在当前Read View生成之前就已经提交,对当前事务可见。

  3. 规则3:如果当前版本的DB_TRX_ID >= max_trx_id → 不可见。该版本对应的事务,是在当前Read View生成之后才启动的,对当前事务不可见,需要回溯到上一个版本。

  4. 规则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. 版本1:DB_TRX_ID=8 → 处于min和max之间,且在m_ids中 → 不可见
  2. 版本2:DB_TRX_ID=5 → 处于min和max之间,且在m_ids中 → 不可见
  3. 版本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生成时机做了完全不同的定义:

  1. READ COMMITTED(读已提交)事务内每执行一次SELECT语句,都会生成一个全新的Read View
  2. REPEATABLE READ(可重复读)事务内第一次执行SELECT语句时,生成一个Read View,整个事务生命周期内复用这个Read View

这个差异,直接导致了两个隔离级别的一致性能力完全不同:

  • RC级别:每次SELECT都用最新的Read View,能看到其他事务已经提交的修改,因此会出现不可重复读。
  • RR级别:整个事务复用同一个Read View,后续的SELECT都用同一个规则判断可见性,因此不会看到其他事务提交的修改,解决了不可重复读。

生成时机的差异可以用如下流程图展示:

四、不同隔离级别下MVCC的行为差异实战

前面讲完了核心原理,现在我们通过可复现的SQL示例,直观展示不同隔离级别下MVCC的行为差异,所有示例均可在MySQL 8.0中直接执行。

首先,我们先初始化测试数据:

sql 复制代码
-- 重置测试表
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 复制代码
<?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 实体类定义

kotlin 复制代码
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接口

java 复制代码
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 服务层接口与实现

java 复制代码
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);
}
ini 复制代码
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 接口层定义

less 复制代码
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);
    }
}

测试方法:

  1. 启动Spring Boot项目,访问Swagger地址:http://localhost:8080/swagger-ui.html
  2. 调用/mvcc/test/rc/1接口,在接口等待的10秒内,手动执行UPDATE user SET age=21 WHERE id=1;并提交,观察日志输出,两次查询结果不一致,出现不可重复读。
  3. 调用/mvcc/test/rr/1接口,同样在等待的10秒内执行UPDATE并提交,观察日志输出,两次查询结果一致,实现了可重复读。

七、MVCC的生产最佳实践

基于MVCC的底层原理,我们总结了生产环境中的核心最佳实践,帮助你规避常见的坑,提升数据库性能与稳定性。

  1. 严格控制长事务 长事务是MVCC的最大天敌,会导致undo log无法清理,版本链过长,查询性能下降,磁盘空间占用飙升。生产环境中必须避免在事务中执行耗时的非数据库操作,禁止在事务中等待人工操作,合理设置autocommit,避免非显式事务导致的长事务。
  2. 合理选择事务隔离级别 对于高并发写、对一致性要求不极致的场景,推荐使用RC隔离级别。RC级别下,undo log的purge更及时,版本链更短,查询性能更好,同时binlog的row格式也能避免主从不一致问题。对于对数据一致性要求高的场景,使用InnoDB默认的RR隔离级别即可。
  3. 避免大事务批量修改数据 大批量的UPDATE/DELETE操作会生成大量的undo log,不仅会导致事务执行时间过长,还会导致版本链急剧膨胀,影响其他事务的查询性能。生产环境中,大批量修改操作建议拆分成多个小事务分批执行。
  4. 正确使用索引,避免全表扫描 全表扫描会导致InnoDB扫描大量的数据行,遍历每个数据行的版本链,查询性能急剧下降。同时,全表扫描还会导致锁范围过大,影响并发性能。必须为查询条件建立合适的索引。
  5. 定期监控undo log的使用情况 生产环境中需要定期监控undo log的大小、增长速度,以及长事务的运行情况。当发现undo log持续增长时,及时定位并处理长事务,避免磁盘空间被占满。

总结

MVCC是InnoDB的核心灵魂,也是MySQL能支撑高并发场景的核心能力。本文从行记录的隐藏列出发,逐层拆解了undo log版本链的生成、Read View的可见性规则、不同隔离级别下的行为差异,纠正了行业内普遍存在的认知误区,配合可复现的SQL示例与Java实战代码,完整还原了MVCC的底层实现原理。

相关推荐
七夜zippoe2 小时前
OpenClaw 数据加密:敏感信息保护的完整方案
数据库·mysql·php·openclaw·敏感保护
万亿少女的梦1682 小时前
基于Node.js和Express的旅游信息管理平台设计与开发
mysql·node.js·express·系统设计·旅游信息管理
万亿少女的梦16813 小时前
基于Python的高考志愿填报辅助系统设计与实现
java·spring boot·python·mysql·vue
万亿少女的梦16813 小时前
基于SpringBoot与Vue的历史博物馆展品展示与游客管理系统设计
spring boot·mysql·vue·系统设计·博物馆管理系统
灯澜忆梦15 小时前
MySQL 完整学习路线
学习·mysql
ClouGence16 小时前
保障公共安全,一线治安机构实时数据应用与实践
数据库·mysql·postgresql
Minxinbb16 小时前
TDSQL for MySQL 服务器重启操作
数据库·mysql·dba
渣渣灰飞17 小时前
MySQL 系统学习 第二阶段 第三章 DQL(Data Query Language)第三节:ORDER BY + LIMIT(排序 + 分页)
数据库·学习·mysql
fengbingchun19 小时前
客户端获取服务器端MySQL数据库表中数据C++实现
mysql
渣渣灰飞20 小时前
MySQL 系统学习 第二阶段 第三章 DQL(Data Query Language)第二节:WHERE 条件查询
sql·学习·mysql