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 占用上来了。我们的做法是解压并行度随集群负载浮动,机器忙时自动降档。存的时候图省,取的时候就要付出代价,这点权衡值得在接入压缩之前先想清楚。

延伸阅读