
我把最后一台逻辑服的机箱推回托架时,Grafana 的 P99 线终于趴平,我关上工具箱当作给自己画了个句号。香港这家机房在荃湾,半夜两点,走廊里只有我和机柜的指示灯在对话。前一周,菲律宾分区的玩家投诉“体力不刷新、跨区公会战积分乱跳”。那一刻我意识到:我们欠一套真正意义上的多分区逻辑服架构,既要让各区独立稳定低延迟,又要在必要的跨区场景下做到可控的一致性。这篇文章,是我这次从设计、选型、落地、踩坑到回归稳定的完整实操记录。希望无论你是第一次搭手游后端的新同学,还是在大促、合服、跨区战里摸爬滚打多年的同路人,都能从中拿到可直接复用的“菜谱”。
一、目标与约束
目标
- 多分区(例如:HK、TW、SEA、JP)逻辑服相互隔离,同区请求闭环,跨区事件可控异步。
- 单区P99 延迟 ≤ 80ms(香港/台北/马尼拉/东京覆盖),跨区事件允许数百毫秒~数秒的最终一致。
- 避免同步异常:时间漂移、重复消费、写放大、缓存击穿、雪崩、跨区“脑裂”。
约束
- 物理部署在香港机房(低时延覆盖东亚/东南亚),系统:CentOS 7(注意内核特性)。
- 尽量使用社区稳健组件:Nginx/Envoy、Kafka、Redis Cluster、MySQL 5.7/8.0(或 MGR)、Prometheus + Grafana、Loki/ELK。
- 预算友好,10G 接入 + 专线/多线 BGP;GPU 非刚需。
二、总体架构(Region-Partition + EventBus + Outbox)
分层与关键点:
Client
↓ (GSLB/DNS/Anycast)
Edge(香港): Nginx/Envoy Access GW ──→ Auth/Session
│
├──> Partition Router(按玩家UID映射分区)
│
└──> Logic Cluster[Region=HK/TW/SEA/JP]
├─ Lobby/Match
├─ Battle/Instance
├─ Economy(支付/道具)
└─ Social(公会/好友)
│
├── Redis Cluster(分区内) - 热数据/令牌/锁
└── MySQL Shards(分区内) - 冷热分层+分库分表
│
└── Outbox(本地事务表) → Kafka(全局Bus)
↑
Cross-Region Aggregators(异步汇总/跨区玩法)
- 分区(Partition)是逻辑概念:每个分区有独立的逻辑服、Redis Cluster、MySQL分片。跨区只通过Kafka 事件总线沟通,避免直接跨库/跨缓存写。
- Outbox Pattern:业务写本地库 & 记录 outbox 表(同一事务提交),由后台 relay 稳定投递到 Kafka,实现“至少一次 + 幂等消费”。
- 幂等键:idempotency_key = partition_id + table + business_pk + version。消费者用去重表或 Redis set 带 TTL 去重。
三、硬件与网络选型(香港机房实配示例)
3.1 机型与存储(单区目标并发 10~20 万 QPS/请求峰值 1~2 万)
| 角色 | 型号/CPU | 内存 | 系统盘 | 数据盘 | 网卡 | 备注 |
|---|---|---|---|---|---|---|
| Access GW | 2×Intel Xeon Silver 4310 | 64GB | 2×480G SSD | - | 2×10G | Nginx/Envoy+Keepalived |
| Logic 节点×12/区 | AMD EPYC 7313P | 256GB | 2×960G SSD | 2×3.84TB NVMe U.2 | 2×10G | Go/Java 微服务 |
| Redis×6/区 | Intel Xeon Gold 6230R | 128GB | 2×480G SSD | 4×1.92TB NVMe | 2×10G | Cluster 3主3从 |
| MySQL×6/区 | AMD EPYC 7402P | 256GB | 2×960G SSD | 4×3.84TB NVMe | 2×10G | Shard ×3,主从 |
| Kafka×5(全局共用) | AMD EPYC 7313P | 128GB | 2×480G SSD | 4×1.92TB NVMe | 2×10G | 3AZ 架构 / 机柜隔离 |
| 监控/日志 | 任意 | 64GB | 2×480G SSD | 2×3.84TB SSD | 2×10G | Prom/Loki/MinIO |
网络:ToR 双上联,BGP 多线,内网 MTU 保持 1500(除非全链路可达 9000),Bonding LACP,VRRP 做VIP漂移。
四、IP 规划与端口
| 组件 | VIP | 端口 |
|---|---|---|
| Access VIP(游戏 TCP/WS) | 10.10.1.10 | 7000-7099 |
| Access VIP(HTTP/REST) | 10.10.1.11 | 80/443 |
| Redis Cluster | 10.10.2.0/24 | 6379, 16379-16479 |
| MySQL Shard0/1/2 | 10.10.3.0/24 | 3306 |
| Kafka Brokers | 10.10.4.0/24 | 9092, 9093 |
| Prometheus/Grafana | 10.10.5.10 | 9090/3000 |
五、时间与时区策略(避免“时间相关的同步异常”第一原则)
- 所有服务器统一 UTC+0,客户端用本地时区渲染;活动窗口按 UTC 精确落地。
- Chrony(CentOS 7 默认可用):
/etc/chrony.conf(香港近源 + 局域对时)
server 0.hk.pool.ntp.org iburst
server 1.hk.pool.ntp.org iburst
server 2.asia.pool.ntp.org iburst
makestep 1.0 3
rtcsync
local stratum 10
allow 10.10.0.0/16
systemctl enable chronyd --now
chronyc tracking
chronyc sourcestats
验收:所有节点时钟偏移 |offset| < 50ms。超过阈值自动降级:拒绝跨区强一致事务,仅允许读。
六、数据分区与路由
6.1 玩家路由(稳定 + 均衡)
PlayerUID 为 64-bit:region_code(8bit) + server_id(8bit) + snowflake(48bit)
逻辑分区 partition_id = region_code * 100 + server_id
网关路由根据玩家 token 中的 partition_id 选择后端 upstream。
6.2 数据库分片(MySQL)
单区 3 个 Shard(示例):user_db_{0..2}
规则:shard_idx = uid % 3,表内再做 range/hash 分表。
跨区不跨库写,跨区玩法使用聚合服务从 Kafka 订阅事件,写独立聚合库。
6.3 缓存(Redis Cluster)
Key 设计:{uid}:profile(利用哈希槽 tag 收敛到同节点,减少跨槽多键操作)
强约束:只在同分区进行多键脚本/事务,跨区用事件驱动。
七、组件与内核调优(CentOS 7)
内核 3.10 默认不带 BBR,若需 BBR 建议用 ELRepo kernel-ml ≥ 4.14。否则使用 fq_codel。
/etc/sysctl.d/90-game.conf:
# 文件句柄与连接跟随业务侧调大
fs.file-max = 2097152
net.core.somaxconn = 65535
net.ipv4.ip_local_port_range = 10000 65535
# 队列、缓冲
net.core.netdev_max_backlog = 250000
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
# TCP
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_max_syn_backlog = 262144
# 队列算法
net.core.default_qdisc = fq_codel
net.ipv4.tcp_congestion_control = cubic
sysctl --system
ulimit -n 1048576
Nginx/Envoy 侧禁 Nagle(上行 WebSocket/长连接),业务 SDK 开启 TCP_NODELAY。
八、部署分步走
8.1 访问层(Nginx Stream + Keepalived)
/etc/nginx/nginx.conf(简化示例,按分区分 upstream):
worker_processes auto;
events { worker_connections 65535; use epoll; }
stream {
upstream logic_hk {
hash $remote_addr consistent;
server 10.10.20.11:7001 max_fails=3 fail_timeout=30s;
server 10.10.20.12:7001 max_fails=3 fail_timeout=30s;
# ...
}
upstream logic_tw { server 10.10.21.11:7001; server 10.10.21.12:7001; }
# ...
map $ssl_preread_server_name $backend {
default logic_hk; # 首次登录默认 HK,再由分区路由重定向
tw logic_tw;
sea logic_sea;
jp logic_jp;
}
server {
listen 7000;
proxy_pass $backend;
proxy_timeout 60s;
proxy_connect_timeout 5s;
}
}
Keepalived(VIP 漂移):
vrrp_instance VI_1 {
state MASTER
interface bond0
virtual_router_id 51
priority 100
advert_int 1
authentication { auth_type PASS auth_pass 1a2b3c4d }
virtual_ipaddress { 10.10.1.10/24 dev bond0 label bond0:1 }
track_script { chk_nginx }
}
8.2 分区路由服务(Partition Router)
登录鉴权后,Router 依据 uid.partition_id 把客户端302/重连指向目标域名(tw.game.example.com),或在网关层Header里下发 X-Partition: tw 走不同 upstream。不要在登录后期再切分区,避免状态迁移。
8.3 Kafka(全局事件总线)
关键配置(每区生产、全局消费):
num.partitions=24 # 按业务与吞吐规划
default.replication.factor=3
min.insync.replicas=2
unclean.leader.election.enable=false
message.max.bytes=10485760
主题设计:
economy.events.{region}、guild.events.{region}、pvp.match.{region}
Key:uid 或者 guild_id,保证分区内顺序性。
8.4 Redis Cluster(分区内)
# 6节点,3主3从
redis-cli --cluster create \
10.10.2.11:6379 10.10.2.12:6379 10.10.2.13:6379 \
10.10.2.14:6379 10.10.2.15:6379 10.10.2.16:6379 \
--cluster-replicas 1
redis.conf 要点:maxmemory-policy allkeys-lfu,tcp-keepalive 60,hash-max-ziplist-entries 512(8.x 改名),latency-monitor-threshold 50。
8.5 MySQL(分区内分片)
5.7/8.0 均可;推荐 GTID + 主从,上 ProxySQL。
关键参数(示例):
[mysqld]
server_id=101
gtid_mode=ON
enforce_gtid_consistency=ON
log_bin=mysql-bin
binlog_format=ROW
binlog_row_image=MINIMAL
innodb_flush_log_at_trx_commit=1
sync_binlog=1
innodb_buffer_pool_size=128G
innodb_io_capacity=2000
max_connections=8000
table_open_cache=20000
表设计(示例):
CREATE TABLE player_profile_00 (
uid BIGINT UNSIGNED NOT NULL,
nickname VARCHAR(32),
level INT,
stamina INT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (uid)
) PARTITION BY HASH(uid) PARTITIONS 64;
8.6 Outbox + 消费幂等(Go 伪代码)
写入(同事务):
BEGIN;
UPDATE player_profile_00 SET stamina = stamina - 10 WHERE uid=? AND stamina>=10;
INSERT INTO outbox (id, aggregate, aggregate_id, event_type, payload, created_at)
VALUES (UUID(), 'player', ?, 'STAMINA_SPENT', JSON_OBJECT('uid', ?, 'delta', 10), NOW(6));
COMMIT;
Relay 投递:
for {
rows := db.Query(`SELECT id, payload FROM outbox WHERE sent = 0 ORDER BY created_at LIMIT 100`)
for rows.Next() {
// 1) 先写 Kafka (Key=uid)
// 2) 成功后标记 sent=1
}
}
消费者幂等:
CREATE TABLE consumer_dedup (
idempotency_key VARCHAR(128) PRIMARY KEY,
processed_at TIMESTAMP(6) NOT NULL
);
k := fmt.Sprintf("%s:%d:%s", region, shard, eventID)
if !insertIgnore("consumer_dedup", k) { return } // 已处理
// 执行业务落库
九、玩法与同步策略(避免“跨区同步异常”的实操要点)
- 同区强一致:同区所有修改都在单区事务 + 单区缓存内闭环,必要时用 Redis Lua 分布式锁(注意过期与看门狗)。
- 跨区仅异步:公会排行榜、跨区锦标赛只消费各区的事件流,汇总库写入最终态,并向客户端声明“最多延迟 N 秒”。
- 绝不做跨区分布式事务:拒绝两地两阶段提交(2PC)牵扯,宁可用补偿事件(Saga)与人工运营回滚工具。
- 时间窗固定 UTC:活动窗口用 UTC,避免夏令时/本地时区差异导致“提前结算”。
十、监控、容量与验收
10.1 指标基线
| 指标 | 目标 |
|---|---|
| Access P99 | ≤ 20ms |
| Logic P99 | ≤ 60ms |
| Redis GET/SET P99 | ≤ 2/3ms |
| MySQL 写 P99 | ≤ 15ms |
| Kafka end-to-end | ≤ 1s(跨区玩法可放宽到 3s) |
| 时钟偏移 | < 50ms |
10.2 压测模型(分区维度)
- 并发:单区 20k 并发,混合 70% 读、30% 写、5% 跨区事件。
- 脚本要点:长连接、心跳、突发 3× 峰值 5 分钟、活动结算 1 分钟“尖峰”。
十一、优化清单(现场证明有效)
- 连接池:应用层 gRPC/DB 连接池热身预建;MySQL max_connections 与 ProxySQL 配比 1:3。
- SQL:避免 N+1,所有排行榜走批量读 + 异步写回。
- 缓存:热点 Key 加随机过期抖动(±10%),避免同刻雪崩;低频 Key 加 Bloom 降低穿透。
- 网络:统一 MTU=1500,确认专线/云边都不启用 9000,否则全链路一致再开。
- GC:Go 服务固定 GOGC=100~150,热点大对象池化;Java 选 ZGC/G1 并设 -XX:MaxGCPauseMillis=50。
- 限流:网关与逻辑双层令牌桶,保护数据库与 Redis。
- 可观测性:每个跨区汇总服务都暴露 落后滞后秒数(lag_seconds),超过阈值自动降级玩法或隐藏入口。
十二、坑与现场复盘(真事儿)
时钟漂移 300ms 导致签名校验失败
某批逻辑服 BIOS 里 RTC 未与 UTC 同步,chrony 初次校时没 makestep。玩家登录频繁 401。
解决:如上配置 makestep,上线前 chronyc tracking 强校验,CI 加“时钟偏移守护”。
MTU 不一致导致间歇性延迟
Kafka Broker 网卡 MTU=9000,但 GW 与交换机仍 1500。峰值时多片段重组,P99 飙升。
解决:全部回到 1500;或者完善全链路 9000 并做 PMTU 探测。
Redis Cluster 插槽迁移时脚本多键失败
公会模块的 Lua 里跨键未用哈希 Tag,迁槽期间 CROSSSLOT。
解决:统一 Key 模板 {guild:%d}:*,强制同槽;脚本加失败重试与熔断。
Kafka ISR 配置不当引发隐性丢事件
min.insync.replicas=1 + acks=1,broker 挂掉时仍返回成功。
解决:生产者 acks=all,集群 min.insync.replicas=2,并开启 unclean.leader.election=false。
TIME_WAIT 激增导致端口耗尽
活动结算扫尾瞬间短连接暴增。
解决:全量长连;必要时增加 ip_local_port_range 与 tcp_tw_reuse=1。
UID Hash 偏斜导致某 Shard 热点
早期 UID 连号,取模偏斜。
解决:Snowflake 打散;老数据迁移脚本夜间批量重分布。
十三、上线脚本与运维手册片段
13.1 systemd(逻辑服)
/etc/systemd/system/logic@.service:
[Unit]
Description=Game Logic %i
After=network.target
[Service]
User=game
WorkingDirectory=/srv/logic/%i
ExecStart=/srv/logic/%i/logic --partition=%i --conf=conf.yml
Restart=always
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
for p in hk-01 hk-02 hk-03; do systemctl enable --now logic@$p; done
13.2 Prometheus 采集(示例)
scrape_configs:
- job_name: 'logic'
static_configs:
- targets: ['10.10.20.11:9100','10.10.20.12:9100']
- job_name: 'kafka'
static_configs:
- targets: ['10.10.4.11:9308','10.10.4.12:9308']
十四、表格:分区容量与阈值
| 分区 | 在线峰值 | QPS 峰值 | Redis 内存上限 | MySQL TPS 峰值 | Kafka 主题/分区 |
|---|---|---|---|---|---|
| HK | 120k | 18k | 256GB | 6k | economy.hk × 24 |
| TW | 80k | 12k | 192GB | 4k | economy.tw × 16 |
| SEA | 150k | 22k | 256GB | 7k | economy.sea × 24 |
| JP | 60k | 9k | 128GB | 3k | economy.jp × 12 |
告警阈值:Redis 使用率 > 75%,MySQL P99 > 30ms,Kafka lag_seconds > 2s,跨区玩法自动降级。
十五、灾备与演练
- 同城双机柜:Kafka、MySQL、Redis 跨机柜副本;交换机与电源冗余。
- 冷备跨城:最关键库做 binlog 级别离线增量同步到海外对象存储,每日演练恢复。
- 演练:每月一次 broker 下线演练、每季度一次“分区丢一半节点”的黑天鹅演练。
十六、从零到上线 Checklist(可直接对照执行)
- 机房/网络:BGP/带宽/MTU/VRRP 验收 ✅
- 系统基线:内核/chrony/ulimit/sysctl/tuned ✅
- 访问层与VIP:Nginx/Envoy + Keepalived 对外连通 ✅
- 分区路由:登录→分区→重连流程自测 ✅
- Redis Cluster:插槽分布/Lua/Tag 规则验收 ✅
- MySQL:分片规则/主从/延迟/DDL 演练 ✅
- Kafka:ISR/acks/主题分区/监控 ✅
- Outbox:双写一致、重复投递与幂等验证 ✅
- 压测:20k 并发/3×尖峰/活动结算 ✅
- 可观测:Dashboards/告警策略/值班手册 ✅
- 演练:降级/熔断/只读模式/灰度开关 ✅
- 运营工具:补单/回滚/公告/热修复开关 ✅
十七、凌晨三点半的合拢
活动结算那晚,我盯着 Grafana 的 P99 线像盯着心电图。跨区汇总的 lag 从 1.8s 缓慢降到 600ms,公会战的积分不再乱跳。机房的灯还是冷的,但聊天室里菲律宾分区的玩家开始刷“GG”、“nice fix”。我把最后的告警阈值再调窄了一点,给值班同学发了条“可以收尾了”。
架构不是一次性的漂亮图,而是每一个边界的取舍:分区内强一致、跨区异步、可观测、可降级、可恢复。只有在凌晨三点半的机房里,你才能听见系统稳定运行时的那种安静。这套多分区逻辑服架构,我用过、修过,也愿意把每个坑都摊开给你看。
下一次你在机房里听见那种安静,希望也有我的这份“菜谱”在你手边。