ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flink与Iceberg流式数据湖实战:架构设计、写入模式与性能调优

Flink与Iceberg流式数据湖实战:架构设计、写入模式与性能调优

1. 为什么要在流式链路里引入数据湖

我第一次在生产环境里把 Flink 和 Iceberg 凑到一起,是因为业务方提了一个当时看起来挺"过分"的需求:订单状态变更的明细,要能按分钟级别查到最新结果,同时还得支持对历史任意时间点的数据做回溯分析。这个需求翻译成技术语言就是——既要低延迟的流式写入,又要能对海量历史数据做高效的批量扫描和增量读取。

传统做法无非两条路。一条是纯流式,用 Flink 把结果写到消息队列或者 KV 存储里,查询快但历史数据难追溯,想做个时间旅行基本没戏。另一条是纯批式,用离线任务定时把数据落到 Hive 表,查询灵活但延迟高,业务方等一个 T+1 的结果黄花菜都凉了。这两条路我都走过,各有各的痛。

Iceberg 出现之后,事情有了转机。它本质上是给数据湖加了一层表格式(Table Format)的抽象,把数据文件、元数据、快照管理得明明白白。Flink 作为流计算引擎,天然擅长处理无界数据流。两者结合,就能实现"流式写入、批式读取、增量消费"这套组合拳。说白了,就是让流处理的结果直接沉淀成一张可以随时查询、可以回溯、可以增量读取的表。

这套方案适合谁?我觉得三类人最该关注。第一类是数据工程师,手里有实时数仓的需求,又不想维护两套存储链路。第二类是分析型开发者,需要频繁对近实时数据做 Ad-hoc 查询。第三类是平台建设者,想给团队提供一套统一的湖仓一体底座。不管你用的是哪套技术栈,只要涉及"实时写入+灵活查询"这个组合,Flink 加 Iceberg 都值得认真研究。

在同程艺龙这类在线旅游场景里,这个组合的价值尤其明显。订单、库存、价格这些数据每时每刻都在变,业务既要看实时大盘,又要做历史对比,还要支撑推荐和风控的离线特征回填。一套链路能同时满足这些诉求,省下来的不只是机器成本,更是维护心智。

2. 整体架构设计与选型考量

2.1 为什么是 Flink 加 Iceberg 这个组合

先说 Flink 这边的选型逻辑。流计算引擎市面上能打的就那么几个,选 Flink 主要看中三点:一是它的 Checkpoint 机制足够成熟,能保证 Exactly-Once 语义,这对数据湖写入至关重要,因为重复写入会产生脏数据;二是 Flink 的 Table API 和 SQL 支持越来越完善,写 Iceberg 表基本就是一句 INSERT INTO 的事;三是社区活跃,Iceberg 的 Flink Connector 维护得比较勤快,遇到问题好找答案。

再说 Iceberg。数据湖表格式这几年竞争激烈,Hudi、Delta Lake、Iceberg 三足鼎立。我最终选 Iceberg,核心原因是它的隐藏分区和快照隔离设计更干净。隐藏分区意味着你写入的时候不用关心分区字段怎么填,Iceberg 自己会根据表定义处理,这对流式写入特别友好——流数据的分区键往往和业务查询的分区键不一致,隐藏分区能省掉很多转换逻辑。快照隔离则保证了读写互不阻塞,写任务在提交新快照的时候,读任务还能基于旧快照查询,不会出现读到一半数据变了的情况。

还有一个现实考量是 Iceberg 对 Flink 的支持成熟度。它的 Flink Connector 支持流式写入、小文件自动合并、增量读取这些关键能力,基本覆盖了流批一体的核心场景。相比之下,有些表格式在流式写入这块的支持要弱一些,要么得自己写自定义 Sink,要么小文件问题处理起来很别扭。

2.2 分层架构怎么搭

整套链路的架构我习惯分成四层来看,从下往上依次是存储层、表格式层、计算层、服务层。

存储层就是对象存储或者 HDFS,负责存实际的数据文件和元数据文件。对象存储的好处是便宜、弹性,缺点是 rename 操作代价高,而 Iceberg 的提交机制恰好对 rename 依赖较重,所以选存储的时候要留意这一点。我们当时用的是兼容 S3 协议的对象存储,配合 Iceberg 的元数据管理,整体还算稳。

表格式层就是 Iceberg 本身,它管理着表结构、分区规范、快照、Manifest 文件这些元信息。这一层是整套方案的核心,所有"时间旅行""增量读取"的能力都来自这里。

