异构数据库数据同步最佳实践

219 阅读7分钟

此项目代码地址: github

背景

在日常项目中,我们经常需要将 MySQL 中的数据同步到 Elasticsearch,以支持复杂的搜索或数据分析需求。传统的方式往往是定时批量同步,但面对实时性要求较高的场景,这种方式显得不够灵活。

本文将介绍如何通过 Maxwell 监听 MySQL 的 binlog,并借助 RabbitMQ 与 Logstash 实现实时数据同步到 Elasticsearch 的完整流程。除了 MySQL,我们将使用 Docker Compose 管理所有服务,力求部署简单、逻辑清晰、便于扩展。架构如下。

    +-------+      +---------+      +-----------+      +----------+      +---------------+
    | MySQL |----->| Maxwell |----->| RabbitMQ  |----->| Logstash |----->| Elasticsearch |
    +-------+      +---------+      +-----------+      +----------+      +---------------+
       ^                                                                       |
       |                                                                       |
     (Binlog)                                                                  |
                                                                               |
                                                                         +---------+
                                                                         | Kibana  |
                                                                         +---------+

相关技术

binlog 监听方案

目前,监听 binlog 的主流方案有:

  • Maxwell (可以独立运行,将 MySQL 的 binlog 解析成易于理解、易于使用的JSON 格式,并将其发送到 Kafka 或其他消息队列,方便消费者进行数据处理和分析。)
  • Debezium
  • Cannel (阿里开源项目,夹带私货多,不推荐)
  • FlinkCDC

消息队列

binlog 获取到之后,需要将获取到的数据发送到消息队列,这里就有一个问题,为什么还需要引入消息队列:

  1. 解耦架构:消息队列将 Maxwell 和 Logstash 解耦,让它们独立运行。Maxwell 专注于监听 MySQL 的 binlog,Logstash 按需消费,互不影响,系统更稳定。
  2. 削峰填谷:RabbitMQ 充当缓冲器,在数据库更新突发高峰时可以积压消息,防止瞬时写入压垮 Elasticsearch,提高整体吞吐能力。
  3. 增加容错能力:当 Logstash 或 Elasticsearch 出现故障时,消息依然保存在队列中,待服务恢复后自动继续消费,不易丢数据。
  4. 支持多消费者: RabbitMQ 支持发布/订阅模式,一份数据可以同时被多个系统消费,如:一个服务写入 Elasticsearch,一个服务更新缓存,一个服务触发通知
  5. 可观测、可控制: RabbitMQ 提供可视化界面,支持查看队列积压、消费速率,有利于系统监控与调优。

数据代理转换器(agent)

  • Logstash
  • Filebeat
  • Fluentd
  • Vector(只能增加,不能修改)

增量数据同步

只有增量同步代码地址:github

准备 mysql

Maxwell 的原理是模拟一个 MySQL 的从库,所以必须得让主库开启 binlog

开启 Binlog

打开 MySQL 配置文件(通常是 my.cnfmy.ini),在 [mysqld] 部分下添加或修改以下配置:

# my.cnf

[mysqld]
# 开启 binlog
log-bin=mysql-bin
# 设置 server_id,必须是唯一的整数
server_id=1
# 设置 binlog 格式为 ROW
binlog_format=ROW
# (可选,推荐) 包含完整的行镜像,便于获取变更前后的数据
binlog_row_image=FULL

重启mysql查看是否生效 sudo systemctl restart mysqld

select @@log_bin
-- 1

select @@binlog_format
--ROW

如果要监听的数据库开启了主从同步,并且不是主数据库,需要再从数据开启binlog联级同步

#/etc/mysql/my.cnf

log_slave_updates = 1

创建 Maxwell 专用账户

为了安全起见,我们不应该使用 root 用户。应该为 Maxwell 创建一个专用用户,并授予它所需的权限。

-- 创建一个名为 'maxwell' 的数据库,Maxwell 将用它来存储自己的状态信息
CREATE DATABASE maxwell;

