first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View File

@ -0,0 +1,135 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 搞懂“实时交互”的IM技术将会有什么新机遇
你好,我是袁武林。我来自新浪微博,目前在微博主要负责消息箱和直播互动相关的业务。接下来的一段时间,我会给你带来一个即时消息技术方面的专栏课程。
你可能会很好奇为什么是来自微博的技术人来讲这个课程微博会用到IM的技术吗
在我回答之前,先请你思考一个问题:
除了QQ和微信你知道还有什么App会用到即时实时消息技术吗
其实除了QQ和微信外陌陌、抖音等直播业务为主的App也都深度用到了IM相关的技术。
比如在线学习软件中的“实时在线白板”导航打车软件中的“实时位置共享”以及和我们生活密切相关的智能家居的“远程控制”也都会通过IM技术来提升人和人、人和物的实时互动性。
我觉得可以这么理解包括聊天、直播、在线客服、物联网等这些业务领域在内所有需要“实时互动”“高实时性”的场景都需要、也应该用到IM技术。
微博因为其多重的业务需求在许多业务中都应用到了IM技术目前除了我负责的消息箱和直播互动业务外还有其他业务也逐渐来通过我们的IM通用服务提升各自业务的用户体验。
为什么这么多场景都用到了IM技术呢IM的技术究竟是什么呢
所以在正式开始讲解技术之前我想先从应用场景的角度带你了解一下IM技术是什么它为互联网带来了哪些巨大变革以及自身蕴含着怎样的价值。
什么是IM系统
我们不妨先看一段旧闻:
2014年Facebook以190亿美元的价格收购了当时火爆的即时通信工具WhatsApp而此时WhatsApp仅有50名员工。
是的也就是说这50名员工人均创造了3.8亿美元的价值。这里我们不去讨论当时谷歌和Facebook为争抢WhatsApp发起的价格战从而推动这笔交易水涨船高的合理性从另一个侧面我们看到的是依托于IM技术的社交软件在完成了“连接人与人”的使命后体现出的巨大价值。
同样的价值体现也发生在国内。1996年几名以色列大学生发明的即时聊天软件ICQ一时间风靡全球3年后的深圳它的效仿者在中国悄然出现通过熟人关系的快速构建在一票基于陌生人关系的网络聊天室中脱颖而出逐渐成为国内社交网络的巨头。
那时候这个聊天工具还叫OICQ后来更名为QQ说到这大家应该知道我说的是哪家公司了没错这家公司叫腾讯。在之后的数年里腾讯正是通过不断优化升级IM相关的功能和架构凭借QQ和微信这两大IM工具牢牢控制了强关系领域的社交圈。
由此可见IM技术作为互联网实时互动场景的底层架构在整个互动生态圈的价值所在。
随着互联网的发展人们对于实时互动的要求越来越高。于是IM技术不止应用于QQ、微信这样的面向聊天的软件它其实有着宽广的应用场景和足够有想象力的前景。甚至在不知不觉之间IM系统已经根植于我们的互联网生活中成为各大App必不可少的模块。
除了我在前面图中列出的业务之外如果你希望在自己的App里加上实时聊天或者弹幕的功能通过IM云服务商提供的SDK就能快速实现当然如果需求比较简单你也可以自己动手来实现
比如在极客时间App中我们可以加上一个支持大家点对点聊天的功能或者增加针对某一门课程的独立聊天室。
例子太多我就不一一列举了。其实我想说的是IM并不是一门仅限于聊天、社交的技术实际上它已经广泛运用于我们身边形形色色的软件中。
随着5G等高速移动网络技术的快速推进网络速度和稳定性大幅提升、网络流量费用降低势必今后还会有越来越多的软件依托即时消息的优势理念加入到IM的大家庭中来毕竟谁不希望所有互动都能“实时触达”而且“安全可靠”呢
应用场景不同,适用的解决方案也不同
另外从技术的角度来看IM技术在后端的实现上并不是孤立存在的实际上我们可以认为IM技术是众多前后端技术的一个综合体只不过和其它业务相比由于自身使用场景在某些技术点上有更多侧重。
在整个IM系统的实现上深度用到了网络、数据库、缓存、加密、消息队列等后端必备知识。架构设计中也在大规模分布式、高并发、一致性架构设计等方面有众多成熟的解决方案。
所以我们可以认为在学习和实践IM技术的过程中也可以系统化提升你在这些方面的整体能力。
我第一次接触IM系统并不是和“人”相关的场景当时就职的公司做的是一个类似“物联网”的油罐车实时追踪控制系统一是通过GPS实时跟踪油罐车的位置判断是否按常规路线行进一是在油罐车到达目的地之后通过系统远程控制开锁。
所以这里的交互实际是“车”和“系统”的互动当然这个系统实现上并没有多大的技术挑战除了GPS的漂移纠偏带来了一些小小的困扰最大的挑战莫过于调试的时候需要现场跟车调试多地奔波。
再后来由于工作的变动我逐渐接触到IM系统中一些高并发的业务场景千万级实时在线用户百亿级消息下推量突发热点的直线峰值等。一步一步地踩坑和重构除了感受到压力之外对IM系统也有了更深层次的理解。
记得几年前,由于消息图片服务稳定性不好,图片消息的渲染比较慢,用户体验不好。
而且由于图片流和文本流在同一个TCP通道TCP的阻塞有时还会影响文本消息的收发。
所以后来我们对通道进行了一次拆分,把图片、文件等二进制流拆到一个独立通道,核心通道只推缩略图流,大幅减轻了通道压力,提升了核心链路的稳定性。
同时,独立的通道也缩短了客户端到文件流的链路,这样也提升了图片的访问性能。
但后来视频功能上线后我们发现视频的PSR11秒内播放成功率比较低原因是视频文件一般比较大为避免通道阻塞不会通过消息收发的核心通道来推送。
所以之前的策略是通过消息通道只下推视频的ID用户真正点击播放时才从服务端下载这种模式虽然解决了通道阻塞的问题但“播放时再下载”容易出现卡顿的情况。
因此针对视频类消息我们增加了一个notify-pull模式当客户端收到一条视频类消息的通知时会再向服务器发起一个短连接的拉取请求缓冲前N秒的数据。
这样等用户点击播放时基本就能秒播了较大地提升了视频消息播放的PSR11秒内播放成功率
因此我们要打造一套“实时、安全、稳定”的IM系统我们需要深入思考很多个地方尤其是作为整个实时互动业务的基础设施扩展性、可用性、安全性等方面都需要有较高的保障。
比如下面几种情况。
某个明星忽然开直播了,在线用户数和消息数瞬间暴涨,该如何应对?
弱网情况下,怎么解决消息收发失败的问题,提升消息到达率?
如何避免敏感聊天内容由于网络劫持而泄露?
诸如此类的问题可能有很多种解决方案,但是对于不同的场景适用的方案可能也不一样。
因此在随后的内容里我希望能够先系统化地带着你了解一下一套基础的IM系统的整体构成以及不同业务场景下可能存在的问题点和瓶颈点。
然后我会从经验角度出发来和你一起深入探讨这些问题并在这一过程中尽量遵循解决问题的3W原则What、Why、How。从问题现象出发结构化分析问题的本质原因并讨论多种解决问题的优劣和选择。
我希望能通过这样的方式不仅让你对IM的核心组成有一个整体的认识而且能够在各个瓶颈点的分析和后续的实践中形成较为深刻的思考和实践能力逐渐完善自身关于IM系统架构的知识网络。
课程设置
我们的课程分成三个模块,基本思路是:先从整体了解、再细化到每个垂直领域去了解它们有什么不同,进而关注到一些实现上的关键技术点、然后再回归到整体层面。
基础篇
本模块我们会开始学习一个即时消息系统的基础结构以及如何为你的App加入即时消息的模块。并且我们会从即时消息系统所适用的业务场景需求出发学习IM有别于其他业务系统的特性功能比如实时性、安全性以及这些功能的具体实现。
场景篇
在场景篇里,我会挑选即时消息技术的几个具体应用场景,这些场景相对个性化,而且在某些特性的技术实现上有一定挑战,我会针对这些场景比较核心的重点和难点来进行拆分讲解。比如,消息的多终端漫游功能的实现重点,以及直播互动场景中峰值流量的应对等等。
进阶篇
进阶篇在基础篇的即时消息的基础能力之上,介绍了相对更高级一些的功能,比如和苹果的推送服务对接。另外也更多关注于即时消息场景里在海量消息、高并发、服务高可用、服务保障等方面的优化实践,这部分内容具备较强的通用性,适用于大部分后端服务架构,从事后端服务开发设计的同学应该都会有所收获。
我希望能通过这个专栏,把这些年积累到的一些一线的实战经验进行梳理和沉淀,让感兴趣的小伙伴从中真正了解到,在超大用户规模的场景下,我们的即时消息系统经常会碰到的一些问题和容易出现瓶颈的环节,以及最终如何通过技术的升级和架构上的优化,来一一化解。
另外我希望你在掌握即时消息技术的同时,还能从这些实际上适用于大部分互联网后端业务的技术点和架构思想中,能体会到技术的互通性,通过思考和沉淀,形成自己的一套后端架构知识体系,并能实际运用到自己的业务或者系统中去。
最后,给你留一个思考题吧,除了前面我提到的聊天、直播互动、物联网等这些场景,你生活中接触到的还有哪些场景,也比较适合用到即时消息技术呢?
你可以给我留言,我们一起讨论。

View File

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

View File

@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 轮询与长连接:如何解决消息的实时到达问题?
你好,我是袁武林。
我在前面第一篇文章中从使用场景的需求方面讲到了IM系统的几个比较重要的特性。其中之一就是“消息到达的实时性”。
实时性场景是所有的IM系统绕不开的话题为了支持互联网的“实时互联”的概念大部分的App都需要实时技术的支持。
我们现在使用的聊天类App、直播互动类App都已经在实时性方面做得很好了消息收发延迟基本都能控制在毫秒级别。
当然这一方面得益于快速发展的移动网络让网络延迟越来越低、网络带宽越来越高另一个重要原因是社交网络App在实时性提升方面的技术也在不断升级迭代。
实时性主要解决的问题是:当一条消息发出后,我们的系统如何确保这条消息最快被接收人感知并获取到,并且尽量让耗费的资源较少。这里关键的几个点是:最快触达,且耗费资源少。
想好了吗下面我们来看一看IM在追求“消息实时性”的架构上所经历过的几个代表性阶段。
短轮询场景
在PC Web的早期时代对于数据的获取大部分应用采用一问一答的“请求响应”式模式实际上像现在我们浏览大部分门户网站的新闻以及刷微博其实都是采用的“请求响应”模式。
但这种依赖“手动”触发的模式,在即时消息系统中当有新消息产生时并不能很好地感知并获取到,所以明显不适用于对实时性要求高的场景。
因此这个时期的IM软件很多采用了一种“短轮询”的模式来定期、高频地轮询服务端的新消息。
在短轮询模式中,服务器接到请求后,如果有新消息就会将新消息返回给客户端,如果没有新消息就返回空列表,并关闭连接。
这种短轮询的方式就好像一位焦急等待重要信件的人,每天骑车跑到家门口的邮局去问是否有自己的信件,有就拿回家,没有第二天接着去邮局问。
作为从一问一答的请求响应模式孵化出来的短轮询模式,具有较低的迁移升级成本,比较容易落地。但劣势也很明显:
为了提升实时性,短轮询的频率一般较高,但大部分轮询请求实际上是无用的,客户端既费电也费流量;
高频请求对服务端资源的压力也较大一是大量服务器用于抗高频轮询的QPS每秒查询率二是对后端存储资源也有较大压力。
因此,“短轮询”这种方式,一般多用在用户规模比较小,且不愿花费太多服务改造成本的小型应用上。
长轮询场景
正是由于“短轮询”存在着高频无用功的问题为了避免这个问题IM逐步进化出“长轮询”的消息获取模式。
长轮询和短轮询相比一个最大的改进之处在于短轮询模式下服务端不管本轮有没有新消息产生都会马上响应并返回。而长轮询模式当本次请求没有获取到新消息时并不会马上结束返回而是会在服务端“悬挂hang等待一段时间如果在等待的这段时间内有新消息产生就能马上响应返回。
这种方式就像等待收信的人每天跑到邮局去问是否有自己的信件,如果没有,他不是马上回家,而是在邮局待上一天,如果还是没有就先回家,然后第二天再来。
比较之下我们会发现长轮询能大幅降低短轮询模式中客户端高频无用的轮询导致的网络开销和功耗开销也降低了服务端处理请求的QPS相比短轮询模式而言显得更加先进。
长轮询的使用场景多见于: 对实时性要求比较高但是整体用户量不太大。它在不支持WebSocket的浏览器端的场景下还是有比较多的使用。
但是长轮询并没有完全解决服务端资源高负载的问题,仍然存在以下问题。
服务端悬挂hang住请求只是降低了入口请求的QPS并没有减少对后端资源轮询的压力。假如有1000个请求在等待消息可能意味着有1000个线程在不断轮询消息存储资源。
长轮询在超时时间内没有获取到消息时,会结束返回,因此仍然没有完全解决客户端“无效”请求的问题。
服务端推送:真正的边缘触发
短轮询和长轮询之所以没法做到基于事件的完全的“边缘触发当状态变化时发生一个IO事件这是因为服务端在有新消息产生时没有办法直接向客户端进行推送。
这里的根本原因在于短轮询和长轮询是基于HTTP协议实现的由于HTTP是一个无状态协议同一客户端的多次请求对于服务端来说并没有关系也不会去记录客户端相关的连接信息。
因此,所有的请求只能由客户端发起,服务端由于并不记录客户端状态,当服务端接收到新消息时,没法找到对应的客户端来进行推送。
随着HTML5的出现全双工的WebSocket彻底解决了服务端推送的问题。
-
这就像之前信件处理的逻辑,等待收信的用户不需要每天都跑到邮局去询问,而只要在邮局登记好自己家里的地址。等真正有信件时,邮局会派专门的邮递员按照登记的地址来把信送过去。
同样,当他需要写信给别人时,也只需要填好收件人地址,然后把信交给邮递员就可以了,不需要再自己跑邮局。
WebSocket
WebSocket正是一种服务端推送的技术代表。
随着HTML5的出现基于单个TCP连接的全双工通信的协议WebSocket在2011年成为RFC标准协议逐渐代替了短轮询和长轮询的方式而且由于WebSocket协议获得了Web原生支持被广泛应用于IM服务中特别是在Web端基本属于IM的标配通信协议。
和短轮询、长轮询相比基于WebSocket实现的IM服务客户端和服务端只需要完成一次握手就可以创建持久的长连接并进行随时的双向数据传输。当服务端接收到新消息时可以通过建立的WebSocket连接直接进行推送真正做到“边缘触发”也保证了消息到达的实时性。
WebSocket的优点是
支持服务端推送的双向通信,大幅降低服务端轮询压力;
数据交互的控制开销低,降低双方通信的网络开销;
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,148 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 ACK机制如何保证消息的可靠投递
你好,我是袁武林。
在第一节的课程中,我们说到了即时消息系统中的四个重要特性,实时性、可靠性、一致性、安全性。
上一节课我们从如何保证消息实时性方面,了解了业界常用的一些方式以及背后具体的原理。那么今天我们接着来讲一讲,在即时消息的系统架构设计里,如何来保证消息的可靠投递。
首先,我们来了解一下,什么是消息的可靠投递?
站在使用者的角度来看,消息的可靠投递主要是指:消息在发送接收过程中,能够做到不丢消息、消息不重复两点。
这两个特性对于用户来讲都是非常影响体验的。我们先说一下不丢消息。
试想一下,你把辛辛苦苦攒到的零花钱打赏给了中意的“主播小姐姐”,但由于系统或者网络的问题,这条对你来说至关重要的打赏消息并没有成功投递给“主播小姐姐”,自然也就没有后续小姐姐和你一对一的互动环节了,想想是不是很悲剧?
消息重复也不用多说,谁也不愿意浪费时间在查看一遍又一遍的重复内容上。
那么在一般的IM系统的设计中究竟是如何解决这两大难题的呢下面我们结合一些简单的案例来看一看“不丢消息”“消息不重复”这些能力在技术上到底是怎么实现的。
消息丢失有哪几种情况?
我们以最常见的“服务端路由中转”类型的IM系统为例非P2P这里解释一下所谓的“服务端路由中转”是指一条消息从用户A发出后需要先经过IM服务器来进行中转然后再由IM服务器推送给用户B这个也是目前最常见的IM系统的消息分发类型。
我们可以把它和少数P2P类型区别一下P2P类型的消息投递是直接由用户A的网络发送到用户B的网络不经过服务端路由。
那么我们来假设一个场景用户A给用户B发送一条消息。接下来我们看看哪些环节可能存在丢消息的风险
参考上面时序图,发消息大概整体上分为两部分:
用户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却看不到消息。所以比较难处理。
上面两种情况都可能导致消息丢失,那么怎么避免这些异常情况下丢消息的问题呢?-
一般我们会用下面这些相应的解决方案:
针对第一部分我们通过客户端A的超时重发和IM服务器的去重机制基本就可以解决问题
针对第二部分业界一般参考TCP协议的ACK机制实现一套业务层的ACK协议。
解决丢失的方案业务层ACK机制
我们先解释一下ACKACK全称 Acknowledge是确认的意思。在TCP协议中默认提供了ACK机制通过一个协议自带的标准的ACK数据包来对通信方接收的数据进行确认告知通信发送方已经确认成功接收了数据。
那么业务层ACK机制也是类似解决的是IM服务推送后如何确认消息是否成功送达接收方。具体实现如下图
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“有消息丢失”的情况就可以重新同步或者修复丢失的数据。
比较常见的消息完整性检查的实现机制有“时间戳比对”,具体的实现如下图:
下面我们来看一下“时间戳机制”是如何对消息进行完整性检查的,我用这个例子来解释一下这个过程。
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,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 消息序号生成器:如何保证你的消息不会乱序?
你好,我是袁武林。
前面几节课,我们较为系统地介绍了如何解决消息实时到达的问题,也对保证消息可靠投递实战中常用的方式进行了一一讲解。
那么今天的课程我们继续一起聊一聊IM系统设计中另一个比较复杂但又非常重要的话题消息收发的一致性。需要提醒的是我们这里的讲到的一致性一般来说是指消息的时序一致性。
为什么消息的时序一致性很重要?
对于聊天、直播互动等业务来说,消息的时序代表的是发送方的意见表述和接收方的语义逻辑理解,如果时序一致性不能保证,可能就会造成聊天语义不连贯、容易出现曲解和误会。
你可以想象一下,一个人说话颠三倒四,前言不搭后语的样子,就理解我们为什么要尤其注重消息的时序一致性了。
对于点对点的聊天场景,时序一致性需要保证接收方的接收顺序和发送方的发出顺序一致;而对于群组聊天,时序一致性保证的是群里所有接收人看到的消息展现顺序都一样。
为什么保证消息的时序一致性很困难?
从理论上来说,保持消息的时序一致性貌似并不难。理论上,我们想象的消息收发场景中,只有单一的发送方、单一的接收方。
如果发送方和接收方的消息收发都是单线程操作并且和IM服务端都只有唯一的一个TCP连接来进行消息传输IM服务端也只有一个线程来处理消息接收和消息推送。这种场景下消息的时序一致性是比较容易能得到保障的。
但在实际的后端工程实现上,由于单发送方、单接收方、单处理线程的模型吞吐量和效率都太低,基本上不太可能存在。
更多的场景下,我们可能需要面对的是多发送方、多接收方、服务端多线程并发处理的情况。所以,知道了难点,我们再来看一看究竟在后端的工程实现上,保证消息的时序一致都存在哪些难点。
消息的时序一致性其实是要求我们的消息具备“时序可比较性”,也就是消息相对某一个共同的“时序基准”可以来进行比较,所以,要保证消息的时序一致性的一个关键问题是:我们是否能找到这么一个时序基准,使得我们的消息具备“时序可比较性”。
在工程实现上,我们可以分成这样几步。
首先是:如何找到时序基准。
其次是:时序基准的可用性问题。
最后是:有了时序基准,还有其他的误差吗,有什么办法可以减少这些误差?
如何找到时序基准?
下面我从消息收发的实际场景来分析一下,收发过程中如何找到一个全局的“时序基准”。在这里,我们来逐一分析一下。
首先,我们来看看发送方的本地序号和本地时钟是否可以作为“时序基准”?
这里解释一下所谓发送方的本地序号和本地时钟是指发送方在发送消息时连同消息再携带一个本地的时间戳或者本地维护的一个序号给到IM服务端IM服务端再把这个时间戳或者序号和消息一起发送给消息接收方消息接收方根据这个时间戳或者序号来进行消息的排序。
仔细分析一下,貌似发送方的本地序号或者本地时钟不适合用来作为接收方排序的“时序基准”,原因有下面几点。
发送方时钟存在较大不稳定因素,用户可以随时调整时钟导致序号回退等问题。
发送方本地序号如果重装应用会导致序号清零,也会导致序号回退的问题。
类似“群聊消息”和“单用户的多点登录”这种多发送方场景都存在同一时钟的某一时间点都可能有多条消息发给同一接收对象。比如同一个群里多个人同时发言或者同一个用户登录两台设备两台设备同时给某一接收方发消息。多设备间由于存在时钟不同步的问题并不能保证设备带上来的时间是准确的可能存在群里的用户A先发言B后发言但由于用户A的手机时钟比用户B的慢了半分钟如果以这个时间作为“时序基准”来进行排序可能反而导致用户A的发言被认为是晚于用户B的。
因此以发送方的本地时钟或者本地序号作为“时序基准”是不可靠的。那么我们接下来看看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生成器”来对私信、群聊等业务进行排序目前的精度能保证秒间有序。
“时序基准”之外的其他误差
有了“时序基准”,是不是就能确保消息能按照“既定顺序”到达接收方呢?答案是并不一定能做到。原因在于下面两点。
IM服务器都是集群化部署每台服务器的机器性能存在差异因此处理效率有差别并不能保证先到的消息一定可以先推送到接收方比如有的服务器处理得慢或者刚好碰到一次GC导致它接收的更早消息反而比其他处理更快的机器更晚推送出去。
IM服务端接收到发送方的消息后之后相应的处理一般都是多线程进行处理的比如“取序号”“暂存消息”“查询接收方连接信息”等由于多线程处理流程并不能保证先取到序号的消息能先到达接收方这样的话对于多个接收方看到的消息顺序可能是不一致的。
所以一般还需要端上能支持对消息的“本地整流”。我们来看一下本地整流。
消息服务端包内整流
虽然大部分情况下聊天、直播互动等即时消息业务能接受“小误差的消息乱序”但某些特定场景下可能需要IM服务能保证绝对的时序。
比如发送方的某一个行为同时触发了多条消息,而且这多条消息在业务层面需要严格按照触发的时序来投递。
一个例子用户A给用户B发送最后一条分手消息同时勾上了“取关对方”的选项这个时候可能会同时产生“发消息”和“取关”两条消息如果服务端处理时把“取关”这条信令消息先做了处理就可能导致那条“发出的消息”由于“取关”了发送失败的情况。
对于这种情况我们一般可以调整实现方式在发送方对多个请求进行业务层合并多条消息合并成一条也可以让发送方通过单发送线程和单TCP连接能保证两条消息有序到达。
但即使IM服务端接收时有序由于多线程处理的原因真正处理或者下推时还是可能出现时序错乱的问题解决这种“需要保证多条消息绝对有序性”可以通过IM服务端包内整流来实现。
比如我们在实现离线推送时在网关机启动后会自动订阅一个本IP的Topic当用户上线时网关机会告知业务层用户有上线操作这时业务层会把这个用户的多条离线消息pub给这个用户连接的那个网关机订阅的Topic当网关机收到这些消息后再通过长连接推送给用户整个过程大概是下图这样的。-
但是很多时候会出现Redis队列组件的Sharding和网关机多线程消费处理导致乱序的情况这样如果一些信令比如删除所有会话的操作被乱序推送给客户端可能就会造成端上的逻辑错误。
然后再说一下离线推送服务端整流的过程:
首先生产者为每个消息包生成一个packageID为包内的每条消息加个有序自增的seqId。
其次消费者根据每条消息的packageID和seqID进行整流最终执行模块只有在一定超时时间内完整有序地收到所有消息才执行最终操作否则将根据业务需要触发重试或者直接放弃操作。通过服务端整流服务端包内整流大概就是图中这个样子我们要做的是在最终服务器取到TCP连接后下推的时候根据包的ID对一定时间内的消息做一个整流和排序这样即使服务端处理多条消息时出现乱序仍然可以在最终推送给客户端时整流为有序的。
消息接收端整流
携带不同序号的消息到达接收端后,可能会出现“先产生的消息后到”“后产生的消息先到”等问题,消息接收端的整流就是解决这样的一个问题的。
消息客户端本地整流的方式可以根据具体业务的特点来实现,目前业界比较常见的实现方式比较简单,步骤如下:
下推消息时,连同消息和序号一起推送给接收方;
接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号就将当前消息追加在会话里;
否则继续往前查找倒数第二条、第三条等,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。
小结
今天我们讲到了在多发送方、多接收方、服务端多线程并发处理的情况下,保持消息时序一致的重要性及处理方法。
对于聊天、直播互动等即时消息的场景,保持消息的时序一致性能避免发送方的意见表述和接收方的语义理解出现偏差的情况。
对于如何保持消息的时序一致性的关键点在于需要找到一个时序基准来标识每一条消息的顺序。这个时序基准可以通过全局的序号生成器来确定常见的实现方式包括支持单调自增序号的资源生成或者分布式时间相关的ID生成服务生成两种方式各有一些限制不过你都可以根据业务自身的特征来进行选择。
有了通过时序基准确定的消息序号由于IM服务器差异和多线程处理的方式不能保证先服务端的消息一定能先推到接收方可以通过“服务端包内整流”机制来保证需要“严格有序”的批量消息的正确执行或者接收方根据消息序号来进行消息本地整流从而确保多接收方的最终一致性。
最后给你留一道思考题。在即时消息收发场景中,用于保证消息接收时序的序号生成器为什么可以不是全局递增的?
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

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

View File

@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 分布式锁和原子性:你看到的未读消息提醒是真的吗?
你好,我是袁武林。
在前面几节课程中,我着重把即时消息场景中几个核心的特性,进行了较为详细的讲解。在实际用户场景下,除了实时性、可靠性、一致性、安全性这些刚需外,还有很多功能对用户体验的影响也是很大的,比如今天我要讲的“消息未读数”。
消息未读数对用户使用体验影响很大这是因为“未读数”是一种强提醒方式它通过App角标或者App内部Tab的数字标签来告诉用户收到了新的消息。
对于在多个社交App来回切换的重度用户来说基本上都是靠“未读数”来获取新消息事件如果“未读数”不准确会对用户造成不必要的困扰。
比如我们看到某个App有一条“未读消息提醒”点进去事件却没有这种情况对于“强迫症患者”实在属于不可接受或者本来有了新的消息但未读数错误导致没有提醒到用户这种情况可能会导致用户错过一些重要的消息严重降低用户的使用体验。所以从这里我们可以看出“消息未读数”在整个消息触达用户路径中的重要地位。
消息和未读不一致的原因
那么在即时消息场景中,究竟会有哪些情况导致消息和未读数出现“不一致”的情况呢?要搞清楚这个问题,我们要先了解两个涉及未读数的概念:“总未读”与“会话未读”。我们分别来看看以下两个概念。
会话未读当前用户和某一个聊天方的未读消息数。比如用户A收到了用户B的2条消息这时对于用户A来说他和用户B的会话未读就是“2”当用户A打开和用户B的聊天对话页查看这两条消息时对于用户A来说他和用户B的会话未读就变成0了。对于群聊或者直播间来说也是一样的逻辑会话未读的对端只不过是一个群或者一个房间。
总未读当前用户的所有未读消息数这个不难理解总未读其实就是所有会话未读的和。比如用户A除了收到用户B的2条消息还收到了用户C的3条消息。那么对于用户A来说总未读就是“5”。如果用户查看了用户B发给他的2条消息这时用户A的总未读就变成了“3”。
从上面的概念我们知道,实际上总未读数就是所有会话未读数的总和,那么,在实现上是不是只需要给每个用户维护一套会话未读就可以了呢?
会话未读和总未读单独维护
理论上是可以的。但在很多即时消息的“未读数”实现中,会话未读数和总未读数一般都是单独维护的。
原因在于“总未读”在很多业务场景里会被高频使用,比如每次消息推送需要把总未读带上用于角标未读展示。
另外有些App内会通过定时轮询的方式来同步客户端和服务端的总未读数比如微博的消息栏总未读不仅包括即时消息相关的消息数还包括其他一些业务通知的未读数所以通过消息推送到达后的累加来计算总未读并不是很准确而是换了另外一种方式通过轮询来同步总未读。
对于高频使用的“总未读”,如果每次都通过聚合所有会话未读来获取,用户的互动会话不多的话,性能还可以保证;一旦会话数比较多,由于需要多次从存储获取,容易出现某些会话未读由于超时等原因没取到,导致总未读数计算少了。
而且,多次获取累加的操作在性能上比较容易出现瓶颈。所以,出于以上考虑,总未读数和会话未读数一般是单独维护的。
未读数的一致性问题
单独维护总未读和会话未读能解决总未读被“高频”访问的性能问题,但同时也会带来新的问题:未读数的一致性。
未读数一致性是指维护的总未读数和会话未读数的总和要保持一致。如果两个未读数不能保持一致就会出现“收到新消息但角标和App里的消息栏没有未读提醒”或者“有未读提醒点进去找不到是哪个会话有新消息”的情况。
这两种异常情况都是我们不愿意看到的。那么这些异常情况究竟是怎么出现的呢?
我们来看看案例,我们先来看看第一个:
用户A给用户B发送消息用户B的初始未读状态是和用户A的会话未读是0总未读也是0。
消息到达IM服务后执行加未读操作先把用户B和用户A的会话未读加1再把用户B的总未读加1。
假设加未读操作第一步成功了第二步失败。最后IM服务把消息推送给用户B。这个时候用户B的未读状态是和用户A的会话未读是1总未读是0。
这样由于加未读第二步执行失败导致的后果是用户B不知道收到了一条新消息的情况从而可能漏掉查看这条消息。
那么案例是由于在加未读的第二步“加总未读”的时候出现异常,导致未读和消息不一致的情况。
那么,是不是只要加未读操作都正常执行就没有问题了呢?接下来,我们再看下第二个案例。
用户A给用户B发送消息用户B的初始未读状态是和用户A的会话未读是0总未读也是0。
消息到达IM服务后执行加未读操作先执行加未读的第一步把用户B和用户A的会话未读加1。
这时执行加未读操作的服务器由于某些原因变慢了恰好这时用户B在App上点击查看和用户A的聊天会话从而触发了清未读操作。
执行清未读第一步把用户B和用户A的会话未读清0然后继续执行清未读第二步把用户B的总未读也清0。
清未读的操作都执行完之后执行加未读操作的服务器才继续恢复执行加未读的第二步把用户B的总未读加1那么这个时候就出现了两个未读不一致的情况。
导致的后果是用户B退出会话后看到有一条未读消息但是点进去却找不到是哪个聊天会话有未读消息。
这里,我来分析一下这两种不一致的案例原因:其实都是因为两个未读的变更不是原子性的,会出现某一个成功另一个失败的情况,也会出现由于并发更新导致操作被覆盖的情况。所以要解决这些问题,需要保证两个未读更新操作的原子性。
保证未读更新的原子性
那么,在分布式场景下,如何保证两个未读的“原子更新”呢?一个比较常见的方案是使用一个分布式锁来解决,每次修改前先加锁,都变更完后再解开。
分布式锁
分布式锁的实现有很多比如依赖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,142 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 智能心跳机制:解决网络的不确定性
你好,我是袁武林。
在前面的章节里,我讲到了在即时消息场景中非常重要的两个特性:“可靠投递”和“实时性”。
为了让消息能更加实时、可靠、快速地触达到接收方大部分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的频率固定发心跳还有稍微复杂的策略是客户端在发送数据空闲后才发送心跳包这种相比较对流量节省更好但实现上略微复杂一些。
下面是一个典型的应用层心跳的客户端和服务端的处理流程图,从图中可以看出客户端和服务端,各自通过心跳机制来实现“断线重连”和“资源清理”。
需要注意的是:对于客户端来说,判断连接是否空闲的时间是既定的心跳间隔时间,而对于服务端来说,考虑到网络数据传输有一定的延迟,因此判断连接是否空闲的超时时间需要大于心跳间隔时间,这样能避免由于网络传输延迟导致连接可用性的误判。
智能心跳
在国内移动网络场景下各个地方运营商在不同的网络类型下NAT超时的时间差异性很大。采用固定频率的应用层心跳在实现上虽然相对较为简单但为了避免NAT超时只能将心跳间隔设置为小于所有网络环境下NAT超时的最短时间虽然也能解决问题但对于设备CPU、电量、网络流量的资源无法做到最大程度的节约。
为了优化这个现象很多即时通讯场景会采用“智能心跳”的方案来平衡“NAT超时”和“设备资源节约”。所谓智能心跳就是让心跳间隔能够根据网络环境来自动调整通过不断自动调整心跳间隔的方式逐步逼近NAT超时临界点在保证NAT不超时的情况下尽量节约设备资源。据说微信就采用了智能心跳方案来优化心跳间隔。
不过从个人角度看随着目前移动资费的大幅降低手机端硬件设备条件也越来越好智能心跳对于设备资源的节约效果有限。而且智能心跳方案在确认NAT超时临界点的过程中需要不断尝试可能也会从一定程度上降低“超时确认阶段”连接的可用性因此我建议你可以根据自身业务场景的需要来权衡必要性。
小结
简单回顾一下今天的内容:为了保证消息下发的实时性,很多即时通讯场景使用“长连接”来降低每次建立连接消耗的时间,同时避免了“短连接轮询”带来的不必要的资源浪费。
但是由于移动网络环境错综复杂网络状态变化、中间链路断开、运营商NAT超时都可能导致这个“长连接”处于不可用状态而且收发双发无法感知到。
通过客户端和IM服务端建立的“心跳机制”可以快速自动识别连接是否可用同时避免运营商NAT超时被断开的情况。“心跳机制”解决了以下三方面的问题
降低服务端连接维护无效连接的开销。
支持客户端快速识别无效连接,自动断线重连。
连接保活避免被运营商NAT超时断开。
心跳探测的实现业界大部分综合采用以下两种方式:
TCP Keepalive。操作系统TCP/IP协议栈自带无需二次开发使用简单不携带数据网络流量消耗少。但存在灵活性不够和无法判断应用层是否可用的缺陷。
应用层心跳。应用自己实现心跳机制需要一定的代码开发量网络流量消耗稍微多一点但心跳间隔的灵活性好配合智能心跳机制可以做到“保证NAT不超时的情况下最大化节约设备资源消耗”同时也能更精确反馈应用层的真实可用性。
最后给大家留一道思考题:
心跳机制中可以结合TCP的keepalive和应用层心跳来一起使用吗
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

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

View File

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

View File

