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 秒会放行双倍流量。
- 滑动窗口用请求时间戳让统计区间平滑移动,解决临界问题,但要存时间戳占内存,且管不住节奏。
- 漏桶以恒定速率漏出请求,把流量整形成平稳细流,适合最怕毛刺的下游,但完全不允许突发。
- 令牌桶恒定速率放令牌,桶容量就是允许的突发量,既能限长期均速又允许合理突发,最常用。
- 限流粒度要分层:既有保护整个服务的全局限流,也要有单用户、单 IP 限流防单一来源刷爆。
- 单机限流器把计数存在进程内存,多实例部署下总量失控;全局限流要用 Redis + Lua 原子计数。
- 被限流拒绝的请求要返回 HTTP 429 并带 Retry-After,引导客户端体面退避而非立刻重试加压。
- 限流只管入口流量,要和管下游故障的熔断、把资源让给核心链路的降级配合,组成稳定性三件套。
总结
回头看那次"活动一开服务就被冲垮"的雪崩,以及我后来在限流上接连踩的坑,最该记住的不是某一种限流算法,而是我动手前那个想当然的判断——"服务能扛多少是它自己的事,我不用管"。这句话错在它把服务的承载能力当成了一个不用操心的、会自动伸缩的东西。可现实是,服务的承载能力是一个被硬资源死死焊住的常量;而流量,尤其是大促、热点、被刷时的流量,是一个可以瞬间暴涨几十倍的变量。一个焊死的常量,去硬接一个能暴涨几十倍的变量,中间没有任何缓冲,结局只能是被冲垮。限流,就是在这个常量和变量之间,亲手装上的那个缓冲阀。
所以做限流,真正的工程量不在"写一个计数器"那一下。一个 if count > limit 谁都会写,它在 Demo 里、在平稳流量下也确实拦得住。真正的工程量在那些魔鬼细节里:你的限流窗口,在边界上会不会漏掉双倍流量?你限的是流量的"数量",还是连"节奏"也管住了?你要不要给业务留一点突发的余地?你的服务多实例部署后,单机的计数器还算数吗?被你拒掉的那个用户,收到的是一句得体的"稍后再试",还是一个莫名其妙的 500?这篇文章的几节,其实就是顺着这条思路展开的:先想清楚服务为什么必须限流,再看固定窗口的临界缺陷,然后是滑动窗口、漏桶、令牌桶三种算法各自的取舍,最后是限流粒度、分布式限流、限流响应这几个把限流真正做进生产的工程细节。
你会发现,限流的思路和我们生活里处理任何"容量有限的入口"的经验都是相通的。一座桥有最大承重,所以桥头会限行;一个景区有最佳接待量,所以会限制每天的售票数;一部电梯有载重上限,所以超载会响铃不走。它们做的是同一件事:承认容量有限,然后在入口处主动管控,绝不允许涌入的量超过里面能安全承载的量。你的服务也是这样一个"容量有限的入口"。承认它有上限,不是示弱,而是负责——一个会限流的服务,是在用"拒绝一部分"这个小代价,守住"对所有人可用"这个底线。
最后想说,限流做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你手动发几个请求,流量平稳得像一条直线,有没有限流跑起来一模一样。它只在真实的流量洪峰面前才显形:一场没预告的大促、一个突然爆红的热点、一个写错了重试逻辑的客户端、一个恶意刷接口的脚本。那时候它会用最难堪的方式给你结账——服务在最该顶住的那几分钟里整个崩掉,数据库连接耗尽,实例雪崩式地一个个倒下,而你只能眼睁睁看着监控曲线、等扩容的机器慢慢起来。所以别等雪崩真的发生再去补限流,在你写下第一个对外接口的时候就该想清楚:这个接口,我的服务每秒最多能稳稳处理多少?超过了我拦得住吗?我拦的是全局还是每个用户?多实例之后我的限流还算数吗?被我拦下的用户,我好好回应他了吗?这几个问题都有了答案,你的服务才不只是 Demo 里那个流量平稳时跑得通的样子,而是一个无论外面是细水长流还是惊涛骇浪,自己始终稳稳运行的可靠系统。
—— 别看了 · 2026