MySQL5 双主复制架构,几乎每个做数据库运维的朋友早晚都会碰到这个词。我早年在维护一套订单系统时,业务量涨到单库扛不住读压力,又不想一上来就拆库分表,于是评估了一圈高可用方案,最后选了双主复制:两个 MySQL 节点互为主从,任何一边写入,另一边实时同步,既能分担读流量,又能做到主库故障时快速切换。这篇文章就把当初从搭环境、改配置、建链路,到后来踩自增冲突、复制中断、脑裂风险的完整过程拆开写透,适合正在做 MySQL 高可用选型、或者已经搭了主从但想升级成双主的同学参考。
1. 为什么业务要上双主,双主到底解决了什么问题
单主从架构在绝大多数场景够用:主库负责写,从库只读,配合半同步还能降低丢数据的概率。但它有个明显的痛点——主库挂了,从库不会自动顶上来。要么靠人工执行切换脚本,要么靠外部高可用组件介入,中间那段业务不可用时间,在核心链路里往往不可接受。双主复制思路不一样:两个节点都开着写能力,互为对方的从库,流量可以从应用层动态切换,某一台挂了,另一台原本就是主,继续干活的成本最低。
1.1 主从架构的短板:故障切换的空窗期
我遇到过不少团队,MySQL 主从配好之后就把监控面板扔一边,直到某天大促前主库磁盘写满,才发现从库虽然在同步,但应用只能连主库,切从库需要改一大堆连接配置和权限。即便用 VIP 漂移,VIP 本身也可能因为主从数据不一致,切换后丢最近几秒的事务。双主解决的就是这个“空窗期”:两侧数据时刻双向同步,任何一侧宕机,业务连接直接打到存活节点,数据完整性和对外可用性都能保住。
1.2 双主复制能做什么、不能做什么
双主最典型的用途有三个:一是同一机房内做高可用互备,写 A 读 B,A 挂了 B 立刻顶上;二是跨机房容灾,两个节点分处不同地域,各自承担本地读写,靠异步复制汇总;三是读写分离的加强版,两边都能读,写能力冗余。需要注意的是,双主并不是让人在上面随意两边同时写同一条数据,它不解决数据冲突问题,只解决单点故障问题。把双主当成“两边乱写也不会出错”的方案,那是误解,后面第 5 节我会专门讲冲突风险。
2. 双主架构的核心原理:复制链路、日志机制与自增策略
想搭好双主,先得把复制这条链路真正理解透,否则出了问题,看状态全 Yes 却不知道数据为什么慢半拍。
2.1 复制原理回顾:从 binlog 到 relay log 再到 position
MySQL 复制本质是三个线程在干活。主库的 binlog dump 线程把 binlog 事件推给从库的 IO 线程,IO 线程把内容拉到本地 relay log,从库的 SQL 线程再重放 relay log 完成数据变更。A 到 B 是一条链路,B 到 A 是另一条链路,两条链路独立工作,互不干扰。链路的位置信息由 binlog 文件名和偏移量决定,CHANGE MASTER TO 里指定的 MASTER_LOG_FILE 和 MASTER_LOG_POS 就是起点。双主搭建时最关键的,就是两条链路的起点坐标要互相咬合,不能各说各话。
2.2 自增 ID 冲突怎么解:auto_increment_increment 与 offset 分配原则
双主如果两边同时写业务表,自增主键必然撞车。MySQL 自带的解决方案是调整自增步长:设置 auto_increment_increment = 2,节点 A 的 auto_increment_offset = 1,节点 B 的 auto_increment_offset = 2。这样 A 生成的是 1、3、5、7,B 生成的是 2、4、6、8,永远错开。不要贪心设成 3 台或更多机器同时写共享库,那样 offset 要按集群规模去规划,而且扩展时非常痛苦。我一般建议,超过两个写节点就不适合继续用双主放大了,直接走中间件或分片更理智。
2.3 半同步复制在双主里的位置
双主异步复制两边都有延迟窗口,极端情况下一台宕机,最后那一小段已经提交但没同步过去的事务可能丢。MySQL 5.5 以后支持半同步复制:事务在主库提交后,要等到至少一个从库确认收到 binlog 事件,才算真正提交成功。双主架构里可以把两条链路都开半同步,这样单边宕机时,事务大概率已经在另一台留了底,损失窗口大大缩小。代价是每次写入多一次往返,吞吐会略降。如果业务允许少量延迟,但严禁丢核心交易数据,半同步是双主的标配。
3. 搭建前的环境规划与 my.cnf 关键参数
很多人一上来就装 MySQL,装完直接 CHANGE MASTER TO,结果不是 server-id 冲突就是二进制日志没开,返工好几次。我整理一下自己常用的初始化流程,照着做基本不会翻车。
3.1 版本选型与节点设计
我当时用的是 MySQL 5.7.38,两个节点都是 8C16G 云主机,CentOS 7.9,数据盘独立挂载。选 5.7 主要是考虑到线上兼容性和半同步插件成熟度。双主对硬件不要求一模一样,但建议性能接近,否则慢节点会拖累整条复制链路。两个节点的 server-id 必须全局唯一,这是铁律,A 设 1,B 设 2,后面所有配置都以这两个 ID 展开。IP 规划方面我习惯用内网互通地址做复制专用通道,避免走外网带来延迟和安全隐患。
3.2 my.cnf 关键参数逐项说明
下面清单是我在双主场景下反复验证过的基线配置,A、B 节点只有 server-id 和 offset 不同,其余保持一致:
[mysqld] # A 节点 server-id = 1 log-bin = mysql-bin binlog_format = ROW log_slave_updates = 1 relay_log = relay-bin relay_log_recovery = 1 auto_increment_increment = 2 auto_increment_offset = 1 expire_logs_days = 7 max_binlog_size = 256M sync_binlog = 1 innodb_flush_log_at_trx_commit = 1 skip_name_resolve = ON# B 节点,复制自 A,仅改动两处 server-id = 2 auto_increment_offset = 2参数逐个说清楚:
- log-bin:双主的底线,不开它就没有复制源。
- binlog_format = ROW:双主场景强烈建议行格式,因为 ROW 模式复制的是变更后的具体行,冲突排查时能直接看到哪个库哪张表哪条记录出错,不像 STATEMENT 模式依赖 SQL 执行环境,两边语法不一致就容易出岔子。
- log_slave_updates = 1:这是双主能否闭环的关键。从库重放日志后,需要把重放产生的变更再写进自己的 binlog,好让对面的节点继续同步。不开它,B 写入的数据永远到不了 A。
- relay_log_recovery = 1:从库崩溃重启自动丢弃未应用的 relay log,避免残留脏数据导致 SQL 线程报错。
- auto_increment_increment / offset:第 2 节已经讲过,A 出奇数,B 出偶数。
- sync_binlog = 1 和 innodb_flush_log_at_trx_commit = 1:双主高可用场景要防的是“提交成功但日志没落盘”,这两项保证每次提交 binlog 和 InnoDB redo 都及时刷盘。单纯的异步主从可以放松,高可用双主不建议放松。
- skip_name_resolve = ON:让复制连接直接用 IP 认证,避免反向 DNS 抖动导致连不上复制源。
配置完千万别急着启动,先 mysqld --validate-config 检查语法,再 systemctl start mysqld。不然写错一个参数名,进程起不来或者半死不活,排查很费时间。
3.3 复制账号与权限的最小化设计
MySQL 复制账号不需要超管权限,够用就好。我在两个节点分别执行:
CREATE USER 'repl'@'10.0.0.%' IDENTIFIED BY 'YourStrongPass'; GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'10.0.0.%';REPLICATION CLIENT 权限用于 SHOW MASTER STATUS 和 SHOW SLAVE STATUS 的检查,监控脚本也靠它。如果你后续要做数据一致性校验,再单独给用于校验的账号加 SELECT 权限,不要图省事直接 GRANT ALL。
4. 双主复制搭建全流程实操:从初始化数据到双向链路收敛
这节我按完整的操作顺序走一遍,每个命令都有注释。
4.1 初始化数据:利用 mysqldump 让两个节点数据对齐
双主搭建的前提是两边数据完全一致,最稳妥的办法是用 A 节点做基础备份,恢复到 B。注意要在 A 上临时锁表,拿到一致性快照,同时记录 binlog 位置。
# 在 A 节点执行 mysql -uroot -p -e "FLUSH TABLES WITH READ LOCK;" # 另开一个窗口执行备份 mysqldump -uroot -p --single-transaction --master-data=2 --set-gtid-purged=OFF -A > /root/db_master.sql # 备份完成后释放锁 mysql -uroot -p -e "UNLOCK TABLES;"FLUSH TABLES WITH READ LOCK 保证锁住期间没有新写入,--master-data=2 会往备份文件里写一行注释形式的 CHANGE MASTER TO 信息,里面包含备份时刻的 binlog 文件和偏移位置,后面建第一条链路直接引用它就行。--set-gtid-purged=OFF 是因为我们走传统位置方式,不需要把 GTID 信息塞进备份。如果用的是 MySQL 5.6 以下,该参数不认识,去掉即可。
把备份文件传到 B 节点并恢复:
scp /root/db_master.sql root@10.0.0.2:/root/ mysql -uroot -p < /root/db_master.sql恢复完成后,在 B 节点执行 SHOW MASTER STATUS,记录它自己的 binlog 文件名和 Position,作为反向链路的起点。这里有个关键点:B 上此时应该还没有任何写入,所以它的 binlog 位置其实就是备份应用完成后的干净位置,直接用。
4.2 建立第一条复制链路:A 到 B
在 B 节点执行 CHANGE MASTER TO,指向 A:
CHANGE MASTER TO MASTER_HOST='10.0.0.1', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='YourStrongPass', MASTER_LOG_FILE='mysql-bin.000016', MASTER_LOG_POS=154;这里的 mysql-bin.000016 和 154 是从备份文件里那条注释复制出来的,不要自己猜。然后:
START SLAVE; SHOW SLAVE STATUS\G重点关注三行:Slave_IO_Running: Yes、Slave_SQL_Running: Yes、Seconds_Behind_Master: 0。如果 IO 线程不是 Yes,检查网络、账号密码和 MASTER_LOG_FILE/POS 是否吻合。SQL 线程不是 Yes,看 Last_SQL_Error 字段。第一条链路顺畅跑起来之前,不要建反向链路,否则两边起点不齐,问题会放大成两头互相找不到起点。
4.3 建立反向链路:B 到 A
A 节点同样执行 CHANGE MASTER TO,指向 B,起点使用 4.1 里记录的 B 节点 SHOW MASTER STATUS 输出的文件名和位置:
CHANGE MASTER TO MASTER_HOST='10.0.0.2', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='YourStrongPass', MASTER_LOG_FILE='mysql-bin.000001', MASTER_LOG_POS=453; START SLAVE;两条链路建好后,做一个双向写入验证。在 A 建一张测试表并插入奇数行,B 应该立刻看到;在 B 插入偶数行,A 也应该立刻看到。两边都能看到对方数据,双主链路才算真正闭合。测试完记得把表删掉,别让脏数据留在业务库里。
5. 双主场景必须处理的写入冲突与一致性风险
双主配好只是开始,真正的坑在运行时。这一块不处理好,双主会变成定时炸弹。
5.1 自增字段冲突的实测表现
我当初专门做过一次实验:A 和 B 同时向一张空表各插 10 条数据,结果 ID 序列确实错开了,但业务日志里出现了一个隐蔽问题——两边插入的先后顺序直接影响最终数据的物理排列。A 插入 1、3、5,B 插入 2、4、6,看起来没问题;可一旦客户端通过某个代理轮询请求两个节点,同一事务被拆到两台执行,后续查询按主键排序的结果会一直忽上忽下。所以双主环境里应用层必须约定好:同一业务实体的写操作只走同一节点,不要两边轮询写。自增参数解决的是“唯一性”问题,不解决“路由一致性”问题,这是两码事。
5.2 应用层写入路由设计:推荐单边写、双边读
双主最常见的健康用法是“单点写入、双向复制、双边读取”:
- 所有写操作固定打到 A,B 只接受只读流量和备份流量。
- A 故障时,应用连接池自动把写流量切到 B,B 由只读变成读写。
- 切换期间,两侧数据最多差几秒,业务侧可接受的延迟取决于半同步的强度。
如果真的需要两边同时写不同模块,也要按模块维度拆分,比如订单表写 A、库存表写 B,保证不会出现同一行数据在两个节点同时更新的情况。跨模块查询再走汇总库或只读端,别让一件涉及订单和库存的操作散落在两台机器上。
5.3 一致性校验:定期巡检,别等故障了才看
数据同步不是配好就永远正确,网络抖动、事务重放失败、手工误操作都可能让两侧分叉。我每晚会跑两样东西:pt-table-checksum 用来比对两端表数据是否一致,pt-heartbeat 用来精确测量复制延迟。pt-table-checksum 会把主键哈希对比结果写入指定库,第二天早上看报告,有差异的表立刻手工订正,绝不过夜。具体安装和用法在 Percona 官方文档里都有,这里强调一点:跑校验的账号不能是复制账号,得单独给一个具备 SELECT、LOCK TABLES、SUPER 的账号,否则校验过程中需要临时锁表时会失败。
6. 踩坑记:双主复制最容易出的几类问题与完整排查链路
我踩过的坑足够写满一张 A4 纸,这里选三个最有代表性的,附上完整排查思路,下次遇到可以少走弯路。
6.1 复制线程停止:SQL 线程的 Last_SQL_Error 定位法
现象:A 节点 SHOW SLAVE STATUS 里 Slave_SQL_Running 变成 No,业务侧表现为 B 上的最新数据查不到。很多新手看到 No 就慌,最后一整条日志才发现是某条 UPDATE 在 B 上找不着对应行。
排查链路是固定套路:
SHOW SLAVE STATUS\G先看 Last_SQL_Error 字段,它直接给出出错 SQL 和原因。如果是“Could not execute Update_rows event ... 0 rows matched”,多半是 B 上该行已被人工删除,或者两边数据早有分叉。处理办法不是手工 SET GLOBAL sql_slave_skip_counter,那等于把错误放过去,数据缺口越滚越大。正确做法是先读 Last_SQL_Error 里的表名和主键值,到 A 和 B 分别查这行数据,确认真实差异,再决定是补齐 B 的数据,还是跳过这条由业务层面补补偿。5.7 对事务型表推荐按事务跳过:
STOP SLAVE; SET GLOBAL sql_slave_skip_counter = 1; START SLAVE;skip_counter 不等于修复,它只是跳过某条已经失败的执行,解决完问题后必须重新跑 pt-table-checksum 把整体差异找出来。
6.2 server-id 冲突的隐性故障
另一个坑更隐蔽。两个节点 server-id 如果因为复制配置模板复制粘贴没改,表面看双主都 Yes,实际 A 的 binlog 事件在 B 上被当成“自己产生的事件”过滤掉了,B 的数据永远比 A 少。排查要点是查 B 上的 SHOW SLAVE STATUS 里 Master_Server_Id 字段,确认它指向 1;再去 A 的 binlog 里 grep 一个事件的 server-id:
mysqlbinlog --base64-output=DECODE-ROWS mysql-bin.000016 | grep -m1 "server id"如果两边 head 里显示的是同一个 server id,立刻修正 my.cnf 再重建链路。severe 的地方在于它不报错,所有状态都 Yes,只有数据对不上才会暴露。
6.3 死循环更新的兜底机制:log_slave_updates 与 server-id 过滤
双主理论上是个环:A 写入 → binlog → B 应用 → B 写 binlog → A 应用 → A 写 binlog……这不就死循环了吗?MySQL 靠 server-id 做了天然短路:SQL 线程在把 relay log 事件写回本地 binlog 时,如果事件原生的 server-id 等于本节点自己的 server-id,就丢弃不写。所以 A 上由 B 同步来的事件再回到 A 后不会再次进入 A 的 binlog,环路自动终止。明白了这个机制,你就能理解为什么双主环境里千万不能手动去改事件的 server id,也不能把 log_slave_updates 关掉。关掉后 B 应用完 A 的日志不写 binlog,A 永远不知道 B 侧发生了什么,双主退化成单向复制,比故障更可怕。
7. 故障切换演练与日常巡检建议
双主搭完,最后一步也是最容易被忽略的一步,就是切换演练。没演练过的高可用,等于没有高可用。
7.1 主节点宕机后的切换步骤
模拟 A 完全不可用:
- 应用层先把写连接全部切到 B。如果用了 VIP,VIP 也漂到 B。
- 确认 B 上的 Slave_IO_Running 和 Slave_SQL_Running 状态;A 挂得太急时,B 上可能有堆积 relay log,等 SQL 线程追到 0 再放业务写入。
- 在 B 上执行业务流量放通前的最后确认:SHOW SLAVE STATUS 看 Seconds_Behind_Master 是否为 0。
- 如果 A 只是网络抖动而非物理机宕机,恢复后要做脑裂保护——立刻在 A 上设置 read_only=1 和 super_read_only=1,防止两个节点同时被写入。
之后 A 修复回来,先把它作为 B 的从库追平数据,追平后再决定是否把主角色切回 A。这套流程每季度至少走一遍,切换脚本每次演练都要跟着更新,别停留在纸上。
7.2 每天该盯的监控项与告警阈值
我在两个节点都部署了监控采集,每分钟抓一次状态,下面这几项是双主特有的核心指标:
- Slave_IO_Running 和 Slave_SQL_Running:任何一个不是 Yes,立即告警。
- Seconds_Behind_Master:峰值超过 30 秒就要关注,超过 60 秒直接人工介入。注意该值在网络瞬断时可能短暂显示 NULL,连续两次采集都是 NULL 要告警。
- binlog 文件增长速率:双主场景下尤其要观察,某些大事务(比如一次 DELETE 太多行)会瞬间打爆磁盘,建议 binlog 盘和数据盘分开挂载。
- auto_increment_offset 是否被误改:这参数极少变,一旦变了自增冲突立刻爆发,所以纳入配置审计。
最后再分享一个我自己养成的习惯:每次变更前在两张节点都执行 SHOW MASTER STATUS,把当前 binlog 文件头、Position 记录下来,写入变更单。变更后如果出现数据不一致,能快速定位是从哪一次变更开始分叉,省下大量翻日志的时间。双主架构从来不是“配完就一劳永逸”,它需要持续观察、定期演练,但只要把链路原理吃透、常见故障心里有数,这套方案在 MySQL 5 时代的中等规模业务里,确实能带来非常实在的可用性提升。