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

MMO类游戏如何在香港服务器部署微服务化架构,降低跨区组队延迟?

发布人:Minchunlin 发布时间:2025-09-20 08:58 阅读量:986
香港机房的监控大屏上,华北、华东、华南、东南亚四个大区的“跨区组队成功时间”曲线像心电图一样抖。玩家在世界频道里炸锅:“跨区组队卡成PPT!”——而这正是我们把核心集群搬到香港、做微服务化的初衷之一:让异地玩家能在“中立枢纽”里更快遇到队友、更稳地开打。
 
我站在42U机柜前,怀疑是网络栈的问题:不是带宽不够,是尾延迟和路径不一致作祟。接下来两周,我和同事把架构从入口到数据一致性全盘“翻修”,一层层把延迟压下去。这篇手记,就是那次改造的完整记录:从硬件、内核、Kubernetes、服务拆分、网关、数据层、消息总线、到观测与回滚策略,以及我们踩过的坑与现场解法。

目标与设计要点

核心目标:把“跨区组队”从平均 2.1s / P95 4.8s 压到平均 < 800ms / P95 < 1.5s;稳定期望 < 1% 失败重试。
 
关键思路:
 
以香港为中心枢纽:路由对中国内地与东南亚延迟较平衡,便于做“队伍锚定”(party pinning)。
 
微服务拆分 + 低开销通信:团队、匹配、会话、网关、地图实例等服务分层;游戏内高频链路走 QUIC/UDP 或直连,控制面走 gRPC/HTTP2。
 
状态局部化:队伍与会话状态尽量聚集在同一 AZ/Node 池;跨区只同步必要字段。
 
入口就近 + 智能锚定:玩家先就近接入(CN/SEA POP),跨区组队时把队伍锚定在“加权最短总延迟”的 Region(多数落在 HK),对剩余成员做单跳回源或就近代理。

物理与网络:我们在香港的“骨架”

机柜与服务器清单(实配样例)

角色 型号/CPU 内存 本地盘 网卡 用途
Gateway/Edge 节点 x4 AMD EPYC 7543P (32C) 128GB 2× 3.84TB U.2 NVMe 2×25GbE SFP28 Envoy/QUIC 入口、HTTP2/gRPC LB
Stateless 微服务节点 x8 EPYC 7452 (32C) 256GB 2× 1.92TB NVMe 2×25GbE 匹配/队伍/会话/支付等
Stateful 节点 x6 Xeon Silver 4314 (16C) 256GB 4× 3.84TB NVMe 2×25GbE Redis Cluster、TiDB/PD/TiKV、NATS
游戏实例节点 x12 EPYC 7F52 (16C@高频) 128GB 1× 1.92TB NVMe 2×25GbE 地图/战斗服(CPU频率敏感)
观测/日志 x2 Xeon 4210 128GB 2× 7.68TB NVMe 2×10GbE Prometheus、Loki、ClickHouse
 
ToR 交换机:双机 25G 汇聚(MLAG),上联双运营商(HKT/NTT),BGP 双栈,机柜间短距 DAC。
 
三网隔离:VLAN10 管理/带外、VLAN20 存储复制、VLAN30 业务/南北向;K8s 覆盖网使用 Cilium eBPF,隧道 MTU 1450。

OS 基线(CentOS 7)与内核/网络栈调优

用户环境历史包袱重,我们坚持用 CentOS 7,但把内核升级与网络栈调到“能跑近实时”的水平。
 
升级内核(ELRepo):为 QUIC/BBR/eBPF 留出空间
 
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
yum --enablerepo=elrepo-kernel install -y kernel-ml kernel-ml-devel
grub2-set-default 0 && reboot
 
网络栈与队列
 
cat >/etc/sysctl.d/99-game-lowlatency.conf <<'EOF'
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.ipv4.udp_mem=134217728 134217728 268435456
net.ipv4.udp_rmem_min=131072
net.ipv4.udp_wmem_min=131072
net.ipv4.tcp_fastopen=3
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_max_syn_backlog=4096
net.ipv4.ip_local_port_range=10000 65000
net.netfilter.nf_conntrack_max=2621440
net.netfilter.nf_conntrack_udp_timeout=60
net.netfilter.nf_conntrack_udp_timeout_stream=180
fs.file-max=10485760
EOF
sysctl --system
 
NIC 队列/中断亲和
 
