不用抽样——指纹为什么必须算全量
旦阅诞生记 · 第 7 篇。上一篇结尾说 BookKey 一辈子只算一次,这一篇先回答一个更早的问题:既然要"算得快",为什么不干脆只算一小段?——这是 BookKey 设计评审时讨论最久的一页,也是被否决得最干脆的一页。它值得单独一篇,因为记录"为什么不做"比记录"做了什么"更难,也更有用。
一、被否决的方案长什么样
先替这个方案说句公道话,把它写到最能打的形态。设计意图:一本 100MB 的书,全量算要读 100MB;那只读几个关键窗口、比如 1MB,理论上快 100 倍:
SampledFingerprint(path):
size = fileSize(path)
win = 256 * 1024 // 窗口 256KB
points = [0, // 头部
size / 4, // 25% 处
size / 2, // 50% 处
size * 3 / 4, // 75% 处
size - win] // 尾部
h = md5()
for p in points:
h.update(readAt(path, p, win)) // 每处读一个窗口
return h.hex()
账面成绩:5 × 256KB ≈ 1.2MB,是全量的 1.2%;手机上约 10 毫秒。头部、三个中段、尾部都照顾到了,看起来既便宜又稳健。评审时它最大的卖点是一句很诱人的话:"内容相同的书,采样窗口大概率也相同"。
方案死于这句话里那个"大概率"。
二、把隐含前提摆上桌
抽样指纹要成立,其实依赖三个前提,逐条过:
| # | 隐含前提 | 设计目标 | 判定 |
|---|---|---|---|
| 1 | 取样点按字节偏移定位,同一本书字节布局相同 | 必须容忍行尾/编码差异 | ❌ 冲突 |
| 2 | 窗口内容逐字节一致,key 才一致 | 同上 | ❌ 冲突 |
| 3 | 想按"归一化后的位置"取样?先全量归一化一遍 | 单趟低成本 | ❌ 自我抵消 |
三个前提,每一个都和 BookKey 的存在理由正面相撞。下面展开最有意思的两处。
三、硬伤一:窗口会滑走
BookKey 的第一个设计决策(第 4 篇)是容忍来源差异:同一本小说从不同网站下载,行尾可能是 CRLF 也可能是 LF,可能有 BOM 也可能没有。现在算一笔账:
- 一本 100MB、5 万行的书,CRLF 版比 LF 版多 5 万字节 ≈ 49KB;
- 25% 取样点在字节 25MB 处。对 CRLF 版,这个字节位置对应正文第 X 行;对 LF 版,同样的字节位置已经滑到了第 X + 数百行;
- 256KB 的窗口里装的是章与章交界处的正文——两个版本取到的窗口内容整段不同,不是差一个字节。
关键在误差的形态:它不是可以容忍的"轻微漂移",而是窗口滑移——上游每差 1 个字节,下游取样点后面的内容就整体错开 1 个字节。头部窗口运气好(BOM 差 3 字节往往还在同一行内),但中段窗口的错开量随行数累积,50% 处已经错开几十 KB,内容毫无相似度可言。
有人会想到补救:按比例取样不就行了?不行——比例只是把上面的算术换了个写法,size/2 依旧是字节位置,滑移依旧存在。再补救:按行号取样?那要先数完全部行——全量扫描又回来了,见下一节。
四、硬伤二:绕开滑移的路,通向全量
滑移的根源是"字节位置"不稳定。那把取样点定义在归一化后的字符位置上——比如"归一化后第 50 万个字符处"——同一本书不管什么来源,归一化后逐字符一致(这正是归一化的意义),窗口内容必然相同。逻辑成立。
但代价看一眼就懂:要知道"归一化后第 50 万个字符"在文件的哪里,你必须先归一化前 50 万个字符——而归一化没法跳跃进行,行尾状态机、裁剪 sink 都要求顺序处理。也就是说,想在 75% 处取样,就得先付出 75% 的全量扫描成本。
把流式管线的成本拆开看会更清楚:
| 阶段 | 占比(定性) | 抽样能省掉吗 |
|---|---|---|
| 文件 I/O(顺序读) | 大头之一 | ❌ 读到 75% 就得读 75% |
| 行尾归一 + 裁剪 | 大头之二 | ❌ 无法跳跃,逐字节状态机 |
| MD5 哈希 | 相对小 | ✅ 省掉 99% |
结论很尴尬:抽样指纹省掉的只是管线里最便宜的一段,却把最贵的两段原封不动留了下来。实测全量 424ms 里,I/O 和归一化占绝对大头——抽样方案在 75% 取样点的"加速比"远不是 100 倍,量级上接近"省了一个 MD5"。
而它要成立,还得再加一个前提:所有设备的文件字节完全一致。这句话值得单独一节。
五、锋利的那一刀:接受它,就不需要 BookKey 了
把三个前提合并起来看,抽样指纹真正要求的是:同一本书在所有设备上字节完全一致。
可是 BookKey 之所以存在,恰恰是因为这个条件不成立——正文不落服务端、各端各自下载、来源五花八门,才需要一个"容忍字节差异、只认内容"的指纹。这两个立场互为否定:
如果你接受抽样的前提,直接对文件字节求 MD5 就够了,BookKey 本身就是多余的; 既然你确实需要 BookKey,就不可能接受抽样的前提。
方案不是"有风险",是逻辑上自相矛盾。这类否定比"性能不够"或"实现复杂"干脆得多——它连工程上的权衡都算不上。
六、抽样并没有错,只是用错了地方
公平起见,抽样指纹在别的场景是标准做法,旦阅自己也在用它的思想:
| 场景 | 为什么抽样可行 |
|---|---|
| 传输校验 / 断点续传 | 字节级一致是协议保证的前提,不是要检测的目标 |
| 大文件秒传(网盘去重) | 先比文件大小,再抽样,不一致再全量——允许失败后重试 |
| 构建缓存(如 ccache) | 输入由构建系统控制,来源单一 |
共同点一目了然:这些场景里"字节一致"要么是前提、要么允许失败后重算。而跨端认书没有重试机会——两端各自算完、交换、裁决,一次不一致就是"不是同一本书",用户的进度就此分家。错误不可恢复的场合,不能用概率性的方法。
七、最终决策
写成一行决策记录(ADR),贴在服务端 README 里:
ADR-003:BookKey 采用全量流式归一化 + 哈希,否决抽样指纹。 理由:抽样以"字节布局一致"为前提,与"容忍来源差异"的设计目标互斥; 改用归一化位置取样则需先全量扫描,加速收益消失。速度问题由"单趟流式 + 一次计算终身缓存"解决,而非降低覆盖面。
对应的落地在第 5 篇:424ms、O(1) 内存;配套的缓存策略是下一篇的主角——《只算一次》:这么贵的指纹,怎么保证一本书一辈子只付一次钱。
小结
| 抽样指纹 | 全量流式(最终方案) | |
|---|---|---|
| 理论速度 | ~10ms | 424ms(台式)/ 1~2.5s(手机) |
| 来源差异容忍 | ❌ 窗口滑移 | ✅ 归一化后逐字符一致 |
| 管线成本 | 省掉哈希,留 I/O+归一化 | 单趟全付 |
| 失败模式 | 静默错判(最危险) | 无 |
| 适用前提 | 字节一致或允许重试 | 无 |
否定一个方案的方法论,比结论本身更值得带走:把方案的隐含前提一条条写出来,逐条对设计目标检验。三个前提对不上目标,讨论性能数据就是浪费时间——性能只决定一个方案跑多快,前提才决定它能不能到终点。