使用 Redisson 優雅關閉訂單

在支付系統中,訂單通常是具有時效性的,例如在下單 30 分鐘後如果還沒有完成支付,那麼就要取消訂單,不能再執行後續流程。說到這,可能大家的第一反應是啓動一個定時任務,來輪詢訂單的狀態是否完成了支付,如果超時還沒有完成,那麼就去修改訂單的關閉字段。當然,在數據量小的時候這麼幹沒什麼問題,但是如果訂單的數量上來了,那麼就會出現讀取數據的瓶頸,畢竟來一次全表掃描還是挺費時的。

針對於定時任務的這種缺陷,關閉訂單的這個需求大多依賴於延時任務來實現,這裏說明一下延時任務與定時任務的最大不同,定時任務有執行週期的,而延時任務在某事件觸發後一段時間內執行,並沒有執行週期。

對於延時任務,可能大家對於 RabbitMQ 的延時隊列會比較熟悉,用起來也是得心應手,但是你是否知道使用 Redis 也能實現延時任務的功能呢,今天我們就來看看具體應該如何實現。

使用 Redis 實現的延時隊列,需要藉助 Redisson 的依賴:

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

首先實現往延時隊列中添加任務的方法,爲了測試時方便,我們把延遲時間設爲 30 秒。

@Component
public class UnpaidOrderQueue {
    @Autowired
    RedissonClient redissonClient;
    public void addUnpaid(String orderId){
        RBlockingQueue<String> blockingFairQueue = redissonClient.getBlockingQueue("orderQueue");
        RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);
        System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+" 添加任務到延時隊列");
        delayedQueue.offer(orderId,30, TimeUnit.SECONDS);
    }
}

添加一個對隊列的監聽方法,通過實現 CommandLineRunner 接口,使它在 springboot 啓動時就開始執行:

@Component
public class QueueRunner implements CommandLineRunner {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private OrderService orderService;
    @Override
    public void run(String... args) throws Exception {
        new Thread(()->{
            RBlockingQueue<String> blockingFairQueue = redissonClient.getBlockingQueue("orderQueue");
            RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);
            delayedQueue.offer(null, 1, TimeUnit.SECONDS);
            while (true){
                String orderId = null;
                try {
                    orderId = blockingFairQueue.take();
                } catch (Exception e) {
                    continue;
                }
                if (orderId==null) {
                    continue;
                }
                System.out.println(String.format(DateTime.now().toString(JodaUtil.HH_MM_SS)+" 延時隊列收到:"+orderId));
                System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+" 檢測訂單是否完成支付");
                if (orderService.isTimeOut(orderId)) {
                    orderService.closeOrder(orderId);
                }
            }
        }).start();
    }
}

在方法中,單獨啓動了一個線程來進行監聽,如果有任務進入延時隊列,那麼取到訂單號後,調用我們 OrderService 提供的檢測是否訂單過期的服務,如果過期,那麼執行關閉訂單的操作。

創建簡單的 OrderService 用於測試,提供創建訂單,檢測超時,關閉訂單方法:

@Service
public class OrderService {
    @Autowired
    UnpaidOrderQueue unpaidOrderQueue;
    public void createOrder(String order){
        System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+" 創建訂單:"+order);
        unpaidOrderQueue.addUnpaid(order);
    }
    public boolean isTimeOut(String orderId){
        return true;
    }
    public void closeOrder(String orderId){
        System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+ " 關閉訂單");
    }
}

執行請求,看一下結果:

在訂單創建 30 秒後,檢測到延時隊列中有任務任務,調用檢測超時方法檢測到訂單沒有完成後,自動關閉訂單。

除了上面這種延時隊列的方式外,Redisson 還提供了另一種方式,也能優雅的關閉訂單,方法很簡單,就是通過對將要過期的 key 值的監聽。

創建一個類繼承 KeyExpirationEventMessageListener,重寫其中的 onMessage 方法,就能實現對過期 key 的監聽,一旦有緩存過期,就會調用其中的 onMessage 方法:

@Component
public class RedisExpiredListener extends KeyExpirationEventMessageListener {
    public static final String UNPAID_PREFIX="unpaidOrder:";
    @Autowired
    OrderService orderService;
    public RedisExpiredListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expiredKey = message.toString();
        if (expiredKey.startsWith(UNPAID_PREFIX)){
            System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+" " +expiredKey+"已過期");
            orderService.closeOrder(expiredKey);
        }
    }
}

因爲可能會有很多 key 的過期事件,因此需要對訂單過期的 key 加上一個前綴,用來判斷過期的 key 是不是屬於訂單事件,如果是的話那麼進行關閉訂單操作。

再在寫一個測試接口,用於創建訂單和接收支付成功的回調結果:

@RestController
@RequestMapping("order")
public class TestController {
    @Autowired
    RedisTemplate redisTemplate;
    @GetMapping("create")
    public String setTemp(String id){
        String orderId= RedisExpiredListener.UNPAID_PREFIX+id;
        System.out.println(DateTime.now().toString(JodaUtil.HH_MM_SS)+" 創建訂單:"+orderId);
        redisTemplate.opsForValue().set(orderId,orderId,30, TimeUnit.SECONDS);
        return id;
    }
    @GetMapping("fallback")
    public void successFallback(String id){
        String orderId= RedisExpiredListener.UNPAID_PREFIX+id;
        redisTemplate.delete(orderId);
    }
}

在訂單支付成功後,一般我們會收到第三方的一個支付成功的異步回調通知。如果支付完成後收到了這個回調,那麼我們主動刪除緩存的未支付訂單,那麼也就不會監聽到這個訂單的 orderId 的過期失效事件。

但是這種方式有一個弊端,就是隻能監聽到過期緩存的 key,不能獲取到對應的 value。而通過延時隊列的方式,可以通過爲 RBlockingQueue 添加泛型的方式,保存更多訂單的信息,例如直接將對象存進隊列中:

RBlockingQueue<OrderDTO> blockingFairQueue = redissonClient.getBlockingQueue("orderQueue");
RDelayedQueue<OrderDTO> delayedQueue = redissonClient.getDelayedQueue(blockingFairQueue);

這樣的話我們再從延時隊列中獲取的時候,能夠拿到更多我們需要的屬性。綜合以上兩種方式,監聽過期更爲簡單,但存在的一定的侷限性,如果我們只需要對訂單進行判斷的話那麼功能也能夠滿足我們的需求,如果需要在過期時獲取更多的訂單屬性,那麼使用延時隊列的方式則更爲合適。究竟選擇哪種,就要看大家的業務場景了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YKAjHriapWsxKlaCz24kLg