ioGame 完整教程:从入门到精通
本教程专为初级程序员设计,从基础概念出发,逐步深入技术原理,配合实战示例,帮助你真正理解并掌握 ioGame 游戏服务器框架。
学习本教程不需要你有游戏服务器开发经验,只需要具备基本的 Java 编程基础即可。
目录
- 第一篇:零基础入门
- 第1章:游戏服务器到底是什么
- 第2章:ioGame 是什么,能帮你做什么
- 第3章:环境搭建与第一个程序
- 第二篇:核心概念
- 第4章:三层架构——ioGame 的骨架
- 第5章:Action 编程模型——写普通 Java 方法就行
- 第6章:路由系统——消息怎么找到你的代码
- 第7章:数据协议——对象怎么变成网络字节
- 第三篇:技术原理
- 第8章:一条请求的全流程
- 第9章:线程模型——为什么不需要加锁
- 第10章:路由查找原理——O(1) 是怎么做到的
- 第11章:序列化原理——jProtobuf 是怎么工作的
- 第12章:分布式通信——逻辑服之间怎么说话
- 第四篇:实战开发
- 第13章:登录系统实战
- 第14章:房间系统实战
- 第15章:广播与推送
- 第16章:跨逻辑服调用
- 第五篇:进阶与部署
- 第17章:性能优化核心思路
- 第18章:三种部署模式
- 第19章:常见问题与排查
第一篇:零基础入门
第1章:游戏服务器到底是什么
1.1 先理解"普通服务器"和"游戏服务器"的区别
如果你之前做过 Web 开发(比如写过后端接口),你可能会觉得服务器就是接收请求、处理请求、返回响应。这种理解对,但不完全对——因为游戏服务器和 Web 服务器的工作方式很不一样。
Web 服务器的工作方式(短连接):
浏览器 --请求--> 服务器
浏览器 <--响应-- 服务器
(连接断开)
比如你在淘宝上点击"查看商品详情",浏览器发一个请求,服务器把商品信息返回,然后连接就断了。下次你再点别的按钮,再新建一个连接。
游戏服务器的工作方式(长连接):
游戏客户端 <=======长连接=======> 游戏服务器
(持续保持连接,可能几小时)
你登录王者荣耀后,这个连接会一直保持。服务器需要随时告诉你:队友移动了、敌人放技能了、草丛里有人等等。这些消息是服务器主动推送给你的,不是等你去问的。
这就是为什么游戏服务器更难做:
| 问题 | Web 服务器 | 游戏服务器 |
|---|---|---|
| 连接方式 | 短连接,请求完就断 | 长连接,保持几小时 |
| 谁主动发消息 | 只有客户端主动请求 | 服务器也要主动推送 |
| 并发处理 | 每个请求独立,无状态 | 玩家之间有交互,有状态 |
| 延迟要求 | 几百毫秒可以接受 | 几十毫秒都嫌慢 |
| 断线处理 | 基本不管 | 必须处理断线重连 |
1.2 从零写一个游戏服务器有多难
假设老板让你从零用 Java 写一个游戏服务器,你需要解决以下所有问题:
你需要自己实现:
├── 网络层
│ ├── TCP/UDP/WebSocket 连接管理
│ ├── 心跳检测(判断玩家是否掉线)
│ ├── 断线重连处理
│ └── 粘包拆包(TCP是流,没有消息边界)
├── 业务层
│ ├── 消息路由(怎么找到处理这条消息的代码)
│ ├── 线程模型(怎么并发处理又不冲突)
│ ├── 玩家会话管理
│ └── 状态同步
├── 分布式
│ ├── 服务发现(新服务器启动怎么被找到)
│ ├── 负载均衡(请求分给哪台服务器)
│ ├── 跨服通信(不同服务器之间怎么通信)
│ └── 容错处理(一台挂了怎么办)
└── 性能优化
├── 内存管理
├── 垃圾回收优化
├── 批量处理
└── 零拷贝
每一个都是复杂的技术难题。所以业界出现了游戏服务器框架——把这些难题封装起来,让开发者只需要写业务逻辑。
1.3 ioGame 的定位
ioGame 是一个基于 Java 的开源游戏服务器框架。它的设计哲学是:
- 低学习门槛:你写普通 Java 方法就行,框架负责剩下的所有事
- 高性能:底层基于 Netty、SOFABolt、Disruptor 等成熟的高性能组件
- 灵活部署:可以从单进程开发,平滑过渡到分布式部署
- 低耦合:网络层、网关层、业务层清晰分离,互不影响
第2章:ioGame 是什么,能帮你做什么
2.1 传统游戏开发的三大痛点
痛点一:网络编程太复杂
传统方式下,你得手动处理网络连接:
// 传统方式:你需要手动写这些
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket client = server.accept();
// 处理粘包拆包
// 处理心跳
// 处理断线重连
// 处理协议编解码
// ... 大量底层代码
}
这意味着你必须深入理解 TCP/IP、NIO、Buffer 管理等底层知识。
痛点二:并发控制头疼
游戏里有大量玩家同时在线,他们的数据可能被同时修改:
// 传统方式:你需要手动加锁
public synchronized void buyItem(User user, int itemId) {
// 玩家买道具,扣金币、加物品
// 但 synchronized 会导致性能瓶颈
}
锁多了性能差,锁少了数据乱。这是一个很难平衡的难题。
痛点三:分布式部署复杂
当一台服务器撑不住时,你需要:
- 配置 ZooKeeper 或 Eureka 做服务注册
- 自己实现负载均衡策略
- 处理跨服通信
- 管理分布式锁
搭建和维护这些基础设施的成本很高。
2.2 ioGame 怎么解决这些问题
ioGame 通过架构设计和封装,把上述复杂性全部屏蔽:
使用 ioGame 后:
├── 网络层:框架自动处理,你的业务代码零感知
├── 并发控制:通过线程模型自动保证,你不需要加锁
├── 分布式:内置服务发现,启动即注册,无需外部中间件
└── 业务开发:写普通 Java 方法就行
2.3 ioGame 适合做什么游戏
- 棋牌类游戏:斗地主、麻将等,需要房间管理、实时对战
- 回合制游戏:需要状态同步、断线重连
- 多人在线游戏:MMORPG、MOBA 等,需要高并发、分布式
- 实时对战游戏:FPS、RTS 等,需要低延迟、高性能
第3章:环境搭建与第一个程序
3.1 环境要求
- JDK:JDK 21 或更高版本(ioGame 21+ 版本使用虚拟线程特性)
- 构建工具:Maven 3.6+
- IDE:IntelliJ IDEA(推荐)、Eclipse 等
3.2 创建第一个 ioGame 项目
步骤一:创建 Maven 项目
创建一个普通 Maven 项目,在 pom.xml 中添加核心依赖:
<dependencies>
<!-- 游戏对外服:负责和客户端保持长连接 -->
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>external-core</artifactId>
<version>21.0</version>
</dependency>
<!-- Broker 网关:负责路由转发 -->
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>broker-server</artifactId>
<version>21.0</version>
</dependency>
<!-- 逻辑服:你的业务代码跑在这里 -->
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>game-server</artifactId>
<version>21.0</version>
</dependency>
<!-- 网络通信实现 -->
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>external-netty</artifactId>
<version>21.0</version>
</dependency>
</dependencies>
为什么需要这么多依赖?
因为 ioGame 把功能拆得很细,你按需引入。
external-core是对外服的接口定义,external-netty是基于 Netty 的具体实现。Broker 和逻辑服也类似。
步骤二:创建数据协议类
游戏客户端和服务器之间需要交换数据,这些数据需要序列化后才能通过网络传输。ioGame 使用 Protobuf 作为默认序列化协议,但不需要你手写 .proto 文件——用 Java 注解就行。
import com.baidu.bjf.remoting.protobuf.annotation.ProtobufClass;
// 请求:客户端发给服务器
@ProtobufClass
public class HelloRequest {
public String name;
@Override
public String toString() {
return "HelloRequest{name='" + name + "'}";
}
}
// 响应:服务器返回给客户端
@ProtobufClass
public class HelloResponse {
public String message;
public long timestamp;
@Override
public String toString() {
return "HelloResponse{message='" + message + "', timestamp=" + timestamp + "}";
}
}
技术原理:
@ProtobufClass 注解告诉框架:这个类需要 Protobuf 序列化。框架会在编译期或启动时自动分析这个类的字段,生成高效的序列化/反序列化代码。你不需要写 .proto 文件,也不需要运行 protoc 编译器,但性能与原生 Protobuf 完全等价。
步骤三:创建业务控制器(Action)
这是 ioGame 最核心的编程模型。你只需要写普通 Java 方法,不需要继承任何类,不需要实现任何接口:
import com.iohao.game.action.skeleton.annotation.ActionController;
import com.iohao.game.action.skeleton.annotation.ActionMethod;
@ActionController(1) // 主路由号 = 1
public class HelloAction {
@ActionMethod(0) // 子路由号 = 0
public HelloResponse sayHello(HelloRequest request) {
HelloResponse response = new HelloResponse();
response.message = "Hello, " + request.name + "! 欢迎来学 ioGame。";
response.timestamp = System.currentTimeMillis();
return response; // 框架自动把返回值发给客户端
}
}
这是什么意思?
@ActionController(1):定义这个类处理 "cmd = 1" 的请求。类似 Spring 的@RestController定义一个控制器。@ActionMethod(0):定义这个方法处理 "subCmd = 0" 的请求。类似 Spring 的@RequestMapping定义一个接口。- 方法参数
HelloRequest request:框架自动把客户端发来的二进制数据反序列化成这个对象。 - 返回值
HelloResponse:框架自动把这个对象序列化后发给客户端。
客户端发送消息时,会带上 cmd = 1, subCmd = 0,框架就能找到 HelloAction.sayHello 方法来处理。
步骤四:启动服务器
public class MyGameServer {
public static void main(String[] args) {
// 1. 配置对外服(客户端连进来的入口)
ExternalServer externalServer = ExternalServer.newBuilder(8888)
.build();
// 2. 配置 Broker 网关(负责转发消息)
BrokerServer brokerServer = BrokerServer.newBuilder()
.build();
// 3. 配置逻辑服(跑你的业务代码)
BarSkeletonBuilder skeletonBuilder = BarSkeletonBuilder.newBuilder()
.scanActionPackage("com.yourgame.action"); // 扫描 Action 的包路径
GameServer gameServer = GameServer.newBuilder()
.setBarSkeleton(skeletonBuilder.build())
.build();
// 4. 启动(按顺序:Broker -> 逻辑服 -> 对外服)
brokerServer.startup();
gameServer.startup();
externalServer.startup();
System.out.println("游戏服务器启动成功!端口: 8888");
}
}
启动顺序为什么重要?
Broker 必须先启动,因为它是消息转发的枢纽。逻辑服启动时要向 Broker 注册自己支持的路由。对外服最后启动,因为它需要把客户端请求转发给 Broker。
3.3 测试运行
启动后,框架会在控制台输出类似这样的调试信息:
┏━━━━━ Debug. [(HelloAction.java:10).sayHello] ━━━ [cmd:1 - subCmd:0 - cmdMerge:65536]
┣ userId: 1001
┣ 参数: helloRequest : HelloRequest{name='张三'}
┣ 响应: HelloResponse{message='Hello, 张三! 欢迎来学 ioGame。'}
┣ 时间: 2 ms (业务方法总耗时)
┗━━━━━ Debug [HelloAction.java] ━━━
这段信息包含:
- 代码位置:HelloAction.java:10,点击可直接跳转
- 路由信息:cmd:1 - subCmd:0,方便前后端联调时核对
- 用户 ID:当前请求是哪个玩家发的
- 参数和响应:请求数据和返回结果
- 执行耗时:性能分析的重要参考
第二篇:核心概念
第4章:三层架构——ioGame 的骨架
4.1 为什么游戏服务器要分层
想象一个餐厅:
- 迎宾员站在门口迎接客人、带座
- 传菜员把客人的点餐单传给厨房,再把菜端回来
- 厨师只负责做菜
这三个人各司其职,互不干涉。如果客人太多,你可以多招几个迎宾员;如果厨房忙不过来,你可以多招几个厨师。这就是分层架构的思想。
传统单体游戏服务器的问题是:迎宾员、传菜员、厨师全是同一个人。客人少的时候没问题,客人一多就崩了,而且你不知道是哪里慢了。
4.2 ioGame 的三层架构
ioGame 把游戏服务器拆成三层:
客户端(游戏 App / H5 / 小程序)
↓ WebSocket / TCP / UDP
┌─────────────────────────────┐
│ 游戏对外服 │ ← 迎宾员:只负责接客(连接管理)
│ (ExternalServer) │
└─────────────┬───────────────┘
↓
┌─────────────────────────────┐
│ Broker 游戏网关 │ ← 传菜员:只负责传话(路由转发)
│ (BrokerServer) │
└─────────────┬───────────────┘
↓
┌─────────────────────────────┐
│ 游戏逻辑服 │ ← 厨师:只负责做菜(业务逻辑)
│ (GameLogicServer) │
└─────────────────────────────┘
游戏对外服(ExternalServer):
- 监听端口,接受客户端连接
- 维护长连接(心跳检测、断线重连)
- 协议解析(TCP/UDP/WebSocket)
- 把请求转发给 Broker,把响应返回给客户端
- 不执行任何业务逻辑
Broker 游戏网关:
- 服务注册与发现(逻辑服启动时自动注册)
- 请求路由(根据 cmd/subCmd 找到对应的逻辑服)
- 负载均衡(把请求分配给压力最小的逻辑服实例)
- 跨服通信中转
- 不执行任何业务逻辑
游戏逻辑服(GameLogicServer):
- 执行 Action 方法(你的业务代码)
- 维护游戏状态(玩家数据、房间状态等)
- 管理玩家会话
- 不直接处理客户端连接,不监听外部端口
4.3 这种分层的好处
- 职责清晰:每层只做一件事,代码好维护
- 独立扩容:
- 连接数太多 → 加对外服
- 转发压力大 → 加 Broker
- 业务计算慢 → 加逻辑服
- 故障隔离:一层崩溃了不影响其他层
- 开发简单:你写业务代码时,完全不用关心网络细节
4.4 位置透明性
这是分层架构带来的一个重要特性:
@ActionController(1)
public class HelloAction {
@ActionMethod(0)
public HelloResponse sayHello(HelloRequest request) {
// 这段业务代码完全不知道:
// 1. 客户端是通过 TCP 还是 WebSocket 连接的
// 2. 请求经过了哪些服务器节点
// 3. 当前是在单机上运行还是在分布式集群中
// 框架全部帮你处理了!
}
}
这意味着你的业务代码可以在"开发模式(单机)"和"生产模式(分布式)"之间无缝切换,一行代码都不用改。
4.5 同进程亲和优化
当你把三层部署在同一进程时(开发调试常用),框架会自动检测并启用内存直接通信:
跨进程通信:
对外服 → 网络协议栈 → Broker → 网络协议栈 → 逻辑服
延迟:毫秒级
同进程通信:
对外服 → 内存队列 → Broker → 内存队列 → 逻辑服
延迟:纳秒级(快 1000 倍以上)
框架在启动时检测各组件是否在同一个 JVM 进程内。如果是,就把网络通信替换为内存通道(如 Disruptor 环形队列),跳过 TCP/IP 协议栈,实现极致性能。这个过程对你完全透明。
第5章:Action 编程模型——写普通 Java 方法就行
5.1 Action 是什么
Action 是 ioGame 的核心编程模型。它的核心思想是:把业务逻辑抽象成普通 Java 方法。
对比传统方式和 ioGame 方式:
传统方式:
┌─────────────────────────────────┐
│ class BuyItemHandler { │
│ void handle(byte[] data) { │
│ // 手动解析二进制数据 │
│ // 手动构造响应对象 │
│ // 手动发送响应 │
│ } │
│ } │
└─────────────────────────────────┘
ioGame 方式:
┌─────────────────────────────────┐
│ @ActionController(1) │
│ class BuyAction { │
│ @ActionMethod(0) │
│ BuyResponse buy(BuyRequest) │
│ // 只写业务逻辑 │
│ return response; │
│ } │
│ } │
└─────────────────────────────────┘
优势一目了然:
- 不需要继承基类
- 不需要实现接口
- 参数自动解析
- 响应自动返回
5.2 Action 方法详解
参数类型
@ActionController(1)
public class DemoAction {
// 方式一:Protobuf 对象参数(推荐)
@ActionMethod(0)
public HelloResponse hello(HelloRequest request) {
return new HelloResponse();
}
// 方式二:基础类型参数(简单场景)
@ActionMethod(1)
public void setLevel(long userId, int level) {
// 框架自动把网络数据解析成方法参数
}
// 方式三:混合参数
@ActionMethod(2)
public boolean checkPermission(long userId, String permission) {
return true;
}
}
技术原理:
框架在启动时扫描所有 Action 方法,分析方法的参数类型和顺序,生成对应的参数解析器。运行时收到请求后,框架根据方法签名自动完成反序列化,把二进制数据注入到方法参数中。
返回值类型
@ActionController(1)
public class ResponseAction {
// 返回对象:自动序列化并推送给客户端
@ActionMethod(0)
public UserInfo getUserInfo(Long userId) {
return new UserInfo();
}
// 返回 void:无需响应(fire-and-forget)
@ActionMethod(1)
public void heartbeat() {
// 处理完就结束,不给客户端返回数据
}
// 返回基础类型:自动包装
@ActionMethod(2)
public int getOnlineCount() {
return 100;
}
}
5.3 生命周期钩子
游戏服务器经常需要在玩家上线、离线、重连时执行一些操作(比如加载玩家数据、保存数据、通知好友)。ioGame 提供了钩子机制:
public class MyUserHook implements UserHook {
@Override
public void into(long userId) {
// 玩家上线时执行
System.out.println("玩家 " + userId + " 上线");
// 加载玩家数据
// 初始化状态
// 发送登录奖励
}
@Override
public void quit(long userId) {
// 玩家离线时执行
System.out.println("玩家 " + userId + " 离线");
// 保存玩家数据
// 清理资源
}
@Override
public void reconnect(long userId) {
// 玩家断线重连时执行
System.out.println("玩家 " + userId + " 重连");
// 恢复游戏状态
// 同步最新数据
}
}
技术原理:
框架在连接建立、断开、重连时自动触发钩子方法。这些钩子在与玩家绑定的同一业务线程中执行,保证状态一致性。
第6章:路由系统——消息怎么找到你的代码
6.1 什么是路由
想象你寄快递:
- 收件地址是 北京市海淀区xx路xx号
- 快递公司根据这个地址把包裹送到正确的地方
在游戏服务器中,路由就是"消息地址",告诉框架这条消息应该交给哪个方法来处理。
6.2 ioGame 的路由结构
ioGame 采用二维路由:
路由 = cmd(主命令) + subCmd(子命令)
例如:
@ActionController(1) ← cmd = 1,标识一个业务模块
@ActionMethod(0) ← subCmd = 0,标识模块内的一个具体动作
这两个数字组合成唯一的 cmdMerge:
cmdMerge = (cmd << 16) | subCmd
cmdMerge = (1 << 16) | 0 = 65536
路由空间有多大?
- cmd 范围:0 ~ 65535(共 65536 个模块)
- subCmd 范围:0 ~ 65535(每个模块有 65536 个动作)
- 总计:65536 × 65536 = 42 亿多个路由,足够用了
6.3 路由的组织建议
推荐按功能模块组织路由:
├── cmd: 1 - 登录模块
│ ├── subCmd: 0 - 账号登录
│ ├── subCmd: 1 - 登出
│ └── subCmd: 2 - 获取登录状态
│
├── cmd: 2 - 玩家模块
│ ├── subCmd: 0 - 获取玩家信息
│ ├── subCmd: 1 - 更新玩家信息
│ └── subCmd: 2 - 设置头像
│
├── cmd: 3 - 背包模块
│ ├── subCmd: 0 - 获取背包列表
│ ├── subCmd: 1 - 使用道具
│ └── subCmd: 2 - 出售道具
│
└── cmd: 4 - 战斗模块
├── subCmd: 0 - 开始战斗
├── subCmd: 1 - 发送战斗指令
└── subCmd: 2 - 结束战斗
这样做的好处:
- 代码按功能模块组织,便于维护
- 前后端联调时,按 cmd 就能定位到模块
- 避免路由冲突
6.4 框架怎么找到你的方法
启动时扫描:
class ActionScanner {
public void scan(String packageName) {
// 1. 扫描指定包下的所有类
List<Class> classes = ClassScanner.scan(packageName);
for (Class clazz : classes) {
// 2. 检查是否有 @ActionController 注解
ActionController controller = clazz.getAnnotation(ActionController.class);
if (controller == null) continue;
int cmd = controller.value();
// 3. 扫描类中的方法
for (Method method : clazz.getMethods()) {
ActionMethod actionMethod = method.getAnnotation(ActionMethod.class);
if (actionMethod == null) continue;
int subCmd = actionMethod.value();
// 4. 注册到路由表
routeTable[cmd][subCmd] = new ActionInvoker(method, clazz);
}
}
}
}
运行时查找:
class Router {
// 二维数组,O(1) 查找
private ActionInvoker[][] routeTable = new ActionInvoker[65536][65536];
public ActionInvoker findRoute(int cmd, int subCmd) {
return routeTable[cmd][subCmd]; // 数组索引,直接定位
}
}
为什么用二维数组?
传统框架可能用 HashMap<String, Handler> 做路由:
- HashMap 查找:计算 hash → 定位桶 → 处理冲突 → O(1) 平均,但常数开销大
- 数组索引:直接计算内存偏移 → O(1),且是最快的 O(1)
ioGame 用二维数组实现路由表,启动时一次性构建,运行时直接索引。官方基准测试显示路由层每秒可处理超过 2000 万次消息分发。
第7章:数据协议——对象怎么变成网络字节
7.1 为什么需要序列化
网络传输的是字节流(010101),不是 Java 对象。所以需要把对象转换成字节(序列化),接收方再把字节还原成对象(反序列化)。
业务对象 → 序列化 → 字节流 → 网络传输 → 字节流 → 反序列化 → 业务对象
常见的序列化方式对比:
| 方式 | 优点 | 缺点 |
|---|---|---|
| JSON | 人类可读,调试方便 | 体积大,性能低 |
| 原生 Protobuf | 体积小,性能高,强类型 | 需要写 .proto 文件,构建复杂 |
| ioGame 的 jProtobuf | 体积小,性能高,纯 Java 注解 | 生态依赖框架 |
7.2 Protobuf 是什么
Protobuf(Protocol Buffers)是 Google 开发的一种二进制序列化协议。它的核心优势:
- 体积小:比 JSON 小 1/3 到 1/2
- 速度快:序列化速度是 JSON 的 5-10 倍
- 跨语言:支持 Java、C++、C#、Go、Python 等
传统 Protobuf 的使用方式(麻烦):
// 1. 编写 .proto 文件
message UserMessage {
required string name = 1;
optional int32 level = 2;
repeated string skills = 3;
}
// 2. 用 protoc 编译器生成 Java 代码
// protoc --java_out=. user.proto
// 3. 在代码中使用生成的类
ioGame 的 jProtobuf(简单):
// 直接用 Java 注解,不需要 .proto 文件
@ProtobufClass
public class UserMessage {
public String name;
public int level;
public List<String> skills;
}
框架会在编译期或启动时扫描 @ProtobufClass 注解,自动生成等效的 Protobuf 序列化代码。生成的代码与手写 Protobuf 代码在性能上完全等价,因为走的是相同的序列化路径。
7.3 协议切换
ioGame 支持在 Protobuf 和 JSON 之间无缝切换:
// 切换序列化协议(一行配置即可)
DataCodecKit.createDataCodec(DataCodecEnum.protobuf);
DataCodecKit.createDataCodec(DataCodecEnum.json);
// 切换传输协议(也是一行配置)
// TCP、WebSocket、UDP 之间切换
技术原理:
框架通过 DataCodec 接口抽象编解码器。业务代码依赖这个抽象接口,不关心具体实现。切换协议时,只需要替换接口的实现类,业务代码完全不需要修改。这就是依赖倒置原则的实践。
7.4 消息结构
ioGame 中,所有网络消息都封装成统一的结构:
┌─────────────────────────────────────┐
│ BarMessage(统一消息对象) │
├─────────────────────────────────────┤
│ HeadMetadata(消息头) │
│ ├── cmd: 主命令号 │
│ ├── subCmd: 子命令号 │
│ ├── userId: 用户标识 │
│ ├── traceId: 链路追踪 ID │
│ └── ... │
├─────────────────────────────────────┤
│ Data(业务数据,Protobuf/JSON 编码) │
└─────────────────────────────────────┘
消息头携带路由信息和控制信息,消息体携带具体的业务数据。Broker 网关只读取消息头进行路由转发,不需要解析消息体,实现了传输层与业务层的彻底解耦。
第三篇:技术原理
第8章:一条请求的全流程
8.1 从客户端点击到服务器响应
让我们追踪一条完整的请求,看看数据在 ioGame 中是怎么流转的。
场景:玩家点击"查看个人信息"按钮
步骤 1:客户端发送请求
┌──────────┐
│ 客户端 │ 发送: cmd=2, subCmd=0, 数据: {userId: 1001}
└────┬─────┘
↓ TCP / WebSocket
步骤 2:对外服接收
┌──────────────┐
│ 对外服 │ 接收字节流 → 协议解码 → 提取 cmd/subCmd/userId
│ External │ 把请求转发给 Broker(不处理业务)
└──────┬───────┘
↓
步骤 3:Broker 路由
┌──────────────┐
│ Broker │ 查路由表: cmd=2 → 玩家逻辑服-1
│ 网关 │ 选择玩家逻辑服-1(负载均衡)
│ │ 转发请求
└──────┬───────┘
↓
步骤 4:逻辑服处理
┌──────────────┐
│ 逻辑服 │ 查路由表: cmd=2, subCmd=0 → PlayerAction.getUserInfo
│ Logic │ 反序列化参数 → 执行业务方法 → 获取返回值
└──────┬───────┘
↓ 返回响应
步骤 5:反向返回
Broker ← 对外服 ← 客户端
步骤 6:客户端显示个人信息
关键理解:
- 对外服和 Broker 都不执行业务逻辑,它们只做"传话"
- 整个过程是异步非阻塞的:对外服把请求发给 Broker 后不会傻等,而是注册一个回调,等逻辑服返回后自动推送给客户端
- 如果三层在同一个进程,步骤 2-5 之间走内存队列,不经过网络
8.2 异步非阻塞的重要性
// 同步阻塞(不好的方式):
// 对外服收到请求后,一直等着逻辑服返回
// 这个等待期间,对外服不能处理其他请求
Response response = logicServer.handle(request); // 阻塞!
sendToClient(response);
// 异步非阻塞(ioGame 的方式):
// 对外服收到请求后,把请求发给 Broker,然后立刻去处理下一个请求
// 等逻辑服返回后,框架自动把响应发给客户端
broker.sendAsync(request, new Callback() {
@Override
public void onResponse(Response response) {
sendToClient(response);
}
});
异步非阻塞让一个线程能同时处理成千上万个连接,这是支撑高并发的关键。
第9章:线程模型——为什么不需要加锁
9.1 传统并发控制的问题
在多线程环境下,如果多个线程同时修改同一个数据,就会出问题:
// 玩家有 1000 金币
// 线程 A:玩家买道具,扣 100 金币 → 读取 1000 → 写入 900
// 线程 B:玩家领奖励,加 50 金币 → 读取 1000 → 写入 1050
//
// 最终结果应该是 950,但可能是 900 或 1050!
传统解决方案是加锁:
public synchronized void updateGold(long userId, long delta) {
User user = users.get(userId);
user.gold += delta;
}
但锁会带来问题:
1. 性能下降:线程竞争锁会阻塞等待
2. 死锁风险:锁的顺序不对可能导致死锁
3. 代码复杂:到处加锁,维护困难
9.2 ioGame 的解决方案:单用户串行 + 多用户并行
ioGame 采用了一种巧妙的线程模型,从根本上避免了锁的竞争:
┌─────────────────────────────────────────┐
│ 业务线程池(假设 4 个线程) │
│ │
│ Thread-1: 处理 玩家A、玩家E、玩家I... │
│ Thread-2: 处理 玩家B、玩家F、玩家J... │
│ Thread-3: 处理 玩家C、玩家G、玩家K... │
│ Thread-4: 处理 玩家D、玩家H、玩家L... │
│ │
│ 规则: │
│ 同一个玩家的所有请求 → 固定落到同一个线程 │
│ 不同玩家的请求 → 分散到不同线程并行处理 │
└─────────────────────────────────────────┘
绑定算法:
// 根据 userId 计算目标线程索引
int threadIndex = hash(userId) % threadCount;
// 示例(4 个线程):
// 玩家 1001: hash(1001) % 4 = 1 → Thread-1
// 玩家 1002: hash(1002) % 4 = 2 → Thread-2
// 玩家 1003: hash(1003) % 4 = 3 → Thread-3
// 玩家 1004: hash(1004) % 4 = 0 → Thread-4
为什么这样就不需要加锁了?
因为同一个玩家的所有请求都由同一个线程顺序处理:
玩家 1001 的请求队列:
Thread-1: [请求1: 扣金币] → [请求2: 买道具] → [请求3: 查看背包]
↓ 串行执行,一个完了再执行下一个
不会有并发问题!
就像一个人排队办事,他的所有事都交给同一个窗口工作人员依次处理,不会有两个工作人员同时处理他的事。
断线重连后怎么办?
框架保证:玩家断线重连后,仍然通过相同的 hash 算法落到同一个线程。所以绑定关系不会变,依然不需要加锁。
9.3 房间类游戏的线程处理
对于棋牌、MOBA 等房间类游戏,同一个房间里有多个玩家。如果按 userId 分线程,可能出现:
房间 101 里有 4 个玩家:
玩家 A (userId=1001) → Thread-1
玩家 B (userId=1002) → Thread-3
玩家 C (userId=1003) → Thread-2
玩家 D (userId=1004) → Thread-1
问题:玩家 A 和 D 都落在 Thread-1,但 B 和 C 落在别的线程
房间对象会被多个线程同时修改,仍然需要加锁
解决方案:按 roomId 绑定线程
// 自定义线程绑定规则
public class MyThreadBinder implements RequestMessageClientProcessorHook {
@Override
public int selectThreadIndex(RequestMessage request) {
// 如果请求带了 roomId,按 roomId 选择线程
if (request.hasRoomId()) {
return hash(request.getRoomId()) % threadCount;
}
// 否则按 userId 选择线程
return hash(request.getUserId()) % threadCount;
}
}
这样,同一个房间里的所有玩家请求都会落到同一个线程,房间内的操作也是串行的,无需加锁。
9.4 虚拟线程处理阻塞任务
有些操作天然是阻塞的,比如数据库查询、HTTP 请求:
@ActionController(1)
public class BadExample {
@ActionMethod(0)
public void badPractice(long userId) {
// 错误:直接查数据库会阻塞整个业务线程
// 这个线程上还有其他玩家的请求在排队!
User user = userDao.load(userId); // 阻塞!危险!
user.gold += 100;
}
}
ioGame 21+ 版本基于 JDK 21 的虚拟线程(Project Loom)提供了解决方案:
@ActionController(1)
public class GoodExample {
@ActionMethod(0)
@VirtualThread // 使用虚拟线程执行这个方法
public void goodPractice(long userId) {
// 虚拟线程处理阻塞任务
User user = userDao.load(userId); // 阻塞的是虚拟线程,不影响平台线程
user.gold += 100;
}
}
虚拟线程的原理:
传统线程(平台线程)由操作系统管理,创建成本高(约 1MB 栈空间),阻塞时会占用宝贵的线程资源。
虚拟线程由 JVM 管理:
- 创建成本极低(约几百字节栈空间)
- 阻塞时自动让出平台线程,不阻塞底层线程
- 适合 I/O 密集型场景
就像一个餐厅有 10 个服务员(平台线程),但有 1000 个顾客(虚拟线程)。顾客点菜时(阻塞),服务员可以去服务其他顾客,而不是傻等。
第10章:路由查找原理——O(1) 是怎么做到的
10.1 为什么路由要快
游戏服务器的消息量很大。假设有 1 万玩家在线,每个玩家每秒发 10 条消息,服务器每秒要处理 10 万条消息。如果路由查找慢,整体性能就上不去。
10.2 传统路由 vs ioGame 路由
传统方式(HashMap):
// 启动时注册
Map<String, Handler> routeMap = new HashMap<>();
routeMap.put("1-0", new HelloHandler());
routeMap.put("1-1", new LoginHandler());
// 运行时查找
Handler handler = routeMap.get("1-0");
// 过程:计算 hashCode → 定位数组位置 → 处理冲突(链表/红黑树)
// 时间复杂度:O(1) 平均,但有 hash 计算和冲突处理的开销
ioGame 方式(二维数组):
// 启动时注册
ActionInvoker[][] routeTable = new ActionInvoker[65536][65536];
routeTable[1][0] = new ActionInvoker(HelloAction.class, "sayHello");
routeTable[1][1] = new ActionInvoker(LoginAction.class, "doLogin");
// 运行时查找
ActionInvoker invoker = routeTable[cmd][subCmd];
// 过程:计算数组偏移地址 → 直接读取
// 时间复杂度:O(1),且是最快的 O(1)(比 HashMap 快得多)
二维数组路由表的优势:
- 无 hash 计算:直接数组索引,CPU 只需做一次乘法加法
- 无冲突处理:数组每个位置独立,不存在哈希冲突
- CPU 缓存友好:数组在内存中连续存储,预取机制有效
- 启动时构建,运行时只读:不需要加锁,多线程安全
10.3 Action 调用的近原生方式
路由找到 Action 后,框架还要调用这个方法。传统方式用反射:
// 反射调用(慢)
Method method = clazz.getMethod("sayHello", HelloRequest.class);
method.invoke(instance, request); // 反射有性能开销
ioGame 在启动时预生成调用逻辑,避免运行时反射:
// 启动期:扫描注解,缓存 MethodHandle
MethodHandle handle = lookup.unreflect(method);
// 运行期:直接调用(接近原生方法调用性能)
handle.invoke(instance, request);
再加上 Action 实例使用单例模式(Flyweight 模式),不需要每次创建对象。综合这些优化,官方基准测试显示单线程每秒可执行 1152 万次业务逻辑调用。
注意:1152 万/秒是极简业务逻辑(如空方法)的理论上限。实际项目中,数据库查询、网络调用、复杂计算等会显著降低这个数字。但这个数字证明了框架本身的开销极低,不会成为性能瓶颈。
第11章:序列化原理——jProtobuf 是怎么工作的
11.1 传统 Protobuf 的工作流程
1. 开发者编写 .proto 文件(定义消息结构)
2. 用 protoc 编译器生成 Java 代码
3. 把生成的代码加入项目
4. 运行时调用生成的代码进行序列化/反序列化
问题在于:需要维护 .proto 文件,构建流程复杂,前后端需要同步 .proto 文件。
11.2 jProtobuf 的工作流程
1. 开发者用 Java 注解定义消息结构
2. 编译期/启动期:框架扫描注解,生成等效的 Protobuf 序列化代码
3. 运行时:直接调用生成的代码
性能等价的关键:
jProtobuf 生成的序列化代码与 protoc 生成的代码在序列化路径上完全一致。也就是说:
- 字段编码方式相同
- 二进制格式相同
- 解析逻辑相同
唯一的区别是生成时机:protoc 在编译前生成,jProtobuf 在编译时或启动时生成。
11.3 零拷贝序列化
传统序列化路径有多次内存拷贝:
传统路径(多次拷贝):
网络缓冲区 → byte[](拷贝1)→ Protobuf 解析 → Java 对象
Java 对象 → Protobuf 编码 → byte[](拷贝2)→ 网络缓冲区
ioGame 优化路径(零拷贝):
网络缓冲区(Direct ByteBuf)→ Protobuf 直读 → Java 对象
Java 对象 → Protobuf 直写 → Direct ByteBuf → 网络发送
DirectByteBuf 的作用:
Netty 的 ByteBuf 有两种:
- HeapByteBuf:分配在 JVM 堆内存中。数据要从堆内存拷贝到 Native 内存才能发给网卡。
- DirectByteBuf:分配在堆外内存(Native 内存)。可以直接发给网卡,无需拷贝。
ioGame 默认使用 DirectByteBuf,避免了 JVM 堆与 Native 堆之间的数据拷贝。
11.4 协议切换的实现
ioGame 通过策略模式实现协议热插拔:
// 抽象接口
interface DataCodec {
byte[] encode(Object obj);
<T> T decode(byte[] data, Class<T> clazz);
}
// Protobuf 实现
class ProtobufDataCodec implements DataCodec { ... }
// JSON 实现
class JsonDataCodec implements DataCodec { ... }
// 运行时切换
DataCodecKit.createDataCodec(DataCodecEnum.protobuf);
业务代码只依赖 DataCodec 接口,不依赖具体实现。切换协议就是换一个新的实现类,业务代码完全不需要改。
第12章:分布式通信——逻辑服之间怎么说话
12.1 为什么逻辑服之间要通信
把功能拆成多个逻辑服后,它们之间需要互相调用:
场景 1:玩家买道具
背包逻辑服 → 调用玩家逻辑服(扣金币)→ 调用邮件逻辑服(发奖励)
场景 2:战斗结算
战斗逻辑服 → 调用排行榜逻辑服(更新排名)→ 调用成就逻辑服(检查成就)
12.2 ioGame 的通信方式
ioGame 提供多种内部通信方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| request/response | 请求-响应,等待结果 | 需要返回值的调用 |
| request/void | 发完就忘,不等待 | 通知类操作 |
| request/multiple_response | 请求,接收多个响应 | 批量查询 |
| EventBus | 发布-订阅,异步 | 事件通知、副作用处理 |
12.3 跨服调用的代码示例
@ActionController(3)
public class BagAction {
@ActionMethod(0)
public BuyResult buyItem(BuyRequest request, FlowContext flowContext) {
// 1. 调用玩家逻辑服扣金币
DeductGoldRequest deductReq = new DeductGoldRequest();
deductReq.userId = request.userId;
deductReq.amount = request.price;
DeductGoldResponse deductResp = flowContext.invokeModuleMessage(
deductReq, // 请求对象
2, // 目标逻辑服的 cmd(玩家模块)
1 // 目标方法的 subCmd(扣金币)
);
if (!deductResp.success) {
throw new MsgException("金币不足");
}
// 2. 添加道具到背包
addItem(request.userId, request.itemId);
// 3. 发布事件(异步通知其他系统)
EventBusKit.fire(new ItemBoughtEvent(request.userId, request.itemId));
return new BuyResult(true, "购买成功");
}
}
关键类 FlowContext:
FlowContext 是 ioGame 在调用 Action 方法时注入的上下文对象,它封装了:
- 当前请求的元信息(userId、cmd、subCmd 等)
- 跨服调用的能力(invokeModuleMessage)
- 向客户端推送消息的能力
- 获取当前会话的能力
12.4 位置透明性
你调用 flowContext.invokeModuleMessage() 时,不需要知道目标逻辑服在哪台机器上:
你的代码:
flowContext.invokeModuleMessage(req, 2, 1);
框架底层自动处理:
┌─────────────────────────────────────────┐
│ 同进程?→ 直接内存引用传递(最快) │
│ 同机器不同进程?→ 本地回环网络通信 │
│ 不同机器?→ TCP 长连接通信 │
└─────────────────────────────────────────┘
无论目标逻辑服在同一进程、同一机器、还是跨机器,你的代码都完全一样。框架自动选择最优的通信路径。
12.5 EventBus 事件总线
EventBus 适合处理"副作用"操作——主逻辑完成后,触发一些不那么紧急的后续操作:
// 定义事件
public class PlayerLevelUpEvent {
public long userId;
public int oldLevel;
public int newLevel;
}
// 发布事件(在主业务逻辑中)
EventBusKit.fire(new PlayerLevelUpEvent(userId, 10, 11));
// 处理事件(在其他地方订阅)
public class LevelUpHandler implements EventHandler<PlayerLevelUpEvent> {
@Override
public void handle(PlayerLevelUpEvent event) {
// 检查是否有新成就解锁
checkAchievements(event.userId, event.newLevel);
// 通知好友
notifyFriends(event.userId, "我升级了!");
// 更新排行榜
updateRank(event.userId);
}
}
技术原理:
EventBus 基于 Disruptor 实现。发布事件是非阻塞的——发布者把事件写入 Ring Buffer 后立即返回,不需要等消费者处理完。即使消费者处理慢,也不会阻塞发布者。这非常适合处理与主逻辑无关的"副作用"。
注意:EventBus 不是持久化消息队列(如 Kafka、RabbitMQ)。如果服务器宕机,Ring Buffer 中的未处理事件会丢失。如果需要可靠投递,应该使用外部 MQ。
第四篇:实战开发
第13章:登录系统实战
13.1 需求分析
登录系统是游戏的入口,需要:
1. 玩家发送账号密码(或 Token)
2. 服务器验证身份
3. 绑定 userId 到当前连接
4. 加载玩家数据
13.2 协议定义
// 登录请求
@ProtobufClass
public class LoginRequest {
public String account;
public String password;
}
// 登录响应
@ProtobufClass
public class LoginResponse {
public boolean success;
public String token;
public long userId;
public String errorMsg;
}
13.3 Action 实现
@ActionController(1) // 登录模块
public class LoginAction {
@ActionMethod(0) // 登录
public LoginResponse login(LoginRequest request, FlowContext flowContext) {
LoginResponse response = new LoginResponse();
// 1. 验证账号密码(实际项目中查数据库或调用认证服务)
User user = userDao.findByAccount(request.account);
if (user == null || !user.password.equals(request.password)) {
response.success = false;
response.errorMsg = "账号或密码错误";
return response;
}
// 2. 绑定 userId 到当前连接
// 这步很重要:绑定后框架才知道这条连接对应哪个玩家
flowContext.setUserId(user.id);
// 3. 生成 Token(可选,用于后续请求验证)
String token = generateToken(user.id);
response.success = true;
response.token = token;
response.userId = user.id;
return response;
}
@ActionMethod(1) // 登出
public void logout(FlowContext flowContext) {
long userId = flowContext.getUserId();
// 保存玩家数据
saveUserData(userId);
// 清除绑定
flowContext.setUserId(0);
}
}
13.4 登录验证扩展
如果需要更复杂的登录验证(比如 JWT、微信 OAuth),可以实现 VerifyHandler:
public class JwtVerifyHandler implements VerifyHandler {
@Override
public boolean verify(VerifyRequest request) {
// 验证 JWT Token
return JwtUtil.verify(request.token);
}
}
第14章:房间系统实战
14.1 需求分析
棋牌、MOBA 等游戏需要房间系统:
1. 创建房间
2. 加入房间
3. 开始游戏
4. 房间内广播
14.2 协议定义
@ProtobufClass
public class CreateRoomRequest {
public int gameType; // 游戏类型:1=斗地主, 2=麻将...
public int maxPlayers; // 最大人数
}
@ProtobufClass
public class RoomInfo {
public long roomId;
public int gameType;
public List<Long> playerIds;
public int status; // 0=等待中, 1=游戏中
}
@ProtobufClass
public class JoinRoomRequest {
public long roomId;
}
@ProtobufClass
public class GameActionRequest {
public long roomId;
public int actionType;
public String data;
}
14.3 Action 实现
@ActionController(10) // 房间模块
public class RoomAction {
@ActionMethod(0) // 创建房间
public RoomInfo createRoom(CreateRoomRequest request, FlowContext flowContext) {
long userId = flowContext.getUserId();
// 生成房间 ID
long roomId = RoomIdGenerator.nextId();
// 创建房间对象
Room room = new Room(roomId, request.gameType, request.maxPlayers);
room.addPlayer(userId);
// 缓存房间
RoomCache.put(roomId, room);
// 记录玩家当前所在房间
PlayerRoomCache.put(userId, roomId);
return room.toInfo();
}
@ActionMethod(1) // 加入房间
public RoomInfo joinRoom(JoinRoomRequest request, FlowContext flowContext) {
long userId = flowContext.getUserId();
Room room = RoomCache.get(request.roomId);
if (room == null) {
throw new MsgException(-1001, "房间不存在");
}
if (room.isFull()) {
throw new MsgException(-1002, "房间已满");
}
if (room.isPlaying()) {
throw new MsgException(-1003, "游戏已开始");
}
room.addPlayer(userId);
PlayerRoomCache.put(userId, request.roomId);
// 广播给房间内所有玩家:有人加入了
broadcastToRoom(request.roomId, new PlayerJoinNotification(userId));
return room.toInfo();
}
@ActionMethod(2) // 开始游戏
public void startGame(StartGameRequest request, FlowContext flowContext) {
long userId = flowContext.getUserId();
Room room = RoomCache.get(request.roomId);
if (room == null) {
throw new MsgException("房间不存在");
}
if (room.getOwnerId() != userId) {
throw new MsgException("只有房主可以开始游戏");
}
room.startGame();
// 广播游戏开始
broadcastToRoom(request.roomId, new GameStartNotification());
}
@ActionMethod(3) // 发送游戏动作
public void sendAction(GameActionRequest request, FlowContext flowContext) {
long userId = flowContext.getUserId();
Room room = RoomCache.get(request.roomId);
if (room == null || !room.isPlaying()) {
return;
}
// 处理游戏动作
room.processAction(userId, request);
// 广播游戏状态更新
broadcastToRoom(request.roomId, room.getGameState());
}
}
14.4 房间广播的实现
private void broadcastToRoom(long roomId, Object message) {
Room room = RoomCache.get(roomId);
if (room == null) return;
for (Long playerId : room.getPlayerIds()) {
UserSession session = UserSessions.getUserSession(playerId);
if (session != null) {
session.writeAndFlush(message);
}
}
}
框架内部会对广播进行优化:构建合并的消息帧,通过 Netty 的 ChannelGroup 批量写入,底层可能合并为单次系统调用,比逐条发送效率高得多。
第15章:广播与推送
15.1 单播、广播、多播
// 单播:给特定玩家发消息
UserSession session = UserSessions.getUserSession(userId);
session.writeAndFlush(message);
// 广播:给所有在线玩家发消息
UserSessions.broadcast(message);
// 多播:给满足条件的玩家发消息
List<Long> targetUserIds = ...;
for (Long id : targetUserIds) {
UserSession session = UserSessions.getUserSession(id);
if (session != null) {
session.writeAndFlush(message);
}
}
15.2 推送的实现原理
游戏服务器经常需要主动推送消息给客户端(比如怪物刷新、其他玩家移动)。在 ioGame 中,推送通过 UserSession 实现:
// 在 Action 方法中,通过 FlowContext 获取当前玩家的会话
@ActionMethod(0)
public void someAction(FlowContext flowContext) {
// 给当前玩家推送消息
flowContext.writeAndFlush(new Notification("你获得了一个宝箱!"));
}
技术原理:
对外服维护着所有在线玩家的连接。当你调用 session.writeAndFlush() 时:
- 如果目标玩家连接在当前对外服,直接发送
- 如果目标玩家在别的对外服上,框架通过 Broker 路由到正确的对外服,再发送给客户端
这个过程对你完全透明。
第16章:跨逻辑服调用
16.1 场景:排行榜更新
战斗结束后,需要更新排行榜。排行榜可能是一个独立的逻辑服。
@ActionController(11) // 战斗模块
public class BattleAction {
@ActionMethod(2) // 战斗结算
public BattleResult settleBattle(BattleSettleRequest request, FlowContext flowContext) {
// ... 计算战斗结果 ...
// 更新排行榜(调用排行榜逻辑服)
UpdateRankRequest rankReq = new UpdateRankRequest();
rankReq.userId = request.userId;
rankReq.score = battleResult.score;
flowContext.invokeModuleMessage(rankReq, 20, 0);
// 检查成就(调用成就逻辑服)
CheckAchievementRequest achReq = new CheckAchievementRequest();
achReq.userId = request.userId;
achReq.achievementType = "first_win";
flowContext.invokeModuleMessage(achReq, 21, 0);
return battleResult;
}
}
16.2 动态绑定逻辑服
对于房间类游戏,匹配成功后需要把玩家绑定到指定房间服:
// 匹配逻辑服
@ActionController(12)
public class MatchAction {
@ActionMethod(0)
public MatchResult match(MatchRequest request, FlowContext flowContext) {
// 找一个压力最小的房间服
int targetLogicServerId = selectLeastLoadedRoomServer();
// 把玩家绑定到这个房间服
flowContext.getBarMessage().setBindingLogicServerId(targetLogicServerId);
return new MatchResult(targetLogicServerId);
}
}
绑定后,该玩家的后续请求会被 Broker 路由到指定的房间服。
注意:绑定数据保存在对外服中。玩家断线后,对外服会清除绑定信息,所以重连后需要重新绑定。框架只提供绑定能力,不维护业务状态。
第五篇:进阶与部署
第17章:性能优化核心思路
17.1 零拷贝技术
传统数据流转路径:
磁盘/网络 → 内核缓冲区 → JVM 堆内存 → 业务对象
(每次拷贝都消耗 CPU 和内存带宽)
ioGame 的优化路径:
网络 → Direct ByteBuf(堆外内存)→ 业务对象
(减少了堆内外拷贝)
具体优化手段:
- DirectByteBuf:使用堆外内存,避免 JVM 堆与 Native 堆之间的拷贝
- CompositeByteBuf:多个数据片段逻辑组合,不需要物理合并
- FileRegion:大文件直接从内核空间发送到网卡,用户空间零介入
17.2 批量处理
游戏服务器中消息通常很小但数量很大。逐条处理的效率低:
逐条处理:
收到消息1 → 解码 → 处理 → 响应
收到消息2 → 解码 → 处理 → 响应
... (大量上下文切换)
批量处理:
收到 [消息1, 消息2, 消息3, ...] → 批量解码 → 批量处理 → 批量响应
... (减少上下文切换,摊薄固定开销)
ioGame 在多层实现批量优化:
- 网络层:SOFABolt 批量解包、批量提交
- 业务层:Disruptor Ring Buffer 批量消费
- 广播层:合并消息帧,批量发送
17.3 内存与 GC 优化
GC 问题:频繁创建对象 → GC 频繁 → 停顿 → 延迟抖动
ioGame 的优化策略:
1. 预分配:启动时分配固定内存(Disruptor Ring Buffer)
2. 对象复用:运行时循环使用对象,不创建新对象
3. 零反射:启动期解析,运行期直接调用
4. 堆外内存:Direct Buffer 减少堆内存压力
目标:将 GC 频率从每秒数次降至数分钟一次
17.4 线程模型优化要点
-
不要在用户线程中做阻塞操作:数据库查询、HTTP 请求、文件读写等阻塞操作会阻塞该线程上所有玩家的请求。应该使用虚拟线程或异步线程池。
-
合理配置线程数:业务线程数不是越多越好。通常 1-4 个业务线程就能发挥很好性能,因为 Disruptor 是单线程消费模型。
-
按业务场景选择绑定键:
- 玩家个人数据 → 按
userId绑定 - 房间/战斗数据 → 按
roomId/battleId绑定 - 全局数据 → 需要额外的并发控制(DB 乐观锁、Redis 分布式锁)
第18章:三种部署模式
18.1 单体开发模式(开发调试)
┌──────────────────────────────┐
│ 一个 JVM 进程 │
│ │
│ ┌────────┐ ┌─────┐ ┌───┐ │
│ │ 对外服 │ │Broker│ │逻辑服│ │
│ └────────┘ └─────┘ └───┘ │
└──────────────────────────────┘
特点:
- 三者在一个进程内,通过内存直接通信
- 零网络延迟,调试方便
- 适合:开发调试、快速原型、小规模部署
18.2 网关分离模式(中小型生产)
┌──────────┐ ┌──────────────────┐
│ 对外服 │ │ JVM 进程 │
│ 独立进程 │ │ │
└────┬─────┘ │ ┌─────┐ ┌───┐ │
↓ 网络通信 │ │Broker│ │逻辑服│ │
(TCP长连接) │ └─────┘ └───┘ │
└──────────────────┘
特点:
- 对外服独立部署,内部 Broker+逻辑服同进程
- 外部流量隔离,内部零拷贝
- 适合:中小型生产环境
18.3 全分布式模式(大型生产)
┌──────┐ ┌──────┐ ┌──────┐
│对外服1│ │对外服2│ │对外服3│ ← 可独立扩容
└──┬───┘ └──┬───┘ └──┬───┘
↓ ↓ ↓
┌──────────────────────────────────┐
│ Broker 集群 │ ← 自带负载均衡
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │B-1 │ │B-2 │ │B-3 │ │
│ └─────┘ └─────┘ └─────┘ │
└────────────┬─────────────────────┘
↓
┌──────────────────────────────────┐
│ 逻辑服集群 │ ← 可独立扩容
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │登录服│ │战斗服│ │排行服│ │
│ └─────┘ └─────┘ └─────┘ │
└──────────────────────────────────┘
特点:
- 完全水平扩展,故障隔离
- 适合:大型生产环境、高可用要求
18.4 平滑过渡
ioGame 的设计目标是:从单体到分布式无需修改业务代码,只需要调整启动配置。
// 开发模式:三者同进程
public static void main(String[] args) {
// 简单的启动方式
IoGameSimpleHelper.run();
}
// 生产模式:分别部署
// 对外服部署在一台机器
// Broker 部署在多台机器
// 逻辑服按模块独立部署
第19章:常见问题与排查
19.1 玩家断线重连
public class MyUserHook implements UserHook {
@Override
public void reconnect(long userId) {
// 恢复玩家状态
Player player = PlayerCache.get(userId);
if (player != null) {
// 同步最新游戏状态给客户端
UserSession session = UserSessions.getUserSession(userId);
session.writeAndFlush(new SyncGameState(player));
}
}
}
19.2 数据库慢查询
@ActionController(1)
public class PlayerAction {
@ActionMethod(0)
@VirtualThread // 使用虚拟线程处理阻塞 I/O
public PlayerInfo loadPlayer(Long userId) {
// 慢查询在虚拟线程中执行,不阻塞业务线程
Player player = playerDao.load(userId);
return convertToInfo(player);
}
}
19.3 路由找不到(cmd/subCmd 错误)
如果客户端发送了 cmd=1, subCmd=0,但框架报 "路由不存在",排查步骤:
- 检查 Action 类上是否有
@ActionController(1) - 检查方法上是否有
@ActionMethod(0) - 检查启动时扫描的包路径是否包含这个类
- 查看启动日志,确认路由表是否成功构建
19.4 性能调优建议
JVM 参数:
-Xms2g -Xmx2g # 固定堆内存,避免动态扩缩
-XX:+UseZGC # 使用 ZGC 低延迟垃圾回收器
-XX:+UseStringDeduplication # 字符串去重
网络参数:
net.core.rmem_max = 16777216 # 接收缓冲区最大值
net.core.wmem_max = 16777216 # 发送缓冲区最大值
业务优化:
- 避免在用户线程中做阻塞任务(数据库查询、HTTP 请求)
- 使用批量操作代替单条操作
- 缓存热点数据,减少重复计算
- 避免频繁创建临时对象(使用 StringBuilder 代替字符串拼接)
附录
A. 学习路线图
第一阶段:入门(1-2 周)
├── 理解游戏服务器 vs Web 服务器的区别
├── 理解 ioGame 三层架构
├── 成功运行 Hello World
├── 编写第一个 Action(登录/查询)
└── 掌握路由系统的使用
第二阶段:进阶(2-4 周)
├── 理解线程模型(为什么不需要加锁)
├── 掌握会话管理(UserSession)
├── 学会广播和推送
├── 实践跨逻辑服调用
└── 完成一个完整的小项目(如简单棋牌)
第三阶段:高级(持续)
├── 深入理解 Disruptor 原理
├── 性能调优和压测
├── 分布式部署实践
├── 自定义扩展(协议、插件)
└── 阅读框架源码
B. 核心概念速查表
| 概念 | 含义 | 类比 |
|---|---|---|
| 对外服 (ExternalServer) | 连接层,管客户端连接 | 餐厅迎宾员 |
| Broker | 网关层,管路由转发 | 餐厅传菜员 |
| 逻辑服 (GameLogicServer) | 业务层,管业务逻辑 | 餐厅厨师 |
| Action | 业务方法,写普通 Java 方法 | 一道菜的菜谱 |
| cmd/subCmd | 路由地址,找到 Action | 快递地址 |
| cmdMerge | cmd 和 subCmd 的合并值 | 邮编 |
| BarSkeleton | 业务框架骨架 | 厨房的工作台 |
| FlowContext | 请求上下文 | 当前这桌客人的订单 |
| UserSession | 玩家会话 | 客人的座位 |
| EventBus | 事件总线 | 餐厅广播 |
| Disruptor | 高性能队列 | 高效传送带 |
C. 关键设计原则总结
ioGame 通过以下设计降低开发复杂度:
- 分层架构:对外服、Broker、逻辑服各司其职,互不影响
- Action 模型:普通 Java 方法即服务,零侵入
- 位置透明:同进程/分布式部署,业务代码不变
- 线程亲和:userId 绑定线程,天然无锁
- 协议抽象:TCP/WS/UDP、Protobuf/JSON 随意切换
- 内置服务发现:无需 ZooKeeper 等外部注册中心
- 零反射运行时:启动期构建,运行期直接调用
写在最后:ioGame 的设计哲学是"让游戏业务开发回归简单"。它把网络通信、并发控制、分布式协调等底层复杂性封装起来,让你专注于游戏玩法本身的实现。希望本教程能帮助你真正理解 ioGame 的设计思想,而不仅仅是学会使用 API。
记住:框架帮你解决了"怎么做"的问题,但"做什么"(游戏设计)仍然需要你自己思考。