简介:针对通过 Docker Compose 搭建 MySQL 一主二从复制环境的实际需求,这份资源为开发与运维人员提供了可复用的配置模板。包内共含 8 个文件,涵盖 docker-compose.yml 编排文件、主从实例的 cnf 配置、启动辅助 sh 脚本以及用于初始化数据的 sql 脚本等,整套压缩包仅 5KB,结构紧凑,便于直接修改和复用。借助该配置,可快速生成一个主库负责写入、两个从库负责读取的复制集群,从而支撑数据冗余与读负载均衡。资源目前已有 39 人学习浏览,适合正在实践容器化数据库部署、希望了解主从同步细节的中级开发者。通过核对文件中的 server-id、log-bin、read-only 等关键参数,读者能更清晰地理解主从复制的配置逻辑,并在本地环境一键拉起整套拓扑。
1. 一主二从不是把三个 MySQL 塞进容器,而是把复制拓扑固化下来
docker-compose 编排 MySQL 一主二从,听起来就是把三个mysql:8.0容器跑起来,再把主库的 binlog 坐标告诉两个从库。但真正动手做过的都知道,这套拓扑最容易翻车的恰恰不是写错的 SQL,而是容器启动顺序、MySQL 初始化时机、固定 IP 这三个琐碎问题叠加在一起导致的“看起来全绿,数据却不同步”。这篇文章会用一套可以直接落地的 compose 文件,把目录规划、主从配置、初始化脚本、验证手法一次讲透,适合三类人:需要在本地快速拉起一套复制拓扑做读写分离联调的开发者、在 CI 里用多实例 MySQL 做集成测试的团队,以及刚接触主从复制、想搞明白 GTID 和 binlog 到底怎么配合的运维新人。
2. 先定目录与镜像,再写主库配置:server-id、binlog、GTID 一次性说清
2.1 目录规划与镜像选择:为什么锁定同一大版本
我一般会在项目根目录下建一个deploy/mysql-replica的独立目录,把配置文件、数据目录、初始化脚本全放进去。这样一次性docker compose down -v就能把整个环境清干净,不会污染本机其他 MySQL 数据。目录结构如下:
mkdir -p mysql-replica/{conf/{master,slave1,slave2},data/{master,slave1,slave2},init} tree mysql-replicamysql-replica ├── conf │ ├── master │ │ └── my.cnf │ ├── slave1 │ │ └── my.cnf │ └── slave2 │ └── my.cnf ├── data │ ├── master │ ├── slave1 │ └── slave2 └── init ├── 00_master_init.sql ├── 10_slave1_init.sql └── 20_slave2_init.sql镜像选择上,建议主从三个容器统一锁定mysql:8.0,不要用latest,也不要混用 5.7 和 8.0。MySQL 8.0 默认的认证插件是caching_sha2_password,5.7 用的是mysql_native_password,两个大版本之间做主从复制,binlog 格式和 GTID 行为都有差异,排查起来非常别扭。锁定同一大版本不是保守,是给自己省掉一类最没价值的故障。
2.2 主库 my.cnf:三个关键参数决定复制形态
主库配置是整套拓扑的地基。下面这份conf/master/my.cnf我用了很长一段时间,参数不追求全,只保留和复制强相关的部分:
[mysqld] server_id = 1 log_bin = mysql-bin binlog_format = ROW gtid_mode = ON enforce_gtid_consistency = ON binlog_expire_logs_seconds = 604800 max_binlog_size = 256M sync_binlog = 1 innodb_flush_log_at_trx_commit = 1 skip_name_resolve = ONserver_id是复制拓扑里每个节点的身份证,主从必须各不相同,后面两个从库是 2 和 3,这个数字只在本套拓扑内唯一即可。log_bin开启 binlog,主库不开 binlog,从库根本没法拉取变更。binlog_format = ROW是行级复制,这是三个关键参数里最容易被忽略的一个——如果你用STATEMENT格式,主库执行NOW()、UUID()这类函数时,从库重放出来的数据很可能不一致;ROW格式直接记录行变更,规避了这类函数带来的主从偏差。
gtid_mode = ON配合enforce_gtid_consistency = ON是 MySQL 8.0 下推荐的做法。开启 GTID 之后,从库执行CHANGE MASTER TO时不需要手工去记MASTER_LOG_FILE和MASTER_LOG_POS,复制位置由 GTID 自动协商,这对后面容器重启、从库重建带来的位置漂移问题提供了非常关键的兜底能力。sync_binlog = 1和innodb_flush_log_at_trx_commit = 1是一对组合,意思分别是“每次事务提交都刷 binlog 到磁盘”和“每次事务提交都把 redo log 刷到磁盘”,它俩同时为 1 才能保证主库崩溃时不丢已经提交的事务,这对复制一致性是最基础的安全垫。
主库配置文件写完后,还需要在初始化脚本里创建一个复制专用账号。官方镜像的/docker-entrypoint-initdb.d机制会在数据目录为空时按文件名顺序执行 SQL,主库的初始化脚本内容如下:
CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123456'; GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;这里特意用IDENTIFIED WITH mysql_native_password创建复制账号,是为了避开 MySQL 8.0 默认caching_sha2_password插件在主从复制连接时需要 RSA 公钥传递的问题。虽然可以直接在主库 my.cnf 里改全局默认认证插件,但只对复制账号指定mysql_native_password影响面更小,本机测试完全够用。REPLICATION CLIENT权限顺手给了,后面用SHOW MASTER STATUS验证复制位置时不需要额外授权。
3. 从库配置与 CHANGE MASTER TO:两个从库的差异只在 server-id 和 relay_log
3.1 从库 my.cnf:read_only 与 log_slave_updates 怎么权衡
两个从库的配置基本一致,唯一的硬性区别是server_id不能重复。从库 1 用 2,从库 2 用 3。下面是从库 1 的配置:
[mysqld] server_id = 2 log_bin = mysql-bin relay_log = relay-bin relay_log_recovery = 1 read_only = ON log_slave_updates = ON gtid_mode = ON enforce_gtid_consistency = ON skip_name_resolve = ON从库要不要单独开log_bin?这里我选择开。log_slave_updates = ON表示从库在重放主库的 binlog 时,同时写一份自己的 binlog。在纯一主二从场景下,这个参数不是必须的,开着会多一份写 I/O。但它的价值在于:如果你后续想从这个从库再级联出一个从库,或者想直接从从库拉全量备份而不增加主库压力,log_slave_updates是前置条件。容器环境里磁盘 I/O 通常不是瓶颈,开着的收益远大于开销。
relay_log_recovery = 1是我强烈建议开启的参数。它表示从库启动时,如果发现 relay log 不完整(比如容器异常重启导致 relay log 尾部损坏),会自动丢弃当前 relay log 并重新从主库拉取。没有这个参数,从库重启后经常会碰到 SQL 线程报错,需要手工STOP SLAVE再START SLAVE才能恢复。read_only = ON对普通账号生效,对具备SUPER权限的 root 不生效,所以测试时别拿 root 去从库写数据验证,看不出效果。
3.2 CHANGE MASTER TO 的完整语句与执行顺序
从库初始化脚本的核心任务是建立复制链路。在 GTID 模式下,CHANGE MASTER TO不需要写MASTER_LOG_FILE和MASTER_LOG_POS,这是 GTID 复制最舒服的地方:
CHANGE MASTER TO MASTER_HOST = 'mysql-master', MASTER_PORT = 3306, MASTER_USER = 'repl', MASTER_PASSWORD = 'Repl@123456', MASTER_AUTO_POSITION = 1; START SLAVE;MASTER_HOST这里用的是 docker compose 里的服务名mysql-master,而不是 IP。服务名由 compose 内置 DNS 解析,容器重建后 IP 可能变化,但服务名不会变,这样从库不会因为主库容器重启而失联。MASTER_AUTO_POSITION = 1是 GTID 复制的核心开关,置为 1 之后,从库会基于 GTID 自动和主库协商复制起点,不需要人工干预。
如果坚持用传统的 binlog + position 方式,语句长这样:
CHANGE MASTER TO MASTER_HOST = 'mysql-master', MASTER_PORT = 3306, MASTER_USER = 'repl', MASTER_PASSWORD = 'Repl@123456', MASTER_LOG_FILE = 'mysql-bin.000003', MASTER_LOG_POS = 157;这种方式要求你先在主库执行SHOW MASTER STATUS拿到当前的 binlog 文件名和位置,再填到从库的CHANGE MASTER TO里。一主二从下要做两次,而且容器的 binlog 文件会随着时间推进不断滚动,只要从库建立时间稍有先后,两个从库拿到的坐标就不同,非常容易出纰漏。我现在的习惯是但凡新环境一律走 GTID,老环境才考虑兼容老的 position 方式。
从库 2 的初始化脚本内容完全一样,只需要把server_id换成 3,其他一字不改。两个从库的差异就这么点,这也是为什么我建议把配置文件拆成三个独立目录而不是三个容器共用一个 my.cnf,后面换参数时不用互相干扰。
4. docker-compose.yml 编排:固定IP、健康检查、初始化脚本三者缺一不可
4.1 完整 compose 文件与锚点复用
下面是整套拓扑的 compose 文件,我用 YAML 锚点把三个节点公共的部分抽了出来,避免主从配置散落三处改起来漏改:
x-mysql-common: &mysql-common image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: Root@123456 TZ: Asia/Shanghai networks: mysql_net: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci services: mysql-master: <<: *mysql-common container_name: mysql-master ports: - "3306:3306" volumes: - ./conf/master/my.cnf:/etc/mysql/conf.d/my.cnf:ro - ./data/master:/var/lib/mysql - ./init/00_master_init.sql:/docker-entrypoint-initdb.d/00_master_init.sql:ro networks: mysql_net: ipv4_address: 172.30.0.10 mysql-slave1: <<: *mysql-common container_name: mysql-slave1 ports: - "3307:3306" volumes: - ./conf/slave1/my.cnf:/etc/mysql/conf.d/my.cnf:ro - ./data/slave1:/var/lib/mysql - ./init/10_slave1_init.sql:/docker-entrypoint-initdb.d/10_slave1_init.sql:ro depends_on: mysql-master: condition: service_healthy networks: mysql_net: ipv4_address: 172.30.0.11 mysql-slave2: <<: *mysql-common container_name: mysql-slave2 ports: - "3308:3306" volumes: - ./conf/slave2/my.cnf:/etc/mysql/conf.d/my.cnf:ro - ./data/slave2:/var/lib/mysql - ./init/20_slave2_init.sql:/docker-entrypoint-initdb.d/20_slave2_init.sql:ro depends_on: mysql-master: condition: service_healthy networks: mysql_net: ipv4_address: 172.30.0.12 networks: mysql_net: driver: bridge ipam: config: - subnet: 172.30.0.0/24<<: *mysql-common是 YAML 锚点语法,表示把x-mysql-common里定义的公共段复制到当前服务下。三个节点共用了同一份镜像、环境变量和网络配置,只有端口、数据目录、初始化脚本、固定 IP 各不相同。container_name显式指定容器名,方便在宿主机上用docker exec -it mysql-slave1直接进容器操作,省得每次查容器 ID。
端口映射上,宿主机 3306 给主库,3307 和 3308 分给两个从库。这样本地可以直接用mysql -h127.0.0.1 -P3307连从库 1,不用进容器。ipv4_address是固定 IP,三个节点分别落在 172.30.0.10 到 172.30.0.12。固定 IP 的意义在于:如果从库的CHANGE MASTER TO里写了 IP 而不是服务名,容器重建后 IP 漂移会直接导致复制中断。虽然推荐用服务名,但固定 IP 作为兜底,两个手段同时上更稳。
4.2 healthcheck 与 depends_on 的配合:先让主库真正可用
depends_on加condition: service_healthy是这套配置里最容易抄错的地方。如果只写depends_on: - mysql-master,compose 默认的service_started条件只保证 master 容器启动了,不保证 MySQL 进程已经能接受连接。从库的初始化脚本要连主库执行CHANGE MASTER TO,如果主库还没就绪,从库脚本直接失败,而且initdb.d的脚本失败会导致容器退出。所以必须在 compose 里给主库加健康检查:
healthcheck: test: ["CMD-SHELL", "mysql -uroot -p$$MYSQL_ROOT_PASSWORD -e 'SELECT 1'"] interval: 5s timeout: 3s retries: 12 start_period: 30s注意$$MYSQL_ROOT_PASSWORD里的双$$是 compose 的特殊转义,表示把MYSQL_ROOT_PASSWORD这个环境变量的取值交给容器内的 shell 去解析,而不是在 compose 渲染阶段就展开。为什么用mysql -e 'SELECT 1'而不是更常见的mysqladmin ping?因为mysqladmin ping在密码错误时依然返回成功——它只要探测到 mysqld 进程存活就输出 alive,认证失败不影响退出码,这会让健康检查形同虚设。SELECT 1是真正走一次认证和查询,能过说明 MySQL 完全可用。
start_period: 30s是给首次初始化留的时间。数据目录为空时,mysql 镜像要跑完整的初始化流程,包括建系统表、执行initdb.d脚本,这个流程在普通机器上可能要 20 到 60 秒。interval: 5s搭配retries: 12,意味着容器启动后 5 秒一次探活,连续失败 12 次才标记为 unhealthy,配合start_period基本能覆盖初始化耗时。
4.3 初始化脚本的执行顺序与幂等性
官方镜像会在数据目录为空时按文件名字母顺序执行initdb.d目录下的 SQL 和 shell 脚本。我给脚本命名时故意加了数字前缀:00_master_init.sql、10_slave1_init.sql、20_slave2_init.sql。这样能保证主库先建好复制账号,然后从库 1 再执行CHANGE MASTER TO。这里有一个隐含依赖:从库 1 的脚本执行时,主库的00_master_init.sql必须已经跑完。depends_on的service_healthy条件保证了主库容器整体可用,而镜像的initdb.d机制本来就是在 mysqld 对外提供服务之前把所有初始化脚本执行完毕,所以这个依赖链是可靠的。
还有个容易忽略的幂等性问题:initdb.d只在数据目录为空时执行,如果你的宿主机data/master目录里已经有上一轮测试留下的数据,下次docker compose up时初始化脚本不会再次运行。这时候要么手动清掉数据目录重新初始化,要么进容器手工执行 SQL。我在本地测试时习惯用docker compose down -v把数据卷一并删掉,确保每次起服务都是干净状态。但这条习惯只适用于纯测试环境,如果你的从库里已经有业务数据,千万别手滑执行down -v。
5. 一主二从配置避坑:五个真实场景下的故障与修复
5.1 现象:从库 IO 线程一直 Connecting
配置完从库后执行SHOW SLAVE STATUS\G,发现Slave_IO_Running是Connecting,Last_IO_Error显示无法连接主库。这个现象在我接触过的部署里出现过很多次。原因通常是三类:MASTER_HOST写了容器 IP 但 IP 没固定,容器重建后 IP 变了;主库初始化脚本没执行成功,repl账号根本不存在;或者容器网络没连通,从库访问不了主库的 3306 端口。
解决思路是先分层排查。在从库容器里执行mysql -h mysql-master -P 3306 -urepl -p,能连上就是账号或认证问题,连不上就是网络或主机名解析问题。网络层面检查docker network inspect mysql_net,确认三个容器都在同一个网络里。如果确认网络正常且账号密码无误,最可能的问题是CHANGE MASTER TO里的MASTER_HOST写成了固定 IP,而 compose 里的固定 IP 和实际分配的 IP 不一致——检查 compose 文件里的ipv4_address是否和SHOW SLAVE STATUS里报错的 IP 对得上。顺手确认主库的my.cnf里skip_name_resolve = ON时,mysql.user表里repl账号的Host是否包含了从库的连接来源网段,否则即使网络通,授权也会挡住连接。
5.2 现象:主库日志报 server-id 重复
两个从库的SHOW SLAVE STATUS里,一个显示Slave_IO_Running: Yes,另一个反复报错Fatal error: The slave I/O thread stops because master and slave have equal MySQL server ids,或者主库错误日志里出现server_id=2 is being used by another server。这个坑的根源非常简单:两个从库的my.cnf是在同一个模板上改的,只改了server_id忘了改另一个,或者从库 1 的配置文件被整个复制成了从库 2。
解决方法是把三个节点的server_id做成显式区分,不要靠复制文件再手动改。我的做法是在 compose 的 environment 段里用名为MYSQL_SERVER_ID的自定义环境变量分别指定 1、2、3,然后在command里通过--server-id=$(MYSQL_SERVER_ID)传入。不过这个写法要求容器 entrypoint 支持变量展开,而官方的mysql:8.0镜像并不直接支持在command中引用 compose 环境变量,所以更稳妥的做法还是三个独立的 my.cnf,启动后用下面这条命令一次性验证:
SHOW VARIABLES LIKE 'server_id';三个容器各执行一次,确认分别是 1、2、3。排查时不要只看SHOW SLAVE STATUS,直接在主库SHOW PROCESSLIST里看有没有两个来自不同从库的连接都带着同一个server_id,这个视角更直观。
5.3 现象:容器重启后主从直接断开
docker compose restart之后,某个从库的Slave_IO_Running变成Connecting,或者报错Got fatal error 1236 from master when reading data from binary log。这个场景要分两种原因讨论。第一种是CHANGE MASTER TO里写死了主库的容器 IP,而 compose 没有配置固定 IP,容器重启后 IP 重新分配,从库自然找不到主库。第二种是数据目录或 binlog 发生了实际变化,比如你执行过docker compose down -v重建了数据卷,主库的auto.cnf里server_uuid变了,从库还拿着旧 UUID 去对接,就会触发 1236 错误。
针对第一种,解决办法是CHANGE MASTER TO里用服务名mysql-master而不是 IP,并在 compose 里同时配置固定 IP 做双保险。针对第二种,最直接的办法是重新做一次全量同步:去主库用mysqldump导出当前快照,导入从库后重置复制链路。注意,基于 GTID 的一主二从,从库重建后不需要手动指定 binlog 坐标,只需要保证从库的gtid_purged和主库的Executed_Gtid_Set对齐,START SLAVE后 GTID 会自动跳过已经执行过的事务。
5.4 现象:初始化目录挂载后 SQL 没有执行
docker compose up后进容器一看,repl账号不存在,CHANGE MASTER TO也没有生效,但容器日志里没有任何报错。原因几乎可以锁定为数据目录不空:mysql 镜像的 entrypoint 只在/var/lib/mysql为空时才执行initdb.d下的脚本。你的宿主机data/master目录里如果残留了上一次初始化的系统表文件,镜像会认为数据库已经初始化过,直接跳过脚本。
解决方法是清理后重建。先docker compose down,再rm -rf data/master data/slave1 data/slave2,最后docker compose up -d。如果不想删数据,另一个选择是不挂载数据目录,让 compose 自动创建匿名卷,或者把初始化脚本手工docker exec -i进去执行。我在本地反复调参数的时候,宁可删数据目录也不愿意保留一个状态不明的旧库——一主二从这种环境,数据本来就是测试数据,重来一次的成本远低于排查一个“为什么脚本没跑”的隐性问题。
5.5 现象:MySQL 8 复制账号认证失败
从库执行START SLAVE后,Last_IO_Error报错提示Authentication plugin 'caching_sha2_password' cannot be loaded,或者出现The server requested authentication method unknown to the client。这是 MySQL 8.0 默认认证插件和复制连接之间最经典的兼容性问题。复制连接不走 SSL 时,caching_sha2_password需要额外的 RSA 公钥交换步骤,而默认配置下从库客户端不会自动完成这个交换,导致认证卡住。
解决方法是创建复制账号时显式指定mysql_native_password,我在第 2 章里的初始化脚本已经这么做了。如果你的复制账号已经用默认插件建好了,补救办法是在主库执行一次覆盖:
ALTER USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'Repl@123456';改完之后在从库执行STOP SLAVE; START SLAVE;重新建立连接。如果不想动账号,也可以在主库 my.cnf 里加一行default_authentication_plugin=mysql_native_password,但这个是全局生效,影响面更大,不如只改复制账号来得干净。另外提醒一句:MySQL 8.0.34 之后的版本已经废弃了mysql_native_password插件,长期方案是直接用caching_sha2_password并给从库配置 SSL 连接,但那是另外一个话题了,本机测试环境用老插件没有实际风险。
6. 用 GTID + 半同步把一主二从做得更稳,顺便给一个快速重建从库的命令
前面所有配置都是异步复制,主库提交事务不等待从库确认。测试环境无所谓,但如果这套拓扑要承接模拟的高可用演练,半同步复制值得加上。半同步的核心行为是:主库提交事务后,至少等待一个从库确认收到了 binlog,才向客户端返回成功。它比异步复制多了一个“不丢事务”的保证,但也要承担从库故障时主库写入变慢的代价。在一主二从里,只要有一个从库活着,主库就能正常提交,所以这个代价被两个从库分担掉了。
Enable 半同步需要主库和从库各装一个插件。MySQL 8.0.26 之前的版本插件名是rpl_semi_sync_master和rpl_semi_sync_slave,8.0.26 之后改名成了rpl_semi_sync_source和rpl_semi_sync_replica。在主库 my.cnf 的[mysqld]段加上:
plugin_load_add = rpl_semi_sync_source.so rpl_semi_sync_source_enabled = ON rpl_semi_sync_source_timeout = 3000从库配置则是:
plugin_load_add = rpl_semi_sync_replica.so rpl_semi_sync_replica_enabled = ONrpl_semi_sync_source_timeout = 3000表示主库等待从库确认的超时时间是 3 秒,超过这个时间自动降级回异步复制。这个参数要根据业务容忍度调,写操作多的业务设太短会频繁降级,设太长会把主库写延迟拉到不可接受。半同步开启后,一主二从里两个从库都需要装replica插件并开启,否则主库会等不到任何一个从库确认而一直卡到超时。
最后给一个快速重建单个从库的实用命令。假设从库 2 的数据坏了需要重建,不用把整个 compose 环境销毁,只针对它操作:
docker exec mysql-master mysqldump \ --single-transaction \ --set-gtid-purged=ON \ --all-databases \ --master-data=2 \ -uroot -pRoot@123456 > /tmp/full_dump.sql--single-transaction用 InnoDB 快照一致性导出,不加锁;--set-gtid-purged=ON是关键,它会把主库当前的 GTID 集合写进 dump 文件。导入从库 2 后,执行下面的命令使 GTID 对齐:
RESET MASTER; SET GLOBAL gtid_purged = '刚才导出的GTID集合中记录的GTID值';然后直接CHANGE MASTER TO ... MASTER_AUTO_POSITION = 1; START SLAVE;,从库就会基于 GTID 自动跳过已同步的事务,从 dump 时刻的位置继续追赶主库。重建单个从库时,不要忘记在导入 dump 前先STOP SLAVE或者干脆清掉从库的旧复制配置,否则新旧 GTID 集合冲突会冒出各种奇怪错误。这套 GTID 重建流程用顺了之后,我对复制链路的依赖程度比 position 时代高了不少——现在我拉起任何一套一主二从,都会默认 GTID,顺手把半同步打开,哪怕只是本机演练,因为这两个东西能在早期提前暴露数据一致性的隐患,而这正是异步复制最难查的问题。希望帮到你。
本文还有配套的精品资源,点击获取