Neo's Blog

不抽象就无法深入思考
不还原就看不到本来面目!

0%

常见系统设计题系列-即时通信系统

设计要点:

长链接接入网关

  • 接入层:如何做负载均衡(基于IP 还是 基于7层的用户唯一标识)
  • 就近接入
  • 接入层如何升级:尽量减少升级(否则断开连接),拆分为负责连接的进程与负责处理业务的进程,两者之间通过共享内存通信

心跳设计

  • 作用:保活
  • 原则:省电 PK 服务器压力

通信协议:采用TCP/HTTP相结合的方式

  • TCP用于低延迟通知类
  • HTTP主要用于拉消息等对延迟不敏感的场景

协议设计

  • 数据安全性
  • 编码复杂度
  • 协议通用型
  • 数据大小

重连策略(客户端与服务端配合)

当一台网关出现问题需要客户端进行重连时,还需要考虑到:不要因为重连问题导致了其他网关服务器也受影响,产生雪崩效应,此时需要考虑以下几点:

  • 打散重连时间:需要进行重连的客户端,在一个时间范围内选择一个随机的时间,这样将这些客户端的重连时间打散,不至于一下子都连接上来。
  • 指数退避:一次重连不上时,客户端还需要再次尝试进行多次重连,然而重连的时间需要像TCP协议那样在阻塞恢复时做指数退避,即第一次重连时间是1秒后,第二次2秒后,第三次4秒后,等等。这个策略也是为了避免由于重连导致的服务雪崩。
  • 服务器保护:上面两条是客户端的重连策略,然而服务器自身也需要进行保护,当服务器判断自己当前的负载到一定程度时,将拒绝客户端的连接请求。

如何保证消息可达(不丢)/唯一(不重复)/保序(不乱序)

  • 不乱序:每条消息都有一条唯一且自增的msg_id
  • 不重复:

消息防丢失

  • 逐条ACK
  • 引入seqno机制:为每一个用户维护一个自增的序列号

存储设计

  • 消息存储库:存储每一个回话下的每一条消息,用于读扩散或者消息漫游;存储周期:长
  • 消息同步库:为每一个用户存储一个使用TIMELINE模型的收件箱,用于读取最新消息(一般是写扩散写入);存储周期:短
  • 万人大群是例外,如果一定要支持的话,可以采用读写扩散混合模式。

如何保证消息的实时性

在通信协议的选择上,我们主要有以下几个选择:

使用 TCP Socket 通信,自己设计协议:58 到家等等

使用 UDP Socket 通信:QQ 等等

使用 HTTP 长轮循:微信网页版等等

不管使用哪种方式,我们都能够做到消息的实时通知。但影响我们消息实时性的可能会在我们处理消息的方式上。例如:假如我们推送的时候使用 MQ 去处理并推送一个万人群的消息,推送一个人需要 2ms,那么推完一万人需要 20s,那么后面的消息就阻塞了 20s。如果我们需要在 10ms 内推完,那么我们推送的并发度应该是:人数:10000 / (推送总时长:10 / 单个人推送时长:2) = 2000

因此,我们在选择具体的实现方案的时候一定要评估好我们系统的吞吐量,系统的每一个环节都要进行评估压测。只有把每一个环节的吞吐量评估好了,才能保证消息推送的实时性。

如何保证消息时序

以下情况下消息可能会乱序:

发送消息如果使用的不是长连接,而是使用 HTTP 的话可能会出现乱序。因为后端一般是集群部署,使用 HTTP 的话请求可能会打到不同的服务器,由于网络延迟或者服务器处理速度的不同,后发的消息可能会先完成,此时就产生了消息乱序。解决方案:

前端依次对消息进行处理,发送完一个消息再发送下一个消息。这种方式会降低用户体验,一般情况下不建议使用。

带上一个前端生成的顺序 ID,让接收方根据该 ID 进行排序。这种方式前端处理会比较麻烦一点,而且聊天的过程中接收方的历史消息列表中可能会在中间插入一条消息,这样会很奇怪,而且用户可能会漏读消息。但这种情况可以通过在用户切换窗口的时候再进行重排来解决,接收方每次收到消息都先往最后面追加。

