DD 数据开发学习站DATA ENGINEERING
模块 04 · 离线计算

CDC 与增量同步

binlog/GTID、全量加增量衔接、幂等落库、删除与 schema 变更,以及 Flink CDC 链路。

难度 · 进阶 约 110 分钟 选学 练习 6 项 计划内 刷题 0/3
建议先学:数据采集与同步
本页目录 · 24 节

本章目标:能说清 CDC 的原理,设计一条“全量初始化 + 增量同步 + 幂等落库”的链路,并处理删除、重复和 schema 变更。 前置知识:数据采集与同步、建表与增删改 预计用时:110 分钟

每天全量抽一遍业务库,数据量一大就抽不完;只按 update_time 拉增量,又会漏掉物理删除。CDC(Change Data Capture,变化数据捕获)解决的就是“业务库变了,数仓怎么知道、怎么可靠地搬过来”。

1. 为什么需要 CDC

批量同步有两种常见做法:

做法 怎么取数 问题
全量同步 每次 SELECT * 整表 数据量大时慢、给业务库压力大
时间戳增量 WHERE update_time > 上次时间 漏物理删除;同一秒的边界数据可能重复或丢失

CDC 直接读数据库的变更日志(MySQL 的 binlog、Oracle 的 redo、PostgreSQL 的 WAL),能拿到 INSERT、UPDATE、DELETE 三种事件,并且带上变更顺序。它适合订单、用户、商品这类需要持续同步的核心表。

2. 核心概念

2.1 两类 CDC

  • 基于日志:解析 binlog/WAL,实时性好、对业务库压力小,能捕获删除。需要数据库开启日志并授予权限。
  • 基于查询:轮询表或时间戳字段,实现简单,但延迟高、难捕获删除,边界容易出错。

生产上常见的组合是:先做一次全量快照,再从快照位点开始追增量日志。全量和增量必须对齐到同一个位点,否则中间会丢数据或重复。

2.2 binlog 与位点

MySQL 的 binlog 有三种格式:

  • STATEMENT:记录 SQL 语句。函数、时间等可能导致主从结果不一致。
  • ROW:记录每一行变更前后的值。CDC 通常要求 ROW。
  • MIXED:由数据库自动选择。

每个事件有位置信息。传统复制用文件名 + 偏移量(mysql-bin.000001, 154),GTID 模式下用全局事务 ID。同步任务要持久化“已经处理到哪个位点”,重启后从位点继续,而不是从头再来。

2.3 幂等与去重

CDC 链路至少一次投递很常见:网络重试、任务重启都可能让同一事件到达两次。落库必须幂等:

  • 有主键的表用 UPSERT / MERGE,同一主键重复写入结果一致。
  • 分区表按“业务日期 + 批次”覆盖写,重跑同一批次先清理再写。
  • 事件表用 event_id 去重,保留每个主键最新一条。

2.4 删除怎么处理

不要物理删除数仓里的历史行。更稳妥的做法是加一个 is_deleted 标记,或者把删除事件写进一张变更日志表,再由下游按需过滤。这样既能追溯,也不会因为一次误删把历史数据清空。

3. 动手:用 DuckDB 模拟一次增量合并

先建一张 CDC 事件表,模拟业务库发过来的变更:

CREATE OR REPLACE TABLE ods_order_cdc (
    event_id INTEGER,
    order_id VARCHAR,
    op VARCHAR,
    amount DECIMAL(12, 2),
    event_ts TIMESTAMP
);

INSERT INTO ods_order_cdc VALUES
    (1, '1001', 'I', 120.00, TIMESTAMP '2024-01-01 10:00:00'),
    (2, '1001', 'U', 150.00, TIMESTAMP '2024-01-01 10:05:00'),
    (3, '1002', 'I', 80.00,  TIMESTAMP '2024-01-01 10:06:00'),
    (4, '1002', 'D', NULL,   TIMESTAMP '2024-01-01 10:10:00'),
    (5, '1003', 'I', 260.00, TIMESTAMP '2024-01-02 09:00:00'),
    (6, '1001', 'U', 150.00, TIMESTAMP '2024-01-02 09:05:00');

