如果你想理解数据库的工作原理, 最简单的方式就是从一条SQL开始, 看看数据库是如何处理它的.
下面这篇文章来自一位实习生, 他将带来一系列的文章, 深入研究以下主题:
ANALYZE 为什么重要。每篇文章都将探讨实际的实现,帮助你理解 PostgreSQL 不仅做了什么,而且是如何以及为什么这样做。
这是第一篇, “SQL在PG中的公路之旅”. 看完本篇, 下次你运行查询时,你将清楚地知道它正在穿越 PostgreSQL 内部的哪段旅程。随着我们在后续文章中探索每个组件,这些知识将深化为你真正的专业技能。
原文: https://internals-for-interns.com/posts/sql-query-roadtrip-in-postgres/
你是否曾好奇,当你输入 SELECT * FROM users WHERE id = 42; 并按下回车键时,究竟发生了什么?这个简单的查询触发了一段穿越 PostgreSQL 内部机制的迷人旅程 —— 一系列涉及多个进程、复杂的内存管理和数十年优化研究的复杂操作。
这是本系列文章的第一篇,我们将深入探讨 PostgreSQL 的查询执行过程。在这篇概述中,我将为你展示从 SQL 文本到结果的完整旅程,为你提供一张路线图。然后,在后续文章中,我们将深入探究每个组件—— 解析器(parser) 、 分析器(analyzer) 、 重写器(rewriter) 、 规划器(planner)和执行器(executor) —— 探索它们各自的工作细节。
理解这段旅程将彻底改变你编写高效查询、诊断性能问题以及思考数据库开发的方式。让我们从宏观的角度开始。
在我们深入探究之前,让我们先了解一下方向。每个查询都会经历 PostgreSQL 内部相同的基本路径:
解析(Parsing) → 分析(Analysis) → 重写(Rewriting) → 规划(Planning) → 执行(Execution)
就是这样。五个阶段。你的 SQL 文本从一端输入,结果从另一端输出。但在每个阶段内部,都有很多事情发生。让我们追踪这段旅程。
让我们从提交查询的那一刻起追踪它的轨迹。你的应用程序与 PostgreSQL 服务器建立连接,并开始使用 PostgreSQL 的通信协议发送查询。
有一点很重要:当你发送 SELECT * FROM users WHERE id = 42; 时,PostgreSQL 接收到的就是你输入的内容——纯文本。你的 SQL 语句以字符串的形式到达,和你写的一模一样。无论你是在 psql 中输入、从应用程序运行查询,还是使用 ORM,情况都是如此。
PostgreSQL 接收到这段文本后,会进行一些快速的验证——确保它编码正确且格式良好。然后,真正的工作就开始了。
解析器是第一站。它的工作是将你的 SQL 文本转换成一个结构化的 解析树(parse tree) 。
解析器逐个字符地读取你的查询,并应用 PostgreSQL 的 SQL 语法规则。它识别出关键词(SELECT、FROM、WHERE)、表名、列引用、运算符——所有查询的句法组成部分。
可以把它想象成一位分析句子的语言老师。解析器识别出名词、动词和从句,但它尚不理解它们在你的数据库上下文中的含义。
在解析 SELECT name FROM users WHERE id = 42 之后,PostgreSQL 知道:
SELECT 子句,带有一个列引用(name)FROM 子句,带有一个表引用(users)WHERE 子句,带有一个比较(id = 42)但它并不知道 users 表是否存在,name 是否是一个有效的列,或者涉及哪些数据类型。那是下一个阶段的工作。
分析器将解析树转换成一个 经过语义验证的查询树(semantically validated query tree) 。这是 PostgreSQL 从理解语法转向理解含义的地方。
分析器会做几件关键的事情:
users,以验证表是否存在。它检查 name 和 id 是否是该表中的真实列。id = 42 是否有意义——id 是一个数字列吗?它可以与整数 42 进行比较吗?是否存在适用于这些类型的比较运算符?users 表以及你正在访问的特定列是否拥有 SELECT 权限。users 指的是一个具有特定 OID(对象标识符)的特定表,name 是一个文本列,id 是一个整数列。如果任何地方失败了——表不存在、列名拼写错误、你缺少权限——分析器就会抛出错误,查询会在此停止。
既然 PostgreSQL 已经理解了我们的查询的含义,它需要在优化开始之前检查是否应该应用任何自动转换。
重写器接收我们经过语义验证的查询树,并应用自动转换。最常见的例子包括:
active_users 是一个定义为 SELECT * FROM users WHERE status = 'active' 的视图,那么查询 SELECT * FROM active_users 将被重写为直接访问 users 表并带上相应的过滤条件。WHERE 条件来强制执行这些策略。如果有一个策略限制了按租户的访问,你的查询可能会变成 SELECT * FROM users WHERE id = 42 AND tenant_id = 123。对于大多数简单查询,重写器不会做太多改变。但对于带有视图和安全策略的复杂应用程序来说,这个阶段可以显著地改变你的查询。
应用了所有转换后,PostgreSQL 现在拥有了需要执行的最终查询。下一个挑战是确定执行它的最有效方式。
我们来到了最精妙的部分: 规划器(planner) 。它的工作是回答这个问题:“执行这个查询最有效的方式是什么?”
规划器会评估多个维度来做出这个决定。让我们看看它考虑的关键因素:
对于查询中的每个表,规划器决定如何检索数据。我们是应该逐行扫描整个 users 表,还是可以使用 id 上的索引直接跳转到匹配的行?
规划器检查每个表所有可用的索引,并评估哪种访问方法将是最快的。有时,索引查找显然是赢家。而在其他时候——特别是对于小表或当你需要大多数行时—— 顺序扫描(sequential scan) 实际上更快,因为它避免了索引导航的开销。
如果你的查询涉及多个表,规划器会独立地为每个表做出这个决定。
当你的查询连接多个表时,规划器面临两个关键决策:我们应该以什么顺序连接它们,以及每个连接应该使用哪种连接算法?
连接顺序至关重要。先连接表 A 和 B,然后再添加 C,可能比先连接 B 和 C,然后再添加 A 要快得多或慢得多。规划器会考虑不同的顺序,以找到最有效的路径。
对于每个连接,规划器还会选择一种算法。PostgreSQL 有三个主要选项:
规划器会评估哪种顺序和算法的组合能最大限度地降低你的特定查询和数据规模的总成本。
所有这些规划决策都依赖于一个关键要素:统计信息。规划器需要知道它正在处理多少数据才能做出良好的选择。
PostgreSQL 通过 ANALYZE 命令收集和维护有关你的表的统计信息。对于每个表,它会跟踪以下信息:
这些统计信息回答了诸如“如果我按 id = 42 过滤,会有多少行匹配?”或“如果我连接这两个表,结果是 10 行还是 1000 万行?”这样的问题。
如果没有准确的统计信息,规划器就会盲目行动。它可能会在顺序扫描更快时选择索引,或者在哈希连接效果更好时选择嵌套循环连接。定期运行 ANALYZE——或启用 autovacuum 自动执行——对于良好的性能至关重要。
有了所有这些信息,规划器会评估不同的执行策略。对于每个可能的计划,PostgreSQL 会基于以下因素计算一个估算成本:
规划器会探索多种选项,并选择估计成本最低的一个。对于只有少数表的简单查询,它可以穷尽地评估所有可能性。但是当你的查询连接很多表时,可能的连接顺序数量会呈指数级爆炸式增长。在这些情况下,PostgreSQL 会切换到一个 遗传查询优化器(Genetic Query Optimizer) ,它使用启发式方法来找到一个好的计划,而无需评估每一种可能性。我们将在未来的文章中探讨这种遗传算法的工作原理。
一旦规划器确定了最佳方法,结果就是一个 执行计划(execution plan) —— 一个操作树,精确地指定了如何检索你的数据。
对于我们简单的查询 SELECT name FROM users WHERE id = 42,计划可能就是:“使用 id 上的索引来找到匹配的行,然后从该行中获取 name 列。”
你可以使用 EXPLAIN 自己查看这个计划:
EXPLAIN SELECT name FROM users WHERE id = 42;
这会向你展示规划器的决策过程和成本估算,这对于理解和优化查询性能是无价的。
有了最优的执行计划,PostgreSQL 终于准备好检索我们的数据并返回结果了。
执行器接收你的执行计划并产生结果。PostgreSQL 使用 拉取式执行模型(pull-based execution model) 。
拉取式是什么意思?不是每个操作都主动将数据推送到下一个操作,而是每个操作都向其子操作请求数据。可以把它想象成一个服务员链条——你向服务员要食物,服务员向厨房要,厨房向食品储藏室要,以此类推。数据只有在被请求时才会流动。
对于我们的查询 SELECT name FROM users WHERE id = 42,它是这样工作的。执行计划可能看起来像:
QUERY PLAN
-----------------------------------------------------------
Index Scan using users_id_idx on users (cost=0.00..8.27 rows=1 width=64)
Filter: (id = 42)
(2 rows)
当执行开始时:
users_id_idx 索引来查找 id = 42。这种拉取式模型是内存高效的,因为 PostgreSQL 只在实际需要数据时才处理数据。它也使得实现限制(limits)和提前终止变得简单——只需停止请求更多的行。
执行器逐行处理结果,根据 线协议(wire protocol) 对其进行格式化,并通过网络连接发送给你的客户端。
一旦所有结果都被发送,PostgreSQL 会自动:
你的后端进程现在已准备好处理下一个查询。
让我们用 SELECT name FROM users WHERE id = 42 再次追溯我们完整的旅程:
每个查询都遵循相同的路径——无论是简单还是复杂,区别仅在于每个阶段发生了多少工作。
理解这个流程有助于你:
EXPLAIN 来查看哪个阶段出现了问题。是规划器选择了错误的路径吗?是统计信息过期了吗?是缺少索引吗?SELECT 语句背后,发生了很多事情。PostgreSQL 透明地处理着连接管理、内存分配、语法验证、语义分析、规则应用、基于成本的优化和拉取式执行。这篇概述在高层次上涵盖了完整的旅程,为你提供了 PostgreSQL 如何将 SQL 转换成结果的路线图。但每个阶段都有引人入胜的细节值得深入探索。
在接下来的几个月里,我们将深入研究以下主题:
ANALYZE 为什么重要。每篇文章都将探讨实际的实现,帮助你理解 PostgreSQL 不仅做了什么,而且是如何以及为什么这样做。
下次你运行查询时,你将清楚地知道它正在穿越 PostgreSQL 内部的哪段旅程。随着我们在后续文章中探索每个组件,这些知识将深化为你真正的专业技能。