RSS
菜单
全部文章快讯开发科技深度热点

第 61 章 高可用切换踩坑

内容摘要

高可用(HA)切换是 DBA 最容易"看着简单、做着出事"的环节。本篇基于 MySQL 8.0 的主从复制、半同步、GTID 与 MHA/Orchestrator 等常见架构,梳理切换流程里最容易踩的 11 个坑。前置知识是主从复制(第 60 篇)与高可用原理。学完后你应能独立设计一套"切换前可校验、切换中可隔离、切换后可验证"的安全流程,避免脑裂、双写与数据丢失。

📖 本篇简介:高可用(HA)切换是 DBA 最容易"看着简单、做着出事"的环节。本篇基于 MySQL 8.0 的主从复制、半同步、GTID 与 MHA/Orchestrator 等常见架构,梳理切换流程里最容易踩的 11 个坑。前置知识是主从复制(第 60 篇)与高可用原理。学完后你应能独立设计一套"切换前可校验、切换中可隔离、切换后可验证"的安全流程,避免脑裂、双写与数据丢失。

目录

  • 坑 1:脑裂后双写导致数据冲突
  • 坑 2:未校验半同步延迟就强行切换
  • 坑 3:VIP 漂移后老主未停机仍可写
  • 坑 4:切换时未等待 relay log 回放完毕
  • 坑 5:用异步复制做金融级高可用
  • 坑 6:切换后忘记改只读路由与写路由
  • 坑 7:GTID 不一致时强行 CHANGE MASTER
  • 坑 8:切换后未重建复制账号与权限
  • 坑 9:自动 failover 脚本误判频繁切换
  • 坑 10:切换后连接池未刷新拓扑缓存
  • 坑 11:忽略 DNS 缓存导致应用连到老主
  • 切换演练与应急注意事项
  • 速查表:切换前后关键检查项
  • 自测练习

坑 1:脑裂后双写导致数据冲突

场景 / 错误示范:

主库 A 网络抖动,HA 工具判定 A 死亡,把从库 B 提升为新主。但 A 只是网卡短暂闪断,几秒后又恢复,仲裁工具把 VIP 又指回 A。此时 A、B 两个库都在接收业务写入,自增主键互相冲突、同一订单出现两份不一致记录。

# 错误:心跳阈值过短,且切换后不隔离老主
masterha_master_switch --master_state=dead \
  --interval=1 --dead_threshold=3   # 3 秒超时即切换,太激进

为什么是坑:高可用的第一原则是"同一时刻全集群只能有一个可写节点"。脑裂(split-brain)发生在网络分区或心跳误判时,老主并未真正下线,新主却已接管,于是双写。双写后两套数据相互覆盖、主键冲突、外键悬空,事后几乎无法自动合并,只能逐行人工比对。根因是切换只看"心跳超时",却没有强制把老主隔离(fencing / STONITH)。

正确做法:

切换流程必须包含"先隔离老主、再提升新主",并借助第三方仲裁(多数派投票或云 API 强制关机),从机制上保证老主不可能再对外服务。

-- 切换后第一时间在老主机上把它改为只读,拒绝一切写入(含 super 权限)
-- 本示例基于 MySQL 8.0
SET GLOBAL super_read_only = ON;
-- 同时在网络层用防火墙/安全组把老主 3306 对业务网段封禁,防止应用重连
# 正确:拉长判定阈值 + 先把老主停掉(STONITH)再提升新主
masterha_master_switch --master_state=dead \
  --dead_threshold=30 --wait_on_failover_error=1 \
  --ssh_user=root --orig_master_is_new_slave

加分点:金融级场景建议引入 fencing token(基于 ZooKeeper/etcd 的租约),应用写之前先校验自己持有的 token 是否仍是当前主库,从协议层杜绝双写。


坑 2:未校验半同步延迟就强行切换

场景 / 错误示范:

DBA 看到主库负载高,直接执行手动切换。但此时半同步从库已经落后主库 30 秒,主库上还没传到从库的事务在切换瞬间全部丢失。

-- 错误:上来就提升,完全不看复制延迟
STOP SLAVE;
RESET MASTER;
-- 直接把这台从库当新主对外服务

为什么是坑:切换前必须确认候选新主已经追平老主的所有事务。半同步复制虽然保证"至少一个从库收到 binlog",但如果用的是 AFTER_SYNC 还是 AFTER_COMMIT、超时后是否降级为异步,都会影响"数据是否真的不丢"。不看 Seconds_Behind_Source 和 GTID 执行位置就切换,等于把未同步的事务直接丢弃。

正确做法:

切换前先查复制状态,确认候选从库已经追上主库的 GTID,再执行提升。

-- 1) 在候选新主上查看复制状态,确认已无延迟
SHOW REPLICA STATUS\G
-- 关注字段:
--   Replica_IO_Running = Yes
--   Replica_SQL_Running = Yes
--   Seconds_Behind_Source = 0
--   Retrieved_Gtid_Set 与 Executed_Gtid_Set 已覆盖主库的 @@global.gtid_executed

-- 2) 确认主库已无新事务写入后,等 Executed_Gtid_Set 追平再提升
-- 3) 追平后把候选从库提升为新主
STOP REPLICA;
RESET REPLICA ALL;
SET GLOBAL super_read_only = OFF;   -- 放开写权限

加分点:用 GTID 集合做差集判断是否追平比看 Seconds_Behind_Source 更可靠,后者在大事务时会显示为 0 但实际还在回放。


坑 3:VIP 漂移后老主未停机仍可写

场景 / 错误示范:

切换通过把 VIP(虚拟 IP)从老主漂移到新主完成。但老主的 MySQL 进程还活着,且仍持有旧连接,应用里少数长连接没断,继续往老主写。

# 错误:只漂 VIP,不动老主进程
ip addr del 10.0.0.10/24 dev eth0   # 老主摘 VIP
ip addr add 10.0.0.10/24 dev eth0   # 新主绑 VIP

为什么是坑:VIP 漂移只影响"新连接连到哪台",对已经建立的 TCP 长连接毫无作用。应用侧连接池里那些没被回收的旧连接,仍然指向老主的物理 IP,继续写入就会造成"新主收大部分写、老主收小部分写"的隐蔽双写。这种坑最可怕——平时不发作,对账时才发现数据对不上。

正确做法:

VIP 漂移之外,必须在老主上主动停 MySQL 或封禁端口,确保老主物理上无法再写。

-- 在老主机上立即拒绝写入,并主动断开业务连接
SET GLOBAL read_only = ON;
SET GLOBAL super_read_only = ON;
-- 杀掉业务连接(保留复制与管理连接)
-- 本示例基于 MySQL 8.0
# 网络层兜底:直接把老主 3306 对业务网段封禁
iptables -A INPUT -p tcp --dport 3306 -s 10.0.0.0/8 -j DROP

加分点:切换演练时专门做一次"老主不关机"的注入测试,验证应用是否真的切干净,比看监控曲线更能暴露问题。


坑 4:切换时未等待 relay log 回放完毕

场景 / 错误示范:

HA 工具检测到 IO 线程已经把 binlog 拉到本地(Relay_Master_Log_File 与主库一致),就立刻把从库提升为主。但 SQL 线程还在慢慢回放 relay log,新主上其实缺了最后几个事务。

-- 错误:只看 IO 线程到位就提升
STOP REPLICA;
SET GLOBAL super_read_only = OFF;
-- 此时 SQL 线程可能还没回放完最后一批 relay log

为什么是坑:复制是"IO 拉取"和"SQL 回放"两条独立线程。IO 线程追平只代表 binlog 已经写到本地 relay log,不代表已经落库。大事务、长事务回放很慢,这期间提升新主,最后那批事务就丢了。判断是否追平必须看 SQL 线程的执行位点,而不是 IO 线程的拉取位点。

