
这个问题的背后其实是数据库并发控制里最精巧的一套机制在起作用——MVCCMulti-Version Concurrency Control多版本并发控制。我刚接触数据库那会儿对“SELECT不阻塞UPDATE”这句话将信将疑直到自己动手做了几组实验、翻了源码和官方文档才真正把这条链路理清楚。这篇文章就把我这些年对MVCC的理解一次性讲透包括核心原理、可见性判断逻辑、实际场景推演还有生产环境里容易踩的坑。1. 为什么会有MVCC先看看没有它会发生什么1.1 一个看似简单的问题先回忆一下你日常写SQL的场景一个业务系统里事务A在统计报表执行一条比较耗时的SELECT与此同时事务B在更新同一张表里的某条订单记录。在没有特殊机制的情况下这两条语句会互不相让吗会的只要在传统的加锁并发模型里这个问题就绕不开。数据库为了保证数据一致性和事务隔离性最简单粗暴的办法就是加锁读的时候加读锁写的时候加写锁读锁和写锁互斥。结果是SELECT要等UPDATE提交之后才能读到数据UPDATE也要等SELECT释放读锁之后才能动手。对于高并发业务来说这种串行化等待几乎致命。那有没有办法让SELECT和UPDATE各干各的有方向大致分成两种一种是进一步细分锁粒度把锁精确到行、甚至到索引项但锁冲突还是存在另一种就是今天的主角——让每一条数据记录同时保留多个历史版本读的人各自读取符合自己事务视角的版本写的人继续更新最新版本。读写之间不仅不需要互相等锁还都能提交成功。这个思路就是MVCC。1.2 MVCC的设计哲学空间换时间MVCC的核心思想概括成一句话就是“以空间换时间”。传统锁机制把并发控制的重心放在“阻塞谁”上MVCC则把重心放在“给谁看哪个版本”上。每条记录被更新时旧版本不会被立刻删除而是以某种方式保留在系统里。新版本记录和旧版本记录之间通过指针形成一条版本链。当某个事务执行普通SELECT时数据库会依据该事务启动时生成的一份额外信息ReadView即一致性视图沿着版本链找到对当前事务可见的那个版本。这个版本可能是最新数据也可能是一两轮更新之前的数据。这就实现了所谓的一致性快照读一个事务在同一个快照视角下多次读取同一条记录结果始终一致。而UPDATE这类写操作则直接操作最新版本并为它生成一个新版本挂到链上。用生活里的场景类比MVCC就像是图书馆里的“预约借阅机制”当一个读者在阅读某本书的最新修订稿时另一个读者完全可以在隔壁看到这本书的旧版本或草稿版。新旧版本并存各取所需互不干扰。2. 深入MVCC核心机制隐藏列、版本链与ReadView2.1 一行数据比你想的多存了三个字段很多人以为数据库表里的一行数据就只有你建表时定义的字段。实际上以InnoDB存储引擎为例每一行还额外维护着几个隐藏的字段MVCC能运转全靠它们。DB_TRX_ID表示最近一次插入或更新这行数据的事务ID。DB_ROLL_PTR回滚指针指向该行的上一个版本在undo log中的位置版本链通过它串联起来。DB_ROW_ID隐式自增ID当表没有主键时InnoDB会自动生成聚簇索引这个字段就作为聚簇索引的键值。对于MVCC来说最关键的是前两个字段。DB_TRX_ID相当于这行数据的“最近修改者标签”ReadView在判断可见性时核心就是拿当前行记录的DB_TRX_ID和ReadView里记录的事务ID范围做比较。DB_ROLL_PTR则负责把分散在undo log里的历史版本串成一条完整的链保证回溯时能沿着链表逐级往上找。2.2 版本链是怎么形成的举个例子假设有一条用户记录id1name张三。初始插入事务ID为10那么DB_TRX_ID10回滚指针指向空。接着事务20执行了UPDATE将name改成李四数据库会执行两个动作首先把当前行旧内容拷贝到undo log生成一个版本记录然后在原行上更新name字段并将DB_TRX_ID改成20DB_ROLL_PTR指向刚才生成的undo记录中。于是版本链就变成了两层新版本DB_TRX_ID20name李四的DB_ROLL_PTR指向旧版本。旧版本DB_TRX_ID10name张三存储在undo log中回滚指针为空。如果再有一次事务30执行UPDATE把name改成王五版本链又会多一层。每次写操作都在链头追加新版本旧版本依次向后移动。查询时从链头沿着DB_ROLL_PTR一路往下找直到找到对当前事务可见的版本为止。这里有个容易被忽略的细节undo log中的旧版本不仅要保留被修改前的字段值还会保留被修改前对应的DB_TRX_ID和DB_ROLL_PTR。因为在ReadView判断可见性时需要逐层读取这些信息缺一不可。2.3 ReadView每个事务的“时间快照”有了版本链还需要一个判断依据——到底哪些版本对当前事务可见。这个判断依据就是ReadView。ReadView的生成时机与隔离级别相关这点在后面会详细展开。先看ReadView里最重要的四个字段creator_trx_id创建这个ReadView的事务ID。m_ids在创建ReadView时当前系统中所有未提交的读写事务ID列表。min_trx_idm_ids中最小的事务ID。max_trx_id创建ReadView时系统即将分配给下一个事务的ID可以理解为当前系统中最大的事务ID上限。判断某个版本是否可见的规则其实可以归纳成几条如果被访问版本的DB_TRX_ID等于creator_trx_id说明这个版本就是当前事务自己修改的肯定可见。如果DB_TRX_ID小于min_trx_id说明该版本早在当前事务启动前就已经提交可见。如果DB_TRX_ID大于等于max_trx_id说明该版本是在当前事务启动之后才被创建不可见。如果DB_TRX_ID落在min_trx_id和max_trx_id之间还要再判断它是否还在m_ids列表中如果在列表中说明事务尚未提交不可见如果不在列表中说明事务已经提交可见。这四条规则就是MVCC可见性判断的核心整个MVCC的“魔法”都浓缩在这几步判断里。我在刚学习的时候为了把这几条规则彻底记牢直接把规则抄在便签纸上贴在显示器旁边做实验时对照着看很快就通了。2.4 RR和RC下ReadView生成时机的差异同一个MVCC机制在不同隔离级别下的表现会有明显差异最典型的就是可重复读RR和读已提交RC两种隔离级别。在可重复读RR级别下事务第一次执行普通SELECT时会生成一个ReadView并一直使用到事务结束。后续所有普通SELECT都复用这同一个ReadView所以整个事务期间看到的数据都是一致的就像事务开始时拍了张“照片”之后一直看这张照片。在读已提交RC级别下事务中每执行一次普通SELECT都会生成一个新的ReadView。这样不同时间点的SELECT能看到不同的已提交数据所以RC级别下同一个事务中两次SELECT可能读到不同的结果也就是不可重复读。两条规则放在一起就能理解为什么RR能解决一部分“换读”问题以及为什么在RC下MVCC的语义会有所放宽。这也是面试中经常被追问的细节把这块弄明白比背十遍八股文都管用。3. 快照读与当前读SELECT为什么不需要等锁3.1 快照读的完整执行流程普通SELECT语句在InnoDB里被称为一致性非锁定读Consistent Nonlocking Read也叫快照读。它不加任何锁直接生成或复用ReadView然后沿着版本链查找对当前事务可见的版本。执行流程大致如下事务开始根据隔离级别确定ReadView的生成策略。定位到目标记录读取行上的DB_TRX_ID。将DB_TRX_ID与ReadView中的min_trx_id、max_trx_id、m_ids进行比较。如果可见直接返回该版本的数据。如果不可见通过DB_ROLL_PTR跳转到链表中的下一个旧版本重复步骤3的判断。直到找到可见版本或者遍历完整个版本链。因为整个过程不涉及锁所以快照读永远不会被其他事务的锁阻塞也不需要等待其他事务提交。这就是“SELECT从来不阻塞UPDATE”的第一层含义SELECT本身不拿锁UPDATE自然不用等它。3.2 当前读UPDATE为什么必须加锁与快照读相对的是当前读Current Read也叫锁定读。UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 都走当前读。当前读读取的是记录的最新版本而不是历史版本并且在读取时需要获取对应的行锁或间隙锁。为什么写操作必须用当前读原因很简单写操作要修改数据如果基于旧版本去更新那后提交的事务就会把先提交的修改覆盖掉导致丢失更新。所以UPDATE必须先拿到最新版本并加锁确保在修改期间没有其他事务能同时写同一条记录。以UPDATE为例执行过程大致是执行器定位到目标行。加X锁排他锁防止其他事务并发修改同一行。读取最新版本的数据。生成undo log复制旧版本信息。修改行内容更新DB_TRX_ID调整DB_ROLL_PTR。如果当前事务尚未提交其他事务的当前读操作会被阻塞但快照读不受影响。这里有个关键认知MVCC并没有完全取代锁。它解决的是读写之间的互斥而写写之间的冲突仍然要靠锁来解决。两个事务同时UPDATE同一条记录时后发起的UPDATE必须等待先获取锁的事务释放锁否则就会一直阻塞。3.3 用一个时间线推演SELECT和UPDATE的完整并发过程为了更直观我们用一张表格推演一遍。事务A执行SELECT事务B执行UPDATE隔离级别采用可重复读RR。时刻事务ASELECT事务BUPDATEt1开启事务生成ReadView开启事务t2SELECT id1读取DB_TRX_ID10的旧版本返回结果暂时未操作t3SELECT id1再次读取仍返回DB_TRX_ID10的版本UPDATE id1获取X锁新增DB_TRX_ID30的新版本t4SELECT id1继续读取旧版本不受t3 UPDATE影响COMMIT在这个推演里事务A在t3时刻第二次SELECT时数据已经变了但它因为使用的是事务开始时生成的ReadView读到的依然是旧版本。整个过程里事务A从未被阻塞事务B的UPDATE也从未因为SELECT的存在而停下来。这正是MVCC在RR隔离级别下最典型的应用场景。那如果事务A执行的是SELECT ... FOR UPDATE呢抱歉当前读才会被阻塞。事务B一旦持有X锁事务A的FOR UPDATE会进入锁等待状态一直到事务B提交或回滚。4. 不同数据库的MVCC实现差异4.1 InnoDB的undo log方案前面讲的主要是InnoDB的实现。InnoDB把旧版本数据统一存放在系统表空间或独立undo表空间中行记录中只保留一个回滚指针通过它指向undo log中的旧版本。优点是主表记录体积小、结构固定缺点是版本链过长时回溯历史版本需要频繁访问undo log产生额外的随机IO。在InnoDB中版本链的长度主要由并发更新次数决定。一个被大量更新过的热记录版本链可能非常长遇到长事务时读操作就需要沿着版本链逐级查找性能会有所下降。4.2 PostgreSQL的堆内多版本方案PostgreSQL采用了另一种思路旧版本直接留在原始数据页堆表中新的更新会在同一数据页上追加一条新的元组tuple。相关事务ID信息xmin、xmax直接写进元组头。然后通过xmax标记旧元组是否失效当清理进程VACUUM运行时再把这些死亡元组从页面上物理清除。这种方案的优点是读取旧版本时不需要访问额外的undo区域局部性更好缺点是同一数据页上可能堆积大量死亡元组造成表膨胀bloat而且清理不及时的话会显著影响查询性能。4.3 其他数据库的取舍Oracle同样使用undo表空间方案与InnoDB类似但Oracle的undo管理更成熟支持自动调优和保留期设置。SQL Server默认使用基于锁的隔离机制但提供了READ_COMMITTED_SNAPSHOT选项和快照隔离级别开启后也走行版本控制路线在tempdb中保存版本数据。这些差异说明MVCC没有一个放之四海而皆准的样板核心思想一致工程实现上各有取舍。4.4 实现方案对比对比维度InnoDBundo log方案PostgreSQL堆内多版本方案旧版本存储位置独立undo log区域原数据页内的新增元组行记录额外信息DB_TRX_ID、DB_ROLL_PTRxmin、xmax等元组头字段历史版本回溯代价可能跨页随机访问IO成本较高同页内查找局部性好但页内易堆积死亡元组空间回收机制purge线程异步清理undo记录VACUUM进程回收死亡元组典型膨胀风险undo表空间膨胀表和索引膨胀5. 生产环境中的MVCC问题排查与调优5.1 版本膨胀undo失控与表膨胀MVCC在带来巨大并发收益的同时也埋了一个隐患——旧版本不会立即消失。如果清理不及时就会出问题。对InnoDB来说undo log不能被purge线程及时回收。一旦purge线程落后undo表空间会持续膨胀磁盘占用暴涨数据库整体性能下滑。常见场景有超长事务长时间不提交、高频UPDATE海量数据后事务不结束等。对PostgreSQL来说死亡元组的堆积会造成表和索引膨胀。这个更直观表占用的磁盘空间是实际数据的数倍但vacuum一直跟不上查询还要扫描大量死亡元组性能自然变差。5.2 长事务是版本膨胀的源头版本膨胀最核心的根源就是长事务。某个事务一直不提交就会导致另一个关键问题被它创建的旧版本对数据库来说“未来可能还需要可见”因此purge线程不敢清理这些版本链上的任何内容。只要这个长事务存在所有它改动过的记录以及与之相关的版本链只能一直保留。我见过一个具体案例某业务系统里一个后台统计作业每天运行两个多小时期间不做任何提交。结果那段时间内所有被更新的业务数据undo log膨胀到平时几十倍。更麻烦的是长事务还会导致其他事务的version链路无法被回收形成连锁反应。5.3 当前读带来的阻塞问题MVCC能解决的只是普通SELECT和写操作之间的阻塞关系。一旦业务SQL里出现了SELECT ... FOR UPDATE或者UPDATE本身MVCC就管不着了。两个业务同时更新同一行照样会出现锁等待锁等待超时后会直接报错返回。不少同学在这个地方踩坑以为有了MVCC就可以在所有场景下不用考虑锁竞争。事实上FOR UPDATE场景下只要事务A一直持有X锁不提交事务B的当前读就会一直阻塞直到锁等待超时。排查这类问题时要看的是锁等待状态和事务持有锁的情况而不是MVCC层面的内容。5.4 监控视图与排查命令实际工作中排查MVCC相关问题主要看几个监控指标和系统视图。InnoDB场景下可以查看information_schema里的 INNODB_TRX、INNODB_LOCKS、INNODB_LOCK_WAITS 表以及通过 SHOW ENGINE INNODB STATUS 查看事务和锁信息。重点关注是否存在运行时间特别长的事务。undo log的增长速率。有没有大量purge线程跟不上或锁等待事件。PostgreSQL场景下查询 pg_stat_activity 可以看活跃事务查 pg_stat_user_tables 可以看n_dead_tup死亡元组数量。如果n_dead_tup持续增长且比活元组数量还多就该手动触发VACUUM ANALYZE或者检查autovacuum配置了。6. 实战心得与避坑建议踩过几次坑之后我总结了几条MVCC相关的经验分享出来供大家参考。第一正确理解“SELECT不阻塞UPDATE”的适用范围。普通SELECT走快照读时确实不会被UPDATE阻塞但开发人员在实际编写系统时要清楚FOR UPDATE和UPDATE本身仍存在锁等待。别把“读不阻塞写”理解成“所有读都不阻塞写”。第二隔离级别的选择会直接影响MVCC行为。RR级别下ReadView只生成一次同一个事务内多次SELECT结果一致。如果业务不需要这种强一致可以考虑用RC级别来降低快照保持时间也能间接减轻一些版本保留的压力。第三尽量避免在事务中做大范围更新也尽量避免长事务。事务里UPDATE行数越多、事务运行时间越长undo log、版本链和各类锁的占用就越大可能直接拖垮整个实例。我个人的习惯是把大事务拆成小批次提交每一步都尽快结束事务。第四监控一定要落地。数据库没有出现问题时你可能感受不到MVCC的代价一旦版本开始膨胀往往已经是事故现场。提前配好undo增长监控、锁等待监控和长事务监控真的能省很多半夜救火的痛苦。MVCC是数据库世界里少有的“读写双赢”机制它让读操作几乎无条件地让路于写操作又通过版本隔离保障了事务的一致性视图。但理解它、用好它不能只停留在“SELECT不阻塞UPDATE”这一句话上。我希望这篇文章能帮你把MVCC的工作原理、可见性判断规则和实际运维中的关键点串起来下次再有人问起这个问题时你能讲得比他期待的更透。