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 可见,当且仅当:
- 版本
begin_ts≤T.start_ts - 版本
end_ts>T.start_ts或者end_ts == INF - 创建该版本的事务
txn_id不等于 T 自身(避免读到自己的未提交修改?这里需要更精细处理)
我们使用时间戳来模拟全局单调递增的 Hybrid Logical Clock,简化实现。
5.3 写操作流程
当事务 T 要更新一行:
- 通过 B+树找到最新版本元组 rid;
- 根据可见性遍历版本链表,找到可见版本;
- 基于可见版本创建新版本(copy-on-write),新版本
begin_ts=T.start_ts,end_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 以及更完善的垃圾回收,进一步加深对数据库内核的理解。