☰
requests进阶实战:Session、重试、代理与反爬应对全攻略
2026/10/1 1:24:39 网站建设 项目流程

写爬虫的朋友大多有这种经历:第一篇文章里把 requests 的 GET、POST、响应解析都跑通了,心想“这不挺简单嘛”,结果一上真实目标网站,不是超时就是 403,再不然就是爬到一半被封 IP。于是很多人开始怀疑是不是自己代码写错了。很多时候代码真没错,只是你对“请求库”的理解停在了调接口那一步。作为爬虫请求库使用系列的第二篇,这篇不讲怎么发 GET 请求,而是把真正影响爬虫稳定性的东西拿出来聊透:Session 会话管理、超时与重试、请求头伪装、并发控制、代理 IP 使用,以及验证码和反爬的应对思路。适合已经会基本 requests 用法、但想在真实项目中把爬虫跑稳跑快的读者,看完可以直接照着改自己的代码。

1. Session 不只是一块“饼干桶”,它决定了爬虫的连接效率

1.1 为什么我强烈建议用 Session 而不是裸调 requests.get

很多人写爬虫图省事,每次都直接requests.get(url),看起来代码简洁,实际上效率非常低。每次调用 requests 的函数,底层都会新建一个完整的 HTTP 连接,做完请求后立刻断开。对单次请求来说没感觉,但等你处理几百上千个 URL,最耗时间的往往不是服务端响应,而是反复建立 TCP 连接的三次握手开销。

用 Session 最大的变化是底层连接会被复用。Session 对象内部维护了一个 urllib3 的连接池,同域名下的多次请求可以共用连接,省去了重复握手的时间。我实测过一个简单的对比:同样访问 200 个同域名 URL,裸 requests 花费 40 多秒,换成 Session 后稳定在 12 秒上下。这不是极端场景,而是爬虫最常见的模式——查列表页、进详情页、下载资源,全是同域名。

除了连接复用,Session 还会自动保存服务端 Set-Cookie 的 Cookie。这意味着你登录一次,后续请求都带着身份。裸请求需要每次手动处理 Cookie,稍不留神就丢登录态,没必要给自己挖这个坑。

1.2 Session 底层把连接池藏在了哪里

Session 底层用的是 urllib3 的PoolManager,连接池默认每个主机最多保有 10 个空闲连接,超过限制的请求会排队等连接释放。对于多数爬虫,10 个连接已经够用,但如果你开了并发且请求密集,可能会出现Connection pool is full, discarding connection这类日志。这其实不是错误,只是 urllib3 告诉你连接池满了,新连接用完后不会回池,直接销毁。

你可以通过调整 HTTPAdapter 来改变连接池大小:

import requests from requests.adapters import HTTPAdapter session = requests.Session() adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20) session.mount('https://', adapter) session.mount('http://', adapter)

pool_connections是缓存到不同主机的连接池数量,pool_maxsize是单个主机连接池上限。做并发爬虫时,把这两个值调到和并发数同级,能减少连接被反复创建销毁的损耗。

有一点容易踩坑:Session 不是线程安全的。多个线程共用一个 Session 实例做并发请求,会偶发 ConnectionError 或读到串掉的响应。我的做法是每个线程创建自己的 Session,或者用 threading.local 包装一下,让每个线程持有独立 Session。代码不复杂,但能避免很多诡异的连接问题。

1.3 实战:登录后保持会话的典型流程

一个典型的登录态爬虫流程长这样:

import requests session = requests.Session() # 先访问一次登录页,拿到必要的 cookie 和 token login_page = session.get('https://example.com/login') token = extract_csrf_token(login_page.text) # 从页面或接口里提取 token # 构造表单数据,模拟登录 payload = { 'username': 'your_account', 'password': 'your_password', 'csrf_token': token, } resp = session.post('https://example.com/login', data=payload) # 登录成功后,直接带着 session 访问需要身份校验的页面 profile = session.get('https://example.com/profile') print(profile.status_code)

这里有个细节值得留意:很多站点登录接口会校验 Referer 和 Origin,如果你的请求没有这两个头,服务端会直接拒绝。Session 里一旦设置了通用 Headers,所有子请求都会自动带上,这就比裸请求逐条传参优雅得多。

还有一个我犯过的错误:把账号密码直接打在 URL 或日志里。登录请求一旦出错,错误堆栈可能包含整个请求对象,密码就跟着日志泄漏了。建议在打日志前把敏感字段洗掉,养成习惯对后续维护非常有帮助。

