Rust in Everything
基于 tantivy 0.27.0

一次写入

主线位置:写入主线全程。从 Index::create_in_dir 到 commit 返回、meta.json 落盘;每一步只讲到看清全貌的深度,细节链到 03 写入。

sequenceDiagram
  participant M as 主线程
  participant W as 索引线程 x3
  participant SU as segment_updater
  participant D as 目录
  M->>D: create_in_dir 写 meta.json
  M->>D: writer 拿锁 起线程
  M->>W: add_document x3 经 channel
  W->>D: 新段 打开六个文件
  M->>W: prepare_commit 换 channel
  W->>D: finalize 写满文件
  W->>SU: schedule_add_segment
  M->>SU: commit
  SU->>D: 原子写 meta.json
  SU->>SU: GC 与考虑合并
  M->>W: 新一批索引线程

程序

实验 intro-run 的 write 子命令(release):schema 和三篇文档照抄 examples/basic_search.rs,title 是 TEXT | STORED,body 是 TEXT;writer 的预算和例子一样是 50 MB。程序在几个时刻打印目录清单,并用 macOS 的 sample 抓本进程的全部线程名;另外装了一个 log 的 logger,把 tantivy 的 debug! 连同线程名打出来:

fn write(dir: &Path, budget: usize) -> tantivy::Result<()> {
  let s = schema();
  let index = Index::create_in_dir(dir, s.clone())?;
  // ... 这里和下面各处还打印目录清单
  let mut w: IndexWriter = index.writer(budget)?;
  threads("after writer()");
  add_three(&w, &s)?; // 三次 add_document,打印返回的 opstamp
  std::thread::sleep(std::time::Duration::from_millis(300)); // 由环境变量 PAUSE 打开
  let op = w.commit()?;
  eprintln!("commit opstamp: {op}");
  threads("after commit()");
  w.wait_merging_threads()?;
  Ok(())
}

