Netty 游戏服务器
游戏服务器架构演进
游戏服务器架构随着产品规模和玩法复杂度不断演进,不同阶段有各自的适用场景和取舍。
单服架构
早期网页游戏和小型手游采用单服架构:所有玩家连接到同一台物理或虚拟服务器,进程内完成全部逻辑处理。
Client ─→ Server (登录 + 场景 + 战斗 + 背包 + 社交)优势: 开发简单、部署成本低、数据完全一致无需跨服同步。
劣势: 单点瓶颈,在线人数受限于单机资源(通常数千人);宕机则全服不可用;难以热更新;服务器位于同一物理区域时远端延迟高。
适用场景:原型验证、小型独立游戏、DAU 低于 5 万的休闲游戏。
分区分服
分区分服将多个独立服务器部署为不同的"区"或"服",玩家在登录时选择一个区,各区之间数据不互通。
区 1: Client ─→ Server1 (独立世界)
区 2: Client ─→ Server2 (独立世界)
区 N: Client ─→ ServerN (独立世界)优势: 水平扩展容易——在线人数增长只需开新区;每个区的负载可控;故障仅影响单个区。
劣势: 玩家无法跨区交友/交易/战斗,形成"鬼区"——新区火爆而老区冷清;合区操作复杂且影响玩家体验。
适用场景:传统 MMORPG(征途、传奇类)、SLG 游戏、滚服运营模式。
主从架构
主从架构将服务器的不同职责拆分为独立的进程或服务节点,通过网络通信协作。
┌──────────────┐
│ Gateway │ 网关层:连接管理、协议编解码
│ (多个实例) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌────▼───┐ ┌─────▼────┐
│ Scene │ │ Logic │ │ Chat │ 逻辑层
│ (多个实例)│ │(多个实例)│ │(单例/集群)│
└─────┬────┘ └────┬───┘ └─────┬────┘
│ │ │
└────────────┼────────────┘
│
┌──────▼──────┐
│ DB │ 存储层
│ Redis/MySQL │
└─────────────┘各层职责:
- 网关层(Gateway): 维护客户端长连接,负责协议编解码、粘包拆包、心跳检测、流量控制。将解析后的消息根据路由规则转发给对应的后端服务。网关本身无业务逻辑,可水平扩展。
- 场景管理(Scene): 管理游戏世界的空间划分,处理玩家移动、AOI(Area of Interest)、技能释放等实时性要求高的操作。场景服务器之间通常不直接通信,通过网关路由。
- 逻辑层(Logic): 处理非实时性业务:玩家属性、背包、任务、社交、活动等。对实时性要求较低,可批量处理。
- 数据存储层(DB): Redis 承担缓存和热数据读写,MySQL/分库分表承担持久化存储。
优势: 各层独立扩展、故障隔离、可针对每层特性做优化(如场景服务器用高性能服务器、逻辑层用有状态服务)。
劣势: 网络开销增加、跨进程调用延迟、部署运维复杂度提升。
适用场景:中型 MMORPG、MOBA、吃鸡类游戏。
无缝世界
无缝世界让所有玩家处于同一个大世界中,通过空间分割(AOI 区域管理)实现超大规模在线。
┌────┬────┬────┬────┐
│ S1 │ S2 │ S3 │ S4 │ Server 实例
├────┼────┼────┼────┤
│ S5 │ S6 │ S7 │ S8 │ 每个 Server 负责
├────┼────┼────┼────┤ 一个区域
│ S9 │S10 │S11 │S12 │
└────┴────┴────┴────┘
玩家跨区域时 → 跨服迁移(Player Migration)
区域间 Gate 路由转发AOI(Area of Interest,感兴趣区域): 每个玩家只关心其周围一定范围内的其他实体(玩家、NPC、怪物),服务器只向相关玩家广播事件。这是无缝世界的核心算法。
格子算法(Grid): 将世界划分为固定大小的格子(如 10m x 10m),玩家所属格子及其相邻 8 格内的实体为 AOI 范围。实现简单,遍历快,适合静态地形。
十字链表算法(Cross Linked List): 用 X 轴和 Y 轴的有序链表管理所有实体位置,AOI 查询通过链表遍历。适合高动态场景,插入/删除 O(n),但内存占用低于格子算法。
跨服迁移(Cross-Server Migration): 玩家从一个场景服务器跨越到另一个时,需要序列化状态、传输到目标服务器、在目标服务器重建并更新网关路由。
优势: 超大世界、统一经济系统、更好的社交体验。
劣势: 架构复杂、跨服迁移易出 Bug 且延迟敏感、运维成本高。
适用场景:MMORPG(魔兽世界、梦幻西游)、开放世界手游。
游戏服务器通用架构
下图展示了一个较通用的游戏服务器分层架构,各层可以根据游戏类型裁剪。
┌─────────────────────────────────────────────────────────────┐
│ 客户端(Unity/UE/手游) │
└──────────────────────────┬──────────────────────────────────┘
│ TCP / WebSocket / KCP
┌──────────────────────────▼──────────────────────────────────┐
│ 网关层(Gateway) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │连接管理 │ │协议编解码 │ │粘包拆包 │ │ 心跳检测 │ │
│ └───────────┘ └──────────┘ └──────────┘ └───────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ 内部 RPC / Protobuf
┌──────────────────────────▼──────────────────────────────────┐
│ 场景管理(Scene) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 场景划分 │ │ AOI │ │ 移动同步 │ │ 技能/碰撞 │ │
│ └───────────┘ └──────────┘ └──────────┘ └───────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 逻辑层(Logic) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 玩家/角色 │ │ 背包 │ │ 技能/Buff│ │ 任务/活动 │ │
│ │ 社交/组队 │ │ 商城 │ │ 成就 │ │ 排行榜 │ │
│ └───────────┘ └──────────┘ └──────────┘ └───────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 匹配服务(Match) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ 排队管理 │ │ ELO 算法 │ │ 动态扩圈 │ │ 确认超时 │ │
│ └───────────┘ └──────────┘ └──────────┘ └───────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 数据存储层 │
│ ┌────────────────────┐ ┌──────────────────────────────────┐│
│ │ Redis(缓存) │ │ MySQL(持久化 + 分库分表) ││
│ └────────────────────┘ └──────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘网关层
网关是客户端连接的第一站,负责以下关键职责:
连接管理: 维护海量 TCP/WebSocket 长连接,每个连接对应一个 Channel 上下文。支持连接鉴权、踢下线、连接数限制。
协议编解码: 将二进制流转换为业务消息对象,以及将响应编码回二进制流。通常使用 Protobuf、MessagePack 等高效序列化协议。
粘包拆包: TCP 流式传输天然存在粘包和拆包问题,Netty 的 LengthFieldBasedFrameDecoder 可以很好地解决。
心跳检测: 定期检测客户端是否存活,超时断开连接释放资源。Netty 的 IdleStateHandler 提供开箱即用的支持。
// 心跳检测 Handler 示例
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
private static final int MAX_MISSED_HEARTBEATS = 3;
private int missedHeartbeats = 0;
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
missedHeartbeats++;
if (missedHeartbeats >= MAX_MISSED_HEARTBEATS) {
// 超过阈值,断开连接
ctx.channel().close();
}
}
}
}
}场景管理
场景划分: 将游戏世界划分为多个独立的场景(副本、地图),每个场景由一个 SceneServer 实例管理。玩家在场景中产生的操作仅在该场景内广播。
区域 AOI: 以下是基于格子算法的 AOI 管理实现示例。
public class GridAOI {
private final int gridSize; // 每个格子的边长(单位:游戏坐标单位)
private final int gridCountX;
private final int gridCountY;
private final Map<Long, Set<Long>> gridEntities; // gridId -> entityIds
public GridAOI(int worldWidth, int worldHeight, int gridSize) {
this.gridSize = gridSize;
this.gridCountX = worldWidth / gridSize;
this.gridCountY = worldHeight / gridSize;
this.gridEntities = new ConcurrentHashMap<>();
}
private long getGridId(int gridX, int gridY) {
return (long) gridX * gridCountY + gridY;
}
/** 获取实体所在的格子 ID */
public long getGridId(int x, int y) {
int gx = x / gridSize;
int gy = y / gridSize;
return getGridId(gx, gy);
}
/** 获取某个位置周围 9 格(含自身)的所有实体 ID */
public Set<Long> getAOIEntities(int x, int y) {
int gx = x / gridSize;
int gy = y / gridSize;
Set<Long> result = new HashSet<>();
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
int nx = gx + dx;
int ny = gy + dy;
if (nx >= 0 && nx < gridCountX && ny >= 0 && ny < gridCountY) {
long gridId = getGridId(nx, ny);
Set<Long> entities = gridEntities.get(gridId);
if (entities != null) {
result.addAll(entities);
}
}
}
}
return result;
}
/** 实体进入场景 */
public void enter(long entityId, int x, int y) {
long gridId = getGridId(x, y);
gridEntities.computeIfAbsent(gridId, k -> ConcurrentHashMap.newKeySet()).add(entityId);
}
/** 实体移动 —— 可能跨格子 */
public void move(long entityId, int oldX, int oldY, int newX, int newY) {
long oldGrid = getGridId(oldX, oldY);
long newGrid = getGridId(newX, newY);
if (oldGrid != newGrid) {
remove(entityId, oldX, oldY);
enter(entityId, newX, newY);
}
}
/** 实体离开场景 */
public void remove(long entityId, int x, int y) {
long gridId = getGridId(x, y);
Set<Long> entities = gridEntities.get(gridId);
if (entities != null) {
entities.remove(entityId);
if (entities.isEmpty()) {
gridEntities.remove(gridId);
}
}
}
}十字链表在处理大量高频移动实体时性能更稳定,格子算法在静态实体(资源点、NPC)多的场景更优。选择依据:同屏实体数超过 500 考虑十字链表,否则格子算法简单可靠。
逻辑层
逻辑层处理游戏中不需要实时同步的业务,通常以 RPC 方式被网关或场景服务调用。
| 模块 | 核心职责 | 数据实体 |
|---|---|---|
| 玩家/角色 | 属性管理、等级、经验、VIP | Player |
| 背包 | 道具增删、堆叠、使用 | Inventory, Item |
| 技能 | 技能树、升级、配置 | Skill |
| Buff | 状态效果、倒计时、叠加/驱散 | Buff |
| 任务 | 进度追踪、条件判定、奖励发放 | Quest, QuestProgress |
| 活动 | 定时开启、排行榜、奖励结算 | Activity |
逻辑层应设计为无状态或状态可恢复,便于扩展和容灾。
数据存储层
Redis 缓存: 存储玩家在线数据、排行榜、Session、分布式锁等热数据。数据类型丰富(String/Hash/ZSet/Stream),读写性能高。
MySQL 持久化: 存储玩家最终数据,按玩家 ID 分库分表。分表策略通常是 player_id % shard_count。
-- 分表示例:玩家背包表
CREATE TABLE `inventory_0` (
`player_id` BIGINT NOT NULL,
`item_id` INT NOT NULL,
`count` INT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL,
PRIMARY KEY (`player_id`, `item_id`)
);
-- ... inventory_1 ~ inventory_15 共 16 张表缓存与数据库一致性策略: 缓存旁路(Cache-Aside)模式——读时回源、写时更新缓存并异步写 DB;或采用先写 DB 再删除缓存的方式。
Netty 游戏服务器实践
Netty 是 Java 游戏服务器的首选网络框架,其高性能的 Reactor 线程模型和丰富的编解码工具链非常适合游戏场景。
Netty 线程模型
┌───────────────────────────────────────────────────┐
│ BossGroup │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │EL-1 │ │EL-2 │ │EL-N │ EventLoop │
│ └──┬──┘ └──┬──┘ └──┬──┘ 线程数默认 = │
│ │ │ │ 核心数 * 2 │
│ Accept Accept Accept │
└──────────────┼───────┼───────┼───────────────────┘
│ │ │
┌─────┴───────┴───────┴────────────────┐
│ WorkerGroup │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │EL-1 │ │EL-2 │ │EL-3 │ │EL-N │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │
│ Read/Write Read/Write ... Read/Write │
└──────────────┴───────┴───────┴───────────────┘- BossGroup: 负责 Accept 新的 TCP 连接,将 Channel 注册到 WorkerGroup。通常只需要 1 个线程。
- WorkerGroup: 负责 Channel 的 I/O 读写。每个 EventLoop 绑定多个 Channel,一个 Channel 的生命周期始终绑定同一个 EventLoop(线程安全)。
- EventLoop: 每个 EventLoop 持有一个 Selector 和一个 TaskQueue,处理 I/O 事件和定时任务。
最佳实践: 不要在 I/O 线程(EventLoop)中执行耗时业务逻辑,应通过自定义线程池异步处理,避免阻塞事件循环。
// Netty 服务器启动示例
public class GameServer {
private final int port;
public GameServer(int port) {
this.port = port;
}
public void start() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
// 开启 TCP 保活和 NO_DELAY 减少延迟
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new GameServerInitializer());
ChannelFuture future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
new GameServer(8080).start();
}
}协议设计
游戏协议采用协议号 + 消息体的结构:
┌────────────┬──────────────┬─────────────────────┐
│ PacketLen │ ProtocolID │ Body (Protobuf) │
│ (4 字节) │ (2 字节) │ (变长) │
└────────────┴──────────────┴─────────────────────┘- PacketLen: 整个包的长度,用于 TCP 粘包拆包。
- ProtocolID: 协议号,标识消息类型。高字节表示模块(0x01=登录、0x02=场景、0x03=战斗),低字节表示具体操作。
- Body: Protobuf 序列化的消息体。对于较大消息(如场景快照),可使用 LZ4 压缩后再放入 Body,并通过 ProtocolID 的一个标志位标识是否压缩。
// cmd_login.proto
syntax = "proto3";
package game;
message LoginRequest {
string account = 1;
string token = 2;
int32 serverId = 3;
}
message LoginResponse {
int32 code = 1;
int64 playerId = 2;
string sessionKey = 3;
PlayerInfo player = 4;
}
message PlayerInfo {
int64 playerId = 1;
string name = 2;
int32 level = 3;
int32 exp = 4;
int32 vipLevel = 5;
}
// cmd_scene.proto
message EnterSceneRequest {
int32 sceneId = 1;
}
message EnterSceneResponse {
int32 sceneId = 1;
repeated Entity entities = 2;
int32 tick = 3;
}
message Entity {
int64 entityId = 1;
int32 entityType = 2; // 1=玩家 2=怪物 3=NPC
int32 x = 3;
int32 y = 4;
int32 hp = 5;
int32 maxHp = 6;
string name = 7;
}
message MoveRequest {
int32 seq = 1; // 客户端序列号,用于去重和排序
int32 targetX = 2;
int32 targetY = 3;
int32 timestamp = 4; // 客户端时间戳
}
message MoveBroadcast {
int64 entityId = 1;
int32 targetX = 2;
int32 targetY = 3;
int32 speed = 4;
}编解码器
Netty 提供 LengthFieldBasedFrameDecoder 解决 TCP 粘包拆包,配合 ProtobufVarint32FrameDecoder 或自定义解码器解析协议。
public class GameServerInitializer extends ChannelInitializer<SocketChannel> {
private static final int MAX_FRAME_LENGTH = 65536; // 最大包长
private static final int LENGTH_FIELD_OFFSET = 0; // 长度字段起始偏移
private static final int LENGTH_FIELD_LENGTH = 4; // 长度字段占 4 字节
private static final int LENGTH_ADJUSTMENT = 0; // 长度修正
private static final int INITIAL_BYTES_TO_STRIP = 4; // 跳过长度字段
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 1. TCP 粘包拆包
pipeline.addLast("frameDecoder",
new LengthFieldBasedFrameDecoder(MAX_FRAME_LENGTH,
LENGTH_FIELD_OFFSET, LENGTH_FIELD_LENGTH,
LENGTH_ADJUSTMENT, INITIAL_BYTES_TO_STRIP));
// 2. 协议解码:读取协议号 + Protobuf 消息体
pipeline.addLast("protocolDecoder", new ProtocolDecoder());
// 3. 协议编码:写入协议号 + Protobuf 消息体
pipeline.addLast("protocolEncoder", new ProtocolEncoder());
// 4. 心跳检测:10s 读空闲判定超时
pipeline.addLast("idleHandler", new IdleStateHandler(10, 0, 0));
// 5. 心跳处理
pipeline.addLast("heartbeatHandler", new HeartbeatHandler());
// 6. 业务路由 —— 分发到业务线程池
pipeline.addLast("dispatcher", new MessageDispatcher());
}
}/**
* 协议解码器:读取协议号,然后根据协议号解析对应的 Protobuf 消息体
*/
public class ProtocolDecoder extends ByteToMessageDecoder {
private static final Logger log = LoggerFactory.getLogger(ProtocolDecoder.class);
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 确保至少可读协议号(2 字节)
if (in.readableBytes() < 2) {
return;
}
in.markReaderIndex();
short protocolId = in.readShort();
// 根据协议号查找对应的 Protobuf 解析器
MessageLite prototype = ProtocolRegistry.getPrototype(protocolId);
if (prototype == null) {
log.warn("unknown protocol id: {}", protocolId);
in.clear();
return;
}
// 剩余字节作为 Protobuf 消息体
byte[] body = new byte[in.readableBytes()];
in.readBytes(body);
try {
Message msg = prototype.getParserForType().parseFrom(body);
out.add(new GameMessage(protocolId, msg));
} catch (InvalidProtocolBufferException e) {
log.error("protobuf decode error, protocolId={}", protocolId, e);
// 解码失败直接丢弃当前包
}
}
}/**
* 协议编码器
*/
public class ProtocolEncoder extends MessageToByteEncoder<GameMessage> {
@Override
protected void encode(ChannelHandlerContext ctx, GameMessage msg, ByteBuf out) {
// 写入协议号
out.writeShort(msg.getProtocolId());
// 写入 Protobuf 消息体
byte[] body = msg.getBody().toByteArray();
out.writeBytes(body);
// 注意:写出的数据会经过 LengthFieldBasedFrameEncoder(若需)或在其上层封装长度
}
}ChannelHandler 链设计
Handler 链的执行顺序直接影响性能:
入站顺序(读取数据):
LengthFieldBasedFrameDecoder → ProtocolDecoder → IdleStateHandler
→ HeartbeatHandler → MessageDispatcher
出站顺序(写入数据):
ProtocolEncoder → LengthFieldBasedFrameEncoder(可选)→ TCP 发送MessageDispatcher 负责将业务消息路由到业务线程池执行,避免阻塞 EventLoop:
public class MessageDispatcher extends ChannelInboundHandlerAdapter {
private static final ExecutorService BUSINESS_POOL =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2);
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (msg instanceof GameMessage) {
GameMessage gameMsg = (GameMessage) msg;
// 提交到业务线程池异步执行
BUSINESS_POOL.submit(() -> handleMessage(ctx, gameMsg));
} else {
ctx.fireChannelRead(msg);
}
}
private void handleMessage(ChannelHandlerContext ctx, GameMessage msg) {
short protocolId = msg.getProtocolId();
IHandler handler = HandlerRegistry.getHandler(protocolId);
if (handler != null) {
try {
handler.handle(ctx, msg.getBody());
} catch (Exception e) {
// 异常处理:记录日志、发送错误码给客户端
}
}
}
}网络同步
网络同步是游戏服务器最核心、最复杂的设计之一。同步方案的选择直接影响游戏类型、开发成本和反外挂能力。
状态同步
状态同步的工作流程:客户端发送操作请求 → 服务器校验并执行 → 服务器将结果广播给所有相关客户端。
Client A: 移动操作 ─────→ Server
│
├─→ 校验合法性(速度/位置/CD)
├─→ 更新服务器状态
└─→ 广播给 Client A、Client B、Client C优势:
- 实现相对简单,服务器是权威状态源。
- 带宽较低,只需同步状态变更而非原始操作指令。
- 防作弊好——客户端无法篡改自己或他人的状态。
劣势:
- 服务器计算压力大,尤其在有大量实体需要广播的场景(如 MMORPG 的百人同屏)。
- 响应延迟:客户端必须等待服务器返回才能看到效果,需要使用预测和插值来改善体验。
- 广播量随同屏人数线性增长,需要 AOI 裁剪。
适用场景: MMORPG、ARPG、卡牌、回合制游戏。
帧同步
帧同步的工作流程:服务器收集所有客户端的操作指令 → 按帧打包 → 广播给所有客户端 → 每个客户端独立执行逻辑。
帧 N: Client A: 指令集 A
Client B: 指令集 B ──→ Server
Client C: 指令集 C │
├─→ 打包帧数据 (Frame N)
└─→ 广播 (A, B, C 收到相同帧)
│
├─→ Client A 执行帧 N
├─→ Client B 执行帧 N
└─→ Client C 执行帧 N
所有客户端结果一致!优势:
- 服务器压力小——只做指令收集和转发,不执行游戏逻辑。
- 带宽与同屏实体数无关,只与指令数有关。
- 客户端可以回放帧数据用于战斗回放、调试。
劣势:
- 反外挂难度大——客户端可作弊(如修改内存产生不一致的执行结果)。
- 要求所有客户端帧率一致,网络波动可能导致卡顿。
- 逻辑必须确定性(Deterministic),浮点数精度、随机数种子、遍历顺序都不能有偏差。
- 断线重连困难——需要向客户端补发大量帧数据。
适用场景: RTS(星际争霸)、格斗游戏、休闲竞技(球球大作战等)。
预测与插值
客户端预测(Dead Reckoning,DR): 为隐藏网络延迟,客户端在收到服务器响应前预测其他实体的状态。
基本原理:服务器周期性广播实体状态(位置、速度、方向),客户端根据上次已知状态和运动模型推算中间状态。
// 航位推算(Dead Reckoning)模型
public class DeadReckoning {
private EntityState lastState;
private long lastUpdateTime;
public EntityState predict(long now) {
if (lastState == null) return null;
long elapsed = now - lastUpdateTime; // 毫秒
float dt = elapsed / 1000f; // 转为秒
float dx = lastState.vx * dt;
float dy = lastState.vy * dt;
EntityState predicted = new EntityState();
predicted.x = lastState.x + dx;
predicted.y = lastState.y + dy;
predicted.vx = lastState.vx;
predicted.vy = lastState.vy;
return predicted;
}
public void onServerUpdate(EntityState serverState) {
this.lastState = serverState;
this.lastUpdateTime = System.currentTimeMillis();
}
}服务器回滚(Server Reconciliation): 当服务器状态到达时,客户端可能已经基于预测状态产生了更多操作。回滚策略:
- 保存最近 N 帧的客户端操作历史。
- 用服务器状态替换当前状态。
- 在服务器状态基础上重新应用客户端未确认的操作。
时间轴:
T1: 客户端发送移动指令 M1
T2: 客户端预测执行 M1,发送 M2
T3: 收到服务器 Acknowledge M1 的权威状态 S1
T4: 检查 S1 与预测差异,如有差异则回滚到 S1 并重放 M2
T5: 插值平滑过渡对比选型
| 维度 | 状态同步 | 帧同步 |
|---|---|---|
| 服务器负载 | 高(执行全部逻辑) | 低(仅转发指令) |
| 网络带宽 | 与同屏实体数成正比 | 与玩家人数成正比 |
| 防作弊 | 强(服务器权威) | 弱(需要额外检测) |
| 重连恢复 | 容易(以服务器状态为准) | 困难(需补帧/状态快照) |
| 回放支持 | 需记录帧数据 | 天然支持 |
| 开发生效 | 快(单端调试) | 慢(需对齐双方) |
| 典型游戏 | MMORPG、MOBA | RTS、格斗、FPS(混合) |
混合方案: 多数现代竞技游戏采用状态同步为主、局部帧同步为辅的混合方案。例如 MOBA 中单位位置用状态同步,技能/弹道用帧同步以确保表现一致。
房间匹配
匹配系统是将玩家组合成对局的核心服务,直接影响玩家体验和留存。
匹配算法
ELO 分匹配: 基于棋类运动的 ELO 等级分系统,玩家胜负后分数加减,匹配时尽量找分数相近的对手。简单常用,但收敛速度慢。
段位分匹配: 将玩家划分为青铜/白银/黄金/铂金/钻石/王者等段位,段位内匹配。配合星数系统,体验更平滑。
等待时间加权: 玩家排队时间越长,匹配条件(实力差距、网络延迟差距)越宽松,避免"永远排不到人"。
算法组合示例:
public class MatchScoreCalculator {
/** 计算两个玩家的匹配得分,得分越高越适合匹配在一起 */
public static double calculateScore(Player a, Player b, long queueTimeMs) {
double score = 0;
// 1. ELO 或段位分差(核心条件)
int ratingDiff = Math.abs(a.getRating() - b.getRating());
double ratingWeight = Math.max(0, 100 - ratingDiff);
score += ratingWeight * 10;
// 2. 延迟相近度
int pingDiff = Math.abs(a.getPing() - b.getPing());
if (pingDiff < 50) {
score += 20;
}
// 3. 等待时间加权——排队越久,匹配条件越宽松
long waitSeconds = queueTimeMs / 1000;
double timeBonus = Math.min(waitSeconds * 2, 50); // 最多加 50 分
score += timeBonus;
// 4. 段位层次差异较大的扣分
if (Math.abs(a.getTierOrdinal() - b.getTierOrdinal()) >= 2) {
score -= 100; // 跳过两个大段位,强烈不推荐
}
return score;
}
}匹配池设计
匹配池是匹配系统的核心数据结构,负责管理所有排队玩家。
┌─────────────────────────────────────────┐
│ 匹配池 │
│ ┌───────────┐ ┌───────────┐ │
│ │ 青铜池 │ │ 白银池 │ ... │
│ │ ┌───────┐ │ │ ┌───────┐ │ │
│ │ │PlayerA│ │ │ │PlayerC│ │ │
│ │ │PlayerB│ │ │ │PlayerD│ │ │
│ │ └───────┘ │ │ └───────┘ │ │
│ └───────────┘ └───────────┘ │
│ │
│ 等待超时 → 扩展条件(跨段位匹配) │
│ 实力区间动态扩大(每 5s 扩一次) │
└─────────────────────────────────────────┘public class MatchPool {
private final ConcurrentMap<Integer, ConcurrentLinkedQueue<MatchPlayer>> pools = new ConcurrentHashMap<>();
/** 玩家进入排队 */
public void enqueue(MatchPlayer player) {
int tier = player.getTierOrdinal();
pools.computeIfAbsent(tier, k -> new ConcurrentLinkedQueue<>()).offer(player);
}
/** 尝试匹配 —— 周期性调用(如每 2 秒) */
public List<MatchGroup> tryMatch() {
List<MatchGroup> results = new ArrayList<>();
for (Map.Entry<Integer, ConcurrentLinkedQueue<MatchPlayer>> entry : pools.entrySet()) {
ConcurrentLinkedQueue<MatchPlayer> queue = entry.getValue();
if (queue.size() < 2) continue;
List<MatchPlayer> candidates = new ArrayList<>();
queue.drainTo(candidates);
// 按等待时间排序,等待越久的越优先匹配
candidates.sort(Comparator.comparingLong(MatchPlayer::getEnqueueTime));
// 等待时间扩展:队列中等待超过 N 秒的玩家
// 允许与相邻段位的玩家匹配
long now = System.currentTimeMillis();
List<MatchPlayer> longWait = candidates.stream()
.filter(p -> now - p.getEnqueueTime() > 15000) // 15秒
.collect(Collectors.toList());
// 匹配逻辑:滑动窗口组合
List<MatchGroup> groups = greedyMatch(candidates);
results.addAll(groups);
// 未匹配成功的玩家重新入队
// ...
}
return results;
}
private List<MatchGroup> greedyMatch(List<MatchPlayer> players) {
// 贪心匹配:按实力排序,相邻玩家组合
List<MatchGroup> groups = new ArrayList<>();
players.sort(Comparator.comparingInt(MatchPlayer::getRating));
int i = 0;
while (i + 1 < players.size()) {
MatchPlayer a = players.get(i);
MatchPlayer b = players.get(i + 1);
// 若实力差距可接受
if (Math.abs(a.getRating() - b.getRating()) <= 200) {
groups.add(new MatchGroup(a, b));
i += 2;
} else {
i++;
}
}
return groups;
}
}匹配结果确认
匹配成功后需要双方(或多方)确认,避免玩家挂机不进入游戏。
确认流程:
匹配成功 ─→ 向所有匹配玩家发送 MatchConfirm 请求
│
├─→ 玩家点击确认 → 服务器记录确认状态
├─→ 全部确认 → 创建房间/加载场景
└─→ 超时未确认 → 取消匹配,重新排队public class MatchConfirmManager {
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final Map<String, MatchConfirmation> pending = new ConcurrentHashMap<>();
/** 发起确认,超时时间 30 秒 */
public void startConfirmation(MatchGroup group) {
String groupId = group.getGroupId();
MatchConfirmation confirmation = new MatchConfirmation(group);
pending.put(groupId, confirmation);
// 给所有玩家发送确认请求
for (MatchPlayer player : group.getPlayers()) {
sendConfirmRequest(player.getChannel(), groupId);
}
// 30 秒后超时检查
scheduler.schedule(() -> {
MatchConfirmation cf = pending.get(groupId);
if (cf != null && !cf.isAllConfirmed()) {
// 超时未确认,取消匹配
cancelMatch(groupId);
}
}, 30, TimeUnit.SECONDS);
}
public void onPlayerConfirm(String groupId, long playerId) {
MatchConfirmation cf = pending.get(groupId);
if (cf == null) return;
cf.confirm(playerId);
if (cf.isAllConfirmed()) {
// 全部确认,启动游戏
pending.remove(groupId);
createGameRoom(cf.getGroup());
}
}
}防外挂
游戏防外挂需要在多个层面构建防线。没有任何方案能 100% 防止外挂,但多层防御可以提高作弊成本。
客户端数据校验
服务器永远不应该信任客户端发送的任何数值。以下数据必须在服务器端重新校验:
public class MoveValidator {
private static final int MAX_SPEED = 10; // 每 tick 最大移动格数
public boolean validateMove(Player player, MoveRequest request) {
// 1. 校验移动速度是否超限
int dx = Math.abs(request.getTargetX() - player.getX());
int dy = Math.abs(request.getTargetY() - player.getY());
double distance = Math.sqrt(dx * dx + dy * dy);
if (distance > MAX_SPEED) {
// 速度异常,可能使用了加速外挂
return false;
}
// 2. 校验移动 CD
long now = System.currentTimeMillis();
if (now - player.getLastMoveTime() < 50) { // 最低 50ms
// 移动频率异常
return false;
}
// 3. 校验路径可达性(没有穿墙)
if (!isPathWalkable(player.getX(), player.getY(),
request.getTargetX(), request.getTargetY())) {
return false;
}
return true;
}
private boolean isPathWalkable(int x1, int y1, int x2, int y2) {
// 使用 Bresenham 直线算法检测路径上所有格子
// 若有不可通行的障碍物(墙壁、水域等),返回 false
// ... 碰撞检测逻辑
return true;
}
}其他需要校验的典型场景:
| 数据 | 校验方式 |
|---|---|
| 伤害数值 | 根据攻击方攻击力、防御方防御力、技能系数重新计算 |
| 技能冷却 | 服务器记录每个技能的冷却结束时间戳 |
| 道具使用 | 校验背包中是否存在该道具及数量 |
| 金币增减 | 记录流水日志,与操作前余额做差校验 |
| BUFF 效果 | 服务器维护 BUFF 列表,客户端申请效果需从服务器获取 |
操作一致性检查
服务器定期对客户端的状态进行"拉取比对"或"快照校验"。
/**
* 定期对比客户端与服务器的状态快照
* 如果偏差超过阈值,判定异常
*/
public class StateConsistencyCheck {
public void check(Player player) {
// 服务器权威状态
int serverX = player.getX();
int serverY = player.getY();
int serverHp = player.getHp();
// 请求客户端报告状态(可选包含周围实体快照)
sendStateRequest(player.getChannel());
// 对比客户端上报的状态
// 如果位置偏差 > 5 格 或 HP 偏差 > 0,记录异常
// ...
}
}行为分析
行为分析是一种被动检测手段,通过统计和分析玩家行为模式识别异常。
异常频率检测:
public class FrequencyAnalyzer {
private final ConcurrentMap<Long, SlidingWindow> actionWindows = new ConcurrentHashMap<>();
public void recordAction(long playerId, String actionType) {
SlidingWindow window = actionWindows.computeIfAbsent(
playerId, k -> new SlidingWindow(60000)); // 60s 窗口
window.add(actionType);
// 检查频次是否异常
int countIn60s = window.getCount(actionType);
int threshold = getThreshold(actionType);
if (countIn60s > threshold * 2) {
// 异常高频操作,标记可疑
markSuspicious(playerId, actionType, countIn60s);
}
}
private int getThreshold(String actionType) {
// 从配置中心获取每种操作的类型正常阈值
switch (actionType) {
case "attack": return 60; // 60 次/分钟
case "move": return 300; // 300 次/分钟
case "chat": return 20; // 20 次/分钟
default: return 100;
}
}
}异常路径检测: 玩家移动轨迹不符合正常寻路规律(如直线穿墙、瞬移、Z 字抖动),可以通过卡尔曼滤波或路径平滑度评估来检测。
IP 聚集检测: 多个账号从同一 IP 短时间内登录并使用相同操作模式,判定为工作室或脚本外挂。
public class IPClusterDetector {
private final Cache<String, Set<Long>> ipPlayers = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
public void onPlayerLogin(long playerId, String ip) {
Set<Long> players = ipPlayers.get(ip, k -> ConcurrentHashMap.newKeySet());
players.add(playerId);
if (players.size() >= 5) {
// 同一 IP 超过 5 个活跃账号,触发风控
triggerRiskAlert(ip, players);
}
}
}反外挂总结:
客户端校验 → 操作一致性 → 行为分析 → 人工封禁
↓ ↓ ↓ ↓
防火墙 校验层 分析层 处罚层
(实时) (周期性) (异步) (事后)反外挂的核心原则:服务器是唯一权威,所有客户端输入的数值都必须经过校验才能执行。行为分析作为兜底手段来发现绕过校验的复杂外挂。应建立分级处罚机制:警告、临时封禁、永久封禁,并支持申诉流程。