MySQL 5.7 双主互备 + Keepalived 高可用搭建

作者:Sunflower 发布时间: 2026-09-11 阅读量:27 评论数:0

MySQL 5.7 双主互备 + Keepalived 高可用搭建

本文记录一套 MySQL 5.7 双主互备 + Keepalived 高可用环境的搭建过程,包括架构设计、双向复制配置、切换脚本、备份方案与从库重建流程。

环境:CentOS 7、MySQL 5.7、Keepalived、XtraBackup 2.4
适用场景:中小规模生产库,要求主库故障时业务能够自动切换到备库

[toc]


一、架构设计

1.1 架构图

flowchart TB
    APP["应用程序<br/>连接 VIP"]
    VIP(["VIP 172.25.16.40<br/>Keepalived"])
    M1["mysql1 172.25.16.41<br/>MASTER · priority 150<br/>MySQL 5.7 · GTID<br/>read_only = OFF"]
    M2["mysql2 172.25.16.42<br/>BACKUP · priority 100<br/>MySQL 5.7 · GTID<br/>read_only = ON"]

    APP -->|"读写"| VIP
    VIP -->|"正常时"| M1
    VIP -.->|"故障时漂移"| M2
    M1 -->|"复制"| M2
    M2 -->|"复制"| M1

正常状态下,应用通过 VIP 访问 mysql1;mysql2 同步 mysql1 的数据,自身为只读。

mysql1 故障时,VIP 切换到 mysql2,mysql2 转为可写。

mysql1 恢复并追平数据后,VIP 切回 mysql1。

1.2 设计前提

1.2.1 双主不等于两台同时可写

MySQL 复制是单向的日志重放,没有冲突检测与合并机制。两台同时接受写入会出现以下问题:

  • 两台分别产生事务,互相复制时同一行的数据会被覆盖,且两端的最终顺序不一致
  • 同一主键在两台分别插入时,复制报 Duplicate entry,SQL 线程中断
  • 两边数据不再一致,且无法自动判定以哪边为准

因此双主结构中,任意时刻只允许一台接受写入。

1.2.2 双向复制的作用

双主结构需要从两个维度理解:

维度取值含义
结构互为主从两台互相作为对方的从库,双向复制链路均已配置
行为主备单写同一时刻只有一台可写,由 VIP 所在节点决定

结构上配置双向复制是为了故障恢复。mysql1 故障期间由 mysql2 承担写入,mysql1 恢复后需要通过反向链路(mysql1 ← mysql2)补齐这段时间的数据。如果只配置单向复制,故障后需要人工重建 mysql1,无法做到自动恢复。

行为上限制单写是为了避免 1.2.1 中的问题。

1.2.3 VIP 的作用

如果应用直连 172.25.16.41,mysql1 故障时需要修改应用配置并重启,无法自动切换。

VIP(虚拟 IP)是一个可以在两台机器之间漂移的地址,当前由哪台持有由 Keepalived 通过 VRRP 协议决定。应用只连接 VIP,不感知后端实际节点。

1.3 主机规划

角色主机名IP说明
主(读写)mysql1172.25.16.41承载业务读写
备(只读)mysql2172.25.16.42实时同步,故障时顶替
虚拟 IP—172.25.16.40应用连接地址
备份存储—NAS存放全量备份

二、环境准备

2.1 前置条件

  • 两台 CentOS 7 服务器,MySQL 5.7,两台版本需一致
  • 两台之间网络互通,开放 3306(复制)与 VRRP 协议(112)
  • MySQL 已初始化
  • 可用的 NAS 共享,用于存放备份

2.2 MySQL 参数配置

两台修改 /etc/my.cnf,server-id 必须不同,其余保持一致:

[mysqld]
server-id = 1641                    # mysql2 改为 1642
log_bin = mysql-bin
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_format = ROW

log_slave_updates = OFF
expire_logs_days = 7
log_bin_trust_function_creators = 1
read_only = 1

参数说明:

  • gtid_mode = ON、enforce_gtid_consistency = ON:开启 GTID。复制位点由「文件名 + 偏移量」改为事务 ID,这是双向复制与故障恢复的基础。
  • expire_logs_days = 7:设置 binlog 过期天数。该参数默认不清理,长期运行会占满磁盘。
  • log_bin_trust_function_creators = 1:两台必须一致。从库该值为 0 时,复制到 CREATE FUNCTION 语句会报错 1418 并中断复制。
  • read_only = 1:两台都默认只读。实际可写状态由 Keepalived 的脚本在切换时控制,见 5.2。
  • log_slave_updates = OFF:两节点互备场景不需要将中继的事件再次写入本机 binlog。

