86-即时消息技术剖析与实战

开篇词 | 搞懂“实时交互”的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 的阻塞有时还会影响文本消息的收发。

所以后来我们对通道进行了一次拆分，把图片、文件等二进制流拆到一个独立通道，核心通道只推缩略图流，大幅减轻了通道压力，提升了核心链路的稳定性。

同时，独立的通道也缩短了客户端到文件流的链路，这样也提升了图片的访问性能。

但后来视频功能上线后，我们发现视频的 PSR1（1 秒内播放成功率）比较低，原因是视频文件一般比较大，为避免通道阻塞，不会通过消息收发的核心通道来推送。

所以之前的策略是：通过消息通道只下推视频的 ID，用户真正点击播放时才从服务端下载，这种模式虽然解决了通道阻塞的问题，但“播放时再下载”容易出现卡顿的情况。

因此，针对视频类消息，我们增加了一个 notify-pull 模式，当客户端收到一条视频类消息的通知时，会再向服务器发起一个短连接的拉取请求，缓冲前 N 秒的数据。

这样等用户点击播放时，基本就能秒播了，较大地提升了视频消息播放的 PSR1（1 秒内播放成功率）。

因此，我们要打造一套“实时、安全、稳定”的 IM 系统，我们需要深入思考很多个地方，尤其是作为整个实时互动业务的基础设施，扩展性、可用性、安全性等方面都需要有较高的保障。

比如下面几种情况。

某个明星忽然开直播了，在线用户数和消息数瞬间暴涨，该如何应对？

弱网情况下，怎么解决消息收发失败的问题，提升消息到达率？

如何避免敏感聊天内容由于网络劫持而泄露？

诸如此类的问题可能有很多种解决方案，但是对于不同的场景适用的方案可能也不一样。

因此在随后的内容里，我希望能够先系统化地带着你了解一下，一套基础的 IM 系统的整体构成，以及不同业务场景下可能存在的问题点和瓶颈点。

然后，我会从经验角度出发来和你一起深入探讨这些问题，并在这一过程中尽量遵循解决问题的 3W 原则（What、Why、How）。从问题现象出发，结构化分析问题的本质原因，并讨论多种解决问题的优劣和选择。

我希望能通过这样的方式，不仅让你对 IM 的核心组成有一个整体的认识，而且能够在各个瓶颈点的分析和后续的实践中，形成较为深刻的思考和实践能力，逐渐完善自身关于 IM 系统架构的知识网络。

课程设置

我们的课程分成三个模块，基本思路是：先从整体了解、再细化到每个垂直领域去了解它们有什么不同，进而关注到一些实现上的关键技术点、然后再回归到整体面。

基础篇

本模块我们会开始学习一个即时消息系统的基础结构，以及如何为你的 App 加入即时消息的模块。并且，我们会从即时消息系统所适用的业务场景需求出发，学习 IM 有别于其他业务系统的特性功能，比如实时性、安全性，以及这些功能的具体实现。

场景篇

在场景篇里，我会挑选即时消息技术的几个具体应用场景，这些场景相对个性化，而且在某些特性的技术实现上有一定挑战，我会针对这些场景比较核心的重点和难点来进行拆分讲解。比如，消息的多终端漫游功能的实现重点，以及直播互动场景中峰值流量的应对等等。

进阶篇

进阶篇在基础篇的即时消息的基础能力之上，介绍了相对更高级一些的功能，比如和苹果的推送服务对接。另外也更多关注于即时消息场景里在海量消息、高并发、服务高可用、服务保障等方面的优化实践，这部分内容具备较强的通用性，适用于大部分后端服务架构，从事后端服务开发设计的同学应该都会有所收获。

我希望能通过这个专栏，把这些年积累到的一些一线的实战经验进行梳理和沉淀，让感兴趣的小伙伴从中真正了解到，在超大用户规模的场景下，我们的即时消息系统经常会碰到的一些问题和容易出现瓶颈的环节，以及最终如何通过技术的升级和架构上的优化，来一一化解。

另外我希望你在掌握即时消息技术的同时，还能从这些实际上适用于大部分互联网后端业务的技术点和架构思想中，能体会到技术的互通性，通过思考和沉淀，形成自己的一套后端架构知识体系，并能实际运用到自己的业务或者系统中去。

最后，给你留一个思考题吧，除了前面我提到的聊天、直播互动、物联网等这些场景，你生活中接触到的还有哪些场景，也比较适合用到即时消息技术呢？

你可以给我留言，我们一起讨论。

01 | 架构与特性：一个完整的IM系统是怎样的？

你好，我是袁武林。在接下来的一段时间里，我将和你一起探索 IM 的相关知识。今天是第一节课，我们就先从 IM 的相关概念开始着手。

说起 IM，我估计你会先愣一下，“IM 是 QQ 或者微信这样的即时聊天系统吗？它是不是很庞大，也很复杂？”

今天我们以一个简单的 App 聊天系统为例，来看下一个简单的聊天系统都有哪些构成要素，以此来了解一个完整的 IM 系统是什么样的。

从一个简单的聊天系统说起

我们可以从使用者和开发者两个角度来看一下。

1. 使用者眼中的聊天系统

如果我们站在一个使用者的角度从直观体验上来看，一个简单的聊天系统大概由以下元素组成：用户账号、账号关系、联系人列表、消息、聊天会话。我在这里画了一个简单的示意图：

这个应该不难理解，我来解释一下。

聊天的参与需要用户，所以需要有一个用户账号，用来给用户提供唯一标识，以及头像、昵称等可供设置的选项。

账号和账号之间通过某些方式（比如加好友、互粉等）构成账号间的关系链。

你的好友列表或者聊天对象的列表，我们称为联系人的列表，其中你可以选择一个联系人进行聊天互动等操作。

在聊天互动这个环节产生了消息。

同时你和对方之间的聊天消息记录就组成了一个聊天会话，在会话里能看到你们之间所有的互动消息。

2. 开发者眼中的聊天系统

从一个 IM 系统开发者的角度看，聊天系统大概由这几大部分组成：客户端、接入服务、业务处理服务、存储服务和外部接口服务。

下面，我大概讲一讲每一个部分主要的职责。

首先是客户端。客户端一般是用户用于收发消息的终端设备，内置的客户端程序和服务端进行网络通信，用来承载用户的互动请求和消息接收功能。我们可以把客户端想象为邮局业务的前台，它负责把你的信收走，放到传输管道中。

其次是接入服务。接入服务可以认为是服务端的门户，为客户端提供消息收发的出入口。发送的消息先由客户端通过网络给到接入服务，然后再由接入服务递交到业务层进行处理。

接入服务主要有四块功能：连接保持、协议解析、Session 维护和消息推送。

我们可以把接入服务想象成一个信件管道，联通了邮局的前台和信件分拨中心。但是实际上，接入服务的作用很大，不仅仅只有保持连接和消息传递功能。

当服务端有消息需要推送给客户端时，也是将经过业务层处理的消息先递交给接入层，再由接入层通过网络发送到客户端。

此外，在很多基于私有通信协议的 IM 系统实现中，接入服务还提供协议的编解码工作，编解码实际主要是为了节省网络流量，系统会针对传输的内容进行紧凑的编码（比如 Protobuf），为了让业务处理时不需要关心这些业务无关的编解码工作，一般由接入层来处理。

另外，还有 session 维护的工作很多时候也由接入服务来实现，session 的作用是标识“哪个用户在哪个 TCP 连接”，用于后续的消息推送能够知道，如何找到接收人对应的连接来发送。

另外，接入服务还负责最终消息的推送执行，也就是通过网络连接把最终的消息从服务器传输送达到用户的设备上。

之后是业务处理服务。业务处理服务是真正的消息业务逻辑处理层，比如消息的存储、未读数变更、更新最近联系人等，这些内容都是业务处理的范畴。

我们可以想象得到，业务处理服务是整个 IM 系统的中枢大脑，负责各种复杂业务逻辑的处理。

就好比你的信到达分拨中心后，分拨中心可能需要给接收人发条短信告知一下，或者分拨中心发现接收人告知过要拒绝接收这个发送者的任何信件，因此会在这里直接把信件退回给发信人。

接着是存储服务。这个比较好理解，账号信息、关系链，以及消息本身，都需要进行持久化存储。

另外一般还会有一些用户消息相关的设置，也会进行服务端存储，比如：用户可以设置不接收某些人的消息。我们可以把它理解成辖区内所有人的通信地址簿，以及储存信件的仓库。

最后是外部接口服务。由于手机操作系统的限制，以及资源优化的考虑，大部分 App 在进程关闭，或者长时间后台运行时，App 和 IM 服务端的连接会被手机操作系统断开。这样当有新的消息产生时，就没法通过 IM 服务再触达用户，因而会影响用户体验。

为了让用户在 App 未打开时，或者在后台运行时，也能接收到新消息，我们会将消息给到第三方外部接口服务，来通过手机操作系统自身的公共连接服务来进行操作系统级的“消息推送”，通过这种方式下发的消息一般会在手机的“通知栏”对用户进行提醒和展示。

这种最常用的第三方系统推送服务有苹果手机自带的 APNs（Apple Push Notification service）服务、安卓手机内置的谷歌公司的 GCM（Google 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 系统的设计实现上是具体如何落地去实现“实时性”“可靠性”“一致性”“安全性”的要求。

最后，留给你一个思考题。消息一定需要在服务端的存储服务里进行存储吗？

欢迎你给我留言，我们一起讨论。

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 没打开等）的情况，还可以通过第三方手机操作系统级别的辅助通道，把这条消息通过手机通知栏的方式投递下去。

这里简单解释一下，常见的第三方操作系统级别的辅助通道。比如苹果手机的 APNs（Apple 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. 能从索引表里获取到最近联系人所需要的信息，为什么还需要单独的联系人表呢？

你可以给我留言，我们一起讨论。感谢你的收听，我们下期再见。

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 长连接的方式是怎么实现“当有消息需要发送给某个用户时，能够准确找到这个用户对应的网络连接”？

你可以给我留言，我们一起讨论，感谢你的收听，我们下期再见。

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 机制

