由来
Apache Kylin定位是大数据量的秒级SQL查询引擎,原理是通过预计算所有可能的维度组合存储在Hbase中,查询时解析SQL获取维度和度量信息,然后再从hbase中扫描获取数据返回,个人认为Kylin最强大的地方在于实现了SQL引擎,如果使用自定义的格式化查询语言也可以完成相应的数据访问操作,无非是指定查询的维度、度量、聚合函数、过滤条件,排序列等等。
但是这种描述较之于SQL太弱了,SQL很灵活的将一些复杂的语义转换,例如kylin中不支持select xxx where xx in (selext xxx)的语句,但是可以通过子查询join的方式实现,这样的局限导致了大量复杂的SQL。除此之外,由于Kylin中可能存在某几个维度的cardinality比较大,当使用该列进行group by的时候会导致需要从hbase中读取大量的记录进行聚合运算甚至排序。Kylin中SQL引擎使用的是Calcite进行SQL解析、优化和部分算子的运算,Calcite的计算是完全基于内存的,所以当Kylin中一个查询需要从hbase中获取大量记录的情况下,内存逐渐会成为瓶颈。
OLAP查询往往是基于历史数据的,历史数据最重要的特性是不可变的,即便偶尔由于程序BUG导致数据需要修复,对这种不经常会变化的数据的查询,并且每一个查询可能消耗大量资源的情况下,缓存是最常用也是最有效的提升性能的办法。
现状
Kylin并不是不对查询结果进行缓存的,对于每一个查询会根据该查询扫描的记录总数是否超过阀值(以此判断是否值的缓存)判断是否缓存结果。但是这部分缓存是基于本机内存的,并且是实例间不可共享的,而一般Kylin查询服务器的架构是多个独立的服务器通过前面的负载均衡器进行请求转发,如下图,所以这部分缓存是无法共享的。由此目前Kylin的缓存机制存在一下几种弊端:
- 缓存在本机内存,对查询最需要的内存资源就是一种消耗。
- 机器间无法共享,导致查询可能时快时慢,并且造成不必要的资源浪费。
- 无法自动过期,build完成之后需要手动清理,否则结果可能出现错误。例如查询select count(1) from xxx;当一个新的segment build完成之前和之后两次查询的结果是相同的,明显第二次是使用缓存的,并且build成功之后并没有将缓存清理,需要手动清除。
改进
了解了Kylin当前缓存的情况,针对以上前两点进行改进,最直接的方案便是将缓存移至外部缓存,首选的key-value缓存当然是redis,如下图,根据现在缓存的设计,可以将查询的SQL和所在的project作为key,查询结果以及一些查询中的统计信息作为value缓存。但是第三点需要针对kylin的实现设计出具体的自动过期方案。
Kylin数据计算和查询流程
Kylin是基于预计算的,计算的是所有定义的维度组合的聚合结果(SUM、COUNT等),既然需要聚合肯定需要一段数据量的积累,Kylin通过在创建Cube时定义一个或者两个(新版本,支持分钟级别粒度)分区字段,根据这个字段来获取每次预计算的输入数据区间,Kylin中将每一个区间计算的结果称之为一个Segment,预计算的结果存储在hbase的一个表中。通常情况下这个分区字段对应hive中的分区字段,以天为例子,每次预计算一天的数据。这个过程称之为build。
除了build这种每个时间区间向前或者向后的新数据计算,还存在两种对已完成计算数据的处理方式。第一种称之为Refresh,当某个数据区间的原始数据(hive中)发生变化时,预计算的结果就会出现不一致,因此需要对这个区间的segment进行刷新,即重新计算。第二种称之为Merge,由于每一个输入区间对应着一个Segment,结果存储在一个htable中,久而久之就会出现大量的htable,如果一次查询涉及的时间跨度比较久会导致对很多表的扫描,性能下降,因此可以通过将多个segment合并成一个大的segment优化。但是merge不会对现有数据进行任何改变。
说句题外话,在kylin中可以设置merge的时间区间,默认是7、28,表示每当build了前一天的数据就会自动进行一个merge,将这7天的数据放到一个segment中,当最近28天的数据计算完成之后再次出发merge,以减小扫描的htable数量。但是对于经常需要refresh的数据就不能这样设置了,因为一旦合并之后,刷新就需要将整个合并之后的segment进行刷新,这无疑是浪费的。
说完了数据计算,接下来讲一下这部分预计算的数据是如何被使用的,Calcite完成SQL的解析并回调kylin的回调函数来完成每一个算子参数的记录,这里我们只需要关心查询是如何定位到扫描的htable的。当一个查询中如果涉及到建cube中使用的分区字段,由于分区字段一般是维度字段,否则每次扫描都需要扫描全部的分区。那么这里涉及表示该字段出现在where子句或者group by子句中,下面分几种情况分别讨论(假设分区字段为dt):
- SQL中没有分区字段,例如select country, count(1) from table group by country,这种查询需要统计的数据设计全部预计算的时间区间和将来计算的时间区间,所以它需要扫描所有的htable才能完成。这种情况下build和refresh计算都会使得结果发生变化。
- SQL中where子句使用了分区字段,例如select country, count(1) from table where dt >= ‘2016-01-01’ and dt < ‘2016-02-01’ 这种情况下可以根据时间区间确定只扫描部分htable。这种情况只有这段时间区间的refresh操作会改变查询结果,如果过滤的时间区间包含一个未来的时间,build操作会导致结果发生改变。
- SQL中group by中使用了分区字段,这种情况下类似于1,需要扫描全部的htable以获取结果。
- SQL中group by和where字段中都使用了分区字段,这种情况类似于2,毕竟SQL执行时首先执行where再进行group by的。
分析
既然数据计算和查询结果有了这种关系,那么就可以利用这种关系解决缓存中最困难的一个问题——缓存过期,即现状中的问题3,但是查询通常是非常复杂的,例如多个子查询join、多个子查询union之类的,并不能轻易地获取group by和where子句的内容,幸运的是,在Kylin每一个查询过程中会将本次查询或者每一个子查询设计的信息保存在OLAPContext对象中,一次查询可能生成多个OLAPContext对象,它们被保存在一个stack中,并且这个stack是线程局部变量,因此可以通过遍历这个stack中内容获取本次查询所有信息,包括使用的cube、group by哪些列、所有的过滤条件等。
缓存设计
- 缓存的key:查询请求,包括SQL、project、limit、offset、isPartial参数,将该信息
- 缓存的value:查询返回结果,包括结果数据和元数据,以及其他统计信息,包括使用了哪些cube,每一个cube的时间分区过滤条件是什么。以及缓存添加的时间。
- 添加缓存:查询获取结果之后将key-value对加入到缓存中,Kylin原生的缓存会缓存异常,这里不进行缓存,主要是由于Kylin内部的异常主要分为三类:AccessDeny、语法错误和其它数据访问异常,第一种原生的缓存也不会进行存储,第二种异常查询一下元数据就可以判断也没必要缓存;第三种则是系统异常,缓存可能会导致修复之后的的查询继续出现错误。因此缓存中只存储执行成功的查询。
- 缓存过期时间:可配置,其实这个值设置无所谓,只是为了将长时间不查询的key删除罢了,可以设置较久。
- 缓存失效:根据上面的分析,数据计算中build和refresh都可能对缓存中的结果产生变化,因此这部分缓存需要自动失效,检查缓存失效的方式由两种,一种主动式,在每次数据计算任务结束之后遍历全部的缓存值判断是否失效,另外一种是被动式,真正读取缓存的时候再去判断它是否已经失效,不被读取的已失效缓存会随着过期时间的到达而自动删除。前者需要redis的keys命令的支持,后者只是在读取value之后根据上一次执行的统计信息执行单个记录的判断,很明显这种情况下被动的方式更加合适。判断的逻辑如下:当每一个数据计算任务完成之后(无论是build、refresh还是merge)都会记录这个segment的最后更新时间(更新cube元数据的时候记录),在查找缓存对的时候首先根据key获取缓存内容,即缓存的value,然后查看每一个使用的cube的分区字段过滤区间,查看该区间的segment是否存在最后更新时间大于缓存添加时间的。如果存在说明这段数据已经被更新,则释放该条缓存,否则说明数据没有被更新,可以继续使用当前缓存作为结果。
条件和说明
- 一个相同的SQL两次查询使用相同的cube。
- 每一个segment的最后更新时间总是有效的,不会出现错误的时间。
* 每一个查询服务器的元数据是相同的,实际情况下不同机器之间可能存在短时间的误差。 - 只统计第一层时间分区(天)的过滤周期,如果where条件中没有出现过滤(情况1,3)则说明过滤区间为[0, Long.MAX_VALUE]
- 虽然merge不会导致数据的修改,但是merge之前可能出现其中某一个segment被build或者refresh,而这部分信息无从获取,所以merge需要和refresh相同对待。
优化
- 缓存作为锁,相同的查询可以通过外部缓存顺序化,如果缓存可以命中则直接从缓存中获取结果,否则首先将该查询作为key加入,value设置为isRunning表示加锁,后面相同的查询等待之前查询结果的返回,可以通过订阅-发布模式或者轮询isRunning标识的方式判断第一次查询是否执行完成,放置同时执行多个相同的查询。
- SQL标准化,查询的SQL可能存在只多一个空格的情况,虽然语义是完全一样的,但是作为key则是不相同的,可以通过修剪SQL中多余的空格(字符串中的除外)完成SQL写法的标准化,更充分的利用缓存。
- 只缓存执行成功的查询结果。不缓存异常。
- 手动清除异常接口。
伪代码
//首先检查key是否存在if exists key value = get key //如果key存在可能存在两种情况:已经有结果或者正在查询 if value is running waiting for finish(loop and check) //轮询是个更好的办法,效率稍低 else //即使有结果也可能存在结果已经失效的情况 if value is valid return value else //为了保证互斥,使用setnx语句设置 set if exist the value is running if return true run the query else wait for finish(loop and check)else //不存在则直接表示running set if exist the value is running if return true run the query eles waiting for finish(loop and check)
总结
本文基于分析Kylin中现有的缓存策略,提出一种使用外部缓存的方案,接下来可以基于此进行编码和测试,希望能够取得较好的效果。