限流:那些你以为懂了但其实没懂的算法,今天我帮你扒光了他们裤子

2026-04-29 11 0

做后端开发这么多年,最让我睡不着觉的不是代码写不出来,是线上突然跑进来一批请求,然后我的服务就像被踩了一脚的蚂蚁——原地去世。

限流,这个词大家肯定听过。但你真的懂它吗?

我见过太多团队一拍脑袋决定用 Redis SETNX 做限流,结果上线第一天就被脉冲流量打穿。也见过有人信誓旦旦说「我用令牌桶,稳的」,结果高并发下一秒崩给你看。

今天我们来好好聊聊限流这件事,把几个经典算法的底裤都扒掉。


固定窗口:最简单的方案,也是最容易翻车的

固定窗口大概是很多人入门限流的第一站。思路简单粗暴:在单位时间内计数,超过阈值就拒绝。

// 固定窗口伪代码
func isAllowed(key string, limit int, windowSec int) bool {
    count := redis.Get("rate:" + key)
    if count >= limit {
        return false
    }
    redis.Incr("rate:" + key)
    redis.Expire("rate:" + key, windowSec)
    return true
}

看起来没问题对吧?单位时间内最多 N 个请求,很合理。

但这里有个巨大的陷阱——窗口边界问题

假设你设置的是每秒 100 个请求。流量在 0.9s 涌进来 100 个,然后在 1.1s 又涌进来 100 个。这两个窗口分别是 [0s,1s) 和 [1s,2s),各自都没超过阈值,看起来一切安好。

但实际上在 0.9s 到 1.1s 这 0.2s 里,你处理了 200 个请求。这就是「脉冲流量」攻击,两倍的 QPS 在你毫无察觉的情况下涌进来。

固定窗口算法的问题是:它把流量切割成了独立的窗口,但真实的流量是连续的。窗口边界处没有任何保护。


滑动窗口:固定窗口的升级版,但还不够

滑动窗口的思路是把时间轴连续化,不再有硬性的窗口边界。你每次请求都计算过去 N 秒内的请求数。

// 滑动窗口伪代码(基于 Redis Sorted Set)
func isAllowed(key string, limit int, windowSec int) bool {
    now := time.Now().UnixMilli()
    windowStart := now - windowSec*1000
    
    // 清理过期数据
    redis.ZRemRangeByScore("rate:"+key, 0, windowStart)
    
    // 统计当前请求数
    count := redis.ZCard("rate:" + key)
    if count >= limit {
        return false
    }
    
    // 记录当前请求
    redis.ZAdd("rate:"+key, now, fmt.Sprintf("%d", now))
    return true
}

滑动窗口解决了窗口边界问题,因为每次检查的都是「过去 N 秒」,而不是某个固定窗口。

但它也有代价:需要存储每一条请求的时间戳,而且每次请求都要清理过期数据。QPS 极高的情况下,ZCard 操作会成为性能瓶颈。

另外,滑动窗口的实现精度和存储精度有关。如果用秒级时间戳,在边界处还是可能出现一点误差。


令牌桶:最优雅的算法,但最容易被错误实现

令牌桶是我最喜欢的限流模型,因为它符合真实流量的特征——允许一定程度的突发流量,同时又能保证长期速率。

想象一个桶,以固定速率往里面放令牌,每来一个请求就从桶里取一个令牌,桶空了就拒绝。桶的容量就是你能承受的突发大小。

