纯热备场景下,用最少的组件(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 实际 prioritydb-b 状态db-b 实际 priorityVIP 在
初始正常read_only=OFF100read_only=ON, IO 活跃70 (90-20)db-a
db-a 宕机MySQL 挂了80 (100-20)read_only=ON, IO 断开90db-b
db-a 恢复read_only=ON, IO 活跃80 (100-20)read_only=OFF90db-b(不回切)
db-b 宕机read_only=ON, IO 断开100MySQL 挂了70 (90-20)db-a

二、环境信息

角色IP初始状态Keepalived priority
db-a192.168.7.31read_only=ON,等 Keepalived 竞选100
db-b192.168.7.32read_only=ON,等 Keepalived 竞选90
VIP192.168.7.33
注意:两台 my.cnf 都配置 read_only=ON。谁成为 MASTER 谁由 notify 脚本打开写入。这样恢复后的节点总是以只读模式启动,不会出现双主同时可写的窗口。

三、多版本兼容速查

配置项MySQL 5.7MySQL 8.0MariaDB 10.x
过期日志expire_logs_days=7binlog_expire_logs_seconds=604800expire_logs_days=7
从库更新log_slave_updateslog_replica_updateslog_slave_updates
半同步主插件semisync_master.sosemisync_source.sosemisync_master.so
半同步从插件semisync_slave.sosemisync_replica.sosemisync_slave.so
半同步主变量rpl_semi_sync_master_enabledrpl_semi_sync_source_enabledrpl_semi_sync_master_enabled
复制语句CHANGE MASTER TOCHANGE REPLICATION SOURCE TOCHANGE MASTER TO
查看复制状态SHOW SLAVE STATUSSHOW REPLICA STATUSSHOW 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 = ON

4.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 0

6.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>&1

6.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>&1

6.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/null

6.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=/rootmysqlmysqladmin 都需要这个变量。

教训: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:nopreempttrack_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 RouterInnoDB Cluster 用户

8.6 建议

  1. 先做方案一——5 分钟改完,中断压到 2-3 秒。绝大多数业务都能接受
  2. 如果业务方坚持"不能丢一个连接",上方案二——加 HAProxy,应用连接跟后端 MySQL 解耦
  3. 方案三留到将来如果要迁到 InnoDB Cluster 再考虑

九、验证清单

#测试项操作预期结果
1初始状态两台都启动db-a 持有 VIP,read_only=OFF;db-b read_only=ON
2主库宕机systemctl stop mysqld 在 db-a8 秒内 VIP 漂移到 db-b,db-b 的 read_only=OFF
3主库恢复(不回切)systemctl start mysqld 在 db-adb-a read_only=ONVIP 留在 db-b
4新主库宕机systemctl stop mysqld 在 db-bVIP 漂移到 db-a,db-a 的 read_only=OFF
5双向复制SHOW REPLICA STATUS\GIO/SQL 线程均为 Yes,延迟为 0
6脑裂防护检查两台 VIP同一时刻仅一台持有 VIP
7脚本变量完整性cat /etc/keepalived/check_mysql.sh$?$LOG 等变量未被展开
8密码安全grep -r 'password' /etc/keepalived/*.sh无明文密码
9notify 日志cat /var/log/keepalived_notify.logERROR 字样

十、故障排查速查

# 查看 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'

十一、总结

这套方案的核心思路只有三点:

  1. 双主免拓扑切换——不管 VIP 在哪台,另一台本来就是它的从库,省去了 CHANGE MASTER 的麻烦
  2. 复制 IO 线程感知——利用 MySQL 原生的复制状态判断主库是否存活,优雅地解决了"恢复后不回切"的问题,不需要 nopreempt,不需要 flag 文件
  3. 最少依赖——整个方案只多了一个 Keepalived(几十万行稳定代码),没有引入任何新的单点

部署时记住三条铁律:heredoc 加引号、notify 脚本设 HOME、检查脚本用 IO 线程不要用 read_only

最后修改:2026 年 05 月 07 日
如果觉得我的文章对你有用,请随意赞赏