正确做法:

用 Replica_SQL_Running 配合 Executed_Gtid_Set 判断 SQL 线程已经回放完毕,再提升。

-- 正确:等 SQL 线程把 relay log 全部回放完
SHOW REPLICA STATUS\G
-- 关键判定:
--   Relay_Master_Log_File 与 Executed_Master_Log_Pos 已对齐
--   Retrieved_Gtid_Set 与 Executed_Gtid_Set 完全相等(无待回放事务)
--   Seconds_Behind_Source = 0

-- 确认无待回放后再提升
STOP REPLICA;
RESET REPLICA ALL;
SET GLOBAL super_read_only = OFF;

加分点:MHA 等工具内部就是靠对比 Retrieved_Gtid_Set 与 Executed_Gtid_Set 来选"最先进的从库"做新主,理解这一点能帮你判断工具选主是否合理。


坑 5:用异步复制做金融级高可用

场景 / 错误示范:

业务是支付清结算,DBA 图省事用默认异步复制做主从,再叠一层 VIP 漂移做高可用。主库宕机瞬间,最后几个已提交事务还没发到从库,切换后用户发现扣款成功但流水丢失。

# my.cnf:默认异步,无任何半同步保障
[mysqld]
log_bin = mysql-bin
server_id = 1
# 没有 rpl_semi_sync_* 任何配置

为什么是坑:异步复制下,主库提交事务时不等待从库确认,主库一宕机,"主库已提交但从库没收到"的事务就永久丢失。金融、账务类业务对"已提交不丢"有硬要求,异步复制天然满足不了。高可用架构再完善,复制本身丢数据,切换就是在放大数据损失。

正确做法:

关键业务开启半同步复制,并配置合理的超时与降级策略。

-- 安装半同步插件(MySQL 8.0 已内置,本示例基于 MySQL 8.0)
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';

-- 主库:至少等一个从库收到 binlog 才返回提交成功
SET GLOBAL rpl_semi_sync_source_enabled = 1;
SET GLOBAL rpl_semi_sync_source_timeout = 5000;   -- 5 秒超时后降级为异步

-- 从库:开启半同步接收
SET GLOBAL rpl_semi_sync_replica_enabled = 1;

加分点:半同步超时降级要谨慎评估——超时后退回异步又会丢数据,金融场景通常配合"多从库 + 强一致组复制(MGR)"而不是简单半同步。


坑 6:切换后忘记改只读路由与写路由

场景 / 错误示范:

主从切换完成,新主已经可写。但应用侧的读写分离配置里,写路由还指向老主域名,读路由也没把新主加进去。结果业务流量要么报错连不上老主,要么读请求打到已经只读的新主上。

# 错误:配置写死老主地址,切换后不更新
WRITE_DS = "mysql://user:pass@10.0.0.11:3306/order"   # 老主 IP,切换后已不可写
READ_DS  = "mysql://user:pass@10.0.0.12:3306/order"

为什么是坑:高可用切换不只是数据库内部的事,应用侧的拓扑感知同样关键。写路由指向老主会直接报错,读路由不更新会导致新主没被读流量利用、老主被反复查询。很多团队只做数据库切换,不做配置热更新,等于切换只完成了一半。

正确做法:

写路由走统一的 VIP/域名并在切换时漂移,读路由通过配置中心动态下发,应用监听拓扑变更事件自动刷新。

# 正确:写路由走 VIP,切换时 VIP 漂移即可,应用无需改配置
WRITE_DS = "mysql://user:pass@mysql-vip.internal:3306/order"
# 读路由从配置中心拉取,拓扑变更时主动推送刷新
READ_DS_LIST = config_center.watch("db/read_replicas")

加分点:把"切换后读路由刷新"做成切换脚本的最后一步并自动校验,比靠人记得改配置可靠得多。


坑 7:GTID 不一致时强行 CHANGE MASTER

场景 / 错误示范:

