Python 异步爬虫流水线:限制并发、控制内存,并把结果持续写入文件

用 asyncio、HTTPX 和有界队列构建下载、解析、写入流水线,讲清背压、任务取消、错误传播及部分结果保留。

亿牛云技术团队2026年10月28日6 分钟阅读

一次给几万个 URL 创建任务,再用 gather() 等待全部结果,通常会把两个问题叠在一起:下载速度超过解析速度,结果又一直留在内存里。更稳妥的做法是让下载、解析和写入各自工作,中间通过有容量上限的队列传递数据。

本教程用三个 Python 文档页面演示这条流水线。最终每个已处理 URL 对应一条 JSONL 记录,包含页面标题或明确的失败原因。示例不需要浏览器、数据库和模型服务;你可以先理解任务如何收尾,再把输入换成自己的页面清单。

准备输入地址

先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。

  • DOC_URL_1:Python 教程 introduction.html 页地址。
  • DOC_URL_2:同目录 controlflow.html 页地址。
  • DOC_URL_3:同目录 datastructures.html 页地址。

在执行后续命令的同一个 Bash/WSL 终端中设置:

export DOC_URL_1='<paste-the-actual-doc-url-1>'
export DOC_URL_2='<paste-the-actual-doc-url-2>'
export DOC_URL_3='<paste-the-actual-doc-url-3>'

下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。

先确定流水线的责任

URL 清单 → 有界任务队列 → 4 个下载协程
                       ↓
                  有界页面队列 → 1 个解析协程
                                      ↓
                                 有界结果队列 → 1 个文件写入协程

四个下载协程共享一个 HTTP 客户端。队列满时,生产者停在 await queue.put(),这就是背压:下游变慢会限制上游继续生产。

这里同时限制下载并发、单页响应大小和等待解析的页面数量。仅设置队列长度不能限制单个响应的大小;也不能限制原始 URL 清单的内存。示例把三个 URL 放在常量里,大批量输入时应把生产者改为逐行读取清单。

正常结束与故障结束也要分开。正常结束按阶段发送结束标记;内部异常则交给 TaskGroup 取消其余任务,不让某个消费者永远等不到结束标记。

安装依赖并准备输出目录

使用 Python 3.12,在一个新目录中执行:

python -m venv .venv
source .venv/bin/activate
python -m pip install "httpx==0.28.1" "beautifulsoup4==4.13.4"

Windows PowerShell 使用 .venv\Scripts\Activate.ps1 激活环境。将下面完整代码保存为 pipeline.py。程序用排他创建模式打开输出文件,已有文件时会立即停止,避免把两批结果混在一起。

写出能够完整结束的流水线

import asyncio
import os
import json
from pathlib import Path
 
import httpx
from bs4 import BeautifulSoup
 
URLS = [
    os.environ["DOC_URL_1"],
    os.environ["DOC_URL_2"],
    os.environ["DOC_URL_3"],
]
WORKERS = 4
MAX_BYTES = 2 * 1024 * 1024
OUTPUT = Path("pages.jsonl")
 
 
class PageTooLarge(Exception):
    pass
 
 
async def produce(jobs):
    for url in URLS:
        await jobs.put(url)
    for _ in range(WORKERS):
        await jobs.put(None)
 
 
async def fetch(client, jobs, pages):
    while True:
        url = await jobs.get()
        if url is None:
            return
        try:
            async with client.stream("GET", url) as response:
                response.raise_for_status()
                media = response.headers.get("content-type", "").lower()
                if "text/html" not in media:
                    raise ValueError("unexpected_content_type")
                body = bytearray()
                async for chunk in response.aiter_bytes():
                    if len(body) + len(chunk) > MAX_BYTES:
                        raise PageTooLarge("response_exceeds_2_mib")
                    body.extend(chunk)
                record = {
                    "url": str(response.url),
                    "requested_url": url,
                    "status": "downloaded",
                    "body": bytes(body),
                    "encoding": response.encoding,
                }
        except (httpx.HTTPError, PageTooLarge, ValueError) as exc:
            record = {
                "url": url,
                "requested_url": url,
                "status": "failed",
                "stage": "download",
                "error_type": type(exc).__name__,
                "error": str(exc),
            }
        await pages.put(record)
 
 
async def parse(pages, results):
    while True:
        record = await pages.get()
        if record is None:
            await results.put(None)
            return
        if record["status"] == "failed":
            await results.put(record)
            continue
        soup = BeautifulSoup(
            record.pop("body"),
            "html.parser",
            from_encoding=record.pop("encoding"),
        )
        title = soup.title.get_text(" ", strip=True) if soup.title else ""
        if not title:
            record.update(
                status="failed", stage="parse",
                error_type="MissingTitle", error="title_is_empty",
            )
        else:
            record.update(status="ok", title=title)
        await results.put(record)
 
 
