2021-11-20

关于SQL的几件事

前两天内部confluence上看到有人在做一个go sql sharding的东西.

瞄了眼讨论大致思路是打算在客户端做.
对于这个方案不置可否.

一来毕竟谈了也没什么意义.
二来各人高兴就好.

主要是看到个举例pushdown优化的一个例子插了几句.

大致是类似一个select * from table_a where filed_a in (select * from table_b where field_b = x)这种嵌套的情况.

看讨论大致是觉得不太可能push down,只能做client side的gather + secondary filter.

但实际上是有可能的是有可能的.

因为本质上从logical plan的角度来说,就是一个各自带filter的两级scan.
类似于
scan table_a
  filter op in field_a
    shuffle/exchange
      gather 
        scan table_b
          filter op = filed_b x
之类的形态.
从各个shard filter出来之后,再在这个结果集做exchange/shuffle回对应的shard再做二次filter.
甚至是如果用client side方案的话,直接在client端做二次filter,直接gather回来做引擎执行.

而理论上是存在可能改写为
scan
  gather
    scan table_a
      filter op in filed_a
        scan table_b
          filter op = field_b x
这种形式.

即把整个过程pushdown到对应的shard直接执行再gather.

这个问题的麻烦点在于不是所有的filter都可以pushdown.
只有符合某些特性的才可以.

以这个例子来说,如果要允许push down的话,需要满足:
则对于任意可能的值m,n.
如果shard_of_a(m) == shard_of_a(n).
那么shard_of_b(m) == shard_of_b(n).

即same_shard_of_a(m,n) -> same_shard_of_b(m,n).
如果m n在table_a中是同一个shard,那么table_b里也是同一个shard.

这样的话,逻辑上应该就是类似于并行存在的独立库表,不存在overlay的情况.
自然也就不怕直接pushdown了.

所以某种程度上来说,写planer/optimizer之类的,也挺有意思的.

虽然从商业角度来说,没什么价值.
或者说很难看到直接的利益冲突.

本质上来说都是compiler,只不过是不同form的transform如何选择的问题.

前段时间因为某些原因需要写个PromQL/MetricQL到Cliekhouse SQL的transpiler.

看了一些实现都是相对比较粗暴的只是做了scan和filter一层,剩下的聚合都是在拉取结果集过来再计算的.
而这个恰恰是需要解决的问题的一个因素.

原本的问题是某个metric的label是个高cardinality的东西.
导致的一个问题是,Prometheus本身的execution engine也是简单scan加filter,然后全量汇聚到内存当中算的.
所以在本身数据比较大的情况下,基本上要么算不出来要么直接内存不足整个进程oom/pod被evict了.

更不用说一个附带的数量多带来的存储空间也比较大的问题.

后来尝试用VictoriaMetric.
它相较于Prometheus在这个场景的优势在于,它的execution engine是增量的,或者说是streaming风格的.
即不像Prometheus需要把整个数据集load进内存之后再计算,而是一边读取一边计算.
并且采用了类似sharding/portioning的方式,可以多个goroutine并行地计算不同存储区域.

所以从计算效率上来说,会比前者有优势.

但是它的问题同样一个是存储.
另外一个也可以说是存储.

它的一个存储优化是对每个不同label value的time serial做个了id,这样减少了label存储的开销.
但因为这样需要一个id到lable到结构.

这样对于这个high cardinality的场景来说,id膨胀会非常快.
而每个time serial又非常稀疏,所以对索引等会有性能上的压力.

再一个就是本身设计上有对id生成速率的一些保护机制.
虽然是一个可配置参数,但是理论上总是有可能被拒绝的.

而且关键的问题是,即使有着前述的一些优势,它的计算时效性在几分钟级别.
是不太实际/可用的.

另外一个考虑过的是timescaledb.
它的优势是本身支持PronQL/MetricQL,所以如果可用的话,差不多也是开箱即用.

问题主要在于可能作为商业开源产品多少会有些小心思.

比如它的chunk机制,尤其关于ttl/retire和size等相关的配置.

本身的一些调度比如compact/compress等是利用了postgresql的job scheduler做的.
存储是自己写的一个类似于前两者的针对time serial优化的一些压缩结构.

这个问题在于比如你要设置限定每个chunk/block大小的话,并不是document上写某个参数就能实现的.
去翻它对应实现的plsql的话会发现依赖于其他一些参数的设定.
有些还是从名字上根本关联/完全想不到有联系的参数.

而这个又基本上属于一个必要的tuning部分.
因为它稍微上层一点的实现其实是把某个时间段的数据作为一个独立表来处理的.

所以如果设置的大小太大或者关联的触发条件参数使得触发得很晚或者根本触发不了的话,会导致表膨胀到设计/预期外的大小从而影响查询性能.

更进一步的,它的索引机制的默认参数里里也有些看着是默认开的优化参数.
但是实际看执行计划会发现触发了不必要的扫描.

比如某些语句指定了某个时间范围,并且chunk/table已经按照预期的确定切割正确了,但是实际上还是会触发范围外的扫描.

但是如果把某些优化参数关掉,再看执行计划会发现符合预期了.

当然,往好处想想,这可能是planer/optimizer的bug.

另外一个维度的问题是timescaledb做ingestion的组件的质量问题.

中间有触发db连接失效,但是ingest没有重试直接退出导致chan block整个stuck的问题.
虽然简单提了pr然后隔几天他们自己看了又用另一方式fix了.

还有一个就是因为自己使用不是直接利用promscale那个组件,而是作为库引入了进来.
因为既然不用prometheus了,所以直接自己写scarp直接入库,整合为一个进程.

然后再剥离ingest的时候发现了很多奇怪和不必要使用方式以及接口调用风格.

最后放弃这个方案的原因大致是有点心累了.

在数据量到一定程度后,计算时间也分钟级以上了.
而且查询到时间范围并没有变化.

所以最后转向了clickhouse.

clickhouse作为最后方案的一个原因是没有一个类似timescale的pushdown引擎.
所以如果要用的话需要自己实现PromQL/MetricQL.

至少是场景所用到的语句和对应函数的支持.

AST部分是用的MetricQL的parser.
一方面是因为MetricQL语法上更松散一些,没有强要求区别instant vector和range vector.
另一方面是Prometheus并不太容易作为一个三方库引入进来,这个也是有相关issue明确了的.

所以剩下的就是到SQL到transpiler.

从结构上来说,MetricQL可以很直接的映射成一个嵌套的SQL.
语法上就是metric source scan + filter + function op -> metric source的递归过程.

而且label selector基本就直接解决了最下层的where和row column是什么了.
剩下的就是具体的函数的sql化问题.

比如sum/max等简单aggregate 函数的group 字段inference.
还有topk之类的window函数的应用.

对应到prometheus的http api的话,就有另外一些问题.

上面的只是针对instant value的查询生成.

当然是一般场景会有某个time range的time serial和time range维度的aggregate.
后者就是比如sum_over_time等函数的实现.

因为语法上限定了x_over_time是一个range vector,所以实际上实现也还好.
就是普通aggregate函数再加个time维度的聚合而已.

主要是range查询.

range查询的问题是形式上它是instant查询的不同时间点集合.
所以一个intuition就是直接生成各个时间点,然后再生成各个时间点的sql union起来.

这个从工程上来说是最直接的.

但实际效果却不是很理想.

虽然clickhouse的设计使得即使是底层复杂sql构成的instant查询,比如带metrci[5m]这种带短时lookback的也能在至多秒级出结果,基本无关总数据量多少.

这样一个直觉的话,最多执行时间会是差不多线性的.

但实际的结果却是出人意料地在AST->Plan的阶段.

像一个小时左右的range查询,step是15秒的话也是,240个instant的union.
一些比较复杂的instant sql本身嵌套的层次就比较多.

给每个嵌套生成一个序列table id的话,最后可能上千甚至几千的table.
sql本身的大小也可能到mb级别.

ClickHouse在处理这种大小的sql的编译优化的时候,就不太行了.
时间起码也是几分钟以上.
这在比如grafana端也早就超时了.

平心而论的话,这个应该多数planer都应付不太过来.
毕竟搜索空间膨胀太快.

另外一个思路是类似于x_over_time的聚合.
根据时间生成一个时间范围相关的pseudo group,当作一个普通字段参与到group by当中.
再在最外层remove替换为时间.

这个方式没再去实现.

可能遇到的问题一个是计算出来的时间跟实际存储数据的时间有一定的不一致情况.
这个倒不是关键问题.

另一个主要的是需要对对应的instant函数的处理实现一套range的版本.

当然,也可以以range的版本,用align之后的pseudo时间去生成instant的版本方式实现.

没有继续往下做的原因一个是有其他新的坑要填.

一个也是实际上原始的cardinality问题只需要instant版本的.
没什么太大的必要去完善支持用不到的部分.

至少是暂时用不到的部分.

这里另外一个题外话就是union的那个问题.

理论上来说,如果clickhouse支持类似presto/trino等直接执行plan的方式等话,可能就没解析的问题了.

毕竟归根到底,sql是plan表达能力的一个子集.
如果用plan的方式,可能有些实现就没那么麻烦.

而且这个union其实也算特定的一个场景.
由几乎同态的自查询构成.

想一下的话,不过也就是scan fitter calculate的时候分到不同的group罢了.
可以针对性地下推,避免膨胀的optimizer搜索.








    



聊聊卡布里尼

最近看了部片叫卡布里尼,算是可能这段时间来比较有意思的一部电影. 故事也不算复杂,就是一个意大利修女去美国传教,建立慈善性质医院的故事. 某种程度上来说,也很一般的西方普世价值主旋律. 但是如果换一套叙事手法,比如共产国际的社会主义革命建立无产阶级广厦千万间的角度来看的话,也不是...