传统的架构(十万级用户量)还是基于多进程思想,这里以TeamTalk为例,TeamTalk是蘑菇街5年前(2015年)开源的内部企业通讯软件,当时还火爆了一下,很多人纷纷研究,各种分析文章满天飞。它的架构如图所示:
简单介绍一下工作原理:
这个架构,在10万用户级别是完全没问题的,适合企业内部通讯这种场景,但是如果在百万的用户级别下,就有一些问题:
所以,势必对架构进行升级,才能扛得住更大的量。route的问题先放一放,msg和dbproxy这一层,我们可以通过引入Kafka解决。
这也是我认为,现代IM架构都会使用MQ的原因之一,引入Kafka的好处:
上面2点是我觉得从十万跨越到百万最重要的点。
我最近主要在研究瓜子IM和openIM,其中瓜子IM的作者封宇大神分享了一系列文章,里面有详尽的时序图、协议设计、分库分表、TimeLine模型同步等等,看完之后会有一个大概的认识。Open-IM-Server是前微信技术专家创业的开源项目,使用go语言开发,使用的收件箱模型,能很好的结合瓜子IM的设计文档,加深理解。
(图片来源:公众号-普通程序员 作者-封宇)
作者分享的架构,一开始其实并没有Kafka这一层,具体可以参考:
看完之后,你可能和我一样有所疑惑:这里面Kafka到底要怎么用?
这个作者好像是微信团队出来的,所以架构的设计上天然就是基于收件箱+写扩散模式,目前还在完善中,已经实现了基本的单聊和群里功能。
我们以上图为例,你可能我和一样,第一眼以为发消息是这个流程:
ws2mschat:
addr: [ 127.0.0.1:9092 ]
topic: "ws2ms_chat"
ms2pschat:
addr: [ 127.0.0.1:9092 ]
topic: "ms2ps_chat"
consumergroupid:
msgToMongo: mongo
msgToMySql: mysql
msgToPush: push
看完之后,我不禁有2个疑问:
第2个疑问,我已经在文章:Golang中如何正确的使用sarama包操作Kafka?中进行了说明,只要producer和comsumer使用正确,最终数据会一致。为什么这里不要求强一致性?我的理解是,对于在线的用户,其实已经通过push进行了推送,客户端自己会对数据进行对齐(本地存储)。离线的客户端上线拉取的时候再对齐,这中间是有充分的时间让我们服务处理的,当然出现了BUG(消费者挂了)另说。
所以,下面主要来分析一下第一个问题。
细究Open-IM-Server中使用Kafka的流程
我画了一个简化版的图:
经过梳理和翻源码,我更正了几个误区:
chat服务send_msg.go的关键代码如下:
func GetMsgID(sendID string) string {
t := time.Now().Format("2006-01-02 15:04:05")
return t + "-" + sendID + "-" + strconv.Itoa(rand.Int())
}
func (rpc *rpcChat) UserSendMsg(_ context.Context, pb *pbChat.UserSendMsgReq) (*pbChat.UserSendMsgResp, error) {
log.InfoByKv("sendMsg", pb.OperationID, "args", pb.String())
// 这里获取一个消息ID,见上面的实现,通过时间戳+随机的一串数字,还不是定长的,实现上有点缺陷。
serverMsgID := GetMsgID(pb.SendID)
pbData := pbChat.WSToMsgSvrChatMsg{}
pbData.MsgFrom = pb.MsgFrom
pbData.SessionType = pb.SessionType
// 发送时间以服务的接收到的时间为主,因为客户端的时间不可靠
// 这里OpenIMServer没有使用单独的字段来进行排序,而是使用的发送时间,个人感觉有点缺陷
// 部署多台机器,时间可能会出现不一致,使用ntpd校时也不能保证百分百一样
pbData.SendTime = utils.GetCurrentTimestampByNano()
// ..
switch pbData.SessionType {
case constant.SingleChatType:
// 发到kafka,扩散写的方式
// 自己的发件箱,对方的收件箱
err1 := rpc.sendMsgToKafka(&pbData, pbData.RecvID)
err2 := rpc.sendMsgToKafka(&pbData, pbData.SendID)
if err1 != nil || err2 != nil {
return returnMsg(&replay, pb, 201, "kafka send msg err", "", 0)
}
return returnMsg(&replay, pb, 0, "", serverMsgID, pbData.SendTime)
case constant.GroupChatType:
// ...
return returnMsg(&replay, pb, 0, "", serverMsgID, pbData.SendTime)
default:
}
// ...
}
transfer的mongo消费者关键代码:
func (mc *HistoryConsumerHandler) handleChatWs2Mongo(msg []byte, msgKey string) {
log.InfoByKv("chat come mongo!!!", "", "chat", string(msg))
pbData := pbMsg.WSToMsgSvrChatMsg{}
err := proto.Unmarshal(msg, &pbData)
if err != nil {
log.ErrorByKv("msg_transfer Unmarshal chat err", "", "chat", string(msg), "err", err.Error())
return
}
pbSaveData := pbMsg.MsgSvrToPushSvrChatMsg{}
pbSaveData.SendID = pbData.SendID
pbSaveData.SenderNickName = pbData.SenderNickName
pbSaveData.SenderFaceURL = pbData.SenderFaceURL
pbSaveData.ClientMsgID = pbData.ClientMsgID
pbSaveData.SendTime = pbData.SendTime
pbSaveData.Content = pbData.Content
pbSaveData.MsgFrom = pbData.MsgFrom
pbSaveData.ContentType = pbData.ContentType
pbSaveData.SessionType = pbData.SessionType
pbSaveData.MsgID = pbData.MsgID
pbSaveData.OperationID = pbData.OperationID
pbSaveData.RecvID = pbData.RecvID
pbSaveData.PlatformID = pbData.PlatformID
Options := utils.JsonStringToMap(pbData.Options)
//Control whether to store offline messages (mongo)
isHistory := utils.GetSwitchFromOptions(Options, "history")
//Control whether to store history messages (mysql)
isPersist := utils.GetSwitchFromOptions(Options, "persistent")
if pbData.SessionType == constant.SingleChatType {
log.Info("", "", "msg_transfer chat type = SingleChatType", isHistory, isPersist)
if isHistory { // 客户端启用了存储消息
if msgKey == pbSaveData.RecvID {
err := saveUserChat(pbData.RecvID, &pbSaveData)
if err != nil {
log.ErrorByKv("data insert to mongo err", pbSaveData.OperationID, "data", pbSaveData.String(), "err", err.Error())
}
} else if msgKey == pbSaveData.SendID {
err := saveUserChat(pbData.SendID, &pbSaveData)
if err != nil {
log.ErrorByKv("data insert to mongo err", pbSaveData.OperationID, "data", pbSaveData.String(), "err", err.Error())
}
}
}
if msgKey == pbSaveData.RecvID {
pbSaveData.Options = pbData.Options
pbSaveData.OfflineInfo = pbData.OfflineInfo
sendMessageToPush(&pbSaveData)
}
log.InfoByKv("msg_transfer handle topic success...", "", "")
} else if pbData.SessionType == constant.GroupChatType {
log.Info("", "", "msg_transfer chat type = GroupChatType")
if isHistory {
uidAndGroupID := strings.Split(pbData.RecvID, " ")
saveUserChat(uidAndGroupID[0], &pbSaveData)
}
pbSaveData.Options = pbData.Options
pbSaveData.OfflineInfo = pbData.OfflineInfo
sendMessageToPush(&pbSaveData)
log.InfoByKv("msg_transfer handle topic success...", "", "")
} else {
log.Error("", "", "msg_transfer recv chat err, chat.MsgFrom = %d", pbData.SessionType)
}
}
func sendMessageToPush(message *pbMsg.MsgSvrToPushSvrChatMsg) {
log.InfoByKv("msg_transfer send message to push", message.OperationID, "message", message.String())
msg := pbPush.PushMsgReq{}
msg.OperationID = message.OperationID
msg.PlatformID = message.PlatformID
msg.Content = message.Content
msg.ContentType = message.ContentType
msg.SessionType = message.SessionType
msg.RecvID = message.RecvID
msg.SendID = message.SendID
msg.SenderNickName = message.SenderNickName
msg.SenderFaceURL = message.SenderFaceURL
msg.ClientMsgID = message.ClientMsgID
msg.MsgFrom = message.MsgFrom
msg.Options = message.Options
msg.RecvSeq = message.RecvSeq
msg.SendTime = message.SendTime
msg.MsgID = message.MsgID
msg.OfflineInfo = message.OfflineInfo
// 先尝试gRPC,否则直接发到Kafka,让push从Kafka消费然后推送
grpcConn := getcdv3.GetConn(config.Config.Etcd.EtcdSchema, strings.Join(config.Config.Etcd.EtcdAddr, ","), config.Config.RpcRegisterName.OpenImPushName)
if grpcConn == nil {
log.ErrorByKv("rpc dial failed", msg.OperationID, "push data", msg.String())
pid, offset, err := producer.SendMessage(message)
if err != nil {
log.ErrorByKv("kafka send failed", msg.OperationID, "send data", message.String(), "pid", pid, "offset", offset, "err", err.Error())
}
return
}
msgClient := pbPush.NewPushMsgServiceClient(grpcConn)
_, err := msgClient.PushMsg(context.Background(), &msg)
if err != nil {
log.ErrorByKv("rpc send failed", msg.OperationID, "push data", msg.String(), "err", err.Error())
pid, offset, err := producer.SendMessage(message)
if err != nil {
log.ErrorByKv("kafka send failed", msg.OperationID, "send data", message.String(), "pid", pid, "offset", offset, "err", err.Error())
}
} else {
log.InfoByKv("rpc send success", msg.OperationID, "push data", msg.String())
}
}
用时序图梳理一下整个过程:
至此,在IM中如何使用Kafka已经相对清晰了,更多的一些细节读者可以通过代码来进一步了解。
感谢阅读,更多的java课程学习路线,笔记,面试等架构资料,点赞收藏+评论转发+关注我之后私信【资料】即可获取免费资料!