索引设计
Paimon 的索引创建可以分为三类:
- 主键索引(物理有序性):这不是一个独立的索引文件,而是数据本身按照主键排序存储。
- 文件内嵌索引(Min/Max、Bloom Filter 等):这些索引信息被嵌入在每个数据文件(如 Parquet/ORC 文件)内部。
- 二级索引(如 Local Index):这些是独立的索引文件,为非主键列提供加速查找的能力。
下面我们详细说明它们分别是在何时、由谁建立的。
主键索引 (Primary Key "Index")
Paimon 是一个基于 LSM-Tree 的存储引擎,它的核心思想就是让数据按主键有序。
-
何时建立?
- 写 L0 文件时:当
MergeTreeWriter调用flushWriteBuffer时,它会从SortBufferWriteBuffer中读取已经按主键排好序的数据流,并写入 Level-0 的 SST 文件。所以,每个 L0 文件内部的数据是按主键有序的。 - Compaction 时:当
CompactManager执行合并任务时,它会读取多个输入文件,在内存中对它们的记录进行归并排序(Merge Sort),然后将全局有序的结果写入一个新的、更大的 SST 文件。这个过程确保了 L1 及以上层级的文件不仅内部有序,而且文件之间的 Key 范围也互不重叠。
- 写 L0 文件时:当
-
谁来负责?
MergeTreeWriter:负责生成有序的 L0 文件。CompactManager:负责通过 Compaction 生成更高层级的、全局更有序的文件。
所以,主键的“索引”能力是数据写入和合并过程中天然形成的物理特性。
文件内嵌索引 (Built-in File Indexes)
这类索引利用了列式存储格式(如 Parquet、ORC)自身的能力。
-
何时建立? 在数据文件被写入的那一刻。无论是
MergeTreeWriter在flush时创建 L0 文件,还是CompactManager在合并时创建新文件,只要有文件写入操作,这些内嵌索引就会被同步生成。 -
谁来负责?
KeyValueFileWriterFactory及其创建的底层文件写入器(例如ParquetWriter)。- 当
MergeTreeWriter调用dataWriter.write(keyValue)时,数据被传递给具体的文件写入器。该写入器会实时计算每个列的统计信息(最大值、最小值、null 值数量等),并根据配置(例如parquet.bloom.filter.enabled=true)为指定的列生成布隆过滤器。 - 当文件关闭时,这些索引信息会被写入文件的元数据部分(例如 Parquet 的 Footer)。
查询时,查询引擎可以先读取这些元数据,利用 Min/Max 信息快速跳过不包含目标数据的整个文件或行组(Row Group),或者用布隆过滤器快速判断某条记录肯定不存在。
二级索引 (Secondary Indexes)
以 Paimon 的 Local Index 为例,它为非主键列提供点查加速。
-
何时建立? 二级索引主要是在 Compaction 期间建立的。这是一个非常关键的设计决策,原因如下:
- 效率高:Compaction 过程本身就需要读取所有输入数据。在读取数据的同时为它们建立索引,然后随新的数据文件一同写出,可以避免为了建索引而产生的额外 I/O。
- 数据稳定:为 L0 文件频繁地创建和更新二级索引是非常低效的,因为 L0 文件生命周期短,很快会被合并。而在 Compaction 后生成的新文件数据更稳定,生命周期更长,此时为它建立索引的收益最高。
-
谁来负责?
CompactManager和其分派的合并任务(Compaction Task)。- 当
MergeTreeWriter调用compactManager.triggerCompaction(...)后,CompactManager会根据策略挑选文件并发起一个异步的合并任务。 - 在这个合并任务中,除了执行标准的读数据、合并、写新数据文件的流程外,如果表配置了二级索引,它还会额外地为正在写出的新数据文件同步创建一个对应的索引文件。
DataFileIndexWriter的作用
除了Parquet自带的索引,Paimon 有自己独立的索引框架,DataFileIndexWriter 就是这个框架的入口,它不依赖于 Parquet/ORC 内置的布隆过滤器。
这两种索引(Parquet 内置索引 vs Paimon 独立索引)是并存且互补的,Paimon 的设计允许同时使用它们。下面来解释一下为什么 Paimon 要自己实现一套索引机制,以及它们之间的关系。
为什么 Paimon 需要独立的 DataFileIndexWriter?
虽然 Parquet 和 ORC 格式本身提供了强大的内嵌索引能力(如 Min/Max 统计、字典编码、布隆过滤器),但它们存在一些局限性,无法满足 Paimon 的所有需求:
-
格式无关性 (Format-Agnostic): Paimon 的核心存储层设计是希望与具体的文件格式解耦的。如果过度依赖 Parquet 的特性,那么当底层格式切换到 ORC 或者未来可能支持的其他格式时,索引逻辑就需要重写。通过
DataFileIndexWriter建立一个抽象层,Paimon 可以在上层实现统一的索引逻辑,而底层可以适配不同的文件格式。 -
更灵活的索引类型: Paimon 希望支持一些 Parquet/ORC 标准中没有的、或者实现不一样的索引类型。例如,Paimon 实现了 BSI (Bit-Sliced Index),这是一种对低基数列(比如性别、省份)进行范围和等值查询非常高效的位图索引。这是 Parquet 原生不支持的。
DataFileIndexWriter的插件化设计(通过FileIndexer.create(...))使得添加新的索引类型变得非常容易。 -
索引与数据分离: Parquet 的布隆过滤器是内嵌在文件中的。有时索引可能会很大,将一个巨大的索引嵌入到数据文件中,可能会影响数据文件的读取效率。Paimon 的
DataFileIndexWriter可以根据索引大小动态决策:- 如果索引很小(小于
file.index.in-manifest-threshold),就将其 嵌入(embed) 到DataFileMeta的元信息中,随 Manifest 文件一起加载,非常高效。 - 如果索引很大,就将其写成一个独立的索引文件(以
.idx结尾),与数据文件并列存放。这样避免了数据文件本身变得臃肿。
// ... existing code ... @Override public void close() throws IOException { Map<String, Map<String, byte[]>> indexMaps = serializeMaintainers(); ByteArrayOutputStream out = new ByteArrayOutputStream(); try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(out)) { writer.writeColumnIndexes(indexMaps); } // 根据大小决定是写入独立文件还是嵌入元数据 if (out.size() > inManifestThreshold) { try (OutputStream outputStream = fileIO.newOutputStream(path, true)) { outputStream.write(out.toByteArray()); } resultFileName = path.getName(); } else { embeddedIndexBytes = out.toByteArray(); } } // ... existing code ... - 如果索引很小(小于
-
对复杂类型的支持: Paimon 的索引框架可以为
MAP等复杂类型的 Key 或 Value 创建索引,这在原生格式中支持有限。MapFileIndexMaintainer就是专门处理这种情况的。
RowDataFileWriter 中的索引流程
主键表(Primary-Key Table)使用的 KeyValueDataFileWriter 是类似的
现在我们来看RowDataFileWriter,它的逻辑清晰地展示了 Paimon 独立索引的构建过程:
-
初始化: 在构造函数中,它会根据表的配置(
fileIndexOptions)决定是否需要创建DataFileIndexWriter。如果表没有配置任何 Paimon 索引,dataFileIndexWriter就是null。// ... existing code ... public RowDataFileWriter( //... ) { // ... this.dataFileIndexWriter = DataFileIndexWriter.create( fileIO, dataFileToFileIndexPath(path), writeSchema, fileIndexOptions); // ... } // ... existing code ... -
逐行写入: 每当
write(InternalRow row)方法被调用时,它不仅将这行数据写入底层的数据文件(通过super.write(row)),还会将这行数据喂给dataFileIndexWriter。// ... existing code ... @Override public void write(InternalRow row) throws IOException { super.write(row); // 同时将行数据写入索引构建器 if (dataFileIndexWriter != null) { dataFileIndexWriter.write(row); } seqNumCounter.add(1L); } // ... existing code ...dataFileIndexWriter内部会根据配置,将行中对应列的值分发给不同的FileIndexWriter实现(比如布隆过滤器索引写入器、BSI 索引写入器等)。 -
完成并获取结果: 当文件写入完成并调用
close()时,dataFileIndexWriter.close()也会被调用,此时它会完成索引的构建,并决定是生成嵌入式索引还是独立索引文件。最终,result()方法会把索引信息(嵌入的字节数组或独立文件名)打包到DataFileMeta对象中返回。
总结
- Parquet/ORC 的布隆索引:是文件格式内置的能力,Paimon 可以通过配置项(如
parquet.bloom.filter.enabled)来启用它。它由底层的ParquetRowDataWriter等具体实现来处理。 - Paimon 的
DataFileIndexWriter:是 Paimon 自建的、可插拔的、格式无关的索引框架。它提供了更强的灵活性和更丰富的索引类型。
在实际使用中,可以同时启用这两种索引。查询时,Paimon 会优先利用自己的索引框架进行过滤,然后再利用文件格式内嵌的索引进行更深层次的过滤,从而最大化地提升查询性能。RowDataFileWriter 正是 Paimon 自建索引体系中负责“在数据写入时同步构建索引”的关键一环。
DataFileIndexWriter
这个类被声明为 final,所以它没有子类。它的灵活性和可扩展性并非通过继承实现,而是通过组合和策略模式来巧妙地组织不同类型的索引。
下面将结合代码,分模块、有逻辑地解析这个类的设计与工作原理。
DataFileIndexWriter 的核心职责非常明确:为一个正在写入的数据文件(Data File)同步地创建其对应的索引文件(Index File)。
它就像一个索引构建的“总指挥”,负责管理针对不同列、不同类型的索引的写入过程。
设计模式:组合与策略模式
这个类是理解 Paimon 索引框架的关键。它本身不实现任何具体的索引算法(如布隆过滤、BSI 等),而是采用组合的设计模式,将具体的索引构建任务委托给内部的多个 IndexMaintainer 对象。
// ... existing code ...
public final class DataFileIndexWriter implements Closeable {
// ...
// 核心数据结构:一个 Map,存储了所有需要维护的索引。
// Key 是列名,Value 是该列对应的索引维护器。
private final Map<String, IndexMaintainer> indexMaintainers = new HashMap<>();
// ...
}
IndexMaintainer 是一个内部接口,它有两个实现:FileIndexMaintainer 和 MapFileIndexMaintainer。这体现了策略模式,针对普通列和 MAP 类型的列,使用了不同的维护策略。
// ... existing code ...
interface IndexMaintainer {
void write(InternalRow row);
String getIndexType();
Map<String, byte[]> serializedBytes();
}
/** 普通列的索引维护器 */
private static class FileIndexMaintainer implements IndexMaintainer {
// ...
}
/** MAP 类型列的索引维护器 */
private static class MapFileIndexMaintainer implements IndexMaintainer {
// ...
}
// ... existing code ...
我们从构造函数开始,一步步看它是如何组织索引的。
初始化 (构造函数)
这是最复杂也最关键的部分。构造函数负责解析用户配置,并为需要建立索引的每一列创建相应的 IndexMaintainer。
// ... existing code ...
public DataFileIndexWriter(
FileIO fileIO,
Path path,
RowType rowType,
FileIndexOptions fileIndexOptions,
@Nullable Map<String, String> colNameMapping) {
this.fileIO = fileIO;
this.path = path;
// ... 准备列名到类型、位置的映射 ...
// 1. 遍历用户配置的所有索引列
for (Map.Entry<FileIndexOptions.Column, Map<String, Options>> entry :
fileIndexOptions.entrySet()) {
// ... 获取列名和字段信息 ...
// 2. 遍历该列上配置的所有索引类型 (比如 bloom-filter, bsi)
for (Map.Entry<String, Options> typeEntry : entry.getValue().entrySet()) {
String indexType = typeEntry.getKey();
IndexMaintainer maintainer = indexMaintainers.get(columnName);
// 3. 判断是普通列还是 MAP 嵌套列
if (entryColumn.isNestedColumn()) {
// 3.1 如果是 MAP 列,创建或获取 MapFileIndexMaintainer
// ...
MapFileIndexMaintainer mapMaintainer = (MapFileIndexMaintainer) maintainer;
if (mapMaintainer == null) {
// ... 创建一个新的 MapFileIndexMaintainer
mapMaintainer = new MapFileIndexMaintainer(/* ... */);
indexMaintainers.put(columnName, mapMaintainer);
}
// 为这个 MAP 维护器添加一个具体的 key 的索引
mapMaintainer.add(entryColumn.getNestedColumnName(), typeEntry.getValue());
} else {
// 3.2 如果是普通列,创建 FileIndexMaintainer
if (maintainer == null) {
maintainer =
new FileIndexMaintainer(
columnName,
indexType,
// 关键点:通过工厂 FileIndexer 创建具体的索引写入器
FileIndexer.create(
indexType,
field.type(),
typeEntry.getValue())
.createWriter(),
// 创建一个字段访问器,用于从 InternalRow 中高效获取数据
InternalRow.createFieldGetter(
field.type(), index.get(columnName)));
indexMaintainers.put(columnName, maintainer);
}
}
}
}
this.inManifestThreshold = fileIndexOptions.fileIndexInManifestThreshold();
}
// ... existing code ...
逻辑梳理:
- 外层循环:遍历所有被指定要建索引的列(例如
col_a,col_b)。 - 内层循环:遍历某一列上要建立的所有索引类型(例如
col_a上要建bloom-filter和bsi)。 - 策略选择:
- 如果是对
MAP类型的某个key建索引,就使用MapFileIndexMaintainer策略。 - 如果是对普通列建索引,就使用
FileIndexMaintainer策略。
- 如果是对
- 工厂模式创建具体索引写入器:
FileIndexer.create(...)是一个工厂方法,它根据传入的indexType(如 "bloom-filter"),返回一个具体的FileIndexer实现(如BloomFilterFileIndexer)。然后调用其createWriter()方法,得到一个真正的索引写入器FileIndexWriter(如BloomFilterWriter)。 - 组合:最后,将创建好的
FileIndexWriter和用于从行数据中取值的FieldGetter组合进一个FileIndexMaintainer对象中,并存入indexMaintainers这个 Map。
至此,DataFileIndexWriter 就完成了准备工作,它内部持有了所有需要维护的索引的“处理器”。
写入数据 (write 方法)
这个方法的逻辑非常简单直接:
// ... existing code ...
public void write(InternalRow row) {
// 将每一行数据广播给所有维护器
indexMaintainers.values().forEach(index -> index.write(row));
}
// ... existing code ...
当外部(RowDataFileWriter)写入一行数据时,DataFileIndexWriter 会将这行数据广播给它管理的所有 IndexMaintainer。每个 IndexMaintainer 接收到这行数据后,会使用自己的 FieldGetter 提取出它关心的列的值,然后喂给它内部持有的具体 FileIndexWriter(如 BloomFilterWriter)去更新索引状态。
完成写入 (close 方法)
当数据文件写入完成时,close 方法被调用,它负责将内存中构建好的所有索引序列化并持久化。
// ... existing code ...
@Override
public void close() throws IOException {
// 1. 让所有维护器序列化自己的索引数据
Map<String, Map<String, byte[]>> indexMaps = serializeMaintainers();
ByteArrayOutputStream out = new ByteArrayOutputStream();
// 2. 使用统一的格式写入器,将所有索引数据写入一个字节流
try (FileIndexFormat.Writer writer = FileIndexFormat.createWriter(out)) {
writer.writeColumnIndexes(indexMaps);
}
// 3. 根据索引总大小,决定是写入独立文件还是嵌入元数据
if (out.size() > inManifestThreshold) {
try (OutputStream outputStream = fileIO.newOutputStream(path, true)) {
outputStream.write(out.toByteArray());
}
resultFileName = path.getName();
} else {
embeddedIndexBytes = out.toByteArray();
}
}
// ... existing code ...
逻辑梳理:
- 调用
serializeMaintainers(),让每个IndexMaintainer将其内部FileIndexWriter构建的索引序列化成byte[]。 - 使用
FileIndexFormat将所有列、所有类型的索引数据,按照 Paimon 定义的统一格式,打包到一个ByteArrayOutputStream中。 - 检查这个字节流的总大小,与阈值
inManifestThreshold比较。- 如果大于阈值,就将其写入一个独立的
.idx文件。 - 如果小于等于阈值,就将其保存在
embeddedIndexBytes字段中,后续会被嵌入到DataFileMeta里。
- 如果大于阈值,就将其写入一个独立的
总结
DataFileIndexWriter 虽然是 final 类,但它通过组合 IndexMaintainer 接口的多个实现,并利用工厂模式 (FileIndexer.create) 动态创建具体的索引写入器,实现了高度的灵活性和可扩展性。
它的工作模式可以概括为:
- 初始化:根据配置,组装一个由多个专业“索引工人”(
IndexMaintainer)组成的团队。 - 处理:将每一行数据这个“原材料”分发给团队里的每个工人。
- 收尾:等所有原材料处理完毕,向每个工人收集他们完成的“零件”(序列化后的索引),然后将这些零件打包成最终的“产品”(一个索引文件或一段嵌入式字节码)。
这种设计将“索引的组织管理”和“具体的索引算法实现”清晰地分离开来,是软件工程中非常经典和优秀的设计实践。
IndexMaintainer 接口
该接口定义了所有“索引工人”必须具备的三种能力:
// ... existing code ...
interface IndexMaintainer {
// 接收一行数据,并更新内部的索引状态
void write(InternalRow row);
// 表明自己是什么类型的索引 (如 "bloom-filter")
String getIndexType();
// 将构建好的索引序列化成字节数组并返回
Map<String, byte[]> serializedBytes();
}
// ... existing code ...
下面我们分别对这两个实现进行剖析。
FileIndexMaintainer:普通列的索引维护者
FileIndexMaintainer 是最基础、最通用的索引维护器,它负责处理非嵌套的普通列(如 INT, STRING, DOUBLE 等)。
核心成员变量
// ... existing code ...
private static class FileIndexMaintainer implements IndexMaintainer {
private final String columnName; // 负责的列名
private final String indexType; // 负责的索引类型
private final FileIndexWriter fileIndexWriter; // 真正干活的索引写入器 (如 BloomFilterWriter)
private final InternalRow.FieldGetter getter; // 从一行数据中高效获取该列值的工具
// ... existing code ...
fileIndexWriter: 这是核心。它是一个具体索引算法的写入器实例,由FileIndexer.create(...).createWriter()工厂方法创建。FileIndexMaintainer只是一个包装器,它将数据传递给这个真正的写入器。getter: 这是一个性能优化的关键。InternalRow.createFieldGetter会根据列的类型和位置生成一个专门的字段访问器,避免了每次都通过列名去查找,提高了数据提取效率。
工作流程
-
构造 (
new FileIndexMaintainer(...)): 在DataFileIndexWriter的构造函数中被创建。它被告知要为哪个columnName、用哪种indexType、使用哪个fileIndexWriter实例,以及如何从InternalRow中获取数据 (getter)。 -
写入 (
write(InternalRow row)):// ... existing code ... public void write(InternalRow row) { // 1. 使用 getter 从行中提取出目标字段的值 // 2. 将提取出的值传递给具体的索引写入器 fileIndexWriter.writeRecord(getter.getFieldOrNull(row)); } // ... existing code ...逻辑非常清晰:接收一行数据 -> 提取目标列的值 -> 喂给具体的索引算法实例。
-
序列化 (
serializedBytes()):// ... existing code ... public Map<String, byte[]> serializedBytes() { // 调用具体索引写入器的序列化方法,并将结果用列名包装成 Map 返回 return Collections.singletonMap(columnName, fileIndexWriter.serializedBytes()); } } // ... existing code ...当索引构建完成时,它调用
fileIndexWriter.serializedBytes()获取最终的索引字节数据,并将其与列名关联起来返回。
小结: FileIndexMaintainer 是一个简单直接的委托者,它将对普通列的索引维护工作完全委托给了具体的 FileIndexWriter 实现。
MapFileIndexMaintainer:MAP 类型列的索引维护者
MapFileIndexMaintainer 则要复杂得多,它专门处理对 MAP 类型数据中指定的 Key 对应的 Value 建立索引的场景。例如,对于一个 MAP<STRING, BIGINT> 类型的列 attributes,我们可能只想为 attributes['click_count'] 和 attributes['price'] 建立索引。
核心成员变量
// ... existing code ...
private static class MapFileIndexMaintainer implements IndexMaintainer {
private final String columnName; // MAP 列的列名
private final String indexType; // 索引类型 (对所有 key 生效)
private final DataType valueType; // MAP 的 Value 类型
// 核心:一个 Map,Key 是用户指定的要索引的 Map Key (如 'click_count'),
// Value 是为这个 Key 的 Value 服务的具体索引写入器。
private final Map<String, org.apache.paimon.fileindex.FileIndexWriter> indexWritersMap;
private final InternalArray.ElementGetter valueElementGetter; // 从 Value 数组中取值的工具
private final int position; // MAP 列在整行中的位置
// ... existing code ...
indexWritersMap: 这是它与FileIndexMaintainer最大的不同。它内部维护了一个Map,管理着多个FileIndexWriter实例,每个实例都对应MAP数据中的一个特定key。
工作流程
-
构造与添加 (
new MapFileIndexMaintainer(...)和add(...)):- 在
DataFileIndexWriter的构造函数中,当第一次遇到某个MAP列的索引配置时,会创建一个MapFileIndexMaintainer实例。 - 随后,针对该
MAP列的每一个要索引的key(例如file-index.bloom-filter.columns=attributes:key1,attributes:key2),都会调用add(String nestedKey, ...)方法。
// ... existing code ... public void add(String nestedKey, Options nestedOptions) { // 为指定的 nestedKey (如 'key1') 创建一个具体的索引写入器,并存入 Map indexWritersMap.put( nestedKey, FileIndexer.create( indexType, valueType, new Options(options.toMap(), nestedOptions.toMap())) .createWriter()); } // ... existing code ...add方法会为这个nestedKey创建一个专属的FileIndexWriter实例,并存入indexWritersMap。 - 在
-
写入 (
write(InternalRow row)): 这是最复杂的逻辑。// ... existing code ... public void write(InternalRow row) { // ... // 1. 从行数据中获取整个 Map 对象 InternalMap internalMap = row.getMap(position); InternalArray keyArray = internalMap.keyArray(); InternalArray valueArray = internalMap.valueArray(); Set<String> writtenKeys = new HashSet<>(); // 2. 遍历 Map 中的所有 key-value 对 for (int i = 0; i < keyArray.size(); i++) { String key = keyArray.getString(i).toString(); // 3. 检查这个 key 是否是我们关心的 (是否在 indexWritersMap 中) org.apache.paimon.fileindex.FileIndexWriter writer = indexWritersMap.getOrDefault(key, null); if (writer != null) { // 4. 如果是,就将对应的 value 喂给它的专属索引写入器 writtenKeys.add(key); writer.writeRecord(valueElementGetter.getElementOrNull(valueArray, i)); } } // 5. 对于那些我们关心、但在这行数据中没有出现的 key,写入 null for (Map.Entry<String, FileIndexWriter> writerEntry : indexWritersMap.entrySet()) { if (!writtenKeys.contains(writerEntry.getKey())) { writerEntry.getValue().writeRecord(null); } } } // ... existing code ...它需要遍历当前行
MAP数据里的所有条目,找到那些用户指定要索引的key,然后把对应的value传递给相应的FileIndexWriter。对于那些配置了索引但在当前行没有出现的key,需要写入null来保持索引的完整性。 -
序列化 (
serializedBytes()):// ... existing code ... public Map<String, byte[]> serializedBytes() { Map<String, byte[]> result = new HashMap<>(); indexWritersMap.forEach( (k, v) -> { // ... // 将列名和 map key 组合成一个唯一的索引名,如 "attributes#k#key1" result.put( FileIndexCommon.toMapKey(columnName, k), v.serializedBytes()); // ... }); return result; } } // ... existing code ...它会遍历
indexWritersMap,将每个key的索引序列化,并使用FileIndexCommon.toMapKey方法生成一个能唯一标识“哪个列的哪个key”的字符串作为结果 Map 的 key。
小结: MapFileIndexMaintainer 是一个更复杂的分发者。它内部管理着一个“工人小组” (indexWritersMap),当接收到一行 MAP 数据时,它会根据 MAP 中的 key 将对应的 value 分发给小组里正确的工人去处理。

2532

被折叠的 条评论
为什么被折叠?



