只算一次——身份计算的成本预算
旦阅诞生记 · 09。上一篇讲了 CRLF 血案,这一篇讲它的直接遗产之一:BookKey 从此被牢牢钉死在导入流程里,一本一生只算一次。这个决定看起来只是个缓存策略,实际上是整个同步体系的地基性承诺——值得单独一篇讲清楚。
从一个反问开始
先做个思想实验:用户按下"同步"按钮,客户端要向服务端报出这本书的身份。如果每次同步都现场重算一遍 BookKey——42 MB 的书,桌面端 424 毫秒,手机端一两秒——听起来也能接受?毕竟指纹算法已经是流式的了。
问题根本不在快慢。重算有一个致命的语义缺陷:你无法保证两次算的是同一份内容。
磁盘上的文件不是只读圣物。用户可能用别的编辑器顺手"修了两个错字",网盘可能同步了半截,杀毒软件可能隔离再放回……每次重算都等于把"这本书是谁"这个身份问题重新开奖一次。一旦重算结果和上次不同,服务端会把它当成另一本书:进度对不上、书架出现幽灵副本,用户看到的现象和第 8 篇那场血案一模一样——而且这次没有 bug 可修,因为重算"算得完全正确",错的是我们居然让它重算。
所以正确的设计不是"算得更快",而是根本不给它重算的机会。
计算点审计
把这个承诺落实成代码纪律,第一步是审计全代码库里 BookKey 的计算调用点。桌面端搜 BookKeyUtil.Compute*,排除测试和自检工具,生产代码里只有三处:
// BookImporter.cs — 文件导入(唯一的主入口)
var (charCount, encodingName, warning) = CopyAsUtf8(srcPath, dest, progress);
var key = BookKeyUtil.ComputeFromUtf8File(dest); // ← 对落盘后的转存文件算
// BookImporter.cs — 粘贴文本导入
File.WriteAllText(dest, normalized, new UTF8Encoding(false));
var key = BookKeyUtil.ComputeFromUtf8File(dest); // ← 同样对落盘文件算
// ShelfViewModel.cs — 仅给缺失 BookKey 的老记录补算(维护路径)
var key = BookKeyUtil.ComputeFromUtf8File(b.FilePath);
BookKeyUtil 的类注释把这条纪律写成了文档:
/// 桌面端走 流式字节路径(ComputeFromUtf8File):单趟顺序读取、O(1) 额外内存,
/// 100MB 的书一两秒完成,且只在「导入」时算一次。
除此之外的所有路径——打开书、翻页、同步上传、换进度——BookKey 一律从书架记录(LibraryStore)里读现成的字符串。同步服务拿到的就是 book.BookKey 这个已经落库的字段,一个字节都不会再从正文文件里读。
审计的结论可以画成一张成本表:
| 时机 | 是否计算 BookKey | 成本 |
|---|---|---|
| 文件导入 | ✅ 算一次 | 42 MB 约 424 ms(流式,后台线程) |
| 粘贴导入 | ✅ 算一次 | 正文通常很小 |
| 老书架记录补算 | ✅ 手动触发 | 批量进度条,逐本提示 |
| 打开书 | ❌ 永不算 | 0 |
| 每次同步 | ❌ 永不算 | 0 |
| 翻页 / 章节跳转 | ❌ 永不算 | 0 |
"补算"那行值得多说一句:它是给从别处拷贝书架数据的场景留的逃生门——书架 JSON 里可能有 BookKey 为空的老记录。它刻意做成一个用户手动点、看得见进度("正在计算书籍标识 3/17:斗破苍穹")的显式操作,先过 IsValid 形态校验过滤掉已有的,绝不悄悄在后台补。身份计算要么发生在用户知情且预期的时刻(导入),要么发生在用户显式要求的时刻(补算),永远不发生在用户看不见的地方。
对落盘文件算,而不是对源文件算
导入流程里有一个容易被忽略的细节:ComputeFromUtf8File(dest) 的参数是 dest——转存后的书库文件,不是用户选中的源文件 srcPath。
这个选择是第 8 篇血案换来的。导入会做三件改变内容的事:编码探测与转码(GB18030 → UTF-8)、BOM 剥离、行尾归一化(NewlineNormalizer)。转存落盘的正文才是这个 App 以后要读、要索引、要同步锚定的事实本体。指纹如果对源文件算,就会出现一种荒诞的状态:服务端记着的身份描述的是"用户硬盘上那份已经不存在的文件",而客户端实际读书的每一页都来自另一份内容。
对落盘文件算,等于让指纹描述将要长期存在的那份事实。书库文件从此成为这本书的单一事实源,BookKey 是它的身份证,CharCount 是它的字数公证——三者说的是同一份字节。
身份稳定的代价:内容以导入时刻为准
"永不算"换来一个必须向用户讲清的语义:这本书就是导入那一刻的这本书。
之后如果书库文件被外部修改,旦阅不会重新鉴定身份。实际行为是保守的:章节索引失效就重扫索引(那是呈现层的事,可以重算),但 BookKey 和同步身份纹丝不动。极端情况下内容被改得面目全非,用户会看到"索引对不上"之类的现象,而不是书在书架里悄悄换了个身份。宁可呈现层报错,也不让身份层漂移——因为呈现错误影响一个人的一次阅读,身份漂移毁掉的是多台设备间的全部进度映射。
这也是为什么补算路径必须手动:它本质上是一次"重新开奖",开了奖,老进度就认不得了。这种操作必须让用户按着按钮、看着进度、明明白白地同意。
服务端这边:更彻底的"不算"
客户端只算一次,服务端干脆一次都不算。
BookKey 到了服务端就是一个不透明字符串:主键、去重键、进度记录的分组键。服务端从不持有正文,自然也从不验证"这个 key 对应的内容真的是这个哈希"——它没有能力验证,也没有必要验证。验证内容一致性的职责完全在客户端(导入时对落盘文件算),服务端只负责记账。
给开发者留的唯一一个内容级校验入口,是那个 fingerprint 自检接口:客户端把归一化后的字符数(不是正文)随请求带上,服务端比对后原样丢弃。它存在的目的不是运行时防盗,而是给跨端一致性测试当裁判——第 4 篇讲过,42 MB 真实文件三方交叉校验就靠它。
信任链因此可以画成一条直线:
导入:源文件 → 转存落盘 → 对落盘文件算指纹 → 写进书架记录(一次)
之后:书架记录里的 BookKey 字符串 → 打开书 / 同步 / 翻页 全部直接读(零次)
服务端:字符串当主键记账(零次,永不接触内容)
收尾:预算表思维
把这篇压缩成一个思维方式:**性能问题分两种,"算得快"和"少算"。**流式哈希、增量 MD5、256 KB 缓冲区,这些是"算得快",上一篇已经讲透;而"只在导入时算一次、其余一切路径读缓存",是"少算"——它省掉的不是 60% 的耗时,是 100%,顺带消灭了"重算出不同身份"这个整类 bug。
代价同样要说清楚:这个设计把内容变更的语义锁死在"导入时刻",用户改书得删了重导。对阅读器来说这是正确的交换——书的身份就该像纸书的印刷一样,出厂即定格。但如果哪天旦阅要做"正文修订推送",这个前提就要重新谈判,到时该写的不是补丁,而是像第 7 篇那样的 ADR。
下一篇开始进第三辑·进度同步协议:客户端和服务端的第一次握手——拉取、推送与并发窗口的设计。