HTTP服务器的特点

最近一个月在学习关系性数据库mysql, 顺便看了一下http服务器的编写。写了这么久的游戏服务器,数据库也一直使用nosql。因此在初次尝试使用mysql编写http服务器程序时,产生了强烈的反差。

在使用APP的验证过程中,经常会遇到短信验证码问题。

下面就以‘如何实现短信验证码服务’来对比一下http服务器和游戏服务器(mysql和nosql)解决此类问题的不同之处。主要涉及数据结构定义、优化、及逻辑代码的更新问题。

先来明确一下‘业务流程’,最简单的‘短信验证码服务’至少要提供3个操作:

1. APP向‘短信验证码服务’发送请求获取验证码
2. 其他服务向‘短信验证码服务’发送请求校验验证码是否正确(这里的‘其他服务和‘短信验证码服务’有可能是同一个服务,这里只是假定,‘短信验证码服务’被单独实现为一个微服务)
3. 定期删除超时的短信验证码(这里验证码超时时间为5分钟)

先说执行流程:

http服务器的每次请求都是相互独立的,即每个请求都是先查询当前DB(这里假设使用Mysql)的状态,然后将状态写入内存,然后根据内存中的状态执行逻辑,然后把处理结果写入DB,释放所有资源(这里仅仅是理论上,比如可能连接DB时会采用连接池,那么释放时也仅仅是把链接归还到链接池而已)。而这里要实现的‘短信验证服务’基本上属于纯DB操作,因此下面直接展示sql语句,逻辑代码直接略过。

而游戏服务器则不太一样,一般会把要操作的数据提前加载入内存,当处理请求时,直接根据当前内存的状态,来处理逻辑,最后把结果写入内存,并极据不同的策略更新到DB。也就是说,不管DB定义的数据结构是怎样的,进程内的数据结构才是直接影响请求处理效率的关键。

先来看按‘http服务器’如何实现‘短信验证服务’。

首先定义mysql中的数据结构:

create table verifycode (
	phone bigint unsigned not null primary key,
	code int unsigned not null,
	time timestamp not null
);

为电话号码为‘100000000’生成一个短信验证码:

insert into verifycode (phone,code) 
values (100000000, floor(rand() * 10000)) 
on duplicate key update 
time= current_timestamp(),code=floor(rand() * 10000);

为‘其他服务’提供电话号码为‘100000000’验证码为10005验证功能:

select count(*) from verifycode where phone = 100000000 and code = 10005;

定期删除过期的验证码(此段代码需要开个定时器执行):

delete from verifycode where 
time < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE);

再来看看如果按‘游戏服务器’的方式该如何实现(直接在进程内操作, 以C++的方式呈现)。

定义数据结构:

struct verifycode {
        long int phone;
        int code;
        time_t time;
};
std::unordered_map<long int, struct verifycode> verifycode_pool;

为电话号码为‘100000000’生成一个短信验证码:

auto &c = verifycode_pool[++codeidx];
c.code = rand() % 10000;
c.phone = 100000000;
c.time = time(nullptr);

为‘其他服务’提供电话号码为‘100000000’验证码为10005验证功能:

auto iter = verifycode_pool.find(100000000);
return (iter != verifycode_pool.end() && iter->second.code == 10005);

定期删除过期的验证码(此段代码需要开个定时器执行):

time_t now = time(nullptr) - 5 * 60;
for (auto iter = verifycode_pool; iter != verifycode_pool; ) {
        if (iter->second.time < now)
                iter = verifycode_pool.erase(iter);
        else
                ++iter;
}

对比一下mysql版本和C++版本的短信验证码服务。可以发现,C++版本的代码基本上就是mysql版本中sql语句的翻译。

也就是说到目前为止,除了http服务器每一个请求都会重新读取Mysql和写入mysql的设计原则与游戏服务器不同外,设计思路上完全一致。


但是上述实现有几个问题。

1. 定期删除过期验证码,需要扫描整张表,时间复杂度为O(N),在有大量验证码的情况下,一次清理就会卡整个进程或整张表,导致”Stop The World”,而mysql在不使用索引的情况下,扫描整张表会更慢。
2. sql语句使用了now(),rand()之类的函数,这将导致mysql查询缓存失效(mysql版’短信验证码服务’特有问题,这个可以通过在应用程序中生成常量值来替换sql语句中的now/rand函数,下面优化不再列出)

为了解决删除过期验证码过慢的问题。

‘http服务器’的优化版本需要做出如下修改,为time字段添加索引:

create table verifycode (
	phone bigint unsigned not null primary key,
	code int unsigned not null,
	time int not null,
        index(time)
);

而C++版本则需要重新设计数据结构, 并实现相应的算法。

采用Time Wheel的方式,将过期的Key直接为300组,超时直接删除整组。

time_t lastupdate = now();
std::unordered_map<long int, int> verifycode;
std::unordered_set verifyexpire[5 * 60];

为电话号码为‘100000000’生成一个短信验证码:

int idx = now() / (5 * 60);
verifycode[100000000] = rand() % 10000;
verifyexpire[idx] = 100000000

为‘其他服务’提供电话号码为‘100000000’验证码为10005验证功能:

auto iter = verifycode.find(100000000);
return (iter != verifycode.end() && iter->second == 10005);

定期删除过期的验证码(此段代码需要开个定时器执行):

time_t now = time(nullptr);
while (lastupdate <= now) {
        int idx = lastupdate % (5 * 60);
        auto &e = verifyexpire[idx];
        for (auto phone:e)
                verifycode.erase(phone);
        e.clear();
        lastupdate++;
}
lastupdate = now;

从优化后的代码,再来对比一下两者的优缺点。

