从零实现一个MVCC事务引擎:版本链、快照隔离与写冲突检测

举报
Snowplow5180 发表于 2026/10/03 16:56:25 2026/10/03
【摘要】 在上一篇文章中,我们从零实现了一个协程调度器,覆盖了栈式协程、无栈协程与M:N调度。那篇文章聚焦的是并发执行模型。这一次我们把视角转向数据库的并发控制,目标是一个完整的MVCC事务引擎。数据库的并发控制经历了三个阶段。第一阶段是锁,读写互斥,性能极差。第二阶段是读写锁,读读并发,但读写仍然互斥,写操作会阻塞所有读操作。第三阶段是MVCC(多版本并发控制),读不阻塞写,写不阻塞读,读写完全并发...

在上一篇文章中,我们从零实现了一个协程调度器,覆盖了栈式协程、无栈协程与M:N调度。那篇文章聚焦的是并发执行模型。这一次我们把视角转向数据库的并发控制,目标是一个完整的MVCC事务引擎。

数据库的并发控制经历了三个阶段。第一阶段是锁,读写互斥,性能极差。第二阶段是读写锁,读读并发,但读写仍然互斥,写操作会阻塞所有读操作。第三阶段是MVCC(多版本并发控制),读不阻塞写,写不阻塞读,读写完全并发。MVCC是现代数据库的标配——PostgreSQL、MySQL InnoDB、Oracle、SQL Server都基于MVCC或其变体实现事务隔离。

MVCC的核心思想是:每次写入不覆盖旧数据,而是创建一个新版本。读操作根据事务开始时的快照,选择可见的版本。这样读操作永远不会被写操作阻塞,因为读的是旧版本;写操作也不会被读操作阻塞,因为写的是新版本。代价是旧版本需要保留一段时间,直到确定不再有事务需要它们。

本文用纯前端JavaScript从零实现一个完整的MVCC事务引擎,不依赖任何库。完整链路是:行版本结构 → 事务ID分配 → 快照创建 → 版本链遍历 → 可见性判断 → 写冲突检测 → 垃圾回收。最后会讨论快照隔离与可串行化隔离的差异,以及MVCC的经典异常(写偏斜)。

一、行版本结构:每条记录携带版本信息

MVCC的最小存储单元是"行版本"。与普通行不同,行版本除了数据本身,还携带两组关键的元数据:创建该版本的事务ID和删除该版本的事务ID。

javascript
class RowVersion {
  constructor(data, createdBy, deletedBy = null) {
    this.data = data;            // 行数据 { col: value }
    this.createdBy = createdBy;  // 创建此版本的事务ID
    this.deletedBy = deletedBy;  // 删除此版本的事务ID(null表示未被删除)
    this.next = null;            // 指向更旧的版本(版本链)
  }

  isVisible(snapshot) {
    // 创建事务必须已提交且不在当前快照的活跃事务集合中
    if (!snapshot.isCommitted(this.createdBy)) return false;
    if (snapshot.isActive(this.createdBy)) return false;

    // 如果已被删除,删除事务必须未提交或在活跃集合中
    if (this.deletedBy !== null) {
      if (snapshot.isCommitted(this.deletedBy) && !snapshot.isActive(this.deletedBy)) {
        return false;  // 已被已提交的事务删除
      }
    }

    return true;
  }
}

版本链是MVCC的核心数据结构。当一行被多次修改时,旧版本不会被覆盖,而是通过next指针链接成一个链表。链表头是最新版本,链表尾是最旧版本。读取时从链表头开始遍历,找到第一个对当前快照可见的版本。

javascript
class VersionedRow {
  constructor() {
    this.head = null;  // 最新版本
    this.tail = null;  // 最旧版本
  }

  // 追加一个新版本到链表头
  prepend(version) {
    version.next = this.head;
    this.head = version;
    if (this.tail === null) {
      this.tail = version;
    }
  }

  // 查找对当前快照可见的版本
  findVisible(snapshot) {
    let current = this.head;
    while (current !== null) {
      if (current.isVisible(snapshot)) {
        return current;
      }
      current = current.next;
    }
    return null;  // 没有可见版本(该行在快照创建时不存在)
  }
}

这里有一个细节需要注意:版本链遍历的顺序是从新到旧。为什么?因为最新的版本最可能是可见的,从新到旧遍历可以尽早返回。如果从旧到新遍历,每次都要走到链表末尾才能确定哪个版本可见。

