在电商、支付等领域,往往会有这样的场景,用户下单后放弃支付了,那这笔订单会在指定的时间段后进行关闭操作,细心的你一定发现了像某宝、某东都有这样的逻辑,而且时间很准确,误差在1s内;那他们是怎么实现的呢?
一般的做法有如下几种

定时任务关闭订单

我们假设,关单时间为下单后10分钟,定时任务间隔也是10分钟;通过上图我们看出,如果在第1分钟下单,在第20分钟的时候才能被扫描到执行关单操作,这样误差达到10分钟,这在很多场景下是不可接受的,另外需要频繁扫描主订单号造成网络IO和磁盘IO的消耗,对实时交易造成一定的冲击,所以PASS

rocketmq延迟队列方式

延迟消息 生产者把消息发送到消息服务器后,并不希望被立即消费,而是等待指定时间后才可以被消费者消费,这类消息通常被称为延迟消息。 在RocketMQ开源版本中,支持延迟消息,但是不支持任意时间精度的延迟消息,只支持特定级别的延迟消息。 消息延迟级别分别为1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,共18个级别。
发送延迟消息(生产者)

  1. /**
  2. * 推送延迟消息
  3. * @param topic
  4. * @param body
  5. * @param producerGroup
  6. * @return boolean
  7. */
  8. public boolean sendMessage(String topic, String body, String producerGroup)
  9. {
  10. try
  11. {
  12. Message recordMsg = new Message(topic, body.getBytes());
  13. producer.setProducerGroup(producerGroup);
  14. //设置消息延迟级别,我这里设置14,对应就是延时10分钟
  15. // "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"
  16. recordMsg.setDelayTimeLevel(14);
  17. // 发送消息到一个Broker
  18. SendResult sendResult = producer.send(recordMsg);
  19. // 通过sendResult返回消息是否成功送达
  20. log.info("发送延迟消息结果:======sendResult:{}", sendResult);
  21. DateFormat format =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  22. log.info("发送时间:{}", format.format(new Date()));
  23. return true;
  24. }
  25. catch (Exception e)
  26. {
  27. e.printStackTrace();
  28. log.error("延迟消息队列推送消息异常:{},推送内容:{}", e.getMessage(), body);
  29. }
  30. return false;
  31. }

消费延迟消息(消费者)

  1. /**
  2. * 接收延迟消息
  3. *
  4. * @param topic
  5. * @param consumerGroup
  6. * @param messageHandler
  7. */
  8. public void messageListener(String topic, String consumerGroup, MessageListenerConcurrently messageHandler)
  9. {
  10. ThreadPoolUtil.execute(() ->
  11. {
  12. try
  13. {
  14. DefaultMQPushConsumer consumer = new DefaultMQPushConsumer();
  15. consumer.setConsumerGroup(consumerGroup);
  16. consumer.setVipChannelEnabled(false);
  17. consumer.setNamesrvAddr(address);
  18. //设置消费者拉取消息的策略,*表示消费该topic下的所有消息,也可以指定tag进行消息过滤
  19. consumer.subscribe(topic, "*");
  20. //消费者端启动消息监听,一旦生产者发送消息被监听到,就打印消息,和rabbitmq中的handlerDelivery类似
  21. consumer.registerMessageListener(messageHandler);
  22. consumer.start();
  23. log.info("启动延迟消息队列监听成功:" + topic);
  24. }
  25. catch (MQClientException e)
  26. {
  27. log.error("启动延迟消息队列监听失败:{}", e.getErrorMessage());
  28. System.exit(1);
  29. }
  30. });
  31. }

实现监听类,处理具体逻辑

  1. /**
  2. * 延迟消息监听
  3. *
  4. */
  5. @Component
  6. public class CourseOrderTimeoutListener implements ApplicationListener<ApplicationReadyEvent>
  7. {
  8. @Resource
  9. private MQUtil mqUtil;
  10. @Resource
  11. private CourseOrderTimeoutHandler courseOrderTimeoutHandler;
  12. @Override
  13. public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent)
  14. {
  15. // 订单超时监听
  16. mqUtil.messageListener(EnumTopic.ORDER_TIMEOUT, EnumGroup.ORDER_TIMEOUT_GROUP, courseOrderTimeoutHandler);
  17. }
  18. }
  1. /**
  2. * 实现监听
  3. */
  4. @Slf4j
  5. @Component
  6. public class CourseOrderTimeoutHandler implements MessageListenerConcurrently
  7. {
  8. @Override
  9. public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
  10. for (MessageExt msg : list)
  11. {
  12. // 得到消息体
  13. String body = new String(msg.getBody());
  14. JSONObject userJson = JSONObject.parseObject(body);
  15. TCourseBuy courseBuyDetails = JSON.toJavaObject(userJson, TCourseBuy.class);
  16. // 处理具体的业务逻辑,,,,,
  17. DateFormat format =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  18. log.info("消费时间:{}", format.format(new Date()));
  19. return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
  20. }
  21. }

