用通俗的方法来描述一个好的服务端架构,最基础也是最重要的就两点:支持百万玩家同时在线,不出问题。这两点也就分别对应了高并发和高可用。
上篇文章介绍的是游戏服务器的一些优化方法:《某百万DAU游戏的服务端优化工作》。
这篇文章系统的介绍游戏服务端中的高并发和高可用。
高并发和高可用是一个相辅相成的工作,当我们支持百万玩家同时在线时却无法保证服务器稳定可用,那高并发支持就无从谈起;而如果当玩家数量较多时服务器就常常出问题,那也不能称为高可用。
1 水平扩展
水平扩展时高并发和高可用的基础,通过支持水平扩展,我们理论上可以通过增加机器获得无限的承载上限,从而支持高并发;在此基础上,若某个进程出现异常,其他进程可以替代其提供服务,从而实现了高可用。
以下图为例,对于不支持水平扩展的架构,游戏服务器中只有一个战斗进程为所有的玩家提供战斗服务,这里存在两个问题:1.一个进程最多只能使用一个一台机器的计算资源,存在性能上限。2.若这个进程或者所在机器/网络发生异常,那么整个系统就不可用了。
游戏服务端的高并发和高可用
不支持水平扩展
水平扩展有两种常见的实现模型:
大厅服和所有的战斗进程进行全连接,需要访问战斗服务时去管理器中查询服务所在的进程地址,然后直接去访问进程。(左图)
在战斗进程前面挂一个路由,路由记录每个战斗所在的战斗进程,相关请求会转发到对应的进程。(右图)
游戏服务端的高并发和高可用
水平扩展的两种模型
1.1 有状态 vs. 无状态
从进程内存中是否保存状态的角度可以将服务分为有状态和无状态:
有状态服务:进程内存中保存状态,比如战斗服务将战斗信息(玩家角色状态、小怪状态等)保存在内存中,玩家操作或者战斗逻辑会改变战斗信息。由于游戏中状态比较复杂,业务改变状态频率比较高,游戏大部分的业务都是用有状态服务的方式提供。
无状态服务:服务只处理流程,不保存数据,一般数据会保存到后端db中,这种服务的逻辑一般会有很多db操作。这种类型的服务在互联网web行业用的比较多,游戏中常见的比如充值、登陆等。(无状态服务在执行一个流程处理中可能会有一些临时变量申请内存)
无状态服务本身不保存状态,所以进程crash也不会丢失信息。此外,下文将介绍,由于使用随机分配的路由方式,无状态服务对异常的容忍更加好,所以,从高可用角度,无状态更加好。
但是,由于无状态不保存状态,所有状态操作都是数据库操作,就造成了开发成本更高(代码写起来更复杂)、数据库压力更大,所以无状态并不适合所有服务,一般对于状态简单明确的服务,可以优先使用无状态,比如好友服务。
1.2 路由策略
对于有状态和无状态服务,他们使用的路由方式也不同。
对于无状态服务,一般使用随机分配的路由方式。随机分配的路由方式有很大的好处,如果某个进程crash了或者网络出现了故障,我们只需要把这个进程从路由中去掉,对后续的请求不会有影响,只会影响此进程当前正在处理的逻辑。
有状态服务的路由需要明确每个请求给哪个进程处理,给其他进程其他进程因为没有相关状态信息也无法处理。比如上文提到的战斗服务,路由根据战斗ID将相关请求发给对应战斗所在的进程中才能处理。路由一般使用取模或者一致性哈希,一般会优先使用一致性哈希而不是取模,防止故障引起抖动。
1.3 举个栗子
下图是某游戏架构的简化版模型,真实的服务器比这个复杂很多,这里主要是为了举个例子。
游戏服务端的高并发和高可用
集群可以分为三类:支持水平扩展的有状态服务、支持水平扩展的无状态服务、不支持水平扩展的单点服务。
其中,支持水平扩展的有状态服务和无状态服务的进程数量能占到项目的90%,单点服务很少。在这种架构情况下,我们游戏的承载上限瓶颈在于单点服务,而单点服务逻辑相对比较简单,承载上限很高。此外,支持水平扩展的服务进程出现异常只会影响此进程所服务的玩家,具有较高的可用性。
有状态服务
在我们游戏的服务器集群中,三分之二左右的进程是处理玩家个人逻辑的进程(玩家集群,很多游戏项目叫大厅服务器)。每个进程处理一部分玩家的业务逻辑,通过shading将玩家分配在不同的玩家进程中。
可以通过增加个人逻辑进程数量提升服务器承载量,我们支持不停服增加或减少进程即动态扩容缩容。,这些进程之间就是平等的,不同进程之间没有强依赖关系。当一个进程crash时,他不会影响其他进程的玩家。
除了玩家进程,还有战斗进程、家族进程等类似进程可以这么设计。
上面提到的都是有状态服务,我们需要记录每个玩家/战斗/家族在哪个进程中,此外,若进程出现异常,虽然不会影响其他玩家/战斗/家族,但当前进程中玩家/战斗/家族都会不可用,而且会丢失一些数据。
无状态服务
我们将部分服务使用无状态实现,比如登陆、支付、好友、部分排行榜等。由于无状态服务具相对于有状态对异常更友好、动态扩容缩容模型 更简单,因此有对于一个新的服务我们优先考虑使用无状态,若状态较复杂才考虑使用有状态服务实现。
单点服务
游戏服务中难免出现一些单点服务,比如玩家管理器、集群管理器、家族管理器等,这类服务不具扩展能力,是游戏服务器的承载瓶颈。此外,也不具有高可用性,如果出现异常会导致导致整个游戏集群不可用。
单点服务逻辑普遍简单(复杂逻辑我们都要支持水平扩容),性能承载普遍较高。比如,我们游戏中目前评估的同时在线保守估计应该在50w,此时我们认为我们的一些单点服务会出现满载,导致游戏无法继续扩容。
此外,单点服务数量较少,出现异常的可能性很低。我们游戏上线近两年,我们也只遇到了两次机器宕机,影响的都是非单点进程,没有影响整体的游戏集群可用性。
当然,单点服务也可以改为支持水平扩展的,只是工作量的问题。理论是来说,是能完全消除单点的,只是对于大部分项目来说性价比不高意义不大。
2 高并发
水平扩展的方案是支持高并发的主要手段(也叫可伸缩Scalability),上文已经介绍过了。
下文主要介绍除了水平扩展高并发的其他方案,以及需要注意的点。
2.1 垂直扩展和性能优化
要提升承载能力,一般有两个方案:
水平扩展:通过增加机器数量提升承载能力。
垂直扩展:通过增加机器配置提升承载能力。让一个机器/线程可以承载更多的玩家。
垂直扩展在某些场景也有有用。一般来说,对于上文我们提到的单点,如果不好消除或者消除成本很高,可以通过垂直扩展把这个逻辑放在高配机器上,提升单点逻辑的承载。
此外,我们常常是对战斗服进行性能优化,比如使用C++写高消耗模块,但对于大厅服一般不将其作为提升承载的首要手段。这个我们不深入讨论,一方面这个总会有上限,难有质变,另一方面不同游戏优化方案千差万别,都是代码级别的优化。
服务端优化和垂直扩展的目标类似,就是让一台机器能承载更多的玩家/逻辑。
2.2 消除系统单点和逻辑单点
上文介绍的消除单点主要是系统单点,也就是用多个进程而不是一个进程提供服务。
消除系统单点的前提是消除逻辑单点。
举个例子:我们投放一个武器时,要给这个武器生成一个全服唯一的ID以标识此武器。这个ID可以使用一个自增的ID,此时就造成逻辑单点。
对于这种情况,如果游戏中生成武器的频率很低,那么这种方案也可以,但如果武器生成频率很高,因为游戏中所有逻辑都需要去一个地方去申请这个ID,那就可能产生瓶颈。对于这种情况,我们一般可以使用uuid代替自增ID。(这个场景也常见于DB中的自增列,所以一般建议少使用自增)
2.3 数据库承载
当玩家在线量达到一定的量级以后,往往对后端的数据库造成很大的压力。
一般来说,数据库本身具有水平扩展能力,加上分库分表等方案,提高承载能力比较容易。但设计数据库结构时也需要考虑索引、shadkey等问题,不然严重影响数据库性能。此外,要考虑数据库并发读写能力,比如mongo中的MMAPv1存储引擎是collection级别锁,而WiredTiger存储引擎是doc级别锁,两者的并发能力差别极大。
游戏逻辑普遍比较复杂,数据读写量很大,如果每次玩家信息变更都去读写数据库,会造成较大的数据库压力。因此,游戏的玩家服务一般都是有状态服务,玩家上线时将数据从数据库读到内存中,在线期间读写数据都是直接操作内存,下线时或隔段时间去落地到数据库。这种方案大大降低了数据库读写操作,对数据库压力会小很多。
而一些数据读写操作频率较低的服务,可以考虑将服务做成无状态然后每次读写都去操作数据库。
2.4 多集群和跨集群
当游戏服务端达到一定的规模后,往往需要分集群部署,分集群解决的场景:
单集群的承载具有上限,比如skynet只支持256个进程。
多区服需求,每个集群对应游戏的一个区服。如果游戏支持多区服并且完全隔离没有跨服通信,实现高并发会容易很多。
全球通服,某些集群希望部署到玩家所在地区。比如美国玩家所用的战斗服部署在美国,东南亚的战斗服部署在东南亚,而他们共用大厅服部署在某地。
多集群中需要解决的一个问题是跨集群通信问题,集群内一般是进程间全连接,但集群间如果进程全连接会造成拓扑混乱连接数量爆炸的问题,因此集群间通信一般使用消息总线,所有的集群通过消息总线进行通信。
游戏服务端的高并发和高可用
2.5 临时的高并发
在游戏业务场景中,玩家的在线和时间、活动等关系很大,不同的时间在线数量可能有几倍几十倍的差别。
对于预期内的高流量,可以通过提前做好扩容来进行承载,参考《忍三的服务端优化》中的“动态扩容和缩容”。
对于非预期的瞬间高并发,可以通过排队系统将流量卡在系统外,动态扩容后再慢慢的进入游戏中。
2.6 战斗场景中的高并发
游戏还有一个特殊的高并发场景,就是MMO的大规模玩家在某场景聚集,比如国战。
这种场景没有完美的解决方案,只能尽量的提高承载量,常见的提高方案有:
将一个场景切分为cell,不同cell放到不同进程。比如bigworld/kbengine和最新的SpatialOS。
提升单进程承载能力。比如逻辑优化、垂直扩展、使用C++写游戏逻辑等,C++和python比起来性能有数量级的差别。
服务降级:简化游戏逻辑,比如国战时一般只要玩家觉得场面热闹就差不多了,很多战斗逻辑其实都简化掉了。
分服/分线/分副本:业务上让玩家隔离。
我之前有篇文章《游戏的数值系统的实现和演化》写过我在之前一款游戏中做的战斗服务端性能优化。
3 高可用
高可用追求系统在运行过程中尽量少的出现系统服务不可用的情况。
评价指标是服务在一个周期内的可用时间(SLA, Service Level Agreement),计算公式为服务可用性=(服务周期总分钟数 - 服务不可用分钟数)/服务周期总分钟数×100%。
一般从两个维度进行评价:1.系统的完全可用:所有服务对于所有用户都是可用的。2.系统的整体可用:部分服务或者部分用户不可用,但系统整体可用。
高可用的目标是争取系统的完全可用,保证整体可用。
大集群下的异常
由于机器故障、网络卡顿或断线等客观存在的小概率异常情况,服务端也需要考虑这些问题,尤其是在大集群场景下,小概率事件累加变成了大概率事件,因此在大集群服务器场景下,高可用是我们必须要考虑的问题。
高可用,其实就是对各种异常状况的隔离和处理,不让小概率异常事件影响游戏的整体服务。
常见的异常有以下几种:
机器/进程/网络异常:阿里云机器的可用性承诺是99.975%,大概是每台机器保证一年的不可用时间在1小时以内。若集群使用一百台机器,理论上来说最差的情况是每三天就有一台机器一小时不可用。当然,真实的可用性比阿里云承诺的好很多,我们游戏大概有100台ECS机器,一年中有两台机器因为机器故障自动重启,并没有出现过持续的不可用。
Saas服务/DB异常:因为我们大数量使用了阿里云的mysql、redis等云服务,这些服务本身也有可用性问题,导致主从切换等。我们游戏最常遇到的问题是因为redis主从切换造成网络闪断需要网络重连。
业务BUG:对于业务BUG,尽量有办法减少bug的影响,不让某些小BUG导致系统整体不可用。
突发性能热点:玩家的正常行为或异常行为导致的业务图发繁忙,主要是上文所说的高并发问题。此外,对于某些玩家的异常行为(比如外挂\DDOS攻击),也要保证不会影响系统整体可用。我记得很早之前(传奇年代),有些外挂能直接让服务器重启。
3.1 基于水平扩展实现高可用
上文中我们提到了水平扩展可以提高并发承载量,同时可以提高可用性,但侧重点不同。对于高并发,水平扩展表示我们可以通过增加机器/进程提高承载量。对于高可用,是说当机器/进程出现异常或者崩溃时,不会影响集群的整体可用。
在上文水平扩展中已经介绍,对于支持水平扩展的服务,有状态服务出现异常只会影响到此进程所提供的服务,其他进程正常运行;对于无状态服务,影响更小,只会影响到正在执行的流程。
当然,这需要我们写一些处理逻辑,包括:
异常监控:通过异常监控,可以快速发现异常,一般使用心跳或者消息超时机制。
异常处理:比如一个消息超时后如何处理,是重试还是忽略。如果一个进程不可用,我们需要将此进程踢出集群。
服务恢复:对于无状态,直接重启即可。对于有状态,可以将状态迁移到其他进程提供服务。服务恢复有很多坑(有状态服务更多),常见的恢复方案比如将所服务的玩家直接踢下线,然后重新登陆。
服务降级:服务降级常见的排队系统、关闭指定功能等。
如何实现上述逻辑其实挺复杂的,这里不详细介绍了。
服务隔离和灰度发布
开发过程中,我们应该将大功能尽量的拆成一个个小的服务,每个服务只负责一小块功能。Skynet也提供了比较好的Service模式,不同的Service可以放在一个进程中,也可以放在不同的进程中。
前文已经介绍服务隔离和灰度发布,也是为了将高风险的服务进行隔离,让它即使出现了问题也不要影响到系统的整体可用。
3.2 主从复制
对于有状态服务,支持水平扩展的进程可以做到一个进程出现异常不影响其他进程提供服务,但这个进程crash了会导致这个进程提供的服务不可用,并且造成内存中的数据丢失等问题。
为了解决这个,常见的方案是主从复制。主从复制在数据库中非常常见,是保证数据库高可用性的常见方案。
主从复制就是在主节点(master)后面挂一个或者多个从节点(slave),主节点实时的将状态/数据复制到从节点。平时是主节点提供服务,当主节点出现问题时,从节点变成主节点,继续提供服务。因为主节点近乎事实的将数据复制到从节点,可以近似保证数据不丢失。
因此,如果想进一步的提升有状态服务或者单点服务的可用性,可以使用主从复制的方案。
游戏服务器使用此方案写业务逻辑的较少,有些集群管理节点(非业务逻辑)会使用此方案。
此外,因为常见的db(mysql/mongo/redis)都自带主从复制,所以无状态服务其实也是将状态让db帮我们管理,从而获得db主从复制带来的数据不会丢失的能力。
3.3 云服务的异常处理
除了ECS机器,我们大量使用了阿里云的各类SAAS服务,比如redis/Mysql/Mongo等DB,也有类似于ELK的日志服务等。
这些服务大部分都支持主从切换等高可用方案,但我们需要考虑当他们进行主从切换时对我们系统产生的影响。
在Mysql和Redis中,当发生主从切换或gateproxy宕机,会导致网络连接断线,因此,我们必须在逻辑中处理网络中断并重连。在网络断线重连阶段,必然导致某些db请求失败,我们也需要处理这种异常问题。
在数据落地场景中,需要判断每次db请求是否成功,若不成功进行重试并且要保证请求的幂等性以防止请求多次执行。
4 高并发和高可用的目标
为了实现高并发和高可用本身具有较大的开发成本,在大部分项目中人力资源也不是无限的。所以大家在做相关工作的时候,也要过度设计,综合考虑业务需求、承载预期和开发成本。
其实我说的开发成本不仅是说程序开发量更大,更多的是越复杂的系统越容易出现问题,如果没有足够的人力去测试、维护和迭代,还不如用更简单的方式实现,反而出问题的概率更低。
当然,如果你的项目组是王者荣耀和和平精英,高并发和高可用要求非常高,人力近乎无限,请忽略此段。
百万同时在线
在游戏行业中,一般将百万同时在线作为游戏服务端架构的并发目标,百万的数量级也是绝大部分游戏(除了王者和吃鸡)的上限。
所以,在游戏前期架构设计、规划和压测中,我们可以按照百万同时在线作为基准去预估不同的系统所需要的承载量,达到这个承载量就可以了。
比如我们游戏,虽然也有不少单点和性能瓶颈,但根据我们的预估,即使这些单点存在,我们也能通过加机器支持到100w的同时在线。那么,这些单点和瓶颈就在我们的预期范围内,我们就不会进一步去优化了。
如果哪天我们游戏大火需要支持千万的同时在线了,理论上也可以继续消除单点和优化性能瓶颈,但成本会大幅度增加。
高可用 != 完全可用
我们追求服务器集群的高可用,并不是要求对于所有的异常都能容灾,那是不可能的,也是不现实的。
按照skynet的思想,若某节点出现异常没有及时响应,所有访问它的请求都会堵塞而不会timeout,若节点挂了,会直接报trace。相当于skynet把集群看为一个整体,没有做容错机制,若某个核心节点挂了,就应该集群整体不可用。
我上文说过,整体上我认可skynet的思想,可以有效地降低业务开发时的思想负担。在这个基础上,对于某些常见的异常,应该尽量降低影响,避免雪崩效应。
技术以外的高并发和高可用
高并发和高可用,和非技术也有较大的关系,比如运维能力、硬件情况,人员素质、管理水平等。
要解决高并发,需要部署大规模集群,就会对对运维能力提出较高的要求。在用户量小集群小的情况下,人工运维是可以接受的,但是随着集群规模和复杂性的增加,人工运维会变得越来越难,必须要工具化和自动化。
而很多游戏系统出现的问题,其实是由运维工作、流程和规范等问题导致的,比如战双上线后运营误发福利。
所以,为了获得高并发和高可用,运维工具、运维流程和规范一定要做好,不要人力运维,要做到运维的工具化和自动化。此外,监控、报警、人员的快速反应也是大规模系统稳定性运行的必要条件。