开发即时通讯系统时如何处理消息的重复存储问题

即时通讯系统开发中那个让人头疼的重复消息问题

即时通讯系统开发的朋友,估计都遇到过这么一种情况:用户发出去的消息,在某些网络环境下竟然重复显示了两条甚至三条一模一样的。这种情况一旦出现,用户体验直接崩塌——要么觉得系统有bug,要么怀疑自己是不是手滑多发了几次。说实话,这个问题看起来简单,真要彻底解决起来,里面的门道还挺多的。

我之前参与过一个社交类APP的开发,当时就踩过这个坑。明明用户只发了一条消息,后台数据库里却存了三条。当时团队花了整整两周才把这个重复存储的问题给彻底制服。后来我复盘了一下,发现这个问题其实可以拆解成好几个层面来看,每个层面都有不同的应对策略。今天我就把这里面的门道给大家捋清楚,争取用最直白的话把这个事情讲明白。

先搞明白:消息为什么会重复?

在动手解决问题之前,咱们得先搞清楚问题的根源。消息重复存储这种事,表面上看是"存了不该存的东西",但实际上背后的原因五花八门。我给大家列举几种最常见的情况,看看你遇到过没有。

第一种情况是最常见的网络重试机制。想象一下这个场景:用户发了一条消息,客户端把消息发给了服务器,服务器正准备把消息写入数据库,结果这时候网络突然抽风了,服务器没来得及告诉客户端"我收到了",客户端等了半天没响应,默认消息没发出去,于是又把消息发了一遍。这种情况下,服务器就会收到两条一模一样的消息。如果服务器的处理逻辑不够严谨,两条都存进去,重复消息就这么产生了。

第二种情况发生在消息传递的链路比较长的时候。举个实际的例子,一条消息从客户端发出去,要经过网关、消息队列、业务处理服务,最后才到数据库。这中间可能经过了三四个服务,每个服务之间都有可能因为超时或者网络抖动导致消息重发。这样一来,到最终存储的时候,消息可能已经被重复发送了好几次。

第三种情况听起来有点魔幻,但确实存在。有些用户或者某些第三方工具,可能会故意或者意外地快速重复点击发送按钮,在极短的时间内发出多条内容相同的消息。这种情况下,严格来说每条消息都是"用户主动发送的",但从业务角度来看,这种重复消息显然不应该都被存储。

还有一种情况跟数据库本身有关。比如数据库的主从同步延迟,或者分布式数据库在某些边界情况下可能出现的数据一致性问题。不过这种情况相对少见,通常出现在系统架构比较复杂的场景中。

解决问题的核心思路:分层处理

搞清楚了原因,接下来就要想办法解决。我个人的经验是,这个问题不能靠某一个环节的单点优化来解决,而是需要在整个消息处理的链路中层层设防,形成一个完整的防护体系。就好像你家里的大门不能只装一把锁,最好再装个防盗门,再加个监控,层层把关。

我把这个解决思路分成四个层次来讲:客户端层、网关层、业务层和存储层。每个层次解决的问题和采用的技术手段都不太一样,咱们一个一个来聊。

客户端层:第一道防线

很多人可能觉得,重复消息的问题应该都由服务器来搞定,客户端随便发就行。但其实如果在客户端这边就能做一些处理,可以大大减轻服务器的压力,也能给用户更好的体验——毕竟重复消息存在本地缓存里看着也闹心。

客户端这边最主要的策略是消息去重标识。具体怎么做呢?每条消息在发送之前,客户端会给它生成一个唯一的ID。这个ID不是简单的自增数字,而是综合了用户ID、时间戳、设备信息、随机因子等元素生成的,保证在极端情况下也不会出现重复。

这个唯一的MessageID会跟着消息体一起发到服务器。服务器收到消息后,首先做的事情就是检查这个ID是否已经存在。如果存在,说明是重复消息,直接丢弃或者返回成功响应但不真正存储;如果不存在,才继续后面的处理流程。

这里有个小技巧:客户端在收到服务器的成功响应之前,应该把这条消息标记为"发送中"状态,并且禁止用户再次发送完全相同的内容。如果用户就是要发两条一样的消息,那就生成不同的MessageID,让两条都发出去。简单说就是:系统自动发的重试消息要能识别,用户主动发的重复消息要放行。

