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,129 @@
<audio id="audio" title="13 | HTTP Tunnel复杂网络下消息通道高可用设计的思考" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/97/03f4b000044bae5ae487178309b2e497.mp3"></audio>
你好,我是袁武林。
在[第1讲“架构与特性一个完整的IM系统是怎样的](https://time.geekbang.org/column/article/127872)中,我有讲到即时消息系统中非常重要的几个特性:实时性、可靠性、一致性、安全性。实际上,这些特性的实现大部分依赖于通道层的稳定和高可用。
对于即时消息系统来说,消息的通道主要承载两部分流量:一部分是用户发出的消息或者触发的行为,我们称为**上行消息**;一部分是服务端主动下推的消息和信令,我们称为**下行消息**。
由此可见,消息通道如果不稳定,一来会影响用户发送消息的成功率和体验,二来也会影响消息的下推,导致用户没法实时收到消息。
那么,在面对如何保障消息通道的高可用这一问题时,业界有哪些比较常用的优化手段呢?
## 让消息通道能连得上
要保障消息通道的高可用最基本的是要让通道能随时连得上。不过你可能会觉得这看起来好像挺简单的不就是申请个外网虚拟IP把接入层服务器挂上去然后通过域名暴露出去就行了吗
但实际上这个“连得上”有时真正做起来却不是那么容易的主要原因在于用户的网络情况复杂性高。比如有的用户走2G网络来连有的通过HTTP代理来连还有的会出现DNS解析服务被封的情况诸如此类。
此外,移动运营商各种比较奇怪的限制也会导致连通性不佳的问题。因此,要想你的通道能让用户随时都连得上,还需要做一些额外的优化。
### 多端口访问
首先就是端口的连通性问题。
计算机端口范围是0 ~ 65535主要分成三大类公认端口0 ~ 1023、注册端口1024 ~ 49151、动态或私有端口49152 ~ 65535
虽然理论上大部分公认端口和注册端口都可以在外网暴露,但实际上,由于移动网管代理的端口限制,以及一些网管软件为了控制安全风险,只允许访问某些端口,因此大部分端口都存在连通性的风险。
目前业界确认比较安全的端口基本上只有80、8080、443、14000这几个。因此如果开发一个外网服务我们应当尽量选用这几个端口来对外进行暴露确保可连通性。
此外还可以通过同时暴露这几个端口中的某几个来进一步提升可连通性。当其中一个端口出现连通性问题时另外的端口还可以作为Failover端口当作备用端口来连接。
### HTTP Tunnel
除了端口上的连通性问题,由于防火墙访问规则限制的问题,或者通过某些代理上网的用户,还存在某些协议不支持的问题。
比如一些公司或者酒店的网络代理只允许通过HTTP协议访问外网PS早期通过CMWAP接入点上网也有这个限制不过在2011年后随着CMWAP和CMNET的接入点融合这个问题得到了解决这样即时消息系统中通过TCP或者UDP实现的私有协议就没法通过网络代理的校验也会导致连不上的问题。
这种场景我们可以通过HTTP Tunnel的方式来对网络代理进行穿透。
所谓HTTP Tunnel其实就是通过HTTP协议来封装其他由于网络原因不兼容的协议比如TCP私有协议
这样不仅解决了网络协议连通性问题而且因为HTTP Tunnel也只是在原来的私有协议内容最外层做了最轻量的HTTP封装HTTP Body内容就是二进制的私有协议所以协议解析时也基本没有额外的代价。
### 多接入点IP列表
在[第6讲“HttpDNS和TLS你的消息聊天真的安全吗](https://time.geekbang.org/column/article/132434)中讲消息通道安全性的时候我就有提到通过HttpDNS能解决DNS劫持的问题。
其实借助HttpDNS我们还能通过返回多个接入点IP来解决连通性的问题后续一个连接失败就尝试下一个这样就相当于给我们提供了一个接入点的Failover机制。
同时为了防止通过HTTP请求DNS时出现失败或者超时的问题我们还可以在客户端进行接入点的预埋。
比如预埋一个域名和几个常用的接入点IP用这个作为请求接入最后的兜底策略。当然这些预埋的域名和接入点IP一般需要尽量保证稳定性如果有变动需要及时预埋到新版App中。
## 让消息通道连得快
解决了通道连得上的问题,接下来我们需要考虑的就是怎么让接入方能连得快。这里有两种实现方式:一个是通过解决跨网延迟来避免通道连接过慢,另一个可以通过跑马竞速来选择速度最快的通道进行接入。
### 解决跨网延迟
同样在[第6讲](https://time.geekbang.org/column/article/132434)中有提到由于运营商跨网延迟的问题我们希望能尽量让某一个运营商的用户通过托管在接入了这个运营商专线的机房接入点来接入网络。因此要让用户连得快首先要求我们需要有多运营商机房的接入点其次要避免运营商DNS解析转发和NAT导致接入IP被解析到其他运营商的问题。
第一个多运营商机房的要求比较好实现基本只是成本方面的投入目前很多IDC机房都支持多线运营商接入。
第二个问题我们可以通过之前讲到的HttpDNS来解决。HttpDNS能直接获取到用户的出口网关IP调度更精准而且绕过了运营商的LocalDNS不会出现DNS解析转发导致错误调度的问题。
### 跑马竞速
除了避免跨网导致通道连接慢的问题之外对于返回的多个接入点IP实际上由于用户上网地点不同和路由规则不同等原因连接接入点IP时延迟也是不一样的。
因此我们还可以通过跑马竞速的方式来动态调整每一个用户优先连接的接入点IP。所谓的“跑马竞速”你可以理解为类似赛马一样我们一次放出多匹马参与比赛最终跑得最快的马胜出。
App终端会对返回的接入点IP列表中的所有IP进行跑马测试并将测速结果上报给服务端服务端根据这个测速结果结合后端接入服务器的负载来动态调整接入点IP列表的顺序让用户优先选用速度更快的接入点。
这里我举一个简单的接入点跑马竞速实现的例子客户端在启动时通过HttpDNS服务获取到多个接入点VIP1、VIP2和VIP3此时客户端针对这3个VIP进行并发测速一般可以通过访问一个固定大小的静态页面来实现根据每个VIP的整体响应耗时来决定后续正式的连接使用哪个VIP。这里由于VIP2响应耗时最少最后客户端会选择使用VIP2来进行接入。这个过程你可以参考下图。
<img src="https://static001.geekbang.org/resource/image/09/8a/0968508d66102a8a495a407ca54a118a.png" alt="">
## 让消息通道保持稳定
解决了消息通道连得上和连得快的问题,另一个提高消息通道可用性的重要手段是让通道能尽量保持稳定。那么,都有哪些因素会导致消息通道不稳定呢?
### 通道和业务解耦
我们知道,对于通道中收发的消息会进行很多业务逻辑的操作,比如消息存储、加未读、版本兼容逻辑等。随着需求的不断迭代和新功能的增加,可能还会新增业务协议或者修改原有业务协议的字段,这些变更都是紧随业务变化的,相对会比较频繁。
但是在即时消息系统中,消息的收发是严重依赖长连接通道的,如果我们的通道层需要跟随业务的变化而不断调整,那么就会导致通道服务也需要频繁地上线、重启。这些操作会让已经连到通道机器的用户连接断开,虽然客户端一般都会有断线重连的机制,但是频繁地断连也会降低消息收发的成功率和用户体验。
因此,要提高消息通道的稳定性,我们要从架构上对通道层进行业务解耦,通道层只负责网络连接管理和通用的逻辑处理。
比如用户和连接的映射维护、通信协议的编解码、建连和断连逻辑处理、ACK包和心跳包处理等将变化较大的业务逻辑下沉到后端的业务处理层。这样不管业务怎么变动我们的通道网关服务都不需要跟着变更稳定性也会更好。
### 上下行通道隔离
除了让通道层和业务隔离,面对消息下推压力比较大的场景,还可以对上下行通道进行拆分隔离。
比如对于直播互动的场景,下推消息由于扇出大,因此当遇到大型直播的时候下行通道的压力会加大,虽然可以通过限流、降级、扩容等方式来缓解,但在这时,系统的整体负载和流量都是比较大的。
这种场景下,我们可以对上下行通道进行拆分隔离,用户上行的消息和行为通过一个短连通道发送到服务端。这样既能避免客户端维护多个长连接的开销,也能解决上行通道被下推消息影响的问题。
想要做进一步的优化我们还可以让这个短连接不是每一次发送完就断开而是支持一定时间的空闲而不断开比如2分钟这样对于用户连续发消息的情况不需要每次再有重新建连的开销用户体验也会更好一些。
下面画了一个图来简单描述下如何对上下行通道进行拆分隔离:
<img src="https://static001.geekbang.org/resource/image/4c/b4/4c32594630ae9c927adbc9cbb54aa6b4.png" alt="">
用户A和用户B分别都通过接入查询服务来获取最优接入点用户A通过上行通道的短连接网关来发送消息发送的消息在上行业务处理服务进行存储、加未读等业务操作然后通过消息队列把这条消息给到下行通道下行分发逻辑服务查询用户B的在线状态等信息并对消息进行必要的推送准备处理比如版本兼容处理接着把消息给到用户B的长连接所在的长连网关机器长连网关机器再将消息推送到用户B的设备中。
这样,我们的上下行通道就通过消息队列的方式进行了隔离和解耦。
### 独立多媒体上传下载
对于图片、视频等多媒体消息由于数据传输量一般都比较大如果也和普通文本消息收发的通道放在一条连接里可能会导致消息收发通道出现阻塞因此我们一般会开辟新的连接通道来传输二进制的文件流。这种优化方式除了能保护消息收发的核心通道也能缩短上传下载的链路提高媒体消息收发的性能。针对多媒体消息的整体上传下载的优化我们在接下来的14和15篇中会详细讲解这里先不做展开了。
## 小结
我们简单回顾一下今天的课程内容。这节课我介绍了一下消息通道在复杂网络情况下,会出现连通性、延迟问题,以及在连接稳定性等方面容易出现连不上、速度慢、连接不稳定的问题,通过分析这些问题出现的具体原因,有针对性地提出了解决这些问题的办法。
- **在解决“通道连不上”的问题上,**我们可以通过暴露多个业界验证过比较安全的连接端口来解决端口连通性问题通过HTTP Tunnel来解决某些网络情况下只允许HTTP协议的数据传输的问题通过HttpDNS和客户端预埋的方式提供多个可选的通道接入点让某些接入点在连不上时还能尝试其他接入点。
- **在解决“通道连接慢”的问题上,**我们可以通过支持多运营商机房接入点,来避免用户的跨运营商网络访问;此外,对于提供的多接入点,客户端还可以通过“跑马竞速”的方式优先使用连接速度更快的接入点来访问。
- **在解决“通道不稳定”的问题上,**我们主要从服务端的架构设计着手,让我们的通道层服务和变化频繁的业务进行解耦,避免业务频繁变动导致通道层服务不稳定;对于消息下行通道压力大的业务场景,还可以隔离消息上下行通道,避免消息的上行被压力大的下行通道所影响;另外,将多媒体的上传下载通道和消息收发的核心通道进行隔离,避免传输量大的多媒体消息造成通道的阻塞,影响消息收发。
面对复杂的移动网络场景,由于不可控因素实在太多,稍不注意我们就容易踩到这样或者那样的坑。比如,我以前的业务里,曾经就出现过由于对外暴露的接入端口不是常见端口,导致很多用户连接不上的情况。但是,通过逐步的摸索和踩坑,也积累了针对移动网络复杂环境下的诸多经验,希望这些经验能够帮助你以后尽量避免出现同样的问题。
最后,给你留一道思考题:**上下行通道隔离能够隔离保护我们的消息接收和消息发送,那么通道隔离会不会带来一些负面影响呢?**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,123 @@
<audio id="audio" title="14 | 分片上传:如何让你的图片、音视频消息发送得更快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/55/7e697a97f23452f2056f58e116634755.mp3"></audio>
你好,我是袁武林。
在前面几节课中,我基本上都是从通用文本消息的角度出发,较为深入地分析讲解了即时消息相关的一些重要特性及核心概念。
随着网络环境的大幅改善及网络资费的显著降低,在很多即时消息场景下,人们的互动不再局限于传统的文本消息,越来越多的用户通过图片、语音、视频等丰富的多媒体消息来完成互动。相较于文本消息而言,多媒体消息在易用性和情感表达上更有优势。
但是多媒体消息相对也会大很多。比如一条文本消息只有不到100字节但一条视频消息可能超过100MB。因此多媒体消息在网络传输、实时触达等方面相对更难一些。
在IM场景中针对图片、语音、视频的优化一直都是一个需要长期投入和突破的重点。今天我们就来看一看针对多媒体消息的优化手段都有哪些。由于篇幅原因我会分成两篇分别从发送和播放两个角度来谈一谈。
## 让图片和视频发送得又快又稳
要想让图片、视频、语音等多媒体消息发送得又快又稳,我们可以从“多上传接入点”“优化上传链路”“分片先行下推”“分片上传”等几种优化方式上着手。下面我分别来详细讲解一下。
### 多上传接入点
先来看一下,我们针对多媒体消息上传的第一种优化手段。国内目前的固网宽带运营商构成复杂,且用户宽带向来呈现出“南电信北联通”的分布现状。而在移动网络下,移动、电信、联通三足鼎立,再加上还有教育网和海外两大网络体系,整体网络结构更加复杂,跨运营商网络访问的高延迟和不稳定性一直是个无法解决的老大难问题。
对于传输数据更多的视频、图片等多媒体消息来说如果出现用户跨运营商来上传文件多次RTTRound-Trip-Time往返时延带来的用户体验会更差。因此要提升多媒体消息的上传性能和成功率我们第一步就要从接入点着手。
传统的优化方式是针对不同的主流运营商提供不同的上传接入点IP然后通过运营商DNS解析让用户能通过本运营商的上传接入点来快速上传图片和视频相应后端的图片上传存储服务也可以部署在多线机房这样上传服务也能快速地把文件流提交给存储层从而避免从接入点到存储服务的跨网开销也能解决其他运营商的用户下载图片时需要跨网的问题。
对于拥有多机房的公司也可以只把上传存储服务部署在单线机房然后再通过专线解决多个单线机房之间的访问。比如目前微博消息箱图片的上传就是采用这种网络访问架构。大概IDC网络架构如下图<br>
<img src="https://static001.geekbang.org/resource/image/f1/cd/f1d6357ba94c4fa570ea08c4f20b79cd.png" alt="">
不过虽然多个运营商接入点IP能解决跨运营商网络访问的问题但是多个接入点IP在管理和使用上会比较复杂。这个也是你在以后的实际应用中需要注意的地方。
有条件的公司还可以利用多线BGPBorder Gateway Protocol边界网关协议机房托管对外只提供一个IP来让访问用户自动选择访问的最佳路由。
BGP的技术原理简单说就是允许同一IP在不同运营商网络中广播不同的路由信息。目前众多云厂商都提供了多线BGP机房托管对于自建机房能力薄弱但对访问性能要求较高的公司来说也可以尝试接入使用。具体BGP的实现细节我在这里就不展开了你可以作为课后阅读来进行补充。
### 上传链路优化
在上传普通文本消息时为了保证发送通道的稳定性一般会把接入服务和业务逻辑处理服务进行拆分部署再加上最前端还有负载均衡层这样实际的上传链路至少已经有3层。另外多媒体消息在上传时在业务逻辑层后面一般还有一个用于文件存储的服务。因此如果多媒体消息也通过文本消息的通道上传整体链路会比较长。
对于图片、视频这种数据传输量大的消息来说,在这么长的链路上来回多次进行数据拷贝,整体性能会比较差,同时也会导致不必要的带宽浪费,而且可能会造成整个文本消息通道阻塞,影响普通消息的收发。
因此出于性能和隔离的考虑我们一般会把这些多媒体消息上传通道和普通消息收发通道独立开。发送多媒体消息时先通过独立通道上传文件流上传完成后会返回文件的唯一标识ID然后再把这个唯一标识ID作为消息的引用通过普通消息收发通道进行发送。具体上传过程你可以参考下图。<br>
<img src="https://static001.geekbang.org/resource/image/f4/1f/f4baa189b575ffda32952caaf569aa1f.png" alt="">
### 语音的“分片先行下推”
由于语音消息一般会有录制时长的限制,和图片、视频等多媒体消息相比,上传的文件较小,也相对比较可控。
从接收方的角度看,图片和视频在接收查看的时候,都会有缩略图或者视频首帧图用于预览,不需要一开始就下载原文件,而语音消息没有预览功能,只能下载原文件用于播放。如果语音也和图片、视频等多媒体消息走一样的上传下载逻辑,播放时就需要去下载原文件,这样会容易出现下载慢、卡顿的情况。
因此,如果有两人正在通过语音实时聊天,我们更希望通过长连下推的方式将语音流推到对端,这样用户在播放语音时就不需要再从远程临时下载文件,使用流畅度也会更好。
可见,在一些即时消息场景的实现中,会通过普通消息收发的长连通道来分片上传语音流,这样更方便通过长连来下推给接收方。
另外IM服务端在接收到分片后可以同步先行把分片的二进制流下推给接收方但暂不显示不需要等所有分片都在后端存储完成再进行下推。这样的好处是当语音的最后一片到达后端并存储完成后IM服务端只需要给接收方推一条“所有分片存储完成”的轻量信令即可让接收方马上看到这条语音消息。这个“分片先行下推”机制在实时性上比远程临时下载的方式更好能有效降低语音聊天的延时。
## 分片上传
要提升多媒体消息的上传性能和成功率,另一个比较大的优化手段是“分片上传”机制。
所谓**分片上传**,是指“在客户端把要上传的文件按照一定规则,分成多个数据块并标记序号,然后再分别上传,服务端接收到后,按照序号重新将多个数据块组装成文件”。
对于图片、视频、语音等这种较大的消息来说,采用分片上传可以让客户端在分片完成后,利用“并行”的方式来同时上传多个分片,从而提升上传效率。
在一些网络环境较差的场景下,采用“分片”的方式,可以在上传失败后进行重试时,不必重新上传整个文件,而只需要重传某一个失败的分片,这样也能提升重新发送的成功率和性能;此外,类似语音这种流式消息,在上传时并不知道文件最终大小,采用分片上传可以让消息文件先部分上传到服务器,而没必要等到录制完才开始上传,这样也能节约上传的整体时长。
### 分多大?
在分片上传中,“分片大小”是一个重要但又比较有挑战的问题。
- 分片太大,片数少,上传的并发度不够,可能会降低上传效率,每个大的分片在失败后重传的成本会比较高。
- 分片太小片数多并发需要的TCP连接太多多条TCP连接的“窗口慢启动”会降低整体吞吐两端拆分与合并分片的开销也相应增加而且传输时的额外流量HTTP报头也会更多。
所以,不同网络环境下如何选择一个“合适”的分片大小至关重要。
一般来说在网络状况较好的环境比如在WiFi、4G下相应的分片大小应该设置得更大一些而在2G、3G弱网情况下分片可以设置小一点。
对于分片大小的设置简单一点的处理可以按照网络状态来粗略划分。比如WiFi下2M4G下1M3G/2G下256K。
当然这个数值不是绝对的很多情况下3G的网络性能可能比WiFi和4G下还好。因此很多大厂会尝试通过算法来“自适应动态根据网络现状调整分片大小”比如腾讯内部的“鱼翅”项目就是通过算法来动态调整分片大小达到优化传输速度和成功率的效果。有兴趣的话你可以课后了解一下也欢迎你在留言区与我一起探讨。
### 断点续传
在上传视频、图片等较大文件时,整体耗时会比较长,用户由于某些原因可能需要在上传未完成时临时暂停,或者遇上系统意外崩溃导致上传中断的情况。对此,如果要再次上传同一个文件,我们希望不必再重新上传整个文件,而是从暂停的位置“断点续传”,而分片上传机制,就能相对简单地实现“断点续传”功能。
给每一次上传行为分配一个唯一的操作标识,每个分片在上传时除了携带自己的序号外,还需要带上这个操作标识,服务端针对接收到的同一个操作标识的分片进行“暂存”,即使由于某个原因暂停上传了,这些“暂存”的分片也不会马上清理掉,而是保留一定的时间。
>
<p>**注意:**<br>
这里只保留一定的时间主要是出于成本考虑,很多上传暂停或者失败的情况,用户并不会继续上传,如果一直保留可能会浪费较多资源。</p>
续传时继续以之前同一个操作标识来上传,客户端先检查服务端已有分片的情况,如果没有过期就继续从上次的位置续传,否则需要重新从头开始上传。“断点续传”功能实现上比较简单,但在上传大文件时,对于提升用户体验是比较明显的。
### 秒传机制
在群聊和小组等半公开的IM场景中经常会有用户收到传播度较广的同一图片、视频等多媒体消息的情况。这些重复的图片和视频如果每次都按照分片上传、落地存储的方式来处理会造成服务端存储的巨大浪费。微博消息业务之前做过一个统计一周内业务中所有上传的图片和视频的重复率能达到30%以上。
此外,对于在服务端中已经存在的视频或者图片,如果能在上传前识别出来,就能避免用户再次上传浪费流量,也能方便用户直接快速地完成上传操作。因此,通过“秒传”机制,可以有效实现优化用户上传的体验,同时减少用户的流量消耗。
秒传机制的实现其实比较简单,如下图所示:<br>
<img src="https://static001.geekbang.org/resource/image/c5/c6/c52f6ff24191cbe2d81de94ed91918c6.png" alt="">
客户端针对要上传的文件计算出一个特征值(特征值一般是一段较短的字符串,不同文件的特征值也不一样),真正上传前先将这个特征值提交到服务端,服务端检索本地已有的所有文件的特征值,如果发现有相同特征值的记录,就认定本次上传的文件已存在,后续就可以返回给客户端已存在文件的相关信息,客户端本次上传完成。
这里需要注意的是特征值的计算一般采用“单向Hash算法”来完成如MD5算法、SHA-1算法。但这些算法都存在“碰撞”问题也就是会有极低概率出现“不同文件的特征值一样的情况”。
要解决这个问题的一个很简单的办法是:**使用多种单向Hash算法在都一致的情况下才判断为重复。**
## 小结
这一讲我从IM中图片、语音、视频等多媒体消息的发送场景的优化出发分析了目前业界比较常用的一些优化手段。比如下面几种。
- 通过“多上传节点”策略,优化上传的网络访问,避免用户跨运营商网络带来的高延迟和不稳定。
- “优化上传链路”,隔离多媒体消息文件上下行通道和普通消息的收发通道,尽量缩短用户到文件存储服务的距离,针对语音消息还可以通过“先行下发”来降低语音聊天的延迟。
- 采用分片上传机制,并分出合适的分片大小,能较大改善上传成功率和速度,对于上传失败后的重试行为也更为友好。
- 断点续传功能和秒传功能解决了“已上传的文件流和片段不再需要重新上传”的问题,不仅节约了资源成本,也大幅提升了用户体验。
图片、视频、音频等多媒体消息的上传优化一直是业界研究的重点方向,除了从架构和设计的角度来优化之外,还有很多基于媒体文件编解码方面的优化。因此,这也是一个需要很多综合知识的环节,需要你去不断学习和实践。在课程之外,你也可以投入更多时间来补充这方面的相关知识。
最后给你留一个思考题:**多媒体消息上传的优化,除了本次课程中提到的这些手段,你还了解哪些多媒体消息上传方面的优化方式呢?**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,156 @@
<audio id="audio" title="15 | CDN加速如何让你的图片、视频、语音消息浏览播放不卡" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ef/1d/ef175c251132f1774ce69c60af89f21d.mp3"></audio>
你好,我是袁武林。
[上一讲](https://time.geekbang.org/column/article/141456),我从即时消息场景中多媒体消息的上传环节出发,介绍了业界常用的几种提升用户上传体验的优化手段。
那么这节课,我会从播放的角度出发,带你了解在浏览和播放图片、视频、语音等多媒体消息时,如何避免灰图和卡顿的问题,以及在节省流量方面,业界都有哪些比较常见的优化策略。
## CDN加速
提升用户浏览图片和播放视频体验的一个有效办法就是:让用户离资源更近。
比如说,北京的用户可以从北京的机房下载图片,而广东的用户可以从广东的节点机房来下载图片,这样让用户和资源实现物理位置上的相邻,以此降低远程访问的耗时,提升下载性能。
业界常用的一种手段就是通过CDNContent Delivery Network内容分发网络对图片和音视频进行加速来提升用户播放体验。
所谓的CDN加速技术就是将客户端上传的图片、音视频发布到多个分布在各地的CDN节点的服务器上当有用户需要访问这些图片和音视频时能够通过DNS负载均衡技术根据用户来源就近访问CDN节点中缓存的图片和音视频消息如果CDN节点中没有需要的资源会先从源站同步到当前节点上再返回给用户。
CDN下载时的访问链路你可以参考下图<br>
<img src="https://static001.geekbang.org/resource/image/52/ea/52ed63773e6c489a1d55668cb74cf1ea.png" alt="">
通过这种资源冗余的方式,既能显著提高用户访问的响应速度,也能有效缓解服务器访问量过大带来的对源存储服务的读写压力和带宽压力。
CDN作为一种非常成熟而且效果明显的资源访问加速技术在用户访问量较大的多媒体业务中被广泛使用直播、短视频、图片等业务都是CDN的重度使用对象。
### CDN预热
大部分CDN加速策略采用的是“拉模式”也就是当用户就近访问的CDN节点没有缓存请求的数据时会直接从文件上传存储的源站下载数据并更新到这个CDN节点的缓存中。
但在即时消息的一些特殊场景中比如对超高热度的大型聊天室来说如果采用“拉模式”可能会导致CDN缓存命中率低高并发的请求都被回源到源站源站的带宽和存储压力都会比较大。
这种情况下我们可以采用“预热”的方式来提前强制CDN节点回源并获取最新文件。大部分CDN都支持这个功能通过CDN服务提供的API接口把需要预热的资源地址和需要预热的区域等信息提交上去CDN收到后就会触发这些区域的边缘节点进行回源来实现预热。此外利用CDN预热功能还可以在业务高峰期预热热门的视频和图片等资源提高资源的访问效率。
### 使用CDN如何保障消息私密性
由于大部分CDN对外都是提供公开的资源访问面对即时消息的一些较为私密的场景其资源的访问权限很难做到精细化控制。比如点对点聊天的一些视频和图片我们希望仅收发双方有权限看到或者某个群里的图片、视频我们希望只有这个群里的用户才能看其他用户即使有下载地址也看不了。
由于这种权限判断的业务逻辑性特别强涉及到需求各异的逻辑判断因此大部分CDN是很难实现这种精细化控制的。
因此我们可以先考虑一下对于私密性要求极高的场景是否有上CDN的必要性。
比如点对点聊天的图片和视频消息只是接收方一人需要查看那么根本没有上CDN的必要不然不仅浪费CDN资源而且多级回源造成的延迟开销可能还会降低用户体验。
而对于用户量较大的超级大群、直播间、聊天室等场景来说如果通过CDN确实能提升用户浏览图片和播放视频的流畅度我们可以选择通过“流加密”的方式来提供私密性的保障。
比如在视频消息中如果针对视频文件使用HLS协议来进行分片那么就可以采用HLS协议自带的加解密功能来实现视频的流加密。
HLS流媒体网络传输协议是苹果公司主导的为提高视频流播放效率而开发的技术。它的工作原理就是把整个媒体流分成一个个小的、基于HTTP的文件来下载每次只下载一部分文件从而达到实现消息加速分发的目的。
HLS实现上由一个包含元数据的M3U8文件和众多被切割的视频片段的TS文件组成。其中M3U8文件作为TS的索引文件用于寻找可用的媒体流可以针对这些视频片段的TS文件进行AESAdvanced Encryption Standard等对称加密从而保证第三方用户即使获取到TS的媒体文件也播放不了。
M3U8的索引文件中支持“针对每一个TS文件可设置相应的获取密钥的地址”这个地址可以作为业务层的鉴权接口获取密钥时通过自动携带的Cookie等信息进行权限判定。只有鉴权通过才会返回正确的密钥而且整个解密过程都是播放器默认自动支持的也不需要人为地对播放器进行改造。
通过HLS实现视频加解密的大概流程如下图<br>
<img src="https://static001.geekbang.org/resource/image/e6/8d/e6b782051e946813ce8893a811ec158d.png" alt="">
用户通过上传服务把视频上传到服务端服务端进行视频的HLS切片并针对切完的TS文件流进行加密同时把密钥存储到密钥服务中。当有用户请求该视频时CDN节点从源站回源加密的视频文件播放器先通过下载的M3U8索引文件获取到“密钥地址”然后将客户端缓存的认证Token拼接到该“密钥地址”后面再通过该地址请求鉴权服务。
鉴权服务先检查携带的认证Token是否有权限访问该视频文件如果权限没问题会从密钥存储服务中将该视频的密钥文件返回给播放器这时播放器就能自动解密播放了。
不过虽然HLS原生支持加解密操作但是对于图片等其他多媒体消息来说没有办法使用这种方式。而且在有的即时消息系统中只支持MP4格式的视频文件。所以针对非HLS视频我们还可以通过播放器的改造来支持自定义的加密方式。
比如通过RC4Rivest Cipher 4一种流加密算法加密MP4格式的视频文件然后从业务接口获取消息地址时下发密钥这样改造后的播放器也可以达到“边解密边播放”的效果。这种方式唯一的成本是需要定制化的播放器才能播放开发成本也相对略高这里先不展开了如果你有兴趣可以在留言区和我讨论交流。
## 边下边播和拖动播放
IM场景中的视频消息在产品策略上都会有时长或大小的限制一般来说都是控制在几分钟以内或者百兆以内的短视频。不过即使是短视频如果用户在播放时需要等到视频全部下载完等待时间也是10s以上了这样用户的播放体验就不太好。
一种常见的优化方案是采用边下边播策略。在播放器下载完视频的格式信息、关键帧等信息后播放器其实就可以开始进入播放同时结合HTTP协议自带支持的Range头按需分片获取后续的视频流从而来实现边下边播和拖动快进。
支持边下边播需要有两个前提条件。
- **格式信息和关键帧信息在文件流的头部。**如果这些信息在文件尾部,就没法做到边下边播了。对于格式信息和关键帧信息不在头部的视频,可以在转码完成时改成写入到头部位置。
<li>**服务端支持Range分片获取。**有两种支持方式。<br>
a.一种是文件的存储服务本身支持按Range获取比如阿里的对象存储服务OSS和腾讯的对象存储服务COS都支持分片获取能够利用存储本身的分片获取机制真正做到“按需下载”。<br>
b.对于不支持分片获取的存储服务来说还可以利用负载均衡层对Range的支持来进行优化。比如Nginx的HTTP Slice模块就支持在接收到Range请求后从后端获取整个文件然后暂存到Nginx本地的Cache中这样取下一片时能够直接从Nginx的Cache中获取不需要再次向后端请求。这种方式虽然仍存在首次获取速度慢和Cache命中率的问题但也可以作为分片下载的一种优化策略。</li>
## 图片压缩和视频转码
另一种优化下载性能的策略是对图片、视频进行压缩和转码,在保证清晰度的情况下尽量降低文件的大小,从而提升下载的性能和降低网络开销。
图片压缩一般又分为客户端压缩和服务端压缩。客户端压缩的目的主要是减小上传文件的大小,提升上传成功率,降低上传时间,这里我们就不再详细展开了。下面我们主要了解一下服务端压缩的一些比较有效的方式。
### 分辨率自适应
针对图片下载性能的优化,一个比较重要的优化点是“分辨率自适应”。
比如在消息会话里的图片我们可以使用低分辨率的缩略图来显示等用户点击缩略图后再去加载大图因为低分辨率的缩略图一般都只有几十KB这样加载起来也比较快。一般服务端会提前压缩几种常见的低分辨率的缩略图然后按照终端机器的分辨率来按需下载。
### WebP和渐进式JPEG
除了“分辨率自适应”的优化方式以外WebP格式也是一种有效的图片下载性能优化手段。
WebP是Google推出的一种支持有损压缩和无损压缩的图片文件格式在保持相同质量的前提下WebP格式的图片比同样的PNG或JPEG图片要小30%左右因此目前已经被互联网界广泛使用。比如有报道称YouTube的视频略缩图采用 WebP格式后网页加载速度提升了10%谷歌的Chrome网上应用商店采用WebP格式图片后每天可以节省几TB的带宽。
但WebP在iOS系统上的支持性不太好需要内置WebP解析库因此在实现上需要一定的开发成本。
另一个图片格式的优化手段是“渐进式JPEG”。
JPEG分两种一种是基线JPEG是最常见的JPEG图格式采用简单的自上而下的方式进行编码和解码一般都是图片从上而下逐行加载另一种是渐进式JPEG将图像划分为多个扫描区域第一次扫描以模糊或低质量设置显示图像后续扫描再逐步提高图像质量因此我们会看到有一个从模糊到清晰的过程。
采用渐进式JPEG压缩的图片能够在加载图像时提供低分辨率的“预览”加载体验更好还有一个好处是渐进式JPEG在图片大小超过10KB时相对基线JPEG压缩比更高。在2015年Facebook改用了渐进式JPEG后用于iOS应用程序节省了10%的数据流量图像加载性能快了15%。
但这里需要你注意的是渐进式JPEG编码比传统基线JPEG的编码速度慢了60%,所以还需要权衡性能和成本的平衡。
针对图片下载性能的优化方式还有很多比如Google在2017年最新推出的图片压缩格式Guetzli还有各家自研的图片格式如腾讯自研的TPG等。如果你有兴趣的话可以自行了解一下。
### H.265转码
下面我们来看一下,针对视频消息的下载性能优化,都有哪些优化手段。
视频的码率是数据传输时单位时间传送的数据BPS。同一种编码格式下码率越高视频越清晰反之码率太低视频清晰度不够用户体验会下降。但码率太高带宽成本和下载流量也相应会增加。
目前主流的视频格式采用H.264编码H.265又名HEVC是2013年新制定的视频编码标准。同样的画质和同样的码率H.265比H.264占用的存储空间要少50%因此在实现时我们可以通过H.265来进行编码,从而能在保证同样画质的前提下降低码率,最终达到降低带宽成本和省流量的目的。
但H.265的编码复杂度远高于H.26410倍左右因此服务端转码的耗时和机器成本也相应会高很多。很多公司也并不会全部都采用H.265编码而是只选取部分热点视频来进行H.265编码通过这种方式在降低转码开销的同时来尽量提升H.265视频的覆盖度。
## 预加载
即时消息场景中短视频播放的一个重要的用户体验指标是一秒内成功开播率也就是我们常说的“秒开”。但每个视频从点击再到下载完元数据信息和部分可播放片段的过程中网络IO耗时是比较高的在不经过优化的情况下经常就需要秒级以上。
对此,一个比较通用的优化策略是:对视频流进行“部分提前加载”。
比如WiFi场景下在用户打开聊天会话页时自动触发当前页中的小视频进行预加载为了平衡流量和播放体验一般只需要预加载部分片段后续如果用户继续观看就可以通过边下边播的方式再去请求后面的视频流。
预加载可以按时间或者大小来限制。比如我们可以设定预加载3s的视频流或者设定预加载512KB的视频流。
## 推流
针对图片和音视频的浏览、播放的体验优化,我们还可以借助即时消息自身的“长连接”优势,通过长连接将部分带宽占用较小的资源推给接收方,这样用户在浏览或播放多媒体消息时,就不会因为需要临时从服务端获取而出现卡顿了。
比如,之前提到语音消息会通过长连接将音频流推送给接收方。同样,对于图片的缩略图和视频的封面图也可以通过长连接实时将资源推送下去,从而减少了加载耗时,提升了用户体验。
但这里,我建议用于消息收发的通道尽量只传输小的音频或者缩略图,避免影响通道造成堵塞。如果你的业务场景中需要直接推送视频流或者原图的,可以通过长连通知客户端重新发起一个新的临时连接,来进行流的传输。
## 小结
这节课,我主要从提升用户图片浏览及音视频播放体验的角度出发,介绍了一些在即时消息场景中,业界比较通用的优化策略。其中,有很多是业界通用的优化方案,还有一些是与即时消息联系比较紧密的优化点。从这里你也可以看出,即时消息并不是一个独立存在的领域,而是多个领域的技术的大融合。
最后,我们再一起回顾下上面提到的针对多媒体消息的下行都有哪些技能树:
- 通过CDN加速让“用户离资源更近”
- 通过“流加密”来解决CDN上多媒体消息的私密性问题
- 为图片提供多种中低分辨率的缩略图来提升图片预览性能;
- 使用WebP和渐进式JPEG来对图片进行压缩以降低体积提升加载性能
- 针对热门的小视频采用H.265转码,在保证画质的同时,降低带宽成本并加快视频加载;
- 通过视频的自动“预加载”功能,达到视频播放“秒开”的效果;
- 借助长连接通道,对体积较小的音频和缩略图进行实时推送,提升用户浏览和播放体验。
由于图片和音视频技术的发展十分迅猛,各种新的优化技术层出不穷,而且针对多媒体消息上传和下载的优化,很多还涉及到深层次的音视频编解码和图片压缩算法的实现,大部分大厂针对这一块也有专门的团队来进行研究。如果你对即时消息场景中多媒体消息的上传和下载的优化有其他思路和想法,也欢迎在留言区给我留言。
最后给你留一道思考题:**针对CDN上的文件访问鉴权你还了解其他可行的方案吗**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="16 | APNs聊一聊第三方系统级消息通道的事" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/d7/dd5322069fc5a9b1dc20fb1a964523d7.mp3"></audio>
你好,我是袁武林。
前面几节课里,我讲到在即时消息场景下,我们会依赖服务端推送技术来提升消息到达的实时性,以及通过各种手段来保证消息收发通道的可用性,从而让消息能尽量实时、稳定地给到接收人。
但在实际情况中出于各种原因App与服务端的长连接会经常断开。比如用户彻底关闭了App或者App切换到后台一段时间后手机操作系统为了节省资源会杀掉进程或者禁止进程的网络功能。在这些情况下消息接收方就没有办法通过App和IM服务端的长连接来进行消息接收了。
那有没有办法能让消息在App被关闭或者网络功能被限制的情况下也能发送到接收人的设备呢答案是有。
现在手机常用的iOS和Android系统都提供了标准的系统级下发通道这个通道是系统提供商维护的与设备的公共长连接可以保证在App关闭的情况下也能通过手机的通知栏来下发消息给对应的接收人设备。而且当用户点击这些通知时还能重新唤醒我们的App不仅能提升消息的到达率还增加了用户的活跃度。
## 第三方系统下发通道
常见的第三方系统下发通道有iOS端的APNs以及Android端的GCM和厂商系统通道。
- iOS端的APNsApple Push Notification service苹果推送通知服务是独立于应用之外依托系统常驻进程来维护和苹果服务器的公共长连接负责全局的系统推送服务。
- 在Android端上有Google的GCMGoogle Cloud MessageGoogle云消息传递。但GCM由于某些技术原因如NAT超时太长、暴露的5228端口连通性差等和某些非技术原因需要和Google服务器建立连接Android端的GCM在国内被大部分手机厂商定制化后直接去掉并替换成了各自的系统通道。目前国内Android的系统级下发通道基本都是厂商通道目前已知的有5家小米、华为、vivo、OPPO、魅族。
### APNs
接下来我们就来了解一下iOS端的系统推送服务APNs。
下面借用苹果官网的一张图来简单说明一下当App在没有打开的情况下[消息通过APNs下发到设备](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html#//apple_ref/doc/uid/TP40008194-CH8-SW1)的过程:<br>
<img src="https://static001.geekbang.org/resource/image/16/d6/16268357f80806572fe74f589b2d18d6.png" alt="">
- 首先Provider也就是IM服务器把消息通过长连接发送到APNs上
- 紧接着APNs把消息推送到接收方用户的iOS设备端并在设备的通知栏进行展示
- 最后用户通过点击通知等操作可以唤醒App来进行一系列的交互活动。
整个流程看起来比较简单。但仔细思考后你可能会对其中的几个地方产生疑惑比如APNs是如何识别App的用户在哪台设备上还有IM服务器是如何告知APNs应该把消息推送给哪个设备的哪个App呢
别着急,下面我就带你来了解一下,这两个问题在技术上是怎么解决的。
先了解一个概念吧DeviceToken。
**什么是DeviceToken**DeviceToken是APNs用于区分识别不同iOS设备同一个App的唯一标识APNs的网关和设备通信时通过系统默认自带的长连接进行连接并通过DeviceToken作为当前连接设备的唯一标识进行系统消息的推送。
要通过APNs实现系统推送我们的推送服务Provider需要先和APNs建立长连建连时携带的证书包含“准备接收”系统消息推送的App的Bundle IdentifierBundle Identifier是一款App的唯一标识
每当我们有消息需要推送时都必须连同消息再携带待接收系统设备的DeviceToken给到APNs。APNs通过这个DeviceToken就能找到对应的连接设备从而就可以把消息通过APNs和设备间的长连接推送给这台设备上的App了。具体的流程你可以参考下面这张[官网图](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/APNSOverview.html#//apple_ref/doc/uid/TP40008194-CH8-SW1)。<br>
<img src="https://static001.geekbang.org/resource/image/11/98/11f10590d6455e22e2f04d098be42c98.png" alt="">
**那么DeviceToken是固定不变的吗**
一般来说在同一台设备上设备的DeviceToken是不会发生变化的除了以下几种情况
- iOS系统升级后
- APNs出于安全等原因禁用了这个DeviceToken。
因此如果DeviceToken由于某种原因发生变化但IM服务端并不知道就会导致IM服务端携带失效的DeviceToken给到APNs最终导致这次系统推送消息失败。
所以一般情况下我们的IM服务端可以在每次启动App时都去请求APNs服务器进行注册来获取DeviceToken。正常情况下客户端每次获取到的DeviceToken都不会变速度也比较快。客户端在首次获取到DeviceToken之后会先缓存到本地如果下次获取到DeviceToken后它没有发生变化那么就不需要我们再调用IM服务端进行更新了。这也算是个小技巧你可以试试看。
**那么如果App没来得及更新到IM服务端但DeviceToken已经过期了我们该怎么办呢**
最新的APNs协议是基于HTTP/2实现的针对每条消息的推送APNs都会返回对应的状态码来明确告知IM服务端此次的消息推送成功与否。
当IM服务端把待推送的消息和DeviceToken给到APNs时APNs会先检查这个DeviceToken是否失效如果已经失效那么APNs会返回一个400的HTTP状态码根据这个状态码IM服务端就可以对维护的这个失效DeviceToken进行更新删除。
当然这里我有必要说一下当我们在使用旧版的APNs协议时可能会比较麻烦一点因为旧版的APNs协议在判断DeviceToken失效时会断开连接这时就需要IM服务端再通过APNs提供的另一个Feedback接口来定时获取这些失效的DeviceToken然后删除掉这样下次推送才不会再用到。
### APNs都能发啥消息
了解了DeviceToken的作用后我们再来看一下通过APNs都能下发什么样类型的消息。
从IM服务端发送到APNs的每一条消息都会有一个Payload负载的数据结构这个Payload包括我们要发送的消息内容和一些推送相关的方式等数据信息一般是一个JSON格式的字符串。
这个字符串可能会包括接收时通知的标题、子标题、具体通知的内容以及App的角标数是多少、接收时播放的声音等等。同时也包括一些自定义的内容字段比如群消息一般还会携带群ID和具体的这条消息ID便于用户点击唤醒App时能够跳转到相应的群聊会话。你可以参考下面这个代码设计
```
{
&quot;aps&quot;: {
&quot;alert : {
&quot;title&quot;: “新消息”, //标题
&quot;subtitle&quot;: “来自: 张三”, //副标题
&quot;body&quot;: “你好” //正文内容
},
&quot;badge&quot;: 1, //角标数字
&quot;sound&quot;: &quot;default&quot; //收到通知时播放的声音
},
&quot;groupID&quot;: “123”,
&quot;mid&quot;: &quot;1001&quot;
}
```
这里需要注意的是Payload的大小是有限制的iOS 8之前是256BiOS 8之后是2KB。在iOS 10以后推送的消息Payload大小调整为了4KB。另外iOS 10之前只能推送文字而在它之后可以支持主标题、副标题还能支持附件。
### 静默推送
除了发送各自通知栏弹窗的强提醒推送外APNs还支持“静默推送”。
“静默推送”是iOS 7之后推出的一种远程系统推送类型它的特色就是没有文字弹窗没有声音也没有角标可以在不打扰用户的情况下悄无声息地唤醒App来进行一些更新操作。
比如我们可以使用“静默推送”来每天定点推送消息让某些App在后台静默地去更新订阅号的文章内容这样用户打开App时就能直接看到不需要再去服务端拉取或者等待服务端的离线推送了。
## APNs的缺陷
了解了APNs的实现机制和能力你可能会比较困惑既然APNs能够做到App打开或者关闭的情况下通过系统通知把消息推送给用户那好像也不需要我们再去实现自己维护一条App和IM服务器的长连接了通过APNs的系统通知的跳转去唤醒App后再根据未读数从服务端拉取未读消息好像也没问题啊
既然如此那么为什么大部分即时消息系统还是要自己维护一条长连通道呢主要原因是由于APNs本身还存在一些缺陷。
### 可靠性低
APNs的第一个缺陷就是可靠性没有保障。这应该是苹果官方出于维护成本的考虑APNs并不能保证推送消息的到达率也不能保证消息不发生延迟。
也就是说你要推送的消息给到了APNs但APNs并不能保证这条消息能真正推送到用户设备上而且也无法保障消息不发生延迟可能你给到APNs的消息需要几分钟后用户才收到这个现象用过iPhone的用户估计应该都碰到过。
### 离线消息的支持差
除了可能丢消息和延迟高的风险外APNs的另一个缺陷就是无法保障离线消息的存储。当用户的设备离线或者关机时APNs就没有办法马上把消息送达给用户这种情况下如果APNs需要向你这台设备发送多条推送时就会启动它的QoSQuality of Service服务质量机制只保留给你最新的一条消息在这种场景下就会存在丢失离线消息的问题。
出现这种问题的主要原因应该也是出于存储成本方面的考虑由于APNs的消息接收量整体基数太大大量的存储和转发对服务器的资源消耗非常大出于成本考虑APNs就会丢掉一部分离线消息。
### 角标累加问题
除此之外APNs还有一个小吐槽点是对于角标的未读数APNs不支持累计+1操作只支持覆盖原来的角标未读数。所以在每条消息下推时APNs还需要把这个用户的总未读消息数一起带下去对后端未读数服务来说会增加额外的调用压力而Android端的厂商通道就相对更友好一些很多厂商的角标未读支持根据接收到的通知消息自动累加的特性。
## Android的厂商通道
对于Android端来说应用的后台保活一直是提升消息在线推送到达率的重要手段很多操作系统默认的配置是切到后台一段时间后会直接杀掉进程或者让进程断网。
虽然App间有互相拉起进程的取巧方式以及系统厂商给某些超级App比如微信的保活白名单机制但对于大部分App来说还是需要在App被杀死或者被限制的情况下通过厂商的系统通道推送消息以此提升整体的消息到达率。
对于开头我提到的5家厂商通道的接入它们都提供了专门的SDKSoftware Development Kit软件开发工具包这些厂商SDK能够支持的功能大致上和APNs比较类似这里就不详细展开了你可以自行了解一下。
不过值得说一下的是由于各家SDK使用上各异开发接入成本会相对较高因此市面上还有很多第三方的Push服务这些第三方的Push服务整合了多家厂商的SDK对外提供统一的接入降低了对接门槛比如个推、信鸽、极光、友盟等。
## 国内统一推送联盟
由于国内各个厂商系统推送通道的差异性造成Android端系统推送整体上的混乱和复杂性。为此在2017年中华人民共和国工业和信息化部简称工信部主导成立了安卓统一推送联盟联合各大手机厂商和运营商共同推出了“推必达”产品对标的是苹果的APNs和Google的GCM。
根据官网的介绍“推必达”除了通过传统的TCP长连网络来进行系统消息的下推外还会和运营商合作支持通过运营商的信令通道来进行消息下推。因此在没有WiFi和移动网络的场景下我们只要有手机信号也能接收到系统推送。
下面是[“推必达”官网提供的技术架构图](http://chinaupa.com/)。你可以看到“推必达”提供了专门的SDK给到客户端当各个IM服务端有消息需要通过系统推送触达用户时会把消息直接或通过第三方推送服务交给各个厂商部署的UPS服务器UPS服务器再通过和客户端SDK维护的长连接把消息推送下去。
<img src="https://static001.geekbang.org/resource/image/8b/34/8b7d9c8b9b59d8b9ff56a38d67292f34.jpeg" alt="">
由于其支持多种消息的触达途径“推必达”的消息到达率据官网反馈在全国34个省市的测试消息到达率为99.999%。目前国内主要的手机厂商如华为、小米、OPPO、vivo等都已经加入到这个联盟中。另外工信部要求到2019年12月31日现有的各推送通道需要兼容统一推送标准。
因此我认为对于Android端的系统推送来说“推必达”产品如果能够成功落地应该是未来系统推送的趋势。
## 小结
简单回顾一下今天课程的内容。这一讲我主要讲到了提升消息整体到达率的利器第三方系统推送。通过手机厂商提供的系统级长连推送服务可以让App在没被打开或后台网络功能被系统限制的情况下也能够通过厂商通道将消息触达到用户。
在iOS端是由苹果提供的APNs服务来提供系统推送的能力。IM服务器把待推送的消息连同唯一标识某台设备的DeviceToken一起给到APNs服务器再由APNs服务器通过系统级的与任何App无关的长连接来推送给用户设备并展示。新版本的APNs服务支持文本、音频、图片等多媒体消息的推送以及无任何弹窗通知的静默推送。
但是APNs并不保证消息推送不发生延迟也不保证消息能真正到达设备对消息大小也有限制。因此在可靠性上APNs比App自建的长连接会差一些所以一般也只是作为自建长连接不可用时的备选通道。
Android端下目前国内主要是由各个手机厂商各自维护的厂商通道来提供系统推送服务。目前已知支持厂商通道的也只有5家而且各家SDK都没有统一所以整体上看Android端的推送接入比较复杂和混乱。
目前工信部主导的“统一推送联盟”,它的目的就在于通过提供统一的接入方式,以此解决这种混乱状况。其推出的产品“推必达”支持在移动网络不可用的情况下,通过电信信令通道来触达用户,能进一步提升消息的到达率,是我们值得期待的解决方案。
系统推送作为一种常用的触达用户的方式,对于即时消息场景来说是提升消息到达率的一条非常重要的途径。但这些系统推送通道目前还存在可靠性低、功能不完善、生态混乱等问题,因此,对消息可靠性要求较高的场景来说,系统推送通道基本上只能作为对自建长连接推送通道的一个补充。系统推送也是一门普适性很强的技术,了解当前系统推送的业界现状和进展,对于你的前后端知识体系也是一个很好的补充和拓展。
最后给大家留一个思考题:**静默推送支持的“唤醒App在后台运行”功能你觉得都有哪些应用场景呢**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="17 | Cache多级缓存架构在消息系统中的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/27/a01c5ceb3a6b782c2d4a2fab997d2227.mp3"></audio>
你好,我是袁武林。
今天我要带你了解的是一项在IM系统中相对比较通用的、使用比较高频的而且对系统性能提升非常明显的技术缓存。
说到缓存,你应该不陌生。相对于磁盘操作,基于内存的缓存对耗时敏感的高并发应用来说,在性能方面的提升是非常明显的。
下面是谷歌的技术奠基人杰夫·狄恩Jeff Dean给出的一些[计算机相关的硬件指标](http://highscalability.com/numbers-everyone-should-know),虽然有些数据可能由于时间太久不够准确,但大致的量级基本还是一致的。
- L1 cache reference 0.5 ns
- Branch mispredict 5 ns
- L2 cache reference 7 ns
- Mutex lock/unlock 100 ns
- Main memory reference 100 ns
- Compress 1K bytes with Zippy 10,000 ns
- Send 2K bytes over 1 Gbps network 20,000 ns
- Read 1 MB sequentially from memory 250,000 ns
- Round trip within same datacenter 500,000 ns
- Disk seek 10,000,000 ns
- Read 1 MB sequentially from network 10,000,000 ns
- Read 1 MB sequentially from disk 30,000,000 ns
- Send packet CA-&gt;Netherlands-&gt;CA 150,000,000 ns
可以看到同样是1MB的数据读取从磁盘读取的耗时比从内存读取的耗时相差近100倍这也是为什么业界常说“处理高并发的三板斧是缓存、降级和限流”了。
使用缓存虽然能够给我们带来诸多性能上的收益但存在一个问题是缓存的资源成本非常高。因此在IM系统中对于缓存的使用就需要我们左右互搏地在“缓存命中率”和“缓存使用量”两大指标间不断均衡。
在今天的课程中我会围绕IM系统中缓存的使用来聊一聊在使用过程中容易碰到的一些问题及相应的解决方案。
## 缓存的分布式算法
对于大规模分布式服务来说,大部分缓存的使用都是多实例分布式部署的。接下来,我们就先来了解一下缓存常见的两种分布式算法:取模求余与一致性哈希。
### 取模求余
取模求余的算法比较简单。比如说用于存储消息内容的缓存如果采用取模求余就可以简单地使用消息ID对缓存实例的数量进行取模求余。
如下图所示如果消息ID哈希后对缓存节点取模求余余数是多少就缓存到哪个节点上。<br>
<img src="https://static001.geekbang.org/resource/image/48/0f/4852180eaec1be40c2c853ad4029300f.png" alt="">
取模求余的分布式算法在实现上非常简单。但存在的问题是如果某一个节点宕机或者加入新的节点节点数量发生变化后Hash后取模求余的结果就可能和以前不一样了。由此导致的后果是加减节点后缓存命中率下降严重。
### 一致性哈希
为了解决这个问题业界常用的另一种缓存分布式算法是一致性哈希。它是1997年麻省理工学院提出的一种算法目前主要应用在分布式缓存场景中。
一致性哈希的算法是把全量的缓存空间分成2的32次方个区域这些区域组合成一个环形的存储结构每一个缓存的消息ID都可以通过哈希算法转化为一个32位的二进制数也就是对应这2的32次方个缓存区域中的某一个缓存的节点也遵循同样的哈希算法比如利用节点的IP来哈希这些缓存节点也都能被映射到2的32次方个区域中的某一个。
那么如何让消息ID和具体的缓存节点对应起来呢
很简单每一个映射完的消息ID我们按顺时针旋转找到离它最近的同样映射完的缓存节点该节点就是消息ID对应的缓存节点。大概规则我画了一个图你可以参考一下<br>
<img src="https://static001.geekbang.org/resource/image/44/a3/442f8d013b505343f702048fae4d33a3.png" alt="">
**那么,为什么一致性哈希能够解决取模求余算法下,加减节点带来的命中率突降的问题呢?**
结合上图我们一起来看一下。假设已经存在了4个缓存节点现在新增加一个节点5那么本来相应会落到节点1的mid1和mid9可能会由于节点5的加入有的落入到节点5有的还是落入到节点1落入到新增的节点5的消息会被miss掉但是仍然落到节点1的消息还是能命中之前的缓存的。
另外其他的节点2、3、4对应的这些消息还是能保持不变的所以整体缓存的命中率相比取模取余算法波动会小很多。
同样,如果某一个节点宕机的话,一致性哈希也能保证,只会有小部分消息的缓存归属节点发生变化,大部分仍然能保持不变。
### 数据倾斜
一致性哈希既然解决了加减节点带来的命中率下降的问题,那么是不是这种算法,就是缓存分布式算法的完美方案呢?
这里我们会发现,一致性哈希算法中,如果节点比较少,会容易出现节点间数据不均衡的情况,发生数据倾斜;如果节点很多,相应的消息就能在多个节点上分布得更均匀。
但在实际的线上业务中,部署的缓存机器节点是很有限的。
所以,为了解决物理节点少导致节点间数据倾斜的问题,我们还可以引入虚拟节点,来人为地创造更多缓存节点,以此让数据分布更加均匀。
虚拟节点的大概实现过程,你可以参考下图:<br>
<img src="https://static001.geekbang.org/resource/image/92/23/92b1c18cb841dc81a3989f37fa2e4f23.png" alt="">
我们为每一个物理节点分配多个虚拟节点比如在上图这里给节点1虚拟出4个节点。当消息进行缓存哈希定位时如果落到了这个物理节点上的任意一个虚拟节点那么就表示真正的缓存存储位置在这个物理节点上然后服务端就可以从这个物理节点上进行数据的读写了。
如上面这个例子本来都落在节点3的4条消息mid4、mid5、mid6、mid7在加入节点1的虚拟节点后mid4和mid5落到了虚拟节点1-2上这样mid4和mid5就被分配到物理节点1上了。可见通过这种方式能更好地打散数据的分布解决节点间数据不平衡的问题。
## 缓存热点问题
通过一致性哈希配合虚拟节点我们解决了节点快速扩容和宕机导致命中率下降的问题及节点间数据倾斜的问题。但在IM的一些场景里还可能会出现单一资源热点的问题。
比如一个超级大V给他的粉丝群发了一篇精心编写的长文章可能一瞬间服务端会有上万的文章阅读请求涌入。由于这些长文章都是作为富文本进行存储的所以存储的数据较大有的文章都超过1MB而且用户还需要随时能够修改文章也不好通过CDN来进行分发。
那么,我们如何去解决这种缓存热点问题呢?
### 多级缓存架构-主从模式
我以上面的“长文章流量热点”的例子来说明一下。为了防止文章下载阅读出现热点时造成后端存储服务的压力太大我们一般会通过缓存来进行下载时的加速。比如说我们可以通过文章的唯一ID来进行哈希并且通过缓存的一主多从模式来进行部署主从模式的部署大概如下图<br>
<img src="https://static001.geekbang.org/resource/image/7e/77/7e6712af075d5f9458109f98ca704477.png" alt="">
一般来说,主从模式下,主库只用于数据写入和更新,从库只用于数据读取。当然,这个也不是一定的。
比如在写多读少的场景下也可以让主库承担一部分的数据读取工作。当缓存的数据读取QPS比较大的情况下可以通过增加从库的方式来提升整体缓存层的抗读取能力。
主从模式是最常见的、使用最多的缓存应用模式。但是主从模式在某些突发流量的场景下会存在一些问题,就比如刚刚提到的“长文章流量热点”问题。
我们对某篇长文章的唯一ID来进行哈希在主从模式下一篇文章只会映射到一个从库节点上。虽然能够通过增加从库副本数来提升服务端对一篇文章的读取能力但由于文章大小比较大即使是多从库副本对于千兆网卡的从库实例机器来说带宽层面也很难抗住这个热点。举个例子单台机器120MB带宽对于1MB大小的文章来说如果QPS到1000的话至少需要8个实例才可以抗住。
另外多从库副本是对主库数据的完整拷贝从成本上考虑也是非常不划算的。除了带宽问题对于某些QPS很高的资源请求来说如果采用的是单主单从结构一旦从库宕机瞬间会有大量请求直接穿透到DB存储层可能直接会导致资源不可用。
### 多级缓存架构-L1+主从模式
为了解决主从模式下单点峰值过高导致单机带宽和热点数据在从库宕机后造成后端资源瞬时压力的问题我们可以参考CPU和主存的结构在主从缓存结构前面再增加一层L1缓存层。
L1缓存顾名思义一般它的容量会比较小用于缓存极热的数据。那么为什么L1缓存可以解决主从模式下的带宽问题和穿透问题呢
我们来看一下L1+主从模式的部署和访问形式:<br>
<img src="https://static001.geekbang.org/resource/image/12/22/12139029db75c111314ff0e402f21722.png" alt="">
L1缓存作为最前端的缓存层在用户请求的时候会先从L1缓存进行查询。如果L1缓存中没有再从主从缓存里查询查询到的结果也会回种一份到L1缓存中。
与主从缓存模式不一样的地方是L1缓存有分组的概念一组L1可以有多个节点每一组L1缓存都是一份全量的热数据一个系统可以提供多组L1缓存同一个数据的请求会轮流落到每一组L1里面。
比如同一个文章ID第一次请求会落到第一组L1缓存第二次请求可能就落到第二组L1缓存。通过穿透后的回种最后每一组L1缓存都会缓存到同一篇文章。通过这种方式同一篇文章就有多个L1缓存节点来抗读取的请求量了。
而且L1缓存一般采用LRULeast Recently Used方式进行淘汰这样既能减少L1缓存的内存使用量也能保证热点数据不会被淘汰掉。并且采用L1+主从的双层模式,即使有某一层节点出现宕机的情况,也不会导致请求都穿透到后端存储上,导致资源出现问题。
### 多级缓存架构-本地缓存+L1+主从的多层模式
通过L1缓存+主从缓存的双层架构,我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。
但有的时候面对一些极热的热点峰值我们可能需要增加多组L1才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本所以有没有更好的平衡极热峰值和缓存成本的方法呢
对于大部分请求量较大的应用来说,应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作,就能充分利用应用层机器的带宽和极少的内存,来低成本地解决带宽问题了。那么,这种方式是否可以实现呢?
答案是可以的,这种本地缓存+L1缓存+主从缓存的多级缓存模式,也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图:<br>
<img src="https://static001.geekbang.org/resource/image/c8/d6/c87661167f77555e906ed2117ba9b5d6.png" alt="">
本地缓存一般位于应用服务器的部署机器上使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存应用层获取数据时先访问本地缓存如果未命中再通过远程从L1缓存层获取最终获取到的数据再回种到本地缓存中。
通过增加本地缓存依托应用服务器的多部署节点基本就能完全解决热点数据带宽的问题。而且相比较从远程L1缓存获取数据本地缓存离应用和用户设备更近性能上也会更好一些。
但是使用本地缓存有一个需要考虑的问题,那就是数据的一致性问题。
还是以“长文章”为例。我们的服务端可能会随时接收到用户需要修改文章内容的请求,这个时候,对于本地缓存来说,由于应用服务器的部署机器随着扩缩容的改变,其数量不一定是固定的,所以修改后的数据如何同步到本地缓存中,就是一个比较复杂和麻烦的事情了。
要解决本地缓存一致性问题业界比较折中的方式是对本地缓存采用“短过期时间”的方式来平衡本地缓存命中率和数据更新一致性的问题。比如说针对“长文章”的本地缓存我们可以采用5秒过期的策略淘汰后再从中央缓存获取新的数据。这种方式对于大部分业务场景来说在产品层面上也是都能接受的。
## 小结
好了,下面简单回顾一下今天课程的内容。
首先我介绍了缓存在高并发应用中的重要性以及在IM系统中使用的部分场景。然后再带你了解了缓存分布式的两种算法取模求余和一致性哈希。
- **取模求余算法**在实现上非常简单,但存在的问题是,取模求余算法在节点扩容和宕机后会出现震荡,缓存命中率会严重降低。
- **一致性哈希算法**解决了节点增删时震荡的问题,并通过虚拟节点的引入,缓解了**“数据倾斜”**的情况。
最后,我着重介绍了业界通用的三种分布式缓存的常见架构。
- **一种是主从模式。**简单的主从模式最常见,但是在面对峰值热点流量时,容易出现带宽问题,也存在缓存节点宕机后穿透到存储层的问题。
- **第二种是L1+主从模式**。通过增加L1缓存层以并行的多组小容量的L1缓存解决了单一热点的带宽问题也避免了单一节点宕机后容易穿透到DB存储层的情况。
- **最后一种是本地缓存+L1+主从的多层模式**。作为低成本的解决方案我们在L1+主从模式的基础上,引入了本地缓存。本地缓存依托应用服务器的本机少量内存,既提升了资源的有效利用,也彻底解决了带宽的问题。同时在性能方面,也比远程缓存获取更加优秀。对于本地缓存的数据一致性问题,我们可以通过“短过期时间”来平衡缓存命中率和数据一致性。
面对高并发业务带来的流量压力,我们不可否认的是,缓存的使用是目前为止最有效的提升系统整体性能的手段。作为系统优化的一把利器,如何用好这个强大的工具,是你需要去不断思考和学习的。希望今天介绍的这几种缓存使用的姿势,能够让你有所收获,并能在自己的业务中去尝试实践。
**最后给你留一道思考题:**
L1+主从模式下如果热点数据都被L1缓存层拦截命中会导致主从缓存层相应的这个热点数据由于长时间得不到读取而被LRU淘汰掉。这样如果下线L1缓存还是会有不少的请求直接穿透到DB存储层。那么有没有办法能够让主从缓存在有L1缓存层的情况下依旧能保持数据热度
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,178 @@
<audio id="audio" title="18 | Docker容器化说一说IM系统中模块水平扩展的实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/c7/5a8c425f03f34d1e4ab0be5ed2b26bc7.mp3"></audio>
你好,我是袁武林。
在[第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)中,我较为系统地讲解了直播场景中突发流量的应对策略。其中比较重要的一点就是:当有热点流量进来时,我们能够通过监控指标对服务进行快速扩缩容。
而快速扩缩容的一个重要前提,就是部署的服务和资源能够做到水平扩展。
那么,今天我们就来聊一聊服务和资源水平扩展的实现问题。
## 垂直扩展
首先从水平扩展(Scale out)的概念说起吧。
要解释水平扩展是什么我们要先了解下与水平扩展相对应的另一个概念垂直扩展Scale up。只有通过这两者可行性和实现层面的对比我们才能更好地理解为什么水平扩展能力对于实现一个架构良好的系统如此重要。
当业务的用户量随着产品迭代不断增长时,相应的后端资源和服务器的压力也在逐渐加大。而解决资源和服务器瓶颈一个有效且较快的方式就是:提升资源服务器和应用服务器的单机处理能力,也就是对资源服务器和应用服务器进行“垂直扩展”。
### 提升单机硬件性能
要对资源和服务进行垂直扩展,一个简单粗暴但也比较有效的方式就是:增强单机服务器的性能。
比如对CPU出现瓶颈的服务器进行升级增加CPU核数和主频对于网卡有瓶颈的服务器升级到万兆网卡并接入到万兆交换机下内存出现瓶颈导致系统吃Swap的情况我们也可以将内存升级到更高配置来进行垂直扩展。一般情况下通过对服务器硬件的升级往往能快速解决短期的系统瓶颈问题。
### 增加单机服务处理能力
除了提升单机硬件的整体性能,我们还可以通过优化单机的服务进程处理能力,来实现对资源和服务的垂直扩展。
比如我们部署的应用程序可以通过增加处理线程数进一步榨干单机的硬件性能以此提升服务进程整体的处理能力或者通过扩大应用程序的使用内存来提升请求的处理效率如Java进程通过-Xmx增大堆内存空间使用机器的本地缓存来优化访问速度对于容易出现带宽瓶颈的业务来说也可以通过架构改造对收发的数据进行压缩以降低数据传输量。
通过对单机服务架构和部署的优化,我们能够在不升级服务器硬件的前提下,提升单机服务的处理能力。
不过,虽然我们可以通过以上这两种方式来解决部分资源和服务的瓶颈问题,但不管是通过什么办法来提升服务器的单机性能,它总是会达到极限的。
所以,采用“垂直扩展”的方式,我们基本上只能解决短期的资源和服务瓶颈,而真正长远且有效的解决方案是:让资源和服务做到可以随时随地进行无差别的扩容,也就是对资源和服务进行“水平扩展”。
## 水平扩展
对于即时聊天的场景来说用户的消息收发往往会牵涉到多层的服务和资源。因此要让IM系统也能做到“水平扩展”我们需要让IM系统涉及到的服务和资源都能够通过快速扩容来缓解突发流量带来的压力。
我在课程第1讲[“架构与特性一个完整的IM系统是怎样的](https://time.geekbang.org/column/article/127872)中也有讲过一个典型的IM系统架构是怎样的你可以参考下图<br>
<img src="https://static001.geekbang.org/resource/image/14/de/14b44a6b21eab122c76c3b940bdf67de.png" alt="">
客户端通过DNS Server或者HttpDNS查询接入服务对外提供的公网VIP虚拟IP。这个公网VIP通过Keepalived绑定到多台LVSLinux Virtual ServerLinux虚拟服务器能够实现单台LVS故障后自动切换到另一台。当用户访问这个公网VIP时就会被路由重定向到真正的LVS上。
LVS运行在操作系统内核态会按照设定的负载均衡策略将IP数据包分发到后端接入网关机接入服务器再把数据透传给业务处理服务层业务处理服务进行具体的业务逻辑处理比如读写缓存和消息存储等。
那么我们就来看看,在上图所示的整个链路上,具体各层在出现压力和瓶颈时,如何做到“水平扩展”?
### 接入层如何水平扩展
首先我们看下链路最外层入口的VIP。
对于LVS服务器来说由于只是在TCP/IP层对数据包进行简单的转发并不需要解析数据包给到应用层所以服务器相应的处理性能是很高的单机每秒数据包转发PPS能达到千万级别。因此在大部分情况下LVS服务器本身不会成为瓶颈。
但是对于IM系统中的直播互动场景来说由于存在瞬时上万级别的长连建连请求场景单台LVS服务器可能会导致建连失败的情况。
针对这种情况我们一般可以通过DNS轮询来实现负载均衡。
简单地说就是在DNS服务器中针对接入服务域名配置多个VIP当用户访问接入服务时DNS服务器就会通过轮询或者其他策略来从A记录中选择某一个VIP供用户连接。
<img src="https://static001.geekbang.org/resource/image/19/69/196800b98770b9478eff5af4e3047a69.png" alt="">
比如上图所示微信长连接通道针对同一个域名进行DNS查询就返回了多条A记录供接入设备选择。
在通过“DNS轮询多VIP”的水平扩展解决了单VIP入口可能出现的瓶颈问题后对于用户的消息收发来说另一个容易出现瓶颈的点就是应用层的接入服务了。
要想解决这个问题,我们先从业务使用的角度来分析一下。
以大型聊天室或者大型直播间的场景为例,其链路压力一般来自于消息扇出后的下推,其他链路环节实际上对于聊天室成员增长,或者直播间在线人数增长并不会太敏感。
所以,正如在[第10篇](https://time.geekbang.org/column/article/137000)中所讲到的,我们要对那些容易随着用户热度增加,而出现流量突升的环节进行架构上的剥离,以便在出现流量峰值时,对这些拆分后相对较轻量的接入服务进行扩容。
另外,我们在设计接入服务时,要尽量做到无状态化。
无状态化的意思就是任意用户随时都可以连接到任意一台接入服务器上,这样我们的接入服务才可以随时进行“水平扩展”。除了接入用户连接外,当服务端有消息需要推送给客户端时,业务逻辑层需要把消息精确推送到具体某个用户连接的网关机上。
要想实现接入层的水平扩展解耦我们一般可以通过维护一个中央的用户和“所在网关机IP”的映射关系或者通过网关机IP维度的Topic来利用消息队列进行精准推送比如离线消息的拉取。通过中央的“在线状态”服务我们就能让接入层和业务层通过这个中央资源来进行交互让接入层做到无状态化。其实现方式如下图<br>
<img src="https://static001.geekbang.org/resource/image/06/7f/068b5c069cee8278da755ce41a6dc07f.png" alt="">
用户通过接入服务上线后接入服务会在中央资源中比如Redis记录当前用户在哪台网关机上线。如果业务层有消息需要推送给这个用户时通过查询这个中央资源就能知道当前用户连接在哪台网关机上然后就可以通过网关机的API接口把消息定向投递推送给用户了。
### 业务层如何水平扩展
利用DNS轮询“单域名多VIP”我们解决了接入层VIP入口的瓶颈问题。此外对于应用层的接入服务的瓶颈问题我们可以结合业务场景通过“服务拆分”将容易出现峰值波动的应用接入服务进行抽离以便于接入层扩容最后通过“中央的在线状态资源”让接入层做到完全“无状态”从而实现了接入层的“水平扩展”。
虽然很多时候,我们的业务层不像接入层一样,对流量的波动那么敏感,但在实际业务中,每天的用户访问也会呈现出比较明显的流量变化。比如在很多聊天场景中,凌晨和上午的流量相对会低一些,而晚上的用户访问量会更大。
因此,为了更好地提升服务器资源利用率,我们的业务层也需要做到“水平扩展”。我们可以在流量低峰时,让业务层服务保持较低的部署规模;在晚上高峰时,通过扩容来保证用户访问的速度。
由于接入层对业务层是强依赖关系,因此业务层水平扩展的关键在于:如何让业务层在扩缩容后,能够被接入层及时感知到?
对于这个关键问题,业界比较常见的解决方式是:对业务层进行“服务化”改造,以此让接入层通过“自动服务发现”,来感知到业务层的变化。
比如通过Thrift、gRPC、Dubbo、Motan等RPC框架结合服务发现和健康检查组件就能对业务层进行“服务化”的改造了。这里我简单画了一张图来说明一下对业务层“服务化”改造的实现过程。<br>
<img src="https://static001.geekbang.org/resource/image/ef/46/ef89c3369f163d3017c2a3cd81505146.png" alt="">
业务层的多台服务器在启动上线时,先在“服务注册中心”进行服务注册,登记当前业务机器支持调用的“服务”;启动后,服务注册中心通过“注册中心主动检测”或者“业务服务器主动上报”的方式,定期对服务的可用性进行健康检查,不可用的业务服务器会从注册中心摘除。
长连网关机在需要调用业务层服务时会先通过服务发现、获取当前要用到的服务所注册的业务服务器地址然后直连某一台具体的业务服务器进行RPC服务调用。
这样,通过对业务层进行“服务化”改造,利用服务注册和服务自动发现机制,我们就能够让业务层做到完全的无状态化。不管我们的业务层如何进行扩缩容,接入层也能随时调用到业务层提供的服务,从而实现了业务层的“水平扩展”。
### 资源层如何水平扩展
解决了业务层水平扩展的问题,我们再来看一下资源层是如何“水平扩展”的。
对于即时消息场景中,依赖度非常高的资源层来说,当面临高并发的突发流量时,同样容易出现读写的瓶颈。比如前面讲到的消息内容和消息索引的缓存、维护未读数的资源、存储离线消息的资源,等等,当用户量和消息收发频率很高时,也都会面临较大压力。
这里所说的资源层,包括我们使用到的缓存和存储。
缓存的水平扩展咱们在上一篇中有讲过针对数据读取压力的问题我们可以通过增加从库、增加L1缓存、应用层支持本地缓存的多级缓存模式等手段来解决。
而对于支持数据写入的主库来说由于其存在单主的情况所以在写入量大时也容易出现瓶颈。因此我们可以通过分片Sharding机制来解决。
下面我用聊天场景中“最近联系人”的缓存来举个例子。
当用户的消息收发非常频繁的时候,主库的缓存更新压力会很大。通过分片机制,我们就可以把不同用户的“最近联系人”缓存分散到不同的缓存实例中;通过增加缓存实例的方式,来缓解单实例的写入压力。
同样,对于存储层资源来说,我们也可以通过增加从库和数据分片,对资源层进行水平扩展,从而提升资源的压力应对能力。
## 容器化部署
现在,我们针对接入层、业务层、资源层的水平扩展方案都有了,接下来要考虑的就是如何实施的问题。
比如,接入层服务和业务层服务在出现瓶颈的时候,我们如何进行快速扩容呢?
目前业界比较成熟的方案是通过Docker等容器化技术来解决。
Docker容器化就是指当我们的服务需要扩容时先把服务打包为Docker镜像通过运维系统或者第三方的Kubernetes等容器管理服务来动态分发镜像到需要部署的机器上并进行镜像的部署和容器启停。
这里的Docker镜像你可以理解为一个“包含服务运行代码和运行环境的最小的操作系统”。当镜像分发到需要部署的机器上后启动此镜像的容器这个“最小化的操作系统”就能够把你的服务运行代码快速部署并运行起来。
那么为什么Docker等类似的容器化技术能够让我们的服务做到快速扩容呢
举个例子,我们在聊天场景中用到的图片和视频转码服务,在生成缩略图和处理视频时,需要先安装一些本地化的工具。
在没有容器化部署前,我们要扩容这个图片或视频服务时,就需要先在相应的部署机器上安装一堆工具,然后才能部署服务。不过有的时候,安装工具对操作系统的内核版本和其他依赖有要求,因此经常会出现有的机器装不上,有的机器还需要安装些别的依赖等情况,整个的扩容效率很低。
而有了Docker等容器化技术之后就能非常方便地解决以上这些问题了。
我们可以以某一个固定版本的操作系统镜像为基础,然后在构建自己的应用镜像时,再安装上依赖的工具包。这样,所有的依赖都已经固化到“最小化的操作系统”中了,不再依赖部署机器本身的操作系统版本,也不需要在部署机器上安装各种工具了。
可见通过Docker等容器化技术我们能非常方便地解决服务部署复杂和低效的问题你可以在以后的实际业务中尝试运用一下。
## 小结
接下来简单回顾一下今天课程的内容。
这一讲,我主要针对即时消息场景中,链路各层如何实现快速地“水平扩展”,介绍和分析了一些解决瓶颈问题的方案。
由于“垂直扩展”的可扩展性依赖于单机自身的硬件能力,并不能彻底解决资源和服务器“无限扩容”的问题,因此需要链路各层能够做到“水平扩展”。
各层的水平扩展的**实现**,有以下几种参考方案。
<li>
<p>**针对接入层的水平扩展**,我们需要解决好两个瓶颈问题:<br>
一个是接入层的入口VIP瓶颈问题我们可以针对单域名支持多VIP映射并通过DNS轮询来进行负载均衡<br>
而针对业务自身的接入层服务,我们可以通过中央的“在线状态”资源,来解耦业务层的依赖,从而实现水平扩展。</p>
</li>
<li>
**针对业务层的水平扩展**,我们可以进行“服务化”改造,依托“服务注册中心”和“服务自动发现”解决调用方寻址问题,实现业务层的水平扩展。
</li>
<li>
**针对资源层的水平扩展**,我们可以通过数据分片机制缓解主库和从库压力,还可以通过多从库提升读取能力,实现资源的水平扩展。
</li>
在链路各层的水平扩展的**具体实施**上我们可以借助Docker等容器化技术来屏蔽部署机器的差异。通过应用镜像的自定义部署环境来提升链路各层水平扩展时的部署效率。
服务和资源的水平扩展能力,决定了我们的系统架构随着业务发展是否具备“高可扩展性”,也决定了这个架构的生命周期。
因此,我们在设计一个系统的时候,可以先问一下自己:我设计的这套架构是否能够随着业务的增长,相应的“业务支撑能力”也能随着服务的“水平扩容”做到线性的提升?相信当你开始思考这些问题的时候,你离成为一位合格的架构师也越来越近了。
最后给大家留一道思考题:**要想解决资源层的写入瓶颈,除了分片机制外,还有什么办法能解决资源写入瓶颈的问题呢(比如直播间观看人数的计数资源)?**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,176 @@
<audio id="audio" title="19 | 端到端Trace消息收发链路的监控体系搭建" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/9a/9475e7eb41087efada8651017ca9a89a.mp3"></audio>
你好,我是袁武林。
前面的大部分课程我基本都是围绕“如何开发和设计一个IM系统”的技术点来进行分析和讲解的但在实际项目的工程落地实践中IM系统的监控和保障也是极其重要的一环。
只有通过对消息收发链路的监控,我们才能够实时地了解到链路是否可用,后端服务是否足够健康;如果监控体系不够完善,我们的业务即使上线了,也是处于“蒙眼狂奔”的状态。所以,我们要在工程上线时有一个**“服务上线,监控先行”**的意识和原则。
今天,我们就一起来聊一聊,消息收发链路中监控体系搭建的问题。
在IM场景中常见的监控模式大概可以分为两种一种是基于数据收集的被动监控一种是基于真实探测的主动监控。下面我们来分别看一下这两种监控模式的实现细节和区别。
## 基于数据收集的被动监控
“基于数据收集的被动监控”,应该是我们日常开发保障中,最常见的服务和系统监控方式了。
一般来说被动监控可以粗略地分成几个子类型系统层监控、应用层监控及全链路Trace监控。
### 系统层监控
系统层监控是整个监控体系中最基础的监控方式,一般来说,它主要监控的是操作系统维度的一些核心性能指标。
举个例子我们要对上线的业务进行监控可以通过Nagios、Zabbix等类似的系统监控工具来实时收集机器相关的性能数据如CPU、内存、IO、负载、带宽、PPS、Swap等数据信息。
由于足够通用,系统监控相关的具体细节,我在这里就不展开了,你可以在留言区与我一起探讨。
### 应用层监控
除了系统层面的监控,我们非常依赖的另一种被动监控是应用自身的监控。
在即时消息场景中我们需要实时监控消息收发接口的QPS、耗时、失败数、消息在线推送到达率等如果出现耗时、失败率增长或者推送到达率降低等情况我们就要引起注意并对其进行分析了。
比如在微博平台的场景里就用到了基于Graphite的监控体系来对微博的应用状态进行监控。
在对应用层业务直接相关的API接口进行监控之外我们还需要知道在消息收发的核心链路中这些业务直接相关的API接口所依赖的其他API或资源的性能情况以便于我们在业务接口出现失败率高或者耗时增长的时候能够通过监控系统快速找到导致这个接口出现问题时所依赖的资源或者其他依赖接口。
比如我们还需要监控离线Buffer用到的Redis的使用量、相应的读写QPS、耗时等。
除了监控应用层整体的情况当业务层直接相关的API接口在整体层面上出现性能问题时由于可能只是某一两台机器的API接口出现了性能问题并且由于统计的原因导致该API接口在整体上看失败率升高或者耗时增加因此为了便于排查和分析我们一般还需要对单机的应用状态分别进行监控。
比如某一台机器由于内存不够吃Swap了或者网络发生抖动导致接口耗时增长了我们就需要针对这台单机进行监控来快速发现问题并处置。
应用层的监控数据收集和使用的架构,你可以参考下图:<br>
<img src="https://static001.geekbang.org/resource/image/2a/ab/2a862092ff112cb8a97e0d5c878cccab.png" alt="">
应用服务进程通过本地套接字的方式把服务自身的Metrics数据发送给本机的“数据收集代理器”Agent或者通过本地日志记录的方式记录服务的Metrics数据。
“数据收集代理器”Agent从本地日志里流式获取这些日志数据最终收集到的监控数据由“数据收集代理器”上报给远程的数据收集服务集群收集集群对数据进行缓存和预聚合然后再提交给存储集群。
监控数据的存储集群出于数据规模和数据聚合查询能力的考虑一般会采用“时序数据库”来进行多精度的存储比如OpenTSDB、InfluxDB等然后通过时序数据库的高压缩比存储和聚合计算功能来解决监控数据规模大和查询效率低的问题最终存储到“时序数据库”中的监控数据通过Web服务对用户提供时间维度的界面查询功能。
对于系统层监控和应用层监控目前业界都有非常成熟的解决方案。比如常见的Statsd + Graphite + Grafana方案和ELKElasticsearch + Logstash + Kibana方案它们的使用度都非常高。在实现上也基本和上面图中展现的监控数据收集与架构方式差不多所以具体的细节实现我在这里就不展开了你可以自行了解一下。
### 全链路Trace监控
除了系统监控和应用服务监控外在严重依赖网络可用性的即时消息场景里很多时候我们需要关心的不仅仅是服务端的性能还要从用户自身的体验角度出发来全局性地监控IM服务的可用性和性能。
另外,由于各个微服务都是独立部署并且互相隔离的,很多时候,我们在排查消息收发失败的原因时,很难查询到具体的异常是由哪一个依赖的服务或者资源引起的,问题定位和处理效率也就非常低。
怎样才能把某次消息收发的各环节的性能数据,以及整个访问链路的情况聚合起来,以便于我们来定位问题呢?
一个比较好的解决方案就是基于Trace服务对消息的收发行为进行“全链路监控数据收集”。
那么接下来我们就来了解一下这个Trace服务到底是什么
Trace一词的出现起源于Google的一篇官方论文[“Dappera Large-Scale Distributed Systems Tracing Infrastructure”](https://ai.google/research/pubs/pub36356)。
在这篇论文中介绍了Google的Dapper系统并首次定义了什么是分布式跟踪系统以及介绍了分布式跟踪系统的三大设计要点低开销、对应用透明、高可扩展性。
为了实现分布式链路追踪Dapper论文提出了Trace、Span、Annotation的概念并给出了一个Trace调用的示例如下图<br>
<img src="https://static001.geekbang.org/resource/image/46/c4/46ce2e37f7d6cd5309f8a77d3f6d27c4.png" alt="">
A~E分别表示五个服务用户发起一次请求到A然后A分别发送RPC请求到B和CB处理请求后返回C还要发起两个RPC请求到D和E最终服务A将请求结果返回给用户。
我们再分别来看一下Trace、Span、Annotation的概念。
**Trace**表示对一次请求完整调用链的跟踪每一条链路都使用一个全局唯一的TraceID来标识。类似于上图中的整个一次调用链路就是一次Trace。
**Span**是指在链路调用中,两个调用和被调用服务的请求/响应过程叫做一次Span。一条Trace可以被认为是由多个 Span组成的一个有向无环图DAG图
比如上图的示例中用户对服务A的请求和响应过程就是一个Span假设叫Span 1服务A对服务B的调用和响应过程是另一个Span假设叫Span 2。Span支持父子关系比如这里的Span 1就是Span 2的父Span这些Span通过同一个TraceID来串联。
一个Span会记录4个时间戳“客户端发送时间Client Send”“服务端接收时间Server Receive”“服务端发送时间(Server Send)”“客户端接收时间Client Receive”。
通过这4个时间戳我们就可以在一次请求完成后计算出整个Trace的执行耗时、服务端处理耗时和网络耗时以及Trace中每个Span过程的执行耗时、服务端处理耗时和网络耗时。
比如,客户端整体调用耗时=Client Receive-Client Send服务端处理耗时=Server Send-Server Receive那么这一次请求的网络耗时=客户端整体调用耗时-服务端处理耗时。
**Annotation**主要用于用户自定义事件Annotation可以携带用户在链路环节中的自定义数据用来辅助定位问题。
比如在直播互动场景中记录发弹幕的Trace的Span里还可以利用Annotation通过KV键值对的方式把房间ID、发送人UID等信息也一起记录下来便于我们后续根据这些KV键值对进行业务维度的查询。
目前业界比较成熟的分布式Trace系统有Twitter的Zipkin、Uber的Jaeger、阿里的鹰眼、美团的Mtrace等等。
在这里,我以使用比较广泛的**Zipkin**为例,其整体的实现架构你可以参考下面的这张[官网图](https://zipkin.io/pages/architecture.html)<br>
<img src="https://static001.geekbang.org/resource/image/bf/27/bfeb4de4b3b5cd7da3979ea7aa430227.png" alt="">
- **Reporter模块**通过AOP探针或者硬编码的方式嵌入到业务代码中负责Span的生成和上报。
- **Transport模块**是Trace数据上报通道支持HTTP、Kafka、Scribe等方式。
- **Colletor模块**负责Trace数据的接收并将Trace数据写入到中央存储中。
- **Storage部分**为存储组件默认是In-Memory存储并支持Cassandra、Elasticsearch、MySQL等存储系统。
- **API层**提供Trace的查询、分析和上报链路的对外接口。
- **UI部分**主要用于页面展示。
可见通过应用类似Zipkin的这种分布式全链路Trace系统我们不仅能做到快速排查消息收发链路中出现的问题而且还能根据Trace数据来分析各个调用环节的性能并可以结合实时数据分析工具如Flink多维度地进行业务维度的监控和报警。
在微博的线上业务中就通过基于Zipkin优化定制的Trace系统来定位消息收发的故障点以及用于链路优化的分析支撑。
以下图中出现的故障点为例:<br>
<img src="https://static001.geekbang.org/resource/image/a7/34/a74473c812c9a53afea75f720654c334.png" alt="">
这一次群聊消息查询失败的原因是调用一个“富文本”解析服务1秒超时失败红框1调用一个叫spage接口而且还发现群聊服务对“富文本消息”解析是串行调用的红框2这里的“串行调用”就是一个待优化点。
全链路监控Trace中一个值得注意的问题是Trace数据采样率。
由于一次消息收发的调用链路Span数一般都非常多对于访问量较大的场景来说全量的Trace数据量太大所以一般会通过采样的方式来减少Trace数据的收集。比如在App启动时让服务端返回告知客户端采样率客户端按照约定的采样率对部分请求进行采样。
举个实际的例子在微博的线上环境中对上行请求一般是百分百采样对下行普通用户一般是1%采样对VIP用户上下行请求是全量采样。
除了采样率问题另一个比较麻烦就是Trace数据的采集问题。
虽然大部分分布式Trace系统支持多语言Reporter来上报数据但由于各系统的完善程度差别比较大特别是基于AOP等探针来“无感知”地对各种中间件的支持还是不太够因此在整体上还需要一定的探针的工作开发量。
另外针对多个异构系统的对接除了在各自系统的业务代码中直接上报Trace数据外我们还可以通过本地日志+Agent上报的方式来解耦异构系统对Trace SDK的强依赖。
## 基于回环探测的主动监控
前面讲到,不管是系统监控、应用监控,还是全链路监控,本质上都是基于数据汇报的被动式监控。
正常情况下,基于各种监控数据收集的被动监控,能够协助我们快速发现问题,但当服务出现故障时,被动监控依赖的数据收集就会容易出现上报延迟,甚至上报失败的情况。比如,当系统负载很高时,业务系统就很难再保障监控数据的上报了。
所以,业界另一种实时监控业务系统可用性的方式就是:“基于回环探测的主动监控”。
对于即时消息场景来说大部分场景都是基于用户维度的消息收发的业务形态因此我们可以通过固定的两个或几个测试用户的UID进行消息的互相收发利用消息收发回环来实时探测消息链路的可用性。大概的思路如下图<br>
<img src="https://static001.geekbang.org/resource/image/8a/c8/8aa8c00907ec584c8678529ace9581c8.png" alt="">
我们分别在机房1和机房2部署探测程序用来监控两个机房的消息收发接口可用性。机房1的探测程序设置成“自己的ID”为UID1“对方的ID”为UID2机房2的探测程序设置刚好相反。
探测失败的情况有以下两种:
- 一是探测程序调用本机房的发送消息API来发送消息如果API调用失败或者超时本次探测就会失败然后报警
- 另一种情况比如机房1的探测程序发送数字N之后在下一次发送前会尝试通过服务的API接口来检测是否有接收到来自机房2的探测程序发出的数字N+1如果没有收到则说明机房2的探测程序可能出现异常或者机房2的应用服务接口有异常这种情况如果连续出现就应当提示失败然后报警。
可以发现,通过回环探测的方式,探测程序不仅能检测本机房应用服务接口的可用性,也能通过连续发送的数字,间接地探测出对方机房应用服务的可用性,从而避免了由于两个机房网络间的异常,而无法发现消息收发失败的情况。
这里我再稍微延伸一下,对于多机房或者单机房的回环探测,你可以参考双机房的探测模式,来制定相应的策略。
主动探测监控程序作为一个独立的第三方,通过模拟用户消息收发的行为,弥补了被动监控可能由于应用服务不可用,导致监控数据无法上报的缺陷。但探测报警的缺陷在于可模拟的用户有限,并不能覆盖所有用户的整体情况,存在以偏概全的风险。
因此,我们可以将主动探测监控和被动监控一起协同使用,作为即时消息服务的监控双保险。
## 小结
好,简单回顾一下这一讲的内容。
今天,我们从消息收发链路的监控体系搭建出发,讲解了业界对于服务监控的两种常见模式:**被动监控**和**主动监控**。
被动监控主要依赖服务器或者应用服务的监控数据上报,通过第三方监控系统,来对监控数据进行展示。
被动监控又可以细分为**系统层监控**和**应用层监控**,这两种监控通过实时收集机器层面和应用服务层面的性能数据,协助我们实时掌握机器和应用服务的可用性。
另外,还有一种**全链路Trace监控**,也属于被动监控,实际上也属于应用层监控的范畴。
它是基于Google的Dapper论文衍生出的众多分布式链路追踪系统进一步通过链路Trace将消息收发行为进行整体的端到端的串联极大地提升了问题排查的效率而且为链路优化分析和用户访问数据分析提供了强有力的监控数据支撑。
为了弥补被动监控依赖机器和应用服务的监控数据上报的问题,我们还可以通过第三方的主动探测程序,来实现主动监控。在消息收发场景中,通过模拟用户收发消息行为的回环探测方式,来监控通道的可用性。
我们在即时消息场景中,就可以通过以上这两种监控方式的协同,来更好地监控消息收发服务的可用性。
搭建一套完备的监控体系的重要性是如此之高,特别是对于大规模的分布式应用场景来说,出现这样或那样的问题和故障,已经是一个常态化的情形。如果没有一套可以实时反映系统整体健康状况的监控系统,无异于是盲人摸象,会让我们无法正确及时地评估业务实际受影响的范围,也无法快速定位问题。
实际上对于今天课程中讲到的这些监控实现的方式也是前后端普遍通用的方案不仅适用于IM的场景大部分的业务场景也都是可以参考使用的也希望你能尝试去了解然后在自己的业务中实践拓展。
最后给你留一个思考题:**全链路Trace系统中如果被Trace服务依赖了其他还没有接入Trace的API是否追踪还能正常运转**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,160 @@
<audio id="audio" title="20 | 存储和并发:万人群聊系统设计中的几个难点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/56/0f27985b59549e494fd3e4a08bcc3656.mp3"></audio>
你好,我是袁武林。
我在场景篇的第10讲[“自动智能扩缩容:直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)中,分析了直播互动场景中,容易出现瓶颈的原因主要在于:“直播间人数多,短时间内活跃度高,消息的扇出量巨大”。
那么,对于同样属于多人互动的群聊场景来说,虽然在“群人数”等方面与高热度的直播间相比要少一些,但由于同时开播的直播间数量一般不会太多,所以群在数量上的总体量级相对要大得多,可能上百万个群同时会有消息收发的情况发生。因此,在整体的流量方面,群聊场景的消息扇出也是非常大的。
而且和直播互动场景不太一样的是,直播互动中,热度高的直播活动持续时间都比较短,可以借助上云,来进行短时间的扩容解决,成本方面也比较可控;但群聊的场景,一般是流量总量高,但是峰值没有那么明显,靠扩容解决不太现实。因此,更多地需要我们从架构和设计层面来优化。
今天,我们就一起从架构设计层面,来聊一聊万人群聊系统中的几个难点,以及相应的解决方案。
## 群聊消息怎么存储?
首先来看一看群聊消息存储的问题。
关于点对点聊天场景我在第2课[“消息收发架构为你的App加上实时通信功能”](https://time.geekbang.org/column/article/127978)中也有讲到:我们在一条消息发出后,会针对消息收发的双方,各自存储一条索引,便于双方进行查询、删除、撤回等操作。
那么,对于群聊消息来说,是不是也需要给群里的每一个用户,都存储一条消息索引呢?
这里需要注意的是:对于点对点聊天来说,针对消息收发双方进行用户维度的索引存储,能便于后续会话维度的消息查看和离线消息的获取,但如果群聊场景也采取这种方式,那么假设一个群有一万个人,就需要针对这一万个人都进行这一条消息的存储,一方面会使写入并发量巨大,另一方面也存在存储浪费的问题。
所以,业界针对群聊消息的存储,一般采取“读扩散”的方式。也就是一条消息只针对群维度存储一次,群里用户需要查询消息时,都通过这个群维度的消息索引来获取。
用户查询群聊消息的大概流程,你可以参考下图:<br>
<img src="https://static001.geekbang.org/resource/image/90/4e/90d8e743b86a2db8474cc00a7cd2d14e.png" alt="">
系统先查询这个用户加入的所有群根据这些群的最新一条消息的ID消息ID与时间相关或者最新一条消息的产生时间来进行“最近联系人”维度的排序再根据这些群ID获取每个群维度存储的消息。
### 怎么保证新加入群的用户只看到新消息?
群聊用户共用群维度的消息存储,能大幅降低用户维度消息的写入。
但这里有一个问题:如果群消息是共享的,怎么保证新加入群的用户看不到加群前的群聊消息呢?
解决这个问题其实比较简单,你可以采取下图这个方案:<br>
<img src="https://static001.geekbang.org/resource/image/a1/ce/a19de4332253082f0324c2e6cdc294ce.png" alt="">
我们只需要在用户加群的时候记录一个“用户加群的信息”把用户加群时间、用户加群时该群最新一条消息的ID等信息存储起来当用户查询消息时根据这些信息来限制查询的消息范围就可以了。
### 单个用户删除消息怎么办?
除了新加群用户消息查询范围的问题,群消息共享存储方案在实现时,还有一个比较普遍的问题:如果群里的某一个用户删除了这个群里的某条消息,我们应该怎么处理?
首先,由于群消息是共用的,我们肯定不能直接删除群消息索引中的记录。
一个可行的办法是:在用户删除消息的时候,把这条被删除消息加入到当前用户和群维度的一个删除索引中;当用户查询消息时,我们对群维度的所有消息,以及对这个“用户和群维度”的删除索引进行聚合剔除就可以了。
同样的处理,你还可以用在其他一些私有类型的消息中。比如,只有自己能看到的一些系统提示类消息等。
## 未读数合并变更
解决了群聊消息存储的问题,还有一个由于群聊消息高并发扇出而引起的问题。
我在[“07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?”](https://time.geekbang.org/column/article/132598)这一篇中讲到过:针对每一个用户,我们一般会维护两个未读数,用于记录用户在某个群的未读消息数和所有未读数。
也就是说,当群里有人发言时,我们需要对这个群里的每一个人都进行“加未读”操作。因此,对于服务端和未读数存储资源来说,整体并发的压力会随着群人数和发消息频率的增长而成倍上升。
以一个5000人的群为例假设这个群平均每秒有10个人发言那么每秒针对未读资源的变更QPS就是5w如果有100个这样的群那么对未读资源的变更压力就是500w所以整体上需要消耗的资源是非常多的。
解决这个问题的一个可行方案是:在应用层对未读数采取**合并变更**的方式,来降低对存储资源的压力。
合并变更的思路大概如下图:<br>
<img src="https://static001.geekbang.org/resource/image/15/70/151bc6eb9ddb5b85ee10cc499d2a9370.png" alt="">
未读变更服务接收群聊的加未读请求将这些加未读请求按照群ID进行归类并暂存到群ID维度的多个“暂存队列”中这些“暂存队列”的请求会通过一个Timer组件和一个Flusher组件来负责处理。
Timer组件负责定时刷新这些队列中的请求比如每一秒从这些“暂存队列”取出数据然后交给Aggregator进行合并处理Flusher组件则会根据这些“暂存队列”的长度来进行刷新比如当队列长度到达100时Flusher就从队列中取出数据再交给Aggregator来进行合并处理。
所以Timer和Flusher的触发条件是这些队列的请求中有任意一个到达均会进行刷新操作。
提交给Aggregator的加未读请求会进行合并操作。比如针对群里的每一个用户将多个归属于该群的加未读请求合并成一个请求再提交给底层资源。
如上图所示群ID为gid1里的用户uid1和uid2通过合并操作由4次加未读操作incr 1合并成了各自一条的加未读操作incr 2。
通过这种方式就将加未读操作QPS降低了一半。如果每秒群里发消息的QPS是10的话理论上我们通过这种“合并”的方式能将QPS降低到1/10。
当然,这里需要注意的是:由于加未读操作在应用层的内存中会暂存一定时间,因此会存在一定程度的加未读延迟的问题;而且如果此时服务器掉电或者重启,可能会丢失掉一部分加未读操作。
为了提升“合并变更”操作的合并程度我们可以通过群ID哈希的方式将某一个群的所有未读变更操作都路由到某一台服务器这样就能够提升最终合并的效果。
### 离线Buffer只存消息ID
通过“合并变更”,我们解决了万人群聊系统中,未读数高并发的压力问题。
接下来我们看一下,在离线推送环节中,针对群聊场景还有哪些可优化的点。
我在第9课[“分布式一致性:让你的消息支持多终端漫游?”](https://time.geekbang.org/column/article/136020)中有讲到,为了解决用户离线期间收不到消息的问题,我们会在服务端按照接收用户维度,暂存用户离线期间的消息,等该用户下次上线时再进行拉取同步。
这里的离线Buffer是用户维度的因此对于群聊中的每一条消息服务端都会在扇出后进行暂存。
假设是一个5000人的群一条消息可能会暂存5000次这样一方面对离线Buffer的压力会比较大另外针对同一条消息的多次重复暂存对资源的浪费也是非常大的。
要解决多次暂存导致离线Buffer并发压力大的问题一种方案是可以参考“未读数合并变更”的方式对群聊离线消息的存储也采用“合并暂存”进行优化所以这里我就不再细讲了。
另一种解决方案是:我们可以对群聊离线消息的暂存进行限速,必要时可以丢弃一些离线消息的暂存,来保护后端资源。
因为通过“版本号的链表机制”,我们可以在用户上线时发现“离线消息”不完整的问题,然后再从后端消息存储中重新分页获取离线消息,从而可以将一部分写入压力延迟转移到读取压力上来。
不过这里你需要注意的是:这种降级限流方式存在丢失一些操作信令的问题,是有损降级,所以非必要情况下尽量不用。
另外针对群聊消息重复暂存的问题我们可以只在离线Buffer中暂存“消息ID”不暂存消息内容等到真正下推离线消息的时候再通过消息ID来获取内容进行下推以此优化群聊消息对离线Buffer资源过多占用的情况。
### 离线消息批量ACK
在群聊离线消息场景中还有一个相对并发量比较大的环节就是离线消息的ACK处理。
我在[“04 | ACK机制如何保证消息的可靠投递](https://time.geekbang.org/column/article/129751)这节课中讲到我们会通过ACK机制来保证在线消息和离线消息的可靠投递。但是对于群的活跃度较高的情况来说当用户上线时服务端针对这个群的离线消息下推量会比较大。
以微博场景中的超大规模的粉丝群为例本来群内的用户就已经比较活跃了如果该群隶属的明星突然空降进来可能会导致大量离线用户被激活同一时间会触发多个用户的离线消息下推和这些离线消息的ACK针对离线消息接收端的ACK回包服务端需要进行高并发的处理因而对服务端压力会比较大。
但实际上由于群聊离线消息的下推发生在用户刚上线时这个时候的连接刚建立稳定性比较好一般消息下推的成功率是比较高的所以对ACK回包处理的及时性其实不需要太高。
因此,一种优化方案是:**针对离线消息接收端进行批量ACK**。
参照TCP的Delay ACK延迟确认机制我们可以在接收到离线推送的消息后“等待”一定的时间如果有其他ACK包需要返回那么可以对这两个回包的ACK进行合并从而降低服务端的处理压力。
需要注意的是接收端的Delay ACK可能会在一定程度上加剧消息重复下推的概率。比如ACK由于延迟发出导致这时的服务端可能会触发超时重传重复下推消息。
针对这个问题,我们可以通过接收端去重来解决,也并不影响用户的整体体验。
### 不记录全局的在线状态
群聊场景下的超大消息扇出,除了会加大对离线消息的资源消耗,也会对消息的在线下推造成很大的压力。
举个例子:在点对点聊天场景中,我们通常会在用户上线时,记录一个“用户连接所在的网关机”的在线状态,而且为了和接入服务器解耦,这个在线状态一般会存储在中央资源中;当服务端需要下推消息时,我们会通过这个“中央的在线状态”来查询接收方所在的接入网关机,然后把消息投递给这台网关机,来进行最终消息的下推。
在群聊场景中,很多实现也会采用类似方式进行在线消息的精准下推,这种方案在群人数较少的时候是没问题的,但是当群成员规模很大时,这种方式就会出现瓶颈。
一个瓶颈在于,用户上线时对“在线状态”的写入操作;另一个瓶颈点在于,服务端有消息下推时,对“在线状态”的高并发查询。
因此,针对万人群聊的场景,我们可以采取类似直播互动中的优化方式,不维护全局的中央“在线状态”,而是让各网关机“自治”,来维护接入到本机的连接和群的映射。你可以参考下图所示的实现过程:<br>
<img src="https://static001.geekbang.org/resource/image/24/cd/2427222d320850a08e76f7200cf094cd.png" alt="">
比如同一个群的用户A、B、C分别通过网关机1、2、3上线建立长连处理建连请求时网关机1、2、3 会分别在各自的本地内存维护当前登录的用户信息。
上线完成后用户A在群里发了一条消息业务逻辑处理层会针对这条消息进行处理查询出当前这条消息所归属群的全部用户信息假设查询到这个群一共有3人除去发送方用户A还有用户B和用户C。
然后业务逻辑处理层把消息扇出到接收人维度投递到全局的消息队列中每一台网关机在启动后都会订阅这个全局的Topic因此都能获取到这条消息接着各网关机查询各自本地维护的“在线用户”的信息把归属本机的用户的消息通过长连下推下去。
通过这种方式,消息下推从“全局的远程依赖”变成了“分片的本地内存依赖”,性能上会快很多,避免了服务端维护全局在线状态的资源开销和压力。
## 小结
今天的课程,我主要是分析了一些在万人群聊场景中比较突出和难解决的问题,并给出了针对性的应对方案。比如以下几种:
- 针对群聊消息的存储,我们可以从点对点的**“写扩散”优化成“读扩散”**,以解决存储写入并发大和资源开销大的问题;
- 针对高热度的群带来的“高并发未读变更”操作,我们可以通过**应用层的“合并变更”**,来缓解未读资源的写入压力;
- 对于离线消息的优化我们只需要存储消息ID避免重复的消息内容存储浪费离线Buffer资源还可以参考TCP的Delay ACK机制**在接收方层面进行批量ACK**,降低服务端的处理并发压力;
- 对于单聊场景中依赖“中央全局的在线状态”,来进行消息下推的架构瓶颈,我们可以在群聊场景中优化成**“网关机本地自治维护”**的方式,以此解决高并发下推时,依赖全局资源容易出现瓶颈的问题,从而提升群聊消息在线下推的性能和稳定性。
针对大规模群聊系统的架构优化,一直是即时消息场景中非常重要和必要的部分,也是体现我们架构能力和功底的环节。
今天课程中介绍的针对万人群聊系统优化的一些应对方案很多都具备普适性比如“未读合并变更”的方案实际上也能应用在很多有写入瓶颈的业务上如DB的写入瓶颈在微博的线上业务中目前也被大范围使用。你也可以看一看自己的业务中是否也有类似可优化的场景可以尝试来参考这个方案进行优化。
最后给大家留一个思考题:**点对点消息的在线下推,也适合采用“网关机自治维护本地在线状态”的方式吗?说说你的看法吧。**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,355 @@
<audio id="audio" title="21 | 期末实战为你的简约版IM系统加上功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/9e/f6c8f6a6da60304fb8540e58f385129e.mp3"></audio>
你好,我是袁武林。
在期中实战中,我们一起尝试实现了一个简易版的聊天系统,并且为这个聊天系统增加了一些基本功能。比如,用户登录、简单的文本消息收发、消息存储设计、未读数提示、消息自动更新等。
但是期中实战的目的主要是让你对IM系统的基本功能构成有一个直观的了解所以在功能的实现层面上比较简单。比如针对消息的实时性期中采用的是基于HTTP短轮询的方式来实现。
因此,在期末实战中,我们主要的工作就是针对期中实战里的消息收发来进行功能优化。
比如我们会采用WebSocket的长连接来替代之前的HTTP短轮询方式并且会加上一些课程中有讲到的相对高级的功能如应用层心跳、ACK机制等。
希望通过期末整体技术实现上的升级你能更深刻地体会到IM系统升级前后对使用方和服务端压力的差异性。相应的示例代码我放在了[GitHub](https://github.com/coldwalker/Sample)里,你可以作为参考来学习和实现。
## 功能介绍
关于这次期末实战,希望你能够完成的功能主要包括以下几个部分:
1. 支持基于WebSocket的长连接。
1. 消息收发均通过长连接进行通信。
1. 支持消息推送的ACK机制和重推机制。
1. 支持客户端的心跳机制和双端的idle超时断连。
1. 支持客户端断线后的自动重连。
## 功能实现拆解
接下来,我们就针对以上这些需要升级的功能和新增的主要功能,来进行实现上的拆解。
### WebSocket长连接
首先期末实战一个比较大的改变就是将之前HTTP短轮询的实现改造成真正的长连接。为了方便Web端的演示这里我建议你可以使用WebSocket来实现。
对于WebSocket我们在客户端JSJavaScript里主要是使用HTML5的原生API来实现其核心的实现代码部分如下
```
if (window.WebSocket) {
websocket = new WebSocket(&quot;ws://127.0.0.1:8080&quot;);
websocket.onmessage = function (event) {
onmsg(event);
};
//连接建立后的事件监听
websocket.onopen = function () {
bind();
heartBeat.start();
}
//连接关闭后的事件监听
websocket.onclose = function () {
reconnect();
};
//连接出现异常后的事件监听
websocket.onerror = function () {
reconnect();
};
} else {
alert(&quot;您的浏览器不支持WebSocket协议&quot;
}
```
页面打开时JS先通过服务端的WebSocket地址建立长连接。要注意这里服务端连接的地址是ws://开头的不是http://的了如果是使用加密的WebSocket协议那么相应的地址应该是以wss://开头的。
建立长连之后要针对创建的WebSocket对象进行事件的监听我们只需要在各种事件触发的时候进行对应的逻辑处理就可以了。
比如API主要支持的几种事件有长连接通道建立完成后通过onopen事件来进行用户信息的上报绑定通过onmessage事件对接收到的所有该连接上的数据进行处理这个也是我们最核心的消息推送的处理逻辑另外在长连接通道发生异常错误或者连接被关闭时可以分别通过onerror和onclose两个事件来进行监听处理。
除了通过事件监听来对长连接的状态变化进行逻辑处理外我们还可以通过这个WebSocket长连接向服务器发送数据消息。这个功能在实现上也非常简单你只需要调用WebSocket对象的send方法就OK了。
通过长连接发送消息的代码设计如下:
```
var sendMsgJson = '{ &quot;type&quot;: 3, &quot;data&quot;: {&quot;senderUid&quot;:' + sender_id + ',&quot;recipientUid&quot;:' + recipient_id + ', &quot;content&quot;:&quot;' + msg_content + '&quot;,&quot;msgType&quot;:1 }}';
websocket.send(sendMsgJson);
```
此外针对WebSocket在服务端的实现如果你是使用JVMJava Virtual MachineJava虚拟机系列语言的话我推荐你使用比较成熟的Java NIO框架Netty来做实现。
因为Netty本身对WebSocket的支持就很完善了各种编解码器和WebSocket的处理器都有这样我们在代码实现上就比较简单。
采用Netty实现WebSocket Server的核心代码你可以参考下面的示例代码
```
EventLoopGroup bossGroup =
new EpollEventLoopGroup(serverConfig.bossThreads, new DefaultThreadFactory(&quot;WebSocketBossGroup&quot;, true));
EventLoopGroup workerGroup =
new EpollEventLoopGroup(serverConfig.workerThreads, new DefaultThreadFactory(&quot;WebSocketWorkerGroup&quot;, true));
ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerGroup).channel(EpollServerSocketChannel.class);
ChannelInitializer&lt;SocketChannel&gt; initializer = new ChannelInitializer&lt;SocketChannel&gt;() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//先添加WebSocket相关的编解码器和协议处理器
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
pipeline.addLast(new WebSocketServerProtocolHandler(&quot;/&quot;, null, true));
//再添加服务端业务消息的总处理器
pipeline.addLast(websocketRouterHandler);
//服务端添加一个idle处理器如果一段时间Socket中没有消息传输服务端会强制断开
pipeline.addLast(new IdleStateHandler(0, 0, serverConfig.getAllIdleSecond()));
pipeline.addLast(closeIdleChannelHandler);
}
}
serverBootstrap.childHandler(initializer);
serverBootstrap.bind(serverConfig.port).sync(
```
首先**创建服务器的ServerBootstrap对象**。Netty作为服务端从ServerBootstrap启动ServerBootstrap对象主要用于在服务端的某一个端口进行监听并接受客户端的连接。
接着,**通过ChannelInitializer对象初始化连接管道中用于处理数据的各种编解码器和业务逻辑处理器**。比如这里我们就需要添加为了处理WebSocket协议相关的编解码器还要添加服务端接收到客户端发送的消息的业务逻辑处理器并且还加上了用于通道idle超时管理的处理器。
最后,**把这个管道处理器链挂到ServerBootstrap再通过bind和sync方法启动ServerBootstrap的端口进行监听**就可以了。
### 核心消息收发逻辑处理
建立好WebSocket长连接后我们再来看一下最核心的消息收发是怎么处理的。
刚才讲到客户端发送消息的功能在实现上其实比较简单。我们只需要通过WebSocket对象的send方法就可以把消息通过长连接发送到服务端。
那么,下面我们就来看一下服务端接收到消息后的逻辑处理。
核心的代码逻辑在WebSocketRouterHandler这个处理器中消息接收处理的相关代码如下
```
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
//如果是文本类型的WebSocket数据
if (frame instanceof TextWebSocketFrame) {
//先解析出具体的文本数据内容
String msg = ((TextWebSocketFrame) frame).text();
//再用JSON来对这些数据内容进行解析
JSONObject msgJson = JSONObject.parseObject(msg);
int type = msgJson.getIntValue(&quot;type&quot;);
JSONObject data = msgJson.getJSONObject(&quot;data&quot;);
long senderUid = data.getLong(&quot;senderUid&quot;);
long recipientUid = data.getLong(&quot;recipientUid&quot;);
String content = data.getString(&quot;content&quot;);
int msgType = data.getIntValue(&quot;msgType&quot;);
//调用业务层的Service来进行真正的发消息逻辑处理
MessageVO messageContent = messageService.sendNewMsg(senderUid, recipientUid, content, msgType);
if (messageContent != null) {
JSONObject jsonObject = new JSONObject();
jsonObject.put(&quot;type&quot;, 3);
jsonObject.put(&quot;data&quot;, JSONObject.toJSON(messageContent));
ctx.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(jsonObject)));
}
}
}
```
这里的WebSocketRouterHandler我们也是采用事件监听机制来实现。由于这里需要处理“接收到”的消息所以我们只需要实现channelRead0方法就可以。
在前面的管道处理器链中因为添加了WebSocket相关的编解码器所以这里的WebSocketRouterHandler接收到的都是WebSocketFrame格式的数据。
接下来我们从WebSocketFrame格式的数据中解析出文本类型的收发双方UID和发送内容就可以调用后端业务模块的发消息功能来进行最终的发消息逻辑处理了。
最后把需要返回给消息发送方的客户端的信息再通过writeAndFlush方法写回去就完成消息的发送。
不过,以上的代码只是处理消息的发送,那么针对消息下推的逻辑处理又是如何实现的呢?
刚刚讲到,客户端发送的消息,会通过后端业务模块来进行最终的发消息逻辑处理,这个处理过程也包括消息的推送触发。
因此我们可以在messageService.sendNewMsg方法中等待消息存储、未读变更都完成后再处理待推送给接收方的消息。
你可以参考下面的核心代码:
```
private static final ConcurrentHashMap&lt;Long, Channel&gt; userChannel = new ConcurrentHashMap&lt;&gt;(15000);
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
//处理上线请求
long loginUid = data.getLong(&quot;uid&quot;);
userChannel.put(loginUid, ctx.channel());
}
public void pushMsg(long recipientUid, JSONObject message) {
Channel channel = userChannel.get(recipientUid);
if (channel != null &amp;&amp; channel.isActive() &amp;&amp; channel.isWritable()) {
channel.writeAndFlush(new TextWebSocketFrame(message.toJSONString()));
}
}
```
首先,我们在处理用户建连上线的请求时,会先在网关机内存记录一个“当前连接用户和对应的连接”的映射。
当系统有消息需要推送时,我们通过查询这个映射关系,就能找到对应的连接,然后就可以通过这个连接,将消息下推下去。
```
public class NewMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String topic = stringRedisSerializer.deserialize(message.getChannel());
//从订阅到的Redis的消息里解析出真正需要的业务数据
String jsonMsg = valueSerializer.deserialize(message.getBody());
logger.info(&quot;Message Received --&gt; pattern: {}topic:{}message: {}&quot;, new String(pattern), topic, jsonMsg);
JSONObject msgJson = JSONObject.parseObject(jsonMsg);
//解析出消息接收人的UID
long otherUid = msgJson.getLong(&quot;otherUid&quot;);
JSONObject pushJson = new JSONObject();
pushJson.put(&quot;type&quot;, 4);
pushJson.put(&quot;data&quot;, msgJson);
//最终调用网关层处理器将消息真正下推下去
websocketRouterHandler.pushMsg(otherUid, pushJson);
}
}
@Override
public MessageVO sendNewMsg(long senderUid, long recipientUid, String content, int msgType) {
//先对发送消息进行存储、加未读等操作
//...
// 然后将待推送消息发布到Redis
redisTemplate.convertAndSend(Constants.WEBSOCKET_MSG_TOPIC, JSONObject.toJSONString(messageVO));
}
```
然后我们可以基于Redis的发布/订阅,实现一个消息推送的发布订阅器。
在业务层进行发送消息逻辑处理的最后会将这条消息发布到Redis的一个Topic中这个Topic被NewMessageListener一直监听着如果有消息发布那么监听器会马上感知到然后再将消息提交给WebSocketRouterHandler来进行最终消息的下推。
### 消息推送的ACK
我在[“04 | ACK机制如何保证消息的可靠投递](https://time.geekbang.org/column/article/129751)中有讲到当系统有消息下推后我们会依赖客户端响应的ACK包来保证消息推送的可靠性。如果消息下推后一段时间服务端没有收到客户端的ACK包那么服务端会认为这条消息没有正常投递下去就会触发重新下推。
关于ACK机制相应的服务端代码你可以参考下面的示例
```
public void pushMsg(long recipientUid, JSONObject message) {
channel.writeAndFlush(new TextWebSocketFrame(message.toJSONString()));
//消息推送下去后将这条消息加入到待ACK列表中
addMsgToAckBuffer(channel, message);
}
public void addMsgToAckBuffer(Channel channel, JSONObject msgJson) {
nonAcked.put(msgJson.getLong(&quot;tid&quot;), msgJson);
//定时器针对下推的这条消息在5s后进行&quot;是否ACK&quot;的检查
executorService.schedule(() -&gt; {
if (channel.isActive()) {
//检查是否被ACK如果没有收到ACK回包会触发重推
checkAndResend(channel, msgJson);
}
}, 5000, TimeUnit.MILLISECONDS);
}
long tid = data.getLong(&quot;tid&quot;);
nonAcked.remove(tid);
private void checkAndResend(Channel channel, JSONObject msgJson) {
long tid = msgJson.getLong(&quot;tid&quot;);
//重推2次
int tryTimes = 2;
while (tryTimes &gt; 0) {
if (nonAcked.containsKey(tid) &amp;&amp; tryTimes &gt; 0) {
channel.writeAndFlush(new TextWebSocketFrame(msgJson.toJSONString()));
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
tryTimes--;
}
}
```
用户在上线完成后服务端会在这个连接维度的存储里初始化一个起始值为0的序号tid每当有消息推送给客户端时服务端会针对这个序号进行加1操作下推消息时就会携带这个序号连同消息一起推下去。
消息推送后服务端会将当前消息加入到一个“待ACK Buffer”中这个ACK Buffer的实现我们可以简单地用一个ConcurrentHashMap来实现Key就是这条消息携带的序号Value是消息本身。
当消息加入到这个“待ACK Buffer”时服务端会同时创建一个定时器在一定的时间后会触发“检查当前消息是否被ACK”的逻辑如果客户端有回ACK那么服务端就会从这个“待ACK Buffer”中移除这条消息否则如果这条消息没有被ACK那么就会触发消息的重新下推。
### 应用层心跳
在了解了如何通过WebSocket长连接来完成最核心的消息收发功能之后我们再来看下针对这个长连接我们如何实现新增加的应用层心跳功能。
应用层心跳的作用,我在[第8课“智能心跳机制解决网络的不确定性”](https://time.geekbang.org/column/article/134231)中也有讲到过,主要是为了解决由于网络的不确定性,而导致的连接不可用的问题。
客户端发送心跳包的主要代码设计如下,不过我这里的示例代码只是一个简单的实现,你可以自行参考,然后自己去尝试动手实现:
```
//每2分钟发送一次心跳包接收到消息或者服务端的响应又会重置来重新计时。
var heartBeat = {
timeout: 120000,
timeoutObj: null,
serverTimeoutObj: null,
reset: function () {
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
this.start();
},
start: function () {
var self = this;
this.timeoutObj = setTimeout(function () {
var sender_id = $(&quot;#sender_id&quot;).val();
var sendMsgJson = '{ &quot;type&quot;: 0, &quot;data&quot;: {&quot;uid&quot;:' + sender_id + ',&quot;timeout&quot;: 120000}}';
websocket.send(sendMsgJson);
self.serverTimeoutObj = setTimeout(function () {
websocket.close();
$(&quot;#ws_status&quot;).text(&quot;失去连接!&quot;);
}, self.timeout)
}, this.timeout)
},
}
```
客户端通过一个定时器每2分钟通过长连接给服务端发送一次心跳包如果在2分钟内接收到服务端的消息或者响应那么客户端的下次2分钟定时器的计时会进行清零重置重新计算如果发送的心跳包在2分钟后没有收到服务端的响应客户端会断开当前连接然后尝试重连。
我在下面的代码示例中,提供的“服务端接收到心跳包的处理逻辑”的实现过程,其实非常简单,只是封装了一个普通回包消息进行响应,代码设计如下:
```
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
long uid = data.getLong(&quot;uid&quot;);
long timeout = data.getLong(&quot;timeout&quot;);
logger.info(&quot;[heartbeat]: uid = {} , current timeout is {} ms, channel = {}&quot;, uid, timeout, ctx.channel());
ctx.writeAndFlush(new TextWebSocketFrame(&quot;{\&quot;type\&quot;:0,\&quot;timeout\&quot;:&quot; + timeout + &quot;}&quot;));
}
```
我们实际在线上实现的时候,可以采用前面介绍的“智能心跳”机制,通过服务端对心跳包的响应,来计算新的心跳间隔,然后返回给客户端来进行调整。
好,到这里,期末实战的主要核心功能基本上也讲解得差不多了,细节方面你可以再翻一翻我在[GitHub](https://github.com/coldwalker/Sample)上提供的示例代码。
对于即时消息场景的代码实现来说,如果要真正达到线上使用的程度,相应的代码量是非常庞大的;而且对于同一个功能的实现,根据不同的使用场景和业务特征,很多业务在设计上也会有较大的差异性。
所以,实战课程的设计和示例代码只能做到挂一漏万,我尽量通过最简化的代码,来让你真正了解某一个功能在实现上最核心的思想。并且,通过期中和期末两个阶段的功能升级与差异对比,使你能感受到这些差异对于使用方体验和服务端压力的改善,从而可以更深刻地理解和掌握前面课程中相应的理论点。
## 小结
今天的期末实战我们主要是针对期中实战中IM系统设计的功能来进行优化改造。
比如,**使用基于WebSocket的长连接**代替基于HTTP的短轮询来提升消息的实时性并增加了**应用层心跳、ACK机制**等新功能。
通过这次核心代码的讲解是想让你能理论结合实际地去理解前面课程讲到的IM系统设计中最重要的部分功能也希望你能自己尝试去动手写一写。当然你也可以基于已有代码去增加一些之前课程中有讲到但是示例代码中没有实现的功能比如离线消息、群聊等。
最后再给你留一个思考题:**ACK机制的实现中如果尝试多次下推之后仍然没有成功服务端后续应该进行哪些处理呢**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="22 | 答疑解惑:不同即时消息场景下架构实现上的异同" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8f/69/8fa36b75260937ae17557e7d6fce1169.mp3"></audio>
你好,我是袁武林。
随着专栏最后一个进阶篇模块的更新后,咱们的即时消息专栏课程,到这里就要告一段落了。首先,感谢你在这段时间里对专栏的持续关注,也非常高兴看到你一直在积极地思考和学习。
在专栏的讨论区,同学们也都十分活跃,都在热情地留言和互动讨论,留下了很多比较典型和有意义的问题。
有一些问题可能限于篇幅的原因我没有详细展开那么今天我就摘录出比较有代表性的5个问题来做一下集中的整理和回复。也希望通过这种方式能够帮助你对这些知识点有更清晰的理解和认识。
## 第一问:消息的服务端存储和本地存储
在[第1讲“架构与特性一个完整的IM系统是怎样的](https://time.geekbang.org/column/article/127872)中,有很多同学都问到了这个问题:**即时消息系统实现中,消息一定需要在服务端的存储服务里进行存储吗?**
首先呢,关于服务端存储的问题,我们需要更多地考虑存储成本和数据安全。
一方面,如果消息不在服务端存储,服务器只是作为消息的中转路由服务,那么,相应的消息存储成本就会低很多,在数据安全性方面也更好一些。
另外,这个问题也和产品定位有关。
比如,如果你的产品定位上不需要支持消息多终端同步(比如微信),那么像核心的消息等这些数据,就可以不在服务端进行存储。不过,用户的好友关系等数据信息还是需要存储在服务端的。
你还需要考虑的一点是,即使是不需要支持多终端消息同步的产品,大部分即时消息系统也是支持离线消息的,这种情况其实也需要在服务端对离线消息进行暂存,虽然可能只是暂存较短时间。
除此之外,消息是否需要在服务端存储,你还需要考虑国内监管机制是否允许的问题。如果监管有要求,那么我们所有的消息数据,都需要在服务端存储一定的时间供监管调看,只是这里存储的使用方不是普通用户,而是监管部门。
## 第二问:长连接消息推送的实现
在[第3讲“轮询与长连接如何解决消息的实时到达问题](https://time.geekbang.org/column/article/128942)中,我留下了一个思考题:**TCP长连接的方式是怎么实现“当有消息需要发送给某个用户时能够准确找到这个用户对应的网络连接”**
对于这个问题,不少同学给出的答案都很棒,比如 @王棕生@小可等几位同学。这个问题由于本身比较重要,提问的同学也不少,所以我这里也专门来回答一下。
首先,用户在长连建立后,需要再执行一个**“上线”操作**。
这个“上线”操作主要的工作就是:
- 将当前登录用户的信息提供到服务端,服务端长连接收到这个用户信息后,在服务端维护一个“用户” -&gt; “连接”维度的映射,这个映射可以存到中央资源里或者网关机的本地内存中;
- 当有消息需要推送给这个用户时,负责消息下推的服务查询这个中央的“用户” -&gt; “连接”维度的映射,来获取该用户的连接,通过这个连接将消息进行下推;
- 或者将消息下发给所有网关机,由网关机来查询本地维护的这个映射,是否有该用户的连接在本机,如果有,就通过当前网关机维护的这个连接来进行消息下推;
- 当用户断连下线的时候,再从这个中央的“用户” -&gt; “连接”维度的映射或者网关机本地删除掉这个映射。
## 第三问应用层ACK的必要性
在课程的[第4讲“ACK机制如何保证消息的可靠投递](https://time.geekbang.org/column/article/129751)中,我留下的思考题是:**有了TCP协议本身的ACK机制为什么还需要业务层的ACK机制**
这个问题大家讨论得也比较多有几位同学也都比较正确地讲出了这两种ACK的差异性比如@小伟@恰同学少年@阳仔@王棕生等同学都回答得很棒。我这里也简单说一下这个问题的答案。
这是因为虽然TCP协议本身的ACK机制能够保证消息在正常情况下传输层数据收发的可靠性但在连接异常断开等场景下也可能存在数据丢失的风险。比如TCP的发送缓冲区和接收缓冲区里的数据都可能会丢失。
另外即使消息从TCP传输层成功给到了应用层也并不能保证应用层数据收发的可靠性因为应用层在接收到传输层的数据后也可能发生其他异常。
比如手机Crash了或者突然没电关机了又或者客户端在将消息写入本地数据库时发生异常失败了这些情况都可能会导致消息即使成功在TCP层被ACK了但在业务层上仍然会被丢失。
但是业务层的ACK机制是在应用层接收到数据并且成功执行完必要的存储等逻辑后再ACK给服务端所以能够更好地保障消息收发在业务层的真正可靠性。
## 第四问:离线消息下推的优化
在[第9讲“分布式一致性让你的消息支持多终端漫游](https://time.geekbang.org/column/article/136020)中,有不少同学还问到:**在离线消息如果特别多的情况下,我们应该采取什么方式来下发这些消息呢?**
对于长时间没有登录或者消息接收频繁的用户来说,当这些用户下线一段时候后再上线,经常会面临大量的离线消息下推的问题。
一种比较好的优化方案是:对多条离线消息采取批量打包+压缩的方式来进行下推。
通过这种方式能够大幅降低传输的数据大小也能有效减少大量离线消息下推后的ACK包数量。
但是面对上万条的离线消息,只单纯地采用批量+压缩的方式还是不够的,这种情况下,我们还可以采用**“推拉结合”**的方式来进行优化。
用户上线后对于服务端离线消息的下推可以只推送用户最近N条消息而对于后续要推送的消息我们可以让用户通过向上翻页来触发自动拉取客户端再从服务端来获取当前聊天会话剩下的消息。
另外,在实际的离线消息场景里,我们还需要考虑到离线消息存储成本的问题。
绝大部分IM系统并不会一直缓存用户的离线消息而是一般会按照条数或者时间周期来保留用户最近N条离线消息或者最近多长时间内的离线消息。
## 第五问:群聊和直播互动消息下推的区别
在[第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)一课中,有同学对这个问题感到疑惑:**群聊场景和直播互动场景在消息推送时,实现上不一样的地方在哪?**
@淡蓝小黑同学也在留言中问到
>
<p>文中提到【通过这个优化,相当于是把直播消息的扇出从业务逻辑处理层推迟到网关层】和 您回复我的【业务层在把消息扇出给到网关机时,就已经具体到接收人了,所以不需要通知网关机来进行变更。】这两条不冲突吗?(我看唯一的区别是文中说的是直播间场景,您回复我的是群聊场景,还是说群聊和直播间是用不同的模式扇出消息的?)<br>
其实我想问的是,用户加入直播间,网关机本地维护【本机某个直播间有哪些用户】这个数据的话,当用户离开直播间,加入另一个直播间时,业务处理层还要通知网关层更新本地维护的那个数据,有可能会出现数据不一致的情况,导致用户加入新直播间了,但由于网关层数据没有更新,用户收到不到新直播间的消息。</p>
这是个好问题!对此,我们可以先想一想群聊和直播的使用场景。
对于直播场景来说,房间和房间之间是隔离状态。也就是说,每次用户进入到一个新房间,消息推送层面只和用户当前进入的房间有关联,与其他房间就没有关系了。
所以,每次用户进入新房间时,都需要通过“加入房间”或者“切换房间”等这些携带用户信息和房间信息的信令,来告诉网关机自己当前连接的是哪个房间。
正是通过这些“加入房间”和“切换房间”的信令,网关机才能够在服务端标记这个“用户 -&gt; 房间 -&gt; 连接”的映射关系。当这些网关机收到某一条直播间的消息时,就能够根据这个映射关系,找到对应的房间用户的连接。
换言之,对于直播间消息来说,当我们的消息从业务层给到网关机时,服务端只需要按房间维度来下发消息就可以了。所以**在直播互动场景中,消息的扇出是可以推迟到网关机层的。**
但是对于群聊来说,群和群之间并不是隔离状态。
对于任何群的消息,服务端都需要通过一条长连接通道来进行消息的下推,并没有一个所谓的“进入群聊”来切换多个群的行为。
所以在App打开建立好长连后客户端发出的“上线”这个操作只是会上报一个当前用户信息不需要、也没有办法再告知自己当前进入了哪个群因而在服务端网关机也只会建立一个“用户” -&gt; “连接”的映射。
因此,**在群聊场景中当某一个群有一条消息发出时我们需要在业务层将这条消息从群维度扇出成UID维度再下发给网关机**。
那么,我们为什么不能在网关机本身查询了群成员的相关信息后,再扇出消息呢?
这个操作在实现上当然也是可以的。不过咱们前面提到,为了解耦网关机和业务层逻辑,我们尽量不在网关机做业务维度的逻辑。
因此这里建议对于群聊消息来说,我们还是在业务层扇出后,再提交到网关机进行处理。
## 小结
正如我在开篇词里讲到,即时消息技术实际上是众多前后端技术的紧密结合,涉及到的知识面也是非常广泛的,而且随着业务形态的不断变化和升级,相应的架构设计和技术侧重点上也各有异同。
因此,形而上学的方式往往很难真正做到有的放矢。但我相信,只要我们从问题和业务形态的本质出发,经过不断地思考和实践,就能够在掌握现有即时消息知识的基础上,找到最适合自身业务的架构和方案。
好了,今天的答疑就到这里,如果你还有问题,可以继续给我留言,我们一起讨论。感谢你的收听,我们下期再见。