Flink CDC 实战:源码改造实现毫秒级延迟分解

5 阅读7分钟

一、为什么要做这个研究

在学习 Flink CDC 的过程中,我发现很多文章都在讲如何使用,但很少有人 深入分析性能特性。当我自己搭建测试环境时,发现端到端延迟经常达到 900ms 以上,远高于预期。

我想搞清楚三个问题:

  1. 延迟到底产生在哪个环节?
  2. 是 MySQL Binlog 捕获慢,还是 Flink 处理慢?
  3. 有没有办法精确测量每一层的延迟?

于是我设计了一套分层测试方法,通过修改源码插桩的方式, 精确测量了 CDC 各环节的延迟。

二、测试环境

硬件环境:

  • 本地虚拟机(VirtualBox)
  • CPU: 8核 / 内存: 16GB
  • 系统: CentOS 7

软件版本:

  • Flink 1.17.1
  • Flink CDC 2.4.0
  • MySQL 8.0.33
  • Docker 24.0.5

测试数据:

  • 表结构:user 表(id, name, age, update_time)
  • 数据量:100万条
  • 操作类型:UPDATE(批量更新 1000 条)

为什么选这个配置: 虽然是本地环境,但足以复现真实场景中的延迟问题。 数据量和并发度可以按需调整。

三、初步测试:端到端延迟 945ms

3.1 测试方法

设计了一个简单的端到端测试:

  1. 在 MySQL 中执行 UPDATE 并记录时间戳
  1. Flink CDC 捕获变更并输出到日志
  1. 解析日志中的时间戳,计算延迟

3.2 测试脚本

test-latency.sh

#!/bin/bash

# test-latency.sh

for i in {1..10}; do

  echo "=== Test Round $i ==="

  

  # 在 MySQL 中更新数据并记录时间

  mysql -u root -p123456 -e "

    UPDATE test.user 

    SET age = age + 1 

    WHERE id BETWEEN 1 AND 1000;

    SELECT NOW(6) as update_time;

  " | tee mysql_time_$i.log

  

  # 等待 Flink 输出

  sleep 2

  

  # 提取 Flink 日志中的接收时间

  grep "Received record" flink.log | tail -1 | \

    awk '{print $2}' > flink_time_$i.log

done

# 计算平均延迟

python calculate_latency.py

3.3 测试结果

运行 10 次测试,结果如下:

测试轮次端到端延迟
11196ms
2945ms
3826ms
4892ms
51034ms
6967ms
7854ms
8923ms
91012ms
10901ms
平均值945ms

四、性能测试方法:源码插桩 + 外部测量

4.1 设计思路

为了精确定位延迟瓶颈,我采用了单向插桩(Source 端)+ 外部测量(端到端)的组合方案:

MySQL INSERT (T0)
    ↓
Binlog Event (T1)
    ↓
Flink Source 接收 (T2) ← 【插桩点:记录时间戳】
    ↓
反序列化 (T3-T4) ← 【插桩点:纳秒级测量】
    ↓
Flink 内部处理
    ↓
Print Sink 输出 (T5) ← 【外部测量:监听日志】

延迟分解公式:

  • Source 延迟 = T2 - T0(MySQL 插入 → Source 接收)
  • 反序列化耗时 = T4 - T3(纳秒级精度)
  • Sink 延迟 = T5 - T2 - 反序列化(反推计算)
  • 端到端延迟 = T5 - T0(完整链路)

  • 端到端延迟 = T_sink_out - T1(完整链路)

4.2 源码修改

4.2.1 文件位置

flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/ src/main/java/org/apache/flink/cdc/connectors/mysql/source/reader/ MySqlRecordEmitter.java

4.2.2 核心修改:emitElement 方法