-- 创建用户 'maxwell' 并设置密码
CREATE USER 'maxwell'@'%' IDENTIFIED BY 'your_strong_password';

-- 授予 Maxwell 管理其自身数据库的权限
GRANT ALL ON maxwell.* TO 'maxwell'@'%';

-- 授予 Maxwell 读取 binlog 和复制数据的核心权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'maxwell'@'%';

-- 刷新权限使其立即生效
FLUSH PRIVILEGES;

服务编排 (Docker Compose)

现在,有趣的部分来了。我们将使用一个 docker-compose.yml 文件来定义和启动 Elasticsearch、Kibana、RabbitMQ 和 Logstash。

目录结构如下:

.
├── docker-compose.yml
├── .env  # 配置文件
├── elasticsearch
│   ├── config
│   │   └── elasticsearch.yml
│   ├── Dockerfile
│   └── init-users.sh  # 如果需要kibana,则需要执行该脚本,为kibana设置登录密码
├── kibana
│   ├── config
│   │   └── kibana.yml
│   └── Dockerfile
├── logstash
│   ├── config
│   │   └── logstash.yml
│   ├── Dockerfile
│   └── pipeline
│       └── logstash.conf
├── maxwell
│   ├── config
│   │   └── config.properties
│   └── Dockerfile
├── rabbitmq
│   ├── config
│   │   └── rabbitmq.conf
│   └── Dockerfile
└── README.md

.env

# 使用的es版本
ELASTIC_VERSION=8.18.4
# 使用的rabbitmq版本
RABBITMQ_VERSION=4-management
# 使用的maxwell版本
MAXWELL_VERSION=latest

## Passwords for stack users
#

# User 'elastic' (built-in)
#
# Superuser role, full access to cluster management and data indices.
# https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html
ELASTIC_PASSWORD='changeme'

# User 'logstash_internal' (custom)
#
# The user Logstash uses to connect and send data to Elasticsearch.
# https://www.elastic.co/guide/en/logstash/current/ls-security.html
LOGSTASH_INTERNAL_PASSWORD='changeme'

# User 'kibana_system' (built-in)
#
# The user Kibana uses to connect and communicate with Elasticsearch.
# https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html
KIBANA_SYSTEM_PASSWORD='changeme'

docker-compose.yml

services:
  elasticsearch:
    build:
      context: elasticsearch/
      args:
        ELASTIC_VERSION: ${ELASTIC_VERSION}
    container_name: elasticsearch
    volumes:
      - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z
      - ./elasticsearch/data:/usr/share/elasticsearch/data:Z
      - ./elasticsearch/init-users.sh:/usr/local/bin/init-users.sh:ro,Z
    ports:
      - 9200:9200
      - 9300:9300
    environment:
      node.name: elasticsearch
      ES_JAVA_OPTS: -Xms512m -Xmx512m
      discovery.type: single-node
      ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
      KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
    networks:
      - My2ES
    restart: unless-stopped
    command: >
      bash -c "/usr/local/bin/docker-entrypoint.sh &
             sleep 10 &&
             /usr/local/bin/init-users.sh &&
             wait"

  kibana:
    build:
      context: kibana/
      args:
        ELASTIC_VERSION: ${ELASTIC_VERSION}
    container_name: kibana
    volumes:
      - ./kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro,Z
    ports:
      - 5601:5601
    environment:
      KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
    networks:
      - My2ES
    depends_on:
      - elasticsearch
    restart: unless-stopped

  rabbitmq:
      build:
        context: ./rabbitmq
        args:
          RABBITMQ_VERSION: ${RABBITMQ_VERSION}
      container_name: rabbitmq
      ports:
        - 5672:5672
        - 15672:15672
      volumes:
      - ./rabbitmq/config/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
      networks:
        - My2ES
      restart: unless-stopped

  logstash:
    build:
      context: logstash/
      args:
        ELASTIC_VERSION: ${ELASTIC_VERSION}
    container_name: logstash
    volumes:
      - ./logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro,Z
      - ./logstash/pipeline:/usr/share/logstash/pipeline:ro,Z
    ports:
      - 5044:5044
      - 50000:50000/tcp
      - 50000:50000/udp
      - 9600:9600
    environment:
      LS_JAVA_OPTS: -Xms256m -Xmx256m
      LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
    networks:
      - My2ES
    depends_on:
      - elasticsearch
      - rabbitmq
    restart: unless-stopped

  maxwell:
    build:
      context: ./maxwell
    container_name: maxwell
    command: bin/maxwell --config /app/config/config.properties
    volumes:
    - ./maxwell/config/config.properties:/app/config/config.properties:ro
    networks:
      - My2ES
    depends_on:
      - rabbitmq
    restart: unless-stopped

