消息积压方面的数学知识:用于队列恢复的容量规划
点击查看原文>
引言
去年,我在辅导的一个团队遇到过一个你们很多人可能都经历过的场景。某个下游依赖(一张在写入突发下被限流的 DynamoDB 表)导致他们的 Kafka 消费者组大约慢了 12 分钟。等限流解除时,主题分区滞后已达 240 万条消息。事故看似“结束”了,但真正的问题才刚刚开始:我们到底还要多久才能真正追平,也就是处理完这些积压的消息?
大多数团队都在靠直觉回答这个问题,并紧张地反复刷新 CloudWatch 仪表盘。但其实有一组很实用的公式,它能把积压恢复从“猜”变成“算”,数学本身并不复杂,难的是凌晨 3 点知道该用哪一个。本文会给出这些公式,解释其背后的直觉,并展示如何把它们接入你的运维手册和自动扩缩容策略。
三个最关键的数字
如果你曾经因为队列积压被告警叫醒,你其实已经知道这三个数字,即使你还没有给它们命名:
到达速率(λ):每秒进入队列的消息数
处理速率(μ):单个消费者每秒可处理的消息数
消费者数量(c):当前运行的消费者个数
你的总处理能力是 c×μ。如果它大于λ,队列就会保持较小的水平;如果小于λ,队列就会增长。本文其余内容都来自这个关系。
注:本文中所有速率(到达速率与处理速率)均以“消息/秒”表示。基于时间的计算(如积压清空时间与恢复时间目标 RTO)除非特别说明,也统一以秒计算。示例中使用分钟仅为便于阅读。
利用率是两者之间的比值:
utilization = arrival_rate / (consumers × processing_rate)
在 80%的利用率时,系统通常看起来没问题;到 95%时,队列就会快速增长。这种关系是非线性的,也正是这种非线性让积压看起来像是突然爆发的。
看一个具体例子。假设你的系统处理能力是 10000 msg/sec。在 80%利用率下,冗余是 2000 msg/sec,也就是到达量与处理量之间的差值。一次 10%的流量突增会把利用率推到 88%,冗余从 2000 降到 1200,队列会增长一些,但还可控。现在,假设你在 90%利用率运行,冗余只有 1000 msg/sec。同样 10%的突增会把它推到 99%,冗余从 1000 骤降到 100 msg/sec。此时队列增长速度会比 80%利用率时快 10 倍。同样的突增,结果却天差地别。
这道“强烈对比”解释了为什么团队会在凌晨被“queue depth:300 万”的告警叫醒,而且睡前还觉得一切正常。系统并没有变,只是冗余比大家想象得更薄。
Little 定律:每个人都该掌握的一个公式
如果你只记住排队论里的一个公式,那就记住这个:
queue_depth = arrival_rate × time_in_queue
这就是 Little 定律。它在凌晨 3 点依然有价值,因为它始终能够成立,无论你用的是 Kafka、SQS、RabbitMQ 还是 Redis 列表。它把你关心的三个量连在一起:只要测出其中两个,第三个就能直接算出。
在积压期间,它能直接告诉你用户的影响。如果队列里有 60 万条消息,到达速率是 5000/sec,那么刚到达的消息大约要等 120 秒才会被处理。这 120 秒还只是排队延迟,处理甚至还没开始,你不需要分布式追踪或性能分析器,只需要做个除法。
反过来同样有用:如果你的 SLA 要求消息必须在 10 秒内处理完,到达速率是 5000/sec,那么可容忍的最大队列深度就是 50000。超过这个值就等同于违反 SLA。这个数字应该放进 CloudWatch 仪表盘并配置上告警。
积压如何形成与清空
积压通常分为三个阶段,每个阶段都能用简单的逻辑来进行解释。
/filters:no_upscale()/articles/capacity-planning-queue-recovery/en/resources/1Figure-1-The-three-phases-of-Queue-Backlog-1778242174192.jpg)
图 1:队列积压的三个阶段
阶段 1:累积。某些环节出问题,比如,消费者崩溃、依赖变慢、流量突增。你的处理能力降到到达速率以下,消息会按如下速率堆积:
growth_rate = arrival_rate - effective_processing_capacity示例:你平时由 25 个 Kafka 消费者处理 10000 msg/sec(每个 400 msg/sec)。一次错误发布让其中 15 个消费者下线。你现在只能处理 4000 msg/sec,但仍有 10000 msg/sec 持续进入。于是每秒会净积压 6000 条消息。一次 10 分钟事故会留下 360 万条积压。
阶段 2:稳定化。根因被修复,消费者恢复,队列不再继续增长,但它不会自动清空。你仍然有 360 万条消息在等待处理。
阶段 3:清空。消费者需要同时处理新到消息与历史积压。可用于清空积压的能力,是处理完新增流量后剩下的那部分:
surplus = total_processing_capacity - arrival_ratedrain_time = backlog_size / surplus
继续这个例子:25 个消费者×400 msg/sec=10000 msg/sec 处理能力。到达速率也是 10000 msg/sec。冗余是……0,这意味着积压永远清不掉。
我见过这个“意外”反复出现。团队把消费者集群配置到刚好覆盖稳态流量(这本身很合理),然后在事故中才发现,“能扛住稳态”就等于“恢复时没有任何余量”。他们盯着消费者滞后仪表盘上的一条平线:消费者都健康、Pod 都绿色,但积压就是不下降。这种失效模式最迷惑人的地方在于,看起来没有任何东西有问题。
如果该团队不是 25 个而是 30 个消费者,冗余就会变成 2000 msg/sec,清空时间是 1800 秒,也就是 30 分钟。多出来的 5 个消费者,就是“半小时恢复”和“无外部干预永远恢复不了”的分水岭。
真正重要的复杂因素
简单的清空公式只是起点。在真实世界中,有三个因素会让它明显失准。
陈旧消息处理更慢
积压消息通常是陈旧的。它们可能会导致缓存未命中(Redis 或 Memcached 条目已被驱逐或覆盖)、触发 Token 刷新,或走到用于校对旧数据的代码路径中。这意味着清空阶段的实际处理速率往往低于平时,有时会低很多。
如果你测过这个指标(也应该去测),可以引入一个退化系数:
effective_drain_rate = surplus × degradation_factor退化系数为 0.7 意味着你原本估算 30 分钟清空,实际会变成 43 分钟。我建议你在下一次事故中测量这个值,比较清空开始前 10 分钟的 p50 处理延时与稳态基线,并把比值写进运维手册。这会是你最有价值的数字之一。经历三四次事故后,你的清空时间估算会与现实非常接近。
流量并非恒定的
如果积压在凌晨 2 点形成,你可能有足够冗余在早高峰前清空,但如果在上午 11 点形成,你就可能陷入麻烦了,下午的高峰可能会让积压继续扩大,直到晚些时候流量回落,你才有余量开始清空队列。
这意味着按峰值配置会带来一种虚假的安全感。你的冗余只在低峰期存在,而那恰恰是你最不需要它的时候。如果你按峰值流量做容量,真实的恢复冗余是“高于峰值的那部分余量”,而不是“高于平均值的余量”。这个区别在做容量规划时非常关键。
实践方面的要点是,清空时间估算必须要考虑积压发生的时间,而不只是积压规模。如果高峰将至,你很可能需要通过 Kubernetes HPA 或云厂商自动扩缩容策略尽快扩容,而不是等待其自然恢复。
重试放大(最危险的一种情况)
这是积压演变为故障的关键机制,也是本文最重要的概念之一。
当队列积压时,消息处理变慢。等待响应的生产者开始超时并重试。每次重试都会向队列再塞一条消息。于是到达速率会因为积压本身而上升:
effective_arrival_rate = base_arrival_rate × (1 + retries_per_timeout × timeout_probability)/filters:no_upscale()/articles/capacity-planning-queue-recovery/en/resources/1Figure-2-Retry-Amplification-The-Metastable-Failure-Loop-1778242174192.jpg)
图 2:重试放大
随着队列增长,超时概率上升,重试增多,队列会进一步增长。这个反馈回路会把实际到达速率推到处理能力之上,即使最初的故障根因已经修复。系统看起来健康,却无法恢复,因为“恢复过程本身”制造的负载比它消化的还多。到这一步,系统已不再因最初事故而失败,而是因自身恢复动力学而失败。
我在上一篇关于弹性事件驱动系统的文章里更详细讨论过重试困境——Bronson等人在HotOS'21论文中描述的亚稳(metastable)失效状态概念,是这里的核心。一个在正常负载下完全稳定的系统,可能因重试放大而长期卡在降级状态。
有个真实场景能说明这种危险。我合作过的一个团队有一条基于 SQS 的订单处理流水线。下游的支付服务宕机了约 8 分钟,期间积压了约 20 万条消息。支付服务恢复后,消费者也恢复了,但上游生产者在这 8 分钟里一直在重试失败的 API 调用。实际到达速率变成基线的 2.5 倍。尽管所有消费者都健康,队列仍继续增长了 40 分钟,直到重试风暴消退。最初 8 分钟的故障,最终演变成接近 1 小时的用户可感知降级。
怎么判断自己处于亚稳态,而不是正常的“慢清空”呢?诊断信号是,如果所有消费者都健康、处理速率也正常,但队列深度仍在增长(或不下降),原因很可能是重试放大。观察 CloudWatch 里的实际到达速率,如果恢复期它高于基线速率,说明重试正在加剧问题。
解决方案需要在架构层面实现,在生产者侧加熔断、使用带抖动的指数退避,并在恢复期具备丢弃或降级重试消息优先级的能力。但第一步是识别风险,而上面的公式能告诉你是否暴露于这种风险中。
多阶段流水线中的级联积压
到目前为止我们讨论的是单队列积压。但大多数生产系统是流水线的:Service A→Queue 1→Service B→Queue 2→Service C。当某一阶段出现积压时,它不会被局限在本阶段,而会发生级联。
/filters:no_upscale()/articles/capacity-planning-queue-recovery/en/resources/1Figure-3-Cascading-Backlogs-in-a-Service-Pipeline-1778242174192.jpg)
图 3:服务流水线中的级联积压
它通常会这样发展:假设 Service B 变慢了——比如,它访问的数据库正处于高负载状态。Queue 2 开始增长。由于单条消息耗时变长,Service B 吞吐下降,但 Service A 仍按正常速率生产,于是 Queue 1 也开始增长,因为 Service B 拉取不够快。几分钟内,两条队列都会告警。
从监控视角看,这像是整个系统都在故障状态。两条队列深度告警同时触发。值班工程师看到了两个问题,往往会本能地同时修复它们,也就是扩容 Service A 消费者、扩容 Service B 消费者,甚至扩容 Service C。但整个流水线吞吐量受最慢阶段的限制。如果瓶颈是 Service B,扩 Service A 对整体吞吐提升为零。你只是在为无效实例烧钱。
我见过这样的流水线案例,上游团队因队列增长把服务扩到 3 倍容量,几小时后才发现队列增长是由下游瓶颈造成的,而不是自身的处理能力不足。真正修复只是改了瓶颈服务的一项配置。新增实例毫无作用。
实践建议有三点。第一,监控流水线每一阶段的队列深度,而不只是你“猜测”的瓶颈点。如果消费者健康但队列增长,这通常意味着瓶颈在下游。第二,恢复期先聚焦瓶颈阶段,恢复其吞吐会同时解锁其他阶段。第三,系统设计应让背压信号传播得比积压更快。如果 Service A 能检测到 Queue 2 超过阈值(通过 CloudWatch 告警、指标,甚至轻量级的健康检查端点),它就应该降低自身摄入速率,而不是继续往“无处可去”的队列里堆消息。
积压传播速度往往快于容量变化的速度。扩错阶段,只会把系统中错误的部分“加速”。
何时应丢弃负载而不是清空积压
有时候,对积压的正确响应不是清空,而是丢弃其中一部分。
设想一下:如果你的预计清空时间是 45 分钟,而消息 TTL 只有 30 秒,那么绝大多数积压消息其实已经失去价值。调用方早已超时并继续后续流程。继续处理这些陈旧消息,只是在浪费算力做对任何人都无益的工作,而此时新请求还在它们后面继续排队。
决策规则很简单:
if drain_time > message_ttl: shed stale messages一个设计良好的准入控制系统,在积压期间会给你三道杠杆。第一,丢弃超过 TTL 的消息,因为调用方已经放弃。第二,降低低价值流量的优先级,批处理分析可以等,实时交易不能等。第三,对具备优雅降级路径的请求返回缓存结果或降级响应。
这与我上一篇文章所讨论的优先级队列模式直接相关,事件并不平等,而积压正是这种差异最重要的时刻。如果你用 SQS,可考虑拆分高优先级与低优先级队列,并配置不同的消费者扩缩容策略;在 Kafka 中,可以通过 Topic 级分区或消费者组优先级实现类似效果。
负载丢弃对容量规划还有个隐性收益,它能有效限制 max_backlog 假设。如果你确定陈旧消息会被丢弃,那么最坏的情况是积压就会被 TTL 窗口限制,而不是被事故时长无限拉长。这会减少你需要预留的冗余,从而降低成本。对很多团队而言,投入智能负载丢弃往往比长期为“很少用到的恢复冗余”预配额外消费者更便宜。
容量规划:把公式变成决策
我需要多少冗余容量?
你需要足够多的消费者来覆盖稳态流量,并且还要有足够的冗余,请确保在恢复时间目标(RTO)内清空最坏情况的积压。
consumers_needed = (arrival_rate / processing_rate) + (max_backlog / (processing_rate × rto))示例:到达速率 10000 msg/sec,每个消费者处理速率为 400 msg/sec,最坏积压 500 万条消息,RTO 为 30 分钟(1800 秒)。
consumers = (10,000 / 400) + (5,000,000 / (400 × 1,800))= 25 + 7 = 32
注:此公式中的 RTO 以秒表示。例如 30 分钟写作 1800 秒。
这比稳态需求高出了 28%的开销。成本是具体且可量化的,按你的单实例价格计算,额外多出了 7 个实例。现在你可以基于数据讨论“这笔成本是否值得承担该风险”,而不再争论“要不要留一点余量”。公式把直觉变成了算术。
你还可以和另一种方案进行对比:投入准入控制,通过负载丢弃或 TTL 强约束降低 max_backlog 假设,从而减少所需的冗余。
自动扩缩容应在何时触发?
不要只根据队列深度触发扩容——当深度已经告警时,你往往已陷入麻烦。应该基于队列深度变化率触发,可在 Prometheus 里用rate(queue_depth[5m])计算,或在 CloudWatch 里用指标运算来实现。
if queue_growth_rate > 0 for more than 2 minutes:estimated_backlog = current_depth + (growth_rate × scale_up_time)target_consumers = arrival_rate / processing_rate + estimated_backlog / (processing_rate × rto)scale_to(target_consumers)
scale_up_time这一项至关重要。如果新消费者实例从创建、拉取镜像到开始处理要 3 分钟,你规划的应该是“容量到位时积压会到哪里”,而不是“此刻积压在哪里”。该延迟会因编排环境差异而明显不同:预缓存镜像的 ECS Task 可能 30 秒就可使用,而从冷仓库拉大镜像的新 Kubernetes Pod 可能需要几分钟的时间。
我可容忍的最大事故时长是多少?
在当前冗余下,故障最多能持续多久,才不会让积压规模超过你在 RTO 内恢复的能力?
max_incident_duration = rto × surplus / accumulation_rate如果你的冗余是 2000 msg/sec,完全中断期间的积压速率是 10000 msg/sec,RTO 是 30 分钟(1800 秒),那么最大可容忍事故时长就是 6 分钟。再长就无法按时恢复。
如果这个数字小得让你不安,那么你有三种杠杆:预留更多容量(简单但贵)、投入更快的检测以缩短积压时间(该杠杆的价值很大,检测时间少 2 分钟,就等效于多 2 分钟的冗余),或构建可不受事故时长影响的准入控制来限制积压上限(在大规模系统中往往最具性价比)。
注意:不可处理消息与死信队列
本文的积压数学默认有一个前提,那就是,队列中的每条消息最终都可处理。现实中,这个前提比很多人想象的更容易失效。几乎总有一小部分消息会持续失败,比如,无效载荷、Schema 不匹配、下游契约变更或状态损坏。如果这些消息留在主队列,消费者就会持续重试,消耗容量却没有任何进展。
在恢复期,这会表现为一种令人困惑的现象:系统看似健康、消费者也在运行,但积压下降明显慢于预期。公式说应该更快,但你的一部分容量被浪费在永远不会成功的工作上。这就是死信队列(DLQ)的重要性所在。
DLQ 会在有限重试次数后把消息移出主流程,让系统聚焦真正可恢复的工作。从积压视角看,它有两个实际收益。第一,把有毒的消息从热点路径移走,保护有效吞吐。第二,防止坏数据的无限重试悄悄侵蚀恢复容量。DLQ 并不能替代对失败消息的排查与修复,但它能确保不可恢复工作不会扭曲主队列的恢复行为。
在大规模系统中,恢复规划不仅关乎容量和重试策略,也关乎你能多快识别并隔离那些注定不会成功并且会造成最小干扰的工作。
带着这个前提,下一步就是让恢复过程变得可度量。
需要测量与记录什么
每次积压事故后,请记录以下值。每一次事故都会让你的模型更准确。
积压峰值的规模——用于校准最坏情况规划假设
事故期间到达速率的峰值——作为重试放大公式和未来冗余估算的输入
实际清空的耗时——用现实结果校验公式
退化系数——清空期处理速度比平时慢多少?比较清空开始前 10 分钟 p50 时延与稳态基线并记录比值。
观测到的重试放大——积压期间实际到达速率是否上升?上升了多少?
发现耗时——多久才被发现?这是减少积压最具成本效益的点。
负载丢弃的效果——如果丢弃了陈旧消息,丢了多少?积压中超过 TTL 的比例是多少?
在记录了三四次事故数据后,你的清空时间估算通常会与现实非常接近。更重要的是,你会看到一种模式,比如,退化系数长期稳定在 0.65,或重试放大总在积压增长 90 秒后出现。这些模式才是把纸面公式转化为运维直觉的关键。
结语
队列积压是算术问题。到达速率减去处理能力等于增长速率;积压规模除以冗余能力等于清空时间。公式很简单,难的是把输入测准,并在需要时随手可得。
队列容量规划最有意思的地方在于,冗余在你不需要的时候是成本,在你需要时则能救你一命。本文的公式能够让你给这笔权衡两端都标上价格。它们能回答你该预留多少冗余、何时扩容、何时丢弃负载、恢复要多久。它们把基于感受的协商,变成基于数字的工程实践。下次队列再积压时,你不必再猜,只要做个除法,就能知道自己处在什么位置。
查看英文原文:The Mathematics of Backlogs: Capacity Planning for Queue Recovery
本文来源:InfoQ