油墨香^_^头像

面试官:怎么不用定时任务实现关闭订单?

一、面试现场:这道题到底在考什么

很多候选人在面试中被问到「不用定时任务,怎么实现关闭超时订单」时,第一反应往往是:写一个定时任务,每隔 1 分钟扫一遍数据库,把超过 30 分钟未支付的订单置为「已关闭」。这个回答没有错,它在绝大多数中小项目里也确实能跑通,但如果你只停留在这个层面,面试官大概率会继续追问三个问题:

  • 扫描效率:订单表数据量到千万、亿级以后,每次全表扫描还要带状态和创建时间条件,数据库扛得住吗?

  • 时间精度:用户要求「下单后 30 分钟未支付就关闭」,你的定时任务 1 分钟跑一次,最坏情况下订单会在 31 分钟才被关闭,这个延迟能接受吗?如果要精确到秒呢?

  • 分布式问题:服务部署了 10 个实例,定时任务每个实例都跑一遍,订单会不会被重复处理?怎么保证只处理一次?

这三个追问,本质上是在考察你对「延迟任务」这个经典场景的理解深度。所谓「不用定时任务」,真正的含义并不是完全抛弃「按时间触发执行」这个思想,而是抛弃那种「固定周期、全量扫描、靠轮询数据库兜底」的笨重方案,转而使用更精准、更高效、更适合分布式场景的延迟任务机制。

本篇文章会系统梳理 8 种主流实现方案,从单机内存方案到分布式消息队列方案,从 Redis 到时间轮算法,逐一拆解原理、给出可运行的代码示例,并对比各自的优缺点和适用场景。读完以后,你不仅能从容回答这道面试题,还能在真实项目中做出正确的技术选型。

本文默认技术栈为 Java 生态,示例代码基于 JDK 8+、Spring Boot 2.7.x、Redis 6.x,消息队列部分同时覆盖 RocketMQ 和 RabbitMQ。

二、先理解需求:关闭订单到底难在哪

在电商系统中,用户提交订单后通常会在页面停留一段时间完成支付。为了避免用户恶意锁库存、也为了给后续库存回流和数据分析提供准确口径,系统一般会规定一个支付超时时间,比如 30 分钟。订单超过 30 分钟仍未支付,就需要被自动关闭,同时做三件事:

  • 把订单状态从「待支付」改为「已关闭」;

  • 释放订单占用的库存;

  • 如果用户使用了优惠券、积分等资产,还需要退还。

这个需求的特殊之处在于:它不是「立刻执行」,也不是「固定周期执行」,而是「在未来的某个不确定的时间点执行」。这类需求在计算机领域有一个专门的名称——延迟任务(Delayed Task)。延迟任务的核心诉求有三个:

  1. 触发时间可控:能在指定时间点或指定延迟时长后触发;

  2. 触发尽量准时:误差要小,尤其在营销抢购场景;

  3. 只执行一次:在分布式环境下不能重复消费,否则可能重复释放库存、重复退还优惠券。

理解了这三点,我们再去审视各种方案,就有了评判标准。下面正式进入方案拆解。

三、方案一:JDK DelayQueue 延迟队列

3.1 原理剖析

DelayQueue 是 JDK 自带的一个无界阻塞队列,位于 java.util.concurrent 包下。它的核心数据结构是优先级队列 PriorityQueue,底层是一个二叉堆。队列中的元素必须实现 Delayed 接口,该接口要求实现两个方法:

  • getDelay(TimeUnit unit):返回距离到期还有多长时间;

  • compareTo(Delayed o):定义元素在队列中的排序规则。

PriorityQueue 会根据 compareTo 的排序结果,始终把「最早到期」的元素放在堆顶。消费者线程调用 take() 方法时,会先检查堆顶元素的 getDelay 是否小于等于 0:如果还没到期,线程就通过 Condition 进入等待状态,直到有新的更早到期元素入队,或者等待时间结束;如果已经到期,就直接弹出该元素返回。这样,消费者线程就实现了「等到订单该关闭的时候再处理」的效果,不需要傻傻地每隔 N 秒轮询一次。

3.2 代码实现

第一步,定义一个订单延迟任务,实现 Delayed 接口:

java

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * 订单延迟关闭任务
 */
