標籤:

為 TiDB 重構 built-in 函數

這是十分鐘成為 TiDB Contributor 系列的第二篇文章,讓大家可以無門檻參與大型開源項目,感謝社區為 TiDB 帶來的貢獻,也希望參與 TiDB Community 能為你的生活帶來更多有意義的時刻。

為了加速表達式計算速度,最近我們對表達式的計算框架進行了重構,這篇教程為大家分享如何利用新的計算框架為 TiDB 重寫或新增 built-in 函數。對於部分背景知識請參考這篇文章,本文將首先介紹利用新的表達式計算框架重構 built-in 函數實現的流程,然後以一個函數作為示例進行詳細說明,最後介紹重構前後表達式計算框架的區別。

重構 built-in 函數整體流程

  1. 在 TiDB 源碼 expression 目錄下選擇任一感興趣的函數,假設函數名為 XX

  2. 重寫 XXFunctionClass.getFunction() 方法

    a. 該方法參照 MySQL 規則,根據 built-in 函數的參數類型推導函數的返回值類型

    b. 根據參數的個數、類型、以及函數的返回值類型生成不同的函數簽名,關於函數簽名的詳細介紹見文末附錄

  3. 實現該 built-in 函數對應的所有函數簽名的 evalYY() 方法,此處 YY 表示該函數簽名的返回值類型

  4. 添加測試:

    a. 在 expression 目錄下,完善已有的 TestXX() 方法中關於該函數實現的測試

    b. 在 executor 目錄下,添加 SQL 層面的測試

  5. 運行 make dev,確保所有的 test cast 都能跑過

示例

這裡以重寫 LENGTH() 函數的 PR 為例,進行詳細說明

首先看 expression/builtin_string.go:

(1)實現 lengthFunctionClass.getFunction() 方法

該方法主要完成兩方面工作: 1. 參照 MySQL 規則推導 LEGNTH 的返回值類型 2. 根據 LENGTH 函數的參數個數、類型及返回值類型生成函數簽名。由於 LENGTH 的參數個數、類型及返回值類型只存在確定的一種情況,因此此處沒有定義新的函數簽名類型,而是修改已有的 builtinLengthSig,使其組合了 baseIntBuiltinFunc(表示該函數簽名返回值類型為 int)

