老刘
发布于 2026-10-21 / 2 阅读

不用抽样——指纹为什么必须算全量

不用抽样——指纹为什么必须算全量

旦阅诞生记 · 第 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+归一化 单趟全付
失败模式 静默错判(最危险) 无
适用前提 字节一致或允许重试 无

否定一个方案的方法论,比结论本身更值得带走:把方案的隐含前提一条条写出来,逐条对设计目标检验。三个前提对不上目标,讨论性能数据就是浪费时间——性能只决定一个方案跑多快,前提才决定它能不能到终点。