Rust in Everything
基于 tantivy 0.27.0

IndexReader 与 Searcher

主线位置:查询主线的第一步。reader_builder().try_into() 打开 meta.json 里的段、建出第一个 Searcher;之后每次查询先 reader.searcher() 拿一份快照,查询的全部统计和结果都来自这份快照。提交之后什么时候换下一份,由 ReloadPolicy 决定。

flowchart TB
  build["try_into"] --> create["create_searcher"]
  reloadc["reload"] --> create
  create --> lock["拿 META_LOCK"]
  lock --> open["打开每个段"]
  open --> gen["登记新的一代"]
  gen --> inner["SearcherInner::new"]
  inner --> warm["Warmer 预热"]
  warm --> swap["ArcSwap store"]
  swap --> get["searcher 克隆 Arc"]
  watch["meta.json 变了"] --> reloadc

读者相关的代码不多:src/reader/(mod.rs 和 warming.rs,共 663 行)加上 src/core/searcher.rs(308 行)和 src/core/executor.rs(243 行)。监听 meta.json 的部分在 Directory 里,见 Directory。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。实验程序在 srcha-lab/src/bin/(reader.rs、exec.rs、warmleak.rs),磁盘索引放在 tempfile 建的临时目录里。

三个对象

IndexReader 只是一个 Arc 加一个监听句柄,真正的状态在 InnerIndexReader:

struct InnerIndexReader {
    doc_store_cache_num_blocks: usize,
    index: Index,
    warming_state: WarmingState,
    searcher: arc_swap::ArcSwap<SearcherInner>,
    searcher_generation_counter: Arc<AtomicU64>,
    searcher_generation_inventory: Inventory<SearcherGeneration>,
}
  • searcher 是一个 ArcSwap<SearcherInner>。IndexReader::searcher 就是 self.searcher.load().clone().into():读出当前的 Arc<SearcherInner>、克隆一次,包成 Searcher。不加锁,也不分配。
  • SearcherInner 装着 schema、Index、每个段一个 SegmentReader、每个段一个 StoreReader,以及这一代的 TrackedObject<SearcherGeneration>。它建好以后不再变。
  • Searcher 是 Arc<SearcherInner> 的薄包装,Clone 只是加一次引用计数。

所以「快照」不是一个比方:同一代的所有 Searcher 共享同一个 SearcherInner,reload 换掉的是 ArcSwap 里的指针,手里已有的 Searcher 不受影响。IndexReader::searcher 的文档要求「一次查询只用同一个 searcher」,原因就在这里:两次调用之间可能隔着一次 reload,拿到的是两组段。

建一个 searcher

try_into 和 reload 走的是同一个函数 create_searcher:

    fn create_searcher(
        index: &Index,
        doc_store_cache_num_blocks: usize,
        warming_state: &WarmingState,
        searcher_generation_counter: &Arc<AtomicU64>,
        searcher_generation_inventory: &Inventory<SearcherGeneration>,
    ) -> crate::Result<Arc<SearcherInner>> {
        let segment_readers = Self::open_segment_readers(index)?;
        let searcher_generation = Self::track_segment_readers_in_inventory(
            &segment_readers,
            searcher_generation_counter,
            searcher_generation_inventory,
        );

        let schema = index.schema();
        let searcher = Arc::new(SearcherInner::new(
            schema,
            index.clone(),
            segment_readers,
            searcher_generation,
            doc_store_cache_num_blocks,
        )?);

        warming_state.warm_new_searcher_generation(&searcher.clone().into())?;
        Ok(searcher)
    }

四步:

  1. open_segment_readers 拿着 META_LOCK 读 meta.json 的段列表,逐个 SegmentReader::open。锁防的是 GC 在「读了列表、还没打开文件」的间隙把文件删掉,细节见 Directory。
  2. 给这组段编一个代号:计数器 fetch_add(1),连同每个段的 id 和删除 opstamp 登记进 Inventory(下一节)。
  3. SearcherInner::new 为每个段开一个 StoreReader,带自己的块缓存。缓存因此跟着代走,reload 以后从空开始,见文档存储。
  4. 有 warmer 就预热;之后 reload 才把新的 Arc 存进 ArcSwap。

reload 没有加锁。两个线程同时 reload(比如监听线程和手动调用),各自建一个 SearcherInner,后 store 的那个留下。

建 searcher 要打开每个段的每个文件(MmapDirectory 下是 mmap,带一层弱引用缓存),实测一个段、刚提交的小索引上 reload() 用时 0.22 到 1.62 ms(20 次,中位数 0.41 ms)。

📝NOTE
冷知识:0.19 之前,searcher() 会阻塞