HTTP服务器,特点:
1. 业务逻辑每个请求都需要读取写入数据库,因此相比游戏服务器的方式来讲会请求会慢很多
2. 业务逻辑均采用关系数据库来进行存储(这是并不是绝对的,现在很多服务器已经开始采用nosql来存储了), 一般只能维绕着sql语句和索引进行优化
3. 数据与逻辑分离,可以动态热更新逻辑代码
4. 数据与逻辑分离,当业务请求能力处理不足,而瓶颈不在DB时,可通过增加逻辑服务器来动态扩容,具有极大的可伸缩性
5. 合理的DB集群架构设计,当DB达到瓶颈时,可动态扩容

游戏服务器,特点:
1. 数据均在进程中内存进行操作,可扩展性差。
2. 由于进程中内存残留业务逻辑状态,几乎或很难进行逻辑代码的热更新。
3. 不同的业务模型需要根据情况设计,才可以使集群具有可伸缩性,不像HTTP天然具有可伸缩性。
4. 数据表现力强,可以充分利用编程语言提供的各种表现方式,优化形式多样。
5. 所的请求全部基于内存状态进行处理,处理速度快

ps.mysql中的InnoDB索引采用B+Tree来实现,因此time字段加上索引后,单纯的从算法上与使用TimeWheel实现的C++版本相比,时间复杂度上并不会有显著区别。在更复杂的应用场景,DB和编程语言实现过程中的优化形式可能会相差甚远,但其本质上也是相同的,都是尽可能快的提高访问速度。

一个高可伸缩的游戏服务器架构

设计完socket通讯协议后,就面临着服务器架构设计了。我希望他是一个去中心化且具有高可伸缩性的集群架构。

水平扩展是高可伸缩的首要条件,因此,在设计之初就必须考虑好水平扩展考方案。事实上这一部分几乎花了我1整个月的时间来设计,在此期间我重写了3版才总算确定下来我认为可用的方案。

第一版设计方案如下:

将服务器分为3类,分别是GateServer, LoginServer, LogicServer。

GateServer管理客户端链接,数据包的加密、解密、广播、转发等与业务逻辑无关的操作。当压力过大时可通过部署多个实例来水平扩展。

LoginServer处理游戏帐号认证,为客户端分配一合适的GateServer(可能是负载最轻),为客户端与GateServer连接分配临时密钥等操作。

客户端通过连接LoginServer分配的GateServer来进行游戏。如果需要限制玩家单人登陆(同一个帐号同时只能有一个socket来管理), 则只能部署一个,如果压力过大,可做登陆排队处理。

LogicServer是游戏业务逻辑服务器,可根据业务类再行分类。每个业务类型服务器可单独部署一份。

每一个LogicServer在启动时向GateServer建立一条socket连接。并把自己可处理的协议ID发送给GateServer进行注册。

当GateServer收到客户端协议ID后,根据LogicServer注册信息来将不同的协议内容转发给不同的LogicServer服务器处理。

LogicServer接收GameServer转发来的协议后,将处理结果发回源GateServer,再由GateServer处理后发回给客户端。

LogicServer之间根据业务模型的需求直接进行互联。

例如:有一个RoleServer(LogicServer类型)进程和一个SceneServer(LogicServer类型)进程,如果SceneServer在业务逻辑中需要RoleServer提供一些支持。那么SceneServer直接对RoleServer进行连接并请求,不需要任何中心服务器结点。

很容易发现,这个架构的瓶颈一定是在LogicServer的定位上。假如单个RoleServer不足以承载足够多的人,而RoleServer内部的逻辑又交互很密切,RoleServer所承载的最大人数将是整个架构的所能承载的最大人数。这严重制约了整个架构的伸缩性。

因此,想要提高整个架构的伸缩性,就必须要让”同一业务类型服务器”可以部署多个实例。


在第二版的设计中,LogicServer向GateServer注册协议ID时,顺便通知GateServer其本身是否有可能会被布署多份实例。

GateServer在向LogicServer转发协议时根据其是否’可能会被被部署’来做不同的处理。如果此LogicServer是可能会部署多份的,则用hash(uid)的值来确定将此协议内容转发到具体哪一个LogicServer服务器。

事情往往没有看上去那么美好,在实现SceneServer时,发现上述规则并不适用。因为SceneServer如果部署多个实例,一定是按地图区域划分的,与hash(uid)没有必然联系。如果要对SceneServer进行正确的消息转发就必须要新增一种LogicServer的子类型。

同样,如果新增某个业务逻辑服务器需要另外一种转发逻辑,就需要同时修改GateServer的转发逻辑。这与框架与业务逻辑解耦的初衷不符。

看起来已经不可能完全保证,在业务逻辑变动的情况下,完全不修改GateServer的代码了。


因此在第三次实现中,我把转发逻辑独立出来,交由业务逻辑处理。

在GateServer中增加了一个元素Agent。框架本身不提供Agent的实现,只提供Agent类的接口。具体的Agent由业务逻辑实现。

在每一个连接到来时,GateServer为其分配一个Agent对象。当客户端消息到来后,GateServer将其交由对应的的Agent对象处理,GateServer不再负责具体的转发逻辑。由此来将业务逻辑代码彻底剥离开来。

假设整个集群部署如下:

有一个GateServer, 两种不同的业务类型的LogicServer。每种LogicServer分别部署N份实例

+-----------------------+ +-------------+ +-------------+
|                       | |             | |             |
|         Gate          | |             | |             |
|                       | |             | |             |
|  +-------+ +-------+  | |             | |             |
|  |       | |       |  | |             | |             |
|  | Agent | | Agent |  | |             | |             |
|  |       | |       |  | |             | |             |
|  +-------+ +-------+  | | LogicServer | | LogicServer |
|                       | |             | |             |
|  +-------+ +-------+  | |  Role x N   | | Scene x N   |
|  |       | |       |  | |             | |             |
|  | Agent | | Agent |  | |             | |             |
|  |       | |       |  | |             | |             |
|  +-------+ +-------+  | |             | |             |
|                       | |             | |             |
+-----------------------+ +-------------+ +-------------+

