目录
1.redis过期通知
redis中维护一个未支付订单的集合,订单仍然落库。 通过订阅redis的键值对过期事件的通知,在收到过期通知后,后续处理订单的各种取消关联动作,删除订单,删除积分,解锁库存等。 缺点:由于redis不是天生的发布订阅的工作模式,所以没办法做到发送端和接收端的来回通信,也就是说无法进行过期通知投送的失败重试,可靠性差了些。
Redis 从 2.8.0 版本开始支持 键空间通知(Keyspace Notifications),可以通过发布/订阅(Pub/Sub)机制向客户端推送键相关的事件,比如键的过期、删除、修改等。
Redis 键空间通知可以分为两类事件:
-
键空间事件 :表示某个键发生了什么操作(
__keyspace@<db>__:<key>
)。 -
键事件 :表示某类事件发生在哪些键上(
__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吧。
毕竟以上所有方案都是能实现目标的。