每次提交新开 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()再丢;用段数和目录文件数检查。