TiFlash 函數下推必知必會丨十分鐘成為 TiFlash Contributor

語言: CN / TW / HK

作者: 黃海升,TiFlash 研發工程師

TiFlash 自開源以來得到了社區的廣泛關注,很多小夥伴通過源碼閲讀的活動學習 TiFlash 背後的設計原理,也有許多小夥伴躍躍欲試,希望能參與到 TiFlash 的貢獻中來。這次,我們特別篩選了 TiFlash 中一些入門級別的 issue,幫助大家無門檻地參與到大型開源項目中來。 Issue 列表:https://github.com/pingcap/tiflash/issues/5092 按照慣例,我們也給 TiFlash New Contributor 準備了限量馬克杯,獲取流程見文末。

背景知識

TiFlash 作為 TiDB HTAP 體系的重要一環,會接收並執行 TiDB 下推下來的算子。而有時 Projection, Selection 等等算子裏會帶有函數,這就意味要下推這些算子就必須支持在 TiFlash 裏執行算子包含的函數。

1.PNG

如上圖所示,如果某個算子帶有 TiFlash 不支持的函數,就會導致一連串的算子都無法下推到 TiFlash 裏執行。為了最大化地發揮 TiFlash MPP 並行計算的能力,我們需要讓 TiFlash 支持 TiDB 的所有函數。看似無關緊要的函數支持,卻是 TiDB HTAP 的重要一環!

手把手教你下推函數

1. 確認要下推的函數的行為

函數是由 TiDB 下推給 TiFlash 執行的,所以必須保證函數在 TiFlash 執行的邏輯和 TiDB 保持一致,包括: - 主要邏輯 - 返回值類型 - 異常處理 - etc

以返回值類型為例,sqrt在 TiDB 一定會返回 float64,即便參數是Decimal類型的,也會在函數內部對參數先evalReal;而 floorceil 則會根據參數的類型和大小決定返回值是普通的整型,還是Decimal 類型。

一般情況下,TiFlash 要與 TiDB 保持一致是比較簡單的。但是對於一些特別的輸入,在實現的時候需要特別關注,如 sqrt 一個負數,是返回 NaN,還是返回 Null,還是拋出異常呢?

所以在實際開發之前,要去好好地看一下 TiDB 是如何實現這個函數的。

2. 將 TiDB function 映射到 TiFlash function

TiDB 對函數的標識是 tipb::ScalarFuncSig,而 TiFlash 使用 func_name 作為函數的標識。

在 TiFlash 的代碼裏,我們會用映射表的形式將 tipb::ScalarFuncSig 映射成 func_name

所以下推新函數的第二步,是給你要下推的函數,在 TiFlash 起個 func_name,然後在對應的映射表裏加一個 tipb::ScalarFuncSigfunc_name 的映射。

2.PNG

通常 SQL 函數會分為 window functionaggregate functiondistinct aggregation functionscalar function在 TiFlash 側會為每一類函數維護一個映射表,映射表和函數的對應如下: - window_func_map - 用於 window function - agg_func_map - 用於普通的聚合函數 - distinct_agg_func_map - 用於 distinct 的聚合函數 - scalar_func_map - 用於一般的標量函數

3. 註冊 TiFlash 函數

在映射了 tipb::ScalarFuncSigfunc_name 後,TiDB 下推的函數會根據 func_name 找到 TiFlash 函數對應的 builder,build 出 TiFlash Function 後,由 TiFlash Function 在實際執行流中執行函數邏輯。

目前在 TiFlash 有兩種 Function Builder 的實現方法,一種是 reuse function,一種是 create function directly。

3.PNG

reuse function

reuse function 用於可以複用其他函數的情況。比如 ifNull(arg1, arg2) -> if(isNull(arg1), arg2, arg1),如果自己直接寫一個 ifNull 的實現就會相當耗費時間,通過這種方式就可以直接複用其他函數的邏輯。

在 TiFlash 中是用 DAGExpressionAnalyzerHelper::function_builder_map 來記錄哪些是複用函數以及如何複用的邏輯。 添加一個對應的 DAGExpressionAnalyzerHelper::FunctionBuilder,在 DAGExpressionAnalyzerHelper::function_builder_map 添加對應的映射 <func_name, FunctionBuilder>。 具體的實現可以參考 DAGExpressionAnalyzerHelper 裏其他 FunctionBuilder 的實現。

create function directly

create function directly 用於不能複用其他函數的情況。需要在 dbms/src/Functions 下面寫對應的函數實現代碼。通常會有一定的分類,比如 String 相關的會在 FunctionString 裏面。

然後調用 factory.registerFunction 將函數實現類註冊在到 FunctionFactory 即可。factory.registerFunction 通常都會放在一起,簡單找找即可。

4. TiFlash 側開發函數

接下來要進行 TiFlash 側函數主體的開發。如果不能複用 TiFlash 已經開發好的函數,那我們就得繼承 IFunction 接口開發一個函數。不過好在 clickhouse 本身已經有很多現成的函數,不過因為不一定與 TiDB/MySQL 兼容,我們不能直接使用,所以留在了 Functions 下面,以待後來者利用。

所以當真的需要繼承 IFunction 實現一個函數時,可以先檢索 Functions 下面有沒有現成的語意相同的 clickhouse 函數,在那個函數上修修改改,滿足與 TiDB/Mysql 的兼容性後,納入 TiFlash Function 體系裏。

如果不巧,沒有現成的 clickhouse 函數利用,那就得從 0 開始開發一個向量化函數,不過也不必擔憂,雖然向量化函數開發相對困難一點,但是還是可以從別的函數上找到一些脈絡,模仿一些開發範式。

TiFlash vs. TiDB

TiFlash 和 TiDB 的向量化函數實現上存在不同點,參與過 TiDB 貢獻的 Contributor 需要關注下: - C++ 與 Golang 的區別 - TiFlash 裏重度使用 C++ 模板去寫函數,尤其是涉及數據類型的代碼; - TiFlash 的向量化函數體系和 TiDB 的函數體系(行式/向量化)的不同 - 表達式相關類的設計、使用與TiDB 差別很大 - IDataType - IColumn - 參數的 Column 類型(vector 和 const)組合會爆炸式增長。比如兩個參數的 function 會有四種組合 - vector, const - vector, vector - const, vector - const, const

以上兩點讓 TiFlash 的函數開發有一定的難度,和 TiDB 的函數開發差別會相當大。可以參考下 Function 目錄下其他函數的實現,比如 FunctionSubStringIndex在開發函數的時候大家應該會有很多體會 :)

可以參考的函數實現

  • TiDBConcat
  • FunctionSubStringIndex
  • Format

5. TiDB 側下推函數

下推函數是從 TiDB 側發起的,所以 TiDB 也要做一些修改,讓函數下推。在 expression/expression.go 裏的 scalarExprSupportedByFlash 會判斷哪些函數可以被下推到 TiFlash 裏執行,TiDB planner 會根據 scalarExprSupportedByFlash 來決定算子是否可以下推到 TiFlash。

比如要下推 sqrt 函數到 tiflash,在 tidb 的 expression/expression.go 中找到函數 scalarExprSupportedByFlash,會發現所有可以下推的函數的名字都被 hard-code 進了各種 switch case,將需要下推的函數 aqrt 加進 switch case 中即可。

6. 驗證函數真的下推了

在 TiDB 和 TiFlash 側的開發都完成後,我們需要先在本地驗證一下整個下推流程是不是真的 work 了。 1. 首先我們需要現在本地啟動一個 TiDB,TiKV,TiFlash 和 PD 的集羣。這裏推薦用 TiUP,按照 官方文檔 安裝 TiUP,用 playground 啟動即可。

比如: tiup playground nightly --tiflash 1 --db 1 --pd 1 --kv 1

  1. 然後用自己 build 好的 TiDB 和 TiFlash 替換掉原有集羣的 TiDB 和 TiFlash
  2. TiFlash

執行 ps -ef | grep tiflash,找到 tiflash 進程,形式應該像這樣:

xzx 11238 11028 52 20:20 pts/0 00:00:05 /home/xzx/.tiup/components/tiflash/v5.0.0-nightly-20210706/tiflash/tiflash server --config-file=/home/xzx/.tiup/data/ScRdWJM/tiflash-0/tiflash.toml

記下進程號 11238,記下 tiflash 後面跟的參數 server --config-file=/home/xzx/.tiup/data/ScRdWJM/tiflash-0/tiflash.toml

然後 kill 11238,用 server --config-file=/home/xzx/.tiup/data/ScRdWJM/tiflash-0/tiflash.toml 啟動自己 build 好的 TiFlash。

  • TiDB

與 TiFlash 類似,找到 tiup TiDB 進程,kill 掉原進程,用對應參數啟動 TiDB 替換即可。

4.png

  1. 驗證下推流程

