关于 SQL 的设计问题

本文讨论的是 SQL 的设计问题。这些问题可以具体指出:一部分是当时有理由的选择,写进标准之后无法再修改;另一部分本来可以避免,因为所需的材料在当时或稍后已经存在。

最后一部分给出一个设计草图。

下面分七节。问题 1 讨论语义,问题 2 至问题 5 讨论几个具体机制,它们的成因相近:语言没有承载相应语义的位置,于是这些语义被编码进关键字,或留给实现。

问题 表现
1 缺少可验证的语义基础,等价性只能在语言之外证明
2 本可局部化的机制成了全局约定
3 类型参数被编码进关键字
4 语法顺序与求值顺序分离,关系不是一等值
5 两个不同概念共用一个动词
6 语言层与执行层之间没有边界
7 实现层形态被固化进接口

问题 1:语义没有固定下来

SQL 标准用自然语言规定语义,但没有形式化定义。求值顺序、别名解析、类型转换和 NULL 传播的不少细节留给实现解释。

后果是具体的:优化器在改写查询时缺少合法性依据。各引擎的优化器都是基于经验的重写规则集合,无法说明"两条查询等价"能否证明。

一个佐证是,SQL 等价性验证已经形成一个独立的研究方向:

  • Cosette 用 Coq 和 Rosette 自动证明 SQL 等价性[1];
  • HoTTSQL 用同伦类型论在 Coq 里形式化 SQL 的多重集语义、相关子查询与聚合[2];
  • VeriEQL 把带完整性约束的复杂 SQL 等价性做成有界模型检查,论文报告它在两万多个基准上比当时最好的一档强一个数量级以上,并在 MySQL 和 Apache Calcite 中找到了实际缺陷[3];
  • SPES 采用符号执行,不做有界化,覆盖范围窄但结论更强[4]。

等价性需要外部工具来验证,这在程序设计语言领域并不常见。如果语义固定、定律成套,"两段程序是否等价"至少是一个定义明确的问题;它可能不可判定,但问题的含义是清楚的。

需要补充历史背景,以免把时间线说错。SQL 的形式语义并非 2017 年才首次出现。更早的有 Negri、Pelagatti 和 Sbattella 在 1991 年的工作[5],此后的机械化工作也不少,例如 Malecha 等人在 Coq 中验证关系引擎的一部分[6]。问题不在于这些工作不存在,而在于它们回避了 NULL、多重集和嵌套子查询,因此能够"证明"一些在真实数据库上不成立的等价律。

覆盖这一范围的是 Guagliardo 和 Libkin 在 2017 年的工作[7]:覆盖 SELECT-FROM-WHERE 加子查询、集合与多重集操作以及 NULL,在大量随机查询和数据库上做了实验验证(对照 PostgreSQL 和 MySQL 的实际行为),并给出该片段与关系代数之间的等价结果,把多重集和 NULL 都计入。JSON、LATERAL 等特性后来才逐步补齐[8]。

这段空白持续了三十多年。原因不是没有人想到,而是语言先冻结,语义后补。而"回避 NULL 的形式语义"与"覆盖 NULL 的形式语义"之间的差别并不是学术上的苛求:前者会给出错误的优化。

问题 2:NULL 与三值逻辑

这一处代价最高,而且本来可以避免。

三值逻辑的影响范围是全局的:

-- 永远不为真,即使 3 确实不在表里
SELECT * FROM t WHERE x NOT IN (1, 2, NULL);

-- LEFT JOIN 之后在右侧加过滤条件,静默退化为 INNER JOIN
SELECT * FROM a LEFT JOIN b ON a.id = b.aid WHERE b.status = 'ok';

-- AVG 静默忽略 NULL 行,分母因此不是行数
SELECT AVG(amount) FROM orders;

这三个例子并不冷僻,是 SQL 使用者会反复遇到的问题,而且性质相同:NULL 被做成语义层面的全局约定,而不是类型层面的局部结构。它渗入谓词、索引、GROUP BY、NOT IN、外连接、聚合,以及每一个 ORM。

这一点常被当作设计品味问题,实际上是一条定理:

SQL 的三值逻辑不增加表达力。

Guagliardo 和 Libkin 在他们那份语义中证明了这一点:该片段里的每一个查询都可以在两值布尔语义下求值[7:1]。更完整的表述见 Console、Guagliardo 和 Libkin 后来的论文:对带 NULL 的数据库,任何多值一阶逻辑扩展都不比标准的两值布尔解释更强[9]。

Ricciotti 和 Cheney 后来在 Coq 中把这份语义机械化:他们推导了上述定理,并把它扩展到带 LATERAL 的查询[8:1]。需要说明的是,那是机械化与扩展,而不是补全一个未完成的证明,原证明在论文中是完整的。他们同时指出,在有分组和聚合的情况下,这条性质是否仍然成立尚无研究。

因此三值逻辑是一个可以整体去除、且不损失表达力的设计选择,而不是"有代价但必须保留"。NULL 要解决的问题是真实的,即信息缺失;合适的做法是把可选性做成类型(T?),让"这里可能为空"成为一条局部的、由编译器检查的类型规则。SQL 把它做成第三真值,代价由所有使用者长期承担。

问题 3:集合与多重集的语法分裂

UNION  /  UNION ALL
INTERSECT / INTERSECT ALL
EXCEPT / EXCEPT ALL

同一个操作对应两个关键字,差别只在一个类型参数。

这反映出一个问题:语言没有承载"关系是集合还是多重集"的位置,只能把它编码进关键字。SQL 对多重集与集合关系的处理一直不自然:SELECT DISTINCT 是后置修饰符,COUNT(DISTINCT x) 是特例语法。

更合适的做法是把基数做成类型参数,由类型决定 UNION 的语义,ALL 关键字不需要存在。

