Flink CDC 入门实战:从原理到踩坑全记录 (datastream/SQL 双版本)

在构建实时数仓和数据湖的过程中,CDC (Change Data Capture) 是数据摄入最核心的环节。传统的 CDC 链路往往比较复杂,而 Flink CDC 凭借其"去 Kafka 化"的极简架构、全增量一体化读取以及无锁算法,成为了目前最主流的数据同步方案。

本文基于 Flink 1.17Flink CDC 2.4 ,带你从零搭建一个实时同步应用,并重点复盘在实战中 90% 的开发者都会遇到的"坑"。

一、 为什么选择 Flink CDC?

1.1 传统方案 vs Flink CDC

传统链路 (Canal/Debezium):

MySQL -> Canal/Debezium -> Kafka -> Flink -> 目标端

痛点: 组件多、链路长、维护成本高、数据在 Kafka 中冗余存储。

1.2 Flink CDC 方案:

MySQL -> Flink -> 目标端

优势:

  • 架构极简: 只需要 Flink 一个组件。
  • 全增量一体化: 自动先读历史数据(快照),读完后无缝切换到 Binlog(增量),保证数据不丢不重 (Exactly-Once)。
  • 无锁读取: 使用增量快照算法,读取全量数据时不需要锁表。

1.3 核心应用场景

  • 实时数据同步: 比如 MySQL -> Elasticsearch / ClickHouse。
  • 实时 ETL: 数据库变更 -> Flink 清洗/聚合 -> 下游系统。
  • 缓存更新(Cache Invalidation): 监听数据库变更,实时失效 Redis 缓存。

二、 环境准备:MySQL (Docker)

Flink CDC 依赖 MySQL 的 Binlog 获取增量数据。请确保你的 MySQL 配置文件 (my.cnf 或 my.ini) 包含以下配置,并重启 MySQL:

Ini,TOML 复制代码
[mysqld]
server-id = 123          # 必须是唯一的整数
log_bin = mysql-bin      # 开启 binlog
binlog_format = ROW      # 必须是 ROW 模式
binlog_row_image = FULL  # 必须是 FULL

如果你使用 Docker,请使用以下命令快速启动一个满足要求的 MySQL 实例:

bash 复制代码
docker run -d \
  --name mysql-flink-cdc \
  -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  mysql:8.0 \
  --server-id=1 \
  --log-bin=mysql-bin \
  --binlog-format=ROW \
  --gtid-mode=OFF

核心配置解读:

binlog-format=ROW : 必须开启 ROW 模式,这样才能记录每一行数据的具体变更。

server-id : 在 MySQL 集群或主从复制中,每个节点的 ID 必须全局唯一。Flink CDC 也会伪装成一个 Slave,因此 MySQL 自身的 ID 不能为 0。

验证配置是否成功

sql 复制代码
-- 查看 binlog 是否开启 (ON 表示开启)
SHOW VARIABLES LIKE 'log_bin'; 

-- 查看格式 (必须是 ROW)
SHOW VARIABLES LIKE 'binlog_format'; 

-- 查看镜像模式 (必须是 FULL)
SHOW VARIABLES LIKE 'binlog_row_image';

-- 查看节点的 ID (必须>0)
SHOW VARIABLES LIKE 'server_id';

ps:从 MySQL 8.0 开始,官方默认开启了 Binlog,并将格式默认设为了 ROW,刚好完美契合 Flink CDC 的需求

三、 实战阶段 1:DataStream API ("Hello World")

这是最底层的 API,适合做数据清洗、复杂自定义逻辑或监控报警。

3.1 Maven 依赖 (🩸坑点 ①)

除了核心依赖外,必须显式引入 flink-connector-base,否则本地运行会报错。

yml 复制代码
<dependencies>
    <!-- Flink Streaming 核心 -->
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-streaming-java</artifactId>
        <version>1.17.1</version>
    </dependency>

	<dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-clients</artifactId>
        <version>1.17.1</version>
    </dependency>
    
    <!-- Flink MySQL CDC 连接器 -->
    <dependency>
        <groupId>com.ververica</groupId>
        <artifactId>flink-connector-mysql-cdc</artifactId>
        <version>2.4.1</version>
    </dependency>

    <!-- ⚠️ 坑点1:本地 IDEA 运行必须补全这个基础包,否则报 NoClassDefFoundError -->
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-base</artifactId>
        <version>1.17.1</version>
    </dependency>
    
    <!-- 开启 Web UI (可选,用于本地查看 Flink 仪表盘) -->
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-runtime-web</artifactId>
        <version>1.17.1</version>
    </dependency>
