1.1 需求拆解:吞吐、低延迟、不丢
日志管道的需求常被一句话带过——「把日志收上来」。但拆开看,它是三条互相牵制的线:吞吐、低延迟、不丢。三条线方向不同:吞吐想攒大批慢慢处理,低延迟想立刻就走,不丢想每条都确认——三者天然打架。本篇把三条线拆到模块级,给后面的选型和实现定调。
三条线各自牵动什么
| 需求 | 表面含义 | 真正牵动的模块 | 不满足会怎样 |
|---|---|---|---|
| 吞吐 | 每秒处理多少条 | 攒批、压缩、顺序写 | 高峰期积压,磁盘被写爆 |
| 低延迟 | 日志多久能被检索到 | 通道缓冲、刷盘策略 | 排障时日志还没到,等于没有 |
| 不丢 | 进程崩了日志还在吗 | WAL、确认机制、落盘 | 故障现场丢失,无从复盘 |
三条线里「吞吐」最容易引起注意,「低延迟」常被默认达成,「不丢」最容易被彻底忽略——很多管道直到第一次崩溃重启才发现丢了一大段。日志这种数据很特殊:写的时候不觉得珍贵,丢的时候才知道是唯一现场。
吞吐是「批发」逻辑
吞吐的核心不是「写得快」,而是攒批:单条写一次磁盘,开销全花在寻道上;攒成一批一次写,单条摊销成本能降一个数量级。所以高吞吐管道的骨架几乎都是「内存攒批 → 定时或定量刷盘」,这决定了模块里必须有攒批器和刷盘策略两个部件。
| 攒批维度 | 常见触发 | 取舍 |
|---|---|---|
| 按条数 | 攒满 N 条 | 吞吐稳,低谷期延迟高 |
| 按时间 | 每隔 T 毫秒 | 延迟可控,吞吐可能碎 |
| 按字节 | 攒满 M KB | 贴合磁盘块大小 |
| 混合 | 条数与时间先到为准 | 实战主流 |
批越大吞吐越高,但批越大意味着崩溃时丢得越多——这就和不丢需求直接冲突,必须靠 WAL 之类的手段补,详见 4.4。
低延迟是「零售」逻辑
低延迟要求日志「尽快可见」。它和吞吐的冲突在于:批还没攒满,要不要先发?答案是把「攒满才发」改成「满了或超时就发」,用时间维度兜底延迟。此外还要注意内存队列长度:队列越长吞吐越平滑,但端到端延迟被队列长度乘以单条处理时间拉长,监控时要把队列积压当作延迟的前置指标看。
不丢是「记账」逻辑
「不丢」的本质是每一笔都有下落:进程崩溃前,已确认的日志必须已在磁盘上。常见实现是 WAL(预写日志):日志先追加写一份 WAL 再进内存批,崩溃后重放 WAL 恢复。代价是每条多一次顺序写——顺序写很快,但仍是一笔开销。
| 机制 | 保证什么 | 代价 |
|---|---|---|
| 无 WAL | 什么都不保证 | 最快 |
| 内存队列 | 延迟平滑 | 崩溃全丢 |
| WAL | 已写日志不丢 | 每条多一次顺序写 |
| 副本双写 | 跨机器不丢 | 网络与存储翻倍 |
选择哪一档取决于业务:开发测试环境无 WAL 无所谓,生产排障现场必须 WAL,金融或计费日志才上副本双写。
三条线的优先级
| 优先级 | 需求 | 理由 | 典型指标 |
|---|---|---|---|
| P0 | 不丢 | 日志是故障现场 | 崩溃后 0 丢失 |
| P0 | 吞吐 | 高峰不能积压打爆 | 峰值 N 万条/秒 |
| P1 | 低延迟 | 可检索才有价值 | P95 < 2 秒 |
优先级定下来,资源就往 P0 倾斜:先保证 WAL 与背压,再把攒批做大冲吞吐,最后用定时刷盘收延迟。三条线不是同时要满分,而是各自有下限:不丢是底线,吞吐看峰值,延迟看 P95。这三条线的拆解方式与 Go 微服务实战 里的可观测性章节思路一致,感兴趣可对照。
量化三条线:一张需求矩阵
拆完原则,还要落到数字上,否则「三条线有下限」仍然是一句口号。建议按下面的矩阵,根据自己业务的真实画像填一遍。示例是一家中型电商的系统日志:峰值发生在秒杀和大促,平时吞吐只有峰值的十分之一,排障对延迟敏感(出问题就想看日志),日志用于故障复盘所以一条都不能丢。
| 指标 | 平时 | 峰值 | 定档依据 |
|---|---|---|---|
| 吞吐 | 2 千条/秒 | 5 万条/秒 | 大促历史峰值 × 2 |
| 延迟 P95 | < 1 秒 | < 3 秒 | 排障体验可接受范围 |
| 丢失率 | 0 | 0 | 日志是唯一故障现场 |
| 积压上限 | 不超 1 分钟 | 不超 5 分钟 | 超过即触发降级采样 |
矩阵里最容易被忽视的是积压上限:很多团队只定吞吐不下限,结果高峰期管道没挂但积压半小时,等于延迟塌方。把积压上限写进矩阵,监控才有一个可以报警的阈值,而不是「看起来还能跑」。填完矩阵还可以做一次压力预演:人为制造一场「峰值 × 2」的灌入,看积压曲线与丢日志告警会不会按预期出现。
三条线之间的三处隐蔽冲突
嘴上说「各有下限」,工程上还有三处隐蔽冲突,不提前调好上线一定返工。第一处是攒批大小与延迟:批越大吞吐越高,但低谷期批迟迟不满,日志就压在内存里。第二处是WAL 与吞吐:每条多一次写,高峰期 WAL 自己就可能成为瓶颈,需要 WAL 也做批量追加。第三处是背压与采集进程:下游满了上游必须慢下来,否则内存队列无边上涨最终 OOM,但慢下来又可能影响同机业务进程。
| 冲突 | 表现 | 调和手段 |
|---|---|---|
| 攒批 vs 延迟 | 低谷期日志迟迟不可见 | 定时刷盘兜底 |
| WAL vs 吞吐 | WAL 写入成为新瓶颈 | WAL 批量追加 |
| 背压 vs 业务 | 队列无边上涨或抢 CPU | 有界通道 + 低优先级 |
这三处冲突的共同点是:它们在低流量时全部隐形。本地跑一万条日志什么问题都没有,一上峰值全部冒头。所以验收一定要在「峰值 × 2」的压力下做,而不是在 comfort zone 里做。调和技术也很直白:定时刷、批量写、有界通道,三者组合几乎能覆盖大部分冲突场景,这在后续 2.3 与 2.4 节会逐个落到代码。
日志管道与业务系统的边界
还有一条边界值得提前划清:日志管道不该成为业务的关键路径。意思是管道挂了,业务服务必须还能正常跑,只是日志暂时收不上来。这听起来像废话,但很多团队做深了之后,会在管道里加各种「实时分析后反控业务」的逻辑,不知不觉把管道做成了关键路径,管道一抖业务跟着抖。正确的姿势是:管道做单向汇入,分析结果是「事后 suger」而非「实时油门」;即便要实时告警,也走旁路订阅而不是串在主链路上。同时管道自身要有降级手段:极端高峰时按业务线分级保量,核心业务日志保量、审计类日志降采样,而不是所有日志一起死扛。这条边界与 Java 订单中心 里「订单链路不挂可观测组件」的原则一样,可对照。
| 边界 | 允许 | 禁止 |
|---|---|---|
| 依赖关系 | 业务不依赖管道存活 | 管道挂业务挂 |
| 分析位置 | 旁路订阅、事后分析 | 串主链路反控 |
| 高峰降级 | 核心日志保量、审计降采样 | 全量死扛到积压 |
边界划清后,排障责任也清了:业务出问题先查业务,管道的问题只影响「日志晚几秒到」,不会模糊故障边界。再补一句:日志管道的可用性目标不要定成「永不挂」,而要定成「挂了业务无感」——前者代价极高且做不到,后者成本可控且真正有用。
踩坑提示
最典型的是把吞吐当唯一目标:团队压测做出很高的 TPS,上线高峰确实没积压,可一次崩溃重启发现丢了十分钟日志——WAL 没做,攒批里的数据全蒸发了。排障时才发现,丢的恰好是故障发生前那段最关键的现场。三条线必须分别定验收:吞吐看峰值 TPS、延迟看 P95、不丢做一次「 kill -9 后重启」的演练,少一条上线都会塌。演练时顺手把「重启后日志能否续上」也验了——能续上说明 WAL 真的生效,不能续上说明所谓不丢只是写在了 PPT 上。
小结
吞吐靠攒批与顺序写,低延迟靠定时刷盘与短队列,不丢靠 WAL 与确认机制。三条线互相牵制,设计上先保不丢底线、再定吞吐峰值、最后收延迟,管道的骨架就稳了。把这三条写成验收指标贴在 wiki 上,后面每一章的实现都有靶子。
需求矩阵里还有一个容易被忽视的细节:指标要有明确的测量点。「延迟」是从日志产生到可检索,还是从采集到落盘?口径不同数字差好几倍。把测量点写进矩阵(例如「采集进程读到该行 → CLS 可检索到该行」),验收时就不会为口径吵架,历史数据也才可比。一张好矩阵,三分在数字、七分在口径定义。
延伸阅读
- 攒批与刷盘的代码骨架,见下一节 2.4。
- WAL 重放的细节,见 4.4。
- 通道与背压的另一个实战,见 Rust 日志管道同源思路 后续章节。
- 可观测性拆解,见 Go 微服务实战。
