开发即时通讯系统时如何实现消息的定时发送

开发即时通讯系统时如何实现消息的定时发送

说实话,之前我接手一个社交类项目的时候,甲方提出了一个看似简单但挺有意思的需求——用户可以设定在未来某个时间点给好友发送生日祝福、节日提醒,甚至定时的工作消息。当时我觉得这事儿挺简单的,不就是加个定时任务吗?结果真正做起来才发现,这里面的门道比想象的要深得多。今天我就把这个过程中的思考和实践整理出来,跟大家聊聊即时通讯系统中定时发送消息到底该怎么实现。

为什么定时发送是个值得认真对待的功能

在展开技术方案之前,我想先聊聊为什么这个功能值得我们花时间去研究。表面上看,定时发送就是一个"延迟投递"的事情,但实际上它涉及到的场景远比我们第一反应想到的祝福消息要丰富得多。

就拿我接触到的一些项目来说吧,有些社交App需要支持"消息撤回"的时间窗口控制,用户发出消息后还有几分钟的思考时间可以撤回;还有一些客服系统需要在非工作时间把用户留言转接到值班人员;更常见的是运营活动场景,凌晨三点的限时福利总不能让运营人员爬起来手动发吧?这些看似不同的需求,底层其实都是对"定时消息"能力的呼唤。

另外从技术角度看,定时消息对整个系统的架构设计也是一种考验。它不像普通消息那样"即发即走",而是需要系统记住这笔"账",在未来某个时间点再"讨回来"。这种时序性的、延迟性的数据和普通的消息流处理方式有很大不同,如果设计不当,轻则消耗大量内存资源,重则导致消息丢失或者送达顺序混乱。所以这个功能做得好不好,往往能体现出一个即时通讯系统的技术功底。

定时发送的核心技术思路

说到具体实现,主流的方案大概有几种,每种都有各自的适用场景和优缺点。我先从最基础的思路开始讲,然后逐步深入到更复杂的方案。

轮询数据库:最朴素但也最直接

最早的时候,我试着用一个最简单的方式来实现定时发送——在数据库里建一张专门存定时消息的表,用户提交发送请求后,消息先不入队,而是存在这张表里,标记好计划的发送时间。然后起一个定时任务,每隔一分钟或者半分钟去扫描这张表,找出那些"时间已到"的消息进行投递。

这个方案的优点很明显,就是实现起来特别直观,逻辑清晰,后续排查问题也方便。而且因为消息存在数据库里,天然就有持久化保障,不用担心服务重启导致消息丢失。但缺点也同样突出——当定时消息的数量多起来之后,轮询的效率会成为瓶颈。假设表里有100万条待发送的消息,每次扫描都要全表查询,这谁受得了?

而且轮询间隔和时效性是一对矛盾。间隔设置得太长,消息就会延迟送达;间隔设置得太短,数据库的压力又会很大。所以这种方案比较适合消息量不大、对时效性要求也不是特别苛刻的场景。如果你的系统每天只有几百条定时消息,用这个方案完全没问题;但如果日均定时消息达到几十万甚至百万级,那就得考虑更高效的方案了。

时间轮算法:让定时任务"转"起来

后来我了解到时间轮(Time Wheel)这个数据结构,发现它真是为定时任务量身定做的。想象一下一个时钟,上面有时针、分针、秒针,但时间轮把这个概念抽象了一下——它是一个环形的数据结构,把时间划分成一个个"槽位",每个槽位代表一个时间间隔。任务被安排在对应的槽位上,随着时间轮转动,任务就会从槽位里掉出来执行。

举个具体的例子,如果我们要支持分钟级的定时消息,可以创建一个有60个槽位的时间轮,每个槽位代表一分钟。当前指针指向某个槽位时,所有注册在这个槽位上的任务都会被执行。新的定时任务来了,计算一下它应该落在哪个槽位,直接挂上去就行,整个过程是O(1)的时间复杂度,非常高效。

如果业务需要更精细的时间控制,比如支持秒级甚至毫秒级的时间轮,那就会用到多级时间轮的概念——秒级时间轮转一圈,推动分级时间轮走一格;分级的转一圈,推动小时级的走一格。这就像机械钟表的齿轮系统一样,一层带一层。这种分层的设计既保证了时间精度,又不会让单个时间轮的槽位数量爆炸。

时间轮的优势在于性能极高,任务添加和删除都是常数时间操作,内存占用也相对可控。但它也有一个天然的短板——因为是内存结构,一旦服务重启,时间轮里还没执行的任务就丢失了。所以在实际生产环境中,我们通常会把定时消息的元数据持久化到数据库或者可靠的存储系统,服务启动时再把数据加载回时间轮。这样既享受了时间轮的高效,又有了持久化的保障。

延迟队列:消息中间件的另一种玩法

除了时间轮,还有一类方案是基于消息队列的延迟队列功能。现在很多消息中间件都支持延迟消息,比如RabbitMQ的插件机制、RocketMQ的延迟级别、Kafka的定时分区等。这类方案的核心思想是:消息发送方把消息发给中间件,但是不立即投递,而是设置一个延迟时间,中间件会在指定时间后把消息投递给消费者。

