ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Scrapy与ClickHouse构建电商数据采集系统:架构设计与性能优化实战

Scrapy与ClickHouse构建电商数据采集系统:架构设计与性能优化实战 1. 电商数据采集系统整体架构设计思路1.1 为什么选择Scrapy而不是Requests单线程做电商数据采集绕不开的第一个问题就是技术选型。我见过太多人一上来就用Requests加BeautifulSoup写个for循环跑几十个页面还行一旦上到几万条商品数据那速度简直让人抓狂。我最早做京东评论采集的时候就是这么干的单线程跑了一晚上才拿到两万条中间还因为超时断了好几次第二天起来一看进度条卡在37%那种崩溃感相信做爬虫的都懂。后来切换到Scrapy框架同样的目标站点同样的网络环境速度直接翻了十几倍。原因很简单Scrapy底层基于Twisted异步网络库默认就支持并发请求而且内置了去重、重试、限速、中间件等一整套机制。你不需要自己去管理线程池也不需要手写重试逻辑框架层面已经帮你处理好了。但Scrapy也不是银弹。它的学习曲线比Requests陡不少尤其是中间件和管道的概念新手容易绕晕。我的建议是如果你只是偶尔抓几个页面做分析Requests完全够用但如果你要构建一个持续运行的电商数据采集系统Scrapy是更合适的选择。具体对比如下对比维度Requests BeautifulSoupScrapy并发能力需手动实现内置异步并发去重机制需自行维护内置dupefilter重试策略手动编写中间件自动处理数据管道无内置Item Pipeline分布式扩展困难配合Redis轻松实现学习成本低中等1.2 数据管道为什么选ClickHouse做存储采集到的数据往哪里存这是第二个关键决策。很多人第一反应是MySQL毕竟熟悉。但电商数据有几个特点写入量大、字段多、查询模式以聚合分析为主。我实测过用MySQL存商品价格监控数据单表到五千万行的时候一个简单的按品类分组求均价的查询要跑十几秒这在做实时看板的时候完全不可接受。ClickHouse是列式存储数据库天生为OLAP场景设计。同样的数据量和查询ClickHouse基本在毫秒级返回。而且它的压缩比非常惊人我这边实测商品数据压缩比能达到8:1左右存储成本直接降了一个数量级。当然ClickHouse也不是没有坑。它不支持标准SQL的完整语法比如没有真正的UPDATE和DELETE只能通过ALTER TABLE做异步变更。另外它对高并发的点查支持不好如果你需要根据商品ID频繁查单条记录那还是得配合Redis或者MySQL做缓存层。1.3 整体数据流向设计整个系统的数据流向我设计成这样Scrapy爬虫负责从电商平台抓取原始数据经过清洗和格式化之后通过Item Pipeline写入消息队列做缓冲然后由消费者批量写入ClickHouse。中间加消息队列的原因是电商平台的反爬策略会导致采集速度波动如果直接写ClickHouse遇到写入峰值可能会丢数据或者触发Too many parts错误。这个架构看起来多了一层但实际跑下来稳定性提升非常明显。我最早是Scrapy直接写ClickHouse的遇到大促期间数据量暴涨ClickHouse频繁报错后来加了Redis做缓冲问题就解决了。2. 核心细节解析与实操要点2.1 Scrapy爬虫的核心配置调优Scrapy的默认配置是面向通用场景的用在电商数据采集上必须做针对性调整。以下是我经过多次压测后总结的一套参数配置直接可以抄作业# settings.py 关键配置 CONCURRENT_REQUESTS 32 CONCURRENT_REQUESTS_PER_DOMAIN 16 DOWNLOAD_DELAY 0.5 DOWNLOAD_TIMEOUT 15 RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504, 408, 429] # 自动限速 AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1 AUTOTHROTTLE_MAX_DELAY 10 AUTOTHROTTLE_TARGET_CONCURRENCY 8 # 去重 DUPEFILTER_CLASS scrapy.dupefilters.RFPDupeFilter DUPEFILTER_DEBUG False # 中间件 DOWNLOADER_MIDDLEWARES { scraper.middlewares.RotateUserAgentMiddleware: 400, scraper.middlewares.ProxyMiddleware: 410, scrapy.downloadermiddlewares.retry.RetryMiddleware: 550, }这里重点说几个参数的选择逻辑。CONCURRENT_REQUESTS设为32是经过测试的平衡点再高容易触发目标站点的风控再低采集效率上不去。DOWNLOAD_DELAY设为0.5秒配合自动限速基本能保证在不被封IP的前提下最大化采集速度。RETRY_HTTP_CODES里我特意加了429这是HTTP状态码里表示请求过多的电商平台限流时经常返回这个。如果不重试这部分数据就丢了。2.2 动态页面渲染的处理方案现在的电商平台大量使用JavaScript动态渲染传统的Scrapy Request拿到的HTML里根本没有商品数据。我试过三种方案第一种是分析Ajax接口直接请求JSON数据这是效率最高的方式但需要逆向分析接口参数而且平台经常改签名算法维护成本高。第二种是使用Splash渲染但Splash项目已经很久没更新了对现代前端框架支持不好。第三种是Scrapy配合Playwright这也是我目前主力使用的方案。Playwright能完整执行页面JavaScript拿到渲染后的DOM。配置方式如下# middlewares.py from scrapy_playwright.page import PageMethod class PlaywrightMiddleware: def process_request(self, request, spider): if request.meta.get(playwright): request.meta[playwright_page_methods] [ PageMethod(wait_for_selector, .product-list), PageMethod(evaluate, window.scrollTo(0, document.body.scrollHeight)), PageMethod(wait_for_timeout, 2000), ] return None这里有个关键点wait_for_selector要选一个商品列表渲染完成后才出现的元素不能选页面框架自带的元素否则等待就失去意义了。滚动到底部的操作是为了触发懒加载很多电商列表页只渲染可视区域的内容。2.3 ClickHouse表结构设计要点ClickHouse的表结构设计和MySQL思路完全不同。MySQL讲究范式尽量减少冗余ClickHouse则鼓励反范式把常用查询需要的字段都冗余进去用空间换时间。我设计的商品价格监控表结构如下CREATE TABLE product_price_monitor ( product_id String, platform String, category String, title String, price Decimal(10, 2), original_price Decimal(10, 2), sales_count UInt32, shop_name String, crawl_time DateTime, dt Date DEFAULT toDate(crawl_time) ) ENGINE MergeTree() PARTITION BY toYYYYMM(dt) ORDER BY (platform, category, product_id, crawl_time) TTL dt INTERVAL 90 DAY;几个设计决策的解释ORDER BY的顺序很关键它决定了数据在磁盘上的物理排序。我把platform和category放在前面因为大部分查询都会带这两个过滤条件。TTL设置为90天自动过期电商价格数据超过三个月参考价值就大幅下降了自动清理能省不少存储。分区键用toYYYYMM而不是toDate是为了控制分区数量。如果按天分区一年就是365个分区ClickHouse对分区数量是有建议上限的按月分区一年只有12个管理起来轻松很多。3. 实操过程与核心环节实现3.1 从零搭建Scrapy项目骨架先创建项目结构这一步没什么好说的标准命令scrapy startproject ecommerce_scraper cd ecommerce_scraper scrapy genspider product_spider example.com项目创建好之后我习惯先规划好目录结构把不同平台的爬虫分开管理ecommerce_scraper/ ├── scrapy.cfg ├── ecommerce_scraper/ │ ├── __init__.py │ ├── items.py │ ├── middlewares.py │ ├── pipelines.py │ ├── settings.py │ └── spiders/ │ ├── __init__.py │ ├── base_spider.py │ ├── platform_a.py │ └── platform_b.pybase_spider.py里放公共逻辑比如请求头管理、翻页处理、数据解析的通用方法。各平台爬虫继承base_spider只实现差异部分。这样后期加新平台的时候代码量能减少一半以上。3.2 Item定义与数据清洗Item的定义要尽量完整把可能用到的字段都列上后面不用可以留空但缺字段再改结构就很麻烦import scrapy class ProductItem(scrapy.Item): product_id scrapy.Field() platform scrapy.Field() category scrapy.Field() title scrapy.Field() price scrapy.Field() original_price scrapy.Field() sales_count scrapy.Field() shop_name scrapy.Field() crawl_time scrapy.Field() url scrapy.Field()数据清洗我放在Pipeline里做主要处理几种情况价格字段去掉货币符号和千分位逗号、销量字段把“1.2万”转成12000、标题去掉多余空白字符。这些看起来是小事但不处理的话存到ClickHouse里类型对不上查询的时候全是坑。3.3 Pipeline写入ClickHouse的完整实现这是整个系统最核心的部分我直接上代码import clickhouse_connect from itemadapter import ItemAdapter from datetime import datetime class ClickHousePipeline: def __init__(self, host, port, database, batch_size): self.host host self.port port self.database database self.batch_size batch_size self.buffer [] classmethod def from_crawler(cls, crawler): return cls( hostcrawler.settings.get(CLICKHOUSE_HOST), portcrawler.settings.get(CLICKHOUSE_PORT), databasecrawler.settings.get(CLICKHOUSE_DB), batch_sizecrawler.settings.get(CLICKHOUSE_BATCH_SIZE, 1000), ) def open_spider(self, spider): self.client clickhouse_connect.get_client( hostself.host, portself.port, databaseself.database, ) def process_item(self, item, spider): adapter ItemAdapter(item) self.buffer.append([ adapter.get(product_id, ), adapter.get(platform, ), adapter.get(category, ), adapter.get(title, ), float(adapter.get(price, 0)), float(adapter.get(original_price, 0)), int(adapter.get(sales_count, 0)), adapter.get(shop_name, ), adapter.get(crawl_time, datetime.now()), ]) if len(self.buffer) self.batch_size: self._flush() return item def _flush(self): if not self.buffer: return self.client.insert( product_price_monitor, self.buffer, column_names[ product_id, platform, category, title, price, original_price, sales_count, shop_name, crawl_time ] ) self.buffer.clear() def close_spider(self, spider): self._flush() self.client.close()批量写入的batch_size设为1000是经过测试的。太小了写入频繁ClickHouse会产生大量小part后台合并压力大太大了内存占用高而且一旦失败重试成本高。1000条一批单批写入时间在200毫秒左右对爬虫整体速度几乎没有影响。3.4 分布式采集的扩展方案单机Scrapy跑久了总会遇到瓶颈这时候就需要上分布式。Scrapy-Redis是最成熟的方案核心思路是把请求队列和去重集合放到Redis里多台机器共享。配置很简单把调度器和去重器换成Redis版本SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter SCHEDULER_PERSIST True REDIS_URL redis://127.0.0.1:6379但这里有个坑要注意Scrapy-Redis默认的调度器是先进先出队列对于电商采集来说你可能希望优先采集某些品类或者某些价格区间的商品。这时候需要自定义调度器用Redis的Sorted Set来实现优先级队列。另外分布式环境下ClickHouse的写入也要做协调。我的做法是每台爬虫机器写自己的本地缓冲文件然后由一个独立的消费者进程统一读取并写入ClickHouse。这样避免了多客户端并发写入导致的part过多问题。4. 常见问题与排查技巧实录4.1 ClickHouse重启报错failed to flush system log的处理这个错误我遇到过好几次通常出现在ClickHouse非正常关闭之后。报错信息大概是“failed to flush system log, already exists”。原因是ClickHouse的系统日志表在重启时尝试flush但上次关闭时残留了未完成的part文件导致冲突。解决方法分两步。第一步找到ClickHouse的数据目录通常在/var/lib/clickhouse/进去之后找到system日志对应的目录把里面残留的tmp_part或者未合并的part文件清理掉。第二步检查metadata目录下对应的表定义文件是否完整如果损坏了需要从备份恢复。预防措施更重要关闭ClickHouse时一定要用systemctl stop clickhouse-server给它足够的时间完成flush。直接kill -9的话下次启动大概率出问题。另外可以在config.xml里把system日志的flush间隔调大一些减少频繁写入。4.2 爬虫被目标站点封禁的应对策略电商平台的反爬手段越来越高级我总结了几种常见封禁方式和应对方法封禁类型表现应对策略IP封禁返回403或连接超时使用代理池轮换IPUser-Agent检测返回验证码页面随机UA中间件请求频率限制返回429自动限速指数退避Cookie追踪登录态失效定期刷新Cookie池行为分析返回假数据模拟真实用户行为轨迹代理池这块我要多说一句。免费代理基本不能用十个里面九个是坏的。付费代理也要选靠谱的供应商而且要做好代理质量检测把响应慢的、返回错误页面的代理及时剔除。我一般会在中间件里加一个代理评分机制每个代理初始100分请求失败扣10分低于60分就暂时禁用。4.3 数据去重与增量采集的实现电商数据采集最怕重复。同一个商品每天采集一次一个月就是30条记录如果不做去重数据量会爆炸式增长。我的做法是在ClickHouse层面用ReplacingMergeTree引擎配合版本字段实现去重CREATE TABLE product_price_monitor ( product_id String, platform String, price Decimal(10, 2), crawl_time DateTime, version UInt64 ) ENGINE ReplacingMergeTree(version) ORDER BY (platform, product_id, crawl_time);ReplacingMergeTree会在后台合并时保留version最大的那条记录。但要注意合并是异步的查询的时候可能还会看到重复数据需要用FINAL关键字或者GROUP BY来去重。增量采集的策略是记录每个商品最后采集时间下次只采集超过一定时间间隔的商品。这个状态可以存在Redis里key是商品IDvalue是最后采集时间戳。4.4 数据质量监控与告警系统跑起来之后最怕的是悄无声息地出问题。比如某天开始采集到的价格全是0或者某个品类的数据突然断了。我搭建了一套简单的监控体系每天定时跑几个检查SQL比如统计当天各平台的数据量、检查价格字段的空值率、对比历史均值看是否有异常波动。如果发现异常通过邮件或者即时通讯工具发告警。-- 检查当天数据量是否正常 SELECT platform, count() as cnt FROM product_price_monitor WHERE dt today() GROUP BY platform HAVING cnt 1000; -- 检查价格异常 SELECT count() as abnormal_count FROM product_price_monitor WHERE dt today() AND (price 0 OR price 1000000);这两个查询很简单但非常有效。我靠它们发现过好几次问题比如某平台改版导致解析规则失效、代理IP全部被封导致采集中断等。4.5 性能瓶颈的定位与优化系统跑久了难免遇到性能问题。我的排查顺序一般是先看爬虫端的采集速度再看网络传输最后看ClickHouse的写入和查询。爬虫端慢的话先检查是不是被限速了看日志里有没有大量重试。如果重试率超过10%说明触发了反爬需要调整采集策略。ClickHouse写入慢的话看system.parts表里part的数量如果超过300个说明合并跟不上写入速度需要降低写入频率或者增加合并线程数。查询慢的话用EXPLAIN看执行计划重点检查有没有全表扫描索引有没有用上。我遇到过一次查询特别慢的情况排查发现是ORDER BY字段顺序不对导致ClickHouse无法利用主键索引。调整ORDER BY顺序之后查询从3秒降到了50毫秒。4.6 数据备份与恢复策略ClickHouse的数据备份不像MySQL那么直观它没有提供类似mysqldump的工具。我目前用的是两种方案结合对于小表直接用SELECT ... INTO OUTFILE导出为CSV简单粗暴但有效。对于大表用ClickHouse自带的BACKUP命令支持增量备份到本地磁盘或者对象存储。恢复的时候要注意如果表结构有变化需要先建好表再导入数据。另外备份文件要定期做恢复演练不然真出问题的时候才发现备份文件是坏的那就尴尬了。我个人在实际操作中的体会是电商数据采集系统最难的不是写爬虫而是保证整个管道长期稳定运行。代码写出来只是开始后面持续的监控、调优、应对平台改版才是真正花时间的地方。建议一开始就把监控和告警做好别等到出问题了才手忙脚乱去查。另外ClickHouse虽然强大但也不是万能的该用Redis缓存的地方就用Redis该用MySQL存元数据的地方就用MySQL别想着一个数据库解决所有问题。
返回列表