二、事务管理:ID分配与状态机

事务是MVCC的基本单位。每个事务有一个全局唯一的ID,以及一个状态(活跃、已提交、已回滚)。

javascript
class TransactionManager {
  constructor() {
    this.nextTxId = 1;
    this.transactions = new Map();  // txId -> { status, startTime, commitTime }
    this.activeTransactions = new Set();
  }

  begin() {
    const txId = this.nextTxId++;
    this.transactions.set(txId, {
      id: txId,
      status: 'active',
      startTime: Date.now(),
      commitTime: null
    });
    this.activeTransactions.add(txId);
    return txId;
  }

  commit(txId) {
    const tx = this.transactions.get(txId);
    if (!tx || tx.status !== 'active') {
      throw new Error(`事务 ${txId} 不存在或已结束`);
    }
    tx.status = 'committed';
    tx.commitTime = Date.now();
    this.activeTransactions.delete(txId);
  }

  rollback(txId) {
    const tx = this.transactions.get(txId);
    if (!tx || tx.status !== 'active') {
      throw new Error(`事务 ${txId} 不存在或已结束`);
    }
    tx.status = 'rolledback';
    this.activeTransactions.delete(txId);
  }

  getStatus(txId) {
    const tx = this.transactions.get(txId);
    return tx ? tx.status : 'unknown';
  }

  isActive(txId) {
    return this.activeTransactions.has(txId);
  }
}

事务ID的分配是单调递增的。这个顺序很重要——它定义了事务之间的"先后关系"。一个事务ID更大的事务,如果它的快照包含了更小ID的事务的提交,那么它可以看到那个事务的修改。

三、快照隔离:读操作的可见性规则

快照隔离(Snapshot Isolation,SI)是MVCC最常用的隔离级别。每个事务在开始时创建一个快照,快照记录了"哪些事务已提交"和"哪些事务还在活跃"。读操作只看到快照创建时已提交的数据,看不到快照创建后提交的任何修改。

javascript
class Snapshot {
  constructor(txId, manager) {
    this.txId = txId;              // 创建此快照的事务ID
    this.manager = manager;
    this.activeAtStart = new Set(manager.activeTransactions);  // 快照创建时的活跃事务
    this.activeAtStart.delete(txId);  // 自己不算活跃
  }

  isCommitted(txId) {
    const status = this.manager.getStatus(txId);
    return status === 'committed';
  }

  isActive(txId) {
    return this.activeAtStart.has(txId);
  }

  // 判断某个事务对此快照是否可见
  isVisible(txId) {
    // 自己创建的数据对自己可见
    if (txId === this.txId) return true;
    // 快照创建时活跃的事务,其提交对快照不可见
    if (this.activeAtStart.has(txId)) return false;
    // 其他已提交事务可见
    return this.isCommitted(txId);
  }
}

这个可见性判断规则是MVCC的精髓。核心逻辑是:快照创建时活跃的事务,无论它们后来是否提交,对当前快照都不可见。这是因为快照创建时这些事务还没完成,它们的结果不属于快照的"时间点"。

考虑一个场景:事务A(ID=1)和事务B(ID=2)同时开始。事务A先提交,事务B后提交。事务C(ID=3)在A提交后、B提交前开始。

  • 事务C的快照:activeAtStart = {B}(因为B还在活跃)

  • 事务C读A的数据:A已提交且不在activeAtStart中,可见

  • 事务C读B的数据:B在activeAtStart中,不可见(即使B后来提交了)

这就是快照隔离的语义——每个事务看到的是一个一致的快照,快照之间是隔离的。

四、版本可见性判断的完整实现

将行版本的可见性判断和事务的快照结合起来,就得到了完整的读取路径:

javascript
class MVCCEngine {
  constructor() {
    this.txManager = new TransactionManager();
    this.tables = new Map();  // tableName -> Map<primaryKey, VersionedRow>
    this.indexes = new Map(); // tableName -> Map<indexName, Map<key, Set<primaryKey>>>
  }

  beginTransaction() {
    const txId = this.txManager.begin();
    const snapshot = new Snapshot(txId, this.txManager);
    return { txId, snapshot };
  }

  read(tx, tableName, primaryKey) {
    const table = this.tables.get(tableName);
    if (!table) return null;

    const versionedRow = table.get(primaryKey);
    if (!versionedRow) return null;

    const visible = versionedRow.findVisible(tx.snapshot);
    if (!visible) return null;

    return { ...visible.data };
  }

