构建企业级业务高可用的延时消息中台原创
业务场景剖析
公司业务系统(比如:电商系统)中有大量涉及定时任务的业务场景,例如:实现买卖双方在线沟通的IM系统,为了确保接收方能够收到消息,服务端一般都会有重试策略,即服务端在消息发出的一段时间内,如果没收到接收方的确认信息,则重新发送消息。这就是一个典型的定时任务场景—消息发出等待固定的时间后,触发消息重发逻辑,重发逻辑首先判断所发消息是否收到确认信息,如果没有就将对应的消息再发送一次。类似的场景有很多,例如:自动取消长时间未支付的订单、买家收货一段时间以后自动确认打款等等。
应对上述场景比较粗暴的解决方案是定时扫库,例如:业务将订单的支付超时时间定义为2小时。可以每1分钟扫一次订单库,将超时订单取消。显然,此方案不够优雅,主要问题如下:
-
增加数据库读压力;
-
不够精确,会有最长1分钟的滞后;
扫库的方案一般体量不大时可以使用,当业务发展到一定规模后就不再适用。对IM消息重发秒级别的定时需求,只能增加扫库的频率,但过于频繁的扫库很可能会将数据库拖垮。显然需要更优雅的技术方案解决定时任务问题。
时间轮算法剖析
时间轮算法可以高效的处理定时任务,并且有非常高的精度。我们以IM的消息重发功能为例介绍下时间轮算法的应用。假设消息发出15秒后触发重发逻辑,可以设计如图1所示的数据结构:
- 一个包含15个元素的数组,数组每个元素指向一个链表,可以理解为15个桶;
- Current Pos指向数组中某个桶,每秒钟向下移动一次,指向下个桶;
- 如果Current Pos已经指向最后一个桶,移动时返回数组头部,指向第一个桶;
- 发消息时将相关信息放入Current Pos指向的桶中(作为链表中的一个元素)。
根据图1可以看出,Current Pos的下一个桶(图1数组中下标5)中的数据,就是所有已经发出15秒的消息,我们可以遍历链表,取出数据,逐个触发消息重发逻辑。
需要注意的是Current Pos是一个循环指针(指向数组末端后,下次偏移会重新指向数组头)。由此我们可以用更形象的方式描述这个结构,把数组首尾相连,形成一个“轮子”,也就是时间轮。如图2所示:
基于Redis实现时间轮
上面介绍的时间轮是将数据放在应用进程内存中的,可靠性比较差,我们可以进一步优化,将时间轮放到公共的存储中,很自然的会想到使用Redis。可以用Redis中的List和String两种数据类型实现时间轮。设计多个List,每个List相当于时间轮中的一个桶,再用一个String保存当前List的Key。如图3所示:
应用程序通过修改CurrentPos的Value实现时间轮指针的移动。很轻松的将进程内存中的时间轮放到了Redis中,提高了数据可靠性,同时可以多个实例访问时间轮,避免了单点问题。
长时间跨度定时需求实现
新的问题来了,现在我们看到的时间轮,可以用来触发秒级别的定时任务,但如果时间跨度比较大,例如小时或者天级别的定时场景,我们就需要一个非常“大”的轮子,将会占用非常多的内存资源。显然不是最优的方案,我们可以继续优化,使用磁盘文件+内存时间轮结合的方案,如图4所示:
- 将数据(需要触发的事件)按触发时间分散存储在多个文件中;
- 每个文件负责存储触发时间在指定区间内的事件,例如:文件A负责区间为2019年11月21日14点~2019年11月21日14点30,则所有在这个时间区间内触发的事件都会存储在这个文件中;
- 内存中只装载最近半小时要触发的事件,并以时间轮形式组织。
应用程序需要选择合适的时机将文件装载到内存,并建立时间轮索引,控制好时间轮转动,将到期事件触发即可。
延时服务中台化
到现在为止,上面介绍的模型已经可以满足业务的定时任务需求,但它还只是一个功能逻辑,我们不能让每个有需求的业务方都去实现一个时间轮,重复造轮子。所以需要将方案进一步地下沉,抽象为一个基础的中台服务,提供通用的延时触发能力,对外提供服务。系统架构设计如图5所示:
业务模块与延时服务的交互可以使用RPC Over TCP,但是对于延时服务来说,需要调用业务模块的RPC接口来触发延时任务,延时服务与业务模块耦合,不利于系统的稳定性,同时业务也需要实现回调接口,侵入比较大,易用性也不强。我们自然可以想到使用消息队列解耦,新的架构如图6所示:
延时消息
看到这里很多同学会说,直接用延时消息不是更好嘛?为什么还要花这么大的篇幅,把事情搞这么复杂(架构设计哲学在于大道至简)。确实是这样,但问题在于不是所有的消息队列都支持延时消息,更不是都能支持任意时间的延时,例如:现在使用非常广泛的RocketMQ对延迟消息的支持就不是很友好:只能支持固定几个档位,不能支持任意时间的延迟。所以为了能够满足业务需求,我们使用外部服务+Redis+MQ的方案(图6),以较低的投入快速实现任意时间的延时消息。
改造MQ实现延时消息
图6架构设计依赖了外部服务以及Redis等来实现延时消息,由于引入过多的组件,整体服务稳定性会受影响,并不是最好的实现方案。更优雅的方案可以通过改造MQ来实现,把时间轮逻辑做到MQ内部。下面以RocketMQ为例介绍延时消息的实现方案,RocketMQ消息存储模型如图7所示:
- 消息按顺序存储在CommitLog文件中;
- Dispatch线程将消息按主题分发到不同的Queue中。
思考
基于RocketMQ实现延时消息,除了实现时间轮算法外还会涉及哪些改动?
- 延时消息的特殊处理;
- 主从Broker之间的数据同步;
- Broker的故障恢复;
- …
可见,实际情况要复杂的多,还有很多点需要注意,这里也没有全部列出,欢迎大家在留言区讨论补充。
总结
针对同一业务需求,会有多种技术方案,单从技术角度看很容易判断出方案的好坏,但我们看问题需要多角度和多维度,不能只关注于方案本身,从上面延时消息实现来看,最优雅的方案同时也是最复杂、实现难度最大方案。反之,借助外部组件可以用较低的投入实现同样的使用效果,虽然有缺陷,但对业务来说感受不到差别,所以我们选择技术方案时不一定要过于追求完美,要结合公司实际情况和团队技术实力,计算投入产出比(ROI),作出合理选择。
作者:孙玄,奈学教育CEO,『架构之美』公众号作者,前58同城技术委员会主席。