批量采集的并发控制:从「一把梭」到「可控的并发」工程实践

批量采集搜索数据,「并发」是个绕不开的话题:不开并发太慢,开了并发容易触发限流、把上游打挂、自己还崩。这篇记录我怎么从「一把梭全开」进化到「可控并发」的工程实践:并发模型、信号量、滑动限流、背压、超时管理,一套完整做法。

为什么「并发越大越快」是错觉

对搜索数据采集,并发和吞吐不是线性关系:

  • 并发 5 和并发 20,吞吐差不多(受限于上游 QPS)
  • 并发 20 开始触发限流,重试导致吞吐反而下降
  • 并发 50,限流重试打满,延迟飙升,成功率下降

原因:搜索数据接口有 QPS 限制(保护上游和成功率),你的并发一旦超过它,就进入「限流 → 重试 → 更挤」的负反馈。正确目标不是「最大并发」,而是「在限流线下最稳定的吞吐」。

第一步:并发模型选型

Python 批量采集,三种模型:

  1. concurrent.futures.ThreadPoolExecutor:简单,适合 I/O 密集的采集
  2. asyncio + 信号量:更轻,适合大量请求
  3. 进程池:一般用不上,采集是 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=51020 三档,观察延迟和成功率,找到你的「甜点值」。我这边 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(...)

超时被触发的请求,交给重试队列(指数退避),而不是立即重发(参考之前限流篇的退避思路)。

第五步:重试队列 + 收敛

并发 + 重试,最怕「无限重试放大问题」。我用的收敛策略:

  1. 限流/超时类错误:进重试队列,指数退避(1s/2s/4s)
  2. 单条最多重试 3 次,仍失败标记 failed 交给补偿任务
  3. 整批失败率超过阈值(如 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 的设计),所以重试不烧钱——但这也意味着「重试要收敛」,否则浪费的是时间不是钱。

工程清单沉淀

  1. 并发数是参数,先测出稳定区间(5-10 起步)
  2. 线程池控并发 + 滑动窗口控频率,两层叠加
  3. 有界队列做背压,别让任务无限堆积
  4. 单请求超时 + 整批超时 + 失败率熔断
  5. 重试用指数退避 + 收敛,系统级故障不硬撑

并发控制不是「开大点」,而是「在限流线下稳定输出」。并发数、频率、队列、超时、重试五件套配齐,批量采集就从「碰运气」变成「可预期」。

接口的 QPS 机制和 status/request_id 字段在 SerpBase 官方文档 里有说明,做并发设计时对照。你的批量采集并发开到多少?评论区聊聊各自的甜点值。

posted @ 2026-09-01 15:32  蜘蛛人  阅读(5)  评论(0)    收藏  举报