老刘
发布于 2026-10-17 / 3 阅读

100MB 的指纹——从 60 秒到 424 毫秒

100MB 的指纹——从 60 秒到 424 毫秒

旦阅诞生记 · 第 5 篇。上一篇说到 BookKey 的归一化口径:去 BOM、行尾统一、首尾裁剪。口径定了,新问题立刻浮出水面——这个指纹怎么算得动。本文所有代码取自 eggBookServer-wb / eggBookWin-wb / eggBookApp-wb 三个工程的真实实现。

一、先算一笔账

txt 小说动辄几十上百 MB。假设我们在手机端用最直觉的方式算指纹:

  1. 把整个文件读成一个字符串;
  2. 归一化(行尾替换、去首尾空白);
  3. 对归一化结果求 MD5。

每一步的代价都可以算出来:

步骤 100MB 文件的代价
读成 JS/UTS 字符串 UTF-16 存储,内存占用 ≈ 200MB
replace(/\r\n/g, "\n") 再生成一份归一化副本,≈ 又是 200MB
纯 JS 实现的 MD5 比原生慢 10~50 倍
合计 10~60 秒,低端机直接 OOM

一部 4GB 内存的手机,光算个指纹就吃掉 400MB——这不是优化问题,是方案本身不成立。所以流式实现的目标从一开始就定了三条:

  1. 单趟:文件只顺序读一遍,不回头、不整串;
  2. O(1) 内存:额外内存与文件大小无关;
  3. 逐字节等价:结果必须和整串路径算出来的一模一样,一个字节都不能差。

第三条最容易被轻视,也最重要——流式和整串各算各的、结果差一个 bit,两台设备就永远认不出彼此。

二、流水线:三段各管一段

归一化在字节流上拆成三层,每层只做一件事:

原始 UTF-8 字节流
   │
   ▼
① 行尾归一:CRLF → LF、孤立 CR → LF(字节级状态机)
   │
   ▼
② 首尾裁剪:丢首部空白;暂存疑似尾部空白(不能边读边吐)
   │
   ▼
③ 增量哈希:每段确定的字节 TransformBlock 进 MD5

为什么不先把行尾归一写回一个"中转流"、再从头读一遍做裁剪?因为那就不是单趟了。三层必须在同一次遍历里串联,任何一层都不能要求"看全量"。

三、行尾归一:一个只能装 1 bit 的状态机

服务端参考实现 Services/BookKeyUtil.cs 里的 NormalizeInto,核心是这个 Feed 闭包:

public static void NormalizeInto(Stream source, HashAlgorithm hasher, int bufferSize = DefaultBufferSize)
{
    if (bufferSize < 8) bufferSize = 8;

    var sink = new NormalizedSink(hasher);
    var pendingCr = false;

    // 统一处理「已读入的一段字节」:先做 CRLF/CR → LF,再交给裁剪 sink
    void Feed(ReadOnlySpan<byte> span)
    {
        foreach (var b in span)
        {
            if (pendingCr)
            {
                pendingCr = false;
                sink.Push(0x0A);            // 上一个 CR 补一个 LF
                if (b == 0x0A) continue;    // CRLF:当前字节已被消费
            }
            if (b == 0x0D) { pendingCr = true; continue; }
            sink.Push(b);
        }
    }

    // 1) 开头的 UTF-8 BOM(EF BB BF)需要先剔除
    var head = new byte[3];
    var headLen = ReadFull(source, head, head.Length);
    var offset = (headLen == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF) ? 3 : 0;
    Feed(head.AsSpan(offset, headLen - offset));

    // 2) 顺序读余下内容
    var buffer = new byte[bufferSize];
    int read;
    while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
        Feed(buffer.AsSpan(0, read));

    // 3) 末尾孤立的 CR 也要补成 LF(随后会被尾部裁剪丢弃)
    if (pendingCr) sink.Push(0x0A);

    sink.Finish();
}

整个状态机的全部状态就是一个 bool pendingCr:上一个字节是不是 CR。遇到 0x0D 先不吐,攒着;下一个字节来了再裁决——是 0x0A 就两个一起消费成一个 LF,不是就先补一个 LF 再处理当前字节。

