2026-07-23 17:06
标签:

超时设 3 秒,跑半小时成功率从 98% 掉到 85%。目标站点 RTT 正常,代理 IP 存活,但大量请求抛ServerTimeoutError。问题出在超时阈值与实际网络时序不匹配,把正常的处理延迟当成了网络故障。aiohttp 超时的实现机制aiohttp 的ClientTimeout底层依赖 asyncio 的定时器协程。每一层超时对应一个独立的asyncio.wait_forloop.call_later调度,触发时取消对应的协程并抛出asyncio.TimeoutError,aiohttp 再包装成aiohttp.ServerTimeoutError三层超时的触发顺序和覆盖关系:关键点:sock_read计的是间隔不是总量。服务器花 8 秒生成页面,但生成后一次性返回,sock_read=10不会触发。但如果服务器先返回头,然后 11 秒后才返回 body,sock_read=10会触发。connect 超时:握手时序分析握手时序分解:走代理时connect阶段涉及多段 RTT 叠加,这是代理场景下connect需要调大的根本原因。sock_read 超时:事件循环与读事件aiohttp 的读取流程:sock_read只在read()阻塞期间计时。数据一到,read()返回,计时器取消,下一次read()重新开始计时。所以设 10 秒不影响 200ms 内完成的正常请求。容易误杀的场景:

场景

服务端行为

sock_read=3s 的结果

动态页面渲染

2-5s 无数据,然后一次性返回

误杀

数据库查询接口

3-8s 无数据,然后返回

误杀

chunked 分块传输

每 4s 发一块

误杀

大文件下载

持续有数据流

正常

静态页面

200ms 内返回

正常

total 超时与连接池的交互total超时和连接池容量直接相关。TCPConnectorlimit控制最大并发连接数,如果一个请求因为服务端慢速响应而长时间不释放,就会挤占其他请求的连接配额。代理链路超时分析代理引入的额外延迟分布在connectsock_read两个阶段:亿牛云提供两种代理模式,超时策略不同:注意:aiohttp 的proxy参数只支持 HTTP 代理(通过CONNECT方法建立隧道)。如果代理服务提供的是 SOCKS5 或 HTTPS 代理,需要用aiohttp-socks包:session 级别 vs request 级别超时生产级完整示例:带监控和熔断

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

import aiohttp

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),

}

配置选型逻辑:先确认链路类型(直连 / HTTP 代理 / SOCKS5 代理),决定connect基准值。评估目标站点响应特征(静态 / 动态 / 慢查询),决定sock_read基准值。total设为正常请求耗时的 3 到 5 倍,兜底防泄漏。上线后看 P99 延迟和超时率,超时率 >5% 就调大sock_read,连接池频繁耗尽就调大limit或调小total这些值是起点。每个目标站点的网络特征不同,最终参数应该基于实际监控数据(P50/P99 延迟、超时率、错误率)持续调整。


评论