【业务场景】订单超时取消的各种方案

目录

1.redis过期通知

2.mq延时消息

3.时间轮

4.延时队列

5.各种方案的优缺点比较


1.redis过期通知

redis中维护一个未支付订单的集合,订单仍然落库。 通过订阅redis的键值对过期事件的通知,在收到过期通知后,后续处理订单的各种取消关联动作,删除订单,删除积分,解锁库存等。 缺点:由于redis不是天生的发布订阅的工作模式,所以没办法做到发送端和接收端的来回通信,也就是说无法进行过期通知投送的失败重试,可靠性差了些。

Redis 从 2.8.0 版本开始支持 键空间通知(Keyspace Notifications),可以通过发布/订阅(Pub/Sub)机制向客户端推送键相关的事件,比如键的过期、删除、修改等。

Redis 键空间通知可以分为两类事件:

  1. 键空间事件 :表示某个键发生了什么操作(__keyspace@<db>__:<key>)。

  2. 键事件 :表示某类事件发生在哪些键上(__keyevent@<db>__:<event>)。

常见的事件类型:

事件类型 描述
set 设置键的值
del 删除键
expired 键过期
evicted 键被 LRU 淘汰
hset 哈希表中设置字段
lpush 列表中插入值
incr 键的值被递增

如何启动键空间通知:

首先需要打开配置。

EX表示的意思是通知过期事件。

E:键事件通知(__keyevent@<db>__:<event> 格式)。

X:过期事件通知。

然后打开一个客户端监听过期事件。

psubscribe "keyevent@0:expired"

另一个客户端设置一个键值的过期周期。

SET mykey "value" EX 10

然后可以看到监听端收到了通知:

对应版本的spring-boot-starter-data-redis是支持该功能的,需要使用的时候查一下即可。

2.mq延时消息

延时消息,即消息发送到mq后驻留一段时间,到期后再推给消费者。目前市面上原生实现延时消息功能的主流MQ只有RocketMQ,也许这和阿里巴巴本身的业务场景有关,RocketMQ诞生于阿里的业务迭代过程,延时消息是阿里订单模块需要的核心能力,所以RocketMQ自带延时消息功能也就不奇怪了。虽然当前其余主流MQ原生并不支持延时消息,但作为一个会用到比较多的一个特性,后续各大主流MQ很大概率会加上,这个大家持续关注即可。这里我用RocketMQ的延时消息特性来实现订单的超期取消。

整个过程如下:

1.下订单的时候在"未付款订单"队列中放入延时30分组的一个订单消息

2.下单的同时也在数据库中创建这个订单,可以用另一个队列来进行这里写数据库的流量削峰,此处省略而已。

3.用户如果付款后去修改数据库里这个订单的状态

4."未付款订单"队列中的这个订单到期,消费者收到这个订单,进行超期处理,比对数据库中的订单状态,已支付就不管,未支付就删除订单、库存回滚等等超期需要进行处理的逻辑

代码示例:

RocketMQ的延时并不是每个消息可以随便定义的,而是定义好不同的级别,然后去调用不同的级别。RocketMQ 默认提供了以下延时级别(可在 Broker 配置文件中修改):

延时级别 时间
1 1 秒
2 5 秒
3 10 秒
4 30 秒
5 1 分钟
6 2 分钟
7 3 分钟
8 4 分钟
9 5 分钟
10 6 分钟
11 7 分钟
12 8 分钟
13 9 分钟
14 10 分钟
15 20 分钟
16 30 分钟
17 1 小时
18 2 小时

自定义延时级别对应的时间 : 可以在 Broker 的 broker.conf 文件中修改 messageDelayLevel 参数,例如:

messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

然后重启 Broker 生效。

依赖:

XML 复制代码
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-client</artifactId>
    <version>4.7.1</version>
</dependency>

代码:

java 复制代码
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.common.RemotingHelper;
​
import java.util.List;
​
public class Test {
    @org.junit.jupiter.api.Test
    public void testProducer() throws Exception {
        //创建消费者,创建的时候可以指定该消费者属于哪个消费者组
        DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
        //指定name server的地址
        producer.setNamesrvAddr("192.168.31.10:9876");
        producer.setSendMsgTimeout(10000);
        producer.start();
        //发送一千条信息
        for (int i = 0; i < 1; i++) {
            try {
                //消息,topic为TopicTest,后面跟的一串是tag
                Message msg = new Message("TopicTest" /* Topic */,
                        "TagA" /* Tag */,
                        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
                );
                //延时消息
                msg.setDelayTimeLevel(16);
                //同步发送
                SendResult sendResult = producer.send(msg);
                /**异步发送,通过自定义回调函数的方式来触发响应
                 producer.send(msg, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                countDownLatch.countDown();
                System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId());
                }
                @Override
                public void onException(Throwable e) {
                countDownLatch.countDown();
                System.out.printf("%-10d Exception %s %n", index, e);
                e.printStackTrace();
                }
                }**/
                System.out.printf("%s%n", sendResult);
            } catch (Exception e) {
                e.printStackTrace();
                //Thread.sleep(1000);
            }
        }
        producer.shutdown();
    }
​
    @org.junit.jupiter.api.Test
    public void testComsumer() throws Exception {
        // 创建消费者,指定消费者组
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");
        // 设置 NameServer 地址
        consumer.setNamesrvAddr("192.168.31.10:9876");
        // 订阅主题和标签(* 表示订阅所有标签)
        consumer.subscribe("TopicTest", "TagA");
​
        // 注册消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
                    // 打印消息内容
                    System.out.printf("Received message: %s%n", new String(msg.getBody()));
                }
                // 消费成功
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
​
        // 启动消费者
        consumer.start();
        System.out.println("Consumer started. Waiting for messages...");
​
        // 为了测试方便,设置消费者运行一段时间后自动停止
        Thread.sleep(30000); // 等待 30 秒消费消息
        consumer.shutdown();
    }
}

3.时间轮

时间轮通过将时间划分为多个时间槽(Slot),每个时间槽存放任务(延时任务)。每个时间轮都包含一个时间刻度(Tick),每当时间轮的当前刻度指针走过一个时间槽时,指针移动到下一个时间槽,执行到期的任务。当任务的延时时间到达时,会被放入对应的时间槽中,时间轮的指针按刻度转动并触发任务的执行。

代码示例:

java 复制代码
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
​
public class TimeWheel {
    // 时间轮的时间槽数量
    private final int slotCount;
    // 时间轮的每个时间槽的时间间隔,单位:毫秒
    private final long tickDuration;
    // 时间轮的槽数组
    private final List<Deque<ScheduledTask>> slots;
    // 当前时间槽的指针
    private AtomicInteger currentSlotIndex;
    // 调度器,周期性执行任务
    private ScheduledExecutorService scheduler;
​
    public TimeWheel(long tickDuration, int slotCount) {
        this.tickDuration = tickDuration;
        this.slotCount = slotCount;
        this.slots = new ArrayList<>(slotCount);
        this.currentSlotIndex = new AtomicInteger(0);
        for (int i = 0; i < slotCount; i++) {
            slots.add(new LinkedList<>());
        }
        this.scheduler = Executors.newScheduledThreadPool(1);
        startWheel();
    }
​
    // 启动时间轮,每 tickDuration 毫秒执行一次
    private void startWheel() {
        scheduler.scheduleAtFixedRate(this::tick, tickDuration, tickDuration, TimeUnit.MILLISECONDS);
    }
​
    // 时间轮每次"滴答"移动,更新指针并执行到期任务
    private void tick() {
        int index = currentSlotIndex.getAndUpdate(i -> (i + 1) % slotCount); // 获取当前时间槽并更新指针
        Deque<ScheduledTask> tasks = slots.get(index);
        // 执行所有到期的任务
        while (!tasks.isEmpty()) {
            ScheduledTask task = tasks.poll();
            task.run();  // 执行任务
        }
    }
​
    // 向时间轮中添加延时任务
    public void addTask(long delay, Runnable task) {
        long ticks = delay / tickDuration;
        if (delay % tickDuration != 0) {
            ticks++;
        }
        int slotIndex = (currentSlotIndex.get() + (int) (ticks % slotCount)) % slotCount;
        slots.get(slotIndex).offer(new ScheduledTask(task));
    }
​
    // 任务类,封装了实际要执行的任务
    private static class ScheduledTask implements Runnable {
        private final Runnable task;
​
        public ScheduledTask(Runnable task) {
            this.task = task;
        }
​
        @Override
        public void run() {
            task.run();  // 执行任务
        }
    }
​
    public static void main(String[] args) {
        TimeWheel timeWheel = new TimeWheel(1000, 10);  // 每秒一个 tick,10 个槽
​
        // 添加任务
        timeWheel.addTask(5000, () -> System.out.println("Task 1 executed after 5 seconds"));
        timeWheel.addTask(15000, () -> System.out.println("Task 2 executed after 15 seconds"));
        timeWheel.addTask(2000, () -> System.out.println("Task 3 executed after 2 seconds"));
    }
}

