香港服务器上的网络游戏如何部署:我用 Redis+Lua 缓存战斗记录,把数据库压力降低到1/5 
技术教程 2025-09-20 10:08 195


夜里 1:40,我们的 MMO 新赛季凌晨 2 点开服,跨区玩家集中回流,我盯着大屏上的链路与 TPS 曲线,MySQL 的写入峰值像心电图一样往上蹿。上次同类活动,我们就是因为“战斗日志直写数据库”把 InnoDB 打成红区——redo log 满、buffer pool 呻吟、flush 队列顶到天花板。

这次我不打算再当“数据库牛马”。我把战斗记录从 DB 热路径里硬生生掰了出来:Redis + Lua 先落缓存、打包批量入库,让数据库只做“冷静的归档员”,而不是“被连击的盾牌”。下面就是我这次在香港服务器上的完整部署与优化全过程,包括踩坑与补锅。

一、目标与整体方案

目标:

  • 战斗记录(Battle Logs)写入在高峰期不拖累业务逻辑线程,P99 < 8ms(缓存路径)。
  • 数据库写入从“逐条”改为“批量”,把 DB 写 QPS 压到原来的 1/5 左右。
  • 故障可回放、可补偿、不丢日志。

方案要点:

  • 接入层:Anycast/智能调度 → Nginx/L4 转发 → 网关服务(鉴权、路由)。
  • 游戏逻辑:与 Redis 交互只做“一次脚本调用”,不拼装多条命令。
  • 缓存层:Redis Cluster(3 主 3 备)+ Lua 脚本写入 Redis Streams 或双写 List/Hash(视需求),并做限长/计数/TTL。
  • 落库层:异步 Stream Consumer Group 批量拉取,写 MySQL(分区表/分库分表)+ 幂等保证。
  • 监控与回滚:Prometheus + Grafana,AOF+RDB 双保险,消费偏移持久化与重放。

ASCII 结构图:

玩家 → Anycast/L4 → Nginx → 网关 → 逻辑服
                               |
                               | EVALSHA (Lua)
                               v
                       Redis Cluster (HK)
                               |
                               | XREADGROUP (batch)
                               v
                           MySQL 8 (HK)

二、香港机房与硬件选型(面向大陆用户的网络考量)

角色 CPU/RAM 存储 网卡 运营商/线路 备注
网关 & 逻辑 (×4) AMD EPYC 7452 32C / 128GB NVMe 1.92TB (系统+日志) 2×10GbE CN2 GIA 优先,PCCW/HGC 备选 单机峰值可撑 35k RPS(含鉴权)
Redis 节点 (×6) EPYC 7402P 24C / 128GB NVMe 3.84TB ×2 RAID1 2×10GbE 同上 3 主 3 备,Cluster 模式
MySQL 主 (×1) EPYC 7542 32C / 256GB NVMe 3.84TB ×4 RAID10 2×25GbE 同上 InnoDB Buffer Pool 160GB
MySQL 备 (×1) 同主 同步复制 2×25GbE 同上 延迟只读,报表/审计

网络指标(实测):

  • 香港机房至 广州 RTT 8~12ms,深圳 9~13ms,上海 23~30ms,北京 28~36ms(CN2 GIA 线路)。
  • 上行 DDoS 清洗能力:≥ 300Gbps(机房侧),在接入层用 ACL + 限速,业务侧令牌桶保护接口。

三、操作系统与内核(CentOS 7)基线

我们统一用 CentOS 7,因为上游环境历史包袱较重(驱动、监控组件稳定)。

系统基线:

# 关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
echo 'madvise' > /sys/kernel/mm/transparent_hugepage/khugepaged/defrag 2>/dev/null || true

# 打开文件句柄
echo '* soft nofile 1048576' >> /etc/security/limits.conf
echo '* hard nofile 1048576' >> /etc/security/limits.conf

# sysctl(网络 + Redis/MySQL 友好)
cat >/etc/sysctl.d/game.conf <<'EOF'
fs.file-max = 2097152
vm.swappiness = 1
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5
vm.overcommit_memory = 1

