SQL 从建表到多表查询:一篇串起 DDL、SELECT、WHERE、聚合、子查询与 JOIN

SQL 真正难的地方,通常不是记住 SELECTWHEREGROUP BY 这些关键字,而是把它们串成一套完整的查询思维:数据如何定义、数据如何筛选、数据如何计算、数据如何聚合,以及多张表如何建立关系

本文基于一组 SQL 基础课程资料重新整理,将原本分散在 DDL、SELECT、数据过滤、SQL 函数、聚集函数、子查询以及 JOIN 中的知识串成一条完整主线。示例以 MySQL 风格为主,但不同 DBMS 的语法、函数和连接能力可能存在差异,实际使用时应以对应数据库当前版本文档为准。

一、先建立 SQL 的整体学习地图

如果把常见 SQL 能力按使用顺序排列,可以得到这样一条路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DDL 定义表结构

SELECT 选择需要的数据列

WHERE 过滤数据行

函数处理字段值

GROUP BY + 聚集函数完成统计

HAVING 过滤分组结果

子查询处理“查询结果上的查询”

JOIN 连接多张关系表

ORDER BY / LIMIT 输出最终结果

这条链路非常重要。很多复杂 SQL,本质上只是上面几个阶段的组合。


二、DDL:先把数据库结构设计正确

DDL(Data Definition Language,数据定义语言)负责定义数据库和数据表结构,常见操作主要是:

1
2
3
CREATE
ALTER
DROP

例如创建一个简单的球员表:

1
2
3
4
5
6
7
8
9
CREATE TABLE player (
player_id INT NOT NULL AUTO_INCREMENT,
team_id INT NOT NULL,
player_name VARCHAR(255) NOT NULL,
height DECIMAL(3, 2) DEFAULT 0.00,
PRIMARY KEY (player_id),
UNIQUE (player_name),
CHECK (height >= 0 AND height < 3)
);

这里已经涉及数据库设计中最常见的约束。

1. 主键 PRIMARY KEY

主键用于唯一标识一条记录,最核心的两个特征是:

1
唯一 + 非空

例如:

1
PRIMARY KEY (player_id)

一张表只能有一个主键,但主键可以由一个字段组成,也可以由多个字段组成联合主键。

2. 外键 FOREIGN KEY

外键用于维护表与表之间的引用关系。例如 player.team_id 可以关联 team.team_id

1
2
3
4
5
CREATE TABLE team (
team_id INT NOT NULL,
team_name VARCHAR(100) NOT NULL,
PRIMARY KEY (team_id)
);
1
2
3
ALTER TABLE player
ADD CONSTRAINT fk_player_team
FOREIGN KEY (team_id) REFERENCES team(team_id);

外键的价值是让数据库直接帮助维护引用完整性。不过在高并发、分库分表或大型分布式系统中,外键也可能增加写入、维护和扩展成本,因此实际项目里也常把这部分一致性校验放到业务层完成。

也就是说:外键不是“必须有”或“绝对不能有”,而是正确性、性能与架构之间的取舍。

3. UNIQUE、NOT NULL、DEFAULT、CHECK

几个很常用的字段约束:

1
2
3
4
5
6
7
8
9
10
11
-- 唯一约束
UNIQUE (player_name)

-- 非空
player_name VARCHAR(255) NOT NULL

-- 默认值
height DECIMAL(3, 2) DEFAULT 0.00

-- 范围检查
CHECK (height >= 0 AND height < 3)

约束的目标不是“让 DDL 看起来更专业”,而是尽量在数据进入数据库时就阻止错误数据。

4. ALTER TABLE 修改表结构

常见的结构调整包括新增、重命名、修改和删除字段:

1
2
3
4
5
6
7
ALTER TABLE player ADD COLUMN age INT;

ALTER TABLE player RENAME COLUMN age TO player_age;

ALTER TABLE player MODIFY COLUMN player_age DECIMAL(3, 1);

ALTER TABLE player DROP COLUMN player_age;

不同数据库对 ALTER TABLE 的细节支持存在差异,因此迁移数据库时尤其要注意方言问题。

5. 表设计不要机械套公式

