目录
一、锁的困境与MVCC的替代路径
二、版本链:元组的时光长廊
三、快照可见性:事务ID与版本时钟
四、写-写冲突的检测:First-Committer-Wins
五、快照隔离的异常:写偏斜的隐蔽破坏
六、MVCC的工程实现差异
七、结语:从快照到可串行化
一、锁的困境与MVCC的替代路径
前两篇文章系统阐述了基于锁的并发控制——两阶段锁协议保证可串行化,多粒度锁与意向锁优化锁管理开销。然而,锁机制存在一个结构性的性能瓶颈:读写冲突。当一个事务正在修改某行数据时,任何试图读取该行的事务都必须等待排他锁释放。反之,当一个长时间运行的只读事务持有共享锁时,任何试图修改该行的事务也被阻塞。
这种读写互斥在OLTP场景中尤为痛苦——短小的更新事务阻塞了大量只读查询,或者反过来,一个复杂的报表查询长时间持有共享锁,阻塞了所有等待更新的写入事务。在互联网规模的高并发场景中,这种互斥导致的等待队列可能迅速膨胀,系统吞吐量急剧下降。
多版本并发控制以一条根本不同的路径绕过这一困境。它的核心思想朴素而大胆:不覆盖,只追加。当事务修改一行数据时,它不直接覆盖原有值,而是创建一个新版本,旧版本保留在数据库中以供并发只读事务继续访问。当事务读取一行数据时,它不尝试获取共享锁,而是根据自己的事务开始时间,从版本链中定位当时可见的版本。
这一策略最直接的后果是:读操作永远不需要等待写操作,写操作也永远不需要等待读操作。只读事务看到的是一个“冻结在开始时刻”的数据库快照——即使其他事务在它运行期间提交了修改,它的快照也不受影响。写入事务则基于快照中的最新可见版本创建新版本,只在遇到两个并发写事务同时修改同一行时产生写-写冲突。
MVCC并非最近的理论创新。它的思想萌芽可追溯至1970年代末的数据库研究,但真正将其推向主流的是PostgreSQL、Oracle和MySQL InnoDB等现代数据库系统的大规模工程实践。如今,MVCC已成为关系数据库并发控制的事实标准——几乎没有新设计的数据库系统会采用纯粹的锁机制,MVCC或其变体几乎无处不在。
二、版本链:元组的时光长廊
MVCC的物理实现依赖于每条数据元组维护一个版本链。当一个事务更新一行数据时,系统不是将旧值就地覆盖,而是执行以下操作:创建一个新的物理元组,包含更新后的数据;在新元组的头部记录创建该版本的事务ID;将新元组链接到旧元组,形成一条时间方向上的版本链——新版本指向旧版本,层层回溯。
一个元组在其生命周期中可能经历多次更新,每次更新在版本链上增加一个新节点。最新的版本是链表的头部,最旧的版本是尾部。不同事务可以根据各自的事务开始时间,沿着版本链回溯,找到对它们而言“应当可见”的那个版本。
当一个事务被回滚时,它创建的新版本被标记为无效,版本链回退到回滚前的状态——无需像锁机制那样执行显式的撤销操作,因为旧版本从未被覆盖,自然留存。这是MVCC在回滚效率上的一个天然优势:回滚不需要恢复旧值,只需要标记新版本无效。
版本链的存储管理是一个重要的工程挑战。旧版本不能无限保留——否则存储空间将被历史版本耗尽。数据库系统通过垃圾回收机制来清理不再需要的旧版本。当一个版本不再可能被任何活跃事务访问时(即所有在它之后开始的事务都已经结束),它就成为垃圾,可以被回收。垃圾回收通常以后台进程的形式运行,间歇性地扫描版本链,清理失效版本并回收存储空间。
三、快照可见性:事务ID与版本时钟
版本链存储了所有历史版本,但一个具体的事务应该看到哪个版本?这是MVCC可见性判断的核心问题。答案取决于快照隔离的具体实现规则,但其基本框架是一致的。
系统为每个事务分配一个单调递增的事务ID。事务开始时,系统记录当前所有活跃(尚未提交或回滚)的事务ID集合。这个活跃事务快照定义了该事务的“可见性边界”——对于版本链上的每个版本,事务根据创建该版本的事务ID和自身的活跃事务快照,按照一组规则判断该版本是否可见。
可见性判断的典型规则如下:如果版本的事务ID等于当前事务ID,则该版本是当前事务自己创建的,可见。如果版本的事务ID在当前事务的活跃事务快照中,说明该版本由尚未提交的并发事务所创建,不可见——当前事务应沿版本链继续回溯。如果版本的事务ID小于当前事务ID且不在活跃事务快照中,说明该版本在事务开始前已提交,可见——这就是当前事务的快照所应看到的数据。
这套可见性规则保证了:事务看到的是一个一致的数据库快照——所有在它开始前已提交的修改都包含在快照中,所有在它开始后才提交的修改(或尚未提交的修改)都不可见。事务在自己的快照中运行,与其他并发事务的修改隔离开来——这就是“快照隔离”名称的由来。
在快照隔离下,只读查询完全不需要获取锁——它只需沿版本链回溯找到可见版本即可。这是MVCC相对于锁机制最显著的性能优势:一个复杂的分析查询可以在数千万行的表上运行数分钟,期间完全不会阻塞在线事务的写入。同样,频繁的短小更新事务也无需等待任何读取操作释放锁。读写操作在MVCC下实现了近乎完全的并发。
四、写-写冲突的检测:First-Committer-Wins
MVCC消除了读写冲突,但写-写冲突仍然需要处理。两个并发事务可能基于相同的快照版本修改同一行数据。如果两者都在旧版本的基础上创建新版本,后提交的事务将覆盖先提交事务的修改——这恰恰是第26篇中所分析的丢失更新异常。
MVCC处理写-写冲突的标准策略是First-Committer-Wins——先提交者胜。当两个事务试图更新同一行时,系统为它们各自创建新版本,但两个新版本都链接到同一个旧版本上。当事务提交时,系统检查旧版本之上是否已有其他事务提交了新版本。如果发现冲突——即该行的最新版本已经不再是事务开始时看到的版本——后提交的事务被强制回滚。只有第一个提交的事务成功写入。
这一策略等价于将写-写冲突的检测延迟到了事务提交时刻,而非在加锁阶段就阻塞冲突事务。它的优势在于避免了长时间持锁,劣势在于后提交的事务需要回滚并重新执行——当写冲突率较高时,反复的回滚和重试可能消耗比锁等待更多的计算资源。这是MVCC在写密集型工作负载下的一个潜在弱点,也是为什么某些写密集场景下纯锁机制可能表现更好的原因。
五、快照隔离的异常:写偏斜的隐蔽破坏
快照隔离在读写并发性能上的优势毋庸置疑,但它在理论上面临一个深层问题:快照隔离不是可串行化的。存在一类并发异常——写偏斜——在快照隔离下可能发生,而在可串行化调度中不会出现。
写偏斜的本质是两个并发事务各自基于一个包含多行的快照做出决策,各自修改快照中的不同行,但两者修改的集合之间在业务语义上存在应当互斥的约束——这种跨行的约束无法通过行级写-写冲突检测来捕捉。
经典的写偏斜场景是医生值班系统。假设医院规定任何时刻必须至少有一位医生在值班。当前数据库中有两位医生在值班。事务T₁(医生A请假)读取值班医生列表,看到包括自己和医生B在内共有2人在值班,判断满足“至少一位”的约束,于是将自己的值班状态改为“休假”。事务T₂(医生B请假)在相同的时间窗口内也读取值班医生列表,同样看到2人在值班,同样判断满足约束,将自己的状态改为“休假”。两个事务各修改了不同行——医生A和医生B的记录——因此First-Committer-Wins检测无法发现冲突。两个事务都成功提交后,值班医生数变为0——医院的规定被无声无息地违反了。
在可串行化调度中,这个冲突会被检测到——因为T₁和T₂之间存在读写冲突(两者都读取了对方的行,而对方修改了该行),冲突图存在环,不可串行化。但在快照隔离下,因为读取的是快照而非共享锁,读操作不留下任何痕迹,写-写冲突检测仅检查同一行的写入冲突,跨行的约束无法被系统捕捉。
防范写偏斜需要更严格的隔离机制——可串行化快照隔离。它在快照隔离的基础上增加了对读写依赖的跟踪,检测事务之间的读写冲突是否可能形成环,在提交时拒绝可能产生写偏斜的事务。这一机制在PostgreSQL 9.1及以后版本中以SERIALIZABLE隔离级别实现,在学术上由Cahill等人在2008年提出的Serializable Snapshot Isolation论文奠定了理论基础。
六、MVCC的工程实现差异
尽管MVCC的核心原理统一,不同数据库系统在具体实现上存在显著差异,这些差异直接影响应用程序在不同数据库上的行为和性能。
PostgreSQL的MVCC实现将新旧版本存储在同一表空间中,旧版本不与主数据分离。更新操作总是创建新版本,旧版本保留在表中直到被VACUUM进程清理。这种策略的优势是实现简洁、版本回溯直接,缺点是表膨胀——频繁更新的表会产生大量旧版本,需要VACUUM定期回收空间。
MySQL InnoDB的MVCC将旧版本存储在单独的撤销表空间中。更新操作在新位置创建新版本,旧版本被移入撤销段。事务通过撤销段中的版本链回溯历史版本。InnoDB的撤销段管理相对轻量,但长事务可能导致撤销段膨胀——因为事务必须保留其快照可能需要的全部历史版本,长事务阻碍了旧版本的清理。
Oracle的MVCC实现与InnoDB类似,同样将旧版本存储在撤销段中,但提供了更精细的撤销保留策略和闪回查询功能——允许用户查询表在过去某个时间点的状态,这得益于撤销段中丰富的历史版本信息。
七、结语:从快照到可串行化
MVCC以版本链和快照可见性两重机制,优雅地解决了锁机制中读写互斥的架构性瓶颈。它让只读查询在不阻塞写入的前提下获得一致性快照,让写入事务在不等待读取的前提下创建新版本。正是由于MVCC,现代数据库系统能够支撑起互联网规模的并发负载——每秒数十万次查询与数万次更新在同一张表上并行发生,彼此几乎不受对方干扰。
然而,快照隔离的理论局限性同样值得清醒认知。写偏斜这类跨行约束异常提醒我们,并发控制并非简单的“读不阻塞写”就能解决所有问题。在某些对数据一致性有严格要求的场景——金融账务、库存扣减、权限检查——快照隔离可能不足以保证业务逻辑的正确性。理解MVCC的可见性规则和快照隔离的异常边界,是开发者在不同隔离级别之间做出审慎选择的前提。
下一篇,我们将回到并发控制的理论高地——可串行化调度的形式化判别方法。如何从一组事务操作的执行序列出发,构造冲突图并判定其是否可串行化?冲突可串行化与视图可串行化之间存在怎样的理论鸿沟?这些形式化工具不仅是对前几篇文章的理论收束,更是理解后续分布式事务与一致性协议的必要基础。