切换后重建主从,DBA 直接 CHANGE REPLICATION SOURCE TO 指向新主,但新主的 GTID 集合和老主有分叉,复制线程立刻报错 ER_GTID_PURGED_WOULD_VIOLATE。

-- 错误:不看 GTID 集合差异直接建复制
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.0.20', SOURCE_PORT=3306,
  SOURCE_USER='repl', SOURCE_PASSWORD='xxx',
  SOURCE_AUTO_POSITION=1;
START REPLICA;
-- 报错:1236 The slave has more gtid executed than the source

为什么是坑:GTID 模式下,从库要求自己执行过的事务集合是主库的子集。如果新老主 GTID 已经分叉(比如老主在被隔离前又写了几个事务),从库带着这些"主库没有的事务"去连新主,就会报 1236。强行 RESET MASTER 清 GTID 又会丢掉一致性依据。

正确做法:

先对比两边 GTID 集合,确认从库没有"主库缺失的事务",必要时先把分叉事务补齐或丢弃,再建复制。

-- 1) 在从库上查看自己已经执行的 GTID
SELECT @@global.gtid_executed;

-- 2) 在新主上查看它的 GTID
SELECT @@global.gtid_executed;

-- 3) 用 gtid_subset 判断从库执行集是否为新主的子集
SELECT gtid_subset('从库的gtid_executed', '新主的gtid_executed');
-- 返回 1 表示是子集,可以安全建复制;返回 0 表示有分叉,需先处理

-- 4) 确认无分叉后再建复制
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.0.20', SOURCE_PORT=3306,
  SOURCE_USER='repl', SOURCE_PASSWORD='xxx',
  SOURCE_AUTO_POSITION=1;
START REPLICA;

加分点:gtid_subset / gtid_set 系列函数是 GTID 故障排查的瑞士军刀,面试和实战都高频。


坑 8:切换后未重建复制账号与权限

场景 / 错误示范:

切换完成,新主上线。但其他从库还在用老主的复制账号连新主,而新主上根本没这个账号,复制全部中断,DBA 半夜被告警叫醒。

-- 错误:切换后只改连接串,不在新主上建复制账号
-- 新主上没有 repl@'10.0.0.%' 账号,所有从库连不上

为什么是坑:复制账号是建在"主库"上的。切换后新主变成了主库,它上面必须存在供其他从库连接的复制账号。很多人切换只关注数据追平,忘了账号体系也要跟着"升主"。同理,应用账号、监控账号的权限也要在新主上确认。

正确做法:

切换流程里固定一步"在新主上创建/确认复制账号并授权"。

-- 在新主上创建复制账号(本示例基于 MySQL 8.0)
CREATE USER IF NOT EXISTS 'repl'@'10.0.0.%'
  IDENTIFIED BY '强随机密码';
-- 复制权限
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'10.0.0.%';
-- 其他从库用此账号指向新主即可
-- 验证:从库上确认复制已正常
SHOW REPLICA STATUS\G
-- Replica_IO_Running = Yes 才算复制链路恢复

加分点:把账号 DDL 纳入"切换剧本(runbook)"的固定步骤,切换后自动执行并校验,避免人肉遗漏。


坑 9:自动 failover 脚本误判频繁切换

场景 / 错误示范:

大促期间主库 CPU 飙到 100%,响应变慢,HA 工具误判主库死亡,触发自动切换。刚切完新主也被流量打满,又触发切换,半小时内来回切了 4 次,业务雪崩。

# 错误:把"响应慢"等同于"主库死",且无切换次数熔断
masterha_manager --wait_on_failover_error=0
# 没有任何冷却时间和切换次数上限

为什么是坑:自动 failover 的判定条件如果只看"端口不通"或"ping 不通",很容易把"高负载假死"误判成"宕机"。更糟的是没有熔断机制,切换本身又会放大压力,形成"越切越慢、越慢越切"的死循环。自动切换必须有"真死"判据和"切一次就停手"的保护。

