ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Flink SQL Kafka到Kafka同步实战:从入门到生产环境避坑指南

Flink SQL Kafka到Kafka同步实战:从入门到生产环境避坑指南

1. 从一个最小可跑的Kafka到Kafka链路说起

Flink SQL做Kafka到Kafka的同步,看起来是最简单的入门demo,但真正动手跑通、跑稳、跑对,中间要踩的坑比想象中多得多。我见过不少团队在这个"Hello World"级别的场景上翻车——要么数据重复写入,要么字段类型对不上,要么checkpoint配了但重启后数据丢了。这篇内容就把这条链路从头到尾拆开讲清楚,包括环境搭建、SQL编写、参数调优、常见故障排查,以及生产环境必须注意的几个关键点。

先说清楚这个demo到底在做什么:从上游Kafka的一个topic消费数据,经过Flink SQL做简单的转换(过滤、字段映射、聚合等),再写入下游Kafka的另一个topic。整条链路涉及三个核心组件——Kafka集群、Flink集群(或本地环境)、Flink SQL Client或SQL Gateway。适合谁看?刚接触Flink SQL的开发者、需要快速验证Kafka数据流转的测试人员、以及想把离线ETL迁移到实时链路的工程师。

为什么选Kafka到Kafka这个场景作为入门?因为它把Flink最核心的几个概念都串起来了:source/sink连接器、checkpoint机制、Exactly-Once语义、watermark与时间语义、状态管理。你把这一个场景吃透,后面换MySQL、Hive、ClickHouse作为sink,只是换个连接器的事,底层逻辑完全一致。

我个人的建议是:不要一上来就搭集群,先用本地环境把SQL跑通,确认数据能正确流转,再逐步往集群上迁移。这样排查问题的成本最低,也最容易定位是SQL写错了还是环境配错了。

2. 环境准备:版本对齐比什么都重要

2.1 Flink与Kafka连接器的版本匹配

这是第一个大坑。Flink的Kafka连接器版本必须和Flink主版本严格对应,差一个小版本都可能报ClassNotFoundException或NoSuchMethodError。我整理了一张对照表,直接照着选:

Flink版本Kafka连接器Jar包Kafka客户端版本Scala版本
1.14.xflink-sql-connector-kafka_2.12-1.14.x.jar2.4.1+2.12
1.15.xflink-sql-connector-kafka_2.12-1.15.x.jar2.8.1+2.12
1.16.xflink-sql-connector-kafka_2.12-1.16.x.jar3.2.0+2.12
1.17.xflink-sql-connector-kafka_2.12-1.17.x.jar3.4.0+2.12
1.18.xflink-sql-connector-kafka_2.12-1.18.x.jar3.6.0+2.12

注意:从Flink 1.15开始,官方推荐使用flink-sql-connector-kafka这个shaded包,它把Kafka客户端依赖都打进去了,避免和Flink自身的依赖冲突。如果你用的是flink-connector-kafka(非sql版本),需要自己额外引入Kafka客户端jar,很容易出现版本冲突。

下载地址在Flink官方仓库的connector目录下,选对应版本即可。下载后放到$FLINK_HOME/lib/目录下,重启Flink集群。

2.2 本地环境的最小化搭建

如果你只是想快速验证,不需要搭完整集群。最低配置是这样:

  • 一台机器(4核8G足够),装好JDK 8或11(Flink 1.18推荐JDK 11)
  • 下载Flink二进制包,解压到任意目录
  • 下载对应版本的Kafka connector jar,丢进lib目录
  • 本地起一个单节点Kafka(用KRaft模式,不需要ZooKeeper,省事)

Kafka单节点启动命令(KRaft模式,以Kafka 3.6为例):

# 生成集群ID KAFKA_CLUSTER_ID=$(bin/kafka-storage.sh random-uuid) # 格式化存储目录 bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c config/kraft/server.properties # 启动Kafka bin/kafka-server-start.sh config/kraft/server.properties

创建两个topic,一个作为source,一个作为sink:

bin/kafka-topics.sh --create --topic source_topic --bootstrap-server localhost:9092 --partitions 3 --replication-factor 1 bin/kafka-topics.sh --create --topic sink_topic --bootstrap-server localhost:9092 --partitions 3 --replication-factor 1

启动Flink本地集群:

$FLINK_HOME/bin/start-cluster.sh

然后启动SQL Client:

$FLINK_HOME/bin/sql-client.sh embedded

到这里环境就绪。整个过程不超过15分钟,比搭一套完整的Hadoop生态简单太多。

2.3 一个容易被忽略的细节:时区与序列化

Flink SQL Client默认使用UTC时区,而Kafka里的数据时间戳可能是本地时区。如果你在SQL里用了PROCTIME()或EVENT_TIME,时间字段对不上会导致watermark不推进,窗口永远不触发。解决办法是在flink-conf.yaml里显式设置:

env.java.opts: -Duser.timezone=Asia/Shanghai

或者在SQL Client启动时加参数:

$FLINK_HOME/bin/sql-client.sh embedded -Duser.timezone=Asia/Shanghai

另一个坑是Kafka消息的序列化格式。Flink SQL默认按JSON格式解析,如果你的上游数据是Avro、Protobuf或纯文本,需要在建表时指定format。这个后面建表部分会详细讲。

3. 建表与SQL编写:把每个字段的来龙去脉搞清楚

3.1 Source表:从Kafka读取数据

假设上游Kafka的source_topic里写的是JSON格式的用户行为数据,长这样:

{"user_id": "1001", "event_type": "click", "page_id": "home", "ts": "2024-01-15 10:30:00"}

对应的Flink SQL建表语句:

CREATE TABLE source_kafka ( user_id STRING, event_type STRING, page_id STRING, ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'source_topic', 'properties.bootstrap.servers' = 'localhost:9092', 'properties.group.id' = 'flink-demo-group', 'scan.startup.mode' = 'latest-offset', 'format' = 'json', 'json.fail-on-missing-field' = 'false', 'json.ignore-parse-errors' = 'true' );

逐行解释关键参数:

  • connector:固定写kafka,Flink会根据这个值去找对应的connector工厂类。
  • scan.startup.mode:消费起始位置。可选值有earliest-offset(从最早开始)、latest-offset(从最新开始)、group-offsets(从消费者组提交的offset开始)、timestamp(从指定时间戳开始)。做demo验证时建议用earliest-offset,这样你能看到历史数据;生产环境通常用group-offsets,保证重启后从上次位置继续。
  • json.fail-on-missing-field:设为false时,JSON里缺少某个字段不会报错,该字段填NULL。生产环境建议设为false,避免因为上游偶尔少发一个字段导致整个作业挂掉。
  • json.ignore-parse-errors:设为true时,解析失败的脏数据会被跳过而不是让作业失败。这个参数是双刃剑——好处是作业稳定性高,坏处是脏数据被静默丢弃,你可能不知道。建议配合监控使用。

提示:WATERMARK FOR ts AS ts - INTERVAL '5' SECOND这行定义了事件时间watermark,允许5秒的乱序。如果你的数据没有乱序,可以不加watermark;如果需要做窗口聚合,watermark是必须的。

3.2 Sink表:写入下游Kafka

CREATE TABLE sink_kafka ( user_id STRING, event_type STRING, page_id STRING, ts TIMESTAMP(3), process_time TIMESTAMP(3) ) WITH ( 'connector' = 'kafka', 'topic' = 'sink_topic', 'properties.bootstrap.servers' = 'localhost:9092', 'format' = 'json', 'sink.partitioner' = 'fixed', 'sink.delivery-guarantee' = 'exactly-once' );

这里有两个参数值得展开说:

sink.partitioner控制数据写到下游Kafka的哪个分区。可选值:

  • fixed:每个Flink并行度写到一个固定分区(默认行为,可能导致数据倾斜)
  • round-robin:轮询写所有分区(均匀分布,但同一key的数据可能乱序)
  • hash:按key的hash值分区(同一key保证写同一分区,推荐)

如果你下游有按user_id聚合的需求,一定要用hash,否则同一用户的数据散落在不同分区,下游消费时无法保证顺序。

sink.delivery-guarantee控制投递语义:

  • at-least-once:至少一次,可能重复
  • exactly-once:精确一次,需要Kafka支持事务
  • none:不做保证

做demo时用at-least-once就够了,生产环境如果下游对重复敏感,必须用exactly-once。但注意,exactly-once要求Kafka版本在0.11以上,且需要配置transaction.timeout.ms,默认是1小时,如果checkpoint间隔超过这个值,事务会超时导致数据丢失。

3.3 核心转换SQL:从source到sink

最简单的直通:

INSERT INTO sink_kafka SELECT user_id, event_type, page_id, ts, CURRENT_TIMESTAMP FROM source_kafka;

加个过滤条件,只保留click事件:

INSERT INTO sink_kafka SELECT user_id, event_type, page_id, ts, CURRENT_TIMESTAMP FROM source_kafka WHERE event_type = 'click';

再加个窗口聚合,统计每个用户每分钟的点击次数:

INSERT INTO sink_kafka SELECT user_id, 'click_count' AS event_type, CAST(COUNT(*) AS STRING) AS page_id, TUMBLE_END(ts, INTERVAL '1' MINUTE) AS ts, CURRENT_TIMESTAMP FROM source_kafka WHERE event_type = 'click' GROUP BY user_id, TUMBLE(ts, INTERVAL '1' MINUTE);

这里用了滚动窗口TUMBLE,窗口大小1分钟。TUMBLE_END返回窗口结束时间。注意page_id字段我用来存聚合结果了,实际项目中应该建一个专门的字段,这里只是为了demo方便。

注意:窗口聚合会产生retract流(因为窗口结果可能更新),而Kafka sink只支持append流。所以上面的SQL在Flink 1.14+里需要额外配置table.exec.sink.upsert-materialize或者改用GROUP BY+HAVING的方式避免retract。更稳妥的做法是用EMIT ON WINDOW CLOSE(Flink 1.16+支持),让窗口只在关闭时输出一次最终结果。

4. 跑起来之后:验证数据流转与常见报错

4.1 数据验证的完整链路

SQL写完后,在SQL Client里执行INSERT INTO语句,作业会提交到Flink集群。验证数据是否正常流转,分三步:

第一步,往source topic写测试数据:

echo '{"user_id":"1001","event_type":"click","page_id":"home","ts":"2024-01-15 10:30:00"}' | bin/kafka-console-producer.sh --topic source_topic --bootstrap-server localhost:9092

第二步,消费sink topic看结果:

bin/kafka-console-consumer.sh --topic sink_topic --bootstrap-server localhost:9092 --from-beginning

第三步,去Flink Web UI(默认8081端口)看作业状态、吞吐量、checkpoint情况。

如果sink topic里能看到数据,说明链路通了。如果看不到,按下面的排查表逐项检查。

4.2 常见报错与排查表

报错信息根本原因解决方案
ClassNotFoundException: org.apache.kafka.clients.consumer.ConsumerConfigKafka connector jar没放对位置或版本不匹配确认jar包在$FLINK_HOME/lib/下,版本与Flink匹配
TimeoutException: Topic source_topic not present in metadataKafka里没有这个topic,或bootstrap地址写错用kafka-topics.sh --list确认topic存在,检查地址端口
JsonParseException: Unexpected character上游数据不是合法JSON检查上游数据格式,或设置json.ignore-parse-errors=true
Watermark has not advanced数据里没有事件时间字段,或watermark定义错误确认ts字段存在且格式正确,检查watermark表达式
Checkpoint expired before completingcheckpoint超时,通常是状态太大或Kafka写入慢增大execution.checkpointing.timeout,或优化Kafka写入性能
The Kafka producer attempted to send a record that is too large单条消息超过Kafka的max.request.size调大Kafka broker的message.max.bytes和producer的max.request.size

4.3 一个真实的排查案例

我之前遇到过一个情况:作业跑起来后,source端能消费到数据,但sink端一条都没有。Flink Web UI显示作业正常运行,没有报错,checkpoint也正常。

排查过程是这样的:先看source的metrics,numRecordsIn有值,说明数据进来了。再看sink的metrics,numRecordsOut是0,说明数据没出去。中间只有一条WHERE event_type = 'click'的过滤条件。去Kafka里查原始数据,发现上游发的event_type字段值是"Click"(首字母大写),而SQL里写的是'click'(全小写)。字符串比较大小写敏感,所有数据都被过滤掉了。

这个坑的教训是:做过滤条件时,一定要先确认上游数据的实际值。可以用SELECT * FROM source_kafka LIMIT 10先看一眼原始数据长什么样,再写转换逻辑。另外,如果业务上确实需要忽略大小写,可以用LOWER(event_type) = 'click'。

4.4 Checkpoint配置:demo可以省,生产不能省

做demo时,很多人不配checkpoint,作业重启后数据就丢了。生产环境必须配,而且参数要调对。在flink-conf.yaml里:

execution.checkpointing.interval: 60s execution.checkpointing.mode: EXACTLY_ONCE execution.checkpointing.timeout: 10min execution.checkpointing.min-pause: 30s execution.checkpointing.max-concurrent-checkpoints: 1 state.backend: rocksdb state.backend.incremental: true state.checkpoints.dir: hdfs:///flink/checkpoints

几个关键点:

  • interval设60秒是折中值。太短会增加系统开销,太长则故障恢复时丢失的数据多。
  • min-pause保证两次checkpoint之间至少有30秒间隔,避免checkpoint连续触发拖垮系统。
  • max-concurrent-checkpoints设为1,避免多个checkpoint同时进行导致资源竞争。
  • state.backend用rocksdb而不是默认的hashmap,因为rocksdb支持增量checkpoint,状态大时优势明显。
  • state.checkpoints.dir必须是一个持久化存储(HDFS、S3等),不能是本地路径,否则集群重启后checkpoint就没了。

5. 从demo到生产:必须补齐的几个关键配置

5.1 并行度与分区数的关系

Flink的并行度和Kafka的分区数直接相关。如果source topic有3个分区,Flink source的并行度最多设为3,多出来的并行度会空闲。如果sink topic有3个分区,sink并行度也建议设为3,保证每个并行度写一个分区。

设置并行度的方式:

-- 在SQL Client里全局设置 SET 'parallelism.default' = '3'; -- 或者针对单个作业设置 INSERT INTO sink_kafka /*+ PARALLELISM(3) */ SELECT ... FROM source_kafka;

如果source和sink的分区数不一致,比如source有6个分区、sink只有3个,那sink端会成为瓶颈。解决办法是调整sink topic的分区数,或者在sink端做rebalance。

5.2 反压与消费延迟的监控

作业跑起来后,最需要关注的指标是消费延迟(consumer lag)。如果lag持续增长,说明消费速度跟不上生产速度。原因可能是:

  • Flink作业并行度不够,增加并行度
  • 单条数据处理逻辑太重(比如复杂的JSON解析),优化SQL
  • Kafka集群本身负载高,检查broker的IO和网络
  • checkpoint时间太长,阻塞了数据处理,调整checkpoint参数

Flink Web UI的Backpressure页面可以看到每个算子的反压情况。如果某个算子显示HIGH,说明它是瓶颈,需要针对性优化。

5.3 Exactly-Once的完整配置

如果业务要求精确一次,除了sink表里设sink.delivery-guarantee = 'exactly-once',还需要:

# flink-conf.yaml execution.checkpointing.mode: EXACTLY_ONCE execution.checkpointing.interval: 60s

以及Kafka broker端的配置:

transaction.state.log.replication.factor=3 transaction.state.log.min.isr=2 transaction.max.timeout.ms=3600000

transaction.max.timeout.ms必须大于Flink的checkpoint间隔加上处理时间,否则事务会超时。默认是15分钟,如果checkpoint间隔是60秒,一般够用,但如果作业处理慢,需要调大。

注意:Exactly-Once会带来额外的性能开销,因为每个checkpoint周期都要做事务提交。如果业务对重复不敏感,用at-least-once就够了,性能更好。

5.4 数据倾斜的处理

如果某个key的数据量特别大(比如某个热门页面的点击量是其他页面的100倍),会导致sink端某个分区写入压力过大。解决办法:

  • 在SQL里对key做加盐处理,比如CONCAT(user_id, '_', CAST(RAND() * 10 AS INT)),把数据打散到多个分区
  • 或者用round-robin分区策略,牺牲顺序性换取均匀分布
  • 如果下游不要求顺序,round-robin是最简单的方案

5.5 作业重启策略

生产环境必须配重启策略,否则作业一挂就彻底停了。在flink-conf.yaml里:

restart-strategy: fixed-delay restart-strategy.fixed-delay.attempts: 10 restart-strategy.fixed-delay.delay: 30s

意思是作业失败后,每隔30秒重试一次,最多重试10次。如果10次都失败,作业进入FAILED状态,需要人工介入。对于Kafka到Kafka这种链路,通常用fixed-delay就够了。如果作业本身很稳定,可以用exponential-delay,重试间隔指数增长,避免频繁重启。

5.6 一个容易忽略的点:Kafka消息的key

