欢迎光临

Redis限流系统设计深度解析:滑动窗口、令牌桶与漏桶算法的生产级实现与方案对比

在高并发系统中,限流(Rate Limiting)是保护服务可用性的第一道防线。无论是应对突发流量洪峰、防止恶意爬虫,还是实现API配额管理,限流都扮演着关键角色。Redis凭借其单线程原子操作、丰富的数据结构和极低的延迟,成为实现限流系统的首选中间件。本文将深入剖析三种主流限流算法在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网关,漏桶适合强制匀速的消费者场景。

生产环境的关键不在于算法本身,而在于多维度组合策略、降级容灾方案和完善的监控告警体系。限流是流量治理的第一道防线,但它不应是唯一防线——与熔断、降级、排队等机制配合,才能构建真正健壮的高并发系统。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Redis限流系统设计深度解析:滑动窗口、令牌桶与漏桶算法的生产级实现与方案对比
分享到: 更多 (0)