0.19 之前 IndexReader 里放的是一个 searcher 池:每次 reload 按 num_searchers(默认 num_cpus::get())建同样多份 Searcher,searcher() 返回 LeasedItem<Searcher>,用完自动还回池子;池子里的全被借走时,Pool::acquire 的文档写着「If no searcher is available, this methods block until a searcher is released」。PR #1411(「remove searcher pool and make Searcher cloneable」,首次发布在 0.19)删掉了 295 行的 src/reader/pool.rs 和 num_searchers 选项,改成现在的 ArcSwap<SearcherInner>,Searcher 变成可以随便克隆的 Arc。CHANGELOG 0.19 的记录是「Remove Searcher pool and make Searcher cloneable.」

ReloadPolicy:谁来调 reload

ReloadPolicy 只有两个变体:

  • Manual:没人替你调,提交以后要自己 reader.reload()。
  • OnCommitWithDelay(默认):try_into 向 Directory::watch 注册一个回调,回调里调 reload,失败只打一条 error! 日志。

回调什么时候被调,取决于目录。

MmapDirectory:500 ms 轮询

FileWatcher::spawn 起一个 thread-tantivy-meta-file-watcher 线程:

                let mut current_checksum_opt = None;

                while state.load(Ordering::SeqCst) == 1 {
                    if let Ok(checksum) = FileWatcher::compute_checksum(&path) {
                        let metafile_has_changed = current_checksum_opt
                            .map(|current_checksum| current_checksum != checksum)
                            .unwrap_or(true);
                        if metafile_has_changed {
                            debug!("Meta file {path:?} was modified");
                            current_checksum_opt = Some(checksum);
                            // We actually ignore callbacks failing here.
                            // We just wait for the end of their execution.
                            let _ = callbacks.broadcast().wait();
                        }
                    }

                    thread::sleep(POLLING_INTERVAL);
                }

每 500 ms(POLLING_INTERVAL)逐行读一遍 meta.json 算 crc32,变了就广播。注意 current_checksum_opt 从 None 开始、unwrap_or(true):线程的第一次轮询一定算「变了」,所以 reader 建好以后,即使没有任何提交,也会紧接着 reload 一次。

RamDirectory:写 meta.json 时广播

RamDirectory::atomic_write 写的若是 meta.json,就当场 broadcast(),并把返回的 future 直接丢掉。WatchCallbackList::broadcast 每次新起一个 watch-callbacks 线程执行回调,所以内存目录上的 reload 同样是异步的,commit() 返回时不一定完成。

实测:提交以后多久可见

reader.rs:默认 reader(),每次加一篇文档、commit(),从 commit() 返回开始每 0.2 ms 查一次 searcher().num_docs(),直到看到新文档;两次提交之间随机停 0 到 1000 ms,避免和轮询周期锁相。各 40 次(负载 4 到 7):

[mmap] gen right after reader(): 0
[mmap] gen 1.2s later, no commit: 1
[mmap] histogram [0,10) [10,100) [100,200) [200,300) [300,400) [400,500) [500,600) [600,..): [0, 5, 10, 10, 7, 8, 0, 0]
[mmap] 40 commits: visible right after commit() 0; lag ms min 24.28 median 244.44 max 481.27; final gen 42
[ram] gen right after reader(): 0
[ram] gen 1.2s later, no commit: 0
[ram] histogram [0,10) [10,100) [100,200) [200,300) [300,400) [400,500) [500,600) [600,..): [2, 0, 38, 0, 0, 0, 0, 0]
[ram] 40 commits: visible right after commit() 0; lag ms min 0.26 median 104.97 max 105.32; final gen 45
[manual mmap] 20 commits: commit() ms min 54.72 median 62.50 max 135.68; reload() ms min 0.22 median 0.41 max 1.62
  • 两种目录上,80 次提交没有一次在 commit() 返回时就可见。
  • MmapDirectory 的延迟大致均匀地落在 0 到 500 ms 之间,中位数 244 ms,就是轮询周期的一半。IndexReader::reload 的文档说自动 reload「can take 10s of milliseconds to kick in」,ReloadPolicy 的文档说「within milliseconds」,都比实测少一个数量级。
  • 不建 reader、1.2 秒内没有提交,mmap 的代号已经从 0 变成 1:那就是第一次轮询触发的 reload。final gen 比提交次数多出的部分,来自 mmap 上这次空 reload,以及后台合并完成时写的 meta.json。
❗IMPORTANT
动脑筋:RamDirectory 在写 meta.json 的同一刻就广播,为什么 40 次里有 38 次要等约 105 ms?

提示:create_searcher 第一步要拿什么锁,提交的最后一步又在做什么?答案在文末。

要「提交完立刻能搜到」,只有一种确定的写法:ReloadPolicy::Manual,commit() 之后自己调 reader.reload()。上面最后一行就是这种用法,reload() 本身不到 2 ms,比提交(中位数 62.5 ms,大部分是落盘同步,见提交)便宜得多。