那么从业务逻辑层面来看,其实就相当于每一个Agent对象分别包含了一个Role Server实例和一个Scene Server实现。如下图:

+-----------------------------+
|                             |
|            Agent            |
|                             |
|   +----------------------+  |
|   |                      |  |
|   |        Role x 1      |  |
|   |                      |  |
|   +----------------------+  |
|   +----------------------+  |
|   |                      |  |
|   |        Scene x 1     |  |
|   |                      |  |
|   +----------------------+  |
|                             |
+-----------------------------+

整个集群一种可能的工作流程是这样的:

帐号认证过程(LoginServer部分):

1. 客户端连接LoginServer进行认证,LoginServer首先检查客户端认证信息是否合法。

2. 如果合法接着检查此帐号是否已经在线,如果在线,则找到此帐号在线的GateServer,并向其发着kick命令。

3. GateServer向LoginServer回应kick成功

4. LoginServer为当前帐号分配GateServer,向GateServer请求为此uid生成一个合法token。

5. LoginServer将GateServer的IP和Port及token返回给客户端

游戏过程(GateServer部分):

1. 客户端拿着从LoginServer获取到的ip和token去连接GateServer并进行认证。

2. GateServer收到新的客户端连接就为其新建一个Agent对象,此后便将此连接所有消息都效由Agent对象处理。

3. GateServer收到LogicServer发来的消息后,根据此消息所属的uid找到对应的Agent来处理,然后把消息交由Agent来处理。

游戏过程(Agent部分):

1. 收到GateServer传递过来的由客户端发来的消息,找到其对应的服务器类型,然后根据此服务器类型需要的转发逻辑来转发到相应的LogicServer中去处理。
2. 收到GateServer传递过来的由LogicServer发来的消息,将其转发给对应的客户端连接

上述过程只是一个整体上的过程,有很多细节都没有详述。比如GateServer可能把消息解密再传递给Agent处理, LoginServer与GateServer可能还需要交换密钥等。

BTW, 处理多连接绝对不是一件容易的事,在第三版方案确定好,又重写了两次才终于把逻辑理顺。

listen函数中的backlog字段

今天在公司无意间又拿redis-benchmark测了一下silly的IO并发性并与redis本身比较了一下。

发现在2000个client同时并发的情况下,性能只是Redis的30%的左右。

直觉上这很不正常,虽然silly所有的数据经过lua层时都需要malloc和memcpy,但最多性能上差个10%~20%就已经很可观了,绝对不可能差70%这么多。

通过不断的调整client的个数,我发现性能并没有明显随着client的数量降低而降低,而是在client个数到达某个值时突然降低的。

比如我自己测的数据是在491个client时,silly的性能与redis相差无几,但在492个client同时并发时,silly的性能锐降到redis 30%左右。

这也再次说明了应该不是malloc和memcpy造成的开销。

在此期间,我分别尝试加大epoll的缓冲区和增大socket预读缓冲区,都没有明显的效果。这也说明问题并不在IO的读取速度上和系统调用开销上。

万般无奈之下Download下来redis3.0的源码开始对着比,最终发现惟一不一样的地方就是redis的listen的backlog竟然是511,而我的只有5.

一下子豁然开朗了,由于backlog队列过小,导致所有的connect必须要串行执行,大部的时间都在等待建立连接上,在将backlog的值改为511后,性能已然直逼redis。

google了一下发现,除了redis连nginx竟然也是用511。但是记忆中backlog参数会影响未完成连请求队列的大小,似乎增加backlog会增加syn洪水攻击的风险。

查了好一会资料,最后发现man上早都指出在Linux 2.2之后listen中的backlog参数仅用于指定等待被accept的已完的socket队列的长度。未完成连接的队列长度则通过/proc/sys/net/ipv4/tcp_max_syn_backlog来指定。

至于为什么backlog是511而不是512, 是因为kernel中会对backlog做roundup_power_of_tow(backlog+1)处理,这里使用511实际上就是为了不浪费太多不必要的空间。

之前一直看资料上说backlog是个经验值,需要根据经验调节。然而并没有想到,当大批量连接涌入时,backlog参数会起到这个大的影响。那么这个经验看来就是要估算每秒的连接建立个数了。

比如web服务器由于http的特性,需要频繁建立断开链接,因此同一时刻必然会涌入大量连接,因此可能需要更高一些的backlog值,但是对于游戏服务器来讲,大多都是长连接,除了刚开服时会有大量连接涌入,大部分情况下连接的建立并不如web服务器那么频繁。当然即使如此依然需要对每秒有多少链接同时进入进行估算,来衡量backlog的大小。

虽然可以不用估算直接使用backlog的最大值,但却可能会造成‘已完成未被Accept的socket的队列’过长,当accept出队列后面的连接时,其已经被远端关闭了。

经过测试,即使backlog为63,在局域网内同时并发2000客户端并无性能影响。


12月23日纠下补充:
1. listen的backlog值其实是会精确指定accept的队列的,只不过它除了控制accept队列的大小,实际上还会影响未完成的connect的队列的大小,因此
roundup_power_of_tow(backlog+1)增大的实际是未完成connect队列的大小。
2. /proc/sys/net/ipv4/tcp_max_syn_backlog 虽然字段名中有一个sync但其实限制的是accept队列的大小,而并非是未完成connect队列的大小

虽不欲写成kernel net源码解析的文件(实际上是怕误人子弟:D), 但还是走一下流程证明一下吧(只针对tcp和ipv4基于3.19)。

