传统架构

传统的架构(十万级用户量)还是基于多进程思想,这里以TeamTalk为例,TeamTalk是蘑菇街5年前(2015年)开源的内部企业通讯软件,当时还火爆了一下,很多人纷纷研究,各种分析文章满天飞。它的架构如图所示:

im 系统架构 im软件架构_mysql

简单介绍一下工作原理:

  • 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万用户级别是完全没问题的,适合企业内部通讯这种场景,但是如果在百万的用户级别下,就有一些问题:

  1. 单点故障。route,所有msg都需要连接到一台route上,进行路由分发。那么这个服务挂了,就完蛋了。当然,也可以在运维层面通过Haproxy和KeepAlive等解决。
  2. msg直连dbproxy,如果某一段时间请求很密集,势必造成dbproxy压力陡增。虽然dbproxy使用了线程池和局部任务队列排队处理,但是因为是FCFS(先来先处理),请求积压了,会造成后面的请求失败、超时等问题。
  3. 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

im 系统架构 im软件架构_java_02

(图片来源:公众号-普通程序员 作者-封宇)

作者分享的架构,一开始其实并没有Kafka这一层,具体可以参考:

  • 一个海量在线用户即时通讯系统(IM)的完整设计Plus

看完之后,你可能和我一样有所疑惑:这里面Kafka到底要怎么用?

Opem-IM-Server

im 系统架构 im软件架构_java_03

这个作者好像是微信团队出来的,所以架构的设计上天然就是基于收件箱+写扩散模式,目前还在完善中,已经实现了基本的单聊和群里功能。

我们以上图为例,你可能我和一样,第一眼以为发消息是这个流程:

  1. gate网关生产2个消息到kafka(上图步骤2和3),然后回复发送者发送成功(步骤4)。
  2. 通过不同的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
  1. 其中transfer服务通过2个不同的消费组,同时把消息持久化到mysql和mongodb中
  2. 因为pusher和transfer是不同的消费组,所以pusher并发的把这个消息通过rpc调用gate,再转发给对方(扩散写,from发件箱和to收件箱各写一份)。如果对方处于离线状态,则通过第三方推送,否则,在线的情况下直接走WebSocket长连接。

看完之后,我不禁有2个疑问:

  • 消息序号(乱序)在哪个环节生成的,我怎么没看到?不然客户端如何做消息排序?
  • 消息都没有入mysql就判定成功,给客户端返回了是不是有问题?如果Kafka丢消息了,可能会出现数据不一致?

第2个疑问,我已经在文章:Golang中如何正确的使用sarama包操作Kafka?中进行了说明,只要producer和comsumer使用正确,最终数据会一致。为什么这里不要求强一致性?我的理解是,对于在线的用户,其实已经通过push进行了推送,客户端自己会对数据进行对齐(本地存储)。离线的客户端上线拉取的时候再对齐,这中间是有充分的时间让我们服务处理的,当然出现了BUG(消费者挂了)另说。

所以,下面主要来分析一下第一个问题。

细究Open-IM-Server中使用Kafka的流程

我画了一个简化版的图:

im 系统架构 im软件架构_java_04

经过梳理和翻源码,我更正了几个误区:

  1. 并不是msg-gateway把消息投递到Kafka,而是通过gRPC调用Chat服务,Chat中会生成2个消息发到Kafka(发件箱和收件箱)
  2. chat本地只生成MsgID(字符串,去重)和发送时间,然后发给给客户端,客户端拿服务端生成的msgID去重和发送时间进行排序。
  3. 消息序号的生成是在transfer的消费者中。一开始我以为没有seq,后面才发现是在写mongodb之前分配了一个seq。这是不是有点问题?应该在chat中生成seq啊,不然客户端pull的时候拿到seq,发送的时候拿到sendTime以那个字段为主呢,莫不是自己构造一个seq?
  4. 在Kafka中,只要消费组名不一样,就可以多次消费,所以上面有3个消费组,分别进行mysql的持久化,离线消息存储mongodb以及推送任务。
  5. 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())
   }
}

用时序图梳理一下整个过程:

im 系统架构 im软件架构_java_05

  1. 客户端通过webSocket发送消息到msg_gateway
  2. msg_gateway通过gRPC调用chat的 UserSendMsg() 发送消息
  3. chat服务主要是本地生成唯一消息ID(去重)和发送时间
  4. 然后投递到Kafka,等待所有Kafka的Slave都收到消息后判断发送成功
  5. gRPC返回
  6. 给客户端回复ACK,携带错误码和服务端生成的MsgID等
  7. transfer中的消费组mysql消费到2条消息(发送者的发件箱、接收者的收件箱)
  8. 持久化到mysql中全量存储,主要是应对后台分析、审计等需求,客户端是从mongodb中拉取的(拉取后删除),这里和微信逻辑类似。微信号称不在服务器存储数据,所以你用微信登录PC端时,你会发现刚刚手机上的消息在PC上怎么看不到?要么是PC端没有pull的过程,要么是离线消息只针对APP端,PC端拉不到。
  9. 同理,transfer中消费组mongodb消费到2条消息
  10. 调用redis的incr,递增用户的消息序号,key格式为:"REDIS_USER_INCR_SEQ: " + UserID,所以是用户范围内递增,因为本身用户只有一个收件箱,没毛病。
  11. 插入mongodb中的chat collection
  12. 优先通过gRPC调用pusher进行推送,否则走Kafka,通过Pusher消费的方式推送
  13. pusher也同样通过gRPC调用msg_gateway的MsgToUser推送消息
  14. 通过websocket推送
  15. 用户b上线的时候,通过pull从mongodb中拉取离线消息(成功后会从mongodb中删除)
  16. ...

尾语

至此,在IM中如何使用Kafka已经相对清晰了,更多的一些细节读者可以通过代码来进一步了解。