2.4 批量聚合:攒批与定时刷
日志一条一条写下游,与一筐一筐写下游,吞吐能差一个数量级。聚合层最重要的动作就是攒批:把零散的日志在内存里拢成一批,凑够数量或时间就整体刷下去。本篇讲攒批的两个触发条件怎么配合、批大小怎么定、攒批叠加上一节的背压会发生什么,以及攒批期间的内存安全。
两个触发条件:数量与时间
攒批有两个触发器,缺一不可:
| 触发器 | 语义 | 优点 | 单独用的后果 |
|---|---|---|---|
| 数量到(count) | 攒够 N 条就刷 | 高峰吞吐高 | 低谷时无限等待 |
| 时间到(timer) | 每 T 毫秒刷一次 | 延迟有上限 | 高峰期小批频繁刷 |
两个触发器的参数组合与业务类型有关,拿「写日志为了什么」反推最有效:
| 日志用途 | 延迟要求 | 建议组合 |
|---|---|---|
| 在线排查 ERROR | 秒级 | 批 500 / 100ms |
| 行为审计 | 分钟级 | 批 5000 / 1s |
| 离线分析 | 小时级 | 批 50000 / 5s |
注意「ERROR 级另走快通道」这件事如果用同一套攒批参数是做不到的:快通道要小批短间隔,慢通道要大批长间隔,两套参数必然是两条独立的攒批路径,在汇聚层就按级别分流,而不是等到攒批里再判断。分级的实现要点与 2.3 的分级通道完全一致。
单条路径上的双触发逻辑,tokio 里典型写法是 tokio::select! 同时等通道消息与定时器:
loop {
tokio::select! {
maybe = rx.recv() => match maybe {
Some(line) => batch.push(line),
None => { flush(&mut batch).await; break; }
},
_ = interval.tick() => flush(&mut batch).await,
}
if batch.len() >= max_batch {
flush(&mut batch).await;
}
}
注意 select! 分支顺序的细节:recv 与 tick 谁先就绪不可预测,但批满的判断放在 select 外面用同步 if 检查,避免「 Victor 分支饥饿」——既有定时器又有满批时,保证满批立即刷、不被定时器分支抢占。
批大小怎么定
批大小是吞吐与延迟之间的旋钮,方向如下:
| 批大小 | 吞吐 | 延迟 | 内存 |
|---|---|---|---|
| 100 | 中 | 极低 | 小 |
| 1,000~5,000 | 高 | 低 | 中 |
| 50,000+ | 略高 | 明显 | 大 |
批从 100 到 2000 吞吐提升最陡(单次写入的固定开销被摊薄),2000 之后曲线平缓,到了几万反而因为单批内存拷贝与压缩耗时上升出现回落。起点建议 2000~5000,配合定时刷间隔 100~500 毫秒。批大小与通道容量联动:批大小 ≥ 通道容量时攒批永远等不满,定时器成了唯一触发,等于自废武功;批大小 ≈ 容量的 1/2 到 2/3 时,数量触发与时间触发在高峰期各占一半,最健康。
攒批与背压的交互
背压章节强调过「传导链不能有隐形缓冲」,攒批内存就是最大的嫌疑犯。攒批缓冲在逻辑上是下游写入的一部分,不是上游缓冲,但物理上它在内存里。判断它算不算「隐形」的标准只有一个:它有没有被计入背压。
| 做法 | 是否隐形 | 说明 |
|---|---|---|
| 攒批有上限,满了触发背压 | 否 | 健康 |
| 攒批无上限,只等数量/时间 | 是 | 高峰期内存涨爆 |
| 攒批上限但丢弃计满 | 半隐形 | 内存安全但会丢 |
攒批内存上限要算清楚:批上限 × 平均行长 × 并发批数。平均行 1KB、批 5000、并发 4 路就是 20MB,这个数字要写进容量规划表,而不是上线后看 RSS 涨上去才想起来。内存与容量的系统化估算在同系列 Rust 相关章节 的容量规划里有完整清单,这里先抓住「批有上限」这一条。
批的完整性约束
积一批刷下去,下游(比如压缩与对象存储)往往把一批写成一个对象。这带来一个完整性问题:一批要么全到,要么全不到。写入失败时整批重试,不能只重试半批——半批重试会造成对象内容撕裂。重试以批为单位,与 Java 云原生订单实战 的批量消息原子性要求完全一致。
| 场景 | 重试单位 | 风险 |
|---|---|---|
| 单条重试 | 条 | 对象撕裂 |
| 整批重试 | 批 | 重复(需幂等) |
| 整批重试+幂等标记 | 批 | 稳 |
整批重试必然带来「重复一批」的可能,所以批对象要带序号或时间范围做幂等标识,下游按标识去重。这件事在 4.4 的崩溃恢复里会再接上。
定时刷的精度陷阱
「每 100 毫秒刷一次」听起来精确,实现时有三个坑。第一,interval.tick() 在 Reactor 压力下会漂移,高峰期定时器可能延迟到 200 甚至 500 毫秒才触发——延迟兜底失效。第二,tokio 定时器最小区间默认约 1 毫秒级,但调度精度取决于运行时负载,别指望微秒级。第三,刷盘操作必须用 spawn_blocking 或专用线程,直接在 select 循环里 await 一个慢 IO,会把 recv 与 tick 全部卡住,背压传导链从这里断裂。
| 坑 | 症状 | 解法 |
|---|---|---|
| 定时器漂移 | 低谷延迟超标 | 记录上次刷时间,超时强刷 |
| 慢 IO 阻塞 select | 背压传导断 | spawn_blocking |
| 定时器精度错觉 | 预期偏差 | SLA 按实测定 |
攒批调参实录
沿用 2.3 的服务(日均 8000 万条,高峰 12 万条/秒)看攒批怎么调。初始参数是「批 500、间隔 1 秒」,观测到下游对象存储每秒约 240 次小写入,延迟均值 1.2 秒——典型的「高频小批」病。三步调整如下:
| 轮次 | 参数 | 对象写入 QPS | p99 延迟 | 说明 |
|---|---|---|---|---|
| 初始 | 批 500 / 1s | 240 | 1.8s | 小批刷不停 |
| 第一轮 | 批 5000 / 200ms | 24 | 320ms | 吞吐与延迟双优 |
| 第二轮 | 批 20000 / 200ms | 6 | 280ms | 边际收益变小 |
第一轮收益最大:写入次数降 10 倍,延迟降 4 倍,内存占用只多了几 MB。第二论批翻 4 倍,延迟仅再降 40ms,但单批内存翻到 20MB 级——典型的收益递减。教训:批大小调一次容易过,看边际收益不值得为小收益冒内存风险。这条曲线在实践中反复出现:从 500 到 5000 是免费的午餐,从 5000 再往上每翻一倍只换回两位数毫秒。这个调参过程与 Node.js 云开发实战 里批量写入服务的调参节如出一辙,方法论可以互相借鉴。
踩坑提示
最常见的坑是定时刷的间隔按「直觉」定成 1 秒甚至 10 秒:10 秒刷一次意味着平均延迟 5 秒、峰值 10 秒,对在线排查简直是灾难。第二个坑是批内日志跨了时间范围边界:一批里混了 23:59 与 0:00 两天的日志,滚动归档按时间切对象时这批不知归哪天。解法是刷批前按时间戳再切一刀,跨天批拆两批。第三个坑是进程退出时忘了刷尾批:SIGTERM 直接退出,内存里最后一批丢掉,优雅关闭要与 2.2 的「停源、排空、刷尾」顺序联动。判据很简单——进程每次正常退出时,刷批计数器应该恰好等于退出日志里记录的未刷批数,不等就是漏刷。退出钩子里务必先做一次无条件 flush 再等待收尾,把「最后一批」当成正常批次对待,而不是特例。
小结
攒批必须数量与时间双触发,批大小起点 2000~5000,间隔 100~500 毫秒,与通道容量保持 1/2 到 2/3 的关系。批内存有上限并计入背压,以批为单位重试并带幂等标识,刷盘交给独立线程。攒批是管道从「能跑」到「跑得快又稳」的第一个大杠杆,调好了下游存储的压力会肉眼可见地平滑。
延伸阅读
- 通道容量与多源汇聚,见 2.2。
- 背压的触发与传导,见 2.3。
- 批写到对象存储后的滚动归档,见 3.4。
- 批量与原子性的服务端案例,见 Java 云原生订单实战。
