一个段
主线位置:磁盘主线全程。一次提交之后目录里有什么、
meta.json写了什么、每个段文件存什么、一个词从哪个文件找到哪个文件;每个文件只讲到能认出它的深度,格式细节链到 04 存储格式。
flowchart TB meta["meta.json 段清单与 schema"] --> seg["段 uuid"] managed[".managed.json 文件清单"] --> seg seg --> term[".term 词典"] term --> idx[".idx 倒排表"] term --> pos[".pos 位置"] seg --> fnorm[".fieldnorm 字段长度"] seg --> store[".store 原文"] seg --> fast[".fast 列存"]
程序
和一次写入同一个程序、同样三篇文档,只把 writer 换成一个线程(writer_with_num_threads(1, 15_000_000)),三篇文档落进同一个段,方便看(release)。提交、wait_merging_threads 之后的目录:
258 .managed.json
0 .tantivy-meta.lock
0 .tantivy-writer.lock
146 0da44b1601a146fd8142ee36fa955622.fast
116 0da44b1601a146fd8142ee36fa955622.fieldnorm
429 0da44b1601a146fd8142ee36fa955622.idx
442 0da44b1601a146fd8142ee36fa955622.pos
238 0da44b1601a146fd8142ee36fa955622.store
1794 0da44b1601a146fd8142ee36fa955622.term
784 meta.json
三类文件:
meta.json:哪些段算数、schema、最后一次提交的 opstamp。整个索引的「当前版本」就是它。.managed.json:ManagedDirectory记下的「归 tantivy 管」的文件清单,GC 只删这里面、又不被任何段引用的文件。- 段文件:
<段 uuid>.<扩展名>,一个段六个。uuid 在文件名里去掉了连字符,在meta.json里带着。
两个锁文件都是 0 字节:锁靠 flock,文件本身一直留着(见怎么读这一卷)。还有一个细节:ls -la 里 meta.json 和 .managed.json 的权限是 -rw-------,段文件是 -rw-r--r--。前两个走 atomic_write:先用 tempfile 在同目录建临时文件、写完 sync_data、再改名,tempfile 建的文件是 0600。索引目录要给另一个用户的进程读时,读不了的是 meta.json。
meta.json 原文
{
"index_settings": {
"docstore_compression": "lz4",
"docstore_blocksize": 16384
},
"segments": [
{
"segment_id": "0da44b16-01a1-46fd-8142-ee36fa955622",
"max_doc": 3,
"deletes": null
}
],
"schema": [
{
"name": "title",
"type": "text",
"options": {
"indexing": {
"record": "position",
"fieldnorms": true,
"tokenizer": "default"
},
"stored": true,
"fast": false
}
},
{
"name": "body",
"type": "text",
"options": {
"indexing": {
"record": "position",
"fieldnorms": true,
"tokenizer": "default"
},
"stored": false,
"fast": false
}
}
],
"opstamp": 4
}
逐项:
index_settings:文档存储用 lz4、每块 16384 字节。IndexSettings里还有sort_by_field(没设时不写)和docstore_compress_dedicated_thread(默认true时不写),所以这里只看到两项。segments:每个段只记三样:uuid、max_doc(段里的文档数,含已删除的)、deletes(有删除时是删除位图对应的 opstamp 和删除数)。段里有哪些文件不记,靠扩展名推出来。多个段时按max_doc从大到小排,见一次写入的三段索引。schema:TEXT | STORED展开成record: position(存词频和位置)、fieldnorms: true、tokenizer: "default"、stored: true。分词器只存名字,打开索引时按名字到TokenizerManager里找,见分词。opstamp:4。一个线程时三篇文档用掉 0 到 2,段登记时合并检查取走 3,提交取 4,见一次写入。- 没有
payload:prepare_commit后set_payload才会有。
meta.json 里没有格式版本号,版本写在每个文件末尾的 footer 里。
六个文件
六个扩展名来自 SegmentComponent,由三个内置插件写出(见源码地图)。Searcher::space_usage() 能按组件、按字段报告占用:
num_docs=3 total=2.46 KB
termdict total=1.68 KB [body=1.47 KB, title=209 B]
postings total=318 B [body=284 B, title=34 B]
positions total=332 B [body=307 B, title=25 B]
fast_fields total=0 B []
fieldnorms total=6 B [body=3 B, title=3 B]
store total=117 B data=110 B offsets=7 B
deletes 0 B
和目录清单对照:
| 文件 | 大小 | 内容 | 写它的插件 | 讲它的页 |
|---|---|---|---|---|
.term | 1794 | 每个字段一个词典:FST 把词映射到序号,TermInfoStore 按序号给出 TermInfo(文档数、在 .idx 和 .pos 里的偏移) | 倒排索引 | 词典 |
.idx | 429 | 每个词的倒排表:文档号和词频,128 篇一块 bitpacking,带跳表 | 倒排索引 | 倒排表 |
.pos | 442 | 每个词在每篇文档里出现的位置,短语查询用 | 倒排索引 | 位置 |
.fieldnorm | 116 | 每篇文档每个字段的长度,一个字节 | 倒排索引 | fieldnorm 与删除位图 |
.store | 238 | STORED 字段的原文,lz4 压缩,16 KB 一块 | 存储 | 文档存储 |
.fast | 146 | 快速字段的列存;这个 schema 没有快速字段,是个空列存 | 快速字段 | 快速字段 |
space_usage 的合计 2.46 KB 比六个文件的 3165 字节小:它只数数据本身。.term 占了一半多,因为每个字段的 FST 和 TermInfoStore 都有固定的开销,三篇文档的词太少,摊不开;body 的 1.47 KB 对应三段 body 里的一百多个不同的词。没有删除,所以没有 .del 文件:删除位图是提交时有删除才写的 <uuid>.<opstamp>.del,文件名里带 opstamp,因为同一个段在不同的提交上可以有不同的删除位图。
.term、.idx、.pos、.fieldnorm 四个都是复合文件(CompositeFile):每个字段一段数据,文件尾部一张小目录记录每个字段从哪开始。最小的 .fieldnorm 正好能整个看清:
00000000: 0604 041b 3a2c 8280 0000 0000 8083 0100 ....:,..........
00000010: 0000 800d 0000 007b 2276 6572 7369 6f6e .......{"version
00000020: 223a 7b22 6d61 6a6f 7222 3a30 2c22 6d69 ":{"major":0,"mi
00000030: 6e6f 7222 3a32 372c 2270 6174 6368 223a nor":27,"patch":
00000040: 302c 2269 6e64 6578 5f66 6f72 6d61 745f 0,"index_format_
00000050: 7665 7273 696f 6e22 3a37 7d2c 2263 7263 version":7},"crc
00000060: 223a 3337 3736 3935 3137 307d 5500 0000 ":377695170}U...
00000070: 3905 0000 9...
06 04 04:title字段三篇文档的长度 6、4、4。1b 3a 2c:body字段三篇的 fieldnorm 字节 27、58、44。27 就是一次查询里explain的dl = 27;后两篇的 body 是 107 和 49 个词(explain里的avgdl = 61正是(27 + 107 + 49) / 3),存下来的 58 和 44 是量化后的编号,不是长度,查表方法见 fieldnorm 与删除位图。82 80 00000000 80 83 01000000 80加0d000000:复合文件的目录,CompositeWrite::close写的。tantivy 的 VInt 用最高位标记「最后一个字节」,82是 2(两个字段),80是偏移增量 0、接 4 字节的字段号 0 和下标 0,83是偏移增量 3、接字段号 1 和下标 0;0d000000是这张目录的长度 13。- 从
7b({)开始到最后:footer。
每个文件末尾的 footer
每个段文件都以同样的结构结尾:一段 JSON、4 字节的 JSON 长度、4 字节的魔数。逐个读出来:
116 0da44b1601a146fd8142ee36fa955622.fieldnorm footer_json_len= 85 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":377695170}
1794 0da44b1601a146fd8142ee36fa955622.term footer_json_len= 85 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":488398474}
429 0da44b1601a146fd8142ee36fa955622.idx footer_json_len= 86 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":3153897209}
442 0da44b1601a146fd8142ee36fa955622.pos footer_json_len= 85 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":171450267}
146 0da44b1601a146fd8142ee36fa955622.fast footer_json_len= 86 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":1818584480}
238 0da44b1601a146fd8142ee36fa955622.store footer_json_len= 85 magic= 1337 {"version":{"major":0,"minor":27,"patch":0,"index_format_version":7},"crc":107955075}
Footer 只有两个字段:写这个文件的 tantivy 版本(含 index_format_version)和文件正文的 crc32。写入时 ManagedDirectory::open_write 返回的写入器套了一层 FooterProxy,边写边算 crc,terminate 时追加 footer。
读的一侧,ManagedDirectory::open_read 调 Footer::extract_footer 从末尾切下 footer,检查魔数,再用 is_compatible 看 index_format_version 是否落在 4 到 7 之间;crc 这时不算,打开一个文件不用把它整个读一遍。要校验内容,显式调 Index::validate_checksum,它对当前 meta.json 引用的每个文件算一遍 crc,返回对不上的文件集合。
现在的 footer 是 serde_json 写的一小段 JSON。0.15 之前它是二进制的:一个 VersionedFooter 枚举(V1、V2…各带 crc32 和 store_compression)、一个 meta 字符串、再一段版本 JSON。#1060(8d32c3ba3,「Change Footer version handling, Make compression dynamic」)把它换成了现在的 Footer { version, crc },并加上 FOOTER_MAGIC_NUMBER: u32 = 1337,提交说明的理由是「Simplify version handling by switching to JSON instead of binary serialization」。同一个 PR 把文档存储的压缩算法从编译期 feature 改成运行时由 IndexSettings 选择,所以上面 meta.json 里才会有 "docstore_compression": "lz4"。首次发布于 0.15。lib.rs 里 version_string() 的文档注释还举着旧格式的例子「tantivy v0.11.0, index_format v1, store_compression: lz4」,store_compression 正是旧 footer 里的字段;0.27.0 实测输出是 tantivy v0.27.0, index_format v7。魔数对不上时,extract_footer 报的错就是「index was created using old an tantivy version which is not supported anymore. Please use tantivy 0.15 or above to recreate the index.」
一个词怎么找到
以一次查询里 body 字段的 old 为例,磁盘主线经过这些文件:
meta.json给出段0da44b16…,SegmentReader::open用MmapDirectory把六个文件 mmap 进来,每个都先过一遍 footer 检查。见 Directory。.term的复合目录找到body(字段 1)那一段,FST 查old得到序号,TermInfoStore按序号给出TermInfo:文档数 1、倒排表在.idx里的字节范围、位置在.pos里的起点。见词典。.idx按TermInfo的范围读出倒排表:文档 0,词频 1。不足 128 篇,整张表是一个 VInt 编码的尾块。见倒排表。- 打分要
dl,从.fieldnorm的body一段取第 0 个字节1b= 27。见 fieldnorm 与删除位图。 - 只有短语查询才读
.pos;old man是两个独立的词,不读。见位置。 - 命中后
searcher.doc读.store:按跳表找到文档 0 所在的块,lz4 解压,反序列化出title。见文档存储。 .fast这次用不到;按字段排序、聚合、快速字段上的范围查询才读它。见快速字段。
一个段打开的代价因此很小:mmap 六个文件、读几个 footer 和复合目录,不加载任何词典或倒排表。实验 startup 把 open_in_dir → reader → 查询 → 取一篇原文连着计时,三篇文档的这个索引,连跑 10 次,第一次累计 5.47 ms,之后最小 0.28 ms(release,负载约 7);1000 篇维基摘要、808 KB、3 个段的索引最小 0.54 ms。README 说「Tiny startup time (<10ms)」,在这两个小索引上成立;大索引的第一次查询还要等操作系统把用到的页调进来。
六个文件加起来 3165 字节(146 + 116 + 429 + 442 + 238 + 1794)。footer 的结构上面已经给出:JSON、4 字节长度、4 字节魔数。不看下面的答案,先算一算这个三篇文档的段里有多少字节是 footer,占几成。答案在动脑筋答案。
动脑筋答案
560 字节,约 18%。
每个文件的 footer = JSON 长度 + 8。JSON 是 85 或 86 字节,差别只在 crc 的十进制位数:3153897209 和 1818584480 是十位数,其他四个是九位数。所以 4 个文件各 93 字节、2 个文件各 94 字节,合计 4 × 93 + 2 × 94 = 560,占 3165 字节的 17.7%。
.fast 最极端:146 字节里 94 字节是 footer,剩下 52 字节是一个零列的列存的骨架。段越大,footer 占比越接近零;但「每篇文档一个段」的写法(比如一次写入末尾那 60 个段)会把这份固定开销乘上段数:每段六个文件、每个文件至少 93 字节 footer,查询时还要逐段打开、逐段查词典。
小结
- 一次提交后的目录:
meta.json、.managed.json、两个 0 字节锁文件,每个段六个<uuid>.<扩展名>文件;有删除时再多一个<uuid>.<opstamp>.del。 meta.json记index_settings、每个段的 uuid、max_doc、deletes,完整的 schema,以及 opstamp;不记段有哪些文件,也不记格式版本。它和.managed.json是原子写的,权限 0600。.term、.idx、.pos、.fieldnorm是按字段分段的复合文件;.store是原文,.fast是列存,没有快速字段时也有一个空列存。- 每个文件末尾是 JSON footer(版本加 crc32)、长度和魔数 1337;打开时只查版本,crc 要
validate_checksum才算。 .fieldnorm的头几个字节就是 BM25 的dl:06 04 04和1b。- 三篇文档的段 3165 字节,footer 占 560;打开一个小索引并查一次,热缓存下在 1 ms 以内,第一次 5.47 ms。