异步爬虫里 aiohttp 超时设多少不会误杀正常请求

1 阅读10分钟

超时设 3 秒,跑半小时成功率从 98% 掉到 85%。目标站点 RTT 正常,代理 IP 存活,但大量请求抛 ServerTimeoutError。问题出在超时阈值与实际网络时序不匹配,把正常的处理延迟当成了网络故障。

aiohttp 超时的实现机制

aiohttp 的 ClientTimeout 底层依赖 asyncio 的定时器协程。每一层超时对应一个独立的 asyncio.wait_forloop.call_later 调度,触发时取消对应的协程并抛出 asyncio.TimeoutError,aiohttp 再包装成 aiohttp.ServerTimeoutError

import aiohttp

# aiohttp.ClientTimeout 源码定义(简化)
# class ClientTimeout:
#     total: Optional[float]       # 整个请求生命周期
#     connect: Optional[float]     # 连接建立(TCP + TLS)
#     sock_connect: Optional[float]# 纯 TCP 握手(connect 的子集)
#     sock_read: Optional[float]   # 相邻两次 socket read 的间隔

# 默认值:total=300(5分钟),其余 None
# 这意味着默认情况下只有 5 分钟总超时兜底,connect 和 sock_read 不设防

timeout = aiohttp.ClientTimeout(
    total=30,          # 请求全生命周期上限,兜底防止协程泄漏
    connect=5,         # TCP 三次握手 + TLS 四次握手
    sock_read=10,      # 两次 read() 之间的最大间隔
    sock_connect=5,    # TCP 握手单独限制,一般不单独设
)

三层超时的触发顺序和覆盖关系:

请求开始
  │
  ├── connect 计时开始 ──→ TCP SYN → SYN-ACK → ACK → TLS ClientHello → ... → Finished
  │                         └── connect 超时在此区间触发
  │
  ├── 请求发送
  │
  ├── sock_read 计时开始 ──→ 等待响应头
  │                         └── 每收到一段数据,计时器重置
  │                         └── 间隔超过 sock_read 则触发
  │
  └── total 计时贯穿全程 ──→ 超过 total 则取消整个协程

关键点:sock_read 计的是间隔不是总量。服务器花 8 秒生成页面,但生成后一次性返回,sock_read=10 不会触发。但如果服务器先返回头,然后 11 秒后才返回 body,sock_read=10 会触发。

connect 超时:握手时序分析

# 错误:connect=1s
# TCP 握手 RTT 通常 50-300ms(国内),跨国 200-800ms
# 但 TLS 1.2 需要额外 1-2 个 RTT,TLS 1.3 需要 1 个 RTT
# connect=1s 在网络抖动或跨地区请求时容易误杀

timeout_bad = aiohttp.ClientTimeout(connect=1)

# 正确:connect=5s
# 覆盖 TCP 握手 + TLS 握手 + 少量抖动余量
timeout_ok = aiohttp.ClientTimeout(connect=5)

握手时序分解:

直连 HTTPS 站点:
  TCP SYN → SYN-ACK → ACK          1 RTT(50-300ms 国内)
  TLS ClientHello → ServerHello     1 RTT
  TLS Finished → Finished           1 RTT(TLS 1.2)
                                   ─────────
  合计 2-3 RTT,约 100-900ms

走 HTTP 代理(CONNECT 隧道):
  client → proxy TCP 握手           1 RTT
  proxy → target TCP 握手           1 RTT(代理到目标的延迟)
  CONNECT 请求 + 响应               1 RTT
  TLS 握手(端到端)                 2-3 RTT
                                   ─────────
  合计 5-6 RTT,约 400ms-3s

走代理时 connect 阶段涉及多段 RTT 叠加,这是代理场景下 connect 需要调大的根本原因。

sock_read 超时:事件循环与读事件

# 错误:sock_read=3s
# 动态页面服务端处理时间 1-5s,期间 socket 无数据可读
# asyncio 的 reader 协程阻塞在 await,sock_read 定时器到期后触发 TimeoutError

