使用 unistore 對 TiDB 快速進行『回表優化』原型驗證

很早之前,TiDB 流傳着一個段子 - 『每天只有 24 次編譯 TiKV 的機會』,雖然現在這個黑歷史早就成了過去,完整編譯一次 TiKV 的時間其實也就是 10 分鐘,使用 debug 編譯速度會更快,但實話,對於想快速開發進行原型驗證的同學,有時候這個耗時還是不能接受。

如果我們有一個 Go 的程序,能模擬 TiKV,那麼我們所有的快速驗證都可以使用 Go 來進行,這樣能大大的提升原型驗證效率,幸運的是,我們早就有了這樣的東東 - unistore。使用 unistore 非常的簡單,直接 make 就能編譯出來一個 binary,然後使用 ./bin/unistore-server --data-dir ./db 就能啓動了,當然使用之前要把 PD 啓動起來,對於 TiDB 則是按照使用 TiKV 的方式。有了 unistore,對我們有什麼好處呢?直觀的就是原型驗證會非常快速了。

最近我在思考如何減少 TiDB 和 TiKV 之間讀取數據的回表操作,假設現在我們有一張表,結構如下:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  `name` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `k` (`k`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

INSERT INTO t VALUES (3, 3, "c");

我們現在插入了一條數據,然後使用 SELECT * FROM t WHERE k = 3 來查詢,對於 TiDB 來說,底層的邏輯如下:

  1. TiDB 首先使用 unique index 來獲得 k = 3 這條數據實際的 primary key,也就是 id = 3
  2. TiDB 使用 primary key id = 3 拿到實際的數據

上面的步驟,我們俗稱回表操作,但這個回表操作有一個很嚴重的問題,就是要有兩次網絡交互。但實際,在上面這個 case 裏面,表的 index 和實際的 row 數據都是在一個 TiKV region 裏面,也就是說,我們在通過 k = 3 拿到對應的 primary key 之後,直接可以進行 id = 3 的數據讀取,然後將實際的 row 給返回。(雖然我們馬上要支持的 clustered index 能緩解不少,但只要是通過其他 index 來查詢,仍然會遇到網絡回表問題)

優化前後的效果大概如下

理論上上面這個優化一定是能提速的,剩下的當然是快速的原型驗證,所以這裏選擇 unistore,因爲上面的兩步,對應的協議都是 KvGet,首先我們先改下 proto,如下:

message GetResponse {
    // A region error indicates that the request was sent to the wrong TiKV node
    // (or other, similar errors).
    errorpb.Error region_error = 1;
    // A value could not be retrieved due to the state of the database for the requested key.
    KeyError error = 2;
    // A successful result.
    bytes value = 3;
    // True if the key does not exist in the database.
    bool not_found = 4;
    bytes lookup_value = 5;
}

我們添加了一個 lookup_value 字段,用來存儲回表操作讀取的值。然後在 unistore 的 KvGet 裏面,做如下改動:

    val = safeCopy(val)

    var lookupValue []byte

    {
        tableID, indexID, _, _ := tablecodec.DecodeKeyHead(req.Key)
        if indexID > 0 && len(val) > 0 {
            var iv kv.Handle
            iv, err = tablecodec.DecodeHandleInUniqueIndexValue(val, false)
            if err == nil {
                key := tablecodec.EncodeRowKeyWithHandle(tableID, iv)
                lookupValue, _ = reader.Get(key, req.GetVersion())
            }
        }
    }

    return &kvrpcpb.GetResponse{
        Value: val,
        LookupValue: lookupValue,
    }, nil
}

上面的邏輯是先嚐試解開 key,如果這個 key 是一個 index,那麼我們就嘗試按照 unique index 的方式解碼,這個其實就能得到實際的 primary key 了,然後通過這個 primary key 直接讀取數據,放到 lookup value 裏面一起返回。

然後在 TiDB 這一層,因爲外面其實使用的 KV interface 來跟 unistore 交互,爲了不改動接口,我們使用 context 的方式,將 lookup value 給傳遞到請求處理那邊,然後 get response 之後將這個值給設置上去,類似如下:

var lookupValue *[]byte
if v := bo.ctx.Value("lookup_value"); v != nil {
    lookupValue = v.(*[]byte)
}

