Rust in Everything
基于 tantivy 0.27.0

合并

主线位置:写入主线的最后一步。提交结束时 SegmentUpdater 问一次合并策略;如果有该合并的段,就交给合并线程在后台重写成一个新段,commit() 不等它。合并完成后再回到 SegmentUpdater 线程上换掉旧段、重写 meta.json。三篇文档提交一次只有一个段,不会触发合并;段多起来以后才轮到本页。

flowchart TB
  ev["收段 提交 合并结束"] --> cmo["consider_merge_options"]
  cmo --> pol["MergePolicy 给候选"]
  pol --> op["MergeOperation 登记"]
  op --> pool["合并线程池"]
  pool --> adv["advance_deletes"]
  adv --> im["IndexMerger write"]
  im --> em["end_merge 排回 SU"]
  em --> chk{"SU 还活着"}
  chk -->|是| swap["替换旧段 写 meta.json"]
  swap --> cmo
  chk -->|否| lost["结果丢弃"]

本页涉及 log_merge_policy.rs(392 行,实现约 140 行)、segment_updater.rs 的合并部分、merger.rs(1622 行,实现约 540 行)和 doc_id_mapping.rs。0.27.0 起,各种文件具体怎么合并(词典、倒排表、fieldnorm、快速字段、doc store)分到了各个插件的 merge 里,例如倒排的 InvertedIndexPlugin::merge。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0(另用 0.26.1 复现了一个实验),release 构建,MmapDirectory 放在临时目录。语料 benches/wiki.json。

什么时候考虑合并

consider_merge_options 在 SegmentUpdater 线程上跑,触发点有三个:收下一个新段(schedule_add_segment)、提交结束、一次合并结束。

    fn consider_merge_options(&self) {
        let (mut committed_segments, mut uncommitted_segments) = self.get_mergeable_segments();
        if committed_segments.len() == 1 && committed_segments[0].num_deleted_docs() == 0 {
            committed_segments.clear();
        }
        if uncommitted_segments.len() == 1 && uncommitted_segments[0].num_deleted_docs() == 0 {
            uncommitted_segments.clear();
        }

        // Committed segments cannot be merged with uncommitted_segments.
        // We therefore consider merges using these two sets of segments independently.
        let merge_policy = self.get_merge_policy();

        let current_opstamp = self.stamper.stamp();
        let mut merge_candidates: Vec<MergeOperation> = merge_policy
            .compute_merge_candidates(&uncommitted_segments)
            // ...
        let commit_opstamp = self.load_meta().opstamp;
        let committed_merge_candidates = merge_policy
            .compute_merge_candidates(&committed_segments)
            // ...
        for merge_operation in merge_candidates {
            // If a merge cannot be started this is not a fatal error.
            // We do log a warning in `start_merge`.
            drop(self.start_merge(merge_operation));
        }
    }
  • 可合并的段:get_mergeable_segments 排除了正在合并中的段。正在合并的段登记在一个 MergeOperation 里,它是 inventory 里的一个被追踪对象,活着就表示这几个段被占用,drop 了就释放,不用显式解锁。
  • 已提交和未提交分开算:未提交的段还可能被 rollback,不能和已提交的段合成一个。两组各问一次策略,合并的目标 opstamp 也不同:未提交组用现领的 opstamp,已提交组用 meta.json 里的。
  • 单个段只在有删除时才交给策略,这是让合并策略有机会「重写一个段来清掉删除」的入口。
  • start_merge 返回的 FutureResult 直接 drop,不等。

MergePolicy 只有一个方法 compute_merge_candidates(&[SegmentMeta]) -> Vec<MergeCandidate>,注释要求它「happen rapidly」,因为它在 SegmentUpdater 线程上跑,会挡住提交。内置两个实现:LogMergePolicy(DefaultMergePolicy 就是它的别名)和从不合并的 NoMergePolicy。IndexWriter::set_merge_policy 随时可以换。

LogMergePolicy

默认值(常量),实验里 {:?} 打出来是:

default policy: LogMergePolicy { min_num_segments: 8, max_docs_before_merge: 10000000, min_layer_size: 10000, level_log_size: 0.75, del_docs_ratio_before_merge: 1.0 }
参数默认含义
min_num_segments8一层里至少这么多段才合并
max_docs_before_merge1000 万存活文档超过它的段不再参与合并
min_layer_size10000小于它的段按 10000 算,全部落在最底层
level_log_size0.75同一层内 log2(文档数) 的最大跨度
del_docs_ratio_before_merge1.0某段删除比例超过它,这一层不够 8 个也合并

compute_merge_candidates:

        let size_sorted_segments = segments
            .iter()
            .filter(|seg| (seg.num_docs() as usize) <= self.max_docs_before_merge)
            .sorted_by_key(|seg| std::cmp::Reverse(seg.max_doc()))
            .collect::<Vec<&SegmentMeta>>();
        // ...
        let mut current_max_log_size = f64::MAX;
        let mut levels = vec![];
        for (_, merge_group) in &size_sorted_segments.into_iter().chunk_by(|segment| {
            let segment_log_size = f64::from(self.clip_min_size(segment.num_docs())).log2();
            if segment_log_size < (current_max_log_size - self.level_log_size) {
                // update current_max_log_size to create a new group
                current_max_log_size = segment_log_size;
            }
            // return current_max_log_size to be grouped to the current group
            current_max_log_size
        }) {
            levels.push(merge_group.collect::<Vec<&SegmentMeta>>());
        }

        levels
            .iter()
            .filter(|level| {
                level.len() >= self.min_num_segments
                    || self.has_segment_above_deletes_threshold(level)
            })
            .map(|segments| MergeCandidate(segments.iter().map(|&seg| seg.id()).collect()))
            .collect()

段从大到小排好,从最大的开始开一层,层的上沿是这一层第一个段的 log2(文档数);往下走,只要 log2 掉得不超过 0.75(文档数不小于上沿的 2^-0.75 ≈ 59%),就留在同一层,否则以它为上沿开新的一层。凑满 8 个段的层整层合并。

把一组段的大小直接喂给策略(用 index.new_segment_meta 造 SegmentMeta):

D. sizes [2000000, 1500000, 300000, 300000, 300000, 300000, 300000, 300000, 300000, 40000, 40000, 40000, 40000, 40000, 40000, 40000, 40000, 9000, 500, 20, 3, 1, 1, 1]
D. candidate: [40000, 40000, 40000, 40000, 40000, 40000, 40000, 40000]
  • 200 万和 150 万:log2 是 20.93 和 20.52,差 0.41,同一层,只有 2 个。
  • 7 个 30 万一层,差一个。
  • 8 个 4 万一层,合并。
  • 最后 7 个段从 9000 到 1,都被 min_layer_size 抬到 10000,同属最底层,也差一个。

这里有两个后果。第一,1 万篇以下的段不分大小:一个 9000 篇的段和几个 1 篇的段同在一层,凑够 8 个就一起重写,小索引上每次合并都等于重写整个索引。第二,默认不看删除:has_segment_above_deletes_threshold 用的是 deletes_ratio(segment) > 1.0,比例不可能大于 1,所以默认配置下删除永远不会单独触发合并。源码注释自己也说「This is not a very sensible default: it was set like that for backward compatibility」。

合并线程

start_merge 先在段管理器里确认这些段都在、且同属已提交或同属未提交,取出它们的 SegmentEntry,然后把活扔进合并线程池(默认 4 个线程,IndexWriterOptions::num_merge_threads)。合并线程里用 catch_unwind 包住真正的 merge,panic 不会带走线程池:

fn merge(
    index: &Index,
    mut segment_entries: Vec<SegmentEntry>,
    target_opstamp: Opstamp,
) -> crate::Result<Option<SegmentEntry>> {
    // ...
    // first we need to apply deletes to our segment.
    let merged_segment = index.new_segment();

    // First we apply all of the delete to the merged segment, up to the target opstamp.
    for segment_entry in &mut segment_entries {
        let segment = index.segment(segment_entry.meta().clone());
        advance_deletes(segment, segment_entry, target_opstamp)?;
    }
    // ...
    let merger: IndexMerger =
        IndexMerger::open(index.schema(), index.settings().clone(), &segments[..])?;

    // ... we just serialize this index merger in our new segment to merge the segments.
    let num_docs = merger.write(&merged_segment)?;
    // ...
}

先把删除推进到目标 opstamp(和提交里的 advance_deletes 是同一个函数),再用 IndexMerger 写新段。新段的文件和普通段一样,只是 doc id 重新编号、删掉的文档不再出现。

写完以后,end_merge 通过 schedule_task 回到 SegmentUpdater 线程,并 .wait() 等它做完:

  1. 合并期间可能又有删除被提交了。如果新段的删除游标落后于当前 meta.json 的 opstamp,就对新段再 advance_deletes 一次。
  2. segment_manager.end_merge 用新段替换旧段。
  3. 被合并的是已提交的段时,用原来的 opstamp 和 payload 重写 meta.json:合并不改变索引的逻辑内容,opstamp 不动。
  4. 再 consider_merge_options 一次:新段可能和别的段凑成新的一层。
  5. GC,删掉旧段的文件。
❗IMPORTANT
动脑筋:提交 20 次以后剩几个段?

一个 writer_with_num_threads(1, 20_000_000),默认合并策略。循环 20 次:加 50 篇维基摘要,commit()。最后调 wait_merging_threads()。剩下几个段,各多少篇?(提示:所有段都不到 1 万篇。)

IndexMerger 做了什么

IndexMerger::open_with_custom_alive_set 为每个非空段开一个 SegmentReader,存活文档数加起来不能到 MAX_DOC_LIMIT(2^31)。write 分两步:

    pub fn write(&self, target_segment: &Segment) -> crate::Result<u32> {
        let doc_id_mapping = if let Some(sort_by_field) = self.index_settings.sort_by_field.as_ref()
        {
            if self.is_disjunct_and_sorted_on_sort_property(sort_by_field)? {
                self.get_doc_id_from_concatenated_data()?
            } else {
                self.generate_doc_id_mapping_with_sort_by_field(sort_by_field)?
            }
        } else {
            self.get_doc_id_from_concatenated_data()?
        };

        // Merge plugins in write order (matching `Index::all_plugins`):
        // inverted index, then fast_fields, store, and custom plugins.
        for plugin in builtin_plugins()
            .into_iter()
            .chain(self.custom_plugins.iter().cloned())
        {
            // ...
            plugin.merge(PluginMergeContext {
                readers: &self.readers,
                doc_id_mapping: &doc_id_mapping,
                target_segment,
                schema: &self.schema,
                settings: &self.index_settings,
            })?;
        }

        Ok(self.max_doc)
    }

第一步只算一张表:新段的第 i 篇文档来自哪个旧段的哪个 doc id。不排序时(get_doc_id_from_concatenated_data)就是按段的顺序把每段的存活文档首尾相接,MappingType 记为 Stacked(没有删除)或 StackedWithDeletes。第二步每个插件拿着这张表合并自己的文件:倒排插件用 TermMerger 把各段词典按序归并,对每个词项把各段的倒排表按新 doc id 重写,再重算 block-max;快速字段按表重排列;doc store 只有在映射是拼接、段里没有删除、块数够多(至少 6 块)且压缩器相同时才整块拷贝压缩好的块(stack),否则逐篇解压再重写。这些格式细节在 04 存储格式。

删除的文档什么时候消失

删除只在段被重写时消失。4 个段各 250 篇(NoMergePolicy),删掉一半,再显式调 IndexWriter::merge:

B. after deleting half: segments(num_docs/max_doc) ["125/250", "125/250", "125/250", "125/250"], .del files 4, dir bytes 786459
B. merge() returned segment with max_doc Some(500)
B. after explicit merge: segments(num_docs/max_doc) ["500/500"], .del files 0, dir bytes 331650

删除后每段 max_doc 仍是 250,还多了 4 个 .del;合并后只剩一个 500 篇的段,目录从 786 KB 降到 332 KB。IndexWriter::merge 是手动合并的入口,传入段 id,返回 FutureResult<Option<SegmentMeta>>。

只有一个段时,默认策略什么也不做;把 del_docs_ratio_before_merge 设成 0.5,单个段删除过半就会被重写:

C. ratio None: one segment, 600 of 1000 deleted -> ["400/1000"]
C. ratio Some(0.5): one segment, 600 of 1000 deleted -> ["400/400"]

这对「全靠 delete + add 做更新」的用法很重要:每次更新都在旧段里留下一篇删掉的文档,默认策略只看段数,删除再多也不会触发合并,搜索时这些文档仍要被倒排表遍历、再被删除位图过滤掉。

wait_merging_threads 与 Drop

IndexWriter::wait_merging_threads 消费掉 writer:关 channel、join 索引线程,然后 wait_merging_thread 等 MergeOperation 的 inventory 变空。合并结束时 end_merge 会再 consider_merge_options,新起的合并会先登记进 inventory、旧的才释放,所以连锁的合并也会一起等完。

不调它、直接 drop 呢?Drop 第一步就是 segment_updater.kill()(见 IndexWriter)。合并线程不受影响,照样写完新段,但 end_merge 要 schedule_task,而 killed 的 SegmentUpdater 直接返回错误,替换旧段、写 meta.json 都不会发生,新段文件成了孤儿。wait_merging_thread 的注释说「Terminating your application without letting merge terminate is perfectly safe」:数据确实安全,代价是这次合并白做了。

如果 writer 活得足够久,下一次提交时合并早已回来,这无所谓。本站的搜索模块恰好是另一种用法:每次 upsert 都 index.writer(50_000_000)、delete_term + add_document、commit()、reader.reload(),然后 writer 出作用域被 drop。用 1000 篇维基摘要先建一次索引,再按这个方式 upsert 40 次(每次之间停 50 ms):

     bulk: segments=  3 files=  22 commit=   0.0ms ["643/643", "284/284", "73/73"]
 upsert 1: segments=  4 files=  29 commit=  72.8ms ["642/643", "284/284", "73/73", "1/1"]
 upsert 2: segments=  5 files=  35 commit=  80.8ms ["641/643", "284/284", "73/73", "1/1", "1/1"]
...
 upsert 8: segments= 11 files=  76 commit= 105.6ms ["635/643", "284/284", "73/73", "1/1", "1/1", "1/1"] ...
upsert 10: segments= 13 files=  87 commit= 102.3ms ["633/643", "284/284", "73/73", "1/1", "1/1", "1/1"] ...
upsert 20: segments= 23 files= 149 commit= 110.8ms ["627/643", "280/284", "73/73", "1/1", "1/1", "1/1"] ...
upsert 30: segments= 33 files= 209 commit= 102.9ms ["622/643", "277/284", "71/73", "1/1", "1/1", "1/1"] ...
upsert 40: segments= 43 files= 269 commit= 113.6ms ["614/643", "275/284", "71/73", "1/1", "1/1", "1/1"] ...
    final: segments= 43 files= 272 commit=   0.0ms ["614/643", "275/284", "71/73", "1/1", "1/1", "1/1"] ...
num_docs=1000 total upsert time 4311ms

40 次 upsert 之后 43 个段,每次加一个,一次也没合并成功;被替换的旧版本一直留在三个大段里(643 篇的段只剩 614 篇存活)。打开 tantivy 的 debug 日志,能看到第 5 次 upsert 的提交里合并已经开始,writer 几毫秒后就被 drop,合并线程照样把新段写完,但日志里再也没有 End merge:

[  880.7ms DEBUG segment_updater tantivy::indexer::segment_updater] Starting merge  - [Seg("06d62d2d"), Seg("a2481057"), Seg("ef7272a9"), Seg("7c0e1cc5"), Seg("2daa4fd7"), Seg("9f8f5db6"), Seg("edf38fa9"), Seg("3e1dfae1")]
[  884.6ms DEBUG main tantivy::directory::mmap_directory] Releasing lock ".tantivy-meta.lock"
[  884.7ms DEBUG merge_thread_0 tantivy::indexer::merger] merge-plugin: ["fieldnorm", "term", "idx", "pos"]
[  884.8ms DEBUG main tantivy::directory::mmap_directory] Releasing lock ".tantivy-writer.lock"
...
[ 1010.7ms DEBUG merge_thread_0 tantivy::indexer::merger] merge-plugin: ["store"]
[ 1010.8ms DEBUG merge_thread_0 tantivy::store::plugin] write-storable-fields
[ 1024.5ms DEBUG merge_thread_0 tantivy::store::plugin] write-storable-fields took 13.71ms
[ 1054.9ms DEBUG segment_updater tantivy::indexer::segment_updater] Starting merge  - [Seg("06d62d2d"), Seg("a2481057"), Seg("2daa4fd7"), Seg("edf38fa9"), Seg("ef7272a9"), Seg("9f8f5db6"), Seg("7c...

下一个 writer 的 SegmentUpdater 是新建的,不知道上一个合并的存在,于是又对同一批段发起一次合并,再次被丢掉。每次 upsert 都多做一次「重写整个小索引」的活,而且和下一次提交抢 CPU(第 6 次以后提交从 80 ms 涨到 100 ms 以上)。用 0.26.1(本站的版本)跑同一个程序,40 次后同样是 43 个段。

对照两种改法:同一个 writer 反复提交,或者每次提交后调 wait_merging_threads() 再丢掉 writer:

# 每次 upsert 后 wait_merging_threads
 upsert 5: segments=  7 files=  47 commit=  71.5ms ["994/998", "1/1", "1/1", "1/1", "1/1", "1/1"] ...
 upsert 6: segments=  1 files=  10 commit= 169.2ms ["1000/1000"]
upsert 40: segments=  7 files=  47 commit=  74.7ms ["994/1000", "1/1", "1/1", "1/1", "1/1", "1/1"] ...
# 一个 writer 用到底
 upsert 5: segments=  8 files=  55 commit=  76.2ms ["689/693", "305/306", "1/1", "1/1", "1/1", "1/1"] ...
 upsert 6: segments=  2 files=  17 commit=  98.5ms ["999/1000", "1/1"]
   waited: segments=  1 files=  10 commit=   0.0ms ["1000/1000"]

(两段输出各自摘了几行,第一行注释是加的。)段数都被压在 8 以下;wait_merging_threads 的那次 upsert 要多等一次合并(169 ms)。本站的具体情况和改法在在本站中讨论。

📝NOTE
没有蠢问题

问: 段多一点有什么关系?反正提交还是 100 ms 左右。

答: 搜索是按段做的:每个段一个 SegmentReader,每个查询词在每个段的词典里各查一次,每个段各跑一遍 scorer 和 collector,ARCHITECTURE.md 也说「having hundreds can have a measurable impact on the search performance」。删除不回收则是另一笔账:被替换的旧版本还在倒排表里,BM25 的文档频率来自词典、总文档数用的是各段 max_doc 之和,都把删掉的文档算在内(BM25)。目录里的文件数和 mmap 的数量也跟着段数线性增长,上面 43 个段是 272 个文件。

索引排序

IndexSettings::sort_by_field 让段内的 doc id 按某个快速字段的值排序,而不是按加入顺序。设置在建索引时给出,写进 meta.json 的 index_settings:

  let settings = IndexSettings {
    sort_by_field: Some(IndexSortByField { field: "ts".to_string(), order: Order::Desc }),
    ..Default::default()
  };
  let index = Index::builder().schema(schema.clone()).settings(settings).create_in_ram()?;

建索引时 IndexBuilder::validate 检查字段:必须存在、必须是快速字段、类型限于 I64、U64、F64、Date、Str、Bytes。Str 和 Bytes 是 0.27.0 新支持的。

排序发生在两处。段定稿时,SegmentWriter::finalize 用 get_doc_id_mapping_from_field 按快速字段的值算出新旧 doc id 的映射,各插件按映射写文件;doc store 不能边写边排,所以排序索引的 store 先写进一个不压缩的临时文件(SegmentComponent::TempStore),定稿时再按新顺序重写成正式的 .store。doc_opstamps 也按映射重排,删除照样按 opstamp 判断。合并时,如果各段在排序字段上首尾不重叠,直接拼接;否则 generate_doc_id_mapping_with_sort_by_field 对各段做 k 路归并,MappingType::Shuffled。

per segment, in doc id order (ts; missing = MAX): [[50, 40, 30, 20, 10], [45, 35, 18446744073709551615]]
after merge: [[50, 45, 40, 35, 30, 20, 10, 18446744073709551615]]
sort by non-fast uid: An invalid argument was passed: 'Field uid is no fast field. Field needs to be a single value fast field to be used to sort an index'
multi-valued ts: commit Ok, order [[5, 7]]
  • 第一段五篇按 ts 降序排好;第二段加了 ts 为 45、5、35 的三篇和一篇没有 ts 的,同一批里删掉了 ts = 5 那篇。删除在排序之后仍然命中了正确的文档,没有 ts 的排在降序的最后(输出里用 u64::MAX 表示缺值)。
  • 两段 ts 区间重叠,合并走归并,结果全局有序。
  • 错误信息说「needs to be a single value fast field」,但多值字段并没有被拒:一篇文档给 ts 两个值(7 和 3),提交成功,排序按其中一个值进行(这里表现为按 7)。
📝NOTE
冷知识:索引排序在 0.24 被删掉,0.27.0 又加了回来

PR #2434(「remove index sorting」,首次发布在 0.24,CHANGELOG 记为「remove index sorting」)把 sort_by_field 整个拿掉,提交说明只有一句「closes https://github.com/quickwit-oss/tantivy/issues/2352」。两年后 PR #2959(「feat: Restore index sorting」,首次发布在 0.27.0)由一个一直在自己的分叉里维护这个功能的下游项目送回来。说明里给了理由:他们按 PostgreSQL 的 ctid 排序索引,结果要逐条对照 Postgres 的可见性信息,按 ctid 顺序检查对缓存友好得多,「resulting in up to 80% speedups for certain queries」;另一个理由是他们接下来要提交「makes Tantivy's segment components extensible」的重构,没有索引排序的话冲突太多,这个重构就是本卷多处提到的插件化(PR #2993)。这次恢复一并恢复了 SegmentComponent::TempStore、加上了 Str/Bytes 排序字段和 NULL 顺序的修复。仓库里的 doc/src/index_sorting.md 还停在「tantivy 0.16 does not do this optimization yet」的年代,链接指向一个旧提交里的 src/core/index_meta.rs。

动脑筋答案

每次提交后数一次段,最后等合并:

A. segments after each of 20 commits: 1 2 3 4 5 6 7 8 9 3 4 5 6 7 8 9 3 4 5 6
A. wait_merging_threads took 233.833µs (total 1.548405291s); now 6 segments [750, 50, 50, 50, 50, 50]
  • 6 个段:[750, 50, 50, 50, 50, 50]。所有段都不到 1 万篇,按 min_layer_size 都算最底层,凑够 8 个就整层合并。
  • 第 8 次提交凑够 8 个段,合并开始;第 9 次提交时合并还没回来,9 个段;第 10 次之前回来了,8 个换成 1 个 400 篇的段,加上第 9、10 次的两个,共 3 个。
  • 第二轮在第 15 次提交又凑够 8 个(400 篇那个也在最底层),合并出 750 篇;第 16 到 20 次的 5 个段不够 8 个,wait_merging_threads 时已经没有合并在跑,几百微秒就返回。
  • 五次运行里这一列有两种:第 17 个数是 3 或 10,取决于第二轮合并在第 17 次提交之前还是之后回来;最终状态五次都一样。换成 NoMergePolicy,段数就是 1 到 20。

小结

  • 收段、提交结束、合并结束时 SegmentUpdater 各考虑一次合并;已提交和未提交的段分开算,单个段只有带删除时才交给策略;正在合并的段由 MergeOperation 占住。
  • LogMergePolicy 默认:每层至少 8 段,1 万篇以下全算最底层,层宽 log2 差 0.75,1000 万篇以上不再合并,删除比例阈值 1.0 等于不看删除。
  • 合并在线程池里先 advance_deletes、再按 doc id 映射让各插件重写文件;end_merge 回到 SegmentUpdater 线程补删除、换段、用原 opstamp 重写 meta.json、再考虑合并、GC。
  • 删除的文档只在段被重写时消失;IndexWriter::merge 可以手动合并,set_del_docs_ratio_before_merge 让删除也能触发合并。
  • wait_merging_threads 等完所有合并;直接 drop 时合并照做、结果丢弃。每次新开 writer、提交后立刻 drop 的用法(本站就是)下合并永远不生效,0.27.0 和 0.26.1 实测 40 次 upsert 都是 43 个段。
  • 0.27.0 恢复了 sort_by_field:段定稿和合并时按快速字段重排 doc id,支持 Str/Bytes;多值字段不会被拒。

💬 关联讨论

发起讨论
加载中...