这个"攒一下"是被迫的。CR 和 LF 是两个独立字节,而分块读取的边界可能恰好切在它们中间——CR 落在块尾,LF 在下一个块的开头。如果读到块尾就把 CR 当孤立 CR 处理掉,CRLF 就被错算成了两个换行,归一化结果和整串路径直接分叉。状态机只有 1 bit,但这 1 bit 必须跨块存活。

流末尾还有一个对称的收尾:如果文件最后一个字节是 CR,pendingCr 还挂着,要补吐一个 LF——虽然它接下来会被尾部裁剪丢掉,但"丢"是裁剪层的职责,行尾归一层必须先把语义补完整。分层的好处就在这里:每层只保证自己的语义正确,不替下一层偷懒。

四、尾部裁剪:不能边读边吐

裁剪的规则看起来人畜无害:去掉首尾的空格、制表、换行。首部好办——吐出第一个非空白字节之前,见一个丢一个。尾部麻烦在你读到空白时,不知道它是不是"真正的结尾":

private sealed class NormalizedSink(HashAlgorithm hasher)
{
    private const byte Space = 0x20, Tab = 0x09, Lf = 0x0A;

    private readonly byte[] _emit = new byte[64 * 1024];   // 待喂给哈希器的缓冲
    private int _emitLen;
    private byte[] _pending = new byte[256];               // 暂存的「可能是尾部空白」的一段
    private int _pendingLen;
    private bool _started;

    private static bool IsTrim(byte b) => b == Space || b == Tab || b == Lf;

    public void Push(byte b)
    {
        if (!_started)
        {
            if (IsTrim(b)) return;          // 首部裁剪:丢掉
            _started = true;
            Emit(b);
            return;
        }

        if (IsTrim(b))
        {
            if (_pendingLen == _pending.Length) Array.Resize(ref _pending, _pendingLen * 2);
            _pending[_pendingLen++] = b;
            return;
        }

        FlushPending();                     // 后面还有正文 → 之前暂存的空白其实是正文的一部分
        Emit(b);
    }

    public void Finish()
    {
        _pendingLen = 0;                    // 真正的尾部空白:丢弃
        FlushEmit();
    }
}

策略:空白先暂存进 _pending;一旦后面出现非空白字节,说明那段空白是正文中间的空行,整串吐出去;直到 Finish() 还没被吐走的,才是真正的尾部空白,丢弃。

这个设计的额外内存是 O(尾部连续空白的长度)。正常小说结尾就几十个字节;测试用例里专门放了"300 个换行结尾"的极端情况——也就 300 字节,无关痛痒。唯一会爆炸的场景是"文件 99% 都是尾部空白",那不属于阅读器的世界。

五、按字节裁剪,为什么切不坏汉字

细心的读者会注意到:裁剪是在字节层做的,没有解码成字符。凭什么安全?

答案藏在编码里。裁剪集合只有三个 ASCII 字符:

  • 空格 0x20
  • 制表 0x09
  • 换行 0x0A

而 UTF-8 的设计保证:多字节字符(汉字等)的每个续字节都 ≥ 0x80,首字节也 ≥ 0x80。也就是说,0x00~0x7F 范围内的字节只可能是 ASCII 字符本身,绝不可能是某个汉字的组成部分。按字节匹配这三个值,永远不会误伤"寒""文"这样的双字节字符。

这是 UTF-8 自洽性送给我们的礼物:字节流在这一点上可以当字符流用,省掉了整个解码环节。顺带说一句,这也是为什么裁剪集合刻意不含全角空格(U+3000)——它不是 ASCII,字节层就够不着它了;想裁它就得解码,代价立刻回来。单元测试里专门锁了这条:" \u3000正文\u3000 " 归一化后必须保留全角空格。

六、桌面端多做的那一点:顺带数出字符数

桌面端 EggBook.Core 用的是 .NET 的 IncrementalHash,同样的 Feed 状态机,但它的 sink 在吐字节给哈希器的同时,还要数出归一化后的字符数——进度定位要用全书绝对字符下标,这个总数得有人提供:

private sealed class NormalizedSink
{
    private readonly IncrementalHash _hasher;
    private readonly Decoder _decoder = new UTF8Encoding(false, false).GetDecoder();
    private readonly char[] _chars = new char[64 * 1024];
    private readonly byte[] _emit = new byte[64 * 1024];
    // ... 与服务端相同的 Push / Finish ...

