上一篇把 Timeline、Instant、FileGroup 和 FileSlice 串了一遍。理解这些概念以后,再看 Hudi 的索引机制会更自然:Hudi 传统写入索引的核心不是让普通 SQL 查询更快,而是帮助写入端在 Upsert、Delete 时快速定位记录属于哪个 FileGroup;到了 Metadata Table 和 Record Index 之后,部分索引能力也会同时影响写入定位和查询裁剪。
普通 Hive / Parquet 表做更新时,最麻烦的是“定位”。如果一批 CDC 数据里来了 order_id=1001 的更新,写入任务必须先知道这条记录是否已经存在,存在的话在哪个文件里。没有索引,就只能把候选文件读出来做匹配;表越大、分区越多、更新越随机,这个成本越高。
1. 索引的写入定位
1.1 背景问题
先看一个很常见的入湖场景。
MySQL 订单表通过 CDC 进入 Kafka,再写入 Hudi。第一次来的数据是:
order_id=1001, dt=2025-07-06, status=CREATED
order_id=1002, dt=2025-07-06, status=CREATED
过了一会儿,order_id=1001 付款成功:
order_id=1001, dt=2025-07-06, status=PAID
Hudi 写入端要回答三个问题:
order_id=1001是新记录还是旧记录更新?- 如果是更新,它原来在哪个 FileGroup?
- 如果分区字段变化了,旧分区里的记录怎么处理?
这就是 Hudi Index 要解决的问题。
索引的输入通常是 HoodieKey。HoodieKey 可以简单理解成:
HoodieKey = record key + partition path
比如:
record key: order_id=1001
partition path: dt=2025-07-06
索引的输出不是一整行数据,而是记录位置,也就是这条记录对应的 FileGroup / FileId。写入端拿到这个位置以后,才能把本次写入拆成 insert、update、delete 等不同路径。
1.2 Index 在写入流程中的位置
先看一张整体流程图。

