半夜接到值班同事的电话,说业务线报“数据库全表锁死了”,查了一圈发现是一条 UPDATE 跑了几十分钟没结束,但诡异的是,应用层的查询接口依然秒回,像完全没受影响一样。围观的人第一反应是“是不是用了缓存”,其实那会儿根本没缓存,真正扛住读写冲突的,是 InnoDB 的 MVCC(Multi-Version Concurrency Control,多版本并发控制)。
这篇文章我打算把 MVCC 拆开讲透,重点回答那个经典问题:为什么普通 SELECT 从来不阻塞 UPDATE?顺带把版本链、ReadView、快照读和当前读这些概念一次性理清,最后再聊几个我实际排查过的坑。适合刚接触数据库原理的后端开发、DBA,以及想在面试里把 MVCC 讲出深度的人——三分钟可能有点夸张,但读完核心机制,你一定能跟别人说明白这回事。
1. 并发控制的十字路口:为什么锁方案搞不定读写冲突
1.1 读写锁模型的最低配代价
先回到最基础的问题:同一行数据,同时来了一个 SELECT 和一个 UPDATE,数据库怎么处理才能保证不丢数据、不错乱?
最朴素的办法是加一把大锁,读写互斥,同一时刻只允许一个操作访问这行数据。问题是,绝大多数业务场景都是读多写少,一个高频查询背后可能对应几十次读,如果每次读都要等前面的写事务提交,系统的并发能力基本就废了。
后来有了读写锁(共享锁 + 排他锁):读读可以并行,读和写互斥,写和写互斥。这个方案看起来合理了,但读写依然是互斥的,也就是说,只要有一个写事务正在改某行,所有想读这行的事务都得在锁外面排队。想象一个电商库存表,一次促销活动某个 SKU 被大量下单,UPDATE 一条接一条,如果每个 UPDATE 持锁期间都卡住一堆 SELECT,几秒钟就能把数据库的连接池打满。
真正要命的是大事务。假设一次 UPDATE 要修改 100 万行,需要执行 10 秒,那么这 10 秒内,所有需要访问这些行的事务都会阻塞,不管它们是读还是写。这种场景下,单纯靠锁做并发控制,代价高到无法接受。
1.2 加锁与不加锁的取舍:从“串行化”到“多版本”
既然加锁这么痛苦,那换一个思路:能不能让读操作不要等写操作完成,而是直接读旧版本的数据?
这就是 MVCC 的核心出发点。它跟锁方案最大的区别在于:写事务修改一行数据时,不是直接把原来的值覆盖掉,而是生成一个新版本,旧版本保留下来;读事务可以读新版本,也可以按需读旧版本,读写之间不再互相等待。
你可以把它想象成团队协作文档的版本历史:同事正在编辑某个段落,你打开文档看到的虽然是他编辑前的版本,但至少你能继续看、继续批注,不用干等他把文档锁住。数据库里面这一点更彻底——每个事务都能看到符合自己隔离级别要求的那个“历史版本”,谁也不挡谁。
InnoDB 的做法是用 undo log 保留下旧版本数据,配合每一行数据上的隐藏列,形成一条版本链,再通过 ReadView 决定某个事务能看到版本链上的哪一个版本。这套机制本质上是用存储空间换并发能力,用“读旧版本”替代“等新版本”。
2. InnoDB的版本链:MVCC藏在一行行的旧数据里
2.1 隐藏列:DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID
InnoDB 的每一行数据背后,都藏着三个对用户不可见但真实存在的隐藏列,MVCC 就是靠它们工作的。
| 隐藏列 | 作用 | 通俗解释 |
|---|---|---|
| DB_TRX_ID | 最近一次插入或更新该行的事务 ID | 这行数据的“最后修改人” |
| DB_ROLL_PTR | 回滚指针,指向 undo log 中该行之前的版本 | 版本回退的“链表指针” |
| DB_ROW_ID | 如果没有主键,InnoDB 用它作为聚簇索引的行 ID | 备用的物理行号 |
DB_TRX_ID 用来判断版本新旧,DB_ROLL_PTR 用来沿着版本链找旧数据。每次更新一行时,InnoDB 不会直接丢弃旧值,而是先把当前行的完整旧版本写入 undo log,然后更新 DB_ROLL_PTR,让它指向刚写入的旧版本。这样一来,从聚簇索引上的最新版本出发,一路沿着回滚指针,就能找到这行数据的所有历史版本。
2.2 undo log版本链的组装过程
用一个最常见的余额更新场景串一遍:
事务 T1 插入一行,账户余额为 100。此时聚簇索引上是“余额=100”,DB_TRX_ID 是 T1 的事务 ID,DB_ROLL_PTR 为空。
事务 T2 把余额更新为 200。InnoDB 先把“余额=100”这个旧版本写入 undo log,然后更新当前行,余额变成 200,DB_TRX_ID 变成 T2 的 ID,DB_ROLL_PTR 指向刚写入的“余额=100”的 undo 记录。
事务 T3 再把余额更新为 300。同理,先把“余额=200”写入 undo log,当前行变成“余额=300”,DB_ROLL_PTR 指向“余额=200”的 undo 记录。
这时候从聚簇索引上的最新版本出发,沿着 DB_ROLL_PTR 一路回溯,可以看到这样一条链:
余额=300(当前行)→ 余额=200(undo)→ 余额=100(undo)→ 插入的初始版本(insert undo)
这条版本链就是 MVCC 判断可见性的数据基础。所谓多版本,并不是说一行物理数据真的有多份拷贝在同一个数据页里,而是说通过 undo log 把每一次修改前的旧版本都串联起来了,需要哪个版本就沿着链找哪个版本。
2.3 ReadView:你站在哪个时空看世界
版本链有了,但不同事务该看哪个版本?总不能让所有事务都看到同一个版本吧。这里就需要 ReadView —— 事务在某个时刻生成的一个“视图快照”。
一个 ReadView 主要包含四部分内容:
- m_ids:生成 ReadView 时,当前系统中所有活跃(未提交)事务的事务 ID 列表;
- min_trx_id:m_ids 中最小的那个事务 ID;
- max_trx_id:生成 ReadView 时,系统已经分配过的事务 ID 最大值加 1,也就是下一个即将分配的事务 ID;
- creator_trx_id:创建这个 ReadView 的事务自己的 ID。
判断某个版本对当前事务是否可见,InnoDB 按照下面这套规则来:
- 如果版本的事务 ID 等于 creator_trx_id:说明这个版本是自己改的,可见;
- 如果版本的事务 ID 小于 min_trx_id:说明这个版本在生成 ReadView 之前就已经提交了,可见;
- 如果版本的事务 ID 大于或等于 max_trx_id:说明这个版本是生成 ReadView 之后才出现的,不可见;
- 如果版本的事务 ID 在 m_ids 列表中:说明这个版本在生成 ReadView 时仍属于未提交事务,不可见;如果不在 m_ids 列表中,说明它已经提交,可见。
第 4 条最容易忽略。很多文章只讲 min 和 max,但读提交(RC)级别下,一个事务 ID 可能正好落在 min 和 max 之间,但它已经提交了,那它就可见;如果它还是活跃事务,那它就不可见。所以最终判断一定得结合活跃事务列表,不能只看两头。
判断的时候,InnoDB 从版本链头(最新版本)开始,逐个版本套用规则,遇到可见的就返回数据,遇到不可见的就沿着 DB_ROLL_PTR 继续往前找。这就像你拿着一个有“时空标记”的筛选器,在一条时间线上一路往回翻,直到找到一条符合你视线范围的版本。
3. 快照读与当前读:SELECT不阻塞UPDATE的真正开关
3.1 两条并行的数据访问通道
在 InnoDB 里,SELECT 和 UPDATE 的“不阻塞”并不是因为它们之间没有竞争关系,而是因为它们走的是两条完全不同的访问通道。
快照读(snapshot read)指的是不加锁的普通 SELECT。它读的是当前事务 ReadView 所能看到的版本。如果最新版本不可见,就沿着版本链一直回溯到可见版本。整个过程中,InnoDB 不会给读取的记录加任何锁。正因为不加锁,它才不会跟 UPDATE 的锁冲突。
当前读(current read)读的是最新的已提交版本,而且会对读取的记录加锁。UPDATE、DELETE、INSERT 都属于当前读(INSERT 是隐式当前读),SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE 也是当前读。UPDATE 执行时,会先对目标行加排他锁(X 锁),其他事务的当前读或者写操作就必须等锁,而快照读不需要等。
所以“你的 SELECT 从来不阻塞 UPDATE”这句话,精确的说法是:不加锁的快照读,从来不会阻塞 UPDATE;会阻塞 UPDATE 的,是其他 UPDATE、DELETE、SELECT ... FOR UPDATE 这类当前读操作。
3.2 为什么“读旧版本”不会导致脏读和幻读
很多人第一次接触 MVCC 时会有疑问:如果 SELECT 可以读旧版本,会不会读到别人改了但没提交的数据?会不会读到已经不应该存在的脏数据?
关键在于 ReadView 的生成规则。
在 RC(读已提交)级别下,每条普通 SELECT 语句执行前都会生成一个全新的 ReadView,所以它能看到“已经提交”的最新事务版本,但看不到未提交事务的修改,这就保证了不会脏读。
在 RR(可重复读)级别下,整个事务的第一条普通 SELECT 会生成一个 ReadView,之后事务内所有普通 SELECT 都复用这个 ReadView。所以哪怕别的事务在中间提交了修改,当前事务看到的依然是首次查询时的数据快照。这就保证了事务内多次查询结果一致,也就是可重复读,同时在快照读层面规避了幻读。
用一个具体例子来感受:
事务 A 执行第一次 SELECT,余额读到 100。此时事务 B 启动,把余额从 100 改成 200,并提交。事务 A 再执行一次 SELECT:
- 在 RC 级别下,A 的第二次 SELECT 会生成新的 ReadView,它能看到 B 的提交,所以读到 200;
- 在 RR 级别下,A 的第二次 SELECT 复用第一次生成的 ReadView,B 的事务 ID 在 A 的 ReadView 边界之外,读不到,所以仍然看到 100。
这就是 MVCC 的底层逻辑:SELECT 不阻塞 UPDATE,不代表它一定要返回最新数据,它返回的是“当前事务应该看到的版本”。
3.3 “SELECT完全不会阻塞UPDATE”是真的吗
作为一个在生产和面试里反复被问到的问题,这里必须把边界说清楚。
普通快照读确实不阻塞 UPDATE,但下面这些情况,SELECT 会以其他方式“堵住” UPDATE:
- 如果你用的是
SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE,这是当前读,会对命中行加锁,自然可能阻塞后续 UPDATE; - 唯一键检查、外键约束检查这类隐式当前读,也可能与 UPDATE 产生锁竞争;
- 资源层面的阻塞,比如一个超大表的全表扫描把磁盘 IO 打满了,或者查询本身触发了大量 redo 刷盘,导致 UPDATE 的整体执行变慢。这种阻塞跟 MVCC 无关,但线上表现确实像“SELECT 拖慢了 UPDATE”。
在实际排查问题时,别一上来就甩锅给 MVCC。先确认业务代码里到底有没有隐式当前读,再看有没有资源竞争,最后才轮到考虑锁冲突。
4. RC和RR的ReadView生成时机差异:决定你能看到哪个世界
4.1 RR下ReadView只生成一次(快照读)
在 RR 级别下,一个事务内的所有普通 SELECT,只在第一次执行时生成 ReadView,后面全部复用。
事务 A 开启事务,事务 ID 为 20。事务 A 第一次执行 SELECT,系统上活跃的事务包括 ID 为 18、19 的事务,所以 ReadView 里记录下 m_ids = [18, 19, 20],min_trx_id = 18,max_trx_id = 21。之后事务 B(事务 ID 为 21)启动并提交了修改。事务 A 再执行 SELECT 时,发现版本的事务 ID 是 21,大于等于 max_trx_id,所以不可见——哪怕事务 B 已经提交了,事务 A 依然看不到它的修改。
这就是为什么 RR 下,一个事务里多次查询看到的数据完全一样。问题也随之而来:事务 A 对整个表的“快照”保持了一致,但如果别的事务插入了新行,事务 A 再次查询时也看不到,这就在快照读层面挡住了幻读。
4.2 RC下每次SELECT都会生成新ReadView
RC 级别则完全相反,每条普通 SELECT 语句都会重新生成 ReadView。
这意味着,只要别的事务在两次 SELECT 之间提交了,当前事务的下一条 SELECT 就能看到人家提交后的新版本。这种机制设计上就是要兼顾“不脏读”和“及时看到已提交数据”。
| 时间线 | 事务 A | 事务 B | A 在 RC 下看到 | A 在 RR 下看到 |
|---|---|---|---|---|
| T1 | BEGIN; SELECT x → 10 | 10 | 10 | |
| T2 | UPDATE x=20; COMMIT | |||
| T3 | SELECT x | 20 | 10 |
这个表格演化出来的场景,就是 RC 和 RR 在 MVCC 层面的直接差异。两者都不脏读,但 RC 每次查询都重新看世界,RR 整个事务只定格一次。
4.3 半一致性读:RC下UPDATE的隐藏加速黑科技
很多人不知道,MySQL 5.6 以后,InnoDB 在 RC 级别下执行 UPDATE 或 DELETE 时,会触发一个叫半一致性读(semi-consistent read)的优化。
什么意思?当 UPDATE 语句扫描到某一行时,发现这一行已经被别的事务锁住,正常情况下它应该直接进入锁等待。但半一致性读会让它先去 undo log 里读一下这行的旧版本,判断 WHERE 条件是否真的需要更新这一行:
- 如果条件不满足:说明这行根本不在我要更新的范围内,直接跳过,不用等锁;
- 如果条件满足:说明这行确实需要更新,那就继续等锁,等持有者释放后再执行当前读。
举个例子。事务 A 执行UPDATE t SET status=1 WHERE status=0,一次性锁定了一大批行。事务 B 执行UPDATE t SET status=2 WHERE id=100,而 id=100 那行 status 本来就是 5,根本不满足 A 的更新条件,但行恰好被 A 锁住了。在 RC 级别下,事务 B 通过半一致性读发现这行不需要等 A 释放,直接跳过,性能提升非常明显。
这个优化之所以能成立,靠的就是 undo log 里保存的旧版本——没有 MVCC 的版本链,半一致性读根本无从谈起。这也是少有的“写操作反过来利用 MVCC 提升性能”的场景。
5. MVCC不是银弹:长事务、undo膨胀与死锁排查实录
5.1 长事务拖垮全局的真相
MVCC 读旧版本不阻塞写,听着完美,但它有个隐藏成本:旧版本不能一直留着,否则空间和性能都撑不住。InnoDB 的 purge 线程负责清理不再被任何 ReadView 引用的旧版本,但前提是,没有任何活跃事务的 ReadView 还需要它们。
问题就出在长事务上。一个事务开了很久不提交,它生成的 ReadView 就会一直存活,那些在它视角里不可见的旧版本,purge 线程永远不能清理。一旦系统里高频更新某张表,而恰有一个大事务跑了几小时,undo log 就会以肉眼可见的速度膨胀,可能直接撑爆磁盘。
我在排查一个慢库时,就遇到过类似情况。业务端有个服务框架默认自动开启了事务,但有些定时任务在一个事务里干了大量读操作,时不时出现异常没提交,结果 undo 表空间持续上涨,整库所有查询都变慢。查的时候直接用这个 SQL 找出问题事务:
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx ORDER BY trx_started;把 trx_started 最早的几个事务揪出来,一查代码,果然是事务边界没管好。
5.2 版本链膨胀对普通SELECT的影响
长事务不仅拖垮 undo 空间,还会让特定行的版本链变得极长,直接影响普通 SELECT 的性能。
想象一个秒杀场景:某个商品的库存行被高频更新,每秒几十次 UPDATE。正常情况下这些提交很快,purge 线程能及时清理掉旧版本。但如果有个事务 A 在这个商品上开了快照读却不提交,它的 ReadView 就会把某个时间点之后的所有版本全部钉住,版本链从几条变成几百条上千条。
这时候事务 A 再读这行,就得从最新版本出发,沿着版本链一路遍历,每条版本都要套一遍可见性判断规则,直到找到符合它 ReadView 的版本为止。单行版本链越长,这次 SELECT 的成本就越高。
实际开发里的经验是:
- 每个事务尽量短,只做必要的修改就提交,别把一堆无关查询塞进事务;
- 高并发场景下要监控长事务,一旦发现事务执行时间超过阈值,立刻告警;
- ORM 层面哪怕只是纯查询,也尽量用只读事务或普通查询,别误开启读写事务。
5.3 一个真实死锁案例:当前读的锁竞争
快照读不阻塞 UPDATE,但 UPDATE 之间、当前读之间是会互相阻塞的,死锁也因此不可避免。
一个非常典型的死锁场景:
事务 1 执行UPDATE t SET a=1 WHERE id=1,拿到了 id=1 这行的锁,然后继续执行UPDATE t SET a=2 WHERE id=2,等待 id=2 的锁。
事务 2 执行UPDATE t SET a=3 WHERE id=2,拿到了 id=2 这行的锁,然后继续执行UPDATE t SET a=4 WHERE id=1,等待 id=1 的锁。
两个事务完美地互相等待,谁也不肯放手。数据库的死锁检测机制会介入,选择一个事务回滚,报 1213 错误。
这个场景里 MVCC 救不了你,因为 UPDATE 是当前读,必须拿锁。排查死锁时,看SHOW ENGINE INNODB STATUS里的 LATEST DETECTED DEADLOCK 段落,里面会记录两个事务分别持有什么锁、在等什么锁。常见解法是:
- 让所有事务按相同顺序访问行,比如统一按主键从小到大处理;
- 把多条 UPDATE 合并成一条 SQL,减少锁获取次数;
- 调整事务隔离级别为 RC,配合半一致性读减少无谓的锁等待。
5.4 唯一键与外键:哪些操作偷偷用上了当前读
还有一种情况经常让开发者栽跟头:明明代码里全是普通 INSERT 和 SELECT,线上却偶发锁等待。原因在于 InnoDB 在某些操作里会隐式使用当前读。
插入一条数据时,如果表上有唯一索引,InnoDB 必须检查这个唯一键是否已经存在。这个检查读的是最新的已提交版本,属于当前读,会对相关记录加锁。如果另一事务正在修改同一个唯一键对应的行,INSERT 就可能被阻塞。
外键约束也是一样的道理。插入或修改子表数据时,需要检查父表中对应记录是否存在,这个检查也是当前读,可能对父表的记录加锁。
我在一次批量导数据的时候踩过这个坑:一边导数据一边有线上服务更新同一张表,导数据任务频繁报锁等待超时。查了半天才发现,导数据的表上有唯一索引,批量 INSERT 时唯一键检查和线上 UPDATE 抢锁,后来把批量任务改成夜间低峰期分批执行,并加了重试机制才消停。
最后说几句实操体会
MVCC 的本质,就是用空间和“历史版本”换并发,把“加锁等你写完”变成“你自己去看旧版本”。真正理解它之后,你会明白为什么生产环境要求事务短小精悍,为什么长事务是数据库性能的头号杀手,为什么有些操作业务上看着只是普通查询,底层却在偷偷进行当前读。
还有一个排查经验想分享:遇到“SELECT 卡住 UPDATE”这类问题,别第一时间怀疑 MVCC 机制出了问题。先看有没有长事务,再看有没有隐式当前读和锁等待,接着查磁盘 IO 和连接池状态,往往真正的原因都藏在代码的事务边界里,而不是数据库引擎本身。多看 information_schema,多压测,比背多少遍定义都有用。