ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL主从复制从原理到实操:读写分离、延迟排查与高可用进阶

MySQL主从复制从原理到实操:读写分离、延迟排查与高可用进阶

最近在忙一个线上项目,单机MySQL扛不住读写压力了,CPU经常飙到80%以上,慢查询一个接一个。团队商量了一下,决定上主从复制架构,一套读写分离的方案先把读流量分流出去。折腾了几天,把主从复制从原理到落地整个过了一遍,踩了不少坑。这篇文章就基于这次实操,把MySQL主从复制的核心原理、搭建步骤、坑点排查全部梳理一遍,给正准备搞主从复制的同学做个参考。

这篇文章适合谁?刚接触MySQL主从复制的新手,可以顺着思路把原理和搭建流程搞明白;已经在用主从复制但遇到问题不好排查的同学,可以直接跳到第四章对照排查;需要做高可用方案选型的同学,重点看第一章和第五章的对比分析。

1. 主从复制到底解决了什么问题

先说结论:主从复制不是万能的,但它确实是目前MySQL架构演进中最基础、最成熟的一步。理解了它能解决什么、不能解决什么,你才能真正用好它。

1.1 读写分离:把主库的压力卸下来

主从复制最直接的价值就是读写分离。典型的业务场景是:写操作占总流量的20%以下,读操作占80%以上。如果所有请求都打到一台MySQL上,读请求会逐渐挤占CPU和IO资源,写操作的响应时间也跟着变长。

我接手这个项目的时候,数据库的读写比例大概在1比5左右,高峰期QPS到了3000多,单实例已经明显吃紧。通过主从复制架构,把读请求全部转发到从库,主库专心处理写操作,效果立竿见影。

说到读写分离,很多人有个误区:以为主从复制搭好之后,应用代码自动就会把读请求发到从库。实际上主从复制只是让从库有了和主库一致的数据,路由逻辑需要你在应用层或者中间件层实现。常见的做法有这么几种:

  • 在应用代码里配置多数据源,通过AOP或者拦截器根据SQL类型自动路由
  • 使用中间件,像ProxySQL、MySQL Router,在SQL层做读写分流
  • 使用分布式数据库中间件,比如ShardingSphere,直接内置了读写分离功能

1.2 数据容灾:多一份副本多一条退路

某次磁盘损坏导致数据丢失的事件让我印象很深。当时因为没做任何复制,只能拿前一天凌晨的备份来恢复,丢了十几个小时的数据,业务影响非常大。

有了主从复制,从库相当于一份实时的数据副本。主库挂了,可以快速把从库提升为主库,业务中断时间从小时级缩到分钟级。虽然这个过程需要人为介入,不能算自动容灾,但已经把RTO(恢复时间目标)缩短了一个量级。

1.3 从库还能做什么:报表、分析、备份

除了读写分离,从库还有不少实用场景:

  • 报表查询。很多报表SQL特别重,动不动就要全表扫描,在主库上跑绝对拖垮线上业务。放到从库上,随便跑,反正影响不到主库。
  • 数据备份。我现在的备份策略就是从从库拉物理备份,完全不影响主库的IO。
  • 开发联调。给开发同学分一个从库账号去查数据,避免他们误操作触碰主库。

注意:从库并不是无限制扩展的。每个从库都会增加主库的复制负担(IO和网络开销),从库数量超过一定规模(比如10个以上),主库的复制线程本身就会成为瓶颈。

1.4 主从复制不是银弹:它解决不了的问题

说完优点,泼盆冷水。主从复制解决不了下面这些问题:

  • 数据一致性。主从复制本质上是异步的(默认情况下),主库提交事务和从库应用事务之间存在延迟。你刚写入一条数据,立刻去读从库,可能读不到。
  • 写入瓶颈。写入能力受限于主库单机的硬件上限,主从复制不解决写扩展的问题。如果写流量也很大,得考虑分库分表或者NewSQL方案。
  • 自动故障转移。传统主从复制方案里,主库挂了不会自动把从库顶上,需要借助MHA、Orchestrator之类的高可用组件,或者购买商业版MySQL的Group Replication。

