gitbook/即时消息技术剖析与实战/docs/148308.md

159 lines
15 KiB
Markdown
Raw Permalink Normal View History

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