正确做法:

判定条件要多维度(进程、端口、SQL 连通性、复制状态),并加冷却时间与切换次数上限。

# 正确:多条件判定 + 冷却 + 熔断
masterha_manager \
  --secondary_check_script=/usr/local/bin/check_mysql_alive \
  --master_ip_failover_script=/usr/local/bin/failover.sh \
  --wait_on_failover_error=1 \
  --last_failover_minute=30   # 30 分钟内不重复切换
-- 判定脚本里要真正执行一次 SQL,而不是只看端口
-- 本示例基于 MySQL 8.0
SELECT 1;   -- 能返回才认为主库活着

加分点:建议自动切换只在"主库彻底无响应"时触发,"主库慢"这类问题优先限流/降级而不是切换。


坑 10:切换后连接池未刷新拓扑缓存

场景 / 错误示范:

切换后新主 IP 变了,但应用连接池缓存了老主的连接和地址,持续报连接超时。运维重启应用才恢复,多花了十几分钟。

# 错误:连接池启动时解析一次地址,之后永不刷新
pool = create_pool("mysql-vip.internal", cached=True)
# 切换后 VIP 虽漂移,但连接池内旧连接全部失效

为什么是坑:连接池为了性能会缓存连接和 DNS 解析结果。切换改变了后端拓扑,连接池却不知道,继续用旧连接,业务就会卡在"连接已断但池里还以为能用"。长连接尤其明显——TCP 层可能没立刻报错,应用写进去才发现失败。

正确做法:

切换后主动通知连接池刷新,或配置连接池的失效检测与快速失败。

# 正确:配置连接池的连接有效性检测 + 拓扑变更时主动重建
pool = create_pool(
    "mysql-vip.internal",
    pool_recycle=60,        # 连接最长存活 60 秒,避免用太久失效连接
    pool_pre_ping=True,     # 取连接前先 ping,失效连接自动剔除
)
# 切换脚本最后一步:调用连接池的 refresh() 重建拓扑

加分点:用 VIP 而不是固定 IP 做写路由,能让连接池在 TCP 重连时自然跟随新主,是最省心的拓扑刷新方案。


坑 11:忽略 DNS 缓存导致应用连到老主

场景 / 错误示范:

团队用域名 mysql-master.internal 指向主库。切换时把域名 A 记录改到新主 IP,但应用和操作系统缓存了旧 DNS,几分钟内还在连老主。

# 错误:改完 DNS 就以为切换完成,不处理各级缓存
dig mysql-master.internal +short   # 权威 DNS 已更新
# 但应用机器上 nscd / systemd-resolved 还缓存着老 IP

为什么是坑:DNS 是有缓存的——操作系统、DNS 解析器、JVM、应用框架每一层都可能缓存。TTL 设得再短,已缓存的记录也要等过期。切换后依赖 DNS 生效,等于把切换时长交给了不可控的缓存 TTL,期间流量仍打老主。

正确做法:

写路由优先用 VIP(二层漂移,秒级生效),把 DNS 只用于发现,且 TTL 设短、切换后主动清缓存。

# 写路由用 VIP,切换时直接漂移,不依赖 DNS 传播
# 若必须用域名,把 TTL 提前调低,并在切换后清各级缓存
# Linux:
systemd-resolve --flush-caches || resolvectl flush-caches
# JVM 应用可加网络缓存策略参数,避免长期缓存旧 IP

加分点:生产主库地址的 DNS TTL 建议平时就调到 5~10 秒,切换窗口前再降到 1 秒,是"平时稳、切时快"的折中。


切换演练与应急注意事项

高可用能力不是"配好 HA 工具"就拥有的,必须靠定期演练暴露隐藏问题。以下是历次真实演练中反复踩到的注意点,单独列出来避免遗漏。

| 注意点 | 说明 | 不做的后果 |

|---|---|---|

