构建企业级Python爬虫系统:登录验证、验证码识别与分布式调度实战

构建企业级Python爬虫系统:登录验证、验证码识别与分布式调度实战 去年我接了一个采集项目第一阶段给客户跑了个单机爬虫脚本每天抓几千条公开商品数据前三天跑得挺顺。一周后回访发现脚本早挂了先是验证码突然冒出来接着登录态失效再有几个页面改成了JS动态加载requests直接抓了个空壳。客户问能不能做成一个“不容易挂”的系统我才意识到日常写爬虫和搭建一套能长期稳定运转的Python爬虫系统完全是两件事。这篇内容不教Python基础语法也不讲requests怎么发请求而是讲清楚如何把登录验证、验证码识别、JS渲染、分布式调度这些模块组装成一套能扛住生产环境考验的全自动爬虫系统。适合已经写过几类站点爬虫、想往工程化和自动化方向进阶的开发者也适合准备用Python搭建数据采集平台的团队。1. 先想清楚一个“企业级”爬虫系统到底要解决什么问题1.1 从单机脚本到分布式系统的三个演进阶段大多数人的爬虫生涯都从单机脚本开始一个Python文件requests循环发请求BeautifulSoup解析结果存CSV。这种模式的痛点不在“能不能爬到”而在“能稳定爬多久”。单机爬虫的典型死法我见过太多跑一半触发了风控被封IP、账号异常、验证码弹出、进程崩了没人管、中途断电断网只能从头再来。这些问题单个看都不大组合起来就是灾难。企业级爬虫系统的核心价值不是“爬得更快”而是“爬得更稳、可恢复、可监控、可扩展”。我把演进路径分成三个阶段你可以对照自己当前处在哪个位置第一阶段脚本加日志加简单重试。给爬虫加上日志记录和失败重试机制能解决“挂了不知道、挂了不恢复”的问题。这个阶段不算系统但已经比裸脚本强很多。第二阶段独立的采集框架加定时调度加数据库存储。把采集、解析、存储拆开数据先落库再做清洗任务由调度器统一触发。从这个阶段开始爬虫变成了一个可以被别人使用的工具而不是只能在自己电脑上跑的个人脚本。第三阶段分布式调度加多Worker并行验证码、渲染、登录态等模块全部服务化。任务队列放在中间件里多个Worker并发消费各自负责登录、渲染、解析等环节系统开始具备横向扩展能力。标题里说的“登录验证验证码识别JS渲染分布式调度”对应的正是第三阶段的能力组合。当你理解了每一块到底解决什么问题再回头看系统架构就非常清晰了。1.2 系统边界与角色划分我搭建这套系统时的角色划分如下你可以直接复用调度中心负责管理任务队列、生成种子任务、控制抓取节奏是整个系统的大脑。采集Worker消费任务负责网络请求、代理挑选、请求重试产出原始HTML或JSON。渲染服务当页面需要JS执行时采集Worker调用渲染服务的HTTP接口拿到渲染后的完整DOM。解析模块从原始HTML或JSON中提取结构化字段可以和采集Worker同进程也可以独立成消费者。验证码服务识别图形验证码、滑块轨迹等供登录模块和采集过程动态调用。存储层原始数据、清洗后数据、任务状态、日志分开存储互不干扰。角色拆清楚之后每个模块都能单独测试、单独扩容。比如验证码识别算法升级只需要替换服务内部实现不牵扯采集逻辑。这是企业级系统和玩具脚本最大的区别——模块边界清晰故障被隔离在局部。1.3 技术栈选型与关键依赖库主语言选Python没什么悬念生态太成熟了。核心库我按功能列一下requests或httpx常规HTTP请求。httpx支持HTTP/2和异步但requests胜在稳定和生态兼容线上项目我更倾向于requests。lxml加BeautifulSoup4HTML解析。实际解析我用lxml的etree更多性能比BeautifulSoup高不少解析速度和内存占用都有优势。PlaywrightJS渲染的首选替代Selenium后面专门讲。ddddocr开源图形验证码识别零门槛复杂验证码接入打码平台。Redis任务队列、去重集合、缓存分布式系统的地基。Celery或自研Worker任务分发与异步执行。Celery功能全但配置略重小型项目用Redis List加Python多进程也能实现。APScheduler定时触发采集任务。选型建议新项目优先用httpx加Playwright加Redis加Celery这套组合兼容性和社区资料都够多。不要过度设计任务量还没起来的时候Redis List就够了。1.4 合规底线要写在架构里采数据之前第一件事是确认你有没有权限采集这些数据。我见过不止一个项目因为乱爬导致账号封禁、域名被拉黑甚至惹上法律麻烦。几点底线必须遵守只采集公开数据不绕过登录边界去拿本不该拿的信息。尊重目标网站的robots.txt约束至少不要对服务器造成主动压力。控制采集频率限速既是保护目标站点也是保护你的账号和IP。不对目标站点发起任何攻击性请求不尝试破解非公开接口。涉及个人信息的数据采集、存储、展示都要经过合规审查。合规不是空话把合规底线写进架构设计里是专业爬虫系统和灰色脚本的根本区别。下面进入正题从登录验证模块开始拆解。2. 登录验证模块Session管理、Token续期与自动重登2.1 登录态的本质爬虫要模拟登录首先得理解网站是怎么识别“你还在线的”。传统做法是服务端给浏览器发一个Session ID存在Cookie里之后每次请求带上。新版站点更多用JWT等Token方案登录成功后拿到一串加密字符串放在请求头或Cookie里过期后需要重新获取。本质上都是一回事让服务端相信“这个请求来自一个已经验证过身份的客户端”。手动模拟登录的路径通常有三条找到登录接口直接POST用户名和密码拿返回的Cookie或Token。这是最常规的方式适合前端未做加密的站点。如果密码在JS里被RSA或AES加密需要先执行加密逻辑再提交。可以用Playwright执行页面里的加密函数或者把加密算法用Python重写。扫码登录模拟扫码页面状态等待手机确认后获取登录态适合微信、支付宝等生态。实际项目里第一条路径能覆盖六成以上的站点。先打开DevTools的Network面板提交一次登录看浏览器发出去的请求格式再用Python模拟一遍基本就通了。2.2 用requests.Session维护状态Cookie落盘复用requests.Session可以自动保存Cookie并在后续请求中携带这是爬虫登录的第一步。import requests session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Referer: https://example.com/login }) # 登录 login_resp session.post(https://example.com/api/login, json{ username: your_account, password: your_password }) print(login_resp.json()) # 之后的请求自动带上登录Cookie resp session.get(https://example.com/account/orders) print(resp.status_code)有两点容易被忽略。第一Session对象不是线程安全的不能多个线程共用一个Session每个线程必须自己创建。第二如果Worker重启内存里的Cookie就丢了需要持久化。最简单的做法是把Cookie字典序列化成JSON存到Redis或本地文件启动时加载回来。import json # 登录成功后将Cookie持久化 cookie_dict session.cookies.get_dict() with open(cookies.json, w, encodingutf-8) as f: json.dump(cookie_dict, f) # 下次启动时恢复 new_session requests.Session() new_session.cookies.update(json.load(open(cookies.json, encodingutf-8)))Cookie落盘之后即使整个采集服务重启也不用重新登录一遍效率提升非常明显。2.3 自动重登机制心跳检测与异常响应触发Cookie和Token都有有效期。很多站点Cookie能存活几个小时到几天Token可能几十分钟就失效。如果爬虫任务跑一整天登录态失效是必然事件不是偶然事件。自动重登需要两层判断。第一层是主动心跳。定时访问一个需要登录态的接口比如“获取用户信息”如果返回未登录就触发重新登录。def is_login_valid(session, check_url): resp session.get(check_url, timeout10) if resp.status_code 200 and resp.json().get(code) 0: return True return False第二层是被动响应。任何请求只要返回401、302跳转登录页、或者接口返回“未登录”的固定错误码就标记当前登录态失效把待重试任务重新放回队列等重新登录后再消费。重登操作必须加分布式锁。否则多个Worker同时发现登录态失效会同时发起登录请求结果就是互相挤掉对方或者触发风控。这一点我在第7章专门讲。2.4 账号风控与频率限制模拟登录做得再完美如果请求频率一看就是机器人账号照样活不长。登录后的接口访问要模拟真人节奏随机间隔、随机顺序、不要用高并发打同一个账号的接口。一个登录态同时超过5个并发请求基本就是危险信号。更稳妥的做法是一个账号一个独立Session并且把账号按任务类型隔离——有的账号专门抓列表页有的账号专门抓详情页某个账号被风控了不至于全盘瘫痪。3. 验证码识别打码平台、开源模型与策略兜底怎么搭配3.1 验证码也分难度梯度验证码是分梯度的。纯数字字母的图形验证码最基础四则运算、滑块、点选文字、拼图、无感验证码难度依次增加。面对不同难度技术选型差别很大。验证码类型识别难度推荐方案预期成功率纯数字字母图形验证码低ddddocr加图像预处理90%以上四则运算中OCR识别数字和运算符再计算85%左右滑块验证码中轨迹模拟平滑拖拽70%左右点选文字/图片高打码平台或自训深度学习模型95%以上无感行为验证码高避免硬刚低频采集或人工兜底不稳定一个核心原则验证码识别不是要100%成功而是在90%以上成功率的前提下有兜底重试机制把失败请求重新排队而不是直接放弃。3.2 ddddocr与图像预处理实战ddddocr是目前最方便的开源图形验证码识别库安装即用CPU也能跑pip install ddddocrimport ddddocr ocr ddddocr.DdddOcr(show_adFalse) with open(captcha.png, rb) as f: image_bytes f.read() result ocr.classification(image_bytes) print(识别结果:, result)大多数干净的图形验证码ddddocr直接识别就有90%以上的准确率。但碰到带干扰线、背景噪点多的需要先做图像预处理灰度化、二值化、去噪、裁边。import cv2 def preprocess_captcha(img_path): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 二值化去掉干扰背景 _, binary cv2.threshold(img, 180, 255, cv2.THRESH_BINARY) # 中值滤波去噪 binary cv2.medianBlur(binary, 3) return binary预处理之后再用ddddocr识别成功率能提升不少。这里要提醒的是二值化的阈值不是固定的得按目标验证码的实际像素分布调参。有的验证码背景是浅色的有的背景是网格线处理方法完全不同这是验证码识别里最需要耐心的地方。另外部分站点的图形验证码做了字体扭曲、字符粘连ddddocr在这种情况下降得很厉害。如果连续识别失败5次以上就该考虑切到打码平台别在一个方案上死磕。3.3 打码平台接入与成本核算商业打码平台通常提供HTTP接口上传图片返回识别结果。接入本身不复杂import requests def identify_by_platform(captcha_path, platform_token): resp requests.post( https://api.captcha-platform.com/identify, data{token: platform_token, type: common}, files{image: open(captcha_path, rb)} ) data resp.json() if data.get(code) 0: return data[result] raise ValueError(f识别失败: {data.get(msg)})成本上图形验证码单次基本在几分钱到几毛钱滑块和点选类贵一些。一天抓10万条数据如果验证码出现率是5%一天打码费也就几十到几百块。这个钱值得花——自建深度模型的训练和标注人力成本远高于直接按量付费。我的建议是低频简单验证码用ddddocr高频复杂验证码直接上平台自建模型只在识别量足够大且平台成本高到不可接受时才考虑。3.4 验证码触发的工程化策略验证码识别的关键不在于识别函数写得有多好而在于触发和重试策略。第一请求先正常发只有服务端返回了验证码标记比如响应里出现captcha字段、验证码图片地址、特定状态码才触发识别不要每个请求都预先识别一遍浪费算力。第二识别结果需要验证。提交验证码后会有一个验证结果可能直接返回成功也可能返回新的验证码让你重试。要设置最大重试次数比如3次超过就把任务回收到待处理队列并发告警通知。第三控制验证码出现的节奏。如果一个IP或账号在短时间内频繁触发验证码说明已经被风控盯上了此时应该主动降速、切换代理或账号。识别只是手段避免触发才是根本。4. JS渲染不盲目上Playwright先学会用接口直连降本增效4.1 先判断页面到底需不需要渲染很多人一看到数据是动态加载的就直接上Selenium这是最消耗资源的做法。正确流程是打开浏览器开发者工具切到Network面板多翻几页把所有XHR接口看一遍。很多页面看起来是JS渲染其实数据都来自一个返回JSON的API接口列表页的下一页数据往往就是/api/list?page2。详情页的核心字段可能来自/api/detail/{id}。页面上的统计数据大概率有对应的统计接口。如果这些接口可以直接请求拿到JSON就用httpx或requests直接调逻辑简单、速度快、稳定。只有两种情况需要上渲染引擎数据不在任何XHR接口里而是混在HTML里由JS动态生成。请求头、签名参数由JS计算生成直接调用接口会被校验拦截。我见过太多人一台服务器跑着10个Selenium实例就是为了抓一个完全可以用requests调到的JSON接口CPU和内存白白浪费。接口直连优先是渲染方案里的第一条铁律。4.2 Playwright与Selenium的选择我为什么选Playwright真需要渲染的时候我推荐Playwright而不是Selenium。Playwright的优势非常明显自带浏览器下载管理、支持Chromium、Firefox、WebKit三种内核、自动等待机制做得很好、异步API性能高、能拦截网络请求和修改请求头。一个简单的Playwright渲染示例import asyncio from playwright.async_api import async_playwright async def render_page(url, script): async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) context await browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., viewport{width: 1366, height: 768} ) page await context.new_page() await page.goto(url, wait_untilnetworkidle, timeout30000) # 可选执行额外脚本比如滚动到底部触发懒加载 if script: await page.evaluate(script) html await page.content() await browser.close() return html这里有两个关键点。第一wait_untilnetworkidle虽然好用但对某些一直有轮询请求的页面会超时建议改成domcontentloaded后再加固定等待或者等待某个核心元素出现。第二每次新建浏览器实例开销很大生产环境必须做浏览器和Context池复用见4.4。4.3 渲染服务化独立模块按需扩容一个容易被忽略的设计渲染不要嵌在采集Worker的内部实现里而是独立成一个服务。采集Worker向渲染服务发一个带URL的HTTP请求渲染服务返回HTML。# 采集Worker侧 import requests def fetch_with_render(url): resp requests.post( http://render-service:9000/render, json{url: url, wait_for: div.product-item}, timeout60 ) resp.raise_for_status() return resp.json()[html]这样设计的好处很多。渲染服务的并发数和采集Worker的并发数可以独立配置渲染瓶颈时只需扩容渲染服务。Playwright升级、浏览器内核更换都是渲染服务内部的事采集端完全无感。业务上还可以让多个采集项目共用一个渲染集群摊薄资源成本。4.4 渲染池的稳定性超时、并发、内存与浏览器实例复用渲染池最常见的三个问题内存泄漏、并发不够、Context隔离不彻底。内存泄漏主要来自浏览器实例没有关闭以及页面对象没有释放。建议用固定的浏览器实例池启动时预创建3到5个浏览器实例每个实例可以再开Context。每次渲染任务用完后关闭Context不直接关浏览器达到复用效果。定时检查浏览器实例的进程内存超过阈值就重启该实例。Context隔离很重要。一个网站的请求Cookie、代理配置必须放在独立的Context里否则不同任务之间会串数据。每个Context用完即销毁是最保险的做法。并发配置上一个浏览器实例同时打开的页面数建议不超过5个否则CPU和网络带宽会饱和。我之前用16核服务器跑渲染服务3个浏览器实例、每个4个并发Context能稳定支撑约12个并发渲染任务CPU还留有余量。5. 分布式调度任务队列、URL去重与Worker弹性扩展5.1 任务队列怎么选从Redis List到Celery的演进分布式调度的核心是任务队列。最简单的方案是用Redis的List数据结构做FIFO队列生产者lpush任务消费者rpop取任务。这个方案代码量极少适合任务量在几十万级别的场景。import redis import json r redis.Redis.from_url(redis://127.0.0.1:6379/0) # 生产者 r.lpush(task:detail, json.dumps({url: https://example.com/detail/1001})) # 消费者Worker进程内 while True: task_data r.brpop(task:detail, timeout5) if not task_data: continue task json.loads(task_data[1]) crawl_detail(task)任务量上来之后需要考虑ack确认机制brpop把任务取走了如果Worker处理到一半崩溃任务就丢了。要保证不丢任务得引入“待确认队列”Worker成功处理完后再删除确认标记。Celery内部实现了这套机制适合不想重复造轮子的团队。我的经验是数据采集系统优先Celery加Redis任务类型多、需要定时、需要重试、需要结果回传这些场景它都覆盖了。5.2 任务模型设计种子、列表、详情、验证四类任务任务不能全是一种类型我通常把任务分成四类种子任务来自配置的中心URL比如某个分类的入口。列表任务翻页列表URL解析出详情页URL和下一页URL。详情任务具体的详情页URL解析出结构化字段。验证任务用于检测登录态、验证码策略是否正常的探针任务。不同类型任务进不同队列消费者按优先级和业务需求分配。列表任务的优先级高于详情任务因为详情URL来源于列表解析验证任务只在需要时插入。分级的好处是当账号被限制或代理质量差时可以单独暂停详情队列保留列表队列继续跑系统不会全线崩溃。5.3 URL去重从Redis Set到布隆过滤器用Redis的Set做URL去重是最直观的def is_new_url(url): return r.sadd(url:seen, url) 1 # 返回1表示之前不存在但URL数量达到千万级后Redis Set的内存开销会非常大。一个100字符的URL存1000万个就是1GB内存。这时需要用布隆过滤器。布隆过滤器用多个哈希函数和一个位数组能大幅节省空间代价是有一定误判率——会把没访问过的URL误判为已访问。在爬虫场景里误判的代价只是漏抓个别数据完全可以接受。from pybloom_live import BloomFilter # 本地布隆过滤器适合单Worker场景 bloom BloomFilter(capacity10_000_000, error_rate0.001) def is_new_url_local(url): if url in bloom: return False bloom.add(url) return True分布式场景下可以用Redis的bitmap实现一个简易布隆过滤器或者使用Redis版布隆过滤器库。需要注意布隆过滤器不支持删除误判率会随插入量上升容量要按数据量的3到5倍预留余量。5.4 Worker拆解登录、采集、渲染、解析各司其职Worker不要只写成一个巨型脚本。我建议按职责拆成几类进程登录Worker专门负责账号登录、会话维护、验证码识别把登录态写入Redis缓存。采集Worker消费列表和详情任务发起请求拿到原始数据后投递到解析队列。渲染Worker消费渲染请求执行Playwright渲染返回完整DOM。解析Worker从原始数据中提取字段清洗后写入数据库。每一类Worker都可以独立部署多份通过Redis的队列天然做负载均衡。需要扩展时在主机上横向增加Worker实例即可不需要停机。这套架构的另一个好处是故障隔离解析Worker出现bug不会影响采集Worker继续抓取数据。6. 全链路稳定性与反反爬限速、重试、幂等、监控告警6.1 全局限速与令牌桶对目标站点保持基本尊敬对目标站点保持克制既是合规要求也是账号和IP存活的保证。全局限速我用令牌桶算法实现以固定速率往桶里加令牌每个请求消耗一个令牌桶满则请求等待或拒绝。import time import threading class TokenBucket: def __init__(self, rate, capacity): self.rate rate # 每秒补充的令牌数 self.capacity capacity # 桶容量 self.tokens capacity self.lock threading.Lock() self.last_refill time.time() def acquire(self, blockTrue): while True: with self.lock: now time.time() self.tokens min(self.capacity, self.tokens (now - self.last_refill) * self.rate) self.last_refill now if self.tokens 1: self.tokens - 1 return True if not block: return False time.sleep(0.05)这里的关键是让限速器做成全局单例并且在分布式场景下用Redis计数器实现跨进程限速。比如限制每秒钟每个域名最多5个请求就用Redis的INCR加EXPIREimport time def rate_limit(r, domain, keyrl, max_requests5, period1): redis_key f{key}:{domain}:{int(time.time()) // period} current r.incr(redis_key) if current 1: r.expire(redis_key, period * 2) if current max_requests: time.sleep(period) return False return True6.2 请求幂等与失败重试同一个任务最多执行几次数据采集天然适合幂等设计同一个URL不管抓多少遍解析结果应该是一样的。基于这个前提任务重试就不会产生脏数据。我习惯给每个任务设置重试状态机状态0待处理状态1处理中状态2成功状态3失败待重试状态4超过最大重试次数进入人工处理队列最大重试次数一般设3次。重试要带退避策略第一次失败后等30秒第二次等5分钟第三次等30分钟。直接快速重试很容易被识别成攻击行为。重试还要区分失败原因网络超时可以重试目标页返回404就别重试了数据真不存在重试一百次也没用。6.3 结构化日志与监控指标出了事能定位到具体任务出了问题能不能快速定位全靠日志设计。我的经验是日志一定要结构化方便按任务ID检索。Python的logging支持extra参数再配合JSON格式化是最实用的方案。import logging import json class JsonFormatter(logging.Formatter): def format(self, record): base { time: self.formatTime(record, self.datefmt), level: record.levelname, module: record.module, message: record.getMessage(), } if hasattr(record, task_id): base[task_id] record.task_id if hasattr(record, url): base[url] record.url return json.dumps(base, ensure_asciiFalse) logger logging.getLogger(crawler)每次请求和任务处理都带上task_id后续排查问题一查到底。监控指标我重点看四个任务吞吐量每分钟成功处理多少任务。失败率失败任务占总任务比例超过阈值触发告警。验证码出现频次突然升高说明风控收紧。登录态失效次数短期内多次失效要检查账号状态。6.4 代理IP池与请求签名对抗的工程化思路同一IP高频请求是触发风控的主要原因。代理IP池属于基础设施我按三个层次维护免费代理时效性差只能用于零星请求不推荐生产环境。付费代理池按量购买的动态IP稳定性看服务商建议按区域和类型分流。自建代理在合法合规前提下用自己的服务器出口IP带宽和可控性最好但资源成本高。代理在代码层要支持自动轮换和失败剔除。每次请求从代理池取一个IP请求失败或返回验证码时打标记连续失败3次的IP自动下线等冷却期后再复用。请求签名这块像时间戳、非加密的请求参数可以在Headers里模拟涉及加密签名和参数还原的优先执行页面JS或者用渲染服务代发请求不要试图在Python里重写全套加密逻辑维护成本太高。7. 实测踩坑记录六个影响最大的问题及排查链路7.1 登录态在分布式多Worker之间互相挤掉现象系统上了三个采集Worker之后账号频繁掉线日志里全是“重新登录”。排查发现每个Worker检测到登录态失效后都执行了登录逻辑导致同一个账号被多个线程同时登录。有些网站单账号多端登录会互踢。解决登录操作加Redis分布式锁锁的key用账号ID过期时间30秒。只有拿到锁的Worker才执行登录其余Worker等待登录完成后再从Redis读取新的Cookie。def login_with_lock(account_id): lock_key flock:login:{account_id} if r.set(lock_key, 1, nxTrue, ex30): try: do_login(account_id) finally: r.delete(lock_key) else: # 等待其他Worker登录完成 time.sleep(random.uniform(1, 3))这个锁看起来简单实际救了我很多次。没有它账号风控被触发只是时间问题。7.2 验证码风暴并发一高全站开始出验证码现象某天中午开始所有Worker请求都开始返回验证码页面一小时内封掉了3个账号。排查回看监控发现前一天我们上线了新代理池后并发跑满单账号每秒请求数翻了5倍。大量并发请求触发了全站风控导致所有账号进入验证码状态。解决全局限速按账号维度控制单账号并发数上限设为3每秒请求数上限设为2。同时对验证码出现率做实时监控一旦单账号验证码出现次数超过阈值自动暂停该账号的任务并告警。7.3 requests.Session不能跨线程共享现象用ThreadPoolExecutor并发抓取时某些请求的登录态莫名其妙丢失返回未登录。排查一开始以为是Cookie过期后来发现是多个线程共用了同一个Session对象。requests.Session内部维护的连接池不是线程安全的并发下Cookie和连接状态会互相污染。解决线程池里每个线程创建自己的Session从Redis加载相同的Cookie初始化。注意Session只要初始化一次不要在每个请求里重复创建否则连接池无法复用速度会下降很多。7.4 Playwright浏览器实例泄漏导致内存爆炸现象渲染服务运行3天后机器内存占用接近100%部分页面渲染变慢。排查通过进程命令发现Chromium子进程数量异常多很多僵尸进程没有回收。原因是代码里browser.close()在某条异常路径上没有执行导致浏览器进程一直驻留。解决所有浏览器操作放进try/finally确保资源释放再写一个定时巡检脚本每5分钟检查一次Chromium进程数量超过阈值就强制清理。后来把浏览器实例池化后这个问题从根本上解决。那一周我学到的教训是玩Playwright的人内存泄漏是躲不掉的必修课。7.5 请求超时黑洞服务端永不返回的请求拖垮线程池现象任务队列积压严重但看日志没有报错所有Worker都像在正常运行。排查逐个请求看耗时发现有一部分请求耗时超过10分钟。这些请求发出去后服务端一直没有返回requests库默认不设timeout线程就一直挂在等待上把Worker线程池占满。解决给所有请求统一设置timeout连接超时5秒、读取超时15秒。超过就抛出异常并走重试逻辑。这是一个看似不起眼但影响极大的细节——不设超时系统会被少数异常请求拖垮。requests.get(url, timeout(5, 15))7.6 任务重复消费导致的脏数据现象明细表里出现重复记录通常是同一条详情数据会被解析两次。排查Redis的brpop取任务后如果进程在处理完成前崩溃Redis会因为连接断开而把任务重新放回队列造成重复消费。Celery默认任务的ack机制配置不当也会出现这个问题。解决任务处理完、数据写入数据库后再确认并移除任务。数据库层面加唯一索引兜底重复写入时更新而不是插入保证幂等。解析入库时带上来源URL的MD5作为业务主键能从根本上防重。站在现在的视角回看这类爬虫系统从来不存在“开发完成”的状态它更像是在和层出不穷的风控策略打一场长期攻防战。今天验证码换了个花样明天接口签名升级后天页面渲染方式又变了。正因如此架构设计才是最重要的资产——模块边界清晰、日志完整、监控到位任何单点变化都能快速响应。我个人这几年最深的体会是爬虫工程化七分在稳定性三分在采集能力。把登录态、验证码、渲染、调度这些环节都做成可替换的标准化模块数据源新增也好、反爬升级也好都只是换掉其中一个零件而已。如果你的爬虫还停留在“本地脚本能跑就行”的阶段建议先从这篇文章里的登录验证模块开始重构逐步把任务队列和渲染服务加进去。等真正跑过一套全天候不停机的采集系统你会对“稳定性压倒一切”这句话有更直观的理解。后面如果有时间我再把监控面板搭建和定时调度的细节单独写一篇。