即时消息技术学习

IM应用场景

  • QQ,微信等聊天类场景: 即时通讯
  • 豆瓣, 知乎等社区类场景: 点对点聊天
  • yy,抖音等直播类场景: 互动,实时弹幕
  • 小米, 京东智能家居类IOT场景:实时监控,远程控制
  • 游戏里场景: 多人互动
  • 交通类场景: 位置共享
  • 教学类场景: 在线白板

基础篇

一个完整的IM系统

以聊天系统为例

使用者眼中的聊天系统

使用者眼中的聊天系统

  • 聊天的参与需要用户,所以需要有一个用户账号,用来给用户提供唯一标识,以及头像、昵称等可供设置的选项。
  • 账号和账号之间通过某些方式(比如加好友、互粉等)构成账号间的关系链。
  • 你的好友列表或者聊天对象的列表,我们称为联系人的列表,其中你可以选择一个联系人进行聊天互动等操作。
  • 在聊天互动这个环节产生了消息。
  • 同时你和对方之间的聊天消息记录就组成了一个聊天会话,在会话里能看到你们之间所有的互动消息。

开发者眼中的聊天系统

开发者眼中的聊天系统

  • 客户端: 一般是用户用于收发消息的终端设备,内置的客户端程序和服务端进行网络通信,用来承载用户的互动请求和消息接收功能
  • 接入服务: 服务端的门户,为客户端提供消息收发的出入口
    • 主要有四块功能:连接保持、协议解析、Session 维护和消息推送。
  • 业务服务: 真正的消息业务逻辑处理层
    • 业务: 消息的存储、未读数变更、更新最近联系人等
  • 存储服务: 账号信息、关系链,以及消息本身,都需要进行持久化存储。
  • 外部接口服务: 让用户在 App 未打开时,或者在后台运行时,也能接收到新消息
    • 将消息给到第三方外部接口服务,来通过手机操作系统自身的公共连接服务来进行操作系统级的“消息推送”

接入服务 和 业务服务 为什么独立拆分?

1
2
3
4
5
6
接入服务作为消息收发的出入口,必须是一个高可用的服务,保持足够的稳定性是一个必要条件。

而业务处理服务由于随着产品需求迭代,变更非常频繁,随时有新业务需要上线重启

如果两个合一起, 会导致连接层不稳定
将“只负责网络通道维持,不参与业务逻辑,不需要频繁变更的接入层”抽离出来,不管业务逻辑如何调整变化,都不需要接入层进行变更,这样能保证连接层的稳定性,从而整体上提升消息收发的用户体验。
1
2
3
4
接入服务和业务处理服务进行拆分有助于 提升业务开发效率,降低业务开发门槛。

接入服务负责处理一切网络通信相关的部分,比如网络的稳定性、通信协议的编解码等
负责业务开发的同事就可以更加专注于业务逻辑的处理

IM系统的特性

  • 实时性: 消息实时触达
  • 可靠性: 消息不丢,不重
  • 一致性: 同一条消息,在多人、多终端需要保证展现顺序的一致性
  • 安全性: 数据传输安全,数据存储安全,消息内容安全
  • 其他: 省电,省流量等

为一个已有APP加上实时通信功能

以点对点为例

  • 消息存储: 消息内容,消息索引
1
2
3
4
5
消息参与者有两个: 发送方, 接收方
收发双方的历史消息独立, 即发送方删除消息,接收方仍有这条消息

消息内容表: 存储消息维度的一些基本信息,比如消息 ID、消息内容、消息类型、消息产生时间等
消息索引表: 收发双方的两个索引通过同一个消息 ID 和这个内容表关联

张三 给 李四 发一条消息
img.png

  • 联系人存储

联系人列表只更新存储收发双方的最新一条消息,不存储两人所有的历史消息

1
2
消息索引表的使用场景一般用于查询收发双方的历史聊天记录,是聊天会话维度;
而联系人表的使用场景用于查询某一个人最近的所有联系人,是用户全局维度。
  • 消息未读数
1
2
3
4
1.用户维度的总未读
2.会话维度的会话未读

需要支持“消息的多终端漫游”的应用需要在 IM 服务端进行未读存储,不需要支持“消息的多终端漫游”可以选择本地存储即可

