支付系统 - 延时任务的设计方案

3,489 阅读18分钟

前言

一般业务中对时间不敏感的离线任务使用诸如Quartz类的定时任务框架即可处理。若遇到如下场景则会比较棘手:

  • 半小时内未支付的订单自动关闭
  • 到达指定时间后执行某任务
  • 半小时后推 Push/短信

这类场景的的共性是,需要等待一定时间或者到达某个指定的时间点再触发指定的业务逻辑,并且对时间的精准性要求很高。有一种优雅的解决方案延迟消息正是为此量身定做。本文即是对此项技术进行探讨。

常见的实现方案

定时扫库

这种方法最为简单,弊端是定时扫描周期设置是一个难点。针对两小时的设置为 20 分钟差不多,但是存在不精准的问题。那如何提供更精准的实现呢?降低扫描周期增大它的频率,更为精准但带来了更多的数据库压力。不管怎样都存在不精准的问题,有些延迟,最长的时间可能会等待一个扫描周期。

在业务上可以保证,但是存在两个问题:

  • 不精准
  • 对数据库的压力

有的同学可能会说扫从库,如果我们做了读写分离那对我们的读也是有影响的。总是,这种方案可以用,它的弊端也很明显。

那有没有更优雅的方案呢?那就是延时消息。

延时消息

一般使用的消息中间件如RabbitMQRocketMQ都能实现延时消息的功能。

RocketMQ

先以RocketMQ举例。在本文撰写之际,RocketMQ开源版本支持18个级别的延时等级,默认值为1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,生产者发送消息时通过设置delayLevel延迟级别值,这样就间接设置了延时时间。

这里简单的过一下它的实现原理,来分析一下它为什么这么不好用。

为了方便描述这里简化描述下RocketMQ投递消息的流程,下图是正常的消息流程。

正常的消息流程

正常的消息流程

这是延迟消息的流程:

延迟消息的流程

延迟消息的流程

正常投递消息后修改Topic进入改名后的队列,为队列创建定时任务扫描到达时间后投递到正常的消费队列。思路很简单,但是这么设计存在一个问题,如果在逻辑上只有一个队列,那么投递到这个队列上的消息必须保证有序性。也就是每次投递必须对消息进行重排序,这种消耗显然是无法接受的。

可能有的同学会问,消息有序是什么意思?我举个例子:

假设我们投递了三个消息,如图示:

FIFO投递

FIFO 投递

它会严格按照先进先出的原则进行投递,这显然不是我们期望的1S C消息应当在5S B之前出队。一个队列势必需要搅动整个队列进行排序才能满足我们的需求。

再看下优化后的实现:

优化后延迟消息的流程

优化后延迟消息的流程

只需要增加delayLevel延迟级别值,按照不同的级别dispatch到不同的队列,每个队列上只有相同延迟级别的消息,这样就避免了消息排序。每个队列的延迟时间是一样的就变成了顺序入队顺序出。

相信看到这里的读者都应该明白了RocketMQ延迟消息设计的思路。我个人觉得这对业务来说不够友好,18个等级的配置显然是不够用的,虽然RocketMQ支持配置不同的等级延迟时间规则。想要支持各种五花八门的延迟时间那就势必得搭多套集群,每套都自定义时间,这太蠢了。不过阿里云收费版本的支持任意时间,可能这就是金钱的力量吧。

总结一下,RocketMQ延迟消息配置单一,可用度不高。

所以如果有一款支持任意时间的延迟消息功能的组件,而不是指定delayLevel,支持传参时指定延迟时间或者定时时间那我们做业务开发就非常舒服了。不过在讲真正的实现时,先来看一下RabbitMQ中如何绕一步实现延迟消息。

RabbitMQ

严格来说,RabbitMQ不支持延迟消息。但是我们可以利用它的死信队列来实现延时的功能。直接说可能不好理解,我举个现象。一个普通队列中积攒的消息没有消费者,普通队列会将这些消息其转移到与其绑定的另一个兜底队列中。这个兜底队列如果有消费者就可以处理这些没人管的消息了,这个兜底队列就是死信队列。当然了,在RabbitMQ中两个队列的绑定是通过交换机和路由键共同作用实现的,并是不队列和队列直接绑定。这块之前因为做互联网拍卖推送系统设计的时候使用不当,结结实实的踩了个坑。

先来聊一下当时的需求。我所设计的拍卖系统业务中有限时拍卖和专场拍卖两种竞价模式。

  • 限时拍卖:用户 A 委托一个价格,用户 B 也委托一个价格,拍卖平台按照委托价格自动加价,到达结拍时间后出价纪录最高者得拍。一般这种竞价周期以天为单位。
  • 专场拍卖:卖家发布一个专场,该专场内只上架该卖家的商品,也就是卖家拍品个人秀。而专场有一个开拍时间,是未来的某一个确定时间点。而该模式线上的实现需要模拟线下现场叫价,每个拍品按照 lot 轮流登场。倒计时 30 秒,有人出价倒计时延长 5 秒。期间端上的用户可以自由出价,价高者得拍。