先看listen的整个流程:

listen系统调用 其实是通过sock->ops->listen(sock, backlog)来完成的。

那么sock->ops->listen函数是咋来的呢,再来看socket系统调用, 其实是通过socket_create间接调用__sock_create来完成的。

sock->ops->listen函数则是通过__socket_create函数中调用pf->create来完成的。而pf其实是通过inet_init函数调用socket_register注册进去的,至于什么时间调用了inet_init这里就不赘述了,毕竟这不是一篇kernel分析的文章:D.

由此我们找到pf->create实际上调用的就是inet_create函数.

啊哈!接着我们终于通过inetsw_array找到sock->ops->listen函数最终其实就是inet_listen函数。可以看到我们通过listen传入的backlog在经过限大最大值之后,直接被赋给了sk_max_ack_backlog字段。

OK,再来看一下kernel收到一个sync包之后是怎么做的

好吧,先去看icsk->icsk_af_ops->conn_request这个函数是怎么来的。

回过头来看inetsw_array发现其中SOCK_STREAM中类型的prot字段其实是指向tcp_prot结构体的。

前面看过的的inet_create函数中的最后部分会调用sk->sk_prot->init函数。而sk_prot字段其实是通过调用sk_alloc时将inetsw_array中的prof字段赋值过去的。

因此在inet_create函数的最后sk->sk_prot->init调用的实际上是tcp_v4_init_sock函数。而在tcp_v4_init_sock函数中会将icsk->icsk_af_ops的值赋值ipv4_specific的地址。由此终于找到了icsk->icsk_af_ops->conn_request其实就是tcp_v4_conn_request函数,此函数随即调用tcp_conn_request函数来完成之后的内容。

在tcp_conn_request中是通过sk_acceptq_is_full来判断的。
从sk_acceptq_is_full函数中看到他是通过sk_max_ack_backlog字段判断的,而这个字段在我们分析listen系统调用时已然看到其实就是listen传入的那个值。

另外需要额外说明的时,在reqsk_queue_alloc中为的listen_sock::syn_table分配的空间仅仅是一个hash表,并不是际的request_sock空间。

谈谈协议的设计

闲来无事,最近接了个公众号玩玩,当然肯定是基于silly的:)

最初的打算是开一个daemon,在收到微信sdk callback后根据好友发送的消息来做出不同的处理。比如根据输入关键字然后去我的blog上去爬取相关信息,每天定时把最新的blog文章做群发。

在实现http client过程中,需要解析dns。虽然gethostbyname可以用来解析域名,但是整个silly底层是基于异步来实现的,而gethostbyname则是以阻塞方式解析的,因此使用gethostbyname会极大地降低整个框架的吞吐量。

向dns服务器请求解析域名时,应该首先以udp方式请求,如果服务端回应超过512字节,则会置截断标志位。客户端发现截断标志位后应该以tcp的方式重新发送请求,在发送请求时,跟udp唯一的区别是,需要在数据包最开始附上两个字节的包长。

这是我第一次遇见一份协议同时适用于udp和tcp协议的。强烈的反差感让我不禁在思考,协议到底应该以什么样的方式进行组织,才可以更方便服务端的解析。

对比一下dns的udp和tcp的协议格式,同一个请求在通过tcp发送时需要增加两个字节的包头。那是因为tcp是属于字节流会粘包,而udp是按报文发送的,换句话说,你发出去是什么,对方就会接到什么。不会有粘包的顾虑,因此tcp需要包头来进行切饱。

当然在tcp情况下即使不用包头,dns请求协议也足够描述出一个完成的数据包。那么为什么要加两个字节的包长呢?

想到这里我就不得不佩服发明’协议栈’的哥们了,说得太形象了。协议栈通过抽象分层,然后明确每一层的作用,这样不但在设计代码时可以更好的解耦,在移植时也可以通过换掉其中某一层来达到快速移植的目的。

以dns的tcp协议为例,有了两个字节的包长就可以在逻辑层以下,抽象出组包层。

组包层的实现,屏蔽掉了底层通信细节,抛给逻辑层的都是一个一个完整的包。逻辑层的实现仅仅是拿一个完整的数据包去反序列化去处理,它并不关心数据是从哪里来的。

假如我们的dns不再通过tcp来通信,而是通过串口。我们所需要做的仅仅需要重新实现一个组包层,组包层从串口读出数据并切割出一个一个完整的数据包抛给逻辑层。而之前实现的逻辑层则一点都不需要改变,从而达到了代码的最大复用性。

这一点我感触颇深,在为silly移植各种协议的过程中,发现有好多库都是把socket直接做到协议里面的,而silly为了达到高吞吐量在c语言层使用的是异步逻辑,只有在lua层才可以进行阻塞访问。因此移植一些c协议库起来就颇为制肘。

因此,在设计协议时,一定要进行抽象分层,明确责任。这样在以后协议移植时会大大增加代码的复用性。

silly的一次大规模重构

最近一个月都在重构silly, 包括其工作模式以及一些扩展库的实现基本上都被重写了。

其实coding的时间并没有想象中的那么长,只是在重构过程中碰到很多取舍情况,大部分时间都耗费在了纠结的时间上。

当初实现silly的初衷是首先满足类似gameserver这类业务模型的需求,然后尽可能多的兼顾其他类型的server。

然而以这两年的工作经验来看,master-worker模式由于多worker之间不能进行通信,因此仅仅适用于http这种不带有状态残留的协议模型。

而对于gameserver这类在进程内存内残留状态的业务开发并没有什么用。因此这个功能算是鸡肋功能了,犹豫再三最终还是将master-woker模式拿掉,将silly改为单纯的单进程单线程工作模式(当然内部还是有单独的timer/socket线程的).

