项目地址:https://github.com/robinfoxnan/BirdTalkServer
BirdTalk 交互协议
前言
首选需要明确:
在单机模式下,不使用消息队列,所有的交互都使用内部的管道来实现;
在集群模式下,使用kafka消息队列,集群主机之间使用消息队列来支持分发;
详细见 《集群策略.md》
1. 交互
1.1 通信协议
使用websocket 进行通信;
参考msg.proto
所有的消息使用一致的模型定义:
- 握手消息:客户端与服务端协议最基本的信息,以及实现负载均衡的重定向;
- 协商秘钥消息:暂无;
- 心跳消息:定时发送心跳(5分钟),保持链接;
- 聊天消息:所有的一对一的聊天以及群聊都使用此类型消息定义;
- 聊天送达消息:送达包括发送成功,已经送达,已读;
- 上传文件消息:小文件在带内上传;
- 上传应答消息:应答上传结果或者应答失败;
- 下载请求:小文件可以带内下载;
- 下载回复:应答文件内容或者应答失败;
- 同步消息请求:登录后,以及送达超时时候用户端发送的同步请求消息,服务端应答
- 用户操作:关于注册登录等相关的操作请求;
- 用户操作结果:关于注册登录等相关的操作的处理结果应答;
- 好友相关操作:查询用户,申请加好友等操作,如果需要对方同意,则需要在服务器转发;
- 好友操作结果:好友操作的结果;
- 群组管理操作:群组操作相关的申请;
- 群组操作结果:群主操作的应答,个别消息需要服务端转发;
// 通用所有消息类型的定义
enum ComMsgType {
MsgTUnused = 0; // 未使用的消息类型
MsgTHello = 1; // 用于握手的消息
MsgTHeartBeat = 2; // 用于保持连接的心跳消息
MsgTError = 3; // 用于传递错误信息的消息
MsgTKeyExchange = 4; // DH密钥交换的消息
MsgTChatMsg = 5; // 聊天消息
MsgTUpload = 6; // 上传文件的消息
MsgTDownload = 7; // 下载文件的消息,文件操作分为带内和带外,这里是小文件可以这样操作
MsgTChatMsgReply = 8;
MsgTUploadReply = 9;
MsgTDownloadReply = 10;
// 用户、好友与群组消息一共是6大类,
MsgTUserOp = 11; // 所有用户相关操作的消息
MsgTUserOpRet = 12;
MsgTFriendOp = 13;
MsgTFriendOpRet = 14;
MsgTGroupOp = 15; // 所有群组相关的操作
MsgTGroupOpRet = 16;
}
消息类型与对应的消息内容一起对应起来使用;
比如当类型为:MsgTChatMsg时,则chatData应该不为空,这样可以避免在运行时候不停的检查消息类型到底是什么;
1.2 握手动作
-
如果客户端配置了多个服务器,需要随机选一个服务器执行连接;
-
连接完成后,客户端应该发送MsgHello包,包中需要描述自己设备的ID以及支持的协议版本号,客户端的类型以及版本号;如果需要协商加密,则在消息中指明是否需要协议上交换秘钥;目前版本不考虑实现,直接使用HTTPS的机制;
-
服务端也需要发送MsgHello包,包括是否需要重定向到负载均衡的服务器上,服务器协议版本号;
-
客户端如果收到重定向消息,则需要重新执行连接动作;如果需要协商秘钥,这里开始协商;如果收到普通的应答,则可以开始其他操作;
1.3 秘钥协商
这里可以参考telegram的秘钥协商,这里先不做,因为如果使用websocket就可以自己加密了;点对点加密可以在用户私聊时候设置,也可以在客户端通过设置提示词来设置对称加密秘钥;
1.4 心跳
由客户端发起心跳包,一般设置5分钟即可;
2. 用户管理相关逻辑
用户的操作定义在user.proto文件中,分为3个方面共6大类消息类型,每一类还有细分的动作;
// 用户和好友操作类型枚举
enum OperationType {
RegisterUser = 0;
UnregisterUser = 1;
DisableUser = 2;
RecoverUser = 3;
SetUserInfo = 4;
RealNameVerification = 5;
Login = 6;
Logout = 7;
FindUser = 8;
AddFriend = 9;
ApproveFriend = 10;
RemoveFriend = 11;
BlockFriend = 12;
SetFriendPermission = 13;
}
2.1 基础用户操作
// 用户信息消息
message UserInfo {
int64 userId = 1; // 注册时候全局分配的
string userName = 2; // 用户名
string nickName = 3; // 昵称
string email = 4; // 邮箱
string phone = 5; // 手机号
string gender = 6; // 性别
int32 age = 7; // 年龄
string region = 8; // 地区
string icon = 9; // 头像
map<string, string> params = 10;
// 其他参数,使用map存储比如,title, pwd, sid, icon
}
// 用户操作请求消息
message UserOpReq {
string operation = 1;
// 操作类型,例如:注册用户、注销、禁用、恢复、设置信息、实名验证、登录、退出、查找用户
UserInfo user = 2;
// 涉及的用户信息
map<string, string> params = 3; // 比如申请好友的附加消息
}
// 用户操作结果消息
message UserOpResult {
string operation = 1; // 操作类型
string result = 2; // "ok" "fail"
repeated UserInfo users = 3; // 涉及的用户信息列表,使用repeated表示多个用户
map<string, string> params = 4;
}
2.1.1 注册
使用UserOpReq 结构发起注册申请;
message UserOpReq {
string operation = 1; // RegisterUser
UserInfo user = 2; // 这里填写
map<string, string> params = 3;
}
支持三种方式注册,
1)匿名注册,只填写用户名,使用英文字母和数字以及下划线连接线,服务端验证唯一性,不需要验证码直接返回用户的唯一ID;
2)邮件注册,服务器生成5位随机码,放到用户的Session中,经过邮件发送给客户;
3)手机注册,服务器生成5位随机码,放到用户的Session中,经过短信发送给客户;
//申请注册时候, 设置params 中 stage="request", code="12345"
//应答时候, 设置params 中 stage="validate", code="12345"
如果发送邮件错误时候:
message UserOpResult {
string operation = 1; // RegisterUser
string result = 2; // "ok" "fail" "validate"
repeated UserInfo users = 3; // 这里没有信息
map<string, string> params = 4; // "error"= "error details"
}
等待验证码过程中,等到"validate" 时跳转到页面等待用户输入验证码;这里可以防止
服务器验证了code之后,如果验证码正确,就分配一个ID,之后使用users字段返回数据;
备注:
1)匿名注册需要检查用户名是否唯一;邮件和手机需要检查是否已经注册过,如果入册过返回错误,提示用户已经注册过,提示使用密码登录,如果忘记了密码,可以使用手机短信或者邮件重置密码;这样的设计主要是防止第三方攻击;
2)如果用户多次输入了错误的验证码,则需要锁定程序若干分钟,防止冒用别人的账户,暴力破解攻击;
3)如果是注销过的账号,不重新分配用户号,一些信息使用旧的,验证方式不变;
2.1.2 注销
基本等同禁用;标记有区别;禁用可以恢复;注销不能恢复;
2.1.3 禁用
禁用功能一般应该是管理员使用;
数据库中标记用户被禁用,同时将各个客户端的session信息都标记禁用或者删除session,通知该账号下的所有现在的设备此账号停用,应该下线;
一般在后台由管理员来操作,或者自己的另一个设备锁定账号;
2.1.4 恢复
如果是管理员操作,则不需要验证;
如果是用户自己申请恢复账号,则需要验证邮箱或者手机号;
2.1.5 设置基础信息
注册后的各种信息都是处于默认的状态,可以更改口令以及各种基本信息,头像等;
备注
1)用户名必须唯一;
2)号码是系统分配不能改;
3)更改邮箱与手机绑定则需要验证新、旧邮箱和手机2次,保证现有账户不是捡到的;
2.1.6 实名认证
目前暂时不支持绝对实名认证;后期可能需要人脸识别,身份证,绑定银行卡等信息进行验证;
2.1.7 登录
客户端应该先检查本地是否有登录过之后的sessionId,使用session和用户号码进行登录验证;
这里仍然有监听和截获的风险;除非添加上秘钥交换的部分逻辑,因为DF 秘钥交换有部分数据不在网络上传递,而其他的方式都无法保证不被截获;
备注:
在telegram中使用了一个秘钥交换之后的部分秘钥来用作sessionID,sessionID和自己的部分私钥组合为秘钥,对方存储着另一半的秘钥,和sessionID组合拼接之后也是秘钥,这样的可以验证用户;
可以防止第三方监听截获;
2.1.8 退出
退出只退出某个账户当前的用户,但是不删除登录的SessionID;主要用于切换用户;
2.2 好友操作
// 好友操作请求消息
message FriendOpReq {
string operation = 1; // 操作类型,例如:申请好友、同意好友、删除好友、屏蔽好友
UserInfo user = 2; // 涉及的用户信息,例如在申请好友时,表示被申请加为好友的用户
map<string, string> params = 4;
}
// 好友操作结果消息
message FriendOpResult {
string operation = 1; // 操作类型
string result = 2; // "ok" "fail"
UserInfo user = 3; // 发起者
UserInfo users = 4; // 涉及的用户信息列表,使用repeated表示多个用户
map<string, string> params = 5;
}
enum OperationType {
FindUser = 8;
AddFriend = 9; // 2种模式
ApproveFriend = 10; // 批准好友
RemoveFriend = 11;
BlockFriend = 12;
UnBlockFriend =13;
SetFriendPermission = 14;
};
2.2.1 查找用户
用户使用mongoDB 存储,可以支持按照用户名,用户号码,邮箱或者手机号进行搜索;
1)用户名是精确查找;手机号,邮箱都是;
2)用户名是模糊查找,用户名不允许一样,但是有可能类似,返回匹配的前若干个;
备注:
搜索好友也需要在user中填写自己的信息,以便在后续返回时候知道数据返回到哪里;
2.2.2 添加好友
好友仍然使用scyllaDB管理,这里的好处就是同一个用户的数据会存储在一起;
添加好友服务端支持2种不同工作模式:
1)微博那种生人社交模式:用户可以关注好友,不需要对方验证;双向关注就认为是好友;此时的模式,单向用户发送消息,需要限制条数;
此种方式比较简单,只需要在服务端在数据中标记双方的关注与粉丝列表,并不需要通知对方;
2)微信熟人社交模式:用户添加好友需要对方验证;用户收到好友验证请求的时候,有3种选择:拒绝、同意、忽略;此种类型的消息需要在scyllaDB中存储相关的记录;
-
请求者提交 FriendOpReq消息,用户列表中写被请求方,服务端转发消息到目的用户;
-
被请求的用户在应答中,将请求者添加到users中,result中写上R/A/I代表三种结果;服务器在数据库中标记状态;转发应答;
2.2.3 移除好友
生人社交模式:直接更改数据库标记,同时记录当前的聊天条目;
熟人社交模式:也是直接更改数据库标记,并不通知用户;
2.2.4 拉黑用户与解除
生人社交模式:仅仅是屏蔽对方的消息,对方所有的消息都不再接收;所以每一条聊天都需要检查权限;
熟人社交模式:并不是等同于删除好友,只是设置屏蔽选项,对方所有的消息都不再接收;如果拉黑对方再删除好友,对方是无法申请好友以及发送消息的;如果仅仅是拉黑
2.2.5 设置好友权限
2.3 群组操作
// 群组成员的信息
message GroupMember {
int64 userId = 1;
string nick = 2;
string icon = 3;
string role = 4; // 角色信息,例如:管理员、普通成员等
int64 groupId = 5;
map<string, string> params = 6; // 其他参数,例如:成员特殊属性等
}
// 群组信息
message GroupInfo {
int64 groupId = 1; // 注册时全局分配的群组ID
repeated string tags = 2; // 群组标签
string groupName = 3; // 群组名称
string groupType = 4; // 群组类型,例如:群聊、广播频道等
map<string, string> params = 5; // 其他参数,例如:是否公开、验证方法、验证口令等
}
// 群组操作类型枚举
enum GroupOperation {
GroupCreate = 0; // 创建群组
GroupDissolve = 1; // 解散群组
GroupSetInfo = 2; // 设置群组本身的各种信息
GroupKickMember = 3; // 踢人
GroupInviteRequest = 4; // 邀请人请求
GroupInviteAnswer = 5; // 邀请后处理结果
GroupJoinRequest = 6; // 加入请求
GroupJoinAnswer = 7; // 加入请求的处理,同意、拒绝、问题
GroupQuit = 8; // 退出群组
GroupAddAdmin = 9; // 增加管理员
GroupDelAdmin = 10; // 删除管理员
GroupTransferOwner = 11; // 转让群主
// 可以根据需要添加其他群组操作
GroupSetMemberInfo = 12; // 设置自己在群中的信息
}
// 群组操作请求消息
message GroupOpReq {
GroupOperation operation = 1; // 群组操作类型: 创建群,解散群,设置信息;踢人,邀请人请求,加入请求,请求结果,增加管理员,转让群主
GroupMember ReqMem = 2; // 申请人,便于转发
GroupInfo group = 3; // 涉及的群组信息
repeated GroupMember members = 4; // 涉及的群组成员信息列表
map<string, string> params = 5;
}
message GroupOpResult{
GroupOperation operation = 1;
GroupMember ReqMem = 2;
string result = 3;
string detail = 4;
GroupInfo group = 5;
repeated GroupMember members = 6; // 涉及的群组成员信息列表
map<string, string> params = 7;
}
2.3.1 创建
任何用户都可以创建群组,此用户天然成为此群的的拥有者(群主),拥有对此群的全部权限,也是根管理员;
群中用户的权限为
O:群主(owner);
A:管理员(Admin);
W:读写权限 (write);
R: 读权限(read);
群主还可以为用户进行分类,比如设置积分等级;
创建时候需要指定群的名字,系统会分配唯一的ID;群创建后添加其他用户;
2.3.2 解散
只有群主拥有对此群的解散功能,解散后此群的所有读写功能失效;客户端只能读之前换缓存的本地消息;
解散时、通知在线用户;
2.3.3 设置基础信息
群管理员可以设置基础信息包括:群名字,群公告,群图标,TAG;
设置用户申请加入是否需要管理员同意;
群主可以设置:群的可见属性,如果隐藏属性,则无法通过搜索找到,只能群管理员通过邀请进入;
普通群可以通过名称、或者设置的TAG搜索;
2.3.4 踢人
管理员有权限移除群用户,直接操作不需要确认;移除后通知该用户被踢;
2.3.5 邀请
隐藏群:管理员可以邀请;因为此类型不允许搜索;
公开群:所有用户都可以邀请;
邀请其实是包含了群的ID号码;
2.3.6 邀请结果
用户可以应答邀请,也可以不应答;
应答的结果就是答应加入群;
如果是聊天群:加入后默认的权限是读写,即可以发消息;
如果是频道群:加入后默认的权限是读,即没有发送消息的权利;
2.3.7 请求加入
对于公开群或者频道,可以通过名字或者TAG来搜索;
对于没有设置认证的群,可以直接加入,如果设置的权限,需要管理员审批后进入;
申请中可以附带留言信息:params detail=“some information”
如果需要审批,服务端从所有的在线管理员中随机选1-2个转发;
2.3.8 请求加入结果
如果有管理员应答,此结果表明允许或者拒绝用户加入群;
应答可以是R或者A(拒绝或者是同意);
可以附带备注信息:params detail=“some information”
如果是同意,服务端将该用户添加到群,如果是拒绝直接转发消息给该用户即可;
同时写入好友操作日志中;
2.3.9 用户主动退群
用户可以选择随时退出群或者频道,该通知会发送到群,但是不发送到频道;
原因是:频道主要用于广播,而群聊也许知道谁在,谁离开了;
2.3.10 群主添加管理员
群主可以添加群管理员,不需要对方确认;
2.3.11 群主移除管理员
群主可以添移除某个群管理员,不需要对方确认;
2.3.12 转让群主
群主可以转移该群给其他人,但是客户端需要提示该操作的风险;
服务端重新设置2个用户的权限,同时群内广播消息;
2.3.13 用户设置自己的信息
群所有用户都可以设置自己在群中的昵称、单独的头像、签名;
2.3.14 搜索群
公开群可以搜索;
隐藏的群不可以搜索;
2.3.15 搜索结果
关键字搜索到的群,会涉及到多页,需要分页处理;
如果提供空白的关键字,可以随机推荐一些群;
2.4 用户、好友、群组操作记录存储
3. 聊天业务相关
3.1 聊天内容消息定义
详细的定义需要参考msg.proto
主要思想是:所有的消息使用一致的模型定义:
// 所有的消息都用
message Msg {
int32 version = 1; // 协议版本号
ComMsgType msgType = 2; // 通用消息类型
oneof message {
MsgHello hello = 3; // Hello消息
MsgHeartBeat heartBeat = 4; // 心跳消息
MsgChat chatData = 5; // 聊天消息
MsgChatReply chatReply = 6; // 聊天回复消息
MsgDownloadReq downloadReq = 7; // 下载请求消息
MsgDownloadReply downloadReply = 8; // 下载回复消息
MsgUploadReq uploadReq = 9; // 上传请求消息
MsgUploadReply uploadReply = 10; // 上传回复消息
UserOpReq userOp = 11; // 用户操作请求消息
UserOpResult userOpRet = 12; // 用户操作结果消息
FriendOpReq friendOp = 13; // 好友操作请求消息
FriendOpResult friendOpRet = 14; // 好友操作结果消息
GroupOpReq groupOp = 15; // 群组操作请求消息
GroupOpResult groupOpRet = 16; // 群组操作结果消息
}
int64 tm = 20; // 时间戳
}
消息类型与对应的消息内容一起对应起来使用;
当socket收到消息后,根据类型直接分发给对应的消息队列上,在消息队列上不同的处理器上直接按照类型处理,不需要对消息类型进行判断:
比如当类型为:MsgTChatMsg时,则chatData应该不为空,这样可以避免在运行时候不停的检查消息类型到底是什么;
// 聊天通信的的消息
message MsgChat {
int64 msgId = 1; // 消息的全网唯一标识,使用雪花算法生成
int64 userId = 2; // 用于存储的clusterKey
int64 fromId = 3; // 发送消息的用户 ID
int64 toId = 4; // 接收消息的用户 ID(对方的用户 ID)
int64 tm = 5; // 消息的时间戳
string devId = 6; // 多设备登录时的设备 ID
string sendId = 7; // 用于确认消息的发送 ID
ChatMsgType msgType = 8; // 消息类型,建议使用枚举
bytes data = 9; // 消息的内容,可以使用 bytes 存储二进制数据或文本数据
MsgPriority priority = 10; // 消息的优先级,建议使用枚举
int64 refMessageId = 11; // 引用的消息 ID,如果有的话
ChatMsgStatus status = 12; // 消息状态,建议使用枚举
int32 sendReply = 13; // 发送消息的回执状态
int32 recvReply = 14; // 接收消息的回执状态
int32 readReply = 15; // 已读状态的回执
EncryptType encType = 16; // 加密类型
string chatType = 17; // p2p, group, system, 如果是群聊,则toId为群聊ID
}
消息定义与说明注释和建议:
- 使用枚举类型
ChatMsgType
、MessagePriority
和MessageStatus
为字段提供更具可读性的值,减少潜在的错误。 ChatMsgType
字段对于区分不同类型的消息是一个很好的补充。- 使用
bytes
类型的data
字段允许存储任意二进制数据,这对于支持多媒体内容或其他二进制数据是很有用的。 refMessageId
字段对于引用先前消息是一个有用的补充。status
字段允许你跟踪消息的整体状态,这对于应用的功能和用户体验是重要的。
3.2 一对一私聊
3.2.1 私聊业务逻辑
-
服务端使用GO来编写,同时使用websocket来做RPC,这样的好处是服务端可以主动推送消息;
-
私聊使用写扩散,每个用户有一个存储所有消息的表,存储按照时间顺序,无论自己发的还是收的消息,以及系统消息都放在这里;所以每个用户发送消息时候需要写2次,分别写入自己的表和对方的表中;写完才算是发送成功;
-
每个用户websocket连接后,直接分配一个协程,协程只做2件事:收消息,从用户的私有队列中发送消息;
-
各个业务处理的逻辑协程只负责业务逻辑,将发送的部分放在用户的协程内;
1)离线数据
用户登录后,发送查询记录包,里面包括当前用户在当前设备上加载的消息的编号;
针对私聊:客户端通过心跳发送一个当前接收的消息的位置(时间戳),服务端按照时间戳查询并加载到会话的缓存,等待发送;(这里之所以按照时间戳查询而不是通过未回执的方式查,是因为存在多终端的问题,这样做可以做到消息同步,而目前微信应该不是这么做的,而Telegram之类的可以同步)
2)消息编号
用户发送数据,在消息设置当前设备上的一个流水号,用于确认发送成功;服务端针对每个会话都维护一个唯一的流水号,入库后应答中包括发送的流水号以及服务端新给的会话中的流水号;
3)入库处理
应该先检查权限,权限设置在用户登录时候加载到redis中;如果比如对方屏蔽了此用户,则需要需要发送错误回执;然后不执行写库;
如果使用了消息队列,入队列就算是发送成功;
如果没有消息队列,则需要写数据库后认为成功;
入库后发送发送成功应答,并将数据写入到对方的消息缓存中等待发送;
单机模式,直接写到对方的管道中就可以了;如果是集群模式,对方可能不在同一台主机就需要使用服务绑定的消息队列转发;如果对方所有设备都离线了,则不转发,等待上线;
如果发放是多设备在线,那么也需要推送到其他的设备;
4) 转发处理
需要注意的是:收方可能是多个设备在线,这时需要推送多个设备;
每个设备都有一个会话的协程;
收方的会话协程从管道中收到消息后,推送给用户;(这里消息可能是自己的,也可能是对方的,系统的,也可能是三类回执)
5)会话协程等待的数据:
用户在某设备发送的消息;
用户发送接收回执;
用户发送的阅读回执;
多设备时候,收方提交一次接收回执与阅读回执即可;
3.2.2 聊天消息的数据存储
在存储方面的考虑:
-
一对一的聊天,都是2个用户,使用写扩散方式每个用户1份数据,这样的的好处是,使用用户ID聚簇,可以提高加载速度。并且减少数据的加载次数,具体在用户的会话区分上,可以在客户端一侧,执行本地的SQLITE存储。
-
对比tinode的策略,它是按照每个会话做一个逻辑,需要管理当前所有的会话,逐个加载或者订阅,而且在测试过程中发现BUG,当如同微信一样删除了某个会话,等于拉了黑名单,无法后续会话了,这个不符合我们的习惯。
-
对于群组聊天,可以使用读扩散的方式,因为写扩散毕竟太占用系统资源了;按照组ID来聚簇;
CREATE TABLE pchat (
pk int, // 分区
uid1 bigint, // 用户自己,P2P时写扩散,每个用户存储一份数据
uid2 bigint, // 对方
id bigint, // 消息全局唯一ID,服务器分配
usid bigint, // 发送方的消息唯一标记
tm timestamp, // 时间戳
tm1 timestamp, // 接收
tm2 timestamp, // 已读
draf text, // 数据
io boolean, // 收,发
del boolean, // 删除标记
t smallint, // 消息类型
PRIMARY KEY (pk, uid1, tm, id)
)
3.3 群聊
3.3.1 群聊的业务逻辑
群聊的基础逻辑是读扩散,因为按照写扩展的话,2000人的群就要写2000次,浪费存储资源,而且用户不一定需要读;
读扩展的问题就是当用户离线的时候,如何不丢包,因为服务器不知道客户端准确的收到了多少条数据;
1)登录
当用户登录时候,服务器需要知道用户隶属于哪些群,从数据库读一次,放到redis中(如果有记录则不需要加载);将群列表发送给用户,让用户确认各个群中所收到的最后一条消息时间戳;
用户针对每个群都需要反馈时间戳,然后查询消息,如果未读消息太多,那么只记载最后100条数据,发送给用户,而且需要告诉用户还有数据没有加载;
这里的问题就是如果每次都查数据库的话,还是耗费资源,但是redis中无法按照时间来查询和过滤,这里的策略是群聊使用有序集合来存放若干条数据,比如1000条,如果条目超过这个容量,后面的条目插入尾部并弹出最早的一条;所以离线用户优先加载最新的数据,而不是全部加载,因为用户可能不关心之前的数据了;
但是,这里服务器需要在前面添加一条“待加载”的提示消息,否则客户端一侧无法知道哪里缺了数据;
2)发送消息
用户发送的群聊消息,服务端先写入redis缓存中,之后写到群聊天的表中,写入成功就回复发送成功的回执;
如果这里写redis出错,那就是重大故障,服务就出问题了,如果写数据库失败也是重大故障;
考虑到集群模式,不同用户在不同的服务器上,连续的流水号不容易高效实现,所以使用雪花算法做唯一消息编码相对合适;
服务端根据群所有用户列表,在线的用户执行转发,不在线的忽略;
其中在集群模式下,
备注:防范一种情况会丢消息,
会话协程:用户登录后,a)内存建立session,b)服务端需要先标记状态,c)然后从redis中提取离线消息发送;
分发协程:A)先写库 B)查用户状态, C)推送;
这里即便是: A–>B (离线) a—>b (在线)都没有问题,因为已经写入缓存了;
避免出现推送了离线的消息,但是因为状态不一致而没有发送新的消息;
3) 群用户在线状态跟踪
不在线的用户是不需要推送群消息的;所以需要跟踪用户的在线状态,尤其是集群环境下,需要根据活跃用户所在的服务器来转发;
服务针对每个群都需要维护一个群成员在线状态表,表需要包括所有群成员目前所在的服务器;
键名 | 作用 | 类 型 | |
---|---|---|---|
gm+群号 | 群所有成员的静态信息 | 哈希 | 与scyllaDB一致 |
gms+node名+群号 | 群所有在线成员所在的服务器 | 哈希 | 用户上线下线时候添加删除, field使用用户号码, value使用上线时间戳, 删除时候比对下线时间戳与上线时间戳,防止抖动 |
gmc+群号 | 群成员分布在每个服务器上个数, 当做分布式计数器 | 哈希 | 用户上线下线时候加减计数 field使用服务器名 value为计数,当计数为0时候不需要转发 |
3.3.2 群聊的数据存储
当用户加入到一个群的时候,用户需要添加到群列表中,需要记录一条数据,用户表示用户进入了一个群;
所以需要在scyllaDB中有个表,用于存储用户与群聊的关联关系,按照用户来分簇,读取时候一次可以读取到参加的所有群;
// 用户加入的群
CREATE TABLE ugroup (
pk int,
uid bigint,
gid bigint,
tm timestamp,
PRIMARY KEY (pk, uid, gid)
);
用户加入群时候,添加记录,退出群时候删除记录;
群聊天记录都保存在一个表中,
// 原文链接:https://blog.csdn.net/robinfoxnan/article/details/135880916
CREATE TABLE gchat (
pk int,
gid bigint,
id bigint,
del boolean,
draf text,
t smallint,
tm timestamp,
uid1 bigint,
uid2 bigint,
ref bigint,
usid bigint,
PRIMARY KEY (pk, gid, id)
);