轮询,长连接

  • 轮询
1
2
短轮询: 高频http请求。  费电费流量, 服务端资源压力:服务器QPS抗压,存储资源
长轮询: 避免高频无用功的问题。 只降低了入口QPS的请求, 对后端存储压力没有减少
  • 长连接
1
2
3
websocket: 双向通信, 数据交互网络开销低,web原生支持

还有各种TCP衍生的: XMPP 协议、MQTT 协议以及各种私有协议

保证消息可靠投递: ACK机制

可靠: 消息不丢,不重

发送消息分为两个部分
发送消息的ACK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
一. 前半部分 
1. 用户A 发送消息到 IM服务器
2. 服务器存储消息
3. 服务器返回给用户A确认
步骤1,2,3 都可能失败。

通过 超时重传 进行不丢处理, 但如果是步骤3失败,服务器已经存在消息会出现消息重复的问题。
通过 唯一ID 服务端进行去重,进行不重处理

二. IM服务器将 存储的消息 推送给 用户B
问题1: 可能服务器掉电没有将消息推送给客户端B
问题2: 可能服务器已经将消息推送给了客户端B,但客户端B处理出错。 即网络层面消息投递成功,但用户B看不到消息

一般参考TCP的ACK机制, 实现业务层的ACK协议

业务层ACK协议

1
2
3
4
5
6
7
8
9
10
11
1. IM 服务器在推送消息时,携带一个标识 SID(安全标识符,类似 TCP 的 sequenceId)
2. 推送出消息后会将当前消息添加到 “待 ACK 消息列表”
3. 客户端 B 成功接收完消息后,会给 IM 服务器回一个业务层的 ACK 包,包中携带有本条接收消息的 SID
4. IM 服务器接收后,会从“待 ACK 消息列表”记录中删除此条消息,本次推送才算真正结束

如果ACK过程失败
IM 服务器的“等待 ACK 队列”一般都会维护一个超时计时器,一定时间内如果没有收到用户 B 回的 ACK 包,会从“等待 ACK 队列”中重新取出那条消息进行重推

重传后导致的重复问题: 客户端B 根据唯一ID进行去重

极端情况: 服务端宕机的同时,客户端没有收到消息。 IM服务器不能进行重传机制。

补救措施: 消息完整性检查

1
2
3
4
5
用户在重新上线时,让服务端有能力进行完整性检查,发现用户 用户 “有消息丢失” 的情况,就可以重新同步或者修复丢失的数据

常见方案
时间戳比对: 客户端发送本地最新时间戳, 服务端对比发送时间戳之后的消息
时间戳可能存在多机器时钟不同步的问题, 所以在实际的实现上,也可以使用全局的自增序列作为版本号来代替

保证消息时序: 消息序号生成器

消息时序一致性: 消息顺序不对会导致语义逻辑出问题

关键问题: 找到一个时序基准,使得我们的消息具备“时序可比较性”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
时序基准: 
全局递增的序号生成器
常见的比如 Redis 的原子自增命令 incr,DB 自带的自增 id,或者类似 Twitter 的 snowflake 算法、“时间相关”的分布式序号生成服务等

时序基准的可用性问题:
面向高并发和需要保证高可用的场景,考虑这个“全局序号生成器”的可用性问题。
1. 类似 Redis 的原子自增和 DB 的自增 id,都要求在主库上来执行“取号”操作,而主库基本都是单点部署,在可用性上的保障会相对较差
2. 类似 snowflake 算法的时间相关的分布式“序号生成器”,虽然在发号性能上一般问题不大,但在时间精度 或者 时钟一致上有问题

从业务层面考虑,不需要全局递增,如群聊保证群内序号递增就好。 所以能通过哈希规则把压力分散到多个主库实例上,大量降低多群共用一个“ID 生成器”的并发压力。
对于大部分即时消息业务来说,产品层面可以接受消息时序上存在一定的细微误差, 如同一秒内消息不按严格时序,用户无感知。微博的消息就是秒间有序

时序基准的误差减少:
IM服务器集群部署, 内部逻辑多线程处理。 并不能保证 先到的消息先推送出去

