关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言
这几天我们遇到一个问题,我们需要做一个自动化的拍卖系统。车辆加入场次之后,拍卖时间开始自动拍卖,直到所有的拍品拍卖结束。
因为车辆加入的场次可以是当天的,也可以是未来某一天的,场次的拍卖开始时间使我们要解决的重中之重。关于这个问题,我们做了技术讨论,主要放在定时任务和延迟消息上,因为延迟消息和定时任务都是用来处理"未来某个时间点需要执行的操作"的技术方案。
有人想用定时任务做;有人想用延迟消息;也有人想用定时任务+延迟消息的。到底应该怎么抉择呢?
我们一起来看一下定时任务和延迟消息的利害关系!
02 延迟消息
延迟消息主要被动触发,等待一个时间点你被唤起触发。实现方案有很多,可以基于JDK
的延迟队列、也可以使用第三方的中间件。
2.1 延迟队列
基于JDK
的延迟队列是最简单的实现方式,无需任何第三方依赖。只需要实现java.util.concurrent.Delayed
接口即可。
java
public class DelayTask implements Delayed {
// 延迟任务名称
private String name;
// 实际延迟时间的时间戳
private long delayTimestamp;
public DelayTask(int time, TimeUnit unit, String name) {
this.delayTimestamp = Instant.now().toEpochMilli() + (time > 0 ? unit.toMillis(time) : 0);
this.name = name;
}
public String getName() {
return name;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(this.delayTimestamp - Instant.now().toEpochMilli(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
DelayTask o1 = (DelayTask) o;
long diff = this.delayTimestamp - o1.getDelay(TimeUnit.MILLISECONDS);
if (diff == 0) {
return 0;
}
return diff > 0 ? 1 : -1;
}
}
getDelay()
主要是为了获取延迟的时间,然后通过compareTo()
实现比较。

2.2 Redis的Sorted Set
Redis
中的一个数据结构是Sorted Set
,它是一个有序集合,利用score
值排序。我们可以用时间戳作为score
,数据结构会自动帮我们排序,我们只要依次轮询取出值,如果当前值的score
小于等于当前时间,就取出即可。
sh
# Redis 命令
zadd delay:task 1750078434263 task1 1750078435800 task2
结合业务代码:
java
while (true) {
DelayTask task = redis.zrange("delay:task" , 0 , 0)
if (task.getTime().compare(Instant.now().toEpochMilli()) <= 0) {
// 处理业务
}
}
2.3 MQ消息
消息中间件在企业中应用尤为广泛,同时支持延迟消息,使用起来非常简单。我们这里以Active MQ
为例。消息的搭建这里不再赘述。
java
this.jmsTemplate.send(this.destination, new MessageCreator() {
@Override
public Message createMessage(Session session) throws JMSException {
MapMessage mapMessage = session.createMapMessage();
mapMessage.setInt("taskId", taskId);
// 延迟消息的属性
mapMessage.setLongProperty(ScheduledMessage.AMQ_SCHEDULED_DELAY, delayTime);
LOGGER.info("延时消息发送:{}", JSONObject.toJSONString(mapMessage));
return mapMessage;
}
});
MQ
的延迟消息应用相比其他的延迟消息应用更广,因为消息一般都会搭建高可用,消息的持久化又避免了消息的丢失。
传统的延迟消息,尤其JDK
自带的延迟队列,对于多节点、分布式系统处理起来就会麻烦一些,需要考虑不同节点处理热任务以及任务丢失的问题,而Redis
的虽然有持久化,但是应用又需要一个守护线程不停地轮询处理任务。个人认为MQ延迟消息
的方式更加方便一下。
03 定时任务
定时任务主要以轮询调度为主,调度中心按计划触发任务执行。主要处理一些周期性、固定频率的任务。
3.1 Timer定时器
定时器只需要继承java.util.TimerTask
即可。
java
public class DelayTImerTask extends TimerTask {
private String name;
public DelayTImerTask(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + this.name + " 任务执行了");
}
}
实现只需要调用schedule()
即可完成。

这里的调用比较像延迟消息。但是timer
可以实现周期的任务:

这种实现方式非常方便,是JDK
自身提供的,定时任务是基于内存的。
3.2 基于@Scheduled
注解
Springboot
已经集成了定时任务的注解,通过简单的配置就可以实现。
java
@Configuration
@EnableScheduling
public class ScheduleTask {
@Scheduled(cron = "0/3 * * * * ?")
private void configureTasks() {
System.out.println("执行静态定时任务时间: " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
}
执行结果:

基于项目的定时任务,需要考虑多节点的幂等性问题。
3.3 分布式调度任务
分布式是始终绕不开的话题,分布式调度任务可以解决多节点的问题,常用的框架xxl-job
。
xxl-job
依赖
xml
<!-- xxl-job-core -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.3.0</version>
</dependency>
java
@Configuration
public class XxlJobConfig {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.executor.appname}")
private String appName;
@Value("${xxl.job.executor.ip}")
private String ip;
@Value("${xxl.job.executor.port}")
private int port;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean(initMethod = "start", destroyMethod = "destroy")
public XxlJobSpringExecutor xxlJobExecutor() {
logger.warn(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppName(appName);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
System.out.println(logRetentionDays);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
logger.warn(">>>>>>>>>>> [adminAddresses]{}|[appName]{}|[ip]{}|[port]{}|[accessToken]{}|[logPath]{}|[logRetentionDays]{}",
adminAddresses,appName,ip,port,accessToken,logPath,logRetentionDays);
return xxlJobSpringExecutor;
}
}
任务编写
java
@JobHandler(value="dealyHandler")
@Component
public class DealyHandler extends IJobHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(DealyHandler.class);
@Override
public ReturnT<String> execute(String param) throws Exception {
// 业务代码
return SUCCESS;
}
}
剩下的只需要在xxl-job
管理界面配置即可:

04 区别与场景
延迟消息处理的时间比较灵活,可以自定义时间,适用于差别性较大的数据,可以逐一订制处理;而定时任务更适合处理时间固定、频率固定的批量的数据。
延迟消息基于事件的延迟处理如订单超时、支付交易异常的结果处理、优惠券的提醒过期等等;还可以削峰填谷,平滑流量,将非紧急的处理请求(如非实时的日志分析、图片处理)放入延迟队列,稍后处理,避免瞬时高峰压垮系统;工作流中的等待步骤,如在一个多步骤的工作流中,某个步骤完成后需要等待一段时间(如等待人工审核、等待外部系统响应超时)才能进行下一步,可以用延迟消息实现等待;还可以用在分布式事务的最终一致性补偿,如在发起一个可能失败的操作后,发送一条延迟检查状态的消息。如果延迟时间后检查到状态不一致,触发补偿操作。
定时任务适用于数据的清理、报表的生成、缓存的刷新、状态机的维护、数据同步等,以及特定的活动的开关,在合同到期日当天零点发送合同续签提醒邮件(如果系统能精确知道所有合同的到期日并提前配置好任务)
05 小结
正所谓尺有所短寸有所长,延迟消息和定时任务也有自己的不足的地方。延迟消息的时间过长会导致浪资源消耗特别严重,例如阿里开源的RocketMQ
就不支持自定义的延迟消息,只支持18个等级的延迟消息,最长的也就2的小时。而定时任务频率太高也会产生资源的浪费。
个人觉得,当一个延迟消息超过24小时,就是不可靠的。而超过24小时的延迟消息,就可以使用定时任务来节省资源。如每天凌晨00:00以后,定时拉取当前需要的处理的延迟任务,然后投递到延迟消息里面。这时候延迟消息最长也不会超过24小时。这样即便消息丢失,也可以通过定时任务手动的方式重新完成消息投递。
大家觉得呢?