
电商数据采集这件事做过的人都清楚最难的从来不是把网页抓下来这第一步而是让整条管道在目标站点的各种反制策略下持续、稳定、低噪音地跑下去。我前后在三个不同规模的电商数据项目里负责过采集链路的搭建从最初用单文件脚本硬怼到后来拆成模块化的采集管道中间踩的坑足够写一本小册子。这次想聊的是我在最近一个项目里围绕请求模块和会话保持这两个核心环节做的一整套工程化实践——它不是什么高深的技术但每一个细节都直接决定了你的采集管道是跑三天就崩还是能安安稳稳跑上几个月。先把话说在前面这篇文章讨论的所有内容都建立在合法合规采集公开数据的前提下。电商平台的服务条款、robots协议、数据使用边界这些是动手之前就必须搞清楚的事。我下面讲的所有技术手段目的都是降低对目标站点的无效请求压力、提高自身采集效率而不是去对抗什么。这个前提想明白了后面的内容才有意义。1. 为什么单文件脚本撑不过第一周1.1 从能跑到能持续跑之间的鸿沟我见过太多人写采集脚本的方式打开编辑器写一个requests.get解析页面存数据跑通了收工。第一天一切正常第二天开始偶尔报错第三天大量超时第四天直接被限流封IP。然后就开始到处找为什么被封的答案却很少有人回头想一个问题——你的脚本从一开始就不是为持续运行设计的。单文件脚本的问题不在于代码写得烂而在于它缺少工程意义上的分层。请求发起、会话管理、错误处理、重试策略、频率控制、数据落库这些东西全部揉在一个函数里任何一个环节出问题都会导致整个流程崩溃而且你很难定位到底是哪一层出了毛病。更关键的是单文件脚本通常没有状态管理——它不知道上一次请求是什么时候发的不知道当前会话是否还有效不知道某个商品页是不是已经抓过了。这种无记忆的运行方式在目标站点看来就是一台毫无规律的请求机器被识别和限制只是时间问题。1.2 采集管道的分层思路我后来把整条链路拆成了四个相对独立的层每一层只关心自己的事层级职责关键设计点请求层发起HTTP请求、处理响应会话复用、超时控制、请求头管理调度层决定什么时候发下一个请求频率控制、任务队列、优先级解析层从响应中提取结构化数据容错解析、字段校验、异常标记存储层数据落库与去重幂等写入、增量更新、失败重试这么拆的好处是当采集出问题时你可以快速判断是请求层被限了、调度层节奏乱了、还是解析层遇到页面改版了。每一层都可以单独测试、单独优化、单独替换而不会牵一发动全身。这篇文章的重点会放在请求层和调度层的交界处——也就是会话保持和请求节奏控制因为这两个地方是绝大多数采集管道猝死的直接原因。2. 请求模块的会话保持到底在保持什么2.1 会话保持不是加个Cookie那么简单很多人对会话保持的理解停留在把登录后的Cookie带上。但在电商采集场景里会话保持要复杂得多。一个完整的会话状态至少包含这几样东西Cookie包括服务端下发的会话标识和各类追踪标识、连接复用TCP层面的keep-alive、请求头的一致性User-Agent、Accept-Language等不能这次一个样下次另一个样、以及请求之间的时间间隔特征。我举个实际例子。早期我用requests.Session()做会话保持以为只要复用Session对象就万事大吉。结果发现跑了一段时间后请求成功率开始下降。排查后发现两个问题一是Session默认的连接池大小有限并发一高就开始排队甚至丢弃连接二是Session里的Cookie会随着服务端下发不断累积某些追踪Cookie过期后没有及时清理导致请求头越来越臃肿反而增加了被识别的概率。2.2 连接池与会话对象的正确配置requests的Session底层用的是urllib3的连接池默认pool_connections是10pool_maxsize也是10。这个默认值在低并发场景够用但如果你要同时维持几十个会话就必须显式调整。我的做法是根据并发规模来定import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(pool_size20, retry_times3): session requests.Session() retry Retry( totalretry_times, backoff_factor0.5, status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, HEAD] ) adapter HTTPAdapter( pool_connectionspool_size, pool_maxsizepool_size, max_retriesretry ) session.mount(http://, adapter) session.mount(https://, adapter) return session这里有几个参数值得展开说。backoff_factor0.5意味着重试间隔会按0.5、1、1.5秒这样递增给目标站点留出喘息空间也避免自己在短时间内反复冲击。status_forcelist里我特意加了429这是请求过多的标准状态码遇到它必须退让而不是硬刚。allowed_methods只允许GET和HEAD重试因为POST重试可能造成重复提交这个坑我在另一个项目里踩过代价是数据库里多了一批重复订单记录。2.3 Cookie的生命周期管理Cookie管理是会话保持里最容易被忽视的一环。我的经验是不要无脑保留所有Cookie而是要有选择地维护。具体做法是给Session挂一个响应钩子每次收到响应后检查Set-Cookie对关键会话Cookie做更新对追踪类Cookie做定期清理。def prune_cookies(session, keep_keys, max_age3600): now time.time() for cookie in list(session.cookies): if cookie.name in keep_keys: continue if cookie.expires and cookie.expires now: session.cookies.clear(cookie.domain, cookie.path, cookie.name)keep_keys里放的是真正维持会话所必需的字段其余的一律按过期时间清理。这个操作看起来不起眼但实测下来能让单个会话的稳定运行时长提升不少因为请求头不会随着时间推移越来越臃肿。提示Cookie的清理策略要根据目标站点的实际行为来定。有些站点的会话标识藏在非标准字段里清理前一定要先抓包确认哪些字段是真正必需的别把关键会话Cookie误删了。3. 请求节奏控制让管道跑得久的核心3.1 固定间隔为什么是错的新手最常用的频率控制是time.sleep(1)每个请求之间固定睡一秒。这个做法的问题在于它假设目标站点的负载和你的请求节奏是无关的但现实恰恰相反。固定间隔在目标站点看来是一种极其规律的信号——每秒一次精确得像机器而正常用户的访问节奏是随机的、有停顿的、有突发的。我做过一个对比测试同样抓1000个商品页固定1秒间隔的方案在跑到大约600个请求时开始出现明显的响应变慢和偶发429而改成随机间隔在0.8到2.5秒之间波动后1000个请求全部完成没有触发限流。这个差异不是偶然的随机化让请求模式更接近真实用户行为。3.2 自适应限速的实现思路比随机间隔更进一步的是自适应限速——根据目标站点的响应状态动态调整请求频率。核心逻辑很简单响应正常就维持或略微加快遇到429或响应时间明显变长就主动降速。class AdaptiveRateLimiter: def __init__(self, base_interval1.0, min_interval0.5, max_interval10.0): self.base base_interval self.min min_interval self.max max_interval self.current base_interval self.last_request 0 def wait(self): elapsed time.time() - self.last_request if elapsed self.current: time.sleep(self.current - elapsed) self.last_request time.time() def on_success(self): self.current max(self.min, self.current * 0.95) def on_throttle(self): self.current min(self.max, self.current * 2.0)这个类的用法是每次请求前调wait()请求成功后调on_success()让间隔缓慢收缩遇到限流信号调on_throttle()让间隔快速翻倍。收缩慢、扩张快这个不对称设计是关键——降速要果断提速要谨慎这样才能在稳定和效率之间找到平衡点。3.3 并发与串行的取舍很多人一上来就想上高并发觉得并发越高采集越快。但在电商采集场景里并发是一把双刃剑。并发确实能提高吞吐但也会让你的请求特征更明显而且一旦某个会话出问题并发下的错误传播会更快。我的建议是先串行跑通再逐步加并发。具体做法是先用单会话串行采集观察目标站点的响应特征确定一个安全的请求频率然后在这个频率基础上用多个独立会话做并发每个会话维护自己的节奏。这样即使某个会话被限其他会话不受影响整体管道不会崩。并发策略适用场景风险单会话串行目标站点限制严格、数据量小速度慢但最稳多会话并发数据量大、站点容忍度较高需要精细的会话隔离分布式采集超大规模、多节点调度复杂成本高4. 采集管道里那些看起来没事其实要命的细节4.1 超时设置不设超时等于埋雷requests默认没有超时这意味着如果目标站点不响应你的请求会一直挂着直到操作系统层面的TCP超时可能是几分钟甚至更久。在采集管道里一个挂死的请求会占住一个连接连接池很快就被耗尽整条管道就卡死了。我的做法是给每个请求设置分层的超时连接超时短一些比如5秒读取超时长一些比如15秒。连接超时短是因为如果连TCP握手都完不成说明网络或目标站点有问题没必要等读取超时长是因为有些页面确实需要更长的传输时间。response session.get(url, timeout(5, 15))这个元组的第一个值是连接超时第二个是读取超时。别小看这个设置我在一个项目里就是因为漏了超时导致管道在某个深夜卡死第二天早上才发现白白浪费了八个小时的采集窗口。4.2 请求头的人味请求头是目标站点识别请求来源的重要依据。默认的requests请求头里User-Agent是python-requests/x.x.x这等于直接告诉对方我是脚本。所以必须替换成真实的浏览器UA而且要注意UA和其他请求头的一致性。我通常会维护一组请求头模板每个会话固定使用其中一套不要这次用Chrome的UA下次用Firefox的那样反而不自然。一套完整的请求头至少包括headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, }注意Accept-Encoding里带上brBrotli因为现代浏览器都支持不带反而不正常。requests本身不自动解压br需要额外装brotli库这个细节很多人会漏。4.3 错误分类处理不是所有错误都值得重试采集过程中会遇到各种各样的错误但它们的性质完全不同。我把错误分成三类可重试错误超时、连接重置、5xx服务端错误、429限流。这些是暂时性的退避后重试通常能成功。不可重试错误404页面不存在、403禁止访问、401未授权。这些重试多少次都没用应该直接标记并跳过。需要人工介入的错误页面结构变化导致的解析失败、验证码拦截。这些不是代码能自动解决的需要报警通知。把错误分类处理的好处是重试逻辑不会浪费在注定失败的请求上同时真正需要关注的问题能及时暴露出来。我在管道里加了一个错误计数器当某类错误在短时间内超过阈值时自动暂停采集并发出通知避免在目标站点已经明确拒绝的情况下继续硬冲。5. 从采集到落库管道末端的稳定性设计5.1 幂等写入重复采集不可怕重复入库才可怕采集管道在重试机制下同一个商品页被采集两次是很正常的事。如果存储层没有做幂等处理数据库里就会出现重复记录。我的做法是给每条数据加一个基于业务字段的唯一标识比如商品ID加采集日期写入时用upsert而不是insert。INSERT INTO products (product_id, title, price, updated_at) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), price VALUES(price), updated_at VALUES(updated_at);这样即使同一个商品被采集多次数据库里也只有一条记录且保留的是最新数据。这个设计让采集管道可以放心地重试不用担心数据污染。5.2 断点续采管道重启后不用从头再来采集管道难免会因为各种原因中断——网络故障、机器重启、代码更新。如果没有断点续采能力每次中断都要从头开始效率极低。实现断点续采的核心是维护一个已完成任务的记录管道启动时先读取这个记录跳过已完成的部分。我通常用Redis的Set来存已完成的URL哈希每完成一个请求就SADD进去。管道重启时先用SMEMBERS拿到已完成集合然后在任务队列里过滤掉这些URL。这个方案的好处是读写快、支持去重、可以设置过期时间自动清理历史记录。注意断点续采的记录要和实际落库状态保持一致。我遇到过一次因为落库失败但断点记录已写入导致那批数据永久丢失的情况。后来改成先落库成功、再写断点记录虽然多了一步但数据一致性有了保障。6. 实测中的几个反直觉发现6.1 慢一点反而更快这个结论听起来矛盾但实测数据支持它。在一个抓取5万个商品页的项目里我把请求间隔从0.5秒放宽到1.5秒总耗时反而从预计的7小时缩短到了5.5小时。原因是间隔太短时大量请求触发限流进入重试队列重试的退避等待加上失败请求的浪费总时间反而更长。放宽间隔后首次请求成功率大幅提升重试少了整体效率就上来了。6.2 会话不是越多越好我一度以为多开几个会话就能线性提升吞吐结果发现会话数超过某个阈值后成功率开始下降。原因是每个会话都需要独立的连接和Cookie维护会话太多时单个会话的请求间隔被拉长反而失去了活跃会话的特征更容易被识别为异常。后来我把会话数控制在8到12个之间配合自适应限速整体表现最稳定。6.3 日志比你想的更重要采集管道的日志不是用来记录发生了什么的而是用来发现将要发生什么的。我在管道里记录了每个请求的响应时间、状态码、重试次数然后用简单的滑动窗口统计来监控这些指标。当平均响应时间开始上升、或429出现频率增加时说明目标站点正在收紧限制这时候主动降速比等到被封再处理要明智得多。class MetricsCollector: def __init__(self, window100): self.window window self.latencies deque(maxlenwindow) self.throttle_count 0 def record(self, latency, status_code): self.latencies.append(latency) if status_code 429: self.throttle_count 1 def avg_latency(self): return sum(self.latencies) / len(self.latencies) if self.latencies else 0 def should_slow_down(self): return self.avg_latency() 3.0 or self.throttle_count 5这个监控逻辑很简单但它在实际项目里帮我提前发现了好几次目标站点的策略调整让我能在管道被限之前主动调整节奏。7. 关于合规与边界的一点个人体会技术手段再高明也不能越过合规的底线。我在每个采集项目开始前都会做三件事确认目标数据是公开可访问的、阅读并遵守目标站点的robots协议和服务条款、控制采集频率不对目标站点造成实质性负担。这三件事不是走形式而是让项目能长期做下去的前提。采集频率的控制不只是技术问题也是态度问题。我始终认为一个负责任的采集管道应该是轻手轻脚的——它获取自己需要的数据但不给目标站点添麻烦。自适应限速、错误退避、会话管理这些技术手段本质上都是在实现这个目标。当你把不给对方添麻烦作为设计原则时很多技术决策会变得清晰该慢就慢该退就退该停就停。这套管道我在最近的项目里跑了将近四个月中间只因为目标站点页面改版调整过一次解析逻辑请求层和调度层几乎没有出过问题。回过头看真正让管道稳定的不是什么黑科技而是把每一个细节都想清楚、做到位——超时设了、重试分了类、会话管了生命周期、频率做了自适应、错误有了监控。这些东西单拎出来都很普通但组合在一起就是一条能持续跑的采集管道。