0%

游戏后端开发

炸服就是因为某种原因瞬间有大流量涌入服务器,导致服务器逐步变慢卡顿直至瘫痪,无法继续提供服务。

游戏服务端技术栈指南

写给谁:从 PHP / Laravel Web 开发转向游戏服务端开发的独立开发者
核心目标:不是罗列技术名词,而是让你在每一个关键分岔路口都能做出不后悔的决策


0. 这份文档怎么用

转型最大的坑,不是技术难,而是在错误的维度上用力。

很多 PHP 出身的开发者转游戏服务端时,会本能地去找”游戏服务端的 PHP 框架”,花三个月搭出一套 Laravel 目录结构,然后发现游戏的核心难点——确定性、延迟、AOI、状态机——一个都没碰过,倒是把 Web 开发那套 RESTful 分层架构复刻了一遍。

所以这份文档的组织方式是这样的:

章节 解决什么问题
第 1 章 你的大脑模型需要重建哪些假设
第 2 章 选什么语言——决定未来 3 年的开发体验
第 3 章 帧同步 / 状态同步 / 预测回滚怎么选
第 4 章 TCP/UDP/KCP、序列化、消息头怎么设计
第 5 章 网关/逻辑服/公共服/DB 代理怎么切
第 6 章 房间、匹配、战斗、背包、排行、聊天的实现要点
第 7 章 Redis / MySQL / 分库分表 / 存盘策略
第 8 章 Unity / Cocos、netcode 框架选型
第 9 章 策划表怎么进代码库
第 10 章 部署、监控、日志、压测、故障排查
第 11 章 客户端不可信意味着什么
第 12 章 12 周从零到上线一款游戏的路线
第 13 章 直接抄的三套技术栈
第 14 章 书、仓库、文档、课程
第 15 章 别人踩过、你不必再踩的 40 个坑
附录 A 你的技能有多少能直接用
附录 B 一页纸速查表

贯穿全文的判断依据:你是独立开发者、单兵作战、PHP 背景、前端能力偏弱、但有 Laravel/Filament 实战经验(这意味着你懂配置化后台、懂权限模型、懂数据表设计——这在游戏服务端的价值被严重低估了,后面会专门讲)。

读法建议:

  • 时间有限 → 读第 1、2、3 章 + 附录 B(半天,足够做出所有关键决策)
  • 准备动手 → 全读一遍,然后按第 12 章的路线图走
  • 只想抄技术栈 → 直接跳第 13 章

第 1 章 思维迁移:Web 后端 vs 游戏服务端的 7 个本质差异

这一章是全文的地基。如果你直接跳到语言选型,会在三个月后撞墙。

1.1 七个差异速览

# 维度 Web 后端(Laravel) 游戏服务端
1 交互模型 请求-响应,无状态,HTTP 天然 长连接,有状态,玩家状态活在内存里
2 延迟目标 200ms 可接受,1s 也能忍 战斗内 50ms 决定手感,100ms 开始”粘”
3 时间语义 一次请求一个时刻,忽略时间 时间是第一公民:tick、帧号、超时、时钟漂移
4 一致性 事务 + 强一致数据库 内存态 + 异步落库 + 最终一致
5 反作弊 客户端输入不可信但影响有限 客户端输入直接决定战斗结果 → 必须服务端权威
6 流量形态 平滑、可预测、弹性伸缩 开服尖峰、活动脉冲、连接数是第一指标
7 失败代价 5xx 报错,用户刷新重试 掉线 = 玩家资产可能回档(等级/背包/经验)

1.2 差异一:长连接 + 有状态 = 你的人生观要变

Laravel 里你是” stateless 的信徒”:每个请求进来,加载用户、查库、返回、忘记一切。水平扩容so easy,加机器就行。

游戏服务端完全相反。玩家的等级、背包位置、技能 CD、所在场景,全都活在某个进程的堆内存里。 这意味着:

1
2
3
4
5
6
7
// ❌ 你在 Laravel 里会这么写(每来一个消息就查一次库)
public function onMessage($client, $message) {
$player = Player::find($client->playerId); // 一次 IO,几毫秒
$player->hp -= $damage;
$player->save(); // 又一次 IO
$client->send($player->toArray());
}

在 1000 人同场景时,这个写法会在第一个 tick 里就打满你的连接池——每一个移动包都触发两次数据库 IO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 游戏服务端的做法:内存态 + 脏标记 + 批量落库
class Player {
public array $data; // 全部数据在内存里
public bool $dirty = false;

public function takeDamage(int $dmg): void {
$this->data['hp'] -= $dmg;
$this->dirty = true; // 只标脏,不落库
}
}

// 30 秒一次(或下线时)把脏数据批量写入 Redis / MySQL
foreach ($players as $p) {
if ($p->dirty) { $store->save($p->data); $p->dirty = false; }
}

这就是”热/温/冷”三级存储的雏形,第 7 章会展开。

关键认知转变:你在 Laravel 里学到的”每个请求都从权威数据源读取”这个好习惯,在游戏服务端是性能自杀。游戏服务端追求的是数据常驻内存 + 变更可控落盘。

1.3 差异二:延迟目标

Laravel 里你的 SLA 可能是”接口 P95 < 300ms”。游戏服务端要分层看:

场景 延迟预算 说明
登录 < 1s 可接受,用户有心理预期
匹配/组队 < 3s 超时用户会重复点击
普通操作响应(开背包、聊天) < 100ms 超过就有”卡”的感觉
战斗内操作回显 < 50ms 超过就能明显感觉到”操作延迟”
PVP 判定往返 < 100ms(RTT) 超过就开始”打不到人”

50ms 的 P99 意味着什么:不是”平均 50ms”,而是 100 次操作里有 1 次超过 50ms。这意味着:

  • 你的逻辑处理必须在 1~5ms 量级(剩下的都留给网络往返)
  • 你不能有任何 同步阻塞 IO(查库、调 HTTP、写文件)在主逻辑线程上
  • 你不能用 GC 停顿几十毫秒的语言在关键路径上(这是 C++/Rust/Go 相对 Java 的核心优势)

1.4 差异三:时间是第一公民

Web 开发里时间是隐形的:请求到达就处理,处理完就返回。

游戏服务端里,每一帧都是一个时间契约:

1
2
3
60 FPS  →  每帧 16.67ms 预算
30 FPS → 每帧 33.33ms
逻辑帧 15~20 FPS(帧同步常用)→ 每帧 50~66ms

在帧同步体系里,逻辑帧率是协议的一部分,客户端和服务端必须约定一致(第 3 章详述)。

时间相关的坑,一个 Web 开发者几乎必然要踩:

  • Time.deltaTime 在客户端和服务端不一致 → 帧同步必不同步
  • 时钟漂移:多台机器的 time() 不一样 → 用 tick 计数 而非墙上时间做逻辑基准
  • 超时判断的边界抖动:>= 还是 >,在 60fps 和 20fps 下行为不同
  • 夏令时 / 时区(PHP 老坑):游戏内时间一律用 UTC 或单调递增的 tick

1.5 差异四:最终一致,而不是强一致

Laravel 里你用 DB::transaction() 把 3 张表的写入包成一个原子操作。游戏服务端很少能这么做,因为:

  1. 玩家内存态和 DB 里的态本来就有时间差(差 30 秒)
  2. 跨服玩法(跨服务器 PK、跨服聊天、赛季结算)天然跨库
  3. 排行榜、社交关系这些数据,延迟几百毫秒完全没问题

所以游戏服务端的主流一致性模型是:

1
2
3
内存态(权威,毫秒级)── 定时/事件 ──▶ Redis(温,秒级)── 批量 ──▶ MySQL(冷,分钟级)
▲ │
└────────────── 崩溃恢复:Redis 优先,回落 MySQL ◀────────────────────────┘

代价是”回档”。玩家掉线、进程崩溃,可能丢失最后 30 秒的经验。这在 Web 业务里不可接受,在游戏业务里是被行业普遍接受的设计——因为可以用”补偿邮件”、经验双倍掉落活动等方式对冲。

1.6 差异五:反作弊是架构约束,不是补丁

Web 后端通常信任”这个请求来自已登录用户”。游戏服务端必须假设客户端是敌对的:

1
2
3
4
5
❌ 客户端上报「我攻击了敌人,造成 100 伤害」
→ 客户端可以改成 100000

✅ 客户端上报「我在 t=12345ms 按了攻击键,目标是 entity#889」
→ 服务端自己查:距离够吗?技能 CD 好了吗?扣的是哪个 buff?伤害怎么算?