| 每季度一次真实切换演练 | 在预发环境完整跑一遍切换剧本 | 真出事时第一次切,必然手忙脚乱 |

| 切换剧本 runbook 书面化 | 每一步命令、回滚点、责任人写清楚 | 人肉记忆,关键时刻漏步骤 |

| 回滚预案提前准备 | 明确什么情况下切回老主、怎么切 | 切坏了只能硬着头皮往前冲 |

| 切换窗口公告与限流 | 提前通知业务方,切换期间限流 | 切换叠加流量,新主扛不住 |

| 切换后数据一致性对账 | 关键业务表行数、金额对账 | 数据丢了当时不知道 |

应急口诀:先隔离老主、再追平位点、后提升新主、最后刷新路由并对账。任何一步顺序颠倒,都可能从"计划性切换"变成"二次事故"。


速查表:切换前后关键检查项

| 阶段 | 检查项 | 合格标准 | 常见错误 |

|---|---|---|---|

| 切换前 | 复制延迟 | Seconds_Behind_Source=0 且 GTID 追平 | 只看端口不看位点 |

| 切换前 | 候选主库状态 | IO/SQL 线程均 Yes | 漏看 SQL 线程回放 |

| 切换中 | 老主隔离 | super_read_only=ON 且端口封禁 | 只漂 VIP 不关机 |

| 切换中 | 新主提升 | super_read_only=OFF 可写 | 忘了放开写 |

| 切换后 | 复制账号 | 新主已有 repl 账号 | 从库全部连不上 |

| 切换后 | 路由刷新 | 写/读路由均指向新拓扑 | 连接池用旧连接 |

| 切换后 | 数据校验 | 关键表行数/对账一致 | 切完不校验就收工 |

常见误区速记:

| 误区 | 正解 |

|---|---|

| VIP 漂移=切换完成 | 还须隔离老主、刷新连接池、校验数据 |

| IO 线程追平=可切换 | 必须 SQL 线程回放完(看 Executed_Gtid_Set) |

| 半同步=绝不丢数据 | 超时降级后仍会丢,金融场景用 MGR |

| 自动切换越快越好 | 要有熔断与冷却,防反复切换雪崩 |


自测练习

1. 脑裂发生的根本原因是什么?切换流程中哪一步是从机制上杜绝双写的关键?

<details>

<summary>参考答案</summary>

根本原因是老主未被隔离仍可写,与新主同时接收写入。关键一步是"先 fencing/STONITH 隔离老主(super_read_only=ON + 网络封禁),再提升新主",必要时引入 fencing token 从协议层校验写权限。

</details>

2. 为什么 `Seconds_Behind_Source=0` 不一定代表可以安全切换?

<details>

<summary>参考答案</summary>

大事务回放时该值可能瞬时显示 0 但实际还有 relay log 未回放。应改用 Retrieved_Gtid_Set 与 Executed_Gtid_Set 是否相等来判断 SQL 线程已追平。

</details>

3. 半同步复制为什么不能完全保证"零丢失"?

<details>

<summary>参考答案</summary>

半同步有超时(rpl_semi_sync_source_timeout),超时后会降级为异步复制,此期间主库宕机仍会丢已提交事务。金融级场景需多从库或 MGR 组复制。

</details>

4. GTID 模式下从库连新主报 1236,应如何排查?

<details>

<summary>参考答案</summary>

用 gtid_subset(从库gtid_executed, 新主gtid_executed) 判断是否有分叉。若从库执行集不是新主子集,说明有分叉事务,需先补齐或丢弃这些事务再建复制,不能盲目 RESET MASTER。

</details>

5. 写路由为什么推荐用 VIP 而不是 DNS?

<details>

<summary>参考答案</summary>

DNS 有多层缓存,切换后传播慢且不可控;VIP 是二层漂移,秒级生效,且能让连接池 TCP 重连时自然跟随新主,无需应用改配置。

</details>

下一篇预告:第 62 章 分库分表踩坑

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 61 章 高可用切换踩坑》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。