为什么 skynet 提供的包协议只用 2 个字节表示包长度

By | 2017-02-14

skynet 提供了一个 lua 库 netpack ,用来把 tcp 流中的数据解析成 长度 + 内容的包。虽然使用 skynet 的同学可以不使用它,而自己另外实现一套解析协议来解析外部 TCP 数据流(比如 skynet 中的 redis driver 解析 redis server 的数据流就是用的换行符分割包),但依然有很多同学询问,能不能自定义包头长度。

这里的这个库定义的协议中,包长度是用 big-endian 的 2 个字节表示的,也就是说一个包的长度不得超过 64K 。这让很多人很为难。已经几次有同学建议,把长度放宽成 4 个字节,因为他们的应用环境中大部分包不会超过 64K ,但总有少量超过这个限制的。

历史上,skynet 的 gate 还是用 C 实现的时候(那个版本依然可以使用)的确可以自定义是使用 2 个字节还是 4 个字节表示包长。但经过一番考虑,我还是去掉了这个选择。

一个好的库,应该简洁,且引导使用者用正确的方法做正确的事情;而不应该提供让用户犯错的机会。在和游戏客户端通讯的时候,如果你只采用一个 TCP 连接,那么允许很长的数据包本身就是错误的。甚至 64K 都太大。

游戏通常需要比较快的响应速度,如果你允许在单个 TCP 连接中插入一个太大的数据块,比如 100K ,那么在比较弱的网络条件下(例如手机网络),处理这个包可能就需要超过 1 分钟的时间。而这么大的数据块,在业务逻辑上大多不期待立刻能发出或收到的。一个典型的应用场景就是用户在拍卖行中查询所有的上架物品,如果把所有返回数据都放在一个数据包中,很容易就变得很大。而查询大量这个操作,用户本身就对立刻回应没有期待。

而在单个 TCP 连接上,这样一个大数据块会阻塞住整个信道,后面本来需要快速送到的数据全部被延迟了。

如果你想在业务层做一个心跳包检测网络是否超时,很容易就把心跳包拦在后面。而通常网络处理层不会提供接口让业务层探知是否正在接受数据(skynet 的 gateserver 就没有提供这样的接口,虽然它很容易提供,只需要修改几行 lua 代码),只能在一个完整的数据包收到后才会交给业务层处理。

正确的做法是,在长度+内容这个协议上再加一个层次。加一条协议叫大数据块,允许把一个大数据块分几段发送。可以在这条协议中加上数据块 id ,在后面引用这个数据块的包中附上数据块 id 即可。

为什么要用这种比较绕的方式,而不直接把包头从 2 字节改成 4 字节?当你做这个设计时,就已经表明你重视了上面提到的问题。


当你把数据包都分割的比较小时,才能实现单个 TCP 连接上承载多个信道的能力。对于网络游戏,并不是所有的数据包都是上下文相关的,你可以看成隐含着有多条线索。比如聊天频道的信息和场景同步的信息就是相互独立的。skynet 为这种场景还提供了额外的 socket api 支持。socket.lwrite 可以把一个字符串(一个数据包)写到低优先级通道。只有等默认通道(高优先级)的包全部发送完后,低优先级通道上的包才至少被发送一个(单个包可以保证原子性)。

比如,你可以用它来发送聊天信息,就不会因为聊天信息泛滥把其它重要数据包都塞住。同样,你可以用来发送被分割后的大数据块。如果同时你还有很多其它重要的数据需要传输给客户端,那么这些数据块就会被打散穿插在其间。

当然,你也可以把所有给客户端的数据全部用 lwrite 发送,而仅仅把心跳包放在常规高优先级通道,可以保证心跳频率更稳定。


另外,采用 4 字节的包长度还有一个安全漏洞,可能被攻击利用。

一般的分割包的代码,在收到包头时,都会根据长度信息预先分配出相当长的空间,等着后面的数据达到后填入。如果攻击者不断在新建立的连接上发送一个恶意的长度信息,比如 2G ,服务器内存很容易被快速消耗光。

早期 skynet 的 gate 实现时,采用的是共享一个固定长度的 ringbuffer 的实现,可以避免这种攻击。但新的版本由于不再允许 4 字节长度,就没有做特别处理了。

如果你的应用环境非常特殊,坚持一个允许更大长度的数据包协议。那么我建议你慎重的实现一个分包模块,而不是简单的把 netpack 库中的 2 改成 4 。