数据库、消息队列、时序系统、搜索索引,底层都站着一个存储引擎。它要解决的核心问题只有一个:如何在持久化介质上组织键值数据,使得读、写、范围扫描三类操作都在可接受的代价内完成。用 C++ 实现存储引擎是理解数据库内幕的最短路径,因为这里没有语言运行时的抽象泄漏,每一次内存拷贝、每一次系统调用都直接对应到性能数字。
一、两条技术路线
所有存储引擎都可以归入两种组织方式,它们的取舍贯穿整个设计。
| 路线 | 代表 | 写放大 | 读放大 | 适用场景 |
|---|---|---|---|---|
| 页式(B+ 树) | InnoDB、SQLite、PostgreSQL | 高(原地更新要写整页 + WAL) | 低(树高约 3 到 4 层) | 读多写少、事务型负载 |
| 日志结构(LSM-Tree) | LevelDB、RocksDB、Cassandra | 低(顺序追加) | 高(要查多层) | 写密集、追加型负载 |
页式的本质是原地更新:数据按固定大小的页(通常 4 KB 到 16 KB)组织成一棵平衡树,修改一个键就要读入整页、改内存、再写回。它把随机写变成了「读 - 改 - 写」,因此必须靠 WAL 先把变更日志顺序落盘来保证崩溃一致性。
LSM-Tree 的本质是顺序追加:所有写入先进内存表,攒够了一次性顺序刷成不可变的有序文件,后台再不断合并。它把随机写彻底转成顺序写,代价是读一个键可能要穿透多层文件。
选型时先问一句:这个负载是「一次写入、多次读取」,还是「持续高频写入」。前者用 B+ 树,后者用 LSM-Tree。
二、页布局与页缓存
2.1 页格式
存储引擎与文件系统之间隔着一层页管理器。页是 I/O 的最小单位,也是缓存的最小单位。
// page.h
#pragma once
#include <cstdint>
#include <cstddef>
#include <array>
constexpr std::size_t kPageSize = 4096;
// 页头:12 字节,控制信息与校验和分离,便于只更新校验字段
struct PageHeader {
std::uint16_t slot_count; // 槽位数量
std::uint16_t free_offset; // 空闲区起始偏移
std::uint16_t free_size; // 空闲区大小
std::uint16_t flags; // 页类型与状态位
std::uint32_t lsn; // 最后修改该页的日志序号
};
static_assert(sizeof(PageHeader) == 12, "页头必须是紧凑的 12 字节");
struct Page {
PageHeader header;
std::array<std::byte, kPageSize - sizeof(PageHeader)> data;
std::uint32_t checksum() const; // 通常用 CRC32C
};
页头里的 lsn 是 WAL 与页式引擎的接缝:恢复时用页的 lsn 与日志的 lsn 比较,决定这一页是否需要重放。
2.2 页缓存
页缓存(buffer pool)是内存中的哈希表加淘汰链表,fetch() 返回一个持有页锁的句柄,析构时自动归还并可能触发淘汰,mark_dirty() 记录脏页与对应的 lsn。
class BufferPool {
public:
explicit BufferPool(std::size_t capacity) : capacity_(capacity) {}
PageHandle fetch(PageId pid);
void mark_dirty(PageId pid, std::uint32_t lsn);
private:
std::size_t capacity_;
std::unordered_map<std::uint32_t, Frame> frames_;
std::list<std::uint32_t> lru_; // 淘汰顺序
std::mutex latch_;
};
命中率决定一切,因为一次缺页就是一次随机 I/O。经典做法是 CLOCK 或 LRU-K 淘汰,并对脏页做后台刷盘。页缓存不能无限增长:内存越大,checkpoint 时的刷盘尖峰越明显,恢复时间也越长。
三、B+ 树实现要点
3.1 节点格式
B+ 树与二叉搜索树的关键差异在两点:节点是页大小的多路分支,且只有叶子节点存数据,内部节点只存分隔键。
// btree_node.h
#pragma once
#include <cstdint>
#include <string_view>
// 叶子节点:按键有序排列的槽位数组
// [header][key0][val0][key1][val1] ... [slot_offsets]
struct LeafNode {
std::uint16_t count;
std::uint16_t next_leaf; // 叶子链表,用于范围扫描
std::uint16_t prev_leaf;
std::uint16_t slot[1]; // 柔性数组,存每项的偏移
};
// 内部节点:N 个键 + N+1 个子页号,布局为
// [header][child0][key0][child1][key1] ... [childN]
struct InternalNode {
std::uint16_t count;
std::uint16_t reserved;
std::uint32_t child[1];
};
键用 std::string_view 传递而不拷贝,比较时用 memcmp 而非 std::string,这一条在热路径上能省下可观的分配开销。
3.2 查找与分裂
查找是自上而下的二分:每个内部节点对分隔键做 std::upper_bound,决定走向哪个子页。真正的难点在插入导致的分裂。
// 分裂的通用逻辑:把一个满节点一分为二,返回提升到父节点的分隔键
struct SplitResult {
PageId right;
std::vector<std::byte> separator;
};
SplitResult split_leaf(LeafNode* leaf, std::size_t slot_bytes);
分裂策略直接影响空间利用率。「对半分裂」在插入有序键时会产生 50% 的空间浪费;「按字节均分」让两侧字节数尽量相等,更好。批量装载(bulk load)时可以预先把叶子填到接近满,再自底向上建内部节点,空间利用率能到 90% 以上。删除时不能立即合并节点,否则会出现「抖动」——删除一个键触发合并,插入一个键又触发分裂。工程上普遍采用延迟合并:节点下溢时先标记,只有当同一父节点的兄弟也欠载时才真正合并,其余情况靠再平衡解决。
3.3 范围扫描
B+ 树最不可替代的能力是有序遍历。叶子节点之间用双向链表连接,找到起点后顺序往后走即可,无需回到根节点。
class BTreeCursor {
public:
void seek(std::string_view key); // 定位到第一个 >= key 的位置
bool valid() const;
void next();
std::string_view key() const;
std::string_view value() const;
private:
std::vector<PageHandle> path_; // 从根到叶的路径,用于向上回溯
std::size_t slot_ = 0;
};
游标持有从根到叶的整条路径,这样 next() 跨页时可以沿着路径向上找到后继,而不必每次从根重新下降。
四、WAL 与崩溃恢复
页式引擎不能直接改磁盘上的页,否则写到一半断电就会留下半个页。WAL(Write-Ahead Logging)的原则是:任何页的修改,其日志必须先落盘。
// wal.h
#pragma once
#include <cstdint>
#include <span>
enum class WalRecordType : std::uint8_t {
kInsert = 1, kDelete = 2, kCommit = 3, kCheckpoint = 4,
};
struct WalRecordHeader {
std::uint32_t lsn;
std::uint32_t txn_id;
std::uint8_t type;
std::uint32_t page_id; // 受影响页
std::uint32_t length; // 负载长度
std::uint32_t crc32c; // 覆盖头部与负载
};
class WalWriter {
public:
std::uint32_t append(const WalRecordHeader& hdr, std::span<const std::byte> payload);
// 组提交:多个事务共用一次 fsync,是 WAL 吞吐的关键
void flush(bool sync);
};
flush(false) 只写进内核页缓存,flush(true) 才调用 fsync。这里的核心优化是组提交(group commit):把短时间内多个事务的日志攒在一起,用一次 fsync 落盘。单条日志一次 fsync 时吞吐被磁盘延迟锁死在几百 TPS,组提交能把它拉到几万 TPS。
恢复流程分三个阶段:
- 分析:从最近一次 checkpoint 的 LSN 开始扫描日志,重建脏页表与活动事务表。
- 重做:对每条日志,若目标页的
lsn小于日志lsn,则重放该修改(幂等)。 - 撤销:对未提交事务的日志做反向撤销,回滚它们已写下的修改。
判断一条日志是否已经反映到页上,靠的正是页头里的 lsn 字段——这也是为什么它必须和页一起原子落盘。
五、LSM-Tree 实现要点
5.1 MemTable 与跳表
LSM-Tree 的写入路径是:先写 WAL,再写内存表。内存表必须支持有序遍历与并发插入,跳表是标准答案。
// memtable.h
#pragma once
#include <atomic>
#include <optional>
#include <string>
#include <string_view>
// 跳表节点:层高随机,最高 12 层足以支撑千万级元素
struct SkipNode {
std::string key;
std::string value;
std::atomic<SkipNode*> next[12];
};
// MemTable 对外暴露 put / remove / get / approximate_size
// remove 写入墓碑标记,get 命中墓碑即返回空
删除不真的删数据,而是写入一个**墓碑(tombstone)**标记。因为旧值可能还躺在更底层的 SSTable 里,只有墓碑才能保证它不会在读取时「复活」。
5.2 SSTable 与布隆过滤器
内存表写满后整体刷成一个 SSTable(Sorted String Table)。它的结构是:数据块 + 索引块 + 布隆过滤器 + 尾部的元信息。数据块内按键有序,块之间用索引定位。
// sstable.h
#pragma once
#include <cstdint>
// 尾部元信息:指向索引块与布隆过滤器,magic 为 "SBT1"
struct SstFooter {
std::uint64_t index_offset;
std::uint64_t bloom_offset;
std::uint64_t data_size;
std::uint32_t magic;
};
布隆过滤器提供 add(key) 与 may_contain(key),内部是一排 std::uint64_t 位图加 k = bits_per_key * ln2 个哈希函数。它的价值在于把「确定不存在」的查询挡在磁盘 I/O 之前:10 bits/key 时误判率约 1%,能把点查的读放大降低一个数量级。每个键用双哈希技巧生成 k 个位置:h_i = h1 + i * h2。
5.3 Compaction
合并策略决定 LSM-Tree 的三种放大之间的平衡。主流有三代方案:
- Leveled(LevelDB/RocksDB 默认):每层容量按 10 倍递增,层内按键范围切分成多个文件,合并时只挑有重叠的文件。空间放大最小(约 1.1 倍),但写放大最大(总写放大可达 20 到 30 倍)。
- Tiered(Cassandra):同层允许多个文件有重叠,合并时一次归并整层。写放大最小,读放大与空间放大最大。
- Hybrid(RocksDB Universal):小层用 tiered,大层用 leveled,是当前的主流折中。
写放大是 LSM-Tree 最昂贵的代价。一个经验法则是:如果写入带宽的 80% 以上都被 compaction 吃掉,就该调大每层容量倍数,或者切换到 tiered 策略。工程实现上,一次 compaction 是一个 CompactionTask:记录层号、参与合并的输入文件号、输出文件号,以及是否为整层合并(major)。
六、三种放大的权衡
任何存储引擎都在读放大、写放大、空间放大之间做三角权衡,没有例外。
| 放大类型 | 定义 | B+ 树 | LSM-Tree |
|---|---|---|---|
| 读放大 | 一次逻辑读触发的物理读次数 | 约 1(树高 3 到 4 层,顶层常驻内存) | 3 到 10(逐层查找) |
| 写放大 | 一次逻辑写导致的物理写字节数 | 10 到 30(页 + WAL + 双写缓冲) | 10 到 30(compaction) |
| 空间放大 | 实际占用与逻辑数据量之比 | 约 1.3(页内碎片) | 1.1 到 2(多版本残留) |
调优的基本手法:
- 降读放大:加大布隆过滤器位数、把索引常驻内存、用
posix_fadvise预读。 - 降写放大:组提交、调整 compaction 触发阈值、SSD 上关掉双写缓冲(有风险)。
- 降空间放大:更激进的 compaction、定期做 full compaction、开启前缀压缩。
七、持久化原语
7.1 fsync 与 O_DIRECT
write() 返回成功只代表数据进了内核页缓存,fsync() 返回才代表数据落到介质。SSD 上单次 fsync 的延迟在 100 微秒到 1 毫秒之间,这是所有持久化系统的性能基线。
#include <fcntl.h>
#include <unistd.h>
// O_DIRECT 绕过页缓存,避免「写一次、缓存一次、再刷一次」的双重开销
int fd = ::open("data.sst", O_RDWR | O_CREAT | O_DIRECT, 0644);
// 约束:缓冲区地址、长度、偏移都要按块大小对齐,故需 posix_memalign 配对齐分配
void* p = nullptr;
::posix_memalign(&p, kPageSize, bytes);
多数引擎的选择是折中:写 WAL 用 O_DIRECT 保证顺序写的确定性,读数据走页缓存以利用局部性。
7.2 mmap 与 posix_fadvise
mmap 把文件映射进地址空间,读写就像访问内存,由内核负责缺页加载与回写,省掉了显式的 read/write 调用与一次拷贝;代价是缺页延迟不可控(SIGBUS 风险),且 OS 的淘汰策略不感知你的访问模式。用 ::madvise(base, size, MADV_RANDOM) 告诉内核这是随机访问,用 posix_fadvise(fd, 0, 0, POSIX_FADV_WILLNEED) 主动预读、POSIX_FADV_DONTNEED 提示内核丢弃不再需要的缓存——在扫描大表后立刻释放缓存能显著降低对其他查询的干扰。
八、并发控制
8.1 Latch Crabbing
B+ 树的并发插入需要同时持有多个页的锁。朴素做法是全程持有根节点的锁,把整棵树串行化。Latch Crabbing(螃蟹式加锁) 的做法是:先锁父节点,再锁子节点,确认子节点安全(不会分裂)后立刻释放父节点锁。
// 悲观插入:只在可能分裂时才继续持有祖先锁
void BTree::insert(std::string_view key, std::string_view value) {
auto* node = root_;
node->latch.lock();
while (!node->is_leaf()) {
auto* child = descend(node, key);
child->latch.lock();
if (child->is_safe_for_insert()) node->latch.unlock(); // 不满则放掉祖先
node = child;
}
insert_into_leaf(node, key, value);
node->latch.unlock();
}
优化的关键是乐观插入:绝大多数插入不会引发分裂,所以先用读锁走一遍,确认所有节点都安全后再拿写锁重做一遍。分裂是小概率事件,乐观路径覆盖了 99% 以上的情况。
并发读与 compaction 也会打架:读线程正在遍历一个 SSTable,后台却想把它合并删掉。解法是引用计数加延迟删除——文件在被打开时计数加一,compaction 只标记删除,等计数归零才真正 unlink。这正是 shared_ptr 在存储引擎里的经典用途。
相关阅读
- https://plumephp.com/cpp-performance-optimization/ — 缓存友好布局与分支预测的通用手法
- https://plumephp.com/cpp-memory-pool-allocators/ — 页缓存与对齐分配器的实现基础
- https://plumephp.com/cpp-concurrency-patterns/ — latch crabbing 背后的并发模式
延伸阅读
- https://plumephp.com/cpp-compilation-linking/ — 编译、链接与目标文件格式的基础
- https://plumephp.com/cpp-atomic-memory-order/ — 无锁跳表所需的内存序知识
- https://plumephp.com/cpp-serialization-libraries/ — SSTable 与 WAL 的序列化选型
文末完整示例
// 最小 KV 引擎骨架:WAL + MemTable + SSTable,可直接编译
// 编译:g++ -std=c++20 -O2 -o kvstore kvstore.cpp
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <string>
#include <string_view>
namespace kv {
constexpr std::size_t kMemFlushBytes = 4096 * 256;
// ---------- 1. MemTable:std::map 简化版(生产用跳表) ----------
struct Entry { std::string value; bool deleted = false; };
class MemTable {
public:
void put(std::string_view key, std::string_view value) {
table_[std::string(key)] = Entry{std::string(value), false};
bytes_ += key.size() + value.size();
}
void remove(std::string_view key) { table_[std::string(key)] = Entry{"", true}; }
std::optional<std::string> get(std::string_view key) const {
auto it = table_.find(std::string(key)); // 墓碑即视为不存在
if (it == table_.end() || it->second.deleted) return std::nullopt;
return it->second.value;
}
std::size_t size_bytes() const { return bytes_; }
const std::map<std::string, Entry>& table() const { return table_; }
private:
std::map<std::string, Entry> table_; // 有序,便于刷成 SSTable
std::size_t bytes_ = 0;
};
// ---------- 2. 引擎:写走 WAL -> MemTable,满则刷 SSTable ----------
class Engine {
public:
explicit Engine(std::filesystem::path dir)
: dir_(std::move(dir)), wal_(dir_ / "wal.log", std::ios::binary | std::ios::app) {
std::filesystem::create_directories(dir_);
}
void put(std::string_view key, std::string_view value) {
append_wal(key, value, false);
mem_.put(key, value);
if (mem_.size_bytes() > kMemFlushBytes) flush();
}
void remove(std::string_view key) {
append_wal(key, "", true);
mem_.remove(key);
}
std::optional<std::string> get(std::string_view key) const { return mem_.get(key); }
private:
void append_wal(std::string_view key, std::string_view value, bool deleted) {
auto klen = static_cast<std::uint32_t>(key.size());
auto vlen = static_cast<std::uint32_t>(value.size());
std::uint8_t flag = deleted ? 1 : 0;
wal_.write(reinterpret_cast<const char*>(&klen), sizeof(klen));
wal_.write(reinterpret_cast<const char*>(&vlen), sizeof(vlen));
wal_.write(reinterpret_cast<const char*>(&flag), sizeof(flag));
wal_.write(key.data(), static_cast<std::streamsize>(key.size()));
wal_.write(value.data(), static_cast<std::streamsize>(value.size()));
}
void flush() { // 整体顺序写出,键已有序
std::ofstream out(dir_ / ("sst_" + std::to_string(++sst_id_) + ".dat"),
std::ios::binary | std::ios::trunc);
for (const auto& [key, entry] : mem_.table()) {
auto klen = static_cast<std::uint32_t>(key.size());
auto vlen = static_cast<std::uint32_t>(entry.value.size());
out.write(reinterpret_cast<const char*>(&klen), sizeof(klen));
out.write(reinterpret_cast<const char*>(&vlen), sizeof(vlen));
out.write(key.data(), static_cast<std::streamsize>(key.size()));
out.write(entry.value.data(), static_cast<std::streamsize>(entry.value.size()));
}
wal_.flush(); // 组提交点:一次 flush 覆盖整批写入
mem_ = MemTable{};
}
std::filesystem::path dir_;
std::ofstream wal_;
MemTable mem_;
std::uint64_t sst_id_ = 0;
};
} // namespace kv
int main() {
kv::Engine engine("/tmp/kvstore_demo");
engine.put("user:1", "alice");
engine.put("user:2", "bob");
engine.remove("user:1");
assert(!engine.get("user:1"));
assert(engine.get("user:2").value() == "bob");
std::puts("kvstore demo ok");
return 0;
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。