iceberg


主要是对官方文档的一些理解和补充说明。

iceberg就是一个高性能的表格式,定义了对外提供了读写规范而无需关注于底层存储的不同。

Format Version

iceberg的格式版本现在正式发布了v1, v2,v3仍在开发当中还未正式使用。 版本的升级是由于采用了破坏兼容的新特性,即旧版本的处理引擎无法正确处理新版本格式的表。不过新的处理引擎可以继续使用旧的格式读写表来保证向前兼容。

v1版本定义了如何使用不可变的文件格式:Parquet, Avro, ORC来管理大型分析表。 v2版本通过删除文件提供了行级别的数据更新和删除功能。 v3版本则是扩展了新的数据类型和能力,这里不展开了。

目标

使用iceberg能够达成一下目标

  1. 可序列化隔离级别:读和并发写隔离。读写操作永远使用已经提交的表快照的数据。写操作支持在一个操作当中删除和添加文件并且保证不会部分可见。读取操作不需要加锁。
  2. 高速度:操作使用O(1)时间复杂度的远程调用来得到一个扫表的计划,而不是随着表规模增大的O(n)。
  3. 大规模:job计划主要由客户端处理,并不会成为中央元数据存储的瓶颈。并且元数据当中包含了基于成本优化所需的信息。
  4. 演进:表将支持完整的schema和分区规范演变。模式演化(类型转换目前有局限)支持安全的列添加、删除、重新排序和重命名,包括在嵌套结构中。
  5. 可靠的类型:表将为一组核心类型提供定义良好且可靠的支持。
  6. 存储分离:分区将是表的配置。读取操作将使用数据值的谓词(而不是分区值)进行计划。表将支持不断发展的分区方案。
  7. 格式:底层数据文件格式将支持相同的模式演化规则和类型。将提供读优化格式和写优化格式。

概念

  • schema:表的字段名和类型信息
  • partition spec:定义如何从字段获取分区值
  • snapshot:表某一时间点的状态
  • manifest list:manifest文件的列表,每个快照有一个
  • manifest:data file或者delete file的列表。是snapshot的子集
  • data file:table 数据存储的文件
  • delete file:存储的数据是标记table中被删除的行

乐观并发

由于一个快照一旦写入完成就不会再修改了,因此在读取指定snapshot数据的时候,不会收到其他修改操作的影响。

metadata文件之间的原子交换操作是可序列化隔离的基础。iceberg允许多个writer并发执行,写数完成之后writer的提交其实就是用新版本的metadata与就的metadata之间做一个原子交换操作。

乐观并发就是值每个writer都乐观地认为自己写入的时依赖的版本在writer提交的时候也仍然不会变。但是如果确实发生了变化,writer提交时最新的version已经不是当时写数时依赖的version了,那么就会尝试在已经定义好的条件下,将本次writer的metadata变更重新在最新的version上应用一遍再提交。例如重写操作提交时候发现数据发生了更新,那么被重写的文件在最新的version中仍然存在的话,这个重写操作的变更是可以成功应用到最新version的metadata当中的。

writer成功提交所需要的条件决定了隔离级别,writer可以选择要经过验证的条件来提供不同的隔离级别。

序列号

每一个snapshot提交的时候都会分配一个序列号并记录到snapshot的元数据中。如果提交失败则会重新申请序列化并修改元数据总的序列号。

为一个snapshot创建的manifest,data file,delete file都会继承这个snapshot的序列号。snapshot文件manifest list记录了manifest的元数据,manifest序列号也记录在这里。写入的新的data file是会使用null替代序列号,在读取的时候会用manifest序列化代替。

将data file, delete file写入新的manifest时(发生重写操作),写入的是读取这个文件时继承的序列号。换句话说,data file, delete file的序列号就是写入这个文件的snapshot的序列化,并且在后续的快照中也仍然保持不变。

那么序列号的作用是啥呢?主要是用于data file和delete file。因为数据删除是通过写delete file完成的,那么有效的数据自然是经过delete file过滤之后的数据,为了delete file删除数据不影响后续新写入的数据,那么delete file自然只能对原来删除之前已经存在的data file生效。换成序列号来理解,就是delete file只能作用于序列号比其小的data file了

行级删除

iceberg有两种行级删除

  • 位置删除:delete file记录的是被标记的data file路径 + 要删除的行在data file中的位置
  • 等值删除:可以理解为sql的过滤条件,例如id = 5,那就是要将id =5的行全部标记为删除

Schema和数据类型

