1. 从单机到集群为什么要聊主从复制做后端开发的几乎没有人能绕开MySQL。从最早自己电脑上装一个单机实例存点数据到后来扛公司线上业务你会发现单机MySQL的瓶颈来得比想象中快。它不光是性能天花板的问题——一台机器挂了整个业务跟着停数据也可能丢。这时候MySQL主从集群就是最经典、最实用的一套解法。所谓主从集群简单说就是一台主库Master负责写一台或多台从库Slave负责读主库把变更记录实时同步给从库。用到主从架构通常解决这几类问题读写分离主库专心处理INSERT、UPDATE、DELETE从库分担SELECT流量把单机的IO压力拆开。故障转移主库挂了从库能顶上虽然中间会有秒级延迟但至少业务不会完全停摆。数据备份与恢复从库本身就是一份热备份误操作删了数据还能从延迟较大的从库捞回来。离线分析报表、统计类的重查询放到从库跑不影响线上主库的写入性能。我见过不少团队项目刚开始就是一台MySQL跑到底等用户量上来才发现加再多的缓存、再多的索引优化单库的写瓶颈也压不住。主从集群不是银弹但它绝对是高可用和数据安全的基础工程。接下来我不打算只讲理论而是从原理到实战带你把一套MySQL主从集群用Docker从零搭起来。整个过程我会讲清楚每一步为什么这么做、参数怎么选、遇到问题怎么排。Docker在这里承担的角色不是“玩具级”的本地模拟而是真实可用的部署方式——你完全可以把这套配置直接搬到测试环境甚至生产环境稍微调整一下资源限制和安全配置就能用。2. 主从复制原理搞懂binlog你就搞懂了一半2.1 一分钟理解主从复制链路MySQL主从复制的核心机制全靠三个线程配合主库的Binlog Dump线程、从库的IO线程、从库的SQL线程。整个流程可以这样理解主库上所有写操作DML、DDL在提交前会按照顺序写进binlog二进制日志。从库通过change master to命令告诉主库“我要从哪个binlog文件、哪个位点开始同步”。从库的IO线程跑去主库拉取binlog拿到后写入从本地的relay log中继日志。从库的SQL线程读取relay log按顺序在从库上重放这些变更数据就同步过去了。听起来不复杂对吧但里面有几个关键点必须吃透。第一主从复制是异步的默认情况。主库写完binlog就提交事务不关心从库有没有收到。从库延迟再大主库也不等。这种设计的优点是主库性能几乎不受影响缺点是主库崩溃时还没传出去的binlog就丢了从库数据会落后甚至不一致。第二binlog是逻辑日志记录的是“操作”本身不是数据页的物理快照。在ROW格式下一条UPDATE语句会记录所有受影响行的前后镜像。所以从库重放本质上就是拿着主库的“操作流水”再执行一遍。第三复制是单向的。主库写、从库读从库上的写操作不会被同步回主库。这也是为什么很多人主从集群搭好了却因为业务代码把写操作发到了从库导致数据不一致——不是架构错是用法错了。2.2 binlog_format到底选什么建主从集群之前必须先决定binlog的格式。MySQL支持三种格式记录内容优点缺点STATEMENT记录原始SQL语句日志量小非确定性函数如NOW()、UUID()会导致主从数据不一致ROW记录每一行变更前后的数据数据一致性最强日志量较大尤其是批量UPDATEMIXED自动判断默认用STATEMENT遇到非安全语句转ROW兼顾日志量和一致性规则对新手不透明难排障我的建议是生产环境直接选ROW。原因很简单STATEMENT格式下一条UPDATE t SET create_time NOW()主库执行时间是10:00:01从库延迟5秒后重放执行时间是10:00:06两边的create_time就对不上了。这种隐蔽的不一致最难排查。ROW格式记录的是每一行的具体值重放结果必然一致。日志量大的问题可以通过binlog压缩MySQL 8.0支持binlog_transaction_compression或者调大binlog_row_image参数来缓解。绝对不要为了省那点磁盘IO去选STATEMENT代价太大。2.3 GTID让主从复制从“位点驱动”升级为“事务驱动”传统的主从复制是通过“binlog文件名位点pos”来定位同步进度的。这种方式最大的痛点在于如果从库relay log损坏或者主从发生切换位点对不上重建主从关系会非常痛苦。MySQL 5.6之后引入了GTID全局事务标识符每个事务在全局都有唯一ID格式是server_uuid:transaction_id。从库只需要告诉主库“我已经执行到哪些GTID了”主库就知道接下来该发什么不再依赖精确的位点。用GTID的好处非常直接主从切换后新从库自动跳过已执行的事务不需要费劲找位点。复制链路变透明SHOW SLAVE STATUS里可以直接看到Retrieved_Gtid_Set和Executed_Gtid_Set一眼就知道差在哪。更加安全MySQL会拒绝执行会造成主键冲突或数据覆盖的错误复制。所以MySQL 5.7、8.0版本搭主从一律建议开启GTID。这也是我后面实战部分的默认配置我不建议任何人再去用基于位点的老方式搭新集群。3. 集群设计一主一从还是主主互备3.1 常见的拓扑结构怎么选主从集群听起来就一主一从但实际设计时拓扑结构有好几种一主一从入门级解决了单点故障和读写分离的一部分需求。多数个人项目、中小业务从这开始。一主多从读压力大多个从库分摊读流量还能让不同从库分别服务不同业务比如一个给报表查询一个给线上用户。双主互备Master-Master两个库互相复制都能写。听起来很美好但写写冲突的问题非常麻烦需要业务层做拆分。我不太推荐新手上来就玩双主半同步、冲突检测都是比较深的用法。级联复制主库 - 从库A - 从库B。从库A既有复制任务又要把自己的binlog继续传递给从库B。适合跨机房、从库数量特别多的场景减少主库分发压力。这次实战我选一主一从。这个拓扑最简单能让你把复制原理彻底跑通也够绝大多数场景用起来。如果你后续需要更高可用可以在从库上加replica parallel workers、开启半同步再配合MHA或Orchestrator做自动故障转移。3.2 为什么用Docker而不是直接装MySQL我一直坚持一个观点本地开发环境、测试环境、甚至部分生产环境Docker部署MySQL都是很优秀的选择。原因不复杂环境隔离是最大的优势。MySQL的配置、数据文件、日志全部挂载到宿主机目录想换版本、清数据、搭集群一条命令就能重新起一套。新人踩坑最狠的“MySQL装了三小时启动不了”大概率是操作系统环境、权限、依赖导致的Docker把这些复杂度降到最低。版本可控也特别香。Docker镜像用mysql:8.0就固定是8.0.x的某个具体小版本团队所有人拉下来跑的结果完全一样。本地和线上版本差一行配置文件的情况在容器化面前基本不存在的。当然生产环境用Docker需要注意数据持久化——一定要把容器内的/var/lib/mysql映射出来否则容器一删数据全没。还要考虑网络模式、资源限制。后面我给出的方案都做了处理直接抄作业就行。3.3 目录映射与配置分片为了让两个容器的配置清晰、可复现我习惯把配置和数据按容器分开目录结构是这样mysql-cluster/ ├── master/ │ ├── conf/ │ │ └── my.cnf │ └── data/ # MySQL数据目录 └── slave/ ├── conf/ │ └── my.cnf └── data/容器删了重建只要conf和data目录还在数据全在、配置不变。这套目录结构也是我所有Docker化MySQL的标准姿势不只是主从集群单机部署我也这么干。4. Docker实战手把手搭起MySQL主从集群4.1 准备Docker环境先确认本机Docker已经装好。如果你是Windows或macOS直接用Docker Desktop如果是Linux服务器用系统包管理器装docker-ce即可。检查一下docker --version docker compose version版本没问题的话测试一下docker能正常跑docker run --rm hello-world能输出版本信息就OK。如果你在Windows上遇到 “Docker Desktop failed to start ... virtualization support not detected” 这类报错八成是WSL2或者虚拟化没开。去系统BIOS里确认虚拟化技术(VT-x/AMD-V)开启然后控制面板 - 程序 - 启用或关闭Windows功能把Windows虚拟机监控程序平台和适用于Linux的Windows子系统都勾上重启后再启动Docker Desktop。4.2 创建自定义网络和目录容器之间要互相通信最简单的做法是建一个用户自定义的bridge网络好处是容器之间能用容器名当DNS名称直接访问。相比直接用--link自定义网络更干净重启容器后IP漂移也不用改配置。mkdir -p /opt/mysql-cluster/{master/conf,master/data,slave/conf,slave/data} cd /opt/mysql-cluster docker network create mysql-cluster-net命令行方式搭集群我建议用docker compose管理配置都在文件里别人拿过去就能复现。咱们先编辑docker-compose.ymlservices: mysql-master: image: mysql:8.0 container_name: mysql-master restart: always environment: MYSQL_ROOT_PASSWORD: root_master_pwd ports: - 3316:3306 volumes: - ./master/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./master/data:/var/lib/mysql networks: - mysql-cluster-net command: --server-id1 mysql-slave: image: mysql:8.0 container_name: mysql-slave restart: always environment: MYSQL_ROOT_PASSWORD: root_slave_pwd ports: - 3326:3306 volumes: - ./slave/conf/my.cnf:/etc/mysql/conf.d/my.cnf - ./slave/data:/var/lib/mysql networks: - mysql-cluster-net command: --server-id2 depends_on: - mysql-master networks: mysql-cluster-net: external: true这里有几个细节值得说一说端口映射为什么要用3316:3306、3326:3306因为宿主机3306可能被本机MySQL占了映射成不同端口能避免冲突。容器内部还是3306对外访问用3316/3326。server-id必须不同这是主从复制的基础条件。同为一个集群里的节点server-id一样会导致复制异常。数据目录和安全注意事项生产环境不要用环境变量MYSQL_ROOT_PASSWORD明文可以改成Docker Secrets或者用配置文件里的密码占位。depends_on只保证容器启动顺序不保证MySQL初始化完成。所以后面配置主从时要用mysqladmin ping确认主库已经ready。4.3 配置主库开启binlog设置双1刷盘主库的核心任务就是把binlog完整、稳定地写出来。编辑master/conf/my.cnf[mysqld] server-id 1 log-bin mysql-bin binlog_format ROW gtid_mode ON enforce_gtid_consistency ON # 双1参数 sync_binlog 1 innodb_flush_log_at_trx_commit 1 # 建议开启方便后续故障恢复 binlog_expire_logs_seconds 604800 # 表名大小写不敏感 lower_case_table_names 1逐个解释log-bin前缀为mysql-bin的binlog文件不写这个就没有binlog复制无从谈起。gtid_mode ON和enforce_gtid_consistency ON这对组合是MySQL官方建议必须一起开。光开gtid_mode不开enforce_gtid_consistency可能产生非GTID安全的事务影响复制稳定性。sync_binlog 1每次事务提交都强制把binlog刷到磁盘。性能有一点损耗但换的是崩溃时binlog不丢失。主库可以接受这个代价。innodb_flush_log_at_trx_commit 1每次提交都刷redo log。这两个一起开就是经典的“双1”配置。对追求性能的极致场景可以松成innodb_flush_log_at_trx_commit2但主从场景我建议不开这个口子。binlog_expire_logs_seconds 604800binlog保留7天防止磁盘被日志塞满。MySQL 8.0里expire_logs_days已废弃用这个。4.4 配置从库只读模式从库GTID蓄势待发编辑slave/conf/my.cnf[mysqld] server-id 2 log-bin mysql-bin binlog_format ROW gtid_mode ON enforce_gtid_consistency ON relay_log mysql-relay-bin # 只读模式 read_only ON super_read_only ON从库的binlog写不写写。不是为了给主库看而是为了后续可能出现的级联复制、主从切换。从库如果不开binlog将来要做“从-主”切换或者做“从的从”时没有binlog就断了链。read_only ON普通用户从库只读。super_read_onlyON连super用户也只读这样才能彻底防止应用层错误写入从库导致数据分叉。lower_case_table_names 1这句我放在主从两个配置里都有注意要在data目录初始化之前设置如果MySQL已经初始化过数据目录改这个参数可能导致启动失败。这也是很多朋友搞挂MySQL的第一个坑如果你已经初始化过了就不要动这个参数。4.5 启动容器等待初始化完成配置写好后启动集群docker compose up -d启动后一定要确认MySQL真正初始化完成别急着配复制。用下面命令等它# 检查容器状态 docker ps # 等主库MySQL可用 docker exec mysql-master mysqladmin ping -uroot -proot_master_pwd --silentmysqladmin ping返回mysqld is alive就表示主库OK了。重复检查从库也一样。4.6 创建复制用户最小权限原则主库需要创建一个专门给从库连接用的用户密码、权限都要独立管控别拿root直接当复制账号。docker exec -it mysql-master mysql -uroot -proot_master_pwd CREATE USER repl% IDENTIFIED BY repl_passwd; GRANT REPLICATION SLAVE ON *.* TO repl%; FLUSH PRIVILEGES;只给REPLICATION SLAVE权限意思是这个账号只能拉取binlog不能读写业务数据。如果后面要切换到半同步复制还需要REPLICATION CLIENT权限我们后面再加。这里注意一下%代表任意IP都能连。如果知道从库的固定IP尽量收紧只写从库的IP或容器IP。但由于Docker网络下容器IP可能变化集群内一般就用%配合Docker网络隔离来保证安全。4.7 查看主库状态记录GTID起点SHOW MASTER STATUS;输出大致是这样的File: mysql-bin.000003 Position: 157 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: 99434a1d-1a2b-3c4d-5e6f-7a8b9c0d1e2f:1-3如果你用GTID模式关键要看最后一行Executed_Gtid_Set后面change master时要用到它。如果用老式位点方式记住File和Position两个值。注意不要在这里执行FLUSH TABLES WITH READ LOCK去锁库。因为我们是空库起步binlog位点就是初始状态没有并发写入不需要锁。线上有数据的情况要么你停写要么用备份工具mysqldump、Percona XtraBackup带位点做初始化这里不展开。4.8 配置从库复制链路进入从库容器执行change masterdocker exec -it mysql-slave mysql -uroot -proot_slave_pwd然后在MySQL里执行CHANGE REPLICATION SOURCE TO SOURCE_HOSTmysql-master, SOURCE_PORT3306, SOURCE_USERrepl, SOURCE_PASSWORDrepl_passwd, SOURCE_AUTO_POSITION1; START REPLICA;MySQL 8.0里老语法是CHANGE MASTER TO、START SLAVE也都兼容。我用的是8.0推荐的新语法功能一样。SOURCE_HOSTmysql-master要用容器名因为我们在同一个自定义网络里Docker DNS会自动解析。用127.0.0.1肯定不行那是容器自己。SOURCE_AUTO_POSITION1表示基于GTID自动定位复制起点不需要手动填binlog文件名和pos。4.9 检查复制状态SHOW REPLICA STATUS\G重点看以下几项Slave_IO_Running: Yes Slave_SQL_Running: Yes Replica_IO_Running: Yes Replica_SQL_Running: Yes Seconds_Behind_Master: 0所有字段都是Yes且延迟为0就说明主从复制已经通了。有个细节Slave_IO_Running和Replica_IO_Running是同一个指标5.7叫Slave_IO_Running8.0用Replica_IO_Running。另外要注意报错信息如果有FATAL ERROR: The slave I/O thread stops because master and slave have equal MySQL server UUIDs那多半是你直接复制了宿主机上已有的MySQL数据目录去建的容器导致server_uuid相同。解决方案就是清掉从库data目录已初始化的数据重新初始化或者手动修改从库的auto.cnf文件。4.10 实测写入验证主从效果主库建库建表插入数据CREATE DATABASE demo; USE demo; CREATE TABLE user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50)); INSERT INTO user(name) VALUES(Alice),(Bob);切到从库看看USE demo; SELECT * FROM user;能查出Alice、Bob这两条记录主从复制就真正跑通了。这一步一定不要跳过很多人的主从表面配好了实际一测才发现数据根本不过去。4.11 在线添加从库从备份恢复到变更自动追平生产环境里主库往往已经有数据了不是从零起步。这种情况下直接在从库上change master从库会从binlog起点开始重放如果你主库binlog已经清理掉了就根本同步不上。正确的做法是用备份工具在主库做一次全量备份mysqldump恢复到从库然后用GTID模式下自动定位从库会自己追平备份时刻到当前的所有binlog变更。# 主库导出空库或线上库都可局部参数按需 docker exec mysql-master sh -c exec mysqldump -uroot -proot_master_pwd --all-databases --single-transaction --triggers --routines --events --set-gtid-purgedON all_backup.sql再导入从库docker exec -i mysql-slave sh -c exec mysql -uroot -proot_slave_pwd all_backup.sql之后再从第4.8步开始change master。GTID模式下mysqlbinlog里记录的不再是“从这个文件这个pos开始”而是“哪些GTID已经执行过了”所以备份自动定位的组合非常优雅这也是GTID明显优于位点的场景。这里要提醒一点mysqldump导出时--set-gtid-purgedON会清空从库原有的GTID执行集合。如果你从库已经有用户自己建的数据恢复之前最好确认一下否则导入之后这些额外数据的GTID信息会丢失影响后续复制。5. 进阶半同步复制、状态监控与故障恢复5.1 切换半同步复制让数据不“裸奔”默认的异步复制主库commit后不等待任何确认。如果主库突然宕机有一部分事务可能还没传到从库这就是数据丢失的窗口。半同步复制Semi-Synchronous Replication就是为了缩小这个窗口而生的。半同步的思路主库提交事务后必须等待至少一个从库收到binlog并写入relay log主库才把结果返回给客户端。这样主库崩溃时事务已经复制到了从库数据丢失窗口基本消除极端情况下从库也同时宕机另说。在主库安装插件INSTALL PLUGIN rpl_semi_sync_source SONAME semisync_source.so; SET GLOBAL rpl_semi_sync_source_enabled 1;在从库安装插件INSTALL PLUGIN rpl_semi_sync_replica SONAME semisync_replica.so; SET GLOBAL rpl_semi_sync_replica_enabled 1;注意MySQL 8.0里的插件名带source和replica5.7叫master和slave。设置后从库需要重启IO线程STOP REPLICA; START REPLICA;让半同步生效。然后通过主库状态变量确认SHOW STATUS LIKE Rpl_semi_sync_source_status; SHOW STATUS LIKE Rpl_semi_sync_source_clients;Rpl_semi_sync_source_statusON且clients大于0说明至少有一个从库已启用半同步。后续业务写入主库时会等待从库确认性能稍下降一点这是数据安全换来的。5.2 复制状态监控日常巡检的3个SQL主从集群不是搭完就一劳永逸。我建议日常巡检只盯三个东西复制线程状态SHOW REPLICA STATUS\G里的Slave_IO_Running、Slave_SQL_Running必须都是Yes。延迟大小Seconds_Behind_Master只是一个近似值等于0不代表没延迟但持续变大一定有问题。GTID集合对比对比主库的Executed_Gtid_Set和从库的Executed_Gtid_Set找出具体缺失的事务ID比单纯看秒数靠谱得多。生产环境建议把这三项集成到监控告警里。脚本判断逻辑不复杂线程不跑报警延迟超过阈值报警。别等到业务方反馈数据不对了才想起来看一眼。5.3 故障演练主库宕机从库如何顶上主从集群在高可用上的核心能力是主库挂掉后能从库顶上。这个操作如果没有提前演练过线上出问题时往往会手忙脚乱。假设主库容器挂了要把从库提升为新的主库在从库执行STOP REPLICA;先停掉复制应用。重启MySQL让从库以非只读模式运行或者直接动态改配置SET GLOBAL read_only OFF; SET GLOBAL super_read_only OFF;如果有应用还在连接旧主库的地址你需要把域名或负载均衡切到新主库。另外还有其他从库指向旧主库的需要重新change master到新主库。这就是故障切换最原始、最手工的形态。生产上通常配一个自动切换组件Orchestrator、MHA之类来干这事儿。但不管你用什么组件自己做一遍手工切换能让你彻底理解复制内部状态。切换过程中最容易踩的坑是旧主库其实没死透只是网络分区了它还在继续写数据。等网络恢复旧主库重新加入两边数据已经分叉复制直接被拒绝。解决思路是确保旧主库以只读模式重新加入集群然后把它降级成新主库的从库不能随便让它重新写。5.4 常见错误与排查“快查表”我把自己实战中碰到过的高频问题整理成一个表方便你排障时对照现象排查思路解决办法Slave_IO_Running: Connecting网络不通、账号密码错、防火墙挡了3306先在从库容器里ping主库容器名再检查复制账号权限和密码Slave_SQL_Running: No1062错误从库有数据重放时主键冲突确认从库没有业务写必要时手动删除或补上冲突行再START REPLICAserver has more than one server_id两个容器配置了相同server-id分别检查master和slave的my.cnf确保server-id唯一binlog找不到复制起点失效主库binlog被清理了检查binlog_expire_logs_seconds从库需要重新全量备份再re-change从库数据与主库不一致但不报错复制的binlog格式为STATEMENT且有非确定性函数改成ROW格式从库重新基于备份重建The slave I/O thread stops because master and slave have equal MySQL server UUIDs克隆了数据目录导致UUID相同修改从库auto.cnf或清空data重新初始化一个非常实用的技巧SQL线程报错对应的事务ID会出现在SHOW REPLICA STATUS里的Last_SQL_Error中。你可以在从库上STOP REPLICA; SET GLOBAL sql_slave_skip_counter 1; START REPLICA;直接跳过这一步。**但注意别乱用——跳过一次只是绕过当前这条SQL如果错误有几十条你应该先搞清楚为什么会冲突人工修复完数据再恢复复制。5.5 安全基线生产环境还要做什么写到这里主从集群的核心已经跑通了。但如果你是在公司环境部署还有几件事必须要做否则上线评审都过不了数据目录和备份目录做好权限管理不要让普通用户能访问master/data。禁用root远程登录所有应用走专用账号按最小权限授权。主从之间通信建议放在独立的内网VPC或Docker overlay网络里不要直接暴露公网端口。定期做恢复演练从从库的备份文件恢复一台全新实例确认能正常启动。备份不是拿来好看的是拿来救命的。6. 我踩过的坑以及给你的建议文章写到最后还是忍不住想分享几个印象特别深的坑。第一个是Windows上的Docker Desktop虚拟化问题。我第一次用的环境是Windows笔记本Docker Desktop反复报“virtualization support not detected”卡了半个多小时。最后发现BIOS里的虚拟化开关没开开了之后Windows功能里还得勾上WSL2的选项。如果你也卡在这步别怀疑Docker有问题先把宿主机环境搞定。第二个是误用了别人的数据目录导致UUID冲突。有一阵为了快速验证集群我图省事把主库的data目录直接拷给从库当初始数据结果两个容器里的server_uuid完全一样IO线程一直连不上报错信息一开始还没看明白。后来清掉从库的auto.cnf让它自己生成新的UUID才解决。从此养成习惯每个实例的data目录必须独立初始化。第三个是半同步的版本坑。MySQL 5.7时代插件名是rpl_semi_sync_master到了8.0改成了rpl_semi_sync_source。网上很多教程还是老语法直接照搬会报找不到插件。版本差异这种事真的只有在版本升级时才会痛一次。这块内容如果还要往后扩展可以考虑用docker compose把MHA或Orchestrator也编排进来。但现在你手上这套GTID 半同步 binlog保留策略的主从集群已经比很多项目里的生产环境扎实了。齐活。