文章目录
一、集群聊天服务器问题产生与解决
1.1 服务器并发量上限问题
为什么要集群服务器?
我们一台服务器上的文件描述符是有限的,32位操作系统大约支持2w左右的并发量,一旦客户端非常多时业务就无法支持了。此时就需要集群或分布式的部署了,引入多台服务器增加并发量。
集群服务器为什么要引入负载均衡器?
我们引入集群后,扩展了多台主机为用户提供服务,但用户使用时并不需要知道连接那台服务器,因此就服务器集群环境中需要引入负载均衡器(反向代理)设备。负载均衡设备统一接收客户端请求,依据特殊的负载算法(轮询、权重、哈希、一致性哈希算法等)将客户端的请求均衡的分发到每个服务器上,我们项目选择使用nginx的tcp负载均衡模块,能够非常轻松提高服务器的并发量(并发量大约为5-6w)。
负载均衡器主要作用:
1、将客户端请求按照负载均衡算法分发到具体的业务服务器上,再将服务器的响应经过负载均衡器转发给客户端;
2、能够和服务器保持心跳机制,检测服务器故障,增加容错率;
3、能够发现新添加的服务器设备,且不影响原本服务器工作,平滑加载配置文件来扩容;
1.2 跨服务器通信问题
如何解决跨服务器聊天问题?
我们客户端聊天业务主要涉及一对一聊天与群聊功能,一旦涉及跨服务器通信,本台服务器维护的_userConnMap表无法识别其它服务器上用户是否在线,并且用户之间的通信过程逻辑会变得异常复杂。
1、解决方案一:我们可以让各个聊天服务器互相之间直接建立TCP连接进行通信,相当于在服务器网络之间进行广播。这样的会设计使得各个服务器之间耦合度太高,不利于系统扩展,并且会占用系统大量的socket资源,各服务器之间的带宽压力很大,不能够节省资源给更多的客户端提供服务。
2、解决方案二:集群部署的服务器之间进行通信,最好的方式就是引入中间件消息队列,解耦各个服务器,使整个系统松耦合,提高服务器的响应能力,节省服务器的带宽资源。 在集群分布式环境中,经常使用的中间件消息队列有ActiveMQ、RabbitMQ、Kafka等,都是应用场景广泛并且性能很好的消息队列,供集群服务器之间,分布式服务之间进行消息通信。我们的项目业务类型并不是非常复杂,对并发请求量也没有太高的要求,因此我们的中间件消息队列选型的是基于发布-订阅模式的redis。
引入消息队列后,我们服务器只需要做订阅消息,发布消息即可。 例如:我们每一台服务器会与消息队列进行连接,服务器会往消息队列中写数据以及从中取数据。每一次有用户在服务器1登录后,服务器1会在消息队列中对subscribe订阅客户端1的消息事件,若在消息队列中客户端1收到消息,消息队列便会将其上报给服务器1,其它服务器也是如此。当两个不同服务器上用户聊天时,如服务器1将消息publish发布到消息队列中,是发给客户端2的消息,消息队列接收到消息,将其notify通知给服务器2进行处理。
二、nginx的tcp负载均衡配置
2.1 nginx安装
1、提前下载好nginx源码包,进行解压。
2、进入解压后文件夹,需要在root用户下执行configure生成相应的makefile文件。由于nginx在1.9版本之前,只支持http协议web服务器的负载均衡,从1.9版本开始以后,nginx开始支持tcp的长连接负载均衡,但是nginx默认并没有编译tcp负载均衡模块,因此编写它时还需要加入–with-stream参数来激活tcp负载均衡模块。
3、再进行make编译。
4、最后makeinstall进行安装。
5、安装完成后,nginx默认安装在/usr/local/nginx目录下。
2.2 负载均衡配置
1、进入nginx.conf配置文件,在配置文件中添加如下配置:
2、配置文件修改完成后,平滑重启即可。以下为nginx常用命令。
nginx -s reload 重新加载配置文件启动
nginx -s stop 停止nginx服务
配置文件修改完成后,执行./nginx -s reload平滑重启,重启后可以看到nginx服务正在运行。
2.3 负载均衡验证
1、在我们的聊天项目中,将服务器端ip地址与端口号改为手动输入。
2、我们上面nginx配置的两台机器分别为6000端口与6002端口,先分别启动它们。
3、在开启两个端口分别通过8000端口号进行客户端登录,分别可以看到服务器被均衡的分配给两台端口号不一样的服务器。
经过配置以后,在客户端多的时候,可以非常轻松提高服务器的并发量。
三、redis中间件
redis中间件: redis首先是一个强大的缓存服务器,比memcache强大很多,不仅仅支持多种数据结构(不像memcache只能存储字符串)如字符串、list列表、set集合、map映射表等结构,还可以支持数据的持久化存储(memcache只支持内存存储),经常被应用到高并发的服务器环境设计之中。
3.1 redis环境配置
redis安装参考博客:Centos7安装Redis
安装成功,可以看到redis默认工作在本地主机的6379端口上。
3.2 redis发布订阅命令
redis存储普通键值对: 启动redis-cli客户端,redis是以键值对形式存储数据的,如图所示我们用set存储键值对数据,get获取数据,这里对redis的命令操作就不过多解释了。
127.0.0.1:6379> set 1 "zhang san" # 设置key-value
OK
127.0.0.1:6379> get 1
"zhang san"
127.0.0.1:6379> set num 1
OK
127.0.0.1:6379> INCR num # redis本身支持事务处理,多线程对key自增自减是线程安全的
(integer) 2
127.0.0.1:6379> INCR num
(integer) 3
redis的发布-订阅机制:发布-订阅模式包含了两种角色,分别是消息的发布者和消息的订阅者。订阅者可以订阅一个或者多个频道channel,发布者可以向指定的频道channel发送消息,有订阅此频道的订阅者都会收到此消息。
订阅频道的命令是 subscribe,可以同时订阅多个频道,订阅后进入订阅阻塞状态,等待该频道上的信息。执行上面命令客户端会进入订阅状态,处于此状态下客户端不能使用除subscribe、unsubscribe、psubscribe和punsubscribe这四个属于"发布/订阅"之外的命令,否则会报错。进入订阅状态后客户端可能收到3种类型的回复,每种类型的回复都包含3个值,第一个值是消息的类型,根据消类型的不同,第二个和第三个参数的含义可能不同。消息类型的取值可能是以下3个:
1、subscribe:表示订阅成功的反馈信息。第二个值是订阅成功的频道名称,第三个是当前客户端订阅的频道数量。
2、message:表示接收到的消息,第二个值表示产生消息的频道名称,第三个值是消息的内容。
3、unsubscribe:表示成功取消订阅某个频道。第二个值是对应的频道名称,第三个值是当前客户端订阅的频道数量,当此值为0时客户端会退出订阅状态,之后就可以执行其他非"发布/订阅"模式的命令了。
简单使用如图所示:
3.3 redis编程环境配置
redis支持多种不同的客户端编程语言,例如Java对应jedis、php对应phpredis、C++对应的则是hiredis,我们需要进行编程环境的配置。
1、git clone https://github.com/redis/hiredis,从github上下载hiredis客户端,进行源码编译安装。
2、进入hiredis目录,执行make编译。
3、执行sudu make install,向系统目录拷贝相应文件,拷贝生成的动态库到/usr/local/lib目录下。
4、使用sudo ldconfig /usr/local/lib命令将/usr/local/lib加入默认库即可。
3.4 redis代码封装
redis封装业务主要功能:
redis.hpp代码如下:
#ifndef REDIS_H
#define REDIS_H
#include <hiredis/hiredis.h>
#include <thread>
#include <functional>
using namespace std;
class Redis
{
public:
Redis();
~Redis();
//连接redis服务器
bool connect();
//向redis指定的通道channel发布消息
bool publish(int channel, string message);
//向redis指定的通道subscribe订阅消息
bool subscribe(int channel);
//向redis指定的通道unsubscrible取消订阅消息
bool unsubscrible(int channel);
//在独立线程中接收订阅通道中的消息:响应通道上发生的i小凹型
void observer_channel_message();
//初始化向业务层上报通道消息的回调对象
void init_notify_handler(function<void(int, string)> fn);
private:
//hiredis同步上下文对象,负责publish消息:相当于我们客户端一个redis-cli跟连接相关的所有信息,需要两个上下文处理
redisContext *_publish_context;
//hiredis同步上下文对象,负责subscribe消息
redisContext *_subcribe_context;
//事件回调操作,收到订阅的消息,给service层上报:主要上报通道号、数据
function<void(int, string)> _notify_message_handler;
};
#endif
redis.cpp代码如下:
#include "redis.hpp"
#include <iostream>
using namespace std;
//构造函数:初始化两个上下文指针
Redis::Redis() : _publish_context(nullptr), _subcribe_context(nullptr)
{
}
//析构函数:释放两个上下文指针占用资源
Redis::~Redis()
{
if (_publish_context != nullptr)
{
redisFree(_publish_context);
}
if (_subcribe_context != nullptr)
{
redisFree(_subcribe_context);
}
}
//连接redis服务器
bool Redis::connect()
{
//负责publish发布消息的上下文连接
_publish_context = redisConnect("127.0.0.1", 6379);
if (nullptr == _publish_context)
{
cerr << "connect redis failed!" << endl;
return false;
}
//负责subscribe订阅消息的上下文连接
_subcribe_context = redisConnect("127.0.0.1", 6379);
if (nullptr == _subcribe_context)
{
cerr << "connect redis failes!" << endl;
return false;
}
//在单独的线程中监听通道上的事件,有消息给业务层上报 让线程阻塞去监听
thread t([&](){
observer_channel_message();
});
t.detach();
cout << "connect redis-server success!" << endl;
return true;
}
//向redis指定的通道channel publish发布消息:调用redisCommand发送命令即可
bool Redis::publish(int channel, string message)
{
redisReply *reply = (redisReply *)redisCommand(_publish_context, "PUBLISH %d %s", channel, message.c_str()); //相当于给channel通道发送消息
if (nullptr == reply)
{
cerr << "publish command failed!" << endl;
return false;
}
freeReplyObject(reply);
return true;
}
/* 为什么发布消息使用redisCommand函数即可,而订阅消息却不使用?
redisCommand本身会先调用redisAppendCommand将要发送的命令缓存到本地,再调用redisBufferWrite将命令发送到redis服务器上,再调用redisReply以阻塞的方式等待命令的执行。
subscribe会以阻塞的方式等待发送消息,线程是有限,每次订阅一个线程会导致线程阻塞住,这肯定是不行的。
publish一执行马上会回复,不会阻塞当前线程,因此调用redisCommand函数。
*/
//向redis指定的通道subscribe订阅消息:
bool Redis::subscribe(int channel)
{
// SUBSCRIBE命令本身会造成线程阻塞等待通道里面发生消息,这里只做订阅通道,不接收通道消息
// 通道消息的接收专门在observer_channel_message函数中的独立线程中进行
// 只负责发送命令,不阻塞接收redis server响应消息,否则和notifyMsg线程抢占响应资源
if (REDIS_ERR == redisAppendCommand(this->_subcribe_context, "SUBSCRIBE %d", channel)) //组装命令写入本地缓存
{
cerr << "subscribe command failed!" << endl;
return false;
}
// redisBufferWrite可以循环发送缓冲区,直到缓冲区数据发送完毕(done被置为1)
int done = 0;
while (!done)
{
if (REDIS_ERR == redisBufferWrite(this->_subcribe_context, &done)) //将本地缓存发送到redis服务器上
{
cerr << "subscribe command failed!" << endl;
return false;
}
}
// redisGetReply
return true;
}
//向redis指定的通道unsubscrible取消订阅消息,与subscrible一样
bool Redis::unsubscrible(int channel)
{
if (REDIS_ERR == redisAppendCommand(this->_subcribe_context, "UNSUBSCRIBE %d", channel))
{
cerr << "unsubscribe command failed!" << endl;
return false;
}
// redisBufferWrite可以循环发送缓冲区,直到缓冲区数据发送完毕(done被置为1)
int done = 0;
while (!done)
{
if (REDIS_ERR == redisBufferWrite(this->_subcribe_context, &done))
{
cerr << "unsubscribe command failed!" << endl;
return false;
}
}
return true;
}
//在独立线程中接收订阅通道中的消息:以循环阻塞的方式等待响应通道上发生消息
void Redis::observer_channel_message()
{
redisReply *reply = nullptr;
while (REDIS_OK == redisGetReply(this->_subcribe_context, (void**)&reply))
{
//订阅收到的消息是一个带三元素的数,通道上发送消息会返回三个数据,数据下标为2
if (reply != nullptr && reply->element[2] != nullptr && reply->element[2]->str != nullptr)
{
//给业务层上报通道上发送的消息:通道号、数据
_notify_message_handler(atoi(reply->element[1]->str), reply->element[2]->str);
}
freeReplyObject(reply);
}
}
//初始化向业务层上报通道消息的回调对象
void Redis::init_notify_handler(function<void(int, string)> fn)
{
this->_notify_message_handler = fn;
}
四、服务器支持跨服务器通信功能
redis主要业务流程:
1、用户登录成功后相应的服务器需要向redis上依据用户id订阅相应通道的消息。
2、当服务器上用户之间跨服务器发送消息时,需要向通道上发送消息。
3、redis接收到消息通知相应服务器进行处理。
我们原来单机服务器的代码仅需要修改如下部分即可实现跨服务器通信:
1、先在服务器业务类中添加redis操作对象;
Redis _redis; //redis操作对象
2、在服务器业务类的构造函数中事先注册回调函数,让redis帮我们监听上报通道上的消息。
//连接redis服务器
if (_redis.connect())
{
//设置上报消息的回调
_redis.init_notify_handler(std::bind(&ChatService::handleRedisSubscribeMessage, this, _1, _2));
}
//从redis消息队列中获取订阅的消息:通道号 + 消息
void ChatService::handleRedisSubscribeMessage(int userid, string msg)
{
lock_guard<mutex> lock(_connMutex);
auto it = _userConnMap.find(userid);
if (it != _userConnMap.end())
{
it->second->send(msg);
return;
}
//存储该用户的离线消息:在从通道取消息时,用户下线则发送离线消息
_offlineMsgModel.insert(userid, msg);
}
3、用户登录成功后,依据用户id向redis订阅相应通道的消息。
//id用户登录成功后,向redis订阅channel(id)通道的事件
_redis.subscribe(id);
用户注销下线后或异常退出时,依据用户id向redis取消相应通道的消息。
//用户注销下线,在redis中取消订阅通道
_redis.unsubscrible(userid);
4、一对一聊天部分也需要同步修改:A向B说话,在map表中未找到B,B可能不在本台服务器上但通过数据库查找在线,要发送的消息直接发送以B用户为id的通道上;也可能是离线状态,发送离线消息。
//处理一对一聊天业务
void ChatService::oneChat(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、先获取目的id
int toid = js["toid"].get<int>();
{
lock_guard<mutex> lock(_connMutex);
auto it = _userConnMap.find(toid);
if (it != _userConnMap.end()) //2、目的id在线,进行消息转发,服务器将源id发送的消息中转给目的id
{
it->second->send(js.dump());
return;
}
}
//查询toid是否在线
User user = _userModel.query(toid);
if (user.getState() == "online")
{
_redis.publish(toid, js.dump());
return;
}
//目的id不在线,将消息存储到离线消息里
_offlineMsgModel.insert(toid, js.dump());
}
群组聊天也需要修改:A向B说话,在map表中未找到B,B可能不在本台服务器上但通过数据库查找在线,要发送的消息直接发送以B用户为id的通道上;也可能是离线状态,发送离线消息。
//群组聊天业务
void ChatService::groupChat(const TcpConnectionPtr &conn, json &js, Timestamp time)
{
//1、获取要发送消息的用户id、要发送的群组id
int userid = js["id"].get<int>();
int groupid = js["groupid"].get<int>();
//2、查询该群组其它用户id
vector<int> useridVec = _groupModel.queryGroupUsers(userid, groupid);
//3、进行用户查找
lock_guard<mutex> lock(_connMutex);
for (int id : useridVec)
{
auto it = _userConnMap.find(id);
if (it != _userConnMap.end()) //用户在线,转发群消息
{
it->second->send(js.dump());
}
else //用户不在线,存储离线消息 或 在其它服务器上登录的
{
//查询toid是否在线
User user = _userModel.query(id);
if (user.getState() == "online") //在其它服务器上登录的
{
_redis.publish(id, js.dump());
}
else //存储离线消息
{
_offlineMsgModel.insert(id, js.dump());
}
}
}
}
测试一下:启动两台服务器分别为6000、6002端口,客户端通过8000端口登录,通过负载均衡器均衡的分配到两台服务器上。
登录在两台不同服务器的用户进行通信,可以看到能够收发到消息。