限速策略基础:限速的核心思想、令牌桶算法、漏桶算法、计数器算法

聊到限速,很多刚入行的朋友第一反应就是“限制流量嘛,简单”。
嗯,确实简单,但想限得稳、限得准、限得让攻击者难受,这里面的门道可不少。

我做了这么多年DDoS防护,见过太多因为限速策略没设计好,结果把正常用户也误杀了的案例。
说白了,限速不是一刀切,而是要在“让坏人进不来”和“让好人进得来”之间找到那个平衡点。

这一章,咱们就把限速的底裤扒干净。
从最核心的思想开始,再到三种经典算法,我会结合实战经验,把每个坑都给你指出来。

限速的核心思想:你凭什么限我?

限速的本质是什么?
我个人习惯把它理解成“资源分配”。

你想想看,服务器处理请求的能力是有限的。CPU、内存、带宽、数据库连接池,这些都是稀缺资源。
当请求量超过处理能力时,系统就会崩溃。限速就是要在系统崩溃之前,主动拒绝一部分请求。

但问题来了:拒绝谁?

这里有两个核心原则:

  • 公平性:每个用户或每个IP应该获得相对公平的资源份额。不能因为一个用户疯狂刷请求,就把所有资源都占光了。
  • 优先级:在资源紧张时,要优先保障高价值用户或核心业务的请求。比如支付接口的限速阈值,肯定要比日志查询接口高得多。

我踩过的坑:
有一次,我对所有API接口用了同一个限速阈值。结果双十一大促时,商品详情页的请求量暴增,直接把支付接口的配额也吃掉了。用户下单失败,投诉电话打爆了。
从那以后,我坚持每个核心接口独立配置限速策略,并且给支付、下单这类关键链路预留了“应急配额”。

所以,限速的核心思想可以总结为三句话:

  1. 识别:区分出谁是正常用户,谁是攻击者(或者异常流量)。
  2. 限制:对异常流量进行压制,对正常流量进行保护。
  3. 反馈:让被限制的请求得到明确的响应(比如返回429状态码),而不是让客户端一直傻等超时。

接下来,我们看看实现限速的三种经典算法。每种算法都有它的脾气,选错了,效果会大打折扣。

令牌桶算法:我最常用的“温柔一刀”

令牌桶算法,是我个人在实战中最喜欢用的算法。
为什么?因为它够“温柔”。

它的工作原理是这样的:

  • 系统以固定的速率往一个“桶”里放令牌。比如每秒放10个。
  • 每个请求来的时候,必须从桶里拿走一个令牌,才能被处理。
  • 如果桶里的令牌用完了,新来的请求就会被拒绝或者排队等待。
  • 桶有一个最大容量。如果令牌放满了,新生成的令牌就会丢弃。

你发现没有?
令牌桶允许“突发流量”。

比如桶的容量是100,速率是每秒10个。如果系统空闲了10秒,桶里就攒了100个令牌。这时候突然来了一波100个请求的突发流量,令牌桶可以一口气全部放行。这很符合很多互联网业务的真实场景——平时请求不多,但搞活动或上热搜时,流量会瞬间暴涨。

实战建议:
令牌桶算法适合大多数Web API和微服务场景。我一般把桶容量设置为速率的2-3倍,这样既能应对突发流量,又不会让系统被冲垮。

下面是一个简单的令牌桶实现示例(伪代码):

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate          # 令牌生成速率(个/秒)
        self.capacity = capacity  # 桶的最大容量
        self.tokens = capacity    # 当前令牌数
        self.last_refill_time = time.now()

    def allow_request(self):
        # 先补充令牌
        now = time.now()
        elapsed = now - self.last_refill_time
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last_refill_time = now

        # 判断是否允许请求
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        else:
            return False

代码很简单,但有几个细节要注意:

  • 时间精度:如果时间精度太低,令牌补充会不准确。我建议用毫秒级的时间戳。
  • 并发安全:在高并发下,令牌的扣减和补充操作必须是原子性的。可以用锁,也可以用Redis的Lua脚本。

漏桶算法:滴水不漏的“铁闸门”

漏桶算法和令牌桶正好相反。
它像一个底部有漏洞的水桶。水(请求)以任意速率倒进桶里,但水只能以固定的速率从漏洞流出去。如果桶满了,新倒进来的水就会溢出(被拒绝)。

漏桶算法的核心特点是:强制平滑流量。

不管你的请求来得有多猛,漏桶都会把它们排队,然后以恒定的速率放行。这非常适合那些对流量平稳性要求极高的场景,比如数据库写入、消息队列生产等。

