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 该不该上,以及一个能长期跑的最小运维方案。