计算层是 Flink,负责把上游的消息流或者 CDC 流转换成 Iceberg 表的写入操作。这里有个关键设计:写入模式选 Append 还是 Upsert。Append 模式只追加新数据,适合日志类场景;Upsert 模式支持主键更新和删除,适合订单状态变更这类需要覆盖的场景。Iceberg 从 0.12 版本开始支持 Flink 的 Upsert 写入,用的是 Flink 的 Upsert Sink 机制,底层靠等值删除(Equality Delete)实现。

服务层是查询引擎,可以是 Trino、Spark、Presto 任意一个。它们通过 Iceberg 的 Catalog 读到表的最新快照,对外提供 SQL 查询能力。这一层和 Flink 写入层是解耦的,互不干扰。

2.3 写入模式的选择逻辑

这里重点说说 Append 和 Upsert 怎么选,因为这是实际落地时最容易纠结的地方。

Append 模式简单直接,每条数据都是新增,不涉及更新和删除。它的优势是写入吞吐高、实现简单、没有额外的小文件压力。缺点是如果你需要更新某条记录,只能靠查询时做去重或者聚合,把复杂度转嫁给了读端。日志采集、埋点数据这类场景用 Append 最合适。

Upsert 模式则是在写入时就完成主键级别的更新。Flink 的 Upsert Sink 会根据主键把相同 key 的数据合并,最终写入 Iceberg 时用等值删除标记旧数据、追加新数据。这样读端拿到的就是已经去重的最新结果,查询逻辑简单。代价是写入时要维护主键索引,内存开销和写入延迟都会高一些,而且等值删除文件多了之后,读性能会受影响,需要定期做 Compaction。

我的经验是:如果业务对读端的实时性要求高、查询模式固定,优先 Upsert;如果写入吞吐是瓶颈、读端可以接受一定的聚合开销,选 Append。同程艺龙这边的订单场景,因为要保证状态最终一致,用的是 Upsert。

3. 核心细节解析与实操要点

3.1 Catalog 配置:HiveCatalog 还是 HadoopCatalog

Iceberg 的 Catalog 有两种常见选择:HiveCatalog 和 HadoopCatalog。这个选择看似小,实际影响很大。

HadoopCatalog 直接用文件系统路径来管理表,不需要额外的元数据服务,部署简单。但它的致命问题是不支持原子性重命名,在对象存储上尤其容易出问题。多个写入任务并发提交时,可能产生冲突,导致元数据损坏。我早期在一个测试环境用 HadoopCatalog,结果两个 Flink 任务同时写一张表,直接把元数据写坏了,表都读不出来。

HiveCatalog 则是把 Iceberg 的元数据注册到 Hive Metastore 里,借助 Metastore 的事务能力保证提交的原子性。这是生产环境的推荐做法。配置起来也不复杂,在 Flink SQL 里这样写:

CREATE CATALOG iceberg_catalog WITH ( 'type' = 'iceberg', 'catalog-type' = 'hive', 'uri' = 'thrift://metastore-host:9083', 'warehouse' = 'hdfs://namenode:8020/warehouse', 'property-version' = '1' );

注意:Hive Metastore 的版本要和 Iceberg 兼容,建议用 Hive 2.x 或 3.x 的稳定版本。Metastore 本身也要做好高可用,否则它挂了整个写入链路就断了。

3.2 表结构设计的关键参数

建 Iceberg 表的时候,有几个参数直接决定了后续的读写性能,我一个个说。

分区策略。Iceberg 支持隐藏分区,常见的有days(ts)、hours(ts)、bucket(id, N)这几种。分区粒度太细会导致小文件爆炸,太粗又会让查询扫描过多数据。我的经验是:流式写入的表,分区粒度控制在小时级或天级比较合适。如果数据量特别大,可以叠加 bucket 分区来打散热点。

CREATE TABLE iceberg_catalog.order_detail ( order_id BIGINT, user_id BIGINT, status STRING, amount DECIMAL(10,2), update_time TIMESTAMP(3), PRIMARY KEY (order_id) NOT ENFORCED ) PARTITIONED BY (days(update_time)) WITH ( 'write.format.default' = 'parquet', 'write.upsert.enabled' = 'true' );

