我们启动了一个新的游戏项目,产品上线后虽并未取得市场上的成功,但研发过程却很有价值,让我受益匪浅,值得记录下来。在这个项目以前我一直从事游戏客户端的开发工作,端游手游都做过。这个新项目我将负责游戏服务端的开发,并且大部分工作都是我一个人在做,从零构建了服务端的绝大部工作。

1. 架构

游戏采用的是世界同服设计,即所有玩家都同一个服里面玩,不需要选择服务器。这种类型的游戏服务端必须采用可横向扩展的架构,否则客户端激增的情况下会击垮某个服务,只能选择停服维护。我们已经看到很多产品在上线当天服务端崩溃停服了数个小时才恢复。

我将整个系统分成四种服务:

  1. 网关服
  2. 游戏逻辑服
  3. 聊天服(ChatServer)
  4. 存储服(DatabaseServer)

每一个服务都是可以动态扩容的,通过在不同的物理机上启动多个实例来分担请求的压力,网关通过负载均衡将客户端路由到后端服务池中。

2. 网关服(GateServer)

GateServer 作为客户端与服务端通信的第一道关卡。客户端启动后,会先发送一个 HTTP 请求到 GateServer 上来获取一些客户端需要知道的信息,包含:

  1. 服务端状态 (正常/维护/关闭等状态)
  2. 客户端是否需要更新 (如需要的话会包含商店跳转地址或热更新文件下载地址)
  3. 公告 (进入游戏场景前显示的公告)
  4. GameServer和ChatServer的IP地址 (用某种负载均衡算法从池中选择一个)

Gateway对内监控着GameServer集群的健康状态,对外提供http服务跟客户端通信,所以我使用nginx来实现。但因为需要能运行一些业务逻辑会涉及部分编程,所以我采用OpenResty。OpenResty在nginx里内嵌了Lua解释器,可以很方便的提供动态服务而不需要在后面在启动一个http server。Gateway启动时从配置文件里读取GameServer集群的IP地址列表,然后为每个IP都启动一个timer,这个timer里定期发送个TCP请求去查询GameServer的状态。因为每个timer都是运行在一个coroutine里的,而tcp通讯都是非阻塞,所以整个服务运行性能良好。

Gateway提供了一些管理接口给内部使用,比如添加删除GameServer。所以整个服务不需要暂停就能动态扩展服务器的容量。这些接口都是简单http请求,所以十分的方便。

Gateway还负责了公告功能,主要用来通知玩家一个主要信息,比如停服维护。

OpenResty实现这些功能太方便了,而且IO通信都是非阻塞的,经过Lua的coroutine封装后对使用者提供同步的API。以后的游戏甚至可以考虑完全用其作为GameServer。

3. 游戏逻辑服(GameServer)

GameServer处理主要的业务逻辑,考虑到服务器端有发送信息给客户端的需求,并且我希望通信能更实时一些,所以使用一条TCP长连接跟客户端通信。GameServer有多个实例,每个都是等价的,玩家连接到那一个上都能进行游戏,所以可能很方便的横向扩展来满足不同的用户容量。

3.1 编程语言选择

我本身是一名C++客户端程序员,个人开发的第一款游戏也是采用cocos2dx/C++开发。第二款游戏考虑到cocos2dx开发效率不高,所以选择用Unity/C#开发。从C++转到C#是很自然很舒服的,毕竟两者比较类似。第三款游戏我开始负责服务器端开发,在编程语言的选择上纠结了很久。考察过Python/Erlang/Java,Erlang的Actor模型是开发高并发实时系统的最佳选择,但其古怪的语法和编程思维的巨大转变让我望而却步。Python加gevent组合似乎也是不错的选择,但作为一名C++程序员我对性能和底层有着特殊的偏好。Java是很多手游公司服务器端编程语言的选择,包括COC的服务器端也是用Java开发的,其实我对Java是很熟悉,实习期间就在一家做Java开发的公司干了半年,但它无论如何也不吸引了我。最终还是选择用我最熟悉的C++来开发:

  • 多年的C++经验让我有信心写出高质量的C++代码
  • 大部分游戏程序员都是写C++的,将来招人会更容易些。
  • 在网络和内存部分C++可以实现的更高效
  • 其他语言平台并没有优秀到让我毫不犹豫的放弃C++

并且我还选择使用C++11,std::bind和std::function以及对lambda的支持,可以很方便的开发异步程序。std::thread和std::atomic也都很好用,其他的一些小的特性也提高了开发效率,让代码更干净。

3.2 EventLoop

