你写的每条 SQL 都没加过锁,可 MySQL 凭什么不怕两个事务打架?

一个事务在查、另一个事务在改同一张表,你怎么保证读到的不是别人改到一半的数据?

一种思路是读写之间通过锁互相协调,但读操作如果频繁等待写锁,吞吐量就会受到影响。

InnoDB 用 MVCC 解决了这个问题:

对于普通的一致性读(非锁定读),不必等待写事务释放锁,而是读取符合当前事务可见性规则的版本。

它靠的是当前记录 + undo log 中的历史版本信息,再通过版本链把同一行的不同版本串起来,根据规则判断当前事务应该看到哪个版本。

说白了,MVCC 真正干活的,是隐藏列、undo log、Read View 三件套。

注意,MVCC 并不是让 InnoDB 完全放弃锁。

普通一致性读主要靠 MVCC 实现非阻塞读取,而 UPDATE、DELETE、SELECT ... FOR UPDATE 等操作仍然会使用锁。

  • 隐藏列:每行记录背后,InnoDB 自动塞了三个你看不到的字段。
  • undo log:专门存历史版本的仓库。
  • Read View:事务读数据时拍的快照,配一套可见性规则。

三件套合起来,就能在并发下给你一个稳定、一致的读取结果。

下面把这三个东西一个个拆开。

先认识隐藏列

InnoDB 的聚簇索引记录内部包含这些隐藏字段。

不需要你建表时声明,对用户完全不可见。

隐藏字段 含义
DB_TRX_ID 最后一次插入或修改这行的事务 ID
DB_ROLL_PTR 回滚指针,指向上一个历史版本
DB_ROW_ID 隐式主键,表没有主键且无非空唯一键时自动生成

DB_TRX_ID 比较好懂:

InnoDB 会为需要事务 ID 的事务分配一个内部事务 ID。

执行 INSERT、UPDATE、DELETE 等写操作时,这个事务 ID 会记录到被修改行的 DB_TRX_ID 中。

比如事务 100 改了一行,那行的 DB_TRX_ID 就变成 100。

DB_ROLL_PTR 是回滚指针,指向这行数据的上一个历史版本。

这些历史版本躺在 undo log 里,靠这个指针,同一行的所有版本能串成一条链。

DB_ROW_ID 是 InnoDB 的隐式主键。

我在《MySQL 聚簇索引和非聚簇索引:为什么二级索引查完还要回表?》里讲过,聚簇索引总得有个键:

有主键时用主键,没主键但有合适的非空唯一索引时用它,两者都没有,InnoDB 才生成一个隐藏的 6 字节 DB_ROW_ID 当作聚簇索引键。

它只在 InnoDB 自动建索引时才真正用上,平时你看不到它。

undo log 是版本仓库

undo log 叫回滚日志,既能支撑事务回滚,也是 MVCC 的版本仓库,历史版本数据都存放在这里。

按操作类型,undo log 分两类:

插入操作产生的是插入型 undo log,只记插入行的主键 ID 就行。

新数据没历史版本,回滚时按主键直接删;

事务提交后,这类 INSERT undo 不再需要用于回滚,而 UPDATE、DELETE 产生的 undo 还可能被其他事务的一致性读使用,因此需要等不再被任何 Read View 需要时,才能由 purge 清理。

更新或删除操作产生的是更新型 undo log。

UPDATE 的 undo log 保存构建修改前版本所需要的信息,DELETE 的 undo log 保存恢复被删除行所需要的信息。

这些旧版本通过 DB_ROLL_PTR 串成版本链,事务提交后不会立刻删除,要等所有依赖这个版本的事务都结束,purge 机制才会清理。

可以把 undo log 想成游戏的存档点。

每次改数据前先存一份旧档,真要反悔或者别人想看旧状态,顺着指针往回翻就行。

Read View 决定你能看到哪个版本

Read View 叫读视图,名字里虽然带"视图"两个字,但它和 SQL 里的视图干的完全是另一件事。

普通视图里存的是 SQL 语句,Read View 里存的是事务 ID 相关的信息,不存 SQL 语句也不存真实行记录。

生成 Read View 的瞬间,相当于给当前事务读数据拍了张快照,把相关的事务 ID 都收集进来。

后面就靠这套 ID 加可见性规则,从版本链里挑出对当前事务可见的那个版本。

Read View 里一共四个字段:

字段 含义
m_ids 生成 Read View 时,仍处于活跃状态的读写事务 ID 集合
min_trx_id m_ids 里最小的事务 ID
max_trx_id 系统下一个要分配的事务 ID
creator_trx_id 生成这个 Read View 的事务自己的 ID

拿到这四个值还不够,还得有可见性算法。

规则就四条:

  1. 如果版本的 DB_TRX_ID 等于 creator_trx_id,说明这版本是自己改的,可读。
  2. 如果版本的 DB_TRX_ID 小于 min_trx_id,说明改这版本的事务在你拍快照前就提交了,可读。
  3. 如果版本的 DB_TRX_ID 大于等于 max_trx_id,说明这版本的事务在你拍快照之后才启动,不可读。
  4. 如果 min_trx_id ≤ DB_TRX_ID < max_trx_id,就看 DB_TRX_ID 在不在 m_ids 里:在,说明事务还没提交,不可读;不在,说明已经提交,可读。

