资讯动态

数据库高可用与容灾实战(2):GTID 与主从切换:传统位点切换为什么会不一致

发布时间:2026/9/28 18:19:04 来源:尧图企业网站定制
位点切换为什么对不上账上一篇拆完了复制的四段流水线留了一个尾巴整条链路只用一个游标定位进度——binlog 文件名加字节偏移量。日常跑复制没问题一到主从切换就出事故障切换的本质上要把从库 A 的进度翻译成新主 B 的进度而 fileoffset 是某台服务器上某条字节流的局部坐标换个服务器这个数字就失去了含义。你会看到三种经典事故照抄旧偏移量落点卡在新主某个事件的字节中间复制线程直接报错侥幸落在某个事件边界上语义却完全对不上某笔事务被静默跳过或重复执行最省事也最危险的指到 SHOW MASTER STATUS 当前末尾等于宣布新主上已有的存量事务从此与老从库无关。这些都是真实演练里反复出现的画面本篇先用模拟把账算细再讲 GTID 如何把事务身份从偏移量升级成全局唯一主键让切换从猜坐标变成算集合差。GTID 的机制给每笔事务发身份证GTID 是一个事务级全局 IDserver_uuid:事务序号序号由事务的原始提交流按源单调递增。一台实例的执行历史就是一串 GTID 集合即gtid_executed形如7e1c...:1-9同源的多个区间用冒号分隔A:1-5:7-9表示缺了 6。它记录在事务前面的 GTID_LOG_EVENT 里随 binlog 一起复制因此每个从库都能如实回答我执行过哪些事务。切换时不再传坐标而是SOURCE_AUTO_POSITION1从库把自己的集合发给对端对端算出差集、只补发缺的事务WAIT_FOR_EXECUTED_GTID_SET(set, timeout)则能把等对端追平某集合变成切换脚本里的同步原语。配套的生命周期管理是gtid_purged已清除出 binlog 的历史区间——从库索要的事务若落在 purged 区间里复制报 1236Could not find first log file name in binary log index file类错误这是位点时代悄悄少数据的显式化对不上账会直接报错而不是静默分叉。工程上必须清楚 GTID 的边界。第一它只保证事务身份的全局性不自动解决谁的数据更多——切换前仍要选出集合最大的候选者。第二游离事务errant transaction是从库上直接写入忘了开super_read_only、或用sql_slave_skip_counter跳出来的不属于主链历史的 GTID从库带着游离事务被提升会把一段野生历史推给全集群被别的节点持有则任何提升都可能丢它。第三gtid_mode的在线迁移要走 OFF → OFF_PERMISSIVE → ON_PERMISSIVE → ON 的四步状态机且enforce_gtid_consistency全程为 ON期间禁止CREATE TABLE ... SELECT这类非事务安全语句进复制链。模拟一把位点切换的雷踩给你看下面的模拟给旧主 A 生成 12 笔事务的事件流按字节容量轮转文件候选新主 R2 复制了前 9 笔但它binlog_checksumnone每个事件短 4 个字节——同一批事务在 R2 上落到了不同的偏移量与不同的文件边界。然后分别复现照抄旧偏移量和指到新主末尾两种传统切换手法。TX_SIZES[42,51,33,60,45,38,55,47,36,52,44,40]# tx1..tx12 事件字节数HDR120# binlog 文件头(FORMAT_DESCRIPTION/PREVIOUS_GTID 等)LIMIT256# 模拟的 max_binlog_size: 超过就轮转新文件defpack(sizes,hdrHDR,limitLIMIT):按文件容量装事件, 返回 [(file_no, [(tx_no, start, end), ...]), ...]files,fno,off,evs[],1,hdr,[]fori,szinenumerate(sizes,1):ifoffszlimit:files.append((fno,evs))fno,off,evsfno1,hdr,[]evs.append((i,off,offsz))offsz files.append((fno,evs))returnfiles a_filespack(TX_SIZES)# 旧主 A, 带 checksum, 写满 12 笔后宕机r2_filespack([s-4forsinTX_SIZES[:9]])# 候选新主 R2: binlog_checksumnone, 事件各短 4 字节print(旧主 A 的文件布局:)forfno,evsina_files:print( mysql-bin.%06d: %s%(fno,, .join(tx%d[%d,%d)%(t,s,e)fort,s,einevs)))print(候选新主 R2(同样的 tx1-9, 但事件字节数不同)的文件布局:)forfno,evsinr2_files:print( mysql-bin.%06d: %s%(fno,, .join(tx%d[%d,%d)%(t,s,e)fort,s,einevs)))pos_file,pos_off[(f,e)forf,evsina_filesfort,s,einevsift7][0]print(\nR1 断链时停在旧主位点: mysql-bin.%06d:%d (tx7 结束)%(pos_file,pos_off))print(\n方案一: CHANGE MASTER TO 指向 R2 的 mysql-bin.%06d, MASTER_LOG_POS%d (照抄旧数字)%(pos_file,pos_off))inside[(f,t,s,e)forf,evsinr2_filesfort,s,einevsiffpos_fileandspos_offe]f,t,s,einside[0]print( R2 上该偏移落在 tx%d 事件内部(字节 %d~%d): 半截 rows event, SQL 线程报 fatal error 直接中止%(t,s,e))after[(f2,t2,s2,e2)forf2,evsinr2_filesfort2,s2,e2inevsiff2pos_fileor(f2pos_fileands2pos_off)]print( 若手工对齐到下一个边界硬跑: 从 tx%d 起回放, tx%d 被静默跳过 - R1 与新主数据分叉%(after[0][1],t))print(\n方案二: 指向 R2 当前末尾 SHOW MASTER STATUS 的位点)max_tmax(t2for_f,evsinr2_filesfort2,_s,_einevs)last_f,last_er2_files[-1][0],r2_files[-1][1][-1][2]lost[t2fort2inrange(8,max_t1)]print( R1 只接收 R2 今后的新写入: 已存在于新主的 tx%s 永远不会发给 R1, 切换即丢 %d 笔%(lost,len(lost)))print(\n结论: fileoffset 只是某台服务器某条字节流的游标, 不携带我已有哪笔事务的语义)运行输出旧主 A 的文件布局: mysql-bin.000001: tx1[120,162), tx2[162,213), tx3[213,246) mysql-bin.000002: tx4[120,180), tx5[180,225) mysql-bin.000003: tx6[120,158), tx7[158,213) mysql-bin.000004: tx8[120,167), tx9[167,203), tx10[203,255) mysql-bin.000005: tx11[120,164), tx12[164,204) 候选新主 R2(同样的 tx1-9, 但事件字节数不同)的文件布局: mysql-bin.000001: tx1[120,158), tx2[158,205), tx3[205,234) mysql-bin.000002: tx4[120,176), tx5[176,217), tx6[217,251) mysql-bin.000003: tx7[120,171), tx8[171,214), tx9[214,246) R1 断链时停在旧主位点: mysql-bin.000003:213 (tx7 结束) 方案一: CHANGE MASTER TO 指向 R2 的 mysql-bin.000003, MASTER_LOG_POS213 (照抄旧数字) R2 上该偏移落在 tx8 事件内部(字节 171~214): 半截 rows event, SQL 线程报 fatal error 直接中止 若手工对齐到下一个边界硬跑: 从 tx9 起回放, tx8 被静默跳过 - R1 与新主数据分叉 方案二: 指向 R2 当前末尾 SHOW MASTER STATUS 的位点 R1 只接收 R2 今后的新写入: 已存在于新主的 tx[8, 9] 永远不会发给 R1, 切换即丢 2 笔 结论: fileoffset 只是某台服务器某条字节流的游标, 不携带我已有哪笔事务的语义这个模拟里每事件差 4 字节只是差异的一个来源真实世界里 checksum 配置、binlog_format、组提交时 PREVIOUS_GTID 事件的长度、甚至 server_id 的字节数都会让同一段历史在不同机器上长成不同的字节形状。所以位点切换没有运气好就对这一说只有错误暴露得晚不早硬报错算最好的结局静默跳过事务才是生产事故的形态。模拟二GTID 集合代数把切换变成可验证的账目有了身份证切换的正确性就能用集合运算表达补课集合 候选新主的已执行集 − 本节点的已执行集提升准入 不丢任何人的合法历史、自身无游离事务、自身无断洞。下面手写一个迷你 GTID 集合代数解析、区间合并、差集、断洞检测对四个节点做完整体检。A,B,R27e1c0001-A,b5d20002-B,f3aa0003-R2defparse(s):uuid:1-7:9, uuid2:1-3 - {uuid: [(1,7),(9,9)]} 已排序无重叠out{}forpartin[p.strip()forpins.split(,)ifp.strip()]:uid,ivalspart.split(:)mergedout.setdefault(uid,[])forivinivals.split(:):lo,hi(int(x)forxiniv.split(-))if-inivelse(int(iv),int(iv))merged.append((lo,hi))merged.sort()stack[]forlo,hiinmerged:ifstackandlostack[-1][1]1:stack[-1](stack[-1][0],max(stack[-1][1],hi))else:stack.append((lo,hi))out[uid]stackreturnoutdeffmt(d):return,.join(%s:%s%(u,:.join(%d-%d%ivifiv[0]!iv[1]else%d%iv[0]forivinivs))foru,ivsinsorted(d.items()))or(empty)defminus(a,b):a - b 的区间差out{}foru,ivsina.items():cuts[(lo,hi)forlo,hiinivs]forclo,chiinb.get(u,[]):nxt[]forlo,hiincuts:ifchiloorclohi:nxt.append((lo,hi))continueifclolo:nxt.append((lo,clo-1))ifchihi:nxt.append((chi1,hi))cutsnxtifcuts:out[u]cutsreturnoutdefholes(d,history):对照主链历史 history{uuid:最大编号}, 找已执行集合里缺的中段bad[]foru,topinhistory.items():ivsd.get(u,[])reachivs[-1][1]ifivselse0gotset()forlo,hiinivs:got.update(range(lo,hi1))bad[%s:%d%(u,n)forninrange(1,reach1)ifnnotingot]returnbad executed{R1:parse(%s:1-7%A),R2:parse(%s:1-9%A),R3:parse(%s:1-9,%s:1-2%(A,B)),# B 是早年被遗忘的旧主 uuidR4:parse(%s:1-5,%s:7-9%(A,A)),# 当年用 skip_counter 跳过 A:6}history{A:9}forname,ginexecuted.items():print(%s gtid_executed %s%(name,fmt(g)))missingminus(executed[R2],executed[R1])print(\n提升 R2 为主, R1 的补课集合: %s%fmt(missing))print( - CHANGE MASTER ... SOURCE_AUTO_POSITION1; 等效 SELECT WAIT_FOR_EXECUTED_GTID_SET(%s, 60)%fmt(missing))r1_afterexecuted[R2]print( - 追平后 R1 %s, R2-R1 差集 %s%(fmt(r1_after),fmt(minus(executed[R2],r1_after))))hist_setparse(,.join(%s:1-%d%(u,t)foru,tinhistory.items()))fornameinexecuted:eminus(executed[name],hist_set)iffmt(e)!(empty):print(\n[%s] 游离事务(errant): %s%(name,fmt(e)))forcandin(R2,R3,R4):loss{}forname,ginexecuted.items():ifnamecand:continueerrant_gminus(g,hist_set)# 剔除游离事务后的合法账本legitminus(g,errant_g)gapminus(legit,executed[cand])iffmt(gap)!(empty):loss[name]fmt(gap)own_errantfmt(minus(executed[cand],hist_set))hholes(executed[cand],history)oknotlossandown_errant(empty)andnothprint(\n候选 %s: 会丢别人的事务%s; 自身游离事务%s; 自身断洞%s - %s%(cand,lossor无,own_errant,hor无,可提升ifokelse拒绝提升))运行输出R1 gtid_executed 7e1c0001-A:1-7 R2 gtid_executed 7e1c0001-A:1-9 R3 gtid_executed 7e1c0001-A:1-9,b5d20002-B:1-2 R4 gtid_executed 7e1c0001-A:1-5:7-9 提升 R2 为主, R1 的补课集合: 7e1c0001-A:8-9 - CHANGE MASTER ... SOURCE_AUTO_POSITION1; 等效 SELECT WAIT_FOR_EXECUTED_GTID_SET(7e1c0001-A:8-9, 60) - 追平后 R1 7e1c0001-A:1-9, R2-R1 差集 (empty) [R3] 游离事务(errant): b5d20002-B:1-2 候选 R2: 会丢别人的事务无; 自身游离事务(empty); 自身断洞无 - 可提升 候选 R3: 会丢别人的事务无; 自身游离事务b5d20002-B:1-2; 自身断洞无 - 拒绝提升 候选 R4: 会丢别人的事务{R1: 7e1c0001-A:6, R2: 7e1c0001-A:6, R3: 7e1c0001-A:6}; 自身游离事务(empty); 自身断洞[7e1c0001-A:6] - 拒绝提升体检结果一眼可读R2 是唯一的合格候选R3 的致命伤是游离事务——B 那两个事务来路不明提升它等于给全集群植入一段没人认领的历史R4 的伤是断洞A:6 被人为跳过它的数据和主链在逻辑上已经不可互证。这正是 Orchestrator 类工具提升前检查的最小内核。注意这套代数只能发现问题不能凭空补回数据R4 缺的 A:6 如果旧主 binlog 还在可以从旧主或 binlog server 上捞捞不到就只能降格成重建处理。GTID 让切换可验证但可验证不等于零丢失——这正是下一篇半同步要解决的。常见陷阱图省事在从库上直接执行订正 SQL哪怕开了read_onlySUPER 账号照样能写一个游离 GTID 就能让这台从库永远无法被提升。RESET MASTER一把梭清掉gtid_executed正在复制的链路上这是自毁开关集合信息丢了就只能回位点时代重建复制。binlog 保留时间比从库最大故障断联时间短从库索要的事务已进gtid_purged复制起不来只能重建。从库/审计日志的保留策略要和expire_logs_days/binlog_expire_logs_seconds对表。用sql_slave_skip_counter跳错误GTID 模式下该参数根本不可用硬绕注入空事务就是在亲手制造断洞先想清楚为什么错。灰度期从gtid_modeOFF混跳到 ONON_PERMISSIVE 允许匿名事务进来迁移收尾不查__UNANONYMOUS__残留切换时会发现账本缺页。落地清单gtid_modeONenforce_gtid_consistencyON作为新建实例默认迁移走四步状态机并在两端核对集合。所有从库强制super_read_onlyON订正走主库或工单平台杜绝游离事务的户口来源。切换脚本三问固化成准入检查候选是否覆盖全员合法历史自身有无 errant有无断洞binlog_expire_logs_seconds至少覆盖最长故障发现时间 最慢补数据时间并监控从库索要 purged 事务的告警。每次演练都导出全节点gtid_executed存档事故时它是唯一的权威账本。GTID 解决了账对得上但异步复制的账本本身就允许新主比旧主少最后一截——切换前所有手段都在减少损失而不是避免损失。要真正做到提交即持久化到远端得把复制的确认语义升级下一篇《数据库高可用与容灾实战3半同步复制与无损切换RPO0 能不能做到》。参考来源MySQL 8.0 Reference ManualReplication with Global Transaction Identifiershttps://dev.mysql.com/doc/refman/8.0/en/replication-gtids.htmlMySQL 8.0 Reference ManualGTID Conceptshttps://dev.mysql.com/doc/refman/8.0/en/replication-gtids-concepts.htmlMySQL 8.0 Reference ManualReplication with GTIDs (How-to)https://dev.mysql.com/doc/refman/8.0/en/replication-gtids-how.htmlGitHubgithub/orchestratorhttps://github.com/github/orchestratorWikipediaUniversally unique identifierhttps://en.wikipedia.org/wiki/Universally_unique_identifier

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