在分布式系统和高并发场景中,限流(Rate Limiting)是保护系统不被击垮的重要手段。常见的限流算法主要分为四种,其设计思想和应用场景各有侧重。
1. 固定窗口算法 (Fixed Window)
这是最简单的限流算法,将时间划分为固定的周期(窗口)。
-
原理:在每个固定的时间窗口内限制请求数量。例如,限制每分钟只能处理 100 个请求。如果当前窗口的计数器达到 100,则拒绝后续请求;当进入下一分钟时,计数器清零。
-
优点:实现简单,内存占用极低。
-
缺点 :临界突刺问题。如果在第一分钟的最后 1 秒进来了 100 个请求,第二分钟的第 1 秒又进来了 100 个请求,那么在短短 2 秒内系统处理了 200 个请求,超出了设定的阈值。
2. 滑动窗口算法 (Sliding Window)
为了解决固定窗口的临界问题,滑动窗口将时间细分为多个小格。
-
原理:将大窗口(如 1 分钟)拆分为更小的时间片段(如 6 个 10 秒的小格)。随着时间的推移,窗口向右滑动,计数器仅统计当前滑动窗口内的总请求数。
-
优点:平滑了限流过程,窗口划分越细,限流越精准,解决了临界突刺问题。
-
实现 :通常使用 Redis 的
ZSET实现,通过score记录请求时间戳,统计count时移除窗口外的数据。
3. 漏桶算法 (Leaky Bucket)
漏桶算法强制执行恒定的处理速率,主要用于流量整形。
-
原理:请求像水一样进入桶中,桶底有一个固定的小孔匀速漏出(处理请求)。如果水流入的速度超过了漏出的速度,多出的水会留在桶内;如果桶满了,新进入的水(请求)直接溢出(被拒绝)。
-
优点:输出频率非常稳定,能绝对保证处理速度不会超过系统负载。
-
缺点 :无法应对突发流量(Burst Traffic)。即便系统资源当前很空闲,请求也必须按死板的速度排队通过。
4. 令牌桶算法 (Token Bucket)
这是目前业界最主流的限流算法,如 Google 的 Guava RateLimiter 就是基于此实现的。
-
原理:
-
系统以恒定速率向桶中放入"令牌"。
-
桶有最大容量,满后令牌丢弃。
-
每个请求进来必须先从桶里抢到一个令牌,抢不到则拒绝或等待。
-
-
优点 :既能限制平均处理速率,又允许一定程度的突发流量(只要桶里还有存量令牌,就可以瞬间处理一波请求)。
-
实现 :不需要真的起一个定时器放令牌,通常通过
计算时间差 * 生成速率来动态计算当前可用令牌数。
常见实现方案对照
| 方案 | 核心技术 | 适用场景 |
|---|---|---|
| Guava RateLimiter | 令牌桶 | 单机限流,适用于 Java 业务逻辑保护。 |
| Sentinel / Hystrix | 滑动窗口/令牌桶 | 微服务架构,支持熔断、降级与限流。 |
| Nginx (limit_req) | 漏桶算法 | 入口层限流,防止恶意爬虫或 CC 攻击。 |
| Redis + Lua | 滑动窗口/令牌桶 | 分布式限流,保证多实例下的全局频次限制。 |
追问1:redis实现一个
要在 Redis 中实现限流,最稳妥且高效的方式是使用 Redis + Lua 脚本 。Lua 脚本能保证"读取-判断-增加"这一系列操作的原子性,避免在分布式高并发下的竞态条件。
以下是两种最常用的实现方案:
1. 滑动窗口限流 (准确度高)
滑动窗口可以避免固定窗口在时间临界点出现的双倍流量问题。我们利用 Redis 的 ZSET(有序集合)来实现。
-
Key : 接口标识(如
limit:user:123:api) -
Value :
ZSET,其中score和member都存时间戳。
Lua 脚本逻辑:
Lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 当前时间戳 (毫秒)
-- ARGV[2]: 窗口大小 (毫秒)
-- ARGV[3]: 最大限制数量
-- 1. 移除窗口时间之外的旧数据
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
-- 2. 统计当前窗口内的请求数
local current_count = redis.call('ZCARD', KEYS[1])
-- 3. 判断是否超过限制
if tonumber(current_count) < tonumber(ARGV[3]) then
-- 未超限,添加当前请求
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
-- 设置 key 过期时间(略大于窗口期即可,防止空间浪费)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
else
-- 已超限
return 0
end
2. 令牌桶限流 (性能最优,支持突发)
令牌桶不需要记录每个请求的时间戳,内存开销更小。它的核心思想是:不真的去起定时器发令牌,而是在请求进来时动态计算应该有多少令牌。
Lua 脚本逻辑:
Lua
Lua
-- KEYS[1]: 限流 key
-- ARGV[1]: 令牌填充速率 (每秒生成的数量)
-- ARGV[2]: 桶的容量
-- ARGV[3]: 当前时间戳 (秒)
-- ARGV[4]: 请求需要的令牌数 (通常为 1)
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_time')
local tokens = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
-- 如果是第一次访问,初始化
if last_time == nil then
tokens = tonumber(ARGV[2])
last_time = tonumber(ARGV[3])
else
-- 计算时间差内生成的令牌
local delta_time = math.max(0, ARGV[3] - last_time)
local generated = delta_time * ARGV[1]
tokens = math.min(tonumber(ARGV[2]), tokens + generated)
last_time = ARGV[3]
end
local result = 0
if tokens >= tonumber(ARGV[4]) then
-- 令牌足够,扣减并允许通过
tokens = tokens - ARGV[4]
result = 1
end
-- 更新桶状态
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_time', last_time)
redis.call('EXPIRE', KEYS[1], 10) -- 自动清理
return result
3. 两种方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 滑动窗口 | 非常精确,能够严格限制任意时间跨度内的流量。 | ZSET 随着请求量增大会占用更多内存。 |
需要强一致性、精确限制的场景,如短信发送频次。 |
| 令牌桶 | 内存占用低(仅存几个数值),支持应对短时间的突发流量。 | 无法保证"绝对平滑"的流量,允许一定程度的倾斜。 | 常见的 API 接口限流、网关层防护。 |