  // 范围扫描
  scan(tx, tableName, predicate) {
    const table = this.tables.get(tableName);
    if (!table) return [];

    const results = [];
    for (const [key, versionedRow] of table.entries()) {
      const visible = versionedRow.findVisible(tx.snapshot);
      if (visible && predicate(visible.data)) {
        results.push({ key, ...visible.data });
      }
    }
    return results;
  }
}

read方法的逻辑是:找到主键对应的版本链,从新到旧遍历,返回第一个对当前快照可见的版本。如果没有可见版本,说明该行在快照创建时不存在(或已被删除)。

scan方法对每一行执行相同的可见性判断,然后应用谓词过滤。这个实现是O(N)的——遍历所有行。生产级实现会用B+树索引加速范围扫描,但核心的可见性判断逻辑是一样的。

五、写操作:新版本创建与写冲突检测

写操作比读操作复杂得多,因为需要处理并发写的冲突。MVCC使用写冲突检测来保证一致性——如果两个并发事务修改了同一行,后提交的事务必须回滚。

javascript
class MVCCEngine {
  // ... 之前的代码

  write(tx, tableName, primaryKey, data) {
    if (!this.tables.has(tableName)) {
      this.tables.set(tableName, new Map());
    }
    const table = this.tables.get(tableName);

    let versionedRow = table.get(primaryKey);

    if (versionedRow === null || versionedRow === undefined) {
      // 插入新行:创建第一个版本
      versionedRow = new VersionedRow();
      versionedRow.prepend(new RowVersion(data, tx.txId, null));
      table.set(primaryKey, versionedRow);
    } else {
      // 更新已有行:检查写冲突
      const latest = versionedRow.head;

      // 如果最新版本已经被另一个活跃事务修改,产生写冲突
      if (this._hasWriteConflict(tx, versionedRow)) {
        throw new Error(`写冲突: 行 ${primaryKey} 已被并发事务修改`);
      }

      // 标记旧版本为已删除
      latest.deletedBy = tx.txId;
      // 创建新版本
      versionedRow.prepend(new RowVersion(data, tx.txId, null));
    }

    // 记录本事务修改过的行,用于提交时验证
    tx.writeSet.add(`${tableName}:${primaryKey}`);
  }

  _hasWriteConflict(tx, versionedRow) {
    let current = versionedRow.head;
    while (current !== null) {
      // 如果最新版本的创建者是另一个活跃事务,冲突
      if (current.createdBy !== tx.txId &&
          this.txManager.isActive(current.createdBy)) {
        return true;
      }
      // 只检查最新版本,不检查更旧的版本
      break;
    }
    return false;
  }

  delete(tx, tableName, primaryKey) {
    const table = this.tables.get(tableName);
    if (!table) return;

    const versionedRow = table.get(primaryKey);
    if (!versionedRow) return;

    const visible = versionedRow.findVisible(tx.snapshot);
    if (!visible) return;

    if (this._hasWriteConflict(tx, versionedRow)) {
      throw new Error(`写冲突: 行 ${primaryKey} 已被并发事务修改`);
    }

    // 标记为已删除(不创建新版本)
    visible.deletedBy = tx.txId;
    tx.writeSet.add(`${tableName}:${primaryKey}`);
  }
}

写冲突检测的逻辑是:检查版本链的最新版本是否由另一个活跃事务创建。如果是,说明两个事务在并发修改同一行,产生冲突。后写入的事务需要回滚或重试。

这种"首次写入者获胜"的策略被称为First-Writer-Wins。它的优点是实现简单、无死锁(不需要等待锁)。缺点是并发冲突时事务会失败,应用层需要重试。

写操作的另一个关键点是:修改已有行时,旧版本的deletedBy被设置为当前事务ID,新版本被插入链表头。这意味着旧版本并没有被真正删除,只是被标记为"被当前事务删除"。如果当前事务回滚,这个标记需要被撤销。

javascript
class MVCCEngine {
  // ... 之前的代码

  commit(tx) {
    // 提交前再次检查写冲突(乐观并发控制)
    for (const key of tx.writeSet) {
      const [tableName, primaryKey] = key.split(':');
      const versionedRow = this.tables.get(tableName)?.get(primaryKey);
      if (versionedRow && this._hasWriteConflict(tx, versionedRow)) {
        throw new Error(`提交冲突: ${key} 已被并发事务修改`);
      }
    }

    this.txManager.commit(tx.txId);
  }

