接口限流完全指南:从一次"活动一开服务就被瞬时流量冲垮"看懂限流算法

2021 年我维护一个电商下单服务,平时流量平稳,我从没为它能扛多少操过心。直到运营搞了一场大促,海报一推送出去,大量用户在同一分钟涌进来抢购,我盯着监控看着请求量像火箭一样窜起来,然后服务就崩了。不是变慢是直接崩:接口大面积超时,数据库连接被瞬间占满,实例被一个个判定不健康踢出负载均衡,被踢掉的实例那份流量又分摊到剩下的实例上,一场标准的雪崩。我第一反应是加机器,可扩容要时间等新实例起来高峰早过了。后来才想明白我的认知错得很彻底:任何服务的处理能力都受 CPU、内存、数据库连接、下游依赖层层制约有客观上限,流量一旦超过这个上限服务不会优雅地慢一点而是被直接拖垮乃至雪崩,而大促峰值可能是平时几十倍你不可能为几分钟峰值常备几十倍机器。真正的解法是限流:主动摸清服务承载上限,把超过上限的流量挡在门外,宁可让一部分请求快速失败也要保住服务整体不崩。本文从头梳理:为什么服务必须主动限流、固定窗口计数器为什么有窗口边界的临界问题会放双倍流量、滑动窗口怎么用时间戳让统计区间平滑移动、漏桶怎么把流量整形成恒定速率、令牌桶怎么靠桶容量在限速的同时允许合理突发、限流粒度怎么分全局和单用户单 IP、单机限流为什么在多实例下总量失控要用 Redis 加 Lua、被限流的请求为什么要返回 429 带 Retry-After,以及限流怎么和熔断降级配合组成稳定性三件套。核心一句:限流就是在焊死的承载常量和能暴涨几十倍的流量变量之间亲手装上的那个缓冲阀。

2021 年我维护一个电商的下单服务。平时流量平稳,服务跑得稳稳当当,我从没为"它能扛多少"操过心。直到有一次,运营搞了一场大促:活动海报一推送出去,大量用户在同一分钟涌进来抢购。我盯着监控,眼看着请求量曲线像火箭一样往上窜——然后服务就崩了。不是变慢,是直接崩:接口大面积超时,数据库连接被瞬间占满,健康检查开始失败,实例被一个个判定为不健康、踢出负载均衡;而被踢掉的实例,它那份流量又被分摊到剩下的实例上,让它们崩得更快——一场标准的雪崩。我第一反应是机器不够,让运维赶紧加机器,可机器扩容要时间,等新实例起来,活动高峰早过了,该崩的也崩完了。我当时的认知是:"服务能扛多少流量,是它本身的能力决定的,我不用特意去管;真扛不住,加机器就行。"后来复盘我才彻底想明白,这个认知错得很彻底。第一,任何服务的处理能力都是有限的——它受 CPU、内存、数据库连接数、下游依赖的能力层层制约,这个上限客观存在;第二,一旦瞬时流量超过这个上限,服务不会"优雅地慢一点",而是会被直接拖垮,甚至引发雪崩;第三,大促的峰值流量可能是平时的几十倍,你不可能为了那几分钟的峰值,常年养着几十倍的机器。真正的解法,不是被动地等服务被冲垮再加机器,而是主动地:先摸清服务的承载上限,然后把超过这个上限的流量挡在门外——宁可让一部分请求快速失败、收到一句"系统繁忙请稍后再试",也要保住服务整体不崩。这就是限流。被限流拒掉的请求,用户损失的是"这一次没抢到";而不限流导致雪崩,损失的是"整个服务对所有人都不可用"。我以为限流不过是"加个计数器,超过数量就拒绝",结果真做下来坑一个接一个:计数器有临界问题、流量不均匀、单机限流在多实例下失效……那次之后我才认真把限流从头搞明白。这篇文章就把它梳理一遍:为什么需要限流、计数器为什么不靠谱、滑动窗口、漏桶和令牌桶这几种算法分别怎么转,以及把限流真正做进生产要避开的那些坑。

问题背景

