☰
爬虫采集遇521状态码:并非IP封禁而是CDN回源失败
2026/10/15 6:11:56 网站建设 项目流程

如果说有什么状态码能让爬虫老手都愣一下,521 绝对排得上号。很多朋友第一次遇到它,是在某个夜深人静的跑批任务里:睡前一分钟还好好的,第二天早上打开日志,满屏一片红,全是521。第一反应是“是不是对方把我封了?”然后开始换 User-Agent、加延时、伪装浏览器,折腾几个小时一点用都没有。

作为常年和 Python Requests 打交道的采集工程师,我踩过这个坑,也帮几个团队排查过同类问题。先说结论:521 在绝大多数情况下不是反爬,更不是封 IP,而是 CDN 边缘节点无法从源站拿到有效响应导致的回源失败。你的代码可能一点问题都没有,问题出在那台提供数据的源站服务器上。但 Requests 侧也确实有能做的事,比如重试策略、超时设置、连接池管理,这些做好了,完全可以把 521 的干扰降到最低。

这篇文章我会按照我实际排查的顺序来讲:先分清 521 到底是什么,再走一遍从 requests 报错到源站状态的完整链路,最后给出可以直接抄走的 Session 配置和异常处理方案。适合正在被 521 折磨、或者想提前把采集任务加固一遍的朋友,同样适合第一次写爬虫、还分不清 5xx 之间区别的新手。

1. 先给 521 定个性:这不是反爬,而是回源失败

1.1 一次完整请求到底经历了什么

在动手改代码之前,得先把 521 放在 HTTP 事务的坐标里看清楚。普通网站的结构是“浏览器/脚本直接连服务器”,但很多站点现在都会在前面挂一层 CDN,用户请求先打到 CDN 的边缘节点,再由边缘节点向后端源站转发,拿到响应后再返回给用户。

这个“边缘节点到源站”的过程,业内叫回源。如果边缘节点和源站之间连接失败、读超时、或者源站返回了不规范的响应,CDN 无法把一个正常的 2xx/3xx 内容交给用户,就会抛出一个 5xx。521 就是这类回源异常里比较典型的一个——它的含义很直接:CDN 已经成功接收了你的请求,但是它在请求源站时彻底失败了。

这也是为什么我说它和“封 IP”完全不是一回事。IP 被封,你得到的一般是 403、503,或者一个让你验证的中间页,因为请求根本没被放进去。而 521 出现时,请求已经正常穿过 CDN 那道门了,是源站这一环出了问题。换句话讲,你的身份、你的 IP、你的请求头可能都没什么嫌疑,问题发生在数据链条的最后一公里。

1.2 521 和 502、503、504 容易混,先记一张表

做排查时最忌讳的是凭感觉。521 不是 HTTP 标准状态码,而是 CDN 服务商自定义的,所以很多同行第一次看到它会懵。为了不混淆,我把类似的几个状态码放进一张表,这样遇到问题时可以快速对号入座:

状态码含义可能的表现为
502网关从上游收到了无效响应源站进程崩溃、返回空响应、后端超时
503服务暂时不可用源站过载、主动拒绝连接、维护中
504网关超时,上游没在规定时间内响应源站处理缓慢、数据库查询卡住
521CDN 连不上源站或源站没回应源站宕机、网络隔离、防火墙丢包、TLS 握手失败

注意 521 和 504 的区分。504 更偏向“连接已经建立,但等不到数据”,而 521 更像“连接本身没建立成功”。这两者的排查方向不一样:521 先看源站是不是挂了、防火墙有没有拦错 IP;504 则优先看源站是不是负载过高、后端有没有慢查询。

所以当你用 Requests 收到 521 时,第一件事不是改 headers,而是先确认:这是不是全站性的问题?换个浏览器打开那个页面,是不是同样 521?如果是,那你的爬虫只是恰好撞上了源站故障,和程序逻辑毫无关系。

2. 三天前还好好的:我的排查路线与结论

2.1 先用 curl 验证,别急着改 Python 代码

遇到 521 时,我习惯性的动作是丢下 Python,先打开终端跑一条最简单的命令:

curl -sS -o /dev/null -w "%{http_code}\n" -I https://example.com/some/path

把目标 URL 换成你实际采集的地址,然后观察返回值。如果 curl 也是 521,说明问题出在服务端链路,不用怀疑自己的代码;如果 curl 能正常 200,而 Requests 报 521,那才是需要在代码侧深挖的场景。