// 令牌桶核心逻辑(单机版)
type TokenBucket struct {
    rate       float64 // 每秒补充的令牌数
    capacity   int     // 桶容量
    tokens     float64 // 当前令牌数
    lastUpdate time.Time
    mu         sync.Mutex
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()
    
    now := time.Now()
    // 补充令牌
    tb.tokens += now.Sub(tb.lastUpdate).Seconds() * tb.rate
    if tb.tokens > float64(tb.capacity) {
        tb.tokens = float64(tb.capacity)
    }
    tb.lastUpdate = now
    
    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

但这里有个坑——并发安全。上面代码用了 mutex 来保护,但在高并发下 mutex 会成为瓶颈。更好的做法是用 atomic 操作,或者用 Redis 的 Lua 脚本实现无锁的令牌桶。

-- Redis 令牌桶 Lua 实现
local key = KEYS[1]
local rate = tonumber(ARGV[1])       -- 每秒补充令牌数
local capacity = tonumber(ARGV[2])   -- 桶容量
local now = tonumber(ARGV[3])        -- 当前时间戳(毫秒)

local lastUpdate = tonumber(redis.call(HGET, key, lastUpdate) or now)
local tokens = tonumber(redis.call(HGET, key, tokens) or capacity)

-- 计算应该补充的令牌
local elapsed = (now - lastUpdate) / 1000
local addTokens = elapsed * rate
tokens = math.min(capacity, tokens + addTokens)

local allowed = 0
if tokens >= 1 then
    tokens = tokens - 1
    allowed = 1
end

redis.call(HSET, key, tokens, tokens)
redis.call(HSET, key, lastUpdate, now)
redis.call(EXPIRE, key, 60)

return allowed

这个 Lua 脚本保证了令牌补充和消费的原子性,是生产环境可用的令牌桶实现。


漏桶:平整流量的一把好手,但不适合互联网场景

漏桶的思想是:流量以任意速率进入桶,但以固定速率流出。当桶满时,新请求被丢弃。

漏桶和令牌桶的区别在于:漏桶强制「匀速输出」,而令牌桶允许突发。

// 漏桶实现
type LeakyBucket struct {
    rate    float64 // 漏出速率(每秒)
    current float64 // 当前水量
    lastLeak time.Time
    mu      sync.Mutex
}

func (lb *LeakyBucket) Allow() bool {
    lb.mu.Lock()
    defer lb.mu.Unlock()
    
    now := time.Now()
    elapsed := now.Sub(lb.lastLeak).Seconds()
    lb.current = math.Max(0, lb.current - elapsed*lb.rate)
    lb.lastLeak = now
    
    if lb.current < lb.capacity {
        lb.current++
        return true
    }
    return false
}

漏桶的问题是:它会把所有突发流量都抹平。对于 API 服务来说,这可能过于严格——用户的一次正常突发请求不应该被当作攻击。

漏桶更适合下游系统的保护,比如数据库写入。当你需要以固定速率往数据库写数据时,漏桶是个好选择。


组合方案:线上环境不会只用一种算法

真实的限流策略往往是多层组合的:

  • 入口限流:在最外层用令牌桶或滑动窗口,应对大量突发流量
  • 服务限流:在应用层用计数器,配合熔断机制
  • 资源限流:对数据库、Redis 等下游资源单独限流,防止压垮依赖

另外,限流一定要考虑分布式场景。单机限流只能保护单机,但在集群环境下,需要统一的限流节点或者分布式协调(Redis + Lua 是常见方案)。


踩坑心得

说几个我踩过的坑:

1. 不要用 GET 类型的 Redis 命令做限流
SETNX + EXPIRE 看起来很美,但如果你在 GET 之后 EXPIRE 之前挂了,请求计数就丢了。用 SET + EXPIRE 把两个操作合并成一个。

2. 限流拒绝的 response 要有明确的标识
返回 429 Too Many Requests,并带上 Retry-After 头。不要只返回一个冷冰冰的错误码,用户不知道要等多久。

3. 监控比限流本身更重要
限流的目的是保护系统,但如果你不知道限流触发了多少次、被限掉的请求特征是什么,你就没办法优化。限流 metrics 要单独打出来。

4. 测试的时候要用真实的流量模型
别用 curl 循环跑测试,那样太均匀了。用 wrk 或者 k6,模拟真实的请求分布,才能看出限流算法在边界的表现。


写在最后

限流不是一个「加上就行」的配置,是需要对业务流量有深刻理解的设计。你得知道:正常流量长什么样、峰值是什么时候、突发场景允许多大的延迟。

每个算法都有自己的适用场景,没有银弹。固定窗口适合内部服务的简单限流,滑动窗口适合需要精确控制的 API 限流,令牌桶适合需要处理突发流量的入口限流,漏桶适合下游保护。

选对了算法,系统稳如老狗。选错了,上线就是灾难片。

希望这篇文章能帮你少踩几个坑。

相关文章

懒得折腾?让小龙虾帮你一键部署AI工具,省心省力还省钱
懒得折腾?让小龙虾帮你一键部署AI工具,省心省力还省钱
为什么你的API总被吐槽?血泪教训总结的RESTful设计避坑指南
Go的错误处理:那些我曾经觉得很蠢、后来发现自己才是蠢的那个设计
AI 为什么会一本正经地胡说八道?
让你的API从能用变优雅:RESTful设计实战经验谈

发布评论