写这篇东西之前,我先说自己现在的开发状态:手里的项目是一个日活不算大的休闲社交游戏,服务端全套跑在skynet上,从最初的单人联调到现在三台云服务器撑完整套逻辑,前前后后踩了不少跟消息机制有关的坑。最开始只是照着云风的wiki和几个开源demo抄代码,能跑起来就完事,但等到要自己设计网关、做数据库异步落库、把一部分业务拆到外部HTTP服务里的时候,才真正体会到"消息就是skynet的生命线"这句话。这篇东西不打算做文档翻译,我就是想把"消息构成、session与type到底在干什么"、"数据库和内存管理为什么总被放在一起讨论"、"一条消息从发送到抵达Lua回调的完整链路"、"外部服务接入的几种内置姿势"这四个问题用实际经验给你讲透。无论你是刚接触skynet的Lua新人,还是正琢磨怎么优化现有服务端架构的C++老手,这篇都能给你省不少弯路。
1. 消息的三元组:source / session / type 到底在解决什么问题
1.1 一条消息在内存里的真实样子
skynet的actor模型和Erlang那种"复制整份消息"的模型本质不同,它不拷数据,传的是指针。底层核心结构是C语言里的skynet_message,每个进入系统流转的消息本质上就是这么个东西:
struct skynet_message { uint32_t source; // 发送方的服务地址 int session; // 会话ID,uint32_t,但通常用int存 int type; // 消息类型,PTYPE_* void *data; // 数据指针 size_t sz; // 数据长度,高8位带type信息 };如果你已经写了几天skynet代码,这个结构值得刻在脑子里。source不是字符串"login_service",它是一个uint32的整数,高8位表示节点ID,低24位表示节点内服务handle。之所以用整数而不用字符串,是因为消息队列里每次比较地址都要做哈希或者字符串比较,对高频调度来说太贵了。在集群场景下,这个整型地址还能直接跨节点路由,不需要查表。
sz这个字段有个细节:它不只表示数据长度,最高8位还用于携带额外的消息类型标志。也就是说真正可用的数据长度位数会缩水,但实际项目中几乎碰不到超过这个上限的消息,这个设计主要是为了在传递时能把type一起打包,省一次拷贝。
1.2 session:没有它,异步世界里没人认得你是谁
session在skynet里的作用,我一般跟团队新人这么解释:它就是快递单号。
你用skynet.send给某个服务发了消息,对方处理完想回结果,它怎么知道回给谁?虽然source字段能告诉它"你是哪个服务发来的",但如果同一个服务连续发了好几条请求,比如一个客户端登录后同时请求角色数据、邮件列表、好友列表,这三条消息都来自同一个agent服务,接收方返回结果的时候,光靠source根本分不清哪条回应对应哪个请求。
session就是用来解决这个问题的。它是系统级的一个自增ID,每次skynet.call或skynet.send发出请求时,发送方会给这条消息分配一个唯一的session号。接收服务在处理完业务后,把session号原样带上,通过skynet.response或者skynet.send回传。发送方在自己的回调里看到这个session号,就知道"哦,这是我之前发出去的第X个请求的响应"。
实操阶段最常见的报错就是there is no session with id。这个错误本质是什么?是接收方尝试回包,结果发现发送方的session状态已经被清掉了。出现原因大部分是:发送方在发起请求后已经超时或者主动关闭了协程,接收方却还在慢慢处理,等它终于回包时,发送方早已不认这个session了。我自己的项目里就犯过一回,当时是在登录流程里,agent请求数据库服务加载角色数据,同时又在等另一个外部API超时返回,超时后agent直接断开了客户端的连接,但数据库服务那边还在跑,最后回包的时候就报了这个错。所以记住一个原则:session的存活周期完全由发送方的协程生命周期控制,不关接收方的事。
| 场景 | session表现 | 易错点 |
|---|---|---|
| skynet.call 同步等待 | 自动分配session,协程挂起,响应通过匹配session唤醒 | 等待超时后未清理,响应回来报no session |
| skynet.send 单向通知 | 可以传0,表示不需要回包;也可以自定义session | 存了session却没人消费,导致内存泄漏 |
| 接收方主动回包 | 必须携带原session,否则发送方无法识别 | session被误改/丢失,响应永远传不回调用者 |
| 集群跨节点请求 | session由请求方节点生成,全局唯一 | 节点间session分配不冲突,但回调归属必须确认 |
1.3 type:为什么路由只认整数,不认字符串
消息队列里还有一个容易被忽略的就是type。skynet内部定义了一些常见的消息类型常量,比如PTYPE_TEXT(1)、PTYPE_RESPONSE(2)、PTYPE_ERROR(3)、PTYPE_LUA(6)等等。这些值不是随便定的,它们在C层和Lua层有一套约定好的对应关系。
type的存在让skynet可以对消息做分层处理。举个例子,PTYPE_RESPONSE是专门用来传递call响应的,收到这种消息的服务,调度层知道应该去唤醒等待中的协程,而不是把它当成一条全新的业务请求。PTYPE_ERROR则用于传递异常信息,比如目标服务启动失败、消息处理时抛错,这些都不该走正常的业务回调流程。如果你自己定义了新消息类型,比如PTYPE_WEB,就要在Lua层注册对应的dispatch处理函数,否则消息会被拿去走默认的PTYPE_LUA逻辑,造成类型错乱。
为什么用整数而不是字符串做类型?一方面还是性能考虑,整数比较和switch都比字符串哈希快。更关键的是,skynet的调度层在C语言里,它不认识Lua的字符串。整型type可以在C层直接参与逻辑判断,而字符串类型就必须依赖Lua层的翻译,这会让消息的生命周期复杂化。
2. 数据库与内存:为什么这两件事要放在一起说
2.1 skynet没有"连接池",只有内存里的临时话术
不少从传统Web后端转过来的同事,第一反应是找一个skynet版的数据库连接池库。这个思路其实跟skynet的模型天然有冲突。传统Web服务是多线程共享连接池,谁拿到连接谁用;但skynet是单进程、多服务、每个服务单线程的actor模型,你的数据库连接驱动跑在哪个服务里,非常重要。
在skynet里,最朴素的数据库方案是:写一个独立的db_service,在这个服务里持有所有数据库连接。当其他业务服务需要查询数据时,发消息给db_service,db_service执行查询,再把结果通过响应消息传回去。为什么要这样设计?因为如果每个业务服务都自己建立数据库连接,连接数会随着服务数量爆炸,而且在skynet的actor模型里,每个回调执行期间不能有阻塞操作(阻塞了会卡住整个服务的工作队列),你总不能每个业务服务里都阻塞着等数据库返回吧。
这个方案的本质是:用消息通信替代线程共享,用session对应关系来模拟一次"同步查询"。理论上你的db_service里确实可以只放一条连接,因为同一时刻它只会处理一条数据库请求。当然实际项目里还是建议开多条连接做简单的并行,这个后面再说。
2.2 数据库回调靠session找回上下文
假设你的业务服务用skynet.call(db_service, "lua", "query", sql)发起一条数据库查询。这条消息走到db_service,对方执行查询,然后把结果skynet.response回来。这里有个关键:db_service根本不知道这条查询是哪个服务发来的、发来干嘛的,它只负责执行SQL并原样返回结果。真正拼接逻辑、知道"该把结果存到哪个玩家的内存变量里"的,是发起方业务服务里的那个协程上下文。
那个协程上下文怎么被找回?靠的还是session。业务服务把session绑定到协程上,等到PTYPE_RESPONSE消息回来时,调度层从消息里解析出session,再查自己服务内部维护的session -> coroutine映射表,找到正在等待的那个协程,恢复它的执行并传入返回数据。
这套机制我建议所有项目组新人都自己手写一遍,哪怕只是玩具代码。你会深刻理解为什么skynet的lua层要维护一个session_id计数器,为什么要有一张映射表,为什么响应消息的session不能丢。很多云风给的lua API把这些隐藏得太好,反而让人对它的运行机制产生偏差。
2.3 内存泄漏高发区:消息的malloc与lua闭包
skynet的C层消息数据是用malloc分配的,消息到达Lua层后,Lua会把这部分数据拷贝成Lua字符串,然后C层再做skynet_free。如果你自己写C扩展服务,特别要注意:消息数据谁分配谁释放,这个责任要明确。凡是使用skynet.send发送的data指针,服务内部接管;凡是自己malloc后作为skynet_message传出去的数据,必须在合适的时机释放。
Lua层的内存管理还给了一个新手经常踩的坑:闭包引用导致消息数据永久无法回收。常见例子是注册回调函数时,不小心在回调外层又捕获了一个大table,而那个table里又保存了一堆临时消息数据,循环引用,GC跑多少轮都清理不掉。实际表现就是服务内存曲线持续上涨,而且只涨不跌。排查手段也简单:skynet.info命令可以看每个服务的消息队列长度和内存占用,配合collectgarbage("count")观察Lua堆大小。如果消息队列长度正常,但内存还在涨,那就多半是Lua层闭包引用泄漏而不是C层消息泄漏。
3. 消息调用链:从send到回调的全链路拆解
3.1 发送层:skynet.send 和 skynet.call 的差别不只是返回值
在skynet里,向另一个服务发消息主要有三个API:skynet.send、skynet.send的变体skynet.rawsend,以及skynet.call。它们核心差别就是"要不要等回包"。
skynet.send(target, proto, ...):最纯粹的单向火焰。把消息塞进目标服务的消息队列后,发送方立刻返回,不关心结果。适合日志、通知、广播这类不需要回执的场景。比如网关通知agent玩家下线,用send即可。
skynet.call(target, proto, ...):带等待的发送。底层通过生成一个session、挂起当前协程、登记等待项、直到收到PTYPE_RESPONSE消息后恢复协程。注意,挂起的是协程,不是ocall(C层的回调线程),所以效率上比单纯send多了一个session分配和协程切换的开销,但胜在写起来像同步逻辑,可读性极好。
skynet.rawsend(target, proto, msg, sz):这是更底层的发送API,参数是一个已经打包好的消息体。适合绕过lua的自动打包,直接发送原始字节。用一些高性能定长协议时会用到。
我实际项目里的建议是:服务内部通信优先用call,跨服消息优先用send。为什么?因为跨服消息往往要经过数据传输层,链路长、超时概率高,用call挂起协程容易造成agent服务协程堆积;而服务内部通信栈短,call的等待开销相对可控,还能直接拿到返回值。
3.2 调度层:工作线程如何"派单",为什么你的回调不能阻塞
理解skynet调度,你需要先放下"每个服务一个线程"的错觉。skynet的核心模型是:N个工作线程,M个服务,每个服务都有一个自己的消息队列,工作线程每轮循环里从"哪个服务队列不为空"的全局队列里取一个服务,然后从这个服务的队列里取其一条消息,调用服务注册的callback函数。消息处理完,回到全局队列,继续取下一个服务。
所以,一个服务在同一时间只会被一个工作线程执行,同一服务内的消息严格串行,不需要加锁。但也正因为如此,你在服务回调里做的一切耗时操作,都会阻塞这一条服务队列后面的所有消息。如果你在agent的dispatch里写了个os.execute,或者做了一段CPU密集的加密解密,同一agent上其他客户端的消息都会被卡住。
解决办法不是把回调写成异步,而是把重活拆出去。常见方案是:把CPU密集计算或第三方HTTP请求都放到独立的worker服务里去做,agent只负责转发请求和接收结果。比如AES加密,我会在项目里放一个crypto_service,里面支持同时跑几个协程处理任务,agent需要加密时直接call它,把敏感数据和密钥传过去,它返回加密结果。这样一个大计算就不会卡住agent的其他消息。
3.3 Lua层:消息从C结构到Lua table的翻译过程
现在来看消息怎么落地到Lua。不论消息来自网络层还是内部send,最终都会走到服务的callback函数。skynet里面是这样设计的:Lua层调用skynet.dispatch注册某种消息类型的处理函数,C层在收到消息后,根据消息的type找到对应的处理函数,把data指针和长度sz转换成一个Lua字符串,加上source和session两个参数一起传给注册的Lua函数。
如果是普通的内部字符串消息,C层会把它转换成Lua字符串传进去;如果是PTYPE_LUA类型,数据通常是打包过的Lua对象(通过lua-cjson、sproto序列化等),Lua层需要先解析再处理。这里有个性能细节:**每一层转换都要分配内存。**消息从网络收到是一个字节数组,到Lua层变成字符串,再被解析成一个table,这一路至少两三次拷贝。如果你在高频路径上(比如每帧同步位置),尽量使用定长二进制消息,避免JSON字符串的解析开销。实测下来,同样的位置同步数据,用sproto二进制协议比用JSON快了将近五倍,而且省掉了GC压力。
3.4 三个典型案例:消息乱序、丢失与回调错位
先说乱序。skynet保证同一个服务(同一个队列内)的消息按发送顺序执行,但不同服务之间没有全序保证。最常见的乱序场景是:agent先向mail_service发送"读取邮件列表",又发送"删除某封邮件",由于两个请求可能是不同协程发出的,邮件服务执行的先后就不保证符合你的预期。对策很简单:同一类操作的请求串行化,也就是一个协程处理完上一个请求再发下一个,不要同时并行发多个相关的写操作。
再说丢失。skynet本身不丢消息,但外部网络传输会丢。很多新人debug时发现某个客户端消息没收到,第一反应是怀疑skynet消息队列丢了消息。实际排查经验告诉我,90%的情况是消息根本没有到达skynet,因为TCP连接在网关那层就被drop了。网关服务接收到一条消息后要先做解包和合法性校验,如果包头不完整、校验失败,会直接丢弃而不是进入分发队列。所以在网关层加一个计数器和日志,很多疑难杂症会立刻暴露。
最后说回调错位。这个在用了skynet.call异步API但没处理好超时时尤其明显。比如数据库查询设置了3秒超时,2.9秒时数据库才返回结果,而你在2.8秒时已经超时放弃、并发起了另外一条新查询。此时旧的响应到达后会携带旧的session,你的等待表里已经没有对应的协程了,skynet就会打印there is no session with id并丢弃它。要避免这个,你在发起新查询前必须先清理掉旧的等待项,或者在超时后主动标记该session为"已过期"。
4. 外部服务的边界:position与skynet内置支持的姿势
4.1 先把"外部服务"这个词掰开
skynet这套框架本身是一个单进程的actor集群,所有skynet内跑的服务(agent、db、gate、logger)都在同一地址空间里协同。所谓"外部服务",我的理解是:不在skynet进程内部、通过某种IPC或网络协议和skynet通信的那部分系统。典型有:独立的账号服务器(HTTP API)、Redis、消息推送服务、监控系统、以及用其他语言写的算法或推荐服务。
为什么要拆分外部服务?大部分情况是因为:这个功能本身就不适合塞进actor模型里。比如一个需要长时间运行的耗时任务,如果放进skynet会阻塞某一类服务队列;又比如一个要被多个游戏区服共享的登录校验逻辑,放进单进程里会导致每个区服都得各写一份。
外部服务的最大价值是隔离,一个是故障隔离,另一个是资源隔离。你可以在外部服务里随便用线程池、阻塞IO、多进程,完全不用担心影响skynet的工作线程调度。
4.2 skynet自带的三种外部服务姿势:socket、gate、httpc
skynet内置支持不少外部通信方式,这里我重点讲自己实际用过的三种。
第一是skynet.socket。这是最基本的TCP/UDP连接封装,主要用来和外部服务建立长连接。内置的socket模块在Lua里用起来跟原生socket库类似,但要注意它也是事件驱动的:skynet.socket.start之后,需要注册skynet.socket.on_data之类的回调来接收数据。我项目里和Redis的通信就是用这个写的,直接给Redis服务发RESP协议命令,省去引入第三方C扩展库的麻烦。不过要注意一点:socket模块的收发是异步的,不能阻塞等数据。如果业务代码里直接写了一个循环等待socket数据,就会卡死整个服务。
第二是skynet.gate。gate是专门做网关的,它其实不算外部服务,而是skynet内部的一个服务模板。但我这里提它是因为:外部客户端连接进来,第一个打交道的一定是gate。gate主要负责TCP连接的接入、处理粘包、以及把连接映射到对应的agent服务。它天生支持分片、校验、CRC,新人对连接管理不熟悉时,直接用gate比自己手写socket层要省心得多。我之前接手过一个老项目,里面连接管理是手写的socket循环,粘包处理埋在纯C扩展里,调一次bug翻半天代码。后来迁移到gate之后,问题少了一个量级。
第三是skynet.httpc / httpd。这两个模块分别负责发HTTP请求和开HTTP服务。如果外部服务是一个标准的RESTful API,用skynet.httpc.get和skynet.httpc.post就够了。我经常拿这个把游戏服务端的部分接口直接暴露成HTTP API,方便运维同事做后台工具调用,比如发公告、踢人、查在线数。注意:httpc是异步实现,别在阻塞逻辑里直接期望它返回值。具体用法是它支持回调方式,也可以配合协程和skynet.call封装成同步等待,项目里建议统一封装一下。
下表总结一下我实际项目中用过的几种接入方式:
| 外部系统类型 | 推荐接入方式 | 注意事项 |
|---|---|---|
| Redis | skynet.socket 自定义RESP协议 / 第三方skynet插件 | 单连接多命令时注意命令与响应的顺序 |
| HTTP API | skynet.httpc | 必须设置超时,超时后要清理callback,防止泄漏 |
| MySQL | db_service内封装mysql驱动 | 查询操作放独立服务,避免阻塞其他业务 |
| 外部WebSocket服务 | skynet.websocket 模块 | 关注心跳与断线重连逻辑 |
| 内网RPC服务(如其他C++服务) | 自己定TCP二进制协议 | 推荐用sproto描述协议,省去手写解析 |
4.3 外部通信的序列化选择:sproto、protobuf还是JSON
最后说一下内外通信的序列化方案。这是所有接外部服务的人都会问的问题:到底用什么格式传数据?
我的建议分场景:
- 内外带宽紧张、延迟敏感(比如客户端同步消息、跨服消息):用sproto或者protobuf,二进制格式,体积小,解析快。
- 运营后台、日志采集、调试工具:用JSON,可读性好,方便排查问题,效率瓶颈不在这一层。
- 服务间RPC:优先考虑sproto。因为云风的sproto本身和skynet的Lua层配合得很好,类型定义直观,还能做字段版本兼容,加减字段不需要双方同时改代码。比protobuf省去编译生成代码那一步,调试起来方便太多。
我在项目里外部服务通信统一用sproto描述。字段变化时,双方只要同步更新.sproto文件,Lua服务端解析照旧,外部服务是Go语言就也维护一份自己的解析代码。缺点是生态相对小,如果你外部服务不是用Go/Lua,而是用Java,那还是老老实实用protobuf或HTTP+JSON更稳妥,毕竟生态里现成的库多。
最后分享一个经验:无论选哪种序列化,一定要在你整个链路里留一个明文打印的逃生舱。我的做法是,在db_service和外部HTTP服务的入口各留一个开关,临时打开后可以把入站消息以可读形式打日志,这样线上排查问题的时候至少不会两眼一抹黑。这个开关平常关着,需要的版本再开,成本极低,但能救命的次数你想象不到。