当前版本不可读时,就顺着 DB_ROLL_PTR 回滚指针找上一个历史版本,重复这套判断,直到找到可读的为止。

要是整条链都找不到,那读到的就是空。

plain 复制代码
function visible(version_trx_id, view):
    if version_trx_id == view.creator_trx_id: return true   // 自己改的
    if version_trx_id <  view.min_trx_id:    return true   // 快照前已提交
    if version_trx_id >= view.max_trx_id:    return false  // 快照后才启动
    # 落在 [min, max) 之间
    if version_trx_id in view.m_ids:         return false  // 还活跃,未提交
    else:                                    return true   // 已提交

举个具体例子走一遍

光看规则还是虚,走一遍就清楚了。

假设有张订单表,只有 id、amount 两个字段。

一开始插入一行:

id=1,amount=199。

这行是事务 99 提交的,所以 DB_TRX_ID=99,刚插入没有历史版本,DB_ROLL_PTR 是空。

为了便于理解,下面假设事务 100 已经获得事务 ID 100,并处于活跃状态;

事务 101 随后获得事务 ID 101。

现在两个事务:

  • 事务 100 执行一次 select amount from order where id=1
  • 事务 101 启动,执行 update order set amount=299 where id=1,然后提交。

事务 100 在 RR 隔离级别下,又执行了一次一模一样的查询。

问题是:事务 100 两次读到的 amount 分别是多少?

下面重点关注当前版本的 DB_TRX_ID 与 Read View 中事务 ID 范围的关系。

事务 100 本身没有修改这行数据,因此不展开 creator_trx_id 对自身修改可见性的特殊处理。

第一次查询时,生成第一个 Read View。

此时事务 101 还没有启动,因此 101 不在当前快照中:

  • m_ids = 100(当前只有事务 100 活跃)
  • min_trx_id = 100
  • max_trx_id = 101
  • 当前行的 DB_TRX_ID = 99(事务 100 只读了,没改过这行)

套规则:

DB_TRX_ID=99,不等于 creator_trx_id;

99 小于 min_trx_id(100),命中第二条,已提交、可读。

所以第一次读到 amount=199。

接着事务 101 做更新

InnoDB 先把旧版本(amount=199,DB_TRX_ID=99)写进 undo log,再改内存里的当前行:

amount=299、DB_TRX_ID=101,DB_ROLL_PTR 指向刚写进 undo log 的那个旧版本,版本链形成。

然后事务 101 提交。

事务 100 第二次查询(RR 级别)。

RR 只在第一次一致性读时建立 Read View,之后的所有一致性读沿用第一次那张。

所以这次判断用的还是刚才那组值:

m_ids=100、min=100、max=101。

当前最新版本 DB_TRX_ID=101:

  • 不等于 creator;
  • 不小于 min(100);
  • 大于等于 max(101),命中第三条,这版本的事务在快照之后才启动,不可读。

读不到 299。

顺着 DB_ROLL_PTR 找到 undo log 里的旧版本:

DB_TRX_ID=99,小于 min_trx_id(100),可读。

amount=199。

于是事务 100 两次查询读到的都是 199。

在 RR 隔离级别下,同一个事务里的普通一致性读,不管别的事务怎么提交修改,都会基于同一张 Read View 读取可见版本,这就是可重复读。

RC 和 RR 的核心差异在 Read View 时机

本文重点讨论 RC 和 RR 下的 MVCC 一致性读。

两者最核心的区别,就是 Read View 的创建时机不同。

RC 下,每次执行一致性读都会新建一个 Read View。

这意味着一个事务里多次查同一行,会读到别的事务刚提交的最新值,所以只解决了脏读,没解决不可重复读。

RR 下,只在第一次一致性读时建立 Read View,之后的所有一致性读沿用这第一张。

后续读看到的都是那张旧快照里的数据,既挡住了脏读,也挡住了不可重复读。

隔离级别 Read View 创建时机 解决脏读 解决不可重复读
RC 读已提交 每次一致性读都新建
RR 可重复读 仅第一次一致性读生成,后续沿用

当没有活跃事务的 Read View 再需要这个旧版本时,purge 机制就会把它清掉。

到这,MVCC 最核心的一条实现链路就串起来了。

相关推荐
尼古拉斯-托尔斯泰-赵四1 小时前
Go 语言,你需要了解的一些规则
开发语言·后端·golang
程序员贺加贝1 小时前
列表导出不够用-SaaS-ERP-单据详情导出的-Provider-模板与文档型-Excel-设计
java·后端·设计模式·架构·excel
2301_800954991 小时前
MySQL 数据库基础:数据类型、查询与备份恢复
数据库·mysql·oracle
SimonKing1 小时前
写文档的最佳搭档:Typora+PicList+SM.MS
java·后端·程序员
起名真的太难了1 小时前
Mysql的数据类型,查询与备份操作
数据库·sql·mysql
程序猿_极客1 小时前
【免费】分享一套优质的基于Php+Mysql的电子购物网站的设计与实现,校园小卖部系统
数据库·mysql·php·购物商城系统
fīɡЙtīиɡ ℡1 小时前
LLM/Agent 安全实战核心要点总结
网络·数据库·安全
程序员夏洛1 小时前
MySQL 中有哪些锁类型?
数据库·mysql
Hive_MOM1 小时前
制造企业技术文件与图纸管理数字化(DCC):从“图纸满天飞“到版本受控
服务器·数据库·制造