基于Docker Compose的MySQL一主二从复制配置实战与避坑指南
简介:面向需要快速搭建MySQL主从复制环境的Docker用户,这里提供了一套基于Docker Compose的一主二从配置方案,解决开发与测试环境中数据库读写分离、数据冗余和负载均衡的部署难题。包内以docker-compose.yml为中心,配套primary、replica1、replica2三个实例的initdb与conf.d目录,包含.cnf配置文件、.sh初始化脚本和.sql建表脚本共8个文件,压缩包小巧仅5KB,目录层级直观,适合直接套用或按需修改。配置逻辑完整覆盖主库server-id与log-bin开启、从库read-only限制、主从复制账号及权限设置,并通过depends_on控制容器启动顺序,同时给出健康检查与等待MySQL就绪的注意点,可避免常见的从库连接失败问题。已有39人学习下载,适合具备一定Docker基础、希望规避手工配置多容器复杂度的运维和开发人员参考,能显著缩短一主二从环境的搭建与排查时间。 干过几年数据库维护的都知道,单库跑业务就像走钢丝,读请求一多主库CPU直接拉满,慢查询能把整个业务拖垮。这阵子我基于docker-compose搭了一套MySQL一主二从,用来做读写分离和容灾演练,整个过程踩了一些坑,这篇文章把配置从头到尾讲清楚。内容包含完整的docker-compose文件、主从配置文件、复制账号初始化、复制状态验证,以及我在实际部署中遇到的高频问题。适合已经会MySQL基础操作、想快速复现一套主从复制环境的开发者,也适合准备把数据库高可用方案落地的运维同学参考。
1. 主从复制方案整体设计
1.1 为什么选MySQL原生binlog复制
MySQL主从复制最经典的实现方式,就是基于binlog的异步复制。主库把变更写入二进制日志,从库上的I/O线程拉取并写到本地relay log,SQL线程再回放relay log。整个链路很简单,数据最终一致,性能开销也不大。
有人可能会问,为什么不用双主、组复制或者半同步?我的判断很直接:一主二从的目标是解决读压力、隔离备份,不需要自动故障转移。原生异步复制足够达到目的,而且排错路径清晰。用组复制虽然能提供高可用,但架构复杂度明显上升,测试环境没必要一上来就堆这么多东西。
我在配置里明确开启了GTID模式。GTID就是每个事务的全局唯一标识,相比传统基于binlog文件名+偏移量的定位方式,GTID让从库连接主库时的位置判断变得自动且可靠。尤其是在容器重启、从库重搭的场合,GTID能省掉一大半手工对齐位置的心智负担。
1.2 为什么用docker-compose而不是直接装多个MySQL实例
以前我在服务器上搭主从,习惯直接装好几个MySQL实例,或者用mysqld_multi管理。结果有两个痛点:一是配置分散,每个实例的my.cnf容易改乱;二是环境不可复现,换台机器就得重新搞一遍。
docker-compose的好处是“基础设施即代码”。一个docker-compose.yml把三个MySQL服务的镜像、端口、数据卷、网络、健康检查全部定义清楚,一条命令拉起整套环境,删了重建也无所谓,数据放在宿主机目录,不会轻易丢。
当然我这里要说明白:生产环境如果跑的是高并发业务,我不建议用docker跑数据库,毕竟容器网络和磁盘IO多多少少会有损耗。但这次搭建的目的是测试、学习和做读写分离演练,docker-compose就是性价比最高的选择。真到生产环境,思路一致,只是部署形态需要换成物理机或云数据库。
1.3 拓扑与端口规划
我规划的拓扑很简单:一个主库master,两个从库slave1、slave2。三个容器跑在同一个自定义bridge网络内,相互之间用服务名通信,这样从库在容器内访问主库时永远走3306端口,不需要关心宿主机端口映射。
| 角色 | 容器名 | 宿主机端口 | 容器内端口 | server-id |
|---|---|---|---|---|
| 主库 | mysql-master | 3306 | 3306 | 1 |
| 从库1 | mysql-slave1 | 3307 | 3306 | 2 |
| 从库2 | mysql-slave2 | 3308 | 3306 | 3 |
数据卷我这样规划:主库数据放在./data/master,从库1放在./data/slave1,从库2放在./data/slave2。外部挂载的好处是容器升级、重搭不会丢数据。这里有一个容易忽略的细节:三个容器的server-id必须不同,否则从库连接主库后会出现复制ID冲突,表现就是Slave_SQL_Running异常。
2. 环境准备与目录规划
2.1 依赖确认
在动手之前,先把环境确认好,省得后面各种灵异问题。
需要安装的东西是Docker和Docker Compose插件。现在主流的docker compose命令是docker compose,老项目里还能看到docker-compose这种写法,底层逻辑一样。检查命令如下:
docker --version docker compose version如果没有Docker,不同发行版安装方式不太一样,这里不展开。我的建议是直接用官方源安装最新稳定版,别用系统自带的旧版本。Compose版本太旧会导致部分语法解析失败,比如healthcheck字段、restart: unless-stopped这类配置在旧版本上不识别。
2.2 镜像选择
镜像我用的是mysql:8.0。选8.0的核心原因是MySQL 8.0已经是当前主流版本,无论是安全特性还是性能都比5.7强很多。需要注意一点:8.0默认的认证插件是caching_sha2_password,这对我们后面创建复制账号有影响,我会在第五节专门讲。
如果不熟悉8.0,想先拿5.7练习,逻辑也完全一致,只是部分参数默认值不同。文中的命令和配置在5.7上基本也能跑,只是复制账号的认证插件问题在5.7上不那么突出。
2.3 目录结构
项目目录我建成了这样:
mysql-cluster/ ├── docker-compose.yml ├── master/ │ ├── conf/my.cnf │ └── data/ ├── slave1/ │ ├── conf/my.cnf │ └── data/ └── slave2/ ├── conf/my.cnf └── data/data目录不用手动创建,Docker挂载时会自动生成。conf/my.cnf必须手动创建,因为这是MySQL服务启动时加载配置的关键文件。我习惯把所有现网用过的配置文件保留在Git里,这样每次重建环境都有一份可追溯的基线。
2.4 主从配置文件
主库master/conf/my.cnf的内容:
[mysqld] server-id=1 log-bin=mysql-bin binlog-format=ROW gtid_mode=ON enforce_gtid_consistency=ON从库slave1/conf/my.cnf和slave2/conf/my.cnf基本一样,只是server-id不同:
[mysqld] server-id=2 log-bin=mysql-bin binlog-format=ROW gtid_mode=ON enforce_gtid_consistency=ON read_only=ON super_read_only=ON从库上我也开了binlog,而且加了read_only和super_read_only。这里解释一下为什么:从库开binlog是为了以后把它提升为主库时还有完整的日志链路;read_only防止业务写入从库,但仅仅read_only对root不生效,所以再加super_read_only,这样即使是超级用户也不能写,除非显式把super_read_only先关掉。
3. docker-compose配置与容器启动
3.1 docker-compose.yml完整内容
直接给出我最终使用的完整配置。为方便阅读,我把服务拆成三块,主库和两个从库都加上了healthcheck,这样后续编排可以基于健康状态做依赖等待。
services: master: image: mysql:8.0 container_name: mysql-master restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: root123 ports: - "3306:3306" volumes: - ./master/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./master/data:/var/lib/mysql networks: - mysql-net healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] interval: 5s retries: 10 slave1: image: mysql:8.0 container_name: mysql-slave1 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: root123 ports: - "3307:3306" volumes: - ./slave1/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./slave1/data:/var/lib/mysql networks: - mysql-net healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] interval: 5s retries: 10 slave2: image: mysql:8.0 container_name: mysql-slave2 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: root123 ports: - "3308:3306" volumes: - ./slave2/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./slave2/data:/var/lib/mysql networks: - mysql-net healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] interval: 5s retries: 10 networks: mysql-net: driver: bridge注意这里把自定义配置文件挂载到/etc/mysql/conf.d/my.cnf,MySQL启动时会自动加载该目录下的所有.cnf文件,从而覆盖默认配置。还有一种方式是直接通过command传参,比如--server-id=1,效果一样,但我更推荐保留my.cnf,因为可读性更好,且后续改配置不需要动compose文件。
3.2 启动并检查容器
在mysql-cluster目录下执行:
docker compose up -d docker compose ps正常情况下三个容器都是Up状态。如果某个容器启动失败,用日志定位:
docker compose logs master常见启动失败原因是my.cnf文件权限或语法问题,比如mysql容器对挂载配置文件的属主要求比较严格,如果宿主机文件权限不对,MySQL可能直接拒绝启动。我遇到过文件是root:root且0644,容器内mysql用户也可以读,但部分发行版默认snap安装的Docker会有SELinux限制,导致挂载目录无法访问,这时候需要调整SELinux context,或者用-v的z后缀,例如在volume配置里写./master/conf/my.cnf:/etc/mysql/conf.d/my.cnf:z。
3.3 在主库创建复制账号
容器启动完成后,进入主库容器:
docker exec -it mysql-master bash mysql -uroot -proot123然后创建复制专用账号。这一步很关键,如果使用MySQL 8.0,强烈建议用mysql_native_password来创建,避免从库I/O线程连不上:
CREATE USER 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'repl123'; GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;REPLICATION SLAVE权限是必须的,REPLICATION CLIENT是为了让这个账号能执行SHOW MASTER STATUS之类的命令,方便排查问题。在生产环境,建议把账号网段限制得更严,比如'repl'@'192.168.1.%',而不是%。
3.4 从库执行CHANGE MASTER
分别在两个从库容器里执行配置主库的命令:
docker exec -it mysql-slave1 bash mysql -uroot -proot123STOP SLAVE; CHANGE MASTER TO MASTER_HOST='master', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_AUTO_POSITION=1; START SLAVE;slave2也执行同样命令。这里专门强调一个最容易踩的坑:MASTER_HOST必须写docker-compose网络里的服务名master,不是localhost,也不是宿主机IP。因为这是在容器内部连接主库容器,走的是docker内网。有人说我宿主机IP也能通,那是因为端口映射到了宿主机,但从库容器访问宿主机IP需要走网关,反而增加了不确定因素,服务名是最稳的。
4. 复制状态验证
4.1 查看关键状态字段
在从库执行:
SHOW SLAVE STATUS\G重点关注这些字段:
| 字段名 | 期望值 | 说明 |
|---|---|---|
| Slave_IO_Running | Yes | I/O线程是否在拉取日志 |
| Slave_SQL_Running | Yes | SQL线程是否在回放日志 |
| Seconds_Behind_Master | 0 | 从库落后主库的秒数 |
| Last_IO_Errno | 0 | I/O线程最近错误号 |
| Last_SQL_Errno | 0 | SQL线程最近错误号 |
| Retrieved_Gtid_Set | 非空 | 从库已拉取的GTID集合 |
只要Snake_IO_Running和Slave_SQL_Running都是Yes,基本就说明复制建立成功了。Seconds_Behind_Master是0表示没有延迟,瞬时写入量大时这个值会跳动,只要稳定回落就不用紧张。
4.2 数据同步测试
验证复制是否真的生效,最直接的办法就是在主库建表写数据。
主库执行:
USE app_db; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); INSERT INTO user(name) VALUES ('zhangsan'), ('lisi');到从库1查询:
USE app_db; SELECT * FROM user;如果能查到两条记录,说明同步正常。我再强调一个细节:从库的数据是异步复制过来的,刚执行完主库写入后立刻查询,理论上可能有毫秒级延迟,但通过GTID方式连接,一般不会出现查询不到的问题。
4.3 从库只读验证
为了确认从库真的不可写,我习惯直接做一次写入测试:
USE app_db; INSERT INTO user(name) VALUES ('wangwu');预期会报错:The MySQL server is running with the --read-only option,这就说明read_only和super_read_only都生效了。有人在这里会发现root可以写,那是因为super_read_only没开,或者当前用户具备SUPER权限。所以配置文件中我特意把super_read_only=ON加上了。
5. 实际部署中的常见问题
5.1 I/O线程起不来,Last_IO_Errno 2061
这个问题在我早期搭8.0主从时频繁出现。报错日志里通常会有Authentication plugin 'caching_sha2_password' cannot be loaded之类的话。原因就是MySQL 8.0默认使用caching_sha2_password认证,而从库连接时用的客户端库可能不支持。
解决办法是我前面写的,创建复制账号时显式指定IDENTIFIED WITH mysql_native_password。虽然MySQL官方已经逐步推荐caching_sha2_password,但兼容性和复制的顺畅度才是这里的关键,开发测试环境没有必要上最严格的安全插件。
5.2 容器重启后复制中断
docker-compose环境经常因为重启或者系统更新导致容器重建。有些情况下重启后Slave_SQL_Running会变成No,或者从库一直卡在某个GTID不再前进。
根因往往是relay log上下文或者server-id冲突。我的排查顺序是:先看Last_SQL_Errno,如果数据量不大,最直接的办法是重建从库复制关系:
STOP SLAVE; RESET SLAVE ALL; CHANGE MASTER TO MASTER_HOST='master', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_AUTO_POSITION=1; START SLAVE;不要怕重建,GTID模式下从库会自动从主库最近的GTID位置开始拉取,不需要重新初始化数据。前提是从库数据目录没有脏数据,如果从库数据已经和主库不一致,那就需要重新灌一次基础数据。
5.3 端口映射导致的连接失败
这是一个看起来很低级但很容易犯的错。很多人在从库执行CHANGE MASTER时,MASTER_PORT写成宿主机映射出来的3307或3308,甚至写成3306但把MASTER_HOST写成宿主机IP,结果I/O线程一直报Last_IO_Errno 2003,无法连接主库。
原因很简单:从库容器和主库容器在同一个docker网络里,它们之间通信使用容器IP和服务名,端口永远是3306。宿主机映射的3307只是给宿主机或外部客户端使用的,容器内部不能拿这个端口去找主库。
5.4 SQL线程报1062或1032错误
这类错表示主从数据已经不一致了,可能是从库被手动写过数据,或者binlog回放时发生主键冲突。错误处理有个原则:不要一上来就执行SET GLOBAL SQL_SLAVE_SKIP_COUNTER=1,这样会把问题掩盖掉。
我的做法是:先判断不一致的范围,如果只有个别事务出错,可以精准定位后手工补偿;如果错误很多,说明从库数据可信度已经很低,直接重建从库更省事。这里重建从库的代价远低于在脏数据上继续修补。
6. 生产环境下的进一步建议
6.1 数据卷与网络规划
用docker-compose搭主从,数据卷必须放在宿主机,别贪图方便写匿名卷,否则容器一删数据就没了。生产环境如果要跑MySQL,我建议不要用默认的docker0或者自定义bridge网络跑数据库集群,因为NAT转发对数据库这种长连接密集型应用不够友好。可以考虑network_mode: host,但代价是端口管理变复杂。
磁盘方面,如果条件允许,把数据目录放到独立的SSD盘上,避免和系统盘抢IO。
6.2 复制状态监控
主从复制配好只是起点,长期运行一定要监控两个关键线程。最简单的做法是写个脚本,每隔30秒在从库执行SHOW SLAVE STATUS,检查Slave_IO_Running和Slave_SQL_Running是否为Yes,不是就告警。
更专业一点,用Prometheus加mysqld_exporter,把Seconds_Behind_Master采集起来,配合Grafana画趋势图。这是一个典型的建设建议,但我在实践中发现很多人连第一层的脚本告警都没做,直到主从断了很久才发现,这种事故特别被动。
6.3 从库作为备份节点
一主二从搭建好以后,我习惯把备份任务放在从库上执行,避免备份影响主库性能。常用的备份命令是:
mysqldump -uroot -proot123 --single-transaction --source-data=2 app_db > backup.sql这里要点一下:MySQL 8.0.26之前参数叫--master-data,之后改成了--source-data。=2表示在备份文件的注释里记录binlog位置或GTID信息,方便后面用这份备份去搭建新的从库。如果要做更快速的物理备份,可以使用percona-xtrabackup,效果更好,但配置复杂度也更高。
6.4 读写分离的下一步延伸
一主二从搭完,如果业务想要真正减轻主库读压力,还需要在这套架构前面接入读写分离中间件。目前比较主流的选择是ProxySQL和ShardingSphere-Proxy。ProxySQL配置简单,对MySQL协议兼容性好,可以把读流量按权重分发到两个从库,写流量强制走主库。
我打算过阵子把ProxySQL加到这套docker-compose环境里,再把读写分离的效果测完发出来。到时候会发现,一主二从本身只是基础设施,上层路由才是完整落地的最后一公里。
最后再分享一个我自己的习惯:每次搭完这类环境,我都会在项目目录里放一份README,把关键账号、端口、复制命令、踩过的坑全写进去。等过三个月自己回来看,会发现这份README比任何博客都实用。这套一主二从环境我现在仍然在测试环境中使用,后续接上ProxySQL后我会再补一篇读写分离的实战文章。希望这篇配置过程能帮你少走一些弯路。
本文还有配套的精品资源,点击获取
