Rust in Everything
基于 tantivy 0.27.0

提交

主线位置:写入主线的第四步。三篇文档已经在某个索引线程的内存池里,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。而它在每次收下一个新段、每次提交结束、每次合并结束时都会跑一次。

❗IMPORTANT
动脑筋:三篇文档提交一次,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,

删除只作用于比它早的文档。这让「先删旧的、再加新的」在同一次提交里成立:

  1. 段定稿时。 索引线程开始一个新段之前,把游标 skip_to 到这批第一篇文档的 opstamp,更早的删除与它无关。finalize 返回每篇文档的 opstamp,apply_deletes 用 WithMap 逐条执行队列里 opstamp 不超过本段最大文档 opstamp 的删除。同一段里比删除晚加入的文档不受影响。
  2. 提交时。 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)
        })
  1. purge_deletes:上一节的第 2 步。
  2. segment_manager.commit:把所有段(包括刚交来的未提交段)放进「已提交」组。
  3. save_metas:写 meta.json,下一节。
  4. GC:删掉不在任何段的文件清单里的托管文件,出错也不影响提交结果(let _ =)。机制见 Directory。
  5. consider_merge_options:问合并策略,有合并就扔给合并线程池,不等它完成。见合并。

PreparedCommit::commit 是 commit_future().wait(),所以用户线程一直阻塞到这五步做完。GC 在合并之前:这次提交淘汰的文件(例如被新 .del 取代的旧 .del)马上就删,合并产生的垃圾要等合并结束那次 GC。

meta.json 的原子写

save_metas:

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 收到通知后在后台重新加载。

📝NOTE
没有蠢问题

问: 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 上也没有改动。

📝NOTE
冷知识:两段式提交是 0.4.3 加的,也是从那时起 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,几乎全花在同步上;每次提交多一个段,段数要靠合并降。

💬 关联讨论

发起讨论
加载中...