IM系统的特性
实时性:使用WebSocket协议来实现实时性
可靠性:不丢失消息,消息不重复,使用发送端超时重传,接收端ack+超时重传+去重组合
一致性:同一条消息,在多人、多终端需要保证展现顺序的一致性,使用全局序号生成器snowflakes
安全性:数据传输安全
IM系统表结构概述
消息内容表:负责保存消息
消息索引表:里面有双方用户之间的uid作为查询索引使用
最近联系人列表:可以存储用户最近的联系人并且存储一条最新发送的消息id,执行的是更新操作,有人发送消息就更新一下列表和最新发送消息的uid
实时通信功能实现
客户端与IM服务端接口实现发送消息接口,可以自定义消息结构
建立消息通道:维持一个TCP长链接或使用websocket
在IM服务端会提供发送消息接口,通过与设备之间的长链接通道发送到对应的客户端
解决消息的实时到达问题
使用短轮询,一问一答方式,弊端:浪费客户端资源,大部分轮询请求无效,且对服务端资源压力也大,大量QPS查询达到服务端
使用长轮询,在获取请求时没有得到消息,并不会马上返回,而是会在服务端"悬挂", 等待一段时间,如果这段时间有消息就马上相应,弊端:虽然减少了客户端资源浪费,没有解决服务端资源浪费,1000个长轮询请求在等待消息,意味着1000个线程在不断轮询消息存储资源。在超时时间内没有获取消息,会结束返回,无法完全解决客户端”无效“请求的问题
WebSocket,基于单个TCP连接的全双工通信协议,优点:支持服务端推送的双向通信,大幅降低服务端的轮询压力,数据交互的控制开销低,降低双发通信的网络开销
ACK机制保证消息可靠传递
发送消息的流程:
发送消息到IM服务端
将消息存储在数据库
向消息发送者返回消息成功信号
IM服务端将消息发送给消息接收者
消息丢失有几种情况?
当用户把消息发送给IM服务器过程中,网络不同等原因失败
IM服务器接收消息进行服务端存储失败
用户等待IM服务器返回超时
当消息在IM系统中存储消息处理完后发送给接收者过程中消息丢失
消息重试机制需要注意的地方:
当用户发送消息后在超时时间内没有收到服务器响应,即针对消息丢失的1、2、3情况采取重试机制
😆注意:当消息并没有丢失只是处理比较慢时,进行了重发消息,这时候我们要在服务端对消息进行去重
业务层ACK机制
TCP协议中,默认提供ACK机制,来对通信方接收数据进行确认,告诉通信方已经确认成功接受了数据。
我们可以使用业务层ACK机制来处理IM服务器端发送消息到接收方过程中的消息丢失
在IM服务器推送消息时,携带一个标识SID安全标识符,推送后将当前消息添加到“待ACK消息列表”,客户端B接收消息后,给IM服务器返回一个业务层的ACK包,包中携带本条接收消息的SID,IM服务器接收后,从待ACK消息列表记录中删除此消息。
当使用ACK机制推送过程中消息丢失发生什么?
发送丢失的情况:
用户B网络不可达
用户B没有读取完数据就崩溃
消息在中间网络途中被某些中间设备丢掉了,tcp层重传不成功
如何解决消息推送丢失的问题?
答:参考TCP协议的重传机制,在IM服务器的“待ACK消息列表”中维护一个超时计时器,如果一定时间内没有收到用户B传回的ACK包,冲等待队列中重新去除那条消息进行重推。
用户回传的ACK包丢失怎么办?
回传的ACK包丢失,会导致接收端收到重复推送的消息,可以采用IM服务端推送消息时携带一个SequenceID, 为本次连接会话中需要的唯一id,针对同一条消息重推时SequenceID消息保持不变,接收方对这个ID进行业务层面的去重
当IM服务器推送消息过程中崩溃消息丢失怎么办?
使用时间戳机制是对消息进行完整性检查的,针对IM服务器对推送消息过程中丢失的情况
IM 服务器给接收方 B 推送 msg1,顺便带上一个最新的时间戳 timestamp1,接收方 B 收到 msg1 后,更新本地最新消息的时间戳为 timestamp1。
IM 服务器推送第二条消息 msg2,带上一个当前最新的时间戳 timestamp2,msg2 在推送过程中由于某种原因接收方 B 和 IM 服务器连接断开,导致 msg2 没有成功送达到接收方 B。
用户 B 重新连上线,携带本地最新的时间戳 timestamp1,IM 服务器将用户 B 暂存的消息中时间戳大于 timestamp1 的所有消息返回给用户 B,其中就包括之前没有成功的 msg2。
用户 B 收到 msg2 后,更新本地最新消息的时间戳为 timestamp2。
通过上面的时间戳机制,用户 B 可以成功地让丢失的 msg2 进行补偿发送。
为什么用TCP的ACK还要有业务层的ACK?
TCP的ACK表示网络层消息是否可达,业务层ACK是真正的业务消息是否可达以及是否正确处理,达到不丢消息、消息不重复的目的,即我们要保证的消息可靠性
消息的时序性
使用全局序号生成器作为时序基准,来保证消息发送的顺序
常见的全局序号生成器有:Redis的原子自增命令incr,DB自带的自增ID,snowflake算法
消息服务端包内整流
例子:用户 A 给用户 B 发送最后一条分手消息同时勾上了“取关对方”的选项,这个时候可能会同时产生“发消息”和“取关”两条消息,如果服务端处理时,把“取关”这条信令消息先做了处理,就可能导致那条“发出的消息”由于“取关”了,发送失败的情况。
对于这种情况,我们一般可以调整实现方式,在发送方对多个请求进行业务层合并,多条消息合并成一条;也可以让发送方通过单发送线程和单 TCP 连接能保证两条消息有序到达。
但即使 IM 服务端接收时有序,由于多线程处理的原因,真正处理或者下推时还是可能出现时序错乱的问题,解决这种“需要保证多条消息绝对有序性”可以通过 IM 服务端包内整流来实现。
整个过程是这样的:
首先生产者为每个消息包生成一个 packageID,为包内的每条消息加个有序自增的 seqID;
其次消费者根据每条消息的 packageID 和 seqID 进行整流,最终执行模块只有在一定超时时间内完整有序地收到所有消息才执行最终操作,否则根据业务需要触发重试或者直接放弃操作。
服务端包内整流大概就是这个样子,我们要做的是在最终服务器取到 TCP 连接后下推的时候,根据包的 ID,对一定时间内的消息做一个整流和排序。
在即时消息收发场景中,用于保证消息接收时序的序号生成器为什么可以不是全局递增的?
答: 这是由业务场景决定的,这个群的消息和另一个群的消息在逻辑上是完全隔离的,只要保证消息的序号在群这样的一个局部范围内是递增的即可; 当然如果可以做到全局递增最好,但是会浪费很多的资源,却没有带来更多的收益
消息的安全性
保证传输链路安全:TLS传输层加密协议
账号密码存储安全:单向散列算法,针对账号密码的存储安全一般比较多的采用“高强度单向散列算法”(比如:SHA、MD5 算法)和每个账号独享的“盐”(这里的“盐”是一个很长的随机字符串)结合来对密码原文进行加密存储。“单向散列”算法在非暴力破解下,很难从密文反推出密码明文,通过“加盐”进一步增加逆向破解的难度。当然,如果“密文”和“盐”都被黑客获取到,这些方式也只是提升破解成本,并不能完全保证密码的安全性。因此还需要综合从网络隔离、DB 访问权限、存储分离等多方位综合防治。
对于未读消息的处理
会话未读和总未读单独维护
对于未读消息的处理来说,我们分为总未读和会话未读。
在业务场景中,会将会话未读和总未读单独维护,原因在于“总未读”在很多业务场景里会被高频使用,比如每次消息推送需要把总未读带上作为角标使用,但对于高频使用的“总未读”,如果每次都通过聚合所有会话来获取,一旦用户会话数量增加,会出现会话超时等原因没有获取到会话未读,导致总未读数计算少了。
未读数的一致性问题
未读数一致性是指:维护的总未读数和会话未读数的总和要保持一致。
我们来看看案例,我们先来看看第一个:
用户 A 给用户 B 发送消息,用户 B 的初始未读状态是:和用户 A 的会话未读是 0,总未读也是 0。
消息到达 IM 服务后,执行加未读操作:先把用户 B 和用户 A 的会话未读加 1,再把用户 B 的总未读加 1。
假设加未读操作第一步成功了,第二步失败。最后 IM 服务把消息推送给用户 B。这个时候用户 B 的未读状态是:和用户 A 的会话未读是 1,总未读是 0。
这样,由于加未读第二步执行失败导致的后果是:用户 B 不知道收到了一条新消息的情况,从而可能漏掉查看这条消息。
那么案例是由于在加未读的第二步“加总未读”的时候出现异常,导致未读和消息不一致的情况。
那么,是不是只要加未读操作都正常执行就没有问题了呢?接下来,我们再看下第二个案例。
用户 A 给用户 B 发送消息,用户 B 的初始未读状态是:和用户 A 的会话未读是 0,总未读也是 0。
消息到达 IM 服务后,执行加未读操作:先执行加未读的第一步,把用户 B 和用户 A 的会话未读加 1。
这时执行加未读操作的服务器由于某些原因变慢了,恰好这时用户 B 在 App 上点击查看和用户 A 的聊天会话,从而触发了清未读操作。
执行清未读第一步,把用户 B 和用户 A 的会话未读清 0,然后继续执行清未读第二步,把用户 B 的总未读也清 0。
清未读的操作都执行完之后,执行加未读操作的服务器才继续恢复执行加未读的第二步,把用户 B 的总未读加 1,那么这个时候就出现了两个未读不一致的情况。
导致的后果是:用户 B 退出会话后,看到有一条未读消息,但是点进去却找不到是哪个聊天会话有未读消息。
这里,我来分析一下这两种不一致的案例原因:其实都是因为两个未读的变更不是原子性的,会出现某一个成功另一个失败的情况,也会出现由于并发更新导致操作被覆盖的情况。所以要解决这些问题,需要保证两个未读更新操作的原子性。
保证未读更新的原子性
分布式锁
使用分布式锁来保证总未读和会话未读数量一致,不过锁的引入会降低吞吐,对锁的管理的异常处理容易出现bug,还需要考虑宕机情况下锁的释放问题
支持事务功能的资源
除了分布式锁外,还可以通过一些支持事务功能的资源,来保证两个未读的更新原子性。
事务提供了一种“将多个命令打包, 然后一次性按顺序地执行”的机制, 并且事务在执行的期间不会主动中断,服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。
比如:Redis 通过 MULTI、DISCARD 、EXEC 和 WATCH 四个命令来支持事务操作。
比如每次变更未读前先 watch 要修改的 key,然后事务执行变更会话未读和变更总未读的操作,如果在最终执行事务时被 watch 的两个未读的 key 的值已经被修改过,那么本次事务会失败,业务层还可以继续重试直到事务变更成功。
依托 Redis 这种支持事务功能的资源,如果未读数本身就存在这个资源里,是能比较简单地做到两个未读数“原子变更”的。
但这个方案在性能上还是存在一定的问题,由于 watch 操作实际是一个乐观锁策略,对于未读变更较频繁的场景下(比如一个很火的群里大家发言很频繁),可能需要多次重试才可以最终执行成功,这种情况下执行效率低,性能上也会比较差。
原子化嵌入脚本
那么有没有性能不错还能支持”原子变更“的方案呢?
其实在很多资源的特性中,都支持”原子化的嵌入脚本“来满足业务上对多条记录变更高一致性的需求。Redis 就支持通过嵌入 Lua 脚本来原子化执行多条语句,利用这个特性,我们就可以在 Lua 脚本中实现总未读和会话未读的原子化变更,而且还能实现一些比较复杂的未读变更逻辑。
比如,有的未读数我们不希望一直存在而干扰到用户,如果用户 7 天没有查看清除未读,这个未读可以过期失效,这种业务逻辑就比较方便地使用 Lua 脚本来实现“读时判断过期并清除”。
原子化嵌入脚本不仅可以在实现复杂业务逻辑的基础上,来提供原子化的保障,相对于前面分布式锁和 watch 事务的方案,在执行性能上也更胜一筹。
不过这里要注意的是,由于 Redis 本身是服务端单线程模型,Lua 脚本中尽量不要有远程访问和其他耗时的操作,以免长时间悬挂(Hang)住,导致整个资源不可用。
解决网络的不确定性
为了实现消息的“服务端推送”,我们针对每一台上线的设备,都会在IM服务端维护相应的“用户设备”和“网络连接”映射
心跳机制
心跳机制可以让 IM 服务端能尽快感知到连接的变化,从而尽早清理服务端维护连接使用的资源,支持客户端断线重连,让建立的长连接存活时间更长。
当IM服务端无法感知到这些连接的异常情况,导致IM服务端可能维护了大量的“无效连接”,造成连接句柄的资源浪费;同时也会缓存了大量实际上已经没有用了的“映射关系”“设备信息”“在线状态”等信息,也是对资源的浪费;另外,IM 服务端在往“无效长连接”推送消息,以及后续的重试推送都会降低服务的整体性能。
心跳机制的实现方式
TCP Keepalive
TCP 的 Keepalive 作为操作系统的 TCP/IP 协议栈实现的一部分,对于本机的 TCP 连接,会在连接空闲期按一定的频次,自动发送不携带数据的探测报文,来探测对方是否存活。操作系统默认是关闭这个特性的,需要由应用层来开启。
默认的三个配置项:心跳周期是 2 小时,失败后再重试 9 次,超时时间 75s。三个配置项均可以调整。
但是TCP Keepalive本身存在一些缺陷,比如心跳间隔灵活性较差,一台服务器只能调整为固定间隔的心跳。
TCP可以用于连接层存活的探测,但并不代表真正的应用层处于可用状态。
比如:IM系统代码出现死锁、阻塞的情况下,实际上已经无法处理业务请求,但此时连接TCP Keepalive的探针不需要应用层参见,仍然能够在内核层正常响应。导致探测的误判,让已经失去业务处理能力的机器不能被及时发现。
应用层心跳
为了解决TCP Keepalive存在的一些不足的问题,采用应用层心跳来提升探测的灵活性和准确性。应用层心跳实际上就是客户端每隔一定时间间隔,向IM服务端发送一个业务层的数据包告知自身存活。
目前大部分 IM 都采用了应用层心跳方案来解决连接保活和可用性探测的问题。比如之前抓包中发现 WhatApps 的应用层心跳间隔有 30 秒和 1 分钟,微信的应用层心跳间隔大部分情况是 4 分半钟,目前微博长连接采用的是 2 分钟的心跳间隔。
每种 IM 客户端发送心跳策略也都不一样,最简单的就是按照固定频率发送心跳包,不管连接是否处于空闲状态。之前抓手机 QQ 的包,就发现 App 大概按照 45s 的频率固定发心跳;还有稍微复杂的策略是客户端在发送数据空闲后才发送心跳包,这种相比较对流量节省更好,但实现上略微复杂一些。
下面是一个典型的应用层心跳的客户端和服务端的处理流程图,从图中可以看出客户端和服务端,各自通过心跳机制来实现“断线重连”和“资源清理”。
需要注意的是:对于客户端来说,判断连接是否空闲的时间是既定的心跳间隔时间,而对于服务端来说,考虑到网络数据传输有一定的延迟,因此判断连接是否空闲的超时时间需要大于心跳间隔时间,这样能避免由于网络传输延迟导致连接可用性的误判。
消息支持多终端漫游
多终端漫游:用户在任意一个设备登录后,都能获取到历史的聊天记录。
实现多终端漫游
通过设备维度的在线状态来实现。
通过离线消息存储来实现。
设备维度的在线状态
对于在多个终端同时登录并在线的用户,可以让 IM 服务端在收到消息后推给接收方的多台设备,也推给发送方的其他登录设备,实现方式:需要能够按照用户的设备维度来记录在线状态。
离线消息存储
如果消息发送时,接收方或者发送方只有一台设备在线,可能一段时间后,才通过其他设备登录来查看历史聊天记录,这种离线消息的多终端漫游就需要消息在服务端进行存储了。当用户的离线设备上线时,就能够从服务端的存储中获取到离线期间收发的消息。
一条消息在服务端存储一般会分为消息内容表和消息索引表,其中消息索引表时按照收发双发的会话维度设计,便于收发双发各自查看两人间的聊天内容。
那么问题来了:离线消息的存储是否可以直接使用这个消息索引表?
首先,对于离线消息的存储,不仅仅需要存储消息,还需要存储一些操作的信令,比如:用户 A 在设备 1 删除了和用户 B 的某条消息,这个信令虽然不是一条消息,也需要在离线消息存储中存起来,这样当用户 A 的另一台设备 2 上线时,能通过离线消息存储获取这个删除消息的信令,从而在设备 2 上也能从本地删除那条消息。
对于这些操作信令,没有消息 ID 的概念和内容相关的信息,而且是一个一次性的动作,没必要持久化,也不适合复用消息索引表;另外,消息索引表是收发双方的会话维度,而获取离线消息的时候是接收方或者发送方的单个用户维度来获取数据的,没必要按会话来存,只需要按 UID 来存储即可。
此外,还有一个需要考虑的点,离线消息的存储成本是比较高的,而我们并不知道用户到底有几个设备,因此离线消息的存储一般都会有时效和条数的限制,比如保留 1 周时间,最多存储 1000 条,这样如果用户一台设备很久不登录然后某一天再上线,只能从离线消息存储中同步最近一周的历史聊天记录。
多消息同步机制
离线消息的同步还有一个重要的问题是,由于并不知道用户到底会有多少个终端来离线获取消息,我们在一个终端同步完离线消息后,并不会从离线存储中删除这些消息,而是继续保留以免后续还有该用户的其他设备上线拉取,离线消息的存储也是在不超过大小限制和时效限制的前提下,采用 FIFO(先进先出)的淘汰机制。
这样的话用户在使用某一个终端登录上线时,需要知道应该获取哪些离线消息,否则将所有离线都打包推下去,就会造成两种问题:一个是浪费流量资源;另外可能会导致因为有很多消息在终端中已经存在了,全部下推反而会导致消息重复出现和信令被重复执行的问题。因此,需要一个机制来保证离线消息可以做到按需拉取。
一种常见的方案是采用版本号来实现多终端和服务端的数据同步。下面简单说一下版本号的概念。
每个用户拥有一套自己的版本号序列空间。
每个版本号在该用户的序列空间都具备唯一性,一般是 64 位。
当有消息或者信令需要推送给该用户时,会为每条消息或者信令生成一个版本号,并连同消息或者信令存入离线存储中,同时更新服务端维护的该用户的最新版本号。
客户端接收到消息或者信令后,需要更新本地的最新版本号为收到的最后一条消息或者信令的版本号。
当离线的用户上线时,会提交本地最新版本号到服务端,服务端比对服务端维护的该用户的最新版本号和客户端提交上来的版本号,如不一致,服务端根据客户端的版本号从离线存储获取“比客户端版本号新”的消息和信令,并推送给当前上线的客户端。
为了便于理解,我简单把这个离线同步消息的过程画了一下。
离线消息存储超过限额了怎么办?
在用户上线获取离线消息时,会先进行客户端和服务端的版本号比较,如果版本号不一致才会从离线消息存储中,根据客户端上传的最新版本号来获取“增量消息”。
如果离线消息存储容量超过限制,部分增量消息被淘汰掉了,会导致根据客户端最新版本号获取增量消息失败。
这种情况的处理方式可以是:直接下推所有离线消息或者从消息的联系人列表和索引表中获取最近联系人的部分最新的消息,后续让客户端在浏览时再根据“时间相关”的消息 ID 来按页获取剩余消息,对于重复的消息让客户端根据消息 ID 去重。
因为消息索引表里只存储消息,并不存储操作信令,这种处理方式可能会导致部分操作信令丢失,但不会出现丢消息的情况。因此,对于资源充足且对一致性要求高的业务场景,可以尽量提升离线消息存储的容量来提升离线存储的命中率。
离线存储写入失败了会怎么样?
在处理消息发送的过程中,IM 服务端可能会出现在获取到版本号以后写入离线消息存储时失败的情况,在这种情况下,如果版本号本身只是自增的话,会导致取离线消息时无法感知到有消息在写离线存储时失败的情况。
因为如果这一条消息写离线缓存失败,而下一条消息又成功了,这时拿着客户端版本号来取离线消息时发现,客户端版本号在里面,还是可以正常获取离线消息的,这样就会漏推之前写失败的那一条。
那么,怎么避免这种离线存储写失败无感知的问题呢?
一个可行的方案是可以在存储离线消息时不仅存储当前版本号,还存储上一条消息或信令的版本号,获取消息时不仅要求客户端最新版本号在离线消息存储中存在,同时还要求离线存储的消息通过每条消息携带的上一版本号和当前版本号能够整体串联上,否则如果离线存储写入失败,所有消息的这两个版本号是没法串联上的。
这样,当用户上线拉取离线消息时,IM 服务端发现该用户的离线消息版本号不连续的情况后,就可以用和离线消息存储超限一样的处理方式,从消息的联系人列表和索引表来获取最近联系人的部分最新的消息。
消息打包下推和压缩
对于较长时间不上线的用户,上线后需要拉取的离线消息比较多,如果一条一条下推会导致整个过程很长,客户端看到的就是一条一条消息蹦出来,体验会很差。
因此,一般针对离线消息的下推会采用整体打包的方式来把多条消息合并成一个大包推下去,同时针对合并的大包还可以进一步进行压缩,通过降低包的大小不仅能减少网络传输时间,还能节省用户的流量消耗。
发送方设备的同步问题
另外还有一个容易忽视的问题,版本号机制中,我们在下推消息时会携带每条消息的版本号,然后更新为客户端的最新版本号。而问题是发送方用于发出消息的设备本身已经不需要再进行当前消息的推送,没法通过消息下推来更新这台设备的最新版本号,这样的话这台设备如果下线后再上线,上报的版本号仍然是旧的,会导致 IM 服务端误判而重复下推已经存在的消息。
针对这个问题,一个比较常见的解决办法是:给消息的发送方设备仍然下推一条只携带版本号的单独的消息,发送方设备接收到该消息只需要更新本地的最新版本号就能做到和服务端的版本号同步了。