SQL 语言于 1974 年作为 IBM System R 数据库的一部分首次出现。现在 50 多年过去了,SQL 已经成为操作大多数工业级数据库的语言,主要应用在应用程序编程和数据分析于两个领域。
1984 年 11 月,数学家和计算机科学家 CJ Date 在 ACM SIGMOD Record 上首次发表了《SQL 数据库语言批判》一文。自从这篇评论发表以来,SQL 标准已经有了许多重大更新,那么有哪些问题从当初开始批评至今仍然存在呢?
SQL 语言于 1974 年作为 IBM System R 数据库的一部分首次出现。如今 50 多年过去了,实际上 SQL 已经成为操作大多数工业级数据库的语言,被主要应用在应用程序编程和数据分析两大领域。
在我作为数据工程师和数据科学家的 12 年职业生涯中,大部分时间都在关注数据分析,而 SQL 是目前为止我用得最多的语言。我喜欢 SQL,因为它给我带来了生产力,但随着时间的推移,我也意识到了它的许多缺陷和特性。
站在实践者的角度,我一直很好奇这些“现实世界”的问题是否有更根本或理论性的基础。这让我想到了数学家和计算机科学家 CJ Date 写的《SQL数据库语言批判》。Date 是前 IBM 员工,著名的数据库研究员,EF Codd 的朋友。自从这篇评论发表以来,SQL 标准已经得到许多重大的更新,但哪些问题是至今仍然存在的呢?
《SQL 数据库语言批判》在 1984 年 11 月首次发表在 ACM SIGMOD Record。本文将研究由几个 IBM 系统(SQL/DS、DB2 和 QMF)实现的 SQL 方言,这些系统为最初的 SQL 标准提供了基础。如果没有使用这些系统的直接经验,阅读本文中的 SQL 示例就有点像阅读 17 世纪的英语——它有一种生硬的、略微陌生的韵律,需要多花些力气才能理解。在下面的例子中,我将使用 SQL[1983]和 SQL[2022]的字眼来区分较早的方言和今天可用的方言。使用未加限定的“SQL”意味着我的评论对于两者是通用的。
本文由八个部分组成,每个部分都描述了不同类别的批评:表达式缺乏正交性、函数缺乏正交性、杂项缺乏正交性、形式定义、与宿主语言不匹配、函数缺失、错误和缺失关系型模型方面。在接下来的文章中,我将逐一阐述这些部分,不那么正式地谈谈这些批评,并解释一下我觉得这些批评指出的问题现在是否仍还存在。
缺乏正交性:表达式
编程语言的正交性大致意味着该语言的构造就像乐高积木——少量的基本块可以以简单直观的方式重新组合。缺乏正交性(再次声明,是随口聊聊哈)意味着该语言在如何将组件组合在一起的方式上有很多特殊情况和异常,这使得学习起来很复杂,使用起来不直观。
本节首先定义表表达式、列表达式、行表达式和标量表达式,它们分别是 SQL 中返回表、列、行和标量值的表达式。在 SQL[1983]中,SELECT 语句的 FROM 子句被限制为只能指定表或视图的名称,而不是通用的表表达式,即子查询或公共表表达式(CTE)。这使得几乎不可能构建嵌套表达式(关系代数的关键特性之一)。现代 SQL 提供了在 FROM 子句中引用 CTE 或子查询的能力,所以这个问题在今天来看基本上已不存在了。然而,表表达式在某些上下文中可以采用“表名”的形式,但在其他上下文中必须是 SELECT * FROM tablename
形式,不知道是怎么想的,真是有趣。
例如,为什么不允许下面的表达作为合法声明:
它和下面这句将返回相同的结果:
两者都是表表达式(返回表的语句),因此应该允许在任何接受表表达式的地方使用,例如 SELECT 语句的 FROM 子句或语句本身。
虽然 SQL[1983]中的 SELECT 语句不允许出现在 FROM 子句中,但是 EXISTS 子句需要它们作为参数。此外,这里的 SELECT 语句必须是一个列表达式(只选择单个列),不用使用返回表、行或标量的语句。什么时候 SELECT 语句是表表达式、列表达式、行表达式或标量表达式?语言本身没有提供任何指导,这完全依赖于查询本身。例如:
是一个列表达式,但是
是一个表表达式。这种随意性在 SQL[2022]中仍然存在。
缺乏正交性:函数
虽然通过引入子查询和 CTE,本节中的一些问题得到了缓解,但现在仍然存在许多问题。
SQL 中的列函数以一列标量作为输入,返回一列标量值(如 MD5 函数或类型转换函数),或单个标量(如 SUM 等聚合函数)。评论作者在这里提出,由于列函数以一列标量值作为输入,所以应该允许任何有效的列表达式。下面是一个不符合这种情况的例子:
是可以的,但是
就不行,即使“SELECT val FROM tbl”是一个有效的列表达式-它从表 tbl 返回单个列 val。
这里的关键问题是,第一个示例中的 SUM 函数的输入是一个列名,但是仅有列名并不能定义列表达式。相反,我们必须查看上下文(即完整查询)来理解“val”列来自“tbl”。换句话说,在 SQL 中,F(X)不仅依赖于 X,还依赖于 F 周围的上下文信息:
和
这两个查询大相径庭,尽管列函数调用 SUM(amount)
是相同的。
这也使得嵌套聚合变得困难。考虑下面的例子:我们有一个电子商务网站的采购数据库,并希望检索(1)每个客户的总消费金额,和(2)所有客户的平均消费。SQL[1983]不能在单个语句中解决这个问题。SQL[2022]可以通过使用 CTE 解决这个问题:
然而,以下(可以说它更自然)语句是不允许的:
在上面的查询中,内部的 SELECT 是一个列表达式(SELECT 语句返回单个列),AVG 是一个接受单个列的函数。但是,上面的语句在大多数数据库中不能用。在 Snowflake 中,上面的查询返回错误消息“单行子查询返回不止一行”,这让我感到困惑,因为显然 AVG 函数期望输入不止一行。
另一个有趣的推论是 HAVING 子句的必要性。HAVING 子句是 SQL 面试官最喜欢的“小手段”。
它与 WHERE 子句有何不同?对于第一次接触 SQL 的人来说,答案并非显而易见。像这样的专门知识当然可以作为是否有相关经验的指示器,但它也很容易被视为 SQL 语言的缺陷。
HAVING 子句为列函数提供了作用域提示,表示函数输入必须使用 GROUP BY 子句。评论作者在这里直言不讳:“SQL 中只需要 HAVING 子句和 GROUP BY 子句作为列函数参数范围规则的结果。”
评论作者还描述了表函数(以表而不仅仅是列作为输入的函数),并对随意和非正交语法的几个实例表示遗憾。首先,EXISTS 函数(接受一个表表达式,返回一个标量)只能在 WHERE 子句中使用,而正交性规定在语言接受标量的任何地方都应该允许使用它。其次,UNION 函数由一个固定操作符表示,由于 SQL[1983]不允许在 FROM 子句中使用任意表表达式,因此不可能在两个表的 UNION 上计算列函数。这个问题在 SQL[2022]中解决了,因为下面的语法现在是合法的:
缺乏正交性:杂项
本节包含了与底层系统的功能和实现细节相关的内容——主机/指示器变量、游标、“long”字段(例如,长度大于 254 的字符字段)。有些限制确实非常令人不解(在 WHERE 或 GROUP BY 子句中不能引用“长”字段!),但是现代数据库系统不再受这些限制。本节中的其他事项已经因为更新 SQL 标准解决了。以下限制已经不存在,排名不分先后:
GROUP BY 中只允许简单表达式(列名);
NULL 字面值不能用于需要标量常量的地方;
没有 UNION ALL 的概念;
只能在一个级别上使用 GROUP BY 构造进行聚合。
虽然这里的大部分讨论已经无关紧要了,但是今天关于 NULL 值的讨论仍然和以前一样令人担心。NULL 处理中的不一致性会导致一些完全意想不到的可怕结果,尤其是在聚合函数中。聚合函数忽略 NULL 值,导致一个不幸的事实:对于值为 x1, x2,…,xn 的列 X,
在 Postgres 中
输出
形式定义、与宿主语言不匹配、缺少函数
这三个部分放在一起,因为我发现它们与现代数据库、现代 SQL 或分析查询处理没有特别的关系。
形式定义。本节重点介绍了开发中的 SQL[1983]标准与 IBM 实现不一致或不够精确的地方——游标定位、锁语句、别名作用域规则等等。我认为这一部分更多的是对标准的批判,而不是语言本身。此外,其中许多问题(游标、锁)与分析处理无关,因此我个人对此不是很感兴趣。
与宿主语言不匹配。与前一节类似,我发现这一点基本上无关紧要。作者指出了 SQL 和宿主语言(如 IBM PL/I)之间的许多差异,这些差异会给程序员带来麻烦。今天,有很多潜在的宿主语言(Python、Ruby、Javascript,只是举几个例子),每个都有自己的特点,SQL 不可能符合所有这些特点。像LINQ这样的技术旨在解决其中的一些问题,但与上面一样,这些主要针对的是应用程序编程用例。
缺少函数。这里提到的函数主要都与游标和锁定有关,我认为它们是与底层系统相关的特定于实现的细节。
错误
本节描述了评论作者认为单纯是语言设计错误的几个方面。这里,NULL 是最好的例子:
在我看来,null 这个概念带来的麻烦远远超过它的价值……当查询中涉及的数据本身不精确时,系统永远不应该对查询产生(虚假的)精确的答案。至少系统应该为用户提供显式的选项,可以忽略空值,或者只要它们存在就视为异常
有趣的是,这与大家的共识相距甚远,甚至在关系模型的最初开发人员中也是如此。EF Codd 自己在他的12条规则中接受了 NULL(第三条)的用法。
其他“错误”包括:
主键是索引的一部分,而不是在创建表时指定的。这里的理由是,主键实际上是表的一个逻辑属性,不应该与主要处理该数据的物理访问路径的索引混为一谈。今天,大多数数据库都允许 CREATE TABLE 语句包含一个主键,所以这个问题已经在很大程度上得到了纠正。
SELECT *对于交互式查询无疑很方便,但在程序中使用时却极易出错。
Date 认为 SELECT * 应该只允许在交互式会话中使用。我在很大程度上同意这种观点,但定义“互动会话”可不是个随便说说就能确定的问题。
不支持关系模型的方面
本节是另一个杂项列表,事实上每一项都阻止了 SQL[1983]真正的成为“关系型”。
主键和外键:主键很容易被 SQL[1983]忽略,而外键甚至不存在。虽然 SQL[2022]允许外键,而且许多数据库强制引用完整性,但 SQL[2022]仍然没有完全理解主键和外键的语义。两个例子:
当在表的主键上执行 GROUP BY 时,并且包括该表中的其他列,因为主键保证惟一,所以保证那些其他列也将是惟一的;但是,SQL 要求这些列也要包含在 GROUP BY 中。
外键与其对应的主键之间的连接很容易是隐式的,但是 SQL 仍然要求显式地写出连接条件。
域:域是“类型”的另一个说法。SQL[1983]中的类型系统只允许原始类型(int、char、float 等)。如今,Postgres 提供了对任意复杂度的用户定义类型的支持,以及允许用户将原语类型限制为可接受值的检查约束。不幸的是,大多数 OLAP 数据仓库不支持用户定义的类型,SQL 本身对这个主题没有太多发言权。
举一个简单的例子来说明这有多么危险,许多数据库都有带有整型主键 ID 列的表。显然,不是所有对整数合法的操作都应该允许在主键列上——两个 PK id 的相加、相乘或相除意味着什么?SQL 和大多数数据库都很乐意让你执行这些操作。
关于型赋值:此处的批评就简单一句话:
一种受限形式的关系型赋值是通过 INSERT…SELECT,但是该操作不会覆盖目标表之前的内容,并且赋值的源不能是任意的代数表达式(或等效的 SELECT)。
现在情况已经不同了。关系型赋值可以通过 CREATE 或 REPLACE TABLE AS 来完成。通过子查询和 CTE,源可以是任何代数表达式。
SQL[1983]不支持显式 JOIN、INTERSECT 和 DIFFERENCE 这些操作符,SQL[2022]已经支持了。SQL92 标准中添加了 JOIN。大多数数据库都支持 INTERSECT 和 MINUS,即使它们不支持,也可以使用 JOIN 实现语义上相同的操作符。
总结
虽然对 SQL 提出的批评有许多已经通过 ANSI 标准的更新得到了解决,但还有许多仍未解决。许多地方仍然缺乏正交性,这使得 SQL 学习和使用起来很是笨拙。但是,从会写 SQL 的人的数量来看,我怀疑它的学习曲线并不是那么高。相反,缺少关系型模型的组件和 NULL 值引起的问题可能是许多查询看起来正确但提供错误答案的原因,特别是对于那些对自己编写查询语句的能力很有信心,但不熟悉那些比较隐蔽陷阱的人来说。
除了上面列出的改进,在2014年的一次采访中,CJ Date 说:“我们没有意识到 SQL 是或将会是多么糟糕。(注意,它现在比当时糟糕得多,尽管它从一开始就很糟糕)。”这句话让我想知道,如果 Date 本人要写一篇更新的评论,它会是什么样子的?我猜他的大部分批评都是围绕着 SQL 进一步偏离关系型模型展开的,但是我想不出具体的例子。
SQL 在市场上的主导地位意味着每个 DBMS 供应商都有强烈的动机来实现 SQL 接口,每个有抱负的程序员都必须学习它。那么,这是否意味着尽管存在各种问题,我们还是要永远使用 SQL 呢?我认为,SQL 将以某种形式继续存在很长一段时间,甚至可能成为主导性的查询语言。但是,我坚信吸取了过去经验教训的新查询语言仍然有发展的空间。
此外,我认为现在是这种语言取得成功的最佳时机。为什么我会这样认为?个中原因超出了这篇文章的范畴,也许下一篇文章我会展开来聊聊。
作者简介
Carlin Eng,Strava前数据工程师和工程经理,后来在雪花公司做了两年的销售工程师和数据科学家,现任Eppo数据工程主管。狂热的自行车手,加州旧金山海豚俱乐部中令人骄傲的一员。
原文链接
https://carlineng.com/?postid=sql-critique#blog
译者简介
冬雨,小小技术宅一枚,现从事研发过程改进及质量改进方面的工作,关注研发、测试、软件工程、敏捷、DevOps、云计算、人工智能等领域,非常乐意将国外新鲜的 IT 资讯和深度技术文章翻译分享给大家,已翻译出版《深入敏捷测试》、《持续交付实战》。
评论