2. 超时、重试与异常兜底,是爬虫稳定运行的生死线

2.1 超时参数里的连接超时和读取超时

很多初学者不写 timeout,认为“不设置最保险,服务器慢我就等”。现实是:一个不设置超时的爬虫,只要目标网站有一个连接长时间不响应,整个爬虫就会卡死在那里。我见过最夸张的情况是一个坏连接让任务卡了 30 分钟,几百个线程全堵在等待上。

requests 的 timeout 参数可以传单个浮点数,也可以传一个二元组:

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

第一个值是连接超时,表示从本地到目标服务器建立 TCP 连接的最长等待时间;第二个是读取超时,表示服务端返回数据包之间的最长间隔。实际项目中,我通常设置连接超时 3~5 秒,读取超时 10~15 秒。连接超时设置太短,在弱网环境容易误伤正常请求;太长则会让异常请求占用大量线程资源。读取超时则要根据目标网站响应速度调整,图片资源多的站点通常会慢一些。

2.2 Retry 怎么配才科学,而不是盲目重试 3 次

requests 本身没有重试机制,但它底层依赖 urllib3,而 urllib3 提供了 Retry 类。通过 HTTPAdapter 挂载进去,就能实现自动重试:

from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry_strategy = Retry( total=3, connect=3, read=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount('https://', adapter)

这里重点说backoff_factor。它控制重试间隔,规则是sleep = backoff_factor * (2 ** (retry_number - 1))。如果 backoff_factor=1,第一次重试前等 1 秒,第二次等 2 秒,第三次等 4 秒,呈指数退避。设成 0 则表示重试之间不等待,遇到服务端繁忙时反而加剧对方压力,容易触发封禁。设置 1 或 0.5 是比较折中的方案。

还要注意一点:status_forcelist 里的状态码只对幂等请求安全。GET 请求重试没问题,但 POST 请求一旦服务端已经处理完事务,重试可能导致数据重复提交。对于登录、提交订单这类接口,建议少用或不用自动重试,宁可把失败写进日志人工处理。

2.3 异常兜底怎么写,才不会让爬虫死在第一行

requests 抛出的所有异常都继承自requests.exceptions.RequestException,所以你可以在外层捕获这个大基类,但这会丢失错误细节。更细的捕获方式是按异常类型区分:

import requests from requests.exceptions import ConnectionError, Timeout, ProxyError, SSLError try: resp = session.get(url, timeout=(3, 10)) resp.raise_for_status() except Timeout: # 超时通常可以重试,但要控制次数 logger.warning(f'timeout: {url}') except ConnectionError: # 连接被重置、DNS 解析失败等,往往需要换代理或换策略 logger.error(f'connection error: {url}') except ProxyError: # 代理失效,需要从代理池移除当前代理 logger.error(f'proxy error: {url}') except SSLError: # 证书校验失败,可以先 disable_warnings 再 verify=False 临时规避,但要有安全评估 logger.error(f'ssl error: {url}') except requests.exceptions.RequestException as e: logger.exception(f'unknown request exception: {e}')

实际项目中,我建议把“请求”封装成独立函数,配合重试装饰器或循环逻辑,统一返回响应对象或 None,由上游判断是否重新入队。这样爬虫主体代码不会被 try/except 淹没,逻辑也清晰得多。

3. 请求头伪装与反爬识别基础,别等 403 了才想起来

3.1 有经验的爬虫会伪装哪些请求头

目标网站的反爬最先看的就是 User-Agent。爬虫默认的 UA 长这样:python-requests/2.31.0,服务端一眼就能识别。最简单的伪装是改成浏览器 UA,但只改 UA 远远不够。

从抓包工具看一次真实浏览器请求,你会发现请求头里有大量的字段:Accept、Accept-Language、Accept-Encoding、Referer、Origin、Sec-Fetch-* 等。这些字段组合起来才能构成一个“正常的浏览器画像”。多数站点校验的重点是这几个:

请求头作用建议
User-Agent标识客户端类型使用项目对应浏览器的最新 UA
Referer来源页面保持和实际访问路径一致
Origin请求来源站点跨域请求时尤其重要
Accept-Language语言偏好设置为目标站点对应语言
Accept-Encoding支持的压缩算法注意 requests 默认解压 gzip,无需干预

构造 Headers 最省事的办法是从浏览器控制台 Network 面板复制某个页面请求,右键 Copy as cURL,再转换成 Python 代码。这样你能拿到当前浏览器完整的请求头、Cookie、可能还有签名参数。不过要注意 Cookie 会过期,不能一个 Header 用到天荒地老。

3.2 别只改 UA,真实反爬的维度比你想的多

很多新手伪装请求头之后依然被反爬识别,就开始怀疑 Headers 写错了。其实服务端的反爬识别是综合判断的,UA 只是最表层的一环。我看到的热门话题里,反复出现“爬虫 ip代理”“爬虫验证码”“playwright 相比于直接解码爬虫的优点”,说明大家已经被反爬搞到头疼了。

反爬维度大致可以拆成下面几个层次:

  • IP 维度:同一 IP 频繁访问同一域名,最容易触发频率限制。
  • 请求头维度:UA、Referer、Cookie 是否合规。
  • 行为维度:请求间隔是否规律、访问路径是否符合站内导航逻辑、有没有访问 robots.txt 之类的试探性请求。
  • 浏览器指纹维度:UA、Canvas、WebGL、时区、字体等组合成指纹。
  • TLS 指纹维度:基于 TLS 握手包特征识别客户端,requests 库的 TLS 指纹非常典型,大厂风控基本都能识别。
  • JS 渲染维度:页面内容由 JS 动态生成,直接请求 HTML 拿不到有效数据。

requests 用的是 Python 自带的 http.client 和 urllib3 的 SSL 实现,它的 TLS 指纹和真实浏览器差别很大。所以用 requests 做简单站点没问题,一旦遇到 TLS 指纹校验,就会遇到“代理换了、UA 改了,还是 403”的怪圈。这种时候可以考虑 curl_cffi 这种能模拟浏览器 TLS 指纹的库,或者直接上 playwright。playwright 解决的是 JS 渲染和浏览器指纹问题,代价是慢、内存占用大,更适用于中小规模采集或对抗复杂反爬的场景。

3.3 实践:一个能直接落地的请求头模板

import random from fake_useragent import UserAgent ua = UserAgent() def build_headers(referer=None, cookie=None): headers = { 'User-Agent': ua.random, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', } if referer: headers['Referer'] = referer if cookie: headers['Cookie'] = cookie return headers

fake_useragent 库每次调用ua.random都会随机返回一个浏览器 UA,能有效避免单一 UA 被识别。但依赖这个库有个隐患:它每次会从远程拉取最新 UA 列表,网络差时会抛出异常。稳妥做法是第一次运行后把 UA 列表缓存到本地,后续从本地读取。

Cookie 的获取方式我建议用 Session + 登录流程自动记录,而不是手动从浏览器复制。自动记录的 Cookie 会随 Session 存活,过期后可以再走一遍登录刷新流程。手动复制虽然简单,但有效期短,而且一旦站点调整 Cookie 策略,你的爬虫就要跟着改代码,维护成本高。

4. 限速、并发与代理,爬虫的效率和安全如何兼得

4.1 爬虫并发设计到底哪个好,别被框架迷了眼

热门话题里“爬虫并发设计到底哪个好”讨论度很高,我也被问过很多次。这里直接给结论:对于中小型采集任务,ThreadPoolExecutor + requests是最省心、最可控的方案。它的优点是不需要引入新依赖,排错直观,线程池大小能精确控制并发数。

那 asyncio 是不是更好?aiohttp的性能确实比 requests 高,但 Python 的异步编程写起来比同步代码复杂,遇到第三方库不支持异步更是折磨。只有当你的目标网站响应非常快、需要大量并发请求时,异步方案才值得投入。

再看 Scrapy。Scrapy 的功能非常全面,自带去重、调度、中间件、并发控制,分布式扩展也很成熟。但它的学习曲线陡峭,而且对于几十个页面到几千个页面的任务来说,Scrapy 的工程化配置反而会拖慢进度。我的判断标准是:任务量在十万级以下、目标网站反爬不复杂的,直接用 ThreadPoolExecutor;数据量巨大、需要分布式抓取的,直接学 Scrapy。团队合作项目可以优先考虑 Scrapy,因为它的组件分工清晰,其他人接手容易。

4.2 一个简单但可靠的并发模板

下面这个模板是我做中小型采集任务常用的结构,核心是用 ThreadPoolExecutor 限制最大并发数,同时用信号量做二次限流:

import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed import requests MAX_WORKERS = 10 SEMAPHORE = threading.Semaphore(5) # 同时最多 5 个请求在飞 def fetch(url): with SEMAPHORE: resp = requests.get(url, timeout=(3, 10)) return resp.status_code, url urls = [f'https://example.com/page/{i}' for i in range(100)] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: future_map = {executor.submit(fetch, url): url for url in urls} for future in as_completed(future_map): url = future_map[future] try: status, url = future.result() print(status, url) except Exception as e: print(f'failed: {url}, error: {e}')

这里的信号量很有意思。线程池设置 10,但同一时间只允许 5 个请求在网络上飞,相当于给并发数加了第二层保险。为什么这么做?因为线程池控制的是“任务并发”,而请求库实际发起的网络连接可能比你预期的更多。当涉及代理 IP 时,代理服务商通常按并发连接数计费或限流,信号量能帮你精准控制 QPS。

一次跑大量 URL 时,建议把并发数控制在 5~20 之间。目标网站性能差、服务器带宽小的情况下,并发开大了不仅容易封 IP,还可能直接把对方的服务拖垮。爬虫的本质是模拟多个用户访问,不是用大水管冲垮站点,作为从业者这点自觉还是要有的。

4.3 限速与优雅退避,别让爬虫变成“攻击”

限速最简单粗暴的方法是time.sleep(1),每个请求之间固定间隔。但固定间隔本身也是一种特征,反爬系统会识别出“这个访问频率过于稳定,不像人类”。更合理的方式是在基础间隔上加入随机抖动:

import random import time BASE_DELAY = 1.5 JITTER_RANGE = (0.5, 2.0) def polite_delay(): time.sleep(BASE_DELAY + random.uniform(*JITTER_RANGE))

每次请求前调用一次,整体节奏会自然很多。如果遇到 429 状态码,说明已经被限流了,这时候不要再急着重试。正确的做法是先看响应头里的Retry-After字段,按服务端指定的时间等待。没有 Retry-After 的情况下,可以用 30 秒到 5 分钟的指数退避,逐步拉开请求间隔。

还有一种叫令牌桶的限速算法,适合需要精确控制速率的场景。思路是桶里最多放 N 个令牌,每次请求取走一个令牌,令牌按固定速率补充。桶空了就说明请求频率超过了设定值。Python 里可以用ratelimit库实现,但很多场景用带抖动的 sleep 已经足够,不必把简单问题复杂化。

4.4 代理 IP 的使用与踩坑记录

代理是爬虫绕不开的话题,尤其当你采集的目标站点有严格 IP 频率限制时。requests 使用代理非常简单,只需要传入 proxies 参数:

proxies = { 'http': 'http://user:pass@ip:port', 'https': 'http://user:pass@ip:port', } resp = requests.get(url, proxies=proxies, timeout=(3, 10))

这里有几个容易踩的坑。一是记得同时配置 http 和 https,漏掉哪个就会导致对应协议的请求直连,IP 直接暴露。二是代理获取最佳的会话时长,很多代理服务商提供的代理 IP 有效期只有几分钟到几十分钟,过期后要继续调用接口重新提取。三是必须验证代理连通性再使用,不能拿到手直接上生产环境。

def check_proxy(proxy, test_url='https://httpbin.org/ip', timeout=5): try: resp = requests.get(test_url, proxies=proxy, timeout=timeout) if resp.status_code == 200: return True except Exception: return False return False

代理池的设计可以围绕“提取 -> 检测 -> 入库 -> 调度”来做。提取接口拿到原始代理列表后,先做一轮基础连通性检测,能用的放进 Redis 或内存队列;爬虫每次取代理时做轮换,失败时标记淘汰。代理质量比数量重要得多。大量不可用代理混在池子里,反而会让系统反复重试,拖慢整体速度。

另外,代理服务商之间质量差异很大,不要只看价格。免费代理基本不靠谱,不是速度慢就是随时失效,只能用来做测试。做正式项目,选稳定服务商是投资,不是成本。这也是我在实际操作中最大的感受:免费的东西往往才是最贵的。

5. 常见问题与排查技巧实录,把“意外”变成“预案”

5.1 错误速查表:看到异常不用慌

我把这几年爬虫开发中遇到的高频异常整理成了速查表,遇到问题先对照一下:

异常信息可能原因排查与解决方案
ConnectionError目标站点不可达、域名解析失败、连接被重置检查网络、换个代理、确认域名可访问
Timeout服务端响应慢、连接被墙、代理不稳定调大读取超时、换代理、减少并发数
SSLError: CERTIFICATE_VERIFY_FAILED证书校验失败补证书链,或 verify=False(要评估安全性)
403 ForbiddenIP 被封、请求头被识别、需要登录换代理、更新 UA/Headers、检查登录态
404 Not FoundURL 规则不对、页面被删除检查 URL 拼接逻辑、抓取当前页面确认地址
JSONDecodeError响应不是 JSON 却调用 .json()先打印 resp.text 前 500 字符确认返回内容
TooManyRedirects重定向循环配合 allow_redirects=False 手动处理
ProxyError代理连接失败、代理认证失败检查代理格式与账号密码、重新提取代理

遇到异常,第一件事是看实际响应内容,而不是只看状态码。很多站点的反爬页面返回 200,但响应体里是一段 JS 挑战脚本或者一个跳转提示。这种时候用resp.text或resp.content打印前几百个字符,基本能判断是哪种反爬策略。

5.2 爬虫验证码卡住怎么办

再说验证码。这是爬虫老手听了都头疼的环节,也是热搜词里“爬虫验证码”频繁出现的原因。验证码的类型基本分三种:

  • 字符/数字识别型:简单,可以用 OCR 识别,也可以用打码平台。
  • 滑块验证码:需要计算缺口位置并模拟拖动轨迹。
  • 行为验证码(点选、无感): 复杂,需要分析 JS 逻辑或使用 Playwright 模拟真实操作。

我的经验是,能不碰验证码就不碰验证码。大多数站点是在检测到异常访问后才弹出验证码,通过限速、换代理、保持合理请求头,很多时候能直接把验证码出现的概率降下来。一旦频繁出现验证码,说明你的访问模式已经被标记了,这时候不是继续硬刚,而是停下来审视自己的请求特征哪里不像正常人。

如果实在绕不过,打码平台是性价比最高的解决方案。这类平台通常提供接口,把验证码图片上传,返回识别结果。接入成本低,字符型验证码识别率能到 95% 以上。滑块验证码则要复杂一些,可以先试试 Playwright,用浏览器环境自动完成滑动操作。注意,这里不是鼓励暴力破解任何机制,而是谈技术路线上的合理选择。真正高效的做法是让爬虫更“克制”,不要频繁触发风控。

5.3 从“采集完就完事”到可维护的项目化改造

很多爬虫项目最终失败不是没爬到数据,而是爬到一半跑挂了没人知道。请求库用法只是基础,真正决定项目能不能长期运行的是工程化能力。

我建议在请求层之上做三件事。第一,日志记录:每个请求的 URL、状态码、耗时、代理信息落到日志里,方便事后复盘。第二,失败任务入队:请求失败的 URL 不直接丢弃,放进 Redis 队列等待重试,避免漏数据。第三,URL 去重:用 Redis 的 Set 或者布隆过滤器做去重,防止重复抓取浪费资源。

一个简单的失败重试队列示例:

import redis r = redis.Redis(host='localhost', port=6379, db=0) def push_failed(url): r.sadd('failed_urls', url) def pop_failed(): return r.spop('failed_urls')

这套结构简单,但效果非常明显。我接过一个维护了三年的项目,核心逻辑就是这么朴素的队列加去重。没有花哨的设计,但正是这些基本功,让爬虫能稳定跑几个月不用人工干涉。稳定的项目从来不靠炫技,靠的是把边界情况都处理到位。

5.4 从 requests 到更广阔的爬虫技术栈

requests 是爬虫入门的必经之路,但它绝不是终点。当你把 Session、重试、代理、并发这些基础都吃透之后,就会发现 requests 的边界其实很明显:它处理不了 JS 渲染,TLS 指纹容易被识别,对大并发场景的支持也没有异步库那么细腻。

到了这个阶段,有几个方向值得探索。一是 httpx,它同时支持同步和异步,API 和 requests 很像,迁移成本低。二是 curl_cffi,专门解决 TLS 指纹模拟问题,用起来和 requests 基本一样。三是 Playwright,适合需要真实浏览器环境的场景,比如处理复杂 JS 渲染和高级验证码对抗。四是 Scrapy,当你的数据量达到十万级、百万级,需要分布式调度时,Scrapy 的生态能帮你省很多事。

技术选型的核心逻辑从来不是“哪个框架最强”,而是“当前任务需要什么能力”。任务简单就用简单的工具,任务复杂再上重型框架,避免过度设计。

最后分享一个我自己的习惯:每次爬虫上线前,我都会问自己三个问题——目标网站允不允许采集、我有没有给对方服务器造成不必要的负担、数据用途是否合规。技术能力决定了你能做多快,而分寸感决定了你能做多久。requests 这个库很简单,但爬虫这个领域从来不简单。希望这篇偏实战的文章,能帮你在“请求库的使用”这条路上少走几步弯路。

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

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

立即咨询