修改后重启:

systemctl restart mysqld

2.3 创建复制账号

在 mysql1 上执行一次,DDL 会随复制同步到 mysql2:

CREATE USER 'synchronize'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'synchronize'@'%';
FLUSH PRIVILEGES;

账号与权限相关的 DDL 只能在一台执行。两台分别执行会互相回放并报 ERROR 1396,导致两条复制链路的 SQL 线程同时中断,详见 5.5。

在 mysql2 上确认同步结果:

SELECT user, host FROM mysql.user WHERE user='synchronize';

三、搭建双向复制

3.1 GTID 与 AUTO_POSITION

传统复制需要指定从库从某个 binlog 文件的某个偏移量开始,切换或重建后需要人工重新计算,容易出错。

GTID 模式下每个事务有全局唯一编号。设置 MASTER_AUTO_POSITION=1 后,从库连接主库时会自动比对已执行的事务集合,由主库计算需要发送的部分,无需人工指定偏移量。

在本文场景中,mysql1 故障重建后可以从 mysql2 自动补齐缺失的事务,不需要人工干预。

3.2 链路 A:mysql2 ← mysql1

在 mysql2 上执行:

CHANGE MASTER TO
  MASTER_HOST='172.25.16.41',
  MASTER_PORT=3306,
  MASTER_USER='synchronize',
  MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION=1;

START SLAVE;

3.3 链路 B:mysql1 ← mysql2

在 mysql1 上执行:

CHANGE MASTER TO
  MASTER_HOST='172.25.16.42',
  MASTER_PORT=3306,
  MASTER_USER='synchronize',
  MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION=1;

START SLAVE;

3.4 验证

两台分别执行:

SHOW SLAVE STATUS\G

关注字段:

字段期望值含义
Slave_IO_RunningYes拉取主库 binlog 的线程运行中
Slave_SQL_RunningYes回放事务的线程运行中
Seconds_Behind_Master0无延迟
Last_Errno0无错误

链路 B(mysql1 ← mysql2)在正常状态下是空闲的。mysql1 是写入方,mysql2 没有新事务需要发送,因此该链路的 Relay_Log_Space 很小、Seconds_Behind_Master 为 0,属于正常现象。该链路的作用是在故障恢复时使用。


四、Keepalived 配置

4.1 安装

两台执行:

yum install -y keepalived
systemctl enable keepalived

4.2 配置文件

/etc/keepalived/keepalived.conf,mysql1:

global_defs {
   router_id mysql_01
}
vrrp_script check_mysql {
    script "/etc/keepalived/check_mysql.sh"
    interval 3
    timeout 2
    weight -60
    fall 3
    rise 2
}
vrrp_instance VI_1 {
    state BACKUP
    interface eth0
    virtual_router_id 40
    priority 150
    advert_int 1
    preempt_delay 300
    authentication {
        auth_type PASS
        auth_pass password
    }
    virtual_ipaddress {
        172.25.16.40
    }
    track_script {
        check_mysql
    }
    notify_master "/etc/keepalived/notify_master.sh"
    notify_backup "/etc/keepalived/notify_backup.sh"
    notify_fault  "/etc/keepalived/notify_fault.sh"
    unicast_src_ip 172.25.16.41
    unicast_peer {
        172.25.16.42
    }
}

mysql2 相同,仅修改四处:

   router_id mysql_02
   priority 100
   unicast_src_ip 172.25.16.42
   unicast_peer {
       172.25.16.41
   }

参数说明:

参数说明
state BACKUP(两台)两台都以备状态启动,通过优先级选举,避免任一节点开机后自封为主
priority 150 / 100数值高者持有 VIP,正常情况下为 mysql1
weight -60健康检查失败时扣减的优先级,取值要求见 5.3
track_script绑定健康检查脚本
preempt_delay 300恢复后延迟 300 秒再抢占 VIP,见 5.4
notify_*状态切换时执行的脚本,用于切换 read_only
unicast_src_ip / unicast_peer使用单播代替组播,多数机房禁用组播

