Rust in Everything
基于 tantivy 0.27.0

设计取舍

主线位置:三条主线的交叉处。前面各页讲的是代码「怎么做」,本页把散在写入主线、查询主线、磁盘主线上的几处选择放在一起,问「为什么这样做、放弃了什么」。机制本身只给一句话和链接,篇幅留给代价和实测。
flowchart TB
  imm["不可变段"] --> g1["读不加锁 可 mmap"]
  imm --> c1["更新 = 删 + 加"]
  bat["批量提交"] --> c2["提交后才可见"]
  nopk["没有主键"] --> c3["删除按 term"]
  sch["一对一 schema"] --> c4["多种分析要复制"]
  syn["同步 API"] --> c5["async 里要挪线程"]
  mm["mmap"] --> g2["打开即用"]
  mm --> c6["SIGBUS 与 RSS"]

ARCHITECTURE.md 把三条原则(查询内存 O(1)、写入内存 O(1)、查询尽量快)和一句代价写在一起:「This comes at the cost of the dynamicity of the index: while it is possible to add, and delete documents from our corpus, the tantivy is designed to handle these updates in large batches.」下面六个取舍,几乎都是这句话在不同地方的展开。原则在代码里的样子见为什么是 tantivy。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0(默认 feature),tokio 1.54.1,release 构建;uptime 负载 3.3 到 5.4(同机有别的实验在跑)。语料是仓库自带的 benches/wiki.json(1000 篇英文维基摘要),需要大一点的索引时重复 50 遍(5 万篇、一个段、磁盘上 20 MB)。schema 是 uid(STRING | STORED)、title(TEXT | STORED)、body(TEXT)。实验程序在 exp-tantivy/design-lab/src/bin/。

不可变段,而不是原地更新

做法。 段文件写完就不再改。Directory::open_write 要求文件不存在,MmapDirectory 的实现 open_write 用 create_new(true) 兑现这一点(见 Directory)。删除也不改段:每次提交为有新删除的段另写一个存活位图,文件名里带着提交的 opstamp,由 SegmentMeta::relative_path 拼出来:

            SegmentComponent::Delete => format!(".{}.del", self.delete_opstamp().unwrap_or(0)),

全索引唯一被替换的是 meta.json,而且是「同目录写临时文件、同步、改名」的整体替换(见提交)。

换来的。

  • 读不用锁。 Searcher 拿着一组 SegmentReader,它们指向的字节不会再变。ARCHITECTURE.md 的说法是「as long as the user holds and reuse the same Searcher, search will happen on an immutable snapshot of the index」。写入线程、合并线程、GC 都不需要和查询协调;GC 删掉的文件,旧 Searcher 在 Unix 上照样能读完(Directory 的实验)。
  • 可以直接 mmap。 文件不会被改写,映射进来的页就一直有效。这一条的另一面在本页最后一节。
  • 可以按块压缩。 倒排表 128 个 doc id 一块做差分加 bitpacking(倒排表),文档存储 16 KB 一块压缩(文档存储)。原地插入或删除一个 doc id 会让整块重写,不可变段从根上绕开了这个问题。
  • 崩溃一致。 磁盘上的 meta.json 永远是某次完整提交的样子,没进 meta.json 的段文件是孤儿,下次 GC 删掉。

放弃的。 更新一篇文档只能「删旧的、加新的」。删掉的文档仍占着 doc id、仍在倒排表里,要等合并重写这个段才消失;在那之前它们还算进 BM25 的文档数和文档频率(fieldnorm 与删除位图)。默认的 LogMergePolicy 只看段数、不看删除比例,靠更新为主的索引会一直背着这些文档,除非调 set_del_docs_ratio_before_merge(合并)。段越多,每个查询要走的词典和倒排表就越多,ARCHITECTURE.md 说「having hundreds can have a measurable impact on the search performance」。所以不可变段的成本最终落在合并上:合并就是周期性地把整段重写一遍。

批量提交,而不是写完即可见

