在 DataFlux 中,我们会用了多个不同的存储引擎(目前主要是 InfluxDB 以及 ElasticSearch),这种混合存储的场景下,将查询语言统一起来,是非常有意义的:

  • DataFlux 是重查询产品,所有的可观测数据,都是通过查询来获取的
  • 在具体的可观测场景下,某个简单的图表,可能底层涉及多个不同的存储引擎查找,如果分别查找,会导致网络 IO 剧增,同时也殃及及页面响应
  • 在巨量观测数据面前,为了防止意外的巨量数据查找,需在查询语句上做保护
  • 不同的存储引擎,其查询语法全然不同,无形中给开发人员带来了额外的工作量
  • 如果接入其它存储引擎,前端开发又得学一遍查询语法,历史页面也需要大量改造

基于这样一个情况,提供统一的查询语言,迫在眉睫。

对于新设计一门语言,这种事情是工程师所喜闻乐见的。对于新语言的开发,命名首当其冲,对 DataFlux 而言,顺其自然,就是「DQL」。

DQL 要做哪些事情

在上面,我们提到统一查询语言的意义,从中我们也能看到 DQL 要做的一些事情,但只是泛泛而谈。这里,我们要大致列举下 DQL 的能力范围:

  • 所有 DataFlux 中的观测数据,都能通过 DQL 查找
  • DQL 查询的返回结构是一致的

不管后端是 InflxuDB 还是 ElasticSearch 还是其它即将引入的存储引擎,它们各自的查询结果返回,结构虽各不相同,但 DQL 需统一好给前端(这里的「前端」包括但不限于 浏览器/命令行等)

  • DQL 需支持参数注入

对浏览器端而言,某些查询条件是不便于直接写入 DQL 的,而 DQL 语句是通过类似 JSON API 发给后端,在这个 JSON 中,需提供各种不同的参数注入,以调整最终的 DQL 查询行为,如额外的分组(group by)参数,查询的时间范围等,因为这些参数,实际上都是可以在 UI 调整的,而表格里面的 DQL 是固定死的,不便于跟 UI 随动,只能通过查询注入

  • DQL 语法要相对简单且高度可扩展

对 DQL 而言,其主要职责是查询(不排除后面提供更新语法),跟 SQL 相比,它只需要提供 SELECT 即可,目前是没有 INSERT/DELETE/UPDATE 功能,从这个角度而言,DQL 就简化了不少。从另一个角度而言,因为底层的存储引擎可能会有多个,对查询功能而言,在语法上,不能对 DQL 做过多限制

  • DQL 针对不同的数据做查询限制

因为不同的数据(日志、时序、对象、APM等),其存储策略不同,查询策略也会有所差异,DQL 需分别对待

确定了这些,接下来我们确定一下语法选型。

语法选择

我们最为熟悉的查询语言莫过于 SQL,现如今已经成了行业标准,大家基本都能看懂 MySQL/PostgreSQL/SQLServer/Oracle 几家的 SQL 语句,大同小异,稍有不同。查询结构大致如下:

  1. SELECT column_name(s) -- 要查什么
  2. FROM table_name -- 从哪查
  3. WHERE condition -- 过滤条件是什么
  4. GROUP BY column_name(s) -- 结果怎么分组
  5. ORDER BY column_name(s); -- 结果怎么排序

先不论采用何种语法,这些基本要素,DQL 都必须满足。对于目前 DataFlux 使用的查询引擎,以 InflxuDB 为例,其基本查询结构为:

  1. SELECT <field_key>[,<field_key>,<tag_key>]
  2. FROM <measurement_name>[,<measurement_name>]
  3. WHERE <conditional_expression>
  4. GROUP BY [* | <tag_key>[,<tag_key]]
  5. ORDER BY time [desc|asc]

而 ElasticSearch 的查询则很庞大(因为它足够灵活),这里以一个简答的查询为例,在 InflxuDB 中查询一条最近的 CPU 数据,其查询大概如下:

  1. SELECT * FROM "cpu" WHERE "host" = '张三的电脑' ORDER BY "time" DESC LIMIT 1

