Rust in Everything
基于 tantivy 0.27.0

文档存储

主线位置:查询主线的最后一步。TopDocs 给出前 10 个 DocAddress 之后,searcher.doc(addr) 到这里取回原文:跳表找到文档所在的块,块缓存里没有就读出来解压,再按块尾的偏移表切出这一篇。也是磁盘主线的倒数第二站 .store。写入时,文档在 add_document 那一刻就进了这里,不等段定稿。

flowchart TB
  add["add_document"] --> sw["StoreWriter::store"]
  sw --> cur["当前块与 doc_pos"]
  cur -- 约 16 KB 满 --> comp["压缩线程"]
  comp --> data[".store 数据区"]
  comp --> sk["SkipIndexBuilder"]
  sk --> tail["跳表与 footer"]
  get["searcher.doc"] --> seek["SkipIndex::seek"]
  seek --> cache{"块缓存"}
  cache -- 命中 --> cut["按偏移切出文档"]
  cache -- 未命中 --> dec["读块并解压"]
  dec --> cut

代码在 src/store/,13 个文件 2813 行(按 tag 0.27.0 统计,含单元测试)。一篇文档在块里是什么字节(字段数,再逐个写字段号、类型码、值),在 文档 一页讲过,这里把它当成一段不透明的字节。.store 文件末尾还有一个目录层加的 footer(版本和 crc),见 Directory。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0 打开 zstd-compression feature,release 构建。语料是仓库里的 benches/wiki.json(1000 篇维基摘要,1.1 MB)和 benches/hdfs.json(10 万行 HDFS 日志,22 MB),都用一个索引线程写成一个段。

一个块里有什么

StoreWriter::store 把文档序列化进当前块,同时记下它在块里的起始偏移:

    pub fn store<D: Document>(&mut self, document: &D, schema: &Schema) -> io::Result<()> {
        self.doc_pos.push(self.current_block.len() as u32);

        let mut serializer = BinaryDocumentSerializer::new(&mut self.current_block, schema);
        serializer.serialize_doc(document)?;

        self.num_docs_in_current_block += 1;
        self.check_flush_block()?;
        Ok(())
    }

check_flush_block 判断块满没满:

        let index_len = self.doc_pos.len() * std::mem::size_of::<usize>();
        if self.current_block.len() + index_len > self.block_size {
            self.send_current_block_to_compressor()?;
        }

块满的时候,send_current_block_to_compressor 在块尾追加每篇文档的偏移(u32 小端)和文档数(u32),整块交给压缩器。所以一个块解压以后是:

[doc 0 字节][doc 1 字节]...[doc n-1 字节][off 0][off 1]...[off n-1][n]

取第 k 篇时,block_read_index 从块尾读出 n,定位偏移表,第 k 篇的范围就是 off k .. off k+1,最后一篇到偏移表开头为止。查一次是常数时间,不用扫块里前面的文档。

块的大小由 IndexSettings 的 docstore_blocksize 决定,默认值来自 default_docstore_blocksize,16384。它跟着 index_settings 写进 meta.json。判断满不满有两个细节:

  • 判断在文档写进去之后做,所以块总是「刚超过」16 KB 才切,超出的量最多是一篇文档。wiki 的文档较长,不压缩时 64 个块的数据区共 1137814 字节,平均每块 17778 字节。
  • 偏移表按 usize(8 字节)计入,实际每项写的是 u32(4 字节)。hdfs 每行日志只有两百字节上下,一块装七八十篇,多算的几百字节让块提前切出:不压缩时 1289 个块共 20859487 字节,平均每块 16183 字节,比 16384 还小。
📝NOTE
冷知识:偏移表原来不在块尾,而是每篇文档前面一个变长整数

0.19 之前,store 在每篇文档前面写一个 VInt 长度。取块里第 k 篇,StoreReader 要从块头开始,读一个长度、跳过一篇,重复 k 次;遍历整个 store 的迭代器也要记着跳过了几篇。PR #1569(「change format for store to make it faster with small documents」)换成现在的格式:写入时把偏移攒在 doc_pos 里,块满了统一追加到块尾。提交说明里的理由是「move index to end of block / it makes writing the block faster due to one less memcopy」:长度前缀要等文档序列化完才知道,偏移则在序列化之前就有了。check_flush_block 里按 usize 估算偏移表的那一行,也是这个 PR 加的。首次发布在 0.19。

压缩器

块交给 Compressor:

pub enum Compressor {
    /// No compression
    None,
    /// Use the lz4 compressor (block format)
    #[cfg(feature = "lz4-compression")]
    Lz4,
    /// Use the zstd compressor
    #[cfg(feature = "zstd-compression")]
    Zstd(ZstdCompressor),
}

