长耗时请求场景下(RAG问答场景),如何基于 Redis 实现分布式公平限流?
长耗时请求场景下(RAG问答场景),如何基于 Redis 实现分布式公平限流?
前言
在普通 Web 接口里,请求通常几十毫秒到几百毫秒就结束。限流时,我们往往只需要控制 QPS 或短时间并发即可。
但在一些长耗时场景里,请求生命周期会明显变长,例如:
- AI 流式问答
- 大文件处理
- 报表导出
- 视频转码
- 长轮询任务
- SSE / WebSocket 类实时响应
这类请求的特点是:单个请求会持续占用后端资源很久。如果完全放开并发,很容易把线程池、模型服务、数据库连接、下游 API 一起打满。
因此,更适合的限流方式不是单纯 QPS,而是控制“全局同时处理中的请求数量”。
本文介绍一种基于 Redis 的分布式公平限流方案:使用 Redis 全局信号量控制并发,用 ZSET 实现公平排队,用定时轮询兜底,用 Pub/Sub 主动唤醒等待请求,并通过通知合并避免消息风暴。
技术原理
1. 为什么需要全局信号量
在单机应用中,可以用 Java 的 Semaphore 控制并发:
Semaphore semaphore = new Semaphore(10);
但在分布式系统里,服务可能有多台实例:
实例 A
实例 B
实例 C
如果每台机器各自维护一个本地信号量,那么全局并发会失控。
例如每台实例允许 10 个并发,3 台机器就可能同时跑 30 个请求。
所以需要一个所有实例共享的并发计数器。Redis 信号量就可以承担这个角色:
Redis 全局信号量 = 整个集群共享的并发许可池
每个请求真正执行前,必须先拿到一个 permit。请求结束后,再释放 permit。
2. 为什么还需要排队队列
如果只有信号量,会有一个问题:谁先拿到 permit 完全取决于并发时序,不一定公平。
更合理的做法是:
所有请求先进入 Redis 队列
只有排在队头窗口内的请求,才允许尝试获取 permit
可以使用 Redis ZSET 存储排队请求:
ZSET key: global:request:queue
score=1 request-A
score=2 request-B
score=3 request-C
score 使用 Redis 原子递增数字生成,保证全局顺序单调递增。
这样无论请求来自哪台机器,都按统一顺序排队。
3. 请求进入后的完整流程
一次请求进入限流器后,大致流程如下:
这里有两个关键点。
第一,先入队,再尝试获取许可。
这样可以避免新请求直接抢走空闲 permit,导致前面排队的请求被插队。
第二,claim 队列资格和获取 permit 是两个动作。
claim 负责公平排队,permit 负责真实并发控制。二者分开,可以让队列逻辑和信号量逻辑各自保持清晰。
4. Ticket:一次请求的本地状态机
每个请求进入限流器后,可以抽象成一个 Ticket。
Ticket 是本机内存对象,不跨机器共享。它代表某一次请求的本地状态。
它有几个状态:
PENDING 等待中
GRANTED 已拿到许可,开始执行业务
TIMED_OUT 排队超时
CANCELLED 客户端断开或主动取消
多个线程可能同时操作同一个 Ticket:
定时轮询线程发现可以执行
定时轮询线程发现超时
客户端连接断开触发取消
Pub/Sub 通知唤醒后尝试执行
所以状态切换要用 CAS:
state.compareAndSet(PENDING, GRANTED);
state.compareAndSet(PENDING, TIMED_OUT);
state.compareAndSet(PENDING, CANCELLED);
这样可以保证:
一次请求只能有一个最终出口
也就是说,它不会既执行了业务,又触发超时拒绝,也不会取消后又继续执行。
5. entry 存活标记:防止僵尸队列项
Redis ZSET 里保存的是排队凭证,但如果某个应用实例崩溃了,它来不及清理 ZSET,队列里就可能留下僵尸请求。
解决办法是给每个请求额外写一个带 TTL 的 entry 标记:
global:request:entry:{requestId} = 1
TTL = 最大等待时间 + 缓冲时间
ZSET 表示:
这个请求排过队
entry 表示:
这个请求还活着
后续 Lua 脚本扫描队头时,会检查 entry 是否存在。如果不存在,就认为该请求已经失效,直接从 ZSET 中清理掉。
6. Lua claim:原子判断是否轮到自己
多个实例可能同时被唤醒,同时尝试从 Redis 队列中出队。
因此,判断“我是不是排到队头了”必须是原子的。
Lua 脚本可以做这几件事:
伪代码如下:
local headEntries = redis.call('ZRANGE', queueKey, 0, maxRank + slack - 1)
for each member in headEntries do
if redis.call('EXISTS', entryPrefix .. member) == 1 then
-- 计算存活排名
else
redis.call('ZREM', queueKey, member)
end
end
if currentRequestInLiveWindow then
local score = redis.call('ZSCORE', queueKey, requestId)
redis.call('ZREM', queueKey, requestId)
redis.call('DEL', entryPrefix .. requestId)
return {1, score}
end
return {0}
这里的 maxRank 通常等于当前可用 permit 数。
如果有 3 个空闲许可,那么队头前 3 个存活请求都有机会被放行。
7. 为什么 claim 成功后还可能拿不到 permit
claim 成功表示:
你已经从公平队列里出队了
但它还不等于真正拿到了执行许可。
因为可用 permit 是一个瞬时状态,分布式并发下可能发生竞争:
实例 A 看到 availablePermits = 1
实例 B 也看到 availablePermits = 1
A claim 成功
B 也可能在相近时间 claim 成功
但真正的 permit 只有 1 个
A 获取成功
B 获取失败
如果获取 permit 失败,就要把请求按原始 score 放回 ZSET。
这就是 Lua 返回原始 score 的原因:
失败回队时,仍然保持原来的排队位置
避免因为一次并发竞争失败,就被重新排到队尾。
8. 异步等待:定时轮询机制
如果请求入队后没有立即拿到 permit,就进入异步等待。
每个等待中的 Ticket 会注册一个定时任务:
scheduler.scheduleAtFixedRate(
poller,
interval,
interval,
TimeUnit.MILLISECONDS
);
这个 poller 每次执行时做三件事:
它不是阻塞当前请求线程等待,而是把等待动作交给调度线程池。
所以等待过程是异步的:
请求入队
-> 没拿到 permit
-> 注册 poller
-> 当前流程返回
-> poller 后续周期性检查 Redis
9. 主动唤醒:Redis Pub/Sub 通知
定时轮询可以保证稳定,但存在延迟。
假设 poll 间隔是 500ms,而某个请求刚好在第 100ms 释放了 permit。后面的请求可能要等到第 500ms 才醒。
为了降低等待延迟,可以引入 Redis Pub/Sub。
当这些事件发生时,发布一条通知:
permit 被释放
队列项被取消
队列项超时
claim 成功导致队列变化
通知内容不需要复杂,可以只是:
permit_changed
所有服务实例启动时订阅同一个 Topic:
global:request:queue:notify
收到通知后,本机把当前实例内等待中的 poller 提前执行一轮。
注意:Pub/Sub 只是“提醒大家状态可能变了”,不是最终裁判。真正能不能执行,仍然要回到 Redis Lua 和 semaphore 判断。
10. 为什么还需要定时轮询兜底
Pub/Sub 不应该被当成绝对可靠机制。
可能出现:
实例重启时错过通知
网络抖动导致通知延迟
Pub/Sub 消息不保留历史
通知处理线程异常
所以必须保留定时轮询。
这就是典型的:
主动通知 + 被动轮询
主动通知负责快,定时轮询负责稳。
很多分布式系统都是类似设计:
注册中心:watch 通知 + 定期拉取
K8s Informer:watch + list
消息系统:push + pull / offset 校验
核心思想是:通知可以优化实时性,但不能作为唯一一致性保障。
11. 如何避免通知风暴
如果每次 Redis 通知都立即遍历本机所有等待请求,通知多时会产生风暴。
可以使用两个变量做合并:
AtomicBoolean firing = new AtomicBoolean(false);
AtomicInteger pendingNotifications = new AtomicInteger(0);
逻辑如下:
void fire() {
pendingNotifications.incrementAndGet();
if (!firing.compareAndSet(false, true)) {
return;
}
executor.execute(() -> {
do {
pendingNotifications.set(0);
if (availablePermits() <= 0) {
break;
}
for (Runnable poller : pollers.values()) {
poller.run();
}
firing.set(false);
} while (pendingNotifications.get() > 0
&& firing.compareAndSet(false, true));
});
}
含义是:
如果当前已经有一轮批量唤醒在执行
新的通知只计数,不再重复启动扫描
这样 100 条通知不会触发 100 次全量扫描,而是被合并成有限轮扫描。
12. 资源清理:状态机 + 统一 cleanup
排队请求可能有多个出口:
取消
超时
抢到许可
每个出口都要清理资源:
从 ZSET 移除
删除 entry 标记
注销 poller
取消定时 future
必要时释放 permit
必要时发布通知
推荐把公共清理收敛到统一方法:
void cleanup() {
removeFromQueue();
deleteEntryMarker();
unregisterPoller();
cancelFuture();
releasePermitIfNeeded();
publishNotifyIfNeeded();
}
并且 cleanup 要设计成幂等:
调用一次安全
调用多次也安全
这样多线程并发下,即使取消和超时同时发生,也不会造成资源泄漏。
总结
长耗时请求不适合只做 QPS 限流,更适合用“全局并发许可 + 公平排队”的方式控制资源。
一套相对完整的实现可以包含:
Redis semaphore:控制全局并发
Redis ZSET:维护公平排队顺序
Redis AtomicLong:生成全局递增 score
entry TTL:清理僵尸队列项
Lua claim:原子判断是否轮到自己
定时轮询:保证通知丢失后仍能推进
Redis Pub/Sub:状态变化时主动唤醒
通知合并:避免消息风暴
Ticket 状态机:保证请求只有一个最终出口
统一 cleanup:保证资源不泄漏
这套方案适合 AI 流式问答、长连接任务、文件处理、报表导出等请求持续时间较长、资源占用明显的场景。
它的核心不是“把请求挡掉”,而是让请求有序进入系统:能处理就放行,暂时不能处理就公平等待,等太久就优雅拒绝。

浙公网安备 33010602011771号