技术9/21/2026·约 4 分钟

完整性证明属于写入的那一方

我们的输入引擎每次启动都把出厂的 80 MB 数据哈希一遍——557 毫秒,而一次按键解码只要 224 微秒。而这些数据早就被证明过一次了,由写入它的那一方。

差两个数量级

我们的输入引擎以一个 bundle 的形式出厂:一本词库、一个静态语言模型、一个长尾专家模型,以及把它们绑在一起的若干 manifest。它是 app 加载的最大件,而在启动时,运行时的做法是:遍历每一个 artifact、读完每一个字节、与 manifest 对哈希。

这件事做一次是对的。每次冷启动都做是错的,而差别并不微妙:

解码一次按键224 µs
组装整个引擎4.7 ms
校验 bundle 的每一个字节557 ms

实测条件还是偏袒它的——快机器、热页缓存、按本机 CPU 调优编译。而这项校验比组装整个引擎还贵一百倍。

而且它挪不到一个无害的地方去。引擎的按键队列排在 start() 后面,于是启动前打下的按键会被缓冲并重放。把每一字节哈希掉的开销整个落在「用户敲下第一个字母」和「第一屏候选出现」之间——真机上,那个窗口要走几十秒。

而它本来什么也没保护

有意思的部分在于,这项检查从来就没买到任何东西。

那些字节被证明过一次,由写入它们的那一方。构建期的发布门禁在它被允许出货之前把它哈希一遍。安装时,把 assets 解开落盘的那个东西会把它刚写下的内容哈希一遍。更新下载完成时,下载它的那个东西在接收之前校验这一份下载。这三个时刻各自都是内容唯一可能出错的那一刻,而每一个时刻里都有人在场、并且有能力对失败做点什么。

之后那份数据就是只读的。在每次读时重新推导同一个证明,不可能发现两次读之间没有发生过的损坏——因为读不修改任何东西。读者不会得到写入方早就不知道的任何信息,而它为此在关键路径上付了钱。

在 iOS 上这个论证更强,强到事后有点难为情:出厂数据躺在代码签名覆盖的 bundle 里。改动一个字节,app 就起不来。有能力改它的人,早就已经有代码执行了。

于是运行时不再哈希。它现在检查的是结构——每个文件是不是普通文件、每个长度是否与 manifest 相符、manifest 之间是否互相自洽。它验证 bundle 是自洽的,不验证它未被篡改,因为在 iOS 上那件事上一层已经保证了,在 Android 上是安装时保证的。

两档,由调用方选

这次改动不是「把校验删掉」。那会是错误的本能,而且会把那个在真正该跑的地方真正有意义的检查一起扔掉。

取而代之的是两档,由调用方挑选:

pub enum ArtifactIntegrity {
    /// Read every byte and compare length and SHA-256 against the manifest.
    Measured,
    /// Check structure only: regular files, lengths, cross-manifest binding.
    Declared,
}

凡是本来就得把整份数据读一遍的都保留完整度量——发布方、安装器、质量评测器、每一个离线工具。它们没有为一次反正都会发生的检查支付延迟代价,数据本来就在内存里。运行时则要便宜的那一档。

正是这个区分让这件事成为一条规则而不是一次优化优化。便宜路径一旦存在,贵的那条就必须被开口索取,于是今后每一个调用方都要做一个决定,而不是继承一个决定。

这条守卫为它自己的例外留了门

一条没有事故故事的规则会烂成一条被人绕开的规则,所以这一条是被强制的——用一组针对「重新引入完整校验」那些调用点的禁写 glob:

forbid = [
  "*runtimebundle::open(*",
  "*runtimebundle::open_for_production(*",
  "*verify_runtime_bundle(*",
  "*ensure_unchanged(*",
]

有意思的是那些 glob 的形状。它们钉住的是会做度量的那些入口。运行时调用的 open_installed 不匹配,这是刻意的,也正是这条规则能被写出来的全部理由:一条无法表达它所存在的理由所需要的那条例外的守卫,会在第一次挡到真实工作时被删掉。

于是这条规则读起来的效果就是:在写入它的地方证明它,别处都不要。 而那些仍然合法的逐字节校验位置是被写下来的,不是靠记得的——这是它能在下一个觉得「完整校验听起来更安全」的人手里活下来的唯一原因。

为什么这可以外推

这个模式比「出厂模型 bundle」老得多。任何不可变产物都会在读时被校验,因为校验很容易被论证、而几乎没有人量过它在关键路径上的代价:

  • 对一个不可变文件每次读都重算校验和;
  • 对一个签名 blob 每次解析都重新验签;
  • 每次运行都从零重新推导整套测试;
  • 一个 schema 在构建期已经对着代码校验过,却每次请求都对着实例再校验一次。

每一种情形里,问题都是同一个,而且它是一个关于所有权的问题,不是关于性能的问题:

谁是那个「能让这份数据变错」的一方,以及他们最后一次有机会是什么时候?

如果答案是某个特定进程在某个特定时刻,那么证明就属于那个进程,只做一次,而下游每个人读到的都已经是算过账的东西。如果答案是「任何人、任何时候」,那么数据就不是只读的,检查就必须留着。

最常见的失败不是漏掉检查。而是跑错了地方——为一个早已被某个当时能让流水线停下来的人确立过的保证,付出一封首字母或首帧的延迟代价。

小结

在为一个校验辩护之前,先量它的成本。我们那次比它所守护的东西贵两个数量级,跑在产品里唯一一个用户会注意到的路径上。「它很便宜」是一个假设,而这一类假设几乎从不被测量。

问这笔开销落在哪里,而不只是它有多大。那 557 毫秒并没有摊在一次启动上;它整个落在第一次按键和第一次候选之间,因为引擎的队列会缓冲启动前打下的输入。任何加进初始化路径的检查,都是一次延迟决策。

然后问:谁能让这份数据变错。如果只有一个写入方、在一个已知的时刻、并且现场有人,那么证明就属于那个时刻,只做一次。在那之后重新推导它不是谨慎;那是同一个计算,被记到读者头上,永远记在关键路径上。

最后,把便宜路径写明确、把昂贵路径改成要开口索取。不要删掉完整检查——为那些反正要把一切读完的调用方保留它,让运行时只要结构,并把规则写成「挡住错的调用点、同时放行对的那一个」。一条无法说出自己例外的强制会被删掉,然后所有人都会回到每次启动哈希八十兆的老路上。

文中提到的产品

继续阅读