我们先解释一下 ACK，ACK 全称 Acknowledge，是确认的意思。在 TCP 协议中，默认提供了 ACK 机制，通过一个协议自带的标准的 ACK 数据包，来对通信方接收的数据进行确认，告知通信发送方已经确认成功接收了数据。

那么，业务层 ACK 机制也是类似，解决的是：IM 服务推送后如何确认消息是否成功送达接收方。具体实现如下图：

IM 服务器在推送消息时，携带一个标识 SID（安全标识符，类似 TCP 的 sequenceId），推送出消息后会将当前消息添加到“待 ACK 消息列表”，客户端 B 成功接收完消息后，会给 IM 服务器回一个业务层的 ACK 包，包中携带有本条接收消息的 SID，IM 服务器接收后，会从“待 ACK 消息列表”记录中删除此条消息，本次推送才算真正结束。

ACK 机制中的消息重传

如果消息推给用户 B 的过程中丢失了怎么办？比如：

B 网络实际已经不可达，但 IM 服务器还没有感知到；

用户 B 的设备还没从内核缓冲区取完数据就崩溃了；

消息在中间网络途中被某些中间设备丢掉了，TCP 层还一直重传不成功等。

以上的问题都会导致用户 B 接收不到消息。

解决这个问题的常用策略其实也是参考了 TCP 协议的重传机制。类似的，IM 服务器的“等待 ACK 队列”一般都会维护一个超时计时器，一定时间内如果没有收到用户 B 回的 ACK 包，会从“等待 ACK 队列”中重新取出那条消息进行重推。

消息重复推送的问题

刚才提到，对于推送的消息，如果在一定时间内没有收到 ACK 包，就会触发服务端的重传。收不到 ACK 的情况有两种，除了推送的消息真正丢失导致用户 B 不回 ACK 外，还可能是用户 B 回的 ACK 包本身丢了。

对于第二种情况，ACK 包丢失导致的服务端重传，可能会让接收方收到重复推送的消息。

针对这种情况，一般的解决方案是：服务端推送消息时携带一个 Sequence ID，Sequence ID 在本次连接会话中需要唯一，针对同一条重推的消息 Sequence ID 不变，接收方根据这个唯一的 Sequence ID 来进行业务层的去重，这样经过去重后，对于用户 B 来说，看到的还是接收到一条消息，不影响使用体验。

这样真的就不会丢消息了吗？

细心的你可能发现，通过“ACK+ 超时重传 + 去重”的组合机制，能解决大部分用户在线时消息推送丢失的问题，那是不是就能完全覆盖所有丢消息的场景呢？

设想一下，假设一台 IM 服务器在推送出消息后，由于硬件原因宕机了，这种情况下，如果这条消息真的丢了，由于负责的 IM 服务器宕机了无法触发重传，导致接收方 B 收不到这条消息。

这就存在一个问题，当用户 B 再次重连上线后，可能并不知道之前有一条消息丢失的情况。对于这种重传失效的情况该如何处理？

补救措施：消息完整性检查

针对服务器宕机可能导致的重传失效的问题我们来分析一下，这里的问题在于：服务器机器宕机，重传这条路走不通了。

那如果在用户 B 在重新上线时，让服务端有能力进行完整性检查，发现用户 B“有消息丢失”的情况，就可以重新同步或者修复丢失的数据。

比较常见的消息完整性检查的实现机制有“时间戳比对”，具体的实现如下图：

下面我们来看一下“时间戳机制是如何对消息进行完整性检查的，我用这个例子来解释一下这个过程。

IM 服务器给接收方 B 推送 msg1，顺便带上一个最新的时间戳 timestamp1，接收方 B 收到 msg1 后，更新本地最新消息的时间戳为 timestamp1。

IM 服务器推送第二条消息 msg2，带上一个当前最新的时间戳 timestamp2，msg2 在推送过程中由于某种原因接收方 B 和 IM 服务器连接断开，导致 msg2 没有成功送达到接收方 B。

用户 B 重新连上线，携带本地最新的时间戳 timestamp1，IM 服务器将用户 B 暂存的消息中时间戳大于 timestamp1 的所有消息返回给用户 B，其中就包括之前没有成功的 msg2。

用户 B 收到 msg2 后，更新本地最新消息的时间戳为 timestamp2。

通过上面的时间戳机制，用户 B 可以成功地让丢失的 msg2 进行补偿发送。

需要说明的是，由于时间戳可能存在多机器时钟不同步的问题，所以可能存在一定的偏差，导致数据获取上不够精确。所以在实际的实现上，也可以使用全局的自增序列作为版本号来代替。

小结

保证消息的可靠投递是 IM 系统设计中至关重要的一个环节，“不丢消息”“消息不重复”对用户体验的影响较大，我们可以通过以下手段来确保消息下推的可靠性。

大部分场景和实际实现中，通过业务层的 ACK 确认和重传机制，能解决大部分推送过程中消息丢失的情况。

通过客户端的去重机制，屏蔽掉重传过程中可能导致消息重复的问题，从而不影响用户体验。

针对重传消息不可达的特殊场景，我们还可以通过“兜底”的完整性检查机制来及时发现消息丢失的情况并进行补推修复，消息完整性检查可以通过时间戳比对，或者全局自增序列等方式来实现。

最后，给你留一个思考题，有了 TCP 协议本身的 ACK 机制为什么还需要业务层的 ACK 机制？

你可以给我留言，我们一起讨论，感谢你的收听，我们下期再见。

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 的原子自增命令 incr，DB 自带的自增 ID，或者类似 Twitter 的 snowflake 算法、“时间相关”的分布式序号生成服务等。

以上，我们了解到 IM 服务端可以用全局序号生成器，来做为时序基准。

如何确保“时序基准”的可用性

使用“全局序号生成器”发出的序号，来作为消息排序的“时序基准”，能解决每一条消息没有标准“生产日期”的问题，但如果是面向高并发和需要保证高可用的场景，还需要考虑这个“全局序号生成器”的可用性问题。

首先，类似 Redis 的原子自增和 DB 的自增 ID，都要求在主库上来执行“取号”操作，而主库基本都是单点部署，在可用性上的保障会相对较差，另外，针对高并发的取号操作这个单点的主库可能容易出现性能瓶颈。

而采用类似 snowflake 算法的时间相关的分布式“序号生成器”也存在一些问题。

一个是发出的号携带的时间精度有限，一般只能到秒级或者毫秒级，比如微博的 ID 生成器就是精确到秒级的。另外由于这种服务大多都是集群化部署，携带的时间采用的服务器时间，也存在时钟不一致的问题（虽然时钟同步上比控制大量的 IM 服务器也相对容易一些）。

由上可知，基于“全局序号生成器”仍然存在不少问题，那这样是不是说基于“全局序号生成器”生成的序号来对消息进行排序的做法不可行呢？

我们从后端业务实现的角度，来具体分析一下。

从业务层面考虑，对于群聊和多点登陆这种场景，没有必要保证全局的跨多个群的绝对时序性，只需要保证某一个群的消息有序即可。

这样的话，如果可以针对每一个群有独立一个“ID 生成器”，能通过哈希规则把压力分散到多个主库实例上，大量降低多群共用一个“ID 生成器”的并发压力。

对于大部分即时消息业务来说，产品层面可以接受消息时序上存在一定的细微误差。比如同一秒收到同一个群的多条消息，业务上是可以接受这一秒的多条消息，未严格按照“接收时的顺序”来排序的。实际上，这种细微误差对于用户来说，基本也是无感知的。

那么，对于依赖“分布式的时间相关的 ID 生成器”生成的序号来进行排序，如果时间精度业务上可以接受，也是没问题的。

从之前微信对外的分享，我们可以了解到：微信的聊天和朋友圈的消息时序也是通过一个“递增”的版本号服务来进行实现的。不过这个版本号是每个用户独立空间的，保证递增，不保证连续。

微博的消息箱则是依赖“分布式的时间相关的 ID 生成器”来对私信、群聊等业务进行排序，目前的精度能保证秒间有序。

如何解决“时序基准”之外的其他误差

有了“时序基准”，是不是就能确保消息能按照“既定顺序”到达接收方呢？答案是并不一定能做到。原因在于下面两点。

IM 服务器都是集群化部署，每台服务器的机器性能存在差异，因此处理效率有差别，并不能保证先到的消息一定可以先推送到接收方，比如有的服务器处理得慢，或者刚好碰到一次 GC，导致它接收的更早消息，反而比其他处理更快的机器更晚推送出去。

IM 服务端接收到发送方的消息后，之后相应的处理一般都是多线程进行处理的，比如“取序号”“暂存消息”“查询接收方连接信息”等，由于多线程处理流程，并不能保证先取到序号的消息能先到达接收方，这样的话对于多个接收方看到的消息顺序可能是不一致的。

所以，我们一般还需要端上能支持对消息的“本地整流”。我们来看一下，如何去做本地整流。

消息服务端包内整流

虽然大部分情况下，聊天、直播互动等即时消息业务能接受“小误差的消息乱序”，但某些特定场景下，可能需要 IM 服务能保证绝对的时序。

比如发送方的某一个行为同时触发了多条消息，而且这多条消息在业务层面需要严格按照触发的时序来投递。

一个例子：用户 A 给用户 B 发送最后一条分手消息同时勾上了“取关对方”的选项，这个时候可能会同时产生“发消息”和“取关”两条消息，如果服务端处理时，把“取关”这条信令消息先做了处理，就可能导致那条“发出的消息”由于“取关”了，发送失败的情况。

对于这种情况，我们一般可以调整实现方式，在发送方对多个请求进行业务层合并，多条消息合并成一条；也可以让发送方通过单发送线程和单 TCP 连接能保证两条消息有序到达。

但即使 IM 服务端接收时有序，由于多线程处理的原因，真正处理或者下推时还是可能出现时序错乱的问题，解决这种“需要保证多条消息绝对有序性”可以通过 IM 服务端包内整流来实现。

整个过程是这样的：

首先生产者为每个消息包生成一个 packageID，为包内的每条消息加个有序自增的 seqID；

