纯热备场景下,用最少的组件(Keepalived + MySQL 原生复制)实现故障全自动切换、自动恢复、零数据丢失。兼容 MySQL 5.7 / 8.0 / MariaDB。
一、设计思路
1.1 核心问题
在做这个方案之前,先想清楚三个问题:
问题 1:为什么不用 MHA / ProxySQL?
- MHA 的 Manager 是单点,且切换后 Manager 自动退出,旧主恢复后仍需人工处理
- ProxySQL 功能强但引入了新的高可用问题(ProxySQL 本身也要做 HA),纯热备场景下得不偿失
- Keepalived 极其成熟,几万行 C 代码跑了二十年,比任何"智能"方案都可靠
问题 2:为什么用双主而不是主从?
传统主从 + VIP 的问题:切换后需要 CHANGE MASTER 反转拓扑。双主(互为主从)的好处是:不管 VIP 在哪台,另一台本来就是它的从库,切换时无需改复制拓扑,VIP 漂过去就能直接写。
问题 3:如何防止恢复后 VIP 自动回切?
这是整个方案最核心的设计点。来看看常规方案的问题:
常规方案(仅用 priority + weight):
db-a priority=100 (主), db-b priority=90 (备)
db-a 宕机 → db-a=80, db-b=90 → db-b 接管 ✓
db-a 恢复 → db-a=100, db-b=90 → db-a 抢回 ✗ (不必要的第二次中断!)如果加 nopreempt:
nopreempt只对 Keepalived 进程启动时的状态转换生效- 对
track_script引起的动态权重变化不生效 - 更糟的是,如果两台都加
nopreempt,连正常的故障切换都会被阻止
本方案的解法:让 health check 脚本利用 MySQL 原生的复制 IO 线程状态来判断"主库是否还活着":
check_mysql.sh 逻辑:
1. MySQL 进程是否存活?→ 否 → exit 1(降权)
2. 我是只读(read_only=ON)吗?
- 是 → IO 线程是否在运行?
- 是 → 主库还活着,我安心当备机 → exit 1(降权,不抢占)
- 否 → 主库挂了,我可以竞选 → exit 0
- 否 → 我已是主库 → exit 0这样恢复后的节点自动检测到当前主库还活着,主动降低优先级,不会抢占 VIP。
1.2 方案架构
应用 ──► VIP (192.168.7.33) ──► 当前主库 (read_only=OFF)
└─► 备机 (read_only=ON)
双向复制 (GTID + 半同步)
|
check 脚本监控复制 IO 线程
判断主库是否存活 → 控制优先级1.3 故障切换时序
正常状态:
db-a (priority=100, read_only=OFF, IO-thread 活跃)
db-b (priority=90, read_only=ON, IO-thread 活跃 → check 返回 1 → 实际=70)
db-a 宕机后 8 秒内:
db-a check 连续失败 2 次 → priority=80
db-b check 发现 IO 线程断开 → 返回 0 → priority=90
90 > 80 → db-b 接管 VIP
notify_master 将 db-b read_only 关闭
db-a 恢复:
MySQL 启动,read_only=ON(my.cnf 默认)
IO 线程连上 db-b → 活跃 → check 返回 1 → priority=80
db-b 仍是 priority=90
80 < 90 → VIP 留在 db-b(不回切!)1.4 Priority 推演表
| 场景 | db-a 状态 | db-a 实际 priority | db-b 状态 | db-b 实际 priority | VIP 在 |
|---|---|---|---|---|---|
| 初始正常 | read_only=OFF | 100 | read_only=ON, IO 活跃 | 70 (90-20) | db-a |
| db-a 宕机 | MySQL 挂了 | 80 (100-20) | read_only=ON, IO 断开 | 90 | db-b |
| db-a 恢复 | read_only=ON, IO 活跃 | 80 (100-20) | read_only=OFF | 90 | db-b(不回切) |
| db-b 宕机 | read_only=ON, IO 断开 | 100 | MySQL 挂了 | 70 (90-20) | db-a |
二、环境信息
| 角色 | IP | 初始状态 | Keepalived priority |
|---|---|---|---|
| db-a | 192.168.7.31 | read_only=ON,等 Keepalived 竞选 | 100 |
| db-b | 192.168.7.32 | read_only=ON,等 Keepalived 竞选 | 90 |
| VIP | 192.168.7.33 | — | — |
注意:两台 my.cnf 都配置 read_only=ON。谁成为 MASTER 谁由 notify 脚本打开写入。这样恢复后的节点总是以只读模式启动,不会出现双主同时可写的窗口。三、多版本兼容速查
| 配置项 | MySQL 5.7 | MySQL 8.0 | MariaDB 10.x |
|---|---|---|---|
| 过期日志 | expire_logs_days=7 | binlog_expire_logs_seconds=604800 | expire_logs_days=7 |
| 从库更新 | log_slave_updates | log_replica_updates | log_slave_updates |
| 半同步主插件 | semisync_master.so | semisync_source.so | semisync_master.so |
| 半同步从插件 | semisync_slave.so | semisync_replica.so | semisync_slave.so |
| 半同步主变量 | rpl_semi_sync_master_enabled | rpl_semi_sync_source_enabled | rpl_semi_sync_master_enabled |
| 复制语句 | CHANGE MASTER TO | CHANGE REPLICATION SOURCE TO | CHANGE MASTER TO |
| 查看复制状态 | SHOW SLAVE STATUS | SHOW REPLICA STATUS | SHOW SLAVE STATUS |
super_read_only | 支持 (5.7.8+) | 支持 | 不支持(去掉所有相关行) |
MASTER_AUTO_POSITION | 支持 | 支持 | 不支持(用 file+pos) |
loose- 前缀 | 支持 | 支持 | 支持 |
四、MySQL 配置
4.1 db-a /etc/my.cnf
[mysqld]
# ---- 基础配置 ----
server-id = 1 # 全局唯一,db-b 设为 2
log-bin = mysql-bin # 开启 binlog
binlog_format = ROW # 行级复制,保证数据一致性
gtid_mode = ON # 开启 GTID
enforce_gtid_consistency = ON # GTID 强制一致性
# ---- 日志与复制 ----
# MySQL 8.0 用 log_replica_updates;5.7/MariaDB 用 log_slave_updates
log_replica_updates = ON
# MySQL 8.0 用 binlog_expire_logs_seconds;5.7/MariaDB 用 expire_logs_days = 7
binlog_expire_logs_seconds = 604800 # 7 天自动清理
# ---- 自增冲突预防 ----
# 双主架构下必须错开自增值,否则同时插入会冲突
auto_increment_increment = 2 # 步长:2
auto_increment_offset = 1 # 偏移:A 生成奇数 (1,3,5...);B 设为 2 生成偶数
# ---- 半同步复制(MySQL 8.0)----
# 5.7/MariaDB 请替换为:semisync_master.so / semisync_slave.so
# 使用 plugin_load_add 而非 plugin-load,避免覆盖默认插件列表
plugin_load_add = "semisync_source.so;semisync_replica.so"
# loose- 前缀:插件未加载时仅警告,不阻止 MySQL 启动
# 避免首次初始化时因变量不存在而报错
loose_rpl_semi_sync_source_enabled = 1
loose_rpl_semi_sync_replica_enabled = 1
rpl_semi_sync_source_timeout = 10000 # 半同步超时 10 秒后降级为异步
# ---- 读写状态 ----
# 初始只读!谁成为 MASTER 由 Keepalived notify 脚本打开写入
read_only = ON
super_read_only = ON # MariaDB 删掉此行4.2 db-b 与 db-a 的差异
# db-b 仅以下三处不同:
server-id = 2 # 唯一 ID
auto_increment_offset = 2 # B 生成偶数 (2,4,6...)
read_only = ON # 同上,初始只读
super_read_only = ON4.3 MariaDB 特别注意事项
# MariaDB 的 my.cnf 去掉这两行:
# super_read_only = ON ← 删除,不支持
# plugin_load_add = "..." ← 用下面代替MariaDB 半同步插件在安装时自动加载,通常不需要在 my.cnf 中配置 plugin_load_add。如需手动加载:
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';五、双向复制搭建
5.1 创建复制用户(在 db-a 上执行一次即可,会自动同步到 db-b)
CREATE USER 'repl'@'%' IDENTIFIED BY 'Repl@123';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;5.2 建立复制
MySQL 8.0 语法:
-- 在 db-a 上执行:指向 db-b
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='192.168.7.32',
SOURCE_USER='repl',
SOURCE_PASSWORD='Repl@123',
SOURCE_AUTO_POSITION=1; -- 基于 GTID 自动定位
START REPLICA;
-- 在 db-b 上执行:指向 db-a
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='192.168.7.31',
SOURCE_USER='repl',
SOURCE_PASSWORD='Repl@123',
SOURCE_AUTO_POSITION=1;
START REPLICA;MySQL 5.7 语法:
-- 将 SOURCE 替换为 MASTER,REPLICA 替换为 SLAVE
CHANGE MASTER TO
MASTER_HOST='192.168.7.32',
MASTER_USER='repl',
MASTER_PASSWORD='Repl@123',
MASTER_AUTO_POSITION=1;
START SLAVE;MariaDB 语法(不支持 MASTER_AUTO_POSITION):
-- 先在 db-b 上查看当前 binlog 位置:
SHOW MASTER STATUS; -- 记录 File 和 Position
-- 在 db-a 上执行:
CHANGE MASTER TO
MASTER_HOST='192.168.7.32',
MASTER_USER='repl',
MASTER_PASSWORD='Repl@123',
MASTER_LOG_FILE='mysql-bin.000003', -- 替换为实际值
MASTER_LOG_POS=237; -- 替换为实际值
START SLAVE;
-- db-b 指向 db-a 同理5.3 验证
-- MySQL 8.0
SHOW REPLICA STATUS\G
-- MySQL 5.7 / MariaDB
SHOW SLAVE STATUS\G
-- 确保以下两项均为 Yes:
-- Replica_IO_Running / Slave_IO_Running: Yes
-- Replica_SQL_Running / Slave_SQL_Running: Yes六、Keepalived 配置
6.1 密码安全加固
# 在两台服务器上执行,避免脚本中出现明文密码
mysql_config_editor set --login-path=keepalived_check \
--host=localhost --user=root --password
# 输入 MySQL root 密码
# 验证
mysql --login-path=keepalived_check -e "SELECT 1"6.2 db-a /etc/keepalived/keepalived.conf
vrrp_script chk_mysql {
script "/etc/keepalived/check_mysql.sh"
interval 2 # 每 2 秒检查一次
weight -20 # 检查失败时扣减 20 点优先级
fall 2 # 连续失败 2 次才触发扣减
rise 2 # 连续成功 2 次才恢复优先级
}
vrrp_instance VI_MYSQL {
state BACKUP # 两台都设 BACKUP,靠 priority 决出 MASTER
interface ens192 # 根据实际网卡名修改
virtual_router_id 51 # 同一组 VRRP 必须相同
priority 100 # 主机 100;备机 90
advert_int 1 # VRRP 通告间隔 1 秒
authentication {
auth_type PASS
auth_pass MySqlVIP # 两台密码必须一致
}
virtual_ipaddress {
192.168.7.33/24 dev ens192 # VIP 地址
}
track_script {
chk_mysql # 关联健康检查脚本
}
notify_master "/etc/keepalived/notify_master.sh"
notify_backup "/etc/keepalived/notify_backup.sh"
notify_fault "/etc/keepalived/notify_fault.sh"
}db-b 配置完全相同,仅priority改为90。
6.3 check_mysql.sh —— 核心健康检查脚本
#!/bin/bash
# ============================================================
# 设计思路(这是整个方案最关键的部分):
#
# 常规方案的致命缺陷:
# 如果只在健康检查中判断 MySQL 是否存活,那么恢复后的节点
# (优先级更高)会立即抢回 VIP,导致不必要的第二次中断。
#
# 如果同时判断 read_only 状态,备机的 read_only=ON 会
# 导致其优先级永远被扣减,主库宕机后 80 > 70 无法切换。
#
# 本方案的解法:
# 利用 MySQL 复制 IO 线程的状态作为"主库是否还活着"的代理指标。
# - 备机(read_only=ON)+ IO 线程运行中 → 主库活着 → 不抢占
# - 备机(read_only=ON)+ IO 线程中断 → 主库挂了 → 可以接管
# - 主库(read_only=OFF) → 已经是主 → 保持
#
# 兼容性:
# - MySQL 5.7: Slave_IO_Running
# - MySQL 8.0: Replica_IO_Running
# - MariaDB: Slave_IO_Running
# 脚本同时 grep 两个关键字,覆盖所有版本
# ============================================================
export HOME=/root # Keepalived 不设 HOME,必须显式指定
# 1. 检查 MySQL 进程是否存活
mysqladmin --login-path=keepalived_check ping &>/dev/null || exit 1
# 2. 如果我是只读状态,判断主库是否还活着
READ_ONLY=$(mysql --login-path=keepalived_check -e "SELECT @@read_only" -sN 2>/dev/null)
if [ "$READ_ONLY" -eq 1 ]; then
# 检查复制 IO 线程:兼容 MySQL 5.7/8.0 和 MariaDB
IO_RUNNING=$(mysql --login-path=keepalived_check \
-e "SHOW REPLICA STATUS\G" 2>/dev/null \
| grep -iE "Replica_IO_Running|Slave_IO_Running" \
| awk '{print $2}')
if [ "$IO_RUNNING" = "Yes" ]; then
# 主库还活着(IO 线程正常接收 binlog)
# 我继续当备机,降低优先级,不参与竞选
exit 1
fi
# 主库挂了(IO 线程中断),我可以竞选主库
fi
exit 06.4 notify_master.sh —— 升级为主库
#!/bin/bash
# 当本机赢得 VRRP 选举成为 MASTER 时由 Keepalived 自动调用
# 作用:关闭 read_only,允许应用写入
export HOME=/root # 必须!否则 mysql 找不到登录配置
LOG=/var/log/keepalived_notify.log
echo "$(date): 升级为 MASTER,关闭 read_only" >> $LOG
# MySQL 8.0/5.7 需要同时关闭 super_read_only
# MariaDB 不支持 super_read_only → 删掉 ", SET GLOBAL super_read_only=OFF" 部分
mysql --login-path=keepalived_check \
-e "SET GLOBAL read_only=OFF; SET GLOBAL super_read_only=OFF;" \
>> $LOG 2>&16.5 notify_backup.sh —— 降级为备机
#!/bin/bash
# 当本机失去 MASTER 身份降级为 BACKUP 时由 Keepalived 自动调用
# 作用:开启 read_only,防止双主同时写入
export HOME=/root
LOG=/var/log/keepalived_notify.log
echo "$(date): 降级为 BACKUP,开启 read_only" >> $LOG
mysql --login-path=keepalived_check \
-e "SET GLOBAL read_only=ON; SET GLOBAL super_read_only=ON;" \
>> $LOG 2>&16.6 notify_fault.sh —— 故障时的最后防线
#!/bin/bash
# 当 VRRP 检测到异常状态(如脑裂)时由 Keepalived 自动调用
# 作用:强制删除本机 VIP,确保不会出现双端同时持有 VIP
export HOME=/root
LOG=/var/log/keepalived_notify.log
echo "$(date): FAULT 状态,强制下线 VIP" >> $LOG
/sbin/ip addr del 192.168.7.33/24 dev ens192 2>/dev/null6.7 部署
# 赋予执行权限
chmod +x /etc/keepalived/check_mysql.sh
chmod +x /etc/keepalived/notify_*.sh
# 启动
systemctl start keepalived
systemctl enable keepalived
# 验证 VIP
ip addr show ens192 | grep 192.168.7.33七、填坑实录
以下是实际部署中遇到的每一个坑,以及排查思路。
坑 1:<<EOF 不加引号,变量被提前展开(致命)
现象:脚本写入了,但 VIP 死活不切换。停掉 MySQL 后 Keepalived 毫无反应。
排查:cat /etc/keepalived/check_mysql.sh 一看,脚本内容变成了:
# $? 变成了字面量 0!永远不会 exit 1
if [ 0 -ne 0 ]; then
exit 1
fi
# $(mysql ...) 被替换成了写入时的返回值
READ_ONLY=0
# $READ_ONLY 被展开成了空字符串
if [ "" -eq 1 ]; then
exit 1
fi根因:cat > file <<EOF 中 heredoc 的分隔符没有用引号括起来,bash 在写入文件前对内容执行了变量替换和命令替换。$?、$(date)、$LOG、$READ_ONLY 全部变成了写入时的字面值。
解决:所有 heredoc 使用 <<'EOF'(单引号阻止任何展开)。
# 错误 ❌ —— 变量会被展开
cat > /etc/keepalived/check_mysql.sh <<EOF
...
EOF
# 正确 ✅ —— 内容原样写入
cat > /etc/keepalived/check_mysql.sh <<'EOF'
...
EOF教训:写完脚本后立刻 cat 检查内容。如果看到硬编码的数字和日期,那就是 heredoc 没加引号。
坑 2:Keepalived 执行脚本时不设置 $HOME
现象:VIP 漂移成功了,但 notify_master.sh 执行 mysql --login-path 时报错:
ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: NO)注意 (using password: NO)——说明 mysql 客户端根本没读到密码。
排查:手工执行脚本正常,但 Keepalived 触发时就失败。原因是 Keepalived 启动的子进程环境变量几乎是空的,$HOME 为空或指向 /,mysql 找不到 ~/.mylogin.cnf。
解决:所有脚本开头添加 export HOME=/root。mysql 和 mysqladmin 都需要这个变量。
教训:systemd / Keepalived 启动的进程环境变量极少。$HOME、$PATH、$USER 都不要假设存在。
坑 3:在健康检查中判断 read_only 导致备机永远无法接管
现象:主库宕机后,备机不接管。priority 推演如下:
db-a 宕机前:db-a=100, db-b=70 (90-20,因为 read_only=ON → check 失败)
db-a 宕机后:db-a=80 (100-20), db-b=70
80 > 70 → VIP 仍在 db-a ✗根因:备机的 read_only=ON 是正常状态,不应该被当作"故障"来扣减优先级。
解决:不检查 read_only 状态来判断故障。改用复制 IO 线程状态(见第六节 check_mysql.sh 的设计思路)。
坑 4:nopreempt 对 track_script 的动态权重变化无效
现象:加了 nopreempt,恢复后的节点仍然抢回 VIP。
根因:Keepalived 的 nopreempt 只在 VRRP 实例初次启动时生效(BACKUP → MASTER 的初始转换)。当 track_script 引起优先级动态变化时,nopreempt 不阻止抢占。
更糟的是,如果两台都加 nopreempt,连正常的故障切换都会被阻止——备机不会抢占优先级已经降低的主库。
解决:不使用 nopreempt,完全依靠 check 脚本中的复制 IO 感知逻辑来控制优先级。
坑 5:MariaDB 不支持 super_read_only
现象:MariaDB 上执行 SET GLOBAL super_read_only=ON 报错:
ERROR 1193 (HY000): Unknown system variable 'super_read_only'解决:所有配置和脚本中出现 super_read_only 的地方,MariaDB 都要删掉:
# MySQL 8.0 / 5.7.8+:
mysql -e "SET GLOBAL read_only=OFF; SET GLOBAL super_read_only=OFF;"
# MariaDB(仅保留 read_only):
mysql -e "SET GLOBAL read_only=OFF;"坑 6:MySQL 8.0 初始化时半同步变量报错
现象:mysqld --initialize 时报:
unknown variable 'rpl_semi_sync_source_enabled=1'根因:初始化阶段半同步插件尚未安装,MySQL 不认识这些变量。
解决:使用 loose- 前缀:
# ❌ 初始化报错
rpl_semi_sync_source_enabled = 1
# ✅ 插件未加载时仅警告
loose_rpl_semi_sync_source_enabled = 1坑 7:--initialize 在已有数据目录上失败
现象:修改 my.cnf 后重新初始化报错退出。
解决:清空数据目录后重新初始化:
systemctl stop mysqld
rm -rf /var/lib/mysql/* # 注意备份!
mysqld --initialize --user=mysql
grep 'temporary password' /var/log/mysqld.log # 查找临时密码
systemctl start mysqld八、VRRP 切换的业务中断问题
8.1 问题的本质
Keepalived 的 VIP 漂移本质上是网络层切换:VIP 从一个网卡解绑、ARP 广播更新、再绑定到另一个网卡。这个过程必然导致:
- 现有 TCP 连接被 RST(复位)
- 应用必须重建连接
- 在 ARP 缓存刷新前(最多几秒),数据包可能发往旧节点
在不改应用代码的前提下,无法做到绝对的零中断。 但可以通过几个层次来大幅缩短中断时间。
8.2 方案一:缩短检测窗口 + JDBC 自动重连(改动最小)
当前中断时长:≈ 6-8 秒(interval 2 × fall 2 + VRRP 协商 + ARP 刷新)
优化后:≈ 2-3 秒
Keepalived 调优——把检测参数压到最激进:
vrrp_script chk_mysql {
script "/etc/keepalived/check_mysql.sh"
interval 1 # 每秒检查,原来 2 秒
weight -20
fall 1 # 失败 1 次立刻触发,原来 2 次
rise 2 # 恢复保持 2 次,避免抖动
}fall 设为 1 的风险:MySQL 瞬时抖动可能触发误切换。如果 MySQL 稳定性没问题可以这么设;
保守的话保留 fall=2,中断也仅多 1 秒。
JDBC 连接串(不改代码,只改连接配置):
# MySQL Connector/J 8.0
jdbc:mysql://192.168.7.33:3306/db?autoReconnect=true&failOverReadOnly=false&maxReconnects=3&initialTimeout=1
# 参数说明:
# autoReconnect=true → 连接断开后自动重连
# failOverReadOnly=false → 重连后不设为只读(热备场景不需要)
# maxReconnects=3 → 最多重试 3 次
# initialTimeout=1 → 首次重试间隔 1 秒对大多数业务,2-3 秒的短暂中断 + 连接池自动重连已经可以接受。
8.3 方案二:加 HAProxy TCP 代理层(推荐,效果最好)
在两台 MySQL 服务器上各部署一个 HAProxy(TCP mode),应用连 HAProxy 而不是直连 MySQL:
应用 ──► VIP ──► HAProxy (:3307) ──► 本机 MySQL (:3306)
│ └─► 对端 MySQL (:3306) [备胎]
│
└─ Keepalived 管 VIP
└─ HAProxy 管 MySQL 流量分发HAProxy 配置 (/etc/haproxy/haproxy.cfg):
global
log /dev/log local0
maxconn 4096
defaults
mode tcp # TCP 模式,不做 SQL 解析
timeout connect 3s
timeout client 30s
timeout server 30s
log global
frontend mysql_frontend
bind 192.168.7.33:3306 # 监听 VIP 的 3306 端口
default_backend mysql_backend
backend mysql_backend
option tcp-check
# TCP 检查:连接 3306 端口成功即认为健康
server db-a 127.0.0.1:3306 check port 3306 inter 1s fall 1 rise 2
server db-b 192.168.7.32:3306 check port 3306 inter 1s fall 1 rise 2 backup两台服务器上 HAProxy 配置几乎相同,只是 server 行里把自己写前面、对端写 backup。为什么这个方案好:
- HAProxy 持有应用连接,后端 MySQL 切换时应用连接不断开(HAProxy 内部重连)
- 切换检测更快:
inter 1s fall 1,1 秒完成 - 不需要 Keepalived 的复杂 check 脚本——HAProxy 自己检测后端
- 可以做到灰度切换:旧连接的查询跑完再切,不丢事务
如果还担心 HAProxy 是单点:
- HAProxy 跟 MySQL 部署在同一台,MySQL 挂了 HAProxy 也没用,不需要额外 HA
- 如果真的要求 HAProxy 也要高可用,可以用 Keepalived 保 HAProxy 的 VIP(就像现在保 MySQL 的 VIP 一样)
代价:多部署一个 HAProxy(yum install 一条命令),多一个进程要管理。
8.4 方案三:MySQL Router(MySQL 官方方案)
MySQL 8.0 + InnoDB ReplicaSet 或 InnoDB Cluster 自带 MySQL Router:
应用 ──► MySQL Router (:6446 r/w) ──► 主库
└─► MySQL Router (:6447 r/o) ──► 备库Router 自动检测主备切换,把读写流量指向新主库。但:
- 需要 MySQL 8.0+
- 需要额外部署 Router 进程
- 对 InnoDB ReplicaSet 的支持比 InnoDB Cluster 弱
对当前的"纯双主热备"架构来说,Router 有点重了。
8.5 四种方案对比
| 中断时间 | 架构改动 | 额外组件 | 适用场景 | |
|---|---|---|---|---|
| 当前方案(VIP 直连) | 6-8 秒 | 无 | 无 | 可容忍短暂中断 |
| 方案一(调优+JDBC重连) | 2-3 秒 | 无(只改连接串) | 无 | 大多数业务 |
| 方案二(+HAProxy) | 1-2 秒,连接不断 | 加代理层 | HAProxy | 要求零感知 |
| 方案三(MySQL Router) | 秒级 | 换架构 | MySQL Router | InnoDB Cluster 用户 |
8.6 建议
- 先做方案一——5 分钟改完,中断压到 2-3 秒。绝大多数业务都能接受
- 如果业务方坚持"不能丢一个连接",上方案二——加 HAProxy,应用连接跟后端 MySQL 解耦
- 方案三留到将来如果要迁到 InnoDB Cluster 再考虑
九、验证清单
| # | 测试项 | 操作 | 预期结果 |
|---|---|---|---|
| 1 | 初始状态 | 两台都启动 | db-a 持有 VIP,read_only=OFF;db-b read_only=ON |
| 2 | 主库宕机 | systemctl stop mysqld 在 db-a | 8 秒内 VIP 漂移到 db-b,db-b 的 read_only=OFF |
| 3 | 主库恢复(不回切) | systemctl start mysqld 在 db-a | db-a read_only=ON,VIP 留在 db-b |
| 4 | 新主库宕机 | systemctl stop mysqld 在 db-b | VIP 漂移到 db-a,db-a 的 read_only=OFF |
| 5 | 双向复制 | SHOW REPLICA STATUS\G | IO/SQL 线程均为 Yes,延迟为 0 |
| 6 | 脑裂防护 | 检查两台 VIP | 同一时刻仅一台持有 VIP |
| 7 | 脚本变量完整性 | cat /etc/keepalived/check_mysql.sh | $?、$LOG 等变量未被展开 |
| 8 | 密码安全 | grep -r 'password' /etc/keepalived/*.sh | 无明文密码 |
| 9 | notify 日志 | cat /var/log/keepalived_notify.log | 无 ERROR 字样 |
十、故障排查速查
# 查看 VIP 在哪台
ip addr show ens192 | grep 192.168.7.33
# Keepalived 实时日志
journalctl -u keepalived -f
# 切换历史
tail -20 /var/log/keepalived_notify.log
# 手动测试 check 脚本(含逐行调试)
bash -x /etc/keepalived/check_mysql.sh; echo "exit code: $?"
# 模拟 Keepalived 环境执行 notify(干净环境变量)
env -i HOME=/root bash /etc/keepalived/notify_master.sh
# 查看 MySQL 复制状态
mysql -e "SHOW REPLICA STATUS\G" 2>/dev/null | grep -E "Running|Seconds_Behind|Error"
# 查看当前只读状态和 hostname
mysql -e "SELECT @@hostname, @@read_only, @@super_read_only"
# 验证 mysql_config_editor
mysql_config_editor print --login-path=keepalived_check
mysql --login-path=keepalived_check -e "SELECT 1"
# 检查脚本内容(变量是否被展开)
cat /etc/keepalived/check_mysql.sh | grep -E '\$\?|\$\(|\$LOG|\$READ_ONLY'十一、总结
这套方案的核心思路只有三点:
- 双主免拓扑切换——不管 VIP 在哪台,另一台本来就是它的从库,省去了
CHANGE MASTER的麻烦 - 复制 IO 线程感知——利用 MySQL 原生的复制状态判断主库是否存活,优雅地解决了"恢复后不回切"的问题,不需要
nopreempt,不需要 flag 文件 - 最少依赖——整个方案只多了一个 Keepalived(几十万行稳定代码),没有引入任何新的单点
部署时记住三条铁律:heredoc 加引号、notify 脚本设 HOME、检查脚本用 IO 线程不要用 read_only。