为填充过快、难以耗尽的工作队列施加背压
当生产者的速度超过工作者时,无界队列并不能吸收负载,它只是推迟了崩溃。这就是背压如何让系统屹立不倒的。
生产者和工作者之间的队列感觉就像一个减震器。工作以突发方式到达,队列将其暂存,而工作者则按自己的节奏消耗它。只有当平均到达率低于平均消耗率时,这种直觉才成立。一旦生产者的速度长时间超过工作者,无界队列就会停止吸收任何东西。它会无限增长,内存或队列存储会耗尽,然后整个系统会立即崩溃。解决方法不是一个更大的队列。而是背压:一个有界队列,它将过载推回给生产者,而不是将其隐藏起来直到系统崩溃。本文将展示无界队列为何会失败,Go 语言中的背压是什么样的,以及学会拒绝所带来的权衡。
为什么无界队列是推迟崩溃,而不是吸收崩溃
把队列想象成一个水桶,水会流入也会流出。如果平均来看,流出速度至少和流入速度一样快,那么水位会保持在一定范围内,突发流量也会被平滑处理。如果平均流入速度哪怕只快一点点,水位就会永远上涨。没有任何大小的水桶能解决永久性的不平衡问题,水桶的大小只会改变你在它溢出前需要等待的时间。
无界队列移除了溢出点,这听起来像一个特性,但实际上是故障所在。不平衡依然存在,所以积压会持续增长,直到耗尽所有可用内存或填满队列存储的硬性限制。在此之前,有两件事会出问题,而且都比干净地拒绝请求更糟糕。
第一是延迟。一个任务进入队列时,如果前面有一百万个项目,它就需要等待这一百万个项目被处理完。当工作进程(worker)处理到它时,创建它的请求通常已经超时,用户已经离开,或者结果已经过时。你现在正把你稀缺的工作进程能力用在计算没人等待的答案上,这会进一步减慢处理速度,加深积压。这是一个会自行恶化的反馈循环。
第二是级联故障。当队列存储最终耗尽内存时,它不会温和地失败。它可能导致托管它的进程崩溃,阻塞每一个尝试入队的生产者,并拖垮共享同一台机器或同一个连接池的不相关服务。一个缓慢的工作进程会演变成系统范围的宕机。无界队列没有阻止崩溃,它只是推迟了崩溃,并使其规模变得更大。
背压是一个会反向施压的有界队列
背压是一种特性,即当下游阶段跟不上时,压力会向上传递到生产者,而不是在中间无形地堆积。构建它最简单的方法是使用一个有硬性容量限制的队列。当队列已满时,生产者无法入队,并且必须当场做出决定。那个决定就是关键所在。正是在这里,系统选择如何有目的地进行减载,而不是让负载来压垮它。
在 Go 中,其原生构件已经具备了背压感知能力。带缓冲的 channel 是一个有界队列,向一个已满的 channel 发送数据会阻塞发送方,直到一个工作者腾出空间。这种阻塞就是背压最直接的表现形式。
// A buffered channel of capacity N is a bounded queue. When it holds N jobs,
// the next send blocks until a worker receives one. The block is the
// backpressure: the producer cannot outrun the workers.
jobs := make(chan Job, 256)
// Producer. This send parks the goroutine if the buffer is full, so the
// producer's own rate is capped by how fast workers drain.
jobs <- job
当生产者可以承受放慢速度时,阻塞式发送是正确的行为,例如,一个没有用户在另一端等待的内部批处理加载器。减慢生产者的速度以匹配工作单元正是你想要的,因为这两个速率现在是耦合的,积压的任务不会增长到超出缓冲区。
但是,当存在有截止时间的用户或上游调用者时,阻塞式发送就是错误的行为。因为工作单元落后而让 HTTP 处理程序无限期阻塞,只是将无界等待从队列转移到了连接池,最终你会耗尽连接而不是内存。当有人在等待时,你希望快速拒绝而不是阻塞,这样调用者就能立即知道系统已经饱和,并可以进行回退或故障转移。
// Non-blocking enqueue for request paths. If the buffer is full, do not wait.
// Reject now so the caller gets a fast, honest "try later" instead of a hang.
select {
case jobs <- job:
// accepted
default:
return errQueueFull // surface as HTTP 429 or 503 with Retry-After
}
阻塞生产者或拒绝任务这两个选项,是背压的一体两面。阻塞使速率耦合。拒绝则丢弃超额部分。为每个生产者选择哪一种方式取决于是否有等待者,而一个真实系统通常会在不同地方同时使用这两种方式。
限制并发,使工作单元不会淹没下游
限制队列可以保护你免受生产者的影响。你还必须限制工作单元,因为工作单元是其下游任何事物的生产者,通常是数据库、GPU 或其他服务。一个常见的错误是为每个任务生成一个 goroutine。这样做看起来很简洁,并且完全移除了队列,但这只是将无限增长的问题转移到了别处。一万个传入的任务变成一万个 goroutine 同时冲击数据库,这时数据库就会崩溃。
固定的工作单元池就是这个上限。你启动已知数量的工作单元,每个工作单元都从有界队列中拉取任务,这个数量就是将要访问下游资源的最大并发数。工作单元池的大小成为你根据下游容量设定的一个有意为之的限制,而不是由到达的流量多少偶然决定的。
// Fixed worker pool. poolSize is the hard ceiling on concurrent work hitting
// the downstream resource. It does not grow with load, which is the point.
func startPool(poolSize int, jobs <-chan Job) {
var wg sync.WaitGroup
for i := 0; i < poolSize; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job) // the only place downstream load is generated
}
}()
}
wg.Wait()
}
现在,这两个边界协同工作。缓冲通道限制了可以等待的工作量,而池则限制了可以运行的工作量。对下游的总负载最多是 poolSize 个并发操作,外加一个最多为缓冲容量的队列,并且当流量高峰到来时,这两个数字都不会改变。流量高峰会冲击入队路径,在那里被拒绝或减速,而绝不会像洪水一样涌向数据库。应根据下游的容量来选择池的大小,例如数据库的连接池限制或你拥有的 GPU 数量,而不是凭感觉选一个宽裕的整数。
丢弃已经太旧而无关紧要的工作
为队列和池设置边界可以让系统保持稳定,但在持续过载的情况下,当工作单元(worker)开始处理那些得以进入的任务时,这些任务可能已经过时了。如果一个请求的时限是五秒,而一个任务已经等待了八秒,那么运行它就是纯粹的浪费。你花费工作单元的时间去生成一个调用者已经放弃的结果,这会挤占那些仍有机会成功的新任务的处理能力。
为每个任务设置一个截止日期可以解决这个问题。给任务标记上它必须完成的时间,并让工作单元在开始执行耗时部分之前检查该截止日期。如果截止日期已过,就丢弃该任务,继续处理下一个。在 Go 语言中,惯用的载体是带有截止日期的上下文(context),它也允许你取消已经在进行中的工作。
type Job struct {
Payload []byte
Deadline time.Time
}
func process(job Job) {
// Skip work that can no longer be useful. Under overload this is what
// keeps workers spending their time on jobs someone still wants.
if time.Now().After(job.Deadline) {
metrics.Expired.Inc()
return
}
ctx, cancel := context.WithDeadline(context.Background(), job.Deadline)
defer cancel()
doExpensiveWork(ctx, job.Payload)
}
丢弃过时的工作是一种着眼于时间而非容量的背压形式。它承认有些任务已经失效,并拒绝让它们在退出时消耗资源。在高峰期间,这能让一个饱和的系统继续为尚存的请求提供服务,而不是费力地处理积压的僵尸任务。
无界队列与反压:并排比较
在关键场景下,即持续过载(到达速率在一段有意义的时间内持续高于消耗速率)时,这两种设计的差异最容易看出来。
| 持续过载下的行为 | 无界队列 | 带反压的有界队列 |
|---|---|---|
| 队列深度 | 无限制增长 | 上限为缓冲区大小 |
| 内存或队列存储 | 填充直至耗尽 | 保持平稳 |
| 已接受作业的延迟 | 无限制上升 | 受限于缓冲区加服务时间 |
| 生产者得知的信息 | 无,直到崩溃 | 立即得知,通过阻塞或拒绝 |
| 故障形态 | 一次性完全崩溃 | 部分故障,一些作业被拒绝 |
| 下游(数据库、GPU) | 因 goroutine 派生而泛滥 | 上限为池大小 |
| 峰值过后的恢复 | 必须先排空巨大的积压 | 已接近稳态 |
无界队列这一列描述的系统并不能处理更多负载。它处理的是相同的负载,但会将故障隐藏起来,直到造成灾难性后果。有界队列这一列描述的系统会更早、更小、更清晰地发生故障,而这正是你希望从故障中得到的。
优先级、隔离和不会放大问题的重试
另外两个部分使背压变得实用而非生硬。第一个是,并非所有工作都是平等的。如果健康检查和批量导入共享一个队列,大量的导入请求将使健康检查“饿死”,而你的监控系统恰恰在你最需要它的时候失灵。使用独立的队列和独立的池来隔离重要的工作,这样一条通道的过载就不会淹没另一条。为关键作业设置一个小的专用池,可以保持它们的流畅运行,即使在批量处理通道拒绝所有请求时也是如此。
第二个是重试。重试一个被拒绝的作业是合理的,但一个简单的重试循环会让背压演变成一场重试风暴。当系统因饱和而拒绝一个作业时,立即重试会给已经饱和的系统增加负载;如果每个客户端都这样做,那么仅重试本身就可能让系统在最初的流量高峰过去很久之后仍然无法恢复。重试需要一种随每次尝试而增长的退避机制,以及对尝试次数的硬性上限,这样一次拒绝就会导致下一次尝试变慢,而不是立即进行第二次冲击。背压和退避是在两端应用的相同理念。服务器通过拒绝来施加压力,而客户端则通过减慢速度而非更猛烈地冲击来响应这种压力。
可观测性将这一切联系在一起。队列深度和等待时间是你的早期预警信号。一个趋于满负荷的队列,或者一个逐渐接近截止时间的等待时间,会在任何请求被拒绝之前就告诉你系统失衡已经开始。对这两个数字设置警报,你就可以按照自己的计划增加工作进程或减慢生产者的速度,而不是通过一次服务中断才发现问题。
权衡:局部故障是维持运行的代价
背压并非没有代价,我们应该坦诚地指出其成本。有界队列会拒绝任务。有上限的池会处理更少的并发请求。截止时间会丢弃那些付出了实际努力才产生的工作成果。这些都是局部故障,而某些用户体验到的就是一个未能通过的请求。如果你以从不拒绝任何请求来衡量成功,那么背压看起来就像是一种倒退。
有意义的比较,不是将背压与一个能接受一切的完美系统进行比较。一旦生产者的速度能够超过工作者,这样的系统就不复存在。比较的对象应该是局部故障与完全故障。无界队列会接受每一个任务,直到它再也无法接受任何任务并导致整个进程崩溃的那一刻。而有界队列会持续地拒绝一小部分任务,并继续为其余的任务提供服务。这就是优雅降级,也正是这个特性,区分了那些在负载下摇摇欲坠的系统和那些直接崩溃的系统。
那种先接受所有工作,之后再想办法处理的本能,在低负载时是没问题的,而大多数系统在大部分时间里都处于低负载状态。稳定性来自于系统在边缘状态下的行为,即当负载很高并持续上升时,懂得如何拒绝就成了一项有用的技能。一个能说“不”的队列是在保护自己。背压并非系统故障。它是系统在选择承受哪些小的故障,从而永远不必承受那个大的故障。