这张图主要说明 Index 位于写入路径中,用来在 Upsert / Delete 前完成 record key 到 FileGroup 的定位。
Hudi 写入时大致会经历这些步骤:
- 读取输入数据,生成 HoodieKey。
- 根据
precombine.field在同一批数据里做合并,保留同一个 key 的最新版本。 - 使用 Index 查询 HoodieKey 是否已经存在。
- 给记录打上 location,也就是它属于哪个 FileGroup。
- 区分 insert、update、delete。
- COW 表重写 Base File,MOR 表追加 Log File。
- 提交 Timeline Instant。
这里最关键的是第 3 步和第 4 步。Index 的本质不是“建一个数据库索引给查询用”,而是在写入路径上做 record key 到 FileGroup 的定位。
可以把它理解成下面这个映射:
record key -> partition path -> file group
或者在全局索引里:
record key -> file group
这个映射越快,Upsert 的定位成本越低;这个映射越重,写入时维护索引的成本也越高。
1.3 有索引和无索引的成本差异
假设一张订单表每天一个分区,每个分区有 500 个 Parquet 文件。现在来了一批 10 万条更新,更新分布在最近 3 天。
没有有效索引时,写入端需要从候选文件里判断这些 key 是否存在。极端情况下,这会变成:
10 万条 incoming records
x 最近 3 天分区
x 每个分区 500 个文件
当然真实执行不会这么笨,Spark 可以并行,Hudi 也会做剪枝,但方向是一样的:更新定位会变成大范围文件扫描和 join。
有索引以后,定位过程会收敛。例如 Bloom Index 会先按分区、key range 缩小候选文件,再用 Bloom Filter 判断某个 key 可能在哪些 Base File 中;Bucket Index 则直接用 hash 计算桶号,定位到对应 FileGroup。
所以索引选择的核心是:
用多少额外存储和维护成本,换取多大的更新定位收益。
如果表只有追加,没有更新,索引价值不大;如果表是大规模 CDC 更新,索引选错会直接影响写入延迟、shuffle、checkpoint 和小文件治理。
1.4 Spark 示例:显式使用 Bloom Index 写入
先用当前单机环境跑一个小例子。这个例子不追求数据量,而是看清楚 Upsert 后 Hudi 怎样保留最新记录,以及 Timeline / FileGroup 会发生什么。
示例脚本如下:
cat > /tmp/hudi_index_demo.py <<'PY'
from pyspark.sql import SparkSession
spark = (SparkSession.builder
.appName("hudi-index-demo")
.master("local[2]")
.config("spark.sql.catalogImplementation", "in-memory")
.getOrCreate())
spark.sparkContext.setLogLevel("WARN")
base_path = "hdfs://localhost:9000/hudi/demo_index_bloom_orders"
rows1 = [
(1001, "2025-07-06", "CREATED", 99.8, 20250706102000),
(1002, "2025-07-06", "CREATED", 28.5, 20250706102010),
(1003, "2025-07-06", "CREATED", 66.0, 20250706102020),
]
rows2 = [
(1001, "2025-07-06", "PAID", 99.8, 20250706102100),
(1004, "2025-07-06", "CREATED", 18.0, 20250706102110),
]
common = {
"hoodie.table.name": "demo_index_bloom_orders",
"hoodie.datasource.write.table.type": "COPY_ON_WRITE",
"hoodie.datasource.write.recordkey.field": "order_id",
"hoodie.datasource.write.partitionpath.field": "dt",
"hoodie.datasource.write.precombine.field": "ts",
"hoodie.datasource.write.operation": "upsert",
"hoodie.index.type": "BLOOM",
"hoodie.bloom.index.prune.by.ranges": "true",
"hoodie.metadata.enable": "true",
}
for rows, mode in [(rows1, "overwrite"), (rows2, "append")]:
df = spark.createDataFrame(
rows,
["order_id", "dt", "status", "amount", "ts"]
)
writer = df.write.format("hudi")
for k, v in common.items():
writer = writer.option(k, v)
writer.mode(mode).save(base_path)
result = (spark.read.format("hudi")
.load(base_path)
.select(
"_hoodie_commit_time",
"_hoodie_file_name",
"order_id",
"dt",
"status",
"amount",
"ts"
)
.orderBy("order_id"))
print("=== hudi index demo result ===")
result.show(truncate=False)
print("=== table path ===")
print(base_path)
print("=== row count ===")
print(result.count())
spark.stop()
PY
执行命令:
source /etc/profile.d/bigdata_env.sh
hdfs dfs -rm -r -f /hudi/demo_index_bloom_orders
spark-submit \
--master local[2] \
--conf spark.sql.catalogImplementation=in-memory \
--jars /opt/module/hudi/lib/hudi-spark3.5-bundle_2.12-1.0.2.jar \
/tmp/hudi_index_demo.py
后续 hdfs dfs 命令中的 /hudi/demo_index_bloom_orders 与 Spark 代码中的 hdfs://localhost:9000/hudi/demo_index_bloom_orders 指向同一张 Hudi 表,只是省略了默认文件系统前缀。本文示例使用 --jars 显式指定 Hudi Spark bundle;如果已经在上一篇将该 bundle 放入 Spark jars,可以省略 --jars,但不要同时混入多个 Hudi 版本的 bundle。
真实输出如下:
=== hudi index demo result ===
+-------------------+------------------------------------------------------------------------+--------+----------+-------+------+--------------+
|_hoodie_commit_time|_hoodie_file_name |order_id|dt |status |amount|ts |
+-------------------+------------------------------------------------------------------------+--------+----------+-------+------+--------------+
|20250706102324442 |470979ce-c664-4489-8b62-a5cd1b154303-0_0-74-84_20250706102324442.parquet|1001 |2025-07-06|PAID |99.8 |20250706102100|
|20250706102311899 |470979ce-c664-4489-8b62-a5cd1b154303-0_0-74-84_20250706102324442.parquet|1002 |2025-07-06|CREATED|28.5 |20250706102010|
|20250706102311899 |470979ce-c664-4489-8b62-a5cd1b154303-0_0-74-84_20250706102324442.parquet|1003 |2025-07-06|CREATED|66.0 |20250706102020|
|20250706102324442 |470979ce-c664-4489-8b62-a5cd1b154303-0_0-74-84_20250706102324442.parquet|1004 |2025-07-06|CREATED|18.0 |20250706102110|
+-------------------+------------------------------------------------------------------------+--------+----------+-------+------+--------------+
=== table path ===
hdfs://localhost:9000/hudi/demo_index_bloom_orders
=== row count ===
4
这里可以看到两点:
order_id=1001被更新成了PAID。- 查询结果只有 4 条,而不是 5 条,说明 Upsert 生效了。
再看 HDFS 目录:
hdfs dfs -ls /hudi/demo_index_bloom_orders/2025-07-06
输出如下:
Found 3 items
-rw-r--r-- 1 root supergroup 96 2025-07-06 10:23 /hudi/demo_index_bloom_orders/2025-07-06/.hoodie_partition_metadata
-rw-r--r-- 1 root supergroup 435655 2025-07-06 10:23 /hudi/demo_index_bloom_orders/2025-07-06/470979ce-c664-4489-8b62-a5cd1b154303-0_0-37-36_20250706102311899.parquet
-rw-r--r-- 1 root supergroup 435628 2025-07-06 10:23 /hudi/demo_index_bloom_orders/2025-07-06/470979ce-c664-4489-8b62-a5cd1b154303-0_0-74-84_20250706102324442.parquet
这里可以看到两个 Base File 的 fileId 前缀相同,后面的 0_0-37-36、0_0-74-84 更接近写入任务相关的 write token,最后的 20250706102311899、20250706102324442 是对应的 commit time。判断 FileGroup 时不要把整个文件名都当成 fileId。COW 表更新时不是原地改 Parquet,而是写出新的 Base File,然后通过 Timeline 决定哪个 FileSlice 对查询可见。
还要注意,COW 表更新时会重写新的 Base File,因此没有变化的旧记录也可能出现在新的 Base File 中;但记录的 _hoodie_commit_time 仍可能保留其最近一次写入该记录的 commit time,不一定和当前 Base File 文件名中的 commit time 完全一致。
Timeline 中也能看到两次 commit:
hdfs dfs -ls /hudi/demo_index_bloom_orders/.hoodie/timeline
输出如下:
Found 7 items
-rw-r--r-- 1 root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102311899.commit.requested
-rw-r--r-- 1 root supergroup 3430 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102311899.inflight
-rw-r--r-- 1 root supergroup 3978 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102311899_20250706102323657.commit
-rw-r--r-- 1 root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102324442.commit.requested
-rw-r--r-- 1 root supergroup 3534 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102324442.inflight
-rw-r--r-- 1 root supergroup 4065 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/20250706102324442_20250706102328027.commit
drwxr-xr-x - root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/timeline/history
这个例子里显式设置的是 Bloom Index,但更重要的是理解写入路径:Index 帮助 Hudi 找到已有记录所在的 FileGroup,Timeline 负责提交版本可见性,FileSlice 负责最终查询看到哪个文件版本。
2. 索引类型
2.1 Bloom Index
Bloom Index 是很多人最早接触的 Hudi 索引。它利用每个数据文件里的 Bloom Filter 来判断某个 record key 是否可能存在于这个文件中。
流程可以理解成:
incoming record key
-> 根据 partition path 找候选分区
-> 根据 key range 剪枝候选文件
-> 用 Bloom Filter 判断 key 是否可能存在
-> 命中候选 FileGroup
图示如下:

这张图主要说明 Bloom Index 不是直接命中唯一文件,而是先缩小候选文件范围,再由后续合并阶段确认记录是否存在。
Bloom Filter 有一个特点:它能判断“一定不存在”,但判断“可能存在”时会有假阳性。也就是说,如果 Bloom Filter 返回 maybe contains,Hudi 还需要在后续合并阶段真正确认记录是否存在。
如果开启 range pruning,并且 record key 在文件中具有一定范围分布特征,Hudi 可以利用文件级 key range 信息缩小候选文件;如果 key 完全随机,这一步的收益会明显下降。
Bloom Index 的常见配置如下:
hoodie.index.type=BLOOM
hoodie.bloom.index.prune.by.ranges=true
hoodie.bloom.index.filter.type=DYNAMIC_V0
hoodie.metadata.enable=true
hoodie.metadata.index.bloom.filter.enable=true
这些参数需要分开理解:
| 参数 | 含义 |
|---|---|
hoodie.index.type=BLOOM |
写入侧选择 Bloom Index |
hoodie.bloom.index.prune.by.ranges |
是否利用 record key 范围做文件剪枝 |
hoodie.bloom.index.filter.type=DYNAMIC_V0 |
根据文件内 key 数量动态调整 Bloom Filter 大小 |
hoodie.metadata.enable |
是否启用 Metadata Table 基础能力 |
hoodie.metadata.index.bloom.filter.enable |
是否在 Metadata Table 中维护 Bloom Filter 索引分区 |
这里没有写 hoodie.bloom.index.use.metadata,因为 Metadata Table / Bloom Metadata Index 相关参数在不同 Hudi 版本中变化较多。对 Hudi 1.0.2 来说,更稳妥的写法是先明确 hoodie.metadata.enable=true 和 hoodie.metadata.index.bloom.filter.enable=true;实际落地时以当前版本官方配置和写入日志为准。Bloom Filter Metadata Index 和 Column Stats Index 是 Metadata Table 中不同的索引分区,Bloom Index lookup 不应简单写成依赖 column stats。
Bloom Index 适合什么场景?
- 更新主要集中在最近分区。
- record key 有一定范围特征,可以被 range pruning 剪枝。
- 表规模不是特别夸张,候选文件数量可控。
- 更希望索引信息跟随数据文件,而不是引入外部系统。
它不适合什么场景?
- 更新随机打到全表所有分区。
- record key 完全随机,range pruning 效果差。
- 表非常大,读取大量文件 footer 或索引信息成本明显。
- 写入延迟要求非常低。
这里可以理解为:Bloom Index 的优势在于比较通用,不需要额外服务;缺点是定位成本仍然和候选文件数量有关。它不是“常数时间定位”,而是“尽量减少要检查的文件”。
2.1.1 事件表去重时为什么常用时间前缀 key
事件表和订单状态表不太一样。点击流、曝光日志、IoT 事件这类数据通常是近似追加的,但重复事件可能在采集、Kafka 重放、任务失败恢复时被引入。此时 Hudi 索引的重点不是处理大量历史更新,而是在写入前把重复事件识别出来。
如果事件天然有 event_id,可以把 record key 设计成类似:
event_ts + event_id
这样做有两个好处:
event_id保证同一个事件可以被识别。event_ts或到达时间前缀让 key 带有一定顺序,Bloom Index 的 range pruning 更容易裁剪候选文件。
这不是说所有事件表都必须这么设计 key。真正要看的是:重复数据主要发生在最近几个分区,还是可能随机打到全表;事件时间是否可信;上游是否已经有全局唯一 ID。如果 key 完全随机,Bloom Index 的范围裁剪价值会下降,反而可能增加 lookup 开销。
如果 event_id 已经是稳定的全局唯一 ID,不一定需要强行拼接 event_ts;拼接时间前缀时要保证同一个事件在重放、修正、延迟到达时仍然生成相同 record key,否则会破坏去重语义。
2.2 Simple Index
Simple Index 的思路更直接:把 incoming records 的 key 和目标表里已有 key 做 join,从而判断哪些是更新,哪些是插入。
在 Hudi 1.0.2 的 Spark 写入配置中,未显式指定 hoodie.index.type 时,默认索引类型通常是 Simple;实际行为仍以当前写入方式和配置为准。它的好处是实现简单、语义直接,不依赖 Bloom Filter 的假阳性判断。
常见配置:
hoodie.index.type=SIMPLE
Simple Index 大致做的是:
incoming keys
join
existing keys from related Hudi table range
-> matched: update
-> not matched: insert
优点:
- 实现直观。
- 对 key 是否有范围特征不敏感。
- 在 Spark 批处理里比较容易理解和排查。
缺点:
- 需要读取目标表中相关范围内的 record key / partition path,并与 incoming keys 做 join;如果是 Global Simple,lookup 范围会扩大到整张表。
- 数据量大时 shuffle 成本明显。
- 随机更新大表时,性能压力会很大。
所以 Simple Index 更适合中小规模表、批量修复、数据量可控的 Spark 写入场景。如果是一张非常大的 CDC 事实表,长期高频 Upsert,Simple Index 往往不是最优选择。
2.3 HBase Index
HBase Index 是外部索引。它把 record key 到 fileId 的映射放到 HBase 中,写入时可以通过 HBase 快速查到记录位置。
常见配置形态类似:
hoodie.index.type=HBASE
hoodie.index.hbase.zkquorum=hadoop1
hoodie.index.hbase.zkport=2181
hoodie.index.hbase.table=demo_hudi_index
具体参数名需要以 Hudi 1.0.2 当前 HBase Index 配置为准,不要直接把示例当成完整生产配置。如果要让它真正可执行,还需要补齐 HBase 依赖、表创建、HBase 客户端配置等步骤。
HBase Index 的特点很鲜明:
| 维度 | 说明 |
|---|---|
| 定位效率 | 点查效率高,适合小批量随机更新 |
| 索引范围 | 天然偏全局索引 |
| 运维成本 | 需要维护 HBase 集群 |
| 一致性 | 外部索引需要考虑 Hudi commit 成功、HBase index 更新失败,或 HBase index 成功、Hudi commit 失败时的恢复和回滚语义 |
| 适用场景 | 维度表、随机更新、高价值低延迟更新 |
我的倾向是:除非团队本来就有稳定 HBase 集群,并且确实需要外部索引的点查能力,否则学习和普通入湖场景不建议一开始就选择 HBase Index。它会把问题从 Hudi 表本身扩展到外部系统运维。
2.4 Flink State Index
Flink 写 Hudi 时,索引问题和 Spark 不太一样。
Spark 写入通常是一个批任务:读一批数据、查索引、写文件、提交。Flink 写入通常是长时间运行的流任务,CDC 数据一条条持续到达。如果每个 checkpoint 周期都去大范围扫描文件定位 key,成本会很高。
所以 Flink 常用状态索引,也就是把 key 到文件位置的映射维护在 Flink State 中。
在 Flink SQL 中常见参数是:
CREATE TABLE hudi_orders (
order_id BIGINT PRIMARY KEY NOT ENFORCED,
dt STRING,
status STRING,
amount DECIMAL(10, 2),
ts BIGINT
) WITH (
'connector' = 'hudi',
'path' = 'hdfs://localhost:9000/hudi/flink_orders',
'table.type' = 'COPY_ON_WRITE',
'index.type' = 'FLINK_STATE',
'write.precombine.field' = 'ts'
);
这里是 Hudi Flink SQL 中常见写法,实际可用参数以 Hudi 1.0.2 Flink connector 文档和当前 hudi-flink1.20-bundle 为准。后续如果写 Flink CDC 入湖实测,需要用 Flink SQL Client 的 SHOW CREATE TABLE 或实际任务日志确认最终参数。
Flink State Index 的优点:
- 点查速度快。
- 适合持续流式 Upsert。
- 能和 Flink checkpoint 一起恢复。
- 对 CDC 入湖比较自然。
缺点也很明显:
- State 会随着 key 数量增长。
- checkpoint 变重,恢复时间变长。
- 状态迁移、扩缩容、TTL 策略都要考虑。
- 如果先用 Spark 或其他方式完成历史全量写入,再启动 Flink 增量任务,Flink State 中一开始并没有历史 record key 到 FileGroup 的映射;这时需要确认 bootstrap / index 初始化 / 全量接增量方案,否则后续更新可能无法正确定位旧记录。
所以 Flink State Index 不是“免费索引”。它是把索引维护成本从文件扫描转移到了 Flink State 和 checkpoint 上。
2.5 Bucket Index
Bucket Index 的思路是:用 hash 把 record key 映射到固定桶,再把桶映射到 FileGroup。
图示如下:

这张图主要说明 Bucket Index 通过 hash 将 record key 映射到 bucket,再映射到 FileGroup。
简单理解:
bucket_id = hash(record_key) % bucket_num
bucket_id -> FileGroup
常见配置如下:
hoodie.index.type=BUCKET
hoodie.index.bucket.engine=SIMPLE
hoodie.bucket.index.num.buckets=8
hoodie.bucket.index.hash.field=order_id
上面是 Spark / DataSource 风格的配置示例;Flink SQL 中的配置项名称可能不同,需要按 Flink connector 文档转换,不能直接原样套用。
Bucket Index 的优势是定位路径短,不需要保存每一个 key 到 FileGroup 的状态映射,也不需要对大量候选文件做 Bloom 检查。对于大规模流式写入,它可以明显减少索引状态和 lookup 成本。
但它也有代价:
- 简单 Bucket Index 的桶数一旦确定,不适合随意修改。
- 如果 key 分布不均,某些 bucket 可能过热。
- 分区大小差异很大时,统一 bucket 数可能不合适。
- Bucket Index 通常要求你提前想清楚 hash field 和 bucket 数。
Hudi 1.0.x 中 Bucket Index 除了 SIMPLE bucket engine,也有 consistent hashing 方向;但具体是否适用于 Spark / Flink、是否支持当前写入模式,需要按对应版本文档核对。学习阶段可以先理解 SIMPLE bucket 的固定桶逻辑:它牺牲一部分灵活性,换取更稳定的定位成本。
2.6 Record Index 与 Metadata Table
如果只看旧资料,Hudi 索引经常被讲成 Bloom、Simple、HBase、Bucket 几类。但在 Hudi 1.0.x 里,Metadata Table 和 Record Index 也需要单独看。
Metadata Table 本身用于加速文件列表、列统计等元数据访问。开启 hoodie.metadata.enable=true 后,files 分区是最基础的 Metadata Table 能力;其他索引分区通常需要单独启用或通过相应索引能力创建。
在 Hudi 1.0.x 相关资料中,常见需要关注的 Metadata Table 分区包括:
files
column_stats
partition_stats
bloom_filter
record_index
secondary_index、expression_index 等能力如果在 newer / latest 文档中看到,需要按当前版本文档重新确认,不要直接当成 Hudi 1.0.2 学习环境里的稳定能力。
在本文的 Bloom Index 示例表里,也能看到 Metadata Table 目录:
hdfs dfs -ls /hudi/demo_index_bloom_orders/.hoodie/metadata
真实输出中可以看到:
drwxr-xr-x - root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/metadata/column_stats
drwxr-xr-x - root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/metadata/files
drwxr-xr-x - root supergroup 0 2025-07-06 10:23 /hudi/demo_index_bloom_orders/.hoodie/metadata/partition_stats
本文第 4 节示例只开启了 Metadata Table 基础能力,因此实际输出中看到 files、column_stats、partition_stats;没有启用 Record Index,也没有在该脚本中显式启用 bloom_filter metadata index,所以不会出现 record_index / bloom_filter 目录。
Record Index 则更进一步:它在 Metadata Table 中保存 record key 到 location 的映射。本文讨论的是 Hudi 1.0.2 语境下的 Global Record Index;官方文档把 Record Index 定义为全局索引,可以跨分区保证 key 唯一性,并通过 hash sharding 支撑更大规模。后续版本中还可能出现 Partitioned Record Index 等变体,不应混为一谈。
常见配置方向如下:
hoodie.index.type=RECORD_INDEX
hoodie.metadata.record.index.enable=true
本文以 Hudi 1.0.2 为准,Record Index 常见配置是 hoodie.index.type=RECORD_INDEX 与 hoodie.metadata.record.index.enable=true;如果阅读 newer / latest 文档,可能会看到新的 global record level index 参数名,实际使用时要回到当前版本文档确认。
Record Index 的价值在于大表随机更新。它避免了每次都通过 Bloom 或 join 在数据文件中找 key,而是把 key -> location 这个映射显式维护起来。
但它同样不是免费午餐:
- Metadata Table 会变大。
- 写入时需要维护 Record Index。
- 全局唯一语义更强,但更新分区路径时要更谨慎。
- 对运维排错能力要求更高。
这里可以理解为:Record Index 是 Hudi 1.x 之后非常值得关注的方向。对于大规模随机更新,它比传统 Bloom / Simple 更接近“真正的记录定位索引”。但如果只是小表或追加表,没有必要一开始就上 Record Index。
2.7 全局索引与非全局索引
Hudi 索引还有一个非常重要的维度:Global Index 和 Non-global Index。

