Python请求频率控制:从sleep到令牌桶的演进

Python请求频率控制:从sleep到令牌桶的演进

在量化交易和爬虫开发中,API请求频率控制是绕不开的坎。交易所的REST接口通常有严格的限频(rate limit)策略,比如每秒最多5次请求,或者每30秒最多120次。一旦超限,轻则返回429错误,重则封禁IP。

很多初学者用time.sleep()粗暴解决问题,但这种方式在复杂场景下非常脆弱。本文带你走一遍频率控制方案的演进路径,从最原始的sleep到工业级的令牌桶(Token Bucket),并给出可直接运行的Python实现。

1. 最原始方案:time.sleep()

最早期的做法是在每次请求前固定sleep一段时间,用时间间隔来控制频率。

python 复制代码
import time
import requests

def fetch_with_sleep(url, interval=0.2):
    """
    每次请求前sleep固定间隔
    interval: 两次请求之间的最小间隔(秒)
    """
    time.sleep(interval)
    resp = requests.get(url)
    return resp.json()

# 使用示例:控制每秒最多5次请求
for i in range(10):
    data = fetch_with_sleep("https://api.example.com/ticker", interval=0.2)
    print(data)

问题分析:

  • 无法应对突发流量:如果外部系统偶尔需要连续快速请求(比如批量撤单),sleep会强制拉长总耗时。
  • 无法精确控制窗口:很多API的限频是基于滑动窗口(如"每10秒最多30次"),固定间隔无法保证窗口内总量不超限。
  • 浪费可用配额:如果API允许每1秒5次,但你设置0.2秒间隔,实际每秒最多5次,但当网络延迟较高时,实际吞吐量会低于限频值。

2. 基于时间窗口的滑动控制

更好的方案是记录请求时间戳,在每次请求前检查当前时间窗口内已发请求数,如果达到上限则等待到窗口结束。

python 复制代码
import time
import threading
from collections import deque

class SlidingWindowLimiter:
    """
    滑动窗口限频器
    基于时间戳队列实现,线程安全
    """
    def __init__(self, max_requests, window_seconds):
        """
        :param max_requests: 窗口内最大请求数
        :param window_seconds: 窗口大小(秒)
        """
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self.timestamps = deque()
        self.lock = threading.Lock()

    def _prune(self, now):
        """清理窗口外的过期时间戳"""
        while self.timestamps and now - self.timestamps[0] >= self.window_seconds:
            self.timestamps.popleft()

    def acquire(self):
        """
        获取请求许可,若当前窗口已满则阻塞等待
        """
        while True:
            with self.lock:
                now = time.time()
                self._prune(now)

                if len(self.timestamps) < self.max_requests:
                    self.timestamps.append(now)
                    return
                
                # 计算需要等待的时间
                wait_time = self.window_seconds - (now - self.timestamps[0])
            
            # 在锁外等待,避免阻塞其他线程
            time.sleep(wait_time)

# 使用示例:每10秒最多30次请求
limiter = SlidingWindowLimiter(max_requests=30, window_seconds=10)

def fetch_with_limit(url):
    limiter.acquire()
    return requests.get(url).json()

优势:

  • 精确控制窗口内的请求总数
  • 支持突发:只要窗口内还有配额,可以连续快速请求

劣势:

  • 实现相对复杂,需要维护时间戳队列
  • 当窗口满时需要精确计算等待时间,在高并发下锁竞争可能成为瓶颈

3. 工业级方案:令牌桶算法

令牌桶(Token Bucket)是网络流量整形中最常用的算法,也是许多API网关(如API Gateway)采用的限流策略。它的核心思想是:

  • 以固定速率往桶里放令牌(比如每秒放5个)
  • 每次请求需要消耗一个令牌,桶空则拒绝或等待
  • 桶有容量上限,允许一定程度的突发(比如桶容量10,意味着可以一次性消耗10个令牌)

相比滑动窗口,令牌桶的优点是允许突发流量 ,同时平滑长期速率。这对于量化交易中"平时低频请求,行情剧烈时突然高频请求"的场景非常契合。

3.1 令牌桶原理图解

ini 复制代码
         +-------------------+
         |    Token Bucket   |
         |   capacity = 10   |
         |   rate = 5/s      |
         +-------------------+
                  |
         +--------+--------+
         |                 |
   有令牌 -> 放行请求    无令牌 -> 等待或拒绝

3.2 Python实现

python 复制代码
import time
import threading

class TokenBucket:
    """
    令牌桶限流器
    支持线程安全,可配置速率和容量
    """
    def __init__(self, rate, capacity):
        """
        :param rate: 令牌生成速率(个/秒)
        :param capacity: 桶容量(最大突发请求数)
        """
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity  # 初始装满令牌
        self.last_refill = time.time()
        self.lock = threading.Lock()

    def _refill(self):
        """补充令牌"""
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
        self.last_refill = now

    def acquire(self, blocking=True, timeout=None):
        """
        获取一个令牌
        :param blocking: 是否阻塞等待
        :param timeout: 阻塞超时时间(秒),None表示一直等待
        :return: 成功获取返回True,否则False
        """
        start_wait = time.time()
        
        while True:
            with self.lock:
                self._refill()
                if self.tokens >= 1:
                    self.tokens -= 1
                    return True
                
                if not blocking:
                    return False
                
                # 计算需要等待的时间
                wait_time = (1 - self.tokens) / self.rate
                if timeout is not None:
                    remaining = timeout - (time.time() - start_wait)
                    if remaining <= 0:
                        return False
                    wait_time = min(wait_time, remaining)
            
            time.sleep(wait_time)

    def acquire_many(self, n, blocking=True, timeout=None):
        """
        获取多个令牌(适用于批量请求)
        """
        for _ in range(n):
            if not self.acquire(blocking, timeout):
                return False
        return True

