2.4 原始快照存 COS:留证据与重放
场景:规则升级之后,数据回不来了
平台跑了三个月,业务侧提了个需求:正文里的一段附加信息以前没采,现在想要,历史数据能补吗。我们第一反应是重新抓——五百个页面重新请求一遍,半天就跑完。但量级换算之后傻眼了:历史详情页有几百万条,重抓等于对目标站再来一次铺底,时间、带宽、被封风险全部翻倍。
那一刻我们意识到,原始 HTML 本身是被丢弃的最有价值的资产。如果当时把每一页的原始 HTML 存下来,规则升级后只要从存档回放即可,一个请求都不用发。于是当天我们就开始把快照写进对象存储 COS。文档入口是 cloud.tencent.com/document/product/436。
补充一点背景:当时团队里对这个决定有过犹豫。多存一份原始页面意味着每月多一笔存储费,而且短期看不见回报——它不像新功能那样有可展示的成果。支持的理由只有一个:重抓历史的沉默成本我们已经在第一个需求里真切感受过一次了。事后证明这份保险买得极划算。
快照还有第二个动机:证据。业务对某些字段有争议时,原始页面是最硬的证据,谁也不想靠内存里的记忆对质。第三个动机是回滚:清洗规则改崩了?从快照重跑一遍就好,源数据一个字节没动。
后来回过头看,快照体系是我们做过的投资回报比最高的决定。规则升级、字段补录、争议对证、事故复盘,四个场景全部受益,而它的成本只有存储费。5.3 节的成本表里快照那一行不到四十元,比机器便宜得多。
方案:存什么、怎么存、存多久
快照设计先回答三个问题。存什么:只存详情页 HTML,列表页只存解析出的条目列表(JSON),因为列表页本身不携带业务数据。怎么存:按站点分桶,路径里带日期,对象键带 URL 指纹。存多久:热数据标准存储留三十天,之后转低频存储,一年后转归档。
gzip 压缩是另一个值得一开始就做的决定。HTML 文本压缩率高,八十 GB 的估算压缩后实际只占十几 GB,存储费直接降了一个量级。代价只是上传时多一步压缩,CPU 开销可以忽略。
| 存储层级 | 对象 | 存储类型 | 单价量级 | 用途 |
|---|---|---|---|---|
| 标准 | 近三十天详情页 HTML | 标准 | 基准价 | 日常回放与对证 |
| 低频 | 三十天到一年 | 低频 | 约标准的一半 | 规则升级批量重跑 |
| 归档 | 一年以上 | 归档 | 约标准的十分之一 | 合规留档 |
| 列表摘要 | 所有列表页条目 JSON | 标准 | 体积极小 | 条目数对账 |
| 列表摘要 | 所有列表页条目 JSON | 标准 | 体积极小 | 条目数对账 |
这个分层在 1.1 节快照年增长八十 GB 的估算下是必须的,全量标准存储的成本会随时间线性涨,归档则几乎不痛。云数据库 MySQL 存结果、COS 存原文,这个分工从单机阶段一直延续到分布式,没再变过。
分层转冷的动作我们也自动化了:每天凌晨跑一次生命周期任务,把三十天前的对象转低频、一年前的转归档。对象存储本身的存储类型转换是有 API 的,不用手动迁移。第一次配置时没敢全量执行,先对一个站点试跑了一周,确认检索性能没受影响才铺开。这种小范围验证再全量的节奏,后来成了我们所有变更的标准流程。
配置与代码:上传与寻址
对象键的命名规则是整套设计的核心,它决定了回放时能否凭结果表直接定位:
func snapKey(siteID, urlFp string, t time.Time) string {
return fmt.Sprintf("snap/%s/%s/%s.html", siteID, t.Format("2006/01/02"), urlFp)
}
这个路径结构同时是权限边界:不同站点的快照落在不同前缀下,授权和清理都可以按站点粒度做。日期层级让按天排查和按月统计成为可能——grep 都不需要,列表就能数出来。
上传用官方 SDK 的分块上传,HTML 先 gzip 压缩,体积缩到原来的两成:
func uploadSnap(ctx context.Context, cli *cos.Client, key string, body []byte) error {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(body); err != nil {
return err
}
gz.Close()
_, err := cli.Object.Put(ctx, key, &buf, &cos.ObjectPutOptions{
ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
ContentType: "text/html",
ContentEncoding: "gzip",
},
})
return err
}
结果表里加一列 snap_key 存对象键,回放时按主键查到对象键直接从 COS 拉,整条链路闭环。下载时注意 ContentEncoding 头,SDK 会自动解压,但手动拼请求时容易忘。
回放脚本本身也值得一说。它的输入是一批 url_fp,输出是重新清洗后的结果,全程不碰网络。第一次用它补字段时,三百万条历史数据一晚上跑完,如果走重新抓取的路子,至少一周还得担惊受怕被封。那天我们开玩笑说这个脚本值一台机器的钱——其实它就值几十行代码。
有了快照之后还有一个意外收获:新规则的开发效率翻倍。以前改清洗规则要先跑一小批真实下载验证,现在从快照池里抽样一百条本地跑,秒级反馈。规则 DSL(4.1 节)之所以敢做得比较复杂,就是因为验证成本足够低。
踩坑:快照体系的四个漏洞
第一个漏洞是存了才发现没存时间戳信息。对象内容只有 HTML,不知道什么时候抓的,只能从对象元数据里读,跨工具对账很难受。后来约定每一个快照对象都必须带自定义元数据 x-cos-meta-crawled-at,写入时顺手完成。
第二个漏洞是同一个 URL 的覆盖问题。起初对象键只按 URL 指拼,同 URL 第二次抓会覆盖第一次。改成指纹加日期之后,同一天内重复抓仍覆盖。最终方案是指纹加日期加抓取序号,保证每一次抓取都留一份。
第三个漏洞是上传失败静默丢。有一阵网络抖动导致部分 PutObject 失败,结果表 snap_key 写了但 COS 里没有,回放时才暴露。改成事务一致性:先上传成功再写 snap_key,且每晚对账一次抽查 snap_key 的存在性,这个对账脚本在 5.1 节的健康检查里。
第四个漏洞是带宽。白天大量上传快照会和采集本身抢带宽,凌晨集中任务时尤为明显。最后把快照上传改成异步:抓到页面先写本地磁盘缓冲目录,慢一点再批量上传,磁盘缓冲目录就是 1.3 节里那次写满系统盘事故的来源——你看,所有的坑都是连着的。
第五个漏洞是删快照。有一次清理无效站点时误删了一个仍在合作中的站点的整桶快照,虽然快照原则上不该删,但当时没有防误删机制。我们学到的教训是对象存储的桶级权限要分级:日常只有上传和读权限,清理脚本用独立子账号且只授予特定前缀的删除权限。云上操作一念之差就是不可逆的,权限分级是唯一的护栏。
顺带分享一下快照命名里一个看似不起眼但很关键的决定:对象键里不带 URL 明文只带指纹。一是键名更短省存储元数据开销,二是 URL 里偶尔会有敏感参数,落在对象键里等于二次传播。快照键对外的可读性差一点没关系,反正寻址永远走数据库里的 snap_key 列。
小结
快照体系的核心价值不在存储,在于让数据可回放、可对证、可回滚。路径命名规则、分层存储、上传一致性,这三件事在单机阶段花两天设计好,后面省的远不止两天。
如果你只能记住一件事,那就是这个:每次抓到的原始 HTML 都值得多花几毫秒存一份。它是最便宜的保险,也是最灵活的实验素材。后来有一次业务方对某批清洗结果统计口径有异议,我们直接回放快照重新清洗,十分钟给出了结论——这份底气就是快照给的。到此单机原型的四个环节全部跑通:抓取、去重、落库、快照。下一篇进入分布式改造,先看 master 与 worker 的任务分发设计,继续看 3.1 节。
快照存储还有一个具体的省事技巧值得记。最早快照按站点 URL 路径分文件存,目录层级跟着站点结构走,结果某天站点改了 URL 结构,一批旧快照翻了半天才找到。后来统一改成指纹加日期的扁平化键名,与站点结构解耦,之后再没出现过找不到快照的事。键名设计的原则是只绑定不变量——这个道理和 3.4 节日志主题按服务而不是按机器拆分完全一样,都是拿不变化的维度做主键。快照的压缩我们选了 zstd 级别,HTML 文本压缩比能到四比一,存储费又省下一小截——慢的是 CPU,省的是账单。压缩还有个副作用要注意:快照回放时多一步解压,批量重放一千个快照会明显感觉到 CPU 占用上来了。我们的做法是解压并行度随集群负载浮动,机器忙时自动降档。存的时候图省,取的时候就要付出代价,这点权衡值得在接入压缩之前先想清楚。
