文档存储
主线位置:查询主线的最后一步。
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 还小。
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) |
|---|---|---|
None | 1138263 | 20865436 |
Lz4(默认) | 694929(61%) | 4218570(20%) |
Zstd 级别 3 | 478661(42%) | 2730541(13%) |
Zstd 级别 9 | 463051(41%) | 2572147(12%) |
Zstd 级别 19 | 454153(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() 把各段的命中数加起来,下面的实验就靠它数块:顺序取一遍所有文档,每块恰好未命中一次。
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 默认缓存 |
|---|---|---|---|---|
None | 1.16 µs | 0.63 µs | 1.20 µs | 1.24 µs |
Lz4 | 5.87 µs | 0.63 µs | 2.91 µs | 2.82 µs |
Zstd 3 | 16.80 µs | 0.63 µs | 8.22 µs | 7.81 µs |
- wiki 只有 64 块,全部装得进 100 块的缓存,预热后 5000 次全部命中,三种压缩器都是 0.63 µs:这是反序列化一篇文档的成本,和压缩无关。
- 未命中的代价就是解压一个约 16 KB 的块:lz4 多 5 µs,zstd 多 16 µs。hdfs 的块压缩比更高、解压更快,所以 lz4 只多不到 2 µs。
- hdfs 有 1289 块,默认缓存的那一列几乎等于无缓存,原因在下面的动脑筋。
合并:能拼接就不解压
段合并时 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-compressionfeature,默认 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。