4、Flink CDC核心API详解:DataStream API与Table/SQL API对比、SourceBuilder构建、DeserializationSchema自定义反序列化

这一章,我们来啃Flink CDC最核心的API。说实话,很多初学者一上来就被各种API搞晕了——到底用DataStream还是Table/SQL?SourceBuilder怎么配?反序列化又是什么鬼?

别急,我一个一个给你拆开讲。先看一张总览图,把今天要聊的三个知识点串起来。

Flink CDC 核心 API 体系 MySQL CDC Source DataStream API Table / SQL API SourceBuilder 构建 DeserializationSchema 自定义 时态表 Join / 动态表 核心差异:DataStream 更灵活,Table/SQL 更简洁

4.1 DataStream API vs Table/SQL API:怎么选?

我刚开始用Flink CDC的时候,也纠结过这个问题。后来做多了发现,这俩没有的优劣,关键看场景。

对比维度 DataStream API Table/SQL API
编程模型 流处理原生API,算子链式调用 声明式SQL,表概念抽象
学习成本 较高,需要理解算子、状态、时间语义 较低,有SQL基础就能上手
灵活性 极高,可自定义算子、状态、触发器 中等,受限于SQL表达能力
复杂事件处理 支持CEP、自定义ProcessFunction 支持MATCH_RECOGNIZE,但有限
状态管理 手动管理ValueState/MapState等 自动管理,SQL隐含状态
CDC场景推荐 需要精确控制反序列化、多流关联 简单同步、宽表构建、ETL
我的建议:如果只是把MySQL数据原样同步到Kafka或另一个数据库,直接用Table/SQL API,一行SQL搞定。但如果要做字段级转换、多流合并、或者对接自定义Sink,DataStream API才是你的菜。

4.2 SourceBuilder:构建CDC Source的正确姿势

不管用哪种API,底层都离不开SourceBuilder。我记得第一次用的时候,参数配错了,结果全量阶段跑完增量阶段一直没数据——排查了半天才发现是startupOptions设成了initial但没给Server ID。

来看一个标准的构建方式:

// 构建 MySQL CDC Source
MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
    .hostname("localhost")
    .port(3306)
    .databaseList("mydb")                 // 监控的数据库
    .tableList("mydb.orders")             // 监控的表,格式 db.table
    .username("cdc_user")
    .password("cdc_pass")
    .serverTimeZone("Asia/Shanghai")
    .startupOptions(StartupOptions.initial())
    .deserializer(new JsonDebeziumDeserializationSchema())
    .build();

这里有几个关键参数,我重点说一下:

  • hostname/port:MySQL地址,注意网络连通性,别配成localhost了。
  • databaseList/tableList:支持正则,比如"mydb\\..*"匹配mydb下所有表。
  • startupOptions:有initial(先全量后增量)、earliest(从最早binlog开始)、latest(从当前binlog开始)、timestamp(指定时间戳)。
  • deserializer:反序列化器,后面重点讲。
避坑指南:我曾经在生产环境遇到过一个问题——serverId没显式设置,导致多个并行度抢同一个Server ID,MySQL直接断开连接。建议每个并行度都手动指定不同的Server ID,比如用5400 + 并行度索引。

4.3 DeserializationSchema:自定义反序列化

默认的JsonDebeziumDeserializationSchema会把CDC事件转成JSON字符串。但很多时候,我们需要自定义格式——比如只取变更后的数据、或者把Debezium的复杂结构拍平。

为什么要自定义?我给你举个例子。默认的CDC JSON长这样:

{
  "before": null,
  "after": {
    "id": 1001,
    "name": "张三",
    "amount": 500.00
  },
  "source": {
    "db": "mydb",
    "table": "orders",
    "ts_ms": 1700000000000
  },
  "op": "c"
}

这个结构太冗余了。如果我只关心after字段和操作类型,完全可以自己写一个反序列化器,直接输出一个POJO。

来看代码:

public class OrderDeserializationSchema
    implements DeserializationSchema<OrderEvent> {

    @Override
    public void open(DeserializationSchema.InitializationContext context) {
        // 可以在这里初始化资源,比如连接池
    }

    @Override
    public OrderEvent deserialize(byte[] message) throws IOException {
        // 1. 解析Debezium的JSON
        JsonNode root = JsonParser.parseBytes(message);
        JsonNode after = root.get("after");
        String op = root.get("op").asText();

        // 2. 只处理插入和更新
        if ("c".equals(op) || "u".equals(op)) {
            OrderEvent event = new OrderEvent();
            event.setId(after.get("id").asLong());
            event.setName(after.get("name").asText());
            event.setAmount(after.get("amount").asDouble());
            event.setOpType(op);
            return event;
        }
        // 删除事件可以忽略,或者特殊处理
        return null;
    }

    @Override
    public boolean isEndOfStream(OrderEvent nextElement) {
        // CDC流是无界的,永远返回false
        return false;
    }

    @Override
    public TypeInformation<OrderEvent> getProducedType() {
        return TypeInformation.of(OrderEvent.class);
    }
}
核心要点:
  • deserialize():核心方法,byte[]转成你的目标类型。返回null表示过滤掉这条记录。
  • isEndOfStream():CDC流是无界的,永远返回false。
  • getProducedType():告诉Flink你的输出类型,用于序列化和状态管理。

然后这样用:

MySqlSource<OrderEvent> source = MySqlSource.<OrderEvent>builder()
    .hostname("localhost")
    .port(3306)
    .databaseList("mydb")
    .tableList("mydb.orders")
    .username("cdc_user")
    .password("cdc_pass")
    .deserializer(new OrderDeserializationSchema())
    .build();

DataStream<OrderEvent> stream = env.fromSource(source, 
    WatermarkStrategy.noWatermarks(), "MySQL CDC Source");

你看,这样下游拿到的就是干净的OrderEvent对象,直接做业务处理,不用再解析JSON了。

我的经验:自定义反序列化时,别忘了处理异常。我曾经遇到过binlog里混入了不完整的记录,反序列化直接抛异常导致任务重启。建议在deserialize()里加try-catch,捕获异常后返回null或者打日志,不要让整个任务挂掉。

4.4 两种API的CDC实战对比

说了这么多,咱们直接看代码对比。同样是读取MySQL的orders表,两种写法差异很大。

Table/SQL API 方式:

// 创建CDC表
tableEnv.executeSql("CREATE TABLE orders_cdc (" +
    "  id BIGINT," +
    "  name STRING," +
    "  amount DECIMAL(10, 2)," +
    "  PRIMARY KEY (id) NOT ENFORCED" +
    ") WITH (" +
    "  'connector' = 'mysql-cdc'," +
    "  'hostname' = 'localhost'," +
    "  'port' = '3306'," +
    "  'database-name' = 'mydb'," +
    "  'table-name' = 'orders'," +
    "  'username' = 'cdc_user'," +
    "  'password' = 'cdc_pass'" +
    ")");

// 直接查
Table result = tableEnv.sqlQuery("SELECT id, name, amount FROM orders_cdc");
tableEnv.toDataStream(result, OrderEvent.class).print();

DataStream API 方式:

MySqlSource<OrderEvent> source = MySqlSource.<OrderEvent>builder()
    .hostname("localhost")
    .port(3306)
    .databaseList("mydb")
    .tableList("mydb.orders")
    .username("cdc_user")
    .password("cdc_pass")
    .deserializer(new OrderDeserializationSchema())
    .build();

DataStream<OrderEvent> stream = env.fromSource(source, 
    WatermarkStrategy.noWatermarks(), "MySQL CDC Source");

stream
    .filter(event -> event.getAmount() > 100)
    .map(event -> new EnrichedOrder(event, getRegion(event.getId())))
    .print();

看到区别了吗?SQL方式简洁,但做复杂过滤和关联就比较吃力。DataStream方式代码多几行,但你可以随意加filter、map、keyBy,甚至用ProcessFunction做状态管理。

注意:Table/SQL API下,CDC表默认是仅追加(append-only)模式。如果你要做更新操作(比如upsert到MySQL),需要声明PRIMARY KEY并启用changelog模式。这个坑我踩过——没设主键,结果下游一直报错说找不到更新行。