flink + kafka 同步binlog的一次关于数据顺序的实践

场景说明

​ 业务部门的Mysql数据使用阿里DTS(数据传输工具)上报了binlog日志。对于数仓部门有两个需求

  1. 从DTS将数据,只保留type(insert,delete,update)跟record到kafka方便多方消费;
  2. 为了验证Kafka准确性,数仓部门也消费kafka数据落地到Hologres,对比hologres与mysql原表保证完全一致。

问题

  1. DTS端并行度为1,但是ETL过程因为下游算子有多个并行度,有可能出现数据乱序(DTS->Kafka乱序)
  2. 消费Kafka的时候由于Kafka有多个分区,同样存在乱序。

DTS->kafka 局部有序

需要因为消费是通过type关联主键执行sql,所以只要保证相同id相邻数据不出现乱序即可。

dts单个并行度是有序,那么只要在让相同id的数据被路由到同一个TaskManager来处理,就可以避免TM之间传输先后造成的乱序,就可以局部有序。

这里主要使用了Flink自定义分区器,把相同主键分到一个TaskManager。

import org.apache.flink.api.common.functions.Partitioner

//定义一个自定义的分区器,按照传入的键分区,PK键是String类型。
object CustomPartitioner extends Partitioner[String] {
   //numPartitions是下游算子数量,相同key的hashCode相同,就可以保证相同key会被发往相同TM。
  override def partition(key: String, numPartitions: Int): Int = {
    key.hashCode % numPartitions
  }
}


dtsStream
//重点是这个 partitionCustom,需要两个参数,第一个是分区键的分区策略,第二个是如何从流里获取分区键
      .partitionCustom(CustomPartitioner, x=> MetaInfoUtil.getPkOrUk(x))
      ....  //Other ETL 
	  .addSink(kafkaProducer)
// kafkaSink也要保证相同id会被存到kafka的相同partition中(在Kafka的Schema指定)
    env.execute(jobName)

kafka指定分区策略Demo

package com.taptap.data.stream.util

import com.alibaba.fastjson.{JSON, JSONObject}
import com.alibaba.fastjson.serializer.SerializerFeature
import org.apache.flink.streaming.connectors.kafka.KafkaSerializationSchema
import org.apache.kafka.clients.producer.ProducerRecord
import org.apache.kafka.common.header.internals.{RecordHeader, RecordHeaders}

import java.lang

class DtsKafkaSerializationSchema extends KafkaSerializationSchema[JSONObject] {

  override def serialize(element: JSONObject, timestamp: lang.Long): ProducerRecord[Array[Byte], Array[Byte]] = {
    val key = MetaInfo.getPuOrUk
    val headers = element.getJSONObject("headers")
    val kafkaHeaders = new RecordHeaders()
    val binlogType = headers.getString("binlog.type")
    val binlogDatabase = headers.getString("binlog.database")
    val binlogTable = headers.getString("binlog.table")
      //把元信息放到Header里
    kafkaHeaders.add(new RecordHeader("binlog.type", binlogType.getBytes()))
    kafkaHeaders.add(new RecordHeader("binlog.database", binlogDatabase.getBytes()))
    kafkaHeaders.add(new RecordHeader("binlog.table", binlogTable.getBytes()))

    val message = element.getJSONObject("message")
    val messageJsonString = JSON.toJSONString(message,SerializerFeature.WriteMapNullValue)
    val record = new ProducerRecord(
      topicName,
      null,
      timestamp,
      key.getBytes(), // 在这里指定分区键,这里是PkorUK
      messageJsonString.getBytes(),
      kafkaHeaders
    )

    record
  }
}

Kafka->hologres 窗口内排序

为了保证partition之间按照时间戳顺序,取了一个小窗口,组内排序。

//使用kafka数据源自带的watermarker,保证局部有序
kafkaSource.assignTimestampsAndWatermarks(WatermarkStrategy
      .forBoundedOutOfOrderness[JSONObject](Duration.ofSeconds(10)))

val insertTag: OutputTag[JSONObject] = new OutputTag[JSONObject]("insert")
val deleteTag: OutputTag[JSONObject] = new OutputTag[JSONObject]("delete")
val updateTag: OutputTag[JSONObject] = new OutputTag[JSONObject]("update")