大部分场景业务能接受“小误差的消息乱序”, 这种可以在接收端进行 本地消息整流
但某些特定场景需要IM 服务能保证绝对的时序, 这种只能在服务端进行 消息整流。 例子: 用户A给用户B 发送分手消息然后取关。 如果顺序倒了,会导致取关后消息发送失败

服务端包内整流:
在发送方对多个请求进行业务层合并,多条消息合并成一条;
离线推送整流: 用户上线后 生产者离线消息打包生成packageId,消费者根据每条消息的 packageID 和 seqID 进行整流,最终执行模块只有在一定超时时间内完整有序地收到所有消息才执行最终推送操作

消息接收端整流
根据序号插入会话

安全性: HttpDNS和TLS

  • 传输安全性
1
2
3
4
5
6
7
8
9
开放网络,可能存在问题: DNS 劫持会导致发往 IM 服务的请求被拦截发到其他服务器,导致内容泄露或失效;或者明文传输的消息内容被中间设备劫取后篡改内容,再发往 IM 服务器引起业务错误等问题。

主要关注两个问题: “访问入口安全” 和 “传输链路安全”

1. 保证访问入口安全:HttpDNS


2. 保证传输链路安全:TLS 传输层加密协议

  • 存储安全性
1
2
3
4
5
6
服务端存储, 内部人员非法查询,数据库'拖库' 问题

账号密码存储安全:“单向散列”算法
消息内容存储安全:端到端加密
1. 消息内容采用“端到端加密”(E2EE), 中间任何链路环节都不对消息进行解密。
2. 消息内容不在服务端存储。
  • 消息内容安全性
1
2
3
4
5
6
依托于第三方的内容识别服务来进行“风险内容”的防范

1. 建立敏感词库,针对文字内容进行安全识别。
2. 依托图片识别技术来对色情图片和视频、广告图片、涉政图片等进行识别处置。
3. 使用“语音转文字”和 OCR(图片文本识别)来辅助对图片和语音的进一步挖掘识别。
4. 通过爬虫技术来对链接内容进行进一步分析,识别“风险外链”。

分布式锁和原子性: 未读消息提醒的正确性

  • 会话未读
  • 总未读
1
2
3
4
5
从概念上来说: 总未读数 就是所有会话未读数 的 总和

但一般实现上: 总未读数 和 会话未读数 进行单独维护

总未读数量高频使用, 总未读数不单单包含即时消息的未读,还有其他业务通知的未读

单独维护 总未读数 和 会话未读数带来的 未读数一致性问题

1
2
3
4
5
6
维护的总未读数和会话未读数的总和要保持一致

保证 未读更新的原子性
分布式锁
支持事务的资源
原子化嵌入脚本

智能心跳机制: 网络的不确定性

需要维护好长连接

1
2
3
4
5
6
7
8
长连接中间链路断开, 两段无感知
需要 '快速','不间断' 感知到 连接可用性的机制: 心跳机制

IM服务端: 感知连接的变化,清理无用连接

服务端维护一些“用户在线状态”和“所有在线设备”这些信息,便于业务使用。 保持没有无效信息

客户端 断线重连,连接保活

心跳监测 实现方式

  • TCP Keepalive
1
2
3
4
5
6
7
8
9
10
TCP 的 Keepalive 作为操作系统的 TCP/IP 协议栈实现的一部分,对于本机的 TCP 连接,会在连接空闲期按一定的频次,自动发送不携带数据的探测报文,来探测对方是否存活。
操作系统默认是关闭这个特性的,需要由应用层来开启。
默认的三个配置项:心跳周期是 2 小时,失败后再重试 9 次,超时时间 75s 。 可调整

优点:
不需要其他开发工作量,上层应用只需要处理探测后的连接异常情况即可
心跳包不携带数据,带宽资源的浪费也是最少的。
缺陷:
比如心跳间隔灵活性较差,一台服务器某一时间只能调整为固定间隔的心跳
另外 TCP Keepalive 虽然能够用于连接层存活的探测,但并不代表真正的应用层处于可用状态。
  • 应用层心跳
1
客户端每隔一定时间间隔,向 IM 服务端发送一个业务层的数据包告知自身存活
  • 智能心跳
