ARTICLE DETAIL

资讯详情

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

MySQL MVCC底层原理详解:版本链、ReadView与隔离级别实践

MySQL MVCC底层原理详解:版本链、ReadView与隔离级别实践 1. 先搞明白MVCC到底在解决什么问题1.1 并发场景下“读”和“写”的天然矛盾做后端开发的迟早要撞上MySQL里这个高频考点MVCC全称Multi-Version Concurrency Control多版本并发控制。它在InnoDB存储引擎里的地位基本等同于地基——事务的隔离性、一致性读、可重复读全都绕不开它。但这玩意儿在网上被讲得特别玄乎版本链、ReadView、隐藏字段、undo log一堆概念砸下来很容易劝退。我这篇尽量用大白话把它拆开揉碎配合能直接跑的实操让你看完不光能应付面试线上遇到诡异读数据问题也知道往哪儿猜。先说MVCC到底解决什么问题。想象一张订单表同一时刻好几个事务同时在读、在写。如果读和写之间完全互斥那高并发场景下数据库的吞吐量直接崩盘所有读操作都可能被写锁卡住。如果读的时候什么都不管又会读到“写了一半”的脏数据。最早的数据库解决方式是加锁读锁和写锁互斥但代价就是读写串行化性能惨不忍睹。MVCC的思路就聪明很多让每个事务读数据的时候不是直接读当前最新值而是从一条“版本链”里挑一个自己当时能看到的历史版本。写操作照常产生新版本读操作照常读旧版本两边各走各的互相不阻塞。1.2 MVCC不是万能的写写冲突依然靠锁这里必须先泼一盆冷水MVCC只优化“读写并发”不负责“写写并发”。两个事务同时改同一行数据依然要排队靠行锁来保证一致性。所以更准确地说InnoDB做并发控制是“MVCC 锁”的组合方案。读读并发随便跑读写并发走MVCC历史版本写写并发用排他锁串行。这个框架感建立起来之后后面所有细节都是往里填肉。另外一个容易忽略的点是隔离级别。MySQL默认隔离级别是Repeatable ReadRROracle默认是Read CommittedRC。MVCC在这两个级别下的行为有本质区别很多踩坑现场就是“我明明用的RR为什么还能读到新数据”结果查下来发现当前读和快照读被混为一谈。所以把MVCC理解成“一套帮助数据库实现隔离级别的底层工具”会更准确它服务于RC和RR两个级别跟读未提交、串行化关系不大——读未提交直接读最新数据串行化靠互斥锁强串行都不需要这套版本挑选机制。2. 版本链是怎么形成的隐藏字段 undo log2.1 聚簇索引里的“三剑客”隐藏字段要理解MVCC先得知道InnoDB在聚簇索引的每行记录里藏了三个隐藏字段。这三兄弟是版本链的物理基础平时用户感知不到但MVCC全靠它们工作。第一个是DB_TRX_ID占用6字节记录的是最近一次修改INSERT或UPDATE这条记录的事务ID。每次有事务写入新值这个字段都会被更新成那个事务的ID。第二个是DB_ROLL_PTR7字节回滚指针它指向undo log里这条记录修改前的旧版本。正因为有这样的指针所有历史版本才能串成一条链表。第三个是DB_ROW_ID6字节隐藏主键只在表没有显式主键且没有唯一索引时会用到用来生成聚簇索引有主键时这个字段可以忽略。很多资料把它们叫“三剑客”实际工作里很少直接查询这几个字段但理解它们的存在至关重要。我看过不少新人搞混以为MVCC是数据库为每个读事务复制了一份数据副本。真不是。数据物理上只存最新一份旧版本都沉淀在undo log里靠DB_ROLL_PTR往回串。你每次查询本质上是拿着自己的“身份凭证”从版本链里挑一个版本看。2.2 undo log旧版本的仓库undo log分两种insert undo log和update undo log。INSERT产生的undo log在事务提交之后就可以直接清理因为插入操作的回滚只需要删除记录而且这条记录在此之前不存在旧版本不可能被其他事务的快照读到。UPDATE和DELETE产生的update undo log就要小心了它们记录的旧版本可能还有读事务在用必须等到所有引用它的ReadView都失效才能被后台的purge线程回收。这里有一个很关键的细节执行UPDATE时InnoDB并不会直接把原来的数据覆盖掉而是先在undo log里记录一份完整旧版本的镜像然后再更新当前记录同时把DB_ROLL_PTR指向刚写下的旧版本。DELETE也是类似只在当前版本打上一个删除标记这个带删除标记的版本同样会被记录到版本链里。随着同一条记录被反复修改版本链就越拉越长。我在生产环境踩过一个非常典型的坑有个定时任务开了事务后对一批热点订单数据做状态流转但因为某一步调了外部接口迟迟不返回事务就这么悬着没提交。期间线上用户又在不断修改这些订单版本链越堆越长undo表空间涨了好几个G查询性能肉眼可见地下降。后来排查information_schema.INNODB_TRX才把悬着的事务揪出来。所以长事务对MVCC的杀伤力是实打实的监控里必须盯住。2.3 怎么亲手验证版本链的存在教科书里总是画一堆链表图但实际操作中想亲手“看到”版本链最直接的办法是开两个会话模拟事务交错执行。先用一张简单表演示一下CREATE TABLE t_mvcc ( id INT PRIMARY KEY AUTO_INCREMENT, value INT ) ENGINEInnoDB; INSERT INTO t_mvcc(value) VALUES (1);会话A开启事务执行UPDATE但不提交。会话B查询information_schema.INNODB_TRX能看到两个事务的trx_id、trx_started、trx_rows_modified。这里trx_id就是DB_TRX_ID的来源。你还会注意到同一条记录在被A修改后如果B执行普通SELECT看到的是旧值1如果B执行SELECT ... FOR UPDATE就会被A的锁挡住。这个对比就是MVCC和锁机制同时在起作用的直观证明。3. ReadView判断版本可见性的核心算法3.1 ReadView里的四个关键成员版本链有了但事务到底该挑哪个版本看这就要引出ReadView读视图。你可以把它理解成事务执行快照读的那一刻给数据库拍的“一张照片”记录当时哪些事务还在活跃。ReadView内部核心是四个成员变量我一个个说清楚。m_ids生成ReadView时当前系统中所有活跃读写事务的ID列表。min_trx_id这个列表里的最小值。max_trx_id注意了它不是活跃事务的最大值而是“生成ReadView时系统尚未分配的下一个事务ID”也就是说比所有已存在事务ID都大。creator_trx_id创建这个ReadView的事务自己的ID。这四者缺一不可也是最容易在面试里被追问的点。网上很多教程对max_trx_id的理解是错的把它当成“当前最大活跃事务ID”。如果这么理解可见性判断结果就会走偏。正确的记忆方式是max_trx_id是“未来事务的起跑线”凡是事务ID大于等于这个值的版本都是在ReadView生成之后才出现的事务修改的这对当前事务来说都是“未来数据”一律不可见。3.2 可见性判断的五条规则压缩成一句话沿着版本链从最新往旧遍历时对每个版本记录下它的DB_TRX_ID然后按下面这个顺序判断DB_TRX_ID等于creator_trx_id自己事务改的版本肯定可见。DB_TRX_ID小于min_trx_id这个事务在ReadView生成前就提交了可见。DB_TRX_ID大于等于max_trx_id这是ReadView生成后才开始的事务不可见。DB_TRX_ID在min_trx_id和max_trx_id之间但不在m_ids列表中说明生成ReadView时这个事务已经提交可见。DB_TRX_ID在m_ids列表中生成ReadView时刻此事务还活跃不可见。如果当前版本不可见就顺着DB_ROLL_PTR往前找下一个更旧版本重复上述判断。找到可见版本就返回走到链表头都没有说明这条记录对当前事务来说是不存在的。这段逻辑我常用一个比喻帮新人记m_ids相当于一份“黑名单”记录的是ReadView生成那一刻还没收工的事务。黑名单里事务的改动你一律看不见名单外且ID不是太大的改动你都能看到。判断到某个版本时如果可见直接返回不可见继续往前翻直到找到第一个能看到的版本。3.3 一个例子彻底看懂挑选过程假设有一行数据value从1改成2改成3三次修改分别由事务ID为20、30、40的事务完成而且都已经提交。现在事务50执行快照读生成ReadView时活跃事务列表m_ids是[45, 48]那么min_trx_id是45max_trx_id是51因为下一个要分配的事务ID是51。从最新版本开始看value3是事务40改的40小于min_trx_id45所以版本40可见。于是事务50就读到3直接返回后面的旧版本根本不会继续遍历。这个例子说明一个要点ReadView生成前已提交的版本会被优先命中遍历往往不需要走到链表深处。再看一个更烧脑的例子。还是value3那个最新版本但假设它是事务48改的。可事务48在m_ids黑名单里不可见于是沿回滚指针找到value2这个版本是事务30改的。30小于min_trx_id45可见。于是事务50最终读到2。哪怕事务48在事务50查询前已经提交了也没用因为在事务50生成ReadView的那一刻48还活跃按当时拍的“照片”来看48的修改对50就是不可见的。这就是RR隔离级别可重复读的最底层原理。4. RC与RR隔离级别下MVCC的差异4.1 RC级别每次快照读都新建ReadViewRC隔离级别下事务内每次执行快照读都会重新生成一个ReadView。这意味着什么同一个事务里第一次SELECT和第二次SELECT之间如果有别的事务提交了修改第二次SELECT就能看到新数据。这就是“不可重复读”的由来。这个特性对并发是友好的因为RC下间隙锁用得少锁冲突概率低。很多互联网业务会把隔离级别从RR降成RC换取更高的吞吐。代价是事务内多次读取结果可能不一致需要业务自己去接受或兜底。如果你的场景是订单金额统计、账户余额核对这种对一致性敏感的逻辑用RC前得想清楚。修改隔离级别有两个常用方式全局级用SET GLOBAL transaction_isolation READ-COMMITTED;会话级用SET SESSION transaction_isolation READ-COMMITTED;。这里有个特别常见的坑MySQL 8.0里的参数名是transaction_isolation老版本的tx_isolation虽然还能在某些客户端里看到但官方已经废弃。我帮人排查问题时遇到过明明改了对的参数却因为驱动连接池保留了旧会话导致隔离级别没生效这在生产环境要特别留意连接池刷新。4.2 RR级别ReadView复用一次拍板用到事务结束RR隔离级别下同一个事务内第一次执行快照读时生成ReadView之后整个事务内所有快照读都复用这一个ReadView不再更新。所以事务内多次SELECT结果永远一致从机制上杜绝了不可重复读。但RR也不是完美的它的代价一是间隙锁更复杂写并发容易发生锁等待二是很多人误以为RR下完全没有幻读。官方文档的说法是“一定程度上避免幻读”。严谨的解释是如果走快照读因为ReadView固定了确实不会出现幻读但如果走当前读比如SELECT ... FOR UPDATE或UPDATE里的读取InnoDB必须读到最新数据并加锁这时依然可能读到“新插入的行”所以需要next-key lock来锁范围。快照读和当前读两条路径幻读防护的逻辑是完全不同的。我面试别人时特别喜欢问一个问题RR下执行SELECT ... WHERE id 100 FOR UPDATE第一次查出3行提交前别的事务插入一行id101并提交再执行同样的FOR UPDATE会不会查出4行很多人答错。正确答案是如果事务一开始没有对这部分数据加过锁第二次当前读有可能多出一行因为当前读的加锁确实能锁住范围但“新插入”这个动作有可能在第一次加锁之后发生必须配合next-key lock才能把范围彻底锁死。这块知识点在现场调优里很容易踩坑。4.3 亲手做一遍RC和RR的可见性实验这个实验我强烈建议读者自己跑一遍比看十篇博客都管用。用前面建好的t_mvcc表按下面步骤操作第一步会话A执行START TRANSACTION; UPDATE t_mvcc SET value 2 WHERE id 1;不提交。第二步会话B先确认隔离级别分别设为RC和RR两种情况来测。第三步B在RC下执行SELECT看到value1A执行COMMITB再次SELECT看到value2。第四步重新开事务B在RR下执行SELECT看到value1A提交后B再次SELECT看到的还是value1等B提交事务后再开新事务SELECT才看到value2。整个实验最直观的地方在于同样一条SQL、同样两个事务的提交顺序只因为隔离级别不同结果南辕北辙。背后的原因就是ReadView的生成次数不同。记住这个实验现象比死记“RC每次读都生成ReadViewRR第一次读生成”要牢靠得多。5. MVCC与当前读、加锁的关系5.1 快照读和当前读两种读的背后完全不同先给结论快照读是不加锁的普通SELECT走MVCC版本链当前读是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT这类必须读取最新版本并加锁的操作。搞清楚一条SQL属于哪种读是排查所有并发问题的基础。当前读读的是“最新已提交版本”这句话很多人忽略。事务A对一行UPDATE未提交事务B执行同样的UPDATE会被阻塞但如果事务B执行普通SELECT完全不会被A阻塞直接通过MVCC读旧版本。同一个会话里SELECT看到的是旧值UPDATE却永远等锁这种“自己看到的数据和别人看到的不一样”的现象正是MVCC和锁两套机制并存的体现。5.2 当前读下的加锁策略与幻读防护关于锁InnoDB在RR下的加锁策略比RC复杂得多。等值查询走唯一索引时当前读只加记录锁锁住命中的这一行等值查询走非唯一索引或者范围查询时会加间隙锁或next-key lock把索引区间内的“空隙”也锁住目的是防止其他事务在区间里插入新行。这就是当前读层面防幻读的手段。举个例子一个订单表status字段上有非唯一索引事务A执行SELECT * FROM orders WHERE status 1 FOR UPDATEMySQL会在status索引上给所有值为1的记录加记录锁同时给值为1的索引项两侧的间隙加间隙锁这样别的事务想往间隙里插入status1的新订单就会被锁挡住。但这也带来副作用一个简单的范围UPDATE可能锁住一大片区间并发插入被误伤。常见优化是让当前读走唯一索引等值尽量把锁范围缩到一行。5.3 MVCC解决不了“丢失更新”最后说一个业务上特别容易踩的认知误区以为把隔离级别设为RR事务就不会有任何并发问题。MVCC保证的是读一致性不保证业务逻辑的原子性。经典的丢失更新场景账户余额是5两个事务同时读到5各自判断“余额充足”后都扣减5最终余额变成0而不是-5但业务想表达的扣减后余额应该更小才对。两个事务互相不知道对方的修改因为MVCC让它们各自读到了快照。要解决必须对行加锁或用乐观锁版本号靠隔离级别本身是兜不住的。这类问题网上有个很典型的案例叫“库存扣减”我聊过的团队十个有八个在这上面栽过跟头。处理思路其实很简单更新语句里带上条件比如UPDATE stock SET count count - 1 WHERE id 1 AND count 0依靠当前读天然拿到最新值并加锁避免先查再改。但写代码的人如果沿用“查出来、减一下、再写回去”的三步走虽然每步都正确组合起来就是有并发漏洞。6. 常见问题与排查技巧6.1 高频误区速查表把平时遇到的最常见的误区整理成一张表方便快速自查误区说法实际情况MVCC能解决所有并发问题只解决读写并发写写冲突仍靠行锁RR隔离级别下彻底没有幻读快照读没有当前读仍需next-key lock防护ReadView在事务内一直固定只对RR成立RC每次快照读都会重建undo log在事务提交后立刻清理insert undo可清理update undo要等purge版本链长一定导致查询慢并非必然但历史版本过多确实增加遍历开销事务隔离级别高就一定安全丢失更新、业务一致性仍需要锁或条件更新兜底这几点可以说覆盖了日常开发讨论群里80%的MVCC错误认知每一条背后都有真实的故事。6.2 线上排查的常用命令和思路遇到“查询结果怪怪的”我的排查顺序一般是这样先确认连接会话的真实隔离级别用SELECT transaction_isolation;看别轻信配置文件因为连接池可能复用了老会话。再查当前有哪些长事务用下面这条SQL看事务是否长时间未提交SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX ORDER BY trx_started;如果怀疑undo膨胀用SHOW ENGINE INNODB STATUS\G里的TRANSACTIONS段落看history list length这个指标。它代表还没被purge线程清理的旧版本数量正常情况下应该是几十、几百的量级如果上万甚至几十万果断去找长事务。另外还有一个特别值得说的经验很多人排查“RR下为什么读到了新数据”最后发现是自己写了SELECT ... FOR UPDATE或者是INSERT INTO ... SELECT这种隐含当前读的语句。所以排查前先列一下出问题的SQL到底是快照读还是当前读能省一半时间。6.3 监控、调优和一些个人经验MVCC层面的监控重点归纳起来三件事最长事务时长、history list length、undo表空间增长。告警建议设三档事务超过60秒未提交需要关注超过5分钟直接报警history list length超过1万或半小时内翻倍报警undo表空间占用异常增长报警。调优方面MySQL 8.0的undo表空间支持自动截断重点参数是innodb_max_undo_log_size。5.7及更早版本建议初始化时多建几个undo表空间避免后期单个文件无限增长。对于版本链过长导致慢查询的场景最常见的优化是拆事务把大批量UPDATE分批提交避免一条记录被反复修改同时控制长事务的数量。最后分享一条我从无数次踩坑里总结出的心得设计任何并发业务之前先给要执行的每条SQL标个身份——它是快照读还是当前读一旦把这两种读的行为分清楚MVCC的理解、隔离级别的选择、锁冲突的排查全都顺理成章。反过来如果脑子里对“这条SQL到底走不走锁”没概念后面线上出问题基本就是靠猜。MVCC不是玄学它就是一套有清晰规则的机制规则掌握了数据库并发行为在你眼里就会变得透明。
返回列表