一. 前言
2016年6月17日凌晨5点钟,我们完成了服务器端V3版本的重构,切换的过程十分平滑且没有对线上用户产生任何影响。
这也正式标志着,我们的游戏服务器进入了一个全新的阶段。
我们上一次的重构是在 2014年12月23日,现在看看,时间过的真快啊。
而熟悉我的人应该知道,我特意为上一次重构写过一篇《游戏服务器端架构升级之路》,其中详细的讲述了我们游戏服务器从农业时代跨越到工业时代的历程。
而这次V3版本的重构,我将其定义为第二次工业革命。也许它没有那么的强大和完美,但是他切实的解决了现存的大部分问题。
二. 背景
之前的文章已经说过,V2版本的服务器的几个优点:
- 支持服务器代码热更新而不影响外网服务
- 架构模式足够简单:push-pull
但是,其简单的架构也存在一些缺点:
-
业务模块之间容易互相影响
比如两个游戏玩法 A 和 B,内部使用的逻辑、存储服务器都完全不同,但是在worker层却是共用的。
所以一旦玩法A的服务器出现问题导致处理变慢,那么worker就会被堵住,而玩法B也会跟着遭殃。
同时,即时在一个业务模块内,也存在请求处理优先级的问题,比如拉取牌局记录和跟注,要尽量避免跟注这种核心逻辑受到影响。
-
限制了游戏逻辑的实现方式
V2的多worker的模式,导致worker必须限制为无状态的,因为worker可能处理任何一个请求,而一个请求也可能被分配到任何一个worker上。
这一点是之前解决服务器热重启的关键,但同时也限制了我们代码逻辑多样性的实现。
比如我们的游戏桌子数据是存储在redis中,所有人对桌子的写操作可能同时分配到多个worker上,而为了避免写冲突,我们不得不通过redis来实现分布式锁。
但是这种分布式锁的代价是很大的,一是增加了很多无用的查询和修改请求,二是由于redis的分布式锁并无法实现先请求先分配的逻辑,所以有可能导致有些请求会被锁很久。
而如果我们有办法让所以对于某个桌子的请求,都路由到某个进程的话,那么就不会再有锁的问题了。
但在V2的架构下,这个是完全没法操作的。
三. V3实现
针对上面提到的问题,我可以说是冥思苦想了很久,问题的关键点在于:
- 要解决上面提到的几个问题
- 要保持server支持热重启的特性
- 要架构足够简单,不要增加研发成本
- 要消息处理路径不能太长,防止变慢
而最终的最终,我才确定下来如下的方案:
好吧,原谅我是用vim直接画的。
这里详细说明下:
broker
其实broker就是原来的worker,只是功能完全变成了路由的作用。负责将请求转发到对应的处理server上面去。
broker与server之间使用tcp连接,broker不关心也不等待server的响应。
server
server则是真正进行真正的业务处理,比如我们现在线上业务大体分为: auth_server(登录), play_server(打牌), hall_server(大厅), common_server(其他)。
这样不同的业务逻辑在部署上就完全分离了,而由于broker本身是不阻塞的,所以即时auth_server变慢,也不会影响到play_server了。
而每一个server内部,是支持消息分组的,每个分组单独一个消息队列,只要将不同优先级的命令字分到不同的分组中去,就不用担心阻塞了。
trigger
trigger在V2时代就存在,只是当时他的角色还没有那么重要,因为worker本身是可以直接和gateway进行交互的,所以trigger当时只是在非worker中进行使用(比如web层)。
而在V3中,trigger变成了一个关键角色,因为broker(即原来的worker)不再处理业务逻辑,而server本身与gateway之间是没有连接的,所以trigger就变成了server与gateway通信的唯一桥梁。
OK,现在我们看看到了这一步,我们已经解决了哪些问题。
- 业务模块之间容易互相影响 YES
- 限制了游戏逻辑的实现方式 NO
- 要保持server支持热重启的特性 NO
- 要架构足够简单,不要增加研发成本 YES
- 要消息处理路径不能太长,防止变慢 YES
我们来看看没有完成的两个问题:
要保持server支持热重启的特性
-
server本身的实现是支持 kill -HUP 的,能够优雅的重启server内的worker进程。
需要特殊说明一下的是,由于业务逻辑的复杂性,一个新worker启动时需要导入的模块太多,从而导致worker的启动极慢,尤其当十几个worker同时启动时更是如此。
所以我们做了一个优化,当worker启动后,直到完全启动成功前,不会替换现有的worker来分配任务。当所有模块都导入结束之后,再进入替换worker的逻辑。
这样的逻辑是极有必要的,因为之前gateway对应的worker是无状态无差别的,所以当一组worker启动很慢时,可能还有另一个组worker可以帮助处理业务。
而server内的worker是分组的,一旦worker启动很慢导致消息被堵住,那么是没有其他worker可以帮忙分担的。
-
一旦需要重启整个server而不只是server的worker进程时,对于不同的server我们可以在broker中配置不同的处理方案。
比如play_server的重启,我们可以要求broker必须等待到连接正常建立,并发送成功后再处理下一个消息;而auth_server的请求比较低,那么就可以直接重启,broker发现连接断开后尝试连接一次,再失败就直接报错。
在保证了上面的两种情况之后,我们基本可以保证业务的热重启了。
限制了游戏逻辑的实现方式
其实在引入了server的模式后,业务开发想使用单个进程做本地存储已经不再受限制了。
我们重点来说一下给桌子废弃掉锁的问题。
- 我们会给play_server启动多个分组,每个分组内一个worker。
- broker层在给play_server做转发的时候,会将用户所在桌子ID取出来放在包头中
- server在收到请求后,会把消息通过取模或者其他方式路由到对应的worker上。由于一个桌子的所有消息都会被路由到一个固定的worker上,所以锁就可以彻底废弃了。
而进桌、换桌、挑战等特殊逻辑的实现会比较复杂,需要将消息做内部的转发处理。不过其实方式是一样的。
四. 可能的问题
-
消息的路由可能不均匀,比如对于打牌消息的路由,在桌子数比较少的情况下,每个worker的繁忙程度很可能是不一样的。
如果要解决的话,要在路由函数那里记录每个worker的繁忙程度。
目前解决的优先级并不高
五. 总结
基本上V3相关的介绍就在这里了。
正如我文章开头说的,也许V3的重构不是最强大和最完美的,但是他切实的解决了我们目前的问题。
我始终相信无论服务器端还是客户端的架构,都是要不断演进的。
而每一次在实践中摸索出来的重构,都会是架构的一次升华。
胡少 on #
不开源吗
Reply
lizhaochao on #
pypi上都开源了吧
Reply
高一平 on #
期望贴上架构图。
Reply