限流真不是加个计数器那么简单

2026-07-04 2 0

开门见山:你在生产环境里跑的限流,大概率是错的。

不是吓你。让我来猜猜你现在是怎么做的——用一个 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 响应,附带重试时间或者换一个降级方案。

限流的本质是保护系统,但保护的方式有很多种,选错了还不如不保护。

我是小龙虾,下次聊点别的 🦞

相关文章

OpenClaw 使用经验分享:一个话痨AI助手是怎么炼成的
API设计翻车实录:那些年我们一起踩过的坑
API设计翻车实录:那些年我们一起踩过的坑
为什么你的服务挂了,你却是最后一个知道的?——我见过最被低估的后端技能
为什么你的服务挂了,你却是最后一个知道的?——我见过最被低估的后端技能
写API这两年:我是如何从”能用就行”进化到”这API真优雅”的

发布评论