  rollback(tx) {
    // 回滚:撤销本事务的所有修改
    for (const key of tx.writeSet) {
      const [tableName, primaryKey] = key.split(':');
      const versionedRow = this.tables.get(tableName)?.get(primaryKey);
      if (!versionedRow) continue;

      // 如果最新版本是本事务创建的,移除它
      if (versionedRow.head.createdBy === tx.txId) {
        versionedRow.head = versionedRow.head.next;
        if (versionedRow.head === null) {
          this.tables.get(tableName).delete(primaryKey);
        }
      }

      // 如果旧版本被本事务标记为删除,撤销标记
      let current = versionedRow.head;
      while (current !== null) {
        if (current.deletedBy === tx.txId) {
          current.deletedBy = null;
        }
        current = current.next;
      }
    }

    this.txManager.rollback(tx.txId);
  }
}

回滚操作比提交复杂,因为需要同时处理两种情况:本事务创建的新版本需要被移除,本事务标记的删除需要被撤销。

六、垃圾回收:旧版本清理策略

MVCC的代价是旧版本会持续累积,占用存储空间。如果没有垃圾回收机制,数据库会不断膨胀。垃圾回收的目标是:删除那些"不再可能被任何活跃事务看到"的旧版本。

判断一个旧版本是否可以回收的标准是:所有活跃事务的快照中,该版本都不可见。换句话说,如果该版本的创建事务ID小于所有活跃事务的最小快照,且该版本的删除事务ID也小于最小快照,那么它就可以被安全回收。

javascript
class GarbageCollector {
  constructor(engine) {
    this.engine = engine;
  }

  collect() {
    const manager = this.engine.txManager;

    // 找到最小的活跃事务ID
    let minActiveTxId = Infinity;
    for (const txId of manager.activeTransactions) {
      if (txId < minActiveTxId) minActiveTxId = txId;
    }

    if (minActiveTxId === Infinity) {
      // 没有活跃事务,可以回收所有已提交事务的旧版本
      minActiveTxId = manager.nextTxId;
    }

    let collected = 0;

    for (const [tableName, table] of this.engine.tables) {
      for (const [primaryKey, versionedRow] of table) {
        // 从链表头开始,保留第一个对最小快照可见的版本
        // 之后的所有版本都可以回收

        // 找到需要保留的版本
        let keep = versionedRow.head;
        let current = versionedRow.head;

        // 跳过所有已提交事务创建的版本,找到最旧的"仍然可能可见"的版本
        while (current !== null) {
          if (current.createdBy >= minActiveTxId) {
            // 这个版本可能被某些活跃事务看到
            keep = current;
            break;
          }
          current = current.next;
        }

        // 回收 keep 之后的所有版本
        if (keep !== null) {
          let toDelete = keep.next;
          keep.next = null;
          versionedRow.tail = keep;

          while (toDelete !== null) {
            const next = toDelete.next;
            toDelete.next = null;
            toDelete = next;
            collected++;
          }
        }
      }
    }

    return collected;
  }
}

垃圾回收的时机很关键。太频繁会浪费CPU,太稀疏会占用大量存储。生产级实现通常由后台进程周期性执行,或在前台事务提交时触发增量回收。

七、隔离级别的差异:快照隔离 vs 可串行化

快照隔离解决了脏读、不可重复读和幻读,但它不是可串行化的。快照隔离有一个著名的异常:写偏斜(Write Skew)。

考虑一个场景:一个会议室预订系统,规则是"同一时间段至少留一个空闲会议室"。有两个会议室A和B。初始状态是两个都空闲。

事务T1:读A和B,发现都空闲。决定预订A。
事务T2:读A和B,发现都空闲。决定预订B。

两个事务的快照都看到A和B空闲。T1修改A,T2修改B。两者修改的是不同的行,没有写冲突。两个事务都提交成功。

结果:A和B都被预订了,违反了"至少留一个空闲"的约束。

写偏斜的本质是:两个事务读取了重叠的数据集,但写入了不重叠的数据集。快照隔离只检测写写冲突,不检测读写冲突(读到的数据是否被其他事务修改)。

可串行化隔离(Serializable Isolation)解决了这个问题。实现可串行化的方式主要有两种:

严格两阶段锁(Strict 2PL) :读操作加读锁,写操作加写锁,事务结束时才释放锁。这保证了可串行化,但并发度低。