net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fastopen = 3
EOF
sysctl --system

# 时钟
yum install -y chrony && systemctl enable --now chronyd

四、Redis 7 部署与关键配置(Cluster + AOF everysec)

4.1 安装与编排

# 建议源码编译固定版本,或使用 remi 仓库稳定版
yum groupinstall -y "Development Tools"
yum install -y jemalloc jemalloc-devel tcl
# 假设已解压至 /opt/redis-7.2.x
make MALLOC=jemalloc && make install
useradd -r -s /sbin/nologin redis
mkdir -p /data/redis/{7001,7002,7003,7004,7005,7006}
chown -R redis:redis /data/redis

4.2 redis.conf 关键项(示例)

port 7001
cluster-enabled yes
cluster-config-file nodes-7001.conf
cluster-node-timeout 3000
protected-mode yes
bind 0.0.0.0
daemonize yes
dir /data/redis/7001
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 80
auto-aof-rewrite-min-size 1gb
save 900 1
save 300 10
save 60 10000
maxmemory 96gb
maxmemory-policy noeviction   # 日志用 Streams,主路径不丢;配合 TTL + XTRIM 控长
repl-backlog-size 512mb
tcp-keepalive 300
# ACL
requirepass "<REDIS_PASS>"
masterauth "<REDIS_PASS>"
rename-command FLUSHALL ""
rename-command KEYS ""

集群创建:

redis-cli -a <REDIS_PASS> --cluster create \
  10.0.0.11:7001 10.0.0.12:7002 10.0.0.13:7003 \
  10.0.0.14:7004 10.0.0.15:7005 10.0.0.16:7006 \
  --cluster-replicas 1

经验:AOF 重写高峰会抖一下磁盘 IO,我把 auto-aof-rewrite-percentage 从默认 100 降到 80,并把 Redis 的数据目录单独放 RAID1 NVMe,避免与系统/日志抢 IO。

五、MySQL 8 配置(批量写友好)

/etc/my.cnf 关键项:

[mysqld]
datadir=/data/mysql
innodb_buffer_pool_size=160G
innodb_log_file_size=8G
innodb_flush_log_at_trx_commit=2
innodb_flush_neighbors=0
innodb_io_capacity=4000
innodb_io_capacity_max=20000
sync_binlog=0
binlog_expire_logs_seconds=259200
max_connections=2000
table_open_cache=8000
thread_cache_size=64
# 批量写优化
innodb_flush_method=O_DIRECT

表结构(按天分区 + 幂等键):

CREATE TABLE battle_log (
  log_id        BIGINT PRIMARY KEY,             -- 由(时间戳+序列)或雪花算法生成
  match_id      BIGINT NOT NULL,
  player_id     BIGINT NOT NULL,
  event_type    TINYINT NOT NULL,
  payload       JSON NOT NULL,
  ts_ms         BIGINT NOT NULL,
  UNIQUE KEY uk_match_player_ts (match_id, player_id, ts_ms)
)
PARTITION BY RANGE COLUMNS(ts_ms) (
  PARTITION p20250918 VALUES LESS THAN (1694976000000),
  PARTITION p20250919 VALUES LESS THAN (1695062400000),
  PARTITION pmax      VALUES LESS THAN (MAXVALUE)
);

经验:把 幂等键(如 match_id + player_id + ts_ms)放唯一索引,批量 INSERT ... ON DUPLICATE KEY UPDATE 就能天然去重,消费端“至少一次”也不怕。

六、Lua 脚本设计:一次 EVAL 完成写流、计数、限长

我们采用 Redis Streams 承载战斗记录(原因:天然顺序、消费者组、便于重放)。Lua 做到“一次请求,多步原子”:

  • XADD 到 stream:battle:{match_id}
  • 对 hash:stats:{match_id} 做计数(如伤害、击杀)
  • 对 list:last:{player_id} 保留最近 N 条摘要(LPUSH + LTRIM)
  • 设置 TTL,防止冷数据占内存
  • 返回写入的 Stream ID 与统计值,便于业务端落地

