目录

SQL,你就不能短一点吗?

先讲个的故 (shi) 事 (gu)

我们是公司内的一个 Software Development Team ,主要开发和维护一个 AB 实验平台,提供给公司内的数据分析师和业务团队使用。为了帮助我们的用户能快速获得 AB 实验相关的数据,我们通过系统实现了实验数据的自动化计算。其中,我们的系统会自动生成实验和指标的数据查询 SQL 语句,再借助数据仓库完成数据计算。在这套系统的设计和实现中,团队花了不少心思。

不过,前段时间一个偶然的 bug 让人大跌眼镜。一天,突然有一个实验,没法生成 SQL 语句了!一生成就报错!按理说这个系统已经稳定运转一段时间了,不应该出现这种低级问题。而且即便是数据指标的配置有问题,也应该是在 SQL 语句的语法校验环节不通过呀,怎么刚开始就结束了?

最后经过研发一通翻日志才发现,是 SQL 语句的长度超过系统数据库的字段限制了: Data is too long for column ... 。虽然通过改库搞定了,但是我心中又想起了之前的那个执念:

SQL,你就不能短一点吗?!

我在这家公司已经做了 6 年数据产品经理,SQL 可一直是个绕不开的东西。不管是自己写,还是设计数据分析相关的功能,亦或是与分析师和研发一起查错,每次一深用 SQL 这个东西,就有说不出的别扭。平时工作比较忙,年前腾出点时间,把之前放下的这个话题又想起来了。

仔细想,现在常用的 SQL 其实不光是长度,总共有以下几个让人不爽的地方:

第一

语法不算难,但是很麻烦。说的专业一点,就是语法中的关键字太多了。直接带来的问题就是,稍微复杂一点的逻辑,用 SQL 写出来巨长无比。身边就有同事感叹:没深入接触数据的东西之前,怎么也没想到随随便便就能搞出个几百行的 SQL 。

这种 “量大管饱” 的特性对于使用者来说是一种折磨:人来写,难;机器来写,万一出了错,人来查,更难。(此处分析师和研发泪目)

第二

方言之间,语法大体上差不多,但是又有很多细节差异很难搞。

比如现在的公司里,数据分析常用的两种 SQL 方言就是 Hive 和 Presto 。(虽然也有 Clickhouse、ES 等引擎,但在数据仓库和数据看板的工具中主要还是这两个引擎。)这两个其实语法大部分是差不多的,大多数函数也通用。即便像 get_json_object (Hive)和 json_extract_scalar(Presto)这种很明确的替代关系,也可以通过批量替换直接搞定。

但是像 "..."'...' 这种符号的处理就很让人头疼,很容易犯错。有的时候用得多了,一个一个替换都需要花不少时间。虽然也可以用 RegExp 替换掉,但 RegExp 本身的门槛就很高了。最近 “全家桶” 新加入了 Clickhouse ,只是还没太多使用经验,不知道有没有类似的问题。

第三

不光脚本长、改来改去,还难复用。除非是通过 CTE 或者 建表 这样的方式。

“复用”可能是工程上要考虑的重要问题之一了。不过从 SQL 语法来看,各个子句(statement)之间永远是完整的“一坨”,完全拆不开。这是不是你写 SQL 的日常:

什么?你要改 SELECT ?麻烦连 GROUP BY 一起改了呗?不然我很难做。什么?哪个字段我不认识 ?哦,这个在 FROM 的子查询那张表里呀,你得把它 SELECT 出来。然后你记得把子查询的 GROUP BY 也改一下哈。什么?错误看不懂?你不就改了…哦!逗号!逗号!子查询里的 GROUP BY 的逗号!什么?你还要加字段吗?那麻烦连 GROUP BY 一起改了呗 …

整个过程在编辑器里上蹿下跳,一改就改出来几十行。不知道其他人有没有用 Git 管理自己 SQL 的习惯,反正要是没有,而且改错了还想退回去几步,就是 Mission Impossible ,不如全删重写。

要写新 SQL 的时候,除非是把之前的脚本落表了,不然几乎不想再去看它。整个过程可能只有写下 SELECT 的那一刻,内心是纯洁的。之后但凡多写一个字,都要考虑好后面的“终局”。就像新学一门程序语言然后写了个 Hello World ,真正的挑战才刚刚开始。

“我没有别的方法,我就有爱”

所以,真的没有什么技术方案可以替代 SQL 的地位吗?

(哦不,嗑,我是产品经理)还有什么方案能满足用户对数据查询语言的 需求 吗?

反思一下日常工作中的数据查询、数据建模等工作,这 需求 主要就以下几方面:

第一

它的语法需要尽量简短一些。那些“占位符”作用的关键字,能少就尽量少一点。比如像 SELECT * ,即便是要查询所有数据, SELECT* 也都不能省略,就很尴尬。

而且这是可行的,因为即便是要表达相同的含义,语言之间也是有表达效率之分的。比如 弄啥哩What are you doing there? 的区别是如此明显。

第二

它要有比较好的概念抽象和模块化能力,能做到好复用、好维护。SQL 的语法对复用这件事真的非常不友好。曾经一时兴起,在搜索引擎中追究一个问题:既然 SQL 大体的执行顺序是

  1. FROM
  2. JOIN … ON …
  3. WHERE
  4. GROUP BY
  5. WITH
  6. HAVING
  7. SELECT
  8. ORDER BY

为什么写的顺序就不能按执行的顺序来呢?不知道你有没有对这个问题产生过困惑?

考虑到设计 SQL 当时的 IT 技术普及度,在得到的答案中,我认为比较靠谱的答案是:SQL 的语法设计,是为了更接近英语的表达。(另一种说法是说 DBA 在搞事情,直接 PASS …)所以你看,当时设计的时候就不是奔着工程效率去的,而是为了更容易理解。

