「時序資料庫」Druid 多維查詢Bitmap索引

時序數據庫從抽象語義上來說總體可以概括為兩個方面的基本需求,一個方面是存儲層面的基本需求:包括LSM寫入模型保證寫入性能、數據分級存儲(最近2小時的數據存儲在內存中,最近一天的數據存儲在SSD中,一天以後的數據存儲在HDD中)保證查詢性能以及存儲成本、數據按時間分區保證時間線查詢性能。另一方面是查詢層面的基本需求:包括基本的按時間線進行多個維度的原始數據查詢、按時間線在多個維度進行聚合後的數據統計查詢需求以及TopN需求等。

可見,多維條件查詢通常是時序數據庫的一個硬需求,其性能好壞也是評價一個時序數據庫是否優秀的一個重要指標。調研了市場上大多時序數據庫(InfluxDB、Druid、OpenTSDB、HiTSDB等),基本上都支持多維查詢,只有極個別的暫時支持的並不完美。通常來說,支持多維查詢的手段無非兩種:Bitmap Index以及Inverted Index,也稱為位圖索引和倒排索引。

接下來筆者會重點介紹使用Bitmap索引來加快多維條件查詢的基本原理以及工程實踐,最後也會對倒排索引進行一個簡單的介紹。其實這兩種索引無論在原理上還是在工程實踐上都極其相似,只是在幾個小的細節問題上有所不同,在文章最後筆者也會進行詳細的說明。

Bitmap索引到底是個神馬

Bitmap稱為位圖,對此不瞭解的童鞋可以自行Google。在此我們舉個簡單的例子來演示如何使用Bitmap Index來加速數據庫的多維查詢性能。下圖是一張典型的時序數據表:

Timestamp

Page

Username

Gender

City

Added

Removed

2011-01-01T01:00:00Z

Justin Bieber

Boxer

Male

San Francisco

1800

25

2011-01-01T01:00:00Z

Justin Bieber

Reach

Female

Taiyuan

2912

42

2011-01-01T02:00:00Z

Ke$ha

Helz

Female

Calgary

1953

17

2011-01-01T02:00:00Z

Ke$ha

Xeno

Male

Taiyuan

3194

170

圖中Timestamp列是時序列,Page、Username、Gender和City這幾個列是維度列,Added以及Removed兩列是數值列。基於這樣的原始表,可以構造一個典型的多維查詢如下:

select Added from datasource where Gender = ‘Female’ and City = ‘Taiyuan’

查詢中使用兩個維度條件進行過濾,分別是Gender以及City列。很顯然,如果不使用任何技術手段的話,在原始表上根據如上兩個維度的過濾條件進行查詢需要遍歷整個原始表,並對相應維度列進行過濾,這個代價很顯然是非常可觀的。那能不能有一種方法可以直接根據維度的過濾條件得到待查找目標行,比如上述示例中能不能根據Gender = ‘Female’ and City = ‘Taiyuan’這兩個過濾條件直接定位到待查找目標行就是第二行,其他行都不滿足條件,這樣的話只需要查找第二行的Added列返回給用戶即可,不再需要野蠻的全表掃描並一條一條數據進行對比。這就是Bitmap索引(倒排索引)的使命!

使用Bitmap索引的基本原理是將這兩列上的數值映射到bitmap上,再採用intersection表示來實現and、or等這種查詢謂詞。在上述示例中,將Gender以及City兩列映射成bitmap如下圖所示:

「時序數據庫」Druid 多維查詢Bitmap索引

原始表中,Gender列中有兩個值:Male和Female,因此需要設置兩個對應的bitmap,Male分配一個,Female分配一個,兩個bitmap的大小對應原始表的數據行數,原始數據有4行,bitmap的大小就是4。再看原始表的Gender列,行1和行4是Male,行2和行3是Female。因此將Male對應的bitmap中座標為1和4的值置為1,其他兩位置為0。Female對應的bitmap中座標為2和3的值置為1,其他兩位置為0。

這樣的bitmap表示什麼意思呢?以Male對應的bitmap來說,下標是1和4的值為1就表示原始表中這一列的第一行和第4行的值為Male。同理,Female對應的bitmap中下標是2和3對應的值為1表示原始表中這一列的第2行和第3行的值為Female。同樣的道理,City列可以表示為上圖右側3個bitmap。

可見,每個維度列有多少種取值(Cardinality),這個維度列就會有多少個Bitmap。每個Bitmap表示對應取值在原始表中哪些行出現過。

