This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
<audio id="audio" title="01 | 架构与特性一个完整的IM系统是怎样的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/d3/0cf0923b85844fef68487772780bcad3.mp3"></audio>
你好我是袁武林。在接下来的一段时间里我将和你一起探索IM的相关知识。今天是第一节课我们就先从IM的相关概念开始着手。
说起IM我估计你会先愣一下“IM是QQ或者微信这样的即时聊天系统吗它是不是很庞大也很复杂
今天我们以一个简单的App聊天系统为例来看下一个简单的聊天系统都有哪些构成要素以此来了解一个完整的IM系统是什么样的。
## 从一个简单的聊天系统说起
我们可以从使用者和开发者两个角度来看一下。
### 1. 使用者眼中的聊天系统
如果我们站在一个使用者的角度从直观体验上来看,一个简单的聊天系统大概由以下元素组成:用户账号、账号关系、联系人列表、消息、聊天会话。我在这里画了一个简单的示意图:
<img src="https://static001.geekbang.org/resource/image/34/76/34af761c49b7ce6ddf5830db93adfc76.png" alt="">
这个应该不难理解,我来解释一下。
- 聊天的参与需要用户,所以需要有一个**用户账号**,用来给用户提供唯一标识,以及头像、昵称等可供设置的选项。
- 账号和账号之间通过某些方式(比如加好友、互粉等)构成账号间的**关系链**。
- 你的好友列表或者聊天对象的列表,我们称为**联系人的列表**,其中你可以选择一个联系人进行聊天互动等操作。
- 在聊天互动这个环节产生了**消息**。
- 同时你和对方之间的聊天消息记录就组成了一个**聊天会话**,在会话里能看到你们之间所有的互动消息。
### 2. 开发者眼中的聊天系统
从一个IM系统开发者的角度看聊天系统大概由这几大部分组成客户端、接入服务、业务处理服务、存储服务和外部接口服务。
下面,我大概讲一讲每一个部分主要的职责。
<img src="https://static001.geekbang.org/resource/image/6a/a2/6a20a4af19e3205201ab315b4680bca2.png" alt="">
**首先是客户端。**客户端一般是用户用于收发消息的终端设备,内置的客户端程序和服务端进行网络通信,用来承载用户的互动请求和消息接收功能。我们可以把客户端想象为邮局业务的前台,它负责把你的信收走,放到传输管道中。
**其次是接入服务。**接入服务可以认为是服务端的门户,为客户端提供消息收发的出入口。发送的消息先由客户端通过网络给到接入服务,然后再由接入服务递交到业务层进行处理。
接入服务主要有四块功能连接保持、协议解析、Session维护和消息推送。
我们可以把接入服务想象成一个信件管道,联通了邮局的前台和信件分拨中心。但是实际上,接入服务的作用很大,不仅仅只有保持连接和消息传递功能。
当服务端有消息需要推送给客户端时,也是将经过业务层处理的消息先递交给接入层,再由接入层通过网络发送到客户端。
此外在很多基于私有通信协议的IM系统实现中接入服务还提供协议的编解码工作编解码实际主要是为了节省网络流量系统会针对传输的内容进行紧凑的编码比如Protobuf为了让业务处理时不需要关心这些业务无关的编解码工作一般由接入层来处理。
另外还有session维护的工作很多时候也由接入服务来实现session的作用是标识“哪个用户在哪个TCP连接”用于后续的消息推送能够知道如何找到接收人对应的连接来发送。
另外,接入服务还负责最终消息的推送执行,也就是通过网络连接把最终的消息从服务器传输送达到用户的设备上。
**之后是业务处理服务。**业务处理服务是真正的消息业务逻辑处理层,比如消息的存储、未读数变更、更新最近联系人等,这些内容都是业务处理的范畴。
我们可以想象得到业务处理服务是整个IM系统的中枢大脑负责各种复杂业务逻辑的处理。
就好比你的信到达分拨中心后,分拨中心可能需要给接收人发条短信告知一下,或者分拨中心发现接收人告知过要拒绝接收这个发送者的任何信件,因此会在这里直接把信件退回给发信人。
**接着是存储服务。**这个比较好理解,账号信息、关系链,以及消息本身,都需要进行持久化存储。
另外一般还会有一些用户消息相关的设置,也会进行服务端存储,比如:用户可以设置不接收某些人的消息。我们可以把它理解成辖区内所有人的通信地址簿,以及储存信件的仓库。
**最后是外部接口服务。**由于手机操作系统的限制以及资源优化的考虑大部分App在进程关闭或者长时间后台运行时App和IM服务端的连接会被手机操作系统断开。这样当有新的消息产生时就没法通过IM服务再触达用户因而会影响用户体验。
为了让用户在App未打开时或者在后台运行时也能接收到新消息我们会将消息给到**第三方外部接口服务**,来通过手机操作系统自身的公共连接服务来进行操作系统级的“消息推送”,通过这种方式下发的消息一般会在手机的“通知栏”对用户进行提醒和展示。
这种最常用的第三方系统推送服务有苹果手机自带的APNsApple Push Notification service服务、安卓手机内置的谷歌公司的GCMGoogle Cloud Messaging服务等。
但GCM服务在国内无法使用为此很多国内手机厂商在各自手机系统中也提供类似的公共系统推送服务如小米、华为、OPPO、vivo等手机厂商都有相应的SDK提供支持。
为了便于理解,我们还是用上面的例子来说:假如收信人现在不在家,而是在酒店参加某个私人聚会,分拨中心这时只能把信交给酒店门口的安保人员,由他代为送达到收信人手中。在这里我们可以把外部接口服务理解成非邮局员工的酒店门口的安保人员。
**这里,我想请你来思考一个架构问题:为什么接入服务和业务处理服务要独立拆分呢?**
我们前面讲到接入服务的主要是为客户端提供消息收发的出入口而业务处理服务主要是处理各种聊天消息的业务逻辑这两个服务理论上进行合并好像也没有什么不妥但大部分IM系统的实现上却基本上都会按照这种方式进行拆分。
我认为,接入服务和业务处理服务独立拆分,有以下几点原因。
**第一点是接入服务作为消息收发的出入口,必须是一个高可用的服务,保持足够的稳定性是一个必要条件。**
试想一下,如果连接服务总处于不稳定状态,老是出现连不上或者频繁断连的情况,一定会大大影响聊天的流畅性和用户体验。
而业务处理服务由于随着产品需求迭代,变更非常频繁,随时有新业务需要上线重启。
如果消息收发接入和业务逻辑处理都在一起,势必会让接入模块随着业务逻辑的变更上线,而频繁起停,导致已通过网络接入的客户端连接经常性地断连、重置、重连。
这种连接层的不稳定性会导致消息下推不及时、消息发送流畅性差,甚至会导致消息发送失败,从而降低用户消息收发的体验。
所以,将“只负责网络通道维持,不参与业务逻辑,不需要频繁变更的接入层”抽离出来,不管业务逻辑如何调整变化,都不需要接入层进行变更,这样能保证连接层的稳定性,从而整体上提升消息收发的用户体验。
**第二点是从业务开发人员的角度看,接入服务和业务处理服务进行拆分有助于提升业务开发效率,降低业务开发门槛。**
模块拆分后,接入服务负责处理一切网络通信相关的部分,比如网络的稳定性、通信协议的编解码等。这样负责业务开发的同事就可以更加专注于业务逻辑的处理,而不用关心让人头痛的网络问题,也不用关心“天书般的通信协议”了。
## IM系统都有哪些特性
上面我们从使用者和从业者两个角度分别了解一个完整IM系统的构成接下来我们和其他系统对比着来看一下从业务需求出发IM系统都有哪些不一样的特性。
### 1. 实时性
对于一个实时消息系统,“实时”二字很好地表达了这个系统的基本要求。
通过微信和你的好友聊天,结果等半天对方才收到,基本上也没有意愿聊了;直播场景下,如果主播的互动消息房间里的粉丝要等很长时间才能收到,也很难让粉丝们有积极参与的欲望。
了解到“实时性”在实时消息场景下的重要性后在技术方面我们会采用哪些手段来提升和保证这一特性呢细节暂不展开在第3篇“轮询与长连接如何解决消息实时到达问题”中我会和你继续探讨“保证消息实时性”的几种方案。
### 2. 可靠性
如果说“实时性”是即时消息被广泛应用于各种社交、互动领域的基本前置条件,那么消息的可靠性则是实时消息服务可以“被信赖”的另一个重要特性。
这里的可靠性通俗来讲,一般包括两个方面。
- **不丢消息。**“丢消息”是互动中让人难以接受的Bug某些场景下可能导致业务可用性差甚至不可用的情况。比如直播间“全员禁言”的信令消息丢失就可能导致直播室不可控的一些情况。
- **消息不重复。**消息重复不仅会对用户造成不必要的骚扰和困惑,可能还会导致比较严重的业务异常,比如直播间“送礼物”的消息由于某种原因被重复发出,处理不妥的话可能会导致用户损失。
那么如何做到“不丢消息”的同时还能解决“消息重复”问题呢对于IM系统可靠性的解决方案我会在接下来的第4篇“ACK机制如何保证消息的可靠投递”和你一起探讨。
### 3. 一致性
消息的一致性一般来是指:同一条消息,在多人、多终端需要保证展现顺序的一致性。
比如,对于单聊场景,一致性是指希望发送方的消息发送顺序和接收方的接收顺序保持一致;而对于一个群的某一条消息,我们希望群里其他人接收到的消息顺序都是一致的;对于同一个用户的多台终端设备,我们希望发送给这个用户的消息在多台设备上也能保持一致性。
缺少“一致性”保障的IM系统经常会导致双方沟通过程中出现一些“奇妙的误会”语言乱序相关的“惨案”。网络上你可以想象一下发给下属、领导或合作方的几条重要工作内容如果消息错乱了后果可能会比较严重。
保证“消息的一致性”也是考验即时消息系统的重要指标那么具体在实战中都有哪些通用的技术能实现这个特性我会后续第5篇“消息序号生成器如何保证你的消息不会乱序”中详细展开。
### 4. 安全性
由于即时消息被广泛应用于各种私密社交和小范围圈子社交,因此用户对于系统的隐私保护能力要求也相对较高。
从系统使用安全性的角度来看,首先是要求“数据传输安全”,其次是要求“数据存储安全”,最后就是“消息内容安全”。
每一个方面实际上业界也都有比较成熟的应对方案具体如何从这几方面入手来保障系统的整体安全性我在第6篇“HttpDNS和TLS你的消息聊天内容真的安全吗”中也会一一细述。
除了以上四大特性,作为一个相对高频使用的系统,消息系统在节能省电、省流量这些方面也增加了众多锦上添花的功能,在后续课程中,关于这些特点在实战方面如何落地,我也会穿插进行讲解。
## 小结
今天我们先从“使用者的直观体验”和“实现上的系统构成”的两个角度和你一起了解一个较完整的IM系统都应该有什么。
之后,我们又从即时消息系统所适用的业务场景需求,了解了即时消息有别于其他业务系统的四大特性。
- 实时性,保证消息实时触达是互动场景的必备能力。
- 可靠性,“不丢消息”和“消息不重复”是系统值得信赖的前置条件。
- 一致性“多用户”“多终端”的一致性体验能大幅提升IM系统的使用体验。
- 安全性,“数据传输安全”“数据存储安全”“消息内容安全”三大保障方面提供全面隐私保护。
在后续课程中我会逐步细述在主流IM系统的设计实现上是具体如何落地去实现“实时性”“可靠性”“一致性”“安全性”的要求。
最后,留给你一个思考题。消息一定需要在服务端的存储服务里进行存储吗?
欢迎你给我留言,我们一起讨论。

