Python 爬虫(五):工程化——重试、并发、去重与"反爬"的真相
Posted on Sat 17 October 2026 in 技术 • Sucre Liu
从脚本到系统,差的是失败处理
能跑通一个页面,和能稳定跑十万个页面,是两回事。差距集中在四件事:错误怎么处理、并发怎么控、重复怎么避免、状态怎么存。这一篇讲前三件,落地存储留到下一篇。
重试:先分清"该不该重试"
不是所有错误都值得重试。重试一个 403,只会让自己更快被封。
| 情况 | 该重试吗 | 说明 |
|---|---|---|
| 连接超时、读超时、连接重置 | ✅ | 网络抖动,重试大概率成功 |
| 429 Too Many Requests | ✅ | 但必须退避,最好看 Retry-After |
| 500 / 502 / 503 / 504 | ✅ | 服务端临时故障 |
| 403 Forbidden | ❌ | 你被拦了,重试更糟 |
| 401 Unauthorized | ❌ | 登录态失效,去重新登录 |
| 404 Not Found | ❌ | 资源不存在,重试无意义 |
| 400 Bad Request | ❌ | 参数错了,重试也一样 |
正确的重试是指数退避 + 抖动(jitter):
import random, time
def backoff(attempt: int, base: float = 1.0, cap: float = 30.0) -> float:
# 2^n 增长,加上随机抖动,避免大量客户端同时重试形成惊群
return min(cap, base * (2 ** attempt)) + random.uniform(0, 1)
有 Retry-After 头时优先听它的(它代表服务器希望你等多久):
if r.status_code == 429:
wait = float(r.headers.get("Retry-After", 0)) or backoff(attempt)
time.sleep(wait)
喜欢用库的话,tenacity 能省不少样板:
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type
import httpx
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential_jitter(initial=1, max=30),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.TransportError)),
)
def get(url: str) -> str:
r = httpx.get(url, timeout=10)
r.raise_for_status()
return r.text
重试要有上限和预算,否则目标站宕机时你的脚本会无限空转。
并发:控制的是"对目标站的压力",不是你的速度
并发越高越快——对你自己是,对目标站是灾难,对你被封的概率也是。并发要按域名限流,而不是全局拉满。
同步可以用线程池:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url: str) -> str:
with requests.Session() as s:
r = s.get(url, timeout=10)
r.raise_for_status()
return r.text
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(fetch, urls)) # 8 条并发,不是 800
异步用 httpx.AsyncClient + 信号量限制并发:
import asyncio, httpx, random
async def fetch(client: httpx.AsyncClient, url: str, sem: asyncio.Semaphore):
async with sem: # 全局并发上限
for attempt in range(4):
try:
r = await client.get(url, timeout=10)
if r.status_code == 429:
await asyncio.sleep(float(r.headers.get("Retry-After", 0)) or backoff(attempt))
continue
r.raise_for_status()
return r.text
except (httpx.TimeoutException, httpx.TransportError):
if attempt == 3:
raise
await asyncio.sleep(backoff(attempt))
return None
async def main(urls):
sem = asyncio.Semaphore(8) # 同时最多 8 个请求
async with httpx.AsyncClient(headers={"User-Agent": UA}, http2=True) as client:
tasks = [fetch(client, u, sem) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
再进一步,可以做一个令牌桶限速器,保证"每秒不超过 N 个请求",而不是只限制"同时 N 个":
import time
class RateLimiter:
def __init__(self, rate: float): # rate = 每秒允许的请求数
self.rate, self.allowance, self.last = rate, rate, time.monotonic()
def acquire(self):
now = time.monotonic()
self.allowance = min(self.rate, self.allowance + (now - self.last) * self.rate)
self.last = now
if self.allowance < 1:
time.sleep((1 - self.allowance) / self.rate)
self.allowance = 0
else:
self.allowance -= 1
经验值:对普通站点,单域名 1–2 请求/秒、并发 4–8 就足够温和。你慢一点,反而是长期能跑的前提。
去重与增量:别重复抓已经抓过的
两个层面的去重:
- URL 去重:先归一化(去掉无用查询参数、统一大小写、去掉
#fragment、补全相对路径),再用集合或数据库判重。 - 内容去重:对正文算哈希,内容没变就不重复入库。
import hashlib
from urllib.parse import urldefrag, urljoin
def normalize(base: str, href: str) -> str:
url = urljoin(base, href)
url, _ = urldefrag(url) # 去掉 #fragment
return url
def digest(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
更"礼貌"的增量抓取是条件请求:记下上次的 ETag 或 Last-Modified,下次带上;服务器没变就回 304,你连正文都不用下载,既省流量又不打扰对方。
headers = {"If-None-Match": saved_etag}
r = session.get(url, headers=headers, timeout=10)
if r.status_code == 304:
... # 内容没变,跳过
else:
saved_etag = r.headers.get("ETag")
"反爬"的真相
先说结论:大多数所谓反爬,靠"降速 + 缓存 + 用官方接口"就能解决,而不是靠对抗。
站点常见的防护,以及对应的正当做法:
| 防护 | 它在拦什么 | 正当做法 |
|---|---|---|
| 限速(429) | 高频请求 | 降速、退避、缓存 |
| UA/头校验 | 明显是脚本的请求 | 带上真实、可联系的 UA 与必要头 |
| 登录/Cookie 校验 | 未授权访问 | 正常登录流程(第 3 篇) |
| 需要 JS 渲染 | 直接请求拿不到 | 先找接口(第 4 篇) |
| IP 封禁 | 单 IP 过量 | 先降速;确实必要再谈代理 |
| 验证码 / 人机验证 | 自动化 | 不绕过,走 API 或放弃 |
关于代理,态度要摆正:
- 代理是"网络出口",本身中性;但用代理规避封禁、伪造来源、绕过地域限制,可能违反服务条款甚至法律。
- 如果你确实需要代理(比如采集自己分布在不同区域的数据),优先用合规的商业代理,并保持频率克制。
- 别去搭"反-反爬"农场(代理池 + 指纹伪装 + 验证码打码)。这条路不稳定、成本高,且风险远大于收益。
一个判断标准:如果你需要不断"对抗"才能拿到数据,那多半说明对方不希望被这样抓。 这时候正确的选择是找官方 API、谈授权,或者放弃。
让系统可观测
长期运行的爬虫必须能回答三个问题:跑到哪了、失败在哪、为什么变慢。
- 结构化日志:每条请求记录 URL、状态码、耗时、重试次数。
logging配 JSON 格式,便于检索。 - 指标:成功率、429/403 计数、平均延迟。异常升高就是信号。
- 断点续跑:把"已完成的 URL / 已抓的 ID"落库(下一篇细讲),中断后能接着跑,而不是从头再来。
小结
- 重试只对超时/网络错误/429/5xx 有意义;指数退避 + 抖动,尊重
Retry-After。 - 并发按域名克制(1–2 req/s、并发 4–8),用信号量或令牌桶,而不是线程数拉满。
- 去重分 URL 与内容两层;增量用
ETag/Last-Modified条件请求。 - 大多数"反爬"靠降速、缓存、官方接口解决;验证码不绕过,别搭对抗农场。
- 让系统可观测、可续跑,才叫工程化。
下一篇收尾:数据落地与调度——存 JSONL / SQLite / Postgres 各适合什么、Scrapy 该不该上,以及一个能长期跑的最小运维方案。