前言
JSON是一种轻量级的数据交换格式,格式简单清晰,易于理解且便于处理,因此也常常作为日志数据的一种内容格式。本文主要介绍下,如何基于SLS强大的查询分析能力,对JSON格式的日志数据进行分析和处理。
举个例子
为了能够说明清楚json日志数据的一些典型的查询分析场景,这里我们首先来构造一个实际系统的例子。
假设我们有一个订单处理系统,客户端程序将订单请求发送到API接入网关,接入网关进行一些鉴权操作后,将请求转发给实际的订单业务服务器,处理完成后,API接入网关会对每一条请求打印记录一条日志,并采集到SLS,供开发人员进行分析。
假设这里的API网关打印的日志是json格式,每一行日志的格式如下图所示:
其中request字段是订单请求信息,一次请求可以包含一个用户的多个订单,订单里包含购买的商品和支付的总价格,简单起见假设每个商品一次只能买一个。 response字段是订单处理结果,并假设由于历史原因,这个字段不总是一个json,成功的时候是"SUCCESS",失败的时候是一个包含code和message的json形式的字符串。
接下来我们就来看看,对于这样的一份系统日志,如何基于SLS进行采集和分析。
采集日志
对于Object类型的JSON日志数据,可以配置logtail的json格式进行快速采集。在logstore中添加logtail配置的时候,选择“JSON-文本日志”
进入到logtail配置页面,对应的模式就是“JSON模式”,这种模式下会自动提取JSON的首层的键值作为日志的字段,对于本文的日志示例,就会采集出request、response两个日志字段。
注意,如果是Array类型的JSON,或者整行日志并不是一个完全标准的JSON,那么就不适用于“JSON模式”采集,这种情况下也可以用“极简模式”或者“完整正则模式”先将对应的日志部分采集上来作为一整个字段,再结合本文后面会提到的JSON函数进行具体的分析。
设置索引
日志采集到SLS之后,要进行查询分析,首先必须要建立索引。
全文索引还是字段索引?
首先索引分为全文索引和字段索引,全文索引就是对整条日志都按照分词符拆分并建立索引,字段索引就是仅针对日志中的某个字段建立索引。具体操作可以参考如何配置索引。
建立哪一种索引呢?可以按照以下思路去决定:
(1)如果对日志中所有字段都有查询需求,那么可以建立全文索引;如果能够明确仅对部分字段有查询分析需求,那么也可以只对部分字段建立字段索引,减少一些索引费用。
(2)如果对某些字段有使用SQL进行分析的需求,那么就必须对该字段建立字段索引,并开启统计。
(3)同时建立全文索引和某些字段的索引,对于这部分字段只会以字段索引为准,不用担心会产生两份索引费用。
回到我们的例子,这里我们对示例中的request和response两个字段都有查询分析的需求,因此对这两个字段都要开启字段索引。
字段索引的类型?
字段索引的类型分为四种:text、long、double、json,设置什么类型的索引取决于对应字段的数据类型,具体操作可以参考字段索引类型说明。
回到我们的主题,对于JSON字段如何决定设置哪种索引呢?可以按照以下思路来看:
(1)如果一个字段本身并不是标准的json,只是内容可能包含json,那么就设置为text类型;如果是标准的json,就设置为json类型。(注意如果非完整的json但是部分合法,可以设置为json类型,SLS会尝试解析出其中合法的部分)
(2)设置为json类型后,如果对JSON对象中某个叶子节点有进一步的分析需求,可以针对叶子节点单独建立一个索引,这样会大大加速对这个叶子节点的查询分析,同时也会产生额外的索引费用。
(3)不能给JSON数组类型的字段建立索引,也不能给JSON对象中包含叶子节点的中间节点建立索引。
这里我们再结合前面的例子来看一下应该如何进行索引配置。假设我们的分析需求是这样的:
那么相应的,我们的建立索引的决策如下:
- request字段,需要建立为json类型的字段索引,并开启统计
- clientIp叶子节点,因为需要经常分析,所以单独建立为子索引,text类型,并开启统计
- http.path叶子节点,因为很少分析,不需要单独建立子索引。如果需要分析的时候,可以通过json函数解析
- param子节点,是中间节点,不能建立子索引,跳过
- userId叶子节点,因为要经常分析,所以单独建立为子索引,text类型,并开启统计
- orders子节点,是中间节点,而且因为是array类型,不能再建立子索引
- response字段,有分析需求,但因为不一定是JSON数据,因此建立为text类型索引,并开启统计
因此我们建立出来的字段索引如图所示:
建立了json类型索引之后,SLS上原始日志的展示也会自动按照json格式进行展开,查看起来非常方便。
设置索引别名
json叶子节点的索引由于包含的路径比较长,如果经常使用,可以设置一个索引别名,这样书写json会更方便。
需要注意的是,不同的字段之间,索引名或者索引别名之间是不能重复的,否则在SQL里就会产生歧义,不知道引用的是哪一列。
举个例子,我们如果对response字段索引设置一个别名叫request,在点击保存的时候就会报错,提示有重复。
对于json类型索引来说,在重复判定这里有一点特殊之处是,json的叶子节点的名称是按照全路径来判定的。
举个例子,如下图所示,假设想将response字段索引的别名设置为clientIp,是可以设置成功的。因为上面那个json叶子节点索引名称是request.clientIp,和response的别名clientIp是不算重复的。
查询分析——有索引的字段
对于有JSON对象叶子节点索引的字段,其查询分析用法和普通的日志字段相同,唯一要注意的就是在使用字段的时候,要使用从json对象根节点要叶子节点的全路径。此外在sql分析语句中使用时,需要加上双引号。
对于我们例子中的两个json叶子节点索引,在使用的时候要分别用request.clientIp
和request.param.userId
比如我们要查询用户186499都有哪些来源ip,就可以用这样的语句,注意其中字段名称的写法:
*and request.param.userId:186499|selectdistinct("request.clientIp")
查询分析——使用JSON函数
使用JSON函数,可以灵活的对JSON数据进行各种动态处理和分析,json相关的处理函数的详细说明可以参考JSON函数,这里我们结合示例日志,对使用json函数中的一些典型问题进行介绍。
什么情况下使用JSON函数?
首先,使用JSON函数是在执行SQL的时候,实时的对JSON数据进行解析,其性能受数据量大小和json结构复杂度的影响。
因此,如果数据量很大或者结构复杂,并且对查询性能有要求,同时json对象的结构相对比较固定,建议优先考虑对JSON叶子节点单独建立字段索引的方式。
而如果数据量比较小,或者出于成本考虑,则可以不建立json叶子节点索引,用json函数来处理。
另外,有些情况只能用json函数去处理:
(1)json对象包含的字段不确定,或者需要先进行一些预处理
比如示例中的response字段,只有在请求失败的时候是json结构,那么现在我们想分析其中的errcode字段的分布情况,就可以先用查询语句过滤出失败的情况,然后在SQL中用json函数动态的提取出相应的字段
*not response:SUCCESS |select json_extract_scalar(response,'$.errcode')
(2)对于json数组,或者json对象的中间节点,这些不能单独建立索引的,只能用json函数实时分析。如例子中的request.param或者request.param.orders部分
json_extract和json_extract_scalar用哪个?
一般来说,json函数最常用的场景就是从json中直接提取出某个字段进行分析。涉及到的函数有两个,json_extract 和 json_extract_scalar,这两个函数的函数名和入参都非常类似,经常有开发者会感到困扰,这两个函数有什么区别呢?
- 首先区别在于返回值的类型
json_extract返回的是一个json类型,json_extract_scalar返回的是一个varchar类型。
注意,这里的类型指的是SQL中的数据类型,如varchar、bigint、boolean、json、array、date等等,和前面说的索引的类型不是一个概念。
使用typeof函数,可以查看对应的SQL对象是什么样的类型。
- 其次区别在于能够解析的部分
json_extract可以解析json中任意一块子结构,json_extract_scalar只能解析标量类型(字符串、布尔或者整形值)的叶子节点,返回的是对应的字符串表示(varchar类型)
那么从使用角度有什么区别呢?我们再结合例子看一下。
假设我们现在要提取request字段中的clientIp字段,那么可以用json_extract_scalar函数 和 json_extract函数看上去都能提取出来,也就说,以下这两条语句都能执行成功:
#使用json_extract_scalar函数 *|select json_extract(request,'$.clientIp')#使用json_extract函数 *|select json_extract_scalar(request,'$.clientIp')
现在假设我们想进一步对clientIp进行来源分析,比如我们想对提取出clientIp的第一段,看看ip归属于哪个大的网段。这里我们先使用json_extract_scalar将clientIp提取出来,然后用split_part函数将clientIp按照"."分割并提取出第一段,然后进行分组求和并排序。
SQL语句如下,执行成功,符合预期。
*|select split_part(json_extract_scalar(request,'$.clientIp'),'.',1)as segment,count(1)as cnt groupby segment
但是如果我们把上面的SQL中的json_extract_scalar 换成 json_extract,执行的时候就会报错
从报错信息中也可以看出来,原因就是split_part是一个字符串处理函数,入参必须要是varchar类型,而json_extract返回的是一个json类型,因此参数类型不匹配导致报错。
画外音:我们在写SQL的过程中,可以先对子语句执行下typeof函数,来确认下当前的对象是什么类型。
一般来说,从json对象中提取字段进行分析,直接使用json_extract_scalar函数即可。原因是这样可以方便的结合其他SQL语句或函数进行进一步的分析。
使用json_extract_scalar要注意一点,就是其返回值始终是varchar类型,比如解析出的是一个数值,那在进一步进行求和之类的分析之前,要先用cast(XXX as bigint)转为数值类型
那么json_extract函数在什么场景下使用呢?
当我们需要对json结构本身进行一些分析处理的时候。
比如前面例子中的request字段中,要统计某个用户的在这次请求中的订单数目,也就是要查看orders子节点这个json数组的长度。
那我们就可以用json_extract函数,先提取出orders这个子节点,然后再用json_array_length函数统计长度。
*|select json_array_length((json_extract(request,'$.param.orders')))
但如果把上面的SQL里的json_extract换成json_extract_scalar,执行出来的结果就是null
原因就在于json_extract_scalar只能将json对象的叶子节点提取为标量类型,并不能正确的提取json对象的中间节点。
json_path应该怎么写?
使用json_extract等函数从json数据中提取字段的时候,需要指定json_path字段,用来表明需要提取json中的哪一部分。
- 首先json_path的基本格式是类似"$.a.b",$符号代表当前JSON对象根节点,然后通过"."号引用到要提取的节点,这个前面已经写过一些示例sql。
- 那如果json的key值本身是a.b的形式,或者包含一些其他的特殊字符呢?
这种情况下可以用中括号[]代替.号,中括号里的节点名称要用双引号括起来。
比如示例中request字段中有个“http.path”字段,为了提取这个字段进行分析,可以这样写:
*|select json_extract_scalar(request,'$["http.path"]')
如果是在sdk中写query,那么要对双引号进行转义
- 那如果是要获提取json数组中的某个元素呢?
这种情况下,可以用中括号[],中括号中用数字来表示数字下标,下标从0开始
比如示例中,我们要查看每个用户下的第一个订单的金额,就可以这样写:
*|select json_extract_scalar(request,'$.param.orders[0].payment')
如果要查看每个用户的第一个订单中购买的第二件商品,可以这样写:
*|select json_extract_scalar(request,'$.param.orders[0].commodity[1]')
json数组如何展开分析?
当日志字段中有json数组时,我们往往需要将json数组中的元素展开来,进行一些聚合统计分析。我们可以结合cast语句和unnest语句来实现。
使用unnest展开json数组
回到前面的示例,假设我们现在要求出所有成功请求的订单的金额之和,示意图如下:
首先用查询语句response字段过滤成功的请求,然后在SQL中提取出request字段中的orders节点,这个是一个json数组,注意这里提取出的是一个json对象,因此要用json_extract函数
*and response: SUCCESS |select json_extract(request,'$.param.orders')
这样查询出的每一行都是一个订单信息,是json类型
接下来我们使用cast语句,将这个json数组转换为sql里的数组,array(json)类型
*and response: SUCCESS |select cast(json_extract(request,'$.param.orders')as array(json))
执行结果的每一行是一个数组,数组的元素是单个的json对象
然后再用unnest语句将数组展开
*and response: SUCCESS |select orderinfo from log, unnest(cast(json_extract(request,'$.param.orders')as array(json)))as t(orderinfo)
执行结果的每一行是一个json对象
接下来就简单了,对每一行用json_extract_scalar提取出payment字段,这是一个varchar类型,再转换为bigint数值类型,然后再求和即可,最终的SQL语句如下:
*and response: SUCCESS |select sum(cast(json_extract_scalar(orderinfo,'$.payment')asbigint))from log, unnest(cast(json_extract(request,'$.param.orders')as array(json)))as t(orderinfo)
多重json数组嵌套
我们再看更复杂一些的情况,如果是JSON数组的元素对象中还有JSON数组,该怎么分析呢?
比如在示例中,要统计所有成功的请求中的,每一种商品被购买的数目
这个的解决思路,也是类似的,先将orders字段提取出来,转换成json(array),unnest展开,展开的结果每一行orderinfo是一个json对象,代表一个订单。对展开后的结果,json_extract提取出commodity数组,再转换成json(array),再unnest展开,这样展开出来的结果,每一行是一个商品。然后再进行分组求和统计即可。
中间步骤就不再赘述了,最终的完整SQL和执行结果如下:
*and response: SUCCESS |select item,count(1)as cnt from(select orderinfo from log, unnest(cast(json_extract(request,'$.param.orders')as array(json)))as t(orderinfo)), unnest(cast(json_extract(orderinfo,'$.commodity')as array(json)))as t(item)groupby item orderby cnt desc
结语
本文从一个实际系统的json日志出发,详细介绍了json日志数据的日志采集、索引选择、查询分析、JSON函数使用等方面的最佳实践,希望可以帮助开发者对基于SLS分析json日志数据有一个全面完整的认识。如果在使用上还有疑问,欢迎通过工单或者答疑群的方式进一步咨询。