asyncio 手写令牌桶限流器:一次接口被刷 30 万次后对 FastAPI 的重构

2026-10-29 0 389

有个做图片识别的朋友上个月找我帮忙看日志。他们一个 /v1/ocr 接口,同一天里同一个 IP 打了三十多万次,平均下来每秒四次,但峰值那几分钟能到两百多。服务本身没挂,问题是 GPU 队列被堵死了,正常用户的请求全在排队,客诉一堆。

他第一反应是上 Nginx 的 limit_req。我让他先别急,问了一句:你们的额度是按什么算的?他说免费用户每天 100 次,专业版每分钟 600 次,企业版不封顶但要求 QPS 不超过 50。这种按用户身份、按不同时间单位、不同口径的额度,Nginx 的 limit_req_zone 配不出来。所以这事只能落到应用层。

于是就有了下面这一摊。我把它完整写出来,包括中间写错的版本。

固定窗口的边界问题,我用一段代码复现了

最早他们自己写过一个最简单的:Redis 里存一个 INCR 计数器,key 是 rate:uid:当前分钟,超了 600 就拒绝。这就是标准的固定窗口。

我把这个逻辑抽出来单独跑了一下,问题立刻暴露。假设额度是每分钟 100 次,窗口在整分时切换。那么一个客户端在 12:00:59 打 100 次,窗口刷新,12:01:00 再打 100 次。两秒之内 200 个请求全部合法通过,是额度的两倍。

# 固定窗口的边界问题,用 asyncio 模拟一下
import asyncio, time

class FixedWindow:
    def __init__(self, limit: int, window: int = 60):
        self.limit = limit
        self.window = window
        self.count = 0
        self.window_start = time.monotonic()

    def allow(self) -> bool:
        now = time.monotonic()
        if now - self.window_start >= self.window:
            self.count = 0
            self.window_start = now
        if self.count < self.limit:
            self.count += 1
            return True
        return False

async def attack(fw: FixedWindow):
    # 故意卡在窗口末尾打满,然后等窗口翻篇再打满
    while time.monotonic() - fw.window_start < 59.5:
        await asyncio.sleep(0.01)
    passed = 0
    for _ in range(100):
        if fw.allow():
            passed += 1
    await asyncio.sleep(1.0)      # 跨过窗口边界
    for _ in range(100):
        if fw.allow():
            passed += 1
    print("两秒内通过:", passed)

fw = FixedWindow(limit=100, window=60)
asyncio.run(attack(fw))

跑出来是 200。这个数字看着不吓人,但如果这个接口是付费的、或者背后是 GPU,两秒放进来两倍的量就足够把队列打满。固定窗口的毛病不在实现,在它天生不具备「连续时间视野」。

为什么最后选了令牌桶

常见的三种算法我都试过:

  • 滑动窗口日志。把每个请求的时间戳存进列表,判断最近 60 秒内的数量。精确,但内存占用跟请求量线性相关。上限 600 次的话,等于每个用户要存 600 个时间戳,一万个用户就是六百万个浮点数。不划算。
  • 漏桶。请求先进队列,以固定速率流出。这个平滑是平滑,但它的语义是「排队」,不是「拒绝」。OCR 这个场景里,用户点了按钮三秒没反应就会重试,排队反而制造更多重复请求。
  • 令牌桶。按速率往桶里补令牌,桶有容量上限,请求消耗令牌。它允许一定程度的突发(桶容量内),超出就立刻拒绝。

关键是最后那条:立刻拒绝。限流器的本职工作就是把超额的那部分挡在外面,而不是替调用方攒着。这点想清楚了,实现就简单很多。

第一版实现,被 time.time() 坑了

下面这版是我第一稿,逻辑上没问题,但有个隐蔽的坑:

import time
from dataclasses import dataclass, field

@dataclass
class TokenBucketV1:
    rate: float          # 每秒补充令牌数
    capacity: int        # 桶容量
    _tokens: float = field(init=False)
    _last: float = field(init=False)

    def __post_init__(self):
        self._tokens = float(self.capacity)
        self._last = time.time()          # 问题在这里

    def allow(self, cost: int = 1) -> bool:
        now = time.time()
        elapsed = now - self._last
        if elapsed > 0:
            self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
            self._last = now
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

测试环境跑得好好的。上线第三天,运维做了一次 NTP 校时,机器时钟往回跳了 0.8 秒。这一跳导致 elapsed 变成负数,被我的 if elapsed > 0 挡了一下没出事——但如果时间往前跳呢?elapsed 变成 30 秒,tokens 直接被补满到 capacity,等于凭空发了一整桶令牌出去。

换个思路就更明显了:我真正关心的是「过了多久」,而不是「现在几点」。time.monotonic() 就是为这个存在的,它单调递增,不受系统时间调整影响。这一行改完,后面再没出现过莫名其妙的额度波动。

改完之后是这样:

import asyncio, time
from dataclasses import dataclass, field