文件格式。Parquet 是默认也是推荐的选择,列式存储对分析查询友好,压缩比也不错。ORC 在 Hive 生态里更常见,但 Iceberg 对 Parquet 的支持更成熟。Avro 一般只用于元数据,不建议做数据文件格式。

写入并行度。Flink 写入 Iceberg 的并行度要和分区数量匹配。如果并行度远大于分区数,会出现多个 subtask 写同一个分区的情况,小文件问题会更严重。反过来并行度太小又会影响吞吐。一般建议并行度设置为分区数的 1 到 2 倍。

3.3 Checkpoint 与提交机制的配合

这是整个链路里最容易被忽视、但出问题最致命的地方。

Flink 写 Iceberg 的提交是绑定在 Checkpoint 上的。每次 Checkpoint 触发时,Flink 会把当前待提交的数据文件信息传给 Iceberg,Iceberg 生成一个新的快照并提交。这意味着Checkpoint 间隔直接决定了数据可见的延迟。如果你设置 10 分钟一次 Checkpoint,那下游查询最多要等 10 分钟才能看到新数据。

但 Checkpoint 也不能太频繁。每次提交都要写元数据文件、更新 Manifest,这些操作有固定开销。间隔太短会导致元数据文件膨胀,反而拖慢查询。我的经验值是 1 到 3 分钟一次 Checkpoint,兼顾延迟和开销。

还有一个坑是 Checkpoint 超时。如果 Iceberg 提交耗时超过了 Checkpoint 超时时间,Checkpoint 会失败,进而触发任务重启。所以 Checkpoint 超时时间要设置得比正常提交耗时长一些,留足余量。我一般设置成提交平均耗时的 3 倍以上。

提示:可以通过 Flink 的 Metrics 监控 Iceberg 提交的耗时,如果发现耗时波动大,要排查是不是小文件太多或者对象存储抖动。

4. 实操过程与核心环节实现

4.1 环境准备与依赖引入

先把依赖理清楚。Flink 和 Iceberg 的版本匹配很关键,版本不匹配会出现各种奇怪的 ClassNotFound 或者方法签名错误。我用的组合是 Flink 1.14 配 Iceberg 0.13,这个组合在当时比较稳。

需要的 Jar 包主要有这几个:iceberg-flink-runtime(包含 Flink 和 Iceberg 的集成代码)、iceberg-hive-runtime(HiveCatalog 支持)、以及对应版本的 Hadoop 依赖。把这些 Jar 放到 Flink 的 lib 目录下,重启集群即可。

如果是用 Flink SQL Client 做开发,还需要在 SQL Client 的配置里加上 Catalog 定义。生产环境一般用 Flink 的 Application 模式提交任务,Catalog 配置写在代码里或者配置文件里。

4.2 从 Kafka 到 Iceberg 的完整链路

假设上游是 Kafka 里的订单变更消息,我们要把它实时写入 Iceberg 表。整个链路分三步:建源表、建目标表、写 INSERT 语句。

源表定义:

CREATE TABLE kafka_order_source ( order_id BIGINT, user_id BIGINT, status STRING, amount DECIMAL(10,2), update_time TIMESTAMP(3), WATERMARK FOR update_time AS update_time - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'order_change', 'properties.bootstrap.servers' = 'kafka-broker:9092', 'properties.group.id' = 'flink-iceberg-group', 'format' = 'json', 'scan.startup.mode' = 'latest-offset' );

目标表就是前面建的 Iceberg 表。写入语句很简单:

INSERT INTO iceberg_catalog.order_detail SELECT order_id, user_id, status, amount, update_time FROM kafka_order_source;

看起来简单,但实际跑起来有几个细节要注意。

水位线的作用。上面源表定义了 5 秒的水位线,这是为了处理乱序数据。如果上游消息可能乱序到达,水位线能保证在一定时间窗口内的数据被正确处理。但 Iceberg 的 Upsert 写入本身不依赖水位线做聚合,水位线主要影响的是 Checkpoint 的触发时机。

主键去重。Upsert 模式下,Iceberg 会根据主键去重。但 Flink 内部在写入前也会做一次基于状态的去重,这个状态是存在 Flink 的 State Backend 里的。如果主键基数很大,状态会膨胀,需要配置好 RocksDB 的调优参数。

小文件合并。流式写入天然会产生大量小文件,因为每个 Checkpoint 都会生成一批新文件。Iceberg 提供了rewrite_data_files动作来合并小文件,可以配置成定时任务跑,也可以让 Flink 在写入时自动合并。自动合并的配置是:

ALTER TABLE iceberg_catalog.order_detail SET ( 'write.distribution-mode' = 'hash', 'write.target-file-size-bytes' = '134217728' );

write.target-file-size-bytes设成 128MB 是个比较通用的值,太小合并频繁,太大单文件读写慢。

4.3 增量读取的实现方式

Iceberg 的增量读取是它的一大亮点。Flink 可以通过incremental模式读取两次快照之间的新增数据,这对构建下游的增量处理链路非常有用。

SELECT * FROM iceberg_catalog.order_detail /*+ OPTIONS('streaming'='true', 'monitor-interval'='1m') */

这段 SQL 会以流的方式持续读取表的新增快照,每 1 分钟检查一次有没有新数据。底层原理是 Flink 的 Iceberg Source 会记录上次读取的快照 ID,下次读取时只拉取这个快照之后的新增文件。

这个能力在实际场景里很有价值。比如下游有个实时风控任务,需要消费订单表的变更,就可以用增量读取的方式,而不是重新全表扫描。省资源,延迟也低。

注意:增量读取依赖快照的保留策略。如果快照过期被清理了,增量读取就会失败。所以要合理配置history.expire.max-snapshot-age-ms,保证快照保留时间覆盖下游消费的最大延迟。

5. 常见问题与排查技巧实录

5.1 写入性能突然下降怎么查

这是我在生产环境遇到最多的问题。表现是 Flink 任务的写入吞吐突然掉下来,Checkpoint 耗时飙升,甚至超时失败。

排查思路按这个顺序走:先看是不是小文件太多。Iceberg 表的元数据里能查到当前的文件数量,如果单分区文件数超过几千个,基本可以确定是小文件问题。解决办法是跑 Compaction 任务合并,或者调大 Checkpoint 间隔减少文件生成速度。

再看是不是对象存储限流。对象存储对请求频率有配额限制,写入并发太高会触发限流,表现为写入延迟增大。这时候要么降低写入并行度,要么联系存储团队提配额。

最后看 Flink 本身的状态。如果用了 Upsert 模式,检查 RocksDB 的状态大小和读写延迟。状态太大或者磁盘 IO 瓶颈都会拖慢写入。

5.2 数据重复或丢失的定位方法

Exactly-Once 语义说起来简单,实际出问题的时候定位起来挺费劲。我的排查清单是这样的:

先确认 Checkpoint 是否成功。如果 Checkpoint 一直失败,任务会重启,重启后从上次成功的 Checkpoint 恢复,这期间的数据可能重复或丢失。看 Flink 的 Checkpoint 历史,确认没有长时间失败。

再确认 Iceberg 的提交是否原子。如果用了 HadoopCatalog 或者存储不支持原子 rename,可能出现元数据写了一半的情况。换成 HiveCatalog 能规避大部分这类问题。

最后确认上游 Kafka 的 offset 提交是否和 Checkpoint 对齐。Flink 的 Kafka Source 会把 offset 保存在 Checkpoint 里,如果 offset 提交和 Checkpoint 不同步,恢复时就会重复消费或漏消费。

5.3 常见问题速查表

问题现象可能原因排查方向解决手段
Checkpoint 超时失败Iceberg 提交耗时过长查看提交 Metrics,检查小文件数量增大 Checkpoint 超时,跑 Compaction
写入吞吐骤降对象存储限流或小文件过多查看存储请求指标和文件数降并行度,合并小文件
查询结果重复Upsert 未生效或快照隔离问题检查主键定义和写入模式确认 Upsert 配置,检查快照
增量读取报错快照已过期被清理查看快照保留配置调大快照保留时间
元数据损坏Catalog 不支持原子提交检查 Catalog 类型换用 HiveCatalog
任务频繁重启状态过大或内存不足查看 TaskManager 内存和 GC调优 RocksDB,扩内存

5.4 几个踩过的坑

第一个坑是时间戳精度。Iceberg 的 timestamp 类型默认是微秒精度,而 Flink 的 TIMESTAMP 是毫秒精度。写入的时候如果不做转换,会出现精度丢失或者类型不匹配的报错。解决办法是在建表时明确指定精度,或者在 SQL 里做 CAST。

第二个坑是分区字段的时区。days(update_time)这种分区函数依赖时区设置。如果 Flink 任务和 Iceberg 表的时区不一致,分区会错乱。建议统一用 UTC 时间,在写入前做好时区转换。