其次消费者根据每条消息的 packageID 和 seqID 进行整流，最终执行模块只有在一定超时时间内完整有序地收到所有消息才执行最终操作，否则根据业务需要触发重试或者直接放弃操作。

服务端包内整流大概就是这个样子，我们要做的是在最终服务器取到 TCP 连接后下推的时候，根据包的 ID，对一定时间内的消息做一个整流和排序。

消息接收端整流

携带不同序号的消息到达接收端后，可能会出现“先产生的消息后到”“后产生的消息先到”等问题。消息接收端的整流就是解决这样的一个问题的，

消息客户端本地整流的方式可以根据具体业务的特点来实现，目前业界比较常见的实现方式也很简单，步骤如下：

下推消息时，连同消息和序号一起推送给接收方；

接收方收到消息后进行判定，如果当前消息序号大于前一条消息的序号，就将当前消息追加在会话里；

否则继续往前查找倒数第二条、第三条等，一直查找到恰好小于当前推送消息的那条消息，然后插入在其后展示。

小结

今天我们讲到了在多发送方、多接收方、服务端多线程并发处理的情况下，保持消息时序一致的重要性以及处理方法。

对于聊天、直播互动等即时消息的场景，保持消息的时序一致性能避免发送方的意见表述和接收方的语义理解出现偏差的情况。

对于如何保持消息的时序一致性的关键点在于：需要找到一个时序基准来标识每一条消息的顺序。

这个时序基准可以通过全局的序号生成器来确定。常见的实现方式包括支持单调自增序号的资源生成，或者分布式时间相关的 ID 生成服务生成。两种方式各有一些限制，不过，你都可以根据业务自身的特征来进行选择。

有了通过时序基准确定的消息序号，由于 IM 服务器差异和多线程处理的方式，不能保证服务端的消息一定能按顺序推到接收方。我们可以通过“服务端包内整流”机制来保证需要“严格有序”批量消息的正确执行；或者，接收方根据消息序号来进行消息本地整流，从而确保多接收方的最终一致性。

最后给你留一道思考题。在即时消息收发场景中，用于保证消息接收时序的序号生成器为什么可以不是全局递增的？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

06 | HttpDNS和TLS：你的消息聊天真的安全吗？

你好，我是袁武林。

在开始之前，我们先回顾一下前面几篇的内容。我们陆续讲到了消息实时性、消息投递的可靠性、消息时序一致性在即时系统业务场景中的重要性和难点，以及相应的实现方案。

如果说消息的“实时性”“投递可靠性”“时序一致性”是评价一个即时消息服务可用性和先进性的重要指标；那么另一个重要的特性：安全性，就是一个 IM 服务能否存在的底线和立命之本。

对于依托即时消息技术之上的各种私密聊天 App、轨迹位置服务、远程工控系统等业务，对于安全性的需要远高于一般业务。

毕竟，没有人能接受私密的聊天内容被第三方窥探，实时位置的暴露甚至可能带来人身方面的安全风险，而涉及了重要的远程工控操作，如果操作被截获或者篡改，可能会导致严重的工程事故。

那么今天，我们就来聊一聊 IM 系统中会有哪些常见的安全问题，针对这些不同安全问题，我们分别采用了哪些技术方案来进行应对。限于篇幅，对于每个技术点的具体实现过程，我们不做深入讨论，你只需要了解到具体方案的适用场景就可以了。

消息安全性的三个维度

既然对于即时消息服务来说，安全性是如此的重要和不可妥协，那么到底都有哪些环节可能导致消息安全方面的问题呢？

一般来说，从消息的产生和流转的细分上，我们大概从三个维度来描述消息的安全性：消息传输安全性、消息存储安全性、消息内容安全性。

如何保证消息传输安全性

我们先来关注一下消息在传输过程中的安全性。

传输安全性这个比较好理解，存在网络交互的即时消息服务，大多需要通过开放网络来进行消息和信令的传输。

可能导致出现安全风险的地方相对也比较多，比如，DNS 劫持会导致发往 IM 服务的请求被拦截发到其他服务器，导致内容泄露或失效；或者明文传输的消息内容被中间设备劫取后篡改内容，再发往 IM 服务器引起业务错误等问题。

在消息传输过程中，我们主要关注两个问题：“访问入口安全”和“传输链路安全”，这也是两个基于互联网的即时消息场景下的重要防范点。

保证访问入口安全：HttpDNS

对于即时消息服务，一般都会提供一个公网的“接入服务”，作为用户消息收发的出入口，并通过域名的方式提供给客户端。对于这个出入口的访问，经常也会由于各种原因导致“访问不了”“地址错误”的问题。

关于访问入口，我们比较常见的问题就是 DNS 劫持。针对接入域名的 DNS 劫持问题的常见原因有下面两类。

第一类是路由器的 DNS 设置被非法侵入篡改了。这种问题常见于一些家用宽带路由器，由于安全性设置不够（比如使用默认密码），导致路由器被黑客或者木马修改了，DNS 设置为恶意的 DNS 地址，这些有问题的 DNS 服务器会在你访问某些网站时返回仿冒内容，或者植入弹窗广告等。

第二类是运营商 LocalDNS 可能会导致接入域名的解析被劫持。

比如下面三种比较典型的情况。

LocalDNS 是部分运营商为了降低跨网流量，缓存部分域名的指向内容，把域名强行指向自己的内容缓存服务器的 IP 地址。

运营商可能会修改 DNS 的 TTL(Time-To-Live，DNS 缓存时间)，导致 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 能识别客户端模拟器仿冒用户真实访问的问题吗？如果不能有什么其他更好的办法？

你可以给我留言，我们一起解答，感谢你的收听，我们下期再见。

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 的原子化嵌入脚本的方式，是否真的能够做到“万无一失”的变更一致性？比如，执行过程中机器掉电会出现问题吗？

你可以给我留言，我们一起讨论，感谢你的收听，我们下次再见。

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 的手机能和外网互通，这个网络地址的转换过程叫做 NAT（Network 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 和应用层心跳来一起使用吗？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

09 | 分布式一致性：让你的消息支持多终端漫游？

你好，我是袁武林。今天我们开始进入场景篇的部分，在这个部分中，我会介绍在几种典型的垂直业务场景下，IM 系统具体是如何实现的。

在即时消息的场景里，消息的多终端漫游是一个相对比较高级的功能，所谓的“多终端漫游”是指：用户在任意一个设备登录后，都能获取到历史的聊天记录。

这个功能对于有多个手机的用户来说是一个非常有用的功能，试想一下用户在交叉使用多个手机进行聊天后，如果不能在多个终端间自动同步所有的聊天记录，使用体验也不会太好。

但并不是所有的即时消息 App 都支持这个特性，比如微信虽然支持多端登录，但不知道出于什么考虑并不能在多端同步历史消息，这可能也是微信为数不多被诟病的一个小问题吧。

而 Telegram 和 QQ 却很好地支持了“多终端漫游”，使得用户在任意端登录都能获取到所有最近收发的消息。

如何实现多终端消息漫游

那接下来我们看一下，怎么才能让收发的消息能在多个终端漫游。要支持消息多终端漫游一般来说需要两个前置条件：一种是通过设备维度的在线状态来实现，一种是通过离线消息存储来实现。

设备维度的在线状态

对于在多个终端同时登录并在线的用户，可以让 IM 服务端在收到消息后推给接收方的多台设备，也推给发送方的其他登录设备。

这样的话，就要求能够按照用户的设备维度来记录在线状态，这个其实也是支持多端登录的一个前提。

离线消息存储

另外，如果消息发送时，接收方或者发送方只有一台设备在线，可能一段时间后，才通过其他设备登录来查看历史聊天记录，这种离线消息的多终端漫游就需要消息在服务端进行存储了。当用户的离线设备上线时，就能够从服务端的存储中获取到离线期间收发的消息。

下面，我简单把“多端在线时消息收发的过程”和“离线设备上线时同步消息的过程”画了一下。你可以自行参考，这里就不作具体说明了。

离线消息同步的几个关键点

对于多终端同时在线的情况，实现上相对比较简单，只需要维护一套设备维度的在线状态就能同时推送多台设备。

而离线设备上线后拉取历史消息的过程就要复杂一些，比如，离线消息的存储和普通消息相比差别在哪？应该怎么存？每次上线怎么知道应该拉取哪些离线消息？因此，这里我来主要说一下离线消息同步的问题。

离线消息该怎么存？

在课程的第 2 讲中，我介绍过比较常见的消息索引的存储方式，你可以回想一下：一条消息在服务端存储一般会分为消息内容表和消息索引表，其中消息索引表是按照收发双方的会话维度来设计的，这样便于收发双方各自查看两人间的聊天内容。

那么问题来了：离线消息的存储是否可以直接使用这个消息索引表？

首先，对于离线消息的存储，不仅仅需要存储消息，还需要存储一些操作的信令，比如：用户 A 在设备 1 删除了和用户 B 的某条消息，这个信令虽然不是一条消息，也需要在离线消息存储中存起来，这样当用户 A 的另一台设备 2 上线时，能通过离线消息存储获取这个删除消息的信令，从而在设备 2 上也能从本地删除那条消息。

对于这些操作信令，没有消息 ID 的概念和内容相关的信息，而且是一个一次性的动作，没必要持久化，也不适合复用消息索引表；另外，消息索引表是收发双方的会话维度，而获取离线消息的时候是接收方或者发送方的单个用户维度来获取数据的，没必要按会话来存，只需要按 UID 来存储即可。

此外，还有一个需要考虑的点，离线消息的存储成本是比较高的，而我们并不知道用户到底有几个设备，因此离线消息的存储一般都会有时效和条数的限制，比如保留 1 周时间，最多存储 1000 条，这样如果用户一台设备很久不登录然后某一天再上线，只能从离线消息存储中同步最近一周的历史聊天记录。

多端消息同步机制

离线消息的同步还有一个重要的问题是，由于并不知道用户到底会有多少个终端来离线获取消息，我们在一个终端同步完离线消息后，并不会从离线存储中删除这些消息，而是继续保留以免后续还有该用户的其他设备上线拉取，离线消息的存储也是在不超过大小限制和时效限制的前提下，采用 FIFO（先进先出）的淘汰机制。