3.1 取每个订单的最新状态

CREATE OR REPLACE TABLE order_current AS
WITH ranked AS (
    SELECT
        order_id,
        op,
        amount,
        event_ts,
        ROW_NUMBER() OVER (
            PARTITION BY order_id
            ORDER BY event_ts DESC, event_id DESC
        ) AS rn
    FROM ods_order_cdc
)
SELECT order_id, amount, event_ts
FROM ranked
WHERE rn = 1 AND op <> 'D';

SELECT * FROM order_current ORDER BY order_id;

结果只剩订单 1001 和 1003。订单 1002 的最新事件是 D,被过滤掉。ORDER BY event_ts DESC, event_id DESC 里的 event_id 是必要的:同一秒内可能有多条事件,单靠时间戳无法确定顺序。

3.2 按分区覆盖,保证重跑幂等

离线数仓更常见的做法是按天分区覆盖。假设 2024-01-02 的增量要写入 dwd_order_inc:

CREATE OR REPLACE TABLE dwd_order_inc (
    dt DATE,
    order_id VARCHAR,
    amount DECIMAL(12, 2)
);

BEGIN TRANSACTION;
DELETE FROM dwd_order_inc WHERE dt = DATE '2024-01-02';

INSERT INTO dwd_order_inc
SELECT
    DATE '2024-01-02' AS dt,
    order_id,
    amount
FROM ods_order_cdc
WHERE event_ts >= TIMESTAMP '2024-01-02 00:00:00'
  AND event_ts <  TIMESTAMP '2024-01-03 00:00:00'
  AND op <> 'D'
QUALIFY ROW_NUMBER() OVER (
    PARTITION BY order_id ORDER BY event_ts DESC, event_id DESC
) = 1;
COMMIT;

重跑同一天时,先删掉该分区再写,结果不会翻倍。注意 DELETE 和 INSERT 要放在同一个事务里,避免删完还没写入时任务失败,留下空分区。

3.3 批量同步工具配置

DataX 的 MySQL 到 Hive 作业,核心是 reader、writer 和切分键:

{
  "job": {
    "content": [
      {
        "reader": {
          "name": "mysqlreader",
          "parameter": {
            "username": "reader",
            "password": "${MYSQL_PASSWORD}",
            "column": ["order_id", "status", "amount", "update_time"],
            "where": "update_time >= '${bizdate}'",
            "connection": [
              {
                "table": ["orders"],
                "jdbcUrl": ["jdbc:mysql://mysql-host:3306/shop"]
              }
            ]
          }
        },
        "writer": {
          "name": "hdfswriter",
          "parameter": {
            "defaultFS": "hdfs://namenode:8020",
            "fileType": "orc",
            "path": "/warehouse/ods/orders/dt=${bizdate}",
            "fileName": "orders",
            "column": ["order_id", "status", "amount", "update_time"],
            "writeMode": "append"
          }
        }
      }
    ]
  }
}

密码用变量传入,不要写进 JSON 提交到 Git。bizdate 也要由调度系统传入,不能写死。

Flink CDC 可以直接把 MySQL 的 binlog 读成流,再写到 Kafka 或数据湖:

CREATE TABLE mysql_orders (
    order_id STRING,
    status STRING,
    amount DECIMAL(12, 2),
    update_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'mysql-cdc',
    'hostname' = 'mysql-host',
    'port' = '3306',
    'username' = 'reader',
    'password' = '${MYSQL_PASSWORD}',
    'database-name' = 'shop',
    'table-name' = 'orders',
    'server-time-zone' = 'Asia/Shanghai'
);