tantivy 0.27.0 把写入路径上的 info! 都降成了 debug!(#3013),所以下面的日志要把级别开到 debug 才看得到;0.26.x 上同样的几行是 info 级别。

建索引:只有 schema 的 meta.json

Index::create_in_dir 在空目录里写下两个文件:

    2.32ms [main] DEBUG tantivy::indexer::segment_updater: save metas
   13.43ms [main] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
   24.18ms [main] DEBUG tantivy::directory::mmap_directory: Atomic Write "meta.json"
...
-- after create_in_dir
    14  .managed.json
   667  meta.json

这时的 meta.json 只有 index_settings、schema 和 "segments": [],opstamp 是 0。.managed.json 是 ManagedDirectory 记的「这些文件归 tantivy 管」的清单,垃圾回收只删清单里的文件。两个文件都是原子写:写临时文件、sync_data、再改名。原文见一个段。

writer:12 核上为什么是 3 个索引线程

Index::writer 只有一个参数,总内存预算:

    pub fn writer<D: Document>(
        &self,
        memory_budget_in_bytes: usize,
    ) -> crate::Result<IndexWriter<D>> {
        let mut num_threads = std::cmp::min(available_parallelism()?.get(), MAX_NUM_THREAD);
        let memory_budget_num_bytes_per_thread = memory_budget_in_bytes / num_threads;
        if memory_budget_num_bytes_per_thread < MEMORY_BUDGET_NUM_BYTES_MIN {
            num_threads = (memory_budget_in_bytes / MEMORY_BUDGET_NUM_BYTES_MIN).max(1);
        }
        self.writer_with_num_threads(num_threads, memory_budget_in_bytes)
    }

本机 available_parallelism() 是 12,先取 min(12, 8) = 8;50 MB 分给 8 个线程每个 6.25 MB,不到 MEMORY_BUDGET_NUM_BYTES_MIN(15 MB),于是改成 50_000_000 / 15_000_000 = 3 个线程,每个分 16.7 MB。sample 看到的线程:

== threads after writer(): 9 ==
   com.apple.main-thread  (serial)
   merge_thread_0
   merge_thread_1
   merge_thread_2
   merge_thread_3
   segment_updater
   thrd-tantivy-index0
   thrd-tantivy-index1
   thrd-tantivy-index2
-- after writer()
    14  .managed.json
     0  .tantivy-writer.lock
   667  meta.json

三类线程各有来处:thrd-tantivy-index* 是 add_indexing_worker 用 std::thread 起的索引线程;segment_updater 和 merge_thread_* 是 SegmentUpdater::create 建的两个 rayon 线程池,前者固定 1 个线程,后者是 IndexWriterOptions 里默认的 4 个,和核数无关。目录里多了 .tantivy-writer.lock:同一个目录同一时刻只能有一个 writer。预算换成 200 MB 时,同一台机器上是 8 个索引线程。线程数、预算和锁的细节见 IndexWriter。

❗IMPORTANT
动脑筋:三篇文档,提交后有几个段?commit() 返回几?

就是上面这段程序:12 核的 M2 Max,index.writer(50_000_000),依次 add_document 三篇,commit() 一次。三次 add_document 返回的 opstamp 是 0 1 2。提交以后 meta.json 里有几个段?commit() 返回的 opstamp 是多少?答案在动脑筋答案。

add_document:opstamp 和 channel

add_document 只做两件事:

    pub fn add_document(&self, document: D) -> crate::Result<Opstamp> {
        let opstamp = self.stamper.stamp();
        self.send_add_documents_batch(smallvec![AddOperation { opstamp, document }])?;
        Ok(opstamp)
    }

opstamp 是一个全局递增的 u64,Stamper 里就是一个 AtomicU64 的 fetch_add。它给每个操作排序:提交的 opstamp 为 n,就包含所有 opstamp 小于 n 的增删。文档本身被包成只有一个元素的批次,送进 IndexWriter::new 建的 crossbeam 有界 channel。容量常量叫 PIPELINE_MAX_SIZE_IN_DOCS(10000),但 channel 里装的是批次:用 run 一次送一大批时,10000 个槽位能装远不止 10000 篇。channel 满了 add_document 就阻塞,这是写入的背压。

调用方线程到这里就返回了,分词、建倒排表都不在它身上。

索引线程:文档进内存池,文件先打开

三个索引线程都在同一个 channel 上等。谁先拿到一个批次,谁就为它新建一个段:index_documents 调 SegmentWriter::for_segment,再把收到的文档一篇篇交给 SegmentWriter::add_document:倒排索引插件分词、把词和位置记进 stacker 的内存池、记 fieldnorm;快速字段插件记列值;存储插件把 STORED 字段序列化进文档存储的缓冲。每批之后检查一次内存,超过 预算 - 1 MB 就把这个段定稿,换一个新段。

for_segment 一建好,段的六个文件就打开了。日志里三篇文档加完、提交之前,三个线程已经各开了六个文件(程序在这里睡了 300 ms):

 1175.91ms added 3 docs, sleeping 300 ms before commit
 1176.41ms [thrd-tantivy-index2] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
 1181.18ms [thrd-tantivy-index2] DEBUG tantivy::directory::mmap_directory: Open Write "ed3c66cec91d4a719a28f4e54c98a532.fieldnorm"
 1181.19ms [thrd-tantivy-index0] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
 1186.20ms [thrd-tantivy-index0] DEBUG tantivy::directory::mmap_directory: Open Write "39699d13823c45e2b5242220a91259a6.fieldnorm"
 1186.26ms [thrd-tantivy-index1] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
 1191.08ms [thrd-tantivy-index1] DEBUG tantivy::directory::mmap_directory: Open Write "592a496b97fb426e8d5b469a16aa5dd0.fieldnorm"
 1191.09ms [thrd-tantivy-index2] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
 1196.23ms [thrd-tantivy-index2] DEBUG tantivy::directory::mmap_directory: Open Write "ed3c66cec91d4a719a28f4e54c98a532.term"
...
 1252.10ms [thrd-tantivy-index1] DEBUG tantivy::directory::mmap_directory: Atomic Write ".managed.json"
 1256.20ms [thrd-tantivy-index1] DEBUG tantivy::directory::mmap_directory: Open Write "592a496b97fb426e8d5b469a16aa5dd0.store"

打开的顺序是 fieldnorm、term、idx、pos、fast、store,正是三个内置插件在 for_segment 里创建的顺序。每次打开前,ManagedDirectory::open_write 先把文件名登记进 .managed.json 并原子重写它:这样进程崩在半路,下次 GC 也知道这些残文件归它删。登记在一把写锁下进行,三个线程轮流,每次约 4 到 5 ms(大头是 sync_data),18 个文件花了约 80 ms,比索引三篇文档本身慢得多。内存池和这一步的细节见 SegmentWriter 与内存池。

prepare_commit:每个线程交出一个段

commit 是 prepare_commit()?.commit()。prepare_commit 先换一个新 channel,旧 channel 的发送端随之丢掉;索引线程把旧 channel 读空后,index_documents 的循环结束,调 finalize 把段写满,再通过 schedule_add_segment 把段交给 segment_updater,然后线程退出。主线程逐个 join,每 join 一个就立刻起一个新的:

 1480.94ms [main] DEBUG tantivy::indexer::index_writer: Preparing commit
 1485.73ms [thrd-tantivy-index1] DEBUG tantivy::directory::mmap_directory: Open Read "592a496b97fb426e8d5b469a16aa5dd0.fieldnorm"
 1489.72ms [thrd-tantivy-index2] DEBUG tantivy::directory::mmap_directory: Open Read "ed3c66cec91d4a719a28f4e54c98a532.fieldnorm"
 1489.72ms [thrd-tantivy-index0] DEBUG tantivy::directory::mmap_directory: Open Read "39699d13823c45e2b5242220a91259a6.fieldnorm"
 1551.73ms [main] DEBUG tantivy::indexer::index_writer: Prepared commit 6

finalize 按倒排索引、快速字段、文档存储的顺序序列化(finalize_inner)。那三行 Open Read 是倒排索引插件在写倒排表之前,把自己刚写完的 .fieldnorm 读回来:倒排表每 128 篇一块,每块要记下让 BM25 词频项最大的那一组(字段长度、词频),给查询时的 Block-Max WAND 估上界,算它要用字段长度。提交之后的线程名从 thrd-tantivy-index0..2 变成了 3..5:

== threads after commit(): 9 ==
   com.apple.main-thread  (serial)
   merge_thread_0
   merge_thread_1
   merge_thread_2
   merge_thread_3
   segment_updater
   thrd-tantivy-index3
   thrd-tantivy-index4
   thrd-tantivy-index5

编号来自 worker_id,它只增不减。每次提交都换一批新线程,旧的段写完就结束,不会有一个线程跨两次提交攒同一个段。

📝NOTE
冷知识:段的六个文件在 0.27.0 改由插件写出

0.26.x 的 SegmentWriter 持有一个 segment_serializer: SegmentSerializer,for_segment 里调 SegmentSerializer::for_segment(segment) 一次把段的文件全部打开,finalize 再统一序列化。这个名字可以追到 2016 年的 43b69c7b3(「renamed module codec -> segment serializer」)。0.27.0 收进了 #2993(0401b4578,「feat: Extensible segment components via plugin trait」),删掉了 SegmentSerializer。提交说明写的动机是「Today every segment component — postings, fast fields, field norms, store — is hardcoded into several places. Adding a new per-segment data structure means forking Tantivy and editing each of those sites.」,并说内置组件「are themselves reimplemented as plugins」。现在的 SegmentWriter 有三个字段 inverted_index、fast_fields、store_writer,分别是三个内置 SegmentPlugin 的写入器,自定义插件排在它们后面。首次发布于 0.27.0,CHANGELOG 把「SegmentSerializer is removed」记在 Breaking change 里。上面日志里文件打开的顺序,就是这三个写入器依次创建的顺序。

commit:segment_updater 写 meta.json

PreparedCommit::commit 把提交交给 segment_updater 线程,并阻塞等它做完。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)
        })

