2.2 多进程汇聚:mpsc 有界通道

上一节解决了「单个文件怎么读」,但真实的日志管道从来不止一个源头:一台机器上好几个服务在写日志,每台机器上又有一个采集器,采集完要往中心汇聚。汇聚这件事听起来就是「多发一收」,用 Rust 写起来第一反应都是 mpsc 通道。本篇把多源汇聚的拓扑、有界通道的Capacity选择、以及 sender 多了之后的各种坑讲清楚。

为什么是 mpsc 而不是 crossbeam

Rust 标准库的 std::sync::mpsc 与 tokio::sync::mpsc 都是多生产者单消费者通道,语义上正合适:N 个采集任务各自拿到一个 sender,消费端只有一个聚合任务。两者的区别在于消费端是否异步:tokio 的通道 recv() 是 .await 的,不阻塞工作线程;标准库的通道配线程池也行,但与 tokio 运行时混用要额外搭桥。日志管道整体跑在 tokio 上,选 tokio::sync::mpsc 最顺。

对比项 std::sync::mpsc tokio::sync::mpsc crossbeam-channel
与异步运行时契合 需搭桥 原生 await 需搭桥
有界/无界 都支持 都支持 都支持
依赖 标准库 tokio 第三方 crate
适用 纯线程模型 异步管道 极致性能场景

第三方通道在极限吞吐上确实有优势,但日志管道的瓶颈几乎从不在通道本身,而在下游的批处理与写入。引入额外依赖换取百分之几的通道性能,不如把精力花在攒批策略上,这个取舍在 2.4 会展开。

汇聚拓扑:先扇入再串行

多个采集源扇入(fan-in)到一个聚合点,是日志管道的标准形状。拓扑上有两种常见做法:

拓扑 形状 优点 坑
直连聚合 N sender → 1 receiver 结构简单 消费端压力大
分级汇聚 每机本地聚合 → 中心聚合 层次清晰、可限速 多一跳延迟

当天量日志全部直连一个中心 receiver 时,那个聚合任务的调度延迟会成为全链路尾巴。分级汇聚给每台机器一个本地聚合点,先做一次攒批与压缩再上行,骨干流量能降一个数量级。对于「每机一个采集器」的部署形态,分级汇聚几乎总是对的。

骨架代码大致如下:

// 多源扇入聚合
let (tx, mut rx) = tokio::sync::mpsc::channel::<LogLine>(capacity);

for path in sources {
    let tx = tx.clone();
    tokio::spawn(async move {
        let mut tail = Tail::open(path).await;
        while let Some(line) = tail.next_line().await {
            if tx.send(line).await.is_err() {
                break; // 聚合端已关闭
            }
        }
    });
}

// 唯一的聚合消费端
while let Some(line) = rx.recv().await {
    batch.push(line);
}

注意 tx.clone() 的含义:每个采集任务持有独立的 sender 句柄,通道内部用引用计数共享队列。最后一个 sender 被 drop 时,recv() 才返回 None,这是判断「所有源头都结束」的信号。

容量怎么选

有界通道的 capacity 是整个管道第一个需要拍板的数字。它不是越大越安全:容量大了,内存占用随着积压线性增长;容量小了,高峰期频繁进入背压。经验起点是「下游一个攒批周期的量」——如果下游每 100 毫秒刷一批、每批最多 5000 条,那么 capacity 设到 5000~10000 就够吸收波动。

容量 内存(每条 1KB 估) 背压频率 适用
1,000 ~1 MB 高 内存极度受限
10,000 ~10 MB 中 多数在线服务
100,000 ~100 MB 低 尖峰流量明显

容量与背压的配合是通道设计的核心矛盾,背压怎么具体生效见下一节 2.3 与 Node.js 云开发实战 里对队列容量的同类讨论。

选定容量之后还有一个工程动作:把队列深度暴露成指标。通道对象本身不提供 len() 之类的查询(tokio 的 mpsc 早期版本有,后被移除),常见替代法是消费端每秒统计一次 recv 计数、与采集端 send 计数相减,差值就是近似积压。这个数字比通道容量本身更有告警价值:容量是静止的配置,积压是活的压力,两者差三个数量级的运维价值。

send 的三种结局

刚写 mpsc 的人常以为 send 只有成功一种结局,实际上有三种,每一种都对应一种线上故障:

send 结果 含义 采集端应对
Ok(()) 入队成功 继续
Err(SendError) 聚合端已关闭 停止采集并告警
阻塞(有界满) 等待空位 背压生效

第二种最危险:聚合任务崩了,所有采集端傻傻阻塞在 send 上,外面看采集器还活着,日志其实一条没收。必须给 send 加超时或让采集端感知通道关闭,否则故障会被「进程都在线」的假象掩盖。监控章节会专门讲怎么暴露这类「隐性停摆」,监控指标设计可参考 Go 即时通讯实战 中连接级心跳的做法。

多源有序性:别指望先到先得

多个 sender 往同一队列塞数据,接收端拿到的顺序完全由到达先后决定,与日志原始时间无关。跨源的「全局有序」是做不到的,也没必要追求:日志检索本来就按各自时间戳排序。真正要防的是源内乱序——同一个文件的两个 chunk 被并发读出又乱序到达。解法是源内保持单任务串行读取,不做源内并行。

排序需求 可实现性 手段
全局有序 不可行 放弃
源内有序 可行 单源单任务
近似全局 可行 按时间戳再排序

如果下游必须按时间合并多源日志,正确做法是在聚合后做一次小窗口的按时间戳重排(窗口 1~2 秒),而不是在通道层面追求有序。窗口重排的代价是多等一个窗口的延迟,换来的是合并结果可解释。

优雅关闭的顺序

关闭一条多源管道,顺序反了会丢数据。正确顺序是「先停采集、再等队列排空、最后关聚合」:每个采集任务 drop 自己的 sender,等 receiver 收到 None 后聚合任务把最后一批刷下去再退出。反过来先关聚合端,所有在途消息直接判负。

阶段 动作 标志
停源 各采集任务退出 sender 全 drop
排空 聚合继续 recv recv 返回 None
刷尾 最后一批写入 聚合任务退出

这个顺序在下一篇的攒批逻辑里还会用到,崩溃恢复相关的内容见 4.4 的 WAL 重放。

踩坑提示

最常见的坑是忘记 clone sender 给最后一个任务:主任务持有原始 sender 不 drop,receiver 永远等不到 None,进程挂在 recv 上不退出。其次是在 async 任务里用阻塞式 send:标准库 mpsc 的 send 阻塞当前线程,在 tokio 任务里会卡住整个工作线程,必须换成 blocking_send 或直接用 tokio 通道。第三是通道容量与下游批大小不匹配:批大小比容量还大,攒批永远等不满,定时刷又把延迟拉长,两者要一起调。

小结

多源汇聚的标准形状是 fan-in 加分级聚合,通道选 tokio mpsc,容量按「一个攒批周期的量」起步。send 的三种结局都要处理,全局有序不可求,源内有序靠单任务串行,关闭顺序是「停源、排空、刷尾」。汇聚层是管道的咽喉,它畅不顺,决定后面持久化层看到的是平稳流还是脉冲流。

延伸阅读

  • 背压的具体实现,见下一节 2.3。
  • 攒批与定时刷的节奏配合,见 2.4。
  • 通道积压如何转化为监控指标,见 Go 微服务实战。
  • 高并发汇聚的另一个案例,见 Java 云原生订单实战。