这张图主要说明 Non-global Index 的唯一性范围是 partition path + record key,而 Global Index 的唯一性范围是整张表的 record key。
2.7.1 Non-global Index
非全局索引默认认为同一个 record key 只需要在同一个 partition path 内唯一。
也就是说:
record key + partition path -> unique record
如果写入 order_id=1001, dt=2025-07-06,索引只需要在 dt=2025-07-06 这个分区范围内查找它。
优点是成本低,lookup 范围小。缺点是如果同一个 key 的 partition path 变了,写入端可能会把它当成新记录插到新分区,旧分区的数据还在。
2.7.2 Global Index
全局索引要求 record key 在整张表内唯一:
record key -> unique record
典型类型包括:
GLOBAL_BLOOM
GLOBAL_SIMPLE
HBASE
RECORD_INDEX
Bucket Index 一般不应直接理解为全局索引;Flink State Index 是否按全局语义处理,需要结合 Flink connector 的 index.global.enabled 等参数和版本文档确认。
全局索引的优势是语义更强,能处理 partition path 变化。比如用户维表按城市分区,用户从杭州迁到上海,如果 key 是 user_id,全局索引可以识别这是同一个用户的分区迁移。
代价是 lookup 成本更高,尤其是传统 global bloom / global simple,可能要跨更多分区和文件。
2.7.3 怎么选择
我一般会先问两个问题:
- record key 是否天然全局唯一?
- partition path 是否可能变化?
如果 record key 只在分区内唯一,或者业务保证更新时 partition path 不变,优先考虑非全局索引。
如果 record key 是全局唯一主键,并且分区字段可能变化,再考虑全局索引。但不要只因为“全局听起来更强”就默认使用全局索引。全局能力越强,维护成本通常也越高。
2.7.4 分区字段变化时要单独确认更新语义
全局索引能发现“这个 key 已经在别的分区出现过”,但发现之后怎么处理旧分区记录,还要看对应参数。
Spark 写入中,传统 Global Bloom / Global Simple 常见参数是:
hoodie.bloom.index.update.partition.path=true
hoodie.simple.index.update.partition.path=true
它们表达的语义是:如果同一个 record key 的新数据带着新的 partition path 到来,写入端把新记录写到新分区,同时让旧分区里的旧记录失效。典型场景是用户表按城市分区,用户从杭州迁到上海。
这些参数主要针对 Spark 侧传统 Global Bloom / Global Simple 语义,是否对当前索引类型生效要看当前版本文档和实际写入日志。
如果这个语义没有处理好,就会出现一个很隐蔽的问题:新分区插入了一条新记录,但旧分区的老记录还在,查询全表时看起来像重复数据。
这里不要只记参数名,要先确认业务语义:
- 分区字段是不是会变化。
- record key 是否全局唯一。
- 旧分区记录应该删除,还是应该保留历史。
- 当前索引类型是否支持对应的 update partition path 参数。
HBase Index、Record Index 和 Flink 侧参数也有类似的分区迁移语义,但名字和默认值会随版本变化。真正落地时要按当前 Hudi 版本文档核对,不要把 Spark Global Bloom 的参数直接套到 Flink SQL 上。
3. 选型与常见问题
3.1 索引选型建议
下面是我在学习和测试环境中的一个粗略选型表。
| 场景 | 推荐方向 | 原因 |
|---|---|---|
| 纯追加日志表 | INSERT / 简单去重策略 | 不做更新时索引收益有限;如果存在重放或重复事件,需要结合 record key、precombine 和 insert 去重参数处理 |
| 小表批量 Upsert | Simple Index | 简单直接,排查方便 |
| 最近分区更新为主的事实表 | Bloom Index | 可以利用分区和 key range 剪枝 |
| 大表随机更新 | Record Index / 合理设计的 Bucket Index | Record Index 更适合全局记录定位,Bucket Index 更适合通过 hash 降低 lookup 和状态成本,但要提前设计 bucket 数与 hash field |
| Flink CDC 实时入湖 | Flink State Index / Bucket Index | 前者语义强,后者状态成本低 |
| 维度表随机更新且已有 HBase | HBase Index | 点查强,但运维成本高 |
| 分区字段会变化 | Global Index / Record Index + 分区迁移参数 | 需要跨分区识别同一 record key,并确认旧分区记录如何失效 |
| key 分布稳定、追求低状态 | Bucket Index | hash 直接定位 FileGroup |
这张表不是绝对规则。索引选型一定要结合数据规模、更新比例、分区策略、写入引擎和延迟目标。
索引选型不是只看索引名字,而是看 record key 分布、分区变化、写入引擎和状态 / 文件扫描成本。
我更关注下面几个问题:
- 更新是集中在最近分区,还是随机打到全表?
- record key 是否全局唯一?
- 分区字段是否会变化?
- 写入引擎是 Spark 批任务,还是 Flink 长流任务?
- 可以接受多少 checkpoint 状态?
- 是否愿意维护外部索引服务?
- 表规模增长后,索引维护成本会不会失控?
3.2 常见问题
3.2.1 为什么同样是 Upsert,有时写入很慢
Upsert 慢不一定是写 Parquet 慢,也可能是索引 lookup 慢。比如 Simple Index 需要 join 大量历史 key,Bloom Index 需要读取大量候选文件的 Bloom Filter,Flink State Index checkpoint 太大,都会拖慢写入。
排查时可以先看:
更新分布在哪些分区
候选文件数量
shuffle 数据量
Flink state 大小
checkpoint 时间
Metadata Table 是否开启
3.2.2 Bloom Index 为什么还有假阳性
Bloom Filter 的设计就是牺牲少量假阳性,换取更小的空间和更快的判断。它可以可靠地说“这个 key 一定不在文件里”,但不能可靠地说“这个 key 一定在文件里”。
所以 Bloom Index 命中候选文件后,后续仍然要在 merge 阶段确认记录。
3.2.3 Bucket 数能不能随便改
简单 Bucket Index 不建议随便改 bucket 数。因为 bucket 数参与了 key 到 bucket 的映射,一旦变化,同一个 key 可能映射到不同 FileGroup,历史数据和新数据就对不上。
如果确实需要调整,应该参考对应版本的 bucket index 管理能力和 clustering 支持,不要直接改参数重跑。
3.2.4 Flink State Index 会不会越来越大
会。只要表里的 key 数量持续增长,状态索引也会增长。RocksDB StateBackend 可以把状态放到本地磁盘并通过 checkpoint 持久化,但 checkpoint 时间、恢复时间、磁盘空间都会受到影响。
这也是为什么大规模 Flink 写入经常会考虑 Bucket Index 或 Record Index,而不是一直把所有 key 映射都压在 Flink State 上。
3.2.5 为什么不一开始就用全局索引
全局索引语义更强,但成本也更高。很多事实表的分区字段来自事件日期,更新通常不会跨分区;这种情况下非全局索引就够了。
如果为了“保险”默认使用全局索引,可能会让每次写入都扩大 lookup 范围,最后把性能问题转嫁给索引层。
3.2.6 为什么明明设置了 record key / PRIMARY KEY 还是有重复数据
先区分两种重复。
第一种是同一个分区内同一个 key 重复。这通常要检查 precombine.field、写入操作是不是 upsert、是否误用了 bulk_insert / insert,以及是否需要 hoodie.datasource.write.insert.drop.duplicates 等去重相关参数。具体参数名以 Hudi 1.0.2 文档为准。
第二种是跨分区重复。比如同一个 user_id 原来在 city=hangzhou,后来写入 city=shanghai。如果使用非全局索引,Hudi 只在新分区里查 key,就可能把它当成新记录。此时要考虑 Global Index、Record Index,或者在业务侧保证分区字段不会变化。
所以“定义了主键”不等于“全表永远不会重复”。Hudi 的 record key 不等同于传统数据库中的强制唯一约束,它需要结合索引类型、分区路径和写入操作共同保证最终语义。
3.3 索引选型判断
Hudi 索引的核心不是“让查询更快”,而是“让更新更可控”。
普通数据湖表最大的问题是文件系统不知道 record key 在哪。Hudi 通过 Index 把 record key 和 FileGroup 关联起来,再通过 Timeline 管理提交,通过 FileSlice 表达某个时间点的文件视图。索引只是其中一环,但它决定了 Upsert 的成本下限。
我不太建议一上来就纠结哪个索引最强。更实际的思路是:
- 先判断表是不是需要更新。
- 再判断更新是否跨分区。
- 再判断写入是 Spark 批还是 Flink 流。
- 最后根据规模决定 Bloom、Simple、State、Bucket、Record Index 这些方案。
如果只是学习环境,小表用 Simple 或 Bloom 就能把流程跑通。如果是生产 CDC 大表,索引选型就必须和分区设计、主键设计、checkpoint、文件大小、compaction 策略一起考虑。
对我来说,索引选型可以概括成一句话:
大表默认优先控制索引范围,实时写入重点控制状态大小,全局索引只在确实需要跨分区唯一性时使用。
4. 总结
这篇主要讲了 Hudi 索引机制。
- Hudi Index 的核心作用是把 HoodieKey 定位到 FileGroup。
- Upsert 是否高效,很大程度取决于索引 lookup 成本。
- Bloom Index 通过 Bloom Filter 和 range pruning 缩小候选文件,但存在假阳性。
- Simple Index 通过 join incoming keys 和历史 keys 判断 insert / update,简单但大表成本高。
- HBase Index 是外部全局索引,点查强,但运维成本高。
- Flink State Index 适合流式 Upsert,但状态和 checkpoint 会随 key 数增长。
- Bucket Index 用 hash 直接定位桶和 FileGroup,适合控制状态成本,但桶数设计要谨慎。
- Hudi 1.0.x 里的 Metadata Table 值得重点关注,它不仅服务文件列表和统计信息,也承载 Record Index 等索引能力;Record Index 在大表随机更新场景中尤其值得单独评估。
- 全局索引能力更强,但不应该默认使用;非全局索引在分区稳定的事实表里通常更划算。
- 判断重复数据时,要同时看 record key、partition path、写入操作、precombine 和索引唯一性范围。
下一篇继续看 Hudi 表类型和查询类型:Copy On Write、Merge On Read、Snapshot Query、Incremental Query 和 Read Optimized Query。理解索引以后,再看 COW / MOR 的写入和读取差异,会更容易判断一张表到底该怎么建。
本文以 Hudi 1.0.2 实测环境为准,latest 文档可能已经包含后续版本变化。