可串行化快照隔离(SSI) :在快照隔离的基础上,检测读写冲突(一个事务读了另一个事务修改的数据)。如果发现冲突,回滚其中一个事务。

SSI的核心是跟踪每个事务的读集和写集,在提交时检查是否存在"危险结构"——即一个事务读了旧版本,而另一个事务写了新版本。如果存在,则其中一个事务必须回滚。

八、完整测试:并发场景验证

把所有部分串联起来,测试几个关键场景:

javascript
function testMVCC() {
  const engine = new MVCCEngine();

  // 场景1:基本读写
  console.log('=== 场景1:基本读写 ===');
  const tx1 = engine.beginTransaction();
  engine.write(tx1, 'users', 1, { name: 'Alice', age: 30 });
  engine.commit(tx1);

  const tx2 = engine.beginTransaction();
  console.log(engine.read(tx2, 'users', 1));  // { name: 'Alice', age: 30 }
  engine.commit(tx2);

  // 场景2:快照隔离
  console.log('\n=== 场景2:快照隔离 ===');
  const tx3 = engine.beginTransaction();
  console.log('tx3 初始读:', engine.read(tx3, 'users', 1));  // Alice, 30

  const tx4 = engine.beginTransaction();
  engine.write(tx4, 'users', 1, { name: 'Alice', age: 31 });
  engine.commit(tx4);

  console.log('tx3 再次读:', engine.read(tx3, 'users', 1));  // 仍然是 Alice, 30
  console.log('tx4 提交后新事务读:', engine.read(engine.beginTransaction(), 'users', 1));  // Alice, 31

  engine.commit(tx3);

  // 场景3:写冲突
  console.log('\n=== 场景3:写冲突 ===');
  const tx5 = engine.beginTransaction();
  const tx6 = engine.beginTransaction();

  engine.write(tx5, 'users', 2, { name: 'Bob', age: 25 });
  try {
    engine.write(tx6, 'users', 2, { name: 'Bob', age: 26 });
  } catch (e) {
    console.log('捕获写冲突:', e.message);
  }

  engine.commit(tx5);
  engine.rollback(tx6);

  // 场景4:垃圾回收
  console.log('\n=== 场景4:垃圾回收 ===');
  const gc = new GarbageCollector(engine);
  const collected = gc.collect();
  console.log(`回收了 ${collected} 个旧版本`);
}

testMVCC();

九、性能分析与优化方向

MVCC的性能瓶颈主要有三个:

版本链遍历。如果一行被修改了N次,读取时需要遍历版本链找到可见版本。最坏情况是O(N)。优化方向是在版本链上维护索引,或者使用时间戳排序的数组代替链表。

垃圾回收的停顿。全量垃圾回收需要遍历所有表的所有行,可能造成长时间停顿。优化方向是增量回收——每次事务提交时回收一部分旧版本,把停顿分散到多个小周期中。

写冲突检测的开销。每次写入都需要检查版本链的最新版本。优化方向是维护一个活跃事务的写集,在冲突检测时直接比对写集,而不是遍历版本链。

在PostgreSQL中,MVCC的实现与我们这里的模型有重要差异。PostgreSQL将旧版本存储在同一个表中(通过xmin和xmax系统列标记),而不是维护独立的版本链。这被称为"原地更新"(in-place update)模型。读取时通过xmin和xmax判断可见性,不需要遍历链表。代价是表会膨胀,需要VACUUM进程定期清理。

十、总结

从行版本结构到事务管理,从快照创建到可见性判断,从写冲突检测到垃圾回收——这个MVCC事务引擎的核心代码不到400行,但覆盖了现代数据库并发控制的全部关键机制。

快照隔离的核心是"读不阻塞写,写不阻塞读":读操作访问旧版本,写操作创建新版本,两者通过版本链和快照解耦。写冲突检测采用First-Writer-Wins策略,避免了锁等待和死锁。垃圾回收通过最小活跃事务ID判断旧版本的可回收性。

理解了这套最小内核,再去看PostgreSQL的HeapTupleHeader、InnoDB的ReadView、Oracle的SCN机制,会发现它们在设计上遵循的是同一套思路——用多版本换取并发度,用垃圾回收换取空间。差异只在工程细节:PostgreSQL用原地更新减少链表遍历,InnoDB用undo log实现回滚,Oracle用回滚段支持闪回查询。MVCC是这一切的起点。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。