杰弗里·乌尔曼的核心思想如何驱动现代数据库:关系代数、查询改写、基于代价的优化、连接算法与类编译器式的规划,帮助系统实现快速与可扩展的查询。

几乎所有写 SQL、构建仪表盘或调优慢查询的人都或多或少受益于杰弗里·乌尔曼的工作——即便他们从未听过他的名字。Ullman 是位计算机科学家和教育家,他的研究与教材帮助定义了数据库如何描述数据、如何推理查询,以及如何高效执行查询。
当数据库引擎把你的 SQL 转成能快速执行的东西时,它依赖的思想必须既精确又可适应。Ullman 帮助形式化了查询的含义(这样系统就能安全地改写它们),并把数据库思维与编译器思维连接起来(因此查询可以被解析、优化并翻译为可执行步骤)。
这种影响很低调,因为它不会以按钮出现在你的 BI 工具里,也不会是云控制台上的可见功能。它表现为:
JOIN 后查询变快本文以 Ullman 的核心思想为导览,讲述在实际工作中最重要的数据库内部机制:关系代数如何支撑 SQL、查询改写如何保持含义、基于代价的优化器为何做出那些选择,以及连接算法如何决定作业是几秒完成还是几小时卡住。
我们还会借用一些类似编译器的概念——解析、改写与规划——因为数据库引擎比很多人想象的更像复杂的编译器。
一个快速承诺:讨论保持准确,但避免大量数学证明。目标是给你能在下次遇到性能、扩展或令人困惑的查询行为时直接应用的思维模型。
如果你曾写过 SQL 并期望它“只意味着一件事”,那么你依赖的是杰弗里·乌尔曼帮助普及和形式化的那些思想:一个清晰的数据模型,加上一套精确描述查询所求的方式。
关系模型把数据视为表(关系)。每张表有行(元组)和列(属性)。这现在看起来很显而易见,但重要的是它带来的规范:
这种表述让我们可以在不靠主观判断的前提下推理正确性与性能。当你知道一张表代表什么以及如何标识行时,就能预测连接该做什么、重复项意味着什么,以及为什么某些过滤会改变结果。
Ullman 的教学常用关系代数作为一种查询计算器:一小套操作(select、project、join、union、difference),可以组合来表达你想要的结果。
它对日常 SQL 的意义:数据库会把 SQL 翻译成代数形式,然后改写成等价的另一种形式。两条看起来不同的查询在代数上可能是相同的——这就是优化器能重新排序连接、下推过滤或删除冗余工作的方式,同时保持语义不变。
SQL 在很大程度上是“要什么”,但引擎通常用代数式的“怎么做”来优化。
SQL 方言不同(Postgres vs. Snowflake vs. MySQL),但基本原理不会变。理解键、关系与代数等价可以帮助你判断查询是逻辑错误、只是慢,还是哪些改动能在不同平台间保持含义。
关系代数是 SQL 的“数学底层”:一小套运算符用来描述你想要的结果。杰弗里·乌尔曼的工作让这种运算符视角变得清晰且易于教学——它仍然是大多数优化器的思维模型。
一个数据库查询可以表示为几种构建块的流水线:
WHERE)SELECT col1, col2)JOIN ... ON ...)UNION)EXCEPT)由于集合很小,推理正确性变得更容易:如果两个代数表达式是等价的,那么在任何有效的数据库状态下它们返回相同的表。
看一个熟悉的查询:
SELECT c.name
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.total > 100;
概念上,这可以表示为:
先对 customers 和 orders 做 join:customers ⋈ orders
对 orders 做过滤 o.total > 100:σ(o.total > 100)(...)
投影你想要的列:π(c.name)(...)
这不是每个引擎内部使用的精确符号,但思路是:SQL 被转成一个运算符树。
许多不同的树可以表示相同的结果。例如,过滤器通常可以早些应用(在大连接前应用 σ),投影通常可以尽早丢弃未使用的列(尽早应用 π)。
这些等价规则使数据库在不改变含义的前提下改写查询以获得更低成本成为可能。一旦把查询看成代数,"优化" 就不再是魔术,而是基于规则的重塑。
当你写 SQL 时,数据库并不会“按你写的方式”执行它。它会把语句翻译成一个查询计划:描述要做的工作的结构化表示。
一个好的心理模型是一个运算符树。叶子节点读取表或索引;内部节点转换并组合行。常见运算包括 scan、filter(选择)、project(投影)、join、group/aggregate 和 sort。
数据库通常把规划分成两层:
Ullman 的影响体现在对保持含义的变换的强调:可以在不改变答案的前提下重排逻辑计划多种方式,然后选择高效的物理策略。
在选择最终执行方式之前,优化器会应用代数式的“清理”规则。这些改写不会改变结果;它们旨在减少不必要的工作。
常见例子:
假设你要查询一个国家的订单:
SELECT o.order_id, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.country = 'CA';
一种天真的做法可能会把所有用户与所有订单先连接,然后再过滤出加拿大的用户。保持含义的改写会把过滤下推,从而使连接涉及的行更少:
country = 'CA'order_id 和 total在计划术语中,优化器尝试把:
Join(Users, Orders) → Filter(country='CA') → Project(order_id,total)
变成更接近:
Filter(country='CA') on Users → Join(with Orders) → Project(order_id,total)
答案相同,但工作量更小。
这些改写之所以容易被忽视,是因为你从未手动输入它们——但它们是同一条 SQL 在一个数据库上快、在另一个上慢的主要原因。
当你运行 SQL 时,数据库会考虑多种能返回相同结果的方式,然后选择它预计代价最低的一种。这个决策过程就是基于代价的优化——也是 Ullman 风格理论在日常性能中最直接的体现之一。
代价模型是优化器用来比较不同计划的评分系统。大多数引擎用几个核心资源来估计代价:
模型不需要完美;需要的是在大多数情况下能方向性地选出较好的计划。
在对计划评分前,优化器会在每一步问自己:**这一步会产出多少行?**这就是基数估计。
如果你过滤 WHERE country = 'CA',引擎会估计表中有多少比例符合条件。如果你把客户与订单连接,它会估计有多少对匹配这些键。这些行数的猜测决定了是选择索引扫描还是全表扫描、哈希连接还是嵌套循环、排序会是小规模还是巨大规模。
优化器的猜测依赖于统计信息:计数、值分布、空值率,有时还有列之间的相关性。
当统计过时或缺失时,引擎可能把行数估计错几个数量级。一条在纸面上看起来便宜的计划可能在实际中代价巨大——典型症状包括数据增长后突发的慢查询、计划的“随机”变化或连接意外地溢写到磁盘。
更好的估计通常需要更多工作:更细致的统计、采样或探索更多候选计划。但规划本身也有时间成本,特别是对复杂查询而言。
因此,优化器要在两者间平衡:
理解这一权衡能帮助你读懂 EXPLAIN 输出:优化器不是试图搞花样,而是在有限信息下追求“可预测的正确”。
Ullman 的工作推广了一个简单但强大的观点:SQL 并不是被“执行”,而是被翻译成执行计划。连接处尤为明显:两条返回相同行的查询,根据引擎选择的连接算法和连接顺序,其运行时可以天壤之别。
嵌套循环连接简单直接:对左侧每一行,在右侧找匹配行。当左侧小且右侧有可用索引时可以很快。
哈希连接从一个输入(通常较小的一侧)构建哈希表,再用另一个输入进行探测。它在大型、未排序的等值连接上表现出色,但需要内存;一旦溢写到磁盘,优势会被抹去。
合并连接在两个输入按排序顺序遍历时工作良好。当双方已经按连接键排序(或者索引可以按键顺序返回行)时,合并连接通常很合适。
当涉及三表或更多表时,可能的连接顺序数量呈指数增长。先连接两张大表可能产生巨大的中间结果,使后续所有操作变慢。更好的顺序通常从最具选择性的过滤开始,向外扩展,保持中间结果尽可能小。
索引不仅加速查找——它们还使得某些连接策略可行。连接键上的索引可以把昂贵的嵌套循环变成每行快速寻址的模式。反之,缺失或不可用的索引可能迫使引擎选择哈希连接或为了合并连接进行大量排序。
数据库不仅仅是“运行 SQL”。它们在很多方面是在编译 SQL。Ullman 的影响既跨越数据库理论又跨越编译器思维,这种联系解释了为什么查询引擎像编程语言工具链一样:在做任何真正工作之前先翻译、改写与优化。
当你发送查询时,第一步看起来就像编译器的前端。引擎会把关键字和标识符分词、检查语法并构建一个解析树(通常简化为抽象语法树)。这是捕获基本错误的地方:缺逗号、列名二义性、分组规则无效等。
一个有用的心理模型:SQL 是一种编程语言,它的“程序”恰好是用来描述数据关系,而不是循环或条件分支。
编译器会把语法转换为中间表示(IR)。数据库也类似:它把 SQL 语法翻译为逻辑运算符,比如:
GROUP BY)这个逻辑形式更接近关系代数而不是 SQL 文本,从而更容易推理语义与等价性。
编译器优化在保持程序结果不变的前提下让执行更便宜。数据库优化器做同样的事情,使用类似的规则系统:
这就是数据库版的“死代码消除”:技术不同,但哲学相同——保持语义,降低代价。
如果查询慢了,不要只盯着 SQL 文本。把查询计划当作编译器输出去看。计划告诉你引擎实际选择了什么:连接顺序、索引使用以及时间消耗的具体环节。
实用结论:学会读 EXPLAIN 输出,把它当作性能的“汇编清单”。这会把调优从猜测变成基于证据的调试。想把这变成习惯?参见 /blog/practical-query-optimization-habits。
良好的查询性能通常始于写 SQL 之前。Ullman 的模式设计理论(尤其是范式化)是关于如何构造数据,使数据库在增长时保持正确、可预测且高效。
范式化旨在:
这些正确性收益会转化为性能收益:更少的重复字段、更小的索引和更少昂贵的更新操作。
你不需要记住证明才能使用这些思想:
当你:
关键是有意识地反范式化,并有保持重复同步的流程。
模式设计决定了优化器能做什么。清晰的键与外键使更好的连接策略、更安全的改写和更准确的基数估计成为可能。相反,过度冗余会膨胀索引并放慢写入,多值列阻碍高效谓词。随着数据量增长,这些早期建模决策往往比微调单条查询更加重要。
当系统“扩展”时,问题通常不仅仅是加更大的机器。更常见的难点是:必须在保持查询含义不变的前提下,让引擎选择完全不同的物理策略来保持运行时间可控。Ullman 强调形式等价性的思想正是让这些策略变化在不改变结果的情况下成为可能的基础。
在小规模时,许多计划都“能工作”。到了规模化时,扫描一张表、使用索引或使用预计算结果之间的差别可能意味着秒级与小时级的差距。理论方面重要的是:优化器需要一套安全的改写规则(比如把过滤下推、重排连接),这些规则不会改变答案,即便它们极大地改变了实际执行的工作。
按日期、客户、地区等做分区,会把一个逻辑表变成许多物理片段。这影响规划:
SQL 文本不变,但最佳计划取决于行所在的位置。
物化视图本质上是“保存的子表达式”。如果引擎能证明你的查询与某个已存结果相匹配(或能改写成匹配),它就能把昂贵的工作(重复的连接与聚合)替换为一次快速查找。这正是关系代数思想的实际应用:识别等价并重用结果。
缓存能加速重复读取,但不能拯救必须扫描过多数据、洗牌巨大中间结果或计算巨大连接的查询。遇到扩展问题时,常见的修复是:减少涉及的数据量(布局/分区)、减少重复计算(物化视图),或改变计划——而不是简单地“加缓存”。
Ullman 的影响体现为一种简单心态:把慢查询看作数据库的一条意图声明,数据库可以对它做改写,然后验证它实际上做了什么。你不需要成为理论家就能受益——只需要一个可重复的流程。
从通常主导运行时间的部分入手:
如果只能做一件事,定位行数首次暴增的操作节点。那通常就是根因。
这些写法既容易出现又代价惊人:
WHERE LOWER(email) = ... 可能阻止索引使用(如果支持,可用函数索引或保持标准化列)。关系代数鼓励两个实际动作:
WHERE 来缩小输入。一个好的假设可以是:“这个连接很贵,因为我们连接了太多行;如果先把 orders 过滤到最近 30 天,连接输入会大幅下降。”
用一个简单决策规则:
EXPLAIN 显示可避免的工作(不必要的连接、晚过滤、不可被索引利用的谓词)。目标不是“写聪明的 SQL”,而是让中间结果可预测且更小——正是 Ullman 的等价性思想最容易帮助识别的改进。
这些概念不仅对数据库管理员有用。如果你在交付应用,你在无形中做出数据库与查询规划决策:模式形状、键的选择、查询模式与数据访问层都会影响优化器能做什么。
例如,如果你使用一种快速原型工作流(比如从聊天界面生成 React + Go + PostgreSQL 应用的 Koder.ai),Ullman 风格的思维模型是一个实用的安全网:你可以审查生成的模式是否有干净的键与关系,检查应用依赖的查询,并在上线前用 EXPLAIN 验证性能。你能更快地在“查询意图 → 计划 → 修复”之间迭代,就能从加速开发中获得更多价值。
你不需要把“理论”当做一个单独的爱好来研究。受益 Ullman 基础知识的最快方法是学到足以自信阅读查询计划的程度——然后在自己的数据库上练习。
搜索这些书籍与讲座主题(无任何关联,仅为常见起点):
从小处做起并把每一步与可观测的事物挂钩:
选 2–3 条真实查询并反复迭代:
IN 改为 EXISTS、提前下推谓词、删除不必要列,比较结果。用清晰、基于计划的语言:
这就是 Ullman 基础的实际回报:你获得了一套共享的词汇来解释性能,而不是靠猜测。
杰弗里·乌尔曼帮助形式化了数据库如何“表示查询的含义”以及如何在保证相同结果集的前提下,把查询安全地改写成更快的等价形式。每当数据库引擎重写查询、调整连接顺序或选择不同的执行计划时,你都会看到这套基础理论的影子。
关系代数是一组小而精的运算(select、project、join、union、difference),用于精确定义查询的结果。数据库通常把 SQL 翻译成类似代数的操作树,从而应用等价规则(例如把过滤提前)再选择执行策略。
因为优化必须证明改写后的查询返回的结果与原查询相同。等价规则允许优化器做诸如:
WHERE 过滤放到连接之前这些改动能在不改变含义的情况下显著减少工作量。
逻辑计划描述要计算的“是什么”(过滤、连接、聚合等抽象操作),与存储细节无关。物理计划则选择“如何”执行这些操作(索引扫描 vs 全表扫描、哈希连接 vs 嵌套循环、并行与否、排序策略)。大多数性能差异来自物理选择,而这些物理选择是在完成逻辑改写之后作出的。
代价优化会评估多种合法计划并选择预估代价最低的那个。代价通常由一些实际资源驱动:处理的行数、I/O(磁盘/SSD/缓存)、CPU 与内存(是否溢写磁盘)。
基数估计是优化器对“这一步会产出多少行”的猜测。这些估算决定了连接顺序、连接类型以及是否值得进行索引查找。当估计错误(通常由统计信息过期或缺失导致)时,可能出现计划突变、磁盘溢写或严重慢查询等不可预测的性能问题。
嵌套循环连接:当左侧很小且右侧可以高效查找(例如有索引)时最佳。哈希连接:适用于大型、未排序的等值连接(例如 A.id = B.id),需要内存,溢写会削弱优势。合并连接:当双方都已按连接键排序(或可廉价排序)时表现良好,索引常常能提供这种顺序。关注几个高信号点:
把计划当作编译器输出去看:它显示了引擎实际做了什么,而不是你写的 SQL 表面样子。
范式化减少重复事实与更新异常,从而通常带来更小的表与索引、更可靠的连接。可读性和约束(键、外键)也更清晰。但在某些分析场景或读取密集的场合,有意识的反范式化是合理的——前提是你有明确的同步/刷新策略并能接受受控的冗余。
在规模扩展时,通常需要改变底层的物理策略而保持查询含义不变。常见手段有:
缓存能加速重复读取,但不能解决需要扫描大量数据或产生巨大中间连接的问题。