skynet 版的 cache server 引出的一点改进
我们自己做的 cache server 已经工作了很长时间了。上次出问题是在 2 月在家工作期间。
这个月又出了一起事故,依旧是 OOM 导致的崩溃。一开始,我百思不得其解,感觉上次已经处理完了所有极限情况,按道理,这是个重 IO 而轻内存的业务,不太可能出现 OOM 的。
通过增加一些 log 以及事后的分析,我才理解了问题。并对应做了修改。
上次的问题出在服务器发送的过载。即,服务器在发送大量数据时,发送速度超过了对端可以接收的速度,然后导致了数据在 skynet 底层积压。这是 skynet 设计之初的历史原因造成的。
skynet 在设计的时候做了如下的假设:
- skynet 处理的业务以交互通讯为主,不会有太多的下行数据。
- skynet 允许在多个服务中向同一个 socket 发送数据,底层保证每次发送的数据是原子的,即 A 服务发送的数据块不会被 B 服务发送的数据穿插打断。
- skynet 的发送数据 api 是非阻塞的,调用即成功,不会挂起等待而引起服务消息重入问题。
第二点的由来是因为最初希望把游戏中的聊天信息和游戏逻辑信息完全分离到不同的服务中去处理,而不需要让数据汇总到一个服务中发出。后来感觉这个优化并不那么重要,但功能依然保留了下来。
第三点其实是为了满足 2 而衍生的。同时想和 skynet 服务内部消息 api 保持一致:send 消息本身不应被阻塞。
结果,skynet 的 socket api 就变得和系统 api 行为不一致。tcp 协议中,发送数据会和对端接收者协商,不会一味的推送数据;而 skynet 则会把数据堆积在底层,远远超过系统给的 buffer 。
因为有第一点的假设,绝大多数时候问题并不会显露。
但是、一旦让 skynet 充当文件服务器类似的业务时,我们就需要仔细考量。二月份的问题的解决利用了后来补丁上的的一个 warning 特性,利用 skynet 消息来通知发送缓冲区过大,从上层主动控制发送速度。
但这并没有完全解决问题。
skynet 在设计的时候,客户端上行数据是无限制的由 socket 线程转发到服务的。这个设计基于一个前提:服务的处理能力一定大于数据上行能力。对于游戏服务的场景,这总能满足。即使对于文件传输服务,也通常不是问题:磁盘 IO 速度总能大于网络速度。如果只是简单的上传文件,应该是不会因为 IO 处理过慢,把网络 buffer 撑爆的。
但这次却出了问题。
这个问题部分和 unity 的 cache server 协议设计有关。cache server 的协议中是没有 session 的概念的,每个请求依持续回应。(btw, cache server 的 上传文件请求是没有设计回应的 。我认为这是个草率的设计决定,很容易造成不合理的服务器/客户端实现。)
假设客户端先请求了一个极大的文件,那么服务器会满足它的请求,逐步把文件发送出去。这个过程仅由二月份的完善,可以做到不在 skynet 底层堆积发送队列。但是,我却疏忽了一点:TCP 实质上是双工的,上行和下行完全独立,可以互不干扰。在客户端慢慢接收数据(数据下行)的同时,它还可以同步的提交后续的请求(数据上行)。如果在此同时,客户端企图上传一个巨大的文件,那么由于协议需要严格遵守时序,处理服务没能在发送数据完成之前去处理后面这个请求,最终导致了上行数据堆积。
我们的 cache server 实现,针对这个问题做了一些修改:
之前简单的
while true do local req = get_request(fd) put_response(fd, req) end
这样的逻辑是行不通的。需要把这里的一个连接一个处理序改成上下行分开两个,跑在两个 coroutine 中;一个管上行、一个管下行。即,put_response
应该投递到另一个 coroutine 中去处理。只有这样,发送回应(可能是下发一个大文件)的过程中遇到带宽限制而造成的等待,才不会影响 get_request
的处理。
即:get_request
和 put_response
过程都可能发生阻塞而等待。前者的等待不应该影响后者的处理过程,反之亦然。
但即使这样,也无法完全消除 OOM 的可能性。因为,上行的请求 q1,q2,q3,q4 ... 会导致服务器产生对应的回应 r1,r2,r3,r4 .... ;它们必须按次序完成。如果 r1 处理过程很长(当 q1 是一个大文件请求时),q2, q3 q4 .... 这些后续请求本身必须放在内存队列中(只是不需要立刻计算出 r2, r3, r4 ...)。这个队列一样有潜在的 OOM 的威胁。
所以完全解决这个问题,必须给 skynet 的 socket 底层增加一个流量控制机制。让上层可以通知 socket 线程暂停接收数据,让 TCP 协议本身阻塞住通讯,这样客户端才不会一味的推送数据。(过去的 skynet 只做了一些简单的保护措施,一旦发送堆积就断开连接,但这个粗暴的办法不适合这个场景。因为我们无法修改 Unity 的客户端实现来做相应的配合)
我在最近的一个 commit 中增加了这样的控制指令,并由 socket 模块自动触发。一旦一个服务收到太多的数据,而业务层不去处理,就会触发这个机制。
最后提一下。这次问题的暴露源于我们在广州给上海的开发团队架设了一个 cache server ,让他们可以直接使用。原来以为 cache server 这种设计在局域网内,需要高带宽低延迟的 cache 服务不适合架设在公网上,尤其是跨城市的这种;但实际用了几个月似乎也没什么不适。只是复杂的网络环境多暴露了一些软件实现问题。