這樣表示完成之後,再來看看查詢語句:where Gender = ‘Female’ and City = ‘Taiyuan’,就可以使用對應bitmap表示為如下形式:

「時序數據庫」Druid 多維查詢Bitmap索引

分別拿出 Gender = ‘Female’ and City = ‘Taiyuan’ 對應的bitmap,執行and操作實際上對應位圖的與運算,最終得到一個結果位圖,結果位圖中只有下標2的值置為1,說明原始表中滿足查詢條件的行只有第二行。接下來的工作就是怎麼查詢第二行的Added數值,這裡就不再贅述。

很多講解位圖索引的博客對位圖索引的介紹大多到此為止,僅僅介紹位圖索引的工作原理。本文在介紹位圖索引工作原理的基礎上還會進一步深入介紹在真實的工程實踐中整個位圖索引工作體系。本文以Druid系統的目標,對Druid中位圖索引的工作原理深入分析。主要包括如下幾個部分:

「時序數據庫」Druid 多維查詢Bitmap索引

之前在一個開源項目中實現過一個倒排索引功能,其實與Bitmap索引實現原理基本一致。因為在之前並沒有接觸過倒排索引相關的實踐知識,因此頭腦中也沒有非常完整的勾勒出這個功能的核心體系,在實現的時候才發現這樣那樣的問題,雖說最後也實現了功能,現在想來整個系統的模塊化設計並不是非常考究。經過倒排索引項目的洗禮,再結合這段時間對Druid中Bitmap索引實現的研究,才將Bitmap索引這樣一個大功能分解成上圖中的五個小功能,每個小功能都是一個獨立模塊,筆者認為任何對Bitmap索引的工程實現都可以參考這五個模塊進行設計思考。接下來就以Druid中Bitmap索引的實現分別就這五個小功能的細節問題進行深入分析。

Bitmap索引如何在內存中構建?

Druid數據實時寫入節點採用LSM結構保證數據的寫入性能。數據先寫入內存,每隔10min(可配)會將內存中的數據persist到本地硬盤形成文件,然後會有一個線程再每隔1h(可配)將本地硬盤的多個文件合併成一個segment。

Bitmap索引構建時機

這裡實際上會碰到第一個需要權衡的問題:Bitmap索引是應該在數據寫入的同時實時構建呢,還是應該在數據從內存persist到硬盤的時候批量構建。很顯然,實時構建會對數據寫入吞吐量造成一定影響,實際測試下來發現寫入性能會下降5%到15%,而且表維度越多,性能下降越明顯。而另一方面,如果是批量構建,那麼內存中的數據實際上是沒有索引的,這部分數據的檢索方式必然與已經持久化到硬盤文件數據的檢索方式完全不同:內存中的數據檢索不走索引直接查數據,文件中的數據檢索需要先走索引再查數據,在實際查詢實現中需要分別處理。

Druid中Bitmap的構建時機採用的後者,即在數據從內存persist到硬盤的時候批量構建。本人實現倒排索引時採用的是前者,主要考慮的問題是希望無論數據是在內存還是在硬盤,都能夠採用統一的檢索方式,即都先根據索引查詢行號,再根據行號查具體數據。這樣將內存檢索和硬盤檢索統一處理的好處是在代碼實現上更加方便,更加簡潔。當然,會犧牲一部分寫入性能。

維度列構建維度字典

為維度列構建維度字典是Druid中非常重要的一個步驟。維度列中的值通常都可枚舉,比如上文示例中維度列Gender只有兩個可選值:Mela和Female,City列同樣取值可枚舉。因此有必要為每個維度列構建字典,將維度值(大多數為String)映射為Int值,大規模減少數據量。維度字典最核心的是兩個Map映射:valueToId和idToValue,以City列為例,該列有三個值,構建出的字典就是 valueToId : , , ,idToValue是map反過來。可以看出來,構建字典就是為維度列的取值賦一個自增的Int值。

同理,可以分別為Page列、UserName列和Gender列構建相應的維度字典,構建完成之後,原始表中第三行的所有維度列就不再是Page:Ke$ha, UserName:Helz, Gender:Female, City:Calgary,而是[1, 2, 1, 2]。

構建Bitmap索引

上文說到Druid中Bitmap索引是在內存數據異步persist到硬盤文件的時候構建的,那接下來就需要看看錶中一行記錄過來之後如何分別為每個維度列構建Bitmap索引。