req := tikvrpc.NewReplicaReadRequest(tikvrpc.CmdGet,
    &pb.GetRequest{
        Key:     k,
        Version: s.version.Ver,
    }, s.replicaRead, &s.replicaReadSeed, pb.Context{
        Priority:     s.priority,
        NotFillCache: s.notFillCache,
        TaskId:       s.taskID,
    })
for {
    loc, err := s.store.regionCache.LocateKey(bo, k)
    if err != nil {
        return nil, errors.Trace(err)
    }
    resp, _, _, err := cli.SendReqCtx(bo, req, loc.Region, readTimeoutShort, kv.TiKV, "")
    ...
    cmdGetResp := resp.Resp.(*pb.GetResponse)
    val := cmdGetResp.GetValue()
    ...

    if lookupValue != nil {
        // 這裏設置了 lookup value
        *lookupValue = cmdGetResp.LookupValue
    }

我們在 PointGet 的 Next 函數裏面,做如下改動:

var lookupValue []byte
// 這裏我們要傳一個引用進去,這樣 get 裏面才能設置值
ctx1 := context.WithValue(ctx, "lookup_value", &lookupValue)

e.handleVal, err = e.get(ctx1, e.idxKey)
if err != nil {
    if !kv.ErrNotExist.Equal(err) {
        return err
    }
}

然後如果有 lookup value,我們就不在進行回表操作了:

var val []byte
if len(lookupValue) == 0 {
    var err error 
    key := tablecodec.EncodeRowKeyWithHandle(tblID, e.handle)
    val, err = e.getAndLock(ctx, key)
    if err != nil {
        return err
    }       
} else {
    val = lookupValue
}

做了如下更新之後,我們基於 sysbench 框架來測試,使用的 sysbench PointGet,當然,我把測試代碼改成了 SELECT * FROM t WHERE k = 3,結果如下:

// 優化後
[ 10s ] thds: 32 tps: 39734.48 qps: 39734.48 (r/w/o: 39734.48/0.00/0.00) lat (ms,95%): 1.30 err/s: 0.00 reconn/s: 0.00
[ 20s ] thds: 32 tps: 37079.88 qps: 37079.88 (r/w/o: 37079.88/0.00/0.00) lat (ms,95%): 1.34 err/s: 0.00 reconn/s: 0.00


// 優化前
[ 10s ] thds: 32 tps: 30474.39 qps: 30474.39 (r/w/o: 30474.39/0.00/0.00) lat (ms,95%): 1.61 err/s: 0.00 reconn/s: 0.00
[ 20s ] thds: 32 tps: 26815.95 qps: 26815.95 (r/w/o: 26815.95/0.00/0.00) lat (ms,95%): 1.89 err/s: 0.00 reconn/s: 0.00

可以看到,不通過網絡回表,對於使用 unique index 來進行點查的情況性能至少能提升 40% 以上,這個收益還是蠻可觀的,而且如果網絡有延遲,這個收益會更大。

上面只是簡單的做了一個原型驗證,那麼,爲啥我們要做這個事情呢?主要對於一些場景是真的有用,譬如 TPC-C,或者銀行的核心交易場景,數據會有明顯的分區特性,而一個分區的實際數據大小又不會太大,所以多數時候,我們都可以通過調度讓分區的數據儘量聚集到一起,這樣我們的回表讀取都是可以不用走網絡了。

當然實際要做的工作還有很多,強烈建議感興趣的同學參與進來,直觀的有:

  • 爲 batch get,scan 甚至 coprocessor 都提供本地回表功能,這個工作量就已經很大了。
  • 如果不在同一個 region,在 TiKV 上面跨 region 讀取,要考慮 region leader 問題。
  • 現在 TiDB 是基於 region 進行調度的,一個分區可能有不同的 table,也就是會有不同的 region,我們後面也需要讓 PD 能支持按照分區進行調度。

上面只是使用 unistore 的一個簡單例子,從規劃原型,寫代碼,跑通流程,改 sysbench 腳本進行落地驗證,我總共花了不到 1 個小時吧,可以看到效率還是非常驚人的。所以,你如果喜歡 TiDB 但又不想被 Rust 虐待,歡迎嘗試下 unistore。另外,我們很多新奇的 idea 也會在 unistore 提前驗證,所以你如果想更加深度的參與到 TiDB 開發中,請聯繫我們。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章