實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)

錦成同學發表於2019-09-28

實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)

一、用三根雞毛做引言

  • 真的! 不騙你們的喔~ 相信大家都遇到類似於:訂單30min後未支付自動取消的開發任務
  • 那麼今日份就來了解一下怎麼用延時佇列 DelayQueue搞定單機版的超時訂單
    實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)

二、延時佇列使用場景

那麼什麼時候需要用延時佇列呢?常見的延時任務場景 舉栗子:

  1. 訂單在30分鐘之內未支付則自動取消。
  2. 重試機制實現,把呼叫失敗的介面放入一個固定延時的佇列,到期後再重試。
  3. 新建立的店鋪,如果在十天內都沒有上傳過商品,則自動傳送訊息提醒。
  4. 使用者發起退款,如果三天內沒有得到處理則通知相關運營人員。
  5. 預定會議後,需要在預定的時間點前十分鐘通知各個與會人員參加會議。
  6. 關閉空閒連線,伺服器中,有很多客戶端的連線,空閒一段時間之後需要關閉之。
  7. 清理過期資料業務。比如快取中的物件,超過了空閒時間,需要從快取中移出。
  8. 多考生考試,到期全部考生必須交卷,要求時間非常準確的場景。

三、解決辦法多如雞毛

  1. 定期輪詢(資料庫等)
  2. JDK DelayQueue
  3. JDK Timer
  4. ScheduledExecutorService 週期性執行緒池
  5. 時間輪(kafka)
  6. 時間輪(Netty的HashedWheelTimer)
  7. Redis有序集合(zset)
  8. zookeeper之curator
  9. RabbitMQ
  10. Quartz,xxljob等定時任務框架
  11. Koala(考拉)
  12. JCronTab(仿crontab的java排程器)
  13. SchedulerX(阿里)
  14. 有贊延遲佇列
  15. .....(雞毛)
  • 解決問題方法真是不勝列舉,正所謂一呼百應,一千個讀者眼裡有一千個哈姆雷特

? ? ? ? ? ? ? ? ? ?

  • 那我們第一篇先來實戰JDK的DelayQueue,萬祖歸宗,萬法同源,學會了最基礎的Queue,就不愁其他的了
  • 後續再寫幾篇使用Redis,Zk,MQ的一些機制,實戰分散式情況下的使用

四、先認親

延時佇列,首先,它是一種佇列,佇列意味著內部的元素是有序的,元素出隊入隊是有方向性的,元素從一端進入,從另一端取出。

其次,延時佇列,最重要的特性就體現在它的延時屬性上,跟普通的佇列不一樣的是,普通佇列中的元素總是等著希望被早點取出處理,而延時佇列中的元素則是希望被在指定時間得到取出和處理,所以延時佇列中的元素是都是帶時間屬性的,通常來說是需要被處理的訊息或者任務。

一言以蔽之曰 : 延時佇列就是用來存放需要在指定時間被處理的元素的佇列。

1) DelayQueue 是誰,上族譜

實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)
看的出來到DelayQueue這一代已經第五代傳人了,

要知道 DelayQueue自幼生在八戒家,長大就往外面拉,熊熊烈火它不怕,水是水來渣是渣。

不過它真的是文韜武略,有一把ReentrantLock就是它的九齒釘耙,抗的死死の捍衛著自己的PriorityQueue.

有典故曰:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
           implements BlockingQueue<E> {
// 用於控制併發的 可重入 全域性 鎖
private final transient ReentrantLock lock = new ReentrantLock();
// 根據Delay時間排序的 無界的 優先順序佇列
private final PriorityQueue<E> q = new PriorityQueue<E>();
// 用於優化阻塞通知的執行緒元素leader,標記當前是否有執行緒在排隊(僅用於取元素時)
private Thread leader = null;
// 條件,用於阻塞和通知的Condition物件,表示現在是否有可取的元素
private final Condition available = lock.newCondition();

       /**
        * 省洛方法程式碼.....  你們懂我的省洛嗎?
        */
複製程式碼
  • 註釋的已經很清楚他們的意思了,也具備了併發程式設計之藝術的 鎖,佇列,狀態(條件)
  • 他的幾個方法也是通過 鎖-->維護佇列-->出隊,入隊-->根據Condition進行條件的判斷-->進行執行緒之間的通訊和喚起
  • 以支援優先順序無界佇列的PriorityQueue作為一個容器,容器裡面的元素都應該實現Delayed介面,在每次往優先順序佇列中新增元素時以元素的過期時間作為排序條件,最先過期的元素放在優先順序最高。
  • DelayQueue是一個沒有大小限制的佇列,因此往佇列中插入資料的操作(生產者)永遠不會被阻塞,而只有獲取資料的操作(消費者)才會被阻塞。

2) 優先順序佇列 PriorityQueue

