☰
电商数据采集全链路实战:Scrapy爬虫架构与ClickHouse存储调优
2026/9/26 9:36:07 网站建设 项目流程

电商数据采集这件事,外行看热闹,内行看门道。很多人以为爬虫就是写个 requests.get 然后解析 HTML,跑通一个页面就算完事。但真正在生产环境里跑过电商数据采集的人都知道,写爬虫只是整个链路的起点,真正吃功夫的是后面那一段——数据怎么稳定落库、怎么应对反爬策略的持续变化、怎么保证管道在长时间运行下不崩、怎么让采集到的数据真正可用。我做过几个电商数据采集项目,从最初的单机脚本到后来的分布式管道,踩过的坑足够写一本小册子。这篇文章就把整套链路的实战经验拆开来讲,从 Scrapy 爬虫的架构设计,到数据管道的稳定性保障,再到 ClickHouse 存储的调优细节,尽量把每个环节的"为什么"讲清楚。

1. 电商数据采集的真实难点在哪里

1.1 不是"能不能爬到",而是"能不能持续爬到"

刚入行的时候,我也觉得爬虫的核心是绕过反爬。后来做久了才发现,反爬只是众多问题中的一个。电商平台的数据采集面临的核心挑战其实是持续性和一致性。

持续性指的是:你今天写好的爬虫,明天可能就失效了。电商平台的页面结构、接口参数、加密方式、风控策略都在持续变化。一个能跑通的爬虫,不代表一周后还能跑通。所以架构设计的第一原则是可维护性优先于性能——代码结构要清晰到任何人接手都能在半小时内定位到需要修改的地方。

一致性指的是:你采集到的数据,字段含义要稳定。电商平台经常调整页面展示逻辑,比如原来"销量"显示的是"月销 1000+",后来改成"已售 1000+",再后来可能变成"1000+人付款"。如果你的解析逻辑写死了匹配"月销"两个字,那平台一改文案你就全挂了。正确的做法是把解析规则抽象成配置,把"文案匹配"和"数据提取"分离。

1.2 动态渲染带来的采集复杂度跃升

现在的电商页面,纯静态 HTML 能拿到的数据越来越少。价格、库存、评价这些核心字段,基本都是 JavaScript 动态渲染出来的。这就意味着单纯的 requests + BeautifulSoup 方案在很多场景下已经不够用了。

应对动态渲染有两条路:一是逆向接口,直接找到页面背后调用的 API,用 requests 模拟请求;二是用浏览器自动化工具(比如 Playwright)渲染页面后再提取。两条路各有优劣:

方案优势劣势适用场景
接口逆向速度快、资源消耗低、易规模化需要分析加密参数、维护成本高接口参数稳定、加密逻辑简单的平台
浏览器渲染所见即所得、适配性强速度慢、资源消耗大、并发受限页面逻辑复杂、接口加密强的平台
混合方案兼顾速度与适配性架构复杂度高大部分生产级项目

我个人的经验是:优先尝试接口逆向,逆向成本太高时再退回到浏览器渲染。但即便是浏览器渲染,也不要用 Selenium,Playwright 在稳定性和速度上都明显更优,尤其是处理 iframe 嵌套和动态加载的场景。

1.3 数据管道的稳定性才是真正的分水岭

很多人把爬虫和数据管道混为一谈,觉得爬虫跑完数据存到数据库就结束了。但实际上,从爬虫到最终可用的数据,中间还有一大段路要走:数据清洗、字段标准化、去重、增量更新、异常监控、失败重试。这一段才是区分"玩具项目"和"生产系统"的关键。

我见过太多项目,爬虫写得挺漂亮,但数据存进去之后一团糟:重复数据堆积、字段类型不统一、增量更新逻辑混乱、出了问题没有任何告警。结果就是数据越采越多,但真正能用的没几条。

2. Scrapy 爬虫架构的实战设计

2.1 为什么选 Scrapy 而不是自己写