我见过不少团队把主从复制当成了万能药,结果上线后发现从库数据延迟、主从数据不一致,反而比单机更麻烦。所以搞主从复制之前,先想清楚你的核心诉求是什么。是读压力大?还是需要容灾?明确目标再动手。

2. 复制原理拆解:数据是怎么从主库跑到从库的

搞懂原理再动手,遇到问题才不慌。MySQL主从复制的核心机制说复杂也复杂,说简单也简单,就是一套基于日志的“传话”机制。

2.1 三个关键角色和两类日志

主从复制链路里,有三个角色:主库(Master)、从库(Slave)、以及连接它们的IO线程和SQL线程(可理解为两条传输通道)。两类日志是:主库的二进制日志(binlog)和从库的中继日志(relay log)。

整个过程可以用一句话概括:从库的主线程(IO线程)去主库拉取binlog,写到本地的relay log里,然后从库的SQL线程读取relay log并顺序执行里面的SQL,把数据变更回放到从库上。

我把整个流程拆成下面几步:

  1. 应用在主库上执行SQL提交事务,同时产生对应的binlog事件。
  2. 主库的dump线程负责响应从库的请求,把binlog事件推送给从库。
  3. 从库的IO线程接收binlog事件,写入从库本地的relay log文件。
  4. 从库的SQL线程读取relay log,按顺序执行其中的事务,完成数据回放。
  5. 执行完毕后,从库更新自己的位点信息,准备拉取下一条日志。

整个过程都是异步的,主库不会等从库确认,这也是主从延迟的根本原因。

2.2 binlog的三种格式:statement、row和mixed

binlog的格式决定了主库记录的是SQL语句还是数据行变更,这个看起来不起眼的参数,实际影响非常大。

  • statement格式:记录原始的SQL语句。优点是日志量小;缺点是某些非确定性函数(如NOW()、UUID())在不同机器上执行结果不一致,复制容易出错。
  • row格式:记录每一行数据的变更前后值。优点是复制最安全、一致性强;缺点是日志量大,一条UPDATE更新100万行会生成100万条变更事件。
  • mixed格式:混合模式,默认使用statement,遇到不确定函数或其他特殊情况自动切换为row。

我在生产环境一律推荐row格式。虽然日志会大一些,磁盘占用多一些,但换来的是数据一致性,太值了。之前在一个项目里用过statement格式,某个凌晨跑批量任务用了一个带UUID()的存储过程,结果从库的数据跟主库对不上了,排查了大半天,最后就是从statement改成row才解决。

2.3 复制的三种模式:异步、全同步、半同步

这是选型绕不开的话题,直接对比给你看:

复制模式主库行为数据一致性性能影响适用场景
异步复制事务提交后立即返回,不等待从库响应可能丢失数据无影响读写分离、报表场景
全同步复制等待所有从库提交后才返回强一致很大,不可用基本不用于生产
半同步复制至少一个从库收到binlog后返回基本不丢数据略有影响要求不丢数据的核心业务

关于半同步复制,多说一句:半同步的“半”是指折中了异步和全同步,主库事务提交后,需要等待至少一个从库确认已经收到binlog(注意是收到,不要求应用完成),才返回客户端提交成功。这样能保证主库即使挂了,已确认的事务至少存在于一个从库的relay log里,不会丢。

我在这次架构升级中,直接配置的是半同步复制,因为业务对资金数据一致性要求比较高,丢数据的后果承担不起。性能损耗在可接受范围内,实测延迟大约增加了8%-12%。

2.4 复制延迟的根源:两个线程和三条瓶颈

很多人问,为什么从库老是延迟?其实就是三个环节的瓶颈:

  1. 主库生成binlog的速度。如果主库并发写入极高,生成的binlog事件量太大,dump线程推送不过来。
  2. 从库IO线程写relay log的IO能力。磁盘慢,IO线程就会积压。
  3. 从库SQL线程回放的速度。单线程回放大事务(比如一次DELETE百万行)时,延迟会肉眼可见地飙升。

MySQL 5.6之后提供了并行复制(MTS),通过将SQL线程拆分成多个worker线程,把不同库/不同表的事务并行回放,能显著缓解延迟问题。我建议所有用5.7及以上版本的同学,都把并行复制打开。