# 环形缓冲 & Offload
ethtool -G eth0 rx 4096 tx 4096
ethtool -K eth0 tso on gso on gro on rx on tx on
# RPS/RFS 配置略,核心思路:将网卡队列与 CPU NUMA 对齐,关键网关节点关闭 irqbalance,手动绑核。
 
时间同步:所有节点使用 chrony 同步到机房 PTP/本地 NTP,延迟指标严禁因时钟漂移失真。

Kubernetes 集群落地(kubeadm + containerd + Cilium)

我们不用笨重的服务网格默认栈,Linkerd(仅控制面 mTLS)+ Envoy(数据面入口) 的组合,延迟开销更可控。
 
Containerd
 
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y containerd.io
mkdir -p /etc/containerd && containerd config default >/etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl enable --now containerd
 
kubeadm 初始化(示例)
 
# kubeadm-config.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.28.7
networking:
  podSubnet: "10.244.0.0/16"
  serviceSubnet: "10.96.0.0/12"
controllerManager:
  extraArgs:
    bind-address: "0.0.0.0"
scheduler:
  extraArgs:
    bind-address: "0.0.0.0"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
eventRecordQPS: 0
systemReserved:
  cpu: "500m"
kubeReserved:
  cpu: "1000m"

kubeadm init --config kubeadm-config.yaml
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
 
Cilium(eBPF,直接路由,MTU 1450)
 
helm repo add cilium https://helm.cilium.io
helm install cilium cilium/cilium --version 1.15.5 \
  --namespace kube-system \
  --set tunnel=disabled \
  --set ipv4NativeRoutingCIDR=10.244.0.0/16 \
  --set bpf.masquerade=true \
  --set mtu=1450
 
MetalLB + BGP 到 ToR(对外暴露 L4 VIP,不额外引入云 LB 开销)
 
# metallb ConfigMap 摘要
address-pools:
- name: prod-pool
  protocol: bgp
  addresses: ["203.0.113.100-203.0.113.120"]

架构总览

入口层(HK):Envoy Gateway(支持 QUIC/UDP、HTTP/2)、全球 GeoDNS(Route53/NS1)做延迟路由。
 
接入 POP(CN/SEA):轻量边缘代理(仅做就近 TLS/QUIC 终止 + 单跳回源 HK)。
 
微服务层(gRPC):
  • auth-svc、session-svc(Redis Cluster)
  • presence-svc(Redis PubSub + NATS)
  • party-svc(队伍/锚定算法)
  • matchmaking-svc(匹配、合服策略)
  • chat-svc、inventory-svc、payment-svc
实例层:zone-server/battle-server(CPU 高频,UDP/QUIC 实时帧)
 
数据层:TiDB(玩家/经济)、Redis Cluster(会话/队伍/热点)、ClickHouse(行为分析)
 
消息:NATS(控制面)、Kafka(日志/埋点)
 
观测与治理:Prometheus/Grafana、Tempo、Loki、Argo Rollouts、OPA/Gatekeeper

入口:Envoy(QUIC + gRPC)的低开销配置

# envoy.yaml(片段)
static_resources:
  listeners:
  - name: quic_ingress
    address: { socket_address: { address: 0.0.0.0, port_value: 443 } }
    udp_listener_config: { quic_options: { quic_protocol_options: {} } }
    filter_chains:
    - filters:
      - name: envoy.filters.network.quic_server
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.quic.server.v3.QuicServerCodecConfig
          upstream_protocols:
            - HTTP2
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          http2_protocol_options: { max_concurrent_streams: 100000 }
          route_config:
            virtual_hosts:
            - name: game
              domains: ["game.example.hk"]
              routes:
              - match: { prefix: "/party.v1.Party" }
                route: { cluster: party-svc }
              - match: { prefix: "/matchmaking.v1" }
                route: { cluster: mm-svc }
  clusters:
  - name: party-svc
    type: EDS
    connect_timeout: 0.2s
    http2_protocol_options: {}
    lb_policy: LEAST_REQUEST
  - name: mm-svc
    type: EDS
    connect_timeout: 0.2s
    http2_protocol_options: {}
    lb_policy: LEAST_REQUEST
 
要点:入口启用 QUIC,控制面走 gRPC HTTP/2,下游到上游保持 HTTP/2,避免协议栈抖动;LB 用 LeastRequest 抑制热点。