自己写爬虫框架不是不行,但除非你有非常特殊的需求,否则 Scrapy 几乎是默认选择。原因很简单:Scrapy 把爬虫开发中最繁琐的部分——请求调度、去重、重试、并发控制、中间件机制——都封装好了,你只需要关注"怎么解析页面"和"怎么处理数据"这两件事。

Scrapy 的核心组件包括引擎、调度器、下载器、爬虫、管道、中间件。理解这些组件的职责划分,是用好 Scrapy 的前提:

  • 引擎:控制数据流在所有组件之间的流转
  • 调度器:管理请求队列,决定下一个要爬的 URL
  • 下载器:实际发起 HTTP 请求,获取响应
  • 爬虫:解析响应,提取数据和新的请求
  • 管道:处理爬虫提取到的数据,比如清洗、去重、存储
  • 中间件:在请求和响应处理过程中插入自定义逻辑,比如设置代理、修改请求头

实际项目中,我们大部分定制化工作都集中在爬虫、管道和中间件这三个部分。

2.2 中间件配置:反爬策略的第一道防线

Scrapy 的下载器中间件是处理反爬的核心位置。以下是我在实际项目中常用的中间件配置思路:

# middlewares.py import random from scrapy import signals class RandomUserAgentMiddleware: def __init__(self, user_agents): self.user_agents = user_agents @classmethod def from_crawler(cls, crawler): return cls(crawler.settings.getlist('USER_AGENT_LIST')) def process_request(self, request, spider): request.headers['User-Agent'] = random.choice(self.user_agents) class RetryWithDelayMiddleware: def process_response(self, request, response, spider): if response.status in [403, 429]: retry_times = request.meta.get('retry_times', 0) if retry_times < 3: request.meta['retry_times'] = retry_times + 1 request.dont_filter = True return request return response

这里有几个关键点值得展开说:

User-Agent 池的维护。不要用网上随便找的 UA 列表,那些大概率已经被标记了。建议用真实浏览器抓取当前主流 UA,定期更新。UA 池不需要很大,20-30 个高质量的比 200 个低质量的效果好得多。

重试策略的设计。Scrapy 自带 RetryMiddleware,但默认的重试逻辑比较粗暴。我建议针对不同的状态码做差异化处理:403 和 429 需要延迟重试,500 系列可以立即重试,404 直接放弃。延迟重试时最好加上指数退避,避免短时间内反复触发风控。

请求间隔的控制。Scrapy 的DOWNLOAD_DELAY是全局配置,但不同平台的容忍度不一样。更好的做法是在 spider 级别设置custom_settings,针对不同目标站点配置不同的延迟。

2.3 Playwright 集成:处理动态 iframe 的正确姿势

电商平台经常把关键信息放在 iframe 里,比如支付页面、评价模块、物流信息。Scrapy 本身不处理 JavaScript 渲染,需要配合 scrapy-playwright 来实现。

安装和基础配置:

pip install scrapy-playwright playwright install chromium

在 settings.py 中启用:

DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" PLAYWRIGHT_BROWSER_TYPE = "chromium" PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, "timeout": 30000, }

处理 iframe 的关键在于page_frame参数。假设你要提取一个嵌套在 iframe 中的评价列表:

def start_requests(self): yield scrapy.Request( url="https://example.com/product/123", meta={ "playwright": True, "playwright_page_methods": [ PageMethod("wait_for_selector", "iframe#review-frame"), ], "playwright_include_page": True, }, callback=self.parse_with_iframe, ) async def parse_with_iframe(self, response): page = response.meta["playwright_page"] frame = page.frame_locator("iframe#review-frame") reviews = await frame.locator(".review-item").all_text_contents() await page.close() for review in reviews: yield {"review": review}

这里有个容易踩的坑:playwright_include_page设为 True 后,必须手动关闭 page,否则浏览器实例会越积越多,最终导致内存溢出。我一开始就因为这个原因,跑了几小时之后服务器直接 OOM。