    private void FlushEmit()
    {
        if (_emitLen == 0) return;
        _hasher.AppendData(_emit, 0, _emitLen);
        CharCount += _decoder.GetChars(_emit.AsSpan(0, _emitLen), _chars, false);
        _emitLen = 0;
    }
}

字节→字符的换算用 Decoder 增量解码:汉字三个字节可能跨两次 FlushEmit,Decoder 会把不完整的尾巴握在手里,等下一批拼上再计一次数。Finish() 里有个耐人寻味的注释:

public void Finish()
{
    _pendingLen = 0;                // 真正的尾部空白:丢弃
    FlushEmit();
    // 说明:不必再 flush Decoder —— 只有「末尾被截断的多字节序列」会被它握在手里,
    // 那种情况本来就属于文件末尾的脏字节,丢弃即可。
}

不 flush 是故意的:文件末尾如果真有半个残缺的汉字,那属于损坏数据,扔掉比补出来更正确。这个决定和编码探测层的"过度编码报错"是同一个哲学——脏数据宁可丢弃,不可脑补。

七、手机端换了一条等价路线

安卓端没有照抄字节级状态机。UTS 插件(x-reader-native)的 BookKeyHasher 走的是按行处理:

function hashStreamInto(hasher : BookKeyHasher, input : InputStream, encoding : string) : void {
    const reader = new BufferedReader(new InputStreamReader(input, Charset.forName(encoding)), 65536)
    let line = reader.readLine()
    while (line != null) {
        const nxt = reader.readLine()
        hasher.feedLine(line, nxt != null)   // needLf:这行后面还有没有下一行
        line = nxt
    }
    reader.close()
}

这里有个一石三鸟的选择:Java 的 BufferedReader.readLine() 天然把 \r\n、\r、\n 都当行分隔符,行内容里不含任何换行。于是:

  • CRLF → LF 的归一化 = 行与行之间补 \n,不用自己写跨块回看;
  • 逐字节状态机在 UTS 里写起来啰嗦(number 装箱、字节数组操作都是坑),字符串 API 反而自然;
  • 首尾裁剪可以放在字符域做——反正裁剪集合是 ASCII,字符层字节层结果一致。

代价是需要一个 needLf 前瞻:要知道"这行是不是最后一行",得先读出下一行。所以循环里始终持有两行。裁剪逻辑集中在 feedLine:

feedLine(s : string, needLf : boolean) : void {
    // BOM 只在「整段文本的第一个字符」处才算 BOM(与服务端 Normalize 一致)。
    // 极易写错:若用「还没开始正文」当判据,那么「前导空白 + BOM」的串会被多剥一次,
    // 手机端与电脑端算出的 key 就对不上了(服务端单元测试专门锁了这条)。
    let base = 0
    if (this.isFirstLine && s.length > 0 && s.charCodeAt(0) == 0xFEFF) base = 1
    this.isFirstLine = false

    // 找本行最后一个「非裁剪字符」
    let last = s.length - 1
    while (last >= base && isTrimChar(s.charCodeAt(last)!)) last = last - 1

    if (last < base) {
        // 整行都是裁剪字符(空行 / 纯空白行 / 只有 BOM 的行)
        if (this.started) {
            this.pending = this.pending + s.substring(base)
            if (needLf) this.pending = this.pending + "\n"
        }
        return                                  // 首部还没开始:直接丢
    }
    // ... 省略:首部裁剪、pending 回吐、emit 正文、行尾空白进 pending
}

哈希的"边编码边喂"借了 Java 标准库的组合技:

// DigestOutputStream 会在字节写入前先喂给 MessageDigest;
// 底下的 sink 只是个「垃圾桶」,定期 reset 以控制内存
this.writer = new OutputStreamWriter(new DigestOutputStream(sink, md), Charset.forName("UTF-8"))

归一化后的字符写进 OutputStreamWriter → 编码成 UTF-8 字节 → DigestOutputStream 截获并喂给 MessageDigest → 丢进一个定期 reset 的"垃圾桶"。字符从进入 hasher 到变成哈希,全程不构造整串。

注意这条路线和字节级路线输入不同:手机端直接对原文件按探测出的编码读(GBK 也能算),桌面端/服务端对已转 UTF-8 的字节流算。殊途同归的前提是归一化口径一致——这就是上一篇说的"参考实现 + 契约测试"存在的意义。

八、等价性不是"我觉得一致",是测出来的