networks:
  My2ES:
    driver: bridge

volumes:
  elasticsearch:

maxwell 配置

maxwell/maxwell/config/config.properties

# tl;dr config
log_level=info

# 指定需要将数据发送至哪里
producer=rabbitmq

# mysql配置
host=mysql的主机host
user=<用上面创建的maxwell的用户名>
password= <用上面创建的maxwell的密码>

#            *** rabbit-mq配置 ***
rabbitmq_host= rabbithost地址
rabbitmq_port=5672
rabbitmq_user= <username> # 默认为guest
rabbitmq_pass= <password> # 默认为guest
rabbitmq_virtual_host=/
rabbitmq_handshake_timeout=20000

rabbitmq_exchange=maxwell # 指定exchange,后面配置logstash的时候需要对应
rabbitmq_queue=maxwell # 指定queue,后面配置logstash的时候需要对应

rabbitmq_exchange_type=fanout
rabbitmq_exchange_durable=false
rabbitmq_exchange_autodelete=false
# rabbitmq_routing_key_template=%db%.%table%
rabbitmq_message_persistent=false
rabbitmq_declare_exchange=true
# rabbitmq_use_ssl=false


#          *** filtering ***
# 配置需要监听的数据库,我指定的是 test 下的所有表
#filter= exclude: *.*, include: foo.*, include: bar.baz, include: foo.bar.col_eg = "value_to_match"
filter= exclude: *.*, include: test.*

logstash 配置

按照自己的设置,

  • 替换 input.rabbitmq 的 host,user、password 等字段,
  • exchange,queue 等字段需要和 maxwell 配置的一样
  • 替换 output.elasticsearch.host 字段为自己的服务的 IP 地址

logstash/pipeline/logstash.conf

input {
  rabbitmq {
    host => "10.0.1.36"
    port => 5672
    user => "guest"
    password => "guest"
    queue => "maxwell"                 # 声明 queue
    exchange => "maxwell"              # 绑定到 exchange
    exchange_type => "fanout"          # 类型要匹配 maxwell 的配置
    auto_delete => false
    ack => true
    # 关键:直接在 input 中使用 json codec 解析消息体,这比在 filter 中处理更高效
    codec => "json"
  }
}