另一个坑是iframe 的加载时机。有些 iframe 是懒加载的,需要先滚动到可视区域才会触发加载。这时候需要在playwright_page_methods里加上滚动操作:

PageMethod("evaluate", "window.scrollTo(0, document.body.scrollHeight)"), PageMethod("wait_for_timeout", 2000),

2.4 分布式爬虫的取舍:什么时候需要,什么时候不需要

很多人一上来就想搞分布式,觉得单机不够"高级"。但实际上,大部分电商数据采集项目根本不需要分布式。单机 Scrapy 配合合理的并发配置,一天采集几十万条数据完全没问题。

真正需要分布式的场景只有两种:一是采集量极大(日采集量百万级以上),二是需要多地域 IP 分散请求。前者可以用 Scrapy-Redis 搭建分布式集群,后者需要考虑代理池的架构。

Scrapy-Redis 的核心思路是把调度器的请求队列和去重集合放到 Redis 里,多个爬虫实例共享同一个队列。配置很简单:

SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = "redis://localhost:6379"

但分布式带来的复杂度是成倍增加的:Redis 的稳定性、实例间的协调、数据一致性、故障恢复,每一项都需要额外投入。我的建议是:先用单机跑通全链路,确认瓶颈确实在采集速度上,再考虑分布式。

3. 数据管道:从原始数据到可用数据

3.1 数据清洗的标准化流程

爬虫拿到的原始数据,离"可用"还差得远。以电商商品数据为例,原始数据通常存在以下问题:

  • 价格字段混有货币符号和千分位分隔符,比如"¥1,299.00"
  • 销量字段是模糊描述,比如"1000+人付款"
  • 商品标题包含大量营销词和特殊字符
  • 时间字段格式不统一,有的是时间戳,有的是"3天前"
  • 分类信息层级不清晰

清洗流程我一般按这个顺序走:

  1. 字段提取:用正则从原始文本中提取结构化数据
  2. 类型转换:统一转换为目标类型(价格转 float,销量转 int)
  3. 格式标准化:统一日期格式、去除多余空白和特殊字符
  4. 异常值处理:标记或剔除明显异常的数据
  5. 字段补全:根据已有字段推导缺失字段
import re from datetime import datetime, timedelta def clean_price(raw_price): """清洗价格字段""" if not raw_price: return None # 去除货币符号和千分位 cleaned = re.sub(r'[¥$€,,\s]', '', raw_price) # 提取数字部分 match = re.search(r'\d+\.?\d*', cleaned) return float(match.group()) if match else None def clean_sales(raw_sales): """清洗销量字段""" if not raw_sales: return 0 # 处理"1000+"格式 match = re.search(r'(\d+)', raw_sales.replace(',', '')) return int(match.group(1)) if match else 0 def clean_relative_time(raw_time): """处理相对时间""" now = datetime.now() if '分钟前' in raw_time: minutes = int(re.search(r'(\d+)', raw_time).group(1)) return now - timedelta(minutes=minutes) elif '小时前' in raw_time: hours = int(re.search(r'(\d+)', raw_time).group(1)) return now - timedelta(hours=hours) elif '天前' in raw_time: days = int(re.search(r'(\d+)', raw_time).group(1)) return now - timedelta(days=days) return None

清洗逻辑一定要写成独立的函数,方便单元测试。我见过太多项目把清洗逻辑直接写在 pipeline 里,结果出了问题根本没法定位是哪一步出的错。

3.2 去重策略:别让重复数据毁掉整个数据集

去重是数据管道里最容易被忽视、但影响最大的环节。电商数据采集的去重比一般爬虫复杂,因为同一个商品可能在不同时间点被多次采集,每次采集到的价格、销量都可能不同。

去重策略需要分两个层面考虑:

URL 层面的去重。Scrapy 自带的 dupefilter 可以过滤重复请求,但默认是基于 URL 的精确匹配。如果 URL 带有随机参数或者时间戳,去重就会失效。这时候需要在 spider 里对 URL 做归一化处理:

from urllib.parse import urlparse, parse_qs, urlencode def normalize_url(url): parsed = urlparse(url) # 只保留关键查询参数 params = parse_qs(parsed.query) keep_params = {k: v for k, v in params.items() if k in ['id', 'sku', 'product_id']} normalized_query = urlencode(keep_params, doseq=True) return f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{normalized_query}"

数据层面的去重。同一个商品在不同时间采集到的数据,需要根据业务需求决定是保留最新一条、保留所有历史记录、还是做增量更新。我的做法是在 ClickHouse 里用 ReplacingMergeTree 引擎,按商品 ID 和采集时间排序,查询时自动取最新版本。

3.3 增量更新:如何避免全量重采

全量重采是最浪费资源的方式,但很多项目就是这么干的。增量更新的核心是记录上次采集的状态,只采集发生变化的部分。

实现增量更新有几种思路:

  • 基于时间戳:记录每个商品的最后采集时间,只采集超过一定时间未更新的商品
  • 基于版本号:如果平台提供了商品更新时间字段,直接对比版本号
  • 基于变更检测:定期采集关键字段(如价格),发现变化后再触发全量采集

我一般用第一种方案,在 ClickHouse 里维护一张采集状态表:

CREATE TABLE crawl_status ( product_id String, last_crawl_time DateTime, crawl_count UInt32, last_price Decimal(10, 2), status String ) ENGINE = ReplacingMergeTree(last_crawl_time) ORDER BY product_id;

每次采集前先查询这张表,筛选出需要更新的商品列表。采集完成后更新状态表。这样可以把采集量降低到全量的 10%-20%。

4. ClickHouse 存储层的设计与调优

4.1 为什么电商数据采集适合用 ClickHouse

电商数据采集的存储需求有几个特点:写入量大、查询以聚合分析为主、数据基本不更新、需要保留历史版本。这几个特点正好是 ClickHouse 的强项。

对比一下常见的存储方案:

存储方案写入性能聚合查询更新操作适用场景
MySQL中等慢快事务型业务
MongoDB高中等中等文档型数据
ClickHouse极高极快慢分析型数据
Elasticsearch高快中等全文检索

ClickHouse 的列式存储和向量化执行引擎,让它在处理"统计某品类商品的价格分布"这类查询时,速度比 MySQL 快几十倍甚至上百倍。

4.2 表结构设计:从查询需求倒推

ClickHouse 的表结构设计,核心原则是从查询需求倒推。先想清楚你要做什么查询,再决定表怎么建。

电商商品数据的典型查询包括:

  • 按品类统计商品数量和平均价格
  • 查询某个商品的历史价格变化
  • 统计各店铺的商品上新频率
  • 分析价格区间的商品分布

基于这些查询,我设计的表结构大致如下:

CREATE TABLE product_data ( product_id String, title String, price Decimal(10, 2), original_price Decimal(10, 2), sales UInt32, shop_id String, shop_name String, category_id String, category_name String, crawl_time DateTime, crawl_date Date ) ENGINE = ReplacingMergeTree(crawl_time) PARTITION BY toYYYYMM(crawl_date) ORDER BY (category_id, product_id, crawl_time);

几个关键设计点:

分区键的选择。按toYYYYMM(crawl_date)分区,每个月一个分区。这样查询某个月的数据时,ClickHouse 只需要扫描对应分区,速度很快。分区粒度不要太细,否则分区数量过多会影响性能;也不要太粗,否则单分区数据量太大。

排序键的设计。ORDER BY (category_id, product_id, crawl_time)决定了数据在磁盘上的物理顺序。把最常用的查询条件放在前面,可以最大化利用索引。这里把category_id放在第一位,是因为按品类查询是最常见的需求。

