长耗时请求场景下(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 流式问答、长连接任务、文件处理、报表导出等请求持续时间较长、资源占用明显的场景。

它的核心不是“把请求挡掉”,而是让请求有序进入系统:能处理就放行,暂时不能处理就公平等待,等太久就优雅拒绝。

posted @ 2026-06-18 16:02  代码丰  阅读(7)  评论(0)    收藏  举报