Appearance
限流算法与工程实践
定位:《秒杀系统设计》网关层、《短链系统设计》防刷的共同底座。也是"背了四种算法"和"真理解四种算法"的分水岭。面试要的不是复述定义,而是每种算法的失败形态、参数含义、单机与分布式的鸿沟。
一、四种算法逐一过堂
1)固定窗口计数
最简实现:INCR count + 到期重置。
窗口 1(上限 10) 窗口 2(上限 10)
●●●●●●●●●●│●●●●●●●●●●
▲
窗口交界前后各 0.5s 内通过了 20 个请求临界突刺:任意时刻往前看 1s,最多可以通过 2 倍限流值——限流阈值形同虚设。这是固定窗口被淘汰的核心理由,也是这道题的第一问。
2)滑动窗口
把 1 s 切成 N 个小格(如 10 格 × 100 ms),判断"最近 1 s 内所有小格之和"是否超限。每过 100 ms 窗口滑动一格。
- 格子越细越平滑,突刺从 2x 压到
1 + 1/N倍;代价是内存与统计开销。 - 本质是用精度换成本的折中,Sentinel 的 LeapArray 就是这个思路的工程化。
3)漏桶
请求进桶,恒定速率流出处理;桶满则拒绝/排队。
- 强平滑:不管进水多猛,出水永远匀速,最适合保护脆弱下游(DB、第三方接口)。
- 缺点:完全无法利用空闲期攒下的容量,合理突发也被削平,对上游不够友好。
4)令牌桶
匀速往桶里放令牌,桶满暂存;请求拿到令牌才通过。
- 允许突发:空闲期攒的令牌可以一次用掉,突发流量只要不超过桶容量就放行。
- Nginx limit_req、Guava RateLimiter 都基于它,是绝大多数场景的默认答案。
Java 手写思路(面试高频,核心是惰性计算,不用后台线程定时放令牌):
java
class TokenBucket {
private final long capacity; // 桶容量 = 可容忍的突发量
private final double rate; // 每秒发放令牌数
private double tokens; // 当前令牌数
private long lastNanos; // 上次补充时间
synchronized boolean tryAcquire(int n) {
long now = System.nanoTime();
tokens = Math.min(capacity,
tokens + (now - lastNanos) * rate / 1e9); // 按时间差补令牌
lastNanos = now;
if (tokens >= n) { tokens -= n; return true; }
return false; // 非阻塞版;阻塞版 sleep 到令牌够
}
}对比汇总:
| 算法 | 突发 | 平滑度 | 适用 |
|---|---|---|---|
| 固定窗口 | 2 倍突刺 | 最差 | 粗粒度统计场景 |
| 滑动窗口 | 小突刺 | 好 | 精确 QPS 控制(Sentinel) |
| 漏桶 | 不允许 | 严格匀速 | 保护 DB/第三方 |
| 令牌桶 | 允许(桶容量内) | 平均匀速 | 绝大多数入口限流 |
二、单机:Guava RateLimiter
SmoothBursty(默认):允许突发,可攒 1s 的令牌(maxBurstSeconds);acquire()阻塞等待令牌、tryAcquire(timeout)限时可失败。SmoothWarmingUp:冷启动预热——系统刚启动时发放速率从一个较低值(稳态/冷因子)线性爬升到稳态。避免冷系统(缓存未热、连接池未建)被第一时间打满。适合保护有预热成本的下游。- 惰性补令牌:与第一节手写思路一致,请求驱动,无定时线程。
- 局限:进程内生效。重启即丢状态。集群下各节点各自为政,总流量 = N × 单机阈值,精确总量必须上分布式。
三、分布式:Redis + Lua
限流是"读计数 → 判断 → 写计数"三步,跨节点并发下不原子就超限,所以必须 Lua:
lua
-- 固定窗口版(最简,有 2 倍突刺)
local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
return c -- c > limit 则拒绝
-- 滑动窗口版:zset member=请求ID score=毫秒时间戳
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[2] - ARGV[3]) -- 清窗外
local c = redis.call('ZCARD', KEYS[1])
if c < tonumber(ARGV[1]) then
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return 1
end
return 0工程要点:
- Redis 挂了限流跟着挂——客户端熔断后降级到本地单机限流(阈值 = 总量/节点数)兜底。降级语义见《分布式理论-BASE》。
- 单个限流 key 是热点(全部请求打一个分片)。量大时按业务分 key 或本地预扣(本地批量领令牌再去 Redis 结算)。
- key 设计即限流维度:
rl:api:order:{uid}/rl:api:order:{ip},TTL 即窗口。
四、Sentinel 的两把刀
- 滑动窗口(LeapArray):环形数组复用采样格,默认 1 s 采样 2 格(可调到 1 ms 级)。每格内 LongAdder 无锁累加 pass/block 统计。窗口滑动只移动指针,老格子复用前重置。它是"格子化的滑动窗口",精度与内存的平衡点。
- WarmUp(冷启动):令牌桶变体。冷却因子
coldFactor默认 3——冷态阈值从阈值/3起步,在预热时长内按梯度爬升到稳态。避免了"系统最脆弱的时刻放进最多流量"。与 Guava SmoothWarmingUp 同思想,但 Sentinel 面向资源维度、支持规则动态下发与集群流控(token server 集中发牌)。 - 三种流控效果:快速失败(超出即拒)、Warm Up(预热)、排队等待(漏桶思想匀速通过)。对应本文第一节三种算法的工程化。
五、面试给方案的标准答法
差生报菜名("我用令牌桶"),优等生先问维度和层级,再按矩阵给方案:
维度(对谁限)
接口 → 保护服务自身容量,阈值来自压测
用户 → uid 维度,防单用户刷(黄牛/爬虫)
IP → 防代理墙,注意 NAT 误伤,只做粗筛
设备 → 设备指纹,对抗群控
层级(在哪限,越靠前越便宜)
网关 → Nginx limit_req / 网关插件:第一道粗闸,拦掉明显超量
服务 → Sentinel(单机滑动窗口)/ Redis+Lua(集群精确总量):细粒度主战场
DB → 连接池上限 + 慢 SQL 熔断:最后防线,绝不让 DB 自己硬扛答法模板(可直接背):"先说目的一致:限流是主动损失部分请求,保住整体可用,属于兜底而非优化。维度上接口 + 用户 + IP 分开设 key。层级上网关粗限、服务细限、DB 兜底。单机用 Sentinel 滑动窗口,需要集群精确总量时上 Redis+Lua,Redis 故障降级本地限流。阈值取压测容量的 70% 留水位,超限返回 429 + Retry-After。"
六、追问链
- 固定窗口 2 倍突刺怎么彻底解决? 滑动窗口或令牌桶;若只能固定窗口,可按倍数收紧阈值(治标)。
- Redis+Lua 的限流 key 成热点怎么办? 业务分 key、本地预扣批量结算;秒杀级热点参考《秒杀系统设计》热点 key 一节。
- 令牌桶参数怎么定? rate = 压测吞吐 × 0.7。capacity = 可容忍突发(下游 1s 能消化的量),保护 DB 时改漏桶或把桶调小。
- 限流返回什么? HTTP 429 +
Retry-After头。对内 RPC 返回限流错误码触发调用方熔断。别返回 500(会污染错误率监控、误触发告警)。 - 限流、熔断、降级什么关系? 限流防过载(入口)。熔断防雪崩(调用侧,错误率/慢调用触发)。降级保核心(丢车保帅)。三者互补,见《秒杀系统设计》降级预案。
- 用户维度 uid 太多,全进 Redis 受得了吗? 只对可疑/高频用户精细化,普通用户走接口维度。或 uid 哈希到本地分片各自限
总量/分片数,用一点精度换掉 Redis 依赖。 - WarmUp 为什么必要? 系统最脆弱的时刻(刚发布、缓存全空、JIT 未热)恰好是流量恢复最猛的时刻。冷启动低阈值过流量是给系统留热身时间。
七、面试官视角小结
面试官在限流题里打量的四件事:
- 知道每个算法的失败形态:固定窗口说得出"临界 2 倍"。令牌桶说得出"突发上限 = 桶容量",比背四个名字值钱。
- 单机与分布式的边界:清楚 Guava 只管本进程。集群精确总量必须共享存储 + 原子性(Lua),以及 Redis 自身故障的降级路径。
- 给方案先问维度和层级:接口/用户/IP × 网关/服务/DB 的矩阵答法,是"有工程经验"的最强信号。
- 参数有出处:阈值挂在压测数据上并留水位,说明懂得限流是容量管理的一环而非拍脑袋。
一句话总结:算法层知道突刺与突发的边界。工程层单机滑动窗口、集群 Redis+Lua、Redis 挂了降本地。方案层按维度 × 层级成矩阵给出。
2026-10 新增(P0 补全)。