面试场景题:电商平台中订单未支付过期如何实现自动关单?

日常开发中,我们经常遇到这种业务场景,如:外卖订单超 30 分钟未支付,则自动取订单;用户注册成功 15 分钟后,发短信息通知用户等等。这就延时任务处理场景。

在电商,支付等系统中,一设都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类以的场景有很多,还有比如到期自动收货超时自动退款下单后自动发送短信等等都是类似的业务问题。

定时任务

    @Scheduled(cron = "0/10 * * * * ? ")
    public void run() {
        log.info("查询超时订单并关闭");
    }
  • 优点:实现容易,成本低,基本不依赖其他组件。
  • 缺点
    • 时间可能不够精确。由于定时任务扫描的间隔是固定的,所以可能造成一些订单已经过期了一段时间才被扫描到,订单关闭的时间比正常时间晚一些。
    • 增加了数据库的压力。随着订单的数量越来越多,扫描的成本也会越来越大,执行时间也会被拉长,可能导致某些应该被关闭的订单迟迟没有被关闭。
  • 总结:采用定时任务的方案比较适合对时间要求不是很敏感,并且数据量不太多的业务
    场景。

JDK 延迟队列 DelayQueue

private DelayQueue<TaskEntity> delayQueue = new DelayQueue<>();

    @PostConstruct
    public void closeOrder() {
        new Thread(() -> {
            while(true) {
                try {
                    TaskEntity task = delayQueue.take();
                    if (task != null) {
                        log.info("订单关闭");
                    }
                } catch (InterruptedException e) {
                    log.error("订单关闭失败");
                }
            }
        }).start();
    }
@Data
@AllArgsConstructor
public class TaskEntity implements Delayed {

    private Integer taskId;

    private String taskName;

    private Long runTime;

    private Long delaySeconds;


    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(this.runTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }


    @Override
    public int compareTo(Delayed o) {
        TaskEntity object = (TaskEntity) o;
        return this.delaySeconds.compareTo(object.getDelaySeconds());
    }
}

DelayQueue 是 JDK 提供的一个无界队列,我们可以看到,DelayQueue 队列中的元素需要实现 Delayed,它只提供了一个方法,就是获取过期时间。

  • 优点:不依赖任何第三方组件,连数据库也不需要了,实现起来也方便。
  • 缺点
    • 因为 DelayQueue 是一个无界队列,如果放入的订单过多,会造成 JVM OOM。
    • DelayQueue 基于 JVM 内存,如果 JVM 重启了,那所有数据就丢失了。
  • 总结:DelayQueue 适用于数据量较小,且丢失也不影响主业务的场景,比如内部系统的一些非重要通知,就算丢失,也不会有太大影响。

Redisson 分布式延迟队列

源码解析

Redisson源码(二)延迟队列RDelayedQueue的使用及原理分析

Redission实现延迟队列消息用到了四个数据结构:

  • timeoutSetName: redisson_delay_queue_timeout:{queue_name} 定期队列,ZSET结构(value为消息,score为过期时间),这样就可以知道当前过期的消息。存放未到期的消息&到期时间,提供消息延时排序功能

  • queueName: redisson_delay_queue:{queue_name} 顺序队列,LIST结构,按照消息添加顺序存储,移除消息时可以按照添加顺序删除。存放未到期消息

  • channelName:redisson_delay_queue_channel:{queue_name} 发布订阅channel主题,用于通知客户端定时器从定期队列转移到期的消息到目标队列

  • getName()目标队列,LIST结构,存储实际到期可以被消费的消息供消费者拉取消费

消息生产源码

  1. 通过redissonClient.getDelayedQueue获取RDelayedQueue对象
  2. 然后delayedQueue调用offer方法去保存消息
  3. 最后真正的保存逻辑是由RedissonDelayedQueue执行offerAsync方法调用的lua脚本
