4.2 写入路径:内存写缓冲
上一节定好了存储底座,本节顺着一条日志走完它在内存里的完整写入路径。日志管道的吞吐瓶颈很少在磁盘,更多在「内存里怎么攒、什么时候刷、满了怎么办」。攒得好,磁盘顺序写的优势才发挥得出来;攒得不好,要么写放大严重、要么内存被撑爆。
一条日志的内存之旅
日志从通道出来到落盘,中间经过三道缓冲,每一道都有满的可能:
| 阶段 | 位置 | 满了的表现 | 应对 |
|---|---|---|---|
| 攒批缓冲 | 业务层 Vec | 批太小,IO 次数多 | 按条数或时间触发 |
| memtable | RocksDB 内存 | 延迟升高 | 刷盘,最多挂 N 个 |
| 通道 | mpsc 有界队列 | 上游被背压 | 让上游等 |
三道缓冲串起来是一条很长的控制链,任何一道卡住,整条管道的吞吐都被压到那一道的水平。设计原则只有一条:把下游的满往上游传,而不是自己吞掉。这与第二篇讲的有界通道背压是同一个心法,只是作用点移到了落盘这一侧,想重温通道一侧的手法可对照 Go 微服务实战 的背压章节。
三道缓冲的容量还要逐级递减:通道最大、攒批次之、memtable 的单次写入最小。理由是越靠近磁盘,处理速度越慢,缓冲越小,抖动传到上游时上游积压的空间越大,整条链反而越稳。反过来如果 memtable 无限大、通道只有几百条,通道动不动就背压,吞吐永远上不去。
攒批:多少条写一次
单条 write 一次 WAL fsync,吞吐会被 fsync 吃光。正确姿势是用 WriteBatch 把一批日志打包成一次原子写入:
let mut batch = WriteBatch::default();
for rec in drain_rx_until(&mut rx, 2000, Duration::from_millis(50)) {
let key = format!("{}:{}", rec.app, rec.seq);
batch.put(key.as_bytes(), &rec.payload)?;
}
db.write_without_wal(&batch)?; // WAL 由批量统一保证
攒批触发条件是「条数或时间,谁先到」:条数到了立刻写,超时 50 毫秒也强制写一次,保证低延迟场景下日志不会在内存里躺太久。这两个数字是典型的双旋钮:
| 触发条件 | 设大 | 设小 |
|---|---|---|
| 条数阈值 | 高吞吐、IO 次数少 | 延迟低、内存占用小 |
| 超时时间 | 攒得更满 | 日志落地更快 |
日志量级与延迟敏感度不同,这两个数没有通解,只能按业务调,5.1 压测会给出一组实测对照。
drain_rx_until 的实现要点是非阻塞抽干:用 try_recv 把通道里现成的日志一次性抽完,而不是一条一条 await,这样在攒批循环里不会因为等下一条而空转。抽到条数上限或者超时立刻返回,交给写入线程落盘。高峰期通道里往往随时有上千条待写,一次抽干直接凑满一批,超时条件几乎不会触发;夜间流量稀疏时则由超时条件兜底,保证日志不会在内存里过夜。
memtable 满了会怎样
memtable 撑到 write_buffer_size 就转为不可变,后台刷成 SST。如果刷盘速度跟不上写入,不可变 memtable 会排队,挂到 max_write_buffer_number 个时,RocksDB stall(停顿)写入:write 调用直接返回错误或阻塞。这是 RocksDB 的自我保护,但对我们来说是管道卡顿的直接症状。
| 现象 | 含义 | 误判 |
|---|---|---|
| 写入变慢 | memtable 在排队 | 常被当成磁盘慢 |
| write 报错 | stall 生效 | 常被当成 bug |
| 延迟尖刺 | 刚好在刷盘 | 常被当成网络抖动 |
排查 stall 的固定顺序是:先看磁盘 IO 队列长度,再看 write_buffer_size 是否过小(刷盘过于频繁),最后看 compaction 是否积压。三个指标对应三种修法,混着调只会越调越糟。
两级缓冲的联动
业务层攒批与 RocksDB 的 memtable 是两级缓冲,容量要匹配:攒批大小 × 并发度 ≈ memtable 的一部分。若业务层一次攒 8MB、并发 8 路,单批就是 64MB,直接把 64MB 的 memtable 一次填满,刷盘根本来不及, stall 就成了家常便饭。我们的经验是让单批写入不超过 memtable 的四分之一,给刷盘留出呼吸空间。
| 配置 | memtable | 单批上限 | 效果 |
|---|---|---|---|
| 夜间低峰 | 64MB | 16MB | 省内存 |
| 白天正常 | 256MB | 64MB | 平衡 |
| 大促高峰 | 512MB × 4 个 | 128MB | 防 stall |
memtable 个数也不能只开 1 个:刷盘期间写入会落到新 memtable,只留 1 个等于刷盘时没有缓冲可用。机器规格与磁盘吞吐决定上面这组数字怎么配,存储侧的部署选型可参考 腾讯云 AI 数据处理平台 ADP 特惠。
刷盘周期的双保险
攒批的时间触发条件,本质就是给日志一个「最迟落地时间」的承诺。但只靠超时会漏掉一种情况:流量突然降到接近零,攒批永远凑不满条数,超时又还没到,最后几条日志就悬在内存里。双保险的做法是超时 + 优雅退出强制刷:进程收到退出信号时,把攒批缓冲里剩下的不管多少都提交一次,再加上停止通道消费、等 memtable 刷完,这就是 4.3 热更新与重启能不丢数据的底气。
| 保障 | 触发 | 防什么 |
|---|---|---|
| 条数触发 | 攒够 N 条 | 低延迟、高吞吐 |
| 超时触发 | 到 50ms | 空闲期日志悬空 |
| 退出强刷 | SIGTERM/SIGINT | 停机丢最后一批 |
注意 SIGKILL 是没法挡的,强杀进程的最后一批只能交给 WAL 重放去救,这也是 4.4 存在的意义。
背压传导的实测
把背压链路打通后,我们做过一次故障注入:下游磁盘限速到十分之一,观察整条管道的反应。理想状态是延迟从毫秒级升到秒级、通道开始积压、告警触发、采样自动降级,全程不丢日志不崩进程。第一次注入时发现采集层在通道满后 CPU 降到零、但通道长度还在涨——原来是还有一处直连写入绕过了有界通道,背压链上开了一个后门。这种全链路演练比看监控更有说服力,注入法可参考 PHP 接口实战 里的降级演练思路,限速与熔断的通用手法可对照 Go 微服务实战 的服务治理章节。
演练之后还要把结果固化成两条防线:一是通道深度告警,积压超过容量的八成就触发,给故障切换留出时间窗;二是自动降级开关,由 4.3 的热更新通道下发,高峰期把采样率从全量切到哈希采样,用降低保真度换管道存活。两条防线都依赖于背压链路本身是通的——如果积压数据根本传不到监控侧,再灵敏的告警也是瞎子。
踩坑提示
最隐蔽的坑是背压断在攒批这一环:攒批循环里只顾自己写,通道满了不知道,上游被 send 阻塞、自己却空转。解法是攒批的 recv 用超时版,超时即便没攒够也强制提交。第二个坑是缓冲无上限:高峰期通道积压几万条,业务层 Vec 悉数收下,内存直接翻几倍。攒批内部也要有硬上限,超过就分批写。第三个坑是 WriteBatch 过大:单个 batch 几百 MB 会把 WAL 写放大到离谱,还占住写锁,拆成多个中等批次更稳。
还有一个容易被忽略的细节:memtable 的内存是每列族各算一份的,列族多的时候总缓冲占用是乘积关系,别只看单个配置觉得不多。我们曾同时开了 12 个列族、每个 256MB,加起来就是 3GB 的 memtable 常驻,内存预算没留够直接吃紧。算内存预算的正确姿势是「每列族缓冲 × 列族数 × memtable 个数」,三个因子一个都不能漏。
最后一个坑在监控侧:写缓冲的积压如果不暴露指标,等到进程 OOM 才发现。至少要导出三个数——攒批等待时长、单批字节数、通道深度,它们是写入路径的三张体检表。
小结
写入路径三级缓冲、两级攒批:业务层按条数或时间攒成 batch,memtable 再往下接,单批不超过 memtable 四分之一,背压一路往上传而不是吞掉。memtable 排队导致的 stall 是管道卡顿的第一嫌疑人,看懂它的三层现象,调参才有方向。下一节讲怎么在不重启的前提下改掉这些参数。
延伸阅读
- 通道背压与 stall 的联动,见上一节 4.1。
- 压测怎么定攒批数字,见 5.1。
- 积压告警的监控手法,见 5.2。
- 其他系统的攒批实践,见 Go 即时通讯实战。
