← [ BACK_TO_NOTES ]

BodySense Redis 第 6 课:Rate Limiting

以 BodySense 登录、注册和刷新接口为案例,学习 Fixed Window、Sliding Window、Token Bucket、Leaky Bucket,以及 Redis INCR+EXPIRE 的分布式限流边界。

#course #growing #backend #security #redis #bodysense

[!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]

  1. 为什么多 API 实例下本地 map 限流会失真?
  2. Fixed Window 最大公平性问题是什么?
  3. 登录接口和 AI Diagnosis 生成接口的限流目标是否完全相同?
  4. 为什么 raw email / raw Refresh Token 不应直接放 Redis Rate Limit Key?
  5. 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。
创建于 2026/9/18 更新于 2026/9/30
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本