public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
    @Override
    public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
        if (delay < 0) {
            throw new IllegalArgumentException("Delay can't be negative");
        }
        long delayInMs = timeUnit.toMillis(delay);
        // 消息过期时间 = 当前时间 + 延迟时间
        long timeout = System.currentTimeMillis() + delayInMs;
        // 生成随机id,应该是为了允许插入到zset重复的消息
        long randomId = ThreadLocalRandom.current().nextLong();
        // 执行脚本
        return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
            // 将消息打包成二进制的, 打包的消息 = 随机数 + 消息,有了随机数意味着消息就可以重复
            "local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);"
            // 将打包的消息和过期时间 插入redisson_delay_queue_timeout队列
            + "redis.call('zadd', KEYS[2], ARGV[1], value);"
            // 顺序插入redisson_delay_queue队列
            + "redis.call('rpush', KEYS[3], value);"
            // 如果刚插入的消息就是timeout队列的最前面,即刚插入的消息最近要到期
            + "local v = redis.call('zrange', KEYS[2], 0, 0); "
            + "if v[1] == value then "
            // 发布消息通知客户端消息到期时间,让它定期执行转移操作
            + "redis.call('publish', KEYS[4], ARGV[1]); "
            + "end;",
            Arrays.<Object>asList(getName(), timeoutSetName, queueName, channelName),
            // 三个参数:1-过期时间 2-随机数 3-消息
            timeout, randomId, encode(e));
    }
}

定时器转移消息源码分析

在调用redissonClient.getDelayedQueue获取RDelayedQueue对象时创建的:

  1. 通过redissonClient.getDelayedQueue获取RDelayedQueue对象

  2. 然后会执行RedissonDelayedQueue的构造函数方法

  3. 在这个构造方法里就会新建QueueTransferTask这个对象去执行转移操作

public class Redisson implements RedissonClient {
    @Override
    public <V> RDelayedQueue<V> getDelayedQueue(RQueue<V> destinationQueue) {
        if (destinationQueue == null) {
            throw new NullPointerException();
        }
        // 执行RedissonDelayedQueue构造方法
        return new RedissonDelayedQueue<V>(queueTransferService, destinationQueue.getCodec(), connectionManager.getCommandExecutor(), destinationQueue.getName());
    }
}
public class RedissonDelayedQueue<V> extends RedissonExpirable implements RDelayedQueue<V> {
    protected RedissonDelayedQueue(QueueTransferService queueTransferService, Codec codec, final CommandAsyncExecutor commandExecutor, String name) {
        ...
        QueueTransferTask task = new QueueTransferTask(commandExecutor.getConnectionManager()) {
            @Override
            protected RFuture<Long> pushTaskAsync() {
                return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
                    // 从redisson_delay_queue_timeout队列获取100个到期的消息
                    "local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
                    + "if #expiredValues > 0 then "
                    + "for i, v in ipairs(expiredValues) do "
                    // 将包装的消息执行解包操作,随机数 + 原消息        
                    + "local randomId, value = struct.unpack('dLc0', v);"
                    // 将原消息插入到{queue_name}队列,就可以被消费了        
                    + "redis.call('rpush', KEYS[1], value);"
                    + "redis.call('lrem', KEYS[3], 1, v);"
                    + "end; "
                    // 转移后redisson_delay_queue_timeout队列也移除这些消息        
                    + "redis.call('zrem', KEYS[2], unpack(expiredValues));"
                    + "end; "
                    // 从定时队列获取最近到期时间然后供定时器到时间再执行
                    + "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
                    + "if v[1] ~= nil then "
                    + "return v[2]; "
                    + "end "
                    + "return nil;",
                    Arrays.<Object>asList(getName(), timeoutSetName, queueName),
                    System.currentTimeMillis(), 100);
            }
            // 主题redisson_delay_queue_channel:{queue_name}注册发布/订命令执行阅监听器
            @Override
            protected RTopic getTopic() {
                return new RedissonTopic(LongCodec.INSTANCE, commandExecutor, channelName);
            }
        };
        // 将定时器命令执行逻辑注册到发布/订阅主题,这样就可以在收到订阅时执行转移操作了
        queueTransferService.schedule(queueName, task);
        ...
    }
}

消息订阅后

订阅到topic消息后,会先判断其是否临期(delay<10ms),如果是则调用pushTask方法(1中有说明),不是则启动一个定时任务(使用的netty时间轮),延时delay后执行pushTask方法。

// 订阅topic onMessage 时调用
private void scheduleTask(final Long startTime) {
    TimeoutTask oldTimeout = lastTimeout.get();
    if (startTime == null) {
        return;
    }
    
    if (oldTimeout != null) {
        oldTimeout.getTask().cancel();
    }
    
    long delay = startTime - System.currentTimeMillis();
    if (delay > 10) {
    	// 使用 netty 时间轮 启动一个定时任务
        Timeout timeout = connectionManager.newTimeout(new TimerTask() {                    
            @Override
            public void run(Timeout timeout) throws Exception {
                pushTask();
                
                TimeoutTask currentTimeout = lastTimeout.get();
                if (currentTimeout.getTask() == timeout) {
                    lastTimeout.compareAndSet(currentTimeout, null);
                }
            }
        }, delay, TimeUnit.MILLISECONDS);
        if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) {
            timeout.cancel();
        }
    } else {
        pushTask();
    }
}    

