游戏服务器的主要作用是将玩家聚在一起,让玩家之间能相互可见,并提供能够使玩家与玩家相互交互的功能。需要连接服务器的游戏称之为网络游戏,网络游戏比单机游戏更受大众的欢迎。真实玩家之间技能操作的较量,真实队友之间完美的配合,这是网络游戏竞技的魅力所在。
本文设计的游戏服务器可以灵活的扩展服务。除了基本的对战,匹配服务,还可能根据需求扩展排位,邮件,好友等服务。服务器也可以通过增加每种服务部署的数量来提高并发能力。下面就来介绍如何实现高并发可扩展游戏服务器。
在分布式服务器架构中一般会根据功能对服务器进行划分,例如
像这样按照功能对服务器进行划分,称为服务器的横向扩展。为什么要对服务器进行划分?原因就像函数或类要职责单一一样。如果所有功能在一个服务中,其中某个功能出了bug导致服务奔溃就会使整个服务不可用。
相较于服务器的横向扩展,同一服务部署多个,叫做服务器的纵向扩展
为什么要有服务之间的通讯? 当客户端通过TCP将消息发送到服务端后,服务端内部服务与服务之间通过协作共同完成客户端的请求。
例如图1.1 处理一个客户端发来的登录消息。在这个例子中,消息的流程如下:
服务与服务之间怎么通讯?这里的一个服务本质就是一个进程,所以我们要讨论的实际上就是进程之间的通讯。
我们将从以下几点去讨论服务之间的通讯:
这里所说的进程标识并不是在一台主机上操作系统为每个进程在启动时分配的唯一标识。而是在多个主机上部署多个服务时,为了让这些服务相互通讯,而分配的唯一标识。
在分布式服务器中,需要唯一标识每一个实例。每个进程都有一个自己的唯一标识,这样在发送消息的时候才能通过唯一标识找到目标进程,就像每个电脑在网络中都有一个唯一的ip一样。
如何构造这个唯一标识。这里提供一种标识方式:zone.group.server_type.instance_id。前面zone表示大区,group表示分组,server_type表示进程的服务类型,instance_id是为了区分同一服务类型的标号。将这些有实际意义的信息编入标识中,在消息转发的时候可以提供更多的转发策略。
进程之间通讯的方式可以是直接通讯,可以是通过一个消息转发中心去间接通讯。如图1.2所示,直接通讯的缺点是一个进程需要与每一个进程建立连接,这样所建立的连接个数是n x n。对于单个进程来说需要维护n个连接,如果在同一台主机上部署多个进程,那么这个主机的连接资源消耗将会是n的数倍。一组服务器超过一千个进程时,单个主机的连接数可能上万,还没开始真正提供服务估计主机资源就消耗了大半。所以我们只能将目光转移到间接通讯上来。如图1.3,间接通讯是所有进程连上消息转发中心,消息转发中心负责将消息发送给目标服务器,这样的通讯方式只需为每个进程建立一个到消息转发中心的连接,这样建立的连接数是n。但是这个通讯模型的问题在于,消息过多时消息转发中心繁忙而成为服务器性能瓶颈。所以根据服务的增加,消息转发中心也会进行分布式部署。
推荐使用MQ实现消息转发中心的功能。而不是手动实现消息转发:新建一个转发服务,所有服务使用TCP连接到转发服务。因为MQ为多种语言提供了客户端。假如服务都是用C++开发的,但如果有一个服务使用其他语言可以更好更快的开发,这时使用MQ会比使用TCP门槛更低更快捷,也不需要处理分包粘包。MQ的优势在于,其他语言可以很方便的接入,以及它的持久化和后台可视化数据统计。
所有进程的标识会作为每个进程的配置,即每个进程知道所有进程标识。
所有进程的进程标识是服务启动前根据配置文件生成,并作为所有服务启动时的配置被加载。
因为这些标识信息可以区分服务类型,所以消息的发送支持以下几种方式:
使用MQ通讯时,每个进程都会将自己的标识作为订阅模式向MQ声明接收匹配此模式消息。消息发送方根据需要,来决定以上述哪种方式发送消息。
对于一个服务,需要处理成百上千个并发请求,绝大多数请求都会涉及到服务与服务之间的通讯。服务间通讯会导致发起的一方等待。例如下面这个例子:A服务的ProcessReq函数在处理的时候需要去B服务拉去数据,ProcessReq发送请求之后不知道B回的消息什么时候到来,能不能到来,这时函数就会陷入等待。
void ProcessReq()
{
...
GetDataFromB(); // wait for msg back
...
}
上述场景如果使用多线程会有很多问题:一个是线程同步有些复杂,另一个更主要的原因是太费资源而且性能不高。如果有上百个请求同时进行,就会启用上百个线程,它们不做别的事情,仅仅是等待。这会占用很多系统资源,并且线程频繁切换也会浪费cpu性能,用户代码对资源的利用率将会很低。
了解了多线程并发的缺点之后,再来看协程。
什么是协程?
对于协程有一定了解的读者可以跳过,对于协程不了解的,这里推荐一篇文章(c++、c版)连接。
简而言之,协程是用户线程控制执行流程切换的产物,只要内存足够大单个线程可以运行无数协程。线程处理协程的切换可以用以下例子来理解。
整个协程切换的过程类似于调用goto
这些关键数据相比一个线程的数据来说少的多,而且协程的切入切出只是一点内存的拷贝,无需切换线程。使用协程并发可以充分利用CPU,使单核CPU利用率接近100%。
因为协程并发非常高效,所以我们的服务进程使用单线程协程的方式并发。在多核CPU上可以部署多个进程来充分利用资源,这种方式比多线程协程更加简单、安全。
目前数据库主要区分关系型数据库与非关系型数据库。我们会根据需求的不同,如读写频率、数据安全等,从这两类中,挑选适合的数据库使用。
在非关系型数据库中,Redis由于其基于内存亦可持久化、支持网络、key-value数据存储,支持多种数据结构如zset、发布订阅,提供多种语言的API等特点,成为我们的选择。并且Redis是单线程,不会存在并发问题。
由于玩家数据修改较为频繁,且对查询性能要求较高,因此将玩家的数据存到Redis中。除此之外,Redis的zset结构,可以很方便的实现排行榜。
而像第三方账号绑定和充值记录等数据,由于对数据的安全性要求更高,且需要关联查询,因此存在MySQL中。
redis中的数据是存在内存的,如果一个用户很久没有登录过服务器,那么他的数据就是不活跃数据。这部分不活跃数据会占用大部分Redis的存储空间。可以将这些不活跃数据迁移到磁盘数据库中,如MYSQL。获取数据时,先从Redis中获取,Redis中数据不存在时,再从MYSQL中获取数据。迁移的具体实现步骤如下:
利用scan
命令遍历Redis的键空间。Scan命令通过反向二进制迭代器循序渐进的遍历Redis的键空间,它的特点是有重复到不会漏掉key。
通过object idletime key ...
命令批量(一次scan的结果)获取key的空转时间,通过这个时间来判断是否为不活跃数据。例如我们认为一个月以前的数据为不活跃数据,空转时常大于一个月就是不活跃数据。
通过type key ...
命令批量获取key的类型并过滤。返回的类型有 string (字符串)、list (列表)、set (集合)、zset (有序集)、hash (哈希表)、stream (流)。我们只迁移是string类型的key(注:为什么不迁移其他类型的key,大家可以思考一下)。
通过ttl key ...
命令批量获取key的过期时间,判断是否设有过期时间。为了简单处理,我们不处理包含有过期时间的key。
获取key最新的数据, 并计算md5sum。将最新的[key,value]保存到MYSQL中。再次获取key的最新数据,并计算md5sum。
对比两次md5sum。如果相同就删除Redis中的数据,key完成迁移,如果不同(表示key被修改,变成了活跃数据)就结束迁移过程。求两次md5sum的原因是,将数据保存到MYSQL这段时间内,有可能用户刚好访问并修改了数据。
玩家数据使用Protobuf结构定义,保存时将其序列化后写入Redis。因为玩家数据读写频繁,所以将其保存到Redis数据库。为什么使用Protobuf,我们将在下文描述。
Protobuf是一种轻便高效的结构化数据存储格式,可用于结构化数据序列化,非常适合数据存储或RPC数据交换格式,可用于通讯协议,数据存储等领域,并且是平台无关的。
游戏玩家数据是一种结构化的数据,下面的代码展示了某游戏玩家数据的结构。
message PbRoleInfo
{
optional PbRoleRegisterInfo register_info = 1;
optional PbRoleLoginInfo login_info = 2;
......
}
message PbRoleRegisterInfo {
uint64 uid = 1;
uint32 register_time = 2;
string country = 3;
uint32 channel = 4;
......
}
message PbRoleLoginInfo {
uint32 last_login_time = 1;
uint32 last_login_ip = 2;
....
}
保存玩家数据时,以玩家的id为key, 数据序列化后的数据为value 写入到Redis中。修改时,从Redis中获取value,反序列化成Protobuf的结构体,修改相应字段后,再序列化存回Redis。
使用Protobuf的优点在于,数据中的int,uint类型会使用Vriant编码来压缩数据。数据的反序列化是以tag(也就是等号后面的标号)为索引来解析的,在修改数据结构后,只要tag不重用数据的解析就不会有问题。
并不是把玩家的所有数据都存在一个key-value中。玩家数据需做一些分类,比如说:好友,邮件等都会单独存数据库。这样做的好处是:每次数据的读取和写入都只涉及相应模块数据;而且更加安全,即使某一模块数据损坏不影响其他模块的数据。
玩家数据修改后,需保存回数据库。如果玩家在线,还需同步给Client。
void ProcessReq()
{
...
SaveData();
if (role.IsOnline()) {
SyncDataToClient();
}
}
对于数据修改同步给Client,不是直接同步所有数据。因为大部分数据都是没有被修改的,所以需要做更细粒度的控制来减少流量。关键思路是划分块,按块的粒度同步。上面的PbRoleInfo就可以利用其自己定义的结构来划分数据块,比如register_info数据变动,就只发送register_info数据块,其余块同理。
而Server在数据修改后采用分块机制同步给Client数据时,都需人为判断最终修改了哪些块的数据。这无疑会增加编码的复杂性与出错率。解决方法是将人为判断那些数据修改了改成用程序判断。比如分别对原数据和修改后的数据的每个块求一次md5sum,对比两次md5sum的不同,来决定最终需发送哪些数据块(即需同步的数据块)。
{
...
old_md5 = md5sum(data)
ProcessReq()
now_md5 = md5sum(data)
compare now_md5, old_md5 then sync data
...
}
这里有一个疑问,为什么数据同步到client时,分块更新,但是更新Redis中玩家数据时,全量更新?为什么Redis中玩家数据不使用hash(哈希表)存储,这样也可以实现分块更新?
一部分原因是,更新Redis走内网服务,不用太担心数据量带宽问题;另一个更重要的原因是,玩家数据如果使用hash存储,在迁移不活跃玩家数据时,将hash结构存到MySQL中会比较麻烦,这也是为什么不活跃数据迁移时,只迁移string类型的key的原因。
日志一般会分成几个等级:error、warn、info、debug
每行日志都会有一个头部信息,这个信息包括 日志等级|时间| 文件名,函数,多少行
如果在主线程调用日志接口打印日志,会引起主线程卡顿。原因是写日志是写磁盘数据,磁盘数据的写入并不会每次都刷新到磁盘中去,而是会先写入缓存,当缓存满了再一起将数据写入磁盘,批量刷入数据到磁盘可能会引起调用线程等待。所以需要另起一个日志线程专门用来写日志。
对于上述的同步,有一个简单有效的设计方案:为主线程和日志线程分别提供一个队列,主线程只将日志往自己的队列中写,日志线程从自己的队列里读取日志消息,当日志线程处理完自己的队列后,加锁,交换两个队列的指针。整个过程中只需要对主线程队列加锁,因为日志线程会通过交换操作访问主线程的队列。这样的设计可以减少线程对锁的等待时间,从而提高效率。
客户端与服务端通过TCP连接进行通讯,connsvr是服务端专门用来管理TCP连接的。下面将介绍connsvr服务如何高效处理TCP连接,以及如何解析TCP字节流的数据。
早些时候linux网络服务器处理TCP连接,需要为每个连接创建一个进程或线程,并通过阻塞的方式读写socket消息流。如今的网络服务器需要支持成千上万的网络连接,这个方式早已被淘汰,取而代之的是I/O多路复用。I/O多路复用允许我们同时检测多个文件描述符(Linux一切皆文件,TCP的连接也是文件描述符),检测到哪些连接有可以执行的I/O操作后,根据I/O事件的类型(可读或可写)去读写文件描述符。
系统调用 select()和 poll()是用来同时检查多个文件描述符就绪状态的方法,它们具有良好的可移植性。但是当检查大量的文件描述符时,这两个函数都会遇到一些问题。
使用epoll()将需要关注的socket文件描述符事件注册到内核,内核记录了注册的文件描述符列表— interest list(兴趣列表),并维护了处于 I/O 就绪态的文件描述符列表— ready list(就绪列表)。
注册socket文件描述符到epoll兴趣列表时,内核会将epoll相关的处理函数设为socket发生I/O事件时的回调。当内核收到某socket的I/O事件时,会调用该回调函数。回调函数会将此socket的引用添加到epoll的就绪列表。想知道有那些I/O事件发生只需要读取就绪列表。
这样的实现在关注大量文件描述符时,性能不会随着关注的文件描述符数量的增长而下降。epoll的兴趣列表,避免了每次都将关注的文件描述符列表在用户与内核之间拷贝。epoll的就绪列表避免了在所有关注的文件描述符列表上遍历出发生I/O事件的文件描述符
从select、poll到epoll是Linux内核的进步,现在的网络服务器都会使用epoll去实现。
关于select、epoll原理的超详细链接
TCP消息是字节流,需要自己定义上层协议解析数据。自定义协议的格式一般是这样设计:固定包头的大小,包头会包含包体的长度字段,包体紧跟在包头之后。
一次接收数据可能不足一个完整的包(分包),也可能包含多个完整的包(粘包)。在解析数据的时候:
最终部署时,每个服务都包含了二进制的执行程序和配置文件。配置文件中的某些配置是可以在部署时动态替代的,比如MYSQL代理服务mysqlsvr的配置的其中一行
mysql_ip : {{mysql_ip}}
连接的ip地址就需要被替换成真实的值。
所以我们的部署需求主要是:
Ansible使用SSH连接到目标机批量执行任务。使用Ansible时需要定义目标机的信息,我们把这些信息配置在host文件中。在host文件中可以为多个目标机定义一个分组,一个分组也可以定义组变量。例如
[dev]
192.168.1.2 ansible_ssh_user=xxx ansible_ssh_pass=xxx ansible_sudo_pass=xxx
192.168.1.3 ansible_ssh_user=xxx ansible_ssh_pass=xxx ansible_sudo_pass=xxx
192.168.1.4 ansible_ssh_user=xxx ansible_ssh_pass=xxx ansible_sudo_pass=xxx
[dev:vars]
mysql_ip=127.0.0.1
Ansible的命令工具ansible-playbook,通过指定host文件和任务文件来在目标机上运行任务。
ansible-playbook -i host --limit=dev task.yml # --limit 指定执行任务的主机分组
Ansible的任务是基于它自己提供的功能模块:我们要拷贝文件到目标机,需要用到copy模块;
- host: all
- name: Copy file
copy:
src: /home/deploay/mysqlsvr/mysqlsvr
dest: /home/user00/mysqlsvr/mysqlsvr
我们需要替换配置文件中的内容并将替换过后的文件拷贝到目标机,要用到template模块;
- host: all
- name: Template a file
template:
src: /home/deploay/config/mysqlsvr.conf
dest: /home/user00/mysqlsvr/mysqlsvr.conf
# 经过template模块替换后mysqlsvr.conf中```mysql_ip : {{mysql_ip}}```被替换成了```mysql_ip : 127.0.0.1```
可执行文件与配置文件传输完成后,要启动服务器,这里使用Anislbe的shell模块直接在目标机上运行shell命令;
- host: all
- name: start mysqlsvr
shell: cd /home/user00/mysqlsvr && ./mysqlsvr mysqlsvr.conf