游戏服务器灾备架构:从数据备份到高可用集群的完整方案 原创

温馨提示:
本文最后更新于 2026-09-11,已超过 0 天没有更新。 若文章内的图片失效(无法正常加载),请留言反馈或直接 联系我

游戏服务器灾备架构:从数据备份到高可用集群的完整方案

凌晨三点,你的游戏服务器宕机了。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 承诺或合规要求时才需要投入。

结语

灾备架构的本质是用确定的成本去对冲不确定的损失。三层模型——数据备份保底线、服务冗余保体验、故障切换保时效——每一层都是独立的防御纵深。不要试图一步到位,从第一层开始,验证通过再加第二层,逐步演进。每季度做一次真实演练,让团队在压力下形成肌肉记忆。真正灾难来临时,活下来的不是准备最全的,而是练得最熟的。