不过这里需要修正前面可能给出的印象:这个问题本身至今没有解决。Ricciotti 和 Cheney 在 2019 年提出了 NRC(Set,Bag),一个同时具有集合类型和多重集类型、并带两者互相转换的演算(ι 把集合视为多重集,δ 去重),给出了有效重写规则,并证明 ι 与 δ 构成一对 Galois 连接[10]。他们在论文中明确写道,混合语义下嵌套查询与扁平查询的相对表达力仍然是开放问题。

因此问题不在于 SQL 的设计草率,而在于这个问题在理论上就困难,而 SQL 把一个尚未解决的问题固定成了语法,之后没有修改余地。本文设计中的 κ 参数正处在这一点上:有基础可用,也有一个未弄清的必要条件。

问题 4:子句顺序与求值顺序不一致,关系不是一等值

SELECT ...      -- 5. 但它写在最前面
FROM ...        -- 1.
WHERE ...       -- 2.
GROUP BY ...    -- 3.
HAVING ...      -- 4.
ORDER BY ...    -- 6.
LIMIT 10;       -- 7.

这是英语语序,不是求值顺序。它便于阅读和书写,非程序员也能理解,在 1970 年代是合理的判断。代价是学习者需要先记住语法顺序不等于执行顺序。

用 C# 的 LINQ 写同一组操作,算子的书写顺序就是组合顺序:

var result =
    Orders
        .Where(o => o.Placed >= new DateOnly(2026, 1, 1))
        .Join(Customers, o => o.Customer, c => c.Id, (o, c) => new { o, c })
        .GroupBy(x => x.c.Country, x => x.o.Total)
        .Select(g => new { Country = g.Key, Revenue = g.Sum(), N = g.Count() })
        .Where(x => x.N > 100)
        .OrderByDescending(x => x.Revenue)
        .Take(10);

算子的书写顺序就是它们在数据流上的组合顺序,不存在 SQL 那种子句顺序与求值顺序的分离。这里说的是组合顺序:LINQ 的执行是延迟的,GroupBy 与 OrderByDescending 仍是阻塞算子。IQueryable<T> 与 IEnumerable<T> 是一等值,中间结果可以绑定、传参和复用。C++20 的 std::ranges 管道在书写顺序与组合顺序一致这一点上性质相同。

更深的问题在下面两点。

关系不是一等值。因此 WITH 只能作为查询表达式的前缀,绑定出的名字不是值:不能作为参数、不能放入数据结构、不能跨语句复用。子查询中当然可以再写 WITH,但那仍然是文本前缀,不是一等值。表值函数晚了三十年才出现。语言集成查询中的 N+1 问题根源在此。

LIMIT 不带 ORDER BY 是合法语句,结果不确定,引擎不报错。原因相同:顺序没有被类型表达。

这个问题同样早有答案,只是出现得太早:

  • NRC 在 1995 年把关系做成一等值,并配备可嵌套集合的类型系统[11];
  • Wong 在 1996 年证明保守性定理:任意嵌套深度的查询都可以在不超过结果嵌套深度的中间结果下表达,而且证明是构造性的,给出了可实现的终止重写算法,把查询归一化为扁平形式,直接对应惯用的 SQL[12];
  • Cheney、Lindley 和 Wadler 的 query shredding 补上了嵌套与扁平之间的转换,并证明"拆开执行再合并"与"直接执行"结果相同[13]。

它没有成为工业现实,原因不是不正确,而是 1995 年没有这个需求。

问题 5:顺序被当作关系的属性

关系的本质是无序的,这是 Codd 关系模型的基本设定[14]。SQL 把 ORDER BY 放进了语句的表达能力,因此需要另一套语法来补足:

SELECT customer, id, placed, total,
       ROW_NUMBER() OVER (PARTITION BY customer ORDER BY placed DESC) AS rn,
       LAG(total)   OVER (PARTITION BY customer ORDER BY placed)      AS prev_total
FROM Orders;

ORDER BY 出现两次,方向不同,而整个查询的输出顺序仍然未定义。

窗口函数需要的顺序与最终输出的顺序是两件事,语言用同一个关键字表达。这是上面两句中 ORDER BY 看起来相同、实际不同的原因。

SQL:2003 为此新增了 OVER 语法、PARTITION BY 与 ORDER BY 子句、frame 子句,以及一个固定的排名函数白名单。

如果顺序是独立的类型,情况会不同。关系无序(Rel<A, κ>),序列有序(Seq<A>),order 是两者之间唯一的转换。"取前 N"只在序列上有定义。窗口函数就只是"分组之后,对每个分区的序列做列计算"。它需要的三样东西(关系、序列、按键分组)都在核心里,只缺一个从"含序列的关系"回到序列的转换,即 unnest。这样窗口函数就是一个普通库函数,不需要新语法。

SQL 的困难在于没有可以承载顺序的类型系统,只能把顺序做成调用时生效的动词,于是同一个动词在不同位置含义不同。

问题 6:执行细节进入语言层

语言层与执行层之间缺少边界:

SELECT /*+ INDEX(o idx_placed) */ ...
FROM orders o USE INDEX (idx_placed) ...
WHERE ... OPTION (MAXDOP 4);

SELECT ... FROM big_table PARTITION (p2026_01) ...
SELECT ... WITH (NOLOCK) ...

索引提示、连接提示、分区选择、并行度、隔离级别、锁提示,都属于执行层,却出现在语言层。它们的共同形态是:一条语句的含义开始依赖厂商和物理布局。MySQL 的 USE INDEX 在 PostgreSQL 中并不存在;同一段查询在不同引擎上的语义、以及优化器实际做了什么,用户无法审阅。这些提示多数情况下也不能真正帮助优化器,只在统计信息过期时起临时缓解作用。

其他领域的做法可以参照。在 eBPF 中,程序类型(program type)决定程序能调用哪些 helper、能挂载到哪些位置,校验器在加载期强制这套限制[15]。能力不是运行时开关,而是加载期固定的接口。bpftime 的 Extension Interface Model 更进一步,把扩展需要的每个资源做成显式声明的 capability,再交给校验器执行[16]。

