10个人同时提问只有3个坑位
开篇引言
上一篇收尾了流式生成子系列,把单个请求的故事讲得比较透了——正常路径走完 finish + done 落库关连接,被取消路径走 cancel + done 保存部分内容再关连接,资源都能干净释放。镜头始终对着一个请求,从 LLM 第一个 Token 吐出到最后一个 Token 收束。
但是把镜头从单个请求拉远到整个集群,会看到完全不一样的压力面。
假设你在一家在线教育公司做开发,公司前段时间上了 RAG 智能客服,主要做课程咨询、退费政策、报名引导这类问题。日常流量不大,平均下来一台机器同时跑 5 ~ 10 个请求。但每周三下午 3 点,运营会推一个「限时五折」的活动短信,全网用户瞬间涌进来问优惠详情、能不能叠加优惠券、报名截止时间。促销那一刻同时来了 200 个 RAG 请求。
这时候问题就来了:
- 下游模型服务有并发上限——本地部署的 GPU 显存有 限,同时跑几十个推理任务直接 OOM;在线 API 平台(如 SiliconFlow)也有并发限制,一下涌进 200 个请求,绝大多数会直接报
429 Too Many Requests - Tomcat 默认 200 个工作线程会被这些 SSE 长连接占满——单个 RAG 请求要跑 30 秒到 1 分钟,普通的 REST 接口都进不来了
你可能会想:那加个限流就行了呗,不是几行代码的事?听起来很合理,但实际跑起来你会发现问题没那么简单——QPS 限流挡不住长连接、本地 Semaphore 在多机部署下管不住总并发、直接拒绝(HTTP 429)的体验又特别差。RAG 这种长耗时 + 资源敏感 + 体验敏感的场景,需要的是另一套思路:排队。
本篇聚焦排队限流的为什么 + 整体架构 + 入队与立即抢占的同步路径。下一篇接着讲异步等待路径——抢不到许可的请求怎么等、跨集群怎么唤醒、超时了怎么给用户交代。
为什么不能简单地「加个 QPS 限流」
要想清楚 RAG 的限流方案,得先想清楚 RAG 请求长什么样。
1. RAG 请求的三个特殊性
1.1 长耗时
普通 REST 接口几十毫秒到几百 毫秒,QPS 限流非常合适——每秒最多放进 N 个请求,超过就拒绝,N 秒后再来。
但 RAG 是长连接,单次请求 10 秒到 1 分钟。考虑两种流量:
A 场景每秒来 10 个请求,每个跑 60 秒。第 1 秒进来 10 个开始跑;第 2 秒又来 10 个,但第 1 秒的还没跑完,同时在跑的变成 20 个;第 3 秒再来 10 个,堆到 30 个……一直堆积到第 60 秒,第 1 秒的 10 个刚好跑完退出,但第 2 秒到第 60 秒的还在跑——稳态并发 10 × 60 = 600。
B 场景每秒也来 10 个请求,但每个只跑 100 毫秒。请求进来 100 毫秒就结束了,下一批还没到上一批就走了,任意时刻同时在跑的最多也就 1 个。
两者的 QPS 都是 10 req/s,QPS 限流看起来一视同仁。但 A 场景任意时刻有 600 个请求同时占着 GPU 显存和连接,B 场景只有 1 个。真正打爆下游的是「同时在跑的数量」,不是「每秒进来的数量」。
QPS 限流挡的是流量速率,挡不住并发存量。

1.2 资源敏感
下游 LLM 服务有硬性的并发上限。本地部署受 GPU 显存物理瓶颈限制——以一张 A100 跑 32B 模型为例,在常见量化和上下文配置下,单卡可承载的并发通常也就在几十量级,超出后要么排队导致延迟飙升,要么直接 OOM;在线 API 平台(如 SiliconFlow)同样设有 RPM/并发限制,超出会直接返回 429。不管是自建还是调三方,并发都有天花板,超了就是报错或拖垮延迟。
1.3 体验敏感
QPS 限流和 HTTP 429 是 REST 接口的常见组合——拒绝就拒绝了,前端弹一句「请稍后再试」,用户刷新一下重试。但 RAG 是用户已经按下回车并且看到了「思考中…」的加载圈圈。这时候直接告诉他「系统繁忙」,体验非常差——他会刷新再问,刷新再问,制造更多的请求把系统继续打爆。
2. 三种朴素方案的对比
针对上面三个特殊性,常见的三种方案各有适用场景。
| 方案 | 工作原理 | 适合场景 | RAG 是否合适 |
|---|---|---|---|
| 直接拒绝(HTTP 429) | 超过并发立即返回错误码 | 短请求、用户能快速重试的场景 | 不合适,长连接 + 用户已等待,体验差 |
| 令牌桶(Bucket4j) | 按速率匀速发令牌,无令牌排队等待或拒绝 | 短请求 + 有突发但平均速率可控 | 不合适,令牌发出后长期占用,速率算不准 |
| 排队 + 信号量 | 总并发设上限,超出的入队等待,超时再拒绝 | 长耗时 + 资源敏感 + 体验敏感 | 合适,给等待者机会、给系统喘息空间 |
排队的核心思路是:先让请求进系统但不立刻执行,给前端一个「思考中…」的连接保持着。后台按 FIFO 顺序逐个抢许可,抢到就跑,没抢到就等,等不到就给个明确的反馈。这样既不会瞬时打爆下游,也不会粗暴拒绝用户。
3. 那用本地 Semaphore 行不行
学过并发的同学这时候会说:「我在 Java 进程里加一把 java.util.concurrent.Semaphore,构造时传 new Semaphore(N),业务方法里 acquire() / release(),不就行了?」
如果你只部署一台机器,这套方案确实能工作。但 RAG 这种长耗时服务为了高可用一定是多机部署的,本地 Semaphore 在多机部署下立刻露馅:
- 节点 A 配置
Semaphore(N),节点 B 也配置Semaphore(N),两台机器加起来集群总并发是 2N - 你想限制集群总并发为 N,就得算
N / 节点数配给每台机器;但节点数动态变化(后续的扩缩容),又得改配置 - 即使配对了,请求被负载均衡随机分发,节点 A 此刻满了节点 B 一个都没有,节点 B 的许可全在闲置,节点 A 后来的请求却被本地拒绝
本地 Semaphore 各算各的,没有全局视角。要想让整个集群按一个统一的并发上限执行,许可必须放在集群共享的地方——Redis 是最自然的选择。
Redisson 中的
RPermitExpirableSemaphore把许可放在 Redis 里集中维护,集群里任何一个节点tryAcquire()都从同一个池子里取,这样集群总并发才真正被卡住。