timeout_bad = aiohttp.ClientTimeout(sock_read=3)

# 正确:sock_read=10s,慢站 20-30s
timeout_ok = aiohttp.ClientTimeout(sock_read=10)

aiohttp 的读取流程:

# aiohttp 内部简化逻辑
async def read_response(reader, timeout):
    while True:
        # sock_read 定时器从这里开始计时
        data = await asyncio.wait_for(reader.read(n), timeout.sock_read)
        if not data:
            break
        # 收到数据,定时器重置
        yield data

sock_read 只在 read() 阻塞期间计时。数据一到,read() 返回,计时器取消,下一次 read() 重新开始计时。所以设 10 秒不影响 200ms 内完成的正常请求。

容易误杀的场景:

场景服务端行为sock_read=3s 的结果
动态页面渲染2-5s 无数据,然后一次性返回误杀
数据库查询接口3-8s 无数据,然后返回误杀
chunked 分块传输每 4s 发一块误杀
大文件下载持续有数据流正常
静态页面200ms 内返回正常

total 超时与连接池的交互

# 错误:不设 total
# 如果服务端以极慢速率持续返回数据(每次间隔 < sock_read),
# 请求会长时间占用连接池中的连接,导致 limit 耗尽

timeout_bad = aiohttp.ClientTimeout(sock_read=10)

# 错误:total <= sock_read
# total 先于 sock_read 触发,sock_read 配置无意义

timeout_bad2 = aiohttp.ClientTimeout(total=10, sock_read=10)

# 正确:total 远大于正常请求耗时,但小于连接池泄漏容忍阈值

timeout_ok = aiohttp.ClientTimeout(total=30, sock_read=10)

total 超时和连接池容量直接相关。TCPConnectorlimit 控制最大并发连接数,如果一个请求因为服务端慢速响应而长时间不释放,就会挤占其他请求的连接配额。

# 连接池配置与超时的协同
connector = aiohttp.TCPConnector(
    limit=100,              # 全局最大并发连接
    limit_per_host=10,      # 单个目标站点最大并发连接
    keepalive_timeout=30,   # 空闲连接保活时间(秒)
    enable_cleanup_closed=True,  # 关闭半开连接清理,防止 FD 泄漏
)

# 超时配置要和连接池配匹配:
# 如果 limit=100, total=60s,最坏情况下 100 个连接全被慢请求占满 60 秒
# 实际吞吐 = 100 / 60 ≈ 1.7 req/s,远低于预期
# 要么提高 limit,要么降低 total,根据目标站点响应时间算

代理链路超时分析

代理引入的额外延迟分布在 connectsock_read 两个阶段:

# 以亿牛云隧道代理为例
# 链路:client → 亿牛云代理服务器 → 目标站点

# connect 阶段增加:
#   client → proxy 的 TCP 握手(1 RTT)
#   proxy → target 的 TCP 握手(1 RTT,代理到目标的延迟)
#   CONNECT 隧道建立(1 RTT)
#   端到端 TLS 握手(2-3 RTT)

# sock_read 阶段增加:
#   代理服务器的转发缓冲(通常 50-200ms)
#   代理出口 IP 到目标站点的网络波动

PROXY_TIMEOUT = aiohttp.ClientTimeout(
    total=30,
    connect=8,       # 直连 5s + 代理额外 3s 余量
    sock_read=15,    # 直连 10s + 代理转发缓冲余量
)

亿牛云提供两种代理模式,超时策略不同:

import aiohttp
import asyncio
import logging
import random

logger = logging.getLogger(__name__)


