在高并发系统中,限流(Rate Limiting)是保护服务可用性的第一道防线。无论是应对突发流量洪峰、防止恶意爬虫,还是实现API配额管理,限流都扮演着关键角色。Redis凭借其单线程原子操作、丰富的数据结构和极低的延迟,成为实现限流系统的首选中间件。本文将深入剖析三种主流限流算法在Redis中的实现方式,从理论原理到生产级代码,再到方案对比与选型建议,帮你构建可靠的限流体系。

一、限流算法概述:为什么需要选择正确的算法
限流的核心目标是:在给定时间窗口内,限制某个对象的请求频率不超过预设阈值。这里的“对象”可以是用户、IP地址、API接口、甚至是整个服务。不同的限流算法在精确度、内存开销、突发流量处理能力等方面各有取舍,选择错误的算法可能导致限流不准确或资源浪费。
主流限流算法有以下四种:
- 固定窗口计数器(Fixed Window):将时间划分为固定窗口,每个窗口内维护计数器,超限即拒绝。实现简单,但存在窗口边界突发问题。
- 滑动窗口计数器(Sliding Window):在固定窗口基础上改进,通过记录每个请求的时间戳实现更精确的限流。可分为滑动窗口日志和滑动窗口计数两种实现。
- 令牌桶(Token Bucket):以固定速率生成令牌放入桶中,请求消耗令牌,桶满则丢弃令牌。允许一定程度的突发流量。
- 漏桶(Leaky Bucket):请求像水滴一样进入桶中,桶以固定速率漏出。超过桶容量则拒绝。强制平滑输出速率。
下面我们逐一深入每种算法在Redis中的实现。
二、固定窗口计数器:最简单但最不精确的方案
2.1 算法原理
固定窗口计数器是最直观的限流方式。将时间轴划分为固定大小的时间窗口(如每分钟一个窗口),每个窗口内维护一个计数器。每次请求到来时,检查当前窗口的计数值是否超过阈值,未超则计数器加1并放行,超限则拒绝。
2.2 Redis实现
使用Redis的INCR命令配合EXPIRE即可实现。关键在于原子性:INCR和EXPIRE必须在同一个事务或Lua脚本中执行,避免竞态条件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 -- fixed_window.lua
-- KEYS[1]: 限流key,如 rate_limit:user:1001
-- ARGV[1]: 窗口大小(秒)
-- ARGV[2]: 最大请求数
local key = KEYS[1]
local window = tonumber(ARGV[1])
local max_count = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
-- 首次访问,设置过期时间(即窗口大小)
redis.call('EXPIRE', key, window)
end
if current > max_count then
return 0 -- 限流
else
return 1 -- 放行
end
Java调用示例(使用Jedis客户端):
1
2
3
4
5
6
7
8
9
10
11
12 public boolean isAllowed(String userId, int maxRequests, int windowSeconds) {
String key = "rate_limit:" + userId;
String luaScript =
"local current = redis.call('INCR', KEYS[1]) " +
"if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end " +
"if current > tonumber(ARGV[1]) then return 0 else return 1 end";
Object result = jedis.eval(luaScript,
Collections.singletonList(key),
Arrays.asList(String.valueOf(maxRequests), String.valueOf(windowSeconds)));
return Long.valueOf(1).equals(result);
}
Python调用示例(使用redis-py):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 import redis
r = redis.Redis(host='localhost', port=6379, db=0)
FIXED_WINDOW_LUA = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
else
return 1
end
"""
fixed_window = r.register_script(FIXED_WINDOW_LUA)
def is_allowed(user_id, max_requests=100, window=60):
key = "rate_limit:" + user_id
return bool(fixed_window(keys=[key], args=[max_requests, window]))
2.3 固定窗口的致命缺陷:边界突发问题
固定窗口最大的问题是“窗口边界效应”。假设限制每分钟100次请求,用户在第59秒发送100次请求,然后在第61秒(新窗口开始)又发送100次请求。虽然两个窗口都没有超限,但在2秒内实际发生了200次请求,远超预期。这种边界突发在高峰场景下可能压垮下游服务。
三、滑动窗口算法:精确限流的核心方案
3.1 滑动窗口日志法(ZSet实现)
滑动窗口日志法的思路是:记录每次请求的时间戳,在判断时移除窗口外的旧记录,统计窗口内请求数。Redis的Sorted Set(ZSet)天然适合这个场景——用时间戳作为score,请求唯一ID作为member。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 -- sliding_window_log.lua
-- KEYS[1]: 限流key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 最大请求数
-- ARGV[4]: 唯一请求ID
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_count = tonumber(ARGV[3])
local request_id = ARGV[4]
-- 移除窗口外的旧记录
local cutoff = now - window
redis.call('ZREMRANGEBYSCORE', key, 0, cutoff)
-- 统计当前窗口内请求数
local current_count = redis.call('ZCARD', key)
if current_count >= max_count then
return 0 -- 限流
end
-- 记录本次请求
redis.call('ZADD', key, now, request_id)
-- 设置key过期,防止无限增长
redis.call('PEXPIRE', key, window / 1000 + 1)
return 1
Python实现示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 import redis
import uuid
import time
r = redis.Redis(host='localhost', port=6379, db=0)
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_count = tonumber(ARGV[3])
local request_id = ARGV[4]
local cutoff = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff)
local current = redis.call('ZCARD', key)
if current >= max_count then
return 0
end
redis.call('ZADD', key, now, request_id)
redis.call('PEXPIRE', key, window + 1000)
return 1
"""
sliding_window = r.register_script(SLIDING_WINDOW_LUA)
def is_allowed(user_id, max_requests=100, window_ms=60000):
key = "sliding_limit:" + user_id
now = int(time.time() * 1000)
request_id = str(uuid.uuid4())
return bool(sliding_window(
keys=[key],
args=[now, window_ms, max_requests, request_id]
))
3.2 滑动窗口日志法的优缺点
| 维度 | 评价 |
|---|---|
| 精确度 | 非常高,无边界突发问题 |
| 内存开销 | 与窗口内请求数成正比,高QPS下内存较大 |
| 时间复杂度 | O(log N),N为窗口内请求数 |
| 适用场景 | 中低QPS、对精确度要求高的场景 |
对于高QPS场景(如每秒上万请求),ZSet中会存储大量member,内存开销不可忽视。每次请求还要执行ZREMRANGEBYSCORE,虽然时间复杂度是O(log N),但在超大ZSet上仍有性能压力。
3.3 滑动窗口计数法:内存优化的折中方案
滑动窗口计数法是对日志法的优化。它将大窗口划分为多个小窗口(格子),每个小窗口维护一个计数器。判断时累加当前时间所在小窗口之前所有小窗口的计数值。相比日志法,它只存储计数器而非每个请求的记录,内存开销大幅降低。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 -- sliding_window_count.lua
-- KEYS[1]: 限流key
-- ARGV[1]: 当前时间戳(秒)
-- ARGV[2]: 窗口大小(秒),如60
-- ARGV[3]: 子窗口数量,如6(每个子窗口10秒)
-- ARGV[4]: 最大请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local sub_count = tonumber(ARGV[3])
local max_requests = tonumber(ARGV[4])
local sub_window_size = window / sub_count
local current_sub = math.floor(now / sub_window_size)
-- 清理过期子窗口
local min_sub = current_sub - sub_count + 1
local fields = redis.call('HKEYS', key)
for _, field in ipairs(fields) do
if tonumber(field) < min_sub then
redis.call('HDEL', key, field)
end
end
-- 累加所有有效子窗口的计数
local total = 0
for i = min_sub, current_sub do
local count = redis.call('HGET', key, tostring(i))
if count then
total = total + tonumber(count)
end
end
if total >= max_requests then
return 0
end
-- 当前子窗口计数+1
redis.call('HINCRBY', key, tostring(current_sub), 1)
redis.call('EXPIRE', key, window + 1)
return 1
这种方案将60秒窗口分为6个10秒子窗口,每个子窗口只存一个数字(计数器),内存开销从O(N)降低到O(子窗口数),同时限流精度损失很小。子窗口数量越多,精度越高但开销也越大,需要根据业务场景权衡。
四、令牌桶算法:支持突发流量的首选方案
4.1 算法原理
令牌桶算法的核心思想:系统以固定速率向桶中添加令牌,桶有最大容量限制(桶满则丢弃多余令牌)。每个请求到来时尝试从桶中取走一个令牌,取到则放行,取不到则拒绝。令牌桶的关键特性是允许突发流量——当桶中有积累的令牌时,短时间内可以处理超过平均速率的请求。
例如,桶容量为10,速率为5个/秒。如果系统空闲了2秒,桶中积累10个令牌(满了),此时突然来10个请求可以瞬间全部放行。这就是令牌桶“存钱罐”式的突发处理能力。
4.2 Redis + Lua实现
令牌桶实现的关键在于计算“当前应该有多少令牌”。由于Redis不会在后台持续添加令牌,我们采用“懒计算”策略:记录上次更新时间和令牌数,每次请求时根据时间差计算新增令牌。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 -- token_bucket.lua
-- KEYS[1]: 令牌桶key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 桶容量
-- ARGV[3]: 令牌生成速率(个/秒)
-- ARGV[4]: 本次请求需要的令牌数(通常为1)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 获取当前桶状态
local bucket = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
-- 首次初始化
if tokens == nil then
tokens = capacity
last_time = now
end
-- 计算新增令牌(懒计算)
local elapsed = (now - last_time) / 1000.0
local new_tokens = elapsed * rate
tokens = math.min(capacity, tokens + new_tokens)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 更新桶状态
redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return allowed
Go语言调用示例(使用go-redis):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52 package ratelimit
import (
"context"
"time"
"github.com/go-redis/redis/v8"
)
var tokenBucketScript = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last_time = now
end
local elapsed = (now - last_time) / 1000.0
local new_tokens = elapsed * rate
tokens = math.min(capacity, tokens + new_tokens)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return allowed
`)
func Allow(ctx context.Context, rdb *redis.Client,
key string, capacity, rate float64) (bool, error) {
now := time.Now().UnixMilli()
result, err := tokenBucketScript.Run(ctx, rdb,
[]string{key},
now, capacity, rate, 1,
).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
4.3 令牌桶的生产级配置建议
- 桶容量设为速率的2-3倍:既能处理合理突发,又不至于缓存过多令牌导致瞬间流量过大。
- 速率根据下游处理能力设定:限流的目的是保护下游,速率应略低于下游最大处理能力。
- key设计要合理:按用户、按IP、按接口分别限流,使用不同前缀区分,如 tb:user:123、tb:ip:1.2.3.4、tb:api:/v1/orders。
- 注意时钟漂移:分布式环境下各节点时钟可能不同步,建议使用NTP同步,或在Lua脚本中用Redis的TIME命令获取时间。
五、漏桶算法:强制平滑流量的利器
5.1 算法原理
漏桶算法将请求比作水滴,水滴进入桶中后以固定速率漏出。如果桶满(入水速率超过漏水速率),新水滴被拒绝。与令牌桶不同,漏桶的核心特点是强制平滑输出——无论输入流量如何波动,输出速率始终恒定。
漏桶与令牌桶的关键区别:令牌桶允许突发(桶中有令牌时可以瞬间处理大量请求),漏桶不允许突发(即使桶中有积压,输出速率也不变)。漏桶更适合需要严格匀速的场景,如消息队列消费、外部API调用配额控制。
5.2 Redis实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 -- leaky_bucket.lua
-- KEYS[1]: 桶key
-- ARGV[1]: 当前时间戳(毫秒)
-- ARGV[2]: 桶容量
-- ARGV[3]: 漏出速率(个/秒)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local leak_rate = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'water', 'last_time')
local water = tonumber(bucket[1])
local last_time = tonumber(bucket[2])
if water == nil then
water = 0
last_time = now
end
-- 漏水:根据时间差计算漏出的水量
local elapsed = (now - last_time) / 1000.0
local leaked = elapsed * leak_rate
water = math.max(0, water - leaked)
local allowed = 0
if water + 1 <= capacity then
water = water + 1
allowed = 1
end
redis.call('HMSET', key, 'water', water, 'last_time', now)
redis.call('EXPIRE', key, math.ceil(capacity / leak_rate) * 2 + 10)
return allowed
六、四种方案横向对比与选型指南
| 维度 | 固定窗口 | 滑动窗口日志 | 滑动窗口计数 | 令牌桶 | 漏桶 |
|---|---|---|---|---|---|
| 精确度 | 低(边界突发) | 最高 | 高 | 中(允许突发) | 中(强制平滑) |
| 内存开销 | 极低 | 高 | 低 | 极低 | 极低 |
| 突发处理 | 不支持 | 不支持 | 不支持 | 支持 | 不支持 |
| 实现复杂度 | 最低 | 中 | 中高 | 中 | 中 |
| 适用场景 | 简单配额 | 精确限流 | 高QPS限流 | API网关 | 消息消费 |
选型建议
- API网关限流:优先选择令牌桶。API调用通常有突发需求,令牌桶的“存钱罐”模型最符合实际使用模式。
- 精准防爬虫:选择滑动窗口日志法。爬虫检测需要精确统计请求频率,边界突发会导致漏判。
- 高QPS限流:选择滑动窗口计数法。在QPS上万时,日志法的ZSet内存开销难以接受,计数法是更务实的选择。
- 消息消费匀速:选择漏桶。强制平滑输出速率,避免消费者被批量消息冲垮。
- 简单配额管理:固定窗口足够。如“每个用户每天最多调用1000次”,边界突发的影响可忽略。
七、生产环境最佳实践
7.1 多维度限流策略
生产环境通常不会只用单一维度限流,而是组合多个维度形成纵深防御:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 // 多维度限流示例
// 1. 全局QPS限流:保护服务整体
// 2. 用户级限流:防止单用户滥用
// 3. 接口级限流:保护昂贵接口
// 4. IP级限流:防御恶意攻击
public RateLimitResult check(String userId, String api, String ip) {
// 全局:10000 QPS
if (!tokenBucket.allow("global", 10000, 10000)) {
return RateLimitResult.denied("全局限流");
}
// 用户级:100 QPS
if (!tokenBucket.allow("user:" + userId, 100, 100)) {
return RateLimitResult.denied("用户限流");
}
// 接口级:500 QPS
if (!tokenBucket.allow("api:" + api, 500, 500)) {
return RateLimitResult.denied("接口限流");
}
// IP级:200 QPS
if (!slidingWindow.allow("ip:" + ip, 200, 60000)) {
return RateLimitResult.denied("IP限流");
}
return RateLimitResult.allowed();
}
7.2 Lua脚本的预加载与缓存
每次执行Lua脚本时传输脚本内容会消耗网络带宽。生产环境应使用EVALSHA替代EVAL,通过SCRIPT LOAD预加载脚本后用SHA哈希值引用:
1
2
3
4
5
6 # Python示例:预加载脚本
sha = r.script_load(TOKEN_BUCKET_LUA)
print("Script SHA:", sha)
# 后续调用使用EVALSHA
result = r.evalsha(sha, 1, key, now, capacity, rate, 1)
大多数Redis客户端的register_script方法已经内置了这个逻辑——首次调用EVAL,后续用EVALSHA,并在NOSCRIPT错误时自动回退。但仍建议在服务启动时显式预加载。
7.3 限流降级与熔断配合
限流不应孤立工作,需要与降级和熔断机制配合形成完整的流量治理体系:
- 限流触发时:返回429状态码,附带Retry-After头部告知客户端重试间隔。
- 连续限流时:如果某个用户/IP连续触发限流,可考虑升级为临时封禁(加入黑名单,TTL自动解封)。
- Redis故障时:限流依赖Redis,Redis不可用时需要有降级策略。可选择“全放行”(优先可用性)或“全拒绝”(优先安全),根据业务场景决定。推荐使用本地Guava RateLimiter作为兜底。
1
2
3
4
5
6
7
8
9
10 // 限流降级伪代码
public boolean isAllowed(String key) {
try {
return redisRateLimiter.isAllowed(key);
} catch (RedisException e) {
// Redis不可用时,降级到本地限流器
log.warn("Redis限流不可用,降级到本地限流", e);
return localRateLimiter.tryAcquire(key);
}
}
7.4 监控与告警
限流系统本身也需要被监控。关键指标包括:
- 限流触发率:被拒绝请求/总请求的比例。突然飙升可能意味着攻击或下游异常。
- 各维度限流分布:哪些用户/IP/接口频繁触发限流,用于识别异常行为。
- Redis延迟:Lua脚本执行时间。正常应在1ms以内,超过10ms需排查。
- 令牌桶水位:桶中剩余令牌数,反映系统负载压力。
八、总结
Redis限流系统的设计不是简单地选一个算法,而是需要根据业务场景的精确度要求、QPS量级、突发容忍度和内存预算来综合决策。固定窗口适合简单配额场景,滑动窗口日志法适合精确限流,滑动窗口计数法在高QPS下是最佳折中,令牌桶适合需要突发处理能力的API网关,漏桶适合强制匀速的消费者场景。
生产环境的关键不在于算法本身,而在于多维度组合策略、降级容灾方案和完善的监控告警体系。限流是流量治理的第一道防线,但它不应是唯一防线——与熔断、降级、排队等机制配合,才能构建真正健壮的高并发系统。
汤不热吧