
写爬虫的朋友大多有这种经历第一篇文章里把 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_connections20, pool_maxsize20) 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, datapayload) # 登录成功后直接带着 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( total3, connect3, read3, backoff_factor1, status_forcelist[500, 502, 503, 504], ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(https://, adapter)这里重点说backoff_factor。它控制重试间隔规则是sleep backoff_factor * (2 ** (retry_number - 1))。如果 backoff_factor1第一次重试前等 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(ftimeout: {url}) except ConnectionError: # 连接被重置、DNS 解析失败等往往需要换代理或换策略 logger.error(fconnection error: {url}) except ProxyError: # 代理失效需要从代理池移除当前代理 logger.error(fproxy error: {url}) except SSLError: # 证书校验失败可以先 disable_warnings 再 verifyFalse 临时规避但要有安全评估 logger.error(fssl error: {url}) except requests.exceptions.RequestException as e: logger.exception(funknown 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标识客户端类型使用项目对应浏览器的最新 UAReferer来源页面保持和实际访问路径一致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(refererNone, cookieNone): headers { User-Agent: ua.random, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, } if referer: headers[Referer] referer if cookie: headers[Cookie] cookie return headersfake_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 [fhttps://example.com/page/{i} for i in range(100)] with ThreadPoolExecutor(max_workersMAX_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(ffailed: {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:passip:port, https: http://user:passip:port, } resp requests.get(url, proxiesproxies, timeout(3, 10))这里有几个容易踩的坑。一是记得同时配置 http 和 https漏掉哪个就会导致对应协议的请求直连IP 直接暴露。二是代理获取最佳的会话时长很多代理服务商提供的代理 IP 有效期只有几分钟到几十分钟过期后要继续调用接口重新提取。三是必须验证代理连通性再使用不能拿到手直接上生产环境。def check_proxy(proxy, test_urlhttps://httpbin.org/ip, timeout5): try: resp requests.get(test_url, proxiesproxy, timeouttimeout) if resp.status_code 200: return True except Exception: return False return False代理池的设计可以围绕“提取 - 检测 - 入库 - 调度”来做。提取接口拿到原始代理列表后先做一轮基础连通性检测能用的放进 Redis 或内存队列爬虫每次取代理时做轮换失败时标记淘汰。代理质量比数量重要得多。大量不可用代理混在池子里反而会让系统反复重试拖慢整体速度。另外代理服务商之间质量差异很大不要只看价格。免费代理基本不靠谱不是速度慢就是随时失效只能用来做测试。做正式项目选稳定服务商是投资不是成本。这也是我在实际操作中最大的感受免费的东西往往才是最贵的。5. 常见问题与排查技巧实录把“意外”变成“预案”5.1 错误速查表看到异常不用慌我把这几年爬虫开发中遇到的高频异常整理成了速查表遇到问题先对照一下异常信息可能原因排查与解决方案ConnectionError目标站点不可达、域名解析失败、连接被重置检查网络、换个代理、确认域名可访问Timeout服务端响应慢、连接被墙、代理不稳定调大读取超时、换代理、减少并发数SSLError: CERTIFICATE_VERIFY_FAILED证书校验失败补证书链或 verifyFalse要评估安全性403 ForbiddenIP 被封、请求头被识别、需要登录换代理、更新 UA/Headers、检查登录态404 Not FoundURL 规则不对、页面被删除检查 URL 拼接逻辑、抓取当前页面确认地址JSONDecodeError响应不是 JSON 却调用 .json()先打印 resp.text 前 500 字符确认返回内容TooManyRedirects重定向循环配合 allow_redirectsFalse 手动处理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(hostlocalhost, port6379, db0) 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 这个库很简单但爬虫这个领域从来不简单。希望这篇偏实战的文章能帮你在“请求库的使用”这条路上少走几步弯路。