InfluxDB 存儲結構、讀、寫

這篇最早是 2021 年 3 月寫的,最近又拿出來複習一遍,也補充了一些新的內容。上一篇博客發表過後已經 3 個月沒有發表新的博客,就把這篇拿出來了。內容沒有完全梳理完畢,算是筆記。先發出來,後續再逐漸完善。

InfluxDB 是開源的時序數據庫,採用列式存儲。原先有開源集羣版本,但在 0.11 版本之後,集羣版本僅在商業版中提供。

在一些場景中,需要用到集羣,但又不需要完整的集羣功能,只需要簡單地實現分片存儲和查詢或者副本集就行了。可以在開源的基礎上添加這些簡單的功能。

添加功能的方式可以分爲通過頂層的 HTTP API 操作和參考 InfluxDB 源碼寫相應擴展。

例如通過 HTTP API 寫入數據時可以通過 Nginx 的一致性 hash 將數據轉發到不同的 InfluxDB 實例,用 HTTP API 把請求發送到所有 InfluxDB 然後手動合併,或者寫個中間層合併。但是資源消耗比較大,而且不夠靈活。因此很有必要了解 InfluxDB 的源碼。

一個系統的發展通常是越來越複雜,並且這個過程中的代碼會受到其發展過程中的影響。選擇一個合適的版本就行了。這裏選擇的是 0.11.1 版本的代碼。按照慣例,省略的代碼用 // ... 代替。

基礎概念

InfluxDB 的 measurement 類似關係數據庫的 table,Point 類似關係數據庫的行,存儲着某一時刻的數據。Point 的接口定義如下:

type Point interface {
	// Measurement 部分
    Name() string
	SetName(string)

    // Tag 部分
	Tags() Tags
	AddTag(key, value string)
	SetTags(tags Tags)

    // Field 部分
	Fields() Fields

    // Timestamp 部分
	Time() time.Time
	SetTime(t time.Time)
	UnixNano() int64

    // Measurement + Tags
	Key() []byte

    // ...
}

Point 有四大塊:

  • Measurement:類似於表。
  • Tag:起到索引的作用。標籤可以有多個,每個標籤是一個 key,value 映射。
  • Field:字段,具體的值。
  • Timestamp:時間點。

這四塊中,Measurement 和 Tag 組成了 Series,類似於一個集合。由於 Series 沒有 Timestamp 維度,因此一個 Series 底下有多個時間點的數據,也就意味着有多個 Point。

Series 的 key 由一個 measurement 名稱、 多個 tag_key=tag_value 組成,例如 series.Key = "cpu,host=A"

influxDB 是怎麼讀取一行數據的?

  • 從查詢語句中解析出所有 field
  • 找到這些 field 的類型對應的 iterator
  • 根據查詢語句對 field 的要求(例如聚合),用裝飾模式創建對應的 iterator,包裹底層的 iterator。
  • 由 Emitter 來調用 iterator 讀取數據
    • 遍歷所有 iterator,讀取每個字段的下一個數據,放到 buffer 裏面。buffer 是一個數組,按照順序存放每個 iterator 的結果。
    • 這次讀取時的【時間、measurement、tags】會發生變化,作爲 row 的 key。
    • 如果讀取後在 buffer 裏面找不到這個 key,則創建一個 row;如果存在,則加入這次讀的數據。

集羣在查詢時,用了一層 remoteIteratorCreator,來讀取遠程 shard 的數據。

底層的 iterator 在 tsdb/engine/tsm1/engine.go 裏面。

// buildIntegerCursor creates a cursor for an integer field.
func (e *Engine) buildIntegerCursor(measurement, seriesKey, field string, opt influxql.IteratorOptions) integerCursor {
	cacheValues := e.Cache.Values(SeriesFieldKey(seriesKey, field))
	keyCursor := e.KeyCursor(SeriesFieldKey(seriesKey, field), opt.SeekTime(), opt.Ascending)
	return newIntegerCursor(opt.SeekTime(), opt.Ascending, cacheValues, keyCursor)
}

Iterator 是如何工作的?

舉一個 sum 的例子。

sum 是函數,在 influxql/call_iterator.go 裏面由 NewCallIterator 創建 integerReduceIntegerIterator 或者 floatReduceFloatIterator

由於 InfluxDB 的數據類型只有四種:浮點數、整數、字符串和布爾值,所以僅需要支持浮點數和整數。

integerReduceIntegerIterator 爲例。

對於要合併的數據,兩者的 field_key 和 tags 是一致的,這樣可以生成一個唯一 key。對於每個 key,生成一個對應的 integerReduceIntegerPoint

integerReduceIntegerPoint 保存了一個 value,放在 prev 結構體裏面。每次得到一個相同 key 的時候,獲取 integerReduceIntegerPoint,並將當前值與 prev 的值相加,並保存到 prev 裏面。在對上一個階段的結果集做一遍這個合併後,得到一批 IntegerPoint,並從大到小排序。每次 Next() 從中取最後一個。

嵌套的 Iterator 是如何配合的?

假設有 IteratorB(IteratorA) 這樣的嵌套。

每次 IteratorB 執行 Next() 的時候,會先執行一次 reduce(),目的是合併或過濾掉符合條件的相鄰的數據。它會去找 IteratorA 獲取一個時間段的所有數據,然後將這些數據轉換爲只剩下一條。

一行的數據是如何合併的?

【時間、measurement、tags】 每次讀取的時候都有可能發生變化。這是因爲每個字段雖然是按照時間順序讀取,但某一時刻,不一定所有的字段都有值,此時讀取的是指定時刻之後的數據。

這樣一個 buffer 中,各個字段的時間可能不一致。如果查詢語句是按時間增序,則取時間最小的那個字段。以這個字段的 【時間、measurement、tags】 去 buffer 中找相匹配的 field。以此組合成 row。

字段的順序怎麼辦?字段的順序在一開始創建 iterator 集合的時候就固定了。每次讀取的時候都是按照 iterator 的順序讀取。在組合 row 的時候,如果一個 iterator 的數據不能滿足 【時間、measurement、tags】,則其對應的下標的值爲 nil。

集羣應該如何處理?

集羣使用 remoteIteratorCreator,它會創建 ReaderIterator,從 io.Reader 裏讀取數據。這個 io.Reader 是一個 TCP 連接。

根據 select 指定的時間範圍,找到時間範圍內的所有 shard 及其所在的集羣節點。然後對每個節點都建立一個連接。

influxDB 是怎麼存儲數據的?

存儲格式:
http://blog.fatedier.com/2016/08/05/detailed-in-influxdb-tsm-storage-engine-one

在啓動後,會定期檢查是否應該將緩存中的數據刷入到磁盤中。

數據文件是 tsm 類型。整個文件包含四個部分:

┌────────┬────────────────────────────────────┬─────────────┬──────────────┐
│ Header │               Blocks               │    Index    │    Footer    │
│5 bytes │              N bytes               │   N bytes   │   4 bytes    │
└────────┴────────────────────────────────────┴─────────────┴──────────────┘

Blocks 存儲着實際數據。每個 Block 表示一批 series + field 相同且經過壓縮後的數據。


┌────────┬────────────────────────────────────┬─────────────┬──────────────┐
│ Header │               Blocks               │    Index    │    Footer    │
│5 bytes │              N bytes               │   N bytes   │   4 bytes    │
└────────┴────────────────────────────────────┴─────────────┴──────────────┘
                            ↑ 取這個部分得到下圖

┌───────────────────────────────────────────────────────────┐
│                          Blocks                           │
└───────────────────────────────────────────────────────────┘
                              ↓ 切割
┌───────────────────┬───────────────────┬───────────────────┐
│      Block 1      │      Block 2      │      Block N      │
└───────────────────┴───────────────────┴───────────────────┘
                              ↓ 切割
┌─────────┬─────────┬───────────────────┬─────────┬─────────┐
│  CRC    │  Data   │  CRC    │  Data   │  CRC    │  Data   │
│ 4 bytes │ N bytes │ 4 bytes │ N bytes │ 4 bytes │ N bytes │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

在向 InfluxDB insert 多條數據的時候, InfluxDB 會將每條數據轉換成一個 Point,並按照 Point 的 series + field 分組。

func (e *Engine) WritePoints(points []models.Point, measurementFieldsToSave map[string]*tsdb.MeasurementFields, seriesToCreate []*tsdb.SeriesCreate) error {
    // ...
    key := string(p.Key()) + keyFieldSeparator + k
    values[key] = append(values[key], NewValue(p.Time().UnixNano(), v))
    // ...
}

這裏的 p 是 point,p.Key() 是 measurement + series,k 是 field key。values 的類型是 map[string][]Value

每個分組的數據會分別加入到緩存裏面。接着寫一份到 WAL(Write Ahead Log,寫日誌) 文件裏面,便於程序重啓或崩潰後恢復內存數據。

緩存裏面包括原先存儲在磁盤上的數據,這些數據會和後續新增的數據合併並且排序。

然後對緩存中每個分組的數據執行壓縮,壓縮的結果是一個 Block。每個分組保存的是一個 Field 的數據,因此一個 Block 對應一個 Field。

block, err := values.Encode(nil)

這裏的 values 的類型是 []Value,即上面的那個 values 取其中一個 field 的分組。說明每個 block 存儲的是一個 field 的數據。

┌─────────┬─────────┐
│  CRC    │  Data   │
│ 4 bytes │ N bytes │
└─────────┴─────────┘

其中的 Data 部分有三個內容:

┌───────────────────┬───────────────────┬───────────────────┐
│  first timestamp  │   all timestamp   │     all value     │
└───────────────────┴───────────────────┴───────────────────┘
  1. Data 部分第一個數據的時間戳
  2. 所有數據的時間戳
  3. 所有數據的值

這裏的時間戳和它對應的值是分成兩部分存儲的。

接着計算這個 Block 的 CRC 校驗碼:

func (t *tsmWriter) WriteBlock(key string, minTime, maxTime int64, block []byte) error {
    // ...
    var checksum [crc32.Size]byte
	binary.BigEndian.PutUint32(checksum[:], crc32.ChecksumIEEE(block))
    // ...
}

先寫 CRC 校驗碼再寫 Data。

寫入 CRC 校驗碼之前會判斷文件是否已經有內容,如果沒有,則寫入 Header。

    ↓ 這個部分
┌────────┬────────────────────────────────────┬─────────────┬──────────────┐
│ Header │               Blocks               │    Index    │    Footer    │
│5 bytes │              N bytes               │   N bytes   │   4 bytes    │
└────────┴────────────────────────────────────┴─────────────┴──────────────┘

之後把這個 Block 的 【key(即 series + field)、field 的數據類型、時間段、該 Block 在文件的偏移量、該 Block 的長度】 添加到內存中的索引。

t.index.Add(key, blockType, values[0].UnixNano(), values[len(values)-1].UnixNano(), t.n, uint32(n))

寫完所有 Block 之後,再把索引寫入到文件。之後把原先的 tsm 文件刪掉。

                                                     ↓ 索引是這個部分
┌────────┬────────────────────────────────────┬─────────────┬──────────────┐
│ Header │               Blocks               │    Index    │    Footer    │
│5 bytes │              N bytes               │   N bytes   │   4 bytes    │
└────────┴────────────────────────────────────┴─────────────┴──────────────┘

連續查詢(Continues Query)

連續查詢是由 InfluxDB 按照一定時間間隔執行查詢,並將結果插入到指定表中。

連續查詢分爲基礎語法和高級語法。它們的區別在於對查詢時間範圍和兩次查詢的間隔的配置。

首先是基礎語法:

CREATE CONTINUOUS QUERY <cq_name> ON <database_name>
BEGIN
  SELECT <function[s]> INTO <destination_measurement> FROM <measurement> [WHERE <stuff>] GROUP BY time(<interval>)[,<tag_key[s]>]
END

基礎語法的查詢間隔爲:GROUP BY time() 的 interval。即如果 interval 爲 1m,則一分鐘執行一次。

基礎語法的查詢時間範圍爲:執行查詢的時間點 now() 減去 GROUP BY time() 的 interval。即 WHERE time >= (now() - interval) AND time < now()

高級語法:

CREATE CONTINUOUS QUERY <cq_name> ON <database_name>
RESAMPLE EVERY <interval> FOR <interval>
BEGIN
  SELECT <function[s]> INTO <destination_measurement> FROM <measurement> [WHERE <stuff>] GROUP BY time(<interval>)[,<tag_key[s]>]
END

高級語法比基礎語法多了一行:RESAMPLE EVERY <interval> FOR <interval>

其中 EVERY 確定查詢間隔,FOR 確定查詢時間範圍。其中一個可以不填,例如:

RESAMPLE EVERY <interval> 或者 RESAMPLE FOR <interval>

其中一個不填時,相當於自動把它的 interval 設置爲 GROUP BY time() 的 interval。

當數據輸入有延遲時,需要使用高級語法的 FOR 擴大查詢範圍。

例如聚合一分鐘的數據需要延遲半分鐘,那麼將 FOR 設置爲 2 分鐘。這樣就能在第三分鐘開始的時候,查詢第一和第二分鐘的數據。由於第二分鐘的數據需要再等半分鐘纔有,所以相當於是獲取第一分鐘的數據。

寫入

https://docs.influxdata.com/influxdb/v1.8/guides/hardware_sizing/

單機寫入的極限是每秒 75 萬個字段。估算方法是一秒的 point 數量乘以一條數據的字段數量。point 數量可以從 _internal 庫的 write 表的 pointReq 獲取。字段數量可以執行 show field keys 獲取。

在某些場景下,不是每一秒鐘都有 75 萬個字段,而是在每分鐘內的某一秒會同時收到超過 75 萬個字段的數據。這種情況要取這些數據在一分鐘每秒的平均值。如果平均值不超過 75 萬個字段,那麼單機可以承受住。

也就是 InfluxDB 最多一分鐘可以寫 4500 萬個字段,實際值可能會比這個小一些,可以通過業務數據測試實際負載。

寫入的優化

https://docs.influxdata.com/influxdb/v2.0/write-data/best-practices/optimize-writes/

  1. 使用 gzip 壓縮
  2. 批量寫,最理想的是一次 5000 行
  3. 轉換爲行的時候,按字典順序排序 tag 的字段
  4. 設置最合適的時間精度。例如同一個 tag 不會有兩條同一秒的數據,那麼就把時間精度設置爲秒級

Series File

https://www.jianshu.com/p/4e6fda6d6b63

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