1.1 需求拆解:要采什么、采多快

场景:周五下午甩过来的表格

业务同学丢过来一份表格:二十三个行业站点,每个站点若干列表页,要求每天把新发布的公开页面采回来,字段就四项——标题、正文、发布时间、来源链接。听起来是个小活,但他补了一句:下周起站点数量要翻三倍,下半年还要接更多行业。

我们当时的采集能力是一台机器上的定时脚本,crontab 每晚跑一轮。问题不在脚本写得不对,而在没人说得清它跑没跑完:没有任务状态、没有失败告警、重跑一次就重复入库。需求压到面前时,我们先停下了键盘——不把需求拆成可量化的指标,写出来的代码一定返工。

开会的时候还有一个小插曲。我们问业务:如果凌晨窗口跑挂了,你们能接受多久之内修好?对方愣了一下说,只要早上九点前数据在看板上,没人关心你们几点跑。这句话后来成了我们整个调度策略的基石:把采集当成离线批处理来做,而不是准实时系统,架构复杂度瞬间降了一个量级。这本书里几乎所有关于凌晨窗口、优先级、断点续爬的设计,都是在给这句话做注脚。

采集需求最怕笼统。我们拉着业务侧开了两个小时会,只问三件事:数据给谁看、能容忍多大延迟、历史数据要不要。这三个答案定下来,架构复杂度的量级就定下来了。这本书里所有看起来过度设计的地方,基本都是被这三个问题的答案逼出来的。

方案:把模糊需求翻译成指标表

我们把需求拆成五项可测量指标,每项都对应一个具体的架构决定。这张表后来贴在墙上,每次加站点都先改表再动代码:

需求维度 我们填的值 对应的架构决定
采集对象 23 个行业站、约 1200 个列表页 按站点分片,worker 按站点维度分工
时效要求 次日 9 点前出齐即可 凌晨窗口集中跑,白天留给回补任务
页面规模 日均新增约 3 万页、总量约 500 万页 必须增量抓取,全量只用于首次铺底
单页字段 标题、正文、发布时间、来源链接 清洗管道四个阶段足够,不搞万能 DSL
数据用途 内部看板加对外数据接口 结果落 MySQL,原始快照留 COS 备查

这张表最大的作用是挡住伪需求。有人提出要实时秒级采集,我们指着时效那一行说当前是次日级;真要实时就得另起一套流式架构,机器和人力成本都要翻几倍,这个账在 5.3 节的成本复盘里细算。还有人问能不能顺带采图片视频,单页字段一行写死了只采文本,多媒体需求单独立项,不让主链路变重。

体量估算有个粗略公式:站点数乘列表页数乘翻页深度乘日均更新率。我们代入 23 站、1200 个列表页、翻页 10 层、日均更新率百分之八,算出来每天新增约三万页,和后来实际线上跑出来的数字误差在一个百分点以内。比拍脑袋准得多,也比直接拿历史日志外推快——首次铺底之前根本没有历史日志。具体拆开是这样:

估算项 估算方式 我们的取值 估算结果
日均新增页数 站点 × 列表页 × 翻页深度 × 更新率 23 × 1200 × 10 × 8% 约 2.8 万页
单日下载量 新增页数 × 页均大小 2.8 万 × 80KB 约 2.2 GB
首次铺底总量 站点数 × 每站历史存量 23 × 约 22 万页 约 500 万页
快照年增长 日新增 × 365 × 压缩后页均 2.8 万 × 365 × 8KB 约 80 GB

最后两行是后来加的。最初我们只估了下载量没估存储量,导致 COS 的容量规划晚了半个月才提上日程——快照存 COS 的决策见 2.4 节。这个表格的意义在于:所有架构决定都能往前追溯到某个数字,而不是我觉着行。

增量抓取是另一个需要提前想清楚的点。列表页每天都有新内容,但绝大多数详情页不变。如果每天把 1200 个列表页翻到底再逐页抓,等于每天都在半全量。我们的做法是以列表页为增量入口,详情页按指纹去重,只采列表里新出现的条目。去重的具体实现是 2.2 节的内容,这里只需要知道:增量策略必须在需求拆解阶段就定下来,它是后面所有队列与状态设计的前提。

配置与代码:先把任务定义写下来

指标定稿后,我们最先落地的不是爬虫本体,而是一份任务描述结构。后来整个平台的调度、分片、断点续爬都建立在这个结构上:

