Scrapy分布式爬虫架构:千万级金融行情数据采集与调优实践
发布时间:2026/9/28 14:59:50来源:尧图网络
先说一个能让你瞬间清醒的场景凌晨两点面板上显示已经抓了820万条行情记录但机器正在疯狂报警——SQLite锁不断、内存被积压的请求队列占满、重试的URL把磁盘撑爆。这不是段子是我第一次接“千万级金融行情数据采集”需求时的真实处境。行情数据不同于普通网页它有明确的时间维度、标的维度和周期维度抓取必须“按时补齐”晚一分钟拿到分时数据这条数据的价值就没了。这篇内容围绕“Scrapy分布式爬虫 千万级金融行情数据采集”展开适合已经跑通单机Scrapy、准备把采集规模拉高一两个数量级的团队。你不需要从零了解Scrapy——我会直接讲分布式改造、动态渲染、存储管道、压测调优这些真正决定成败的部分。文中方案都是我实际搭过的架构代码也是可落地的那种不是概念演示。1. 一千万条数据的真实体量设计起点不能拍脑袋1.1 先算一笔账行情数据到底有多“重”很多团队对“千万级”没有概念上来就玩命堆节点。我先给你一个估算方法以后接任何数据项目都能用把数据量换算成“每天新增多少条 单条多大 怎么取”。以全市场约5000只股票为例日线每只每天1条全市场一天新增约5000条60分钟线每只每天4条一天约2万条5分钟线每只每天48条一天约24万条1分钟线每只每天240条一天约120万条。也就是说1000万条1分钟K线大约是8个交易日的数据量而换成日线则是五六年的全市场历史。这是完全不同的两个量级决定了你的存储和调度方案根本不能一样。再看存储成本。单条1分钟K线按“时间戳代码开高低收成交量成交额”大约200字节1000万条就是2GB裸数据MySQL里加索引和冗余字段实际占盘在4~6GB。听起来不大但注意“每天新增120万条”这个增速一年下来就是几十GB往上走这已经足够让单机SQLite和单表MySQL感到痛苦了。还有请求量的估算。如果走批量HTTP接口一次请求能带回100~800条K线1000万条才对应1万到10万次请求如果图省事让每只股票单独建一个详情页请求那直接就是1000万次并发调度和去重系统瞬间失去意义。所以设计的起点不是“怎么跑得快”而是选对数据入口先把请求量压到数量级可控。1.2 单机Scrapy的真正瓶颈在哪里很多人以为分布式是因为GIL其实Scrapy本身就是异步的IO密集型的抓取在单机上可以跑得很快。真正的瓶颈有三个一是内存里的请求队列。Scrapy默认的调度器是内存队列10万个待抓URL大约占几十MB看起来不多但金融数据的重试会把同一批URL反复放回队列叠加延迟处理和并发积压内存可以肉眼可见地往上涨。二是落库通道的单点限制。SQLite写锁、单表MySQL的慢索引在每秒几百行写入时就开始拖后腿而行情数据又是“必须连续写”的任何落库失败都意味着当天数据缺失。三是故障恢复极差。单机进程一崩内存里的待抓队列全部丢光断点续爬全靠自己实现。我们第一版就吃过这个亏运维半夜自动重启了节点结果第二天发现漏了三天数据还得重新回补。单机不是不能用而是“千万级 日常运维”这套组合下它太脆弱了。所以我的结论很简单把请求队列和去重从进程内搬到Redis是性价比最高的第一步。2. 从单机到集群Redis调度中枢改造的完整链路2.1 为什么调度中枢选Redis而不是消息队列做分布式爬虫最常见的做法就是引入一个“共享调度中枢”所有节点从这个中枢领取URL、上报结果。选型时我在Redis和Kafka之间纠结过最后选了Redis原因很具体爬虫调度需要的不只是队列还有去重集合。Redis的dubfilter天然支持Set一条SISMEMBER命令就能判断URL是否抓过scrapy-redis这个库虽然老但足够成熟改造成本极低Kafka适合“事件流”而不是“待办清单”用它调度URL你会额外引入消费位点管理、分区均衡这些复杂度对爬虫场景完全是负重。当然Redis单实例也有上限但千万级数据量下它远不是瓶颈瓶颈通常在目标站点限速和远端数据接口的吞吐。与其担心Redis撑不住不如担心所有节点共用同一个Redis时的运维抖动所以要给调度Redis单独部署实例不要和业务缓存混用。2.2 落地scrapy-redis配置与代码改造安装依赖后就改settings.py核心配置如下# settings.py SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter REDIS_URL redis://10.0.20.10:6379/0 # 不自动清空队列重启后从上次的断点继续 SCHEDULER_PERSIST True SCHEDULER_FLUSH_ON_START FalseSpider这边如果每次启动要注入新的起始URL可以用RedisSpider直接从Redis Key里读import scrapy from scrapy_redis.spiders import RedisSpider class QuoteBarSpider(RedisSpider): name quote_bar redis_key quote:start_urls def make_request_from_data(self, data): url data.decode() return scrapy.Request(url, callbackself.parse_bar, dont_filterTrue)这样每个节点都从quote:start_urls这个Key里取任务谁取了谁跑天然负载均衡。某个节点挂了其他节点会把Redis里的任务领走不会造成重复浪费几乎不用额外写故障转移代码。改造一个爬虫从单机到分布式本质就是这几行配置加一个队列Key。2.3 请求指纹去重默认方案会在金融数据上翻车scrapy-redis默认的去重指纹是“请求方法URL请求体”的SHA1这在普通网页爬虫上没问题但在行情页面上经常翻车行情节点的URL往往会带时间戳、token、随机参数这类动态字段。比如同一个标的的同一天K线第一次请求带ts1672500000第二次带ts1672503600URL变了但其实业务上完全重复。默认指纹会把两次都当成新请求去重形同虚设。我的做法是重写指纹函数用“业务主键”代替URLimport hashlib from scrapy_redis.dupefilter import RFPDupeFilter class BizDupeFilter(RFPDupeFilter): def request_fingerprint(self, request): # meta里的 biz_key 由 spider 在构造 Request 时设置 biz_key request.meta.get(biz_key) if biz_key is None: biz_key request.url return hashlib.sha1(biz_key.encode()).hexdigest()在Spider里构造请求时把股票代码、周期、日期拼成biz_keyyield scrapy.Request( urlapi_url, meta{ biz_key: fbar:{code}:{period}:{trade_date}, code: code, period: period, }, callbackself.parse_bar, )这样去重粒度是“业务是否重复”而不是“URL是否一样”。这个细节我强烈建议所有做行情采集的人一开始就落实不然后期补数据、对账时你会被重复数据折磨疯。3. 动态行情页与iframePlaywright下钻的三种接法3.1 为什么泛解析在行情页上基本失灵金融站点的行情页是我见过最不爱“好好输出HTML”的页面类型。K线图用Canvas画分时数据藏在iframe里加载表格内容靠JavaScript异步填充有的还要先滚滚动条、触发懒加载页面上才出现数字。你用Scrapy直接抓返回的HTML往往只能拿到一个外壳数据一个都看不到。iframe是最坑的父页面只是骨架真正的内容在子文档里二三十个子iframe按需加载普通XPath根本索引不到。面对这种页面老实说继续用泛解析是死路必须换采集思路。3.2 方案一scrapy-playwright 整页渲染如果你的业务确实是“把页面当截图取”那就接入scrapy-playwright这个扩展。它是官方维护的异步方案不会像我最早那样傻乎乎在downloader middleware里同步启动一个浏览器进程直接把Twisted事件循环堵死。启用很简单settings.py里声明DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, }在Spider的Request里用meta触发渲染yield scrapy.Request( urlpage_url, meta{ playwright: True, playwright_include_page: True, }, callbackself.parse_page, )整页渲染的优点是“所见即所得”但缺点也明显每个请求都要开浏览器上下文内存开销大吞吐远低于纯HTTP请求。实测下来一个4C8G的节点整页渲染模式下并发只能开到4~6个再高就频繁OOM。所以这个方案我定位为“兜底”而不是主力。3.3 方案二iframe内页单独拆目标更聪明的做法是不要试图穿透iframe而是把iframe的src当作新的URL去抓。用Playwright或者直接抓父页面HTML先把所有iframe的src解析出来这些src往往直接指向带有真实数据参数的子页面或接口。例如你发现分时数据的iframe src是/quote/api/bar?code600519periodminute那这本身就是一条干净的业务请求直接用普通Scrapy Request去抓它比在浏览器里渲染整个父页面效率高一个数量级。我常用的链路抓父页面提取所有iframe的src记录归属业务字段过滤掉广告、未知域名的iframe只保留匹配行情数据路径的src把这些src转成独立请求合并进分布式队列在子请求的解析函数里直接提取JSON。这里要提个醒如果那个iframe接口需要登录态或来源校验你的抓取会踩到合规边界我处理的场景都是未登录可访问的公开接口不能越界的东西一律不碰。3.4 方案三直接复刻底层JSON接口这是我在行情采集里最推荐、但也被问最多的一种接法。用浏览器开发者工具打开目标行情页切到Network面板刷新页面你会看到大量XHR请求——行情数据几乎都是通过JSON接口下发的页面只是把这些JSON渲染成图表。把其中一个关键XHR的URL、请求头、参数拉出来分析用curl或Scrapy直接复刻返回的就是结构化数据不用跟HTML较劲。实际效果同样的4C8G节点整页渲染只能开到并发4复刻JSON接口后并发能开到20以上内存占用还低一大截。复刻接口有一点要注意接口字段名经常变动需要把字段映射抽成一层配置而不是在解析函数里写死几百行字段名。我们后来维护了一个field_mapping.json后端接口改字段时只改配置不用动爬虫代码。这个习惯帮我躲过了不少半夜被喊起来改代码的局面。4. 数据管道里的细节去重指纹、分片存储与增量更新4.1 业务主键与幂等写入采集到的行情数据会进入Item Pipeline但我不建议你像普通爬虫一样“抓到一条写一条”。行情数据最重要的特征是同一业务主键会被不同方式的请求反复拿到——批量接口和详情接口可能返回同一天的K线重试也可能重复。所以写入逻辑必须是幂等的。对K线数据业务主键就是“股票代码周期时间戳”。在Pipeline里组装SQL时用INSERT ... ON DUPLICATE KEY UPDATEINSERT INTO bar_1min (code, trade_time, open, high, low, close, volume, amount) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE openVALUES(open), highVALUES(high), lowVALUES(low), closeVALUES(close), volumeVALUES(volume), amountVALUES(amount);Pipeline里批量攒够500~1000条再统一提交千万别一条一条execute。实测差距很夸张1000条一块提交入库耗时是逐条提交的十分之一。4.2 分表与分区MySQL还是ClickHouse千万级数据如果坚持塞进一张MySQL表到几百万条后你会发现查询开始变慢索引膨胀明显。两种常见解法MySQL按交易日分区以trade_date字段做RANGE分区每次回补数据只影响当天分区查询按时间裁剪也能命中分区。实现成本低适合团队本来就在用MySQL、不打算引入新组件的情况。ClickHouse接管行情库用MergeTree引擎分区键设为toYYYYMM(trade_time)排序键设为(code, trade_time)查询性能远超MySQL适合后面要跑行情统计、回测的场景。我自己的项目最终是“双轨”原始K线进ClickHouse因为回测和聚合分析太需要这玩意了同时保留一个轻量MySQL库存最近30天的数据给业务接口临时查询用因为业务团队熟悉MySQL。存储选型这块我给个对比对比项MySQL分区表ClickHouse MergeTree写入吞吐单表单分区每秒几百行需要攒批攒批插入每秒可上万行按时间聚合慢索引膨胀明显快天生按时间组织的运维成本低团队都会高需要独立集群和权限管理适合场景在线小查询、管理后台回测、分析、报表、海量时序4.3 增量与全量的协同全量采集解决“历史存量”增量采集解决“今天的新数据”二者必须共用一套业务主键和去重逻辑不然对账就是一场灾难。我的做法是Redis里维护一个同步位点# 每个标的每周期记一个最后同步时间 key fquote:sync:latest:{code}:{period} redis.set(key, trade_date, ex60 * 60 * 24 * 30)Spider启动时读这个位点只补上次同步之后的数据如果没有位点就走全量回补逻辑。这套机制还天然解决节假日问题周末和周一是同一根K线你要保证“最后同步时间”对齐交易日历而不是自然日。我们用交易日历文件做了一层映射周一生成的日期键是上周五的trade_date避免把周一的空不敢写成“没有数据”。还要留意复权因子。金融行情里的“前复权”“后复权”会改变历史价格如果你只存原始价后面做策略回测时就得自己算复权非常痛苦。建议在表里单独存adjust_factor字段把前复权、后复权、不复权的计算交给查询层。5. 集群稳定性压测实录瓶颈、报错与调优全过程5.1 三节点实测你以为是并发不够其实是限速某次项目要抓8000多个标的、近半年5分钟线总量约1200万条。我们选的批量接口单次能带回800条K线所以总请求量才1.5万次。当时的直觉是“这么点请求3个节点还不够用”但压测数据狠狠打了脸节点数每节点并发域名级限速单次返回条数完成一轮全量耗时163秒/请求800约12小时343秒/请求800约4小时36放开限速800报错率暴涨被迫回退结论很清楚千万级采集的瓶颈很少在“节点不够”而在“目标站点的限速和突发模式”。我们把并发从6降到4、加了域名级延时之后总耗时虽然拉长了一点但报错率从8%降到0.1%以内整体可靠性反而更优。分布式在这里真正的价值不是“把10万请求变成几万秒”而是让这些请求在限速、重试、节点宕机的情况下依然能“稳稳爬完”。5.2 典型报错一Redis连接池被打满首轮压测跑到一半开始疯狂报redis.exceptions.ConnectionError写日志的瞬间整个统计面板都在跳红字。排查下来发现是scrapy-redis每个请求都要做入队、出队、指纹SISMEMBER默认Redis连接池只有几十个连接20个并发就把池子打穿了。我当时的处理单独起一个专用Redis实例数据量小、不持久化只给调度器用在Redis端调大maxclients给scrapy-redis的连接参数加超时避免一个慢连接拖死全局REDIS_PARAMS { socket_timeout: 10, socket_connect_timeout: 5, }改完后连接池再也没炸过。顺带提醒一下Redis进程挂了比连接池耗尽还惨因为所有节点会一起失去调度能力。所以生产环境至少给调度Redis做一主一从主挂了从顶上节点侧做好重连。5.3 典型报错二下载超时引发雪崩压测中途有个远端接口响应变慢单次请求执行了20多秒才超时。按理说只是慢但连锁反应来了Scrapy默认会把超时请求重试三次重试又超时队列里的请求量瞬间膨胀三倍整个集群开始拥堵。这类雪崩的解法是给超时和重试戴上“紧箍咒”DOWNLOAD_TIMEOUT 10 RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504] # 关键对外部波动敏感的接口减半并发加长重试间隔 CONCURRENT_REQUESTS_PER_DOMAIN 4 CONCURRENT_REQUESTS 16我还写了一个带退避的重试中间件第一次失败等1秒第二次等2秒第三次等4秒用指数退避替代立即重试。这样即使上游抖动节点也会慢慢放慢节奏而不是用力撞墙。核心原则就一句在外部系统已经摇摇欲坠时你的爬虫应该做的事是“退让”而不是“加速”。5.4 典型报错三节点内存持续上涨直到被OOM抓了十几分钟后某个节点内存从4G一路涨到8G然后被OOM Kill。第一反应是“内存泄漏”后来发现好几个原因都有份页面渲染方案里Playwright的浏览器页面对象没及时关闭每个页面对象都占几十MBItemPipeline把所有数据攒在列表里等到2万条才flush一下结果列表本身吃掉了巨量内存大HTML响应的body没被释放Scrapy引擎层又持有引用。解决方法是逐个击破渲染后无论成功失败都await page.close()Pipeline攒批阈值降到500条就提交对大响应体用response.text前先response.body切片不保留整个body的额外副本。调完以后3个节点跑完50多个小时内存曲线基本是一条横线。6. 采集合规与频率控制踩过边界之后我才总结出的规矩6.1 尊重平台边界先把“能抓什么”定清楚做金融行情采集最容易踩的坑不是技术而是边界。我的原则很简单只采集公开的、未登录可访问的行情数据用于技术研究或预研验证如果目标页面明确有登录声明的数据坚决不碰更不去伪造登录态或绕过验证机制。每接到一个新目标站点第一件事是看robots.txt和服务条款明确告诉团队哪些路径不能抓。这不是唱高调。真实工作里很多平台对公开行情数据的态度是“能看但别恶意并发”。你只要保持合理的访问频率、不做超出正常浏览行为的突发密集请求多数情况不会触发风控。反而是那种“100个线程往里冲”的做法最容易导致整个出口段被短时封禁连正常访问都受影响。6.2 域级限速与Autothrottle把礼貌写进配置Scrapy自带AutoThrottle但它的自适应逻辑对金融数据这种“必须按时间补齐”的任务来说偏保守。我更建议显式配置域级并发和延时把“礼貌节奏”写死在settings里AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1.0 AUTOTHROTTLE_MAX_DELAY 10.0 AUTOTHROTTLE_TARGET_CONCURRENCY 4.0 CONCURRENT_REQUESTS_PER_DOMAIN 4 DOWNLOAD_DELAY 0.5 # 针对批量接口可以适当放宽对页面类请求必须更严格 DOMAIN_DELAY_MAP { quote.example.com: 2.0, }这里的核心思路是分域治理行情接口效率高可以稍微紧凑普通页面、资讯页则严格限速。千万不要一刀切否则会出现“一个慢域拖住所有节点配额”的尴尬情形。另外我想强调不同目标的请求模式不要“一波流”。我吃过亏半夜回补历史数据时把整个月的缺口用密集请求去补结果触发风控后面一个月每天都战战兢兢。后来改成“每节点每小时最多处理3000个请求多出的排队”用Redis做每小时请求计数把突发抹平成一个高原而不是一个尖峰。6.3 重试与退避故障时的正确姿势分布式爬虫集群里重试策略写得好不好直接决定你在出故障时是“原地卡死”还是“平滑降温”。我用的是指数退避加重试上限的中间件import time from scrapy.downloadermiddlewares.retry import RetryMiddleware class BackoffRetryMiddleware(RetryMiddleware): def _retry(self, request, reason, spider): retries request.meta.get(retry_times, 0) 1 if retries self.max_retry_times: spider.crawler.stats.inc_value(retry/exceeded) return None delay min(2 ** retries, 60) request.meta[retry_times] retries # 用 Twisted 的 callLater 延迟重放 from twisted.internet import reactor d defer.Deferred() reactor.callLater(delay, self._enqueue_request, request, spider) return d重点是设置重试上限默认3~4次足够并且每次重试都留出退避时间。这样如果上游接口连续故障我们的集群会“大面积排空”而不是“反复撞击同一个黑洞”。后期我还给重试计数器加了自定义统计项发到监控面板一旦某个域名重试率超过5%就立刻告警人工判断是要降级还是改配置。6.4 可观测性别等数据对不上账才后悔最后一条经验之谈采集系统的核心指标不是你抓了多少而是“今天该有的数据有没有到齐”。我给每个爬虫节点加了一个心跳机制和统计上报关键指标就三组进度指标当前队列长度、已抓URL数、最近一小时抓取条数健康指标节点进程是否存活、Redis连接是否正常、最近5分钟有无异常堆栈对齐指标每个标的-周期的最新trade_date是否等于今日的交易日历值。日志和统计数据统一发到一套监控系统用一张看板展示“数据缺口矩阵”哪个标的缺一天的K线一目了然。数据采集这件事大多数人死在“采集失败后没人知道”而不是死在“采集失败本身”。有了这个看板凌晨的告警电话少了很多好多问题在变成事故之前就被处理掉了。还有一个值得长期投入的点把交易日历表维护好。金融数据的全局对齐完全依赖交易日历哪天开市、哪天休市、周一取的是哪根K线都得在配置里写清楚。我们把交易日历做成独立配置每季度核对一次后续所有采集任务的“今日是否要跑”“今天补哪天的数据”都由它统一回答。这个不起眼的配置文件帮我省掉的半夜回补操作数都数不过来。写在最后做千万级行情采集绕不开的是“数据口径先行”——先和需求方定清楚每个字段的含义、复权方式、周期粒度再去动爬虫代码。技术上的分布式、去重、分片解决的只是“把数据正确搬回来”的问题如果数据口径一开始就错了搬回来的越多后面返工的代价越大。至少对我而言这个顺序一旦颠倒后面每一步都在还债。
网站建设高端定制企业官网