先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:一个下单服务,平时流量平稳。一场大促带来瞬时流量洪峰,请求量在一分钟内暴涨到平时的几十倍,服务被直接冲垮——接口大面积超时、数据库连接耗尽、实例被踢出负载均衡,流量分摊到剩余实例后引发连锁雪崩。

我当时的错误认知:"服务能扛多少是它自己的事,我不用管;扛不住就加机器。"

真相:任何服务的处理能力都有一个客观上限(受 CPU、内存、数据库连接、下游依赖制约)。流量一旦超过这个上限,服务不是"慢一点",而是被拖垮乃至雪崩。加机器有扩容延迟,也不可能为几分钟的峰值常备几十倍机器。正确的做法是限流:主动测出服务的安全承载量,把超过这个量的流量挡在门外,让超额请求快速失败而不是拖垮整个服务。限流的本质是一个取舍——牺牲一部分请求,换整个服务的存活。

要把限流做好,需要几块认知:

  • 为什么服务必须主动限流,不限流的代价是什么;
  • 为什么"固定窗口计数器"这种最朴素的限流有缺陷;
  • 滑动窗口、漏桶、令牌桶这几种算法各自怎么转、适合什么场景;
  • 限流粒度怎么定(全局 / 单用户 / 单 IP);
  • 分布式限流、限流后的响应、限流与降级熔断的配合这些工程坑怎么处理。

一、为什么需要限流:服务的承载能力是有限的

先把这件最根本的事钉死:你的服务,有一个它能稳定处理的流量上限

这个上限不是凭感觉,它是被一系列硬资源卡死的:CPU 核数、内存大小、数据库连接池的容量、下游服务能给你的配额。这些资源每一项都是有限的,它们当中最紧的那一个,就决定了你服务的天花板。流量在天花板以下,服务稳定;流量一旦顶破天花板,情况会迅速恶化——请求开始排队,排队导致响应变慢,变慢导致连接迟迟不释放、占用进一步堆积,堆积又让新请求等得更久……这是一个正反馈的恶性循环,它的终点就是服务彻底失去响应。

限流要做的,就是在流量到达天花板之前,主动地把它截断。最朴素的限流思路是固定窗口计数器:把时间切成一个个固定的窗口(比如每 1 秒一个),每个窗口里设一个计数,请求进来就加一,超过阈值就拒绝,窗口一到就清零重来。

import time


class FixedWindowLimiter:
    """固定窗口计数器:每个时间窗口内最多放行 limit 个请求。"""

    def __init__(self, limit: int, window_sec: float = 1.0):
        self._limit = limit
        self._window = window_sec
        self._count = 0
        self._window_start = time.time()

    def allow(self) -> bool:
        now = time.time()
        # 时间过了一个窗口,计数清零、重开一个窗口
        if now - self._window_start >= self._window:
            self._window_start = now
            self._count = 0
        if self._count < self._limit:
            self._count += 1
            return True
        return False                  # 本窗口已满,拒绝

这个计数器,实现起来确实简单,也确实能拦住一部分流量。但它有一个不容易一眼看出的致命缺陷——下一节我们专门看这个缺陷,因为正是它,把后面那几种更精巧的算法逼了出来。

二、固定窗口的临界问题:窗口边界上的双倍流量

固定窗口计数器的缺陷,出在窗口与窗口的交界处

设想限流是"每秒最多 100 个请求"。现在有这样一串时序:在第 1 个窗口的最后 0.5 秒,涌进来 100 个请求——没超,全部放行;紧接着窗口切换,计数清零;在第 2 个窗口的最前 0.5 秒,又涌进来 100 个请求——计数从 0 开始,也没超,也全部放行。可你回过头看这跨越窗口边界的 1 秒钟(前一窗口的后半段 + 后一窗口的前半段),实际放行了 200 个请求——整整是你设定的 100 的两倍

这就是固定窗口的临界问题:它只保证"每个窗口内不超限",但不保证"任意一个连续的 1 秒内不超限"。而打垮服务的,恰恰是"任意连续 1 秒内的流量"。攻击者甚至能精准地卡着边界打,让你的限流形同虚设。问题的根源是:固定窗口的时间边界是"跳变"的——计数到点"啪"地清零,清零的那一刻,服务对刚刚过去的流量完全失忆了。

