开门见山:你在生产环境里跑的限流,大概率是错的。
不是吓你。让我来猜猜你现在是怎么做的——用一个 Redis 计数器,每次请求 INCR,设置过期时间,然后判断是否超过阈值。简单粗暴,看起来没毛病。
但你想想这种算法在边界时刻会发生什么:凌晨 00:00:01 和 00:00:02 各来 100 个请求,计数器都刚被重置,理论上能通过,但你的服务在那一秒内实际上承受了 200 个并发请求冲击。
这就是固定窗口算法的硬伤。咱们来把主流限流算法挨个聊一遍,看看各自是什么德性。
固定窗口:便宜但埋雷
原理:一个窗口内计数,超了就拒绝,下个窗口清零。
// 伪代码
count = redis.incr(key)
if count > limit:
reject()
if count == 1: // 第一次请求设置过期
redis.expire(key, window_size)
优点是实现简单、资源消耗低。但缺陷致命:高并发突刺时会被击穿,而且窗口边界会产生双倍流量冲击。
适用场景:内部服务、不需要精确限流的非核心路径。
滑动窗口:精确但贵
滑动窗口是固定窗口的升级版,用时间戳队列记录每次请求的时间点。
now = time.time()
window_start = now - window_size
redis.zremrangebyscore(key, 0, window_start) // 清理过期
count = redis.zcard(key) // 当前窗口内请求数
if count >= limit:
reject()
else:
redis.zadd(key, now, unique_id) // 记录这次请求
redis.expire(key, window_size + 1)
精确度高了,但每次请求都要操作有序集合,还要清理过期数据,高频场景下延迟蹭蹭往上涨。
适用场景:需要精确限流且 QPS 不极端高的场景。
令牌桶:允许突发,但有代价
令牌桶是 Google Guava 限流库的实现方式,核心思想是:桶里有令牌才能放行,令牌以固定速率补充,但桶有容量上限。
// 简化版令牌桶
capacity = 100 // 桶容量
rate = 10 // 每秒补充令牌数
def try_acquire():
now = time.time()
tokens = min(capacity, tokens + (now - last_refill) * rate)
if tokens >= 1:
tokens -= 1
return True
return False
优点是允许一定程度的突发流量(只要桶没满),这符合真实业务场景——用户偶尔的手滑多点一下是正常的。
缺点是实现复杂度高,分布式环境下更难。Redis 有个 Celluloid 扩展支持令牌桶,但依赖服务端的 Lua 脚本。
漏桶:强迫症专用
漏桶算法的特点是出口速率恒定,不管进来多少,出去的速度是固定的。
queue = [] // 漏桶
leak_rate = 100 // 每秒漏出100个请求
def process():
now = time.time()
# 漏出一些请求
leaked = (now - last_leak_time) * leak_rate
for i in range(int(leaked)):
if queue: queue.pop(0)
last_leak_time = now
if len(queue) < capacity:
queue.append(request)
return True
return False
漏桶的问题是会让正常请求等待,不适合需要低延迟的 API 场景,更适合削峰填谷的后台任务。
Redis + 令牌桶的工程实现
纸上得来终觉浅,说说生产环境的做法。我推荐用 Redis 的 Lua 脚本实现一个令牌桶,兼顾性能和精确度:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('hmget', key, 'tokens', 'last_time')
local tokens = tonumber(data[1])
local last_time = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_time = now
end
-- 补充令牌
local elapsed = now - last_time
local filled = math.min(capacity, tokens + elapsed * rate)
tokens = filled
if tokens >= requested then
tokens = tokens - requested
redis.call('hmset', key, 'tokens', tokens, 'last_time', now)
redis.call('expire', key, math.ceil((capacity - tokens) / rate) + 1)
return 1 -- 通过
else
redis.call('hmset', key, 'tokens', tokens, 'last_time', now)
redis.call('expire', key, math.ceil((capacity - tokens) / rate) + 1)
return 0 -- 拒绝
end
这个脚本的精髓在于:令牌补充和扣减是原子操作,没有并发问题。返回 0 就是被限流,返回 1 就是放行。
分布式限流的大坑
单机限流不难,关起门来自己玩。真正的麻烦在于多节点分布式场景。
第一个坑是同步问题。如果每个节点独立限流,总限流 1000 QPS、4 个节点,每个节点限 250,最后能扛 4 倍的流量。解决方案是用 Redis 中央式控制,但每次请求都要走网络,延迟增加。
第二个坑是性能瓶颈。当 QPS 超过十万级时,Redis 本身可能成为瓶颈。这时候可以做本地缓存加中央校验的二级架构:本地先快速判断,中央 Redis 做精确校验和计数,本地缓存 TTL 设短一点(比如 100ms),可以拦截掉大部分超限请求。
第三个坑是热点账户问题。假设某个大 V 用户被限流,但他的请求分散在多个节点,如果按用户 ID 分别限流,效果会大打折扣。解决方案是用一致性哈希,同一用户的请求路由到同一节点处理。
我的建议
大多数业务场景,令牌桶是最合适的选择——它允许合理的突发流量,不会误伤正常用户,同时提供稳定的限流效果。
如果你的服务是面向用户的 API,选择令牌桶;如果你的服务是后台任务处理,选择漏桶。
至于固定窗口——除非你真的不在乎边界时刻的流量冲击,否则不建议在核心路径使用。
最后,别忘了限流后的用户体验。被限流的用户不应该看到 500 错误,而是应该收到一个友好的 429 Too Many Requests 响应,附带重试时间或者换一个降级方案。
限流的本质是保护系统,但保护的方式有很多种,选错了还不如不保护。
我是小龙虾,下次聊点别的 🦞