TiDB 2.0 中,咱們引入了一個叫 Chunk 的數據結構用來在內存中存儲內部數據,用於減少內存分配開銷、下降內存佔用以及實現內存使用量統計/控制,其特色以下:git
只讀github
不支持隨機寫golang
只支持追加寫數據庫
列存,同一列的數據連續的在內存中存放緩存
Chunk 本質上是 Column 的集合,它負責連續的在內存中存儲同一列的數據,接下來咱們看看 Column 的實現。session
Column 的實現參考了 Apache Arrow,Column 的代碼在 這裏。根據所存儲的數據類型,咱們有兩種 Column:數據結構
定長 Column:存儲定長類型的數據,好比 Double
、Bigint
、Decimal
等app
變長 Column:存儲變長類型的數據,好比 Char
、Varchar
等框架
哪些數據類型用定長 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。
追加一個元素須要根據具體的數據類型調用具體的 append 方法,好比: appendInt64、appendString 等。
一個定長類型的 Column 能夠用以下圖表示:
咱們以 appendInt64 爲例來看看如何追加一個定長類型的數據:
使用 unsafe.Pointer
把要 append 的數據先複製到 elemBuf 中;
往 nullBitmap 中 append 一個 1。
上面第 1 步在 appendInt64
這個函數中完成,第 二、3 步在 finishAppendFixed 這個函數中完成。其餘定長類型元素的追加操做很是類似,感興趣的同窗能夠接着看看 appendFloat32、appendTime 等函數。
而一個變長的 Column 能夠用下圖表示:
咱們以 appendString 爲例來看看如何追加一個變長類型的數據:
把數據先 append 到 data 中;
往 nullBitmap 中 append 一個 1;
上面第 1 步在 appendString 這個函數中完成,第 二、3 步在 finishAppendVar 這個函數中完成。其餘邊長類型元素的追加操做也是很是類似,感興趣的同窗能夠接着看看 appendBytes、appendJSON 等函數。
咱們使用 appendNull 函數來向一個 Column 中追加一個 NULL
值:
往 nullBitmap 中 append 一個 0;
若是是定長 Column,須要往 data 中 append 一個 elemBuf 長度的數據,用來佔位;
若是是變長 Column,不用往 data中 append 數據,而是往 offsets 中 append 當前 data 的 size 做爲下一個元素在 data 中的起始點。
如上圖所示:Chunk 中的 Row 是一個邏輯上的概念:Row 中的數據存儲在 Chunk 的各個 Column 中,同一個 Row 中的數據在內存中沒有連續存儲在一塊兒,咱們在獲取一個 Row 對象的時候也不須要進行數據拷貝。提供 Row 的概念是由於算子運行過程當中,大多數狀況都是以 Row 爲單位訪問和操做數據,好比聚合,排序等。
Row 提供了獲取 Chunk 中數據的方法,好比 GetInt64、GetString、GetMyDecimal 等,前面介紹了往 Column 中 append 數據的方法,獲取數據的方法能夠由 append 數據的方法反推,代碼也比較簡單,這裏就再也不詳細介紹了。
目前 Chunk 這個包只對外暴露了 Chunk, Row 等接口,而沒有暴露 Column,因此,寫數據調用的是在 Chunk 上實現的對 Column 具體函數的 warpper,好比 AppendInt64;讀數據調用的是在 Row 上實現的 Getxxx 函數,好比 GetInt64。
在重構前,TiDB 1.0 中使用的執行框架會不斷調用 Child 的 Next 函數獲取一個由 Datum 組成的 Row(和剛纔介紹的 Chunk Row 是兩個數據結構),這種執行方式的特色是:每次函數調用只返回一行數據,且不論是什麼類型的數據都用 Datum 這個結構體來封裝。
這種方法的優勢是:簡單、易用。缺點是:
若是處理的數據量多,那麼框架上的函數調用開銷將會很是大;
Datum 佔用的無效內存太大,內存浪費比較多(存一個 8 字節的整數須要 56 字節);
Datum 沒有重用,golang 的 gc 壓力大;
每一個 Operator 一次只輸出一行數據,要進行更加緩存友好的計算、更充分的利用 CPU 的 pipeline 很是困難;
Datum 中的 interface 類型的數據,統計它的內存使用量比較困難。
在重構後,TiDB 2.0 中使用的執行框架會不斷調用 Child 的 NextChunk 函數,獲取一個 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 內存控制,咱們也會在後續文章中詳細介紹,這裏也再也不展開了。
採用了新的執行框架後,OLAP 類型語句的執行速度、內存使用效率都有極大提高,從 TPC-H 對比結果 看,性能有數量級的提高。
做者:張建