多源异构数据库实时同步至 Doris

---基于 Kafka + Flink 的实时 CDC 数据集成方案

总体架构设计

整体架构自左向右分为"数据源层 → 采集层(CDC)→ 消息层(Kafka)→ 计算层(Flink)→ 存储层(Doris)→ 应用层",并由贯穿全链路的"监控与治理层"提供可观测性保障。在迁移灰度期间,原有 Spark 离线链路继续并行保留,作为数据兜底与全量校验基准,待实时链路稳定验证后再逐步下线。

架构分层说明

|------------|------------------------------------------------------------------------------|
| 分层 | 说明 |
| 数据源层 | 各项目的 MySQL、MongoDB、Elasticsearch 等业务数据库/存储,是数据的权威来源(Source of Truth)。 |
| 采集层(CDC) | 以最小侵入方式捕获数据变更:MySQL 基于 Binlog,MongoDB 基于 Change Stream/oplog,ES 基于业务双写或轮询采集。 |
| 消息层(Kafka) | 作为统一的数据总线,解耦采集端与计算端,提供削峰填谷、多消费者、可回放的能力。 |
| 计算层(Flink) | 承担数据清洗、类型转换、维表关联、多表合并、Schema 适配等实时计算逻辑,并将结果写入 Doris。 |
| 存储层(Doris) | 作为统一的实时数据仓库,承接 Upsert/Delete 语义,对外提供实时查询能力。 |
| 应用层 | 实时看板、BI 报表、实时风控、数据服务接口等下游应用。 |
| 监控与治理层 | 贯穿全链路的 Schema 管理、延迟监控、Lag 监控、失败告警、数据一致性对账等能力。 |

核心设计原则

  • **最小侵入:**源库改造优先选择 CDC 方式接入,业务侧无需大改动;仅当 CDC 不可行时(如 ES)才采用业务双写方案。
  • **消息格式统一:**Kafka 消息统一采用 Debezium 风格的 Envelope 结构(before / after / op / ts_ms / source 等),便于 Flink 端用统一的逻辑处理增删改语义。
  • **顺序性保证:**同一主键的多条变更消息必须保证消费顺序,通过 Kafka 消息 Key(库名.表名.主键 或其 hash)保证落在同一分区。
  • **幂等与 Exactly-once:**通过 Flink Checkpoint + Kafka 幂等/事务生产 + Doris 主键模型 Upsert,实现端到端精确一次或"至少一次 + 幂等"的等效效果。
  • **全量增量无缝衔接:**接入 CDC 前必须完成存量数据的全量导入,并确保全量快照位点(binlog position / resume token)与后续增量精确衔接,避免数据断档或重复。

分数据源接入方案

MySQL → Kafka

推荐使用 Flink CDC Connector(内核基于 Debezium,具备全量快照 + 增量 Binlog 一体化能力)或独立部署 Debezium/Canal + Kafka Connect,二者可结合实际情况选用:若已有成熟的 Flink 实时平台且下游只有 Flink 一个消费方,优先使用 Flink CDC Connector;若需要将 MySQL 变更统一落一份 Kafka 供多个下游系统消费,推荐 Debezium/Canal + Kafka Connect 模式。

关键步骤:

  1. 开启 MySQL Binlog,格式设置为 ROW,并将 binlog_row_image 设置为 FULL,保证 UPDATE 事件能拿到变更前后的完整字段。
  2. 创建专用复制账号,仅授予 REPLICATION SLAVE / REPLICATION CLIENT / SELECT 等最小权限,与业务账号权限隔离。
  3. 优先对接从库(Read Replica)订阅 Binlog,而非直连主库,降低对主库资源的争抢和影响。
  4. 部署 CDC 组件,通过 include.list / table.include.list 精确圈定同步范围,避免采集无关库表。
  5. 首次启动执行全量快照(Snapshot),大表建议使用无锁的增量快照(Incremental Snapshot)算法,支持断点续传。
  6. 快照完成后自动切换到增量 Binlog 订阅,消息按库表拆分写入对应 Kafka Topic。
  7. 消息 Key 设置为表主键,保证同一行记录的变更严格落在同一分区,消费端有序处理。

MongoDB → Kafka

1、使用 MongoDB Change Streams(基于 oplog 实现,需副本集或分片集群,MongoDB 3.6+ 支持)配合 Debezium MongoDB Connector 或 Flink CDC MongoDB Connector 实现变更捕获。