提示:并行复制不是万能的,它只能缓解因回放效率导致的延迟。如果是主库写入量实在太大导致的延迟,本质上要从业务层考虑分库分表,复制层面的优化就无能为力了。

3. 实操搭建:一主一从环境配置全过程

理论讲完,下面进入实操。我们在生产环境用的是MySQL 8.0,这里以8.0为例。准备两台服务器,一台主库Master(假设IP为192.168.1.101),一台从库Slave(假设IP为192.168.1.102),操作系统都是Linux,MySQL版本保持一致(这一点很重要,跨大版本复制能少踩很多坑)。

3.1 主库配置:核心参数和授权操作

主库需要开启binlog,配置一个server-id,再授权一个复制专用的账号。

# /etc/my.cnf 主库配置 [mysqld] server-id = 101 log_bin = /var/log/mysql/mysql-bin.log binlog_format = ROW # 如果只需要复制某个业务库,可以配置下面两个参数 # binlog_do_db = your_db_name # binlog_ignore_db = mysql # 每次事务提交后立即刷盘,保证binlog不丢 sync_binlog = 1

配置完修改后,重启MySQL让参数生效:

sudo systemctl restart mysqld

主库上创建复制专用账号:

CREATE USER 'repl'@'192.168.1.%' IDENTIFIED BY 'YourStrongPassword'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'192.168.1.%'; FLUSH PRIVILEGES;

注意:复制账号只需要REPLICATION SLAVE权限,不要给ALL PRIVILEGES,最小权限原则在数据库账号管理里同样适用。

查看主库当前状态,记录File和Position值,这是从库开始复制的起点:

SHOW MASTER STATUS\G

记录类似下面的信息:

*************************** 1. row *************************** File: mysql-bin.000003 Position: 1549782 Binlog_Do_DB: Binlog_Ignore_DB:

3.2 从库配置:从零开始建立同步链路

从库配置相对复杂一些,关键是先做一次全量数据初始化,再启动复制。

# /etc/my.cnf 从库配置 [mysqld] server-id = 102 log_bin = /var/log/mysql/mysql-bin.log binlog_format = ROW # 从库开启binlog是为了级联复制或者做从库备份,按需配置 # 下面两个参数可以过滤不需要复制的库 replicate_ignore_db = mysql # 指定并行复制线程数,8个对大多数场景足够 slave_parallel_workers = 8

重启从库MySQL之后,在主库上用mysqldump建一个初始快照。这里有两个关键参数:

# --master-data=2 会在备份文件里记录主库的binlog位置,后续可以直接用 # --single-transaction 保证innodb的备份一致性 # --set-gtid-purged=OFF 如果是基于位点复制,需要关掉gtid mysqldump -u root -p --single-transaction --master-data=2 --set-gtid-purged=OFF --databases your_db_name > your_db.sql

把备份文件拷贝到从库,然后导入:

scp your_db.sql root@192.168.1.102:/data/ mysql -u root -p < /data/your_db.sql

在从库MySQL里执行复制配置。注意:如果是通过mysqldump带--master-data生成的备份,备份文件开头会有CHANGE MASTER TO语句和注释掉的MASTER_LOG_FILE/MASTER_LOG_POS,可以直接抄过来:

CHANGE MASTER TO MASTER_HOST = '192.168.1.101', MASTER_USER = 'repl', MASTER_PASSWORD = 'YourStrongPassword', MASTER_LOG_FILE = 'mysql-bin.000003', MASTER_LOG_POS = 1549782; START SLAVE;

启动复制后,查看复制状态:

SHOW SLAVE STATUS\G

如果输出中这两个值是YES,说明同步链路已经正常工作:

Slave_IO_Running: Yes Slave_SQL_Running: Yes

3.3 上半同步复制:多几行配置换来数据安全

上面搭建的是默认的异步复制模式。如果我们想要半同步,在MySQL 8.0里配置非常方便,主库和从库都要装插件并启用:

主库执行:

INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so'; SET GLOBAL rpl_semi_sync_master_enabled = 1; SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 超时时间1秒

从库执行:

INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so'; SET GLOBAL rpl_semi_sync_slave_enabled = 1; STOP SLAVE IO_THREAD; START SLAVE IO_THREAD;