这样的话用户在使用某一个终端登录上线时，需要知道应该获取哪些离线消息，否则将所有离线都打包推下去，就会造成两种问题：一个是浪费流量资源；另外可能会导致因为有很多消息在终端中已经存在了，全部下推反而会导致消息重复出现和信令被重复执行的问题。因此，需要一个机制来保证离线消息可以做到按需拉取。

一种常见的方案是采用版本号来实现多终端和服务端的数据同步。下面简单说一下版本号的概念。

每个用户拥有一套自己的版本号序列空间。

每个版本号在该用户的序列空间都具备唯一性，一般是 64 位。

当有消息或者信令需要推送给该用户时，会为每条消息或者信令生成一个版本号，并连同消息或者信令存入离线存储中，同时更新服务端维护的该用户的最新版本号。

客户端接收到消息或者信令后，需要更新本地的最新版本号为收到的最后一条消息或者信令的版本号。

当离线的用户上线时，会提交本地最新版本号到服务端，服务端比对服务端维护的该用户的最新版本号和客户端提交上来的版本号，如不一致，服务端根据客户端的版本号从离线存储获取“比客户端版本号新”的消息和信令，并推送给当前上线的客户端。

为了便于理解，我简单把这个离线同步消息的过程画了一下。

离线消息存储超过限额了怎么办？

在用户上线获取离线消息时，会先进行客户端和服务端的版本号比较，如果版本号不一致才会从离线消息存储中，根据客户端上传的最新版本号来获取“增量消息”。

如果离线消息存储容量超过限制，部分增量消息被淘汰掉了，会导致根据客户端最新版本号获取增量消息失败。

这种情况的处理方式可以是：直接下推所有离线消息或者从消息的联系人列表和索引表中获取最近联系人的部分最新的消息，后续让客户端在浏览时再根据“时间相关”的消息 ID 来按页获取剩余消息，对于重复的消息让客户端根据消息 ID 去重。

因为消息索引表里只存储消息，并不存储操作信令，这种处理方式可能会导致部分操作信令丢失，但不会出现丢消息的情况。因此，对于资源充足且对一致性要求高的业务场景，可以尽量提升离线消息存储的容量来提升离线存储的命中率。

离线存储写入失败了会怎么样？

在处理消息发送的过程中，IM 服务端可能会出现在获取到版本号以后写入离线消息存储时失败的情况，在这种情况下，如果版本号本身只是自增的话，会导致取离线消息时无法感知到有消息在写离线存储时失败的情况。

因为如果这一条消息写离线缓存失败，而下一条消息又成功了，这时拿着客户端版本号来取离线消息时发现，客户端版本号在里面，还是可以正常获取离线消息的，这样就会漏推之前写失败的那一条。

那么，怎么避免这种离线存储写失败无感知的问题呢？

一个可行的方案是可以在存储离线消息时不仅存储当前版本号，还存储上一条消息或信令的版本号，获取消息时不仅要求客户端最新版本号在离线消息存储中存在，同时还要求离线存储的消息通过每条消息携带的上一版本号和当前版本号能够整体串联上，否则如果离线存储写入失败，所有消息的这两个版本号是没法串联上的。

这样，当用户上线拉取离线消息时，IM 服务端发现该用户的离线消息版本号不连续的情况后，就可以用和离线消息存储超限一样的处理方式，从消息的联系人列表和索引表来获取最近联系人的部分最新的消息。

消息打包下推和压缩

对于较长时间不上线的用户，上线后需要拉取的离线消息比较多，如果一条一条下推会导致整个过程很长，客户端看到的就是一条一条消息蹦出来，体验会很差。

因此，一般针对离线消息的下推会采用整体打包的方式来把多条消息合并成一个大包推下去，同时针对合并的大包还可以进一步进行压缩，通过降低包的大小不仅能减少网络传输时间，还能节省用户的流量消耗。

发送方设备的同步问题

另外还有一个容易忽视的问题，版本号机制中，我们在下推消息时会携带每条消息的版本号，然后更新为客户端的最新版本号。而问题是发送方用于发出消息的设备本身已经不需要再进行当前消息的推送，没法通过消息下推来更新这台设备的最新版本号，这样的话这台设备如果下线后再上线，上报的版本号仍然是旧的，会导致 IM 服务端误判而重复下推已经存在的消息。

针对这个问题，一个比较常见的解决办法是：给消息的发送方设备仍然下推一条只携带版本号的单独的消息，发送方设备接收到该消息只需要更新本地的最新版本号就能做到和服务端的版本号同步了。

小结

好了，最后我来总结一下今天的内容。

消息多终端漫游是很多即时消息场景中需要支持的一个特性，要支持消息多终端漫游，有两种实现方式：一种是要求在线状态需要支持用户设备维度进行维护；另一种是要求消息和信令在服务端进行用户维度的离线存储。

除此之外，还需要一个“多终端和服务端状态同步的机制”来保证数据的最终一致性，业界比较常见的方案是采用版本号机制来根据客户端版本和服务端版本的差异，在用户上线时来获取“增量消息和信令”。离线消息存储未命中时，可以通过持久化的最近联系人列表和索引表来进行有损的补救。针对离线消息的下推还可以通过“多条消息打包和压缩”的方式来优化上线体验。

最后给大家一个思考题：如果用户的离线消息比较多，有没有办法来减少用户上线时离线消息的数据传输量？

以上就是今天课程的内容，你可以给我留言，我们一起讨论。感谢你的收听，我们下期再见。

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 服务机器缩容相比有什么区别？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

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-V（Key-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 机制、未读原子化变更等。如果你对今天的内容有什么思考与解读，欢迎给我留言，我们一起讨论。感谢你的收听，我们下期再见。

12 | 服务高可用：保证核心链路稳定性的流控和熔断机制

你好，我是袁武林。

在第 10 讲“自动智能扩缩容：直播互动场景中峰值流量的应对”中，我针对直播互动场景中面对“突发流量”和“高并发峰值”的业务形态的几个关键技术点进行了分析，并介绍了具体的应对方式。

但是，仅从设计优化、服务拆分、自动扩容等方面进行优化，有时候并不能完全解决问题。比如，有时流量增长过快，扩容流程还来不及完成，服务器可能就已经抗不住了。

不仅如此，在即时消息系统的很多实现中，业务上对于外部接口或者资源的依赖比较多。比如：发消息可能需要依赖“垃圾内容识别”的 API 来进行消息内容的过滤；下推图片消息时，推送服务需要依赖图片服务获取缩略图来进行推流；有的时候，当业务中依赖的这些接口、资源不可用或变慢时，会导致业务整体失败或者被拖慢而造成超时，影响服务的整体可用性。

对于上面这些问题，在实际线上业务中其实并不少见，甚至可以说是常态化。既然突发流量我们没法预测，业务上也不可能不依赖任何外部服务和资源，那么有什么办法能尽量避免，或者降低出现这些问题时对核心业务的影响呢？

流量控制

针对超高流量带来的请求压力，业界比较常用的一种方式就是“流控”。

“流控”这个词你应该不陌生，当我们坐飞机航班延误或者被取消时，航空公司给出的原因经常就是“因为目的机场流量控制”。对于机场来说，当承载的航班量超过极限负荷时，就会限制后续出港和到港的航班来进行排队等候，从而保护整个机场的正常运转。

同样，在即时消息系统中，突发超高流量时，为了避免服务器整体被流量打死，我们可以通过流控来扔掉或者通过排队的方式来保护系统在能力范围内的运转。比如，我在第 10 讲“自动智能扩缩容：直播互动场景中峰值流量的应对”中讲到，当有突发的高热度直播活动时，为了保护推送服务的整体可用性，我们可以通过流控扔掉一些非核心的消息，比如一些普通的进出场消息或点赞消息。

流控的常用算法

目前，业界常用的流控算法有两种：漏桶算法和令牌桶算法。

漏桶算法

“漏桶算法”的主要目的是控制数据注入到网络的速率，平滑网络上的突发流量。“漏桶算法”在实现上文如其名：它模拟的是一个漏水的桶，所有外部的水都先放进这个水桶，而这个桶以匀速往外均匀漏水，如果水桶满了，外部的水就不能再往桶里倒了。

这里你可以把这些外部的水想象成原始的请求，桶里漏出的水就是被算法平滑过后的请求。从这里也可以看出来，漏桶算法可以比较好地控制流量的访问速度。

令牌桶算法

令牌桶算法是流控中另一种常用算法，控制的是一个时间窗口内通过的数据量。令牌桶算法大概实现是这样的：

每 1/r 秒往桶里放入一个令牌，r 是用户配置的平均发送速率（也就是每秒会有 r 个令牌放入）。

桶里最多可以放入 b 个令牌，如果桶满了，新放入的令牌会被丢弃。

如果来了 n 个请求，会从桶里消耗掉 n 个令牌。

如果桶里可用令牌数小于 n，那么这 n 个请求会被丢弃掉或者等待新的令牌放入。

请求通过令牌桶算法实现限流的大概过程，我在这里画了一张图。

算法按一定速度均匀往桶里放入令牌，原始请求进入后，根据请求量从令牌桶里取出需要的令牌数，如果令牌数不够，会直接抛弃掉超限的请求或者进行等待，能成功获取到令牌的请求才会进入到后端服务器。

与漏桶算法“精确控制速率”不太一样的是，由于令牌桶的桶本身具备一定的容量，可以允许一次把桶里的令牌全都取出，因此，令牌桶算法在限制请求的平均速率的同时，还允许一定程度的突发流量。

比如，明星粉丝群的场景里，平时用户发消息的请求是比较小的，一般都比设置的阈值小很多，这样令牌桶平时都是处于“满”的状态；如果群隶属的明星突然空降出现在群里，群里的粉丝瞬间活跃起来，会快速、突发地消耗掉令牌桶里缓存的 b 个令牌，对于后端业务层就会出现一定的峰值流量。

