原文链接:www.inlighting.org/archives/un...
Iceberg 默认使用 Copy On Write 技术,也就是当你删除一行数据时,它会读取原有的文件,删除目标行,然后再重新写一遍,这样开销显然很大。后面 Iceberg 引入了 Merge On Read 技术,通过标记的方式,实现高效的数据删除。
Iceberg 的 Delete File 负责进行标记删除,目前 Delete File 有三种实现:
- Position Delete:将会在 V3 被废弃,由 Deletion Vector 替代。
- Equality Delete:主要用于 Flink 这种流式写入系统。
- Deletion Vector:Position Delete 进化版,V3 引入。
Position Delete
Position Delete 算一种特别的 Data File(Parquet、ORC 等等),一般和你 Data File 的格式保持一致,里面存储 Data File 里面被删除的行号。
数据构造
这里用 Spark-SQL 创建 Position Delete 表
bash
./bin/spark-sql --packages org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.9.0\
--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
--conf spark.sql.catalog.spark_catalog=org.apache.iceberg.spark.SparkSessionCatalog \
--conf spark.sql.catalog.spark_catalog.type=hive \
--conf spark.sql.catalog.local=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.local.type=hadoop \
--conf spark.sql.catalog.local.warehouse=$PWD/warehouse \
--conf spark.sql.defaultCatalog=local
sql
use local;
create database test;
use test;
CREATE TABLE position_delete (id bigint, data string) USING iceberg
TBLPROPERTIES ('write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read');
INSERT INTO position_delete VALUES (1, '1'), (2, '2'), (3, '3'), (4, '4');
update position_delete set data = 'v3' where id = 3;
文件结构
Position Delete 一共有三列:
- file_path:对应删除 Data File 的路径
- pos:被删除的行号,从 0 开始计数。
- row:保存被删除行的完整数据。这一列是可选的,Spark 默认写是不带这一列的。可以用于数据审计、一致性检查啥的。
bash
parquet cat data/00000-5-060ba8dc-6166-4918-8b5c-9809dbea7012-00001-deletes.parquet
{"file_path": "/warehouse/test/position_delete/data/00002-2-6a844605-c2e7-450a-a9a3-42679bdf2d18-0-00001.parquet", "pos": 0}