log_battle.lua:

-- KEYS:
-- 1: stream key, e.g. stream:battle:{match_id}
-- 2: hash stats key, e.g. hash:stats:{match_id}
-- 3: list last key (player), e.g. list:last:{player_id}
-- ARGV:
-- 1: maxlen (approx), e.g. 2000
-- 2: ttl seconds for stream/hash/list, e.g. 86400
-- 3: event_type
-- 4: match_id
-- 5: player_id
-- 6: ts_ms
-- 7: payload json

local stream = KEYS[1]
local hstats = KEYS[2]
local llast  = KEYS[3]

local maxlen = tonumber(ARGV[1])
local ttl    = tonumber(ARGV[2])
local etype  = ARGV[3]
local match  = ARGV[4]
local player = ARGV[5]
local tsms   = ARGV[6]
local payload= ARGV[7]

-- 1) XADD with MAXLEN ~
local sid = redis.call('XADD', stream, 'MAXLEN', '~', maxlen, '*',
  'etype', etype, 'match', match, 'player', player, 'ts', tsms, 'payload', payload)

-- 2) HINCRBY stats (e.g. count per etype)
local stat_field = 'etype:' .. etype
local stat_val = redis.call('HINCRBY', hstats, stat_field, 1)

-- 3) LPUSH last events list per player, trim to 100
redis.call('LPUSH', llast, payload)
redis.call('LTRIM', llast, 0, 99)

-- 4) TTL
redis.call('EXPIRE', stream, ttl)
redis.call('EXPIRE', hstats, ttl)
redis.call('EXPIRE', llast, ttl)

return {sid, stat_field, stat_val}

加载与缓存脚本 SHA(逻辑服启动时做一次):

// Go (go-redis v9 示例)
sha, err := rdb.ScriptLoad(ctx, luaScriptString).Result()
// 业务写入
res, err := rdb.EvalSha(ctx, sha,
    []string{
        fmt.Sprintf("stream:battle:{%d}", matchID),
        fmt.Sprintf("hash:stats:{%d}", matchID),
        fmt.Sprintf("list:last:{%d}", playerID),
    },
    2000, 86400, etype, matchID, playerID, tsMs, payloadJSON,
).Result()
if isNoScript(err) {
    res, err = rdb.Eval(ctx, luaScriptString, ...).Result()
}

细节:钥匙(KEYS)里我用了一致性哈希标签 {},同一场战斗的 key 会落到同一槽,可以减少跨槽事务复杂度。

七、消费落库:批量与幂等

我们用 Consumer Group 消费 Streams,按时间/条数双阈值批量写入 DB。

Python(asyncio + redis.asyncio + aiomysql)简化示例:

import asyncio, json, aiomysql
from redis.asyncio import Redis

STREAM = "stream:battle:{%d}"  # match_id slot-tagged
GROUP  = "gdb"
CONSUMER = "c1"
BATCH_SIZE = 500
BATCH_MS = 50

async def ensure_group(r, skey):
    try:
        await r.xgroup_create(name=skey, groupname=GROUP, id='$', mkstream=True)
    except Exception as e:
        if "BUSYGROUP" in str(e): pass
        else: raise