关键步骤:

  1. 确认 MongoDB 部署为副本集或分片集群(单机部署不支持 Change Stream/oplog 订阅)。
  2. 适当调大 oplog 窗口(oplog size),保证消费方短暂中断后仍可通过 resume token 从断点续订,避免因 oplog 被覆盖导致断流。
  3. 创建专用只读账号,仅授予 changeStream / find 等最小权限。
  4. 部署 CDC 组件,配置需要监听的 database/collection 白名单。
  5. 首次启动执行全量 Collection 扫描(全量快照),并记录快照时刻的 resume token 作为增量订阅起点。
  6. 快照完成后切换到 Change Stream 增量订阅,insert/update/replace/delete 事件统一写入 Kafka。
  7. 对 update 事件开启 fullDocument: "updateLookup"(或 0+ 的 fullDocumentBeforeChange),获取变更后的完整文档,避免下游 Upsert 时字段缺失。

2、或者使用mongo shake,实施过程中,发现mongo shake更好用。

作业设计:

  • 每个业务域/项目建议拆分为独立的 Flink 作业(或按 Doris 目标表拆分),避免"一个大作业挂了影响全部同步链路",也便于独立扩缩容和版本升级。
  • 开启 Checkpoint(建议间隔 10~60 秒,依据延迟容忍度和状态大小权衡),结合 Kafka Offset 提交与 Doris Sink 的幂等写入,实现端到端 Exactly-once 或"至少一次 + 幂等"的等效效果。
  • 作业升级/重启统一基于 Savepoint 进行,保证 Kafka Offset 和内部状态一致恢复,避免重复消费或丢数据。

数据处理:

  • 统一制定类型映射规范(MySQL 的 DECIMAL/DATETIME、MongoDB 的 ObjectId/日期/内嵌文档、ES 的动态字段等如何映射为 Doris 列类型)。
  • 维表关联(Lookup Join)用于补充维度信息时,建议使用 Doris/Redis 作为维表存储并开启异步 IO + 本地缓存,避免同步查询成为吞吐瓶颈。
  • 多表 Join/宽表构建场景需评估 State 大小及 TTL 设置,防止状态无限增长导致 Checkpoint 变慢甚至 OOM。
  • 统一处理增删改事件(op=c/u/d):插入/更新事件转换为 Doris 的 Upsert,删除事件转换为携带删除标记的行(配合 Doris Batch Delete)或触发物理删除。

Schema 变更感知:

  • 引入统一的元数据/Schema 注册中心,监听源端 DDL 变更事件(Debezium 原生支持捕获 DDL),自动或半自动触发 Doris 目标表加字段及 Flink 作业 Schema 刷新,避免人工遗漏导致同步中断。

反压与资源:

  • 持续监控 Flink Web UI 的反压指标与 Kafka 消费 Lag,合理设置并行度、Buffer 大小;Doris 写入吞吐不足会反压至 Kafka Source,导致 Lag 持续增大,是常见瓶颈点。

数据延迟评估

数据在实时链路中流转的各环节延迟拆解如下(正常运行、无明显积压场景下的经验值,具体数值需结合实际压测结果校准):

|----------------------------------------------|-------------------------|----------------------------|
| 环节 | 典型延迟 | 说明 |
| ① 源库变更产生 → Binlog/oplog 落盘 | < 100 ms | 取决于源库自身的日志刷盘策略,通常极快 |
| ② CDC 捕获(Debezium/Flink CDC 读取 Binlog/oplog) | 100 ms ~ 1 s | 主从延迟较高或存在大事务时可能升至数秒甚至数十秒 |
| ③ 写入 Kafka(含网络传输 + Broker 落盘) | 十几 ms ~ 百 ms | 取决于 acks 策略与 Broker 负载 |
| ④ Flink 消费与处理(含攒批、维表关联等待) | 几百 ms ~ 数秒 | 涉及双流 Join/较大乱序容忍窗口时可能升至数十秒 |
| ⑤ Doris Stream Load 写入并可查询 | 1 ~ 5 s 一批,写入耗时百ms~1s | 取决于攒批策略(条数/时间阈值)设定 |
| 端到端总延迟(正常情况,无复杂 Join) | 约 3 ~ 10 秒 | 满足绝大部分实时看板、实时运营场景需求 |
| 端到端总延迟(含维表关联/多流 Join) | 约 10 ~ 30 秒 | 视 Watermark 乱序容忍窗口大小而定 |
| 异常情况(主从延迟高/Kafka积压/Flink反压/Doris导入排队) | 分钟级 ~ 数十分钟 | 需配合监控告警快速发现并处理,避免影响扩大 |