说完了业务模式,大家可以很自然的想到使用延时消息来实现这个定时消息的功能。当时是怎么做的呢?因为之前使用的是RocketMQ知道它没有定时消息的功能。于是就着重关注了市面上份额巨大的RabbitMQ。果不其然,搜索RabbitMQ 延迟消息就弹出来很多相关信息。当时具体看的是哪篇已经忘记了,好像叫什么30分钟关闭订单怎么实现?试试 RabbitMQ之类的文章。大致扫了一眼,这种可以设置time-to-live的消息模式是满足我的定时需求的。只需要在专场开始时间和现在求个差,设置为ttl就行。至于RabbtiMQ使用ttl实现延迟消息其实有两种方式。当时就选择了非插件的模式,因为插件使用erlang开发,出了问题不好改就没考虑。看一下业务当时的实现吧:

队列与死信的绑定:

队列与死信的绑定

队列与死信的绑定

客户端发送延时消息:

@Component
public class DelayMessageProducer  {

	private static Logger logger = LoggerFactory.getLogger(DelayMessageProducer.class);

	@Autowired
	private RabbitTemplate rabbitTemplate;


	public void send(DelayMessage data) {

		logger.info("【延迟消息生产者】准备发送消息,data:{}", data.toJSON());

		LocalDateTime now = LocalDateTime.now();
		LocalDateTime beginTime = data.getBeginTime();

		long delayMillis  = Duration.between(now, beginTime).toMillis();

		long delaySeconds = Duration.ofMillis(delayMillis).getSeconds();

		if (delayMillis < 0) {
			logger.warn("【延迟消息生产者】 警告:延迟时间计算有误,开始时间:{}", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(beginTime));
			return;
		}

		logger.info("消息理论在{}秒后到达战场", delaySeconds);

		rabbitTemplate.convertAndSend(RabbitConst.Begin.EXCHANGE, RabbitConst.Begin.ROUTING_KEY, data, m -> {
				m.getMessageProperties().setExpiration(String.valueOf(delayMillis));
				return m;
		});
	}
}

嗯,相信使用过RabbitMQ的同学都能看懂其中的含义。关键就是这行m.getMessageProperties().setExpiration(String.valueOf(delayMillis))设置ttl。当时测了几次专场,没什么毛病正准备上线的时候突然就出问题了。

我还记得当时 PM 喊我的时候说,自己发布了一个三天后的专场,然后又发布了个 10 分钟后的,怎么现在还没开始。我当时心想一定是MQ挂了,看了一下没问题,百思不得其解。其实就是上面RocketMQ中描述过的消息有序性问题。10分钟后到达的消息必须得等它前面的3天大哥出队才能轮到它。当时有点郁闷,因为专场模式运营准备第二天就发公告了,结果前一天下午出问题了。

后来我研究了一下,RabbitMQJAVA API是支持动态创建队列的。很自然的,当我发送这种定时消息时为每其动态创建一个队列,这样这些队列上就永远只有一个消息,当然不存在消息排序问题的了。下一个问题这个队列总不能一直存在吧,然后我就发现队列其实也可以设置存活时间。刚开始我设置的和消息ttl时间一致,经过测试发现有时候到达消息投递时间队列自动删除时会报错,我严重怀疑是消息没来得及处理的原因。因为deadline的缘故,我就直接把队列删除时间在ttl的基础上加了几秒。这样,这个问题就临时解决了。来看一下当时的实现吧:

为了方便使用,我还专门封装了个类,虽然我知道这只是一个临时的实现。没办法,即使是往项目里拉了坨屎,我也希望屎可以立住,而不是稀的:

由于代码太多,我提供一下地址,需要的朋友可以自行查看 FixedTimeQueueHelper

需要注册到Spring中,以单例模式使用:

@Bean(name = "rabbitAdmin")
public RabbitAdmin getRabbitAdmin(RabbitTemplate rabbitTemplate) {
 return new RabbitAdmin(rabbitTemplate);
}
 // 临时队列小助手
@Bean
public FixedTimeQueueHelper fixedTimeQueueHelper(RabbitAdmin rabbitAdmin) {
 return new FixedTimeQueueHelper(rabbitAdmin);
}

注入进来就可以使用了:

@Component
public class FixedTimeMessageProducer  {

	private static Logger logger = LoggerFactory.getLogger(FixedTimeMessageProducer.class);

	@Autowired
	private FixedTimeQueueHelper fixedTimeQueueHelper;