因為我們的DelayQueue裡面維護了一個優先順序的佇列PriorityQueue 簡單的看下:

    //預設容量11
     private static final int DEFAULT_INITIAL_CAPACITY = 11;
    //儲存元素的地方 陣列
    transient Object[] queue; // non-private to simplify nested class access
    //元素個數
    private int size = 0;
    //比較器
    private final Comparator<? super E> comparator;
複製程式碼
  1. 預設容量是11;
  2. queue,元素儲存在陣列中,這跟我們之前說的堆一般使用陣列來儲存是一致的;
  3. comparator,比較器,在優先順序佇列中,也有兩種方式比較元素,一種是元素的自然順序,一種是通過比較器來比較;
  4. modCount,修改次數,有這個屬性表示PriorityQueue也是fast-fail的;
  5. PriorityQueue不是有序的,只有堆頂儲存著最小的元素;
  6. PriorityQueue 是非執行緒安全的;

3) DelayQueue的方法簡介

  • 入隊方法 : 若新增的元素是隊首(堆頂)元素,就把leader置為空,並喚醒等待在條件available上的執行緒;
public boolean add(E e) {    return offer(e);}
public void put(E e) {    offer(e);}
public boolean offer(E e, long timeout, TimeUnit unit) {    return offer(e);}
public boolean offer(E e) {    
    final ReentrantLock lock = this.lock;    
    lock.lock();   //加鎖 因為優先佇列執行緒不安全
    try {
        q.offer(e);  //判斷優先順序 進行入隊      
    if (q.peek() == e) {    //-----[1]
        //leader記錄了被阻塞在等待佇列頭生效的執行緒 新增一個元素到佇列頭,
        //表示等待原來佇列頭生效的阻塞的執行緒已經失去了阻塞的意義
        //,此時需要獲取新的佇列頭進行返回了
        leader = null;      
        //獲取佇列頭的執行緒被喚起,主要有兩種場景:
        //1. 之前佇列為空,導致被阻塞的執行緒
        //2. 之前佇列非空,但是佇列頭沒有生效(到期)導致被阻塞的執行緒
        available.signal();     
    }        
        return true; //因為是無界佇列 所以新增元素肯定成功  直到OOM
    } finally {    
        lock.unlock();   //釋放鎖
    }
}
複製程式碼

offer()方法,首先獲取獨佔鎖,然後新增元素到優先順序佇列,由於q是優先順序佇列,所以新增元素後,peek並不一定是當前新增的元素,如果[1]為true,說明當前元素e的優先順序最小也就即將過期的,這時候啟用avaliable變數條件佇列裡面的執行緒,通知他們佇列裡面有元素了。

  • 出隊方法 take()

請看我詳細的註釋,絕不是蜻蜓點水

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock; //獲取鎖 
    lock.lockInterruptibly(); //可中斷鎖     併發類裡面凡是呼叫了await的方法獲取鎖時候都是使用的lockInterruptibly方法而不是Lock. 
    //也是一種fail-fast思想吧,await()方法會在中斷標誌設定後丟擲InterruptedException異常後退出 不至於死死的等待
    try {
        for (;;) {//會寫死迴圈的都是高手
            E first = q.peek();//get隊頭元素
            if (first == null)
                // 佇列頭為空,則阻塞,直到新增一個入隊為止(1)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS);//獲取剩餘時間
                if (delay <= 0)
                    // 若佇列頭元素已生效,則直接返回(2)
                    return q.poll();
                first = null; // don't retain ref while waiting 等待的時候不能引用,表示釋放當前引用的(3)
                if (leader != null)
                    // leader 非空時,表示有其他的一個執行緒在出隊阻塞中 (4.1)
                    // 此時掛住當前執行緒,等待另一個執行緒出隊完成
                    available.await();
                else {
                    //標識當前執行緒處於等待佇列頭生效的阻塞中 (4.2.1)
                    Thread thisThread = Thread.currentThread(); 
                    leader = thisThread;
                    try {
                        // 等待佇列頭元素生效(4.2.2)
                        available.awaitNanos(delay);
                    } finally {
                        //最終釋放當前的執行緒 設定leader為null (4.2.3)
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }     //(5)
    } finally {
        if (leader == null && q.peek() != null)
            // 當前執行緒出隊完成,通知其他出隊阻塞的執行緒繼續執行(6)
            available.signal();
            lock.unlock();//解鎖結束
    }
}
複製程式碼

那麼,下面的結論肉眼可見:

  1. 如果佇列為空,則阻塞,直到有個執行緒(生產者投遞資料)完成入隊操作
  2. 獲取佇列頭,若佇列頭已生效,則直接返回
  3. 未生效則釋放當前引用
  4. 當佇列頭部沒有生效時候:
    1. 若有另一個執行緒已經處於等待佇列頭生效的阻塞過程中,則阻塞當前執行緒,直到另一個執行緒完成出隊操作
    2. 若沒有其他執行緒阻塞在出隊過程中,即當前執行緒為第一個獲取佇列頭的執行緒
      • 標識當前執行緒處於等待佇列頭生效的阻塞中(leader = thisThread
      • 阻塞當前執行緒,等待佇列頭生效
      • 佇列頭生效之後,清空標識(leader=null)
  5. 再次進入迴圈,獲取佇列頭並返回
  6. 最後,當前執行緒出隊完成,通知其他出隊阻塞的執行緒繼續執行

4) Leader/Follower模式

  1. 如果不是隊首節點,根本不需要喚醒操作!
  2. 假設取值時,延時時間還沒有到,那麼需要等待,但這個時候,佇列中新加入了一個延時更短的,並放在了隊首,那麼 此時,for迴圈由開始了,取得是新加入的元素,那之前的等待就白等了,明顯可以早點退出等待!
  3. 還有就是如果好多執行緒都在此等待,如果時間到了,同時好多執行緒會充等待佇列進入鎖池中,去競爭鎖資源,但結果只能是一個成功, 多了寫無畏的競爭!(多次的等待和喚醒)
    實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)

