找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2515

积分

0

好友

357

主题
发表于 4 天前 | 查看: 16| 回复: 0

mall电商系统技术架构图

在日常开发中,尤其是电商、支付等场景下,我们经常会遇到需要处理延时任务的需求。例如:

  • 生成订单30分钟未支付,则自动取消。
  • 生成订单60秒后,给用户发送短信提醒。

这类任务被称为延时任务。它与我们熟知的定时任务有什么区别呢?主要有以下三点:

  1. 触发时间:定时任务有明确的触发时间(如每天凌晨2点);延时任务则在某事件(如下单)触发后,经过一段指定时间才执行。
  2. 执行周期:定时任务通常按固定周期执行;延时任务一般只执行一次。
  3. 任务数量:定时任务往往是批处理多个任务;延时任务通常是处理单个特定的任务。

下面,我们以“订单超时自动取消”为例,深入分析五种主流的实现方案。

方案分析

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接口。队列只在元素的延迟期满时,才能从中取出元素。

其工作流程如下图所示:

JDK DelayQueue 生产者-消费者模型

  • 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中分数最小(即最先超时)的元素,判断其是否已超时并进行处理。

其原理如下图所示:

Redis 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秒后被消费。

Redis ZSet 方案执行日志

并发问题与解决方案
在高并发场景下,多个消费者线程可能同时读到同一个超时订单,导致重复消费。解决方法是利用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 键过期事件执行日志

重要缺陷:Redis的发布/订阅(Pub/Sub)模式是“即发即弃”的。如果订阅事件的客户端在键过期时恰好断开连接,那么它将永久丢失这条通知,导致订单未被正确处理。因此,此方案不适用于要求高可靠性的业务场景

优点:实现简单,无需主动轮询,延迟精度高。
缺点:存在消息丢失风险,可靠性低。

5)使用消息队列(如RabbitMQ)

专业的消息队列中间件是处理延时任务的强大工具。以RabbitMQ为例,它通过“死信交换器”机制来实现延时队列。

思路

  1. 为消息或队列设置TTL(生存时间),超时的消息会变成“死信”。
  2. 配置一个专门处理死信的交换器和队列。
  3. 当普通队列中的消息过期后,会被自动路由到死信队列,消费者从死信队列中获取并处理这些“延时到期”的消息。

优点

  • 高效可靠:利用消息队列本身的高性能和持久化机制。
  • 易于扩展:天然支持分布式,可以方便地进行横向扩展。
  • 功能完善:具备ACK机制、重试等,保证消息的可靠投递。

缺点

  • 系统复杂度增加:需要引入并维护RabbitMQ集群。
  • 成本提高:增加了外部依赖和运维成本。

对于构建高可用的 后端架构 来说,引入成熟的消息队列来处理异步和解耦场景是非常常见的选择。

总结与选择

方案 优点 缺点 适用场景
数据库轮询 简单,支持集群 延迟大,耗资源,伤数据库 数据量小,对实时性要求低的低频任务
JDK DelayQueue 延迟低,效率高 数据易失,内存受限,难集群 单机、高性能、允许任务丢失的内部场景
时间轮算法 比DelayQueue效率更高 同DelayQueue 单机、海量短时延任务(如连接超时管理)
Redis ZSet 数据持久,可集群,精度高 需额外轮询,有并发竞争 分布式系统,数据量中等,要求可靠
Redis 过期通知 实现简单,无需轮询 有消息丢失风险,不可靠 对可靠性要求不高的辅助性任务
消息队列 可靠,可扩展,功能全 系统复杂,有运维成本 企业级应用,高并发,要求高可靠性和可扩展性

在选择具体方案时,需要综合考虑数据量实时性要求系统可靠性以及团队运维能力。对于像“订单超时自动取消”这样的核心业务,建议优先考虑Redis ZSetRabbitMQ延时队列方案,它们在可靠性、性能和分布式支持上取得了较好的平衡。理解这些方案的原理和优劣,也是应对 面试求职 中系统设计类问题的关键。


本文探讨了多种技术实现,更多关于系统架构、高并发处理及微服务实践的深度讨论,欢迎访问 云栈社区 与广大开发者共同交流学习。




上一篇:μNPU基准评测:端侧推理功耗延迟与内存
下一篇:7nm四芯粒RISC-V Ogopogo:密度超B20019%
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 01:42 , Processed in 1.057713 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表