View File

@@ -0,0 +1,178 @@
<audio id="audio" title="02 | 消息收发架构为你的App加上实时通信功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/42/8a14ab829a8cd7ca120d003fc510e542.mp3"></audio>
你好,我是袁武林。
前一篇文章中我们从使用者的直观角度和从业者的实现维度了解一个IM系统都应该具备哪些要素。但实际上从我的角度来看我更倾向于把“IM”看作是一门可以融入到各种业务系统中为业务系统提供“实时交互”能力的技术模块。
比如极客时间想在它的App中增加一个互动模块支持用户点对点的实时聊天功能。那么我们就可以相应地通过一些IM SDK的方式快速地把即时消息的技术引入到已有的业务系统中。
同样,一个传统的视频网站如果想让自己的视频支持弹幕功能,也可以通过引入即时消息的技术,来让视频弹幕的参与者能实时、高效地和其他观看者进行各种互动。
所以,从某种程度上看,随着移动网络的快速发展以及资费的快速下降,即时消息技术也越来越多地被广泛应用到各种业务系统中,用于提升用户实时互动的能力。
那么,接下来,我们就一起从即时消息更细化的实现角度来看一看,给一个已有系统增加即时消息功能,大致上都有哪些具体工作。
<img src="https://static001.geekbang.org/resource/image/1c/28/1c38c735ff95d2df0ca15040394f6f28.png" alt="">
如果为原有的业务系统增加实时消息模块,在不需要重建账号体系的前提下,整体上大概包括几块内容:
**一般来说首先需要制定好消息内容和未读数的存储,另外需要建立比原业务系统更加高效实时的消息收发通道,当然也包括依托第三方辅助通道来提升消息到达率。**
下面我们分别来看一下各部分大体需要做的工作都包括哪些。
## 消息存储
我们回想一下上一篇的内容,即时消息系统中,消息作为互动的载体,是必不可少的要素之一。
一般来说,大部分即时消息系统为了便于查看历史消息或者用于暂存离线消息,都需要对消息进行服务端存储,因此,我们先来看一看,这些互动过程产生的消息在服务端应该怎么存储或者暂存。
### 消息索引和消息内容
这里,我以点对点消息的存储为例,来讲解一下。
**点对点消息的参与方有两个:消息发送方和消息接收方。**收发双方的历史消息都是相互独立的。互相独立的意思就是:假设发送方删除了某一条消息,接收方仍然可以获取到这条消息。
所以,从库表的设计上分析,这里需要索引表中收发双方各自有一条自己的索引记录:一条是消息发送方的发件箱索引,另一条是消息接收方的收件箱索引。
由于收发双方看到的消息内容实际都是一致的,因此还需要一个独立的消息内容表。
消息内容表用于存储消息维度的一些基本信息比如消息ID、消息内容、消息类型、消息产生时间等。收发双方的两个索引表通过同一个消息ID和这个内容表关联。
这里假设张三给李四发送一条消息消息存储在MySQL或者类似的关系型数据库中那么上面涉及的两张表大致如下
- 内容表
<img src="https://static001.geekbang.org/resource/image/b5/8f/b50954d72596da912c510d8b556b408f.jpg" alt="">
- 索引表
<img src="https://static001.geekbang.org/resource/image/0b/01/0b4a032608bdd2b56fa7c826d8cc9601.jpg" alt="">
比如张三给李四发了一条“你好”的消息那么这个动作会向内容表存储一条消息。这条消息内容是这样的ID为1001消息内容是“你好”消息类型是文本消息还有当时消息创建的时间。
并且,它同时会往索引表里存储两条记录。
一条是张三的索引内容有会话对方的UID李四的UID是发件箱的索引也就是0同时记录这条消息的内容表里的消息ID为1001。
另一条是李四的索引内容有会话对方的UID张三的UID是收件箱的索引也就是1同样也同时记录这条消息的内容表里的消息ID为1001。
### 联系人列表
有了消息和索引后如上一篇中的描述一般IM系统还需要一个最近联系人列表来让互动双方快速查找需要聊天的对象联系人列表一般还会携带两人最近一条聊天消息用于展示。
这里你需要理解的是,和消息索引表的存储逻辑相比,联系人列表在存储上有以下区别。
- **联系人列表只更新存储收发双方的最新一条消息,不存储两人所有的历史消息。**
- **消息索引表的使用场景一般用于查询收发双方的历史聊天记录,是聊天会话维度;而联系人表的使用场景用于查询某一个人最近的所有联系人,是用户全局维度。**
在库表的设计上,联系人列表的存储实际和消息索引表类似,只不过消息索引表在接收到消息时,大部分情况都是插入操作,而联系人列表很多时候是更新操作。
- 最近联系人表
<img src="https://static001.geekbang.org/resource/image/49/4c/49c11075b4732b492e900aab58770c4c.jpg" alt="">
还是刚才那个例子,张三给李四发完消息后,除了在内容表和索引表插入记录,还会更新各自的最近联系人表,这里需要分别更新张三的最近联系人表和李四的最近联系人表。
比如更新张三的最近联系人表如果和李四之前没有聊天记录那么新插入一条联系人记录。联系人的对方UID为李四的UID和这个联系人最新的一条消息ID是1001。
如果张三和李四之前已经有过聊天记录那么只需要更新张三和李四的最新的一条聊天消息ID为1001同样的办法再更新一次李四的联系人列表。
以上就是消息存储部分最重要的三个表,消息内容表、消息索引表、联系人列表。它们大致的存储结构,我们就设计好了。
## 消息收发通道
设计好消息的存储结构后,接下来,我们需要考虑的是:如何将消息发出去,以及怎么把消息投递给接收方。这里逻辑上涉及了两条通道:一条是消息发送通道,一条是消息接收通道。
发送方通过发送通道把消息从本地发送到IM服务端IM服务端通过接收通道把消息投递给接收方。
### 消息发送通道
发送通道的实现上有很多种方式,比如下面的两种。
1. IM服务端提供一个HTTP协议的API接口客户端需要发送消息时调用这个接口把消息发给IM服务端。
1. 客户端和IM服务端维护一个TCP长连接客户端有消息发送时会以私有协议来封装这条要发送的消息然后通过这个TCP长连接把消息发给IM服务端。
<img src="https://static001.geekbang.org/resource/image/3a/47/3a685f9d3f363cb7f748b73b6f8a6147.png" alt="">
所以,发送通道的实现相对比较简单,**重点在于IM服务端提供消息发送的API发送方可以通过任意方式调用到这个API把消息发出去即可。**
### 消息接收通道
对于我们最常见的非P2P模式的IM系统来说由于**有一条消息要投递给某个接收方**这个事件接收方并没有办法能实时知道只有IM服务端收到发送方发出的消息时能实时感知到因此消息投递这个动作一般都是IM服务端触发的这里我们不去讨论由接收方通过轮询获取消息的模式
下面,我画了一张图来说明接收通道的业务逻辑,目前业界在消息接收通道的实现上较多采用的方式是下面这样的。
<img src="https://static001.geekbang.org/resource/image/41/33/41bd1f4c74e980f88cd1a2247f292133.png" alt="">
解释一下这张图。
IM服务端的网关服务和消息接收方设备之间维护一条TCP长连接或者Websocket长连接借助TCP的**全双工能力,也就是能够同时接收与发送数据的能力。**当有消息需要投递时通过这条长连接实时把消息从IM服务端推送给接收方。
对于接收方不在线比如网络不通、App没打开等的情况还可以通过第三方手机操作系统级别的辅助通道把这条消息通过手机通知栏的方式投递下去。
这里简单解释一下常见的第三方操作系统级别的辅助通道。比如苹果手机的APNsApple Push Notification Service通道、Android手机的GCM通道还有各种具体手机厂商如小米、华为等提供的厂商通道。
这些通道由于是手机厂商来维护的只要手机网络可通因此可以在我们的App在没有打开的情况下也能把消息实时推送下去。
当然,这些第三方操作系统级别的辅助通道也存在一些问题,因此大部分情况下也只是作为一个辅助手段来提升消息的实时触达的能力,这个在后续课程中,我会再详细说明。
因此对于消息接收通道重点在于需要在IM服务端和接收方之间维护一个可靠的长连接什么叫可靠的长连接呢这里的可靠可以理解为下列两种情况。
<li>
IM服务端和接收方能较为精确地感知这个长连接的可用性当由于网络原因连接被中断时能快速感知并进行重连等恢复性操作。
</li>
<li>
可靠性的另一层含义是通过这个长连接投递的消息不能出现丢失的情况否则会比较影响用户体验。这个问题的解决会在后续第3篇的课程中来详细展开。
</li>
我在上面大概说明了一下,逻辑上消息收发通道各自的作用和一般的实现,当然这两条通道在实际的实现上,可以是各自独立存在的,也可以合并在一条通道中。
## 消息未读数
现在我们有了消息的收发通道和消息的存储用户通过发送通道把消息发到IM服务端IM服务端对消息内容、收发双方的消息索引进行存储同时更新双方的最近联系人的相关记录然后IM服务端通过和消息接收方维护的接收通道将消息实时推送给消息接收方。
如果消息接收方当前不在线,还可以通过第三方操作系统级别的辅助通道,来实时地将消息通过手机通知栏等方式推送给接收方。
整体上来看,一条消息从发送、存储、接收的生命之旅基本上比较完整了,但对于即时消息的场景来说,还有一个比较重要的功能,会对双方在互动积极性和互动频率上产生比较大的影响,这个就是消息的未读数提醒。
用过QQ、微信的用户应该都有一个比较明显的感知很多时候为了避免通知栏骚扰会限制掉App在通知栏提醒权限或者并没有注意到通知栏的提醒这些情况都可能会让我们无法及时感知到“有人给我发了新的消息”这个事情。
那么作为一个重要的补救措施就是消息的未读提醒了。就我个人而言很多时候是看到了QQ或者微信App的角标上面显示的多少条未读消息才打开App然后通过App里面具体某个联系人后面显示和当前用户有多少条未读这个数字来决定打开哪个联系人的聊天页进行查看。
**上面通过未读提醒来查看消息的环节中涉及了两个概念:一个是我有多少条未读消息,另一个是我和某个联系人有多少条未读消息。**
因此,我们在消息未读数的实现上,一般需要针对用户维度有一个总未读数的计数,针对某一个具体用户需要有一个会话维度的会话未读的计数。
那么,这两个消息未读数变更的场景是下面这样的:
1. 张三给李四发送一条消息IM服务端接收到这条消息后给李四的总未读数增加1给李四和张三的会话未读也增加1
1. 李四看到有一条未读消息后打开App查看和张三的聊天页这时会执行未读变更将李四和张三的会话未读减1将李四的总未读也减1。
这个具体的未读数存储可以是在IM服务端如QQ、微博也可以是在接收方的本地端上存储微信一般来说需要支持“消息的多终端漫游”的应用需要在IM服务端进行未读存储不需要支持“消息的多终端漫游”可以选择本地存储即可。
对于在IM服务端存储消息未读数的分布式场景如何保证这两个未读数的一致性也是一个比较有意思的事情这个问题我会留到第6篇来和你详细讨论。
## 小结
上面我们从一条消息“产生、存储、接收”的整个生命周期出发,较为系统地从实现的角度上对消息系统的几个关键部分进行了讲述。可以简单地总结为下面几点。
<li>
消息的发送方通过发送通道来把消息提交到IM服务端。
</li>
<li>
IM服务端接收到发送的消息后会进行消息的存储以便于后续历史消息的查看消息的存储从实现上可以分为消息内容存储、消息索引存储、最近联系人列表存储。
</li>
<li>
IM服务端接收到发送的消息后还会针对接收方进行未读数的变更以提醒用户查看未读的消息消息未读数的实现上一般分为用户维度的总未读和会话维度的会话未读。
</li>
<li>
IM服务端进行完消息存储和未读变更后会通过接收通道把消息推送给接收方接收通道一般是通过IM服务端和消息接收方之间维护的长连接来实现还会使用第三方操作系统级别的辅助通道来提升“自建的长连接不可用“时实时触达的能力。
</li>
**最后,留给你两个思考题。**
1.消息存储中,内容表和索引表如果需要分库处理,应该按什么字段来哈希? 索引表可以和内容表合并成一个表吗?
2.能从索引表里获取到最近联系人所需要的信息,为什么还需要单独的联系人表呢?
你可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="03 | 轮询与长连接:如何解决消息的实时到达问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/64/b981526cb391ea86b792b0ddd5933064.mp3"></audio>
你好,我是袁武林。
我在前面第一篇文章中从使用场景的需求方面讲到了IM系统的几个比较重要的特性。其中之一就是“消息到达的实时性”。
实时性场景是所有的IM系统绕不开的话题为了支持互联网的“实时互联”的概念大部分的App都需要实时技术的支持。
我们现在使用的聊天类App、直播互动类App都已经在实时性方面做得很好了消息收发延迟基本都能控制在毫秒级别。
当然这一方面得益于快速发展的移动网络让网络延迟越来越低、网络带宽越来越高另一个重要原因是社交网络App在实时性提升方面的技术也在不断升级迭代。
实时性主要解决的问题是:当一条消息发出后,我们的系统如何确保这条消息最快被接收人感知并获取到,并且尽量让耗费的资源较少。这里关键的几个点是:最快触达,且耗费资源少。
想好了吗下面我们来看一看IM在追求“消息实时性”的架构上所经历过的几个代表性阶段。
## 短轮询场景
在PC Web的早期时代对于数据的获取大部分应用采用一问一答的“请求响应”式模式实际上像现在我们浏览大部分门户网站的新闻以及刷微博其实都是采用的“请求响应”模式。
但这种依赖“手动”触发的模式,在即时消息系统中当有新消息产生时并不能很好地感知并获取到,所以明显不适用于对实时性要求高的场景。
因此这个时期的IM软件很多采用了一种“短轮询”的模式来定期、高频地轮询服务端的新消息。
在短轮询模式中,服务器接到请求后,如果有新消息就会将新消息返回给客户端,如果没有新消息就返回空列表,并关闭连接。
这种短轮询的方式就好像一位焦急等待重要信件的人,每天骑车跑到家门口的邮局去问是否有自己的信件,有就拿回家,没有第二天接着去邮局问。
<img src="https://static001.geekbang.org/resource/image/f0/db/f0de61adecb8925483517cb5df2aecdb.png" alt="">
作为从一问一答的请求响应模式孵化出来的短轮询模式,具有较低的迁移升级成本,比较容易落地。但劣势也很明显:
- 为了提升实时性,短轮询的频率一般较高,但大部分轮询请求实际上是无用的,客户端既费电也费流量;
- 高频请求对服务端资源的压力也较大一是大量服务器用于抗高频轮询的QPS每秒查询率二是对后端存储资源也有较大压力。
因此,“短轮询”这种方式,一般多用在用户规模比较小,且不愿花费太多服务改造成本的小型应用上。
## 长轮询场景
正是由于“短轮询”存在着高频无用功的问题为了避免这个问题IM逐步进化出“长轮询”的消息获取模式。
长轮询和短轮询相比一个最大的改进之处在于短轮询模式下服务端不管本轮有没有新消息产生都会马上响应并返回。而长轮询模式当本次请求没有获取到新消息时并不会马上结束返回而是会在服务端“悬挂hang等待一段时间如果在等待的这段时间内有新消息产生就能马上响应返回。
这种方式就像等待收信的人每天跑到邮局去问是否有自己的信件,如果没有,他不是马上回家,而是在邮局待上一天,如果还是没有就先回家,然后第二天再来。
<img src="https://static001.geekbang.org/resource/image/55/8b/55b526787ddc5761508df251c5f76d8b.png" alt="">
比较之下我们会发现长轮询能大幅降低短轮询模式中客户端高频无用的轮询导致的网络开销和功耗开销也降低了服务端处理请求的QPS相比短轮询模式而言显得更加先进。
长轮询的使用场景多见于: 对实时性要求比较高但是整体用户量不太大。它在不支持WebSocket的浏览器端的场景下还是有比较多的使用。
但是长轮询并没有完全解决服务端资源高负载的问题,仍然存在以下问题。
<li>
服务端悬挂hang住请求只是降低了入口请求的QPS并没有减少对后端资源轮询的压力。假如有1000个请求在等待消息可能意味着有1000个线程在不断轮询消息存储资源。
</li>
<li>
长轮询在超时时间内没有获取到消息时,会结束返回,因此仍然没有完全解决客户端“无效”请求的问题。
</li>
## 服务端推送:真正的边缘触发
短轮询和长轮询之所以没法做到基于事件的完全的“边缘触发当状态变化时发生一个IO事件这是因为服务端在有新消息产生时没有办法直接向客户端进行推送。
这里的根本原因在于短轮询和长轮询是基于HTTP协议实现的由于HTTP是一个无状态协议同一客户端的多次请求对于服务端来说并没有关系也不会去记录客户端相关的连接信息。
因此,所有的请求只能由客户端发起,服务端由于并不记录客户端状态,当服务端接收到新消息时,没法找到对应的客户端来进行推送。
随着HTML5的出现全双工的WebSocket彻底解决了服务端推送的问题。
<img src="https://static001.geekbang.org/resource/image/3c/6b/3c2eaba794372659e78bb9d678d16d6b.png" alt=""><br>
这就像之前信件处理的逻辑,等待收信的用户不需要每天都跑到邮局去询问,而只要在邮局登记好自己家里的地址。等真正有信件时,邮局会派专门的邮递员按照登记的地址来把信送过去。
同样,当他需要写信给别人时,也只需要填好收件人地址,然后把信交给邮递员就可以了,不需要再自己跑邮局。
### WebSocket
WebSocket正是一种服务端推送的技术代表。
随着HTML5的出现基于单个TCP连接的全双工通信的协议WebSocket在2011年成为RFC标准协议逐渐代替了短轮询和长轮询的方式而且由于WebSocket协议获得了Web原生支持被广泛应用于IM服务中特别是在Web端基本属于IM的标配通信协议。
和短轮询、长轮询相比基于WebSocket实现的IM服务客户端和服务端只需要完成一次握手就可以创建持久的长连接并进行随时的双向数据传输。当服务端接收到新消息时可以通过建立的WebSocket连接直接进行推送真正做到“边缘触发”也保证了消息到达的实时性。
WebSocket的优点是
1. 支持服务端推送的双向通信,大幅降低服务端轮询压力;
1. 数据交互的控制开销低,降低双方通信的网络开销;
1. Web原生支持实现相对简单。
### TCP长连接衍生的IM协议
除了WebSocket协议在IM领域还有其他一些常用的基于TCP长连接衍生的通信协议如XMPP协议、MQTT协议以及各种私有协议。
这些基于TCP长连接的通信协议在用户上线连接时在服务端维护好连接到服务器的用户设备和具体TCP连接的映射关系通过这种方式客户端能够随时找到服务端服务端也能通过这个映射关系随时找到对应在线的用户的客户端。
而且这个长连接一旦建立,就一直存在,除非网络被中断。这样当有消息需要实时推送给某个用户时,就能简单地通过这个长连接实现“服务端实时推送”了。
但是上面提到的这些私有协议都各有优缺点XMPP协议虽然比较成熟、扩展性也不错但基于XML格式的协议传输上冗余比较多在流量方面不太友好而且整体实现上比较复杂在如今移动网络场景下用得并不多。
而轻量级的MQTT基于代理的“发布/订阅”模式在省流量和扩展性方面都比较突出在很多消息推送场景下被广泛使用但这个协议并不是IM领域的专有协议因此对于很多IM下的个性化业务场景仍然需要大量复杂的扩展和开发比如不支持群组功能、不支持离线消息。
因此对于开发人力相对充足的大厂目前很多是基于TCP或者UDP来实现自己的私有协议一方面私有协议能够贴合业务需要做到真正的高效和省流另一方面私有协议相对安全性更高一些被破解的可能性小。目前主流的大厂很多都是采用私有协议为主的方式来实现。
## 小结
这一篇我们介绍了即时消息服务中是如何解决“消息实时性”这个难题。
为了更好地解决实时性问题,即时消息领域经历过的几次技术的迭代升级:
- 从简单、低效的短轮询逐步升级到相对效率可控的长轮询;
- 随着HTML5的出现全双工的WebSocket彻底解决了服务端推送的问题
- 同时基于TCP长连接衍生的各种有状态的通信协议也能够实现服务端主动推送从而更好解决“消息收发实时性”的问题。
最后给你留一个思考题TCP长连接的方式是怎么实现“当有消息需要发送给某个用户时能够准确找到这个用户对应的网络连接”
你可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="04 | ACK机制如何保证消息的可靠投递" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/02/dcbd56165c527ae956f120b37d33d202.mp3"></audio>
你好,我是袁武林。
在第一节的课程中,我们说到了即时消息系统中的四个重要特性,实时性、可靠性、一致性、安全性。
上一节课我们从如何保证消息实时性方面,了解了业界常用的一些方式以及背后具体的原理。那么今天我们接着来讲一讲,在即时消息的系统架构设计里,如何来保证消息的可靠投递。
首先,我们来了解一下,什么是消息的可靠投递?
站在使用者的角度来看,消息的可靠投递主要是指:消息在发送接收过程中,能够做到不丢消息、消息不重复两点。
这两个特性对于用户来讲都是非常影响体验的。我们先说一下不丢消息。
试想一下,你把辛辛苦苦攒到的零花钱打赏给了中意的“主播小姐姐”,但由于系统或者网络的问题,这条对你来说至关重要的打赏消息并没有成功投递给“主播小姐姐”,自然也就没有后续小姐姐和你一对一的互动环节了,想想是不是很悲剧?
消息重复也不用多说,谁也不愿意浪费时间在查看一遍又一遍的重复内容上。
那么在一般的IM系统的设计中究竟是如何解决这两大难题的呢下面我们结合一些简单的案例来看一看“不丢消息”“消息不重复”这些能力在技术上到底是怎么实现的。
## 消息丢失有哪几种情况?
我们以最常见的“服务端路由中转”类型的IM系统为例非P2P这里解释一下所谓的“服务端路由中转”是指一条消息从用户A发出后需要先经过IM服务器来进行中转然后再由IM服务器推送给用户B这个也是目前最常见的IM系统的消息分发类型。
我们可以把它和少数P2P类型区别一下P2P类型的消息投递是直接由用户A的网络发送到用户B的网络不经过服务端路由。
那么我们来假设一个场景用户A给用户B发送一条消息。接下来我们看看哪些环节可能存在丢消息的风险
<img src="https://static001.geekbang.org/resource/image/5b/28/5b2ee576e22ac109121714aaaaec3528.png" alt="">
参考上面时序图,发消息大概整体上分为两部分:
- 用户A发送消息到IM服务器服务器将消息暂存然后返回成功的结果给发送方A步骤1、2、3
- IM服务器接着再将暂存的用户A发出的消息推送给接收方用户B步骤4
其中可能丢失消息的场景有下面这些。
在第一部分中。步骤1、2、3都可能存在失败的情况。
由于用户A发消息是一个“请求”和“响应”的过程如果用户A在把消息发送到IM服务器的过程中由于网络不通等原因失败了或者IM服务器接收到消息进行服务端存储时失败了或者用户A等待IM服务器一定的超时时间但IM服务器一直没有返回结果那么这些情况用户A都会被提示发送失败。
接下来,他可以通过重试等方式来弥补,注意这里可能会导致发送重复消息的问题。
比如客户端在超时时间内没有收到响应然后重试但实际上请求可能已经在服务端成功处理了只是响应慢了因此这种情况需要服务端有去重逻辑一般发送端针对同一条重试消息有一个唯一的ID便于服务端去重使用。
在第二部分中。消息在IM服务器存储完后响应用户A告知消息发送成功了然后IM服务器把消息推送给用户B的在线设备。
在推送的准备阶段或者把消息写入到内核缓冲区后如果服务端出现掉电也会导致消息不能成功推送给用户B。
这种情况实际上由于连接的IM服务器可能已经无法正常运转需要通过后期的补救措施来解决丢消息的问题后续会详细讲到这里先暂且不讨论。
即使我们的消息成功通过TCP连接给到用户B的设备但如果用户B的设备在接收后的处理过程出现问题也会导致消息丢失。比如用户B的设备在把消息写入本地DB时出现异常导致没能成功入库这种情况下由于网络层面实际上已经成功投递了但用户B却看不到消息。所以比较难处理。
上面两种情况都可能导致消息丢失,那么怎么避免这些异常情况下丢消息的问题呢?<br>
一般我们会用下面这些相应的解决方案:
<li>
针对第一部分我们通过客户端A的超时重发和IM服务器的去重机制基本就可以解决问题
</li>
<li>
针对第二部分业界一般参考TCP协议的ACK机制实现一套业务层的ACK协议。
</li>
## 解决丢失的方案业务层ACK机制
我们先解释一下ACKACK全称 Acknowledge是确认的意思。在TCP协议中默认提供了ACK机制通过一个协议自带的标准的ACK数据包来对通信方接收的数据进行确认告知通信发送方已经确认成功接收了数据。
那么业务层ACK机制也是类似解决的是IM服务推送后如何确认消息是否成功送达接收方。具体实现如下图
<img src="https://static001.geekbang.org/resource/image/a4/7e/a4e3c1cfb27aa32e1c42891f3c14eb7e.png" alt="">
IM服务器在推送消息时携带一个标识SID安全标识符类似TCP的sequenceId推送出消息后会将当前消息添加到“待ACK消息列表”客户端B成功接收完消息后会给IM服务器回一个业务层的ACK包包中携带有本条接收消息的SIDIM服务器接收后会从“待ACK消息列表”记录中删除此条消息本次推送才算真正结束。
### ACK机制中的消息重传
如果消息推给用户B的过程中丢失了怎么办比如
- B网络实际已经不可达但IM服务器还没有感知到
- 用户B的设备还没从内核缓冲区取完数据就崩溃了
- 消息在中间网络途中被某些中间设备丢掉了TCP层还一直重传不成功等。
以上的问题都会导致用户B接收不到消息。
解决这个问题的常用策略其实也是参考了TCP协议的重传机制。类似的IM服务器的“等待ACK队列”一般都会维护一个超时计时器一定时间内如果没有收到用户B回的ACK包会从“等待ACK队列”中重新取出那条消息进行重推。
### 消息重复推送的问题
刚才提到对于推送的消息如果在一定时间内没有收到ACK包就会触发服务端的重传。收不到ACK的情况有两种除了推送的消息真正丢失导致用户B不回ACK外还可能是用户B回的ACK包本身丢了。
对于第二种情况ACK包丢失导致的服务端重传可能会让接收方收到重复推送的消息。
针对这种情况一般的解决方案是服务端推送消息时携带一个Sequence IDSequence ID在本次连接会话中需要唯一针对同一条重推的消息Sequence ID不变接收方根据这个唯一的Sequence ID来进行业务层的去重这样经过去重后对于用户B来说看到的还是接收到一条消息不影响使用体验。
## 这样真的就不会丢消息了吗?
细心的你可能发现通过“ACK+超时重传+去重”的组合机制,能解决大部分用户在线时消息推送丢失的问题,那是不是就能完全覆盖所有丢消息的场景呢?
设想一下假设一台IM服务器在推送出消息后由于硬件原因宕机了这种情况下如果这条消息真的丢了由于负责的IM服务器宕机了无法触发重传导致接收方B收不到这条消息。
这就存在一个问题当用户B再次重连上线后可能并不知道之前有一条消息丢失的情况。对于这种重传失效的情况该如何处理
### 补救措施:消息完整性检查
针对服务器宕机可能导致的重传失效的问题我们来分析一下,这里的问题在于:服务器机器宕机,重传这条路走不通了。
那如果在用户B在重新上线时让服务端有能力进行完整性检查发现用户B“有消息丢失”的情况就可以重新同步或者修复丢失的数据。
比较常见的消息完整性检查的实现机制有“时间戳比对”,具体的实现如下图:
<img src="https://static001.geekbang.org/resource/image/14/c6/149af9b46ff04769d8957efaac84e1c6.png" alt="">
下面我们来看一下“时间戳机制”是如何对消息进行完整性检查的,我用这个例子来解释一下这个过程。
- IM服务器给接收方B推送msg1顺便带上一个最新的时间戳timestamp1接收方B收到msg1后更新本地最新消息的时间戳为timestamp1。
- IM服务器推送第二条消息msg2带上一个当前最新的时间戳timestamp2msg2在推送过程中由于某种原因接收方B和IM服务器连接断开导致msg2没有成功送达到接收方B。
- 用户B重新连上线携带本地最新的时间戳timestamp1IM服务器将用户B暂存的消息中时间戳大于timestamp1的所有消息返回给用户B其中就包括之前没有成功的msg2。
- 用户B收到msg2后更新本地最新消息的时间戳为timestamp2。
通过上面的时间戳机制用户B可以成功地让丢失的msg2进行补偿发送。
需要说明的是,由于时间戳可能存在多机器时钟不同步的问题,所以可能存在一定的偏差,导致数据获取上不够精确。所以在实际的实现上,也可以使用全局的自增序列作为版本号来代替。
## 小结
保证消息的可靠投递是IM系统设计中至关重要的一个环节“不丢消息”“消息不重复”对用户体验的影响较大我们可以通过以下手段来确保消息下推的可靠性。
- 大部分场景和实际实现中通过业务层的ACK确认和重传机制能解决大部分推送过程中消息丢失的情况。
- 通过客户端的去重机制,屏蔽掉重传过程中可能导致消息重复的问题,从而不影响用户体验。
- 针对重传消息不可达的特殊场景,我们还可以通过“兜底”的完整性检查机制来及时发现消息丢失的情况并进行补推修复,消息完整性检查可以通过时间戳比对,或者全局自增序列等方式来实现。
最后,给你留一个思考题:**有了TCP协议本身的ACK机制为什么还需要业务层的ACK机制**
你可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="05 | 消息序号生成器:如何保证你的消息不会乱序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/fa/9c6c12b88f9c1145099de00a155b2cfa.mp3"></audio>
你好,我是袁武林。
前面几节课,我们较为系统地介绍了如何解决消息实时到达的问题,也对保证消息可靠投递实战中常用的方式进行了一一讲解。
那么今天的课程我们继续一起聊一聊IM系统设计中另一个比较复杂但又非常重要的话题消息收发的一致性。需要提醒的是我们这里的讲到的一致性一般来说是指消息的时序一致性。
## 为什么消息的时序一致性很重要?
对于聊天、直播互动等业务来说,消息的时序代表的是发送方的意见表述和接收方的语义逻辑理解,如果时序一致性不能保证,可能就会造成聊天语义不连贯、容易出现曲解和误会。
你可以想象一下,一个人说话颠三倒四,前言不搭后语的样子,就理解我们为什么要尤其注重消息的时序一致性了。
对于点对点的聊天场景,时序一致性需要保证接收方的接收顺序和发送方的发出顺序一致;而对于群组聊天,时序一致性保证的是群里所有接收人看到的消息展现顺序都一样。
## 为什么保证消息的时序一致性很困难?
从理论上来说,保持消息的时序一致性貌似并不难。理论上,我们想象的消息收发场景中,只有单一的发送方、单一的接收方。
如果发送方和接收方的消息收发都是单线程操作并且和IM服务端都只有唯一的一个TCP连接来进行消息传输IM服务端也只有一个线程来处理消息接收和消息推送。这种场景下消息的时序一致性是比较容易能得到保障的。
但在实际的后端工程实现上,由于单发送方、单接收方、单处理线程的模型吞吐量和效率都太低,基本上不太可能存在。
更多的场景下,我们可能需要面对的是多发送方、多接收方、服务端多线程并发处理的情况。所以,知道了难点,我们再来看一看究竟在后端的工程实现上,保证消息的时序一致都存在哪些难点。
消息的时序一致性其实是要求我们的消息具备“时序可比较性”,也就是消息相对某一个共同的“时序基准”可以来进行比较,**所以,要保证消息的时序一致性的一个关键问题是:我们是否能找到这么一个时序基准,使得我们的消息具备“时序可比较性”。**
**在工程实现上,我们可以分成这样几步。**
- 首先是:如何找到时序基准。
- 其次是:时序基准的可用性问题。
- 最后是:有了时序基准,还有其他的误差吗,有什么办法可以减少这些误差?
## 如何找到时序基准?
下面我从消息收发的实际场景来分析一下,收发过程中如何找到一个全局的“时序基准”。在这里,我们来逐一分析一下。
首先,**我们来看看发送方的本地序号和本地时钟是否可以作为“时序基准”?**
这里解释一下所谓发送方的本地序号和本地时钟是指发送方在发送消息时连同消息再携带一个本地的时间戳或者本地维护的一个序号给到IM服务端IM服务端再把这个时间戳或者序号和消息一起发送给消息接收方消息接收方根据这个时间戳或者序号来进行消息的排序。
仔细分析一下,貌似发送方的本地序号或者本地时钟不适合用来作为接收方排序的“时序基准”,原因有下面几点。
<li>
发送方时钟存在较大不稳定因素,用户可以随时调整时钟导致序号回退等问题。
</li>
<li>
发送方本地序号如果重装应用会导致序号清零,也会导致序号回退的问题。
</li>
<li>
类似“群聊消息”和“单用户的多点登录”这种多发送方场景都存在同一时钟的某一时间点都可能有多条消息发给同一接收对象。比如同一个群里多个人同时发言或者同一个用户登录两台设备两台设备同时给某一接收方发消息。多设备间由于存在时钟不同步的问题并不能保证设备带上来的时间是准确的可能存在群里的用户A先发言B后发言但由于用户A的手机时钟比用户B的慢了半分钟如果以这个时间作为“时序基准”来进行排序可能反而导致用户A的发言被认为是晚于用户B的。
</li>
因此以发送方的本地时钟或者本地序号作为“时序基准”是不可靠的。那么,我们接下来看看**IM服务器的本地时钟是否可以作为“时序基准”**
这里也解释一下IM服务器的本地时钟作为“时序基准”是指发送方把消息提交给IM服务器后IM服务器依据自身服务器的时钟生成一个时间戳再把消息推送给接收方时携带这个时间戳接收方依据这个时间戳来进行消息的排序。
我们分析一下好像IM服务器的本地时钟作为接收方消息排序的“时序基准”也不太合适。
因为在实际工程中IM服务都是集群化部署集群化部署也就是许多服务器同时部署任务。
虽然多台服务器通过NTP时间同步服务能降低服务集群机器间的时钟差异到毫秒级别但仍然还是存在一定的时钟误差而且IM服务器规模相对比较大时钟的统一性维护上也比较有挑战整体时钟很难保持极低误差因此一般也不能用IM服务器的本地时钟来作为消息的“时序基准”。
**既然单机本地化的时钟或者序号都存在问题那么如果有一个全局的时钟或者序号是不是就能解决这个问题了呢所有的消息的排序都依托于这个全局的序号这样就不存在时钟不同步的问题了。那么最后我们来看看IM服务端的全局序列是否可以作为“时序基准”**
比如说如果有一个全局递增的序号生成器应该就能避免多服务器时钟不同步的问题了IM服务端就能通过这个序号生成器发出的序号来作为消息排序的“时序基准”。
而且这种“全局序号生成器”可以通过多种方式来实现常见的比如Redis的原子自增命令incrDB自带的自增id或者类似Twitter的snowflake算法、“时间相关”的分布式序号生成服务等。
## “时序基准”的可用性问题
使用“全局序号生成器”发出的序号,来作为消息排序的“时序基准”,能解决每一条消息没有标准“生产日期”的问题。但如果是面向高并发和需要保证高可用的场景,还需要考虑这个“全局序号生成器”的可用性问题。
首先类似Redis的原子自增和DB的自增id都要求在主库上来执行“取号”操作而主库基本都是单点部署在可用性上的保障会相对较差另外针对高并发的取号操作这个单点的主库可能容易出现性能瓶颈。
而采用类似snowflake算法的时间相关的分布式“序号生成器”虽然在发号性能上一般问题不大但也存在一些问题。
一个是发出的号携带的时间精度有限一般能到秒级或者毫秒级比如微博的ID生成器就是精确到秒级的另外由于这种服务大多都是集群化部署携带的时间采用的服务器时间也存在时钟不一致的问题虽然时钟同步上比控制大量的IM服务器也相对容易一些
**由上可知,基于“全局序号生成器”仍然存在不少问题,那这样是不是说基于“全局序号生成器”生成的序号来对消息进行排序的做法不可行呢?**
我们从后端业务实现的角度,来具体分析一下。
从业务层面考虑,对于群聊和多点登录这种场景,没有必要保证全局的跨多个群的绝对时序性,只需要保证某一个群的消息有序即可。
这样的话如果可以针对每一个群有独立一个“ID生成器”能通过哈希规则把压力分散到多个主库实例上大量降低多群共用一个“ID生成器”的并发压力。
对于大部分即时消息业务来说,产品层面可以接受消息时序上存在一定的细微误差,比如同一秒收到同一个群的多条消息,业务上是可以接受这一秒的多条消息,未严格按照“接收时的顺序”来排序的,实际上,这种细微误差对于用户来说,基本也是无感知的。
那么对于依赖“分布式的时间相关的ID生成器”生成的序号来进行排序如果时间精度业务上可以接受也是没问题的。
从之前微信对外的分享,我们可以了解到:微信的聊天和朋友圈的消息时序也是通过一个“递增”的版本号服务来进行实现的。不过这个版本号是每个用户独立空间的,保证递增,不保证连续。
微博的消息箱则是依赖“分布式的时间相关的ID生成器”来对私信、群聊等业务进行排序目前的精度能保证秒间有序。
## “时序基准”之外的其他误差
有了“时序基准”,是不是就能确保消息能按照“既定顺序”到达接收方呢?答案是并不一定能做到。原因在于下面两点。
<li>
IM服务器都是集群化部署每台服务器的机器性能存在差异因此处理效率有差别并不能保证先到的消息一定可以先推送到接收方比如有的服务器处理得慢或者刚好碰到一次GC导致它接收的更早消息反而比其他处理更快的机器更晚推送出去。
</li>
<li>
IM服务端接收到发送方的消息后之后相应的处理一般都是多线程进行处理的比如“取序号”“暂存消息”“查询接收方连接信息”等由于多线程处理流程并不能保证先取到序号的消息能先到达接收方这样的话对于多个接收方看到的消息顺序可能是不一致的。
</li>
所以一般还需要端上能支持对消息的“本地整流”。我们来看一下本地整流。
### 消息服务端包内整流
虽然大部分情况下聊天、直播互动等即时消息业务能接受“小误差的消息乱序”但某些特定场景下可能需要IM服务能保证绝对的时序。
比如发送方的某一个行为同时触发了多条消息,而且这多条消息在业务层面需要严格按照触发的时序来投递。
一个例子用户A给用户B发送最后一条分手消息同时勾上了“取关对方”的选项这个时候可能会同时产生“发消息”和“取关”两条消息如果服务端处理时把“取关”这条信令消息先做了处理就可能导致那条“发出的消息”由于“取关”了发送失败的情况。
对于这种情况我们一般可以调整实现方式在发送方对多个请求进行业务层合并多条消息合并成一条也可以让发送方通过单发送线程和单TCP连接能保证两条消息有序到达。
但即使IM服务端接收时有序由于多线程处理的原因真正处理或者下推时还是可能出现时序错乱的问题解决这种“需要保证多条消息绝对有序性”可以通过IM服务端包内整流来实现。
比如我们在实现离线推送时在网关机启动后会自动订阅一个本IP的Topic当用户上线时网关机会告知业务层用户有上线操作这时业务层会把这个用户的多条离线消息pub给这个用户连接的那个网关机订阅的Topic当网关机收到这些消息后再通过长连接推送给用户整个过程大概是下图这样的。<br>
<img src="https://static001.geekbang.org/resource/image/85/5f/85d6aa4b551a21c34ed6501ecf19445f.png" alt="">
但是很多时候会出现Redis队列组件的Sharding和网关机多线程消费处理导致乱序的情况这样如果一些信令比如删除所有会话的操作被乱序推送给客户端可能就会造成端上的逻辑错误。
然后再说一下离线推送服务端整流的过程:
<img src="https://static001.geekbang.org/resource/image/f9/b8/f97ce351151a86cd7cfb06d7f58513b8.png" alt="">
- 首先生产者为每个消息包生成一个packageID为包内的每条消息加个有序自增的seqId。
- 其次消费者根据每条消息的packageID和seqID进行整流最终执行模块只有在**一定超时时间内完整有序**地收到所有消息才执行最终操作否则将根据业务需要触发重试或者直接放弃操作。通过服务端整流服务端包内整流大概就是图中这个样子我们要做的是在最终服务器取到TCP连接后下推的时候根据包的ID对一定时间内的消息做一个整流和排序这样即使服务端处理多条消息时出现乱序仍然可以在最终推送给客户端时整流为有序的。
### 消息接收端整流
携带不同序号的消息到达接收端后,可能会出现“先产生的消息后到”“后产生的消息先到”等问题,消息接收端的整流就是解决这样的一个问题的。
消息客户端本地整流的方式可以根据具体业务的特点来实现,目前业界比较常见的实现方式比较简单,步骤如下:
<li>
下推消息时,连同消息和序号一起推送给接收方;
</li>
<li>
接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号就将当前消息追加在会话里;
</li>
<li>
否则继续往前查找倒数第二条、第三条等,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。
</li>
## 小结
今天我们讲到了在多发送方、多接收方、服务端多线程并发处理的情况下,保持消息时序一致的重要性及处理方法。
对于聊天、直播互动等即时消息的场景,保持消息的时序一致性能避免发送方的意见表述和接收方的语义理解出现偏差的情况。
对于如何保持消息的时序一致性的关键点在于需要找到一个时序基准来标识每一条消息的顺序。这个时序基准可以通过全局的序号生成器来确定,常见的实现方式包括**支持单调自增序号的资源生成**,或者**分布式时间相关的ID生成服务生成**,两种方式各有一些限制,不过,你都可以根据业务自身的特征来进行选择。
有了通过时序基准确定的消息序号由于IM服务器差异和多线程处理的方式不能保证先服务端的消息一定能先推到接收方可以通过“服务端包内整流”机制来保证需要“严格有序”的批量消息的正确执行或者接收方根据消息序号来进行消息本地整流从而确保多接收方的最终一致性。
最后给你留一道思考题。**在即时消息收发场景中,用于保证消息接收时序的序号生成器为什么可以不是全局递增的?**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,175 @@
<audio id="audio" title="06 | HttpDNS和TLS你的消息聊天真的安全吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/ce/a7527c089049ac30abb2635a08f63fce.mp3"></audio>
你好,我是袁武林。
在开始之前,我们先回顾一下前面几篇的内容。我们陆续讲到了消息实时性、消息投递的可靠性、消息时序一致性在即时系统业务场景中的重要性和难点,以及相应的实现方案。
如果说消息的“实时性”“投递可靠性”“时序一致性”是评价一个即时消息服务可用性和先进性的重要指标那么另一个重要的特性安全性就是一个IM服务能否存在的底线和立命之本。
对于依托即时消息技术之上的各种私密聊天App、轨迹位置服务、远程工控系统等业务对于安全性的需要远高于一般业务。
毕竟,没有人能接受私密的聊天内容被第三方窥探,实时位置的暴露甚至可能带来人身方面的安全风险,而涉及了重要的远程工控操作,如果操作被截获或者篡改,可能会导致严重的工程事故。
那么今天我们就来聊一聊IM系统中会有哪些常见的安全问题针对这些不同安全问题我们分别采用了哪些技术方案来进行应对。限于篇幅对于每个技术点的具体实现过程我们不做深入讨论你只需要了解到具体方案的适用场景就可以了。
## 消息安全性的三个维度
既然对于即时消息服务来说,安全性是如此的重要和不可妥协,那么到底都有哪些环节可能导致消息安全方面的问题呢?
一般来说,从消息的产生和流转的细分上,我们大概从三个维度来描述消息的安全性:消息传输安全性、消息存储安全性、消息内容安全性。
## 如何保证消息传输安全性
我们先来关注一下消息在传输过程中的安全性。
传输安全性这个比较好理解,存在网络交互的即时消息服务,大多需要通过开放网络来进行消息和信令的传输。
可能导致出现安全风险的地方相对也比较多比如DNS劫持会导致发往IM服务的请求被拦截发到其他服务器导致内容泄露或失效或者明文传输的消息内容被中间设备劫取后篡改内容再发往IM服务器引起业务错误等问题。
在消息传输过程中,我们主要关注两个问题:“访问入口安全”和“传输链路安全”,这也是两个基于互联网的即时消息场景下的重要防范点。
### 保证访问入口安全HttpDNS
对于即时消息服务,一般都会提供一个公网的“接入服务”,作为用户消息收发的出入口,并通过域名的方式提供给客户端。对于这个出入口的访问,经常也会由于各种原因导致“访问不了”“地址错误”的问题。
关于访问入口我们比较常见的问题就是DNS劫持。针对接入域名的DNS劫持问题的常见原因有下面两类。
第一类是路由器的DNS设置被非法侵入篡改了。这种问题常见于一些家用宽带路由器由于安全性设置不够比如使用默认密码导致路由器被黑客或者木马修改了DNS设置为恶意的DNS地址这些有问题的DNS服务器会在你访问某些网站时返回仿冒内容或者植入弹窗广告等。
第二类是运营商LocalDNS可能会导致接入域名的解析被劫持。
比如下面三种比较典型的情况。
1. LocalDNS是部分运营商为了降低跨网流量缓存部分域名的指向内容把域名强行指向自己的内容缓存服务器的IP地址。
1. 运营商可能会修改DNS的TTL(Time-To-LiveDNS缓存时间)导致DNS的变更生效延迟影响服务可用性。我们之前一个线上业务域名的TTL在某些省市能达到24小时。
1. 一些小运营商为了减轻自身的资源压力把DNS请求转发给其他运营商去解析这样分配的IP地址可能存在跨运营商访问的问题导致请求变慢甚至不可用。
**那么如何防止DNS劫持呢我们一起来看看。**
**对于宽带路由器的DNS设置被篡改的问题**,一般,我们会重置一下路由器的配置,然后修改默认的路由管理登录密码,基本上都能解决,这里不做细述。
**解决运营商LocalDNS的域名劫持和调度错误**业界比较常用的方案有HttpDNS。HttpDNS绕开了运营商的LocalDNS通过HTTP协议而不是基于UDP的DNS标准协议来直接和DNS服务器交互能有效防止域名被运营商劫持的问题。
而且由于HttpDNS服务器能获取到真实的用户出口IP所以能选择离用户更近的节点进行接入或者一次返回多个接入IP让客户端通过测速等方式选择速度更快的接入IP因此整体上接入调度也更精准。
当然调度精准的另一个前提是HttpDNS服务自身需要有比较全的IP库来支持。目前很多大厂也基本都支持HttpDNS为主运营商LocalDNS为辅的模式了像很多第三方云厂商也提供对外的HttpDNS解析服务。HttpDNS的实现架构如下图
<img src="https://static001.geekbang.org/resource/image/75/73/75ed50f7ba71865a8b68f1bdb376fe73.png" alt="">
这里介绍一下这张图。用户的请求不再通过运营商来查询域名的解析而是通过HTTP独立提供的一个方法来进行查询这个HTTP接口后端再去向权威DNS请求以及去做一个数据的同步。
### 保证传输链路安全TLS传输层加密协议
接下来,我们来看看第二种情况,消息传输链路。对于消息在传输链路中的安全隐患,基本可以总结为以下几种。
1. 中断,攻击者破坏或者切断网络,破坏服务可用性。
1. 截获,攻击者非法窃取传输的消息内容,属于被动攻击。
1. 篡改,攻击者非法篡改传输的消息内容,破坏消息完整性和真实语义。
1. 伪造攻击者伪造正常的通讯消息来模拟正常用户或者模拟IM服务端。
接下来,我们一起来逐一解决这几种隐患。
**关于消息链路中断,我们采取多通道方式进行解决。**
在即时消息系统中对于“中断传输”这种主动攻击破坏服务可用性的行为一般可以采取多通道方式来提升链路可用性比如很多IM系统的实现中如果主链路连接不通或者连接不稳定就会尝试自动切换到failover通道这个failover通道可以是
<li>
从HttpDNS服务返回的多个“接入IP”中选择性进行切换防止某一个“接入IP”的中间链路被破坏。
</li>
<li>
从当前数据传输协议切换到其他传输协议比如从基于UDP协议的QUIC协议切换到基于TCP协议的私有协议或者针对TCP的私有协议提供HTTP Tunnel来对数据进行二次封装微博目前支持这种方式防止某些针对特定协议的中断攻击。
</li>
**关于消息传输过程被截获、篡改、伪造我们则利用私有协议和TLS的技术来进行防控。**
对于消息传输过程中被第三方截获消息内容、消息内容被恶意篡改以及第三方伪造IM服务端或者伪造客户端来获取消息或者执行恶意操作的情况业界也有很多应对策略来进行防护。
- 私有协议
对于采用二进制私有协议的即时消息系统本身由于编码问题天然具备一定的防窃取和防篡改的能力相对于使用JSON、XML、HTML等明文传输系统被第三方截获后在内容破解上相对成本更高因此安全性上会更好一些。
- TLS
消息内容加密传输也能保证被截获后无法获取到消息明文,同样也不能对加密的内容进行篡改,但问题的关键是加密秘钥的协商本身需要较高的安全性保障。
比如双方约定好一个固定秘钥来进行加密,但由于客户端代码被反编译等原因,可能导致秘钥泄露;或者双方在连接建立时再协商好一个临时秘钥,但这个临时秘钥在传输上本身就可能被截获,从而导致后续的密文都能被破解。
另外的问题是,如果有第三方伪装成服务端来和客户端交换秘钥,这样即使后续的传输内容都是加密的也没有办法防止消息的泄露问题。
因此为了解决上面一系列的安全问题业界一般采用TLS协议来对业务数据进行保护TLS巧妙地把“对称加密算法”“非对称加密算法”“秘钥交换算法”“消息认证码算法”“数字签名证书”“CA认证”进行结合有效地解决了消息传输过程中的截获、篡改、伪造问题。
这里我解释一下具体的过程。
1. 非对称加密算法和秘钥交换算法用于保证消息加密的密钥不被破解和泄露。
1. 对称加密算法对消息进行加密,保证业务数据传输过程被截获后无法破解,也无法篡改消息。
1. 数字签名和CA认证能验证证书持有者的公钥有效性防止服务端身份的伪造。
TLS本身相对于原本的TCP三次握手需要更多算法确认、秘钥协商交换、证书验证等环节因此在握手环节会多出1-2个RTT(Round-Trip Time往返时延)所以TLS在连接效率和传输性能上有一定的额外开销。
针对这个问题最新的TLS 1.3版本进行了优化可以支持1-RTT甚至0-RTT的握手环节能较大幅度降低TLS的额外消耗TLS 1.3在2018年8月才定稿最终版本RFC 8446大规模铺开使用还需一定时间像微信早在几年前TLS 1.3的草案阶段就自行实现了“基于TLS1.3的MMTLS协议”来保护消息传输中的安全。关于TLS的细节我就不在这篇中展开了有兴趣的同学可以自行再找资料研究。
## 如何保证消息存储安全性
由于消息漫游和离线消息等业务需要大部分即时消息服务会将消息暂存在IM服务器端的数据库并保留一定的时间对于一些私密的消息内容和用户隐私数据如果出现内部人员非法查询或者数据库被“拖库”可能会导致隐私信息的泄露。
### 账号密码存储安全:“单向散列”算法
针对账号密码的存储安全一般比较多地采用“高强度单向散列算法”比如SHA、MD5算法和每个账号独享的“盐”这里的“盐”是一个很长的随机字符串结合来对密码原文进行加密存储。
“单向散列”算法在非暴力破解下很难从密文反推出密码明文通过“加盐”进一步增加逆向破解的难度。当然如果“密文”和“盐”都被黑客获取到这些方式也只是提升破解成本并不能完全保证密码的安全性。因此还需要综合从网络隔离、DB访问权限、存储分离等多方位综合防治。
### 消息内容存储安全:端到端加密
针对消息内容的存储安全,如果存储在服务端,不管消息内容的明文或者密文都存在泄漏的风险。因此保证消息内容存储安全的最好方式是:
1. 消息内容采用“端到端加密”E2EE中间任何链路环节都不对消息进行解密。
1. 消息内容不在服务端存储。
采用“端到端加密”方式进行通信,除了收发双方外,其他任何中间环节都无法获取消息原文内容,即使是研发者也做不到“破解”并且获取数据,顶多停止这种加密方式。
业界很多聊天软件如WhatsApp、Telegram就采用了“端到端加密”方式来保证消息内容的安全。但国内的大部分即时消息软件如QQ、微信等由于网络安全要求目前暂时还没有采用“端到端加密”。
“端到端加密”之所以更加安全是因为是由于和服务端TLS加密不一样“端到端加密”的通信双方各自生成秘钥对并进行公钥的交换私钥各自保存在本地不给到IM服务端。发送方的消息使用接收方的公钥来进行加密因此即使是IM服务端拿到了加密信息由于没有接收方的私钥也无法解密消息。
## 消息内容安全性
内容安全性主要是指针对消息内容的识别和传播的控制,比如一些恶意的链接通过即时消息下发到直播间或者群,可能会导致点击的用户被引诱到一些钓鱼网站;另外一些反政、淫秽的图片、视频等消息的传播会引起不良的负面影响,需要进行识别处置并避免二次传播。
针对消息内容的安全性一般都依托于第三方的内容识别服务来进行“风险内容”的防范。
比如下面的几种方案:
<li>
建立敏感词库,针对文字内容进行安全识别。
</li>
<li>
依托图片识别技术来对色情图片和视频、广告图片、涉政图片等进行识别处置。
</li>
<li>
使用“语音转文字”和OCR图片文本识别来辅助对图片和语音的进一步挖掘识别。
</li>
<li>
通过爬虫技术来对链接内容进行进一步分析,识别“风险外链”。
</li>
一般来说针对内容安全的识别的方式和途径很多也有很多成熟的第三方SaaS服务可以接入使用。
对于IM服务端来说更多要做的是要建立和“识别”配套的各种惩罚处置机制比如识别到群里有个别人发色情视频或者图片可以联动针对该用户进行“禁言处理”如果一个群里出现多人发违规视频可以针对该群“禁止发多媒体消息”或者进行“解散群”等操作。具体处置可以根据业务需要灵活处理。
## 小结
即时消息中,消息安全性是各种私密社交场景的核心需求,一般可以从三个维度来对安全性进行评价。
<li>
消息传输安全性。“访问入口安全”和“传输链路安全”是基于互联网的即时消息场景下的重要防范点。针对“访问入口安全”可以通过HttpDNS来解决路由器被恶意篡改和运营商的LocalDNS问题而TLS传输层加密协议是保证消息传输过程中不被截获、篡改、伪造的常用手段。
</li>
<li>
消息存储安全性。针对账号密码的存储安全可以通过“高强度单向散列算法”和“加盐”机制来提升加密密码可逆性;对于追求极致安全性的即时消息场景并且政策允许的情况下,服务端应该尽量不存储消息内容,并且采用“端到端加密”方式来提供更加安全的消息传输保护。
</li>
<li>
消息内容安全性。针对消息内容的安全识别可以依托“敏感词库”“图片识别”“OCR和语音转文字”“外链爬虫抓取分析”等多种手段并且配合“联动惩罚处置”来进行风险识别的后置闭环。
</li>
最后给你留一个思考题:**TLS能识别客户端模拟器仿冒用户真实访问的问题吗如果不能有什么其他更好的办法**
你可以给我留言,我们一起解答,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,130 @@
<audio id="audio" title="07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/da/e892156e61931c819550f583f95d32da.mp3"></audio>
你好,我是袁武林。
在前面几节课程中,我着重把即时消息场景中几个核心的特性,进行了较为详细的讲解。在实际用户场景下,除了实时性、可靠性、一致性、安全性这些刚需外,还有很多功能对用户体验的影响也是很大的,比如今天我要讲的“消息未读数”。
消息未读数对用户使用体验影响很大这是因为“未读数”是一种强提醒方式它通过App角标或者App内部Tab的数字标签来告诉用户收到了新的消息。
对于在多个社交App来回切换的重度用户来说基本上都是靠“未读数”来获取新消息事件如果“未读数”不准确会对用户造成不必要的困扰。
比如我们看到某个App有一条“未读消息提醒”点进去事件却没有这种情况对于“强迫症患者”实在属于不可接受或者本来有了新的消息但未读数错误导致没有提醒到用户这种情况可能会导致用户错过一些重要的消息严重降低用户的使用体验。所以从这里我们可以看出“消息未读数”在整个消息触达用户路径中的重要地位。
## 消息和未读不一致的原因
那么在即时消息场景中,究竟会有哪些情况导致消息和未读数出现“不一致”的情况呢?要搞清楚这个问题,我们要先了解两个涉及未读数的概念:“总未读”与“会话未读”。我们分别来看看以下两个概念。
<li>
**会话未读:**当前用户和某一个聊天方的未读消息数。比如用户A收到了用户B的2条消息这时对于用户A来说他和用户B的会话未读就是“2”当用户A打开和用户B的聊天对话页查看这两条消息时对于用户A来说他和用户B的会话未读就变成0了。对于群聊或者直播间来说也是一样的逻辑会话未读的对端只不过是一个群或者一个房间。
</li>
<li>
**总未读:**当前用户的所有未读消息数这个不难理解总未读其实就是所有会话未读的和。比如用户A除了收到用户B的2条消息还收到了用户C的3条消息。那么对于用户A来说总未读就是“5”。如果用户查看了用户B发给他的2条消息这时用户A的总未读就变成了“3”。
</li>
从上面的概念我们知道,实际上总未读数就是所有会话未读数的总和,那么,在实现上是不是只需要给每个用户维护一套会话未读就可以了呢?
### 会话未读和总未读单独维护
理论上是可以的。但在很多即时消息的“未读数”实现中,会话未读数和总未读数一般都是单独维护的。
原因在于“总未读”在很多业务场景里会被高频使用,比如每次消息推送需要把总未读带上用于角标未读展示。
另外有些App内会通过定时轮询的方式来同步客户端和服务端的总未读数比如微博的消息栏总未读不仅包括即时消息相关的消息数还包括其他一些业务通知的未读数所以通过消息推送到达后的累加来计算总未读并不是很准确而是换了另外一种方式通过轮询来同步总未读。
对于高频使用的“总未读”,如果每次都通过聚合所有会话未读来获取,用户的互动会话不多的话,性能还可以保证;一旦会话数比较多,由于需要多次从存储获取,容易出现某些会话未读由于超时等原因没取到,导致总未读数计算少了。
而且,多次获取累加的操作在性能上比较容易出现瓶颈。所以,出于以上考虑,总未读数和会话未读数一般是单独维护的。
### 未读数的一致性问题
单独维护总未读和会话未读能解决总未读被“高频”访问的性能问题,但同时也会带来新的问题:未读数的一致性。
未读数一致性是指:**维护的总未读数和会话未读数的总和要保持一致**。如果两个未读数不能保持一致就会出现“收到新消息但角标和App里的消息栏没有未读提醒”或者“有未读提醒点进去找不到是哪个会话有新消息”的情况。
这两种异常情况都是我们不愿意看到的。那么这些异常情况究竟是怎么出现的呢?
**我们来看看案例,我们先来看看第一个:**
<img src="https://static001.geekbang.org/resource/image/1c/c5/1c65a1af8589c1b4f6e84173c6bca1c5.png" alt="">
用户A给用户B发送消息用户B的初始未读状态是和用户A的会话未读是0总未读也是0。
消息到达IM服务后执行加未读操作先把用户B和用户A的会话未读加1再把用户B的总未读加1。
假设加未读操作第一步成功了第二步失败。最后IM服务把消息推送给用户B。这个时候用户B的未读状态是和用户A的会话未读是1总未读是0。
这样由于加未读第二步执行失败导致的后果是用户B不知道收到了一条新消息的情况从而可能漏掉查看这条消息。
那么案例是由于在加未读的第二步“加总未读”的时候出现异常,导致未读和消息不一致的情况。
那么,是不是只要加未读操作都正常执行就没有问题了呢?**接下来,我们再看下第二个案例。**
<img src="https://static001.geekbang.org/resource/image/fa/42/fa303ab3ddf0aba4baf2e0630a8a5542.png" alt="">
用户A给用户B发送消息用户B的初始未读状态是和用户A的会话未读是0总未读也是0。
消息到达IM服务后执行加未读操作先执行加未读的第一步把用户B和用户A的会话未读加1。
这时执行加未读操作的服务器由于某些原因变慢了恰好这时用户B在App上点击查看和用户A的聊天会话从而触发了清未读操作。
执行清未读第一步把用户B和用户A的会话未读清0然后继续执行清未读第二步把用户B的总未读也清0。
清未读的操作都执行完之后执行加未读操作的服务器才继续恢复执行加未读的第二步把用户B的总未读加1那么这个时候就出现了两个未读不一致的情况。
导致的后果是用户B退出会话后看到有一条未读消息但是点进去却找不到是哪个聊天会话有未读消息。
**这里,我来分析一下这两种不一致的案例原因:其实都是因为两个未读的变更不是原子性的,会出现某一个成功另一个失败的情况,也会出现由于并发更新导致操作被覆盖的情况。所以要解决这些问题,需要保证两个未读更新操作的原子性。**
## 保证未读更新的原子性
那么,在分布式场景下,如何保证两个未读的“原子更新”呢?一个比较常见的方案是使用一个分布式锁来解决,每次修改前先加锁,都变更完后再解开。
### 分布式锁
分布式锁的实现有很多比如依赖DB的唯一性、约束来通过某一条固定记录的插入成功与否来判断锁的获取。也可以通过一些分布式缓存来实现比如MC的add、比如Redis的setNX等。具体实现机制我这里就不细讲了在我们的实战课程中我们会有相应的代码体现。
不过要注意的是分布式锁也存在它自己的问题。由于需要增加一套新的资源访问逻辑锁的引入会降低吞吐同时对锁的管理和异常的处理容易出现Bug比如需要资源的单点问题、需要考虑宕机情况下如何保证锁最终能释放。
### 支持事务功能的资源
除了分布式锁外,还可以通过一些支持事务功能的资源,来保证两个未读的更新原子性。
事务提供了一种“将多个命令打包,然后一次性按顺序地执行”的机制,并且事务在执行的期间不会主动中断,服务器在执行完事务中的所有命令之后,才会继续处理其他客户端的其他命令。
比如Redis通过 MULTI、DISCARD 、EXEC和WATCH四个命令来支持事务操作。
比如每次变更未读前先watch要修改的key然后事务执行变更会话未读和变更总未读的操作如果在最终执行事务时被watch的两个未读的key的值已经被修改过那么本次事务会失败业务层还可以继续重试直到事务变更成功。
依托Redis这种支持事务功能的资源如果未读数本身就存在这个资源里是能比较简单地做到两个未读数“原子变更”的。
但这个方案在性能上还是存在一定的问题由于watch操作实际是一个乐观锁策略对于未读变更较频繁的场景下比如一个很火的群里大家发言很频繁可能需要多次重试才可以最终执行成功这种情况下执行效率低性能上也会比较差。
### 原子化嵌入脚本
那么有没有性能不错还能支持“原子变更”的方案呢?
其实在很多资源的特性中都支持“原子化的嵌入脚本”来满足业务上对多条记录变更高一致性的需求。Redis就支持通过嵌入Lua脚本来原子化执行多条语句利用这个特性我们就可以在Lua脚本中实现总未读和会话未读的原子化变更而且还能实现一些比较复杂的未读变更逻辑。
比如有的未读数我们不希望一直存在而干扰到用户如果用户7天没有查看清除未读这个未读可以过期失效这种业务逻辑就比较方便地使用Lua脚本来实现“读时判断过期并清除”。
原子化嵌入脚本不仅可以在实现复杂业务逻辑的基础上来提供原子化的保障相对于前面分布式锁和watch事务的方案在执行性能上也更胜一筹。
不过这里要注意的是由于Redis本身是服务端单线程模型Lua脚本中尽量不要有远程访问和其他耗时的操作以免长时间悬挂Hang导致整个资源不可用。
## 小结
本节课我们先了解了未读数在即时消息场景中的重要性,然后分析了造成未读数和消息不一致的原因,原因主要在于:“总未读数”和“会话未读数”在大部分业务场景中需要能够独立维护,但两个未读数的变更存在成功率不一致和并发场景下互相覆盖的情况。
接下来我们探讨了几种保证未读数原子化变更的方案,以及深入分析了每种方案各自的优劣,三种方案分别为:
- **分布式锁**,具备较好普适性,但执行效率较差,锁的管理也比较复杂,适用于较小规模的即时消息场景;
- **支持事务功能的资源**不需要额外的维护锁的资源实现较为简单但基于乐观锁的watch机制在较高并发场景下失败率较高执行效率比较容易出现瓶颈
- **原子化嵌入脚本**,不需要额外的维护锁的资源,高并发场景下性能也较好,嵌入脚本的开发需要一些额外的学习成本。
这一篇我们讲到的内容,简单来看只是消息未读数不一致的场景,但是,如果我们站在宏观视角下,不难看出在分布式场景下,这其实是一个并发更新的问题。
不管是分布式锁、还是支持事务功能的资源,以及我们最后讲到的原子化的嵌入脚本,其实不仅仅可以用来解决未读数的问题,对于通用的分布式场景下涉及的需要保证多个操作的原子性或者事务性的时候,这些都是可以作为可选方案来考虑的。
最后给你留一个思考题:**类似Redis+Lua的原子化嵌入脚本的方式是否真的能够做到“万无一失”的变更一致性比如执行过程中机器掉电会出现问题吗**
你可以给我留言,我们一起讨论,感谢你的收听,我们下次再见。

