一、版本链:魔法的起点
InnoDB每行数据都有几个隐藏列:DB_TRX_ID(最后修改这行的事务ID),DB_ROLL_PTR(回滚指针,指向undo log里的旧版本),还有DB_ROW_ID(如果没主键就用它)。你每更新一行,其实不是直接在原数据上改,而是写一条新的,同时把旧的版本通过DB_ROLL_PTR串起来。像不像一本日记?每次修改不撕掉重写,而是在后面续写,并标注“修改人:事务3872”和“上一版在第38页”。这样,一个版本链就形成了。
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,读几乎不受写的影响,只在真正需要写的时候才去竞争行锁。
三、三个要命的落地陷阱

有一次线上跑批,一个查询事务开了30秒没结束,开发人员也没当回事。结果undo log疯涨,磁盘使用率从40%直线拉到95%,告警响成一片。原因很简单:这个长事务的快照需要历史版本,即使其他事务已经提交,purge线程也不敢清理那些undo页,因为清理了长事务就读不到正确版本了。怎么办?监控并杀掉长事务是第一要务,可以设置
max_execution_time,或者在应用层把大查询丢到备库、离线平台去跑。调整innodb_purge_threads和innodb_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——但细节决定成败。下次再踩坑,可别怪我没提醒。