而以我有限的服务器开发经验来看,在高并发分布式开发过程中, 应该将业务流程抽象成pipeline节点, 然后根据每个pipeline节点的负载情况动态增加或减少相应节点的物理进程。因此拿掉master-worker工作模式之后,准备在多进程通信方面多增加一些支持。

单进程单线程模式并不意味着就无法充分使用多核cpu, 如果服务器性能足够好,则可以尽可能多的把进程布到同一台物理机上,如是服务器性能略差则可以相应减少进程数。采用这种模式可以大大提高服务器的可伸缩性而并不需要业务逻辑做出改变。

另一个比较大的重构就是lua层的socket coroutine调度方面。

在之前的coroutine调度模块中,不管是core.lua中还是其他相应的扩展库中,都是直接使用lua提供的原生coroutine库进行操作的。

这样就会有一个问题,core.lua没有自成一个闭环,也就是说只要其他基础库实现的有bug, 就会扰乱socket coroutine的调度,而且极难查出问题出在哪。而在阅读lua源码的过程中可以看到他的所有api接口都是自成闭环的,换句话说只要在luaconfig.h中把检查参数打开,只要你传入非法值,他都是可以检测到的,这极大的降低了debug的难度。

为了使用socket coroutine的调度也自成闭环,我封装的core.fork/core.wakeup/core.sleep等与coroutine相关的函数,除了core.lua之外,不允许其扩展库使用lua原生的coroutine库。而core中提供的相应的coroutine操作函数在调用时都会去检查和设置相应的coroutine状态。

这样即使某个扩展库对coroutine的操作流程不正常,也只会影响调用这个扩展库的coroutine, 更重要的是一旦这个扩展库出现coroutine调度上的bug, core层lua代码能及时发现问题,并将错误信息抛出,极大的除低了debug的难度。

btw, 个人觉得一个成熟的库,其自身是否为闭环是重要标志之一。

在重构扩展库gate.lua时我犯了一个错误, 这个错误导致我重写了好几遍代码,最后才醒悟过来。

在一开始时,我想让gate尽可能的适用于各种情况, 因此重写了一次又一次总觉得不太满意。 最后我幡然醒悟,满足各种情况的库是不可能做到的。

最后确立了core.lua + C核心代码 为整个silly的核心,其他都是扩展库,而gate.lua仅仅不过是一种对核心代码的特殊情况的使用而已。 到此才停止了对gate.lua不停重构。

由此,我得到一点重要的启示。一个框架一定要明确这个框架的核心要解决哪些问题,然后提供灵活而正交的接口供扩展库使用。扩展库的功能则是拿核心接口采取不同的策略去处理不同的情况。

内存数据库和进程内cache

上家公司是做TPS游戏的,因此其实对玩家数据的操作其实不是很频繁,而且由于当时服务器的数据库部分是单独剥离开给指定的人维护的。因此对于cache这一块,从来没有仔细的考虑过。新公司的游戏需要与离线玩家进行战斗,因此在写业务逻辑时是需要cache支持的。但是总觉得cache设计之初的定位有些问题,由此引发了一些思考。


最初我一直在纠结于内存数据库,我觉得诸如redis这种内存数据库已经把所有数据内容都常驻内存了。那么设计一个cache真的有必要么?直接随用随取不就行了么。在服务器进程中再cache一份冗余数据, 是否真的有必要呢? 如果服务器进程中有必要cache一份数据那么内存数据库的存在是为了解决什么问题呢?

最终我发现我被内存数据库这几个字给带偏了,内存数据中数据常驻内存是不假, 但是他是以独立进程存在的。也就是说服务器想要获取内存数据库中的数据,首先需要通过socket发送请求,然后数据库进程接收到请求后取出相应数据通过socket返回给服务器进程。 这中间牵涉到系统调用,上下文切换引cpu cache大面积失效,网络传输延迟等各种因素的存在。因为相对于服务器进程直接读写本地内存来讲,性能相差了不止一个数量级。

那么即然如此,直接用服务器+mysql不就好了嘛。为什么会有memcache和redis的出现呢?他们的一定是为了解决某类问题才出现的。

首先数据库的作用是容灾,即在服务器进程出现bug崩溃时,可以将玩家丢失数据的代价降到最低。而游戏服务器本质上就是一直在操作玩家数据(所以才说游戏服务器一般属于io密集型,所以才越来越流行使用脚本来编写业务逻辑),因此数据库的写入和读取速度在很大程度上会影响服务器程序的性能。由于mysql中所有DB数据都是存入硬盘的,读写性能很容易成为瓶颈,有好多服务器为了提高mysql的读写性能,都会专门配备ssd。

而memcache/redis的机制是把数据全部cache进内存的,因此在读写性能上要比mysql出彩不少。但是由于memcache没有数据落地功能。因此一般是挂在mysql前面做缓存使用。

换句话来说,内存数据库也是数据库,他的作用也是用来容灾的,之所以会出现主要是为了解决在大规模频繁读写数据库时的性能瓶颈。因此内存数据库并不能替换进程内cache。


那么进程内cache主要是用来解决什么问题呢?

从cpu的cache发展历史来看,程序一般有一个特点。那就是读的频率要远远大于写的频率。

如果读和写都直接穿透到数据库的话,由于上下文切换,网络传输延迟等原因,与仅仅在写数据时才穿透数据库,而读直接读取时程内cache的数据相比,性能应该会差上几个数量级。

因此进程内cache实现最简单的一种方式就是write through。当然,也明一些其他的设计方式,比如在进程内cache对数据置脏标志位,在写入关键数据时,会将此玩家脏数据写入DB,无疑这种方式效率最高,但实现起来与业务逻辑偶合度也会更高。

由于有了进程内cache, 因此在对数据库结构设计时,有时候可以存在更多的优化空间。

