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重试,适合同步代码。如果你的代码是异步的,建议使用aiolimiter或aioredis等异步限流库。
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. 总结与踩坑提醒
核心要点:
- 先读API文档:每个交易所的限频规则不同,有的按请求次数、有的按权重、有的按连接数。先搞清楚规则再选方案。
- 预留缓冲:不要把限频阈值设到100%,建议留20%余量应对网络重试和突发。
- 重试策略 :429响应后要等待
Retry-After头指定的时间,不要盲目重试。
常见坑:
- 时钟漂移:多实例部署时,各机器时钟不同步会导致分布式限频失效。用NTP同步。
- 线程安全:单机多线程必须加锁,否则计数会错乱。
- 网络延迟:令牌桶控制的是"发出请求"的频率,不包含网络往返时间。如果API响应慢,实际吞吐量会低于预期。
从sleep到令牌桶,本质是从"拍脑袋控制"到"精确流量整形"的进化。如果你的量化策略还停留在sleep阶段,建议尽快切换到令牌桶方案------尤其是在对接高频接口时,这可能是你避免被封号的关键一环。
更多内容请关注本站。