宿主接口这一层还有两个数据。WAF(ICDE 2025)实现基于 WebAssembly 的用户自定义函数执行环境,其第一个发现是:超过 70% 的执行时间花在数据库引擎与 WASM 运行时之间的数据传输上;他们用编译期布局对齐加共享内存零拷贝,把这部分开销降低 3.1 倍[17]。bpftime/EIM 一系工作在比较两条路线时指出,Wasm 沙箱会带来额外的检查与数据拷贝开销[16:1]。

这两件事与索引提示不是同一种问题,但成因相近:边界划错,代价会以其他形式出现。索引提示让执行细节进入语言层,这里则让代价变成数据拷贝。

问题 7:编译期结构在边界上丢失

WebAssembly 是栈机。这是有意为之,为了单遍类型校验和较小的体积。代价是编译器需要先把 SSA 图线性化为操作数栈,块边界上只剩类型签名;JIT 拿到之后要靠数据流分析把 φ 节点插回、把循环结构和向量化机会重新识别。

编译期已知的信息被丢弃,运行期重新发现。

SQL 的用户自定义函数历史上一直较慢,原因不止一个:逐行解释、无法内联、缺少向量化、元数据不足都在其中。它与上面的 Wasm 有一处结构上的相似:UDF 的接口不携带可供优化的信息,优化器只能把它当作黑盒执行。相似并不等于同一机制,Wasm 丢失的是 IR 形态,SQL UDF 缺少的是内联与元数据,但两者指向同一条设计原则。

更合适的做法是让 IR 边界保留结构,使 JIT 的工作从"重新发现"变成"降级"。这在程序设计语言领域是成熟方向,多阶段编程从 1990 年代起就在处理"哪些信息在哪一层保持可见"[18]。

已经存在的理论基础

前面七节如果只有诊断,就只是问题清单。这一节给出可用材料。以下内容限于已有定理或已有系统。

增量可以自动派生

DBSP(VLDB 2023 最佳论文)把增量计算定义成流上的运算,给出了把任意 DBSP 程序机械转换成增量程序的算法,并且已有 Lean 形式化[19][20]。它之前的工程原型是 DDlog:程序员写非增量的 Datalog,编译器合成增量实现[21]。更早的是 Differential Dataflow[22]。

这里需要把记号写清楚。设输入状态为 I、这一轮的输入变化为 δ,Q 的增量版本 ΔQ 满足:

Q(I ⊕ δ) = Q(I) ⊕ ΔQ(I, δ)

右边是机械派生出来的。注意 ΔQ 一般依赖当前状态 I,不只是变化量 δ;这一点在待研究方向第 9 条中会用到。对线性算子,这一依赖可以退化;对 distinct 这类非线性算子,I 不能省去。

从类型一侧过来的是 Datafun。它用模态类型系统跟踪单调性:类型系统区分任意函数与单调函数,上下文分成离散上下文与单调上下文,因此 fix 只能作用于单调函数,终止性由类型保证。POPL 2020 的那篇进一步证明了关键结果:不动点的导数等于其导数的不动点[23]。

合起来,用户不需要为增量维护重写查询。增量视图维护从"需要专家实现的技术"变成"编译器的职责"。

等价性可以被证明

“增量可以自动派生”成立的前提是语言有确定的语义和一组合法的重写律,也就是问题 1 要解决的问题。现成材料是:一份覆盖 NULL 与多重集的形式语义[7:2],以及一组分别面向不同片段、不同方法的验证工具(Coq/Rosette、同伦类型论、有界 SMT、符号执行)[1:1][2:1][3:1][4:1]。它们还不是一条流水线,但合起来足以回答具体问题。

因此"规范应当固定定律,而不只固定语法"有工具支撑:可以写进规范,可以验证优化器没有改变语义,也可以回答"这两个查询是否等价"。

类型系统可以承担一部分文档职责

这是整个论证的核心。逐项对照:

原来由什么承载 现在可以由什么承载
NULL 的语义,写在文档里 可选类型 T?,编译器强制处理
集合与多重集的差别,靠 ALL 关键字 基数类型参数 κ[10:1]
语义类型检查的可判定性,SQL 上不清楚 NRC 上有相应结果[24];能推到什么程度仍待回答
单调性,靠人工注意 模态类型跟踪[23:1];POPL 2024 还证明了单调程序在特定数值抽象下存在完全的抽象解释[25]
"这条查询为什么是这个结果",靠日志 溯源半环,注解作为代数元素传播[26]
并行许可,靠经验判断 等级模态 / coeffect[27][28];源头是更早的有界线性逻辑[29]

最后一行需要把本文的判断和文献结论分开。

[26:1] 证明的是:给关系加注解,注解的传播构成半环(加法对应并,乘法对应连接),这是文献结论。[27:1][28:1] 中的等级是另一套代数,用来描述"这段计算要求上下文允许什么",这也是文献结论。本文认为这两套东西是同一个代数在两种用法上的复用:一份带注解的关系,其注解如何传播,与并行许可如何合成,是同一套机制。这一句是本文的判断,没有文献直接支持,属于本设计需要验证的假设,不能当作可引用的结果。把它标出来,是因为整篇中最容易被误当成结论的地方就是它。

协议与所有权可以被类型检查

会话类型从 Honda 1993 年的二元会话类型起步[30],发展到多方会话类型,再到 Scalas、Dardha、Hu 和 Yoshida 把完整的多方会话 π-演算编码进标准线性 π-演算:编码保持类型、操作上可靠且完备,并在一套 Scala 工具链上落地[31]。

意义是直接的:一次 exchange by key 就是一个多方协议,每个参与者发出第 k 个分区,接收方从所有来源收齐。它可以被静态检查,而不是靠测试。

限制同样需要说明。多方会话类型不保证跨多个交错会话的死锁自由,标注工作量也不小。因此"shuffle 可以被类型检查"成立,"整个分布式执行不会死锁"不成立。

重写搜索的基础设施已经具备

