接缝——块边界上的六个陷阱
旦阅诞生记 · 第 6 篇。上一篇讲了流式 BookKey 怎么把 100MB 的指纹算进 424 毫秒,途中带过了三个跨块陷阱。这一篇把"块边界"当成一类 bug 的母题,把散落在三端代码里的六个接缝一次讲透——它们症状各异,病根是同一个。
一、只要按块读,就有接缝
O(1) 内存的代价,是把一个连续的整体切成块处理。而切块的地方就是接缝:上一块的最后一个字节和下一块的第一个字节,本来在文件里是挨着的,处理逻辑却分别在两次循环里——任何"需要看邻居才能下结论"的规则,都会在接缝处露馅。
旦阅按块读的地方不少:流式哈希、章节索引、分块读取。数一数,文件里藏着三类接缝:
| 接缝 | 宽度 | 为什么危险 |
|---|---|---|
| BOM | 3 字节 | 判断它需要"读满"3 个字节,一次 Read 给不满 |
| CRLF | 2 字节 | CR 和 LF 是两个独立字节,块边界可以切在中间 |
| UTF-8 多字节字符 | 3~4 字节 | 汉字可能被切成两半 |
| UTF-16 代理对 | 2 个 char | emoji 是两个"字符单元",按 char 数会数错 |
| 字符下标 ↔ 字节偏移 | 两种坐标系 | 换算错 1,全书进度整体漂移 |
| 文件末尾 | 任意 | "结尾"要读到没有下一块才能确认 |
六个陷阱对应六条缝。下面逐个过,代码全部来自真实实现。
二、陷阱一:BOM 不是"一个字节",读一次未必读得满
UTF-8 的 BOM 是 EF BB BF 三个字节。流式处理的第一个动作就是跳过它,直觉写法是:
// ❌ 直觉写法
var head = new byte[3];
int n = source.Read(head, 0, 3); // 以为 n == 3
if (head 是 BOM) { /* 跳过 3 字节 */ }
错在 Stream.Read 的契约上:它只保证至少读 1 个字节,不保证读满你要的量。本地文件通常一次给满,但这个"通常"在 FileStream 换成网络流、解压流、管道的那一刻就碎了——BOM 只被读了 1 个字节,判断失败,BOM 被当正文喂进哈希,key 比别的端多 3 个字节,同步从此失联。
所以参考实现里有一个专门的"读满"循环:
private static int ReadFull(Stream source, byte[] buffer, int count)
{
var total = 0;
while (total < count)
{
var n = source.Read(buffer, total, count - total);
if (n <= 0) break;
total += n;
}
return total;
}
// 调用处:读满 3 字节再判断
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));
注意最后一行:读不满也要把已读到的喂下去——文件总共就 1 个字节时,那 1 个字节也是正文。判断和消费是两件事。
三、陷阱二:同一份"首行",三个运行时三种定义
BOM 的判断位置,三端各不相同,而且必须各不相同:
- 服务端/桌面端(字节层):对原始字节流判断
EF BB BF,用上面的ReadFull; - 手机端(字符层):Android 的
InputStreamReader不会替你吃 BOM,U+FEFF 会以字符形式出现在首行,所以判据是"首行第一个字符是不是0xFEFF"; - 桌面端如果用了 .NET 的
StreamReader:它在某些设置下会把 BOM 吃掉——服务端测试里有一段专门的注释:
/// 按 Kotlin BufferedReader.readLine 的规则切行,并给出每行的字节区间。
///
/// 这里刻意不借助 C# 的 StreamReader:它会在某些设置下把 BOM 吃掉,
/// 而 Android 上的 InputStreamReader 不会 —— BOM 会以 U+FEFF 出现在首行,
/// 这正是 UTS 实现所依赖的行为。
同一份文件,三个运行时对"第一个字符"的答案不同。复刻另一个端的算法时,最危险的恰恰是这些"顺手"的标准库——它们各自替你做了不一样的隐式处理。测试复刻时绕开 StreamReader,就是为了不把这份隐式行为带进验证。
四、陷阱三:状态必须"显式"活在循环之外
上一篇详述过 CR 落块尾的问题:读到 0x0D 时不知道下一个是不是 0x0A,只能把 1 bit 状态(pendingCr)留到下一块。这里只强调写法上的纪律:
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);
}
}
pendingCr 声明在 Feed 外面、循环复用它——状态的生命周期大于块的长度,这是"跨块"能成立的前提。反过来的写法(把状态藏在函数内部、每次调用重置)在本地文件上测试全是绿的,换一个块边界刁钻的输入当场翻车。1 字节分块的测试(ChunkSizes 里故意放了 1)就是逼着每一个这样的状态在极限边界上工作。
五、陷阱四:半个字符,和"计数"的两种口径
字节流切片最隐蔽的受害者是字符本身。桌面端的 sink 一边喂哈希一边要数出归一化后的字符数(进度定位要用全书绝对字符下标),而一个三字节汉字完全可能跨两次输出缓冲:
private void FlushEmit()
{
if (_emitLen == 0) return;
_hasher.AppendData(_emit, 0, _emitLen);
CharCount += _decoder.GetChars(_emit.AsSpan(0, _emitLen), _chars, false);
_emitLen = 0;
}
Decoder 是 .NET 的增量解码器:多字节序列被切断时,它把不完整的尾巴握在手里,等下一批拼上再计数。Finish() 里那个"故意不 flush"的决定上一篇解释过——文件末尾残缺的半个汉字属于脏数据,丢弃,不脑补。
字符数还有第二层坑:"字符"按什么数? Java/Kotlin 和 UTS 的 string.length 数的是 UTF-16 code unit,而 emoji 这类增补平面字符在 UTF-16 里是两个 char(代理对),编码成 UTF-8 却是 4 个字节。章节索引要按字节累加,就绕不开这个口径:
function utf8ByteLen(s : string) : number {
let n = 0
let i = 0
const len = s.length
while (i < len) {
const c = s.charCodeAt(i)!
if (c < 0x80) {
n = n + 1
} else if (c < 0x800) {
n = n + 2
} else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < len) {
const c2 = s.charCodeAt(i + 1)!
if (c2 >= 0xDC00 && c2 <= 0xDFFF) {
// 代理对:一个字符占 4 字节
n = n + 4
i = i + 1 // 两个 char 一起消费
} else {
n = n + 3
}
} else {
n = n + 3
}
i = i + 1
}
return n
}
漏掉代理对的分支,一本书里的 emoji 会让字节坐标整体偏移——而偏移的量取决于书里 emoji 的个数,直到用户翻到带表情的章节,定位才会错位。这种"离起点越远越错"的 bug,是坐标系类问题最讨厌的形态。
六、陷阱五:双坐标系必须同步推进,还要会自我体检
章节索引是双坐标系问题的大本营:扫描一遍文件,同时记下每个候选标题的字符下标(给进度定位用)和字节偏移(给分块读取用)。两套数字必须严格同步,任何一处口径不一致都会让"字符上正确、字节上错位":
export function scanChapterMarks(path : string, ...) : ChapterScanResult {
// ...
let charPos = 0
let bytePos = 0
let first = true
const reader = new BufferedReader(new InputStreamReader(input, Charset.forName(enc)), 65536)
let line = reader.readLine()
while (line != null) {
let s = line
if (first) {
if (s.length > 0 && s.charCodeAt(0) == 0xFEFF) {
s = s.substring(1)
// BOM 占 3 个字节,但不计字符
bytePos = bytePos + 3
}
first = false
}
const lineCharStart = charPos
const lineByteStart = bytePos
const contentBytes = utf8ByteLen(s)
// 字符下标按「含换行」的口径累加(每行 +1 表示那个 \n),
// 这样得到的下标与「把整本读成字符串后逐字符数」完全一致 ——
// 否则章节 start/end 会和旧版本记录对不上,进度百分比也会整体偏移。
charPos = charPos + s.length + 1
bytePos = bytePos + contentBytes + 1
// ... 记录候选标题行、兜底分块 ...
line = reader.readLine()
}
// 累加值应满足:末行有换行 → 等于文件大小;末行无换行 → 多算 1。
// 其余情况说明行尾不是纯 \n(CRLF 会让每行少算 1 字节、一路偏小),索引不可信。
const ok = bytePos == fileSize || bytePos == fileSize + 1
// ...
}
三个细节值得放大看:
① BOM 只动一套坐标。 3 个字节、0 个字符——bytePos += 3 而 charPos 不动。这种"坐标系间非线性"的点,正是双坐标系统最容易错位的地方。
② "含换行"的口径是刻意的。 每行无脑 +1,代价是末行没有换行符时总字符数多 1,收益是与"整串读取后的 length"严格相等——老版本整串解析记下的进度,和新版索引记下的进度,因此可以无缝衔接。
③ 用累加值给索引做体检。 bytePos 是一路累加出来的,文件大小是独立事实,两者对不上(CRLF 文件每行会少算 1 字节,一路偏小)就说明口径假设错了——此时 ok=false,上层自动回退整串解析,绝不拿着错位的索引去读文件。这是整条链路里我最喜欢的一个设计:不信任自己的输出,让两条独立路径互相作证。
七、陷阱六:按字节切着读,凭什么不会读出乱码
索引给了字节偏移,阅读时按字节区间读当前章。字节切片怎么保证不把汉字切成两半?
/**
* 读取文件中 [startByte, endByte) 这一段字节所对应的文本。
*
* 起止都是「行首字节偏移」(由 scanChapterMarks 给出),天然落在字符边界上,
* 因此不用处理「UTF-8 续字节被切断」的问题。
* 文件是 UTF-8 + LF,按 readLine 逐行读、行间补 \n,即可原样还原这一段。
*/
export function readByteRange(path : string, startByte : number, endByte : number) : string {
答案不在读取函数里,在索引的约束里:所有字节偏移都诞生于"行首"这个结构性边界。行首意味着它前面是换行符、后面是一个新字符的起始字节——UTF-8 的多字节序列永远不可能跨越行边界。所以只要字节偏移只从 lineByteStart 这类位置产生、只在行首之间传递,"切坏汉字"在构造上就不可能发生。
这比"切完检查一下是不是合法 UTF-8"干净得多:检查只能发现错误,约束才能消灭错误。
八、没有运行时,怎么验证这一切
最后一个陷阱在流程里:UTS 代码没法在这台机器上编译运行,安卓端的索引算法怎么测?
服务端测试套件的做法是逐行复刻:用 C# 把 scanChapterMarks / readByteRange / utf8ByteLen 一比一抄一遍,然后锁三条断言:
- 扫描出的字节偏移,与一个独立实现算出的"真实行首字节位置"一致;
- 扫描出的字符下标,与"把整本读成字符串后逐字符数"的结果一致;
- 用扫描结果生成的章节,与原先"整串解析"切出来的完全一致——保证重构不悄悄改变用户的章节划分和阅读进度。
第 2、3 条断言的价值超出测试本身:它们是"流式结果 = 整串结果"这个等式的直接陈述。加上上一篇的 1 字节分块和黄金值,旦阅里所有"按块处理"的算法,都被"和整串对照"这个母题的测试罩着。
九、清单与纪律
| # | 陷阱 | 症状 | 修法 |
|---|---|---|---|
| 1 | BOM 读不满 3 字节 | BOM 混进正文,key 多 3 字节 | ReadFull 循环;判断与消费分离 |
| 2 | 运行时对"首行"定义不同 | 换端后 BOM 判断失效 | 明确每个运行时的隐式行为,测试复刻时绕开 |
| 3 | CR 落块尾 | CRLF 被算成两个换行 | 状态显式声明在块循环之外 |
| 4 | 半个字符(Decoder / 代理对) | 字符数错、emoji 处偏移 | 增量解码器;utf8ByteLen 消费代理对 |
| 5 | 双坐标系错位 | 章节定位随位置漂移 | 口径对齐 + 累加值自检 + 错了就回退 |
| 6 | 字节切片切断汉字 | 解出乱码 | 偏移只允许诞生在行首 |
写代码时的三条纪律,比清单更值得带走:
- 凡需要看邻居才能下的结论,结论必须显式存成状态,且状态的生命周期大于块;
- 不确定就攒着——把不确定性推迟到下一块或
Finish(),永远比"赌一把现在就吐"便宜; - 让两条独立路径互相作证——流式对整串、累加值对文件大小,任何一条失去交叉验证,就离悄悄出错不远了。
下一篇说一个更省心的话题:这么贵的指纹,为什么要保证一辈子只算一次。