引擎的选择。ReplacingMergeTree会在后台合并时自动去重,保留排序键相同记录中版本号最大的那条。配合crawl_time作为版本号,就能实现"同一商品保留最新采集数据"的效果。

4.3 批量写入:性能提升的关键

ClickHouse 最忌讳的就是单条写入。每次 INSERT 都会生成一个新的 part,part 过多会导致合并压力剧增,最终影响查询性能。

正确的做法是批量写入,每批至少 1000 条,理想情况下 10000 条以上。在 Scrapy 的 pipeline 里,可以用缓冲区实现:

class ClickHousePipeline: def __init__(self, ch_client, batch_size=5000): self.client = ch_client self.batch_size = batch_size self.buffer = [] def process_item(self, item, spider): self.buffer.append(dict(item)) if len(self.buffer) >= self.batch_size: self.flush() return item def flush(self): if not self.buffer: return self.client.insert('product_data', self.buffer) self.buffer = [] def close_spider(self, spider): self.flush()

这里有个细节:close_spider里一定要 flush,否则最后一批不满 batch_size 的数据会丢失。我就因为这个疏忽丢过一批数据,排查了半天才发现问题。

4.4 ClickHouse 常见故障处理

ClickHouse 在长时间运行中会遇到一些典型问题,这里分享几个我实际遇到过的:

重启报错 "failed to flush system log, already exists"。这个错误通常是因为 ClickHouse 在关闭时没有正常清理 system log 表,重启时尝试重新创建已经存在的表。解决方法是在配置文件中设置:

<clickhouse> <system_logs> <flush_on_crash>false</flush_on_crash> </system_logs> </clickhouse>

或者手动删除对应的 system log 表后重启。这个问题的根本原因是 ClickHouse 的 system log 表使用了ReplicatedMergeTree引擎,在单机环境下容易出现元数据不一致。

写入速度突然变慢。通常是因为 part 数量过多,后台合并跟不上。可以通过查询system.parts表查看 part 数量:

SELECT count() FROM system.parts WHERE table = 'product_data' AND active = 1;

如果 part 数量超过 300,就需要考虑优化写入策略了。要么增大批量写入的批次大小,要么调整max_insert_block_size参数。

内存占用过高。ClickHouse 的聚合查询会消耗大量内存,尤其是GROUP BY高基数字段时。可以通过设置max_memory_usage限制单查询内存,或者优化查询语句,先用WHERE过滤再聚合。

5. 监控与告警:让管道自己说话

5.1 必须监控的核心指标

数据管道跑起来之后,最怕的就是"静默失败"——爬虫还在跑,但数据已经不对了。所以监控体系是生产环境的必备组件。

我一般监控这几类指标:

  • 采集层:请求成功率、平均响应时间、被拦截率、重试次数
  • 处理层:数据清洗成功率、字段缺失率、去重率
  • 存储层:写入速率、part 数量、查询延迟、磁盘使用率
  • 业务层:日采集量、新增商品数、价格变化商品数

这些指标可以用 Prometheus + Grafana 搭建监控面板,也可以用简单的脚本定期检查并发送告警。

5.2 告警规则的设计

告警规则的设计原则是宁可漏报,不可误报。频繁的误报会让人对告警麻木,最终真正出问题时反而被忽视。

我的告警规则大致如下:

指标告警阈值告警级别
请求成功率低于 80% 持续 10 分钟严重
日采集量低于历史均值 50%严重
字段缺失率高于 10%警告
磁盘使用率高于 85%警告
part 数量超过 500警告

告警渠道我一般用企业微信机器人或者邮件,关键是告警信息要包含足够的上下文,比如当前值、阈值、可能的原因、建议的处理方式。只发一句"采集量异常"的告警,收到的人根本不知道从哪查起。

5.3 失败重试与断点续采

再稳定的管道也会遇到失败。关键是要有失败重试和断点续采机制。