1
2
3
4
5
6
国内移动网络场景下,各个地方运营商在不同的网络类型下 NAT 超时的时间差异性很大
用固定频率的应用层心跳在实现上虽然相对较为简单,但为了避免 NAT 超时,只能将心跳间隔设置为小于所有网络环境下 NAT 超时的最短时间

所谓智能心跳,就是让心跳间隔能够根据网络环境来自动调整,通过不断自动调整心跳间隔的方式,逐步逼近 NAT 超时临界点,在保证 NAT 不超时的情况下尽量节约设备资源

需要不断尝试, 会从一定程度上降低“超时确认阶段”连接的可用性

场景篇

分布式一致性, 多端漫游

用户在任意一个设备登录后,都能获取到历史的聊天记录。

1
2
3
4
5
收发的消息在多个终端漫游,两个前置条件:
设备维度的在线状态
多个终端同时登录并在线的用户,可以让 IM 服务端在收到消息后推给接收方的多台设备,也推给发送方的其他登录设备。
离线消息存储
当用户的离线设备上线时,就能够从服务端的存储中获取到离线期间收发的消息

自动智能扩缩容: 直播互动场景中的峰值流量的应对

直播互动的流量峰值具有“短时间快速聚集”的突发性,流量紧随着主播的开播和结束而剧烈波动

1
2
3
4
5
6
7
8
消息下推的并发峰值, 理论:  
点对点: 如果两个人每 10 秒说一句话,实际上每秒的消息下推数只有 0.1
500人群聊: 群里每个人也是每 10 秒说一句话,实际每秒的消息下推数是 500 / 10 * 500 = 25000;
10万人在线直播互动: 如果直播间里每个人也每 10 秒说一句话,实际每秒可产生的消息下推数就是 100000 / 10 * 100000 = 10 亿

实际上,10 万人的直播间一般不会有这么高的发言和互动热度,
即使能达到,也会在服务端进行限流和选择性丢弃。一个是考虑服务端的承受能力基本不可能达到这个量级,
另一方面,即使消息能全部推下去,客户端也处理不了每秒一万条消息的接收,对客户端来说,一般每秒接收几十条消息就已经是极限了

直播互动挑战: 高并发压力

  • 在线状态本地化
1
2
3
4
5
6
7
8
9
10
压力: 消息下推环节中消息从一条扇出成十万条。 是消息扇出后的推送

普通聊天场景的扇出逻辑: 查询聊天接收方在哪台接入服务器,然后把消息投递过去,最后由接入服务器通过长连接进行投递

问题:
普通聊天场景,为了进行精准投递避免资源浪费,一般会维护一个中央的“在线状态",逻辑层在这里查询,然后投递到对应网关机
但在直播互动,10万人次的房间,查询量级大

优化:
每个网关机维护本机的连接用户状态, 每条消息全量发送给所有网关机,由网关机自行判断推送

直播互动场景的消息投递

  • 微服务的拆分
1
2
3
4
5
6
7
8
9
10
11
12
下推消息还受制于网关机的带宽、PPS、CPU 等方面的限制,会容易出现单机的瓶颈,
因此当有大型直播活动时,还需对这些容易出现瓶颈的服务进行水平扩容。


拆分: 核心服务和非核心服务, 对核心服务进行扩容
对于核心服务,我们需要隔离出“容易出现瓶颈点的”和“基本不会有瓶颈的”业务。

比如:
核心服务: 发弹幕、打赏、送礼、点赞、消息下推等
非核心服务: 直播回放和第三方系统的同步等

然后在核心服务中: 消息的发送行为和处理一般不容易出现瓶颈, 一个 10w 人的直播间里每秒的互动行为一般超不过 1000

直播互动场景的服务拆分

  • 自动扩缩容
1
2
3
4
监控服务或者机器的一些关键指标, 进行自动扩缩容

业务性能指标: 比如直播间人数、发消息和信令的 QPS 与耗时、消息收发延迟等;
机器性能指标: 主要是通用化的机器性能指标,包括带宽、PPS、系统负载、IOPS 等
  • 智能负载均衡