服务拆分与“队伍锚定”算法

“锚定”怎么做?
 
跨区组队时,设每位玩家到候选 Region(HK、SG、GZ)RTT 分别为 r_i(region),我们求解最小化加权总和的 Region,同时考虑服务器拥塞系数 ρ 与节点可用区约束:
 
 
w_i 默认为 1,可对队长/主播加权。
 
α 用于避免在低延迟但拥塞的 Region 上继续叠加队伍。
 
选定 region 后,party-svc 将 队伍状态 固定在该 region 的 Redis hash,presence-svc 通知成员迁移或保持连接(若使用边缘代理,成员只需切换回源目标,链路不中断)。
 
Go 版 party-svc 核心片段(gRPC)
// party.proto 省略
type PartyService struct {
  redis *redis.ClusterClient
  nats  *nats.Conn
}

func (p *PartyService) CreateOrJoin(ctx context.Context, req *pb.JoinReq) (*pb.JoinResp, error) {
  // 1. 汇总每个成员的 RTT 探针(由接入网关持续上报)
  rtts := fetchRTTs(ctx, req.MemberIDs)

  // 2. 计算最优 region
  best, score := selectRegion(rtts, currentLoad())

  // 3. 在 best region 的 Redis hash 写入队伍锚定
  key := fmt.Sprintf("party:%s", req.PartyId)
  err := p.redis.HSet(ctx, key, "region", best, "members", strings.Join(req.MemberIDs, ",")).Err()
  if err != nil { return nil, status.Error(codes.Internal, err.Error()) }
  p.redis.Expire(ctx, key, 30*time.Minute)

  // 4. 通知 presence 进行就地迁移
  notifyPresence(p.nats, req.PartyId, best)


  return &pb.JoinResp{Region: best, Score: score}, nil
}
 
数据层:Redis Cluster + TiDB 的组合拳
 
Redis Cluster(6主6从):用于会话/队伍/热点 KV,hashslot 一致性将 party:xxx 定位到同一 shard,尽量与计算节点同 AZ。
 
cluster-require-full-coverage no 防止单分片短故障全盘不可写。
 
repl-backlog-size 512mb 缓冲跨 AZ 抖动。
 
TiDB:玩家/交易与一致性需求高的表,tidb_ddl_reorg_batch_size 根据 NVMe IOPS 调参,tikv_gc_life_time 在活动高峰拉长,避开 GC 抢 IO。

Kubernetes 部署片段(反亲和 + 资源保障)

apiVersion: apps/v1
kind: Deployment
metadata: { name: party-svc, labels: { app: party } }
spec:
  replicas: 8
  selector: { matchLabels: { app: party } }
  template:
    metadata:
      labels: { app: party, tier: stateless }
      annotations:
        linkerd.io/inject: enabled
    spec:
      containers:
      - name: party
        image: registry.example.com/game/party:1.12.3
        ports: [{containerPort: 8080, name: grpc}]
        resources:
          requests: { cpu: "500m", memory: "512Mi" }
          limits: { cpu: "2", memory: "2Gi" }
        env:
        - { name: REDIS_ADDRS, value: "redis-0,redis-1,..." }
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: "topology.kubernetes.io/zone"
        whenUnsatisfiable: DoNotSchedule
        labelSelector: { matchLabels: { app: party } }
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector: { matchExpressions: [{key: app, operator: In, values: [party]}]}
            topologyKey: "kubernetes.io/hostname"

观测:我们如何“看见”延迟

RED 方法(Rate/Errors/Duration)为主,P99 强制埋点。
 
Histogram:gRPC Server/Client 双端开直方图,_bucket 用指数边界(1ms~5s)。
 
黑盒探针:接入 POP 每 2s 对 HK 做 UDP/QUIC 与 TCP 双向探测。
 
Prometheus 规则示例:
 
- record: svc:grpc_latency_p99:rate5m
  expr: histogram_quantile(0.99, sum(rate(grpc_server_handling_seconds_bucket[5m])) by (le, service))
- alert: CrossRegionPartyLatHigh
  expr: svc:grpc_latency_p99:rate5m{service="party-svc"} > 1.5
  for: 10m
  labels: { severity: page }
  annotations: { summary: "跨区组队P99 > 1.5s" }

实测数据(抽样一周)