时间轮算法,时间轮算法如果将数据放在jvm中,会有oom的缺点。 如果将数据放在数据库里,时间轮里只存ID,可以拉高一些时间轮的数据容量,但是吞吐量肯定还是跟不上mq,适合量级中等的延时任务处理。

4.延时队列

JDK自带的延时队列,百度一下就能秒懂,这里就不展开了,本质上还是把任务以线程的方式放在JVM内。

5.各种方案的优缺点比较

名称 优点 缺点
redis过期通知 一般先在系统都会用redis来做登录的持久化,所以使用redis大概率不会引入额外的组件和复杂度;其次数据存在JVM之外不容易引起OOM,支持一定数据量的数据。 消息投递失败后没有重试机制,有一定概率造成消息的丢失。
MQ延时消息 会引入MQ,提升系统复杂度,编码量会有所提升 可靠性最佳、容量最大
时间轮 不会引入额外的系统复杂度 存在JVM内存中,扛不住大数据量,容易OOM
延时队列 不会引入额外的系统复杂度 存在JVM内存中,扛不住大数据量,容易OOM

综上所述可以发现,MQ的延时消息是可靠性最佳、容量最大的一种解决方案。但是!作者有如下观点:

技术、架构应当为业务服务,性能上的提升一定意味着架构复杂度的提升,是否一开始就要用到架构最复杂、性能最极限的解决方案?是值得商榷的。应当是先明确自身需要开发的系统的体量、数据量、并发量,辅以对JVM、Redis、数据库的压力测试,最终在考虑是否要引入MQ吧。

毕竟以上所有方案都是能实现目标的。

相关推荐
猫猫不是喵喵.4 分钟前
【Redis】一人一单秒杀活动
数据库·redis·缓存
信徒_4 分钟前
Redis 和 Mysql 中的数据一致性问题
数据库·redis·mysql
weisian15144 分钟前
Redis篇-19--运维篇1-主从复制(主从复制,读写分离,配置实现,实战案例)
java·运维·redis
weisian1511 小时前
Redis篇-15--数据结构篇7--Sorted Set内存模型(有序集合,跳跃表skip list,压缩列表ziplist)
数据结构·redis·list
alden_ygq3 小时前
etcd详解
linux·redis·etcd
涛粒子4 小时前
Redis 事务
数据库·redis·缓存
knight-n5 小时前
Redis网络模型
java·redis·mybatis
清酒伴风(面试准备中......)6 小时前
面经自测——Redis分布式锁实现/Redis/用Redis实现消息队列的功能怎么做中的RedLock具体解决了什么问题
数据库·redis·分布式·面试·面经·实习·自测
东阳马生架构17 小时前
Redis应用—4.在库存里的应用
redis