APISIX限流插件在某些时候会莫名其妙地让请求漏过去,平时看不出来,流量一暴增,后端就扛不住了,直接雪崩。这事儿挺要命的,因为限流本身就是为了保护系统,结果它自己先出了问题。到底是算法本身有坑,还是实现上埋了雷?咱们就从漏桶算法的实现细节扒一扒,看看到底哪儿容易出漏洞。

一、限流为啥会跟雪崩扯上关系

你想啊,一个系统平时跑得好好的,突然涌进来几倍于平时的流量,如果什么都不干,数据库、微服务立马被压垮,然后请求排队,越积越多,导致整个系统响应越来越慢,最后彻底瘫掉。这就是雪崩。限流的作用就是提前拦一下,让进到后端的请求数量控制在它能处理的范围以内。但要是限流插件自己没拦住该拦的请求,那跟没装一样,雪崩照样发生。

所以限流插件不允许犯这种错误。但现实是,很多生产事故查到最后,发现限流的配置或者实现有漏洞,导致关键时刻掉了链子。下面咱们就拿APISIX的limit-req插件开刀,看看它用的漏桶算法到底有什么隐患。

二、APISIX限流插件是怎么个原理

APISIX的limit-req插件用的是漏桶算法,它的核心思路很简单:准备一个桶,请求进来先扔桶里,然后按照固定的速率从桶底往外流。如果桶满了,新来的请求直接拒绝。只要桶的大小(burst)设置得当,就能把突发的流量削平,变成均匀的平稳流量。

2.1 漏桶算法的几个关键参数

  • rate:流量速率,比如每秒允许处理50个请求。
  • burst:桶的容量,也就是允许暂存在桶里的最大请求数量。当瞬间流量超过rate时,多出来的请求可以先存在桶里,等后面慢慢消耗。

举个例子:rate=50,burst=20。平时每秒只进50个请求,桶是空的。如果忽然一秒钟来了80个,那么前50个正常通过,多出来的30个里有20个能暂存在桶里(因为burst=20),剩下的10个直接拒绝。桶里的20个会在接下来的时间里以每秒50个的速度被处理,这就叫削峰填谷。

听起来挺完美的,对吧?但实现细节稍微差一点,结果可能就天差地别。下面咱们用代码模拟一下,看看不同实现会有什么问题。

2.2 用Python模拟一个漏桶算法(带上注释)

我们先用Python写一个标准的漏桶算法,方便看边界情况。

import time

class LeakyBucket:
    """
    漏桶限流器
    rate: 每秒处理速率
    burst: 桶容量(最大暂存数)
    """
    def __init__(self, rate: float, burst: int):
        self.rate = rate
        self.burst = burst
        self.tokens = 0.0        # 当前桶里的令牌数量(实际表示可暂存的请求数)
        self.last_time = time.time()  # 上次更新时间

    def allow_request(self) -> bool:
        now = time.time()
        # 计算从上次到现在流出了多少令牌
        elapsed = now - self.last_time
        # 流出的令牌数 = 时间差 * 速率
        leaked = elapsed * self.rate
        # 更新桶内令牌,但不能超过burst
        self.tokens = max(0, self.tokens - leaked)
        self.last_time = now

        # 如果桶内令牌还有空位,可以存下新请求
        if self.tokens < self.burst:
            self.tokens += 1  # 新请求占用一个令牌空间
            return True
        else:
            return False  # 桶满了,拒绝

# 测试:rate=5,burst=2,模拟连续10个请求
bucket = LeakyBucket(rate=5, burst=2)
for i in range(10):
    allowed = bucket.allow_request()
    print(f"请求{i+1}: {'通过' if allowed else '拒绝'}")
    time.sleep(0.01)  # 每隔10ms发一个请求

如果运行上面这段,结果会是前几个通过,然后因为burst只有2,最后一个拒绝。但这里面有一个细微的漏洞:如果两个请求的时间间隔非常短,比如小于1微秒,elapsed几乎为0,leaked也几乎是0,桶内令牌几乎没减少,那么第二个请求会直接消耗一个新令牌,可能造成短时间内连续通过的请求数超过burst。不过因为tokens是浮点数,而且max(0, tokens-leaked)确保了合理更新,所以一般不会出大问题。

但是,很多实际的限流插件为了性能,会用整数运算代替浮点数,这就可能带来精度损失,导致实际通过的请求数超过配置值。

三、隐藏在整数运算中的漏洞

3.1 整数除法丢精度,流量悄悄超标

假设我们用整型变量存储令牌数,速率也用整型表示(比如每秒钟处理100个),那么时差elapsed也必须换算成整数毫秒或微秒。如果速率不是整数,或者时间粒度太粗,计算出来的流出令牌数就会被截断。

举个例子:rate=1.5(每秒1.5个),burst=3。我们用毫秒做时间单位,每毫秒应流出 1.5/1000=0.0015 个,但如果采用整数计算,很可能直接取整为0,导致几毫秒内桶内令牌不减,而新请求不断进来,桶一下子就满了,但这还不是最要命的。

最要命的情况是:时间差累积到一定程度,应该流出比如2.5个,但整数运算只减去2,剩下0.5的差值留在桶里。日积月累,桶里会多出不少容量,等于无形中放大了burst。当突发流量来的时候,插件就会放行超出预期的请求量。

3.2 模拟整数运算带来的“虚增”效果