# 使用示例:每秒5个请求,允许突发10个
bucket = TokenBucket(rate=5, capacity=10)

def fetch_with_bucket(url):
    if bucket.acquire(timeout=5):
        return requests.get(url).json()
    else:
        raise Exception("请求超时,被限流")

# 批量请求场景:一次获取3个令牌
def fetch_batch(urls):
    if bucket.acquire_many(len(urls), timeout=10):
        return [requests.get(u).json() for u in urls]

3.3 更优雅的实现:使用ratelimit

如果不想自己造轮子,Python生态中已有成熟的库。ratelimit库基于装饰器实现,代码更简洁:

python 复制代码
from ratelimit import limits, sleep_and_retry
import requests

# 每10秒最多调用5次
@sleep_and_retry
@limits(calls=5, period=10)
def call_api(url):
    return requests.get(url).json()

# 使用示例
for i in range(20):
    print(call_api("https://api.example.com/data"))

注意:sleep_and_retry装饰器会在限流时自动sleep重试,适合同步代码。如果你的代码是异步的,建议使用aiolimiteraioredis等异步限流库。

4. 三种方案对比

方案 实现难度 突发支持 平滑速率 适用场景
time.sleep() 极低 不支持 不精确 简单脚本,低频请求
滑动窗口 支持(窗口内) 较好 对总量有严格要求的场景
令牌桶 中高 支持(容量内) 优秀 量化交易、爬虫、API网关

选择建议:

  • 如果只是写个一次性脚本抓数据,sleep足够
  • 如果对接交易所API且策略复杂,令牌桶是首选
  • 如果API限频规则是"每10秒最多N次"这种固定窗口,滑动窗口更直观

5. 实战案例:币安行情接口限频

以币安(Binance)的REST API为例,其权重限制为每分钟1200点(每个接口有不同权重)。我们可以用令牌桶模拟:

python 复制代码
import requests
import json

# 币安权重令牌桶:速率20权重/秒,桶容量1200
weight_bucket = TokenBucket(rate=20, capacity=1200)

def binance_request(path, params=None):
    """
    带权重控制的币安API请求
    """
    # 假设该接口权重为1
    weight_bucket.acquire()
    
    resp = requests.get(
        f"https://api.binance.com{path}",
        params=params,
        timeout=5
    )
    
    if resp.status_code == 429:
        # 被限频,需要重试
        print("429 Too Many Requests, waiting...")
        time.sleep(5)
        return binance_request(path, params)
    
    return resp.json()

# 批量获取K线
kline_data = []
for symbol in ["BTCUSDT", "ETHUSDT", "BNBUSDT"]:
    data = binance_request("/api/v3/klines", {
        "symbol": symbol,
        "interval": "1m",
        "limit": 100
    })
    kline_data.extend(data)

print(f"获取到 {len(kline_data)} 条K线数据")

6. 进阶优化:多实例协调

单机令牌桶在分布式场景下不适用。如果多个进程或服务共享API配额,需要引入Redis等集中式存储:

python 复制代码
import redis
import time

class RedisTokenBucket:
    """
    基于Redis的分布式令牌桶
    使用Lua脚本保证原子性
    """
    def __init__(self, redis_client, key, rate, capacity):
        self.redis = redis_client
        self.key = key
        self.rate = rate
        self.capacity = capacity
    
    def acquire(self):
        """
        使用Lua脚本原子地获取令牌
        """
        lua_script = """
        local key = KEYS[1]
        local rate = tonumber(ARGV[1])
        local capacity = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        
        -- 获取当前令牌数和上次补充时间
        local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)
        local last_refill = tonumber(redis.call('hget', key, 'last_refill') or now)
        
        -- 补充令牌
        local elapsed = now - last_refill
        tokens = math.min(capacity, tokens + elapsed * rate)
        
        -- 判断是否有令牌
        if tokens >= 1 then
            tokens = tokens - 1
            redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
            return 1
        else
            redis.call('hmset', key, 'tokens', tokens, 'last_refill', now)
            return 0
        end
        """
        
        result = self.redis.eval(
            lua_script,
            1,
            self.key,
            self.rate,
            self.capacity,
            time.time()
        )
        return result == 1

# 使用示例
r = redis.Redis(host='localhost', port=6379, db=0)
distributed_bucket = RedisTokenBucket(r, 'api_bucket', rate=10, capacity=50)

# 多进程共享限频
def worker(worker_id):
    for i in range(20):
        if distributed_bucket.acquire():
            print(f"Worker {worker_id} 请求成功")
        else:
            print(f"Worker {worker_id} 被限流")
        time.sleep(0.1)

# 启动多个worker线程模拟分布式
import threading
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()

7. 总结与踩坑提醒

核心要点:

  1. 先读API文档:每个交易所的限频规则不同,有的按请求次数、有的按权重、有的按连接数。先搞清楚规则再选方案。
  2. 预留缓冲:不要把限频阈值设到100%,建议留20%余量应对网络重试和突发。
  3. 重试策略 :429响应后要等待Retry-After头指定的时间,不要盲目重试。

常见坑:

  • 时钟漂移:多实例部署时,各机器时钟不同步会导致分布式限频失效。用NTP同步。
  • 线程安全:单机多线程必须加锁,否则计数会错乱。
  • 网络延迟:令牌桶控制的是"发出请求"的频率,不包含网络往返时间。如果API响应慢,实际吞吐量会低于预期。

sleep到令牌桶,本质是从"拍脑袋控制"到"精确流量整形"的进化。如果你的量化策略还停留在sleep阶段,建议尽快切换到令牌桶方案------尤其是在对接高频接口时,这可能是你避免被封号的关键一环。

更多内容请关注本站。