Python爬虫稳定性提升:5个实战核心秘籍

📅 发布时间:2026/10/12 15:03:13
Python爬虫稳定性提升:5个实战核心秘籍
干爬虫这行最让人头疼的不是写不出代码而是跑得好好的脚本某天突然被对方网站识别、封了IP数据爬到一半全废。这几年我接手的项目里凡是能在线上稳定跑几个月的爬虫几乎都把心思花在稳定性上——提取式API怎么用、隧道代理怎么配、请求节奏怎么控都不是可有可无的锦上添花而是决定任务能不能交付的硬指标。这篇就聊聊我在实际项目中用来提升爬虫稳定性的5个核心秘籍适合正在从“能跑通”走向“能长期跑”阶段的Python爬虫开发者参考。1. 先把稳定性的问题拆清楚被ban、超时、数据飘很多新手以为爬虫写完了就完事但真正上线跑三天就开始出各种幺蛾子。我在帮团队review爬虫项目时通常先不看代码写了多少功能而是看它能不能扛住真实网络环境的毒打。这里的“毒打”主要来自三个方向。1.1 稳定性差的三大典型表现第一是请求被封。最直观的现象就是状态码突然变成403、429或者页面返回一个验证码页。我见过不少项目脚本白天还能正常抓到了晚上对方风控收紧同一套代码就全军覆没连首页都打不开。第二是超时与连接重置。有些网站的反爬策略不是直接封IP而是对某些请求路径做延迟处理让你等10秒、20秒甚至直接reset连接。如果代码里没处理好超时时间线程就会全部卡死在等待上整个任务看起来像死机了一样。第三是数据不完整或重复。爬到了页面但关键字段解析失败、漏掉了翻页时的最后一页、同一个商品被重复入库这些都属于稳定性问题不只是逻辑bug那么简单。因为它们往往不是必现的而是跟网络波动、页面结构调整、代理IP质量都有关系排查起来极其费劲。1.2 影响稳定性的核心变量从我的经验看爬虫稳定性主要被四个变量牵着走IP信誉度、请求频率、指纹一致性、数据链路完整性。IP信誉度决定了你的请求在对方风控系统里的初始评分。数据中心机房IP天然的评分就低住宅IP评分高。这也解释了为什么同样的代码本地跑没问题放到云服务器上跑却被秒封。请求频率是最容易控制也最常被忽略的变量。很多脚本没有限速逻辑for循环直接怼到底几千个请求几秒内发完任何一个正常用户都不会这么干风控不封你封谁。指纹一致性指的是UA、Cookie、浏览器特征等请求头信息是否看起来像同一个真人。如果每次请求的UA都不固定或者一会儿带Cookie一会儿不带风控系统很容易把你标记为脚本。最后是数据链路完整性。从发起请求、拿到响应、解析字段、清洗数据到入库任何一环出问题都会让整个任务失败。很多爬虫稳定性差其实不是网络层挂的而是数据层先崩了。这四个变量其实就对应了下面要展开的五个核心秘籍。本质上提升稳定性就是在控制这四个变量。2. 核心秘籍一把请求节奏握在手里——限速与并发控制我在很多爬虫代码里看到过这样的操作一个for循环里面是requests.get()不加任何等待几千个URL一次性跑完。这种写法在测试的时候看起来效率很高但放到真实环境里基本活不过半天。2.1 限速不是怂而是活得久要理解限速的意义你得先站在对方网站运维的角度想问题。一个正常用户访问你的网站通常是打开一个页面看几秒甚至几分钟再点下一个。而爬虫是连续不断的请求间隔可能只有几百毫秒。行为特征差异太大根本不需要什么高深的机器学习几个简单的统计指标就能识别。所以限速的首要目的是让请求的时间分布看起来接近真人。具体限到什么程度取决于目标网站的防护严格程度。比如我之前爬一个公开的新闻站点每秒2到3个请求就完全没问题但爬某个电商平台的商品详情页时每秒钟超过1个请求就会触发滑块验证。除了固定间隔更推荐的是在请求间隔里加入随机抖动。比如间隔在1到3秒之间随机浮动而不是精确地每两秒一次。精确的间隔本身就是机器的特征加入随机性可以明显降低被识别的概率。2.2 落地一套可配置的限速模块我习惯在项目里封装一个简单的限速器而不是在每个请求前手动time.sleep()。手动sleep的问题是当你后面引入多线程或异步时每个线程都在sleep实际QPS会翻倍限速就失效了。下面是一个基于令牌桶思路的小工具它能把整个进程的请求频率限制在设定值以内import threading import time class RateLimiter: def __init__(self, qps): self.qps qps self.tokens qps self.lock threading.Lock() self.last_refill time.time() def _refill(self): now time.time() elapsed now - self.last_refill self.tokens min(self.qps, self.tokens elapsed * self.qps) self.last_refill now def acquire(self): while True: with self.lock: self._refill() if self.tokens 1: self.tokens - 1 return time.sleep(0.05)这个限速器是线程安全的。多个爬虫线程同时调用acquire()整个进程的总请求速率也不会超过预设的QPS。比散落在代码各处的time.sleep好维护得多。2.3 并发控制的取舍限速和并发是两回事但经常被混在一起。限速控制的是总请求频率并发控制的是同时进行的请求数量。理论上你可以在低频下开很多线程但实践中不建议这么做。我踩过的坑是用ThreadPoolExecutor开了20个线程每个线程内部又没做好限速结果并发一上来对方直接把我服务器IP封了。后来改成全局限速器控制总QPS并且把线程数降到5到8个才稳定下来。另一个经验是如果目标网站加载速度很快、单请求耗时短那并发度反而不需要太高。因为瓶颈不在并发而在频率。如果对方网站响应很慢比如平均要10秒才返回这时可以适当提高并发因为单位时间内实际发出的请求数仍然受限于总QPS。对于协程方案我个人建议在爬虫项目里慎用。aiohttp虽然性能好但调试起来比较麻烦而且很多代理库和第三方API适配做得不如requests成熟。除非目标网站对性能有极端要求否则requests加线程池加全局限速器是性价比最高的组合。3. 核心秘籍二提取式API的正确打开方式提取式API这个词这几年在爬虫圈子里出现得越来越频繁。它解决的是“拿到HTML之后怎么稳定地变成结构化数据”这一环的问题也顺便帮你处理了很多反爬细节。3.1 提取式API到底是什么简单说提取式API就是一类专门做数据提取的服务。你把目标URL发给它它替你去请求目标网站然后在服务端完成JS渲染、特征处理、HTML解析最后返回一个整理好的JSON数据。这类服务通常还会附带一些额外的能力比如自动更换出口IP、管理Cookie会话、处理验证码。对开发者来说相当于把爬虫里最脆弱、最容易被封的几个环节全部外包出去了。我在实际项目里用这类API最多的情况是处理那些必须登录才能访问、或者页面由JavaScript动态渲染的站点。自己写Selenium去渲染不仅慢还特别吃内存跑久了进程会越来越卡。而提取式API天生就是干这个的返回速度和稳定性通常比本地渲染方案好很多。3.2 什么场景下用提取式API不是说所有爬虫都应该用提取式API它有明确的适用边界。我整理过一个简单的对比表对比维度自研解析提取式API初次接入成本高需要处理反爬、解析、渲染低填URL就能拿到JSON单条请求成本便宜只需付带宽和代理费较贵按请求次数计费被封风险自己承担服务商承担大部分稳定性依赖自身代码质量依赖服务商的基建能力适用数据量适合大规模、单一站点长期抓取适合小规模、多站点、复杂页面如果你要长期抓某一个固定站点的海量数据自研方案明显更划算因为边际成本低。但如果你要抓的是几十个不同的网站每个站点量不大自研成本反而会高得离谱——你要为每一个网站单独写解析逻辑还要单独处理它们的反爬策略光是维护成本就够喝一壶。3.3 接入时的稳定性细节用提取式API也不是无脑调就行。我在接入时最关注三个点配额管理、超时重试、降级方案。配额管理很关键。这类服务通常按请求次数计费如果代码里有一个循环写得不严谨很可能几分钟就把一天的配额消耗完。我会在调用的统一入口处加一个计数器每次都检查剩余配额低于阈值就自动切换到备份Channel或者直接暂停任务等待配额刷新。超时重试也一样。服务商再稳定也难免有响应慢的时候。我一般会给提取式API调用设置一个更长的超时时间比如30秒并且配合指数退避重试三次。如果三次都失败就把URL写进一个retry队列后面专门跑一个补偿任务。降级方案最容易被忽略。我的原则是任何第三方服务都要假设它会挂。提取式API挂了就要自动回退到自研的基础解析逻辑用普通的requests加代理去兜底。虽然体验差一点但至少数据链路不会完全中断。4. 核心秘籍三隧道代理的选型与轮换策略如果说限速是控制请求频率那代理就是控制IP信誉度。隧道代理是我个人目前最推荐给中大规模爬虫项目的方案因为它在稳定性和便利性之间找到了一个很好的平衡点。4.1 隧道代理的运行机制很多刚入门的同学对代理的概念还停留在“买一批代理IP填到代码里挂了就换一个”的阶段。这个方案不是不行但维护成本很高IP池要自己管理失效检测要自己做换IP时机也要自己判断稍有不慎就会把大量请求打到已经失效的IP上。隧道代理的机制完全不同。它给你的是一个固定的入口域名和端口你的爬虫只需要把代理设置为这个入口请求发出后服务商在后端自动帮你分配一个可用的出口IP。用个生活化的类比普通代理IP像打车你要自己找车、自己跟司机沟通、车子出了问题你自己换一辆隧道代理像坐公交专线你只管上车至于今天走哪条路、换哪个司机都不需要你操心。隧道代理常见的计费方式是按流量或按并发数通常还会在后台给出一个IP切换频率的配置项比如每5个请求切换一次IP或者每10分钟切换一次。4.2 会话保持与IP切换的平衡用隧道代理有一个经常踩坑的细节它默认是每次请求都可能换IP但对于需要保持登录状态的爬虫任务频繁切换IP会让会话失效。比如你爬一个需要登录才能看到数据的站点登录之后服务器会记录你的IP和Session的绑定关系。如果你下一个请求突然换了IP服务端很可能判定为异常登录直接干掉你的Session。解决办法是选择带“会话保持”功能的隧道代理。这种模式下只要你在规定的时间窗口内比如10分钟所有请求都会走同一个出口IP超过时间窗口或者主动发一个切换指令才会更换IP。我在代码里的做法是把requests的Session对象和隧道代理配合使用同时在登录前先通过一个预热请求建立会话登录后再开始正式抓取。这样Session里的Cookie和隧道代理的出口IP绑定关系就不会被打乱。4.3 隧道代理的配置实战与踩坑隧道代理的配置本身不复杂它的代理地址一般是这样的格式http://用户名:密码隧道域名:端口在requests里的写法是import requests proxies { http: http://user:passwordtunnel.example.com:8000, https: http://user:passwordtunnel.example.com:8000, } session requests.Session() session.proxies.update(proxies) resp session.get(https://httpbin.org/ip, timeout30) print(resp.json())这里有两个非常容易踩的坑。第一个是密码里的特殊字符。很多隧道代理的密码是服务商随机生成的里面可能包含、/、:这些字符。如果你直接拼接URL代理地址会被解析错误。解决方法是提前用urllib.parse.quote()对用户名和密码做URL编码。第二个坑是别把静态资源请求也走隧道代理。有些页面里会有大量图片、CSS、JS文件如果这些请求全部走隧道流量会消耗得飞快而且完全没有必要。我一般只对目标URL请求走代理静态资源直接直连或者用一个单独的普通代理池。还要注意隧道代理的并发限制。服务商通常会在后台标注最大并发连接数超过之后会排队或直接拒绝。所以在开线程池的时候要确保线程数不超过这个上限否则你会看到大量请求超时以为隧道挂了其实是被限流了。5. 核心秘籍四重试、超时与异常兜底网络环境是不可靠的这应该是每个爬虫开发者的第一课。哪怕你的限速做得再精准、代理配得再完美请求还是会有可能超时、被重置、返回异常状态码。所以重试机制和异常兜底是稳定性方案里绝对不能省的一环。5.1 优雅的重试机制设计我见过很多新手写的重试就是for i in range(3): try: ... except: continue这种写法问题很大。第一三次重试之间的间隔是固定的如果请求失败是因为对方网站已经处于过载状态你立刻重试只会加重对方的负担也可能让自己的IP更快被标记。第二所有异常都用同样的重试策略可能浪费大量时间在不可恢复的错误上。规范的做法是指数退避加随机抖动。指数退避意味着每一次重试的等待时间都翻倍比如第一次等2秒、第二次等4秒、第三次等8秒。随机抖动是在这个基础上加一个随机的偏移量比如2到4秒、4到6秒避免多个请求在同一时刻集中重试造成“惊群效应”。代码层面的实现我推荐用tenacity这个库它已经把重试、退避、异常判断都封装好了from tenacity import ( retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type, ) retry( stopstop_after_attempt(4), waitwait_random_exponential(multiplier1, max10), retryretry_if_exception_type((requests.Timeout, requests.ConnectionError)), ) def fetch_page(url, session): resp session.get(url, timeout30) if resp.status_code 429: raise TooManyRequestsError(resp.status_code) resp.raise_for_status() return resp注意我在重试条件里放了两个东西网络异常类超时、连接错误和业务状态码异常429。这两类错误都是暂时性的重试有概率成功。但如果返回的是404、403这类状态码重试通常也没用不如直接放弃把问题记录到日志里。5.2 超时与连接管理的细节超时参数是很多爬虫出错的高发区。很多人只写了一个timeout5以为这是总时间限制。其实在requests里timeout是一个元组可以分别控制连接超时和读取超时。session.get(url, timeout(5, 30))这个元组的含义是建立连接最多花5秒连接建立之后读取响应最多等30秒。为什么要分开设置因为连接超时通常可以短一些目标网站如果连不上说明对方宕机或者你的代理不可用没必要等太久。而读取超时要长一些因为有些页面生成需要时间尤其是那些做了动态渲染的页面30秒是比较合理的范围。连接池管理也值得多说一句。requests的Session默认会用urllib3的连接池你可以在Session上设置HTTPAdapter来调整连接池大小from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections10, pool_maxsize20, max_retries0) session.mount(https://, adapter) session.mount(http://, adapter)这里pool_connections控制的是不同域名可以缓存的连接数pool_maxsize控制的是同一个域名下最多可以同时保存多少个连接。如果你开了10个线程并发pool_maxsize至少要大于10否则线程们会互相等待释放连接拖慢整体速度。5.3 异常兜底让爬虫断点续爬重试只能解决临时性错误如果目标网站封了你的账号或者代理池整体失效重试再多次也是白搭。这时候需要的是异常兜底机制让爬虫能够安全地停下来、记住进度、下次启动时接着跑。我的做法是维护一个任务队列。每个待抓取的URL先写入队列抓到成功并且数据入库后才从队列里标记为完成。如果某个URL重试多次仍然失败就把它放到一个“死信队列”里等过几个小时再用新的代理策略重新尝试。这个任务队列可以用Redis来实现也可以用SQLite。对于中小型爬虫项目SQLite足够用而且天然支持事务不需要额外部署服务。import sqlalchemy as sa engine sa.create_engine(sqlite:///crawler_tasks.db) metadata sa.MetaData() tasks sa.Table( tasks, metadata, sa.Column(id, sa.Integer, primary_keyTrue), sa.Column(url, sa.String, uniqueTrue), sa.Column(status, sa.String, defaultpending), sa.Column(retry_count, sa.Integer, default0), sa.Column(last_error, sa.Text), ) metadata.create_all(engine)每次爬虫启动时先从表里捞一批statuspending的任务跑完再更新状态。这样即使程序中途崩溃或者被服务器杀掉重启之后也只是从上次未完成的地方继续不会从头再爬一遍。6. 核心秘籍五数据层稳定——去重与持久化最后一个核心秘籍也是我每次写爬虫都会强调的数据层必须稳定。很多爬虫代码在网络层做了一大堆防护结果数据却存得乱七八糟——重复的条目一堆类型错误、编码错误频繁最后统计出来的数据根本没法用。6.1 去重别把数据爬重复了爬虫重复抓取是很常见的事。翻页的时候页面重复、多线程并发时同一个URL被同时抓、任务重启后没有断点机制导致重复跑这些都可能导致同一份数据被入库多次。最简单的去重是基于URL去重。在任务进入队列之前先查一下数据库里这个URL是否已经处理过。我通常会在任务表上给URL加一个唯一约束配合INSERT OR IGNORE或捕获完整性错误来实现。但URL去重还不够。有时候同一个商品会有多个不同的URL比如带有不同追踪参数的链接内容是一样的。这种情况下需要基于内容指纹去重。方法是抓取核心字段拼接成一个字符串然后做MD5或SHA1哈希把这个哈希作为一个唯一键存起来。import hashlib def make_content_id(item): raw f{item.get(title)}|{item.get(price)}|{item.get(sku)} return hashlib.sha1(raw.encode(utf-8)).hexdigest()在数据表里给这个content_id也加上唯一约束入库前先检查一遍。实践下来内容去重能帮我筛掉大约5%到10%的重复数据这些重复数据在后期统计时会非常恶心。6.2 持久化存储的稳定性设计存储这个环节我强烈建议即使是小爬虫也至少用SQLite不要用CSV或JSON文件直接落地。CSV在并发写入时会损坏JSON文件在程序崩溃时可能只写了一半这两个坑我都踩过。用SQLAlchemy操作SQLite的方式很成熟。需要注意的是在多线程环境里对SQLite写入时可能会遇到database is locked报错。这通常是多个线程同时写一个文件导致的。解决方法是开启WAL模式同时把数据库连接的check_same_thread设为False并且做好队列写入或使用连接池。engine sa.create_engine( sqlite:///crawler_data.db, connect_args{check_same_thread: False, timeout: 20}, )我在项目里还会开启WAL模式它能让读取和写入并行进行减少锁冲突with engine.connect() as conn: conn.execute(sa.text(PRAGMA journal_modeWAL))写入策略上我建议批量插入而不是一条一条insert。批量提交不仅快得多而且对数据库的压力更小减少了锁冲突的概率。一次性插入几十条甚至几百条是没有问题的关键是控制好事务的大小避免一个事务过大导致内存占用过高。最后是事务和重试的配合。数据库写入也可能因为锁超时等原因失败这时候同样需要重试但重试的次数要控制得比网络请求更严格一般两到三次即可。如果三次都失败就把这一批数据先写到本地JSON里暂存等程序空闲再补写而不是直接丢掉。这样数据层稳住了爬虫的重启、断点、去重才有依靠。否则前面四层做得再好最后数据存得乱七八糟整个项目仍然不可用。我个人这几年在爬虫稳定性的路上踩过的坑比写业务代码的坑多得多。最深的体会是稳定性不是一个技巧而是一整套工程习惯限速要控制、代理要管理、重试要设计、数据要兜底。还有一个很容易被忽略的心态问题——不要追求一次把数据跑完而是追求跑了之后能接着跑、挂了之后能恢复。把“跑完”换成“跑稳”很多问题会迎刃而解。最后分享一个很多人忽视的小技巧任何第三方服务无论是提取式API还是隧道代理都要提前设计降级方案。把请求层、代理层、数据层分开抽象哪一层出问题都可以单独重试或切换这套架构能让你在换代理、换API供应商时不动核心代码才是长期稳定的根本。