这种方式相比定时任务好了很多,但是有一个致命的缺点,就是延迟等级只有18种(商业版本支持自定义时间),如果我们想把关闭订单时间设置在15分钟该如何处理呢?显然不够灵活。

rabbitmq死信队列

Rabbitmq本身是没有延迟队列的,只能通过Rabbitmq本身队列的特性来实现,想要Rabbitmq实现延迟队列,需要使用Rabbitmq的死信交换机(Exchange)和消息的存活时间TTL(Time To Live)
死信交换机 一个消息在满足如下条件下,会进死信交换机,记住这里是交换机而不是队列,一个交换机可以对应很多队列。
一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。 上面的消息的TTL到了,消息过期了。
队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。 死信交换机就是普通的交换机,只是因为我们把过期的消息扔进去,所以叫死信交换机,并不是说死信交换机是某种特定的交换机
消息TTL(消息存活时间) 消息的TTL就是消息的存活时间。RabbitMQ可以对队列和消息分别设置TTL。对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。如果队列设置了,消息也设置了,那么会取值较小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。

  1. byte[] messageBodyBytes = "Hello, world!".getBytes();
  2. AMQP.BasicProperties properties = new AMQP.BasicProperties();
  3. properties.setExpiration("60000");
  4. channel.basicPublish("my-exchange", "queue-key", properties, messageBodyBytes);

可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间,两者是一样的效果。只是expiration字段是字符串参数,所以要写个int类型的字符串:当上面的消息扔到队列中后,过了60秒,如果没有被消费,它就死了。不会被消费者消费到。这个消息后面的,没有“死掉”的消息对顶上来,被消费者消费。死信在队列中并不会被删除和释放,它会被统计到队列的消息数中去
处理流程图
谁再用定时任务实现关闭订单(low) - 图1
创建交换机(Exchanges)和队列(Queues)
创建死信交换机
谁再用定时任务实现关闭订单(low) - 图2
如图所示,就是创建一个普通的交换机,这里为了方便区分,把交换机的名字取为:delay
创建自动过期消息队列 这个队列的主要作用是让消息定时过期的,比如我们需要2小时候关闭订单,我们就需要把消息放进这个队列里面,把消息过期时间设置为2小时
谁再用定时任务实现关闭订单(low) - 图3
创建一个一个名为delay_queue1的自动过期的队列,当然图片上面的参数并不会让消息自动过期,因为我们并没有设置x-message-ttl参数,如果整个队列的消息有消息都是相同的,可以设置,这里为了灵活,所以并没有设置,另外两个参数x-dead-letter-exchange代表消息过期后,消息要进入的交换机,这里配置的是delay,也就是死信交换机,x-dead-letter-routing-key是配置消息过期后,进入死信交换机的routing-key,跟发送消息的routing-key一个道理,根据这个key将消息放入不同的队列
创建消息处理队列 这个队列才是真正处理消息的队列,所有进入这个队列的消息都会被处理
谁再用定时任务实现关闭订单(low) - 图4
消息队列的名字为delay_queue2 消息队列绑定到交换机 进入交换机详情页面,将创建的2个队列(delayqueue1和delayqueue2)绑定到交换机上面
谁再用定时任务实现关闭订单(low) - 图5 自动过期消息队列的routing key 设置为delay 绑定delayqueue2
谁再用定时任务实现关闭订单(low) - 图6
delayqueue2 的key要设置为创建自动过期的队列的x-dead-letter-routing-key参数,这样当消息过期的时候就可以自动把消息放入delay_queue2这个队列中了 绑定后的管理页面如下图:
谁再用定时任务实现关闭订单(low) - 图7
当然这个绑定也可以使用代码来实现,只是为了直观表现,所以本文使用的管理平台来操作 发送消息

  1. String msg = "hello word";
  2. MessageProperties messageProperties = newMessageProperties();
  3. messageProperties.setExpiration("6000");
  4. messageProperties.setCorrelationId(UUID.randomUUID().toString().getBytes());
  5. Message message = newMessage(msg.getBytes(), messageProperties);
  6. rabbitTemplate.convertAndSend("delay", "delay",message);

