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和编程语言实现过程中的优化形式可能会相差甚远,但其本质上也是相同的,都是尽可能快的提高访问速度。



发表评论