一、背景
我们使用 Iceberg 构建湖仓一体平台的初衷是希望解决业务方在使用 Hive 数仓时的一些痛点。主要包括以下几大方面:
(1)Hive 的查询性能达不到交互式分析的要求,所以经常需要把 Hive 的数据储存到其它引擎当中。
(2)上一点造成了出仓链路越来越多,越来越复杂,维护成本高。
(3)另外,出仓的数据容易形成数据孤岛,造成数据冗余,导致存储成本上涨。
(4)最后,Hive 的时效性不好,即使用 FIink 流式的引擎写入,延迟也会在小时级别。
我们希望我们的湖仓一体平台能够解决这些痛点,我们的目标是:
(1)首先,平台要是互联互通的,要支持各种引擎的访问,避免数据孤岛的出现。
(2)第二,查询要高效,以满足交互式分析的要求。
(3)第三,使用要尽可能的便捷,尽可能降低业务方的门槛。
我们的湖仓一体架构如上图所示,采用 Iceberg 来存储数据,数据是在 HDFS 上。入湖的几条链路包括 FIink、Spark 引擎来写入,也提供 java 的 API,业务方可以直接通过 API 来写入数据,后台有一个叫做 Magnus 的服务对 Iceberg 的数据进行不断的优化。另外我们也用 Alluxio 来对数据进行缓存加速。我们使用 Trino 来进行交互式分析,对外提供查询接口。写入 Iceberg 的数据有一部分是要继续写入下游的 Iceberg 表。一般是数仓的分层建模的场景。虽然我们减少了 Hive 出仓的链路,但是有一些场景可能 Trino 的查询还是达不到响应时间的要求。比如毫秒级的响应,可能还是会出仓到 ClickHouse、ES 等其它存储中。
下面简单介绍一下 Iceberg 的表结构,以及我们为什么选 Iceberg 作为存储格式。
Iceberg 有文件级别的元数据管理。它基于 snapshot 来做多版本的控制。每一个 snapshot 对应一组 manifest,每一个 manifest 再对应具体的数据文件。我们选 Iceberg 的一个比较重要的原因是其开放的存储格式。它有着比较好的 API 和存储规范的定义,方便我们在后续对它做一些功能上的扩展。
二、查询加速
接下来介绍我们目前的一些比较重要的工作。其中最核心的一项是查询加速。
因为我们面对的是 OLAP 的场景,一般是会有过滤条件的。所以我们第一个思路是如何尽可能过滤掉不需要扫描的数据。Iceberg 在文件级别记录了每一个列的一些统计信息,比如说 MinMax 值,这些统计可以在查询计划阶段就把一些不需要的文件过滤掉。我们很直观的一个想法是,如果对数据进行排序,就会让相同的数据有更好的聚集效果,在过滤的时候就会过滤掉更多的文件。
所以我们最早是做了多维的排序。过滤字段可能有多个,不能用简单的线性排序来做。线性排序只对靠前的排序字段有比较好的聚集效果。所以我们比较了 Z-ORDER 和 Hibert Curve 这两种排序方式。从多维排序的实现来比较,发现 Hibert 的聚集性会更好一点。所以我们目前都是采用 Hibert 的方式。不管是 Z-ORDER 还是 Hibert ,都要求参与排序的字段是一个整型值。对于非整型的数据,我们用 Boundary Index 的方式来参与计算。
我们会把数据按照需要多少区间,来切出不同的 Boundary。根据它的 Boundary Index 来参与 Z-ORDER 和 Hibert Curve 的计算。
有了排序以后,另一个问题是多维的排序字段是不可以无限增加的。一般来说排序字段的个数越多,其聚集效果会越差。我们对业务方的建议是一般不要超过四个排序字段。如果有更多的过滤字段怎么办?我们考虑到对于一些基数比较高的过滤字段,不去做排序,而是通过创建索引的方式,也能有一个比较好的过滤效果。
我们实现的索引是为了判断一个数据文件是否满足查询条件的要求。所以我们的索引是文件级别的,一个表可以针对不同的列创建不同的索引。一个 DataFile 可能会关联多个索引文件,我们把索引文件和 DataFile 的元数据一起存储在 manifest 里。
下面介绍一下我们支持的索引种类:
(1)BloomFilter:计算比较简单,占用空间也比较小。存在 false positive 的问题,只支持等值的查询。
(2)Bitmap:功能更强大,支持等值和范围查询,匹配更精准,更精准是因为可以对多个条件匹配到的数据进行交并补计算,同时它返回的行号也可以帮助进一步 skip 数据。Bitmap 的缺点是占用空间比较大,尤其是对一些高基数的字段,创建 Bitmap 索引,可能加载索引的时间已经超过了过滤掉数据所节约的时间,甚至会产生一些负向的效果。
(3)BloomRF:我们参考一篇论文,实现了一种 BloomRF 索引,它与 BloomFilter 的原理类似,但是用了多段的有序哈希函数来支持等值和范围的查询。它的存储开销也与 BloomFilter 类似。其问题也是会有 false positive。
(4)TokenBloomFilter、NgramBloomFilter,TokenBitmap、NgramBitmap:是针对 token 的索引,是为日志场景设计的。相当于对日志做一些分词的操作。分词完成以后,构建 BloomFilter 或者 Bitmap 这样的索引。TokenBloomFilter 和 TokenBitmap 针对的是英文的分词,Ngram 针对的是中文的分词。
除了索引以外,我们也在做对预计算的支持,内部叫做 Cube,或者 AggIndex,是针对聚合计算的加速。目前支持单表和星型模型的查询。一个 Cube 的定义,主要定义两个信息:一个是 Cube 的维度字段;另一个是 Cube 需要的聚合计算,常见的如 count、min、max、count distinct 等都是支持的。另外聚合是做在文件级别的。
举一个例子:
它是一个星型模型,lineorder 表是事实表,会关联 dates 、part 和 supplier 维表。如果要对这样一个查询场景去定义 Cube,所有需要在 group by 、where 语句中使用的字段都要作为维度字段。大家可以看到预计算是定义在事实表上的。它的预计算的定义是跟 lineorder 表关联的。但是这里使用到的一些列可能是有维表当中的列。我们做了一个叫做关联列的实现。事实表不仅可以用关联列来定义 Cube,同时也能用关联列对事实表的数据来进行排序和索引。像查询里,p_brand 上有一个过滤条件,Cube 数据也可以用到索引来进行过滤。上面的过滤条件也可以用来过滤事实表的数据。
定义了 Cube 以后,Magnus 服务会在后台去负责 Cube 文件的生成。因为是文件级别的聚合,所以生成的逻辑是每一个文件会去关联其他的文件。比如这是事实表当中的一个 DataFile,它会去关联三张维表。这三张维表关联完以后会计算聚合值,最终会生成一个 CubeFile。CubeFile 与索引的情况类似,它会跟 DataFile 关联起来,一起保存在 Manifest 当中。
对聚合值的处理,因为我们做的是文件级别的聚合。所以真正查询的时候,还需要把文件级别的聚合再做 global merge, 才能得到最终的一个聚合效果。这里分两种情况:
一种是可以直接累加的一些聚合值,如 min、max、count,在生成 Cube 文件的时候,可以直接存储聚合结果;有一些不能直接累加,比如 Average,存储的是中间状态。查询时需要判断能否用 Cube 来响应,比如下图中展示的查询:
它是一个原始的逻辑计划。我们会去找查询当中的 aggregation 节点。对于 aggregation 节点,判断其 source 表中是否存在一个 Cube 能满足聚合计算的要求。如果找到,会把逻辑计划进行转换。转换完以后,原来的 table scan 就会切换成 Cube 模式,就不去读原始的数据了,而是去读 Cube 文件的数据。因为 Cube 文件是异步生成的,所以就肯定会存在一种情况,可能有一些文件已经构建了 Cube,有一些文件可能还没有生成 Cube。查询改写这一侧会稍微有一点不一样。对于这种情况,我们的处理思路是把有 Cube 的部分,保持跟原来一样的改写方式;没有 Cube 的部分,现场把 Cube 的数据算出来,与已有 Cube 的数据做一次 union 以后,再做 global merge,这样可以得到一个最终的结果。
当然这个做法只适用于只有少量文件还没有 Cube 的情况。如果大部分文件都没有 Cube,那么直接退化成原始的计算会更好。
Cube 做好之后,我们目前在探索用 star-tree index 对 Cube 来做一个增强。我们参考了 Apache Pinot 的实现。
要解决的问题是,Cube 是可以响应不同的维度组合的。比如 Cube 的定义可能选了三个维度,查询的时候只用到了其中的两个或者一个,Cube 也是可以响应的。所以从节省存储的角度来说,用最细粒度的维度来定义 Cube。这样只需要一个 Cube,就可以响应所有维度组合的查询。
但是如果维度选的比较多,生成的 Cube,它的数据量也会比较大。而且维度多了以后,聚合效果会变差。如果用最细粒度定义的 Cube,去响应很少维度的查询,中间还需要额外做很多聚合的计算。
如果针对每一个查询都去定义特定的 Cube,可以保证查询的时候 Cube 一定是最优的。但是它的问题是所需要的存储成本就会比较高,所有不同的组合,都要实现,生成不同的 Cube 文件。
Star-Tree Index 希望在两者之间做一个折中。针对我们的 Cube 生成 Star-Tree Index 这样一个数据结构。
举一个例子,比如我的 Cube 的定义是 Dim1、Dim2、Dim3 这三个字段,聚合值是 count。虽然维度一共有三个,但是常用的可能是 Dim1、Dim2 这两个。这时候就可以按照 Dim1、Dim2 指定这两个维度字段来生成 star tree。star tree 是一个多叉树,每一层对应一个维度。每一层的节点是当前这一个维度的取值。比如 Dim1 的取值是 1、2、3,Dim2 的取值是 a、 b 、c。Star-Tee Index 会针对不同的取值来构造树的节点。每一层还会有一个特殊的 star 节点,star 节点的含义是忽略掉这一层的取值,或者我们认为 star 是一个通配符。全部聚合在一起以后,它的聚合的结果是多少。对于 star 节点,会额外生成一些 star record。star 节点下面的这些节点都会生成具体的一个 star record。比如例子里面,Dim1 取值是 “*” 的时候,Dim2 可能有 a、d 这两种。如果查询当中只用到了 Dim2 这一个维度,那么可以通过 star record 来进行响应。因为我只需要考虑 Dim1 为 “*” 的情况。
三、智能优化
介绍完查询加速以后,再来讲一下我们目前做的智能优化的一些工作。
针对的是我们的 Magnus 服务。我们最根本的目标是希望尽可能降低用户的使用门槛。比如 Hive 用户,他可能需要了解一些大数据的原理;小文件多了,应该怎么处理,可能需要做一些合并;Hive 表应该怎么做分桶,文件内部怎么做排序。我们目前所处的一个阶段,叫做自动化的阶段。用户不需要知道这么多底层的知识。但是他还是需要告诉我一些业务上的逻辑。比如常用的过滤字段是哪些,常用的聚合的模型是什么样子的。我们再根据用户提供的信息来自动帮他去创建索引,去创建 Cube。
最终我们是希望进一步简化,用户只是建表。表在建出来的使用过程当中,我们可以对它做一个智能的持续的优化。Magnus 服务就是以此为目的来开发的。它主要负责的功能包括:
(1)一个是自动的后台优化,目前所有 Iceberg 表的写入操作,Magnus 都会监听,当监听到写入事件后,它会根据自己内部的一些调度逻辑,通过 spark 任务对表进行一些操作,比如排序、创建索引、构建 Cube 等。
(2)另一个比较重要的功能是,它可以帮助我们把 Iceberg 表的一些详情做一个图形化的展示,便于我们定位和排查问题。比如下图中显示的一张 Iceberg 表。
可以看到表是定义了排序字段的,在界面上可以看到它某一个分区下有多少个文件,这些文件有哪些已经按照用户的要求做了排序,有哪些已经按照用户的要求去构建了索引等等。
(3)第三个功能是智能化的推荐。实现方式是使用 Trino 把查询明细全部落库。
查询明细当中包含了每张表用到的过滤字段,Magnus 服务会去定期去分析这些查询明细,结合用户的历史查询以及 Iceberg 表本身的统计信息。当然有一些统计信息可能是需要用 Trino 去现场计算出来的。结合这些信息以后,会给出一些优化建议。
上面的例子展示的是 Magnus 对某一张表的一次优化建议。可以看到表里面用户原本是定义了排序和索引字段的。Magnus 分析结果来看,首先是排序可以增加几个字段,同时可以删掉一些不必要的字段。索引也是可以去掉一些用不到的索引。后续我们会考虑根据推荐去验证效果。如果效果好,后面可以考虑去自动帮助用户进行修改。
四、现状
最后来介绍一下我们目前落地的情况。
目前主要场景包括 BI 报表、指标服务、A/B Test、人群圈选和日志等。
Iceberg 表总量大约为 5PB,日增 75TB。Trino 查询每天在 20 万左右,P95 的响应时间是 5 秒。我们给自己的定位为秒级到 10 秒级。过滤的数据量(估算)为 500TB/ 天,占比约 100%~200%。