本文是12月4号在《DataFunTalk技术交流会:阿里云实时查询分析专场》分享的议题《面向异构数据的Schema-on-Read分析技术与实践》的文字版记录。
一、背景介绍
数字化转型浪潮中,物理世界到数字世界的映射带来了各种各样结构的数据。
第一种是完全无结构的数据,完全没有任何规律,典型的如syslog这种记录系统事件的日志;
第二种是半结构化数据,有局部的结构,但是不同的行的字段的数目是不确定的,典型的比如JSON日志;
第三种是结构化的数据,符合关系数据模型,有着确定的字段数据,不同的行记录之间的列是完全对齐的,比如交易订单数据等。
各种各样的日志,采集到云上之后,通过查询分析,可以产生丰富的数据价值。比如生成各种可视化图表,对查询关键词或者SQL结果列的某个条件生成异常告警,或者是定时SQL对数据进行加工清洗等等。这些都是基于对日志的查询分析衍生出来的多样化的应用场景。
前面提到的日志数据的不同的结构化的程度,对于日志的分析带来了什么样的挑战呢?
我们来看一个简单的例子,比如我们的日志中有a和b两个字段,为了能够进行查询分析,需要为这两个字段分别建立索引。随着业务场景的变化,日志中出现了一个新的c字段,为了对这个新的字段进行分析,此时需要更新索引配置(必要的话还需要对历史数据执行索引重建)。可能随着时间推移,又出现了一个新的d字段,那么就需要再次去更新索引配置。。。
可以看出,对于半结构化和非结构化的数据,由于无法实现确定和枚举字段,一旦出现新的字段就要去更新索引,并且字段发生变化时往往并不能及时感知,因此给运维带来了很大的不便。
另一方面,异构数据往往数据价值密度没有那么高,如果对于全部字段都去开启索引,所产生的索引费用可能与实际产生的数据价值不匹配。换句话说,可能只是偶尔需要去分析,但是却不得不对所有的数据都建立索引。
那么,我们如何去解决这些问题,从而做到增效降本呢?
二、技术实现
首先我们回顾一下日志分析技术流派的演进历程。
-
最传统的日志分析方式,是日志直接存储在应用程序所在机器的本地磁盘上,运维人员登录到跳板机,通过pssh+grep脚本的方式去分析日志。这种方式简单有效,不依赖第三方数据采集和处理设施,时至今日也还是常常会用到,比如快速定位线上问题日志。不过局限性也很明显,比如单机磁盘空间有限且不能可靠存储,bash脚本只能做简单的处理难以进行复杂的分析等等。
-
以ElasticSearch为代表的搜索引擎,通过分区+多副本将数据可靠的集中存储,通过倒排索引实现快速的关键词检索,并且有自身的DSL可以进行聚合分析,是日志分析领域的老牌劲旅。优点是查询检索能力强,特别是各种模糊、短语查询等等,不足之处是自定义DSL在分析场景下的表达能力以及易用性不如标准SQL。
-
大数据领域基于MapReduce架构发展出来的,Hive和Spark为代表的批处理引擎,也同样可以用来对日志数据进行分析。优点是可以完成非常大规模的数据分析,并且支持SQL,不足之处是相对而言由于批处理引擎设计更关注扩展性和容错,因此在实时分析场景下显得速度较慢。
-
以ClickHouse为代表的,各种MPP架构的OLAP引擎则以“快”为第一目标,定义好执行计划分配到各个节点,然后就是全内存流水线操作,出错后快速失败,再结合代码生成、向量化等技术,最大化的去做加速。相对应的,代价就是可扩展性以及容错性不足,因此对于长周期处理任务不太适合,更适合交互式实时分析场景。此外要发挥出MPP的速度优势,存储层面往往要结合列式存储(加速IO)和额外的索引(快速定位)。
-
相较于基于全文索引或是列存之上的各种以规模和速度为目标的系统,Loki则另辟蹊径,存储用最低成本的对象存储,然后只构建稀疏的Lable索引,计算的时候通过硬扫的方式进行,从而保证了最低的成本和较高的灵活性,缺点显然就是分析速度慢。
从架构分类的角度上看,目前SLS相当于是结合了ES与ClickHouse,通过倒排索引实现快速搜索,通过列存+MPP计算架构实现了强大的分析能力,并且完整支持标准SQL。不足之处则是倒排索引和列式存储带来额外的成本,并且对于数据Schema有要求。
那么,对于类似Loki所瞄准的无结构低成本的应用场景,SLS如何更好的去覆盖呢?
我们先看一下Schema-on-Write和Schema-on-Read的概念。
在数据分析场景中,Schema-on-Write指的是数据的Schema是预先定义好的,数据在写入数据仓库之前,必须要按照定义好的Schema去写入,如果原始数据不符合定义的Schema,则需要先通过一些ETL的过程去对数据进行清洗加工,然后再写入。数据写入的同时,往往能够根据确定的Schema建立一些索引结构,查询的时候也同样直接按照确定的Schema去查询,往往能够获得较好的查询性能。
而Schema-on-Read指的是原始数据在写入的时候不做过多的校验,而在查询之前,再去按照查询分析的需求去定义一个Schema并分析,类似在数据之上按需建立一个视图。可以看出这种方式更灵活,但性能上会受到一些折扣。
Schema-on-Write和Schema-on-Read更多的是一种设计理念,而不是具体的实现。
举个例子,我们今天常说的“湖仓一体”,其中“仓”这个概念就更侧重于Schema-on-Write,关注数据价值和分析性能,而“湖”这个概念则更侧重于Schema-on-Read,关注数据采集的灵活性与广泛性。
具体到我们的日志分析场景,如何去应用Schema-on-Read的设计理念?
现有SLS的查询分析流程,日志在写入的时候,如果有索引,则按照其配置分词后生成对应的倒排索引数据,同时如果有勾选开启统计,则同时为对应的字段构建列式存储。而在执行查询语句的时候,同样根据索引配置切分分词,然后从倒排索引中计算命中的行,如果是SQL分析语句,则根据要分析的字段读取对应的列存数据,然后进行相应的计算。这是一个典型的Schema-on-Write的流程。
在此基础上,如果要实现Schema-on-Read,则必须要解决以下问题:
(1)没有Schema信息,SQL引擎怎么执行?
因为SQL执行引起都是遵从关系数据模型,需要知道每张表有哪些列,每一列是什么类型。如果没有这些信息,就无法生成执行计划。
(2)没有列存数据,怎么读取指定列的数据?
写入的时候只有原始的行存数据,没有列存数据。怎么从这种无结构的行存数据中,获取SQL分析需要的对应列的数据。
首先针对第一个问题,没有列的类型信息,SQL引擎如何执行?
我们的做法是从用户输入的SQL语句中自动推断出所需要的Schema。
这里举一个最简单的SQL语句的例子,Select id,login from users,这条SQL语句经过词法分析和语法分析后,会对原始语句进行分词并构建出一棵抽象语法树,语法树里面就隐含了这条语句需要什么样的Schema,比如对于刚才这条SQL语句,我们就可以从语法树中推断出,需要从users表里面,读取id和login这两列,类型分别是varchar,这就是这条语句所需要的schema信息。
具体到实现上,SQL语句在经过词法分析后,在执行语义分析之前,先根据前面从语法树中推断出的SQL语句需要的Schema信息,在内存中去创建一个对应的临时表,注入到元数据管理模块。
然后再去执行语义分析过程,此时就可以从元数据管理模块中获得刚才创建的临时表,从而完整正确的语义校验,并顺利生成执行计划,并分发到相应的数据节点和计算节点去执行。一旦执行计划完成分发后,这个临时表就可以清理掉了。
第二个要解决的问题是在执行数据读取的过程中,如何从非结构化的原始数据中,匹配提取出结构化的数据来参与计算。
举个例子,比如 select count(1) as pv, Api, Status group by Api, Status 这条SQL语句,前面在Schema推断的过程中,会推断出有一张临时表,表里面包含了Api、Status这两列的数据。在执行阶段,就需要从原始数据中,读取出只属于这两列的数据。
从直观逻辑上,这个执行过程是这样的:
-
遍历所有的日志行
-
对于每一行日志,遍历所有的字段列
-
遍历所有的待匹配的列(这里是Api、Status列),和当前的日志字段key比较是否相等,如果相等,将字段value加入到对应的结果列中
-
如果遍历完一行日志,都没有对应的列匹配上,则填充一个NULL值
-
最终得到Api和Status两列数据,这两列的行数和原始数据的行数相等
然而直接按照这个算法去执行,是非常慢的,原因有两个:
(1)IO慢:行存本身相比于列存需要从磁盘上读取更多的数据,一是因为读的时候需要将所有数据都读取出来,二是因为行存数据的压缩率比列存低。磁盘IOPS是有限的,更多的数据读取意味着需要更多的时间,这个是影响性能的主要因素。
(2)匹配慢:上述算法中需要三轮循环,时间复杂度比较高。此外大量的字符串比较本身也是一个低效的操作。
针对IO慢的问题,那么需要做的就是尽量去减少需要读取到内存中的数据量。
首先,根据查询时间段进行过滤,日志数据天然具备时间这个有序的主键。存储层面日志的原始数据是按照时间去排布的,因此可以先根据用户查询的时间范围,进行第一轮过滤。
其次,如果用户的数据量很大,建立用户针对少量字段预先建立索引,达到一种“稀疏索引”的效果,比如用户id、tag这种字段,数据量不大并且能够有效的减少查询范围。在数据读取的过程会根据这个索引快速过滤掉不符合条件的数据块。
最后,数据读取的过程中增加LRU缓存,结合计算层面的亲和性调度,提高缓存命中率,从而减少到磁盘的实际读取量。
针对匹配慢的问题,优化思路主要是通过hash比较、优先比较上次匹配位置、提前终止等方式减少循环次数并尽量避免字符串直接比较:
-
首先根据待匹配的列名,计算出对应的hash值
-
然后遍历日志行,针对每一行遍历字段列,比较的时候通过计算hash并匹配
-
记录待匹配的列上一次成功匹配字段的offset位置,后续优先比较这些offset对应的字段
-
记录字段偏移量上的匹配命中情况,如果已经匹配则提前终止
经过上述这些优化,可以有效的提高整个匹配过程的效率。
我们再整体回顾下Schema-on-Read分析的技术实现架构:
SQL解析阶段,用户输入的SQL经过词法分析得到抽象语法树,从抽象语法树中推断出这条SQL语句需要的Schema,然后生成一张临时表注册到内存中的元数据管理模块,后续会使用这张临时表的Schema信息,进行语义分析和plan优化,生成执行计划。
在数据读取阶段,先通过时间范围和已有的索引信息去定位数据块,数据读取到内存后,使用优化后的算法从原始的非结构化数据中匹配构造出需要参与计算的列式数据,之后就可以按照标准SQL的计算过程进行处理。
此外在整个数据读取和计算过程中,原始数据、中间数据、结果数据都会使用对应的缓存来加速计算。
三、Scan 介绍
接下来我们介绍一下SLS在产品层面上,基于上述“Schema-on-Read”的理念推出的"Scan模式"的功能。
从SLS控制台的界面上,可以看到在“查询/分析”按钮右侧有“SQL增强”和“Scan扫描”两个按钮。举个实际场景的例子,比如日志格式是这样的,前半段的时间字段是固定的,而且所有的查询分析语句都会指定时间字段。后半段的Content字段则没有确定的结构。
因此,我们可以对于time字段建立索引,走基于索引模式的高性能查询分析,对于content字段则无须建立索引,走基于扫描模式的轻量查询分析。
和索引模式类似,scan模式下分为scan查询和scan分析。
scan查询的语法是这样的,竖线前面是索引过滤条件,竖线后面是一个where加上一个布尔表达式。比如where id=XXX做精确匹配,也可以用like进行模糊匹配,也可以用内置的各种标量函数去过滤。
scan分析,也就是在scan模式下使用SQL语句。scan分析的语法和索引模式完全相同,都是竖线后面写标准SQL,区别在于索引模式下SQL中只能分析预先开启索引的列,而扫描模式下,待分析的列不需要事先建立任何索引。
需要强调的是,scan分析支持完备的SQL语法,包括各种聚合和关联分析能力,以及内置函数。
Scan模式的优势,首先在于可以根据业务在分析上的实际需求,按需对部分字段建立索引,因此可以对成本做到比较好的控制。
同时对于无索引的字段,也能够有完备的标准SQL分析能力,从而可以灵活的满足这种长尾的分析需求,避免因为没有实现规划索引而造成的无法进行聚合关联分析的情况。
四、未来展望
以上是就是目前SLS在Schema-on-Read查询分析上的一些工作,至于未来的进一步探索方向,首先是通过向量化引擎进一步的提升计算过程的性能,其次在类型推断上可以考虑根据语法上下文做更智能的推断,最后就是在存储层面直接根据输入数据的规律自动去按列式格式组织。
这些探索将会在保证灵活性的同时,大幅提升Schema-on-Read分析模式的性能,感兴趣的同学可以保持关注,谢谢。