通过令牌桶算法来限流的应用实例是比较多的，Google 开源的 Guava 就通过令牌桶算法实现了一个单机版的 RateLimiter，Google 在工程实现上做了一些优化，比如 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 一起关联部署，而且共同依赖多个依赖服务 A、B、C。如果此时 API 1 依赖的服务 B 由于资源或者网络等原因造成接口变慢，就会导致和 API 1 一起关联部署的 API 2 也出现整体性能被拖累变慢的情况，继而导致依赖 API 1 和 API 2 的其他上层的服务也级联地性能变差，最终可能导致系统整体性能的雪崩。

虽然服务间的调用能够通过超时控制来降低被影响的程度，但在很多情况下，单纯依赖超时控制很难避免依赖服务性能恶化的问题。这种情况下，需要能快速“熔断”对这些性能出现问题的依赖调用。

一种常见的方式是手动通过开关来进行依赖的降级，微博的很多场景和业务都有用到开关来实现业务或者资源依赖的降级。

另一种更智能的方式是自动熔断机制。自动熔断机制主要是通过持续收集被依赖服务或者资源的访问数据和性能指标，当性能出现一定程度的恶化或者失败量达到某个阈值时，会自动触发熔断，让当前依赖快速失败（Fail-fast），并降级到其他备用依赖，或者暂存到其他地方便于后续重试恢复。在熔断过程中，再通过不停探测被依赖服务或者资源是否恢复，来判断是否自动关闭熔断，恢复业务。

自动熔断这一机制目前业界已经有很多比较成熟的框架可以直接使用，比如，Netflix 公司出品的 Hystrix，以及目前社区很火热的 Resilience4j 等。

小结

今天，我主要从服务高可用的角度出发，带你了解了如何在面对突发流量时，通过流控来保障系统整体运行在压力阈值范围内，从而让业务大部分仍然处于“柔性可用”的状态，而不至于被流量打死。

接下来我介绍了一下流控实现的两大算法：漏桶算法和令牌桶算法。

漏桶算法的核心能力在于平滑实际流量的速度，让被流控的服务始终按固定速度来处理流量。

令牌桶算法并不精确限制流量给到被流控服务的速度，主要是限制请求的平均速度，而且允许被流控服务出现一定突发流量请求。

此外，针对分布式业务需要控制全局流量的场景，我们一般还可以通过中央式的资源来实现，比如 Redis+Lua。

在全局流控实现中，我们可以通过滑动窗口和细粒度分桶来解决流量不均衡和边界处理不平滑的问题；对于超大流量可能导致中央资源出现瓶颈的情况，可以通过“本地批量预取”来降低中央资源的压力。

另外，对于突发流量和微服务间依赖复杂导致的雪崩问题，就需要通过“熔断机制”来进行快速失败，我们可以通过手动降级和自动熔断来互相配合，以保障系统的整体稳定性和可用性。

“限流”“熔断机制”和“缓存”一起被列为高并发应用工程实现中的三板斧，可见其对高并发业务稳定性的重要程度。在包括即时通讯的很多业务场景中，超高并发的突发流量也是随处可见。所以，掌握好流量和熔断这两种利器，也是对我们后端架构能力的一种很好的提升。

最后给你留一个思考题：自动熔断机制中，如何来确认 Fail-fast 时的熔断阈值（比如：当单位时间内访问耗时超过 1s 的比例达到 50% 时，对该依赖进行熔断）？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

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 和客户端预埋的方式，提供多个可选的通道接入点，让某些接入点在连不上时还能尝试其他接入点。

在解决“通道连接慢”的问题上，我们可以通过支持多运营商机房接入点，来避免用户的跨运营商网络访问；此外，对于提供的多接入点，客户端还可以通过“跑马竞速”的方式优先使用连接速度更快的接入点来访问。

在解决“通道不稳定”的问题上，我们主要从服务端的架构设计着手，让我们的通道层服务和变化频繁的业务进行解耦，避免业务频繁变动导致通道层服务不稳定；对于消息下行通道压力大的业务场景，还可以隔离消息上下行通道，避免消息的上行被压力大的下行通道所影响；另外，将多媒体的上传下载通道和消息收发的核心通道进行隔离，避免传输量大的多媒体消息造成通道的阻塞，影响消息收发。

面对复杂的移动网络场景，由于不可控因素实在太多，稍不注意我们就容易踩到这样或者那样的坑。比如，我以前的业务里，曾经就出现过由于对外暴露的接入端口不是常见端口，导致很多用户连接不上的情况。但是，通过逐步的摸索和踩坑，也积累了针对移动网络复杂环境下的诸多经验，希望这些经验能够帮助你以后尽量避免出现同样的问题。

最后，给你留一道思考题：上下行通道隔离能够隔离保护我们的消息接收和消息发送，那么通道隔离会不会带来一些负面影响呢？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

14 | 分片上传：如何让你的图片、音视频消息发送得更快？

你好，我是袁武林。

在前面几节课中，我基本上都是从通用文本消息的角度出发，较为深入地分析讲解了即时消息相关的一些重要特性及核心概念。

随着网络环境的大幅改善及网络资费的显著降低，在很多即时消息场景下，人们的互动不再局限于传统的文本消息，越来越多的用户通过图片、语音、视频等丰富的多媒体消息来完成互动。相较于文本消息而言，多媒体消息在易用性和情感表达上更有优势。

但是，多媒体消息相对也会大很多。比如，一条文本消息只有不到 100 字节，但一条视频消息可能超过 100MB。因此，多媒体消息在网络传输、实时触达等方面相对更难一些。

在 IM 场景中，针对图片、语音、视频的优化一直都是一个需要长期投入和突破的重点。今天，我们就来看一看针对多媒体消息的优化手段都有哪些。由于篇幅原因，我会分成两篇，分别从发送和播放两个角度来谈一谈。

让图片和视频发送得又快又稳

要想让图片、视频、语音等多媒体消息发送得又快又稳，我们可以从“多上传接入点”“优化上传链路”“分片先行下推”“分片上传”等几种优化方式上着手。下面我分别来详细讲解一下。

多上传接入点

先来看一下，我们针对多媒体消息上传的第一种优化手段。国内目前的固网宽带运营商构成复杂，且用户宽带向来呈现出“南电信北联通”的分布现状。而在移动网络下，移动、电信、联通三足鼎立，再加上还有教育网和海外两大网络体系，整体网络结构更加复杂，跨运营商网络访问的高延迟和不稳定性一直是个无法解决的老大难问题。

对于传输数据更多的视频、图片等多媒体消息来说，如果出现用户跨运营商来上传文件，多次 RTT（Round-Trip-Time，往返时延）带来的用户体验会更差。因此，要提升多媒体消息的上传性能和成功率，我们第一步就要从接入点着手。

传统的优化方式是：针对不同的主流运营商提供不同的上传接入点 IP，然后通过运营商 DNS 解析，让用户能通过本运营商的上传接入点来快速上传图片和视频；相应后端的图片上传存储服务，也可以部署在多线机房，这样上传服务也能快速地把文件流提交给存储层，从而避免从接入点到存储服务的跨网开销，也能解决其他运营商的用户下载图片时需要跨网的问题。

对于拥有多机房的公司，也可以只把上传存储服务部署在单线机房，然后再通过专线解决多个单线机房之间的访问。比如，目前微博消息箱图片的上传，就是采用这种网络访问架构。大概 IDC 网络架构如下图：

不过，虽然多个运营商接入点 IP 能解决跨运营商网络访问的问题，但是多个接入点 IP 在管理和使用上会比较复杂。这个也是你在以后的实际应用中需要注意的地方。

有条件的公司，还可以利用多线 BGP（Border Gateway Protocol，边界网关协议）机房托管，对外只提供一个 IP，来让访问用户自动选择访问的最佳路由。

BGP 的技术原理，简单说就是：允许同一 IP 在不同运营商网络中广播不同的路由信息。目前众多云厂商都提供了多线 BGP 机房托管，对于自建机房能力薄弱但对访问性能要求较高的公司来说，也可以尝试接入使用。具体 BGP 的实现细节我在这里就不展开了，你可以作为课后阅读来进行补充。

上传链路优化

在上传普通文本消息时，为了保证发送通道的稳定性，一般会把接入服务和业务逻辑处理服务进行拆分部署，再加上最前端还有负载均衡层，这样，实际的上传链路至少已经有 3 层。另外，多媒体消息在上传时，在业务逻辑层后面一般还有一个用于文件存储的服务。因此，如果多媒体消息也通过文本消息的通道上传，整体链路会比较长。

对于图片、视频这种数据传输量大的消息来说，在这么长的链路上来回多次进行数据拷贝，整体性能会比较差，同时也会导致不必要的带宽浪费，而且可能会造成整个文本消息通道阻塞，影响普通消息的收发。

因此，出于性能和隔离的考虑，我们一般会把这些多媒体消息上传通道和普通消息收发通道独立开。发送多媒体消息时，先通过独立通道上传文件流，上传完成后会返回文件的唯一标识 ID，然后再把这个唯一标识 ID 作为消息的引用，通过普通消息收发通道进行发送。具体上传过程你可以参考下图。

语音的“分片先行下推”

由于语音消息一般会有录制时长的限制，和图片、视频等多媒体消息相比，上传的文件较小，也相对比较可控。

从接收方的角度看，图片和视频在接收查看的时候，都会有缩略图或者视频首帧图用于预览，不需要一开始就下载原文件，而语音消息没有预览功能，只能下载原文件用于播放。如果语音也和图片、视频等多媒体消息走一样的上传下载逻辑，播放时就需要去下载原文件，这样会容易出现下载慢、卡顿的情况。

因此，如果有两人正在通过语音实时聊天，我们更希望通过长连下推的方式将语音流推到对端，这样用户在播放语音时就不需要再从远程临时下载文件，使用流畅度也会更好。

可见，在一些即时消息场景的实现中，会通过普通消息收发的长连通道来分片上传语音流，这样更方便通过长连来下推给接收方。