资料中给出了一个“三少一多”的设计思路:

  • 表数量尽量精简;
  • 字段数量尽量精简;
  • 联合主键字段数量尽量少;
  • 通过键关系提高表之间的复用。

它背后的核心其实只有两个词:简单、可复用

不过这不是绝对规则。真实项目中,规范化、冗余、查询效率和写入成本之间经常需要折中,不能为了“表少”而硬把几十个业务概念塞进一张巨无霸表。


三、SELECT:查询的第一原则是“只拿需要的数据”

最基础的 SQL 查询:

1
2
SELECT player_name
FROM player;

查询多个字段:

1
2
SELECT player_name, height, team_id
FROM player;

1. 为什么生产环境不推荐 SELECT *

开发时为了快速看数据,下面这条语句确实很爽:

1
2
SELECT *
FROM player;

但生产代码更推荐显式指定字段:

1
2
SELECT player_id, player_name, team_id
FROM player;

原因很简单:

  • 不需要的字段没必要从数据库读取;
  • 可以减少结果集和网络传输量;
  • 表结构变化时,代码行为更加稳定;
  • SQL 本身更容易看出到底依赖哪些字段。

SELECT * 不是禁术,但更适合数据探索和临时排查,而不是长期业务代码。

2. 使用 AS 起别名

1
2
3
4
SELECT
player_name AS name,
height AS player_height
FROM player;

表也可以起别名:

1
2
SELECT p.player_name, p.height
FROM player AS p;

当 SQL 开始 JOIN 三四张表以后,别名不是装饰品,而是保命装备。

3. DISTINCT 去重

1
2
SELECT DISTINCT team_id
FROM player;

需要注意,DISTINCT 针对的是后面所有字段组成的结果行

1
2
SELECT DISTINCT team_id, player_name
FROM player;

只要 player_name 不同,两行就仍然会被认为不同。

4. ORDER BY 排序

1
2
3
SELECT player_name, height
FROM player
ORDER BY height DESC;

多字段排序:

1
2
3
SELECT player_name, team_id, height
FROM player
ORDER BY team_id ASC, height DESC;

含义是:

  1. 先按 team_id 升序;
  2. team_id 相同时,再按 height 降序。

5. LIMIT 控制返回数量

MySQL / PostgreSQL 风格:

1
2
3
4
SELECT player_name, height
FROM player
ORDER BY height DESC
LIMIT 5;

如果业务上明确只需要一条记录,可以直接限制:

1
2
3
4
SELECT player_id
FROM player
WHERE player_name = 'Stephen Curry'
LIMIT 1;

不同 DBMS 对“限制返回行数”的语法可能不同,例如可能使用 LIMITTOPFETCH FIRST,这也是 SQL 标准与数据库方言并存的典型例子。


四、WHERE:真正决定查询范围的地方

WHERE 用于过滤数据行。

1
2
3
SELECT player_name, height
FROM player
WHERE height > 2.00;

常见条件包括:

场景 示例
等于 height = 2.00
不等于 height <> 2.00
大于 / 小于 height > 2.00
区间 height BETWEEN 1.90 AND 2.10
空值 height IS NULL
非空 height IS NOT NULL
集合 team_id IN (1001, 1002)

1. AND、OR 与括号

例如:

1
2
3
4
SELECT player_name, height, team_id
FROM player
WHERE height > 2.00
AND team_id = 1001;

如果同时出现 ANDOR,优先级通常是:

1
()  >  AND  >  OR

所以复杂条件不要考验自己半年后的记忆,直接加括号:

1
2
3
4
SELECT player_name, height, team_id
FROM player
WHERE (team_id = 1001 OR team_id = 1002)
AND height > 2.00;

括号多写两个字符,能少查两个小时的 bug,怎么算都划算。

2. IN 与 BETWEEN

多个离散值:

1
2
3
SELECT player_name, team_id
FROM player
WHERE team_id IN (1001, 1002);

连续区间:

1
2
3
SELECT player_name, height
FROM player
WHERE height BETWEEN 1.90 AND 2.10;

3. NULL 不能直接用等号判断