设置了让消息6秒后过期 注意:因为要让消息自动过期,所以一定不能设置delay_queue1的监听,不能让这个队列里面的消息被接受到,否则消息一旦被消费,就不存在过期了
接收消息 接收消息配置好delay_queue2的监听就好了

  1. package wang.raye.rabbitmq.demo1;
  2. import org.springframework.amqp.core.AcknowledgeMode;
  3. import org.springframework.amqp.core.Binding;
  4. import org.springframework.amqp.core.BindingBuilder;
  5. import org.springframework.amqp.core.DirectExchange;
  6. import org.springframework.amqp.core.Message;
  7. import org.springframework.amqp.core.Queue;
  8. import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
  9. import org.springframework.amqp.rabbit.connection.ConnectionFactory;
  10. import org.springframework.amqp.rabbit.core.ChannelAwareMessageListener;
  11. import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
  12. import org.springframework.beans.factory.annotation.Autowired;
  13. import org.springframework.context.annotation.Bean;
  14. import org.springframework.context.annotation.Configuration;
  15. @Configuration
  16. publicclassDelayQueue{
  17. /** 消息交换机的名字*/
  18. publicstaticfinalString EXCHANGE = "delay";
  19. /** 队列key1*/
  20. publicstaticfinalString ROUTINGKEY1 = "delay";
  21. /** 队列key2*/
  22. publicstaticfinalString ROUTINGKEY2 = "delay_key";
  23. /**
  24. * 配置链接信息
  25. * @return
  26. */
  27. @Bean
  28. publicConnectionFactory connectionFactory() {
  29. CachingConnectionFactory connectionFactory = newCachingConnectionFactory("120.76.237.8",5672);
  30. connectionFactory.setUsername("kberp");
  31. connectionFactory.setPassword("kberp");
  32. connectionFactory.setVirtualHost("/");
  33. connectionFactory.setPublisherConfirms(true); // 必须要设置
  34. return connectionFactory;
  35. }
  36. /**
  37. * 配置消息交换机
  38. * 针对消费者配置
  39. FanoutExchange: 将消息分发到所有的绑定队列,无routingkey的概念
  40. HeadersExchange :通过添加属性key-value匹配
  41. DirectExchange:按照routingkey分发到指定队列
  42. TopicExchange:多关键字匹配
  43. */
  44. @Bean
  45. publicDirectExchange defaultExchange() {
  46. returnnewDirectExchange(EXCHANGE, true, false);
  47. }
  48. /**
  49. * 配置消息队列2
  50. * 针对消费者配置
  51. * @return
  52. */
  53. @Bean
  54. publicQueue queue() {
  55. returnnewQueue("delay_queue2", true); //队列持久
  56. }
  57. /**
  58. * 将消息队列2与交换机绑定
  59. * 针对消费者配置
  60. * @return
  61. */
  62. @Bean
  63. @Autowired
  64. publicBinding binding() {
  65. returnBindingBuilder.bind(queue()).to(defaultExchange()).with(DelayQueue.ROUTINGKEY2);
  66. }
  67. /**
  68. * 接受消息的监听,这个监听会接受消息队列1的消息
  69. * 针对消费者配置
  70. * @return
  71. */
  72. @Bean
  73. @Autowired
  74. publicSimpleMessageListenerContainer messageContainer2(ConnectionFactory connectionFactory) {
  75. SimpleMessageListenerContainer container = newSimpleMessageListenerContainer(connectionFactory());
  76. container.setQueues(queue());
  77. container.setExposeListenerChannel(true);
  78. container.setMaxConcurrentConsumers(1);
  79. container.setConcurrentConsumers(1);
  80. container.setAcknowledgeMode(AcknowledgeMode.MANUAL); //设置确认模式手工确认
  81. container.setMessageListener(newChannelAwareMessageListener() {
  82. publicvoid onMessage(Message message, com.rabbitmq.client.Channel channel) throwsException{
  83. byte[] body = message.getBody();
  84. System.out.println("delay_queue2 收到消息 : "+ newString(body));
  85. channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); //确认消息成功消费
  86. }
  87. });
  88. return container;
  89. }
  90. }

这种方式可以自定义进入死信队列的时间;是不是很完美,但是有的小伙伴的情况是消息中间件就是rocketmq,公司也不可能会用商业版,怎么办?那就进入下一节

时间轮算法