4.3 健康检查脚本

/etc/keepalived/check_mysql.sh,两台相同,权限 755:

#!/bin/bash
# keepalived 健康检查 + 角色校准
# 退出码:0=健康(保持/可争取 VIP)  1=不健康(让出/不抢 VIP)
VIP="172.25.16.40"
IFACE="eth0"
MAX_LAG=10
LOG=/var/log/keepalived-mysql.log
MYSQL="mysql --defaults-file=/etc/keepalived/.my.cnf --connect-timeout=3"

# 1) mysqld 可达且能执行查询
$MYSQL -N -B -e "SELECT 1;" >/dev/null 2>&1 || exit 1

# 2) 判定本机是否持有 VIP
if ip -o addr show dev "$IFACE" | grep -q "$VIP/"; then HOLD=1; else HOLD=0; fi

# 3) 按角色校准 read_only(幂等:不一致才写)
if [ "$HOLD" = "1" ]; then want=0; else want=1; fi
cur=$($MYSQL -N -B -e "SELECT @@GLOBAL.read_only;" 2>/dev/null)
if [ -n "$cur" ] && [ "$cur" != "$want" ]; then
    $MYSQL -e "SET GLOBAL read_only=$want;" 2>>"$LOG" \
      && echo "$(date '+%F %T') [$(hostname)] enforce read_only=$want (was $cur)" >> "$LOG"
fi

# 4) 仅备角色才校验复制健康——数据落后时不允许顶替、也不允许抢回 VIP
if [ "$HOLD" = "0" ]; then
    info=$($MYSQL -E -e "SHOW SLAVE STATUS" 2>/dev/null)
    [ -z "$info" ] && exit 0
    sql=$(echo "$info" | awk -F': ' '/Slave_SQL_Running:/{print $2; exit}')
    lag=$(echo "$info" | awk -F': ' '/Seconds_Behind_Master:/{print $2; exit}')
    [ "$sql" = "Yes" ] || exit 1
    if [ "$lag" != "NULL" ] && [ -n "$lag" ] && [ "$lag" -gt "$MAX_LAG" ] 2>/dev/null; then
        exit 1
    fi
fi
exit 0

脚本包含两个部分需要留意:

  1. 校验复制健康状态,而非仅判断进程存活(第 4 段)
  2. 按当前角色校准 read_only(第 3 段)

4.4 notify 脚本

健康检查每 3 秒执行一次,切换发生时需要立即调整 read_only,不能等待下一个检查周期,因此需要 notify 脚本。

连接数据库的凭据文件 /etc/keepalived/.my.cnf,两台相同,权限 600:

[client]
socket=/var/lib/mysql/mysql.sock
user=keepalive
password=password

创建最小权限账号,在 mysql1 执行一次:

CREATE USER 'keepalive'@'localhost' IDENTIFIED BY 'password';
GRANT PROCESS, REPLICATION CLIENT, SUPER ON *.* TO 'keepalive'@'localhost';
FLUSH PRIVILEGES;

三个 notify 脚本,两台相同,权限 755。

notify_master.sh,本机取得 VIP 时执行,转为可写:

#!/bin/bash
LOG=/var/log/keepalived-mysql.log
echo "$(date '+%F %T') [$(hostname)] -> MASTER : set read_only=0" >> "$LOG"
mysql --defaults-file=/etc/keepalived/.my.cnf \
      --connect-timeout=3 -e "SET GLOBAL read_only=0;" 2>>"$LOG"

notify_backup.sh,本机失去 VIP 时执行,转为只读:

#!/bin/bash
# 复制线程不受 read_only 影响,备机设为只读是安全的
LOG=/var/log/keepalived-mysql.log
echo "$(date '+%F %T') [$(hostname)] -> BACKUP : set read_only=1" >> "$LOG"
mysql --defaults-file=/etc/keepalived/.my.cnf \
      --connect-timeout=3 -e "SET GLOBAL read_only=1;" 2>>"$LOG"

notify_fault.sh,仅记录日志:

#!/bin/bash
echo "$(date '+%F %T') [$(hostname)] -> FAULT" >> /var/log/keepalived-mysql.log

4.5 启动与验证

两台执行:

chmod 600 /etc/keepalived/.my.cnf
chmod 755 /etc/keepalived/check_mysql.sh /etc/keepalived/notify_*.sh
systemctl restart keepalived

