合并
主线位置:写入主线的最后一步。提交结束时
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_segments | 8 | 一层里至少这么多段才合并 |
max_docs_before_merge | 1000 万 | 存活文档超过它的段不再参与合并 |
min_layer_size | 10000 | 小于它的段按 10000 算,全部落在最底层 |
level_log_size | 0.75 | 同一层内 log2(文档数) 的最大跨度 |
del_docs_ratio_before_merge | 1.0 | 某段删除比例超过它,这一层不够 8 个也合并 |
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() 等它做完:
- 合并期间可能又有删除被提交了。如果新段的删除游标落后于当前
meta.json的 opstamp,就对新段再advance_deletes一次。 segment_manager.end_merge用新段替换旧段。- 被合并的是已提交的段时,用原来的 opstamp 和 payload 重写
meta.json:合并不改变索引的逻辑内容,opstamp 不动。 - 再
consider_merge_options一次:新段可能和别的段凑成新的一层。 - GC,删掉旧段的文件。
一个 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)。本站的具体情况和改法在在本站中讨论。
问: 段多一点有什么关系?反正提交还是 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)。
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;多值字段不会被拒。