监控与治理体系

|-----------------|-------------------------------------------------------|
| 监控项 | 说明 |
| 端到端延迟监控 | 在消息体中埋入源端变更时间戳,逐环节打点计算耗时,构建端到端延迟大盘 |
| Kafka 消费 Lag 监控 | 监控各 Topic/分区的消费 Lag,设定阈值告警,及时发现消费能力不足或作业异常 |
| Flink 作业监控 | 监控 Checkpoint 成功率/耗时、反压指标、TaskManager 资源使用率,异常自动告警 |
| Doris 导入监控 | 监控 Stream Load 成功率、耗时、失败原因分类,以及版本数/Compaction 状态 |
| 数据一致性对账 | 定时(如每小时/每日)对源库与 Doris 做行数、聚合值、抽样明细的比对,发现差异及时告警并支持一键补数 |
| Schema 变更审计 | 记录所有源端 DDL 变更与下游同步操作日志,便于问题追溯 |

flink 写入doris代码实例:

java 复制代码
package com.xiaoxi.example.kafkatodoris;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import org.apache.doris.flink.cfg.DorisExecutionOptions;
import org.apache.doris.flink.cfg.DorisOptions;
import org.apache.doris.flink.cfg.DorisReadOptions;
import org.apache.doris.flink.sink.DorisSink;
import org.apache.doris.flink.sink.writer.serializer.SimpleStringSerializer;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.CheckpointConfig;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Properties;
import java.util.UUID;


