提交
主线位置:写入主线的第四步。三篇文档已经在某个索引线程的内存池里,
commit()关掉 channel 逼线程把段写出来,领一个提交 opstamp,然后在SegmentUpdater线程上把删除落到各段、原子地换掉meta.json、删掉没用的文件、问合并策略要不要合并。commit()返回时数据已经落盘,但已经打开的读者要等重新加载才看得到。
sequenceDiagram participant App as 用户线程 participant IW as IndexWriter participant WK as 索引线程 participant SU as SegmentUpdater participant Dir as Directory App->>IW: commit IW->>IW: 换新 channel 旧发送端 drop WK->>WK: 取完积压 finalize WK->>SU: schedule_add_segment 等它完成 IW->>WK: join 再起新线程 IW->>IW: stamp 得到提交 opstamp IW->>SU: schedule_commit SU->>Dir: purge_deletes 写 del 文件 SU->>Dir: save_metas 原子写 meta.json SU->>Dir: 垃圾回收 SU->>SU: consider_merge_options SU-->>App: 返回 opstamp
本页涉及 src/indexer/ 下的 prepared_commit.rs(53 行)、index_writer.rs 的提交和删除部分、delete_queue.rs(305 行)和 segment_updater.rs(1170 行,实现约 720 行)。
实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建;索引放在 tempfile 建的临时目录,用 MmapDirectory。schema 是 uid(STRING | STORED)、title(TEXT | STORED)、body(TEXT)。
prepare_commit:切断 channel,等线程交段
commit 就是 self.prepare_commit()?.commit()。prepare_commit:
pub fn prepare_commit(&mut self) -> crate::Result<PreparedCommit<'_, D>> {
// ...
debug!("Preparing commit");
// this will drop the current document channel
// and recreate a new one.
self.recreate_document_channel();
let former_workers_join_handle = std::mem::take(&mut self.workers_join_handle);
for worker_handle in former_workers_join_handle {
let indexing_worker_result = worker_handle
.join()
.map_err(|e| TantivyError::ErrorInThread(format!("{e:?}")))?;
indexing_worker_result?;
self.add_indexing_worker()?;
}
let commit_opstamp = self.stamper.stamp();
let prepared_commit = PreparedCommit::new(self, commit_opstamp);
debug!("Prepared commit {commit_opstamp}");
Ok(prepared_commit)
}
recreate_document_channel 换上新的 channel,旧的发送端随之 drop。老的索引线程把旧 channel 里剩下的文档取完,不管段满没满都 finalize,schedule_add_segment(...).wait() 等 SegmentUpdater 收下这个段,再发现 channel 已空,退出。prepare_commit 逐个 join 它们,每 join 一个就为新 channel 起一个新线程。所以一次提交会把全部索引线程换一遍,「每个拿到过文档的线程交一个段」也是从这里来的:两万篇短文档、3 个线程,提交后正好 3 个段(IndexWriter 的实验)。
join 完以后,这次提交包含哪些文档就确定了:所有段都已交给 SegmentUpdater,而 prepare_commit 拿着 &mut self,期间不可能有新文档进来。这时再领一个 opstamp 作为提交的 opstamp。PreparedCommit 继续借着 &mut IndexWriter,可以 set_payload 附一段字符串(写进 meta.json 的 payload),然后 commit() 或者 abort()(就是 rollback())。
opstamp
opstamp 是 writer 上所有操作的全序号,由一个共享的 AtomicU64 发放(Stamper)。writer 创建时从 meta.json 的 opstamp 接着往下数(新 writer 的第一个操作领到的就是这个值),所以它跨提交、跨进程重启都不会倒退,调用方可以拿它和自己的消息队列对齐:meta.json 里的 opstamp 是 X,就说明 opstamp 小于 X 的操作都已经持久化。
领号的不只是用户操作。add_document、delete_term、prepare_commit 各领一个;run 领 n + 1 个;还有一处容易漏掉的:SegmentUpdater 每次 consider_merge_options 都要 self.stamper.stamp(),作为未提交段的合并目标 opstamp。而它在每次收下一个新段、每次提交结束、每次合并结束时都会跑一次。
commit() 返回几?新建的空索引,writer_with_num_threads(1, 20_000_000),依次 add_document 三篇(返回 0、1、2),然后 commit()。返回值是几?把线程数换成 3(writer_with_num_threads(3, 60_000_000))呢?
commit() 的文档写的是「Commit returns the opstamp of the last document that made it in the commit」。实际返回的是提交自己的 opstamp,它一定比这次提交里最后一篇文档的大,中间隔着几个号取决于交了几个段。
删除:一个按 opstamp 排序的队列
tantivy 没有主键,删除按 term(或者内部的按 query)来。delete_term 把 term 包成 TermQuery,交给 delete_query:
pub fn delete_query(&self, query: Box<dyn Query>) -> crate::Result<Opstamp> {
let weight = query.weight(EnableScoring::disabled_from_schema(&self.index.schema()))?;
let opstamp = self.stamper.stamp();
let delete_operation = DeleteOperation {
opstamp,
target: weight,
};
self.delete_queue.push(delete_operation);
Ok(opstamp)
}
队列里放的是已经建好的 Weight 和 opstamp,还没有去任何段里找文档。delete_term 对 delete_query 的错误一概吞掉,只返回一个新 opstamp(注释写着「For backward compatibility, if Term is invalid for the index, do nothing」),所以 term 的字段类型不对时不会报错。
DeleteQueue 是一个「单生产者、多消费者的广播队列」:写入端是一个 Vec,消费者各拿一个 DeleteCursor。游标读到末尾时,把写入端积攒的操作冻成一个只读块挂到链表上,块用 Arc 共享,没有游标指着就被释放。每个索引线程、每个 SegmentEntry 都有自己的游标,各自记着「我已经处理到哪个删除了」。
判断一篇文档该不该删只有一条规则,DocToOpstampMapping::is_deleted:
Self::WithMap(doc_opstamps) => {
let doc_opstamp = doc_opstamps[doc_id as usize];
doc_opstamp < delete_opstamp
}
Self::None => true,
删除只作用于比它早的文档。这让「先删旧的、再加新的」在同一次提交里成立:
- 段定稿时。 索引线程开始一个新段之前,把游标
skip_to到这批第一篇文档的 opstamp,更早的删除与它无关。finalize返回每篇文档的 opstamp,apply_deletes用WithMap逐条执行队列里 opstamp 不超过本段最大文档 opstamp 的删除。同一段里比删除晚加入的文档不受影响。 - 提交时。
purge_deletes对每个段调advance_deletes,把游标后面直到提交 opstamp 的删除全部执行。这时用的是DocToOpstampMapping::None:段里剩下的删除都比段里任何文档晚(早的那些在第 1 步已经消费掉了),一律生效。
有新删除的段写一个新的删除文件,文件名是 段 id.提交 opstamp.del,内容是存活文档的位图(格式见 fieldnorm 与删除位图)。段文件本身不可变,删除只是换一个 .del。下面的实验里三篇文档提交后,delete_term(uid = "b") 再提交:
meta.json segments: [
{
"deletes": {
"num_deleted_docs": 1,
"opstamp": 7
},
"max_doc": 3,
"segment_id": "86f75da2-267e-4fb4-8637-724899e9743a"
}
]
meta.json opstamp: 7
目录里多了 86f75da2267e4fb48637724899e9743a.7.del。max_doc 还是 3,删除的文档仍占着 doc id 和磁盘空间,要等合并重写这个段才真正消失,见合并。
SegmentUpdater:一个线程串行化所有元数据变更
SegmentUpdater 持有当前的 IndexMeta、段管理器(已提交、未提交两组段)、合并策略和两个线程池。schedule_task 把一个闭包扔进只有一个线程的 pool,返回一个 FutureResult;收段、提交、合并结束、GC 全部排在这一个线程上,所以它们之间不需要别的锁,meta.json 的每个版本也一定是某个完整状态。killed 之后 schedule_task 直接返回 Segment updater killed。
schedule_commit 是提交的主体:
self.schedule_task(move || {
let segment_entries = segment_updater.purge_deletes(opstamp)?;
segment_updater.segment_manager.commit(segment_entries);
segment_updater.save_metas(opstamp, payload)?;
let _ = garbage_collect_files(segment_updater.clone());
segment_updater.consider_merge_options();
Ok(opstamp)
})
purge_deletes:上一节的第 2 步。segment_manager.commit:把所有段(包括刚交来的未提交段)放进「已提交」组。save_metas:写meta.json,下一节。- GC:删掉不在任何段的文件清单里的托管文件,出错也不影响提交结果(
let _ =)。机制见 Directory。 consider_merge_options:问合并策略,有合并就扔给合并线程池,不等它完成。见合并。
PreparedCommit::commit 是 commit_future().wait(),所以用户线程一直阻塞到这五步做完。GC 在合并之前:这次提交淘汰的文件(例如被新 .del 取代的旧 .del)马上就删,合并产生的垃圾要等合并结束那次 GC。
meta.json 的原子写
pub(crate) fn save_metas(metas: &IndexMeta, directory: &dyn Directory) -> crate::Result<()> {
debug!("save metas");
let mut buffer = serde_json::to_vec_pretty(metas)?;
// Just adding a new line at the end of the buffer.
writeln!(&mut buffer)?;
// ...
directory.sync_directory()?;
directory.atomic_write(&META_FILEPATH, &buffer[..])?;
debug!("Saved metas {:?}", serde_json::to_string_pretty(&metas));
Ok(())
}
调用它的方法先把已提交的段按 max_doc 从大到小排序(注释说是为了多线程搜索时更均衡),再组装 IndexMeta:index_settings、segments、schema、opstamp、可选的 payload。
MmapDirectory 的 atomic_write 在同一目录下建临时文件、写入、sync_data,再 persist(rename)成 meta.json。注释解释了为什么临时文件不放系统临时目录:两者可能不在同一个文件系统上,跨文件系统的 rename 不是原子的。rename 之前的 sync_directory 让新段文件的目录项先落盘。于是任何时刻磁盘上的 meta.json 要么是旧版本,要么是新版本,它引用的段文件都已经完整。崩溃后重新打开,就回到最后一次成功写入的 meta.json,没进 meta.json 的段文件是孤儿,下次 GC 删掉。
读者也是靠这个文件知道有了新提交:MmapDirectory 监听 meta.json 的变化,默认的 ReloadPolicy::OnCommitWithDelay 收到通知后在后台重新加载。
问: commit() 已经返回了,为什么马上搜还是搜不到?
答: 「提交」只保证写进了目录。已经打开的 IndexReader 按自己的 ReloadPolicy 决定什么时候换 Searcher。实测同一个进程里:默认 reader 在 commit() 返回那一刻还是 0 篇,轮询到第 440 ms 左右才看到 3 篇(三次运行 439、443、447 ms);ReloadPolicy::Manual 的 reader 调一次 reload() 立刻看到。已经拿在手里的 Searcher 是快照:删除提交后,旧 searcher 仍是 3 篇,reload() 后的新 searcher 是 2 篇。0.27.0 给 commit 的文档补了一段话专门说这件事(PR #3057)。reader 怎么加载、为什么有延迟,见 IndexReader 与 Searcher。
commit_opstamp() 不会更新
commit_opstamp 的文档是「Returns the opstamp of the last successful commit」,返回字段 committed_opstamp。在 0.27.0 的源码里,这个字段只在 IndexWriter::new 里赋过一次值(meta.json 里的 opstamp),提交路径上没有任何地方更新它:
[1 thread(s)] adds -> 0 1 2; commit -> 4; meta opstamp 4; commit_opstamp() 0
[1 thread(s)] delete -> 6; add -> 7; prepared 9; commit -> 9; meta opstamp 9 payload Some("upsert b"); commit_opstamp() 0
[1 thread(s)] run(2 ops) -> 13; commit -> 15; commit_opstamp() 0
三次提交以后 commit_opstamp() 还是 0。用到这个字段的还有两处:rollback 的返回值(rollback 会新建 writer,重新从 meta.json 读,所以返回值是对的),和 delete_all_documents:
pub fn delete_all_documents(&self) -> crate::Result<Opstamp> {
// Delete segments
self.segment_updater.remove_all_segments();
// Return new stamp - reverted stamp
self.stamper.revert(self.committed_opstamp);
Ok(self.committed_opstamp)
}
它把计数器倒回到这个过时的值。在同一个 writer 上先提交、再 delete_all_documents:
delete_all: first commit 6; delete_all_documents -> 0; next add -> 0; commit -> 2; meta opstamp 2
meta.json 的 opstamp 从 6 退回到 2,「opstamp 不会倒退」不再成立,拿它和外部队列对齐的程序会以为丢了操作。每次新建 writer、只做一次提交的用法(本站的 replace_all 就是)不受影响,因为新 writer 的 committed_opstamp 就是当前 meta.json 的值。0.26.1 的代码相同,0.27.0 之后 main 上也没有改动。
committed_opstamp 不再更新PR #231(「issue/230 Add an optional commit message.」,首次发布在 0.4.3)为了让提交能带一段 payload,把原来的 commit 拆成 prepare_commit 加 PreparedCommit。拆之前,commit 里有一行 self.committed_opstamp = self.stamper.stamp();,提交的 opstamp 就记在 writer 上;拆的时候这一行被删掉,opstamp 改由 prepare_commit 里的局部变量 commit_opstamp 交给 PreparedCommit,而新加的 PreparedCommit::commit 只调 segment_updater().commit(self.opstamp, self.payload),没有回写 writer 的字段。从那以后,committed_opstamp 只在 writer 新建时赋值,也就是上面实验看到的样子。
小提交的代价
一次提交至少交出一个新段,哪怕只有一篇文档。单篇文档的提交在本机的耗时(各 30 次,取最小、中位、最大;NoMergePolicy,uptime 负载约 5):
ram 1 thread(s) reused: min 0.80ms median 0.84ms max 3.03ms
ram 1 thread(s) fresh: min 0.93ms median 1.00ms max 1.09ms
ram 3 thread(s) reused: min 0.80ms median 0.87ms max 0.97ms
ram 3 thread(s) fresh: min 0.98ms median 1.04ms max 1.17ms
mmap 1 thread(s) reused: min 53.88ms median 61.78ms max 67.14ms
mmap 1 thread(s) fresh: min 55.00ms median 61.01ms max 70.17ms
mmap 3 thread(s) reused: min 54.03ms median 61.09ms max 95.39ms
mmap 3 thread(s) fresh: min 53.97ms median 60.00ms max 70.17ms
fresh 是每次新建 writer、提交、drop,reused 是一个 writer 反复提交。新建 writer 只多 0.15 ms 左右;RamDirectory 上不到 1 ms,换成磁盘就是 60 ms。差别在落盘同步:同一台机器上单次 File::sync_data 约 3.9 ms(20 个 4 KiB 文件的中位数),而一次单段提交要同步的地方不少:6 个段文件各在 terminate 时同步一次;每个新文件第一次打开时把 .managed.json 原子重写一遍(也要 sync_data);meta.json 的临时文件一次,sync_directory 一次。这是 macOS 上的数字,Rust 标准库在 macOS 上的 sync_data 走 F_FULLFSYNC,Linux 上的代价不同,本卷没有测。
比耗时更要紧的是段数。每次提交多一个段,删除只标记不回收,段数和删除比例都只能靠合并降下来。一个 writer 反复提交时,凑够 8 个小段就会合并;本站那种「每次 upsert 新开 writer、提交完立刻 drop」的用法,合并永远来不及交结果,段数随提交次数线性增长,实验在合并。
动脑筋答案
[1 thread(s)] adds -> 0 1 2; commit -> 4; meta opstamp 4; commit_opstamp() 0
[3 thread(s)] adds -> 0 1 2; commit -> 6; meta opstamp 6; commit_opstamp() 0
- 单线程:三篇文档领了 0、1、2。
prepare_commit关 channel,唯一的线程交出一个段,SegmentUpdater收段时consider_merge_options领走 3;prepare_commit自己领 4。返回 4。提交结束时的consider_merge_options又领走 5,所以下一次delete_term拿到 6(上面commit_opstamp()那组输出的第二行)。 - 三线程:三篇文档被三个线程各取一篇,交了三个段,收段时领走 3、4、5,提交的 opstamp 是 6。三次运行结果相同,但这取决于线程调度:三篇都被同一个线程取走时,只会交一个段,返回 4。
小结
prepare_commit换掉 channel,join 全部索引线程(每个都把手里的段定稿交出),再起新线程,然后领提交 opstamp;PreparedCommit可以带 payload,commit()阻塞到SegmentUpdater做完。- opstamp 是全序:删除只作用于 opstamp 比它小的文档。除了用户操作,收段、提交、合并结束时的
consider_merge_options也领号,所以commit()的返回值不是「最后一篇文档的 opstamp」。 - 删除先进队列(存的是
Weight),段定稿时按每篇文档的 opstamp 执行一次,提交时把剩下的执行完,结果写成新的段 id.opstamp.del。 SegmentUpdater一个线程串行化所有元数据变更;提交 = 落删除、段转为已提交、原子写meta.json(同目录临时文件 +sync_data+ rename)、GC、考虑合并。commit()返回只代表落盘;默认 reader 本机约 440 ms 后才看到,Manual要自己reload()。commit_opstamp()从 0.4.3 起就不更新,同一个 writer 提交后再delete_all_documents会让meta.json的 opstamp 倒退。- 本机一次单篇提交 60 ms,几乎全花在同步上;每次提交多一个段,段数要靠合并降。