验证一,确认 VIP 所在节点:

ip -o addr show dev eth0 | grep 172.25.16.40
# 应当只在 mysql1 上输出

验证二,确认 read_only 状态:

-- 两台分别执行
SHOW VARIABLES LIKE 'read_only';
-- mysql1 期望 OFF,mysql2 期望 ON

验证三,确认切换日志:

cat /var/log/keepalived-mysql.log
# mysql1: -> MASTER : set read_only=0
# mysql2: -> BACKUP : set read_only=1

五、设计要点

5.1 健康检查的判断依据

常见做法是判断 mysqld 进程是否存在:

# 不推荐
pidof mysqld || exit 1

这种方式存在缺陷。从库的 SQL 线程中断时 mysqld 进程仍然存在,本机在 Keepalived 看来是健康的,但数据可能已经落后较长时间。此时如果主库故障,VIP 会切换到这个数据过期的节点。

推荐做法(即 4.3 脚本第 4 段):

  • 检查 Slave_IO_Running 与 Slave_SQL_Running 均为 Yes
  • 检查 Seconds_Behind_Master 未超过阈值(示例为 10 秒)
  • 仅在「本机未持有 VIP」时执行上述判断。本机持有 VIP 时,其从库链路中断属于正常情况,不应据此判定自身不健康

5.2 read_only 的持久化

read_only 是动态变量,重启后恢复为 my.cnf 中的值,MySQL 5.7 没有 SET PERSIST。如果不做处理,会出现以下情况:

  • my.cnf 未配置 read_only 时,两台重启后都变为可写
  • 仅依赖 notify 脚本设置时,脚本可能在 mysqld 就绪之前执行,SET GLOBAL read_only=1 失败且不会重试

结果是备库重启后长期处于可写状态,任何直连备库的写入都会造成数据分叉。

处理方式:

  1. 两台 my.cnf 都配置 read_only = 1,保证开机默认只读。该状态的失败方向是安全的,即多只读、不会多写
  2. 健康检查脚本按角色校准(4.3 脚本第 3 段):持有 VIP 的设为 0,未持有的设为 1,每 3 秒执行一次

需要注意的副作用:配置之后,不经过 Keepalived 的手工重启会使该机先进入只读状态,需要手动执行 SET GLOBAL read_only=0; 恢复。

5.3 weight 的取值

Keepalived 的权重是加减关系,脚本失败时的实际优先级为 priority - weight。

配置主库失败后结果
weight -5150 - 5 = 145145 > 100,备库不会接管
weight -60150 - 60 = 9090 < 100,备库接管

weight 的绝对值必须大于两台 priority 的差值,否则健康检查不产生实际作用。示例中差值为 50,取 -60 留有余量。

5.4 自动回切的延迟控制

主库恢复后是否自动切回 VIP,取决于是否需要额外确认数据已追平。

两个机制配合:

  1. preempt_delay 300:主库恢复后延迟 300 秒再抢占,这段时间用于追平故障期间落后的事务
  2. 健康检查中的延迟限制:主库在未持有 VIP 的状态下,如果 Seconds_Behind_Master 超过阈值或 SQL 线程未运行,脚本判定为不健康,优先级降至 90,无法抢占 VIP;追平后优先级恢复为 150

如果不希望自动切回,可去掉 preempt_delay 并改用 nopreempt。此时 VIP 不会自动切回,需要人工执行切换。

5.5 DDL 的执行范围

双向复制环境下,DDL 与账号变更只能在一台执行。

两条复制链路均处于启用状态。在 mysql1 执行 CREATE USER 时:

  • 该语句会复制到 mysql2 并执行
  • 如果在 mysql2 也执行了同样的语句,mysql2 的这条语句会复制回 mysql1
  • 两台都收到对方发来的建用户语句,此时用户已存在,报 ERROR 1396,两条链路的 SQL 线程同时中断

因此,建库建表、修改结构、建账号、授权、建存储过程等操作只在主库执行一次。如确实需要在两台手工执行,使用幂等写法(CREATE USER IF NOT EXISTS、CREATE TABLE IF NOT EXISTS)。

5.6 健康检查脚本的性能

不建议在健康检查脚本中执行 systemctl restart mysqld:

# 不推荐
pidof mysqld
if [ $? -ne 0 ]; then
    systemctl restart mysqld
    ...
fi

健康检查脚本由 Keepalived 每 3 秒调用一次,需要尽快返回。systemctl restart mysqld 通常耗时数秒到十几秒,会阻塞 VRRP 状态机,可能造成心跳超时与误切换。

职责划分:

  • Keepalived 负责决策,健康则保持,不健康则让出
  • systemd 负责拉起进程
# 两台执行
mkdir -p /etc/systemd/system/mysqld.service.d
cat > /etc/systemd/system/mysqld.service.d/override.conf <<'EOF'
[Service]
Restart=on-failure
RestartSec=5
EOF
systemctl daemon-reload

效果:mysqld 异常退出(崩溃、被 OOM killer 终止、被 kill -9)时,systemd 在 5 秒后拉起。systemctl stop mysqld 属于正常停止,退出码为 0,不会触发重启,不影响手工维护。

说明:MySQL 的 RPM 单元文件中包含 RestartPreventExitStatus=1,即启动失败(退出码 1)不重试,用于避免配置错误导致的重复重启。该默认行为保留即可。


六、备份方案

高可用解决的是机器故障,备份解决的是数据误操作。DROP TABLE 会实时复制到备库,切换 VIP 无法恢复。

6.1 备份方式

常见的脚本写法是先备份到本地再打包上传:

xtrabackup --backup --target-dir=/local/backup
tar -zcf /local/backup.tar.gz /local/backup
mv /local/backup.tar.gz /nas/

存在三个问题:

  1. 本地需要额外空间,全量数据与压缩包同时存在
  2. 主库本地磁盘可能已接近上限,写入失败会导致备份中断
  3. 未检查返回码时,xtrabackup 失败后脚本仍会继续执行并输出「备份成功」,而 NAS 上的产物不完整

改用 --stream 直接写入 NAS,本地不落地:

xtrabackup --stream=xbstream ... 2> xtrabackup.log | cat > /nas/full-日期.xbstream

6.2 备份脚本

/mysqldata/mysqlbackupdata/backup.sh:

#!/bin/bash
###############################################################################
# MySQL 全备 → NAS(xtrabackup --stream 直写,不落本地盘)
# 失败:不覆盖正式包、保留旧备份、退出码非 0
###############################################################################
NasPath="/mnt/nas/mysqlbackup"
NasShare="//nas-server/backup/mysqlbackup"
CifsUser="backup"; CifsPass="password"

DBUser="backup"; DBPass="password"
Socket="/var/lib/mysql/mysql.sock"

RetainDays=7          # 保留最近几份
MinSizeMB=5000        # 产物小于此值判定为失败(拦截残包)
LogFile="/var/log/mysqlbackup.log"

ts(){ date +"%F %T"; }
log(){ echo "$(ts) $*" >> "$LogFile"; }
fail(){ log "FAILED: $*"; echo "$(ts) MySQL备份失败: $*" >&2; exit 1; }

STAMP=$(date +%F)
DEST="${NasPath}/full-${STAMP}.xbstream"
TMP="${DEST}.part"
XLOG="${NasPath}/full-${STAMP}.log"

# 1) 确保 NAS 挂载
mkdir -p "$NasPath"
if ! mountpoint -q "$NasPath"; then
    log "NAS 未挂载,尝试挂载"
    mount -t cifs "$NasShare" "$NasPath" -o username="$CifsUser",password="$CifsPass" \
        || fail "NAS 挂载失败"
fi

# 2) 前置检查(含数据库凭据校验)
[ -S "$Socket" ] || fail "socket 不存在: $Socket"
mysql --socket="$Socket" -u"$DBUser" -p"$DBPass" -e "SELECT 1" >/dev/null 2>&1 \
    || fail "mysqld 不可达 或 DB 凭据错误"

# 3) 备份:stream 直写 NAS,先写 .part
log "开始备份 → $DEST"
ulimit -n 65535 2>/dev/null
xtrabackup --user="$DBUser" --password="$DBPass" --socket="$Socket" \
    --backup --stream=xbstream 2> "$XLOG" | cat > "$TMP"
rc=${PIPESTATUS[0]}                        # 取 xtrabackup 的返回码,不是 cat 的

