为什么队列不能解决过载(以及应该怎么做)

Lobsters Hottest 新闻

摘要

解释了为什么无界队列是软件系统中的一个bug,利用利特尔法则和浴缸类比说明队列只能吸收波动,而非持续负载。讨论了延迟死亡螺旋,并主张改用背压机制。

<p><a href="https://lobste.rs/s/exgvan/why_queues_don_t_fix_overload_what_do">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/08 03:19

# 为什么队列不能解决过载问题(以及正确的做法) 来源:https://pmbanugo.me/blog/why-queues-dont-fix-overload-and-what-to-do-instead *本文讨论软件系统中背压力的物理定律、延迟死亡螺旋,以及为什么无界队列是一种缺陷。* 如果你曾在流量高峰期间负责运维或系统可靠性,你一定感受过那种滋味。警报响起,CPU 飙到 100%,内存使用爆表,服务器要么崩溃,要么软件变得无响应。 团队的即时反应几乎总是相同的:我们需要一个更大的缓冲区。于是你增加 Go 通道的大小或 Redis 缓冲区的限制。也许你的系统中还没有队列,所以你决定加入 Kafka 或 RabbitMQ——问题解决了!你现在可以在不崩溃的情况下处理这样的流量高峰……直到你的业务达到一个更大的里程碑,你又回到了同样的困境。 这是当今软件工程中最直观的应对方式。队列不仅被用作权宜之计,它们还被提前加入,作为解耦系统的手段。然而,一个系统的过载会级联到其他系统。 但队列就像浴缸或水槽,和浴缸一样,软件也受物理定律约束。如果从水龙头流出的水比排水管排出的水快,把浴缸变大并不能防止水漫金山。它只是延迟了灾难的发生。 > 我会在后续文章中讨论延迟如何影响你的系统,以及如何为可靠系统设计反馈循环。现在,让我们先聚焦于队列的物理本质,以及为什么它们不能解决过载。 正如 Fred Hebert 的名言:**“队列不能解决过载。”** 队列仅在工作到达率偶尔且暂时高于工作完成率时才有用。它们吸收的是波动,而非持续负载。 当你依赖无界队列来处理流量高峰时,你并没有解决问题。你只是在确保故障将是灾难性的,而且很可能无法恢复。 ### 排队论的浴缸模型 > 当到达率(水龙头)超过处理率(窄管)时,缓冲区(浴缸)最终会溢出。更大的浴缸只能延迟不可避免的结局。 ### 延迟死亡螺旋(与利特尔定律) 让我们看看队列的物理本质。排队论是对等待队列的数学研究,它有一个数学证明叫做**利特尔定律**。 利特尔定律指出,系统中的任务数量 \(L\) 等于它们到达的速率 \(\lambda\) 乘以处理它们所需的平均时间 \(W\)。 > L = λW 假设你的服务每秒处理 1000 个请求。突然,每秒到达 5000 个请求。你现在有每秒 4000 个请求的赤字。 因为你使用了无界邮箱或队列,服务不会立即崩溃。它乖乖地接受每个传入请求,并将它们堆叠在内存中。当到达率 \(\lambda\) 超过你的处理能力时,利特尔定律指出,队列中的任务数量 \(L\) 必须趋向无穷大。 当队列达到 1 万、5 万、10 万时,那个在巨大队列中排第 10 万个的请求会怎样? 当你的工作进程最终处理到它时,发送它的客户端很可能已经超时了。或者用户看到一个旋转的加载图标,感到沮丧并刷新了页面。那次刷新只是向队列尾部**添加了另一个**请求,进一步增加了到达率 \(\lambda\)。 你的服务器现在正花费昂贵的 CPU 周期处理已离开用户发出的死请求。因为你处理的是死请求,你的有效处理时间 \(W\) 变得更差。由于重试,队列增长得更快。内存压力触发垃圾回收暂停,进一步降低你的处理速率。 > 排水管堵塞,水龙头却开得更大。 这就是延迟死亡螺旋——一种级联的系统故障,其中增加的响应时间导致请求排队,造成进一步延迟,最终使系统完全饱和。这就是无界队列导致的结果。 ``` 无界队列(Erlang 默认) 时间 延迟 (ms) 流量峰值 (λ > W) 超时 SLA 死亡螺旋 有界队列(Tina) 时间 流量峰值 (λ > W) 超时 SLA 稳定延迟 丢弃负载(被丢弃的请求) ``` ### 残酷的事实:你必须丢弃负载 你无法以超过硬件允许的速度处理数据。如果你的网络数据传输速率、CPU 处理速度或磁盘 I/O 吞吐量已达上限,你无法通过软件神奇地加快速度。因此,你必须丢弃数据。例如,在网络环境中,你会丢弃数据包。 你的工程选择只剩下**如何**以及**在哪里**丢弃数据。 大多数框架选择隐式丢弃数据。队列不断增长,直到服务器耗尽内存,操作系统杀死进程,然后你丢弃所有数据。结果所有正在处理的请求都丢失了,服务完全瘫痪,直到重启。 另一种选择是通过**负载丢弃**和**背压**显式丢弃数据。 如果系统已达容量,它必须立即拒绝新工作。它必须直视发送者的眼睛说:“我满了。走开。”发送者必须立即被告知,以便做出策略决策:是丢弃这个请求,稍后重试,还是向用户显示降级体验?这不是系统的失败。这是系统通过正确的反馈循环成功保护自己。 ### 在源头丢弃负载 在 **Tina** (https://github.com/pmbanugo/tina) 中,我围绕一条不变的定律设计了框架:当容量被超过时,多余的部分立即丢弃。 Tina 是我用 Odin 构建的一个高吞吐量线程-核心并发框架。它不通过队列来解决过载。系统中的每个资源——邮箱、消息池、跨分片通道——都有严格的上限,并在启动时预分配。运行时没有动态分配(或 malloc),也没有邮箱的“无界”模式。一个标准的 Isolate 邮箱默认恰好持有 256 条消息(可配置)。 > Isolate 是 Tina 中的并发单元。它是一个单线程、基于消息传递的 actor,顺序处理其邮箱中的消息。 当一个 Isolate 尝试向已满的邮箱发送消息时,系统不会分配隐藏缓冲区。它不会暂停发送者。它以 O(1) 时间拒绝该消息,并立即将失败返回给发送者。 ``` [ 发送者 Isolate ] [ 目标 Isolate 邮箱 ] │ ┌───┬───┬───┬───┐ send(target, msg) ─────(满!)───────X │msg│msg│...│msg│ (256/256) │ └───┴───┴───┴───┘ ▼ 返回 .mailbox_full (零分配,O(1) 快速拒绝) ``` 通过同步返回失败,框架强制应用程序在系统达到容量时立即做出策略决策,而不是将失败推迟到几分钟后的下游超时。 以下是在代码中的样子。在 Tina 中,发送消息是一个同步的 `ctx_send` 调用,返回一个 `Send_Result`: ```odin result := tina.ctx_send(ctx, destination_handle, TAG_DATA, &payload) #partial switch result { case .ok: // 消息成功入队。 return tina.Effect_Receive{} case .mailbox_full: // 目标过载。我们必须丢弃负载。 tina.ctx_log(ctx, .WARN, TAG_OVERLOAD, "目标过载,丢弃请求。") // 我们显式丢弃工作并等待下一条消息。 return tina.Effect_Receive{} case .pool_exhausted: // 分片的内存池已完全饱和。让它崩溃。 return tina.Effect_Crash{reason = .system_saturated} } ``` 如果你在构建一个遥测或指标发射器,丢弃数据是可以接受的,你可以显式地忽略结果。在 Tina 中,下划线记录了你忽略背压力的架构决策: ```odin // 即发即弃。如果指标服务过载,就丢弃指标。 _ = tina.ctx_send(ctx, metrics_handle, TAG_METRIC, &payload) ``` ### 通过超时实现有界可靠性 `ctx_send` 的行为像 UDP。它快速、尽力而为,并提供即时反馈。但如果你正在编写一个计费服务,并且**需要**保证响应呢? 如果你需要可靠性,你可以使用 `.call` 模式。它不是同步函数调用,而是向调度器返回一个 `Effect_Call`。 ```odin // 发送请求并暂停 Isolate,直到收到回复。 return tina.Effect_Call{ to = billing_handle, message = transform_request_to_message(request), timeout = 5000, // 强制超时,单位毫秒 } ``` 在过载系统中,你的 `.call` 请求可能会被丢弃,因为计费邮箱已满。如果发生这种情况,你不会无限等待。强制超时触发,调度器用一条 `TAG_CALL_TIMEOUT` 消息唤醒你的 Isolate,然后你处理失败。 你无法区分“计费服务死了”、“消息被丢弃了”还是“计费服务只是很慢”。从系统工程的角度看,你不应该在意。响应完全相同,因为系统未能在所需的 SLA 内服务你的请求。你必须重试、升级、或者失败客户端请求。 ### 可预测性胜过简洁性 强制开发者在每次发送消息时处理 `Send_Result`,比将消息丢进无界的 Erlang 邮箱或无限扩展的 Go 通道更加繁琐。 我接受了这个权衡,因为可预测性和崩溃安全性比少打几个字更重要。 通过严格限制队列并强制超时,我们从结构上消除了延迟死亡螺旋。系统优雅地降级。它立即丢弃多余的负载,并在流量恢复正常时迅速恢复,因为它不会花接下来的二十分钟处理死请求。 如果你对高吞吐量线程-核心架构、零分配状态机或确定性模拟测试感兴趣,我很乐意听取你对我的项目 **Tina** 的架构批评。你可以在 **GitHub** (https://github.com/pmbanugo/tina) 上找到源代码、文档和示例。

相似文章

任务队列看似简单实则棘手

Lobsters Hottest

一篇探讨任务队列隐藏复杂性的技术博客文章,分析了它们为何看似简单实则棘手,并提供了系统设计的有用视角,如警惕队列、限制和故障模型。

我们应该摒弃平均CPU利用率

Hacker News Top

本文解释为何平均CPU利用率对于延迟敏感型工作负载是一个误导性指标,利用排队论和一个真实的生产事故案例,主张采用更细致的监控方法。

负载均衡系统的惊人经济学

Hacker News Top

一篇博客文章分析了M/M/c队列模型,并表明在负载均衡系统中增加服务器数量,在恒定每服务器负载下可以改善延迟,这是云经济学中一个有益且有些违反直觉的结果。