public class KafkaLookupDorisExample {
    private static final String KAFKA_BOOTSTRAP = "host01:9092,host02:9092,host03:9092";
    private static final String KAFKA_USERNAME = "kafka_user";
    private static final String KAFKA_PASSWORD = "kafka_password";
    private static final String KAFKA_TOPIC = "demo_topic";
    private static final String KAFKA_GROUP = "flink_demo_group";
    private static final String MYSQL_URL =
            "jdbc:mysql://host01:3306/demo_db?useUnicode=true&characterEncoding=UTF-8"
                    + "&serverTimezone=Asia/Shanghai&useSSL=false";
    private static final String MYSQL_USER = "mysql_user";
    private static final String MYSQL_PASSWORD = "mysql_password";
    private static final String MYSQL_LOOKUP_SQL =
            "select order_id, user_id, amount from demo_order where order_id = ?";
    private static final String DORIS_FENODES = "host01:8030,host02:8030";
    private static final String DORIS_USER = "doris_user";
    private static final String DORIS_PASSWORD = "doris_password";
    private static final String DORIS_TABLE = "demo_db.demo_table";
    private static final long CHECKPOINT_INTERVAL_MS = 600_000L;
    private static final String CHECKPOINT_DIR = "hdfs://namenode/flink/checkpoints/demo_job";
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.enableCheckpointing(CHECKPOINT_INTERVAL_MS);
        CheckpointConfig ck = env.getCheckpointConfig();
        ck.setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE);
        ck.setCheckpointTimeout(3 * 60 * 60_000L);
        ck.setMinPauseBetweenCheckpoints(CHECKPOINT_INTERVAL_MS);
        ck.setTolerableCheckpointFailureNumber(5);
        ck.setMaxConcurrentCheckpoints(1);
        ck.setExternalizedCheckpointCleanup(
                CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
        ck.setCheckpointStorage(CHECKPOINT_DIR);
        env.setStateBackend(new EmbeddedRocksDBStateBackend(true));
        KafkaSource<String> kafkaSource = KafkaSource.<String>builder()
                .setBootstrapServers(KAFKA_BOOTSTRAP)
                .setTopics(KAFKA_TOPIC)
                .setGroupId(KAFKA_GROUP)
                .setStartingOffsets(OffsetsInitializer.latest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .setProperties(kafkaProps())
                .build();
        DataStream<String> stream = env.fromSource(
                        kafkaSource,
                        WatermarkStrategy.noWatermarks(),
                        "kafka-source")
                .process(new LookupProcess())
                .name("mysql-lookup");
        stream.sinkTo(buildDorisSink())
                .name("doris-sink");
        env.execute("kafka_lookup_doris_example");
    }
    private static Properties kafkaProps() {
        Properties props = new Properties();
        props.setProperty(
                "sasl.jaas.config",
                "org.apache.kafka.common.security.plain.PlainLoginModule required username=\""
                        + KAFKA_USERNAME
                        + "\" password=\""
                        + KAFKA_PASSWORD
                        + "\";");
        props.setProperty("security.protocol", "SASL_PLAINTEXT");
        props.setProperty("sasl.mechanism", "PLAIN");
        return props;
    }
    private static DorisSink<String> buildDorisSink() {
        Properties loadProps = new Properties();
        loadProps.setProperty("format", "json");
        loadProps.setProperty("read_json_by_line", "true");
        return DorisSink.<String>builder()
                .setDorisOptions(DorisOptions.builder()
                        .setFenodes(DORIS_FENODES)
                        .setTableIdentifier(DORIS_TABLE)
                        .setUsername(DORIS_USER)
                        .setPassword(DORIS_PASSWORD)
                        .build())
                .setDorisReadOptions(DorisReadOptions.builder().build())
                .setDorisExecutionOptions(DorisExecutionOptions.builder()
                        .setLabelPrefix("demo-label-" + UUID.randomUUID())
                        .setDeletable(false)
                        .setBufferFlushMaxRows(50000)
                        .setBufferFlushIntervalMs(60_000L)
                        .setMaxRetries(3)
                        .setStreamLoadProp(loadProps)
                        .build())
                .setSerializer(new SimpleStringSerializer())
                .build();
    }
    /**
     * 用 Kafka 里的 orderId 回查 MySQL,输出 Doris 可用的 JSON。
     */
    private static class LookupProcess extends ProcessFunction<String, String> {
        private transient Connection conn;
        private transient PreparedStatement ps;
        @Override
        public void open(Configuration parameters) throws Exception {
            Class.forName("com.mysql.cj.jdbc.Driver");
            connect();
        }
        private void connect() throws Exception {
            closeQuietly();
            conn = DriverManager.getConnection(MYSQL_URL, MYSQL_USER, MYSQL_PASSWORD);
            ps = conn.prepareStatement(MYSQL_LOOKUP_SQL);
        }
        private void ensureConn() throws Exception {
            if (conn == null || conn.isClosed() || ps == null || ps.isClosed() || !conn.isValid(3)) {
                connect();
            }
        }
        @Override
        public void processElement(String value, Context ctx, Collector<String> out) throws Exception {
            if (value == null || value.trim().isEmpty()) {
                return;
            }
            JSONObject json = JSON.parseObject(value);
            String orderId = json.getString("orderId");
            if (orderId == null || orderId.isEmpty()) {
                return;
            }
            DemoRow row = lookup(orderId);
            if (row == null) {
                return;
            }
            out.collect(JSON.toJSONString(row));
        }
        private DemoRow lookup(String orderId) throws Exception {
            try {
                return doLookup(orderId);
            } catch (java.sql.SQLException e) {
                connect();
                return doLookup(orderId);
            }
        }

最后flink程序的延迟,由Prometheus接入kafka export,获取topic的offset来监控程序的延迟,并且配置告警,程序出现延迟可以发送邮件或者群消息

相关推荐
zyseo81 小时前
谷歌SEO 站内搜索优化实战:把站内搜索词变成关键词金矿
java·服务器·数据库
qq_401700411 小时前
Qt 串口/网口通信:Hex 与 ASCII 编码转换深度指南
开发语言·数据库·qt
王大傻09281 小时前
堆叠注入(Stacked Queries Injection)详解:原理、利用与防御
服务器·网络·数据库·web安全·网络安全
ClouGence1 小时前
数据库迁移工具 CloudCanal v6.5.0.0 发布:新增 TDSQL PostgreSQL 多条链路,支持 MongoDB 双向同步
数据库·mysql·mongodb
AIGS0012 小时前
从814张表到250个本体:本体建模在做什么
数据库·excel·erp·数据中台·智能问数·本体语义·企业大脑
fengkai45452 小时前
十二、Redis -1
运维·数据库·redis
用户3610588626123 小时前
Flink Time 之时间语义深度剖析:从 Processing Time 到 Event Time 与 Watermark 机制
大数据·flink
这个DBA有点耶3 小时前
Change Buffer深入:二级索引写入的隐形加速器与它的代价
数据库·mysql·代码规范
for_ever_love__3 小时前
MySQL 事务隔离级别讲透:MVCC、幻读与四个级别怎么选
java·数据库·mysql·事务·mvcc·不可重复读·幻读