电商数据采集这件事外行看热闹内行看门道。很多人以为爬虫就是写个 requests.get 然后解析 HTML跑通一个页面就算完事。但真正在生产环境里跑过电商数据采集的人都知道写爬虫只是整个链路的起点真正吃功夫的是后面那一段——数据怎么稳定落库、怎么应对反爬策略的持续变化、怎么保证管道在长时间运行下不崩、怎么让采集到的数据真正可用。我做过几个电商数据采集项目从最初的单机脚本到后来的分布式管道踩过的坑足够写一本小册子。这篇文章就把整套链路的实战经验拆开来讲从 Scrapy 爬虫的架构设计到数据管道的稳定性保障再到 ClickHouse 存储的调优细节尽量把每个环节的为什么讲清楚。1. 电商数据采集的真实难点在哪里1.1 不是能不能爬到而是能不能持续爬到刚入行的时候我也觉得爬虫的核心是绕过反爬。后来做久了才发现反爬只是众多问题中的一个。电商平台的数据采集面临的核心挑战其实是持续性和一致性。持续性指的是你今天写好的爬虫明天可能就失效了。电商平台的页面结构、接口参数、加密方式、风控策略都在持续变化。一个能跑通的爬虫不代表一周后还能跑通。所以架构设计的第一原则是可维护性优先于性能——代码结构要清晰到任何人接手都能在半小时内定位到需要修改的地方。一致性指的是你采集到的数据字段含义要稳定。电商平台经常调整页面展示逻辑比如原来销量显示的是月销 1000后来改成已售 1000再后来可能变成1000人付款。如果你的解析逻辑写死了匹配月销两个字那平台一改文案你就全挂了。正确的做法是把解析规则抽象成配置把文案匹配和数据提取分离。1.2 动态渲染带来的采集复杂度跃升现在的电商页面纯静态 HTML 能拿到的数据越来越少。价格、库存、评价这些核心字段基本都是 JavaScript 动态渲染出来的。这就意味着单纯的 requests BeautifulSoup 方案在很多场景下已经不够用了。应对动态渲染有两条路一是逆向接口直接找到页面背后调用的 API用 requests 模拟请求二是用浏览器自动化工具比如 Playwright渲染页面后再提取。两条路各有优劣方案优势劣势适用场景接口逆向速度快、资源消耗低、易规模化需要分析加密参数、维护成本高接口参数稳定、加密逻辑简单的平台浏览器渲染所见即所得、适配性强速度慢、资源消耗大、并发受限页面逻辑复杂、接口加密强的平台混合方案兼顾速度与适配性架构复杂度高大部分生产级项目我个人的经验是优先尝试接口逆向逆向成本太高时再退回到浏览器渲染。但即便是浏览器渲染也不要用 SeleniumPlaywright 在稳定性和速度上都明显更优尤其是处理 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( urlhttps://example.com/product/123, meta{ playwright: True, playwright_page_methods: [ PageMethod(wait_for_selector, iframe#review-frame), ], playwright_include_page: True, }, callbackself.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天前分类信息层级不清晰清洗流程我一般按这个顺序走字段提取用正则从原始文本中提取结构化数据类型转换统一转换为目标类型价格转 float销量转 int格式标准化统一日期格式、去除多余空白和特殊字符异常值处理标记或剔除明显异常的数据字段补全根据已有字段推导缺失字段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(minutesminutes) elif 小时前 in raw_time: hours int(re.search(r(\d), raw_time).group(1)) return now - timedelta(hourshours) elif 天前 in raw_time: days int(re.search(r(\d), raw_time).group(1)) return now - timedelta(daysdays) 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, doseqTrue) 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 都会生成一个新的 partpart 过多会导致合并压力剧增最终影响查询性能。正确的做法是批量写入每批至少 1000 条理想情况下 10000 条以上。在 Scrapy 的 pipeline 里可以用缓冲区实现class ClickHousePipeline: def __init__(self, ch_client, batch_size5000): 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_crashfalse/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-AgentReferer、Accept-Language 这些都要带上必要时使用代理池但代理质量比数量重要遇到验证码不要硬破考虑降低频率或者换时间段最重要的一点尊重目标站点的 robots.txt 和服务条款。采集公开数据用于分析研究是合理的但不要对目标站点造成过大压力。6.2 关于数据质量的几点体会数据质量问题的根源往往在采集阶段而不是清洗阶段。如果采集时字段就提取错了后面怎么清洗都是错的。所以解析规则要写单元测试用真实的页面样本验证字段提取失败时要记录原始数据方便回溯定期抽样人工校验确保解析逻辑没有漂移建立数据质量基线偏离基线时及时告警6.3 关于架构演进的几点体会不要一开始就追求完美架构。我见过太多项目前期花大量时间设计分布式架构、微服务拆分结果业务需求一变全部推倒重来。正确的做法是从简单开始按需演进第一阶段单机 Scrapy SQLite快速验证需求第二阶段单机 Scrapy ClickHouse提升存储和查询能力第三阶段Scrapy-Redis 分布式 监控告警支撑大规模采集第四阶段数据管道服务化支持多数据源接入每个阶段解决当前最痛的问题不要提前优化。6.4 一个具体的性能调优案例最后分享一个实际的调优案例。之前有个项目采集 10 万条商品数据从开始到全部入库花了 6 个小时。分析后发现瓶颈在三个地方第一Playwright 渲染太慢。每个页面渲染平均耗时 3 秒10 万条就是 83 小时。优化方案是把能接口化的页面改成接口采集只对必须渲染的页面用 Playwright。优化后渲染页面占比从 100% 降到 15%。第二ClickHouse 写入批次太小。原来每 100 条写一次part 数量爆炸。改成每 5000 条写一次后写入速度提升明显。第三去重逻辑太耗时。原来在 Python 里用集合去重10 万条数据去重耗时 20 分钟。改成在 ClickHouse 里用 ReplacingMergeTree 自动去重后这部分时间完全省掉了。三项优化加起来总耗时从 6 小时降到 40 分钟。这个案例说明性能优化的前提是找到真正的瓶颈盲目优化往往事倍功半。这套电商数据采集系统从最初的单机脚本演进到现在前后迭代了十几个版本。每一次迭代都是被实际问题逼出来的没有哪一次是为了架构而架构。如果你也在做类似的项目我的建议是先把最小可用链路跑通然后根据实际遇到的问题逐步优化。采集这件事稳定比快更重要可持续比功能多更重要。