Kylin cuboid算法修改

緣由

    近期由於發現線上cube的構建時間太慢(一個項目的cube構建前一天的數據一般需要170分鐘左右),目前我們接入的應用才三個,如果後期接入更多的cube之後會導致更慢的cube構建速度,於是深入瞭解了一下cuboid是如何確定的,看了代碼之後發現和我們預想的不一樣,於是經過諮詢社區之後也覺得之前的算法是存在一定的問題(2.x版本已經對此做了修改),因此就準備對cuboid的計算進行修改。

Kylin原有cuboid算法

    瞭解了kylin中如何對cube進行優化(參見OLAP引擎——Kylin介紹Kylin使用之創建Cube和高級設置)之後,下面來看一下kylin是確定哪些cuboid需要計算,哪些是不需要計算的呢?

    在Kylin中保存cube的時候需要對cuboid進行校驗,通過三種方案計算出生成cuboid的數量,然後對比三種方法生成的數量是否相同,如果不相同這說明cube的定義存在問題(例如一個hierarchy組的不如維度分佈在不同的group中)。在Kylin中,使用位圖來計算cuboid,每一個維度在位圖上使用一個位置(所以維度超過64就會出現問題),每一個cuboid的值是一個long值,它的二進制中爲0的位置表示對應的維度不出現在這個cuboid中,而爲1的表示該cuboid是這些維度的組合。保存cube的時候會通過三種方式計算可能產生的cuboid個數,根據個數是否相同來判斷cube的定義是否正確:

  • 根據樹的根節點(base cuboid)計算它的spanning cuboid然後再一次計算每一個spanning cuboid的spanning cuboid,並將它們加入到set中,但是需要保證每一個cuboid的spanning cuboid是不相同的,因爲如果重複可能會導致cuboid被重複計算,每一個cuboid在計算spanning cuboid是從它的child cuboid中過濾掉它的兄弟cuboid的child cuboid,這樣就能夠保證每一個cuboid的spanning是唯一的。
  • 從0開始,遞增直到base cuboid(位圖上所有維度對應的位置都爲1),依次遞增,對每一個cuboid判斷它是否需要被計算,然後統計所有需要計算的cuboid個數。這種方法可能隨着維度數的增加變得性能很差,試想32個維度的cube需要2的32次方的遍歷。
  • 通過數學的方法計算需要計算的cuboid個數。

    首先看一下kylin中如何驗證一個cuboid是否需要計算(第二種計算方案利用了這種方式判斷滿足分組的cuboid的計數):

public static boolean isValid(CubeDesc cube, long cuboidID) {
    RowKeyDesc rowkey = cube.getRowkey();

    if (cuboidID < 0) {
        throw new IllegalArgumentException("Cuboid " + cuboidID + " should be greater than 0");
    }

    if (checkBaseCuboid(rowkey, cuboidID)) {
        return true;
    }

    if (checkMandatoryColumns(rowkey, cuboidID) == false) {
        return false;
    }

    if (checkAggregationGroup(rowkey, cuboidID) == false) {
        return false;
    }

    if (checkHierarchy(rowkey, cuboidID) == false) {
        return false;
    }

    return true;
}

可以看出kylin會檢查一個cuboid多個屬性,按照如下步驟:

  • 查看是否大於0,由於使用位圖,所以所有的cuboid都必須是大於0的值
  • 查看是否base cuboid
  • 查看它是否包含所有mandatory維度,並且除去mandatory維度之外還必須包含至少一個其它維度,也就是除去mandatory之後爲0的cuboid不需要預計算
  • 查看這個cuboid是否符合分組的定義
  • 查看這個cuboid是否符合hierarchy維度組的定義。

    之所以沒有檢查derived維度組,是因爲derived維度組並不是約束cuboid的,而使用的是替換的方式將一個維度表中的derived維度替換成使用主鍵的維度。

重點來看一下檢查是否滿足分組約束的,其他的檢查都是比較簡單的:

private static boolean checkAggregationGroup(RowKeyDesc rowkey, long cuboidID) {
    long cuboidWithoutMandatory = cuboidID & ~rowkey.getMandatoryColumnMask();
    long leftover;
    for (AggrGroupMask mask : rowkey.getAggrGroupMasks()) {
        if ((cuboidWithoutMandatory & mask.uniqueMask) != 0) {
            leftover = cuboidWithoutMandatory & ~mask.groupMask;
            return leftover == 0 || leftover == mask.leftoverMask;
        }
    }

    leftover = cuboidWithoutMandatory & rowkey.getTailMask();
    return leftover == 0 || leftover == rowkey.getTailMask();
}

從這段代碼看出,每一個維度組保存了三個mask信息:

  • groupMask:每一個組中所有的維度對應的位置都置爲1的值。
  • uniqueMask:只包含在本組而不包含在後面所有組的那些維度對應位置都置爲1的值。
  • leftoverMask:不包含在本組中,但是包含在後面其餘組中的所有維度對應的位置都置爲1的值。