用類似 explain select sum(sqrt(x)) from test 的查詢來看函數是否被下推到 tiflash 計算。 創建 tiflash 副本: sql create table test.t (xxx); -- 因為通常本地起一個節點, 所以 tiflash 副本數只能設 1 alter table test.t set tiflash replica 1; 測試的 SQL 可以像這樣: sql -- 儘量使用 MPP set tidb_enforce_mpp=1; -- 強制只能走 TiFlash set tidb_isolation_read_engines='tiflash'; explain select xxxfunc(a) from t; 如果函數被下推到了 TiFlash,那 explain 的結果可以看到包含該函數的 Projection 算子在 TiFlash 側。explain sql 可以反覆執行多幾次,因為 TiFlash 副本建立需要一些時間,但是不會太長。如果很長一段時間都看不到函數下推了,那麼應該就是真的有問題。:)

explain sql 執行成功之後,可以把 explain 去掉,實際執行下 sql 看效果。

7. 測試

提交 pr 後,在 TiFlash 的 GitHub CI 裏,會啟動實際的 TiDB, TiFlash, PD, TiKV 集羣,自動執行單元測試和集成測試。需要貢獻者提前準備測試的代碼。

集成測試

對於函數下推,通常會在 integration-test 增加一組測試。在 tests/fullstack-test/expr 下面,為新的下推函數建一個 func.test,測試內容參照同目錄下其他函數的測試即可,如 substring_index.test

單元測試

形式

TiFlash 的函數單測放在 dbms/src/Functions/test 下面。通常命名格式為 gtest_${func_name}.cpp

單測模板如下: ```C++

include

include

namespace DB::tests { class {gtest_name} : public DB::tests::FunctionTest { };

TEST_F({gtest_name}, {gtest_unit_name}) try { const String & func_name = {function_name};

// case1
ASSERT_COLUMN_EQ(
    {ouput_result},
    executeFunction(
        func_name,
        {input_1},
        {input_2},
        ...,
        {input_n},);
// case2
...
// case3
...

} CATCH

TEST_F({gtest_name}, {gtest_unit_name2})... TEST_F({gtest_name}, {gtest_unit_name3})... ...

} // namespace DB::tests ``` 可以參考該目錄下其他函數單測的寫法, 做適當調整。

FunctionTestUtils 是用於函數測試的公共類,裏面提供了各類常用的方法,如 createColumn 等等。如果在寫 gtest 時發現有其他可以共用方法,也可以補充在這裏。

內容

以 function(arg_1, arg_2, arg_3, … arg_n) 為例,一個 TiFlash 函數單元測試的內容應該至少包含以下幾個部分:

數據類型

對於每個 arg_i 的所有支持類型 Type,需要測試 Type 與 Nullable(Type)。此外理論上所有 arg_i 都應該支持 DataTypeNullable(DataTypeNothing),但是 TiDB 很少會用到 DataTypeNullable(DataTypeNothing),所以碰到相關的 bug 可以先記下來。

列類型

對於 arg_i 的每種 Type: 1. 如果該 Type 不為 nullable,需要測試兩種形式的列: - ColumnVector - ColumnConst 2. 如果該 Type 為 nullable,需要測試三種形式的列: - ColumnVector - ColumnConst(ColumnNullable(non-null value)) - ColumnConst(ColumnNullable(null value)) 3. 如果該 Type 為 DataTypeNullable(DataTypeNothing), 需要測試兩種形式的列: - ColumnVector - ColumnConst(ColumnNullable(null value))

邊界值

一些通用的邊界值例子如下: 1. 數值類型(int,double,decimal 等):最大/最小值,0 值,null 值 2. 字符串類型:空字符串,中文等非 ascii 字符,null 值,有 collation/無 collation 3. 日期類型:zero date,早於 1970-01-01 的某個時間,夏令時時間,null 值

此外,對於具體的函數,可以根據其具體實現,有針對性地構造邊界值。

返回值類型

根據 MySQL 相關文檔,確保 TiFlash 函數返回值類型與 MySQL/TiDB 一致

