简介:docker-compose.yml 配合 cnf 配置、sh 启动脚本与 sql 初始化文件,构成一套可直接运行的 MySQL 一主二从复制部署模板,面向使用 Docker Compose 搭建数据库复制环境的开发、运维与测试人员。8 个文件按 primary、replica1、replica2 目录组织,yml 定义容器编排与依赖关系,cnf 负责 server-id、log_bin、read-only 等主从关键参数,sh 和 sql 用于初始化与校验,整个压缩包仅 5KB,结构轻量,便于快速导入修改。已有 39 人学习下载。相比手工部署,拿到后可基于官方 mysql 镜像在本地一键启动三个实例,通过端口映射与配置调整即可体验主写从读、二进制日志同步及常见故障排查流程,对理解复制原理或准备开发测试环境都有直接帮助。
1. 一主二从的 MySQL 集群:docker-compose 编排是最快的落地方式
先用一句话说透这个资源:它不是一份 PPT 讲解,而是一套可以直接落地的 docker-compose 配置工程,把 MySQL 的主库和两个从库拆成三个独立容器,通过一份编排文件统一拉起。你不需要先在机器上装好三个 MySQL 实例,也不需要手动去改系统级配置文件,只要机器上有 Docker 和 docker-compose,按目录结构放好文件,执行一条docker-compose up -d,三节点的复制集群就能跑起来。适合谁用?正在做读写分离改造的团队、需要给报表查询提供独立数据源的后端开发者,以及那些想在一台开发机上快速复现主从复制场景的测试人员。它能省掉你手工初始化主从关系时最容易踩的权限、日志位点和容器重启这些坑,这份资源的价值就在这里。
2. 为什么要一主二从:主从复制的原理与选型理由
2.1 MySQL 主从复制的三种日志角色
先把原理讲清楚,否则配置看不懂。MySQL 一主二从的核心是 binlog 日志,主库把每一次写操作记录成二进制日志,从库通过 IO 线程把主库的 binlog 拉到自己本地,写成 relay log,再由 SQL 线程把 relay log 里的语句回放一遍。整个过程有三个角色:主库的 binlog dump 线程、从库的 IO 线程、从库的 SQL 线程。
这个资源里的"一主二从"指的是一个主库同时服务两个从库。两个从库都从同一个 binlog 位点开始复制,但互相之间没有任何数据同步关系。也就是说,从库1和从库2是并行地从主库拉取数据,不是串联的。这样的好处是从库之间互不干扰,任何一个从库挂掉都不会影响另一个的复制进度,也不会增加主库的额外逻辑负担。
2.2 为什么不是一主一从,也不是双主
一主一从能解决数据库的容灾问题,但解决不了读压力。当你的应用读多写少,比如典型的电商后台、内容管理系统,一台从库很快就会被查询拖垮。一主二从则把读流量拆成两份,理论上读吞吐可以提升到接近单机的两倍。更重要的是,它给你留出了"一个从库专门跑报表"的余量:从库1可以正常承接在线查询,从库2可以接一些大范围的 GROUP BY、聚合统计任务,即使慢查询把从库2拖慢了,也不会直接影响线上业务。
那为什么不用双主?双主在同时写入时需要处理冲突检测和自增偏移,配置复杂度成倍上升,而且大多数业务根本不需要双写。一主二从的拓扑足够应对绝大多数读写分离场景,维护成本低,出问题后链路也容易定位。docker-compose 编排的价值在于:它把三个实例的配置、数据目录、端口映射全部固化在同一份工程里,换一台机器也能以同样的方式复现,比手工在三台服务器上装 MySQL 再手动配置要快得多。
2.3 容器化部署的边界:哪些场景不适合
我必须说清楚,这个资源适合开发环境、测试环境、以及中小规模的业务部署,但不适合那种需要跨机房高可用的场景。容器里的 MySQL 数据虽然可以通过 volume 持久化,但如果你把容器跑在单台机器上,那这台机器挂了,三个容器全挂,一主二从的容灾意义就打了折扣。真正的生产环境,建议还是把三个节点分散到不同的物理机,或者用 Docker Swarm、Kubernetes 去调度。这套 docker-compose 的配置价值在于帮你验证复制逻辑和业务代码,而不是直接当作生产高可用方案。
从配置层面看,容器化部署和裸机部署的 MySQL 配置几乎完全一致,区别在于启动方式和网络通信。docker-compose 会为三个服务创建一个 bridge 网络,容器之间可以用服务名互相访问。这意味着从库连接主库时,不需要写 IP 地址,直接写主库的服务名即可。这个细节在后面配置里会反复用到。
3. docker-compose 编排三节点:目录结构与服务定义
3.1 项目目录规划
拿到这个资源后,第一步不是急着运行,而是先看目录结构。合理的目录规划能让你在后续排错时省掉大量时间。我一般会这样组织:
mysql-master-slave/ ├── docker-compose.yml ├── mysql-master/ │ ├── conf/ │ │ └── my.cnf │ └── init/ │ └── init.sql ├── mysql-slave1/ │ ├── conf/ │ │ └── my.cnf │ └── init/ │ └── init.sql └── mysql-slave2/ ├── conf/ │ └── my.cnf └── init/ └── init.sql每个节点一个独立目录,数据卷也会挂载到各自的 data 目录。为什么从库也要一个 init 目录?因为你需要在这个目录里放置创建复制账号的 SQL,或者在从库第一次启动时执行一些基础初始化。不过需要注意,/docker-entrypoint-initdb.d目录下的脚本只会在数据目录为空时执行一次,如果容器已经初始化过,之后重建不会重复执行。
3.2 docker-compose.yml 完整配置
这份资源最核心的文件就是docker-compose.yml。以下是三节点配置的完整内容:
version: '3.8' services: mysql-master: image: mysql:8.0 container_name: mysql-master restart: always environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: appdb MYSQL_USER: appuser MYSQL_PASSWORD: apppass ports: - "3306:3306" volumes: - ./mysql-master/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./mysql-master/data:/var/lib/mysql - ./mysql-master/init:/docker-entrypoint-initdb.d networks: - mysql-cluster command: --server-id=1 --log-bin=mysql-bin --binlog-format=ROW mysql-slave1: image: mysql:8.0 container_name: mysql-slave1 restart: always environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: appdb MYSQL_USER: appuser MYSQL_PASSWORD: apppass ports: - "3307:3306" volumes: - ./mysql-slave1/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./mysql-slave1/data:/var/lib/mysql - ./mysql-slave1/init:/docker-entrypoint-initdb.d networks: - mysql-cluster command: --server-id=2 --relay-log=relay-bin mysql-slave2: image: mysql:8.0 container_name: mysql-slave2 restart: always environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: appdb MYSQL_USER: appuser MYSQL_PASSWORD: apppass ports: - "3308:3306" volumes: - ./mysql-slave2/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./mysql-slave2/data:/var/lib/mysql - ./mysql-slave2/init:/docker-entrypoint-initdb.d networks: - mysql-cluster command: --server-id=3 --relay-log=relay-bin networks: mysql-cluster: driver: bridge3.3 每个服务的配置参数说明
先看镜像版本,这里用mysql:8.0。如果你用的是 MySQL 5.7,复制语法基本一致,但 8.0 默认的 caching_sha2_password 认证插件会在主从复制时带来一点额外要求,后面坑的部分会提到。
三个服务的环境变量保持一致,MYSQL_ROOT_PASSWORD是 root 密码,MYSQL_DATABASE会在首次启动时自动创建,MYSQL_USER和MYSQL_PASSWORD创建一个普通应用账号。注意,这个普通账号和复制账号不是一回事,复制账号需要在初始化 SQL 里单独创建。
端口映射部分,主库映射到宿主机的 3306,两个从库分别映射到 3307 和 3308。这样你可以在宿主机上用不同的端口分别连接三个实例,方便测试。
command里传的参数值得细看。主库传了--server-id=1 --log-bin=mysql-bin --binlog-format=ROW,从库传了--server-id=2 --relay-log=relay-bin。server-id 必须全局唯一,这是复制的硬性要求。主库必须开启 log-bin,否则从库找不到复制源。binlog 格式选择 ROW 是生产环境的主流做法,它记录的是行级别的变更,比 STATEMENT 格式更安全,不会因为存储函数或触发器的差异导致主从数据不一致。
从库的 relay-log 参数是可选但推荐的,它指定中继日志的文件名前缀。如果不指定,MySQL 会用默认的主机名,容器的主机名是容器 ID 之类的随机串,日志文件名不稳定,排错时看着难受。
网络配置里,三个服务挂在同一个自定义 bridge 网络mysql-cluster上,容器之间通过服务名互通。这里有一个常见误区:有人会在 MySQL 配置文件里写主库的 IP 地址,但容器 IP 在重启后会变化,所以一定要写服务名mysql-master,docker 内置的 DNS 会解析到对应的容器 IP。
还有一个容易被忽略的点:restart: always。当容器异常退出或者宿主机重启时,Docker 会自动把容器拉起来。如果你的 MySQL 因为配置错误启动失败,这个策略会让它反复尝试重启,日志里会出现大量重启记录。排查的时候先用docker-compose logs看原因,再决定是否修改。
4. 初始化两份配置与启动顺序:先把数据目录挂对
4.1 主库的 my.cnf 与复制账号授权
主库的配置文件放在mysql-master/conf/my.cnf,内容是这样的:
[mysqld] server-id = 1 log-bin = mysql-bin binlog-format = ROW binlog-do-db = appdb max_binlog_size = 128M expire_logs_days = 7binlog-do-db这里我写的是appdb,意思是只对这一个库做复制。如果你有多个需要复制的库,可以写多行,或者干脆不写这个参数,默认复制所有库。要注意的是,binlog-do-db的过滤逻辑在 8.0 和 5.7 里有细微差别,它是基于当前默认库来判断的,如果你在应用里用use other_db;然后再操作 appdb 的表,可能会被过滤掉。经验不足的人容易在这里翻车,安全的做法是明确指定库,并且在应用连接串里固定数据库名。
然后是创建复制账号的初始化 SQL。这个文件放在mysql-master/init/init.sql:
CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'replpass'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;使用mysql_native_password是为了兼容旧版从库连接,如果你三个节点都是 MySQL 8.0,使用默认的caching_sha2_password也可以,但需要额外配置get_master_public_key。为了避免这个麻烦,我一般直接指定 native 方式。
这里有个关键点:这个 init.sql 只在主库数据目录为空时执行。如果第一次启动时密码配错了,后面改掉 SQL 再重启容器,脚本不会重新执行,数据目录已经生成。解决办法是删除整个 mysql-master/data 目录再启动,这算是容器化 MySQL 的"后悔药"。
4.2 从库的 my.cnf 与中继日志设置
两个从库的 my.cnf 基本一样,区别只在 server-id。以从库1为例:
[mysqld] server-id = 2 relay-log = relay-bin log-replica-updates = OFF read_only = ONread_only = ON是个保护设置,它会让普通账号在从库上的写入被拒绝,保证从库只作为数据读取方。注意 read_only 对 root 不受限制,所以测试时如果用 root 连接从库,还是能写入的。
log-replica-updates这个参数在 MySQL 8.0 里默认是 OFF,如果之后你想把从库1再作为下一级主库,需要把它改为 ON,并且开启 log-bin。这个资源只做一级主从,所以保持默认就好。
从库初始化 SQL 里其实不需要写太多东西,但有一个初始化行为很重要:从库容器第一次启动时,MySQL 会执行/docker-entrypoint-initdb.d下的 SQL。如果你在从库的 init.sql 里直接写CHANGE MASTER TO和START SLAVE,看起来很美,但实际容易出问题,因为主库可能还没有完全初始化完,或者复制账号还没生效。我建议从库的 init 目录里放一个空 SQL 文件,或者只放一些无害的建表脚本,复制链路的建立我们放到启动后手动执行,这样每一步都可控,出了问题也容易定位。
4.3 启动严格顺序与手工建立复制链路
docker-compose 的depends_on可以控制服务启动的先后顺序,但注意它默认只控制容器创建顺序,不保证主库内部初始化完成。所以我们的策略是:先启动主库,等它初始化完成后,再启动两个从库。
启动顺序第一步:
docker-compose up -d mysql-master等待主库初始化完成,可以用这条命令检查日志:
docker-compose logs -f mysql-master | grep "ready for connections"看到类似mysqld: ready for connections的输出后,表示主库已经就绪。此时从库还没有启动,主库的 binlog 位置已经开始记录,但不用担心,从库启动后可以通过MASTER_AUTO_POSITION=1自动同步位点,前提是启用 GTID 或者手动指定文件位置。这里我们为了简化,使用 GTID 模式更省心。
把主库的配置改为开启 GTID,修改 my.cnf:
gtid_mode = ON enforce_gtid_consistency = ON修改完需要重建主库容器。然后创建复制账号(同上)。接下来启动两个从库:
docker-compose up -d mysql-slave1 mysql-slave2等从库起来后,手动进入从库1配置主库信息:
docker exec -it mysql-slave1 mysql -uroot -prootpass在 MySQL 命令行里执行如下语句:
CHANGE MASTER TO MASTER_HOST='mysql-master', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='replpass', MASTER_AUTO_POSITION=1; START SLAVE; SHOW SLAVE STATUS\G从库2执行同样的步骤,只是把容器名改成 mysql-slave2。
SHOW SLAVE STATUS\G输出里重点看两个字段:Replica_IO_Running和Replica_SQL_Running,都应该是Yes。如果一个是Connecting,多半是网络不通或者账号密码不对。用docker exec -it mysql-slave1 ping mysql-master试试能不能通。
在 MySQL 8.0 环境里,CHANGE MASTER TO语法仍然可用,但文档里更推荐CHANGE REPLICATION SOURCE TO,两者等价,旧语法兼容性更好,我习惯用旧语法,减少在不同版本间切换的心智负担。
5. 避坑清单:主从配置里最常见的五个坑
这里把我实际部署中踩过的坑和身边的开发者经常遇到的问题集中总结一下,每条按现象、原因、解决展开。
5.1 从库一直显示Connecting,复制起不来
现象:执行SHOW SLAVE STATUS\G后看到Replica_IO_Running: Connecting,这个状态持续几分钟甚至一直不变。
原因:最常见的是三件事。第一,主库的复制账号端口不对,注意你映射到宿主机的端口是 3306,但从库访问的是 docker 网络内的 3306,不是宿主机的端口,所以MASTER_PORT要写 3306,别写宿主机端口。第二,复制账号不存在,确认是否在 master 的容器里执行了创建账号的 SQL,并且用了mysql_native_password。第三,主库的 server-id 和从库重复,导致从库连接被拒绝。
解决:先检查网络,进入从库容器里尝试连接主库的 3306 端口:
docker exec -it mysql-slave1 bash mysql -h mysql-master -P 3306 -u repl -preplpass如果连接成功,说明网络没问题。接着查看主库的错误日志:
docker logs mysql-master 2>&1 | tail -100如果看到Slave I/O thread相关报错,把报错信息复制到搜索引擎,基本都能定位。这里有个血泪经验:docker-compose 里不要用localhost连接主库,要从库容器内访问主库,必须用服务名。
5.2 主从数据不一致,一开始复制就报错
现象:启动复制后,SHOW SLAVE STATUS里Last_SQL_Error字段出现错误,比如Error_code: 1050,说的是表已存在。
原因:从库在第一次启动时已经执行了 init.sql,里面可能包含了创建表的语句,而后复制又开始执行主库同样建表语句,就冲突了。另外,如果从库数据目录里有残留数据,而主库 binlog 位点又不是从零开始,就会出现主键冲突或者找不到行。
解决:最干净的办法是重新初始化从库。删除从库容器的 data 目录:
docker-compose down mysql-slave1 rm -rf mysql-slave1/data docker-compose up -d mysql-slave1然后重新执行 CHANGE MASTER 和 START SLAVE。如果业务允许,更好的方式是用mysqldump把主库当前数据快照导入从库,再设置 GTID 位点,这样可以保证数据基线一致。不过 docker-compose 资源里为了演示方便,通常假设从库不需要先加载数据。如果你的主库已经有存量数据,记得先用如下命令导出:
docker exec mysql-master mysqldump -uroot -prootpass --all-databases --master-data=1 > master_backup.sql再把这份 SQL 导入到从库里,导入完成后再启动复制。
5.3 容器重启后从库复制线程断了
现象:宿主机重启或者手动执行docker-compose restart后,从库的复制线程变成No running,需要手动跑START SLAVE。
原因:CHANGE MASTER TO的配置已经写入了从库的数据文件,重启后理论上应该自动连上,但 MySQL 8.0 里遇到过 relay log 损坏或者 GTID 上下文丢失的情况,导致复制线程停止。
解决:先执行SHOW SLAVE STATUS看Last_IO_Error和Last_SQL_Error。如果是 relay log 问题,最简单的方式是执行以下语句重新拉取:
STOP SLAVE; RESET SLAVE ALL; CHANGE MASTER TO MASTER_HOST='mysql-master', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='replpass', MASTER_AUTO_POSITION=1; START SLAVE;为了减少手动操作的频率,我习惯在 docker-compose 里为主库和从库都加上restart: always,至少保证容器级别会自动拉起。更高阶的做法是写一个健康检查脚本,定期检查从库SHOW SLAVE STATUS的状态,发现异常就报警或者自动尝试恢复。
5.4 MySQL 8.0 的认证插件导致连接失败
现象:从库执行CHANGE MASTER TO后,IO 线程报错:Authentication plugin 'caching_sha2_password' cannot be loaded。
原因:MySQL 8.0 默认的认证插件是caching_sha2_password,从库连接主库时,如果双方版本或者插件不匹配,就会失败。尤其是在早期 8.0 版本里,从库的客户端库可能不认识这个插件。
解决:在创建复制账号时显式指定IDENTIFIED WITH mysql_native_password BY 'replpass'。另外,你还需要确认主库的default_authentication_plugin不要强制改成新的,否则即使账号用了 native,后续权限变更也可能被覆盖。稳妥的做法是在 my.cnf 里加上:
[mysqld] default_authentication_plugin = mysql_native_password注意,这个参数在 MySQL 8.4 中被移除了,如果以后升级到 8.4 要用别的方式处理,那是另一个话题。
5.5 主库 binlog 被清理,从库追不上
现象:从库落后主库很久,然后执行START SLAVE时报错:Could not find first log file name in binary log index。
原因:主库的 binlog 设置了expire_logs_days = 7,也就是只保留 7 天的日志。如果从库因为某种原因停机超过 7 天,或者复制长时间中断,主库的老 binlog 已经被清理,从库就没有位置可以继续。
解决:如果从库落后时间还在容忍范围内,可以重新全量同步。但如果数据量大,全量同步成本高,建议提前做好备份策略。对于这个 demo 工程,最简单的办法是重建从库数据目录,重新走初始化流程。生产环境里,binlog 保留时间要结合从库允许的最大延迟来考虑,我一般设置 14 天以上,并且用监控平台跟踪从库延迟,超过阈值立刻报警。
6. 让三节点真正生效:读写分离与复制延迟监控
6.1 应用层的读写分离路由
一主二从的复制链路搭好之后,应用层怎么用才是关键。很多人在这里犯迷糊,以为 MySQL 会自动做读写分离,实际上 MySQL 原生并不知道哪个请求是读哪个是写,需要应用层自己路由。常见做法是配置两个数据源,一个主库数据源负责写,一个从库数据源负责读,然后再根据业务方法名或者注解切换。
以 Spring Boot 为例,你可以在配置里这样定义两个数据源:
spring: datasource: master: url: jdbc:mysql://localhost:3306/appdb?useSSL=false username: appuser password: apppass slave1: url: jdbc:mysql://localhost:3307/appdb?useSSL=false username: appuser password: apppass slave2: url: jdbc:mysql://localhost:3308/appdb?useSSL=false username: appuser password: apppass然后通过 AOP 在 Service 方法上做切面,方法名以get、query、select开头的走从库,其他走主库。这个方案的优点是透明,缺点是如果某天代码里出现了一个名字叫getXXX的写方法,数据就会写到从库——而从库开了read_only,写操作会直接报错。所以不少团队改用独立的读写标识注解,比如@ReadOnly,这样更明确。
如果你不想在代码里做路由,也可以用中间件,比如 ProxySQL。ProxySQL 可以直接配置分组规则,把流量按语句类型分发到主库或者从库,应用端只需要连接 ProxySQL 的地址,不需要做任何改造。这个资源里的 docker-compose 工程同样可以在同一个网络里挂一个 ProxySQL 容器,把 MySQL 的 3306 端口换成 ProxySQL 的 6033 端口。
6.2 用脚本监控复制延迟
复制延迟是一个不可忽略的指标,尤其在一主二从里,两个从库各自的延迟可能不一样。写一个简单的 Shell 脚本,定期检查从库的Seconds_Behind_Master。
#!/bin/bash for port in 3307 3308; do echo "--- MySQL port $port ---" docker exec mysql-slave1 mysql -uroot -prootpass -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master done注意这个脚本里我固定写了 mysql-slave1,实际使用时应该根据端口匹配容器名,或者用循环遍历。更好的方式是使用 mysql 客户端的--host参数,在宿主机上直接连接两个从库映射出来的端口:
mysql -h127.0.0.1 -P3307 -uroot -prootpass -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master mysql -h127.0.0.1 -P3308 -uroot -prootpass -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_MasterSeconds_Behind_Master为 0 表示没有延迟,如果数值持续增长,说明从库的 SQL 线程跟不上了。常见的诱因是从库硬件性能差、从库上有大查询、或者 relay log 写入磁盘太慢。这时候可以把报表查询都挪到一个从库,另一个从库只接线上短查询。
6.3 验证主从效果的三个关键操作
配置完不是结束,必须做一次完整验证。我一般依次执行下面三个操作。
第一步,在主库创建一个测试表并插入数据:
CREATE TABLE test_table (id INT PRIMARY KEY, name VARCHAR(50)); INSERT INTO test_table VALUES (1, 'hello');第二步,分别在两个从库查询:
SELECT * FROM test_table;能查出数据,说明复制链路正常。注意不要连错端口,主库是 3306,从库1是 3307,从库2是 3308。
第三步,验证只读权限。用应用账号连接从库并尝试插入:
INSERT INTO test_table VALUES (2, 'world');会报出The MySQL server is running with the --read-only option,这是符合预期的,说明从库保护规则生效了。
这三步走完,一主二从才算真正投入使用。从那以后,我每次搭完复制集群,都会强制自己先跑一遍这个最小验证流程,而不是只看SHOW SLAVE STATUS里的两个Yes。因为那两个字段只代表 IO 线程和 SQL 线程在工作,不代表数据真的同步到了用户能看到的表里。这个习惯帮我避开了好几次"看起来正常、实际数据没过去"的假象。希望这些排查思路能让你在搭自己的集群时少走几步弯路。
本文还有配套的精品资源,点击获取