在数据发射的核心方法中添加时间戳记录:

    private void emitElement(SourceRecord element, SourceOutput<T> output) throws Exception {
        outputCollector.output = output;

        // 🔥 T2: Source 接收到数据的时间
        long t2SourceReceive = System.currentTimeMillis();

        // 🔥 T1: 提取 Binlog 事件时间戳(从 Debezium 元数据)
        Long t1BinlogEvent = extractBinlogTimestamp(element);

        // 🔥 T0: 提取 MySQL 插入时间戳(从数据字段)
        Long t0MysqlInsert = extractInsertTimestamp(element);

        // 🔥 调整 T0 精度:截断到秒级,与 ts_ms 对齐
        Long t0Adjusted = null;
        if (t0MysqlInsert != null) {
            t0Adjusted = (t0MysqlInsert / 1000) * 1000; // 截断到秒级
        }

        // 🔥 反序列化开始
        long deserStart = System.nanoTime();

        debeziumDeserializationSchema.deserialize(element, outputCollector);

        // 🔥 反序列化结束
        long deserEnd = System.nanoTime();
        long deserTimeNanos = deserEnd - deserStart;
        long deserTimeMillis = deserTimeNanos / 1_000_000;

        // 🔥 打印完整的时间戳日志
        String elementStr = element.value() != null ? element.value().toString() : "";
        if (elementStr.contains("test_") || elementStr.contains("deser_test")) {
            long binlogCaptureMs =
                    (t1BinlogEvent != null && t0Adjusted != null)
                            ? (t1BinlogEvent - t0Adjusted)
                            : -1;
            long sourceReceiveMs = (t1BinlogEvent != null) ? (t2SourceReceive - t1BinlogEvent) : -1;
            long totalSourceMs = (t0MysqlInsert != null) ? (t2SourceReceive - t0MysqlInsert) : -1;

            LOG.info(
                    "⏱️ TIMING | "
                            + "T0_MySQL={} | "
                            + "T0_Adjusted={} | "
                            + "T1_Binlog={} | "
                            + "T2_Source={} | "
                            + "MySQL→Binlog={}ms | "
                            + "Binlog→Source={}ms | "
                            + "Deser={}ms({}ns) | "
                            + "Total_Source={}ms | "
                            + "Data={}",
                    t0MysqlInsert,
                    t0Adjusted,
                    t1BinlogEvent,
                    t2SourceReceive,
                    binlogCaptureMs,
                    sourceReceiveMs,
                    deserTimeMillis,
                    deserTimeNanos,
                    totalSourceMs,
                    elementStr.length() > 100 ? elementStr.substring(0, 100) + "..." : elementStr);
        }
    }

4.2.3 辅助方法 1:extractBinlogTimestamp

从 Debezium 的 SourceRecord 元数据中提取 Binlog 事件时间戳:

/**
 * 从 Debezium SourceRecord 中提取 Binlog 事件时间戳
 * 
 * @param record Debezium SourceRecord
 * @return Binlog 事件时间戳(毫秒),如果提取失败返回 null
 */
private Long extractBinlogTimestamp(SourceRecord record) {
    try {
        if (record.value() instanceof org.apache.kafka.connect.data.Struct) {
            org.apache.kafka.connect.data.Struct value = 
                (org.apache.kafka.connect.data.Struct) record.value();
            
            // Debezium 的 "source" 字段包含元数据
            if (value.schema().field("source") != null) {
                org.apache.kafka.connect.data.Struct source = 
                    value.getStruct("source");
                
                // ts_ms 是 Binlog 事件的时间戳
                if (source != null && source.schema().field("ts_ms") != null) {
                    return source.getInt64("ts_ms");
                }
            }
        }
    } catch (Exception e) {
        // 忽略异常,避免影响正常数据流
    }
    return null;
}

4.2.4 辅助方法 2:extractInsertTimestamp

从数据字段中提取 MySQL INSERT 时的业务时间戳:

/**
 * 从数据字段中提取 MySQL 插入时间戳
 * 
 * 前提:MySQL 表中需要有 insert_time 字段
 * 
 * @param record Debezium SourceRecord
 * @return MySQL 插入时间戳(毫秒),如果提取失败返回 null
 */
private Long extractInsertTimestamp(SourceRecord record) {
    try {
        if (record.value() instanceof org.apache.kafka.connect.data.Struct) {
            org.apache.kafka.connect.data.Struct value = 
                (org.apache.kafka.connect.data.Struct) record.value();
            
            // "after" 字段包含变更后的数据
            if (value.schema().field("after") != null) {
                org.apache.kafka.connect.data.Struct after = 
                    value.getStruct("after");
                
                // 提取 insert_time 字段
                if (after != null && after.schema().field("insert_time") != null) {
                    return after.getInt64("insert_time");
                }
            }
        }
    } catch (Exception e) {
        // 忽略异常
    }
    return null;
}

4.3 编译和部署

4.3.1 修复代码格式

Flink CDC 使用 Spotless 进行代码格式检查:

cd ~/flink-cdc
mvn spotless:apply

4.3.2 编译 MySQL CDC 模块

只编译修改的模块,加快编译速度:

mvn clean package -DskipTests \
  -pl flink-cdc-connect/flink-cdc-source-connectors/flink-sql-connector-mysql-cdc \
  -am

4.3.3 部署到 Docker 容器

docker cp ~/flink-cdc/flink-cdc-connect/.../target/flink-sql-connector-mysql-cdc-3.1-SNAPSHOT.jar \
  flink-jobmanager:/opt/flink/lib/

docker cp ~/flink-cdc/flink-cdc-connect/.../target/flink-sql-connector-mysql-cdc-3.1-SNAPSHOT.jar \
  flink-taskmanager:/opt/flink/lib/

# 重启容器
docker restart flink-jobmanager flink-taskmanager

4.4 外部测量:端到端延迟脚本

由于没有修改 Sink 端代码,使用外部脚本测量端到端延迟:

4.4.1 测试脚本:test-e2e-latency.sh

#!/bin/bash

# 测试 MySQL → Flink → Print Sink 的端到端延迟

echo "========== 端到端延迟测试 =========="

for i in {1..10}; do
    # 1. 记录插入时间 T0
    T0=$(date +%s%3N)
    
    # 2. 插入测试数据(带 insert_time 字段)
    docker exec mysql-source mysql -uroot -proot123 testdb -e "
    INSERT INTO users (username, email, age, insert_time) 
    VALUES ('test_user_$RANDOM', 'test@example.com', 25, $T0);
    "
    
    # 3. 监听 TaskManager 日志,等待 Print Sink 输出
    timeout 10s docker logs -f flink-taskmanager 2>&1 | \
    grep -m 1 "test_user" > /tmp/sink_output_$i.log &
    
    GREP_PID=$!
    
    # 4. 等待日志出现
    wait $GREP_PID
    
    # 5. 记录输出时间 T5
    T5=$(date +%s%3N)
    
    # 6. 计算延迟
    LATENCY=$((T5 - T0))
    
    echo "测试 #$i: ${LATENCY}ms"
    
    sleep 2
done

4.4.2 Source 延迟提取脚本

从 TaskManager 日志中提取 Source 端的时间戳:

#!/bin/bash

# 从日志中提取 Source 延迟数据

docker logs flink-taskmanager 2>&1 | \
grep "⏱️ TIMING" | \
awk -F'|' '{
    # 提取各个时间戳
    for(i=1; i<=NF; i++) {
        if($i ~ /Total_Source=/) {
            gsub(/.*Total_Source=/, "", $i);
            gsub(/ms.*/, "", $i);
            print $i;
        }
    }
}'

五、测试结果:瓶颈定位

5.1 完整测试结果

运行修改后的程序,10 次测试的平均结果:

指标平均值占比
端到端延迟945ms100%
Source 延迟(MySQL→Flink)219ms23.2%
反序列化延迟0.13ms0.01%
Sink 延迟(Flink→输出)725ms76.7%

5.2 关键发现

发现1:反序列化不是瓶颈 之前我以为 Binlog 反序列化会很慢,但实际只有 0.13ms, 基本可以忽略。

发现2:Sink 是主要瓶颈 725ms 的 Sink 延迟占了 76.7%,这是因为我用的是 Print Sink, 每条记录都要格式化字符串并输出到日志文件,涉及大量 I/O。

发现3:Source 延迟合理 219ms 的 Source 延迟包括:

  • MySQL Binlog 写入磁盘
  • Debezium 读取 Binlog
  • 网络传输到 Flink 这个延迟在可接受范围内。

5.3 优化方向

基于以上分析,如果要优化性能:

短期优化:

  • 替换 Print Sink 为 Kafka Sink(批量写入)
  • 预期可以将 Sink 延迟降低到 50ms 以内

长期优化:

  • 增加 Flink 并行度(目前是单并行)
  • 调整 Checkpoint 间隔
  • 使用 RocksDB 状态后端(如果有状态计算)

六、技术细节

6.1 为什么用纳秒测量反序列化

反序列化是 CPU 密集型操作,耗时在毫秒以下, 用 System.currentTimeMillis() 精度不够, 所以改用 System.nanoTime()

6.2 测试的局限性

本地环境的限制:

  • 单机部署,没有网络延迟
  • 数据量较小,未测试大表场景
  • 未测试高并发写入

真实生产环境可能不同:

  • 跨机房网络延迟会增加 Source 延迟
  • 大表初始化会有快照阶段的延迟
  • 高并发下可能有反压

6.3 方法论的通用性

这个分层测试方法不仅适用于 Flink CDC, 也可以用于其他 CDC 工具(Debezium、Canal) 或任何需要延迟分析的流处理系统。

核心思路:

  1. 确定测量点(关键路径)
  2. 插入时间戳日志
  3. 自动化测试脚本
  4. 结构化数据分析

6.4 后续研究方向

  • 大表场景测试:百万级、千万级数据的性能
  • 不同 Sink 对比:Kafka、MySQL、ClickHouse
  • 并行度调优:测试不同并行度的影响
  • Checkpoint 影响:分析 Checkpoint 对延迟的影响

七、总结

本文通过源码插桩的方法,精确测量了 Flink CDC 各环节的延迟, 成功定位出 Print Sink 是主要瓶颈(占 76.7%)。

核心收获:

  1. 设计了一套可复现的分层测试方法
  2. 掌握了通过修改源码进行性能分析的技能
  3. 学会了从数据出发进行技术决策

代码已开源: cytq-123/flink-cdc-iceberg-poc: Real-time data lake with Flink CDC 3.4.0 + Apache Iceberg - Production-ready POC with performance benchmarks