[beatdb] 使用 Zig 构建的基于 S3 的 KV存储引擎

为什么要重新造一个 KV 引擎?

这个问题的答案很简单:因为现有方案在 S3 上都不够好。

传统 LSM 引擎(RocksDB、LevelDB)为本地 SSD 设计。它们假设磁盘延迟在微秒级,随机读写廉价。但当你把存储层换成 S3,一切都变了:

SlateDB 是一个认真对待这个问题的项目——一个 Rust 实现的、专门为对象存储设计的 LSM KV 引擎。它做对了很多事情:WAL 复用 SST 格式、manifest CAS 协议、size-tiered compaction。

BeatDB 是 SlateDB 的 Zig 重新实现。 为了更好的学习理解 SlateDB。

为什么是 Zig?

没有特别的原因, 想学习一下这门语言 测试零拷贝数据编码 SlateDB 用 FlatBuffers 做序列化,需要 schema 编译器、生成代码、运行时验证。BeatDB 用 Zig 的 extern struct 直接把内存布局映射到磁盘格式——编译期确定大小,零运行时开销,@ptrCast 一行完成反序列化: 这里是从 tigerbeetle 学习而来

pub const Footer = extern struct {
    block_count: u32,
    bloom_offset: u64,
    index_offset: u64,
    index_len: u32,
    data_size: u64,
    magic: u32 = 0xDB_BE_A7_00,
    comptime {
        std.debug.assert(@sizeOf(Footer) == 40);
    }
};

// 从文件末尾读取 footer:零拷贝
const footer: *const Footer = @ptrCast(@alignCast(
    bytes[bytes.len - @sizeOf(Footer)..].ptr
));

40 字节,编译期断言大小,没有序列化开销。TigerBeetle 用同样的模式处理 LSM 的所有 on-disk 结构,我们从中学到了这个技巧。

从头构建事件循环 (从bunjs学习从头构建一个事件循环)Zig 在 0.12 版本移除了 async/await。对象存储的 I/O 延迟在百毫秒级,async 的微秒级调度优势完全被淹没。BeatDB 用 std.Thread + Mutex + Condition 做并发,代码直白,调试简单,没有 tokio 那样的隐式状态机。

显式内存管理。 每个 allocator 都是参数传递的,没有全局状态。MemTable 的内存在启动时一次性预分配,flush 后游标归零复用,整个生命周期零碎片。

架构:从写入到查询

BeatDB 的数据流和 SlateDB 一致,但实现细节不同:

写入路径:
  put(k,v) → WAL Buffer → MemTable
                ↓ (flush)
           WAL SST (对象存储)
                ↓ (freeze + flush)
           L0 SST (对象存储)
                ↓ (compaction)
           Sorted Run (对象存储)

读取路径:
  get(k) → MemTable → IMM list → L0 SSTs → Sorted Runs

四个后台线程各司其职:

线程职责触发条件
WAL flushWAL buffer → WAL SSTbuffer 超过 64MB 或 100ms 定时
FlusherIMM → L0 SSTMemTable 冻结后
CompactorL0 + SR → 新 SRL0 数量 >= 4
GC删除过期文件定期运行

线程间通过 Mutex + Condition 协调。没有 channel,没有 actor,没有消息队列。共享状态通过 DbState 结构保护,临界区尽可能短。

双区 MemTable:为 S3 省钱的内存结构

这是 BeatDB 和 SlateDB 差异最大的地方。

SlateDB 用跳表做 MemTable。跳表的问题在 S3 场景下被放大:每个节点 32-64 字节的指针开销意味着 100MB 内存只能存不到 50MB 有效数据。MemTable 越快满,flush 越频繁,S3 PUT 请求越多,账单越贵。

BeatDB 借鉴 TigerBeetle 的 table_memory.zig,设计了双区架构:

┌─────────────────────────────────────────────┐
│  Index Array (固定 24B entries, 可排序)       │
│  [offset, key_len, val_len, seq, kind, pad] │
│  [offset, key_len, val_len, seq, kind, pad] │
│  ...                                        │
├─────────────────────────────────────────────┤
│  Data Arena (变长 KV payload, 只追加)         │
│  [key1][val1][key2][val2][key3][val3]...    │
└─────────────────────────────────────────────┘

