
在日常开发中,尤其是电商、支付等场景下,我们经常会遇到需要处理延时任务的需求。例如:
- 生成订单30分钟未支付,则自动取消。
- 生成订单60秒后,给用户发送短信提醒。
这类任务被称为延时任务。它与我们熟知的定时任务有什么区别呢?主要有以下三点:
- 触发时间:定时任务有明确的触发时间(如每天凌晨2点);延时任务则在某事件(如下单)触发后,经过一段指定时间才执行。
- 执行周期:定时任务通常按固定周期执行;延时任务一般只执行一次。
- 任务数量:定时任务往往是批处理多个任务;延时任务通常是处理单个特定的任务。
下面,我们以“订单超时自动取消”为例,深入分析五种主流的实现方案。
方案分析
1)数据库轮询
思路
这是一种在小型项目中较为常见的方案。其核心是启动一个定时线程,周期性扫描数据库,根据订单的创建时间判断是否有超时订单,然后执行更新(如标记为取消)或删除操作。
实现
例如,可以使用经典的定时任务框架Quartz来实现。首先在Maven项目中引入依赖:
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.2.2</version>
</dependency>
然后,创建一个任务类并配置触发器:
public class MyJob implements Job {
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println("要去数据库扫描啦。。。");
}
public static void main(String[] args) throws Exception {
// 创建任务
JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
.withIdentity("job1", "group1").build();
// 创建触发器,每3秒钟执行一次
Trigger trigger = TriggerBuilder
.newTrigger()
.withIdentity("trigger1", "group3")
.withSchedule(
SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(3).repeatForever())
.build();
Scheduler scheduler = new StdSchedulerFactory().getScheduler();
// 将任务及其触发器放入调度器
scheduler.scheduleJob(jobDetail, trigger);
// 调度器开始调度任务
scheduler.start();
}
}
运行代码,控制台会每隔3秒输出:要去数据库扫描啦。。。
优点:实现简单,易于理解,且通过数据库可以天然支持集群操作。
缺点:
- 资源消耗大:频繁扫描数据库,对服务器内存和数据库连接池造成压力。
- 存在延迟:扫描间隔决定了最坏情况下的延迟时间(如每隔3分钟扫描一次,则订单可能超时3分钟后才被处理)。
- 数据库压力:当订单数据量达到千万级时,周期性全表或范围扫描对数据库性能损耗极大。
2)JDK的延迟队列(DelayQueue)
思路
利用java.util.concurrent包下的DelayQueue。这是一个无界阻塞队列,其中的元素必须实现Delayed接口。队列只在元素的延迟期满时,才能从中取出元素。
其工作流程如下图所示:

poll():非阻塞获取并移除队首的超时元素,若无超时元素则返回null。
take():阻塞获取并移除队首的超时元素,若没有超时元素,则当前线程进入等待,直到有元素满足条件。
实现
首先,定义一个订单延时对象:
public class OrderDelay implements Delayed {
private String orderId;
private long timeout;
OrderDelay(String orderId, long timeout) {
this.orderId = orderId;
this.timeout = timeout + System.nanoTime();
}
public int compareTo(Delayed other) {
if (other == this)
return 0;
OrderDelay t = (OrderDelay) other;
long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
}
// 返回距离超时时间还有多久
public long getDelay(TimeUnit unit) {
return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
}
void print() {
System.out.println(orderId + "编号的订单要删除啦。。。。");
}
}
然后,编写测试代码,模拟5个订单,每个延迟3秒处理:
public class DelayQueueDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("00000001");
list.add("00000002");
list.add("00000003");
list.add("00000004");
list.add("00000005");
DelayQueue<OrderDelay> queue = new DelayQueue<OrderDelay>();
long start = System.currentTimeMillis();
for (int i = 0; i < 5; i++) {
// 延迟三秒取出
queue.put(new OrderDelay(list.get(i), TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS)));
try {
queue.take().print();
System.out.println("After " + (System.currentTimeMillis() - start) + " MilliSeconds");
} catch (InterruptedException e) {
}
}
}
}
输出如下,可以看到每个订单都被精确延迟了3秒处理:
00000001编号的订单要删除啦。。。。
After 3003 MilliSeconds
00000002编号的订单要删除啦。。。。
After 6006 MilliSeconds
00000003编号的订单要删除啦。。。。
After 9006 MilliSeconds
00000004编号的订单要删除啦。。。。
After 12008 MilliSeconds
00000005编号的订单要删除啦。。。。
After 15009 MilliSeconds
优点:任务触发延迟极低(毫秒级),效率高。
缺点:
- 数据易失:任务信息存储在内存中,服务器重启或宕机会导致数据全部丢失。
- 集群扩展困难:内存队列难以在多个服务实例间同步。
- 内存限制:海量延时订单会占用大量JVM内存,可能导致
OOM(内存溢出)。
- 代码复杂度:需要自行处理任务的持久化和分布式协调问题。对于需要深入理解 Java 并发编程的开发者来说,这是一个很好的练习场景。
3)时间轮算法(HashedWheelTimer)
思路
时间轮可以类比于钟表。一个时间轮被分为多个槽位(tick),一个指针按固定频率跳动。每次跳动到一个槽位,就处理该槽位上的所有任务。如果任务延迟时间超过了轮子一圈的时长,可以通过圈数来记录。

