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,这就很不符合预期。
分析了一下慢的原因,发现问题主要是两点:
- 在
Open()
的时候把所以数据加载 - 编码解码以及中间的数据变换操作效率低
其实归根到底,都是历史原因,算是技术债务,容我慢慢道来。
对于第一点,查询有可能是 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.Datum
到 chunk.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。