SegmentWriter 与内存池
主线位置:写入主线的第二、三步。索引线程从 channel 取到那三篇文档,交给它当前的
SegmentWriter:分词,词项和倒排表写进内存池,字段长度、快速字段各自记账,原文直接流进.store文件。内存池快满或者commit时,finalize把整个段写到磁盘。
flowchart TB
doc["AddOperation"] --> sw["SegmentWriter add_document"]
sw --> inv["倒排写手"]
sw --> ff["快速字段写手"]
sw --> st["doc store 写手"]
inv --> tok["分词"]
tok --> sub["subscribe"]
sub --> hm["ArenaHashMap 词项"]
sub --> ar["MemoryArena 倒排表"]
inv --> fn["fieldnorm 缓冲"]
ff --> col["columnar 写手"]
st --> file["边写边压缩 store"]
sw --> chk{"mem_usage 满了"}
chk -->|是| fin["finalize"]
chk -->|否| doc
本页的代码分在四处:src/indexer/segment_writer.rs(992 行,实现约 220 行),倒排写手 src/index/inverted_index_plugin.rs(722 行,含合并),src/postings/ 里的 postings_writer.rs、recorder.rs,以及 stacker crate(tantivy-stacker 0.27.0,src/ 下 1742 行)。
实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。schema 是三个字段:uid(STRING | STORED)、title(TEXT | STORED)、body(TEXT)。语料是 benches/wiki.json:1000 篇,标题加正文共 1,069,673 字节;另有 benches/hdfs.json(10 万行日志)。
0.27.0 的 SegmentWriter:三个插件写手
0.27.0 之前,SegmentWriter 自己持有倒排表、fieldnorm、快速字段和 doc store 的写手,自己处理每种字段类型。PR #2993(「Extensible segment components via plugin trait」)把一个段拆成若干「插件」,每个插件拥有几种文件扩展名,负责写、合并和统计空间。内置的三个按写入顺序排在 builtin_plugins 里:InvertedIndexPlugin(fieldnorm、term、idx、pos 四个文件)、FastFieldsPlugin(fast)、StorePlugin(store)。SegmentWriter 只剩下分发:
pub struct SegmentWriter {
pub(crate) max_doc: DocId,
pub(crate) segment: Segment,
pub(crate) inverted_index: InvertedIndexPluginWriter,
pub(crate) fast_fields: FastFieldsPluginWriter,
pub(crate) store_writer: StorePluginWriter,
custom_plugins: Vec<Box<dyn PluginWriter>>,
pub(crate) doc_opstamps: Vec<Opstamp>,
schema: Schema,
}
add_document 把 opstamp 记进 doc_opstamps(删除要用,见提交),doc id 就是当前的 max_doc,然后依次调三个内置写手:
let AddOperation { document, opstamp } = add_operation;
let doc_id = self.max_doc;
self.doc_opstamps.push(opstamp);
// Built-in writers index the document generically — zero-copy for any `Document` type.
self.inverted_index.index_document(doc_id, &document)?;
self.fast_fields.index_document(&document)?;
self.store_writer.store(&document, &self.schema)?;
内置写手是具体类型,泛型地接收任何 D: Document;只有自定义插件(custom_plugins)走 trait 对象,拿到的是类型擦除的 &dyn ErasedDocument。自定义插件(SegmentPlugin)本卷只提这一句。
三个写手在 SegmentWriter::for_segment 里构造,构造时就打开了各自的输出文件:倒排写手打开 .fieldnorm 和 .term/.idx/.pos,快速字段写手打开 .fast,doc store 打开 .store。所以IndexWriter那个 drop 实验里,被丢弃的段留下了一组 0 字节的文件,只有 .store 不是空的。
倒排表在内存里
InvertedIndexPluginWriter::index_document 先把文档的值按字段排序、分组,跳过没有索引的字段,再按字段类型处理。文本字段拿这个字段的分词器(TextAnalyzer,构造时按 schema 里的名字从 TokenizerManager 取,取不到就是 SchemaError("Error getting tokenizer for field: ..."),见分词)跑一遍,把每个词元交给 index_text;数值、日期、IP 等字段直接把值编码进词项,一个值一个词项。文本字段最后用词元个数记一笔 fieldnorm(见 fieldnorm 与删除位图)。
index_text 拼出完整的词项字节(字段号 + 类型码 + 词元,布局见 Term),算出位置,调 subscribe。超过 MAX_TOKEN_LEN(u16::MAX - 5 = 65530 字节)的词元打一条 warn 日志后丢弃。同一字段的多个值之间位置空一格(POSITION_GAP = 1),短语查询不会跨值匹配。
一张表,两个池
所有字段的词项共用一张哈希表。IndexingContext:
pub(crate) struct IndexingContext {
/// The term index is an adhoc hashmap,
/// itself backed by a dedicated memory arena.
pub term_index: ArenaHashMap,
/// Arena is a memory arena that stores posting lists / term frequencies / positions.
pub arena: MemoryArena,
pub path_to_unordered_id: PathToUnorderedId,
}
MemoryArena 是 stacker 里的内存池:一个 Vec<Page>,每页 1 MB(PAGE_SIZE = 1 << 20),地址 Addr 是 32 位,高 12 位是页号、低 20 位是页内偏移。放进去的东西必须是 Copy、不能有 Drop,按未对齐的方式读写,所以 API 只给 read/write_at,不给引用。好处是分配就是在当前页上挪一下指针,整个池丢掉就是释放几十个大块,mem_usage 就是 页数 × 1 MB,精确且便宜。
ArenaHashMap 是开放寻址(线性探测)的哈希表,表本身是 Vec<KeyValue>,每项 8 字节(32 位地址 + 32 位哈希);键和值不在表里,而在它自己的另一个 MemoryArena 里,按「2 字节长度 + 键 + 值」连续存放。表填到一半就翻倍(is_saturated:table.len() <= len * 2)。
subscribe 是倒排表的核心,SpecializedPostingsWriter::subscribe:
let (term_index, arena) = (&mut ctx.term_index, &mut ctx.arena);
term_index.mutate_or_create(term.serialized_term(), |opt_recorder: Option<Rec>| {
if let Some(mut recorder) = opt_recorder {
let current_doc = recorder.current_doc();
if current_doc != doc {
recorder.close_doc(arena);
recorder.new_doc(doc, arena);
}
recorder.record_position(position, arena);
recorder
} else {
let mut recorder = Rec::default();
recorder.new_doc(doc, arena);
recorder.record_position(position, arena);
recorder
}
});
哈希表里每个词项的值是一个 Recorder,二三十字节的 Copy 结构;它记着当前文档和一个 ExpUnrolledLinkedList 的头尾地址,真正的倒排数据写在 ctx.arena 里。Recorder 有三种,按字段的 IndexRecordOption 选(posting_writer_from_field_entry):
| Recorder | 选用 | 每篇文档写进链表的内容 |
|---|---|---|
DocIdRecorder | Basic;STRING 字段、数值、日期、facet | doc id 差值(vint) |
TermFrequencyRecorder | WithFreqs | doc id 差值、词频 |
TfAndPositionRecorder | WithFreqsAndPositions;TEXT 字段 | doc id 差值、每个位置 + 1、结束标记 |
ExpUnrolledLinkedList 是「块大小指数增长的展开链表」:第一块 8 字节,之后每块翻倍,到 32 KB 封顶;每块末尾留一个 Addr 指向下一块。只出现一两次的词只占一个 8 字节的块,高频词的块越来越大,摊下来每字节的链接开销很小。
想象一间档案室。门口有一个卡片柜(哈希表 table),每个词一张卡片,卡片上只写「去仓库哪一格找这个词的登记卡」(key_value_addr)。登记卡(键加 Recorder)放在一号仓库(term_index 自己的 MemoryArena),上面记着这个词最近一次出现在哪篇文档(current_doc),以及它的笔记本放在二号仓库的什么位置(head/tail)。
笔记本(ExpUnrolledLinkedList)第一本只有 8 页,写满了就去二号仓库(ctx.arena)领一本两倍厚的,在旧本最后一页写下新本的位置(块尾的 Addr),最厚 32 KB 一本。两个仓库都按整箱(1 MB 的 Page)进货,管理员数箱子就知道用了多少地方(mem_usage)。
快速字段和 doc store
快速字段写手是 columnar crate 的 ColumnarWriter:它也有一个 MemoryArena,外加数值、布尔、字节、字符串、IP、日期六张 ArenaHashMap(按列名找列),每张表各带一个自己的 MemoryArena。列存的格式在快速字段。
doc store 不进内存池。StorePluginWriter 把每篇文档序列化进当前块,块满(默认 16 KB)就交给压缩器写进 .store;默认开 docstore_compress_dedicated_thread,压缩在单独的 docstore-compressor-thread 上做(DedicatedThreadBlockCompressorImpl::new)。所以原文是边索引边落盘的,它在 mem_usage 里只占当前块的缓冲。块格式见文档存储。
什么时候算满
判满不在 SegmentWriter 里,在索引线程的 index_documents:
let mut segment_writer = SegmentWriter::for_segment(memory_budget, segment.clone())?;
for document_group in grouped_document_iterator {
for doc in document_group {
segment_writer.add_document(doc)?;
}
let mem_usage = segment_writer.mem_usage();
if mem_usage >= memory_budget - MARGIN_IN_BYTES {
debug!(
"Buffer limit reached, flushing segment with maxdoc={}.",
segment_writer.max_doc()
);
break;
}
}
每处理完一批(add_document 是一篇一批)查一次 mem_usage,到了 预算 - 1_000_000(MARGIN_IN_BYTES)就停下定稿。留 1 MB 余量,是因为一批文档可以很大,检查只在批与批之间做。
mem_usage 是三个写手之和。倒排写手是两张表、两个池加 fieldnorm 缓冲;快速字段写手是 columnar 的一个池加六张表;doc store 是压缩前的块缓冲。问题在起点。用 SegmentWriter::for_segment 直接建空的段写手,量 mem_usage:
one TEXT field budget 15000000: 13632680
one TEXT field budget 50000000: 13632680
one TEXT field budget 400000000: 13632680
one STORED-only field budget 15000000: 13631680
one STORED-only field budget 50000000: 13631680
one STORED-only field budget 400000000: 13631680
uid+title+body budget 15000000: 13634680
uid+title+body budget 50000000: 13634680
uid+title+body budget 400000000: 13634680
一篇文档都没写,就已经 13.6 MB,和预算、和 schema 几乎无关。按源码可以把它算到字节:
- 词项哈希表的初始容量由
compute_initial_table_size定:不超过每线程预算的三分之一的最大 2 的幂,封顶2^19。预算 15 MB 时三分之一是 5 MB,2^19 × 8= 4,194,304 字节已经放得下,所以只要预算不低于约 12.6 MB,表一律是 4 MiB。 - 9 个
MemoryArena各有一页:倒排的两个(词项池、倒排表池),columnar 的一个池和六张表各自的池。9 × 1,048,576= 9,437,184 字节。 - columnar 的六张表初始容量 4 项,各 32 字节,共 192 字节。
- 每个有 fieldnorm 的字段一个
Vec::with_capacity(1_000)的字节缓冲,1000 字节。
4,194,304 + 9,437,184 + 192 = 13,631,680,正好是只有一个存储字段(没有 fieldnorm)的读数;一个 TEXT 字段多 1000,三个字段(STRING 也有 fieldnorm)多 3000。
0.22 之前每线程最低只要 3 MB(HEAP_SIZE_MIN = MARGIN_IN_BYTES * 3),文档例子里常见 index.writer(3_000_000)。PR #2176(「increase min memory to 15MB for indexing」,首次发布在 0.22.0)的说明是:「With tantivy 0.20 the minimum memory consumption per SegmentWriter increased to 12MB. 7MB are for the different fast field collectors types (they could be lazily created). Increase the minimum memory from 3MB to 15MB.」同一个提交把变量名里的 arena 改成了 budget,常量成了今天的 MEMORY_BUDGET_NUM_BYTES_MIN。上面算出来的 13.6 MB 里,columnar 的 7 个池正好占 7 MB 多,说明里那句「could be lazily created」到 0.27.0 也还没有做。
这就是满不满的真正判据:预算 15 MB 时阈值是 14,000,000,本页 schema 的空写手(13,634,680)离阈值只剩 365,320 字节,任何一个池多领一页就超了。按 wiki.json 实测:
wiki: 1000 docs, 1069673 bytes of title+body
budget 15000000: mem_usage at 0 docs = 13634680, 1000 docs = 13661304
-> full after 1262 docs (1.26 passes over wiki), mem_usage = 14712880
budget 16666666: mem_usage at 0 docs = 13634680, 1000 docs = 13661304, 2000 docs = 14712880
-> full after 2604 docs (2.60 passes over wiki), mem_usage = 15767456
budget 50000000: mem_usage at 0 docs = 13634680, 1000 docs = 13661304, 2000 docs = 14712880, 3000 docs = 15767456
-> full after 49633 docs (49.63 passes over wiki), mem_usage = 49501888
mem_usage 按页跳:前 1000 篇只多了 26,624 字节(fieldnorm 缓冲在长),到第 1262 篇某个池领了第二页,一下跳到 14,712,880,段满。16,666,666 字节(writer(50_000_000) 的每线程预算)能多容一页,2604 篇满。整 50 MB 一个线程时可以写到 49,633 篇(同样的 1000 篇重复了约 50 遍,新词项很少,主要是倒排表在长)。
index.writer_with_num_threads(1, 15_000_000),合并策略 NoMergePolicy,把 wiki.json 的 1000 篇重复 20 遍(uid 各不相同)逐篇 add_document,最后提交一次。提交后有几个段?把预算换成 writer_with_num_threads(1, 50_000_000) 呢?
hdfs.json 是另一种形状:10 万行短日志,词项大量重复。单线程 15 MB 时分成 12 个段,每段约 8,700 行:
hdfs 100000 docs, 1 thread x 15000000 -> 12 segments [8777, 8753, 8731, 8730, 8699, 8662, 8554, 8542, 8513, 8502, 8456, 5081]
问: index.writer(50_000_000) 是不是说这个 writer 最多占 50 MB 内存?
答: 不是。预算只约束 mem_usage 统计到的那部分:两个倒排池、词项表、columnar 的池和表、fieldnorm 缓冲、doc store 当前块。channel 里积压的最多一万篇文档、分词器和压缩线程自己的缓冲、合并线程打开的段都不算。反过来,预算里有 13.6 MB 是空写手的固定开销,每个线程都要付一次,留给文档的只有 预算 - 1 MB - 13.6 MB:15 MB 的线程约 0.37 MB,这就是上面一个段只装 1262 篇的原因。
finalize:把段写到磁盘
段满、或者 channel 关闭(提交时)以后,index_documents 调 SegmentWriter::finalize。设了索引排序(IndexSettings::sort_by_field)时先算出新旧 doc id 的映射,见合并的最后一节;然后 finalize_inner 按顺序让每个写手序列化:
let segment = &self.segment;
Box::new(self.inverted_index).serialize(segment, mapping)?;
Box::new(self.fast_fields).serialize(segment, mapping)?;
Box::new(self.store_writer).serialize(segment, mapping)?;
for writer in self.custom_plugins {
writer.serialize(segment, mapping)?;
}
倒排写手(serialize)先写 .fieldnorm,再把它读回来,因为写倒排表时要算每块的 block-max 分数,用得到字段长度;然后 serialize_postings 把哈希表里所有词项按「字段、JSON 路径、词项字节」排序,逐个读出 Recorder 的链表写进 .term、.idx、.pos。内存里的哈希表是无序的,词典要有序(FST 只能按序构建),这次排序就是两者之间的转换。这些文件的格式分别在倒排表、位置和词典。
finalize 返回每篇文档的 opstamp。index_documents 接着用它们把删除队列里已有的删除落到这个新段上(apply_deletes),再把段包成 SegmentEntry,schedule_add_segment(...).wait() 交给 SegmentUpdater。这时段文件已经在磁盘上,但还没进 meta.json,读者看不到,这一步之后的事在提交。
动脑筋答案
IndexWriter 1 thread(s) x 15000000 bytes, 20000 wiki docs -> 16 segments [1297, 1287, 1284, 1274, 1274, 1269, 1266, 1262, 1253, 1252, 1248, 1243, 1240, 1228, 1222, 1101]
IndexWriter 1 thread(s) x 50000000 bytes, 20000 wiki docs -> 1 segments [20000]
IndexWriter 3 thread(s) x 16666666 bytes, 20000 wiki docs -> 9 segments [2735, 2655, 2641, 2612, 2592, 2534, 2210, 1043, 978]
- 15 MB:16 个段,前 15 个各在 1,220 到 1,300 篇之间满,和直接驱动
SegmentWriter量到的 1262 一致;最后一个 1101 篇是提交时没写满的残段。段的大小看的是「哪个池先领第二页」,不是文档数。 - 50 MB 单线程:两万篇离 49,633 篇的满点还远,一个段。
- 第三行是
writer(50_000_000)的真实配置(3 个线程,每个 16,666,666 字节):9 个段。每个线程 2,500 篇上下就满,提交时三个线程各交一个残段。
小结
- 0.27.0 的
SegmentWriter只做分发:opstamp 记进doc_opstamps,文档交给倒排、快速字段、doc store 三个内置插件写手;三个写手一构造就打开了各自的文件。 - 倒排表:所有字段共用一张
ArenaHashMap(线性探测,8 字节一项,键值在它自己的池里),每个词项的值是一个Recorder,倒排数据写在另一个MemoryArena的指数增长链表里;Recorder按IndexRecordOption分三种。 - doc store 边写边压缩落盘,默认每个段写手一个压缩线程,不占内存池。
- 判满:每批之后比较
mem_usage >= 预算 - 1 MB。空写手就有 13,631,680 字节加每个 fieldnorm 字段 1000 字节,来自 4 MiB 的词项表和 9 个 1 MB 页;15 MB 的线程实际只有约 0.37 MB 可用,wiki.json 约 1,260 篇一段。 finalize先写 fieldnorm 再按序写词典和倒排表,返回 opstamp 用来落删除,然后把段交给SegmentUpdater;进meta.json要等提交。