Bus error: 10
主线位置:磁盘主线第二步,
MmapDirectory把段文件映射进内存,之后每次查询直接读映射。mmap 的收益和代价见设计取舍,MmapDirectory的 mmap 缓存、只校验 footer 不算 crc 见 Directory。
flowchart TB
rd["reader 映射 .idx"] --> chg{"外部怎么换文件"}
chg -->|就地写 cp| same["同一个 inode 变短"]
chg -->|新文件改名| newi["新 inode"]
same --> bus["读越界 SIGBUS"]
newi --> old["旧映射照常读"]
bus --> dead["进程被杀"]
实验 crate:exp-tantivy/pita-lab,src/bin/p13_sigbus.rs:build 写 5000 篇维基摘要成一个段(.idx 951,202 字节);serve 打开 reader,每 300 ms 查一次 old man,共 12 次;check 调 Index::validate_checksum。serve 跑起来 1.2 秒后,在另一个 shell 里换掉 .idx。M2 Max、macOS 27、rustc 1.99.0,release。
现象
cp 一个短文件覆盖 .idx(模拟「把备份拷回去」「两次构建的产物拷到同一个目录」):
query #1: Ok(265)
query #2: Ok(265)
query #3: Ok(265)
query #4: Ok(265)
$ cp short.idx c3d2c176ff794c21a17c941308ef4723.idx
bash: line 1: 98034 Bus error: 10 target-pita/release/p13_sigbus serve pita-lab/ix/sigbus
exit status 138
第 5 次查询没有返回 Err,也没有 panic,进程被信号杀掉,退出码 138(128 + SIGBUS 的 10)。Linux 上同样的信号是 7 号,shell 打印的是 Bus error。
rsync 两种写法(各自重建索引、重新起 serve,只看最后两行;括号里是 .idx 的 inode 变化和新大小):
$ rsync -a src/ sigbus/ (inode 472695758 -> 472695771, size 16384)
query #12: Ok(265)
exit status 0
$ rsync -a --inplace src/ sigbus/ (inode 472696330 -> 472696330, size 16384)
query #5: Ok(265)
exit status 138
删掉整个目录、换上新目录:
query #4: Ok(265)
$ rm -rf sigbus && mv sigbus-new sigbus
query #5: Ok(265)
...
query #12: Ok(265)
exit status 0
原因
段文件是只读映射。MmapDirectory 打开文件后 mmap 整个文件,关掉文件句柄,之后读倒排表就是读这块内存。
cp和rsync --inplace是对同一个 inode 截断再写:映射还指着这个文件,文件变短后,访问超出新长度的那几页,内核给进程发 SIGBUS。读内存没有返回值,tantivy 没有机会把它变成Err。rsync默认先写临时文件再改名,rm、mv只动目录项:旧 inode 没人删得掉,还被映射引用着,进程继续读旧内容,直到它重新打开索引。
tantivy 自己从不改写已经写好的文件:MmapDirectory::open_write 要求文件不存在,meta.json 走「临时文件加改名」。所以只要外部程序不就地改写索引目录,就不会发生。这是 mmap 的固有代价,不是 bug。
打开时也不做完整校验:footer 的 magic 和版本在打开文件时检查(截短的文件打不开,见下文),crc32 只有 Index::validate_checksum 才算。
修法
- 部署新索引:建在新目录里,整个换。 写进
index-v2/,查询进程切到新路径,或者rm -rf旧目录再mv新目录(上面第三组输出),然后让查询进程重新Index::open_in_dir。旧 reader 在重新打开之前继续用旧文件,不会崩。 - 同步或备份工具不要用就地写。
rsync不加--inplace、--append;cp拷到新目录,不要拷到正在被读的目录上。 - 索引目录只给 tantivy 写。 其他程序(清理脚本、同步盘客户端)不要碰。
怎么检查
拷贝或恢复之后、交给查询进程之前,用另一个进程校验一遍:
let index = Index::open_in_dir(&dir)?;
println!("validate_checksum: {:?}", index.validate_checksum());
三种状态(截短的 .idx、等长但中间翻转了一个字节的 .idx、完好的索引):
validate_checksum: Err(OpenReadError(IoError { io_error: Custom { kind: InvalidData, error: "Footer magic byte mismatch. File corrupted or index was created using old an tantivy version which is not supported anymore. Please use tantivy 0.15 or above to recreate the index." }, filepath: "c3d2c176ff794c21a17c941308ef4723.idx" }))
validate_checksum: Ok({"0ca9d8dbfba742c98c9450f09a34e2c8.idx"})
validate_checksum: Ok({})
- 截短的文件连 footer 都没有了,返回
Err;错误文字说的是「旧版本 tantivy」,其实是文件坏了。 - 长度不变、内容被改,只有算 crc 才发现,返回坏掉的文件名。
- 返回空集合才说明所有段文件完好。它要把每个段文件完整读一遍,适合在部署流程里做,不适合放在每次启动的热路径上。
运行中的进程如果被 Bus error 杀掉,先查同一时刻有谁在写索引目录:同步任务、备份恢复、部署脚本。
小结
- 段文件被 mmap;外部程序就地截断或改写它(
cp、rsync --inplace),下一次读到那几页时进程被 SIGBUS 杀掉,退出码 138,没有错误可接。 - 新文件改名(
rsync默认)、删目录再mv,都不影响已经打开的 reader。 - 部署时建新目录整体替换,拷贝之后用
validate_checksum检查;索引目录只让 tantivy 写。