此项目代码地址: 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 或其他消息队列,方便消费者进行数据处理和分析。)DebeziumCannel(阿里开源项目,夹带私货多,不推荐)FlinkCDC
消息队列
binlog 获取到之后,需要将获取到的数据发送到消息队列,这里就有一个问题,为什么还需要引入消息队列:
- 解耦架构:消息队列将 Maxwell 和 Logstash 解耦,让它们独立运行。Maxwell 专注于监听 MySQL 的 binlog,Logstash 按需消费,互不影响,系统更稳定。
- 削峰填谷:RabbitMQ 充当缓冲器,在数据库更新突发高峰时可以积压消息,防止瞬时写入压垮 Elasticsearch,提高整体吞吐能力。
- 增加容错能力:当 Logstash 或 Elasticsearch 出现故障时,消息依然保存在队列中,待服务恢复后自动继续消费,不易丢数据。
- 支持多消费者: RabbitMQ 支持发布/订阅模式,一份数据可以同时被多个系统消费,如:一个服务写入 Elasticsearch,一个服务更新缓存,一个服务触发通知
- 可观测、可控制: RabbitMQ 提供可视化界面,支持查看队列积压、消费速率,有利于系统监控与调优。
数据代理转换器(agent)
- Logstash
- Filebeat
- Fluentd
- Vector(只能增加,不能修改)
增量数据同步
只有增量同步代码地址:github
准备 mysql
Maxwell 的原理是模拟一个 MySQL 的从库,所以必须得让主库开启 binlog。
开启 Binlog
打开 MySQL 配置文件(通常是 my.cnf 或 my.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 语句)等
- Maxwell本身支持全量数据导出,通过
-
数据库导出+批量导入,利用
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 字段里),也没有 database、table 字段,所以就不需要其他的处理。
设置管道
当我们存在多个管道的时候,需要配置 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