注意: 1. Decimal 類型在 TiFlash 的內部表示有四種:Decimal32,Decimal64,Decimal128 和 Decimal256,對於所有 Decimal 類型,這四種內部表示都需要測試到。 2. 函數的每個 arg_i 可能的類型實際上應該以 TiDB 可能下推的類型為準,考慮到獲取 TiDB 可能下推的類型比較麻煩,當前測試可以根據 TiFlash 目前支持的類型來寫 3. 有一部分 TiDB 下推的函數中,其下推的函數簽名中包含了類型信息,例如對於 a = b ,TiDB 下推的函數簽名包括:EQInt,EQReal,EQString,EQDecimal,EQTime,EQDuration,EQJson,雖然 a 和 b 各自都可以是 int/real/string/decimal/time/duration/json 類別,但是 TiDB 下推的時候保證了 a 和 b 的類別是一致的,從工作量角度考慮,當前測試只需要保證相同類別之間的 equal 函數被測試到即可,int = decimal 這種的可以先不測。 4. 對於輸入參數可以無窮多的函數(例如 case when),需要確保其最小循環單元被測試到。 5. 預期測試過程中會發現很多 bug,對於一些比較容易 fix 的 bug,可以在測試的同時順便 fix,對於一些比較難或者不確定需不需要 fix 的 bug,可以先開 issue,再將相應的測試註釋掉。

常見的問題

  1. 函數即使返回 null,也需要給其對應的 nestedColumn 賦一個有意義的值

TiFlash 中的函數實現中,有一個可以重載的函數:useDefaultImplementationForNulls,對於大多數函數來説,如果不需要對 null 做特殊處理的話,可以返回 true,這樣的話,在實現這個函數的時候就不需要有任何 null 值相關的考慮,其原理是在 IExecutableFunction::defaultImplementationForNulls 中會將 nullable column 的 nestedColumn 取出來傳給該函數,而 nestedColumn 始終都是 not null 的類型。

當然對於一些需要對 null 值特殊處理的函數,比如 concat_ws,因為要達到 “輸出參數如果是 null 則忽略該參數” 的目的,concat_ws 需要自己處理 null 值邏輯,這樣的話就必須重載 useDefaultImplementationForNulls 讓其返回 false。對於需要自己實現 null 值處理邏輯的函數,如果結果為 null,必須給這個 nullable column 的 nestedColumn 設上一個有意義的值,所有 Function 都假設 nullable column 對應的 nestedColumn 中每一行都是一個有意義的值,即使是 null。之前出現過因為 nestedColumn 裏面值不合法導致的bug,具體可以參照:#3875, #2268 推薦默認值如下: - 數值類型:零值 - Date相關類型:zerodate - 字符串類型:空字符串

  1. 使用 useDefaultImplementationForConstants() 簡化函數開發

TiFlash 中的函數實現中,有一個可以重載的函數:useDefaultImplementationForConstants,如果重載這個方法返回 true,那麼在函數開發的時候,可以不考慮 const, const, ..., const 的列組合。

IExecutableFunction::defaultImplementationForConstantArguments 中會將 const, const, ..., const 轉為 vector, vector, .., vector 來處理。

  1. 使用 getArgumentsThatAreAlwaysConstant 簡化函數開發 (不推薦)

在函數開發中,可能發現某個參數通常為常量,並且如果假設該參數一直為常量的話,開發函數會簡單很多,這時候可以考慮強制該參數為常量,不為常量就報錯。這時重載 getArgumentsThatAreAlwaysConstant,返回指定的常量參數的下標(從 0 開始)即可。

但是通常情況下不要這麼做,除非是開發週期要求很緊的時候,在後面也最好找時間補回去。

如果你在貢獻的過程中遇到其他問題,請來這裏提問:https://internals.tidb.io/c/sqlengine

限量馬克杯獲取流程

5.jpg 任何一個新加入集體的小夥伴都將收到我們充滿誠意的禮物,成為 New TiFlash Contributor 即可獲贈限量版馬克杯,很榮幸能夠認識你,也很高興能和你一起堅定地走得更遠。獲取流程如下: 1. 認領 issue,issue 列表:Issue 列表:https://github.com/pingcap/tiflash/issues/5092; 2. 提交 PR; 3. PR 提交之後,請耐心等待維護者進行 Review; - 代碼提交後 CI 會執行測試,需要保證所有的單元測試是可以通過的。期間可能有其它的提交會與當前 PR 衝突,這時需要修復衝突; - 維護者在 Review 過程中可能會提出一些修改意見。修改完成之後如果 reviewer 認為沒問題了,你會收到 LGTM(looks good to me) 的回覆。當收到兩個及以上的 LGTM 後,該 PR 將會被合併; 4. 合併 PR 後自動成為 Contributor,就可以填表單領取你的專屬馬克杯啦,表單地址:https://forms.pingcap.com/f/tidb-contribution-swag 5. 後台 AI 核查 GitHub ID 及資料信息,確認無誤後會快遞寄出屬於你的限量版馬克杯。