一句话总结:能写出"对"的 SQL 只是起点,高级开发要能写出"在大数据量下仍然对而且快"的 SQL,并且让接手的人三个月后还能看懂。
1. 概念
- CTE(WITH 子句):命名子查询。可读性的最大武器。
- 窗口函数(Window Function) :
OVER (PARTITION BY ... ORDER BY ...),在不分组的前提下做组内统计。 - UPSERT :
INSERT ... ON CONFLICT [PG]/INSERT ... ON DUPLICATE KEY UPDATE [MySQL]。 - 批量写入(Bulk Insert):一条 SQL 写多行。
- 集合操作 :
UNION/UNION ALL/INTERSECT/EXCEPT。 - 关联子查询(Correlated Subquery):子查询里引用外层列。可读性好但常常慢。
2. 原理:可读性 = 性能的前提
复杂 SQL 性能问题,80% 第一步是看不懂。看不懂就改不动,改不动就只能"再加一个 hint""再补一个索引"。
写法原则:
- 能用 CTE 就用 CTE,把每一步逻辑命名。
- 早过滤,晚 JOIN------先在 CTE 里把每个源表过滤到最小集再 JOIN。
- 能用窗口函数就别用关联子查询------优化器对窗口函数处理得更好。
SELECT *永远不写在生产代码里------列变动会击穿你的契约,且抑制覆盖索引。
3. 生产实践
3.1 用 CTE 把复杂查询拆开
反例(嵌套子查询):
sql
SELECT u.id, u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_cnt,
(SELECT SUM(amount) FROM orders o WHERE o.user_id = u.id AND o.status = 2) AS paid_amt
FROM users u
WHERE u.created_at > '2026-01-01';
正例(CTE + 一次 JOIN):
sql
WITH paid_stats AS (
SELECT user_id,
COUNT(*) FILTER (WHERE TRUE) AS order_cnt, -- PG 语法
SUM(amount) FILTER (WHERE status = 2) AS paid_amt
FROM orders
GROUP BY user_id
)
SELECT u.id, u.name, COALESCE(s.order_cnt, 0), COALESCE(s.paid_amt, 0)
FROM users u
LEFT JOIN paid_stats s ON s.user_id = u.id
WHERE u.created_at > '2026-01-01';
FILTER (WHERE ...) [PG] 或 SUM(CASE WHEN ... THEN ... ELSE 0 END) [通用] 比关联子查询更易优化、更易读。
注 :PG 12 之前 CTE 是"优化栅栏",会阻止优化器跨 CTE 优化;PG 12+ 默认不再是栅栏。MySQL 8.0+ 也支持 CTE。
3.2 窗口函数典型用法
取每个用户最新一条订单:
sql
WITH ranked AS (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
)
SELECT * FROM ranked WHERE rn = 1;
滚动 7 日累计:
sql
SELECT day,
SUM(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7d
FROM daily_sales;
和前一行做差(环比):
sql
SELECT day, amount,
amount - LAG(amount) OVER (ORDER BY day) AS diff_vs_prev
FROM daily_sales;
会了 ROW_NUMBER / RANK / LAG / LEAD / SUM OVER 五个,90% 的报表 SQL 不用写程序。
3.3 UPSERT(幂等写入)
PG:
sql
INSERT INTO user_stats (user_id, login_count, last_login_at)
VALUES (1, 1, now())
ON CONFLICT (user_id) DO UPDATE
SET login_count = user_stats.login_count + 1,
last_login_at = EXCLUDED.last_login_at;
MySQL:
sql
INSERT INTO user_stats (user_id, login_count, last_login_at)
VALUES (1, 1, NOW())
ON DUPLICATE KEY UPDATE
login_count = login_count + 1,
last_login_at = VALUES(last_login_at); -- MySQL 8.0.20+ 改用 row alias
要点:
- 必须有唯一约束 或主键,UPSERT 才能识别冲突。
- 这是幂等写入的核心手段,网络重试、MQ 重投都靠它。
ON CONFLICT DO NOTHING用于"插入不报错就行"的场景。
3.4 批量写入
sql
-- ✅ 一条 SQL 多行 (推荐)
INSERT INTO events (user_id, event_type, payload) VALUES
(1, 'login', '{"ip":"..."}'::jsonb),
(2, 'click', '{"page":"..."}'::jsonb),
(3, 'view', '{"id":...}'::jsonb);
- 一次插入控制在 500--1000 行之间(网络包 + binlog/WAL 容量),不要 10 万行一条。
- 走事务,失败回滚干净。
- PG 极致性能用
COPY FROM STDIN,MySQL 用LOAD DATA INFILE。
3.5 分页:不要用 OFFSET 翻深页
sql
-- ❌ 翻到第 10000 页:OFFSET 200000 LIMIT 20
SELECT * FROM orders ORDER BY id DESC OFFSET 200000 LIMIT 20;
-- 数据库要扫 200020 行丢掉前 200000 行
正确(Keyset / Seek Pagination):
sql
-- 客户端记住上一页最后一条 id
SELECT * FROM orders
WHERE id < :last_seen_id
ORDER BY id DESC
LIMIT 20;
- 性能与翻多少页无关,永远是 O(LIMIT)。
- 副作用:不能直接跳到第 N 页。多数 APP 用"加载更多"就够了。
- 真要支持跳页:第一页用 keyset、后端缓存边界 ,或干脆前端限制只能跳到第 100 页。
3.6 IN 列表的边界
sql
SELECT * FROM orders WHERE user_id IN (?, ?, ?, ... 一万个);
- PG :大列表用
= ANY(ARRAY[...])或VALUES子查询,优化器处理得更好。 - MySQL :
IN列表太大会让优化器估算耗时上升。 - 极端大列表 (成千上万):把列表灌进临时表 或
UNNEST,然后 JOIN。
sql
-- PG 优雅写法
SELECT o.*
FROM orders o
JOIN UNNEST(ARRAY[101, 102, ...]) AS t(uid) ON o.user_id = t.uid;
3.7 NULL 的坑
NULL = NULL→ 不是 true 也不是 false,是 unknown。WHERE col != 'X'不会包含 col IS NULL 的行 。如果业务想包含,要(col IS NULL OR col != 'X')。COUNT(*)数所有行;COUNT(col)跳过 NULL。- 聚合函数大部分跳过 NULL,除了
COUNT(*)。 - PG 提供
NULLS FIRST / NULLS LAST控制 ORDER BY 的 NULL 位置。
4. 反例与踩坑
反例 1:SELECT * 在生产代码里
- 新加一列(比如
description TEXT)→ 接口返回突然多 50 KB,客户端 OOM。 - 抑制覆盖索引,白白回表。
- 唯一例外 :
EXISTS (SELECT * FROM ...)写法上无所谓,优化器不实际取列。
反例 2:WHERE 1=1 拼 SQL
代码层动态拼接出 WHERE 1=1 AND ...,看似无害,但容易隐藏漏写过滤条件。 正确 :用命名参数 + 可选条件,在 ORM 或 query builder 层组装;别拼字符串。
反例 3:用 count(*) 做分页总数
sql
SELECT count(*) FROM orders WHERE complex_condition; -- 慢
深表上 count(*) 永远是慢的(尤其 PG,需要扫全索引)。正确:
- 业务上能接受"约多少条"的话,用
EXPLAIN的估算值。 - 真要精确,异步算了缓存。
- 移动端列表完全不展示总数。
反例 4:在 WHERE 里用 NOT IN (子查询)
sql
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM blacklist);
- 如果
blacklist.user_id有 NULL,结果会全空(NULL 语义)。 - 优化器经常处理不好。 正确 :
NOT EXISTS或LEFT JOIN ... WHERE ... IS NULL。
反例 5:UNION 当 UNION ALL 用
UNION 会去重 (隐式排序 + 哈希)。如果你知道两边没重叠,永远用 UNION ALL。
反例 6:对字符串 ID 用 LIKE 'prefix%' 翻页
表面看走索引,但**prefix% 命中范围大时跟全表扫差不多**,且无法 keyset。
反例 7:把业务逻辑塞进存储过程 / 触发器
- 触发器隐式执行,调试地狱,版本管理麻烦。
- 存储过程换库困难,审计成本高。 只在三种情况下用:审计日志触发器、强约束触发器、性能极敏感的批处理过程。
5. 上线前自检清单 ✅
- 这条 SQL 用 CTE 拆开了吗?三个月后接手的人能看懂吗?
- 是否避开了
SELECT *? - 分页是 keyset 还是 OFFSET?如果是 OFFSET,最大页深是多少?
- UPSERT 依赖的唯一约束建了吗?
- IN 列表会不会被业务变量撑到几千?
- NULL 的语义处理对了吗(
!=、NOT IN、聚合)? - 用了
UNION是否需要去重?能不能换UNION ALL? - 大批量写入是否分批 + 事务?有重试/幂等设计吗?
6. 延伸阅读
- 《SQL 反模式》(SQL Antipatterns)------ 几乎覆盖了所有"看起来对其实坑"的写法。
- Markus Winand 系列(use-the-index-luke、modern-sql.com)------ modern-sql 那本讲窗口函数和 CTE 极透。
- PG 文档 · "Common Table Expressions" + "Window Functions"。
下一章预告:
06-执行计划与优化.md。