如何在香港服务器的 Ubuntu 系统中部署的网络游戏,设置并结合 Redis 与内存持久化,解决排行榜与实时战报性能瓶颈?

深夜 2:40 的香港荔枝角机房,我把工卡夹在胸前,一手端着便携显示器,一手拖着 KVM 小车,沿着机柜排号找过去。监控大屏上,华南几个大区的在线人数突然抬头,排行榜接口抖成锯齿,实时战报流读者堆积。香港到珠三角的 RTT 平均 18~24ms,本不算高,但我们在排行榜 + 战报两个热点上踩了雷。那一刻,我心里只有一个声音:把 Redis 和内存持久化架好,今晚一定要稳住。
1. 业务背景与性能瓶颈画像
场景:
- MMO/竞技类网络游戏,赛季制,玩家在对局结束后需要更新积分,并在客户端展示总榜、赛季榜、地区榜等。
- 游戏内“实时战报”频道要把击杀、连胜、据点占领等事件近实时推送给观战端与主播工具。
瓶颈:
- 排行榜落在 MySQL 做排序 + limit,写放大 + 索引热区,导致P95 过 60ms。
- 战报通过 Kafka→HTTP 网关→WebSocket,链路长,客户端抖动敏感;峰值时积压。
目标:
- 写入(对局结算)峰值 3.5~5 万 RPS 稳定。
- 排行榜查询 P95 ≤ 8 ms;战报推送端到端 ≤ 150 ms(同城)/ ≤ 300 ms(跨区)。
2. 硬件与网络基线(香港机房实况)
所在机房:荔枝角/葵涌一线机房,单机房三可用区等距布点(机柜不跨层)。
标配服务器(实测可复用):
- CPU:AMD EPYC 7413(24C/48T),或 Intel Xeon Silver 4410Y(2×12C)均可
- 内存:256 GB ECC DDR4
- 磁盘:NVMe 2×1.92 TB(RAID1,mdadm),本地盘
- 网卡:双口 10GbE(Intel X710),bonding(active-backup)
- 系统:Ubuntu 22.04 LTS,内核 5.15
- 带宽:独享 1~10 Gbps,上联 BGP,多线回内地
延迟参考(业务实测,供标定):
- 香港 ↔ 深圳/广州:18~24 ms
- 香港 ↔ 上海:35~45 ms
- 香港 机房内:≤ 0.3 ms
3. 架构总览与数据流设计
[游戏服] ──(gRPC/HTTP)──> [结算服务]
│
│(pipeline / Lua)
▼
[Redis 写入口]
│ │
[ZSET 排行] [Streams 战报]
│ │
[读副本/Cluster 分片] │
│ ▼
[WebSocket 推送层]
│ │
[CDN/边缘节点] │
│ │
[客户端/观战端/主播工具]
要点:
- 排行榜用 ZSET,按赛季/模式/分区分 Key,读走副本或 Cluster 分片。
- 战报用 Redis Streams,MAXLEN ~ N 控制内存占用,消费组做并发推。
- Lua 保证“积分更新 + 战报写入”原子性,减少跨 RTT。
- 内存持久化采用:AOF everysec + RDB preamble;主从/Cluster 多副本 + 定时冷备。
4. Redis 与“内存持久化”策略选择
为何 Redis:
- ZSET 原生排名/区间操作;Streams 原生日志/事件队列;延迟可控。
持久化组合:
- AOF(everysec)+ RDB preamble:
- appendonly yes,appendfsync everysec(平衡吞吐与数据丢失 ≤1s)。
- aof-use-rdb-preamble yes 让重启快;no-appendfsync-on-rewrite yes 降低抖动。
复制/集群:
- 单主多副本(Sentinel)或 Redis Cluster 3 主 3 备,提升可用与扩展。
冷备:
- 每小时 BGSAVE/RDB + 每日 AOF 压缩,传 S3 兼容存储(香港同城+跨城)。
- 为什么不只用 RDB:写放大小,但重启恢复窗口大、丢失窗口不可控;AOF everysec 是更合理的线上默认。
5. Ubuntu 系统级优化(可复制的调优清单)
内核参数(/etc/sysctl.d/99-game.conf)
vm.overcommit_memory = 1
vm.swappiness = 1
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 16384
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
net.ipv4.tcp_rmem = 4096 87380 268435456
net.ipv4.tcp_wmem = 4096 65536 268435456
fs.file-max = 2000000
应用:sudo sysctl --system
关闭 THP(永久)
# /etc/systemd/system/disable-thp.service
[Unit]
Description=Disable Transparent Huge Pages
[Service]
Type=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/defrag"
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now disable-thp
文件句柄 / ulimit(systemd 单元)
# /etc/systemd/system/redis.service.d/limits.conf
[Service]
LimitNOFILE=1000000
NUMA(EPYC/Xeon 多路)
单实例绑定一颗 NUMA(numactl --cpunodebind=0 --membind=0),或使用 Redis cluster 多实例“每 NUMA 一实例”。
时钟同步
chrony 对准香港授时源,避免跨区事件排序抖动。
6. Redis 部署与核心配置示例
6.1 安装(Ubuntu 22.04)
sudo add-apt-repository ppa:redislabs/redis -y
sudo apt update && sudo apt install -y redis-server
6.2 单主多从(Sentinel)示例
/etc/redis/redis.conf(摘录)
bind 10.0.10.11 127.0.0.1
protected-mode yes
port 6379
requirepass Strong#Passw0rd!
masterauth Strong#Passw0rd!
# 持久化
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 512mb
aof-use-rdb-preamble yes
# 内存控制
maxmemory 180gb
maxmemory-policy allkeys-lfu
# I/O 线程(网络 I/O)
io-threads 4
io-threads-do-reads yes
# 延迟监控
latency-monitor-threshold 100
Sentinel(3 节点) /etc/redis/sentinel.conf
port 26379
sentinel monitor game-redis 10.0.10.11 6379 2
sentinel auth-pass game-redis Strong#Passw0rd!
sentinel down-after-milliseconds game-redis 5000
sentinel failover-timeout game-redis 60000
三台哨兵分布不同机柜/可用区。
6.3 Redis Cluster(三主三从)要点
开启 cluster-enabled yes,并合理用 Hash Tag:lb:{s1}:global、war:{s1}:realtime 保证相关 Key 同槽,避免跨槽 Zxxx 操作失败。
容量增长按“每 NUMA 节点一个主实例”横向加。
7. 排行榜(ZSET)设计与实战代码
Key 规范:
- lb:{season}:{mode}:{region}
- 示例:lb:{s7}:solo:cn-south
- 成员(member):player:{uid};分值(score):MMR/积分。
常用操作:
- 写入/增量:ZINCRBY
- TopN:ZREVRANGE key 0 99 WITHSCORES
- 附近排名:ZRANK + ZRANGE rank-10 rank+10 WITHSCORES
- 清理:ZREMRANGEBYRANK key 0 -1000001(只保留前 100 万)
Python(async)示例
import asyncio, json
import redis.asyncio as redis
r = redis.Redis(host='10.0.10.11', port=6379, password='Strong#Passw0rd!', decode_responses=True)
SEASON = 's7'; MODE = 'solo'; REGION = 'cn-south'
LB = f"lb:{{{SEASON}}}:{MODE}:{REGION}"
async def add_score(uid: int, delta: float):
member = f"player:{uid}"
# pipeline 降低 RTT
pipe = r.pipeline(transaction=False)
pipe.zincrby(LB, delta, member)
pipe.zrevrank(LB, member)
score, rank = await pipe.execute()
return float(score), int(rank)
async def top_n(n=100):
return await r.zrevrange(LB, 0, n-1, withscores=True)
asyncio.run(add_score(12345, 24))
分区/赛季归档
赛季结束将旧榜 RENAME 到 lb:{s7}:solo:cn-south:archive,并仅保留 Top 10k(前端展示用)。
8. 实时战报(Streams)设计与实战代码
Key 规范:
war:{season}:{region}
事件字段:ts(服务器时间)、match_id、uid、type(kill/streak/cap)、payload(JSON)。
采用 MAXLEN ~ 200000 控制内存占用(近似裁剪)。
生产(结算服务或游戏服)
import time, json
import redis.asyncio as redis
r = redis.Redis(host='10.0.10.11', port=6379, password='Strong#Passw0rd!', decode_responses=True)
WAR = 'war:{s7}:cn-south'
async def push_event(evt: dict):
evt.setdefault('ts', int(time.time()*1000))
return await r.xadd(WAR, evt, maxlen=200000, approximate=True)
消费(WebSocket 推送层,消费组)
import asyncio
import redis.asyncio as redis
r = redis.Redis(host='10.0.10.12', port=6379, password='Strong#Passw0rd!', decode_responses=True)
GROUP = 'realtime'; CONSUMER = 'pusher-1'; STREAM = 'war:{s7}:cn-south'
async def ensure_group():
try:
await r.xgroup_create(STREAM, GROUP, id='0-0', mkstream=True)
except redis.ResponseError as e:
if 'BUSYGROUP' not in str(e):
raise
async def loop():
await ensure_group()
while True:
resp = await r.xreadgroup(GROUP, CONSUMER, streams={STREAM: '>'}, count=512, block=1000)
for _, msgs in resp or []:
# TODO: 推送到 WebSocket 广播集群
# 处理完成后 ACK
ids = [msg_id for msg_id, _ in msgs]
await r.xack(STREAM, GROUP, *ids)
asyncio.run(loop())
9. 原子性与一致性:Lua 脚本一把梭
需求:对局结算时既要增加积分,又要写一条战报,还不想多次 RTT。
Lua(score_and_event.lua)
-- KEYS[1] = leaderboard key (ZSET)
-- KEYS[2] = stream key (STREAM)
-- ARGV[1] = member (player:{uid})
-- ARGV[2] = delta (score inc)
-- ARGV[3] = event json string
-- ARGV[4] = maxlen (e.g., 200000)
local newscore = redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1])
redis.call('XADD', KEYS[2], 'MAXLEN', '~', ARGV[4], '*', 'member', ARGV[1], 'delta', ARGV[2], 'score', tostring(newscore), 'event', ARGV[3])
return newscore
调用(Python)
import json, redis.asyncio as redis
r = redis.Redis(host='10.0.10.11', port=6379, password='Strong#Passw0rd!', decode_responses=True)
LUA = open('score_and_event.lua','r').read()
sha = None
async def score_and_event(lb, stream, uid, delta, event):
global sha
if not sha:
sha = await r.script_load(LUA)
member = f'player:{uid}'
evt = json.dumps(event)
return await r.evalsha(sha, 2, lb, stream, member, str(delta), evt, '200000')
线上实践:与 pipeline 相比,Lua 在原子性上更硬,抖动也更小。
10. 压测方法与对比数据(表格)
压测条件:
单主两从(10.0.10.11 主,.12/.13 从);客户端 4 台接入;bond 10G。
redis.conf 关键项:appendfsync everysec,io-threads 4,maxmemory-policy allkeys-lfu。
命令
redis-benchmark -h 10.0.10.11 -p 6379 -a 'Strong#Passw0rd!' \
-t ZINCRBY,ZREVRANGE,XRANGE,XADD -n 500000 -c 512 -P 16
结果(样例,供参考)
| 指标 | MySQL 排序方案(旧) | Redis 优化前 | Redis + AOF everysec + Lua | Redis + Cluster(三主三从) |
|---|---|---|---|---|
| 排行写(ZINCRBY 等效)RPS | — | 28.5k | 41.2k | >95k(线性扩) |
| Top100 查询 P95 | 62 ms | 11.7 ms | 6.8 ms | 4.2 ms |
| 战报写入(XADD)RPS | — | 35k | 57k | >120k |
| 战报端到端延迟(同城)P95 | 280 ms | 190 ms | 125 ms | 110 ms |
| 主节点 CPU 峰值 | — | 86% | 72% | 55%/节点 |
说明:Cluster 数据为 3 分片,总吞吐近似线性;实际取决于客户端路由+热键分布。
11. 线上故障与坑位复盘
AOF 重写引发瞬时抖动:
- 现象:每次 auto-aof-rewrite 触发,P95 短暂上扬。
- 处理:开启 no-appendfsync-on-rewrite yes;将重写窗口挪到夜间低峰;并行副本切读。
跨槽操作失败(Cluster):
- 现象:ZINTER/ZUNION 某些 Key 跨槽报错。
- 处理:统一 Key 命名用 Hash Tag({s7}),强制相关 Key 同槽;必要时在应用层做“先拉再聚合”。
热键与倾斜:
- 现象:总榜 TopN 热度导致单分片 QPS 飙升。
- 处理:把 TopN 结果缓存到边缘(1~3 秒 TTL),对尾部请求走直连;对超热 Key 做“副本只读”。
内存碎片率飙升:
- 现象:实例 Fragmentation > 1.7,实际可用内存骤降。
- 处理:控制对象体积;用 allkeys-lfu 淘汰冷对象;评估 stream MAXLEN;必要时重启+预热(有 AOF/RDB 作保障)。
THP 未关导致尾延迟抖动:
- 现象:P99 偶发 100ms+。
- 处理:永久关闭 THP;核对生效脚本,避免内核升级后失效。
磁盘写高峰:
- 现象:AOF 落盘遇 NVMe 垂直抖动。
- 处理:RAID1 使用写直通;保留 20% 盘空闲;将监控阈值绑定延迟而非 IOPS。
客户端连接耗尽:
- 现象:连接数爆表,maxclients 未调。
- 处理:升 LimitNOFILE;按接入层做连接池;对 WebSocket 推送层分片消费。
12. 监控告警与容量规划
监控项:
- Redis:instantaneous_ops_per_sec、used_memory、mem_fragmentation_ratio、aof_current_size、aof_last_bgrewrite_status、rejected_connections、latency。
- 系统:NVMe 延迟、CPU steal、软中断、网卡丢包、bond 切换。
- 业务:排行榜写入/查询 RPS、战报生产/消费滞后、端到端延迟。
告警门限(示例)
- mem_fragmentation_ratio > 1.7 连续 5 分钟。
- aof_current_size 24 小时增长 > 100GB(触发手动重写/归档)。
- consumer_lag > 10,000(战报消费延迟)。
容量估算(粗模)
- ZSET:每成员约 50~100B(Key 前缀 + member + score + 元数据);100 万玩家 ≈ 100MB 级别/榜。
- Streams:单条 100300B;MAXLEN ~ 200,000 ≈ 2060MB/流。
- 结合对象头/碎片,实例内存控制在物理内存的 70~75%。
13. 备份、容灾与演练
冷备流程
# 每小时 RDB 到本地
redis-cli -a 'Strong#Passw0rd!' BGSAVE
# 每日 AOF 重写压缩
aof-tools rewrite /var/lib/redis/appendonly.aof
# 备份到对象存储(示例 rclone)
rclone sync /var/lib/redis s3hk:game-redis-backup/`date +%F` --fast-list
RPO/RTO
- RPO:≤ 1s(AOF everysec 假设正常落盘);
- RTO:单主多从切换 515s;Cluster 单分片故障 310s。
演练
- 每月在预备环境做主崩溃、网络隔离、磁盘写满三类演练;记录切换时间与数据一致性校验结果。
清晨 6:10,我从机房里出来,玻璃门上反着自己通宵的脸。新一轮高峰到了,排行榜曲线在 Grafana 上变得温顺,战报流的 lag 贴着 X 轴走。风从港湾那头吹进来,天刚亮,我给同事发了一句,“今晚我们把 Redis 和内存持久化打通了,以后这两个口子稳了。”
后来我们把这套方案在新赛季、跨区大地图上复用,扩到 Redis Cluster 三主三从,接了边缘节点的近源缓存。玩家只会看到“更丝滑”的榜单和战报,而我们知道,那一夜的冷风,把系统稳住了。