前言

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

业务场景

我们以订单功能为例说明下:生成订单后一段时间不支付订单会自动关闭。最简单的想法是设置定时任务轮询,但是每个订单的创建时间不一样,定时任务的规则无法设定,如果将定时任务执行的间隔设置的过短,太影响效率。还有一种想法,在用户进入订单界面的时候,判断时间执行相关操作。

一、定时任务

一般情况下,最不推荐的方式就是关单方式就是定时任务方式,原因我们可以看下面的图来说明

springboot集成支付宝设置订单超时 支付宝订单超时关闭_java

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

二、RabbitMQ延迟队列

我们在下单后,往MQ投递一个消息,设置其有效期为30分钟,在不设置对应队列的消费者的情况下,该消息将一直不被消费,那么30分钟后,该消息过期会被投递到死信队列,由死信消费者消费,我们就可以在死信消费者根据订单id执行相应的业务逻辑

  • MQ的配置:
#配置virtual-host虚拟主机
spring.rabbitmq.virtual-host=test_order_close
#ip地址
spring.rabbitmq.host=127.0.0.1
#用户名  密码
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
#连接端口号
spring.rabbitmq.port=5672

#死信队列
test.dlx.exchange=test_order_dlx_exchange
test.dlx.queue=test_order_dlx_queue
test.dlx.routingKey=dlx

##备胎交换机
test.order.exchange=test_order_exchange
test.order.queue=test_order_queue
test.order.routingKey=test.order
  • 死信的config类
@Component
public class DeadLetterMQConfig {

    /**
     * 订单交换机
     */
    @Value("${test.order.exchange}")
    private String orderExchange;

    /**
     * 订单队列
     */
    @Value("${test.order.queue}")
    private String orderQueue;

    /**
     * 订单路由key
     */
    @Value("${test.order.routingKey}")
    private String orderRoutingKey;

    /**
     * 死信交换机
     */
    @Value("${test.dlx.exchange}")
    private String dlxExchange;

    /**
     * 死信队列
     */
    @Value("${test.dlx.queue}")
    private String dlxQueue;
    /**
     * 死信路由
     */
    @Value("${test.dlx.routingKey}")
    private String dlxRoutingKey;

    /**
     * 声明死信交换机
     */
    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange(dlxExchange);
    }

    /**
     * 声明死信队列
     */
    @Bean
    public Queue dlxQueue() {
        return new Queue(dlxQueue);
    }

    /**
     * 声明订单业务交换机
     */
    @Bean
    public DirectExchange orderExchange() {
        return new DirectExchange(orderExchange);
    }

    /**
     * 声明订单队列 核心操作一
     */
    @Bean
    public Queue orderQueue() {
        Map<String, Object> arguments = new HashMap<>(2);
        // 绑定我们的死信交换机
        arguments.put("x-dead-letter-exchange", dlxExchange);
        // 绑定我们的路由key
        arguments.put("x-dead-letter-routing-key", dlxRoutingKey);
        return new Queue(orderQueue, true, false, false, arguments);
    }

    /**
     * 绑定订单队列到订单交换机
     */
    @Bean
    public Binding orderBinding() {
        return BindingBuilder.bind(orderQueue()).to(orderExchange()).with(orderRoutingKey);
    }

    /**
     * 绑定死信队列到死信交换机
     */
    @Bean
    public Binding binding() {
        return BindingBuilder.bind(dlxQueue()).to(dlxExchange()).with(dlxRoutingKey);
    }
}
  • Controller
@GetMapping("/addOrder")
    public String addOrder(){
        String orderId=System.currentTimeMillis()+"";
        OrderEntity orderEntity=new OrderEntity("订单过期测试",orderId,0);
        int result= orderMapper.addOrder(orderEntity);
        if(result<=0){
            return "fail";
        }
        rabbitTemplate.convertAndSend(orderExchange,orderRoutingKey,orderId,messagePostProcessor());
        return "success";
    }

    //处理待发送消息
    private MessagePostProcessor messagePostProcessor(){
        return  new MessagePostProcessor() {
            @Override
            public Message postProcessMessage(Message message) throws AmqpException {
                //设置有效期20秒
                message.getMessageProperties().setExpiration("20000");
                return message;
            }
        };
    }
  • 死信消费者
@Component
public class OrderDlxConsumer {

    @Autowired
    private OrderMapper orderMapper;

    //监听死信队列
    @RabbitListener(queues = "test_order_dlx_queue")
    public void orderConsumer(String orderId) {
        System.out.println("死信队列获取消息:" + orderId);
        if (StringUtils.isEmpty(orderId)) {
            return;
        }
        //根据id查询
        OrderEntity orderEntity = orderMapper.getOrder(orderId);
        if (null == orderEntity) {
            return;
        }
        //获取状态
        Integer orderStatus=orderEntity.getOrderStatus();
        //判断未支付 , 关闭订单
        if(0==orderStatus){
            orderMapper.updateStatus(orderId,2);
        }
    }
}
  • 测试

springboot集成支付宝设置订单超时 支付宝订单超时关闭_rabbitmq_02


接口测试工具调用我们写的controller方法新增成功后数据库表中会新增一条status为0的记录

springboot集成支付宝设置订单超时 支付宝订单超时关闭_分布式_03

  • 过期时间到了之后会执行死信消费者的方法

springboot集成支付宝设置订单超时 支付宝订单超时关闭_spring_04

三、Redis过期监听

在生成订单时,向 Redis 中增加一个 KV 键值对,K 为订单号,保证通过 K 能定位到数据库中的某个订单即可,V 可为任意值。假设,生成订单时向 Redis 中存放 K 为订单号,V 也为订单号的键值对,并设置过期时间为 30 分钟,如果该键值对在 30 分钟过期后能够发送给程序一个通知,或者执行一个方法,那么即可解决订单关闭问题。实现:通过监听 Redis 提供的过期队列来实现,监听过期队列后,如果 Redis 中某一个 KV 键值对过期了,那么将向监听者发送消息,监听者可以获取到该键值对的 K,注意,是获取不到 V 的,因为已经过期了,这就是上面所提到的,为什么要保证能通过 K 来定位到订单,而 V 为任意值即可。拿到 K 后,通过 K 定位订单,并判断其状态,如果是未支付,更新为关闭,或者取消状态即可。

修改 redis 相关事件配置。找到 redis 配置文件 redis.conf,查看 notify-keyspace-events 配置项,如果没有,添加 notify-keyspace-events Ex,如果有值,则追加 Ex,相关参数说明如下:

  • 引入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  • 配置

定义配置 RedisListenerConfig 实现监听 Redis key 过期时间

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisListenerConfig {

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

定义监听器 RedisKeyExpirationListener,实现KeyExpirationEventMessageListener 接口,查看源码发现,该接口监听所有 db 的过期事件 keyevent@*:expired

import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;

/**
 * 监听所有db的过期事件__keyevent@*__:expired"
 */
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    /**
     * 针对 redis 数据失效事件,进行数据处理
     * @param message
     * @param pattern
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {

        // 获取到失效的 key,进行取消订单业务处理
        String expiredKey = message.toString();
        System.out.println(expiredKey);
    }
}

总结

以上方法只是个人对于关单的一些想法,可能有些地方有疏漏,当然如果你有更好的关单方式也可以随时在下方评论区沟通交流,另外期待您的关注和回复,谢谢!