class ProxyCrawler:
    """亿牛云代理爬虫:支持隧道代理和短效代理两种模式"""

    def __init__(self, mode="tunnel"):
        self.mode = mode

        if mode == "tunnel":
            # 隧道代理:固定地址,服务端自动切换出口 IP
            # 优点:不需要管理 IP 池
            # 缺点:无法控制具体出口 IP,切 IP 时请求可能中断
            self.proxy_url = "http://username:password@proxy.16yun.cn:9010"
            self.timeout = aiohttp.ClientTimeout(total=30, connect=8, sock_read=15)
        else:
            # 短效代理:拿到一批 IP,自行轮换
            # 优点:可以筛选高质量 IP
            # 缺点:需要维护 IP 池和健康检查
            self.proxy_pool = [
                "http://ip1:port", "http://ip2:port", "http://ip3:port",
            ]
            self.timeout = aiohttp.ClientTimeout(total=30, connect=8, sock_read=15)

        self.connector = aiohttp.TCPConnector(
            limit=100,
            limit_per_host=10,
            keepalive_timeout=30,
            enable_cleanup_closed=True,
            # 代理场景下关闭 keepalive 可避免复用到坏 IP 的连接
            force_close=self.mode != "tunnel",
        )

    def _get_proxy(self):
        if self.mode == "tunnel":
            return self.proxy_url
        else:
            return random.choice(self.proxy_pool)

    async def fetch(self, url, session):
        """带重试的请求,超时后换代理 IP 重试"""
        max_retries = 3
        backoff = aiohttp.ClientTimeout(total=30, connect=8, sock_read=15)

        for attempt in range(max_retries):
            proxy = self._get_proxy()
            try:
                async with session.get(
                    url, proxy=proxy, timeout=self.timeout
                ) as resp:
                    if resp.status == 200:
                        return await resp.text()
                    if resp.status in (429, 502, 503):
                        # 限流或代理 IP 被封,退避后重试
                        await asyncio.sleep(1 + attempt * 2)
                        continue
                    logger.warning(f"HTTP {resp.status}: {url}")
                    return None
            except aiohttp.ServerTimeoutError:
                # 隧道代理:下次请求自动换 IP
                # 短效代理:random.choice 会选另一个 IP
                logger.warning(
                    f"timeout attempt {attempt + 1}/{max_retries} "
                    f"proxy={proxy} url={url}"
                )
                await asyncio.sleep(0.5 * (attempt + 1))
            except aiohttp.ClientProxyConnectionError:
                # 代理连接失败,立即换 IP
                logger.warning(f"proxy dead: {proxy}")
                await asyncio.sleep(0.1)
            except aiohttp.ClientError as e:
                logger.warning(f"client error: {url} {type(e).__name__}: {e}")
                await asyncio.sleep(0.5)
        return None

注意:aiohttp 的 proxy 参数只支持 HTTP 代理(通过 CONNECT 方法建立隧道)。如果代理服务提供的是 SOCKS5 或 HTTPS 代理,需要用 aiohttp-socks 包:

# SOCKS5 代理需要 aiohttp-socks
from aiohttp_socks import ProxyConnector

connector = ProxyConnector.from_url("socks5://user:pass@host:port")
session = aiohttp.ClientSession(connector=connector, timeout=PROXY_TIMEOUT)

session 级别 vs request 级别超时

# 全局默认超时
DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30, connect=5, sock_read=10)

# 慢接口专用超时
SLOW_API_TIMEOUT = aiohttp.ClientTimeout(total=60, connect=5, sock_read=30)

# 快接口专用超时(对响应速度有要求的场景)
FAST_API_TIMEOUT = aiohttp.ClientTimeout(total=10, connect=3, sock_read=5)


async def mixed_requests():
    connector = aiohttp.TCPConnector(limit=100, limit_per_host=10)

    async with aiohttp.ClientSession(
        timeout=DEFAULT_TIMEOUT,
        connector=connector,
    ) as session:
        # 用 session 默认超时
        async with session.get("https://api.example.com/list") as resp:
            data = await resp.json()

        # 慢接口在 request 级别覆盖
        async with session.get(
            "https://api.example.com/report",
            timeout=SLOW_API_TIMEOUT,
        ) as resp:
            report = await resp.json()

        # 快接口在 request 级别覆盖
        async with session.get(
            "https://api.example.com/health",
            timeout=FAST_API_TIMEOUT,
        ) as resp:
            status = resp.status