网关层:流量入口的把关

网关是消息进入系统的第一道关口,在这里做去重处理效率很高,因为所有消息都要经过网关。而且网关通常是无状态的,可以水平扩展,处理能力有保障。

在网关层做去重,核心是用一个高性能的缓存来存储最近处理过的消息ID。这个缓存通常选用Redis这样的内存数据库,因为速度快、能扛并发。具体的策略可以这样设计:每收到一条消息,先用它的MessageID去缓存里查一下,如果命中了,说明这条消息之前已经处理过了,直接返回成功响应,不往后转发;如果没命中,把MessageID写入缓存,然后放行到下一层。

这里需要注意的是缓存的过期时间设置。如果设置得太长,缓存会占用太多内存;如果设置得太短,可能会漏掉一些真正的重复消息。我的经验是根据业务场景来定,对于即时通讯这种场景,一般设置为消息最大可能延迟时间的2到3倍比较合适。比如预估一条消息在整个链路中最多可能延迟30秒,那缓存就设置为60到90秒。

还有一个要考虑的问题是缓存的容量。如果系统流量很大,缓存里存的消息ID会很多,这时候要做好淘汰策略,定期清理过期数据,避免缓存被撑爆。

业务层:核心逻辑的防护

业务层是真正处理消息逻辑的地方,这里的去重工作要做得更细致一些。因为网关层只能做最基础的ID比对,而业务层可以根据更多的业务信息来判断消息是否应该被存储。

在业务层,我建议采用组合去重的策略。什么意思呢?就是不去单纯依赖MessageID,而是综合多个字段来判断重复。比如对于私聊消息,可以把发送者ID、接收者ID、消息内容哈希值这三个要素组合起来生成去重键。同一个发送者发给同一个接收者的完全相同的内容,在短时间内出现两次,那大概率就是重复消息。

这种组合去重的优势在于,它能够识别出一些跨会话的重复消息。比如某个用户给群里发了一条消息,结果因为网络问题重发了好几次,业务层可以通过(群ID + 消息内容哈希)这个组合键来识别并过滤重复消息。

业务层的去重判断通常需要在数据库里做,因为要把去重状态持久化下来。这时候要注意数据库的查询性能,不能每次去重都做全表扫描。比较好的做法是在消息表中给去重字段建立索引,让查询在毫秒级完成。

存储层:最后的安全网

即便前面三层都做了防护,还是有可能会有漏网之鱼到达存储层。所以存储层也需要设置最后一道防线,确保重复消息不会污染数据库。

存储层的去重主要依靠数据库的唯一索引约束。在消息表的设计中,把MessageID设为主键或者唯一索引,这样当有重复的MessageID试图写入时,数据库会直接抛出唯一性冲突异常。捕获到这个异常后,直接当成功处理就行——因为重复消息本来就不该被存储,返回成功响应是合理的。

这里有个细节需要特别注意:数据库的唯一索引要建在真正需要去重的字段上,而不是随便找个字段就建索引。有些同学为了省事,可能会用消息创建时间作为唯一索引的一部分,这就很危险——因为时间在某些情况下可能会重复,导致正常的消息反而写不进去。

声网的实践中是怎么做的

说到音视频和即时通讯这个领域,声网作为全球领先的实时互动云服务商,在消息处理架构上积累了大量的实践经验。他们服务着全球超过60%的泛娱乐APP,每天处理的消息量级非常惊人。在这么大规模的考验下,他们形成了一套行之有效的消息去重体系。

,声网的架构设计非常强调分层处理的思想。从客户端的SDK到服务端的核心服务,每一个环节都有相应的去重机制。这种设计的好处是,任何一层出了问题,都不会导致最终出现重复消息。就像我前面说的,层层把关,层层防护。

另外,声网在消息ID的设计上也有讲究。他们用的是一种基于分布式时钟的ID生成算法,保证在分布式环境下生成的每个ID都是全局唯一的。这种算法综合了时间戳、数据中心ID、机器ID和序列号等多个因素,即使在极高并发的情况下,也能保证不出现ID冲突。