filter {
  # 在这里添加 grok 或 mutate 来处理 maxwell 的数据结构
  # 例如:添加 @timestamp 或提取字段
  # json {
  #   # source => "message"
  # }

  translate {
    source => "[type]"
    target => "[@metadata][action]"
    dictionary => {
      "insert" => "index"
      "bootstrap-insert" => "index"
      "update" => "update"
      "delete" => "delete"
    }
    fallback => "unknown"
  }

  # 如果事件类型未被成功映射 (例如 DDL 事件),则丢弃该事件
  if [@metadata][action === 'unknown'] {
    drop {}
  }

  mutate {
    add_field => { "[@metadata][es_index]" => "%{[database]}-%{[table]}" }
    add_field => { "[@metadata][es_id]" => "%{[data][id]}" }
  }

  mutate {
    lowercase => [ "[@metadata][es_index]" ]
  }

  # 处理datetime字段
  if [data][createdAt] {
    date {
      match => [ "[data][createdAt]", "yyyy-MM-dd HH:mm:ss" ]
      target => "[data][createdAt]"
    }
  }
  
  if [data][updatedAt] {
    date {
      match => [ "[data][updatedAt]", "yyyy-MM-dd HH:mm:ss" ]
      target => "[data][updatedAt]"
    }
  }

  if [@metadata][action] in ["index", "update"] {
    ruby {
      code => '
        # 获取 data 字段的值
        data_hash = event.get("data")
        # 检查 data_hash 是否是一个有效的哈希表 (Hash)
        if data_hash.is_a?(Hash)
          # 遍历哈希表,将其所有键值对设置到事件的顶层
          data_hash.each do |k, v|
            event.set(k, v)
          end
        end
      '
    }
  }

  mutate {
    # 删除掉不需要的字段 
    remove_field => [
      "message",
      "original",
      "@version",
      "@timestamp",
      "event",
      "database",
      "type",
      "ts",
      "xid",
      "commit",
      "data",
      "old",
      "table"
    ]
  }
}

output {
  elasticsearch {
    hosts => ["http://10.0.1.36:9200"]
    index => "%{[@metadata][es_index]}"
    document_id => "%{[@metadata][es_id]}"
    action => "%{[@metadata][action]}"
    user => "elastic"
    password => "changeme"
    # 开启重试机制,以防 ES 暂时不可用
    retry_on_conflict => 3
  }

  # 将结果打印到标准输出
  stdout {
    codec => rubydebug {
      # 将 metadata 选项设置为 true
      metadata => true
    }
  }
}

kibana 配置

kibana/config/kibana.yml