生产级完整示例:带监控和熔断

import aiohttp
import asyncio
import logging
import time
from collections import defaultdict, deque

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)


class MetricsCollector:
    """请求指标采集:响应时间分位数、超时率、错误率"""

    def __init__(self, window_size=1000):
        self.latencies = defaultdict(lambda: deque(maxlen=window_size))
        self.timeouts = defaultdict(int)
        self.errors = defaultdict(int)
        self.total = defaultdict(int)

    def record(self, host, latency, timed_out=False, error=False):
        self.total[host] += 1
        if timed_out:
            self.timeouts[host] += 1
        elif error:
            self.errors[host] += 1
        else:
            self.latencies[host].append(latency)

    def get_p99(self, host):
        lats = sorted(self.latencies[host])
        if not lats:
            return None
        idx = int(len(lats) * 0.99)
        return lats[min(idx, len(lats) - 1)]

    def get_timeout_rate(self, host):
        t = self.total[host]
        return self.timeouts[host] / t if t > 0 else 0

    def summary(self):
        result = {}
        for host in self.total:
            result[host] = {
                "total": self.total[host],
                "p99_ms": round(self.get_p99(host) * 1000, 1) if self.get_p99(host) else None,
                "timeout_rate": round(self.get_timeout_rate(host), 4),
                "error_count": self.errors[host],
            }
        return result


class CircuitBreaker:
    """熔断器:超时率超阈值时暂停请求"""

    def __init__(self, threshold=0.3, recovery_time=60):
        self.threshold = threshold
        self.recovery_time = recovery_time
        self.failure_count = defaultdict(int)
        self.open_until = defaultdict(float)

    def is_open(self, host):
        now = time.monotonic()
        if self.open_until[host] > now:
            return True
        if self.open_until[host] > 0 and self.open_until[host] <= now:
            # 半开状态,允许试探
            self.open_until[host] = 0
            self.failure_count[host] = 0
        return False

    def record_failure(self, host):
        self.failure_count[host] += 1
        if self.failure_count[host] >= 3:
            self.open_until[host] = time.monotonic() + self.recovery_time
            logger.warning(f"circuit breaker opened: {host}")

    def record_success(self, host):
        self.failure_count[host] = 0
        self.open_until[host] = 0


class ProductionCrawler:
    """生产级异步爬虫:代理 + 超时 + 重试 + 监控 + 熔断"""

    PROXY_URL = "http://username:password@proxy.16yun.cn:9010"

    def __init__(self, concurrency=50):
        self.timeout = aiohttp.ClientTimeout(total=30, connect=8, sock_read=15)
        self.connector = aiohttp.TCPConnector(
            limit=concurrency,
            limit_per_host=10,
            keepalive_timeout=30,
            enable_cleanup_closed=True,
        )
        self.semaphore = asyncio.Semaphore(concurrency)
        self.metrics = MetricsCollector()
        self.breaker = CircuitBreaker(threshold=0.3, recovery_time=60)

    async def fetch(self, url, session):
        host = url.split("/")[2]

        # 熔断检查
        if self.breaker.is_open(host):
            logger.info(f"skipped (circuit open): {host}")
            return None

        async with self.semaphore:
            start = time.monotonic()
            for attempt in range(3):
                try:
                    async with session.get(
                        url, proxy=self.PROXY_URL, timeout=self.timeout
                    ) as resp:
                        latency = time.monotonic() - start
                        if resp.status == 200:
                            text = await resp.text()
                            self.metrics.record(host, latency)
                            self.breaker.record_success(host)
                            return text
                        if resp.status in (429, 502, 503):
                            await asyncio.sleep(1 + attempt * 2)
                            continue
                        self.metrics.record(host, latency, error=True)
                        return None
                except aiohttp.ServerTimeoutError:
                    logger.warning(f"timeout attempt {attempt + 1}: {url}")
                    await asyncio.sleep(0.5 * (attempt + 1))
                except aiohttp.ClientError as e:
                    logger.warning(f"error attempt {attempt + 1}: {url} {e}")
                    await asyncio.sleep(0.5 * (attempt + 1))

            # 所有重试用完
            self.metrics.record(host, 0, timed_out=True)
            self.breaker.record_failure(host)
            return None

    async def crawl(self, urls):
        async with aiohttp.ClientSession(
            timeout=self.timeout,
            connector=self.connector,
        ) as session:
            tasks = [self.fetch(url, session) for url in urls]
            results = await asyncio.gather(*tasks, return_exceptions=True)

        # 输出指标
        for host, stats in self.metrics.summary().items():
            logger.info(f"metrics {host}: {stats}")

        return results


