本文轉自:
http://hbasefly.com/2018/02/09/timeseries-database-5/
在
時序數據庫概述一文中,筆者提到時序數據庫的基礎技術棧主要包括高吞吐寫入實現、數據分級存儲|TTL、數據高壓縮率、多維度查詢能力以及高效聚合能力等,上文《時序數據庫技術體系 – InfluxDB存儲引擎TSM》基於InfluxDB存儲引擎TSM介紹了時序數據庫的高性能寫入能力以及基於列式存儲的數據高壓縮率實現。接下來兩篇文章分別基於InfluxDB系統的倒排索引實現以及Druid系統的Bitmap索引實現介紹時序數據庫的多維度查詢實現原理。
InfluxDB系統TSM存儲引擎個人認為有兩個最核心的工作模塊,其一是TSM針對時序數據在內存以及文件格式上做了針對性的優化,優雅地實現了時序數據的高效率寫入以及高壓縮率存儲,同時文件級別的B+樹索引可以有效提高時序數據根據SeriesKey查詢時間序列的性能;其二是InfluxDB系統還實現了內存以及文件級別的倒排索引,有效實現了根據給定維度fieldKey查詢對應SeriesKey的功能,這樣再根據SeriesKey、fieldKey和時間間隔就可以在文件中查找到對應的時序數據集合。
上文筆者提到SeriesKey等於measurement+tags(datasources),其中measurement表示一張時序數據表,tags(多組維度值)唯一確定了數據源。用戶的查詢通常有以下兩種查詢場景,以廣告時序數據平台來說:
1. 查看最近一小時某一個廣告(數據源)總的點擊量,典型的根據SereisKey、fieldKey(點擊量)和時間范圍查找時序數據,再做聚合(sum)。
2. 統計最近一天網易考拉(指定廣告商)發布在網易雲音樂(指定廣告平台)的所有廣告總的點擊量。這種統計查詢並沒有給出具體的廣告(SeriesKey),僅指定了兩個廣告維度(廣告商和廣告平台)以及查詢指標 – 點擊量。這種查詢就首先需要使用倒排索引根據measurement以及部分維度組合(廣告商=網易考拉,廣告平台=網易雲音樂)找到所有對應的廣告源,假如網易考拉在網易雲音樂上發布了100個廣告,就需要查找到這100個廣告點擊量對應的SeriesKey,再分別針對所有SeriesKey在最近一天這個時間范圍查找點擊量數據,最后做sum聚合。
如何根據measurement以及部分維度組合查找到所有滿足條件的SeriesKey?InfluxDB給出了倒排索引的實現,稱之為TimeSeries Index,意為TimeSeries的索引,簡稱TSI。InfluxDB TSI在1.3版本之前僅支持Memory-Based Index實現,1.3之后又實現了Disk-Based Index實現。
Memory-Based Index
Memory-Based Index方案將所有TimeSeries索引加載到內存提供服務,核心數據結構主要有:
其中seriesByTagKeyValue是一個雙重map,即map<tagkey, map<tagvalue, List<SeriesID>>>。以上文中廣告商=網易考拉為例來解釋:
tagkey為廣告商,廣告商可以有網易考拉,還可能有網易嚴選,所以一個廣告商這個tagkey對應一個map。map的key是tagvalue,value是SeriesID集合。示例中tagvalue為網易考拉,映射的值為SeriesID集合。
因此上文中第二種查詢場景就可以通過下述步驟完成:
1. 通過seriesByTagKeyValue這個內存結構以及給定的維度值廣告商=網易考拉找到所有包含該維度值的SeriesID集合
2. 同樣的方法,通過seriesByTagKeyValue以及給定的維度值廣告平台=網易雲音樂找到包含該維度值的SeriesID集合
3. 兩個SeriesID集合再做交集就是同時滿足廣告商=網易考拉,廣告平台=網易雲音樂的所有SeriesID
4. 再在SeriesByID – map<SeriesID, SeriesKey>中根據SeriesID集合映射查找到SeriesKey集合
5. 最后根據SeriesKey集合以及時間范圍找到所有滿足條件的時序數據集合
這里為什么使用SeriesID作為跳板找到SeriesKey,而不是直接映射得到SeriesKey?因為seriesByTagKeyValue這個結構中索引到的SeriesKey會有大量冗余,一個SeriesKey包含多少Tag組合,就會有多少份冗余。舉個簡單的例子:
假如現在有3個Tag組合形成一個seriesKey:measurement=mm,tagk1=tagv1,tagk2=tagv2,tagk3=tagv3。那么構造形成的雙重Map結構seriesByTagKeyValue就會為:
<tagk1, <tagv1, seriesKey>>
<tagk2, <tagv2, seriesKey>>
<tagk3, <tagv3, seriesKey>>
此時,假如用戶想找tagk1=tagv1這個維度條件下的seriesKey,那第一個map就滿足條件。很顯然,這種場景下3個Tag組成的seriesKey,最終形成的seriesByTagKeyValue就會有3重seriesKey冗余。
因此使用Int類型的SeriesID對SeriesKey進行編碼,將長長的SeriesKey編碼成短短的SeriesID,可以有效減少索引在內存中的存儲量。另外,SeriesID集中存儲在一起可以使用Int集合編碼有效壓縮。
Memory-Based Index實現方案好處是可以根據tag查找SeriesKey會非常高效,但是缺點也非常明顯:
1. 受限於內存大小,無法支持大量的TimeSeries。尤其對於某些基數非常大的維度,會產生大量的SeriesKey,使用Memory-Based Index並不合適。
2. 一旦InfluxDB進程宕掉,需要掃描解析所有TSM文件並在內存中全量構建TSI結構,恢復時間會很長。
Disk-Based Index
正因為Memory-Based Index存在如此重大的缺陷,InfluxDB 1.3之后實現了Disk-Based Index。Disk-Based Index方案會將索引持久化到磁盤,在使用時再加載到內存。InfluxDB官網對Disk-Based Index實現方案做了如下說明:

不難看出,InfluxDB中倒排索引和時序數據使用了相同的存儲機制 – LSM引擎。因此倒排索引也是先寫入內存以及WAL,內存中達到一定閾值或者滿足某些條件之后會執行持久化操作,將內存中的索引寫入文件。當磁盤上文件數超過一定閾值會執行Compaction操作進行合並。實際實現中,時序數據點寫入系統后會抽出Measurement、Tags並拼成SeriesKey,在系統中查看該SeriesKey是否已經存在,如果存在就忽略,否則寫入內存中相應結構(參考log_file文件中變量InMemory Index)。接着內存中的數據會flush到文件(參考log_file文件中CompactTo方法),接下來筆者將會重點介紹TSI文件格式,如下圖所示:

TSI文件主要由4個部分組成:Index File Trailer,Measurement Block,Tag Block以及Series Block。
1. File Trailer主要記錄Measurement Block、Tag Block以及Series Block在TSI文件中的偏移量以及數據大小。
2. Measurement Block存儲數據庫中表的信息,通常來說Measurement不會太多,一個Block也就夠了。
3. Tag Block實際上是seriesByTagKeyValue這個雙重map – map<tagkey, map<tagvalue, List<SeriesID>>>在文件中的實際存儲。
4. Series Block存儲了數據庫中所有SeriesKey。
Measurement Block

Measurement Block存儲數據庫中所有時序數據表表名信息,Block主要由三部分組成:Block Trailer Section、Hash Index Section以及Measurement Entry Section。
1. Block Trailer Section記錄了Hash Index Section以及Measurement Data Section在文件中的偏移量以及數據大小,是Measurement Block讀取解析的入口。
2. Hash Index是一個Hash索引。實現機制很簡單,就是一個Map結構 – map<measurement, offset>。使用Hash函數將給定measurement映射到數組的特定位置,將該特定數組位的值置為該measurement在文件中的實際偏移量。Hash Index主要有兩個核心作用:
(1)加快Measurement的查找效率。正常情況下在Block中查找某個Measurement Entry只能依次遍歷查找,或者二分查找,而使用Hash索引可以直接在o(1)復雜度找到待查Measurement。
(2)減小內存開銷。如果沒有Hash Index,在Measurement Block中查找一個Measurement Entry,需要將該Block全部加載到內存再查找。Measurement Block本身大小不特定,有可能很大,也可能很小,一旦Block很大的話內存開銷會非常之大。而使用Hash Index的話,只需要將Hash Index加載到內存,根據Hash Index定位到Measurement Entry具體的offset,直接根據偏移量加載具體的待查找measurement。
3. Measuremen是具體的時序數據表,比如廣告信息表等。Measurement是一個復合結構,由一系列字段組成,其中name表示指標名,TagBlock offset以及TagBlock size表示該Measurement所對應的TagBlock在索引文件中的偏移量以及大小。因此可以使用Measurement過濾掉大量不屬於該Measurement的Tags。
Tag Block

TagBlock中存儲同一個Measurement下的Tags。Tag Block由三部分組成:Block Trailer、Tag Key Section以及Tag Value Section:
1. Block Trailer:存儲Tag Key Hash Index的offset以及size,TagKey Section的offset以及size,TagValue Section的offset以及size。通過解析Trailer,可以快速找到Block中各個部分的解析入口。
2. Tag Key Section:存儲指定Measurement下所有維度名信息,比如廣告時序數據有publisher、advertiser、gender、country等維度。每個Tag Key由多個字段組成,是一個復合結構,如下圖所示:

其中key字段表示維度名,TagValue相關字段(TagValue.offset、TagValue.size,…)表示該維度下所有維度值在文件中的存儲區域。
3. Tag Value Section:存儲某個維度下的所有維度值。比如廣告時序數據中advertiser這個維度可能有多個值,比如google.com、baidu.com、163music.com等等一系列值,所有這些值會集中存儲在一起,這個區域就是advertiser維度對應的Tag Value Section。同理,其他維度諸如publisher、gender、country等都會有對應的Tag Value Section。Tag Value Section中每個Tag Value也是一個復合結構,如下圖所示:
其中value字段和series.data兩個字段是需要重點關注的兩個字段。前者表示具體的維度值,后者表示這個維度值對應的一系列SeriesKey。注意,存儲的時候並沒有直接存儲SeriesKey,而是存儲SeriesID。上文重點說明了存儲SeriesID而不直接存儲SeriesKey的原因。
關於Tag Block,筆者在思考的時候一直在思考兩個問題:
1. Tag Block中每個數據Section都有對應的Hash Index,用來加速查找。但是有沒有注意到Hash Index只能實現等值查找加速,但是不能實現范圍查找,比如大於、小於條件查找。假如現在用戶想要根據維度advertiser=163music.com查找對應的所有seriesKey,可以很容易:
(1)在Tag Key Section的Hash Index一下子就找到對應Tag Key(advertiser)在文件中的offset
(2)再從文件中加載出Tag Key,解析出advertiser對應的Tag Value Section在文件中的offset
(3)根據Tag Vlaue Section在文件中的offset加載出Tag Value Section對應的Hash Index,使用163music.com在Hash Index中就可以一下子找到對應的Tag Value的offset
(4)根據offset加載出Tag Value對應的series.data,即對應的一系列SeriesID,即一系列SeriesKey
但是,如果用戶想查詢advertiser>163music.com對應的所有seriesKey,怎么玩?很顯然,只根據Hash Index是玩不轉的(有一種結構可以玩的轉 – B+樹,上篇文章有提到過),這里教大家一招,如果能夠保證數據(Tag Value Section中Tag Value有序存儲)的有序,就可以玩的轉了。也就是說,Hash Index + 有序就可以實現B+樹可以實現的快速范圍查找。這一招很有用!
2. 根據SeriesID如何找到對應的SeriesKey?首先SeriesKey是如何映射為SeriesID的(即字典編碼的實現),其次SeriesID與SeriesKey的對應關系是否需要存儲下來?讀完下文才會明白。
Series Block

Series Block用來存儲整個數據庫中所有SeriesKey,有的童鞋肯定會說了整個數據庫中辣么多SeriesKey,只放在一個Block中是不是不合適?筆者之前也是如此想的,不過了解了Series Block的結構之后就釋然了。Series Block主要由四部分構成:Block Trailer、Bloom Filter Section、Series Index Chunk以及一系列SeriesKeyChunk。
1. Block Trailer:和其他Block Trailer一樣,主要存儲該Block中其他Section在文件中的偏移量以及大小,是讀取解析Block的入口。
2. Bloom Filter Section:和Hash Index基本一樣的原理,不過Bloom Filter只用來表征給定seriesKey是否已經在文件中存在。
3. Series Index Chunk:B+樹索引,由多個Index Entry組成,每個Index Entry又由三個部分構成,分別是Capacity、MinSeriesKey、HashIndex。如下圖所示:

其中MinSeriesKey作為B+樹的節點值,用來與給定檢索值進行對比,比之大則繼續查找右子樹,比之小則查找左子樹。HashIndex又是一個Hash索引,如果確定待檢索seriesKey的葉子索引節點就是該Index Entry,就使用該Hash Index直接進行定位。
4. Series Key Chunk:存儲SeriesKey集合,如下圖所示,SeriesKey字段是一個復合結構,字段中記錄所有包含的Tag信息以及seriesKey的命名。
了解完Series Block的結構之后,你就知道這個Block可不一般,一個Block內部竟然有B+樹索引,這個配置可是有點高級的。而且索引節點中竟然有Hash Index。可見這個Block的配置絕對是文件級別的配置。如果對HBase中HFile熟悉的童鞋很容易明白,這個Block的結構和HFile的結構其實很像。
內存中倒排索引構建
1. 時序數據寫入到系統之后先將measurement和所有的維度值拼成一個seriesKey
2. 在文件中確認該seriesKey是否已經存在,如果已經存在就忽略,不需要再將其加入到內存倒排索引。那問題轉化為如何在文件中查找某個seriesKey是否已經存在?這就是Series Block中Bloom Filter的核心作用。
(1)首先使用Bloom Filter進行判斷,如果不存在,肯定不存在。如果存在,不一定存在,需要進一步判斷。
(2)使用B+樹以及HashIndex進一步判斷。
3. 如果seriesKey在文件中不存在,需要將其寫入內存。這里可以將內存中的結構理解為兩個核心數據結構:
(1)<measurement, List<tagKey>>,表示時序表與對應維度集合的映射
(2)seriesByTagKeyValue那樣一個雙重Map結構:<tagKey, <tagValue, List<SeriesKey>>>
倒排索引flush成文件
1. <measurement, List<tagKey>>以及<tagKey, <tagValue, List<SeriesKey>>都需要經過排序處理,排序的意義在於有序數據可以結合Hash Index實現范圍查詢,另外Series Block中B+樹的構建也需要SeriesKey排序。
2. 在排序的基礎上首先持久化<tagKey, tagValue, List<SeriesKey>>結構中所有的SeriesKey,也就是先構建Series Block。以此持久化SeriesKey到SeriesKeyChunk,當Chunk滿了之后,根據Chunk中最小的SeriesKey構建B+樹中的Index Entry節點。當然,Hash Index以及Bloom Filter是需要實時構建的。這個過程類似於HFile的構建過程以及上篇文章TSM文件的構建過程。但與TSM文件構建過程不一樣的是,Series Block在構建的同時需要記錄下SeriesKey與該Key在文件中偏移量的對應關系,即<SeriesKey, SeriesKeyOffset>,這一點至關重要。
3. 將<tagKey, <tagValue, List<SeriesKey>>結構中所有的SeriesKey由第二步<SeriesKey, SeriesKeyOffset >中的SeriesKeyOffset代替。形成新的結構:<tagKey, <tagValue, List<SeriesKeyOffset>>。為什么要這么處理?還記得上文中提到的SeriesID與SeriesKey的映射關系么,如果還記得,你一定會恍然大悟,新結構其實就是<tagKey, <tagValue, List<SeriesKeyID>>>。
4. 在新結構<tagKey, <tagValue, List<SeriesKeyId>>>的基礎上首先持久化tagValue,將同一個tagKey下的所有tagValue持久化在一起並生成對應Hash Index寫入文件,接着持久話寫下一個tagKey的的所有tagValue。
5. 所有tagValue都持久話完成之后再以此持久化所有的tagKey,形成Tag Block。最后持久化measurement形成Measurement Block。
使用倒排索引加速維度條件過濾查詢
上文提到TSI體系也是LSM結構,所以倒排索引文件會不止一個,這些文件會根據一定規則觸發compaction形成一些大文件。如果用戶想根據某個表的部分維度查詢某個時間段的所有時序數據的話(where tagk1=tagv1 from measurement1),是首先需要到所有TSI文件中查找的,為了方便起見,這里假設只有一個TSI文件:
1. 根據measurement1在Measument Block進行過濾,可以直接定位到該measurement1對應的所有維度值所在的文件區域。
2. 加載出該measurement1對應tag key區域的Hash Index,使用tagk1進行hash可以直接定位到該tagk1對應的tag value的存儲區域。
3. 加載出tagk1對應tag value區域的Hash Index,使用tagv1進行hash可以直接定位到該tagv1對應的所有SeriesID。
4. SeriesID就是對應SeriesKey在索引文件中的offset,直接根據SeriesID可以加載出對應的SeriesKey。
5. 根據SeriesKey、fieldKey以及時間范圍在TSM文件中查找對應的滿足查詢條件的時間序列,具體見上篇文章《時序數據庫技術體系 – InfluxDB存儲引擎TSM》。
文章總結
InfluxDB的倒排索引是一個很有代表性的實現方案,方案中文件格式定義、Hash Index以及B+樹索引的使用、全局編碼的實現都很有借鑒意義。但是,Disk-Based Index倒排索引相比其他系統來說還是有很多不同的:
1. Disk-Based Index是一個完整的LSM結構,LSM系統需要做的事情它都需要實現,比如flush、compaction等。因此可以把它看作一個獨立的系統,與原數據沒有任何耦合。
2. Disk-Based Index僅僅實現了Tag到SeriesKey的映射,而沒有實現Tag到SeriesKey+FieldKey+Timestamp映射。這能保證InfluxDB的倒排文件比較小,可以有效利用緩存,否則倒排索引文件將會變的非常之大。而且會引入索引數據失效過期的問題,比如某些很久以前的時序過期了,索引對應的數據集就需要相應的調整。
參考文獻