ARTICLE DETAIL

资讯详情

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

义乌购商品数据采集实战:精准解析与高可用架构

义乌购商品数据采集实战:精准解析与高可用架构 1. 项目概述当义乌购遇上“精准解析”与“高可用”做义乌购的商品数据采集是我去年接手的一个需求。当时客户做批发选品系统需要把义乌购的商品详情结构化入库供内部比价、货源分析和运营选品使用。一开始我以为这就是个普通的爬虫项目无非是抓页面、解HTML结果动手之后才发现义乌购的商品详情接口远不是“发个请求拿JSON”这么简单。它的数据结构、字段语义、价格档位和库存逻辑处处带着批发业务的特有逻辑而在生产环境里如果接口调用稍微频繁一点又会触发风控、限流、签名过期这些问题。可以说这个项目的核心就落在标题里的六个字上精准解析、高可用架构。先说清楚“精准解析”是什么概念。商品详情接口返回的数据往往包含基础信息、销售信息、规格信息、图片信息、物流信息等一大堆字段。拆开看每个字段都不复杂但组合起来却很容易出错。比如同一个商品零售价、批发价、代发价是三个完全不同的字段再比如SKU规格可能是一个嵌套的JSON数组不同商家命名还不一样。如果不做细致的字段清洗和数据归一化入库的数据根本没法用于比价和选品。而“高可用”则是指这套采集系统不能只是一个跑完就结束的脚本而应该是一套能持续稳定运行、能应对风控波动、能在部分节点挂了之后自动恢复的服务架构。这篇文章我会完整复盘这个项目的落地过程从接口分析、字段解析、数据清洗到生产环境的架构设计、缓存策略、监控告警再到我踩过的坑和排查思路。内容偏实战适合已经会写Python脚本、想往“生产级数据采集”方向进阶的开发者。如果你只是随便抓几个页面做测试那这篇对你来说会有点重但如果你想把采集做成一个能长期稳定跑的服务那这篇文章应该能帮你少走不少弯路。2. 整体设计与思路拆解为什么不能只写一个爬虫脚本2.1 先吃透义乌购的接口请求模式义乌购的商品详情表面上看是一个普通的商品页但它的数据加载方式里藏着关键信息。我打开一个商品页面发现大部分基础字段是直接渲染在服务端返回的HTML里的但同时页面又会发起一个异步请求去拉取SKU库存、批发价格、物流模板这些动态数据。这个异步接口是标准的JSON格式返回结构比较规整适合直接用代码解析。这里要注意一个区别。很多采集项目图省事直接用正则或者XPath从HTML里抠数据我一开始也这么干过。但后来发现HTML结构经常变商家编辑商品后DOM结构会微调正则写死了很容易碎。而且HTML里只有基础字段价格、库存、SKU这些关键数据还是得走异步接口。与其搞两套解析逻辑不如直接在异步接口上做文章HTML只作为兜底校验。这个思路定下来之后整个项目的解析链路就清晰多了。异步接口的请求需要带上商品ID和几个动态参数。其中有一个签名参数是服务端根据商品ID、时间戳和密钥算出来的有效期很短。我第一次拿到这个接口时想偷懒直接把浏览器里的完整请求复制出来跑前面几次是通的但过了几分钟再跑就返回签名过期。所以这个接口不能“一次写好永久用”必须在代码里动态生成签名参数。签名算法本身不复杂但缺少官方文档的情况下需要用浏览器调试工具去定位请求发起位置再从JS代码里逆出算法。这一步是纯体力活但也是整个项目的第一个技术门槛。2.2 批发场景的数据特点决定了“精准解析”比“抓取”更重要拿到接口返回的JSON之后真正的难点才开始。义乌购是批发平台商品数据结构里藏着很多零售平台没有的字段维度。举几个典型例子价格不是单一值而是按不同档位拆分的。常见的有零售价、批发价、代发价、量大价对应的购买数量区间还不一样。起批量是个独立字段但不一定在基础信息里可能在每个SKU的库存数据里。不同颜色不同尺码的起批量可能不同。商品图片不止是商品主图还有细节图、规格图、模特图不同类型的用途完全不同导出的链接结构也不同。商家自定义属性五花八门有的写“材质: 涤纶”有的写“成分: 100%聚酯纤维”需要做归一化处理。这些特点决定了如果只是简单地把接口字段映射到数据库表里后面做选品和比价时会非常痛苦。比如价格字段在批发场景下用户真正关心的是“我买100件和买1000件的价格差多少”而不是一个孤零零的“单价”。所以解析阶段就必须把价格档位解析成结构化的数组并且按数量区间排序方便后续做价格梯度计算。如果这一步偷懒后面所有依赖价格的业务逻辑都会跟着出错。2.3 高可用架构的设计核心把采集变成服务而不是任务很多人在本地写采集脚本跑得很爽一到生产环境就各种崩。核心原因在于脚本和服务的思维模式完全不同。脚本是一次性的、同步的、单机的服务是持续运行的、异步的、分布式的。义乌购这个项目我一开始就按服务的标准来设计虽然前期开发成本高了一些但后来的稳定性收益非常明显。高可用架构设计围绕三个核心点展开请求的可靠性包括超时控制、重试策略、失败降级、代理IP池管理。数据的可靠性包括缓存、持久化、幂等去重、消息队列削峰。系统状态的可观测性包括日志采集、指标监控、告警通知。这三个点对应到具体技术栈上就是Redis做缓存、RabbitMQ或Celery做异步任务队列、Prometheus加Grafana做监控、飞书或钉钉机器人做告警。这些组件单独看都不复杂但组合在一起就能支撑起一套每天跑几十万次请求、偶发风控波动也不会丢数据的采集服务。3. 核心细节解析从字段映射到数据清洗的全链路处理3.1 数据模型设计字段分类与定制化映射对接接口前先设计数据模型。这一步不建议偷懒直接照搬接口字段名而是要根据下游业务需求重新定义一套内部统一的字段标准。我当时的做法是分成四个大组基础信息组商品ID、标题、类目、品牌、货号、主图链接、详情页链接。销售信息组零售价、批发价、代发价、价格档位数组、起批量、库存总量、销量。规格信息组SKU列表每个SKU包含规格名、规格值、价格、库存、图片。运输与售后组物流模板、发货地、运费、是否支持七天退货等。这样分组的好处是后续不管对接多少个下游系统大家统一按这套模型取数不会因为接口字段调整导致所有下游都要跟着改。而且数据模型一旦稳定解析逻辑和数据库表结构都可以围绕它来设计后期维护成本低很多。这里有一个细节值得展开。接口返回的价格档位字段原始格式可能是price_rules: [ {min: 2, max: 99, price: 8.50}, {min: 100, max: 999, price: 7.80}, {min: 1000, max: null, price: 6.90} ]解析时不能直接把数组存进数据库就完事而是需要做两件额外的事一是按min排序确保档位顺序正确二是计算每个档位的“价格/数量比”和“相邻档位价差”方便后续做批量采购决策分析。这些计算逻辑放在解析层完成下游业务就不用重复处理了。3.2 SKU规格解析处理嵌套结构是绕不开的坎SKU是商品解析里最容易出问题的地方。义乌购的SKU结构通常是两层嵌套外层是规格维度颜色、尺码、套餐内层是具体规格值对应的库存和价格。有些商品还有第三层比如“颜色”下面又区分“款式”。我的解析方案是用递归方式把SKU树拍平成一行行的SKU记录每条记录有完整的规格路径、价格、库存、图片链接并生成一个复合唯一键商品ID规格路径哈希。这个唯一键的作用是用于后续的幂等去重和数据更新。做采集的人都知道同一个商品每天要采集很多次如果没有唯一键数据库里会堆满重复数据。有了唯一键每次采集都走“存在则更新、不存在则插入”的逻辑数据永远是干净的。拍平SKU的代码逻辑不算复杂但有几个坑要注意规格值中可能包含空格、换行符、HTML标签残留需要统一清洗。同一个规格值在不同商家那里写法可能不同比如“黑色”和“黑”如果不做映射同一个商品的两个SKU会被误判为不同记录。有些商品没有SKU只有一个默认规格拍平逻辑需要兼容这种情况。我在项目里维护了一份规格值归一化映射表用简单规则加人工维护的方式把常见的同义规格值归并到统一的标准值。一开始是纯人工维护后来接了一个基于编辑距离的模糊匹配准确率提升明显漏网之鱼再用人工兜底。3.3 数据清洗把“脏数据”挡在入库之前解析完成的数据如果直接入库会给下游埋雷。因为义乌购的商品数据是商家自己填的质量参差不齐。以我实际跑过的数据为例大约有8%到12%的商品存在至少一项脏数据问题。清洗环节不是可有可无的装饰而是保障数据质量的核心关卡。我设计了一个五步清洗流程类型检查与强转确保价格字段是数字、库存字段是整数字段缺失或类型非法时先置空再走缺省逻辑。去空格与标准化去掉字符串首尾空格、统一换行符、去掉不可见字符。单位归一化克与千克、厘米与米、件与打全部统一。枚举字段映射把“是否包邮”这类字段归一为布尔值把“七天退换”这类字段归一为枚举值。业务规则校验价格是否大于0、起批量是否小于库存量如果违反规则标记为“待人工复核”。第五步很重要因为有些脏数据不是格式问题而是业务逻辑矛盾。比如一个商品标着起批量100件但库存总量只有3件这明显是商家填错了。这种数据如果直接入库下游采购系统会把采购单发给一个根本没法履约的商品。所以清洗环节遇到业务规则冲突时不要擅自修正而是打上异常标记进入待复核队列。这个设计在项目上线后的第一个月里帮我们拦下了几百条问题数据业务方也因此对我们的数据质量有了很高的信任度。4. 实操过程与核心环节实现从单机采集到高可用服务4.1 基础采集模块请求、重试与签名签发的工程化封装先写最基础的采集模块。我选择Python的httpx作为HTTP客户端主要看中它同时支持同步和异步而且连接池管理比requests更省心。请求模块的核心是一个带重试机制的通用函数import asyncio import hashlib import hmac import json import time import httpx class ItemAPIClient: def __init__(self, base_url, secret_key, proxy_poolNone): self.base_url base_url self.secret_key secret_key self.proxy_pool proxy_pool self.client httpx.AsyncClient( timeouthttpx.Timeout(10.0, connect5.0), limitshttpx.Limits(max_connections50, max_keepalive_connections20), ) def _sign(self, item_id: int, timestamp: int) - str: message f{item_id}{timestamp}.encode(utf-8) return hmac.new(self.secret_key.encode(utf-8), message, hashlib.sha256).hexdigest() async def fetch_item(self, item_id: int, retries: int 3): timestamp int(time.time()) params { item_id: item_id, timestamp: timestamp, sign: self._sign(item_id, timestamp), } for attempt in range(retries): try: async with self.client.stream(GET, self.base_url, paramsparams) as resp: if resp.status_code ! 200: raise RuntimeError(fHTTP {resp.status_code}) data await resp.aread() return json.loads(data) except Exception as exc: if attempt retries - 1: raise await asyncio.sleep(1.5 ** attempt) return None这个模块有两个工程化细节。重试策略我没有用固定间隔而是用了“指数退避加抖动”的方式每次重试间隔按1.5的幂次增长。这样做的原因是如果签名过期或服务端临时抖动快速重试往往还会撞上同样的错误但等几秒再试成功率会明显提升。抖动可以用random.uniform加一点随机量避免多个任务同时重试造成集体踩踏。连接池参数也需要根据生产情况调。刚开始我用默认配置结果高并发时频繁出现“连接池满”的报错。后来把max_connections提到50同时在业务层面做了并发信号量控制保持“池子够大但不至于打死对方服务器”的平衡。这个参数要结合自己业务的并发量来调没有统一标准。4.2 代理IP池与限流策略高可用架构里的“护城河”商品接口请求频率一旦上来服务器风控几乎是必然触发。义乌购的风控表现主要有几种返回验证码页面、接口返回“访问过于频繁”、签名参数虽然正确但被拒绝。针对这种情况单靠本机IP轮换是不够的需要一个完整的代理IP池管理方案。我的做法是自建了一个轻量级代理池服务。代理来源是第三方代理商提供的HTTP代理池子定期从供应商API拉取可用IP然后做实时健康检查。每个IP的可用状态、连续失败次数、最后成功时间都被记录下来。采集任务发请求前从池子里按策略取一个IP用完后归还如果某个IP连续失败超过阈值就自动踢出池子。代理池的健康检查代码大致长这样class ProxyPool: def __init__(self, check_url, threshold3): self.check_url check_url self.threshold threshold self.available set() self.fail_count {} async def check_proxy(self, proxy: str) - bool: try: async with httpx.AsyncClient( proxies{http://: proxy, https://: proxy}, timeout5.0, ) as client: resp await client.get(self.check_url) return resp.status_code 200 except Exception: return False async def health_check_loop(self): while True: for proxy in list(self.available): ok await self.check_proxy(proxy) if not ok: self.fail_count[proxy] self.fail_count.get(proxy, 0) 1 if self.fail_count[proxy] self.threshold: self.available.discard(proxy) self.fail_count.pop(proxy, None) else: self.fail_count[proxy] 0 await asyncio.sleep(30)除了代理池客户端还需要做限速。我给每个商品ID配置了最小访问间隔同一IP对同一接口的访问频率做了约束。这里有个取舍问题限速太严会拖慢采集速度限速太松又容易触发风控。我的经验是从保守值开始跑观察风控触发频率然后逐步放宽找到一个“持续跑24小时不触发验证码”的临界值。每个平台的风控策略不一样这个值需要实际测试来校准没有捷径。4.3 异步任务队列与并发调度数据采集的“削峰填谷”采集合规的高可用不仅体现在单次请求的成功率上更体现在大批量任务的调度上。如果每天要采集几十万个商品详情全部用线程池硬怼一是容易把自己机器打满二是并发一高就会触发对方风控三是任务失败后的恢复逻辑会写得很痛苦。我的方案是引入消息队列把“任务下发”和“任务消费”完全解耦。上游把商品ID列表写入RabbitMQ队列下游的Worker按自己的节奏消费任务。这个架构的天然好处是队列天然支持削峰。即使上游一次性丢进来10万个商品ID下游也能根据自身处理能力慢慢消费不会瞬间把压力打到对方服务器上。任务失败可以重回队列。Worker处理失败时把消息重新投递到延迟队列稍后再试。重试几次仍失败则进入死信队列供人工分析。支持水平扩展。采集需求变大时多起几个Worker实例就行无需改动代码。下面是Worker消费任务的核心逻辑async def worker_loop(queue_name: str): async for message in channel.consume(queue_name): item_id json.loads(message.body) try: data await client.fetch_item(item_id) # 解析、清洗、入库 await process_item(data) await message.ack() except Exception: # 超过重试次数则进死信队列 await message.nack(requeueFalse)这套架构跑下来的实际效果是单台4核8G的服务器配合10个Worker协程日均处理10万商品详情完全没压力。而且系统内有任何一批任务卡住了只影响那批任务整体采集链路不受影响。这种稳定性的价值做生产爬虫的人应该都懂。4.4 Redis缓存策略把热点数据挡在数据库外面商品详情数据有一个明显的访问特征少量热销商品的访问频率极高大量长尾商品访问频率很低。如果每个请求都直接查数据库数据库压力会很大。我在Redis里做了一层缓存按商品ID缓存解析完成后的结构化数据设置了合理的过期时间。缓存策略的核心参数是过期时间和缓存粒度。过期时间我最初设为24小时后来发现一个问题某些热销商品的价格和库存变化很快一天一更新不够用。于是改成了“价格类数据6小时过期、基础信息类数据24小时过期”的分级缓存策略。不同数据写进不同的Redis key避免更新价格时把整块数据都刷掉。async def get_item_with_cache(item_id: int): cache_key fitem:detail:{item_id} cached await redis.get(cache_key) if cached: return json.loads(cached) data await fetch_and_parse_item(item_id) await redis.set(cache_key, json.dumps(data), ex6 * 3600) return data这里有一个容易被忽略的细节就是缓存穿透问题。有些商品ID不存在或者已经被商家下架每次查询都会落空并打到数据库。我在Redis里对那些“查无此商品”的ID做了短时间空缓存比如5分钟过期这样同一个失效ID不会反复穿透数据库。这个优化虽然不起眼但生产环境下能省下不少无效数据库查询。4.5 监控告警与故障自愈系统没挂不代表没有隐患高可用不等于“不出故障”而是“故障能被快速发现并自动恢复”。我为采集系统设计了三个维度的监控全都可以在Grafana面板上一眼看出当前状态请求成功率按时间窗口统计成功请求占比。正常情况下应在99.5%以上。如果掉到95%以下说明可能触发了风控或者代理池出现问题。任务队列积压量RabbitMQ队列中未消费的消息数量。如果积压量持续上升说明消费速度跟不上生产速度需要扩容Worker。数据入库延迟从任务下发到数据落库的平均耗时。这个指标能直观反映整个链路的健康程度。告警通知接入了飞书机器人。规则大概是这样请求成功率连续3分钟低于95%触发提醒队列积压量超过5000条且持续10分钟触发提醒入库延迟超过30秒触发提醒。这些阈值不是拍脑袋定的而是根据两周试运行的数据分布算出来的。告警频率控制很重要宁可少报不可乱报狼来了喊多了团队会麻木。故障自愈方面我实现了两个自动化动作。一是Worker进程崩溃后由Supervisor或Kubernetes自动拉起二是代理池整体不可用时采集任务自动进入“防抖模式”把并发数降为正常值的五分之一等代理池恢复后再调回。这个机制在实际运行中救过我一命有次代理供应商API故障正常情况下所有采集任务都会超时但因为防抖模式的存在只有少量请求在缓慢重试系统没有任何告警风暴。5. 常见问题与排查技巧实录那些文档里不会写的事5.1 签名过期不是算法不对是时间戳没对齐这是我最先遇到的坑。签名算法明明从JS里逆出来了本地测试也通过了但放到服务器上就频繁报签名过期。排查了半天最后发现问题出在时间同步上。服务器系统时间比标准时间慢了大概2分钟导致生成签名用的时间戳比服务端校验的时间窗口旧了。用NTP服务同步时间后问题立即消失。所以如果你遇到签名过期的问题第一件事不是检查算法而是看服务器时间准不准。本地开发环境通常没问题但云服务器ECS、Docker容器的基础镜像时间可能会有偏差。经验是跑类似任务前先执行date命令看一眼时间再用ntpdate同步一下能省掉很多无意义的排查。5.2 同一IP触发验证码不一定是频率太高可能是代理不干净有段时间我频繁收到验证码响应排查后发现不是并发太高而是代理池里有大量被其他用户滥用过、已经被平台标记的脏IP。这些IP本身质量就差哪怕你只请求一次也会被弹验证码。解决方法是给代理池增加“脏IP自动隔离”机制如果一个IP首次请求就被弹验证码立即标记并踢出而不是重试之后再发现。代理质量参差不齐是代理池方案里的常态。别迷信代理供应商的宣传自己的健康检查和脏IP隔离机制比什么都重要。5.3 SKU数据缺失不是接口问题是商品本身没有SKU测试用例覆盖再全也总有漏掉的边界情况。有次我针对SKU解析写了一套单元测试全通过了但跑真实商品数据时频繁报异常。排查后发现部分商品没有SKU维度接口返回的SKU字段是null我的解析代码按正常的嵌套结构去遍历直接报了属性错误。修复很简单加一个空值判断就行。但更值得记住的是教训写解析逻辑时要把“字段不存在”“字段为空”“字段类型不同”当作正常情况来处理而不是当作异常情况。商品数据是人工填写的千奇百怪的情况都会出现。5.4 商品详情页跳转导致解析错乱加了硬编码校验有个Low但很隐蔽的坑是部分商品ID对应的是已拼接链接、已下架商品或非同平台的商品接口返回的数据不是目标商品。光看HTTP 200并不能说明请求成功需要校验数据内层的关键字段。我在解析前加了一步轻量级校验检查返回JSON里的商品ID是否与请求参数一致标题是否为空价格是否大于0。三个条件任意一个不满足就判定为异常数据进入重试或跳过。这一步看似多余但在数据质量检测中能拦下不少“脏数据”。5.5 高并发下MySQL写入瓶颈从批量更新到分批写入采集系统稳定运行后新的性能瓶颈出现在数据库端。单条逐条写入商品数据的方式太慢了在日产10万条数据的场景下MySQL的写入速度成为瓶颈。我把写入逻辑改成了分批批量插入每次累积200条后执行一次INSERT配合ON DUPLICATE KEY UPDATE实现更新。INSERT INTO item_detail (item_id, title, price, stock, raw_data, updated_at) VALUES (%s, %s, %s, %s, %s, NOW()), (%s, %s, %s, %s, %s, NOW()) ON DUPLICATE KEY UPDATE title VALUES(title), price VALUES(price), stock VALUES(stock), raw_data VALUES(raw_data), updated_at NOW();这个改动看起来不起眼但写入性能提升了好几倍数据库负载明显下降。做采集系统的持久化层时千万记住别用ORM的逐条save模式批量SQL带来的性能提升能把数据库从生死线上拉回来。还有一个细节是数据库连接池参数。对采集场景连接池最小空闲连接数和最大连接数都要比普通业务系统设置得大一些因为采集写入是突发的连接池太小会拖慢批量写入速度。我的配置是线程池50个数据库连接池30个经测试配合得很好。6. 合规与工程化这个项目最容易被忽略的一块拼图聊技术聊到这里已经覆盖了精准解析和高可用架构的多数核心问题。作为一个做生产采集系统的开发者我觉得有责任把合规这件事也讲清楚。这不是说教而是从自己踩过的坑里总结出来的经验。关于采集的合规性首先要看平台的服务协议。义乌购作为B2B平台不同的接口和页面可能有不同的使用条款。在做采集之前务必确认这种行为是否被允许是否需要申请API权限。我的做法是优先寻找官方开放平台是否提供API如果官方有接口哪怕收费、哪怕频次有限也比较稳妥。只有在官方接口无法覆盖需求、且平台规则允许的情况下才考虑解析目标页面或接口。其次在一个法治社会做数据采集务必要注意个人信息保护和数据安全方面的合规要求。如果采集的数据涉及个人信息要格外克制。义乌购的商品详情数据主要是商家经营信息但仍要避免采集超出业务必要范围的字段比如商家的非公开联系方式。我自己在项目里做了一件合规性加固的事就是把采集范围限定在客户明确授权的商品类目和商品ID清单内不做全网全量抓取。同时所有采集数据都按约定用途使用不对外转售。这些看起来不是技术问题但在真实合作场景里往往比技术细节更能决定项目的生死。工程化方面我还有一个体会想分享。生产级采集系统的代码一定要有完善的日志记录。我习惯把每次请求的商品ID、耗时、状态码、异常信息、使用的代理IP、命中的缓存层级全部写入结构化日志。这样一旦出现数据异常可以倒查是请求问题、解析问题还是入库问题定位效率提升数倍。最后再分享一个我个人的实测体验。我在这个项目里最得意的一个设计是把接口解析、数据清洗和入库逻辑完全解耦成三个独立的Python模块中间用Queue传递数据。这个设计初期比“一条龙直写数据库”的方式多了不少代码量但后期维护时收益巨大。解析规则改了清洗模块和数据入库不用动加了新业务需要数据预处理直接在清洗模块后面接一个处理器就行数据库从MySQL换成PostgreSQL只动入库模块。模块之间的边界就是生产级代码和一次性脚本之间最本质的区别。
返回列表