我希望服务器可以单机承载10000的并发用户。使用异步IO是不二之选,而且Linux平台的epoll提供了性能优良的IO多路复用机制。而外我希望在处理网络数据时内存使用必须高效,环形缓存冲可以很好的解决这个问题。最终用了1800多行代码实现这个网络模块,性能上我很满意,而且代码量小,易于维护。

为什么不使用libevent? 一是因为libevent实现的太复杂,源码有20多万行。而是因为我对evbuffer的设计很不满意,处理网络数据最好用连续内存,而evbuffer内部使用链表将内存块串起来,使得我不得不多一次内存拷贝。三是因为写一个异步IO库并不需要太多工作量,我1800行代码就搞定了,自己维护起来方便。

目前游戏已经上线半年,玩家登录了几百万次,该模块运行稳定。以后整理下代码可以考虑开源出来,完全可以取代libevent,

3.3 多线程

为了充分利用多核,我使用了多线程,并且每个线程都跑了一个EventLoop。在主线程里监听一个端口,接到用户连接后把fd传给worker线程,默认情况启动四个worker线程,这个可以根据CPU数配置。以后的网络通信都发生在worker线程,业务逻辑也跑在worker线程。另外还启动若干Job线程,用来运行一些不需要那么及时又比较耗时的操作,比如更新排行榜。

3.4 通信协议

本来想用protobuf做通信协议,结果protobuf的C#实现都不能运行在Unity中,主要是因为Unity在移动平台上使用AOT使得protobuf的C#库无法运行。也找不到其他让我满意的,于是决定自己写。我希望协议定义在一个DSL中,然后生成服务器端和客户端都能用的代码,并且不使用反射,以便能够在移动平台上使用。考虑到解析一个自定义的DSL比较麻烦,我决定使用xml来定义协议。客户端和服务器的通信大部分都是Request-Response模式,所以一个协议定义大致如下:

<protocols version="1">
  <status>
    <code name="Ok" value="0"/>
    <code name="PasswordError" value="1"/>
  </status>
  <protocol name="Login" id="1">
    <request>
      <field name="account" type="string"/>
      <field name="password" type="string"/>
    </request>
    <response>
      <field name="error" type="int"/>
      <field name="playerData" type="binary"/>
    </response>
  </protocol>
</protocols>

二进制编码使用的是messagepack,这个协议工具虽然没有protobuf强大,但完成了我们需要的功能,而且在内存使用上更加高效。最主要的是生成的代码使用起来很舒服。生成工具用paython写的,可以生成C++,C#,Go代码。

4. 聊天服(ChatServer)

要到写聊天服务器时,我突然想换种语言,当时golang比较火热,而且也很符合我的口味,于是用golang从零手撸的一个聊天服务器。依稀记得当时测试单进程每秒可以处理18万条消息,上线后也比较稳定。这套聊天服务器后来在公司多个项目中反复使用,随简单粗糙也立下很多功劳,历经多次重构,也就1000多行代码。

golang的简单性和高并发深深影响了我,后来项目中将不同功能拆分到不同服务中,除了游戏逻辑服务器还继续用C++外,其他服务大部分都是golang写了。

5. 存储服(DatabaseServer)

我们使用MongoDB而不是MySQL,因为没有历史包袱,所以我想尝试下新技术。

MongoDB以文档的形式而非表格的形式管理数据,并围绕着文档数据提供了丰富的操作功能,再加上Schemaless的特性,简直就是开发利器。

MongoDB认为数据应该全部放到内存里,并使用mmap的方式管理数据。也就是说当所有数据都是热数据时,对数据的查询和更新速度都是极快的。在我们的实际使用中基本都是1ms ~ 5ms的访问速度。这一点很适合游戏,因为玩家的数据通常比较小且查询更新频繁。

我没有使用MongoDB的Auto-Sharding,而是在应用中直接访问数据库,并根据Id将数据分散到不同的Shard中。我们使用AWS EC2,在一开始就启动4个Shard,都跑在同一台机器上,并在这台机器上挂了4个EBS,EBS就像一个独立的硬盘,每个Shard使用一个EBS作为数据盘。将来如果超过单机限制,就把Shard移到新的机器上。这个过程十分简单,先关闭数据库进程,开启新机器,将EBS挂到新的机器上,在新机器上启动MongoDB进程。开启4个Shard是我对用户量预估后作出的选择,我认为在一开始就要预估好用户量,不要发生ReSharding。

另外使用Redis来做排行榜。

6. 监控

GameServer会记录每一次请求的完成时间并存储在InfluxDB。InfluxDB是用Go写的时序数据库,并内置的WebUI可以直接查询并显示曲线图,非常适合存储性能监控数据。

MongoDB的监控使用MMS

使用Monit监控着Gateway, GameServer, MongoDB, Reids的服务可用性。