搜索 API 延迟优化:并发数、超时与超时的真实代价
同一个 API key,有人跑 1000 个词要一个半小时,有人二十分钟跑完------差别往往不在代码,在三个参数的配合:并发数、超时值和重试策略。配错了不只是慢,还会多扣 credits:官网明确"Executed failures may consume credits; an unknown execution or timeout is not automatically refunded"------超时的请求可能照扣费。
这篇讲清三件事:并发数和 QPS 的关系、超时值怎么定、以及为什么"超时后立即重试"是最贵的操作。字段与计费口径以 SerpBase 官方文档的端点与计费说明 为准;search/news/videos 每次 1 credit。
并发数的上限不是你的线程数
错误码 1029 的描述是"QPS or concurrency limit exceeded"------QPS 和并发是两个独立维度,哪个超了都报同一个码。价格页按套餐给 QPS 档位(Starter 2 QPS、Growth 5、Pro/Business 15、Enterprise 50),并发上限文档没给具体数字,但可以确定:它不是无限大。
这意味着"开 32 个线程并发跑"不一定更快------超出限制的部分都在收 1029,重试还继续占额度。真正的最优并发是:
有效吞吐 = min(套餐 QPS, 并发上限) × 单请求成功率
调优的第一步不是加线程,是测出自己 key 的实际档位:固定间隔探测,看有没有 1029。没超就缩短间隔再测,直到出现 1029,那个间隔就是你当前的安全线。
超时值:太长和太短都花钱
超时值设多少,是个双向成本问题:
| 超时设置 | 问题 |
|---|---|
| 太短(如 3 秒) | 正常慢请求被误杀,重试一次 = 多一次请求,而且被杀的请求可能已经扣费 |
| 太长(如 120 秒) | 真故障时线程全卡死,批量任务尾延迟被拖长,后面全排队 |
合理起点:先探到 p50 和 p95 基线,超时设 p95 的 2~3 倍。比如实测 p50 400ms、p95 1.2s,超时设 3~4 秒比较稳------既容得下偶发慢请求,又不会让线程卡太久。
还有一个容易忽略的点:重试的超时应该比首次短。首次给 4 秒,重试给 2 秒------第一次慢可能是冷启动,重试还慢大概率是真有问题,别把线程继续押上。
带预算感知的调优客户端
python
import random, threading, time
from concurrent.futures import ThreadPoolExecutor
import requests
API = "https://api.serpbase.dev/google/search"
KEY = "你的 API Key"
# 基于探测结果的配置:示例按 5 QPS 档设计
QPS = 5.0
CONCURRENCY = 4
TIMEOUT_FIRST = 4.0
TIMEOUT_RETRY = 2.0
MAX_ATTEMPTS = 3
_gate = threading.Semaphore(CONCURRENCY)
_pacer = threading.Lock()
_next_slot = [0.0]
def acquire_slot() -> None:
"""全局匀速放行,把并发请求摊到 QPS 预算内。"""
with _pacer:
now = time.monotonic()
start = max(now, _next_slot[0])
_next_slot[0] = start + 1.0 / QPS
delay = start - now
if delay > 0:
time.sleep(delay)
def search_once(q: str, timeout: float) -> dict:
resp = requests.post(API,
headers={"X-API-Key": KEY, "Content-Type": "application/json"},
json={"q": q, "page": 1}, timeout=timeout)
return resp.json()
def search(q: str) -> dict:
with _gate:
for attempt in range(1, MAX_ATTEMPTS + 1):
acquire_slot() # 先抢 QPS 配额再发请求
timeout = TIMEOUT_FIRST if attempt == 1 else TIMEOUT_RETRY
try:
data = search_once(q, timeout)
except requests.Timeout:
# 超时最贵:服务端可能已执行并计费,重试前先退避
time.sleep((2 ** attempt) + random.random())
continue
code = data.get("status")
if code == 0:
return data
if code == 1029: # 被限流:退避后重试
time.sleep((2 ** attempt) + random.random())
continue
if code in (1000, 1001, 1020): # 不可重试
raise RuntimeError(f"{code}: {data.get('error')}")
if code in (1500, 1502, 1503, 1504):
time.sleep((2 ** attempt) + random.random())
continue
if code == 1004:
return {"status": 1004, "empty": True}
return {"status": -1, "error": "exhausted"}
def batch(keywords: list, workers: int = CONCURRENCY) -> list:
results, stats = [], {"ok": 0, "empty": 0, "failed": 0}
with ThreadPoolExecutor(max_workers=workers) as pool:
for data in pool.map(search, keywords):
if data.get("status") == 0:
stats["ok"] += 1
results.append({"q": data["query"], "organic": len(data.get("organic", []))})
elif data.get("empty"):
stats["empty"] += 1
else:
stats["failed"] += 1
print(stats)
return results
if __name__ == "__main__":
batch(["serp api", "rank tracker", "google maps api"] * 5)
这段代码的核心是 acquire_slot:用一个全局 pacing 把并发请求摊到 QPS 预算内------不是"并发发了再说",而是从源头就不超速。这比"超了再退避"高效得多,因为退避本身也是请求。
三条调优经验
- 超时的请求按已扣费记账。 官网说得很清楚:已执行的失败可能扣费,超时和无法判定的执行不自动退款。所以客户端超时后直接抛异常是最坏选择------本地认为失败了,服务端可能正在执行并且计费。至少要把
credits_charged从异常路径里能拿到的信息记下来,批量结束后用GET /account/credits对账(0 credits,限流 60 次/分),差额就是"你以为没花其实花了"的部分。 - 并发别超过 QPS 档位太多。 5 QPS 的 key 开 4 个 worker 是合理的(留余量给重试),开 16 个就是自己制造 1029。判断方法:跑 100 个词,如果 stats 里 1029 重试占比超过 10%,说明并发超过承载了,降下来。
- 尾延迟比平均值重要。 批量任务的总时长由最慢的那个请求决定,不是平均值。如果 p50 300ms 但 p99 5 秒,你的 1000 词任务会被最后几十个词拖死------这时候该调的是超时和重试策略,不是并发。
FAQ
怎么测自己 key 的 QPS 档位? 固定间隔探测:从每秒 1 次开始,没遇到 1029 就提速到 2 次、2.5 次,遇到就退回上一档。价格页有套餐档位表(2/5/15/50),实测结果应该能对上你的套餐。
超时后重试,服务端会执行两次吗? 可能。这正是超时"贵"的原因------本地看到超时,但服务端的 worker 可能还在跑并且完成了请求。所以退避要够长,别让重试和原请求撞在一起。
并发和 QPS 哪个先超? 取决于配置。窄并发(2-4 worker)一般是 QPS 先超;宽并发(16+ worker)可能并发先超。两者都报 1029,只能靠降并发和降速率分别试出来。
超时值设多少合适? 没有普适值,先测基线:p50 的 2-3 倍作为起点,再根据批量任务的尾延迟表现调整。记住重试的超时要比首次短。
先用探测脚本测出你的实际 QPS 档位,再把客户端改成"抢配额再发请求"的模式------同样的 key,吞吐可能差一倍,而且少扣一堆冤枉 credits。