asyncio版令牌桶不能直接套用threading版逻辑,因为threading.lock在协程中会阻塞event loop且不可await,导致并发请求串行化、吞吐量骤降;必须改用asyncio.lock或无锁结构。

asyncio 版令牌桶为什么不能直接套用 threading 版逻辑
因为 threading.Lock 在协程里会阻塞整个 event loop,不是 awaitable;用它会导致所有并发请求串行化,吞吐量断崖下跌。真正的异步限流必须用 asyncio.Lock 或无锁结构(如原子计数器 + asyncio.sleep)。
- 常见错误现象:
RuntimeWarning: coroutine 'Lock.acquire' was never awaited,或压测时 QPS 卡在 1–2,远低于预期 - 正确做法:用
asyncio.Lock包裹桶状态更新,且只在需要修改tokens和last_refill时加锁,读取当前令牌数可不加锁(容忍短暂脏读) - 性能影响:锁粒度越小越好,避免把
await asyncio.sleep()放在锁内——那会让其他协程干等
如何实现线程安全又低开销的 async 令牌桶
核心是分离「判断是否放行」和「更新桶状态」两个动作,用「先检查、再尝试更新」+ 失败重试(或直接拒绝)的策略,减少锁持有时间。
- 使用场景:FastAPI / Starlette 中间件、aiohttp 路由装饰器、或独立的限流服务客户端
- 关键参数:
rate(每秒令牌数)、burst(最大令牌数)、refill_interval(推荐设为1.0 / rate,但避免浮点精度误差) - 示例片段:
async def _refill_tokens(self): now = time.time() if now < self.last_refill + self.refill_interval: return False # 原子更新 last_refill,避免多协程同时 refill 导致令牌溢出 async with self._lock: if now >= self.last_refill + self.refill_interval: self.tokens = min(self.burst, self.tokens + 1) self.last_refill = now return True return False
asyncio.Lock 和 aioredis 结合做分布式限流要注意什么
单机 asyncio.Lock 只管得住本进程协程,跨实例就得靠外部存储;但 Redis 的 INCR + EXPIRE 组合不是原子的,直接拼命令会丢令牌或误限流。
- 常见错误现象:高并发下
redis.exceptions.ConnectionError频发,或同一用户在不同实例上被重复计数 - 必须用 Lua 脚本封装 refill + check 逻辑,例如用
redis.eval执行一段脚本,保证「读当前值 → 判断 → 写新值 → 设 TTL」全在服务端原子完成 - 兼容性影响:Redis 6.0+ 支持
ACL权限控制,Lua 脚本里不能调用EVALSHA以外的命令(除非显式授权),否则报NOPERM - 参数差异:本地桶用
time.time(),Redis 桶必须用redis.time()(即服务器时间),否则时钟漂移导致 refill 错乱
为什么 aiofiles 或 httpx 不该在限流中间件里做耗时 IO
限流本身应该是微秒级决策,一旦在里面加日志写文件、查数据库、或调第三方 API,就会把整个路由 pipeline 拖慢,甚至让限流器自己成为瓶颈。
立即学习“Python免费学习笔记(深入)”;
- 典型踩坑:在
__aenter__里用aiofiles.open记录每次请求,结果磁盘 IO 成为性能天花板 - 正确做法:限流只做两件事——返回 True/False,和可选地更新内存/Redis 状态;审计日志、告警、指标上报全部异步解耦(比如发到
asyncio.Queue由后台 task 消费) - 性能影响:实测在 FastAPI middleware 中加入一次
httpx.AsyncClient.get("https://httpbin.org/delay/0.1"),P99 延迟从 8ms 涨到 120ms+
事情说清了就结束。真正难的不是写个能跑的 async 令牌桶,而是想清楚哪些操作必须原子、哪些可以妥协、以及什么时候该放弃“精确限流”去换确定性延迟。










