文章

Redis + Lua 分布式令牌桶限流原理

限流是高并发系统的基本防护手段——接口 QPS 再高,下游也扛不住 unlimited 流量。常见算法有固定窗口、滑动窗口、漏桶、令牌桶,其中令牌桶最通用:允许短时突发(桶内有积累),但长期平均速率受限,这正好匹配真实流量模式。

本文从令牌桶的完整工作流讲起,再逐个拆解每个设计决策背后的“为什么”。

  1. 令牌桶算法:完整工作流
  2. 为什么惰性计算,而不是定时器主动填充
  3. 为什么需要 Lua
  4. 完整 Lua 脚本实现
  5. 用 Hash 优化存储
  6. 冷启动问题
  7. Lua 脚本放在哪里
    1. 内联字符串(最简单,不推荐生产用)
    2. 独立 .lua 文件(推荐起步方案)
    3. EVALSHA 缓存(高并发生产推荐)
  8. 并发怎么变串行
  9. 与其他限流算法对比
  10. 一句话总结

令牌桶算法:完整工作流

先放下实现细节,从“一个请求到来时发生什么”看令牌桶的全貌。

模型:想象一个桶,以恒定速率往里放令牌,桶有容量上限;每次请求消耗一个令牌,桶空则拒绝。

四个参数

参数含义
capacity桶容量(最大令牌数)
rate令牌填充速率(个/秒)
tokens当前令牌数
last_refill上次填充时间

一个请求的完整处理流程

flowchart TB
    A([请求到来]) --> B[① 读状态]
    B --> C[② 算令牌]
    C --> D{③ 有令牌?}
    D -- 是 --> E[消耗→放行]
    D -- 否 --> F[拒绝]
    E --> G[④ 写状态]
    F --> G
    G --> H([⑤ 返回])

五步拆解:

  1. 读状态:从 Redis 取出 tokenslast_refill
  2. 算令牌elapsed = now − last_refilltokens = min(capacity, tokens + elapsed × rate)last_refill = now
  3. 判定tokens ≥ cost?是则消耗令牌放行,否则直接拒绝
  4. 写状态:把 tokenslast_refill 写回 Redis
  5. 返回:放行/拒绝 + 剩余令牌数

关键在第②步——不是真的有个定时器在持续放令牌,而是请求到来时,用时间差一次性算出这段时间该补的令牌数。这叫惰性计算(lazy refill)。

用一个具体例子走一遍。假设 capacity=10, rate=2(每秒补 2 个令牌,桶最多 10 个):

sequenceDiagram
    participant R as 请求
    participant B as 令牌桶 (capacity=10, rate=2)
    Note over B: t=0s tokens=10 (满桶)

    R->>B: t=1s 请求
    Note over B: elapsed=1s 补2个<br/>tokens = min(10, 10+2) = 10
    B-->>R: 消耗1个 → tokens=9 ✓ 放行

    R->>B: t=1.5s 请求
    Note over B: elapsed=0.5s 补1个<br/>tokens = min(10, 9+1) = 10
    B-->>R: 消耗1个 → tokens=9 ✓ 放行

    R->>B: t=2s 连续10个请求
    Note over B: elapsed=0.5s 补1个<br/>tokens = min(10, 9+1) = 10
    B-->>R: 前9个消耗完 → tokens=0
    B-->>R: 第10个 → 拒绝 ✗

    Note over B: ... 空闲1秒 ...

    R->>B: t=3s 请求
    Note over B: elapsed=1s 补2个<br/>tokens = 0+2 = 2
    B-->>R: 消耗1个 → tokens=1 ✓ 放行

    Note over B: ... 空闲5秒 ...

    R->>B: t=8s 请求
    Note over B: elapsed=5s 补10个<br/>tokens = min(10, 1+10) = 10
    B-->>R: 消耗1个 → tokens=9 ✓ 放行

例子中能看到令牌桶的两个核心特性:

  • 允许突发:空闲 5 秒后桶又满了,可以瞬间处理 10 个请求
  • 长期限速:持续高并发下,放行速率不会超过 rate(本例中每秒最多 2 个)

整个流程就是这么简单。接下来的问题是:每一步的细节为什么这样设计,而不是别的做法。

为什么惰性计算,而不是定时器主动填充

对比两种实现:

主动填充(定时器):

1
2
3
每 100ms 执行一次:
  if tokens < capacity:
    tokens += rate * 0.1

惰性填充(请求到来时算):

1
2
3
请求到来时:
  elapsed = now - last_refill
  tokens = min(capacity, tokens + elapsed * rate)