错误思路:

1
WHERE height = NULL

正确写法:

1
WHERE height IS NULL

或者:

1
WHERE height IS NOT NULL

4. LIKE 与通配符

% 表示零个或多个字符:

1
2
3
SELECT player_name
FROM player
WHERE player_name LIKE 'S%';

_ 表示一个字符:

1
2
3
SELECT player_name
FROM player
WHERE player_name LIKE '_urry';

需要特别注意前导通配符:

1
WHERE player_name LIKE '%urry%'

这类模式匹配通常更难利用普通 B-Tree 索引范围检索,数据量大时很容易变成昂贵扫描。因此如果业务可以改成前缀匹配:

1
WHERE player_name LIKE 'Curr%'

通常会更友好。


五、SQL 函数:数据不只是查出来,还要加工

常见 SQL 内置函数大致可以按四类理解。

1. 算术函数

1
2
3
SELECT ABS(-2);          -- 2
SELECT MOD(101, 3); -- 2
SELECT ROUND(37.25, 1); -- 37.3

实际查询中:

1
2
3
4
SELECT
player_name,
ROUND(height, 1) AS height_1
FROM player;

2. 字符串函数

常见操作包括:

1
2
3
4
5
6
7
CONCAT()
LENGTH()
CHAR_LENGTH()
LOWER()
UPPER()
REPLACE()
SUBSTRING()

例如:

1
2
3
4
SELECT
player_name,
CHAR_LENGTH(player_name) AS name_length
FROM player;

3. 日期函数

常见思路包括:

1
2
3
4
5
6
7
CURRENT_DATE()
CURRENT_TIME()
CURRENT_TIMESTAMP()
EXTRACT()
YEAR()
MONTH()
DAY()

例如:

1
SELECT EXTRACT(YEAR FROM CURRENT_DATE()) AS current_year;

4. 转换与空值处理

1
2
3
SELECT CAST(123.123 AS DECIMAL(8, 2));

SELECT COALESCE(NULL, 1, 2);

COALESCE 很适合处理空值:

1
2
3
4
SELECT
player_name,
COALESCE(height, 0) AS height
FROM player;

5. 函数的两个现实问题

第一,不同 DBMS 的函数并不完全一致。同一个目标,在 MySQL、Oracle、SQL Server、PostgreSQL 中可能有不同写法。

第二,不要随手在过滤字段上套函数然后默认索引还能正常工作。例如:

1
WHERE DATE(create_time) = '2026-08-10'

这种写法是否能有效使用索引,要结合具体数据库、索引设计和执行计划确认。对性能敏感的 SQL,最终都应该回到 EXPLAIN,而不是靠记忆口诀判案。


六、聚集函数:从“查明细”进入“做统计”

SQL 中最常见的聚集函数有:

函数 作用
COUNT() 计数
MAX() 最大值
MIN() 最小值
SUM() 求和
AVG() 平均值

例如:

1
2
3
4
5
6
SELECT
COUNT(*) AS player_count,
AVG(height) AS avg_height,
MAX(height) AS max_height,
MIN(height) AS min_height
FROM player;

1. COUNT(*) 与 COUNT(column) 的区别

1
2
SELECT COUNT(*)
FROM player;

统计的是结果集行数。

而:

1
2
SELECT COUNT(height)
FROM player;

会忽略 height IS NULL 的数据。

这两个 SQL 语义不同,不能因为看起来都叫 COUNT 就互相替换。

2. GROUP BY:按维度分组

统计每支球队的球员数量:

1
2
3
4
5
SELECT
team_id,
COUNT(*) AS player_count
FROM player
GROUP BY team_id;

多个字段联合分组:

1
2
3
4
5
6
SELECT
team_id,
height,
COUNT(*) AS player_count
FROM player
GROUP BY team_id, height;

3. WHERE 与 HAVING 到底差在哪里

一句话记住:

1
2
WHERE 过滤数据行
HAVING 过滤分组后的结果

例如:先筛选身高大于 1.90 的球员,再按球队统计,只保留球员数大于 5 的球队:

1
2
3
4
5
6
7
8
9
SELECT
team_id,
COUNT(*) AS player_count,
AVG(height) AS avg_height
FROM player
WHERE height > 1.90
GROUP BY team_id
HAVING COUNT(*) > 5
ORDER BY player_count DESC;

这里的处理过程是:

1
2
3
4
5
WHERE 先减少明细数据
→ GROUP BY 分组
→ 聚集函数计算
→ HAVING 过滤统计结果
→ ORDER BY 排序

如果把 COUNT(*) > 5 写进 WHERE,逻辑阶段就错了,因为 WHERE 执行时分组统计结果还没有形成。


七、理解 SELECT 的逻辑执行顺序

写 SQL 时,我们习惯的语法顺序是:

1
2
3
4
5
6
7
SELECT ...
FROM ...
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...
LIMIT ...;

但理解查询时,更有价值的是下面这个逻辑顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FROM

WHERE

GROUP BY

聚集计算 / HAVING

SELECT

DISTINCT

ORDER BY

LIMIT

这能解释大量看似奇怪的问题。

比如为什么 WHERE 里不能直接过滤聚集结果?

因为:

1
WHERE 执行时,GROUP BY 和聚集计算还没发生。

为什么 HAVING 可以?

因为它处于分组和聚集之后。

理解执行顺序以后,SQL 就不再是一堆关键字,而是一条数据处理流水线。


八、子查询:把“查询结果”继续当数据使用

子查询就是嵌套在另一个 SQL 中的查询。

1. 非关联子查询

查询最高的球员:

1
2
3
4
5
6
SELECT player_name, height
FROM player
WHERE height = (
SELECT MAX(height)
FROM player
);

内部查询:

1
SELECT MAX(height) FROM player

可以独立执行一次,得到一个固定结果,然后外层查询再使用它。

2. 关联子查询

如果想查询“每支球队中,高于本队平均身高的球员”:

1
2
3
4
5
6
7
8
9
10
SELECT
p.player_name,
p.team_id,
p.height
FROM player AS p
WHERE p.height > (
SELECT AVG(p2.height)
FROM player AS p2
WHERE p2.team_id = p.team_id
);

内部查询依赖外部查询当前行的 team_id,因此它属于关联子查询。

3. EXISTS / NOT EXISTS

查询至少存在一条比赛成绩记录的球员:

1
2
3
4
5
6
7
SELECT p.player_id, p.player_name
FROM player AS p
WHERE EXISTS (
SELECT 1
FROM player_score AS ps
WHERE ps.player_id = p.player_id
);

查询从未出现过比赛记录的球员:

1
2
3
4
5
6
7
SELECT p.player_id, p.player_name
FROM player AS p
WHERE NOT EXISTS (
SELECT 1
FROM player_score AS ps
WHERE ps.player_id = p.player_id
);

EXISTS 关心的是:子查询有没有返回记录,而不是返回了什么字段。

4. IN、ANY、ALL

IN 用于集合包含判断:

1
2
3
4
5
6
SELECT player_id, player_name
FROM player
WHERE player_id IN (
SELECT player_id
FROM player_score
);

ANYALL 通常要与比较运算符一起使用:

1
2
3
4
5
-- 比某个集合中的任意一个值大
WHERE height > ANY (...)

-- 比某个集合中的所有值都大
WHERE height > ALL (...)

资料中给出的性能思路是结合索引情况与表大小判断 INEXISTS 的选择,本质上仍然是在讨论“谁来驱动谁”。但实际工程中不要把它背成一条永远正确的固定规则,最终应结合数据量、索引和执行计划验证。

5. 子查询也可以作为 SELECT 字段

例如统计每支球队的球员数:

1
2
3
4
5
6
7
8
SELECT
t.team_name,
(
SELECT COUNT(*)
FROM player AS p
WHERE p.team_id = t.team_id
) AS player_count
FROM team AS t;

这种写法很直观,但数据量大时也要关注执行成本。


九、JOIN:关系型数据库真正强大的地方

关系型数据库中的表不是孤岛,JOIN 就是把这些关系重新组合起来。

资料分别介绍了 SQL92 和 SQL99 的连接写法。实际开发中更推荐使用结构清晰的 SQL99 风格:

1
2
3
4
SELECT ...
FROM table1
JOIN table2 ON ...
JOIN table3 ON ...;

相比把所有表都堆在 FROM 后,再把连接条件和过滤条件一起塞进 WHERE,这种写法更容易阅读和维护。

1. CROSS JOIN:笛卡尔积

1
2
3
SELECT *
FROM player
CROSS JOIN team;

如果 player 有 37 行,team 有 3 行,理论上会得到:

1
37 × 3 = 111 行

笛卡尔积本身并不是错误,但业务查询中出现“行数突然爆炸”,第一件事就是检查是不是漏了连接条件。

2. INNER JOIN:只保留匹配数据

1
2
3
4
5
6
7
SELECT
p.player_name,
p.height,
t.team_name
FROM player AS p
JOIN team AS t
ON p.team_id = t.team_id;

JOIN 不写类型时,通常表示 INNER JOIN

3. LEFT JOIN:左表全部保留

1
2
3
4
5
6
SELECT
t.team_name,
p.player_name
FROM team AS t
LEFT JOIN player AS p
ON t.team_id = p.team_id;

即使某支球队没有球员记录,球队本身仍然会出现在结果中,对应的球员字段为 NULL

这类写法特别适合:

  • 查“所有主体 + 可能存在的明细”;
  • 找没有关联数据的记录;
  • 报表中不能丢失主维度的场景。

例如找“没有球员数据的球队”:

1
2
3
4
5
SELECT t.team_id, t.team_name
FROM team AS t
LEFT JOIN player AS p
ON t.team_id = p.team_id
WHERE p.player_id IS NULL;

4. RIGHT JOIN

逻辑上与 LEFT JOIN 相反:保留右表全部记录。

1
2
3
4
SELECT ...
FROM player AS p
RIGHT JOIN team AS t
ON p.team_id = t.team_id;

实际工程中,如果团队统一使用 LEFT JOIN,很多 RIGHT JOIN 都可以通过交换表位置改写,这样代码风格更统一。

5. FULL OUTER JOIN

标准 SQL 中还有全外连接,用于同时保留左右两边未匹配的数据。

但它的支持情况与具体 DBMS 有关,所以不要默认所有数据库都能直接执行:

1
2
3
4
SELECT ...
FROM table_a
FULL OUTER JOIN table_b
ON table_a.id = table_b.id;

6. 非等值 JOIN

连接条件并不一定必须使用等号。

例如用身高级别表 height_grades 给球员匹配等级:

1
2
3
4
5
6
7
SELECT
p.player_name,
p.height,
h.height_level
FROM player AS p
JOIN height_grades AS h
ON p.height BETWEEN h.height_lowest AND h.height_highest;

这就是典型的区间连接。

7. 自连接

同一张表也可以和自己 JOIN。

例如找出所有比某个球员更高的人:

1
2
3
4
5
6
7
SELECT
b.player_name,
b.height
FROM player AS a
JOIN player AS b
ON b.height > a.height
WHERE a.player_name = 'Blake Griffin';

本质上只是把同一张表起两个不同的别名,然后把它们当作两份数据参与连接。

8. NATURAL JOIN、USING 与 ON

SQL99 还提供:

1
2
3
NATURAL JOIN
JOIN ... USING (...)
JOIN ... ON ...

虽然 NATURAL JOINUSING 可以缩短语句,但大型业务 SQL 更常见的做法仍然是把连接条件明确写出来:

1
2
JOIN team AS t
ON p.team_id = t.team_id

这样阅读代码时不需要猜“到底使用了哪个同名字段”。


十、把 WHERE、GROUP BY、HAVING、JOIN 串起来

看一条更接近真实报表场景的 SQL。

需求:

  • 查询身高大于 1.90 的球员;
  • 关联球队名称;
  • 按球队分组;
  • 只保留球员数不少于 5 的球队;
  • 展示球员数和平均身高;
  • 按球员数从高到低排序;
  • 只取前 10 条。