type TaskSpec struct {
    SiteID    string `json:"site_id"`
    EntryURL  string `json:"entry_url"`
    ListRule  string `json:"list_rule"`
    DetailRule string `json:"detail_rule"`
    Depth     int    `json:"depth"`
    IntervalH int    `json:"interval_h"`
    Priority  int    `json:"priority"`
    UserAgent string `json:"user_agent"`
    Enable    bool   `json:"enable"`
}

其中三个字段是后来加的。Priority 用来给热点站点插队,UserAgent 用来应对 5.2 节讲的封禁,Enable 是给运维留的应急开关——某个站突然抽风时不用改代码重启,直接把任务停掉。这说明需求拆解不可能一次到位,但先把主结构立住,后面加字段的成本就低得多。

优先级我们分三档:P0 是客户点名要的站点,P1 是常规更新,P2 是历史回补。master 按档位分发任务,worker 本地队列里 P0 永远排最前。这套分发机制在 3.1 节展开,这里只强调一点:优先级不是调度器自己的事,是需求阶段就要确认的业务语义。

另外把次日九点这个交付时间也写进了任务结构体之外的地方——一个全局的截止检查器。每天九点前控制台会把未达标任务标红,相关人员手机收到提醒。这个简单的功能让扯皮从每天都发生变成一个月一次。

踩坑:三个没问出口的问题

第一个坑是翻页深度。业务说每个列表页看第一页就行,实际上有的站点新内容藏在翻页深处,我们按一页采,上线第二周漏了将近三成数据,又回头补翻页逻辑。教训:深度参数必须允许按站点覆盖,全局默认值只能兜底。

第二个坑是动态加载。有个站的列表用接口异步渲染,我们按静态 HTML 解析,采到的列表项永远是缓存里那批。上线当天看数据量正常,第二天才发现更新时间全是历史时间。后来加了抓包确认接口地址的前置流程,遇到量异常先怀疑渲染方式,这套排查清单在 5.2 节。

第三个坑是缓存。开发环境每次都请求新页面,测试很顺;上线后发现部分页面走了站点侧 CDN 缓存,采回来的是几小时前的旧内容,发布时间失真。我们的对策是发布时间一律以页面正文解析为准,不信 HTTP 响应头里的时间,同时把响应头的 Last-Modified 记进日志,方便对账。

第四个坑是业务自己也没想清楚。有一次对方说要加六个站点,协议签下来才发现其中两个需要登录才能看列表,等于需求范围直接变了。从那以后加站点必须先过一个小检查单:是否公开可访问、是否有反爬、字段是否在现有规则覆盖范围内、更新频率多少。过不了检查单的站点先进排队,不进开发计划。这个小检查单在 5.4 节的合规护栏里也有。

后来我们把检查单做成了一个小工具:填一个站点地址,自动跑 robots 检查、抓几页样本、预估更新频率、输出一份评估报告。工具虽然粗糙,但让加站点这件事从拍脑袋变成了流程,业务侧也服气。

口径:怎么算采到了

需求拆解还有一个容易漏的产出:验收口径。如果没有明确口径,出了问题双方各执一词——业务说数据少了,我们说脚本跑完了。所以我们把验收标准也写进了指标表的附注:

验收项 判定标准 差了怎么办
完整率 列表条目数与详情页入库数误差小于 2% 先看快照,再决定补采或改规则
及时率 次日 9 点前完成率大于 99% 查凌晨窗口是否被其他任务挤占
准确率 抽样五十条人工核对,字段错误小于 1% 触发清洗规则回溯
去重率 重复入库条目占比小于 0.1% 查指纹库是否漏判

口径定好了,后面的监控告警才有阈值可配——5.1 节的告警规则几乎照搬了这张表。这也是为什么我们坚持把验收口径放在需求阶段而不是上线之后:上线后再补口径,历史数据没有基线可比。

小结

需求拆解的产出不是一份文档,而是一张指标表加一份任务结构体:表挡伪需求,结构体定主骨架。没有这两样,后面写多少代码都是在给模糊需求还债。指标确定之后,下一步是算方案的账:自研、用现成框架还是直接买托管服务,这正是 1.2 节的内容;机器要花多少钱在 1.3 节。如果你对整体技术地图更感兴趣,也可以先跳到 1.4 节。

最后送一句给同样在小团队里做采集的人:别急着写第一行抓取代码。我们花在拆需求上的两个小时,是整个项目里回报最高的两个小时。

延伸阅读