比如有些逻辑中需要数据A, 而数据A是通过数据B查表或计算就可以得到的。 这个时候就没有必要将数据A存入数据库,也没有必要逻辑代码使用数据A时每次都去计算一下。仅仅在进程序内cache加载数据完成的那一时刻去计算一次并放入进程内cache即可。这样即提高了逻辑的运行,又减少了数据库的占用空间。最重要的时不会出现由于程序Bug导至A与B不致的情况。况且将A存入数据库然后直接从数据库加到到进程内cache的网络开销并不一定比进程内cache加载完成后从B算出A的值会少。

Silly多端口监听支持

前两天在编写一个新服务器程序时才意识到,多台不同类型的服务器相互通信,服务器仅监听一个端口号是非常不方便的,比如服务器集群中的ctrl_server需要接受多种不同的服务器建立链接,让所有链接去涌入同一个端口显然是不明智的,这时候就需要监听多个不同的端口。

让我纠结了两天的问题是到底该以怎么样的方式增加多端口支持。

首先先到的是,去掉配置文件中port的支持,增加一个listen API让lua逻辑自己去指定监听哪几个端口号,但是有一个明显的问题,多个worker现在启动时加载同一套lua代码,一个worker监听成功就会导致,其他worker失败。

现在面临着两个旋择,一个就是让配置文件可以没个worker指定一个bootstrap,但这样会有一个问题,多个worker并不能通信,有可能两个端口的命令需要操作一块共享的数据,因此并不能实现。

还有一个让lua逻辑判断只有workid为零时才真的去监听,其他worker仅仅注册一下相关端口的处理函数即可。但总感觉这种设计有点bad taste的感觉。

还有一种方式就是让silly底层可以支持对一个端口多次监听,但如果lua逻辑是在刚启动就进行端口监听(大多数情况也的确如此),那么多个worker同时调用listen api时就要处理并发问题,这样事情就稍微变得复杂一点了。

为了简化设计,我修改了为lua提供的listen API语义,此api仅仅向socket注册不同端口号的处理函数并不实际进行监听操作,silly根据配置文件中配置的端口号组进行监听,当从某个端口号的listen fd accept出socket向socket.lua发消息时,将这个socket来源于哪个监听端口号一并发出。

这样socket.lua就可以根据端口号来为这个socket fd选择合适的处理函数。

为了避免lua中listen的端口号和配置文件指定的端口号不同的错误,silly的config文件在指定端口号组时需要为每个端口号起一个名字,在lua代码中listen传入的参数是port的名字,而并非是数字。


11月4日补充:

如上文中的ctrl_server即需要接受client请求又需要与其他server进行通信。

client与server之间的通信是不被信任的,所以所有的通信数据包需要进行加密处理,而server与server之间的通信是可以相互信任的,所以都是明文发送。因此一般的服务器集架设过程中会为每台服务器配置2块网卡,一块网卡接入公网为client服务器,另一块网卡用于与其他服务器组成一个局域网。

这样做可以不但可以增加server之间通信的安全性,还可以提高server之间通信的效率。

仔细回忆一两台计算机通过网络通信的流程。1台电脑发出数据包后, 数据包首先到达网关,网关根据去目的ip找到相应的计算机,然后将数据包转到给相应的计算机。

如果server之间使用公网ip进行通信,那么就必须要经过电信的网关,这样数据包不但会有被拦截的风险,还会增加不必要的开销。毕竟相比局域网的网关来说,电信的网关实在太远了。

为了防止有恶意链接从公网接入开放给其他服务器进行通信的接口, 在listen时必须能够支持仅对某块网卡上的特定端口进行监听。

因此silly除了要增加多端口监听支持外, 还需要增加对指针ip地址的某端口进行监听。当我们需要针对任何网卡进行监听时,将ip地址设为0.0.0.0即可表示INADDR_ANY.

服务器的分布式部署

两周前使用redis-benchmark测了一下silly的并发请求响应速度, 开1000个客户端, 平均大概每秒能响应6W个请求。
但是这些请求仅仅是PING/PONG协议,也就是说这基本上就是纯网络I/O的性能,如果考虑到逻辑处理、数据压缩、加密、解密等时间, 可能一个silly都不一定能够撑得起5000人的访问量。

所以最近两周除了在研究redis之外, 就是在研究怎么给silly增加cluster支持,以便可以将计算分摊到不同的计算机上来降低客户端的请求的响应延迟,silly应该怎么增加对cluster的支持。

直到昨天下班的路上我才想通,其实对于silly的模型来讲,他的任务应该相当的KISS,仅仅只是用coroutine的方式来处理数据即可。分布式功能应该在应用层实现,并不需要silly做额外的支持。


首先给定这样一个命题:
所有玩家在同一张地图上游戏,一台物理server能支持5000人的并发量,那么如何才能支撑百万级的玩家在同一张地图上游戏。

首先将服务器分为auth_server, channel_server, scene_server, ctrl_server这四种服务器。

整个服务器集群由一个auth_server、一个ctrl_server、若干channel_server、若干scene_server组成。

所有server在启动时主动向ctrl_server建立一条socket链接, 以便可以定时向ctrl_server汇报和查询状态(如当前负载情况等),另外在停服时可以直接操作ctrl_server, 然后ctrl_server通过建立的socket连接来通知所有server停服,这样可以避免人工操作出现的各种差错。

auth_server服务器的作用仅仅用来做用户认证,和负载均衡。在客户端用户登陆验证正确后,auth_server根据向ctrl_server查询到的所有channel_server的负载状态,挑选出各适的channel_server服务器的ip及端口号,然后将其和临时通行证一并发给客户端。 当客户端得到临时通行证和相应的channel_server地址/端口号之后,随即与auth_server断开连接以便auth_server可以有更多的资源处理其他客户的认证请求。

