数据库指南

把 SQL 格式化成能评审的样子

按子句结构而不是行顺序读查询、方言相关的标识符与大小写折叠、能扛住评审的排版方式,以及格式化能暴露的那一类缺陷 —— 还有它回答不了的性能问题。

格式化查询不是装饰。一条从 ORM 日志里抠出来的单行语句,和把子句逐层堆开的同一条语句,携带的信息完全一样,但只有其中一种能被人评审。本文要讲的是「堆开之后你能看见什么」:解释了 SQL 一半报错的求值顺序、各方言彼此不同的引用规则,以及那一小撮在子句对齐瞬间就显形的缺陷。

查询不是按书写顺序求值的

SQL 写的时候 SELECT 在最前,求值的时候却是 FROM 在最前。逻辑顺序是:FROM 及其连接,然后 WHERE,然后 GROUP BY,然后 HAVING,然后窗口函数,然后 SELECT,然后 DISTINCT,然后 ORDER BY,最后 LIMIT 和 OFFSET。数据库可以用任何能得到相同结果的次序去执行,但定义语义的是这个逻辑顺序,而它解释了人们踩到的相当一部分报错。

它解释了为什么在 SELECT 中定义的列别名不能在 WHERE 里用:WHERE 求值时 SELECT 还没跑,别名根本不存在。它也解释了为什么同一个别名在 ORDER BY 里通常可以用 —— ORDER BY 在 SELECT 之后。它还解释了为什么 HAVING 能对聚合结果过滤而 WHERE 不能,以及为什么把谓词从 HAVING 挪到 WHERE 可能改变结果而不只是「优化」:WHERE 在分组前剔除行,HAVING 在分组后剔除组。

格式化在这里之所以重要,是因为缩进让子句边界可见,而子句边界就是求值阶段。一旦每个关键字都另起一行、它的参数都缩进在下面,自上而下读这条查询就等于沿着逻辑管道走一遍。这才是格式化的真正理由:不是为了整齐,而是为了看清每个表达式属于哪一个阶段。

这也给了你阅读陌生查询的策略。从 FROM 开始,一层层把行集搭起来:主表是谁、每个连接补进或筛掉了什么、WHERE 去掉了什么、GROUP BY 折叠了什么。读完这些再去看 SELECT —— 它是最后一步变换,而不是第一步。

能扛住评审的排版

流传下来的排版约定,都是那些能让差异变小、让错误显形的约定。每个主要子句关键字顶行书写;它的参数缩进一级;每个连接单独一行并把 ON 条件带在身边;WHERE 里每个 AND 各占一行。这里没有一条是审美偏好 —— 每条规则都让某一类改动表现为一行差异,而不是一整块重排。

前置逗号值得忍受最初的不适。逗号放在行尾时,在 SELECT 列表末尾加一列会碰到两行:新增的那行,以及现在需要补逗号的上一行。逗号放在行首时,只碰一行。调试时临时注释掉某一列同理 —— 而那恰恰是你凌晨三点真正会做的操作。团队在这件事上会分裂,唯一确定错误的做法是在同一个文件里改到一半换风格。

缩进深度应当只反映嵌套深度。子查询或 CTE 的主体相对父级缩进一级,读起来是对的;而把子查询对齐到某个任意列位,只要有人改个表名就立刻不可读。当查询长过一屏时,优先用公共表表达式而不是嵌套子查询,因为具名 CTE 给每个阶段起了名字,把金字塔变成了清单。

最后,把格式化放进工作流,而不是当成一次性清理。一个只格式化过一次、之后全靠手改的迁移文件必然会漂移,下一次格式化产生的差异会把真实改动和空白重排混在一起。改成保存时格式化或放进 pre-commit 钩子,这个问题就不会发生。

同一条查询,排成可评审的样子
-- 难以评审:子句边界不可见。
select c.id, c.name, count(o.id) orders, sum(o.total) revenue from customers c
left join orders o on o.customer_id = c.id and o.status = 'paid' where c.region
in ('eu','uk') and c.created_at >= '2026-01-01' group by c.id, c.name having
count(o.id) > 0 order by revenue desc limit 25;

