基于 Flink 的小米数据集成实践
摘要:本文整理自小米计算平台高级工程师胡焕,在 FFA 数据集成专场的分享。本篇内容主要分为四个部分:
发展现状
思考实践
引擎设计
未来规划
Tips:点击「阅读原文」查看原文视频&演讲 ppt
01
发展现状
首先介绍一下小米计算平台,小米计算平台主要负责小米集团的数据开发平台的建设,体现在产品上是小米数据工场,底层引擎上常见的 Flink、Spark、Iceberg、Hive 等等都是由计算平台在负责。
上图是小米数据工场的技术架构图。
正中间的蓝色高亮框是小米自研的消息中间件 Talos,可以把它替换成大家比较熟悉的 Kafka,这对今天的分享内容来说几乎没有任何差别。
Talos 右下方的蓝色高亮框是表格存储的技术选型,小米的数据湖技术选型选择了 Iceberg,Iceberg 是小米数据集成的主要场景之一。
右下角的红色高亮框就是数据集成。小米数据集成目前的主要场景还是数据的入湖入仓,但数据出湖出仓的场景也在快速增长。我们的最终建设目标是建立各种异构数据系统之间对接的能力,所以目前我们把数据集成作为一种基础服务在建设。
在产品层面,我们将数据集成划分为四个主要场景。
第一个主要场景是数据采集,主要是一些时间敏感数据的采集,比如客户端和服务端的埋点数据的采集、日志文件采集、物联网数据采集等等。这些场景需要一些专用的采集技术来支持,基本上没有办法整合到一个引擎中,我们将这些数据集成场景拆分为独立的采集和集成两个部分,两者通过消息队列进行对接。
采集的部分设计了单独的数据采集中心,用来配置各种采集服务。这些采集服务统一都输出到消息队列,这样我们可以把集成的部分统一起来,通过组合不同的实时集成作业来支持各种数据集成的场景。
第二个主要场景是实时集成作业,目前只支持消息队列和数据库作为上游数据源。消息队列场景里很大一部分的数据来源就是来源于数据采集中心,不同的采集场景对应的主流实时集成场景略有不同,例如埋点场景通常写入到 Iceberg 进行进一步的加工和分析,日志场景通常不做额外的加工直接写入到 ES 里,还有一些比较特殊的是 Schema On Read 场景,它们直接写入 HDFS 文件。
数据库场景与消息队列场景略有不同,消息队列场景里的采集和集成这两个部分是完全相互独立的,在数据库场景里,这两个部分则是紧密结合的。
第三个主要场景是离线集成作业,离线场景相对来说简单很多,略微麻烦一点的就是 MySQL 的分库分表和 Doris 这两个场景,后面为大家做详细介绍。
第四个主要场景是跨集群同步作业,目前我们只支持 Hive 和 Iceberg 做跨集群的同步,这是因为会涉及到跨国的数据传输,在合规和网络方面都会存在一些额外要求,所以我们尽量避免在采集或集成的环节做跨集群同步。
这四个场景除了数据采集中心无法整合到一个引擎里,其余三个场景都通过小米数据集成引擎支持了。
上图是集成作业的主要场景的作业数量,红色是实时作业,蓝色是离线作业,灰色字体的是存量作业。
我们将实时集成作业分为消息队列场景和数据库场景,再加上离线集成作业、跨集群同步作业,构成了四个主要场景。
注意到每个场景里还有相当多的存量作业,在孵化小米数据集成引擎以前,这些场景都是用不同的引擎来支持的。
上图展示的是小米数据集成的演进。
消息队列场景过去是由 Spark Streaming 支持的,现在换成了 Flink SQL。
数据库实时场景过去是由自研采集服务支持的,且只支持 MySQL 数据库,现在整体升级到了 Flink CDC + Flink SQL,并借助 Flink CDC 支持了更多的数据库。
离线场景里过去是由 DataX 支持的,现在我们也都换成了 Flink SQL。
跨集群同步场景过去由 Hadoop 的 DISTCP 命令来实现的,这个命令只能拷贝底层的文件,在拷贝 Hive 表的时候需要配合另外的命令来添加分区,体验非常糟糕,换到了 Flink SQL 就没有这个问题了,还增加了对 Iceberg 表的跨集群实时拷贝的支持。
直到现在小米还有大量的存量作业在等待迁移,这些存量作业的各种引擎给我们带来了非常大的维护负担。一方面占用了大量人力去熟悉和维护这些引擎,另一方面是我们很难在这些差异巨大的引擎上构建出统一的产品,这需要增加很多分支判断,也很容易出错。所以我们非常迫切地希望把这些场景由同一个引擎里支持起来,目前来看,Flink 在数据集成领域的优势极其明显,小米的数据集成引擎就是基于 Flink 构建的。
02
思考实践
首先我们尝试抽象出几个核心概念,并通过核心问题来界定每个核心概念的范围和边界。
我们将数据集成领域的生产实践都归类到这三个层级上,分别是数据集成领域、数据集成产品、数据集成引擎,这三个层级处理的核心问题是一层一层递进的。
数据集成领域的核心问题是连接。
数据集成的概念是相对于数据开发的概念来定义的,上图的右侧展示的就是数据开发领域最常见的技术栈,左侧展示的则是数据集成领域的范围。
在数据开发领域,离线数仓通常使用 Spark+Hive 的技术栈,实时数仓的最新的技术栈是 Flink+数据湖,这里的 Hive 和数据湖都可以用 Flink 或 Spark 直接访问。我们通常所说的数据开发的工作主要就是在离线数仓或数据湖内进行的,我们可以认为数据开发领域主要是基于现成的可以直接访问的数据进行开发。
在数据集成领域,关注的重点则是:数据在哪里,怎么访问这些数据,怎么让数据正确的参与到计算中。图中最左侧列出来的是最常见的几个数据集成场景,在这些场景里我们不能像常规的数据开发一样直接访问这些数据。比如我们肯定不会在要进行数据计算的时候才实时的连接到服务器甚至客户端读取数据,即使是最常规的数据库场景,我们通常也需要用一张 ODS 表来代替数据库参与计算,在作业中直连数据库很容易会让数据库压力过大,产生各种异常。
我们希望在数据开发过程中能够以一种统一的方式访问上游的各种数据源,这个处理过程就是数据集成领域里要解决的核心问题:连接。
注意这里强调的是连接,数据集成经常与数据导入的概念混淆,把数据导入到 ODS 表之后再去做数据开发是数据集成领域的一个优秀实践,但不是所有场景都必须要有导入这个步骤,在 Flink 中只要有 Connector 的支持,在简单场景中我们完全可以直接连接数据源做数据开发。
数据集成产品的核心问题是效率。
上图展示了最常见的数仓建模规范,我们主要关注其中的 ODS 表。
数据开发过程中经常需要多次读取原始数据,将原始数据导入到 ODS 表,再用 ODS 表替代原始数据参与数据开发,就可以避免重复连接上游的数据源。
现在经常提到的用 ELT 替代 ETL 的做法,代表的是 ODS 表设计的一个优秀实践,我们称之为镜像同步。镜像同步要求 ODS 表结构与上游数据的结构尽可能保持完全一致,并尽可能的保留上游数据的所有细节,数据清洗的步骤则往后放,改为基于 ODS 表实施,这样如果清洗逻辑存在问题,我们基于 ODS 表进行修复的代价也非常小。
有镜像同步这个优秀实践作为基础,我们就能够将数据集成的过程标准化了。将镜像同步的整个流程中的各种重复工作固化下来,就形成了数据集成产品,从而可以大幅提高数据集成工作的开发效率。我们可以认为数据集成产品就是从数据集成领域的各种优秀实践上发展而来的。
这里需要关注一个细节,将数据开发的结果导出到数据应用的过程中,同样存在很多优秀实践,而且这些优秀实践的展现形式、底层技术与数据集成产品的相似度都非常高。很多情况下我们会把这两种场景整合到一个产品里,在技术上这是非常合理的决策,但本质上数据导出场景更合适的名称是数据系统集成,这种做法在某种意义上是拓展了数据集成领域的边界。
数据集成引擎的核心问题是异构。
在底层技术上,我们需要有相应的数据集成引擎来支持我们的数据集成产品。在引擎的设计中,最核心的问题是解决异构数据系统对接带来的各种问题,引擎涉及到的数据系统越多,碰到的问题和解决方案也就越复杂。
上图展示的是其中的一个例子,不同数据系统支持的字段类型在语义上有细微差别,这些语义差别是数据集成引擎的主要问题来源之一。
举个例子,MySQL 没有布尔类型,通常我们用 tinyint(1)来实现布尔类型,但转换为布尔类型还需要配置 JDBC 参数对 Connector 行为做动态的调整。当镜像同步到 Hive 或者 Iceberg 的时候,字段类型如果没有匹配上,就可能会出现类型转换的异常。
Unsigned 也是一个非常经典的问题,其中最容易出错的是 Bigint Unsigned。Flink、Hive、Iceberg 都没有无符号类型,如果考虑到精度问题,我们就只能使用 decimal(20,0)来保存 Bigint Unsigned。但很多字段设计成 Unsigned 只是希望保证这个字段没有负数值,并不会产生有符号数值溢出的情况,所以很多用户仍然希望在 ODS 表中继续使用 Bigint,这里就很容易导致作业出现各种问题。
直到现在我们还有很多问题在解决中,对于数据集成引擎来说,解决异构数据系统对接带来的各种问题,屏蔽这些数据系统之间的差异,是最核心也是最有挑战的问题。
总结一下关键词,数据集成领域的核心是连接,数据集成产品的核心问题是效率,数据集成引擎的核心问题是异构。各种生产实践基本都可以对应到这三个层级。
Auto Catalog 特性是小米在数据集成领域的一个核心实践,通过 Auto Catalog 特性可以大幅度提升涉及异构数据系统的作业的开发效率。
正中间的 SQL 就是通过 Auto Catalog 的方式,实现的 MySQL 写入 Iceberg 的作业。右上角的语句是通过 Create Table 语法来引用 MySQL 表的方式,我们使用 Catalog 语法,也就是下面 SQL 中的“mysql_order.order.orders”三层结构来引用 MySQL 表的时候,就可以省略掉 Create Table 语句,在列比较多的情况下,这个 Create Table 语句构造很繁琐也很容易出错。
常规情况下使用 Catalog 语法,我们需要提前使用 Create Catalog 语句对 Catalog 进行注册,左上角和下方的两条语句就是 Create Catalog 语句。Auto Catalog 特性是在引擎中自动解析 SQL 中的 Catalog 进行自动注册,这样我们就可以省略这两个 Create Catalog 语句。这不仅可以提高我们的开发效率,最主要还可以避免一些敏感信息的泄露。
省略掉这三个 DDL 语句之后,整个 SQL 就变得非常简洁了,对数据开发和数据查询的效率提升都非常显著。
Auto Catalog 特性的两个环节都需要配合底层技术的支持。
第一个是在使用 Catalog 语法引用库表的环节。Catalog 语法虽然很简洁,但目前只有少数 Connector 原生提供了相应的 Catalog 实现。
这里我们用了两个措施:
基于 Netflix Metacat 建设了统一的元数据服务,我们把连接信息和账号密码等敏感信息都保存到 Metacat 里,这就避免了对外暴露。
利用 HiveCatalog 的兼容表特性,在 Flink 里变相实现其他数据系统的 Catalog。主要做法就是用 Metacat 实现 HiveMetaStore 接口,这个做法有个缺点,就是增加了类型转换的复杂度。比如原生提供的 Iceberg Catalog,它只需要关注 Iceberg 类型与 Flink 类型之间的双向类型转换,但如果用 Metacat,类型转换过程就变成了原生类型、Metacat 类型、Hive 类型到最后的 Flink 类型的四重类型转换,复杂度显著提升。所以建议有原生 Catalog 的情况下,尽量使用原生 Catalog。
第二个是自动注册 Catalog 的环节。手工构造 DDL 语句比较繁琐,因为它需要连接信息和账号密码,但正因如此构造 DDL 语句的过程本身就包含了授权步骤。而自动注册 Catalog 就规避掉了这个鉴权过程,所以我们引入了 Apache Ranger,它是一个安全管理的框架。我们基于 Apache Ranger 建设了统一权限机制,在 Flink SQL 中做了一个插件,通过在 SQL 优化器中增加规则的方式,来实施表级别的鉴权,这样我们就可以避免用户去访问无权限的 Catalog 或者库表了。
集成作业是我们最主要的数据集成产品,用于提供异构的数据源导入到 ODS 表的最佳实践。集成作业底层引擎是基于 Flink SQL 的,但与常规的 Flink SQL 作业相比,它额外提供了三个特性:
镜像同步,即在集成作业中整合了自动创建目标表的逻辑。在表的列非常多,或者包含一些很复杂的嵌套结构类型的情况下,这个特性可以节省很多工作量。
自动同步,即在源表的表结构发生变动的时候,自动将表结构的改动同步到目标表中。既可以保证数据的完整性,同时也减少了人工介入。
流批一体,即保障一次作业提交就可以完成整体同步,自动无缝衔接全量同步的批作业步骤和增量同步的流作业步骤。
这三个特性与常规的 Flink SQL 作业存在较大差异,所以我们在 Flink SQL 作业的基础上整合成了一个集成作业的产品,并进而发展出了数据集成引擎。
MySQL 实时集成作业里我们也多加了三个额外的特性。
第一个,专用采集账号。MySQL 的 Binlog 采集权限是整个 MySQL 实例级别的。也就是说只要有了采集权限,就相当于有了该实例所有 DB 的读权限。在小米 MySQL 实例部署多个 DB 的情况非常普遍,对用户账号直接开放采集权限的话,DBA 那边是完全没有办法接受的。所以我们仍然沿用采集作业和集成作业分开的一个架构,中间通过消息队列对接。
这样采集账号就只在采集作业中使用,采集作业里我们需要去做一个控制,即我们只向 Topic 输出单个 DB 的 Binlog。通过这个方式,将采集权限给限制在了 DB 的级别。
第二个,自动断点续传。我们与 DBA 平台打通了获取主从库拓扑结构的接口。这样我们就可以优先连接从库进行采集,在从库失效的情况下,我们还可以尝试获取其他可用的从库做自动重连。但这里有个前提是需要 MySQL 开启了 GTID,GTID 是 MySQL 的全局事务标志,在主从库中都能保持唯一,GTID 是自动断点续传的基础。
第三个,千表同步连接问题。MySQL 实例上,如果建立的采集作业太多,就会给我们的服务造成压力,所以我们需要尽可能复用采集作业。因为前面我们就提供了 DB 级别的 Binlog Topic,我们就直接共享了 DB 级别的 Binlog Topic。同一个 DB 上的所有表都会复用同一个 Binlog Topic。
在表特别多的情况下,Binlog Topic 的消费速度仍然有可能会成为瓶颈,因此我们在消息队列上还增加了按表过滤的环节,把消息过滤的逻辑下推到消息队列的服务端执行,这样能够有效减少网络流量、提高消费速度。
整个架构里,我们把采集作业的部分换成了 Flink CDC,但整体仍然是以消息队列为核心的架构。
分库分表中间件主要有两种实现。一种是将分片规则直接下发到 Client 端,这种情况中间件对外会直接暴露分表或者分库的名称。另外一种是基于代理的,对外展示为单库单表,实际上是由代理服务去转发请求到各个分库或者分表里。
第一种分库分表的支持相对简单一点,我们在 Catalog 语法中拓展了正则匹配的支持,可以显著的提升这种场景的开发效率。
第二种基于代理的中间件就会麻烦很多,代理中间件不太友好的地方,一个是在实现细节上和 MySQL 服务端有很多差异,另外一个是通过代理服务也无法采集它的 Binlog。所以关键点还是依赖于前面提到的元数据管理服务,就是我们需要通过 Metacat 去获取它真实的拓扑结构。
我们现在的实现是增加一个特殊的后缀,把它真正的分库分表的名称暴露出来,在实际执行的时候,SQL 语句会被转换成 UNION ALL 的形式后再执行。
上图是 Doris 写入支持分区覆盖语义的案例。
Doris 本身不支持 OVERWRITE 这个语义,但在实际场景中,我们有很多用户希望使用这个特性,而 Doris 本身又有类似的机制可以实现相似效果,只是目前的 Connector 还没有支持。
我们在数据集成引擎里加了一个处理,将 OVERWRITE 这个语句转换成等价于右边的三个 SQL 语句的操作,用 Doris 的临时分区特性来实现了 OVERWRITE 的语义。
前面两个例子都是把输入的 SQL 做了一些处理,实际执行的 SQL 是在数据集成引擎内生成的。这个机制,我们也同样用在了自动同步的特性上。
这里我们抽象出了一个叫 Schema Job 的概念。Schema Job 总是基于源表的表结构,按照最佳实践生成一个目标表的表结构,再把目标表的表结构替换掉,跑完了 Schema Job 我们就可以认为源表和目标表的表结构已经保持一致了。
离线集成作业支持自动同步非常简单,只需要在跑 Batch Job 之前执行一次 Schema Job 就可以了。
实时集成作业支持自动同步会稍微麻烦一点,仍然是先跑一个 Schema Job 把源表和目标表的表结构变成一致的,然后再跑起 Stream Job。当 Stream Job 退出的时候,我们需要做一个额外的判断,如果 Stream Job 是因为 Schema 变更而退出的,我们就再调度一个 Schema Job 去保持表结构一致,然后再尝试按照新的表结构跑起 Stream Job,就这样一直循环下去。
这里有一个细节,对于数据库场景,在发生 DDL 变更时,通常在 CDC Connector 里可以采集到一条 DDL 消息,我们可以用这个 DDL 消息触发 Stream Job 的退出。但在消息队列场景里,消息体的结构变更是不会产生类似 DDL 的消息的,这个时候如果我们不做任何处理,这个作业会一直正常的执行下去,但这些新的字段可能就被遗漏掉或者丢弃掉了。
这个时候我们就依赖一个叫做 fail-on-unknown-field 的特性,设置了这个特性之后,我们会实时检查消息结构体中是否有 SQL 中没有定义的字段。当检测到未知字段后,我们就会令当前的 Stream Job 失败,尝试触发 Schema Job 的循环。
我们在半结构化的数据接入场景上非常依赖这个特性,举例一个非常经典的场景:
很多业务的后端团队和数据团队在组织架构上是分开的,中间通过消息队列做数据对接。这种场景里,消息的生产端是后端团队在负责,消费端是数据团队自己建作业去消费,消息体很多情况下就是某个核心领域模型的 JSON。
在很多情况下,后端团队更新领域模型后,数据团队是不知道的。不做额外处理的情况下,Flink SQL 作业会一直正常执行并忽略消息体中的新字段,甚至在开启了 ignore-parse-errors 特性时可能导致整个消息都被丢弃。在这个场景里我们就可以用 fail-on-unknown-field 特性将作业主动失败掉,然后提示用户更新消息体的 Schema。
基于 fail-on-unknown-field 特性实施 Schema Evolution 有两个前提,第一个是消息体结构变更不会特别频繁,第二个是消息体结构变更本身是能够向前兼容的。如果不满足这两个前提,这套方案的可靠性就有很大的隐患。
这种情况下我们需要回归到 Schema On Read 的高可靠性方案,也就是基于 Hive/HDFS 的方案。Hive 本身有一套非常成熟的 Schema On Read 的工具包,Schema On Read 在写链路上不需要解析消息的结构,直接把整个消息体按行存的格式写入到 HDFS 文件上,只在读这些文件的时候才需要用到 Schema 去尝试解析。这样即使我们的 Schema 与消息体不匹配,也只是影响解析出来的数据,原始数据本身是不会丢失的。
比较可惜的是,目前的几个主流数据湖技术都是基于列式存储的,没有现成可用的 Schema On Read 方案,这也是我们后期可能要去拓展的一个方向。
这里再分享一下 TiDB 百亿级单表实时集成的案例。
TIDB 是一款非常优秀的国产分布式开源数据库,从数据集成的角度来看,它有两个非常显著的特点:第一是单表的数据量能够支持非常大的规模,可以上到百亿行/数十 TB 的规模;第二是支持快照机制,这对流批一体是非常友好的特性。
我们在 TIDB 实时集成的开发过程中,碰到的主要困难都是在全量同步步骤中写入 Iceberg 的过程发生的。这里最主要的问题是,Iceberg 的 Flink Connector 实现只提供了 Stream Writer,Stream Writer 在数据量巨大的批处理场景下的性能比较差,我们主要做了两个优化。
上图展示的是 write-distribution-mode 的优化,从上图可以看到集成作业的逻辑非常简单,作业通过 TableSourceScan 从 TIDB 读数据,再通过 IcebergStreamWriter 往 Iceberg 里写数据。TableSourceScan 在读到数据之后,怎么把数据发送给 IcebergStreamWriter 呢?这里就是 Iceberg 的 write-distribution-mode 的配置。
目前有两种模式,左上方是 None 模式,这个模式里 Writer 不占用单独的 stage,而是直接在 TableSourceScan 的 TaskManager 上写入 Iceberg 中。这个模式少了一个 shuffle 阶段,如果 TableSourceScan 的数据分布比较均匀,它的入湖速度就会非常快。但因为 Iceberg 每个 Writer 写入每个分区的时候都会产生一批写入文件,这样写入文件数量就等于 Partition 数量乘以 Writer 的数量。当表规模很大的时候就会产生大量的小文件,对 Compaction 和 HDFS NameNode 造成很大的压力。
左下方是 Hash 模式,这个模式专门为小文件数量做优化,保证每个 Partition 只能由一个 Writer 写入。但分配 Partition 到 Writer 时是用的哈希算法进行分配,因为 Partition 的数量本身就非常少,用哈希算法去分配的时候,几乎无法避免的会产生数据倾斜的问题。
这两种模式在流场景下表现都很不错,但是在 TIDB 的全量同步的过程中,它的问题就会被放大到令我们无法接受。所以我们就引入了 RoundRobin 模式,主要还是在哈希模式的基础上去解决数据倾斜的问题。
我们分析了几个最常见的分区函数,通过设定一个特殊排序,按照顺序逐个把 Partition 分配到 Writer,来确保 Partition 与 Writer 的均衡。这里用到了 PartitionCustom 的分区方法,通过自定义的 Partitioner 对分区进行匹配,目前完成适配的分区函数如下:
Bucket 分区函数,只需要将 bucket_id 按 Writer 数量取模就可以达到理论上最好的均衡效果。
Truncate 分区函数,只能支持数值型,用分区名称除以分区函数的宽度,就可以得到一个连续的整数值,再按 Writer 数量取模即可,这个方式在常见场景和合理配置下可以达到最优的均衡效果,但如果宽度设置过大,反而可能导致数据被集中在少数 Writer 中。
Identity 分区函数,只能支持数值型,将分区名称代表的数值取整,再按 Writer 数量取模,当分区名称连续变化时效果比较好,分区名称是离散值时效果较差。
RoundRobin 模式在常见场景的效果非常显著,实际测出来的性能相比前两者能有三倍的提升。
上图左边展示的是 Iceberg 实现 Row Level Delete 的核心逻辑。Iceberg 有个 Delete Storage 来缓存 Checkpoint 期间的所有新增操作,更新和删除操作会根据 Delete Storage 中是否有相应的记录,决定是写入到 eq-delete-file 还是 pos-delete-file,正常情况下 Iceberg 一次 Checkpoint 会提交三个数据文件。
在做大表的全量同步时,Delete Storage 经常缓存了太多数据触发 OOM,我们最终决定在全量同步的阶段跳过 Delete Storage 的步骤,因为全量同步阶段只有新增,没有更新和删除,实际上用不到 Delete Storage。
这个效果非常显著,我们终于成功的支持了百亿级别单表的全量同步。
但跳过 Delete Storage 会带来一个问题,增量同步过程是必须依赖 Delete Storage 的,这就导致全量同步和增量同步无法一起执行,基于 HybridSource 的方案就不适合使用这个优化措施了。
我们只能将 TIDB 实时集成作业拆分成两个单独的作业:Batch Job 和 Stream Job。
Batch Job 基于快照读取 TiDB 的全量数据,它会配置前面说的各个优化项,并行度设置也会大一点。
Batch Job 执行完成后,再执行 Stream Job,Stream Job 从消息队列中按照快照时间点接上全量同步的进度,继续消费 CDC 事件执行实时同步的步骤,这个阶段的配置是单独优化的,并行度也会设置的相对小一些。
我们将这两个作业的调动逻辑放在 Flink Application 中实施,这样在用户层面看起来就只调度了一个作业,但在实际执行的过程中,Flink Application 会按需调度不同的 Flink Job。
这里重温一下 Flink Application 这个概念,在 Flink on Yarn 模式中,Flink 作业的 jar 包提交到 Yarn 集群后,在 main 方法中跑的逻辑就是 Flink Application。Flink Application 跑在 JobManager 的节点上,但逻辑上仍然是两个独立的模块。而且我们可以在 Flink Application 中提交多个 Flink Job。
目前我们在 Flink Application 中是串行调度各个 Flink Job,这样在状态恢复的时候就会比较简单,因为每次只有一个 Flink Job 需要恢复。从本质上来说,外部调度 Flink Job 也能达到完全一致的效果,只是 Flink Application 中刚好有一个比较合适的地方可以放这些逻辑。
小米数据集成引擎的核心逻辑就是跑在 Flink Application 中的。
03
引擎设计
上图是小米数据集成引擎的总体架构图,目前称这个引擎为 MIDI(Mi Data Integration)。MIDI 的核心逻辑都跑在 Flink Application 中,Flink Application 会在适当的时间调度三种作业:Batch Job、Stream Job、Schema Job。
MIDI 的输入我们称之为 MIDI SQL,是在 Flink SQL 的基础上增加了一些自定义语法,MIDI SQL 目前只支持简单的数据集成场景。
从 MIDI SQL 中我们会解析出三张表,Source Table 是上图最左边源数据系统中的表,Sink Table 是右边的目标数据系统中的表,Middle Table 是下方的长条,代表的是包含源表 CDC 事件的 Topic。此外还有一个叫做 Application State Backend 的概念,主要用来记录 Flink Job 的执行情况。
上图是一个典型的数据库实时集成作业的时序图,包含了自动同步和流批一体的特性。
MIDI 首先会调度一个 Schema Job 去保证源表和目标表的表结构完全一致,然后生成 Batch SQL 并调度一个 Batch Job 来执行全量同步的步骤。全量同步步骤采用批模式执行,并行度设置相对高一些。
之后 MIDI 会获取全量同步的进度点,然后按进度点生成对应的 Stream SQL,并调度 Stream Job 接上之前的进度继续执行实时的增量同步的步骤。实时同步阶段采用流模式执行,并行度设置相对会低一点。
MIDI 执行完每一个 Flink Job 都会记录一个执行日志,也就是 Flink Application State Backend 的作用。它实际上就是一个文本文件,与 Checkpoint 目录放在一起。当作业中断后再恢复的时候,MIDI 会先从执行日志里找到当时正在跑的那个 Flink Job,再去执行相应的恢复动作。
再回到时序图里。如果源表 Schema 没有任何变更,Stream Job 跑起来之后会一直执行。当源表发生了 Schema 变更,就会触发作业退出,然后进入一个循环。我们会尝试调度 Schema Job 来完成表结构同步,再基于新的表结构调度 Stream Job。这样我们就能始终保持当前正在跑的 Stream Job,一定是以最新的表结构在进行同步。
在这个设计思路下,Stream Job 成为了一种特殊的有界流,Stream Job 的生命周期与它执行的 Stream SQL 的 Schema 是强绑定的,Schema 失效后,Stream Job 也就相应的结束退出了。
这是我们定义的 MIDI-SQL,主要引入三个自定义语法:Auto、Stream、Stream With。
Auto 语法是用来开启自动同步的,它必须与“Select *”共同使用。在实际执行的时候,“Auto *”会被替换为当前最新的表结构字段。
Stream 语法其实早就被 Flink 抛弃了,现在 Flink SQL 语法上并不区分批作业和流作业,主要以 Source 是否为有界流来确定执行模式。但 MIDI 因为使用了 Catalog 语法来引用库表,同样的 SQL 语句,使用 CDC Connector 时就是流模式,使用 JDBC Connector 时就是批模式,我们无法通过 SQL 语句区分两种情况,所以就把 Stream 语法又加回来了,用来判断执行模式,在 Catalog 中选择不同的 Connector。
Stream With 语法是用来实施流批一体的,MIDI 的流批一体方案还是基于消息队列的,我们用这个语法将消息队列与源表关联起来。
04
未来规划
未来我们将对上图提到的几点进行探索,这里重点提三个点:
Schema On Read 场景支持:基于 Flink 和数据湖的方案更适合的是结构化数据集成的场景,在半结构化和非结构化场景里,Schema On Read 仍然是一个最佳实践,未来我们希望继续探索如何在数据湖技术上提供 Schema On Read 的支持。
智能数据补偿:这是我们尝试增加的第四种 Flink Job,我们希望定时执行这个步骤,自动的增量的对源表和目标表的数据做比对和补偿。
引擎特性打磨:MIDI 目前仍然在比较初期的阶段,很多特性还需要打磨,目前正在整理部分特性反馈到社区共同建设。
往期精选