TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

PingCAP發表於2018-06-19

什麼是 Chunk

TiDB 2.0 中,我們引入了一個叫 Chunk 的資料結構用來在記憶體中儲存內部資料,用於減小記憶體分配開銷、降低記憶體佔用以及實現記憶體使用量統計/控制,其特點如下:

  • 只讀

  • 不支援隨機寫

  • 只支援追加寫

  • 列存,同一列的資料連續的在記憶體中存放

Chunk 本質上是 Column 的集合,它負責連續的在記憶體中儲存同一列的資料,接下來我們看看 Column 的實現。

1. Column

Column 的實現參考了 Apache Arrow,Column 的程式碼在 這裡。根據所儲存的資料型別,我們有兩種 Column:

  • 定長 Column:儲存定長型別的資料,比如 DoubleBigintDecimal

  • 變長 Column:儲存變長型別的資料,比如 CharVarchar

哪些資料型別用定長 Column,哪些資料型別用變長 Column 可以看函式 addColumnByFieldType 。

Column 裡面的欄位非常多,這裡先簡單介紹一下:

  • length

    用來表示這個 Column 有多少行資料。

  • nullCount

    用來表示這個 Column 中有多少 NULL 資料。

  • nullBitmap

    用來儲存這個 Column 中每個元素是否是 NULL,需要特殊注意的是我們使用 0 表示 NULL,1 表示非 NULL,和 Apache Arrow 一樣。

  • data

    儲存具體的資料,不管定長還是變長的 Column,所有的資料都儲存在這個 byte slice 中。

  • offsets

    給變長的 Column 使用,儲存每個資料在 data 這個 slice 中的偏移量。

  • elemBuf

    給定長的 Column 使用,當需要讀或者寫一個資料的時候,使用它來輔助 encode 和 decode。

1.1  追加一個定長的非 NULL 值

追加一個元素需要根據具體的資料型別呼叫具體的 append 方法,比如: appendInt64appendString 等。

一個定長型別的 Column 可以用如下圖表示:

TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

我們以 appendInt64 為例來看看如何追加一個定長型別的資料:

  • 使用 unsafe.Pointer 把要 append 的資料先複製到 elemBuf 中;

  • 將 elemBuf 中的資料 append 到 data 中;

  • 往 nullBitmap 中 append 一個 1。

上面第 1 步在 appendInt64 這個函式中完成,第 2、3 步在 finishAppendFixed 這個函式中完成。其他定長型別元素的追加操作非常相似,感興趣的同學可以接著看看 appendFloat32appendTime 等函式。

1.2  追加一個變長的非 NULL 值

而一個變長的 Column 可以用下圖表示:

TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

我們以 appendString 為例來看看如何追加一個變長型別的資料:

  • 把資料先 append 到 data 中;

  • 往 nullBitmap 中 append 一個 1;

  • 往 offsets 中 append 當前 data 的 size 作為下一個元素在 data 中的起始點。

上面第 1 步在 appendString 這個函式中完成,第 2、3 步在 finishAppendVar 這個函式中完成。其他邊長型別元素的追加操作也是非常相似,感興趣的同學可以接著看看 appendBytesappendJSON 等函式。

1.3  追加一個 NULL 值

我們使用 appendNull 函式來向一個 Column 中追加一個 NULL 值:

  • 往 nullBitmap 中 append 一個 0;

  • 如果是定長 Column,需要往 data 中 append 一個 elemBuf 長度的資料,用來佔位;

  • 如果是變長 Column,不用往 data中 append 資料,而是往 offsets 中 append 當前 data 的 size 作為下一個元素在 data 中的起始點。

2. Row

TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

如上圖所示:Chunk 中的 Row 是一個邏輯上的概念:Row 中的資料儲存在 Chunk 的各個 Column 中,同一個 Row 中的資料在記憶體中沒有連續儲存在一起,我們在獲取一個 Row 物件的時候也不需要進行資料拷貝。提供 Row 的概念是因為運算元執行過程中,大多數情況都是以 Row 為單位訪問和運算元據,比如聚合,排序等。