async def write(results):
    written = 0
    with OUTPUT.open("x", encoding="utf-8", newline="\n") as handle:
        try:
            while True:
                record = await results.get()
                if record is None:
                    break
                handle.write(json.dumps(record, ensure_ascii=False) + "\n")
                written += 1
                if written % 10 == 0:
                    handle.flush()
        finally:
            handle.flush()
            print("written_records=" + str(written))
 
 
async def finish_downloads(tasks, pages):
    await asyncio.gather(*tasks)
    await pages.put(None)
 
 
async def main():
    jobs = asyncio.Queue(maxsize=16)
    pages = asyncio.Queue(maxsize=8)
    results = asyncio.Queue(maxsize=16)
    limits = httpx.Limits(
        max_connections=WORKERS, max_keepalive_connections=WORKERS
    )
    async with httpx.AsyncClient(
        timeout=httpx.Timeout(20.0),
        limits=limits,
        follow_redirects=True,
        headers={"User-Agent": "DocumentPipelineTutorial/1.0"},
    ) as client:
        async with asyncio.TaskGroup() as group:
            group.create_task(produce(jobs))
            downloads = [
                group.create_task(fetch(client, jobs, pages))
                for _ in range(WORKERS)
            ]
            group.create_task(parse(pages, results))
            group.create_task(write(results))
            group.create_task(finish_downloads(downloads, pages))
 
 
if __name__ == "__main__":
    asyncio.run(main())

运行:

python pipeline.py

这里没有 queue.join(),因为结束条件由明确的生产者和结束标记控制。四个下载任务全部结束后,协调协程才向页面队列发送一个结束标记;解析协程读完前面的页面后,再通知唯一的写入协程收尾。提前在每个下载协程结束时直接通知解析协程,会让快协程关闭仍在接收其他下载结果的下游。

解析在事件循环中执行,适合本例受大小限制的短页面。复杂清洗或大规模 CPU 计算应使用单独的有界处理池,不能以“用了 async”推断解析也会并行。

怎样解释输出

以下是字段示意,标题以实际页面为准:

{"url":"https://example.16yun.cn/3/tutorial/introduction.html","requested_url":"https://example.16yun.cn/3/tutorial/introduction.html","status":"ok","title":"An Informal Introduction to Python — Python documentation"}

网络失败也写一条记录,例如 status=failed、stage=download,而不是静默少一行。只有正常完成、三个输入均已处理时,才应看到三行结果;这不等于三个都成功。

用下面的读取检查统计最终结果,不要只看最后打印的写入数量:

import json
from collections import Counter
 
with open("pages.jsonl", encoding="utf-8") as handle:
    rows = [json.loads(line) for line in handle]
print("records:", len(rows))
print("statuses:", dict(Counter(row["status"] for row in rows)))
print("inputs:", len({row["requested_url"] for row in rows}))
for row in rows:
    if row["status"] != "ok":
        print(row["requested_url"], row["stage"], row["error_type"])

若要做下一批,先归档当前文件,再运行程序。程序没有把历史文件当作断点;恢复逻辑应以 requested_url 的成功记录筛选待处理输入,失败记录不能算作完成。

为什么既记录失败,又让程序报错

HTTP 错误、超时、响应过大和类型不符属于某个 URL 的处理结果。记录后,其他页面可以继续。

字段名拼错、解析器内部缺陷、磁盘写入失败则不能冒充普通网页失败。它们会使任务抛出异常,TaskGroup 取消相关协程,进程以失败结束。这样可以避免“程序显示完成,文件只写了一部分”的误判。不要在每层增加 except Exception: pass。

按 Ctrl+C 时,协程取消也会触发文件写入器的 finally。已经写入 Python 文件缓冲的记录会被刷新;仍在页面队列、结果队列或网络中的数据可能没有写入。正常取消和断电不同,flush() 不承诺断电后的磁盘持久性,也不保证硬中断时最后一行完整。

常见问题与调整方法

现象先检查什么如何调整并确认
并发开大后反而更慢目标站限流、超时记录、连接数先把 WORKERS 降到 2,比较相同输入的成功数与耗时
内存仍然上涨单页大小、输入清单是否全部读入、是否额外缓存 soup保持响应大小上限,逐行生产 URL;不要收集所有 HTML 再解析
写入慢导致下载停顿结果队列是否长期满、磁盘是否拥堵这是背压生效;先确认磁盘,再考虑一个写入协程内分批写入
FileExistsError输出文件是否已存在归档文件或换新文件名,不要直接改为追加而混入旧批次
停止后记录少于输入是否取消、是否存在未消费队列将已成功的 requested_url 与输入比对,补采未完成项
出现 ExceptionGroup异常组中的第一个业务外异常修复该异常;不要只捕获整个异常组后打印“完成”

本例每十条刷新一次,小批次在正常退出时刷新。如果需要按时间刷新,应让写入协程同时控制定时器和缓冲区;如果需要“每条确认后才算完成”,应改用事务存储,并在提交成功后记录完成状态。提高并发数无法替代这些持久性要求。

进一步了解任务取消和队列行为,可阅读 Python TaskGroup 与任务取消 和 asyncio 队列。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。