public class OrderDelayTask implements Delayed {

    /** 订单号 */
    private final String orderNo;

    /** 订单到期时间,单位:毫秒时间戳 */
    private final long expireTime;

    public OrderDelayTask(String orderNo, long delay, TimeUnit timeUnit) {
        this.orderNo = orderNo;
        this.expireTime = System.currentTimeMillis() + timeUnit.toMillis(delay);
    }

    public String getOrderNo() {
        return orderNo;
    }

    /**
     * 返回距离到期还有多久
     */
    @Override
    public long getDelay(TimeUnit unit) {
        long diff = expireTime - System.currentTimeMillis();
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    /**
     * 按到期时间升序排列,最早到期的排在最前面
     */
    @Override
    public int compareTo(Delayed other) {
        if (other == this) {
            return 0;
        }
        long diff = getDelay(TimeUnit.MILLISECONDS)
                - other.getDelay(TimeUnit.MILLISECONDS);
        return diff > 0 ? 1 : (diff < 0 ? -1 : 0);
    }
}

第二步,编写延迟队列管理器和消费者。在 Spring Boot 项目中,我们可以用一个 @Component 承载队列,引擎启动时开启一个守护线程持续消费:

java

import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.concurrent.DelayQueue;

@Component
public class OrderCloseManager {

    private final DelayQueue<OrderDelayTask> queue = new DelayQueue<>();

    private volatile boolean running = true;

    private Thread worker;

    /**
     * 项目启动时开启消费者线程
     */
    @PostConstruct
    public void start() {
        worker = new Thread(this::consume, "order-close-thread");
        worker.setDaemon(true);
        worker.start();
    }

    /**
     * 下单成功后调用,把订单放入延迟队列
     */
    public void submit(String orderNo, long delaySeconds) {
        queue.offer(new OrderDelayTask(orderNo, delaySeconds, TimeUnit.SECONDS));
    }

