一种轻量级延迟任务的设计思路

开场白

日常的业务开发常有延迟触发的需求,比如常见的订单创建一段时间未付款,会自动触发关闭;注册用户一段时间内未完善资料,可以触发提醒资料更新等。这样的需求,就是延迟调度的应用场景。

本文旨在提出一种分布式延迟调度的实现方法,为需要进行延迟调度设计的童鞋提供一种设计思路。

实现一、QelayQueue调度实现

DelayQueue是一个高效的内存延时阻塞队列,可以为任务元素增加延迟获取的时间,从而实现在单Java进程内延迟触发。

优点:

  1. 延迟任务可精确触发。

缺点:

  1. 支持单进程内调度,分布式场景下的需求无法支持;
  2. DelayQueue是内存队列,进程宕机、重启都会造成任务丢失。

实现二、周期任务 + 数据库

将任务记录写入数据库,使用固定周期从数据库中加载延迟任务并执行,这是一种相对简单但暴力有效的实习思路。

优点:

  1. 任务记录写入数据库,可防止进程崩溃引起的任务丢失。

缺点:

  1. 周期任务扫描,临界点任务可能到导致多延迟一个周期触发;
  2. 如果把周期设置的很低(比如1s),则会给数据库带来负担。

实现三、周期任务 + DelayQueue + 数据库

另外一个思路:

  1. 设定一个延迟阈值,比如5m;
  2. 所有延迟任务记录先写入数据库;
  3. 延迟时间在阈值的内任务直接放入DelayQueue排队,到期则触发;
  4. 另起一个周期任务,设定阈值执行周期,每次扫描下一个周期内任务,并加载DelayQueue排队,到期则触发。

通过以上操作,保证最近延迟阈值内的任务,都已经在DelayQueue中排队等待触发。需要注意的是,任务记录需要有状态,"创建"、"排队"、"触发成功"、"触发失败",都需做状态更新,避免任务重复执行。

实现流程可参考下图:

优点:

  1. 延迟任务可精确触发
  2. 任务记录写入数据库,防止进程崩溃引起任务丢失;
  3. 周期加载延迟任务无需高频率执行,减少数据库负担。

缺点:

  1. 周期加载延迟任务在进程内管理(未做分布式协调),多点部署的场景下,可能被多次执行,需要处理额外的争用问题。

实现四、Quartz + DelayQueue + 数据库实现

考虑"实现三"中在进程内周期加载延迟任务存在多次执行的问题,可引入Quartz进行任务管理,保证多点部署场景下,同一个任务只能在一台机器上执行,从而解决多点部署的场景下任务被多次执行的问题。
实现流程可参考下图:

优点:

  1. 延迟任务可精确触发;
  2. 延迟任务持久化,保证不会出现任务丢失;
  3. 没有高频率的轮询操作,不会给数据库造成负担;
  4. 一般业务量不大的系统,使用此实现完全足够。

考虑这样一个场景,如果一秒内要触发的任务量很大,使用Quartz又只会在一个节点上进行延迟任务加载(Quartz不支持任务分片,只能支持失败飘移),那延迟任务能精确触发吗?

实现五、分布式调度 + DelayQueue + 数据库

分布式调度系统(xxl-job、elastic-job、tb-schedule)均支持对任务进行分片,因此使用分布式调度系统替换Quartz,周期任务使用分片策略,即可实现在多机集群上用分片信息加载不同的延迟任务,从而充分发挥多机并行的计算能力,尽可能的保证延迟触发的精确性。

一些后话

  • 数据库中延迟记录膨胀

任务记录在日积月累,数据库中的延迟任务记录会不断膨胀。基于延迟任务特性,一般都是在一个相对较短的时间内触发,因此可以在任务触发成功后,将记录迁到历史,只在当前库中存储还未触发、触发失败的任务,从而降低当前库的数据量,以提升在任务表上的查询性能。当然还可以有分库分表等技术方案可以考虑。

  • 处理好明确失败的任务补偿

对于触发失败的任务,已经失去精确触发的意义,处理时,首先可以在现场执行补偿(可使用spring-retry等工具,同时需要补偿回调具备幂等性),另外起一个线程补偿任务,周期扫描延迟任务记录表,补偿触发即可。

  • 处理好进程崩溃、重启的任务补偿

对于任务长时间处于"创建"、"排队"等状态的任务,应设定一定阈值(比如10m),超过延迟触发时间点一个阈值周期的任务,理解为已经失败,进行单独扫描补偿。

总结

本文提供了一种思路,结合外部调度,Java延迟阻塞队列、数据库,设计了一个相对轻量的技术实现。可实现延迟任务持久化,不丢失,还可支持大批量任务精确触发。

End.