SQL 可读只是起点:注入、NULL、方言与执行计划
SQL 在 1986 年被标准化为 ANSI X3.135、1987 年为 ISO 9075。标准每五年左右修订一次(SQL:1992、:1999、:2003、:2008、:2011、:2016、:2023),任何时刻都没有任何主流数据库**完整**实现过它。你真正会遇到的方言——PostgreSQL、MySQL、SQLite、T-SQL、Oracle、BigQuery、Snowflake、Spark、ClickHouse——在最核心的 80% CRUD 上一致,在几乎所有其他细节上都不一致。一个不知道自己面对的是哪种方言的 SQL 格式化器,就是在猜。
SQL 拥有计算机领域最成功的语法——它活过了四代框架,今天每位数据分析师、工程师乃至每个有数据库环节的 AI 系统都仍在伸手去拿它。它也拥有几乎是当前所有在用语言中单位面积遗留怪癖最多的那一个。两件事来源相同:SQL 比 C 还老,比我们今天理解的 Unix 还老,而且它被设计在那样一个市场里——1970 年代末的关系数据库厂商明确地想要"拥抱-扩展-消灭"对方的语法。
这篇是写给"会跨多个系统读 SQL,希望那些反复出现的困惑停止变成意外"的人。
SQL 到底是什么
SQL 是关系型数据的声明式查询语言。你描述你想要的结果,数据库优化器决定怎么算。每条 SQL 语句本质上是其中一类:
- DML(数据操作语言):
SELECT、INSERT、UPDATE、DELETE、MERGE。 - DDL(数据定义语言):
CREATE、ALTER、DROP、TRUNCATE,作用于表、索引、视图、schema。 - DCL(数据控制语言):
GRANT、REVOKE。 - TCL(事务控制语言):
BEGIN、COMMIT、ROLLBACK、SAVEPOINT。
SELECT 语句是用得最多、也被误解得最多的。它在标准里的(简化)完整文法:
SELECT [DISTINCT] columns
FROM tables
[JOIN ... ON ...]
WHERE row_predicate
GROUP BY grouping_columns
HAVING group_predicate
ORDER BY columns
LIMIT n OFFSET m
写过几次 SQL 的人都知道这个顺序。他们不知道的是:数据库不按这个顺序执行。
clause 的执行顺序
你写 SQL 的 clause 顺序是为了人类方便。数据库处理它们的顺序(概念上)是:
FROM—— 选表,做 join。WHERE—— 过滤行。GROUP BY—— 把行收进分组。HAVING—— 过滤分组。SELECT—— 计算输出列。DISTINCT—— 去重。ORDER BY—— 排序。LIMIT/OFFSET—— 分页。
由这个顺序衍生出三条结果,它们解释了大多数"这查询为什么不对"的问题:
- 不能在
WHERE里引用SELECT的别名。 WHERE 求值时别名还不存在。PostgreSQL 严格不允许;某些方言(MySQL、BigQuery)作为扩展允许。 - 可以在
ORDER BY里引用SELECT的别名。 ORDER BY 在 SELECT 之后跑。 HAVING作用在分组上;WHERE作用在行上。 用COUNT(*) > 5过滤属于 HAVING;用country = 'US'过滤属于 WHERE——把它放进 HAVING 也能跑,但更慢,因为你把要丢的行带过了 GROUP BY。
执行顺序是概念上的;真正的优化器会激进重排追求性能。但上述依赖规则是真实语义,不是单纯优化。
NULL 不是你以为的那个
NULL 是 SQL 的"未知"。它不是 0,不是空字符串,不是 false。它的语义会以诡异的方式传播:
SELECT NULL = NULL; -- NULL(不是 true!)
SELECT NULL <> NULL; -- NULL
SELECT NULL = ''; -- NULL
SELECT NULL + 1; -- NULL
SELECT NULL || 'x'; -- NULL(Postgres)
SELECT 1 IN (1, 2, NULL); -- TRUE
SELECT 1 NOT IN (1, 2, NULL); -- NULL —— 不是 FALSE
规则是:任何涉及 NULL 的运算都返回 NULL(少数有文档的例外:COALESCE、IS NULL、IS NOT NULL)。
最致命的是子查询返回 NULL 时的 NOT IN:
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM banned);
如果 banned 里有一个 NULL user_id,这条查询永远返回零行。因为对任意 id,id NOT IN (..., NULL, ...) 求值为 NULL,不是 TRUE,于是 WHERE 拒绝该行。每家公司都有人把这个 bug 上过线。
防御:优先 NOT EXISTS 胜过 NOT IN;或者过滤掉子查询里的 NULL:WHERE id NOT IN (SELECT user_id FROM banned WHERE user_id IS NOT NULL);或用 left anti-join。
另一个 NULL 惊喜:COUNT(*) 包含 NULL;COUNT(column) 不包含。COUNT(DISTINCT column) 也丢 NULL。想"数这一列被设置过的行"用了错的 COUNT 的人,本意是 COUNT(*),结果被算成 COUNT(column)。
排序与分组把 NULL 当成同一组,虽然 NULL = NULL 是 NULL。一致性不是 SQL 的设计目标。
JOIN:四种类型与笛卡儿陷阱
五种 join 类型,越往下越吓人:
INNER JOIN(或简写JOIN):两表谓词都满足的行。LEFT JOIN(或LEFT OUTER JOIN):左表所有行 + 右表匹配行(无匹配处为 NULL)。RIGHT JOIN:LEFT 的镜像。避免使用——人从上往下读,反向 join 会让人困惑。改写成 LEFT。FULL OUTER JOIN:两边所有行,无匹配处填 NULL。MySQL 8.0 之前不直接支持;用LEFT JOIN UNION ALL RIGHT JOIN模拟。CROSS JOIN(或老语法的,):左表每行 × 右表每行,行数O(N*M)。
陷阱:忘了 join 谓词。
SELECT * FROM orders, customers;
这是 CROSS JOIN。如果 orders 一百万行、customers 十万行,你刚刚要了一千亿行。多数优化器真的会开始产出。 ANSI 风格的 JOIN ... ON 缺谓词时会喧哗;逗号风格沉默。别用逗号 join。
另一个微妙点:LEFT JOIN ... ON vs LEFT JOIN ... WHERE,谓词不等价。
-- A:保留每个 order,即使没有匹配的 shipment
SELECT * FROM orders o
LEFT JOIN shipments s ON s.order_id = o.id AND s.status = 'sent';
-- B:丢掉那些 shipment 状态非 'sent' 或者缺失的 order
SELECT * FROM orders o
LEFT JOIN shipments s ON s.order_id = o.id
WHERE s.status = 'sent';
B 里 WHERE 把 LEFT JOIN 又变回了 INNER JOIN——因为不匹配时 s.status 是 NULL,NULL = 'sent' 是 NULL,不通过 WHERE。人写 B、心里想着 A,是天天在发生的事。
方言地图
SQL 标准在关键字上字面一致;方言在以下细节上都不同:
- 标识符引号。 标准:双引号(
"my column")。MySQL:反引号。T-SQL:方括号[...],或开了SET QUOTED_IDENTIFIER ON后双引号。Postgres:双引号,大小写敏感。 - 字符串字面量。 标准:单引号。MySQL 默认双引号也可作字符串,除非开
ANSI_QUOTES。 - 字符串拼接。 标准:
||。MySQL:CONCAT(...)(开PIPES_AS_CONCAT才支持||)。T-SQL:+。 LIMIT n OFFSET m在 Postgres / MySQL / SQLite 通用。T-SQL 用OFFSET m ROWS FETCH NEXT n ROWS ONLY(2012+)。Oracle 12c+ 类似FETCH FIRST。- 自增。 Postgres:
SERIAL(旧)或GENERATED ... AS IDENTITY(推荐)。MySQL:AUTO_INCREMENT。SQLite:INTEGER PRIMARY KEY隐式 rowid。T-SQL:IDENTITY(1,1)。 - Upsert。 Postgres:
ON CONFLICT ... DO UPDATE。MySQL:ON DUPLICATE KEY UPDATE。SQLite:ON CONFLICT(类似 Postgres)。T-SQL:MERGE(带脚注——微软已记录足够多 MERGE bug 让谨慎用户更愿意写原子 INSERT+UPDATE)。 - 布尔类型。 Postgres:原生
BOOLEAN。MySQL:BOOLEAN是TINYINT(1)别名。SQL Server:没有 boolean,用BIT。SQLite:任意表达式结果。 - JSON 函数。 Postgres:
->、->>、jsonb_*,成熟。MySQL 5.7+:JSON_EXTRACT、->,类似但运算符语义有差。SQLite:json_extract。SQL Server:JSON_VALUE、JSON_QUERY。BigQuery、Snowflake:各家自己。别期望可移植。 - 日期算术。 Postgres:
now() - interval '1 day'。MySQL:DATE_SUB(NOW(), INTERVAL 1 DAY)。T-SQL:DATEADD(DAY, -1, GETDATE())。SQLite:datetime('now', '-1 day')。
不锁定方言就格式化 "SQL" 的工具,至少在某些输入上会默默地切错 token。本站工具显式支持 14 种方言,正因为这是关键字列表、函数名、标识符规则保持正确的唯一办法。
SQL 注入
把这条规则讲到第一百万次,因为每一拨新程序员都要重学一次:永远不要用字符串拼接把用户输入构造成 SQL。
# 灾难性错误
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")
# 正确
cursor.execute("SELECT * FROM users WHERE name = %s", (name,))
错误版本里,给定 name = "'; DROP TABLE users; --",构造出:
SELECT * FROM users WHERE name = ''; DROP TABLE users; --'
正确版本把 SQL 与参数分别发给数据库;数据库先解析 SQL 一次,把参数作为数据绑定,永远不当代码。没有任何"聪明的转义函数"是安全的——唯一安全路径是参数化查询(也叫预编译语句、绑定变量)。
容易漏掉的边角:
- 标识符插值。 参数绑定的是值,不是列名 / 表名。
SELECT * FROM ?不行。如果必须动态指定表名,对白名单做校验之后再做字符串拼接。 LIKE通配符。 用户输入可能含%、_,是通配符。用应用代码或方言函数转义。- ORM 注入。 多数 ORM 默认安全,但都提供原生 SQL 逃生口。审计逃生口。
- 存储过程。 用
EXEC+ 拼接输入构建动态 SQL 的存储过程,注入风险等同于应用代码。 - 二阶注入。 把恶意字符串先存起来,之后取出执行(比如从日志表)。防御:在使用时参数化,不只是输入时。
第一代注入防御(把 ' 转 '')可证明地不完整,永远不要依赖。用参数化查询、用 ORM;非动态 SQL 不可时,结构由代码控制的拼接做,值绑定分开做。
性能:格式化器帮不上忙的部分
漂亮的 SQL 仍然可以是慢 SQL。常见失败模式:
- JOIN 列、WHERE 过滤列、ORDER BY 列上没索引。 一直看执行计划(Postgres 的
EXPLAIN ANALYZE、MySQL 的EXPLAIN、SSMS 的查询计划)。 - 隐式类型转换让索引失效。
WHERE phone = 5551234,而phone是VARCHAR,MySQL 强转方式可能让全表扫描。类型字面对齐。 - 生产代码里
SELECT *。 ad-hoc 没事;放进代码里就是定时炸弹——三年后有人加了个BLOB列,查询慢 100 倍。 - N+1 查询。 ORM lazy-load 关系造成的循环。格式化器看得到 SQL 但看不到循环。
OR谓词 让查询走两次索引扫描。有时改写成两个 SELECT 的UNION ALL更快。- WHERE 左侧带函数。
WHERE LOWER(name) = 'alice'用不了name的索引。要么建函数索引,要么单独存一列小写值。
格式化只让 bug 变得清晰可见,不会让它变快。
常见坑
NOT IN配会返回 NULL 的子查询。 改写为NOT EXISTS。COUNT(column)用错了,应该是COUNT(*)。GROUP BY用列序号。GROUP BY 1在 MySQL / Postgres / BigQuery 能用;ANSI 标准要求具名列;T-SQL 也允许。用名字更清楚。- 忘了 ON clause 造成隐式 cross-join。
- 聚合与非聚合混用而没分组。 Postgres 拒绝;MySQL 5.7 之前安静地选一行。正确写
GROUP BY。 - 跨时区的日期算术。 永远存 UTC。在应用层显示时再转用户时区。Unix 时间戳那篇博客讲得更深入。
- MySQL 里
0 = ''。 隐式转换又来。字符串引起来。 COUNT(DISTINCT a, b)。 Postgres 把 NULL 当成 distinct;其他方言不一定。检查。- 加锁惊喜。
SELECT ... FOR UPDATE在 MySQL InnoDB、Postgres、SQL Server 之间语义有微妙差。读自己引擎的文档。 - T-SQL 的 MERGE 在并发下有有据可查的 bug。Aaron Bertrand 的 "Use Caution with SQL Server's MERGE Statement" 是 SQL Server 写 MERGE 的必读。
把 SQL 读好
如果有人把一份 200 行的查询丢给你让你 debug,操作顺序是:
- 格式化。缩进 clause、对齐 JOIN、关键字大小写一致。
- 找出 FROM 与 JOIN 结构。复杂的话画在纸上。
- 读 WHERE 过滤、再读 GROUP BY 聚合、再读 HAVING 后过滤。
- SELECT 最后看——它是输出,不是逻辑。
- ORDER BY 与 LIMIT 是表现,不是查询语义。
大多数"这查询到底干啥"的问题,都解析成"格式化器把 clause 拆开后结构变可见了"。这就是 SQL 格式化器最大的价值:不让查询变快,不让查询变正确,只让它可读到 debug 的人能找到 bug。
格式化之外,真正能让你不踩坑的规则:用户输入永远参数化、把 NULL 当第三种值(不是 falsy)、NOT EXISTS 胜过 NOT IN、永远不信逗号 join、不要指望核心 CRUD 之外的东西在方言间可移植。
主要参考资料
用于核对本文技术细节的标准与官方文档。
为任意主流方言格式化 SQL
本站 SQL 格式化工具支持 14 种方言(PostgreSQL、MySQL、SQLite、T-SQL、PL/SQL、BigQuery、Redshift、Spark SQL 等),可配置缩进、关键字大小写、压缩。当 CI 给你扔过来一行 200 列嵌套 CTE 时格外有用。所有运算在浏览器内。
打开 SQL 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。