@dataclass
class TokenBucket:
    rate: float
    capacity: int
    _tokens: float = field(init=False)
    _last: float = field(init=False)

    def __post_init__(self):
        self._tokens = float(self.capacity)
        self._last = time.monotonic()

    def _refill(self) -> None:
        now = time.monotonic()
        elapsed = now - self._last
        if elapsed > 0:
            self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
            self._last = now

    def try_acquire(self, cost: int = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

关于加锁:先搞清楚哪些代码是原子的

写完上面这版,我第一反应是「并发安全吗」,然后条件反射想在 try_acquire 外面包一层 asyncio.Lock。

后来意识到没必要。try_acquire 从头到尾没有一个 await,在 asyncio 的单线程事件循环里,它执行期间不可能被切走。整个函数就是一个原子操作。加锁不但没意义,反而引入了额外的调度开销。

但这个结论有前提。FastAPI 里有个很容易忽略的区别:用 async def 定义的端点跑在事件循环里,用普通 def 定义的端点会被自动丢进线程池执行。他们项目里有几个老接口是同步的,如果这些接口也共用同一个桶,那就真的是多线程访问同一份状态了。

我的处理是:限流这条路径只允许 async def,同步端点在入口处用 asyncio.to_thread 转一层,或者干脆给它单独配一个带锁的桶。把并发模型理清楚,比到处加锁省事。

别在桶里排队

网上能搜到的令牌桶实现大多长这样:拿不到令牌就 await asyncio.sleep(),睡一会儿再试。我一开始也这么写,压测的时候发现了问题。

假设桶空了,同时有 500 个协程在等。每个协程算出自己需要等待的时间,然后 sleep,同一时刻醒来,一起去抢那把锁(或者一起去改 _tokens)。醒来 500 个,只有 1 个能拿到令牌,剩下 499 个重新算时间再睡。这就是典型的惊群,CPU 大部分时间花在上下文切换上。

而且真实场景里这个等待毫无意义。用户点了个 OCR,你让他挂在那儿等 8 秒才给结果,体验还不如直接返回「稍后再试」。所以我把等待逻辑删了,只留 try_acquire,拿不到就是 429。

如果确实有内部任务需要排队(比如后台的批量转码),单独写一个 acquire,用 asyncio.Semaphore 控制在途数量,比在桶里自旋干净得多。

接进 FastAPI

先装依赖:

pip install fastapi uvicorn cachetools

桶的存放需要一个容器。这里有个问题我一开始没注意:如果用普通 dict,key 是 plan:user_id,随着用户量增长它会无限膨胀。十万用户走一遍,就是十万个桶对象挂在内存里,而且大多数早就不会再用了。

用 cachetools.TTLCache 就行,带容量上限和过期时间,访问时自动续期:

from fastapi import FastAPI, Depends, HTTPException, Request
from cachetools import TTLCache

app = FastAPI()

# 最多缓存 10 万个桶,30 分钟没访问就回收
buckets: TTLCache = TTLCache(maxsize=100_000, ttl=1800)

# (每秒补充令牌数, 桶容量)
PLAN_LIMITS = {
    "free":       (100 / 86400, 20),   # 每天 100 次,允许一次突发 20
    "pro":        (600 / 60,     60),
    "enterprise": (50,           100),
}

def get_bucket(uid: str, plan: str) -> TokenBucket:
    key = f"{plan}:{uid}"
    bucket = buckets.get(key)
    if bucket is None:
        rate, cap = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"])
        bucket = TokenBucket(rate=rate, capacity=cap)
        buckets[key] = bucket
    return bucket

async def rate_limit(request: Request):
    user = request.state.user       # 由前面的认证中间件写入
    bucket = get_bucket(user["id"], user["plan"])
    if not bucket.try_acquire():
        raise HTTPException(
            status_code=429,
            detail="请求过于频繁",
            headers={"Retry-After": "1"},
        )

@app.post("/v1/ocr", dependencies=[Depends(rate_limit)])
async def ocr(request: Request):
    ...
    return {"ok": True}

request.state.user 是我在认证中间件里挂上去的,做法很简单,@app.middleware("http") 里解完 JWT 之后 request.state.user = payload 就可以。这样限流器和业务代码都不需要重复解析 token。

还有一个细节:Retry-After 头我写死成 1 秒,其实可以算出来——桶空的时候,补一个令牌需要 1 / rate 秒。对 pro 用户来说是 0.1 秒,取整成 1 秒也够用。免费用户那个 86400 分之一秒的速率算出来是 864 秒,就得单独处理了,不然客户端会等一个半小时。这块我暂时没做,直接给的 1,但代码里留了注释,后面接 SDK 的时候要补。

多进程之后,额度变成了四倍

单进程跑着挺好,但他们是用 uvicorn --workers 4 起的。四个 worker 各自持有一份 buckets 字典,意味着免费用户每天 100 次的额度,实际能打 400 次。

这个问题的解法只有一个方向:把状态从进程内存挪到共享存储。Redis 是现成的。

直接把逻辑翻译成 Lua 脚本,用 Redis 的原子性保证正确性。但这里有个坑我踩了整整一个下午:Lua 里不能用 ARGV 传时间戳。

第一版我是这么写的:Python 端算好 time.monotonic() 传进去。问题是四台应用服务器启动时间不一样,monotonic 的基准点各不相同,传到 Lua 里彼此一比就乱套了。改用 time.time() 也不行,机器之间 NTP 同步总有几十毫秒误差,高并发下这几十毫秒足够多放出去一批令牌。

正确做法是让 Redis 自己给时间,用 TIME 命令,它在 Lua 里可以直接调用:

-- KEYS[1] = bucket key
-- ARGV[1] = rate(每秒令牌数)  ARGV[2] = capacity
-- ARGV[3] = cost  ARGV[4] = ttl 秒
local key      = KEYS[1]
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local cost     = tonumber(ARGV[3])
local ttl      = tonumber(ARGV[4])

-- 用 Redis 服务器的时间,毫秒精度,所有节点看到的是同一个时钟
local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts     = tonumber(data[2])

if tokens == nil then
    tokens = capacity
    ts     = now
end

local delta = now - ts
if delta < 0 then delta = 0 end

-- delta 是毫秒,rate 是每秒,所以要除以 1000
tokens = math.min(capacity, tokens + delta * rate / 1000)

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

redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', tostring(now))
redis.call('EXPIRE', key, ttl)
return allowed

Python 那边用 redis.asyncio 调,注意把脚本 register_script 缓存下来,不要每次都传全文上去:

import redis.asyncio as aioredis

class RedisTokenBucket:
    def __init__(self, redis: aioredis.Redis, rate: float,
                 capacity: int, key_prefix: str = "rl:"):
        self.redis = redis
        self.rate = rate
        self.capacity = capacity
        self.prefix = key_prefix
        self._sha = None

    async def _load(self):
        if self._sha is None:
            self._sha = await self.redis.script_load(LUA_SRC)
        return self._sha

    async def try_acquire(self, uid: str, cost: int = 1) -> bool:
        sha = await self._load()
        try:
            ok = await self.redis.evalsha(
                sha, 1,
                f"{self.prefix}{uid}",
                self.rate, self.capacity, cost, 300,
            )
        except aioredis.ResponseError as e:
            if "NOSCRIPT" not in str(e):
                raise
            # Redis 重启过,脚本缓存丢了,重新加载一次
            self._sha = await self.redis.script_load(LUA_SRC)
            ok = await self.redis.evalsha(
                self._sha, 1,
                f"{self.prefix}{uid}",
                self.rate, self.capacity, cost, 300,
            )
        return bool(ok)

那个 NOSCRIPT 分支不是防御性编程的洁癖。Redis 主从切换的时候,从库升主,脚本缓存是空的,第一批请求全会报这个错。我上线后第二周就遇上了。

压测的几个数字

下面这些是我在 4 核 8G 的云主机上跑的,uvicorn --workers 4,容量设 50,代价 1,用 ab -n 5000 -c 200 打,你机器不一样结果肯定不同,别当绝对值看。

实现方式 200 响应 429 响应 说明
单进程 + 内存桶 50 4950 数字精确,符合预期
4 进程 + 内存桶 201 4799 每进程一份桶,额度放大四倍
4 进程 + Redis Lua 50 4950 共享状态,恢复精确

耗时上,单进程内存版 5000 个请求 QPS 在 9000 左右,Redis 版降到 2600 左右。这个差距主要是每请求一次网络往返带来的。如果对延迟敏感,可以在应用层加一层本地预检——先用内存桶挡掉明显超额的请求(比如本地桶已经空了就先拒),只有本地桶有余额时才去问 Redis。这样大部分被拒的请求根本不碰网络。代价是会有一定误拒,但限流器误拒几个请求,总比被刷爆强。

回过头看

这套东西写完,我最大的感受是:限流器的核心难点不在算法,在「想清楚你要什么语义」。是要排队、要平滑、还是要快速拒绝?这三种需求对应完全不同的实现,混在一起写出来的东西一定四不像。

另外就是那些细枝末节,time.monotonic、Redis 的 TIME、NOSCRIPT、TTLCache 的容量上限,每一个单拎出来都不算事,凑在一起就能让线上限流凭空多放或者少放一大截。这些代码现在都在跑,日志里 429 的曲线我看着还挺踏实。

如果你们也在做类似的东西,建议先别急着抄实现,把「额度到底按什么维度算」这件事在纸上推一遍。我见过太多项目限流器写得挺精致,结果 key 拼错了,用户 A 的额度扣在用户 B 头上。

asyncio 手写令牌桶限流器:一次接口被刷 30 万次后对 FastAPI 的重构
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python asyncio 手写令牌桶限流器:一次接口被刷 30 万次后对 FastAPI 的重构 https://www.taomawang.com/server/python/2980.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务