BodySense Redis 第 6 课:Rate Limiting
以 BodySense 登录、注册和刷新接口为案例,学习 Fixed Window、Sliding Window、Token Bucket、Leaky Bucket,以及 Redis INCR+EXPIRE 的分布式限流边界。
[!info] related notes
BodySense Redis 第 6 课:Rate Limiting
[!abstract] 学习目标 不只会写 INCR,而是能根据接口风险、成本、公平性和用户体验选择限流算法,并理解为什么分布式多实例需要共享状态。
先从零解释:Rate Limiting 到底是什么
Rate Limiting 可以直译成“速率限制”。
它解决的问题不是:
某个请求是不是合法业务请求?
而是:
即使请求本身合法,在一个时间尺度内,允许它发生多少次?
例如登录接口:
1 次密码输错
→ 很正常
5 分钟 10 次
→ 可疑,但还可能是用户忘记密码
1 秒 500 次
→ 高概率自动化攻击 / 滥用
所以限流通常保护四类资源:
Security
→ brute force / credential stuffing
Capacity
→ 防止下游数据库、模型、文件处理被打满
Cost
→ 限制昂贵 AI / 外部 API 调用
Fairness
→ 避免单个用户吃掉所有共享资源
Rate Limit、Authentication Throttling 与 Account Lockout 不同
通用 Rate Limit:
在一个时间窗口内
对某个 dimension
允许 N 次操作
Authentication Throttling:
结合 account + source/device + history
→ 控制 failed authentication 的尝试速率和风险升级
Account Lockout:
达到特定状态
→ 暂时/持续禁用某个 authenticator 或登录路径
固定:
连续错误 5 次
→ 整个账号冻结 30 分钟
只是其中一种简单 lockout policy,而且可能被攻击者反向利用:
attacker knows victim email
→ intentionally fail N times
→ victim account unavailable
所以认证系统更适合组合:
- account-level failed-attempt counter;
- source/IP/device rate limit;
- progressive delay;
- suspicious-pattern challenge;
- MFA/WebAuthn step-up;
- 必要时才 lock specific authenticator。
详见 Authentication Throttling & Account Lockout。
HTTP 层常见结果:429
如果请求身份是合法的,只是现在请求太频繁,常见响应是:
429 Too Many Requests
而不是:
401 Unauthorized
403 Forbidden
500 Internal Server Error
这能让客户端知道:
不是永久没有权限,而是“现在超过速率限制”。
限流器最少需要三样东西
无论算法如何,至少要回答:
1. 对谁计数? → dimension / identity
2. 多长时间尺度? → window / refill
3. 允许多少? → limit / capacity
例如:
dimension = login + IP
window = 5 minutes
limit = 10
这里的 IP 不是算法;它只是“计数对象”。
为什么限流需要共享状态
如果 API 有多个实例:
Client
├─ request 1 → API A
├─ request 2 → API B
└─ request 3 → API C
每个进程只用本地内存计数:
A count=4
B count=3
C count=2
系统无法得到真实总请求数。
Redis 提供共享:
rate:<dimension>
→ shared counter / timeline
所有实例看到同一份状态。
BodySense 当前限流
当前认证路径:
Login 10 / 5 minutes
Register 5 / 15 minutes
Refresh 60 / 5 minutes
现役算法:Fixed Window。
Fixed Window:先学最简单的时间桶
Fixed Window 可以理解成:
把时间切成一块一块固定区间,每个区间有独立计数器。
例如 10 次 / 5 分钟:
12:00:00 ───────── 12:04:59
window #1
count <= 10
12:05:00 ───────── 12:09:59
window #2
count重新开始
Redis 最简单可以表示成:
Key = rate:login:<dimension>
Value = current count
TTL = 当前窗口剩余生命周期
第一次请求:
INCR → 1
→ 设置 TTL
后续请求:
INCR → 2,3,4...
→ 不重新延长窗口
例如:
窗口:12:00:00 - 12:04:59
上限:10
每次:
INCR key
if first:
EXPIRE key window
简单、高效、容易解释。
边界突发
假设允许 10 次 / 1 分钟:
12:00:59 → 10 requests
12:01:00 → new window
12:01:01 → 10 requests
极短时间内可能通过 20 次。
这就是 fixed-window boundary burst。
Sliding Window:不按整点切桶,而是看“此刻往前 N 秒”
Fixed Window 的问题是时间边界人为切开。
Sliding Window 的问题定义改成:
现在是 12:01:10
窗口 60 秒
→ 统计 12:00:10 ~ 12:01:10 的请求
窗口随着 now 不断向前滑。
Redis 常见实现之一是 Sorted Set:
member = request identity
score = timestamp
为什么 Sorted Set 合适?
因为它既能:
按 score 找时间范围
→ 删除窗口外旧记录
→ 统计窗口内还剩多少
所以不是“Sliding Window 就必须 Sorted Set”,而是 Sorted Set 恰好很好地表达时间排序集合。
目标:
从“当前固定桶”变为“向前滚动 N 秒内真实发生多少次”。
一种 Redis Sorted Set 实现:
score = request timestamp
member = unique request id
流程:
ZREMRANGEBYSCORE key -inf now-window
ZADD key now request-id
ZCARD key
EXPIRE key ...
优点:
- 更公平;
- 窗口边界不会突然清零。
代价:
- 每个请求要保存成员;
- 内存和操作成本高于一个 String counter;
- 多命令原子边界需要处理。
Token Bucket:允许 Burst,但限制长期平均速率
想象一个桶里装的是“请求许可 Token”。
配置:
capacity = 10
refill = 1 token / second
系统不断按时间补 Token,但最多 10 个。
一个请求到来:
token >= 1
→ 消耗 1
→ allow
token = 0
→ reject / wait
这带来一个很重要的性质:
如果用户一段时间没请求,桶已经攒满 10 个:
可以瞬间 burst 10 次
之后长期速度又被 refill rate 限制。
所以 Token Bucket 适合:
允许短时 burst
+
限制长期平均速率
把请求理解成消耗 Token:
bucket capacity = 10
refill = 1 token / second
request consumes 1
允许一定 burst,同时限制长期平均速率。
适合:
- API 通用限流;
- 需要平滑但允许短时突发。
Leaky Bucket:重点是把输出“匀速化”
另一个经典类比是一个漏水桶:
请求进入桶
↓
以固定速率漏出 / 被处理
Token Bucket 更强调:
允许 burst
控制长期速率
Leaky Bucket 更强调:
把输出处理节奏变平滑
在实际系统里,两者实现细节可能有变体,因此不要只背名字,要看真实 admission / queue 语义。
把请求看成进入一个桶,然后按固定速率流出。
更强调:
平滑输出速率。
常用于需要稳定处理节奏的场景。
Auth Login 需要多维 Dimension
对于普通 API:
IP
API key
user_id
往往已经足够表达公平性和容量。
但 Password Login 还要考虑攻击 pattern。
Brute Force
one account
→ many password attempts
需要 account-level counter。
Password Spraying
one source
→ many accounts
→ few attempts each
需要:
- unique accounts per source;
- subnet/device dimension;
- cross-account signal。
Credential Stuffing
known leaked username/password pairs
→ distributed proxies/devices
只靠单 IP limiter 很容易被绕过。
所以登录 throttling 的 dimension 更像:
account
+
source IP/subnet
+
device/browser
+
account × source
+
unique accounts touched
+
historical risk
完整攻击模型见 Brute Force、Password Spraying 与 Credential Stuffing。
Rate Limit Dimension
限流的关键不只是算法,还包括“对谁计数”。
可能维度:
IP
user_id
email
refresh token family
API key
device
endpoint
BodySense 对敏感 dimension 在进入 Redis key 前做 SHA-256,是为了避免:
rate:login:user@example.com
rate:refresh:<raw-refresh-token>
把 PII / bearer credential 暴露在 Redis keyspace、日志或诊断工具中。
Redis Failure 时怎么办
限流 Redis 挂了:
fail open?
fail closed?
local fallback?
没有统一答案。
安全敏感认证接口可以偏保守;但某些普通低风险 API 如果完全 fail closed,Redis 故障会让整个站点不可用。
因此新增用例时必须明确 failure policy。
Retry-After
当客户端被限制:
429 Too Many Requests
最好同时告诉:
多久可以再试?
HTTP 可使用 Retry-After 或 RateLimit 相关头,让前端不要盲目重试。
BodySense 当前真实 Lua 固定窗口
当前 apps/api/internal/auth/rate_limit.go 中不是伪代码,而是:
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {current, ttl}
调用侧先把业务 dimension 做 SHA-256:
raw dimension
→ sha256
→ bodysense:auth:rate:<digest>
这样可以避免把 email、raw Refresh Token 等敏感信息直接暴露在 Redis Key。
脚本返回:
current count
+
remaining ttl
Go 再计算:
Allowed = count <= policy.Limit
RetryAfter = ttl
这里可以看到课程前几课串起来了:
String counter
+ TTL
+ Lua atomicity
+ hashed key dimension
+ shared multi-instance state
核心语义:
INCR
+
第一次请求设置 EXPIRE
+
读取 TTL
+
返回 count / ttl
这正好结合第 4 课的原子性知识。
Prediction
[!question]
- 为什么多 API 实例下本地 map 限流会失真?
- Fixed Window 最大公平性问题是什么?
- 登录接口和 AI Diagnosis 生成接口的限流目标是否完全相同?
- 为什么 raw email / raw Refresh Token 不应直接放 Redis Rate Limit Key?
- Sorted Set Sliding Window 相比 String Counter 多付出了什么成本?
[!success]- Answer / 解析 1. 为什么多 API 实例下本地 map 限流会失真?
因为请求会被负载均衡到不同实例,每个实例只看到自己的局部计数。假设真实发生 12 次请求,A/B/C 可能分别只看到 4 次,于是每个实例都认为没有超限。Redis 或网关级共享状态让所有实例基于同一计数做决策。2. Fixed Window 最大的公平性问题是什么?
窗口边界突发。用户可以在旧窗口结束前瞬间打满额度,又在新窗口开始后立刻再次打满,所以极短时间内通过的请求数可能接近配置上限的两倍。3. Login 和 AI Diagnosis 的限流目标完全相同吗?
不相同。Login 更偏向防暴力破解、credential stuffing 和账号安全;AI Diagnosis 还涉及昂贵模型调用、系统容量、并发与成本控制。它们的 dimension、阈值、burst tolerance、failure policy 都可能不同。4. 为什么 raw email / raw Refresh Token 不应直接进入 Redis Key?
Redis key 可能出现在日志、诊断命令、备份、监控或运维输出中。Email 属于 PII,Refresh Token 更是 bearer credential。先做稳定 digest / HMAC 后再作为 dimension,可以保留“同一主体映射到同一 Key”的能力,同时降低敏感数据泄露风险。5. Sliding Window 相比 String Counter 多付出了什么成本?
它通常需要为窗口内的每次请求保存一个时间点成员,并执行 ZREMRANGEBYSCORE + ZADD + ZCARD 等操作,因此占用更多内存、命令数更多、复杂度通常为 O(log N) 级别,还要额外处理并发原子性和历史成员清理。换来的收益是更真实的滚动窗口和更好的公平性。
Lab
实现两个隔离版本:
Fixed Window
INCR
EXPIRE
TTL
Sliding Window
ZREMRANGEBYSCORE
ZADD
ZCARD
用同一组突发请求比较:
- 通过数;
- 内存;
- 命令数;
- 用户感知;
- 并发正确性。
L4 Gate
给出三类接口:
Auth Login
AI Generation
File Upload
分别选择限流策略,并解释:
- 对 Auth Login,额外区分 brute force / password spraying / credential stuffing;
- account-level 与 source-level dimension;
- hard lockout 是否会制造 DoS;
- abuse risk;
- cost control;
- burst tolerance;
- fairness;
- Retry-After;
- Redis failure policy。