例如,一个8槽的时间轮,每1秒跳动一格(tickDuration=1s)。一个20秒后执行的任务,指针需要转2整圈(20 / 8 = 2 圈余数4),最终落在第5个槽位(4 + 1)上。
实现
Netty 提供了高效的时间轮实现 HashedWheelTimer。首先添加依赖:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.24.Final</version>
</dependency>
然后,创建延时任务:
public class HashedWheelTimerTest {
static class MyTimerTask implements TimerTask {
boolean flag;
public MyTimerTask(boolean flag) {
this.flag = flag;
}
public void run(Timeout timeout) throws Exception {
System.out.println("要去数据库删除订单了。。。。");
this.flag = false;
}
}
public static void main(String[] argv) {
MyTimerTask timerTask = new MyTimerTask(true);
Timer timer = new HashedWheelTimer();
timer.newTimeout(timerTask, 5, TimeUnit.SECONDS);
int i = 1;
while (timerTask.flag) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i + "秒过去了");
i++;
}
}
}
输出如下,任务在5秒后被执行:
1秒过去了
2秒过去了
3秒过去了
4秒过去了
5秒过去了
要去数据库删除订单了。。。。
6秒过去了
优点:效率比DelayQueue更高,代码复杂度相对较低,延迟精度也较好。
缺点:与DelayQueue类似,存在数据易失、集群扩展麻烦和内存OOM风险等问题。它更适合处理大量高性能的内部延时调度,而非核心业务数据。
4)基于Redis的实现
Redis因其高性能和丰富的数据结构,常被用于实现延时任务。这里介绍两种主流方法。
思路一:利用ZSet有序集合
Redis的ZSet(有序集合)每个元素都有一个分数(score),可以根据分数进行排序。我们可以将订单的超时时间戳作为分数,订单号作为成员存入ZSet。然后,启动一个后台进程或定时任务,不断查询ZSet中分数最小(即最先超时)的元素,判断其是否已超时并进行处理。
其原理如下图所示:

实现
public class AppTest {
private static final String ADDR = "127.0.0.1";
private static final int PORT = 6379;
private static JedisPool jedisPool = new JedisPool(ADDR, PORT);
public static Jedis getJedis() {
return jedisPool.getResource();
}
// 生产者:生成5个订单,延迟3秒
public void productionDelayMessage() {
for (int i = 0; i < 5; i++) {
Calendar cal1 = Calendar.getInstance();
cal1.add(Calendar.SECOND, 3);
int second3later = (int) (cal1.getTimeInMillis() / 1000);
AppTest.getJedis().zadd("OrderId", second3later, "OID0000001" + i);
System.out.println(System.currentTimeMillis() + "ms:redis生成了一个订单任务:订单ID为" + "OID0000001" + i);
}
}
// 消费者:循环处理超时订单
public void consumerDelayMessage() {
Jedis jedis = AppTest.getJedis();
while (true) {
Set<Tuple> items = jedis.zrangeWithScores("OrderId", 0, 1); // 取出分数最小的元素
if (items == null || items.isEmpty()) {
System.out.println("当前没有等待的任务");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
int score = (int) ((Tuple) items.toArray()[0]).getScore();
Calendar cal = Calendar.getInstance();
int nowSecond = (int) (cal.getTimeInMillis() / 1000);
if (nowSecond >= score) { // 判断是否超时
String orderId = ((Tuple) items.toArray()[0]).getElement();
jedis.zrem("OrderId", orderId); // 移除已处理订单
System.out.println(System.currentTimeMillis() + "ms:redis消费了一个任务:消费的订单OrderId为" + orderId);
}
}
}
public static void main(String[] args) {
AppTest appTest = new AppTest();
appTest.productionDelayMessage();
appTest.consumerDelayMessage();
}
}
运行后,可以看到订单大约在3秒后被消费。

并发问题与解决方案
在高并发场景下,多个消费者线程可能同时读到同一个超时订单,导致重复消费。解决方法是利用zrem命令的返回值:只有当返回值大于0时,才表示当前线程成功移除并抢到了该任务。
将消费逻辑修改为:
if(nowSecond >= score){
String orderId = ((Tuple)items.toArray()[0]).getElement();
Long num = jedis.zrem("OrderId", orderId);
if( num != null && num>0){ // 判断是否抢占成功
System.out.println(System.currentTimeMillis()+"ms:redis消费了一个任务:消费的订单OrderId为"+orderId);
}
}
优点:数据持久化,服务重启不丢失;利用 Redis 易实现分布式集群;时间精度高。
缺点:需要额外维护一个消费者进程进行轮询;在极端高并发下仍需处理竞争问题。
思路二:利用键空间通知(Keyspace Notifications)
Redis 提供了键过期事件通知功能。我们可以为每个订单在Redis中设置一个键,并为其设置过期时间(如30分钟)。当键过期时,Redis会发布一个事件,我们的应用订阅这个事件即可执行取消订单的逻辑。
实现
首先,修改Redis配置文件redis.conf,开启键空间事件通知:
notify-keyspace-events Ex
然后,编写客户端代码进行订阅和处理:
public class RedisTest {
private static final String ADDR = "127.0.0.1";
private static final int PORT = 6379;
private static JedisPool jedis = new JedisPool(ADDR, PORT);
private static RedisSub sub = new RedisSub();
public static void init() {
new Thread(new Runnable() {
public void run() {
jedis.getResource().subscribe(sub, "__keyevent@0__:expired"); // 订阅过期事件
}
}).start();
}
public static void main(String[] args) throws InterruptedException {
init();
for (int i = 0; i < 10; i++) {
String orderId = "OID000000" + i;
jedis.getResource().setex(orderId, 3, orderId); // 设置键,3秒后过期
System.out.println(System.currentTimeMillis() + "ms:" + orderId + "订单生成");
}
}
static class RedisSub extends JedisPubSub {
public void onMessage(String channel, String message) {
System.out.println(System.currentTimeMillis() + "ms:" + message + "订单取消");
}
}
}
运行后,订单在3秒过期时触发了取消操作。

重要缺陷:Redis的发布/订阅(Pub/Sub)模式是“即发即弃”的。如果订阅事件的客户端在键过期时恰好断开连接,那么它将永久丢失这条通知,导致订单未被正确处理。因此,此方案不适用于要求高可靠性的业务场景。
优点:实现简单,无需主动轮询,延迟精度高。
缺点:存在消息丢失风险,可靠性低。
5)使用消息队列(如RabbitMQ)
专业的消息队列中间件是处理延时任务的强大工具。以RabbitMQ为例,它通过“死信交换器”机制来实现延时队列。
思路
- 为消息或队列设置TTL(生存时间),超时的消息会变成“死信”。
- 配置一个专门处理死信的交换器和队列。
- 当普通队列中的消息过期后,会被自动路由到死信队列,消费者从死信队列中获取并处理这些“延时到期”的消息。
优点:
- 高效可靠:利用消息队列本身的高性能和持久化机制。
- 易于扩展:天然支持分布式,可以方便地进行横向扩展。
- 功能完善:具备ACK机制、重试等,保证消息的可靠投递。
缺点:
- 系统复杂度增加:需要引入并维护RabbitMQ集群。
- 成本提高:增加了外部依赖和运维成本。
对于构建高可用的 后端架构 来说,引入成熟的消息队列来处理异步和解耦场景是非常常见的选择。
总结与选择
| 方案 |
优点 |
缺点 |
适用场景 |
| 数据库轮询 |
简单,支持集群 |
延迟大,耗资源,伤数据库 |
数据量小,对实时性要求低的低频任务 |
| JDK DelayQueue |
延迟低,效率高 |
数据易失,内存受限,难集群 |
单机、高性能、允许任务丢失的内部场景 |
| 时间轮算法 |
比DelayQueue效率更高 |
同DelayQueue |
单机、海量短时延任务(如连接超时管理) |
| Redis ZSet |
数据持久,可集群,精度高 |
需额外轮询,有并发竞争 |
分布式系统,数据量中等,要求可靠 |
| Redis 过期通知 |
实现简单,无需轮询 |
有消息丢失风险,不可靠 |
对可靠性要求不高的辅助性任务 |
| 消息队列 |
可靠,可扩展,功能全 |
系统复杂,有运维成本 |
企业级应用,高并发,要求高可靠性和可扩展性 |
在选择具体方案时,需要综合考虑数据量、实时性要求、系统可靠性以及团队运维能力。对于像“订单超时自动取消”这样的核心业务,建议优先考虑Redis ZSet或RabbitMQ延时队列方案,它们在可靠性、性能和分布式支持上取得了较好的平衡。理解这些方案的原理和优劣,也是应对 面试求职 中系统设计类问题的关键。
本文探讨了多种技术实现,更多关于系统架构、高并发处理及微服务实践的深度讨论,欢迎访问 云栈社区 与广大开发者共同交流学习。