十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL MVCC原理详解:从版本链到ReadView,解决读写并发

MySQL MVCC原理详解:从版本链到ReadView,解决读写并发 1. 从“读写互相堵”说起MVCC到底解决了什么我在不少团队里都见过同一个问题线上订单表在高峰期频繁执行UPDATE后台的分析查询却被卡得完全跑不动。很多人第一反应是“加索引”调了半天发现效果甚微最后定位到根因才发现是InnoDB在读写并发时的处理机制没搞清楚。MySQL的多版本并发控制也就是MVCC正是解决这个痛点的核心机制。MVCC的全称是Multi-Version Concurrency Control中文叫多版本并发控制。它的思想很直接一张表里的同一行记录在并发场景下可以同时存在多个历史版本读操作读取其中一个符合条件的版本写操作基于最新版本生成新版本两者各走各的路径互不阻塞。这就是为什么InnoDB在默认隔离级别可重复读RR下普通的SELECT和INSERT、UPDATE、DELETE可以并发执行而不会互相等锁。要理解MVCC的价值先得回到并发控制的三个老大难问题脏读、不可重复读、幻读。传统的做法是加锁——读加共享锁、写加排他锁读完才能写、写完才能读一致性保证了但并发度也归零了。MVCC的思路则是“用空间换并发”通过保留数据的多个历史版本让读操作不需要等待写操作释放锁而写操作也不需要担心读操作会读到中间态。锁仍然存在但锁的粒度被限制在“写与写冲突”这个范围内读与写彻底解耦。对于刚接触MySQL的人可以把MVCC理解成一套“账本体系”每次修改不直接覆盖原来的记录而是在账本上新增一行旧记录留在原地作为历史版本。任何一个时刻进来查账的人只要按照自己的“身份信息”去匹配账本里哪一行对他可见就行。这样即便有人正在改账查询也能安心地读到某个稳定的历史快照不会被打断也不会看到改到一半的脏数据。MySQL特指InnoDB引擎把这套账本体系落地成了隐藏字段、undo log版本链、ReadView三段式结构下面逐一拆开。2. 版本链与隐藏字段MVCC的地基MVCC不是凭空存在的它依赖InnoDB在每一行记录上维护的三个隐藏字段以及由undo log串联而成的版本链。搞懂这两个东西才算真正摸到MVCC的门槛。2.1 三列隐藏字段分别干了什么InnoDB的聚簇索引记录里除了用户定义的业务字段还有三个隐藏的数据库字段这是MVCC的物理基础隐藏字段长度作用DB_ROW_ID6字节行ID如果没有定义主键且没有非空唯一键InnoDB用它作为聚簇索引键有主键时它不参与版本控制DB_TRX_ID6字节最近一次插入或更新该行的事务ID事务ID是InnoDB内部递增分配的每个写事务拿一个值越大表示事务开始得越晚DB_ROLL_PTR7字节回滚指针指向该记录上一个版本在undo log中的位置通过它串起整个历史版本链这里最容易混淆的是DB_TRX_ID。它记录的是“最后一次修改这条记录的事务ID”注意是最后一次不是第一次。每次UPDATE都会刷新这个字段的值同时把旧版本连同旧的事务ID一起放进undo log由DB_ROLL_PTR指过去。DB_ROW_ID很多人误以为是MVCC必需的实际上只有当表没有主键时它才被用来支撑聚簇索引。如果表有主键DB_ROW_ID就是一个冗余字段MVCC的可见性判断根本不看它。我之前排查一个诡异问题时发现有人试图通过DB_ROW_ID追版本历史这方向本身就走偏了正确路径永远是从DB_TRX_ID和DB_ROLL_PTR入手。2.2 undo log怎么把版本串成链undo log回滚日志存放在回滚段rollback segment中分两类insert undo log和update undo log。insert undo log在事务提交后就可以直接清理因为新插入的记录没有更早的历史版本不需要提供给任何读操作做版本回退update undo log则要保留更长时间它记录了修改前的旧值是版本链的重要组成部分。我用一个例子演示版本链的形成过程。假设有一张表CREATE TABLE t_order ( id INT PRIMARY KEY, status VARCHAR(20) ) ENGINEInnoDB;初始状态事务T1插入一行(1, pending)。此时聚簇索引记录为DB_TRX_ID 1DB_ROLL_PTR nullstatus pending紧接着事务T2执行UPDATE t_order SET status paid WHERE id 1。InnoDB不是直接把这行的status改成paid就完事而是做两件事把当前记录statuspending连同DB_TRX_ID1写进undo log生成一条update undo记录。在原记录上修改status为paid更新DB_TRX_ID2并让DB_ROLL_PTR指向刚才那条undo log。逻辑形态变成版本链: 记录1: statuspaid, trx_id2, roll_ptr ---- 记录0 记录0: statuspending, trx_id1, roll_ptr ---- null再来一个事务T3执行UPDATE t_order SET status shipped WHERE id 1版本链继续延长record(trx_id3, statusshipped) - record(trx_id2, statuspaid) - record(trx_id1, statuspending)这就是undo log版本链。注意“最新版本在聚簇索引记录上历史版本在undo log里通过回滚指针串行连接”这个基本模型后文的全部可见性判断都是在这个链条上完成的。还有一个容易忽略的细节DELETE操作在InnoDB里并不直接删除物理记录而是做一次特殊的UPDATE把记录上的删除标记位delete_bit置为true同时生成一条delete标记的undo记录进入版本链。真正的物理清除要等purge线程介入后面的章节会聊到。3. ReadView生成与可见性算法把“可见性”算出来版本链搭好了接下来问题是一个读事务面对一条装了多个版本的记录怎么确定哪个版本对它可见答案是ReadView中文一般叫读视图。它不是MySQL里某个表或某个文件而是事务在特定时刻为“一致性读”生成的一组内存数据结构相当于给当时正在活跃的写事务拍了一张快照。3.1 ReadView的四个核心字段一个ReadView主要包含四个信息字段名含义m_ids生成ReadView时系统中所有活跃未提交读写事务的ID列表min_trx_id上述活跃事务列表中最小的IDmax_trx_id生成ReadView时InnoDB分配给下一个新事务的ID注意不是当前最大事务ID而是“下一个要分配的ID”creator_trx_id生成这个ReadView的事务自己的ID为什么max_trx_id是“下一个要分配的ID”而不是当前最大活跃事务ID因为这样判断边界更安全所有ID大于等于max_trx_id的事务都是在ReadView生成之后才开始的对当前读事务来说都是“未来事务”它们做的修改一律不可见。如果某个活跃事务已经拿到了ID100ReadView的max_trx_id可能是101这个边界把ID100的事务也排进了“不可见区间”但别急是否不可见还要看它是否在m_ids里两个条件要配合起来用。3.2 可见性判断的五个步骤拿到一个版本后先读它的DB_TRX_ID记为trx_id然后按下列顺序判断如果trx_id等于creator_trx_id说明这个版本是当前事务自己修改的肯定可见。自己改的数据自己看不到那才是见鬼了。如果trx_id小于min_trx_id说明这个版本在ReadView生成之前就已经提交了事务ID比所有活跃事务都小不可能还在“进行中”可见。如果trx_id大于等于max_trx_id说明这个版本是由ReadView生成之后才启动的事务修改的属于“未来事务”不可见。如果min_trx_id ≤ trx_id max_trx_id需要进一步查m_ids列表trx_id不在m_ids中说明它虽然落在活跃事务ID区间内但已经提交了可见。如果trx_id在m_ids列表中说明修改这个版本的事务在ReadView生成时仍然活跃、尚未提交它产生的新版本不可见。不可见的情况下怎么办沿着DB_ROLL_PTR找到undo log里的上一个版本取出它的DB_TRX_ID重复上述判断一直到找到可见版本或走完整个版本链。这也是为什么版本链越长一次读操作要做的比较越多性能损耗越明显。为了便于记忆可以简化成一句话ReadView只认三种版本——自己改的、已经提交的、以及在我创建快照之前提交的。除此之外一律回溯上一个版本。diff版本时把“已提交的”标准再细化为提交时间早于我创建快照的时间用事务ID表示就是trx_id min_trx_id或者其他区间外的已提交事务。3.3 RC与RR同一套规则不同的生成时机这是MVCC里最关键的对比。读已提交RC和可重复读RR使用的可见性判断算法完全一样区别只在ReadView的生成时机。在RC隔离级别下每次执行快照读普通SELECT都会生成一个新的ReadView。这意味着同一个事务里两次SELECT可能拿到不同的结果。举个例子事务A先SELECT看到statuspending事务B在这时提交了UPDATE把status改成paid事务A再SELECT一次因为生成了新的ReadViewB已经不在活跃列表里了于是这次读到的就是paid。这就是“不可重复读”现象的本质来源。在RR隔离级别下只有事务第一次执行快照读时生成ReadView之后整个事务内共用这一个ReadView。还是上面这个场景事务A第一次SELECT后ReadView已经创建B提交与否不影响它第二次SELECT继续复用同一个ReadViewB的修改依旧不可见。所以RR能保证事务内的快照读结果一致。一个特别容易踩坑的理解是“RR靠只生成一次ReadView解决幻读”。这个说法严格来说不准确——它解决的只是“快照读的幻读”也就是同一事务内两次普通SELECT返回行数一致。但如果是当前读SELECT...FOR UPDATE或者UPDATE语句去定位新插入的行RR仍然可能出现幻读这时候要依赖next-key lock去兜底。关于快照读和当前读的区别下一节详细展开。这里还有一个实践层面的提醒不要以为把隔离级别改成RCReadView生成频繁就会拖垮性能。RC少了很多不必要的锁竞争和gap lock在很多高并发业务里反而是首选RR的优势在于应用层不需要处理不可重复读代码简单。两者没有绝对优劣只有适不适合业务。4. 快照读与当前读MVCC的一体两面MVCC章节里最绕的就是快照读和当前读的区别。不少人学完ReadView以后天真地以为“MVCC让所有读都不加锁”一查线上死锁日志才发现根本不是这么回事。问题就出在没分清两种读模式。4.1 当前读为什么需要锁普通SELECT是快照读走ReadView返回版本链上的历史版本不加锁。但下面这些操作属于当前读它们读取的是记录的最新版本并且必须加锁SELECT ... LOCK IN SHARE MODE加S锁共享锁SELECT ... FOR UPDATE加X锁排他锁UPDATE ...加X锁DELETE ...加X锁INSERT ...有隐式锁触发唯一键冲突等情况时会显式加锁为什么这些操作不能走ReadView因为它们是“要修改数据的读”。如果UPDATE基于一个历史版本去做修改最后覆盖了别人刚提交的新版本就产生了丢失更新。所以UPDATE必须先锁定最新版本确保在事务提交前其他事务无法修改同一行。这也是数据库保证写操作串行化的基本手段。MVCC加锁和传统加锁最大的区别在于它让“读老版本”的任务交给快照读完成而当前读只需锁住最新版本。这样读写互不阻塞但写与写之间依旧严格互斥。理解这一点遇到“为什么两个UPDATE互相死锁”这类问题时就很容易定位他们走的根本不是MVCC的旁路而是锁的直路。4.2 一条UPDATE语句的完整MVCC之旅为了把当前读和版本链串起来我跟踪一条UPDATE语句在RR隔离级别下的执行过程。假设版本链当前状态为1: record(id1, statuspaid, trx_id100, roll_ptr) - undo(statuspending, trx_id99)事务T200执行UPDATE t_order SET status canceled WHERE id 1;第一步当前读InnoDB定位到id1的聚簇索引记录加X锁。注意此时虽然版本链里有statuspending的旧版本但UPDATE要求读取最新版本所以它得到的是statuspaid。第二步写新版本T200在最新版本基础上生成新记录statuscanceledDB_TRX_ID更新为200DB_ROLL_PTR指向statuspaid那个旧版本。版本链变成了1: record(id1, statuscanceled, trx_id200, roll_ptr) - undo(statuspaid, trx_id100) - undo(statuspending, trx_id99)第三步事务继续执行其他语句。此时如果另一个快照读进来会根据它的ReadView去决定看到canceled、paid还是pending。第四步事务T200提交。此时版本链并不会立即缩短purge线程才是真正的“环卫工”。从这个过程可以看到MVCC并没有减少写操作的I/O次数反而因为要额外维护undo log而增加了写放大。所以很多写密集场景下undo log膨胀会导致磁盘I/O成为瓶颈。这也是为什么“短事务快速提交”是InnoDB调优里永恒的主旋律。长事务拖着不提交undo log没法purge版本链越拉越长读性能会跟着劣化。5. 容易被忽略的细节二级索引、Purge与长事务聚簇索引上的MVCC机制讲明白了但实际业务里绝大多数查询都会走二级索引。MVCC在二级索引上的表现和聚簇索引有些微妙差异这些细节恰恰是线上问题的高发区。5.1 二级索引回表与PAGE_MAX_TRX_ID二级索引记录本身并没有DB_TRX_ID和DB_ROLL_PTR它的叶子节点只存储索引列和主键值。那MVCC怎么判断二级索引上的记录是否可见InnoDB的做法很务实先通过二级索引找到主键值然后回表到聚簇索引读取完整记录上的DB_TRX_ID再走ReadView判断可见性。判断为不可见时不能直接在二级索引继续往后扫而要先回表顺着聚簇索引的版本链找到对当前事务可见的版本再看这个可见版本是否仍然满足查询的二级索引条件。举个例子二级索引idx_status上有记录statuspaid指向id1回表后发现statuspaid的版本对当前事务不可见在版本链里找到的可见版本是statuspending但pending不满足查询条件WHERE statuspaid那这一行就要跳过。这种“二次过滤”逻辑看着繁琐却是保证二级索引读结果正确性的关键。为了减少不必要的回表InnoDB在二级索引页的头部维护了一个PAGE_MAX_TRX_ID字段记录当前页中最大事务ID。如果某个ReadView的min_trx_id大于等于这个值说明这个二级索引页里所有记录的最后修改事务都已经提交可以直接使用索引数据而不必回表验证。这是一个非常漂亮的优化但在长事务场景下会失效min_trx_id长期不变且很大二级索引页的PAGE_MAX_TRX_ID又不断被更新最终每次读都要回表性能断崖式下跌。排查这类问题时如果发现同样一条SQL在测试库飞快、在线上某个连接里慢得离谱优先查这个连接的会话是不是开启了一个长时间不提交的事务。5.2 Purge线程与undo log膨胀purge线程的作用是清理两类数据一是已提交事务的delete mark记录二是已提交事务的update undo log中不再被任何ReadView需要的旧版本。为什么需要purge因为版本链不可能无限增长。当事务提交后如果已经没有任何活跃的ReadView会引用某个历史版本这个版本就成了纯垃圾留在undo log里只增加磁盘占用和读路径上的比较成本。purge线程就是负责识别并物理删除这些过期版本。purge有一个硬约束不能有任何一个活跃ReadView还在引用该版本。如果一个事务在上午10点创建了ReadView期间一直不提交那么它引用的历史版本就得一直保留哪怕产生这些版本的事务早就提交了。这直接导致两个后果长事务阻塞purgeundo log持续膨胀最终可能出现undo表空间爆满的故障。版本链过长每次可见性判断都要遍历多个旧版本查询性能劣化。之前线上出过一个事故某个报表任务在事务里先执行了一批UPDATE然后开着事务跑了一个多小时的分析查询。结果这期间核心业务表的undo log从几百MB涨到了几十GB磁盘告警大量查询变慢。这个问题的元凶不是SQL写得烂而是事务设计没有考虑MVCC的purge约束。处理办法也简单把分析查询拆出来不要和UPDATE放在同一个长事务里如果确实需要一致性快照可以用START TRANSACTION READ ONLY再加上隔离级别控制尽可能缩短事务存活时间。5.3 长事务是MVCC的头号杀手这句话我原样送给所有看到这里的人。MVCC的设计天生对长事务不友好原因总结起来有三点ReadView一旦创建就不能释放它引用的所有历史版本都不能被purge。活跃事务ID列表m_ids越大每次可见性判断时对列表的查找开销越高。长事务占用的快照会让所有旧版本的清理工作停摆产生连锁影响。日常排查慢SQL时我会习惯性先查一下information_schema里的长事务SELECT trx_id, trx_started, trx_state, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec FROM information_schema.innodb_trx ORDER BY duration_sec DESC LIMIT 10;如果发现有事务跑了超过30秒还没结束先确认它是不是需要这么长的快照不需要就赶紧提交或回滚。很多MVCC相关的性能问题排查到最后都是“有个事务忘了提交”简单到让人不好意思但影响大到让DBA抓狂。6. 实战排查与面试高频追问这一节把前面所有内容落回到两类场景线上问题的定位思路以及面试里最容易被追问的几个坑。6.1 线上实例事务内两次SELECT结果不一致前一阵有同事反馈一个统计程序在RC隔离级别下偶发数据对不上。代码逻辑大致是START TRANSACTION; SELECT COUNT(*) FROM t_order WHERE status paid; -- 其他业务处理 SELECT COUNT(*) FROM t_order WHERE status paid; COMMIT;RC下两次SELECT各生成一次ReadView中间如果有其他事务提交了新的paid订单第二次COUNT就会多出记录统计自然对不上。这个问题的本质不是代码bug而是隔离级别的语义和需求不匹配。如果业务要求两次统计结果一致正确做法有两种一是把隔离级别调整为RR让事务内快照读复用同一个ReadView二是在应用层把两次查询合并成一条SQL避免跨时间点统计。我个人的建议是统计类场景优先用RR或一次性聚合查询交易类场景在明确需求下用RC。不要试图用MVCC机制去“猜”结果一致性隔离级别本身已经定义了行为边界。类似的问题也经常出现在“先查后改”的业务逻辑里先SELECT拿到一个值再去UPDATE但SELECT是快照读等到UPDATE变成当前读时最新版本可能已经被别的事务改写。此时如果业务要求这个值不能被并发篡改就必须把SELECT改成SELECT ... FOR UPDATE用当前读加锁来保证后续修改的原子性。MVCC不是万能的免锁方案一致性读和加锁读要按业务语义分开选。6.2 如何观察版本链排查MVCC问题时不能只靠脑补。InnoDB提供了一些底层手段让我们观察版本链的真实状态最直接的是在事务执行过程中查看undo log相关信息不过一般我们不需要深入到那个层面。实际用得最多的是通过performance_schema和information_schema的数据拼出事务全貌。下面这条SQL可以同时观察到当前所有事务及其执行状态SELECT t.trx_id, t.trx_state, t.trx_started, t.trx_mysql_thread_id, p.PROCESSLIST_DB AS db, p.PROCESSLIST_INFO AS sql_text, p.PROCESSLIST_TIME AS elapsed_sec FROM information_schema.innodb_trx t LEFT JOIN performance_schema.threads th ON th.PROCESSLIST_ID t.trx_mysql_thread_id LEFT JOIN performance_schema.processlist p ON p.ID t.trx_mysql_thread_id\G如果怀疑某个事务持有的ReadView阻塞了purge可以对比trx_started时间和undo表空间的增长趋势如果在事务运行期间undo表空间大小只涨不降基本可以锁定长事务阻塞purge。至于版本链本身更偏Oracle风格的深入分析需要借助工具解析ibdata文件对大多数MySQL使用场景来说成本偏高不太划算。日常只要把SHOW ENGINE INNODB STATUS里的TRANSACTIONS段落看明白已经能解决九成问题。截取一段典型输出---TRANSACTION 1655, ACTIVE 123 sec MySQL thread id 51, OS thread handle 140381223635712 ... History list length 233History list length是理解MVCC健康度的关键指标之一它表示purge线程尚未清理的undo版本链表长度。如果这个值持续走高且伴随性能劣化说明有事务在阻碍purge顺着innodb_trx表找活跃事务就对了。6.3 MVCC与幻读的关系再辨析面试里有一道经典题RR隔离级别下MVCC能否完全解决幻读正确答案是“不能完全解决能解决快照读下的幻读但不能解决当前读下的幻读”。来看一个能复现当前读幻读的例子。事务A执行START TRANSACTION; SELECT * FROM t_order WHERE status pending;此时只返回1行。事务B插入一条新的pending订单并提交。事务A再次执行SELECT * FROM t_order WHERE status pending;由于RR复用了第一次快照读的ReadViewB的新数据不可见两次结果一致快照读的幻读被挡掉了。但如果事务A改成当前读SELECT * FROM t_order WHERE status pending FOR UPDATE;这个查询会锁定范围内所有满足条件的记录。在RR下InnoDB会额外加上间隙锁gap lock或临键锁next-key lock来阻止其他事务在范围内插入新数据。事务B的INSERT会被阻塞直到A提交或回滚。从结果上看当前读的幻读也被锁机制防范了。真正残留的幻读场景是A先做了一次快照读然后B插入新数据并提交A在同一个事务里把查询改成当前读时可能看到B插入的新行产生“前后结果不一致”的感知。这种混合读写模式在复杂业务里是真实存在的理解它比背结论重要得多。还有一种更隐蔽的情况如果B插入数据后执行了commitA再做FOR UPDATE当前读锁机制会挡住并插入的并发事务但A在第一次快照读时根本没锁B要插入的间隙B已经在ReadView之后提交了除非A走到当前读并命中B插入的间隙否则仍可能读到B的数据。实操中我通常建议团队凡是涉及“先查后写”的核心链路全部统一用当前读不要混用快照读和当前读避免在隔离级别语义的边缘试探。MVCC是一套“看着简单、用起来处处是坑”的机制。它把读写互斥问题化解于无形却把版本生命周期、事务时长、锁粒度的选择都留给了使用者。摸清这套机制不只会写SQL更要能解释一条语句在并发环境下为什么是这个结果。这也是我把这篇文章的重心放在版本链、ReadView和purge联动上的原因——只背概念定义遇到线上故障依然是一头雾水把机制链路串起来才能在一次奇怪的慢查询或数据不一致背后快速找到那个真正的逻辑节点。
返回列表