// pushTaskAsync 就是前面1中重写的方法
private void pushTask() {
    RFuture<Long> startTimeFuture = pushTaskAsync();
    startTimeFuture.onComplete((res, e) -> {
        if (e != null) {
            if (e instanceof RedissonShutdownException) {
                return;
            }
            log.error(e.getMessage(), e);
            scheduleTask(System.currentTimeMillis() + 5 * 1000L);
            return;
        }
        
        if (res != null) {
            scheduleTask(res);
        }
    });
}

消息消费源码分析

Redis Blpop 命令移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

public class RedissonBlockingQueue<V> extends RedissonQueue<V> implements RBlockingQueue<V> {
    @Override
    public RFuture<V> takeAsync() {
        // 执行redis中List的BLPOP命令,从{queue_name}队列阻塞取出元素
        return commandExecutor.writeAsync(getName(), codec, RedisCommands.BLPOP_VALUE, getName(), 0);
    }
}

简单总结大致流程

offer()入队流程

  1. 将 打包的消息和过期时间 插入定期队列timeoutSetName
  2. 顺序插入queueName队列
  3. 如果刚插入的消息就是即将要过期的,就发送消息通知客户端消息到期时间,定期将消息执行转移操作。publish channelName 2000
    在这里插入图片描述

消息转移流程

  1. 在调用redissonClient.getDelayedQueue获取RDelayedQueue对象时,会创建QueueTransferTask,后面调用queueTransferService.schedule(queueName, task);订阅主题
  2. 入队发布消息后,订阅消息的客户端收到信息,开始转移消息
  3. 定期队列timeoutSetName获取100个到期的消息,解包 将消息插入到 getName() 目标队列
  4. 顺序队列queueName定期队列timeoutSetName移除到期消息
  5. 定期队列timeoutSetName在获取第一个要到期的消息的时间戳提供给定时器
  6. 定时器继续执行

take()出队流程

  1. 执行blpop命令执行任务,移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

demo

pom依赖

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.17.6</version>
        </dependency>

redis配置信息

spring:
  redis:
    host: 127.0.0.1
    port: 6379

任务枚举类

package com.example.demo.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum RedisDelayQueueEnum {

    ORDER_PAYMENT_TIMEOUT("ORDER_PAYMENT_TIMEOUT","超时订单自动关闭队列");

    /**
     * 延迟队列 Redis Key
     */
    private String code;

    /**
     * 中文描述
     */
    private String name;

}

队列出入队工具类

package com.example.demo.util;

import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class RedisDelayQueueUtil {

    @Autowired
    private RedissonClient redissonClient;

    public <T> void addDelayQueue(String queueName, T data, long delay, TimeUnit timeUnit) {
        try {
            RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingDeque(queueName);
            RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
            delayedQueue.offer(data, delay, timeUnit);
            log.info("(添加延时队列成功) 队列键:{},队列值:{},延迟时间:{},单位:{}", queueName, data, delay,timeUnit);
        } catch (Exception e) {
            log.error("(添加延时队列失败) {}", e.getMessage());
            throw new RuntimeException("(添加延时队列失败)");
        }
    }


    public <T> T getDelayQueue(String queueName) {
        try {
            RBlockingQueue<Object> blockingDeque = redissonClient.getBlockingDeque(queueName);
            T data = (T) blockingDeque.take();
            log.info("(队列出队成功) 队列键:{},队列值:{},延迟时间:{},单位:{}", queueName, data);
            return data;
        } catch (Exception e) {
            log.error("(队列出队失败) {}", e.getMessage());
            throw new RuntimeException("(队列出队失败)");
        }
    }
}

延迟队列执行器

package com.example.demo.handler;

import com.example.demo.enums.RedisDelayQueueEnum;
import org.springframework.beans.factory.InitializingBean;

public interface RedisDelayQueueHandler<T> extends InitializingBean {

    void execute(T t);


    @Override
    default void afterPropertiesSet() throws Exception {
        RegisterHandlerUtil.registerScheduler(supportedHandler(), this);
    }

    RedisDelayQueueEnum supportedHandler();

}

package com.example.demo.handler;

import com.example.demo.enums.RedisDelayQueueEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@Slf4j
public class OrderTimeOutHandler implements RedisDelayQueueHandler<Map> {

