尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/24 19:31:12

资讯中心
01
ARTICLE

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

深入理解InnoDB MVCC:解开SELECT不阻塞UPDATE之谜
半夜接到值班同事的电话说业务线报“数据库全表锁死了”查了一圈发现是一条 UPDATE 跑了几十分钟没结束但诡异的是应用层的查询接口依然秒回像完全没受影响一样。围观的人第一反应是“是不是用了缓存”其实那会儿根本没缓存真正扛住读写冲突的是 InnoDB 的 MVCCMulti-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_IDInnoDB 的每一行数据背后都藏着三个对用户不可见但真实存在的隐藏列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 的事务 IDDB_ROLL_PTR 为空。事务 T2 把余额更新为 200。InnoDB 先把“余额100”这个旧版本写入 undo log然后更新当前行余额变成 200DB_TRX_ID 变成 T2 的 IDDB_ROLL_PTR 指向刚写入的“余额100”的 undo 记录。事务 T3 再把余额更新为 300。同理先把“余额200”写入 undo log当前行变成“余额300”DB_ROLL_PTR 指向“余额200”的 undo 记录。这时候从聚簇索引上的最新版本出发沿着 DB_ROLL_PTR 一路回溯可以看到这样一条链余额300当前行→ 余额200undo→ 余额100undo→ 插入的初始版本insert undo这条版本链就是 MVCC 判断可见性的数据基础。所谓多版本并不是说一行物理数据真的有多份拷贝在同一个数据页里而是说通过 undo log 把每一次修改前的旧版本都串联起来了需要哪个版本就沿着链找哪个版本。2.3 ReadView你站在哪个时空看世界版本链有了但不同事务该看哪个版本总不能让所有事务都看到同一个版本吧。这里就需要 ReadView —— 事务在某个时刻生成的一个“视图快照”。一个 ReadView 主要包含四部分内容m_ids生成 ReadView 时当前系统中所有活跃未提交事务的事务 ID 列表min_trx_idm_ids 中最小的那个事务 IDmax_trx_id生成 ReadView 时系统已经分配过的事务 ID 最大值加 1也就是下一个即将分配的事务 IDcreator_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 复用第一次生成的 ReadViewB 的事务 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 18max_trx_id 21。之后事务 B事务 ID 为 21启动并提交了修改。事务 A 再执行 SELECT 时发现版本的事务 ID 是 21大于等于 max_trx_id所以不可见——哪怕事务 B 已经提交了事务 A 依然看不到它的修改。这就是为什么 RR 下一个事务里多次查询看到的数据完全一样。问题也随之而来事务 A 对整个表的“快照”保持了一致但如果别的事务插入了新行事务 A 再次查询时也看不到这就在快照读层面挡住了幻读。4.2 RC下每次SELECT都会生成新ReadViewRC 级别则完全相反每条普通 SELECT 语句都会重新生成 ReadView。这意味着只要别的事务在两次 SELECT 之间提交了当前事务的下一条 SELECT 就能看到人家提交后的新版本。这种机制设计上就是要兼顾“不脏读”和“及时看到已提交数据”。时间线事务 A事务 BA 在 RC 下看到A 在 RR 下看到T1BEGIN; SELECT x → 101010T2UPDATE x20; COMMITT3SELECT 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 status1 WHERE status0一次性锁定了一大批行。事务 B 执行UPDATE t SET status2 WHERE id100而 id100 那行 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 a1 WHERE id1拿到了 id1 这行的锁然后继续执行UPDATE t SET a2 WHERE id2等待 id2 的锁。事务 2 执行UPDATE t SET a3 WHERE id2拿到了 id2 这行的锁然后继续执行UPDATE t SET a4 WHERE id1等待 id1 的锁。两个事务完美地互相等待谁也不肯放手。数据库的死锁检测机制会介入选择一个事务回滚报 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多压测比背多少遍定义都有用。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。