在iceberg当中定义了一组基本类型和嵌套类型,例如list,map,structs。 schema本身就是一个struct类型,有各种基本类型和嵌套类型一起组成。有哪些类型这里就不加介绍,可直接参考官网。

默认值

一个字段有两个相关的默认值设置,initial-defaultwrite-default。 initial-default在往已存在的schema当中添加字段的时候设置,write-default与其一起初始化为相同的值,不同的是后者可以在后续模式演化的时候进行修改。对于可选字段,null作为其默认值。

默认值使得可以不用重写表文件,例如添加一个新列之后,将新列的值直接处理为默认值,就像写入了一列值全部为默认值的新列一样。但是实际上并没有实际执行重写数据的操作就可以实现这点。 在写入数据文件的时候还可以省略有默认值的列,会自动填充默认值进行写入的。但是如果省略了没有默认值的列,那么写入会直接报错。

schema演化

shema的新增、删除、重命名、重排序都是支持的。 schema的变更应用会生成一个唯一的schema id来标识新的schema,然后加入到schema列表当中并设置为当前schema。这些可以在metadata文件当中看到。

schema演化当中的类型变更是比较局限的,只支持

  • int long
  • float double
    • decimal(P, S) to decimal(P', S) 其中 P' > P 

简而言之,只支持从低精度类型转为高精度类型,因为这种转换不需要重写数据就可以正常工作

schema当中的每个字段都有一个field id,对于schema演化

  • 添加字段会分配一个新的field id
  • 重命名只会修改字段名而不会修改field id。
  • 删除字段会从当前schema当中移除字段,主要额外注意的是,除非字段为null或者当前快照没有被修改,否则字段删除操作是无法回滚的

除此之外,还有一个schema演化不支持,

  • 不允许将结构体的字段子集分组到嵌套结构体中,也不允许将字段从嵌套结构体移动到它的直接父结构体中(struct<a, b, c> ,struct<a, struct<b, c>>之间的转化)。
  • 不允许将基本类型演变为结构,也不允许将单字段结构演变为基本类型(map<string, int> map<string, struct<int>>之间的转化)。

schema演化还和上面的默认值有些关联

  • initial-default必须在添加字段时设置,并且不能更改
  • write-default必须在添加字段时设置,并且可能会更改
  • 添加的字段为必需字段,那么它的两个默认值必须设置为非null
  • 添加的字段为可选字段,默认值可能为null,应该显式设置
  • 当将新字段添加到具有默认值的结构时,更新结构的默认值是可选的。
  • 如果结构体的initial-default中缺少字段值,则必须使用该字段的initial-default
  • 如果结构体的write-default中缺少字段值,则必须使用该字段的write-default 简而言之,就是不显式设置值的字段必须可以从struct或者字段本身获取到默认值,否则会保存。

column投影

iceberg当中的字段都是通过field id进行操作的。投影都是基于field id,只要field id匹配的上,就可以进行投影映射。

iceberg还有一个表属性schema.name-mapping.default用来定义默认的映射,可以在使用的field id不存在的使用使用备用字段。

Identifier Field IDs

identifier-field-ids这个属性当作主键理解比较简单。只要两个行的identifier-field-ids相同,那么这两个行就表示同一个entity。不过需要注意的是,iceberg不保证identifer的唯一性,这个由处理引擎和数据的提供者保证。

类似于主键,可以作为identifier字段一定是可以进行比较的(排除float, double),以及必选的避免出现null值。

预留的Field IDs

iceberg表不能使用大于2147483447(整数)的字段id。最大值- 200)的field id。这个范围已经预留给用户数据模式的元数据使用了。

分区

manifest文件存储的数据文件元数据中包含分区值的元组,在扫描数据文件的时候可以使用分区值来过滤掉不需要的数据文件。

分区规范定义了如何从一个记录当中获取分区值元组,包含一下部分:

  • 分区名
  • source column id或者source column id的列表,来自于schema
  • 分区字段id,在一个分区规范当中是唯一的。v2版本之后在所有的分区规范当中都是唯一的。
  • transform,如何将source column id(s)转换为分区字段值 选择作为分区的列一定是基本数据类型,不能是list或者map,但是这个列可以被嵌套在struct当中。

一个分区规范的示例如下

{
    "spec-id" : 2,
    "fields" : [ {
      "name" : "trip_id_bucket_16",
      "transform" : "bucket[16]",
      "source-id" : 2,
      "field-id" : 1001
	}]
}

分区规范当中记录了transform这个转换,所以在查询的时候不需要手动设置分区条件,这简化了查询。