if __name__ == "__main__":
    urls = [f"https://example.com/page/{i}" for i in range(500)]
    crawler = ProductionCrawler(concurrency=50)
    asyncio.run(crawler.crawl(urls))

超时与重试的量化分析

超时设太短再靠重试补救,本质上是把串行的等待时间拉长了:

# 场景:目标站点正常响应需要 4 秒

# 错误做法:total=2s + retry=3
# 每次 2s 超时 → 重试 → 2s 超时 → 重试 → 2s 超时
# 总耗时 6s,结果还是失败,白白浪费 3 次请求和 6 秒
BAD = aiohttp.ClientTimeout(total=2)

# 正确做法:total=30s + retry=2(仅对网络错误重试)
# 第一次请求 4s 成功,不需要重试
GOOD = aiohttp.ClientTimeout(total=30, connect=5, sock_read=10)

重试应该留给确定性失败(连接断开、代理不可用),而不是用重试来掩盖超时配置不当。

推荐配置矩阵

import aiohttp

# ┌─────────────────────┬────────┬─────────┬───────────┬───────────┐
# │ 场景                 │ total  │ connect │ sock_read │ 适用说明   │
# ├─────────────────────┼────────┼─────────┼───────────┼───────────┤
# │ 直连普通网站          │ 20     │ 5       │ 10        │ 响应 <2s   │
# │ 直连慢速网站          │ 60     │ 5       │ 30        │ 政府/查询   │
# │ 走代理普通网站        │ 30     │ 8       │ 15        │ 亿牛云隧道  │
# │ 走代理慢速网站        │ 90     │ 10      │ 40        │ 代理+慢站  │
# │ 走 SOCKS5 代理       │ 30     │ 10      │ 15        │ 额外握手   │
# └─────────────────────┴────────┴─────────┴───────────┴───────────┘

CONFIGS = {
    "direct_normal": aiohttp.ClientTimeout(total=20, connect=5, sock_read=10),
    "direct_slow": aiohttp.ClientTimeout(total=60, connect=5, sock_read=30),
    "proxy_normal": aiohttp.ClientTimeout(total=30, connect=8, sock_read=15),
    "proxy_slow": aiohttp.ClientTimeout(total=90, connect=10, sock_read=40),
    "proxy_socks5": aiohttp.ClientTimeout(total=30, connect=10, sock_read=15),
}

配置选型逻辑:

  1. 先确认链路类型(直连 / HTTP 代理 / SOCKS5 代理),决定 connect 基准值。
  2. 评估目标站点响应特征(静态 / 动态 / 慢查询),决定 sock_read 基准值。
  3. total 设为正常请求耗时的 3 到 5 倍,兜底防泄漏。
  4. 上线后看 P99 延迟和超时率,超时率 >5% 就调大 sock_read,连接池频繁耗尽就调大 limit 或调小 total

这些值是起点。每个目标站点的网络特征不同,最终参数应该基于实际监控数据(P50/P99 延迟、超时率、错误率)持续调整。