View File

@@ -0,0 +1,130 @@
<audio id="audio" title="08 | 智能心跳机制:解决网络的不确定性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/0e/dd0ad90e41342399fbced685e0fe1c0e.mp3"></audio>
你好,我是袁武林。
在前面的章节里,我讲到了在即时消息场景中非常重要的两个特性:“可靠投递”和“实时性”。
为了让消息能更加实时、可靠、快速地触达到接收方大部分IM系统会通过“长连接”的方式来建立收发双方的通信通道这些基于TCP长连接的通信协议在用户上线连接时会在服务端维护好连接到服务器的用户设备和具体TCP连接的映射关系通过这种方式服务端也能通过这个映射关系随时找到对应在线的用户的客户端而且这个长连接一旦建立就一直存在除非网络被中断。
因为“长连接”方式相比“短连接轮询”,不仅能节约不必要的资源开销,最重要的是能够通过“服务端推送”,提供更加实时的消息下发。
同样对于发送方来说如果发送消息也能通过“长连接”通道把消息给到IM服务端相对于短连接方式也能省略TCP握手和TLS握手的几个RTT的时间开销在用户体验和实时性上也会更好。
## 为什么需要心跳机制
“长连接”方式给我们带来了众多好处,那么要让消息通过“长连接”实现可靠投递,最重要的环节就在于如何维护好这个“长连接”。
由于这个“长连接”底层使用的TCP连接并不是一个真正存在的物理连接实际上只是一个无感知的虚拟连接中间链路的断开连接的两端不会感知到因此维护好这个“长连接”一个关键的问题在于能够让这个“长连接”能够在中间链路出现问题时让连接的两端能快速得到通知然后通过“重连”来重新建立新的可用连接从而让我们这个“长连接”一直保持“高可用”状态。
这个能够“快速”“不间断”识别连接可用性的机制,被称为“心跳机制”。“心跳机制”通过持续地往连接上发送“模拟数据”来试探连接的可用性,同时也让我们的连接在没有真正业务数据收发的时候,也持续有数据流通,而不会被中间的网络运营商以为连接已经没有在使用而把连接切断。
下面,我会从两个方面带你详细了解一下心跳机制在长连接维护中的必要性。
### 降低服务端连接维护的开销
首先心跳机制可以让IM服务端能尽快感知到连接的变化从而尽早清理服务端维护连接使用的资源。
对于大部分即时通讯场景,消息收发双方经常处于移动网络环境中,手机信号强弱变化及中间路由故障等,都可能导致“长连接”实际处于不可用状态。
比如用户拿着手机进电梯了手机网络信号忽然完全没了长连接此时已经不可用但IM服务端无法感知到这个“连接不可用”的情况另外假如我们上网的路由器忽然掉线了之前App和IM服务端建立的长连接此时实际也处于不可用状态但是客户端和IM服务器也都无法感知。
我在前面讲过之所以能够实现消息的“服务端推送”是因为我们针对每一台上线的设备都会在IM服务端维护相应的“用户设备”和“网络连接”这么一个映射关系除此之外很多时候为了节省网络开销还会在服务端临时缓存一些没必要每次请求都携带的客户端的信息比如app版本号、操作系统、网络状态等这样客户端一旦建好长连后只需要首次携带这些信息后续请求可以不用再携带而是使用IM服务端缓存的这些信息。另外在很多IM的实现上还会在服务端维护一些“用户在线状态”和“所有在线设备”这些信息便于业务使用。
如果IM服务端无法感知到这些连接的异常情况会导致的一个问题是IM服务端可能维护了大量“无效的连接”从而导致严重的连接句柄的资源浪费同时也会缓存了大量实际上已经没有用了的“映射关系”“设备信息”“在线状态”等信息也是对资源的浪费另外IM服务端在往“无效长连接”推送消息以及后续的重试推送都会降低服务的整体性能。
### 支持客户端断线重连
通过“心跳”快速识别连接的可用性,除了可以降低服务端的资源开销,也被用于支撑客户端的断开重连机制。
对于客户端发出心跳包如果在一定的超时时间内考虑到网络传输具有一定的延迟性这个超时时间至少要大于一个心跳的间隔比如连续两次发送心跳包都没有收到IM服务端的响应那么客户端可以认为和服务端的长连接不可用这时客户端可以断线重连。
导致服务端没有响应的原因可能是和服务端的网络在中间环节被断开,也可能是服务器负载过高无法响应心跳包,不管什么情况,这种场景下断线重连是很有必要的,它能够让客户端快速自动维护连接的可用性。
### 连接保活
维护一条“高可用”的长连接,还有一个重要的任务就是**尽量让建立的长连接存活时间更长**。
这里你可能会问:难道在用户网络和中间路由网络都正常的情况下,长连接还可能会被杀死?
答案是:确实会。
探究这个原因的话我可能要从IPv4说起。由于IPv4的公网IP的资源有限性约43亿个为了节省公网IP的使用通过移动运营商上网的手机实际上只是分配了一个运营商内网的IP。
在访问Internet时运营商网关通过一个“外网IP和端口”到“内网IP和端口”的双向映射表来让实际使用内网IP的手机能和外网互通这个网络地址的转换过程叫做NATNetwork Address Translation
NAT本身的实现机制并没有什么不妥问题在于很多运营商为了节省资源和降低自身网关的压力对于一段时间没有数据收发的连接运营商会将它们从NAT映射表中清除掉而且这个清除动作也不会被手机端和IM服务端感知到。
这样的话如果没有了NAT映射关系长连接上的消息收发都无法正常进行。而且多长时间会从NAT映射表清除每个地方的运营商也是不尽相同从几分钟到几小时都有。假设用户有几分钟没有收发消息可能这个长连接就已经处于不可用状态了。
那么如果我们的客户端能在没有消息收发的空闲时间给服务端发送一些信令就能避免长连接被运营商NAT干掉了这些“信令”一般就是通过心跳包来实现。
## 心跳检测的几种实现方式
介绍完了心跳机制的重要性我们来学习一下如何去实现心跳检测。目前业界有三种常用的实现方法TCP Keepalive、应用层心跳及智能心跳。下面我们分别来看一看。
### TCP Keepalive
TCP的Keepalive作为操作系统的TCP/IP协议栈实现的一部分对于本机的TCP连接会在连接空闲期按一定的频次自动发送不携带数据的探测报文来探测对方是否存活。操作系统默认是关闭这个特性的需要由应用层来开启。
默认的三个配置项心跳周期是2小时失败后再重试9次超时时间75s。三个配置项均可以调整。
这样来看TCP的Keepalive作为系统层TCP/IP协议栈的已有实现不需要其他开发工作量用来作为连接存活与否的探测机制是非常方便的上层应用只需要处理探测后的连接异常情况即可而且心跳包不携带数据带宽资源的浪费也是最少的。
由于易用性好、网络消耗小等优势TCP Keepalive在很多IM系统中被开启使用之前抓包就发现WhatsApps使用空闲期10秒间隔的TCP Keepalive来进行存活探测。
虽然拥有众多优势但TCP Keepalive本身还是存在一些缺陷的比如心跳间隔灵活性较差一台服务器某一时间只能调整为固定间隔的心跳另外TCP Keepalive虽然能够用于连接层存活的探测但并不代表真正的应用层处于可用状态。
我举一个例子比如IM系统出现代码死锁、阻塞的情况下实际上已经无法处理业务请求了但此时连接层TCP Keepalive的探针不需要应用层参与仍然能够在内核层正常响应。这种情况就会导致探测的误判让已失去业务处理能力的机器不能被及时发现。
### 应用层心跳
为了解决TCP Keepalive存在的一些不足的问题很多IM服务使用应用层心跳来提升探测的灵活性和准确性。**应用层心跳实际上就是客户端每隔一定时间间隔向IM服务端发送一个业务层的数据包告知自身存活。**
如果IM服务端在一定时间内没有收到心跳包就认定客户端由于某种原因连接不可达了此时就会从IM服务端把这个连接断开同时清除相应分配的其他资源。
应用层心跳和TCP Keepalive心跳相比由于不属于TCP/IP协议栈的实现因此会有一些额外的数据传输开销但是大部分应用层心跳的设计上心跳包都尽量精简一般就几个字节比如有些应用层心跳包只是一个空包用于保活有的心跳包只是携带了心跳间隔用于客户端调整下一次的心跳所以额外的数据开销都非常小。
应用层心跳相比TCP Keepalive由于需要在应用层进行发送和接收的处理因此更能反映应用的可用性而不是仅仅代表网络可用。
而且应用层心跳可以根据实际网络的情况来灵活设置心跳间隔对于国内运营商NAT超时混乱的实际情况下灵活可设置的心跳间隔在节省网络流量和保活层面优势更明显。
目前大部分IM都采用了应用层心跳方案来解决连接保活和可用性探测的问题。比如之前抓包中发现WhatApps的应用层心跳间隔有30秒和1分钟微信的应用层心跳间隔大部分情况是4分半钟目前微博长连接采用的是2分钟的心跳间隔。
每种IM客户端发送心跳策略也都不一样最简单的就是按照固定频率发送心跳包不管连接是否处于空闲状态。之前抓手机QQ的包就发现App大概按照45s的频率固定发心跳还有稍微复杂的策略是客户端在发送数据空闲后才发送心跳包这种相比较对流量节省更好但实现上略微复杂一些。
下面是一个典型的应用层心跳的客户端和服务端的处理流程图,从图中可以看出客户端和服务端,各自通过心跳机制来实现“断线重连”和“资源清理”。
<img src="https://static001.geekbang.org/resource/image/bc/01/bcb269cadc8b781b56c35071e316e301.png" alt="">
需要注意的是:对于客户端来说,判断连接是否空闲的时间是既定的心跳间隔时间,而对于服务端来说,考虑到网络数据传输有一定的延迟,因此判断连接是否空闲的超时时间需要大于心跳间隔时间,这样能避免由于网络传输延迟导致连接可用性的误判。
### 智能心跳
在国内移动网络场景下各个地方运营商在不同的网络类型下NAT超时的时间差异性很大。采用固定频率的应用层心跳在实现上虽然相对较为简单但为了避免NAT超时只能将心跳间隔设置为小于所有网络环境下NAT超时的最短时间虽然也能解决问题但对于设备CPU、电量、网络流量的资源无法做到最大程度的节约。
为了优化这个现象很多即时通讯场景会采用“智能心跳”的方案来平衡“NAT超时”和“设备资源节约”。所谓智能心跳就是让心跳间隔能够根据网络环境来自动调整通过不断自动调整心跳间隔的方式逐步逼近NAT超时临界点在保证NAT不超时的情况下尽量节约设备资源。据说微信就采用了智能心跳方案来优化心跳间隔。
不过从个人角度看随着目前移动资费的大幅降低手机端硬件设备条件也越来越好智能心跳对于设备资源的节约效果有限。而且智能心跳方案在确认NAT超时临界点的过程中需要不断尝试可能也会从一定程度上降低“超时确认阶段”连接的可用性因此我建议你可以根据自身业务场景的需要来权衡必要性。
## 小结
简单回顾一下今天的内容:为了保证消息下发的实时性,很多即时通讯场景使用“长连接”来降低每次建立连接消耗的时间,同时避免了“短连接轮询”带来的不必要的资源浪费。
但是由于移动网络环境错综复杂网络状态变化、中间链路断开、运营商NAT超时都可能导致这个“长连接”处于不可用状态而且收发双发无法感知到。
通过客户端和IM服务端建立的“心跳机制”可以快速自动识别连接是否可用同时避免运营商NAT超时被断开的情况。“心跳机制”解决了以下三方面的问题
- **降低服务端连接维护无效连接的开销。**
- **支持客户端快速识别无效连接,自动断线重连。**
- **连接保活避免被运营商NAT超时断开。**
心跳探测的实现业界大部分综合采用以下两种方式:
- **TCP Keepalive。**操作系统TCP/IP协议栈自带无需二次开发使用简单不携带数据网络流量消耗少。但存在灵活性不够和无法判断应用层是否可用的缺陷。
- **应用层心跳。**应用自己实现心跳机制需要一定的代码开发量网络流量消耗稍微多一点但心跳间隔的灵活性好配合智能心跳机制可以做到“保证NAT不超时的情况下最大化节约设备资源消耗”同时也能更精确反馈应用层的真实可用性。
最后给大家留一道思考题:
**心跳机制中可以结合TCP的keepalive和应用层心跳来一起使用吗**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。