async def main(match_id):
    r = Redis(host="10.0.0.21", port=7001, password="...", decode_responses=True)
    pool = await aiomysql.create_pool(host="10.0.0.31", user="game", password="...", db="game", autocommit=False)

    skey = STREAM % match_id
    await ensure_group(r, skey)

    buf, last = [], asyncio.get_event_loop().time()
    while True:
        msgs = await r.xreadgroup(GROUP, CONSUMER, streams={skey: ">"}, count=BATCH_SIZE, block=BATCH_MS)
        if msgs:
            for _stream, entries in msgs:
                for _id, fields in entries:
                    payload = json.loads(fields["payload"])
                    buf.append((_id, payload))
        now = asyncio.get_event_loop().time()
        if len(buf) >= BATCH_SIZE or (buf and (now - last)*1000 >= BATCH_MS):
            async with pool.acquire() as conn:
                async with conn.cursor() as cur:
                    try:
                        # 批量写
                        sql = """INSERT INTO battle_log
                                 (log_id, match_id, player_id, event_type, payload, ts_ms)
                                 VALUES (%s, %s, %s, %s, CAST(%s AS JSON), %s)
                                 ON DUPLICATE KEY UPDATE payload=VALUES(payload)"""
                        vals = []
                        for sid, p in buf:
                            # 生成幂等 log_id,可用雪花;示例用 ts_ms+序列
                            vals.append((int(p["ts_ms"]), int(p["match"]), int(p["player"]),
                                         int(p["etype"]), json.dumps(p), int(p["ts_ms"])))
                        await cur.executemany(sql, vals)
                        await conn.commit()
                        # ACK
                        await r.xack(skey, GROUP, *[sid for sid, _ in buf])
                        buf.clear(); last = now
                    except Exception:
                        await conn.rollback()
                        await asyncio.sleep(0.2)  # backoff
        else:
            await asyncio.sleep(0.005)

asyncio.run(main(match_id=123456))

要点:

  • COUNT + BLOCK 结合 微批,把单条写放大成 100~1000 条的批量提交。
  • 消费失败不 ACK,重试时因 幂等键 不会重复入库。
  • 高峰期可按 match_id 拆多组 Worker 并行消费(slot 一致不会跨节点)。

八、接入层与逻辑服优化

Nginx:开启 reuseport、worker_cpu_affinity,worker_connections 65535。

逻辑服:

  • Redis 连接池设置:Min 64 / Max 1024,pipeline 聚合写,EVALSHA 优先。
  • 同机部署 Redis Proxy(如 twemproxy/valkey proxy),或直连 Cluster(框架自带路由)。
  • JSON 序列化预分配 buffer,避免 GC 抖动。
  • 关键路径不可持锁等待 DB——全部走缓存脚本返回即可。

九、监控与告警

Redis 指标:

  • instantaneous_ops_per_sec、latency(P95/P99 < 5ms)
  • mem_fragmentation_ratio < 1.6,超了压测下调 hash-max-ziplist-entries/切 jemalloc 版本
  • aof_current_rewrite_time_sec、rdb_last_bgsave_status

MySQL 指标:

  • InnoDB Buffer Pool 命中率 > 99%
  • Log waits 过高 → 调整 innodb_log_file_size、IO 能力
  • 表分区与冷热数据比例,归档任务水位

系统指标:

  • SoftIRQ、netdev 丢包、NIC 利用率
  • Load Avg 与 run queue(Redis 单实例 CPU < 250% 为佳,避免超调)

十、压测数据(真实场景截取)

场景 峰值并发在线 战斗日志产生 Redis 写 OPS Redis P99 MySQL 写 QPS DB P99 备注
改造前(直写 DB) 18,000 28k/s - - 28k/s 35~90ms 峰值时抖动致关服
改造后(Redis+Lua+批落库) 18,000 28k/s 30k/s 4.8ms ~5.6k/s 8~15ms 稳定,峰值平滑
提升后(多副本+扩 worker) 28,000 45k/s 48k/s 6.3ms ~9k/s 10~18ms 仍稳定

十一、坑与现场解决

AOF 重写卡顿

现象:战斗高峰触发 AOF rewrite,个别实例 latency spike。

处理:提前把 auto-aof-rewrite-percentage=80,并安排 凌晨 4:30 强制重写;数据盘独立 RAID;vm.dirty_ratio 下调到 10,减轻 writeback 抖动。

内存碎片率异常

现象:mem_fragmentation_ratio > 2.0。

处理:切 jemalloc 新版;调低 value 尺寸(payload 压缩/裁剪冗余字段);用 Streams + MAXLEN ~ 控制段长,冷数据靠 TTL 淘汰。

消费者组 pending 积压

现象:XPENDING 长时间 > 1e6。

处理:增加消费者并行度;按 match_id sharding 多个 stream;对“死信”定期 XCLAIM 转移处理;DB 写失败时快速回滚 + 重试 + 指标告警。