我遇到过一种非常迷惑的情况:curl 永远 200,Requests 换个 UA 也 200,但只要放到生产服务器的固定出口 IP 上,就开始大量 521。后来查出来是源站防火墙的规则有问题,把该出口 IP 所在的网段给误拦了,导致 CDN 回源时握手直接被丢。这个状态在外网完全看不出异常,只有换出口才能暴露。遇到这种场景,先别急着调 Requests,找运行环境网络的同学核对出网 IP、白名单才是最效率的做法。

2.2 回源故障的常用定位手段

如果确认 521 是全站或某个区域的问题,那要顺着“回源链路”往下查。对没有源站权限的爬虫工程师来说,能做的有限,但可以从两个维度辅助定位:

维度一:判断是不是源站整体失联。观察目标站点的主页、静态资源、图片是否全部 521。如果静态资源可以正常加载,只有某一个 API 或页面 521,说明源站服务本身活着,挂的是某个应用或某个接口。如果整个域名都是 521,那源站机器或者源站所在网络大概率出事了。

维度二:看 521 的持续时间。我自己的项目里以 10 分钟为观察窗口。如果 521 持续超过 10 分钟,大概率是源站真实故障,与爬虫压力无关;如果只在某个时间段出现,比如整点跑批时,或者在某个地区集中出现,那可能是回源路径上某个节点不稳定。这个信息对后续设计重试策略很重要,后面会细说。

2.3 为什么“换 IP、加头”在这里基本没用

很多人看到 5xx 就条件反射地认为是风控,于是花了大量时间换 User-Agent、模拟浏览器指纹、调请求间隔。但在 521 这个场景里,这些手段基本无效。因为边缘节点根本没把你的请求内容当问题,它只是作为中间人没找到源站,于是把源站的病痛转嫁给了你。

这不是劝你不要优化请求头,而是想说:先诊断清楚,再动手优化。我在实际项目里见过团队为了一个 521 告警连夜改代码,改完依然 521,最后发现是上游源站的云服务商在维护。那种感觉就像人感冒了,你非要给门锁换一把新钥匙,毫无意义。

3. 回到 Requests:为什么代码没变,却开始报 521

3.1 常见的一个误解:请求头不像会被“记住”

先解决一个特别容易误导人的地方:Requests 每次发请求时,连接都是独立的还是复用的?这个问题的答案直接关系到 521 的表现。

如果你在循环里直接requests.get(),底层会为每一个请求创建新的连接;如果你用了requests.Session(),默认会复用 TCP 连接。521 这类回源故障和单次连接关联很强,用 Session 能让每次请求的 TCP 握手次数变少,间接降低触发回源超时的概率。这不是玄学,而是因为回源链路本身就脆弱,少一次握手就少一次失败机会。

来看一段很多人会写的最小复现代码:

import requests urls = ["https://example.com/page/1", "https://example.com/page/2"] for url in urls: resp = requests.get(url, timeout=5) print(resp.status_code)

这段代码的问题在于:每个 URL 都会走一次完整的 DNS 解析、建立新连接、再发起请求。如果源站本身就不稳定,连十次可能有三次在建立连接阶段就失败,从而产生 521。用 Session 改写后,TCP 连接被反复利用,失败率肉眼可见地下降。

3.2 连接超时和读取超时要分开设计

Requests 的timeout参数很多人只写一个数字,比如timeout=5,意思是连接超时和读取超时共用 5 秒。这在网络稳定的环境下没问题,但源站一旦变慢,5 秒内还没拿到 body,Requests 就会抛异常,日志里记录的可能是ReadTimeout,也可能最终被包装成 521。

更合理的写法是拆分:

resp = session.get( url, timeout=(3.05, 10) )

第一个数字是连接超时(连 TCP 套接字的最长时间),第二个数字是读取超时(收到响应体数据块的最长等待时间)。连接超时给短一点,是为了快速失败;读取超时给长一点,是为了容忍源站的慢处理。这个组合对 521 尤其重要——很多 521 正是源站处理太慢,CDN 等不下去了,在源头就断掉连接。

3.05 这个数字不是随手写的,它满足一个常见的底层超时规则:可以把 3.05 理解为“3 秒 + 少量余量”,这样在极端情况下既能快速失败又不至于过于激进。你可以根据自己的场景调整,但一定要把两个时间分开。

3.3 证书验证、HTTP/2、重定向对 521 的间接影响

还有一个容易被忽略的细节:TLS 握手。Requests 默认会校验证书,如果源站 TSL 证书链不完整,CDN 边缘节点在回源时也会报错。这种情况下,你在自己电脑上访问可能一切正常,因为本地操作系统里的根证书更全;但 CDN 节点上的证书校验可能更严格,导致边缘节点回源时握手失败,最终返回 521。

遇到这种问题,爬虫侧能做的有限,但至少可以通过verify=False排除自己本地的校验干扰,快速判断问题范围:

