一個簡單的時間視窗設計與實現

一灰灰發表於2018-06-21

logo

如何設計一個計數的時間視窗

時間視窗,通常對於一些實時資訊展示中用得比較多,比如維持一個五分鐘的交易明細時間視窗,就需要記錄當前時間,到五分鐘之前的所有交易明細,而五分鐘之前的資料,則丟掉

一個簡單的實現就是用一個佇列來做,新的資料在對頭新增;同時起一個執行緒,不斷的詢問隊尾的資料是否過期,如果過期則丟掉

另外一中場景需要利用到這個時間視窗內的資料進行計算,如計算著五分鐘交易中資金的流入流出總和,如果依然用上面的這種方式,會有什麼問題?

  • 如果時間視窗大,需要儲存大量的明細資料
  • 我們主要關心的只有資金流入流出;存這些明細資料得不償失
  • 每次新增or刪除過期資料時,實時計算流入流出消耗效能

針對這種特殊的場景,是否有什麼取巧的實現方式呢?

I. 方案設計

1. 基於佇列的輪詢刪除方式

將時間視窗分割成一個一個的時間片,每個時間片中記錄資金的流入流出總數,然後總的流入流出就是所有時間片的流入流出的和

新增資料:

  • 若未跨時間片,則更新隊頭的值
  • 若跨時間片,新增一個佇列頭

刪除資料:

  • 輪詢任務,判斷佇列尾是否過期
  • 隊尾過期,則刪除隊尾,此時若隊頭資料未加入計算,也需要加入計算

image.png

2. 基於佇列的新增時刪除方式

相比較前面的輪詢方式,這個的應用場景為另外一種,只有在新增資料時,確保資料的準確性即可,不需要輪詢的任務去刪除過期的資料

簡單來說,某些場景下(比如能確保資料不會斷續的進來,即每個時間片都至少有一個資料過來),此時希望我的時間視窗資料是由新增的資料來驅動並更新

新增資料:

  • 未跨時間片,則更新隊頭值
  • 跨時間片,新塞入一個,並刪除舊的資料

image.png

II. 基於陣列的時間視窗實現

針對上面第二種,基於陣列給出一個簡單的實現,本篇主要是給出一個基礎的時間視窗的設計與實現方式,當然也需要有進階的case,比如上面的資金流入流出中,我需要分別計算5min,10min,30min,1h,3h,6h,12h,24h的時間視窗,該怎麼來實現呢?能否用一個佇列就滿足所有的時間視窗的計算呢?關於這些留待下一篇給出

1. 時間輪計算器

前面用佇列的方式比較好理解,這裡為什麼用陣列方式來實現?

  • 固定長度,避免頻繁的新增和刪除物件
  • 定位和更新資料方便

首先是需要實現一個時間輪計算器,根據傳入的時間,獲取需要刪除的過期資料

@Data
public class TimeWheelCalculate {
    private static final long START = 0;

    private int period;
    private int length;

    /**
     * 劃分的時間片個數
     */
    private int cellNum;

    private void check() {
        if (length % period != 0) {
            throw new IllegalArgumentException(
                    "length % period should be zero but not! now length: " + length + " period: " + period);
        }
    }

    public TimeWheelCalculate(int period, int length) {
        this.period = period;
        this.length = length;

        check();

        this.cellNum = length / period;
    }

    public int calculateIndex(long time) {
        return (int) ((time - START) % length / period);
    }

    /**
     * 獲取所有過期的時間片索引
     *
     * @param lastInsertTime 上次更新時間輪的時間戳
     * @param nowInsertTime  本次更新時間輪的時間戳
     * @return
     */
    public List<Integer> getExpireIndexes(long lastInsertTime, long nowInsertTime) {
        if (nowInsertTime - lastInsertTime >= length) {
            // 已經過了一輪,過去的資料全部丟掉
            return null;
        }

        List<Integer> removeIndexList = new ArrayList<>();
        int lastIndex = calculateIndex(lastInsertTime);
        int nowIndex = calculateIndex(nowInsertTime);
        if (lastIndex == nowIndex) {
            // 還沒有跨過這個時間片,則不需要刪除過期資料
            return Collections.emptyList();
        } else if (lastIndex < nowIndex) {
            for (int tmp = lastIndex; tmp < nowIndex; tmp++) {
                removeIndexList.add(tmp);
            }
        } else {
            for (int tmp = lastIndex; tmp < cellNum; tmp++) {
                removeIndexList.add(tmp);
            }

            for (int tmp = 0; tmp < nowIndex; tmp++) {
                removeIndexList.add(tmp);
            }
        }
        return removeIndexList;
    }
}
複製程式碼