1
2
3
4
在建立长连接前,客户端先通过一个入口调度服务来查询本次连接应该连接的入口 IP,在这个入口调度服务里根据具体后端接入层机器的具体业务和机器的性能指标,来实时计算调度的权重
负载低的机器权重值高,会被入口调度服务作为优先接入 IP 下发;负载高的机器权重值低,后续新的连接接入会相对更少。

而不单纯是轮询负载,会导致有的机器承载了很多连接,有的则很少

服务高可用:保证核心链路稳定性的流控和熔断机制

流量控制

1
2
3
4
5
6
7
8
9
10
11
12
流控常用算法:
漏桶算法
控制数据注入到网络的速率,平滑网络上的突发流量
它模拟的是一个漏水的桶,所有外部的水都先放进这个水桶,而这个桶以匀速往外均匀漏水,如果水桶满了,外部的水就不能再往桶里倒了

令牌桶算法
控制一个时间窗口内通过的数据量
基本逻辑:
1. 每 1/r 秒往桶里放入一个令牌,r 是用户配置的平均发送速率(也就是每秒会有 r 个令牌放入)。
2. 桶里最多可以放入 b 个令牌,如果桶满了,新放入的令牌会被丢弃。
3. 如果来了 n 个请求,会从桶里消耗掉 n 个令牌。
4. 如果桶里可用令牌数小于 n,那么这 n 个请求会被丢弃掉或者等待新的令牌放入。

全局流控

1
2
对于单机瓶颈的问题,通过单机版的流控算法和组件就能很好地实现单机保护
但在分布式服务的场景下,很多时候的瓶颈点在于全局的资源或者依赖,这种情况就需要分布式的全局流控来对整体业务进行保护。
1
2
3
通用流控方案: 
一般是通过中央式的资源(如:Redis、Nginx)配合脚本来实现全局的计数器,
或者实现更为复杂的漏桶算法和令牌桶算法,比如可以通过 Redis 的 INCR 命令配合 Lua 实现一个限制 QPS(每秒查询量)的流控组件
  • 细粒度控制
1
2
3
4
5
6
在限制 QPS 的时候,流控粒度太粗,没有把 QPS 均匀分摊到每个毫秒里
上一秒的最后一个毫秒和下一秒的第一个毫秒都出现了最大流量,就会导致两个毫秒内的 QPS 翻倍

简单的处理方式:
把一秒分成若干个 N 毫秒的桶,通过滑动窗口的方式,将流控粒度细化到 N 毫秒
基于滑动窗口来统计 QPS,这样也能避免边界处理时不平滑的问题。
  • 流控依赖资源的瓶颈
1
2
3
4
5
6
7
8
9
10
如: 流控使用的 Redis 资源由于访问量太大导致出现不可用的情况

方案: 本地批量预取
让使用限流服务的业务进程,每次从远程资源预取多个令牌在本地缓存,
处理限流逻辑时先从本地缓存消耗令牌,本地消费完再触发从远程资源获取到本地缓存,
如果远程获取资源时配额已经不够了,本次请求就会被抛弃
注意:
本地预取可能会导致一定范围的限流误差
比如:上一秒预取的 10 个令牌,在实际业务中下一秒才用到,这样会导致下一秒业务实际的请求量会多一些
所以对于需要精准控制访问量的场景来说可能不是特别适合

自动熔断机制

针对突发流量,除了扩容和流控外,还有一个能有效保护系统整体可用性的手段就是熔断机制。

1
2
3
4
5
6
7
多依赖的微服务中的雪崩效应: 
为了便于管理和隔离,我们经常会对服务进行解耦,独立拆分解耦到不同的微服务中,微服务间通过 RPC 来进行调用和依赖
一个服务变慢,因为依赖关系,上层级联服务性能变差,最终导致系统整体性能的雪崩

一种常见的方式是手动通过开关来进行依赖的降级,微博的很多场景和业务都有用到开关来实现业务或者资源依赖的降级。
更智能的方式是自动熔断机制:
开源框架: Netflix 公司出品的 Hystrix,以及目前社区很火热的 Resilience4j 等

“限流”, “熔断机制” 和 “缓存” 一起被列为高并发应用工程实现中的三板斧

进阶篇