【阅读笔记】Java游戏服务器架构实战
书籍链接:Java游戏服务器架构实战
作者提供的源码链接:kebukeYi / book-code
这里对书籍中比较重要的知识点(精华部分)进行摘录(总结)
文章目录
一、游戏服务器架构的总体设计
-
游戏服务器开发时不能仅考虑接收客户端的信息并返回正确信息即可,需要考虑多线程处理消息的情况,否则会出现数据不一致的问题。比如给同一个用户请求的消息加锁或者把请求消息分配到固定的消息队列中。
-
良好的架构设计,需要预知项目哪些功能是公共的、是可以在架构中实现的,这样可以减少重复代码,提前为不同的业务开发提供服务。
-
单体游戏服务器架构的缺点是当注册人数或同时在线人数达到一定限度时,就需要开新的服务器,这样会导致旧服务器的用户越来越少,因此需要重新进行合服操作(用户数据合并到一个数据库中,不同用户连接同一个服务器)
-
分布式微服务器架构的优点是不同模块可以并行访问,如果某个功能坏了,对其他功能影响小,可以修复后快速重启进程,但缺点是对架构的设计难度较大,增加了运维难度,增加了不同模块通信功能的开发量。
一个域名可以配置多个ip
地址,客户端在访问时服务器时可以通过DNS
服务器中的负载均衡算法,返回一个可用的ip
地址,参考一个域名对应多个IP地址。
实际上,大型网站总是部分使用DNS域名解析,利用域名解析作为第一级负载均衡手段,即域名解析得到的一组服务器并不是实际提供服务的物理服务器,而是同样提供负载均衡服务器的内部服务器,这组内部负载均衡服务器再进行负载均衡,再请求发到真实的服务器上,最终完成请求。
二、数据库的选择和安装
- 游戏服务器使用MongoDB进行数据持久化,而非Mysql,原因是MongoDB是文档型数据库,方便将用户的所有信息用json串保存在一个文档中,便于集中写入和读取。
三、游戏服务中心的开发
- 对于游戏的外围服务,比如用户注册、登录、公告、获取服务器列表、角色创建等,可以单独从游戏服务中脱离出来通过游戏服务中心进行统一管理,这样再开发另一款游戏时可以统一使用这个游戏服务中心。
- 游戏服务中心服务是IO密集型服务,应该分配少内存、多CPU内核(多线程);游戏服务是CPU密集型服务,应该分配少CPU内核、多内存(少线程)。
- 游戏服务中心是一个全局的服务,当游戏用户增多,并发数增大以后,可以对游戏服务中心进行动态扩展、使用
Nginx
实现负载均衡,后期还可以将游戏服务中心拆解为多个微服务。 - 游戏服务器需要支持动态伸缩(随着用户量需要增加服务器,因此需要考虑负载均衡算法,支持服务器的集群部署,服务器扩展),使用Web网关统一对服务进行权限控制和流量管理,支持高可用(两台服务实例),使用服务注册中心进行服务治理(服务发现,服务移除)
客户端通过域名,向DNS负载获取某一个服务的网关的IP地址,然后再向这个网关发送请求。网关通过注册中心服务,知道当前有哪些服务可以使用,根据转发匹配规则,从请求的URL中获取匹配的参数,然后将请求转发到匹配成功的服务上面处理。 - 游戏服务中心的开发流程:1)先统一网络通信的JSON数据格式;2)读取数据时,先访问redis缓存(热点数据),如果不存在再访问
MongoDB
(使用JPA的findById
来访问)。findById有synchronized
修饰防止缓存击穿,通过给缓存设置默认值防止缓存穿透,每次更新缓存时会重新设置过期时间防止缓存雪崩;3)将id字符串放入常量池,使用synchronized
代码块避免id被用户重复注册;4)全局捕获异常处理,减少每个controller写try/catch
,便于代码维护,还能将异常统一记录到日志中;5)redis使用setnx
来创建用户<id,name>,登录成功后通过JWT
实现token;
四、Web服务器网关开发
consul
是服务发现和配置的工具,是一个分布式高可用的系统(http
访问)。网关服务从consul
查询所有的业务服务的注册信息,根据服务名称实现客户端的请求转发- web服务器网关并不需要对所有的url进行权限验证,比如静态资源的访问。
Spring cloud gateway
的核心工作原理是使用全局过滤的组件GlobalFilter
作用于所有请求路由,而GatewayFilter
只作用于某个请求的路由。 - 客户端负载均衡组件
ribbon
配合consul
注册中心使用。此外为了解决同一个用户(id相同)的请求被负载到不同的游戏服务注册中心中,导致用户多次注册的问题,这里需要保证在负载时将同一个用户id
打到相同服务器上,可以通过对id求 hashcode再取余 来解决 - 在网关流量限制中,常见的限流算法包括计数器算法,漏桶算法,令牌桶算法。在网关中添加限流过滤器,一个是设置网关的总流量,二是单个用户请求的次数。
五、游戏服务器网关开发
- 对于一个游戏,必须有多个长连接网关的实例服务,网关的动态伸缩可以防止部分
DDoS
攻击,把流量分散在不同网关上可以防止流量过多而导致服务崩溃 - 客户端必须通过 游戏服务中心 获得游戏网关的信息(端口和
ip
),这些信息通过consul
服务注册中心获取(游戏网关会将信息注册到consul
上)
- 在
Java
中利用discoveryClient
,通过服务名从服务注册中心获取服务列表信息。在游戏网关的负载均衡上,使用hash求余法来让同一个用户请求同一个游戏网关,避免并发操作导致的数据不一致 - 客户端和游戏服务器的通信过程中,需要对协议进行编码解码(序列化为二进制数据/反序列化为java对象),加密/解密,连接认证,和心跳检测等动作。相较于原生的java socket,
netty
提供了更加方便进行socket
的长连接。 - 在对客户端和游戏服务器的通信协议制定中,数据包分包头和包体,包头长度固定,包体存放业务数据,长度可变。由于请求信息和响应信息不同,可以分别使用客户端请求协议和服务器端响应协议。
Netty
是一个优秀的异步网络通信框架,已经对网络消息的编码和解码提供了一个完整的解决方案,开发人员只需要根据需求实现自定义的业务即可。MessageToByteEncoder
是Netty
提供的一个编码抽象类,只需要继承这个类,实现encode
方法即可。在encode
方法中,把消息对象的数据依次写入ByteBuf
, 完成消息应用层的序列化,Netty
的底层会在向网络发送数据时,从ByteBuf
中读取序列化之后的Bytes
数组,再发送到Socket中。- 客户端接收到服务器发送的数据之后,需要将数据解码。接收方在最初接收的一个完整的数据包是一个Bytes数组,
Netty
会把这个Bytes
数组封装为ByteBuf
返回给上层应用。解码要做的就是把ByteBuf
中的Bytes
数组数据转化为程序中使用的可读数据对象
Netty
在进行I/O处理时采用的是Reactor模型,主要包括3个模块:多路复用器(Acceptor
),事件分发器(Dispatcher
)和事件处理器(Handler
)。其中Acceptor
负责与客户端建立连接,Dispatcher
是收到消息后,将消息分发到相应的连接链路中,handler
是一个责任链,用于处理消息读写等相应业务。- 虽然重写
Netty
中MessageToByteEncoder
类中encode()
,可以实现消息对象的序列化,但是在实际业务开发中,开发人员并不关心网络层的序列化和反序列化,因此需要对消息进行再次封装实现消息对象的自动序列化和反序列化。- 假设请求消息对象为
xxxMsgRequest
,响应消息对象为xxxMsgResponse
,这里可以通过定义消息抽象类AbstractGameMessage
(抽象xxxMsgRequest
和xxxMsgResponse
的公共功能),接着新增一个ResponseHandler
,利用反射将网络数据包转化为数据对象,并将该Handler
添加到initChannel
的pipeline
中) - 实际开发中,消息体通常是复杂的数据对象并不是单纯的基本数据类型,这里可以通过
JSON
或者protocol buffers
来实现序列化和反序列化。
- 假设请求消息对象为
- 在连接认证中,认证如果成功需要向客户端发送token,这个
token
中包括第三方唯一id,用户Id,角色id,区id和加密公钥。 - 对于网络通信的安全性,由于非对称加密(
RSA
)在明文较长时计算量大,因此这里使用对称加密对数据进行加密,使用非对称加密对对称加密的密钥进行加密。 - 这里说的心跳检测是业务层的一种连接检测机制。它可以检测网络是否正常,客户端是否一直处于空闲等待状态,方便业务逻辑根据这些状态做一些处理,比如关闭空闲连接、保存用户数据到数据库、更新好友在线状态、统计用户在线时间等。如果依赖于TCP底层的
keep-alive
机制,没有及时性,因为它默认两小时才检测一次。- 业务的心跳一般是由客户端发起。为了减少消息的发送量,心跳并不是固定周期的发送,而是客户端检测到当前连接在一定时间内没给服务器发送任何消息(比如用户长时间不做任务操作),才会开始发送心跳消息,而且要在连接认证成功之后才开始发送。
- 如果服务器长时间接收不到任何消息,包括心跳消息(比如网络拥堵,手机应用长时间切到后台),服务器就认为客户端已联系不上了,就会主动断开连接。
- 在
Netty
中提供一个用于检测连接空闲的IdleStateHandler
,包括readerIdleSeconds
,writerIdleTimeSeconds
和allIdleTimeSecond
s三个参数
- 消息幂等处理是指:同样的一条消息,只处理一次即可。对于同一个用户的同一条连接,发送的消息序列
ID
都是递增的。游戏服务器网关在连接认证之后,会记录最近一次收到的消息的序列ID
,如果收到的新消息的序列ID
小于等于上次收到的序列ID
,表示此消息已处理过,丢弃此次请求的消息。重新连接之后,会重置这个序列ID
。
六、游戏服务网关与游戏业务服务之间的通信
-
spring Cloud gateway
的网关组件已完成了对http
请求的消息过滤,负载均衡和转发,可以与业务服务器进行交互。但是这种http
连接是短连接,在游戏服务器中,要求游戏网关和游戏服务器之间建立长连接,即在服务启动时建立连接,在转发时直接发送消息即可,可以减少建立连接时的等待时间,而且为了让网络的延迟越小,消息的序列化和反序列化的速度要快,消息体要尽量小。 -
如果游戏网关和不同的游戏业务服务器直接建立长连接,这个连接网不利于游戏网关和业务服务的动态伸缩和服务的扩展。
-
为了实现游戏网关和游戏业务服务之间的解耦,让游戏网关对游戏业务服务无感知,可以在两者之间增加一个消息中间件(观察者模式/发布订阅模式),比如游戏网关在收到用户的消息之后,会将消息(
xxxMsgRequest
)发布到消息总线服务的一个固定的Topic1
上面;对于游戏业务服务器则会监听Topic1
中的消息并处理消息,处理完消息之后会将响应消息(xxxMsgResponse
)发布到Topic2
中;游戏网关会监听Topic2
中业务服务器发布的消息,并返回给客户端。
比如游戏业务服务包括战斗服务,副本服务和数据服务等,其中战斗服务包括两个服务器,副本服务也包括两个服务器,它们与游戏网关之间的交互是通过消息总线中间件来交互的
-
Spring Cloud Bus
为了解决微服务中各个节点之间的消息同步,一个服务节点可以向其他节点广播消息,其他节点可以根据消息改变自身状态。它底层的网络通信依赖与消息中间件,目前支持Kafka
和RabbitMQ
。Spring Cloud Bus
封装和简化了对底层消息中间件的调用。 -
Kafka
以Topic
为基本单位来存储信息,在创建Topic时,可以指定Topic的分区。当发送消息时,可以通过消息的key
决定存储在哪个分区上,实现负载均衡;消费者消费消息时也是以Topic为基本单位的(即指定一个消费组groupId),在同一个groupId下可以有n个消费者,理论上消费者的数目(n)等于这个Topic的分区数量,这样就能保证一个消费者对应一个Topic分区,实现并发处理 -
游戏网关和客户端的在进行网络通信时离不开消息(客户端的
ip
地址,请求消息)的序列化和反序列化,游戏网关和游戏业务服务之间利用消息中间件(消息中间件里存的是二进制信息)进行网络通信时也如此,只不过包括的数据字段不一样。因此需要增加一个GameMessageInnerDecoder
,它的两个主要功能就是序列化发送消息并送到消息总线中,和反序列化从消息总线中接收消息。 -
游戏网关在接收客户端消息时需要实现负载均衡:首先先利用服务注册中心中的服务ID(
serviceId
)获取服务器ID列表(服务在启动时会向服务注册中心注册当前的服务ID和服务器ID);接着将客户端的消息存放在消息中间件的Topic中。由于一个游戏服务器网关和游戏服务器之间是1对多的关系,如果它们使用同一个Topic(一个服务)则会造成每个游戏服务器都收到游戏服务器网关的转发信息(非目标服务器需要额外判断转发消息到达的服务器ID与当前的服务器ID是否相同,不同则会丢弃该消息)
-
为了减少网络资源的浪费,这里可以为每个服务器分配一个独立的
Topic
,每个Topic
包括服务器的ID
,因此在游戏网关中先对客户消息进行处理,接着增加一个DispatchGameMessageHandler
类,用于转发客户消息到具体的业务服务器中,这里会通过playerServiceInstance
的selectServerById
来获取一个可达的服务器,并缓存这个服务器的信息。
-
游戏网关将客户端消息转发到游戏服务器的流程(就是观察者模式的实例化):
- 1)游戏服务器网关收到消息之后,根据选择的服务实例信息获取此服务实例的服务器ID;
- 2)然后生成游戏服务器网关与此服务实例通信的
business_message_topic_服务器ID
,将消息序列化,并发送到消息总线服务的business_message_topic_服务器ID中; - 3)而此服务实例在启动的时候,会监听同样的
business_topic_服务器ID
信息,用于接收游戏服务器网关发布的消息。
-
游戏网关接收游戏服务器的响应信息的流程
- 1)业务服务器在完成消息的处理之后,会将消息发布到Topic为
gateway_message_topic_网关服务器ID
的消息总线服务上。 - 2)游戏网关的另一个职责是监听
gateway_message_topic_网关服务器ID
,并接收业务服务器发布的消息。
- 1)业务服务器在完成消息的处理之后,会将消息发布到Topic为
-
注意:在通信过程中,一定要把
Topic
对应上,它是整个消息服务通信的纽带。如果
发布消息的Topic
和监听消息的Topic
对应不上,是完成不了消息服务通信的。
七、游戏业务处理框架开发
-
游戏业务处理框架中对线程数量的管理需要考虑任务的类型:I/O密集型,计算密集型还是两者都有;如果有多种类型的任务则需要考虑使用多个线程池:
- 比如业务处理是计算密集型(比如游戏中总战力的计算、战报检验、业务逻辑处理,如果有N个处理器,建议分配N+1个线程)
- 数据库操作是IO密集型(比如数据库操作(数据库和Redis读写)、网络I/O操作(不同进程通信)、磁盘I/O操作(日志文件写入))
分配两个独立的线程池可以使得业务处理不受数据库操作的影响
-
在对线程的使用上,一定要严格按照不同的任务类型,使用对应的线程池。在游戏服务开发中,要严格规定开发人员不可随意创建新的线程。如果有特殊情况,需要特殊说明,并做好其使用性的评估,防止创建线程的地方过多,最后不可控制。
-
在
Netty
中,最常用的几个线程模型核心类有EventExecutorGroup
、DefaultEventExecutorGroup
、EventExecutor
、DefaultEventExecutor
、NioEventLoopGroup
、NioEventLoop
。- 由于在这里的游戏业务中不涉及NIO,所以都是
EventExecutorGroup
(线程池组)和EventExecutor
。在Netty中,EventExecutorGroup相当于一个线程池,EventExecutor
相当于一个java中方的Executor.SingleThreadExecutor
线程池。在游戏开发时,不同类型的任务会被分到不同的线程池组中去执行。
- 由于在这里的游戏业务中不涉及NIO,所以都是
-
从异步处理的问题出发:
-
1)如果要想获得线程的返回结果(数据库查询结果),可以利用线程创建
Future
对象,但是这种方法在使用future.get()
等待结果时,主线程会一直等待,导致线程利用率降低。 -
2)采用回调函数(
Consumer
函数式接口),在子线程执行后调用回调函数完成值的更新,但是这样也会存在问题:调用回调函数的线程和创建回调函数的线程不是同一个线程,如果控制不当容易出现并发操作同一个对象的问题。
-
创建线程Netty的线程池组可以处理以上两个问题:重写了JDK的
Future
,添加了Listener
方法,并新添加了Promise
类(继承自Netty的Future
)。Promise
中可以设置返回的结果,然后会调用Listener
方法。Promise
是和EventExecutor
一起使用的,在创建Promise
的时候,会在构造方法中添加一个EventExecutor
参数,这样监听方法就会在EventExecutor
线程中执行,代码如下所示。
-
这里重写的
Future
接口中包含方法addListener
,在使用时需要传入Listener
的实例,而在创建Listener
实例时需要重写operationComplete
方法。这样让executor
线程即完成创建promise
对象并返回查询结果,又完成listener对象对future
对象的监听(future.get()
),这样主线程就不会阻塞。
-
-
在游戏服务器中,大多数用户是操作自己的数据,也有功能是一个用户的操作影响另一个用户的操作,对于前者只需要对信息按顺序进行处理即可,不存在并发问题;对于后者会出现并发现象。
-
Netty
中关于消息处理的设计具有顺序性,异步性和可扩展性。当客户端与服务器建立连接时,会创建一个Channel连接对象,在这个Channel中,客户端的消息是按顺序处理的(因为一个channel
默认在同一个EventExecutor
中处理) -
Netty
处理消息有几个核心类:Channel
、ChannelPipeline
(默认实现类是DefaultChannelPipeline)、ChannellnboundHandler
、ChannelOutboundHandler
、ChannelHandlerContext
它们构成了Netty消息处理的整个框架。- 其中
Channel
是一个连接对象,而ChannelPipeline
中会管理一个由多个ChannelOutboundHandler
和ChannelInboundHandler
组成的链表; ChannellnboundHandler
负责处理接收到的消息,ChannelOutboundHandler
负责管理服务器发送出去的消息;Channel
接收到客户端的消息,会将消息通过ChannelPipeline
发送到ChannelHandler
的链表中。
Netty
的整个消息处理系统类似于一个责任链,这个责任链由ChannelHandler
组成。例如客户端与服务器创建连接,即初始化Channel
时,可以向ChannelPipeline
的Handler链表中添加Handler
来完成消息的顺序处理过程。
- 其中
-
Handler
链表的好处是提供了灵活的扩展性,想添加一个事件的处理,只需要添加一 个Handler
即可。例如在项目后期需要添加一些监控统计,比如流量统计、消息吞吐量等,只需要添加相应功能的Handler
实现类即可。但是要注意Handler
处理的顺序性,进入的消息是从链表头部向链表尾部流动,发出的消息是从链表尾部向链表头部流动。 -
由于用户大部分时候是处理自己的数据,因此为每个用户分配一个
channel
对象来专门处理信息。这里需要一个线程安全的集合,来管理playerId
和channel
实例的映射。 -
玩家与服务器进行连接时会先注册
channel
对象,如果channel
还未成功注册,玩家发送了其他新的消息,这时会将这些消息放在waitTaskList
队列上排队,待channel
注册成功之后会一次性执行队列上的任务;如果channel
注册成功了,新的消息并不会继续在waitTaskList
队列上排队,而是在EventExecutor
上排队。
-
-
现在考虑不同游戏用户之间的数据交互,比如一个用户需要查看另一个用户的数据,例如排行榜的显示,或者一个用户给另一个用户赠送体力等。
-
举个例子:一个游戏用户给另一个游戏用户赠送体力值时,如果直接操作对方的数据,就有可能出现这样的情况。假如用户A、B都在线,用户A查询到用户B的数据,给用户B
添加体力过程会分成3步。1)获取用户B当前剩余的体力值。
2)在剩余的体力值上添加赠送的体力值。
3)将当前最新的体力值set回用户B的Player对象中。很明显这3步不是原子操作,那么如果在向B的Player中set当前体力值之前,这
个时候正好用户B自己操作通关了某个副本,扣除了10点体力值,就会导致A给B的Player添加体力值的时候就包括了这10点体力值,相当于B的体力值没有任何损失。而实际上赠送的体力和扣除的体力是不能抵消的。 -
在解决不同游戏用户数据交互时,一般有两种方法:
- 一种是游戏功能上面的设计,尽可能避免产生不同用户之间的数据直接交互;
- 另一种需要和策划进行有效的沟通,在不影响用户体力值的情况下是可以被接受的。类似赠送体力值这样的功能,在功能设计上,可以不直接操作对方用户的数据,通过间接的方式,比如给对方发送一封邮件。发送邮件的操作可以是原子性的。
-
在架构设计上解决用户数据之间的直接交互:比如在竞技场中,一个游戏用户要挑战竞技场排行榜上的另一用户,需要提前预览这个对手的一些信息,比如防守阵容、战斗力、各个英雄的职业等。这就需要在当前用户的查询请求中,向服务端查询另一个用户的竞技场信息。为了防止出现上面所说的多线程并发操作的异常,可以使用
Promise/Future
的机制。 -
因为在上面的架构设计中,一个
playerld
对应一个GameChannel
,所以当需要查询一个用户的数据时,需要向这个用户的GameChannel
中发送一个事件。而这个事件被处理之后,它的返回结果由Promise
的监听接口带回。- 解决这个问题的本质就是解决多线程之间的数据交互问题。在
GameChannel
设计的时候,规定了对一个用户的数据操作都是以事件驱动的。比如处理客户端的请求,会把请求封装为一个任务事件,放到GameChannel
中处理; - 本质是在同一个
Executor
中按顺序执行所有的事件,所以这整个过程是线程安全的(这样多个用户查询当前用户信息时,只需要向当前用户发送Event
事件即可,它的返回结果由创建Promise
以及监听接口的线程带回,该线程由各自用户创建)
- 上面的代码中,其他用户将
GetPlayerInfoEvent
发送到playerId
对应的GameChannel
中,在playerId对应的GameBusinessMessageDispatchHandler
中需要通过if_else
来判断其他用户发送的Event类型,做出相应的处理,userEventTriggered
方法如下:
- 解决这个问题的本质就是解决多线程之间的数据交互问题。在
-
上面代码存在的问题是不利于功能的扩展,即每增加一个事件需要修改
userEventTriggered
的判断条件,这里可以采用注解来让GameChannel
事件自动分发处理。- 1)首先定义
UserEvent
注解; - 2)接着修改
DispatchGameMessageService
方法,在项目启动的时候会自动扫描@GameMessageHandler
类实例,接着扫描该类实例下所有被@UserEvent
标注的方法,并把类实例和方法缓存下来。
- 1)首先定义
-
在
GameBusinessMessageDispatchHandler
收到事件的处理时,只需要调用
dispatchUserEventService.callMethod(utx,evt,promise);
即可完成其他玩家发送过来要处理的事件,并返回结果给其他玩家设置的监听接口。
-
八、游戏用户数据管理
-
游戏用户数据需要异步加载:原因是如果游戏服务在等待网络I/O向数据库请求数据时发生了阻塞,会导致游戏服务的吞吐量下降,因此需要考虑将加载数据的线程池和业务处理的线程池区分开,让业务处理线程不阻塞,提高系统吞吐量和响应时间。
- 1)在加载数据的时候,最好是查询一次就可以将所有的数据查出,这样可以减少网络I/O次数,节省更多查询时间,因此这里选择
MongoDB
数据库。 - 2)在本架构设计中,用户第一次进入游戏时,会在
GameChannel
注册的时候加载用户的数据到内存中,用户在游戏服务中心服务登录的时候,会先将用户的Player
数据缓存在Redis
中,进入游戏的时候,从Redis
中读取用户数据,加载到内存(SRAM
)中,这里的Redis
是作为二级缓存(DRAM
)的。 - 3)因为
Redis
是内存型数据库,从Redis
中读取数据要比数据库快很多,但是这种方式在请求Redis
的时候,还是会有网络I/O的操作,阻塞当前线程的业务处理,需要将其修改为异步加载,将Redis
的请求放到另外线程进行处理。
- 1)在加载数据的时候,最好是查询一次就可以将所有的数据查出,这样可以减少网络I/O次数,节省更多查询时间,因此这里选择
-
异步加载游戏数据的实现(使用
Netty
):之前创建的PlayerDao
是同步加载数据库数据的,这里创建AsyncPlayerDao
,将数据库的调用过程放到特定的线程池中,并通过事件驱动的方式交给GameChannel
的handler
进行处理。当查询Player
事件执行完之后,通过Promise
回调方法会将结果缓存在内存中。
-
上面是通过异步将数据加载到缓存(
redis
)中,接下来介绍游戏数据持久化到数据库(MongoDB
/redis
)-
游戏数据持久化方式有2种:
- 1)实时更新,即当用户修改了数据,直接将数据写回数据库中,适合并发量小的场景;
- 2)定时更新,即定时将缓存数据整体更新到数据库一次,适合并发量大的场景,定时间隔需要设置在一个合理的范围内,好处是更新次数比实时更新少很多,缺点1是会丢失一部分数据,导致数据回档,缺点2是当定时触发时,数据库压力最高,定时没有触发时数据库压力最低;
- 3)对于缺点2的解决方法是每个用户自己维护一个属于自己的定时器,从用户第一次进入游戏开始计时,每隔固定间隔持久化一次数据。
-
数据库持久化操作也需要采用异步的方式,即将持久化的操作封装为任务事件,放到指定的数据持久化线程中执行。由于要持久化的是
Player
对象的集合,这就会导致业务线程和持久化线程访问Players
时存在对象共享的问题(并发操作会导致数据不一致),解决方法如下3种:- 1)数据持久化的时候,将对象拷贝一份,缺点是需要业务人员在开发时手动完成对象复制;
- 2)先把业务操作的
players
序列化为json,再交给数据持久化线程处理,序列化虽耗时,但不用关注集合并发问题; - 3)使用线程安全的集合,比如
ConcurrentHashMap
,LinkedBlockingQueue
等
-
在移除
GameChannel
,或者关闭游戏服务器进程的时候,需要强制将缓存中的数据持久化到数据库中 -
当
GameChannel
注册成功之后,需要开启两个定时器:设置Redis
持久化定时器和MongoDB
持久化定时器 ,由于Redis持久化速度较快,故时间间隔可以短些
-
-
将
Player
对象中的数据和行为进行分离 :原因是Player
对象(主要是各种业务线程来操作)中包含了数据以及对应的get
,set
方法,如果把Player
对象的行为,比如等级升级,英雄技能学习,宠物领取等行为都放在Player
类中,会十分的臃肿,而且这些方法不能以get
,set
开头,因为这些行为不需要被序列化成json
。- 因此可以用专门的
manager
类来单独管理Player
对象的行为,比如HeroManager
管理英雄技能的学习,levelManager
管理玩家的等级等。 - 在
XXXManager
类中,可以在对象创建时,将数据对象通过构造方法传进来,之后就可以在这个XXXManager
类中直接对这个数据对象进行操作了。但是在XXXManager
类的管理中,一个XXXManager
类不要操作另外一个XXXManager
管理的数据,比如在技能升级时,扣道具的操作要放在道具的Manager中,而不要在技能Manager
中直接操作背包数据进行修改。这样可以保证数据操作的唯一性。
- 因此可以用专门的
九、RPC通信设计与实现
-
分布式架构永远绕不过的问题就是不同进程间的数据通信,即远程过程调用(Remote Procedure Call, RPC)现在被统一称为内部RPC。现在有很多开源的RPC库,比如gRPC、Dubbo、Hessian、Thrift等。毋庸置疑,这些都是非常优秀的RPC框架,但是它们大部分都用于Web服务,且为短连接,满足不了游戏对高性能的需要。
-
有些架构师在项目初期就全面实行微服务化,也有架构师认为架构是根据需求变化的,应该根据项目的需求来选择合适的架构,架构也是随着项目变化而变化的,不能贪图一次性的完美。总之,架构应该以满足目前需求为先,并具有一定的前瞻性。
-
游戏服务需不需要微服务化:
- 1)在Web服务中,服务拆分的粒度可以很细,甚至一个数据库表都可以对应一个微服务。这个服务只负责处理这个数据库表的操作,比如订单服务,只需要负责订单的创建、查询、状态更新即可;商品“秒杀”服务,只需要实现商品“秒杀”服务功能即可。功能独立,界限清楚是Web服务方便进行微服务化的主要原因之一。
- 2)对于游戏服务来说,游戏大部分功能之间依赖性强,功能界限不是很明确,所以不太适合细粒度的划分。比如背包功能,很多功能都会依赖它,例如技能升级需要消耗道具,副本通关需要消耗和增加道具,英雄养成系统更不用说了。所以将这些功能多、相互依赖强的功能都放在同一个服务中,可以称之为游戏的核心服务。基本上游戏的所有功能都是围绕这个核心服务展开的。
唯一的选择就是将功能拆分成不同的进程,并实现单个进程的负载均衡(线程池),利用多组硬件的资源满足整个服务的需求。这种拆分的路线和微服务的理念非常类似,但是却达不到Web微服务的粒度。因此,游戏服务的拆分就是为了分担服务压力,提升服务的处理能力。 - 游戏服务拆分的目标就是分担服务的压力,提升服务的处理能力。所以在对游戏服务进行拆分时需要考虑这些因素,下面以竞技场和世界聊天服务为例子:
- 1)由于游戏的数据基本上是存在内存中的,但有些服务并非如此,比如在竞技场中,玩家需要根据排行榜来匹配其他玩家,而排行榜的数据需要访问redis缓存,如果业务线程通过I/O大量访问redis缓存会存在阻塞问题,因此需要将排行榜查询这个服务单独拆分出来,由非业务线程来处理;
- 2)比如聊天服务,这个服务就相对独立一些。虽然服务器只是中转聊天信息,但是如果聊天的人太多,就会占用大量的网络资源比如世界聊天,如果同时在线5000人的话,一条聊天数据就需要发送5000次,如果同时有10个人一起发消息,就是50000次。这么多的消息,业务线程肯定处理不过来,不仅使聊天消息延迟,也会因为线程繁忙导致其他的业务消息延迟。因为它们是使用的同一个
EventExecutorGroup
,所以这个服务最好是单独拆分出来。 - 类似的服务还有地图服务、排行榜服务、组队匹配服务、战斗服务等,不同的游戏服务的功能不一样,但是本质是一样的,就是要把一些严重影响吞吐量的服务拆分出来。而同时在线人数太多、内存不足的问题,可以使用负载均衡来解决。
-
使用总线服务自定义RPC组件:传统的
RPC
组件(gRPC
,Thift
等)存在的问题是,在一个服务与其他服务进行通信时,需要知道该服务的ip和端口,对于新的服务的创建和销毁无法感知(业务部署通常是多实例的),服务之间的耦合性高,而且不同服务既是客户端又是服务端,维护起来比较麻烦。因此这里利用消息总线服务,封装了适合游戏服务的RPC系统,开发人员只需知道服务的ID,不用知道服务器的ip和端口。- 业务服务之间的通信与之前介绍的游戏网关和游戏业务服务,在设计上是一样的,即基于总线服务实现。
- 假设当前的游戏业务包括游戏核心业务、聊天业务和竞技场业务。游戏核心服务部署了3台服务器,竞技场服务也部署了3台服务器。假如竞技场上面有这样一个功能,在竞技场每挑战一次,都会扣除一次挑战次数,一个游戏角色,一天只能挑战5次,但是可以使用钻石购买竞技场挑战次数。而钻石的数量记录是在游戏核心服务中的。
-
负载均衡如何实现:由于每个服务是多个实例的,因此目标服务实例的ID就不是固定了,需要通过一个公共的服务来同步服务实例的存活信息。
- 其实并不用抽象出一个公共服务出来,目标服务实例ID的获取方式如下所示:在多个服务同时访问一个目标服务实例ID时,先从内存找,不存在再从redis(二级缓存)中找,如果找到了则ping一下通不通,如果不存在再从服务注册中心中选择一个实例(同时存入内存和redis)
- 在需要一个目标服务实例ID的时候只需要调用
PlayerServicelnstance
方法中的selectServerld
方法即可。它会统一管理目标服务实例ID的选择。选择的目标服务实例ID会缓存在本地内存中,如果没有一个清理策略的话,会存储得越来越多,最终导致内存泄漏。这里的建议是,可以在playerld
对应的GameChannel
结束的时候,发送一个清理服务实例ID缓存事件,来清理服务实例ID的缓存数据。
- 其实并不用抽象出一个公共服务出来,目标服务实例ID的获取方式如下所示:在多个服务同时访问一个目标服务实例ID时,先从内存找,不存在再从redis(二级缓存)中找,如果找到了则ping一下通不通,如果不存在再从服务注册中心中选择一个实例(同时存入内存和redis)
-
这里补充一下为什么不使用其他的RPC组件而是使用消息中间件作为自定义的RPC通信,主要原因是
- 1)一开始已经使用总线服务来处理游戏网关和游戏业务服务之间的通信,如果使用第三方的RPC组件,则需要维护两种通信框架;
- 2)考虑到第三方RPC组件与本系统服务框架可能存在不兼容问题,又不方便修改,长期下去不利于项目的维护。
-
这里区分客户端发送的消息和RPC的消息,定义了一个枚举类型
在创建竞技场服务项目时,可以在
application.yml
上配置竞技场服务要监听的topic
,以及数据处理之后要发布到的topic
。
-
RPC
消息的发送和接收过程(使用Netty
线程池实现消息的异步处理,消息处理过程包括编码/解码,加密/解密,连接认证,心跳检测等):- 1)大部分
RPC
的请求都是由客户端的请求操作触发的(可以放在之前定义的GatewayMessageContext
类中,不需要再引入新的类就可以直接调用RPC
的发送方法)。RPC
消息的发送就和客户端返回消息一样,需要经过一系列的Handler
,最终到达GameChannelPipeline
链表的头部Handler
。因此在AbstractGameChannelHandlerContext
添加writeRPCMessage
方法; - 2)当
RPC
请求发送之后,竞技场服务需要监听和发送RPC消息一样的Topic
,来接收RPC
消息,而且要把接收到的RPC请求纳入GameChannel
进行处理; - 3)经过
GameChannelPipeline
的一系列Handler
的处理之后,最后消息到达GameChannelPipline
的HeadContext
类中,在这里调用GameChannel
的channel.unsafeSendRpcMessage(gameMessage,callback);
方法。在这里面会判断是否为RPC
响应消息,如果是响应消息的话,会调用GameRpcService
中发送RPC
响应消息的方法到消息总线中。
- 1)大部分
-
RPC
请求超时检测:- 当一个服务给另一个服务发送一条消息的时候,如果目标服务长时间没有响应,应该有超时处理。最好的方式是给业务代码抛出一个超时的异步,以便业务做相应的处理。比如在发送
RPC
消息时,会调用addCallBack()
回调方法,此时会在缓存中建立RPC
消息序列的ID和回调接口之间的映射,如果该RPC
请求消息有了响应,则从缓存集合中删除掉该消息序列ID和value。 - 如果在启动延时任务时发现缓存集合中的
ID-value
还在,说明该ID的RPC
消息序列还没有返回响应结果。
- 当一个服务给另一个服务发送一条消息的时候,如果目标服务长时间没有响应,应该有超时处理。最好的方式是给业务代码抛出一个超时的异步,以便业务做相应的处理。比如在发送
十、事件系统的设计与实现
-
事件系统主要由事件源(事件产生)、事件内容(发布事件)、事件管理器、事件监听接口组成。
- 事件系统流程:在服务启动的时候,功能模块需要注册对事件监听的接口,监听的内容包括事件和事件源(事件产生的对象)。当事件触发的时候,就会调用这些监听的接口,并把事件和事件源传到接口的参数里面,然后在监听接口里面处理收到的事件。事件只能从事件发布者流向事件监听者,不可以反向传播。
-
为什么要使用事件系统:
不同模块之间存在数据交互,如果某个模块直接调用其他模块会导致随着系统规模扩大复杂度相应增大,由于模块A与其他模块是1对多的关系,引入事件系统后会向所有模块广播一个事件数据,模块通过监听接口监听该消息是否是自己所需要的,这样可以减少模块与模块之间的直接调用,使代码更容易维护。 -
事件系统的实现并不复杂(类似于观察者模式),一般由3个部分组成,即事件内容对象、事件分发管理器、事件监听接口。功能模块可以继承实现事件监听接口,然后将监听接口的实现类实例注册到事件分发管理器上面。当一个事件产生的时候,调用事件分发管理器,把事件发送到对应的监听接口中。因为事件系统是一个公共组件,所以把它放在
my-game-common
项目中。- 由于每次在创建事件监听类(
PlayerUpdateListener
)时都需要继承事件监听接口(IGameEventListener
),创建新的事件(PlayerUpdateEventGame
)时需要继承事件内容(EventGame
)接口。会导致监听类非常多,事件类非常多,使用起来相对麻烦一些。这样不仅浪费开发时间,而且也使代码变得臃肿,因此这种方式适合事件不是太多的服务。 - 因此可以自定义基于注解的事件系统(将不同监听类中通用的方法抽象出来),新增两个特定注解,
@GameEventService
和@GameEventListener
,其中@GameEventService
用于标记在类上面,它继承了Spring中@Service
的特性,标记了这个注解的类可以作为Bean被Spring容器管理。@GameEventListener
标记在类的方法中,表明这个方法处理某个事件 - 比如
@GameEventListener(PlayerUpgradeLevelEvent.class)
。每次当sendGameEvent
之后,会通过String key = gameEventMessage.getClass().getName(); List<IGameEventListener> listeners = this.eventListenerMap.get(key);
,即通过对应的事件内容类,找到对应的Listener
列表,并逐一进行listerner.update()
。(是不是很像观察者模式)
- 由于每次在创建事件监听类(
个人总结和思考
个人的学习建议:
-
第一阶段:一边阅读该书,一边理解作者提供的代码
-
1)阅读完后问自己如下几个问题(系统架构角度)
- 【问】:游戏开发架构至少需要那几个模块?
【答】:游戏服务中心(注册,公告等),web网关(DNS负载),游戏服务网关(服务负载),服务注册中心(consul),业务服务,总线服务。
- 【问】:为什么要开发游戏服务中心?
【答】:对所有开发的游戏进行用户注册,用户登录服务的统一管理 - 【问】:为了让整个系统能够动态伸缩,游戏网关如何将客户端发送的消息交给游戏业务服务器去处理?
【答】:首先网关通过Netty与业务服务器之间建立长连接,接着使用Spring Cloud Bus 总线服务,这里底层用到了Kafka消息中间件(底层用到了Netty
),业务服务器监听gatway_topic_id,用于接收客户端传来的序列化后的消息,而游戏网关则监听business_message_topic,用于接收业务服务器处理后的消息。 - 【问】:为什么要按不同的业务类型来分配独立的线程池?
【答】:比如业务处理是计算密集型的,线程数创建过多会消耗内存资源,而数据库操作时I/O密集型的,可以创建多个线程来访问;不同独立的线程池的目的是让某个业务的阻塞不影响其他业务的线程(避免服务雪崩)。 - 【问】:为什么要使用Netty的线程池组来解决异步数据加载?
【答】:Java的Future类会阻塞主线程,Java回调函数的调用和创建并不是同一个线程,会存在线程竞争存在的问题;而Netty在处理消息时会为每个用户创建一个channel,每个channel由线程池组中的一个线程(Executor)来处理,在channel中可以append要处理的事件Handler,因此Netty消息处理能够保证有序,而且重写了Java的Future,并创建Promise和Listener,让创建回调和调用listener可以是同一个线程。 - 【问】:如何通过注解来给Netty中的Channel自动分发事件处理?
- 【问】:为什么要建立事件系统,如何建立基于注解的自动事件系统?
【答】:可以使得业务模块之间的交互耦合性不是那么高。可以利用观察者模式(注册和发布)和注解,利用SpringBoot的自动扫描,生成实例Bean由Spring容器管理,在方法上增加GameEventListener(class)注解识别并执行指定的方法。
- 【问】:游戏开发架构至少需要那几个模块?
-
实现的细节主要关注如下这几部分(代码层面)
- 如何利用
consul
实现服务注册和发现 - 如何利用
Spring Cloud Bus
来访问Kafka
,实现游戏网关和业务服务之间的客户端消息通信、以及业务服务和业务服务之间的内部RPC通信 Netty
消息处理过程Netty
如何异步加载数据,并利用事件监听器回调返回处理后的数据- 基于注解的事件驱动开发
- 事件系统的开发
- 如何利用
-
-
第二阶段:代码部分反复巩固和学习,在理清各个模块之间究竟是怎么分工和交互的,项目架构为什么要这么设计,这样设计是基于怎样的需求,能解决什么样的问题,还是基于某种设计理念之后,吃透作者提供的代码。
-
谈一谈我对架构的理解:
- 1)架构师一定要改变思维,不能只停留在单体服务上,要思考如何保证服务运行时的高可用性,因此服务一定是多实例的,是集群部署的(
redis
,mysql
,rabbitMQ
等),要尝试提出分布式高可用服务的解决方案; - 2)在满足需求的情况下,架构师更多要考虑系统的性能(吞吐量,
CPU
,内存等使用率,时延等),可以简单理解: 程序员要生孩子(实现项目的功能性需求),架构师要陪伴孩子成长(随着项目需求的变化,对项目的非功能性需求提出解决方案)。 - 3)架构师平时要更关注于服务的可用性,模块的可重用性,后期是否易维护等抽象的问题。
- 4)架构师既要有编程能力,又要有解决项目问题的经验,也要有理论知识(软设、系分、架构考就完了)
- 5)架构是根据需求变化的,应该根据项目的需求来选择合适的架构,架构也是随着项目变化而变化的,不能贪图一次性的完美。总之,架构应该以满足目前需求为先,并具有一定的前瞻性。
- 1)架构师一定要改变思维,不能只停留在单体服务上,要思考如何保证服务运行时的高可用性,因此服务一定是多实例的,是集群部署的(