4.1 RocksDB 分段:SST 与 WAL
前两篇把日志收齐、压好、采完样,到了落盘这一步,最初直脑筋的做法是按小时建文件、攒一批写一批。跑了两天就出事:高峰期写入抖动成锯齿,崩溃时最后一批日志无影无踪,按业务线检索要 grep 一堆碎文件。落盘需要一个既写得快、崩了又能找回的存储层,我们选了 RocksDB。
分段键怎么选
分段的第一步不是选技术方案,而是选分段键。可选的维度有三个:业务线(app)、时间(小时或天)、租户(tenant)。我们踩过选错的亏:最早按时间分段,每小时一个列族,以为这样归档方便,结果是同一条业务线的日志被切碎到几十个列族里,按业务检索时要把所有段都扫一遍,compaction 也被切成大量小文件,写放大严重。改成按业务线分段后,归档时按列族导出即可,检索按业务线也能直接定位到少数几个段。
| 分段键 | 检索友好 | 归档友好 | 段数膨胀 |
|---|---|---|---|
| 业务线 | 好(按业务定位) | 好(整段导出) | 稳定 |
| 时间 | 差(要跨段扫) | 好(直接切) | 每段一份 |
| 租户 | 好 | 一般 | 随客户数涨 |
经验是把高频检索维度作为分段键,归档需求用「段内按时间排序再加层索引」满足。一个段里键的排列是 app:timestamp:seq,前缀固定段,中段排序时间,检索与归档两头都不耽误。多维度检索干脆交给上游的日志服务 CLS 去做,本地存储只保最快的写入路径,分工明确。检索层的设计可对照 Node.js 实战:云端看板 的索引分片思路,是同一类取舍。
为什么选 RocksDB
裸文件追加写最快,但它不给你按 key 查的能力,崩溃恢复也只能靠回扫整个文件。RocksDB 是嵌入式 KV 底座,采用 LSM(Log-Structured Merge)树结构,写操作先入内存、顺序落盘,写入吞吐天然贴合日志这种只追加不修改的场景。它同时给出三件日志管道最需要的东西:memtable 写缓冲、WAL(Write-Ahead Log,预写日志) 保证崩溃不丢、SST(Sorted String Table) 排好序方便检索与归档。
| 方案 | 写入吞吐 | 崩溃恢复 | 按 key 检索 | 运维复杂度 |
|---|---|---|---|---|
| 裸文件追加 | 最高 | 差(回扫) | 差 | 低 |
| SQLite | 中 | 好 | 好 | 中 |
| RocksDB | 高 | 好(WAL 重放) | 好(LSM) | 中高 |
存储选型在别的系统里也是同一道题,分库分表的分段思路可对照 MySQL 分片实战,LSM 与 B 树的取舍在那里同样成立。
分段策略:分库还是分列族
日志管道的分段指按维度把日志拆开存,我们的维度是业务线,每条日志带 app 字段,落入对应段。RocksDB 给两种分法:多个 DB 实例(各自一个目录),或单 DB 多列族(Column Family)。经验结论是段数少于 16 用列族,超过就分库。列族共享一组后台线程,省资源;分库彼此隔离,一个业务线写炸了不拖累别人,崩溃恢复也只需重放受损的那个库。
| 维度 | 多列族 | 多 DB 实例 |
|---|---|---|
| 资源占用 | 共享后台线程 | 各开一套 |
| 隔离性 | 弱(共享 compaction) | 强 |
| WAL | 默认共享 | 各自独立 |
| 适合段数 | ≤16 | >16 |
打开一个分段的库
打开 DB 时把关键参数一次给齐,事后调参等于搬家:
use rocksdb::{DB, Options, ColumnFamilyDescriptor};
let mut opts = Options::default();
opts.create_if_missing(true);
opts.create_missing_column_families(true);
// memtable 撑到 256MB 就刷盘
opts.set_write_buffer_size(256 * 1024 * 1024);
// 最多同时挂 4 个 memtable,超了写入会 stall
opts.set_max_write_buffer_number(4);
// level0 攒到 4 个 SST 就触发合并
opts.set_level_zero_file_num_compaction_trigger(4);
let apps = ["order", "pay", "search"];
let cfs: Vec<_> = apps.iter().map(|a| {
let mut cf = Options::default();
// 量大的业务线单独给大缓冲
cf.set_write_buffer_size(128 * 1024 * 1024);
ColumnFamilyDescriptor::new(*a, cf)
}).collect();
let db = DB::open_cf_descriptors(&opts, "/data/logpipe/db", cfs)?;
每个列族单独配写缓冲大小,让量大的业务线多攒一点、量小的尽快落盘,这一招在高峰期尤其管用。
SST 与 WAL 的分工
一次写入在 RocksDB 内部的旅程是:先追加写 WAL(顺序写磁盘,保证崩溃可重放),再写内存 memtable;memtable 满了转为不可变,刷成 level0 的 SST 文件;后台 compaction 把 SST 逐层往下合并。WAL 保不丢,SST 保好查,两者分工明确。
| 组件 | 存哪 | 作用 | 生命周期 |
|---|---|---|---|
| WAL | 磁盘顺序文件 | 崩溃后重放 | 对应 memtable 刷盘后可删 |
| memtable | 内存跳表 | 攒写缓冲 | 满了刷成 SST |
| SST | 磁盘有序文件 | 检索与归档 | compaction 逐层合并 |
WAL 有个诱惑人的开关:manual_wal_flush 之类参数让人以为可以把 WAL 关掉省 IO——日志管道绝不能关 WAL,关掉等于把崩溃恢复丢进垃圾桶。WAL 多久 fsync 一次,是不丢与吞吐之间最直接的旋钮,4.4 会展开讲。
WAL 的目录也有讲究:默认与 SST 同目录,共享同一份 IO。把 WAL 独立到一块盘上,刷 WAL 与 compaction 各走各的磁臂,高峰期写入平滑很多。尤其是日志这种写多读少的负载,WAL 是持续的顺序写流,单独一块盘还能避开 compaction 的随机读抢带宽。
SST 的分层与归档衔接
SST 按 level 分层存放:level0 是 memtable 直接刷下来的小文件,允许有重叠的 key 范围,查询要全部扫一遍;level1 往上是 compaction 合并出来的、key 范围互不重叠的大文件。层数越深,单文件越大,查询要读的文件越少。日志管道对 SST 还有一个额外用法:level0 的 SST 天然是按时间段切好的数据块,直接复制到对象存储就是一份现成的归档,比攒一批再导出省事。
| 层级 | 单文件大小 | key 范围 | 查询代价 |
|---|---|---|---|
| level0 | 较小 | 可能重叠 | 全部都要扫 |
| level1+ | 越深越大 | 不重叠 | 按范围定位 |
归档用这个手法要注意时间边界:level0 文件是滚动产生的,归档前要对齐到整点或整小时,否则归档块的时间范围稀碎,检索时拼不回来。
踩坑提示
第一个坑是列族开太多:曾为每个租户建一个列族,上线 200 个租户后,memtable 内存占用按列族数线性涨,机器 OOM;共享的 compaction 队列也排成长队,写入延迟全线升高。解法是把租户按哈希分桶到固定 16 个列族。第二个坑是 WAL 与 SST 同盘:刷盘与 compaction 抢同一根磁臂,高峰期写入抖动剧烈,分到两块盘后立刻平滑。第三个坑是目录权限:DB 目录会创建多层,部署时少给一级权限,启动报错信息非常有迷惑性。
compaction 的 IO 尖刺也会反过来抢业务进程的资源,限速可以削峰:
| 参数 | 建议值 | 作用 |
|---|---|---|
| max_background_compactions | 4 | 合并线程上限 |
| rate_limiter | 1GB/s | 压制 compaction 吞吐 |
| compaction_readahead_size | 2MB | 机械盘建议开 |
这些数字的最优值完全取决于存储介质,NVMe 与机械盘是两个世界,云盘规格与吞吐的关系可参考 腾讯云 AI 数据处理平台 ADP 特惠。
小结
RocksDB 用 WAL 换不丢、memtable 换吞吐、SST 换检索。分段先定维度,再按 16 条线决定共享列族还是分库隔离。写缓冲与 compaction 参数是落地后第一批要调的旋钮,下一节从写入路径把缓冲机制讲透。
延伸阅读
- 写缓冲的完整路径,见下一节 4.2。
- WAL 重放与 exactly-once,见 4.4。
- 分库分表对照,见 MySQL 分片实战。
- 后台任务治理,见 Go 微服务实战。
