Tokio 背压设计:通道满了,比内存爆了更早告诉你问题
Tokio 背压设计:通道满了,比内存爆了更早告诉你问题
一、异步系统不能只会 spawn
Tokio 让并发任务创建变得很轻,但任务多不等于系统稳。生产代码里,如果上游不断发送任务,下游处理不过来,内存会慢慢堆积。背压就是让系统更早暴露容量问题。
有界通道是最直接的背压工具。通道满了,发送方会等待或失败。这个信号比内存爆掉更早,也更可控。
二、背压要沿链路传播
flowchart TD A[请求入口] --> B[有界队列] B --> C[Worker] C --> D[外部模型] B --> E{队列是否满} E -- 满 --> F[拒绝或降级]如果队列无界,上游永远觉得发送成功,压力就被藏进内存。如果队列有界,系统会在容量不足时明确反馈。反馈可以是等待、拒绝、降级或扩容。
背压不能只停在内部。用户请求进入时,如果队列已经满,应该返回明确错误或排队提示,而不是让请求一直挂着。等待也要有超时。
三、Tokio 通道要有容量
use tokio::sync::mpsc; let (tx, mut rx) = mpsc::channel::<Job>(100); tokio::spawn(async move { while let Some(job) = rx.recv().await { handle(job).await; } });容量 100 不是随便写的。它应来自处理速度、允许等待时间和内存预算。任务很大时,容量应更小;任务很轻时,可以适当放大。
async fn submit(tx: &mpsc::Sender<Job>, job: Job) -> Result<(), String> { tx.send(job).await.map_err(|_| "worker closed".to_string()) }发送失败也要处理。worker 已关闭时,继续接受任务没有意义。错误路径必须显式返回。
四、指标决定容量是否合理
通道长度、等待时间、处理耗时、拒绝次数,都应进入指标。只看任务成功率,会漏掉系统已经在排队的事实。队列持续接近满,说明容量不足或下游变慢。
背压还要配合取消。用户取消请求后,排队任务应尽量移除或在执行前检查是否仍然需要。否则系统会处理一堆没人等的结果。
多级队列要避免互相掩盖。入口队列很短,但下游模型队列无界,压力仍然会堆到后面。每一层都要有容量和指标,才能知道瓶颈在哪里。
任务优先级也要明确。交互任务、后台索引任务、批量分析任务不应共享同一个公平队列。低优先级任务可以延后或丢弃,交互任务则需要更严格的等待预算。
还要处理 worker panic。某个 worker 因错误退出后,如果没有监控,队列消费能力会下降,最终表现为排队变长。应记录 worker 数量,并在任务失败时返回结构化错误。
最后,背压不是用户体验的敌人。明确拒绝和合理排队,比让所有任务一起慢到超时更负责。
背压还要结合批处理。小任务可以合并成批量请求,减少模型或外部服务调用次数。但批次等待时间不能无限增长,否则交互体验会变差。批大小和最大等待时间要一起配置。
队列满时的策略要按业务区分。有些任务可以丢弃,有些必须落盘等待,有些应立即失败。比如日志分析可以延后,文件写入不能静默丢。背压策略不能一刀切。
还要把队列容量写进配置和文档。容量不是魔法数字,应该有推导依据:平均处理耗时、内存占用和最大可接受等待时间。后续调整时,团队知道为什么是这个值。
最后,压测要覆盖下游变慢。只有正常下游时看不出背压效果。让模型接口延迟翻倍,观察队列、拒绝和恢复,才能证明设计有效。
一个参考数字:如果 worker 平均处理耗时 200ms,队列容量 100,在正常流速下完全够用。但下游延迟从 200ms 涨到 2s 时,相同容量下队列会在 2 秒内打满。容量不是拍脑袋写的,应该用"可接受的最大排队时间 ÷ 平均处理耗时"来估算。
五、总结
Tokio 背压设计要使用有界通道、超时、拒绝语义和队列指标,让容量问题尽早暴露。
异步不是无限接任务。通道满了,是系统在诚实地告诉你:下游已经跟不上节奏。