@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 期中实战动手写一个简易版的IM系统
你好,我是袁武林。
到上一讲为止IM的相关课程已经进行过半在前面的课程中我们讨论的大部分内容都比较偏理论你理解起来可能会比较抽象。为了让你对前面讲到的知识有更深入的理解今天我们就来回顾、梳理近期学习的内容一起尝试搭建一个简单的IM聊天系统。
在开始之前呢我先来说明一下IM课程的期中、期末实战的课程计划和设计思路。
期中和期末实战是希望你以自己动手实现为主提供的Demo主要作为参考在设计层面上并不能让你直接用于线上使用。
本次期中实战Demo的主要关注点是消息的存储、未读数的设计并以“短轮询”的方式来实现消息的实时触达。希望你能从用户的使用场景出发来理解消息存储设计的思路以及未读数独立两套存储的必要性。
另外在期末实战中我会从“短轮询”方式调整为WebSocket长连接的方式并且加上ACK机制、应用层心跳等特性。希望你能在两次实战中通过对比真正理解长连接方式相比“短轮询”方式的优势并且通过ACK机制和应用层心跳真正理解为什么它们能够解决“数据丢失”和“连接可靠性”的问题。
OK下面我们说回本次实战。
这个聊天系统要求并不复杂只需要构建简单的Web界面没有界面通过命令行能实现也行。我在这里写了一个简易版的Demo供你参考。
示例代码你可以在GitHub上下载。
需求梳理
这个简易聊天系统的大概要求有以下几点:
支持用户登录;
双方支持简单的文本聊天;
支持消息未读数(包括总未读和会话未读);
支持联系人页和未读数有新消息的自动更新;
支持聊天页有新消息时自动更新。
需求分析
我们先来分析一下整体需求。
首先要支持用户登录先要有“用户”。对应的数据底层需要有一个用户表用户表的设计可以比较简单能够支持唯一的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加上实时通信功能”中讲到的消息存储来实现此项功能。
消息的存储大概分为消息内容表、消息索引表、联系人列表,这里我用最基础的字段来给你演示一下。单库单表的设计如下:
消息内容表:
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 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?”一讲中,我讲到了消息未读数在聊天场景中的重要性,这里我们也把未读数相关的功能加上来。
未读数分为总未读和会话未读总未读虽然是会话未读之和但由于使用频率很高会话很多时候聚合起来性能比较差所以冗余了总未读来单独存储。比如你可以采用Redis来进行存储总未读可以使用简单的K-VKey-Value结构存储会话未读使用Hash结构存储。大概的存储格式如下
总未读:
owneruid_T, 2
会话未读:
owneruid_C, otheruid1, 1
owneruid_C, otheruid2, 1
最后,我们一起来看看如何支持消息和未读自动更新。
在“03 | 轮询与长连接如何解决消息的实时到达问题”一讲中我讲到了保证消息实时性的三种常见方式短轮询、长轮询、长连接。对于消息和未读的自动更新的设计你可以采用其中任意一种我实现的简版代码里就是采用的“短轮询”来在联系人页面和聊天页面轮询未读和新消息的。实现上比较简单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 = $('<li></li>');//创建一个li
li_current.text(msg.content);
ul_pane.append(li_current);
});
});
)
服务端核心代码:
List<MessageVO> msgList = Lists.newArrayList();
List<MessageRelation> relationList = relationRepository.findAllByOwnerUidAndOtherUidAndMidIsGreaterThanOrderByMidAsc(ownerUid, otherUid, fromMid);
/** 先拼接消息索引和内容 */
User self = userRepository.findOne(ownerUid);
User other = userRepository.findOne(otherUid);
relationList.stream().forEach(relation -> {
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 <= 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,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 服务高可用:保证核心链路稳定性的流控和熔断机制
你好,我是袁武林。
在第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”中我分析了直播互动场景中“突发流量”和“高并发峰值”业务形态下的几个关键技术点并介绍了具体的应对方式。
但是,仅从设计优化、服务拆分、自动扩容等方面进行优化,有时候并不能完全解决问题。比如,有时流量增长过快,扩容流程还来不及完成,服务器可能就已经抗不住了。
不仅如此在即时消息系统的很多实现中业务上对于外部接口或者资源的依赖比较多。比如发消息可能需要依赖“垃圾内容识别”的API来进行消息内容的过滤下推图片消息时推送服务需要依赖图片服务获取缩略图来进行推流有的时候当业务中依赖的这些接口、资源不可用或变慢时会导致业务整体失败或者被拖慢而造成超时影响服务的整体可用性。
对于上面这些问题,在实际线上业务中其实并不少见,甚至可以说是常态化。既然突发流量我们没法预测,业务上也不可能不依赖任何外部服务和资源,那么有什么办法能尽量避免,或者降低出现这些问题时对核心业务的影响呢?
流量控制
针对超高流量带来的请求压力,业界比较常用的一种方式就是“流控”。
“流控”这个词你应该不陌生,当我们坐飞机航班延误或者被取消时,航空公司给出的原因经常就是“因为目的机场流量控制”。对于机场来说,当承载的航班量超过极限负荷时,就会限制后续出港和到港的航班来进行排队等候,从而保护整个机场的正常运转。
同样在即时消息系统中突发超高流量时为了避免服务器整体被流量打死我们可以通过流控来扔掉或者通过排队的方式来保护系统在能力范围内的运转。比如我在第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”中讲到当有突发的高热度直播活动时为了保护推送服务的整体可用性我们可以通过流控扔掉一些非核心的消息比如一些普通的进出场消息或点赞消息。
流控的常用算法
目前,业界常用的流控算法有两种:漏桶算法和令牌桶算法。
漏桶算法
“漏桶算法”的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。“漏桶算法”在实现上文如其名:它模拟的是一个漏水的桶,所有外部的水都先放进这个水桶,而这个桶以匀速往外均匀漏水,如果水桶满了,外部的水就不能再往桶里倒了。
这里你可以把这些外部的水想象成原始的请求,桶里漏出的水就是被算法平滑过后的请求。从这里也可以看出来,漏桶算法可以比较好地控制流量的访问速度。
令牌桶算法
令牌桶算法是流控中另一种常用算法,控制的是一个时间窗口内通过的数据量。令牌桶算法大概实现是这样的:
每 1/r秒往桶里放入一个令牌r是用户配置的平均发送速率也就是每秒会有r个令牌放入
桶里最多可以放入b个令牌如果桶满了新放入的令牌会被丢弃。
如果来了n个请求会从桶里消耗掉n个令牌。
如果桶里可用令牌数小于n那么这n个请求会被丢弃掉或者等待新的令牌放入。
请求通过令牌桶算法实现限流的大概过程,我在这里画了一张图。
算法按一定速度均匀往桶里放入令牌,原始请求进入后,根据请求量从令牌桶里取出需要的令牌数,如果令牌数不够,会直接抛弃掉超限的请求或者进行等待,能成功获取到令牌的请求才会进入到后端服务器。
与漏桶算法“精确控制速率”不太一样的是,由于令牌桶的桶本身具备一定的容量,可以允许一次把桶里的令牌全都取出,因此,令牌桶算法在限制请求的平均速率的同时,还允许一定程度的突发流量。
比如明星粉丝群的场景里平时用户发消息的请求是比较小的一般都比设置的阈值小很多这样令牌桶平时都是处于“满”的状态如果群隶属的明星突然空降出现在群里群里的粉丝瞬间活跃起来会快速、突发地消耗掉令牌桶里缓存的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 < 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讲自动智能扩缩容直播互动场景中峰值流量的应对中有讲过为了便于管理和隔离我们经常会对服务进行解耦独立拆分解耦到不同的微服务中微服务间通过RPC来进行调用和依赖如下图所示
API 1和API 2一起关联部署而且共同依赖多个依赖服务ABC如果此时API 1依赖的服务B由于资源或者网络等原因造成接口变慢就会导致和API 1一起关联部署的API 2也出现整体性能被拖累变慢的情况继而导致依赖API 1和API 2的其他上层的服务也级联地性能变差最终可能导致系统整体性能的雪崩
虽然服务间的调用能够通过超时控制来降低被影响的程度但在很多情况下单纯依赖超时控制很难避免依赖服务性能恶化的问题这种情况下需要能快速熔断对这些性能出现问题的依赖调用
一种常见的方式是手动通过开关来进行依赖的降级微博的很多场景和业务都有用到开关来实现业务或者资源依赖的降级
另一种更智能的方式是自动熔断机制自动熔断机制主要是通过持续收集被依赖服务或者资源的访问数据和性能指标当性能出现一定程度的恶化或者失败量达到某个阈值时会自动触发熔断让当前依赖快速失败Fail-fast并降级到其他备用依赖或者暂存到其他地方便于后续重试恢复在熔断过程中再通过不停探测被依赖服务或者资源是否恢复来判断是否自动关闭熔断恢复业务
自动熔断这一机制目前业界已经有很多比较成熟的框架可以直接使用比如Netflix公司出品的Hystrix以及目前社区很火热的Resilience4j等
小结
今天我主要从服务高可用的角度出发带你了解了如何在面对突发流量时通过流控来保障系统整体运行在压力阈值范围内从而让业务大部分仍然处于柔性可用的状态而不至于被流量打死
接下来我介绍了一下流控实现的两大算法漏桶算法和令牌桶算法
漏桶算法的核心能力在于平滑实际流量的速度让被流控的服务始终按固定速度来处理流量
令牌桶算法并不精确限制流量给到被流控服务的速度主要是限制请求的平均速度而且允许被流控服务出现一定突发流量请求
此外针对分布式业务需要控制全局流量的场景我们一般还可以通过中央式的资源来实现比如Redis+Lua
在全局流控实现中我们可以通过滑动窗口和细粒度分桶来解决流量不均衡和边界处理不平滑的问题对于超大流量可能导致中央资源出现瓶颈的情况可以通过本地批量预取来降低中央资源的压力
另外对于突发流量和微服务间依赖复杂导致的雪崩问题就需要通过熔断机制来进行快速失败我们可以通过手动降级和自动熔断来互相配合以保障系统的整体稳定性和可用性
限流熔断机制缓存一起被列为高并发应用工程实现中的三板斧可见其对高并发业务稳定性的重要程度在包括即时通讯的很多业务场景中超高并发的突发流量也是随处可见所以掌握好流控和熔断这两种利器也是对我们后端架构能力的一种很好的提升
最后给你留一个思考题自动熔断机制中如何来确认Fail-fast时的熔断阈值比如当单位时间内访问耗时超过1s的比例达到50%对该依赖进行熔断
以上就是今天课程的内容欢迎你给我留言我们可以在留言区一起讨论感谢你的收听我们下期再见

View File

@ -0,0 +1,137 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 HTTP Tunnel复杂网络下消息通道高可用设计的思考
你好,我是袁武林。
在第1讲“架构与特性一个完整的IM系统是怎样的”中我有讲到即时消息系统中非常重要的几个特性实时性、可靠性、一致性、安全性。实际上这些特性的实现大部分依赖于通道层的稳定和高可用。
对于即时消息系统来说,消息的通道主要承载两部分流量:一部分是用户发出的消息或者触发的行为,我们称为上行消息;一部分是服务端主动下推的消息和信令,我们称为下行消息。
由此可见,消息通道如果不稳定,一来会影响用户发送消息的成功率和体验,二来也会影响消息的下推,导致用户没法实时收到消息。
那么,在面对如何保障消息通道的高可用这一问题时,业界有哪些比较常用的优化手段呢?
让消息通道能连得上
要保障消息通道的高可用最基本的是要让通道能随时连得上。不过你可能会觉得这看起来好像挺简单的不就是申请个外网虚拟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你的消息聊天真的安全吗”中讲消息通道安全性的时候我就有提到通过HttpDNS能解决DNS劫持的问题。
其实借助HttpDNS我们还能通过返回多个接入点IP来解决连通性的问题后续一个连接失败就尝试下一个这样就相当于给我们提供了一个接入点的Failover机制。
同时为了防止通过HTTP请求DNS时出现失败或者超时的问题我们还可以在客户端进行接入点的预埋。
比如预埋一个域名和几个常用的接入点IP用这个作为请求接入最后的兜底策略。当然这些预埋的域名和接入点IP一般需要尽量保证稳定性如果有变动需要及时预埋到新版App中。
让消息通道连得快
解决了通道连得上的问题,接下来我们需要考虑的就是怎么让接入方能连得快。这里有两种实现方式:一个是通过解决跨网延迟来避免通道连接过慢,另一个可以通过跑马竞速来选择速度最快的通道进行接入。
解决跨网延迟
同样在第6讲中有提到由于运营商跨网延迟的问题我们希望能尽量让某一个运营商的用户通过托管在接入了这个运营商专线的机房接入点来接入网络。因此要让用户连得快首先要求我们需要有多运营商机房的接入点其次要避免运营商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来进行接入。这个过程你可以参考下图。
让消息通道保持稳定
解决了消息通道连得上和连得快的问题,另一个提高消息通道可用性的重要手段是让通道能尽量保持稳定。那么,都有哪些因素会导致消息通道不稳定呢?
通道和业务解耦
我们知道,对于通道中收发的消息会进行很多业务逻辑的操作,比如消息存储、加未读、版本兼容逻辑等。随着需求的不断迭代和新功能的增加,可能还会新增业务协议或者修改原有业务协议的字段,这些变更都是紧随业务变化的,相对会比较频繁。
但是在即时消息系统中,消息的收发是严重依赖长连接通道的,如果我们的通道层需要跟随业务的变化而不断调整,那么就会导致通道服务也需要频繁地上线、重启。这些操作会让已经连到通道机器的用户连接断开,虽然客户端一般都会有断线重连的机制,但是频繁地断连也会降低消息收发的成功率和用户体验。
因此,要提高消息通道的稳定性,我们要从架构上对通道层进行业务解耦,通道层只负责网络连接管理和通用的逻辑处理。
比如用户和连接的映射维护、通信协议的编解码、建连和断连逻辑处理、ACK包和心跳包处理等将变化较大的业务逻辑下沉到后端的业务处理层。这样不管业务怎么变动我们的通道网关服务都不需要跟着变更稳定性也会更好。
上下行通道隔离
除了让通道层和业务隔离,面对消息下推压力比较大的场景,还可以对上下行通道进行拆分隔离。
比如对于直播互动的场景,下推消息由于扇出大,因此当遇到大型直播的时候下行通道的压力会加大,虽然可以通过限流、降级、扩容等方式来缓解,但在这时,系统的整体负载和流量都是比较大的。
这种场景下,我们可以对上下行通道进行拆分隔离,用户上行的消息和行为通过一个短连通道发送到服务端。这样既能避免客户端维护多个长连接的开销,也能解决上行通道被下推消息影响的问题。
想要做进一步的优化我们还可以让这个短连接不是每一次发送完就断开而是支持一定时间的空闲而不断开比如2分钟这样对于用户连续发消息的情况不需要每次再有重新建连的开销用户体验也会更好一些。
下面画了一个图来简单描述下如何对上下行通道进行拆分隔离:
用户A和用户B分别都通过接入查询服务来获取最优接入点用户A通过上行通道的短连接网关来发送消息发送的消息在上行业务处理服务进行存储、加未读等业务操作然后通过消息队列把这条消息给到下行通道下行分发逻辑服务查询用户B的在线状态等信息并对消息进行必要的推送准备处理比如版本兼容处理接着把消息给到用户B的长连接所在的长连网关机器长连网关机器再将消息推送到用户B的设备中。
这样,我们的上下行通道就通过消息队列的方式进行了隔离和解耦。
独立多媒体上传下载
对于图片、视频等多媒体消息由于数据传输量一般都比较大如果也和普通文本消息收发的通道放在一条连接里可能会导致消息收发通道出现阻塞因此我们一般会开辟新的连接通道来传输二进制的文件流。这种优化方式除了能保护消息收发的核心通道也能缩短上传下载的链路提高媒体消息收发的性能。针对多媒体消息的整体上传下载的优化我们在接下来的14和15篇中会详细讲解这里先不做展开了。
小结
我们简单回顾一下今天的课程内容。这节课我介绍了一下消息通道在复杂网络情况下,会出现连通性、延迟问题,以及在连接稳定性等方面容易出现连不上、速度慢、连接不稳定的问题,通过分析这些问题出现的具体原因,有针对性地提出了解决这些问题的办法。
在解决“通道连不上”的问题上我们可以通过暴露多个业界验证过比较安全的连接端口来解决端口连通性问题通过HTTP Tunnel来解决某些网络情况下只允许HTTP协议的数据传输的问题通过HttpDNS和客户端预埋的方式提供多个可选的通道接入点让某些接入点在连不上时还能尝试其他接入点。
在解决“通道连接慢”的问题上,我们可以通过支持多运营商机房接入点,来避免用户的跨运营商网络访问;此外,对于提供的多接入点,客户端还可以通过“跑马竞速”的方式优先使用连接速度更快的接入点来访问。
在解决“通道不稳定”的问题上,我们主要从服务端的架构设计着手,让我们的通道层服务和变化频繁的业务进行解耦,避免业务频繁变动导致通道层服务不稳定;对于消息下行通道压力大的业务场景,还可以隔离消息上下行通道,避免消息的上行被压力大的下行通道所影响;另外,将多媒体的上传下载通道和消息收发的核心通道进行隔离,避免传输量大的多媒体消息造成通道的阻塞,影响消息收发。
面对复杂的移动网络场景,由于不可控因素实在太多,稍不注意我们就容易踩到这样或者那样的坑。比如,我以前的业务里,曾经就出现过由于对外暴露的接入端口不是常见端口,导致很多用户连接不上的情况。但是,通过逐步的摸索和踩坑,也积累了针对移动网络复杂环境下的诸多经验,希望这些经验能够帮助你以后尽量避免出现同样的问题。
最后,给你留一道思考题:上下行通道隔离能够隔离保护我们的消息接收和消息发送,那么通道隔离会不会带来一些负面影响呢?
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论。感谢你的收听,我们下期再见。

View File

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

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 CDN加速如何让你的图片、视频、语音消息浏览播放不卡
你好,我是袁武林。
上一讲,我从即时消息场景中多媒体消息的上传环节出发,介绍了业界常用的几种提升用户上传体验的优化手段。
那么这节课,我会从播放的角度出发,带你了解在浏览和播放图片、视频、语音等多媒体消息时,如何避免灰图和卡顿的问题,以及在节省流量方面,业界都有哪些比较常见的优化策略。
CDN加速
提升用户浏览图片和播放视频体验的一个有效办法就是:让用户离资源更近。
比如说,北京的用户可以从北京的机房下载图片,而广东的用户可以从广东的节点机房来下载图片,这样让用户和资源实现物理位置上的相邻,以此降低远程访问的耗时,提升下载性能。
业界常用的一种手段就是通过CDNContent Delivery Network内容分发网络对图片和音视频进行加速来提升用户播放体验。
所谓的CDN加速技术就是将客户端上传的图片、音视频发布到多个分布在各地的CDN节点的服务器上当有用户需要访问这些图片和音视频时能够通过DNS负载均衡技术根据用户来源就近访问CDN节点中缓存的图片和音视频消息如果CDN节点中没有需要的资源会先从源站同步到当前节点上再返回给用户。
CDN下载时的访问链路你可以参考下图-
通过这种资源冗余的方式,既能显著提高用户访问的响应速度,也能有效缓解服务器访问量过大带来的对源存储服务的读写压力和带宽压力。
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实现视频加解密的大概流程如下图-
用户通过上传服务把视频上传到服务端服务端进行视频的HLS切片并针对切完的TS文件流进行加密同时把密钥存储到密钥服务中。当有用户请求该视频时CDN节点从源站回源加密的视频文件播放器先通过下载的M3U8索引文件获取到“密钥地址”然后将客户端缓存的认证Token拼接到该“密钥地址”后面再通过该地址请求鉴权服务。
鉴权服务先检查携带的认证Token是否有权限访问该视频文件如果权限没问题会从密钥存储服务中将该视频的密钥文件返回给播放器这时播放器就能自动解密播放了。
不过虽然HLS原生支持加解密操作但是对于图片等其他多媒体消息来说没有办法使用这种方式。而且在有的即时消息系统中只支持MP4格式的视频文件。所以针对非HLS视频我们还可以通过播放器的改造来支持自定义的加密方式。
比如通过RC4Rivest Cipher 4一种流加密算法加密MP4格式的视频文件然后从业务接口获取消息地址时下发密钥这样改造后的播放器也可以达到“边解密边播放”的效果。这种方式唯一的成本是需要定制化的播放器才能播放开发成本也相对略高这里先不展开了如果你有兴趣可以在留言区和我讨论交流。
边下边播和拖动播放
IM场景中的视频消息在产品策略上都会有时长或大小的限制一般来说都是控制在几分钟以内或者百兆以内的短视频。不过即使是短视频如果用户在播放时需要等到视频全部下载完等待时间也是10s以上了这样用户的播放体验就不太好。
一种常见的优化方案是采用边下边播策略。在播放器下载完视频的格式信息、关键帧等信息后播放器其实就可以开始进入播放同时结合HTTP协议自带支持的Range头按需分片获取后续的视频流从而来实现边下边播和拖动快进。
支持边下边播需要有两个前提条件。
格式信息和关键帧信息在文件流的头部。如果这些信息在文件尾部,就没法做到边下边播了。对于格式信息和关键帧信息不在头部的视频,可以在转码完成时改成写入到头部位置。
服务端支持Range分片获取。有两种支持方式。-
a.一种是文件的存储服务本身支持按Range获取比如阿里的对象存储服务OSS和腾讯的对象存储服务COS都支持分片获取能够利用存储本身的分片获取机制真正做到“按需下载”。-
b.对于不支持分片获取的存储服务来说还可以利用负载均衡层对Range的支持来进行优化。比如Nginx的HTTP Slice模块就支持在接收到Range请求后从后端获取整个文件然后暂存到Nginx本地的Cache中这样取下一片时能够直接从Nginx的Cache中获取不需要再次向后端请求。这种方式虽然仍存在首次获取速度慢和Cache命中率的问题但也可以作为分片下载的一种优化策略。
图片压缩和视频转码
另一种优化下载性能的策略是对图片、视频进行压缩和转码,在保证清晰度的情况下尽量降低文件的大小,从而提升下载的性能和降低网络开销。
图片压缩一般又分为客户端压缩和服务端压缩。客户端压缩的目的主要是减小上传文件的大小,提升上传成功率,降低上传时间,这里我们就不再详细展开了。下面我们主要了解一下服务端压缩的一些比较有效的方式。
分辨率自适应
针对图片下载性能的优化,一个比较重要的优化点是“分辨率自适应”。
比如在消息会话里的图片我们可以使用低分辨率的缩略图来显示等用户点击缩略图后再去加载大图因为低分辨率的缩略图一般都只有几十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,172 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 APNs聊一聊第三方系统级消息通道的事
你好,我是袁武林。
前面几节课里,我讲到在即时消息场景下,我们会依赖服务端推送技术来提升消息到达的实时性,以及通过各种手段来保证消息收发通道的可用性,从而让消息能尽量实时、稳定地给到接收人。
但在实际情况中出于各种原因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下发到设备的过程-
首先Provider也就是IM服务器把消息通过长连接发送到APNs上
紧接着APNs把消息推送到接收方用户的iOS设备端并在设备的通知栏进行展示
最后用户通过点击通知等操作可以唤醒App来进行一系列的交互活动。
整个流程看起来比较简单。但仔细思考后你可能会对其中的几个地方产生疑惑比如APNs是如何识别App的用户在哪台设备上还有IM服务器是如何告知APNs应该把消息推送给哪个设备的哪个App呢
别着急,下面我就带你来了解一下,这两个问题在技术上是怎么解决的。
先了解一个概念吧DeviceToken。
什么是DeviceTokenDeviceToken是APNs用于区分识别不同iOS设备同一个App的唯一标识APNs的网关和设备通信时通过系统默认自带的长连接进行连接并通过DeviceToken作为当前连接设备的唯一标识进行系统消息的推送。
要通过APNs实现系统推送我们的推送服务Provider需要先和APNs建立长连建连时携带的证书包含“准备接收”系统消息推送的App的Bundle IdentifierBundle Identifier是一款App的唯一标识
每当我们有消息需要推送时都必须连同消息再携带待接收系统设备的DeviceToken给到APNs。APNs通过这个DeviceToken就能找到对应的连接设备从而就可以把消息通过APNs和设备间的长连接推送给这台设备上的App了。具体的流程你可以参考下面这张官网图。-
那么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时能够跳转到相应的群聊会话。你可以参考下面这个代码设计
{
"aps": {
"alert : {
"title": “新消息”, //标题
"subtitle": “来自: 张三”, //副标题
"body": “你好” //正文内容
},
"badge": 1, //角标数字
"sound": "default" //收到通知时播放的声音
},
"groupID": “123”,
"mid": "1001"
}
这里需要注意的是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和移动网络的场景下我们只要有手机信号也能接收到系统推送。
下面是“推必达”官网提供的技术架构图。你可以看到“推必达”提供了专门的SDK给到客户端当各个IM服务端有消息需要通过系统推送触达用户时会把消息直接或通过第三方推送服务交给各个厂商部署的UPS服务器UPS服务器再通过和客户端SDK维护的长连接把消息推送下去。
由于其支持多种消息的触达途径“推必达”的消息到达率据官网反馈在全国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,176 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 Cache多级缓存架构在消息系统中的应用
你好,我是袁武林。
今天我要带你了解的是一项在IM系统中相对比较通用的、使用比较高频的而且对系统性能提升非常明显的技术缓存。
说到缓存,你应该不陌生。相对于磁盘操作,基于内存的缓存对耗时敏感的高并发应用来说,在性能方面的提升是非常明显的。
下面是谷歌的技术奠基人杰夫·狄恩Jeff Dean给出的一些计算机相关的硬件指标虽然有些数据可能由于时间太久不够准确但大致的量级基本还是一致的。
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->Netherlands->CA 150,000,000 ns
可以看到同样是1MB的数据读取从磁盘读取的耗时比从内存读取的耗时相差近100倍这也是为什么业界常说“处理高并发的三板斧是缓存、降级和限流”了。
使用缓存虽然能够给我们带来诸多性能上的收益但存在一个问题是缓存的资源成本非常高。因此在IM系统中对于缓存的使用就需要我们左右互搏地在“缓存命中率”和“缓存使用量”两大指标间不断均衡。
在今天的课程中我会围绕IM系统中缓存的使用来聊一聊在使用过程中容易碰到的一些问题及相应的解决方案。
缓存的分布式算法
对于大规模分布式服务来说,大部分缓存的使用都是多实例分布式部署的。接下来,我们就先来了解一下缓存常见的两种分布式算法:取模求余与一致性哈希。
取模求余
取模求余的算法比较简单。比如说用于存储消息内容的缓存如果采用取模求余就可以简单地使用消息ID对缓存实例的数量进行取模求余。
如下图所示如果消息ID哈希后对缓存节点取模求余余数是多少就缓存到哪个节点上。-
取模求余的分布式算法在实现上非常简单。但存在的问题是如果某一个节点宕机或者加入新的节点节点数量发生变化后Hash后取模求余的结果就可能和以前不一样了。由此导致的后果是加减节点后缓存命中率下降严重。
一致性哈希
为了解决这个问题业界常用的另一种缓存分布式算法是一致性哈希。它是1997年麻省理工学院提出的一种算法目前主要应用在分布式缓存场景中。
一致性哈希的算法是把全量的缓存空间分成2的32次方个区域这些区域组合成一个环形的存储结构每一个缓存的消息ID都可以通过哈希算法转化为一个32位的二进制数也就是对应这2的32次方个缓存区域中的某一个缓存的节点也遵循同样的哈希算法比如利用节点的IP来哈希这些缓存节点也都能被映射到2的32次方个区域中的某一个。
那么如何让消息ID和具体的缓存节点对应起来呢
很简单每一个映射完的消息ID我们按顺时针旋转找到离它最近的同样映射完的缓存节点该节点就是消息ID对应的缓存节点。大概规则我画了一个图你可以参考一下-
那么,为什么一致性哈希能够解决取模求余算法下,加减节点带来的命中率突降的问题呢?
结合上图我们一起来看一下。假设已经存在了4个缓存节点现在新增加一个节点5那么本来相应会落到节点1的mid1和mid9可能会由于节点5的加入有的落入到节点5有的还是落入到节点1落入到新增的节点5的消息会被miss掉但是仍然落到节点1的消息还是能命中之前的缓存的。
另外其他的节点2、3、4对应的这些消息还是能保持不变的所以整体缓存的命中率相比取模取余算法波动会小很多。
同样,如果某一个节点宕机的话,一致性哈希也能保证,只会有小部分消息的缓存归属节点发生变化,大部分仍然能保持不变。
数据倾斜
一致性哈希既然解决了加减节点带来的命中率下降的问题,那么是不是这种算法,就是缓存分布式算法的完美方案呢?
这里我们会发现,一致性哈希算法中,如果节点比较少,会容易出现节点间数据不均衡的情况,发生数据倾斜;如果节点很多,相应的消息就能在多个节点上分布得更均匀。
但在实际的线上业务中,部署的缓存机器节点是很有限的。
所以,为了解决物理节点少导致节点间数据倾斜的问题,我们还可以引入虚拟节点,来人为地创造更多缓存节点,以此让数据分布更加均匀。
虚拟节点的大概实现过程,你可以参考下图:-
我们为每一个物理节点分配多个虚拟节点比如在上图这里给节点1虚拟出4个节点。当消息进行缓存哈希定位时如果落到了这个物理节点上的任意一个虚拟节点那么就表示真正的缓存存储位置在这个物理节点上然后服务端就可以从这个物理节点上进行数据的读写了。
如上面这个例子本来都落在节点3的4条消息mid4、mid5、mid6、mid7在加入节点1的虚拟节点后mid4和mid5落到了虚拟节点1-2上这样mid4和mid5就被分配到物理节点1上了。可见通过这种方式能更好地打散数据的分布解决节点间数据不平衡的问题。
缓存热点问题
通过一致性哈希配合虚拟节点我们解决了节点快速扩容和宕机导致命中率下降的问题及节点间数据倾斜的问题。但在IM的一些场景里还可能会出现单一资源热点的问题。
比如一个超级大V给他的粉丝群发了一篇精心编写的长文章可能一瞬间服务端会有上万的文章阅读请求涌入。由于这些长文章都是作为富文本进行存储的所以存储的数据较大有的文章都超过1MB而且用户还需要随时能够修改文章也不好通过CDN来进行分发。
那么,我们如何去解决这种缓存热点问题呢?
多级缓存架构-主从模式
我以上面的“长文章流量热点”的例子来说明一下。为了防止文章下载阅读出现热点时造成后端存储服务的压力太大我们一般会通过缓存来进行下载时的加速。比如说我们可以通过文章的唯一ID来进行哈希并且通过缓存的一主多从模式来进行部署主从模式的部署大概如下图-
一般来说,主从模式下,主库只用于数据写入和更新,从库只用于数据读取。当然,这个也不是一定的。
比如在写多读少的场景下也可以让主库承担一部分的数据读取工作。当缓存的数据读取QPS比较大的情况下可以通过增加从库的方式来提升整体缓存层的抗读取能力。
主从模式是最常见的、使用最多的缓存应用模式。但是主从模式在某些突发流量的场景下会存在一些问题,就比如刚刚提到的“长文章流量热点”问题。
我们对某篇长文章的唯一ID来进行哈希在主从模式下一篇文章只会映射到一个从库节点上。虽然能够通过增加从库副本数来提升服务端对一篇文章的读取能力但由于文章大小比较大即使是多从库副本对于千兆网卡的从库实例机器来说带宽层面也很难抗住这个热点。举个例子单台机器120MB带宽对于1MB大小的文章来说如果QPS到1000的话至少需要8个实例才可以抗住。
另外多从库副本是对主库数据的完整拷贝从成本上考虑也是非常不划算的。除了带宽问题对于某些QPS很高的资源请求来说如果采用的是单主单从结构一旦从库宕机瞬间会有大量请求直接穿透到DB存储层可能直接会导致资源不可用。
多级缓存架构-L1+主从模式
为了解决主从模式下单点峰值过高导致单机带宽和热点数据在从库宕机后造成后端资源瞬时压力的问题我们可以参考CPU和主存的结构在主从缓存结构前面再增加一层L1缓存层。
L1缓存顾名思义一般它的容量会比较小用于缓存极热的数据。那么为什么L1缓存可以解决主从模式下的带宽问题和穿透问题呢
我们来看一下L1+主从模式的部署和访问形式:-
L1缓存作为最前端的缓存层在用户请求的时候会先从L1缓存进行查询。如果L1缓存中没有再从主从缓存里查询查询到的结果也会回种一份到L1缓存中。
与主从缓存模式不一样的地方是L1缓存有分组的概念一组L1可以有多个节点每一组L1缓存都是一份全量的热数据一个系统可以提供多组L1缓存同一个数据的请求会轮流落到每一组L1里面。
比如同一个文章ID第一次请求会落到第一组L1缓存第二次请求可能就落到第二组L1缓存。通过穿透后的回种最后每一组L1缓存都会缓存到同一篇文章。通过这种方式同一篇文章就有多个L1缓存节点来抗读取的请求量了。
而且L1缓存一般采用LRULeast Recently Used方式进行淘汰这样既能减少L1缓存的内存使用量也能保证热点数据不会被淘汰掉。并且采用L1+主从的双层模式,即使有某一层节点出现宕机的情况,也不会导致请求都穿透到后端存储上,导致资源出现问题。
多级缓存架构-本地缓存+L1+主从的多层模式
通过L1缓存+主从缓存的双层架构,我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。
但有的时候面对一些极热的热点峰值我们可能需要增加多组L1才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本所以有没有更好的平衡极热峰值和缓存成本的方法呢
对于大部分请求量较大的应用来说,应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作,就能充分利用应用层机器的带宽和极少的内存,来低成本地解决带宽问题了。那么,这种方式是否可以实现呢?
答案是可以的,这种本地缓存+L1缓存+主从缓存的多级缓存模式,也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图:-
本地缓存一般位于应用服务器的部署机器上使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存应用层获取数据时先访问本地缓存如果未命中再通过远程从L1缓存层获取最终获取到的数据再回种到本地缓存中。
通过增加本地缓存依托应用服务器的多部署节点基本就能完全解决热点数据带宽的问题。而且相比较从远程L1缓存获取数据本地缓存离应用和用户设备更近性能上也会更好一些。
但是使用本地缓存有一个需要考虑的问题,那就是数据的一致性问题。
还是以“长文章”为例。我们的服务端可能会随时接收到用户需要修改文章内容的请求,这个时候,对于本地缓存来说,由于应用服务器的部署机器随着扩缩容的改变,其数量不一定是固定的,所以修改后的数据如何同步到本地缓存中,就是一个比较复杂和麻烦的事情了。
要解决本地缓存一致性问题业界比较折中的方式是对本地缓存采用“短过期时间”的方式来平衡本地缓存命中率和数据更新一致性的问题。比如说针对“长文章”的本地缓存我们可以采用5秒过期的策略淘汰后再从中央缓存获取新的数据。这种方式对于大部分业务场景来说在产品层面上也是都能接受的。
小结
好了,下面简单回顾一下今天课程的内容。
首先我介绍了缓存在高并发应用中的重要性以及在IM系统中使用的部分场景。然后再带你了解了缓存分布式的两种算法取模求余和一致性哈希。
取模求余算法在实现上非常简单,但存在的问题是,取模求余算法在节点扩容和宕机后会出现震荡,缓存命中率会严重降低。
一致性哈希算法解决了节点增删时震荡的问题,并通过虚拟节点的引入,缓解了“数据倾斜”的情况。
最后,我着重介绍了业界通用的三种分布式缓存的常见架构。
一种是主从模式。简单的主从模式最常见,但是在面对峰值热点流量时,容易出现带宽问题,也存在缓存节点宕机后穿透到存储层的问题。
第二种是L1+主从模式。通过增加L1缓存层以并行的多组小容量的L1缓存解决了单一热点的带宽问题也避免了单一节点宕机后容易穿透到DB存储层的情况。
最后一种是本地缓存+L1+主从的多层模式。作为低成本的解决方案我们在L1+主从模式的基础上,引入了本地缓存。本地缓存依托应用服务器的本机少量内存,既提升了资源的有效利用,也彻底解决了带宽的问题。同时在性能方面,也比远程缓存获取更加优秀。对于本地缓存的数据一致性问题,我们可以通过“短过期时间”来平衡缓存命中率和数据一致性。
面对高并发业务带来的流量压力,我们不可否认的是,缓存的使用是目前为止最有效的提升系统整体性能的手段。作为系统优化的一把利器,如何用好这个强大的工具,是你需要去不断思考和学习的。希望今天介绍的这几种缓存使用的姿势,能够让你有所收获,并能在自己的业务中去尝试实践。
最后给你留一道思考题:
L1+主从模式下如果热点数据都被L1缓存层拦截命中会导致主从缓存层相应的这个热点数据由于长时间得不到读取而被LRU淘汰掉。这样如果下线L1缓存还是会有不少的请求直接穿透到DB存储层。那么有没有办法能够让主从缓存在有L1缓存层的情况下依旧能保持数据热度
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

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

View File

@ -0,0 +1,188 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 端到端Trace消息收发链路的监控体系搭建
你好,我是袁武林。
前面的大部分课程我基本都是围绕“如何开发和设计一个IM系统”的技术点来进行分析和讲解的但在实际项目的工程落地实践中IM系统的监控和保障也是极其重要的一环。
只有通过对消息收发链路的监控,我们才能够实时地了解到链路是否可用,后端服务是否足够健康;如果监控体系不够完善,我们的业务即使上线了,也是处于“蒙眼狂奔”的状态。所以,我们要在工程上线时有一个“服务上线,监控先行”的意识和原则。
今天,我们就一起来聊一聊,消息收发链路中监控体系搭建的问题。
在IM场景中常见的监控模式大概可以分为两种一种是基于数据收集的被动监控一种是基于真实探测的主动监控。下面我们来分别看一下这两种监控模式的实现细节和区别。
基于数据收集的被动监控
“基于数据收集的被动监控”,应该是我们日常开发保障中,最常见的服务和系统监控方式了。
一般来说被动监控可以粗略地分成几个子类型系统层监控、应用层监控及全链路Trace监控。
系统层监控
系统层监控是整个监控体系中最基础的监控方式,一般来说,它主要监控的是操作系统维度的一些核心性能指标。
举个例子我们要对上线的业务进行监控可以通过Nagios、Zabbix等类似的系统监控工具来实时收集机器相关的性能数据如CPU、内存、IO、负载、带宽、PPS、Swap等数据信息。
由于足够通用,系统监控相关的具体细节,我在这里就不展开了,你可以在留言区与我一起探讨。
应用层监控
除了系统层面的监控,我们非常依赖的另一种被动监控是应用自身的监控。
在即时消息场景中我们需要实时监控消息收发接口的QPS、耗时、失败数、消息在线推送到达率等如果出现耗时、失败率增长或者推送到达率降低等情况我们就要引起注意并对其进行分析了。
比如在微博平台的场景里就用到了基于Graphite的监控体系来对微博的应用状态进行监控。
在对应用层业务直接相关的API接口进行监控之外我们还需要知道在消息收发的核心链路中这些业务直接相关的API接口所依赖的其他API或资源的性能情况以便于我们在业务接口出现失败率高或者耗时增长的时候能够通过监控系统快速找到导致这个接口出现问题时所依赖的资源或者其他依赖接口。
比如我们还需要监控离线Buffer用到的Redis的使用量、相应的读写QPS、耗时等。
除了监控应用层整体的情况当业务层直接相关的API接口在整体层面上出现性能问题时由于可能只是某一两台机器的API接口出现了性能问题并且由于统计的原因导致该API接口在整体上看失败率升高或者耗时增加因此为了便于排查和分析我们一般还需要对单机的应用状态分别进行监控。
比如某一台机器由于内存不够吃Swap了或者网络发生抖动导致接口耗时增长了我们就需要针对这台单机进行监控来快速发现问题并处置。
应用层的监控数据收集和使用的架构,你可以参考下图:-
应用服务进程通过本地套接字的方式把服务自身的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”。
在这篇论文中介绍了Google的Dapper系统并首次定义了什么是分布式跟踪系统以及介绍了分布式跟踪系统的三大设计要点低开销、对应用透明、高可扩展性。
为了实现分布式链路追踪Dapper论文提出了Trace、Span、Annotation的概念并给出了一个Trace调用的示例如下图-
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为例其整体的实现架构你可以参考下面的这张官网图-
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系统来定位消息收发的故障点以及用于链路优化的分析支撑。
以下图中出现的故障点为例:-
这一次群聊消息查询失败的原因是调用一个“富文本”解析服务1秒超时失败红框1调用一个叫spage接口而且还发现群聊服务对“富文本消息”解析是串行调用的红框2这里的“串行调用”就是一个待优化点。
全链路监控Trace中一个值得注意的问题是Trace数据采样率。
由于一次消息收发的调用链路Span数一般都非常多对于访问量较大的场景来说全量的Trace数据量太大所以一般会通过采样的方式来减少Trace数据的收集。比如在App启动时让服务端返回告知客户端采样率客户端按照约定的采样率对部分请求进行采样。
举个实际的例子在微博的线上环境中对上行请求一般是百分百采样对下行普通用户一般是1%采样对VIP用户上下行请求是全量采样。
除了采样率问题另一个比较麻烦就是Trace数据的采集问题。
虽然大部分分布式Trace系统支持多语言Reporter来上报数据但由于各系统的完善程度差别比较大特别是基于AOP等探针来“无感知”地对各种中间件的支持还是不太够因此在整体上还需要一定的探针的工作开发量。
另外针对多个异构系统的对接除了在各自系统的业务代码中直接上报Trace数据外我们还可以通过本地日志+Agent上报的方式来解耦异构系统对Trace SDK的强依赖。
基于回环探测的主动监控
前面讲到,不管是系统监控、应用监控,还是全链路监控,本质上都是基于数据汇报的被动式监控。
正常情况下,基于各种监控数据收集的被动监控,能够协助我们快速发现问题,但当服务出现故障时,被动监控依赖的数据收集就会容易出现上报延迟,甚至上报失败的情况。比如,当系统负载很高时,业务系统就很难再保障监控数据的上报了。
所以,业界另一种实时监控业务系统可用性的方式就是:“基于回环探测的主动监控”。
对于即时消息场景来说大部分场景都是基于用户维度的消息收发的业务形态因此我们可以通过固定的两个或几个测试用户的UID进行消息的互相收发利用消息收发回环来实时探测消息链路的可用性。大概的思路如下图-
我们分别在机房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,168 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 存储和并发:万人群聊系统设计中的几个难点
你好,我是袁武林。
我在场景篇的第10讲“自动智能扩缩容直播互动场景中峰值流量的应对”中分析了直播互动场景中容易出现瓶颈的原因主要在于“直播间人数多短时间内活跃度高消息的扇出量巨大”。
那么,对于同样属于多人互动的群聊场景来说,虽然在“群人数”等方面与高热度的直播间相比要少一些,但由于同时开播的直播间数量一般不会太多,所以群在数量上的总体量级相对要大得多,可能上百万个群同时会有消息收发的情况发生。因此,在整体的流量方面,群聊场景的消息扇出也是非常大的。
而且和直播互动场景不太一样的是,直播互动中,热度高的直播活动持续时间都比较短,可以借助上云,来进行短时间的扩容解决,成本方面也比较可控;但群聊的场景,一般是流量总量高,但是峰值没有那么明显,靠扩容解决不太现实。因此,更多地需要我们从架构和设计层面来优化。
今天,我们就一起从架构设计层面,来聊一聊万人群聊系统中的几个难点,以及相应的解决方案。
群聊消息怎么存储?
首先来看一看群聊消息存储的问题。
关于点对点聊天场景我在第2课“消息收发架构为你的App加上实时通信功能”中也有讲到我们在一条消息发出后会针对消息收发的双方各自存储一条索引便于双方进行查询、删除、撤回等操作。
那么,对于群聊消息来说,是不是也需要给群里的每一个用户,都存储一条消息索引呢?
这里需要注意的是:对于点对点聊天来说,针对消息收发双方进行用户维度的索引存储,能便于后续会话维度的消息查看和离线消息的获取,但如果群聊场景也采取这种方式,那么假设一个群有一万个人,就需要针对这一万个人都进行这一条消息的存储,一方面会使写入并发量巨大,另一方面也存在存储浪费的问题。
所以,业界针对群聊消息的存储,一般采取“读扩散”的方式。也就是一条消息只针对群维度存储一次,群里用户需要查询消息时,都通过这个群维度的消息索引来获取。
用户查询群聊消息的大概流程,你可以参考下图:-
系统先查询这个用户加入的所有群根据这些群的最新一条消息的ID消息ID与时间相关或者最新一条消息的产生时间来进行“最近联系人”维度的排序再根据这些群ID获取每个群维度存储的消息。
怎么保证新加入群的用户只看到新消息?
群聊用户共用群维度的消息存储,能大幅降低用户维度消息的写入。
但这里有一个问题:如果群消息是共享的,怎么保证新加入群的用户看不到加群前的群聊消息呢?
解决这个问题其实比较简单,你可以采取下图这个方案:-
我们只需要在用户加群的时候记录一个“用户加群的信息”把用户加群时间、用户加群时该群最新一条消息的ID等信息存储起来当用户查询消息时根据这些信息来限制查询的消息范围就可以了。
单个用户删除消息怎么办?
除了新加群用户消息查询范围的问题,群消息共享存储方案在实现时,还有一个比较普遍的问题:如果群里的某一个用户删除了这个群里的某条消息,我们应该怎么处理?
首先,由于群消息是共用的,我们肯定不能直接删除群消息索引中的记录。
一个可行的办法是:在用户删除消息的时候,把这条被删除消息加入到当前用户和群维度的一个删除索引中;当用户查询消息时,我们对群维度的所有消息,以及对这个“用户和群维度”的删除索引进行聚合剔除就可以了。
同样的处理,你还可以用在其他一些私有类型的消息中。比如,只有自己能看到的一些系统提示类消息等。
未读数合并变更
解决了群聊消息存储的问题,还有一个由于群聊消息高并发扇出而引起的问题。
我在“07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?”这一篇中讲到过:针对每一个用户,我们一般会维护两个未读数,用于记录用户在某个群的未读消息数和所有未读数。
也就是说,当群里有人发言时,我们需要对这个群里的每一个人都进行“加未读”操作。因此,对于服务端和未读数存储资源来说,整体并发的压力会随着群人数和发消息频率的增长而成倍上升。
以一个5000人的群为例假设这个群平均每秒有10个人发言那么每秒针对未读资源的变更QPS就是5w如果有100个这样的群那么对未读资源的变更压力就是500w所以整体上需要消耗的资源是非常多的。
解决这个问题的一个可行方案是:在应用层对未读数采取合并变更的方式,来降低对存储资源的压力。
合并变更的思路大概如下图:-
未读变更服务接收群聊的加未读请求将这些加未读请求按照群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课“分布式一致性让你的消息支持多终端漫游”中有讲到为了解决用户离线期间收不到消息的问题我们会在服务端按照接收用户维度暂存用户离线期间的消息等该用户下次上线时再进行拉取同步。
这里的离线Buffer是用户维度的因此对于群聊中的每一条消息服务端都会在扇出后进行暂存。
假设是一个5000人的群一条消息可能会暂存5000次这样一方面对离线Buffer的压力会比较大另外针对同一条消息的多次重复暂存对资源的浪费也是非常大的。
要解决多次暂存导致离线Buffer并发压力大的问题一种方案是可以参考“未读数合并变更”的方式对群聊离线消息的存储也采用“合并暂存”进行优化所以这里我就不再细讲了。
另一种解决方案是:我们可以对群聊离线消息的暂存进行限速,必要时可以丢弃一些离线消息的暂存,来保护后端资源。
因为通过“版本号的链表机制”,我们可以在用户上线时发现“离线消息”不完整的问题,然后再从后端消息存储中重新分页获取离线消息,从而可以将一部分写入压力延迟转移到读取压力上来。
不过这里你需要注意的是:这种降级限流方式存在丢失一些操作信令的问题,是有损降级,所以非必要情况下尽量不用。
另外针对群聊消息重复暂存的问题我们可以只在离线Buffer中暂存“消息ID”不暂存消息内容等到真正下推离线消息的时候再通过消息ID来获取内容进行下推以此优化群聊消息对离线Buffer资源过多占用的情况。
离线消息批量ACK
在群聊离线消息场景中还有一个相对并发量比较大的环节就是离线消息的ACK处理。
我在“04 | ACK机制如何保证消息的可靠投递”这节课中讲到我们会通过ACK机制来保证在线消息和离线消息的可靠投递。但是对于群的活跃度较高的情况来说当用户上线时服务端针对这个群的离线消息下推量会比较大。
以微博场景中的超大规模的粉丝群为例本来群内的用户就已经比较活跃了如果该群隶属的明星突然空降进来可能会导致大量离线用户被激活同一时间会触发多个用户的离线消息下推和这些离线消息的ACK针对离线消息接收端的ACK回包服务端需要进行高并发的处理因而对服务端压力会比较大。
但实际上由于群聊离线消息的下推发生在用户刚上线时这个时候的连接刚建立稳定性比较好一般消息下推的成功率是比较高的所以对ACK回包处理的及时性其实不需要太高。
因此一种优化方案是针对离线消息接收端进行批量ACK。
参照TCP的Delay ACK延迟确认机制我们可以在接收到离线推送的消息后“等待”一定的时间如果有其他ACK包需要返回那么可以对这两个回包的ACK进行合并从而降低服务端的处理压力。
需要注意的是接收端的Delay ACK可能会在一定程度上加剧消息重复下推的概率。比如ACK由于延迟发出导致这时的服务端可能会触发超时重传重复下推消息。
针对这个问题,我们可以通过接收端去重来解决,也并不影响用户的整体体验。
不记录全局的在线状态
群聊场景下的超大消息扇出,除了会加大对离线消息的资源消耗,也会对消息的在线下推造成很大的压力。
举个例子:在点对点聊天场景中,我们通常会在用户上线时,记录一个“用户连接所在的网关机”的在线状态,而且为了和接入服务器解耦,这个在线状态一般会存储在中央资源中;当服务端需要下推消息时,我们会通过这个“中央的在线状态”来查询接收方所在的接入网关机,然后把消息投递给这台网关机,来进行最终消息的下推。
在群聊场景中,很多实现也会采用类似方式进行在线消息的精准下推,这种方案在群人数较少的时候是没问题的,但是当群成员规模很大时,这种方式就会出现瓶颈。
一个瓶颈在于,用户上线时对“在线状态”的写入操作;另一个瓶颈点在于,服务端有消息下推时,对“在线状态”的高并发查询。
因此,针对万人群聊的场景,我们可以采取类似直播互动中的优化方式,不维护全局的中央“在线状态”,而是让各网关机“自治”,来维护接入到本机的连接和群的映射。你可以参考下图所示的实现过程:-
比如同一个群的用户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,347 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 期末实战为你的简约版IM系统加上功能
你好,我是袁武林。
在期中实战中,我们一起尝试实现了一个简易版的聊天系统,并且为这个聊天系统增加了一些基本功能。比如,用户登录、简单的文本消息收发、消息存储设计、未读数提示、消息自动更新等。
但是期中实战的目的主要是让你对IM系统的基本功能构成有一个直观的了解所以在功能的实现层面上比较简单。比如针对消息的实时性期中采用的是基于HTTP短轮询的方式来实现。
因此,在期末实战中,我们主要的工作就是针对期中实战里的消息收发来进行功能优化。
比如我们会采用WebSocket的长连接来替代之前的HTTP短轮询方式并且会加上一些课程中有讲到的相对高级的功能如应用层心跳、ACK机制等。
希望通过期末整体技术实现上的升级你能更深刻地体会到IM系统升级前后对使用方和服务端压力的差异性。相应的示例代码我放在了GitHub里你可以作为参考来学习和实现。
功能介绍
关于这次期末实战,希望你能够完成的功能主要包括以下几个部分:
支持基于WebSocket的长连接。
消息收发均通过长连接进行通信。
支持消息推送的ACK机制和重推机制。
支持客户端的心跳机制和双端的idle超时断连。
支持客户端断线后的自动重连。
功能实现拆解
接下来,我们就针对以上这些需要升级的功能和新增的主要功能,来进行实现上的拆解。
WebSocket长连接
首先期末实战一个比较大的改变就是将之前HTTP短轮询的实现改造成真正的长连接。为了方便Web端的演示这里我建议你可以使用WebSocket来实现。
对于WebSocket我们在客户端JSJavaScript里主要是使用HTML5的原生API来实现其核心的实现代码部分如下
if (window.WebSocket) {
websocket = new WebSocket("ws://127.0.0.1:8080");
websocket.onmessage = function (event) {
onmsg(event);
};
//连接建立后的事件监听
websocket.onopen = function () {
bind();
heartBeat.start();
}
//连接关闭后的事件监听
websocket.onclose = function () {
reconnect();
};
//连接出现异常后的事件监听
websocket.onerror = function () {
reconnect();
};
} else {
alert("您的浏览器不支持WebSocket协议"
}
页面打开时JS先通过服务端的WebSocket地址建立长连接。要注意这里服务端连接的地址是ws://开头的不是http://的了如果是使用加密的WebSocket协议那么相应的地址应该是以wss://开头的。
建立长连之后要针对创建的WebSocket对象进行事件的监听我们只需要在各种事件触发的时候进行对应的逻辑处理就可以了。
比如API主要支持的几种事件有长连接通道建立完成后通过onopen事件来进行用户信息的上报绑定通过onmessage事件对接收到的所有该连接上的数据进行处理这个也是我们最核心的消息推送的处理逻辑另外在长连接通道发生异常错误或者连接被关闭时可以分别通过onerror和onclose两个事件来进行监听处理。
除了通过事件监听来对长连接的状态变化进行逻辑处理外我们还可以通过这个WebSocket长连接向服务器发送数据消息。这个功能在实现上也非常简单你只需要调用WebSocket对象的send方法就OK了。
通过长连接发送消息的代码设计如下:
var sendMsgJson = '{ "type": 3, "data": {"senderUid":' + sender_id + ',"recipientUid":' + recipient_id + ', "content":"' + msg_content + '","msgType":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("WebSocketBossGroup", true));
EventLoopGroup workerGroup =
new EpollEventLoopGroup(serverConfig.workerThreads, new DefaultThreadFactory("WebSocketWorkerGroup", true));
ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerGroup).channel(EpollServerSocketChannel.class);
ChannelInitializer<SocketChannel> initializer = new ChannelInitializer<SocketChannel>() {
@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("/", 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("type");
JSONObject data = msgJson.getJSONObject("data");
long senderUid = data.getLong("senderUid");
long recipientUid = data.getLong("recipientUid");
String content = data.getString("content");
int msgType = data.getIntValue("msgType");
//调用业务层的Service来进行真正的发消息逻辑处理
MessageVO messageContent = messageService.sendNewMsg(senderUid, recipientUid, content, msgType);
if (messageContent != null) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("type", 3);
jsonObject.put("data", JSONObject.toJSON(messageContent));
ctx.writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(jsonObject)));
}
}
}
这里的WebSocketRouterHandler我们也是采用事件监听机制来实现。由于这里需要处理“接收到”的消息所以我们只需要实现channelRead0方法就可以。
在前面的管道处理器链中因为添加了WebSocket相关的编解码器所以这里的WebSocketRouterHandler接收到的都是WebSocketFrame格式的数据。
接下来我们从WebSocketFrame格式的数据中解析出文本类型的收发双方UID和发送内容就可以调用后端业务模块的发消息功能来进行最终的发消息逻辑处理了。
最后把需要返回给消息发送方的客户端的信息再通过writeAndFlush方法写回去就完成消息的发送。
不过,以上的代码只是处理消息的发送,那么针对消息下推的逻辑处理又是如何实现的呢?
刚刚讲到,客户端发送的消息,会通过后端业务模块来进行最终的发消息逻辑处理,这个处理过程也包括消息的推送触发。
因此我们可以在messageService.sendNewMsg方法中等待消息存储、未读变更都完成后再处理待推送给接收方的消息。
你可以参考下面的核心代码:
private static final ConcurrentHashMap<Long, Channel> userChannel = new ConcurrentHashMap<>(15000);
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
//处理上线请求
long loginUid = data.getLong("uid");
userChannel.put(loginUid, ctx.channel());
}
public void pushMsg(long recipientUid, JSONObject message) {
Channel channel = userChannel.get(recipientUid);
if (channel != null && channel.isActive() && 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("Message Received --> pattern: {}topic:{}message: {}", new String(pattern), topic, jsonMsg);
JSONObject msgJson = JSONObject.parseObject(jsonMsg);
//解析出消息接收人的UID
long otherUid = msgJson.getLong("otherUid");
JSONObject pushJson = new JSONObject();
pushJson.put("type", 4);
pushJson.put("data", 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机制如何保证消息的可靠投递”中有讲到当系统有消息下推后我们会依赖客户端响应的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("tid"), msgJson);
//定时器针对下推的这条消息在5s后进行"是否ACK"的检查
executorService.schedule(() -> {
if (channel.isActive()) {
//检查是否被ACK如果没有收到ACK回包会触发重推
checkAndResend(channel, msgJson);
}
}, 5000, TimeUnit.MILLISECONDS);
}
long tid = data.getLong("tid");
nonAcked.remove(tid);
private void checkAndResend(Channel channel, JSONObject msgJson) {
long tid = msgJson.getLong("tid");
//重推2次
int tryTimes = 2;
while (tryTimes > 0) {
if (nonAcked.containsKey(tid) && tryTimes > 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课“智能心跳机制解决网络的不确定性”中也有讲到过主要是为了解决由于网络的不确定性而导致的连接不可用的问题。
客户端发送心跳包的主要代码设计如下,不过我这里的示例代码只是一个简单的实现,你可以自行参考,然后自己去尝试动手实现:
//每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 = $("#sender_id").val();
var sendMsgJson = '{ "type": 0, "data": {"uid":' + sender_id + ',"timeout": 120000}}';
websocket.send(sendMsgJson);
self.serverTimeoutObj = setTimeout(function () {
websocket.close();
$("#ws_status").text("失去连接!");
}, self.timeout)
}, this.timeout)
},
}
客户端通过一个定时器每2分钟通过长连接给服务端发送一次心跳包如果在2分钟内接收到服务端的消息或者响应那么客户端的下次2分钟定时器的计时会进行清零重置重新计算如果发送的心跳包在2分钟后没有收到服务端的响应客户端会断开当前连接然后尝试重连。
我在下面的代码示例中,提供的“服务端接收到心跳包的处理逻辑”的实现过程,其实非常简单,只是封装了一个普通回包消息进行响应,代码设计如下:
@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
long uid = data.getLong("uid");
long timeout = data.getLong("timeout");
logger.info("[heartbeat]: uid = {} , current timeout is {} ms, channel = {}", uid, timeout, ctx.channel());
ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":0,\"timeout\":" + timeout + "}"));
}
我们实际在线上实现的时候,可以采用前面介绍的“智能心跳”机制,通过服务端对心跳包的响应,来计算新的心跳间隔,然后返回给客户端来进行调整。
到这里期末实战的主要核心功能基本上也讲解得差不多了细节方面你可以再翻一翻我在GitHub上提供的示例代码。
对于即时消息场景的代码实现来说,如果要真正达到线上使用的程度,相应的代码量是非常庞大的;而且对于同一个功能的实现,根据不同的使用场景和业务特征,很多业务在设计上也会有较大的差异性。
所以,实战课程的设计和示例代码只能做到挂一漏万,我尽量通过最简化的代码,来让你真正了解某一个功能在实现上最核心的思想。并且,通过期中和期末两个阶段的功能升级与差异对比,使你能感受到这些差异对于使用方体验和服务端压力的改善,从而可以更深刻地理解和掌握前面课程中相应的理论点。
小结
今天的期末实战我们主要是针对期中实战中IM系统设计的功能来进行优化改造。
比如使用基于WebSocket的长连接代替基于HTTP的短轮询来提升消息的实时性并增加了应用层心跳、ACK机制等新功能。
通过这次核心代码的讲解是想让你能理论结合实际地去理解前面课程讲到的IM系统设计中最重要的部分功能也希望你能自己尝试去动手写一写。当然你也可以基于已有代码去增加一些之前课程中有讲到但是示例代码中没有实现的功能比如离线消息、群聊等。
最后再给你留一个思考题ACK机制的实现中如果尝试多次下推之后仍然没有成功服务端后续应该进行哪些处理呢
以上就是今天课程的内容,欢迎你给我留言,我们可以在留言区一起讨论,感谢你的收听,我们下期再见。

