SQL 是少数"写完就交给别人读"的代码。一份查询在仓库里躺三年,中间被五个人改过,排版规范的价值就体现在那时——能不能三十秒内定位到 WHERE 里多加的那个条件。
压缩版:
select o.id,o.created_at,u.name,sum(oi.amount) total from orders o join users u on u.id=o.user_id join order_items oi on oi.order_id=o.id where o.status='paid' and o.created_at>='2024-01-01' group by o.id,o.created_at,u.name having sum(oi.amount)>1000 order by total desc limit 20
排版后:
SELECT
o.id,
o.created_at,
u.name,
SUM(oi.amount) AS total
FROM orders AS o
JOIN users AS u
ON u.id = o.user_id
JOIN order_items AS oi
ON oi.order_id = o.id
WHERE o.status = 'paid'
AND o.created_at >= '2024-01-01'
GROUP BY o.id, o.created_at, u.name
HAVING SUM(oi.amount) > 1000
ORDER BY total DESC
LIMIT 20;
内容一模一样,第二份能一眼看出:查了哪几张表、过滤条件是什么、聚合在哪个层级。
| 规则 | 做法 | 理由 |
|---|---|---|
| 关键字大写 | SELECT、FROM、WHERE |
一眼区分关键字和标识符 |
| 主子句顶格 | 每个主子句单独一行,不缩进 | 竖着扫就能看到查询骨架 |
| 选择列一行一个 | 缩进 4 空格 | 增删列时 diff 只有一行 |
ON 缩进 |
比 JOIN 多缩进 2–4 空格 |
连接条件归属明确 |
| 逗号位置 | 前置或尾随,全库统一 | 见下 |
别名写 AS |
orders AS o |
省略 AS 时 from a b 容易看成两个表 |
| 语句以分号结尾 | ; |
批量执行和脚本化时安全 |
| 缩进用空格 | 不用 Tab | 不同编辑器 Tab 宽度不一致 |
逗号前置还是尾随是个长期争论,两种都合理:
-- 尾随逗号(更常见)
o.id,
o.created_at,
u.name
-- 前置逗号(注释掉最后一行不会语法错)
o.id
, o.created_at
, u.name
前置逗号的好处是注释掉任意一行都不会破坏语法,适合经常手工改查询的分析场景。选一个,写进规范文档,然后用工具强制。
嵌套三层以上的子查询是 SQL 可读性的头号杀手。用 WITH 把它摊平:
WITH paid_orders AS (
SELECT id, user_id, created_at
FROM orders
WHERE status = 'paid'
AND created_at >= '2024-01-01'
),
order_totals AS (
SELECT order_id, SUM(amount) AS total
FROM order_items
GROUP BY order_id
)
SELECT
po.id,
po.created_at,
u.name,
ot.total
FROM paid_orders AS po
JOIN users AS u
ON u.id = po.user_id
JOIN order_totals AS ot
ON ot.order_id = po.id
WHERE ot.total > 1000
ORDER BY ot.total DESC
LIMIT 20;
每一段都能单独拿出来跑,调试时可以逐个 SELECT * FROM paid_orders 验证中间结果。需要递归查询树形结构时用 WITH RECURSIVE。
| 对象 | 约定 | 示例 |
|---|---|---|
| 表、列 | snake_case 全小写 |
order_items |
| 表名 | 复数(或全库统一单数) | users、orders |
| 主键 | id |
id |
| 外键 | <单数表名>_id |
user_id |
| 关联表 | 两表名用下划线连接 | order_items |
| 布尔列 | is_ / has_ 前缀 |
is_paid、has_refund |
| 时间列 | created_at、updated_at |
类型用 timestamptz |
| 金额 | 整数存最小单位,或用 numeric |
amount_cents |
| 索引 | idx_<表名>_<列名> |
idx_orders_status |
| 唯一约束 | uq_<表名>_<列名> |
uq_users_email |
几条硬性要求:
order、user、desc、status、key、range 在不同数据库里是保留字,用了就得到处加引号,而且引号风格(双引号 / 反引号 / 方括号)各库还不一样。cust_addr 省下的几个字符,远不值后来人猜错的成本。orders.order_id 是好的,orders.orders_id 是冗余的。timestamptz,MySQL 用 DATETIME 并约定存 UTC。SELECT *。 上游加一列,下游所有 * 查询的输出结构都变了,ORM 映射和报表会静默出错。只在临时排查时用。JOIN 必须写 ON。 漏掉 ON 会变成笛卡尔积,数据量一大直接把库打满。宁可用显式 CROSS JOIN 表达"我就是要交叉"。ORDER BY 不要依赖隐式顺序。 没有 ORDER BY 时返回顺序是未定义的,加了索引可能就变了。分页必须配 ORDER BY,且排序键要能唯一确定顺序(否则第二页可能重复第一页的行)。-- 排除已退款订单(财务口径要求) 有用;-- 查询订单 是废话。行注释用 --,块注释用 /* */。格式约定只有被自动检查才有约束力。常用选择:
# sqlfluff:支持 PostgreSQL / MySQL / BigQuery 等多种方言,可 lint 也可 fix
pip install sqlfluff
sqlfluff lint --dialect postgres query.sql
sqlfluff fix --dialect postgres query.sql
把它接进 pre-commit 和 CI,规范才不会在第三次赶工时崩掉。IDE 里一般也有内置的格式化(DataGrip、DBeaver、VS Code 的 SQL 插件),配成与 sqlfluff 一致的规则即可。
需要把 SQL 塞进日志或配置文件时再考虑压缩成一行,但仓库里始终保留格式化版本——压缩是不可逆的,注释会被一起去掉。