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 | 说明 |
|---|---|---|---|
| 主(读写) | mysql1 | 172.25.16.41 | 承载业务读写 |
| 备(只读) | mysql2 | 172.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_Running | Yes | 拉取主库 binlog 的线程运行中 |
Slave_SQL_Running | Yes | 回放事务的线程运行中 |
Seconds_Behind_Master | 0 | 无延迟 |
Last_Errno | 0 | 无错误 |
链路 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
脚本包含两个部分需要留意:
- 校验复制健康状态,而非仅判断进程存活(第 4 段)
- 按当前角色校准
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失败且不会重试
结果是备库重启后长期处于可写状态,任何直连备库的写入都会造成数据分叉。
处理方式:
- 两台
my.cnf都配置read_only = 1,保证开机默认只读。该状态的失败方向是安全的,即多只读、不会多写 - 健康检查脚本按角色校准(4.3 脚本第 3 段):持有 VIP 的设为 0,未持有的设为 1,每 3 秒执行一次
需要注意的副作用:配置之后,不经过 Keepalived 的手工重启会使该机先进入只读状态,需要手动执行 SET GLOBAL read_only=0; 恢复。
5.3 weight 的取值
Keepalived 的权重是加减关系,脚本失败时的实际优先级为 priority - weight。
| 配置 | 主库失败后 | 结果 |
|---|---|---|
weight -5 | 150 - 5 = 145 | 145 > 100,备库不会接管 |
weight -60 | 150 - 60 = 90 | 90 < 100,备库接管 |
weight 的绝对值必须大于两台 priority 的差值,否则健康检查不产生实际作用。示例中差值为 50,取 -60 留有余量。
5.4 自动回切的延迟控制
主库恢复后是否自动切回 VIP,取决于是否需要额外确认数据已追平。
两个机制配合:
preempt_delay 300:主库恢复后延迟 300 秒再抢占,这段时间用于追平故障期间落后的事务- 健康检查中的延迟限制:主库在未持有 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/
存在三个问题:
- 本地需要额外空间,全量数据与压缩包同时存在
- 主库本地磁盘可能已接近上限,写入失败会导致备份中断
- 未检查返回码时,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
两处顺序细节:
cp之后需要再执行一次chown。如果先用 root 执行chown -R、之后又用 rootcp放回auto.cnf,该文件属主为root:root,mysqld 以 mysql 用户无法读取,启动失败并报Errcode: 13 - Permission denied。- 恢复前确认
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 切换到 mysql2 | ip 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-id | my.cnf | 两台不同 | 复制拓扑标识 |
gtid_mode | my.cnf | ON | 事务级定位,故障恢复的基础 |
expire_logs_days | my.cnf | 7 | 防止 binlog 占满磁盘 |
log_bin_trust_function_creators | my.cnf | 1 | 两台一致,否则复制遇到 CREATE FUNCTION 会中断 |
read_only | my.cnf | 1 | 两台默认只读,见 5.2 |
weight | keepalived.conf | -60 | 绝对值需大于两台 priority 之差 |
preempt_delay | keepalived.conf | 300 | 自动回切前的等待时间 |
MAX_LAG | check_mysql.sh | 10 | 延迟超过该值不允许顶替或抢回 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 方案存在以下限制,需要在上线前明确:
- 网络分区:两台机器均存活但互相通信中断时,双方都会认为对方故障并尝试成为主节点,可能出现两个 VIP。VRRP 协议无法区分「对方故障」与「网络中断」。
- 缓解方式:增加网关可达性检查,无法连通网关时降低优先级;更彻底的方式是使用 IPMI 做 fencing 或改用 MySQL Group Replication。
- 对多数中小规模环境,网络分区比机器故障罕见,可以接受该残余风险,但需要写入运维手册。
- 两端同时写入:架构上限制单写,但并非协议强制。如果有人绕过 VIP 直连备库写入,仍会造成数据分叉。运维规范中需要明确:任何情况下只连接 VIP。
- 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 需要单端执行。