集群聊天服务器项目总结
首先是就是项目介绍集群聊天服务器项目(零)——项目介绍中的内容,就不再次copy过来了
项目简单介绍
技术栈
环境和库依赖
按模块介绍整个项目
程序的主要模块是网络模块、业务模块、数据模块、Json、redis发布订阅消息队列模块以及nginx负载均衡模块
网络模块
网络模块底层采用的是陈硕的muduo
库,其采用的是 one loop per thread + nonblocking IO
的网络事件模型,其基于epoll的事件处理机制能够高效地处理网络事件,提供了高效的事件处理和内存管理方式。有一个较高muduo
通过事件驱动的方式实现了异步I/O,能够支持上万的并发连接。用户只需要关注连接到来和socket
消息到来时的业务处理。
使用muduo库作为项目的核心网络模块,提供高并发以及高可用网络IO服务,解耦网络和业务模块的代码,提高了系统的可维护性和可扩展性
业务模块
这一模块主要完成相应的业务处理,如
-
客户端新用户注册
-
客户端用户登录
-
添加好友和添加群组
-
一对一好友聊天
-
群组聊天
-
离线消息存储
其工作方式是通过解析收到json
数据根据消息类型来调用对应的业务函数
数据模块
数据模块主要就是对MySQL数据库的表和操作一系列封装。
本项目的数据库表为:User
表、Friend
表、AllGroup
表、GroupUser
表、OfflineMessage
表
本项目对MySQL的CRUD基本操作封装为一个类MySQL
,然后将表的字段封装为一个类并提供对应的 get
和 set
方法也就是加入 ORM
(object Relation Model)类,业务层操作的都是对象,DAO
层(数据访问层)即xxxmodel
类才访问数据,对表的业务操作的封装(如往表中插入新用户的数据、根据id查询用户信息等)。
比如:user
表有id、name、passwd字段,将其封装为User
类(ORM
层),有对应的数据成员和函数成员getId、getName、setId、setName等,然后封装具体的用户表的操作类UserModel
,里面提供插入新用户方法insert(User &user)
、查询用户信息方法query(int id)
以及更新用户状态的成员函数updateState(User user)
这么做的好处是解耦了业务层和数据层。
其中表的规模大概是5w行以内的数据量。
Json
本项目使用json来序列化和反序列化消息作为私有通信协议。
Json是一种轻量级的数据交换格式(也叫数据序列化方式)。Json采用完全独立于编程语言的文本格式来存储和表示数据。简洁和清晰的层次结构使得 Json 成为理想的数据交换语言。
未来计划改进为protobuf
nginx负载均衡模块
该模块的主要作用是实现集群服务器的功能,使得客户端可以连接到不同的服务器上,从而提高整个聊天服务器的并发量(单端口可达6w并发量)。其中配置nginx
基于权重的负载/轮询均衡算法。
nginx
的主要用处或优点如下:
- 把
client
请求按负载算法分发到具体业务服务器Chatserver - 能和
ChatServer
保持心跳机制,检测ChatServer
保持心跳机制,检测ChatServer
故障 - 能发现新添加的
ChatServer
设备方便服务器扩展数量
redis发布订阅消息队列
redis发布订阅消息队列的主要作用是在使用了集群服务器之后实现跨服务器聊天。该消息队列在程序中的工作流程大致如下:
用户c1在某个服务器上连接后,要把该连接在redis队列上订阅一个通道号为c1(通道号为用户id)
别的客户端c2在不同的服务器上登录要给c1发消息,会直接发到redis队列上的通道号c1,消息队列会把信息发送到正在订阅通道c1的redis连接上 (阻塞等待消息中),客户端c1在其对应的接收信息线程中可接受到c2发来的信息
每个连接到服务器的用户要开一个单独线程进行监听通道上的事件,有消息给业务层上报。
当服务器发现发送的对象id没有在自己的_userConnMap
上,就要往消息队列上publish
,消息队列就会把消息发布给订阅者。
消息队列是长连接跨服务器聊天通用方法
消息队列这种中间件是典型的观察者模式的应用实践
遇到的问题
服务器异常退出没有将用户状态置为offline
解决方法:服务器main.cpp 中设置一个信号处理函数来设置用户状态
// chat/src/server/main.cpp
void resetHandler(int)
{
ChatService::instance()->reset();
exit(0);
}
客户端异常退出没有将用户状态置为offline
解决方法:连接断开时,调用业务层提供的方法来将对应用户的映射信息从服务器的_userConnMap
中删除,并且设置用户状态为offline
// ChatServer.cpp
void ChatServer::onConnection(const TcpConnectionPtr &conn)
{
if(!conn->connected())
{
ChatService::instance()->clientCloseException(conn);
conn->shutdown();
}
}
客户端登录后输入logout 退出登录服务端出错
错误信息:
{“id”:4,"
exception caught in Thread ChatServer1
reason: [json.exception.parse_error.101] parse error at line 1, column 10: syntax error while parsing object key - invalid string: missing closing quote; last read: ‘"’; expected string literal
Aborted
排查和解决过程:
gdb调试检查服务端接收到的json序列化的字符串,发现收到的数据不完整,那么可能是发送方发送不完整,定位到发送语句:
send(clientfd, buffer.c_str(), sizeof(buffer.c_str()) + 1, 0);
并且通过测试发现:
sizeof(buffer.c_str()) = 8
strlen(buffer.c_str()) = 31
所以不应该使用 sizeof(buffer.c_str())
而应该换成 strlen
一个用户没法存储多条离线消息,上线时只能收到一条消息
通过查看MySQL表,发现原因是离线消息表offlinemessage
中的 userid
和message
都设置为了 unique
,这样导致同一个用户只能有一条离线消息记录到表中,
解决方法:
offlinemessage
表 字段都不要设置为unique
,因为一个userid
可以对应条离线消息
客户端登录时接收一条以上的离线消息导致直接终止
错误信息:
排查过程
先是检查自己有没有输入中文或者中文字符
然后通过打印登录时从服务器获得的json串,发现输出不完整
这里是个有趣的问题,我之前做百万并发测试的时候把内核的rmem
和wmem
都调到了512,导致了这里read一轮读出的json序列化串不完成造成解析失败。
解决方法:
sudo modprobe ip_conntrack
sudo vim etc/sysctl.conf
按如下修改
修改之后输入:sudo sysctl -p
nginx编译安装错误
错误1:
src/os/unix/ngx_user.c:36:7: error: ‘struct crypt_data’ has no member named ‘current_salt’
36 | cd.current_salt[0] = ~salt[0];
解决方法:
# vim src/os/unix/ngx_user.c
将对应代码注释掉
错误2:
error: cast between incompatible function types from ‘size_t (*)(ngx_http_script_engine_t *)’ {aka ‘long unsigned int (*)(struct <anonymous> *)’} to ‘void (*)(ngx_http_script_engine_t *)’ {aka ‘void (*)(struct <anonymous> *)’} [-Werror=cast-function-type]
解决方法:
输入 vim objs/Makefile
把 -Werror删掉 (-Werror,它要求GCC将所有的警告当成错误进行处理)
错误3:
mv: cannot move ‘/usr/local/nginx/sbin/nginx’ to ‘/usr/local/nginx/sbin/nginx.old’: Permission denied
这表示移动文件没权限
解决方法:
将目标目录的权限更改为当前用户拥有的权限
sudo chown -R $(whoami) /usr/local/nginx
项目面试可能问题
你这里数据都是明文传输,不安全怎么解决?
进行加密
对称加密码算法:加解密效率高,AES加解密算法
非对称加密:公钥和私钥,加密复杂,效率慢,但是安全、RSA算法
实践方法:第一次用非对称加密发送对称密钥,后面双方都用对称密钥加密信息
客户端消息如何按序显示
消息添加序列号seq,接收方维护一个下一次应该接收消息的序列号,如果后发的提前到了,则会将消息缓存起来。加序号不仅可以保证消息按序到达,还可实现其他功能,如消息撤回
如果给消息添加一个时间戳,到达客户端再按时间排序,但是有问题,比如以1s为周期进行消息显示,很可能最先发的消息没有和它相邻的消息一起进行排序,即无法实现全局有序显示。
不能用短链接吗?
http就是B/S 无状态、短链接,无法主动推消息,只能被动响应
服务端要主动推消息,常用websocket
集群聊天服务器需要处理大量的客户端连接请求,而使用短链接会导致频繁的连接和断开操作,增加服务器的负担。当客户端需要发送消息时,短链接需要重新建立连接,而在连接的建立和断开时会产生额外的网络开销和延迟,影响系统的性能。
通常IM即时聊天都在服务器上有长连接模块
如果网络拥塞严重,ChatServer端如何感知客户端在线还是掉线
客户端发FIN包,服务器recv 得到0表示client下线
心跳机制设计:
listen socket 8080
通用业务处理
UDP socket 8080
心跳业务处理
启动一个心跳计时器(server启动),超时1s,把所有账号心跳计数+1
connect
成功的client
分配一个心跳计数(heartbeatcnt
)
如设计的消息格式为: userid:zhangsan1, heartbeatcnt :4
若账号心跳计数 》 5,即判定client掉线,拆除client所有连接及其他资源(业务层的一些数据)
若从TCP 协议分析,传输层,keepalive,用于确定对方没说话还是掉线可以吗?
不可以,因为keepalive
默认关闭,setsockopt
开启,默认每隔两小时发送一个空报文段,探测对方是否在线。 若探测无响应,延迟75s继续发送探测包,依旧没有则再探测9次 (75 * 9)。但是若应用层死锁了,传输层检测意义不大了。
所以,基于长连接业务通常都是在业务层自己设计心跳保持机制
怎么保证消息的可靠传输
应用层实现消息确认机制
为什么tcp的消息确认机制不能保证消息可靠传输?
超时重传可能失败
send(fd, buf, buf_size, 0);
返回>0 只是将用户空间数据buf 拷贝到内核空间的TCP发送缓冲区中,不代表发送成功。这是tcp的消息确认机制不能保证消息可靠传输原因之一
最终由内核TCP协议栈 将数据发送出去。
TCP IP MAC最大数据 MTU 1500字节,但TCP和IP头都占20B,故实际携带数据为1460字节
只要是数据传输失败,或是返回ACK失败,C端都认为数据发送失败,启动超时重传定时器,连发几次后若还不行,就发一个RST包。
最终还是要在业务上实现可靠传输:
客户要发送的消息都缓存起来,如下
历史消息如何存储
主要有两种存储位置:
本地消息存储 和 云消息存储
本地
好友qq号作为文件夹,
SQLite
嵌入式数据库(嵌入到当前进程里),方便查询
云消息存储
存储到mysql
,若为长时间的聊天数据可以存储到文件服务器上即 dump-> fileserver,因为mysql超过千万行后索引空间占用大,磁盘操作很慢。
除了redis,还知道其他组件能完成相应的功能(消息队列)?
redis
功能 : 缓存数据库,k-v,数据持久化;分布式锁、发布订阅channel
服务器中间件:放在后端中间,不能单独跑。如MQ消息队列,kafka
, zeromq
,rabbitmq
,rocketmq
redis运行不稳定,挂了怎么办?
redis消息积累的过快,消费消息过慢,可能导致挂
消息的消费不可靠。
非核心业务、流量不是非常大,可以用redis的发布订阅功能
为什么要用redis作为跨服务器通信的组件,为什么各个server不能相互直接通信呢?
若各个server直连,则服务器还要承担客户端职责,服务器间耦合性高。需要和每个server相互连接,还要不断心跳。