注意:
漏桶算法不擅长处理突发流量。如果桶的容量设置得太小,正常的突发请求也会被大量拒绝。我曾经在一个视频上传服务上用过漏桶,结果用户上传大文件时频繁失败,因为上传请求的并发量超过了桶的容量。

漏桶算法的实现也很直观:

class LeakyBucket:
    def __init__(self, rate, capacity):
        self.rate = rate          # 漏水速率(个/秒)
        self.capacity = capacity  # 桶的最大容量
        self.water = 0            # 当前水量(请求数)
        self.last_leak_time = time.now()

    def allow_request(self):
        # 先漏水
        now = time.now()
        elapsed = now - self.last_leak_time
        self.water = max(0, self.water - elapsed * self.rate)
        self.last_leak_time = now

        # 判断是否允许请求
        if self.water < self.capacity:
            self.water += 1
            return True
        else:
            return False

你看,代码结构和令牌桶很像,但逻辑完全相反。
令牌桶是先加令牌再扣减,漏桶是先漏水再加水。

计数器算法:简单粗暴的“土办法”

计数器算法,顾名思义,就是统计单位时间内的请求次数。
比如,限制1分钟内最多100个请求。超过100个,就拒绝。

它是最容易理解的算法,实现起来也最简单:

class CounterLimiter:
    def __init__(self, limit, window_size):
        self.limit = limit            # 窗口内允许的最大请求数
        self.window_size = window_size # 时间窗口大小(秒)
        self.counter = 0
        self.window_start = time.now()

    def allow_request(self):
        now = time.now()
        # 如果当前时间已经超出了当前窗口,重置计数器
        if now - self.window_start >= self.window_size:
            self.counter = 0
            self.window_start = now

        if self.counter < self.limit:
            self.counter += 1
            return True
        else:
            return False

但是,计数器算法有一个著名的缺陷:临界突变。

举个例子:
我限制1分钟100个请求。如果用户在第一个59秒内发了100个请求,然后在第60秒到第61秒之间又发了100个请求。因为第60秒重置了计数器,所以这200个请求实际上是在2秒内发完的,但计数器算法却认为它们分别在两个不同的窗口内,全部放行了。

这就是所谓的“突刺”现象。

我曾经踩过的坑:
早期做CC攻击防护时,我用计数器算法限制每个IP每秒10个请求。结果攻击者卡着每秒的边界,在每秒钟的最后0.1秒和下一秒的最开始0.1秒集中发送请求,瞬间打出了20 QPS的流量,直接绕过了我的限速。后来我改用滑动窗口计数器,才解决了这个问题。

滑动窗口计数器是对固定窗口计数器的改进。它把时间窗口切分成更小的粒度(比如把1分钟切成6个10秒的小窗口),然后统计最近N个小窗口的总请求数。这样就能更平滑地统计流量,避免临界突变。

三种算法对比:选哪个?

我把这三种算法的核心差异整理成了一张表,方便你对比:

特性 令牌桶 漏桶 计数器(固定窗口)
是否允许突发 允许(桶容量内) 不允许 允许(窗口边界有风险)
流量平滑度 中等 最高 最低
实现复杂度 中等 中等 简单
适用场景 Web API、微服务、网关 数据库写入、消息队列、日志收集 简单限流、IP黑名单、非关键路径
主要缺点 需要处理并发安全 无法应对突发流量 临界突变问题

我个人习惯的选型原则是:

  • 大部分对外API,用令牌桶。它最灵活,能兼顾突发和限速。
  • 对后端存储或中间件的写入操作,用漏桶。保护下游系统不被冲垮是第一位的。
  • 简单的IP级别限速或临时防护,用计数器。实现快,够用就行。

知识体系总览

为了让你更直观地理解这三种算法的关系,我画了一张图:

限速算法知识体系图 限速算法知识体系 限速核心思想 令牌桶算法 漏桶算法 计数器算法 特性:允许突发 适用:Web API、网关 特性:强制平滑 适用:数据库、消息队列 特性:简单粗暴 适用:IP限速、简单场景 选型建议: 对外API用令牌桶,保护后端用漏桶,临时防护用计数器

这张图把三种算法的定位和适用场景串在了一起。你可以把它当作一个速查表,在实际项目中快速决策。

我的一个小习惯:
在项目初期,我会先用计数器算法快速实现一个限速功能,保证系统能上线。等业务稳定了,再根据实际流量特征,逐步替换成令牌桶或漏桶。不要一开始就追求完美,先跑起来再说。

好了,限速的基础知识就聊到这里。
这三种算法是后续所有高级限速策略的基石。理解了它们,你就能看懂Nginx的limit_req模块、Guava的RateLimiter、以及各种网关的限速配置到底在干什么。