Files
douyin/backend/rpa_engine/douyin_im/rate_limit.py
T
2026-07-17 10:21:18 +08:00

118 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""全局出站发送限流器。
目的:托管账号数量多(如 1000 个)时,避免自动回复/手动发送在同一时刻
大量并发打向抖音接口,占满服务器出站带宽或触发平台级风控。
机制(两层,先到先过):
1. 并发上限(Semaphore):同时在途的发送请求数不超过 KEFU_SEND_MAX_CONCURRENCY
2. 令牌桶(QPS):平均发送速率不超过 KEFU_SEND_RATE_PER_SEC,允许 KEFU_SEND_BURST 的突发。
限流器按事件循环惰性创建(backend 主循环一个实例;独立脚本各自一个),
所有账号共享同一个实例,因此是跨账号的全局闸门。
环境变量:
KEFU_SEND_MAX_CONCURRENCY 同时在途发送数上限,默认 20,<=0 表示不限
KEFU_SEND_RATE_PER_SEC 平均每秒发送数上限,默认 10,<=0 表示不限
KEFU_SEND_BURST 令牌桶容量(突发上限),默认与并发上限相同
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
logger = logging.getLogger("douyin_im.rate_limit")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, "") or default)
except ValueError:
return default
class GlobalSendLimiter:
"""并发上限 + 令牌桶。用法:async with limiter: await 发送。"""
def __init__(
self,
max_concurrency: int = 20,
rate_per_sec: float = 10.0,
burst: float | None = None,
):
self.max_concurrency = int(max_concurrency)
self.rate_per_sec = float(rate_per_sec)
self.burst = float(burst if burst is not None else max(1, max_concurrency))
self._sem = (
asyncio.Semaphore(self.max_concurrency) if self.max_concurrency > 0 else None
)
self._tokens = self.burst
self._last_refill = time.monotonic()
self._token_lock = asyncio.Lock()
# 统计:便于日志观察限流是否生效
self._waited_total = 0.0
self._acquired_count = 0
async def _take_token(self) -> None:
if self.rate_per_sec <= 0:
return
async with self._token_lock:
while True:
now = time.monotonic()
self._tokens = min(
self.burst, self._tokens + (now - self._last_refill) * self.rate_per_sec
)
self._last_refill = now
if self._tokens >= 1.0:
self._tokens -= 1.0
return
await asyncio.sleep((1.0 - self._tokens) / self.rate_per_sec)
async def __aenter__(self) -> "GlobalSendLimiter":
start = time.monotonic()
if self._sem is not None:
await self._sem.acquire()
try:
await self._take_token()
except BaseException:
if self._sem is not None:
self._sem.release()
raise
waited = time.monotonic() - start
self._waited_total += waited
self._acquired_count += 1
if waited > 1.0:
logger.info(
"send throttled: waited %.1fs (in-flight cap=%s, rate=%s/s)",
waited, self.max_concurrency or "∞", self.rate_per_sec or "∞",
)
return self
async def __aexit__(self, *exc) -> None:
if self._sem is not None:
self._sem.release()
# 每个事件循环一个实例(backend 主循环即全局唯一;脚本自用循环互不影响)
_limiters: dict[int, GlobalSendLimiter] = {}
def get_send_limiter() -> GlobalSendLimiter:
loop = asyncio.get_running_loop()
key = id(loop)
limiter = _limiters.get(key)
if limiter is None:
max_conc = int(_env_float("KEFU_SEND_MAX_CONCURRENCY", 20))
rate = _env_float("KEFU_SEND_RATE_PER_SEC", 10.0)
burst = _env_float("KEFU_SEND_BURST", max(1, max_conc))
limiter = GlobalSendLimiter(max_conc, rate, burst)
_limiters[key] = limiter
logger.info(
"global send limiter ready: concurrency=%s, rate=%s/s, burst=%s",
max_conc if max_conc > 0 else "unlimited",
rate if rate > 0 else "unlimited",
burst,
)
return limiter