要修掉它,就得让限流的统计窗口跟着当前时刻平滑地移动,而不是一格一格地跳。这个窗口任何时刻都只统计"从现在往前数 1 秒"这个区间——它没有"边界",自然也就没有"边界上的失忆"。这就是滑动窗口

三、滑动窗口:让统计区间跟着当前时刻平滑移动

滑动窗口的实现思路很直接:不再用一个笼统的计数,而是记下每一个请求发生的精确时间戳。判断一个新请求要不要放行时,先把"当前时刻往前 1 秒"之外的旧时间戳全部淘汰掉,再看剩下的(也就是真正落在最近 1 秒内的)请求数有没有超限。

import collections
import time


class SlidingWindowLimiter:
    """滑动窗口:记录每个请求的时间戳,只统计最近 window 秒内的。"""

    def __init__(self, limit: int, window_sec: float = 1.0):
        self._limit = limit
        self._window = window_sec
        self._hits = collections.deque()     # 存放近期请求的时间戳

    def allow(self) -> bool:
        now = time.time()
        # 把"当前时刻往前 window 秒"之外的旧时间戳全部清掉
        while self._hits and now - self._hits[0] >= self._window:
            self._hits.popleft()
        # 剩下的就是真正落在最近 window 秒内的请求
        if len(self._hits) < self._limit:
            self._hits.append(now)
            return True
        return False

滑动窗口彻底解决了临界问题——因为它的统计区间永远是"此刻往前 1 秒",这个区间随着时间连续地滑动,不存在"清零"那个瞬间,也就不存在"边界失忆"。任何一个连续的 1 秒,它都数得准。

但滑动窗口也有它的代价。其一,它要存下每个请求的时间戳,流量大时这个 deque 会很长,占内存。其二,也是更关键的——滑动窗口管的是"数量",它管不了"节奏"。它允许"最近 1 秒来了 100 个"放行,但这 100 个,可能是在某 10 毫秒内挤成一团瞬间砸进来的。对你的服务来说,"1 秒均匀来 100 个"和"10 毫秒内砸进 100 个再静默 990 毫秒",压力天差地别。要管住流量的节奏、把它"整形"成平滑的,得换一种思路——漏桶。

四、漏桶算法:把流量整形成恒定的速率

漏桶(Leaky Bucket)算法,换了一个看问题的角度:它不直接数"窗口里有几个请求",而是把请求想象成水。所有进来的请求,先倒进一个桶里;桶底有一个洞,水以一个恒定的速率往外漏——"漏出去"就代表"被处理"。如果水进得太快、桶满了,再进来的水就溢出,溢出就是被拒绝。

import time


class LeakyBucket:
    """漏桶:请求像水一样进桶,以恒定速率漏出;桶满则拒绝。"""

    def __init__(self, capacity: float, leak_rate: float):
        self._capacity = capacity        # 桶容量
        self._leak_rate = leak_rate      # 每秒漏出(处理)多少个
        self._water = 0.0                # 当前桶里的水量
        self._last = time.time()

    def allow(self) -> bool:
        now = time.time()
        # 先按流逝的时间,算出这段时间漏掉了多少水
        leaked = (now - self._last) * self._leak_rate
        self._water = max(0.0, self._water - leaked)
        self._last = now
        # 再看这个请求(一滴水)加进来,会不会让桶溢出
        if self._water + 1 <= self._capacity:
            self._water += 1
            return True
        return False                     # 桶满了,溢出 = 拒绝

漏桶最大的特点是:无论进水多么忽快忽慢,出水的速率永远是恒定的。它把一股忽高忽低、毛刺密布的流量,"整形"成了一股平稳的细流交给下游。这对那些处理能力恒定、最怕流量毛刺的下游(比如一个第三方接口、一个老旧系统)非常友好。