这种方案的好处是解耦做得好。业务方只需要负责把定时消息扔进队列,不需要自己维护时间轮或者轮询逻辑,后续的消息消费、失败重试、监控告警这些都可以复用现有的消息中间件能力。尤其是当你的系统已经用了某款消息中间件的情况下,接入成本特别低。

不过延迟队列也有局限性。首先是功能灵活性受限,不同中间件对延迟时间的支持粒度不一样,有的只支持固定的几个延迟级别(比如1秒、5秒、30秒、1分钟等),不支持任意的延迟时间。其次是消息的可见性和可管理性问题,消息进入延迟队列后,通常没办法实时查询它当前的状态或者修改它的发送时间。最后,如果你的系统对消息的时序性要求很高,还得额外处理消息在延迟期间的顺序问题。

所以我觉得延迟队列比较适合作为整个定时消息体系的一个组成部分,用来处理一些简单场景的延迟投递需求,而核心的、复杂的定时发送逻辑可能还是需要自己来实现。

定时消息的系统架构设计

聊完几种核心的技术思路,我想再从系统架构的角度说说怎么把这些技术组合起来。一个完善的定时消息系统大概需要以下几个核心模块的协作。

消息的接收与存储

当用户发起一个定时发送请求时,系统首先需要做的是把这条消息持久化下来。这里说的持久化不仅仅是存个文本内容就完事了,而是要把消息的所有元数据都记录清楚——发送方、接收方、消息内容、计划发送时间、消息状态(待发送/已发送/已取消)、重试次数、优先级等等。

存储方案的选择上,我建议用关系型数据库和缓存配合的策略。关系型数据库(比如MySQL)存核心数据,保证数据可靠性;Redis之类的缓存用来做快速查询,比如查询某个用户有多少条待发送的定时消息。数据库的表结构大概是这样的:

字段名 类型 说明
message_id BIGINT 消息唯一标识
sender_id BIGINT 发送方用户ID
receiver_id BIGINT 接收方用户ID
content TEXT 消息内容
scheduled_time DATETIME 计划发送时间
status TINYINT 状态:0-待发送 1-已发送 2-已取消
created_at DATETIME 创建时间
sent_at DATETIME 实际发送时间

这里有个小细节值得注意,就是status字段的设置。很多人在设计的时候可能只考虑到"待发送"和"已发送"两种状态,但实际上"已取消"这个状态也很重要。比如用户设置了明早八点发送的生日祝福,结果六点醒来发现日期搞错了,这时候系统得支持用户把这条消息取消掉。如果没有这个状态,要么用户只能眼睁睁看着错误的消息发出去,要么就得用删除操作,但删除又会带来数据一致性的问题。所以预留一个"已取消"状态,把删除变成逻辑删除,是更稳妥的做法。

调度引擎的核心逻辑

调度引擎是整个定时消息系统的"心脏",它的任务就是按时把到期的消息送进发送队列。具体怎么实现调度引擎?我自己的做法是时间轮加持久化的组合。

首先,时间轮肯定是要用的,它可以保证高效的调度。但我们不能让时间轮成为系统的单点,所以在部署上通常会采用主备模式——多个调度节点同时运行,通过某种分布式协调机制(比如etcd、ZooKeeper)选出一个主节点来运行时间轮,其他节点处于待命状态。主节点定时向协调服务汇报心跳,一旦主节点挂了,备节点自动升级为主节点接管工作。

其次,每个节点在启动的时候,都要从数据库里加载所有"待发送"状态的消息到本地的时间轮。这个加载过程要注意效率问题,如果消息量很大,不能一次性全加载进来,那样启动时间会很长。我的做法是做一个增量加载——节点只加载"未来一小段时间内"要发送的消息,比如未来一小时的消息。更久远的消息由另一个定时任务慢慢加载,或者干脆按需加载——当时间轮转动到某个槽位时,再去数据库查这个时间段有没有消息。

调度引擎每转动一个时间格,就把对应格子里所有到期的消息取出来,批量写入到即时消息的发送队列里,同时把消息状态更新为"发送中"。这里要注意批量操作的效率,单独一条条处理和批量处理性能可能差上几十倍。

消息发送与状态回执

p>当定时消息被调度引擎从"待发送"状态取出来之后,它就变成了一条普通的待发送消息,后续的流程和即时消息没有什么区别——通过IM的长连接通道推送给接收方,如果推送失败就进入重试逻辑,推送成功就更新消息状态为"已发送"并记录实际发送时间。

这里有个细节需要处理好,就是消息的幂等性。因为定时消息从调度到发送中间经过了好几个环节,中间任何一个环节出问题都可能导致消息被重复处理。比如调度引擎刚把消息写入发送队列,这时候服务重启了,重启后这条消息又会被重新调度一次。为了防止同一条消息被发送两次,我们需要在消息的流转过程中加入幂等控制,比如用message_id做唯一键,或者在状态流转时加乐观锁。

特殊场景的处理策略

