从爬虫到告警:手把手拆解商品价格监控系统的架构与实战

发布时间:2026/9/16 7:33:10
从爬虫到告警:手把手拆解商品价格监控系统的架构与实战 1. 从“手动比价”到“系统监控”这个项目到底在解决什么问题先说个真实的场景。我平时有固定在某几个电商平台买东西的习惯但有一阵子发现同一件商品在不同时间点的价格波动特别离谱——今天看是599明天可能变成699大促前偷偷涨回原价再打折这些都是常规操作。刚开始我靠收藏夹和备忘录手动记录结果发现根本盯不过来商品一多平台一多手动刷新不仅效率低还容易漏掉真正的低价窗口。后来我就想与其每天手动去翻页面不如干脆写一个商品价格监控系统让我想盯的商品一旦价格降到心理价位就自动通知我。这个项目一开始只是自用的小脚本但做着做着发现里面涉及的东西比想象中多数据采集、反爬处理、数据清洗、价格趋势分析、通知推送还要考虑系统的稳定性和可扩展性。于是我把这个小脚本重构成了一套完整的系统。这篇文章就围绕这个系统的设计与实现展开适合三种人看一是经常网购、想要一个自己专属比价工具的朋友二是正在学爬虫和数据处理的开发者想知道一个完整的监控系统是怎么串起来的三是准备做类似价格监控/库存监控/信息变更监控项目的工程师可以参考一下我的架构设计和踩坑经验。在开始之前先说明一点这个系统本身是一个通用技术方案不绑定任何特定平台。实际部署时你需要以目标网站最新的页面结构和规则为准遵守相关法律法规尊重网站的访问约定。2. 整体架构设计不是只有爬虫而是一条完整的数据流水线很多人一听价格监控系统第一反应就是哦写个爬虫定时抓价格。确实爬虫是核心环节之一但一个能长期稳定运行、不漏报不误报的系统远不止爬虫这么简单。我做这套系统时把它拆成了四条链路数据采集层、数据处理层、存储与分析层、通知与展示层。2.1 数据采集层定时任务、代理策略和请求伪装采集层的核心职责很简单按照预设的调度策略从指定的商品页面抓取价格、标题、库存状态等基础信息。但简单两个字背后藏着一大堆细节。首先是调度策略。我早期用的是最简单的每30分钟全量跑一遍所有监控商品后来发现这种做法很低效——一方面高频请求容易触发目标网站的反爬机制另一方面很多商品的价格一天只变一两次全量高频抓取纯粹是浪费资源。后来我改成了分层调度策略监控粒度适用场景调度频率高频监控大促期间、即将开抢的商品每10-15分钟标准监控普通商品日常跟踪每1-2小时低频监控价格长期稳定的商品每6-12小时这个表看起来简单但背后的逻辑是按需分配采集资源我后面会展开说。其次是请求伪装。主流电商平台基本都有基础的反爬策略比如校验请求头、检测访问频率、识别浏览器指纹等。我常用的策略包括随机轮换User-Agent、设置合理的请求间隔加上随机抖动、使用Cookie池维持会话状态。这里一定要强调一个原则伪装的目的不是攻击而是让请求更接近真实用户的行为模式避免对目标站点造成异常压力。2.2 数据处理层清洗、抽取、标准化采集到原始HTML或JSON数据后不能直接丢进数据库。不同平台的页面结构不同价格信息可能藏在各种位置——有的在JSON-LD结构化数据里有的在页面的特定DOM节点中还有的经过JS动态加载。所以处理层的核心任务有三个提取目标字段、清洗脏数据、统一数据格式。举个例子同样的商品平台A返回的是599.00平台B返回的是599还有的带折扣信息599原价799。这些数据如果不在入库前标准化后续的价格分析、趋势对比根本没法做。我做了一个统一的商品数据模型核心字段包括商品ID平台商品唯一标识商品标题当前价格统一为浮点数保留两位小数原价/划线价库存状态有货/无货/预售抓取时间戳商品详情页URL2.3 存储与分析层价格历史记录的积累这一层是系统的记忆。价格监控的核心价值不在于某一刻抓到多少钱而在于积累下来的历史价格序列——有了历史数据才能判断当前价格是高位还是低位才能预测大促期间的可能走向才能在价格跌破阈值时准时触发告警。存储选型上我用的是MySQL加Redis的组合MySQL存储商品主数据和全量价格历史记录Redis缓存最新的价格快照和高频访问的查询结果。如果监控规模再上一个量级比如几千万条价格记录那就要考虑时序数据库了这个后面在架构演进部分会详细说。2.4 通知与展示层价格告警和可视化系统最终要给用户提供价值靠的就是这一层。我实现了两种输出方式主动式通知价格跌破设定的目标价时通过Server酱、邮件、钉钉/企业微信机器人等方式推送告警。这个对时效性要求较高容不得延迟。被动式展示一个简单的Web页面展示所有监控商品的状态看板、价格走势图、历史告警记录。方便随时打开看一眼而不是被动等通知。考虑到很多读者可能是单人开发或小团队使用我在这里要强调一个观点初期不需要做太重的架构但数据模型设计和模块边界一定要清晰否则后面每加一个功能都可能要重构。3. 数据采集的实战细节从XPath到渲染引擎的取舍数据采集是整个系统里踩坑最多的地方值得单独拿出来讲。很多新手写爬虫最常犯的毛病是能跑就行——今天能抓到数据就万事大吉结果第二周目标网站改版全挂了。3.1 页面解析方案的演进静态解析优先渲染引擎兜底我在项目初期主要用requests加BeautifulSoup解析静态HTML配合XPath或CSS选择器定位节点。这个方案速度快、资源消耗小对于服务端渲染的页面非常有效。但后来遇到越来越多的动态加载页面——商品价格是页面加载后通过JavaScript异步请求接口填充的直接抓静态HTML拿不到价格。这时候有两条路分析页面背后的数据接口API直接请求接口拿JSON数据。使用渲染引擎如Selenium、Playwright模拟真实浏览器等待JS执行完再抓取。我优先推荐第一种。因为接口返回的都是结构化数据解析成本低而且性能通常比渲染页面好得多。用浏览器开发者工具的网络面板刷新页面筛选XHR或Fetch请求很快就能找到数据接口。如果接口有签名参数才考虑用渲染引擎方案。用Playwright做兜底时我总结了一套比较顺手的流程import asyncio from playwright.async_api import async_playwright async def fetch_dynamic_page(url: str) - str: async with async_playwright() as p: # 用 Chromium 内核headless 模式 browser await p.chromium.launch(headlessTrue) page await browser.new_page() # 设置超时和等待策略 await page.goto(url, timeout30000, wait_untilnetworkidle) # 等待价格节点出现 await page.wait_for_selector(.price-now, timeout10000) content await page.content() await browser.close() return content注意wait_untilnetworkidle这个参数它会在网络连接空闲后才返回基本能保证JS渲染完毕。不过它也有代价如果页面有持续轮询的请求可能会一直等不到空闲所以要配合超时时间和显式等待一起用。3.2 请求频率控制别把自己玩进去关于请求频率我的原则很简单在不影响目标网站正常服务的前提下采集。我在代码里做了一个简单的限速器import time import random class RateLimiter: def __init__(self, min_interval2.0, max_interval5.0): self.min_interval min_interval self.max_interval max_interval self.last_request_time 0 def wait(self): current time.time() elapsed current - self.last_request_time interval random.uniform(self.min_interval, self.max_interval) if elapsed interval: time.sleep(interval - elapsed) self.last_request_time time.time()随机化间隔非常关键。固定间隔的请求在目标站点的访问日志里会呈现出极其规律的指纹特征很容易被识别和限制。随机抖动之后请求时间分布更接近真实用户行为。3.3 异常处理和重试机制监控系统最不能怕的就是失败价格监控系统是7x24小时运行的而网络请求一定会出问题超时、连接重置、返回异常状态码、IP被临时限制……如果代码不做异常处理任何一个环节失败都可能导致整条采集链路中断那监控就成了瞎子。我的异常处理分三层单次请求异常捕获TimeoutError、ConnectionError、HTTPError等记录日志等待下一次调度。单商品连续失败如果同一个商品连续N次通常设3次采集失败标记该商品为采集异常不再继续重试高频率但保留在队列中降频尝试。全局任务熔断如果整体失败率超过阈值比如30%说明可能是目标网站改版或网络环境出问题暂停整个采集任务发告警给管理员。def fetch_with_retry(url, max_retries3, backoff_factor2): for attempt in range(max_retries): try: response requests.get(url, timeout10, headersrandom_headers()) response.raise_for_status() return response.text except (requests.Timeout, requests.ConnectionError) as e: if attempt max_retries - 1: raise time.sleep(backoff_factor * (attempt 1)) except requests.HTTPError as e: # 4xx 错误一般不重试直接抛出 if e.response.status_code 500: raise time.sleep(backoff_factor * (attempt 1))指数退避Exponential Backoff是一个特别经典的策略失败后先等2秒再等4秒再等8秒避免重试风暴。这个经验在微服务、分布式系统里也通用在这里同样适用。3.4 断点续抓让任务中断不丢数据系统运行最怕什么不是某一刻失败而是失败之后数据链路的断裂。我做了一个断点续抓机制每个商品记录当前采集状态和最后成功采集时间调度器重启时扫描所有状态为执行中或待采集且长时间未更新的任务重新加入队列。配合Redis的有序集合做任务去重确保同一时间同一个商品不会有两个采集任务并发执行。4. 价格数据的清洗与存储科学建模是半壁江山采集到的数据五花八门如果不在入库前做好清洗和规范后面分析、告警全是空中楼阁。我在这个模块里踩过的坑比采集层还多。4.1 价格标准化的坑小数、符号、区间价价格字段是最容易出问题的。我遇到过的情况包括599没有小数、599.00、599、599元、,599某些英文站点的千分位分隔符、599-799区间价。还有更隐蔽的页面上显示的是券后价599实际原始HTML里的JSON数据可能有个originalPrice:799和一个promotionPrice:599到底哪个是当前价我的处理规则是优先取平台结构化数据中的实际成交价字段。如果没有结构化数据从DOM中优先取折扣价/促销价兼容券后价等文案。区间价取最低值作为当前价同时记录区间上下限。所有价格统一转成浮点数单位统一为元入库前用Decimal做精确运算避免浮点误差。为什么要用Decimal而不是float因为金融级别的价格计算里0.1 0.2这种浮点误差是不能接受的。虽然监控系统对单次价格计算精度要求不高但累计误差在趋势分析、均价计算中会被放大。4.2 数据表设计主数据与价格历史分离我在数据库设计上用了一主一从的思路商品主数据表product存储商品的静态信息比如标题、链接、图片、所属平台。每条记录有一个唯一的商品编号。价格历史表price_history存储每一次采集的价格快照字段包括商品编号、当前价格、原价、库存状态、采集时间。价格阈值表price_alert_rule存储用户的关注规则包括目标价格、通知渠道、启用状态。为什么要主数据和历史数据分离因为商品信息更新的频率低而价格变化的频率高。如果混在一张表里每次价格更新都要更新整行记录不仅浪费资源还丢失了历史轨迹。分离之后主表只保留最新状态历史表记录每一次变化分析时直接查历史表即可。4.3 数据一致性重复数据、脏数据、异常数据的兜底采集任务并发执行时可能同一商品同一秒抓了两次页面改版后可能解析出的价格字段是None。这些都需要在清洗阶段处理。我的做法是入库前先查询该商品在该时间窗口内是否已有记录如果有则跳过。清洗阶段识别异常值价格为负、价格为0、价格比历史记录低超过某个比例比如80%以上——这种通常是解析错误而不是真实降价要标记为异常。所有清洗规则集中在一个函数里处理方便单元测试覆盖。其实这里有个经验值得分享价格异常值的过滤不能死板。有时候平台会出现秒杀价或清仓价确实会比历史价低很多。我的方案是不是直接丢弃异常值而是标记为待人工确认同时触发一个降低频率的临时监控任务去复核。如果复核确认是真实降价再告警如果复核发现解析错误就修正解析规则。5. 价格趋势分析与告警触发让系统会思考有了干净、连续的价格历史数据接下来的核心价值就是分析和告警。如果只是把价格抓下来存进数据库那系统只是记录仪谈不上监控。真正的监控应该做到两点一是在正确的时候触发告警二是能对价格走势提供有意义的参考。5.1 跌价趋势评估不只是低于阈值就通知最初我的告警逻辑极其简单当前价低于目标价就推送。用了几天发现问题很大——遇到先涨价再降价的套路系统会在涨价后的高位触发一次低于目标价的告警实际上用户并没有真正捡到便宜。还有促销前先涨后降系统频繁推送低价但用户已经对通知产生免疫。后来我加入了几个辅助指标历史价格百分位当前价格在过去90天内的价格分布中所处的百分位。如果当前价格处于5%百分位说明这是近三个月的最低档位。降幅比例相比于最近30天的平均价格当前价降了多少。一般超过15%算显著降幅。价格走势斜率通过简单线性回归判断最近7天的价格是处于快速下跌通道还是横盘震荡。综合这三个指标告警触发条件变成触发条件 当前价低于用户设定阈值且当前价处于历史价格的20%百分位以下或7日降幅超过15%这个逻辑让误报率大幅下降。当然这套优化也不是一蹴而就的我调试了很久才达到比较理想的状态。5.2 通知渠道的选型与优先级通知渠道我分别接入了邮件、企业微信机器人和一个自建的Web页面。不同渠道有不同的适用场景渠道优势劣势适用场景邮件稳定可靠可追溯时效性一般日报告、周报告、非紧急告警企业微信/钉钉实时性好支持富文本依赖企业账号紧急降价、库存变化Web页面直观支持图表需要主动访问综合看板、历史查询在我的实际使用中主力通知渠道是企业微信机器人因为它可以推送到手机端消息格式也够灵活。Server酱也是一个不错的选择适合个人项目快速集成。5.3 定时报表除了告警还要让人心中有数除了实时告警我还加了一个每日定时报表。每天早上9点推送一份汇总今日监控商品总数、状态正常数、异常数。降价Top10商品筛选出12小时内降幅最大的商品。即将到达目标价的商品距离目标价不超过3%的商品提醒用户重点关注。昨日告警汇总哪些商品触发了什么类型的告警。这个日报看起来简单但实际使用中价值非常高。它把被动等待降价变成了主动掌握趋势尤其适合监控商品数量较多的情况。6. 部署实践与性能优化从单机脚本到稳定服务系统在开发环境跑通和在生产环境稳定运行完全是两回事。我在部署阶段主要做了三件事容器化、任务调度的可靠性保证、以及性能瓶颈排查。6.1 Docker Compose一键部署整套系统的组件包括Python采集任务、MySQL数据库、Redis缓存、Web服务、定时调度器。如果用传统方式手动部署光环境配置就够折腾。我把所有组件写进了docker-compose.ymlversion: 3.8 services: mysql: image: mysql:8.0 container_name: price-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: price_monitor volumes: - ./data/mysql:/var/lib/mysql ports: - 3306:3306 restart: unless-stopped redis: image: redis:7-alpine container_name: price-redis ports: - 6379:6379 restart: unless-stopped crawler: build: ./crawler container_name: price-crawler depends_on: - mysql - redis environment: DB_HOST: mysql REDIS_HOST: redis volumes: - ./logs:/app/logs restart: unless-stopped web: build: ./web container_name: price-web ports: - 8080:8080 depends_on: - mysql - redis restart: unless-stopped用restart: unless-stopped保证容器挂了能自动重启这在无人值守的服务器上特别重要。6.2 调度器的可靠性避免重复执行和任务丢失我用的调度方案是APScheduler加Redis分布式锁。为什么不用简单的crontab因为crontab没有持久化任务状态也没有失败重试的概念更没法在分布式多实例部署时避免重复执行。用Redis分布式锁的伪代码大致是这样import redis import uuid r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def acquire_lock(lock_name, expire_seconds60): token str(uuid.uuid4()) # SET NX EX 原子操作不存在时设置并设置过期时间 result r.set(lock_name, token, nxTrue, exexpire_seconds) if result: return token return None def release_lock(lock_name, token): # Lua 脚本保证原子性只有 token 匹配才删除 lua_script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end r.eval(lua_script, 1, lock_name, token)这套方案的好处是即使有多个采集实例同时运行也只有拿到锁的实例真正执行任务避免重复采集和重复告警。6.3 性能瓶颈排查慢SQL、连接池、批量写入系统上线跑了一周后我发现采集任务的执行时间越来越长。排查下来主要问题有三个第一数据库写入太频繁。每条价格记录一条INSERT监控500个商品、每天抓24次一天就是12000条记录虽然不算多但如果每条都单独提交事务开销不小。优化方案是批量写入每积累50条记录或每隔5秒统一提交一次。第二SQL查询缺少索引。价格历史表如果按商品编号查询一定要建(product_id, created_at)复合索引。否则数据量上来之后按商品查趋势图会越来越慢。第三采集任务和数据入库的耦合太紧。采集线程抓完数据后直接操作数据库数据库一旦抖动整个采集链路都会阻塞。后来我改成了生产者-消费者模式采集线程把解析好的数据扔进Redis队列独立的入库进程从队列里取数据再写库。这个解耦让采集和入库互不拖累。7. 扩展思路这朵小火花还能点起哪些火单个商品的监控系统做扎实之后你会发现这套架构可以照搬到很多相似的场景里。虽然不是每一条都值得马上动手做但知道路可以往哪里走本身很有价值。7.1 从监控价格到监控库存与促销状态价格只是商品状态的一个维度。库存状态、促销类型、是否参与满减、优惠券剩余数量这些信息对决策同样重要。数据层已经支持多字段存储扩展起来就是增加解析规则和分析逻辑。比如如果一个商品长期缺货一旦监测到有货就立刻通知这在购买限量商品或抢购热门商品时非常实用。7.2 从比价到多平台比价与历史低价日历如果把多个同款商品在不同平台的监控数据放在一起做横向对比就自然形成了一个多平台比价系统。再进一步把一年的价格历史按月份聚合就能生成历史低价日历——哪些月份容易出低价、大促前价格曲线如何变化一目了然。我甚至可以基于历史数据做个简单的价格预测模型虽然不是精确预测但能给出未来两周什么样的价格值得出手的参考区间。7.3 从自有系统到通用监控框架我后来把核心代码抽成了一个通用监控框架的核心逻辑可以监控的不只是价格还包括某个页面是否更新某个API的响应时间是否异常某个文章是否被删除等。本质上这套框架做的事情就是定期采集外部信息标准化处理后和历史数据进行对比异常变化触发通知。这套思路在舆情监控、竞品动态跟踪、网站可用性监控里都是通用的。8. 避坑总结我在这套系统上踩过的几个典型坑最后把实操中最容易让人抓狂的几个问题集中说一下每个都是我当初花了不少时间才解决的。如果你打算自己搭一套提前知道这些能省很多时间。8.1 时间戳时区问题一开始我把价格记录的时间戳存成北京时间后来服务器迁移到海外系统时间变成UTC历史数据的时间轴直接乱掉了。这是个非常经典的低级错误。解决方案是所有时间字段统一存储为UTC时间戳展示时再转换成用户本地时区。数据层面只管准确不管好看。8.2 页面改版后的解析规则失效目标网站一次小改版就可能让原来写的XPath完全失效。这类问题没法完全避免但可以减轻。我的做法是对解析结果做结构校验如果解析出的商品标题、价格字段为空或者商品ID匹配不到预设格式立刻标记该条解析为失败而不是把空数据入库。同时设置监控指标——解析失败率超过某个阈值时自动通知管理员检查页面结构。8.3 告警风暴告警机制做得太灵敏一天推几十条通知人就会麻。我加了两个机制一是告警去重同一商品同一阈值条件下24小时内只推一次避免价格在阈值附近反复横跳时疯狂刷屏二是冷却期机制触发告警后进入冷却状态冷却期内即使满足条件也不重复推送冷却结束且价格恢复到阈值以上后才能重新武装下一次告警。8.4 固定IP的资源限制如果采集规模较大用单机固定IP很容易触发风控。更稳妥的方案是使用住宅代理池或者云函数分布式采集。但这会显著增加成本所以初始阶段建议还是控制采集规模把频率降到合理水平优先保证稳定性而不是覆盖所有商品。回到最开始的问题——手动比价的时代已经过去了。当你看中的商品降价时系统能在几分钟内告诉你当你打开看板时能看到过去90天的价格轨迹当你设定好心理价位后剩下的只需要等通知。这种把人工盯梢变成自动化值守的体验就是这个项目最核心的价值。我在实际部署运行这套系统几个月后最大的体会是它省下的不只是时间更是一种不用惦记着去刷新页面的心理负担。如果你也想搞一套自己的价格监控系统从最小可行版本开始先把一条链路跑通再逐步完善分析和通知这条路一定走得通。