通常为了优化体验,有的 IM 系统可能会采取异步发送确认机制(例如:QQ)。即消息只要到达服务器,然后服务器发送到 MQ 就算发送成功。如果由于权限等问题发送失败的话后端再推一个通知下去。这种情况下 MQ 就要选择合适的 Sharding 策略了:

按to_user_id进行 Sharding:使用该策略如果需要做多端同步的话发送方多个端进行同步可能会乱序,因为不同队列的处理速度可能会不一样。例如发送方先发送 m1 然后发送 m2,但服务器可能会先处理完 m2 再处理 m1,这里其它端会先收到 m2 然后是 m1,此时其它端的会话列表就乱了。

按conversation_id进行 Sharding:使用该策略同样会导致多端同步会乱序。

按from_user_id进行 Sharding:这种情况下使用该策略是比较好的选择

通常为了优化性能,推送前可能会先往 MQ 推,这种情况下使用to_user_id才是比较好的选择。

用户在线状态如何做

很多 IM 系统都需要展示用户的状态:是否在线,是否忙碌等。主要可以使用 Redis 或者分布式一致性哈希来实现用户在线状态的存储。

Redis 存储用户在线状态

看上面的图可能会有人疑惑,为什么每次心跳都需要更新 Redis?如果我使用的是 TCP 长连接那是不是就不用每次心跳都更新了?确实,正常情况下服务器只需要在新建连接或者断开连接的时候更新一下 Redis 就好了。但由于服务器可能会出现异常,或者服务器跟 Redis 之间的网络会出现问题,此时基于事件的更新就会出现问题,导致用户状态不正确。因此,如果需要用户在线状态准确的话最好通过心跳来更新在线状态。

由于 Redis 是单机存储的,因此,为了提高可靠性跟性能,我们可以使用 Redis Cluster 或者 Codis。

分布式一致性哈希存储用户在线状态

alt text

使用分布式一致性哈希需要注意在对 Status Server Cluster 进行扩容或者缩容的时候要先对用户状态进行迁移,不然在刚操作时会出现用户状态不一致的情况。同时还需要使用虚拟节点避免数据倾斜的问题。

多端同步怎么做

读扩散

前面也提到过,对于读扩散,消息的同步主要是以推模式为主,单个会话的消息 ID 顺序递增,前端收到推的消息如果发现消息 ID 不连续就请求后端重新获取消息。但这样仍然可能丢失会话的最后一条消息,为了加大消息的可靠性,可以在历史会话列表的会话里再带上最后一条消息的 ID,前端在收到新消息的时候会先拉取最新的会话列表,然后判断会话的最后一条消息是否存在,如果不存在,消息就可能丢失了,前端需要再拉一次会话的消息列表;如果会话的最后一条消息 ID 跟消息列表里的最后一条消息 ID 一样,前端就不再处理。这种做法的性能瓶颈会在拉取历史会话列表那里,因为每次新消息都需要拉取后端一次,如果按微信的量级来看,单是消息就可能会有 20 万的 QPS,如果历史会话列表放到 MySQL 等传统 DB 的话肯定抗不住。因此,最好将历史会话列表存到开了 AOF(用 RDB 的话可能会丢数据)的 Redis 集群。这里只能感慨性能跟简单性不能兼得。

写扩散

alt text

对于写扩散来说,多端同步就简单些了。前端只需要记录最后同步的位点,同步的时候带上同步位点,然后服务器就将该位点后面的数据全部返回给前端,前端更新同步位点就可以了

如何处理未读数

在 IM 系统中,未读数的处理非常重要。未读数一般分为会话未读数跟总未读数,如果处理不当,会话未读数跟总未读数可能会不一致,严重降低用户体验。

读扩散

对于读扩散来说,我们可以将会话未读数跟总未读数都存在后端,但后端需要保证两个未读数更新的原子性跟一致性,一般可以通过以下两种方法来实现:

使用 Redis 的 multi 事务功能,事务更新失败可以重试。但要注意如果你使用 Codis 集群的话并不支持事务功能。

使用 Lua 嵌入脚本的方式。使用这种方式需要保证会话未读数跟总未读数都在同一个 Redis 节点(Codis 的话可以使用 Hashtag)。这种方式会导致实现逻辑分散,加大维护成本。

写扩散

对于写扩散来说,服务端通常会弱化会话的概念,即服务端不存储历史会话列表。未读数的计算可由前端来负责,标记已读跟标记未读可以只记录一个事件到信箱里,各个端通过重放该事件的形式来处理会话未读数。使用这种方式可能会造成各个端的未读数不一致,至少微信就会有这个问题。

如果写扩散也通过历史会话列表来存储未读数的话那用户时间线服务跟会话服务紧耦合,这个时候需要保证原子性跟一致性的话那就只能使用分布式事务了,会大大降低系统的性能。

如何存储历史消息

读扩散

对于读扩散,只需要按会话 ID 进行 Sharding 存储一份就可以了。

写扩散

对于写扩散,需要存储两份:一份是以用户为 Timeline 的消息列表,一份是以会话为 Timeline 的消息列表。以用户为 Timeline 的消息列表可以用用户 ID 来做 Sharding,以会话为 Timeline 的消息列表可以用会话 ID 来做 Sharding。

接入层怎么做

实现接入层的负载均衡主要有以下几个方法:

硬件负载均衡:例如 F5、A10 等等。硬件负载均衡性能强大,稳定性高,但价格非常贵,不是土豪公司不建议使用。

使用 DNS 实现负载均衡:使用 DNS 实现负载均衡比较简单,但使用 DNS 实现负载均衡如果需要切换或者扩容那生效会很慢,而且使用 DNS 实现负载均衡支持的 IP 个数有限制、支持的负载均衡策略也比较简单。

DNS + 4 层负载均衡 + 7 层负载均衡架构:例如 DNS + DPVS + Nginx 或者 DNS + LVS + Nginx。有人可能会疑惑为什么要加入 4 层负载均衡呢?这是因为 7 层负载均衡很耗 CPU,并且经常需要扩容或者缩容,对于大型网站来说可能需要很多 7 层负载均衡服务器,但只需要少量的 4 层负载均衡服务器即可。因此,该架构对于 HTTP 等短连接大型应用很有用。当然,如果流量不大的话只使用 DNS + 7 层负载均衡即可。但对于长连接来说,加入 7 层负载均衡 Nginx 就不大好了。因为 Nginx 经常需要改配置并且 reload 配置,reload 的时候 TCP 连接会断开,造成大量掉线。

DNS + 4 层负载均衡:4 层负载均衡一般比较稳定,很少改动,比较适合于长连接。

对于长连接的接入层,如果我们需要更加灵活的负载均衡策略或者需要做灰度的话,那我们可以引入一个调度服务,如下图所示:

alt text

Access Schedule Service 可以实现根据各种策略来分配 Access Service,例如:

根据灰度策略来分配

根据就近原则来分配

根据最少连接数来分配

https://xie.infoq.cn/article/19e95a78e2f5389588debfb1c

数据冷热分离

对于 IM 来说,历史消息的存储有很强的时间序列特性,时间越久,消息被访问的概率也越低,价值也越低。

alt text

如果我们需要存储几年甚至是永久的历史消息的话(电商 IM 中比较常见),那么做历史消息的冷热分离就非常有必要了。数据的冷热分离一般是 HWC(Hot-Warm-Cold)架构。对于刚发送的消息可以放到 Hot 存储系统(可以用 Redis)跟 Warm 存储系统,然后由 Store Scheduler 根据一定的规则定时将冷数据迁移到 Cold 存储系统。获取消息的时候需要依次访问 Hot、Warm 跟 Cold 存储系统,由 Store Service 整合数据返回给 IM Service。

你的支持是我坚持的最大动力!