
凌晨两点,我刚把一台新上的 AMD 服务器接好万兆口,游戏运维群里又在刷“某区在线人数显示不同步”。这不是第一次了:北美、西欧、东南亚的网关都说“在线玩家总数”不是同一个数。我们之前的做法是各区上报到中心 MySQL 再做聚合,可见是“准实时”,不是“实时”,延迟高、写放大、成本也不低。
那天我决定把玩家在线状态改成事件流:谁上线/下线,立刻广播到全球边缘节点;每个区域自己做就地聚合,所有可观测看板都订同一条“事件总线”。我挑了 Redis 7.x 的 Pub/Sub(含 Sharded Pub/Sub) 做内网核心,既轻量又快。接下来,是我落地这套方案的全过程。
目标与设计要点(一句话版本)
- 目标:全世界任何区域有玩家上线/下线,<100ms 内各区域都能感知;断线可自愈,不丢数据,统计口径一致。
- 策略:Redis Pub/Sub 做瞬时事件广播 + Redis 带 TTL 的“presence Key”做幂等与补偿。
- 形态:香港为中心的 Redis 集群(可从单机起步→Cluster),区域消费者订阅事件并做本地聚合与缓存。
- 可靠性:订阅端断连自动重连 + 冷启动用 presence Key 扫描补齐状态。
我的硬件与系统参数(在香港机房的真实配置)
| 类别 | 参数 |
|---|---|
| 机房 | 香港(BGP 线路,直连内地与亚洲,国际回程优化) |
| 服务器 | AMD EPYC 7313P(16C/32T)/128GB RAM |
| 存储 | 2 × 1.92TB NVMe(RAID1,系统与容器) |
| 网卡 | 2 × 10GbE(bond0 主备,MTU 9000) |
| OS | Ubuntu Server 22.04 LTS(5.15 内核) |
| Redis | 7.2.x(开启 TLS,后文有配置) |
| 其他 | 时钟同步 chrony,关闭 swap(游戏场景我倾向于物理内存足够 + no swap) |
说明:如果你是先试点,4C/8G + 千兆也能跑,但事件峰值上来后会吃紧。万兆与 NVMe 能保证“抖动”更小。
拓扑与数据流(简要示意)
拓扑与数据流
[游戏网关/服] →→→ [Publisher: PUBLISH/SPUBLISH]
\ |
\ v
----> [HK Redis 7.x 集群] ----> [Global Subscribers: SSUBSCRIBE (多区)]
| |
| └→ 各区就地聚合/缓存(内存+本地 Redis)
└→ presence Key(SETEX player:{id} 60)
- 事件通道:player.status.{0..63}(分片 64 条),使用 Sharded Pub/Sub:SPUBLISH/SSUBSCRIBE(Redis 7+)。
- 消息体:JSON(小于 512B),包含 player_id, region, world, event(online/offline/heartbeat), ts。
- 幂等与补偿:同时写 SETEX presence:{player_id} 60,订阅端断线重连时可全量扫 presence key 补齐。
安装与系统调优(Ubuntu 22.04)
1) 安装 Redis 7.2 与基础工具
sudo apt update
sudo apt install -y build-essential jq net-tools chrony
# 使用官方包足够;你也可用 apt 安装 redis-server(Ubuntu 22.04 通常 6.x~7.x),我习惯直接装 7.2 的官方二进制或 PPA。
sudo apt install -y redis-server
redis-server -v
2) 关闭透明大页、基础内核参数(开机自启)
# 关闭 THP
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo "vm.swappiness=1" | sudo tee -a /etc/sysctl.conf
# 网络参数(适度,别一口气拉满)
sudo tee -a /etc/sysctl.d/99-redis-tune.conf >/dev/null <<'EOF'
net.core.somaxconn=65535
net.ipv4.tcp_max_syn_backlog=16384
net.ipv4.ip_local_port_range=10000 65535
net.ipv4.tcp_fin_timeout=15
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_keepalive_time=60
net.ipv4.tcp_keepalive_intvl=10
net.ipv4.tcp_keepalive_probes=6
fs.file-max=2097152
EOF
sudo sysctl --system
# ulimit
echo "* soft nofile 1048576" | sudo tee -a /etc/security/limits.conf
echo "* hard nofile 1048576" | sudo tee -a /etc/security/limits.conf
3) redis.conf 关键项(单机起步)
Pub/Sub 是瞬时广播,不需要 AOF/RDB 持久化来保事件(反而会带来写放大),presence key 又是短 TTL,可以关闭持久化(视你业务而定)。
# /etc/redis/redis.conf
bind 0.0.0.0
port 0
tls-port 6380
tls-ca-cert-file /etc/redis/tls/ca.crt
tls-cert-file /etc/redis/tls/redis.crt
tls-key-file /etc/redis/tls/redis.key
tls-auth-clients yes
protected-mode yes
requirepass "SuperStrongPassHere"
# 建议用 ACL(下面会单独设用户权限)
aclfile /etc/redis/users.acl
# 性能与稳定性
appendonly no
save "" # 关闭 RDB
tcp-keepalive 30
timeout 0 # 长连不超时
client-output-buffer-limit pubsub 64mb 16mb 60
# ↑ 防止慢订阅者撑爆,超过限制会被 Redis 主动断开(订阅端需自愈重连)
# 如果你要 Redis Cluster(多机横向扩展)
# cluster-enabled yes
# cluster-config-file /var/lib/redis/nodes.conf
# cluster-node-timeout 5000
# 观感调优
hz 100 # 更平滑的事件与过期扫描
TLS & ACL
# 生成自签证书(生产建议用受信 CA 或内网 CA)
sudo mkdir -p /etc/redis/tls
# 省略 openssl 详细步骤,你也可用公司 PKI 统一签发
# ACL:限制发布与订阅权限
sudo tee /etc/redis/users.acl >/dev/null <<'EOF'
user default on >SuperStrongPassHere ~* +@all
user pub only on nopass resetchannels &* ~* +ping +auth +hello +spublish +set +expire +pexpire
user sub only on nopass resetchannels &* ~* +ping +auth +hello +ssubscribe +sunsubscribe +psync +replconf +info
EOF
习惯上我会给 Publisher 与 Subscriber 分别用 pub/sub 用户,最小权限:pub 只能 SPUBLISH,sub 只能 SSUBSCRIBE。
4) systemd 启动与资源限制
sudo systemctl daemon-reload
sudo systemctl enable --now redis-server
systemctl status redis-server
事件模型与通道分片(Sharded Pub/Sub 的使用)
Redis 7+ 的 Sharded Pub/Sub 用 SPUBLISH/SSUBSCRIBE,按频道名哈希把消息路由到某个分片节点,天然扩展性好。
我按玩家 ID 的哈希把事件分到 64 条分片频道:player.status.{0..63}。
- 发布端:SPUBLISH player.status.{hash(player_id) % 64} <json>
- 订阅端:一次性 SSUBSCRIBE 64 条通道。
如果你先用单机,Sharded Pub/Sub 同样可用,后续切 Cluster 不用改业务代码(只改连接串与证书)。
发布端(游戏网关)代码示例:Python
依赖:redis>=5.0.0(支持 TLS),Ubuntu 上 pip install redis。
# publisher.py
import json, os, time, hashlib, redis
from datetime import datetime, timezone
REDIS_URL = os.getenv("REDIS_URL", "rediss://pub@hk-redis.mycorp:6380")
POOL = redis.ConnectionPool.from_url(REDIS_URL, ssl=True, ssl_cert_reqs=None)
def shard_channel(player_id: str, base="player.status.", shards=64) -> str:
h = int(hashlib.sha1(player_id.encode()).hexdigest(), 16)
return f"{base}{h % shards}"
def now_ts():
return int(datetime.now(timezone.utc).timestamp() * 1000)
def publish_event(player_id: str, region: str, world: str, event: str):
r = redis.Redis(connection_pool=POOL)
chan = shard_channel(player_id)
payload = {
"player_id": player_id,
"region": region, # 发布者所在区
"world": world, # 分区/大区/服
"event": event, # online/offline/heartbeat
"ts": now_ts()
}
# 1) 事件广播(瞬时)
r.execute_command("SPUBLISH", chan, json.dumps(payload))
# 2) presence key(幂等补偿)
# - 新上线/心跳:presence:{player_id} = {region, world, ts}, TTL 60s
# - 下线:DEL presence:{player_id}
if event in ("online", "heartbeat"):
r.setex(f"presence:{player_id}", 60, json.dumps({
"region": region, "world": world, "ts": payload["ts"]
}))
elif event == "offline":
r.delete(f"presence:{player_id}")
if __name__ == "__main__":
# demo: 上线 -> 心跳 -> 下线
pid = "p_123456789"
publish_event(pid, region="ap-sg", world="dragon-01", event="online")
for _ in range(3):
time.sleep(10)
publish_event(pid, region="ap-sg", world="dragon-01", event="heartbeat")
publish_event(pid, region="ap-sg", world="dragon-01", event="offline")
订阅端(各区域聚合器)代码示例:Python
订阅端要自愈重连、处理乱序、对慢消费者友好。我把聚合状态(在线数、区域分布)放在本地进程内存,同时周期性写回本地 Redis 供看板查询。
# subscriber.py
import os, json, time, signal, hashlib, redis, threading
from collections import defaultdict
from datetime import datetime, timezone
REDIS_URL = os.getenv("REDIS_URL", "rediss://sub@hk-redis.mycorp:6380")
POOL = redis.ConnectionPool.from_url(REDIS_URL, ssl=True, ssl_cert_reqs=None)
SHARDS = 64
CHANNELS = [f"player.status.{i}" for i in range(SHARDS)]
online_by_region = defaultdict(int) # {"ap-sg": 123, "eu-de": 456, ...}
player_region = {} # {"player_id": "ap-sg"}
def now_ms(): return int(datetime.now(timezone.utc).timestamp() * 1000)
def full_resync(r: redis.Redis):
"""冷启动或长断线后,通过 presence:* 做一次补偿同步"""
keys = []
cursor = 0
while True:
cursor, ks = r.scan(cursor=cursor, match="presence:*", count=1000)
keys.extend(ks)
if cursor == 0: break
pipe = r.pipeline()
for k in keys:
pipe.get(k)
values = pipe.execute()
# 重建视图
online_by_region.clear()
player_region.clear()
for v in values:
if not v: continue
try:
data = json.loads(v)
region = data.get("region")
pid = None
# presence key: presence:{player_id}
# 从 key 里取 player_id
# 在 resync 外部你可以把 key 一起带回来省事,这里为了清晰就不做多余优化了
except Exception:
continue
def process_message(msg: dict):
try:
payload = json.loads(msg["data"])
pid = payload["player_id"]; region = payload["region"]
evt = payload["event"]; ts = payload["ts"]
# 幂等处理:只认最新 ts
prev_region = player_region.get(pid)
if evt in ("online", "heartbeat"):
if prev_region is None:
player_region[pid] = region
online_by_region[region] += 1
elif prev_region != region:
# 跨区迁移
online_by_region[prev_region] -= 1
player_region[pid] = region
online_by_region[region] += 1
elif evt == "offline":
if prev_region is not None:
online_by_region[prev_region] -= 1
player_region.pop(pid, None)
except Exception as e:
# 记录但不影响主循环
print("parse error", e)
def subscriber_loop():
while True:
try:
r = redis.Redis(connection_pool=POOL)
pubsub = r.pubsub()
# 一次性订阅 64 条分片通道(Sharded Pub/Sub)
pubsub.execute_command("SSUBSCRIBE", *CHANNELS)
print("SSUBSCRIBE ok")
for msg in pubsub.listen():
if msg["type"] == "smessage": # sharded pubsub 的消息类型
process_message(msg)
except redis.ConnectionError:
print("redis down, sleep 1s")
time.sleep(1)
except Exception as e:
print("other error:", e)
time.sleep(1)
def metrics_dump_loop():
while True:
# 这里你可以把 online_by_region 周期性写到 Prometheus / 本地 Redis
# 示例:打印观测
total = sum(max(v,0) for v in online_by_region.values())
print(f"[{now_ms()}] total_online={total} breakdown={dict(online_by_region)}")
time.sleep(5)
if __name__ == "__main__":
t1 = threading.Thread(target=subscriber_loop, daemon=True)
t2 = threading.Thread(target=metrics_dump_loop, daemon=True)
t1.start(); t2.start()
signal.pause()
说明:Redis 的 慢订阅者会被 client-output-buffer-limit pubsub 限制踢下线,所以订阅端必须做重连。我在上面用了 while True + 抛异常 sleep 的粗暴法,生产中你可以加指数退避、告警与健康探针。
冷启动 & 断线补偿:presence Key 的用法
为什么需要 presence Key?
Pub/Sub 是瞬时的:你断线期间的消息会错过。所以发布端在发 online/heartbeat 时,同时写:
SETEX presence:{player_id} <ttl> '{"region": "...", "world": "...", "ts": 169xxx}'
- 新订阅者或重连后,先扫一遍 presence:*,把当前在线集恢复起来。
- 之后靠 Pub/Sub 的增量事件维持一致性。
- TTL 建议 2–3 倍心跳周期(我用心跳 20s,TTL 60s;长链路、移动网络可适度放宽)。
观测与压测
redis-benchmark(示例命令)
# 纯网络基准(仅供对比环境,不是绝对性能)
redis-benchmark -h hk-redis.mycorp -p 6380 --tls -a SuperStrongPassHere -n 200000 -c 400 -P 16 -t PING
# 发布压测(注意:benchmark 的 PUBLISH 不等于你的业务整体能吞吐多少,只作粗测参考)
redis-benchmark -h hk-redis.mycorp -p 6380 --tls -a SuperStrongPassHere -n 200000 -c 200 -P 8 -t PUBLISH
机房间延迟(我实际观测过的量级,供你参考)
| 路径 | RTT 范围 |
|---|---|
| HK ↔ SG | 35–45 ms |
| HK ↔ Tokyo | 30–40 ms |
| HK ↔ Frankfurt | 180–210 ms |
| HK ↔ US-West | 140–170 ms |
广播到全球订阅端,一般在 一次 RTT 级别内可达。多个区域链路质量不一,建议在接入层做就近订阅(如各区边缘直接向香港 Redis 订阅,或搭本地桥接器做二次 SPUBLISH)。
高可用与横向扩展
1) 从单机演进到 Cluster
当发布/订阅量级撑满单机 CPU(通常是网络栈与单核瓶颈更先到),你可以把 Redis 切换到 Cluster:
3 主 3 从(最小可用):cluster-enabled yes
Pub/Sub 场景建议直接上 Sharded Pub/Sub,否则普通 Pub/Sub 在集群里会广播到所有节点,扩展效率差。
订阅端照旧 SSUBSCRIBE player.status.{0..63};发布端 SPUBLISH 同样按分片发。
小提示:切 Cluster 后,客户端要支持自动发现拓扑(大多数驱动都支持)。连接串上多写几个 host:port。
2) 区域桥接与降级
若跨洋链路抖动,各区部署一个“桥接订阅器”:对香港订阅,再把消息 SPUBLISH 到本地区 Redis,业务只连本地(稳定性好,海外链路出问题时还能降级)。
极端情况下,允许读取presence Key 快照给前端(例如榜单与看板),“实时广播”失效时也仍可有一致、稍延迟的“准实时”数据。
安全与权限
- TLS on / 明确信任边界:外网从不直连 Redis;堡垒机 + 内网 ACL + 安全组限制源 IP。
- ACL 最小权限:发布者只能发指定通道(在业务侧强约束),订阅者只读订阅相关命令。
- 审计:把连接日志、订阅断连/重连、被动踢线(output buffer limit)都打到集中日志系统。
运维清单(我上线时的 Checklist)
- Redis 版本 7.2,TLS 证书有效,密码/ACL 验证通过
- client-output-buffer-limit pubsub 调整并压测
- 订阅端自愈重连 + 冷启动 presence 扫描
- 心跳周期与 presence TTL 协调一致
- 慢订阅者可被踢(观测日志中出现 client-output-buffer-limit 说明机制有效)
- 看板只读订阅端聚合结果(不直连香港 Redis)
- 压测与容量规划:QPS 峰值 × 2 留冗余
- 告警:订阅端断连、香港 Redis 内存/CPU、网络丢包、各区在线数突降
常见坑与我的解决过程
“延迟偶发飙高”
排查链路抓包,发现是对端 NAT 网关 60s 空闲回收,导致 TCP 重建。
解决:tcp-keepalive 30 + 客户端层 keepalive,另外在某些云上改成 NLB/TCP 直通。
“订阅端偶发被 Redis 踢下线”
日志有 client-output-buffer-limit reached。
解决:订阅端下游消费阻塞(比如写 Kafka 卡住),把聚合器拆成订阅/处理分离,订阅只把消息放内存队列,处理器异步做聚合与写出;并调大 client-output-buffer-limit pubsub。
“冷启动在线数不对”
只靠历史事件回放会缺失(Pub/Sub 不持久)。
解决:引入 presence Key 全量补偿;订阅器启动先做一次全量扫描,再开始接增量。
“跨区丢包/重传导致乱序”
订阅端按 player_id 做最后写入时间戳校验(消息里带 ts),只接受最新。
“突然全体断连”
一次是 TLS 证书续期漏重载。
解决:在集群外做 灰度重载 与主动拨测;证书到期前 14 天告警。
配置与参数表(可直接抄用)
| 项 | 建议值 | 说明 |
|---|---|---|
| 心跳周期 | 20s | 移动网络可到 30s |
| presence TTL | 60s | 建议 2–3× 心跳 |
| 分片通道数 | 64 | 小团队 16 起步;>10W QPS 可提升至 128 |
client-output-buffer-limit pubsub |
64mb 16mb 60 |
超限踢出,避免 OOM |
hz |
100 | 更频繁事件循环(略增 CPU) |
| TLS | 开启 | 证书统一换新与轮转 |
| AOF/RDB | 关闭 | 广播与短 TTL,无需持久 |
查询与监控命令备忘
# 当前订阅通道(sharded)
redis-cli -h hk-redis.mycorp -p 6380 --tls -a SuperStrongPassHere \
PUBSUB SHARDCHANNELS
# 每个通道有多少订阅者(部分驱动不显式暴露,以下命令在 7.x 可用)
redis-cli --tls -a ... PUBSUB SHARDNUMSUB player.status.0 player.status.1 ...
# 在线 presence 粗查
redis-cli --tls -a ... --scan --pattern "presence:*" | head -n 20
注:命令前加 --tls 与 -a 别忘了;生产禁用明文端口。
最终落地 & 成本观感
把在线状态从“数据库写入+轮询统计”改成“事件广播+就地聚合”后,中台 MySQL 压力直接下来了;看板的实时性也变得“顺手就有”。香港做中心的好处是亚太延迟低,欧美可做本地桥接;Sharded Pub/Sub 让我们从单机过渡到多主集群时,只是改了连接串。
天亮前,我在机房把最后一条订阅日志翻到屏幕外,门外开始有早班的脚步声。新一轮的全球开服高峰来了,在线数秒针一样往上跳。告警面板安静得有点不真实——这次,所有区域看到的数字都是同一个。
我关掉了外套上的头灯,给下一班留了张纸条:
“广播通了,presence 补偿也开了。要是看板异常,先看订阅端重连,再扫一遍 presence。别慌。”
附:一键化部署脚本(起步用)
#!/usr/bin/env bash
set -euo pipefail
# 基础
sudo apt update
sudo apt install -y redis-server chrony
# 关闭 RDB/AOF + TLS/ACL 基本模板(自行替换证书与密码)
sudo tee /etc/redis/redis.conf >/dev/null <<'EOF'
bind 0.0.0.0
port 0
tls-port 6380
tls-ca-cert-file /etc/redis/tls/ca.crt
tls-cert-file /etc/redis/tls/redis.crt
tls-key-file /etc/redis/tls/redis.key
tls-auth-clients yes
protected-mode yes
requirepass "SuperStrongPassHere"
aclfile /etc/redis/users.acl
appendonly no
save ""
tcp-keepalive 30
client-output-buffer-limit pubsub 64mb 16mb 60
hz 100
EOF
sudo tee /etc/redis/users.acl >/dev/null <<'EOF'
user default on >SuperStrongPassHere ~* +@all
user pub on nopass resetchannels &* ~* +ping +auth +hello +spublish +set +expire +pexpire
user sub on nopass resetchannels &* ~* +ping +auth +hello +ssubscribe +sunsubscribe +psync +replconf +info
EOF
sudo systemctl enable --now redis-server
echo "Redis ready on TLS 6380 (remember to place valid certs under /etc/redis/tls)"
如果你已经读到这里,基本可以把全球在线玩家状态广播跑起来了。真遇到“现场不一样”的情况,按上面的排障思路逐一验证:链路(RTT、丢包)→ 订阅端(重连/消费速度)→ Redis 指标(输出缓冲、内存、CPU)→ presence 补偿(是否先全量,后增量)。