传统架构
传统的架构(十万级用户量)还是基于多进程思想,这里以TeamTalk为例,TeamTalk是蘑菇街5年前(2015年)开源的内部企业通讯软件,当时还火爆了一下,很多人纷纷研究,各种分析文章满天飞。它的架构如图所示:
简单介绍一下工作原理:
- login:客户端先通过http发到login(这里应该叫rebanlancer,负载均衡),获取一个低负载(登录用户数量,即tcp连接数)的msg IP地址
- msg:然后通过tcp连接msg,进行登录认证、查询会话、收发消息等。msg在整个架构中就是一个网关的角色,主要负责管理客户端的tcp连接和与客户端通信,业务实现和消息存储都在dbproxy中。因为msg是无状态设计,所以是可以水平复制的。技术实现上主要使用epoll I/O复用,是单进程方式。这样一台支持3-5万连接没啥问题,部署2-3台就可以实现10万的用户在线了。
- route:msg之间通过一个路由服务进行通信,以解决在不同的msg上用户消息传递的问题。
- dbproxy:业务实现和消息存储,也是无状态设计,所以可以部署2个,一个用来处理登录业务,一个做负责消息业务。
- 还有其他一些模块,这里就不展开讲了,比如httpmsg提供http接口给外部进行调用,推送服务,文件服务等等。
这个架构,在10万用户级别是完全没问题的,适合企业内部通讯这种场景,但是如果在百万的用户级别下,就有一些问题:
- 单点故障。route,所有msg都需要连接到一台route上,进行路由分发。那么这个服务挂了,就完蛋了。当然,也可以在运维层面通过Haproxy和KeepAlive等解决。
- msg直连dbproxy,如果某一段时间请求很密集,势必造成dbproxy压力陡增。虽然dbproxy使用了线程池和局部任务队列排队处理,但是因为是FCFS(先来先处理),请求积压了,会造成后面的请求失败、超时等问题。
- dbproxy是1个大模块,改动代价很高。如果增加一个功能没测试好,发布上线后崩溃或者有BUG整个服务将无法使用。另外一点就是,各种业务的频率也是不一样的,比如登录因为用户不停打开APP,所以最频繁,应该拆分开。使用不同的mysql,降低压力。
所以,势必对架构进行升级,才能扛得住更大的量。route的问题先放一放,msg和dbproxy这一层,我们可以通过引入Kafka解决。
这也是我认为,现代IM架构都会使用MQ的原因之一,引入Kafka的好处:
- 流量削峰:Kafka QPS是百万级的,mysql QPS 不太好计算,但是差不多几千到几万,肯定比Kafka慢很多,所以消息在存储进mysql之前,让Kafka这个大肚子挡一下,以避免一下子把Mysql打死。
- 解耦合:服务的可用性毋庸置疑的重要,通过引入Kafka,我们把系统间的强依赖变成了基于数据的弱依赖,原来修改一个模块可能其他模块也会受影响。现在,由于Kafka不同消费者之间可以同时消费消息,所以我完全可以把大模块拆成一个个小的模块,处理自己感兴趣的消息。
上面2点是我觉得从十万跨越到百万最重要的点。
现代架构
我最近主要在研究瓜子IM和openIM,其中瓜子IM的作者封宇大神分享了一系列文章,里面有详尽的时序图、协议设计、分库分表、TimeLine模型同步等等,看完之后会有一个大概的认识。Open-IM-Server是前微信技术专家创业的开源项目,使用go语言开发,使用的收件箱模型,能很好的结合瓜子IM的设计文档,加深理解。
瓜子IM
(图片来源:公众号-普通程序员 作者-封宇)
作者分享的架构,一开始其实并没有Kafka这一层,具体可以参考:
- 一个海量在线用户即时通讯系统(IM)的完整设计Plus
看完之后,你可能和我一样有所疑惑:这里面Kafka到底要怎么用?
Opem-IM-Server
这个作者好像是微信团队出来的,所以架构的设计上天然就是基于收件箱+写扩散模式,目前还在完善中,已经实现了基本的单聊和群里功能。
我们以上图为例,你可能我和一样,第一眼以为发消息是这个流程:
- gate网关生产2个消息到kafka(上图步骤2和3),然后回复发送者发送成功(步骤4)。
- 通过不同的consume name,实现多个服务同时消费这个消息。在config.yaml中可以看到针对Kafka的配置:2个topic,3个消费组
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
- 其中transfer服务通过2个不同的消费组,同时把消息持久化到mysql和mongodb中
- 因为pusher和transfer是不同的消费组,所以pusher并发的把这个消息通过rpc调用gate,再转发给对方(扩散写,from发件箱和to收件箱各写一份)。如果对方处于离线状态,则通过第三方推送,否则,在线的情况下直接走WebSocket长连接。
看完之后,我不禁有2个疑问:
- 消息序号(乱序)在哪个环节生成的,我怎么没看到?不然客户端如何做消息排序?
- 消息都没有入mysql就判定成功,给客户端返回了是不是有问题?如果Kafka丢消息了,可能会出现数据不一致?
第2个疑问,我已经在文章:Golang中如何正确的使用sarama包操作Kafka?中进行了说明,只要producer和comsumer使用正确,最终数据会一致。为什么这里不要求强一致性?我的理解是,对于在线的用户,其实已经通过push进行了推送,客户端自己会对数据进行对齐(本地存储)。离线的客户端上线拉取的时候再对齐,这中间是有充分的时间让我们服务处理的,当然出现了BUG(消费者挂了)另说。
所以,下面主要来分析一下第一个问题。
细究Open-IM-Server中使用Kafka的流程
我画了一个简化版的图:
经过梳理和翻源码,我更正了几个误区:
- 并不是msg-gateway把消息投递到Kafka,而是通过gRPC调用Chat服务,Chat中会生成2个消息发到Kafka(发件箱和收件箱)
- chat本地只生成MsgID(字符串,去重)和发送时间,然后发给给客户端,客户端拿服务端生成的msgID去重和发送时间进行排序。
- 消息序号的生成是在transfer的消费者中。一开始我以为没有seq,后面才发现是在写mongodb之前分配了一个seq。这是不是有点问题?应该在chat中生成seq啊,不然客户端pull的时候拿到seq,发送的时候拿到sendTime以那个字段为主呢,莫不是自己构造一个seq?
- 在Kafka中,只要消费组名不一样,就可以多次消费,所以上面有3个消费组,分别进行mysql的持久化,离线消息存储mongodb以及推送任务。
- transfer的mongo消费者,写入mongodb后(如果客户端设置了 isHistory 字段),会先尝试gRPC直接调用push给客户端推消息,否则通过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())
}
}
用时序图梳理一下整个过程:
- 客户端通过webSocket发送消息到msg_gateway
- msg_gateway通过gRPC调用chat的 UserSendMsg() 发送消息
- chat服务主要是本地生成唯一消息ID(去重)和发送时间
- 然后投递到Kafka,等待所有Kafka的Slave都收到消息后判断发送成功
- gRPC返回
- 给客户端回复ACK,携带错误码和服务端生成的MsgID等
- transfer中的消费组mysql消费到2条消息(发送者的发件箱、接收者的收件箱)
- 持久化到mysql中全量存储,主要是应对后台分析、审计等需求,客户端是从mongodb中拉取的(拉取后删除),这里和微信逻辑类似。微信号称不在服务器存储数据,所以你用微信登录PC端时,你会发现刚刚手机上的消息在PC上怎么看不到?要么是PC端没有pull的过程,要么是离线消息只针对APP端,PC端拉不到。
- 同理,transfer中消费组mongodb消费到2条消息
- 调用redis的incr,递增用户的消息序号,key格式为:"REDIS_USER_INCR_SEQ: " + UserID,所以是用户范围内递增,因为本身用户只有一个收件箱,没毛病。
- 插入mongodb中的chat collection
- 优先通过gRPC调用pusher进行推送,否则走Kafka,通过Pusher消费的方式推送
- pusher也同样通过gRPC调用msg_gateway的MsgToUser推送消息
- 通过websocket推送
- 用户b上线的时候,通过pull从mongodb中拉取离线消息(成功后会从mongodb中删除)
- ...
尾语
至此,在IM中如何使用Kafka已经相对清晰了,更多的一些细节读者可以通过代码来进一步了解。