    private void consume() {
        while (running) {
            try {
                // take 会阻塞,直到最早到期的任务可以出队
                OrderDelayTask task = queue.take();
                String orderNo = task.getOrderNo();
                // 1. 查询订单当前状态,防止重复关闭
                // 2. 若仍为待支付,则执行关闭操作:改状态、释放库存、退还优惠券
                closeOrder(orderNo);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }

    private void closeOrder(String orderNo) {
        // 实际项目中这里调用订单服务完成关闭逻辑
        System.out.println("关闭订单: " + orderNo);
    }

    @PreDestroy
    public void shutdown() {
        running = false;
        if (worker != null) {
            worker.interrupt();
        }
    }
}

使用方式非常简单,用户下单成功后调用:

java

orderCloseManager.submit("20261006220001", 30 * 60);

3.3 优缺点与适用场景

优点:

  • JDK 原生支持,零额外依赖,实现简单;

  • 基于阻塞队列,不轮询、不空转,到点才唤醒,CPU 友好;

  • 单机吞吐量尚可,适合中小规模任务。

缺点:

  • 无持久化:任务保存在 JVM 内存中,服务重启后所有待关闭订单全部丢失,需要配合「启动扫描兜底」才能保证不丢单;

  • 不可横向扩展:队列在单机内存里,多实例部署时每个实例各维护一份队列,订单在 A 实例入队,B 实例根本感知不到,除非配合外部路由;

  • 内存占用:百万级任务常驻内存会有压力;

  • 缺少可靠的重试和监控机制:任务执行失败后无法自动重投,需要开发者自己补。

适用场景:单机服务、任务量不大、对可靠性要求不高的内部系统或演示项目。生产级电商系统不建议单独使用。

3.4 面试追问:DelayQueue 底层为什么不用简单链表?

如果面试官追问底层,可以回答:因为核心操作是「取最早到期的元素」和「插入新元素」。如果用链表,取最早到期元素在最坏情况下要遍历整条链,时间复杂度 O(n);而 PriorityQueue 基于二叉堆,插入是 O(log n),取堆顶是 O(1),删除堆顶后重建堆是 O(log n)。对延迟队列这种「高并发入队 + 常取出堆顶」的场景,堆结构显然更合适。此外,take() 未到期时通过 Condition.awaitNanos 限时等待,既避免了忙轮询,又能在新元素入队时被 signal 提前唤醒,兼顾了实时性和 CPU 开销。

四、方案二:时间轮算法 TimeWheel

4.1 从 DelayQueue 的痛点引出时间轮

DelayQueue 虽然取堆顶是 O(1),但每次插入和删除都要做 O(log n) 的堆调整。当任务并发量极大,比如秒杀场景下每秒几十万订单入队,堆调整的开销就不可忽视了。有没有一种「入队 O(1)、出队 O(1)」的数据结构?答案就是时间轮(TimeWheel)。

时间轮的模型非常直观:想象一个圆形时钟,刻度把圆周分成 N 格,每格代表一个时间片段(tick)。一个指针沿着刻度匀速转动,每跳一格就处理该格子里挂载的所有任务。任务进来时,根据它距离当前时间还差多少个 tick,算出它应该挂到哪一格上。

4.2 单层时间轮的原理

假设我们实现一个单层时间轮:

  • tick 间隔为 1 秒;

  • 一圈共 60 格,即最多能表示 60 秒内的延迟任务;

  • 用一个 List 数组存放任务,数组下标就是格子编号。

当一个订单需要在 45 秒后关闭时,计算方式为:格子编号 = (当前指针位置 + 45) % 60,然后把任务追加到对应格子的链表尾部。指针每秒前进一格,走到该格时,遍历链表,执行所有到期任务。

单层时间轮的问题很明显:如果延迟 600 秒,超过了 60 格的表示范围,就会取模落到错误的格子,导致任务被提前执行。所以单层时间轮只适合「延迟范围固定且不大」的场景。解决办法是多层时间轮。

4.3 多层时间轮的原理

多层时间轮类似「钟表时分秒」的设计:

  • 第一层(秒针):60 格,每格 1 秒,转一圈 60 秒;

  • 第二层(分针):60 格,每格 60 秒,转一圈 3600 秒;

  • 第三层(时针):24 格,每格 3600 秒,转一圈 24 小时。

当任务延迟 100 秒时,100 秒超过秒针一圈的范围,就把它挂到分针层:先计算分针格 100 / 60 = 1,余数 100 % 60 = 40。当分针走到第 1 格时,该格里的任务会被「降级」——从分针层摘除,重新计算后在秒针层挂到第 40 格。这样,任意长度的延迟都能通过「上层降级到下层」的方式渐进逼近到期时间。

Netty 的 HashedWheelTimer 和 Kafka 内部的延迟操作管理,都用到了时间轮思想。Kafka 甚至针对「精度」和「范围」的矛盾设计了「层级时间轮 + 延迟队列」混合结构,这是面试中的高频加分点。

4.4 Netty HashedWheelTimer 实战

Netty 提供了现成的 HashedWheelTimer,引入依赖后可以直接使用:

xml

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-common</artifactId>
    <version>4.1.100.Final</version>
</dependency>

构造时间轮并提交延迟任务:

java

import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import io.netty.util.TimerTask;
import java.util.concurrent.TimeUnit;

public class NettyTimeWheelDemo {

    public static void main(String[] args) throws InterruptedException {
        // 每格 1 秒,一圈 60 格
        HashedWheelTimer timer = new HashedWheelTimer(1, TimeUnit.SECONDS, 60);

        TimerTask closeTask = new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                // 这里执行订单关闭逻辑
                System.out.println("时间到,执行订单关闭");
            }
        };

        // 30 分钟后执行
        timer.newTimeout(closeTask, 30, TimeUnit.MINUTES);

        // 防止主线程退出
        Thread.sleep(60_000);
        timer.stop();
    }
}

构造器的三个核心参数分别是:tickDuration(每格时长)、unit(时间单位)、ticksPerWheel(每圈格数)。Netty 的 HashedWheelTimer 本质是单层时间轮,它会把 ticksPerWheel 向上取整到 2 的幂次方,用位运算代替取模提升性能。延迟超过一圈范围的任务会挂到 timer 内部的普通任务队列,由工作线程在空闲时重新计算并放入轮盘,相当于用「轮盘 + 溢出队列」的方式变相扩容。

4.5 自实现一个简化时间轮

理解原理最好的方式是自己写一遍。下面是一个极简版单层时间轮:

java

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

/**
 * 极简单层时间轮
 */
public class SimpleTimeWheel {

