批量采集的并发控制:从「一把梭」到「可控的并发」工程实践
批量采集搜索数据,「并发」是个绕不开的话题:不开并发太慢,开了并发容易触发限流、把上游打挂、自己还崩。这篇记录我怎么从「一把梭全开」进化到「可控并发」的工程实践:并发模型、信号量、滑动限流、背压、超时管理,一套完整做法。
为什么「并发越大越快」是错觉
对搜索数据采集,并发和吞吐不是线性关系:
- 并发 5 和并发 20,吞吐差不多(受限于上游 QPS)
- 并发 20 开始触发限流,重试导致吞吐反而下降
- 并发 50,限流重试打满,延迟飙升,成功率下降
原因:搜索数据接口有 QPS 限制(保护上游和成功率),你的并发一旦超过它,就进入「限流 → 重试 → 更挤」的负反馈。正确目标不是「最大并发」,而是「在限流线下最稳定的吞吐」。
第一步:并发模型选型
Python 批量采集,三种模型:
concurrent.futures.ThreadPoolExecutor:简单,适合 I/O 密集的采集asyncio+ 信号量:更轻,适合大量请求- 进程池:一般用不上,采集是 I/O 密集不是 CPU 密集
从 ThreadPoolExecutor 开始最稳。重点是把并发数做成参数,测试出「稳定区间」:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def crawl_batch(keywords, max_workers, fetch):
results = {}
with ThreadPoolExecutor(max_workers=max_workers) as ex:
futures = {ex.submit(fetch, kw): kw for kw in keywords}
for fut in as_completed(futures):
kw = futures[fut]
try:
results[kw] = fut.result()
except Exception as e:
results[kw] = {"error": str(e)}
return results
先跑 max_workers=5、10、20 三档,观察延迟和成功率,找到你的「甜点值」。我这边 5-10 最稳,20 开始出现限流。
第二步:信号量 + 滑动限流(精细控制)
ThreadPoolExecutor 控制「同时在跑的数量」,但要精细控制「单位时间的请求频率」,加滑动窗口限流:
import threading
import time
class SlidingRateLimiter:
def __init__(self, max_requests, window_seconds):
self.max_requests = max_requests
self.window = window_seconds
self.timestamps = []
self.lock = threading.Lock()
def acquire(self):
with self.lock:
now = time.time()
self.timestamps = [t for t in self.timestamps if now - t < self.window]
if len(self.timestamps) >= self.max_requests:
wait = self.window - (now - self.timestamps[0])
time.sleep(wait)
self.timestamps.append(time.time())
配合使用:线程池限制并发数,滑动限流限制频率,两层叠加。这比「只开线程池」精细得多——很多限流是「每秒 N 次」级别的,滑动窗口能精确贴着线走。
第三步:背压——别让任务堆积
并发控制最容易忽略的是「任务队列堆积」。如果 fetch 慢、提交快,内存里堆了成千上万个待执行任务,一旦批量失败全是重试。
背压的核心:控制「提交速度」,而不是只控制「执行并发」。用有界队列:
from queue import Queue
def crawl_with_backpressure(keywords, max_workers, max_queue=100):
queue = Queue(maxsize=max_queue) # 有界队列:满则阻塞提交
results = {}
def worker():
while True:
try:
kw = queue.get(timeout=1)
except Exception:
return # 队列空了,退出
try:
results[kw] = fetch(kw)
except Exception as e:
results[kw] = {"error": str(e)}
queue.task_done()
# 启动 max_workers 个 worker
threads = [threading.Thread(target=worker) for _ in range(max_workers)]
for t in threads:
t.start()
for kw in keywords:
queue.put(kw) # 队列满时会阻塞,天然背压
queue.join()
for t in threads:
t.join()
return results
有界队列的 put 会在满时阻塞——这就是背压:上游慢了,提交就停,而不是无限堆任务。
第四步:超时管理
并发下,超时最容易失控。单个请求 timeout=30 是最低要求,并发下还要管「总超时」:
# 单个请求超时 + 整体任务超时
data = fetch(keyword, timeout=30)
# 伪代码:整体超时
with Timeout(total_seconds=600): # 整批 10 分钟上限
results = crawl_batch(...)
超时被触发的请求,交给重试队列(指数退避),而不是立即重发(参考之前限流篇的退避思路)。
第五步:重试队列 + 收敛
并发 + 重试,最怕「无限重试放大问题」。我用的收敛策略:
- 限流/超时类错误:进重试队列,指数退避(1s/2s/4s)
- 单条最多重试 3 次,仍失败标记
failed交给补偿任务 - 整批失败率超过阈值(如 20%),停止整批,告警——别在系统级故障下硬撑
def crawl_with_retry(keywords, max_retries=3):
pending = list(keywords)
for attempt in range(max_retries):
results = crawl_batch(pending, max_workers=10)
failed = [kw for kw, r in results.items() if r.get("error")]
if not failed:
break
if len(failed) / len(keywords) > 0.2:
alert("批量失败率过高,停止") # 收敛:系统级故障不硬撑
break
time.sleep(0.5 * 2 ** attempt)
pending = failed
return results
踩坑记录
坑 1:只控制并发不控制频率。 线程池 10 个线程,每个循环发请求,实际频率可能远超 QPS 限制。加滑动限流后稳定了。
坑 2:任务无限堆积。 一次 10 万关键词的批量,内存里堆了几万个任务,失败重试雪上加霜。有界队列背压解决。
坑 3:超时没收敛。 单请求 30s,但整批没有总超时,一次网络抖动整批卡死半小时。加整批超时 + 失败率熔断。
坑 4:重试零成本的前提用对了。 这个接口失败/超时自动退 credits(SerpBase 的设计),所以重试不烧钱——但这也意味着「重试要收敛」,否则浪费的是时间不是钱。
工程清单沉淀
- 并发数是参数,先测出稳定区间(5-10 起步)
- 线程池控并发 + 滑动窗口控频率,两层叠加
- 有界队列做背压,别让任务无限堆积
- 单请求超时 + 整批超时 + 失败率熔断
- 重试用指数退避 + 收敛,系统级故障不硬撑
并发控制不是「开大点」,而是「在限流线下稳定输出」。并发数、频率、队列、超时、重试五件套配齐,批量采集就从「碰运气」变成「可预期」。
接口的 QPS 机制和 status/request_id 字段在 SerpBase 官方文档 里有说明,做并发设计时对照。你的批量采集并发开到多少?评论区聊聊各自的甜点值。

浙公网安备 33010602011771号