MVCC,你真的用对了吗?底层拆解与三大陷阱避坑指南

MVCC,多版本并发控制——听起来高大上,实际上很多人用了半辈子MySQL,也没真正搞懂它到底是怎么运作的。我就见过不少“老司机”,遇到性能问题一头雾水,最后归咎于“优化器抽风”,甚至直接拉上DBA背锅,说什么“数据库不行了”。得,今天我决定把这事掰开揉碎讲清楚。不整虚的。

一、版本链:魔法的起点

InnoDB每行数据都有几个隐藏列:DB_TRX_ID(最后修改这行的事务ID),DB_ROLL_PTR(回滚指针,指向undo log里的旧版本),还有DB_ROW_ID(如果没主键就用它)。你每更新一行,其实不是直接在原数据上改,而是写一条新的,同时把旧的版本通过DB_ROLL_PTR串起来。像不像一本日记?每次修改不撕掉重写,而是在后面续写,并标注“修改人:事务3872”和“上一版在第38页”。这样,一个版本链就形成了。
InnoDB行记录隐藏列与版本链结构
InnoDB行记录隐藏列与版本链结构
然后轮到ReadView登场。说白了,这就是事务开始时的一个快照——记录了当前活跃事务ID的最小值、最大值,以及一个活跃事务列表。当你要读取一行数据时,顺着版本链找到第一个trx_id满足条件的版本:如果trx_id小于最小活跃ID,那么它是在快照之前提交的,可见;如果大于等于最大活跃ID,说明是快照之后才开始的,不可见;如果落在中间,就要检查是否在活跃列表中——在则不可见,不在则可见。这个判断逻辑简单吧?但这里就埋了一个大坑:很多人以为在“可重复读”隔离级别下,一个事务内的所有读都是快照读,结果混入了当前读——比如用了SELECT ... FOR UPDATE,这就直接读最新版本并加锁,完全绕过MVCC!有一次我们就栽在这上面:一个报表事务先做了快照读,中间某个步骤又搞了个当前读去更新状态,读到的数据版本不一致,导致计算出的总额死活对不上。查了半天,拍断大腿——怎么就忘了当前读这茬呢!

二、别光谈理论,上压测数据

MVCC不是银弹,但在读多写少的场景,相比传统基于锁的方案(比如S2PL),吞吐量提升一个数量级是很常见的。我曾经做过一组压测:一张订单表500万行,50个并发,混合读写(读写比8:2)。测试两种隔离级别——RC(读已提交)和SERIALIZABLE。在RC下,纯读QPS能跑到2.1万,写操作也因为读不阻塞写,维持在800 TPS左右;而SERIALIZABLE下,读QPS直接掉到1800,写更是惨到120 TPS。差距就这么大。为什么会这样?因为SERIALIZABLE为了严格串行化,使用了大量的共享锁甚至范围锁,读读之间虽然不互斥,但读写冲突严重,大量事务在等待锁释放。而MVCC下的RC,读几乎不受写的影响,只在真正需要写的时候才去竞争行锁。
MySQL与PostgreSQL MVCC实现对比架构图
MySQL与PostgreSQL MVCC实现对比架构图
不过话说回来,PostgreSQL的实现又不太一样。它没有undo log,旧版本直接堆在数据文件中,通过元组(tuple)的可见性标记和事务快照来判断。好处是没有回滚段的争用,坏处嘛——更新频繁时表膨胀得飞快,必须靠VACUUM来回收空间。有一次一个朋友的公司,用PG存日志,没调好autovacuum,几个小时后磁盘就爆了。所以说,没有银弹,只有取舍。我们在一次支付系统的优化中,起初用的是MySQL的RC+MVCC,偶发幻读(因为update这种当前读会看到新插入的行),导致对账不平。后来升到RR,幻读没了,但间隙锁导致死锁率飙升到1.2%!最后没办法,把热点账户拆出来,用CAS乐观锁(version字段)替代部分更新,死锁率才降到0.05%,QPS反而提升了30%。看见没?光指望MVCC的死不了人,但活得未必好。

三、三个要命的落地陷阱

三、三个要命的落地陷阱
三、三个要命的落地陷阱
陷阱一:长事务让undo log疯狂膨胀
有一次线上跑批,一个查询事务开了30秒没结束,开发人员也没当回事。结果undo log疯涨,磁盘使用率从40%直线拉到95%,告警响成一片。原因很简单:这个长事务的快照需要历史版本,即使其他事务已经提交,purge线程也不敢清理那些undo页,因为清理了长事务就读不到正确版本了。怎么办?监控并杀掉长事务是第一要务,可以设置max_execution_time,或者在应用层把大查询丢到备库、离线平台去跑。调整innodb_purge_threadsinnodb_purge_batch_size只能缓解,根子还在业务逻辑上。 陷阱二:二级索引的“幽灵”读数
二级索引不存trx_id,判断可见性必须回表看聚簇索引的版本。如果某个事务删了一行,二级索引页上只是标记删除,物理上可能还没清。这时,另一个事务扫描二级索引,可能看到一条已标记删除的记录,回表后发现最新版本不可见,于是退回再找……这不仅性能差,更可怕的是在某些边界条件下,COUNT(*) 结果会比实际行数多。我们一个统计业务就因为这个,周报表对数对到半夜。解决方案?调整purge线程的活跃度,让历史版本尽快清理;定期执行OPTIMIZE TABLE重建索引;或者直接改用RC隔离级别,减少间隙锁带来的索引膨胀(代价是可能出现幻读,自行权衡)。 陷阱三:快照读与当前读混用引起的数据不一致
经典的check-then-act竞态:先快照读到库存>0,再用这个值去update扣减——但update拿到的行锁是基于最新版本的,中间如果被其他事务改了,你基于旧快照的计算就全错了。解决?要么用SELECT ... FOR UPDATE提前锁住行,要么直接UPDATE ... WHERE stock>0,让数据库替你保证原子性。对热点行,还可以引入CAS思想:给表加个version字段,更新时带着版本号比较,更新失败就重试。别问我是怎么知道的,当年超卖赔的那些钱,够请全公司吃一个星期火锅了。 说这么多,其实MVCC就那点东西——版本链、ReadView、purge——但细节决定成败。下次再踩坑,可别怪我没提醒。
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:MVCC,你真的用对了吗?底层拆解与三大陷阱避坑指南
文章链接:https://www.lfdjt.com/info_23_7707.html