    /** 每格时长,单位毫秒 */
    private final long tickMs;

    /** 一圈格数 */
    private final int wheelSize;

    /** 格子数组,每个格子装一个任务链表 */
    private final List<List<TimerTaskEntry>> wheel;

    /** 当前指针位置 */
    private int currentIndex = 0;

    private final ScheduledExecutorService scheduler =
            Executors.newSingleThreadScheduledExecutor();

    public SimpleTimeWheel(long tickMs, int wheelSize) {
        this.tickMs = tickMs;
        this.wheelSize = wheelSize;
        this.wheel = new ArrayList<>(wheelSize);
        for (int i = 0; i < wheelSize; i++) {
            wheel.add(new ArrayList<>());
        }
    }

    public void start() {
        scheduler.scheduleAtFixedRate(this::advance, tickMs, tickMs, TimeUnit.MILLISECONDS);
    }

    /**
     * 添加延迟任务,delayMs 为延迟毫秒数
     */
    public void addTask(long delayMs, Consumer<Void> action) {
        long deadline = System.currentTimeMillis() + delayMs;
        int ticks = (int) ((delayMs + tickMs - 1) / tickMs);
        int targetIndex = (currentIndex + ticks) % wheelSize;
        wheel.get(targetIndex).add(new TimerTaskEntry(deadline, action));
    }

    /**
     * 指针前进一格,并执行当前格子里所有到期任务
     */
    private void advance() {
        currentIndex = (currentIndex + 1) % wheelSize;
        List<TimerTaskEntry> bucket = wheel.get(currentIndex);
        long now = System.currentTimeMillis();
        List<TimerTaskEntry> ready = new ArrayList<>();
        for (TimerTaskEntry entry : bucket) {
            if (entry.deadline <= now) {
                ready.add(entry);
            }
        }
        bucket.clear();
        for (TimerTaskEntry entry : ready) {
            entry.action.accept(null);
        }
    }

    /**
     * 定时任务包装类
     */
    private static class TimerTaskEntry {
        private final long deadline;
        private final Consumer<Void> action;

        TimerTaskEntry(long deadline, Consumer<Void> action) {
            this.deadline = deadline;
            this.action = action;
        }
    }
}

五、方案三:Redis ZSET 有序集合延迟队列

5.1 原理剖析

Redis 的 ZSET(有序集合)天然适合做延迟队列。思路非常巧妙:把「到期时间戳」作为 score,把「订单号」作为 member。这样一个订单加入队列时,自带它在未来何时应该被处理的信息。消费者不需要扫描订单表,只需要不断执行:ZRANGEBYSCORE delay_queue 0 当前时间戳,就能拿到所有已经到期的订单。

但这里有个分布式场景下的关键问题:多个消费者实例同时执行 ZRANGEBYSCORE 会拿到同一批订单,导致重复消费。解决办法是把「获取到期任务」和「从集合中移除任务」两步合并成一步原子操作。Redis 原生支持 Lua 脚本,可以写成:先 ZRANGEBYSCORE 拿到到期成员,再 ZREM 删除这批成员,最后把成员返回给调用方。因为 Redis 单线程执行命令、Lua 脚本整体原子执行,所以多个实例抢任务时,一个订单只会被一个实例移除成功,自然实现了「只处理一次」。

5.2 代码实现

下面基于 Spring Boot 的 StringRedisTemplate 演示。生产环境中建议把 Lua 脚本封装为 DefaultRedisScript,并配合消费者循环拉取。

java

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;

@Component
public class RedisDelayQueue {

    private static final String DELAY_QUEUE_KEY = "order:delay:close";

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 下单成功后,把订单加入延迟队列
     * @param orderNo 订单号
     * @param expireTime 到期时间戳,单位毫秒
     */
    public void addTask(String orderNo, long expireTime) {
        stringRedisTemplate.opsForZSet().add(DELAY_QUEUE_KEY, orderNo, expireTime);
    }