从 Position Delete 的列结构看出,Position Delete 是一对多的,一个 Position Delete 能同时对应多个 Data File。
Equality Delete
Equality Delete 算一种特别的 DataFile(Parquet、ORC 等等),里面存储 Data File 里面被删除的值。
数据构造
Equality Delete 目前只能用 Flink 造出来,Flink 环境有点复杂,故这里的步骤详细点。
这里使用 Flink 1.20.1。启动前需要设置 HADOOP_CLASSPATH,不然写 Iceberg 的一些 Hadoop 依赖会找不到。
这里我用的是 Hadoop 3.4.1。
bash
wget https://archive.apache.org/dist/hadoop/core/hadoop-3.4.1/hadoop-3.4.1.tar.gz
tar zxvf hadoop-3.4.1.tar.gz
HADOOP_HOME=`pwd`/hadoop-3.4.1
export HADOOP_CLASSPATH=`$HADOOP_HOME/bin/hadoop classpath`
下载 iceberg 依赖到 flink/lib
目录下。
bash
cd flink/lib/
wget https://repo.maven.apache.org/maven2/org/apache/iceberg/iceberg-flink-runtime-1.20/1.9.0/iceberg-flink-runtime-1.20-1.9.0.jar
启动 Flink
bash
./bin/start-cluster.sh
启动 Flink-SQL
bash
./bin/sql-client.sh
sql
CREATE CATALOG iceberg WITH (
'type'='iceberg',
'catalog-type'='hadoop',
'warehouse'='file:///Users/smith/Software/flink-1.20.1/data/iceberg'
);
use catalog iceberg;
create database test;
use test;
CREATE TABLE equality_delete (
`id` INT COMMENT 'unique id',
`name` string,
`data` STRING NOT NULL,
PRIMARY KEY(`id`, `name`) NOT ENFORCED
) with ('format-version'='2', 'write.upsert.enabled'='true');
insert into equality_delete values(1, 'smith', 'a'), (2, 'danny', 'b'), (3, 'alice', 'c'), (4, 'bob', 'd');
insert into equality_delete values(4, 'bob', 'e'), (5, 'dennis', 'f'), (6, 'jasson', 'g'), (7, 'parker', 'h');
这里设置了 id
和 name
为主键列。Flink 在每次 insert 前,都会先写一遍 Equality Delete,把之前的数据给删了,然后再插入新数据。
比如执行 insert into equality_delete values(1, 'smith', 'a');
,Flink 会先生成一个 Equality Delete,标记删除之前 id=1 AND name='smith'
的所有行。再生成一个 Data File 插入 (1, 'smith', 'a')
。
文件结构
Equality Delete 的列数等同于你的主键列。
bash
❯ parquet cat data/00000-0-7995d4c6-810e-48ca-a72c-9f6b41ec6936-00002.parquet
{"id": 4, "name": "bob"}
{"id": 5, "name": "dennis"}
{"id": 6, "name": "jasson"}
{"id": 7, "name": "parker"}
Equality Delete 同样是一对多关系,一个 Equality Delete 可以对应删除多个 Data File。
Deletion Vector
Deletion Vector 是在 V3 中推出,用于取代 Position Delete。其存储格式是一个 bitmap,第 n 个 bit 被设置意味着其对应的 Data File 第 n 行被删除。
Deletion Vector 和 Data File 是一对一关系。Deletion Vector 存储着该 Data File 所有被删除的行。这就意味着写入引擎每一次删除数据时,都要把过去的删除记录读出来,进行合并,生成一个新的 Deletion Vector。
Deletion Vector 通过 referenced_data_file
字段关联。
DeleteFileIndex 构建逻辑
Iceberg 在 planFiles()
的时候,通过 DeleteFileIndex 处理 Data File 和 Delete File 的映射关系。
DeleteFileIndex 内部维护了如下几个索引结构:
java
class PartitionKey {
int partitionSpecId;
Object partitionValue;
}
class DeleteFileIndex {
List<EqualityDeleteFile> globalEqualityDeletes;
Map<PartitionKey, EqualityDeleteFile> equalityDeleteFilesByPartition;
Map<PartitionKey, PositionDeleteFile> positionDeleteFilesByPartition;
Map<String, PositionDeleteFile> positionDeleteFilesByPath;
Map<String, DeletionVector> deletionVectorsByPath;
}
PartitionKey
PartitionKey 由 PartitionSpecId + PartitionValue 组成。
注意即使一个表是非分区表,其 DeleteFile 也是有 PartitionKey 的,可以简单的认为是:
java
class PartitionKey {
int partitionSpecId = 0; // 所有表都有一个初始为 0 的 PartitionSpecId
Object partitionValue = null;
}
索引创建逻辑
java
for (DeleteFile file : files) {
switch (file.content()) {
case POSITION_DELETES:
if (isDeletionVector(file)) {
// Deletion Vector 是一定有 referenced_data_file
addDeleteVectorsByPath(file);
} else {
if (hasReferencedDataFileLocation(file)) {
addPositionDeletesByPath(file);
} else {
addPositionDeletesByPartition(file);
}
}
break;
case EQUALITY_DELETES:
if (isPartitioned(file)) {
addEqualityDeleteFilesByPartition(file);
} else {
// 如果该 Equality Delete 是没有分区的,那么意味着它是 Global 的,即对应所有分区
addGlobalEqualityDeleteFiles(file);
}
break;
default:
throw new UnsupportedOperationException("Unsupported content: " + file.content());
}
}
GlobalEqualityDeletes
如果一个 Equality Deletes 是没有分区的,那么其就会被加入 GlobalEqualityDeletes,即匹配所有分区。
Partition Spec 通过如下代码判断其是没有分区的:
java
public boolean isPartitioned() {
return fields.length > 0 && fields().stream().anyMatch(f -> !f.transform().isVoid());
}
Partition Spec 里面的 transform Void
是不算分区的。Void 的意思就是以前他是一个分区列,后面 schema evolution 被删除了,v1 表为了兼容性问题,拿 Void 做个占位。iceberg.apache.org/spec/#parti...
EqualityDeletesByPartition / PositionDeletesByPartition
没什么好说的,存储 PartitionKey → 对应 EqDeleteFiles / PosDeleteFiles 的映射。
PositionDeleteFilesByPath / DeletionVectorsByPath
如果 Delete File 的 referenced_data_file
有值,那说明其有直接对应的 Data File 文件,针对这种情况,直接加入 PositionDeleteFilesByPath / DeletionVectorsByPath 索引。
像 Deletion Vector 是一对一关系,其 referenced_data_file
是一定有值的。
Data File 和 Delete File 匹配规则
官方有具体的描述规则:iceberg.apache.org/spec/#scan-...
现在假设把一个 Data File 输入构建好的 DeleteFileIndex,DeleteFileIndex 执行如下匹配逻辑:
- 查找
globalEqualityDeletes
,满足:- Data File 的 sequence_number < Equality Delete 的 sequence_number。
- 检查主键列的统计信息和 Data File 的列统计信息是否匹配。
- 查找
equalityDeleteFilesByPartition
,满足:- Data File 和 Equality Delete 含有相同的 Partition Key。
- Data File 的 sequence_number < Equality Delete 的 sequence_number。
- 检查主键列的统计信息和 Data File 的列统计信息是否 overlap。
- 查找
deletionVectorsByPath
,满足:referenced_data_file
对应即可。
- 如果该 Data File 存在对应的 Deletion Vector,那么就不用找 Position Delete 了,直接返回即可。返回
found_global_eq_delete
+found_partition_eq_delete
+found_deletion_vector
。 - 如果没有 Deletion Vector,查询
positionDeleteFilesByPartition
,满足:- Data File 的 Partition Key 等于 Position Delete 的 Partition Key。
- Data File 的 sequence_number ≤ Position Delete 的 sequence_number。
- 查询
positionDeleteFilesByPath
,满足:referenced_data_file
对应即可。
- 返回
found_global_eq_delete
+found_partition_eq_delete
+found_partition_pos_delete
+found_path_pos_delete
为什么 Equality Delete 的 sequence_number
不是 ≤ 关系?
Sequence number 是每一次 snapshot commit 的时候,自增提交的。在一批次 commit 中,可能会同时夹杂了 Delete File 和 Data File。如果你允许了 ≤,那么请问在这一批次中,你的 Equality Delete 知道要对应哪些 Data File 吗。
且在 Flink upsert 的场景中,他 commit 里面的 Equality Delete 本来就是用于删除过去 sequence_number
中出现的主键,所以这么设计很合理。
至于 Position Delete 和 Deletion Vector ≤ 没关系的原因是,Position Delete 的文件里面,本来就有 file_path
告诉你对应哪一个 Data File。Deletion Vector 则由 referenced_data_file
来告诉你对应的 Data File。
对应关系小结
- 一个 Data File 可以同时关联 Equality Delete + (Position Delete / Deletion Vector)。
- 如果一个 Data File 已经关联了 Deletion Vector,那么其一定不存在 Position Delete。
传统 Position/Equality Delete 存在的性能问题
Position Delete 和 Equality Delete 都是一对多模型。在现在 AP 引擎里面,一个 Data File 会被切分成多个 split,然后每个 split 被分发到不同的节点上面。
比如一个 Data File 关联了 2 个 Delete File,此时该 Data File 被分割成两个 split,分发到两个节点上去。此时需要加载 4(2*2) 个 Delete File。相同于相同的 Delete File 被重复读取了一次。
此外在处理关联关系的时候,也很麻烦,毕竟上面的 DeleteFileIndex 的构建逻辑这么复杂。
Deletion Vector 则通过强制一对一的关系,有效缓解了这种情况。同时查询引擎在处理关联时也更加高效。
至于 Equality Delete,可以通过改写查询计划避免重复读取 Equality Delete。把一个 scan 查询,改写成 left anti join。左表是 Equality Delete,右表是 Data File。Join on 的 key 是主键。
个人觉得 Equality Delete 比较鸡肋,本质上就是 Iceberg 无法实现主键模型,然后为了迎合 Flink,硬凑出的产物。重点是 Iceberg 自己本身还不能完全保证主键不重复,全靠写引擎自己自觉。
反正 Equality Delete Flink 用的是爽,就是恶心读引擎了。
本质上还是 Iceberg 就不应该涉足主键模型,主键模型交给 Hudi、Paimon 这种专业的就行了。
结尾
以上不一定都对,如果错误,欢迎评论。