ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库MVCC详解:从版本链到ReadView,一文讲透并发控制

数据库MVCC详解:从版本链到ReadView,一文讲透并发控制

这个问题的背后,其实是数据库并发控制里最精巧的一套机制在起作用——MVCC(Multi-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核心机制:隐藏列、版本链与ReadView

2.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 版本链是怎么形成的

举个例子,假设有一条用户记录(id=1,name='张三')。初始插入事务ID为10,那么DB_TRX_ID=10,回滚指针指向空。

接着事务20执行了UPDATE,将name改成'李四',数据库会执行两个动作:首先把当前行旧内容拷贝到undo log,生成一个版本记录;然后在原行上更新name字段,并将DB_TRX_ID改成20,DB_ROLL_PTR指向刚才生成的undo记录中。于是版本链就变成了两层:

  • 新版本(DB_TRX_ID=20,name='李四')的DB_ROLL_PTR指向旧版本。
  • 旧版本(DB_TRX_ID=10,name='张三')存储在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_id:m_ids中最小的事务ID。
  • max_trx_id:创建ReadView时,系统即将分配给下一个事务的ID,可以理解为当前系统中最大的事务ID上限。

判断某个版本是否可见的规则,其实可以归纳成几条:

  1. 如果被访问版本的DB_TRX_ID等于creator_trx_id,说明这个版本就是当前事务自己修改的,肯定可见。
  2. 如果DB_TRX_ID小于min_trx_id,说明该版本早在当前事务启动前就已经提交,可见。
  3. 如果DB_TRX_ID大于等于max_trx_id,说明该版本是在当前事务启动之后才被创建,不可见。
  4. 如果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,然后沿着版本链查找对当前事务可见的版本。

执行流程大致如下:

  1. 事务开始,根据隔离级别确定ReadView的生成策略。
  2. 定位到目标记录,读取行上的DB_TRX_ID。
  3. 将DB_TRX_ID与ReadView中的min_trx_id、max_trx_id、m_ids进行比较。
  4. 如果可见,直接返回该版本的数据。
  5. 如果不可见,通过DB_ROLL_PTR跳转到链表中的下一个旧版本,重复步骤3的判断。
  6. 直到找到可见版本,或者遍历完整个版本链。

因为整个过程不涉及锁,所以快照读永远不会被其他事务的锁阻塞,也不需要等待其他事务提交。这就是“SELECT从来不阻塞UPDATE”的第一层含义:SELECT本身不拿锁,UPDATE自然不用等它。

3.2 当前读:UPDATE为什么必须加锁

与快照读相对的是当前读(Current Read),也叫锁定读。UPDATE、DELETE、INSERT,以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 都走当前读。

当前读读取的是记录的最新版本,而不是历史版本,并且在读取时需要获取对应的行锁或间隙锁。为什么写操作必须用当前读?原因很简单:写操作要修改数据,如果基于旧版本去更新,那后提交的事务就会把先提交的修改覆盖掉,导致丢失更新。所以UPDATE必须先拿到最新版本并加锁,确保在修改期间没有其他事务能同时写同一条记录。

以UPDATE为例,执行过程大致是:

  1. 执行器定位到目标行。
  2. 加X锁(排他锁),防止其他事务并发修改同一行。
  3. 读取最新版本的数据。
  4. 生成undo log,复制旧版本信息。
  5. 修改行内容,更新DB_TRX_ID,调整DB_ROLL_PTR。
  6. 如果当前事务尚未提交,其他事务的当前读操作会被阻塞;但快照读不受影响。

这里有个关键认知:MVCC并没有完全取代锁。它解决的是读写之间的互斥,而写写之间的冲突,仍然要靠锁来解决。两个事务同时UPDATE同一条记录时,后发起的UPDATE必须等待先获取锁的事务释放锁,否则就会一直阻塞。

3.3 用一个时间线推演SELECT和UPDATE的完整并发过程

为了更直观,我们用一张表格推演一遍。事务A执行SELECT,事务B执行UPDATE,隔离级别采用可重复读RR。

时刻事务A(SELECT)事务B(UPDATE)
t1开启事务,生成ReadView开启事务
t2SELECT id=1,读取DB_TRX_ID=10的旧版本,返回结果暂时未操作
t3SELECT id=1,再次读取,仍返回DB_TRX_ID=10的版本UPDATE id=1,获取X锁,新增DB_TRX_ID=30的新版本
t4SELECT id=1,继续读取旧版本,不受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 实现方案对比

对比维度InnoDB(undo 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的工作原理、可见性判断规则和实际运维中的关键点串起来,下次再有人问起这个问题时,你能讲得比他期待的更透。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进