01 · 关系模型与 SQL 基础
前端最常用的
array.filter().map()只是"单表思维"。数据库是集合思维:一次声明"我要什么样的行",由引擎决定怎么取。本篇帮你建立关系模型心智,并把 JOIN、子查询、GROUP BY、窗口函数一次讲透。
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 从"前端数组操作"迁移到"集合思维"的 SQL 心智课 |
| 核心内容 | 关系模型、SELECT 执行顺序、JOIN 类型、子查询、GROUP BY 与窗口函数 |
| 读完能回答 | JOIN 和子查询什么时候等价?为什么我写的 SQL 慢但数组操作不慢? |
| 相关板块 | DB02 索引原理 | C10 数据建模与访问 | SDB01 SQL 速查 |
一、集合思维:SQL 不是"一行行处理"
前端处理数据是命令式的:遍历、条件、拼装。 SQL 是声明式的:你说"我要哪些行",引擎决定怎么找。
| 前端操作 | SQL 对应 | 差异 |
|---|---|---|
arr.filter(...) | WHERE | SQL 在磁盘/内存层筛,你只描述条件 |
arr.map(...) | SELECT 列 / 表达式 | 投影 |
arr.sort() | ORDER BY | 通常要排序或走索引 |
arr.reduce() | SUM/COUNT/AVG | 聚合,且要配合 GROUP BY |
| 嵌套循环查关联 | JOIN | 由引擎选连接算法 |
关键认知:SQL 不保证行处理顺序。不要在 SELECT 里依赖"上一步的结果",一切以 WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT 的逻辑顺序理解。
二、SELECT 逻辑执行顺序(背下来)
FROM / JOIN 先定"从哪些表取"
WHERE 行级过滤
GROUP BY 分组
HAVING 组级过滤(只能用聚合结果)
SELECT 投影与计算
ORDER BY 排序
LIMIT 取前 N为什么重要:
WHERE里不能用 SELECT 里定义的别名(别名还没算出来);HAVING里能用聚合(因为分组已完成)。这两条是新手报错的高频点。
三、JOIN:四种连接到底取什么
-- 以 用户 users 左连订单 orders 为例
SELECT u.name, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id;| JOIN | 语义 | 前端类比 |
|---|---|---|
INNER JOIN | 两边都匹配的行 | 两个数组做交集 |
LEFT JOIN | 左表全保留,右表无匹配补 NULL | 左表做 map 找不到就补空 |
RIGHT JOIN | 右表全保留 | 对称的 LEFT |
FULL OUTER JOIN | 两边都保留 | 并集,无匹配补 NULL |
最常见 bug:LEFT JOIN 后 WHERE 里过滤右表列会让它"变回 INNER"。 因为
o.id IS NOT NULL把无匹配行筛掉了。
四、子查询 vs JOIN:什么时候等价
| 写法 | 适合 |
|---|---|
JOIN | 要取被关联表的列、或做聚合拼接 |
IN (子查询) | 只要"存在即算",且子查询结果不大 |
EXISTS | 只要存在性判断,子查询很大时更优(可提前短路) |
派生表 FROM (...) | 先算一个中间结果再继续查(注意必须起别名) |
-- 三种表达"买过东西的用户",结果可等价但代价不同
SELECT DISTINCT u.name FROM users u JOIN orders o ON o.user_id = u.id;
SELECT u.name FROM users u WHERE u.id IN (SELECT user_id FROM orders);
SELECT u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);别纠结"哪个更快"的玄学:把执行计划(EXPLAIN)跑出来看,而不是猜。
五、GROUP BY 与窗口函数:聚合 vs 逐行保留
5.1 GROUP BY
-- 每个用户的下单数
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5; -- 组级过滤
SELECT里出现的非聚合列必须出现在 GROUP BY 里(否则大多数数据库报错,只有 MySQL 宽松)。这是"分组后一行代表一组"的后果。
5.2 窗口函数:不压缩行数
-- 每个用户最近一单的排名,行数不变
SELECT user_id, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders;| 函数 | 用途 |
|---|---|
ROW_NUMBER() | 编号,排名不并列 |
RANK()/DENSE_RANK() | 排名,可并列(RANK 会跳号) |
SUM() OVER (...) | 累计/分组小计,保留每一行 |
LAG()/LEAD() | 取上/下一行,环比常用 |
判断框架:要"每行都保留、同时算一个组内统计" → 窗口函数;要"每组只剩一行" → GROUP BY。
六、事务与索引:SQL 之外的"能不能跑快、准"
- 多行操作的一致性边界 → 见 DB03 事务与隔离级别
- 为什么某些 WHERE 快某些慢、怎么建索引 → 见 DB02 索引原理与优化
- 建表与模型设计 → 见 DB04 Schema 设计
七、自检清单
- [ ] 能用集合思维描述一条 SQL,而不只是"遍历"
- [ ] 背得出 SELECT 逻辑执行顺序,知道 WHERE 里不能用别名
- [ ] 知道 LEFT JOIN + WHERE 过滤右表列会变回 INNER
- [ ] 知道子查询 vs JOIN vs EXISTS 各自的适用场景
- [ ] 会用 GROUP BY + HAVING,并知道非聚合列要进 GROUP BY
- [ ] 会用窗口函数保留每一行做排名/累计
- [ ] 慢查询会用 EXPLAIN 看执行计划,而不是猜
八、常见误区
| 误区 | 事实 |
|---|---|
| "SQL 是逐行执行的" | 声明式,引擎决定执行计划 |
| "GROUP BY 后还能随便选列" | 非聚合列必须进 GROUP BY |
| "LEFT JOIN 后加 WHERE 右表列 = 某值 没问题" | 会把 LEFT 变回 INNER |
| "JOIN 一定比子查询快" | 取决于执行计划,看 EXPLAIN |
| "窗口函数和 GROUP BY 一样" | 窗口函数不压缩行数 |
| "LIMIT 就少取数据" | 排序/分组仍可能全量计算,索引才真正减少代价 |
九、动手练习
练习 A:集合思维转换
把前端代码翻译成 SQL:orders.filter(o => o.user_id === 3 && o.amount > 100).sort((a,b) => b.created_at - a.created_at).slice(0, 10)。
练习 B:追一次 LEFT JOIN 丢失
SELECT u.name, o.id FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id = 123 会丢什么?怎么改才对?
练习 C:写一个"每人最近一单"
只用窗口函数,不 GROUP BY。要求保留每单的完整信息。
相关文档
本区
- 02 · 索引原理与优化 —— 为什么慢、怎么建索引
- 03 · 事务与隔离级别 —— 一致性边界
- 04 · Schema 设计与范式反范式 —— 建表与模型设计
- 11 · pgvector 向量检索 —— 关系模型之上的向量扩展
其他域
- 10 · 数据建模与数据访问 —— SQL + ORM 的使用侧
- 12 · SQL 速查 —— 常用语法速查