	public void send(FixedTimeMessage data) {

		logger.info("【定时消息生产者】准备发送消息,data:{}", data.toJSON());
		try {
			fixedTimeQueueHelper.declareAndSend(RabbitConst.FixedTime.EXCHANGE, RabbitConst.FixedTime.ROUTING_KEY,
					data.getId(),
					data.getExcutetime(),
					data.getPayload());
		} catch (FixedTimeDeclareException e) {
			logger.warn("定时队列创建失败,{}", e.getMessage());
		}
	}

}

当然了,这些临时队列也是绑定在之前已经提前声明好的死信队列上。

/**
 * 创建队列,该定时队列用来兜底多个临时队列中没有路由的消息
 */
private void createTimeFixedQueue() {
 Exchange exchangeFixedTime = ExchangeBuilder.directExchange(RabbitConst.FixedTime.EXCHANGE).durable(true).build();
 Queue  queueFixedTime  = QueueBuilder.durable(RabbitConst.FixedTime.QUEUE).build();
 Binding bindingFixedTime  = BindingBuilder.bind(queueFixedTime).to(exchangeFixedTime).with(RabbitConst.FixedTime.ROUTING_KEY).noargs();
 rabbitAdmin.declareExchange(exchangeFixedTime);
 rabbitAdmin.declareQueue(queueFixedTime);
 rabbitAdmin.declareBinding(bindingFixedTime);
}

到了这里我的采坑过程就讲完了。可能有的小朋友会问了,“啊你这个太 Low 了,直接把队列删了消息没有 ACK 怎么办?”。问的好,对此我的回答是,在我的业务场景中,用户会在专场开始前看到具体的倒计时秒数,而系统实现分秒不差。也就是说,定的是几点就是几点开始。如果因为系统原因没有准时开始,等RabbitMQ Broker再次投递直到应答成功也许时间跨度几秒到几分钟不等。那么等几分钟后再开始用户还停留在那个页面的还有多少?如果有用户留在该页面,和他竞拍的其他玩家不在,有损拍卖的公平性。并且专场拍卖中还包含着限时出价的逻辑。因此,这个场景不需要手动应答,自动应答即可。出现故障运营后台有补发功能,当然前提是他们公告用户了。

题外话:

其实在系统设计之初,就有考虑使用时间轮来实现这个功能。因为我做东西喜欢做通用的组件,其实是有些费时间。所以就直接用RabbitMQ来实现了,简单就是美。没想到天道好轮回,最后还是得回来补上这个坑。幸好及时的临时处理了这个问题,不然另一个解决方案就是定时任务高频扫描了,让我去这么实现就好像让我临时吃了屎。做系统设计一定要仔细调研方案,不然做到最后可能得推到重来,切记。

时间轮算法

好了,终于来到了本篇的重点,时间轮算法,先给出论文的 下载地址。我的认知是学习一项技术最好从源头上看,学习它的底层逻辑。尽量不要看二手资料,因为很容易被二创者夹带不正确的私货及价值观。当然这个问题见仁见智,别人咀嚼过的东西经过加工只需要我们费一点脑子去看,即使有部分是错的,学习 70% 总比 0% 强。只要能满足你阶段性的学习需求也无妨,等到哪一天你发现关注的人说的东西似乎不对,那说明你成长了,取关他就是了。就像我这篇文章一样,在这里说了半天,如果你之前并不了解这项技术就很容易被我带了节奏。如果你经常看某一个人的文章,很容易潜移默化的被输入价值观。希望读者保持独立思考的能力,大部分人只是看起来很厉害而已,真正的成长还得靠你自己。

开门见山的说:时间轮算法可以用于高效的执行大量的定时任务,实现简单并且具有非常高的精度。怎么实现呢?接下来我们一起来看看。

天级

先来个最简单的版本。有这样一个需求:一天内整点需要执行任务。如果让你来实现你会怎么做?首先,最容易想到的是数组+链表。为了方便理解,我画了个图:

天级时间轮

天级时间轮

其中时间刻度就指的是一周内24个小时,每过一个小时,指针就移动一下。如图中所示在一天内的第5个小时处,也就是凌晨 5 点对应的链表上发现了多个任务。直接都取出来执行即可。可能有的朋友会问,那时间转满了怎么办?我明天想用怎么办?增加时间跨度即可。

周级

直接把时间跨度调到一周,这样就支持一周内的整点任务了。

周级时间轮

周级时间轮

其中刻度就指的是一周内7*24个小时,每过一个小时,指针就移动一下。如图中所示在一周的第28个小时处,也就是周二凌晨 4 点对应的链表上发现了一个任务。直接取出来执行即可。当然了这张图中每个时刻我只画了一个任务。

此时可能有小伙伴要问了,那我要支持一年的怎么办?

抢答:那还不简单,想要支持一年的,把时间刻度增大到365*24就完事了。