注意最后一步,从库需要重启IO线程才能激活半同步插件。配置完成后,在主库执行:

SHOW STATUS LIKE 'Rpl_semi_sync_master_status';

如果返回的值是ON,说明半同步复制已生效。

提示:rpl_semi_sync_master_timeout这个参数很关键,它表示等待从库确认的超时时间。如果从库一直不确认,超过这个时间后主库会自动降级为异步复制,不会阻塞业务写入。超时时间设置太短(比如100毫秒)可能频繁降级,太长(比如10秒)又会让写事务响应变慢,建议根据网络延迟实测调整,一般500ms到2s之间比较合理。

3.4 基于GTID的复制:比位点复制更省心

刚才演示的是基于日志文件和位点的复制,还有一个更先进的方案叫GTID复制。GTID(全局事务标识符)是每个事务在主库上生成时分配的唯一ID,从库可以通过GTID自动追数据,不需要手动指定日志文件和位置,避免了位点绑定带来的切换麻烦。

开启GTID需要修改主库从库的配置文件:

[mysqld] gtid_mode = ON enforce_gtid_consistency = ON

然后从库连接方式变为:

CHANGE MASTER TO MASTER_HOST = '192.168.1.101', MASTER_USER = 'repl', MASTER_PASSWORD = 'YourStrongPassword', MASTER_AUTO_POSITION = 1; START SLAVE;

GTID复制的好处在于切换方便:主从切换后,新从库不需要重新找位点,它知道自己已经执行过哪些GTID,会跳过这些事务继续往后同步。对于后续做高可用主从切换的同学,强烈建议直接用GTID模式。

不过我和团队实际操作下来的体感是:如果是第一次搭主从,先按位点复制把链路跑通再切换GTID会比较平滑。否则一下子引入GTID,出了问题排查面更大。

4. 常见问题与排查技巧实录

搭建过程中必然遇到问题,这块我踩过不少坑,按出现频率从高到低整理出来,每个都配了排查思路。

4.1 复制线程起不来:Slave_IO_Running是Connecting怎么办

这是最常见的问题。IO线程连不上主库,状态就是Connecting。

第一步检查网络。看从库能不能连通主库的3306端口:

telnet 192.168.1.101 3306

第二步确认账号和权限。在主库上执行:

SELECT user,host FROM mysql.user WHERE user='repl';

如果host限制是192.168.1.%,而从库的IP是192.168.1.102就没问题。注意MySQL的host匹配是精确匹配优先,如果同时存在'%'和'192.168.1.%',可能会命中你意料之外的那条规则。

第三步检查主库是否开启了binlog:

SHOW VARIABLES LIKE 'log_bin';

如果值是OFF,那说明主库根本没开binlog,复制无从谈起。

第四步看主库的防火墙和SELinux,这俩经常是“元凶”。生产上建议把数据库服务器之间的3306端口在防火墙里限定来源放行,SELinux如果没把握配置,有时直接临时放宽容许或者设成宽松模式验证一次,确认是它的问题后再做精细配置。我就遇到过一次,从库的IO线程怎么也连不上主库,排查了一圈最后发现是防火墙没放行来源IP。

4.2 主从延迟飙升:Seconds_Behind_Master突然变成几千

这个问题的排查我总结了几个步骤:

先用这个命令定位延迟发生在哪一环:

SHOW SLAVE STATUS\G

关注三个数据:

Master_Log_File: mysql-bin.000009 Read_Master_Log_Pos: 382105466 Relay_Log_File: relay-bin.000012 Relay_Log_Pos: 92177644
  • 如果Read_Master_Log_Pos和Master_Log_Pos差距很大,说明IO线程拉取日志的速度跟不上主库新产生的日志量,通常是网络带宽或磁盘IO瓶颈。
  • 如果Relay_Log_Pos和Read_Master_Log_Pos差距很大,说明SQL线程执行速度跟不上,需要检查是否有大事务或者并行复制参数是否没生效。

针对SQL线程慢,常见原因一个是单线程回放大事务,解决办法就是把并行复制打开并适当调大worker数量;另一个是主库上有长时间未提交的写事务,导致从库需要等它结束。

