资讯动态

Python并发爬虫:线程池设计、顺序落盘与断点续抓实战

发布时间:2026/10/16 5:26:22 来源:尧图企业网站定制
看到这个标题我第一反应是皱眉——“Python创建1000个线程爬盗版小说网站按章节顺序排进txt”。先说大实话这标题里的三个兴趣点其实都很有讨论价值并发规模怎么定、抓取任务怎么调度、乱序结果怎么归位。但“1000线程”和“盗版小说”这两件事我必须先泼两盆冷水。前者是典型的性能认知误区后者是版权红线。我写爬虫几年最深的体会是真正值得钻研的从来不是“怎么把速度堆上去”而是“怎么在一个合理且合规的框架下把任务做对、做稳、做成可维护的工程”。这个标题刚好集中了抓取、并发、调度、数据落盘这几个最常见的工程问题所以我打算用一篇实战笔记把正确做法完整拆开讲。下面案例里我不会碰任何盗版内容源。我会用“本地部署的测试小说站”作为演示对象这个站点的页面结构、编码方式、章节链接规则都按常见内容站的模式搭建。你只要把思路和代码迁移到有授权的内容源或自己的项目上即可。1. 关于“1000线程”和“盗版小说”先把两盆冷水泼完1.1 1000线程为什么只是听起来快Python的多线程有个绕不开的机制全局解释器锁GIL。同一时刻只有一个线程能执行Python字节码这是CPython解释器的底层限制。也就是说如果你的任务主要是CPU计算用1000个线程也不会让计算快一毫秒反而会因为线程切换疯狂损耗性能。爬虫任务呢大部分时间花在网络IO上等待响应时GIL会被释放所以多线程确实能提升吞吐量。但“提升”不等于“线性提升”更不等于“线程越多越好”。我之前在一个模拟环境里做过对比测试目标是一台本机起的HTTP服务返回一小段文本。用单线程顺序抓100个章节耗时约40秒用16线程的线程池耗时约6秒强行开到300线程耗时反而涨到11秒左右。我没敢真开1000线程打到公网服务上但在本机模拟过1000线程同时发起请求结果那台测试服务直接出现大量连接超时我的程序也先崩了——不是目标崩是我的进程先崩。线程是有真实成本的。每个线程都要占用独立栈空间操作系统默认栈大小通常是8MB64位Linux下1000个线程光是虚拟内存就预留了接近8GB线程调度、上下文切换、Python内部的锁竞争都会随着线程数增长急剧放大。用一个生活化类比10个人流水线搬砖效率确实比1个人高但1000个人挤在同一个仓库门口抢着进出效率反而不如那10个人。并发不是越高越好是有甜点区的这个后面会具体讲到数据。1.2 版权红线下的目标调整这个案例我换成自家测试站接下来是更重的那盆冷水盗版小说网站这个目标本身就不该碰。抓取未经授权的版权内容不仅涉及侵权还可能让你自己的服务器、账号、网络背上法律风险。我在技术社区的交流中经常看到有人问“怎么爬XX小说站”这种问题我一律不回答也不会提供任何形式的绕过方案。技术本身是中性的但怎么用是有立场的。为了演示“高并发抓取”和“章节顺序落盘”的核心技术点我会用一个更干净的测试环境本地部署的小说内容站或者任何你有明确授权的内容源。比如我自己就常常用本地一个模拟站点来跑实验页面结构做得跟真实小说站类似——有书籍目录页、章节正文页、章节编号URL——但所有内容都是我写的示例文本不存在版权问题。如果你把本文的思路用在真实业务中记住两条底线第一目标内容必须是你有权限抓取的比如你自己搭建的服务、明确开放API的平台、或对方授权允许抓取的站点第二无论目标服务是否开放都要尊重它的robots.txt声明并控制合理的请求频率。这不仅是合规问题也是工程问题——对目标服务器造成过大压力你自己的爬虫也得不到好结果。2. 重画架构图并发抓取的每一步其实都有顺序逻辑2.1 数据模型先行目录页与正文页怎么拆很多新人上手就写循环先是for i in range(1000):然后requests.get(url)一把梭。这种写法大概率会在跑到一半时出各种问题。我的习惯是先画数据模型把任务抽象清楚再写代码。假设我的本地测试站结构如下书籍目录页http://localhost:8000/book/1001/章节正文页http://localhost:8000/chapter/1001/1/目录页里按顺序列出了所有章节标题和链接正文页里有章节标题和正文内容。这样一个简单的结构其实已经涵盖了绝大多数内容站的基本特征先拿到“目录清单”再根据清单去抓“详情数据”。于是程序内部的数据结构可以这样设计# 章节信息只需要三个字段 from dataclasses import dataclass dataclass class Chapter: book_id: int no: int # 章节序号从1开始连续编号 title: str url: str为什么要单独维护一个no字段因为URL里的数字不一定连续有些站会做隐藏章节、删除章节而我们要保证最终txt文件的顺序绝对正确不能依赖URL的数值必须在解析目录页时就建立一个带序号的章节列表。这一步是把“顺序问题”前置解决的关键。拿到目录页后用requests配合解析库提取所有章节链接构造出chapters [Chapter(...), ...]。这个列表是后续所有任务的数据源本身是有序的并发抓取过程中即便返回顺序乱了最后也可以根据no归位。2.2 十个线程还是三五个协程并发规模怎么定这是我被问得最多的问题“到底开多少线程合适”先说结论没有固定答案但有个基本区间。对于网络IO密集型的爬虫任务用线程池时max_workers设置在8到16通常是比较稳的甜点区。如果你的网络延迟很高比如目标服务器在海外可以适当加到20到30如果你和目标服务器在同一个机房4到8个线程可能就足够压满带宽了。你可以在自己的小规模测试里做一个对照分别用1、4、8、16、32、64线程抓同样数量的页面看哪一档的“总耗时”最低且“失败率”最低那就是你当前网络条件下的最优值。除了线程还有一个更轻量的选择协程。用asyncio加aiohttp单线程内通过事件循环调度成百上千个异步任务资源开销比线程小得多。现代Python 3.11以上的asyncio稳定性已经相当好如果要抓几千个页面我更推荐协程方案。但协程的代码风格跟同步写法差别较大调试门槛高一些如果是几个小时内跑完的一次性任务线程池其实足够。下面这张表是我对不同方案的直观对比方案并发模型适合场景主要缺点requests ThreadPoolExecutor多线程中小规模、快速上手线程数过高时有调度开销asyncio aiohttp协程大规模、延迟高、连接复用要求高异步编程心智负担较重Scrapy框架内置并发需要分布式、中间件、Pipeline架构较重学习曲线长multiprocessing多进程CPU密集解析任务进程间通信、内存开销大这也是对“1000线程”最直接的回应在Python里线程不是拿来堆数量的无论是线程池还是协程核心思路都是用合理的并发数去掩盖网络延迟而不是用极端并发去制造更大问题。2.3 请求层的保命设计复用会话、超时、重试与限速浮在表面的是并发数量沉在水下的是请求质量。一个成熟的抓取任务请求层至少要包含四件事连接复用、超时控制、失败重试、频率限制。连接复用用requests.Session()代替裸的requests.get()。Session会复用底层TCP连接避免每抓一个章节都重新握手对速度和目标服务器负载都有好处。超时控制必须同时设置连接超时和读取超时例如timeout(5, 10)。不设超时的爬虫就像没有保险丝的电线一旦某个连接卡住整个线程池都会被拖着走不了。失败重试网络抖动是常态但重试不能是死磕。最稳妥的是“指数退避”策略第一次失败等1秒第二次等2秒第三次等4秒连续失败多次后放弃该章节并记录日志。频率限制无论线程池开多少线程总体上要控制请求速率。一个简单的做法是每个线程在请求前time.sleep(0.1)左右另一个更精确的做法是使用令牌桶限速器比如rate_limiter RateLimiter(max_calls10, period1)之类。这样做不是为了“温柔”而是为了让爬虫能长时间稳定运行。请求太多太快目标服务器触发限流后你的下一次请求可能直接返回503或403几十个线程瞬间全部失败最后什么都抓不到——这个我在后面的实测部分会展示真实数据。3. 用线程池落地抓取生产线生产者消费者分离3.1 ThreadPoolExecutor替代“手搓1000线程”的写法既然已经明确“1000线程”是个伪需求那正确的姿势是什么答案是concurrent.futures.ThreadPoolExecutor。Python官方提供的线程池可以控制最大并发数并且用队列来分发任务比手动threading.Thread加Queue要省心得多。先看最基础的框架import requests from concurrent.futures import ThreadPoolExecutor, as_completed ROOT http://localhost:8000 def fetch_chapter(chapter): 抓取单个章节正文返回(章节序号, 正文内容) resp requests.get(chapter.url, timeout(5, 10)) resp.encoding utf-8 text parse_content(resp.text) # 假设有解析函数 return chapter.no, text def main(): chapters get_chapter_list(ROOT /book/1001/) results {} with ThreadPoolExecutor(max_workers16) as executor: future_map {executor.submit(fetch_chapter, ch): ch for ch in chapters} for future in as_completed(future_map): no, text future.result() results[no] text # 到这里results按no排序后写文件这个模式把并发细节交给线程池我们只需要关心“提交任务”和“收集结果”。as_completed会在每个任务完成时立刻返回所以不需要等待最慢的那个。进程退出时线程池自动收尾不会残留僵尸线程。需要注意一个细节future.result()可能在任务内部出现异常如果某个章节网络异常这里会抛异常。因此fetch_chapter函数内部要做好异常捕获返回(chapter.no, None)或抛出带章节信息的自定义异常方便外层统一记录失败章节。不要在一个章节失败时导致整个流程崩溃。3.2 把抓取、解析、落盘拆成三条流水线有一个很常见的错误在fetch_chapter函数里边抓取边解析边写文件。看起来高效其实把多个职责揉在一起既不利于定位问题也不利于控制并发下的资源竞争。我更推荐的拆分方式是三段式抓取线程只负责拿网页HTML存到内存或临时文件。解析阶段从HTML中提取标题和正文。落盘阶段把有序结果写到最终txt文件。可以用queue.Queue把三个阶段串起来形成流水线。不过对于中等规模任务更省事的做法还是分阶段执行先并发抓取全部章节内容并暂存再做解析和合并。比如用Python的sqlite3做过渡存储每个章节一行字段是(book_id, no, title, content)。这样即使任务中途崩了已抓完的内容也不会丢。import sqlite3 conn sqlite3.connect(novel_cache.db) conn.execute(CREATE TABLE IF NOT EXISTS chapters (book_id INT, no INT, title TEXT, content TEXT)) conn.execute(CREATE UNIQUE INDEX IF NOT EXISTS idx_book_no ON chapters(book_id, no)) conn.commit()写入时用INSERT OR REPLACE这意味着断点续抓时重复抓到的章节会自动覆盖旧数据。这个设计的价值是爬虫不要求一次跑完可以随时中断、随时从上次的位置继续。3.3 断点续抓与状态统计让爬虫可以被优雅打断我实际跑任务时最怕的不是慢而是跑到一半手动停了重启以后全得重来。所以断点续抓不是可选优化而是基本要求。实现起来就是两步抓到内容先写数据库或临时文件启动时查询哪些章节已经抓过、跳过这些章节。def get_missing_chapters(chapters, cache_pathnovel_cache.db): conn sqlite3.connect(cache_path) done set(row[0] for row in conn.execute( SELECT no FROM chapters WHERE book_id?, (BOOK_ID,))) return [ch for ch in chapters if ch.no not in done]此外建议在控制台打印进度统计例如# 每秒统计一次已完成数量 # 输出示例: [ 145/1200 ] 失败2 剩余1055 耗时00:03:21实测下来一个带断点续抓和进度统计的爬虫会给你的心态带来很大改变。你不再需要盯着屏幕担心意外中断你可以放心去吃饭回来以后看看日志继续让它跑就行了。4. 章节顺序落盘的两种正解占位回填与分片合并4.1 谁先返回谁先写结果是灾难性的乱序很多新手走到这一步会自然写出类似这样的代码with open(novel.txt, w) as f: for future in as_completed(future_map): no, title, text future.result() f.write(f{title}\n{text}\n)这个逻辑就是“谁先抓完谁先写”。在并发环境下返回顺序完全不可控可能第45章先抓到第5章后抓到于是txt文件里第45章排在前面。等全部写完才发现整个文件章节错乱还得重新处理。为什么会乱因为每个线程发出去的请求经过的网络路径不同服务器响应速度也不同甚至同一个章节请求第一次很慢、第二次很快。并发抓取本身就是“乱序返回”的不附加任何约束输出自然是乱的。整理一下思路抓取阶段允许乱落盘阶段必须有序。两者之间必须有一个“归位”步骤。4.2 方案A按章节号占位用seek回填内容方案A的思路很巧妙既然每章长度不定那就先预生成一个总文件为每章预留一个固定大小的“占位区间”。抓完某一章后根据章节号计算出这段内容在文件中的起始偏移量用seek跳过去把内容写入对应的区间。这个方案的精髓在于预留阶段确定顺序回填阶段只负责原地写入。但工程上有两个麻烦一是每章预留多大如果预留太小长章节会溢出覆盖下一章的内容如果预留太大文件中间会有一堆空白分之浪费空间也不美观。二是最后需要一次“压缩”处理来去掉占位符。所以我个人对方案A的评价是思路值得讲因为它揭示了“先分配位置再填入数据”这个哲学但工程实践中我几乎不用它。除非你有办法预先精确知道每一章的字数比如从目录页能拿到字数信息否则预留空间的不确定性太折腾。如果真想用回填可以简化出一版专门用于标题对齐with open(novel.txt, w, encodingutf-8) as f: for ch in chapters: # 有序的章节列表 f.write(f{ch.title}\n.ljust(50)) f.write(\n) # 占位后面用seek回填该章内容位置后再写正文 # 但正文内容仍需通过临时文件/数据库曲线归位整体复杂度过高为了完整性我给出示例但我的结论是优先看方案B。4.3 方案B并发写临时分片最后顺序合并方案B是更接地气、更可维护的做法并发抓取阶段每个章节写入一个独立的临时文件或者上一节说的数据库表临时文件命名为chapter_0001.txt、chapter_0002.txt这样带序号的文件名全部抓完后写一个简单的合并脚本按文件名序号从小到大读取并拼接到最终的novel.txt。这个方案有什么好处第一临时分片天然隔离并发写入互不干扰不需要加锁第二文件名上的序号就是排序依据非常直白第三如果某个章节没抓到合并时一检查序号缺失就能发现方便单独补抓。关键代码分为两段。抓取阶段的落盘def save_chapter(book_id, no, title, content, tmp_dirtmp_chapters): path f{tmp_dir}/chapter_{no:04d}.txt with open(path, w, encodingutf-8) as f: f.write(f{title}\n\n{content}\n)合并阶段import os def merge_chapters(tmp_dir, output_file): files sorted(os.listdir(tmp_dir)) # 按文件名排序 with open(output_file, w, encodingutf-8) as out: for name in files: if not name.endswith(.txt): continue with open(os.path.join(tmp_dir, name), r, encodingutf-8) as f: out.write(f.read()) out.write(\n\n)合并脚本还可以顺便统计数量total sum(1 for name in files if name.endswith(.txt)) print(f合并完成共 {total} 个章节)这里有个细节临时文件名对齐前导零比如chapter_{no:04d}.txt。如果只有10章可能看不出问题一旦超过1000章chapter_2.txt和chapter_10.txt的字符串排序会出问题10排在2前面。用04d格式化就能保证字典序等于数值序。4.4 用脚本验证章节连续性别等读者来骂合并完成不等于完事。你一定要验证最终文件的章节编号是否连续。这一步别偷懒用脚本扫一遍就行。最简单的验证方式抓取最终txt里所有## 第X章这样的标题行提取编号检查是否1到N连续且无重复。如果站点章节标题不统一也可以利用合并前已有的chapters列表做比对import re def check_continuity(output_file, expected_total): titles [] with open(output_file, r, encodingutf-8) as f: for line in f: m re.match(r^第(\d)章\s(.*)$, line.strip()) if m: titles.append((int(m.group(1)), m.group(2))) nos [no for no, _ in titles] if len(nos) ! expected_total: print(f章节数不足期望{expected_total}实际{len(nos)}) missing [i for i in range(1, expected_total 1) if i not in set(nos)] if missing: print(f缺失章节{missing[:20]}...) print(验证完成)这一步的价值不在于写完代码那一刻而在于它能成为你整个抓取流程的“质量闸门”。以后不管你是改代码、改站点结构、还是增加新书只要跑一遍验证就知道输出文件有没有问题。5. 实测对照与现场翻车记录5.1 一组小规模对照测试1线程、16线程、1000线程我在本地测试站上模拟了1200个章节的抓取实验用三种并发配置做对照。测试站是本机服务网络延迟非常低约1到5ms所以这里的绝对时间只对当前环境有意义我更想让你看的是“相对变化趋势”。并发配置总耗时失败数进程内存峰值备注单线程约42秒0约40MB稳定但慢16线程线程池约7.5秒1约48MB最快且稳定64线程线程池约8.9秒6约70MB开始出现少量超时1000线程手搓约13秒约240约400MB以上大量连接超时进程接近崩溃你没看错1000线程并没有比64线程快甚至比64线程还慢而且失败率爆炸。原因我前面讲过了线程调度开销、内存压力、连接数膨胀以及服务端在大量并发请求下开始拒绝服务。如果把这个实验搬到公网环境1000线程的结局大概率更惨目标站点有正常的风控时高并发会直接触发拦截你的IP被限流或临时封禁之后所有请求全部失败。这就是为什么我一直强调并发控制的核心指标不是“并发多高”而是“平均响应时间、失败率、完成率”这三者是否健康。5.2 运行中真实遇到的坑连接咬死、句柄耗尽、日志刷屏第一个坑是连接咬死。通俗讲就是某个请求既不返回也没超时线程池里的工作线程一直卡在那里任务队列越积越多。根源通常是没设超时或目标服务器有连接黑洞。解法是预设合理超时同时给每个工作线程的请求加重试上限比如连续失败3次就放弃该章节记录到失败清单。第二个坑是文件句柄耗尽。每个TCP连接、每个打开的临时文件都会占用一个文件描述符。我曾在Windows和默认Linux ulimit环境下跑过高并发结果进程报“Too many open files”。这个问题的核心原因还是“同时打开的连接/文件太多”。缓解措施是限制并发数、及时关闭不再使用的文件、使用with语句管理文件句柄。第三个坑很容易被忽视日志刷屏。如果你在每个章节请求前后都print一行日志16线程跑1200个章节就会刷出2400行输出既拖慢写入速度也让你没法看关键信息。我的做法是进度汇总在生产消费阶段打印错误日志单独打到一个文件里控制台只显示汇总。5.3 关于限速与重试我最想说的一句话收尾最后想分享一个我在几次翻车后总结的经验真正的稳定不是来自更强的反制手段而是来自更克制的请求节奏。我自己最舒服的抓取体验反而是把线程数压到8、每线程请求间隔0.2秒、重试退避指数拉满的时候。那个过程几乎不需要盯着看断点续抓保证了就算中途被服务端断连重启后还能从上次停下的位置继续。相反当我试图“榨干”性能时每次都会付出更多的时间去处理翻车和封禁。这篇笔记没有给出“1000线程”的代码因为那本就是我反对的方向。但从线程池的设定、数据库暂存、分片合并到最后的连续性校验这套方案在真正需要稳定抓取几千上万章节内容时会远比“一把梭”可靠得多。在做技术分享时我也一直提醒自己工具可以锋利但使用工具的人最好知道边界在哪里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