尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MySQL主从复制实战:binlog、GTID、延迟排查与半同步切换

MySQL主从复制实战:binlog、GTID、延迟排查与半同步切换 MySQL主从复制这东西属于那种平时没人想起出事全公司盯着的基础设施。我印象最深的一次是某个凌晨两点业务侧反馈某类查询返回了过期数据排查下来主库早就提交成功了从库回放卡在一条大表的 UPDATE 上Seconds_Behind_Master还老老实实显示 0。那次之后我重新把复制的链路、参数和坑位完整梳理了一遍。这篇就把 MySQL 主从复制从原理到落地拆开讲三个线程分别干什么、binlog 格式怎么选、异步和 GTID 怎么搭、延迟和一致性问题怎么查、半同步和切换要注意什么。适合已经会写 SQL 但没亲手搭过复制链路的同学也适合维护着一主两从、想搞清楚哪些参数不能随便动的人。1. 复制链路里其实只跑着三个线程但时间线比想象中长很多人对复制的理解停在主库写 binlog、从库读 binlog 回放这一句话上。这句话没错但它省掉了所有会出问题的地方binlog 什么时候真正落盘、从库什么时候拉取、relay log 什么时候被消费、事务在从库什么时候可见。这四个时间点之间的距离就是延迟、丢数据和各种诡异现象的来源。1.1 主库的 dump 线程与事务提交的真实落盘时机主库侧负责复制的是 binlog dump 线程。从库 IO 线程连上来发一个我要从某个位置开始的请求dump 线程就把 binlog 里对应位置之后的 event 读出来推过去。这个线程是被动的只有从库连着才工作不连就不推。所以从库断了之后主库的 binlog 会一直往前滚滚到超过保留期被 purge 掉从库再回来就接不上了——这是最经典的一类故障。真正决定主库说提交成功了这条数据到底安不安全的是另外两个参数。sync_binlog控制 binlog 从内存缓冲刷到磁盘的频率设成 1 表示每次事务提交都刷一次磁盘innodb_flush_log_at_trx_commit控制 redo log 的刷盘策略同样设成 1 表示每次提交都落盘。这两个都设 1才是常说的双一配置。为什么不设 1 会出问题假设sync_binlog0事务提交后 binlog 还留在操作系统缓存里主库此时异常重启这条 binlog 可能就没了但从库那边可能已经通过 dump 线程把它拉走了并且回放完了。主库恢复后没有这条记录从库有这就是主从不一致。所以任何对数据可靠性有要求的场景这两个参数都不能省。注意sync_binlog1对写入性能是有实打实影响的尤其是在高并发小事务场景下。我见过不少环境为了压测数字好看把它调大上线后主从数据出现差异排查花的时间远超省下的那点 QPS。1.2 从库的两段式拉取IO 线程写 relay logSQL 线程负责回放从库侧是两个线程配合。IO 线程负责和主库的 dump 线程通信把收到的 event 顺序写进本地的 relay log同时更新mysql.slave_master_info表里记录的主库位点。SQL 线程则是另一个独立角色它读 relay log把里面的 event 在自己的存储引擎上重放一遍然后更新mysql.slave_relay_log_info里的位点。这两个线程完全异步这也是延迟产生的结构性原因IO 线程拉得再快SQL 线程跟不上relay log 就在磁盘上越堆越多。反过来SQL 线程很快但网络或主库压力导致 IO 线程拉得慢relay log 就很短。所以看延迟不能只看一个数字得分别看 IO 和 SQL 两个线程的状态。在 8.0 里位点信息默认存在两张 InnoDB 表里而不是文件。这个改动很关键用表存位点意味着位点更新和事务回放可以在同一个事务里完成从库崩溃恢复时位点和数据是一致的。早期用文件存位点的版本崩溃后经常出现位点已经推进了但事务没回放完或者反过来非常难处理。1.3 一条事务从主库提交到从库可见中间隔着多少环节把链路串起来看一条事务在从库可见之前要经过这些环节环节发生位置耗时影响因素主库 binlog 落盘主库磁盘 IO、sync_binlogdump 线程推送主库到从库网络网络带宽、RTT、binlog 体积IO 线程写 relay log从库从库磁盘、relay log 刷盘策略SQL 线程回放从库从库 CPU、锁竞争、并行度、单事务大小这四段里任何一段变慢最终体现出来的都是应用读到了旧数据。而且这四段的延迟特征完全不同网络问题表现为 IO 线程状态异常、relay log 不增长回放问题表现为 relay log 持续增长、SQL 线程一直 running。定位方向完全不一样先分清楚是哪一段比一上来就调参数有用得多。2. binlog 格式这道选择题选错了后面全在还债binlog_format有三个取值STATEMENT、ROW、MIXED。这个参数在 5.7 之后默认就是 ROW8.0 更是固化了这个方向。但我在不少存量环境里还看到 STATEMENT问起来通常是当年为了省空间或者一直没改过。2.1 STATEMENT 在真实业务里为什么越来越站不住脚STATEMENT 记录的是原始 SQL 语句。听起来很省空间但它有一个致命前提同一条 SQL 在主库和从库执行必须产生完全相同的效果。这个前提在现实里经常不成立。举几个典型情况。带LIMIT不带ORDER BY的更新主从两侧的扫描顺序可能不同更新的行就不一样。用到NOW()、RAND()、UUID()这类非确定性函数两侧取值不同。还有自增列在并发插入场景下主从分配到的自增值可能错位。这些都不是理论问题我在生产环境里见过用RAND()做随机排序分页的查询配上 STATEMENT 格式从库数据直接错乱。还有一个更隐蔽的语句里带了用户变量或者临时表操作SQL 线程重放时的上下文和主库不一样结果同样无法保证。提示如果接管了一个还在用 STATEMENT 格式的老库不要直接改参数了事。先跑一轮全量一致性校验确认当前主从数据是对齐的否则切换格式后差异会被固化下来后面更难处理。2.2 ROW 模式的空间与 IO 代价以及怎么把它压回去ROW 格式记录的是每一行数据变更前后的镜像好处是确定性极强SQL 线程回放时不依赖任何环境因素也不依赖 SQL 的具体写法。代价是 binlog 体积可能暴涨尤其是批量更新场景一条UPDATE t SET status0 WHERE create_time 2024-01-01影响十万行STATEMENT 只记一条语句ROW 要记十万行的前后镜像。体积上去了直接带来三个连锁反应磁盘写入量增加、主从网络传输压力增加、从库 relay log 占用增加。这三点叠加起来延迟自然就上来了。压回去的思路有这么几个。一是控制单次事务影响的行数批量更新拆成小批比如每批一千行中间留一点间隔。这不光减小 binlog也减少主库的锁持有时间和从库的回放压力。二是调整 binlog 的过期时间8.0 里用binlog_expire_logs_seconds默认三十天很多业务根本不需要留这么久按从库最长可能断开的时间来估算就行。三是合理设置max_binlog_size默认 1G大事务会突破这个限制产生超大文件拆分事务能顺带解决。2.3 binlog_row_image 与最小化写入ROW 格式下还有一个参数值得单独说binlog_row_image取值 FULL、MINIMAL、NOBLOB。默认是 FULL记录变更行的所有列无论这一列有没有变。设成 MINIMAL 后只记录主键和被修改的列binlog 体积能明显下降。但 MINIMAL 有个前提从库上必须能通过主键唯一定位到那一行。表如果没有主键或者唯一键MINIMAL 模式下回放会出问题可能导致从库扫描全表甚至报错。所以这个参数不能脱离表结构单独讨论。我在实际使用中的做法是先把无主键表清理掉8.0.13 之后可以用sql_require_primary_keyON从源头拦住新建无主键表确认所有表都有主键再考虑开 MINIMAL。反过来如果环境里还留着一批历史无主键表就老老实实保持 FULL稳定压倒一切。3. 从零搭一套异步复制几行配置背后躲着的坑配置本身不复杂复杂的是顺序、权限和一致性快照。下面这套流程我在多个环境里跑过按这个顺序基本不会卡住。3.1 server_id、log_bin、binlog_format 这几个必选项主库的my.cnf需要确认这几项[mysqld] server_id 101 log_bin /var/lib/mysql/binlog binlog_format ROW binlog_row_image FULL sync_binlog 1 innodb_flush_log_at_trx_commit 1 binlog_expire_logs_seconds 604800 max_binlog_size 512M从库对应配置[mysqld] server_id 102 relay_log /var/lib/mysql/relaylog relay_log_recovery ON read_only ON super_read_only ON log_replica_updates ONserver_id必须全局唯一且非零这是硬性要求。我遇到过一次克隆虚拟机导致两台机器server_id相同现象是复制刚启动就断报错信息里明确提到主从 server id 一致。这个坑很蠢但很常见做镜像或模板的时候特别容易忘。relay_log_recovery ON也是必须的。它让从库启动时丢弃未处理完的 relay log重新从位点拉取避免 relay log 损坏导致回放中断。8.0 里这个参数默认就是 ON但老环境升级上来可能是 OFF。super_read_only ON比read_only ON更彻底。read_only对拥有 SUPER 权限的账号无效也就是说运维账号照样能往里写误操作防不住。super_read_only会把这类账号也挡住。3.2 主库建复制账号权限粒度别图省事CREATE USER repl_user10.0.0.% IDENTIFIED BY strong_password_here; GRANT REPLICATION SLAVE ON *.* TO repl_user10.0.0.%; FLUSH PRIVILEGES;只给REPLICATION SLAVE权限就够了不要图省事给ALL PRIVILEGES或者SUPER。复制账号一旦被盗能做的事情越少越好。另外主机段尽量收窄不要写%。3.3 用 mysqldump 做一致性快照时的两个关键参数从库初始化需要一份主库的一致性快照同时要知道这份快照对应的 binlog 位点。mysqldump能同时完成这两件事mysqldump -h 10.0.0.11 -u root -p \ --single-transaction \ --source-data2 \ --set-gtid-purgedOFF \ --databases app_db \ /tmp/app_db.sql--single-transaction让整个导出过程在一个可重复读事务里完成保证快照一致同时不锁表前提是表都用 InnoDB。--source-data2老版本叫--master-data2会把当时的 binlog 位点以注释形式写进导出文件后面配复制时直接从文件里拿就行。--set-gtid-purged这个参数容易踩坑。如果目标环境要用 GTID 复制设成 ON 或者注释掉导出文件里会带上SET GLOBAL.GTID_PURGED语句如果目标环境不用 GTID必须设成 OFF否则导入时会报 GTID 相关的错。导入完成后从导出文件的注释里找到位点grep -m1 CHANGE REPLICATION SOURCE /tmp/app_db.sql3.4 CHANGE REPLICATION SOURCE 与启动验证在从库上执行8.0.23 之后的写法之前的版本用CHANGE MASTER TO参数名对应为MASTER_HOST等CHANGE REPLICATION SOURCE TO SOURCE_HOST 10.0.0.11, SOURCE_PORT 3306, SOURCE_USER repl_user, SOURCE_PASSWORD strong_password_here, SOURCE_LOG_FILE binlog.000042, SOURCE_LOG_POS 156; START REPLICA;启动后重点看三个地方。SHOW REPLICA STATUS\G里Replica_IO_Running和Replica_SQL_Running都应该是YesLast_IO_Error和Last_SQL_Error应该是空的Seconds_Behind_Master一开始会比较大等追平后归零。如果 IO 线程起不来八成是网络、账号权限或者位点不对。如果 SQL 线程起不来看Last_SQL_Error常见的是主从表结构不一致、从库有写入了数据导致主键冲突。4. GTID 模式新环境真的不该再抱着 fileposition 不放binlog file position 这套机制能用但它有个根本问题位点是环境相关的。换一台机器、重做一次快照、切换一次主库位点就变了所有下游都得跟着改。GTID 就是为了解决这个问题。4.1 GTID 的组成与自动定位到底省了什么GTID 的格式是server_uuid:transaction_id比如3e11fa47-71ca-11e1-9e33-c80aa9429562:23。每个事务在主库提交时被分配一个全局唯一编号从库记录自己已经执行过的 GTID 集合。配置复制时只要告诉从库从你还没有的那部分开始不需要关心文件位置。这个改变带来的直接好处是切换变简单了。主库挂掉把某个从库提升为主其他从库重新指向新主库只要SOURCE_AUTO_POSITION1它们会自动算出自己缺哪些事务。用位点的方案下这一步要手工算每个从库的位点从库一多就是噩梦。另外 GTID 让某个事务是否已经在某台机器上执行过变成一个可以查询的问题。SHOW REPLICA STATUS里的Executed_Gtid_Set和Retrieved_Gtid_Set能直接告诉你缺口在哪排查时省掉很多猜测。4.2 从传统模式切到 GTID 的四步状态机GTID 不能直接从 OFF 跳到 ON必须走中间状态因为集群里可能存在还没处理完的匿名事务。标准顺序是这样-- 第一步先开一致性检查观察一段时间日志 SET GLOBAL.ENFORCE_GTID_CONSISTENCY WARN; -- 观察无异常后 SET GLOBAL.ENFORCE_GTID_CONSISTENCY ON; -- 第二步进入两种中间态 SET GLOBAL.GTID_MODE OFF_PERMISSIVE; SET GLOBAL.GTID_MODE ON_PERMISSIVE; -- 第三步等待匿名事务清零 SHOW STATUS LIKE ONGOING_ANONYMOUS_TRANSACTION_COUNT; -- 必须等到 Value 为 0 -- 第四步正式开启 SET GLOBAL.GTID_MODE ON;每一步都要同步写到配置文件里否则重启后会回退。ONGOING_ANONYMOUS_TRANSACTION_COUNT是这一整套流程里唯一需要等待的地方如果集群里有持续写入可能要等一会儿。这个值不为 0 就切最后一步会留下没有 GTID 的事务后面很麻烦。提示切换过程中不要同时做表结构变更也不要在切换中间态时重启实例。中间态是设计成可回退的但回退和前进交错执行容易出问题。4.3 GTID 模式下跳过错误的正确姿势这是我在实际使用中踩得最深的一个坑。传统模式下复制出错很多人习惯用SET GLOBAL sql_slave_skip_counter 1; START SLAVE;跳过。GTID 模式下这个参数直接不可用设了也不生效会报错提示。正确的做法是注入一个空事务让从库认为这个 GTID 已经执行过STOP REPLICA; SET GTID_NEXT 3e11fa47-71ca-11e1-9e33-c80aa9429562:23; BEGIN; COMMIT; SET GTID_NEXT AUTOMATIC; START REPLICA;这里的 GTID 要填报错信息里Last_SQL_Error提到的那个。原理是手工执行一个空事务把这个编号写进从库的gtid_executed集合复制再继续时就会跳过它。当然跳过的前提是你确认这个事务确实可以丢。跳过之前务必把对应 binlog 解析出来看清楚内容用mysqlbinlog加上 GTID 过滤参数定位到具体事务。直接跳等于承认数据不一致后面得靠校验和补数据来兜底。5. 延迟与不一致从 Seconds_Behind_Master 骗人那一刻开始排查延迟排查最怕的就是看一个数字就下结论。Seconds_Behind_Master这个值在多种场景下会给出误导性结果必须结合 relay log 的增长情况和两个线程的状态一起看。5.1 Seconds_Behind_Master 为什么不可信替代方案是什么这个值的计算方式是SQL 线程当前回放的事件时间戳减去IO 线程最近一次收到的事件时间戳。听起来合理但有几个明显的失效场景。主库长时间没有写入时两个时间戳都停在很久以前差值接近 0看起来毫无延迟但实际上从库可能因为某个大事务卡住了relay log 里堆着一堆没回放的内容。反过来从库刚启动时 SQL 线程还没开始跑这个值可能直接是 NULL。更靠谱的做法是用心跳表。原理是在主库上每隔固定时间比如一秒更新一行记录的时间戳从库上查这行记录用当前时间减去记录里时间戳得到的才是真实延迟-- 主库定时执行 UPDATE heartbeat SET ts NOW() WHERE id 1; -- 从库查询 SELECT TIMESTAMPDIFF(SECOND, ts, NOW()) AS real_delay FROM heartbeat WHERE id 1;这个方案的好处是直观、准确不依赖内部实现细节。社区里有现成的工具封装了这个逻辑也可以自己写个脚本定时跑。我个人更倾向自己写逻辑简单出问题好排查。还有一个辅助判断看 relay log 文件数量和总大小。如果持续增长说明 SQL 线程跟不上问题在回放侧如果基本不增长但延迟报警问题多半在 IO 侧或者主库根本没写入。5.2 无主键表与大事务两个最高频的延迟源头无主键表在 ROW 模式下是灾难。主库执行一条带条件的 UPDATEbinlog 里记录的是每行变更的前后镜像从库回放时需要通过主键定位到对应行。表没有主键从库只能全表扫描一条影响一万行的更新从库可能要扫一万次全表。表越大延迟越夸张而且会持续累积。判断方法很简单查一下有没有无主键表SELECT t.table_schema, t.table_name FROM information_schema.tables t LEFT JOIN information_schema.table_constraints c ON c.table_schema t.table_schema AND c.table_name t.table_name AND c.constraint_type PRIMARY KEY WHERE t.table_schema NOT IN (mysql,sys,information_schema,performance_schema) AND t.table_type BASE TABLE AND c.constraint_name IS NULL;解决就是加主键没有业务主键就加自增列。这件事没有捷径。大事务是另一个源头。一个事务更新几十万行从库回放时这个事务要么全做完要么全不做中间过程对其他会话的锁影响也大。表现是延迟曲线呈现明显的尖峰尖峰出现的时间和主库的批量任务时间对得上。处理方式是把大事务拆小。批量更新按主键范围分批每批控制在几千行以内批与批之间短暂停顿。这不只是优化复制延迟对主库的锁持有时间、undo 空间占用、甚至备份的稳定性都有好处。5.3 并行回放的参数组合与生效条件单线程回放是复制延迟的结构性瓶颈主库并发写入从库排队一个个回放写入压力一大必然追不上。8.0 的并行回放能一定程度缓解但有生效条件。[mysqld] replica_parallel_workers 8 replica_parallel_type LOGICAL_CLOCK replica_preserve_commit_order ON binlog_transaction_dependency_tracking WRITESETreplica_parallel_workers设成 8 意味着最多 8 个工作线程并行回放。LOGICAL_CLOCK模式允许在主库上同时提交的事务在从库上并行执行这是并行的基础。replica_preserve_commit_order保证从库上的提交顺序和主库一致开了之后并行才安全——关掉它会出现从库读到中间状态的问题一般不建议关。binlog_transaction_dependency_tracking控制主库怎么标记事务之间的依赖关系WRITESET是基于行级别的冲突检测比COMMIT_ORDER并行度更高。8.0 里默认就是WRITESET。有个关键前提容易忽略并行回放的效果取决于主库上事务本身的并发度。如果业务是单线程串行写入主库事务之间都有依赖从库再并行也没用还是得排队。所以不要指望调大replica_parallel_workers能解决所有延迟先看清楚写入模式。5.4 一致性校验的落地方式延迟解决了不代表数据就是一致的。跳过的错误、切换时的边界情况、历史上的误操作都可能留下不一致。定期校验是必要的。CHECKSUM TABLE只能看出两张表整体是否一致定位不到具体行而且对大表不友好。更实用的方式是分块校验按主键范围切分每块计算一个摘要值对比主从两侧。社区里有现成的工具做了这件事会自己在从库上执行查询做比对输出差异行并生成修复语句。校验的节奏看业务容忍度。核心表可以每天跑一次非核心表每周一次。校验本身有资源消耗放在低峰期执行并且限制并发和每次扫描的量避免校验把从库压垮。6. 半同步与切换先把可用性问题算清楚再动手异步复制的语义是主库提交成功即返回客户端从库有没有收到完全不保证。主库故障时还没传过去的 binlog 就丢了。半同步复制是为了缩小这个窗口。6.1 AFTER_SYNC 与 AFTER_COMMIT 的差别半同步的核心是主库提交后等待至少 N 个从库确认收到 binlog再返回客户端。这里面有个关键参数rpl_semi_sync_master_wait_point取值AFTER_COMMIT和AFTER_SYNC。AFTER_COMMIT是早期实现主库先提交事务、写 binlog然后等从库确认。这中间有个窗口事务在主库已经提交、对其他会话可见了但从库还没收到。如果此时主库崩溃客户端可能已经读到了这个事务的结果切换后这个事务却不存在。AFTER_SYNC则是先等从库确认收到 binlog再在主库上提交。这样事务对客户端可见的时候从库一定已经拿到了 binlog。5.7 之后默认用AFTER_SYNC这是更合理的语义。配置示例-- 主库 INSTALL PLUGIN rpl_semi_sync_master SONAME semisync_master.so; SET GLOBAL rpl_semi_sync_master_enabled ON; SET GLOBAL rpl_semi_sync_master_timeout 1000; SET GLOBAL rpl_semi_sync_master_wait_for_slave_count 1; -- 从库 INSTALL PLUGIN rpl_semi_sync_slave SONAME semisync_slave.so; SET GLOBAL rpl_semi_sync_slave_enabled ON;8.0.26 之后插件名和变量名改成了 source/replica 的说法比如rpl_semi_sync_source_enabled但旧名字一般还保留着兼容。6.2 半同步退化成异步的静默风险rpl_semi_sync_master_timeout设的是等待确认的超时时间单位毫秒默认一万毫秒。超时后主库不会报错而是自动退化成异步复制。这个设计是为了可用性——从库全挂了也不能让主库写不进去。问题在于这个退化是静默的。退化之后主库照常写入从库照常在追看起来一切正常但语义已经从半同步变成了异步。如果没人监控这个状态等下次主库真出问题时才发现数据丢了。监控指标是Rpl_semi_sync_master_status值为 OFF 就说明已经退化。这个指标要放进常规监控退化了要告警。同时Rpl_semi_sync_master_no_tx这个计数器记录了多少次事务是没等到确认就提交的也是重要参考。注意超时时间设得太短会导致频繁退化设得太长会导致主库在从库异常时写入卡顿。一千毫秒是个常见的折中值但具体要看网络 RTT 和业务能接受的写入延迟。6.3 切换前的检查清单主从切换是这类架构里风险最高的操作。我给自己定了几个必查项每次切换前过一遍。第一确认新主库的数据是最全的。对比各从库的Executed_Gtid_Set选包含事务最多的那个。如果有多个从库各自比别的从库多了不同事务说明发生过复杂情况需要先把缺的事务补齐再说。第二确认所有从库的 SQL 线程已经追平或接近追平。带着延迟切换丢失的数据量会更大。第三确认应用的连接串能快速改指向。有没有配置中心、DNS 缓存多久、连接池会不会一直持有旧连接这些都得提前确认。我见过切换完成后应用还在连老主库十几分钟的情况原因是连接池没做失效处理。第四确认老主库已经彻底停止写入。切换后如果老主库恢复并且继续接受写入就会出现双写处理起来极其麻烦。可以在切换时直接把老主库设成super_read_only或者干脆停掉。第五准备好回滚路径。切换失败要能切回去这意味着老主库的 binlog 要完整保留切换期间没有做过不可逆的操作。7. 一些我在实际环境里反复用到的判断方法复制链路出问题时大部分人的第一反应是翻参数文档。我的经验是先花三十秒做一次分诊能省掉大量试错。看SHOW REPLICA STATUS里两个线程的状态如果 IO 线程是Connecting问题在连接层——网络不通、账号密码错、主库挂了、防火墙拦了。如果 IO 线程是No看Last_IO_Error里面通常写得很清楚。如果 IO 正常、SQL 线程是No看Last_SQL_Error这类错误一般分两种数据冲突主键重复、行不存在和结构问题表不存在、列不匹配。数据冲突要用校验工具定位差异行后手工修复结构问题先同步表结构再重启复制。如果两个线程都是Yes但延迟在涨看 relay log 增长速度和从库的负载。从库上跑着备份、跑着大查询、或者磁盘 IO 打满都会拖慢回放。这种情况优先解决资源争抢调参数是次要的。最后分享两个小技巧。一是给Relay_Log_Space加监控这个值持续增长就是延迟的早期信号比延迟数值本身更灵敏。二是给Retrieved_Gtid_Set和Executed_Gtid_Set的差值做监控能精确算出还差多少个事务没回放比秒数更直观。这两个指标接进监控系统之后很多问题在业务感知之前就能发现。
返回列表