逻辑服偶发超时

现象:Redis 客户端连接放大 + GC 抖动。

处理:连接池上限,禁用超大对象;EVALSHA + pipeline,将 5~8 步操作合并为 1 次脚本调用;Go 堆 profiling 定位 JSON 分配热点后做对象池化。

MySQL redo 打满

现象:活动刚开始 3 分钟 redo 100%。

处理:批量阈值从 100 → 500 条,并行 4 路;innodb_log_file_size 提升到 8G,磁盘队列足够时 P99 降明显。

十二、上线 checklist(我自己的)

  1.  Redis Cluster 所有主从延迟 < 50ms;failover 演练通过
  2.  AOF + RDB 恢复演练:抽样回放 10 分钟内日志到测试库
  3.  消费者组“断电”演练:模拟 Worker 挂死,恢复后无丢单
  4.  DB 写入 TPS 峰值预留 30% 余量,redo 最高 < 70%
  5.  逻辑服降级开关:Redis 故障 → 本地 ringbuffer 暂存 + 速率限制
  6.  告警门槛:Redis p99>10ms 1 分钟、pending>2e5、DB commit>50ms 连续 5 分钟

十三、完整部署步骤汇总(CentOS 7)

  • 服务器就绪:绑定 CN2 GIA 线路,BGP 优选;上 ACL 与基础防护。
  • 系统基线:limits、sysctl、THP、chrony。
  • Redis:编译安装 → 3 主 3 备 Cluster → AOF everysec → ACL/rename 危险命令。
  • MySQL:RAID10、O_DIRECT、Buffer Pool 充足、分区表、幂等键。
  • 逻辑服:接入 EVALSHA 调用 Lua;连接池与 pipeline;降级策略。
  • Worker:Streams 消费组,微批写入,重试与 ACK 管理。
  • 监控:Prometheus 抓 Redis/MySQL/系统指标;Grafana 大屏;告警规则。
  • 演练:failover、断电、回放;压测到 1.2× 峰值。

十四、附:Nginx 与 systemd 示例

Nginx(片段):

worker_processes auto;
events { worker_connections 65535; use epoll; multi_accept on; }
http {
  sendfile on; tcp_nopush on; tcp_nodelay on;
  keepalive_timeout 15;
  server {
    listen 80 reuseport backlog=65535;
    location /api/ {
      proxy_pass http://game-logic-upstream;
      proxy_http_version 1.1;
      proxy_set_header Connection "";
    }
  }
  upstream game-logic-upstream {
    keepalive 256;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=5s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=5s;
  }
}

Redis systemd(按端口模板化):

[Unit]
Description=Redis 7001
After=network.target

[Service]
User=redis
Group=redis
ExecStart=/usr/local/bin/redis-server /etc/redis/7001.conf --supervised systemd
ExecStop=/usr/local/bin/redis-cli -a <REDIS_PASS> -p 7001 shutdown
LimitNOFILE=1048576
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

新赛季开了 65 分钟,图表上最陡的那根线不见了。Redis 的 OPS 像一条平稳的河,MySQL 的写入从近 30k/s 掉到 6k/s 上下,redo 不再尖叫。

我把耳机摘了,机房依旧冷得彻骨,但手机上安静到有些不真实:没有 PagerDuty 的红点。

有人把战斗的火力倾泻在副本里,而我把写入的火力分流到了缓存里。数据库那位“老同事”终于可以喘口气了。

我合上机柜门,轻声“晚安”。这是我在香港服务器上用 Redis+Lua 扛下战斗记录的全部做法与坑。下一次版本迭代,我只需要把 Streams 的分片再细一点,worker 的并行度再拉一档就够了。

如果你也要复用我的路线,可以从这三招先手开始

  • 先改写路径:Lua 一把梭,保证战斗日志写入只有一次网络往返。
  • 再上消费者组:把“每条”改成“每批”,幂等键护体。
  • 最后稳磁盘:AOF 重写窗口、DB redo 空间、IO 上限都要算过。