但漏桶的这个优点,反过来也是它的缺点:它太"死板"了。出水速率恒定,意味着它完全不允许突发——哪怕你的服务此刻明明很空闲、完全有能力一下子多处理一些请求,漏桶也只肯按那个恒定速率慢慢放。可现实中,很多服务是允许、甚至欢迎合理突发的:平时攒下的处理余力,就该用来消化偶尔的小高峰。要做到"平时限速、但允许动用余力来应对突发",得换最后一种、也是用得最多的一种算法——令牌桶。

五、令牌桶算法:限速的同时允许合理突发

令牌桶(Token Bucket)把漏桶的思路反了过来。漏桶限制的是"请求出去的速率";令牌桶限制的是"令牌进来的速率"。具体是这样:有一个桶,系统以一个恒定的速率往桶里放令牌;每个请求想被放行,必须先从桶里拿到一个令牌;拿不到令牌,就被拒绝。

import time


class TokenBucket:
    """令牌桶:以恒定速率放令牌,请求拿到令牌才放行。"""

    def __init__(self, capacity: float, refill_rate: float):
        self._capacity = capacity          # 桶最多装多少令牌
        self._refill_rate = refill_rate    # 每秒补充多少令牌
        self._tokens = float(capacity)     # 起始时桶是满的
        self._last = time.time()

    def allow(self, need: int = 1) -> bool:
        now = time.time()
        # 按流逝时间补令牌,但桶里令牌数不能超过 capacity
        self._tokens = min(
            self._capacity,
            self._tokens + (now - self._last) * self._refill_rate,
        )
        self._last = now
        if self._tokens >= need:
            self._tokens -= need
            return True                    # 拿到令牌,放行
        return False                       # 没令牌,拒绝

令牌桶的精妙,全在那个桶容量上。系统空闲时没什么请求,令牌就在桶里慢慢攒着,一直攒到桶满。这时如果突然来一波小高峰,这批请求可以一次性把攒下的令牌都取走——于是它们被瞬间放行了。这就是令牌桶允许突发的来源:桶容量,就是它允许的最大突发量。而一旦攒下的令牌被取光,后续请求就只能按令牌补充的速率慢慢被放行了——这又保证了长期平均速率不超限。"平时攒余量,突发时释放,长期不超速",这正是大多数业务想要的限流形态,也是令牌桶成为最常用限流算法的原因。把限流器套到接口上,通常用一个装饰器:

import functools


class TooManyRequests(Exception):
    """请求超出限流阈值时抛出。"""


def rate_limited(limiter):
    """把任意限流器包成装饰器,套在接口函数上。"""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if not limiter.allow():
                raise TooManyRequests("请求太频繁,请稍后再试")
            return func(*args, **kwargs)
        return wrapper
    return decorator


# 用起来:这个接口每秒补 50 个令牌,最多允许 100 个的突发
_bucket = TokenBucket(capacity=100, refill_rate=50)


@rate_limited(_bucket)
def create_order(user_id: int):
    return do_create_order(user_id)

六、工程坑:限流粒度、分布式限流、限流响应

算法都有了,但要把限流真正做进生产,还有几个绕不开的工程坑。

坑 1:限流粒度要分清——全局、单用户、单 IP。"每秒 1000 个请求"这个限流,是对的?如果是全局的一个总闸,那么一个写脚本疯狂刷接口的恶意用户,就能一个人占满这 1000 的配额,把所有正常用户全挡在门外。所以限流通常要分层:既有保护整个服务的全局限流,也有针对单个用户 / 单个 IP 的限流,防止任何单一来源过度占用资源。

# 按用户维度限流:每个用户一个独立的令牌桶。
_user_buckets: dict[str, TokenBucket] = {}


def allow_for_user(user_id: str) -> bool:
    """单用户限流:防止任何一个用户刷爆接口、挤占别人的配额。"""
    bucket = _user_buckets.get(user_id)
    if bucket is None:
        # 每个用户:每秒补 5 个令牌,最多允许攒 10 个的突发
        bucket = TokenBucket(capacity=10, refill_rate=5)
        _user_buckets[user_id] = bucket
    return bucket.allow()

