Rust in Everything
基于 tantivy 0.27.0

每次提交新开 writer:段只增不减

主线位置:写入主线最后一步,提交之后 consider_merge_options 让合并线程在后台合并小段,合并结束时由 SegmentUpdater 换掉旧段、写 meta.json。合并策略和 wait_merging_threads 与 Drop 的关系见合并。

flowchart TB
  open["新开 writer"] --> add["加文档"]
  add --> cm["commit"]
  cm --> mg["后台起合并"]
  cm --> drop["drop writer"]
  drop --> kill["SegmentUpdater 停"]
  mg --> done["合并写完新段"]
  done --> lost["换段被拒 结果丢弃"]
  kill --> lost

实验 crate:exp-tantivy/pita-lab,src/bin/p11_drop.rs。MmapDirectory 临时目录,schema 是 uid、title、body,文档取自 benches/wiki.json。循环 20 次:index.writer(50_000_000)(本机 12 核,3 个索引线程)、加 50 篇、commit(),然后直接 drop,或者先 wait_merging_threads()。默认 LogMergePolicy。M2 Max、macOS 27、rustc 1.99.0,release。

现象

每 5 次提交看一次段数,最后数一下目录里的文件。连跑两次:

drop                 segments after commit r5=15 r10=30 r15=45 r20=60; files in dir 373; num_docs 1000
wait_merging_threads segments after commit r5=7 r10=4 r15=1 r20=7; files in dir 46; num_docs 1000
drop                 segments after commit r5=15 r10=30 r15=45 r20=60; files in dir 372; num_docs 1000
wait_merging_threads segments after commit r5=7 r10=4 r15=1 r20=7; files in dir 46; num_docs 1000
  • 直接 drop:每次提交 3 个线程各交一个段,20 次就是 60 个段,一次都没合并成功。
  • 目录里 373 个文件:60 个段 × 6 个文件是 360 个;多出来的里有 meta.json、.managed.json、两个锁文件,其余是合并线程写完却没人认领的段文件(孤儿文件要等下一次提交的 GC 清理,见 IndexWriter)。
  • 先 wait_merging_threads 再丢:段数在 1 到 7 之间来回,文件 46 个。
  • 文档数都对,查询结果都对,没有任何错误。代价在别处:段越多,每次查询要走的段越多;删除过的文档也一直留在段里,不会被合并清掉。

原因

commit 结束时 SegmentUpdater 会问合并策略,有候选就交给合并线程,然后立刻返回,不等合并。合并线程写完新段,要回到 SegmentUpdater 里做 end_merge:换掉旧段、写 meta.json。

IndexWriter 的 Drop 第一步就是让 SegmentUpdater 停下:

    fn drop(&mut self) {
        self.segment_updater.kill();
        self.drop_sender();
        for work in self.workers_join_handle.drain(..) {
            let _ = work.join();
        }
    }

它不等合并线程。合并照样做完,但 end_merge 要经 schedule_task 交给已经 killed 的 SegmentUpdater,被拒绝,新段成了孤儿文件。下一个 writer 不知道这次合并,看到的还是那些小段,又起一次合并,又被丢掉。机制和日志里看不到 End merge 的实验见合并。

这不是 bug。wait_merging_thread 的注释说「Terminating your application without letting merge terminate is perfectly safe」,数据确实安全;只是在「每次写入都新开 writer」的用法下,合并永远白做。

修法

用一个长命的 writer。 整个进程开一个,所有写入共享它(做法见 Failed to acquire Lockfile: LockBusy)。合并在后台落地,下一次提交时早已换好段。

必须每次新开时,提交后等合并再丢:

let mut writer: IndexWriter = index.writer(50_000_000)?;
// add_document ...
writer.commit()?;
writer.wait_merging_threads()?; // consumes the writer

上面输出的 wait_merging_threads 两行就是这样改的。wait_merging_threads 会等这次提交引发的合并,以及它们连锁引出的合并,全部落地;代价是这次写入要多等一次合并的时间。本站的搜索模块早先就是「提交后直接丢」,现在改成了等合并,见在本站中。

怎么检查

  • 看段数: index.searchable_segment_ids()?.len(),或者数 meta.json 里的 segments。默认合并策略下,长期稳定在几十以上、并且随写入次数线性增长,就是这个坑。
  • 看目录: 文件数远多于 段数 × 6,多出来的就是没人认领的合并结果。
  • 看日志: 打开 tantivy 的 debug 日志,有 Starting merge 而没有对应的 End merge。
  • 看代码: index.writer( 出现在每次写入都会走的函数里,而且后面没有 wait_merging_threads。

小结

  • commit 不等合并;writer 被 drop 时 SegmentUpdater 先停,合并写完的新段没人认领,结果丢弃。
  • 「每次写入新开 writer、提交后 drop」:20 次提交 60 个段、373 个文件,合并一次也没生效;删除的文档也一直不被清理。
  • 用一个长命的 writer,或者提交后 wait_merging_threads() 再丢;用段数和目录文件数检查。

💬 关联讨论

发起讨论
加载中...