但是,数据服务提供了更强大的抽象能力。现在数据服务的API结构是任意定制的、code first,而且数据服务依赖的基础设施——redis又被证明非常强大,不仅仅是性能极佳,而且提供了多种数据结构抽象。那么,数据服务是否可以维护其他服务的状态?
在web开发中,用缓存维护服务状态是一种很常规的开发思路。而在游戏服务端开发中,由于场景服务的存在,这种思路通常并不靠谱。
为什么要用缓存维护服务状态?
考虑这样一个问题:如果服务的状态维护在服务进程中,那么服务进程挂掉,状态就不存在了。而对于我们来说,服务的状态是比服务进程本身更加重要的——因为进程挂了可以赶紧重启,哪怕耽误个1、2s,但是状态没了却意味着这个服务在整个分布式服务端中所处的全局一致性已经不正确了,即使瞬间就重启好了也没用。
那么为了让服务进程挂掉时不会导致服务状态丢掉,只要分离服务进程的生命周期和服务状态的生命周期就可以了。
将进程和状态的生命周期分离带来的另一个好处就是让这类服务的横向扩展成本降到最低。
比较简单的分离方法是将服务状态维护在共享内存里——事实上很多项目也确实是这样做的。但是这种做法扩展性不强,比如很难跨物理机,而且共享内存就这样一个文件安全性很难保障。
我们可以将服务状态存放在外部设施中,比如数据服务。
这种可以将状态存放在外部设施的服务就是无状态服务(stateless
service)。而与之对应的,场景服务这种状态需要在进程内维护的就是有状态服务(stateful
service)。
有时候跟只接触过游戏服务端开发的业务狗谈起无状态服务,对方竟然会产生 一种“无状态服务是为了解决游戏断线重连的吧”这种论点,真的很哭笑不得。断线重连在游戏开发中固然是大坑之一,但是解决方案从来都跟有无状态毫无关系, 无状态服务毕竟是服务而不是客户端。如果真的能实现一个无状态游戏客户端,那真的是能直接解决坑人无数的断线重连问题。
无状态游戏客户端意味着网络通信的成本跟内存数据访问的成本一样低——这当然是不可能实现的。
无状态服务就是为了scalability而出现的,无状态服务横向扩展的能力相比于有状态服务大大增强,同时实现负载均衡的成本又远低于有状态服务。
分布式系统中有一个基本的CAP原理,也就是一致性C、响应性能A、分区容错P,无法三者兼顾。无状态服务更倾向于CP,有状态服务更倾向于AP。但是要补充一点,有状态服务的P与无状态服务的P所能达到的程度是不一样的,后者是真的容错,前者只能做到不把鸡蛋放在一个篮子里。
两种服务的设计意图不同。无状态服务的所有状态访问与修改都增加了内网时延,这对于场景服务这种性能优先的服务是不可忍受的。而有状态服务非常适合场景同步与交互这种数据密集的情景,一方面是数据交互的延迟仅仅是进程内方法调用的开销,另一方面由于数据局部性原理,对同样数据的访问非常快。
既然设计意图本来就是不同的,我们这一节就只讨论数据服务与无状态服务的关系。
游戏中可以拆分为无状态服务的业务需求其实有很多,基本上所有服务间交互需求都可以实现为无状态服务。比如切场景服务,因为切场景的请求是有限的,对时延的要求也不会特别高,同理的还有分配房间服务;或者是面向客户端的IM服务、拍卖行服务等等。
数据服务对于无状态服务来说,解决了什么问题?简单来说,就是转移了无状态服务的状态维护成本,同时让无状态服务具有了横向扩展的能力。因为状态维护在数据服务中,所以无状态服务开多少个都无所谓。因此无状态服务非常适合计算密集的业务需求。
你可能觉得我之前在服务划分一节之后直接提出要引入MQ有些突兀,实际上,服务划分要解决的根本问题就是让程序员能清楚自己定义每种服务的意图是什么,哪一种服务更适合Request-Reply,哪一种服务更适合Ask-Sync。
假设策划对游戏没有分服的需求,理论上讲,有节操的程序是不应该以“其他游戏就这样做的”或“做不到”之类的借口搪塞。每一种服务都由分布式的多个节点共同提供服务,如果服务的消息流更适合Request-Reply pattern,那么实现为无状态服务就更合适,原因有二:
针对第二点,可能需要稍微介绍下rabbitMQ。rabbitMQ中有exchange(交换机)、queue、binding(绑定规则)三个主要概念。其中,exchange是对应生产者的,queue是对应消费者的,binding则是描述消息从exchange到queue的路由关系的。exchange有两种常用类型direct、topic。其中direct exchange接收到的消息是不会dup的,而topic exchange则会将接收到的消息根据匹配的binding确定要dup到哪个target queue上。
这样,对于无状态服务,比如同一命名空间下的切场景服务,可以共用同一个queue,然后client发来的消息走direct exchange,就可以在MQ层面做到round-robin,将消息轮流分配到不同的切场景服务上。
而且无状态服务本质上是没有扩容成本的,波峰就多开,波谷就少开。
程序员负责为不同服务规划不同的横向扩展方式。比如类似公会服务这种走MQ的,横向扩展的触发条件就是现在请求数量级或者是节点压力。比如场景服务这种Ask-Sync的,横向扩展就需要借助第三方的服务作为仲裁者,而这个仲裁者可以实现为基于MQ的服务。
这里有个问题需要注意一下。
由于现在同一个client上来的request消息可能由无状态服务的不同节点处理,那么就会出现这样的情况:
假如Sa与Sb有交集,那就会出现竞态条件,如果这时允许服务A存回结果,那数据就有可能存在不一致。
类似的情况还会出现在像率土之滨或者cok这种策略游戏的大世界刷怪需求中。当然前提是玩家与大地图上的元素交互和后台刷怪逻辑都是基于无状态服务做的。
这其实是一个跨进程共享状态问题,而且是一个高度简化的版本——因为这个共享状态只在一个实例上维护。可以引入锁来解决问题,思路通常有两个:
最直观的一种方案是悲观锁。也就是如果要进行修改操作,就需要在读相关数据的时候就都加上锁,最后写成功的时候释放锁。获得锁所有权期间其他impure服务任意读写请求都是非法的。
但是,这毕竟不是多线程执行环境,没有语言或平台帮你做自动锁释放的保证。获取悲观锁的服务节点不能保证一定会将锁释放掉,拿到锁之后节点挂掉的可能性非常大。这样,就需要给悲观锁增加超时机制。
第二种方案是乐观锁。也就是impure服务可以随意进行读请求,读到的数据会额外带个版本号,等写的时候对比版本号,如果一致就可以成功写回,否则就通知到应用层失败,由应用层决定后续操作。