高性能全量数据采集引擎:从架构设计到实战踩坑全解析

高性能全量数据采集引擎:从架构设计到实战踩坑全解析 如果你接到过这样一个需求把某个公共软件中心里的所有软件信息完整抓下来包括软件名称、版本、分类、下载链接、更新日志、依赖关系而且不是抓一次就完事而是要定期全量刷新还要保证不重复、不遗漏、不把对方服务器打挂——你就会明白全量数据采集这几个字的分量远不是写个循环调 requests 那么简单。这个标题里的高性能和全量是关键。普通爬虫只要能把数据拿回来就行但你一旦要构建引擎面对的就是另一个量级的问题几万甚至几十万个软件条目每个软件又有多个版本、多个下载镜像、多个关联文件光 URL 数量就能到百万级。这时候你会发现单线程跑太慢盲目上并发又容易触发封禁数据解析不健壮就各种 NullPointer存储设计不合理就越来越难维护。这篇文章是我自己在搭建公共软件中心数据采集引擎时沉淀下来的完整思路从架构设计、并发选型、解析策略、存储方案到实测数据、踩坑记录、合规边界都会展开讲。适合已经会写基础爬虫、但想进一步搭建工程化采集系统的开发者也适合那些被数据量大、更新频繁、反爬严格折磨过的朋友。我尽量不绕弯子直接说结论和理由。1. 这个项目的价值定位为什么全量采集远比抓数据复杂1.1 公共软件中心的数据形态和采集场景先明确一下什么是公共软件中心。它可以是各类开源软件仓库、软件下载站、应用分发平台特征是软件数量多、层次分明分类目录、信息结构化程度不一、更新频率不可预测。我和这种数据源打交道比较多原因是很多内部工具链需要离线软件包做安全审计、依赖分析、版本追踪。你不能每次都登录对方网站手动查必须有一个本地镜像式的数据库定时同步。这就把问题从抓取一个页面变成了维护一个大型软件信息仓库。一个典型的公共软件中心数据形态大概长这样分类页操作系统、开发工具、网络应用、多媒体、安全工具……软件详情页名称、版本、作者、许可证、简介、官网、下载按钮版本列表页同一个软件的多个历史版本每个版本有不同的下载地址和更新时间下载文件可能是 exe、dmg、deb、rpm、zip也可能是源码包这些页面之间还有交叉引用。比如一个软件依赖另一个软件的库A 的详情页里会链接到 B 的详情页。所以严格来说你采集的数据不是页面集合而是一张有向图。1.2 全量采集的三座大山规模、时效、完整度我在设计这个引擎之前先用小脚本摸底过目标站点的体量结论很扎心软件条目数约 6 万版本记录约 40 万涉及下载链接约 120 万每天有大量软件发布新版本大约每天新增/变更 2000~5000 条记录页面结构在某些分类下不统一有的软件详情页字段齐全有的只有一句话简介如果按最简单的方式用 requests 单线程逐页请求假设每个页面 1~2 秒一页一天跑一遍一天 1440 秒只够抓 1000 多个页面要抓完 40 万条版本记录光理论上就要好几天。等抓完前面的数据已经过期了。这就是时效的难题。完整度是另一个容易被忽略的坑。全量采集最怕的不是抓不到而是你不知道哪些数据没抓到。比如网络超时、页面结构轻微变化导致解析失败、反爬策略暂时性拦截如果不做校验和补偿机制数据库里会静默缺失一批数据而你浑然不觉。这在后期做数据审计的时候非常致命。1.3 适合谁来学习和借鉴如果你只是需要抓一个几百条数据的小网站这篇的架构对你来说是过度设计的没必要。但如果你遇到的情况符合以下特征那这篇文章的大多数方案都能直接用目标数据量在万级以上且存在层级关系需要周期性地全量刷新而不是一次性抓完就跑需要保证数据的完整性和可追溯性被抓取的对象有明显反爬意识不能直接用默认并发跑这个项目本质上是一个数据管道工程而不只是写爬虫。它的核心难点在于如何用可控的代价在合理时间内拿到尽量完整且最新的数据。下面我按架构、抓取、解析、存储、实测、合规、踩坑几个维度分别展开。2. 引擎架构设计把采集任务拆成流水线2.1 总体分层调度层、抓取层、解析层、存储层我见过很多人写爬虫所有的东西都堆在一个文件里requests 请求、正则解析、数据库写入全部耦合在一起。数据量小的时候没事数据量一大就全乱套了。这个项目的第一个决策就是把流程拆成四层调度层负责任务队列的管理。决定先抓哪些 URL、哪些 URL 优先级更高、哪些需要重试、哪些已经失效需要剔除。抓取层只负责发请求、收响应。不关心响应内容怎么解析只关心有没有成功拿到 HTML 或 JSON。解析层把 HTML/JSON 转成结构化数据。不关心数据怎么存储只输出统一的字典结构。存储层把结构化数据写入数据库。不关心数据从哪来只负责去重、更新、落库。这样做最大的好处是每一层都能独立测试、独立扩展。我一开始就吃了耦合的亏最开始版本里解析函数里直接写了 SQL后面目标站点改版解析要改存储也要跟着动改一处崩一片。拆开之后站点改版几乎只影响解析层。2.2 为什么必须解耦调度与执行可能有人觉得我用一个 for 循环把所有 URL 丢给线程池不就行了为什么还要单独做调度层原因是全量采集场景下的 URL 集合不是一次就能确定下来的。实际情况是先抓分类页从分类页拿到软件列表页的 URL再从列表页拿到详情页的 URL再从详情页拿到版本列表和下载链接的 URL。这是一个不断涌现新 URL 的过程。如果你用静态列表你根本不知道下一步要抓什么。所以调度层必须维护一个动态队列支持从已抓取页面中提取新 URL 并加入队列按 URL 类型划分优先级比如先抓分类页再抓详情页最后抓下载链接记录每个 URL 的抓取状态待抓取/抓取中/成功/失败/重试中支持断点续采重启后能恢复队列而不是从零开始这就是为什么调度层不能省。它本质上是一个简单的任务状态机。我用了一段简单的代码来定义任务状态供参考from enum import Enum class TaskStatus(str, Enum): PENDING pending # 待抓取 IN_PROGRESS in_progress # 抓取中 SUCCESS success # 抓取成功且解析成功 PARTIAL partial # 抓取成功但解析部分失败 FAILED failed # 抓取失败网络/超时/状态码错误 PERMANENT_FAILED permanent_failed # 多次重试后仍失败放弃这个状态机是整个引擎的骨架。后续所有重试、报警、断点续采全都围绕这套状态来展开。2.3 数据流转格式统一 dict 结构解析层输出的数据我建议统一成 dict然后由存储层再决定怎么映射到表。我用的是一个约定俗成的结构record { source_url: https://example.com/soft/xxx, category: development, name: MySoft, version: 1.2.3, license: MIT, description: ..., published_at: 2024-05-01T12:00:00Z, download_urls: [ {platform: linux, url: https://example.com/download/xxx.deb, size: 123456}, {platform: windows, url: https://example.com/download/xxx.exe, size: 654321} ], dependencies: [libabc2.0, libxyz] }这个 dict 的好处是平台无关。今天你用了 MySQL明天想换成 PostgreSQL 或者 ClickHouse存储层改一改适配就行解析层完全不用动。这也是将调试期成本前置、将维护期成本降低的思路。3. 高性能抓取层并发模型与参数实测3.1 三种并发方案对比抓取层是高性能的主战场。我实测过三种主流方案分别记录一下真实感受方案一requests ThreadPoolExecutor多线程这是最容易上手、也是我最终的主力方案。实测下来在目标站点没有强反爬的情况下30~50 个线程跑QPS每秒请求数能稳定在 20~30 左右。瓶颈主要不在 CPU而在网络 IO 等待。requests 的阻塞 IO 模型下线程数量必须大于你期望的并发请求数因为大量线程都阻塞在等待响应上。方案二aiohttp asyncio异步这个方案在 IO 密集型任务里理论上最高效一个线程就能占满网络。但我实测下来有个尴尬的问题aiohttp 的响应解析和 requests 的生态不完全兼容很多辅助库比如某些代理中间件、重试库都要重新适配。而且公共软件中心这种站点的响应速度波动很大异步代码写不好容易出现一个慢请求拖住一堆任务的问题需要有超时和并发控制的精细管理。方案三httpx支持同步异步httpx 是个年轻的库同时支持 requests 风格的 API 和 async。我测试过它在并发场景下的表现效率介于 requests 和 aiohttp 之间接口设计也舒服。但由于我要在大量现有代码库上跑迁移成本高于收益最后没有选它做主力。3.2 我的最终选择和理由我最终选择了requests 线程池 动态并发调整的组合。理由很实际稳定压倒一切。全量采集不是一次性压测而是要跑几天的长任务任何偶发崩溃都要付出沉重的补偿成本。requests 的生态太成熟了。遇到超时、重试、代理切换、SSL 异常都有现成的库可以直接上出了问题也好排查。线程池的并发模型容易控制和调参。我用的是concurrent.futures.ThreadPoolExecutor配合队列调度每个线程从队列取任务、执行、回报结果逻辑非常清晰。核心代码大致长这样import requests from concurrent.futures import ThreadPoolExecutor, as_completed from queue import Queue def fetch(url, session, timeout10, retries3): for attempt in range(retries): try: resp session.get(url, timeouttimeout) if resp.status_code 200: return url, resp.text elif resp.status_code in (403, 429): # 遇到反爬限制等待后重试 time.sleep(2 ** attempt) else: return url, None except requests.RequestException as e: if attempt retries - 1: return url, None time.sleep(1) return url, None with ThreadPoolExecutor(max_workers32) as executor: future_to_url {executor.submit(fetch, url, session): url for url in url_batch} for future in as_completed(future_to_url): url, content future.result() if content: # 交给解析层 pass3.3 关键参数调优并发数、超时、连接池、重试经过我的多轮实测有几个参数真的值得反复调并发数我测试过 10、20、30、40、60 个线程。20 以下跑不满网络带宽60 以上开始出现大量超时和连接重置30~40 是最稳的甜点区。为什么因为目标服务器对单 IP 的并发连接数是有限制的超过之后不是立刻拒绝而是行为变得不可预测——有的请求慢到超时有的直接连接重置。所以不要盲目追求高并发找到对方服务器的舒适区才是最优解。超时时间连接超时设为 5 秒读取超时设为 10 秒。最开始我设的是 30 秒结果一个卡死的请求能把线程占住 30 秒在线程池里相当于白白浪费一个并发名额。设短一点配合重试机制反而更稳。连接池requests 的 Session 内部维护连接池默认大小是 10。并发 32 个线程时连接池太小会导致频繁建连。我把它调大from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections20, pool_maxsize40, max_retries0) session.mount(http://, adapter) session.mount(https://, adapter)重试策略用指数退避而不是以固定间隔重试。第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多三次。这个策略的好处是给服务端喘息时间避免重试风暴。3.4 限速与礼貌策略别把自己爬进黑名单即使目标是公开数据也不代表你可以用最高强度去打对方服务器。我在这个项目里做了三层限速全局 QPS 限制用一个简单的令牌桶保证全局每秒请求数不超过 20。单域名并发限制如果同一个域名下的请求过快对方会明显感知。我限制同一时刻对同一域名的请求数不超过 8。低峰期调度部分站点在白天响应快晚上会启动一些防御策略。我会把重试比较多的 URL 留到低峰期再跑。很多新手觉得我加了 User-Agent 就是伪装了其实服务器可以从请求频率分布、行为特征来判断你是人还是爬虫。人的操作是慢、稀疏、有思考间隔爬虫是快、密集、整齐划一。所以限速不是怂而是一种策略。4. 解析层设计从粗糙正则到结构化字段4.1 公共软件中心常见页面结构分析在动手写解析之前先把目标站点的页面结构摸清楚。公共软件中心的页面通常有几种典型模式服务端渲染的 HTML数据直接嵌在 HTML 里解析相对简单。JSON API 接口页面通过调用后端接口返回 JSON前端渲染解析最省事。JavaScript 动态渲染数据需要执行 JS 才能拿到这个是最麻烦的。我遇到的最常见情况是混合模式列表页是 HTML详情页的部分字段通过 JS 异步加载。这种情况下你需要先判断哪些字段在 HTML 里就有哪些必须走额外的 API 请求。建议先抓几个页面存下来人工分析一遍。我一般会用 Python 快速检查页面里是否存在特定的标志性结构import re def detect_page_type(html): if re.search(r__NEXT_DATA__, html): return nextjs_ssr # Next.js 服务端渲染数据在 script 里 if re.search(rwindow\.__INITIAL_STATE__, html): return vue_ssr if re.search(rapplication/json, html): return json_api return html4.2 解析方案对比XPath / CSS选择器 / 正则解析 HTML 我推荐lxml XPath 或者parsel CSS 选择器不推荐把正则当主武器。原因是 HTML 结构变化非常频繁正则的健壮性太差。比如你要取软件名称用 XPathfrom lxml import html tree html.fromstring(html) name tree.xpath(//h1[classsoftware-name]/text()) version tree.xpath(//span[classversion]/text())用 CSS 选择器from parsel import Selector sel Selector(texthtml) name sel.css(h1.software-name::text).get() version sel.css(span.version::text).get()正则只适合处理精确、无嵌套的片段比如从文本中提取版本号、邮箱、URL。这算是正则的正确使用方式version_pattern re.compile(r(\d\.\d(?:\.\d)?)) match version_pattern.search(raw_text)4.3 软件版本号、依赖关系、下载链接的特殊处理软件版本号的解析是公共软件中心项目里最容易出错的地方。版本号不算严格的只有数字和点它可能有 alpha、beta、rc、build 号、日期后缀。比如1.2.32.0.0-beta.12024.05.011.2.3.4567这种情况下不能简单用字符串比较大小必须实现一个版本号比较器把版本拆成数字段和非数字段逐段比较。这个小工具看着不起眼但增量更新的时候全靠它来判断新版本是否真的比旧版本新。依赖关系的解析更麻烦。软件 A 依赖软件 B但 B 的标识可能是多种形式有的写的是包名有的写的是 URL有的不写版本号。我的处理方式是解析出依赖后再通过名称模糊匹配去辅助表中找对应的软件条目。如果找不到先标记为未解析依赖等下一轮采集时再尝试。下载链接的处理需要特别小心。很多下载链接不是直链而是带参数的跳转链接甚至有时效性比如签名 URL 几小时后失效。这意味着你不能只在采集时验证链接还要定期重新验证链接的有效性。我会在存储层单独维护一张下载链接表每次全量采集后抽样验证一定比例的链接是否仍有效凡是失效的标记出来并在下轮更新时重点抓取。5. 存储与增量更新数据落库的正确姿势5.1 表结构设计软件主表、版本表、下载链接表软件中心的数据天然是一对多的关系一个软件有多个版本一个版本有多个下载文件。如果全塞在一张表里字段冗余严重更新也麻烦。我分为三张表软件主表software存软件的稳定属性。字段类型说明idINT PK AUTO_INCREMENT自增主键nameVARCHAR(255)软件名称categoryVARCHAR(100)分类licenseVARCHAR(50)许可证official_urlVARCHAR(500)官网地址descriptionTEXT简介first_seen_atDATETIME首次发现时间last_updated_atDATETIME最近更新时间版本表software_version存每个软件的每个版本信息。字段类型说明idBIGINT PK AUTO_INCREMENT主键software_idINT关联软件主表versionVARCHAR(50)版本号release_dateDATE发布日期changelogTEXT更新日志checksumVARCHAR(128)文件校验值如有下载链接表download_link存具体的文件下载位置。字段类型说明idBIGINT PK AUTO_INCREMENT主键version_idBIGINT关联版本表platformVARCHAR(50)平台类型urlTEXT下载地址file_sizeBIGINT文件大小字节last_checked_atDATETIME最后验证时间is_validTINYINT链接是否仍然有效这套结构能支持绝大多数查询场景。比如查一下某个软件在某个平台的最新版本一条 SQL 就能搞定。5.2 全量更新与增量更新的状态对比逻辑你可能会问既然说是全量采集为什么还要增量更新这两者不矛盾。我的做法是第一次做全量抓取之后每次做增量比对。每次任务开始时先拉取软件列表页提取所有软件的标识符比如 name 最新版本号和数据库中的记录比对。如果一致跳过如果不一致说明这个软件有更新进详情页重新抓取。这样就能避免每天把 40 万条版本记录全部重新抓一遍。实现上可以用一个 hash 标记。我的简化方案是def compute_record_hash(record): content f{record[name]}|{record[version]}|{record[published_at]} return hashlib.md5(content.encode()).hexdigest()把 hash 存在软件主表的last_hash字段里每次采集时对比。hash 一样就跳过不一样就更新。5.3 断点续采如何保证重启后不重复不遗漏全量采集一旦跑起来少则几小时多则几天。这个过程中很可能会遇到服务重启、目标站点临时抽风、网络断线、数据库连接超时。如果没有断点续采机制之前的辛苦就白费了。我的做法是在调度层的队列表里记录每个 URL 的状态。任务启动时先查库里有哪些PENDING状态的任务从这些任务继续跑同时把所有IN_PROGRESS状态的任务重置为PENDING因为进程已经退出那些任务实际没有完成。只有SUCCESS状态的任务不会被重复执行。这个机制的可靠性在于每个 URL 的抓取、解析、入库这三个动作要么全部成功要么重来一遍。我用了先标记后执行的策略从队列取出任务状态置为IN_PROGRESS执行抓取解析成功数据写入数据库用事务保证原子性更新队列任务状态为SUCCESS如果第 4 步之前进程崩了重启后任务状态还是IN_PROGRESS会被重置为PENDING重新执行数据也不会重复因为存储层做了唯一索引。5.4 数据库连接池和批量写入的注意事项写数据库的时候如果用一条 SQL 插一条40 万条记录能让数据库连接忙死。我的建议是使用批量写入。以 MySQL 为例用executemany一次插入几百条import MySQLdb def batch_insert(cursor, table, columns, rows): placeholders ,.join([%s] * len(columns)) col_str ,.join(columns) sql fINSERT INTO {table} ({col_str}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE ... cursor.executemany(sql, rows)数据库连接池我用的是DBUtils.PooledDB可以避免频繁建立连接。注意连接池的大小要和采集并发数匹配我用的持久连接池 10 个连接写操作串行批量执行读操作可以并发。实践证明这个配置在数据量百万级时依然够用。6. 全流程实测跑通一个真实软件中心的完整链路6.1 目标站点分析和入口梳理我用一个模拟的真实案例来说明整个引擎的工作流程。假设目标站点是https://soft.example.org某种公共软件中心结构为分类页https://soft.example.org/category/{分类名}列表页每类下面有多页软件列表详情页https://soft.example.org/software/{软件slug}版本历史https://soft.example.org/software/{slug}/versions首先用调度层初始化入口 URL把分类页全部加入队列。categories [development, system, network, multimedia] initial_tasks [] for cat in categories: initial_tasks.append({ url: fhttps://soft.example.org/category/{cat}, type: category, depth: 1, })这里的depth字段很重要它可以防止爬虫无限深入。我把深度限制为 3分类页1→ 列表页2→ 详情页3。版本历史页虽然属于第四层但在详情页里单独做跳转不计入深度限制。6.2 实测采集过程的性能数据我跑了一轮真实全量采集服务器配置是 4 核 CPU、8G 内存普通宽带网络。实际数据如下目标约 6 万个软件条目、41 万个版本记录总 URL 数约 120 万并发线程数32全局 QPS约 15~25总耗时约 22 小时成功解析率99.1%失败 URL 数约 1.1 万个主要是超时和 40322 小时听起来很长但你要知道在单线程下这个任务要跑一周多。而且这 22 小时大部分时间是花在下载链接的验证上纯页面抓取只占大约 6 小时。如果只做页面数据的增量更新不做链接验证每天跑一轮大约 40 分钟就完成了。6.3 运行中遇到的异常与修复过程实测过程中遇到过几个典型问题逐个说一下问题一内存不断增长跑了几个小时后内存占用从 300M 涨到 3G 多差点 OOM。排查发现是队列里的 URL 只进不出而且每个 URL 都保留了一整页 HTML 在内存里等待解析。修复方案是解析完立即释放 HTML不保留页面快照队列的已完成任务定期清理出内存只把状态写入数据库。问题二数据库连接被间歇性断开MySQL 的wait_timeout默认是 8 小时我的连接池里的连接在空闲 8 小时后被服务端断开但连接池不知道继续用就报错。修复方案是加一个连接池的ping检查每次取出连接时先 ping 一下如果失效就重建。def get_conn(pool): conn pool.connection() conn.ping(reconnectTrue) return conn问题三部分页面的字符编码不一致站点大多数页面是 UTF-8但一些老页面是 GBK/GB2312直接按 UTF-8 解析会得到乱码。写了一个小函数识别编码import chardet def safe_decode(content_bytes): detected chardet.detect(content_bytes) encoding detected.get(encoding, utf-8) or utf-8 try: return content_bytes.decode(encoding, errorsreplace) except LookupError: return content_bytes.decode(utf-8, errorsreplace)chardet的性能不算快但对于少量老页面完全可以接受而且用了缓存机制同一个域名只检测一次。7. 反爬与合规的边界哪些限制要应对、哪些底线不能碰7.1 常见限制手段与应对方案公共软件中心的运营者通常不是针对某个人做反爬而是防御所有异常流量。我遇到的限制主要是这几类User-Agent 检测最简单的限制。解决方式是随机切换常见浏览器的 UA并保持请求头完整Accept、Accept-Language、Referer 等。我用了一个 UA 池每次请求随机取一个。频率限制单位时间内同一 IP 的请求数超过阈值就返回 429 或直接拒绝。对策就是前面提到的限速以及遇到 429 时退避等待。这里有个优化点不是所有 URL 的优先级都一样下载链接验证这种低频操作可以放在深夜跑避开高峰。动态参数有些页面在请求时需要携带特定的 token 或签名可能藏在 cookie 里或由 JS 动态生成。我在一个站点遇到过X-Requested-With头的校验请求里带上这个头就能正常访问。还有的站点会校验Referer把来源页面加上就行。这些都是公开协议层面的处理没有破坏任何技术措施。JS 动态渲染数据藏在 JS 里直接抓 HTML 拿不到。我的处理策略是优先寻找页面里是否有 JSON 数据接口比如window.__DATA__ {...}。如果确实没有才考虑用无头浏览器比如 Playwright。但无头浏览器的开销非常大一条页面几秒钟并发上不去所以只在万不得已时才用。多数情况下公共软件中心的列表页和详情页都有服务端渲染的版本不需要走到那一步。7.2 合规底线公开数据、robots、抓取强度这里必须把话说清楚采集公共软件中心的公开信息软件名称、版本、描述等本身并不违法但前提是——只采集公开的、无版权问题的信息遵守对方网站的 robots 协议和用户协议不采集任何个人隐私数据控制抓取强度不对目标服务器造成实质影响。我在项目启动前会先查看目标站点的 robots.txt 和用户协议。如果站点明确禁止爬虫抓取就要重新评估这个项目的合法性和必要性。如果是内部使用、数据量可控、且不对网站造成压力我通常会在项目中设置更保守的速率比如 QPS 限制为 5~10并在代码注释中标注合规要求。7.3 被封禁后的处理策略再稳的爬虫也难免遇到封禁。我遇到过一次连续 403原因是某个重试逻辑写成了无限循环导致短时间内对同一路径发起了上百次请求。被封禁后的正确做法是立即停止所有抓取任务不要硬闯。确认封禁范围是只封了某个 URL还是整个域名可以通过手动访问页面来验证。等待冷却时间一般等 1~2 小时有的严格站点要 24 小时。降低并发和频率从被封之前的 20 QPS 降到 5 QPS。更换出口 IP如果你的部署环境支持多 IP可以切换一个出口但要注意同一个 IP 仍然要限速。我在架构里专门加了一个熔断器逻辑如果连续 10 个请求都返回 403/429就自动暂停整个抓取层等待冷却后再继续。这个比事后人工干预可靠得多。8. 几件印象深刻的踩坑小事8.1 重试风暴无限重试把对方服务器打挂这个是我最想分享的一个教训。早期版本里对失败请求写了while True式的重试想着多试几次总能成功。结果有一次目标站点刚好在维护返回 500我的程序就不停地重试几小时里发了几十万个请求给对方服务器造成了明显压力自己的 IP 也被封得干干净净。后来我做了两个改进一是所有重试必须用指数退避并且有最大重试次数二是加全局熔断器当失败率超过 30% 时停止抓取并报警。这才避免了类似的悲剧。8.2 解析器没关掉导致内存泄漏这个坑藏得很深。lxml的html.fromstring()会创建一个解析树这个树在不再使用后需要被正确释放。如果你把每次解析得到的 HTML 元素对象都存在某个全局列表里内存会不断增长。我处理的方式是每批任务处理完显式调用del tree并建议在每次循环后gc.collect()。另外一个大坑是 Python 的requests.Session没有关闭。每创建一个 Session 都会打开连接池不关闭就是句柄泄漏。确保每个线程用完 session 后close()。8.3 下载链接的时效性验证我最初采集时把下载链接直接入库没有验证。结果用了几个月后发现很多链接已经变成 404。后来我加了一个链接验证任务每轮全量采集结束后随机抽样 10% 的下载链接发 HEAD 请求验证状态码。发现失效比例超过 5% 时触发对这些软件的重新采集。如果目标站点支持 HEAD 请求这个验证成本很低。但有些服务器对 HEAD 处理不标准返回 405。这种情况可以退化为 GET 请求只读取前几十字节就断开。如果你想再进一步还可以在解析时计算下载文件的大小和服务器返回的 Content-Length 对比用这个来判断链接是否完整。8.4 时区与时间字段的坑公共软件中心的时间字段格式五花八门有 UTC、有东八区、有带时区偏移的 ISO 字符串。我最初直接存字符串后面做最近一周更新列表时发现时间对不上。后来统一把所有时间解析成 UTC入库一律存datetime类型展示时再转时区。这个规范看着小但从第一天就定下来能省很多事。最后再分享几个实操层面的小建议项目跑通到现在我再补充几个不写进文档但非常实用的经验。一是日志一定要结构化。不要只打印字符串要打印 JSON 格式的日志包含 URL、状态、耗时、重试次数。后面任何排查都离不开这些数据。我用的标准格式是{time: 2024-06-01T10:00:00Z, level: INFO, url: https://soft.example.org/software/xxx, status: 200, duration_ms: 350, retry_count: 0, task_type: detail}二是监控指标一定要有。可以不用上重型的监控系统但至少要有一个简单的计数器已抓取数、已解析数、失败数、重试数、当前队列长度、当前内存占用。我用的是 Prometheus client 暴露指标再在 Grafana 里配了一个仪表盘。没有监控跑了 20 小时的定时任务半夜挂了第二天早上才知道那种感觉太难受了。三是数据校验不要等全部跑完再做。在采集过程中就应定期抽样检查随机抽取若干已入库的记录和原始页面重新比对确认解析逻辑没有退化。这个过程可以做成独立的校验任务每跑一万条就触发一次。四是版本控制里别把代码写死。软件中心的站点结构一定会变所有 XPath/CSS 选择器建议都写在配置文件里而不是散落在代码里。站点改版时只需要改配置文件就能适配大部分变化。这个项目做到现在我最大的体会是爬虫写得好不好不在于并发有多高、代码有多花哨而在于它是否能在无人值守的情况下稳定跑几天并且在出问题时让问题可定位、可恢复、可追溯。高性能全量采集引擎本质上是一个可靠性工程而不只是抓取工程。希望我的这些经验能帮你少走点弯路。