server.name: kibana
server.host: 0.0.0.0
elasticsearch.hosts: [ http://10.0.1.36:9200 ]
i18n.locale: "zh-CN"

monitoring.ui.container.elasticsearch.enabled: true
monitoring.ui.container.logstash.enabled: true

## Security credentials
elasticsearch.username: kibana_system
elasticsearch.password: ${KIBANA_SYSTEM_PASSWORD}

ES 配置

cluster.name: docker-cluster
network.host: 0.0.0.0

xpack.license.self_generated.type: trial
xpack.security.enabled: true

服务启动与检查

启动

通过如下命令启动服务

docker compose up -d

检查

分别查看各个容器的日志信息,检查服务是否启动成功的

docker logs -f 容器id
es 启动成功标志

rabbitmq 启动成功标志

maxwell 启动成功标志

logstash 启动成功标志

kibana 启动成功标志

访问 http://127.0.0.1:5601并出现如下界面

数据同步

当服务启动之后,首先检查 rabbitmq 的 queue 和 exchanges 是否绑定

访问 http://127.0.0.1:15672/

创建数据表

CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(36) NOT NULL,
  `age` int DEFAULT NULL,
  `createdAt` datetime DEFAULT CURRENT_TIMESTAMP,
  `updatedAt` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- 插入数据
INSERT INTO test.user VALUES (1, 'zs', 12, '2025-07-30 10:41:02', '2025-07-30 10:41:04');
INSERT INTO test.user VALUES (2, 'ls', 13, '2025-07-30 10:42:49', '2025-07-30 10:42:54');

查看 logstash 日志

此时,可以看到,我们的数据已经被 logstash 进行了解析,登录到 kibana,我们就可以看到 数据库名称-表名称的索引已经被创建

也推荐 chrome 插件 es-client, 可以更直观的看到数据

存量数据同步

更改的代码参考: github

上面的设置,Maxwell 只能从 binlog 中读取增量数据。为了保证 Elasticsearch 中的数据完整性,我们需要在系统上线之前,将 MySQL 中的存量数据一次性导入到 ES 中,实现存量数据同步,存量数据同步的方式有多种,如:

  • 使用Logstash JDBC插件直接同步存量数据

    • 这是最直接的方法,通过Logstash的JDBC输入插件一次性同步所有历史数据
    • 还能指定 cron 进行同步,防止数据丢失
  • Maxwell全量同步模式

    • Maxwell本身支持全量数据导出,通过maxwell-bootstrap命令
    • 缺点是 不支持复杂条件过滤(如 WHERE 语句)等
  • 数据库导出+批量导入,利用 mysqldump命令

    • 容易出错:格式转换、字段映射问题多

我们采用Logstash JDBC 的方式进行同步,此时,整个数据同步的系统架构就变为:

  • 增量数据同步:使用 Maxwell 监听 binlog,将变更推送至 RabbitMQ,Logstash 订阅队列并写入 ES。
  • 存量数据同步:使用Logstash JDBC插件直接同步存量数据

下载 jdbc

地址: dev.mysql.com/downloads/c…

选择对应的版本和平台

我们将其放在 logstash/drivers/mysql-connector-j-8.0.33.jar

配置 logstash

因为此时,我们是通过双通道进行数据同步的,所以需要配置双通道,原来增量同步的通道已经配置好,为了以示区别,我们改名如下:

logstash/pipeline/logstash.conf-> logstash/pipeline/logstash-rabbmitmq.conf

新增 jdbc 通道,在 input中,一个 jdbc的配置只能同步一张表,所以如果需要同步多张表的话,则需要配置多个jdbc,如下配置就是我同步 test 数据库下的 user 表和 regions 表。

# logstash/pipeline/logstash-jdbc.conf

input {
  jdbc {
    jdbc_driver_library => "/usr/share/logstash/drivers/mysql-connector-j-8.0.33.jar"
    jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
    jdbc_connection_string => "jdbc:mysql://10.0.1.36:3306/test?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
    jdbc_user => "root"
    jdbc_password => "12345678"
    # 指定同步的cron,(每分钟同步一次)
    schedule => "* * * * *"                                               
    statement => "SELECT * FROM user"
    # 这里可以增加一些原始信息,用于后续用到
    add_field => { "[@metadata][table]" => "user" }
    # 是否开启分页
    jdbc_paging_enabled => true
    # 每页的数据量                                        
    jdbc_page_size => 1000
    # 记录上次同步位置的文件                                                    
    last_run_metadata_path => "/usr/share/logstash/.logstash_jdbc_user_last_run"   
    # 是否保存上次执行状态
    record_last_run => true     
    # 启用增量字段                                              
    use_column_value => true       
     # 增量字段名(建议是主键或更新时间)                                           
    tracking_column => "id"                                                  
    tracking_column_type => "numeric"
    codec => "json"
  }
  jdbc {
    jdbc_driver_library => "/usr/share/logstash/drivers/mysql-connector-j-8.0.33.jar"
    jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
    jdbc_connection_string => "jdbc:mysql://10.0.1.36:3306/test?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
    jdbc_user => "root"
    jdbc_password => "12345678"
    schedule => "* * * * *"                                                   
    statement => "SELECT * FROM regions"
    add_field => { "[@metadata][table]" => "regions" }
    jdbc_paging_enabled => true                                               
    jdbc_page_size => 1000                                                    
    last_run_metadata_path => "/usr/share/logstash/.logstash_jdbc__regions_last_run"  
    record_last_run => true                                                   
    use_column_value => true                                             
    tracking_column => "id"                                                   
    tracking_column_type => "numeric"
    codec => "json"
  }
}

filter {

  mutate {
    replace => { "[@metadata][es_index]" => "test-%{[@metadata][table]}" }
    replace => { "[@metadata][es_id]" => "%{id}" }
    lowercase => [ "[@metadata][es_index]" ]
  }

# 处理时间字段(如果有)
  if [createdAt] {
    date {
      match => [ "createdAt", "yyyy-MM-dd HH:mm:ss" ]
      target => "createdAt"
    }
  }

  if [updatedAt] {
    date {
      match => [ "updatedAt", "yyyy-MM-dd HH:mm:ss" ]
      target => "updatedAt"
    }
  }

  mutate {
    remove_field => [
      "message",
      "original",
      "@version",
      "@timestamp",
      "table"
    ]
  }
}

output {
  elasticsearch {
    hosts => ["http://elasticsearch:9200"]
    index => "%{[@metadata][es_index]}"
    document_id => "%{[@metadata][es_id]}"
    user => "elastic"
    password => "changeme"
    # 开启重试机制,以防 ES 暂时不可用
    retry_on_conflict => 3
  }

  # 将结果打印到标准输出
  stdout {
    codec => rubydebug {
      # 将 metadata 选项设置为 true
      metadata => true
    }
  }
}

这里和增量数据不一样的地方是, logstash JDBC 的输入,它默认每条记录是一个扁平结构(不是嵌套在 data 字段里),也没有 databasetable 字段,所以就不需要其他的处理。

设置管道

当我们存在多个管道的时候,需要配置 pipelines.yml这个文件,如果不配置,logstash 则会将 pipeline下的多个管道合并称一个管道。文档

# logstash/config/pipelines.yml

- pipeline.id: jdbc
  path.config: "/usr/share/logstash/pipeline/logstash-jdbc.conf"
  pipeline.workers: 2

- pipeline.id: rabbitmq
  path.config: "/usr/share/logstash/pipeline/logstash-rabbitmq.conf"
  queue.type: persisted

修改 docker compose

主要修改 logstash 模块

……
 logstash:
    build:
      context: logstash/
      args:
        ELASTIC_VERSION: ${ELASTIC_VERSION}
    container_name: logstash
    volumes:
      - ./logstash/config/:/usr/share/logstash/config:ro,Z
      - ./logstash/pipeline:/usr/share/logstash/pipeline:ro,Z
      - ./logstash/drivers:/usr/share/logstash/drivers:ro,Z  # 挂载驱动目录
    ports:
      - 5044:5044
      - 50000:50000/tcp
      - 50000:50000/udp
      - 9600:9600
    environment:
      LS_JAVA_OPTS: -Xms256m -Xmx256m
      LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
    networks:
      - My2ES
    depends_on:
      - elasticsearch
      - rabbitmq
    restart: unless-stopped

……

遇到的问题

问题 1
java.sql.SQLException: Plugin 'mysql_native_password' is not loaded

MySQL 服务器未启用 mysql_native_password 插件,而你 JDBC 驱动尝试使用这个插件进行认证。

解决方案,进入 mysql,执行如下命令查看 mysql_native_password插件是否存在和启用

SHOW PLUGINS;

-- 如果不存在则执行
INSTALL PLUGIN mysql_native_password SONAME 'auth_mysql_native_password.so';

如果插件存在,首先启用该插件,修改 mysql 配置文件

[mysqld]
mysql_native_password=ON

然后重新启动 mysql,并设置密码以及较验插件。

ALTER USER 'your_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';

测试环境放宽密码策略,

-- 放宽密码校验策略(仅建议测试或开发环境)
-- 查看当前密码策略等级:
SHOW VARIABLES LIKE 'validate_password.policy';
-- 常见值说明:
-- 0: LOW, 1: MEDIUM(默认), 2: STRONG
-- 临时将其调低(如 LOW):
SET GLOBAL validate_password.policy = LOW;
-- 降低最小密码长度:
SET GLOBAL validate_password.length = 6;

如果想要永久生效,则加在 mysql 的配置文件中

[mysqld]
validate_password.policy=LOW
validate_password.length=6
问题 2
java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed

这是 MySQL 8 及以上版本的 JDBC 驱动为了安全默认禁止了“公钥检索”(Public Key Retrieval),当你使用 caching_sha2_password 或者某些认证方式时,如果没有在 JDBC URL 里显式允许公钥检索,就会报这个错。

只需要在 pipeline 的 JDBC 连接字符串后面添加参数:

allowPublicKeyRetrieval=true

如:

jdbc:mysql://10.0.1.36:3306/test?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true