resp = session.get(url, verify=False)

这只是排查手段,不建议长期关掉证书校验。如果在关掉之后依然 521,那就不是证书问题;如果关掉之后恢复正常,那问题定位就清晰了——要么是证书链没配全,要么是源站只欢迎特定 TLS 版本。

至于 HTTP/2,Requests 默认使用的 HTTP/1.1。某些源站会在 HTTP/2 连接上表现更稳定,但你一般没必要为了一个 521 去引入额外的第三方库。先把超时、重试、Session 这些基础项做好,收益要高得多。

4. 从 Session 到重试策略:一套能落地的防御写法

4.1 用 Session 一次性搞定连接池和请求头

既然 521 和连接稳定性强相关,那第一个操作就是把散装的requests.get改成 Session。Session 内部维护了一个连接池,同一主机的连接可以被复用,降低握手的频率,也能让 TCP 连接保持 warm 状态。

同时建议把请求头统一维护在 Session 里,保证老实稳定:

import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive", })

这里把 User-Agent 换成常用浏览器的值,只是为了让服务端正常识别请求来源,同时配合Connection: keep-alive让 TCP 连接尽量保持。还有一点要说清楚:不要随意复制那些“伪装成真实浏览器”的完整超长 header 列表,很多字段在服务端看来反而很可疑。平时采集公开页面,保持少量、合理的请求头就够了。

4.2 给连接池和重试加上“安全带”

Session 用上了还不够,因为 521 本质是回源层在做 SMART 判断,失败一次不能代表永远失败。合理重试能帮你扛过源站的短暂抖动,前提是重试必须讲策略。

配合urllib3的 Retry 类是标准做法:

from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry( total=3, # 总共重试 3 次 connect=3, # 连接失败重试 3 次 read=3, # 读取失败重试 3 次 status=3, # 状态码触发时重试 3 次 status_forcelist=[521, 502, 503, 504], # 仅重试明确的 5xx backoff_factor=0.8, # 退避因子,指数增长 respect_retry_after_header=True, ) adapter = HTTPAdapter( max_retries=retry, pool_connections=10, pool_maxsize=20, ) session.mount("https://", adapter)

几个参数的解释:

  • status_forcelist只放进 5xx 和 521,不要放 403、404、429。403 经常是账号权限或真实风控,重试无用;404 是资源不存在,重试只会浪费额度;429 是限流,重试间隔要尊重响应头里的 Retry-After。
  • backoff_factor=0.8意味着重试间隔分别是 0.8、1.6、3.2 秒,指数增长。这个间隔能有效降低对源站的瞬时压力。
  • respect_retry_after_header=True是为了让 urllib3 尊重服务端给你的 Retry-After 头,如果源头明确说了“请 10 秒后再来”,那就老老实实等。

这一套写好后,遇到短时 521 会自动重试,日志里就少了很多“惊悚时刻”。

4.3 把“重试”和“幂等”一起考虑

为什么重试可以放心做?因为 GET 请求天然是幂等的:同一次请求发出多次,结果不会因为重试而改变状态。所以爬虫里的重试可以比较大胆。但如果涉及带参数的 POST 请求,就要小心了——同一个请求可能触发服务端重复处理。大部分采集任务都是 GET,但如果你真在用 POST 抓取,重试策略就保守一点,比如只重试连接层异常,不要重试读取层异常。

还有一个容易踩的坑:重试次数过多,会让源站的负载雪上加霜。本来源站就因为某个瞬时问题在 521,你的重试请求在短时间内又把连接池打满,就会形成“越重试越 521”的恶性循环。所以我在生产环境里把total控制在 3 以内,最多别超过 5,宁可把任务留到下一轮跑,也不在一个周期内反复冲击。

5. 多线程变慢之后为什么容易复现 521

5.1 并发越高,越要关注连接池上限

很多团队用ThreadPoolExecutor或者asyncio并发抓取同一站点。单线程跑没什么问题,一旦并发提升到 20、30,521 就开始冒头。这不是因为对方故意封你,而是源站处理能力跟不上,CDN 回源排队时间太长,继而超时。

Requests 的Session自带连接池默认上限是 10(pool_connections)。如果你用 20 个线程共享同一个 Session,会有部分请求等不到空闲连接而阻塞,表现就是变慢;如果你每个线程单独建 Session,那连接池上限就形同虚设,瞬间打到源站的连接数会非常高。

我的习惯是把并发控制在 5 到 10 之间,配合HTTPAdapter的pool_maxsize保持一致。比如开 8 个线程,就把pool_maxsize设成 16 左右,留一点余量。别贪多——采集这个事,稳定比速度重要。你用 50 个线程把一个普通页面并发拉 5000 次,源站的 CPU 和带宽都会报警,结果就是把本来好好的回源链路压垮,随后就是一大批 521。

5.2 请求频率要做“平滑”,而不是“固定间隔”

还有一个细节是很多新手忽略的:固定间隔请求有时候比突发请求更容易把源站搞垮。比如设置time.sleep(1),每个线程每秒发一次请求,最终形成的请求序列可能是“0 秒、0.1 秒、0.3 秒、1 秒、2.1 秒”,并不平滑。多个线程叠在一起,极容易形成小范围的突发峰值。

换个做法,在循环里加入随机抖动:

import random import time # 在请求之间随机等待 0.5~2 秒 time.sleep(random.uniform(0.5, 2.0))

这不算什么高级操作,但能把请求间隔打散,避免整点撞车。这个对 521 的帮助是间接的:源站更不容易被瞬时流量冲击,CDN 回源成功率就稳了。

5.3 失败请求要退避,而不是立刻重试

即便有了上面的重试策略,也要在业务代码里控制“失败后的下一次尝试时机”。比如某个页面连续两次 521,你就应该让线程停 5 秒、10 秒,而不是立刻再打第三次。直接在业务层加一句:

if resp.status_code == 521: time.sleep(5) # 重新加入待处理队列,而不是原地重试

这和我前面说的 Retry 有配合关系:HTTPAdapter的重试解决的是单个请求内部的瞬时失败,而业务层的退避解决的是“这个页面当前不可用”的系统性失败。两者不能互相替代。

6. 长线稳定运行的采集中台设计

6.1 状态码不是唯一信号,内容校验才是

当采集任务长了,你会发现状态码 200 也不代表抓到了正确页面。源站出问题时,CDN 可能会返回一个 521 页面,也可能返回一个 200 状态的“网络异常”提示页。如果程序只看状态码,就会把异常页当成正常数据处理,导致数据污染。

靠谱的做法是再加一层内容校验。比如你抓的是一个新闻列表页,校验里面有没有文章标题结构;抓的是详情页,就校验正文长度是否大于某个阈值。在 Requests 拿到响应后做一次快速断言:

def check_page_content(html: str) -> bool: # 假设页面必须包含 <article> 和至少 200 个字符 if "<article" not in html or len(html) < 200: return False return True

只有校验通过才记录成功,否则把它当失败重试。这样就算源站偶尔返回“假 200”,也不会污染最终数据。

6.2 日志里至少要有这几个字段

采集任务翻车时,最怕的是日志里只有一行521,你连是哪个 URL、哪次重试、当时响应头是什么都不知道。所以我强烈建议把日志结构化采集中台里常见的几个字段:

字段示例用途
url/api/news/list快速定位失败地址
status_code521状态码
attempt1 / 3第几次重试
elapsed_ms2340请求耗时
exceptionReadTimeout异常类型
proxy_ip出网 IP 标识确认是否受出口影响

我之前排查一个持续数小时的 521,就是靠日志里的出口 IP 字段发现,只有某台机器的出口网段持续失败,其他机器完全正常。这个信息如果只靠代码提交记录去猜,可能还要多折腾一整天。

6.3 定时任务要设计“熔断”,别让坏页面刷屏

最后提一个容易被忽略的设计:熔断。如果连续 N 次请求同一个 URL 都返回 521,说明大概率不是瞬时问题,而是源站正在故障或者你的代码逻辑有误。这时候继续重试就是浪费时间。

我的做法是:

fail_count = 0 MAX_FAIL_COUNT = 5 if resp.status_code == 521: fail_count += 1 if fail_count >= MAX_FAIL_COUNT: # 把该 URL 放入“待观察队列”,暂停重试 10 分钟 time.sleep(600) fail_count = 0

当然,不同站点故障恢复时间不同,10 分钟这个值需要根据自己的调度周期调整。思路是:别在源站明显故障时还死死地请求同一个资源,先把它隔离掉,让采集任务整体继续跑,最后再集中处理失败队列。

另外,无论什么场景,都建议只采集你有权访问的公开数据,尊重目标站点的速度和资源,如果站点方希望降低频率,就调整策略配合。毕竟爬虫工程是一门平衡术,效率和稳定之间,长期来看稳定更重要。

如果真的卡在 521 上,我最后的建议是:先别急着改代码,打开浏览器看一眼目标页面是不是同样报错。如果浏览器也 521,那你去优化 Requests 只是白费力气;如果浏览器正常而 Requests 异常,再按上面的排查链路一步步走,大概率能很快定位到问题所在。祝采集顺利,少一点凌晨三点被日志吵醒的经历。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询