在缓存的使用上,声网采用的是多级缓存架构。第一级是本地缓存,处理速度最快;第二级是分布式缓存,覆盖范围广;第三级是持久化存储,保证数据不丢失。这种多级架构能够在性能和可靠性之间取得很好的平衡。

不同场景下的策略调整

虽然大体的思路是相似的,但在不同的业务场景下,去重策略还是需要做一些调整。我给大家整理了几个常见场景的策略差异,方便大家在实际开发中参考。

场景类型 特点 去重策略调整
一对一私聊 消息量相对可控,实时性要求高 可以采用较短的去重窗口,重点关注发送者和接收者的组合
群聊消息 消息量大,同一消息会被多次投递 需要更高效的去重机制,建议在网关层做集中处理
消息撤回 需要对撤回操作本身也做去重 撤回消息也要生成唯一ID,防止撤回指令重复执行
消息回复 回复链可能很长 除了MessageID,还要考虑回复关系的唯一性约束

这里面有个点值得单独提一下,就是消息撤回场景下的去重。很多同学在开发撤回功能的时候,可能会忽略撤回指令本身也可能重复的问题。比如用户点击撤回按钮,因为网络不好没反应,用户又点了一次,这时候就会发出两条撤回指令。如果不做去重,第一条撤回指令执行后把消息删了,第二条撤回指令执行时就找不到那条消息了,可能还会报错。所以撤回这种操作,也要当作普通消息来对待,做好去重处理。

一些容易踩的坑

聊完了解决方案,我再给大家说几个在实现过程中容易踩的坑,这些都是我或者身边同事实实在在踩过的,希望能够帮大家避个雷。

第一个坑是去重判断的时机。有的人会在消息写入数据库之前做去重判断,这本身没问题,但问题在于判断和写入不是原子操作。在高并发情况下,很可能出现两个请求同时通过去重判断,然后都写入了数据库。解决这个问题的方法有两个:一是在数据库层面用唯一索引做最后保障,二是用分布式锁把去重判断和写入操作变成原子性的。

第二个坑是测试不够充分。重复消息的问题在正常测试环境下很难复现,因为正常情况下网络是稳定的,不会触发重试机制。建议大家专门搭建弱网环境来测试,或者用一些工具来模拟网络延迟和丢包,看看系统在极端情况下表现如何。

第三个坑是消息ID生成规则不合理。有些同学为了简单,直接用数据库自增ID作为消息ID。这在单机环境下没问题,但在分布式环境下,多个节点同时生成ID,就可能出现重复。更合理的做法是用UUID,或者类似雪花算法这样的分布式ID生成方案。

第四个坑是忽略了消息内容的变更。比如用户发了一条消息,然后修改了消息内容重新发送。这时候如果只用MessageID去重,就会把修改后的消息也给过滤掉。所以去重逻辑要区分清楚:如果是重试导致的内容完全相同的消息,应该去重;如果是用户主动修改后重新发送的消息,不应该去重。这两个场景的区分,可以通过判断消息内容是否发生变化来实现。

写在最后

回过头来看,消息重复存储这个问题,说大不大,说小不小。说不大,是因为它不影响系统的核心功能,只是影响用户体验;说不小,是因为一旦出现,会让用户对产品产生不信任感。

解决这个问题的关键,我总结下来就是三点:第一,要有清醒的认识,知道问题可能出在哪个环节;第二,要分层治理,每个环节都做好自己的本分;第三,要持续监控,发现问题及时处理。

技术这条路就是这样,看起来简单的问题,真要做好了不容易。但只要我们多思考、多实践,总能找到合适的解决方案。希望这篇文章能给正在做即时通讯开发的朋友们一些启发,如果能帮到大家,那这篇文章就没白写。

上一篇即时通讯 SDK 的付费版本是否支持无限并发连接
下一篇 实时通讯系统的服务器故障自动切换如何配置

为您推荐

联系我们

联系我们

在线咨询: QQ交谈

邮箱:

工作时间:周一至周五,9:00-17:30,节假日休息
关注微信
微信扫一扫关注我们

微信扫一扫关注我们

手机访问
手机扫一扫打开网站

手机扫一扫打开网站

返回顶部