游戏服务端开发究竟解决了什么问题

从零手写服务端框架
来源:说给开发游戏的你
公众号有置顶功能,无论是iOS还是Android都可以在公众号主页进行设置,方便查阅。
服务端开发是一个很笼统的概念,狭义讲,服务端开发只是后台程序员的逻辑开发,比如一些新功能(针对新数据的增删改查),或者游戏的新玩法等等;而广义上讲,服务端开发的工作会涉及除了:
web/移动客户端/游戏客户端等前端部分;
运维/工具等支持设施;
这两部分之外的所有开发工作。
写逻辑的话,小说君个人认为,不论是做什么服务端开发,都大同小异,区别可能在于:
写web,解一下json,做一下增删改查。
写游戏,解一下私有协议,改进程状态。
当然,小说君只做过游戏,所以对web后端开发也不太了解,如有认识错误欢迎指正。
大部分服务端程序员日常工作都是写逻辑为主,但是相信不少同学也对逻辑之下的框架感兴趣,今天的主题就是如何从零手写一个服务端框架。
之前小说君在博客园上写了篇「游戏服务端究竟解决了什么问题」,想讲的内容太多,导致很多细节反而缺失了,于是被一些评论喷成装X、形而上,比较无奈。
因此,小说君打算参考当时的一些内容,重新梳理一下,写一个系列介绍如何编写、优化、迭代服务端框架。限于篇幅,同时又希望尽量保证细节,这个连载大概会有五六篇,每篇文章都会有一个主题关键字。
本篇文章是第一篇,关键字是「解决需求」,所以本文的目的是从零实现一个能满足最小需求集合的服务端框架。小说君会写得尽量通俗易懂,以保证没接触过游戏开发或者服务端开发的同学也能正常阅读。
ps.题图是国内二十年前非常火的mud,「侠客行」,纯文字界面的网游。看完本文,相信你也可以用半天的时间写出这样一个游戏的服务端框架。
下面进入正题。
当讨论到游戏服务端的时候,我们首先想到的会是什么?要回答这个问题,我们需要从游戏服务端的需求起源说起。
游戏对服务端的需求起源有两种:
第一种是单机游戏联网版。这种通常实现为主客机模式,主机部分可以看做服务端。
第二种是所有mmo(multiple massive online,大型多人在线,也就是我们常见的PC端网游,下面都简称mmo了)的雏形mud。这种跟webserver比较类似,一个host服务多clients,表现为cs(client server)架构。
第一种需求长盛不衰,一方面是console游戏一直以来都特别适合这一套,成本低,不需要大规模的房间服务器;另一方面是手游的发展现状,即使霸榜的还是那么几款MMO,但是碎片化的PVE玩法+开房间式同步PVP玩法的手游是大多数,毕竟MMO手游再怎么火也不可能改变手游时间碎片化的事实的,手游不会再重走端游老路了。
第二种需求就不用说了,网上大把例子可以参考。最典型的是假设有一个场景,场景中有很多玩家和怪,玩家和怪的位置、逻辑都在服务端驱动。
解决方案毕竟是不断发展的,即使速度很慢。
说不断发展是特指第一种需求的解决方案,发展原因就是国情,外挂太多。像war3这种都还是纯正的主客机,但是后来对战平台出现、发展,逐渐过渡成了cs架构。真正的主机其实是建在服务器的。
因此,服务器这边也维护了房间状态。后来的一系列ARPG端游也都是这个趋势,服务端越来越重,逐渐变得与第二种需求的解决方案没什么区别。 现在的各种ARPG手游也是一样。
说发展速度很慢特指针对第二种需求的解决方案,慢的原因也比较有意思,那就是魔兽世界成了不可逾越的鸿沟。bigworld在魔兽世界用之前名不见经传,魔兽世界用了之后国内厂商也跟进。发展了这么多年,现在的无缝世界服务端跟当年的无缝世界服务端并无二致。
发展慢的原因就观察来说可能需求本身就不是特别明确,MMO核心用户是重社交的,无缝世界核心用户是重体验的。前者跑去玩了天龙八部和倩女不干了,说这俩既轻松又妹子多;后者玩了console游戏也不干了,搞了半天MMO无缝世界是让我更好地刷刷刷的。
所以仔细想想,这么多年了,能说大成的无缝世界游戏除了天下就是剑3,但是收入却跟重社交的那几款(比如天龙八部)完全不在一个量级。许多引进了之后过段时间就销声匿迹的海外游戏就不说了,国内厂商的天刀和天谕其实相比剑3收入还是差挺多的。
接下来进入技术话题。
两种需求起源,最终其实导向了同一种业务需求。传统MMO架构(就是之前说的天龙、倩女类架构),一个进程维护多个场景,每个场景里多个玩家,额外的中心进程负责帮玩家从一个场景/进程切到另一个场景/进程。bigworld架构,如果剥离开其围绕切进程所做的一些外围设施,核心工作流程基本也能用这一段话描述。
再对问题做下抽象,我们谈到游戏服务端首先想到的就应该是多玩家客户端面对同一场景的视图同步,下面简称为场景服务。
文章不会讨论帧同步或是状态同步这种比较上层的问题,我们将重点放在数据流上。
如何实现场景服务?
首先,我们看手边工具,socket。
之所以不提TCP或UDP是因为要不要用UDP自己实现一套TCP是另一个待撕话题,本文不做讨论。
因此,我们假设,后续实现是建立在对传输层协议一无所知的前提之上的。这样,我们设计的时候不需要考虑各种协议的适配,只需要关注socket这一普适抽象。
socket大家都很熟悉,优点就是各操作系统上抽象统一。
因此,之前的问题可以规约为:如何用socket实现场景同步?
拓扑结构是这样的(之后的所有图片连接箭头的意思表示箭头指向的对于箭头起源的来说是静态的):
场景服务有两个核心需求:
低网络时延
要做到前者,最理想的情况就是由程序员把控完整的消息流,换句话说,就是不借助第三方的消息库/连接库。当然,例外是你对某些第三方连接库特别熟悉,比如很多C++服务端库喜欢用的libevent,或者mono中的IO模块。
要做到后者,就需要保持场景同步逻辑的简化,也就是说,场景逻辑最好是单线程的,并且跟IO无关,因为单位与单位之间、单位与玩家之间、玩家与玩家之间的会发生非常频繁的数据交互。其核心入口就是一个主循环,依次更新场景中的所有单位,刷新状态,并通知client。
正是由于这两个需求的存在,网络库的概念就出现了。网络库由于易于实现,概念简单,而且笼罩着「底层」光环,所以如果除去玩具性质的项目之外,网络库应该是程序员造过最多的轮子之一。
那么,网络库需要解决什么问题?
抛开多项目代码复用不谈,网络库首先解决的一点就是,将传输层的协议(stream-based的TCP协议或packet-based的UDP协议)转换为应用层的消息协议(通常是packet-based)。
对于业务层来说,接收到流和包的处理模型是完全不同的。对于业务逻辑程序员来说,包显然是处理起来更直观的。
流转包,基本都是借助一个缓冲区的概念来实现的。缓冲区的实现有很多,简单列几种:
最简单的可伸缩的non-trivial buffer,可以简单认为就是一个字节数组,快满了就扩容。
ringbuffer,固定大小的环形字节数组,可以构建无锁情景。
bufferlist,跟名字一样,就是buffer的list,扩容成本可以忽略不计。
不同的结构适用于不同的需求,有的方便做zero-copy,有的方便做无锁,有的纯粹图个省事。如果脱离了具体的应用情景跑分,谁一定比谁好都说不准。
buffer需要提供的语义也很简单,无非就是add、remove。buffer是只服务于网络库的。
网络库要解决的第二个问题是,为应用层建立IO模型。
之前提到过,场景服务具有富交互的特点,而poll模型可以避免大量共享状态的存在,理论上应该是最合适场景服务的。所谓poll,就是IO线程准备好数据放在消息队列中,用户线程负责轮询poll,这样,应用层的回调就是由用户线程进入的,保证模型简单。
而至于IO线程是如何准备数据的,平台不同做法不同。linux上最合适的做法是reactor,win最合适的做法就是proactor,一个例外是mono,mono跑在linux平台上的时候虽然IO库是reactor模型,但是在C#层面还是表现为proactor模型。reactor还是proactor是一个比较古老的话题了,最简单的理解可以认为前者是借助一个poll轮询在同一个线程接收数据,后者是不需要借助任何轮询而在多个IO线程接收数据。
提供统一poll语义的网络库可以隐藏这种平台差异,让应用层看起来就是统一的本线程poll,本线程回调。由于socket是全双工的,因此IO模型对于任意一侧都是适用的。
网络库要解决的第三个问题是,封装具体的连接细节。cs架构中一方是client一方是server,因此连接细节在两侧是不一样的。
连接细节的不同就体现在,client侧,核心需求是发起建立连接,外围需求是重连;server侧,核心需求是接受连接,外围需求是主动断开连接。而两边等到连接建立好,都可以基于这个连接构建同样的IO模型就可以了。
接下来简单介绍一种网络库实现。
一个连接好的socket对应一个connector。结构如下:
public interface IRemote
string RemoteIp { }
int RemotePort { }
int Id { }
int Push(byte[] buffer, int len, int offset);
int PushBegin(int len);
int PushMore(byte[] buffer, int len, int offset);
public interface ILocal
string RemoteIp { }
int RemotePort { }
internal class Connector : IRemote, ILocal
private const int HeadLen = 4;
// system socket
private Socket sysS
// todo change to bufferlist
// todo not ensure thread-safe yet
private ConnectorBuffer receiveBuffer = new ConnectorBuffer();
private ConnectorBuffer sendBuffer = new ConnectorBuffer();
private readonly SwapContainer&Queue&Message&& msgQueue = new SwapContainer&Queue&Message&&();
// todo not implemented yet
private RC4 rc4R
private RC4 rc4W
// will be set to true when exception or ServerNetwork.Dispose,
// after which Network will close this connection
internal bool DefferedClose { }
internal bool Connected { }
public int Id { }
connector负责向上提供IO模型抽象(poll语义)。同时,其借助维护的两个buffer,来实现流转包和包转流。
网络库中的server部分主要组件是ServerNetwork,维护接受连接(与主动断开)与N条connector。
// context for socket listener
// manage all clients accepted
public class ServerNetwork
public int ClientCount { get { return clientConnectorsDict.C } }
// io thread pushes while user thread pops
private readonly Dictionary&int, Connector& clientConnectorsDict = new Dictionary&int, Connector&();
// io thread pushes while user thread pops
private readonly SwapContainer&Queue&Connector&& toAddClientConnectors = new SwapContainer&Queue&Connector&&();
// io or user thread pushes while user thread pops
// currently, only user thread pushes
private readonly SwapContainer&Queue&Connector&& toRemoveClientConnectors = new SwapContainer&Queue&Connector&&();
// connectorId for next accepted client
private int nextClientConnectorId = 1;
//system socket
private Socket listenS
public void BeginAccept()
public void Poll()
网络库中的client部分主要组件是ClientNetwork,维护连接(与重连)与一条connector。从代码中可以看出来跟ServerNetwork稍有不同。
// context for connect socket
// manage just one connector, which means local client
public class ClientNetwork
class ConnectAsyncResult
public Exception Ex;
public Connector C
// compared to serverNetwork
// clientNetwork hold one connector for connect socket only
private ConnectAsyncResult defferedConnected =
// ip:port for host
private readonly string hostIp;
private readonly int hostP
// system socket
private readonly Socket sysS
// block api
public void Connect()
public void SendData(byte[] buffer)
public void SendDatav(params byte[][] buffers)
public void Poll()
// user thread
public void Close()
// todo client only
public void SetClientRc4Key(string key)
Network层面的协议非常简单,就是数据长度+数据。
有了网络库这个基础设施,我们其实就已经搞定了一个原型服务端「框架」了。
服务器可以借助网络库监听端口,提供服务。
客户端可以借助网络库与服务端建立连接,发送消息。
服务器接收到消息可以做处理,然后返回给客户端。
虽然很简陋,但是消息流已经基本完成了。
但是,如果是游戏服务端的话,显然还需要存档功能。
这点也比较简单,我们可以直接在服务器上集成一个数据库的客户端库,服务器启动时借助这个客户端库跟数据库建立连接。然后客户端发过来消息,服务器更新完玩家状态,把状态直接存回数据库即可。
这样一个简单的游戏服务端框架就搞定了。
这样一个东西,其实完全是可以支撑一个联网小游戏的,但是我们肯定不能止步于此。很随意就能对这个设计提出两个致命问题:
服务器存档直接调用数据库的同步API;
单机的处理连接数到达瓶颈。
下篇文章,我们会对这个框架做一次优化,还是那个词,达到「工业级可用」。
版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。
ID:ArchDigest
互联网应用架构丨架构技术丨大型网站丨大数据丨机器学习
更多精彩文章,请点击下方:阅读原文
责任编辑:
声明:本文由入驻搜狐号的作者撰写,除搜狐官方账号外,观点仅代表作者本人,不代表搜狐立场。
今日搜狐热点太原达内php培训班:游戏服务端究竟解决了什么问题?(二)_太原达内PHP培训
太原达内php培训班:游戏服务端究竟解决了什么问题?(二)
来源:伯乐在线
4 游戏世界状态的维护方式
4.1数据服务的定位
游戏世界的状态可以简单分为两个部分,一部分是需要存档的,比如玩家数据;一部分是不需要存档的,比如场景状态。
对于访问较频繁的部分,比如场景状态,会维护成纯内存数据;对于访问较不频繁的部分,比如玩家存档,就可以考虑维护在第三方。这个第三方,就是数据服务。
数据服务与之前所提到的场景服务、IM服务等都属于应用层的概念。数据服务通常也会依赖于一种基础设施抽象,那就是缓存。
4.1.1 传统架构中的数据服务
传统MMO架构中,数据服务的概念非常模糊。
我们还是先通过回顾发展历史的形式来厘清数据服务的定义。回到场景进程的发展阶段,玩家状态是内存中的数据,但是服务器不会一直开着,因此就有了存盘(文件或db)需求。但是随着业务变复杂,存盘逻辑需要数据层暴露越来越多的存储API细节,非常难扩展。因此发展出了Db代理进程,场景进程直接将存档推给Db代理进程,由Db代理进程定期存盘。
这样,存储API的细节在Db代理进程内部闭合,游戏逻辑无须再关注。场景进程只需要通过协议封包或者RPC的形式与Db代理进程交互,其他的就不用管了。
Db代理进程由于是定期存盘,因此它相当于维护了玩家存档的缓存。这个时候,Db代理进程就具有了数据服务的雏形。
跟之前的讨论一样,我在这里又要开始批判一番了。
很多团队至今,新立项的项目都仍然采用这种Db代理进程。虽然确实可以用来满足一定程度的需求,但是,存在几个致命问题。
第一,Db代理进程让整个团队的代码复用级别保持在copy-paste层面。玩家存档一定是项目特定的,而采用Db代理进程的团队,通常并不会将Db代理进程设计成普适、通用的,毕竟对于他们来说,Db代理进程是场景进程和存盘之间的唯一中间层。举个例子,Db代理进程提供一个LoadPlayer的RPC接口,那么,接口实现就一定是具体游戏相关的。
第二,Db代理进程严重耦合了两个概念:一个是面向游戏逻辑的存储API;一个是数据缓存。数据缓存本质上是一种新的基础设施抽象,kv发展了这么多年,已经涌现出无数高度成熟的工业级缓存基础设施,居然还有新立项游戏对此后知后觉。殊不知,自己对Db代理进程再怎么做扩展,也不过是在feature
set上逐渐接近成熟的KV,但是在可用性上就是玩具和工业级生产资料的差距。举个最简单的例子,有多少团队的Db代理进程能提供一个规范化的容忍多少秒掉线的保证?
第三,Db代理进程在分区分服架构下通常是一区一个的,一个很重要的原因就是Db代理进程通常是自己YY写出来的,很少能够解决扩容问题。如果多服共用一个Db代理进程,全局单点给系统增加不稳定性的问题暂且按下不表,负载早就撑爆了。但是只是负责缓存玩家存档以及将存档存盘,这跟之前讨论过的全局IM服务定位非常类似,又有什么必要分区分服?
我们可以构建一个数据服务解决这些问题。至于依赖的具体缓存基础设施,我之后会以redis为例。
redis相比于传统的KV比如memcache、tc,具有不同的设计理念,redis的定位是一种数据结构服务器。游戏服务端开发可以拿redis当缓存用,也可以直接当一个数据库用。
数据服务解决了什么问题
数据服务首先要解决的就是玩家存档问题。redis作为一个高性能缓存基础设施,可以满足逻辑层的存档需求。同时还可以实现额外的落地服务,比如将redis中的数据定期存回mysql。之所以这样做,一方面是因为redis的定位是高性能缓存设施,那就不希望它被rdb、aofrewrite机制拖慢表现,或者卡IO;另一方面是对于一些数据分析系统,用SQL来描述数据查询需求更合适,如果只用redis,还得单独开发查询工具,得不偿失。
数据服务其次要解决的问题是可以做到服务级别的复用。这一点我们可以借助企业应用开发中的ORM来设计一套对象-kv-关系映射。也就是数据服务是统一的,而不同的业务可以用不同的数据结构描述自己的领域模型,然后数据服务的配套工具会自动生成数据访问层API、redis中cache关系以及mysql中的table
schema。也就是说,同样的数据服务,我在项目A中引用并定义了Player结构,就会自动生成LoadPlayer的API;在项目B中定义User同理生成LoadUser的API。
这两个问题是比较容易解决的,最关键的还是一个思路的转换。
下面看一种non-trivial的实现。Phial中的DataAccess部分,Phial的Model代码生成器。
实际上,数据服务除去缓存基础设施的部分,都属于外围机制。在有些设计中,我们可以看到还是存在缓存服务与逻辑服务的中间层。这种中间层的单点问题很容易解决——只要不同的逻辑服务访问不同的中间层节点即可。中间层的意义通常是进行RPC到具体缓存协议API的转换,在我的实现中,由于已经有了数据访问API的自动生成,因此没有这种中间层存在的必要。所有需要访问数据服务的逻辑服务都可以直接通过数据访问API访问。
其中还有几点细节:
数据访问层API的调用规范与RPC的调用规范保持了统一,都是基于async/await模式。
通过数据服务对任意存档进行增加或修改都会记录一个job,由落地服务定期检查job进行落地。
引入新的问题
目前仍然遗留了几个问题:
redis单实例的性能确实很强悍,但是如果全区全服只开一个redis实例确实是存在问题的,这个问题需要解决。
数据服务对于传统MMO架构来说可以无缝替换掉丑陋的Db代理进程,但是,既然数据服务已经能提供抽象程度如此高的存储接口,那是否还可以应用在其他地方?
4.1.2 无状态服务中数据服务的定位
之前提到过,游戏世界的状态除了需要存档的玩家数据,还有一部分是不需要存档的逻辑服务的状态。
数据服务如果只是用来替代MMO中的Db代理进程的,那么它的全部职责就仅仅是为需要存档的数据提供服务。从更高的抽象层次来看的话,数据服务相当于是维护了client在服务端的状态。
但是,数据服务提供了更强大的抽象能力。现在数据服务的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,那么实现为无状态服务就更合适,原因有二:
一个Request上来,取相关数据,处理,直接返回。整个状态的生命周期保持在一次RPC调用过程中,这描述的就是Request-Reply的工作方式。
目前只有走MQ的消息pipeline支持Request-Reply pattern,而MQ通常都能很好地支持无状态服务的round-robin
work distribution。
针对第二点,可能需要稍微介绍下rabbitMQ。rabbitMQ中有exchange(交换机)、queue、binding(绑定规则)三个主要概念。其中,exchange是对应生产者的,queue是对应消费者的,binding则是描述消息从exchange到queue的路由关系的。exchange有两种常用类型direct、topic。其中direct
exchange接收到的消息是不会dup的,而topic exchange则会将接收到的消息根据匹配的binding确定要dup到哪个target
这样,对于无状态服务,比如同一命名空间下的切场景服务,可以共用同一个queue,然后client发来的消息走direct
exchange,就可以在MQ层面做到round-robin,将消息轮流分配到不同的切场景服务上。
而且无状态服务本质上是没有扩容成本的,波峰就多开,波谷就少开。
程序员负责为不同服务规划不同的横向扩展方式。比如类似公会服务这种走MQ的,横向扩展的触发条件就是现在请求数量级或者是节点压力。比如场景服务这种Ask-Sync的,横向扩展就需要借助第三方的服务作为仲裁者,而这个仲裁者可以实现为基于MQ的服务。
这里有个问题需要注意一下。
由于现在同一个client上来的request消息可能由无状态服务的不同节点处理,那么就会出现这样的情况:
某个client由于一些原因,快速发了两个message1、message2。
message1先到了服务A,服务A去数据服务拉相关数据集合Sa,并进行后续处理。
此时message2到了服务B,服务B去数据服务拉相关数据集合Sb,进行后续处理,处理完毕,将结果存回数据服务。
然后服务A才处理完,并尝试将处理结果存回数据服务。
假如Sa与Sb有交集,那就会出现竞态条件,如果这时允许服务A存回结果,那数据就有可能存在不一致。
类似的情况还会出现在像率土之滨或者cok这种策略游戏的大世界刷怪需求中。当然前提是玩家与大地图上的元素交互和后台刷怪逻辑都是基于无状态服务做的。
这其实是一个跨进程共享状态问题,而且是一个高度简化的版本——因为这个共享状态只在一个实例上维护。可以引入锁来解决问题,思路通常有两个:
最直观的一种方案是悲观锁。也就是如果要进行修改操作,就需要在读相关数据的时候就都加上锁,最后写成功的时候释放锁。获得锁所有权期间其他impure服务任意读写请求都是非法的。
但是,这毕竟不是多线程执行环境,没有语言或平台帮你做自动锁释放的保证。获取悲观锁的服务节点不能保证一定会将锁释放掉,拿到锁之后节点挂掉的可能性非常大。这样,就需要给悲观锁增加超时机制。
第二种方案是乐观锁。也就是impure服务可以随意进行读请求,读到的数据会额外带个版本号,等写的时候对比版本号,如果一致就可以成功写回,否则就通知到应用层失败,由应用层决定后续操作。
带过期机制的悲观锁和乐观锁本质上都属于可抢占的分布式锁,相当于是将paxos要解决的问题退化为单Acceptor,因此实现起来非常简单。可过期的悲观锁和乐观锁唯一的区别就是前者在申请锁的时候有可能申请失败,而后者申请锁时永远不会失败。两种方案具体的表现优劣跟业务需求有关,不论一开始选择的是哪一种,都非常容易切换到另一种。
我在示例中实现了一个简单的乐观锁,在提交修改的时候用一个lua脚本做原子检查就能简单实现。如果要实现带过期机制的悲观锁,需要保证应用层有简单的时钟同步机制,而且在申请锁的时候也要写一个lua脚本。
在应用层也做了对应修改,调用数据访问层API可以按如下这种方式调用。之所以用了RTTI,是考虑到有可能会改成悲观锁实现,在Dispose的时候会自动release
lock。现在pure服务与impure服务对数据服务调用的接口是不一样的,我们甚至还可以基于这一点在底层做一些扩展,最典型的比如读写分离。当然,这些都是引入主从之后要考虑的问题了。
有了这样一个简易的锁机制,我们可以保证单redis实例内的一致性。
引入新的问题
有了无状态服务的概念,我们的架构中就可以逐步干掉类似切场景管理这种单点进程。无状态服务是高可用的,也就是说,任意挂掉一个,仍然能持续提供服务。
整个游戏服务端理论上应该具有整体持续提供服务的能力。也就是说,随便挂掉一个节点,不需要停服。场景服务挂掉一个节点,不会影响其他任何服务,只是玩家短期内无法进行场景相关操作了而已。
而我们见过的大多数架构,处处皆单点,这完全不能叫可用的架构。有的时候一个服务端跑的好好的,有人硬是要额外加一个全局单点,而且理由是更容易管理,让人哭笑不得。分布式系统中动不动就想加单点,这是病,得治。判断一整个游戏服务端是否具有可用性很简单,随便kill掉一个节点,如果服务端仍然能持续提供服务,即使是部分client受到了影响,也能称为是可用的。
但是,现在逻辑服务具有可用性了,可是数据服务还没有具有可用性,数据服务依赖于一个redis实例,这个redis实例反而成为了整个服务端中的单点。
幸好,redis像其他大多数工业级缓存基础设施一样,已经提供了足够用的可用性机制。但是,在讨论redis的可用性机制之前,我们先解决一下数据服务的一个遗留问题,那就是如何构建一个可以扩展的全局数据服务。
4.2 数据服务的扩展
redis是一种stateful
service,继续应用之前的CAP原则,redis是倾向于AP的。之后我们可以看到,redis的各种扩展,实际上都是基于这个原则来做的。
4.2.1 分片方案
我们遇到的问题是,如果将数据服务定位为全局服务,那仅用单实例的redis就难以应对多变的负载情况。毕竟redis是单线程的。
从mysql一路用过来的同学这时都会习惯性地水平拆分,redis中也是类似的原理,将整体的数据进行切分,每一部分是一个分片shard,不同的shard维护的key集合是不同的。
那么,问题的实质就是如何基于多个redis实例设计全局统一的数据服务。同时,有一个约束条件,那就是我们为了性能需要牺牲全局一致性。也就是说,数据服务进行分片扩展的前提是,不提供跨分片事务的保障。redis
cluster也没有提供类似支持,因为分布式事务本来就跟redis的定位是有冲突的。
因此,我们之后的讨论会有一个预设前提:不同shard中的数据一定是严格隔离的,比如是不同组服的数据,或者是完全不相干的数据。要想实现跨shard的数据交互,必须依赖更上层的协调机制保证,底层不做任何承诺。
这样,我们的分片数据服务就能通过之前提到的简易锁机制提供单片内的一致性保证,而不再提供全局的一致性保证。
基于同样的原因,我们的分片方案也不会在分片间做类似分布式存储系统的数据冗余机制。
分片方案解决了什么问题
分片需要解决两个问题:
第一个问题,分片方案需要描述shard与shard之间的联系,也就是cluster membership。
第二个问题,分片方案需要描述dbClient的一个请求应该交给哪个shard,也就是work distribution。
针对第一个问题,解决方案通常有三:
presharding,也就是sharding静态配置。
gossip protocol,其实就是redis
cluster采用的方案。简单地说就是集群中每个节点会由于网络分化、节点抖动等原因而具有不同的集群全局视图。节点之间通过gossip
protocol进行节点信息共享。这种方案更强调CAP中的A原则,因为不需要有仲裁者。
consensus system,这种方案跟上一种正相反,更强调CAP中的C原则,就是借助分布式系统中的仲裁者来决定集群中各节点的身份。
需求决定解决方案,对于游戏服务端来说,后两者的成本太高,而且增加了很多不确定的复杂性,因此现阶段这两种方案并不是合适的选择。比如gossip
protocol,redis
cluster现在都不算是release,确实不太适合游戏服务端。而且,游戏服务端毕竟不是web服务,通常是可以在设计阶段确定每个分片的容量上限的,也不需要太复杂的机制支持。
但是第一种方案的缺点也很明显,做不到动态增容减容,而且无法高可用。但是如果稍加改造,就足以满足需求了。
在谈具体的改造措施之前,先看之前提出的第二个问题。
第二个问题实际上是从另一种维度看分片,解决方案很多,但是如果从对架构的影响上来看,大概分为两种:
一种是proxy-based,基于额外的转发代理。例子有twemproxy/Codis。
一种是client
sharding,也就是dbClient(每个对数据服务有需求的服务)维护sharding规则,自助式选择要去哪个redis实例。redis
cluster本质上就属于这种,client侧缓存了部分sharding信息。
第一种方案的缺点显而易见,在整个架构中增加了额外的间接层,pipeline中增加了一趟round-trip。如果是像twemproxy或者Codis这种支持高可用的还好,但是github上随便一翻还能找到特别多的没法做到高可用的proxy-based方案,无缘无故多个单点,这样就完全搞不明白sharding的意义何在了。
第二种方案的缺点就是集群状态发生变化的时候没法即时通知到dbClient。
第一种方案,我们其实可以直接pass掉了。因为这种方案本质上还是更适合web开发的。web开发部门众多,开发数据服务的部门有可能和业务部门相去甚远,因此需要统一的转发代理服务。但是游戏开发不一样,数据服务逻辑服务都是一帮人开发的,没什么增加额外中间层的必要。
那么,看起来只能选择第二种方案了。
将presharding与client
sharding结合起来后,现在我们的改造成果是:数据服务是全局的,redis可以开多个实例,不相干的数据需要到不同的shard上存取,dbClient掌握这个映射关系。
引入新的问题
目前的方案只能满足游戏对数据服务的基本需求。
大部分采用redis的游戏团队,一般最终会选定这个方案作为自己的数据服务。后续的扩展其实对他们来说不是不可以做,但是可能有维护上的复杂性与不确定性。今天这篇文章,我就继续对数据服务做扩展,后面的内容权当抛砖引玉。
现在的这个方案存在两个问题:
首先,虽然我们没有支持在线数据迁移的必要,但是离线数据迁移是必须得有的,毕竟presharding做不到万无一失。而在这个方案中,如果用单纯的哈希算法,增加一个shard会导致原先的key到shard的对应关系变得非常乱,抬高数据迁移成本。
其次,分片方案固然可以将整个数据服务的崩溃风险分散在不同shard中,比如相比于不分片的数据服务,一台机器挂掉了,只影响到一部分玩家。但是,我们理应可以对数据服务做更深入的扩展,让其可用程度更强。
针对第一个问题,处理方式跟proxy-based采用的处理方式没太大区别,由于目前的数据服务方案比较简单,采用一致性哈希即可。或者采用一种比较简单的两段映射,第一段是静态的固定哈希,第二段是动态的可配置map。前者通过算法,后者通过map配置维护的方式,都能最小化影响到的key集合。
而对于第二个问题,实际上就是上一节末提到的数据服务可用性问题。
4.2.2 可用性方案
讨论数据服务的可用性之前,我们首先看redis的可用性。
对于redis来说,可用性的本质是什么?其实就是redis实例挂掉之后可以有后备节点顶上。
redis通过两种机制支持这一点。
一种机制是replication。通常的replication方案主要分为两种。一种是active-passive,也就是active节点先修改自身状态,然后写统一持久化log,然后passive节点读log跟进状态。另一种是active-active,写请求统一写到持久化log,然后每个active节点自动同步log进度。
还是由于CAP原则,redis的replication方案采用的是一种一致性较弱的active-passive方案。也就是master自身维护log,将log向其他slave同步,master挂掉有可能导致部分log丢失,client写完master即可收到成功返回,是一种异步replication。
这个机制只能解决节点数据冗余的问题,redis要具有可用性就还得解决redis实例挂掉让备胎自动顶上的问题,毕竟由人肉去监控master状态再人肉切换是不现实的。
因此还需要第二种机制。
第二种机制是redis自带的能够自动化fail-over的redis sentinel。reds
sentinel实际上是一种特殊的reds实例,其本身就是一种高可用服务,可以多开,可以自动服务发现(基于redis内置的pub-sub支持,sentinel并没有禁用掉pub-sub的command
map),可以自主leader
election(基于sentinel实现的raft算法),然后在发现master挂掉时由leader发起fail-over,并将掉线后再上线的master降为新master的slave。
redis基于自带的这两种机制,已经能够实现一定程度的可用性。那么接下来,我们来看数据服务如何高可用。
数据服务具有可用性的本质是什么?除了能实现redis可用性的需求——redis实例数据冗余、故障自动切换之外,还需要将切换的消息通知到每个dbClient。
由于是redis sentinel负责主从切换,因此最自然的想法就是问sentinel请求当前节点主从连接信息。但是redis
sentinel本身也是redis实例,数量也是动态的,redis
sentinel的连接信息不仅在配置上成了一个难题,动态更新时也会有各种问题。而且,redis sentinel本质上是整个服务端的static
parts(要像dbClient提供服务),但是却依赖于redis的启动,并不是特别优雅。另一方面,dbClient要想问redis
sentinel要到当前连接信息,只能依赖其内置的pub-sub机制。redis的pub-sub只是一个简单的消息分发,没有消息持久化,因此需要轮询式的请求连接信息模型。
上一节末提到过,要想最小化数据迁移成本可以采用两段映射或一致性哈希。这时还有另一种可以扩展的思路,如果采用两段映射,那么我们可以动态下发第二段的配置数据;如果采用一致性哈希,那么我们可以动态下发分片的连接信息。这其中的动态,就可以基于新的符合Phial规范的服务来做。而这个通知机制,就非常适合采用Phial中的Notify
pattern实现。而且redis
sentinel的实现难度比较低,我们完全可以以较低的成本实现一个扩展性更强,定制性更强,还能额外支持分片服务的部分在线数据迁移机制的服务。
同时,有一部分我在这篇文章里也没提过,那就是落地服务所依赖的mysql的可用性保障机制。相比于再开一个额外的mysql高可用组件,倒不如整合到同样的一个数据服务监控服务中。
这个监控服务就是watcher。由于原理类似,接下来的讨论就不再涉及对mysql的监控部分,只针对redis的。
watcher解决了什么问题?
要能够监控redis的生存状态。这一点实现起来很简单,定期的PING
redis实例即可。需要的信息以及做出客观下线和主观下线的判断依据都可以直接照搬sentinel实现。
要做到自主服务发现,包括其他watcher的发现与所监控的master-slave组中的新节点的发现。前者基于MQ定期Notify通知,后者定期INFO
监控的master实例即可。
要在发现master客观下线的时候选出leader进行后续的故障转移流程。这部分实现起来算是最复杂的部分,接下来会集中讨论。
选出leader之后将一个最合适的slave提升为master,然后等老的master再上线了就把它降级为新master的slave。
解决这些问题,watcher的职责就已经达成,我们的数据服务也就更加健壮,可用程度更高。
引入新的问题
但是,如果我们引入了新的服务,那就引入了新的不确定性。如果引入这个服务的同时还要保证数据服务具有可用性,那我们就还得保证这个服务本身是可用的。
先简单介绍一下redis
sentinel的可用性是如何做到的。同时监控同一组主从的sentinel可以有多个,master挂掉的时候,这些sentinel会根据一种raft算法的工业级实现选举出leader,算法流程也不是特别复杂,至少比paxos简单多了。所有sentinel都是follower,判断出master客观下线的sentinel会升级成candidate同时向其他follower拉票,所有follower同一epoch内只能投给第一个向自己拉票的candidate。在具体表现中,通常一两个epoch就能保证形成多数派,选出leader。有了leader,后面再对redis做SLAVEOF的时候就容易多了。
如果想用watcher取代sentinel,最复杂的实现细节可能就是这部分逻辑了。
这部分逻辑说白了就是要在分布式系统中维护一个一致状态,举个例子,可以将“谁是leader”这个概念当作一个状态量,由分布式系统中的身份相等的几个节点共同维护,既然谁都有可能修改这个变量,那究竟谁的修改才奏效呢?
幸好,针对这种常见的问题情景,我们有现成的基础设施抽象可以解决。
这种基础设施就是分布式系统的协调器组件(coordinator),老牌的有zookeeper(zab),新一点的有etcd(raft)。这种组件通常没有重复开发的必要,像paxos这种算法理解起来都得老半天,实现起来的细节数量级更是难以想象。因此很多现成的开源项目都是依赖这两者实现高可用的,比如codis就是用的zk。
zk解决了什么问题?
就我们的游戏服务端需求来说,zk可以用来选leader,还可以用来维护dbClient的配置数据——dbClient直接去找zk要数据就行了。
zk的具体原理我就不再介绍了,具体的可以参考lamport的paxos paper,没时间没精力的话搜一下看看zk实现原理的博客就行了。
简单介绍下如何基于zk实现leader
election。zk提供了一个类似于os文件系统的目录结构,目录结构上的每个节点都有类型的概念同时可以存储一些数据。zk还提供了一次性触发的watch机制。leader
election就是基于这几点概念实现的。
假设有某个目录节点/election,watcher1启动的时候在这个节点下面创建一个子节点,节点类型是临时顺序节点,也就是说这个节点会随创建者挂掉而挂掉,顺序的意思就是会在节点的名字后面加个数字后缀,唯一标识这个节点在/election的子节点中的id。
一个简单的方案是我们可以每个watcher都watch
/election的所有子节点,然后看自己的id是否是最小的,如果是就说明自己是leader,然后告诉应用层自己是leader,让应用层进行后续操作就行了。但是这样会产生惊群效应,因为一个子节点删除,每个watcher都会收到通知,但是至多一个watcher会从follower变为leader。
优化一些的方案是每个节点都关注比自己小一个排位的节点。这样如果id最小的节点挂掉之后,id次小的节点会收到通知然后了解到自己成为了leader,避免了惊群效应。
还有一点需要注意的是,临时顺序节点的临时性体现在一次session而不是一次连接的终止。例如watcher1每次申请节点都叫watcher1,第一次它申请成功的节点全名假设是watcher10002(后面的是zk自动加的序列号),然后下线,watcher10002节点还会存在一段时间,如果这段时间内watcher1再上线,再尝试创建watcher1就会失败,然后之前的节点过一会儿就因为session超时而销毁,这样就相当于这个watcher1消失了。解决方案有两个,可以创建节点前先显式delete一次,也可以通过其他机制保证每次创建节点的名字不同,比如guid。
至于配置下发,就更简单了。配置变更时直接更新节点数据,就能借助zk通知到关注的dbClient,这种事件通知机制相比于轮询请求sentinel要配置数据的机制更加优雅。
我在实现中将zk作为路由协议的一种整合进了Phial规范,这样基于zk的消息通知可以直接走Phial的RPC协议。
有兴趣的同学可以看下我实现的zkAdaptor,leader
election的功能作为zkAdaptor的特殊API,watcherService会直接调用。而配置下发直接走了RPC协议,集成在统一的Phial.RPC规范中。zkAdaptor仅支持Phial.RPC中的Notify
watcher的实现在这里。
5.总结目前形成的架构以及能做什么
整理下这篇文章到目前为止做了什么事情:
在文章的一开始确定了游戏服务端要解决的核心两个问题:消息的pipeline与游戏世界状态维护。
通过回顾历史的形式提出游戏服务端中最常见的需求情景:多玩家场景同步,并梳理了场景同步最适合的消息pipeline。
结合切场景的扩展需求,提出Gate这种基础设施抽象(infrastructure abstraction,简称IA)。
尝试进行高内聚、低耦合的服务划分,并总结Gate无法兼顾的消息pipeline。
针对Gate无法处理的消息pipeline(service -& service),提出新的MQ-IA,可以大大简化服务间拓扑关系。
基于不同的IA与相关协议,提出更高层次的RPC协议,定义了适合.Net2.0和.Net4.5的两种异步RPC调用规范。实现了不同IA到统一规范的Adaptor。总结了游戏中RPC应用的pattern,不同pattern如何与不同IA结合使用。
同样通过回顾历史的形式引入数据服务来取代传统MMO中的Db代理进程。
结合MQ与数据服务,提出无状态服务在游戏服务端中的应用情景,展开介绍数据服务对于无状态服务的意义所在。
基于构建全局数据服务的理念,尝试实现一种多实例的、每实例内向不同服务提供原子修改操作级别一致性的数据服务。
为数据服务增加了符合需求的高可用支持。引入了zookeeper,可以让普通的服务也可以复用同样的协调者组件。
总结下出现的几种概念:
IA。包括Gate、MQ、内存db、持久化强一致性db、分布式协调器等等。不同的IA各司其职,各自只负责解决分布式系统中的一小部分问题。
RPC与Pattern。面向应用层的统一服务调用方式与规范。
Adaptor。不同的IA与相关协议到统一RPC与Pattern的适配器。
到目前为止的拓扑图:
系统设计中的static parts与dynamic parts
gate/mq/zk/redis/mysql
almost all custom services
这篇文章的灵感起源是the
log,看完之后深有感触。虽然JAVA不是一门好语言,但是JAVA技术栈却发展得如此优雅。JAVA技术栈上的每一种IA都专注于解决特定的一小块问题,比如这里提到的。未来的应用框架开发者,就像是用胶水将这些基础设施粘合起来。游戏服务端程序员通常习惯于c++的小圈子,甚至有一种传教的趋势宣扬c++才是代表的游戏服务端的核心技术。有的时候,游戏程序员需要从c++的小圈子跳出来向外走一走,有可能你就不想再湮没在繁文缛节中,而是发现更大的世界。
不过话又说回来,不喜欢跳出c++小圈子的游戏服务端程序员,大部分又都对c++本身其实知之甚少,奉OOP为圭臬,各种虚继承、多继承出来的代码看到想吐。尝试用模板的各种奇技淫巧把c++写成haskell的虽然更有跳出c++小圈子的倾向,但是既然都如此用了,又何必拘泥于c++?
我在这篇文章里尽量少的插入代码,尽量描述游戏服务端定义问题、解决问题的思路。服务端用C#写的毕竟是少数,但是有了思路随便改写成其他语言都没问题。
我顺便也借着写这篇博客的机会,整理了下一些小东西放在github上。
比如之前的面向组合子博客提到的代码生成器组合子,CodeC
比如之前的定时器博客提到的linux内核风格定时器,以及基于定时器写的example,C#协程,都放在这里,CoroutineSharp
比如之前的游戏AI博客提到的行为树编译器原型和c# runtime示例,Behaviour
还有学习parsec的一个小结,可以用来parse单个c#文件拿到一些描述信息的,当然纯属学习性质,有这种需求的时候最好优先用反射。cs_file_parser
然后就是跟这篇博客相关的
一个简单的网络库,N
一个简单的基于Network的Gate,GateS
规范的整个底层库,P
为底层库开发的两个配套代码生成器,Phial.CodeG
示例实现,Phial.Fantasy。
github中的以演示为目的,因此相比于博客,还有不少部分是to be
determined(比如详细的配置流程、MQ的集群化、mysql的故障转移集成、落地服务的实现细节等等),之后我也会继续维护。
“谁也无法改变现状,唯有无数程序员血洒大地,才能使项目重建天日。”这一点也不夸张,软件项目做烂了就是个坑,参与者也不过是填坑的。就像是在魔兽世界战场遇到国家队一样,你赢也赢不了,出也出不去。
美国《赫芬顿邮报》(The Huffington Post)网站刚刚登载了一名女程序员的文章。该文章讲述了她作为女程序员在职场生活中所感受到的孤独,并分析了这种孤独感产生的五大原因。
聚类是将一组对象进行分组,使得同一组(簇)内的对象相似性远大于不同组之间的相似性。
毫无疑问,机器学习/人工智能的子领域在过去几年越来越流行。由于大数据是目前科技领域最热门的趋势,基于这些大规模的数据,机器学习在预测和计算建议方面变得不可思议的强大。最常见的机器学习的例子就是 Netflix(一家在线影片租赁提供商)的算法,它能基于你过去看过的电影来推荐电影,或着 Amazon 的算法,它能基于你过去买的书,来推荐书给你。
Copyright (C)
All Rights Reserved
选择城市和中心
达内北京亦庄大学生实训基地
达内北京网络营销中心
达内北京会计中心

我要回帖

更多关于 网页游戏服务端 的文章

 

随机推荐