    /**
     * 原子地获取已到期的订单集合
     */
    public List<String> fetchExpired(long now) {
        String lua = "local members = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +
                "if #members > 0 then redis.call('ZREM', KEYS[1], unpack(members)) end " +
                "return members";
        DefaultRedisScript<List> script = new DefaultRedisScript<>(lua, List.class);
        return stringRedisTemplate.execute(script, Collections.singletonList(DELAY_QUEUE_KEY), String.valueOf(now));
    }
}

5.3 消费端的两种处理模式

轮询拉取:起一个线程每隔 1 秒执行 fetchExpired,拿到到期订单后处理。实现简单,但存在 1 秒左右的调度延迟和大量空轮询。

阻塞读取:只取最早到期的订单,计算 expireTime - now,如果还没到期就让当前线程睡到到期时间,再重新取。这样能减少空转,但对时钟漂移和 Redis 网络延迟较敏感。

5.4 优缺点与适用场景

优点:Redis 本身性能高,支持持久化;基于 ZSET 的逻辑简洁,能借助 Redis 天然支持多实例抢任务,横向扩展方便;还能同时复用 Redis 作为缓存、分布式锁等基础设施。

缺点:依赖 Redis 可用性,Redis 故障会影响延迟任务;极端情况下若大量任务到期扎堆,ZREM 后消费者处理失败,需要自己补重试或兜底扫表;Lua 脚本和时钟同步需要细心处理。

适用场景:团队已经使用 Redis、订单量中等偏大、能接受秒级误差的场景,是目前中小企业里非常常见的选择。

六、方案四:RocketMQ 延迟消息

6.1 原理剖析

RocketMQ 原生支持延迟消息,但有一个重要限制:它不支持任意延迟时间,而是内置了 18 个延迟等级,从 1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h。生产者发送消息时指定延迟等级,RocketMQ 内部会把消息先投递到内置的 SCHEDULE_TOPIC_XXXX 主题,由定时任务到期后再投递到业务主题,消费者才能收到。

如果业务规定的超时时间是 30 分钟,恰好落在 18 个等级中的「30m」,用 RocketMQ 就很合适。但如果要 15 分钟、45 分钟这种等级之外的时间,就不能直接满足,需要做映射或补偿处理,这也是面试中经常被追问的点。

6.2 代码实现

生产端发送延迟关闭订单消息:

java

import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;

@Component
public class RocketDelayProducer {

    @Resource
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 发送延迟关闭订单消息
     * delayLevel 参考 RocketMQ 内置等级,5 表示 1 分钟
     */
    public void sendDelayClose(String orderNo, int delayLevel) {
        Message<String> msg = MessageBuilder.withPayload(orderNo).build();
        rocketMQTemplate.syncSend("order-close-topic", msg, 3000, delayLevel);
    }
}

消费端监听并执行关闭逻辑:

java

import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;

@Component
@RocketMQMessageListener(topic = "order-close-topic", consumerGroup = "order-close-consumer")
public class OrderCloseConsumer implements RocketMQListener<String> {

    @Override
    public void onMessage(String orderNo) {
        // 消费时先查询订单状态,确认仍是待支付再关闭,保证幂等
        closeOrder(orderNo);
    }