这一条直接决定技术选型:

  • 如果你做状态同步(服务端算状态)→ 服务端必须跑游戏逻辑 → 需要高性能语言(Go/C#/C++)
  • 如果你做帧同步(客户端算)→ 逻辑在客户端跑,但服务端要重放校验 → 服务端也要跑同一份逻辑 → 同样需要高性能语言,且要求双端共享代码

PHP 在这条路上基本出局(后面展开)。

1.7 差异六:流量形态

Web 后端:日活曲线平滑,峰谷比 3~5 倍,K8s HPA 一缩一放就行。

游戏服务端:

  • 开服尖峰:新服开放瞬间,10 倍日常流量挤进来
  • 晚高峰脉冲:晚上 8-11 点,社交游戏流量翻 3 倍
  • 活动脉冲:限时活动开启,1 分钟内 5 倍
  • 连接数是第一指标,不是 QPS。1 万个玩家在线可能只有 200 QPS 的业务请求,但占了 1 万个长连接

这对架构的影响:你的服务必须能优雅地拒绝/降级,而不是崩溃。连接管理、心跳、超时踢出、限流,都是必需组件。

1.8 差异七:失败代价

场景 Web 后端挂了 游戏服务端挂了
表现 502,用户刷新 玩家集体掉线
数据 事务回滚,无损失 内存里的玩家数据丢失
恢复 重启即好 需要从 Redis/MySQL 恢复玩家状态
玩家感知 “网站崩了” “游戏不玩了”

所以游戏服务端必须内置状态外置机制(第 5.4、7 章详述)。

1.9 小结:你的 PHP 经验哪些能用,哪些用不了

能直接用(比你以为的多):

PHP/Laravel 技能 在游戏服务端的对应
MySQL 表设计、索引优化 玩家/背包/订单表设计,游戏 DB 优化一样
Redis 使用(缓存、排行榜 ZSET、分布式锁) 游戏服务端 Redis 用法几乎完全一致
Filament / FastAdmin 的配置化后台 GM 后台、配表管理、线上数据订正——这是独立开发者的救命技能
权限模型 / RBAC GM 权限分级(策划看数值、运营改商城、运维看日志)
队列(Redis/Kafka) 跨服消息、异步落库、邮件下发
定时任务调度 日常任务、赛季结算、离线结算
限流/防重/幂等 支付回调幂等、道具领取幂等
分布式 ID(雪花号等) 游戏内全局唯一 ID(timestamp|worldId|sequence)
Docker / Supervisor / Nginx 服务进程管理、反向代理、负载均衡

用不了(需要重学):

Web 习惯 游戏服务端为什么不行
同步阻塞 IO(查库、调 API) 主逻辑线程会被卡住,所有玩家一起卡
请求-响应思维 玩家状态是持续存在的,不是每个包都从 DB 读
RESTful 路由风格 游戏用消息号(opcode)路由,不是 URL
JSON 传输 高频消息必须二进制(Protobuf/FlatBuffers)
无限扩容假设 有状态服不能随便加机器,玩家和进程是绑定的
协程 = 并发 游戏里更常用固定 tick 的事件循环,不是自由并发

一句话总结第 1 章:Web 教你的”可扩展性”和”无状态”在游戏服务端大部分时候是反模式;你在 Laravel 里练就的数据建模、后台工具化、配置驱动能力,在游戏服务端则是稀缺技能。


第 2 章 语言选型:这可能是全文最重要的一章

语言选错的代价,是未来 2-3 年的持续摩擦。它决定了:

  • 你的 AI 协作者(也就是我)能帮你写多少
  • 你招不招得到人(虽然你是单人)
  • 性能天花板在哪
  • 生态成熟度(工具链、库、踩坑资料)
  • 你能不能同时写客户端和服务端(双端共享逻辑)

2.1 六种语言的横向对比

维度 PHP (Swoole/Workerman) Node.js / TS Go C# / .NET C++ Rust
性能上限 中 中高 高 高 极高 极高
并发模型 协程(需扩展) 单线程事件循环 goroutine async/await + TPL 线程池/协程 async (tokio)
GC 影响 小(引用计数) 大(会卡顿) 小(STW 极短) 大 无 无
冷启动 中 快 快 中 慢 慢
编译期检查 弱 中(TS) 强 强 强 强
学习曲线(对 PHP 开发者) 极低 低 中 中 高 高
官方游戏框架 无 Pomelo(停更)/Colyseus Pitaya/Leaf/Nakama ET skynet 无
双端共享代码 ✗ ✗(H5 除外) ✗ ✓ (ET/Unity) ✓ (cocos) ✗
生态成熟度 Web 极强 / 游戏弱 Web 强 / 游戏中 游戏中上 游戏中上 游戏最强 游戏弱
招聘/协作 容易 容易 中 中 难 难
AI 辅助产出质量 高 高 高 高 中 中

2.2 逐个拆解

PHP 路线:Swoole / Workerman / Laravel Octane

能做什么:

  • 长连接 WebSocket 服务器(Workerman / Laravel Reverb)
  • 卡牌、棋牌、休闲游戏的大厅 + 战斗服
  • H5 小游戏(浏览器唯一可选 WebSocket)
  • 你的老本行——游戏后台、GM 系统、运营平台

硬伤:

  1. 协程需要 C 扩展(Swoole/OpenSwoole)。Windows 本地 Laragon 环境装起来是各种折腾,跨平台开发体验割裂。
  2. 协程上下文让传统调试工具失效:Xdebug 穿不透协程栈,var_dump() 可能输出空或错乱值。你在 Laravel 里那套”打日志、看堆栈”的排障习惯会直接失灵。
  3. Swoole BASE 模式下单个协程崩溃可能拖垮整个 Worker;Workerman 则是纯 PHP 多进程,性能上限低但调试体验好。
  4. PHP 生态里几乎没有游戏服务端框架。你得自己从零实现 Actor、tick 循环、房间管理、协议编解码。PHP 大佬社区里做游戏服务端的项目,质量和文档都不成体系。
  5. 无法双端共享逻辑。如果你用 Unity/Cocos 客户端,PHP 服务端和客户端代码要写两遍。

什么时候 PHP 是对的选择:

  • 你做卡牌 / 棋牌 / 回合制 / 休闲游戏(低实时性要求)
  • 客户端是 H5 / 小游戏(WebSocket)
  • 游戏是你的老本行的延伸(如你已经熟悉快手开放平台对接,再做一个游戏)
  • 团队/客户强制要求 PHP

我的判断:如果你的目标是”做出一个能上线运营的游戏”,PHP + Swoole 可行但不优雅。如果目标是”建立可以持续精进的游戏服务端能力”,PHP 是最差的选择——因为它让你停留在舒适区,不接触真正的技术难点。

Node.js / TypeScript 路线

能做什么:

  • H5 / 小游戏全栈(客户端和服务端同语言,收益巨大)
  • Colyseus:MIT 协议的现代多人游戏框架,房间即核心抽象,schema 声明式状态同步(二进制 patch,默认 50ms/20fps),内置匹配,SDK 覆盖 Unity/Godot/Cocos/Flutter/Swift/原生 C
  • Pomelo(网易,2019 停更):设计理念经典但别新项目用
  • 你已经有 Node.js 服务端运维经验(pm2、进程守护、日志)——这是真实的迁移优势

硬伤:

  1. 单线程 + V8 GC。Node 的 GC 停顿在 10-100ms 量级,对”50ms 延迟预算”的战斗场景是硬伤。虽然 V8 的分代 GC 和增量标记已经改善很多,但在 tick 循环里做大量对象分配仍会造成周期性卡顿。
  2. CPU 密集逻辑要靠 worker_threads 逃逸,复杂度陡增。
  3. 类型安全靠 TS 编译期,运行时仍是 JS。

什么时候 Node 是对的选择:

  • H5 / 小游戏 / 微信小游戏
  • 团队是前端背景,想快速出原型验证玩法
  • 游戏本体逻辑轻(回合制、卡牌、休闲),实时性要求不高

Go 路线 ⭐ 独立开发者的甜点区

能做什么:

  • Pitaya:Go 写的分布式游戏服务器框架,connector(接入)/ handler(业务)分离,内建 RPC、服务发现(etcd/Consul)、原生 Prometheus/OpenTracing 集成、官方 Dockerfile + K8s Helm Chart
  • Leaf:轻量,单进程内按需组合模块,自带时序调度器
  • Nakama:Go 写的全栈游戏后端——认证、存储、匹配、聊天、排行榜、权威对局循环,脚本可用 Lua/TS/Go
  • Open Match 2:Google 的匹配服务,gRPC 架构,frontend/backend/director 三件套
  • Agones:K8s 上的专用游戏服务器编排(GameServer/Fleet 资源,本地 SDK sidecar 上报健康,Fleet 保持热备池避免冷启动)
  • KCP(skynet 作者的 Go 移植)/ gNet(高性能非阻塞网络库)

优点:

  1. goroutine + channel 天生长连接,百万连接不是梦
  2. GC 停顿极短(STW 通常 < 1ms),能满足战斗场景
  3. 编译期类型检查,重构安全——这点对 AI 辅助开发极其重要
  4. 协程写法让业务代码像同步代码一样直白,接近 PHP 的开发体验
  5. 二进制部署,单机文件几个 MB,容器化天然适配
  6. 官方维护的云原生生态(K8s、gRPC、Prometheus、etcd 全家桶)
  7. AI 训练数据充足,我(以及任何 AI 助手)帮你写 Go 游戏服务端的质量最高

缺点:

  1. 没有 C++ 的极限性能——但对你单机 1-5 万在线的游戏,Go 绰绰有余。信通院白皮书说 C++ 能效比是 Go 的 1.8 倍,但那是 5000+ 同屏战斗的极端场景。
  2. 游戏领域的现成框架比 C++ 少(但有 Pitaya/Leaf/Nakama 补上)
  3. 不能双端共享逻辑(如果你用 Unity 客户端)

什么时候 Go 是对的选择:

  • MMORPG / SLG / 卡牌 / 棋牌 / 休闲竞技 等绝大多数商业手游品类
  • 想要”单体起步、架构可演进”——从单进程写到分布式集群,Go 的路径最顺
  • 想要 K8s 编排 + 弹性伸缩

我的判断:对独立 PHP 开发者,Go 是默认推荐。理由:性能足够、并发模型天然匹配、类型安全让你(和 AI)能重构、生态齐全、招聘容易、我的产出质量最高。缺点只有一个——不能和 Unity 客户端共享代码,但可以通过”协议文件 + 导表”来管理一致性。

C# / .NET 路线 ⭐ 如果你用 Unity,必看

ET Framework 是这个方向的皇冠,功能覆盖度极高:

ET 能力 说明
N 变 1 全部服务端组件挂到一个进程,用 VS 单步调试分布式逻辑。这是 ET 最大的杀手锏——分布式服务最难的是调试,ET 直接把它降维成单体调试
1 变 N 开发时单进程,发布时改一份配置拆成多进程。改哪部分功能放哪个服,代码几乎不用动
双端共享逻辑 客户端(Unity/C#)和服务端(.NET)同一份 C# 代码。战斗公式、 buff 逻辑写一遍,天然不会不一致
位置透明 Actor 拿到 EntityId 就能发消息,不用关心对象在哪个进程。类 Erlang 消息机制
async/await 原生协程 await session.Call(...) 这种写法在 PHP 里只能靠 Swoole 硬凑
纯 C# KCP 库 作者自研,性能强
零 GC 二进制序列化 基于 BSON,避开 GC 压力
AOI 九宫格实现,动态调整可见玩家以降低服务器负载
热更 逻辑代码热重载,二进制动态库注入,零停机升级
Recast 寻路 服务端 3D NavMesh
压测机器人 直接用客户端逻辑层代码写机器人,4 台 24 核机器模拟 1 万人

缺点:

  1. Windows 上开发、Linux 上运行(虽然 .NET 已跨平台)
  2. C# 的 GC 依然存在(ET 用零 GC 序列化 + 对象池缓解)
  3. ET 生态的文档以中文为主,国际化文档少
  4. 如果客户端不是 Unity/Cocos,这套就浪费了

什么时候 C# 是对的选择:

  • 客户端是 Unity(尤其是中小规模、逻辑双端共享的 MMO/ARPG)
  • 想要双端一份代码(这是巨大的效率提升,能省掉 30-50% 的逻辑开发量)
  • 想用 ET 这类成熟框架,不想从零造分布式轮子

我的判断:如果你的客户端是 Unity 且游戏逻辑偏重度(ARPG/MMO/SLG),ET 是最好的选择,双端共享代码的价值无可替代。如果客户端是 Cocos Creator(TypeScript),Go + 协议双导是更自然的组合。

C++ 路线

能做什么:

  • skynet(云风):中文游戏服务端圈的事实标准,C + Lua 混合。Lua 写业务(策划能改),C 写调度(性能)。Actor 模型,单节点数万连接。云风本人发明的十字链表 AOI 算法就出自这里。
  • 性能天花板最高,适合 5000+ 同屏战斗、3A 级项目
  • 腾讯全异步 Actor 框架单机 10 万连接;网易《逆水寒》用 C++ 协程把 IO 线程数减少 70%

缺点(对独立开发者是致命的):

  1. 学习曲线陡。C++ 的内存管理、UB、生命周期、游戏服务端特有的指针问题,会让你把大量精力花在跟业务无关的地方
  2. 编译慢,迭代体验差(skynet 增量编译能缓解,但依然不如 Go/Node 的秒级编译)
  3. Lua 双向边界有跨语言调用开销,调试链路长
  4. 生态偏运维传统:没有配置中心、没有 K8s Helm Chart、服务发现要手写
  5. AI 辅助写 C++ 游戏服务端的质量明显低于 Go/C#

什么时候 C++ 是对的选择:

  • 你要做的游戏同屏人数极高(千人国战、5000+ 战斗)
  • 你已经会 C++,或者有 C++ 背景的合伙人
  • 你要读 skynet / 网易 / 腾讯的源码来学习

我的判断:不推荐你从 C++ 开始。你的时间是最大的成本,Go 能达到 90% 的性能,但开发效率是 C++ 的 2-3 倍。等你真的撞到性能天花板,再考虑把热点模块用 C++ 重写(这也是 Go 生态的常规做法)。

Rust 路线

性能天花板与 C++ 相当,零 GC,内存安全。tokio 是异步运行时的事实标准。

缺点:游戏领域几乎没有成熟框架和资料;学习曲线陡;薪资溢价 40% 说明稀缺度高(也说明生态薄)。不推荐作为转型首选,但如果你想投资未来的技术护城河,Rust 是好选择。

Java / Kotlin 路线

Netty 的 NIO Reactor 模型成熟稳定,国内页游/手游早期爆款几乎全是 Java 服务端,积累了大量线上排障经验。Spring Cloud 生态完整。

缺点:GC 停顿在帧同步类玩法是硬伤(虽然可以调优,ZGC/Shenandoah 已改善很多);JVM 内存占用大,容器密度低;协程需要 Kotlin 或 虚拟线程(Loom,目前仍在演进)。

什么时候 Java 是对的选择:你已有 Java 经验、或者项目需要 Spring 生态全家桶、或者团队是 Java 背景。你没有 → 跳过。

2.3 决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
你的客户端引擎是什么?
│
├─ Unity (C#)
│ └─ 逻辑双端共享?→ ET (C#) ⭐⭐⭐⭐⭐
│ └─ 不追求共享、要生态/云原生 → Go + Unity SDK ⭐⭐⭐⭐
│
├─ Cocos Creator (TypeScript)
│ └─ H5/小游戏 → Node.js/TS + Colyseus ⭐⭐⭐⭐⭐
│ └─ 原生 App → Go + Protobuf 协议双导 ⭐⭐⭐⭐
│
├─ Godot (GDScript/C#)
│ └─ Godot 4 官方 multiplayer → Go/Node 均可 ⭐⭐⭐
│
├─ H5 / 微信小游戏
│ └─ Node.js/TS + Colyseus ⭐⭐⭐⭐⭐
│
└─ 还没定 / 想先做原型
└─ Node.js + Colyseus(最快出活)→ 验证玩法后再迁 Go ⭐⭐⭐⭐

2.4 如果你坚持 PHP(我把话说明白)

不要用 Laravel 硬改。Laravel 的请求-响应生命周期和游戏的常驻内存模型是冲突的。

如果一定要 PHP,用这个组合:

层 技术 说明
长连接网关 Workerman 或 Swoole/OpenSwoole Workerman 调试友好(Xdebug 可用),Swoole 性能高但调试困难
业务后台 Laravel + Octane(Swoole/FrankenPHP/RoadRunner) 你最熟悉的部分,GM/运营/配表后台
通信 WebSocket(Reverb 或 Workerman) H5 唯一选择
序列化 Protobuf(不要 JSON) PHP 的 protobuf 扩展有坑,可考虑手写 varint 编解码或用 msgpack
状态 Redis 会话、背包缓存、排行榜 ZSET
战斗逻辑 独立进程跑(Node/Go) 把重逻辑剥离出去,PHP 只做大厅

这是”混合架构”:PHP 做它擅长的(后台、运营、H5 接入),Go/Node 做它擅长的(实时战斗)。你会看到大厂也是这个路子——腾讯《和平精英》匹配系统用 Go 重构后响应速度提升 40%;网易《逆水寒》C++ 核心 + Lua 脚本扩展。

2.5 我的最终推荐

主推:Go + (Unity 用 NGO/FishNet 或 ET / Cocos 用 Protobuf 协议) + Redis + MySQL + Protobuf + KCP/TCP + Docker

理由,按权重排序:

  1. 性能足够:Go 的性能对绝大多数商业游戏绰绰有余,GC 停顿 < 1ms
  2. 开发效率接近 PHP:goroutine 让业务代码像同步代码一样写,go func() 就完事
  3. 类型安全:struct + 强类型 + 编译期检查 → 你(和 AI)重构时不会炸。这是长期开发体验的关键
  4. AI 辅助质量最高:Go 是 LLM 训练数据最丰富的后端语言之一,我给你写出来的代码可靠性最高
  5. 云原生原生适配:容器化、K8s、Prometheus 全是现成的
  6. 生态有成熟框架:Pitaya / Leaf / Nakama / Open Match / Agones
  7. 单兵可行:一个人能维护的范围

如果你的客户端是 Unity 且要做重度逻辑 → 改推 ET (C#),双端共享代码能省掉巨量工作量。

如果你的客户端是 H5/小游戏 → 改推 Node.js + Colyseus。

2.6 一张选型速查表(可打印)

1
2
3
4
5
6
7
8
9
10
11
场景                          → 首选                    备选
──────────────────────────────────────────────────────────────────
Unity + 重逻辑 + 双端共享 → ET (C#) —(无)
Unity + 轻中度 → Go + NGO/FishNet C# + NGO
Cocos + 原生 App → Go Node.js
H5 / 微信小游戏 → Node.js + Colyseus Go
MMORPG / SLG → Go (+ Pitaya) C++/skynet
休闲竞技 / 棋牌 → Go 或 Node.js PHP/Swoole
卡牌 / 回合制 → 任意,甚至 Laravel —
独立原型(先验证玩法) → Node.js + Colyseus Go
服务端编排(K8s 专用服) → Agones + Open Match 自研


第 3 章 同步模型:游戏服务端的第一性问题

这是整份技术栈文档里技术含量最高、也最容易被 Web 开发者误解的一章。

如果你只读一章,读这章。

3.1 先纠正一个 Web 直觉

在 Web 里,「数据同步」通常指:前端发请求 → 后端返回最新数据 → 前端渲染。一次一请求,一次一真相。

游戏里完全不同。一个玩家在跑图,他每秒钟会产生几十次状态变化(位置、朝向、动画状态、技能特效、附近实体的相对位置)。你要决定的是:

这几十次变化里,哪些走网络、谁算、什么时候算、算错了怎么办。

这一章的三种答案,对应三种完全不同的技术栈。

3.2 三大同步模型全景对比

维度 状态同步 (State Sync) 帧同步 (Lockstep) 预测回滚 (Prediction + Rollback)
网络传什么 游戏对象状态 玩家操作指令 指令 + 权威状态
谁在算逻辑 服务器 所有客户端 客户端算(预测)+ 服务器算(权威)
计算分布 100% 服务端 100% 客户端(服务器只中转/校验) 双端都算
带宽 高(状态数据多) 低(只传输入) 中
延迟体感 差(RTT 全额延迟) 中(锁步等待) 好(本地零延迟)
反作弊 强 弱(需重放校验) 强
确定性要求 无 极高 高(仅重放部分需确定)
开发难度 中 高 高
典型品类 MMORPG、卡牌、SLG、棋牌 RTS、MOBA、格斗 FPS、TPS、竞速、动作
服务器压力 高 低 中
适合独立开发者 ✅ 最适合 ❌ 坑极多 ⚠️ 有挑战

3.3 状态同步(State Synchronization)

原理

服务器持有权威状态,玩家客户端只发”我要做什么”,服务器算完把结果广播出去。

1
2
3
4
5
6
客户端                     服务器
│ 移动指令 (dir, seq) │
│ ──────────────────────> │ 校验 + 计算
│ │ 位置更新
│ 状态包 (pos, hp, ...) │
│ <────────────────────── │

关键子技术:客户端插值(Interpolation)

服务器不可能 60Hz 给每个玩家发位置(带宽爆炸)。实际做法:

  1. 服务器以 10~20Hz 发送状态快照
  2. 客户端收到后不立即显示,而是放到一个缓冲区
  3. 客户端渲染时,播放 比最新快照落后约 100ms 的历史状态

这就是为什么状态同步的网络游戏里,人物移动看起来”稍微延迟一点点但很顺滑”。用 100ms 的延迟换来了 6 倍的带宽节省。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 客户端插值缓冲的基本结构(伪代码)
type Snapshot struct {
Tick int64
Pos Vec3
Yaw float64
}

type Interpolator struct {
buf []Snapshot // 按 Tick 排序
renderDelay time.Duration // 通常 100ms
}

func (ip *Interpolator) Sample(now time.Time) Snapshot {
target := now.Add(-ip.renderDelay)
// 找到 target 两侧的快照,做 lerp
a, b := ip.bracket(target)
t := float64(target.Sub(a.Time())) / float64(b.Time().Sub(a.Time()))
return Snapshot{Pos: a.Pos.Lerp(b.Pos, t), Yaw: lerpAngle(a.Yaw, b.Yaw, t)}
}

插值的代价:任何被插值的实体都会”落后”。所以本地玩家不应该用插值,应该用预测(第 3.5 节)。

状态同步的带宽控制

一个 100 人同场场景,假设每个实体 20 字节状态,20Hz 更新:

  • 全量广播:100 × 100 × 20 × 20 = 4 MB/s —— 荒谬
  • 实际做法:AOI 裁剪(只发视野内实体,见第 6.3 节)+ 增量更新(只发变化的字段)+ 降频(远处低频)

裁剪后,视野内通常只有 20~30 人:

  • 30 × 30 × 20 × 20 = 360 KB/s —— 仍然不小
  • 加上增量(只发位置变化,实测约 8 字节)和降频(远处 5Hz),可以压到 50 KB/s 以内

带宽预算的实用经验值:单个在线玩家的下行带宽控制在 10~30 KB/s 是健康区间。1 万玩家 × 20KB/s = 200 MB/s ≈ 1.6 Gbps 出口——这是选机器时的硬指标。

状态同步的三个隐藏成本

  1. 服务器必须跑完整游戏逻辑 → 性能压力全在服务端 → 直接排除 PHP
  2. AOI 是必需品,否则带宽直接爆
  3. 客户端必须处理”服务器说你在哪,你在移动”的不一致 → 需要预测或硬直

3.4 帧同步(Lockstep / Deterministic Lockstep)

原理

服务器只同步输入,每个客户端自己算。

1
2
3
4
5
6
时间轴:
Tick 1: 玩家A输入[移动] 玩家B输入[攻击] 玩家C输入[静止]
Tick 2: 玩家A输入[攻击] 玩家B输入[移动] 玩家C输入[技能]
...

所有客户端拿到同一串输入 → 各自执行同一套逻辑 → 得到同一份世界状态

致命前提:确定性(Determinism)

同样的初始状态 + 同样的输入序列,在任何机器上、运行任何多次,必须得到逐比特一致的结果。

这是帧同步的全部难点。Unity 环境下有一长串破坏确定性的东西:

破坏源 说明 解决方案
浮点数误差 不同 CPU 架构(Intel vs AMD)、不同编译器优化等级、FMA 指令有无,会产生微小差异 核心逻辑用定点数(Fixed Point)
Time.deltaTime 帧率不同 → delta 不同 → 逻辑分叉 固定逻辑帧率(如 20 FPS),用 1.0f/20.0f 常量
Random 无固定种子 每次运行结果不同 自定义确定性 PRNG(如 xorshift),服务端开局下发种子
容器遍历顺序 Dictionary / HashSet 遍历顺序不保证 用有序容器(List + 显式排序,或 SortedDictionary)
多线程调度顺序 协程/线程执行顺序不固定 逻辑单线程,或严格同步点
Unity 物理引擎 Physics.Simulate 内部实现不确定 不用 Unity 物理做核心逻辑,自己写简单 AABB / 圆形碰撞
对象创建顺序 不同端创建顺序不同 → 实体 ID 分配不同 确定性 ID 分配(种子 + 计数器)
网络输入到达顺序 包乱序 用帧号排序,等齐再执行

关键设计原则:逻辑层与表现层彻底分离。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────┐
│ 逻辑层(必须 100% 确定) │
│ · 定点数运算 │
│ · 固定 tick 驱动 │
│ · 确定性随机 │
│ · 简单几何碰撞(不用 PhysX) │
│ · 输出:实体位置/血量/技能状态 │
└─────────────────────────────────────────────┘
↓ 只读
┌─────────────────────────────────────────────┐
│ 表现层(可以不确定,随便炫) │
│ · Transform / Rigidbody / Animator │
│ · 粒子、特效、屏幕震动 │
│ · Unity 原生物理(仅作视觉) │
│ · 插值平滑(掩盖逻辑帧的离散感) │
└─────────────────────────────────────────────┘

Unity 项目里做严格帧同步,专业说法是:Transform、Rigidbody、Animator、Particle 属于表现层;核心战斗逻辑用定点数算位置、技能、碰撞、伤害,物理只做表现或非核心辅助。

帧同步的延迟问题与回滚

纯锁步有个致命问题:必须等最慢的人。

如果网络最差的玩家 RTT 是 200ms,一帧 50ms(20FPS),那大家都要等 4 帧才能推进 everyone。实际操作上会”卡帧等输入”。

解决方案是 预测 + 回滚(Prediction + Rollback),也就是 GGPO 的思路:

1
2
3
4
5
6
7
1. 玩家按方向键
2. 客户端立即本地应用输入(预测),画面立刻响应 → 手感为零延迟
3. 客户端把输入发给服务器
4. 服务器在第 N 帧收到真实输入
5. 如果真实输入 ≠ 客户端的预测(比如服务器说玩家当时被眩晕了)
→ 回滚到分歧帧,重放所有后续帧
→ 用真实输入重演

实现回滚的四个难点:

  1. 状态快照:每帧保存状态
    • 手动实现(项目早期方便,但繁琐)
    • 反射(性能有问题)
    • 代码生成(推荐):只适用内存紧凑型游戏,可用 memcpy
    • 命令模式(Command Pattern):适用大部分游戏,因为游戏内存通常很复杂——不保存内存,而是记录”我做了什么操作”,回滚时反向撤销
  2. 回滚后表现层的处理:生成的 mesh、特效要销毁重建
  3. 回滚后的日志混乱:拆分多份日志,或加实例 ID 区分
  4. 重演必须是纯函数:不能有外部 IO、不能有随机数副作用、不能有发奖/发邮件这类一次性副作用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 典型的 Replicate / Reconcile 模式(Unity + FishNet 风格)
// 客户端
if (IsLocalPlayer) {
// 1. 立即本地应用(预测)
ApplyInput(input);
// 2. 发送输入
SendInputToServer(input);
// 3. 收到权威状态后比对
if (Mathf.Abs(serverPos - transform.position) > 0.01f) {
// 4. 拉回 + 重放后续输入
transform.position = serverPos;
ReplayPendingInputs();
}
}

客户端预测 + 服务端校验(Reconciliation)是现代动作游戏的标配。 FishNet 内置这套机制(这是很多人从 Mirror 迁走的主要原因),Mirror 的通用预测能力官方仍标注为 “Researching”。

帧同步 vs 状态同步,怎么选

判断维度 选帧同步 选状态同步
玩家数 ≤ 8(通常) 几十到上万
强反作弊要求 低(需重放校验) 高
物理主导 是(但要用确定性物理) 否
带宽紧张 ✅ 帧同步带宽极低 ❌ 状态同步带宽大
服务器成本敏感 ✅ 服务器压力小 ❌ 服务器扛全部逻辑
团队规模 小且强 任意
品类 RTS / MOBA / 格斗 MMORPG / 卡牌 / 棋牌 / SLG

对独立开发者的直接建议:

  • 除非你做的是 RTS/MOBA/格斗,且人数 ≤ 8,否则选状态同步。
  • 帧同步的确定性工程量(定点数库、状态快照/回滚、确定性物理、双端一致的环境)通常是3-6 个月的额外工作,而且极易做出”99% 情况一致,1% 情况永久 desync”的产品——这种 bug 排查起来是噩梦。
  • 状态同步虽然费带宽、费 CPU,但问题域是线性可理解的,更适合一个人维护。

3.5 预测回滚(Prediction + Reconciliation)—— 动作游戏的标准解

这是 3.2 表格 里第三种,实际它是状态同步 + 客户端预测的混合,也是目前 FPS/TPS/竞速/动作游戏的事实标准。

核心三件套:

技术 作用 归属
客户端预测 (Client-side Prediction) 本地输入立即生效,零延迟手感 客户端
服务器权威 (Server Authority) 服务器算权威结果,校验合法性 服务端
客户端回滚重演 (Reconciliation) 收到权威状态后比对,不一致则回滚重演 客户端
服务器延迟补偿 (Lag Compensation) 服务器根据客户端的历史视角重新计算命中判定 服务端
实体插值 (Entity Interpolation) 远端玩家用插值渲染,不回滚 客户端

延迟补偿(Lag Compensation)是服务器权威架构里最容易被低估的部分。

问题:玩家 A 看到 B 在屏幕左侧,开了枪。但在网络传输 + 服务器处理期间,B 已经跑到了右边。按服务器当下的状态,这一枪应该打空。但玩家明明瞄准了。

解法:服务器保存每个玩家最近 N 帧的历史位置。当收到 A 的射击请求时,服务器根据 A 的”客户端视角时间”(通常是 A 收到这一帧的往返时间的一半之前)回溯到那个时刻的 B 的位置再判定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 延迟补偿的核心逻辑(简化)
type LagCompensator struct {
history map[EntityID][]HistoricalState // 每个实体最近 N 帧
}

type HistoricalState struct {
Tick int64
Pos Vec3
}

func (lc *LagCompensator) ResolveShot(shooter *Player, target *EntityID, aimDir Vec3, rtt time.Duration) bool {
// 1. 回溯到射手当时的视角时间
rewindTick := shooter.CurrentTick - int64(rtt.Seconds()*float64(TickRate))
// 2. 取目标在那一帧的位置
hist := lc.history[*target]
var targetPos Vec3
for _, h := range hist {
if h.Tick == rewindTick { targetPos = h.Pos; break }
}
// 3. 用历史位置做命中判定
return rayIntersects(aimDir, targetPos, shooter.HistoricalPos(rewindTick))
}

注意这里客户端也必须保存”我开枪时的历史位置”,否则回溯后射线起点不对。这需要客户端在每个 tick 记录自己的位置。

这是”服务器权威”架构的必然复杂度来源。PHP 做不了这个——你需要每 tick 高频写历史位置、结构紧凑的环形缓冲、低延迟的状态共享。Go/C#/C++ 都能做。

3.6 如何选:一张决策流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你的游戏是什么品类?
│
├─ MMORPG / SLG / 卡牌 / 棋牌 / 放置
│ └─ 状态同步(无脑选,服务器权威)
│
├─ 多人对战 FPS / TPS / 竞速
│ ├─ 玩家数 > 16?
│ │ └─ 是 → 状态同步 + 客户端预测 + 延迟补偿
│ │ └─ 否 → 状态同步 + 客户端预测(更激进)
│
├─ MOBA / RTS / 格斗
│ ├─ 玩家数 ≤ 8 且追求极致公平
│ │ └─ 帧同步 + 回滚(GGPO 路线)
│ │ └─ ⚠️ 独立开发者慎选
│ └─ 玩家数 > 8 → 状态同步 + 预测
│
├─ 回合制 / 战棋 / 卡牌对战
│ └─ 状态同步(甚至纯 HTTP 轮询都行)
│
└─ H5 小游戏
└─ WebSocket + 状态同步,20Hz 快照足够

3.7 同步相关的调试工具(务必自己造)

这三个工具是每个做同步的游戏都必须有的,从第一天就要有:

工具 作用 实现难度
状态校验和(Checksum) 每 N 帧计算全局状态 hash,客户端间/客户端-服务端比对,不一致立即报警 低(关键!)
网络模拟器 在本地注入延迟(50/100/200ms)、丢包(1%/5%)、抖动、乱序 中(所有 netcode 框架都自带)
回放系统(Replay) 记录输入流,可离线重放整个对局 中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 状态校验和:帧同步项目的救命稻草
func ComputeChecksum(entities []*Entity) uint32 {
var h uint32 = 2166136261 // FNV-1a
for _, e := range entities { // 必须按 ID 排序遍历!
h ^= uint32(e.ID)
h *= 16777619
h ^= uint32(e.Pos.X * 1000) // 定点数
h *= 16777619
h ^= uint32(e.Pos.Y * 1000)
h *= 16777619
h ^= uint32(e.HP)
h *= 16777619
}
return h
}

每个做帧同步的人都应该记住:没有 checksum 的帧同步项目,第一次 desync 出现时你无从下手,整个项目就此卡死。

3.8 同步模型 → 技术栈的映射

同步模型 服务端必须具备 语言建议 框架建议
状态同步 跑完整游戏逻辑、每 tick 处理 Go / C# / C++(PHP 出局) 自研 + Pitaya/Leaf 骨架
帧同步 跑同一份逻辑(双端共享)、状态快照 C#(ET 双端共享最佳) 或 C++ ET / 自研 + GGPO 思路
预测回滚 历史位置环形缓冲、低延迟 Go / C# 客户端用 NGO/FishNet/Quantum


第 4 章 网络层:传输协议与消息设计

4.1 传输协议选型

五种协议的实际表现

协议 可靠性 延迟 带宽 穿透 典型用途
TCP ✅ 有序可靠 ❌ 队头阻塞,抖动时延迟飙升 中 ⚠️ 需中转 登录、聊天、邮件、GM
UDP ❌ 不可靠 ✅ 最低 ✅ 最省 ✅ 最好 FPS 射击、状态同步
KCP ✅ 可靠有序 ✅ 快(牺牲带宽换延迟) ❌ 费带宽 ✅ 游戏行业主流(战斗/移动)
WebSocket ✅ 中 中 ❌ H5 / 小游戏 / 后台推送
QUIC ✅ ✅ 快 中 ✅ 移动端网络切换场景(未来趋势)

各协议的致命细节

TCP 的队头阻塞——这是最容易被低估的问题。

TCP 保证字节流有序到达。如果一个包丢了,后面所有包都要等它重传。在移动网络下(4G 抖动、WiFi 切换),单次 RTT 从 30ms 涨到 300ms 是常态,TCP 上的表现是整条连接的所有消息一起卡住。

游戏行业对此的解法是 KCP(skynet 作者云风的作品):

  • 基于 UDP 实现可靠有序传输
  • 快速重传(RTO 计算激进,丢了立刻重传而不是等超时)
  • 选择性确认(只重传真正丢的包)
  • 代价:带宽消耗比 TCP 高 10~30%
1
2
KCP 的取舍:牺牲 20% 带宽,换取 50% 的延迟下降。
对于"1 万玩家 × 20KB/s"的场景,这是划算的。

KCP 的三个使用要点(很多人在这里翻车):

  1. `KCP 的 stream 模式和 message 模式要选对:游戏协议用 message 模式(保留消息边界),不要用 stream 模式自己切包
  2. 必须自己做心跳:KCP 的底层 UDP 没有连接概念,需要应用层心跳(一般 10~20s 一次,3 次超时就断开)
  3. 必须自己做重连:KCP 不支持连接迁移,重连要重新握手 + 恢复状态

WebSocket 的场景:浏览器唯一选项。所有 H5 小游戏、微信小游戏只能用 WebSocket(或 WebTransport 试验性)。它的握手开销大(HTTP Upgrade),但后续是二进制帧,性能尚可。

QUIC:基于 UDP 的多路复用,解决 TCP 队头阻塞 + 支持连接迁移(手机从 WiFi 切 4G 不用重连)。生态还在成熟中,新项目可以考虑,但不作为首选。

实战推荐:混合协议

没有一个游戏只用一种协议。 典型配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌────────────────────────────────────────────┐
│ 登录 / 账号 / 商城 / 邮件 / 排行榜 │
│ → HTTP / HTTPS(Protobuf 或 JSON) │
│ → 可靠优先,可以重试 │
├────────────────────────────────────────────┤
│ 聊天 / 好友 / 公会 / 邮件推送 │
│ → WebSocket 或 TCP(Protobuf) │
│ → 可靠有序,需要 ACK │
├────────────────────────────────────────────┤
│ 战斗 / 移动 / 技能 / 位置同步(高频) │
│ → KCP 或 UDP(Protobuf / FlatBuffers) │
│ → 延迟优先 │
└────────────────────────────────────────────┘

实战技巧:在同一个端口上同时支持 TCP 和 UDP(很多游戏这么干),或者用协议嗅探:收到首包后判断是 HTTP / WebSocket / KCP,分发给不同处理器。这样运维只需开放一个端口。

4.2 序列化选型

对比

方案 体积 序列化速度 反序列化速度 零拷贝 schema 演进 跨语言
JSON 大 慢 慢 ✗ 无(靠约定) ✅ 最好
Protobuf 小 快 快 ✗ ✅ 强 ✅ 好
FlatBuffers 中(比 pb 大 ~25%) 最快 最快 ✅ ✅ 强 ✅ 好
MessagePack 小(比 pb 小 ~40%) 中 中 ✗ 中 ✅ 好
Cap’n Proto 小 极快 极快 ✅ ✅ 强 ✅ 好

(体积/速度数据来自公开的横评测试,量级参考用,精确值随数据类型变化)

选型结论

场景 选择 理由
通用 RPC / 业务接口 Protobuf 综合最优、生态最广、向后兼容、团队成员最熟
帧同步 / 高频状态推送 FlatBuffers 零拷贝读取,帧同步每秒几十次序列化,收益明显
H5 / 调试接口 / 后台 JSON 可读性 > 一切,浏览器原生支持
极限带宽优化 MessagePack + gzip 体积最小,适合移动端
配置表 JSON / 二进制(自定义) 见第 9 章

2026 年的最佳实践不是”自研二进制协议”,而是”基于成熟序列化库定制消息帧”。自研只保留消息头、加密、校验层,序列化体交给 Protobuf / FlatBuffers 处理。

为什么 Protobuf 是默认选择:

  • 向后兼容做得最好(加字段不破坏老客户端)
  • 跨语言代码生成成熟(Go / C# / C++ / Java / PHP / Rust / TS 全都有)
  • 生态工具链完整(protoc、grpc、buf)
  • 团队接手成本低

FlatBuffers 什么时候值得:

  • 帧同步里每秒几十次序列化,零拷贝收益可观
  • 需要”不反序列化就读取某个字段”(比如只想读 HP 做校验)
  • 代价:schema 演进比 Protobuf 麻烦,工具链不如 Protobuf 成熟

4.3 消息帧设计

消息头

游戏协议的消息头通常包含:

1
2
3
4
5
6
7
8
9
┌──────────────────────────────────────────────────┐
│ 2B 长度 (不含头) │ ← 解决粘包/拆包
│ 2B 消息号 (opcode) │ ← 路由
│ 4B 序列号 (seq) │ ← 去重/乱序检测
│ 4B 时间戳 (server time) │ ← 延迟测量/时间同步
│ 2B 错误码 (可选) │
│ 2B 保留/扩展 │
│ N 消息体 (Protobuf / FlatBuffers) │
└──────────────────────────────────────────────────┘

固定头 16 字节,用二进制读写(binary.BigEndian / PutUint32),不要用 JSON 包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Go: 消息帧的读写(生产级)
const HeaderSize = 16

type Header struct {
Length uint16 // 消息体长度
Opcode uint16 // 消息号
Seq uint32 // 序列号
Timestamp uint32 // 服务器时间戳(毫秒)
ErrCode uint16
Reserved uint16
}

func (h *Header) Marshal() []byte {
buf := make([]byte, HeaderSize)
binary.BigEndian.PutUint16(buf[0:2], h.Length)
binary.BigEndian.PutUint16(buf[2:4], h.Opcode)
binary.BigEndian.PutUint32(buf[4:8], h.Seq)
binary.BigEndian.PutUint32(buf[8:12], h.Timestamp)
binary.BigEndian.PutUint16(buf[12:14], h.ErrCode)
return buf
}

// 粘包/拆包处理:一个连接一个 buffer,可能有多帧
type FrameReader struct {
buf []byte
}

func (fr *FrameReader) Feed(data []byte) ([]*Packet, error) {
fr.buf = append(fr.buf, data...)
var packets []*Packet
for {
if len(fr.buf) < HeaderSize { break } // 头不全,继续等
h := &Header{}
length := int(binary.BigEndian.Uint16(fr.buf[0:2]))
total := HeaderSize + length
if len(fr.buf) < total { break } // 体不全,继续等
// 完整一帧
body := fr.buf[HeaderSize:total]
packets = append(packets, &Packet{Header: h, Body: body})
fr.buf = fr.buf[total:] // 消费掉
}
return packets, nil
}

踩坑提醒:fr.buf = fr.buf[total:] 这种切片写法会一直持有底层数组的引用,长期运行会导致内存持续增长。生产环境应该用 bytes.Buffer 或定期 copy 剩余数据。Go 里更好的做法是固定大小的 ring buffer。

消息号(Opcode)设计

1
2
3
4
5
6
7
8
0x0001 - 0x00FF    握手/心跳/基础
0x0100 - 0x01FF 登录/角色
0x0200 - 0x02FF 场景/移动
0x0300 - 0x03FF 战斗/技能/伤害
0x0400 - 0x04FF 背包/道具/装备
0x0500 - 0x05FF 社交(好友/公会/聊天)
0x0600 - 0x06FF 活动/任务
0x0700 - 0x07FF 商城/支付

约定:

  • 客户端 → 服务端(C2S)和 服务端 → 客户端(S2C)用不同号段或用不同最高位标记
  • 保留 0xFFFF 做错误/调试指令
  • 消息号一旦发布永不复用(老客户端还在线时不能用旧号)

哪些消息可以”不可靠”

并非所有消息都需要重传。设计协议时给每个消息打上可靠性标记:

消息类型 可靠性 说明
位置同步(高频) 可丢 下一帧会覆盖,丢了就丢了
技能释放 必须可靠 丢了玩家会放不出技能
伤害结算 必须可靠 丢了就少伤害
聊天消息 必须可靠 + ACK 需要送达确认
背包变更 必须可靠 数据不一致 = 资损

4.4 加密与防篡改

游戏客户端的请求必须假设全部是伪造的。

层次 措施 说明
传输 TLS / WSS 防中间人。注意:TLS 增加握手延迟,KCP 场景通常自己实现握手加密
协议头 校验和 / CRC 防随机损坏和简单篡改
请求体 HMAC 签名(含时间戳 + nonce) 防重放 + 防篡改
业务 服务端权威校验 最重要的一层:所有数值服务端重算
1
2
3
4
5
6
7
8
9
10
11
// 请求签名防重放
func Sign(secret, opcode uint16, seq uint32, timestamp uint32, body []byte) []byte {
h := hmac.New(sha256.New, secret)
h.Write([]byte{byte(opcode >> 8), byte(opcode), byte(seq >> 24), byte(seq >> 16), byte(seq >> 8), byte(seq)})
binary.BigEndian.PutUint32(tb, timestamp)
h.Write(tb)
h.Write(body)
return h.Sum(nil)
}

// 服务端校验:时间戳偏差 > 5s 直接丢弃;nonce 记录在 Redis 防重放

但要明白:签名防的是”随手改包”和”脚本小子”,防不住内存修改器。真正的防线是服务端权威——客户端报什么都是无效的,服务端自己算。签名只是过滤掉 99% 的低级攻击,减少带宽和 CPU 浪费。

4.5 心跳与超时

参数 典型值 说明
心跳间隔 15~30s 移动端网络波动大,太短浪费带宽
心跳超时 3 次心跳未响应 即 45~90s 判定掉线
客户端 TCP keepalive 30s 操作系统层兜底
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Go: 连接生命周期管理
type Conn struct {
fd net.Conn
lastActive atomic.Int64 // 最后一次收到数据的 Unix 时间
lastSend atomic.Int64
}

func (m *Manager) tick() {
now := time.Now().Unix()
for _, c := range m.conns {
idle := now - c.lastActive.Load()
if idle > 90 {
c.close("heartbeat timeout")
continue
}
// 服务端也主动发心跳(有些客户端网络不主动上报)
if now-c.lastSend.Load() > 20 {
c.sendHeartbeat()
}
}
}

重要:不要只依赖心跳包。任何收到数据都算”活跃”,这能避免玩家在挂机(但连接还在)时被误踢。

4.6 断线重连

玩家在电梯里断网 30 秒,游戏必须能接回来。

标准做法:

1
2
3
4
5
6
1. 客户端保存 session_token(登录时服务端下发,带玩家 ID + 登录时间)
2. 断线后,客户端用指数退避重连:1s, 2s, 4s, 8s, 15s, 30s(上限)
3. 重连时带上 session_token + 上次收到的最后 seq
4. 服务端验证 token 有效性(Redis 里查)
5. 断线期间玩家状态保留在内存(标记为"离线"但保留),超时(如 5 分钟)才真正清理
6. 重连成功后:服务端重发场景数据 + 缺失期间的增量消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 重连时的状态恢复
func (s *Session) onReconnect(token string, lastSeq uint32) error {
player, err := s.playerMgr.Restore(token) // 从 Redis/内存找回玩家
if err != nil { return ErrSessionExpired }

// 补发断线期间的增量消息
missed := s.msgStore.Since(player.ID, lastSeq)
for _, m := range missed { s.send(m) }

// 重置玩家状态
player.Online = true
player.ClearOfflineTimer()
return nil
}

断线保留时长是个产品决策:保留太久浪费内存(内存里每个玩家对象可能几 KB),太短玩家体验差。常见做法是保留 5~10 分钟。

4.7 流量控制与背压

这是 Web 开发者最容易漏掉的一环。Web 里一个慢客户端只会占一个请求线程;游戏里一个慢客户端会拖住整个广播。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 每个连接一个发送队列 + 上限,超出就断开(而不是无限堆积)
type SendQueue struct {
ch chan []byte
maxSize int
dropped int64
}

func (q *SendQueue) Push(msg []byte) bool {
select {
case q.ch <- msg:
return true
default:
q.dropped++
// 队列满:这个客户端消费不过来,慢客户端
return false // → 触发断开
}
}

关键原则:宁可断开一个卡住的客户端,也不要让整个房间的广播被它拖住。

其他流量控制手段:

  • 令牌桶限流:单客户端每秒消息数上限(防客户端 bug 刷屏)
  • 广播分级:核心战斗消息(血量、死亡)> 位置更新 > 特效表现(可丢)
  • 背压传染防护:一个场景服的广播循环里,任何一个连接写失败要立即跳过,不能阻塞其他人

4.8 带宽实测参考

品类 单玩家下行 单玩家上行 备注
MMORPG 10~30 KB/s 1~3 KB/s AOI 裁剪后
状态同步竞技 15~40 KB/s 2~5 KB/s 含预测回滚
帧同步 MOBA 3~8 KB/s 1~2 KB/s 只传输入,优势巨大
回合制/卡牌 < 1 KB/s < 0.5 KB/s 几乎可以忽略
H5 小游戏 5~15 KB/s 1~2 KB/s

1 万玩家 × 20KB/s ≈ 200 MB/s ≈ 1.6 Gbps —— 这是选机器时的第一道算术题,比”C++ 还是 Go”重要得多。



第 5 章 服务端架构:从单服到分布式

5.1 演进路径

不要一开始就做分布式。 这是独立开发者最常犯的架构错误——还没跑通玩法就先搭了 K8s 集群。

1
2
3
4
5
6
7
8
9
10
阶段 1:单进程单体        ← 从这里开始
所有逻辑在一个进程里
↓ 玩家变多
阶段 2:多进程分服 ← 分房间/分线,多机部署
↓ 业务复杂
阶段 3:逻辑分层 ← 抽出公共服务(聊天/排行/匹配)
↓ 规模大
阶段 4:分布式 ← 一致性哈希分片、跨服玩法
↓ 全球运营
阶段 5:多机房/全球 ← 就近接入、异地容灾

判断标准:

  • 阶段 1 能撑 单服 1000~3000 人(Go + 优化得当)
  • 阶段 2 撑 单服 1 万人 / 集群 10 万人
  • 阶段 3 是 MMORPG 的常规形态
  • 阶段 4+ 只有大公司才需要

独立开发者的现实建议:从阶段 1 开始,把架构设计成”逻辑分层,物理单机“。代码里分好 gateway / logic / common / dbproxy 的包,部署时先跑在同一个进程里,等有需要再拆进程——改配置就能拆,这是 ET 框架的 1 变 N 思路,非常值得借鉴。

5.2 经典分层架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌──────────────────────────────────────────────────────────────┐
│ 客户端 │
└──────────────────────────┬───────────────────────────────────┘
│ TCP / KCP / WebSocket
┌──────────────────────────▼───────────────────────────────────┐
│ 接入层 Gateway(无状态) │
│ · 连接管理、协议编解码、鉴权握手 │
│ · 玩家 ID ↔ 连接 的映射 │
│ · 限流、黑名单、软路由 │
│ ✅ 水平扩容无压力 │
└──────────────────────────┬───────────────────────────────────┘
│ 内部 RPC
┌──────────────────────────▼───────────────────────────────────┐
│ 逻辑层 GameServer / SceneServer(有状态,内存态) │
│ · 玩家对象、场景实体、战斗结算、技能、AI、副本 │
│ · 固定 tick 循环 │
│ ⚠️ 扩容需要状态迁移 │
└──────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────▼───────────────────────────────────┐
│ 公共层 CommonService(完全无状态) │
│ · 匹配、排行榜、聊天、公会、跨服邮件、全局事件总线 │
│ ✅ 随便扩 │
└──────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────▼───────────────────────────────────┐
│ 数据层 DBProxy / Storage(有状态但职责单一) │
│ · MySQL 读写、Redis 缓存、事务协调、分库分表路由 │
└──────────────────────────────────────────────────────────────┘

各层的关键设计

接入层(Gateway)—— 你的”无状态前端服务器”

它的职责边界很清晰:

  • 握手、鉴权、限流
  • 协议编解码(这是最耗 CPU 的部分之一,和业务逻辑分开可以独立扩容)
  • 维护 playerID → conn 映射
  • 软路由(ET 框架的特性):客户端连的是网关,网关把逻辑请求转发到真正的逻辑服;当逻辑服被攻击/过载时,可以动态换路由,客户端无感知、不断连、不丢数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Gateway: 极简的连接管理
type Gateway struct {
conns sync.Map // playerID -> *Conn
playerMap sync.Map // connID -> playerID
locator Locator // 定位玩家在哪个逻辑服
}

func (g *Gateway) onHandshake(c *Conn, req *HandshakeReq) error {
// 1. 校验 token(Redis)
player, err := g.auth.Verify(req.Token)
if err != nil { return ErrAuthFailed }

// 2. 限流检查
if !g.limiter.Allow(player.ID) { return ErrRateLimited }

// 3. 找到玩家当前所在逻辑服(或分配新的)
node := g.locator.Find(player.ID)
if node == nil {
node = g.locator.Assign(player.ID)
}

// 4. 记录连接
g.conns.Store(player.ID, c)
c.SetNode(node)

// 5. 通知逻辑服玩家上线
g.rpc(node, "PlayerOnline", player.ID)
return nil
}

逻辑层(GameServer)—— 你的”游戏世界”

这是整个系统最重的部分。核心特征:

  • 固定 tick 循环(不是事件驱动,也不是自由并发)
  • 单线程处理一个场景(避免锁)
  • 玩家的所有状态在这里的内存里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 场景服的 tick 循环 —— 整个游戏服务端的心脏
func (s *Scene) Run(ctx context.Context) {
const tickRate = 20 // 20 FPS 逻辑帧
const tickDur = time.Second / tickRate

ticker := time.NewTicker(tickDur)
defer ticker.Stop()

var last = time.Now()
for {
select {
case <-ctx.Done():
s.saveAll() // 退出前落盘
return
case <-ticker.C:
now := time.Now()
delta := now.Sub(last)
last = now

s.processIncoming() // 处理本帧收到的网络消息
s.updateAI(delta) // AI、寻路
s.updateSkills(delta)// 技能 CD、buff、伤害结算
s.updateMovement(delta)
s.updateAOI() // 计算谁该看到谁
s.broadcast() // 广播状态给需要的人
s.checkTimeouts() // 超时、断线处理
}
}
}

为什么是固定 tick 而不是事件驱动?

  1. 可预测:每帧的工作量可控,不会因为某个事件突然卡住整帧
  2. 可同步:如果要做帧同步,固定 tick 是前提
  3. 易调试:问题可以按 tick 重放

缺点是”响应延迟”最高为一个 tick(20 FPS = 50ms)。但对于状态同步游戏,这个延迟被插值掩盖了。

公共层(CommonService)—— 你的”共享服务”

匹配、排行、聊天、公会这些功能不该每个逻辑服都跑一份。抽出来的好处:

  • 排行榜是全局的(分服排行 vs 全服排行的实现完全不同)
  • 聊天需要跨场景广播
  • 匹配需要跨服搜索玩家
  • 独立扩容(活动期间聊天服务可以单独加机器)

数据层(DBProxy)—— 你的”存储代理”

不是简单地”包一层 MySQL 驱动”,而是承担:

  • 读写分离:写走主库,读走从库
  • 分库分表路由:playerID % 16 → 决定库表
  • 缓存旁路:读请求先查 Redis,miss 再查 MySQL 并回填
  • 写缓冲:内存队列攒批,异步刷盘

5.3 有状态 vs 无状态的边界

这是分布式架构里最核心的取舍。边界划错了,扩展能力就废了。

层 状态性 能不能随便加机器 为什么
Gateway 无状态 ✅ 随便加 状态在客户端连接上,进程崩了连接断了客户端重连即可
逻辑服 有状态 ❌ 需迁移 玩家对象在内存里
公共层 无状态 ✅ 随便加 无状态或状态在 DB
DBProxy 有状态但单一职责 ⚠️ 谨慎 连接池是状态,但可以分片水平扩

关键认知:”有状态”不等于”不能扩展”。有状态服可以通过一致性哈希分片来扩展:

1
2
3
4
5
6
7
8
// 一致性哈希路由:玩家 → 节点
// 优势:扩容时只迁移 1/N 的玩家;取模 N→N+1 时要迁移几乎全部
func (r *ConsistentHash) GetNode(playerID uint64) *Node {
key := fmt.Sprintf("player:%d", playerID)
hash := md5.Sum([]byte(key))
pos := binary.BigEndian.Uint32(hash[:4]) % r.ringSize
return r.ring[pos]
}
1
2
3
4
取模路由:玩家在 node = id % 3
扩容到 4:id % 3 ≠ id % 4 → 几乎所有玩家都要迁移 ❌

一致性哈希:扩容到 4 节点 → 只有 1/4 的玩家需要迁移 ✅

5.4 状态外置与故障恢复

有状态服最怕崩溃——进程一挂,内存里 2000 个玩家对象全没了。

三级存储方案:

1
2
3
热数据:进程内存(Player Object)      访问:纳秒级    丢失:崩溃即丢
温数据:Redis(player:12345 → JSON) 访问:亚毫秒 丢失:最多 30 秒
冷数据:MySQL(player 表) 访问:毫秒级 丢失:无
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定时快照:每 30 秒把脏数据刷到 Redis
func (s *Scene) periodicSnapshot() {
for _, p := range s.players {
if !p.Dirty() { continue }
// 1. 写 Redis(快速恢复)
s.redis.SetEX(ctx, fmt.Sprintf("player:%d", p.ID), 3600, p.ToJSON())
// 2. 标记待刷 MySQL
s.dbQueue.Push(p.ID)
p.ClearDirty()
}
}

// 崩溃恢复:Redis 优先,回落 MySQL
func (m *PlayerManager) Restore(playerID uint64) (*Player, error) {
// 先查 Redis
if data, err := m.redis.Get(ctx, fmt.Sprintf("player:%d", playerID)); err == nil {
return PlayerFromJSON(data), nil
}
// Redis 没有(超时太久或从未存过)→ 查 MySQL
return m.db.Load(playerID)
}

更进一步的保障:

  • 玩家数据在 Redis 里的 TTL 应该是 1 小时,但定时快照每 30 秒写一次 → 最多丢 30 秒
  • 重要数据(充值、付费道具)走独立链路,实时落库 + 幂等设计
  • 战斗中的玩家不落盘(副本/竞技场是临时状态,结束后结算一次)

5.5 分服与跨服

分服(Shard / World)

早期页游和现在的手游都用分服。核心是数据隔离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
方案 A:database-per-world(最常见)
World 1 → DB 1
World 2 → DB 2
World 3 → DB 3
✅ 隔离彻底,World 3 的排行榜查询只查 DB 3
❌ 跨服功能难做

方案 B:共享表 + shard_key
SELECT * FROM players WHERE world_id = 3 AND player_id = 100500;
✅ 小规模时运维简单
❌ 表大了以后性能下降

方案 C:混合(推荐)
全局账号库(认证、封禁、设备、token 生成)
分世界库(角色状态、背包、进度)
✅ 一个账号可以在多个世界有角色,这是标准做法

账号与角色分离是必须理解的架构概念:

1
2
3
客户端 → 全局账号服务 → 认证 + 发 token → 选区 → 世界服
↓
世界服信任 token,不重新验密码

这样设计的好处:

  • 角色状态可以在世界间迁移
  • 封禁是全局的(一次封禁,所有世界都进不去)
  • 支付是全局的

跨服(Cross-Server)

分服带来隔离,跨服玩法解决隔离带来的问题:

1
2
3
World 1 ─┐
World 2 ─┼─→ 跨服服务 ← 战斗服
World 3 ─┘

典型跨服玩法:跨服 PVP、跨服公会战、全服竞技场、全服排行、跨服聊天、赛季 tournaments、共享世界 Boss、跨服副本。

跨服的关键设计:

  • 不需要访问完整玩家数据库。世界服发送玩家快照给跨服服务
  • 跨服服务算完结果 → 各世界服各自应用奖励和排行变化
  • 这样耦合度低,且世界服不需要开放数据库

玩家 ID 冲突

各服独立生成 ID 会冲突。两种解法:

  1. 组合标识符:(worldId, playerId) 全局唯一
  2. 全局唯一 ID:把 timestamp | worldId | sequence 编码进一个数字

分布式 ID 策略上线后极难改,因为它会出现在:数据库记录、公会成员、消息体、交易流水、日志、跨服协议里。立项就要定好。

5.6 服务发现

方案 特点 适用
静态配置 写死在配置文件里 单机/少量节点,最简单
etcd Go 生态主流,K8s 生态标配 Go 项目的推荐选择
Consul 服务发现 + KV + 健康检查一体 多语言团队
Nacos 阿里系,配置中心强 Java 团队 / 国内环境
K8s Service/DNS 基础设施即发现 已上 K8s 时最省事

Pitaya 内建 etcd/Consul 服务发现,这是它相对 skynet 的现代之处。

5.7 部署形态

单机部署(起步阶段)

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────┐
│ 1 台 8C16G 云服务器 │
│ ┌────────────────────────────────┐ │
│ │ game-server (多进程) │ │
│ │ ├─ gateway × 2 │ │
│ │ ├─ scene-1 × 1 │ │
│ │ └─ common × 1 │ │
│ └────────────────────────────────┘ │
│ Redis (或用云 Redis) │
│ MySQL │
│ Nginx (WebSocket 反代 + 静态资源) │
└─────────────────────────────────────┘

单机能撑多少(Go + 优化良好):

  • 状态同步游戏:3000~8000 长连接
  • 帧同步游戏:10000+ 长连接(逻辑在客户端)
  • 纯大厅/聊天:2 万+

容器化(中期)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 多阶段构建:编译 → 精简运行镜像
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/gameserver ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY --from=builder /bin/gameserver /usr/local/bin/gameserver
EXPOSE 8000 8001
ENTRYPOINT ["/usr/local/bin/gameserver"]

容器化的游戏服务端要点:

  • 必须设 ulimit -n 足够大(比如 100 万),容器默认 1024 远远不够
  • 必须处理优雅退出(收到 SIGTERM 后停止接收新连接、落盘、退出)
  • 健康检查端点:/healthz 返回当前连接数、tick 延迟
  • 不要在容器里存数据(用卷或直接用外部 MySQL/Redis)
  • 日志输出到 stdout,用 sidecar 收集

K8s + Agones(大规模专用服务器)

竞技游戏需要「每场比赛一个专用服务器进程」的模型(因为不同房间要互相隔离,避免一个房间崩了影响别人)。这是 Agones 的场景:

1
2
3
4
5
Agones 提供:
GameServer 资源 = 一个游戏服务器实例(一个 Pod)
Fleet 资源 = 一组保持热备的 GameServer(避免冷启动)
Allocation = 把一个 GameServer 分配给一场比赛
本地 SDK sidecar = 上报健康状态、接收生命周期指令

完整链路:

1
2
3
4
5
6
7
8
9
玩家排队 → Open Match 创建 Ticket
→ Open Match 的 Match Function 按 MMR 分组
→ 生成 Match
→ 你的分配逻辑向 Agones 请求一个 GameServer
→ Agones 标记为 Allocated,返回 IP:Port
→ 写回 Ticket
→ 客户端直连专用服务器
→ 90 秒战斗
→ 结果回写各世界服 → 循环

Agones + Open Match 是 2026 年 Google 官方推荐的组合,两者是互补关系不是竞争关系:

  • Agones 回答”比赛在哪里跑”
  • Open Match 回答”谁和谁比赛”
  • Nakama 回答”玩家在比赛之外做什么”(登录、好友、钱包、排行榜)

独立开发者的现实判断:Agones + Open Match 是一套重 Kubernetes 工程。如果你一个人做游戏,这套的运维负担可能超过收益。更务实的做法是:自己写一个轻量的”房间分配器”(用 Redis 存房间号 → 进程地址的映射,分配时从 Redis 池里取一个空闲进程),几十行代码就能覆盖 80% 的需求。

5.8 消息队列

游戏服务端里 MQ 的用途比 Web 少(游戏逻辑强调实时同步),但有几个场景离不开:

场景 用 MQ 吗 说明
玩家数据落库 ✅ 攒批写,削峰
跨服玩法 ✅ 世界服 → MQ → 跨服服务
支付回调 ✅ 异步处理 + 幂等
邮件/公告 ✅ 广播给在线玩家
战斗内实时逻辑 ❌ 必须同步调用,等结果
玩家操作响应 ❌ 必须同步返回

选型:

  • NATS:轻量、Go 生态原生、性能好。游戏服务端首选
  • Redis Streams:如果已经在用 Redis,不想多引入一个组件
  • Kafka:需要高吞吐、持久化、消息回放时
  • RabbitMQ:功能全但重,游戏场景用不上那么多特性

Pitaya 用 NATS(它本身基于 etcd + NATS),这是它的默认推荐。



第 6 章 核心系统拆解

这一章逐个拆游戏服务端的标准模块。每个模块给出设计要点 + 实现骨架 + 常见坑。

6.1 房间系统(Room)

房间是状态同步游戏的基本单元。一个房间 = 一个独立游戏实例 = 一个逻辑执行单元。

设计要点

决策 选项 建议
房间归属 独占进程 / 进程内多房间 进程内多房间(一个 Scene 进程管 N 个房间),房间数上限受内存限制
房间状态 全部内存 / 关键状态内存+DB 战斗房间全内存,结束后结算
房间生命周期 固定时长 / 人数满足 / 空闲销毁 三者都要:到时结束、人满即开、空闲 5 分钟回收
空房间回收 立即 / 延迟 延迟回收(避免频繁创建销毁的抖动)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 房间管理器:池化 + 延迟回收
type RoomManager struct {
rooms sync.Map // roomID -> *Room
pool chan *Room // 空闲房间池
maxRooms int
}

func (m *RoomManager) Acquire(cfg RoomConfig) (*Room, error) {
// 1. 优先从池里取
select {
case r := <-m.pool:
r.Reset(cfg)
return r, nil
default:
}
// 2. 新建(检查上限)
if m.count() >= m.maxRooms {
return nil, ErrRoomLimit
}
r := NewRoom(cfg)
m.rooms.Store(r.ID, r)
return r, nil
}

func (m *RoomManager) Release(r *Room) {
r.Clear() // 清空所有玩家引用
select {
case m.pool <- r: // 回收复用
default:
}
}

房间的 tick 模型

一个房间一个 goroutine 是最简单可靠的方案(Go 的 goroutine 很轻,一个房间几千个 goroutine 没问题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (r *Room) Run() {
ticker := time.NewTicker(r.tickDur)
defer ticker.Stop()
for {
select {
case <-r.ctx.Done():
r.finalize() // 结算、发奖励、释放
return
case msg := <-r.msgQueue: // 网络消息进来
r.handleMsg(msg)
case <-ticker.C:
r.tick() // 逻辑推进 + 广播
}
}
}

注意:所有房间共享一个物理进程时,不要让一个房间的 tick 阻塞其他房间。Go 里每个房间一个 goroutine 天然隔离,这是 Go 相比 Java 单线程事件循环的优势。

6.2 匹配系统(Matchmaking)

匹配算法的选择

算法 适用 复杂度 质量
随机 凑人数即可 O(1) 差(体验割裂)
按段位分桶 有段位体系的游戏 O(1) 中(等待时间长)
Elo / Glicko 评分 竞技游戏 O(n) 好
基于等待时间放宽阈值 所有(必配) — —

Elo 评分(最常用,可解释性好):

1
2
3
新分 = 旧分 + K × (实际结果 - 预期结果)
预期结果 = 1 / (1 + 10^((对手分 - 自己分) / 400))
K 值:新手 40,熟练 24,大师 16

Glicko-2 更准确(考虑了对手分差的不确定度),但实现复杂。对独立开发者,Elo 够用。

匹配池的设计(关键)

1
匹配池 = 一个「等待中的玩家队列」,按若干维度分桶
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 匹配池:按 (模式, 区域, 段位段) 分桶
type MatchPool struct {
buckets sync.Map // key → *Bucket
}

type Bucket struct {
mu sync.Mutex
players []*Ticket // 按等待时间升序
}

func (b *Bucket) TryMatch(need int, maxWait time.Duration) []*Ticket {
b.mu.Lock()
defer b.mu.Unlock()

// 1. 基础:取等待时间最长的 N 个
if len(b.players) < need { return nil }
candidates := b.players[:need]

// 2. 弹性放宽:等得越久,允许的段位差越大
oldest := time.Since(candidates[0].EnqueueTime)
tolerance := CalculateTolerance(oldest, maxWait) // 线性或指数放宽

// 3. 校验段位差
for _, p := range candidates {
if math.Abs(p.Rating - candidates[0].Rating) > tolerance {
return nil // 本轮不成团,下轮再试
}
}
return candidates
}

// 弹性匹配:随等待时间放宽匹配范围
func CalculateTolerance(waited, maxWait time.Duration) int {
base := 50 // 初始 ±50 分
max := 500 // 最大 ±500 分
progress := float64(waited) / float64(maxWait)
if progress > 1 { progress = 1 }
return int(float64(base) + (float64(max)-float64(base))*progress)
}

弹性匹配是竞技游戏匹配体验的关键。严格的 MMR 过滤器(±50 分)匹配质量高但离线时段排队时间爆炸。随等待时间逐步放宽是行业标准做法。

匹配池的分层顺序(性能优化)

匹配池的过滤条件应该按区分度从高到低排列,先缩小候选集:

1
2
3
1. 区域(region)    ← 区分度最高,跨国玩家的延迟不可接受
2. 模式(mode) ← 3v3 不能和 5v5 混
3. 段位段 ← 最后才过滤

常见错误:全局一个池,先按 mode 过滤 → 每次都要扫描全池。正确做法是池的分层就是嵌套的:region/mode/段位段 三级索引。

匹配流程(完整链路)

1
2
3
4
5
6
7
8
9
10
11
12
1. 玩家点开始 → 创建 Ticket(玩家ID、模式、评分、区域、进入时间)
2. Ticket 进入匹配池
3. 匹配器循环(每 1~2 秒跑一次):
a. 从池中取出候选 Ticket
b. 校验人数够、段位差在容忍范围内
c. 不足 → 留在池里,等待
d. 足够 → 移除所有 Ticket,生成 Match
4. Match 提交给"服务器分配器":
a. 从进程池取一个空闲房间/专用服务器
b. 把连接信息(IP:Port + 房间密钥)写入 Redis
5. 通知匹配池里的玩家:连到 IP:Port,用房间密钥握手
6. 玩家进入服务器,超时未到则替换/退款

断线与补位

“玩家中途掉线”不是边缘情况,是常态(这是别人文档里的话,但确实如此)。

匹配流程必须支持:

  • 服务器上报「我需要 1 个玩家」→ 生成新 Ticket 回到池里
  • 匹配结果是可变的组,不是一次性的不可变集合
  • 超时机制:分配后 N 秒内未到齐,释放位置或判定失败

6.3 AOI(Area of Interest,视野管理)

这是 MMO 服务端最核心的性能技术。没有 AOI,千人同屏的带宽需求是荒谬的。

问题量化

1
2
1000 人同图,每人每秒动 2 次,每次全图广播 1000 条:
1000 × 2 × 1000 = 200 万条/秒

四级演进

方案 单次移动消息数 找”附近的人”成本 抗边界抖动 适用
全图广播 O(n) 0 — 原型、几十人小房间
视野半径裁剪 O(k) O(n) 全图扫描 ❌ 百人品级
九宫格(格子 AOI) O(k) O(格内人数) 需配滞回 千人同图主流
十字链表 O(k) O(k),增量维护 ✅ 天然平滑 超大规模 MMO
十字链表 + 降频 + 位面 O(k) 再打折 O(k) ✅ 万人同图工业方案

九宫格实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 2D 九宫格 AOI
type GridAOI struct {
cellSize float64
grids map[CellCoord]*Cell
}

type CellCoord struct{ X, Y int }

type Cell struct {
entities map[uint64]*Entity
}

func (g *GridAOI) cellOf(pos Vec2) CellCoord {
return CellCoord{
X: int(math.Floor(pos.X / g.cellSize)),
Y: int(math.Floor(pos.Y / g.cellSize)),
}
}

// 玩家移动:只处理跨格的情况
func (g *GridAOI) onMove(e *Entity, newPos Vec2) {
oldCell := g.cellOf(e.Pos)
newCell := g.cellOf(newPos)

if oldCell == newCell {
// 同格移动:位置更新照常广播给九宫格内的观察者
g.broadcastToNeighbours(newCell, e, MoveMsg(e.Pos))
e.Pos = newPos
return
}

// 跨格:三组差集
oldSet := g.neighbours(oldCell) // 旧九宫格的 9 格
newSet := g.neighbours(newCell) // 新九宫格的 9 格

// a 类:在旧九宫格,不在新九宫格 → 离开视野
for _, c := range oldSet.Difference(newSet) {
for _, other := range g.grids[c].entities {
other.Send(LeaveAOIMsg(e.ID))
}
g.Send(LeaveAOIMsg(e.ID), g.grids[c].entities) // 告诉离开的人"我走了"
}
// c 类:不在旧九宫格,在新九宫格 → 进入视野
for _, c := range newSet.Difference(oldSet) {
for _, other := range g.grids[c].entities {
other.Send(EnterAOIMsg(e))
e.Send(EnterAOIMsg(other))
}
}
// b 类:交集 → 位置更新照常发,不发 enter/leave

// 维护格子索引
g.grids[oldCell].Remove(e)
g.grids[newCell].Add(e)
e.Pos = newPos
}

网格边长怎么定

经验值:网格边长 = 视野半径 × 1.5 ~ 2.0

  • 太小(如 50m)→ 玩家频繁跨格,AOI 计算开销大
  • 太大(如 1000m)→ 单格内人数过多,九宫格查找变慢
  • 刚好 1.5~2 倍视野半径 → 九宫格能覆盖视野范围,但边缘不会有多余实体

三个必备的补丁

1. 滞回(Hysteresis)防抖

玩家在格子边界来回横跳,会产生 enter/leave 事件风暴。

1
2
进入视野半径 = 80m
离开视野半径 = 100m ← 两个阈值错开

走进 80m 才算”出现”,走出 100m 才算”消失”。这是所有 AOI 实现的标配。

2. 十字链表(3D 场景)

云风(skynet 作者)提出的经典优化。不需要格子,每个实体在 X/Y/Z 三个轴上各维护一条有序链表,查询时沿链表向两边走,到半径外就停。

  • 插入/删除:O(1) 均摊
  • 查询:O(k),k 是视野内实体数
  • 天然平滑,不需要滞回
1
2
3
4
轴标记设计:
PosMarker 实体在该轴的实际坐标
NegMarker -坐标(从另一端遍历用)
RangeMarker 标记实体的半径边界

3. 远近分级降频

视野内再分三六九等:

距离 同步频率 内容
0~50m 每帧(20Hz) 完整状态 + 朝向 + 动画
50~200m 5Hz 位置 + 朝向
200~500m 1Hz 仅位置,不同步朝向

远处的敌人你根本看不出他是 20Hz 还是 5Hz 在动。

4. 位面分层(Layering)—— 最后的兜底

《魔兽世界》用的技术:同一个主城,玩家被悄悄分到多个平行位面,每个位面里同屏人数可控。你感觉”人山人海”,服务器眼里是”十几个小房间”。

国产 MMO 国战:同屏人数上限 + 超出的玩家降级为简化模型(”假人”,只同步位置不播动作);极端场景直接分线、分战场。

静态与动态分离

树木、石头、建筑等静态物体不需要 AOI 同步(客户端本地渲染或一次性加载)。AOI 只管理动态实体(玩家、怪物、NPC)。这一条能砍掉 50% 以上的实体管理开销。

6.4 战斗系统

职责划分

1
2
3
4
5
6
7
8
9
客户端                          服务端
│ 输入(技能ID + 朝向 + 目标) │
│ ──────────────────────────→ │
│ │ ① 校验:技能CD、状态、距离、朝向、资源
│ │ ② 计算:命中判定、伤害公式、buff
│ │ ③ 结算:扣血、加buff、记录伤害统计
│ 结算结果(血量、buff、特效触发)│
│ ←────────────────────────── │
│ ④ 表现:播放技能动画/特效 │ ← 表现由客户端负责,服务端只发"发生了什么"

关键原则:服务端发的是「发生了什么」,不是「怎么播」。

1
2
❌ 服务端发:skill_fire_animation(id=123, pos=..., rot=...)
✅ 服务端发:skill_triggered(skillId=fireball, casterId=42, targetId=88, effectId=fx_fireball_01)

服务端不需要知道客户端有没有这个特效资源。

伤害计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 服务端权威的伤害计算
func (ctx *BattleContext) calcDamage(attacker, defender *Entity, skill *SkillConfig) Damage {
// 1. 基础伤害
base := skill.BaseDamage

// 2. 攻击属性加成
atk := attacker.Atk()
base = base + atk*skill.AtkRatio

// 3. 防御减免(减法公式 vs 乘法公式,选一个别混用)
def := defender.Def()
damage := base * (1 - def/(def+Constant)) // 乘法:避免负数和无限收益

// 4. 暴击
if ctx.rng.Roll(skill.CritRate) {
damage *= skill.CritMultiplier
}

// 5. 减伤 buff
damage *= (1 - defender.DamageReduction())

// 6. 上下限钳制
damage = clamp(damage, 0, skill.MaxDamage)

return Damage{Value: math.Round(damage), IsCrit: crit}
}

数值公式的通用原则(重要,你的策划会反复问):

原则 说明
减法有下限,乘法无上限 base - def 会在 def > base 时变负;base * (1 - k) 天然在 [0, base]
边际递减 防御/减伤用 k/(k+x) 形式,收益递减,不会出现”堆到一定量就无敌”
整数结算 最终伤害取整,避免浮点误差累积
常量集中管理 所有魔法数字(Constant、系数)放配置表,不要硬编码

命中判定

1
2
3
4
5
6
7
8
9
// 判定必须在服务端,且要考虑延迟补偿(第 3.5 节)
func (ctx *BattleContext) resolveHit(shooter, target *Entity, aim Vec3) bool {
// 1. 延迟补偿:取目标在射手视角时刻的位置
rewindTick := ctx.shooterViewTick(shooter)
targetPos := ctx.history.Position(target.ID, rewindTick)

// 2. 射线检测(服务端用历史位置,客户端用当前位置显示)
return rayHit(aim, ctx.history.Position(shooter.ID, rewindTick), targetPos, ctx.hitRadius)
}

6.5 背包与道具

这是你最熟悉的领域(数据库 CRUD),但有几个游戏特有的规则:

关键设计

规则 说明
唯一物品 ID vs 模板 ID itemId=10001(模板,”红药水”)vs uid=880123456(实例,”玩家 42 的第 3 瓶红药水”)
堆叠 同模板可堆叠(count=99),不同实例独立
属性随机词条 装备的词条是实例级属性,同模板不同实例属性不同
背包容量 格数限制(slots),也可能有限重
交易/拾取 需要事务或状态机
ID 唯一性 uid 必须全局唯一 → 分布式 ID 方案在这里发挥作用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 背包模型:模板 + 实例分离
type Inventory struct {
playerID uint64
slots [MaxSlots]*ItemStack // 固定数组,O(1) 定位
}

type ItemStack struct {
TemplateID uint32 // 物品模板(配置表)
Count uint32 // 堆叠数量
UID uint64 // 唯一实例 ID(不可堆叠时有意义)
Attributes map[uint32]int64 // 随机词条(装备用)
Locked bool // 交易中锁定
}

// 增删物品的原子性(防重复/丢失)
func (inv *Inventory) AddItem(templateID uint32, count uint32) error {
// 1. 优先堆叠到已有槽位
for i, s := range inv.slots {
if s != nil && s.TemplateID == templateID && s.Count+count <= MaxStack {
s.Count += count
return nil
}
}
// 2. 找空槽位
for i, s := range inv.slots {
if s == nil {
inv.slots[i] = &ItemStack{TemplateID: templateID, Count: count, UID: nextUID()}
return nil
}
}
return ErrBagFull
}

背包操作必须在服务端原子完成。客户端显示的背包是服务端的镜像——永远不要相信客户端发来的”我有多少个物品”。

6.6 排行榜

Redis ZSET 是标准答案。这个用法你在 Web 开发里应该已经很熟了:

1
2
3
4
ZADD rank:leaderboard:arena:1 <score> <playerID>
ZREVRANK rank:leaderboard:arena:1 0 99 WITHSCORES # 取前 100
ZREVRANK rank:leaderboard:arena:1 <myRank> # 查我的排名
ZREMRANGEBYRANK rank:leaderboard:arena:1 0 -101 # 只保留前 100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全局排行榜:各世界服上报,全服服务合并
// 1. 每个世界服定期(如每 10 秒)上报本地前 N 名
// 2. 全服服务读取所有世界的上报,合并去重,取全局前 N
// 3. 结果缓存,定时刷新

func (s *GlobalRankService) refresh() {
all := map[uint64]int64{} // playerID → score
for _, world := range s.worlds {
local := s.redis.ZRevRangeWithScores(ctx, rankKey(world.ID), 0, 99)
for _, item := range local {
pid := parsePlayerID(item.Member)
if score, ok := all[pid]; !ok || item.Score > score {
all[pid] = item.Score
}
}
}
// 写入全局榜
s.redis.Del(ctx, globalRankKey)
for pid, score := range all {
s.redis.ZAdd(ctx, globalRankKey, &redis.Z{Score: score, Member: pid})
}
}

注意事项:

  • 只保留前 N 名(ZREMRANGEBYRANK),否则 ZSET 会无限膨胀
  • 分数相同时用 playerID 做二级排序,保证顺序稳定
  • 排行榜更新频率可以低(10 秒一次足够),不需要实时
  • 分数变化时异步推送给玩家(”你上榜了!”)

6.7 聊天与社交

聊天系统

类型 实现
世界频道 广播到当前场景/地图的所有人(受 AOI 约束,因为你只能看到视野内的人)
公会频道 广播到公会成员(公会成员列表缓存)
私聊 点对点,直接投递到目标连接的发送队列
系统消息 定向推送(playerID → conn 映射)
1
2
3
4
5
6
// 定向推送:按 playerID 找连接(Gateway 层)
func (g *Gateway) PushTo(playerID uint64, msg []byte) error {
v, ok := g.conns.Load(playerID)
if !ok { return ErrPlayerOffline }
return v.(*Conn).Send(msg) // 走发送队列,满了就断开
}

社交系统(好友、公会、师徒)本质是关系图 + 状态数据:

1
2
好友关系:存 MySQL(关系型、需要查询"我的好友列表")+ Redis 缓存(热点)
公会:公会信息 + 成员列表(Redis 存成员列表的 Set,MySQL 持久化)

6.8 任务与活动

这是策划的主战场,也是最容易设计失误的地方。

任务系统的经典设计:状态机

1
2
3
任务状态:未接取 → 进行中 → 已完成(可提交)→ 已提交(已领奖)
↓
已过期
1
2
3
4
5
6
7
8
9
type Quest struct {
ID uint32
Type QuestType // 主线/日常/成就/限时
State QuestState
Progress map[uint32]int64 // 目标ID → 当前进度
Target map[uint32]int64 // 目标ID → 需求数量
ExpireAt time.Time // 限时任务
Reward []ItemStack
}

关键设计原则:

  • 任务进度用事件驱动更新,不是轮询扫描
    1
    玩家击杀怪 → 触发事件 OnMonsterKilled(monsterID) → 广播给相关任务
  • 主线任务串行(做不了 B 才解锁 A),日常任务并行
  • 限时任务用时间戳判定,不用定时器(定时器在重启后会丢)

活动系统

活动是配置驱动的运营内容。正确的做法:

1
2
3
活动配置表(Excel)→ 导表工具 → 活动配置数据
活动运行实例(Redis/内存)→ 记录每个玩家的活动进度
活动结算(定时/手动)→ 发奖励

不要为每个活动写代码。 这是独立开发者的生死线——活动会有几十个,每个写代码就是几十个维护负担。配置化是唯一出路(第 9 章)。

6.9 支付与充值

这是最容易出资损事故的地方,必须最谨慎。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 支付回调处理:幂等 + 严格校验
func (s *PayService) HandleCallback(req *PayCallback) error {
// 1. 验签(用渠道公钥,不要用渠道的 SDK 除非你信它)
if !s.verifySign(req) { return ErrInvalidSign }

// 2. 幂等检查:用渠道的订单号(不是自己的)
if s.redis.Exists(ctx, fmt.Sprintf("paid:cb:%s", req.ChannelOrderID)) {
return nil // 已处理过,直接返回成功
}
// 用 SETNX 抢锁,防并发
ok, _ := s.redis.SetNX(ctx, fmt.Sprintf("paid:cb:%s", req.ChannelOrderID), 1, time.Hour)
if !ok { return nil }

// 3. 查自己的订单(以自己系统的订单状态为准,不能信渠道传的商品信息)
order, err := s.orderRepo.FindByChannelOrderID(req.ChannelOrderID)
if err != nil { return err }
if order.Status == OrderStatusPaid { return nil } // 已发货

// 4. 幂等发货(用订单 ID 做唯一约束)
if err := s.deliver(order); err != nil { return err }

// 5. 标记订单完成
s.orderRepo.MarkPaid(order.ID, req.ChannelOrderID)
return nil
}

铁律:

  1. 永远以自己系统的订单状态为准,不信任渠道传来的商品 ID / 金额
  2. 幂等键用渠道订单号(全局唯一且稳定)
  3. 发货要有唯一约束(数据库层面),不能只靠 Redis 锁
  4. 先发货后标记状态(反过来会漏发);发货失败要能重试
  5. 金额用整数(分),绝不用浮点
  6. 所有支付相关表要有对账任务(每日跟渠道核对)

在 Laravel 里做支付回调,经验是完全可迁移的——幂等、签名验证、订单状态机、对账,这些你在快手开放平台对接时应该都做过。这是你转游戏服务端最直接可用的经验。



第 7 章 数据存储:热温冷分离

7.1 三级存储模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌───────────────────────────────────────────────────────────────┐
│ 热层:进程内存 (Player Object) │
│ · 访问:纳秒级 │
│ · 内容:等级、位置、背包(引用)、技能CD、buff、场景实体 │
│ · 风险:进程崩溃即丢 │
├───────────────────────────────────────────────────────────────┤
│ 温层:Redis │
│ · 访问:亚毫秒级 │
│ · 内容:玩家快照 JSON、会话 token、排行榜 ZSET、分布式锁、 │
│ 匹配池、房间映射、在线状态 │
│ · 风险:需要持久化配置(AOF/RDB);单点需高可用 │
├───────────────────────────────────────────────────────────────┤
│ 冷层:MySQL │
│ · 访问:毫秒级 │
│ · 内容:账号、角色基础、物品实例、订单流水、日志归档 │
│ · 风险:写入是瓶颈 → 必须异步化 + 分库分表 │
└───────────────────────────────────────────────────────────────┘

7.2 Redis 在游戏服务端的用法

你在 Web 开发里用 Redis 的经验,几乎 100% 可以直接迁移。这是你最大的存量资产之一。

用途 数据结构 关键点
玩家快照 String (JSON) TTL 1 小时,配合 30 秒定时刷新
会话 token String 登录时生成,TTL = 会话有效期
在线状态 String / Set online:12345 TTL 60s,心跳续期
排行榜 ZSET ZADD 更新分数,ZREVRANK 查排名
分布式锁 String + SET NX EX 单个 Redis 实例是 SPOF,慎用
匹配池 List / ZSET 按等待时间排序 → 用 ZSET(score = 入池时间戳)
房间映射 Hash / String room:10086 → ip:port
道具/配置缓存 String / Hash 配表数据常驻
限流计数 String (INCR) 滑动窗口用 ZSET 更准
幂等标记 String (SETNX) 支付回调、领奖去重
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 玩家快照的读写
func (s *SnapshotStore) Save(p *Player) error {
data, err := json.Marshal(p.ToSnapshot())
if err != nil { return err }
return s.redis.SetEX(ctx, fmt.Sprintf("player:%d", p.ID), 3600, data).Err()
}

func (s *SnapshotStore) Load(id uint64) (*Player, error) {
data, err := s.redis.Get(ctx, fmt.Sprintf("player:%d", id)).Bytes()
if err == redis.Nil {
return s.db.LoadPlayer(id) // 回落 MySQL
}
if err != nil { return nil, err }
return PlayerFromSnapshot(data), nil
}

Redis 的两个生产级坑

1. 分布式锁不是银弹

SET key val NX EX 30 不是原子的 fail-safe 逻辑(业务执行超过 30s 时锁已过期)。需要看门狗或用 Redlock。但更重要的是:不要用 Redis 锁做”必须成功”的事(比如发货),应该用数据库唯一约束兜底。

2. 单点故障

单 Redis 实例重启 = 所有在线玩家状态丢失。生产环境至少:

  • 主从 + Sentinel,或
  • Redis Cluster,或
  • 云厂商托管 Redis(阿里云/腾讯云 Redis 有高可用版)

7.3 MySQL 表设计

核心表清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
-- 账号表(全局,不分世界)
CREATE TABLE accounts (
id BIGINT UNSIGNED PRIMARY KEY, -- 分布式ID
platform VARCHAR(32) NOT NULL, -- 渠道
platform_uid VARCHAR(64) NOT NULL, -- 渠道用户ID
token_version INT NOT NULL DEFAULT 0,
banned_until DATETIME NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_platform (platform, platform_uid)
) ENGINE=InnoDB;

-- 角色表(分世界)
CREATE TABLE players (
id BIGINT UNSIGNED PRIMARY KEY, -- 角色ID
account_id BIGINT UNSIGNED NOT NULL,
world_id INT NOT NULL,
name VARCHAR(32) NOT NULL,
level INT NOT NULL DEFAULT 1,
exp BIGINT NOT NULL DEFAULT 0,
vip_level INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_account_world (account_id, world_id),
KEY idx_name (name),
KEY idx_world_level (world_id, level)
) ENGINE=InnoDB;

-- 物品实例表(背包明细,可拆)
CREATE TABLE player_items (
uid BIGINT UNSIGNED PRIMARY KEY, -- 实例唯一ID
player_id BIGINT UNSIGNED NOT NULL,
template_id INT NOT NULL,
count INT NOT NULL DEFAULT 1,
attributes JSON NULL, -- 随机词条
created_at DATETIME NOT NULL,
KEY idx_player (player_id)
) ENGINE=InnoDB;

-- 订单表(资损敏感,单独库或独立监控)
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
channel VARCHAR(32) NOT NULL,
channel_order_id VARCHAR(64) NOT NULL,
product_id INT NOT NULL,
amount_cents INT NOT NULL, -- 金额(分)
status TINYINT NOT NULL, -- 0待付 1已付 2已发货 3失败
created_at DATETIME NOT NULL,
paid_at DATETIME NULL,
UNIQUE KEY uk_channel_order (channel, channel_order_id), -- 幂等的关键
KEY idx_player (player_id)
) ENGINE=InnoDB;

uk_channel_order 这个唯一索引就是你的资损防线。任何重复发货都会在这里被数据库挡住。

分库分表

分片键选择:

数据 分片键 理由
角色表 player_id % N 或 world_id 查角色基本按 ID 或世界
物品表 player_id % N 查询总是”这个玩家有什么”
订单表 player_id % N 同上
账号表 account_id % N 登录按 ID
日志表 按时间分(月/周) 归档场景
1
2
3
4
5
// 分片路由
func (r *ShardedRouter) Table(table string, playerID uint64) string {
shard := playerID % uint64(r.shardCount)
return fmt.Sprintf("%s_%d", table, shard)
}

什么时候才需要分库分表:

  • 单表超过 1000 万行 或 10GB
  • 单库连接数打满(MySQL 默认最大连接数 151,线上建议 500)
  • 写入 TPS 超过 5000

独立开发者的建议:先不分。你的游戏在达到 1000 万玩家之前,单库 + 索引优化 + Redis 缓存完全够用。等真的慢了再上分库分表,中间加一层 Router 抽象,届时迁移成本可控。过早的分库分表是纯浪费。

写入策略

这是游戏服务端数据层最关键的设计。

1
2
3
4
5
❌ 直写:每收到一个操作就 UPDATE players SET hp=... WHERE id=...
→ 1000 人 × 20 次/秒 = 2 万 TPS 写库 → 数据库瞬间爆炸

✅ 攒批 + 异步:
内存队列 → 每 100ms 或每 500 条批量 flush → Redis → 定时 → MySQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 攒批写入
type BatchWriter struct {
queue chan *Player
batch []*Player
ticker *time.Ticker
}

func (w *BatchWriter) Run() {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case p := <-w.queue:
w.batch = append(w.batch, p)
if len(w.batch) >= 500 {
w.flush()
}
case <-ticker.C:
w.flush()
}
}
}

func (w *BatchWriter) flush() {
if len(w.batch) == 0 { return }
// 1. 先写 Redis(快,恢复用)
for _, p := range w.batch {
w.snapshotStore.Save(p)
}
// 2. 再批量写 MySQL(慢,持久化用)
w.db.BatchUpsertPlayers(w.batch)
w.batch = w.batch[:0]
}

分层写盘的延迟设计:

数据类型 落 Redis 延迟 落 MySQL 延迟 丢失可接受度
位置坐标 不落 不落 完全可接受(重连后校正)
血量/战斗状态 不落 战斗结束才落 可接受
经验/金币 30 秒 5 分钟 轻微(可补偿)
背包物品 30 秒 5 分钟 轻微(可补偿)
充值/付费道具 立即 立即 + 事务 不可接受
订单流水 立即 立即 不可接受

7.4 分布式 ID

游戏内 ID 的特殊性:会出现在数据库、消息体、公会成员、交易记录、日志、跨服协议里。改 ID 策略 = 改整个系统。

方案对比

方案 优点 缺点 适用
数据库自增 简单 分库冲突、暴露业务量 单库场景
雪花算法 (Snowflake) 趋势递增、无中心 依赖时钟、暴露时间 通用首选
号段模式(数据库分配段) 无时钟依赖 需要 DB 极端严苛场景
业务维度编码 自带语义 长度受限 玩家 ID、订单 ID

雪花 ID 的结构:

1
2
3
4
5
6
0 | 41位时间戳(ms) | 10位工作节点 | 12位序列号
| | | |
| | | └─ 同毫秒内自增,4096个/ms
| | └─ 机器ID(1024台)
| └─ 毫秒时间戳(41位 ≈ 69年)
└─ 符号位(正数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Go: 雪花ID生成器
type Snowflake struct {
mu sync.Mutex
lastTime int64
nodeID int64
sequence int64
epoch int64 // 起始时间戳(毫秒),如 2024-01-01
}

func (s *Snowflake) Next() uint64 {
s.mu.Lock()
defer s.mu.Unlock()

now := time.Now().UnixMilli()
if now == s.lastTime {
s.sequence = (s.sequence + 1) & 0xFFF // 4095
if s.sequence == 0 {
for now <= s.lastTime { // 序列耗尽,等下一毫秒
now = time.Now().UnixMilli()
}
}
} else {
s.sequence = 0
}
// 时钟回拨保护
if now < s.lastTime {
now = s.lastTime
}
s.lastTime = now

return uint64((now-s.epoch)<<22 | s.nodeID<<12 | s.sequence)
}

时钟回拨是雪花 ID 的经典事故:NTP 校时把时间往回调,可能导致 ID 重复。生产环境要:① 用单调递增的时钟源 ② 检测到回拨时等待或报警 ③ nodeID 用机器标识自动分配(Redis 分配)而不是手写。

游戏里的 ID 建议分层:

1
2
3
4
玩家 ID:雪花 ID(或 timestamp|worldId|sequence 编码)
物品实例 UID:雪花 ID(或独立的实例号段)
订单 ID:雪花 ID
配置表 ID:策划定义的短 ID(int,如 10001 = 红药水)

注意区分:配置表 ID 是策划维护的短数字(人类可读),运行时 UID 是系统生成的。这两个不能混。

7.5 缓存策略

Cache-Aside(最常用)

1
2
读:查缓存 → miss 查 DB → 回填缓存 → 返回
写:更新 DB → 删除缓存(或更新缓存)

为什么写是”删缓存”而不是”更新缓存”:

  • 更新缓存可能和并发写产生顺序问题(后写的先更新缓存)
  • 删除缓存是”下次读时重建”,最终一致

缓存穿透 / 击穿 / 雪崩(你在 Web 里应该都处理过):

问题 现象 解法
穿透 查不存在的 key,每次都打到 DB 缓存空值 + Bloom Filter
击穿 热点 key 过期瞬间,大量请求打到 DB 互斥锁重建 / 逻辑过期
雪崩 大量 key 同时过期 过期时间加随机抖动

游戏特有的缓存:配置表

配置表是游戏服务端最热的数据(技能属性、怪物属性、道具定义、关卡配置)。它应该是:

1
2
3
① 进程启动时全量加载进内存(只读)
② 有版本号,运营改表后推送"重载配置"消息
③ 永不失效(配置变了才重载)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 配置管理器:热重载 + 原子替换
type ConfigManager struct {
current atomic.Pointer[GameConfig] // 原子指针,读无锁
version atomic.Int64
}

func (m *ConfigManager) Reload() error {
newCfg, err := LoadConfigFromFile() // 从本地文件或配置中心
if err != nil { return err }
m.current.Store(newCfg) // 原子替换,所有读立刻看到新版本
m.version.Add(1)
return nil
}

func (m *ConfigManager) Get() *GameConfig {
return m.current.Load() // 无锁读,热路径零开销
}

原子指针替换是 Go 1.19+ 提供的标准做法,读侧完全无锁。这比”加锁读 map”快一个数量级,在每秒几万次读的场景下差异显著。

7.6 数据库选型补充

存储 用途 说明
MySQL 核心业务数据 关系完整、事务、你的主场
Redis 热数据、排行榜、池子 一切需要”快”的地方
MongoDB 日志、动态结构数据 ET 框架用 BSON;配置复杂的文档型数据
ClickHouse / Doris 日志分析、BI 玩家行为分析、运营报表
对象存储 (OSS/S3) 存档、回放、日志归档 大文件
Kafka 埋点上报、离线计算 数据分析链路

对你的迁移价值:你在 FastAdmin/Laravel 里的 MySQL + Redis 经验直接复用,这是最不需要重新学的一块。



第 8 章 客户端协同:引擎与 Netcode 选型

8.1 引擎选择

引擎 语言 优势 劣势 适合
Unity C# 生态最大、asset store 最全、招聘容易、ET 双端共享 包体大、移动端优化累、许可证政策变动 中重度、跨平台
Unreal C++/蓝图 画面顶级、内置网络同步、编辑器强 学习成本极高、编译慢、包体巨大 3A、主机
Cocos Creator TypeScript 2D/轻3D优秀、国内生态好、TS 便于与 Node 服务端共享类型 3D 能力弱于 Unity 休闲、卡牌、2D、微信小游戏
Godot GDScript/C# 开源免费、轻量、编辑器友好 商业化案例少、招聘难 独立游戏、2D
自研引擎 任意 完全可控 个人项目的最大陷阱 除非你已经是引擎工程师

独立开发者的绝对红线:不要自研引擎。 你要学的东西已经够多了。引擎的自研工作量通常是游戏本身的 3-5 倍。

我的建议:

  • 2D / 休闲 / 卡牌 / 微信小游戏 → Cocos Creator(TypeScript,且 Node 服务端可共享类型定义)
  • 3D 中重度 / 需要双端共享逻辑 → Unity(配合 ET)
  • 极致 2D 独立作 → Godot

8.2 Netcode 选型(Unity)

这是 Unity 多人游戏最关键的决策。先定同步模型,再选 SDK。

方案 同步模型 授权 自托管 适用
Netcode for GameObjects (NGO) 状态同步 + 预测 官方免费 ✅ 官方首选,Unity 6 生态
Netcode for Entities (NCE) DOTS/ECS 状态同步 + Ghosts 官方免费 ✅ 海量实体(MMO)、有 DOTS 基础
Mirror 状态同步 MIT ✅ 存量项目、成熟生态
FishNet 状态同步 + 内置预测 免费 + Pro ✅ 要预测但不想租云
Photon Fusion 2 状态同步 + 预测 + 回滚 商业(按 CCU) ❌(托管) 不想运维基础设施
Photon Quantum 确定性回滚 商业 ❌ 确定性竞技游戏

选型决策

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
游戏类型 + 你的约束
│
├─ 竞技动作,不愿运维
│ └─ Photon Fusion 2 ✅ 预测/回滚/延迟补偿全内置
│ ⚠️ 按 CCU 计费,成功了成本涨;迁移出去是重写不是改配置
│
├─ 竞技动作,要自托管
│ └─ FishNet ✅ 预测是主要卖点(从 Mirror 迁走的主要原因)
│ ⚠️ 社区比 Mirror 小,重大版本会动 API
│
├─ 合作/社交,2~8 人
│ └─ NGO ✅ 官方支持有保障,跟 Unity 服务无缝集成
│
├─ MMO,海量实体
│ └─ Netcode for Entities ⚠️ 必须接受 ECS 架构承诺
│
├─ 存量 Mirror 项目
│ └─ 继续 Mirror ⚠️ 通用预测仍标注 "Researching",PoC 后再定
│
└─ 确定性竞技
└─ Photon Quantum ⚠️ 需要把模拟逻辑与普通 Unity 物理/MonoBehaviour 分离

三个必须知道的坑

1. 所有方案都自带网络模拟器,从第一天就用

一个只在 localhost 0ms 跑过的游戏,在第一个跨时区的真实玩家接入时就会崩掉——而那时那些错误的假设已经成了承重结构。

四种方案都提供模拟器。测试矩阵至少包含:50ms / 100ms / 200ms 延迟,1% / 5% 丢包,抖动。

2. 不要同步能推导出来的东西

发送输入 + 权威状态,让客户端自己算表现。每帧给每个道具同步一个 transform,会在你察觉之前烧光带宽预算。

3. 网络化的 spawn 要池化

Spawn 在网络上的成本比本地高,而那些拖垮单机帧率的分配问题,在加上 spawn 消息广播后会更糟。

所有方案都有对象池。频繁创建的网络对象必须走池。

决策表(2026 版)

方案 最适合 主要优势 主要顾虑
Photon Fusion GameObject 动作、FPS/TPS、合作 预测/重演、延迟补偿、多拓扑 Photon 依赖、CCU 成本、专用服务器是另一套
NGO + MPS Unity 6、UGS 生态的合作游戏 Unity 原生集成、Sessions/Relay/Lobby MPS Sessions 要 Unity 6;高级动作预测非开箱即用
Netcode for Entities DOTS/ECS、大量实体、专用服务器 Ghosts、预测、延迟补偿 ECS 承诺、学习曲线、重演成本
FishNet GameObject、要看源码、自托管 预测、服务器权威、传输层灵活 自定义许可;周边基础设施和 QA 仍归你
Mirror 存量代码、要求 MIT、自托管 快照插值、延迟补偿 通用预测仍是 Researching
Photon Quantum 确定性竞技 确定性 ECS + 回滚 模拟逻辑要与普通 Unity 物理分离
Nakama 后端驱动多人 认证、存储、匹配、聊天、权威对局循环 不是 3D 动作状态同步的即插即用方案

给独立开发者的推荐:

  • 要预测 + 自托管 → FishNet
  • 要官方支持 + 不做重度动作 → NGO
  • 不想运维 → Photon Fusion 2(但算清成本)
  • 和 ET 一起用 → ET 自带网络层(支持可靠 UDP/TCP/WebSocket),不用选第三方 netcode

8.3 通信桥:Unity ↔ 服务端

如果服务端不是 C#,需要定义协议。三种方式:

方式 A:Protobuf(推荐)

1
2
3
4
proto/game.proto
↓ protoc 生成
C# 客户端 (protobuf-net 或 Google.Protobuf)
Go 服务端 (google.golang.org/protobuf)

优点:跨语言、schema 单一真相源、向后兼容、生成的代码类型安全。

Unity 注意事项:

  • Unity 支持 .NET Standard 2.1,用 protobuf-net 或官方 Google.Protobuf 都行
  • 官方 Google.Protobuf 在 IL2CPP 下需要 protoc 生成 C# 代码(不要用反射)
  • 生成的代码要放进 Assets/Plugins/,注意 asmdef 定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
syntax = "proto3";
package game;

message C2S_Login {
string token = 1;
int32 platform = 2;
}

message S2C_LoginResult {
int32 err_code = 1;
uint64 player_id = 2;
bytes player_snapshot = 3; // 或者展开字段
}

message C2S_Move {
int32 dir_x = 1; // 方向(量化后的整数)
int32 dir_y = 2;
uint32 seq = 3; // 序列号
}

message S2C_EntityState {
uint64 entity_id = 1;
int32 x = 2; // 定点数:坐标 × 1000
int32 y = 3;
uint32 hp = 4;
uint32 state_mask = 5; // 位掩码标记哪些字段有效(增量同步用)
}

注意 state_mask 这个设计——状态同步的增量更新靠它。服务端只设置发生变化的字段对应的位,客户端按位解析。

方式 B:FlatBuffers(帧同步 / 高频)

零拷贝读取,帧同步场景收益明显。代价是 schema 演进更麻烦,Unity 侧要引入 com.google.flatbuffers 库。

方式 C:手写二进制(极致性能场景)

只保留消息头 + 字段直写。不推荐起步用——维护成本高,且 Protobuf 已经足够。

8.4 客户端与服务端的职责边界

这张表是防止客户端被写崩的关键:

职责 客户端 服务端 说明
界面渲染 ✅ 唯一职责 ✗
动画/特效播放 ✅ ✗ 服务端只发”触发了 skill_x”
玩家位置(本地) ✅ 预测 ✅ 权威 客户端预测,服务端校正
玩家位置(他人) ✅ 插值 ✅ 广播
血量/蓝量显示 ✅ ✅ 权威值 客户端可以插值显示,但服务端是唯一真相
伤害计算 ❌ 只显示 ✅ 唯一计算方
技能 CD 显示 ✅ 本地预测 ✅ 权威
背包数据 ✅ 缓存显示 ✅ 权威
掉落/奖励 ❌ ✅ 唯一决定方 客户端请求”我要开箱”,服务端决定开出了什么
匹配/组队 ✅ 发起 ✅ 分配
充值 ✅ 发起支付 ✅ 发货 支付结果以服务端为准

铁律:客户端只负责”看”和”表达意图”,服务端负责”是”和”给”。 一旦你在客户端计算了任何影响游戏结果的东西,游戏就出现了可被利用的漏洞。

8.5 热更新

这是游戏项目运营的核心能力——出问题时不能等应用商店审核(iOS 审核可能一周)。

三大热更对象

对象 方案 说明
配表 走 CDN 的 JSON/BIN 最简单,必须有
资源 Unity Addressable + 分包 + CDN 资源依赖闭包自动处理
代码 HybridCLR(C# 热更) 纯 C# 代码热更,无需重新打包
ILRuntime 早期方案,有性能损耗
Lua (xLua/Puerts) 逻辑用 Lua 重写,热更最灵活

配表热更的完整链路

1
2
3
策划改 Excel → 提交 Git → CI 触发导表 → 生成 JSON/BIN → 上传 CDN
→ 配置中心推送"新版本号" → 服务端拉取 → 原子替换内存配置
→ 客户端拉取(按需,带缓存)

关键:版本号 + 灰度

1
2
config_version: 1001
cdn_url: https://cdn.example.com/config/1001/

服务端启动时拉一次,之后按版本号变更推送。客户端启动时带本地版本号请求,差量更新。

代码热更(HybridCLR)的现实

  • 热更的代码不能有 AOT 泛型限制(HybridCLR 会补齐,但要注意)
  • 热更会增加包体和复杂度,不是所有项目都需要
  • 如果你的游戏只有运营活动更新 → 配表热更就够了
  • 如果你的游戏要频繁改战斗逻辑 → 代码热更价值很大

独立开发者建议:先只做配表热更。这是 90% 的运营需求(改数值、加活动、修数值 bug),成本最低。代码热更等你的项目真的需要时再加。

8.6 平台 SDK 接入

如果你要做商业发行,需要接各平台的 SDK:

平台 需要的能力
微信小游戏 登录(wx.login)、支付、云开发、广告、分享
抖音小游戏 同上 + 短视频能力
抖音/快手小游戏 登录、支付、广告(你有快手平台经验,这里是优势)
iOS 苹果内购(IAP)严格合规、Sign in with Apple
Android 支付渠道多、Google Play 结算合规

你已有的快手开放平台对接经验在这里是稀缺资产——渠道 SDK 接入、支付回调、幂等处理、订单对账,这些坑你都已经踩过了。

技术建议:

  • SDK 接入放在独立的”平台适配层”,游戏核心逻辑不直接依赖任何渠道 SDK
  • 用一个 IPlatformAdapter 接口,各渠道实现一份
  • 这样换渠道、加渠道不用动核心代码
1
2
3
4
5
6
7
8
9
10
11
12
// 平台适配层
type PlatformAdapter interface {
Login(ctx context.Context, code string) (*UserInfo, error)
Pay(ctx context.Context, req *PayRequest) (*PayResult, error)
VerifyPayCallback(req *PayCallback) error
ReportEvent(event string, data map[string]any)
}

// 实现
type KuaishouAdapter struct{ /* 快手 SDK */ }
type WechatAdapter struct{ /* 微信 SDK */ }
type MockAdapter struct{ /* 本地开发用 */ }

这个抽象层的设计,你在 Laravel 里做多渠道支付时应该写过同样的东西。直接复用你的经验。



第 9 章 配表与数值流水线

9.1 为什么配表是独立开发者的生死线

想象一下没有配表的世界:

1
2
3
4
5
6
// ❌ 硬编码
if skillID == 1001 {
damage = 100 * 1.5
} else if skillID == 1002 {
damage = 200 * 1.2
}

策划想调一下”烈焰箭伤害从 150 改成 160”,你得:改代码 → 重新编译 → 重新发布 → 等 iOS 审核 → 上线。两周。

有配表的世界:

1
策划改 Excel → 提 Git → CI 导表 → 上传 CDN → 服务端拉取生效

5 分钟。 一个月改 20 次数值,就是 40 小时 vs 2 小时。

独立开发者的游戏,运营期的数值调整频率会远超你的预期。 配表不是锦上添花,是让你能持续运营的前提。

9.2 Luban:推荐的配表工具

Luban 是目前国内游戏行业最主流的开源配表工具(Unity 生态,也支持其他引擎)。

特性 说明
类型系统 不只是格式转换,是给配表加了真正的类型系统
OOP 继承 支持表行继承变体 → 行为树、技能、任务、副本定义可以表达变体,而不是把变体拍平成可空列
校验规则 ref 引用检查、path 路径检查、range 范围检查
多格式输入 Excel (csv/tsv/xls/xlsx/xlsm)、json、xml、yaml、lua
多格式输出 二进制、json、bson、xml、lua、yaml
代码生成 c#、java、go、cpp、lua、python、javascript、typescript、rust、php、erlang、godot
无反射 生成的代码不调反射接口 → 兼容混淆/加固工具(HybridCLR、ILRuntime、xlua、puerts 的硬约束)
MIT 许可 可商用

为什么”校验”比”导出”重要:

转换器会愉快地导出一张技能表引用了不存在的怪物 ID 的表,你到运行时才发现。带 ref 检查的流水线会在构建时就失败。

这个差异在项目规模变大后是决定性的。

Luban 的成本:schema 定义是新增的维护物(README 明确说不会自己写出来)。工具链的复杂度换来的是「转换器 → 编译器」的质变。

9.3 Luban 风格的表结构

1
2
3
4
第一行:字段名(英文 key)    ← 程序用
第二行:类型(int/float/string/bool/数组/自定义) ← 校验用
第三行:中文注释 ← 策划看,导出时忽略
第四行起:数据

这就是手游行业惯例的「四行表头规范」。Luban 在此基础上扩展了类型系统和校验。

9.4 配置的加载与热更

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 服务端启动时加载配置
func (s *Server) loadConfig() error {
cfg, err := luban.LoadBinary("config.bytes") // 或从 CDN 拉取
if err != nil { return err }
s.configMgr.Store(cfg) // 原子指针
return nil
}

// 监听配置中心推送
func (s *Server) onConfigChanged(ev *ConfigEvent) error {
log.Printf("收到配置更新通知: version=%d", ev.Version)
newCfg, err := luban.LoadFromCDN(ev.Version)
if err != nil {
log.Errorf("加载新配置失败,保留旧配置: %v", err)
return err // ⚠️ 失败时保留旧配置,不要让服务带着空配置跑
}
s.configMgr.Store(newCfg)
log.Printf("配置已更新到 version=%d", ev.Version)
return nil
}

关键设计点:

  1. 原子替换(atomic.Pointer)→ 读侧零锁,热路径无开销
  2. 加载失败保留旧配置 → 配置服务挂了游戏还能玩
  3. 版本号 → 客户端和服务端能判断是否一致
  4. 配置在内存里是只读的 → 不需要加锁(除了热更时刻的原子替换)

9.5 数值设计的基本原则

这部分不是纯技术,但决定游戏好不好玩,而且独立开发者最容易犯错。

1. 数值要有”锚点”

层级 作用 示例
基准值 一切以此为参照 1 级小怪 HP = 100
成长值 随等级/装备的增量 每级 +15%
阈值 明确的关卡 10 级是一个明显的”墙”

最常见的错误是”每个数值都是拍脑袋定的”。定一个基准值,然后用比例关系推出其他所有值。

2. 用公式,不用硬编码

1
2
攻击力 = 基础攻击 × (1 + 装备加成) + 武器基础值
伤害 = 攻击力 × 技能系数 × (1 - 目标减伤) × 暴击系数

所有公式的输入(基础值、系数、减伤上限)都应该在配表里。策划能改公式里的数字,但不能改公式本身。

3. 减法有下限,乘法无上限

1
2
❌ 伤害 = 攻击力 - 防御         // 防御 > 攻击力 时出现负伤害
✅ 伤害 = 攻击力 × (1 - 防御/(防御 + K)) // 恒在 [0, 攻击力]

4. 边际递减

1
2
❌ 防御 = 基础防御 × 等级        // 线性成长,堆到一定量就无敌
✅ 减伤率 = K / (K + 防御值) // 收益递减,有天然上限

5. 敏感度分析

改一个数值前,先问:改 10% 会发生什么?

数值 改 10% 的影响 风险
怪物 HP 战斗时长 ±10% 低
玩家攻击力 战斗时长 -10%(伤害占比高时更明显) 中
技能冷却 DPS 变化可达 ±30%(有攻速buff时) 高
暴击率 期望伤害变化非线性 高
资源产出 经济系统可能失衡 高

乘区(乘法区)比加区敏感度高。伤害 = 攻击力 × 技能系数 × 暴击系数 三个乘区各改 10%,总伤害变 33%。

设计原则:一个数值调整能造成的总影响应该被控制在 ±20% 以内。想做大幅调整,一次只动一个乘区。

6. 经济系统的闭环

1
产出(副本/任务)→ 消耗(装备强化/商店)→ 回收(分解/交易税)

产出 > 消耗 → 通胀 → 后期数值全废
产出 < 消耗 → 紧缩 → 玩家没资源 → 卡在某个进度

必须做”经济模拟”:跑一个模拟器,用不同活跃度的玩家模型跑 30 天,看资源曲线。这是游戏数值设计的标准工具,很多团队会专门写。

9.6 版本管理与灰度

配表的版本管理:

1
2
3
4
5
6
7
8
9
10
11
config/
├── excel/ # 策划的源表(Git 管理)
│ ├── item.xlsx
│ ├── skill.xlsx
│ └── monster.xlsx
├── schema/ # Luban 的 schema 定义
│ └── defs.xml
├── output/ # 导出的产物(也可不入 Git,走 CI 产物)
│ ├── config.bytes
│ └── code/ # 生成的代码
└── versions.json # 版本记录

建议:

  • Excel 源表入 Git(可以看 diff,能回滚)
  • 导出产物走 CI 产物存储(不要入 Git,避免冲突)
  • 每次发布生成一个版本号,记录”这个版本改了什么表”

灰度发布:

1
2
v1001 全量
v1002 只给 10% 玩家 → 观察 2 小时 → 逐步放量 → 全量

游戏行业的灰度靠配置中心下发版本号实现,服务端根据玩家 ID hash 决定用哪个版本。这个机制从第一天就要建,后加会很难。

9.7 客户端与服务端的配置一致性

这是配表最常见的线上事故来源:客户端显示的技能描述是 150 伤害,服务端算出来是 180。

防御措施:

  1. 配置表用同一个 Luban schema 生成两端代码 → 结构一致
  2. 配置版本号在登录握手时交换 → 不一致时告警
  3. 关键数值在服务端再校验一次 → 即使不一致也不至于出错
1
2
3
4
5
6
7
8
9
10
11
12
13
// 登录握手时校验配置版本
func (s *Server) onLogin(req *LoginReq) (*LoginResp, error) {
clientCfgVer := req.ConfigVersion
serverCfgVer := s.configMgr.Version()
if clientCfgVer != serverCfgVer {
// 不阻断登录(可能只是小版本差异),但要告警
log.Warnf("配置版本不一致: client=%d server=%d player=%d",
clientCfgVer, serverCfgVer, req.PlayerID)
s.metrics.Inc("config_version_mismatch")
}
// 提示客户端拉取新配置
return &LoginResp{ConfigVersion: serverCfgVer, ...}, nil
}

第 10 章 运维与可观测

10.1 部署

进程管理:Supervisor vs systemd vs pm2

方案 适用 备注
systemd Linux 生产首选 免费、自带日志、崩溃重启、Restart=always
Supervisor 多进程管理 Web 场景常见,游戏服也可以
pm2 Node.js 你已经在用(狂野战车项目)
Docker + K8s 容器化 弹性伸缩、编排

systemd 单元示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# /etc/systemd/system/gameserver@.service
[Unit]
Description=Game Server %i
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/www/server/gameserver
ExecStart=/usr/bin/node /www/server/gameserver/server.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# 资源限制
LimitNOFILE=1000000
# 环境
Environment="NODE_ENV=production"
Environment="SERVER_ID=%i"

[Install]
WantedBy=multi-user.target
1
2
3
# 启用多个实例
sudo systemctl enable --now gameserver@1 gameserver@2 gameserver@3
sudo systemctl status gameserver@1

注意 LimitNOFILE:默认 1024,一个连接占一个 fd,1000 连接就满了。游戏服务端必须调到 10 万以上。这是最常见的”服务莫名其妙连接不上”的根因。

Nginx 反向代理(WebSocket)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
upstream game_ws {
least_conn;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
keepalive 32;
}

server {
listen 443 ssl http2;
server_name game.example.com;

ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;

location /ws {
proxy_pass http://game_ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ← WebSocket 必需
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_read_timeout 300s; # 长连接,不能太短
proxy_send_timeout 300s;
}
}

踩坑提醒:

  • Connection "upgrade" 少了 → WebSocket 握手失败,返回 400
  • proxy_read_timeout 默认 60s → 长连接被 nginx 掐断。心跳 30s 的话刚好边缘,建议调到 300s
  • 必须透传真实 IP(X-Real-IP),否则你的 IP 限流/封禁全部失效
  • WebSocket 有连接数限制,要调 worker_connections 和 worker_processes

优雅退出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 收到 SIGTERM 后:停止收新连接 → 等现有连接结束 → 落盘 → 退出
func (s *Server) shutdown(ctx context.Context) {
log.Println("收到退出信号,开始优雅关闭")

// 1. 停止接受新连接
s.gateway.StopAccepting()

// 2. 通知所有玩家(可选,避免玩家莫名其妙掉线)
s.gateway.BroadcastSystemNotice("服务器正在维护,请稍后重连")

// 3. 等待现有连接处理完(最多等 30s)
done := make(chan struct{})
go func() {
s.gateway.WaitAllClosed(30 * time.Second)
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}

// 4. 落盘所有玩家数据
s.sceneManager.SaveAll()

// 5. 退出
log.Println("已安全退出")
os.Exit(0)
}

容器环境的必答题:K8s 发送 SIGTERM 后默认只等 30s,超时就 SIGKILL(数据直接丢)。所以:

  • 监听 SIGTERM
  • 在 30s 内完成落盘
  • 设置 terminationGracePeriodSeconds: 60 留足时间

10.2 日志

日志分级

级别 用途 游戏服务端特别说明
FATAL 服务无法继续 立即告警
ERROR 单个操作失败 告警 + 堆栈
WARN 降级、异常但不影响 关注
INFO 关键生命周期事件 游戏服务端应该很克制
DEBUG 开发细节 生产关闭
TRACE 逐包日志 生产绝对关闭

游戏服务端日志的特殊要求:

  • 不要每包都打日志(每秒几万包会把磁盘打满)→ 用采样或计数
  • 战斗日志要能复盘 → 记录关键事件(技能释放、死亡、掉线),而不是每个位移
  • 玩家操作日志要有玩家 ID → 排查客诉时唯一有用的信息
  • 日志要能关联 traceID → 一次请求跨多层的完整链路
1
2
3
4
5
6
7
8
9
10
11
12
13
// 带玩家上下文的日志
func (l *Logger) Player(pid uint64) *Logger {
return l.With("player_id", pid)
}

// 战斗事件日志(结构化,方便检索)
l.Player(attacker.ID).Info("skill_cast",
"skill_id", skill.ID,
"target_id", target.ID,
"damage", dmg.Value,
"is_crit", dmg.IsCrit,
"tick", ctx.Tick,
)

日志落盘

方案 说明
文件 + logrotate 简单,但要处理磁盘满
stdout → 采集器 Docker/K8s 标准做法(推荐)
Loki + Promtail 轻量,适合中小规模
ELK 功能全,重
云厂商日志服务 阿里云 SLS / 腾讯云 CLS,省事

磁盘满是最常见的”半夜服务挂了”的原因。必须有:

  • 日志轮转 + 保留策略
  • 磁盘使用率告警(>80% 告警,>90% 紧急)
  • 定期清理历史日志

10.3 监控指标

游戏服务端要监控的指标(按重要性):

第一梯队(挂了必须知道)

指标 为什么 告警阈值建议
在线玩家数 掉了一半就是掉线了 环比跌 30%
tick 延迟 游戏逻辑卡顿的直接指标 P99 > 50ms
连接成功率 玩家进不来 < 95%
进程存活 基础 立即
CPU / 内存 资源饱和 CPU > 85% 持续 5min

第二梯队(影响体验)

指标 说明
消息延迟分布 客户端到服务端再返回的耗时(P50/P99)
广播消息量/秒 带宽趋势
Redis 命中率 缓存失效的早期信号
MySQL 慢查询数 数据层压力
匹配等待时长 玩家体验的核心指标
消息队列堆积 异步链路堵了
登录成功率 / 耗时 开服/更新的关键路径

第三梯队(业务)

指标 说明
付费转化率 商业健康度
留存率 商业健康度
道具产出/消耗比 经济系统健康度

tick 延迟是游戏服务端独有的、最重要的技术指标:

1
2
3
4
5
6
// 每个 tick 记录耗时
func (s *Scene) tick() {
start := time.Now()
// ... tick 逻辑
s.tickDuration.Observe(time.Since(start).Seconds()) // Prometheus Histogram
}

游戏客户端卡顿 90% 的原因是服务端 tick 抖动。这个指标必须从第一天就有。

10.4 压测

工具选型

工具 适用 特点
wrk HTTP/TCP 基准 多线程 + 事件驱动,输出分位数
vegeta 脚本化多阶段压测 原生 Go,输出 JSON/CSV,支持自定义负载模型和熔断判断
ghz gRPC 专项 内置 proto 解析、实时指标
k6 复杂链路(登录→下单→支付) JavaScript 脚本、指标丰富、可集成 CI
hey 快速验证单接口 轻量、支持 HTTP/2
自研机器人 真实游戏行为 最真实,但成本最高

游戏服务端压测的关键:不能只压 HTTP 接口,要压”游戏行为”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 自研压测机器人:模拟真实玩家行为
type Bot struct {
conn *Client
playerID uint64
state string // "lobby" / "matching" / "in_battle"
}

func (b *Bot) Run(ctx context.Context) {
for ctx.Err() == nil {
switch b.state {
case "lobby":
b.doLogin()
b.doMatchRequest()
case "in_battle":
b.doMove() // 每 tick 一次移动指令
b.doSkill() // 随机释放技能
b.checkBattleEnd()
}
}
}

压测数据必须包含三类黄金指标:

  1. 吞吐量(RPS / 每秒消息数)
  2. 延迟分位数(P50 / P90 / P99 / P999)
  3. 错误率

只看平均值是最大的陷阱。平均延迟 100μs 听起来不错,但如果 P99 是 10ms,1% 的用户体验就是灾难。更隐蔽的是平均值会掩盖双峰分布——90% 走缓存只要 10μs,10% 走磁盘要 1ms,平均 109μs 看着还行,但那 10% 的用户已经在骂娘。

压测的标准化流程

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 开启 profiling
# Go: 导入 _ "net/http/pprof",启动 /debug/pprof/
# C++/C: perf record -F 99 -p <pid> -g -- sleep 30

# 2. 压测(30s 起,正式数据要 5min+ 才有意义)
vegeta attack -rate=1000 -duration=30s -timeout=5s -targets=targets.txt | vegeta report -type=json > report.json

# 3. 立即采集 profile
curl -s http://localhost:8080/debug/pprof/profile?seconds=30 > cpu.pprof
go tool pprof -http=:8081 cpu.pprof # 可视化火焰图

# 4. 读分位数
jq '.metrics.http_reqs.p50, .metrics.http_reqs.p99, .metrics.http_reqs.errors' report.json

火焰图的价值在于打破猜测:

你以为瓶颈在数据库查询,火焰图一看 80% 的时间花在 JSON 序列化上。你以为锁竞争很严重,火焰图一看大部分时间在内存拷贝。

没有火焰图的性能分析都是盲人摸象。 每次遇到性能问题第一件事就是抓火焰图。

Linux 侧的工具:

1
2
3
4
5
# 系统级延迟分布(一眼看出是单峰、双峰还是长尾)
funclatency -d 1
biolatency -d 1
# CPU 硬件计数器
perf stat -e cache-misses,branch-misses ./server

10.5 分布式追踪

当一个请求跨了 Gateway → 逻辑服 → DBProxy → Redis,出了问题你需要知道卡在哪一层。

方案:

  • OpenTelemetry(现在的事实标准,游戏服务端用得还不多但值得)
  • SkyWalking(Java 生态强,Go 支持一般)
  • Jaeger(CNCF 项目)
  • 简单方案:自己传 traceID(成本低,够用)
1
2
3
4
5
6
7
8
9
// 简单方案:在消息头里带 traceID
type Header struct {
Length uint16
Opcode uint16
Seq uint32
Timestamp uint32
TraceID uint32 // 链路追踪ID
...
}

10.6 故障排查手册

这套流程应该在项目开始时就写下来,而不是出事才现查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. 玩家投诉"卡" / "掉线"
↓
2. 查监控:在线玩家数、tick 延迟、连接数曲线
├─ 在线玩家骤降 → 查进程是否重启(systemctl status / docker ps)
│ └─ 重启原因?看 OOM / panic 日志
├─ tick 延迟飙升 → 抓火焰图 / 看 DB 慢查询 / 看 Redis 延迟
└─ 连接数正常但玩家说卡 → 查该玩家所在场景服的状态
↓
3. 定位到具体进程
↓
4. 抓现场(火焰图 / goroutine dump / heap dump)
↓
5. 定位根因
↓
6. 临时止血(重启 / 扩容 / 限流 / 回滚配置)
↓
7. 事后修复 + 写进文档

几个救命工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Go: 抓 goroutine 栈(查死锁/阻塞)
kill -QUIT <pid> # 栈打印到日志

# Go: 内存 profile
curl http://localhost:6060/debug/pprof/heap > heap.pprof

# Linux: 查连接数
ss -ant | grep :8001 | wc -l

# Linux: 查端口占用
ss -tlnp | grep 8001

# 查文件句柄
ls /proc/<pid>/fd | wc -l
cat /proc/<pid>/limits | grep files

10.7 灰度与回滚

游戏服务端的回滚比 Web 危险,因为有状态:

回滚对象 难度 方法
代码 中 灰度发布:新版本只给 10% 玩家,观察后放量
配表 低 版本号回退,配置中心推旧版本
数据结构 高 必须向前兼容(新代码能读旧数据),用扩展式迁移

数据库变更的铁律:

  1. 只加不改不删(加字段可以,改字段类型不行,删字段不行)
  2. 新旧代码要能共存一段时间(灰度期间 10% 玩家跑新代码,90% 跑旧代码)
  3. 删字段要等所有实例都升级后,隔一个版本再删
  4. 大表变更用在线 DDL(gh-ost / pt-online-schema-change),不要 ALTER TABLE 锁表

在 Laravel 里做迁移的经验直接可用,加上”游戏服务不能停机”这个约束就完整了。



第 11 章 反作弊与安全

11.1 威胁模型

先明确你要防谁:

攻击者 能力 目标 防御
普通外挂(内存修改器) 改客户端内存/速度 秒杀、无敌、加速 服务端权威(最重要)
协议外挂(抓包改包) 自定义客户端 改伤害、刷道具 签名校验 + 服务端校验
脚本工作室 自动化操作 挂机刷资源 行为检测 + 风控
DDoS 网络层 打瘫服务 限流 + 高防 + 架构隔离
内部人员 访问数据库/GM 刷资源、导数据 权限分级 + 操作审计

核心原则:客户端的一切都不可信,只有服务端的计算结果是真的。

11.2 逐层防御

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────┐
│ 第 1 层:传输安全 │
│ TLS / WSS,握手时协商加密密钥 │
│ 防:中间人窃听 │
├─────────────────────────────────────────────────────────┤
│ 第 2 层:协议完整性 │
│ 消息头校验和、HMAC 签名(含 timestamp + nonce) │
│ 防:随手改包、重放攻击 │
├─────────────────────────────────────────────────────────┤
│ 第 3 层:服务端权威(最关键) │
│ 所有游戏结果由服务端计算,客户端只上报"意图" │
│ 防:99% 的功能性外挂 │
├─────────────────────────────────────────────────────────┤
│ 第 4 层:行为风控 │
│ 异常检测:操作频率异常、路径异常、收益异常 │
│ 封号策略、掉线策略 │
│ 防:脚本工作室 │
└─────────────────────────────────────────────────────────┘

11.3 服务端权威的落地

这个表是开发时反复要查的:

客户端上报 服务端必须校验
移动方向 是否在移动中、是否被控速、是否穿墙(碰撞检测)
攻击目标 距离、视线、是否同场景、是否存活、是否可攻击(阵营)
技能释放 CD、消耗(蓝/怒)、是否被沉默、是否前置状态满足
拾取物品 是否在拾取范围内、是否已存在、归属权
使用道具 是否拥有、数量是否足够、是否在限制状态
购买商品 价格、数量上限、货币是否足够、限购是否超
结算结果 一律不接受客户端上报的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 攻击请求的服务端校验(完整版)
func (s *Scene) handleAttack(attacker *Entity, req *AttackReq) error {
// 1. 目标存在且在同一场景
target := s.getEntity(req.TargetID)
if target == nil || target.SceneID != attacker.SceneID {
return ErrInvalidTarget
}
// 2. 目标还活着
if target.IsDead() { return ErrTargetDead }
// 3. 距离检查(服务端自己的位置 + 延迟补偿后的目标位置)
rewindTick := s.shooterViewTick(attacker)
from := s.history.Position(attacker.ID, rewindTick)
to := s.history.Position(target.ID, rewindTick)
if from.Distance(to) > s.config.MaxAttackRange {
return ErrOutOfRange
}
// 4. 阵营检查
if !s.canAttack(attacker, target) { return ErrInvalidTarget }
// 5. 技能 CD
skill := s.config.GetSkill(req.SkillID)
if !attacker.CanCast(skill) { return ErrSkillOnCooldown }
// 6. 资源消耗
if !attacker.Consume(skill.Cost) { return ErrNotEnoughResource }

// 7. 服务端算伤害(客户端报什么都不采纳)
damage := s.calcDamage(attacker, target, skill)

// 8. 应用
target.ApplyDamage(damage)
s.broadcastSkillEvent(attacker, target, skill, damage)
return nil
}

注意代码里完全没有用到 req.Damage 之类的字段——如果客户端协议里有这个字段,删掉它。留着就是给外挂的邀请函。

11.4 速度外挂的检测

客户端加速(快跑)是最常见的外挂,且不涉及改包。检测方法:

1
2
3
服务器记录玩家的位置变化序列
→ 计算实际速度 = 位移 / 时间
→ 超过理论最大速度 × 阈值 → 标记

关键难点:网络延迟和丢包会让”客户端位置跳变”,不能简单判定。正确做法:

  • 服务端自己跑移动逻辑(状态同步的标准做法)→ 客户端位置只用于显示,服务端位置才是权威
  • 平滑检测:连续 N 次超速才判定,避免误判网络抖动
  • 阈值放宽:理论最大速度 × 1.2(留出网络误差)

11.5 DDoS 防护

游戏服务端是 DDoS 的重灾区,因为长连接本身就吃资源(一个连接占内存 + 心跳开销)。

层次 措施
网络层 云厂商高防 IP / 游戏盾(腾讯云、阿里云都有游戏专用防护)
连接层 连接频率限制(IP 维度)、最大连接数限制、握手需要验证
协议层 非法包直接丢弃,不做业务处理;限流每 IP 每秒包数
架构层 接入层与逻辑层分离,打接入层不影响游戏逻辑(但会挡玩家进来)
运维层 快速拉黑名单、封 IP

最容易被忽略的一点:接入层要能独立扩容。被打的时候多加几台接入层机器,逻辑层不受影响。

11.6 GM 权限与审计

独立开发者的系统里,GM 后台是最容易被入侵的入口(因为它能直接改数据)。

1
2
3
4
5
6
7
权限分级:
策划 → 只读(看数值、看报表)
运营 → 有限写(发公告、给道具,走工单)
运维 → 系统级(重启、配置热更)
你 → 全部

所有 GM 操作 → 审计日志(谁、何时、改了什么、IP)

这正好是你的强项。你在 Laravel + Filament 里做过的 RBAC 权限系统、FastAdmin 里的菜单/权限体系,直接搬过来就是 GM 后台。这是从 Web 转游戏服务端最容易被低估的一块价值。

审计日志要求:

  • 不可篡改(只允许追加,不允许删改)
  • 包含完整上下文(操作人、IP、时间、变更前后值)
  • 有告警(一次改 100 万金币要立刻报警)

11.7 常见安全误区

误区 正确做法
“客户端在局域网就放松校验” 永远不放松。公司内网也是不可信的
“加了签名就安全了” 签名防重放,业务校验才是根本
“数值在客户端算,加密就行” 加密协议 ≠ 安全。客户端有内存可读
“GM 后台加个登录密码就行” 要 2FA + 操作审计 + IP 白名单
“上线前不需要做安全” 上线前是最容易改的窗口,那时候做成本最低


第 12 章 学习路径:一份 12 周的实战路线图

这份路线的设计原则:

  1. 每阶段都有可运行的产物,不是”学完再做”
  2. 越早接触难点越好——同步模型、tick 循环、AOI 不能拖到最后
  3. 复用你的存量优势——数据库、Redis、后台、权限,这些不用重学
  4. 不追求”学完”,追求”能上线一个小游戏”

12.1 第 1-2 周:建立手感(Go + 游戏循环)

目标:跑通一个能玩的”多人聊天室 + 小游戏”

技术:Go + TCP/WebSocket + Protobuf

1
2
3
4
5
6
7
8
9
10
第 1 周:
Day 1-2 Go 语法速成(你有 PHP 基础,重点看:struct、interface、goroutine、channel、error 处理)
Day 3 TCP 长连接服务器:连接管理、心跳、断线检测
Day 4-5 Protobuf 定义消息 + Go 生成代码 + 编解码
Day 6-7 WebSocket 版(为了快速看到效果,用浏览器做客户端)

第 2 周:
Day 1-2 固定 tick 循环:一个房间 20 FPS,每帧广播玩家位置
Day 3-4 客户端用浏览器 Canvas 画一个能移动的小人
Day 5-7 断线重连 + 状态恢复

产物:一个能用浏览器打开的多人小方块移动 demo

这一周要意识到的三件事:

  1. PHP 的 while (true) { sleep(1) } 在 Go 里是 for { select { ... } }
  2. 每个连接一个 goroutine 是最简单的模型(不需要 NIO)
  3. tick 循环和事件处理是分离的两件事——网络消息进队列,tick 时统一处理

12.2 第 3-4 周:状态同步实战

目标:做一个真正像游戏的场景同步

1
2
3
4
5
6
7
8
9
10
第 3 周:
Day 1-2 状态同步:服务端权威位置,客户端插值渲染
Day 3 状态包设计:全量 vs 增量(位掩码)
Day 4-5 AOI 九宫格(先用 2D 平面,100 个 NPC)
Day 6-7 降频策略(近处 20Hz,远处 5Hz)

第 4 周:
Day 1-2 实体 ID 池 + 对象池(避免频繁 GC)
Day 3-4 简单战斗:HP、伤害、死亡、复活
Day 5-7 消息频率控制 + 带宽统计

产物:一个 50 人同屏的 2D 小游戏 demo

关键指标:打开 DevTools Network 面板看单包大小。这是你第一次直观感受带宽。

12.3 第 5-6 周:数据层与配置化

1
2
3
4
5
6
7
8
9
第 5 周:
Day 1-2 玩家数据落库:内存 → Redis → MySQL 三级
Day 3-4 背包系统:模板 + 实例 + 堆叠(你在 Laravel 做过,直接迁移)
Day 5-7 分布式 ID(雪花算法)+ 登录流程

第 6 周:
Day 1-2 Luban 配表:定义 schema、导表、代码生成
Day 3-4 配置热更:原子替换 + 版本号
Day 5-7 任务系统:事件驱动的进度更新

产物:一个能登录、存数据、改配表不用重启的游戏

这一步是你最大的舒适区——背包、任务、配置、数据库,全是 Laravel 里的老朋友。你在这里能重建自信。

12.4 第 7-8 周:房间、匹配、运营闭环

1
2
3
4
5
6
7
8
9
第 7 周:
Day 1-2 房间系统:池化、tick、生命周期
Day 3-4 匹配:ZSET 匹配池 + Elo + 弹性放宽
Day 5-7 断线重连 + 匹配中的断线处理

第 8 周:
Day 1-2 GM 后台(直接复用你的 Filament 经验!)
Day 3-4 排行榜 + 好友 + 聊天
Day 5-7 埋点 + 日志 + 监控面板

产物:一个能运营的闭环(登录→匹配→对战→结算→排行榜→GM 改数据)

第 8 周是最爽的一周。GM 后台你做过 FastAdmin 和 Filament,一天就能搭出来。这是你在转游戏服务端路上最大的正反馈点,一定要走到这里。

12.5 第 9-10 周:客户端(如果需要)

1
2
3
4
5
6
7
8
9
第 9 周:
Day 1-2 引擎选型确认 + 项目搭建
Day 3-5 客户端与服务端协议对接(Protobuf)
Day 6-7 客户端表现层(动画、特效、UI)

第 10 周:
Day 1-3 客户端预测 + 服务端校正(如果做动作游戏)
Day 4-5 网络模拟器测试(注入延迟/丢包)
Day 6-7 配表热更到客户端

如果客户端用 Unity 且你想走 ET 路线:这里要重新评估,因为 ET 的价值就在于双端共享。第 9-10 周会变成”用 ET 重构前面的代码”——但这个重构成本是值得的,因为之后每次改动都是双端一起改。

12.6 第 11-12 周:上线准备

1
2
3
4
5
6
7
8
9
第 11 周:
Day 1-2 压测:写压测机器人 + 找瓶颈 + 火焰图
Day 3-4 性能优化:内存池、批处理、零拷贝
Day 5-7 部署:systemd + Nginx + Redis + MySQL + 监控

第 12 周:
Day 1-2 灰度发布 + 配置热更 + 监控告警
Day 3-4 安全加固:签名、限流、风控
Day 5-7 小范围公测 + 修 bug + 补文档

产物:一个真实可访问、有玩家能玩的游戏

12.7 12 周之后的持续成长

1
2
3
4
5
第 13-24 周:把游戏做深
· 加玩法(这才是正事)
· 加运营(活动、签到、排行榜)
· 加性能(压测 → 优化 → 压测)
· 加架构(分服、跨服、集群)

之后的方向选择:

方向 内容
做深玩法 战斗系统、成长线、社交
做大规模 分服、跨服、集群、CDN、全球部署
做工具链 自研编辑器、策划工具、数据分析平台
做平台化 把你的游戏服务端框架做成产品(面向其他开发者)

以你现在的技能结构,我建议走”做深玩法 + 工具链”。工具链(编辑器、配表工具、GM 平台、运营系统)是你 Web 背景的差异化优势,别人做不了。


第 13 章 推荐技术栈清单(分三档)

13.1 档位 A:「快速验证」——最快做出能玩的东西

适合:第一个项目、验证玩法、2D 休闲/卡牌

1
2
3
4
5
6
7
8
9
客户端    Cocos Creator (TypeScript) / 纯 Canvas
服务端 Node.js + TypeScript
框架 Colyseus(房间 + 状态同步 + 匹配,开箱即用)
通信 WebSocket(二进制)
序列化 Colyseus 自带 schema(也可换 Protobuf)
存储 Redis(会话、排行榜)+ MySQL(持久化)
数据库 PostgreSQL 或 MySQL
部署 Docker Compose + Nginx
监控 Prometheus + Grafana(或简单点用日志)

优势:一周能出原型,同语言(TS)省掉跨语言协议成本
劣势:CPU 密集逻辑有 GC 天花板,不适合重度战斗

13.2 档位 B:「标准商业」—— 推荐主推 ⭐

适合:MMORPG / SLG / 卡牌 / 休闲竞技,能运营几年

1
2
3
4
5
6
7
8
9
10
11
12
13
客户端    Unity (C#) 或 Cocos Creator (TS)
服务端 Go 1.23+
框架 自研骨架 + Pitaya/Leaf 参考 / Nakama(如果想省事)
通信 KCP(战斗)+ WebSocket(后台/H5)
序列化 Protobuf(业务)+ FlatBuffers(高频状态,可选)
存储 Redis(热数据/排行/池子)+ MySQL(持久化,分库分表预留)
配置 Luban 导表 + 配置中心(自研/Nacos)
部署 Docker + systemd(或 K8s + Agones,规模需要时)
监控 Prometheus + Grafana + Loki(ELK 太重)
日志 结构化 JSON(zap/logrus/slog)→ stdout → 采集
配置中心 Nacos / etcd / 自建(版本号 + 灰度)
消息队列 NATS
ID 雪花算法

13.3 档位 C:「重度 / 双端一体」

适合:Unity 客户端 + 中重度逻辑(ARPG/MMO),追求极致效率

1
2
3
4
5
6
7
8
9
10
11
客户端    Unity 6 (C#)
服务端 C# (.NET 8+)
框架 ET Framework(N变1 单步调试、1变N 拆分、双端共享逻辑)
通信 ET 内置(KCP/TCP/WebSocket 协议软路由)
序列化 ET 内置 BSON 零 GC 序列化
寻路 Recast (NavMesh)
热更 HybridCLR(代码)+ YooAssets(资源)+ Luban(配表)
AI 行为树
压测 复用客户端逻辑层写机器人(ET 特性)
存储 MySQL + Redis + MongoDB
部署 Docker + K8s

优势:双端一份逻辑代码,省掉 30-50% 的逻辑工作量;单步调试分布式逻辑
劣势:C# 技术栈在游戏外的就业面窄;ET 文档以中文为主

13.4 三档对比

维度 A:Node.js B:Go ⭐ C:C#/ET
上手速度 最快 快 中
性能上限 中 高 高
双端共享 ✗ ✗ ✓
生态成熟度 游戏侧中 游戏侧中高 游戏侧中高
AI 辅助质量 高 最高 高
单人可维护性 高 高 中
适合的项目规模 小 中大 中大

13.5 如果要我给你一句话建议

先用 Node.js + Colyseus 花一周做一个能玩的小 demo,确认你真的喜欢这件事(这是最重要的一步,很多人写完服务端才发现自己不喜欢)。

然后正式项目直接上 Go,从第一天就按 B 档的架构走,物理上先单机、逻辑上分好层。

如果客户端确定是 Unity 且逻辑重度,第 8 周时认真评估 ET——双端共享代码的价值在项目后期会越来越大。


第 14 章 学习资源清单

14.1 必读书籍

书 价值 优先级
《游戏编程模式》(Game Programming Patterns) 游戏架构的通用语言:状态模式、享元、对象池、事件、组件、ECS。无论用什么语言都该读 ⭐⭐⭐⭐⭐
《Networked Physics》(Daniel Raknet 等) 网络同步的深度参考 ⭐⭐⭐⭐
《Serverless Game Server》/ 《Game Server Architecture》 服务端架构模式 ⭐⭐⭐
《大型网站技术架构》/ 《凤凰架构》 你的存量知识,直接迁移 ⭐⭐⭐⭐
《Redis 设计与实现》 你的存量知识 ⭐⭐⭐⭐

14.2 必读源码/仓库

项目 语言 为什么读 优先级
skynet C+Lua 中文游戏服务端的事实标准;云风的十字链表 AOI;Actor 模型 ⭐⭐⭐⭐⭐
Pitaya Go 现代 Go 游戏服务端的完整形态;connector/handler 分离 ⭐⭐⭐⭐⭐
ET Framework C# 分布式游戏服务的工程化极致 ⭐⭐⭐⭐⭐
Colyseus TypeScript 房间抽象、声明式状态同步的最佳实践 ⭐⭐⭐⭐
Nakama Go 全栈游戏后端(认证/存储/匹配/聊天) ⭐⭐⭐⭐
Luban C# 配表流水线 ⭐⭐⭐⭐
Colyseus / Photon 对比实现 — 看同一问题的不同解法 ⭐⭐⭐
awesome-gameserver-cn — 框架索引,找资料用 ⭐⭐⭐
KBEngine Python 成熟的 MMO 引擎,AOI/watcher/witness 体系 ⭐⭐⭐
Pomelo(已停更) Node 设计理念经典(房间/组件/handler) ⭐⭐

读源码的方法:不要从头读。挑一个具体问题(比如”AOI 的 enter/leave 怎么算的”),在仓库里搜相关文件,读懂那一段。

14.3 文档与规范

资源 用途
Protocol Buffers 官方文档 编码规范、兼容性规则、best practices
FlatBuffers GitHub 零拷贝原理
Unity Netcode 官方文档 + Multiplayer Samples NGO 的权威用法
FishNet 文档 + Discord 预测/校正的实操
Agones 官方文档(Google) 专用服务器编排
Open Match 官方文档 匹配服务
Nakama 文档 全栈后端
Colyseus 官方文档 + Discord 房间/状态同步
Luban 官方文档 (datable.cn) 配表
Google SRE Book(免费在线) 必读,游戏服务端本质上是分布式系统,SRE 的方法论完全适用
Game Programming Patterns 英文在线版(免费) 模式速查

14.4 持续跟踪的信息源

来源 内容
GameDevNation / GameDev Report 行业动态
GDC Vault(免费部分) 顶级技术分享(Netcode 专场信息量极大)
Unity / Unreal 官方博客 引擎与网络方案更新
Nakama / Agones / Colyseus 的 Discord 实战问题
awesome-gameserver-cn 中文框架索引
知乎 / 公众号「游戏行业」相关 国内实践

14.5 练手项目建议(按难度递增)

难度 项目 练到什么
⭐ 多人聊天室(Go + WS) 长连接、协议、心跳
⭐⭐ 2D 小方块多人游戏 状态同步、插值、tick
⭐⭐ 带 AOI 的 2D MMO 房间 AOI、降频、带宽控制
⭐⭐⭐ 卡牌对战服务器 房间、匹配、回合制逻辑、结算
⭐⭐⭐ 休闲竞技 + 匹配 + 排行榜 完整闭环:匹配、Elo、ZSET
⭐⭐⭐⭐ MMO 房间 + GM 后台 + 配表 商业化项目的完整形态
⭐⭐⭐⭐⭐ 帧同步 Demo 确定性定点数、状态快照、回滚

第 4-5 个练手项目基本可以变成你的正式项目。不要每个都做完——做完第 3 个就该考虑做自己的产品了。


第 15 章 常见坑清单

15.1 架构层

# 坑 后果 正确做法
1 过早分布式 一个人维护 10 个服务,累死 逻辑分层、物理单机起步
2 每个房间一个进程 内存浪费、进程数爆炸 一个进程管 N 个房间
3 有状态服无状态扩 玩家随机漂移、状态丢失 一致性哈希 + 状态外置
4 所有逻辑塞一个服务 改一个活动要全量发布 按变更频率分层
5 DBProxy 只是个 JDBC 封装 缓存、路由、分片没做 承担真正的数据层职责
6 分布式 ID 上线后改 数据全乱 立项定好,永不改
7 没有 traceID 出问题查不出链路 消息头带 traceID

15.2 性能层

# 坑 后果 正确做法
8 每包都打日志 磁盘打满、服务崩溃 采样 + 计数
9 每帧分配对象 GC 压力、帧率抖动 对象池
10 主逻辑里同步查库 所有玩家一起卡 内存态 + 异步落库
11 广播给所有人 带宽爆炸 AOI
12 ulimit -n 默认 1024 连接数上不去就断了 调到 10 万+
13 慢客户端不踢 一个客户端拖垮全房间 发送队列上限 + 主动断开
14 在 tick 里做 JSON 序列化 火焰图第一名的常客 Protobuf / 预编码
15 不看 P99 只看平均 隐藏的长尾问题 强制看分位数

15.3 同步层

# 坑 后果 正确做法
16 帧同步里用浮点 不同步 定点数
17 帧同步里用 Time.deltaTime 不同步 固定逻辑帧常量
18 遍历 Dictionary 顺序不确定 → 不同步 有序容器
19 忘了 isLocalPlayer 检查 所有客户端控制所有玩家 第一个多人项目最常见的 bug
20 本地玩家也用插值 操作有延迟感 本地预测,远程插值
21 回滚逻辑里有外部副作用 重演时重复发奖 副作用分离到 tick 之外
22 没有 checksum desync 无法定位 定期状态 hash 比对
23 AOI 边界抖动 enter/leave 事件风暴 滞回(进入/离开阈值错开)
24 没做延迟模拟测试 真实网络下崩溃 从第一天注入延迟/丢包

15.4 数据层

# 坑 后果 正确做法
25 支付回调不幂等 重复发货 → 资损 唯一索引 + 幂等键
26 信任渠道传来的商品信息 玩家改金额 以自己系统的订单为准
27 金额用 float 精度丢失 整数(分)
28 配置表无校验 线上才发现引用了不存在的 ID Luban 的 ref 检查
29 客户端和服务端配置版本不一致 显示和实际不符 握手时交换版本号 + 告警
30 DB 直接改玩家数据 缓存不一致 走服务端的 GM 接口
31 无数据备份 / 无恢复演练 事故时数据没了 自动备份 + 定期演练恢复

15.5 运维层

# 坑 后果 正确做法
32 不处理 SIGTERM 容器被杀,玩家数据丢 优雅退出 + 落盘
33 磁盘满不告警 半夜服务崩溃 磁盘告警 + 日志轮转
34 Nginx 忘配 Connection "upgrade" WebSocket 握手 400 照抄配置
35 proxy_read_timeout 太短 长连接被掐断 调大 + 心跳间隔对齐
36 不透明真实 IP IP 限流失效 X-Real-IP + X-Forwarded-For
37 没有灰度 出问题全量受影响 灰度 + 快速回滚
38 DB 变更锁表 服务停摆 在线 DDL + 向前兼容
39 没有压测就上线 开服就崩 上线前必压
40 GM 后台权限过大 内部事故 RBAC + 审计 + 告警

附录 A:从 PHP 到游戏服务端的能力映射表

PHP 技能 游戏服务端对应 迁移难度 备注
Laravel 路由/控制器 消息号路由(opcode) 低 从 URL 路由换成消息分发
Eloquent ORM 手写 SQL 或 sqlx/GORM 低 玩家数据不适合 ORM,用仓储模式
Redis 缓存 Redis 热数据 无 直接用
Redis ZSET 排行榜 排行榜 无 直接用
MySQL 表设计 玩家/物品/订单表 无 直接用
数据库迁移 数据结构变更 低 加”向前兼容”约束
队列 / 事件 消息队列、事件总线 低
定时任务 日常任务、结算 低
缓存锁 分布式锁 低 但要理解它的失效场景
支付回调 / 幂等 充值发货 无 你的最强项
RBAC / 权限 GM 后台权限 无 你的最强项
Filament / FastAdmin 后台 GM 平台、运营后台 无 你的最强项
导表 Excel 脚本 Luban 配表流水线 低 加类型系统和校验
Nginx 配置 反向代理 WebSocket 低 加 Upgrade 头
Docker 容器化部署 无 直接用
Supervisor / systemd 进程管理 无 直接用
Prometheus / Grafana 监控 低 加游戏专属指标
限流 / 防重 游戏风控 低
分布式 ID(雪花) 玩家 ID、物品 UID 无 直接用
分布式事务 不适用 — 改用最终一致 + 补偿
同步阻塞 IO 不适用 — 全部改成异步/内存态
协程(Swoole) goroutine 中 概念相通,实现不同
RESTful API 消息协议 中
单元测试 战斗逻辑测试 低 比 Web 更重要
消息队列消费者 落库 / 跨服 低

能力映射总结:

  • 约 60% 的技能可以直接迁移(数据层、后台、运维、支付)
  • 约 25% 需要改造(路由、协程、队列、定时任务)
  • 约 15% 是全新的(同步模型、AOI、tick 循环、帧确定性、战斗系统)

这 15% 就是你转型要学的东西——而它们恰好是游戏服务端最有价值的部分。


附录 B:技术选型决策速查(贴在显示器上)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌─────────────────────────────────────────────────────────────┐
│ 客户端引擎 │
│ Unity 重逻辑 → ET(C#) Unity 轻中度 → Go+NGO/FishNet │
│ Cocos → Go 或 Node.js H5/小游戏 → Node+Colyseus │
│ │
│ 同步模型 │
│ 状态同步(默认) │ 帧同步(≤8人竞技,慎选) │ 预测回滚(动作) │
│ │
│ 传输 │
│ 战斗 KCP/UDP │ 业务 TCP/WS │ 后台 HTTP │
│ │
│ 序列化 │
│ Protobuf(默认) │ FlatBuffers(帧同步高频) │ JSON(后台) │
│ │
│ 存储 │
│ 内存(权威) → Redis(热) → MySQL(冷) → MQ(异步批量) │
│ │
│ 配置 │
│ Luban 导表 + 原子热替换 + 版本号灰度 │
│ │
│ 必做监控指标 │
│ 在线人数 │ tick延迟P99 │ 连接成功率 │ CPU/内存 │ 磁盘 │
│ │
│ 铁律 │
│ 客户端只"看"和"表达意图",服务端负责"是"和"给" │
│ 宁可断开一个卡住的客户端,不要拖住整个房间 │
│ 没有火焰图的性能分析都是盲人摸象 │
│ 没有 checksum 的帧同步,第一次 desync 就卡死 │
│ 支付幂等是唯一不能出错的代码 │
└─────────────────────────────────────────────────────────────┘