CREATE TABLE kafka_orders (
    order_id STRING,
    status STRING,
    amount DECIMAL(12, 2),
    update_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'upsert-kafka',
    'topic' = 'ods_orders',
    'properties.bootstrap.servers' = 'kafka:9092',
    'key.format' = 'json',
    'value.format' = 'json'
);

INSERT INTO kafka_orders
SELECT order_id, status, amount, update_time
FROM mysql_orders;

upsert-kafka 会按主键把更新和删除写成 upsert 消息,下游消费时不会把同一订单的多次变更当成多条新订单。

4. 全量 + 增量的衔接

一条可靠的初始化流程:

1. 记录当前 binlog 位点 P
2. 用一致性快照导出全量数据(带事务或锁,保证快照对应位点 P)
3. 全量写入 ODS,标记快照完成
4. 从位点 P 开始消费 binlog,写增量表
5. 下游按主键合并全量和增量

如果第 2 步和第 3 步之间业务库继续写入,快照和增量会重叠。重叠不可怕,幂等合并可以解决;可怕的是漏数据。所以位点要在快照开始前记录,不能在快照结束后才记录。

5. 常见坑

5.1 只按 update_time 拉增量

物理删除不会更新 update_time,永远拉不到。业务库如果用软删除(is_deleted),要确认删除操作会更新这个字段。

5.2 同一秒的数据重复或丢失

用 >= 拉增量但不记录上次最大时间,或者用 > 但事务提交顺序和时间戳不一致,都会出问题。CDC 用位点而不是时间戳,正是为了避开这个边界。

5.3 事件乱序

同一订单的更新事件可能因为分区、网络等原因乱序到达。合并时按 event_ts 和事件序号排序,取最新一条,而不是“最后到达的一条”。

5.4 schema 变更

源表加字段、改类型,CDC 任务可能直接失败或写出 null。上线前约定变更流程:先兼容新旧字段,再切换,最后清理旧字段。Flink CDC 新版本支持部分 schema evolution,但下游存储也要支持。

5.5 没有监控

至少监控三项:同步延迟(源库当前时间 - 已处理事件时间)、失败次数、数据量突增突降。没有监控的 CDC 任务,等业务发现报表不对时往往已经错了好几天。

6. 在线练习

题目 考点
196. 删除重复的电子邮箱 去重与保留最小主键
182. 查找重复的电子邮箱 重复数据识别
1173. 即时食物配送 I 🔒 状态与时间条件过滤

7. 面试延伸

面试题:全量同步和 CDC 增量同步怎么选?

是什么:全量简单但慢、压力大;CDC 实时、能捕获删除,但需要日志权限和位点管理。

解决什么问题:数据量增长后,全量窗口越来越长;CDC 把同步变成持续的小批量变更。

我的实操边界:本章用 DuckDB 模拟 CDC 合并,并用 Flink CDC 语法写过链路,未在生产 MySQL 上长期运行。

面试题:CDC 怎么保证不丢不重?

是什么:不丢靠持久化位点和至少一次投递;不重靠主键幂等、事件去重或分区覆盖。

解决什么问题:网络重试和任务重启必然带来重复,幂等比“绝不重复”更容易实现。

我的实操边界:本章演示了分区覆盖和最新事件合并,未实现两阶段提交。

面试题:物理删除怎么同步?

是什么:日志型 CDC 能捕获 DELETE;查询型同步要靠软删除字段或定期全量比对。

解决什么问题:数仓里保留历史,同时让下游知道这条记录已经失效。

我的实操边界:本章用 op = 'D' 过滤当前表,未实现墓碑表和保留策略。

8. 本章验收

  • 能说出基于日志和基于查询的 CDC 各自优缺点。
  • 能解释 binlog 位点、GTID 在增量同步中的作用。
  • 能用窗口函数从事件表取每个订单的最新有效状态。
  • 能写出“先删分区再写入”的幂等 SQL,并说明为什么要放在事务里。
  • 能说出物理删除的两种处理方式及其代价。
  • 能列出至少三个 CDC 同步监控指标。
本章任务0 / 6
本页目录