news 2026/9/24 19:31:11

深入理解InnoDB MVCC:解开SELECT不阻塞UPDATE之谜

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解InnoDB MVCC:解开SELECT不阻塞UPDATE之谜

半夜接到值班同事的电话,说业务线报“数据库全表锁死了”,查了一圈发现是一条 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 按照下面这套规则来:

  1. 如果版本的事务 ID 等于 creator_trx_id:说明这个版本是自己改的,可见;
  2. 如果版本的事务 ID 小于 min_trx_id:说明这个版本在生成 ReadView 之前就已经提交了,可见;
  3. 如果版本的事务 ID 大于或等于 max_trx_id:说明这个版本是生成 ReadView 之后才出现的,不可见;
  4. 如果版本的事务 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 UPDATESELECT ... 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事务 BA 在 RC 下看到A 在 RR 下看到
T1BEGIN; SELECT x → 101010
T2UPDATE x=20; COMMIT
T3SELECT x2010

这个表格演化出来的场景,就是 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,多压测,比背多少遍定义都有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 19:31:04

信创自主可控测评利器:二进制分析工具能力拆解与实战

这两年做信创适配和自主可控测评的朋友应该都有同感:最难的不是写代码,而是面对一堆从合作方手里拿过来的二进制文件。没有源码、没有文档、甚至不知道对方用了哪些第三方库,你只知道它是个可执行文件或者动态库——但它能不能跑在国产CPU上&…

作者头像 李华
网站建设 2026/9/24 19:29:26

零代码API实战:用PostgREST将PostgreSQL快速发布为RESTful接口

1. 为什么我放弃手写 CRUD,开始折腾“零代码 API”过去几年我一直在做数据相关的后端服务,最烦的一件事就是:数据表和业务接口之间那点重复劳动。每张表都要写查询、写参数校验、写分页、写异常处理,表一多,光维护这些…

作者头像 李华
网站建设 2026/9/24 19:27:37

Spring Boot部署Kubernetes实战:镜像构建、探针配置与滚动发布

最近团队在折腾把Spring Boot服务迁到Kubernetes上的事,前前后后踩了不少坑,也总结出一些能直接抄作业的套路。很多人一上来就找一堆YAML模板往上一贴,结果要么Pod起不来,要么流量一上来就内存爆掉,还有的连探针都没配…

作者头像 李华
网站建设 2026/9/24 19:26:32

TCP滑动窗口全解析:原理、流量控制与拥塞控制

TCP 滑动窗口这个概念,很多人学的时候觉得不难,但一到实际调优就翻车。面试被问到"滑动窗口怎么实现流量控制",能说出"控制发送速率"的人不少,再往下问一句"它和拥塞控制的窗口有什么区别"&#xf…

作者头像 李华
网站建设 2026/9/24 19:26:14

鸿蒙Flutter网络层实战:dio配置、权限与踩坑指南

Flutter开发鸿蒙应用聊到网络,十个群里九个会问dio怎么配。前面两篇我们把开发环境、工程骨架和基础组件都过了一遍,这篇直接进入正题:用dio把网络请求跑通,并且能应对鉴权、超时、取消、上传下载这些真实场景。内容不光是贴代码&…

作者头像 李华
网站建设 2026/9/24 19:26:14

C盘清理六大方法:从系统工具到用户文件迁移的完整指南

1. C盘清理的底层逻辑与方案选型 1.1 为什么C盘总是最先满 Windows系统默认把用户文件夹、临时目录、系统还原点、休眠文件、虚拟内存页面文件全部放在C盘。你装软件时如果一路点“下一步”,绝大多数程序也会默认往 C:\Program Files 或 C:\Program Files (x86)…

作者头像 李华