</dependencies>

3.2 Java 代码实现

java 复制代码
package com.jerry.flinkcdcdemo;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jerry.flinkcdcdemo.domain.Product;
import com.ververica.cdc.connectors.mysql.source.MySqlSource;
import com.ververica.cdc.debezium.JsonDebeziumDeserializationSchema;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.RestOptions;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

import java.util.HashMap;
import java.util.Map;

/***
 * flink - cdc - mysql
 * 使用 flink cdc DataStream
 *
 * 如果只是做清洗、过滤、简单报警: 使用 DataStream API 是最灵活、最高效的。
 * 如果要做聚合统计 (Sum, GroupBy, Window): 强烈建议使用 Flink SQL / Table API。参照
 */
public class FlinkCdcDataStreamDemo {

    public static void main(String[] args) throws Exception {
        // 1. 环境准备 (带 Web UI)
        Configuration conf = new Configuration();
        conf.setInteger(RestOptions.PORT, 8801);
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(conf);
        // 为了方便本地观察,设置并行度为 1
        env.setParallelism(1);

        // 开启 Checkpoint (生产环境必须开启,用于故障恢复和保证 Exactly-Once)
        // 每 3000ms 做一次 Checkpoint
        env.enableCheckpointing(300000);

        // 2. 配置 Source (为了数字显示正常,加上那个配置)
        Map<String, Object> customConverterConfigs = new HashMap<>(); // ⚠️ 坑点3:配置 DECIMAL 格式化,防止金额变成 Base64 乱码
        // key: "decimal.format" -> value: "NUMERIC" (转成普通数字) 或者 "STRING" (转成字符串)
        customConverterConfigs.put("decimal.format", "NUMERIC");

        // 3. 构建 MySQL CDC Source
        MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
                .serverTimeZone("UTC") // ⚠️ 坑点4:解决时区不一致导致的连接报错
                .hostname("8.xx.xxx.xx")        // 数据库地址 // ⚠️ 坑点2:务必使用真实 IP,不要无脑写 localhost
                .port(3306)                   // 端口
                .databaseList("flink_demo")   // 监控的数据库
                .tableList("flink_demo.products") // 监控的表 (库名.表名)
                .username("root")             // 用户名
                .password("passward")    // 替换为你的密码!!!
                /**
                 * initial(): 第一次启动时,读取全量数据,然后切换到 Binlog。
                 * latest(): 只读取启动之后的新变更。
                 */
                .startupOptions(com.ververica.cdc.connectors.mysql.table.StartupOptions.initial())
                .deserializer(new JsonDebeziumDeserializationSchema(false, customConverterConfigs)) // 将变更数据转为 JSON 字符串
                .build();

        // 4. 将 Source 添加到环境,生成 DataStream
        DataStream<String> rawStream = env.fromSource(mySqlSource, WatermarkStrategy.noWatermarks(), "MySQL CDC Source");


        // 4. Map: 解析 JSON 字符串 -> Product 对象
        DataStream<Product> productStream = rawStream.map(new MapFunction<String, Product>() {
            private final ObjectMapper objectMapper = new ObjectMapper();

            @Override
            public Product map(String value) throws Exception {
                JsonNode root = objectMapper.readTree(value);
                // 获取操作类型: r=read, c=create, u=update, d=delete
                String op = root.get("op").asText();

                // 只有 insert(c), update(u), read(r) 会有 "after" 数据
                // delete(d) 只有 "before" 数据
                JsonNode dataNode = op.equals("d") ? root.get("before") : root.get("after");

                if (dataNode == null || dataNode.isNull()) {
                    return null; // 异常情况保护
                }

                return new Product(
                        dataNode.get("id").asInt(),
                        dataNode.get("name").asText(),
                        dataNode.get("price").asDouble(),
                        op
                );
            }
        }).filter(p -> p != null); // 过滤掉空数据

        productStream.print(); // 3. 打印原始 JSON 字符串

        // 5. KeyBy & Process: 按商品名分组,监控价格剧烈波动
        productStream
                .keyBy(p -> p.name) // 按商品名称分组
                .process(new KeyedProcessFunction<String, Product, String>() {

                    // 这里可以定义状态 (State),比如保存上一次的价格
                    // ValueState<Double> lastPriceState;

                    @Override
                    public void processElement(Product current, Context ctx, Collector<String> out) throws Exception {

                        String op = current.op;

                        // 简单的业务逻辑演示
                        if ("r".equals(op) || "c".equals(op)) {
                            out.collect("【新上架/初始化】商品: " + current.name + ", 价格: " + current.price);
                        }
                        else if ("u".equals(op)) {
                            // 这里其实可以通过 Debezium 原始 JSON 的 "before" 字段拿到旧价格
                            // 但为了演示 API,我们假设这里做简单的阈值判断
                            if (current.price > 10000) {
                                out.collect("【 ⚠ 价格预警】商品 " + current.name + " 价格过高: " + current.price);
                            } else {
                                out.collect("【价格更新】商品 " + current.name + " 现价: " + current.price);
                            }
                        }
                        else if ("d".equals(op)) {
                            out.collect("【商品下架】商品: " + current.name);
                        }
                    }
                })
                .print(); // 6. 打印结果

        // 6. 执行任务
        env.execute("Flink CDC Simple Demo");
    }
}