另外，IM 服务端在接收到分片后，可以同步先行把分片的二进制流下推给接收方但暂不显示，不需要等所有分片都在后端存储完成再进行下推。这样的好处是：当语音的最后一片到达后端并存储完成后，IM 服务端只需要给接收方推一条“所有分片存储完成”的轻量信令，即可让接收方马上看到这条语音消息。这个“分片先行下推”机制在实时性上比远程临时下载的方式更好，能有效降低语音聊天的延时。

分片上传

要提升多媒体消息的上传性能和成功率，另一个比较大的优化手段是“分片上传”机制。

所谓分片上传，是指“在客户端把要上传的文件按照一定规则，分成多个数据块并标记序号，然后再分别上传，服务端接收到后，按照序号重新将多个数据块组装成文件”。

对于图片、视频、语音等这种较大的消息来说，采用分片上传可以让客户端在分片完成后，利用“并行”的方式来同时上传多个分片，从而提升上传效率。

在一些网络环境较差的场景下，采用“分片”的方式，可以在上传失败后进行重试时，不必重新上传整个文件，而只需要重传某一个失败的分片，这样也能提升重新发送的成功率和性能；此外，类似语音这种流式消息，在上传时并不知道文件最终大小，采用分片上传可以让消息文件先部分上传到服务器，而没必要等到录制完才开始上传，这样也能节约上传的整体时长。

分多大？

在分片上传中，“分片大小”是一个重要但又比较有挑战的问题。

分片太大，片数少，上传的并发度不够，可能会降低上传效率，每个大的分片在失败后重传的成本会比较高。

分片太小，片数多，并发需要的 TCP 连接太多，多条 TCP 连接的“窗口慢启动”会降低整体吞吐，两端拆分与合并分片的开销也相应增加，而且传输时的额外流量（HTTP 报头）也会更多。

所以，不同网络环境下如何选择一个“合适”的分片大小至关重要。

一般来说，在网络状况较好的环境，比如在 WiFi、4G 下，相应的分片大小应该设置得更大一些；而在 2G、3G 弱网情况下，分片可以设置小一点。

对于分片大小的设置，简单一点的处理可以按照网络状态来粗略划分。比如，WiFi 下 2M，4G 下 1M，3G/2G 下 256K。

当然，这个数值不是绝对的，很多情况下 3G 的网络性能可能比 WiFi 和 4G 下还好。因此，很多大厂会尝试通过算法来“自适应动态根据网络现状调整分片大小”，比如腾讯内部的“鱼翅”项目，就是通过算法来动态调整分片大小，达到优化传输速度和成功率的效果。有兴趣的话，你可以课后了解一下，也欢迎你在留言区与我一起探讨。

断点续传

在上传视频、图片等较大文件时，整体耗时会比较长，用户由于某些原因可能需要在上传未完成时临时暂停，或者遇上系统意外崩溃导致上传中断的情况。对此，如果要再次上传同一个文件，我们希望不必再重新上传整个文件，而是从暂停的位置“断点续传”，而分片上传机制，就能相对简单地实现“断点续传”功能。

给每一次上传行为分配一个唯一的操作标识，每个分片在上传时除了携带自己的序号外，还需要带上这个操作标识，服务端针对接收到的同一个操作标识的分片进行“暂存”，即使由于某个原因暂停上传了，这些“暂存”的分片也不会马上清理掉，而是保留一定的时间。

注意：
这里只保留一定的时间主要是出于成本考虑，很多上传暂停或者失败的情况，用户并不会继续上传，如果一直保留可能会浪费较多资源。

续传时继续以之前同一个操作标识来上传，客户端先检查服务端已有分片的情况，如果没有过期就继续从上次的位置续传，否则需要重新从头开始上传。“断点续传”功能实现上比较简单，但在上传大文件时，对于提升用户体验是比较明显的。

秒传机制

在群聊和小组等半公开的 IM 场景中，经常会有用户收到传播度较广的同一图片、视频等多媒体消息的情况。这些重复的图片和视频，如果每次都按照分片上传、落地存储的方式来处理，会造成服务端存储的巨大浪费。微博消息业务之前做过一个统计：一周内，业务中所有上传的图片和视频的重复率能达到 30% 以上。

此外，对于在服务端中已经存在的视频或者图片，如果能在上传前识别出来，就能避免用户再次上传浪费流量，也能方便用户直接快速地完成上传操作。因此，通过“秒传”机制，可以有效实现优化用户上传的体验，同时减少用户的流量消耗。

秒传机制的实现其实比较简单，如下图所示：

客户端针对要上传的文件计算出一个特征值（特征值一般是一段较短的字符串，不同文件的特征值也不一样），真正上传前先将这个特征值提交到服务端，服务端检索本地已有的所有文件的特征值，如果发现有相同特征值的记录，就认定本次上传的文件已存在，后续就可以返回给客户端已存在文件的相关信息，客户端本次上传完成。

这里需要注意的是，特征值的计算一般采用“单向 Hash 算法”来完成，如 MD5 算法、SHA-1 算法。但这些算法都存在“碰撞”问题，也就是会有极低概率出现“不同文件的特征值一样的情况”。

要解决这个问题的一个很简单的办法是：使用多种单向 Hash 算法，在都一致的情况下才判断为重复。

小结

这一讲，我从 IM 中图片、语音、视频等多媒体消息的发送场景的优化出发，分析了目前业界比较常用的一些优化手段。比如下面几种。

通过“多上传节点”策略，优化上传的网络访问，避免用户跨运营商网络带来的高延迟和不稳定。

“优化上传链路”，隔离多媒体消息文件上下行通道和普通消息的收发通道，尽量缩短用户到文件存储服务的距离，针对语音消息还可以通过“先行下发”来降低语音聊天的延迟。

采用分片上传机制，并分出合适的分片大小，能较大改善上传成功率和速度，对于上传失败后的重试行为也更为友好。

断点续传功能和秒传功能解决了“已上传的文件流和片段不再需要重新上传”的问题，不仅节约了资源成本，也大幅提升了用户体验。

图片、视频、音频等多媒体消息的上传优化一直是业界研究的重点方向，除了从架构和设计的角度来优化之外，还有很多基于媒体文件编解码方面的优化。因此，这也是一个需要很多综合知识的环节，需要你去不断学习和实践。在课程之外，你也可以投入更多时间来补充这方面的相关知识。

最后给你留一个思考题：多媒体消息上传的优化，除了本次课程中提到的这些手段，你还了解哪些多媒体消息上传方面的优化方式呢？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

15 | CDN加速：如何让你的图片、视频、语音消息浏览播放不卡？

你好，我是袁武林。

上一讲，我从即时消息场景中多媒体消息的上传环节出发，介绍了业界常用的几种提升用户上传体验的优化手段。

那么这节课，我会从播放的角度出发，带你了解在浏览和播放图片、视频、语音等多媒体消息时，如何避免灰图和卡顿的问题，以及在节省流量方面，业界都有哪些比较常见的优化策略。

CDN 加速

提升用户浏览图片和播放视频体验的一个有效办法就是：让用户离资源更近。

比如说，北京的用户可以从北京的机房下载图片，而广东的用户可以从广东的节点机房来下载图片，这样让用户和资源实现物理位置上的相邻，以此降低远程访问的耗时，提升下载性能。

业界常用的一种手段，就是通过 CDN（Content 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 文件进行 AES（Advanced Encryption Standard）等对称加密，从而保证第三方用户即使获取到 TS 的媒体文件，也播放不了。

M3U8 的索引文件中，支持“针对每一个 TS 文件可设置相应的获取密钥的地址”，这个地址可以作为业务层的鉴权接口，获取密钥时通过自动携带的 Cookie 等信息进行权限判定。只有鉴权通过，才会返回正确的密钥，而且整个解密过程都是播放器默认自动支持的，也不需要人为地对播放器进行改造。

通过 HLS 实现视频加解密的大概流程如下图：

用户通过上传服务，把视频上传到服务端；服务端进行视频的 HLS 切片并针对切完的 TS 文件流进行加密，同时把密钥存储到密钥服务中。当有用户请求该视频时，CDN 节点从源站回源加密的视频文件，播放器先通过下载的 M3U8 索引文件获取到“密钥地址”，然后将客户端缓存的认证 Token 拼接到该“密钥地址”后面，再通过该地址请求鉴权服务。

鉴权服务先检查携带的认证 Token 是否有权限访问该视频文件，如果权限没问题，会从密钥存储服务中将该视频的密钥文件返回给播放器，这时播放器就能自动解密播放了。

不过，虽然 HLS 原生支持加解密操作，但是对于图片等其他多媒体消息来说，没有办法使用这种方式。而且在有的即时消息系统中，只支持 MP4 格式的视频文件。所以，针对非 HLS 视频，我们还可以通过播放器的改造，来支持自定义的加密方式。

比如，通过 RC4（Rivest 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.264（10 倍左右），因此服务端转码的耗时和机器成本也相应会高很多。很多公司也并不会全部都采用 H.265 编码，而是只选取部分热点视频来进行 H.265 编码，通过这种方式，在降低转码开销的同时来尽量提升 H.265 视频的覆盖度。

预加载

即时消息场景中，短视频播放的一个重要的用户体验指标是：一秒内成功开播率，也就是我们常说的“秒开”。但每个视频从点击，再到下载完元数据信息和部分可播放片段的过程中，网络 IO 耗时是比较高的，在不经过优化的情况下经常就需要秒级以上。

对此，一个比较通用的优化策略是：对视频流进行“部分提前加载”。

比如 WiFi 场景下，在用户打开聊天会话页时，自动触发当前页中的小视频进行预加载，为了平衡流量和播放体验，一般只需要预加载部分片段，后续如果用户继续观看，就可以通过边下边播的方式再去请求后面的视频流。

预加载可以按时间或者大小来限制。比如，我们可以设定预加载 3s 的视频流，或者设定预加载 512KB 的视频流。

推流

针对图片和音视频的浏览、播放的体验优化，我们还可以借助即时消息自身的“长连接”优势，通过长连接将部分带宽占用较小的资源推给接收方，这样用户在浏览或播放多媒体消息时，就不会因为需要临时从服务端获取而出现卡顿了。