-- 可评审:一子句一行,前置逗号,连接自带 ON。
SELECT
    c.id
  , c.name
  , COUNT(o.id)  AS orders
  , SUM(o.total) AS revenue
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status = 'paid'
WHERE c.region IN ('eu', 'uk')
  AND c.created_at >= '2026-01-01'
GROUP BY
    c.id
  , c.name
HAVING COUNT(o.id) > 0
ORDER BY revenue DESC
LIMIT 25;

标识符、引用与大小写折叠

标识符的处理是格式化真正可能把查询改坏的地方,而各方言在两个互相独立的维度上都不一样:用什么字符给标识符加引号,以及不加引号的标识符会被怎么处理。第二个才是危险的那一半,也正是人们会忘掉的那一半。

SQL 标准规定不带引号的标识符折叠为大写,Oracle 和 DB2 遵循它。PostgreSQL 则折叠为小写,这是一处有明确文档记载的偏离,而且在实践中远比标准行为常见。实际效果是:在 PostgreSQL 里,不加引号写出的 userId 会变成 userid —— 如果这一列当初是带引号以 "userId" 创建的,那么两者就不再指同一个东西。这正是「同一条 ORM 生成的查询在某个环境正常、换个环境就报错」的来源。

MySQL 又是另一套:列名和别名不区分大小写,而表名是否区分则取决于 lower_case_table_names,再往下还取决于文件系统本身是否区分大小写。同一份 schema,在开发者的 macOS 笔记本上是一种行为,在 Linux 服务器上可能是另一种。SQL Server 把这件事交给数据库排序规则决定,通常不区分大小写,但并不必然如此。

由此得出的规则很简单:格式化器可以改关键字的大小写,绝不能改标识符引号内部任何内容的大小写。检查格式化结果时,带引号的标识符和字符串字面量是仅有的两样需要逐字符与原文比对的东西,其余都是空白。

中间一列是造成静默失败的那一列,它描述的是不加引号书写的标识符会发生什么。

方言不加引号时折叠为标识符引号备注
PostgreSQL小写"order"带引号的标识符区分大小写,必须完全一致
Oracle / DB2大写"ORDER"遵循 SQL 标准,带引号的名字通常是全大写
MySQL / MariaDB不折叠;列名比较时不区分大小写`order`未开启 ANSI_QUOTES 时双引号表示字符串字面量
SQL Server取决于数据库排序规则[order]开启 QUOTED_IDENTIFIER 时也接受双引号
SQLite不折叠;ASCII 范围内比较时不区分大小写"order"出于兼容性也容忍反引号和方括号

关键字大小写只是约定,仅此而已

在所有主流方言中 SQL 关键字都不区分大小写,所以 SELECT、select 和 SeLeCt 对解析器而言完全相同。「关键字大写、标识符小写」这个近乎通用的约定之所以存在,是因为它给眼睛提供了第二个信号:不必解析整行,扫一眼大写字母就能找到子句边界。

少数派风格主张全部小写,理由是既然有了语法高亮就不必再喊了,这完全站得住脚。站不住脚的是在同一个代码库里两种混用 —— 那时大小写不再携带任何信息,而每次差异里都掺着噪音。选定一种,写进格式化器配置,让工具去执行。

有一点要当心:某些格式化器的关键字大小写设置同时会作用于内置函数名和数据类型,还有少数会毫不犹豫地把恰好与保留字同名的标识符也转成大写。如果你的 schema 里有叫 order、value 或 user 的列,第一次格式化之后请专门检查它们。

子句堆开之后能看见什么

格式化的回报,是一小撮在单行里几乎看不见、在子句堆开后几乎一眼可见的缺陷。最常见的是外连接被悄悄变成了内连接:LEFT JOIN 会保留左表的行并在右侧补 NULL,而 WHERE 子句里针对右表列的谓词又恰好把这些行全部丢弃 —— 因为 NULL 不满足任何等值比较。查询上仍然写着 LEFT JOIN,行为却已经不是了。把这个谓词挪进 ON 子句就能恢复本意。

子句一旦对齐,另外几种模式也变得可扫描。逗号分隔的 FROM 列表缺少连接谓词就是笛卡尔积,把表堆开之后「缺失的条件」表现为一处空白,而不是一个藏起来的遗漏。SELECT 里的关联子查询会落在自己的缩进层级上,你也就是在那里注意到它每输出一行就要跑一次。针对可能返回 NULL 的子查询使用 NOT IN 会一行都返回不了,这是个 NULL 语义陷阱:用自然语言描述时毫无破绽,把子查询单独拆开就看得见。

格式化还能让聚合错误显形。SELECT 里每一个非聚合列都必须出现在 GROUP BY 中,当两份列表上下堆叠时,一眼就能比对。PostgreSQL 会拒绝不匹配的写法;MySQL 历史上则接受它,并从每组里任取一行返回 —— 这是个不会有任何报错提示的正确性 bug。

这些都不是格式化器在找 bug。格式化器只是让结构可见,找 bug 的是读代码的人。这恰恰是格式化应当发生在评审之前而不是之后的原因。

那个「不是 LEFT JOIN 的 LEFT JOIN」
-- 本意:列出每一位客户,若有已支付订单则一并带出。
-- 实际:没有已支付订单的客户被丢掉了,因为对他们来说
-- o.status 是 NULL,而 NULL = 'paid' 永远不成立。
SELECT c.id, c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

-- 正确写法:这个条件属于连接,而不属于行过滤。
SELECT c.id, c.name, o.total
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status = 'paid';

-- 唯一适合把条件写进 WHERE 的情形,是刻意的反连接:
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

格式化回答不了的性能问题

格式化过的查询更容易推理,仅此而已。格式化不包含任何关于表规模、索引定义、统计信息、数据分布或优化器将选择何种计划的信息,所以它既不能让查询变快,也无法预测哪条查询慢。能回答这些问题的只有 EXPLAIN,而且最好是在贴近真实数据量的环境下执行 EXPLAIN ANALYZE —— 为一千行选出的计划,放到一千万行上往往是错的。

这里有个具体的陷阱:格式化过的查询看上去很专业,而「专业」很容易被误读成「高效」。一个排得整整齐齐的五表连接,如果 select 列表里塞着关联子查询,那它依然是 select 列表里的关联子查询。真正慢的那条查询,通常是谓词用不上索引的那条 —— 列被函数包住、LIKE 以通配符开头、bigint 列与字符串参数之间发生隐式类型转换,或者跨两张表的 OR 阻断了索引合并。这些在格式化之后没有一个看起来像是错的。

诚实的分工是:格式化服务于正确性评审和可读性,EXPLAIN 服务于性能。做了前者并不减少对后者的需要。如果一条查询重要到值得仔细格式化,那它通常也重要到值得看一眼执行计划。

最后说一句压缩,它是同一件工具的反向用法。把查询压成单行不会让数据库的解析快到能测出来:解析耗时相对于计划生成和执行可以忽略,而且多数驱动本来就会缓存预编译语句。只有当查询必须塞进字符串字面量或单个日志字段时才压缩它,仓库里留格式化版本。

  • 在评审之前格式化,而不是之后 —— 目的就是让人看见子句结构。
  • 任何一次重排之后,把带引号的标识符和字符串字面量与原文比对,其余都只是空白。
  • 看到 LEFT JOIN 的右表列出现在 WHERE 谓词里,先按 bug 处理,再去证明它不是。
  • 方言要设成真正执行这条语句的数据库,而不是你平时用得最多的那个。
  • 用文本比较工具比对两份格式化后的版本,而不是靠肉眼并排读。
  • 需要把查询内嵌进应用代码时,用转义工具一次性转义,别手工去改引号。

要点回顾

  • 按逻辑顺序读查询 —— FROM、WHERE、GROUP BY、HAVING、SELECT、ORDER BY —— 因为这个顺序解释了 SQL 的大部分报错。
  • 格式化器可以改关键字大小写,但绝不能改带引号标识符的大小写;而 PostgreSQL 把不带引号的名字折叠为小写,是标识符「突然指向别处」最常见的原因。
  • 让每个子句关键字顶行、每个连接和每个 AND 各占一行,仅这一个习惯就能让「LEFT JOIN 被 WHERE 错误过滤」暴露出来。
  • 关键字大小写纯属显示约定,所以选定一种、配置一次,并且永远不要在一个代码库里混用两种。
  • 格式化不能证明任何性能结论;请在贴近真实的数据上跑 EXPLAIN,并对「把列包进函数」和「以通配符开头的 LIKE」这类谓词保持警惕。

继续了解相关检查与工具