如果两个分区规范具有相同数量的字段,并且对于每个相应的字段,这些字段具有相同的源列ID、转换定义和分区名称,则认为它们相互等效。如果表中已经定义了兼容的分区规范,则writer不能创建新的分区规范,即不同重复创建意义相同的分区规范。

分区字段id必需要被重用。

transform

定义了如何从源字段的值计算得到分区字段的值。

transform name描述源字段类型结果类型
identify使用原始值,不处理Any与源字段类型一致
bucket[N]对n取模intlongdecimaldatetimetimestamptimestamptztimestamp_nstimestamptz_nsstringuuidfixedbinaryint
truncate[W]截断宽度为wintlongdecimalstringbinary与源字段类型一致
year从日期/时间戳中提取年份datetimestamptimestamptztimestamp_nstimestamptz_nsint
month提取月份datetimestamptimestamptztimestamp_nstimestamptz_nsint
day提取天数datetimestamptimestamptztimestamp_nstimestamptz_nsint
hour从时间戳中提取小时timestamptimestamptztimestamp_nstimestamptz_nsint
void总为nullAny源字段类型或者为int
  • void transfrom用于替换掉分区字段现有的transform,以便在v1表中删除这个transform。
  • bucket transform使用murmur3 hash, x86 variant,随机种子0生成一个32bit的哈希值,然后去掉符号位保证哈希值为正,然后对N取模。这个过程可以用伪代码表示为def bucket_N(x) = (murmur3_x86_32_hash(x) & Integer.MAX_VALUE) % N。而且比较重要的是,可以通过演化分区规范从而在表增大的时候扩大桶的数量。
  • truncate transform。对于数值类型来说,截断是指截断为W的倍数。即v-(v%W)最后剩下的值就是W的整数倍,这个倒是有些别扭。对于string和binary类型就是截断的长度,这个倒是比较好理解,而且string类型截断是保留不超过W个码点的有效utf8字符串,binary是保留w个字节。

分区演化

可以通过添加、删除、重命名或者重新排序分区规范来演化表的分区信息。

修改分区字段信息会产生一个新的分区规范,然后添加到metadata文件的分区规范列表当中,并且设置为表的默认规范。修改分区规范只会新增一个metadata文件,没有其它的修改了。也就是说分区规范的修改只会影响后续的新增数据,已有的数据和分区不会改变,除非进行重写

在分区演化的过程中,修改分区信息不应该导致分区字段id发生改变,因为分区字段id被用作清单文件中的分区元组字段id。

在v2版本,每个分区字段必须显示追踪分区字段id,新的ID是基于metadata记载的上一次分配的分区id来分发的。可以在metadata文件当中找到last-partition-id这个属性。

但是在v1版本,就没有追踪这个分区字段id,而是从1000开始按顺序分配,这样当使用来自多个规范的manifest文件读取元数据的时候就存在问题,因为相同的分区字段id可能包含不同的数据类型。比如说分区字段id为1001的分区字段被删除,然后又添加了一个分区字段,现在它的分区字段id在v2版本当然是1002,但是在v1版本还是1001。所以v1版本的分区演化最好加上下面的约束

  1. 不要重排序分区字段
  2. 不要drop分区字段,而是用void transform替代原来的transform
  3. 只新增分区字段

排序

可以按列对分区内的数据进行排序来提高性能。排序的信息可以按照排序列的顺序在每个数据文件和删除文件中声明。

与分区规范类似,排序也有排序字段列表和sort order id,排序字段列表中的字段顺序就定义了应用到数据当中的字段排序顺序。

"default-sort-order-id" : 1,
"sort-orders" : [ {
  "order-id" : 0,
  "fields" : [ ]
}, {
  "order-id" : 1,
  "fields" : [ {
    "transform" : "identity",
    "source-id" : 1,
    "direction" : "asc",
    "null-order" : "nulls-first"
  } ]
} ]

source-id和transform都是记录从源字段的关系,所以和分区规范当中的意义是一样的。然后direction定义了正序还是倒序,null-order则是定义了null值是排在前还排在后,只能为nulls-firstnulls-last

order-id为0则是预留的值,给未排序使用的。对浮点数进行排序应该产生以下行为:-NaN < -Infinity < -value < -0 < 0 < value < Infinity < NaN。这与Java浮点类型比较的实现一致。

数据文件或者删除文件通过在manifest当中记录sort order id来关联排序信息。这些都记录在表的元数据文件当中,writer在写入新数据的时候需要按照默认的sort order id的排序信息来进行排序。但是如果默认的排序会导致写入开销过高的话也是排序也是不必要的,比如流式写入。