用 Python 标准库从零实现一个生产可用的令牌桶限流器

举报
yd_263393080 发表于 2026/11/02 11:57:28 2026/11/02
【摘要】 不依赖任何第三方库,只用 threading 和 time,实现一个支持平均速率与突发流量、线程安全、可装饰的令牌桶限流器,并讲清楚令牌桶与漏桶的本质差别和几个容易写错的并发细节。

限流是后端避不开的话题。Python 生态里限流库不少,但大多数场景下你其实不需要引入它们——标准库里的 threading 和 time 就足够写出一个正确、轻量、好理解的令牌桶限流器。这篇笔记把整个实现拆开讲清楚,重点放在几个并发上容易写错的细节。

一、令牌桶 vs 漏桶:别选错

两种算法常被混为一谈,但行为差别很大。漏桶(Leaky Bucket)以固定速率漏水,请求像倒水一样进来,桶满了直接拒绝——它的输出速率永远恒定,不管你瞬间倒多少水。令牌桶(Token Bucket)则是以固定速率往桶里放令牌,请求来了先拿令牌,拿得到就放行,桶里的令牌可以攒到一个上限。

关键差别:令牌桶允许突发。如果桶容量是 10、填充速率 5/秒,那么空闲一段时间后桶攒满 10 个令牌,下一秒可以瞬间放过 10 个请求,之后才回落到 5/秒。对大多数面向用户的接口,这种突发容忍反而更合理——用户刷新页面会并发打几个接口,漏桶会误杀,令牌桶不会。

二、核心实现

实现的关键是不真的起一个线程去定时放令牌,而是惰性计算:记录上次补充时间,本次请求时根据时间差一次性补足。这样实现零额外线程,也避免了定时器的漂移问题。

import threading
import time

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate          # 令牌/秒
        self.capacity = capacity  # 桶容量
        self.tokens = capacity    # 初始放满
        self.last = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self, need=1):
        with self.lock:
            now = time.monotonic()
            elapsed = now - self.last
            self.tokens = min(
                self.capacity,
                self.tokens + elapsed * self.rate,
            )
            self.last = now
            if self.tokens >= need:
                self.tokens -= need
                return True
            return False

不到 20 行。几个要点:用 time.monotonic() 而不是 time.time(),因为限流算的是间隔,墙上时钟会被 NTP 调整甚至回拨,monotonic 保证单调递增;补充量用 min 截断到 capacity,否则空闲一天能攒出几百万令牌,瞬间放行就把下游打挂;锁只保护这一小段计算,临界区极短。

三、装饰器封装

直接调 acquire 不够优雅,包成装饰器更顺手。这里一个常见需求是"被限流时怎么办"——直接抛异常、返回降级值、还是 sleep 等待。把策略做成可注入的回调最灵活:

def rate_limit(rate, capacity, on_reject=None):
    bucket = TokenBucket(rate, capacity)
    def deco(fn):
        def wrapper(*args, **kwargs):
            if bucket.acquire():
                return fn(*args, **kwargs)
            if on_reject is not None:
                return on_reject(*args, **kwargs)
            raise RuntimeError('rate limited')
        return wrapper
    return deco

@rate_limit(rate=5, capacity=10, on_reject=lambda *a, **k: {'msg': 'too busy'})
def handle(req):
    ...

注意桶是装饰器闭包里的单例,所有对同一函数的调用共享一个桶,这正是我们想要的。如果给每个调用单独建桶,限流就形同虚设了。

四、几个容易写错的并发细节

1. 别用 time.time()。前面提过,墙上时钟回拨会让 elapsed 变成负数,tokens 越算越少,限流器直接卡死。monotonic 是为这种场景生的。

2. 锁的粒度。有人为了"减少锁竞争"把 tokens 的读取放在锁外、只把写回放锁内,这是经典的 check-then-act 竞态:两个线程同时读到 tokens=1,都判断够用,都进临界区减一,tokens 变成 -1。限流器必须把"读-判-写"作为一个原子操作整体放进锁里。

3. acquire 的 need 参数。有些接口一个请求消耗多个令牌(比如批量接口按批量大小计费),把 need 暴露出来比写多个限流器更干净。但要注意:当 need > capacity 时永远拿不到令牌,构造时应断言 need <= capacity,否则是个静默死锁。

4. 不要 sleep 等待。有的实现 acquire 失败会 time.sleep 到下一个令牌可用再重试。在 Web 上下文里这很危险——线程被占着 sleep,并发一高线程池就耗光了。宁可快速失败让上层决定重试或降级。

五、验证与小结

验证最直观的办法是起 100 个线程同时抢一个 rate=10/capacity=10 的桶,统计 1 秒内放行数——应该先瞬间放 10 个,之后大约每秒 10 个。实测下来和理论吻合,抖动在 1 个以内。

整个实现不到 50 行,没有第三方依赖,行为可预测。当然它只适合单机限流;分布式限流得把 tokens 状态搬到 Redis 之类的地方,用 SET + Lua 脚本保证原子性,那是另一个话题了。但即便上分布式,单机这层令牌桶作为本地预过滤仍然值得保留——能挡掉大部分无效请求,不让它们打到 Redis 上。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。