skynet cluster 模块的设计与编码协议
skynet 在最初的设计里,希望做一个分布式系统,抹平 actor 放在本机和处于网络两端的差别。所以,设计了 master/slave 模式。利用 4 个字节表示 actor 的地址,其高 8 位是节点编号,低 24 位是进程(节点)内的 id 。这样,在同一个系统中,不管处于哪个进程下,每个 actor (在 skynet 中被成为服务)都有唯一的地址。在投递消息时,无需关心目的地是在同一个进程内,还是通过网络来投递消息。
随后,我发现试图抹平网络和本地差异的想法不那么靠谱。想把一个分布式系统做得(和单一进程同样)可靠,无论如何都简单不了。而 skynet 的核心希望可以保持简单稳定。所以我打算把分布式的支持放在稍上一点的层次实现。
先来说说同一进程下的服务通讯和跨网络的通讯到底有什么不同。
进程内的内存是共享的,skynet 是用 lua 沙盒来隔离服务状态,但是可以通过 C 库来绕过沙盒直接沟通。如果一个服务生产了大量数据,想传给您一个服务消费,在同一进程下,是不必经过序列化过程,而只需要通过消息传递内存地址指针即可。这个优化存在 O(1) 和 O(n) 的性能差别,不可以无视。
同一进程内的服务从底层角度来说,是同生共死的。Lua 的沙盒可以确保业务错误能够被正确捕获,而非常规代码不可控的错误,比如断电、网络中断,不会破坏掉系统的一部分而另一部分正常工作。所以,如果两个 actor 你确定在同一进程内,那么你可以像写常规程序那样有一个共识:如果我这个 actor 可以正常工作,那么对端协作的另一个 actor 也一样在正常工作。就等同于,我这个函数在运行,我当然可以放心的调用进程内的另一个函数,你不会担心调用函数不存在,也不会担心它永远不返回或是收不到你的调用。这也是为什么我们不必为同一进程内的服务间 RPC 设计超时的机制。不用考虑对方不相应你的情况,可以极大的简化编写程序的人的心智负担。比如,常规程序中,就没有(非 IO 处理的)程序库的 API 会在调用接口上提供一个超时参数。
同一进程内所有服务间的通讯公平共享了同一内存总线的带宽。这个带宽很大,和 CPU 的处理速度是匹配的。可以基本不考虑正常业务下的服务过载问题。也就是说,大部分情况下,一个服务能生产数据的速度不太会超过另一个服务能消费数据的速度。这种情况会造成消费数据的服务过载,是我们使用 skynet 框架这几年来 bug 出现最多的类型。而跨越网络时,不仅会因为生产速度和消费速度不匹配造成过载,更会因为传递数据的带宽和生产速度不匹配而过载。如果让开发者时刻去考虑,这些数据是投递到本地、那些数据是投递到网络,那么已经违背了抹平本地和网络差异这点设计初衷。
所以我认为,除非你的业务本来就是偏重 IO 的,也就是你根本不打算利用单台硬件的多核心优势来增强计算力,抹平本机和网络的差异是没有意义的。无论硬件怎样发展,你都不可能看到主板上的总线带宽和 TCP 网络的带宽工作在同一数量级的那一天,因为这是物理基本规律决定的。
当你的业务需要高计算力,把 actor 放在一台机器上才可以正常的发挥 CPU 能力去合作;如果你的系统又需要分布式扩展,那么一定是有很多组独立无关的业务可以平行处理。这两类工作必须由构架系统的人自己想清楚,规划好怎么部署这些 actor ,而不可能随手把 actor 扔在分布式系统中,随便挑台硬件运行就够了。
恰巧网络游戏服务就是这种业务类型。多组服务器、多个游戏场景之间交互很弱,但其中的个体又需要很强的计算力。这就是 skynet 切合的应用场景。
我在 skynet 的核心层之上,设计了 cluster 模块。它大部分用 lua 编写,只有通讯协议处理的部分涉及一个很小的 C 模块。用 Lua 编写可以提高系统的可维护性,和网络通讯的带宽相比,Lua 相对 C 在处理数据包的性能降低是微不足道的。
它的工作原理是这样的:
在每个 skynet 节点(单个进程)内,启动一个叫 clusterd 的服务。所有需要跨进程的消息投递都先把消息投递到这个服务上,再由它来转发到网络。
在 cluster 集群中的每个节点都使用一个字符串来命名,由一个配置表来把名字关联到 ip 地址和端口上。理论上同一个 skynet 进程可以监听多个消息入口,只要用名字区分开,绑定在不同的端口就可以了。
为了和本地消息做区分,cluster 提供了单独的库及一组新的 API ,这个库是对 clusterd 服务通讯的浅封装。当然,也允许建立一个代理服务,把代理服务它收到的消息,绑上指定名字,转发到 clusterd 。这样就和之前的 master/slave 模式几乎没有区别了。
在 skynet 1.0 版中,是不支持 cluster.send 的。也就是不能直接向 cluster 单向推送消息,而必须使用 cluster.call 也就是请求回应方式。这么做是考虑以下几个因素:
推送实质上是可以用请求回应替代的,因为你可以简单忽略掉回应。如果担心 call 操作阻塞住执行流程,那么可以使用 skynet.fork 来调用 cluster.call ,或是另外写一个服务来代理推送。即,向这个服务 send 消息,让代理服务来阻塞调用。
cluster 有一个显式的网络过程,我希望使用者可以明白这一点,而不是把它藏起来。对于网络推送,就必然存在失败的可能:对方节点宕机、tcp 连接意外断开(这在云主机上尤其常见)都有可能发送不到,所以必须有一个回应机制来反馈这种异常。固然可以实现一套可靠的不丢消息的消息队列,但我认为应该在更高的层次来做,否则为保证消息队列的可靠性而做出的性能损失以及实现复杂度的提升是不太划算的。而 cluster.call 则可以直接抛出 error 通知调用者失败,用的人清晰了解这种失败的可能性,迫使用的时候做出失败处理逻辑。而 cluster.send 从 api 设计上就无法留这个余地。
但近一年的实践表明,无论是我们自己公司的人使用、还是第三方 skynet 用户,都迫切的需要 cluster.send 语义,这使我对上面的想法做出妥协,打算在 skynet 1.1 中正式引入 cluster.send 。
最初的实现比较随意。打算在原有基础上打个小补丁,尽量对老代码和流程不做修改。所以我扩展了 skynet.ret 的实现,让它可以根据消息的 session 是否为 0 来判断是不是一个单向推送(send),如果不需要返回则什么都不做。这样,只要约定,任何消息都写上 skynet.ret 回应,由框架来决定是否真的要回应就好了。这样,cluster.send 依旧利用 cluster.call 去请求,对方对于跨节点的推送也可以做出回应。发起请求方再把回应包扔掉即可。
换句话说,cluster 间的通讯协议并没有修改,无论是 cluster.call 还是 cluster.send 都是发起的一样的网络请求,都要求对方回应。一个服务在响应本地推送消息时,调用 skynet.ret 会直接忽略;而响应网络推送时,是经由 clusterd 转发过来的请求,(由于协议限制,clusterd 无法区分是一个推送还是一个请求),skynet.ret 则做出一个回应。最后回应包会转回发起方时被扔掉。
最开始用这个模式来使用 cluster.send 的同学,由于了解整个历史,所以理解并接受这个用法。但新同学还是容易漏掉这处细节。skynet.send 和 cluster.send 的这个小区别确实挺让人意外的,我也不认为在文档中写清楚就够了,毕竟大部分人是不读文档的。为了遵循最小意外原则,我打算修正 cluster 通讯协议,明确区分 send 和 call 的网络包。
下面记录一下 cluster 通讯协议编码,以备日后查阅。通过了解编码协议,也能理解为什么在 cluster 通讯层次上,光靠判断 session 是否为 0 来区分推送和请求包是不够的。
在 cluster 间的一条 TCP 连接上,两端是严格分为请求方和回应方。比如 A 发起连接到 B ,那么只能是 A 向 B 提出请求,B 回应它;如果 B 想向 A 发起请求的话,需要由 B 向 A 再建立一条通道。
请求协议是这样编码的:
首先是两个字节的包头,高位在第一个字节、低位在第二个字节。采用大端编码是和 skynet 的 gate 模块一致。
第三个字节是一个 type ,可以区分这是一个小的完整包,还是一个大包的一部分;请求地址是 32bit id 还是一个字符串。
type 为 0 ,表示这是一个不超过 32K 字节的完整包,目的地址是一个 32bit id 紧跟在后面第 4 到 7 字节。接下来 8 到 11 字节是 session 号;如果 session 为 0 表示这是一个推送包,不需要回应;否则需要根据 session 来匹配回应包。12 字节开始就是包的内容。
type 为 1 或 0x41 ,表示这是一个超过 32K 字节的请求(1)或推送(0x41),这个包本身没有数据内容,只有地址、sesson 和包的总长度。分别以此编码在后面 12 字节内。其中长度采用的是小端编码的 32bit 数字。
type 为 0x80 ,和 0 类似,但地址是一个字符串而不是 32bit 的 id 。字符串最长不超过 255 字节,用一个字节的长度加字符串内容编码在原来 4 字节 id 的位置。
type 为 0x81 或 0xc1 时,对应 1 和 0x41 ,只是把地址从 id 换为字符串。
type 为 2 或 3 时,表示这是一个长消息中的一部分。包内容为 4 字节的 session 和具体的数据。 2 表示还有后续的数据,3 表示这是最后一段数据。
回应的编码协议简单一些:
WORD size (big endian) DWORD session BYTE type 0: error 1: ok 2: multi begin 3: multi part 4: multi end PADDING msg type == 0, error msg type == 1, msg type == 2, DWORD size type == 3/4, msg
这里,超过 32K 的数据包需要分成小包传输,而分开的小包依赖 session 来区隔,所以必须让每组长消息都有唯一 session 。这样就必须额外在 type 字段来区分是请求还是推送。
Comments
Posted by: 呵呵 | (7) May 10, 2017 01:37 PM
Posted by: starrydb | (6) April 5, 2017 10:17 AM
Posted by: starrydb | (5) April 5, 2017 10:13 AM
Posted by: heibor | (4) April 2, 2017 11:19 PM
Posted by: abvic | (3) April 1, 2017 09:30 PM
Posted by: Cloud | (2) April 1, 2017 11:47 AM
Posted by: abvic | (1) March 31, 2017 10:28 PM