游戏服务器灾备架构:从数据备份到高可用集群的完整方案 原创
游戏服务器灾备架构:从数据备份到高可用集群的完整方案
凌晨三点,你的游戏服务器宕机了。3000 名在线玩家的世界数据正在丢失,公会战排行表损坏,充值订单卡在中间状态。你打开终端,发现最近一次备份是 23 小时前——这意味着一旦恢复,所有玩家将回滚一整天的进度。
这不是假设。这是无数游戏运维团队真实经历过的灾难。本文将从数据备份到高可用集群,给你一套能在生产环境直接落地的完整灾备方案。
一、灾备体系三层模型
游戏服务器灾备不是”做个备份”就完事,它是一个分层防御体系。核心思路:单点故障不丢数据,局部故障不中断服务,整体故障快速恢复。
1.1 三层架构图
┌─────────────────────────────────────────────────────────────┐ │ 游戏服务器灾备三层模型 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌─── 第三层:故障切换 ───────────────────────────────────┐ │ │ │ Keepalived VIP 漂移 + 自动健康检查 + 服务自动重启 │ │ │ │ 目标:RTO < 30s(玩家几乎无感知) │ │ │ └──────────────────────────────────────────────────────┘ │ │ ▲ 兜底 │ │ ┌─── 第二层:服务冗余 ───────────────────────────────────┐ │ │ │ Nginx LB → 多 WorldServer 实例 → 共享 MySQL/Redis │ │ │ │ 目标:单节点故障不影响在线玩家 │ │ │ └──────────────────────────────────────────────────────┘ │ │ ▲ 前提 │ │ ┌─── 第一层:数据备份 ───────────────────────────────────┐ │ │ │ XtraBackup 全量+增量 → 远程异地同步 → 定期恢复验证 │ │ │ │ 目标:RPO < 5min(最多丢 5 分钟数据) │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘
1.2 各层职责与目标
| 层级 | 核心职责 | RTO 目标 | RPO 目标 | 技术手段 |
|---|---|---|---|---|
| 数据备份 | 确保数据不丢 | 1-4h | < 5min | XtraBackup + binlog + rsync |
| 服务冗余 | 单点不中断 | < 30s | 0 | Nginx + 多实例 + 共享存储 |
| 故障切换 | 整体快速恢复 | < 30s | 0 | Keepalived + VIP + 健康检查 |
三层之间是递进关系:没有第一层,二三层的数据可能是脏的;没有第二层,故障切换只能恢复单点而非整体;没有第三层,单点故障靠人工介入恢复时间不可控。
二、数据库备份策略
数据库是游戏服务器的命脉——玩家账号、角色数据、充值记录、公会信息全在里面。备份策略的选择直接决定灾难恢复的成败。
2.1 mysqldump vs Percona XtraBackup
| 维度 | mysqldump | Percona XtraBackup |
|---|---|---|
| 备份方式 | 逻辑备份(SQL 语句) | 物理备份(复制文件) |
| 锁表影响 | InnoDB 不锁但大表慢 | 热备份,不锁表 |
| 恢复速度 | 慢(逐条执行 SQL) | 快(复制文件 + recover) |
| 增量备份 | 不支持 | 支持 |
| 压缩率 | 中(文本压缩) | 高(物理压缩) |
| 适用场景 | 小库(<10GB)或跨版本迁移 | 大库(>10GB)生产环境 |
游戏数据库通常 50GB 起步,部分大型 MMO 超过 500GB。生产环境一律用 XtraBackup,mysqldump 仅用于小表导出或开发环境。
2.2 XtraBackup 全量 + 增量备份方案
备份策略:每周日全量,每天增量,每小时 binlog。
备份目录结构: /backup/mysql/ ├── full/ # 全量备份 │ └── 2026-09-11_000000/ ├── incremental/ # 增量备份 │ ├── 2026-09-11_060000/ │ └── 2026-09-11_120000/ ├── binlog/ # binlog 归档 │ └── mysql-bin.001234 └── remote/ # 异地同步缓冲
全量备份脚本
#!/bin/bash
# full_backup.sh - MySQL 全量热备份
# 使用 Percona XtraBackup,不锁表
BACKUP_DIR="/backup/mysql/full"
DATE=$(date +%Y-%m-%d_%H%M%S)
BACKUP_PATH="${BACKUP_DIR}/${DATE}"
RETENTION_DAYS=7
# MySQL 连接配置
INNOBACKUP="/usr/bin/xtrabackup"
MYSQL_USER="backup_user"
MYSQL_PASSWORD="/etc/mysql/.backup.cnf" # 用配置文件,不暴露密码
mkdir -p "${BACKUP_DIR}"
# 执行全量备份(热备份,不锁表)
${INNOBACKUPEX} --backup \
--defaults-file=/etc/mysql/my.cnf \
--read-file-from-stdin << 'PASS'
--user=${MYSQL_USER} \
--password-file=${MYSQL_PASSWORD} \
--target-dir="${BACKUP_PATH}" \
--parallel=4 \
--compress \
--compress-threads=4
# 验证备份完整性
if [ $? -eq 0 ]; then
echo "[$(date)] 全量备份成功: ${BACKUP_PATH}" >> /var/log/mysql-backup.log
# 清理超过保留期的旧备份
find ${BACKUP_DIR} -maxdepth 1 -type d -mtime +${RETENTION_DAYS} \
-exec rm -rf {} \;
# 触发远程同步
/usr/local/bin/sync_backup_remote.sh "${BACKUP_PATH}"
else
echo "[$(date)] 全量备份失败!" >> /var/log/mysql-backup.log
# 发送告警
curl -s -X POST "https://alert.example.com/api/notify" \
-d "title=MySQL全量备份失败&message=$(hostname) $(date)"
exit 1
fi
增量备份脚本
#!/bin/bash
# incremental_backup.sh - MySQL 增量备份
# 基于最近一次全量备份
BACKUP_DIR="/backup/mysql/incremental"
FULL_DIR="/backup/mysql/full"
DATE=$(date +%Y-%m-%d_%H%M%S)
BACKUP_PATH="${BACKUP_DIR}/${DATE}"
RETENTION_DAYS=7
# 获取最近一次全量备份目录
LATEST_FULL=$(ls -dt ${FULL_DIR}/*/ 2>/dev/null | head -1)
if [ -z "${LATEST_FULL}" ]; then
echo "[$(date)] 无可用全量备份,跳过增量" >> /var/log/mysql-backup.log
exit 1
fi
# 执行增量备份
xtrabackup --backup \
--defaults-file=/etc/mysql/my.cnf \
--user=backup_user \
--password-file=/etc/mysql/.backup.cnf \
--target-dir="${BACKUP_PATH}" \
--incremental-basedir="${LATEST_FULL}" \
--parallel=4
if [ $? -eq 0 ]; then
echo "[$(date)] 增量备份成功: ${BACKUP_PATH} (基于 ${LATEST_FULL})" \
>> /var/log/mysql-backup.log
# 清理旧增量备份
find ${BACKUP_DIR} -maxdepth 1 -type d -mtime +${RETENTION_DAYS} \
-exec rm -rf {} \;
else
echo "[$(date)] 增量备份失败!" >> /var/log/mysql-backup.log
exit 1
fi
Crontab 定时任务
# MySQL 备份计划任务 # 每周日 00:00 全量备份 0 0 * * 0 /usr/local/bin/full_backup.sh # 每天 06:00/12:00/18:00 增量备份 0 6,12,18 * * * /usr/local/bin/incremental_backup.sh # 每小时 binlog 归档 0 * * * * /usr/local/bin/binlog_archive.sh
2.3 远程异地同步
备份只存在本地等于没备份——磁盘损坏时备份和数据库一起完蛋。必须异地同步。
#!/bin/bash
# sync_backup_remote.sh - 备份文件异地同步
# 使用 rsync over SSH,支持断点续传
BACKUP_PATH=$1
REMOTE_HOST="backup@remote-dc.example.com"
REMOTE_PATH="/data/backup/mysql/"
if [ -z "${BACKUP_PATH}" ]; then
echo "Usage: $0 "
exit 1
fi
# rsync 同步,限速 50MB/s 避免占满带宽
rsync -avz --progress --partial \
--bwlimit=51200 \
-e "ssh -i /home/backup/.ssh/id_rsa -p 2222" \
"${BACKUP_PATH}" \
"${REMOTE_HOST}:${REMOTE_PATH}"
if [ $? -eq 0 ]; then
echo "[$(date)] 远程同步成功: ${BACKUP_PATH}" >> /var/log/mysql-backup.log
else
echo "[$(date)] 远程同步失败!" >> /var/log/mysql-backup.log
curl -s -X POST "https://alert.example.com/api/notify" \
-d "title=备份远程同步失败&message=${BACKUP_PATH}"
exit 1
fi
2.4 灾难恢复:从 XtraBackup 还原
# 1. 准备备份(应用日志,使备份一致)
xtrabackup --prepare --target-dir=/backup/mysql/full/2026-09-11_000000/
# 2. 如果有增量,依次 prepare
xtrabackup --prepare --apply-log-only \
--target-dir=/backup/mysql/full/2026-09-11_000000/ \
--incremental-dir=/backup/mysql/incremental/2026-09-11_060000/
xtrabackup --prepare --apply-log-only \
--target-dir=/backup/mysql/full/2026-09-11_000000/ \
--incremental-dir=/backup/mysql/incremental/2026-09-11_120000/
# 3. 最终 prepare
xtrabackup --prepare --target-dir=/backup/mysql/full/2026-09-11_000000/
# 4. 停 MySQL,拷回数据
systemctl stop mysqld
rm -rf /var/lib/mysql/*
xtrabackup --copy-back --target-dir=/backup/mysql/full/2026-09-11_000000/
chown -R mysql:mysql /var/lib/mysql
systemctl start mysqld
# 5. 应用 binlog 补齐到故障点
mysqlbinlog /backup/mysql/binlog/mysql-bin.001234 | mysql -u root -p
三、文件级备份:配置、世界数据、用户上传
数据库只是游戏数据的一部分。服务器配置文件、世界生成数据、玩家上传的头像/截图同样需要备份。
3.1 需要备份的文件清单
| 类型 | 路径示例 | 备份频率 | 说明 |
|---|---|---|---|
| 服务器配置 | /opt/game-server/config/ | 每次变更 | Git 版本控制 + 自动快照 |
| 世界数据 | /opt/game-server/world/ | 每 6 小时 | 地图、区块、实体数据 |
| 玩家上传 | /opt/game-server/uploads/ | 每天 | 头像、截图、自定义皮肤 |
| 日志文件 | /var/log/game-server/ | 每天压缩 | 保留 30 天用于审计 |
| 插件/Mod | /opt/game-server/plugins/ | 每次更新 | 版本号 + 校验和 |
3.2 文件级定时备份脚本
#!/bin/bash
# file_backup.sh - 游戏服务器文件级备份
# 支持:配置文件、世界数据、用户上传、日志归档
set -euo pipefail
GAME_ROOT="/opt/game-server"
BACKUP_ROOT="/backup/files"
DATE=$(date +%Y-%m-%d_%H%M%S)
BACKUP_DIR="${BACKUP_ROOT}/${DATE}"
RETENTION_DAYS=14
# 定义备份目标
declare -A BACKUP_TARGETS=(
["config"]="${GAME_ROOT}/config"
["world"]="${GAME_ROOT}/world"
["uploads"]="${GAME_ROOT}/uploads"
["plugins"]="${GAME_ROOT}/plugins"
)
# 日志单独处理(先压缩再备份)
LOG_DIR="/var/log/game-server"
LOG_ARCHIVE="${BACKUP_DIR}/logs.tar.gz"
mkdir -p "${BACKUP_DIR}"
# 备份各目录
for name in "${!BACKUP_TARGETS[@]}"; do
src="${BACKUP_TARGETS[$name]}"
dst="${BACKUP_DIR}/${name}.tar.gz"
if [ -d "${src}" ]; then
# 使用 tar 压缩,排除临时文件
tar czf "${dst}" \
--exclude='*.tmp' \
--exclude='*.lock' \
--exclude='cache' \
-C "$(dirname ${src})" "$(basename ${src})"
# 生成校验和
md5sum "${dst}" > "${dst}.md5"
echo "[$(date)] 备份 ${name}: ${dst} ($(du -h ${dst} | cut -f1))"
else
echo "[$(date)] 跳过 ${name}: 目录不存在 ${src}"
fi
done
# 日志压缩归档
if [ -d "${LOG_DIR}" ]; then
tar czf "${LOG_ARCHIVE}" -C "$(dirname ${LOG_DIR})" "$(basename ${LOG_DIR})"
md5sum "${LOG_ARCHIVE}" > "${LOG_ARCHIVE}.md5"
fi
# 异地同步
rsync -az --partial --bwlimit=25600 \
-e "ssh -i /home/backup/.ssh/id_rsa" \
"${BACKUP_DIR}" \
backup@remote-dc.example.com:/data/backup/files/
# 清理旧备份
find ${BACKUP_ROOT} -maxdepth 1 -type d -mtime +${RETENTION_DAYS} \
-exec rm -rf {} \;
echo "[$(date)] 文件备份完成: ${BACKUP_DIR}"
Crontab 配置
# 世界数据每 6 小时备份
0 */6 * * * /usr/local/bin/file_backup.sh
# 配置变更时触发(用 inotifywait 监听)
# 需要安装 inotify-tools
inotifywait -m -e modify,create,delete \
/opt/game-server/config/ |
while read path event file; do
/usr/local/bin/file_backup.sh config_only
done
四、服务冗余:Nginx 负载均衡 + 多 WorldServer 实例
单实例运行的游戏服务器,一个 OOM 或段错误就让全员掉线。多实例冗余是消除单点的核心手段。
4.1 架构图
┌─────────────────┐
│ DNS / VIP │
│ game.azcore.top │
└────────┬────────┘
│
┌────────┴────────┐
│ Nginx (LB) │
│ 负载均衡层 │
│ 192.168.1.10 │
└────────┬────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌────────┴───────┐ ┌───────┴────────┐ ┌───────┴────────┐
│ WorldServer-1 │ │ WorldServer-2 │ │ WorldServer-3 │
│ 192.168.1.21 │ │ 192.168.1.22 │ │ 192.168.1.23 │
│ 端口 8081 │ │ 端口 8082 │ │ 端口 8083 │
└────────┬───────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌────────────┴────────────┐
│ │
┌────────┴────────┐ ┌──────────┴──────┐
│ MySQL Primary │ │ Redis Cluster │
│ 192.168.1.30 │ │ 192.168.1.40 │
│ + Replica │ │ 3 节点哨兵模式 │
└─────────────────┘ └─────────────────┘
4.2 Nginx 负载均衡配置
游戏服务器的连接特点是长连接、低延迟、有状态,负载均衡策略需要特别处理。
# /etc/nginx/conf.d/game-lb.conf
# 游戏服务器负载均衡配置
# WorldServer 后端池
upstream game_worldservers {
# 最少连接数策略(比轮询更适合游戏长连接)
least_conn;
# 会话保持:同一玩家路由到同一服务器
# 游戏服务器有状态,必须 sticky
hash $arg_player_id consistent;
server 192.168.1.21:8081 max_fails=3 fail_timeout=10s;
server 192.168.1.22:8082 max_fails=3 fail_timeout=10s;
server 192.168.1.23:8083 max_fails=3 fail_timeout=10s;
# 备用服务器(当全部主用挂了时启用)
server 192.168.1.24:8084 backup;
keepalive 1024; # 保持长连接
}
# WebSocket / TCP 游戏连接
server {
listen 8080;
proxy_protocol on;
location / {
proxy_pass http://game_worldservers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Server-ID $server_addr;
# 超时设置(游戏长连接需要长超时)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 连接失败时自动切换
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 3;
}
}
# HTTP API(充值、排行榜、公告等无状态接口)
server {
listen 443 ssl http2;
ssl_certificate /etc/ssl/game.azcore.top.crt;
ssl_certificate_key /etc/ssl/game.azcore.top.key;
location /api/ {
proxy_pass http://game_worldservers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
4.3 多 WorldServer 实例部署
用 systemd 管理多实例,每个实例独立配置、独立目录、独立端口。
# /etc/systemd/system/worldserver@.service
# 模板化 systemd 单元,通过 @1 @2 @3 启动不同实例
[Unit]
Description=Game WorldServer Instance %i
After=network.target mysql.service redis.service
[Service]
Type=simple
User=game
Group=game
# 每个实例的独立配置
EnvironmentFile=/opt/game-server/config/instance-%i.env
WorkingDirectory=/opt/game-server
ExecStart=/opt/game-server/bin/worldserver \
--config /opt/game-server/config/world-%i.conf \
--port ${WORLD_PORT} \
--instance-id ${INSTANCE_ID}
# 重启策略:崩溃自动重启,5 秒间隔,最多 10 次
Restart=always
RestartSec=5
StartLimitBurst=10
StartLimitIntervalSec=60
# 资源限制
LimitNOFILE=65536
MemoryMax=4G
# 日志
StandardOutput=append:/var/log/game-server/world-%i.log
StandardError=append:/var/log/game-server/world-%i.error.log
[Install]
WantedBy=multi-user.target
# /opt/game-server/config/instance-1.env INSTANCE_ID=1 WORLD_PORT=8081 # /opt/game-server/config/instance-2.env INSTANCE_ID=2 WORLD_PORT=8082 # /opt/game-server/config/instance-3.env INSTANCE_ID=3 WORLD_PORT=8083
启动与管理命令
# 启动三个实例 systemctl start worldserver@1 worldserver@2 worldserver@3 # 查看状态 systemctl status worldserver@1 worldserver@2 worldserver@3 # 平滑重启(先 drain 连接再重启) systemctl reload worldserver@1 # 扩容:直接启动第 4 个实例 systemctl start worldserver@4 # 然后在 Nginx 配置中添加 server 192.168.1.24:8084 并 reload
4.4 共享数据库读写分离
多 WorldServer 共享一个数据库,写入压力集中。用 MySQL 主从复制 + 读写分离解决。
# ProxySQL 读写分离配置(核心部分) # 写操作走 Primary,读操作走 Replica -- 配置后端 MySQL 节点 INSERT INTO mysql_servers(hostgroup_id,hostname,port) VALUES (10,'192.168.1.30',3306), -- HG10: 写(Primary) (20,'192.168.1.31',3306); -- HG20: 读(Replica) -- 配置路由规则 -- 写操作路由到 Primary INSERT INTO mysql_query_rules(rule_id,active,match_digest,destination_hostgroup,apply) VALUES (1,1,'^INSERT.*',10,1), (2,1,'^UPDATE.*',10,1), (3,1,'^DELETE.*',10,1), (4,1,'^CREATE.*',10,1), (5,1,'^ALTER.*',10,1), -- 读操作路由到 Replica (6,1,'^SELECT.*',20,1); -- 配置后端健康检查 UPDATE mysql_servers SET status='ONLINE'; LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
五、Redis 缓存层:热点数据与 Session 共享
游戏服务器有大量热点数据:在线玩家列表、排行榜、公会信息、匹配队列。全查数据库扛不住,Redis 是标配。
5.1 Redis 在游戏架构中的角色
┌───────────────────────────────────────────────────┐
│ Redis 缓存层架构 │
├───────────────────────────────────────────────────┤
│ │
│ WorldServer-1 ──┐ ┌── Redis Master │
│ │ │ 192.168.1.40 │
│ WorldServer-2 ──┼──→ Sentinel ──→ Redis Replica │
│ │ │ 192.168.1.41 │
│ WorldServer-3 ──┘ └── Redis Replica │
│ 192.168.1.42 │
│ │
│ 缓存内容: │
│ ├── player:online → 在线玩家 Hash(TTL 30s) │
│ ├── ranking:score → Sorted Set 排行榜 │
│ ├── guild:{id}:info → 公会信息 Hash(TTL 5min) │
│ ├── session:{token} → 玩家会话(TTL 1h) │
│ └── match:queue → 匹配队列 List │
│ │
└───────────────────────────────────────────────────┘
5.2 Redis 哨兵模式配置
# /etc/redis/redis.conf (Master - 192.168.1.40) bind 192.168.1.40 port 6379 daemonize yes # 持久化:AOF + RDB 双保险 save 900 1 save 300 10 save 60 10000 appendonly yes appendfsync everysec # 内存策略:游戏数据不能随便淘汰 maxmemory 8gb maxmemory-policy allkeys-lru # 主从认证 requirepass GameRedis2026!Secure masterauth GameRedis2026!Secure
# /etc/redis/sentinel.conf port 26379 daemonize yes # 监控 Master:2 个哨兵同意才切换(共 3 个哨兵) sentinel monitor game-master 192.168.1.40 6379 2 sentinel auth-pass game-master GameRedis2026!Secure # 主观下线判定(5 秒无响应) sentinel down-after-milliseconds game-master 5000 # 故障转移超时 sentinel failover-timeout game-master 30000 # 同时只有一个 Replica 升级为 Master sentinel parallel-syncs game-master 1
5.3 WorldServer 连接 Redis 的代码模式
// 游戏服务器 Redis 连接初始化(Lua 示例)
// 适用于使用 Lua 脚本的游戏后端
local redis = require "resty.redis"
local red = redis:new()
-- 哨兵模式连接:自动发现 Master
red:set_timeouts(1000, 1000, 1000) -- 连接/发送/读取超时
local function get_master_from_sentinel()
local sentinel = redis:new()
local ok, err = sentinel:connect("192.168.1.40", 26379)
if not ok then return nil, err end
-- 查询当前 Master 地址
local res, err = sentinel:command("SENTINEL", "get-master-addr-by-name", "game-master")
if not res then return nil, err end
return res[1], tonumber(res[2]) -- host, port
end
-- 获取 Master 并连接
local host, port = get_master_from_sentinel()
local ok, err = red:connect(host, port)
red:auth("GameRedis2026!Secure")
-- 缓存玩家在线状态
function set_player_online(player_id, server_id)
local key = "player:online:" .. player_id
red:hmset(key, {
server = server_id,
ts = os.time(),
status = "active"
})
red:expire(key, 30) -- 30 秒无心跳则自动下线
-- 同时加入在线列表
red:sadd("player:online:set", player_id)
end
-- 获取排行榜(Sorted Set)
function get_ranking(leaderboard, start, count)
-- ZREVRANGE 返回按分数降序排列
return red:zrevrange(leaderboard, start, start + count - 1, "WITHSCORES")
end
-- 会话共享:跨服登录令牌
function set_session(token, player_id)
red:setex("session:" .. token, 3600, player_id) -- 1 小时过期
end
function get_player_by_session(token)
return red:get("session:" .. token)
end
六、故障切换:Keepalived + VIP 漂移
多实例解决了 WorldServer 的单点问题,但 Nginx 本身还是单点。Keepalived 通过 VIP 漂移让两个 Nginx 互为主备。
6.1 架构图
客户端
│
VIP: 192.168.1.100
│
┌────────────┴────────────┐
│ Keepalived VIP │
│ (只在一个节点激活) │
└────────────┬────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────┴─────┐ ┌────┴─────┐ ┌────┴─────┐
│ Nginx-1 │ │ Nginx-2 │ │ Nginx-3 │
│ MASTER │ │ BACKUP │ │ BACKUP │
│ .10 │ │ .11 │ │ .12 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼───────────────┘
│
后端 WorldServer 集群
6.2 Keepalived 配置
主节点(MASTER)
# /etc/keepalived/keepalived.conf (Nginx-1 - MASTER)
global_defs {
router_id NGINX_LB_01
enable_script_security
}
# 健康检查脚本:检测 Nginx 是否存活
vrrp_script check_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 2 # 每 2 秒检查一次
weight -20 # 检查失败则优先级降 20
fall 3 # 连续 3 次失败才判定为 down
rise 2 # 连续 2 次成功才恢复
}
# VIP 虚拟路由
vrrp_instance VI_GAME_LB {
state MASTER
interface eth0
virtual_router_id 51
priority 100 # 主节点优先级 100
# 认证(同一 VRRP 组必须一致)
authentication {
auth_type PASS
auth_pass Game@2026
}
# VIP 地址
virtual_ipaddress {
192.168.1.100/24 dev eth0
}
# 绑定健康检查
track_script {
check_nginx
}
# 状态变更通知脚本
notify_master "/etc/keepalived/notify.sh master"
notify_backup "/etc/keepalived/notify.sh backup"
notify_fault "/etc/keepalived/notify.sh fault"
}
备用节点(BACKUP)
# /etc/keepalived/keepalived.conf (Nginx-2 - BACKUP)
global_defs {
router_id NGINX_LB_02
enable_script_security
}
vrrp_script check_nginx {
script "/etc/keepalived/check_nginx.sh"
interval 2
weight -20
fall 3
rise 2
}
vrrp_instance VI_GAME_LB {
state BACKUP
interface eth0
virtual_router_id 51 # 必须和 MASTER 一致
priority 90 # 低于 MASTER
authentication {
auth_type PASS
auth_pass Game@2026
}
virtual_ipaddress {
192.168.1.100/24 dev eth0
}
track_script {
check_nginx
}
notify_master "/etc/keepalived/notify.sh master"
notify_backup "/etc/keepalived/notify.sh backup"
notify_fault "/etc/keepalived/notify.sh fault"
}
健康检查脚本
#!/bin/bash
# /etc/keepalived/check_nginx.sh
# 检测 Nginx 是否正常工作
NGINX_PID=$(pgrep -x nginx | head -1)
NGINX_PORT=$(ss -tlnp | grep ':8080 ' | grep nginx | wc -l)
if [ -n "${NGINX_PID}" ] && [ "${NGINX_PORT}" -gt 0 ]; then
exit 0 # Nginx 存活且端口在监听
else
# 尝试自动重启 Nginx
systemctl restart nginx
sleep 2
# 重启后再检查
NGINX_PID=$(pgrep -x nginx | head -1)
if [ -n "${NGINX_PID}" ]; then
exit 0
else
exit 1 # 重启失败,触发 VIP 漂移
fi
fi
状态变更通知脚本
#!/bin/bash
# /etc/keepalived/notify.sh
# VIP 漂移时发送告警
STATE=$1
HOSTNAME=$(hostname)
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
case "${STATE}" in
master)
MSG="⚠️ VIP 漂移到 ${HOSTNAME},当前节点已接管流量"
;;
backup)
MSG="ℹ️ ${HOSTNAME} 降级为 BACKUP,VIP 已漂移"
;;
fault)
MSG="🔴 ${HOSTNAME} 发生故障,VIP 已漂移到其他节点"
;;
esac
# 发送告警通知
curl -s -X POST "https://alert.example.com/api/notify" \
-d "title=Keepalived状态变更&message=${TIMESTAMP} ${MSG}"
# 记录日志
echo "[${TIMESTAMP}] ${MSG}" >> /var/log/keepalived-notify.log
6.3 自动恢复流程
故障检测与恢复时序: T+0s Nginx-1 崩溃 T+2s check_nginx.sh 检测失败,尝试重启 Nginx T+4s 重启失败,Keepalived 标记为 down T+6s priority 从 100 降到 80(低于 BACKUP 的 90) T+8s Nginx-2 上的 Keepalived 抢占 VIP T+8s VIP 漂移到 192.168.1.11(Nginx-2) T+8s notify.sh 发送告警通知 T+8s 玩家连接自动重连到新 Nginx 总恢复时间:约 8 秒(RTO < 10s) 数据丢失:0(RPO = 0,因为后端 WorldServer 不受影响)
七、监控告警:Prometheus + Grafana + AlertManager
灾备不是出了事再处理,而是出事前就能预警。监控是整个灾备体系的眼睛。
7.1 监控架构
┌──────────────────────────────────────────────────────┐ │ 监控告警架构 │ ├──────────────────────────────────────────────────────┤ │ │ │ ┌─ Exporters ────────┐ ┌─ Prometheus ──────────┐ │ │ │ │ │ │ │ │ │ node_exporter │──→│ TSDB 时序数据库 │ │ │ │ mysqld_exporter │ │ 15s 采集间隔 │ │ │ │ redis_exporter │ │ 保留 90 天 │ │ │ │ nginx_exporter │ │ │ │ │ │ game_exporter │ └───────┬───────────────┘ │ │ │ (自定义游戏指标) │ │ │ │ └────────────────────┘ │ │ │ ┌────────┴────────┐ │ │ │ Grafana │ │ │ │ 可视化面板 │ │ │ └────────┬────────┘ │ │ │ │ │ ┌────────┴────────┐ │ │ │ AlertManager │ │ │ │ 告警路由 │ │ │ └────┬──────┬──────┘ │ │ │ │ │ │ ┌────┴┐ ┌───┴────┐ │ │ │钉钉 │ │Webhook │ │ │ │机器人│ │回调 │ │ │ └─────┘ └────────┘ │ │ │ └──────────────────────────────────────────────────────┘
7.2 Prometheus 核心配置
# /etc/prometheus/prometheus.yml
global:
scrape_interval: 15s # 15 秒采集一次
evaluation_interval: 15s # 15 秒评估一次告警规则
scrape_timeout: 10s
# 告警规则文件
rule_files:
- /etc/prometheus/rules/*.yml
# 告警管理器
alerting:
alertmanagers:
- static_configs:
- targets: ['localhost:9093']
# 采集目标
scrape_configs:
# Nginx 负载均衡
- job_name: 'nginx'
static_configs:
- targets:
- '192.168.1.10:9113'
- '192.168.1.11:9113'
# MySQL
- job_name: 'mysql'
static_configs:
- targets:
- '192.168.1.30:9104' # Primary
- '192.168.1.31:9104' # Replica
# Redis
- job_name: 'redis'
static_configs:
- targets:
- '192.168.1.40:9121'
- '192.168.1.41:9121'
- '192.168.1.42:9121'
# WorldServer 实例
- job_name: 'worldserver'
static_configs:
- targets:
- '192.168.1.21:9091'
- '192.168.1.22:9091'
- '192.168.1.23:9091'
metrics_path: /metrics
# 节点系统指标
- job_name: 'node'
static_configs:
- targets:
- '192.168.1.10:9100'
- '192.168.1.11:9100'
- '192.168.1.21:9100'
- '192.168.1.22:9100'
- '192.168.1.23:9100'
- '192.168.1.30:9100'
- '192.168.1.40:9100'
7.3 告警规则
# /etc/prometheus/rules/game-alerts.yml
groups:
- name: game_server_alerts
rules:
# WorldServer 实例离线
- alert: WorldServerDown
expr: up{job="worldserver"} == 0
for: 30s
labels:
severity: critical
team: game-ops
annotations:
summary: "WorldServer {{ $labels.instance }} 离线"
description: "WorldServer 已离线超过 30 秒,可能需要手动介入"
# 在线玩家数突降(可能掉线潮)
- alert: PlayerCountDrop
expr: |
game_online_players / game_online_players offset 5m < 0.8
for: 1m
labels:
severity: warning
annotations:
summary: "在线玩家数 5 分钟内下降超过 20%"
description: "当前 {{ $value }},可能存在大规模掉线"
# MySQL 主从延迟
- alert: MySQLReplicationLag
expr: mysql_slave_status_seconds_behind_master > 60
for: 2m
labels:
severity: warning
annotations:
summary: "MySQL 从库延迟 {{ $value }} 秒"
description: "主从复制延迟超过 1 分钟,读写分离可能返回旧数据"
# Redis 内存使用率
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "Redis 内存使用率超过 85%"
description: "{{ $labels.instance }} 内存即将打满"
# 磁盘空间不足
- alert: DiskSpaceLow
expr: |
100 - (node_filesystem_avail_bytes / node_filesystem_size_bytes * 100) > 85
for: 5m
labels:
severity: warning
annotations:
summary: "磁盘空间不足"
description: "{{ $labels.instance }} {{ $labels.mountpoint }} 使用率超过 85%"
# Nginx 活跃连接数过高
- alert: NginxHighConnections
expr: nginx_connections_active > 10000
for: 2m
labels:
severity: warning
annotations:
summary: "Nginx 活跃连接数过高"
description: "当前 {{ $value }} 活跃连接,可能需要扩容"
7.4 AlertManager 路由配置
# /etc/alertmanager/alertmanager.yml
global:
resolve_timeout: 5m
# 告警分组
route:
group_by: ['alertname', 'severity']
group_wait: 10s # 首次告警等待时间
group_interval: 30s # 同组告警间隔
repeat_interval: 4h # 重复告警间隔
receiver: 'default'
receivers:
# 默认:发送到钉钉
- name: 'default'
webhook_configs:
- url: 'https://oapi.dingtalk.comrobot/send?access_token=xxx'
send_resolved: true
# 严重告警:电话 + 钉钉
- name: 'critical'
webhook_configs:
- url: 'https://oapi.dingtalk.com/robot/send?access_token=xxx'
send_resolved: true
# 可补充短信/电话通知渠道
# 路由规则
route:
routes:
- matchers: ['severity="critical"']
receiver: 'critical'
- matchers: ['team="game-ops"']
receiver: 'default'
# 静默规则(维护期间屏蔽)
inhibit_rules:
- source_matchers: ['alertname="WorldServerDown"']
target_matchers: ['alertname="PlayerCountDrop"']
equal: ['instance']
八、灾难恢复演练
灾备方案不演练等于没有。真正灾难来临时,你需要的是肌肉记忆而不是翻文档。
8.1 RTO/RPO 目标定义
| 故障级别 | 定义 | RTO 目标 | RPO 目标 | 恢复手段 |
|---|---|---|---|---|
| L1 单实例 | 单个 WorldServer 崩溃 | < 30s | 0 | Nginx 自动剔除 + systemd 自动重启 |
| L2 单节点 | Nginx 节点故障 | < 10s | 0 | Keepalived VIP 漂移 |
| L3 数据库 | MySQL 主库故障 | < 5min | < 1min | 从库提升为主库 + binlog 补偿 |
| L4 整机房 | 机房断电/网络中断 | < 30min | < 5min | 异地备份恢复 + 新机房启动 |
| L5 数据损坏 | 误操作/恶意删除 | < 4h | 回滚到损坏前 | XtraBackup 恢复 + binlog 回放到指定位置 |
8.2 演练计划
┌──────────────────────────────────────────────────────┐ │ 季度灾难恢复演练计划 │ ├──────────┬──────────┬───────────────────────────────┤ │ 月份 │ 演练类型 │ 内容 │ ├──────────┼──────────┼───────────────────────────────┤ │ Q1-M1 │ L1 单实例 │ kill -9 WorldServer 进程 │ │ Q1-M2 │ L2 LB故障 │ 停掉 Master Nginx │ │ Q1-M3 │ L3 DB切换 │ 停 MySQL Primary 观察从库提升 │ │ Q2-M1 │ L5 数据恢复 │ 删除测试表,从备份恢复 │ │ Q2-M2 │ 全链路 │ 同时断 Nginx+WorldServer │ │ Q2-M3 │ 异地恢复 │ 在远程机房从零恢复全部服务 │ └──────────┴──────────┴───────────────────────────────┘
8.3 L1 故障模拟:WorldServer 崩溃
#!/bin/bash
# drill_l1_worldserver_crash.sh
# 演练目标:验证单实例崩溃后 30 秒内恢复
set -euo pipefail
TARGET_INSTANCE=${1:-1}
TARGET_IP="192.168.1.2${TARGET_INSTANCE}"
TARGET_PORT="808${TARGET_INSTANCE}"
echo "========================================"
echo "L1 演练:WorldServer-${TARGET_INSTANCE} 崩溃测试"
echo "开始时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"
# 记录当前在线玩家数
BEFORE_PLAYERS=$(curl -s "http://${TARGET_IP}:${TARGET_PORT}/api/online_count" | jq -r '.count')
echo "演练前在线玩家: ${BEFORE_PLAYERS}"
# 记录开始时间
START_TIME=$(date +%s)
# === 模拟故障:kill 进程 ===
echo "[1] 终止 WorldServer-${TARGET_INSTANCE}..."
ssh root@${TARGET_IP} "pkill -9 -f 'worldserver.*instance-${TARGET_INSTANCE}'"
# 等待 Nginx 健康检查触发
echo "[2] 等待 Nginx 健康检查..."
sleep 10
# 检查 Nginx 是否已将该实例从后端池剔除
echo "[3] 检查 Nginx 后端状态..."
BACKEND_STATUS=$(curl -s "http://192.168.1.10:8080/upstream_status" \
| grep "${TARGET_IP}:${TARGET_PORT}" \
| awk '{print $2}')
echo " 后端状态: ${BACKEND_STATUS}"
# 等待 systemd 自动重启
echo "[4] 等待 systemd 自动重启..."
sleep 15
# 检查服务是否恢复
RECOVERED=$(curl -s -o /dev/null -w "%{http_code}" \
"http://${TARGET_IP}:${TARGET_PORT}/health" || echo "000")
END_TIME=$(date +%s)
RECOVERY_TIME=$((END_TIME - START_TIME))
AFTER_PLAYERS=$(curl -s "http://${TARGET_IP}:${TARGET_PORT}/api/online_count" \
| jq -r '.count' || echo "0")
echo "========================================"
echo "演练结果"
echo "========================================"
echo "恢复时间: ${RECOVERY_TIME}s (目标 < 30s)"
echo "恢复后在线玩家: ${AFTER_PLAYERS} (演练前: ${BEFORE_PLAYERS})"
echo "玩家流失: $((BEFORE_PLAYERS - AFTER_PLAYERS))"
if [ ${RECOVERY_TIME} -lt 30 ]; then
echo "✅ L1 演练通过"
else
echo "❌ L1 演练失败:恢复时间 ${RECOVERY_TIME}s 超过 30s 目标"
fi
8.4 L5 故障模拟:数据恢复
#!/bin/bash
# drill_l5_data_recovery.sh
# 演练目标:从备份恢复数据库到指定时间点
BACKUP_DIR="/backup/mysql/full"
LATEST_BACKUP=$(ls -dt ${BACKUP_DIR}/*/ | head -1)
TEST_DB="drill_recovery_test"
RECOVERY_TARGET_TIME="2026-09-11 12:00:00"
echo "========================================"
echo "L5 演练:数据库时间点恢复"
echo "目标时间: ${RECOVERY_TARGET_TIME}"
echo "开始时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"
START_TIME=$(date +%s)
# 1. 准备备份
echo "[1] 准备备份: ${LATEST_BACKUP}"
xtrabackup --prepare \
--target-dir="${LATEST_BACKUP}" 2>&1 | tail -3
# 2. 恢复到测试数据库
echo "[2] 恢复到测试库: ${TEST_DB}"
mkdir -p /tmp/recovery_test
xtrabackup --copy-back \
--target-dir="${LATEST_BACKUP}" \
--datadir=/tmp/recovery_test 2>&1 | tail -3
# 3. 启动临时 MySQL 实例
echo "[3] 启动临时 MySQL 实例 (端口 3310)"
mysqld_safe --defaults-file=/tmp/recovery_test/my.cnf \
--port=3310 \
--socket=/tmp/recovery_test/mysql.sock \
--datadir=/tmp/recovery_test &
sleep 5
# 4. 应用 binlog 到目标时间点
echo "[4] 应用 binlog 到 ${RECOVERY_TARGET_TIME}"
mysqlbinlog --stop-datetime="${RECOVERY_TARGET_TIME}" \
/backup/mysql/binlog/mysql-bin.* \
| mysql --host=127.0.0.1 --port=3310 -u root
# 5. 验证数据
echo "[5] 验证恢复数据..."
ROW_COUNT=$(mysql --host=127.0.0.1 --port=3310 -u root -e \
"SELECT COUNT(*) FROM game_db.player_data" \
--silent)
END_TIME=$(date +%s)
RECOVERY_TIME=$((END_TIME - START_TIME))
echo "========================================"
echo "演练结果"
echo "========================================"
echo "恢复时间: ${RECOVERY_TIME}s (目标 < 4h = 14400s)"
echo "恢复后玩家数据行数: ${ROW_COUNT}"
if [ ${RECOVERY_TIME} -lt 14400 ]; then
echo "✅ L5 演练通过"
else
echo "❌ L5 演练失败:恢复时间超过 4h 目标"
fi
# 清理
killall mysqld
rm -rf /tmp/recovery_test
九、成本与收益分析
灾备不是越全越好,而是在预算范围内把最可能的故障覆盖到。下面分析各方案的投入产出比。
9.1 成本拆解
| 方案组件 | 额外服务器 | 软件成本 | 人力成本 | 月度成本估算 |
|---|---|---|---|---|
| XtraBackup 备份 | 1 台备份服务器 | 免费(社区版) | 2 人天搭建 | ¥800(服务器) |
| 多 WorldServer 实例 | 2 台额外游戏服务器 | 免费 | 3 人天配置 | ¥3,000 |
| MySQL 主从 + ProxySQL | 1 台从库 + ProxySQL | 免费 | 2 人天 | ¥1,500 |
| Redis 哨兵集群 | 2 台额外 Redis | 免费 | 1 人天 | ¥1,000 |
| Keepalived VIP | 1 台额外 Nginx | 免费 | 1 人天 | ¥800 |
| Prometheus 监控 | 1 台监控服务器 | 免费 | 3 人天 | ¥800 |
| 异地备份同步 | 远程存储/云 OSS | ¥0.12/GB/月 | 0.5 人天 | ¥500 |
| 全套方案合计 | 约 6 台额外服务器 | 全免费 | 约 12 人天 | ¥8,400/月 |
注意:以上基于自建机房估算,云服务器费用略高但弹性更好。软件全用开源方案,零授权费用。
9.2 收益分析
不投入灾备的隐性成本:
- 一次严重宕机事件导致的玩家流失率:5-15%
- 活跃月 ARPU 假设 ¥50,1 万 DAU 的游戏月收入约 ¥1,500,000
- 一次 4 小时宕机导致 10% 玩家流失 = ¥150,000/月收入损失
- 充值订单因数据丢失导致的退款纠纷:难以估量,但严重损害品牌
投入 ¥8,400/月,避免单次 ¥150,000+ 的损失。ROI = 17.8 倍,这还没算品牌信誉和玩家留存。
灾备方案对比表
| 方案 | 覆盖故障级别 | RTO | RPO | 月度成本 | 搭建复杂度 | 适用规模 | 关键技术 |
|---|---|---|---|---|---|---|---|
| 基础备份方案 | L5 数据丢失 | 4-8h | 1-24h | ¥800 | ★☆☆☆☆ | 小型(<500 人在线) | mysqldump + crontab + rsync |
| XtraBackup + 增量 | L5 数据丢失 | 1-4h | < 5min | ¥1,500 | ★★☆☆☆ | 中小型(500-2000 人) | XtraBackup 全量+增量 + binlog |
| 多实例 + 主从 DB | L1 单实例 / L3 DB | < 5min | < 1min | ¥5,500 | ★★★☆☆ | 中型(2000-5000 人) | Nginx LB + 多 WorldServer + MySQL 主从 |
| 完整高可用集群 | L1-L4 全覆盖 | < 30s | < 1min | ¥8,400 | ★★★★☆ | 大型(5000-20000 人) | Keepalived VIP + 多实例 + Redis 哨兵 + 监控 |
| 异地多活 | L1-L5 全覆盖 | < 10s | 0 | ¥30,000+ | ★★★★★ | 超大型(>20000 人) | 双机房 + 数据同步 + 全局 LB + 自动切换 |
选型建议:日活 500 以下选基础备份,500-2000 选 XtraBackup 方案,2000-5000 上多实例,5000 以上必须完整高可用集群。异地多活仅在有明确 SLA 承诺或合规要求时才需要投入。
结语
灾备架构的本质是用确定的成本去对冲不确定的损失。三层模型——数据备份保底线、服务冗余保体验、故障切换保时效——每一层都是独立的防御纵深。不要试图一步到位,从第一层开始,验证通过再加第二层,逐步演进。每季度做一次真实演练,让团队在压力下形成肌肉记忆。真正灾难来临时,活下来的不是准备最全的,而是练得最熟的。