深入解析数据库并发控制MVCC的实现

张开发
2026/6/1 17:11:54 15 分钟阅读
深入解析数据库并发控制MVCC的实现
MVCC多版本并发控制Multi-version Concurrency Control MVCC是一种通过维护数据多个版本来实现并发控制的技术。其基本思想是为每次事务生成一个新版本的数据在读数据时选择不同版本的数据即可以实现对事务结果的完整性读取。在使用MVCC 时每个事务都是基于一个已生效的基础版本进行更新事务可以并行进行从而可以产生一种图状结构。如图所示基础数据的版本为1同时产生了两个事务事务A与事务B。这两个事务都各自对数据进行了一些本地修改这些修改只有事务自己可见不影响真正的数据。之后事务A首先提交生成数据版本2基于数据版本2又发起了事务C事务C继续提交生成了数据版本3最后事务B提交此时事务B的结果需要与事务C的结果合并如果数据没有冲突即事务B没有修改事务A与事务C修改过的变量那么事务B可以提交否则事务B提交失败。事务在基于基础数据版本做本地修改时为了不影响真正的数据通常有两种做法。1将基础数据版本中的数据完全拷贝出来再修改2每个事务中只记录更新操作而不记录完整的数据读取数据时再将更新操作应用到用基础版本的数据从而计算出结果。MVCC的设计理念深受版本控制系统如Git的影响其工作流程与版本控制系统的操作流程高度相似。在事务处理中上述两种策略各有优势完整拷贝策略简单直观但可能占用较多存储空间增量记录策略则更为高效能有效减少存储开销。MVCC的工作流程在很大程度上类似于版本控制系统如Git的操作流程可以说Git等版本控制系统的设计理念深受MVCC思想的影响。事务处理MVCC重点不在于并发控制而在于实现事务Transaction。假设在一个关系型数据库中更新操作以事务进行每个事务包括对若干行数据的更新操作。更新事务必须具有原子性即事务中的所有更新操作要么同时生效要么都不生效。在事务无法生效即需要进行事务回滚时通常会依赖于回滚日志Undo Log。回滚日志是一种专门用于事务恢复的日志技术它详细记录了事务在执行过程中对数据的所有修改。若事务失败或系统崩溃回滚日志能够用于将数据恢复至事务开始前的状态。以MySQL为例在MVCC中对于每次更新操作旧值会被保存到一条回滚日志日志中即它是该记录的旧版本。随着更新次数的增加所有的版本都会通过回滚指针Roll Pointer连接成一个链表称之为版本链。链首就是最新的记录链尾就是最早的旧记录。举个例子比如有个事务A插入了一条新记录insert into user(id, name) values(1, 张三)。现在来了一个事务B对该记录的name做出了修改改为“李四”。在事务B修改该行数据时数据库会先对该行加锁然后把该行数据拷贝到回滚日志中作为旧记录即在回滚日志中有当前行的拷贝副本。拷贝完毕后修改该行name为“李四”并且修改该行的事务ID为当前事务B的ID, 并将回滚指针指向拷贝到回滚日志的副本记录即表示上一个版本就是它事务提交后释放锁。此时又来了个事务C修改同一个记录将name修改为“王五”。在事务C修改该行数据时数据库也先为该行加锁然后把该行数据拷贝到回滚日志中作为旧记录发现该行记录已经有回滚日志了那么最新的旧数据作为链表的表头插在该行记录的回滚日志最前面。现在想回滚到事务Bname值为“李四”的时候只需通过回滚日志的链表指针顺着列表找到对应的回滚日志日志将旧值恢复到数据行即可。隔离级别事务隔离级别是数据库系统中用于控制并发访问的机制以确保数据的一致性和完整性。常见的事务隔离级别包括读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read、序列化Serializable。1读未提交读未提交隔离级别允许事务读取其他事务未提交的数据可能导致脏读Dirty Read。写操作可能使用锁但读操作不等待其他事务的锁释放。transaction T1 { // 不使用锁 read(data); // 写操作 write(data); commit(); } transaction T2 { // 不使用锁 read(data); // 可能从T1读到未提交的数据 // 写操作 write(data); commit(); }2读已提交只允许事务读取其他事务已提交的数据防止脏读。读操作通常使用共享锁Shared Lock且在读取后立即释放。写操作使用排他锁Exclusive Lock直到事务提交时释放。transaction T1 { // 写操作使用排他锁 lock(exclusive, data); write(data); commit(); // 释放排他锁 unlock(exclusive, data); } transaction T2 { // 读操作使用共享锁 lock(shared, data); read(data); // 只读取已提交的数据 // 读取数据以后立即释放共享锁 unlock(shared, data); // 写操作使用排他锁 lock(exclusive, data); write(data); commit(); // 释放排他锁 unlock(exclusive, data); }3可重复读确保事务在整个过程中读取的数据一致防止不可重复读。读操作使用共享锁并保持直到事务结束。写操作使用排他锁直到事务结束。此外为防止幻读可能还需要使用间隙锁Gap Lock以锁定数据间隙。transaction T1 { // 写操作使用排他锁 lock(exclusive, data); write(data); commit(); // 释放排他锁 unlock(exclusive, data); } transaction T2 { // 读操作使用共享锁 lock(shared, data); read(data); // 在整个事务中读取一致的数据 // 间隙锁隐式通过查询条件锁定范围如WHERE id BETWEEN 1 AND 10 lock(gap, range_data); read(range_data); // 锁定间隙防止幻读 // 写操作使用排他锁 lock(exclusive, data); write(data); commit(); // 释放共享锁、间隙锁和排他锁 unlock(exclusive, data); unlock(gap, range_data); unlock(shared, data); }3序列化确保事务完全隔离防止幻读提供最高的隔离级别。使用范围锁Range Lock等机制锁定数据的范围确保事务之间的完全隔离防止幻读。transaction T1 { // 对读和写操作使用范围锁 lock(range, data); read(data); write(data); commit(); // 释放范围锁 unlock(range, data); } transaction T2 { // 对读和写操作使用范围锁 lock(range, data); read(data); // 避免幻读 write(data); commit(); // 释放范围锁 unlock(range, data); }MySQL默认选用可重复读作为事务隔离级别这主要得益于其通过多版本并发控制机制在事务启动时即创建数据快照。这一设计确保了同一事务内多次读取操作的结果保持一致从而有效规避了读已提交级别下可能出现的不可重复读问题。与此同时InnoDB存储引擎通过运用间隙锁和临键锁Next-Key Lock技术对索引范围进行锁定显著降低了幻读现象多次读取同一数据范围时由于其他事务的插入或删除操作导致每次读取的结果集不同的发生概率。这是读已提交级别所无法实现的因为该级别无法对索引范围进行如此精细的锁定。尽管在并发写场景下读已提交级别的性能可能稍胜一筹但可重复读级别通过快照读无需加锁与当前读需加锁的巧妙结合在保障数据一致性的同时也维持了较高的系统性能。此外MySQL在设计上更倾向于优先避免数据异常特别是在处理银行账户、金融交易等关键业务场景时数据的一致性和完整性至关重要。当然用户仍可根据实际需求手动将事务隔离级别切换至读已提交以适应高并发写入场景的特殊要求。总结从硬抗到疏导驾驭流量的艺术缓存层设计需在即时响应和扩展性之间权衡本地内存访问速度快但容量有限跨节点共享数据则会增加网络延迟。同时要防范缓存穿透过滤无效请求、缓存击穿对热点数据进行加锁控制和缓存雪崩分散缓存过期时间等问题。消息队列通过异步解耦机制兼顾实时性与系统容错能力。Kafka利用分区顺序写入特性处理海量数据流RocketMQ则借助二次确认与补偿机制确保资金交易等场景无差错。数据库层借助日志追加记录操作轨迹采用版本快照隔离读写操作。查询时访问固定版本的数据避免锁冲突更新时生成新版本确保事务的完整性。并发系统设计的精髓并非是追求单一组件的极致性能而是一种关于“流动”与“控制”的艺术。它要求不再将压力视为需要硬抗的敌人而是将其视为需要引导和疏解的能量。通过分层设计将一个巨大而不可控的压力问题分解为一系列更小、更清晰、更易于管理的子问题并在每一层都做出最恰当的权衡与取舍。

更多文章