egg(POPL 2021 杰出论文)用 e-graph 加等价饱和,一次构建所有等价形式,再用代价模型选择最优[32]。RisingLight 社区用 egg 重写查询引擎时,大约一千行代码就实现了谓词下推、投影消除、连接重排、常量折叠和代价模型,运行真实 TPC-H 查询[33]。Flan(POPL 2024 杰出论文)说明类型化的声明式语言在高性能场景可行:它用多阶段编程为 Datalog 生成特化代码,在若干基准上对 Soufflé 一类系统取得过最高 12 倍的加速[34]。

归一化加代价模型不是研究课题,是一个教学项目能够完成的工程量。这一点在论证中有实际作用:它把"设计一个更好的语言"从"需要发明很多东西"降为"需要把已经发明出来的东西组合起来"。

一处没有直接先例的地方

前面五部分都有成熟工作可用。有一处没有。

把等级模态真正用在数据引擎的并行许可上,没有找到直接对应的工作。相近的只有这些:

  • coeffect 理论本身,跟踪"计算对环境的要求",与 effect 对偶[27:2];
  • effects 与 coeffect 通过 grading 统一在同一个演算里[28:2];
  • 有界线性逻辑按资源使用量分级,是"允许复制几份"的先驱[29:1];
  • 嵌套数据并行(NESL 一脉)的 work/span 代价语义[35],它给出"并行度改变不影响结果"的经典保证,但那是代价模型,不是类型系统。

因此"用类型系统约束查询引擎的并行许可"这个交叉点没有直接先例。

这是整套设计中最具原创性的部分,也是唯一没有前人工作可参照的部分。没有先例可能意味着可行,也可能意味着不可行。

目前没有证据区分这两种情况。在投入语法设计之前,应当先验证这一部分。

将这些组合起来:一个设计草图

上一节说先验证那一处,再设计语法。这个顺序是正确的,但没有草图也无法说明前面五部分各自放在什么位置,以及最不确定的地方在哪里。

下面这份内容是一张草图,不是规范。记号、类型、核心原语、库函数实现和等级规则都在本节给出,因此这一节可以单独阅读。一份可用的规范至少还缺三样:形式化的类型规则、一套可以直接运行的合规测试、真实引擎上的性能数据。三样都需要更专业的工作。

这一节只做一件事:把前面五部分按同一方向组合起来,检查是否会立刻暴露问题。

记号与类型

先约定记号,下面直接使用,不再解释。

A, B ::= int | bool | text | Decimal | Money | Instant | Date | Interval | Month
       | {l₁: A₁, …, lₙ: Aₙ}          -- 行(结构化记录)
       | A | B                        -- 和类型
       | T?                           -- 可选:没有 NULL,缺失进类型
       | Rel<A, κ>    κ ∈ {set, bag}  -- 关系
       | Seq<A>                       -- 有序列
       | ∀ρ. …                        -- 行多态;{..ρ} 表示"其余字段由 ρ 表示"

效应(effect)和等级(coeffect)是两回事:

效应   pure | read | write                 -- 这段计算对世界做了什么
许可   π ⊆ {dup, reorder, partition, retry}
等级   g 是一个许可集合                     -- 这段计算要求上下文允许什么
合成   g₁ ⊓ g₂ = g₁ ∩ g₂                   -- 整体只和最保守的部分一样自由
□_g A                                      -- 在许可 g 之下使用 A 合法

代数结构是许可的见证,是声明而不是证明:Monoid<B>(结合律加单位元)授予 partition;CommutativeMonoid<B> 再加 reorder 和 dup;Invertible<B> 允许流式撤回。校验器不证明结合律(那不可判定),只保证声明与使用一致:声明了交换律,引擎就有权重排;声明与实际不符,责任在作者。

表面语法:

|> 管道;_ 是当前行(在 group 的聚合体里是当前子关系),__ 是 join 右侧的行
@2026-01-01 是有类型的日期字面量,不是字符串靠引擎隐式转换
T? 上的操作必须显式:x ?? d 取默认值,x is some v 判定并绑定

写入:

apply : Table<A> → Change<A>+ → (int, Table<A>)          ! write

Change<A> = Insert (Rel<A>) (Conflict<A>)
          | Delete (Rel<A>)
          | Update (Rel<A>) (A → A)

Conflict<A> = Reject | Ignore | Resolve (A → A → A)

Update 的参数是一个普通函数 A → A,因此可以在不写库的情况下对查询结果试运行,并做单元测试。upsert、MERGE、INSERT … ON CONFLICT … DO UPDATE 都不是新原语,它们是 apply 的参数取值。

类型先行

SQL 中由文档和约定承载的内容,能放进类型的都放进类型。最直接的是这四处:

Rel<A, κ>      κ ∈ {set, bag}     基数进类型,UNION ALL 不需要存在
Seq<A>                            顺序进类型,take 只在这里有定义
T?  /  A | B                      可选性与和类型,NULL 消失
pure | read | write               副作用进类型,优化器有依据

另外三项,即单调性、溯源和并行许可,不走"值有类型"这条路,而走等级:它们描述的是"这段计算要求上下文允许什么"(见"已经存在的理论基础"中"类型系统可以承担一部分文档职责"一节)。这是效应与等级需要分开的原因。

核心原语

构造    rel · from
形状    where · select · join
合并    union · diff
聚合    group · fold
嵌套    unnest
递归    fix
基数    distinct · bag
顺序    order · take · skip
写入    apply

9 组、17 个写法,就是核心的全部(distinct/bag 是同一组基数转换的两个方向,take/skip 是同一组切片的两个方向)。下面提到的"核心"指的就是这张表。

order 与 unnest 是关系与序列之间两个方向的转换;fold 是唯一携带代数结构的原语,count、sum、collect 等都是它的实例。

管道

书写顺序等于求值顺序:

Orders
|> where _.placed >= @2026-01-01
|> join Customers on _.customer == __.id
|> group by __.country {
     n:       count(),
     revenue: sum(_.total),
     aov:     avg(_.total)
   }
|> where _.n > 100
|> order by _.revenue desc
|> take 10