定时器方案有三个问题:

  1. 不必要的开销:QPS=0 时定时器还在空跑,几十万个 key 就是几十万个空转定时器。惰性方案没人来就零开销。

  2. 精度与成本的矛盾:间隔 1s 粒度太粗,间隔 10ms 精度够但开销爆炸。惰性方案的精度天然等于请求到达的时间分辨率,与业务对齐。

  3. 分布式环境没法做:定时器放应用侧,多实例并发写 Redis 又要加锁;放 Redis 侧,Redis 没有原生定时器调度能力。

本质上,令牌数是时间的确定函数 tokens(now) = min(capacity, tokens(last) + (now - last) * rate),请求到来时一个公式就能算出精确值,不需要定时器去近似模拟。能算出来的东西,就不要用定时器去维护。

为什么需要 Lua

分布式限流用 Redis 存储状态(tokenslast_refill),但存在竞态问题:

1
2
3
4
请求A: GET tokens → 1      # 还有1个令牌
请求B: GET tokens → 1      # 也认为还有1个
请求A: SET tokens 0        # 消耗掉
请求B: SET tokens 0        # 也消耗掉 → 超发了!

Lua 脚本在 Redis 中原子执行——单线程模型保证读取、计算、写入一气呵成,中间不会被其他命令插入。

完整 Lua 脚本实现

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
-- KEYS[1]: 限流 key
-- ARGV[1]: capacity(桶容量)
-- ARGV[2]: rate(每秒填充令牌数)
-- ARGV[3]: now(当前时间戳,毫秒)
-- ARGV[4]: cost(本次请求消耗令牌数)

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

-- 获取当前状态
local tokens = tonumber(redis.call('GET', key .. ':tokens') or capacity)
local last_refill = tonumber(redis.call('GET', key .. ':last_refill') or now)

-- 惰性填充:计算从上次到现在应补充的令牌
local elapsed = math.max(0, now - last_refill) / 1000  -- 毫秒转秒
local refill = elapsed * rate
tokens = math.min(capacity, tokens + refill)

-- 尝试消耗令牌
local allowed = 0
if tokens >= cost then
    tokens = tokens - cost
    allowed = 1
end

-- 写回状态
redis.call('SET', key .. ':tokens', tokens)
redis.call('SET', key .. ':last_refill', now)

-- 设置过期时间,避免冷启动时积累巨量令牌
local ttl = math.ceil(capacity / rate) + 1
redis.call('EXPIRE', key .. ':tokens', ttl)
redis.call('EXPIRE', key .. ':last_refill', ttl)

return { allowed, tokens }

调用方式:

1
2
EVALSHA <sha> 1 rate_limit:user:123 10 2 1717833600000 1
# 返回: 1) 是否放行(1/0)  2) 剩余令牌数

用 Hash 优化存储

上面的脚本用两个独立的 String key 存 tokenslast_refill。更优的做法是用 Redis Hash 把它们塞进一个 key:

1
2
3
4
5
6
7
8
local cache = redis.call('HMGET', key, 'tokens', 'last_time')
local current_tokens = tonumber(cache[1])
local last_time = tonumber(cache[2])

-- ... 计算逻辑同上 ...

redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
redis.call('EXPIRE', key, expire)

HMGET 是 Hash 多字段读取命令——从 Hash key 中同时读出多个字段值。等价于:

1
2
HMGET rate_limit:user:123 tokens last_time
→ ["7.5", "1717833600000"]

在 Lua 中返回值是数组,cache[1]tokens 字段,cache[2]last_time 字段。字段不存在时返回 false(Redis 的 nil),tonumber(nil) 得到 nil,正好走初始化逻辑。

对比两种存储方式:

 两个 String key一个 Hash key
读取2 次 GET1 次 HMGET
写入2 次 SET1 次 HMSET
过期需分别 EXPIRE1 次 EXPIRE
内存Hash 编码更紧凑同左

Hash 方案少了一半命令开销,且过期时间只需设置一次,两个字段不会出现一个过期一个没过期的异常状态。

冷启动问题

如果 key 不存在,tokens 默认设为 capacity(满桶)。长时间没人用之后突然来一波流量,会瞬间放行 capacity 个请求。应对方式:

  • 初始令牌设为 0 或一个较小的值
  • key 首次创建时设 last_refill = now,不补历史令牌

上面脚本用的是「初始满桶」,可根据业务调整。

TTL 设为 capacity / rate + 1,意思是“从空桶到满桶所需时间再多 1 秒”。超过这个时间 key 自然失效,下次再来按新桶算,避免冷启动积累巨量令牌。

Lua 脚本放在哪里

Lua 脚本不“部署”到 Redis 上,而是由应用侧在每次调用时发送给 Redis 执行。和 SQL 类比——SQL 文件放 resources/sql/,Lua 文件放 resources/lua/,运行时发给服务端执行。

内联字符串(最简单,不推荐生产用)

1
2
3
4
5
6
7
8
9
10
11
String script = 
    "local key = KEYS[1] " +
    "local now = tonumber(ARGV[1]) " +
    "return allowed";

Long result = redisTemplate.execute(
    new DefaultRedisScript<>(script, Long.class),
    List.of("rate_limit:user:123"),
    String.valueOf(System.currentTimeMillis()),
    "2", "10", "600"
);

脚本藏在代码字符串里,不好读、不好维护、没有语法高亮。

独立 .lua 文件(推荐起步方案)

1
2
3
src/main/resources/
  └── lua/
      └── rate_limiter.lua
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class RedisConfig {

    @Bean
    public DefaultRedisScript<Long> rateLimiterScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("lua/rate_limiter.lua"));
        script.setResultType(Long.class);
        return script;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
@Autowired
private DefaultRedisScript<Long> rateLimiterScript;

public boolean isAllowed(String key) {
    Long result = redisTemplate.execute(
        rateLimiterScript,
        List.of(key),
        String.valueOf(System.currentTimeMillis()),
        "2", "10", "600"
    );
    return result != null && result == 1L;
}

脚本独立文件,有语法高亮,可单独做语法检查。

EVALSHA 缓存(高并发生产推荐)

每次 EVAL 都会把完整脚本传给 Redis,浪费带宽。EVALSHA 先用 SCRIPT LOAD 注册脚本拿到 SHA1 哈希,后续只传哈希:

1
2
第一次:  SCRIPT LOAD "lua脚本内容"  →  返回 "a1b2c3..."
后续:    EVALSHA a1b2c3... 1 key arg1 arg2 ...

应用侧封装:

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
@Component
public class RateLimiter {

    private String sha;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @PostConstruct
    public void init() {
        String script = // 读 lua 文件内容
        sha = redisTemplate.scriptLoad(script);
    }

    public boolean isAllowed(String key) {
        try {
            Long result = redisTemplate.execute(
                new DefaultRedisScript<>(sha, Long.class, true),
                List.of(key), ...);
            return result != null && result == 1L;
        } catch (RedisSystemException e) {
            // SHA 失效(Redis 重启过),重新加载
            sha = redisTemplate.scriptLoad(script);
            return isAllowed(key);
        }
    }
}
1
2
3
4
5
6
# redis-py 的 RegisterScript 自动处理 SHA 缓存和重载
from redis import Redis

r = Redis()
script = r.register_script(Path("lua/rate_limiter.lua").read_text())
result = script(keys=["rate_limit:user:123"], args=[now_ms, 2, 10, 600])
方式适用场景要点
内联字符串快速验证不要用于生产
独立 .lua 文件一般项目可读性好,推荐起步
EVALSHA 缓存高并发生产省带宽,需处理 SHA 失效重载

并发怎么变串行

Redis 单线程处理命令(6.0+ 的多线程只管 IO 读写,命令执行仍是单线程)。EVAL 执行 Lua 脚本时,Redis 阻塞其他所有命令,直到脚本跑完。100 个并发请求同时到达 Redis,也是排队逐个执行,每个脚本内部的读→算→写不会被中间插入。

等价于在应用侧加了一把全局锁,但不用自己实现锁——没有加锁、解锁、死锁、锁超时这些问题。

代价是所有限流请求都汇入 Redis 这一个瓶颈。如果限流 QPS 极高(比如几十万),Redis 单节点会成为性能天花板。实际场景中限流 QPS 一般远不到这个量级;真到那个量级,可以用本地限流 + 分布式限流二级缓存:本地 Guava RateLimiter 先挡一层,漏下来的再走 Redis。

与其他限流算法对比

算法特点适用场景
固定窗口简单,但窗口边界有突发(2 倍流量)要求不严格
滑动窗口比固定窗口平滑,但内存开销大中等精度
漏桶严格匀速输出,无法应对合理突发需要严格匀速
令牌桶允许突发(桶内有积累),长期速率受限最通用,最推荐

令牌桶的优势:允许短时突发,但长期平均速率受限。这正好匹配真实流量模式——偶尔的突发是正常的,不应一刀切拒绝。

一句话总结

令牌桶 = 用时间差惰性计算令牌数;Lua = 保证读-算-写原子性;Redis = 分布式共享状态。 三者组合,实现简洁、正确、高性能的分布式限流。

本文由作者按照 CC BY 4.0 进行授权