    @Override
    public void execute(Map map) {
        log.info("(收到超时订单延迟消息) {}", map);
        // TODO 订单相关,处理业务逻辑...
//        1.调用第三方(微信,支付宝)的支付接口,查询订单是否已经支付,如果确认没支付则,调用关闭订单支付的api,并修改订单的状态为关闭,同时回滚库存数量。
//        2.如果支付状态为已支付则需要做补偿操作,修改订单的状态为已支付,订单历史记录
        log.info("调用第三方(微信,支付宝)的支付接口确认未支付,超时订单关闭");
    }

    @Override
    public RedisDelayQueueEnum supportedHandler() {
        return RedisDelayQueueEnum.ORDER_PAYMENT_TIMEOUT;
    }
}

注册不同延迟队列的执行bean

package com.example.demo.handler;

import com.example.demo.enums.RedisDelayQueueEnum;
import org.springframework.stereotype.Component;

import java.util.EnumMap;

@Component
public class RegisterHandlerUtil {

    private static EnumMap<RedisDelayQueueEnum, RedisDelayQueueHandler<?>> handlerrMap = new EnumMap<>(RedisDelayQueueEnum.class);

    public static void registerScheduler(RedisDelayQueueEnum type, RedisDelayQueueHandler<?> handler) {
        handlerrMap.putIfAbsent(type, handler);
    }

    public static RedisDelayQueueHandler getHandler(RedisDelayQueueEnum type) {
        return handlerrMap.get(type);
    }
}

redis延迟队列主线程

package com.example.demo.handler;

import com.digitforce.framework.spring.SpringContextHolder;
import com.example.demo.enums.RedisDelayQueueEnum;
import com.example.demo.util.RedisDelayQueueUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class RedisDelayQueueRunner {

    @Autowired
    private RedisDelayQueueUtil redisDelayQueueUtil;

    private ThreadPoolExecutor threadPool;


    @PostConstruct
    public void run() {
        log.info("redis 延迟队列启动");

        threadPool = new ThreadPoolExecutor(10, 50, 30, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(1000), Executors.defaultThreadFactory());

        threadPool.execute(() -> {
            while (true) {
                RedisDelayQueueEnum[] queueEnums = RedisDelayQueueEnum.values();
                log.info("queueEnums" + queueEnums.toString());
                for (RedisDelayQueueEnum queueEnum : queueEnums) {
                    Object value = redisDelayQueueUtil.getDelayQueue(queueEnum.getCode());
                    if (value != null) {
                        RedisDelayQueueHandler handler = RegisterHandlerUtil.getHandler(queueEnum);
                        handler.execute(value);
                    }
                }
            }
        });
        log.info("线程池启动成功");
    }
}

在这里插入图片描述

Redis故障可能带来的挑战

如果Redis发生故障(例如崩溃或者网络问题),会对未支付订单的处理产生以下影响:

  • 任务丢失:所有存储在Redis延迟队列中的任务可能会因为Redis的故障而丢失。
  • 任务重复处理:在Redis崩溃时,如果处理逻辑未得到执行,可能会出现重复操作的情况,导致资源的浪费或状态的不一致。

为了应对Redis挂掉的情况,我们可以采取以下措施

  1. 使用持久化存储
    • 持久化订单信息:
      在将未支付订单放入Redisson的延迟队列之前,将这些订单的信息存储在关系型数据库(如MySQL)中。数据库可以保证数据的持久性,即使Redis出现故障。

    • 定期同步:
      定期检查和同步Redis延迟队列与数据库中的订单记录,确保在状态更新或系统崩溃后能够恢复未支付的订单状态。

  2. 任务状态管理
    • 订单状态字段:
      在数据库中维护订单的状态字段(如待支付、已关闭等)。当放入延迟队列后,更新订单的状态为“待关闭”。如Redis崩溃,可以根据状态字段判断哪些订单需要被关闭。
    • 使用分布式锁:
      在处理未支付订单的关闭操作时应用分布式锁,以防止同一订单被多个消费者同时关闭,从而造成数据不一致。
  3. 监控与报警机制
    • Redis监控:
      设置监控(如使用Prometheus和Grafana)监测Redis的健康状态,如果Redis出现故障可以及时收到警报。
    • 异常处理机制:
      在关闭订单的业务逻辑中加上异常处理逻辑,如果尝试读取或更新Redis失败,应进行重试或回退策略,防止造成无法预知的后果。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值