太棒了,举一反三的能力我很欣赏。但是如果我要支持一年精确到秒的呢?365*24*60*60这么多的刻度,我使用数组去保存是不是有点浪费空间啊?如果我有个任务是这周的最后一秒,这个轮子会傻傻的跑满很多秒才拿到,拾取任务的效率不高。那有没有稍微优雅一点的手段呢?答案是肯定的,那就是循环数组。话不多说,请接着往下看。

无限小时级

由于我做 PPT 也是二把刀水平,就随便画了下,凑活看吧。

无限小时级时间轮

无限小时级时间轮

简单的解释一下,时间刻度只有24个,代表一天的24个小时,黑色箭头到最下面时会重新回到1(有没有感觉那个旋转很魔性)。这也是为什么叫时间轮的原因,其实就是循环数组。原谅我没把它画成个轮子,下次我尽力(如果有更简单做这种简单动画的工具请不吝赐教)。

还需要注意的一点是每个任务上的Round标记。只有指针路过时发现是Round=0的任务才会执行。得益于这样的设计,投递任务时,可以准确的计算出这个任务的Round以及对应的时间刻度。每循环一次,Round>0的任务减一,等归零就可以执行了。

那这个轮子支持多少小时呢?答案是无限小时,当然如果考虑可用性的话,需要做容错机制,最简单的就是持久化。

层级时间轮

相信看到这里的读者对时间轮的基本原理有了一定的认识,上面的设计,还是只能支持到小时级别。如果说你想给它改造为秒级,在不考虑内存的情况下可以简单粗暴的增加刻度为24*60*60。如果还想再优雅一下,可以使用层级时间轮。这是什么意思呢?

在讨论之前,先根据上文的描述定义下专业的概念:

  • 事件(要执行的定时任务)
  • 桶(链表)
  • 游标(当前指针)
  • 时间精度(时间刻度盘)
  • 数组(循环)

上面举的两个简单例子,是不是让你产生了一种错觉,觉得游标所在的时间刻度就是真正的时间?其实不是这样的!为什么会产生这种错觉呢?因为从一开始我们就是以现实生活中的时间单位进行分析。实际上程序中的轮子可不保证就是按照时分秒来的。它一圈可能是 20/30/50 秒都有可能。无论是在单层还是分层时间轮的实现中,时间都是一个相对的概念。内部需要有自己实现的时钟,并且一直在跳动。当你需要新增一个任务时,根据当前时钟和任务执行时间计算对应的指针位置。我举个例子:

如果当前时钟 currentTime 为 0 秒,盘面满刻度为 30 秒,一跳为 1 秒。你的任务需要在 18 秒后执行,现在新增那么它会进入index=18的位置。 如果时间过了十八秒,当前盘面时钟 currentTime 为 18。你再新增一个需要在 10 秒后执行的任务,那么它会落到index=28的位置。那如果同时有一个需要在 20 秒后执行的任务呢?很明显是进入到了index=18+28-30=6的位置。

其实说白了,盘面的时间和你现实生活中的时间是不一致的。游标的计算就是根据当前时钟去推算需要过多久,然后根据这个过多久时间去按照时间轮现有的刻度去计算任务投递的位置,明白这点很关键。

请注意下面是一段错误的理解,实际上刚开始我以为时间轮的设计完全是和现实的时分秒关系对应的。至于为什么有耐心画一幅错误的图,看了说的话你就懂了):

层级时间轮是以小刻度为底盘,逐渐升级到大刻度,随着时间的推移这个任务会不断的降级到低等级的底盘上。如果需要具体的实现,可以参考Kafka,希望大家能在那里发现新的天地。

上面提到了高可用性的轮子,也简单说一下。我们需要一个大跨度的时间轮,比如两个月就需要考虑可靠性,一般我们需要进行持久化。所以这就有一个持久化方案设计的问题。首先我们不能放在本地内存中,因为现代的应用多是分布式,所以可以考虑放在Redis中。至于是不是将所有的消息都丢到Redis中,我的答案是否。毕竟一款通用的组件要考虑方方面面,如果业务出现很多好几个月后才到期的延迟消息全部加载在Redis中也是一种资源浪费。当然了,你如果不是想设计一个方方面面通用性强的组件,并且业务可以接受全部丢到Redis中也 OK。比如,我们可以将半小时内将发生的延时消息加载到Redis中。至于其它几天甚至于几个月的消息,暂时不会使用所以可以将其保存在文件中。

当然有的同学会说,Redis也会挂啊,我的答案是做高可用。毕竟即便你保存在Mysql中,Mysql也需要做高可用。使用Redis现有的数据模型,简单轻便还不用设计关系模型简直是巨大的优点。

后语

这篇文章是对延时任务的总结,希望对大家能有所帮助。如果大家有什么更好的方案、心得,请不吝赐教。如果有写好的时间轮代码也可以发出来围观围观。最后以一篇劝学结尾,祝大家身体健康,后会有期。