Python 异步爬虫流水线:限制并发、控制内存,并把结果持续写入文件
用 asyncio、HTTPX 和有界队列构建下载、解析、写入流水线,讲清背压、任务取消、错误传播及部分结果保留。
一次给几万个 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 队列。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。