    private void closeOrder(String orderNo) {
        System.out.println("关闭订单: " + orderNo);
    }
}

6.3 幂等与重试

消息消费必须做幂等,因为 RocketMQ 消费失败后会重试,业务上可能连续收到同一条消息。建议先根据订单号和当前状态判断,只有「待支付」才执行关闭;最稳妥的做法是把幂等键写入 Redis 或数据库唯一约束中,避免重复释放库存、重复退款。

6.4 优缺点与适用场景

优点:天然支持分布式,消息持久化、可靠性高,消费分组和重试机制完善,适合中大型系统。

缺点:延迟等级固定,不能任意指定;需要引入消息队列这一套基础设施,运维成本较高;消费幂等必须自己做。

适用场景:已经有 RocketMQ 技术栈的团队,且「30 分钟关闭订单」这类需求恰好能映射到内置延迟等级的场景。

七、方案五:RabbitMQ 延迟消息

7.1 死信队列 TTL + DLX 方案

RabbitMQ 本身没有原生的延迟消息能力,但可以通过「消息 TTL + 死信交换机(DLX)」组合来实现。核心思路:生产者把消息发到一个没有消费者的「死信队列」,并给消息设置过期时间 TTL;消息到期后,RabbitMQ 会自动把它转发到配置好的死信交换机,再由死信交换机路由到真正的业务队列,消费者从业务队列消费即可。

这个方案的麻烦之处在于:RabbitMQ 在判断消息是否过期时,只会检查队首消息的 TTL。如果有多个订单延迟时间不同,会让「后到期」的消息阻塞「先到期」的消息。解决办法是使用延迟交换机插件,或者为不同延迟时间创建不同队列。

7.2 延迟插件方案

RabbitMQ 官方社区提供了 rabbitmq-delayed-message-exchange 插件。安装后,交换机类型变成 x-delayed-message,可以在消息头中携带 x-delay 指定毫秒级延迟。它内部会把消息暂存起来,到期后再投递到业务队列,支持任意延迟时间,体验比死信队列好很多。

7.3 优缺点与适用场景

优点:可靠性高,管理界面完善,延迟插件支持任意时间;适合已有 RabbitMQ 基础设施的团队。

缺点:插件模式下暂存的消息仍受内存、磁盘和吞吐量限制;死信队列方案对多延迟场景不友好;整体实现和排查链路更长。

适用场景:团队已使用 RabbitMQ,且需要灵活延迟时间的业务。

八、方案六:Redisson 延迟队列

8.1 原理剖析

Redisson 对 Redis 做了深封装,提供了 RDelayedQueue 和 RBlockingQueue 组合,直接暴露 offer(item, delay, timeUnit) 和 take() 接口,屏蔽了 ZSET、Lua 脚本这些底层细节。内部实现本质上仍是基于 Redis 的 ZSET 和定时检查器,但开发者使用起来就像本地阻塞队列一样简单。

8.2 代码实现

生产端把订单放入延迟队列:

java

import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import jakarta.annotation.Resource;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;

@Component
public class RedissonDelayClose {

    @Resource
    private RedissonClient redissonClient;

    public void addTask(String orderNo, long delay, TimeUnit unit) {
        RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue("order:delay:close:queue");
        RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
        delayedQueue.offer(orderNo, delay, unit);
    }
}

消费端持续阻塞读取:

java

import org.redisson.api.RBlockingQueue;
import org.redisson.api.RedissonClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;

@Component
public class RedissonDelayConsumer {

    @Resource
    private RedissonClient redissonClient;

