一、为什么要做这个研究
在学习 Flink CDC 的过程中,我发现很多文章都在讲如何使用,但很少有人 深入分析性能特性。当我自己搭建测试环境时,发现端到端延迟经常达到 900ms 以上,远高于预期。
我想搞清楚三个问题:
- 延迟到底产生在哪个环节?
- 是 MySQL Binlog 捕获慢,还是 Flink 处理慢?
- 有没有办法精确测量每一层的延迟?
于是我设计了一套分层测试方法,通过修改源码插桩的方式, 精确测量了 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 测试方法
设计了一个简单的端到端测试:
- 在 MySQL 中执行 UPDATE 并记录时间戳
- Flink CDC 捕获变更并输出到日志
- 解析日志中的时间戳,计算延迟
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 次测试,结果如下:
| 测试轮次 | 端到端延迟 |
|---|---|
| 1 | 1196ms |
| 2 | 945ms |
| 3 | 826ms |
| 4 | 892ms |
| 5 | 1034ms |
| 6 | 967ms |
| 7 | 854ms |
| 8 | 923ms |
| 9 | 1012ms |
| 10 | 901ms |
| 平均值 | 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 次测试的平均结果:
| 指标 | 平均值 | 占比 |
|---|---|---|
| 端到端延迟 | 945ms | 100% |
| Source 延迟(MySQL→Flink) | 219ms | 23.2% |
| 反序列化延迟 | 0.13ms | 0.01% |
| Sink 延迟(Flink→输出) | 725ms | 76.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) 或任何需要延迟分析的流处理系统。
核心思路:
- 确定测量点(关键路径)
- 插入时间戳日志
- 自动化测试脚本
- 结构化数据分析
6.4 后续研究方向
- 大表场景测试:百万级、千万级数据的性能
- 不同 Sink 对比:Kafka、MySQL、ClickHouse
- 并行度调优:测试不同并行度的影响
- Checkpoint 影响:分析 Checkpoint 对延迟的影响
七、总结
本文通过源码插桩的方法,精确测量了 Flink CDC 各环节的延迟, 成功定位出 Print Sink 是主要瓶颈(占 76.7%)。
核心收获:
- 设计了一套可复现的分层测试方法
- 掌握了通过修改源码进行性能分析的技能
- 学会了从数据出发进行技术决策