Row 提供了獲取 Chunk 中資料的方法,比如 GetInt64GetStringGetMyDecimal 等,前面介紹了往 Column 中 append 資料的方法,獲取資料的方法可以由 append 資料的方法反推,程式碼也比較簡單,這裡就不再詳細介紹了。

3. 使用

目前 Chunk 這個包只對外暴露了 Chunk, Row 等介面,而沒有暴露 Column,所以,寫資料呼叫的是在 Chunk 上實現的對 Column 具體函式的 warpper,比如 AppendInt64;讀資料呼叫的是在 Row 上實現的 Getxxx 函式,比如 GetInt64

執行框架簡介

1. 老執行框架簡介

在重構前,TiDB 1.0 中使用的執行框架會不斷呼叫 Child 的 Next 函式獲取一個由 Datum 組成的 Row(和剛才介紹的 Chunk Row 是兩個資料結構),這種執行方式的特點是:每次函式呼叫只返回一行資料,且不管是什麼型別的資料都用 Datum 這個結構體來封裝。

TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

這種方法的優點是:簡單、易用。缺點是:

  • 如果處理的資料量多,那麼框架上的函式呼叫開銷將會非常大;

  • Datum 佔用的無效記憶體太大,記憶體浪費比較多(存一個 8 位元組的整數需要 56 位元組);

  • Datum 沒有重用,golang 的 gc 壓力大;

  • 每個 Operator 一次只輸出一行資料,要進行更加快取友好的計算、更充分的利用 CPU 的 pipeline 非常困難;

  • Datum 中的 interface 型別的資料,統計它的記憶體使用量比較困難。

2. 新執行框架簡介

在重構後,TiDB 2.0 中使用的執行框架會不斷呼叫 Child 的 NextChunk 函式,獲取一個 Chunk 的資料。

TiDB 原始碼閱讀系列文章(十)Chunk 和執行框架簡介

這種執行方式的特點是:

  • 每次函式呼叫返回一批資料,資料量由一個叫 tidb_max_chunk_size 的 session 變數來控制,預設是 1024 行。因為 TiDB 是一個混合 TP 和 AP 的資料庫,對於 AP 型別的查詢來說,因為計算的資料量大,1024 沒啥問題,但是對於 TP 請求來說,計算的資料量可能比較少,直接在一開始就分配 1024 行的記憶體並不是最佳的實踐( 這裡 有個 github issue 討論這個問題,歡迎感興趣的同學來討論和解決)。

  • Child 把它產出的資料寫入到 Parent 傳下來的 Chunk 中。

這種執行方式的好處是:

  • 減少了框架上的函式呼叫開銷。比如同樣輸出 1024 行結果,現在的函式呼叫次數將會是以前的 1/1024。

  • 記憶體使用更加高效。Chunk 中的資料組織非常緊湊,存一個 8 位元組的整數幾乎就只需要 8 位元組,沒有其他額外的記憶體開銷了。

  • 減輕了 golang 的 gc 壓力。Chunk 佔用的記憶體可以不斷地重複利用,不用頻繁的申請新記憶體,從而減輕了 golang 的 gc 壓力。

  • 查詢的執行過程更加快取友好。如我們之前所說,Chunk 按列來組織資料,在計算的過程中我們也儘量按列來計算,這樣既能讓一列的資料儘量長時間的待在 Cache 中,減輕 Cache Miss 率,也能充分利用起 CPU 的 pipeline。這一塊在後續的原始碼分析文章中會有詳細介紹,這裡就不再展開了。

  • 記憶體監控和控制更加方便。Chunk 中沒有使用任何 interface,我們能很方便的直接獲取一個 Chunk 當前所佔用的記憶體的大小,具體可以看這個函式:MemoryUsage。關於 TiDB 記憶體控制,我們也會在後續文章中詳細介紹,這裡也不再展開了。

3.  新舊執行框架效能對比

採用了新的執行框架後,OLAP 型別語句的執行速度、記憶體使用效率都有極大提升,從 TPC-H 對比結果 看,效能有數量級的提升。

作者:張建

相關文章