延迟调度系统(一)——场景与设计

首先,来说一下业务场景,也即什么时候,需要有这么一个系统,举两个例子

1. 订单推送——点评侧垂直业务产生新的订单之后,需要推送给美团订单中心,以在美团APP展示出来。这种通过RPC调用的推送,是可能调用失败的,那么失败之后怎么处理呢?业务线程自己立即重试还是睡一会再重试都不是很合理。在推送失败后,新建个task丢给重试中心统一处理无疑更加优雅。

2. 延迟任务——一个常见的场景就是用户下单一段时间之后,触发XX操作。这种情况下,之前团队经常使用的方式就是新写一个job,轮询扫描表,然后执行XX操作。这种用job来执行延迟操作的方式我觉得是很不优雅的。一是浪费资源,扫描了一些无用数据,或者根本没有需要执行的任务,也会启动一次job。二是job代码和业务代码一般会在不用项目中写,使业务逻辑分离了。

以上的两个例子,都涉及到了延迟调度系统,以下说说我们在设计和实现延迟调度系统时的一些思考。

问题1:延迟调度的时间,要不要精准

这个问题的答案很大程度上影响了系统的复杂性。举个例子,在调度时,甚至可以选择使用一个job,每两分钟到DB中拉取出需要处理的task,然后执行重试{公司内部有团队就是这么做的}。这样做,当任务量少的时候,也能做到分钟级别的精确。但这种方法,想要提高精确度就比较蛋疼了,毕竟job也需要容器启动,DB拉数据等各种操作。而通过实时轮询DB拉取任务的方式就更加蛋疼了。

 当希望能够更加精确的实现延迟调度时,就需要引入DelayedQueue、环形队列等额外的调度控制手段了。

问题2:如何保证一个task只被消费一次

当服务被单例部署的时候,这个问题是不存在的。然而在稍有规模的互联网公司内部,一般服务都是无状态的部署在对等的集群上的,同一个服务存在多个部署实例,那么如何保证一个任务只被一个实例消费和执行呢?强迫业务方的回调幂等不现实,因而重试系统必须要自己保证task只被消费一次。个人感觉实现这种保证主要有以下两种方式:

(1) 在添加task时,在DB表里就反映出该task被哪台机器执行,每台机器只去DB load应该由自己执行的task

这种情况下,直接将机器IP等硬信息写到DB Task表里肯定不合理,一旦某台机器跪了就悲剧了。因此,可以考虑加一层虚节点,配合一致性哈希来实现这种保证。DB Task表里存放应该执行此task的虚节点,再使用zookeeper与一致性哈希,将虚节点映射为真实机器上。如此,当某机器跪了或者新增机器时,整个集群都可以自动的进行调整,不会漏执行,也不会多执行。

(2) 从DB加载到内存之后,存在一种机制来判断这个task是不是应该被本机执行,否的话就丢弃该task,不执行

这种方式下,不管三七二十一,先去DB拉task,然而在真正执行该任务之前,需要进行一部判断,看这个task是不是被其他机器执行了。比如在Task表里新增一个字段,在执行这个任务之前,先用MySQL乐观锁更新一下这个字段,要是失败了,就说明已经被其他机器执行过了,就丢弃这个任务。或者,引入redis等缓存来实现。总之,就是提供一个第三方(DB,缓存)作为多个服务实例的中介,让各个实例能实时查询任务是不是被执行过了。

问题3:业务方接入和使用的复杂度

这种公共系统,是给各个业务方使用的,要是系统的接入和使用过于复杂,这就比较蛋疼了。因而,在系统设计的时候,要充分的考虑系统的易用性。这里再延伸一下业务方使用retry两个不同场景,一般最容易想到的是如下:

        boolean isSuccess = xxService.doSomeThing();
        if (!isSuccess) {
            retry(params);
        }

那么,考虑这么一种场景,执行结果isSuccess = false,且这时候,发生了断电,爆炸或宇宙辐射,就是导致retry没执行到,可咋整呢?这时,可以分两个阶段与retry系统交互:

        retry(params, RetryStatusEnum.Ready);
        boolean isSuccess = xxService.doSomeThing();
        if (isSuccess) {
            retry(params, RetryStatusEnum.Success);
        }

有点点像分布式事务的两阶段提交

原文地址:https://www.cnblogs.com/dosmile/p/6565111.html