该文章只是为了练习场景题的分析框架,可能存在漏洞,欢迎交流。
背景说明
你所在的项目组正在开发一款大型MMO游戏,需要设计一个跨服战场系统:
-
战场支持最多200vs200玩家同屏战斗,涉及多个服务器的玩家参与。
-
战场有动态事件机制:地图中会随机刷新Boss、资源点、可占领要塞。
-
战场持续30分钟,结束后根据积分排名发放奖励。
技术约束:
-
使用 Skynet 作为服务端框架。
-
已有基础架构:玩家数据服务、战斗计算服务、网关服务。
-
需要解决的核心问题:高并发下的状态同步、跨服数据交换、动态负载均衡。
现有简单实现的缺陷:
-
当前将所有玩家塞进一个Skynet服务中,导致单服务CPU飙升至90%以上,帧同步延迟高达500ms。
-
跨服玩家数据交换通过数据库中转,延迟高(>1秒)。
-
战场事件触发逻辑耦合严重,难以维护。
需要解决的问题
- 如何扩展服务,让玩家可以分散到不同的服务开始战斗,降低单点负载
- 如何优化跨服玩家数据交换延迟高的问题
- 重构战场事件触发逻辑,进行解耦,具体问题代码如下
lua
function BattleService:update(dt)
-- ... 其他逻辑 ...
-- 检查Boss刷新
if os.time() > self.boss_refresh_time and not self.boss_alive then
self:SpawnBoss()
end
-- 检查资源点刷新
for _, point in ipairs(self.resource_points) do
if os.time() > point.next_refresh then
self:RefreshResourcePoint(point)
end
end
-- 检查要塞占领
-- ... 更多 if else ...
end
设计思路
提炼需求
设计一个每晚定时开启、支持400人同场、跨服延迟低于200ms、保证战斗和积分强一致的、基于Skynet的高可用MMO战场系统。
模块设计
[玩家客户端]
|
| (TCP/WebSocket)
v
[网关集群] (Gateway Cluster) - 维持连接,转发消息
|
| (内部RPC)
v
[战场管理器] (Battle Manager) - 1. 战场生命周期(创建、开始、结束)
2. 服务发现与负载均衡
3. 跨服集群协调
|
| (分配玩家到分区)
v
[战场分区服务] (Battle Zone Service) x N - 每个服务负责一块地图区域
| - 处理区域内玩家的移动、技能、状态计算
| - 管理区域内的动态事件(如资源点)
|
v
[战场核心服务] (Battle Core Services)
|
|-------- [事件调度服务] (Event Scheduler) - 管理全局事件(如Boss刷新)
|-------- [数据聚合服务] (Data Aggregator) - 实时计算和同步积分榜
|-------- [持久化服务] (Persistence Service) - 落地存储战斗记录、奖励
-
网关:负责协议解析、加密解密、将玩家会话绑定到具体的Battle Zone Service。
-
战场管理器:全局单例。玩家报名后,管理器根据其来源服和负载情况,将其分配到某个Battle Zone Service。它是整个战场的"大脑"。
-
战场分区服务:这是最核心的无状态(或弱状态)战斗逻辑单元。每个分区服务负责地图上的一块矩形区域。玩家在区域内移动、战斗。当玩家从一个区域移动到另一个区域时,由管理器协调,在两个分区服务间迁移玩家的会话上下文。这是实现水平扩展的关键。
-
核心服务:这些是支撑性的专项服务。事件调度器管理全局定时事件;数据聚合服务从各分区收集积分,计算排行榜;持久化服务异步将数据存库。
具体方案的权衡
难点一:400人实时状态同步,如何保证低延迟和可扩展性?
-
方案A(中心广播):所有玩家状态都发送到同一个中心服务,由它计算后再广播。简单,但单点瓶颈明显,400人时必然延迟爆炸。不可行。
-
方案B(完全P2P):玩家间直接同步。延迟最低,但网络连接数爆炸(N²),且外挂难以防范。不安全,不可控。
-
方案C(基于分区的广播 + 兴趣列表):
-
我的选择:采用此方案,并进行细化。
-
具体设计:
- 分区内广播:每个Battle Zone Service只负责同步本区域内的玩家。400人分到4个分区,每个分区只有100人,广播压力骤减。
- AOI(兴趣管理):玩家只同步给他"看得见"的其他玩家。在分区内,采用"九宫格"算法。玩家进入某个格子,只同步给相邻9个格子内的玩家。这再次大幅减少了无效的广播量。
- 同步协议:采用增量同步+快照补偿。平时只同步变化的状态(如"血量-10")。网络抖动时,定期(如每2秒)发送一次完整状态快照,防止状态漂移。
-
-
权衡:牺牲了"全图玩家状态立即可得"的便利性,换来了极致的性能和扩展性。这对于大规模战场是必须的。
难点二:玩家都挤在一个角落"团战",导致单个分区服务过载怎么办?
-
方案A(静态分区):一开始就划分好,不做调整。实现简单,但热点区域的服务会过载,体验下降。不满足高可用要求。
-
方案B(动态分区):
-
我的选择:采用此方案。
-
具体设计:
- 监控:每个Battle Zone Service定期向Battle Manager报告负载(玩家数量、CPU、消息队列长度)。
- 分裂:当某个分区的负载超过阈值(如玩家>150),管理器命令该分区服务进行"分裂"。它将所负责的地图区域一分为二,并孵化一个新的分区服务来承担其中一半。原分区的玩家,根据其坐标,被迁移到新的分区服务中。
- 迁移:玩家迁移是无感的。网关会将玩家后续的消息转发到新的分区服务。玩家的状态数据通过cluster.call在服务间快速传递。
-
-
权衡:实现了负载均衡,但大大增加了系统的复杂性(需要服务发现、状态迁移、数据一致性保证)。这是为了满足"400人同场不卡顿"这个核心体验必须付出的架构代价。
难点3:事件触发逻辑耦合严重,需要重构
核心设计思想是:将事件系统抽象为一个独立的、可配置的、支持热更新的服务群。
[事件配置表] (JSON/Lua)
|
| 加载配置
v
[战场分区服务] <---> [事件调度服务] (EventScheduler)
| |
| [事件处理器池] (EventHandlers)
| |
| +-------+-------+
| | | |
v v v v
[玩家战斗逻辑] [Boss事件] [占领事件] [资源点事件] ...(更多插件)
-
事件调度服务 (EventScheduler):
-
单一职责:只负责何时触发何种事件。它不关心事件的具体逻辑。
-
基于配置驱动:从配置文件或数据库加载事件列表。每个事件定义包括:事件ID、类型、触发时间/条件、参数(如BossID,坐标)。
-
在配置中增加 trigger_condition字段,可以是一个Lua函数字符串或条件表达式。条件满足时,才触发事件处理。
-
-
事件处理器 (XXXEventHandler):
- 插件化模块:每个事件类型(如Boss、占领)都是一个独立的Lua文件模块。它们实现一组标准接口。
- 标准接口示例:
ua
-- boss_event_handler.lua
local M = {}
-- 事件被触发时调用
function M.on_trigger(event_id, config, battle_zone_id)
local boss_id = config.boss_id
local x, y = config.x, config.y
-- 1. 通知对应的战场分区服务:在(x,y)生成一个Boss实体
-- 2. 该Boss实体的战斗、血量管理等,由战场分区服务负责
-- 3. 可能记录Boss的全局状态(如归属)到DataAggregator
end
-- 事件持续中,每帧/每秒更新(用于处理占领进度条等)
function M.on_update(event_id, dt)
-- 更新逻辑
end
-- 事件结束(Boss被击杀、占领完成)时调用
function M.on_finish(event_id, result)
-- 发放奖励,通知排行榜
end
return M
- 如何加载:EventScheduler根据事件类型字段(如 "type": "boss_spawn"),动态 require对应的处理器模块(如 "event_handlers.boss_spawn")。
与战场分区服务的协作
事件被触发后,如何影响游戏世界?
-
消息通信:EventScheduler通过 skynet.send异步通知具体的 BattleZoneService。
-
示例:BossEventHandler.on_trigger中,会向目标分区服务发送一条 {cmd="spawn_boss", boss_id=1001, x=100, y=200}的消息。
状态持久化和热更新
- 状态持久化:EventScheduler将当前所有事件的运行状态(如Boss是否已召唤、占领进度)定期序列化到 skynet.sharedata或 Redis。服务崩溃重启后,可以恢复现场。
- 热更新:因为每个事件处理器都是独立的Lua模块,可以使用Skynet的热更新机制,单独更新某个事件逻辑(如调整Boss技能),而无需重启整个战场服务。
不足
- 容灾:虽然分区服务可以动态重启,但Battle Manager目前是单点。未来可以考虑引入etcd或ZooKeeper实现管理器的选主,做到完全去单点。
- 通信开销:事件触发从函数调用变成了服务间消息,有微小的延迟。但这对于游戏事件(秒级)来说完全可以接受。
- 调试链路变长:问题可能出现在事件调度、处理器或战场服务中,需要更清晰的日志追踪。