自从2003年开发VOIP Radius Server以及修改Gnugk依赖,从事服务器开发已经近五年了,对服务器开发也有一些自己独到的看法以及见解。当摆脱了技术本身的束缚之后,才理解重要的并不是某种技术的运用,而是整体设计的考虑,也慢慢明白了设计是开发的灵魂的道理。
从技术层面来看,各个平台都有一些自己特有的东西,比如Windows 平台下面的IOCP技术,可以说为了支持大的并发,IOCP是一个Windows平台的必选方案。而在Linux下面Epoll又是所有开发人员需要掌握的技术。当然还有FreeBSD下面Kqueue的应用了。一些其他平台也有自己独有的AIO库。
随着网络开发的进一步理念加深,跨平台库也吸引了越来越多的使用者的眼光。比如行业里面最出名的莫过于ACE、ASIO(Boost公司)两大支持库。新的版本中都对IOCP支持,使用的是Proactor设计模式实现的。
当我们拥有了以上的知识背景后,我们就可以开始着手设计了。而这仅仅是一个必要条件,而不是重复条件。为什么呢?
我们先来提一下集群式服务器开发的常用几个技术知识。
1:线程
2:线程池
3:内存池
4:数据库连接池
5:为了达到1:10000的连接,可以采用Server-Client的连接方式,而为了达到1:10000*100的连接,我们怎么办呢?一般会采用Client-> ConnServer -> LogicServer。这是技术背景。ConnServer在接受完Client 的连接后,将Logic Server 暴露给Client,并立刻断开连接。以后的数据交互就和Conn Server没有关系了,这种架构有很多的优势。
[图一:标准集群GameServer架构方案]
首先要说的是线程,在服务器开发中,线程是一个非常重要的概念,尤其是现在多核服务器的发展。当然,提到了线程自然应该说到线程之间的互斥。这也是服务器开发者们在开发最初最容易出现的问题。体现在一个资源或者多个资源在多个线程中共享使用如何避免出现脏数据的问题。
线程池,池,顾名思义,是一个存储容器,一个浅显的比方,我们把水事先存放在水池里面,当我们需要的时候,就去里面取,用完了就还给池(其实这里并不是非常合适的例子,毕竟我们用完了水是丢掉)。这是一个由多个线程组成的一个队列,当有事情发生时候,我们把当前的空闲的线程丢给他,为他服务。当下一个事件发生的时候,我们又从池里面取一个空闲的线程丢给他,为他服务。当服务完毕,把线程丢回池中。起到反复利用的目的。
内存池,同样也是一个池。这个概念的产生是为了避免服务器频繁的分配内存,而采取预先分配一定数目的对象,并将对象们放到队列中,当需要的时候,从该队列中取出,当用完,就返回池中。比如我们的Server可能会存在10000个连接,我们预先开辟10000个Client对象,存储在list<Client *> pFreeClientsList中,当需要的时候,从队列中pop一个出来,当使用完毕就丢回pFreeClientsList。这种机制很好的起到了避免频繁开辟内存对象的目的,可以很好的提高系统的性能。
数据库连接池,同上面一致的道理,在服务器中,数据库访问也是一个很大的瓶颈,所以同样采取上面的道理,使用连接池的概念。当然在数据库连接方面也有一个特殊的问题存在。就是数据库的连接不宜过多,所以传统的来一个处理,就开一个连接是不合理的,必须采用控制适当的连接次数。
当然另外一些需要提到的是内存数据库。硬盘的访问速度和内存的访问速度不是一个数量级的,而且随着内存的硬件价格越来越低,内存数据库的可行性也越来越高,尤其是实时性要求高的系统,完全可以采用内存数据库和物理数据库想结合的方法来处理。
当系统的连接数量从万上百万级别的时候,服务器程序就超越了服务器本身,我们需要考虑的问题将从一下几个方面开展:
1:如何划分系统中功能?
2:如何保证整个系统的性能可控,直观的说就是系统每一步时候瓶颈在哪里?
3:如何保证当系统的瓶颈凸显时候,简单的添加一组服务器,就可以达到分压目的?
4:系统的灾难部分出现的时候,如何保证系统依然可以完整运行?
第一个问题是如何划分系统中的功能。在软件开发中,我们追求的是每个函数功能尽量简单,易学里面的道理叫做大道至简。软件开发中同样适用,在服务器开发中,同样适用。如何将整个系统中的需求抽象为功能,并如何更好的划分功能,将极大减少系统开发的难度,并能够使得系统的可扩展性非常强。
第二个问题是瓶颈问题。从物理上面来分析,性能在硬盘,内存,CPU是三个决定因素的地方。而从软件的角度就包含了数据库系统,操作系统,服务器软件系统三个方面,更细节方面拿游戏服务器来说,Conn Server 的压力,Logic Server的压力,还是DB Server的压力了。
第三个问题还体现在分组方面。比如当Conn Server出现压力的时候,如何简单的添加一个Conn Server就达到分压目的。当Logic Server出现压力,或者DB Server出现压力。另外就是如果服务器设计以组的方式出现,应该如何管理组以达到分压目的。
第四个问题是灾难恢复。在重要的系统中,由于涉及到的系统、硬件、软件非常多,很容易某个系统出现故障,这个时候,系统应该具有很好的伸缩性,故障出现后,系统必须依然运行顺利。
所以在设计服务器时候,应该考虑上面的因素。下面我提出在集群服务器开发中的两种可行的方案。
[图二:基于功能划分的集群GameServer架构]
[图三:组划分的集群服务器架构]
在图二中,系统按照功能方式划分系统,当压力增加的时候,按照功能方式添加某服务器,可以简单的达到分压的目的。在Conn Server中保存所有有效Hall Server的连接,以及当前该Hall Server的当前连接数。代码示意如下:
class THallServer
{
public:
THallServer();
virtual ~THallServer();
THallServer(int port);
public:
SOCKET _hallServer; //保持同HallServer连接的Socket对象
int _maxConn; //该HallServer的最大连接数量
int _currentConn; //当前连接数量
int GetCurrentConn();
char _hallServerAddr[32];
int _hallServerPort;
};
class THallServerList
{
public:
THallServerList();
virtual ~THallServerList();
public:
list<THallServer *> pHallServerList;
SOCKET _listenHallServer;
HANDLE ListenThread;
public:
void Start();
THallServerList(int port);
//Accept线程
static unsigned __stdcall ListenThreadFunc(LPVOID lpVoid);
};
上面的代码是该设计方案的类代码。从代码中我们可以理解出思想如下:
Conn Server里面存在一个THallServerList对象,该对象监听端口,当有HallServer连接过来,将该HallServer存入队列,并实时获取该Server当前的压力情况,可以起到一个负载均衡的作用。而保持的HallServer队列,当客户端连接过来,Conn Server则从pHallServerList中将当前currentConn最小的服务器发送给客户端,以后客户端将同该Hall Server发起连接。
在该系统中,当我们的Conn Server不够的时候,可以考虑架设多台Conn Server,当客户端无法连接时候,程序自动连接下一台Conn Server.比如conn1.doserver.net、conn2.doserver.net、conn3.doserver.net、connn.doserver.net。
图三中是按照组划分的系统组成。该方案目前来说,我还并没有实施过,只是在方案上面进行过探讨。希望有时间我可以设计一个案例出来再做展示。
[图四:改进的功能划分集群GameServer架构二]
在项目的实施过程中,我发现了Hall Server其实并不需要同Logic Server进行交互,如果Hall Server在保留同Client的1W多连接的情况下依然保持过多的同Logic Server的连接,势必压力非常大,这时候如果在之间使用ISServer来交互,就可以减少很多的连接数量,也使得系统更加清晰。
Hall Server只需要获取所有的Logic Server的名称,Logic Server的地址,Logic Server的端口,以及当前的连接数量。所以通过之间的一个信息服务器作为桥梁,就可以很好的解决这个问题。这种架构就可以达到非常完美的解决上面提到的4个难点的问题了。
后记:封闭开发之余,很想把自己的在服务器开发的经验分享一下,所以就借用了2个小时整理此小文,希望大家喜欢。同时欢迎大家指点,建议。也欢迎转载,但是无比保留版权以及原作者信息。非常感谢。
胡章优 2008-12-3 于北京
作者:胡章优,吉林大学机械学院教师。长春优狐科技开发有限公司董事长兼总经理。
Tel: 13596199043
Mail: [email protected] ([email protected])
Site: http://doserver.net
QQ: 3803308