写入是 O(1):追加数据到 Arena,追加索引到 Index Array。排序时只移动 24 字节的 IndexEntry,Data Arena 完全不动。

内存利用率:假设平均 key=32B, value=256B,100K 条记录:

同样的内存预算下,BeatDB 能多缓冲 12% 的数据再 flush,直接减少 S3 请求次数。

更妙的是自适应排序:如果你的 key 天然有序(时间戳、自增 ID),整个 MemTable 只有一个 sorted run,零排序开销。随机 key 才会触发后台归并排序。

S3 客户端:纯 Zig,零外部依赖

Zig 生态没有等价于 Rust object_store crate 的库。我们自建了完整的 S3 客户端栈:

┌─────────────────────────────────────┐
│  S3Store (实现 ObjectStore vtable)   │
├─────────────────────────────────────┤
│  S3Client (HTTP 请求 + 签名)         │
│    ├── AWS Signature V4             │
│    ├── 请求构建                      │
│    └── XML 响应解析                  │
├─────────────────────────────────────┤
│  std.http.Client (HTTP/1.1 + TLS)   │
└─────────────────────────────────────┘

AWS Signature V4 用 std.crypto.auth.hmac.sha2.HmacSha256 实现,没有 OpenSSL 依赖。Conditional PUT 用 If-None-Match: * 实现 manifest CAS——自 2024 年 8 月起 S3 原生支持这个语义,不再需要 DynamoDB 做锁。

覆盖 S3、Cloudflare R2、GCS(S3 兼容模式)。MinIO 和 LocalStack 用于本地测试。

这里的 std.http.Client 性能不太好, 需要重新实现

Manifest:乐观并发的数据库状态

Manifest 是 BeatDB 的”元数据数据库”,记录所有 L0 SST、Sorted Run、WAL 范围和 epoch。

写入协议很简单:文件名单调递增(manifest/00000000000000000001.manifest),用 put_if_absent(CAS)写入。如果返回 AlreadyExists,说明有并发更新,重新读取最新 manifest 后重试。

Writer 和 Compactor 各自维护 epoch。新 writer 启动时 bump writer_epoch,旧 writer 下次提交时发现 epoch 不匹配,自动退出。这就是 fencing——没有分布式锁,没有 ZooKeeper,只靠对象存储的 CAS 语义。

Compaction:优先降低读放大

对象存储场景下的放大因子优先级和本地磁盘完全不同:

放大类型对象存储影响优先级
写放大网络带宽,但云厂商通常不收费
读放大GET 请求昂贵,延迟高
空间放大S3 存储便宜($0.023/GB)

所以 BeatDB 的 compaction 策略优先控制 L0 SST 数量。V1 实现 size-tiered compaction:L0 数量达到 4 时触发,用 k-way merge 合并为 Sorted Run。当 L0 超过 8 个时,写入路径暂停(backpressure),等 compactor 追上来。

Compactor 运行在独立线程,通过 manifest CAS 与主线程协调。冲突时以最新 manifest 为基础重新应用 compaction 结果——乐观并发,不需要锁。

开发日志

BeatDB 的开发按周推进,每周一个主题:

主题关键产出
1核心数据结构RowEntry、Block、Bloom Filter、SST、MemTable、MergeIterator
2存储抽象层ObjectStore vtable、MemStore、FsStore、RetryingStore
3WALWAL SST 格式、WAL Buffer、崩溃恢复 Replay
4Manifest数据结构、版本化存储、Writer Fencing
5读写路径Db open/close、put/get/delete、scan、IMM flush
6Compaction状态机、Size-tiered 策略、Backpressure
7GC & CheckpointCheckpoint API、垃圾回收
8双区 MemTableRFC-0001 完整实现
9S3 SDKAWS SigV4、S3Client、S3Store

之后做了一轮 Gap Analysis,修复了 20 个对标 SlateDB/TigerBeetle 的问题:GC 集成、CRC32c 校验、zstd 压缩、Block Cache、SST 二分查找等。

如果你对用 Zig 构建存储系统感兴趣,或者想了解 LSM 引擎在对象存储上的设计取舍,欢迎阅读源码和设计文档。这不是一个玩具项目——它是一个认真思考过每个设计决策的工程实践。

参考资料

重新学习 C++