大事务是主从延迟的“幕后黑手”。我之前遇到一次从库延迟达到上亿位点的情况,查了一圈,发现是凌晨有个定时任务在主库上跑了一次DELETE,一次性删除几千万条历史数据,生成的binlog事件量巨大,从库根本追不上。从那以后,我定了一条规矩:任何批量DML必须分批执行,每批处理一万行,间隔几秒再继续。这个习惯保住了很多次数据库的稳定运行。

另外,如果从库硬件配置比主库低(比如磁盘是机械盘、CPU核数少),延迟也很难追平。主从硬件尽量同规格,这是最容易被低估的一点。

4.3 主从数据不一致:怎么发现和修复

数据不一致的原因很多:用了非row格式的binlog、从库被手动写入过数据(虽然从库默认只读,但REPLICATION权限的账号不受限)、版本不一致导致的语义差异等。

排查手段用一张表做对比。比如核心业务表orders:

-- 主库执行 SELECT COUNT(*) FROM orders; CHECKSUM TABLE orders;
-- 从库执行 SELECT COUNT(*) FROM orders; CHECKSUM TABLE orders;

如果两边checksum不一致,就说明数据确实有差异。修复的方法比较粗暴,但有效:重新基于主库做一次全量备份恢复到从库,然后再继续同步。对一致性要求极高的场景,可以考虑定期用pt-table-checksum和pt-table-sync这类工具做校验修复。这两个工具属于Percona Toolkit,不依赖特定环境,是通用的开源工具,生产实践中用的非常多。

注意:从库一旦发现无法自动解决的问题,最快的恢复路径往往不是去“改”从库数据,而是重做。因为从库只是一个副本,重做成本相比排查一个由于手工修改导致的复杂问题是低很多的。别犹豫,直接重做。

4.4 从库被写入数据导致同步中断

MySQL从库如果不配置只读,被手工写入数据,可能会和正在回放的事务冲突,导致SQL线程报错并停止。SQL线程报错后,错误的SQL已经执行并记录到了relay log里,如果直接跳过可能导致数据漏应用或者不一致,所以“跳过错误”这个操作要非常谨慎。

稳妥的做法是先确认报错类型,再决定是跳过单条事务还是重新初始化从库。比如在从库的whose表里多了一条id=100的数据,回放时INSERT本身就冲突。

如果确认跳过的是对业务无影响的错误(比如误操作导致的重复插入),可以用这个方法跳过:

STOP SLAVE SQL_THREAD; SET GLOBAL sql_slave_skip_counter = 1; START SLAVE SQL_THREAD;

但是如果这类错误出现的频率高,或者无法判断影响面,直接重新搭建从库是最保险的方案。另外,从库务必开启只读模式:

[mysqld] read_only = 1 super_read_only = 1

super_read_only这个参数值得强调:普通账号对从库的写操作会被拒绝,就连SUPER权限的账号也会被挡在写操作之外。这样从库就基本不会被意外写入了。

4.5 典型问题速查表

异常现象可能原因处理建议
Slave_IO_Running: Connecting网络不通、账号错误、主库binlog未开启按4.1的四步排查法逐项检查
Slave_SQL_Running: NoSQL线程执行失败,事务冲突或错误SQL结合报错和后续事务判断是跳过还是重做从库
Seconds_Behind_Master持续增长大事务、从库硬件弱、并行复制未开启分批处理DML、升级从库硬件、开启并行复制
主从数据不一致binlog不是ROW格式、从库被写入校验checksum后用工具修复或重做从库
切换主从后复制不生效未正确配置新主库的binlog和账号确认新主库开启binlog、创建复制账号、重新CHANGE MASTER

5. 主从复制的进阶:从“能跑”到“好用”

复制链路跑起来了,只是第一步。从“能跑”到“好用”,中间还差很多功力和配置。有一些很重要的优化点,是基于我自己的经验总结出来的,特别值得你参考。

5.1 主从延迟不可完全消除,但可以把影响降下来

用主从复制,就不得不认真考虑延迟问题。很多业务对读操作的一致性要求没那么高,但对特定数据(比如支付结果、账号信息)要求必须读到最新。怎么低成本地处理这种延迟?

