服务器的分布式部署

两周前使用redis-benchmark测了一下silly的并发请求响应速度, 开1000个客户端, 平均大概每秒能响应6W个请求。
但是这些请求仅仅是PING/PONG协议,也就是说这基本上就是纯网络I/O的性能,如果考虑到逻辑处理、数据压缩、加密、解密等时间, 可能一个silly都不一定能够撑得起5000人的访问量。

所以最近两周除了在研究redis之外, 就是在研究怎么给silly增加cluster支持,以便可以将计算分摊到不同的计算机上来降低客户端的请求的响应延迟,silly应该怎么增加对cluster的支持。

直到昨天下班的路上我才想通,其实对于silly的模型来讲,他的任务应该相当的KISS,仅仅只是用coroutine的方式来处理数据即可。分布式功能应该在应用层实现,并不需要silly做额外的支持。


首先给定这样一个命题:
所有玩家在同一张地图上游戏,一台物理server能支持5000人的并发量,那么如何才能支撑百万级的玩家在同一张地图上游戏。

首先将服务器分为auth_server, channel_server, scene_server, ctrl_server这四种服务器。

整个服务器集群由一个auth_server、一个ctrl_server、若干channel_server、若干scene_server组成。

所有server在启动时主动向ctrl_server建立一条socket链接, 以便可以定时向ctrl_server汇报和查询状态(如当前负载情况等),另外在停服时可以直接操作ctrl_server, 然后ctrl_server通过建立的socket连接来通知所有server停服,这样可以避免人工操作出现的各种差错。

auth_server服务器的作用仅仅用来做用户认证,和负载均衡。在客户端用户登陆验证正确后,auth_server根据向ctrl_server查询到的所有channel_server的负载状态,挑选出各适的channel_server服务器的ip及端口号,然后将其和临时通行证一并发给客户端。 当客户端得到临时通行证和相应的channel_server地址/端口号之后,随即与auth_server断开连接以便auth_server可以有更多的资源处理其他客户的认证请求。

每一个scene_server管理地图上的一部分场景,并用来处理这块游戏场景内的游戏逻辑,这样所有的scene_server组合起来的集群就是一张大的地图,和完整的游戏逻辑。

每一个channel_server与所有的scene_server相连接,并将每一个scene_server负责的区域信息与此scene_server的socket连接做hash映射,每一个客户端只会与一个channel_server相连接。

channel_server所做的事仅仅是根据当前客户端的人物的位置,自行转发到不同的scene_server服务器中去, 具体逻辑由scene_server进行处理。 当客户端在地图中行走时,如果channel_server判断超出了当前的scene_server的范围, 则根据客户端的当前位置从映射表中查出当前区域信息所在的scene_server的socket链接, 并将数据转到的新的scene_server服务器中去。

这样看来只需要一个常量的代价(数据被中转一次,hash查询),channel_server和scene_server几乎可以无限扩展,那么整个逻辑的响应速度也会是常量。惟一受限的将会是auth_server的响应速度。

ps.集群的思想参考自redis的集群,由于我并没有参与过RPG游戏开发,因此上述观点均为理论推测。

SIGPIPE信号

昨天夜里实现了按行来切为数据包的协议, 今天就迫不及待使用resdis-benchmark来测试一下silly的性能。

结果发现到100000个请求左右时就有一定机率程序会默默的退出, 使用gdb看了一下, 是因为向socket写入数据时触发了SIGPIPE信号。

google了一下发现,当向一个已经关闭的socket第二次写入数据时,就会触发SIGPIPE。

但是我把所有的_socket_close都加了打印也没有发现问题。

最后偶然间发现,如果一个socket被对方关闭, 那么第一次write函数不一定返回出错, 但是第二次write却一定会触发SIGPIPE, 而SIGPIPE的默认行为则是退出运行。

假设Client和Server按如下顺序运行则会出现上述情况:
Client connect
Server accept
Client send
Server recv and process data
Client close
Server write result(此时write有一定机率依然会返回写入数据的长度,并不报错)
Server write result2(此时即会触发SIGPIPE信号, 导致程序退出)

因此在写socket程序, 如无特殊需要一定要使用signal(SIGPIPE, SIG_IGN)来忽略SIGPIPE信号, 不然这会是一个大坑。

silly的socket模块重构

最初, 我仅仅最只想将silly实现成一个socket异步框架, 每一个socket有数据或事件过来直接将注册的处理函数异步回调即可。

然后, 随着三国杀的一步步实现, 我发现我之前考虑处理时漏掉了数据库环节。