第三个坑是并发写入冲突。多个 Flink 任务同时写同一张 Iceberg 表时,如果 Catalog 不支持并发提交,会互相覆盖。HiveCatalog 通过 Metastore 的锁机制能处理这个问题,但也要注意锁竞争带来的性能损耗。如果并发写入量大,建议按业务拆分多张表,而不是挤在一张表上。

6. 性能调优与运维经验

6.1 写入端的调优参数

写入端的调优核心是平衡吞吐、延迟和文件数量。几个关键参数我列一下。

write.target-file-size-bytes控制单个数据文件的目标大小,默认 512MB。流式场景下我一般调到 128MB 到 256MB,因为流式写入的文件往往达不到目标大小就提交了,设太大没意义。

write.distribution-mode控制数据在写入前的分发方式。hash模式会按分区键做 hash 分发,保证同一分区的数据落到同一个 subtask,减少小文件。none模式则不做分发,适合并行度远小于分区数的场景。

write.upsert.enabled开启 Upsert 写入。开启后 Iceberg 会维护主键索引,写入时做去重。这个参数对内存有要求,主键基数大的时候要相应调大 TaskManager 内存。

write.metadata.delete-after-commit.enabled控制提交后是否删除旧元数据文件。开启能减少元数据膨胀,但会失去部分历史快照的追溯能力。根据业务对时间旅行的需求来定。

6.2 读取端的优化思路

读取端主要是查询引擎的事,但 Iceberg 层面也有优化空间。

分区裁剪是提升查询性能最有效的手段。查询时尽量带上分区字段的过滤条件,让 Iceberg 能跳过不相关的分区。比如查某天的数据,WHERE 条件里带上update_time >= '2024-01-01' AND update_time < '2024-01-02',Iceberg 就只会扫描这一天的文件。

列裁剪同样重要。Iceberg 是列式存储,查询时只读需要的列能大幅减少 IO。所以写 SQL 的时候别偷懒用SELECT *,明确列出需要的字段。

Manifest 合并。Iceberg 的 Manifest 文件记录了数据文件的元信息,Manifest 太多会影响查询规划的速度。定期跑rewrite_manifests能合并 Manifest,提升查询效率。

6.3 日常运维要做的事

这套链路跑起来之后,日常运维主要盯三件事。

一是小文件监控。写个定时任务,每天统计各分区的文件数量,超过阈值就触发 Compaction。Compaction 任务本身也是 Flink 或者 Spark 任务,要注意和写入任务错峰跑,避免资源竞争。

二是快照管理。快照不能无限保留,否则元数据会膨胀到不可控。配置合理的过期策略,比如保留最近 7 天的快照,更早的自动清理。但清理前要确认没有下游任务依赖旧快照。

三是Checkpoint 健康度监控。Checkpoint 的成功率和耗时是链路健康的核心指标。设置告警,一旦成功率下降或者耗时异常增长,及时介入排查。

提示:Compaction 任务建议用独立的资源队列跑,不要和实时写入任务抢资源。Compaction 是 IO 密集型任务,对磁盘和网络压力大。

7. 这套方案还能怎么扩展

跑通基础链路之后,我陆续做了一些扩展,效果都不错。

一个是接入 CDC 数据。用 Flink CDC 抓取业务库的变更,直接写入 Iceberg,省掉了 Kafka 这一层。这样链路更短,延迟更低。但要注意 CDC 的变更类型(INSERT/UPDATE/DELETE)要和 Iceberg 的写入模式匹配,DELETE 操作需要 Iceberg 支持行级删除。

另一个是多引擎共享。同一张 Iceberg 表,Flink 负责写,Trino 负责交互式查询,Spark 负责大批量分析。三个引擎通过同一个 HiveCatalog 访问同一份数据,各取所长。这种架构下,Catalog 的高可用和元数据的兼容性就特别重要。

还有一个是冷热分层。热数据放在高性能存储上,保证查询延迟;冷数据定期迁移到低成本存储,降低整体成本。Iceberg 的分区演进能力让这种迁移变得相对平滑,可以按分区做数据搬迁。

我个人在实际操作中的体会是,Flink 加 Iceberg 这套组合的上手门槛不高,但真正跑稳需要把 Catalog、Checkpoint、小文件这三块吃透。很多团队卡住不是因为技术难,而是细节没抠到位。把这几块理顺了,剩下的就是按业务需求做调优和扩展的事了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进