IM服务器设计-基础
IM做为非常经典的服务器系统,其设计时候的考量具备代表性,所以这一次花几个篇幅讨论其相关设计。
主要内容相当部分参考了 一套海量在线用户的移动端IM架构设计实践分享一文,在此之上补充了更好的消息存储设计以及集群设计。
整体架构
以上架构图中,分为几个部分:
- 客户端:支持IOS、Android系统。
- 接入层:负责维护与客户端之间的长连接。
- 逻辑层:负责IM系统中各逻辑功能的实现。
- 存储层:存储IM系统相关的数据,主要包括Redis缓存系统(用于保存用户状态及路由数据)、消息数据。
上图中几部分的交互如下:
- 客户端通过gate接入IM服务器。在这里,客户端与gate之间保持TCP长连接,客户端使用DNS查询域名返回最近的gate地址进行连接。
- Gate的作用:保持与客户端之间的长连接,将请求数据转发给后面的逻辑服务LogicServer。LogicServer最上面是一个消息路由服务Router,根据请求的类型转发到后面具体的逻辑服务器。其中c代表客户端,s代表服务器,g代表群组,因此比如c2c服务就是处理客户端之间消息的服务器,而auth服务是处理客户端登录请求的服务器。
- 逻辑类服务器与存储层服务打交道,其中:redis用于存储用户在线状态、用户路由数据(用户路由数据就是指用户在哪个gate服务上维护长连接),而DB用于存储用户的消息数据,这部分留待下一部分讲解。
- 以上的接入层、逻辑层由于本身不存储状态,因此都可以进行横向扩展。看似Gate维护着长连接,但是即使一个Gate宕机,客户端检测到之后可以重新发起请求接入另一台Gate服务器。
数据存储
- 路由数据:存放在Redis中,格式为(UID,客户端在哪个gate登录)。
- 消息数据:存储在DB中,部分也会缓存在缓存中方便查询,这部分做为下一部分文章的重点来讲解,不在这部分展开讨论。
核心交互流程
统一登录系统
登录授权(auth)
- 客户端通过统一登录系统验证登录密码等。
- SSO验证客户端用户名密码之后,生成登录token并返回给客户端。
- 客户端使用UID和返回的token向gate发起授权验证请求。
- gate同步调用logic server的验证接口。
- logic server请求SSO系统验证token合法性。
- SSO向auth系统返回验证token结果。
- 如果验证成功,auth系统在redis中存储客户端的路由信息,即客户端在哪个gate上登录。
- auth系统向gate返回验证登录结果。
- gate向客户端返回授权结果。
登出(logout)
- 客户端向gate发出logout请求。
- gate设置客户端UID对应的peer无效,然后应答客户端登出成功。
- gate向logic server发出登录请求。
- 处理该类请求的c2s服务器,清除redis中的客户端路由信息。
踢人(kickout)
用户请求授权时,可能在另一个设备(同类型设备,比如一台苹果手机登录时发现一台安卓手机也在登录这个账号)开着软件处于登录状态。这种情况需要系统将那个设备踢下线。
新的客户端登陆流程同上面的登陆认证流程,只不过在auth模块完成认证之后,会做如下的操作:
- 根据UID到redis中查询路由数据,如果不存在说明前面没有登陆过,那么就像登陆流程一样返回即可。
- 否则说明前面已经有其他设备登陆了,将向前面的gate发送踢人请求,然后保存新的路由信息到redis中。
- gate接收到踢人请求,踢掉客户端之后断掉与客户端的连接。
客户端上报消息(c2s消息)
- 客户端向gate发送c2s消息数据。
- gate应答客户端。
- gate向逻辑服务器发送c2s消息。
- logic server的c2s模块,将消息发送到MQ消息总线中。
- appserver消费MQ消息做处理。
应用服务器推送消息(s2c消息)
- 业务服务器向逻辑服务器发送s2c消息。
- 逻辑服务器的s2c模块从redis中查询UID的路由数据,知道该用户在哪个gate上面登陆。
- 逻辑服务器向gate发送s2c消息。
- gate服务器向客户端发送s2c消息。
- 客户端收到之后向gate ack消息。
- gate向逻辑服务器ack s2c消息。
单对单聊天(c2c消息)
- 客户端向gate发送c2c消息。
- gate向逻辑服务器发送c2c消息。
- 逻辑服务器的c2c模块保存消息到消息存储中,此时会将该消息的未读标志置位表示未读。
- 逻辑服务器应答gate,说明已经保存了该消息,即客户端发送成功。
- gate应答客户端,表示c2c消息发送成功。
- 逻辑服务器的c2c模块,查询redis服务看该c2c消息的目标客户端的路由信息,如果不在线就直接返回。
- 否则说明该消息的目的客户端在线,向所在gate发送c2c消息。
- gate向客户端转发c2c消息。
- 客户端向gate应答收到c2c消息。
- gate向逻辑服务器应答客户端已经收到c2c消息。
- 逻辑服务器的c2c模块,在消息存储中清空该消息的未读标志表示消息已读。
注意第7步中,逻辑服务器的c2c模块在向gate转发c2c消息之后,需要加上定时器,如果在指定时间没有收到最后客户端的应答,需要重发。尝试几次重发都失败则放弃,等待下次用户登录了拉取离线消息。
群聊消息(c2g消息)
- 客户端A向gate发送c2g消息。
- gate向逻辑服务器发送c2g消息。
- 逻辑服务器的c2g模块将消息保存到SendMsg DB中,这部分消息将根据消息的发送者ID水平扩展。
- c2g模块从cache中查询该群组的用户ID,如果查不到会到存放群组信息的DB中查询。
- 遍历获取到的群组ID,保存消息到RecvMsg DB中,这部分消息将根据接受者ID水平扩展。
- 查询redis,知道哪些群组用户当前在线。
- 向当前在线的用户所在gate发送c2g消息。
- gate转发给客户端c2g消息。
- 客户端应答gate c2g消息。
- gate应答逻辑服务器的c2g模块用户已经收到c2g消息。
- c2g模块修改发送消息库该消息已读。
登录后拉取离线消息流程
- 客户端请求离线消息,其中会带上的字段是:客户端uid、当前客户端上保存的最大消息id(msgid)、每次最多获取多少离线消息(size)。当msgid为0的时候,由服务器自行查询当前的离线消息返回给客户端;否则服务器只会返回该消息id以后的消息。在这个例子中,假设第一次请求时,msgid为0,即由服务器查询需要给客户端返回哪些离线消息。
- im服务器查询uid为100的用户的前10(因为size=10)的离线消息,具体来说就是去消息接收表中查询uid=100且read flag为false的前10条消息。这里假设第一次查询返回的消息中,最大消息id为100。
- 向客户端返回最新离线消息,同时带上最大离线消息id 100。
- 客户端收到离线消息只会,由于收到的消息数量等于size,说明可能还有没有读取的离线消息,因此再次向服务器查询,这一次带上的消息id为100,表示请求该id之后的未读消息。
- IM服务器收到这一次拉取离线消息请求之后,由于msgid不为0,因此首先会将uid=100且msgid在100之前的未读消息全部置为已读。
- 获取uid=100且msgid>100的未读消息返回给客户端。
如果每次拉取的离线消息都等于拉取离线消息数量,客户端会一直重复拉取离线消息流程,直到拉取完毕。
协议设计
协议格式
协议分为包头和包体两部分,其中包体为固定的大小,包括:
- version(4字节):协议版本号。
- cmd(4字节):协议类型。
- seq(4字节):序列号。
- timestamp(8字节):消息的时间戳
- body length(4字节):包体大小。
其中,包体部分使用protobuf来定义,以下介绍不同命令的包体格式。
认证(auth)
message AuthRequest {
string token = 1; // 从SSO服务器返回的登录token,登录之后保存在客户端
srting uid = 2; // 用户ID
}
message AuthResponse {
int32 status = 1; // 应答状态码,0表示成功,其他表示失败
string err_msg = 2; // 错误描述信息
}
登出(logout)
message LogoutRequest {
string token = 1; // 从SSO服务器返回的登录token,登录之后保存在客户端
srting uid = 2; // 用户ID
}
message LogoutResponse {
}
踢人(kickout)
message KickoutRequest {
enum Reason {
OTHER_LOGIN = 1; // 其他设备登录
}
int32 reason = 1; // 踢人原因
}
message KickoutResponse {
}
心跳包
无包体
单对单消息(c2c)
// 发送者发送消息的协议
message C2CSendRequest {
string from = 1; // 发送者
string to = 2; // 接收者
string content = 3; // 消息内容
}
message C2CSendResponse {
int64 msgid = 1; // 落地的消息ID
}
// 推送给接收者的协议
message C2CPushRequest {
string from = 1;
string content = 2;
int64 msgid = 3;
}
message C2CPushResponse {
int64 msgid = 1; // 消息id,服务器收到这个id可以去置位这个消息已读
}
群聊(c2g)
// 发送者发送群消息协议
message C2GSendRequest {
string from = 1; // 发送者
string group = 2; // 群
string content = 3; // 消息内容
}
message C2GSendResponse {
int64 msgid = 1; // 落地的消息ID
}
// 推送给其他群成员消息协议
message C2GPushRequest {
string from = 1; // 发送者
string group = 2; // 群
string content = 3; // 消息内容
int64 msgid = 4; // 落地的消息ID
}
message C2GPushResponse {
int64 msgid = 1; // 落地的消息ID
}
拉离线消息(pull)
message C2SPullMessageRequest {
string uid = 1;
int64 msgid = 2; // 拉取该消息id以后的离线消息,为0由服务器自行判断
int32 limit = 3; // 单次拉取离线消息的数量
}
message PullMsg {
string from = 1; // 发送者
int64 group = 2; // 目的群
string content = 3; // 消息内容
int64 msgid = 4; // 消息编号
int64 send_time = 5; // 服务器接收消息时间
}
message C2SPullMessageResponse {
repeated PullMsg msg = 1; // 离线消息数组
}