Default 依次看 feature:有 lz4-compression 就是 Lz4,否则有 zstd-compression 就是 Zstd,都没有是 None。tantivy 的默认 feature 里有 lz4-compression,没有 zstd-compression;默认 feature 里那个 columnar-zstd-compression 管的是快速字段里的 sstable 字典(见 快速字段),和 doc store 无关。ZstdCompressor 只有一个可选的 compression_level,不设就是 zstd 的默认级别 3,在 meta.json 里写成 "zstd" 或 "zstd(compression_level=9)"。

两种压缩的块格式一样:4 字节小端的原始长度,后面是压缩数据(lz4 用 lz4_flex 的 block 格式,zstd 用 zstd::bulk)。解压时先读这 4 个字节,一次分配好输出缓冲区。None 也照样走一遍,只是拷贝。

压缩不在索引线程上做。IndexSettings::docstore_compress_dedicated_thread 默认 true,DedicatedThreadBlockCompressorImpl::new 为每个正在写的段起一个名叫 docstore-compressor-thread 的线程,中间是容量为 3 的 sync_channel:索引线程把块的副本扔过去就继续分词,最多积压 3 块。

数据区和跳表之后是 28 字节的 DocStoreFooter:格式版本(u32,现在是 2)、跳表的起始偏移(u64)、压缩器编号(u8)、15 个保留字节,再往后才是目录层的 footer。footer.rs 里描述格式的注释只列了后三项,漏了开头的版本号。读的一方按这个编号选解压器(Decompressor::from_id),不看 meta.json 里当前的设置。所以改了 docstore_compression 以后,旧段照旧按旧的压缩器读,新段用新的。编号是 0(不压缩)、1(lz4)、4(zstd),中间的 2 和 3 曾经是 Brotli 和 Snappy,PR #2123 在 0.21 把它们删了,理由是「LZ4 provides fast and simple compression whereas Zstd is exceptionally flexible」,编号没有重排。

五种设置写同一份语料,.store 文件的大小(字节):

压缩器wiki(原始 JSON 1157023)hdfs(原始 JSON 22406992)
None113826320865436
Lz4(默认)694929(61%)4218570(20%)
Zstd 级别 3478661(42%)2730541(13%)
Zstd 级别 9463051(41%)2572147(12%)
Zstd 级别 19454153(40%)2446800(12%)

括号里是相对 None 的比例。块只有 16 KB,压缩器看到的上下文有限;日志行之间重复多,压得比维基正文狠得多。级别 19 在 hdfs 上建索引花了 6.21 秒,级别 3 是 0.33 秒,换来的只是再小 10%。ZstdCompressor 解析配置时,级别 ≥ 15 会打一条 warn!,说会拖慢写入。

跳表:从文档号找到块

每压完一块,BlockCompressorImpl::compress_block_and_write 登记一个 Checkpoint:这块覆盖的文档号范围 doc_range 和它在文件里的字节范围 byte_range,交给 SkipIndexBuilder::insert:

    pub fn insert(&mut self, checkpoint: Checkpoint) {
        let mut skip_pointer = Some(checkpoint);
        for layer_id in 0.. {
            if let Some(checkpoint) = skip_pointer {
                skip_pointer = self.get_layer(layer_id).insert(checkpoint);
            } else {
                break;
            }
        }
    }

第 0 层记下每个块。一层里攒够 CHECKPOINT_PERIOD(8)个 checkpoint,就把这 8 个序列化成该层的一个小块,再生成一个覆盖这 8 个的 checkpoint 交给上一层。上一层的 byte_range 指的是下一层序列化数据里的位置,不是 .store 数据区的位置。64 个块是三层:64、8、1。

每个小块的编码在 CheckpointBlock::serialize:个数、第一个文档号、第一个字节偏移,然后每个 checkpoint 只写文档数和字节数两个 VInt。区间首尾相接(push 里有 assert!(checkpoint.follows(prev_checkpoint))),起点不用重复存。实测 wiki 的 64 个块,跳表在 lz4 下只有 269 字节;hdfs 的 1289 个块是 5757 字节。

查找在 SkipIndex::seek:从最上层开始,在当前范围里顺序找第一个 doc_range.end > target 的 checkpoint,用它的字节偏移进入下一层,一直走到第 0 层,得到目标块。每层最多扫一个小块(8 个左右),解码的是 VInt。

StoreReader 和块缓存