🩸 DataStream API 踩坑复盘

  • 报错 java.lang.NoClassDefFoundError: .../RecordEmitter
    • 原因: 项目中缺少 flink-connector-base 依赖。虽然 flink-streaming-java 包含了部分基础类,但 FLIP-27 Source 接口的实现位于 connector-base 中。
  • 报错 The MySQL server has a timezone offset ...
    • 原因: MySQL 容器通常默认是 UTC 时区,而本地 Java 程序(如 Windows/Mac)默认取系统时区(如 Asia/Shanghai)。Flink CDC 对时间类型极其敏感,要求两端时区对齐。
  • 解决: 代码中添加 .serverTimeZone("UTC")。
    • 现象:价格字段显示为 "price": "CSdc" (乱码)
    • 原因: 底层 Debezium 为了不丢失精度,默认将 DECIMAL 类型序列化为 Base64 编码的字符串。
    • 解决: 自定义反序列化配置 decimal.format 为 NUMERIC。

⚠️ 额外提示:关于 Java 17 的潜在问题

我的运行环境是 JDK 17 (D:\JAVA\Java\openjdk-17.0.14\bin\java.exe)

修复了上面的报错后,再次运行可能会遇到类似 InaccessibleObjectExceptionmodule java.base does not "opens java.util" to unnamed module 的错误。这是因为 Flink 在 Java 17 下运行时,需要开放一些模块权限。

如果修复依赖后遇到上述新报错,在 IDEA 的"运行配置 (Run Configuration)" -> "VM Options" 中加入以下参数:

Plaintext 复制代码
--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED

**建议:**最省事的方法是将项目 JDK 切换回 Java 8 (1.8) 或 Java 11,这两个版本对 Flink 1.17 的支持最丝滑,不需要加额外的 JVM 参数。

⚠️补充mysql建表语句:

sql 复制代码
CREATE DATABASE flink_demo;
USE flink_demo;

CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255),
    price DECIMAL(10, 2),
    description VARCHAR(255)
);

INSERT INTO products (name, price, description) VALUES ('iPhone 15', 5999.00, 'Apple Phone');

⚠️初始化数据库后,启动flink程序:

添加一条数据后,可以看到

sql 复制代码
update products set price=5499 where id = 1;

op: 操作类型(r=快照读取, c=插入, u=更新, d=删除)

四、 实战阶段 2:Flink SQL API (进阶聚合)

如果你要做实时统计(例如:实时计算商品总价值、PV/UV),DataStream API 处理 CDC 的"撤回流"(Retract Stream)非常麻烦(需要手动处理 -U 减去旧值,+U 加上新值),而 Flink SQL 是处理 CDC 数据的终极武器。

