This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="09 | 分布式一致性:让你的消息支持多终端漫游" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/8a/6fbe81d04168fd794f3e424f6504eb8a.mp3"></audio>
你好我是袁武林。今天我们开始进入场景篇的部分在这个部分中我会介绍在几种典型的垂直业务场景下IM系统具体是如何实现的。
在即时消息的场景里,消息的多终端漫游是一个相对比较高级的功能,所谓的**“多终端漫游”是指:用户在任意一个设备登录后,都能获取到历史的聊天记录。**
这个功能对于有多个手机的用户来说是一个非常有用的功能,试想一下用户在交叉使用多个手机进行聊天后,如果不能在多个终端间自动同步所有的聊天记录,使用体验也不会太好。
但并不是所有的即时消息App都支持这个特性比如微信虽然支持多端登录但不知道出于什么考虑并不能在多端同步历史消息这可能也是微信为数不多被诟病的一个小问题吧。
而Telegram和QQ却很好地支持了“多终端漫游”使得用户在任意端登录都能获取到所有最近收发的消息。
## 如何实现多终端消息漫游
那接下来我们看一下,怎么才能让收发的消息能在多个终端漫游。要支持消息多终端漫游一般来说需要两个前置条件:一种是通过设备维度的在线状态来实现,一种是通过离线消息存储来实现。
### 设备维度的在线状态
对于在多个终端同时登录并在线的用户可以让IM服务端在收到消息后推给接收方的多台设备也推给发送方的其他登录设备。
这样的话,就要求能够按照用户的设备维度来记录在线状态,这个其实也是支持多端登录的一个前提。
### 离线消息存储
另外,如果消息发送时,接收方或者发送方只有一台设备在线,可能一段时间后,才通过其他设备登录来查看历史聊天记录,这种离线消息的多终端漫游就需要消息在服务端进行存储了。当用户的离线设备上线时,就能够从服务端的存储中获取到离线期间收发的消息。
下面,我简单把“多端在线时消息收发的过程”和“离线设备上线时同步消息的过程”画了一下。你可以自行参考,这里就不作具体说明了。
<img src="https://static001.geekbang.org/resource/image/1e/e5/1ebf1653c400d562c3d5bd07b0f0e1e5.png" alt="">
<img src="https://static001.geekbang.org/resource/image/c0/04/c097f863ec7f5a3988e9a2e05edc3204.png" alt="">
## 离线消息同步的几个关键点
对于多终端同时在线的情况,实现上相对比较简单,只需要维护一套设备维度的在线状态就能同时推送多台设备。
而离线设备上线后拉取历史消息的过程就要复杂一些,比如,离线消息的存储和普通消息相比差别在哪?应该怎么存?每次上线怎么知道应该拉取哪些离线消息?因此,这里我来主要说一下离线消息同步的问题。
### 离线消息该怎么存?
在课程的[第2讲](https://time.geekbang.org/column/article/127978)中,我介绍过比较常见的消息索引的存储方式,你可以回想一下:一条消息在服务端存储一般会分为消息内容表和消息索引表,其中消息索引表是按照收发双方的会话维度来设计的,这样便于收发双方各自查看两人间的聊天内容。
那么问题来了:离线消息的存储是否可以直接使用这个消息索引表?
首先对于离线消息的存储不仅仅需要存储消息还需要存储一些操作的信令比如用户A在设备1删除了和用户B的某条消息这个信令虽然不是一条消息也需要在离线消息存储中存起来这样当用户A的另一台设备2上线时能通过离线消息存储获取这个删除消息的信令从而在设备2上也能从本地删除那条消息。
对于这些操作信令没有消息ID的概念和内容相关的信息而且是一个一次性的动作没必要持久化也不适合复用消息索引表另外消息索引表是收发双方的会话维度而获取离线消息的时候是接收方或者发送方的单个用户维度来获取数据的没必要按会话来存只需要按UID来存储即可。
此外还有一个需要考虑的点离线消息的存储成本是比较高的而我们并不知道用户到底有几个设备因此离线消息的存储一般都会有时效和条数的限制比如保留1周时间最多存储1000条这样如果用户一台设备很久不登录然后某一天再上线只能从离线消息存储中同步最近一周的历史聊天记录。
### 多端消息同步机制
离线消息的同步还有一个重要的问题是由于并不知道用户到底会有多少个终端来离线获取消息我们在一个终端同步完离线消息后并不会从离线存储中删除这些消息而是继续保留以免后续还有该用户的其他设备上线拉取离线消息的存储也是在不超过大小限制和时效限制的前提下采用FIFO先进先出的淘汰机制。
这样的话用户在使用某一个终端登录上线时,需要知道应该获取哪些离线消息,否则将所有离线都打包推下去,就会造成两种问题:一个是浪费流量资源;另外可能会导致因为有很多消息在终端中已经存在了,全部下推反而会导致消息重复出现和信令被重复执行的问题。因此,需要一个机制来保证离线消息可以做到按需拉取。
一种常见的方案是采用版本号来实现多终端和服务端的数据同步。下面简单说一下版本号的概念。
- 每个用户拥有一套自己的版本号序列空间。
- 每个版本号在该用户的序列空间都具备唯一性一般是64位。
- 当有消息或者信令需要推送给该用户时,会为每条消息或者信令生成一个版本号,并连同消息或者信令存入离线存储中,同时更新服务端维护的该用户的最新版本号。
- 客户端接收到消息或者信令后,需要更新本地的最新版本号为收到的最后一条消息或者信令的版本号。
- 当离线的用户上线时,会提交本地最新版本号到服务端,服务端比对服务端维护的该用户的最新版本号和客户端提交上来的版本号,如不一致,服务端根据客户端的版本号从离线存储获取“比客户端版本号新”的消息和信令,并推送给当前上线的客户端。
为了便于理解,我简单把这个离线同步消息的过程画了一下。
<img src="https://static001.geekbang.org/resource/image/b2/46/b2979f028df32610328dad4f509a9546.png" alt="">
### 离线消息存储超过限额了怎么办?
在用户上线获取离线消息时,会先进行客户端和服务端的版本号比较,如果版本号不一致才会从离线消息存储中,根据客户端上传的最新版本号来获取“增量消息”。
如果离线消息存储容量超过限制,部分增量消息被淘汰掉了,会导致根据客户端最新版本号获取增量消息失败。
这种情况的处理方式可以是直接下推所有离线消息或者从消息的联系人列表和索引表中获取最近联系人的部分最新的消息后续让客户端在浏览时再根据“时间相关”的消息ID来按页获取剩余消息对于重复的消息让客户端根据消息ID去重。
因为消息索引表里只存储消息,并不存储操作信令,这种处理方式可能会导致部分操作信令丢失,但不会出现丢消息的情况。因此,对于资源充足且对一致性要求高的业务场景,可以尽量提升离线消息存储的容量来提升离线存储的命中率。
### 离线存储写入失败了会怎么样?
在处理消息发送的过程中IM服务端可能会出现在获取到版本号以后写入离线消息存储时失败的情况在这种情况下如果版本号本身只是自增的话会导致取离线消息时无法感知到有消息在写离线存储时失败的情况。
因为如果这一条消息写离线缓存失败,而下一条消息又成功了,这时拿着客户端版本号来取离线消息时发现,客户端版本号在里面,还是可以正常获取离线消息的,这样就会漏推之前写失败的那一条。
那么,怎么避免这种离线存储写失败无感知的问题呢?
一个可行的方案是可以在存储离线消息时不仅存储当前版本号,还存储上一条消息或信令的版本号,获取消息时不仅要求客户端最新版本号在离线消息存储中存在,同时还要求离线存储的消息通过每条消息携带的上一版本号和当前版本号能够整体串联上,否则如果离线存储写入失败,所有消息的这两个版本号是没法串联上的。
这样当用户上线拉取离线消息时IM服务端发现该用户的离线消息版本号不连续的情况后就可以用和离线消息存储超限一样的处理方式从消息的联系人列表和索引表来获取最近联系人的部分最新的消息。
### 消息打包下推和压缩
对于较长时间不上线的用户,上线后需要拉取的离线消息比较多,如果一条一条下推会导致整个过程很长,客户端看到的就是一条一条消息蹦出来,体验会很差。
因此,一般针对离线消息的下推会采用整体打包的方式来把多条消息合并成一个大包推下去,同时针对合并的大包还可以进一步进行压缩,通过降低包的大小不仅能减少网络传输时间,还能节省用户的流量消耗。
### 发送方设备的同步问题
另外还有一个容易忽视的问题版本号机制中我们在下推消息时会携带每条消息的版本号然后更新为客户端的最新版本号。而问题是发送方用于发出消息的设备本身已经不需要再进行当前消息的推送没法通过消息下推来更新这台设备的最新版本号这样的话这台设备如果下线后再上线上报的版本号仍然是旧的会导致IM服务端误判而重复下推已经存在的消息。
针对这个问题,一个比较常见的解决办法是:给消息的发送方设备仍然下推一条只携带版本号的单独的消息,发送方设备接收到该消息只需要更新本地的最新版本号就能做到和服务端的版本号同步了。
## 小结
好了,最后我来总结一下今天的内容。
消息多终端漫游是很多即时消息场景中需要支持的一个特性,要支持消息多终端漫游,有两种实现方式:一种是要求在线状态需要支持用户设备维度进行维护;另一种是要求消息和信令在服务端进行用户维度的离线存储。
除此之外,还需要一个“多终端和服务端状态同步的机制”来保证数据的最终一致性,业界比较常见的方案是采用版本号机制来根据客户端版本和服务端版本的差异,在用户上线时来获取“增量消息和信令”。离线消息存储未命中时,可以通过持久化的最近联系人列表和索引表来进行有损的补救。针对离线消息的下推还可以通过“多条消息打包和压缩”的方式来优化上线体验。
最后给大家一个思考题:**如果用户的离线消息比较多,有没有办法来减少用户上线时离线消息的数据传输量?**
以上就是今天课程的内容,你可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,113 @@
<audio id="audio" title="10 | 自动智能扩缩容:直播互动场景中峰值流量的应对" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/7b/3128ed8cee6b5b8f6ee76c8bec9ac77b.mp3"></audio>
你好,我是袁武林。
随着近几年各种直播App和百万答题App的火爆和风靡具有高实时性要求的直播互动场景开始纷纷借助即时消息技术来保证直播过程中的各种互动消息和行为能够及时、可靠地投递比如用户给主播打赏或者送礼的互动行为不能有超过10秒的延迟更不能丢失否则会导致主播和房间其他用户看不到。即时消息技术凭借其在实时性和可靠性方面的优势已经被广泛应用在互动直播场景中。
那么,和传统的消息聊天场景相比,直播互动在业务形态上究竟有哪些区别?在技术层面又有哪些高难度的挑战?
## 业务形态区别和技术挑战
首先,在业务形态上,与传统的即时消息场景不太一样,直播互动的流量峰值具有“短时间快速聚集”的突发性,流量紧随着主播的开播和结束而剧烈波动。
另外,直播互动是以房间为单位,传统的群聊业务和聊天室业务虽然也有千人群和千人聊天室,但和直播间动辄几十万、上百万人的规模相比还是小巫见大巫的。而且,直播互动由于房间有时效限制和明星效应,用户发言和互动的积极性会更高,毕竟可能“错过这村就没这店了”。
超大的房间规模及高热度的互动导致的一个问题就是消息下推的并发峰值。这里我们可以简单用数字来直观感受一下点对点聊天场景如果两个人每10秒说一句话实际上每秒的消息下推数只有0.1群聊或者聊天室场景假设是一个500人群如果群里每个人也是每10秒说一句话实际每秒的消息下推数是500 / 10 * 500 = 25000那么对于一个10w人在线的直播互动场景如果直播间里每个人也每10秒说一句话实际每秒可产生的消息下推数就是100000 / 10 * 100000 = 10亿。
当然,这里只是用这个例子计算一下理论值,来让你了解直播互动中的并发压力与普通聊天场景的区别之大。
实际上10万人的直播间一般不会有这么高的发言和互动热度即使能达到也会在服务端进行限流和选择性丢弃。一个是考虑服务端的承受能力基本不可能达到这个量级另一方面即使消息能全部推下去客户端也处理不了每秒一万条消息的接收对客户端来说一般每秒接收几十条消息就已经是极限了。因此由于业务形态的不同直播互动中的高并发挑战与传统的即时消息场景相比要大得多。
## 直播互动的高并发应对
对于直播互动中高并发带来的技术挑战,我们从架构层面来看有哪些应对手段呢?下面我们先来分析一下直播互动中的一个比较大的挑战:高并发压力。
### 在线状态本地化
实际上,直播互动中的并发压力主要来自于消息下推环节中消息从一条扇出成十万条后的那部分,消息扇出前相对压力并不大。
那么,我们的优化重点主要是在扇出后的逻辑上,对于普通的消息聊天场景,扇出后的推送逻辑主要是:“查询聊天接收方在哪台接入服务器,然后把消息投递过去,最后由接入服务器通过长连接进行投递。” 如果采用这种方式来处理直播互动的消息下推,大概的流程会是下图这样的:
<img src="https://static001.geekbang.org/resource/image/ec/e3/ecab49d604405dd1ecbdf5dc3cad03e3.png" alt="">
首先用户通过接入网关机进入直播间接着网关会上报用户的在线状态假设这时用户A发送了一条弹幕消息这条消息会在业务逻辑处理层进行处理并且业务逻辑处理层通过查询刚才维护的用户在线状态会相应地查询用户A所在直播间的其他用户都在哪些网关机上并把相应的消息投递到这些用户所在的网关服务器最后再由网关服务器推送给用户的设备。
这里存在的一个问题是,在普通的聊天场景中,为了进行精准投递避免资源浪费,一般会维护一个中央的“在线状态”,逻辑层在确定好投递的接收人后,通过这个“在线状态”查询对应接收人所在的网关机,然后只需要把消息投递给这台网关机就可以了。
但是对于直播互动场景来说如果采用这种模式一个10w人的房间每条消息需要对这个在线状态进行10w次查询这个量级是非常大的因此往往这个地方就会成为瓶颈。
那么针对直播互动场景,对于这个“精准投递”应该如何进行优化呢?我们一起来思考一下。
一般来说,即使是一个热度较大、在线人数几十万的直播间,房间里的用户实际上也是“无状态的”相对分散在多台网关机上。
以10w人的房间来说假设有50台网关机那么平均每台网关机上这个直播间的用户应该有2000人我们完全没有必要去“精准”确认这个直播间的用户都在哪台网关机上只需要把这个直播间的消息都全量“投递”给所有网关机即可每台网关机也只需要在本地维护一个“某个房间的哪些用户的连接在本机”最终由网关机把消息下推给本机上当前直播间的在线用户。优化后的直播消息下推架构大概是这样
<img src="https://static001.geekbang.org/resource/image/f1/18/f1719e464ad9a5cf5c395ccbd500cf18.png" alt="">
首先每一台网关机在启动时会订阅一个全局的消息队列当用户进入直播间后会在每台网关机的本机维护一个在线状态同样的假设这时用户A发送了弹幕消息这条消息会在业务逻辑处理层进行处理紧接着再由业务处理层投递给刚才网关机订阅的全局的消息队列这样所有网关机都能收到消息最后每台网关机根据本机维护的某个直播间的在线用户再把消息下推给用户设备。
通过这个优化,相当于是把直播消息的扇出从业务逻辑处理层推迟到网关层,而且扇出后的下推不需要依赖任何外部状态服务,这样就能大幅提升直播互动消息的下推能力。
至于直播间里极少数的点对点类型的消息扇出下推(比如主播对某个用户禁言后下推给这个用户的提醒消息),可能会有一定的资源浪费,但这类消息数量相对较少,整体上看收益还是比较大的。
### 微服务拆分
对于直播互动的高并发场景来说仅仅有架构和设计层面的优化是不够的。比如下推消息还受制于网关机的带宽、PPS、CPU等方面的限制会容易出现单机的瓶颈因此当有大型直播活动时还需对这些容易出现瓶颈的服务进行水平扩容。
此外,为了控制扩容成本,我们希望能够区分出直播互动场景里的核心服务和非核心服务,以进一步支持只针对核心服务的扩容。同时,对于核心服务,我们需要隔离出“容易出现瓶颈点的”和“基本不会有瓶颈的”业务。
基于这些考虑,就需要对直播互动的整个服务端进行“微服务拆分”改造。
首先,我来分析一下对于整个直播互动的业务来说,哪些是核心服务、哪些是非核心服务。比如:发弹幕、打赏、送礼、点赞、消息下推,这些是比较核心的;其他的如直播回放和第三方系统的同步等,这些业务在直播时我们是不希望干扰到核心的互动消息和行为的收发的。
除此之外在核心服务里消息的发送行为和处理一般不容易出现瓶颈一个10w人的直播间里每秒的互动行为一般超不过1000在这一步我们不希望和容易出现瓶颈的消息下推业务混在一起。因此我们可以把消息的发和收从接入层到业务处理层都进行隔离拆分。整个系统进行微服务化改造后大概就是下面这样
<img src="https://static001.geekbang.org/resource/image/a1/a2/a1bdb645abf349044f2cbab3f0c351a2.png" alt="">
核心服务通过DB从库或者消息队列的方式与非核心服务解耦依赖避免被直接影响容易出现瓶颈的长连接入服务独立进行部署并且和发送消息的上行操作拆分成各自独立的通道这样一方面能够隔离上行操作避免被下行推送通道所影响另一方面轻量、独立的长连接入服务非常便于进行扩容。
### 自动扩缩容
通过微服务拆分后,你就需要考虑如何对拆分出来的服务进行扩容了,因为在平时没有高热度的直播时,考虑到成本的因素,一般不会让整个服务的集群规模太大。当有大型直播活动时,我们可以通过监控服务或者机器的一些关键指标,在热度快要到达瓶颈点时来进行扩容,整个过程实际不需要人工参与,完全可以做成自动化。
对于直播互动场景中的监控指标一般可以分为两大类:
- **业务性能指标**比如直播间人数、发消息和信令的QPS与耗时、消息收发延迟等
- **机器性能指标**主要是通用化的机器性能指标包括带宽、PPS、系统负载、IOPS等。
我们通过收集到的业务性能指标和机器性能指标,再结合模拟线上直播间数据来进行压测,找出单机、中央资源、依赖服务的瓶颈临界点,制定相应的触发自动扩缩容的指标监控阈值。
大概的自动化扩缩容的流程如下:
<img src="https://static001.geekbang.org/resource/image/bd/02/bdd6fa1a766ea5a862bec34689612902.png" alt="">
### 智能负载均衡
了解了自动扩缩容的整体流程,还有一个在扩容中需要你关注的问题。
对于直播互动的消息下推来说长连接入服务维护了房间和用户的长连接那么这里的问题在于扩容前的机器已经存在的长连接可能已经处于高水位状态新扩容的机器却没有承载用户连接而对于长连接入服务前端的负载均衡层来说大部分都采用普通的Round Robin算法来调度并不管后端的长连接入机器是否已经承载了很多连接这样会导致后续新的连接请求还是均匀地分配到旧机器和新机器上导致旧机器过早达到瓶颈而新机器没有被充分利用。
在这种情况下,即便是让负载均衡层支持自定义的复杂的均衡算法,也可能无法解决流量不平衡的问题。因为很多情况下,负载均衡层本身也是需要扩容的,自定义的均衡算法也只能在某一台负载均衡机器上生效,无法真正做到全局的调度和均衡。
一个更好的方案是接管用户连接的入口,在最外层入口来进行全局调度。
比如在建立长连接前客户端先通过一个入口调度服务来查询本次连接应该连接的入口IP在这个入口调度服务里根据具体后端接入层机器的具体业务和机器的性能指标来实时计算调度的权重。负载低的机器权重值高会被入口调度服务作为优先接入IP下发负载高的机器权重值低后续新的连接接入会相对更少。
通过这种方式,我们就基本能解决旧机器和新机器对于新增流量负载不均衡的问题了。
## 小结
接下来我们简单回顾一下今天课程的内容。今天这个章节我主要从直播互动的场景出发,先带你从业务维度了解到直播互动相对普通聊天具有“突发流量”“超高下推并发”等特点。为了应对这些问题,我们可以从几个方面来进行针对性的优化。
- 首先,在线状态本地化维护,降低远程资源依赖,提升单机处理能力。
- 其次,对服务整体进行拆分,区分核心和非核心服务,隔离“容易出现瓶颈”的服务。
- 接着,通过收集业务和机器两类指标,建立容量评估模型,自动进行服务扩缩容。
- 最后,根据后端机器负载水平调度全局,接入服务入口,解决扩容后新接入流量在新扩容机器和旧机器间流量不均衡的问题。
如今自动扩缩容作为互联网公司标配的平衡服务处理能力和资源成本的基础设施特别是对于流量峰值波谷明显的业务带来的收益非常明显。虽然我们今天站在直播互动的场景来展开这个话题但这个技术其实不仅仅可以用于IM场景也具有很强的业务普适性希望你能从中有所收获和启发。
最后给大家留一道思考题:**通过长连接的接入网关机缩容时与普通的Web服务机器缩容相比有什么区别**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="11 | 期中实战动手写一个简易版的IM系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/21/ba47def1b2a7d38755259a9339e21321.mp3"></audio>
你好,我是袁武林。
到上一讲为止IM的相关课程已经进行过半在前面的课程中我们讨论的大部分内容都比较偏理论你理解起来可能会比较抽象。为了让你对前面讲到的知识有更深入的理解今天我们就来回顾、梳理近期学习的内容一起尝试搭建一个简单的IM聊天系统。
**在开始之前呢我先来说明一下IM课程的期中、期末实战的课程计划和设计思路。**
期中和期末实战是希望你以自己动手实现为主提供的Demo主要作为参考在设计层面上并不能让你直接用于线上使用。
本次期中实战Demo的主要关注点是消息的存储、未读数的设计并以“短轮询”的方式来实现消息的实时触达。希望你能从用户的使用场景出发来理解消息存储设计的思路以及未读数独立两套存储的必要性。
另外在期末实战中我会从“短轮询”方式调整为WebSocket长连接的方式并且加上ACK机制、应用层心跳等特性。希望你能在两次实战中通过对比真正理解长连接方式相比“短轮询”方式的优势并且通过ACK机制和应用层心跳真正理解为什么它们能够解决“数据丢失”和“连接可靠性”的问题。
OK下面我们说回本次实战。
这个聊天系统要求并不复杂只需要构建简单的Web界面没有界面通过命令行能实现也行。我在这里写了一个简易版的Demo供你参考。
示例代码你可以在[GitHub](https://github.com/coldwalker/Sample)上下载。
## 需求梳理
这个简易聊天系统的大概要求有以下几点:
- 支持用户登录;
- 双方支持简单的文本聊天;
- 支持消息未读数(包括总未读和会话未读);
- 支持联系人页和未读数有新消息的自动更新;
- 支持聊天页有新消息时自动更新。
## 需求分析
我们先来分析一下整体需求。
**首先,要支持用户登录,先要有“用户”**。对应的数据底层需要有一个用户表用户表的设计可以比较简单能够支持唯一的UID和密码用于登录即可。当然如果有用户头像信息聊天时的体验会更好所以这里我们也加一下。简单的库表设计可以是这样的
```
CREATE TABLE IM_USER (
uid INT PRIMARY KEY,
username VARCHAR(500) NOT NULL,
password VARCHAR(500) NOT NULL,
email VARCHAR(250) DEFAULT NULL,
avatar VARCHAR(500) NOT NULL
);
```
对应的实体类User字段和库表一致这里就不罗列了我们需要设计用户通过邮箱和密码来登录。因为课程主要是涉及IM相关的知识所以这里对用户信息的维护可以不做要求启动时内置几个默认用户即可。
**有了用户后,接下来就是互动了,这一期我们只需要关注简单的文本聊天即可**。在设计中我们需要对具体的聊天消息进行存储便于在Web端使用因此可以简单地按照“[02 | 消息收发架构为你的App加上实时通信功能](https://time.geekbang.org/column/article/127978)”中讲到的消息存储来实现此项功能。
消息的存储大概分为消息内容表、消息索引表、联系人列表,这里我用最基础的字段来给你演示一下。单库单表的设计如下:
```
消息内容表:
CREATE TABLE IM_MSG_CONTENT (
mid INT AUTO_INCREMENT PRIMARY KEY,
content VARCHAR(1000) NOT NULL,
sender_id INT NOT NULL,
recipient_id INT NOT NULL,
msg_type INT NOT NULL,
create_time TIMESTAMP NOT NUll
);
```
```
消息索引表:
CREATE TABLE IM_MSG_RELATION (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`mid`)
);
CREATE INDEX `idx_owneruid_otheruid_msgid` ON IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
```
```
联系人列表:
CREATE TABLE IM_MSG_CONTACT (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`other_uid`)
);
```
- **消息内容表**由于只是单库单表消息ID采用自增主键主要包括消息ID和消息内容。
- **消息索引表**使用了索引“归属人”和消息ID作为联合主键可以避免重复写入并增加了“归属人”和“关联人”及消息ID的索引用于查询加速。
- **联系人列表**:字段和索引表一致,不同点在于采用“归属人”和“关联人”作为主键,可以避免同一个会话有超过一条的联系人记录。
消息相关实体层类的数据结构和库表的字段基本一致这里不再列出需要注意的是为了演示的简单方便这里并没有采用分库分表的设计所以分库的Sharding规则你需要结合用户消息收发和查看的场景多加考虑一下库表的设计。
**OK有了用户和消息存储功能现在来看如何支持消息未读数。**
在“[07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?](https://time.geekbang.org/column/article/132598)”一讲中,我讲到了消息未读数在聊天场景中的重要性,这里我们也把未读数相关的功能加上来。
未读数分为总未读和会话未读总未读虽然是会话未读之和但由于使用频率很高会话很多时候聚合起来性能比较差所以冗余了总未读来单独存储。比如你可以采用Redis来进行存储总未读可以使用简单的K-VKey-Value结构存储会话未读使用Hash结构存储。大概的存储格式如下
```
总未读:
owneruid_T, 2
会话未读:
owneruid_C, otheruid1, 1
owneruid_C, otheruid2, 1
```
**最后,我们一起来看看如何支持消息和未读自动更新。**
在“[03 | 轮询与长连接:如何解决消息的实时到达问题?](https://time.geekbang.org/column/article/128942)”一讲中我讲到了保证消息实时性的三种常见方式短轮询、长轮询、长连接。对于消息和未读的自动更新的设计你可以采用其中任意一种我实现的简版代码里就是采用的“短轮询”来在联系人页面和聊天页面轮询未读和新消息的。实现上比较简单Web端核心代码和服务端核心代码如下。
```
Web端核心代码
newMsgLoop = setInterval(queryNewcomingMsg, 3000);
$.get(
'/queryMsgSinceMid',
{
ownerUid: ownerUid,
otherUid: otherUid,
lastMid: lastMid
},
function (msgsJson) {
var jsonarray = $.parseJSON(msgsJson);
var ul_pane = $('.chat-thread');
var owner_uid_avatar, other_uid_avatar;
$.each(jsonarray, function (i, msg) {
var relation_type = msg.type;
owner_uid_avatar = msg.ownerUidAvatar;
other_uid_avatar = msg.otherUidAvatar;
var ul_pane = $('.chat-thread');
var li_current = $('&lt;li&gt;&lt;/li&gt;');//创建一个li
li_current.text(msg.content);
ul_pane.append(li_current);
});
});
)
```
```
服务端核心代码:
List&lt;MessageVO&gt; msgList = Lists.newArrayList();
List&lt;MessageRelation&gt; relationList = relationRepository.findAllByOwnerUidAndOtherUidAndMidIsGreaterThanOrderByMidAsc(ownerUid, otherUid, fromMid);
/** 先拼接消息索引和内容 */
User self = userRepository.findOne(ownerUid);
User other = userRepository.findOne(otherUid);
relationList.stream().forEach(relation -&gt; {
Long mid = relation.getMid();
MessageContent contentVO = contentRepository.findOne(mid);
if (null != contentVO) {
String content = contentVO.getContent();
MessageVO messageVO = new MessageVO(mid, content, relation.getOwnerUid(), relation.getType(), relation.getOtherUid(), relation.getCreateTime(), self.getAvatar(), other.getAvatar());
msgList.add(messageVO);
}
});
/** 再变更未读 */
Object convUnreadObj = redisTemplate.opsForHash().get(ownerUid + Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
if (null != convUnreadObj) {
long convUnread = Long.parseLong((String) convUnreadObj);
redisTemplate.opsForHash().delete(ownerUid + Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
long afterCleanUnread = redisTemplate.opsForValue().increment(ownerUid + Constants.TOTAL_UNREAD_SUFFIX, -convUnread);
/** 修正总未读 */
if (afterCleanUnread &lt;= 0) {
redisTemplate.delete(ownerUid + Constants.TOTAL_UNREAD_SUFFIX);
}
return msgList;
```
**这里需要注意两个地方:**
- 其一由于业务上使用的“消息对象”和存储层并不是一一对应关系所以一般遇到这种情况你可以为展现层在实体层基础上创建一些VO对象来承接展示需要的数据。比如我在这里创建了一个用于消息展现的VO对象MessageVO。
- 其二,在未读数的实现上,由于只是演示,这里并没有做到两个未读的原子变更,所以可能存在两个未读不一致的情况,因此上方代码最后部分我简单对总未读做了一个为负后的修正。
## 其他实现上的注意点
前面我们从需求梳理出发把这个简易聊天系统核心部分的设计思路和核心代码讲了一下也强调了一下这套代码实现中容易出现问题的地方。除此之外我希望在代码实现中你还能考虑以下这些问题虽然这些不是IM系统实现中的核心问题但对于代码的整洁和设计的习惯培养也是作为一个程序员非常重要的要求。
### 代码分层
首先是代码分层方面,我们在代码实现上,应该尽量让表现层、业务层和持久化层能分离清楚,做到每一层的职责清晰明确,也隔离了每一层的实现细节,便于多人协作开发。
表现层控制数据输入和输出的校验和格式,不涉及业务处理;业务层不涉及展现相关的代码,只负责业务逻辑的组合;持久化层负责和底层资源的交互,只负责数据的写入、变更和获取,不涉及具体的业务逻辑。
### 依赖资源
另外对于代码中使用到的Web服务器、DB资源、Redis资源等我们尽量通过Embedding的方式来提供这样便于在IDE中执行和代码的迁移使用。
## 小结
这一节课主要是安排一次实战测试让你来实现一个简易的聊天系统涉及的IM相关知识包括消息存储的设计、消息未读的设计、消息实时性的具体实现等等。
在实现过程中希望你能做到合理的代码分层规划通过Embedding方式提升代码对IDE的友好性及代码的可迁移性。相信通过这一次理论与实践相结合的动手实战能够帮助你进一步加深对IM系统核心技术的理解。
千里之行,积于跬步。通过脚踏实地的代码实现,将理论知识变成真正可用的系统,你的成就感也是会倍增的。
期中实战的演示代码并没有完全覆盖之前课程讲到的内容所以如果你有兴趣也非常欢迎你在代码实现时加入更多的特性比如长连接、心跳、ACK机制、未读原子化变更等。如果你对今天的内容有什么思考与解读欢迎给我留言我们一起讨论。感谢你的收听我们下期再见。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="12 | 服务高可用:保证核心链路稳定性的流控和熔断机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/00/6d47eb536cbf95e8b455179c1841de00.mp3"></audio>
你好,我是袁武林。
在[第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)中,我分析了直播互动场景中“突发流量”和“高并发峰值”业务形态下的几个关键技术点,并介绍了具体的应对方式。
但是,仅从设计优化、服务拆分、自动扩容等方面进行优化,有时候并不能完全解决问题。比如,有时流量增长过快,扩容流程还来不及完成,服务器可能就已经抗不住了。
不仅如此在即时消息系统的很多实现中业务上对于外部接口或者资源的依赖比较多。比如发消息可能需要依赖“垃圾内容识别”的API来进行消息内容的过滤下推图片消息时推送服务需要依赖图片服务获取缩略图来进行推流有的时候当业务中依赖的这些接口、资源不可用或变慢时会导致业务整体失败或者被拖慢而造成超时影响服务的整体可用性。
对于上面这些问题,在实际线上业务中其实并不少见,甚至可以说是常态化。既然突发流量我们没法预测,业务上也不可能不依赖任何外部服务和资源,那么有什么办法能尽量避免,或者降低出现这些问题时对核心业务的影响呢?
## 流量控制
针对超高流量带来的请求压力,业界比较常用的一种方式就是“流控”。
“流控”这个词你应该不陌生,当我们坐飞机航班延误或者被取消时,航空公司给出的原因经常就是“因为目的机场流量控制”。对于机场来说,当承载的航班量超过极限负荷时,就会限制后续出港和到港的航班来进行排队等候,从而保护整个机场的正常运转。
同样,在即时消息系统中,突发超高流量时,为了避免服务器整体被流量打死,我们可以通过流控来扔掉或者通过排队的方式来保护系统在能力范围内的运转。比如,我在[第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)中讲到,当有突发的高热度直播活动时,为了保护推送服务的整体可用性,我们可以通过流控扔掉一些非核心的消息,比如一些普通的进出场消息或点赞消息。
## 流控的常用算法
目前,业界常用的流控算法有两种:漏桶算法和令牌桶算法。
### 漏桶算法
“漏桶算法”的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。“漏桶算法”在实现上文如其名:它模拟的是一个漏水的桶,所有外部的水都先放进这个水桶,而这个桶以匀速往外均匀漏水,如果水桶满了,外部的水就不能再往桶里倒了。
这里你可以把这些外部的水想象成原始的请求,桶里漏出的水就是被算法平滑过后的请求。从这里也可以看出来,漏桶算法可以比较好地控制流量的访问速度。
### 令牌桶算法
令牌桶算法是流控中另一种常用算法,控制的是一个时间窗口内通过的数据量。令牌桶算法大概实现是这样的:
1. 每 1/r秒往桶里放入一个令牌r是用户配置的平均发送速率也就是每秒会有r个令牌放入
1. 桶里最多可以放入b个令牌如果桶满了新放入的令牌会被丢弃。
1. 如果来了n个请求会从桶里消耗掉n个令牌。
1. 如果桶里可用令牌数小于n那么这n个请求会被丢弃掉或者等待新的令牌放入。
请求通过令牌桶算法实现限流的大概过程,我在这里画了一张图。
<img src="https://static001.geekbang.org/resource/image/53/9e/5328b89d5145d5c9fdf52f654e74859e.png" alt="">
算法按一定速度均匀往桶里放入令牌,原始请求进入后,根据请求量从令牌桶里取出需要的令牌数,如果令牌数不够,会直接抛弃掉超限的请求或者进行等待,能成功获取到令牌的请求才会进入到后端服务器。
与漏桶算法“精确控制速率”不太一样的是,由于令牌桶的桶本身具备一定的容量,可以允许一次把桶里的令牌全都取出,因此,令牌桶算法在限制请求的平均速率的同时,还允许一定程度的突发流量。
比如明星粉丝群的场景里平时用户发消息的请求是比较小的一般都比设置的阈值小很多这样令牌桶平时都是处于“满”的状态如果群隶属的明星突然空降出现在群里群里的粉丝瞬间活跃起来会快速、突发地消耗掉令牌桶里缓存的b个令牌对于后端业务层就会出现一定的峰值流量。
通过令牌桶算法来限流的应用实例是比较多的Google开源的Guava就通过令牌桶算法实现了一个单机版的RateLimiterGoogle在工程实现上做了一些优化比如SmoothBursty特性支持累计N个时间窗口产生的令牌桶这样应对突发流量的能力会更好一些。
## 全局流控
对于单机瓶颈的问题,通过单机版的流控算法和组件就能很好地实现单机保护。但在分布式服务的场景下,很多时候的瓶颈点在于全局的资源或者依赖,这种情况就需要分布式的全局流控来对整体业务进行保护。
业界比较通用的全局流控方案一般是通过中央式的资源Redis、Nginx配合脚本来实现全局的计数器或者实现更为复杂的漏桶算法和令牌桶算法比如可以通过Redis的INCR命令配合Lua实现一个限制QPS每秒查询量的流控组件。
下面的示例代码是一个精简版的Redis+Lua实现全局流控的例子
```
-- 操作的Redis Key
local rate_limit_key = KEYS[1]
-- 每秒最大的QPS许可数
local max_permits = ARGV[1]
-- 此次申请的许可数
local incr_by_count_str = ARGV[2]
-- 当前已用的许可数
local currentStr = redis.call('get', rate_limit_key)
local current = 0
if currentStr then
current = tonumber(currentStr)
end
-- 剩余可分发的许可数
local remain_permits = tonumber(max_permits) - current
local incr_by_count = tonumber(incr_by_count_str)
-- 如果可分发的许可数小于申请的许可数,只能申请到可分发的许可数
if remain_permits &lt; incr_by_count then
incr_by_count = remain_permits
end
-- 将此次实际申请的许可数加到Redis Key里面
local result = redis.call('incrby', rate_limit_key, incr_by_count)
-- 初次操作Redis Key设置1秒的过期
if result == incr_by_count then
redis.call('expire', rate_limit_key, 1)
end
-- 返回实际申请到的许可数
return incr_by_co
```
一个需要注意的细节是在每次创建完对应的限流Key后你需要设置一个过期的时间。整个操作是原子化的这样能避免分布式操作时设置过期时间失败导致限流的Key一直无法重置从而使限流功能不可用。
此外,在实现全局流控时还有两个问题需要注意:一个是流控的粒度问题,另一个是流控依赖资源存在瓶颈的问题。下面我们分别来看一下,在实现全局流控时是如何解决这两个问题的。
### 细粒度控制
首先是针对流控的粒度问题。举个例子在限制QPS的时候流控粒度太粗没有把QPS均匀分摊到每个毫秒里而且边界处理时不够平滑比如上一秒的最后一个毫秒和下一秒的第一个毫秒都出现了最大流量就会导致两个毫秒内的QPS翻倍。
一个简单的处理方式是把一秒分成若干个N毫秒的桶通过滑动窗口的方式将流控粒度细化到N毫秒并且每次都是基于滑动窗口来统计QPS这样也能避免边界处理时不平滑的问题。
### 流控依赖资源瓶颈
全局流控实现中可能会出现的另一个问题是有时入口流量太大导致实现流控的资源出现访问瓶颈反而影响了正常业务的可用性。在微博消息箱业务中就发生过流控使用的Redis资源由于访问量太大导致出现不可用的情况。
针对这种情况,我们可以通过“本地批量预取”的方式来降低对资源的压力。
所谓的“本地批量预取”,是指让使用限流服务的业务进程,每次从远程资源预取多个令牌在本地缓存,处理限流逻辑时先从本地缓存消耗令牌,本地消费完再触发从远程资源获取到本地缓存,如果远程获取资源时配额已经不够了,本次请求就会被抛弃。
通过“本地批量预取”的方式能大幅降低对资源的压力比如每次预取10个令牌那么相应地对资源的压力能降低到1/10。
但是有一点需要注意本地预取可能会导致一定范围的限流误差。比如上一秒预取的10个令牌在实际业务中下一秒才用到这样会导致下一秒业务实际的请求量会多一些因此本地预取对于需要精准控制访问量的场景来说可能不是特别适合。
## 自动熔断机制
针对突发流量,除了扩容和流控外,还有一个能有效保护系统整体可用性的手段就是熔断机制。
不过在介绍熔断机制前,我们先来了解一下多依赖的微服务中的雪崩效应,因为在没有“熔断机制”前,雪崩效应在多依赖服务中往往会导致一个服务出问题,从而拖慢整个系统的情况。
在[第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”](https://time.geekbang.org/column/article/137000)中有讲过为了便于管理和隔离我们经常会对服务进行解耦独立拆分解耦到不同的微服务中微服务间通过RPC来进行调用和依赖。如下图所示
<img src="https://static001.geekbang.org/resource/image/b4/48/b401778f630e9f919f2042489cbf8548.png" alt="">
API 1和API 2一起关联部署而且共同依赖多个依赖服务A、B、C。如果此时API 1依赖的服务B由于资源或者网络等原因造成接口变慢就会导致和API 1一起关联部署的API 2也出现整体性能被拖累变慢的情况继而导致依赖API 1和API 2的其他上层的服务也级联地性能变差最终可能导致系统整体性能的雪崩。
虽然服务间的调用能够通过超时控制来降低被影响的程度,但在很多情况下,单纯依赖超时控制很难避免依赖服务性能恶化的问题。这种情况下,需要能快速“熔断”对这些性能出现问题的依赖调用。
一种常见的方式是手动通过开关来进行依赖的降级,微博的很多场景和业务都有用到开关来实现业务或者资源依赖的降级。
另一种更智能的方式是自动熔断机制。自动熔断机制主要是通过持续收集被依赖服务或者资源的访问数据和性能指标当性能出现一定程度的恶化或者失败量达到某个阈值时会自动触发熔断让当前依赖快速失败Fail-fast并降级到其他备用依赖或者暂存到其他地方便于后续重试恢复。在熔断过程中再通过不停探测被依赖服务或者资源是否恢复来判断是否自动关闭熔断恢复业务。
自动熔断这一机制目前业界已经有很多比较成熟的框架可以直接使用比如Netflix公司出品的Hystrix以及目前社区很火热的Resilience4j等。
## 小结
今天,我主要从服务高可用的角度出发,带你了解了如何在面对突发流量时,通过流控来保障系统整体运行在压力阈值范围内,从而让业务大部分仍然处于“柔性可用”的状态,而不至于被流量打死。
接下来我介绍了一下流控实现的两大算法:漏桶算法和令牌桶算法。
- **漏桶算法**的核心能力在于平滑实际流量的速度,让被流控的服务始终按固定速度来处理流量。
- **令牌桶算法**并不精确限制流量给到被流控服务的速度,主要是限制请求的**平均速度**,而且允许被流控服务出现一定突发流量请求。
此外针对分布式业务需要控制全局流量的场景我们一般还可以通过中央式的资源来实现比如Redis+Lua。
在全局流控实现中,我们可以通过滑动窗口和细粒度分桶来解决流量不均衡和边界处理不平滑的问题;对于超大流量可能导致中央资源出现瓶颈的情况,可以通过“本地批量预取”来降低中央资源的压力。
另外,对于突发流量和微服务间依赖复杂导致的雪崩问题,就需要通过“熔断机制”来进行快速失败,我们可以通过手动降级和自动熔断来互相配合,以保障系统的整体稳定性和可用性。
“限流”“熔断机制”和“缓存”一起被列为高并发应用工程实现中的三板斧,可见其对高并发业务稳定性的重要程度。在包括即时通讯的很多业务场景中,超高并发的突发流量也是随处可见。所以,掌握好流控和熔断这两种利器,也是对我们后端架构能力的一种很好的提升。
最后给你留一个思考题:**自动熔断机制中如何来确认Fail-fast时的熔断阈值比如当单位时间内访问耗时超过1s的比例达到50%时,对该依赖进行熔断)?**
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。