StoreReader::open 先切下 footer,按 footer 里的偏移把文件分成数据区和跳表,跳表这一段立刻 read_bytes()(mmap 下就是映射里的一个切片)。每次取文档走 get → block_checkpoint(跳表)→ read_block:

    fn read_block(&self, checkpoint: &Checkpoint) -> io::Result<Block> {
        let cache_key = checkpoint.byte_range.start;
        if let Some(block) = self.cache.get_from_cache(cache_key) {
            return Ok(block);
        }

        let compressed_block = self.get_compressed_block(checkpoint)?;
        let decompressed_block =
            OwnedBytes::new(self.decompressor.decompress(compressed_block.as_ref())?);

        self.cache
            .put_into_cache(cache_key, decompressed_block.clone());

        Ok(decompressed_block)
    }

缓存的键是块在文件里的起始偏移,值是解压后的整块。BlockCache 是 Option<Mutex<LruCache<usize, Block>>> 加两个命中计数:容量传 0 时是 None,每次都解压。锁只包住查找和插入,解压在锁外,所以两个线程同时错过同一块,会各解压一次,后插入的覆盖先插入的。

谁持有这些 StoreReader:SearcherInner::new 给每个段开一个,容量是 IndexReaderBuilder 的 doc_store_cache_num_blocks,默认 DOCSTORE_CACHE_CAPACITY = 100。由此有三点:

  • 100 是每个段的块数,不是整个索引的。段越多,缓存的上限越高,最坏是「段数 × 100 × 约 16 KB」。
  • 同一代 searcher(reader.searcher() 拿到的是同一个 Arc<SearcherInner> 的克隆)共享缓存;reload 产生新一代,所有段的 StoreReader 都重新打开,缓存从空开始。
  • 自己调 segment_reader.get_store_reader(n) 拿到的是另一个独立的缓存。

searcher.doc_store_cache_stats() 把各段的命中数加起来,下面的实验就靠它数块:顺序取一遍所有文档,每块恰好未命中一次。

📝NOTE
冷知识:0.14 之前只缓存「上一块」,模块文档至今还这么写

src/store/mod.rs 开头的模块文档写着:「If the last document requested was in the same block, the reader is smart enough to avoid decompressing the block a second time, but their is no real uncompressed block* cache.」这描述的是早期的 StoreReader:两个 RefCell 字段 current_block_offset 和 current_block,只记住最近解压的那一块,换一块就覆盖。提交 9ab25d257(「Cache store reader blocks in an LRU fashion」,没有 PR 号)把它们换成 Arc<Mutex<LruCache<usize, Block>>>,容量是写死的常量 LRU_CACHE_CAPACITY = 100,并加上命中和未命中计数,首次发布在 0.14。容量后来变成构造参数(5750224d4「set docstore cache size at construction」,0.19),对外的设置名在 PR #1821(「fix doc store cache docs」)里从 doc_store_cache_size 改成 doc_store_cache_num_blocks(0.20),名字里点明数的是块。模块文档那一段 2017 年(87152daef)就在了,一直没跟着改。

取文档的开销,wiki 1000 篇、hdfs 10 万篇,各自打乱顺序取一遍全部文档,重复 5 轮取最快一轮的平均值(release,测试时 uptime 负载 8 到 13,机器上同时有其他负载;wiki 换一个时间重跑,各项差异在 5% 以内)。「无缓存」用 doc_store_cache_num_blocks(0),「默认缓存」先取一遍预热:

压缩器wiki 无缓存wiki 默认缓存hdfs 无缓存hdfs 默认缓存
None1.16 µs0.63 µs1.20 µs1.24 µs
Lz45.87 µs0.63 µs2.91 µs2.82 µs
Zstd 316.80 µs0.63 µs8.22 µs7.81 µs
  • wiki 只有 64 块,全部装得进 100 块的缓存,预热后 5000 次全部命中,三种压缩器都是 0.63 µs:这是反序列化一篇文档的成本,和压缩无关。
  • 未命中的代价就是解压一个约 16 KB 的块:lz4 多 5 µs,zstd 多 16 µs。hdfs 的块压缩比更高、解压更快,所以 lz4 只多不到 2 µs。
  • hdfs 有 1289 块,默认缓存的那一列几乎等于无缓存,原因在下面的动脑筋。
❗IMPORTANT
动脑筋:hdfs 的段有 1289 个块,默认缓存 100 块。随机顺序取 10 万篇文档、连取 5 轮,缓存命中率大约是多少?提示:随机访问时 LRU 的命中率只取决于缓存能装下多大比例的块。答案在文末。

合并:能拼接就不解压

