网络游戏如何在香港服务器的 Ubuntu 环境中,结合 Redis 缓存解决排行榜与实时对局性能瓶颈

凌晨 2 点,我们的部署在香港机房的 PvP 实时对局刚开赛季,在线从 2 万涨到 5 万。Grafana 上 RTT 曲线开始微微抖,匹配队列从几百飙到几千。更要命的是排行榜写入抖成锯齿,偶发延迟从 5ms 拉到了 80ms——玩家投诉“结算后排名晚了半分钟才刷新”。
排查很快指向两处热点:
- 排行榜写入(频繁 ZADD / ZINCRBY),并发冲突 + AOF 落盘抖动。
- 匹配服的实时状态同步(房间心跳、事件广播),在高峰时段出现连接池耗尽、Nagle 影响小包延迟。
这篇文章就是我如何在 Ubuntu + Redis 的栈上,一步步把它们拉回稳定线的完整过程。
1. 现场环境与约束
1.1 目标
- 排行榜:全球榜/分区榜/赛季榜,写入 P99 < 10ms,查询 TOP-K P99 < 15ms,赛季结算零丢失。
- 实时对局:匹配/房间状态广播端到端 P99 < 50ms(网内),峰值并发 8 万连接。
- 架构可横向扩展,支持无损灰度、快速回滚。
1.2 机房与硬件(单机规格)
| 组件 | 型号/参数 |
|---|---|
| 机房 | 香港,双路由,电信/联通/移动直连 |
| 服务器 | 2 × Intel® Xeon® Silver 4410Y(12C/24T ×2,共 24C/48T) |
| 内存 | 256 GB DDR4 |
| 系统盘 | 2 × 480 GB SATA SSD(RAID1) |
| 数据盘 | 2 × 1.92 TB NVMe(RAID1 via mdadm,专供 AOF/日志) |
| 网卡 | 10GbE(Intel X710),机内互联走 10G TOR 交换机,支持 Jumbo Frame |
| OS | Ubuntu Server 22.04.4 LTS(5.15 内核) |
| Redis | 7.2.x(官方 APT,禁 THP,AOF everysec) |
注:排行榜和实时对局拆到不同 Redis 集群,避免关键路径相互扰动。
2. 架构 & 数据流设计
2.1 拓扑(文字版)
[客户端] ⇄ [网关] ⇄ [匹配/对局服务] ⇄ (Pub/Sub | Streams) ⇄ [Redis-RT 集群]
↘ (ZSET, HASH) ↘
[业务服务/结算] ⇄ (ZADD/ZINCRBY/ZRANGE) ⇄ [Redis-Rank 集群]
- Redis-RT(RealTime):承载房间心跳、事件广播、短期会话(TTL),读写高并发、可丢弃部分瞬时数据。
- Redis-Rank(Leaderboard):承载赛季榜/日榜/区服榜(ZSET 为主),强一致性需求更高,关注持久化与重放。
2.2 关键策略
- 一服务一集群:实时与排行榜分离,避免 AOF/重写对实时延迟的影响。
- 有界内存 + 明确 TTL:RT 集群设定 maxmemory + 逐出策略,关键榜单 noeviction,防止写失败。
- 热点拆分:大区、游戏模式、赛季 ID 划分 key 前缀,必要时做一致性哈希分片。
3. Ubuntu 层面的系统优化
3.1 基础依赖
sudo apt update && sudo apt -y install build-essential net-tools iftop htop jq numactl iotop
sudo apt -y install redis-server # 7.2.x
3.2 关闭透明大页(THP)
# 临时
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 永久:/etc/rc.local(Ubuntu 默认无,手动创建)
sudo bash -c 'cat > /etc/rc.local <<EOF
#!/bin/bash
echo never > /sys/kernel/mm/transparent_hugepage/enabled
exit 0
EOF'
sudo chmod +x /etc/rc.local
3.3 Sysctl 网络/内存参数
结合 10GbE,避免 TIME_WAIT 堆积与小包延迟;不使用已废弃的 tcp_tw_recycle。
/etc/sysctl.d/99-game.conf
vm.overcommit_memory = 1
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_fin_timeout = 30
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_sack = 1
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
fs.file-max = 2000000
net.ipv4.ip_local_port_range = 20000 65535
应用:
sudo sysctl --system
3.4 NIC 调优与 Jumbo Frame(可选)
# 如果交换机已启用 MTU 9000,全链路一致再打开
sudo ip link set dev eno1 mtu 9000
sudo ethtool -K eno1 gro on gso on tso on rx on tx on
4. Redis 集群规划与关键配置
4.1 拆分与角色
| 集群 | 节点 | 角色 | 用途 |
| Redis-RT | 3 主 3 从 | Cluster 模式 | Pub/Sub、Streams、会话、房间状态(短 TTL) |
| Redis-Rank | 3 主 3 从 | Cluster 模式 | 排行榜 ZSET、玩家分数、元数据(持久化) |
单机演示可先用哨兵 + 主从;正式服建议 Cluster,减少单分片热度与 failover 影响面。
4.2 redis.conf 关键项(示例:Rank 集群)
# /etc/redis/redis.conf
bind 0.0.0.0
port 6379
protected-mode yes
requirepass <REDACTED_STRONG_PASS>
masterauth <REDACTED_STRONG_PASS>
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 3000
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite yes
aof-use-rdb-preamble yes
save 900 1 300 10 60 10000
maxmemory 180gb
maxmemory-policy noeviction
tcp-keepalive 60
latency-monitor-threshold 10
策略说明
- Rank 集群采用 noeviction,防止关键写失败;容量靠观测 + 扩分片。
- 打开 aof-use-rdb-preamble 降低加载时延;重写期间用 no-appendfsync-on-rewrite 降抖。
- RT 集群改用 maxmemory-policy allkeys-lru 或 allkeys-lfu,并给所有键明确 TTL。
4.3 systemd override,提升连接上限
sudo systemctl edit redis-server
写入:
[Service]
LimitNOFILE=1000000
TasksMax=infinity
应用:
sudo systemctl daemon-reload
sudo systemctl restart redis-server
5. 数据模型与 Key 设计
5.1 命名约定
# 排行榜
rank:{season}:{region}:{mode} -> ZSET (member=playerId, score=MMR or points)
rank:profile:{playerId} -> HASH (name, avatar, region, lastScore)
rank:top:{season}:{region}:{mode} -> LIST (cache of topK, warm with TTL)
# 实时对局/匹配
rt:match:pool:{region}:{mode} -> ZSET (member=sessionId, score=ELO, score as timestamp for aging)
rt:room:{roomId} -> HASH (host, mode, members, ts) (TTL 5m sliding)
rt:events:{roomId} -> STREAM (maxlen ~10k)
rt:sess:{sessionId} -> STRING (playerId) (TTL by heartbeat)
5.2 内存预算(示例)
| 项 | 估算 |
| 全球榜 5 百万玩家 ZSET | ~1.2GB(member id 8B、score 8B、编码压缩后估) |
| 赛区/模式拆分(10 倍) | 热门分片各 2–3 百万,占 2–3GB |
| profile HASH | 300B/人 × 5M ≈ 1.5GB(可冷热分层,落 MySQL/ES) |
| RT 会话 | 8 万并发 × 200B ≈ 16MB(有 TTL) |
经验:排行榜热分片优先放高性能分片,长尾分片可降低副本数。
6. 代码实现(Go 与 Node.js 片段)
6.1 Go(go-redis v9)——排行榜写入与 Top-K 查询
import (
"context"
"time"
goredis "github.com/redis/go-redis/v9"
)
var rdb = goredis.NewClusterClient(&goredis.ClusterOptions{
Addrs: []string{"10.0.0.11:6379","10.0.0.12:6379","10.0.0.13:6379"},
Password: os.Getenv("REDIS_PASS"),
ReadOnly: true,
RouteRandomly:true,
PoolSize: 1024,
MinIdleConns: 256,
DialTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
})
type ScoreUpdate struct { PlayerID string; Score float64 }
func UpdateScore(ctx context.Context, season, region, mode string, u ScoreUpdate) error {
key := fmt.Sprintf("rank:%s:%s:%s", season, region, mode)
pipe := rdb.Pipeline()
pipe.ZAdd(ctx, key, goredis.Z{Score: u.Score, Member: u.PlayerID})
pipe.Expire(ctx, key, 90*24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
func TopK(ctx context.Context, season, region, mode string, k int64) ([]string, error) {
key := fmt.Sprintf("rank:%s:%s:%s", season, region, mode)
ids, err := rdb.ZRevRange(ctx, key, 0, k-1).Result()
return ids, err
}
Lua 原子更新(避免并发丢写/越权)
-- file: update_score.lua
-- KEYS[1]=rank key, ARGV[1]=playerId, ARGV[2]=newScore, ARGV[3]=ttlSec
local exists = redis.call('ZSCORE', KEYS[1], ARGV[1])
if exists then
if tonumber(ARGV[2]) < tonumber(exists) then
return exists -- 不降低分(可按业务改)
end
end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return ARGV[2]
Go 调用:
script := goredis.NewScript(string(os.ReadFile("update_score.lua")))
res, err := script.Run(ctx, rdb, []string{key}, playerId, score, 90*24*3600).Result()
6.2 Node.js(ioredis)——匹配池 & 实时事件
import Redis from 'ioredis'
const rt = new Redis.Cluster([
{ host: '10.0.0.21', port: 6379 },
{ host: '10.0.0.22', port: 6379 },
{ host: '10.0.0.23', port: 6379 },
], { redisOptions: { password: process.env.REDIS_PASS, maxRetriesPerRequest: 1 } })
// 带时间衰减的匹配池(score = ELO +/- age)
export async function enlist(sessionId:string, elo:number) {
const key = `rt:match:pool:apac:pvp`
const agePenalty = Date.now()/1000/600 // 每 10 分钟 -1
await rt.zadd(key, elo - agePenalty, sessionId)
await rt.expire(key, 60)
}
// 房间事件使用 Streams,限制长度,消费者组做服务水平扩展
export async function pushEvent(roomId:string, payload:Record<string,any>) {
const key = `rt:events:${roomId}`
return rt.xadd(key, 'MAXLEN', '~', 10000, '*', 'd', JSON.stringify(payload))
}
// 订阅(消费者组)
export async function consume(roomId:string, group='g1', consumer='c-'+process.pid) {
const key = `rt:events:${roomId}`
try { await rt.xgroup('CREATE', key, group, '0', 'MKSTREAM') } catch {}
while(true) {
const streams = await rt.xreadgroup('GROUP', group, consumer, 'BLOCK', 2000, 'COUNT', 100, 'STREAMS', key, '>')
// 处理并 XACK
}
}
实战建议:房间事件用 Streams 替代裸 Pub/Sub,可回放、可追踪、可做水平扩展;纯广播可仍用 Pub/Sub。
7. 部署流程(生产可落地)
7.1 Redis 安装与目录
sudo apt install -y redis-server
sudo usermod -aG redis $USER
sudo mkdir -p /data/redis/{rank,rt}/{aof,rdb,log}
sudo chown -R redis:redis /data/redis
7.2 挂载 NVMe 给 AOF
/etc/fstab 示例:
/dev/nvme0n1p1 /data ext4 noatime,nodiratime,discard 0 1
7.3 Cluster 初始化(以 Rank 为例)
# 分别在 3 台主机上启动 redis-server,并开放 6379/TCP + 16379/TCP(cluster bus)
redis-cli -a $PASS --cluster create \
10.0.0.11:6379 10.0.0.12:6379 10.0.0.13:6379 \
10.0.0.14:6379 10.0.0.15:6379 10.0.0.16:6379 \
--cluster-replicas 1
7.4 防火墙与安全
sudo ufw allow from 10.0.0.0/16 to any port 6379 proto tcp
sudo ufw allow from 10.0.0.0/16 to any port 16379 proto tcp
sudo ufw enable
7.5 监控与告警
- redis_exporter + Prometheus:跟踪 latency_spike, instantaneous_ops_per_sec, used_memory, aof_current_rewrite_time_sec。
- Grafana 面板:分片维度 QPS/RT、Top 热 key、网络带宽、AOF fsync 耗时。
告警建议:
| 指标 | 阈值(示例) | 动作 |
| cmd latency p99 | > 15ms(Rank)/ > 30ms(RT) 持续 5 分钟 | 降级展示/扩容分片 |
| used_memory_ratio | > 0.75 | 扩容 or 清理长尾榜单 |
| aof_rewrite_in_progress | > 600s | 排查磁盘 IO,考虑调大 auto-aof-rewrite-percentage |
| rejected_connections | > 0 | 增加 maxclients/连接池 |
8. 压测方法与结果
8.1 基准工具
redis-benchmark -h 10.0.0.11 -p 6379 -a $PASS -t zadd,zrange \
-n 500000 -c 512 -P 16 --cluster
8.2 结果(实测样本,单集群 3 主 3 从)
| 场景 | QPS(平均) | P95 延迟 | P99 延迟 | 备注 |
| ZADD(排行榜写入) | 240k ops/s | 5.8ms | 9.7ms | AOF everysec + NVMe |
| ZRANGE TOP-100 | 310k opEs/s | 4.1ms | 7.2ms | 使用 WITHSCORES & pipeline |
| Streams XADD | 380k ops/s | 2.8ms | 6.5ms | MAXLEN~ 控制内存 |
| Pub/Sub 广播 | 800k msgs/s | 1.5ms | 3.8ms | 小包,10Gb |
实测在 AOF 重写窗口有 5%—10% 抖动,通过将 AOF 放独立 NVMe、no-appendfsync-on-rewrite yes 明显改善。
9. 常见坑与现场排障记录
AOF 重写导致抖动:
现象:排行榜写入 P99 跳到 60ms。
定位:INFO Persistence 中 aof_rewrite_in_progress:1,IO 利用率 95%。
解法:AOF 放独立 NVMe;开启 aof-use-rdb-preamble yes;调整 auto-aof-rewrite-percentage 100,auto-aof-rewrite-min-size 2gb;业务侧启用写入缓存(bulk pipeline)。
连接池耗尽:
现象:匹配服突发 429/超时。
定位:服务端连接数逼近 maxclients,客户端池默认过小。
解法:服务端 maxclients 200000;客户端 PoolSize ≥ 峰值并发 × 1.2;启用 MinIdleConns 保温。
Jumbo Frame 链路不一致:
现象:偶发丢包、重传增加。
定位:一台 TOR 交换机端口 MTU 1500,其余 9000。
解法:要么全 1500,要么全 9000。混配=灾难。
DNS 查询阻塞:
现象:客户端偶发卡顿 100ms。
定位:库默认 dial 反查主机名,/etc/hosts 缺少映射。
解法:全部使用 IP;/etc/hosts 添加内网解析;Go 进程设置 GODEBUG=netdns=go 观察。
NTP 漂移导致赛季结算异常:
现象:跨机时间差 600ms,导致 TTL 与结算触发错位。
解法:Chrony 同步,设置 makestep,监控 NTP drift 告警。
热 key:
现象:单个模式的 TOP-K 访问远超其他分片。
解法:预热 rank:top:* 到 LIST,前端优先读缓存;必要时把该模式拆到独立 hash slot(自定义 tag {})。
Lua 脚本阻塞:
现象:复杂脚本执行 50ms,阻塞单线程。
解法:脚本只做原子校验与少量操作,复杂逻辑挪到业务;超时保护 + 幂等设计。
10. 发布、灰度与回滚
双写灰度:新集群先只读(影子),对小流量做双写校验,校验通过再切读。
有序回滚:保留旧集群 24 小时,AOF 继续写入,切换失败时通过 DNS/IPVS 反向回切。
数据迁移:用 redis-cli --cluster reshard 控制每次迁移 slot 数量与速率(业务低谷执行)。
11. 安全与合规
内网 ACL + requirepass 强口令,分环境区分;只开放内网访问。
禁止在日志/告警中打印完整连接串与密码。
访问凭据统一接入 Vault/KMS,应用用短期 token 动态换取。
12. 运维脚本片段
12.1 延迟巡检
#!/usr/bin/env bash
h=${1:-10.0.0.11}
redis-cli -h $h -a $PASS --latency | awk '{print strftime("%F %T"), $0}' >> /var/log/redis-latency.log
12.2 热 Key Top-N
redis-cli -a $PASS --hotkeys | head -n 50
12.3 快速导出 Top-1000(赛季榜)
redis-cli -a $PASS -h 10.0.0.11 ZREVRANGE rank:2025s1:apac:pvp 0 999 WITHSCORES > top1000.txt
赛季第二周,我又站在 7 楼过道上,风还是那么冷。但曲线稳了:排行榜写入 P99 9ms,实时事件 P99 40ms。玩家在论坛里说“结算几乎秒更”。那晚我把 aof_rewrite_in_progress 盯到 0,合上笔记本,给机房的风扇点了个赞。
如果你也正在把游戏部署在香港的 Ubuntu 服务器上,且被排行榜与实时对局的性能卡住了,希望这份实操手记能让你少走几步弯路。参数不是教条,思路才是:拆分职责、控制抖动、观测先行、容量留白、灰度发布。剩下的,就交给你和你的曲线了