[ "$rc" -eq 0 ]                || { rm -f "$TMP"; fail "xtrabackup 返回码 $rc(见 $XLOG)"; }
grep -q 'completed OK' "$XLOG" || { rm -f "$TMP"; fail "日志无 'completed OK'(见 $XLOG)"; }

size_mb=$(( $(stat -c %s "$TMP" 2>/dev/null || echo 0) / 1048576 ))
[ "$size_mb" -ge "$MinSizeMB" ] || { rm -f "$TMP"; fail "产物过小 ${size_mb}MB,疑似不完整"; }

mv -f "$TMP" "$DEST" || fail "改名失败"
log "备份成功: $DEST (${size_mb}MB)"

# 4) 清理旧备份(保留最近 RetainDays 份)
cd "$NasPath" || fail "无法进入 $NasPath"
ls -1t full-*.xbstream 2>/dev/null | tail -n +$((RetainDays+1)) | while read -r f; do
    log "删除旧备份 $f"
    rm -f -- "$f" "${f%.xbstream}.log"
done

log "完成"
exit 0

脚本的安全设计:

设计作用
先写 .part,全部校验通过后才改名正式包始终完整,不会出现不完整的包被当作成功产物
三重校验:返回码 + 日志 completed OK + 产物大小下限任一环节不通过即判定失败
失败直接 exit 1,且不删除旧备份备份失败时上一份可用备份仍然保留
清理逻辑只匹配 full-*.xbstream不会误删 NAS 上的其他文件

6.3 定时任务

crontab -e

10 22 * * * /bin/bash /mysqldata/mysqlbackupdata/backup.sh

两处需要注意:

  • 使用 /bin/bash。脚本通过 ${PIPESTATUS[0]} 取管道中 xtrabackup 的返回码。写成 /bin/sh 时脚本开头的 shebang 会被忽略,PIPESTATUS 取不到值,rc 为空,校验失败,备份成功也会报告失败。
  • 第一次上线前先手动执行一次,确认产物大小在正常量级(示例环境约 30G),再交给 cron。

6.4 保留策略

RetainDays=7 与 expire_logs_days=7 配合使用,全备与 binlog 均保留一周,两者对齐。这样可以在最近 7 天内恢复到任意时间点(全备 + 之后的 binlog 回放)。

NAS 空间估算:单份 30G × 7 份约 210G。如果库持续增长,需要同步调整 NAS 容量或减少保留份数。


七、从库重建

备库数据不可用时(例如长时间未同步、数据已被人工写入),可以直接使用一份全备重建,不需要尝试追平。

7.1 重建流程

以使用 mysql1 的全备重建 mysql2 为例。

第 0 步:在 mysql1 上停止复制并释放空间

STOP SLAVE;
SET GLOBAL expire_logs_days=7;
PURGE BINARY LOGS TO 'mysql-bin.000135';            -- 清至备份点文件

顺序上需要先停复制再执行 PURGE,否则可能清掉从库仍需读取的 binlog。

第 1 步:停止 mysql2 的 Keepalived 与复制

systemctl stop keepalived        # 先停,否则其检查脚本会反复尝试重启 mysqld
mysql -e "STOP SLAVE;"
systemctl stop mysqld

第 2 步:保留原数据目录并保存本机身份文件

