Rust in Everything
基于 tantivy 0.27.0

一个段

主线位置:磁盘主线全程。一次提交之后目录里有什么、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

和目录清单对照:

文件大小内容写它的插件讲它的页
.term1794每个字段一个词典:FST 把词映射到序号,TermInfoStore 按序号给出 TermInfo(文档数、在 .idx 和 .pos 里的偏移)倒排索引词典
.idx429每个词的倒排表:文档号和词频,128 篇一块 bitpacking,带跳表倒排索引倒排表
.pos442每个词在每篇文档里出现的位置,短语查询用倒排索引位置
.fieldnorm116每篇文档每个字段的长度,一个字节倒排索引fieldnorm 与删除位图
.store238STORED 字段的原文,lz4 压缩,16 KB 一块存储文档存储
.fast146快速字段的列存;这个 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。

每个段文件都以同样的结构结尾:一段 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,返回对不上的文件集合。

📝NOTE
冷知识:footer 在 0.15 改成了 JSON

现在的 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 为例,磁盘主线经过这些文件:

  1. meta.json 给出段 0da44b16…,SegmentReader::open 用 MmapDirectory 把六个文件 mmap 进来,每个都先过一遍 footer 检查。见 Directory。
  2. .term 的复合目录找到 body(字段 1)那一段,FST 查 old 得到序号,TermInfoStore 按序号给出 TermInfo:文档数 1、倒排表在 .idx 里的字节范围、位置在 .pos 里的起点。见词典。
  3. .idx 按 TermInfo 的范围读出倒排表:文档 0,词频 1。不足 128 篇,整张表是一个 VInt 编码的尾块。见倒排表。
  4. 打分要 dl,从 .fieldnorm 的 body 一段取第 0 个字节 1b = 27。见 fieldnorm 与删除位图。
  5. 只有短语查询才读 .pos;old man 是两个独立的词,不读。见位置。
  6. 命中后 searcher.doc 读 .store:按跳表找到文档 0 所在的块,lz4 解压,反序列化出 title。见文档存储。
  7. .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)」,在这两个小索引上成立;大索引的第一次查询还要等操作系统把用到的页调进来。

❗IMPORTANT
动脑筋:3165 字节里,footer 占多少?

六个文件加起来 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。

💬 关联讨论

发起讨论
加载中...