最近一个月在学习关系性数据库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和编程语言实现过程中的优化形式可能会相差甚远,但其本质上也是相同的,都是尽可能快的提高访问速度。