上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-10 17:50 阅读量:726


凌晨 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 服务器上,且被排行榜与实时对局的性能卡住了,希望这份实操手记能让你少走几步弯路。参数不是教条,思路才是:拆分职责、控制抖动、观测先行、容量留白、灰度发布。剩下的,就交给你和你的曲线了

目录结构
全文