面试题:分布式限流怎么做?令牌桶、滑动窗口各适合什么场景,为什么 Redis + Lua 是集群限流的标准答案?
这是「每日一题」专栏的第十七篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 为什么 vLLM 是当前最快的 LLM 推理框架,今天按轮换回到后端/分布式方向,聊上一篇预告的题:分布式限流。
一、先想清楚:限流到底在保护什么
面试答限流题,最忌讳上来就背令牌桶。先想清楚三个被保护的对象:
- 保护下游:你的服务调用第三方接口、数据库、下游 RPC,它们的容量是硬上限。请求量超出,下游直接雪崩,重试风暴又加剧雪崩——这是最典型的限流动机。
- 保护自身:突发流量打爆线程池和连接池,请求排队,RT 飙升,健康请求跟着一起被拖死。限流是「丢弃超额请求,保住核心可用性」的主动选择。
- 保护公平性与成本:防止单个用户/租户刷爆共享资源,按配额分配容量。
一句话定位:限流是系统的「断路器之前的最后一道闸」——限流挡在入口,熔断挡在出口。
二、单机限流四种算法,先分清再比较
1. 固定窗口(Fixed Window)
把时间切成固定长度窗口,每个窗口内计数,超限拒绝。实现最简单:一个计数器 + 窗口起始时间。
致命缺陷是临界突刺:假设限制是每分钟 100 次,59 分 59 秒来了 100 次,下一秒窗口重置,又放进来 100 次——瞬间 200 次打向下游。窗口边界处流量翻倍,限流形同虚设。
2. 滑动窗口(Sliding Window)
把「当前时刻往前看一个窗口长度」作为判断区间,彻底消灭临界突刺。两种实现:
- 滑动日志:记录每个请求的时间戳,统计窗口内的请求数。精确,但每个请求都要存时间戳,内存 O(N)。
- 滑动窗口计数:把大窗口切成 N 个小格子,只统计「上一格的部分 + 当前格」,用加权近似。内存固定 O(N),精度靠格子数换。
面试能答出「滑动窗口是固定窗口的精度升级,滑动日志是滑动窗口的存储换精度」就过了第一关。
3. 令牌桶(Token Bucket)
桶里按固定速率放令牌,桶有容量上限;请求来了先取令牌,取到就放行,取不到就拒绝。两个参数:速率 rate(每秒补充多少令牌) 和 容量 capacity(桶最多存多少)。
关键特性:允许突发(burst)。桶里攒了 capacity 个令牌,瞬间可以放行 capacity 个请求,然后恢复到 rate 的稳态速率。适合允许短时突发、追求吞吐的场景:API 网关、抢购入口、视频流拉取。
4. 漏桶(Leaky Bucket)
请求进桶,桶底以恒定速率漏出处理——输出速率恒定,不管输入多猛。实现上就是「请求排队 + 匀速消费」,本质是带缓冲的固定速率器。
关键特性:严格平滑,不允许突发。适合下游对速率极敏感的场景:数据库写入、消息生产、日志上报。代价是突发时延迟变大、请求排队。
选型一句话:要吞吐、允许突发 → 令牌桶;要平滑、下游脆弱 → 漏桶;要简单、能容忍边界误差 → 固定窗口;要精确 → 滑动窗口。网关(如 Nginx limit_req、Sentinel、Guava RateLimiter)默认都是令牌桶变体,不是没道理的。
三、从单机到集群:问题立刻变难
单机限流在每个实例内存里计数,天然没问题。一旦服务水平扩容到 N 个实例,就出现两个核心矛盾:
- 计数不共享:每个实例各数各的,总量是单机限额的 N 倍。用户调 10 次接口打到 10 台机器,每台都放行——限流等于没限。
- 入口不统一:请求被负载均衡随机分发,无法在入口做全局判断。
所以集群限流的本质是:把「计数状态」从各实例本地,挪到一个所有实例都能原子访问的共享存储——标准答案就是 Redis。
演进路线要能讲出层次感:
- 第一步:单机限流(Guava RateLimiter / 本地计数器),撑住单机突发;
- 第二步:Redis 中心化限流,全局精确,但每次请求多一次 RTT 和 Redis 压力;
- 第三步:分层限流(本地预分配 + 中心校准),本地桶先挡大部分流量,周期性向中心同步配额——用「近似」换「性能」,是生产级网关(如 Sentinel 集群流控、自研网关)的常见形态。
四、为什么 Redis + Lua 是标准答案
核心原因只有一个词:原子性。
限流是典型的「读-判-写」三步操作:读当前计数 → 判断是否超限 → 写入新计数。在并发下,这三步必须原子执行,否则两个请求同时读到 count=99,都放行,超限。Redis 单命令(INCR、EXPIRE)是原子的,但「多命令组合」不是——所以必须用 Lua 脚本把整个流程包进一次原子执行。顺带还省掉了 N 次网络往返:一个 EVAL 搞定原来 3~4 次 RTT 的活。
令牌桶的 Lua 实现
-- KEYS[1]: 令牌数 key;KEYS[2]: 上次补充时间 key
-- ARGV[1]: 桶容量;ARGV[2]: 补充速率(令牌/秒);ARGV[3]: 当前时间戳(ms);ARGV[4]: 本次请求消耗令牌数
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 懒补充:不用定时任务,取的时候按时间差补令牌
local tokens = tonumber(redis.call('GET', KEYS[1]) or capacity)
local last = tonumber(redis.call('GET', KEYS[2]) or now)
tokens = math.min(capacity, tokens + (now - last) / 1000 * rate)
redis.call('SET', KEYS[2], now)
if tokens >= requested then
redis.call('SET', KEYS[1], tokens - requested)
return 1 -- 放行
end
redis.call('SET', KEYS[1], tokens)
return 0 -- 拒绝
注意 懒补充 这个细节:令牌不用定时任务去加,取的时候按「当前时间 - 上次补充时间」一次性补齐,然后 SET 回写。这是面试里区分「背过」和「真懂」的点。
滑动窗口的 Lua 实现(ZSET 版)
-- KEYS[1]: 窗口有序集合 key;ARGV[1]: 窗口大小(ms);ARGV[2]: 限额;ARGV[3]: 当前时间戳(ms);ARGV[4]: 请求唯一 ID
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local rid = ARGV[4]
-- 先清掉窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, rid)
redis.call('PEXPIRE', KEYS[1], window) -- 顺带设过期,防止 key 堆积
return 1
end
return 0
这个版本直接对应滑动日志思路,每个请求一条 ZSET 记录,精确无临界突刺;代价是请求量大时 ZSET 内存和清理开销上升——所以生产上更常用「滑动窗口计数」的格子近似版,把 ZSET 换成几个 INCR 计数器。
五、集群限流的进阶坑,答出来是加分项
坑 1:Redis 是单点,限流器挂了怎么办?
要预先定好降级策略:fail-open(挂了放行) 还是 fail-closed(挂了拒绝)。业务接口通常 fail-open + 本地限流兜底(Redis 超时异常时降级到本地令牌桶),宁可短暂超限也不能让整个服务不可用;对账、支付类接口可以 fail-closed。答出「降级策略 + 本地兜底」是明确加分项。
坑 2:Redis 集群下 key 怎么分布?
Redis Cluster 按 key 哈希分片,不同用户/接口的限流 key 会散到不同节点——这没问题;但如果一个限流器逻辑需要多个 key(比如令牌桶的两个 key),它们必须落在同一节点,否则跨节点无法原子。解法是 hash tag:把 key 写成 {user:123}:tokens 和 {user:123}:last,花括号内的部分参与哈希,保证同节点。
坑 3:Redis 性能扛不住怎么办?
中心化限流每次请求一次 RTT,QPS 百万级的网关扛不住。演进路线:本地令牌桶 + 周期向 Redis 同步配额(Sentinel 集群流控的做法),或者一致性哈希把用户分片到固定节点做分片限流(每个分片各自限额,总量等于分片限额之和)。本质都是「用全局近似换性能」。
六、收尾
分布式限流这道题,把三层讲清楚就是满分:
- 算法层:四种算法各自的精度、突发性、成本,选型依据;
- 一致性层:单机 → Redis 中心化的演进动机,Lua 解决的是「多步操作原子性」问题,懒补充、hash tag、过期时间是实现细节;
- 可用性层:Redis 挂了的降级策略,性能扛不住的演进路线。
能答出「为什么是 Lua 而不是多条命令」「令牌桶为什么允许突发而漏桶不」「挂了怎么降级」这三个追问,这道题就稳了。
明日预告:系统设计方向 ——「设计一个短链接系统:短码怎么生成才不碰撞、跳转为什么用 301、读写比悬殊的存储层和缓存层怎么选?」