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 微服务实战。