除此之外,leftover表示不包含在所有的mask同時也不是mandatory維度的那些維度。

   在檢查一個cuboid是否符合組約束的時候首先去除了mandatory維度,然後檢查每一個分組,如果該cuboid中有一個維度是某一個group獨有的(包含在uniqueMask中),那麼說明只需要在該組中檢查就可以了,此時判斷這個cuboid再去除所有該組的維度之後是否不包含任何維度(說明除去mandatory維度以外不包含任何該組外的維度了)或者它還包含了不在本組但是在後面所有組的所有的維度。最後,如果它不包含在任何組中,那麼只需要查看它是否等於leftover就可以了。從這裏可以推斷出,所有的cuboid包含這兩部分:1、每一個組內成員的任意組合(全部成員都包含在一個組裏面),2、只有部分維度包含在一個組裏面,其餘的維度等於這個組的leftoverMask。

優化cuboid算法

   但是這種計算cuboid的策略和我們上面分析的不一致,並且這種算法總是考慮每一個組的leftoverMask,所以會導致兩個問題:1、分組的順序影響計算的cuboid,2、分組的時候需要考慮到每一個組的leftover有哪些維度,不容易和查看進行匹配。總體來講,這是一個較爲複雜的邏輯,這會導致我們不能根據可能查詢的SQL輕易地推斷出如何進行分組,因此我們考慮簡化這部分邏輯,目標只有一個:減小cuboid計算量,不再計算第二部分cuboid。
修改之後的isValid函數保持相同的邏輯,而checkAggregationGroup如下:

private static boolean checkAggregationGroup(RowKeyDesc rowkey, long cuboidID) {
    long cuboidWithoutMandatory = cuboidID & ~rowkey.getMandatoryColumnMask();
    long leftover;
    for (AggrGroupMask mask : rowkey.getAggrGroupMasks()) {
        //all in one mask group
        leftover = cuboidWithoutMandatory & ~mask.groupMask;
        if (leftover == 0)
            return true;
    }

    return false;
}

修改之後的組規則只會考慮這個cuboid是否屬於某一個分組,這樣就邊的清晰明瞭了。

除了這裏的修改之外,還有比較重要的修改在於計算每一個cuboid的spanning cuboid,目前採取的策略如下:

  • 如果是base cuboide,那麼它的spanning cuboid就是所有組的groupMask。
  • 查看該cuboid所有小於它的sibling cuboid(1的個數相同,但是位置不同),並將每一個sibing cuboid的child cuboid加入到一個set中。
  • 查看該cuboid的child cuboid,如果在2中計算的set中存在則不作爲spanning cuboid,否則加入到結果集中。
  • 在計算cuboid的child cuboid的時候會遍歷所有組,如果屬於某個組則從這個cuboid中去掉該組中的某個維度作爲它的child cuboid。

    通過數學方法計算cuboid數量也需要相應的修改,根據我們優化之後的cuboid計算方法,這個計算可以演化成在多個分組中如果計算出不同的組合個數,例如[1001101, 10001100, 00101101]這三個mask,如果計算包含在其中一個或者多個mask的數的個數,最簡單的計算就是分別計算出每一個mask可能的組合數,然後三個masj的組合數相加,在減去他們之間的交集,只不過這裏面的交集還有交集,所以需要遞歸的進行,代碼如下:

private static int mathCalculateGroupCount(RowKeyDesc rowkey, long[] groups) {
    int sum = 0;
    for(int i = 0 ; i < groups.length ; ++ i) {
        sum += mathCount(rowkey, groups, i, groups.length - 1);
    }
    return sum;
}

private static int mathCount(RowKeyDesc rowkey, long[] groups, int cur, int end) {
    long current = groups[cur];
    if(current == 0)
        return 0;
    //ignore all 0 cuboid
    int count = mathCalcCuboidCount_combination(rowkey, current) - 1;
    long[] next = new long[end - cur];
    int index = 0;
    for(int i = cur + 1 ; i <= end ; ++ i) {
        long com = current & groups[i];
        next[index++] = com;
    }
    return count - mathCalculateGroupCount(rowkey, next);
}

但是cuboid的計算主要是在計算cube的時候使用的,所以需要保證如下幾點:

  • 新算法計算出的cuboid需要是之前算法計算出的cuboid的子集,如果不能滿足,則可能出現查詢定位到新算法計算出的cuboid而實際上老數據並沒有計算這個cuboid導致返回數據爲空,通過分析可以看出新的算法只是在組規則中加強了約束條件。
  • 通過新老算法計算出的cuboid在merge的時候會不會出現問題,由於1的保證,在merge的時候會導致merge之後的數據包含在新算法的cuboid包含了新老數據,而其他的cuboid都只包含老數據,所以需要保證查詢的時候不會查到老的cuboid,這點是通過cuboid爲匹配的情況下往樹的上層查找的過程中只會查找已計算的cuboid保證,因此最終還是會查找到新算法計算出的cuboid,就不會導致查詢數據爲空了。

這種優化帶來的好處:

  • 縮短每次build的時間,每層需要計算的key變少了,同時下一層的輸入也變小了。
  • 減小hbase中存儲cuboid的空間。
  • 對老數據沒有任何影響。

缺點:

  • 如果沒有某一次查詢不能命中到某一個分組,需要從base cuboid中掃描,可能導致更大的掃描範圍,性能降低。
  • 代碼修改可能會帶來未知的bug。

總結

    總體來說,本次對cuboid算法的修改是具有可行性的,但是相對比較冒進的,如果查詢的SQL大部分情況下都是確定的,那麼這樣的修改帶來的好處遠大於它所帶來的查詢的影響,目前修改之後運行比較穩定。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章