使用 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 开发中,请联系我们。

你可能感兴趣的:(使用 unistore 对 TiDB 快速进行『回表优化』原型验证)