type builtinLengthSig struct {n baseIntBuiltinFuncn}n nfunc (c *lengthFunctionClass) getFunction(args []Expression, ctx context.Context) (builtinFunc, error) {n // 參照 MySQL 規則,對 LENGTH 函數返回值類型進行推導n tp := types.NewFieldType(mysql.TypeLonglong)n tp.Flen = 10n types.SetBinChsClnFlag(tp)n n // 根據參數個數、類型及返回值類型生成對應的函數簽名,注意此處與重構前不同,使用的是 newBaseBuiltinFuncWithTp 方法,而非 newBaseBuiltinFunc 方法n // newBaseBuiltinFuncWithTp 的函數聲明中,args 表示函數的參數,tp 表示函數的返回值類型,argsTp 表示該函數簽名中所有參數對應的正確類型n // 因為 LENGTH 的參數個數為1,參數類型為 string,返回值類型為 int,因此此處傳入 tp 表示函數的返回值類型,傳入 tpString 用來標識參數的正確類型。對於多個參數的函數,調用 newBaseBuiltinFuncWithTp 時,需要傳入所有參數的正確類型n bf, err := newBaseBuiltinFuncWithTp(args, tp, ctx, tpString)n if err != nil {n return nil, errors.Trace(err)n }n sig := &builtinLengthSig{baseIntBuiltinFunc{bf}}n return sig.setSelf(sig), errors.Trace(c.verifyArgs(args))n}n

(2) 實現 builtinLengthSig.evalInt() 方法

func (b *builtinLengthSig) evalInt(row []types.Datum) (int64, bool, error) {n // 對於函數簽名 builtinLengthSig,其參數類型已確定為 string 類型,因此直接調用 b.args[0].EvalString() 方法計算參數n val, isNull, err := b.args[0].EvalString(row, b.ctx.GetSessionVars().StmtCtx)n if isNull || err != nil {n return 0, isNull, errors.Trace(err)n }n return int64(len([]byte(val))), false, niln}n

然後看 expression/builtin_string_test.go,對已有的 TestLength() 方法進行完善:

func (s *testEvaluatorSuite) TestLength(c *C) {n defer testleak.AfterTest(c)() // 監測 goroutine 泄漏的工具,可以直接照搬n // cases 的測試用例對 length 方法實現進行測試n // 此處注意,除了正常 case 之外,最好能添加一些異常的 case,如輸入值為 nil,或者是多種類型的參數n cases := []struct {n args interface{}n expected int64n isNil booln getErr booln }{n {"abc", 3, false, false},n {"你好", 6, false, false},n {1, 1, false, false},n ...n }n for _, t := range cases {n f, err := newFunctionForTest(s.ctx, ast.Length, primitiveValsToConstants([]interface{}{t.args})...)n c.Assert(err, IsNil)n // 以下對 LENGTH 函數的返回值類型進行測試n tp := f.GetType()n c.Assert(tp.Tp, Equals, mysql.TypeLonglong)n c.Assert(tp.Charset, Equals, charset.CharsetBin)n c.Assert(tp.Collate, Equals, charset.CollationBin)n c.Assert(tp.Flag, Equals, uint(mysql.BinaryFlag))n c.Assert(tp.Flen, Equals, 10)n // 以下對 LENGTH 函數的計算結果進行測試n d, err := f.Eval(nil)n if t.getErr {n c.Assert(err, NotNil)n } else {n c.Assert(err, IsNil)n if t.isNil {n c.Assert(d.Kind(), Equals, types.KindNull)n } else {n c.Assert(d.GetInt64(), Equals, t.expected)n }n }n }n // 以下測試函數是否是具有確定性n f, err := funcs[ast.Length].getFunction([]Expression{Zero}, s.ctx)n c.Assert(err, IsNil)n c.Assert(f.isDeterministic(), IsTrue)n}n

最後看 executor/executor_test.go,對 LENGTH 的實現進行 SQL 層面的測試:

// 關於 string built-in 函數的測試可以在這個方法中添加nfunc (s *testSuite) TestStringBuiltin(c *C) {n defer func() {n s.cleanEnv(c)n testleak.AfterTest(c)()n }()n tk := testkit.NewTestKit(c, s.store)n tk.MustExec("use test")n n // for lengthn // 此處的測試最好也能覆蓋多種不同的情況n tk.MustExec("drop table if exists t")n tk.MustExec("create table t(a int, b double, c datetime, d time, e char(20), f bit(10))")n tk.MustExec(`insert into t values(1, 1.1, "2017-01-01 12:01:01", "12:01:01", "abcdef", 0b10101)`)n result := tk.MustQuery("select length(a), length(b), length(c), length(d), length(e), length(f), length(null) from t")n result.Check(testkit.Rows("1 3 19 8 6 2 <nil>"))n}n

重構前的表達式計算框架

TiDB 通過 Expression 介面(在 expression/expression.go 文件中定義)對表達式進行抽象,並定義 eval 方法對表達式進行計算:

type Expression interface{n ...n eval(row []types.Datum) (types.Datum, error)n ...n}n

實現 Expression 介面的表達式包括:

  • Scalar Function:標量函數表達式
  • Column:列表達式
  • Constant:常量表達式

下面以一個例子說明重構前的表達式計算框架。

例如:

create table t (n c1 int,n c2 varchar(20),n c3 doublen)n nselect * from t where c1 + CONCAT( c2, c3 < 「1.1」 )n

對於上述 select 語句 where 條件中的表達式: 在編譯階段,TiDB 將構建出如下圖所示的表達式樹:

執行階段,調用根節點的 eval 方法,通過後續遍歷表達式樹對表達式進行計算。

對於表達式 『<』,計算時需要考慮兩個參數的類型,並根據一定的規則,將兩個參數的值轉化為所需的數據類型後進行計算。上圖表達式樹中的 『<』,其參數類型分別為 double 和 varchar,根據 MySQL 的計算規則,此時需要使用浮點類型的計算規則對兩個參數進行比較,因此需要將參數 「1.1」 轉化為 double 類型,而後再進行計算。

同樣的,對於上圖表達式樹中的表達式 CONCAT,計算前需要將其參數分別轉化為 string 類型;對於表達式 『+』,計算前需要將其參數分別轉化為 double 類型。

因此,在重構前的表達式計算框架中,對於參與運算的每一組數據,計算時都需要大量的判斷分支重複地對參數的數據類型進行判斷,若參數類型不符合表達式的運算規則,則需要將其轉換為對應的數據類型。

此外,由 Expression.eval() 方法定義可知,在運算過程中,需要通過 Datum 結構不斷地對中間結果進行包裝和解包,由此也會帶來一定的時間和空間開銷。

為了解決這兩點問題,我們對表達式計算框架進行重構。

重構後的表達式計算框架

重構後的表達式計算框架,一方面,在編譯階段利用已有的表達式類型信息,生成參數類型「符合運算規則」的表達式,從而保證在運算階段中無需再對類型增加分支判斷;另一方面,運算過程中只涉及原始類型數據,從而避免 Datum 帶來的時間和空間開銷。

繼續以上文提到的查詢為例,在編譯階段,生成的表達式樹如下圖所示,對於不符合函數參數類型的表達式,為其加上一層 cast 函數進行類型轉換;

這樣,在執行階段,對於每一個 ScalarFunction,可以保證其所有的參數類型一定是符合該表達式運算規則的數據類型,無需在執行過程中再對參數類型進行檢查和轉換。

附錄

  • 對於一個 built-in 函數,由於其參數個數、類型以及返回值類型的不同,可能會生成多個函數簽名分別用來處理不同的情況。對於大多數 built-in 函數,其每個參數類型及返回值類型均確定,此時只需要生成一個函數簽名。
  • 對於較為複雜的返回值類型推導規則,可以參考 CONCAT 函數的實現和測試。可以利用 MySQLWorkbench 工具運行查詢語句 select funcName(arg0, arg1, ...) 觀察 MySQL 的 built-in 函數在傳入不同參數時的返回值數據類型。
  • 在 TiDB 表達式的運算過程中,只涉及 6 種運算類型(目前正在實現對 JSON 類型的支持),分別是
    • int (int64)
    • real (float64)
    • decimal
    • string
    • Time
    • Duration
  • 通過 WrapWithCastAsXX() 方法可以將一個表達式轉換為對應的類型。

  • 對於一個函數簽名,其返回值類型已經確定,所以定義時需要組合與該類型對應的 baseXXBuiltinFunc,並實現 evalXX() 方法。(XX 不超過上述 6 種類型的範圍)

---------------------------- 我是 AI 的分割線 ----------------------------------------

回顧三月啟動的《十分鐘成為 TiDB Contributor 系列 | 添加內建函數》活動,在短短的時間內,我們收到了來自社區貢獻的超過 200 條新建內建函數,這之中有很多是來自大型互聯網公司的資深資料庫工程師,也不乏在學校或是剛畢業在刻苦鑽研分散式系統和分散式資料庫的學生。

TiDB Contributor Club 將大家聚集起來,我們互相分享、討論,一起成長。

感謝你的參與和貢獻,在開源的道路上我們將義無反顧地走下去,和你一起。

成為 New Contributor 贈送限量版馬克杯的活動還在繼續中,任何一個新加入集體的小夥伴都將收到我們充滿了誠意的禮物,很榮幸能夠認識你,也很高興能和你一起堅定地走得更遠。

成為 New Contributor 獲贈限量版馬克杯,馬克杯獲取流程如下:

  1. 提交 PR
  2. PR提交之後,請耐心等待維護者進行 Review。
    1. 目前一般在一到兩個工作日內都會進行 Review,如果當前的 PR 堆積數量較多可能回復會比較慢。
    2. 代碼提交後 CI 會執行我們內部的測試,你需要保證所有的單元測試是可以通過的。期間可能有其它的提交會與當前 PR 衝突,這時需要修復衝突。
    3. 維護者在 Review 過程中可能會提出一些修改意見。修改完成之後如果 reviewer 認為沒問題了,你會收到 LGTM(looks good to me) 的回復。當收到兩個及以上的 LGTM 後,該 PR 將會被合併。
  3. 合併 PR 後自動成為 Contributor,會收到來自 PingCAP Team 的感謝郵件,請查收郵件並填寫領取表單

    • 表單填寫地址:領取 TiDB Contributor 限量版馬克杯
  4. 後台 AI 核查 GitHub ID 及資料信息,確認無誤後隨即便快遞寄出屬於你的限量版馬克杯

  5. 期待你分享自己參與開源項目的感想和經驗,TiDB Contributor Club 將和你一起分享開源的力量

了解更多關於 TiDB 的資料請登陸我們的官方網站:PingCAP

加入 TiDB Contributor Club 請添加我們的 AI 微信:tidbai


推薦閱讀:

gRPC-rs:從 C 到 Rust
TiDB,為SQL注入分散式可擴展性
TiDB 在 360 金融貸款實時風控場景應用
TiDB 源碼初探

TAG:TiDB |