我在项目里用了这样一套平衡方案。对于强一致性要求的少量读请求,强制路由到主库执行;对于大部分可以接受秒级延迟的读请求,自由路由到从库。具体可以借助中间件实现,比如在数据访问层给SQL打标签,或者干脆在业务代码里分两层数据源。不需要把整个系统做成分库分表那种重架构,很多时候,一个强制主库路由的注解就能解决问题。

5.2 并行复制和参数调优,实测下来最有用的几个设置

MySQL 5.7及以上版本的并行复制,配置得当能让从库的回放效率翻倍。关键的参数和常用配置如下:

[mysqld] # 开启基于库级别的并行复制(5.7的MTS功能) slave_parallel_type = LOGICAL_CLOCK slave_parallel_workers = 16 # 如果一行数据要并行复制,数据库必须开行级并行,否则还是会串行。

LOGICAL_CLOCK是基于事务提交的时间戳来并行,不同事务只要在时间窗口内没有依赖关系就可以并行应用。slave_parallel_workers线程数不能盲目设置太大,通常设置为从库CPU核心数的一半到三分之二比较合适。我们跑下来的经验是:从库8核16G的配置,workers设16比设4的延迟改善要明显得多。

另外一个容易忽略的参数是binlog_group_commit_sync_delay和binlog_group_commit_sync_no_delay_count,这两个参数可以让主库把多个事务的binlog合并刷盘,提高主库的写吞吐量。但它会牺牲一点点响应时间,需要根据业务场景权衡。我一般不会盲目调这两个参数,除非确实有大批量写入又对延迟不敏感的场景。

5.3 监控主从状态:要盯的指标清单

主从复制不是配好就能一直安稳跑下去,日常监控是必须的。至少要盯这几个指标:

  • Slave_IO_Running和Slave_SQL_Running:两个线程是否正常,这个一眼就能看到。
  • Seconds_Behind_Master:从库延迟的秒数,超过阈值就报警。
  • Master_Log_File和Read_Master_Log_Pos的差值:IO线程是否在持续拉取新日志。
  • 从库磁盘空间:relay log如果因为异常中止复制而没有及时清理,会一直累积占满磁盘。
  • 主库binlog大小:如果binlog增长太快,要关注是否有大事务。

我们用的是自研的监控脚本,每隔30秒采集一次SHOW SLAVE STATUS的关键字段,超过阈值推送到告警群。另外,我自己还加了每天定时对比主从核心表checksum的任务,虽然不能覆盖所有行,但能提前暴露出大批量不一致的问题。

5.4 主从切换演练:别等事故来了才练

主从复制的最终目标之一,是在主库故障时快速切换到从库。但如果你没演练过切换,真出事的时候大概率手忙脚乱。

建议步骤:

  1. 在从库上执行STOP SLAVE;,确保不再接收新的变更。
  2. 确认从库的Seconds_Behind_Master已经为0,也就是数据追平了。
  3. 在从库上开启读写:
SET GLOBAL read_only = OFF;
  1. 把应用的数据源连接切到新主库。
  2. 原主库恢复后,把它作为新主库的从库加入集群,或者干脆重做。

整个切换过程中,最关键的是第2步,一定要等从库追平再切换,否则会丢数据。如果用了半同步复制,追平的时间会大大缩短,但也要确认一下。

我建议每季度做一次切换演练,把切换时间、丢数据情况、应用影响全部记录下来。周密的演练计划是最好的保险,因为你用了主从架构,就等于欠下了这笔“切换能力”的账,早晚要还的。

结尾

主从复制这套架构,我实际用下来的体会是:单机MySQL撑不住时,它是性价比极高的第一步;但你要清楚它的能力边界,异步复制会有延迟,自动切换需要外部组件,写扩展需要分库分表。把这些边界摸清楚,就不会在故障发生时手足无措。如果这篇文章能帮你少踩一个坑,或者让你在上线前多想一步监控和切换的事,那这个整理就值了。最后再分享一个小技巧:配置任何复制参数之前,先想清楚它在你这个具体场景里的目的是什么,别照搬网上配置,因为生产环境每个参数都有它的体感差异和代价。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进