這個計算器的實現比較簡單,首先是指定時間視窗的長度(length),時間片(period),其主要提供兩個方法

  • calculateIndex 根據當前時間,確定過期的資料在陣列的索引
  • getExpireIndexes 根據上次插入的時間,和當前插入的時間,計算兩次插入時間之間,所有的過期資料索引

2. 時間輪容器

容器內儲存的時間視窗下的資料,包括實時資料,和過去n個時間片的陣列,其主要的核心就是在新增資料時,需要判斷

  • 若跨時間片,則刪除過期資料,更新實時資料,更新總數
  • 若未跨時間片,則直接更新實時資料即可
@Data
public class TimeWheelContainer {
    private TimeWheelCalculate calculate;

    /**
     * 歷史時間片計數,每個時間片對應其中的一個元素
     */
    private int[] counts;

    /**
     * 實時的時間片計數
     */
    private int realTimeCount;

    /**
     * 整個時間輪計數
     */
    private int timeWheelCount;

    private Long lastInsertTime;


    public TimeWheelContainer(TimeWheelCalculate calculate) {
        this.counts = new int[calculate.getCellNum()];
        this.calculate = calculate;
        this.realTimeCount = 0;
        this.timeWheelCount = 0;
        this.lastInsertTime = null;
    }

    public void add(long now, int amount) {
        if (lastInsertTime == null) {
            realTimeCount = amount;
            lastInsertTime = now;
            return;
        }


        List<Integer> removeIndex = calculate.getExpireIndexes(lastInsertTime, now);
        if (removeIndex == null) {
            // 兩者時間間隔超過一輪,則清空計數
            realTimeCount = amount;
            lastInsertTime = now;
            timeWheelCount = 0;
            clear();
            return;
        }

        if (removeIndex.isEmpty()) {
            // 沒有跨過時間片,則只更新實時計數
            realTimeCount += amount;
            lastInsertTime = now;
            return;
        }

        // 跨過了時間片,則需要在總數中刪除過期的資料,並追加新的資料
        for (int index : removeIndex) {
            timeWheelCount -= counts[index];
            counts[index] = 0;
        }
        timeWheelCount += realTimeCount;
        counts[calculate.calculateIndex(lastInsertTime)] = realTimeCount;
        lastInsertTime = now;
        realTimeCount = amount;
    }

    private void clear() {
        for (int i = 0; i < counts.length; i++) {
            counts[i] = 0;
        }
    }
}
複製程式碼

3. 測試

主要就是驗證上面的實現有沒有明顯的問題,為什麼是明顯的問題?

  • 深層次的bug在實際的使用中,更容易暴露
public class CountTimeWindow {

    public static void main(String[] args) {
        TimeWheelContainer timeWheelContainer = new TimeWheelContainer(new TimeWheelCalculate(2, 20));

        timeWheelContainer.add(0, 1);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 0, "first");

        timeWheelContainer.add(1, 1);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 0, "first");

        timeWheelContainer.add(2, 1);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 2, "second");
        Assert.isTrue(timeWheelContainer.getCounts()[0] == 2, "second");

        for (int i = 3; i < 20; i++) {
            timeWheelContainer.add(i, 1);
            System.out.println("add index: " + i + " count: " + timeWheelContainer.getTimeWheelCount());
        }

        // 剛好一輪

        timeWheelContainer.add(20, 3);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 20, "third");
        timeWheelContainer.add(21, 3);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 20, "third");


        // 減去過期的那個資料
        timeWheelContainer.add(22, 3);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 26 - 2, "fourth");
        Assert.isTrue(timeWheelContainer.getCounts()[0] == 6, "fourth");


        timeWheelContainer.add(26, 3);
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 24 - 2 - 2 + 3, "fifth");
        System.out.println(Arrays.toString(timeWheelContainer.getCounts()));


        timeWheelContainer.add(43, 3);
        System.out.println(Arrays.toString(timeWheelContainer.getCounts()));
        Assert.isTrue(timeWheelContainer.getTimeWheelCount() == 6, "six");
    }
}
複製程式碼

III. 其他

1. 一灰灰Blog: https://liuyueyi.github.io/hexblog

一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛

2. 宣告

盡信書則不如,已上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激

3. 掃描關注

blogInfoV2.png

相關文章