段合并时 doc store 由 StorePlugin::merge 处理(0.27.0 起 doc store 是一个内置的段插件,接口见 合并)。文档顺序不变时,它对每个旧段做一个选择:

                let store_reader = reader.get_store_reader(1)?;
                if reader.has_deletes()
                    // If there is not enough data in the store, we avoid stacking in order to
                    // avoid creating many small blocks in the doc store. Once we have 5 full blocks,
                    // we start stacking. In the worst case 2/7 of the blocks would be very small.
                    || store_reader.block_checkpoints().take(7).count() < 6
                    || store_reader.decompressor() != store_writer.compressor().into()
                {
                    for doc_bytes_res in store_reader.iter_raw(reader.alive_bitset()) {
                        let doc_bytes = doc_bytes_res?;
                        store_writer.store_bytes(&doc_bytes)?;
                    }
                } else {
                    store_writer.stack(store_reader)?;
                }

stack 把旧段的整个数据区原样拷进新文件,再把它的每个 checkpoint 平移文档号和字节偏移后登记进新跳表,一个块都不解压。三种情况必须逐篇重写:旧段有删除(被删的文档要去掉,块内偏移就变了);旧段不足 6 个块(避免新文件里夹着很多小块);旧段的压缩器和现在的设置不同。最后一条就是改 docstore_compression 之后旧数据会被换成新格式的地方:只在合并时发生。

开了索引排序(sort_by_field)时,写入阶段先写一个临时的 .store.temp(不压缩、块大小 16000),段定稿时按新文档顺序逐篇读出来再写一遍正式的 .store,见 StorePluginWriter::new。那里打开临时 store 的注释说「The docstore is configured to have one doc per block」,和上面 16000 的块大小对不上,是一句过时的注释。

两个坑

zstd 写的索引,用默认 feature 的 tantivy 打开。 meta.json 里是 "docstore_compression": "zstd",反序列化在 Index::open_in_dir 就失败,报成数据损坏:

Index::open_in_dir error: Data corrupted: 'Data corruption (in file `"meta.json"`): Meta file cannot be deserialized. Error("unsupported variant `zstd`, please enable Tantivy's `zstd-compression` feature", line: 3, column: 34). Content: ...

meta.json 说 lz4,某个段的 footer 却是 zstd。 先用 zstd 建索引、后来把设置改回 lz4,旧段还没被合并时就是这个状态。用不带 zstd-compression 的程序打开,open_in_dir 成功,index.reader() 给每个段开 StoreReader 时 panic,不是返回错误:

thread 'main' (6182506) panicked at .../tantivy-0.27.0/src/store/decompressors.rs:40:18:
unknown compressor id 4

from_id 对未知编号直接 panic!;footer.rs 解析 footer 时,版本号比当前支持的大也是 panic!。实验里的第二种状态是手改 meta.json 造出来的。

动脑筋答案

lz4       .store   4218570 B  data   4212691 offsets  5757  docs 100000 blocks 1289  index 0.32s
          fetch random doc: miss 2.91 us/doc, hit 2.82 us/doc  (warm-up misses 92517, cache entries 100, hits in timed rounds 37455, misses 462545)

5 轮共 50 万次取文档,命中 37455 次,7.5%。缓存装 100 块,随机挑一篇文档、它所在的块正好在缓存里的概率约是 100 / 1289 = 7.8%。预热那一轮也只有 7483 次命中(100000 − 92517)。所以 hdfs 那一列「默认缓存」和「无缓存」几乎一样:块缓存对「同一页结果来自相邻文档」「反复取同几篇」有用,对在大段里随机取文档没用。要它起作用,要么调大 doc_store_cache_num_blocks,要么让常被一起取的文档在段里挨着(比如按时间写入的日志按时间取)。

小结

  • 文档写入时就序列化进当前块,块在超过 docstore_blocksize(默认 16384)后切出;块尾是 u32 偏移表和文档数,块内取第 k 篇是常数时间(0.19 起,#1569)。
  • 压缩在每段一个的 docstore-compressor-thread 上做;Lz4 默认,Zstd 要开 zstd-compression feature,默认 feature 里的 columnar-zstd-compression 与 doc store 无关。
  • footer 记压缩器编号(0、1、4),读时按 footer 选解压器;旧段在合并时才换成新压缩器。
  • 跳表每 8 个 checkpoint 升一层,区间首尾相接只存长度;1289 个块的跳表 5.7 KB。
  • 块缓存是每段、每代 searcher 一个 100 块的 LRU;wiki 上命中省掉 5 µs(lz4)到 16 µs(zstd),hdfs 1289 块随机取文档命中率只有 7.5%。
  • 合并时,没有删除、至少 6 块、压缩器没变的段整块拼接,不解压。
  • zstd 索引在没开 feature 的程序里:meta.json 写着 zstd 时报 DataCorruption,meta.json 和段 footer 不一致时 panic。

💬 关联讨论

发起讨论
加载中...