谁再用定时任务实现关闭订单(low) - 图8
(1)创建环形队列,例如可以创建一个包含3600个slot的环形队列(本质是个数组)
(2)任务集合,环上每一个slot是一个Set 同时,启动一个timer,这个timer每隔1s,在上述环形队列中移动一格,有一个Current Index指针来标识正在检测的slot。
Task结构中有两个很重要的属性: (1)Cycle-Num:当Current Index第几圈扫描到这个Slot时,执行任务 (2)订单号,要关闭的订单号(也可以是其他信息,比如:是一个基于某个订单号的任务)
假设当前Current Index指向第0格,例如在3610秒之后,有一个订单需要关闭,只需: (1)计算这个订单应该放在哪一个slot,当我们计算的时候现在指向1,3610秒之后,应该是第10格,所以这个Task应该放在第10个slot的Set中 (2)计算这个Task的Cycle-Num,由于环形队列是3600格(每秒移动一格,正好1小时),这个任务是3610秒后执行,所以应该绕3610/3600=1圈之后再执行,于是Cycle-Num=1
Current Index不停的移动,每秒移动到一个新slot,这个slot中对应的Set,每个Task看Cycle-Num是不是0: (1)如果不是0,说明还需要多移动几圈,将Cycle-Num减1 (2)如果是0,说明马上要执行这个关单Task了,取出订单号执行关单(可以用单独的线程来执行Task),并把这个订单信息从Set中删除即可。 (1)无需再轮询全部订单,效率高 (2)一个订单,任务只执行一次 (3)时效性好,精确到秒(控制timer移动频率可以控制精度)

redis过期监听

1.修改redis.windows.conf配置文件中notify-keyspace-events的值 默认配置notify-keyspace-events的值为 “” 修改为 notify-keyspace-events Ex 这样便开启了过期事件
2. 创建配置类RedisListenerConfig(配置RedisMessageListenerContainer这个Bean)

  1. package com.zjt.shop.config;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.context.annotation.Bean;
  4. import org.springframework.context.annotation.Configuration;
  5. import org.springframework.data.redis.connection.RedisConnectionFactory;
  6. import org.springframework.data.redis.core.RedisTemplate;
  7. import org.springframework.data.redis.listener.RedisMessageListenerContainer;
  8. import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
  9. import org.springframework.data.redis.serializer.StringRedisSerializer;
  10. @Configuration
  11. public class RedisListenerConfig {
  12. @Autowired
  13. private RedisTemplate redisTemplate;
  14. /**
  15. * @return
  16. */
  17. @Bean
  18. public RedisTemplate redisTemplateInit() {
  19. // key序列化
  20. redisTemplate.setKeySerializer(new StringRedisSerializer());
  21. //val实例化
  22. redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
  23. return redisTemplate;
  24. }
  25. @Bean
  26. RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
  27. RedisMessageListenerContainer container = new RedisMessageListenerContainer();
  28. container.setConnectionFactory(connectionFactory);
  29. return container;
  30. }
  31. }

3.继承KeyExpirationEventMessageListener创建redis过期事件的监听类

  1. package com.zjt.shop.common.util;
  2. import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
  4. import com.zjt.shop.modules.order.service.OrderInfoService;
  5. import com.zjt.shop.modules.product.entity.OrderInfoEntity;
  6. import com.zjt.shop.modules.product.mapper.OrderInfoMapper;
  7. import lombok.extern.slf4j.Slf4j;
  8. import org.springframework.beans.factory.annotation.Autowired;
  9. import org.springframework.data.redis.connection.Message;
  10. import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
  11. import org.springframework.data.redis.listener.RedisMessageListenerContainer;
  12. import org.springframework.stereotype.Component;
  13. @Slf4j
  14. @Component
  15. public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
  16. public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
  17. super(listenerContainer);
  18. }
  19. @Autowired
  20. private OrderInfoMapper orderInfoMapper;
  21. /**
  22. * 针对redis数据失效事件,进行数据处理
  23. * @param message
  24. * @param pattern
  25. */
  26. @Override
  27. public void onMessage(Message message, byte[] pattern) {
  28. try {
  29. String key = message.toString();
  30. //从失效key中筛选代表订单失效的key
  31. if (key != null && key.startsWith("order_")) {
  32. //截取订单号,查询订单,如果是未支付状态则为-取消订单
  33. String orderNo = key.substring(6);
  34. QueryWrapper<OrderInfoEntity> queryWrapper = new QueryWrapper<>();
  35. queryWrapper.eq("order_no",orderNo);
  36. OrderInfoEntity orderInfo = orderInfoMapper.selectOne(queryWrapper);
  37. if (orderInfo != null) {
  38. if (orderInfo.getOrderState() == 0) { //待支付
  39. orderInfo.setOrderState(4); //已取消
  40. orderInfoMapper.updateById(orderInfo);
  41. log.info("订单号为【" + orderNo + "】超时未支付-自动修改为已取消状态");
  42. }
  43. }
  44. }
  45. } catch (Exception e) {
  46. e.printStackTrace();
  47. log.error("【修改支付订单过期状态异常】:" + e.getMessage());
  48. }
  49. }
  50. }

4:测试 通过redis客户端存一个有效时间为3s的订单:
谁再用定时任务实现关闭订单(low) - 图9
结果:
谁再用定时任务实现关闭订单(low) - 图10