View File

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

View File

@ -0,0 +1,99 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 真正的高贵,不是优于别人,而是优于过去的自己
你好,我是袁武林。
不知不觉中,在大家的耐心陪伴下,即时消息专栏的更新马上就要结束了。
首先,要感谢各位同学对我的信任和宽容,给我这个机会,让我能够在这段短暂的时间里,带领大家一起来学习即时消息这一门古老但又充满活力的技术。
对我来说,这是一段非常特别和有趣的体验,我也第一次尝试通过专栏的方式,将自己了解的知识进行体系化的输出。
当然这并不是一件简单的事情,知识点整理、稿件打磨、录音、留言反馈等等,背后的艰辛可能也只有经历过的人才能体会到。但每次专栏更新上线,看到自己的付出有帮助到一些同学,并且得到肯定的时候,我瞬间感觉又充满了战斗的能量。
关于专栏的落地
实际上,在开始筹备这个专栏的时候,我个人是比较没有信心的。
原因倒不是说技术层面的问题而是担心即时消息这个话题因为受众相对没有那么广泛在推出之后如何让更多非IM行业的小伙伴也能够感兴趣并且参与进来一直是我和极客时间团队需要认真考虑的一个事情。
因为一直没有想到更好的办法所以这个专栏甚至中途一度停滞中断。直到6月下旬经过和极客时间小伙伴们的多次讨论我们才最终决定继续推进这个专栏的落地。
关于即时消息技术的前景
从我们的角度看随着4G网络的普及和5G网络的逐步推广在移动网络场景中不管是流量资费还是网络的稳定性和速度、带宽相比几年前都有了非常明显的提升。
在这样的大环境下,原本更多被用于聊天等社交场景的即时通讯技术,也被大范围应用于“万物互联”的物联网场景,以及新型社交模式,如直播互动、游戏互动等。而且,还有越来越多对实时性要求高的业务场景,也都开始引入即时通讯技术,来提升用户的使用体验。
因此,我们觉得,开设一门即时通讯技术相关的课程,不仅能够让同学们从基础原理层面上更体系化地了解即时通讯底层核心的技术实现,并且能够帮助大家在各自从事的业务系统中,结合实际场景,来尝试引入课程中介绍到的某些适合自己业务的技术点。
专栏真正开始落地后,在一系列自我摧残式的存稿,以及逐字逐句和编辑的磨稿过程中,我才切身体会到做一个专栏有多不容易。
和以前随意写写博客相比,一个严谨的专栏需要耗费大量的时间和精力去提炼技术点,并且要尽量用“通俗易懂”的文字来组织语言。
专栏的落地对于我自己来说,也是一个成长和学习的过程。
特别是在留言区,就某些技术点和各位同学们的讨论,大家的一些有趣、富有想象力的观点,也能让我重新思考,涉及的这些技术点是不是某些方面的考虑还不够,或者是否还有更好的实现落地方案。
在专栏即将结束的时候,我也想借这个机会,和参与到这门课程学习中的你,聊一聊我对技术和学习方法的一些个人的思考。
学习方法论:知识的广度和深度问题
很多时候,我们搞技术的小伙伴经常会听到人们对于知识的两种学习路径:一种是追求知识的广度,力图多点开花,前后端通吃;另一种是追求某一个知识点的深度,从对某一个小知识点的“了解和会用”,到“底层原理分析”,再到“思考优化”的这种路径来学习知识。
但是对于很多从事技术的小伙伴们来说,这样很容易陷入到一个“两难”的学习方法论的抉择中,毕竟现在的新技术层出不穷。
就拿即时消息的技术体系来说暂且不去讲多种语言实现的问题光是Java体系下网络通信的NIO框架就有好几种要选择哪个框架就是一个让人很纠结的问题。
这种情况下很容易让人陷入一个怪圈就是我们可能花费了大量的精力放在框架特性介绍对比、框架使用方法研究等知识上面力求通过掌握更多的NIO框架的使用来提升自己这方面知识的广度。但从我个人角度来看是不推荐这种学习和研究方式的。
我认为所谓的知识广度是一个伪命题,换句话说,知识的广度只是一个结果,而不是一种学习的方法。
我更推荐的是:针对某一个特定的技术点,从使用方法下沉到原理层面上,再逐步去拓展和了解研究学习过程中碰到的各种疑问与实现细节,从而“由点到面”地去吃透该知识点。
打个比方要对NIO框架进行研究我觉得没必要先去纠结各种框架的优劣而是可以选择一种比较主流大众的框架去做实践然后逐步去了解这个NIO框架使用到的JDK的IO库的实现方式。
比如搞明白JDK的IO库模型是从传统的阻塞型的BIO发展到JDK 1.4开始支持多路复用的NIO再发展到JDK 1.7继续改进的异步的AIO以此来了解这几种模型的区别和迭代改进的地方。
最后再深入到底层的操作系统层了解它们是如何支持这些IO模型的。
比如要了解Linux的select和epoll的实现原理可能还需要了解IO过程中涉及到的IO事件、最终的TCP连接的状态以及与数据收发之间的关系。因此你还可以对TCP协议进行体系化的了解。
通过这种层层深入的学习方式我们就能较为扎实地掌握一个NIO框架涉及到的垂直知识体系。
这样你在掌握知识点的同时知识广度方面也能自然而然地充实起来以后再切换到其他NIO框架就相对简单了很多也能更客观和精确地对这些框架从底层原理层面来进行比对了。
当然,这种逐层深入的学习方法,在前期可能需要花费大量的精力和时间,因此需要你一定的耐心和坚持,但在对底层原理层知识有一定的积累之后,这种学习的方法也会越来越轻松。
学习方法论:碎片化知识和系统化学习
关于技术学习路径的另一个小小的建议是:从了解碎片化的知识开始,逐步扩大你在某一方面的技能树,然后再对这方面的整体知识进行系统化的学习和总结沉淀。
随着现在各种技术博客、技术类公众号、技术站点等等的普及和传播,大部分时候,我们只能通过工作之余的些许碎片化时间,来了解某些细碎的知识点。
当然,这些碎片化的知识点是一种不错的积累,但这些零散的知识点,在碎片化学习的时候,由于学习时间短、文章内容比较聚焦于一个小点,很容易让我们看完之后产生一种“我知道了”,然后就没有后续了,难以进行复杂的思考。
所以,我个人的建议是,对于某一技术点方面的碎片化的知识,在一段时间的学习后,还是需要进行系统化的“复盘”,一来可以补充遗漏的某些技术细节,二来能够形成对这个知识点的整理和总结思考,最终沉淀成自己的文档或者代码输出。
另外,你也可以尽量优先地去选择学习与自己工作内容息息相关的技术点,这样在理解知识和后续的输出实践上,也会更有帮助。
写在最后
对于从事技术工作的我们来说,技术探索的道路总是显得如此漫长,不断迭代更新的技术时常会让我们眼花缭乱,各种技术知识产品的爆炸式推广,让我们获取知识的方式变得如此简单。
但另一方面,在快节奏的生活方式下,在繁重的工作、与家人朋友的相处时间之外,我们的时间总是显得远远不够用。
如何保持一颗对新鲜事物的好奇心,如何高效有序地管理时间,如何形成一套适合自己的学习方法,这个时候可能往往比技术本身更重要。
海明威说:“真正的高贵,不是优于别人,而是优于过去的自己。”
无论如何,可能我们在一开始的时候,学习的技巧并不是那么高明,但只要我们保持终身学习的姿态,持续地投入到真正喜欢的事情上,就能让今天的我们比昨天进步一点点。
我相信,时间的复利最终能让你真正采撷到成功的芬芳玫瑰!