Rust in Everything
基于 tantivy 0.27.0

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 写。

💬 关联讨论

发起讨论
加载中...