9 月 14 日
为了这一天,我们已经等了好几周。早在 7 月,我们的住宅代理服务商就因为账单问题停摆了,此后两个月里,我们 1,375 个来源中越来越大的一部分只能在降级状态下抓取,甚至完全抓不到——请求超时、IP 在会话中途被封,那种不会表现为明显宕机,只会让数字悄悄缩水,直到某天难堪得没法忽视的慢性失血。我们续费了。新的代理池,20,000 条轮换住宅会话线路,每个出口节点在接入生产环境前都先做了探测。探测通过率 100%。阿联酋、德国、日本——全部在线,全部干净。
于是我们跑了一次有人值守的抓取——这次是真有人盯着仪表盘,而不是把一切交给 cron 信任。我预期的是和灾难相反的结果。
但我们得到的是:数据库在单次抓取中丢了 84,401 行,102 个来源的数据量比上一次快照跌了超过 25%。比我们刚刚修复的那两个月代理断供还要严重。
一个错误的理论,简述一下
我的第一反应——这个我得坦率承认——是新代理池可能比探测结果显示的更差。也许会话 IP 在抓取过程中被限速了,而一次性探测根本捕捉不到这种情况。这是个合理的猜测。但它也是错的,而相信它哪怕只是半天,也耽误了我们本就没有的时间,因为在你调试这种理论的整段时间里,抓取 cron 都得保持暂停——你不会想让一次糟糕的抓取结果覆盖掉一份好的快照,而自己还在瞎猜原因。
打破这个理论的线索是 dubizzle。这是我们在阿联酋的高流量来源之一,那次运行它实际抓到了 32,595 条房源——这个数字相当健康,说明代理显然在正常工作——然后却把其中几乎全部都丢弃了。不是抓取失败,而是抓到了,又扔掉了。
9 月 16 日:找到真正的 bug
从这一刻起,这件事就不再是代理的故事,而变成了"行覆盖"的故事。我们的持久化层在每一轮抓取中都会把每个来源的 <site>_cars_for_sale.csv 整表覆盖——只保留当前在售房源,整表替换。如果一次抓取顺利完成,这个逻辑是对的。但如果抓取在中途超时,而抓取器把它勉强收集到的那一小批数据当作完整快照写了进去,那就是灾难性的。一个通常保有 9,000 条在售房源的来源,会被它在时间耗尽前抓到的 1,200 条覆盖,剩下的 7,800 条就此消失——不是因为这些车卖掉了,而是因为我们自己的代码把一次不完整的抓取当成了完整的。
代理没有骗我们。问题出在重试逻辑上,而且它已经悄悄出问题很久了,只是被一个更健康的代理池掩盖了——以前,抓取往往能在重试逻辑真正起作用之前就完成。
等我们把账算清楚,这套重试逻辑实际造成的代价是这样的:
| 旧重试扫描 | 修复后的扫描 | |
|---|---|---|
| 每个代理的重试次数 | 3 | 1 |
| 每次尝试的延迟 | 1 秒 | — |
| 50 个代理池的有效上限 | 每个 URL 约 56 分钟 | 总预算 120 秒 |
| 超时时的行为 | 把部分批次当完整快照写入 | 跳过该来源,保留原有快照 |
放弃一个 URL 要花 56 分钟。把这个数字乘到一次覆盖上千个来源的抓取里,你得到的正好就是我们看到的那种无声、分布不均的失败——有些来源完全正常,102 个被掏空,日志里却没有任何东西在喊"代理断供了",因为严格来说,确实没有断供。
9 月 25 日:修复方案,以及对你真正重要的那部分
九天后上线的那个补丁做了两件不起眼的事。一是给重试逻辑的时间上限做了封顶,让一次卡住的抓取快速失败,而不是白白耗掉将近一个小时;二是——这才是真正阻止数据丢失的关键——一次不完整的抓取不再覆盖完整的行集合。如果一个来源只抓到了它平常 9,000 条房源里的 1,200 条,我们会保留之前那份完整快照,而不是用一个零头去覆盖它。同一天还上线了第二项改动:写入一个可见的 CRAWL_PAUSED 标记,我们的监控会在为任何数据下滑发警报之前先检查这个标记——这样,一次主动暂停就会被读作暂停,而不是警报。
为什么一个正在挑现代二手车的读者会关心这些?因为我们发布的低于市场价标记——截至本周,网站上实时显示着 12,823 个——只有在拿来对比的同类群体是真实、完整的区域市场数据,而不是被超时截断的一个碎片时,才是诚实的。一个建立在丢失了四分之三对比数据的快照之上的"好价"标签,根本不是好价标签,只是戴着绿色标牌的噪音。我们宁愿把抓取暂停九天,也不愿发布那样的结果。
如今这个数据集覆盖 73 个地区、1,375 个来源,共有 1,108,535 条房源——比这一切开始之前还要大,代理已经更新,行覆盖的 bug 也已修复。但我反复想起的数字不是这个总数,而是 84,401——那是一次在代理层面看起来健康的抓取,和一次真正健康的抓取之间的差距。