代与 Inventory

每次 create_searcher 产生一个 SearcherGeneration:

pub struct SearcherGeneration {
    segments: BTreeMap<SegmentId, Option<Opstamp>>,
    generation_id: u64,
}

generation_id 是 reader 内部计数器的值,从 0 开始;segments 记下这一代每个段的 id 和删除 opstamp。只有段集合和删除状态一起才能说清「这一代看到的是什么」:同一个段在新的删除落盘以后,.del 换了,SegmentId 不变、opstamp 变了。SearcherInner::new 一开头就 assert_eq! 两边的段集合一致。

这个结构登记在 Inventory<SearcherGeneration> 里。Inventory 和 TrackedObject 来自外部的 census 0.4.2 crate(lib.rs 直接重导出):Inventory 里存的是 Weak 列表,track 返回一个 TrackedObject(内部是 Arc),list() 把还能 upgrade 的项取出来。SearcherInner 持有自己那一代的 TrackedObject,于是「某一代还活着」等价于「还有人拿着这一代的 Searcher」,不需要任何显式注销。

Warmer:每一代的预热和清理

Warmer 是给使用者维护段级缓存用的:

pub trait Warmer: Sync + Send {
    fn warm(&self, searcher: &Searcher) -> crate::Result<()>;
    fn garbage_collect(&self, live_generations: &[&SearcherGeneration]);
}
  • IndexReaderBuilder::warmers 收的是 Vec<Weak<dyn Warmer>>。每次用之前 upgrade,upgrade 不了的就从列表里删掉(pruned_warmers),所以 warmer 的生命周期归调用方管。
  • warm 在 create_searcher 里、新代发布之前同步执行;warmer 不止一个时,最多用 num_warming_threads 个线程(Executor::multi_thread,线程名前缀 tantivy-warm-)并行跑。
  • 第一次有 warmer 参与预热时,start_gc_thread_maybe 起一个 tantivy-warm-gc 线程,每秒(GC_INTERVAL)调一次 gc_maybe:用 Inventory::list() 得到活着的代,只要有一个预热过的代已经死了,就对每个 warmer 调 garbage_collect。
  • 没有 warmer 时既不预热也不起线程。

reader.rs 的第二部分注册一个记日志的 warmer,Manual 策略下提交并 reload 三次,扣住第 3 代、丢掉第 0 代,等 2.5 秒;再丢掉第 3 代,提交、reload,再等 2.5 秒:

held gen 3 (s0 gen 0)
  warm gen=0 segs=0 thread=Some("main")
  warm gen=1 segs=1 thread=Some("main")
  warm gen=2 segs=2 thread=Some("main")
  warm gen=3 segs=3 thread=Some("main")
  gc live=[3] thread=Some("tantivy-warm-gc")
  warm gen=4 segs=4 thread=Some("main")
  gc live=[4] thread=Some("tantivy-warm-gc")

预热在调用 reload 的线程上(这里是 main),连空索引的第 0 代也预热;清理在 tantivy-warm-gc 上,看到的活代只有还被人拿着的那一代。第 1、2 代从来没被 searcher() 取过,reload 一换代它们就死了。

gc_loop 是 for _ in crossbeam_channel::tick(GC_INTERVAL),Weak upgrade 失败时什么也不做,但也不退出循环。warmleak.rs 反复建一个带 warmer 的 reader 再丢掉,用 ps -M 数进程的线程:

threads at start: 1
after creating and dropping 1 reader(s) with a warmer: 2 threads
after creating and dropping 2 reader(s) with a warmer: 3 threads
after creating and dropping 3 reader(s) with a warmer: 4 threads
after creating and dropping 4 reader(s) with a warmer: 5 threads
after creating and dropping 5 reader(s) with a warmer: 6 threads
after dropping the warmer too, 2.5 s later: 6 threads

同一程序不注册 warmer 时,线程数一直是 1。每个用过 warmer 的 IndexReader 会留下一个永不退出的线程,每秒醒一次;长期运行、反复重建 reader 的服务要注意。

Searcher 是快照

一个 Searcher 一旦取到,它看到的段、删除状态、文档存储缓存就固定了,之后的提交、合并、GC 都不影响它:

  • 被 GC 删掉的段文件,旧 Searcher 在 macOS 和 Linux 上照样能读,因为 mmap 还拴着;Directory 一页做过这个实验(合并后旧 searcher 仍数出 849 篇)。
  • 查询用的 BM25 统计(N、n、avgdl)全部从这个 Searcher 取,见 BM25。
  • 代价是内存和文件:拿着旧 Searcher 不放,旧段的 mmap 和文件空间就一直不释放。

Executor:按段并行