坑 2:单机限流在多实例部署下会失效。上面所有限流器,计数都存在进程内存里。如果你的服务部署了 10 个实例,每个实例配"每秒 1000",那整个服务实际放行的是 10 × 1000 = 10000——单机限流器互相看不见,合起来的总量完全失控。要做全局限流,必须把计数放到一个所有实例共享的外部存储里,通常是 Redis,并且用 Lua 脚本保证"读计数、判断、加一"是原子的:

import redis

r = redis.Redis()

# Lua 脚本:在 Redis 服务端【原子地】完成 计数+1 -> 判断是否超限。
_LIMIT_SCRIPT = """
local current = redis.call('incr', KEYS[1])
if current == 1 then
    redis.call('expire', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
    return 0
end
return 1
"""


def allow_distributed(key: str, limit: int, window_sec: int) -> bool:
    """分布式限流:所有实例共用 Redis 里的同一个计数。"""
    ok = r.eval(_LIMIT_SCRIPT, 1, key, limit, window_sec)
    return ok == 1

坑 3:被限流拒绝的请求,要给出"得体"的响应。限流拒掉一个请求,不能简单地抛个 500 错误了事——那会让客户端以为是服务出 bug 了。规范的做法是返回 HTTP 429(Too Many Requests)状态码,并在响应头里带上 Retry-After,明确告诉客户端"过多久之后可以再试"。这样客户端就能体面地退避重试,而不是立刻又发一个请求过来火上浇油。

from flask import Flask, jsonify

app = Flask(__name__)
_api_bucket = TokenBucket(capacity=200, refill_rate=100)


@app.route("/api/order", methods=["POST"])
def create_order_api():
    if not _api_bucket.allow():
        resp = jsonify({"error": "请求过于频繁,请稍后再试"})
        resp.status_code = 429                  # 标准的"请求过多"状态码
        resp.headers["Retry-After"] = "1"       # 告诉客户端 1 秒后再来
        return resp
    return jsonify({"status": "ok"})

坑 4:限流不是孤军作战,要和降级、熔断配合。限流解决的是"入口流量太大",但服务出问题不只这一种原因。如果是某个下游依赖挂了或变慢,该用的是熔断——快速失败,不再把请求往那个坏掉的下游送。如果是系统整体压力大,该考虑降级——临时关掉一些非核心功能(比如推荐、积分),把资源让给核心链路(下单、支付)。限流、熔断、降级是服务稳定性的三件套,各管一段、配合使用。下面这张图,把一个请求经过限流判断的完整路径串起来:

关键概念速查

概念 / 手段 说明
限流 主动把超过服务承载上限的流量挡在门外,牺牲部分请求保整体存活
固定窗口计数器 每个时间窗口内计数,实现最简单,但有窗口边界的临界问题
临界问题 固定窗口边界两侧各打满,跨边界的连续 1 秒会放行双倍流量
滑动窗口 记录每个请求时间戳,统计区间随当前时刻平滑移动,解决临界问题
漏桶算法 请求进桶,以恒定速率漏出,把流量整形成平稳细流,但不允许突发
令牌桶算法 恒定速率放令牌,请求取令牌才放行,桶容量即允许的突发量,最常用
限流粒度 分全局、单用户、单 IP 多层,防止单一来源占满全局配额
分布式限流 单机限流器多实例下总量失控,要把计数放 Redis 并用 Lua 保证原子
429 响应 被限流的请求返回 Too Many Requests 状态码,带 Retry-After 引导退避
限流熔断降级 限流管入口流量,熔断管坏掉的下游,降级让资源给核心链路,三者配合

避坑清单

  1. 任何服务的处理能力都有客观上限,流量超过上限不是变慢而是被拖垮乃至雪崩,必须主动限流。
  2. 限流的本质是取舍:牺牲一部分请求快速失败,换整个服务存活;被拒优于全员不可用。
  3. 固定窗口计数器实现简单但有临界问题:窗口边界两侧各打满,跨边界连续 1 秒会放行双倍流量。
  4. 滑动窗口用请求时间戳让统计区间平滑移动,解决临界问题,但要存时间戳占内存,且管不住节奏。
  5. 漏桶以恒定速率漏出请求,把流量整形成平稳细流,适合最怕毛刺的下游,但完全不允许突发。
  6. 令牌桶恒定速率放令牌,桶容量就是允许的突发量,既能限长期均速又允许合理突发,最常用。
  7. 限流粒度要分层:既有保护整个服务的全局限流,也要有单用户、单 IP 限流防单一来源刷爆。
  8. 单机限流器把计数存在进程内存,多实例部署下总量失控;全局限流要用 Redis + Lua 原子计数。
  9. 被限流拒绝的请求要返回 HTTP 429 并带 Retry-After,引导客户端体面退避而非立刻重试加压。
  10. 限流只管入口流量,要和管下游故障的熔断、把资源让给核心链路的降级配合,组成稳定性三件套。

