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)
}
四步:
open_segment_readers拿着META_LOCK读meta.json的段列表,逐个SegmentReader::open。锁防的是 GC 在「读了列表、还没打开文件」的间隙把文件删掉,细节见 Directory。- 给这组段编一个代号:计数器
fetch_add(1),连同每个段的 id 和删除 opstamp 登记进Inventory(下一节)。 SearcherInner::new为每个段开一个StoreReader,带自己的块缓存。缓存因此跟着代走,reload以后从空开始,见文档存储。- 有 warmer 就预热;之后
reload才把新的Arc存进ArcSwap。
reload 没有加锁。两个线程同时 reload(比如监听线程和手动调用),各自建一个 SearcherInner,后 store 的那个留下。
建 searcher 要打开每个段的每个文件(MmapDirectory 下是 mmap,带一层弱引用缓存),实测一个段、刚提交的小索引上 reload() 用时 0.22 到 1.62 ms(20 次,中位数 0.41 ms)。
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。
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 查询 top10 | OR 查询 Count | AND 查询 top10 | AND 查询 Count |
|---|---|---|---|---|---|
| 1 | 单线程 | 0.029 ms | 0.310 ms | 0.006 ms | 0.125 ms |
| 1 | 8 线程 | 0.048 ms | 0.330 ms | 0.019 ms | 0.133 ms |
| 8 | 单线程 | 0.052 ms | 0.315 ms | 0.024 ms | 0.132 ms |
| 8 | 8 线程 | 0.044 ms | 0.096 ms | 0.032 ms | 0.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;段多、要扫全量时才有收益。