工程实战

同步请求路径的队列与背压设计:网关层怎么不被打爆

之前写过 LLM 流式响应背压处理(讲的是客户端消费 SSE 流跟不上时怎么办),也写过 LLM 异步任务队列设计(讲的是后台任务队列用 BullMQ/Celery 怎么搭)。这两篇覆盖的都不是本文要讲的场景——同步请求路径上的排队:用户发一个请求过来,网关要在几百毫秒到几秒内决定”接、排、还是拒”,而不是把任务丢进后台队列慢慢处理。

这个决策在 LLM API 网关/中转层特别关键:上游模型服务商限流了、某个区域延迟飙升了、瞬时流量超过后端处理能力了,网关必须实时做出反应,而不是无脑排队等死。

一、为什么同步网关不能照搬后台任务队列的思路

后台任务队列(BullMQ/Celery 那一套)的核心假设是:调用方不在乎立刻拿到结果,队列可以无限堆积,Worker 慢慢消化。但同步 API 请求完全相反——调用方在等一个响应,队列每多等一秒,都是实打实的延迟成本。

这意味着同步路径的队列必须回答一个后台队列不需要回答的问题:队列多长就该拒绝新请求,而不是让它排队? 这个阈值不是拍脑袋定的,要跟下游超时预算挂钩。

二、队列深度与超时预算的联动

假设你的服务对外承诺 P99 延迟 8 秒,其中模型调用本身平均耗时 3 秒。留给排队等待的预算就只有 5 秒左右。如果当前队列里有 20 个请求在排队,每个平均处理 3 秒,那么第 21 个请求进来时预计要等 60 秒才能开始处理——早就超过了 5 秒预算,这种情况下应该直接拒绝,而不是让它排队排到超时才失败。

关键公式:

预计等待时间 = 队列深度 × 平均处理耗时 / 并发处理数

如果预计等待时间已经超过剩余超时预算,网关应该立刻返回 429,并带上 Retry-After 头,而不是把请求塞进队列再让它在超时那一刻失败——尽早失败比”排队排到超时”对调用方更友好,调用方能立刻决定重试还是降级,而不是白等一整个超时周期。

三、降级、排队、拒绝:一个简单的决策树

新请求到达
  ├─ 队列深度 < 低水位线?          → 直接排队处理
  ├─ 队列深度 ∈ [低水位, 高水位]?   → 排队,但优先级低的请求开始被限速
  └─ 队列深度 > 高水位线?          → 拒绝新请求(429 + Retry-After)
                                      或触发降级(换更快的小模型 / 返回缓存结果)

低水位和高水位的具体数值要通过压测得出,不是配置文件里抄一个通用值就能用。经验做法:先用一天的真实流量做基线,取处理耗时 P95 作为参考,再逐步调高负载观察队列积压速度,找到系统开始出现级联超时的拐点,把高水位线设在拐点之前留出安全边际。

降级比拒绝更友好,但不是所有场景都能降级。如果业务允许,用更小更快的模型顶上、或者返回一个稍旧的缓存结果,比直接 429 体验好得多。但如果请求语义要求必须用指定模型(比如客户明确要求用 Claude Opus 而不是 Haiku),降级就不成立,只能排队或拒绝。

四、多租户场景:一个大客户不能打爆所有人的队列

如果网关服务多个租户(这是 API 中转场景的常态),共享一条队列会导致一个流量突增的租户把所有其他租户的请求都挤到超时。解决方式是按租户分片的有界队列 + 全局并发预算:

from collections import defaultdict, deque
import time

class TenantAwareQueue:
    def __init__(self, per_tenant_limit=10, global_limit=100):
        self.queues = defaultdict(deque)
        self.per_tenant_limit = per_tenant_limit
        self.global_limit = global_limit
        self.in_flight = 0

    def try_enqueue(self, tenant_id, request):
        if len(self.queues[tenant_id]) >= self.per_tenant_limit:
            return False, "tenant_queue_full"
        if sum(len(q) for q in self.queues.values()) >= self.global_limit:
            return False, "global_queue_full"
        self.queues[tenant_id].append((request, time.time()))
        return True, None

    def dequeue_fair(self):
        # 轮询各租户队列,避免某个租户长期占用处理槽位
        for tenant_id, q in self.queues.items():
            if q:
                return tenant_id, q.popleft()
        return None, None

这里的关键设计是双层限制:单租户配额防止一个租户耗尽全局资源,全局配额防止总排队量超过系统承受能力。出队用轮询而非严格 FIFO,避免大租户的请求量优势转化成处理顺序优势。

五、可观测性:三个必须监控的指标

排队和背压策略调得对不对,光看错误率是不够的,至少要盯这三个指标:

指标说明异常信号
队列深度分布各时间点的队列长度,而非仅看瞬时值长期在高水位附近徘徊,说明容量不足
排队等待时间 P50/P99请求从入队到开始处理的耗时P99 持续接近超时预算,说明背压触发过晚
拒绝率(按租户拆分)429 占该租户总请求的比例单个租户拒绝率异常高,可能是配额设置不合理或该租户流量模式变化

拒绝率这个指标容易被忽略,但它是判断”背压策略是不是太保守”的直接信号——如果长期没有任何请求被拒绝,可能说明高水位线设得太高,系统其实一直在临界点运行,只是还没遇到真正的流量尖峰。

六、常见坑

  • 把排队等待时间算进用户可见延迟之外:有些实现只统计”处理耗时”不统计”排队耗时”,导致监控面板看起来一切正常,用户却在抱怨慢。延迟指标必须从请求到达网关那一刻开始计时。
  • 超时后没有主动清理队列里的僵尸请求:调用方已经因为客户端超时断开连接,但请求还在队列里排着,继续占用处理槽位。网关应该在出队时先检查请求是否已经过期或连接是否已断开,过期的直接丢弃,不浪费处理资源。
  • 背压阈值写死在代码里,不能动态调整:真实流量模式会随时间变化(大促、区域故障转移),阈值应该做成可以热更新的配置,而不是每次调整都要重新发布。
  • 只对下游限流做了处理,没对自身队列做背压:很多团队接了 客户端令牌桶限速 应对上游模型服务商的限流,却忘了自己网关内部的队列同样需要背压保护,两层缺一不可。

七、相关阅读