BirdTalk即时通信协议设计

项目地址: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 握手动作

  1. 如果客户端配置了多个服务器,需要随机选一个服务器执行连接;

  2. 连接完成后,客户端应该发送MsgHello包,包中需要描述自己设备的ID以及支持的协议版本号,客户端的类型以及版本号;如果需要协商加密,则在消息中指明是否需要协议上交换秘钥;目前版本不考虑实现,直接使用HTTPS的机制;

  3. 服务端也需要发送MsgHello包,包括是否需要重定向到负载均衡的服务器上,服务器协议版本号;

  4. 客户端如果收到重定向消息,则需要重新执行连接动作;如果需要协商秘钥,这里开始协商;如果收到普通的应答,则可以开始其他操作;

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
}

消息定义与说明注释和建议:

  • 使用枚举类型 ChatMsgTypeMessagePriorityMessageStatus 为字段提供更具可读性的值,减少潜在的错误。
  • 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)
      );
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值