限流的作用是保护后端服务不被突发流量击垮,但大多数限流器在实现时把请求当成完全相同的流量单元,谁先到谁先拿令牌。这种机制在用户价值差异明显的业务中会造成新的问题:高等级用户可能因为普通用户抢占资源而被误限流。要解决这个问题,需要把用户等级引入限流决策,用优先级队列对请求进行差异化调度。本文将分析常见限流算法的不公平来源,并给出基于用户等级与优先级队列的设计和实现。

为什么常见限流算法会放大不公平
固定窗口、滑动窗口、漏桶和令牌桶是四种经典限流算法。固定窗口在固定时间片内计数,滑动窗口通过时间分段平滑边界,漏桶强制匀速流出,令牌桶以固定速率补充令牌。这些算法虽然实现不同,但有一个共同假设:流量是同质的。计数器只记录总请求数,令牌桶只维护令牌数量,不会关心请求来自哪个用户、该用户等级如何。
在高并发场景下,这种无差别处理会产生明显的不公平。例如活动期间,大量普通用户和少量VIP用户同时发起请求。由于普通用户基数大、请求到达早,固定窗口或令牌桶会迅速消耗完可用配额,导致后续到达的VIP请求被拒绝。如果从业务角度看,VIP用户贡献了更多收入,却因为普通用户的流量被限流,这种结果显然不合理。更严重的是,某些恶意脚本或爬虫可能利用先到先得机制,持续占用资源,进一步加剧不公平。
可以把这个过程抽象成单队列模型:所有请求进入同一个队列,服务端按 FIFO 顺序处理。即使队列有容量限制,高等级用户也没有插队能力。下面是一个简单的固定窗口计数器实现,它只关心总次数,完全没有优先级概念。
public class FixedWindowRateLimiter {
private final long windowSizeMillis;
private final int maxRequests;
private long windowStart;
private int counter;
public FixedWindowRateLimiter(long windowSizeMillis, int maxRequests) {
this.windowSizeMillis = windowSizeMillis;
this.maxRequests = maxRequests;
this.windowStart = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart > windowSizeMillis) {
windowStart = now;
counter = 0;
}
if (counter < maxRequests) {
counter++;
return true;
}
return false;
}
}
这段代码对所有调用者返回相同的通过逻辑,无法区分VIP和普通用户。要改变这种情况,需要在限流入口增加优先级维度,而不是仅靠先来后到。
用户等级与优先级队列的模型设计
引入用户等级后,限流器可以把请求分成多个优先级队列。例如将用户划分为VIP、普通和低优先级三个等级,每个等级对应一个队列。当系统有可用令牌时,优先从VIP队列中取出请求处理,VIP队列为空后再处理普通队列,最后才处理低优先级队列。这样高等级用户即使来得晚,也能优先获得资源。
完全严格优先级可能造成低优先级请求长时间饥饿,因此更常用的是加权公平排队。假设总限流配额为每秒1000个请求,可以给VIP分配600个、普通分配300个、低优先级分配100个。这样既能保证高等级用户优先获得大部分资源,又不会让低等级用户完全无法请求。权重可以根据业务实时调整,例如大促期间临时提高VIP占比。
在单机实现中,Java的PriorityBlockingQueue适合作为优先级队列。请求对象需要实现Comparable接口,按等级从高到低、同等级按到达时间从早到晚排序。下面是一个基础实现:
import java.util.concurrent.PriorityBlockingQueue;
public class PriorityRateLimiter {
private static final int LEVEL_VIP = 3;
private static final int LEVEL_NORMAL = 2;
private static final int LEVEL_LOW = 1;
private final PriorityBlockingQueue<Request> queue;
public PriorityRateLimiter(int capacity) {
this.queue = new PriorityBlockingQueue<>(capacity);
}
public boolean offer(String userId, int level, long timestamp) {
return queue.offer(new Request(userId, level, timestamp));
}
public Request poll() {
return queue.poll();
}
static class Request implements Comparable<Request> {
private final String userId;
private final int level;
private final long timestamp;
Request(String userId, int level, long timestamp) {
this.userId = userId;
this.level = level;
this.timestamp = timestamp;
}
@Override
public int compareTo(Request other) {
if (this.level != other.level) {
return Integer.compare(other.level, this.level);
}
return Long.compare(this.timestamp, other.timestamp);
}
}
}
上面的队列只负责排序,真正限流时还需要配合令牌生成逻辑。例如后台线程定时向队列对应的限流器补充令牌,然后从优先级队列中取出请求尝试获取令牌。成功则放行,失败则丢弃或等待。这种模式适合网关层统一入口,但需要注意队列容量和线程安全,避免请求积压导致内存问题。
分布式场景下用 Redis 实现优先级限流
单机优先级队列在微服务多实例部署时无法全局生效。每个实例只维护自己的队列,可能出现某个实例VIP请求很多,而另一个实例普通请求大量通过的情况。要实现全局限流,需要将限流状态放到 Redis 等共享存储中,并通过原子操作保证判断与扣减的一致性。
Redis 的 EVAL 命令可以执行 Lua 脚本,脚本在 Redis 服务端原子执行,非常适合实现限流。我们可以为每个用户等级维护一个独立的计数器,同时维护一个全局令牌桶计数器。当请求到达时,先判断用户等级对应的计数器是否还有剩余额度;如果有,再判断全局令牌是否还有余量。为了体现优先级,脚本可以按等级顺序分配全局令牌,例如先给VIP预留足够的份额,再允许普通用户抢占剩余部分。
下面是一段 Lua 脚本,假设键 rate:global 保存全局令牌数,rate:level:3、rate:level:2、rate:level:1 分别保存VIP、普通、低优先级已使用配额。脚本限制普通用户不能超过全局剩余令牌中为非VIP预留之外的部分:
local level = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
local globalLimit = tonumber(ARGV[4])
local levelLimit = tonumber(ARGV[5])
local globalKey = KEYS[1]
local levelKey = KEYS[2]
local globalUsed = tonumber(redis.call('GET', globalKey) or '0')
local levelUsed = tonumber(redis.call('GET', levelKey) or '0')
if globalUsed >= globalLimit then
return 0
end
if level == 3 then
if levelUsed >= levelLimit then
return 0
end
else
local reservedForVip = math.floor(globalLimit * 0.6)
local availableForOthers = globalLimit - reservedForVip
local othersUsed = globalUsed - (tonumber(redis.call('GET', 'rate:level:3') or '0'))
if levelUsed >= levelLimit or othersUsed >= availableForOthers then
return 0
end
end
redis.call('INCR', globalKey)
redis.call('INCR', levelKey)
if globalUsed == 0 then
redis.call('PEXPIRE', globalKey, window)
redis.call('PEXPIRE', levelKey, window)
end
return 1
应用层可以用任意 Redis 客户端执行这段脚本。例如 Java 使用 StringRedisTemplate 调用 execute 方法,Go 使用 redis.Client.Eval。关键在于通过 Lua 保证判断和扣减不会被打断,避免并发下超限。高等级用户由于拥有独立且更高的配额,在全局资源紧张时仍能通过,低等级用户则只能使用剩余资源。
落地时的工程化细节与避坑点
优先级限流不是简单加几个队列就够了,工程落地时需要控制额外开销。用户等级的解析应该尽量前置,例如网关从 JWT 中读取 level 字段,或者从请求头 X-User-Level 中获取,避免每次限流都查数据库。等级信息可以在登录后写入上下文,限流组件只读取内存中的值,延迟要控制在微秒级。
队列和计数器都需要设置上限。单机优先级队列若没有容量限制,请求积压过多会导致内存溢出;Redis 中的计数器键也要设置合理的过期时间,防止 key 残留。可以使用 PEXPIRE 在第一次写入时设置窗口时长,超过窗口自动清理。监控上需要区分各等级的通过数、拒绝数和等待时间,一旦发现低优先级请求长时间为 0,说明权重配置可能过于偏向高等级,需要调整。
另外还要防止高等级用户无限制占用资源。VIP 用户虽然优先级高,但也应有独立上限,例如每秒最多 500 次。否则单个 VIP 账号的异常流量可能打垮后端。可以在优先级限流之外叠加针对单个用户或 IP 的细粒度限流,形成多层防护。最后,当限流器拒绝请求时,建议返回明确的错误码或降级结果,而不是简单丢弃,方便客户端提示用户稍后重试或者引导升级等级。