第三

需要它支持的引擎足够广泛。这可能是大家对 SQL 产生了“习得性无助”的重要原因。SQL 作为一个非常非常非常…老的语言,各种引擎对它的支持实在是太好了。学会并使用它,几乎可以让工作顺畅地过度到任何常用的数据库引擎。当然,学习新的语法细节还是必要的。

其实市面上在这方面已经有很多其他的方案。比如 MATLAB、R、Python、Scala、GraphQL 等等。但是截止发文,目前了解到的是他们各有各的欠缺。MATLAB 和 R 只能用于各自的执行引擎,Python 和 Scala 又太“软件开发语言”了,而 GraphQL 貌似更多应用于 API 开发而在分析查询方面没什么成功案例。

还比如 EdgeDB 和它的 EdgeQL 。在我兴致勃勃地发现 EdgeQL 的语法好像很适合并开始看它的案例时,发现它只能用在 EdgeDB 上。

当然,在“能支持”的背后,还有一个更隐蔽的问题是“支持得好”,也就是能“在理解的基础上执行”。因为在实践中我发现,有一些我以为优化器会做的事情,实际中似乎并没有发生。比如,是否会通过 AST 还是其他什么方式,提前判断是否可以把子查询以外的 WHERE 复制一份放到子查询里,以便提前过滤无效数据。

还有,比如,为了实现 SQL 语句模块化,我们的计算脚本中大量使用了 CTE (Common Table Expression,就是 WITH ... AS ... 那套)。但是在技术实现上,这样写好像是把中间结果放在内存里了。这可是个大问题呀。本来只是想从语法上做一下划分,结果中间数据被实际存下来了,这样就很难应对更大的数据量了。

有什么解决思路吗?

在上面的三点需求中,可能更难实现的是第三点。因为技术方案推广这件事,已经远远超出了技术本身。

但是,这其实有“曲线救国”的办法: 编译 (Complie)。这个概念在非技术领域可能用得实在不多,我换一个:翻译 。就是在不改变语义的情况下,把一种语言的表达转换成另一种语言。

既然 SQL 应用的这么广泛,但它自身的语法又不够友好,那么只要有另一种能满足需求的语言,写完之后再编译成 SQL 拿去执行,不就好了?(叫 SQL 的“超集”应该不合适,对吧)

经过一番搜索,还真找到一种:PRQL (官网:https://prql-lang.org/ )。这几个字母是 Pipelined Relational Query Language 的缩写,直译就是“管道的、关系的查询语言”。官网还贴心的给出了:pronounced “Prequel” 。

这还是个很新的语言,官网也不过 0.11 版本,都没到 1.0 。它的语法有点像 GraphQL (不要喷,只是有点)。而它最吸引我的是:

可以编译成 SQL !可以编译成 SQL !可以编译成 SQL !!!

而且看官网的 Playground ,这个编译几乎是实时的!很快!

比如官网给出的这个案例,编译完的 SQL 是这样的:

复杂吧?一看就不省心吧?

WITH table_1 AS (
  SELECT
    customer_id,
    total - 0.8 AS _expr_0,
    invoice_date
  FROM
    invoices
),
table_0 AS (
  SELECT
    COALESCE(SUM(_expr_0), 0) AS sum_income,
    COUNT(*) AS ct,
    customer_id
  FROM
    table_1
  WHERE
    _expr_0 > 5
    AND invoice_date >= DATE '2010-01-16'
  GROUP BY
    customer_id
)
SELECT
  c.customer_id,
  CONCAT(c.last_name, ', ', c.first_name) AS name,
  table_0.sum_income,
  table_0.ct,
  version() AS db_version
FROM
  table_0
  JOIN customers AS c ON table_0.customer_id = c.customer_id
ORDER BY
  table_0.sum_income DESC
LIMIT
  10

而原始的 PRQL 是这样的:

from invoices
filter invoice_date >= @1970-01-16
derive {
  transaction_fees = 0.8,
  income = total - transaction_fees
}
filter income > 1
group customer_id (
  aggregate {
    average total,
    sum_income = sum income,
    ct = count total,
  }
)
sort {-sum_income}
take 10
join c=customers (==customer_id)
derive name = f"{c.last_name}, {c.first_name}"
select {
  c.customer_id, name, sum_income
}
derive db_version = s"version()"

不仅关键字更少,而且那些 table_0table_1 什么的都是自动加上的!很 智能 有木有!

根据目前了解来看,相比前面说的那几种语言,PRQL 有一些突出的优势:

  1. 虽然加入的更多减号、大括号什么的符号,但是跟 Python 和 Scala 这样的语言还是有明显区别的,基本保持在一个命令式的程度,没有真的变成那种“开发语言”;
  2. 从案例上看,语法上各个子句之间还是多少有点关联的,不过已经加入了 F-StringS-String 这样类似 元编程 的能力,表达效率和扩展性上应该更好;
  3. 由于它可以编译成 SQL (能覆盖哪些具体语法还没测试),那么理论上能支持 SQL 的场景都可以用 PRQL 先简单写下逻辑,再编译成可执行的 SQL ,而不需要想 Python 那样,需要执行环境支持对应的包 (最近我们就遇到过一次);
  4. SELECT * FROM t_table; 用 PRQL 写出来只剩 from t_tableNICE ~

所以,后面可以考虑对一些比较复杂的数据逻辑,尝试使用 PRQL 编写了,看能不能提高 SQL 编写这块工作的效率。

最后

虽然有点贪心,不过很期待官网出一些逆向工程的能力,把现在的大坨 SQL 反编译回去。咱就是说,可以提需求吗亲 ~