Searcher::search_with_executor 把「每个段跑一遍收集器」交给 Executor::map。Executor 只有两种:

  • SingleThread:在调用线程上依次跑,第一个错误就返回。Index 默认用它(Index::search_executor)。
  • ThreadPool(Arc<rayon::ThreadPool>):set_multithread_executor(n) 建一个线程名前缀 tantivy-search- 的 rayon 池;set_default_multithread_executor() 用 available_parallelism(),本机是 12。map 在 pool.scope 里给每个段 spawn 一个任务,结果带着下标经 crossbeam channel 送回,再按下标排好,所以 merge_fruits 拿到的顺序和段序号一致。任务里的 panic 会传回调用方。

并行的粒度是段:一个段只会在一个线程上跑。

Searcher::search 用的是 self.inner.index.search_executor(),也就是 SearcherInner 里那份 Index 的执行器。Index 是 Clone 的,reader_builder() 把当时的 Index 克隆进 reader,之后 reload 也一直用这份克隆。reader.rs 的第三部分建一个 4 段的索引,用自定义收集器在 for_segment 里记下线程名:

main thread: Some("main")
reader built before set_multithread_executor: ["seg0@main", "seg1@main", "seg2@main", "seg3@main"]
reader built after  set_multithread_executor: ["seg0@tantivy-search-1", "seg1@tantivy-search-0", "seg2@tantivy-search-0", "seg3@tantivy-search-0"]

先建 reader、后调 set_multithread_executor,查询仍在 main 上串行。执行器要在 reader_builder() 之前设好。

并行值不值,exec.rs 在 hdfs 日志(10 万行)上比较 1 个段和 8 个段(NoMergePolicy,每 12,500 篇提交一次)、单线程和 8 线程执行器,各取 30 次中的最小值(负载 3.6 到 5):

段数执行器OR 查询 top10OR 查询 CountAND 查询 top10AND 查询 Count
1单线程0.029 ms0.310 ms0.006 ms0.125 ms
18 线程0.048 ms0.330 ms0.019 ms0.133 ms
8单线程0.052 ms0.315 ms0.024 ms0.132 ms
88 线程0.044 ms0.096 ms0.032 ms0.044 ms

OR 查询是 receiving src dest(63,120 篇命中),AND 查询是 +packetresponder +terminating(31,563 篇)。

  • 只有一个段时,线程池只增加调度开销。
  • 8 个段、要扫完全部命中的 Count 快了 3.3 倍和 3.0 倍。
  • 取前 10 本来就有剪枝,几十微秒的查询里线程调度的开销和收益相当,8 线程反而可能更慢。search_with_executor 的文档也写了:并行降低单个查询的延迟,不提高吞吐。

动脑筋答案

因为 META_LOCK。提交在 SegmentUpdater 线程上依次写 meta.json、做 GC(见提交)。写 meta.json 的那一刻,RamDirectory 起一个 watch-callbacks 线程去 reload;reload 第一步 open_segment_readers 要拿 META_LOCK,而这时 GC 正拿着它列活文件。META_LOCK 是阻塞锁,acquire_lock 拿不到就按 retry_policy 睡 100 ms 再试,最多 100 次。所以:

[ram] histogram [0,10) [10,100) [100,200) [200,300) [300,400) [400,500) [500,600) [600,..): [2, 0, 38, 0, 0, 0, 0, 0]

38 次刚好撞上 GC、睡了一轮 100 ms;2 次抢在 GC 之前拿到锁,0.26 ms 就可见。MmapDirectory 上也有同样的锁,只是 500 ms 的轮询把它盖住了。

小结

  • IndexReader 里是一个 ArcSwap<SearcherInner>;searcher() 克隆一个 Arc,同一代共享段、文档存储缓存和统计;reload 建一个新的 SearcherInner 再换指针,本身不加锁。
  • create_searcher:拿 META_LOCK 打开段,登记一代 SearcherGeneration(段 id 加删除 opstamp),为每个段开 StoreReader,预热,最后发布。
  • OnCommitWithDelay 在 MmapDirectory 上是 500 ms 轮询 meta.json 的 crc32,实测提交后 24 到 481 ms 可见,reader 建好后还会空 reload 一次;RamDirectory 写 meta.json 时广播,但多数要等 META_LOCK 的 100 ms 重试。要确定可见就用 Manual 加 reload()。
  • 活着的代由 census 的 Inventory 记录;Warmer 按 Weak 注册,预热同步、清理在每秒一次的 tantivy-warm-gc 线程上,这个线程在 reader 丢掉后不退出。
  • Executor 默认单线程,并行按段切;Searcher 用的是建 reader 时克隆进去的 Index 的执行器,必须先设执行器再建 reader;段多、要扫全量时才有收益。

💬 关联讨论

发起讨论
加载中...