-- Rel<{country: text, n: int, revenue: Money?, aov: Decimal?}, set>

问题 4 与问题 5 在这里同时消失。没有子句顺序,SELECT 不再写在最前面;take 只作用于 Seq,因此忘记排序直接取前 N 是编译错误,而不是结果不确定。

两处与 SQL 不同,都是类型决定的。HAVING 不再存在,因为聚合产生的是一个普通关系,where 照常使用。sum/avg 在空集上返回 Money?,count 返回 int,因为空集的计数有定义。因此 revenue ?? 0 必须显式写出,而不是遗漏后静默拿到 NULL。

库函数

SQL 中依靠专用语法存在的功能,包括外连接、半连接、窗口、每组取前 N、去重计数和 UPSERT,都可以在核心原语之上定义为库函数,不需要新增语法:

库函数 实现 SQL 中需要
cross 1 行 CROSS JOIN 关键字
左 / 右 / 全外连接 3 行 LEFT/RIGHT/FULL 三个关键字加三套 NULL 语义
semijoin / antijoin 2 行 EXISTS / NOT EXISTS 子查询
每组取前 N 1 行 ROW_NUMBER 子查询,或 LATERAL
窗口框架本身 3 行 SQL:2003 整套 OVER 加 frame 子句
count_distinct 1 行 COUNT(DISTINCT) 专用语法
upsert 1 行 INSERT … ON CONFLICT … DO UPDATE

关键实现(join 只接受等值键,返回 {left, right} 嵌套结构;表面语法自动展平):

let cross l r = join l r (fun _ -> ()) (fun _ -> ())

let semijoin l r kl kr =
  l |> join (r |> select { k: kr(_) } |> distinct) kl (fun x -> x)
    |> select { left: _.left }

let left_join l r kl kr =
  union
    (l |> join r kl kr |> select { left: _.left, right: some(_.right) })
    (l |> diff (l |> semijoin r kl kr)
       |> select { left: _, right: none })

let window r k ord f =
  r |> group by k { part: f (collect (order by ord)) }
    |> unnest(part)

let top_n r k ord n = r |> window k ord (take n)

let upsert t incoming key resolve =
  apply t { Insert incoming (Resolve resolve) }

窗口函数是其中对比最清楚的例子:

SELECT customer, placed, total,
       ROW_NUMBER() OVER (PARTITION BY customer ORDER BY placed DESC) AS rn,
       LAG(total)   OVER (PARTITION BY customer ORDER BY placed)      AS prev_total
FROM Orders;
Orders
|> order by _.customer, _.placed
|> window by _.customer {
     rn:         row_number(),
     prev_total: lag(_.total, 1)
   }

SQL:2003 为此新增了整个 OVER 语法、frame 子句和一个固定的排名函数白名单。这里它是一个库函数,因为它需要的三样东西(关系、序列、按键分组)都在核心里;把分区从"含序列的关系"贴回每一行的转换由 unnest 提供。

代价也需要说明。window 从箭头形状可以看出是阻塞算子,需要先读完全部输入。库函数在许可层面没有代价,在执行层面有。

还有一处需要单独说明。apply 的 Update 接受一个选择器关系和一个普通函数,因此可以在不写库的情况下对查询结果试运行,也能做单元测试。同一个函数还回答了"这个视图能否被写入",这属于类型判断的范围,而不是 DBA 的经验。

对照案例

下面用表面语法列出几组常用查询,SQL 与本文写法并排。重点不在语法长短,而在哪些保证被移进了类型,以及哪些陷阱因此写不出来。用到的库函数已经在前两节定义。

过滤、排序、取前 N

SELECT o.id, o.placed, o.total
FROM Orders o
WHERE o.placed >= DATE '2026-01-01'
  AND o.total > 100
ORDER BY o.total DESC
LIMIT 10;
Orders
|> where _.placed >= @2026-01-01 and _.total > 100
|> select { id, placed, total }
|> order by _.total desc
|> take 10

SQL 中 ORDER BY 写在 SELECT 之后,却在投影之前求值,LIMIT 才是最后一步;上面四个算子的排列就是求值顺序。take 只存在于 Seq 上,因此忘记排序直接取前 N 是编译错误,而不是结果不确定。

外连接与空值

SELECT c.name,
       COALESCE(SUM(o.total), 0) AS revenue,
       COUNT(o.id)               AS orders
FROM Customers c
LEFT JOIN Orders o ON o.customer = c.id
GROUP BY c.id, c.name
ORDER BY revenue DESC;

-- 两个陷阱:
--   COUNT(o.id) 写成 COUNT(*) 会把空行算进去
--   AVG(o.total) 静默忽略空行,分母不再是行数
Customers
|> left join Orders on _.id == __.customer
|> group by { id: _.id, name: _.name } {
     revenue: sum(__.total ?? 0),
     orders:  count(__.customer)
   }
|> order by _.revenue desc

外连接之后右侧列的类型是 Money? 或 int?,不能直接参与比较和算术。要在外连接之后按右侧列过滤,必须显式写 __.total ?? 0 或 __.total is some v;SQL 中同样的写法会静默把外连接退化成内连接。

count() 与 count(x) 的差别由参数类型决定:前者数行,后者只数 x 有值的行。SQL 中 COUNT(*) 与 COUNT(o.id) 形式接近,语义差别直接决定结果是否正确。sum(__.total ?? 0) 把"为空时算多少"写在代码里;SQL 的 SUM 自动忽略 NULL,漏掉 COALESCE 不会报错,只会给出一个看似合理的数字。

复用中间结果

WITH monthly AS (
  SELECT date_trunc('month', placed) AS m,
         customer,
         SUM(total) AS amt
  FROM Orders
  GROUP BY 1, 2
)
SELECT a.customer, a.m, a.amt,
       b.amt AS prev_amt,
       a.amt - b.amt AS delta
FROM monthly a
JOIN monthly b
  ON b.customer = a.customer
 AND b.m = a.m - INTERVAL '1 month';
let monthly =
  Orders
  |> group by { m: month(_.placed), customer: _.customer }
              { amt: sum(_.total) }

monthly
|> join (monthly |> select { customer, m: _.m + 1.month, prev: _.amt })
       on _.customer == __.customer and _.m == __.m
|> select {
     customer: _.customer,
     m:        _.m,
     amt:      _.amt,
     prev_amt: __.prev,
     delta:    _.amt - __.prev
   }

monthly 是一个值,可以被引用两次(形成一个 DAG)、作为参数传入函数、放进库、在别处再绑定。SQL 的 WITH 不是值,只能作为查询表达式的前缀。重算还是物化由编译器决定:它看得见同一个绑定被使用了两次,可以自由选择,而结果相同;SQL 中同一个 WITH 在不同引擎上可能被内联展开,也可能被物化,性能和语义都可能随之变化。另外,分组键直接写表达式,不需要通过 GROUP BY 1, 2 的位置来指代。

两侧都有 customer 与 m,所以最后的 select 用 _ 与 __ 限定来源;未限定的同名字段会报错,而不是由引擎静默选择一边。

递归

WITH RECURSIVE reach AS (
  SELECT src, dst FROM Edges
  UNION
  SELECT r.src, e.dst
  FROM reach r
  JOIN Edges e ON e.src = r.dst
)
SELECT * FROM reach;

-- 必须是 UNION 而不是 UNION ALL,否则不会终止。
let reach =
  fix (λr.
    Edges
    |> union (r
        |> join Edges on _.dst == __.src
        |> select { src: _.src, dst: __.dst }))

fix 是核心原语,不是语句形状,因此可以嵌套、作为参数、出现在函数体中。SQL 的 WITH RECURSIVE 只能在语句最外层,一条查询里只能有一个锚点配一个递归项。终止性来自类型:fix 只在集合(κ = set)上有定义,对多重集没有。因此"必须去重才能停"不是需要记住的经验,而是类型选择的必然结果。

去重计数与近似

SELECT COUNT(DISTINCT customer) FROM Orders;

-- 需要近似时,各家方言互不相同:
--   APPROX_COUNT_DISTINCT()   Oracle / Presto
--   HLL_COUNT.DISTINCT()      BigQuery
--   uniqCombined()            ClickHouse

这两个功能都是 fold 的实例:

let count_distinct r x =
  r |> select { v: x(_) } |> distinct |> fold(0, (n, _) -> n + 1)

let approx_count r x ε =
  r |> select { v: x(_) } |> fold(hll_new(ε), (s, v) -> hll_add(s, v))
Orders |> count_distinct(_.customer)
-- int,精确

Orders |> approx_count(_.customer)
-- HLL<int, 1%>,带误差上界

count_distinct 先投影出目标列,用 distinct 得到集合,再用 fold 计数。approx_count 不去重,而是把 fold 携带的代数从整数加法换成一个 HyperLogLog 幺半群:hll_new(ε) 是单位元,hll_add 是合并操作;因为它是交换幺半群,引擎可以把数据分片后分别计算再合并。

近似不是"另一个函数名",而是一个带误差参数的类型 HLL<T, ε>:它不能直接与整数比较,必须显式取出估计值或误差范围,误差写在签名里。精确与近似在 SQL 中是不同厂商的方言函数,没有共同语义,迁库意味着重写;在这里它们是同一个算子的两种实现,差别只在 fold 携带的代数。

这些案例不代表所有查询都会变短。单表简单查询两边几乎一样长;CASE WHEN 一类表达式层的冗长没有解决;迁移成本主要不在语言,而在格式化器、语法高亮、ORM、BI 和审计工具需要重建;现有优化器是多年积累的结果,重做会先失去它。

许可等级的推断

最后是整套设计中最不确定的部分。前面已经说明,等级是许可集合,合成是求交。逐条原语写下来:

π(where r p)   = π(r) ⊓ π(p)
π(select r f)  = π(r) ⊓ π(f)
π(join r s)    = π(r) ⊓ π(s)
π(union r s)   = π(r) ⊓ π(s)
π(group r k f) = π(r) ⊓ π(fold 声明的代数)
π(order r k)   = π(r) ⊓ {dup}          -- 丢掉了 partition / reorder
π(take n)      = ⊤ ⊓ {dup}

有两点结果。第一,推断只往一个方向走:交只会让许可变小,不会变大,因此不需要解约束、不需要不动点迭代,沿语法树自底向上一遍算完,时间复杂度是线性的。第二,库函数不需要标注:上面那些库函数没有写一行等级标注,left_join 的许可自动就是"两侧都能重排才可重排",因为它就是这样组合出来的。

order 那一行是全部原语中唯一主动降低等级的操作,对应"排序是阻塞的、会破坏并行结构"。推理是自洽的。

草图中最不确定的部分

就是并行许可这一处。

没有找到任何文献把 graded/coeffect 类型用在数据引擎的并行许可上。因此无法判断这是原创的结果,还是推断有误。草图中其他地方都能指出对应的定理,只有这一处不能。

这也是"先验证那一处,再设计语法"的原因:整套设计是否成立,取决于它。

关于 SQL 当年的选择

需要补充一段历史背景。

Codd 的关系模型本身是简洁的[14:1]:集合语义、没有 NULL、没有顺序概念。SQL 在产品化过程中为性能和便利加入了三样东西。

多重集省去了每一步的去重代价,在 1970 年代的硬件上这是决定性的。NULL 省去了为信息缺失做类型建模的工作量,使缺失的信息可以立即使用。子句式英语语法便于阅读和书写,非程序员也能接受,在数据库向企业推广的年代同样是决定性的。

三样在当年都是合理的工程判断。问题不在错误,而在当时合理的选择被永久写进了标准。

SQL 的困难在于语言一旦冻结就没有修改余地。UNION ALL 会长期存在,三值逻辑会长期存在,ORDER BY 会长期同时表示"排序结果"和"窗口使用的顺序"。事后可以补窗口函数、JSON 和图查询,但无法修改底层结构。

这里的关键不是语言的优美程度,而是错误是否仍然可以修正。

有待研究的方向

前面把问题分散在各节中讲了。这里集中列出,按离落地有多远排序。

缺少关键一环的

有邻近工作,但关键的一环没有人补。

1. 混合基数语义的表达力边界。 Ricciotti 和 Cheney 自己写明,混合语义下嵌套与扁平的相对表达力仍是开放问题[10:2]。这直接关系到"UNION 的语义由基数类型决定"能推到多远。

2. 类型等价判定的边界。 幂等缓存和物化视图匹配都需要判断"两个类型是否相同"。NRC 上有语义类型检查的结果[24:1],但加入 effect、coeffect 和增量之后是否仍然成立,目前不清楚。

3. 把会话类型接到 shuffle 上。 多方会话类型已经成熟[31:1],但没有人把它用到查询计划的分区交换上。已知障碍是跨会话死锁不保证、标注负担重。

4. 零拷贝宿主接口的类型化。 WAF 在运行时层面给出了方案[17:1],但"这个缓冲区属于宿主、模块不可写"这件事没有表达在类型里。

5. 保留结构的 UDF IR。 栈机往返会丢失编译期信息,这是问题 7 描述的情形。正面解法(多阶段编程[18:1])是成熟方向,但没有人做过数据引擎的版本。

6. 代数声明的校验。 如果用户声明的函数并不满足 Monoid/CommutativeMonoid,引擎会给出静默的错误结果,而且由于重排常由并行引起,错误还不是确定性的。目前只能靠审计标准库。这是整套设计中最危险的失效模式。

7. 可执行的形式语义与一致性测试套件。 形式语义已经有了[7:3],验证工具也有一组[1:2][2:2][3:2][4:2],但缺少一份"任何引擎都必须通过、且被当作规范一部分"的合规测试集。说数据库领域从来没有正确性测试并不准确:sqllogictest 就是一套跨引擎的结果正确性测试,DuckDB 等在使用;标准也有各自的 conformance 测试。缺少的是规范级、可执行、强制的那一份。一套可执行、可强制的一致性测试,比一份声明更能界定"合规"。TPC 的评测目标是性能;TPC-C 和 TPC-E 另有 ACID 与一致性要求,但那属于事务语义,不是查询语言语义。这可能是上面七条中最容易实施、收益最确定的一条。

没有理论基础的

8. 类型化的并行许可。 就是前面"一处没有直接先例的地方"讨论的内容。它决定整套设计是否成立。其他五部分都有前人工作,只有这一部分没有。

9. 一个尚未想清楚的冲突。 这一条是本文的推测,没有找到文献支持,列出以便核实。

DBSP 的增量机制建立在流构成交换群(有可加逆)之上,这是 Δ 能够机械派生的前提[19:1]。而 set 语义要求并集的幂等。可逆与幂等如何共存于同一个代数,目前没有答案。

去重是非线性算子。用前面修正过的记号说,它使增量 ΔQ(I, δ) 无法摆脱对当前状态 I 的依赖,因此不是直接派生的。如果这个观察成立,"集合语义下的增量也是派生的"就是有条件的,而条件是什么尚不清楚。

这一条如果成立,会同时影响问题 3 和"增量可以自动派生"那一条。

组合本身

10. 把上述内容放进同一个类型系统。 这一组不指向某个具体问题,但它是前面所有条目的前提。

DBSP 有代数,没有类型系统,也管不了并行许可。Datafun 有类型系统,但是函数式的、不是关系代数式的,也没有处理宿主接口。NRC 有类型系统,但停在集合与多重集,没有 effect、coeffect、增量和所有权。会话类型在程序设计语言一侧成熟,没有人接到 shuffle 上。WAF 和 bpftime 在运行时一侧很扎实,完全在类型层之外。

每一部分都各自成熟,但没有工作在做同一件事:把它们装进同一个类型系统。

因此结论不是"SQL 应当被取代",而是这里有一批已经被解决的问题,从未被组合过。组合本身是新的工作,而组合中最关键的一部分暂时没有基础。

下一步是先做一个例子:把 CommutativeMonoid 的声明接到许可等级的推导上,在纸上跑通,检验"类型化的并行许可"这一处是否真的没有基础。这件事完成之后,再讨论语法。

参考文献


  1. Shumo Chu, Chenglong Wang, Konstantin Weitz, Alvin Cheung. Cosette: An Automated Prover for SQL. CIDR 2017. ↩︎ ↩︎ ↩︎

  2. Shumo Chu, Konstantin Weitz, Alvin Cheung, Dan Suciu. HoTTSQL: Proving Query Rewrites with Univalent SQL Semantics. PLDI 2017. ↩︎ ↩︎ ↩︎

  3. Yang He, Pinhan Zhao, Xinyu Wang, Yuepeng Wang. VeriEQL: Bounded Equivalence Verification for Complex SQL Queries with Integrity Constraints. OOPSLA 2024. 预印本:arXiv:2403.03193。 ↩︎ ↩︎ ↩︎

  4. Qi Zhou, Joy Arulraj 等. SPES: A Symbolic Approach to Proving Query Equivalence Under Bag Semantics. ICDE 2022. ↩︎ ↩︎ ↩︎

  5. M. Negri, G. Pelagatti, L. Sbattella. Formal semantics of SQL queries. ACM Transactions on Database Systems, 1991. ↩︎

  6. Gregory Malecha, Greg Morrisett, Avraham Shinnar, Ryan Wisnesky. Toward a Verified Relational Database Management System. POPL 2010. ↩︎

  7. Paolo Guagliardo, Leonid Libkin. A Formal Semantics of SQL Queries, Its Validation, and Applications. PVLDB 11(1): 27–39, 2017. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  8. Wilmer Ricciotti, James Cheney. A Formalization of SQL with Nulls. arXiv:2003.11331, 2020.(该语义的 Coq 机械化:推导并扩展了 [7:4] 的三值逻辑消除结果。) ↩︎ ↩︎

  9. Marco Console, Paolo Guagliardo, Leonid Libkin. Propositional and predicate logics of incomplete information. Artificial Intelligence 302: 103603, 2022. ↩︎

  10. Wilmer Ricciotti, James Cheney. Mixing set and bag semantics. DBPL 2019. 预印本:arXiv:1905.02069。 ↩︎ ↩︎ ↩︎

  11. Peter Buneman, Shamim A. Naqvi, Val Tannen, Limsoon Wong. Principles of Programming with Complex Objects and Collection Types. Theoretical Computer Science 149(1), 1995. ↩︎

  12. Limsoon Wong. Normal Forms and Conservative Extension Properties for Query Languages over Collection Types. Journal of Computer and System Sciences 52(3), 1996. ↩︎

  13. James Cheney, Sam Lindley, Philip Wadler. A Practical Theory of Language-Integrated Query. ICFP 2013;Query Shredding: Efficient Relational Evaluation of Queries over Nested Multisets. SIGMOD 2014. ↩︎

  14. E. F. Codd. A Relational Model of Data for Large Shared Data Banks. Communications of the ACM 13(6), 1970. ↩︎ ↩︎

  15. Linux 内核 eBPF 子系统:BPF 指令集、program type 与 helper 函数白名单,以及加载期校验器。可定位入口:内核文档 Documentation/bpf/、bpf-helpers(7)、bpf(2)。 ↩︎

  16. Yusheng Zheng, Tong Yu, Yiwei Yang, Yanpeng Hu, Xiaozheng Lai, Dan Williams, Andi Quinn. Extending Applications Safely and Efficiently. OSDI 2025, pp. 557–574.(Extension Interface Model 与用户态 eBPF 运行时 bpftime。) ↩︎ ↩︎

  17. Zhuo Huang 等. WAF: An Efficient WebAssembly-based Execution Environment for User-defined Functions. ICDE 2025.(报告 WASM UDF 超过 70% 的执行时间消耗在引擎与运行时之间的数据传输上,并用编译期布局对齐加共享内存零拷贝将开销降低 3.1 倍,相比容器方案快至 18.1 倍。) ↩︎ ↩︎

  18. Walid Taha, Tim Sheard. MetaML and Multi-Stage Programming with Explicit Annotations. Theoretical Computer Science, 2000. ↩︎ ↩︎

  19. Mihai Budiu, Tej Chajed, Frank McSherry, Leonid Ryzhyk, Val Tannen. DBSP: Automatic Incremental View Maintenance for Rich Query Languages. VLDB 2023(最佳论文);扩展版 VLDB Journal 34(39), 2025。 ↩︎ ↩︎

  20. Tej Chajed. DBSP mathematical formalization using the Lean theorem prover. 2022. ↩︎

  21. Leonid Ryzhyk, Mihai Budiu. Differential Datalog. Datalog 2.0 Workshop, 2019. ↩︎

  22. Frank McSherry, Derek G. Murray, Rebecca Isaacs, Michael Isard. Differential Dataflow. CIDR 2013. ↩︎

  23. Michael Arntzenius, Neel Krishnaswami. Datafun: A Functional Datalog. ICFP 2016;Seminaïve Evaluation for a Higher-Order Functional Language. POPL 2020. ↩︎ ↩︎

  24. Jan Van den Bussche, Dirk Van Gucht, Stijn Vansummeren. Well-definedness and semantic type-checking for the nested relational calculus. Theoretical Computer Science 371(3), 2007. ↩︎ ↩︎

  25. Marco Campion, Mila Dalla Preda, Roberto Giacobazzi, Caterina Urban. Monotonicity and the Precision of Program Analysis. POPL 2024. ↩︎

  26. Todd J. Green, Grigoris Karvounarakis, Val Tannen. Provenance Semirings. PODS 2007. ↩︎ ↩︎

  27. Tomas Petricek, Dominic Orchard, Alan Mycroft. Coeffects: Unified Static Analysis of Context-Dependence. ICALP 2013. ↩︎ ↩︎ ↩︎

  28. Marco Gaboardi, Shin-ya Katsumata, Dominic Orchard, Flavien Breuvart, Tarmo Uustalu. Combining Effects and Coeffects via Grading. ICFP 2016. ↩︎ ↩︎ ↩︎

  29. Jean-Yves Girard, Andre Scedrov, Philip Scott. Bounded Linear Logic: A Modular Approach to Polynomial-Time Computability. Theoretical Computer Science 97(1), 1992. ↩︎ ↩︎

  30. Kohei Honda. Types for Dyadic Interaction. CONCUR 1993;Kohei Honda, Vasco T. Vasconcelos, Makoto Kubo. Language Primitives and Type Discipline for Structured Communication-Based Programming. ESOP 1998. ↩︎

  31. Alceste Scalas, Ornela Dardha, Raymond Hu, Nobuko Yoshida. A Linear Decomposition of Multiparty Sessions for Safe Distributed Programming. ECOOP 2017. ↩︎ ↩︎

  32. Max Willsey, Chandrakana Nandi, Yisu Remy Wang, Oliver Flatt, Zachary Tatlock, Pavel Panchekha. egg: Fast and Extensible Equality Saturation. POPL 2021(杰出论文)。 ↩︎

  33. Runji Wang. Building an SQL Optimizer with Egg. EGRAPHS 2023(PLDI 2023 同址研讨会,邀请报告)。RisingLight 是用 Rust 写的教学数据库系统。 ↩︎

  34. Supun Abeysinghe, Anxhelo Xhebraj, Tiark Rompf. Flan: An Expressive and Efficient Datalog Compiler for Program Analysis. POPL 2024(杰出论文)。 ↩︎

  35. Guy E. Blelloch. Programming Parallel Algorithms. Communications of the ACM 39(3), 1996. ↩︎

posted @ 2026-09-28 18:07  方而静  阅读(25)  评论(0)    收藏  举报