4.1 Maven 依赖冲突 (🩸坑点 ⑤)

从 DataStream 切换到 SQL 开发时,最容易遇到 Ambiguous factory classes 错误。

冲突原因: flink-table-planner-loaderflink-table-planner_2.12 不能共存。前者是轻量级加载器,后者是完整的执行引擎。本地调试需要完整的引擎。

解决办法: 移除 loader 依赖,引入 planner 和 bridge。

yml 复制代码
	<!-- 必须引入 Bridge -->
	<dependency>
	    <groupId>org.apache.flink</groupId>
	    <artifactId>flink-table-api-java-bridge</artifactId>
	    <version>1.17.1</version>
	</dependency>
	<!-- 必须引入完整的 Planner -->
	<dependency>
	    <groupId>org.apache.flink</groupId>
	    <artifactId>flink-table-planner_2.12</artifactId>
	    <version>1.17.1</version>
	</dependency>
	<!-- ❌ 务必移除或注释掉 flink-table-planner-loader -->

4.2 SQL 逻辑实现

Flink SQL 会自动识别 CDC 数据的变更类型(Insert, Update, Delete),通过 -U/+U 机制自动维护聚合结果。

java 复制代码
package com.jerry.flinkcdcdemo;

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;

/***
 * 基于 Flink SQL 的 CDC 示例
 * 1. 从 MySQL 数据库读取增量数据
 * 2. 打印到控制台
 * 3. 执行 SQL 查询
 *    - 简单查询 (SELECT *)
 *    - 带过滤条件 (WHERE)
 */
public class FlinkSqlCdcDemo {
    public static void main(String[] args) {
        // 1. 创建环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        
        // 创建 Table 环境 (Flink SQL 的入口)
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

        // 2. 用 SQL 定义 Source 表 (DDL)
        // 这相当于告诉 Flink:MySQL 里有一张表,长这样,请你去连接它
        String createTableSql = "CREATE TABLE products_cdc (" +  // ⚠️注意:这是逻辑映射,不是在 MySQL 创建物理表
                "   id INT PRIMARY KEY NOT ENFORCED," + // 必须声明主键 与 MySQL 中的主键保持一致
                "   name STRING," +
                "   price DECIMAL(10, 2)," +
                "   description STRING" +
                ") WITH (" +
                "   'connector' = 'mysql-cdc'," +       // 使用 MySQL CDC 连接器
                "   'hostname' = '8.xx.xxx.xx'," +
                "   'port' = '3306'," +
                "   'username' = 'root'," +
                "   'password' = 'passward'," +        // 记得改密码
                "   'database-name' = 'flink_demo'," +  // <--- 这里指明了它实际上连的是 MySQL 的哪个库
                "   'table-name' = 'products'," +       // <--- 这里指明了它实际上连的是 MySQL 的哪个表
                "   'server-time-zone' = 'UTC'" +       // 解决时区报错的关键
                ")";

        // 执行建表语句 (注册元数据,还不会真正运行)
        tableEnv.executeSql(createTableSql);

        // ==========================================
        // 玩法 1:简单查询 (相当于 SELECT * FROM)
        // ==========================================
//         tableEnv.executeSql("SELECT * FROM products_cdc").print();

        // ==========================================
        // 玩法 2:带过滤的查询
        // ==========================================
//         tableEnv.executeSql("SELECT id, name, price FROM products_cdc WHERE price > 5000").print();

        // ==========================================
        // 玩法 3:实时聚合 (这是 DataStream 最难做,但 SQL 最简单的)
        // 需求:实时计算所有商品的总价值,以及最贵的商品价格是多少
        // ==========================================
        String aggSql = "SELECT " +
                "  COUNT(*) as product_count, " +
                "  SUM(price) as total_value, " +
                "  MAX(price) as max_price " +
                "FROM products_cdc";

        System.out.println("开始执行实时聚合 SQL...");
        tableEnv.executeSql(aggSql).print();

    }
}

🩸 Flink SQL 核心概念辨析 (小白误区)

