七千份工具快照复制进冷存储,一个字节都没变
工具快照(tool snapshot)是探测器在某一时刻存下的一个 server 的工具列表:每个工具的名称、描述和输入 schema,加上由它们算出的指纹。2026-10-08,我们的数据库里有 7,111 份快照,合计 116,621 条工具记录。存这些记录的表有 271 MB,在 853 MB 的数据库里排第二。
从 2026-10-07 18:37Z 到 2026-10-08 12:37Z,我们把其中 7,102 份从 Postgres 复制到了对象存储,每份快照一个 gzip 对象。这篇讲我们怎么确认副本完全一致,以及哪些还没有复制。
为什么要复制出去
我们现在监测 2,980 个 endpoint,目录还在增长。页面读的是每个 server 当前的工具列表。旧的列表是历史:有用,但很少有人读。留在数据库里,这段历史会随着每一个新加入的 server 一起变大。
复制不等于搬走。复制没有删掉任何东西:7,111 行快照全都还在数据库里。删除旧行是另一个更晚的步骤,还没有运行过,而它的前提是我们能证明副本无误。
一份副本长什么样
每份快照变成一个对象,以快照 id 命名:cold/v1/snap/<snapshot id>.json。里面是一个 JSON 文档,有五个字段:schema 名(cold/v1)、编码(gzip)、产生这份快照的 run(11 列)、快照这一行本身(9 列),以及全部工具记录(每条 10 列,按 id 排序)。字节在上传前先 gzip。对象的 key 只能由小写 UUID 拼成,其他任何形式在发出请求之前就会被拒绝。
上传之前,写入程序先对 JSON 字节算 SHA-256。上传之后,它向存储查询对象大小,和自己发出的字节数比对。两步都对上,才把三个数写回快照那一行:cold_bytes(存储里的大小)、cold_raw_bytes(gzip 之前的大小)和 cold_sha256(哈希)。这次更新只在该行还没有记录副本时生效,所以后来的写入者永远覆盖不了先到的那个。
7,102 份副本里,192,740,260 字节的 JSON 存进去后是 48,749,733 字节,压缩比 3.95 比 1。快照大小的中位数是 8.6 KB 的 JSON,存储后 2.6 KB。
四种核对
经不起往返的数字。 JavaScript 把 JSON 里的每个数字都读成双精度浮点数。1.50 这样的文本会变回 1.5,有 18 位有效数字的数会丢掉几位,-0 写回去会变成 0。写第一份副本之前,我们检查了所有已存输入 schema 里的每一个数字,共 233,554 个:小数末尾带 0 的 0 个,有效数字 18 位及以上的 0 个,负零 0 个。有效数字 16 或 17 位的有 24,357 个,但只有 10 种不同的值,都已经是 JavaScript 写出来的最短形式。我们还定下:副本一律按值比较,不按文本比较,因为 1e21 及以上的数字会以指数形式回来,值相同,字符不同。
写入时的哈希。 那一行里存着 JSON 原始字节的 SHA-256。之后任何人读到这个对象,解压、算哈希,就知道它是不是我们写进去的那一个。
每小时一行证明。 复制任务每小时跑一次,在第 37 分钟。复制任何东西之前,它先随机挑一份已归档的快照,用读取冷数据的同一段代码读出它的对象,核三件事:存储里的前两个字节是 1f 8b,也就是 gzip 的标志;解压时不超过 1 MiB 的上限;解压结果的 SHA-256 等于 cold_sha256。它对一个已归档的 run 对象(下文会讲到)做同样的检查,然后打出一行。从 2026-10-07 19:37Z(第一个有快照对象可抽的轮次)到次日 12:37Z,18 轮全部两项都是 ok。
手工读回三份。 我们用存储的命令行工具下载了三个对象,在本地算哈希。三个哈希都等于 cold_sha256,三个字节数都等于 cold_raw_bytes。然后把里面的内容和数据库里的行按值比对:run、快照、每一条工具记录。三份全部一致。
跑了多快
在每小时的那一轮里,复制排在最后。它先取最旧的快照,跳过不满 15 分钟的,每轮最多 500 份。和这一轮的其他工作一样,过了 10 分钟就不再开始新的工作。
从 2026-10-07 18:37Z 起的前四轮,分别复制了 312、319、314、312 份。每一轮都撞上了 10 分钟的限制,单次上传的中位数约 0.8 秒:每小时约 310 份,不是计划中的 500 份。当晚探测器更新之后,单次上传的中位数在 0.16 到 0.36 秒之间,每轮不到 7 分钟就到了 500 份的上限。到 2026-10-08 09:37Z 那一轮,积压只剩下文说的三份,之后的轮次只需要复制新快照。19 轮一共写了 7,102 份副本。
有一次上传失败,在 03:40Z,存储服务返回了错误。那一轮计了数、记了日志,那一行没有写 key,后来的一轮复制了这份快照。
还没有复制的
一份快照太大。 它的 JSON 有 1,841,224 字节,超过我们自己的读取程序接受的 1 MiB。我们从不写读取程序会拒绝的对象,所以这一份留在数据库里,每一轮都会在日志里记一笔。
两份快照正好列了 1,000 个工具。 复制任务在写之前会从数据库读回这份快照,而那次读取一次最多返回 1,000 行。正好 1,000 条记录时,任务分不清这是完整的列表还是被截断的列表,于是拒绝写入,不去存一份可能缺了内容的副本。两份都留在数据库里。改成分页读取已经记进待办。
新快照要等。 快照满 15 分钟后的第一轮才会被复制。2026-10-08 13:15Z 时,有 6 份在等。
较早的已签名 envelope。 每个 run 也有一个类似的对象:run、它的检查结果和它的 envelope,一起 gzip,放在 cold/v1/att/<envelope id>.json。2026-09-25 起的已签名 envelope 在 run 结束时就有了冷副本,只有 2026-09-29 的一次写入失败,当时作为警告记在了那个 run 上。2026-09-25 之前的 13,276 个已签名 envelope 还没有冷副本。对之后的删除步骤,我们的规则是:envelope 没有经过核验的副本,这个 run 就永远不会成为候选。
为什么要一个字节都不差
签名证明里记录了工具列表的两个指纹:一个基于工具名,一个基于每个工具的名称和输入 schema。快照就是这两个指纹的来源。想核对签名里的指纹是否对得上我们说看到的工具,你需要一字不差的工具列表。如果副本把输入 schema 里的一个数字舍入了,人看着没问题,但它再也算不出签名里的那个指纹。如果副本改了描述里的一个字符,它就对不上快照自己的内容摘要(content digest),那个摘要是覆盖描述的。无论哪种情况,我们的存档都会和我们自己的签名互相矛盾,读者也无从判断该信哪一个。签名覆盖了什么另有一篇文章。
我们怎么数的
- 快照:2026-10-08T13:15Z 时工具快照表里的全部行,共 7,111 行。一行有冷对象 key 才算已复制,而写入程序只在确认上传大小之后才写 key:7,102 行有,9 行没有(上文的三份和六份新快照)。
- 大小是这些行里记录的大小之和。数据库和表的大小是同一时刻 Postgres 报告的数值,含索引。
- 监测中的 endpoint:在 2026-10-08T13:00Z 之前七天内至少有一次计划内 run 的 endpoint。
- 233,554 个数字是在 2026-10-07 15:37Z 数的,范围是当时存着的全部输入 schema。
- 轮次、各轮复制数、上传耗时和证明行,来自每小时任务自己打出的日志,时间从 2026-10-07 18:37Z 到 2026-10-08 12:37Z。
- 三个对象在 2026-10-07 下载并比对;2026-10-08 13:15Z 我们又对同样的文件算了一次哈希。
- 已签名 envelope:带签名、且没有被判为不合格(disqualified)的 envelope。
- 局限:每小时的证明每轮每类只读一个对象,手工比对只覆盖了三个对象。我们没有把 7,102 个对象全部重读一遍。覆盖每一个对象的,是写入路径本身:gzip 之前算哈希,上传之后核大小,两者都对上才更新那一行。