mv /var/lib/mysql /var/lib/mysql.old              # 旧数据改名保留,可回退
rm -f /var/lib/mysql.old/*-relay-bin.*            # 清理 relay 日志腾出空间
mkdir -p /var/lib/mysql
cp /var/lib/mysql.old/auto.cnf /root/mysql2_auto.cnf.bak

auto.cnf 保存本机的 server-uuid。GTID 体系中每个事务都带有产生它的 UUID 标记,如果重建时将其替换为主库的 UUID,两台机器的 UUID 相同,GTID 体系会失效。因此该文件需要保留并恢复。

第 3 步:解包与 prepare

mkdir -p /var/lib/mysql-restore && cd /var/lib/mysql-restore
ulimit -n 65535                                   # 表较多时避免 "Too many open files"
xbstream -x < /mnt/nas/mysqlbackup/full-2026-09-10.xbstream
xtrabackup --prepare --target-dir=/var/lib/mysql-restore
# 结尾需出现 completed OK!

第 4 步:copy-back 与恢复身份文件

xtrabackup --copy-back --target-dir=/var/lib/mysql-restore --datadir=/var/lib/mysql
chown -R mysql:mysql /var/lib/mysql
restorecon -Rv /var/lib/mysql                     # CentOS 7 + SELinux 环境需要执行

cp /root/mysql2_auto.cnf.bak /var/lib/mysql/auto.cnf
chown mysql:mysql /var/lib/mysql/auto.cnf
cat /var/lib/mysql/auto.cnf                            # 确认是本机 UUID

systemctl start mysqld

两处顺序细节:

  1. cp 之后需要再执行一次 chown。如果先用 root 执行 chown -R、之后又用 root cp 放回 auto.cnf,该文件属主为 root:root,mysqld 以 mysql 用户无法读取,启动失败并报 Errcode: 13 - Permission denied。
  2. 恢复前确认 xtrabackup --version 为 2.4.x,8.x 版本无法处理 5.7 的备份。

第 5 步:对齐 GTID

恢复出来的库,gtid_executed 可能落后于备份的真实坐标。原因是全备中的 mysql.gtid_executed 表可能滞后于数据落盘的位置。不对齐时,接上复制后第一个事务会报 1062 Duplicate entry,主库重发从库认为尚未执行、但数据中已存在的事务。

权威坐标在备份文件中的 xtrabackup_binlog_info:

cat /var/lib/mysql-restore/xtrabackup_binlog_info
# 输出:mysql-bin.000135   926515169   8a0787a6-...:1-191,9b7b18f7-...:1-87767207
#                                        └────────── 使用这个 GTID 集合 ──────────┘

使用该值对齐:

RESET MASTER;   -- 清空 binlog 与 gtid_executed(SET gtid_purged 的前置条件)
SET GLOBAL gtid_purged='8a0787a6-...:1-191,9b7b18f7-...:1-87767207';

第 6 步:重建两条链路

-- 先建链路 A:mysql2 ← mysql1
CHANGE MASTER TO
  MASTER_HOST='172.25.16.41', MASTER_PORT=3306,
  MASTER_USER='synchronize', MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION=1;
START SLAVE;
-- 等待 Seconds_Behind_Master = 0

-- 再建链路 B:mysql1 ← mysql2
CHANGE MASTER TO
  MASTER_HOST='172.25.16.42', MASTER_PORT=3306,
  MASTER_USER='synchronize', MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION=1;
START SLAVE;

第 7 步:恢复 Keepalived 并确认 VIP

systemctl start keepalived
# 两台分别执行
ip -o addr show dev eth0 | grep 172.25.16.40

旧数据目录 /var/lib/mysql.old 建议保留 1~2 天,确认新库运行正常后再删除。

7.2 重建后的校验

数据量较大、且经历过重建的备库,建议执行一次全库校验,确认两边数据一致:

pt-table-checksum --host=172.25.16.41 --user=<user> --password=<pass> \
                  --databases=<库名> --replicate=percona.checksums
pt-table-sync --print --replicate=percona.checksums <从库DSN> <主库DSN>
# --print 仅输出差异 SQL,确认后再决定是否执行

八、故障切换与演练

这套架构在上线前需要至少执行一次切换演练。

8.1 演练步骤

在低峰时段,于 mysql1 上模拟真实崩溃:

systemctl kill -s SIGKILL mysqld

8.2 预期现象

时间现象验证方式
5 秒内mysql1 的 mysqld 被 systemd 拉起systemctl status mysqld 显示 active
10~15 秒内健康检查连续失败,mysql1 优先级降至 90,VIP 切换到 mysql2ip a | grep 172.25.16.40,仅在 mysql2 上有输出
同时mysql2 的 read_only 变为 OFF,恢复读写SHOW VARIABLES LIKE 'read_only'
mysql1 追平后 300 秒VIP 切回 mysql1再次检查 ip a;cat /var/log/keepalived-mysql.log

8.3 演练后确认

-- 两台分别执行,应恢复为 0 / Yes / Yes
SHOW SLAVE STATUS\G   -- 查看 Seconds_Behind_Master、Slave_IO_Running、Slave_SQL_Running
# VIP 回到 mysql1
ip -o addr show dev eth0 | grep 172.25.16.40
# read_only 恢复为 mysql1=OFF、mysql2=ON

九、附录

9.1 关键参数

参数位置值说明
server-idmy.cnf两台不同复制拓扑标识
gtid_modemy.cnfON事务级定位,故障恢复的基础
expire_logs_daysmy.cnf7防止 binlog 占满磁盘
log_bin_trust_function_creatorsmy.cnf1两台一致,否则复制遇到 CREATE FUNCTION 会中断
read_onlymy.cnf1两台默认只读,见 5.2
weightkeepalived.conf-60绝对值需大于两台 priority 之差
preempt_delaykeepalived.conf300自动回切前的等待时间
MAX_LAGcheck_mysql.sh10延迟超过该值不允许顶替或抢回 VIP

9.2 常用排查命令

# VIP 当前所在节点
ip -o addr show dev eth0 | grep 172.25.16.40

# Keepalived 状态与选举日志
systemctl status keepalived
journalctl -u keepalived --no-pager | tail -30

# 切换历史(notify 脚本写入)
cat /var/log/keepalived-mysql.log

# 手工执行健康检查(0 表示健康)
/etc/keepalived/check_mysql.sh; echo "exit=$?"

# 复制状态
mysql --defaults-file=/etc/keepalived/.my.cnf -e "SHOW SLAVE STATUS\G" \
  | grep -E 'Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master|Last_Errno'

# binlog 现状
mysql -e "SHOW BINARY LOGS;"

9.3 常见问题

现象原因处理
Last_Errno: 1062 Duplicate entry双向复制两端执行了相同的变更定位冲突事务,按 GTID 注入空事务跳过,并确保后续变更只在单端执行
Last_Errno: 1418两台 log_bin_trust_function_creators 不一致两台都设为 1(my.cnf 与 SET GLOBAL),然后 START SLAVE
Keepalived 日志出现 unknown authentication type 'MD5'auth_type 填入了无效值改为 auth_type PASS,auth_pass 不超过 8 字符
备库重启后 read_only 为 OFF未配置 my.cnf 的 read_only,且 notify 未生效见 5.2
备份产物只有几 MB脚本未检查返回码,失败后仍继续执行见第六章的三重校验脚本

9.4 架构的适用范围与限制

VRRP 方案存在以下限制,需要在上线前明确:

  1. 网络分区:两台机器均存活但互相通信中断时,双方都会认为对方故障并尝试成为主节点,可能出现两个 VIP。VRRP 协议无法区分「对方故障」与「网络中断」。
    • 缓解方式:增加网关可达性检查,无法连通网关时降低优先级;更彻底的方式是使用 IPMI 做 fencing 或改用 MySQL Group Replication。
    • 对多数中小规模环境,网络分区比机器故障罕见,可以接受该残余风险,但需要写入运维手册。
  2. 两端同时写入:架构上限制单写,但并非协议强制。如果有人绕过 VIP 直连备库写入,仍会造成数据分叉。运维规范中需要明确:任何情况下只连接 VIP。
  3. VIP 切换存在秒级中断:VIP 漂移与应用重连期间业务有短暂中断。对中断时间敏感的场景需要应用层重试与连接池配合。

9.5 上线检查清单

  • 两台 MySQL 版本一致、server-id 不同
  • gtid_mode=ON、enforce_gtid_consistency=ON、expire_logs_days=7
  • 两台 log_bin_trust_function_creators=1
  • 两台 my.cnf 均配置 read_only=1
  • 双向复制两条链路 Slave_IO_Running、Slave_SQL_Running 均为 Yes,延迟为 0
  • 复制账号与 keepalive 账号已创建(只在一台创建)
  • keepalived.conf 两台均为 state BACKUP、priority 不同、weight -60、preempt_delay 按需配置
  • 三个 notify 脚本可执行,.my.cnf 权限为 600
  • 手工执行健康检查脚本返回 0
  • 全备脚本手动执行成功,产物大小正常
  • cron 使用 /bin/bash
  • 两台均确认 my_print_defaults mysqld 能读取到预期值
  • 完成一次切换演练

小结

这套架构的核心是两条约束:

  • 双向复制提供两台机器「都能作为主库」的能力
  • VIP 与 Keepalived 保证同一时刻只有一台接受写入

配置本身不复杂,需要留意的是几个不配置也不报错、但在故障时才暴露的环节:健康检查需要包含复制状态、read_only 需要双重保障、weight 需要大于优先级差值、回切需要延迟控制、DDL 需要单端执行。

评论