做法。 文档进了索引线程的内存池并不可见,要等 commit 把它们写成段、换掉 meta.json。已经打开的 IndexReader 还要再等自己的 ReloadPolicy:默认的 OnCommitWithDelay 靠一个每 500 ms 轮询一次 meta.json 的线程发现变化(Directory),提交页实测提交后约 440 ms 才看到。这个变体的定义后面跟着一行注释:

    OnCommitWithDelay, // TODO add NEAR_REAL_TIME(target_ms)

这行 TODO 是 2019 年 PR #517(加入 IndexReader,首次发布在 0.9)写下的,当时变体叫 OnCommit;PR #2235(0.22)把它改名为 OnCommitWithDelay,TODO 原样留到了 0.27.0。tantivy 没有「从 writer 直接拿一个能看到未提交数据的 reader」的入口:IndexReader 只从 Index 建,只认 meta.json 里的段。ReloadPolicy 的文档仍写着「The index is reloaded within milliseconds after a new commit is available」,和 500 ms 的轮询对不上。

放弃的:小批量的吞吐。 一次提交至少交出一个段、同步若干个文件;提交页量过,本机单篇文档提交约 60 ms,几乎全是落盘同步。把同样 1000 篇文档用一个长命 writer(1 个索引线程、默认合并策略、MmapDirectory)写入,只改提交的频率(batch,各跑一次):

commit every 1000: commits=   1 add+commit    102.3 ms (  0.10 ms/doc); segments before wait  1, after  1; files 10; total    102.4 ms
commit every  100: commits=  10 add+commit    719.1 ms (  0.72 ms/doc); segments before wait 10, after  3; files 22; total    764.9 ms
commit every   10: commits= 100 add+commit   7613.1 ms (  7.61 ms/doc); segments before wait  9, after  2; files 16; total   7688.8 ms
commit every    1: commits=1000 add+commit  97257.9 ms ( 97.26 ms/doc); segments before wait  6, after  6; files 40; total  97258.2 ms

总时间几乎和提交次数成正比:一篇一提交比一次提交慢约 950 倍。每次提交约 97 ms,比提交页单测的 60 ms 多,多出的部分里有后台合并(每凑够 8 个段合并一次)和提交争用 CPU 与磁盘同步,本页没有拆开测。提交越频繁,段越多、合并越忙,最后剩下的段也越碎。

换来的: 内存有上界(攒到预算就切段,见为什么是 tantivy),提交是一个清楚的持久化点(opstamp 可以和外部队列对齐),读者看到的永远是某次完整的提交。要「写完即可见」的应用,只能在两头之间选一个点:攒一个时间窗口或一批文档再提交,查询方用 ReloadPolicy::Manual 在提交后立刻 reload()。

没有主键:删除按 term

做法。 ARCHITECTURE.md:「Tantivy does not offer any notion of primary id, so it is up to the user to use a field in their schema as if it was a primary id」。写入路径上没有任何「这篇文档是否已经存在」的检查:add_document 只领一个 opstamp、把文档送进 channel。删除是 delete_term:把 term 包成 TermQuery 排进删除队列,等段定稿和提交时才在各段里执行,只作用于 opstamp 比它小的文档(提交)。

nopk 实验:同一个 uid 加两次,再用「删 + 加」更新,最后两种按 title 删除:

add uid=a twice                          all=2 uid:a=2
delete_term(uid=a) + add, one commit     all=1 uid:a=1
delete_term(title="Old Man, v3")         all=1 uid:a=1
delete_term(title="v3")                  all=0 uid:a=0
  • 重复的 uid 照单全收,两篇都在。唯一性完全是调用方的约定。
  • 同一次提交里先 delete_term 再 add_document,新文档的 opstamp 更大,不受这次删除影响,结果是 1 篇。
  • title 是 TEXT,索引里存的是分词后的小写词元。用整句原文做 term 什么也删不掉,也不报错(delete_term 连字段类型不对都吞掉);用其中一个词元 v3 则删掉了所有含这个词的文档。主键字段要用不分词的 STRING(或数值字段)。

换来的: 写入路径上没有读。要保证唯一,add_document 就得在每个段的词典里查一遍这个键,还要和同一批里尚未定稿的文档比较;现在它只是一次原子加和一次 channel 发送,删除推迟到段定稿时批量执行。放弃的: 唯一性、原子的 upsert 语义,以及删除的即时回收(见上一节)。

📝NOTE
冷知识:IndexWriter::run 是为「删 + 加」成组而加的

PR #493(「Add grouped operations」,首次发布在 0.9)之前,更新只能分别调 delete_term 和 add_document,两者各领各的 opstamp。channel 里一条消息就是一篇文档(channel::Sender<AddOperation>),索引线程每处理一篇就检查一次内存,一组文档中间随时可能切段。这个 PR 加了 UserOperation 枚举和 run,提交说明的第一条是「added UserOperation enum, added IndexWriter.run, and added MultiStamp」,后来又「removed MultiStamp in favor of std::ops::Range」。0.9.0 的 CHANGELOG 写的是:「Added grouped add and delete operations. They are guaranteed to happen together (i.e. they cannot be split by a commit). In addition, adds are guaranteed to happen on the same segment.」今天的 run 仍是这个结构:get_batch_opstamps 一次领 n + 1 个连续的号,UserOperation 的 Delete 进删除队列,所有 Add 装进一个 AddBatch 作为一条消息发出;这个 PR 把 channel 的元素从 AddOperation 改成了 Vec<AddOperation>(今天是 AddBatch),索引线程的 index_documents 处理完一整条消息才检查内存是否超预算,所以同一组的文档一定在同一个段里。它仍然不是主键:run 里的 Delete 一样是按 term 删。

schema:严格、一对一

做法。 schema 在建索引时定下、存进 meta.json,之后没有任何修改入口;IndexBuilder::open_or_create 发现传入的 schema 和磁盘上的不同,就返回 SchemaError("An index exists but the schema does not match.")。手工往 meta.json 加字段,查询能用、合并会失败,见 Schema 与字段。

「一对一」落在写入侧:InvertedIndexPluginWriter::new 按字段号为每个字段准备一个 TextAnalyzer,index_document 按字段分组后取 per_field_text_analyzers[field.field_id()] 分词。一个字段只有一种分析方式,一段输入只进一个字段。ARCHITECTURE.md 的 Limitations 写得很直白:同一段文本用两种分词器各索引一次、或把几个字段拼成一个字段来索引,「This is not something tantivy supports, and it is up to the user to duplicate field / concatenate fields before feeding them to tantivy」。

放弃的: 动态加字段、一份输入多种分析。比如中文正文想同时支持 jieba 分词和整串精确匹配,就要在 schema 里建两个字段,每篇文档把文本放两遍,STORED 只开一个,免得文档存储也存两份。需要无模式数据时,退路是 JSON 字段:路径编进 term 的字节里(Term),代价是类型由值推断。另外,TantivyDocument::parse_json 遇到 schema 里没有的键直接跳过、不报错(文档),拼错字段名只会让这个字段静悄悄地空着。

换来的: 每个字段的类型、分词器、要不要位置、要不要 fieldnorm 都是确定的,写入侧可以按字段号直接索引 Vec,不用在每篇文档上判断;读取侧每个文件按字段分区(CompositeFile),查询时 BM25 按字段算统计(一次查询)。

同步 API,自带线程

做法。 tantivy 不依赖 tokio(源码地图)。写入是标准库线程加 crossbeam-channel,SegmentUpdater 和合并是 rayon 线程池(一次写入 数过:writer(50_000_000) 之后进程里有 9 个线程,含主线程)。查询是普通的同步函数:Searcher::search 取 Index 上的 executor,交给 search_with_executor 逐段执行。默认 executor 是 Executor 的 SingleThread 变体,Index::search_executor 的文档说「By default the executor is single thread, and simply runs in the calling thread」:查询就在调用者的线程上跑完才返回。

src/ 里的 async fn 除了两个文档里隐藏的快速字段列举函数(list_dynamic_column_handles 等),都在 quickwit feature 下,比如 Searcher::doc_async、InvertedIndexReader::warm_postings。它们做的都是异步地把字节取进来:在查询之前把远程存储上要用的部分预热好,之后的查询本身仍是同步的。打分和遍历倒排表是纯 CPU 活,tantivy 没有在里面留让出点。

放弃的: 和 async 运行时的直接配合。在 tokio 的 worker 线程上直接调 searcher.search,这个 worker 在查询结束前不会去跑别的任务;tokio 是协作式调度,任务只在 .await 处让出,同步调用不返回,这个 worker 上排着的任务就只能等。写入侧同理:commit() 一次要几十毫秒,add_document 在 channel 满了(PIPELINE_MAX_SIZE_IN_DOCS = 10,000 篇)时会阻塞,文档原话是「If the indexing pipeline is full, this call may block」。

实验 tokio_block:5 万篇的索引上,一次 RegexQuery(".*e.*") 加 Count 约 7 ms(热缓存)。tokio 多线程运行时,2 个 worker;一个「无关」任务每 5 ms 醒一次,记录每次醒来晚了多少;同时一次性 spawn 100 个请求任务,每个做一次查询。四种做法:

  • idle:请求任务只 sleep(7 ms),作为基线;
  • inline:在 async 任务里直接调 slow_search;
  • spawn_blocking:tokio::task::spawn_blocking(move || slow_search(&s, &i)).await;
  • blocking_max2:同上,但先从一个容量为 2 的 Semaphore 拿许可,同时最多 2 个查询。

Searcher 内部是一个 Arc,clone 很便宜,可以直接 move 进 spawn_blocking 的闭包。

❗IMPORTANT
动脑筋:直接在 async 任务里查,那个每 5 ms 醒一次的任务最多会晚多少?

2 个 worker,100 个请求任务一次性 spawn,每个任务做一次约 7 ms 的同步查询,不让出。基线(idle)下它每次晚约 2.5 ms。inline 时,它最长一次晚多少毫秒?换成不限并发的 spawn_blocking 呢?答案在文末。

结论先说:在 async 代码里调 tantivy,查询和提交都放进 spawn_blocking,并且用信号量(或者一个固定大小的专用线程池)限制同时在跑的查询数。只做前一半不够,原因在答案里。

换来的: tantivy 不绑定任何运行时,同步代码、tokio、async-std 都能用;线程模型由它自己掌控:写入并行度由内存预算决定(IndexWriter),查询并行度由 Index::set_multithread_executor 决定(IndexReader 与 Searcher)。

mmap:打开即用,代价在别处

做法。 MmapDirectory 把每个段文件整个映射进来,open_mmap 的核心是一个 unsafe 块:

    let mmap_opt: Option<memmap2::Mmap> = unsafe {
        memmap2::Mmap::map(&file)
            .map(Some)
            .map_err(|io_err| OpenReadError::wrap_io_error(io_err, full_path.to_path_buf()))
    }?;

unsafe 的前提是:映射期间没有人改或截断这个文件。读出的 OwnedBytes 只是映射的一段,不拷贝(Directory)。ARCHITECTURE.md:「loading an index is as fast as mmapping its files」。

换来的。 mmap_cost 实验在一个新进程里打开 5 万篇的索引(一个段,磁盘上 20283 KB),每一步记缺页次数(getrusage 的 minflt + majflt)、RSS(ps)和打开的文件描述符数(/dev/fd)。连跑三次,第三次的输出:

index dir: 20283 KB on disk
start                           0.00 ms  faults +5      rss    3344 KB (+    64)  fds 4
open index + reader             0.26 ms  faults +38     rss    3968 KB (+   624)  fds 4
  segments 1, docs 50000
query old man (1st)             0.08 ms  faults +58     rss    4752 KB (+   784)  fds 4
query old man (2nd)             0.05 ms  faults +5      rss    4800 KB (+    48)  fds 4
regex .*e.* count (1st)         6.85 ms  faults +489    rss   12592 KB (+  7792)  fds 4
regex .*e.* count (2nd)         6.82 ms  faults +5      rss   12640 KB (+    48)  fds 4

打开一个 20 MB 的索引用了 0.26 ms,RSS 只涨 624 KB:没有「加载」这一步。文件描述符始终是 4 个:open_mmap 映射完就关掉了 File,映射本身不占 fd。

放弃的。

  • 代价在第一次访问时才付,而且算进你的 RSS。 old man 第一次查询有 58 次缺页,第二次 5 次。正则查询要把 body 字段的倒排表扫一遍,第一次 489 次缺页、RSS 涨 7792 KB;本机页大小 16 KiB,489 × 16 KiB ≈ 7.6 MiB,和 RSS 的增量对得上,基本就是 8.2 MB 的 .idx 被摸过的部分。这些页属于文件、可以被系统回收,但监控里看到的 RSS 会随查询覆盖的范围上涨。这里的文件刚写完、都在页缓存里,所以全是轻缺页;冷缓存时每次缺页都是一次磁盘读,第一次查询的延迟会落在这上面,本机没法在不用 root 的情况下清页缓存,没有测。
  • 缓存交给操作系统。 tantivy 不管哪些页留在内存里,能做的只有 Unix 上用 open_with_madvice 给映射加一个 madvise 提示。索引比内存大时,哪些页被换出由内核决定。
  • I/O 错误变成信号。 读映射内存没有返回值可以报错。sigbus 实验打开 reader、查一次,然后把段的 .idx 截断到 16384 字节,再查同一个查询:
before: 10 hits
truncated f4db3485c0a24fd2a342a0a99bb0300d.idx from 200442 to 16384 bytes
bash: line 1: 68010 Bus error: 10              ~/exp-tantivy/target-design/release/sigbus
exit status 138

after: 那一行没有打印出来:进程被 SIGBUS 直接杀掉,没有 Err,也没有 panic 可以捕获。外部程序截断或就地改写段文件,在 mmap 下都是这个结果。tantivy 自己从不改已经写好的文件,所以只要外部不碰索引目录就不会发生;删除文件是安全的(只删目录项,见 Directory),Windows 上则根本删不掉正在映射的文件。打开时也不校验 crc,要校验得显式调 Index::validate_checksum。

不想要这些代价,可以换 Directory:RamDirectory 把文件放在堆上,或者自己实现一个按需读的目录(quickwit 的远程存储就是这样,配上面的异步预热)。

和 Lucene 对照

tantivy 的名词几乎都来自 Lucene,概念对照表在为什么是 tantivy。本卷离线没有 Lucene 的源码,只能从 tantivy 仓库自己的文字里核实三处取舍上的差别:

取舍tantivy 的选择出处
打分BM25,K1 = 1.2、B = 0.75 写成常量;能换的只有统计量(Bm25StatisticsProvider)ARCHITECTURE.md:「The relevance score implemented in the tantivy is not configurable. Tantivy uses the same score as the default similarity used in Lucene」
存储抽象文件是 FileSlice,不是可 seek 的流;默认假定能 mmapARCHITECTURE.md:「Contrary to Lucene however, "files" are quite different from some kind of io::Read object」
输入到索引字段一对一,不支持一个输入索引成多个字段ARCHITECTURE.md 的 Limitations,见上文 schema 一节

不可变段、meta.json 式的提交点、按 term 删除、段合并,这些是两者共有的设计。Lucene 在实时可见性(从 writer 打开 reader)和按 term 原子更新上提供了什么,本卷无法离线核实,不展开。

动脑筋答案

tokio_block 跑了三轮(每轮四种做法依次跑),输出原样:

one search: 46850 hits, 9.2 ms (cold)
one search: 7.3 ms (warm)
tokio multi_thread, worker_threads=2; 100 request tasks spawned at once, one search each
-- round 0
         idle: searches took     7.6 ms; ticker wakeups=   4 lag p50=  2.51 ms p99=  2.51 ms max=  2.56 ms
       inline: searches took   411.3 ms; ticker wakeups=   6 lag p50=  2.51 ms p99=163.46 ms max=205.72 ms
spawn_blocking: searches took   213.1 ms; ticker wakeups=  18 lag p50=  1.52 ms p99= 26.60 ms max= 48.38 ms
blocking_max2: searches took   413.4 ms; ticker wakeups=  64 lag p50=  1.73 ms p99=  2.69 ms max=  2.74 ms
-- round 1
         idle: searches took     8.6 ms; ticker wakeups=   4 lag p50=  2.20 ms p99=  2.52 ms max=  2.52 ms
       inline: searches took   382.8 ms; ticker wakeups=   6 lag p50=  2.53 ms p99=143.16 ms max=176.75 ms
spawn_blocking: searches took   210.1 ms; ticker wakeups=  11 lag p50=  1.63 ms p99=  7.81 ms max=149.18 ms
blocking_max2: searches took   391.5 ms; ticker wakeups=  61 lag p50=  1.75 ms p99=  2.54 ms max=  2.71 ms
-- round 2
         idle: searches took     9.0 ms; ticker wakeups=   4 lag p50=  2.52 ms p99=  2.52 ms max=  2.60 ms
       inline: searches took   383.0 ms; ticker wakeups=   5 lag p50=  2.53 ms p99=119.87 ms max=249.93 ms
spawn_blocking: searches took   208.1 ms; ticker wakeups=  20 lag p50=  1.54 ms p99=  2.53 ms max= 94.98 ms
blocking_max2: searches took   387.2 ms; ticker wakeups=  61 lag p50=  1.41 ms p99=  2.78 ms max=  2.78 ms
  • inline:最长晚了 177 到 250 ms。 100 个查询在 2 个 worker 上排队跑完用了约 400 ms(100 × 7.3 ms ÷ 2),这期间 ticker 本该醒约 80 次,实际只醒了 5、6 次。两个 worker 都被同步查询占住,ticker 的计时器到了也没有线程去跑它。基线里约 2.5 ms 的延迟在没有任何查询时也有,和查询无关。
  • 不限并发的 spawn_blocking:最长仍晚了 48 到 149 ms。 worker 线程确实空出来了,但 100 个查询同时交给了 tokio 的阻塞线程池(默认最多 512 个线程),远多于 12 个核;查询总耗时只从 400 ms 降到 210 ms 左右,也说明它们在互相抢 CPU。最可能的解释是 worker 线程也被操作系统调度了出去,本页没有用调度追踪确认。阻塞池只解决「不占 worker」,不解决「CPU 超额订阅」。
  • blocking_max2:最长晚 2.7 到 2.8 ms,和基线一样,ticker 醒了 61 到 64 次。查询总耗时和 inline 相同(同样是 2 路并行),但 async 那一侧完全不受影响。实际服务里信号量的容量按留给查询的核数定。

小结

  • 不可变段换来不加锁的读、mmap、按块压缩和崩溃一致;代价是更新等于删加,删除的文档在合并前仍占空间、仍算进 BM25 统计,合并是周期性的整段重写。
  • 批量提交换来有上界的内存和清楚的持久化点;本机 1000 篇一篇一提交比一次提交慢约 950 倍。默认 reader 靠 500 ms 轮询发现提交,NEAR_REAL_TIME 从 0.9 起一直是 TODO。
  • 没有主键:重复文档照收,更新是 delete_term + add_document(或 run 成组),删除要用不分词的字段,写入路径上没有读。
  • schema 严格且一对一:每个字段一个分析器,不能改 schema,一段输入想多种分析就得自己复制字段;JSON 字段是无模式数据的退路。
  • 同步 API、自带线程:查询在调用者线程上跑完。在 tokio 里要 spawn_blocking 加并发上限;只挪线程不限并发,实测仍会把无关任务拖慢上百毫秒。
  • mmap 让打开 20 MB 的索引只要 0.26 ms、不占文件描述符;代价是第一次访问才缺页、RSS 随查询覆盖范围上涨、缓存交给内核,文件被截断时进程直接被 SIGBUS 杀掉。

💬 关联讨论

发起讨论
加载中...