主题
第二十五章:分布式限流
Q77:本地限流和分布式限流有什么区别?为什么有了本地限流还需要分布式限流?
答:
限流的目标是「限制请求通过的速率」,但「本地限流」和「分布式限流」作用范围完全不同,面对集群部署时,本地限流会出现「总量失控」的问题。
一、本地限流
本地限流是在单个进程/实例内部计数,用 Guava RateLimiter、Sentinel 单机模式、漏桶/令牌桶的本地实现等。
text
实例 A ── 本地限流(限制本实例 100 QPS)
实例 B ── 本地限流(限制本实例 100 QPS)
实例 C ── 本地限流(限制本实例 100 QPS)问题:每个实例各自限自己的流,互不知晓。如果全局只允许 100 QPS,但你有 3 个实例,每个都限 100,实际总流量可达 300 QPS,超出全局限制。
text
期望:全局最多 100 QPS
实际:实例 A(100) + 实例 B(100) + 实例 C(100) = 最多 300 QPS
└─ 全局总量没被限制住!本地限流的优点是「零网络开销、延迟极低、单机性能极高」;缺点是「无法跨实例协同,控制不住全局总量」。
二、分布式限流
分布式限流是把计数状态放到所有实例共享的存储(通常是 Redis)里,让所有实例共同维护一个全局计数器。
text
实例 A ──┐
实例 B ──┼──► Redis(全局计数器)── 总量在这里统一限制
实例 C ──┘这样无论请求从哪个实例进来,都去 Redis 里增加计数、判断是否超限,实现全局统一限流。
三、为什么需要分布式限流
| 维度 | 本地限流 | 分布式限流 |
|---|---|---|
| 作用范围 | 单个实例 | 全局(所有实例) |
| 全局总量控制 | 不能 | 能 |
| 延迟 / 性能 | 极低(内存操作) | 略高(多一次 Redis 网络往返) |
| 依赖 | 无 | 依赖 Redis(或其分布式存储) |
| 适用场景 | 保护单实例自身、粗粒度防突发 | 保护后端整体、精确控制全局额度 |
结论:单机部署时本地限流够用;一旦多实例部署,要控制「全局总量」,就必须用分布式限流。常见的做法是本地限流兜底 + 分布式限流精确控制结合:本地先粗限(快、挡突发),Redis 再细限(精、控全局)。
四、典型结合架构
text
请求 ──► 本地限流(先挡掉明显超量的,零开销)
│ 放行
▼
Redis 分布式限流(全局精确控制)
│ 放行
▼
业务处理本地限流负责「快速拒绝大部分流量」,分布式限流负责「精确保证全局不超过阈值」,两者配合兼顾性能与精确。
Q78:分布式限流用 Redis 实现,有哪些方案?固定窗口、滑动窗口、令牌桶在分布式下怎么落地?
答:
分布式限流就是把限流算法的「状态」放到 Redis,让多个实例共享。第十七章讲过限流算法本身,这里重点讲分布式环境下怎么落地、有哪些坑。
一、方案总览
| 方案 | 实现方式 | 精度 | 成本 | 适用 |
|---|---|---|---|---|
| 固定窗口(计数器) | INCR key + EXPIRE | 低(有临界突刺) | 低(1~2 次命令) | 简单、容忍误差的场景 |
| 滑动窗口 | ZSet + 时间戳,Lua 原子执行 | 高 | 中(3~4 次命令) | 需要精确、防边界突刺 |
| 令牌桶 / 漏桶 | Hash 存状态 + Lua,原子补充 + 消费 | 中高 | 中(1 次 Lua) | 需要平滑输出、允许突发 |
| 漏斗(基于 Redis 的 Sentinel/Redisson) | 现成框架(Redisson RRateLimiter) | 高 | 低(框架封装) | 快速落地,Java 生态 |
二、方案一:固定窗口(最简单)
python
def fixed_window(user_id: str, limit: int, window_seconds: int = 1) -> bool:
import time
current = int(time.time() // window_seconds)
key = f"rate:{user_id}:{current}"
count = redis_client.incr(key)
if count == 1:
redis_client.expire(key, window_seconds)
return count <= limit优缺点:实现简单、命令少;但有「窗口边界突刺」问题(两个窗口交界处可能 2 倍于限流值通过)。适合对精度要求不高的场景。
三、方案二:滑动窗口(精确、防突刺)
用 ZSet 存「每次请求的时间戳」,配合 Lua 原子地「删旧 + 加新 + 计数」,避免窗口边界问题:
lua
-- KEYS[1]:限流 key
-- ARGV[1]:窗口大小(秒);ARGV[2]:限流阈值;ARGV[3]:当前时间戳;ARGV[4]:唯一请求标识
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local member = ARGV[4]
-- 1. 删除窗口外的旧请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window * 1000)
-- 2. 加入当前请求
redis.call('ZADD', KEYS[1], now, member)
-- 3. 统计窗口内请求数
local count = redis.call('ZCARD', KEYS[1])
-- 4. 设置过期,防止 key 堆积
redis.call('PEXPIRE', KEYS[1], window * 1000)
if count > limit then
return 0 -- 超限
else
return 1 -- 放行
endpython
def sliding_window(user_id: str, limit: int, window_seconds: int = 1) -> bool:
key = f"rate:{user_id}:sliding"
now_ms = int(time.time() * 1000)
member = f"{now_ms}-{uuid.uuid4()}"
result = redis_client.eval(SLIDING_WINDOW_LUA, 1, key, window_seconds, limit, now_ms, member)
return result == 1必须用 Lua:ZREMRANGEBYSCORE + ZADD + ZCARD 三步要原子完成,否则并发下计数会错乱。
优缺点:精度高,能精确统计「最近 N 秒」的请求数,无边界突刺;但每条请求都要写一条 ZSet 记录,内存和命令开销比固定窗口高。
四、方案三:令牌桶(平滑 + 允许突发)
令牌桶用 Hash 存「当前令牌数 + 上次补充时间」,Lua 原子「补令牌 + 消费」:
lua
-- KEYS[1]:桶 key
-- ARGV[1]:桶容量;ARGV[2]:令牌补充速率(个/秒);ARGV[3]:当前时间戳(秒)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or tostring(capacity))
local last_time = tonumber(redis.call('HGET', KEYS[1], 'last_time') or tostring(now))
-- 补充令牌(不超过容量)
local elapsed = now - last_time
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HSET', KEYS[1], 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', KEYS[1], 2)
return 1 -- 放行
else
redis.call('HSET', KEYS[1], 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', KEYS[1], 2)
return 0 -- 拒绝
end优缺点:允许合理的突发(令牌攒够可一波放行),输出平滑,最适合「允许突发 + 平均限速」的 API 限流;实现比固定窗口复杂。
五、方案四:现成框架(Redisson RRateLimiter)
Java 生态里,Redisson 提供了 RRateLimiter,底层就是基于 Redis 的令牌桶实现,封装好了原子性、分布式协调、配置:
java
RRateLimiter limiter = redisson.getRateLimiter("rate:api");
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS); // 每秒 100
limiter.tryAcquire(); // 尝试获取令牌如果团队用 Java,直接用现成框架比自己写 Lua 更省心、更可靠。
六、分布式限流的通用注意点
- 原子性:多命令操作(滑动窗口的 3 步、令牌桶的补+消费)必须用 Lua 包成一个原子操作,否则并发下计数错误;
- 时钟一致:分布式下各实例时间要一致(最好用 Redis 服务器时间
TIME而非各自机器时间),避免时间戳不一致; - 性能开销:分布式限流每次请求都要访问 Redis,有网络 RTT,高 QPS 下要配合本地限流先挡一层;
- 降级:Redis 故障时限流不能跟着瘫痪,要有降级策略(如 Redis 不可用时放行或本地限流兜底);
- 精度 vs 成本的权衡:固定窗口最省但精度低,滑动窗口最准但开销大,令牌桶居中,按需求选。
Q79:分布式限流有哪些常见的坑?限流方案如何做好降级和容错?
答:
分布式限流依赖 Redis 这个「共享状态」,一旦 Redis 出问题或设计不当,限流本身会变成新的故障源。下面是几个高频坑和应对办法。
一、常见坑
| 坑点 | 后果 | 规避 |
|---|---|---|
| 多命令非原子 | 并发下计数错乱,限流失效 | 用 Lua 把「判断 + 计数」原子化 |
| 依赖各实例机器时间 | 时钟不一致导致窗口计算错误 | 用 Redis 服务器时间,或做时钟同步 |
| 每次请求都打 Redis | 高 QPS 下限流本身成为瓶颈 | 本地限流先挡,Redis 细控 |
| Redis 故障时限流跟着挂 | 限流依赖 Redis,Redis 挂了业务也受牵连 | 降级策略:Redis 不可用时的处理 |
| 降级策略是「直接拒绝」 | Redis 短暂抖动导致大量误杀 | 优先「放行」或「本地限流兜底」,而非一挂了就全拒 |
| 不做限流维度区分 | 全局限流误伤正常流量 | 按 用户/IP/接口/租户 等维度分开限 |
| 限流状态无 TTL | key 无限堆积,内存泄漏 | 限流 key 必须设过期时间 |
| 用 keyspace 通知做精确限流 | 过期事件不可靠,导致限流不准 | 不要在限流里依赖不可靠机制 |
二、降级与容错的核心原则
原则 1:限流的降级方向应该是「放行」而非「拒绝」(取决于业务)
当 Redis 限流组件不可用时,有两种降级:
- 降级为放行:适用于「限流是优化手段,不是生死线」的场景。Redis 抖动时宁可放行也不能误杀正常用户;
- 降级为本地限流:Redis 不可用时,退回到本地限流做粗粒度兜底,至少保证单机不被打爆。
对于「保护后端、防雪崩」的限流,通常优先本地限流兜底,而不是直接全放行或全拒绝。
原则 2:本地限流 + 分布式限流双层防护
text
第一层:本地限流(零网络开销,先挡明显超量的)
第二层:分布式限流(Redis 精确控制全局)本地限流保证「即使 Redis 挂了,单机也不会被瞬间打爆」;分布式限流保证「Redis 正常时,全局总量精确受控」。两者结合是生产环境最稳健的做法。
三、一个带降级的实现思路
python
def rate_limit(user_id: str, limit: int) -> bool:
try:
# 分布式精确限流
return distributed_limit(user_id, limit)
except RedisError:
# Redis 故障,降级到本地限流兜底
return local_limiter.try_acquire(user_id)关键点:
- 捕获 Redis 异常:不让限流组件的故障拖垮业务;
- 本地兜底:降级到本地限流,保证单机不被打爆;
- 超时控制:访问 Redis 设短超时,避免 Redis 慢查询拖慢请求。
四、限流维度设计
限流不是「一刀切」,要按维度分别限:
| 维度 | 作用 |
|---|---|
| 全局 / 接口 | 保护后端整体不被打爆 |
| 用户 / 租户 | 防止单个用户/租户占用过多资源 |
| IP | 防单 IP 恶意刷 |
| 方法 / 路径 | 核心接口和普通接口区别对待 |
| 时间粒度 | 秒级 / 分钟级 / 天级区分 |
合理的维度设计能「精准打击恶意流量、不误伤正常用户」。
五、小结
- 分布式限流必须原子(Lua)、时钟一致、设 TTL;
- 降级方向优先「本地限流兜底」,而非 Redis 一挂就全拒绝或全放行;
- 双层防护(本地 + 分布式)是生产最稳方案;
- 限流要分维度,避免全局误伤;访问 Redis 要设超时,故障要能降级。