这是【实时数仓】系列第2篇。上篇讲Paimon选型,这篇讲CDC踩坑。
CDC日志:正常 ✓
Flink日志:正常 ✓
Doris日志:正常 ✓
但数据:不对 ✗
排查:从CDC到Flink到Doris
#mermaid-svg-flUt7npgM8tqxHea{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-flUt7npgM8tqxHea .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-flUt7npgM8tqxHea .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-flUt7npgM8tqxHea .error-icon{fill:#552222;}#mermaid-svg-flUt7npgM8tqxHea .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-flUt7npgM8tqxHea .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-flUt7npgM8tqxHea .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-flUt7npgM8tqxHea .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-flUt7npgM8tqxHea .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-flUt7npgM8tqxHea .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-flUt7npgM8tqxHea .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-flUt7npgM8tqxHea .marker{fill:#333333;stroke:#333333;}#mermaid-svg-flUt7npgM8tqxHea .marker.cross{stroke:#333333;}#mermaid-svg-flUt7npgM8tqxHea svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-flUt7npgM8tqxHea p{margin:0;}#mermaid-svg-flUt7npgM8tqxHea .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-flUt7npgM8tqxHea .cluster-label text{fill:#333;}#mermaid-svg-flUt7npgM8tqxHea .cluster-label span{color:#333;}#mermaid-svg-flUt7npgM8tqxHea .cluster-label span p{background-color:transparent;}#mermaid-svg-flUt7npgM8tqxHea .label text,#mermaid-svg-flUt7npgM8tqxHea span{fill:#333;color:#333;}#mermaid-svg-flUt7npgM8tqxHea .node rect,#mermaid-svg-flUt7npgM8tqxHea .node circle,#mermaid-svg-flUt7npgM8tqxHea .node ellipse,#mermaid-svg-flUt7npgM8tqxHea .node polygon,#mermaid-svg-flUt7npgM8tqxHea .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-flUt7npgM8tqxHea .rough-node .label text,#mermaid-svg-flUt7npgM8tqxHea .node .label text,#mermaid-svg-flUt7npgM8tqxHea .image-shape .label,#mermaid-svg-flUt7npgM8tqxHea .icon-shape .label{text-anchor:middle;}#mermaid-svg-flUt7npgM8tqxHea .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-flUt7npgM8tqxHea .rough-node .label,#mermaid-svg-flUt7npgM8tqxHea .node .label,#mermaid-svg-flUt7npgM8tqxHea .image-shape .label,#mermaid-svg-flUt7npgM8tqxHea .icon-shape .label{text-align:center;}#mermaid-svg-flUt7npgM8tqxHea .node.clickable{cursor:pointer;}#mermaid-svg-flUt7npgM8tqxHea .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-flUt7npgM8tqxHea .arrowheadPath{fill:#333333;}#mermaid-svg-flUt7npgM8tqxHea .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-flUt7npgM8tqxHea .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-flUt7npgM8tqxHea .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flUt7npgM8tqxHea .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-flUt7npgM8tqxHea .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flUt7npgM8tqxHea .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-flUt7npgM8tqxHea .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-flUt7npgM8tqxHea .cluster text{fill:#333;}#mermaid-svg-flUt7npgM8tqxHea .cluster span{color:#333;}#mermaid-svg-flUt7npgM8tqxHea div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-flUt7npgM8tqxHea .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-flUt7npgM8tqxHea rect.text{fill:none;stroke-width:0;}#mermaid-svg-flUt7npgM8tqxHea .icon-shape,#mermaid-svg-flUt7npgM8tqxHea .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-flUt7npgM8tqxHea .icon-shape p,#mermaid-svg-flUt7npgM8tqxHea .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-flUt7npgM8tqxHea .icon-shape .label rect,#mermaid-svg-flUt7npgM8tqxHea .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-flUt7npgM8tqxHea .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-flUt7npgM8tqxHea .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-flUt7npgM8tqxHea :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} CDC
写入
正常
正常
数据不对
MySQL源库
Flink
Doris
查CDC日志
查Flink日志
查Doris数据
问题在Doris写入
第一步:确认CDC是否捕获了变更
sql
SELECT order_id, status, update_time
FROM mysql_source
WHERE order_id = 'WB20260601001';
结果:CDC捕获了状态变更已发货→已完成,时间戳也对。
第二步:确认Flink是否正确处理
在Flink任务里加了日志,打印每条记录的order_id、status、update_time。确认输出是对的。
第三步:查Doris里的实际数据
sql
SELECT order_id, status, update_time
FROM doris_orders
WHERE order_id = 'WB20260601001';
结果:status是"已发货",update_time是旧的时间戳。
CDC捕获了变更,Flink处理了变更,但Doris里存的还是旧值。
问题出在Doris写入。
定位:乱序导致数据被覆盖
查了Doris的导入日志,发现一个关键细节:
这个运单的两条记录(已发货、已完成)几乎同时到达Doris。但"已完成"先到,"已发货"后到。
Doris的Unique Key模型在没有Sequence Column的情况下,按写入版本号决定覆盖顺序------后写入的版本号更高,会覆盖先写入的。
| 顺序 | T1(先到) | T2(后到) | Doris最终值 | 结果 |
|---|---|---|---|---|
| 正常 | 已发货 | 已完成 | 已完成 | ✓ |
| 乱序 | 已完成 | 已发货 | 已发货 | ✗ |
Doris不知道哪条数据更新,它只看版本号。
为什么乱序是CDC管道的固有问题
一开始我以为乱序是偶发的网络抖动。后来发现不是。
CDC管道里,同一条记录的多次变更可能在毫秒级内到达。Flink的并行度会影响处理顺序:同一个运单的两次变更,可能被不同的并行实例处理,谁先写入Doris取决于谁先完成,不取决于谁先发生。
MySQL binlog: 已发货 → 已完成(时间顺序)
↓
Flink并行实例: 实例A处理"已完成" 实例B处理"已发货"
↓ ↓
Doris写入: "已完成"先到 "已发货"后到
↓
Doris最终值: 已发货 ✗
所以乱序不是网络问题,是CDC管道的结构性问题。只要用Flink+Doris,就一定会遇到。
解决:Sequence Column
Doris的Unique Key模型支持Sequence Column,可以指定一个字段作为排序依据。写入时,按这个字段的值排序,值大的覆盖值小的,值小的不会覆盖值大的。
核心机制: Doris在内部维护一个隐藏列__DORIS_SEQUENCE_COL__,用户指定哪个字段作为Sequence列后,Doris在每次写入时比较Sequence列的值,只有新值大于已有值才会覆盖。
两种启用方式
| 属性 | 含义 | 表中是否必须有对应列 |
|---|---|---|
function_column.sequence_col |
把Sequence列映射到表中已有的列 | 是 |
function_column.sequence_type |
只指定Sequence列的类型,存在隐藏列中 | 否 |
支持的类型:整数类型、DATE、DATETIME。建表后不能改。
方式一:映射到已有列
sql
CREATE TABLE orders (
order_id BIGINT,
status VARCHAR(32),
update_time DATETIME
) UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 4
PROPERTIES (
"function_column.sequence_col" = "update_time"
);
导入时不需要额外参数,Doris自动用update_time列的值作为排序依据。
方式二:使用隐藏列
sql
CREATE TABLE orders (
order_id BIGINT,
status VARCHAR(32),
update_time DATETIME
) UNIQUE KEY(order_id)
DISTRIBUTED BY HASH(order_id) BUCKETS 4
PROPERTIES (
"function_column.sequence_type" = "DATETIME"
);
导入时需要在header里指定映射:
bash
curl --location-trusted -u root \
-H "columns: order_id,status,update_time,source_sequence" \
-H "function_column.sequence_col: source_sequence" \
-T testData http://host:port/api/db/orders/_stream_load
验证效果
导入三条同Key数据:
1 已发货 2026-06-01 10:00:00
1 运输中 2026-06-01 11:00:00
1 已完成 2026-06-01 12:00:00
不管到达顺序如何,Doris最终存的是update_time最大的那条:已完成 2026-06-01 12:00:00。
在已有表上启用
sql
ALTER TABLE orders ENABLE FEATURE "SEQUENCE_LOAD"
WITH PROPERTIES ("function_column.sequence_type" = "DATETIME");
检查是否启用
sql
SET show_hidden_columns = true;
DESC orders;
如果输出包含__DORIS_SEQUENCE_COL__,说明已启用。
Sequence Column的选择
update_time是最常见的选择,但有些业务场景下update_time可能重复------同一条记录在同一秒内多次变更,update_time一样大,Doris不知道谁覆盖谁。
这种情况下,需要更细粒度的字段,比如带毫秒的时间戳,或者自增的版本号。
Doris 2.0.14的Bug
我们用的是Doris 2.0.14。
改完Sequence Column之后,跑了两天没问题。第三天突然发现:某些运单在Doris里有两条记录,主键重复了。
查了半天,发现是Doris 2.0.14的一个已知Bug:执行ALTER TABLE ENABLE FEATURE "SEQUENCE_LOAD"后立即导入数据,会导致重复主键。
解决方案:删表重建,或者升级到2.0.15以上。
我们选了删表重建,因为升级涉及面太大。
回头看:CDC涉及四个层面
乱序问题排查了三天。不是问题难,是排查方向错了------一直在查CDC和Flink,没想到问题在Doris的写入顺序。
回头看,CDC看起来简单------配个连接器,起个任务,完事。但实际上涉及四个层面,每个层面都有坑:
| 层面 | 典型问题 | 根因 |
|---|---|---|
| 源库 | 连接失败、权限不足 | binlog默认不开,CDC账号需要REPLICATION SLAVE权限 |
| 网络 | 偶尔断开、握手失败 | MySQL开了SSL后CDC必须配SSL参数 |
| SQL引擎 | 语法报错 | Flink SQL语法和MySQL差别大(DATE_SUB不存在、value是保留字) |
| 存储引擎 | 数据对不上 | Doris后写覆盖先写,乱序是结构性问题 |
怎么判断问题在哪一层
排查CDC问题,不能只看CDC日志。关键是判断问题出在哪一层。
| 特征 | 问题在哪层 | 排查方法 |
|---|---|---|
| 任务启动就失败 | 源库层 | 看报错信息 |
| 任务偶尔断开 | 网络层 | 看网络监控、检查SSL配置 |
| 处理数据时报语法错误 | SQL引擎层 | 查Flink SQL文档 |
| 一切正常但数据不对 | 存储引擎层 | 逐条对比源库和目标库 |
我们的乱序问题就是第四种:一切正常,但数据不对。
写在最后
CDC是实时数仓最简单的一步,但涉及源库、网络、SQL引擎、存储引擎四个层面,每个层面都有坑。
乱序问题排查了三天。不是问题难,是排查方向错了------一直在查CDC和Flink,没想到问题在Doris的写入顺序。
什么时候该用Sequence Column?
| 场景 | 是否需要Sequence Column |
|---|---|
| 同一条记录多次变更(订单状态变更) | 必须用 |
| 同一条记录只写一次(日志数据) | 不需要 |
CDC四个层面的排查优先级:
- 源库层:任务启动就失败,看报错信息
- 网络层:任务偶尔断开,看网络监控
- SQL引擎层:处理数据时报语法错误,查Flink文档
- 存储引擎层:一切正常但数据不对,最难排查
下一篇讲Flink实时ETL------数据抽过来之后怎么处理。