ioGame 完整教程:从入门到精通

本教程专为初级程序员设计,从基础概念出发,逐步深入技术原理,配合实战示例,帮助你真正理解并掌握 ioGame 游戏服务器框架。
📚
零基础入门
从游戏服务器基础概念到第一个可运行的 Hello World 程序
⚙️
核心架构
三层架构、Action 模型、路由系统与数据协议深度解析
🔬
技术原理
无锁并发、O(1) 路由、零拷贝序列化、分布式通信机制
🚀
实战部署
登录与房间系统实战、三种部署模式、性能优化

ioGame 完整教程:从入门到精通

本教程专为初级程序员设计,从基础概念出发,逐步深入技术原理,配合实战示例,帮助你真正理解并掌握 ioGame 游戏服务器框架。

学习本教程不需要你有游戏服务器开发经验,只需要具备基本的 Java 编程基础即可。


目录


第一篇:零基础入门

第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 把游戏服务器拆成三层:

客户端 (Client) 游戏对外服 ExternalServer 长连接 · 协议适配 · 心跳检测 Broker 游戏网关 路由转发 · 负载均衡 · 服务发现 游戏逻辑服 GameLogicServer 业务逻辑 · 状态管理 · Action 执行
客户端(游戏 App / H5 / 小程序)
        ↓  WebSocket / TCP / UDP
┌─────────────────────────────┐
│      游戏对外服                │  ← 迎宾员:只负责接客(连接管理)
│  (ExternalServer)           │
└─────────────┬───────────────┘
              ↓
┌─────────────────────────────┐
│      Broker 游戏网关           │  ← 传菜员:只负责传话(路由转发)
│  (BrokerServer)             │
└─────────────┬───────────────┘
              ↓
┌─────────────────────────────┐
│      游戏逻辑服                │  ← 厨师:只负责做菜(业务逻辑)
│  (GameLogicServer)          │
└─────────────────────────────┘

游戏对外服(ExternalServer):
- 监听端口,接受客户端连接
- 维护长连接(心跳检测、断线重连)
- 协议解析(TCP/UDP/WebSocket)
- 把请求转发给 Broker,把响应返回给客户端
- 不执行任何业务逻辑

Broker 游戏网关:
- 服务注册与发现(逻辑服启动时自动注册)
- 请求路由(根据 cmd/subCmd 找到对应的逻辑服)
- 负载均衡(把请求分配给压力最小的逻辑服实例)
- 跨服通信中转
- 不执行任何业务逻辑

游戏逻辑服(GameLogicServer):
- 执行 Action 方法(你的业务代码)
- 维护游戏状态(玩家数据、房间状态等)
- 管理玩家会话
- 不直接处理客户端连接,不监听外部端口

4.3 这种分层的好处

  1. 职责清晰:每层只做一件事,代码好维护
  2. 独立扩容
  3. 连接数太多 → 加对外服
  4. 转发压力大 → 加 Broker
  5. 业务计算慢 → 加逻辑服
  6. 故障隔离:一层崩溃了不影响其他层
  7. 开发简单:你写业务代码时,完全不用关心网络细节

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 开发的一种二进制序列化协议。它的核心优势:

  1. 体积小:比 JSON 小 1/3 到 1/2
  2. 速度快:序列化速度是 JSON 的 5-10 倍
  3. 跨语言:支持 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 从客户端点击到服务器响应

客户端 请求 对外服 转发 Broker 路由 逻辑服 处理 返回响应

让我们追踪一条完整的请求,看看数据在 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 的解决方案:单用户串行 + 多用户并行

业务线程池(单用户串行 + 多用户并行) Thread-1 玩家A、玩家D... 请求1 → 请求2 → 请求3 串行执行,无锁 Thread-2 玩家B、玩家E... 请求1 → 请求2 → 请求3 串行执行,无锁 Thread-3 玩家C、玩家F... 请求1 → 请求2 → 请求3 串行执行,无锁 三个线程并行执行,充分利用多核 CPU

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 快得多)

二维数组路由表的优势:

  1. 无 hash 计算:直接数组索引,CPU 只需做一次乘法加法
  2. 无冲突处理:数组每个位置独立,不存在哈希冲突
  3. CPU 缓存友好:数组在内存中连续存储,预取机制有效
  4. 启动时构建,运行时只读:不需要加锁,多线程安全

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(堆外内存)→ 业务对象
(减少了堆内外拷贝)

具体优化手段:

  1. DirectByteBuf:使用堆外内存,避免 JVM 堆与 Native 堆之间的拷贝
  2. CompositeByteBuf:多个数据片段逻辑组合,不需要物理合并
  3. 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 线程模型优化要点

  1. 不要在用户线程中做阻塞操作:数据库查询、HTTP 请求、文件读写等阻塞操作会阻塞该线程上所有玩家的请求。应该使用虚拟线程或异步线程池。

  2. 合理配置线程数:业务线程数不是越多越好。通常 1-4 个业务线程就能发挥很好性能,因为 Disruptor 是单线程消费模型。

  3. 按业务场景选择绑定键

  4. 玩家个人数据 → 按 userId 绑定
  5. 房间/战斗数据 → 按 roomId / battleId 绑定
  6. 全局数据 → 需要额外的并发控制(DB 乐观锁、Redis 分布式锁)

第18章:三种部署模式

单体开发模式 对外服 + Broker + 逻辑服 同一 JVM 进程 内存直接通信 ✓ 开发调试 ✓ 快速原型 网关分离模式 对外服 ↕ 网络 Broker 逻辑服 ✓ 中小型生产 ✓ 流量隔离 全分布式模式 对外服集群 Broker 集群 逻辑服集群 ✓ 大型生产 ✓ 弹性伸缩

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,但框架报 "路由不存在",排查步骤:

  1. 检查 Action 类上是否有 @ActionController(1)
  2. 检查方法上是否有 @ActionMethod(0)
  3. 检查启动时扫描的包路径是否包含这个类
  4. 查看启动日志,确认路由表是否成功构建

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 通过以下设计降低开发复杂度:

  1. 分层架构:对外服、Broker、逻辑服各司其职,互不影响
  2. Action 模型:普通 Java 方法即服务,零侵入
  3. 位置透明:同进程/分布式部署,业务代码不变
  4. 线程亲和:userId 绑定线程,天然无锁
  5. 协议抽象:TCP/WS/UDP、Protobuf/JSON 随意切换
  6. 内置服务发现:无需 ZooKeeper 等外部注册中心
  7. 零反射运行时:启动期构建,运行期直接调用

写在最后:ioGame 的设计哲学是"让游戏业务开发回归简单"。它把网络通信、并发控制、分布式协调等底层复杂性封装起来,让你专注于游戏玩法本身的实现。希望本教程能帮助你真正理解 ioGame 的设计思想,而不仅仅是学会使用 API。

记住:框架帮你解决了"怎么做"的问题,但"做什么"(游戏设计)仍然需要你自己思考。