失败重试相对简单,Scrapy 自带重试中间件,配置好重试次数和延迟即可。但要注意区分可重试错误和不可重试错误:网络超时、503 错误可以重试,404、403 重试也没用。

断点续采稍微复杂一些。核心思路是记录采集进度,重启后从上次中断的位置继续。实现方式是在 Redis 或 ClickHouse 里维护一个进度表:

class ProgressTracker: def __init__(self, redis_client): self.redis = redis_client def mark_done(self, task_id): self.redis.sadd('completed_tasks', task_id) def is_done(self, task_id): return self.redis.sismember('completed_tasks', task_id) def get_pending(self, all_tasks): done = self.redis.smembers('completed_tasks') return [t for t in all_tasks if t not in done]

这样即使管道中途崩溃,重启后也能从断点继续,不用从头再来。

6. 一些踩坑之后的经验总结

6.1 关于反爬的几点体会

反爬对抗是一个持续的过程,没有一劳永逸的方案。我的体会是:不要试图硬刚,要学会"融入"。具体来说:

  • 请求频率不要太高,模拟真实用户的浏览节奏
  • 请求头要完整,不只是 User-Agent,Referer、Accept-Language 这些都要带上
  • 必要时使用代理池,但代理质量比数量重要
  • 遇到验证码不要硬破,考虑降低频率或者换时间段

最重要的一点:尊重目标站点的 robots.txt 和服务条款。采集公开数据用于分析研究是合理的,但不要对目标站点造成过大压力。

6.2 关于数据质量的几点体会

数据质量问题的根源往往在采集阶段,而不是清洗阶段。如果采集时字段就提取错了,后面怎么清洗都是错的。所以:

  • 解析规则要写单元测试,用真实的页面样本验证
  • 字段提取失败时要记录原始数据,方便回溯
  • 定期抽样人工校验,确保解析逻辑没有漂移
  • 建立数据质量基线,偏离基线时及时告警

6.3 关于架构演进的几点体会

不要一开始就追求"完美架构"。我见过太多项目,前期花大量时间设计分布式架构、微服务拆分,结果业务需求一变,全部推倒重来。

正确的做法是从简单开始,按需演进:

  1. 第一阶段:单机 Scrapy + SQLite,快速验证需求
  2. 第二阶段:单机 Scrapy + ClickHouse,提升存储和查询能力
  3. 第三阶段:Scrapy-Redis 分布式 + 监控告警,支撑大规模采集
  4. 第四阶段:数据管道服务化,支持多数据源接入

每个阶段解决当前最痛的问题,不要提前优化。

6.4 一个具体的性能调优案例

最后分享一个实际的调优案例。之前有个项目,采集 10 万条商品数据,从开始到全部入库花了 6 个小时。分析后发现瓶颈在三个地方:

第一,Playwright 渲染太慢。每个页面渲染平均耗时 3 秒,10 万条就是 83 小时。优化方案是把能接口化的页面改成接口采集,只对必须渲染的页面用 Playwright。优化后渲染页面占比从 100% 降到 15%。

第二,ClickHouse 写入批次太小。原来每 100 条写一次,part 数量爆炸。改成每 5000 条写一次后,写入速度提升明显。

第三,去重逻辑太耗时。原来在 Python 里用集合去重,10 万条数据去重耗时 20 分钟。改成在 ClickHouse 里用 ReplacingMergeTree 自动去重后,这部分时间完全省掉了。

三项优化加起来,总耗时从 6 小时降到 40 分钟。这个案例说明:性能优化的前提是找到真正的瓶颈,盲目优化往往事倍功半。

这套电商数据采集系统从最初的单机脚本演进到现在,前后迭代了十几个版本。每一次迭代都是被实际问题逼出来的,没有哪一次是"为了架构而架构"。如果你也在做类似的项目,我的建议是:先把最小可用链路跑通,然后根据实际遇到的问题逐步优化。采集这件事,稳定比快更重要,可持续比功能多更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询