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