在介紹具體的構建流程之前,需要先說明一個關鍵的點:每個維度列實際上都會維護一個Bitmap數組:MutableBitmap[],數組大小為每個維度列的可取值多少(Cardinality),比如Gender列只有Male和Female兩個取值,Bitmap數組大小就為2。數組的第一個值為Male對應的位圖數據,數組的第二個值為Female對應的位圖數據。這裡就有一個問題,為什麼說數組的第一個值是Male對應的位圖數據,而不是第二個值呢?這就是用到了上文中提到的維度字典,Male對應的維度字典值為0,就對應數組下標為0;Female對應的維度字典值為1,對應數據下標就為1。

下面以其中一行數據為例介紹構建Bitmap索引的過程:

1. 首先會為每一行生成一個自增的rowNum

2. 遍歷所有維度列,分別為每個維度列構建相應的Bitmap數組

  • 針對某個緯度列的value值,首先在維度字典中根據value找到對應的id,這個id即是Bitmap數組的下標,根據這個下標找到該value對應的位圖數據,即MutableBitmap[id]
  • 定位到位圖數據之後,再將該位圖下標為rowNum的bit位置為1

為了更加具體地理解整個Bitmap索引構建的過程,我們以上文中Gender維度列為例模擬構建的過程:

1. Gender維度列會維護了一個位圖數組MutableBitmap[] bitmaps,裡面有兩個位圖元素,下標為0的是Male對應的bitmap,下標為1的是Female對應的bitmap。初始時這兩個bitmap中都沒有任何數字。

2. 遍歷第一行(rowNum = 0),值為Male,根據維度字典找到對應的id位0,即Male對應的位圖數據為bitmaps[0],將bitmaps[0]下標0(rowNum為0)的bit位置為1,得到:

「時序數據庫」Druid 多維查詢Bitmap索引

3. 遍歷第二行(rowNum = 1),值為Female,根據維度字典找到對應的id位1,即Male對應的位圖數據為bitmaps[1],將bitmaps[1]下標1(rowNum為1)的bit位置為1,得到:

「時序數據庫」Druid 多維查詢Bitmap索引

4. 遍歷第三行(rowNum = 2),值為Female,根據維度字典找到對應的id位1,即Male對應的位圖數據為bitmaps[1],將bitmaps[1]下標2(rowNum為2)的bit位置為1,得到:

「時序數據庫」Druid 多維查詢Bitmap索引

5. 遍歷第一行(rowNum = 3),值為Male,根據維度字典找到對應的id位0,即Male對應的位圖數據為bitmaps[0],將bitmaps[0]下標3(rowNum為3)的bit位置為1,得到:

「時序數據庫」Druid 多維查詢Bitmap索引

這樣,就可以得到Gender維度列的Bitmap索引。當然,遍歷一行數據時,同時會為所有其他維度列構建Bitmap索引,上述過程僅以Gender維度列作為示例,其他維度列同理可得。

Bitmap索引如何進行壓縮處理?

Bitmap索引為什麼需要壓縮?

還是以Gender列為例,上文我們知道這個維度列只有兩個取值:Male和Female,因此無論對於Male對應的位圖數據,還是Female對應的位圖數據,都會存在大量的連續的0或者連續的1,非常適合壓縮編碼,減小存儲空間。

Bitmap索引如何進行壓縮?

針對Bitmap的壓縮有非常多的算法,大家可以自行Google。根據壓縮效率、解碼效率以及intersection等計算效率等指標的權衡,特別推薦使用RoaringBitmap壓縮算法。有興趣的同學可以自行Google。

Bitmap索引如何持久化存儲?

Druid中原始數據每隔一段時間就會落盤一次,隨著原始數據的落盤,原始數據中維度列對應的Bitmap索引也需要執行持久化存儲。在實際實現中,Druid首先將維度字典持久化到文件,再將原始數據(維度列都使用維度字典編碼處理)持久化到文件,最後再將維度列對應的Bitmap索引持久化到文件。

對於Druid系統來說,這裡需要關注兩點:

1. Druid系統是列式存儲系統,同一個segment中所有列的數據都會分別獨立存儲在一起形成多個列文件,比如City列的數據會存儲在一起形成文件,Added列的數據會存儲在一起形成文件。其他列同理。

2. Druid系統中的文件分為兩種,一種是定長文件格式,一種是非定長文件格式。定長文件針對於列數值是定長的,比如某些數值列是Double的,有些數據列是Long類型,再比如維度列經過編碼之後所有維度列都是Int類型,時間列是Long類型。這些定長文件格式很簡單,直接存儲數值即可。而非定長文件通常主要針對列數值不是定長的,比如維度字典文件中需要存儲維度值,這些維度值通常是字符串,並不定長;比如Bitmap索引的存儲文件中需要存儲Bitmap位圖數據,這些值也不是定長的。下文主要介紹Bitmap索引的存儲,所以重點介紹非定長文件格式。

