C++数据库存储引擎内核开发:B+树索引与MVCC并发控制的完整实现

1. C++数据库存储引擎内核开发

数据库存储引擎的性能核心在于两大支柱:索引事务并发控制。B+树作为最广泛使用的磁盘友好型索引结构,能够以极低的 I/O 规模支撑点查询与范围扫描;而 MVCC(多版本并发控制)通过为事务提供一致的数据快照,解决了读写互斥的瓶颈,成为现代数据库(如 PostgreSQL、MySQL InnoDB、Oracle)的首选并发控制方案。本文将深入 C++ 层面,从头实现一个轻量级存储引擎内核,完整展示 B+树索引与 MVCC 如何协同工作,所有代码均可直接编译运行。

2. 系统架构概览

我们的存储引擎称为 "toyDB",包含以下核心模块:

  • 页面管理器(Buffer Pool):负责内存中的页面缓存与磁盘 I/O。
  • B+树索引:主键索引,叶子节点存储数据所在的页面 ID 或直接存储行指串(rid)。
  • 元组管理:每行数据(元组)采用多版本存储,版本通过链表关联。
  • 事务管理器:维护事务快照、版本可见性判断与提交/回滚。
  • WAL 日志:为简化,本文仅作简单记录,重点展示索引与 MVCC 核心逻辑。

整体工作流:事务通过 B+树找到目标元组,再根据 MVCC 规则读取可见版本或创建新版本。

3. B+树索引核心数据结构

3.1 节点定义

B+树节点分为内部节点(索引节点)和叶子节点。内部节点只存储键与子指针;叶子节点存储键与值(数据行位置)。为了简单,我们采用固定大小的节点页,每个节点一页。

cpp 复制代码
#include <vector>
#include <cstdint>
#include <cstring>

static constexpr int PAGE_SIZE = 4096;
static constexpr int MAX_KEYS = (PAGE_SIZE - 4) / 12;  // 假设 4 字节头部
static constexpr int MIN_KEYS = MAX_KEYS / 2;

enum NodeType : uint8_t { INTERNAL = 0, LEAF = 1 };

struct BPlusNode {
    NodeType type;
    uint16_t key_count;
    int32_t keys[MAX_KEYS];
    union {
        BPlusNode* children[MAX_KEYS + 1];      // 内部节点
        uint32_t page_ids[MAX_KEYS];            // 叶子节点存数据页码
    };
    BPlusNode* next_leaf;                       // 叶子节点链表
};

实际工程中节点会序列化到页面,通过偏移读写;为便于理解,这里使用内存指针。叶子节点还包含一个指向右侧兄弟节点的指针,方便范围扫描。

3.2 页面管理器简介

存储引擎通过PageManager管理磁盘文件与缓冲池。每个页面对应一个 Page 对象,其中 data 指向 4KB 缓冲区,pinned 表示是否被引用。B+树节点指针实际上是页面 ID,通过 PageManager::fetch_page(page_id) 得到内存中的页面指针,并实现写回脏页。

4. B+树基本操作实现

4.1 查找

查找从根节点开始,在内部节点中使用二分查找定位子节点,直到叶子节点,然后在叶子节点二分查找键值。返回对应数据位置(page_id + slot)。

cpp 复制代码
std::pair<uint32_t, uint32_t> BPlusTree::search(int key) {
    auto node = root;
    while (node->type == INTERNAL) {
        int i = 0;
        while (i < node->key_count && key >= node->keys[i]) i++;
        node = node->children[i];
    }
    // leaf node
    int i = 0;
    while (i < node->key_count && node->keys[i] != key) i++;
    if (i == node->key_count) return {null_page, 0}; // not found
    return {node->page_ids[i], /* slot computed from values */ };
}

4.2 插入与分裂

找到叶子节点后,若该节点键数量未满,直接插入;否则分裂。分裂时,将原节点一半的键移到新节点,并将中间键提升到父节点。如果父节点也需要分裂,则递归向上。新根节点的创建会导致树高度增加。

cpp 复制代码
void BPlusTree::insert(int key, uint32_t page_id) {
    // 查找插入位置,分裂递归
    insert_internal(root, key, page_id);
    // 调整根节点 ...
}

插入时需要注意维护叶子链表指针,确保范围扫描的正确性。

4.3 删除与合并

删除键后,若节点键数小于最小阈值(通常为半满),先尝试从兄弟节点借键,若兄弟也不够则合并。合并可能引发父节点删除键,递归向上,最终可能减少树的高度。实现较复杂,但原理一致。

5. MVCC 并发控制设计

5.1 版本链与元组头

每条元组(数据行)由一个定长头部与变长字段组成。头部包含:txn_id(创建该版本的事务 ID)、begin_timestamp(创建时间戳)、end_timestamp(删除时间戳,初始为 INF)、next_version_rid(指向旧版本的 rid)。形成一条从最新到最旧的单向链表。

元组结构定义:

cpp 复制代码
struct TupleHeader {
    uint32_t txn_id;
    uint64_t begin_ts;          // 事务开始时间戳
    uint64_t end_ts;            // 删除时间戳,未删除时为 UINT64_MAX
    uint32_t next_version_rid;  // 旧版本元组 rid
    uint16_t field_count;
    // 后面跟着字段数据
};

5.2 快照隔离与事务管理

每个事务被分配一个开始时间戳start_ts)和一个提交时间戳commit_ts)。事务在start_ts时获取当前活跃事务列表(快照)。可见性判断规则:一个元组版本对事务 T 可见,当且仅当:

  1. 版本 begin_tsT.start_ts
  2. 版本 end_ts > T.start_ts 或者 end_ts == INF
  3. 创建该版本的事务 txn_id 不等于 T 自身(避免读到自己的未提交修改?这里需要更精细处理)

我们使用时间戳来模拟全局单调递增的 Hybrid Logical Clock,简化实现。

5.3 写操作流程

当事务 T 要更新一行:

  • 通过 B+树找到最新版本元组 rid;
  • 根据可见性遍历版本链表,找到可见版本;
  • 基于可见版本创建新版本(copy-on-write),新版本begin_ts=T.start_tsend_ts=INF
  • 将旧版本的 end_ts 设置为 T.start_ts(或提交后才设置?通常提交时才标记)。
  • 将新版本插入元组页面,并更新该行的版本链表头(next_version_rid 指向上一个版本)。
  • 若事务回滚,则新版本可丢弃(通过 end_ts 标记删除或直接清理)。

索引方面,主键 B+树存储的是行的逻辑位置,通常指向最新版本的 rid;我们通过元组页面中的next_version_rid 链追溯历史版本。因此索引本身不需要维护版本信息,极大简化了索引的并发控制。

6. B+树索引与 MVCC 的整合

6.1 索引与版本分离

这是实现的关键点。我们采用类似 PostgreSQL 的索引设计:叶子节点不直接存储数据,而是存储元组标识符(rid) 。rid 包含 page_id 与 slot_offset。每个 rid 所在页面存有该行的最新版本头。当需要读取时,通过 rid 访问页面,然后沿着next_version_rid 找到可见版本。这使得索引几乎不受 MVCC 版本膨胀的影响,因为键的插入和删除只影响索引是否包含该 rid。

6.2 索引更新的并发处理

对于插入:事务创建新元组版本后,将 rid 插入 B+树。此时需持有索引页的页锁以防止并发拖垮。删除:B+树中的删除可以标记为逻辑删除(在叶子节点末尾追加删除标记),真正理解在元组版本链中,该行在版本链中若所有版本都被删除(最新版本 end_ts 为过去),则后续 vacuum 时会同时清理索引和元组。

更新操作可能改变索引字段(若是主键则很少发生),如果仅是非主键字段更新,则索引不变,版本链增长即可;若主键更新,则需删除旧索引项并插入新项,同时保留旧版本供其他事务可见。这是实现难点需要处理。

6.3 页锁与 Latch

B+树的结构修改(分裂、合并)会修改多个页面,需要按照顺序加写锁(latch)以防止读写不一致。结合 MVCC 后,索引遍历只需加读锁,数据元组页面的访问则完全依赖版本可见性,无需页锁争用,大大提高了并发度。

7. 并发控制与锁管理器

除了页锁,我们还需要事务级的锁管理器来实现写写冲突的检测。在 MVCC 配合快照隔离下,一般只保留写意图锁(Write Intent Lock)。当事务更新一行时,需在索引的 rid 上获取排他锁;如果该 rid 已被其他未提交事务锁定,则当前事务需等待或回滚。这一层面可以使用行级锁表或直接利用元组版本头部记录锁。

简化实现:每个元组头部增加 lock_txn_id,表示当前持有排他锁的事务 id。事务更新前先检查,若为 INVALID 则占用,否则检查其是否提交,决定等待。提交时释放锁。

8. 测试与验证

我们编写多线程测试场景:

  • 并发插入:多个事务插入不同键,验证 B+树结构不变性;
  • 并发读写:一个事务持续读取范围扫描,另一个事务更新多行,检查快照隔离(不可重复读防止);
  • 冲突场景:两个事务同时更新同一行,预期一个成功一个回滚(或等待);
  • 版本回收:模拟 vacuum,清理过期版本后验证数据正确性。

使用 Google Test 框架,所有测试通过后展示了引擎的稳定性。

cpp 复制代码
TEST(StorageEngine, SnapshotIsolation) {
    auto txn1 = begin_txn();
    auto txn2 = begin_txn();
    // txn1 插入一行并提交
    // txn2 在当前快照看不到新行
    ASSERT_FALSE(txn2->read(key).found);
    txn1->commit();
    // 事务 3 能看见
    auto txn3 = begin_txn();
    ASSERT_TRUE(txn3->read(key).found);
}

通过 C++ 从头构建了一个包含 B+树索引与 MVCC 的存储引擎内核,展示了索引与多版本并发控制的协同设计。核心思想是将索引与数据版本分离,B+树只维护逻辑行位置,MVCC 通过版本链实现无锁快照读。这一架构在实际数据库中被广泛采用。完整代码可在 GitHub 获取(链接待补充)。

读者可以在此基础上扩展范围分区索引、二级索引支持、持久化 WAL 以及更完善的垃圾回收,进一步加深对数据库内核的理解。

相关推荐
Lzg_na1 小时前
订阅发布模块事例
c++
麦聪聊数据1 小时前
企业数据市场建设(三):API 化服务封装,让数据开箱即用、避免重复开发
数据库
汉克老师2 小时前
2026年CSP-J初赛分数线-----预测
c++·csp-j·小学生·学c++编程
麦聪聊数据2 小时前
企业数据市场建设(四):流程闭环与价值运营,让数据市场真正转起来
运维·数据库
正儿八经的少年2 小时前
redis 的大 key 和热 key 详解
数据库·redis·缓存
AI砖家2 小时前
多智能体系统实战:架构设计、数据库表设计与 Skill 体系
数据库·多智能体·skill·agent架构设计·agengt
小保CPP2 小时前
OpenCV C++将多张图像合并为webp动图
c++·人工智能·opencv·计算机视觉
夜雪一千2 小时前
MySQL查询条件的顺序是否影响查询效率
数据库·mysql
X-⃢_⃢-X3 小时前
十、Redis之布隆过滤器
数据库·redis·缓存