1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
t.team_id,
t.team_name,
COUNT(*) AS player_count,
ROUND(AVG(p.height), 2) AS avg_height
FROM player AS p
JOIN team AS t
ON p.team_id = t.team_id
WHERE p.height > 1.90
GROUP BY t.team_id, t.team_name
HAVING COUNT(*) >= 5
ORDER BY player_count DESC
LIMIT 10;

这条 SQL 几乎把前面的知识全部串起来了。

按逻辑理解:

1
2
3
4
5
6
7
8
9
1. FROM / JOIN:组装 player + team
2. ON:确定两张表如何关联
3. WHERE:只留下 height > 1.90 的明细
4. GROUP BY:按球队分组
5. COUNT / AVG:计算每组统计值
6. HAVING:过滤球员数不足 5 的组
7. SELECT:输出需要的字段
8. ORDER BY:按人数排序
9. LIMIT:只返回前 10 条

复杂 SQL 一旦按照这个顺序拆开,就没有表面上那么吓人。


十一、从这些基础语法里能提炼出的 SQL 优化习惯

1. 不要无脑 SELECT *

明确列出真正需要的字段:

1
2
SELECT id, name, status
FROM orders;

而不是:

1
2
SELECT *
FROM orders;

2. 尽量尽早过滤无效数据

WHERE 尽量在进入分组、排序和复杂 JOIN 前减少数据量。

3. 明确只需要少量结果时限制返回数量

1
LIMIT 1

或者分页限制结果集,至少可以降低无意义的数据返回与网络传输。

4. 谨慎使用前导模糊匹配

1
LIKE '%keyword%'

在大表上可能非常昂贵。必要时应重新评估索引策略、搜索方案甚至数据模型,而不是继续往 SQL 后面堆 %

5. WHERE、ORDER BY、JOIN 条件要关注索引

例如经常出现:

1
2
WHERE user_id = ?
ORDER BY create_time DESC

或者:

1
ON order.user_id = user.id

都值得结合真实查询模式设计索引,而不是等线上慢查询冒烟以后再“加个索引试试”。

6. 不要在谓词字段上随意做函数或隐式转换

1
WHERE DATE(create_time) = ?
1
WHERE CAST(user_id AS CHAR) = ?

这类写法要特别关注是否影响索引使用。必要时调整查询范围表达方式,并通过执行计划验证。

7. JOIN 不是越多越强

连接表越多,候选数据组合和优化器搜索空间通常也越复杂。没有必要参与结果的表,不要为了“SQL 看起来很高级”硬 JOIN 进来。

8. 不同 DBMS 不要强求一套 SQL 到处跑

这些地方尤其容易出现方言差异:

  • 分页 / 限行;
  • 日期函数;
  • 字符串函数;
  • 类型转换;
  • 外连接支持;
  • DDL 修改语法。

SQL 有标准,但每个数据库也都有自己的“口音”。代码要可移植,就必须主动管理这些差异。


十二、最后:SQL 的核心不是语法,而是数据流

把本文压缩成一句话:

1
2
3
4
先定义数据结构,再选择数据;
先过滤明细,再进行分组;
先建立关系,再做聚合;
最后排序并限制结果。

再压缩一点,就是:

1
2
3
4
5
6
7
FROM / JOIN
→ WHERE
→ GROUP BY
→ HAVING
→ SELECT
→ ORDER BY
→ LIMIT

当你真正理解这条数据流以后,DDL、SELECT、WHERE、聚集函数、子查询和 JOIN 就不再是互相割裂的知识点。

复杂 SQL 也不是“会不会写”的问题,而是能不能把业务问题拆成:

  1. 数据从哪里来;
  2. 哪些行需要留下;
  3. 是否需要分组;
  4. 每组计算什么;
  5. 是否需要继续关联或嵌套查询;
  6. 最终如何排序、分页和返回。

把这六件事想清楚,SQL 基本就已经写完了一半。


SQL 从建表到多表查询:一篇串起 DDL、SELECT、WHERE、聚合、子查询与 JOIN
https://allendericdalexander.github.io/2026/08/10/db/53sql/02sql-from-ddl-to-join/
作者
AtLuoFu
发布于
2026年8月10日
许可协议