    @PostConstruct
    public void start() {
        new Thread(() -> {
            RBlockingQueue<String> queue = redissonClient.getBlockingQueue("order:delay:close:queue");
            while (true) {
                try {
                    String orderNo = queue.take();
                    closeOrder(orderNo);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }, "redisson-order-close-thread").start();
    }

    private void closeOrder(String orderNo) {
        System.out.println("关闭订单: " + orderNo);
    }
}

8.3 优缺点与适用场景

优点:API 简洁,开发效率高;基于 Redis 实现,具备持久化和分布式能力;Redisson 还提供了看门狗、分布式锁等配套能力。

缺点:黑盒封装导致底层问题定位困难;极端数据量下需要关注 Redis 内存和 Redisson 内部定时器的性能;引入 Redisson 本身也是新依赖。

适用场景:Java 团队、不想重复造轮子,又希望获得 Redis 延迟队列能力的中小型项目。

九、方案七:数据库轮询优化方案

9.1 为什么会误认为「必须全表扫描」

很多人一提到数据库轮询,就想到 select * from t_order where status = '待支付' 然后全扫。其实只要设计好索引和查询条件,数据库轮询也可以很高效。关键前提有两个:订单表上必须建立 (status, expire_time) 联合索引;查询条件必须同时带上过滤性最强的状态列和到期时间范围。这样扫描范围就被限制在「已经超时且仍未支付」的少量记录中,而不是整张表。

9.2 代码实现

查询已经到期的待支付订单:

sql

select id, order_no, expire_time
from t_order
where status = 1
  and expire_time < now()
order by expire_time asc
limit 200;

关闭订单时用「带状态条件的更新」实现乐观锁,天然避免重复关闭:

java

/**
 * 只把「待支付」改成「已关闭」,返回影响行数
 * 影响行数为 1 才表示当前实例抢单成功
 */
int rows = orderMapper.update(
    "update t_order set status = 2, close_time = now() " +
    "where id = ? and status = 1",
    orderId);
if (rows > 0) {
    // 释放库存、退还优惠券
}

9.3 分页与游标优化

若每批处理 200 条,处理完后继续取下一批,直到影响行数为 0。更优秀的做法是使用游标:每次记录最后处理的 id 作为下次查询的起点,避免 limit 大偏移量带来的性能问题。也可以结合 expire_time 做时间游标,每次只查比上次处理时间更晚的记录。

9.4 优缺点与适用场景

优点:实现最简单,不引入额外中间件;借助联合索引和状态条件更新,可靠性和幂等性容易保证;适合对实时性要求不极端(分钟级)的场景。

缺点:仍然有轮询周期带来的固有延迟;订单量极大时对数据库仍有压力;横向扩展需要靠分布式锁或状态条件更新来避免重复处理。

适用场景:中小规模系统、已有数据库基础设施、可以接受分钟级延迟的业务。

十、方案八:分布式定时任务框架

10.1 原理剖析

如果团队已经有 XXL-JOB、Elastic-Job、PowerJob 等分布式定时任务框架,也可以用它来「分片扫描 + 状态条件更新」实现关闭订单。核心思路是:任务按订单维度分片,每个分片只扫描一部分订单;每个实例只处理属于自己的分片,天然避免重复。再配合 where status = 1 的条件更新,保证即使分片重叠也不会重复关闭。

10.2 代码实现

以 XXL-JOB 为例,任务处理器大致如下:

java

@XxlJob("closeExpiredOrderJob")
public void closeExpiredOrderJob() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    List<Order> orders = orderMapper.selectExpiredOrders(shardIndex, shardTotal, 200);
    for (Order order : orders) {
        int rows = orderMapper.closeIfPending(order.getId());
        if (rows > 0) {
            // 释放库存、退还优惠券
        }
    }
}

10.3 优缺点与适用场景

优点:复用现有调度基础设施,运维成本低;分片机制天然支持横向扩展;任务执行有日志、有重试、有告警。

缺点:仍然是周期扫描,延迟取决于调度周期;分片不均匀时可能有热点;框架本身有学习成本。

适用场景:已经有分布式调度框架、订单量中等、希望快速落地的团队。

十一、八种方案横向对比

方案精度可靠性分布式额外依赖适用规模
JDK DelayQueue高低(内存)不支持无单机小规模
时间轮高低(内存)不支持Netty单机高吞吐
Redis ZSET秒级中高支持Redis中小规模
RocketMQ 延迟消息等级制高支持RocketMQ中大型
RabbitMQ 延迟消息高(插件)高支持RabbitMQ + 插件中大型
Redisson 延迟队列秒级中高支持Redis + Redisson中小规模
数据库轮询优化分钟级高支持无(需索引)中小规模
分布式定时任务分钟级高支持XXL-JOB 等中小规模

十二、面试回答总结

当面试官问「不用定时任务,怎么实现关闭超时订单」时,可以按以下层次回答:

第一层,澄清题意:所谓「不用定时任务」,不是不要「按时间触发」,而是不要「固定周期全量扫描数据库」这种笨重方案。真正要解决的是延迟任务的三个诉求:触发时间可控、尽量准时、分布式下只执行一次。

第二层,给出方案:单机可以用 JDK DelayQueue 或时间轮;分布式可以用 Redis ZSET + Lua、RocketMQ 延迟消息、RabbitMQ 延迟插件、Redisson 延迟队列;如果团队已有分布式调度框架,也可以用分片扫描 + 状态条件更新。数据库轮询经过索引优化后,在中小规模下也是可接受的兜底方案。

第三层,讲清取舍:DelayQueue 和时间轮零依赖但无持久化、不能横向扩展;Redis 方案灵活但依赖 Redis 可用性;RocketMQ 延迟等级固定但可靠性高;RabbitMQ 插件支持任意延迟但依赖插件;数据库轮询最简单但延迟受调度周期限制。

第四层,落到实践:生产环境通常不会只用一种方案,而是「Redis/消息队列做主力 + 数据库轮询做兜底」。同时必须做幂等:关闭订单时用 where status = 1 的条件更新,或用订单号做唯一约束,保证重复消息、重复扫描都不会重复释放库存、重复退款。最后,选择方案时要结合团队已有的技术栈,而不是为了炫技引入一堆新中间件。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_41581588/article/details/167178405

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--