4

union scan的技术债务

 1 year ago
source link: https://www.zenlife.tk/union-scan-technical-debt.md
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

union scan的技术债务

2023-07-06

最近优化 union scan 性能时,发现这个技术债务问题啊,真的是一言难尽。

一个事务修改的数据,它自己要能够看到,这个实现中用到了 union scan。tidb 中事务的修改数据会 buffer 到内存中,直到事务提交的时候,通过 2PC 刷到存储层。union scan 是这样一个算子,它会在读取的时候,把下层的存储数据,和 membuffer 中的缓存数据,做一个合并操作,这样子事务就可以看到自己的修改了。

除了事务,还有不少其它地方也使用到了 union scan,比如说临时表的数据就是存储在 membuffer,读取临时表也需要通过 union scan 这个算子;比如说缓存表,数据结缓存也是使用的 membuffer,读取操作也是通过 union scan。

使用过程中我们发现 union scan 的性能很差,比如说读远程数据走 coprocessor 下推,花了 100ms 这是能符合正常认知的。而如果 membuffer 数据都是纯内存了,通过 union scan 读取花了 100ms,这就很不符合预期。

分析了一下慢的原因,发现问题主要是两点:

  1. Open() 的时候把所以数据加载
  2. 编码解码以及中间的数据变换操作效率低

其实归根到底,都是历史原因,算是技术债务,容我慢慢道来。

对于第一点,查询有可能是 select ... from xx limit 1,limit 1 只需要返回一条数据的,但是 union scan 会在 Open() 操作中,把全部的 membuffer 数据变换成 [][]types.Datum,这操作全部是浪费的。如果 membuffer 数据比较多,这里就会慢。

正确的做法应该是一个"流式"的数据加载,算子在 Open() 的时候只做基本的初始化,不要加载数据。等到了不停地调 Next() 阶段,再流式的读一点数据处理一点。这样 limit N 就没有什么性能开销了。

好了,为什么当前代码是在 Open() 的时候把数据全部加载了呢?历史原因啊。最早的时候,我们的 union scan 的实现,不是从 membuffer 读取数据的。membuffer 的数据格式是 kv 格式,而算子处理使用到的是以 row 为单位的。当时的实现是让写操作"双写",写一份 kv 格式到 membuffer,同时还写一份 []row 格式,提供给 union scan 使用。

双写这个事情很恶心,维护两套格式,两处代码,很容易出错。尤其比如说,写一处成功了,写另一处失败咋整?再比如写了 kv,然后数据回滚了,还得回滚两处。两套代码的维护,极易出错。后来 @霜爷 做了一点好事情,就把 []row 的那一份写干掉了,然后从 membuffer 的 kv 恢复出 []row 数据,也就是 [][]types.Datum。现在回答为什么代码是在 Open() 的时候把数据全部加载这个问题:因为最早的双写就是生成出了 [][]types.Datum 的,所以那一次的重构仍然保持从 membuffer 恢复出来的是全部的 [][]types.Datum。这个重构还是非常非常有价值的...这个事情不能怪霜爷。

再说第二个点,编解码以及中间的数据格式变换问题,这里再展开是两处的技术债务。

最初我们 executor 接口的数据传递都是使用的行存格式,一行用一个 []types.Datum 表示。后来重构,改成了 chunk.Chunk 格式,但是重构其实不是特彻底,只把读的那一条链路改掉了,而写的那一条链路还是使用 []types.Datum。这就造成了一个问题,实际上现在 row 有两套表示,一套是 []types.Datum, 另一套是 chunk.Row

聪明的你肯定想到了,我们只需要有一个 Row 的 interface 表示,就可以"渐近"地重构,完成全部的工作,底下两套实现都实现 Row 接口的方法,使用中依赖于 Row 的接口而不是依赖具体实现。结果有个大聪明,直接把 Row interface 也给干掉了。因为 interface 方式的传参,性能还是赶不上直接用 chunk.Chunk 的传参数。为了把表达式计算的性能优化到位,他就直接用 chunk 传参了。那么有些地方需要 []types.Datum,有些地方需要 chunk.Row 的怎么处理呢?提供转换函数,需要的时候就做类型转换呗。类型转换的开销...肯定没有测试。这种转换代码容易导致大量的额外对象分配,拖慢性能。

union scan 里面,通过 Open() 得到 []types.Datum 之后,要计算 filter 条件或者 virtual column 一类,都得走到表达式计算,就需要 []types.Datumchunk.Row 的转换,这就是中间的数据格式变换问题。

再说编解码的技术债务,我们历史是经历过一个编码格式的变换。大意是,kv 那一层的数据表示,如果用 列/类型/值,列/类型/值... 这样的结构,其处理性能是不如把类型把包到一起放到头部,把值打包到一起,然后头部有第几列 offset 多少这种信息,类似于 offsetoffsetoffset..|类型类型类型..|值值值.. 这种形式。代码库里面所有编解码的,都受到这个改动的影响。代码要改的地方很多,改不完,又容易漏。我们要为新的代码处理一套,老的代码保留一套,还要判断数据 version 字段了决定使用哪些代码。

有个大聪明想到,为了重用代码,我们可以这样处理:如果是解码旧格式,我们可以不完整实现一套,可以直接把旧编码格式,再编码成新编码格式下的 []byte,之后的部分就可以统一成一套处理方式了。也就是说,解码不是解码,而是编码成另一套格式下的 []byte,再次执行解码操作...这个想法真是太伟大了,某大牛说,计算机科学中没有什么问题,是加一层不能解决的。真是英雄所见略同。

我们的 union scan 里面的数据转换过程,就是 membuffer中 kv 格式的 []byte => []byte (新旧编码格式处理层) => []Datum (解码) => chunk.Row (表达式计算要求的类型格式) => []Datum (中间数据) => chunk.Row (executor Next() 传递使用的格式) 这么长长的编解码和数据格式转换链路。这里面的对象分配有多少,能不慢么?

我尝试优化了一下这里,直接从 kv 到 chunk.Chunk 去编解码,处理一下类型转换的对象分配,对比发现前后的对象分配数量直接减少了 10 倍

Before vs After:
cd executor
go test -tags intest -run XXX -bench BenchmarkUnionScanRead  -benchmem -cpuprofile cpu1.out -benchtime 45s
    2091          25964141 ns/op        17071121 B/op     334832 allocs/op
    5428           9718020 ns/op         1665827 B/op      34998 allocs/op

这技术债务真的是...一言难尽。大聪明其实也不是什么贬义的意思哈,因为我自己平时写代码也不少干么种"大聪明"的事情。相当于在代码里面留了许多 Sleep,等待"有缘人"去把性能提高十倍。嗯,代码里面有 TODO 都是 NEVER DO 大家都懂的。

前人栽树,后人...脸上笑嘻嘻,心里 MMP。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK