Skip to content

Commit 144eb02

Browse files
puppylpgclaude
andcommitted
Add: Redis + Lua 分布式令牌桶限流原理
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b52582f commit 144eb02

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
Ruby 环境、本地开发完整流程以 `README.md` 为准。这里只记录仓库特有规则和容易踩坑的点。
1010

11+
## 中文标点规则
12+
13+
- 中文正文中的引号**必须用中文弯引号**,且**左引号 `"`(U+201C)和右引号 `"`(U+201D)必须配对**,不能两个都用右引号 `""`
14+
- 这是反复犯的错误:写中文引号时经常把左引号也写成右引号,变成了 `""为什么"` 而不是正确的 `“为什么”`。生成后必须检查左引号是否为 U+201C。
15+
1116
## 构建与质量卡点
1217

1318
仓库没有独立测试套件。CI 的卡点是 `bundle exec jekyll build``htmlproofer`
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
---
2+
title: "Redis + Lua 分布式令牌桶限流原理"
3+
date: 2026-06-08 03:37:00 +0800
4+
categories: [redis, concurrency]
5+
tags: [redis, lua, rate-limiting, token-bucket, distributed]
6+
description: "从令牌桶算法核心公式出发,讲清惰性计算的设计原因、Lua 脚本保证原子性的机制、以及生产中的脚本管理与冷启动问题。"
7+
---
8+
9+
限流是高并发系统的基本防护手段——接口 QPS 再高,下游也扛不住 unlimited 流量。常见算法有固定窗口、滑动窗口、漏桶、令牌桶,其中令牌桶最通用:允许短时突发(桶内有积累),但长期平均速率受限,这正好匹配真实流量模式。
10+
11+
本文从令牌桶的完整工作流讲起,再逐个拆解每个设计决策背后的“为什么”。
12+
13+
1. Table of Contents, ordered
14+
{:toc}
15+
16+
# 令牌桶算法:完整工作流
17+
18+
先放下实现细节,从“一个请求到来时发生什么”看令牌桶的全貌。
19+
20+
**模型**:想象一个桶,以恒定速率往里放令牌,桶有容量上限;每次请求消耗一个令牌,桶空则拒绝。
21+
22+
**四个参数**
23+
24+
| 参数 | 含义 |
25+
|------|------|
26+
| `capacity` | 桶容量(最大令牌数) |
27+
| `rate` | 令牌填充速率(个/秒) |
28+
| `tokens` | 当前令牌数 |
29+
| `last_refill` | 上次填充时间 |
30+
31+
**一个请求的完整处理流程**
32+
33+
```mermaid
34+
flowchart TB
35+
A([请求到来]) --> B[① 读状态]
36+
B --> C[② 算令牌]
37+
C --> D{③ 有令牌?}
38+
D -- 是 --> E[消耗→放行]
39+
D -- 否 --> F[拒绝]
40+
E --> G[④ 写状态]
41+
F --> G
42+
G --> H([⑤ 返回])
43+
```
44+
45+
五步拆解:
46+
47+
1. **读状态**:从 Redis 取出 `tokens``last_refill`
48+
2. **算令牌**`elapsed = now − last_refill``tokens = min(capacity, tokens + elapsed × rate)``last_refill = now`
49+
3. **判定**`tokens ≥ cost`?是则消耗令牌放行,否则直接拒绝
50+
4. **写状态**:把 `tokens``last_refill` 写回 Redis
51+
5. **返回**:放行/拒绝 + 剩余令牌数
52+
53+
关键在第②步——不是真的有个定时器在持续放令牌,而是**请求到来时,用时间差一次性算出这段时间该补的令牌数**。这叫惰性计算(lazy refill)。
54+
55+
用一个具体例子走一遍。假设 `capacity=10, rate=2`(每秒补 2 个令牌,桶最多 10 个):
56+
57+
```mermaid
58+
sequenceDiagram
59+
participant R as 请求
60+
participant B as 令牌桶 (capacity=10, rate=2)
61+
Note over B: t=0s tokens=10 (满桶)
62+
63+
R->>B: t=1s 请求
64+
Note over B: elapsed=1s 补2个<br/>tokens = min(10, 10+2) = 10
65+
B-->>R: 消耗1个 → tokens=9 ✓ 放行
66+
67+
R->>B: t=1.5s 请求
68+
Note over B: elapsed=0.5s 补1个<br/>tokens = min(10, 9+1) = 10
69+
B-->>R: 消耗1个 → tokens=9 ✓ 放行
70+
71+
R->>B: t=2s 连续10个请求
72+
Note over B: elapsed=0.5s 补1个<br/>tokens = min(10, 9+1) = 10
73+
B-->>R: 前9个消耗完 → tokens=0
74+
B-->>R: 第10个 → 拒绝 ✗
75+
76+
Note over B: ... 空闲1秒 ...
77+
78+
R->>B: t=3s 请求
79+
Note over B: elapsed=1s 补2个<br/>tokens = 0+2 = 2
80+
B-->>R: 消耗1个 → tokens=1 ✓ 放行
81+
82+
Note over B: ... 空闲5秒 ...
83+
84+
R->>B: t=8s 请求
85+
Note over B: elapsed=5s 补10个<br/>tokens = min(10, 1+10) = 10
86+
B-->>R: 消耗1个 → tokens=9 ✓ 放行
87+
```
88+
89+
例子中能看到令牌桶的两个核心特性:
90+
- **允许突发**:空闲 5 秒后桶又满了,可以瞬间处理 10 个请求
91+
- **长期限速**:持续高并发下,放行速率不会超过 `rate`(本例中每秒最多 2 个)
92+
93+
整个流程就是这么简单。接下来的问题是:每一步的细节为什么这样设计,而不是别的做法。
94+
95+
# 为什么惰性计算,而不是定时器主动填充
96+
97+
对比两种实现:
98+
99+
**主动填充(定时器):**
100+
101+
```
102+
每 100ms 执行一次:
103+
if tokens < capacity:
104+
tokens += rate * 0.1
105+
```
106+
107+
**惰性填充(请求到来时算):**
108+
109+
```
110+
请求到来时:
111+
elapsed = now - last_refill
112+
tokens = min(capacity, tokens + elapsed * rate)
113+
```
114+
115+
定时器方案有三个问题:
116+
117+
1. **不必要的开销**:QPS=0 时定时器还在空跑,几十万个 key 就是几十万个空转定时器。惰性方案没人来就零开销。
118+
119+
2. **精度与成本的矛盾**:间隔 1s 粒度太粗,间隔 10ms 精度够但开销爆炸。惰性方案的精度天然等于请求到达的时间分辨率,与业务对齐。
120+
121+
3. **分布式环境没法做**:定时器放应用侧,多实例并发写 Redis 又要加锁;放 Redis 侧,Redis 没有原生定时器调度能力。
122+
123+
本质上,令牌数是时间的确定函数 `tokens(now) = min(capacity, tokens(last) + (now - last) * rate)`,请求到来时一个公式就能算出精确值,不需要定时器去近似模拟。**能算出来的东西,就不要用定时器去维护。**
124+
125+
# 为什么需要 Lua
126+
127+
分布式限流用 Redis 存储状态(`tokens``last_refill`),但存在竞态问题:
128+
129+
```
130+
请求A: GET tokens → 1 # 还有1个令牌
131+
请求B: GET tokens → 1 # 也认为还有1个
132+
请求A: SET tokens 0 # 消耗掉
133+
请求B: SET tokens 0 # 也消耗掉 → 超发了!
134+
```
135+
136+
Lua 脚本在 Redis 中原子执行——单线程模型保证读取、计算、写入一气呵成,中间不会被其他命令插入。
137+
138+
# 完整 Lua 脚本实现
139+
140+
```lua
141+
-- KEYS[1]: 限流 key
142+
-- ARGV[1]: capacity(桶容量)
143+
-- ARGV[2]: rate(每秒填充令牌数)
144+
-- ARGV[3]: now(当前时间戳,毫秒)
145+
-- ARGV[4]: cost(本次请求消耗令牌数)
146+
147+
local key = KEYS[1]
148+
local capacity = tonumber(ARGV[1])
149+
local rate = tonumber(ARGV[2])
150+
local now = tonumber(ARGV[3])
151+
local cost = tonumber(ARGV[4])
152+
153+
-- 获取当前状态
154+
local tokens = tonumber(redis.call('GET', key .. ':tokens') or capacity)
155+
local last_refill = tonumber(redis.call('GET', key .. ':last_refill') or now)
156+
157+
-- 惰性填充:计算从上次到现在应补充的令牌
158+
local elapsed = math.max(0, now - last_refill) / 1000 -- 毫秒转秒
159+
local refill = elapsed * rate
160+
tokens = math.min(capacity, tokens + refill)
161+
162+
-- 尝试消耗令牌
163+
local allowed = 0
164+
if tokens >= cost then
165+
tokens = tokens - cost
166+
allowed = 1
167+
end
168+
169+
-- 写回状态
170+
redis.call('SET', key .. ':tokens', tokens)
171+
redis.call('SET', key .. ':last_refill', now)
172+
173+
-- 设置过期时间,避免冷启动时积累巨量令牌
174+
local ttl = math.ceil(capacity / rate) + 1
175+
redis.call('EXPIRE', key .. ':tokens', ttl)
176+
redis.call('EXPIRE', key .. ':last_refill', ttl)
177+
178+
return { allowed, tokens }
179+
```
180+
181+
调用方式:
182+
183+
```bash
184+
EVALSHA <sha> 1 rate_limit:user:123 10 2 1717833600000 1
185+
# 返回: 1) 是否放行(1/0) 2) 剩余令牌数
186+
```
187+
188+
# 用 Hash 优化存储
189+
190+
上面的脚本用两个独立的 String key 存 `tokens``last_refill`。更优的做法是用 Redis Hash 把它们塞进一个 key:
191+
192+
```lua
193+
local cache = redis.call('HMGET', key, 'tokens', 'last_time')
194+
local current_tokens = tonumber(cache[1])
195+
local last_time = tonumber(cache[2])
196+
197+
-- ... 计算逻辑同上 ...
198+
199+
redis.call('HMSET', key, 'tokens', new_tokens, 'last_time', now)
200+
redis.call('EXPIRE', key, expire)
201+
```
202+
203+
`HMGET` 是 Hash 多字段读取命令——从 Hash key 中同时读出多个字段值。等价于:
204+
205+
```
206+
HMGET rate_limit:user:123 tokens last_time
207+
→ ["7.5", "1717833600000"]
208+
```
209+
210+
在 Lua 中返回值是数组,`cache[1]``tokens` 字段,`cache[2]``last_time` 字段。字段不存在时返回 `false`(Redis 的 nil),`tonumber(nil)` 得到 nil,正好走初始化逻辑。
211+
212+
对比两种存储方式:
213+
214+
| | 两个 String key | 一个 Hash key |
215+
|---|---|---|
216+
| 读取 | 2 次 GET | 1 次 HMGET |
217+
| 写入 | 2 次 SET | 1 次 HMSET |
218+
| 过期 | 需分别 EXPIRE | 1 次 EXPIRE |
219+
| 内存 | Hash 编码更紧凑 | 同左 |
220+
221+
Hash 方案少了一半命令开销,且过期时间只需设置一次,两个字段不会出现一个过期一个没过期的异常状态。
222+
223+
# 冷启动问题
224+
225+
如果 key 不存在,`tokens` 默认设为 `capacity`(满桶)。长时间没人用之后突然来一波流量,会瞬间放行 `capacity` 个请求。应对方式:
226+
227+
- 初始令牌设为 0 或一个较小的值
228+
- key 首次创建时设 `last_refill = now`,不补历史令牌
229+
230+
上面脚本用的是「初始满桶」,可根据业务调整。
231+
232+
TTL 设为 `capacity / rate + 1`,意思是“从空桶到满桶所需时间再多 1 秒”。超过这个时间 key 自然失效,下次再来按新桶算,避免冷启动积累巨量令牌。
233+
234+
# Lua 脚本放在哪里
235+
236+
Lua 脚本不“部署”到 Redis 上,而是由应用侧在每次调用时发送给 Redis 执行。和 SQL 类比——SQL 文件放 `resources/sql/`,Lua 文件放 `resources/lua/`,运行时发给服务端执行。
237+
238+
## 内联字符串(最简单,不推荐生产用)
239+
240+
```java
241+
String script =
242+
"local key = KEYS[1] " +
243+
"local now = tonumber(ARGV[1]) " +
244+
"return allowed";
245+
246+
Long result = redisTemplate.execute(
247+
new DefaultRedisScript<>(script, Long.class),
248+
List.of("rate_limit:user:123"),
249+
String.valueOf(System.currentTimeMillis()),
250+
"2", "10", "600"
251+
);
252+
```
253+
254+
脚本藏在代码字符串里,不好读、不好维护、没有语法高亮。
255+
256+
## 独立 .lua 文件(推荐起步方案)
257+
258+
```
259+
src/main/resources/
260+
└── lua/
261+
└── rate_limiter.lua
262+
```
263+
264+
```java
265+
@Configuration
266+
public class RedisConfig {
267+
268+
@Bean
269+
public DefaultRedisScript<Long> rateLimiterScript() {
270+
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
271+
script.setLocation(new ClassPathResource("lua/rate_limiter.lua"));
272+
script.setResultType(Long.class);
273+
return script;
274+
}
275+
}
276+
```
277+
278+
```java
279+
@Autowired
280+
private DefaultRedisScript<Long> rateLimiterScript;
281+
282+
public boolean isAllowed(String key) {
283+
Long result = redisTemplate.execute(
284+
rateLimiterScript,
285+
List.of(key),
286+
String.valueOf(System.currentTimeMillis()),
287+
"2", "10", "600"
288+
);
289+
return result != null && result == 1L;
290+
}
291+
```
292+
293+
脚本独立文件,有语法高亮,可单独做语法检查。
294+
295+
## EVALSHA 缓存(高并发生产推荐)
296+
297+
每次 `EVAL` 都会把完整脚本传给 Redis,浪费带宽。`EVALSHA` 先用 `SCRIPT LOAD` 注册脚本拿到 SHA1 哈希,后续只传哈希:
298+
299+
```
300+
第一次: SCRIPT LOAD "lua脚本内容" → 返回 "a1b2c3..."
301+
后续: EVALSHA a1b2c3... 1 key arg1 arg2 ...
302+
```
303+
304+
应用侧封装:
305+
306+
```java
307+
@Component
308+
public class RateLimiter {
309+
310+
private String sha;
311+
312+
@Autowired
313+
private RedisTemplate<String, String> redisTemplate;
314+
315+
@PostConstruct
316+
public void init() {
317+
String script = // 读 lua 文件内容
318+
sha = redisTemplate.scriptLoad(script);
319+
}
320+
321+
public boolean isAllowed(String key) {
322+
try {
323+
Long result = redisTemplate.execute(
324+
new DefaultRedisScript<>(sha, Long.class, true),
325+
List.of(key), ...);
326+
return result != null && result == 1L;
327+
} catch (RedisSystemException e) {
328+
// SHA 失效(Redis 重启过),重新加载
329+
sha = redisTemplate.scriptLoad(script);
330+
return isAllowed(key);
331+
}
332+
}
333+
}
334+
```
335+
336+
```python
337+
# redis-py 的 RegisterScript 自动处理 SHA 缓存和重载
338+
from redis import Redis
339+
340+
r = Redis()
341+
script = r.register_script(Path("lua/rate_limiter.lua").read_text())
342+
result = script(keys=["rate_limit:user:123"], args=[now_ms, 2, 10, 600])
343+
```
344+
345+
| 方式 | 适用场景 | 要点 |
346+
|------|---------|------|
347+
| 内联字符串 | 快速验证 | 不要用于生产 |
348+
| 独立 .lua 文件 | 一般项目 | 可读性好,推荐起步 |
349+
| EVALSHA 缓存 | 高并发生产 | 省带宽,需处理 SHA 失效重载 |
350+
351+
# 并发怎么变串行
352+
353+
Redis 单线程处理命令(6.0+ 的多线程只管 IO 读写,命令执行仍是单线程)。`EVAL` 执行 Lua 脚本时,Redis 阻塞其他所有命令,直到脚本跑完。100 个并发请求同时到达 Redis,也是排队逐个执行,每个脚本内部的读→算→写不会被中间插入。
354+
355+
等价于在应用侧加了一把全局锁,但不用自己实现锁——没有加锁、解锁、死锁、锁超时这些问题。
356+
357+
代价是所有限流请求都汇入 Redis 这一个瓶颈。如果限流 QPS 极高(比如几十万),Redis 单节点会成为性能天花板。实际场景中限流 QPS 一般远不到这个量级;真到那个量级,可以用**本地限流 + 分布式限流二级缓存**:本地 Guava RateLimiter 先挡一层,漏下来的再走 Redis。
358+
359+
# 与其他限流算法对比
360+
361+
| 算法 | 特点 | 适用场景 |
362+
|------|------|----------|
363+
| 固定窗口 | 简单,但窗口边界有突发(2 倍流量) | 要求不严格 |
364+
| 滑动窗口 | 比固定窗口平滑,但内存开销大 | 中等精度 |
365+
| 漏桶 | 严格匀速输出,无法应对合理突发 | 需要严格匀速 |
366+
| 令牌桶 | 允许突发(桶内有积累),长期速率受限 | **最通用,最推荐** |
367+
368+
令牌桶的优势:允许短时突发,但长期平均速率受限。这正好匹配真实流量模式——偶尔的突发是正常的,不应一刀切拒绝。
369+
370+
# 一句话总结
371+
372+
**令牌桶 = 用时间差惰性计算令牌数;Lua = 保证读-算-写原子性;Redis = 分布式共享状态。** 三者组合,实现简洁、正确、高性能的分布式限流。

0 commit comments

Comments
 (0)