每一个scene_server管理地图上的一部分场景,并用来处理这块游戏场景内的游戏逻辑,这样所有的scene_server组合起来的集群就是一张大的地图,和完整的游戏逻辑。

每一个channel_server与所有的scene_server相连接,并将每一个scene_server负责的区域信息与此scene_server的socket连接做hash映射,每一个客户端只会与一个channel_server相连接。

channel_server所做的事仅仅是根据当前客户端的人物的位置,自行转发到不同的scene_server服务器中去, 具体逻辑由scene_server进行处理。 当客户端在地图中行走时,如果channel_server判断超出了当前的scene_server的范围, 则根据客户端的当前位置从映射表中查出当前区域信息所在的scene_server的socket链接, 并将数据转到的新的scene_server服务器中去。

这样看来只需要一个常量的代价(数据被中转一次,hash查询),channel_server和scene_server几乎可以无限扩展,那么整个逻辑的响应速度也会是常量。惟一受限的将会是auth_server的响应速度。

ps.集群的思想参考自redis的集群,由于我并没有参与过RPG游戏开发,因此上述观点均为理论推测。

silly的socket模块重构

最初, 我仅仅最只想将silly实现成一个socket异步框架, 每一个socket有数据或事件过来直接将注册的处理函数异步回调即可。

然后, 随着三国杀的一步步实现, 我发现我之前考虑处理时漏掉了数据库环节。

由于所有socket事件均为异步, 当一个client发过来一个请求, 而这个请求需要使用到数据库数据时, 可能就会写出类似下面这样的代码:

socket.recv(fd,function(fd, data)
--process segment1
db.get(key, function(value)
--process segment2
end)
end)

这种异步代码写起来非常费劲, 而且如果通信协议设计不好, 当–process segment2还没有被执行, 下一条socket的数据包又过来, 就有可能造成错误的结果, 而且不容易发现问题所在。

这次的重构,我希望可以在上层屏蔽掉底层的异步逻辑,这样代码写起来会更清晰不容易出错。


大概设计是这样的。

每一个socket在建立链接之后, 便拥有一个coroutine, 和一个数据队列。
每一个socket数据包过来之后均使用这个socket的coroutine来处理,如果coroutine在处理数据包过程中挂起,则将数据推入此socket的数据队列中.
每个socket的coroutine恢复并处理完上一个数据包后,会检查队列是否为空, 如果队列为空则yield等待下一下数据包的到来, 否则将消耗完数据队列中的数据。

由于socket/tcp具有fifo的特性, 如果目标数据库服务器的request/response同样是按照fifo方式处理的话, 就可以实现一个socketfifo模块(如果是其他方式,只要对sockefifo稍加修改即可)。

socketfifo模块用来在每一个socket的coroutine中管理数据库socket的命令/应答,在向数据库socket发送命令后阻塞此socket的coroutine, 直到获取到数据库socket发过来的数据。

socketfifo持有一个coroutine队列。

socketfifo向数据库socket发送命令之后, 将当前的coroutine(即调用socketfifo模块的socket所在的coroutine)塞入coroutine队列中, 然后挂起当前的coroutine。
在socketfifo收到数据后,按照fifo的特性,从coroutine队列中取出一个coroutine将其唤醒并将数据返回给此coroutine。这样即可达到阻塞访问socket的目的。

虽然用同样的方法实现了阻塞connect, 但如果connect函数并非是在coroutine中调用就需要手动创建一个coroutine来将connect函数包住。


在重构过程中发送一个关于客户端主动close事件的bug。

抽象上看, 整个silly是通过两个通道来运行起来的。

socket –> worker (silly_queue) 主要向worker通知客户端的行为事件
worker –> socket(pipe) 主要向socket发送操作命令

最初设计时, 当客户端断开连接时, socket模块随即就释放所有资源,但是此时有可能silly_queue中还有此socket的数据包。
而socket模块中是有链接池存在的, 当某个socket被释放回链接池之后, 是有可能被分配给其他client的。

假设有两个socket, s1, s2。
在极端的情况下, 当s1主动断开链接时, 恰好worker还有s1的数据没有处理完,因此worker还不知道s1已经断开了,但此时s1所占用的struct conn结构体已经被释放到链接池了。
此时s2连接到服务器,恰好将struct conn分配给s2使用。

由于silly分配的socket id的特殊性,虽然s1和s1拥有不同的socket id, 但是他们都能索引到同一个struct conn结构体,如果此时处理s1数据包的函数需要向s1发送数据, 此时其实是向s2发送的数据包, 就会产生数据错乱的情况。

因此修改了一下socket关闭的策略, 当检测到client关闭socket之后,释放除struct conn结构体之后的所有资源,然后将struct conn置成一个特殊状态,直到worker收到client的关闭消息后再调用close函数,将struct conn归还给链接池。

在worker在主动关闭socket时,同样存在另外一个队列缓存问题, 当worker向socket模块发送close命令时, 并不知道silly_queue中是否还有此socket的数据,那么socket.lua模块就不知道应该什么时间释放与此socket有关的资源, 过早的释放资源容易引起其他问题。

因此在worker向socket模块发送shutdown(worker主动关闭socket命令定义为shutdown, 与tcp的shutdown无关)之后, socket模块释放除struct conn结构体之外的所有资源, 其后将struct conn置成一特殊状态,向worker回一条SILLY_SOCKET_SHUTDOWN消息, 当worker处理SILLY_SOCKET_SHUTDOWN消息时, 肯定已经消耗掉silly_queue中的与此socket有关的数据了。

此时socket.lua可以释放与此socket有关的资源,并调用close命令将struct conn结构体归还给链接池。