class LeakyBucketInteger:
    """
    使用整数毫秒和整数除法模拟的漏桶(有精度损失)
    """
    def __init__(self, rate_per_sec: float, burst_ms: int):
        self.rate = rate_per_sec  # 每秒速率,不一定是整数
        self.burst = burst_ms     # 桶容量,这里用毫秒表示可存的请求数
        self.tokens = 0
        self.last_ms = int(time.time() * 1000)

    def allow_request(self) -> bool:
        now_ms = int(time.time() * 1000)
        elapsed_ms = now_ms - self.last_ms
        # 整数除法!实际流出的令牌可能是小数,但被截断
        leaked = int(elapsed_ms * self.rate / 1000)  # 注意:单位换算后取整
        self.tokens = max(0, self.tokens - leaked)
        self.last_ms = now_ms

        if self.tokens < self.burst:
            self.tokens += 1
            return True
        return False

# 测试:rate=3.3,burst=5,连续发20个请求,间隔5ms
bucket = LeakyBucketInteger(rate_per_sec=3.3, burst_ms=5)
passed = 0
for i in range(20):
    if bucket.allow_request():
        passed += 1
    time.sleep(0.005)  # 5ms间隔
print(f"实际通过的请求数:{passed}")

肉眼可见,因为整数截断,每次计算出的leaked都比实际应流出的少一点点,导致桶内令牌积累得比预期慢,最后通过的请求数可能超过理论值(3.320/10001000?其实理论每秒3.3个,20个请求间隔5ms,总时间100ms,理论最多通过大约0.33个?不对,我们先看结果)。

这里不纠结具体数字,重点是:整数运算带来的累积误差,让限流器悄悄多放行了请求。在多轮、高并发的场景下,这个误差会不断累加,最后可能在流量高峰时让后端多承受百分之几甚至更多的压力,成为雪崩的导火索。

四、APISIX中的具体实现有没有踩这个坑

APISIX的limit-req插件基于Lua的resty.limit.req库,它使用的是“基于时间差的漏桶算法”,与上面的整数版类似,但它用的是微秒级时间(ngx.now()返回的是秒+微秒的浮点数),并且用math.floor做向下取整。微秒级的精度确实很高,但是当速率非常巨大或非常微小时,浮点数精度本身也可能带来轻微偏差。

更关键的是,该库在计算流出令牌数时用的是 leaked = math.floor(elapsed * rate / 1000),其中elapsed是毫秒。如果rate是像1.5这样的非整数,elapsed * rate也是一个浮点数,math.floor会截断小数部分,每次损失一点点。当请求间隔非常均匀时,这个误差会周期性出现,导致实际通过的请求数量略高于设定值。

不过这个误差通常很小,真正的“大坑”往往在配置层面。

五、配置上的“漏掉”才是雪崩元凶

5.1 burst设置过大,限流形同虚设

很多人在配置limit-req时,怕正常业务被限流,就把burst设得特别大,比如rate=50,burst=1000。这意味着桶可以存1000个请求,即使瞬间来了1000个请求,也全放行。而后面这些请求只能以每秒50个的速度消耗,相当于在后端维护一个巨大的积压队列。如果后端的处理能力就是50/s,这1000个请求会一直排队,内存越堆越高,最终把系统拖垮。而且在高并发下,这1000个请求还没处理完,下一波又来了,雪崩直接上演。

这种场景下,限流插件并没有漏掉请求——它确实按规则放行,但规则本身就是错误的。所以从结果看,它“漏掉”了本应保护后端的意图。

5.2 分布式部署下,各节点独立限流

APISIX通常以多节点集群方式部署,每个节点上的limit-req插件只统计自己的流量。如果每个节点都设置rate=100,burst=200,那么当流量分布到3个节点时,后端实际承受的速率可能是300/s,而插件以为只有100/s。后端就被超额的流量打垮了。

这是典型的“分布式限流缺少协调”问题。很多团队会忽略这个,以为在单个节点上配置好了就行。结果流量一分散,限流效果大打折扣。

六、应用场景、优缺点和注意事项

应用场景

  • 防止突发流量冲击:比如秒杀、瞬时热点,漏桶能把毛刺削平。
  • 保护下游弱依赖:内部服务调用、数据库连接池。
  • 配合熔断降级:限流是第一道防线,熔断是最后一道。

技术优缺点

  • 优点:实现简单稳定,能强制平滑流量,适合需要严格均匀速率的场景(比如出口带宽限制)。
  • 缺点:对突发流量不够灵活,即使桶有空余,也严格按照rate消耗,响应延迟会变高。令牌桶算法更灵活,允许一定程度的突发。
  • APISIX的limit-req实现精度高(微秒级),但整数取整仍有微小误差,大多数业务可接受。

注意事项

  1. burst不要设置过大:最好等于rate,或者略大于后端的单次峰值容量,否则等于没限流。
  2. 分布式限流必须加中央协调:可以用Redis集中计数,APISIX也有limit-count插件能做分布式。
  3. 警惕整数截断的累积误差:如果速率设置是小数、而且突发流量持续时间长,建议用高精度计算(如用浮点数避免多次取整),或者适当降低rate留出余量。
  4. 监控限流效果:如果经常有请求被拒绝,说明容量不够;如果几乎没有拒绝,说明配置太松,要检查burst和rate。

文章总结

APISIX的限流插件本身是可靠的,但它的“漏洞”往往出在两个方面:一是实现层面的整数截断微误差,在极端情况下可能让实际通过的请求略超预期;二是配置层面的burst滥用和分布式不协调,这才是导致系统雪崩的主要原因。要真正防御雪崩,除了理解漏桶算法细节,更重要的是合理设定参数、做好分布式限流、持续监控流量变化。限流不是银弹,但用对了,就是一个可靠的护盾。