ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Skynet框架实践指南:Actor模型与游戏服务端架构设计

Skynet框架实践指南:Actor模型与游戏服务端架构设计 Skynet这套框架说实话在游戏服务端圈子里已经不算新鲜事物了但直到今天我依然觉得它是理解Actor模型、消息调度、分布式服务拆分最好的教材之一。很多团队拿它做棋牌、SLG、MMO的服务器底子也有不少人把它当成学习高并发服务端设计的入门骨架。这一章我把Skynet的高级特性和工程实践串一遍重点讲那些代码注释里不会写、文档也说得比较含糊的东西比如消息调度的底层逻辑、服务划分的边界、热更新的正确姿势、以及线上问题怎么排查。不管你是刚接手Skynet项目的新人还是准备自研框架想找参考系的老手这篇内容应该都能给你一些实际可用的东西。1. 为什么到现在还要聊Skynet框架定位与核心设计哲学1.1 Skynet解决的核心问题游戏服务端和普通互联网后端最大的区别在于“状态”和“实时性”。一个玩家的背包数据、一个房间里的对战进度都是强状态而且这些状态的变化频率极高。传统的Web后端可以做到无状态水平扩展但游戏服务器不行你没法随便把一场麻将的牌局状态丢到另一个进程里去。Skynet的思路很直接用独立服务Actor来管理独立的状态服务之间通过消息通信状态不共享。每个服务可以看作一个带独立内存空间的小型进程服务之间没有锁竞争也没有共享内存的脏读问题。这种模型天然适合游戏业务因为玩家数据、房间数据、排行榜数据本身就是天然隔离的。这套框架用C写核心调度用Lua写业务逻辑两者通过一套轻量级的消息传递机制衔接。C层负责多线程调度和网络IOLua层负责具体的业务处理。这种分层让业务开发效率很高同时核心性能又有保障。很多团队选Skynet不是因为它在所有指标上都胜出而是它把“并发模型”“服务治理”“网络层”这些底层事情都解决掉了业务层只需要关注玩法逻辑本身。1.2 Actor模型在游戏服务端的最佳落地方式Actor模型其实不是Skynet的发明Erlang、Akka这些也都用这套思想。但Skynet的落地方式和Erlang有本质区别它没有像Erlang那样把进程轻量级进程作为基本单位而是把“服务”作为基本单位。一个服务就是一个独立的调度单元拥有自己的消息队列和Lua虚拟机。这个设计和游戏业务的匹配度非常高。举个例子你做一个棋牌游戏通常是一桌一个服务还是所有桌子共用一个服务Skynet里两种方案都成立。一桌一个服务的好处是隔离性强一个桌子崩了不影响其他桌子坏处是服务数量会很多内存开销也大。所有桌子共用一个服务的好处是资源占用少坏处是一个服务的逻辑复杂度会迅速膨胀而且一旦这个服务挂了全部桌子都跟着遭殃。从工程实践来看我见过比较稳健的做法是按玩法模式做服务维度拆分。比如“斗地主大厅服务”管所有斗地主的房间“麻将房间服务”管麻将的房间。一个服务内用子模块来隔离开不同局每个子模块有自己的状态、自己的定时器相当于把Actor再细分为逻辑上的“轻量级Actor”。这样做既控制了服务总数又保留了状态隔离。1.3 消息模型send/call/responseSkynet的消息分三种语义send发送后不管、call发送并等待结果、response对call的回应。这个模型和HTTP的请求响应模式很像区别在于Skynet是全异步的。很多刚上手的人最不适应的就是“没有返回值”这件事。在Skynet里你调用一个远程服务的方法不能像普通函数一样拿返回值你得通过回调或者协程挂起来等结果。Skynet通过内置的协程解决了这个痛点当你调用skynet.call的时候框架自动帮你把协程挂起等目标服务的response回来之后再唤醒协程继续往下跑。写起来像同步跑起来是异步这是Skynet用起来最舒服的地方。但这里有一个隐藏的坑如果目标服务在处理call消息时崩溃了消息处理函数抛异常调用方的协程可能永远等不到response导致协程泄漏。所以实际工程中一定要在服务入口处做消息级容错。我在后面的章节会专门讲这套容错机制的写法。2. 深入调度内核消息队列、工作线程与并发模型2.1 消息是如何流转的Skynet的消息流转流程可以简化成三步源服务发出消息、框架路由、目标服务消费。但真正复杂的是第二步——框架如何决定一个消息应该挂在哪个队列上以及工作线程如何从队列里取出消息来执行。每个服务在创建的时候框架会为它分配一个独占的消息队列。源服务发的消息会进入目标服务的私有队列。同时框架维护一个全局队列里面放的是那些“有消息需要处理”的服务。工作线程会从全局队列里取出一个服务然后把这个服务的私有队列一次性取走整个队列替换再逐个处理里面的消息。这个设计很巧妙。它保证了同一个服务内的消息一定是顺序执行的不会有两个工作线程同时处理同一个服务的消息因此服务内部完全不需要加锁。而多线程并行的粒度体现在不同服务之间。这就是Skynet“单线程逻辑多线程并行”的真相——每个服务自己看是单线程的整个系统看是多线程的。2.2 全局队列与私有队列的交互细节我刚才说工作线程会“把服务的私有队列取走”这个机制叫“转移”。转移的收益是什么如果工作线程一条一条地从私有队列里取消息那每次取都要向全局队列确认“这个服务还有没有消息”会产生大量的锁竞争。一次性把整个队列搬走意味着这个服务在接下来一段时间内的消息处理都由当前工作线程独占中间不需要再碰全局锁。这里面有个值得注意的点如果在处理消息的过程中服务产生了新消息这是极常见的比如一个服务处理某条消息后给自己发了一条定时消息或者给别的服务发消息后别人回了一条这条新消息会进入一个叫“二次队列”的临时缓冲。当前工作线程处理完原有队列后会把二次队列再搬过来继续处理。这个过程可能会反复好多次直到某个瞬间队列里确实没有新消息了服务才会重新回到全局队列等待分配。理解这个机制对性能调优的意义非常大。如果一个服务的消息处理很快但消息量很大那么它可能一直霸占着某个工作线程导致其他服务得不到调度。这也是我在第三节讲服务划分时要强调“粒度和频率要匹配”的原因。2.3 工作线程数与业务线程的取舍Skynet的线程参数在配置文件的thread字段里设置默认是8。很多团队上线前会把这个数调到物理核心数甚至超线程数觉得线程越多处理能力越强。这个想法是很危险的。让我用一个具体的例子来说明。假设你的服务器是8核16线程的CPU你把thread设成16理论上16个工作线程可以同时运行。但Skynet的核心调度是有锁的全局队列的入队出队需要加锁网络模块的回调分发也要加锁。当工作线程多了锁竞争会急剧上升性能不升反降。而且线程切换本身就有开销。我自己常用的调法是thread设为(CPU物理核心数 - 1)或(物理核心数 - 2)留出资源给系统、网络模块和GC线程。比如8核机器我一般设6或7。这看起来有点保守但实测下来吞吐最稳。当然如果你的业务全是IO密集型比如大量消息转发而每个消息处理都很轻那可以适当调高一些。关键是线上要压测不能靠感觉拍脑袋。3. 服务间通信与分布式扩展3.1 本地通信的精简设计在单机范围内Skynet服务间通信非常便宜。skynet.send skynet.call这套API专门为同进程通信做了优化没有序列化和网络IO本质是在内存里复制一份消息体然后塞进目标队列。但“便宜”不等于“免费”。消息的分配和回收、协程的创建和销毁都是有代价的。在极端高频率下比如每秒几十万次的调用这些开销也会变得明显。所以设计API边界的时候要遵循一个原则高频调用要走批量接口不能一次一调。举个例子回合制游戏里要定时的给所有在线玩家推送属性变化。如果每个玩家一次skynet.send一万在线就是一万次消息传递。如果专门做一个批量推送接口让数据源服务把玩家的变化打包成一个大消息发给会话服务会话服务再拆开做网络发送消息量就能从一万降到一个。这类优化在服务端架构里往往比加机器、调参数有效得多。3.2 集群方案选型Skynet集群、Redis、消息队列单机永远有上限所以Skynet项目迟早要面对集群问题。Skynet官方提供了一个cluster模块核心思想是通过节点互连让一个节点上的服务可以像调用本地服务一样调用远程节点上的服务。但官方的cluster模块我用下来最大的感受是能用但要想清楚边界。它适合“低频、弱一致、容错要求不高”的跨服通信。比如跨服聊天、跨服排行、跨服好友。它不适合“高频、强一致、必须严格事务”的场景比如跨服组队做副本、跨服交易。原因很简单跨节点的skynet.call是走异步网络消息的中间任何一环断掉通信就会失败而且消息没有事务保证。更稳妥的集群方案是把“需要强一致的数据”收拢到单机处理跨服之间只同步“弱一致的结果”。比如跨服排行榜各服玩家把积分上报到中央排行榜服务排行榜服务定时排一次序各服定期拉取结果做展示。这个过程不需要实时同步即使有短时间的数据延迟对玩家体验影响也不大。如果业务真的需要跨服强一致比如跨服战场匹配到一起打一场比赛我建议直接用独立的Redis或专门的match服务来做状态协调而不是硬套Skynet的cluster。很多人在这一步上头什么都想用Skynet的集群解决最后把系统的复杂度推到了一个失控的地步。3.3 跨服道具与玩家数据一致性跨服场景里一致性最敏感的就是道具和货币。玩家在A服买了一个道具在B服要用这里必须处理“最终一致”和“脏读”的问题。Skynet本身不提供分布式事务它提供的只是一个消息转发基础设施。真正的一致性保障还得靠业务层。我的经验是玩家资产类的数据永远有一个“归属服”概念。不管玩家当前在哪个服活动他的资产变更请求都会回源到归属服做操作。其他服需要用到玩家资产信息时只能读缓存副本不能直接改。这样虽然会有轻微的读写延迟但能保证任何时刻资产变更是串行的不会出现两个服同时扣同一份货币的情况。这个方案在工程上非常好落地。核心资产服务独立成节点所有涉及资产变动的消息都路由给它它独占一份数据库或Redis里的资产记录。其他逻辑服务如果要在战斗中发奖励先把“奖励事件”发给资产服务由它统一扣增。千万别把资产操作散落到各个业务服务里否则后面排查数据问题能查到怀疑人生。4. 热更新方案原理、写法与坑4.1 破解Skynet的模块加载机制热更新是游戏服务器绕不开的话题。Skynet的Lua服务通过require加载模块加载过的模块会缓存在package.loaded表里。所谓热更新本质上就是想办法把已加载的模块从package.loaded里替换成新版本。但直接package.loaded[某模块]nil然后重新require是一个典型的“看起来有效但隐患极大”的做法。问题在于旧的模块可能被其他模块引用着。比如A模块require了B模块B模块被你热更了A模块里的局部引用还指着旧的B模块除非A也重新加载否则A仍然在用旧代码。很多团队的热更系统做得比较复杂不仅替换模块还要追踪依赖关系把引用了旧模块的父模块也一并重载。这里我不推荐一上来就搞自动依赖追踪因为追踪关系分析一旦出错影响范围会非常广。更稳的做法是把热更代码和非热更代码做严格区隔凡是可能热更的模块都不允许被其他模块在局部引用持有只通过服务间消息来调用。这样热更一个服务时只需要重启那个服务或者替换那一个服务内部的模块即可。4.2 普通逻辑热更的正确做法Skynet提供了一种机制可以让你热更一个正在运行的服务而不重启它。你把新代码通过消息发给服务服务在自己的上下文里加载并替换指定模块。我在项目里常用的热更流程是先准备一个“热更控制服务”它接收运维下发的热更指令。热更指令里包含需要热更的服务名和模块路径。热更控制服务向目标服务发送一个更新的消息附带新模块的代码通常是已经上传到服务器的lua文件路径。目标服务收到消息后清理package.loaded里指定模块的缓存重新require新代码。加载完后返回成功信息控制服务记录日志方便回溯。这里有一个很重要的操作约束热更只能改函数逻辑不能改状态结构。也就是说如果新代码里写死了某个表新增了一个字段但已在运行的服务里那些老数据对象没有这个字段一访问就会nil错误。要做状态迁移得单独写迁移逻辑不能靠热更函数本身去修复。4.3 状态型服务的热更策略有些服务是强状态的比如房间服务它管理着一场进行中的对局玩家操作、出牌状态、牌堆数据全在内存里。这类服务不能直接重启也不能简单替换代码因为状态一旦丢失玩家就打不了这一局了。处理这类服务的常见思路是“双版本共存”。你把服务内部逻辑切成两段基础能力段和玩法版本段。基础能力段长期保持稳定比如玩家进出房、准备状态、心跳检测。玩法版本段是每次改动最多的地方比如某张牌的技能效果、某个副本的掉落概率。热更只替换玩法版本段基础能力段不动。这样做的核心收益是你不再需要“重启服务”这种重型操作只需要把玩法版本段从老版本平滑切换到新版本。切换时要注意的是不能在有消息正在处理的间隙去切换。我通常是在一个消息处理的空闲点比如一局结束、玩家都离开房间、或者一个回合结算完毕做切换确保没有半截状态落在新老代码之间。5. 工程实践代码组织、配置、日志与监控5.1 服务划分的最佳实践服务划分是整个Skynet架构里最容易做错的一步。划分太粗一个服务承载过多职责出问题难排查一来消息风暴就集体崩溃划分太细服务数量膨胀服务间通信成本上升部署运维也变复杂。我见过比较健康的划分思路是“按领域模型划分而不是按功能动作划分”。比如玩家服务管玩家的基础数据登录、登出、基础资料的读取修改战斗服务管战斗流程不直接碰玩家资产社交服务管好友、聊天这种关系型功能。三个服务各自专注一块领域交互靠消息。另外有一条很实用的原则一个服务的消息处理函数要尽量“短、平、快”。如果一个消息处理函数里要做大量耗时操作比如多次数据库查询、多次远程服务调用那么这个服务很容易成为瓶颈。改成把耗时操作丢到一个专门的后台服务里异步处理并让主服务在必要时候才等待结果这是提升服务吞吐量最关键的优化手段之一。5.2 配置管理从写死到动态游戏服务器启动时通常都会读一份配置Skynet本身支持在启动配置里写死一些参数比如监听端口、数据库连接串、集群节点列表。但工程上光靠启动配置是不够的因为运维要经常调整一些线上参数比如某个玩法的开关、新年活动的掉落倍率、某个服务的并发上限这些开关如果在代码里写死每一轮改都要重新打包发布。我的实践是搭建一个独立的“配置中心服务”把所有动态配置统一收口。业务服务启动的时候从配置中心拉取最新配置运行中通过注册回调来监听配置变更。配置中心本身基于数据库或etcd持久化提供一个后台管理界面给运营改配置。这样做的好处非常明显——改活动数值不需要发版运营自己就能操作同时所有服务的配置来源统一不会出现两个服务理解同一个配置但口径不一致的问题。要注意的是配置中心服务必须做到极高的可用否则所有业务服务启动时都拉不到配置就直接起不来。我那时候做了一个本地文件缓存兜底配置中心挂了也能读上次的配置继续启动。5.3 日志规范与分布式追踪Skynet的项目有个老大难问题服务多、消息多出了问题很难定位一条完整的业务链路。比如玩家报了一个“我充值成功但道具没到账”的问题你得知道这条充值请求从客户端进入后经过了哪些服务、每个服务的处理结果如何。这就必须做链路追踪。我在项目里给每个进入系统的外部请求分配一个唯一的request_id通常由客户端生成或网关生成所有服务在处理这条请求时都把request_id带在日志里。日志格式统一为“时间 | 服务名 | request_id | 日志级别 | 内容”这样翻日志的时候只需要grep一个request_id就能串出完整链路。Skynet本身不提供日志框架需要自己封装。我的封装里还会带上当前服务的内存占用、消息队列长度、协程数量这些现场信息。线上排查问题时这些上下文信息往往比具体日志内容更有价值。比如某个服务队列一直增长说明消息处理速度跟不上消息产生速度这时候看协程数量就能判断是不是大量协程卡在等待结果上。5.4 内存和性能调优的实战手段Skynet服务的内存主要消耗在Lua侧每一个服务都有自己的Lua虚拟机所以服务的数量直接决定内存下限。如果一个开100个服务、每个服务都require了几个大模块光基础内存占用就相当可观了。所以“多服共用”和“单服过大”之间要找到合适的平衡点。Lua的GC是全停顿式的内存达到一定规模后GC停顿时间会非常明显。线上表现经常是服务运行一段时间后玩家忽然感觉操作有顿挫几秒后又恢复正常这就是GC在跑。遇到这类问题我一般先看内存增长速度。如果内存一直在增长先排查有没有缓存没清理、消息是否在泄漏协程挂起后一直没人响应。如果内存稳定但在高位GC还是频繁就检查是不是有一些大表的创建和销毁特别频繁比如每帧创建了一个巨大的配置表用完就丢。性能调优方面还有个切入点Skynet的网络层是基于epoll的单连接的收发性能很高但CPU的占用跟协议序列化、反序列化的开销强相关。如果你的协议是JSON性能天花板很低换用二进制协议比如google protobuf或云风的pbc能明显降低CPU。协议设计的取舍往往比调并发参数来得更直接。6. 常见问题与排查技巧实录6.1 服务启动失败或起不来Skynet服务起不来最常见的原因是启动配置里引用了不存在的lua文件或者服务启动过程中依赖了另一个还没启动的服务。这里有个经验服务间的初始化顺序要好好控制。全局性的服务比如数据库、配置中心要先启动业务服务后启动。如果业务服务启动时依赖了某个全局服务但对方还没起就会在启动过程中出现调用超时或错误。排查这类问题时第一件事不是看业务代码而是看服务启动日志。Skynet的每个服务启动时都会打印自身的服务名和启动参数如果卡在某一步没有继续打印那基本就是依赖的服务没就绪或用例数据没准备好。我通常会在关键的启动步骤里加日志尤其是外部服务依赖的地方确保启动过程是可见可追踪的。6.2 消息风暴服务被瞬时大消息量打垮消息风暴在Skynet项目里特别常见。典型触发场景是某个活动开服瞬间大量玩家同时登录、同时领取奖励、同时匹配对局大量消息涌向同一个服务服务处理不过来消息队列不断积压最终导致内存暴涨、GC频繁、响应时间飙升。缓解消息风暴通常有几个阶梯做法。第一步是限流在服务入口处加一个简单的令牌桶每秒最多处理多少条消息超出部分直接丢弃或返回失败。第二步是削峰把玩家请求先入队异步批量处理。比如登录并发高可以把登录请求先写进一个待处理队列几个工作线程同时消费而不是让玩家服务单线程硬扛。第三步是资源隔离把容易被打爆的服务独立部署或做多实例分片避免一个服务扛所有流量。排查时先用监控图表看队列长度如果队列一直涨、协程数持续高位那基本可以锁定是单服务扛不住。再结合request_id看哪类请求量最大、处理最慢优先优化那条链路。6.3 死锁一动不动日志也不走了Skynet里出现死锁大部分情况是“循环等待”。最典型的是A服务call了B服务B服务的消息处理函数里又call了A服务两边互相等待对方响应。这在开发前期不常见但随着服务越拆越细、调用链越来越长这种循环依赖就会冒出来。排查死锁时我一般会先把所有服务的协程状态拉出来看每个协程阻塞在哪一条调用上。Skynet有提供调试手段可以打印当前正在处理的消息和协程上下文通过这些信息能很清楚地看到谁在等谁。找到循环链路后优先打破单向依赖。比如让A不要直接call B而是通过一个事件广播的方式通知BB处理完后再通过回调结果通知A把同步调用改成异步消息。还有一个容易忽略的死锁场景是单服务内部如果服务消息处理函数里阻塞等待自己发出的消息比如在A服务里发了一条给自己处理的call而这个服务恰好只有一个工作线程那这条消息永远没人处理自己把自己锁死了。这种bug隐蔽性极强我栽过一次之后现在凡是服务内部需要延迟处理的逻辑一律用skynet.timeout定时器而不是发送消息给自己。6.4 内存持续增长协程泄漏与缓存失控内存持续增长算是Skynet项目线上最头疼的问题。它不像崩溃一样立刻暴露而是慢慢地让服务器响应变慢最后在凌晨三四点突然OOM。Skynet里有两类典型的内存增长源。第一类是协程泄漏服务处理某条消息时开了很多协程去异步处理但这些协程在等待时被异常中断或永远等不到响应就悬挂在协程表里不释放。第二类是缓存失控代码里把一些查询结果缓存到了内存表里但没有做过期清理或容量上限时间一长缓存无限膨胀。排查这两类问题先把“协程总数”和“内存占用”同时监控起来。如果协程数量一直在涨那就去看协程阻塞在哪条code路径上。如果协程稳定但内存涨就去看缓存。Skynet可以利用debug模块来查看当前服务的对象分配情况也可以每隔一段时间打一份内存快照做对比。吃几次亏之后我在服务的消息处理入口处会统一做异常捕获任何协程里抛出异常都必须被记录下来并清理现场防止协程被异常打断后没人回收。6.5 问题速查表现象可能原因排查切入点服务启动失败配置路径错误、依赖服务未就绪启动日志、依赖顺序消息队列持续积压处理速度不足、阻塞调用过多队列长度、协程数、热点函数耗时出现死锁循环call、自身call协程栈打印、消息链路分析内存缓慢增长协程泄漏、缓存未清理协程总数、内存快照、GC日志GC停顿导致卡顿高内存水位大对象创建销毁内存分配点监控、对象池化跨服数据不一致多处写同一数据检查归属服写入原则、缓存过期热更后行为异常旧模块引用未清理检查依赖关系重建、热更日志最后再分享一个实践中的体会我做了这么多年Skynet项目最大的感受是框架本身其实很轻真正的复杂度都来自业务服务的划分和消息调用的组织方式。如果你能把服务边界画清楚、把每个服务的职责想明白、把消息调用链控制在三层以内Skynet用起来会非常顺手甚至有点“无感”。反而是一上来就想用它的高级特性比如集群、跨服通信、自动热更而不先把基础模型打扎实最后都会被这些高级特性反噬。如果你刚开始接触Skynet我建议你花时间亲手写几个模拟业务比如写一个简单的玩家登录流程、一个房间创建和加入流程、一个排行榜刷新流程。这些流程覆盖了服务创建、消息通信、定时器、数据库交互、跨服同步绝大部分核心场景。把这几套跑通了Skynet在你眼里就不再是黑盒了。后面再遇到什么高级需求往上叠加就不会觉得吃力。
返回列表