//定义窗口内的processFunction
	val myProcessAllFunction = new ProcessAllWindowFunction[JSONObject, JSONObject, 	TimeWindow] {
      	override def process(context: Context, elements: Iterable[JSONObject], out: Collector[JSONObject]): Unit = {
        //按照时间戳排序
        	val sorted = elements.toList.sortBy(_.getLong("timestamp"))
        	for (i <- sorted) {
          	val tp = i.getString("binlog.type")
          	tp match {
            	case "insert" => context.output(insertTag, i)
            	case "update" => context.output(updateTag, i)
            	case "delete" => context.output(deleteTag, i)
          		}
        	}
      	}
  	}

	//允许迟到数据
	val lateness = new OutputTag[JSONObject]("Lateness")

	//主要逻辑
    val sideOut: DataStream[JSONObject] = streamSource
      .windowAll(TumblingEventTimeWindows.of(Time.seconds(3)))
      .allowedLateness(Time.seconds(10))
      .sideOutputLateData(lateness)
      .process(myProcessAllFunction)
    val insertStream: DataStream[JSONObject] = sideOut.getSideOutput(insertTag)
    val updateStream = sideOut.getSideOutput(updateTag)
    val deleteStream = sideOut.getSideOutput(deleteTag)

	//处理迟到数据
	sideOut.getSideOutput(lateness)
      .windowAll(TumblingEventTimeWindows.of(Time.seconds(3)))
      .process(myProcessAllFunction)

	//根据类型落sink
	insertStream.addSink(insertSink)
    updateStream.addSink(updateSink)
    deleteStream.addSink(deleteSink)
    env.execute()


你的理解基本正确,但需要更精确的技术实现方式。使用 **Docker 方法 3** 可以搭建 **Flink + MySQL + Kafka 的完整实时数据管道**,但直接从 MySQL 抽取数据再写回 MySQL 需要额外组件(如 **Flink CDC**)。以下是具体实现方案: --- ### **1. 核心概念澄清** - **Flink 本身不能直接读取 MySQL 的增量数据**(Binlog),需要 **Flink CDC** 组件。 - **典型架构**: ``` MySQL → (Flink CDC) → Flink → (计算/转换) → 另一个 MySQL ``` --- ### **2. 使用 Docker Compose 搭建完整环境** #### **(1) 编写 `docker-compose.yml`** ```yaml version: '3' services: # MySQL 源数据库 mysql_source: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: test ports: - "3306:3306" # MySQL 目标数据库 mysql_sink: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: test_output ports: - "3307:3306" # Flink (JobManager + TaskManager) flink-jobmanager: image: apache/flink:1.16.0-scala_2.12 ports: - "8081:8081" command: jobmanager depends_on: - mysql_source - mysql_sink flink-taskmanager: image: apache/flink:1.16.0-scala_2.12 command: taskmanager depends_on: - flink-jobmanager scale: 2 # 启动2个TaskManager # 可选:Kafka(如果需要中间缓冲) zookeeper: image: confluentinc/cp-zookeeper:7.0.0 environment: ZOOKEEPER_CLIENT_PORT: 2181 kafka: image: confluentinc/cp-kafka:7.0.0 depends_on: - zookeeper ports: - "9092:9092" environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 ``` #### **(2) 启动环境** ```bash docker-compose up -d ``` 访问 `http://localhost:8081` 进入 Flink WebUI。 --- ### **3. 实现 MySQL → Flink → MySQL 的数据同步** #### **(1) 使用 Flink CDC 连接器** ```sql -- 在 Flink SQL Client 中执行 -- 1. 创建 MySQL 源表(读取 Binlog) CREATE TABLE mysql_source ( id INT, name STRING, PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = 'mysql_source', 'port' = '3306', 'username' = 'root', 'password' = '123456', 'database-name' = 'test', 'table-name' = 'users' ); -- 2. 创建 MySQL 目标表 CREATE TABLE mysql_sink ( id INT, name STRING, PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://mysql_sink:3306/test_output', 'username' = 'root', 'password' = '123456', 'table-name' = 'users_output' ); -- 3. 启动同步任务 INSERT INTO mysql_sink SELECT * FROM mysql_source; ``` #### **(2) 验证数据同步** ```bash # 进入 MySQL 源库插入数据 docker exec -it mysql_source mysql -uroot -p123456 INSERT INTO test.users VALUES (1, 'Alice'); # 查询目标库 docker exec -it mysql_sink mysql -uroot -p123456 SELECT * FROM test_output.users_output; -- 应看到同步数据 ``` --- ### **4. 关键注意事项** 1. **Flink CDC 要求 MySQL 开启 Binlog** ```sql -- 在 MySQL 中确认 SHOW VARIABLES LIKE 'log_bin'; -- 必须为 ON ``` 2. **性能调优** - 增加并行度:`SET 'parallelism.default' = '2';` - 使用 Checkpoint:`SET 'execution.checkpointing.interval' = '10s';` 3. **扩展场景** - 如果需要复杂计算(如聚合、JOIN),可在 Flink SQL 中处理后再写入目标库。 ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值