5)Delayed

public interface Delayed extends Comparable<Delayed> { 
    long getDelay(TimeUnit unit);
}
複製程式碼

據情報顯示:Delayed是一個繼承自Delayed的介面,並且定義了一個Delayed方法,用於表示還有多少時間到期,到期了應返回小於等於0的數值。

很簡答就是定義了一個,一個哈,一個表延遲的介面,就是個規範介面,目的就是騙我們去實現它的方法.哼~

五、再實戰

說了那麼多廢話,讓我想起了那句名言:一切沒有程式碼實操的講解都是耍流氓 至今深深的烙在我心中,所以我一定要實戰給你們看,顯得我不是流氓...

  • 實戰以 訂單下單後三十分鐘內未支付則自動取消 為業務場景

  • 該場景的程式碼邏輯分析如下:

    1. 下單後將訂單直接放入未支付的延時佇列中
    2. 如果超時未支付,則從佇列中取出,進行修改為取消狀態的訂單
    3. 如果支付了,則不去進行取消,或者取消的時候做個狀態篩選,即可避免更新
    4. 或者支付完成後,做個主動出隊
    5. 還有就是使用者主動取消訂單,也做個主動出隊
  • 那麼我們寫程式碼一定要通用,先來寫個通用的Delayed 通用...嗯! 泛型的


import lombok.Getter;
import lombok.Setter;

import java.util.Date;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 * @author LiJing
 * @ClassName: ItemDelayed
 * @Description: 資料延遲實現例項 用以包裝具體的例項轉型
 * @date 2019/9/16 15:53
 */

@Setter
@Getter
public class ItemDelayed<T> implements Delayed {

    /**預設延遲30分鐘*/
    private final static long DELAY = 30 * 60 * 1000L;
    /**資料id*/
    private Long dataId;
    /**開始時間*/
    private long startTime;
    /**到期時間*/
    private long expire;
    /**建立時間*/
    private Date now;
    /**泛型data*/
    private T data;
    
    public ItemDelayed(Long dataId, long startTime, long secondsDelay) {
        super();
        this.dataId = dataId;
        this.startTime = startTime;
        this.expire = startTime + (secondsDelay * 1000);
        this.now = new Date();
    }

    public ItemDelayed(Long dataId, long startTime) {
        super();
        this.dataId = dataId;
        this.startTime = startTime;
        this.expire = startTime + DELAY;
        this.now = new Date();
    }

    @Override
    public int compareTo(Delayed o) {
        return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }
}
複製程式碼
  • 再寫個通用的介面,用於規範和方便統一實現 這樣任何型別的訂單都可以實現這個介面 進行延時任務的處理

public interface DelayOrder<T> {


    /**
     * 新增延遲物件到延時佇列
     *
     * @param itemDelayed 延遲物件
     * @return boolean
     */
    boolean addToOrderDelayQueue(ItemDelayed<T> itemDelayed);

    /**
     * 根據物件新增到指定延時佇列
     *
     * @param data 資料物件
     * @return boolean
     */
    boolean addToDelayQueue(T data);

