快速字段
主线位置:磁盘主线的最后一站
.fast。倒排表回答「哪些文档含这个词」,快速字段回答反过来的问题:「这个文档的这个字段值是多少」。查询主线里按字段排序的TopDocs、范围查询的一种实现、聚合,读的都是它。写入主线里,SegmentWriter每收一篇文档就把FAST字段的值记进内存,段定稿时一次写成.fast。
flowchart TB
doc["Document"] --> ffw["FastFieldsWriter"]
ffw --> cw["ColumnarWriter"]
cw --> ops["每列一串 NewDoc 与 Value"]
ops --> card{"推断基数"}
card --> idx["列索引"]
ops --> strq{"字符串列"}
strq -- 是 --> dict["排序字典 sstable"]
dict --> ords["值换成序号"]
strq -- 否 --> vals["u64 值"]
ords --> est["各编码估算"]
vals --> est
est --> pick["取最小写入"]
idx --> fast[".fast"]
pick --> fast
tantivy 这一侧只有薄薄一层:src/fastfield/ 共 3020 行,FastFieldsWriter 按 schema 把字段名和值交给列存,读的一侧是 FastFieldReaders。真正的格式在工作区里的 tantivy-columnar crate(columnar/src/ 共 14438 行,按 tag 0.27.0 统计,含单元测试,不含 benches),它又依赖 tantivy-bitpacker 和 tantivy-sstable。三者在 0.27.0 的版本号都是 0.27.0;0.26.1 时 columnar 的版本号还是 0.7.0。
实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,release 构建。语料是仓库里的 benches/hdfs.json:10 万行 HDFS 日志,每行有一个真实的秒级 timestamp。实验程序给每篇文档写十几个快速字段,用一个线程写成一个段,然后通过 fast_fields().dynamic_column_handle(..) 拿到每一列的原始字节,自己解析出列索引的类型和值的编码。
.fast 文件:一串列加一张目录
ColumnarWriter 按值的类型分六张 ArenaHashMap(数值、日期、布尔、IP、字节、字符串),键是列名。每条记录写成一串 ColumnOperation:换到新文档时写一个 NewDoc(doc),然后是 Value(v),都压进 stacker 的内存池(段写入 讲过这个池)。
serialize 把所有列按(列名、类型)排序后依次写出。文件结构由 ColumnarSerializer 决定:
[列 1][列 2]...[列 n][目录 sstable][目录长度 u64][num_rows u32][版本 u32][魔数 2,113,119,66]
目录是一个 RangeSSTable,键是「列名 + \0 + 类型码」(prepare_key),值是这一列在文件里的字节范围。类型码进了键,所以同名的列可以有几种类型并存,JSON 字段里同一路径既有数字又有字符串时就是这样。打开 .fast 只读目录,用到哪列才切哪列。
基数不写在 schema 里
一个文档在某列上可以有一个值、没有值、或多个值,这叫列的基数(cardinality)。0.27.0 的 schema 里不声明它,ColumnWriter::record 边写边推断:
match delta_with_last_doc(self.last_doc_opt, doc) {
DocumentStep::Same => {
// This is the last encounterred document.
self.cardinality = Cardinality::Multivalued;
}
DocumentStep::Next => {
self.last_doc_opt = Some(doc);
self.write_symbol::<S>(ColumnOperation::NewDoc(doc), arena);
}
DocumentStep::Skipped => {
self.cardinality = self.cardinality.max(Cardinality::Optional);
self.last_doc_opt = Some(doc);
self.write_symbol::<S>(ColumnOperation::NewDoc(doc), arena);
}
}
同一个文档出现第二次就是 Multivalued,跳过了文档就至少是 Optional;get_cardinality 再看最后一个有值的文档后面还有没有文档。Cardinality 三档有序,只升不降。推断按段进行:同一个字段,这个段里每篇都有值是 Full,另一个段里漏了一篇就是 Optional,合并时再统一。
每一列的字节是「列索引 + 列值 + 列索引长度(u32)」,读出来是 Column 的两个字段:
pub struct Column<T = u64> {
pub index: ColumnIndex,
pub values: Arc<dyn ColumnValues<T>>,
}
列值是一个稠密数组,下标叫 row id;列索引负责把文档号翻译成 row id(ColumnIndex::value_row_ids):
- Full:row id 就是文档号,列索引只有开头那 1 个字节的基数码。
- Optional:列值只存有值的文档,列索引是一个文档号集合,取值时算「它前面有几个有值的文档」(rank)。
OptionalIndex仿照 roaring bitmap:每 65536 个文档一块,块里的值少于DENSE_BLOCK_THRESHOLD就存成u16列表(稀疏块),否则存成位图,每 64 位后面跟 2 字节的累计计数(稠密块,固定 10240 字节)。阈值是 10240 / 2 = 5120,常量旁的注释也写 5120;同一个文件里结构体的文档注释写的是 6144,是旧值。 - Multivalued:一个 optional 索引记哪些文档有值,再加一列「每个有值文档的第一个 row id」(start offsets),用
serialize_multivalued_index写出。
实测两列:opt 只有文档号是 10 的倍数的文档有值,multi 第 i 篇有 i % 3 个值:
opt U64 optional codec BlockwiseLinear min 0 gcd 10 amplitude/gcd 9999 rows 10000; total 17320 B (index 17146, values 170)
multi U64 multivalued codec BlockwiseLinear min 10 gcd 1 amplitude/gcd 999971 rows 99999; total 139169 B (index 78848, values 60317), start offsets codec Linear (58350 B)
...
multi doc 0: []
multi doc 1: [10]
multi doc 2: [20, 21]
multi doc 3: []
opt doc 0 Some(0), doc 1 None, doc 10 Some(10)
opt 的 10000 个值只占 170 字节,列索引却有 17146 字节:第一块(文档 0 到 65535)有 6554 个值,超过 5120,是 10240 字节的稠密块;第二块 3446 个值,是 6892 字节的稀疏块。值很少的列,大头在列索引。
数值列:先统计,再让每种编码报价
所有数值(u64、i64、f64、日期、布尔,以及字符串的序号)都先单调映射成 u64,然后走 serialize_u64_based_column_values:
for val in vals.boxed_iter() {
let val_u64 = val.to_u64();
stats_collector.collect(val_u64);
for (_, estimator) in &mut estimators {
estimator.collect(val_u64);
}
}
for (_, estimator) in &mut estimators {
estimator.finalize();
}
let stats = stats_collector.stats();
let (_, best_codec, best_codec_estimator) = estimators
.into_iter()
.flat_map(|(codec_type, estimator)| {
let num_bytes = estimator.estimate(&stats)?;
Some((num_bytes, codec_type, estimator))
})
.min_by_key(|(num_bytes, _, _)| *num_bytes)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "No available applicable codec.")
})?;
best_codec.to_code().serialize(wrt)?;
第一遍扫描同时喂给统计器和每个候选编码的估算器;每个估算器报一个字节数(报 None 表示不适用),取最小的那个,写一个字节的编码号,再扫第二遍真正写。所以列值必须能遍历两遍。
StatsCollector 统计最小值、最大值、行数,以及所有值与第一个值之差的最大公约数(gcd)。这四个数以 VInt 写在每列值的开头(ColumnStats),三种编码共用。gcd 的用处在注释里写着:秒级时间戳被转成纳秒存,差值全是 10⁹ 的倍数,除掉以后就和存秒一样小。
三种编码(CodecType):
| 编码 | 存什么 | 每个值的位宽 |
|---|---|---|
Bitpacked | (v - min) / gcd | 整列统一:(max - min) / gcd 需要的位数 |
Linear | 整列拟合一条直线,存值与直线的差 | 整列统一:最大偏差需要的位数 |
BlockwiseLinear | 每 512 个值一段,各拟合一条直线,存差 | 每段各自的位宽,每段另存直线参数和位宽 |
读的时候都是常数时间。BitpackedReader::get_val 是一次位提取加一次乘加:
fn get_val(&self, doc: u32) -> u64 {
self.stats.min_value + self.stats.gcd.get() * self.unpacker().get(doc, &self.data)
}
bitpacked::load 对 1 到 8、16、20、24、32、64 位这几种位宽各生成一个把位宽写成常量的读取器,其余位宽共用一个通用的。BlockwiseLinearReader::get_val 先用 idx / 512 找到段,在段里算直线值再加上位提取出的差。
直线(Line)是整数运算:斜率用首尾两个值算,是一个 32.32 的定点数;截距取「让所有差都非负」的那个值,于是差可以按无符号数打包。Linear 的直线只用前 LINE_ESTIMATION_BLOCK_LEN(512)个值训练,攒不够 512 个值就训练不出直线,估算报 None。
普通的数值列只有两个候选。 serialize_column_mappable_to_u64 传的是:
serialize_u64_based_column_values(
column_values,
&[CodecType::Bitpacked, CodecType::BlockwiseLinear],
output,
)?;
Linear 只出现在另外两处:多值列的 start offsets(候选是 Bitpacked 和 Linear),以及 0.27.0 新增的 tie-breaker 列(serialize_generated_tie_breaker_column,三种都参与)。tie-breaker 的值在段内连续递增,正是一条直线。
真实数据选了什么
实验的十二列,各自的列类型、基数、选中的编码、统计头和字节数:
ts U64 full codec Bitpacked min 1440670432 gcd 1 amplitude/gcd 20032418 rows 100000; total 312519 B (index 1, values 312514)
ts_sorted U64 full codec BlockwiseLinear min 1440670432 gcd 1 amplitude/gcd 20032418 rows 100000; total 27790 B (index 1, values 27785)
ts_date DateTime full codec BlockwiseLinear min 10664042468854775808 gcd 1000000000 amplitude/gcd 20032418 rows 100000; total 27799 B (index 1, values 27794)
ts_ms U64 full codec BlockwiseLinear min 1440670432000 gcd 1000 amplitude/gcd 20032418 rows 100000; total 27792 B (index 1, values 27787)
seq U64 full codec BlockwiseLinear min 0 gcd 1 amplitude/gcd 99999 rows 100000; total 1749 B (index 1, values 1744)
rand U64 full codec Bitpacked min 115005805221539 gcd 1 amplitude/gcd 18446297446089869496 rows 100000; total 800027 B (index 1, values 800022)
rand1k U64 full codec Bitpacked min 0 gcd 1 amplitude/gcd 999 rows 100000; total 125013 B (index 1, values 125008)
severity Str full codec BlockwiseLinear min 0 gcd 1 amplitude/gcd 1 rows 100000; total 783 B (index 1, values 727, dictionary 47)
blk Str full codec Bitpacked min 0 gcd 1 amplitude/gcd 9 rows 100000; total 50168 B (index 1, values 50007, dictionary 152)
opt U64 optional codec BlockwiseLinear min 0 gcd 10 amplitude/gcd 9999 rows 10000; total 17320 B (index 17146, values 170)
multi U64 multivalued codec BlockwiseLinear min 10 gcd 1 amplitude/gcd 999971 rows 99999; total 139169 B (index 78848, values 60317), start offsets codec Linear (58350 B)
tb I64 full codec Linear min 9223372036706411460 gcd 1 amplitude/gcd 99999 rows 100000; total 37 B (index 1, values 32)
再把同样的值分别只交给一种编码去写,看三种编码各自的报价(字节):
ts n=100000 Bitpacked 312514, Linear 412535, BlockwiseLinear 316419
ts_sorted n=100000 Bitpacked 312514, Linear 312524, BlockwiseLinear 27785
ts_ms n=100000 Bitpacked 312516, Linear 437529, BlockwiseLinear 27787
seq n=100000 Bitpacked 212509, Linear 16, BlockwiseLinear 1744
rand n=100000 Bitpacked 800022, Linear 800033, BlockwiseLinear 802281
rand1k n=100000 Bitpacked 125008, Linear 200024, BlockwiseLinear 138883
seq[..511] n=511 Bitpacked 582, Linear n/a (No available applicable codec.), BlockwiseLinear 18
ts:日志原样的时间戳。 跨度 20032418 秒(约 232 天),要 25 位;10 万行里有 5613 处后一行比前一行早,不是单调的。分段直线反而多花了每段的参数,Bitpacked以 312514 对 316419 胜出,每个值 25 位。ts_sorted:同一批时间戳排好序。 这才是「单调递增的时间戳」,每 512 个值一条直线,差只要两三位,27785 字节,是Bitpacked的 9%。ts_date:同样的值存成日期字段。 统计头里的min是一个巨大的数、gcd 是 10⁹:日期在快速字段里是i64纳秒(加 2⁶³ 映射成u64),gcd 把 10⁹ 除掉,结果和存秒只差统计头的 9 个字节。ts_ms同理,gcd 是 1000。rand:均匀随机的u64。 什么也压不了,64 位。rand1k是 0 到 999 的随机数,10 位,Bitpacked正好 125000 字节加 8 字节头。seq:0 到 99999。 一条完美的直线,Linear只要 16 字节,但它不在普通列的候选里,选中的是BlockwiseLinear的 1744 字节(196 段,每段只存直线参数,位宽是 0)。tb是同样形状的 tie-breaker 列,Linear参与竞争,32 字节。seq[..511]:不到 512 个值,Linear报不了价。
Line::eval 把「斜率 × 位置」的结果先转成 i32 再加到截距上。compute_slope 在首尾差值太大时放弃,直接给斜率 0。0.26 时这个上限是 1 << 32:首尾差在 2³¹ 到 2³² 之间时,乘出来的线性部分超出 i32、符号翻转,直线偏得离谱,这一段的残差全部要 64 位。PR #2971 把上限改成 1 << 31,提交说明是「new limit prevent an overflow in eval which caused the residual to be 64b when a slop of zero would give a smaller one」,CHANGELOG 记为「Fix overflow in the blockwise linear codec on large jumps」,首次发布在 0.27.0。
both candidates: codec byte 0 (2058 bytes)
Bitpacked: 2058 bytes for 512 values
BlockwiseLinear: 4131 bytes for 512 values
Bitpacked: 2058 bytes for 512 values
BlockwiseLinear: 2065 bytes for 512 values
4131 字节就是 512 × 8 再加段参数。估算器训练的是同一条直线,旧版本报价时就按 64 位算,两个候选一起比时选了编码 0(Bitpacked)。所以这个 bug 不会写错数据,影响的是体积:一列里只要有这样的段,分段直线的总报价就虚高,比较可能因此偏向 Bitpacked。这组数据修好以后 BlockwiseLinear 是 2065 字节,仍比 Bitpacked 多 7 字节。
字符串和字节:先建字典,再存序号
STRING | FAST 或 TEXT 加快速字段选项的文本,进快速字段前按 schema 里给快速字段指定的分词器切(默认 raw,整串一个词元,见 Schema 与字段)。每个词元交给这一列自己的 DictionaryBuilder:一张 SharedArenaHashMap,第一次见到的词按出现顺序分配一个临时号 UnorderedId。serialize 时把所有词按字节序排序,写成一个 VoidSSTable(只有键没有值的 sstable),同时得到「临时号 → 排序后的序号」的映射:
let terms = self.build_sorted_terms(arena);
// TODO Remove the allocation.
let mut unordered_to_ord: Vec<OrderedId> = vec![OrderedId(0u32); terms.len()];
let mut sstable_builder = sstable::VoidSSTable::writer(wrt);
for (ord, (key, unordered_id)) in terms.into_iter().enumerate() {
let ordered_id = OrderedId(ord as u32);
sstable_builder.insert(key, &())?;
unordered_to_ord[unordered_id.0 as usize] = ordered_id;
}
serialize_bytes_or_str_column 把列里的临时号换成序号,剩下的就是一个普通的 u64 列,走上一节同样的编码选择。整列的字节是「字典 + 列 + 字典长度(u32)」。
序号按字节序排,所以比较两个文档的序号就是比较字符串,按字符串字段排序不用解出字符串。序号只在一个段里有意义:两个段里的 "WARN" 序号可能不同,合并时要重新映射。实验里的两个字符串列:
severity terms 2, blk terms 10
severity ord 0 = "INFO", ord 1 = "WARN"
severity 10 万篇里只有 2 篇是 WARN,序号几乎全是 0,BlockwiseLinear 的大多数段位宽为 0,727 字节;字典 47 字节。blk 有 10 个不同的词,序号要 4 位,Bitpacked 正好 50000 字节加头。
字典用的是 tantivy-sstable,不是倒排索引词典用的 FST(见 词典)。默认 feature columnar-zstd-compression 打开 sstable 的 zstd:一个 sstable 块超过 2048 字节时用级别 3 压一次,压完没变小就存原样(DeltaWriter::flush_block)。
读:从字段名到值
查询时用 segment_reader.fast_fields() 拿到 FastFieldReaders,再按类型取列:u64、i64、f64、date、str 等。它在列存目录里按「字段名 + 类型码」找到字节范围,读第一个字节决定编码,构造出 Column。之后 Column::first(doc) 或 values_for_doc(doc) 就是「列索引翻译 row id,再按 row id 解码」。.fast 通过 mmap 打开,列不用整列读进内存;排序、聚合这类逐文档读值的循环,每次取值是常数时间。
字段类型和取列用的类型要对上。按类型码找列,找不到就和字段不存在一样处理,u64 字段用 .i64("ts") 去取,报的是 SchemaError("Field `ts` is missing or is not configured as a fast field."),.u64("ts") 则正常;不确定类型时可以用 u64_lenient,它返回列和实际类型。实验里绕过类型检查读原始字节用的是 dynamic_column_handle,它返回的 DynamicColumnHandle::file_slice() 带着 #[doc(hidden)],不是给普通用户的接口。
tag 之后,main 上的 6bced0ba0(#3179)给列存加了一种不经字典的字符串列格式和 V3 版本号,提交说明写明「The columnar writer does not produce plain columns yet」。
u64 快速字段,10 万篇文档依次写入 0、1、2……99999。.fast 里这一列的值部分是多少字节,用的哪种编码?提示:先想想每种编码报多少价,再看哪几种参加比较。答案在文末。动脑筋答案
seq U64 full codec BlockwiseLinear min 0 gcd 1 amplitude/gcd 99999 rows 100000; total 1749 B (index 1, values 1744)
...
seq n=100000 Bitpacked 212509, Linear 16, BlockwiseLinear 1744
1744 字节,BlockwiseLinear。Bitpacked 要 17 位一个值,212509 字节;Linear 一条直线就描述完整列,残差全是 0,只要 16 字节,但普通列的候选只有 Bitpacked 和 BlockwiseLinear,它根本没参加比较。BlockwiseLinear 把 10 万个值切成 196 段,每段一条直线、位宽 0,存的全是段参数。同样一列值如果是 tie-breaker 列,Linear 会参与,实验里那一列只要 32 字节。
小结
.fast是tantivy-columnar的格式:所有列首尾相接,最后一张 sstable 目录按「列名 + 类型码」记字节范围。- 一个文档有几个值不写在 schema 里,按段推断成
Full、Optional、Multivalued;后两者靠一个仿 roaring 的文档号集合(65536 一块,少于 5120 个值存列表,否则存位图)。 - 数值列先统计 min、max、gcd,再让候选编码估算字节数、取最小。普通列只比
Bitpacked和BlockwiseLinear;Linear只用于多值列的 start offsets 和 tie-breaker 列。 - 实测:日志原样的时间戳 25 位
Bitpacked;排好序后BlockwiseLinear只要它的 9%;日期按纳秒存,gcd 把 10⁹ 除掉;随机u6464 位。 - 字符串列每段一个排好序的 sstable 字典,列里存序号;序号的大小关系就是字符串的大小关系,但只在段内有效。
- 0.27.0 修了分段直线在大跳变时残差变成 64 位的问题(#2971);旧版本数据不会错,只是这样的段按 64 位计价,整列可能因此选了更大的编码。