Druid中非定長數值存儲的文件格式如下圖所示:

「時序數據庫」Druid 多維查詢Bitmap索引

可以看出,Druid系統中使用了3個文件來存儲非定長數據:meta文件,header文件以及value文件,其中meta文件主要存儲一些元數據信息,比如存儲數值個數、存儲數值總大小等;value文件存儲實際的數值,一個數值一個數值寫進去,在實際數據之前有一個int值表示該數值的大小;header文件實際上是value文件中每個數值在value文件的偏移量,文件中每個值都是一個int。

維度字典文件存儲

緯度列數據字典在數據寫入的時候就會構建,並一直保存在內存。Druid會在persist的時候將其持久化形成維度字典文件,每個維度列的字典會單獨形成一個文件,比如Gender維度列的數據字典會形成一個文件,City維度列的數據字典會形成另一個文件。下圖是City維度列形成的維度列字典文件格式(沒有列出meta文件):

「時序數據庫」Druid 多維查詢Bitmap索引

City維度列的數據字典一共有3個值:Calgary、San Francisco和Taiyuan,持久化到文件後就是上圖格式,需要特別注意的是:數據字典的值是按照字典序由小到大排列之後存入文件的。比如上圖中Calgary、San Francisco和Taiyuan就是按照由小到大排序後存儲的。

這個點是工程實踐中非常重要的一個技術點。上文中我們說數據字典在構建的時候其實並沒有強調排序,而是按照維度列進來系統的順序構建字典的,比如San Francisco先進入系統,在第一行,所以San Francisco對應的編碼值就為0,Taiyuan是第二行,所以Taiyuan對應的編碼值為1,同理,Calgary編碼值為2。而且,Bitmap索引構建也是依賴於非排序的維度字典。如果此時在持久化的時候要將維度字典進行排序,那意味著Bitmap位圖數據在Bitmap數組MutableBitmap[]中的位置也需要相應的變化,保持一致。

為什麼需要排序?如果不排序直接存儲行不行?

解答這個問題之前先看看維度字典文件,可以得到文件中只存儲了維度列的值,並沒有存儲對應的編碼值,那編碼值哪去了?實際上編碼值隱含在維度列值的下標,比如Calgary是第一個值,那對應的編碼值就是0,Taiyuan是第三個值,對應的編碼值就是2。基於這樣的事實,如果不排序,你來告訴我如果說我想查Taiyuan對應的編碼值,如何查?那就蒙圈了,需要一個一個遍歷的查,如果某個維度Cardinality很大的話,不就跪了。而反過來,如果排序的話,就可以通過二分查找來查,下文會舉例介紹。

Bitmap索引文件存儲

Bitmap索引文件和維度字典文件是一一對應的,每個維度列的Bitmap索引會單獨形成一個文件,比如Gender維度列的Bitmap索引會形成一個文件,City維度列的Bitmap索引會形成一個文件。下圖是City維度列形成的Bitmap索引文件:

「時序數據庫」Druid 多維查詢Bitmap索引

注意,Bitmap索引文件中Bitmap位圖數據的存儲順序必須和維度字典中對應值的存儲順序一致。比如維度字典中Calgary存儲在文件中第一的位置,對應的Bitmap位圖就必須存儲在相應第一的位置。

查詢時如何根據Bitmap索引構建Cursor體系?

以查詢語句select Added from datasource where Gender = ‘Female’ and City = ‘Taiyuan’為例,看看如何實現將where Gender = ‘Female’ and City = ’Taiyuan’這麼一個多維度過濾條件轉化成如下Bitmap與運算的結果:

「時序數據庫」Druid 多維查詢Bitmap索引

這樣一個過程實際上可以分為兩步:

1. 如何根據Gender = ‘Female’找到對應的位圖數據?同理,如何根據City = ’Taiyuan’找到對應的位圖數據?

2. 如何根據and操作符實現位圖與操作?

根據and操作符實現位圖與操作是比較簡單的,現在很多Bitmap實現包中都有類似的功能,在此不再贅述。因此構建Cursor體系實際上就簡化為根據維度過濾條件查找對應的位圖數據這樣一個問題。為了更加具體,我們以City = ’Taiyuan’為例定位對應的位圖數據。整個過程分為如下幾個部分:

1. 在City列對應的維度字典文件中查找’Taiyuan’值在文件中的下標

「時序數據庫」Druid 多維查詢Bitmap索引

因為文件中維度值是由小到大排序的,所以查找的戰術思想是二分查找。首先將查找指針移動到header文件的中心,中心下標curIndex = (minIndex,maxIndex)>>>1,header文件的中心值為offset_SanFrancisco,這個offset實際上指向了value文件中的San Francisco(這裡忽略了一些細節),這個值與我們要找的值Taiyuan相比較,很顯然前者小於後者,因此繼續往後找。經過多次的查找,最終定位到Taiyuan對應的下標是2(從0開始哦)。

2. 在City列對應的Bitmap索引文件中查找下標為2的Bitmap位圖數據,如下圖所示,首先在header文件中找到下標為2的offset為offset_ty_bm,再根據偏移值在value文件中定位出Taiyuan對應的bitmap位圖數據。(忽略具體的查找細節)

「時序數據庫」Druid 多維查詢Bitmap索引

經過這兩步的執行就可以根據City = ’Taiyuan’得到對應的bitmap位圖數據,同理,根據Gender = ‘Female’可以得到對應的bitmap位圖數據,兩者使用與運算就可以得到一個最終的Bitmap位圖索引,這個位圖索引我們稱為Cursor。

如何根據Cursor體系快速查找對應行數據?

Cursor結構體構建出來之後,如果根據這個結構快速的查找對應的行數據呢?這個過程也可以分為兩步:

1. 根據上文介紹知道Cursor結構體實際上就是一個bitmap,bitmap中置為1的下標表示該行數據符合過濾條件。因此需要順序遍歷這個bitmap的所有位,如果目標位為1,表示該目標位下標對應的行滿足過濾條件,需要將該行的對應數據找出來返回給用戶。否則不滿足過濾條件,直接跳過。

2. 假如bitmap中下標為的位置值為1,表示第二行滿足過濾條件,因此需要查找第二行Added列的值。實現起來很簡單,因為該列的所有值都存儲在一個文件中,而且每個值都定長(都是Int),因此可以很快的在文件中加載出startOffset為Ints.Bytes,endOffset為2*Ints.Bytes的值,即為Added的值。

其他需要考慮的問題

講到這裡,筆者基本上已經將Bitmap索引的工程實踐需要考量的技術點都做了介紹,但還有幾個點需要考慮:

1. Bitmap索引目前僅支持寫入,不支持更新。如果需要支持更新,需要做另外的考慮。

2. Bitmap索引文件需要在segment合併的時候也執行合併,合併的過程實際上也是一行一行的讀出來,然後再根據上述過程生成一個新的Bitmap索引文件。

Inverted Index(倒排索引)工程實踐

筆者之前在一個開源項目中實現了倒排索引功能,現在看來,基本實現思路和上述過程基本一致,核心不同點在於:倒排索引中每個維度列取值不再對應bitmap,而是對應一個列表。舉個栗子,Bitmap索引體系中,Gender維度列中Male對應一個bitmap是[1,0,0,1]。換成倒排索引,Gender維度列中Male對應的不再是bitmap,而是一個List : [0,2],分別表示第1行和第三行。

除此之外,還有一些實現細節有些許不同:

1. Bitmap壓縮性能通常沒有倒排索引中List壓縮效果好,前者會存在較大的存儲空間開銷。

2. Bitmap使用intersection實現and、or等操作的性能要好於倒排索引的List結構,後者需要從小到大遍歷查找

3. 使用Bitmap構建的Cursor加速原始數據查找,需要遍歷bitmap來找哪一行滿足條件,只有bit位是1的才滿足條件;而倒排索引構建的Cursor不需要查找,List中的數值就直接對應行號。

在常見的時序數據庫中,InfluxDB和HiTSDB都使用了倒排索引來加速多維度查詢,倒排索引會首先在內存中構建並持久化到文件(或HBase),在使用時再將索引加載到內存。

文章總結

這是很早之前花時間將之前研究的Bitmap索引知識整理了出來,拿出來和大家分享。本文從理論和工程實踐兩個方面對Bitmap索引的工作原理進行了深入的介紹,筆者認為文章的核心在於如何在工程實踐中將Bitmap索引這麼一個大命題分解成五個子命題,每個子命題中我們又應該重點關注哪些技術點。不得不說,要講清楚Bitmap索引的工程實踐確實有一定難度,文中或多或少會有一些難於理解的地方甚至紕漏。還忘各位看官擔待指正!


分享到:


相關文章: