这个问题的答案很简单:因为现有方案在 S3 上都不够好。
传统 LSM 引擎(RocksDB、LevelDB)为本地 SSD 设计。它们假设磁盘延迟在微秒级,随机读写廉价。但当你把存储层换成 S3,一切都变了:
SlateDB 是一个认真对待这个问题的项目——一个 Rust 实现的、专门为对象存储设计的 LSM KV 引擎。它做对了很多事情:WAL 复用 SST 格式、manifest CAS 协议、size-tiered compaction。
BeatDB 是 SlateDB 的 Zig 重新实现。 为了更好的学习理解 SlateDB。
没有特别的原因, 想学习一下这门语言 测试零拷贝数据编码 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 flush | WAL buffer → WAL SST | buffer 超过 64MB 或 100ms 定时 |
| Flusher | IMM → L0 SST | MemTable 冻结后 |
| Compactor | L0 + SR → 新 SR | L0 数量 >= 4 |
| GC | 删除过期文件 | 定期运行 |
线程间通过 Mutex + Condition 协调。没有 channel,没有 actor,没有消息队列。共享状态通过 DbState 结构保护,临界区尽可能短。
这是 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 才会触发后台归并排序。
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 是 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 语义。
对象存储场景下的放大因子优先级和本地磁盘完全不同:
| 放大类型 | 对象存储影响 | 优先级 |
|---|---|---|
| 写放大 | 网络带宽,但云厂商通常不收费 | 中 |
| 读放大 | 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 |
| 3 | WAL | WAL SST 格式、WAL Buffer、崩溃恢复 Replay |
| 4 | Manifest | 数据结构、版本化存储、Writer Fencing |
| 5 | 读写路径 | Db open/close、put/get/delete、scan、IMM flush |
| 6 | Compaction | 状态机、Size-tiered 策略、Backpressure |
| 7 | GC & Checkpoint | Checkpoint API、垃圾回收 |
| 8 | 双区 MemTable | RFC-0001 完整实现 |
| 9 | S3 SDK | AWS SigV4、S3Client、S3Store |
之后做了一轮 Gap Analysis,修复了 20 个对标 SlateDB/TigerBeetle 的问题:GC 集成、CRC32c 校验、zstd 压缩、Block Cache、SST 二分查找等。
如果你对用 Zig 构建存储系统感兴趣,或者想了解 LSM 引擎在对象存储上的设计取舍,欢迎阅读源码和设计文档。这不是一个玩具项目——它是一个认真思考过每个设计决策的工程实践。
LevelDB — Google 开源的 LSM KV 引擎,奠定了 LSM-Tree 工程实现的基线。BeatDB 的 SST 格式(data blocks → index block → bloom filter → footer)直接继承了 LevelDB 的 table format 设计。LevelDB 的 table_format.md 和 impl.md 是理解 LSM 引擎 on-disk 结构的最佳起点。
mini-lsm — Chi Zhang (skyzh) 的 Rust LSM 教程项目。如果你想从零理解 LSM-Tree 的每一层抽象——从 MemTable、SST 编码、Block Cache 到 Compaction 策略——这是目前最好的学习路径。BeatDB 的 Block 编码、MergeIterator 和 Compaction 状态机的设计都参考了 mini-lsm 的分层思路。推荐配合 mini-lsm tutorial book 阅读。
SlateDB — BeatDB 最直接的参考对象。SlateDB 是第一个为对象存储原生设计的 LSM 引擎(Rust 实现),它的核心洞察——WAL 复用 SST 格式、manifest CAS fencing、size-tiered compaction 优先控制读放大——直接影响了 BeatDB 的架构决策。BeatDB 在此基础上做了 Zig 层面的重新设计:用 extern struct 替代 FlatBuffers、用双区 MemTable 替代跳表、用线程模型替代 async runtime。详见 SlateDB 官网 和 设计文档。
TigerBeetle — 用 Zig 构建的高性能金融交易数据库。BeatDB 的双区 MemTable 直接借鉴了 TigerBeetle 的 table_memory.zig 设计(Index Array + Data Arena),extern struct 零拷贝序列化模式也来自 TigerBeetle 的实践。它证明了 Zig 在系统级存储引擎中的可行性——显式内存管理、编译期断言、无隐藏控制流。
Bun — 用 Zig 构建的 JavaScript 运行时。Bun 是目前最大规模的 Zig 生产项目之一,它在 HTTP 客户端、文件 I/O、内存分配器等方面的工程实践为 BeatDB 提供了参考。特别是 Bun 对 std.http.Client 和 TLS 栈的使用经验,直接帮助了 BeatDB 的 S3 客户端实现。 ENDOFREF