指标 改造前 改造后 备注
组队成功平均耗时 2.1s 780ms 入口 QUIC + 锚定
组队 P95 4.8s 1.42s 队伍状态局部化
匹配排队掉线率 2.3% 0.6% Presence 重试与幂等
HK ↔ 广州 RTT(中位) 38ms 35ms 路由与拥塞控制
HK ↔ 新加坡 RTT(中位) 43ms 41ms BBR + 拥塞规避
Redis P99 GET 3.6ms 1.8ms Redis shard 热点打散
Envoy 入站 P99 12ms 5ms HTTP/2 直连上游
 

部署步骤总览(从空白到上线)

机房落地:上电、上联、BGP 宣告,VLAN 分区,MTU 1450 验证。
 
CentOS 7 基线:内核升级、sysctl、ethtool、PTP/chrony。
 
K8s:containerd + kubeadm、Cilium eBPF、MetalLB BGP。
 
入口:Envoy(QUIC+HTTP/2)、GeoDNS、就近 POP 代理上线。
 
数据层:Redis Cluster、TiDB、NATS/Kafka、备份与巡检脚本。
 
服务发布:party/presence/matchmaking 等微服务灰度(Argo Rollouts canary 10%→30%→100%)。
 
观测/告警:Prometheus/Grafana 仪表盘、SLO 与自动回滚。
 
压测与热身:k6 + ghz(gRPC)模拟真实玩家曲线,热点 Key 回放。
 
联调:客户端 QUIC 切换、接入 POP 单跳回源校验。
 
上线与应急:一键“Region 迁移开关”,必要时手工 re-pin 队伍。

常见坑 & 我们现场是怎么解的

MTU 被 VXLAN 吃掉:CNI 默认 1500,实网 1500,VXLAN 额外开销导致分片。解法:Cilium 直路由模式 + mtu=1450,入口与 POP 同步配置。
 
conntrack 爆表:高并发 QUIC + gRPC 混合冲击 nf_conntrack。解法:把 nf_conntrack_max 拉到 2.6M,并分 bucket;Envoy 调 idle_timeout,客户端开连接池。
 
Redis 热点 key:party:hot 被撞成单分片。解法:Key 结构加入哈希前缀 {party:<hash>:<id>} 固定 hashslot,配合 Lua 限流。
 
GC 停顿:Go 1.19 默认 GC 可能在匹配高峰卡顿。解法:升级 Go 1.21,GOGC=120,热路径对象池化。
 
时钟漂移:两台 POP 探针时钟相差 300ms,导致“假高延迟”。解法:PTP 优先,探针侧时间戳统一由服务端校正。
 
Ingress 过度代理:Istio sidecar 默认链路多一跳。解法:控制面 Linkerd,数据面直连 Envoy,上游 mTLS 由 Linkerd 兜底。
 
BGP 会话 flap:MetalLB 与 ToR 计时器不一致。解法:调和 keepalive/holdtime,关闭 ToR 侧 aggressive dampening。

成本与弹性

  • 网关/微服务层可弹性扩缩,队伍锚定让我们以较少实例承载“高价值链路”。
  • 游戏实例节点按活动档期临时加仓,TiDB/Redis 只做“水平温扩”,避免重平衡高峰期与活动期重叠。
  • 附:客户端侧的两点小改动(意外地关键)
  • “软切换”:玩家已经接入 CN POP,组队锚定在 HK 时,仅切换回源地址,不做全量重连;通过 0-RTT(QUIC)把会话 token 复用。
  • RTT 探针:客户端每 5s 给 HK/SG/GZ 各发 3 个 UDP 探测包,丢最差一个,回报中位数。这样 party-svc 才能算得准。
 
改造上线第五天,凌晨一点,机房里那盏偶尔闪烁的黄灯终于稳定成了绿。Grafana 的 P95 下来了,世界频道里也安静了。有人在频道里喊:“跨区带新也不卡了,今晚冲一下大秘境?”——我笑了笑,合上了那本写满参数与抓包截图的黑皮笔记本。
 
这就是把 MMO 架构落在香港做微服务化的意义:不是炫技,而是让分布在不同城市、不同网络里的玩家们,能更快地相遇、更稳定地并肩。
 
如果你也正踩在同样的坑里,照着这份清单走一遍,先把链路理顺、把状态定住、把尾延迟打下来。剩下的,就交给凌晨三点的机房和你自己。
目录结构
全文