写 SQL 这件事我们都很熟insert、update、delete敲下去就有结果简单直接。可一旦把它放回真实场景——两个客户端同时改同一张表一个人还没提交另一个就来了——事情立刻就不直观了我改的数据你什么时候能看到我还没提交的东西你怎么就读到了同一个事务里两条一模一样的 select凭什么给出两个答案这篇文章走的正是这条线先把事务是什么、ACID 里谁是因谁是果讲清楚顺手把 begin / commit / rollback 和单条 SQL 也是事务这件事说透再用两个终端把四种隔离级别挨个做实验看看脏读、不可重复读、幻读到底长什么样最后拆开 MVCC——三个隐藏列、undo log 版本链、ReadView 的可见性判断正面回答那句被问得最多的话RR 下人家都提交了我为什么还是看不到所以这篇文章要回答的其实就三件事谁先谁后你能看到谁以及这一切究竟是怎么做到的。咱们开始吧。目录一、先看全景数据库为什么需要一个打包机制二、事务到底是什么以及 ACID 里的因果三、事务在 MySQL 里怎么用四、隔离性为什么不同事务看到的世界可以不一样五、四种隔离级别一场实验课5.1 读未提交脏读5.2 读已提交不可重复读5.3 可重复读幻读和它的解法5.4 串行化5.5 怎么查看和设置六、MVCC读和写为什么能同时进行6.1 前置知识一每行记录身上都藏着你看不见的列6.2 前置知识二undo log 和版本链6.3 前置知识三事务 ID 是单向增长的6.4 ReadView一张拍照时刻表6.5 一条版本记录到底该不该给我看6.6 RC 和 RR 的分水岭ReadView 什么时候生成6.7 当前读与快照读七、回头看主线上的问题都答上了八、一致性最后一块拼图得靠你配合九、速查表咱们先站远一点看这张地图。MySQL事务这个话题容易学着学着就散成一地零件一会儿是隔离级别一会儿是隐藏列一会儿又是ReadView。所以开头先把整条主线摆出来后面每一段都是在往这条线上挂东西。一、先看全景数据库为什么需要一个打包机制MySQL是一套网络服务这一点我们平时容易忽略。它一头是服务端mysqld默认端口 3306另一头是形形色色的客户端——命令行的 mysql、Python 的 pymysql、界面工具 Navicat都算。客户端还能远程连你在自己电脑上装个客户端连机房里那台数据库完全没问题。这就带来一个必然结果同一个时刻会有一堆客户端在同一台数据库上做增删查改。而且 MySQL 服务端自己内部就是多线程设计同时处理多个请求本来就是它的日常。你在这边敲 SQL 的时候隔壁同事的代码正在往同一张表里写这不是意外这是常态。于是两个挺要命的问题就冒出来了。先说抢票票表里只剩 1 张余票你查到票数大于 0正准备扣减这时候线程被切走了隔壁那位也来查看到的还是有票也准备扣减——最后一张票卖给了两个人。再说转账我卡里 200 块给你转 100在数据库里这是两条 SQL先给我减 100再给你加 100要是在减完了还没加的空档里网络断了、进程崩了这 100 块就凭空消失账再也对不上。把这两个例子里我们希望它是什么样提炼出来其实是四条很朴素的要求要么全做完、要么全不做我买票的过程别影响你买完就得算数机器重启也得在买之前和买之后都得是一个确定的状态。数据库给出的答案就是事务。二、事务到底是什么以及 ACID 里的因果站在写 SQL 的人的角度看一条 UPDATE 就是一条 UPDATE没什么特别的。但站在用数据库的人的角度看一次转账就是一件事它得两条 UPDATE 一起完成才叫完成。这两条 UPDATE 单独拎出来没有意义合起来才是转账这个业务动作。我们把这种逻辑上相关、要么一起成功要么一起失败的一组 DML 语句叫做一个事务。所以大白话说事务不是程序员的语法糖而是使用者的视角。我要完成一件业务上的事这件事背后可能有好几条 SQL我不关心它们怎么排、出错了怎么办我只要求要么全成要么全败。这组SQL 是一个整体这个整体就是事务。这里有个容易被忽略的点MySQL 要同时应付很多客户端也就意味着它同时要跑很多个事务。它怎么管这些事务老规矩——先描述再组织。一个事务在 MySQL 内部就是一个对象有自己的结构体InnoDB 里叫trx_t里面装着事务 ID、状态、锁信息这些内容然后这些事务对象再被组织进事务列表里统一调度。这个点先提一句后面讲 MVCC 时用得上因为事务 ID 就藏在里面。接着看那四条要求它们就是大名鼎鼎的 ACID。原子性Atomicity一个事务里的操作要么全做完要么全不做不会停在中间某个环节。执行过程中出错就回滚到事务开始前的样子就像这事压根没发生过。持久性Durability事务一旦提交对数据的修改就是永久的系统故障也不会丢。隔离性Isolation数据库允许多个事务并发地读写数据隔离性用来防止它们交叉执行导致数据不一致。而隔离到什么程度又细分出了隔离级别读未提交、读已提交、可重复读、串行化。一致性Consistency事务开始前和结束后数据库的完整性没有被破坏数据从一个一致状态走到另一个一致状态。这里有个很重要的因果关系要单独拎出来一致性和另外三个不完全在一个层面上。原子性、隔离性、持久性是因一致性是果——只要前三个做到了一致性在技术层面就能得到保障。这话现在听着有点绕等我们把回滚和隔离级别走完你回头看这句会特别顺。三、事务在 MySQL 里怎么用概念讲完来看落地。第一件事不是所有存储引擎都支持事务。show engines; -- InnoDB 那行 Transactions 是 YESMyISAM 是 NOInnoDB 支持事务这也是我们后面所有讨论的舞台MyISAM 不支持所以它不在这个话题的射程里。第二件事事务得能开、能关、能反悔begin; -- 或者 start transaction开启一个事务 update account set balance balance - 100 where id 1; savepoint s1; -- 打个存档点起个名字 update account set balance balance 100 where id 2; rollback to s1; -- 只退回到 s1第一条 update 保留 rollback; -- 或者整个回滚退到事务开始前 commit; -- 确认提交数据从此持久化savepoint 就像是给旅行途中插的路标走到哪觉得不对可以定向退回来。要是不设保存点rollback 就只能一路退到事务开始的地方。还有一条铁律一旦 commit 了就再也 rollback 不回来了——提交之后的数据想撤销只能再写一条 SQL 改回去。这个坑挺多人踩实验里常见有人 commit 之后又 rollback然后懵了其实不是 rollback 失效了是它本来就只作用于还没提交的操作。第三件事也是最容易被忽略的一件正常业务里我们很少手动写 rollback因为事务本来就是为不正常的情况设计的。比如你 begin 之后update 做到一半客户端进程被 kill 了、网线掉了、服务崩了——这时候 MySQL 会自己把这个事务回滚掉那些没提交的修改一点痕迹都不会留下。这个自动回滚就是原子性的现场演出。第四件事得回答一个我们一直没想过的问题平时敲的那一条条不带 begin 的 SQL和事务是什么关系答案有点反直觉单条 SQL 也是事务。InnoDB 会给每条 SQL 默认包上一个事务只不过这个事务被自动提交了所以你感觉不到。想验证也简单把自动提交关掉select autocommit; -- 默认是 1也就是打开 set autocommit 0; -- 关掉它 delete from account where id 3; -- 执行了但没有提交 -- 此时让客户端崩掉重连再查id 3 又回来了数据还在说明这条 delete 根本没生效——因为它压根没提交。所以单条 SQL 也是事务这件事是真的平时只是 autocommit 顺手帮你把 commit 也做了。顺带说一句手动执行 begin 之后就必须手动 commit这跟 autocommit 是什么值没关系。手动开启的事务别指望 autocommit 帮你兜底。四、隔离性为什么不同事务看到的世界可以不一样到这里原子性和持久性在实验里都见过了。接下来要啃的是最难理解的那个隔离性。先讲个故事。你有没有想过你出生的时候你的父母早就来到这个世界了但你不该也不需要知道他们出生到你出生之前发生过的所有事。反过来也一样几十年前的那些人也看不到今天的世界。每个人来到这个世界的时间不一样各自能看到的世界就不一样。数据库里的并发事务也是这个道理。一个事务有它自己的开始和结束在它运行期间不该看到别的还没提交的事务改了啥也不该看到比它晚来的事务提交的结果。如果非让每个事务都无脑看最新数据那才反直觉。所以隔离性管的是运行中的事务事务 A 和事务 B 撞车了谁先来谁先跑谁先来的动作对后面的人才有意义。那么新问题就来了——隔离要隔离到什么程度这个程度就是隔离级别。在聊级别之前先看清数据库的并发场景其实只有三类读-读最省心没人改数据加锁纯属浪费写-写靠行锁排队另有更新丢失的话题本文不展开真正的主战场是读-写——隔离级别讲的其实全是读和写撞上了怎么办。五、四种隔离级别一场实验课MySQL 支持四种隔离级别从松到紧是读未提交RU、读已提交RC、可重复读RR、串行化Serializable。前面三种的区别基本都体现在读上我们用两个终端就能把它们的脾气摸清楚。前提也很简单准备一张 account 表id、name、balance 三列再把两个终端的隔离级别调成要测的那一个。5.1 读未提交脏读-- 终端 A begin; update account set name lisi where id 2; -- 改完先不提交-- 终端 B隔离级别 RU select * from account; -- 看到了 lisi —— A 还没提交呢A 连 commit 都没执行B 就把人家改的东西读走了。这种现象叫脏读。它最要命的地方在于万一 A 后面回滚了B 读到的这个东西压根没存在过。原因也很直接——RU 下读的时候基本不加锁谁写了一半我照读不误。效率是高但坑太大正式环境严重不建议使用。5.2 读已提交不可重复读既然是读已提交那就等你提交了我再看实验现象如下-- 终端 ARC begin; update account set name lisi where id 2; commit;-- 终端 BRC begin; select * from account where id 2; -- 第一次读张三 -- …… A 在这中间提交了 …… select * from account where id 2; -- 第二次读李四注意还在同一个事务里同一个事务里同样一条 select前后两次结果不一样这叫不可重复读。有人会问人家都提交了我读到不是应该的吗——不该。B 是一个事务原子性是它自己的属性在 B 自己运行期间它读到的数据应该是一个口径的。A 提交了让后面新开的事务看到完全没问题但不该让一个正在跑的事务中途换了口径。举个实际点的例子HR 按薪资区间给大家挑年终礼物读到 1000~2000 的发水杯、3000~4000 的发微波炉如果读的过程中数据在变就可能出现有人既符合第一个区间、又符合第二个区间名单直接对不上。5.3 可重复读幻读和它的解法到了 RR也是 MySQL 的默认级别实验现象更有意思-- 终端 ARR begin; insert into account values (5, wangwu, 1234.5); update account set name lisi where id 2; delete from account where id 4; commit;-- 终端 BRR begin; select * from account; -- A 的这些改动一条都看不到 select * from account; -- 提交了还是看不到 commit; select * from account; -- 自己事务结束了这回看到了增、删、改全试一遍B 在自己事务里一概看不到A 提交了也不行非得 B 自己也结束、重新查才看得到。这就是可重复读的字面意思同一个事务里反复读读到的结果保持一致。那幻读是什么有些数据库在 RR 下挡不住 insert——你屏蔽得了对已有数据的修改屏蔽不了凭空多出来的新记录于是同一个事务里两次查第二次查出一批之前没有的行像产生了幻觉这就叫幻读。它属于不可重复读的一种特殊形态专门针对新增。MySQL 在 RR 下基本把幻读也解决了普通的快照读靠 MVCC下一节讲来挡而 insert / update / delete 这类写操作和加锁读靠 Next-Key Lock间隙锁加行锁兜底。这块展开是另一个话题知道结论就够。5.4 串行化串行化是最高级别事务排队一个一个来。实验里我们让终端 B 的事务开着A 想去 delete 同一张表直接卡住一直等到锁等待超时报错A 一提交B 那边的操作才放行。安全是真安全但性能没了——读写全都要加锁排队。数据库本来就是给一堆上层应用共享的这么玩效率会很惨。所以串行化一般是宁可慢也不能错的极端场景才用它是安全的方案但不是高效的方案。四种级别摆在一起其实就是一个取舍安全性和并发性你更看重哪个。隔离级别中文能读到什么可能出现的问题READ UNCOMMITTED读未提交别人没提交的也能读到脏读、不可重复读、幻读READ COMMITTED读已提交只能读别人已提交的不可重复读、幻读REPEATABLE READ可重复读整个事务里读到的口径一致幻读InnoDB 下基本避免SERIALIZABLE串行化事务排队执行基本没有并发问题代价是性能最低顺带说一句这个平衡不是数据库替你定的是场景定的。MySQL 的做法不是拍板一个级别让大家用而是把四种都提供出来让你按业务自己选——这才是它要提供多种隔离级别的原因。5.5 怎么查看和设置-- 查看当前的隔离级别 select transaction_isolation; -- 新版本 -- select tx_isolation; -- 5.7.20 之前的写法 -- 设置隔离级别 set session transaction isolation level read committed; -- 只影响当前会话 set global transaction isolation level repeatable read; -- 影响之后新建的连接session 只影响当前这个连接你退出客户端它就没了global 是全局的但它不会动已经在跑的连接只对之后新建立的连接生效——因为新连接建立时会把 global 的值复制一份当成自己的 session 值。实验里经常遇到我明明改了怎么没生效八成就是没重新连客户端。另外多个客户端做实验时建议把隔离级别设成一致的不然你会看到一些看起来很莫名其妙的现象。六、MVCC读和写为什么能同时进行前面我们用实验把四种级别的表现摸清了但有一件事一直没说RR 下 A 明明把数据改了、还提交了B 怎么就看不到B 读的到底是哪一份数据这就要讲到全文最核心的部分MVCC多版本并发控制。一句话概括它是一套无锁的并发控制专门用来解决读-写冲突。思路很朴素——既然读和写会打架那就别让它们盯同一份数据写操作去改最新的数据读操作去读历史版本两边根本不在一个地方自然就不用互相加锁读写也就能并发了。那么问题就变成三件事历史版本存在哪怎么串起来读的时候怎么知道该拿哪一份为了回答它们我们需要三个前置知识一个一个来。6.1 前置知识一每行记录身上都藏着你看不见的列我们建表的时候写了几列就是几列但InnoDB在背后给每一行记录都悄悄加了几列它们不在你desc出来的表结构里隐藏列大小作用DB_TRX_ID6 字节最近一次修改插入/更新这条记录的事务 IDDB_ROLL_PTR7 字节回滚指针指向 undo log 里这条记录的上一个版本DB_ROW_ID6 字节隐藏的自增 ID表里没有主键时用它来建聚簇索引另外还有一个删除标记位它放在记录头里不算严格意义上的隐藏列但大家习惯一起讲删除一条记录并不是真的把数据抹掉而是把这个标记改一下。这几个字段里我们主线上最重要的是前两个。DB_TRX_ID回答了这行数据最后一次是被哪个事务改的DB_ROLL_PTR回答了上一个版本在哪。有了这两个一行数据就有了自己的身份证和上一站的地址。至于DB_ROW_ID它存在的意义是表没主键时InnoDB总得有个东西来撑起聚簇索引吧那就用它。6.2 前置知识二undo log 和版本链假设 account 表里有一行张三28 岁。现在有个事务事务 ID 是 10要把它改成 29 岁。InnoDB 不会直接覆盖了事而是分几步走先把改之前的样子张三28 岁DB_TRX_ID 9抄一份存进 undo log再把这行记录的DB_ROLL_PTR指向这份刚存下来的历史版本最后才把数据改成 29 岁并把这行的DB_TRX_ID更新成 10。如果后面又来一个事务ID 是 11要改成 30 岁剧本一模一样再来一遍先把 29 岁这份抄进 undo log让新记录的DB_ROLL_PTR指过去再改成 30 岁。于是就有了这样一条链每次修改都留下一个旧版本旧版本之间用回滚指针串起来像链表一样这个结构就叫版本链。有了它一个事务读数据时只要沿着链往前走就能找到自己该看到的那个版本。这里顺便澄清一个容易混淆的点版本链主要是给快照读用的而回滚rollback本身InnoDB 走的是 undo log 里记录的反向操作——比如把年龄从 30 改回 29再改回 28。两条路共用 undo log 这份材料但干的事情不一样。6.3 前置知识三事务 ID 是单向增长的再回到那个老问题两个事务怎么分先后InnoDB 的做法是给每个事务发一个全局唯一、单向增长的事务 ID越早开始的事务 ID 越小越晚的越大。有了这个先后顺序就能靠比大小来判断——这是后面 ReadView 判可见性的基石。另外前面说过 MySQL 内部的事务是对象trx_t那么 ReadView 本质上也是一个对象。这一点很重要既然是对象那什么时候创建它就是一个可以被设计的选择。而 RC 和 RR 的区别恰好就落在这个选择上。6.4 ReadView一张拍照时刻表版本链有了事务 ID 有了还差最后一块拼图一个事务怎么知道自己该看哪一份版本答案是 ReadView读视图。它其实就是事务在做快照读时生成的一份记录记下我读的这一刻系统里各个事务都是什么状态。之后拿版本链里的DB_TRX_ID跟这份记录比一比就知道该看哪个版本了。先看它的样子InnoDB 源码里的 ReadView这里只留主线相关的字段class ReadView { private: ids_t m_ids; // 生成这张视图时还没提交的活跃事务 ID 列表 trx_id_t m_up_limit_id; // 低水位m_ids 里最小的 ID比它还小的版本都已提交 trx_id_t m_low_limit_id; // 高水位系统已经分配过的最大事务 ID 再加 1 trx_id_t m_creator_trx_id; // 创建这张视图的事务 ID也就是我 };四个成员翻译成人话就是m_ids是我拍照时还在跑的那些事务的清单它是我判断谁跟我并发的依据m_up_limit_id是清单里最小的那个 ID凡是比它还小的事务说明在我来之前就结束了属于我出生前的事m_low_limit_id是我拍照时系统最大事务 ID 1也就是下一个即将被分配的 ID比它还大的都是在我之后才来的事务m_creator_trx_id就是我自己。必须提醒一句这两个水位的名字起得有点反直觉 ——m_up_limit_id是低水位下界m_low_limit_id是高水位上界。别被名字绕进去记住含义就行。6.5 一条版本记录到底该不该给我看有了 ReadView判断就变成一道很机械的题拿着版本链上某个版本的DB_TRX_ID跟我的视图对一遍。InnoDB 里做这件事的函数长这样// 简化自 ReadView::changes_visible版本 ID 是 id 的这条记录我能看到吗 bool changes_visible(trx_id_t id, ...) const { // 1. 比低水位还小 → 它早提交了或者它就是我自己改的 → 看得见 if (id m_up_limit_id || id m_creator_trx_id) return true; // 2. 大于等于高水位 → 我拍照时它还没出生我压根不认识 → 看不见 if (id m_low_limit_id) return false; // 3. 活跃清单是空的 → 除了我没人还在跑剩下的都已提交 → 看得见 if (m_ids.empty()) return true; // 4. 在活跃清单里能找到 → 它跟我同时在跑没提交→ 看不见 // 找不到 → 说明我拍照那一刻它已经提交了 → 看得见 return !std::binary_search(m_ids.data(), m_ids.data() m_ids.size(), id); }代码很直白规则就四条版本的事务 ID 比低水位小说明早就提交了可见版本的事务 ID 是我自己我改的当然看得见可见版本的事务 ID 大于等于高水位说明我拍照时你还没出生不可见版本的事务 ID 在我的活跃清单里说明你跟我同时在跑不可见——不在清单里说明拍照那一刻你已经提交了可见。拿个具体例子把规则跑一遍。假设系统里事务 ID 已经分配到 4事务 2 生成 ReadView 时活跃事务是 1 和 3那么m_ids是 1 和 3低水位是 1高水位是 4 1 5创建者是 2。现在版本链上最新那个版本的DB_TRX_ID是 4也就是事务 4 改的。逐条判4 小于 1 吗不是。4 等于 2我自己吗不是。4 大于等于 5 吗不是。4 在活跃清单 1、3 里吗不在——这说明我拍照的时候事务 4 已经提交了。结论是这个版本我可见事务 2 能读到事务 4 提交的最新数据。等一下这不就是读已提交RC的行为吗没错这个例子的结果就是 RC。那 RR 是怎么做到连提交了都看不到的答案藏在下一小节。6.6 RC 和 RR 的分水岭ReadView 什么时候生成关键的一句话ReadView 不是事务一创建就有的而是在这个事务第一次快照读的时候才生成的。RR 下一个事务只在第一次快照读时生成 ReadView之后一直复用同一份。视图不变谁是活跃事务不变可见性自然也不变——你后面改多少、提交多少次我都按老照片来判一律看不到。这就是可重复读的由来。RC 下每次快照读都重新生成一份。时间在往前走每张照片都比上一张新只要你在两次读之间提交了我下一张照片里你就不在活跃清单里了于是我就看到你了。这就是 RC 下能看到别人提交的原因也是 RC 下会出现不可重复读的原因。先用一张时序图看看 RR 的常规情形那反过来呢如果 B 从 begin 之后一直没做过快照读直到 A 提交完了B 才第一次 select——这时候 B 才生成 ReadView拍照时 A 已经不在活跃清单里了于是 B 就看到了 A 的修改。明明是在 RR 级别下。这个反直觉的实验恰好是最有说服力的证据ReadView 的生成时机直接决定了这个事务能看到什么。所谓 RR保证的是一个事务前后读到的口径一致而不是一定读不到别人改的。只要你在别人提交之前读过一次你的视角就被锁定了你要是从头到尾没读过那你的第一次读会看到当时最新的世界。6.7 当前读与快照读上面讲的普通 select 都是快照读——沿着版本链读历史版本不加锁。但写操作不行insert、update、delete 修改的必须是最新的数据它们得加锁这叫当前读。当前读的成员不止增删改还有两个显式加锁的查询select * from account where id 1 lock in share mode; -- 加共享锁当前读 select * from account where id 1 for update; -- 加排他锁当前读这个区别很有用。RR 下我们在实验里看到 B 一直读的是老版本但只要把查询改成 lock in share mode立刻就能读到 A 提交后的最新值。顺便也就能理解串行化为什么那么慢那一级别下普通 select 也会被当成加锁读来处理读写双方都得上锁排队冲突自然就多了。七、回头看主线上的问题都答上了走到这里前面悬着的几个问题可以一次性交代清楚了。为什么读和写能并发因为写操作改的是最新数据当前读加锁读操作读的是历史版本快照读不加锁两边摸的压根不是同一份数据不用互相等。为什么别人没提交的数据我看不到因为版本链上那个最新版本的DB_TRX_ID还躺在我的活跃清单里可见性判断直接把它挡掉了。为什么 RC 和 RR 表现差这么多因为一个每次读都重新拍照一个只拍一次。为什么回滚能回得去因为旧版本都躺在 undo log 里——回滚就是把反向操作再执行一遍把数据恢复成原来的样子。为什么隔离级别不同看到的数据就不一样因为隔离级别本质上决定的就是你按哪一张 ReadView 的照片来判可见性。八、一致性最后一块拼图得靠你配合最后回到 ACID 里最有哲学味的那一条一致性。MySQL 自己并没有一个叫一致性的模块——它实际做的是原子性、隔离性、持久性这三件事而这三件事恰好能保证事务执行的结果会让数据库从一个一致状态转到另一个一致状态。所以 A、I、D 是因C 是果。但这只是技术层面的一半另一半得靠你。举个例子你要实现转账中间故意只写了给 A 扣 100忘了写给 B 加 100然后交给 MySQL 执行——MySQL 会老老实实把这条扣钱的操作原子地、隔离地、持久地执行完每一步都无懈可击可账就是错的。它不懂你的业务也判断不了你的 SQL 写得对不对。所以一致性是数据库和使用者一起维护的数据库保证你给我的这组操作要么全做要么全不做你保证这组操作本身就是对的。九、速查表概念 / 结构作用关键字段或操作出现位置事务把一组逻辑相关的 DML 打包成要么全成要么全败的整体begin、commit、rollback、savepointSQL 层InnoDB 支持MyISAM 不支持autocommit决定单条 SQL 是否自动提交set autocommit 0/1会话变量默认打开隔离级别控制事务之间互相影响的程度READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLEset session/global transaction isolation level脏读读到了别人还没提交的数据读不加锁RU 级别下出现不可重复读同一事务里两次读结果不一致ReadView 每次都重建RC 级别下出现幻读同条件下第二次读多出了新记录针对 insert 场景InnoDB 的 RR 下基本避免DB_TRX_ID记录最后一次被哪个事务修改6 字节每行记录的隐藏列DB_ROLL_PTR指向 undo log 里的上一个版本7 字节每行记录的隐藏列DB_ROW_ID无主键时的隐藏自增 ID6 字节每行记录的隐藏列undo log保存旧版本也保存反向操作版本链就串在这上面InnoDB版本链把一条记录的所有历史版本串起来靠 DB_ROLL_PTR 串联undo log 中ReadView记录生成时刻的活跃事务用来判断可见性m_ids、m_up_limit_id、m_low_limit_id、m_creator_trx_idInnoDB事务第一次快照读时生成快照读读历史版本不加锁普通 selectRC / RR当前读读最新版本要加锁insert / update / delete、lock in share mode、for update各隔离级别RC 与 RR 的分水岭ReadView 重建还是复用RC 每次快照读重建RR 只在第一次建InnoDB