Q1:"为什么我 executeSql 了,数据库里没看到表?"

  • 误区: 认为 Flink 的 CREATE TABLE 等同于 MySQL 的 DDL。
  • 正解: Flink 中的 CREATE TABLE 只是在内存中注册了一个逻辑映射关系,告诉 Flink 引擎:"当你查 products_cdc 时,请去连接 MySQL 的 products 表读取 Binlog"。它不会在 MySQL 中创建任何物理文件。

Q2:"为什么控制台打印的结果和数据库查出来的不一样?"

  • 现象: Flink 控制台会疯狂打印 +I, -U, +U 的数据流,看起来很乱。
  • 正解:数据库展示的是静态快照 (Snapshot)------即"比赛结束时的比分"。
  • Flink 展示的是动态流 (Changelog Stream)------即"整场比赛的进球记录"。
  • 仔细观察 Flink 日志中最新的一条 +U (Update After) 数据,你会发现它的数值与数据库当前查询的结果是完全一致的。
    现象: Flink 控制台会疯狂打印 +I, -U, +U 的数据流,看起来很乱。

那对于Q2,我来个比较通俗的解释

数据库 (Static View)

你在数据库里执行 SELECT 时,数据库给你的是**"此时此刻"**的快照。它不会告诉你"刚才这个总数是 12000,后来变成了 27000,最后变成了 39998"。它直接把最终结果甩给你。
Flink SQL (Dynamic Stream)

Flink 是流式计算,它给你展示的是一部 "记账电影"。
请看你截图中的日志流:

| +I | 1 | ...: 刚开始,Flink 读到了第 1 条数据,算出总价是 6999。

| -U | 1 | ... & | +U | 2 | ...: 紧接着,Flink 发现数据变了(或者读到了第 2 条数据),它先把旧的结果(6999)撤回(-U),然后输出新的累加结果(12998)。

... (中间经过多次计算) ...

| +U | 4 | 39998.00 | ...: 这就是最终态! 当所有数据都处理完,或者没有新变动时,这就是当前的结果。
比喻:

数据库 像是比分牌,你抬头看一眼,现在的比分是 4:0。

Flink 像是现场解说员,他会告诉你:"刚才进了第 1 个球... 哎呀现在进了第 2 个... 现在是第 3 个... 好的比赛结束,最终 4:0"。

五、 总结

搭建一个 Production-Ready 的 Flink CDC 应用,代码量其实很少,但环境配置和依赖管理才是最大的拦路虎。

Checklist:

  • Dependencies: 确保 planner 不冲突,connector-base 不缺失。
  • Config: MySQL Binlog
  • 格式为 ROW,时区设置为 UTC,Decimal 格式化要处理。
  • Network: Docker 环境下分清 localhost
  • 和真实宿主机 IP,防止 Connection Refused。
  • Concept: 理解 Flink SQL 的逻辑表映射和动态表(Dynamic Table)的撤回机制。

掌握了这些,你就掌握了构建实时数仓的第一把钥匙!🚀

相关推荐
TG:@yunlaoda360 云老大4 小时前
腾讯云国际站代理商的TAPD如何帮助企业进行成本控制?
大数据·云计算·腾讯云
Hello.Reader5 小时前
Flink SQL ANALYZE TABLE手动采集表统计信息,让优化器“更懂数据”
大数据·sql·flink
好好沉淀5 小时前
开发过程中动态 SQL 中where 1=1的作用是什么
java·服务器·开发语言·数据库·sql
ggabb5 小时前
新国标电动车爬坡困境:当限速25km/h遭遇安全危机,无责伤亡谁来买单?
大数据·人工智能·安全
小马爱打代码5 小时前
慢SQL:查询、定位分析解决的完整方案
数据库·sql
羑悻的小杀马特5 小时前
破局IoT与大数据协同难题!Apache IoTDB用硬核性能打底、强生态护航,成行业新宠!
大数据·物联网·ai·apache·iotdb
cyhysr5 小时前
oracle的model子句让sql像excel一样灵活2
sql·oracle·excel
程途拾光1585 小时前
企业组织架构图导出Word 在线编辑免费工具
大数据·论文阅读·人工智能·信息可视化·架构·word·流程图
Giser探索家5 小时前
卫星遥感数据核心参数解析:空间分辨率与时间分辨率
大数据·图像处理·人工智能·深度学习·算法·计算机视觉