三条流水线,两套范式(字节级 vs 行级),靠什么保证逐字节等价?服务端测试套件里三类测试分工明确:

① 极端分块。用一个每次只肯吐 N 字节的假 Stream,逼着状态机在最刁钻的边界上工作:

[Theory]
[MemberData(nameof(ChunkSizes))]        // 包含 chunkSize = 1
public void 流式与整串结果一致(int chunkSize)
{
    foreach (var raw in Cases)
    {
        var expected = BookKeyUtil.Compute(raw);
        var actual = BookKeyUtil.ComputeStreamed(
            new ChunkedStream(ToUtf8Bytes(raw), chunkSize), BookKeyUtil.DefaultAlgo, chunkSize);
        Assert.Equal(expected, actual);
    }
}

ChunkSizes 里故意放了 1:每次读一个字节,pendingCr、_pending、_emit 每一处状态跨越都被逼到极限。1 字节分块都对,256KB 更不会错。

② 黄金值契约。归一化口径一旦被谁"顺手优化",黄金值当场红:

// md5("abc")
Assert.Equal("md5:900150983cd24fb0d6963f7d28e17f72", BookKeyUtil.Compute("\uFEFF  abc \r\n\r\n"));
// md5("")
Assert.Equal("md5:d41d8cd98f00b204e9800998ecf8427e", BookKeyUtil.Compute("   \r\n\t "));
// 流式路径必须给出同样的值
Assert.Equal("md5:900150983cd24fb0d6963f7d28e17f72",
    BookKeyUtil.ComputeStreamed(new MemoryStream(ToUtf8Bytes("\uFEFF  abc \r\n\r\n")), "md5", 1));

③ 真实文件三方交叉。42MB 的真实小说,手机端、桌面端、服务端各自算一遍,三个 key 必须逐字符相同。这一步锁的是"三份独立实现 vs 同一个真实世界"。

九、实测

台式机,100MB、CRLF 行尾、混合汉字:

文件大小: 100.0 MB
流式 BookKey : md5:8fc243f7f9f4fd1e957d5d5bf0462e5f
流式耗时     : 424 ms  ≈ 236 MB/s
整串 BookKey : md5:8fc243f7f9f4fd1e957d5d5bf0462e5f   与流式一致=True

读盘 + 归一化 + MD5 合计 236 MB/s——瓶颈基本在 MD5 本身,归一化几乎白送。手机端按同口径折算(ARM 单核 MD5 约 150~300MB/s,存储读 100~300MB/s),100MB 约 1~2.5 秒,低端机 3~5 秒。

对比一下起点:从 10~60 秒 + 400MB 峰值内存,到 424 毫秒 + 256KB 缓冲区。而且这是一次性成本——导入流程本来就要整文件读一遍做编码探测,BookKey 挂在同一趟读取上,等于零额外 I/O。算完缓存进书籍记录,之后打开书永不重算(缓存的设计下一篇细说)。

十、为什么不走捷径:抽样指纹

写到这里一定会有人问:算指纹何必读全文件?头尾各取 1MB,秒出。我们认真评估过,否决,理由有两个,且都是致命的:

  1. 按原始字节偏移取样,CRLF 文件比 LF 文件多出的字节会把取样点推到不同位置——同一本书换个来源,取的"中间片段"内容不同,key 不同,同步直接失效;
  2. 想按"归一化后的字符位置"取样,就得先知道归一化总长——而知道总长需要全量扫一遍,绕回了原点。

抽样只在"所有设备文件字节完全一致"时安全,而 BookKey 的存在意义恰恰是容忍来源差异。目标和方法互斥,没有折中。

顺带一提:MD5 在这里只当内容指纹,不涉及安全;真介意碰撞可把 hashAlgo 换成 sha256——字段从第一天就留着,切换不用改表结构。

小结

整串方案 流式方案
文件读取 一次性载入 单趟顺序
额外内存 ≈ 400MB(100MB 书) O(1),缓冲区 256KB
耗时(100MB) 10~60 秒或 OOM 424ms(台式)/ 1~2.5s(手机折算)
等价性保证 —(它就是基准) 1 字节分块 + 黄金值 + 三方交叉

流式版本最深的体会:性能问题的答案往往不是"更快的算法",而是"更少的数据搬运"。MD5 一毫秒都没变快,我们只是不再让它等 400MB 的字符串到位。