Flink SQL写入Kafka时,默认不设置消息key。如果下游消费者需要按key做分区或去重,需要在sink表里指定key字段。Flink 1.16+支持在sink表里加key字段:

CREATE TABLE sink_kafka ( `key` STRING, user_id STRING, ... ) WITH ( ... );

然后在INSERT时把user_id赋给key:

INSERT INTO sink_kafka SELECT user_id AS `key`, user_id, event_type, ... FROM source_kafka;

这样写入Kafka的消息就带key了,下游可以按key做分区消费。

6. 几个我踩过的坑和对应的解法

6.1 SQL Client里中文乱码

在SQL Client里执行含中文的SQL,或者查询含中文的数据,可能出现乱码。原因是JVM的默认字符集不是UTF-8。解决办法是在flink-conf.yaml里加:

env.java.opts: -Dfile.encoding=UTF-8

或者在启动SQL Client时指定:

$FLINK_HOME/bin/sql-client.sh embedded -Dfile.encoding=UTF-8

6.2 时间戳字段的精度问题

Kafka里的时间戳通常是毫秒级(13位),而Flink SQL的TIMESTAMP(3)是毫秒精度,TIMESTAMP_LTZ(3)是带时区的毫秒精度。如果上游数据的时间戳是秒级(10位),直接映射会得到1970年的日期。需要在SQL里做转换:

-- 秒级转毫秒级 TO_TIMESTAMP_LTZ(ts_seconds * 1000, 3) AS ts

或者在建表时用计算列:

ts AS TO_TIMESTAMP_LTZ(ts_seconds * 1000, 3)

6.3 作业提交后一直INITIALIZING

这种情况通常是资源不够。Flink作业需要申请TaskManager的slot,如果slot不够,作业会一直等待。检查flink-conf.yaml里的taskmanager.numberOfTaskSlots,默认是1,如果并行度是3,至少需要3个slot。可以调大这个值,或者增加TaskManager的数量。

另一个可能是Kafka连接超时。如果bootstrap地址写错了,作业会卡在连接阶段。检查Kafka是否正常运行,以及网络是否通。

6.4 checkpoint成功但数据重复

如果用了at-least-once,数据重复是正常的。但如果用了exactly-once还重复,检查两个地方:一是Kafka事务是否真的开启了,看sink表的properties.transaction.timeout.ms是否设置;二是checkpoint是否真的完成了,看Flink Web UI的Checkpoints页面,如果显示IN_PROGRESS或FAILED,说明checkpoint没成功,作业重启后会从上一个成功的checkpoint恢复,导致数据重复。

6.5 一个关于资源释放的提醒

做demo时,很多人跑完就不管了,Flink作业一直在后台跑,占用资源。记得用STOP命令停掉作业:

STOP JOB 'job-id';

或者在Flink Web UI上点Cancel。如果作业卡死无法取消,可以用flink cancel命令强制取消。另外,Kafka的消费者组如果不再使用,记得清理,否则offset会一直保留,影响后续消费。

7. 这条链路还能怎么扩展

Kafka到Kafka的直通只是起点。实际项目中,你可能会遇到这些变体:

  • 多路输出:一个source topic的数据,根据event_type分流到不同的sink topic。用Flink SQL的STATEMENT SET或者多个INSERT语句实现。
  • 维表关联:source数据需要关联MySQL里的用户信息,用Flink SQL的LOOKUP JOIN实现。
  • 聚合后写入:按窗口聚合后写入Kafka,下游再做实时大屏展示。
  • 数据清洗:过滤脏数据、字段脱敏、格式转换,这些都可以在SQL里完成。

我个人的经验是,Flink SQL能覆盖80%的实时ETL场景,剩下20%需要自定义UDF或改用DataStream API。但对于Kafka到Kafka这种链路,SQL完全够用,而且开发效率比写Java代码高得多。

最后分享一个实用技巧:在SQL Client里调试时,可以先用SELECT * FROM source_kafka LIMIT 10预览数据,确认字段和格式没问题,再写INSERT语句。这样能避免大部分因为数据格式不匹配导致的错误。另外,把常用的建表语句保存成.sql文件,用-f参数批量执行,比在Client里一行行敲效率高得多:

$FLINK_HOME/bin/sql-client.sh embedded -f /path/to/job.sql

这个方式也方便把SQL纳入版本管理,团队协作时直接共享文件即可。

RELATED READING

延伸阅读

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