比如，之前提到语音消息会通过长连接将音频流推送给接收方。同样，对于图片的缩略图和视频的封面图也可以通过长连接实时将资源推送下去，从而减少了加载耗时，提升了用户体验。

但这里，我建议用于消息收发的通道尽量只传输小的音频或者缩略图，避免影响通道造成堵塞。如果你的业务场景中需要直接推送视频流或者原图的，可以通过长连通知客户端重新发起一个新的临时连接，来进行流的传输。

小结

这节课，我主要从提升用户图片浏览及音视频播放体验的角度出发，介绍了一些在即时消息场景中，业界比较通用的优化策略。其中，有很多是业界通用的优化方案，还有一些是与即时消息联系比较紧密的优化点。从这里你也可以看出，即时消息并不是一个独立存在的领域，而是多个领域的技术的大融合。

最后，我们再一起回顾下上面提到的针对多媒体消息的下行都有哪些技能树：

通过 CDN 加速，让“用户离资源更近”；

通过“流加密”来解决 CDN 上多媒体消息的私密性问题；

为图片提供多种中低分辨率的缩略图来提升图片预览性能；

使用 WebP 和渐进式 JPEG 来对图片进行压缩，以降低体积，提升加载性能；

针对热门的小视频采用 H.265 转码，在保证画质的同时，降低带宽成本并加快视频加载；

通过视频的自动“预加载”功能，达到视频播放“秒开”的效果；

借助长连接通道，对体积较小的音频和缩略图进行实时推送，提升用户浏览和播放体验。

由于图片和音视频技术的发展十分迅猛，各种新的优化技术层出不穷，而且针对多媒体消息上传和下载的优化，很多还涉及到深层次的音视频编解码和图片压缩算法的实现，大部分大厂针对这一块也有专门的团队来进行研究。如果你对即时消息场景中多媒体消息的上传和下载的优化有其他思路和想法，也欢迎在留言区给我留言。

最后给你留一道思考题：针对 CDN 上的文件访问鉴权，你还了解其他可行的方案吗？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

16 | APNs：聊一聊第三方系统级消息通道的事

你好，我是袁武林。

前面几节课里，我讲到在即时消息场景下，我们会依赖服务端推送技术来提升消息到达的实时性，以及通过各种手段来保证消息收发通道的可用性，从而让消息能尽量实时、稳定地给到接收人。

但在实际情况中，出于各种原因，App 与服务端的长连接会经常断开。比如，用户彻底关闭了 App，或者 App 切换到后台一段时间后，手机操作系统为了节省资源，会杀掉进程或者禁止进程的网络功能。在这些情况下，消息接收方就没有办法通过 App 和 IM 服务端的长连接来进行消息接收了。

那有没有办法，能让消息在 App 被关闭或者网络功能被限制的情况下，也能发送到接收人的设备呢？答案是：有。

现在手机常用的 iOS 和 Android 系统，都提供了标准的系统级下发通道，这个通道是系统提供商维护的与设备的公共长连接，可以保证在 App 关闭的情况下，也能通过手机的通知栏来下发消息给对应的接收人设备。而且，当用户点击这些通知时，还能重新唤醒我们的 App，不仅能提升消息的到达率，还增加了用户的活跃度。

第三方系统下发通道

常见的第三方系统下发通道，有 iOS 端的 APNs，以及 Android 端的 GCM 和厂商系统通道。

iOS 端的 APNs（Apple Push Notification service，苹果推送通知服务)，是独立于应用之外，依托系统常驻进程来维护和苹果服务器的公共长连接，负责全局的系统推送服务。

在 Android 端上，有 Google 的 GCM（Google Cloud Message，Google 云消息传递）。但 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。

什么是 DeviceToken？DeviceToken 是 APNs 用于区分识别不同 iOS 设备同一个 App 的唯一标识，APNs 的网关和设备通信时，通过系统默认自带的长连接进行连接，并通过 DeviceToken 作为当前连接设备的唯一标识进行系统消息的推送。

要通过 APNs 实现系统推送，我们的推送服务（Provider）需要先和 APNs 建立长连，建连时携带的证书包含“准备接收”系统消息推送的 App 的 Bundle Identifier（Bundle 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 之前是 256B，iOS 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 需要向你这台设备发送多条推送时，就会启动它的 QoS（Quality of Service，服务质量）机制，只保留给你最新的一条消息，在这种场景下，就会存在丢失离线消息的问题。

出现这种问题的主要原因应该也是出于存储成本方面的考虑，由于 APNs 的消息接收量整体基数太大，大量的存储和转发对服务器的资源消耗非常大，出于成本考虑，APNs 就会丢掉一部分离线消息。

角标累加问题

除此之外，APNs 还有一个小吐槽点是：对于角标的未读数，APNs 不支持累计 +1 操作，只支持覆盖原来的角标未读数。所以在每条消息下推时，APNs 还需要把这个用户的总未读消息数一起带下去，对后端未读数服务来说会增加额外的调用压力，而 Android 端的厂商通道就相对更友好一些，很多厂商的角标未读支持根据接收到的通知消息自动累加的特性。

Android 的厂商通道

对于 Android 端来说，应用的后台保活一直是提升消息在线推送到达率的重要手段，很多操作系统默认的配置是：切到后台一段时间后，会直接杀掉进程或者让进程断网。

虽然 App 间有互相拉起进程的取巧方式，以及系统厂商给某些超级 App（比如微信）的保活白名单机制，但对于大部分 App 来说，还是需要在 App 被杀死或者被限制的情况下，通过厂商的系统通道推送消息，以此提升整体的消息到达率。

对于开头我提到的 5 家厂商通道的接入，它们都提供了专门的 SDK（Software 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 在后台运行”功能，你觉得都有哪些应用场景呢？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论。感谢你的收听，我们下期再见。

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 缓存一般采用 LRU（Least Recently Used）方式进行淘汰，这样既能减少 L1 缓存的内存使用量，也能保证热点数据不会被淘汰掉。并且，采用 L1+ 主从的双层模式，即使有某一层节点出现宕机的情况，也不会导致请求都穿透到后端存储上，导致资源出现问题。

多级缓存架构 - 本地缓存 +L1+ 主从的多层模式

通过 L1 缓存 + 主从缓存的双层架构，我们用较少的资源解决了热点峰值的带宽问题和单点穿透问题。

但有的时候，面对一些极热的热点峰值，我们可能需要增加多组 L1 才能抗住带宽的需要。不过内存毕竟是比较昂贵的成本，所以有没有更好的平衡极热峰值和缓存成本的方法呢？

对于大部分请求量较大的应用来说，应用层机器的部署一般不会太少。如果我们的应用服务器本身也能够承担一部分数据缓存的工作，就能充分利用应用层机器的带宽和极少的内存，来低成本地解决带宽问题了。那么，这种方式是否可以实现呢？

答案是可以的，这种本地缓存 +L1 缓存 + 主从缓存的多级缓存模式，也是业界比较成熟的方案了。多级缓存模式的整体流程大概如下图：

本地缓存一般位于应用服务器的部署机器上，使用应用服务器本身的少量内存。它是应用层获取数据的第一道缓存，应用层获取数据时先访问本地缓存，如果未命中，再通过远程从 L1 缓存层获取，最终获取到的数据再回种到本地缓存中。

通过增加本地缓存，依托应用服务器的多部署节点，基本就能完全解决热点数据带宽的问题。而且，相比较从远程 L1 缓存获取数据，本地缓存离应用和用户设备更近，性能上也会更好一些。

但是使用本地缓存有一个需要考虑的问题，那就是数据的一致性问题。

还是以“长文章”为例。我们的服务端可能会随时接收到用户需要修改文章内容的请求，这个时候，对于本地缓存来说，由于应用服务器的部署机器随着扩缩容的改变，其数量不一定是固定的，所以修改后的数据如何同步到本地缓存中，就是一个比较复杂和麻烦的事情了。

要解决本地缓存一致性问题，业界比较折中的方式是：对本地缓存采用“短过期时间”的方式，来平衡本地缓存命中率和数据更新一致性的问题。比如说，针对“长文章”的本地缓存，我们可以采用 5 秒过期的策略，淘汰后再从中央缓存获取新的数据。这种方式对于大部分业务场景来说，在产品层面上也是都能接受的。

小结

好了，下面简单回顾一下今天课程的内容。

首先，我介绍了缓存在高并发应用中的重要性，以及在 IM 系统中使用的部分场景。然后再带你了解了缓存分布式的两种算法：取模求余和一致性哈希。

取模求余算法在实现上非常简单，但存在的问题是，取模求余算法在节点扩容和宕机后会出现震荡，缓存命中率会严重降低。

一致性哈希算法解决了节点增删时震荡的问题，并通过虚拟节点的引入，缓解了“数据倾斜”的情况。

最后，我着重介绍了业界通用的三种分布式缓存的常见架构。

一种是主从模式。简单的主从模式最常见，但是在面对峰值热点流量时，容易出现带宽问题，也存在缓存节点宕机后穿透到存储层的问题。

第二种是 L1+ 主从模式。通过增加 L1 缓存层，以并行的多组小容量的 L1 缓存，解决了单一热点的带宽问题，也避免了单一节点宕机后容易穿透到 DB 存储层的情况。

最后一种是本地缓存 +L1+ 主从的多层模式。作为低成本的解决方案，我们在 L1+ 主从模式的基础上，引入了本地缓存。本地缓存依托应用服务器的本机少量内存，既提升了资源的有效利用，也彻底解决了带宽的问题。同时在性能方面，也比远程缓存获取更加优秀。对于本地缓存的数据一致性问题，我们可以通过“短过期时间”来平衡缓存命中率和数据一致性。

面对高并发业务带来的流量压力，我们不可否认的是，缓存的使用是目前为止最有效的提升系统整体性能的手段。作为系统优化的一把利器，如何用好这个强大的工具，是你需要去不断思考和学习的。希望今天介绍的这几种缓存使用的姿势，能够让你有所收获，并能在自己的业务中去尝试实践。

最后给你留一道思考题：