总结

回头看那次"活动一开服务就被冲垮"的雪崩,以及我后来在限流上接连踩的坑,最该记住的不是某一种限流算法,而是我动手前那个想当然的判断——"服务能扛多少是它自己的事,我不用管"。这句话错在它把服务的承载能力当成了一个不用操心的、会自动伸缩的东西。可现实是,服务的承载能力是一个被硬资源死死焊住的常量;而流量,尤其是大促、热点、被刷时的流量,是一个可以瞬间暴涨几十倍的变量。一个焊死的常量,去硬接一个能暴涨几十倍的变量,中间没有任何缓冲,结局只能是被冲垮。限流,就是在这个常量和变量之间,亲手装上的那个缓冲阀。

所以做限流,真正的工程量不在"写一个计数器"那一下。一个 if count > limit 谁都会写,它在 Demo 里、在平稳流量下也确实拦得住。真正的工程量在那些魔鬼细节里:你的限流窗口,在边界上会不会漏掉双倍流量?你限的是流量的"数量",还是连"节奏"也管住了?你要不要给业务留一点突发的余地?你的服务多实例部署后,单机的计数器还算数吗?被你拒掉的那个用户,收到的是一句得体的"稍后再试",还是一个莫名其妙的 500?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚服务为什么必须限流,再看固定窗口的临界缺陷,然后是滑动窗口、漏桶、令牌桶三种算法各自的取舍,最后是限流粒度、分布式限流、限流响应这几个把限流真正做进生产的工程细节。

你会发现,限流的思路和我们生活里处理任何"容量有限的入口"的经验都是相通的。一座桥有最大承重,所以桥头会限行;一个景区有最佳接待量,所以会限制每天的售票数;一部电梯有载重上限,所以超载会响铃不走。它们做的是同一件事:承认容量有限,然后在入口处主动管控,绝不允许涌入的量超过里面能安全承载的量。你的服务也是这样一个"容量有限的入口"。承认它有上限,不是示弱,而是负责——一个会限流的服务,是在用"拒绝一部分"这个小代价,守住"对所有人可用"这个底线。

最后想说,限流做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你手动发几个请求,流量平稳得像一条直线,有没有限流跑起来一模一样。它只在真实的流量洪峰面前才显形:一场没预告的大促、一个突然爆红的热点、一个写错了重试逻辑的客户端、一个恶意刷接口的脚本。那时候它会用最难堪的方式给你结账——服务在最该顶住的那几分钟里整个崩掉,数据库连接耗尽,实例雪崩式地一个个倒下,而你只能眼睁睁看着监控曲线、等扩容的机器慢慢起来。所以别等雪崩真的发生再去补限流,在你写下第一个对外接口的时候就该想清楚:这个接口,我的服务每秒最多能稳稳处理多少?超过了我拦得住吗?我拦的是全局还是每个用户?多实例之后我的限流还算数吗?被我拦下的用户,我好好回应他了吗?这几个问题都有了答案,你的服务才不只是 Demo 里那个流量平稳时跑得通的样子,而是一个无论外面是细水长流还是惊涛骇浪,自己始终稳稳运行的可靠系统。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 完全指南:从一次"知识库问答把整个文档塞进 prompt、token 直接爆了"看懂检索增强生成

2026-5-21 19:41:32

技术教程

大模型流式输出完全指南:从一次"用户问完干等十几秒才看到回答"看懂 SSE 流式响应

2026-5-21 19:51:40

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索