由于所有socket事件均为异步, 当一个client发过来一个请求, 而这个请求需要使用到数据库数据时, 可能就会写出类似下面这样的代码:

socket.recv(fd,function(fd, data)
--process segment1
db.get(key, function(value)
--process segment2
end)
end)

这种异步代码写起来非常费劲, 而且如果通信协议设计不好, 当–process segment2还没有被执行, 下一条socket的数据包又过来, 就有可能造成错误的结果, 而且不容易发现问题所在。

这次的重构,我希望可以在上层屏蔽掉底层的异步逻辑,这样代码写起来会更清晰不容易出错。


大概设计是这样的。

每一个socket在建立链接之后, 便拥有一个coroutine, 和一个数据队列。
每一个socket数据包过来之后均使用这个socket的coroutine来处理,如果coroutine在处理数据包过程中挂起,则将数据推入此socket的数据队列中.
每个socket的coroutine恢复并处理完上一个数据包后,会检查队列是否为空, 如果队列为空则yield等待下一下数据包的到来, 否则将消耗完数据队列中的数据。

由于socket/tcp具有fifo的特性, 如果目标数据库服务器的request/response同样是按照fifo方式处理的话, 就可以实现一个socketfifo模块(如果是其他方式,只要对sockefifo稍加修改即可)。

socketfifo模块用来在每一个socket的coroutine中管理数据库socket的命令/应答,在向数据库socket发送命令后阻塞此socket的coroutine, 直到获取到数据库socket发过来的数据。

socketfifo持有一个coroutine队列。

socketfifo向数据库socket发送命令之后, 将当前的coroutine(即调用socketfifo模块的socket所在的coroutine)塞入coroutine队列中, 然后挂起当前的coroutine。
在socketfifo收到数据后,按照fifo的特性,从coroutine队列中取出一个coroutine将其唤醒并将数据返回给此coroutine。这样即可达到阻塞访问socket的目的。

虽然用同样的方法实现了阻塞connect, 但如果connect函数并非是在coroutine中调用就需要手动创建一个coroutine来将connect函数包住。


在重构过程中发送一个关于客户端主动close事件的bug。

抽象上看, 整个silly是通过两个通道来运行起来的。

socket –> worker (silly_queue) 主要向worker通知客户端的行为事件
worker –> socket(pipe) 主要向socket发送操作命令

最初设计时, 当客户端断开连接时, socket模块随即就释放所有资源,但是此时有可能silly_queue中还有此socket的数据包。
而socket模块中是有链接池存在的, 当某个socket被释放回链接池之后, 是有可能被分配给其他client的。

假设有两个socket, s1, s2。
在极端的情况下, 当s1主动断开链接时, 恰好worker还有s1的数据没有处理完,因此worker还不知道s1已经断开了,但此时s1所占用的struct conn结构体已经被释放到链接池了。
此时s2连接到服务器,恰好将struct conn分配给s2使用。

由于silly分配的socket id的特殊性,虽然s1和s1拥有不同的socket id, 但是他们都能索引到同一个struct conn结构体,如果此时处理s1数据包的函数需要向s1发送数据, 此时其实是向s2发送的数据包, 就会产生数据错乱的情况。

因此修改了一下socket关闭的策略, 当检测到client关闭socket之后,释放除struct conn结构体之后的所有资源,然后将struct conn置成一个特殊状态,直到worker收到client的关闭消息后再调用close函数,将struct conn归还给链接池。

在worker在主动关闭socket时,同样存在另外一个队列缓存问题, 当worker向socket模块发送close命令时, 并不知道silly_queue中是否还有此socket的数据,那么socket.lua模块就不知道应该什么时间释放与此socket有关的资源, 过早的释放资源容易引起其他问题。

因此在worker向socket模块发送shutdown(worker主动关闭socket命令定义为shutdown, 与tcp的shutdown无关)之后, socket模块释放除struct conn结构体之外的所有资源, 其后将struct conn置成一特殊状态,向worker回一条SILLY_SOCKET_SHUTDOWN消息, 当worker处理SILLY_SOCKET_SHUTDOWN消息时, 肯定已经消耗掉silly_queue中的与此socket有关的数据了。

此时socket.lua可以释放与此socket有关的资源,并调用close命令将struct conn结构体归还给链接池。

btw, 将多进程改进为多线程时,我曾以为可以躲避通信的复杂度, 现在看来, 当时想的太少了。 只要按照多进程的设计思路(使用队列通信), 不管是否真的是多进程, 异步通信协议的处理是必不可少的。