btw, 将多进程改进为多线程时,我曾以为可以躲避通信的复杂度, 现在看来, 当时想的太少了。 只要按照多进程的设计思路(使用队列通信), 不管是否真的是多进程, 异步通信协议的处理是必不可少的。

silly中socket犯的错误

silly的socket模块最初是使用epoll实现的, 后来为了在mac上开发就加入了kevent, 然后使用socket_poll.h来封装成了socket_poll。

但是由于对epoll和kevent的不了解,导致了在实现silly_socket.c文件中的_process函数时犯了一些很silly的错误。 直到昨天去实现异步发送时才突然发现epoll和kevent的不同之处。


linux下,在调用epoll_wait函数时将一个struct epoll_event结构体的数组作为参数传入,当epoll_wait函数返回且返回值大于0时, 则会将当前已经发生的事件和发生事件的文件描述符设置到传入的sctruct epoll_event结构体的数组中。

struct epoll_event结构体中的events成员用于设置相应的事件,data成员用于存储设置的用户自定义数据。问题的关键就在于events可以是几个事件的成员组合,如他的值可能是EPOLLIN | EPOLLOUT.

而我在代码中使用了if…else if … else的结构

这就会导致一个问题,如果一个socket fd即是可读又是可写的, 代码中就会将写事件略过,直到下次调用epoll_wait时,写事件又会再次被置入struct epoll_event数组中去,这样就无形中增加了epoll_wait的调用次数。

由于epoll_wait是一个系统调用, 所以会增加很多不必要的开销。而且在极端情况下如果此socket一直有读事件, 将会导致此socket fd的写被饿死。

由于是异步发送, 如果一直得不到写事件,就会导致所有的数据被挂入wlist链表上去,内存会持续增加直到wlist上的数据被写出。

其实这个结构是我第一次学epoll时从网上抄来了,由于一直没用过写事件,所以也没有察觉这种写法的问题。


在调用kevent函数之后同样将事件结果一个struct kevent结构体数组中。

struct kevent结构体中filter成员用于标志出当前struct kevent中所发生的事件, udata成员则用于存储用户自定义数据。 但是与epoll恰好相反的是每一个struct kevent变量中filter仅仅只能表示一个事件。而且这些事件的值并不是某一个二进制bit如:
#define EVFILT_READ (-1)
#define EVFILT_WRITE(-2)

因此拿filter成员与相应的事件标志位去做与操作会造成即使仅仅EVFILT_READ触发也会被误判EVFILT_READ和EVFILT_WRITE同时触发的假象。


最后就是在命令设处理上的一个设计上的问题了。

socket模块必须要实现异步发送才能才能够让worker线程尽可能的去处理逻辑,而不是阻塞在数据发送上去。

所以socket模块提供的发送接口应该仅仅是将要发送的数据以消息的形式发送给socket线程,然后当调用epoll_wait/kevent函数得到此描述符WRITE事件时, 将数据写入socket。由于epoll_wait/kevent函数均为system call。为了降低开销,一般超时时间则设为-1, 即永久等待。

为了能够在调用socket模块提供的异步发送接口时能够让epoll_wait/kevent函数从内核中及时返回, 给socket线程通知要发送数据的消息应该通过pipie或Uinx域socket的方式来发送。由于pipe在一次发送小于PIPE_BUF大小的数据时write为原子操作,而异步发送接口可能会被多个线程同时调用。因此选择使用pipe而不是Unix域socket。

最开始设计时这样的,在epoll_wait/kevent返回之后如果pipe中有数据时取出一条命令去执行, 然后执行其他socket的事件。之所以这样设计是担心如果一直处理pipe中的数据,而pipe中又一直有数据来, 那么其他socket就会被饿死。

但是今天早上在地铁上想了一路,发现这样做其实是有问题的。

首先这增加了epoll_wait/kevent的调用次数是毋容置疑的。
其次即然最坏的情况下pipe中会一直有数据, 那么如果一次pipe只去处理一个命令,就会导致后续调用socket模块提供的异步发送接口阻塞,最坏的情况下所有的worker都会被塞住, 造成所有client无响应。

将程序改为循环执行完pipe中所有命令之后再去处理其他socket事件, 再分析一下pipe中会一直有数据的情况。

如果pipe中一直有数据, 那么同样所有的socket不会得到处理,在socket事件没有得到处理的情况下, pipe中还持续有命令产生, 只能说明一点整个系统过载了。

如果整个系统过载了不管哪种实现方式都不能避免处理缓慢的情况。

然后在系统不过载的情况下, 显然循环处理完当前pipe中的所有命令之后, 再去处理其他socket事件会更有效率。

BTW,我想应该可以能过压力测试来确定最大连接数来避免系统过载。


8月2日补充:

在处理pipe的命令时, 我采用了与一般socket相同的处理方式, 即当此事件如果为pipe的READ事件则处理, 后来去瞅了瞅skynet, 发现他是先去处理pipe中的命令。

仔细想了许久终于发现这样做的好处了:

如果按照epoll_wait/kevent函数返回的事件列表去顺序处理, 最坏的情况则可能pipe被放在事件数组中的最后一个。那么在pipe中的命令被处理完以前,所有的worker线程将全被阻塞。

假如epoll_wait/kevent返回了100个event,而pipe恰好被放在event[99]。那么当pipe被处理之前就已经有99个socket被处理, 而socket线程处理前99个socket的事件时,由于worker线程将被阻塞,因此其他cpu均被浪费在空转上了。那么系统对第一个数据包的响应速度则为(处理99个fd的时间 + pipe的处理时间)

如果首先处理完pipe中的命令,其他线程就处于就绪状态, 一旦接收到socket的数据就可以立即开始处理。那么系统对于整个数据包的响应速度则为(pipe的处理时间 + 一个socket fd的读时间)

因此首先处理pipe可以更充分的使用cpu.