同等的 ElasticSearch 的查询语句则大相庭径,对于这么复杂的查询,如果没有专门的 IDE,是很难写正确的:

  1. {
  2. "query": {
  3. "bool": {
  4. "must": [
  5. {
  6. "bool": {
  7. "should": [
  8. {
  9. "term": {
  10. "class": {
  11. "value": "cpu"
  12. }
  13. }
  14. }
  15. ]
  16. }
  17. },
  18. {
  19. "term": {
  20. "host": {
  21. "value": "张三的电脑"
  22. }
  23. }
  24. }
  25. }
  26. "size": 1,
  27. "sort": [
  28. {
  29. "last_update_time": {
  30. "missing": "_last",
  31. "order": "desc",
  32. "unmapped_type": "string"
  33. }
  34. }
  35. ]
  36. }
  37. }

综合俩种查询风格,我们可以看到:

  • InflxuDB 的查询风格,跟 SQL 基本一致,这也很容易理解,毕竟大家都很熟悉,写起来差不多
  • ElasticSearch 的查询很臃肿,但极为灵活,对 ElasticSearch 本身的特性而言,这是正确的设计。虽然 ElasticSearch 也支持 SQL 形式的查询,但其功能(相对)没有 JSON 格式强大

对 DQL 而言,这两种风格,似乎都不太合适:

  • 类 SQL 的语法肯定能满足查询需求,但其关键字太多(SELECT/FROM),写起来繁琐,另外容易让人联想到 insert/update 等语法,而这些在 DQL 设计之初就决定不予支持
  • JSON 语法没有必要,DQL 没有这么灵活的查询需求(主要还是太难写了)

为此,我们看了下其它的查询语言,比如 PromQL:

  1. http_requests_total{job="apiserver", handler="/api/comments"}

这里的查询语义为:查询指标 http_requests_total,以 job="apiserver" AND handler="/api/comments" 为过滤条件。注意,这里省去了 SELECT/FROM 这样的语法,直接通过出现的位置来「暗示」其语义,翻译成 SQL 就是:

  1. SELECT * FROM http_requests_total WHERE `job`="apiserver" AND `handler`="/api/comments";

在我们看来,PromQL 的语法,正是 DQL 喜欢的味道,它们要做的事情,其实异曲同工:只专注查询。

确定了语法选型,接下来的事情,就是如何实现这些语法了。语法的实现,有几种常见的思路:

  • 直接裸解析,暴力如 TCL 这种 C 编译器,就是这种。当然 InflxuDB 的查询语言处理,也是手写的
  • 通过专门的语法生成工具,如 ANTLR 或者 yacc/lex(bison/flex)

我们看了下 PromQL 的实现,决定采用 yacc,相比 ANTLR:

  • Golang 中内置了 yacc 实现(PromQL 就是用 golang 实现的),跟我们的技术栈契合很好
  • yacc 的性能(内存消耗)相对更好(之前我们通过 ALTLR 做过 InfluxQL 的翻译,性能不太理想)
  • 最主要的是,我们的工程师相对更熟悉 yacc

DQL 为什么是现在这个样子

最终,DQL 的语法结构大概如下:

  1. namespace::data-source:(target-clause)
  2. {where-clause}
  3. [time-expr]
  4. by-clause
  5. order-by-clause
  6. limit-clause

从基本的语法结构中,可以看出,我们倾向于采用一些特殊符号来「暗示」高频语义,而非用确定的单词(如 SELECT/FROM 等),因为它们极为常用,简化其输入是我们优先考虑的。但对于相对低频的语义,我们还是选用了英文单词,但还是一个原则:减少输入,如将 GROUP BY,简化成了 BY,但 ORDER BY 我们保持原样,因为它相对不常用。

各个语法结构说明如下:

  • namespace: 查询的数据类型,类似于 MySQL 中的一个数据库

这里我们借鉴了 C++ 中 namespace 语法,如 std::string str1 = "hello",对DQL 而言,就是形如 object::HOSTmetric::cpulogging::nginx,看起来语义很契合。

在 DataFlux 中,截止目前,已经有如下几种数据类型,故需要在语法层面,对查询的数据做命名空间划分,如

  1. 时序(metirc/M
  2. 对象(object/O
  3. 日志(logging/L
  4. 事件(event/E
  5. 安全(security/S
  6. RUM(rum/R
  7. APM(tracing/T
  8. 自定义对象(custom_object/CO

为便于输入,DQL 对各个命名空间,都做了别名。对于最常用的时序数据(M),甚至可以略去别名,默认就是 M 这个命名空间,进一步简化了 DQL 的编写。

  • data-source:基本查询范围,类似于数据库表

以对象为例,这里填写的是对象分类名(class),以时序为例,这里填写的是指标集名称,以日志为例,这里填写的是来源(source),以此类推

  • target-clause:查询的字段列表,类似于表字段

如查询 CPU 指标集的两个字段:M::cpu:(usage_guest, usage_idle) LIMIT 1,表示在时序命名空间(M)中查找指标集为 cpu 的两个指标(usage_guest, usage_idle),且只查询一条。

  • where-clause:以 {} 来表示过滤条件

如查询主机 CPU 空闲率大于 90% 的机器:

  1. cpu:(host) { usage_idle>90 }

注意,这里的过滤条件可以有多个,按照列表语义来处理,以 , 分割,它们之间是 AND 的关系:

  1. # 如下三个语义是等价的
  2. cpu:(host) { conditon1, condition2 }
  3. cpu:(host) { conditon1 AND condition2 }
  4. cpu:(host) { conditon1 && condition2 }

既然有 AND 关系,那就有 OR 关系:

  1. # 如下俩个语义是等价的
  2. cpu:(host) { conditon1 || condition2 }
  3. cpu:(host) { conditon1 OR condition2 }

还可以用括号表示条件之间的各种组合:

  1. cpu:(host) { conditon0 AND (conditon1 || condition2) }
  • time-expr:时间过滤条件,其表达形式为 [start:end:by-interval]

初步看来,这里似乎有一点冗余,比如 time-expr 本质上是一个 where-clauseby-clause 的合体,即既指定查询的时间范围,又指定时间范围的分组。之所以将这个语法单独拧出来,主要还是因为,在 DataFlux 的查询中,基于时间的查找以及分组,使用频率极高,几乎所有的查询都有涉及,为了将它们从 where-clauseby-clause 中「解放」出来,就单独设计了这个语法单元,我们直接可以在这里实现时间范围过滤以及分组,属于一种「快捷方式」。

另外,这里的 startend 支持多种时间类型的表示,如:

  1. [10h:5m:1m] 表示 10 小时以前至 5 分钟以前的时间范围,将查询到的数据,按照 1 分钟的间隔进行分组。在终端手编写 DQL 时,这样指定时间范围非常方便
  2. [1626401634:1626402634:1m] 表示两个 UNIX 时间戳时间范围,也是按照 1 分钟的间隔分组。这样做更便于大多数编程语言的处理
  3. [2019-01-01 12:13:14:5m:1w:1d] 表示自 2019-01-01 12:13:14 至一周(1w)前的时间范围,将查询到的数据,按照一天(1d)的间隔分组。这里支持日期格式,主要便于通过 Web 前端的时间控件来指定时间
  • by-clause:分组语法(同 SQL 中的 GROUP BY
  • order-by-clause:排序语法
  • limit-clause:限制返回数量

DQL 如何解决一些具体问题

  • DQL 如何控制查询的数据安全?

DataFlux 是一个准 SAAS 平台,简而言之,就是多租户平台。在这种情况下,不能因为某个意外的查询,影响其他租户的数据体验。基于此,需要对不同的租户,有独立的查询空间:

  1. 每个独立的工作空间,底层的存储是逻辑隔离的,可以简单理解为,不同租户的数据,是存储在不同的数据库上。而单个 DQL 查询,只能在单个「数据库」上执行查找,不存在「串库」的情况
  2. 由于观测数据量极大,极有可能是在指定数据查询的时间范围时,手抖了一下,造成底层存储的巨量 IO 查询,进而影响整个集群的租户。为此,在 DQL 的 HTTP 查询接口上支持时间范围的指定(默认 15 分钟),即使没有指定,DQL 本身也会检查查询的时间范围是否超过系统的设定,这在很大程度上保护的底层的存储系统
  • DQL 是如何辅助 DataFlux 前端开发的?

前面提到,DQL 的 HTTP 接口是支持注入的,为便于前端实现各种复杂的数据观测场景,DQL 额外支持如下这些查询参数注入:

  1. 返回的最大点数控制:在一些密集绘图的前端页面上,巨量的数据返回,可能导致前端页面卡死甚至奔溃。有了最大点数控制,就能杜绝这种情况
  2. 过滤条件注入:这个跟时间范围的注入类似,主要应用在数据权限控制上
  3. 排序字段注入:在一些特定的数据页面上,需要对返回的数据,按照指定的字段来排序,比如,返回的主机列表上,虽然默认按照主机名来排序(这个默认的 DQL 写好了),单用户可以选择按照 CPU 或内存使用率来排序,此时就可以在 HTTP 请求参数上额外指定排序字段,覆盖默认 DQL 上的 ORDER BY 字段
  4. 禁止多字段返回:在一些 UI 效果上,多列返回是无法绘图的,但为了避免 DQL 真的返回了多列数据,可以对应的 UI 效果上,通过 HTTP 接口禁用多列查询,这样依赖, DQL 解析阶段就能检测到错误,非常便于日常的开发以及调试
  5. 额外的其它一些注入,主要也是便于实现数据展示效果,比如深度分页、查询的高亮显示等等
  • 「即时」的数据查询效果

DataFlux 中的数据,大部分都是通过 DataKit 上传的,为此,我们在 DataKit 中内置的 DQL 查询终端。数据采集完后,稍后片刻(考虑到多级缓存、网络传输延迟等因素)即可通过 DQL 查询到刚刚上传的数据,而不用打开 DataFlux 来查看数据。另外,某些情况下,特别是在开发阶段,DataFlux 前端可通过这个命令行终端,来排查一些数据问题

  • 灵活的数据处理

主要体现在如下方面:

  1. 方便不同的数据接入:如果有新的数据分类需要接入,扩充一个 namespace 即可。如果有新的存储引擎接入,只需要增加一套对应存储引擎的查询翻译即可
  2. 可以对查询到的数据,进行灵活的额外处理。假定 InfluxDB 不支持某个数学处理函数,DQL 查询到数据后,可通过 Golang/Python 等,自定义实现即可。另外,还能跨服务实现数据的多级计算,比如将 DQL 查询到的数据,送给 Function 处理

目前,DataFlux 中绝大多数的数据查询,都是通过 DQL 来实现的,历经近一年的开发迭代,DQL 日趋稳定,功能也日渐强大。随着 DataFlux 业务的不断发展,DQL 也将面临着更大的挑战。