第 05 章 · SQL 写法

一句话总结:能写出"对"的 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""再补一个索引"。

写法原则:

  1. 能用 CTE 就用 CTE,把每一步逻辑命名。
  2. 早过滤,晚 JOIN------先在 CTE 里把每个源表过滤到最小集再 JOIN。
  3. 能用窗口函数就别用关联子查询------优化器对窗口函数处理得更好。
  4. 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 EXISTSLEFT JOIN ... WHERE ... IS NULL

反例 5:UNIONUNION 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

相关推荐
invicinble15 小时前
对于spring的bean应该有哪些领域的认识
java·后端·spring
Amazing530715 小时前
docker compose 漏一个参数全失效
后端·代码规范
ZengLiangYi15 小时前
从零实现 Embedding 服务:文本转向量
人工智能·后端
星栈15 小时前
订单状态机别写散:我在 Rust CRM 里把 6 个状态收进领域模型
后端·rust·全栈
韩小兔修媛史16 小时前
SpringBoot面试八股文(持续更新)
spring boot·后端·面试
码上出头16 小时前
地理围栏从0到1:我是怎么把轮询接口从每分钟2000次干到0次的
后端
神奇小汤圆16 小时前
搞懂数据库索引:它到底帮了什么忙,又埋了什么坑?
后端
浮游本尊16 小时前
Java学习第38天 - 企业级 REST API 设计、OpenAPI 契约与接口可靠性
后端
苍何16 小时前
分享最近高频用 Agent 提效的 4 大场景
后端