L1+ 主从模式下，如果热点数据都被 L1 缓存层拦截命中，会导致主从缓存层相应的这个热点数据，由于长时间得不到读取而被 LRU 淘汰掉。这样，如果下线 L1 缓存，还是会有不少的请求直接穿透到 DB 存储层。那么有没有办法，能够让主从缓存在有 L1 缓存层的情况下，依旧能保持数据热度？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论，感谢你的收听，我们下期再见。

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 绑定到多台 LVS（Linux Virtual Server，Linux 虚拟服务器）上，能够实现单台 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 等容器化技术，来屏蔽部署机器的差异。通过应用镜像的自定义部署环境，来提升链路各层水平扩展时的部署效率。

服务和资源的水平扩展能力，决定了我们的系统架构随着业务发展是否具备“高可扩展性”，也决定了这个架构的生命周期。

因此，我们在设计一个系统的时候，可以先问一下自己：我设计的这套架构是否能够随着业务的增长，相应的“业务支撑能力”也能随着服务的“水平扩容”做到线性的提升？相信当你开始思考这些问题的时候，你离成为一位合格的架构师也越来越近了。

最后给大家留一道思考题：要想解决资源层的写入瓶颈，除了分片机制外，还有什么办法能解决资源写入瓶颈的问题呢（比如直播间观看人数的计数资源）？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论，感谢你的收听，我们下期再见。

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 方案和 ELK（ElasticSearch + Logstash + Kibana）方案，它们的使用度都非常高。在实现上，也基本和上面图中展现的监控数据收集与架构方式差不多，所以具体的细节实现，我在这里就不展开了，你可以自行了解一下。

全链路 Trace 监控

除了系统监控和应用服务监控外，在严重依赖网络可用性的即时消息场景里，很多时候，我们需要关心的不仅仅是服务端的性能，还要从用户自身的体验角度出发，来全局性地监控 IM 服务的可用性和性能。

另外，由于各个微服务都是独立部署并且互相隔离的，很多时候，我们在排查消息收发失败的原因时，很难查询到具体的异常是由哪一个依赖的服务或者资源引起的，问题定位和处理效率也就非常低。

怎样才能把某次消息收发的各环节的性能数据，以及整个访问链路的情况聚合起来，以便于我们来定位问题呢？

一个比较好的解决方案就是：基于 Trace 服务，对消息的收发行为进行“全链路监控数据收集”。

那么，接下来我们就来了解一下，这个 Trace 服务到底是什么？

Trace 一词的出现，起源于 Google 的一篇官方论文“Dapper，a Large-Scale Distributed Systems Tracing Infrastructure”。

在这篇论文中，介绍了 Google 的 Dapper 系统，并首次定义了什么是分布式跟踪系统，以及介绍了分布式跟踪系统的三大设计要点：低开销、对应用透明、高可扩展性。

为了实现分布式链路追踪，Dapper 论文提出了 Trace、Span、Annotation 的概念，并给出了一个 Trace 调用的示例，如下图：

A~E 分别表示五个服务，用户发起一次请求到 A，然后 A 分别发送 RPC 请求到 B 和 C，B 处理请求后返回，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，是否追踪还能正常运转？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论，感谢你的收听，我们下期再见。

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 的写入瓶颈），在微博的线上业务中，目前也被大范围使用。你也可以看一看，自己的业务中是否也有类似可优化的场景，可以尝试来参考这个方案进行优化。

最后给大家留一个思考题：点对点消息的在线下推，也适合采用“网关机自治维护本地在线状态”的方式吗？说说你的看法吧。

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论，感谢你的收听，我们下期再见。

21 | 期末实战：为你的简约版IM系统，加上功能

你好，我是袁武林。

在期中实战中，我们一起尝试实现了一个简易版的聊天系统，并且为这个聊天系统增加了一些基本功能。比如，用户登录、简单的文本消息收发、消息存储设计、未读数提示、消息自动更新等。

但是期中实战的目的，主要是让你对 IM 系统的基本功能构成有一个直观的了解，所以在功能的实现层面上比较简单。比如针对消息的实时性，期中采用的是基于 HTTP 短轮询的方式来实现。

因此，在期末实战中，我们主要的工作就是针对期中实战里的消息收发来进行功能优化。

比如，我们会采用 WebSocket 的长连接，来替代之前的 HTTP 短轮询方式，并且会加上一些课程中有讲到的相对高级的功能，如应用层心跳、ACK 机制等。

希望通过期末整体技术实现上的升级，你能更深刻地体会到 IM 系统升级前后，对使用方和服务端压力的差异性。相应的示例代码我放在了GitHub里，你可以作为参考来学习和实现。

功能介绍

关于这次期末实战，希望你能够完成的功能主要包括以下几个部分：

支持基于 WebSocket 的长连接。

消息收发均通过长连接进行通信。

支持消息推送的 ACK 机制和重推机制。

支持客户端的心跳机制和双端的 idle 超时断连。

支持客户端断线后的自动重连。

功能实现拆解

接下来，我们就针对以上这些需要升级的功能和新增的主要功能，来进行实现上的拆解。

WebSocket 长连接

首先，期末实战一个比较大的改变就是，将之前 HTTP 短轮询的实现，改造成真正的长连接。为了方便 Web 端的演示，这里我建议你可以使用 WebSocket 来实现。

对于 WebSocket，我们在客户端 JS（JavaScript）里主要是使用 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 在服务端的实现，如果你是使用 JVM（Java Virtual Machine，Java 虚拟机）系列语言的话，我推荐你使用比较成熟的 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 机制的实现中，如果尝试多次下推之后仍然没有成功，服务端后续应该进行哪些处理呢？

以上就是今天课程的内容，欢迎你给我留言，我们可以在留言区一起讨论，感谢你的收听，我们下期再见。

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 维度，再下发给网关机。

那么，我们为什么不能在网关机本身查询了群成员的相关信息后，再扇出消息呢？

这个操作在实现上当然也是可以的。不过咱们前面提到，为了解耦网关机和业务层逻辑，我们尽量不在网关机做业务维度的逻辑。

因此这里建议对于群聊消息来说，我们还是在业务层扇出后，再提交到网关机进行处理。

小结

正如我在开篇词里讲到，即时消息技术实际上是众多前后端技术的紧密结合，涉及到的知识面也是非常广泛的，而且随着业务形态的不断变化和升级，相应的架构设计和技术侧重点上也各有异同。

因此，形而上学的方式往往很难真正做到有的放矢。但我相信，只要我们从问题和业务形态的本质出发，经过不断的思考和实践，就能够在掌握现有即时消息知识的基础上，找到最适合自身业务的架构和方案。

好了，今天的答疑就到这里，如果你还有问题，可以继续给我留言，我们一起讨论。感谢你的收听，我们下期再见。

结束语 | 真正的高贵，不是优于别人，而是优于过去的自己

你好，我是袁武林。

不知不觉中，在大家的耐心陪伴下，即时消息专栏的更新马上就要结束了。

首先，要感谢各位同学对我的信任和宽容，给我这个机会，让我能够在这段短暂的时间里，带领大家一起来学习即时消息这一门古老但又充满活力的技术。

对我来说，这是一段非常特别和有趣的体验，我也第一次尝试通过专栏的方式，将自己了解的知识进行体系化的输出。

当然这并不是一件简单的事情，知识点整理、稿件打磨、录音、留言反馈等等，背后的艰辛可能也只有经历过的人才能体会到。但每次专栏更新上线，看到自己的付出有帮助到一些同学，并且得到肯定的时候，我瞬间感觉又充满了战斗的能量。

关于专栏的落地

实际上，在开始筹备这个专栏的时候，我个人是比较没有信心的。

原因倒不是说技术层面的问题，而是担心即时消息这个话题，因为受众相对没有那么广泛，在推出之后，如何让更多非 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 框架，就相对简单了很多，也能更客观和精确地对这些框架从底层原理层面来进行比对了。

当然，这种逐层深入的学习方法，在前期可能需要花费大量的精力和时间，因此需要你一定的耐心和坚持，但在对底层原理层知识有一定的积累之后，这种学习的方法也会越来越轻松。

学习方法论：碎片化知识和系统化学习

关于技术学习路径的另一个小小的建议是：从了解碎片化的知识开始，逐步扩大你在某一方面的技能树，然后再对这方面的整体知识进行系统化的学习和总结沉淀。

随着现在各种技术博客、技术类公众号、技术站点等等的普及和传播，大部分时候，我们只能通过工作之余的些许碎片化时间，来了解某些细碎的知识点。

当然，这些碎片化的知识点是一种不错的积累，但这些零散的知识点，在碎片化学习的时候，由于学习时间短、文章内容比较聚焦于一个小点，很容易让我们看完之后产生一种“我知道了”，然后就没有后续了，难以进行复杂的思考。

所以，我个人的建议是，对于某一技术点方面的碎片化的知识，在一段时间的学习后，还是需要进行系统化的“复盘”，一来可以补充遗漏的某些技术细节，二来能够形成对这个知识点的整理和总结思考，最终沉淀成自己的文档或者代码输出。

另外， 你也可以尽量优先地去选择学习与自己工作内容息息相关的技术点，这样在理解知识和后续的输出实践上，也会更有帮助。

写在最后

对于从事技术工作的我们来说，技术探索的道路总是显得如此漫长，不断迭代更新的技术时常会让我们眼花缭乱，各种技术知识产品的爆炸式推广，让我们获取知识的方式变得如此简单。

但另一方面，在快节奏的生活方式下，在繁重的工作、与家人朋友的相处时间之外，我们的时间总是显得远远不够用。

如何保持一颗对新鲜事物的好奇心，如何高效有序地管理时间，如何形成一套适合自己的学习方法，这个时候可能往往比技术本身更重要。

海明威说：“真正的高贵，不是优于别人，而是优于过去的自己。”

无论如何，可能我们在一开始的时候，学习的技巧并不是那么高明，但只要我们保持终身学习的姿态，持续地投入到真正喜欢的事情上，就能让今天的你比昨天进步一点点。

我相信，时间的复利最终能让你真正采撷到成功的芬芳玫瑰！