十分鐘成為 Contributor 系列 | 為 TiDB 重構 built-in 函式

PingCAP發表於2017-06-23

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

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

重構 built-in 函式整體流程

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

  2. 重寫 XXFunctionClass.getFunction() 方法

    • 該方法參照 MySQL 規則,根據 built-in 函式的引數型別推導函式的返回值型別
    • 根據引數的個數、型別、以及函式的返回值型別生成不同的函式簽名,關於函式簽名的詳細介紹見文末附錄
  3. 實現該 built-in 函式對應的所有函式簽名的 evalYY() 方法,此處 YY 表示該函式簽名的返回值型別

  4. 新增測試:

    • 在 expression 目錄下,完善已有的 TestXX() 方法中關於該函式實現的測試
    • 在 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 {
    baseIntBuiltinFunc
}

func (c *lengthFunctionClass) getFunction(args []Expression, ctx context.Context) (builtinFunc, error) {
    // 參照 MySQL 規則,對 LENGTH 函式返回值型別進行推導
    tp := types.NewFieldType(mysql.TypeLonglong)
    tp.Flen = 10
    types.SetBinChsClnFlag(tp)

    // 根據引數個數、型別及返回值型別生成對應的函式簽名,注意此處與重構前不同,使用的是 newBaseBuiltinFuncWithTp 方法,而非 newBaseBuiltinFunc 方法
    // newBaseBuiltinFuncWithTp 的函式宣告中,args 表示函式的引數,tp 表示函式的返回值型別,argsTp 表示該函式簽名中所有引數對應的正確型別
    // 因為 LENGTH 的引數個數為1,引數型別為 string,返回值型別為 int,因此此處傳入 tp 表示函式的返回值型別,傳入 tpString 用來標識引數的正確型別。對於多個引數的函式,呼叫 newBaseBuiltinFuncWithTp 時,需要傳入所有引數的正確型別
    bf, err := newBaseBuiltinFuncWithTp(args, tp, ctx, tpString)
    if err != nil {
        return nil, errors.Trace(err)
    }
    sig := &builtinLengthSig{baseIntBuiltinFunc{bf}}
    return sig.setSelf(sig), errors.Trace(c.verifyArgs(args))
}複製程式碼

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

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

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

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

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

// 關於 string built-in 函式的測試可以在這個方法中新增
func (s *testSuite) TestStringBuiltin(c *C) {
    defer func() {
        s.cleanEnv(c)
        testleak.AfterTest(c)()
    }()
    tk := testkit.NewTestKit(c, s.store)
    tk.MustExec("use test")

    // for length
    // 此處的測試最好也能覆蓋多種不同的情況
    tk.MustExec("drop table if exists t")
    tk.MustExec("create table t(a int, b double, c datetime, d time, e char(20), f bit(10))")
    tk.MustExec(`insert into t values(1, 1.1, "2017-01-01 12:01:01", "12:01:01", "abcdef", 0b10101)`)
    result := tk.MustQuery("select length(a), length(b), length(c), length(d), length(e), length(f), length(null) from t")
    result.Check(testkit.Rows("1 3 19 8 6 2 <nil>"))
}複製程式碼

重構前的表示式計算框架

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

type Expression interface{
    ...
    eval(row []types.Datum) (types.Datum, error)
    ...
}複製程式碼

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

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

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

例如:

create table t (
    c1 int,
    c2 varchar(20),
    c3 double
)

select * from t where c1 + CONCAT( c2, c3 < “1.1” )複製程式碼

對於上述 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 型別的支援),分別是
  1. int (int64)
  2. real (float64)
  3. decimal
  4. string
  5. Time
  6. Duration

    通過 WrapWithCastAsXX() 方法可以將一個表示式轉換為對應的型別。

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

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

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

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

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

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

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

  1. 提交 PR
  2. PR提交之後,請耐心等待維護者進行 Review。目前一般在一到兩個工作日內都會進行 Review,如果當前的 PR 堆積數量較多可能回覆會比較慢。程式碼提交後 CI 會執行我們內部的測試,你需要保證所有的單元測試是可以通過的。期間可能有其它的提交會與當前 PR 衝突,這時需要修復衝突。維護者在 Review 過程中可能會提出一些修改意見。修改完成之後如果 reviewer 認為沒問題了,你會收到 LGTM(looks good to me) 的回覆。當收到兩個及以上的 LGTM 後,該 PR 將會被合併。
  3. 合併 PR 後自動成為 Contributor,會收到來自 PingCAP Team 的感謝郵件,請查收郵件並填寫領取表單
  4. 後臺 AI 核查 GitHub ID 及資料資訊,確認無誤後隨即便快遞寄出屬於你的限量版馬克杯
  5. 期待你分享自己參與開源專案的感想和經驗,TiDB Contributor Club 將和你一起分享開源的力量

瞭解更多關於 TiDB 的資料請登陸我們的官方網站:pingcap.com

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

相關文章