    /**
     * 移除指定的延遲物件從延時佇列中
     *
     * @param data
     */
    void removeToOrderDelayQueue(T data);
}
複製程式碼
  • 來具體的任務,具體的邏輯具體實現
@Slf4j
@Lazy(false)
@Component
public class DelayOwnOrderImpl implements DelayOrder<Order> {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ExecutorService delayOrderExecutor;

    private final static DelayQueue<ItemDelayed<Order>> DELAY_QUEUE = new DelayQueue<>();

    /**
     * 初始化時載入資料庫中需處理超時的訂單
     * 系統啟動:掃描資料庫中未支付(要在更新時:加上已支付就不用更新了),未過期的的訂單
     */
    @PostConstruct
    public void init() {
        log.info("系統啟動:掃描資料庫中未支付,未過期的的訂單");
        List<Order> orderList = orderService.selectFutureOverTimeOrder();
        for (Order order : orderList) {
            ItemDelayed<Order> orderDelayed = new ItemDelayed<>(order.getId(), order.getCreateDate().getTime());
            this.addToOrderDelayQueue(orderDelayed);
        }
        log.info("系統啟動:掃描資料庫中未支付的訂單,總共掃描了" + orderList.size() + "個訂單,推入檢查佇列,準備到期檢查...");

        /*啟動一個執行緒,去取延遲訂單*/
        delayOrderExecutor.execute(() -> {
            log.info("啟動處理的訂單執行緒:" + Thread.currentThread().getName());
            ItemDelayed<Order> orderDelayed;
            while (true) {
                try {
                    orderDelayed = DELAY_QUEUE.take();
                    //處理超時訂單
                    orderService.updateCloseOverTimeOrder(orderDelayed.getDataId());
                } catch (Exception e) {
                    log.error("執行自營超時訂單的_延遲佇列_異常:" + e);
                }
            }
        });
    }

    /**
     * 加入延遲訊息佇列
     **/
    @Override
    public boolean addToOrderDelayQueue(ItemDelayed<Order> orderDelayed) {
        return DELAY_QUEUE.add(orderDelayed);
    }

    /**
     * 加入延遲訊息佇列
     **/
    @Override
    public boolean addToDelayQueue(Order order) {
        ItemDelayed<Order> orderDelayed = new ItemDelayed<>(order.getId(), order.getCreateDate().getTime());
        return DELAY_QUEUE.add(orderDelayed);
    }

    /**
     * 從延遲佇列中移除 主動取消就主動從佇列中取出
     **/
    @Override
    public void removeToOrderDelayQueue(Order order) {
        if (order == null) {
            return;
        }
        for (Iterator<ItemDelayed<Order>> iterator = DELAY_QUEUE.iterator(); iterator.hasNext(); ) {
            ItemDelayed<Order> queue = iterator.next();
            if (queue.getDataId().equals(order.getId())) {
                DELAY_QUEUE.remove(queue);
            }
        }
    }
}
複製程式碼

解釋一番上面的寫的東東

  1. delayOrderExecutor是注入的一個專門處理出隊的一個執行緒
  2. @PostConstruct是啥呢,是在容器啟動後只進行一次初始化動作的一個註解,相當實用
  3. 啟動後呢,我們去資料庫掃描一遍,防止有漏網之魚,因為單機版嗎,佇列的資料是在記憶體中的,重啟後肯定原先的資料會丟失,所以為保證服務質量,我們可能會錄音.....所以為保證重啟後資料的恢復,我們需要重新掃描資料庫把未支付的資料重新裝載到記憶體的佇列中
  4. 接下來就是用這個執行緒去一直不停的訪問佇列的take()方法,當佇列無資料就一直阻塞,或者資料沒到期繼續阻塞著,直到到期出隊,然後獲取訂單的資訊,去處理訂單的更新操作

六、後總結

  • 這就是單機的不好處,也是一個痛點,所以肯定是不太適合訂單量特別大的場景 大家也要酌情考慮和運用
  • 相對於同等量級的資料庫輪詢操作來說,真是節省了不少資料庫的壓力和連線,還是值得一用的,我們可以只儲存訂單的id到延時例項中,這樣縮減佇列單個例項記憶體儲存
  • 那還有技巧就是更新的時候注意控制好冪等性,控制好冪等性,會讓你輕鬆很多,順暢很多,但是資料量大了,要蛀牙的哦

那今日份的講解就到此結束,具體的程式碼請移步我的gitHub的mybot專案Master分支查閱,fork體驗一把,或者評論區留言探討,寫的不好,請多多指教~~

實戰|我還是很建議你用DelayQueue搞定超時訂單的-(1)

相關文章