队列不吸收过载,只会把快速失败变成慢速失败,无界队列则把慢速失败变成系统性崩溃。到达率持续超过服务率时,客户端重试加剧洪泛,队列最终装满deadline已过的请求,白白消耗算力和金钱。
队列不会吸收过载。它只是把快速失败转换成慢速失败,而如果队列是无界的,它就把慢速失败转换成全面崩溃。背压(backpressure)是一种对"你拒绝什么"的设计,而尽早拒绝几乎总是比晚拒绝要好。
以下是这个过程,每次都一样。到达速率在某段时间内超过了服务速率,队列开始增长,延迟也随之增长——因为延迟等于排队时间加上服务时间。客户端命中自己的超时时间并重试,这又提高了到达速率,队列增长得更快。等你发现的时候,队列里已经积压了二十分钟的工作量,队列中的每个任务都已经超过了它的截止时间,系统把所有容量都花在了生产那些到达时就会被丢弃的答案上。
依赖服务的属性让每个阶段都更糟糕。服务时间以秒计,所以队列消化得很慢。每个请求都要计费,所以处理一批已死请求的积压不仅仅是浪费容量,而是在为没有人收到的输出花钱。而且重试是昂贵的而不是免费的,所以这个放大循环是有代价的。
因此起点不是"队列应该多大",而是"队列必须是有界的,而且我必须决定到达边界时会发生什么"。无界队列不是被推迟的容量决策,它是一种做得很差的容量决策。
"队列超过 500 时拒绝"是一个没有任何意义的阈值,因为 500 个任务在好日子是三十秒的工作量,在坏日子是十五分钟——而坏日子恰恰是这个规则触发的时候。在做决策之前先把深度转换成时间:
// Little's Law,作为 admission control 规则使用。
// 预估等待时间 = 队列深度 / 服务速率
// 其中服务速率 = 并发数 / 平均服务时间。
function estimatedWaitMs(depth: number, concurrency: number, avgServiceMs: number) {
const perSecond = concurrency / (avgServiceMs / 1000);
return (depth / perSecond) * 1000;
}
function admit(job: Job, q: QueueStats): "run" | "reject" {
const wait = estimatedWaitMs(q.depth, q.concurrency, q.p50ServiceMs);
// 拒绝任何无法在调用者截止时间内完成的任务。
// 接受它意味着为在所有人停止监听之后才到达的答案付费。
if (wait + q.p95ServiceMs > job.deadline.remaining()) return "reject";
return "run";
}
两个属性让它比深度阈值更好。它会在服务时间变化时自动适应,而这种情况恰恰会打破固定阈值。而且它会产生一个诚实的拒绝消息:你可以告诉调用者预估等待时间和原因,这让行为良好的客户端能够智能地退避,而不是立即在同一堵墙上重试。
这个通用原则值得单独说明。永远不要开始你无法按时完成的工作。在有免费依赖的系统中,这条规则关乎效率。在这里它关乎金钱。
如果在平静时期就预先决定了优先级类别,负载丢弃会更容易。一个可用的默认集,从顶部开始丢弃:
推测性和预取工作。没有人请求的建议、预计算摘要、可以明天再跑的后台富化。丢弃它们没有代价,而且通常占总体积的很大比例。
已经失败的工作的重试。在过载情况下,重试是放大器。用占请求总数比例表示的重试预算——Google SRE 书中描述的客户端重试预算——从结构上限制它,而不是依赖每个调用者都表现得礼貌。
截止时间已过的请求。不占容量,对用户零影响,而且你很难想象饱和队列中有多少是这样的。
低级或超出配额的租户。如果你有多层服务,这就是它们存在的意义。在过载期间统一丢弃意味着你最有价值的流量和最没价值的流量受到的影响一样大。
等待中的用户的交互请求。排在最后。当你丢弃这些的时候,答案是容量而不是策略。
无论你丢弃什么,用 429 或 503 加上 Retry-After 来丢弃它。拒绝如果不携带时间信息会招致立即重试,而立即重试正是你试图阻止的事情。
队列通常按先进先出服务,这是公平的。在持续过载情况下,公平是错误的目标:最老的任务最可能已经超时,所以先进先出意味着系统性地服务那些最不可能被需要的请求。
在过载时服务最新的任务,意味着你完成的是那些调用者仍在等待的任务,所以一些请求会成功而不是所有请求都慢速失败。旧任务然后在过期时被丢弃而不是被执行。这是过载控制文献中的已知技术,确实会让人不舒服,因为它会故意抛弃最旧的工作——但在过载情况下的替代方案不是"每个人最终都会被服务",而是"每个人都会被服务得太晚"。
如果这感觉太激进,更温和的版本可以获得大部分好处:保持 FIFO 顺序,但在出队时检查每个任务的截止时间,不执行就丢弃已过期的任务。仅这一点就能把充满死工作的队列转换成按活工作速度排空的队列。
这个依赖增加了一个经典背压没有的维度。负载不是唯一可能耗尽的东西;金钱也可以。一个失控的循环、一个病毒式传播的页面或一个永远重试的 bug,可以完全在你的并发限制内,却在一个下午花掉一个月的预算。
所以在负载限制器旁边运行一个支出限制器,具有相同的结构:滚动窗口、阈值和与相同优先级类别绑定的丢弃策略。当每小时支出速率超过其上限时,首先丢弃推测性工作,然后降级到更便宜的降级阶梯,最后才拒绝。针对支出速率报警,而不是总支出——总支出告诉你的是事后,而速率在几分钟内就会告诉你。
每个租户的预算属于同一机制。没有它们,一个客户的失控集成是从所有其他人的容量和所有其他人的预算中服务的,第一个信号是账单。
在队列处丢弃是最后一道防线。更好的做法是让生产者慢下来,而实现这一点的机制平淡无奇却有效:限制队列大小,这样同步生产者会自然阻塞;对远程生产者返回 429 带上 Retry-After,这样行为良好的客户端会自我节流;在响应中暴露当前预估等待时间,这样客户端可以决定是否提交;对于内部生产者,让依赖前的信号量成为阻塞的东西,因为等待许可证的生产者是在免费施加自己的背压。
唯一永远不起作用的是要求生产者表现得体贴。背压必须是一种机制,因为礼貌在事故中存活不下来,在你无法控制的客户端面前也存活不下来。
Concurrency Control: Not Melting Your Own Rate Limit
Queues and Background Jobs for Slow AI Calls
Designing an AI Feature That Degrades Gracefully