Skip to content

限流算法与工程实践 ​

定位:《秒杀系统设计》网关层、《短链系统设计》防刷的共同底座。也是"背了四种算法"和"真理解四种算法"的分水岭。面试要的不是复述定义,而是每种算法的失败形态、参数含义、单机与分布式的鸿沟。

一、四种算法逐一过堂 ​

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。"

六、追问链 ​

  1. 固定窗口 2 倍突刺怎么彻底解决? 滑动窗口或令牌桶;若只能固定窗口,可按倍数收紧阈值(治标)。
  2. Redis+Lua 的限流 key 成热点怎么办? 业务分 key、本地预扣批量结算;秒杀级热点参考《秒杀系统设计》热点 key 一节。
  3. 令牌桶参数怎么定? rate = 压测吞吐 × 0.7。capacity = 可容忍突发(下游 1s 能消化的量),保护 DB 时改漏桶或把桶调小。
  4. 限流返回什么? HTTP 429 + Retry-After 头。对内 RPC 返回限流错误码触发调用方熔断。别返回 500(会污染错误率监控、误触发告警)。
  5. 限流、熔断、降级什么关系? 限流防过载(入口)。熔断防雪崩(调用侧,错误率/慢调用触发)。降级保核心(丢车保帅)。三者互补,见《秒杀系统设计》降级预案。
  6. 用户维度 uid 太多,全进 Redis 受得了吗? 只对可疑/高频用户精细化,普通用户走接口维度。或 uid 哈希到本地分片各自限 总量/分片数,用一点精度换掉 Redis 依赖。
  7. WarmUp 为什么必要? 系统最脆弱的时刻(刚发布、缓存全空、JIT 未热)恰好是流量恢复最猛的时刻。冷启动低阈值过流量是给系统留热身时间。

七、面试官视角小结 ​

面试官在限流题里打量的四件事:

  • 知道每个算法的失败形态:固定窗口说得出"临界 2 倍"。令牌桶说得出"突发上限 = 桶容量",比背四个名字值钱。
  • 单机与分布式的边界:清楚 Guava 只管本进程。集群精确总量必须共享存储 + 原子性(Lua),以及 Redis 自身故障的降级路径。
  • 给方案先问维度和层级:接口/用户/IP × 网关/服务/DB 的矩阵答法,是"有工程经验"的最强信号。
  • 参数有出处:阈值挂在压测数据上并留水位,说明懂得限流是容量管理的一环而非拍脑袋。

一句话总结:算法层知道突刺与突发的边界。工程层单机滑动窗口、集群 Redis+Lua、Redis 挂了降本地。方案层按维度 × 层级成矩阵给出。

2026-10 新增(P0 补全)。