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.x | flink-sql-connector-kafka_2.12-1.14.x.jar | 2.4.1+ | 2.12 |
| 1.15.x | flink-sql-connector-kafka_2.12-1.15.x.jar | 2.8.1+ | 2.12 |
| 1.16.x | flink-sql-connector-kafka_2.12-1.16.x.jar | 3.2.0+ | 2.12 |
| 1.17.x | flink-sql-connector-kafka_2.12-1.17.x.jar | 3.4.0+ | 2.12 |
| 1.18.x | flink-sql-connector-kafka_2.12-1.18.x.jar | 3.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.ConsumerConfig | Kafka connector jar没放对位置或版本不匹配 | 确认jar包在$FLINK_HOME/lib/下,版本与Flink匹配 |
TimeoutException: Topic source_topic not present in metadata | Kafka里没有这个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 completing | checkpoint超时,通常是状态太大或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=3600000transaction.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-86.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纳入版本管理,团队协作时直接共享文件即可。