除了基础的定时发送功能,实际业务中还会遇到一些特殊情况需要处理。我把自己踩过的一些坑和解决方案分享出来。

跨时区的问题怎么破

这是一个很容易被忽视但又确实会遇到的问题。假设你做了一个面向全球用户的社交App,用户在美国设置了每天早上九点给国内的父母发消息问候,那系统该怎么处理?首先得明确"早上九点"是用户当地的早上九点,还是父母所在时区的早上九点?从产品体验来说,肯定应该以用户当地的时间为准。

解决这个问题的思路是:用户设置定时消息时,系统记录的不是纯粹的"几点几分",而是"哪个时区的几点几分"。在消息即将发送之前,调度引擎需要把计划发送时间转换成UTC时间,然后再判断当前UTC时间是否已经超过了这个UTC时间点。另外在存储层面,计划发送时间字段最好统一用UTC时间存储,避免时区混乱带来的各种bug。

发送失败的重试机制

定时消息和普通消息一样,也会遇到发送失败的情况——接收方不在线、对方账号已注销、网络抖动等等。对于定时消息的重试,我们需要考虑一个问题:重试要不要影响原定的发送时间?比如一条消息计划今天早上八点发送,结果对方一直不在线,一直重试到下午才成功,那这条消息的"实际发送时间"应该记录为早上八点还是下午?

从业务角度来说,定时消息的价值在于"在指定时间点让接收方看到消息",所以即使送达延迟了,消息内容里最好还是体现出原定的发送时间。比如生日祝福,如果因为网络问题延迟了两小时送达,用户看到"2月14日08:00发出的祝福"总比看到"2月14日10:00发出的祝福"要来得合理。所以在实现上,定时消息的"scheduled_time"应该作为消息内容的一部分固化下来,不要因为重试而改变。

大批量消息的性能优化

有时候业务场景会产生大量的定时消息,比如运营活动要求给100万用户同时发送活动通知。这种场景下,如果还是一条条处理,性能肯定跟不上。优化思路主要有两个层面:

  • 批量读取:调度引擎在扫描到期消息时,不要一条条从数据库读,而是按批次读取,比如一次读5000条,批量写入发送队列。
  • 批量推送:对于可以群发的消息,借助IM系统的广播或者批量推送能力,而不是建立100万个长连接分别发送。

另外还可以做一些预计算和预加载的工作。比如知道明天早上有一个运营活动要给100万用户发消息,就可以提前把相关的消息数据加载到缓存,甚至提前把消息推到离用户更近的接入节点,这样可以大大降低正式发送时的系统压力。

和声网能力的结合

说到这里,我想提一下声网在即时通讯领域的技术积累。声网作为全球领先的实时音视频云服务商,在即时通讯方面提供了相当完善的解决方案。声网的实时消息服务支持单聊、群聊、聊天室等多种场景,底层用的是自建的分布式消息系统,在高并发、低延迟方面做了大量优化。

如果你正在开发即时通讯系统,又不想从零实现复杂的定时发送逻辑,可以考虑集成声网的SDK或者API。声网的解决方案涵盖了消息的可靠送达、离线消息存储、消息漫游等功能,定时发送这种能力作为上层功能封装,技术上是完全可以实现的。而且声网的服务经过了大规模的实战检验,像泛娱乐、社交、直播这些对即时通讯要求很高的领域,都有大量成功案例,技术成熟度方面不用太担心。

从成本角度考虑,自建一套完整的IM系统包括定时发送功能,需要投入不少的开发资源和运维资源,而且要处理各种边界情况和异常场景。如果你的核心业务不在IM这一块,而是把IM作为辅助功能来使用,那直接用声网这类现成的解决方案显然是更经济的选择。你可以专注于自己的核心业务逻辑,把即时通讯这种"基础设施"交给更专业的团队来做。

写在最后

回顾整个定时消息功能的实现过程,我觉得最重要的几点经验是:第一,要根据实际的消息量级和时效性要求选择合适的技术方案,不要过度设计也不能将就;第二,持久化和可靠性是核心,无论用什么算法都要确保消息不会因为服务重启而丢失;第三,细节决定体验,跨时区、幂等性、状态流转这些看似不起眼的地方,往往是影响用户感受的关键。

技术选型这件事真的没有绝对的好坏之分,只有合适与否。轮询数据库简单粗暴,时间轮高效灵活,延迟队列开箱即用,关键是要对自己的业务场景有清醒的认识。如果你正在搭建一个面向全球用户的即时通讯系统,需要处理高并发、低延迟的消息传输,同时又要支持定时发送这类高级功能,那不妨多了解一下声网这类专业服务商的能力。毕竟术业有专攻,把专业的事情交给专业的团队来做,往往能少走很多弯路。

上一篇开发即时通讯APP时如何实现消息清理提醒设置
下一篇 即时通讯 SDK 的技术文档是否提供离线版本

为您推荐

联系我们

联系我们

在线咨询: QQ交谈

邮箱:

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

微信扫一扫关注我们

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

手机扫一扫打开网站

返回顶部