依次是:把删除落到各段(本例没有删除,什么都不写);把未提交的段转为已提交;原子写 meta.json;垃圾回收;问合并策略要不要合并。日志:

 1551.77ms [main] DEBUG tantivy::indexer::prepared_commit: committing 6
 1551.82ms [segment_updater] DEBUG tantivy::indexer::segment_updater: save metas
 1555.84ms [segment_updater] DEBUG tantivy::directory::mmap_directory: Atomic Write "meta.json"
 1559.94ms [segment_updater] DEBUG tantivy::indexer::segment_updater: Saved metas Ok("{\n  \"index_settings\": {\n    \"docstore_compression\": \"lz4\",\n    \"docstore_blocksize\"...
 1560.00ms [segment_updater] DEBUG tantivy::indexer::segment_updater: Running garbage collection
 1560.02ms [segment_updater] DEBUG tantivy::directory::managed_directory: Garbage collect
 1560.13ms [segment_updater] DEBUG tantivy::directory::mmap_directory: Releasing lock ".tantivy-meta.lock"
commit opstamp: 6

所有改元数据的操作都排在这一个线程上,所以它们天然是串行的,不用再加锁协调。GC 时拿的是 .tantivy-meta.lock,防止别的进程的 reader 正在按旧 meta.json 打开段时文件被删。三个段都只有一篇文档,合并策略 LogMergePolicy 默认至少 8 个段才合并,这次什么都不做。提交后的目录、meta.json 原文和每个文件的大小见一个段;删除、meta.json 的原子写和 opstamp 的含义见提交。

合并在后台,writer 一丢就没了

commit() 返回时,合并(如果有)还在 merge_thread_* 上跑。wait_merging_threads 会等它们做完;直接 drop writer 则走 Drop,第一行就是 self.segment_updater.kill(),之后合并的结果不会再被登记。

实验 upserts 模仿「每次更新新建一个 writer、提交后就丢掉」的用法:循环 20 次,每次 writer(50_000_000)、加三篇、commit,一组直接 drop,一组先 wait_merging_threads(release):

rounds=20 wait=false segments=60 files=367
rounds=20 wait=true segments=7 files=46

不等的那组,每次提交都留下 3 个只有一篇文档的段,20 次就是 60 个段,一次都没合并成。合并其实发起了:打开 debug 日志能看到 segment_updater 打出 Starting merge,合并线程也在跑;但合并做完要回到 segment_updater 登记,那时 writer 已经被丢掉,schedule_task 看到 is_alive() 为假就直接返回错误,合并出的段不会进 meta.json。不打日志连跑 5 次都是 segments=60(文件数 367 或 368,多出来的是没登记的合并产物,留给之后的 GC);打开日志拖慢以后,偶尔有一次合并赶在下一次提交前登记成功。等的那组,段数被合并压到了 7 个。用 0.26.1 跑同一段程序,两个数字相同(segments=60 和 segments=7)。合并策略和这个现象背后的机制见合并。

动脑筋答案

段数是 3,commit() 返回 6。

段数:三个索引线程在同一个 channel 上等,三个单篇的批次正好被三个线程各拿走一个,每个线程为它拿到的第一篇文档新建一个段。实验 segcount 在内存索引上把同样的步骤重复 100 次,按预算统计提交后的段数(release):

budget=50000000 runs=100 segments: 1->0 2->0 3->100
budget=15000000 runs=100 segments: 1->100 2->0 3->0
budget=200000000 runs=100 segments: 1->0 2->0 3->100

50 MB(3 个线程)和 200 MB(8 个线程)都是 100 次全 3 段;15 MB 只有 1 个线程,100 次全 1 段。段数取决于拿到文档的线程有几个,所以它随核数和预算变化:在单核机器上 50 MB 也只有 1 个线程。想让一批文档落进同一个段,用 run 把它们放进同一个批次。

opstamp:三篇文档用掉 0、1、2。每个段交给 segment_updater 时,schedule_add_segment 会调 consider_merge_options,后者开头 let current_opstamp = self.stamper.stamp();,给可能的合并操作取一个 opstamp。三个段各取一个,用掉 3、4、5;prepare_commit 取下一个,6。用一个线程(writer_with_num_threads(1, 15_000_000))写同样三篇,只有一个段,日志是 Prepared commit 4,commit opstamp: 4。所以 opstamp 不是「操作个数」,两次提交的 opstamp 之差也不等于这期间加了几篇文档。

小结

  • writer(50 MB) 在 12 核上是 3 个索引线程:先取 min(核数, 8),每线程不足 15 MB 时改成 预算 / 15 MB;另有 1 个 segment_updater 和 4 个 merge_thread_*。
  • add_document 只取 opstamp、把单篇批次送进有界 channel;channel 的容量按批次算。
  • 索引线程拿到第一篇文档就新建段、打开六个文件;每个新文件同步改写一次 .managed.json,18 个文件约 80 ms。
  • prepare_commit 换 channel、等每个线程定稿交段、起新一批线程(编号继续往上);commit 在 segment_updater 线程上落删除、写 meta.json、GC、考虑合并。
  • 3 篇文档在 3 个线程上是 3 个段;commit 返回 6,因为每个段登记时为合并取了一个 opstamp。
  • 不调 wait_merging_threads 就丢掉 writer,后台合并作废:20 次提交留下 60 个段,等合并时是 7 个;0.26.1 相同。
  • 0.27.0 里段由三个内置 SegmentPlugin 写出,SegmentSerializer 已删除(#2993);写入日志降为 debug 级别(#3013)。

💬 关联讨论

发起讨论
加载中...