SEO 摘要: 本文介绍如何避免异步系统中的队列积压问题,重点讨论消息队列、延迟、可用性、多租户公平性、死信队列、延迟队列、背压、限流和工作负载隔离等设计方法,帮助团队降低系统恢复时间并提升异步架构的可靠性。

简介
从现实生活中理解队列和算法
自从大学上第一门计算机科学课程以来,我就一直对算法在现实世界中的应用很感兴趣。很多时候,我们可以从现实生活中的现象出发,抽象出对应的算法。尤其是在排队时,比如在杂货店、道路上或机场里,我经常会这样思考。排队无聊时,正好可以想想排队论。
十多年前,我曾在海外某大型公司的物流中心工作过一天。那一天,我在算法的引导下,从货架上取下商品,把商品从一个箱子转移到另一个箱子里,再把箱子搬走。我发现,和许多人一起协同工作时,最有意思的地方在于:整个工作本质上像是一个经过精心编排的物理版归并排序,而我自己正是这个排序过程的一部分。
在排队论中,队列较短时的行为通常并不那么有趣。毕竟,队列短的时候,大家都很满意。只有当队列出现严重积压,比如活动队伍排到门外并绕过街角时,人们才会开始认真思考吞吐量和优先级。
本文将讨论海外某些大型技术团队在处理队列积压时采用的策略,包括如何设计异步系统,使其能够快速释放消息队列,并对工作负载进行优先级管理。首先,我会介绍最重要的一点:如何防止队列积压。文章前半部分会说明哪些情况会导致队列积压,后半部分则介绍一些避免积压或更优雅地处理积压的方法。
队列的两面性
队列是构建可靠异步系统的强大工具。队列允许一个系统从另一个系统接收消息,并在消息被完全处理之前一直保留这些消息。即使发生长时间中断、服务器故障或依赖系统异常,队列也不会轻易丢弃消息,而是会持续驱动消息处理,直到消息成功完成。
最终结果是,队列可以提升系统的持久性和可用性。但它也会带来代价:由于重试机制的存在,系统延迟有时会增加。
在许多大型系统中,队列被广泛用于构建异步架构。有些系统会处理耗时较长的工作流;有些系统会处理涉及全球实物移动的业务流程,例如订单履约;有些系统会协调多个耗时步骤,例如请求计算实例、等待实例启动,然后完成数据库配置;还有些系统会利用批处理优势,例如指标和日志摄取系统会收集大量数据,然后将数据汇总并合并成数据块。
使用队列异步处理消息的优势很明显,但风险也同样存在,只是更加隐蔽。多年来,工程团队发现,原本用于提升可用性的队列,有时反而会适得其反。事实上,队列可能会显著拉长系统在中断后的恢复时间。
在基于队列的系统中,如果处理停止,但消息仍不断到达,系统就会不断累积“消息债务”,形成大量队列积压,从而增加处理时间。如果工作完成得太晚,其结果可能已经失去价值,最终反而造成可用性问题。而这恰恰是队列原本希望避免的问题。
换言之,基于队列的系统具有两种运行模式,也可以说具有“双重行为”。当队列没有积压时,系统延迟较低,处于快速模式。但是,如果故障或意外负载模式导致消息到达速度超过处理速度,系统就会迅速进入更危险的运行模式。在这种模式下,端到端延迟会越来越高,而且可能需要很长时间才能处理完积压,重新回到快速模式。
基于队列的异步系统
为了更好地说明基于队列的系统,下面以两类云服务背后的工作原理为例:一类是函数计算服务,它可以响应事件并执行代码,而调用方不需要关心底层运行基础设施;另一类是物联网核心服务,它可以让连接设备安全、便捷地与云应用程序和其他设备交互。
在函数计算服务中,用户可以上传函数代码,并通过两种方式之一调用函数:
- 同步调用:函数输出会在 HTTP 响应中返回;
- 异步调用:HTTP 响应会立即返回,而函数在后台执行,并在失败时重试。
函数计算服务需要确保即使服务器发生故障,函数也可以继续运行。因此,它需要使用持久队列来存储请求。借助持久队列,如果函数第一次运行失败,请求仍然可以被重新发送并再次处理。
在物联网核心服务中,设备可以连接到应用程序,并订阅发布—订阅消息主题。当设备或应用程序发布消息时,具有匹配订阅的应用程序会收到自己的消息副本。
由于许多受限的物联网设备不希望消耗有限资源,等待所有订阅设备、应用程序和系统都收到消息副本,因此大多数发布—订阅消息传递都是异步完成的。这一点尤其重要,因为当另一台设备发布某条消息时,对该消息感兴趣的订阅设备可能处于离线状态。离线设备重新连接后,通常希望先恢复连接状态,再接收积压消息。因此,系统后台需要进行多种持久化和异步处理。
这类基于队列的系统通常会使用持久队列来实现。托管消息队列服务可以提供持久、可扩展、至少一次的消息传递语义,因此许多大型技术团队在构建可扩展异步系统时,都会选择这类服务。在基于队列的系统中,一个组件通过将消息放入队列来生产数据,另一个组件则通过定期拉取消息、处理消息,并在完成后删除消息来消费这些数据。
异步系统故障如何导致队列积压
在函数计算服务中,如果函数调用比平时更慢,例如因为依赖项变慢,或者函数暂时发生故障,系统不会丢失数据,而是会重新尝试调用函数。函数计算服务会将调用排入队列。当函数重新恢复正常后,系统会处理积压的调用。
不过,我们需要关注一个问题:处理这些积压并恢复正常到底需要多久?
假设某个系统在处理消息时发生了一小时中断。无论消息到达速率和处理能力如何,要在恢复后一小时内处理完积压并恢复正常,系统容量就需要翻倍。实际情况中,系统可用容量可能会超过所需容量,尤其是在使用弹性服务时,因此恢复速度可能更快。
但另一方面,当系统处理积压时,函数所交互的其他系统未必已经准备好承受突然增加的大量积压请求。在这种情况下,追赶进度可能需要更长时间。异步服务会在中断期间积累队列积压,从而导致恢复时间较长。而同步服务不同,它通常会在中断期间丢弃请求,因此恢复速度反而更快。
多年来,在讨论队列时,人们有时会认为延迟对异步系统并不重要。异步系统通常是为了提升持久性而构建的,或者是为了避免直接调用方承担延迟而构建的。但实践表明,处理时间其实非常重要。很多时候,我们甚至希望异步系统的延迟达到亚秒级,或更短。
当为了提高持久性而引入队列时,人们很容易忽略积压带来的高处理延迟。异步系统的隐性风险,正是在于处理大量积压所需的时间。
如何衡量异步系统的可用性与延迟
关于如何权衡可用性与延迟的讨论,会引出一个有趣的问题:我们应该如何衡量异步服务的延迟和可用性,并为它们设定目标?
从生产者角度衡量错误率,可以让我们对可用性有一定了解,但这种了解相对有限。生产者侧的可用性大体上取决于队列本身的可用性。因此,如果系统建立在高可用的托管队列服务之上,生产者侧可用性通常也会接近队列服务本身的可用性。
另一方面,如果从消费者端衡量可用性,系统看起来可能会比实际情况更差。原因是,某次处理失败后,消息可能会被重试,并在下一次尝试中成功。
我们也可以通过死信队列(DLQ)来衡量可用性。如果消息无法成功处理,系统最终会丢弃它,或将其放入死信队列。死信队列是一个单独的队列,用于存储无法处理的消息,方便后续调查和人工干预。被丢弃消息或进入死信队列消息的比例,是一个很好的可用性衡量指标。不过,当我们通过这个指标发现问题时,通常已经太晚了。虽然可以为死信队列中的消息数量设置告警,但死信队列信息到达得太晚,不能完全依赖它来检测问题。
那么,如何衡量延迟呢?
同样,生产者观察到的延迟主要反映的是队列服务本身的延迟。因此,我们会把更多精力放在衡量消息在队列中的存活时间上。这样可以快速发现系统是否落后,或者是否频繁出错并触发重试。每当消息到达队列时,许多队列服务都会提供时间戳。基于这个时间戳,每次从队列中取出消息时,系统都可以记录并生成“系统落后程度”的指标。
不过,延迟问题可能更加微妙。毕竟,某些积压是可以预期的,而且对某些消息来说也是可以接受的。例如,在物联网场景中,设备有时可能离线,或者读取消息的速度较慢。这是因为许多物联网设备功耗低、网络连接不稳定。作为物联网核心服务的运营方,团队需要区分两类情况:一种是设备离线或主动缓慢读取消息所导致的预期小规模积压;另一种是系统范围内出现的意外积压。
在物联网系统中,可以使用类似 AgeOfFirstAttempt 的指标来检测服务状态。这个指标记录的是当前时间减去消息入队时间,但前提是这是系统第一次尝试将消息传递给设备。这样一来,当设备本身存在积压时,团队仍然可以获得一个较为纯净的指标,不会因为设备重试处理消息或重新排队而受到干扰。
为了让指标更加纯净,还可以使用第二个指标,例如 AgeOfFirstSubscriberFirstAttempt。在发布—订阅系统中,可以订阅某个主题的设备或应用程序数量通常没有实际上限。因此,将消息发送给一百万个设备时,延迟自然会高于发送给单个设备时的延迟。为了获得稳定指标,系统可以在第一次尝试向某个主题的第一个订阅者发布消息时,记录计时器指标。此外,还可以使用其他标准,衡量系统发布剩余消息的进度。
AgeOfFirstAttempt 这类指标可以作为系统范围问题的早期告警。它之所以有效,很大程度上是因为它过滤掉了那些选择更慢读取消息的设备所带来的噪音。值得注意的是,类似物联网核心服务这样的系统,实际检测的指标远不止这些。但在所有与延迟相关的指标中,一种常见策略是将首次尝试的延迟与重试尝试的延迟分开分类。
由于请求可能在服务器之间来回跳转,也可能在各个系统之外出现延迟,因此衡量异步系统的延迟和可用性并不容易,调试也很困难。为了帮助进行分布式追踪,团队通常会在排队消息中传播请求 ID,以便将相关链路拼接起来。也可以使用分布式追踪系统来辅助解决这类问题。
多租户异步系统中的队列积压
许多异步系统都是多租户系统,会代表许多不同客户处理工作。这为延迟和可用性管理增加了一个复杂维度。
多租户的优势在于,它可以避免团队分别运营大量独立队列,并且在运行混合工作负载时提升资源利用率。但是,客户通常希望系统表现得像自己的单租户系统一样:无论其他客户的工作负载如何变化,自己的工作负载都能获得可预测的延迟和较高的可用性。
在将消息放入队列时,云服务通常不会直接向调用方暴露内部队列,而是提供轻量级 API。该 API 会对调用方进行身份验证,并在每条消息入队前附加调用方信息。这类似于前面介绍的函数计算架构:当用户异步调用函数时,函数计算服务会将消息放入内部队列并立即返回,而不是直接暴露内部队列。
这些轻量级 API 还可以帮助系统增加公平性限制。在多租户系统中,公平性非常重要,它可以确保任何单个客户的工作负载都不会影响其他客户。实现公平性的一种常见方式,是对每个客户进行速率限制,同时允许一定程度的突发流量。在许多系统中,随着客户逐步增长,团队也会提高对应客户的限制。这些限制可以在意外峰值发生时提供保护,为后台容量调整争取时间。
从某些方面看,异步系统中的公平性和同步系统中的限流类似。但在异步系统中,这一点更加重要,因为大量队列积压可能会迅速堆积起来。
为了说明这一点,可以想象一个异步系统缺乏足够“吵闹邻居”保护时会发生什么。如果系统中某个客户的流量突然失控,并在系统内造成队列积压,运维人员可能需要 30 分钟才能介入、了解情况并缓解问题。
在这 30 分钟内,生产者端可能扩展得很好,并将所有消息都成功排入队列。但是,如果排队消息数量达到消费者扩展后容量的 10 倍,就意味着系统可能需要 300 分钟才能处理完积压并恢复正常。即使短时间的流量峰值,也可能导致数小时的恢复时间,进而造成数小时的服务影响。
实际系统中通常会有许多补偿机制,用于最大限度减少或防止队列积压带来的负面影响。例如,自动扩展可以帮助缓解负载增加带来的问题。但是,即使暂时不考虑这些补偿机制,单独分析队列造成的影响也很有价值,因为这有助于设计在多个层面都更可靠的系统。
实践中,有一些设计模式可以帮助系统避免大量队列积压和过长恢复时间。
异步系统中,每一层保护都很重要
同步系统通常不希望出现积压,因此会通过入口限流和准入控制来保护系统。而在异步系统中,系统的每个组件都需要保护自己,避免过载,并防止某个工作负载消耗超过合理份额的资源。总会有一些工作负载绕过入口准入控制,因此系统需要额外保护,防止服务过载。
使用多个队列帮助调节流量
从某些角度看,单一队列和多租户天然存在矛盾。当所有工作都排在共享队列中时,很难将一个工作负载与其他工作负载隔离开来。
实时系统通常用 FIFO 队列实现,但更希望具备类似 LIFO 的行为
客户反馈表明,当队列出现积压时,他们通常更希望系统优先处理新数据。当容量可用时,再处理中断或流量峰值期间堆积下来的旧数据。
队列积压治理:构建弹性多租户异步系统的策略
大型异步系统通常会使用多种模式,以确保多租户异步系统能够适应工作负载变化。相关技术很多,而不同系统也有各自不同的活跃性和持久性要求。下面介绍一些常见模式,以及云服务客户也可以在自身系统中采用的方法。
对于企业研发团队而言,队列积压治理并不只是架构设计问题,也涉及需求分析、缺陷跟踪、测试验证、发布上线和故障复盘等完整研发流程。借助 PingCode 这类智能化研发管理工具,团队可以将队列治理相关的需求、任务、缺陷、测试结果、发布记录和 Wiki 复盘沉淀串联起来,让异步系统的稳定性改进过程更加数据化、自动化和可追踪。
将工作负载拆分到独立队列中
在某些系统中,与其让所有客户共享一个队列,不如为每个客户提供自己的队列。为每个客户或工作负载增加队列并不总是最划算的做法,因为服务轮询所有队列需要消耗资源。但在客户数量较少的系统或相邻系统中,这种简单方案可能非常实用。
另一方面,如果系统有数十个甚至数百个客户,独立队列就会变得臃肿。例如,在物联网系统中,不可能为环境中的每一台设备都使用一个独立队列,因为轮询成本无法良好扩展。
随机分区
在某些系统中,为每个客户轮询一个独立队列成本很高;但只使用一个队列,又会带来前文提到的一些问题。因此,一种折中方法是预置固定数量的队列,并将每个客户哈希到少量队列上。
在消息入队之前,系统会检查目标队列中哪一个消息最少,然后将消息加入该队列。当某个客户的工作负载增加时,它会在自己映射到的队列中造成积压,但其他工作负载会自动从这些队列中路由出去。实现一定程度的资源隔离,并不一定需要大量队列。这种技术也被许多大型服务采用。
将过量流量分流到单独队列
从某些方面说,等到队列已经出现积压后再为流量确定优先级,已经有些晚了。但是,如果处理消息的成本较高或耗时较长,把部分消息移动到单独的溢出队列中,仍然有价值。
在某些系统中,消费者服务会实现分布式限流。当消费者从队列中取出某个已经超过配置速率的客户消息时,会将这些多余消息重新放入单独的溢出队列中,并从主队列删除。一旦有资源可用,系统仍然可以处理溢出队列中的消息。本质上,这近似于优先级队列。
类似逻辑有时也可以在生产者端实现。这样,如果系统从某个工作负载接收到大量请求,该工作负载就不会把热路径队列中的其他工作负载挤出去。
将旧流量分流到单独队列
与分流过量流量类似,也可以分流较旧的流量。当系统从队列中取出消息时,可以查看消息已经存在多久。除了记录消息年龄之外,系统还可以利用这些信息决定是否将消息移动到积压队列中。只有当实时队列处理完后,系统才处理积压队列。
如果某个系统需要在负载高峰期摄取大量数据,但处理进度已经落后,那么它可以迅速将流量峰值分流到另一个队列中。分流速度取决于系统从队列取出消息再重新放入队列的速度。相比简单地按顺序处理整个积压队列,这种方法可以释放消费者资源,使系统更快处理新消息。这是一种近似 LIFO 的排序方法。
删除过旧消息:消息存活时间
一些系统可以丢弃较旧的消息。例如,某些系统会快速处理增量变更,但也会定期执行完整同步。我们通常将这类周期性同步系统称为反熵清理工具。
在这种情况下,如果某条消息是在最近一次清理之前进入队列的,系统就可以以较低成本直接丢弃它,而不必再分流这些较旧的排队消息。
限制每个工作负载使用的线程和其他资源
和同步服务一样,异步系统也需要防止某个工作负载使用超过合理份额的线程资源。
以物联网系统中的规则引擎为例,客户可以配置系统,将设备消息路由到客户自己的搜索集群、数据流或其他后端资源。如果这些客户资源响应变慢,但传入消息速率保持不变,系统中的并发量就会增加。由于系统在任意时刻能够处理的并发量是有限的,规则引擎必须防止任何一个工作负载消耗超过合理份额的并发资源。
利特尔法则描述了系统中的这一影响:系统中的并发量等于到达率乘以每个请求的平均延迟。例如,如果服务器以平均 100 毫秒的速度每秒处理 100 条消息,那么平均会消耗 10 个线程。如果延迟突然增加到 10 秒,它就会突然使用 1000 个线程,实际峰值可能更多,这很容易耗尽线程池。
规则引擎可以使用多种技术防止这种情况发生。它可以使用非阻塞 I/O 避免线程耗尽,不过单台服务器仍然会受到其他资源限制,例如内存、连接文件描述符数量和依赖超时时间。第二种并发保护是使用信号量,用于衡量和限制任意时刻单个工作负载可使用的并发量。
规则引擎还可以使用基于速率的公平性限制。由于工作负载随时间变化是正常现象,系统也可以随时间自动调整限制,以适应工作负载变化。此外,由于规则引擎是基于队列的,它可以在设备与后台资源之间充当缓冲层,也可以在安全限制自动扩展期间吸收短期波动。
在许多服务中,团队会为每个工作负载使用独立线程池,避免一个工作负载消耗所有可用线程。也可以使用计数器限制每个工作负载允许的并发,并使用基于速率的限制方法隔离与速率相关的资源。
使用背压防止队列积压继续扩大
如果某个工作负载导致了不合理的队列积压,而消费者处理速度跟不上,许多系统会自动开始更积极地拒绝生产者提交的新工作。一个工作负载很容易在一天之内堆积出大量积压。即使已经隔离该工作负载,它仍可能带来意外和较高处理成本。
这种方法可以非常简单:系统只需要定期测量某个工作负载的队列深度,前提是该工作负载位于自己的队列中,然后根据积压大小反向调整入站限制即可。
如果多个工作负载共享一个队列,这种方法就不那么容易实现。虽然许多消息队列服务可以返回队列中的消息数量,但未必能返回具有某个特定属性的消息数量。系统仍然可以测量整体队列深度,并据此施加背压,但这会影响恰好共享同一队列的无辜工作负载,显然不够公平。有些消息系统可以提供更细粒度的积压可见性,因此更适合实现这类能力。
并不是所有系统都适合使用背压。例如,在订单处理系统中,即使后台已经出现积压,团队通常也更愿意继续接收订单,而不是阻止新订单进入。当然,这需要配套的后台优先级排序机制,确保最紧急的订单优先处理。
使用延迟队列推迟工作
当系统意识到需要降低某个工作负载的吞吐量时,可以对该工作负载使用退避策略。通常可以借助消息队列服务的延迟投递能力来实现。
当系统处理一条消息并决定稍后再处理时,可以将该消息重新排入单独的突发队列,并设置延迟参数,使该消息在延迟队列中保持几分钟不可见。这样,系统就可以优先处理更新的数据。
避免在途消息过多
许多队列服务会限制能够同时传递给消费者的在途消息数量。这个限制不同于队列中可以存在的消息总数;队列中的消息总数通常没有实际限制,但消费者一次处理的在途消息数量是有限的。
如果系统把消息从队列中取出,在途消息数量就会增加;如果随后无法删除这些消息,就可能触发问题。例如,有些错误会导致代码在处理消息时未捕获异常,并且忘记删除消息。在这些情况下,从队列服务视角看,消息在可见性超时时间(VisibilityTimeout)内仍然处于在途状态。
因此,在设计错误处理和过载策略时,需要牢记这些限制。与其让过量消息保持不可见状态,不如倾向于将它们移动到其他队列。
FIFO 队列也有类似限制,只是细节略有不同。使用 FIFO 队列时,系统会按顺序消费给定消息组中的消息,但不同消息组之间的消息并不一定按顺序处理。因此,如果某个消息组中出现少量积压,系统仍然可以继续处理其他消息组中的消息。
不过,一些 FIFO 队列只会轮询最近一段范围内未处理的消息。如果某些消息组中的未处理消息超过该范围,其他包含新消息的消息组就可能无法及时得到处理。
使用死信队列处理无法处理的消息
无法处理的消息会导致系统过载。如果系统收到无法处理的消息,例如消息触发了输入验证的边缘情况,消息队列服务可以通过死信队列功能,将这些消息自动移动到一个单独队列中。
只要死信队列中出现任何消息,团队就应该发出告警,因为这意味着存在需要修复的错误。死信队列的优势在于,团队可以在修复错误后重新处理这些消息。
确保每个工作负载在轮询线程中都有额外缓冲
如果某个工作负载的吞吐量已经高到即使在稳定状态下,轮询线程也始终处于繁忙状态,那么系统可能已经进入没有缓冲能力的状态,无法吸收突发流量。
在这种状态下,传入流量的小幅峰值也会导致持续的未处理积压,进而增加延迟。团队需要在轮询线程中预留缓冲,以应对此类突发情况。
一种衡量方式是跟踪轮询尝试中返回空响应的次数。如果每次轮询都能取回一条或多条消息,那么要么说明轮询线程数量刚刚合适,要么说明轮询线程不足,无法满足传入流量需求。此时需要结合其他指标进一步判断。
为长时间运行的消息续约或发送心跳
当系统处理队列消息时,队列服务会给系统一定时间来完成处理。如果超过这段时间,队列服务会假设处理方已经崩溃,并将消息重新传递给另一个消费者重试。
如果代码继续运行,却忘记了这个截止时间,同一条消息可能会被多次并行处理。第一个处理器在超时后仍在处理消息,第二个处理器会接收到该消息,并以类似方式在超时后继续处理,随后可能还有第三个、第四个处理器,依此类推。
因此,消息处理逻辑应当在消息过期时停止工作,或者持续向队列服务发送心跳,告知系统仍在处理该消息。这个概念类似于领导者选举中的租约。
这是一个隐患,因为系统延迟可能在过载期间增加。例如,数据库查询可能变慢,或者服务器承担了超过自身处理能力的工作。当系统延迟超过可见性超时时间阈值时,已经过载的服务可能会出现类似 fork 炸弹的效果,让同一条消息被多个消费者重复处理,进一步加剧压力。
提前规划跨主机调试能力
理解分布式系统中的故障非常困难。前文介绍了一些用于检测异步系统的方法,例如定期记录队列深度、传播追踪 ID,以及与分布式追踪系统集成。
当系统包含比普通消息队列更复杂的异步工作流时,团队通常会使用工作流编排服务。这类服务可以直观展示工作流,并简化分布式调试。与此同时,故障排查往往还涉及任务分派、文档记录、沟通同步、工时投入和复盘审批等协作环节,团队可以通过 Worktile 这类通用项目协作系统统一管理相关事项,减少跨团队排查过程中的信息遗漏。
结论:通过优先级、分流和背压减少队列积压
在异步系统中,人们很容易忽略延迟的重要性。很多人会认为异步系统有时确实应该花更长时间,因为它们前面有一个用于可靠重试的队列。
但是,过载和故障可能会堆积大量难以处理的队列积压,导致服务无法在合理时间内恢复。造成积压的情况很多:可能是某个工作负载或客户以意外的高频率向队列中写入消息;也可能是处理某类工作负载的成本比预期更高;还可能是依赖系统出现延迟或故障。
在构建异步系统时,团队需要关注并预测这些积压情况,并通过优先级划分、分流、限流和背压等手段,尽可能减少队列积压,避免系统进入难以恢复的状态。
文章包含AI辅助创作:队列积压治理:如何避免异步系统出现难以恢复的消息积压,发布者:shang,转载请注明出处:https://worktile.com/kb/p/3975168
微信扫一扫
支付宝扫一扫