游戏服务器优雅关服设计与实现
前言
在分布式游戏服务器的架构设计中,优雅关服是一个经常被忽视但又极其重要的技术问题。不当的关服处理可能导致数据丢失、状态不一致,甚至影响玩家体验。本文将分享我们在基于Actor模型的游戏服务器中,如何设计和实现一套优雅关服机制的完整思考过程。
问题背景
原有关服机制的挑战
我们的游戏服务器采用Actor模型架构,包含以下主要Actor类型:
- Player Actor:处理玩家相关逻辑
- Map Actor:处理地图相关逻辑(城市、军队、建筑等)
- Alliance Actor:处理联盟相关逻辑
- Rank Actor:处理排行榜逻辑
原有的关服机制采用简单的分层关闭策略:
const (PlanNormal = 0 // 普通Actor先关闭PlanDomain = 1 // 常驻Actor后关闭
)
但这种简单的分层策略存在几个关键问题:
1. 消息丢失风险
问题场景:当PlanNormal
级别的Actor(如Player Actor)先关闭后,PlanDomain
级别的Actor(如Map Actor)仍在运行,可能会向已关闭的Player Actor发送cast消息:
// Map Actor计算完奖励后,向Player发送cast消息
actor.RpcCast(playerId, &RewardParams{Rewards: rewards})
由于Player Actor已关闭,这些消息会丢失,可能导致玩家奖励缺失。
2. 依赖关系复杂
Actor间存在复杂的调用依赖:
Player -> Map
:玩家操作地图对象Map -> Player
:地图状态变化通知玩家Player -> Alliance
:玩家操作联盟功能Multiple Player -> Map
:多人协作场景
简单的分层无法很好地处理这些复杂的依赖关系。
3. RPC调用处理不完整
系统中的RPC调用包括:
SyncRpcCall
:同步调用,阻塞等待结果RpcCall
:异步调用,有回调处理RpcCast
:单向通知,无需回复
关服时,正在进行的RPC调用可能被中断,导致调用方超时等待或状态不一致。
设计思考过程
思路一:基于依赖关系的拓扑排序
核心思想:构建Actor间的依赖关系图,使用拓扑排序确定关闭顺序。
优点:
- 理论上完美解决依赖问题
- 数学上严谨
缺点:
- 依赖关系图维护复杂
- cast消息导致依赖图过于复杂
- 循环依赖难以处理
- 实现和维护成本高
思路二:消息重定向机制
核心思想:Actor关闭时,将发往它的消息重定向到其他处理器。
优点:
- 保证消息不丢失
- 可以处理复杂的业务逻辑
缺点:
- Actor特有业务逻辑难以在外部复制
- 增加系统复杂性
- 性能开销较大
思路三:源头切断 + 自然消化 ⭐
核心思想:切断消息产生的源头,让现有消息在系统内自然消化完成。
分析消息来源:
- 客户端请求:通过网关进入系统
- 定时器事件:全局定时器和Actor内部定时器
- Actor间RPC调用:通常由前两者触发
策略:
- 停止网关,阻断客户端新请求
- 停止定时器,阻断定时事件
- 等待现有消息链自然完成
优点:
- 简单有效,易于实现
- 避免消息丢失问题
- 无需复杂的依赖分析
- 保持Actor模型完整性
技术实现
基于"源头切断 + 自然消化"的思路,我们设计了以下技术方案:
1. 优雅关服流程
beforeShutdown := func() {logger.INFO("=== Starting graceful shutdown ===")// 1. 停止外部输入源stopExternalSources(netService)// 2. 停止定时器触发 stopTimerTriggers()// 3. 等待消息自然消化waitForMessageDrain(5 * time.Minute)logger.INFO("=== Graceful shutdown completed ===")
}
2. 停止外部输入源
stopExternalSources := func(netService *misc.ServiceWraper) {logger.INFO("Stopping external sources...")// 停止网络服务,不再接受新的客户端连接if err := netService.Stop(); err != nil {logger.ERR("Stop NetService failed: ", err)} else {logger.INFO("NetService stopped successfully")}
}
3. 分层定时器处理
我们的系统有两种定时器:
全局定时器(持久化)
// 停止ticker但保留Redis数据,重启后可恢复
if err := gen.Cast(timertask.ServerName, &timertask.StopTickerParams{}); err != nil {logger.ERR("Stop global timer failed: ", err)
} else {logger.INFO("Global timer ticker stopped")
}
Actor定时器(内存级别)
// 通过filter机制自动停止
func NewTTWrapper(uuid string, ctx IGActor, handler func(interface{})) *TTWrapper {ins := new(TTWrapper)tick := func() bool {err := gen.Cast(uuid, &CheckTickerParams{})return err != nil && !errors.Is(err, gen.ErrNotExist)}ins.timerTask = NewTimerTask(tick, SupStopping) // filter检查关服状态return ins
}func (t *TimerTask) notifyCheckTicker() {if t.filter != nil && t.filter() { // 检查SupStopping()logger.WARN("notifyCheckTicker failed, Manager is stopping!")return}// 正常触发逻辑...
}
4. 系统状态监控
为了准确判断消息是否消化完成,我们实现了系统状态监控:
type SystemStatus struct {TotalActors int // 总Actor数量IdleActors int // 空闲Actor数量TotalMessages int // 总消息数量Timestamp int64 // 状态时间戳
}func (ins *Supervisor) getSystemStatus() *SystemStatus {status := &SystemStatus{Timestamp: time.Now().Unix()}// 统计各层级Actor的消息队列状态for level := 0; level < MaxPlanLevel; level++ {ins.plan[level].Each(func(actorId string, _ int8) bool {if server, ok := gen.GetGenServer(actorId); ok {status.TotalActors++queueLen := server.ChannelLen()status.TotalMessages += queueLenif queueLen == 0 {status.IdleActors++}}return true})}return status
}
5. 智能等待机制
waitForMessageDrain := func(timeout time.Duration) {logger.INFO("Waiting for message drain, timeout: ", timeout)start := time.Now()checkInterval := 2 * time.Second// 记录初始状态actor.LogSystemStatus()for time.Since(start) < timeout {// 检查系统是否准备好关闭if actor.IsSystemReadyForShutdown() {logger.INFO("System is ready for shutdown")return}time.Sleep(checkInterval)logger.INFO("Message draining in progress, remaining: ", timeout - time.Since(start))}// 最终状态检查actor.LogSystemStatus()
}func IsSystemReadyForShutdown() bool {status := GetSystemStatus()return status.TotalActors == 0 || (status.TotalActors > 0 && status.IdleActors == status.TotalActors && status.TotalMessages == 0)
}
代码重构优化
在实现功能的基础上,我们还对原有的关服代码进行了重构优化:
重构前的问题
- 单一大函数,逻辑复杂难懂
- 错误处理简陋
- 日志信息不够详细
- 代码可维护性差
重构后的改进
1. 模块化设计
func (ins *Supervisor) shutdownActors() {currentPlan := ins.plan[ins.stopPlan]// 检查是否需要切换到下一个关闭级别if ins.shouldSwitchToNextPlan(currentPlan) {if ins.switchStoppingPlanLv() >= MaxPlanLevel {logger.INFO("All shutdown plans completed")return}currentPlan = ins.plan[ins.stopPlan]}// 清理超时的Actorins.cleanupTimeoutActors()// 执行当前级别的Actor关闭ins.executeShutdownPlan(currentPlan)
}
2. 详细的进度日志
func (ins *Supervisor) logShutdownProgress(stoppedCount, removedCount int) {if stoppedCount > 0 || removedCount > 0 {logger.INFO("Shutdown progress - Plan level: ", ins.stopPlan, " Stopped: ", stoppedCount, " Removed: ", removedCount," Currently stopping: ", ins.stoppingActors.Len())}
}
3. 超时处理优化
func (ins *Supervisor) cleanupTimeoutActors() {timeoutThreshold := time.Now().Unix() - ShutdownTimeouttimeoutCount := 0ins.stoppingActors.PopByScore(timeoutThreshold, func(actorId string, _ int8, _ int64) bool {timeoutCount++logger.WARN("Actor shutdown timeout, force removing: ", actorId)return true})if timeoutCount > 0 {logger.WARN("Cleaned up ", timeoutCount, " timeout actors")}
}
实际运行效果
实施优雅关服后,关服日志变得清晰可观:
INFO: === Starting graceful shutdown ===
INFO: Stopping external sources...
INFO: NetService stopped successfully
INFO: Stopping timer triggers...
INFO: Global timer ticker stopped
INFO: Actor timers will stop automatically via filter mechanism
INFO: Waiting for message drain, timeout: 5m0s
INFO: System status - Total actors: 15 Idle actors: 12 Total messages: 3 Ready for shutdown: false
INFO: Message draining in progress, remaining time: 4m58s
INFO: System status - Total actors: 15 Idle actors: 15 Total messages: 0 Ready for shutdown: true
INFO: System is ready for shutdown, message drain completed
INFO: Shutdown progress - Plan level: 0 Stopped: 10 Removed: 0 Currently stopping: 0
INFO: === Switching to Domain Actor shutdown phase ===
INFO: Starting to shutdown daemon actors...
INFO: Shutdown progress - Plan level: 1 Stopped: 5 Removed: 0 Currently stopping: 0
INFO: All shutdown plans completed
INFO: === Graceful shutdown completed ===
方案优势总结
1. 数据安全性
- ✅ 避免消息丢失:不会出现Domain Actor向已关闭Player发cast的问题
- ✅ 状态一致性:所有消息都能正常处理完成
- ✅ 业务连续性:重要的全局定时任务在重启后继续执行
2. 实现简洁性
- ✅ 避免复杂依赖分析:无需构建和维护复杂的依赖关系图
- ✅ 保持架构完整性:不破坏Actor模型的封装性
- ✅ 易于理解和维护:逻辑清晰,代码可读性好
3. 可观测性
- ✅ 详细的状态监控:实时了解系统关闭进度
- ✅ 完善的日志记录:便于问题排查和优化
- ✅ 可控的超时机制:防止无限期等待
4. 扩展性
- ✅ 保留原有机制:分级关闭机制得以保留和优化
- ✅ 模块化设计:易于扩展和修改
- ✅ 配置化超时:可根据业务需求调整等待时间
经验总结
通过这次优雅关服的设计和实现,我们得到了以下宝贵经验:
1. 架构设计原则
- 简单优于复杂:简单有效的方案往往比复杂精巧的方案更可靠
- 源头治理:解决问题要从根源入手,而不是在结果上修修补补
- 渐进式改进:保持系统稳定性的前提下逐步优化
2. 实现技巧
- 分层处理:不同类型的问题用不同的策略处理
- 状态监控:可观测性是系统可靠性的重要保障
- 超时保护:任何等待都要有超时机制
3. 代码质量
- 模块化设计:单一职责,便于理解和维护
- 详细日志:帮助理解系统行为和问题排查
- 优雅降级:即使在异常情况下也要保证基本功能
后续优化方向
虽然当前方案已经很好地解决了主要问题,但仍有进一步优化的空间:
1. 更智能的等待策略
- 根据消息类型区分重要性
- 动态调整等待时间
- 支持强制关闭模式
2. 更精细的监控
- Actor级别的状态监控
- 消息类型统计
- 关闭性能指标
3. 配置化管理
- 关闭策略配置化
- 超时时间可配置
- 日志级别可调整
结语
优雅关服是分布式系统设计中的一个重要主题。通过"源头切断 + 自然消化"的设计思路,我们成功实现了一套简单、可靠、可观测的优雅关服机制。这个案例证明了有时候最简单的方案往往是最有效的方案。
希望这次的设计和实现经验能够为其他面临类似问题的开发者提供一些参考和启发。在分布式系统的设计中,我们要始终牢记:简单、可靠、可观测,这是构建健壮系统的基本原则。