语法
总览
SQL 语言分类
| 缩写 | 全称 | 作用 | 常见语句 |
|---|---|---|---|
| DDL | Data Definition Language(数据定义语言) | 定义/修改模式与对象结构 | CREATE、ALTER、DROP |
| DML | Data Manipulation Language(数据操纵语言) | 增删改查表中的数据 | SELECT、INSERT、UPDATE、DELETE |
| DCL | Data Control Language(数据控制语言) | 权限与安全控制 | GRANT、REVOKE |
| TCL | Transaction Control Language(事务控制语言) | 事务提交与回滚 | COMMIT、ROLLBACK、SAVEPOINT |
有的教材把 SELECT 单独称为 DQL(Data Query Language,数据查询语言);本文仍按习惯归在 DML 一侧讨论。
内容
本文是 数据库系统笔记 的 SQL 独立部分。理论侧(关系代数、优化、事务)见主笔记。
参考:
全文默认使用下列模式:
1 | student(sid, name, age, gender, department) |
▸执行顺序(理解用)
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY。WHERE 不能使用 SELECT 列表别名;HAVING 可使用聚集结果。
DDL
(Data Definition Language,数据定义语言:建表、改表、删表、建索引等,管的是“结构”不是行数据。)
建表
1 | CREATE TABLE student ( |
常见类型
(几乎所有类型允许 NULL,除非声明 NOT NULL):
| 类型 | 含义 |
|---|---|
CHAR(n) |
定长字符串,长度固定为 $n$;不足时通常右侧补空格 |
VARCHAR(n) |
变长字符串,最长 $n$;按实际长度存,更省空间 |
INT |
整型(机器相关的有限整数子集) |
SMALLINT |
小整型,取值范围通常小于 INT |
NUMERIC(p,d) |
定点小数:一共 $p$ 位有效数字,其中小数点后 $d$ 位 |
REAL / DOUBLE |
单精度 / 双精度浮点数(精度机器相关) |
FLOAT(n) |
浮点数,精度至少 $n$ 位 |
DATE |
日期(年-月-日) |
TIME |
时间(时:分:秒) |
TIMESTAMP |
日期时间戳(日期 + 时间) |
BLOB |
二进制大对象,存图片、音视频等二进制数据 |
CLOB |
字符大对象,存很长的文本 |
键与约束
建表不只是“有哪些列”,更要声明谁唯一标识一行、表与表如何引用、哪些值非法。下面按 SQL 里最常写的约束说明;理论侧(超键/候选键/闭包)见 数据库系统笔记。
主键 PRIMARY KEY
(Primary Key)
- 是什么:从表的候选键里选出的一个,用来唯一标识每一行。
- 规则:唯一 + 非空(不能是
NULL);一张表通常只有一个主键(可以是单列或联合多列)。 - 为什么需要:更新/删除要精确定位一行(
WHERE sid = 1);JOIN 时有稳定连接列;很多引擎会据此建索引。 - 用在哪:几乎每张实体表(学生、订单、商品);中间表常用联合主键。
1 | -- 单列主键(两种等价写法) |
▸主键 vs `UNIQUE`
- 主键:唯一且非空,表级“官方身份证”,一般只有一个。
UNIQUE:唯一,但允许 NULL(多数引擎对 NULL 的唯一性处理有差异);一张表可有多个唯一约束(如邮箱、手机号)。- 业务上“也能唯一标识、但不是主键”的列 → 用
UNIQUE(候选键落库的常见写法)。
外键 FOREIGN KEY
(Foreign Key)
- 是什么:本表某列(或列组)的值必须在另一张表的主键(或唯一键)中出现,或为
NULL(允许“暂不关联”时)。 - 为什么需要:保证参照完整性——不能出现“选课记录指向不存在的学生”。
- 用在哪:订单→用户、成员→学生/社团、
teaches.ID→instructor.ID、instructor.dept_name→department.dept_name。
1 | CREATE TABLE instructor ( |
父表(被引用)改删时,子表(带外键)怎么办——用参照动作:
| 动作 | 含义 | 典型场景 |
|---|---|---|
NO ACTION / RESTRICT |
有子行引用则禁止删/改父行 | 默认、最安全 |
ON DELETE CASCADE |
删父行时级联删子行 | 删用户顺带删其购物车 |
ON UPDATE CASCADE |
改父键时子表外键跟着改 | 学号变更(少见) |
SET NULL |
父行没了,子行外键置空 | 岗位撤销,员工“暂无部门” |
SET DEFAULT |
置为默认值 | 产品相关 |
1 | FOREIGN KEY (sid) REFERENCES student(sid) |
CASCADE 级联(详解)
Cascade 原意是“瀑布式连带”:对父表(被引用表)做删除或更新时,数据库按外键定义自动同步处理子表(引用表),不必手写第二条 DELETE/UPDATE。
为什么需要:没有级联时,父行若仍被子行引用,删除常被直接拒绝;若先手工删子再删父,应用层容易漏步骤,留下“指向已不存在主键”的脏数据或半截清理。
| 写法 | 父表操作 | 子表自动发生什么 |
|---|---|---|
ON DELETE CASCADE |
DELETE 掉被引用的主键行 |
所有引用该键的子行一并删除 |
ON UPDATE CASCADE |
UPDATE 改了被引用的主键值 |
子表外键列改成新值 |
1 | -- 假设 member.sid 已声明 ON DELETE CASCADE / ON UPDATE CASCADE |
对比(同一场景,没有 CASCADE,默认 NO ACTION/RESTRICT):
1 | -- member 里还有 sid=1 时: |
▸何时用 / 何时慎用
适合 CASCADE
- 子行是父行的附属数据:用户→购物车、订单→订单明细、学生→入社记录。
- 业务语义就是“父没了,子也没意义”。
慎用 CASCADE
- 子行有独立业务价值(如历史订单、审计流水):误删父表会连锁删光子表,不可轻易恢复。
- 多层外键链:
A ← B ← C都 CASCADE 时,删 A 可能拖垮一大片,需想清楚范围。 - 更稳妥时改用
ON DELETE SET NULL(保留子行、断开关联)或禁止删除(RESTRICT),由应用显式归档。
▸别和另外两种 “CASCADE” 搞混
| 出现位置 | 含义 |
|---|---|
外键 ON DELETE/UPDATE CASCADE |
改删父键时级联改删子表数据(上文) |
REVOKE ... CASCADE |
收回权限时,连带收回由此转授出去的权限 |
| 事务里的“级联回滚” | 并发控制概念:一事务中止导致读过其脏写的事务也回滚(见主笔记),不是外键语法 |
口语里说“加个 cascade”,在建表语境下几乎总是指外键的 ON DELETE CASCADE。
▸外键方向
外键建在引用方(多的一端 / 关系表)。member.sid → student.sid,不是反过来在 student 上写“有哪些社团”。
级联也写在子表外键定义上:是“子表声明:父没了我就跟着没”。
其他常用约束
| 约束 | 作用 | 为什么需要 / 用在哪 |
|---|---|---|
NOT NULL |
该列禁止空 | 姓名、主键列;必填业务字段 |
UNIQUE |
取值不重复(主键以外) | 登录名、身份证号、邮箱 |
CHECK (条件) |
行级/表级取值校验 | 年龄≥0、成绩 in (‘A’..’F’)、起止日期有序 |
DEFAULT 值 |
插入未给列时用默认 | 注册年份、状态=’pending’ |
1 | CREATE TABLE takes ( |
三类完整性(对应关系)
| 完整性 | SQL 里主要靠 |
|---|---|
| 实体完整性 | PRIMARY KEY(非空且唯一) |
| 参照完整性 | FOREIGN KEY + 参照动作 |
| 用户定义完整性 | NOT NULL / UNIQUE / CHECK / 触发器 / 断言 |
▸用本文模式串起来
student.sid、club.cid:各自主键。member(sid,cid):联合主键防重复入社;两个外键分别挂学生与社团。- 删学生且
ON DELETE CASCADE:其member行自动清掉,避免“幽灵选课”。 - 删社团若没有级联且仍有成员:删除被拒绝 → 先清
member或改策略。
改表与删表
1 | ALTER TABLE student ADD resume VARCHAR(256); |
更具体的例子:
1 | -- 1) 给 student 增加一列,并设默认值 |
▸`DELETE` 与 `TRUNCATE` / `DROP`
DELETE:逐行删,可加WHERE,受事务与触发器影响。TRUNCATE:快速清空数据,表结构保留;多数引擎记日志方式不同,通常不能按条件删。DROP TABLE:表定义与数据一起去掉;依赖此外键的表需先处理,否则报错。
索引(语法级)
1 | CREATE INDEX idx_student_dept ON student(department); |
物理意义与 B+ 树见主笔记索引章。参考:菜鸟教程 CREATE INDEX。
DML 基础
(Data Manipulation Language,数据操纵语言:对表中已有数据进行插入、更新、删除;查询见后文各章。)
插入
1 | -- 按表定义列的顺序插入全部列;列数、类型、顺序必须与表一致 |
▸`INSERT` 易错点
VALUES不写列名时,顺序 = 建表时列顺序,中间插过列就会错位。INSERT ... SELECT不要写成VALUES (SELECT ...)(多数引擎语法不对)。- 违反主键 /
UNIQUE/NOT NULL/ 外键时整句失败(除非用IGNORE等方言扩展)。
更新与 CASE
1 | -- 基本更新:改满足条件的行;务必带 WHERE,否则整表都被改 |
▸`UPDATE` 与 `CASE`
SET col = expr:每行用表达式算新值;CASE是表达式,不是独立语句。CASE WHEN ... THEN ... ELSE ... END:类似编程里的 if-else;漏写ELSE时,未命中分支结果常为NULL(会把原值改成空,危险)。- 更新前先用同样的
WHERE做一次SELECT核对影响行数,是面试/生产里的稳妥习惯。
删除
1 | -- 删一行:用主键(或能唯一锁定该行的条件)定位 |
▸只删一行
SQL 没有单独的“删第 N 行”语法,靠 WHERE 精确匹配。
稳妥做法:条件落在主键 / UNIQUE 上(如 WHERE sid = 1),保证最多一行。
若 WHERE 能匹配多行(如 WHERE department = 'CS'),会一次删掉所有命中行。
▸`DELETE` / `TRUNCATE` / `DROP` 区别
DELETE |
TRUNCATE |
DROP |
|
|---|---|---|---|
| 类别 | DML | 多视为 DDL(产品相关) | DDL |
能否 WHERE |
能 | 一般不能 | 不适用 |
| 表结构 | 保留 | 保留 | 删除 |
| 事务 | 通常可回滚 | 很多引擎不可或受限 | 提交后结构已无 |
| 触发器 | 常逐行触发 | 往往不触发行级触发器 | 对象直接消失 |
单表查询
从一张表里把需要的行/列取出来。业务上对应:列表筛选、模糊搜索、排序分页、取 Top-K。
SELECT 骨架
1 | -- DISTINCT:去掉重复的 department(多重集 → 集合语义) |
要点:
*表示全部列;DISTINCT去重;默认ALL保留重复(多重集)。SELECT列表可含表达式:salary/12 AS monthly(算月薪展示,不必改表)。- 关键字大小写不敏感;字符串比较是否敏感依排序规则/方言。
LIKE:%任意串,_单字符。参考:菜鸟教程 LIKE。
▸为何有 `DISTINCT` / `ORDER BY` / `LIMIT`
| 功能 | 为什么需要 | 典型场景 |
|---|---|---|
DISTINCT |
投影后天然会产生重复行 | 去重名单、枚举取值 |
ORDER BY |
集合无序,展示必须显式排序 | 排行榜、时间线 |
LIMIT/TOP |
只要前几条,省传输与阅读成本 | 首页 Top10、分页 |
WHERE 常用谓词
1 | -- IN:属于给定集合;比一长串 OR 好读,常接子查询 |
三值逻辑:与 NULL 比较得 UNKNOWN;WHERE 只保留真。判断空必须用 IS NULL。
ORDER BY 与限行
1 | SELECT name, age |
“最大差距两人”类题:自连接 + 排序 + LIMIT(Quiz 套路)。
1 | -- 自连接:同一张表当两个人 s1、s2 来配对 |
连接
多表信息拼在一起看。为什么需要:范式拆表后,学生在一张表、选课在另一张,业务却要“谁选了什么课”。参考:菜鸟教程 JOIN。
笛卡尔积与旧式内连接
1 | -- 旧式写法:FROM 多表 = 先笛卡尔积,再用 WHERE 当连接条件 |
FROM 多表先理解为笛卡尔积,再用 WHERE 过滤——优化器会改写,但语义如此。
INNER / LEFT / RIGHT / FULL
先固定两张小表(数字少才好看清)。连接条件一律:student.sid = member.sid。
表 student(左表)
| sid | name |
|---|---|
| 1 | 小明 |
| 2 | 小红 |
| 3 | 小刚 |
表 member(右表,谁进了哪个社)
| sid | club |
|---|---|
| 1 | 舞蹈社 |
| 1 | 篮球社 |
| 2 | 舞蹈社 |
| 4 | 棋社 |
读表:小明进了两个社;小红进了舞蹈社;小刚没进任何社;member 里还有 sid=4(棋社),但 student 里没有这个人(脏数据或学生已删)。
INNER JOIN(只要两边对得上)
1 | SELECT s.sid, s.name, m.club |
结果只有“学生表里有、且确实入过社”的行:
| sid | name | club |
|---|---|---|
| 1 | 小明 | 舞蹈社 |
| 1 | 小明 | 篮球社 |
| 2 | 小红 | 舞蹈社 |
- 小刚没了(左有右无)
- sid=4 棋社没了(右有左无)
- 小明出现两行:一人多社,INNER 会复制左行
▸何时用 INNER
只要“有效关联”,不要光杆司令。例如:下过单的用户、选过课的学生。
LEFT JOIN(左表全留,右对不上就 NULL)
1 | SELECT s.sid, s.name, m.club |
| sid | name | club |
|---|---|---|
| 1 | 小明 | 舞蹈社 |
| 1 | 小明 | 篮球社 |
| 2 | 小红 | 舞蹈社 |
| 3 | 小刚 | NULL |
- 小刚还在,
club为NULL→ 表示“这个学生没有匹配的入社记录” - sid=4 棋社仍然不出现(右独有,LEFT 不管)
找“从未入社的学生”:
1 | SELECT s.sid, s.name |
结果:只有小刚。
▸何时用 LEFT
花名册要完整,右边信息可有可无。例如:全部员工 + 可选的部门名;全部学生 + 可选的社团。
RIGHT JOIN(右表全留,左对不上就 NULL)
1 | SELECT s.sid, s.name, m.sid AS member_sid, m.club |
| sid | name | member_sid | club |
|---|---|---|---|
| 1 | 小明 | 1 | 舞蹈社 |
| 1 | 小明 | 1 | 篮球社 |
| 2 | 小红 | 2 | 舞蹈社 |
| NULL | NULL | 4 | 棋社 |
- 棋社那行留下了,学生侧是
NULL→ “入社记录在,学生表对不上” - 小刚没了(左独有,RIGHT 不管)
A RIGHT JOIN B 等价于 B LEFT JOIN A(换左右即可),所以生产里少写 RIGHT,统一用 LEFT 更不易晕。
▸何时用 RIGHT
理论上要“以右表为主”。实务更常把主表放到左边,改写成 LEFT。
FULL OUTER JOIN(两边的独苗都留)
1 | SELECT s.sid, s.name, m.sid AS member_sid, m.club |
| sid | name | member_sid | club |
|---|---|---|---|
| 1 | 小明 | 1 | 舞蹈社 |
| 1 | 小明 | 1 | 篮球社 |
| 2 | 小红 | 2 | 舞蹈社 |
| 3 | 小刚 | NULL | NULL |
| NULL | NULL | 4 | 棋社 |
= INNER 的匹配行 ∪ 仅左有(小刚)∪ 仅右有(棋社)。
MySQL 长期没有 FULL OUTER JOIN,可用 UNION 模拟:
1 | SELECT s.sid, s.name, m.club |
▸何时用 FULL
对账、比对两份名单:看两边都有、只有左、只有右。例如:系统用户表 vs 考勤打卡表。
一眼对照
同一份数据,四种结果差在“独苗要不要”:
| 小明/小红(两边都有) | 小刚(仅左) | 棋社 sid=4(仅右) | |
|---|---|---|---|
| INNER | 有 | 丢 | 丢 |
| LEFT | 有 | 有,右列 NULL | 丢 |
| RIGHT | 有 | 丢 | 有,左列 NULL |
| FULL | 有 | 有,右列 NULL | 有,左列 NULL |
▸记法
- INNER = 交集(配得上才要)
- LEFT = 左表底册 + 能配上的右信息
- RIGHT = 右表底册 + 能配上的左信息
- FULL = 两边底册都要(对账)
NATURAL / USING / ON
1 | -- NATURAL:自动按全部同名列等值连接(省事但不安全) |
▸NATURAL JOIN
按全部同名列等值连接。列名一不小心同名即误连。作业与生产更推荐显式 ON。
自连接
1 | -- 同一张 emp 表:a 是员工,b 是其经理 |
集合运算
把两个查询结果当集合做并/交/差。为什么需要:两个条件筛出的名单要合并或对比,又不想写很绕的 OR/EXISTS。
模式需相容:列数相同、类型相容(都选 name 才能竖着接在一起)。
还是用一张小 student:
| name | department | gender |
|---|---|---|
| 小明 | CS | Male |
| 小红 | CS | Female |
| 小美 | Math | Female |
| 小刚 | EE | Male |
两个子查询各自会得到:
查询 A:CS 系 → 小明、小红
查询 B:女生 → 小红、小美
注意:小红两边都有。差别就出在“叠在一起时要不要去掉这份重复”。
UNION
(合并后去重)
1 | SELECT name FROM student WHERE department = 'CS' -- A:小明、小红 |
结果(小红只留一次):
| name |
|---|
| 小明 |
| 小红 |
| 小美 |
含义:名字出现在「CS 或 女生」里即可,人只报一次。引擎通常要排序/哈希去重,所以更慢一点。
UNION ALL
(合并后不去重)
1 | SELECT name FROM student WHERE department = 'CS' |
结果(小红出现两次):
| name |
|---|
| 小明 |
| 小红 |
| 小红 |
| 小美 |
含义:把两段结果直接竖着接起来,不管有没有重复行。
▸一张图记住
| 小明(仅 A) | 小红(A 和 B 都有) | 小美(仅 B) | 小刚(都不沾) | |
|---|---|---|---|---|
UNION |
1 次 | 1 次 | 1 次 | 无 |
UNION ALL |
1 次 | 2 次 | 1 次 | 无 |
什么时候用 UNION ALL
| 场景 | 为什么用 ALL |
|---|---|
| 两段结果本来就不会重复(如 2023 流水 ∪ 2024 流水,按年切开) | 去重纯浪费,ALL 更快 |
就要保留重复(两份名单叠在一起做后续 COUNT,重复代表“命中两次条件”) |
UNION 会把信息弄丢 |
递归 CTE 里 UNION ALL 往下扩一层 |
标准写法,通常不在这里去重 |
1 | -- 例:把两年订单流水拼成一张长表(订单号跨年不重复 → 用 ALL) |
和 OR 的关系:
「CS 或女生」用 WHERE department='CS' OR gender='Female' 在同一张表上也能写,且每人一行。UNION / UNION ALL 更适合:两段查询结构不同(不同表、不同列计算),再竖着合并。
1 | -- 同表时 OR 往往更直接(结果无重复小红) |
INTERSECT / EXCEPT(顺带)
1 | -- INTERSECT:两边都出现 → 只有小红(既是 CS 又是女生) |
▸面试一句
UNION = 并上再去重;UNION ALL = 只拼接、不去重、通常更快。需要重复或确定无重复时用 ALL。
聚集与分组
把多行收成「每个组一行」的统计(每系人数、均薪等)。
GROUP BY 在干什么
一句话:按某列(或多列)的取值,把行分堆;每一堆用 COUNT/AVG/… 收成一行。
student(小数据)
| sid | name | department | age |
|---|---|---|---|
| 1 | 小明 | CS | 20 |
| 2 | 小红 | CS | 22 |
| 3 | 小刚 | EE | 19 |
| 4 | 小美 | EE | 21 |
| 5 | 小强 | Bio | 18 |
没有 GROUP BY 时,整表是一堆 5 行。写上 GROUP BY department 后,引擎先在脑子里分成三堆:
1 | CS 堆:小明、小红 → 之后收成 1 行 |
1 | SELECT department, COUNT(*) AS cnt, AVG(age) AS avg_age |
| department | cnt | avg_age |
|---|---|---|
| CS | 2 | 21 |
| EE | 2 | 20 |
| Bio | 1 | 18 |
department能出现在结果里,是因为它是分组键(每堆里这个值相同)。COUNT(*)/AVG(age)是对这一堆里的行算的。- 不能写
SELECT name, COUNT(*) ... GROUP BY department:一堆里有多个name,引擎不知道该留哪一个(严格模式报错)。
▸`COUNT(*)` 里的 `*` 是什么
SELECT *的*= 所有列。COUNT(*)的*= 按行计数(历史写法),不是把各列加起来。COUNT(age)只数age非空的行;COUNT(*)行在就数,不管哪列是 NULL。
过滤行 vs 过滤组(同一张表走两遍)
执行顺序要记住这一段:
1 | FROM 取出行 |
WHERE |
HAVING |
|
|---|---|---|
| 时机 | 分组之前 | 分组并算完聚集之后 |
| 对象 | 每一行(小明这一行要不要) | 每一组(CS 这一组要不要) |
能否用 COUNT/AVG |
不能(组还没形成) | 能 |
| 典型条件 | age >= 20、department = 'CS' |
COUNT(*) >= 2、AVG(age) > 20 |
只过滤行:WHERE
题意:只统计 age ≥ 20 的学生,再按系数人数。
1 | SELECT department, COUNT(*) AS cnt |
WHERE后剩下:小明20、小红22、小美21(3 行)。- 分堆:CS={小明,小红},EE={小美};Bio 空堆,不出现。
- 结果:
| department | cnt |
|---|---|
| CS | 2 |
| EE | 1 |
注意:小刚虽是 EE,但被 WHERE 提前丢掉,不会进 EE 的 COUNT。
只过滤组:HAVING
题意:先按系统计全部学生,再只要「至少 2 人」的系。
1 | SELECT department, COUNT(*) AS cnt |
- 不分
WHERE,5 行全进分组 → CS:2,EE:2,Bio:1。 HAVING看的是每组的COUNT(*),不是某一行的 age。- 结果:
| department | cnt |
|---|---|
| CS | 2 |
| EE | 2 |
Bio 组被丢掉时,小强整个人都不输出——因为输出单位已经是「组」而不是「行」。
两个一起用(对照)
1 | -- ① 先丢掉年龄 < 20 的行 |
| 步骤 | 还剩什么 |
|---|---|
WHERE age >= 20 |
小明、小红、小美 |
GROUP BY |
CS:2,EE:1 |
HAVING COUNT(*) >= 2 |
只留 CS:2(EE 只剩 1 人,组被丢) |
若把 HAVING COUNT(*) >= 2 误写成 WHERE COUNT(*) >= 2 → 报错:WHERE 阶段没有组,没有 COUNT。
▸口诀
- 条件里是一行上的列(age、salary、sid)→ 多半
WHERE。 - 条件里是一组上的统计(人数、均薪、总学分)→ 必须
HAVING(或先写成子查询/CTE 再在外层WHERE)。
规则补充:
SELECT中未进入聚集的列,必须出现在GROUP BY中(严格模式)。COUNT(*)计行;COUNT(col)忽略该列 NULL;SUM/AVG忽略 NULL;输入全空时SUM/AVG常为 NULL,COUNT(*)为 0。
例:系均薪高于全校均薪
instructor(小数据)
| name | dept_name | salary |
|---|---|---|
| 张三 | CS | 90 |
| 李四 | CS | 70 |
| 王五 | EE | 80 |
| 赵六 | EE | 100 |
| 钱七 | Bio | 60 |
1 | -- 系均薪高于全校均薪的系 |
带入数值:
- 全校均薪(标量子查询,不分系):$(90+70+80+100+60)/5 = 80$。
- 各组均薪:CS $(90+70)/2 = 80$;EE $(80+100)/2 = 90$;Bio $= 60$。
HAVING AVG(salary) > 80→ 只留 EE(90)。CS 等于 80 不进;Bio 低于不进。
| dept_name | avg_salary |
|---|---|
| EE | 90 |
例:每个系参加社团的学生百分比
student
| sid | name | department |
|---|---|---|
| 1 | 小明 | CS |
| 2 | 小红 | CS |
| 3 | 小刚 | EE |
| 4 | 小美 | EE |
| 5 | 小强 | Bio |
member(只列出 sid;一人可多社,故后面用 DISTINCT)
| sid | cid |
|---|---|
| 1 | 10 |
| 1 | 20 |
| 2 | 10 |
| 4 | 10 |
1 | -- WHERE 先留下“参加过社团”的学生,再按系计数 |
带入数值:
SELECT sid FROM member→{1, 2, 4}(去重后;小明两社仍算一人)。WHERE sid IN (...)后剩下:小明(CS)、小红(CS)、小美(EE)。小刚、小强未入社被丢掉。- 分母:全校
COUNT(*)$= 5$。 - 按系:CS 有 2 人 → $2 \times 100.0 / 5 = 40$;EE 有 1 人 → $1 \times 100.0 / 5 = 20$;Bio 无人入社 → 无行。
| department | percentage |
|---|---|
| CS | 40 |
| EE | 20 |
若分母改成「本系人数」:CS 为 $2/2=100$,EE 为 $1/2=50$(题意不同,面试常追问这一点)。
子查询
查询里再套查询。为什么需要:条件依赖“另一查询的结果”(高于平均、属于某集合、存在关联行),一层 SELECT 写不清。
标量
1 | -- 子查询返回单个值,拿来比较 |
标量子查询必须返回至多一行一列(多行会报错)。
IN / NOT IN
1 | -- IN:sid 落在“社团 1 的成员”集合里 |
▸NOT IN 与 NULL
子查询结果含 NULL 时,NOT IN 整体可能恒为未知,导致结果为空。更稳妥:NOT EXISTS。
SOME / ALL / ANY
1 | -- > SOME:至少大于集合中某一个(“比 Comp. Sci. 里某人高”) |
= SOME 等价 IN;<> ALL 等价 NOT IN(仍需注意 NULL)。ANY 与 SOME 同义。
EXISTS / NOT EXISTS
1 | -- EXISTS:只要子查询有一行就算真(不关心选出什么,常写 SELECT 1) |
相关子查询:内层引用外层元组。逻辑上对外层每行执行一次(优化器可能改写为半连接)。
FROM 中的子查询
1 | -- 先算出“每系均薪”当临时表 t,再筛均薪 > 50000 |
标准 SQL 中,FROM 子查询一般不能引用同级其他表列,除非 LATERAL(方言支持不一)。
全称量化
自然语言「所有……都……」,SQL 没有 FOR ALL。标准改写:
$$
\text{对所有 } x,\ P(x)
\quad\Longleftrightarrow\quad
\text{不存在 } x,\ \text{使得 }\neg P(x)
$$
口语:全部成立 ⟺ 找不到反例。SQL 用 NOT EXISTS 写“找不到”。
积木:EXISTS 的真假(先死记)
1 | EXISTS (子查询) -- 子查询能取出 ≥1 行 → 整个为 TRUE |
相关子查询:里层用到外层当前行(如 student.sid)。外层换一个人,里层就重跑一遍。
例 1:参加了 JL SUN 的「所有」俱乐部
题目
找出:JL SUN 管的每一个社,这个学生都进了。
(不是“至少进一个”,是“一个都不能少”。)
小数据
club
| cid | name | supervisor |
|---|---|---|
| 10 | 舞蹈社 | JL SUN |
| 20 | 篮球社 | JL SUN |
| 30 | 棋社 | 别人 |
member
| sid | cid | 含义 |
|---|---|---|
| 1 | 10 | 小明 → 舞蹈 |
| 1 | 20 | 小明 → 篮球 |
| 2 | 10 | 小红 → 舞蹈(没进篮球) |
- JL SUN 的全集 = {10, 20}(棋社 30 不管)
- 小明两个都有 → 要
- 小红缺 20 → 不要
先写成人话三层(不要急着看 SQL)
先改成否定句:不存在一个JL SUN的社团,这个学生没有参与。
对某一个学生 S:
1 | 第 0 层 正在考察学生 S |
对应到双重否定:
1 | S 合格 |
SQL:每一层在干什么(对着缩进看)
1 | SELECT student.name |
▸最容易晕的一点
第 2 层的 NOT EXISTS:不是最终答案,只负责定义「没参加」。
真正决定「选不选这个学生」的,是第 1 层外面的那个 NOT EXISTS(有没有坏社)。
真假跳变表(建议抄一遍)
固定学生 S、俱乐部 C:
| member 里有 (S,C)? | 第 2 层 NOT EXISTS |
含义 | C 会不会进第 1 层结果(在 JL SUN 前提下) |
|---|---|---|---|
| 有 | 假 | 参加了 | 不会(AND 短路掉) |
| 没有 | 真 | 没参加 | 会(这是坏俱乐部) |
再往外:
| 第 1 层能查出坏俱乐部吗 | 第 1 层外的 NOT EXISTS |
学生 S |
|---|---|---|
| 能(≥1 个坏社) | 假 | 丢掉 |
| 不能(0 个坏社) | 真 | 留下 |
用小明走(应留下)
- 第 0 层:S = 小明 (sid=1)。
- 扫 JL SUN 的社。
- C=10:member 有 (1,10) → 第 2 层
NOT EXISTS=假 → 10 不是坏社。 - C=20:有 (1,20) → 同理不是坏社。
- 第 1 层结果空 → 外层
NOT EXISTS=真 → 输出小明。
用小红走(应丢掉)
- S = 小红 (sid=2)。
- C=10:有 (2,10) → 不是坏社。
- C=20:没有 (2,20) → 第 2 层
NOT EXISTS=真 → 20 是坏社,第 1 层查到一行。 - 外层
NOT EXISTS=假 → 不输出小红。
▸嵌套括号(人话版)
1
2
3
4
5对每个学生 S:
若不存在俱乐部 C,使得
( C.supervisor = 'JL SUN'
且 不存在 member 行 (S 进了 C) )
则选出 S
例 2:只有女生的俱乐部(单层,练手)
只要一层:反例 =「社里有个非女生」。
1 | SELECT club.name |
不要空社时再加:
1 | AND EXISTS (SELECT 1 FROM member WHERE member.cid = club.cid) |
例 3:选了 Comp. Sci.「全部」课(与例 1 同构)
把「JL SUN 的社」换成「CS 系的课」,结构一字不差:
1 | SELECT DISTINCT s.ID |
验算可用计数(同一门不重复计时):
1 | -- 选中的 CS 课门数 = CS 课总门数 → 全选了 |
▸认题
| 题意 | 全集 | 反例 | 几层 NOT EXISTS |
|---|---|---|---|
| 所有 JL SUN 社都参加 | JL SUN 的 club | 某社没进 | 两层 |
| 只有女生 | 该社成员 | 有个非女生 | 一层 |
| 全部 CS 课都选了 | CS 的 course | 某门没选 | 两层 |
口诀:全称 → 找反例 → 外层 NOT EXISTS;反例若是「缺一条关联记录」→ 里层再 NOT EXISTS。
WITH 与 CTE
CTE 是什么
CTE(Common Table Expression,公用表表达式)= 给一段查询起临时名字,写在 WITH 里,后面像用表一样用。
复杂逻辑若全塞进一层层括号,阅读成本高;拆成「先算出中间表 → 再查」更清楚。作用域只在本条 SQL,不是永久建表。
读法:派生表 vs CTE(同一题两写法)
目标不变:找出 Comp. Sci. 系里 salary > 80000 的教师姓名。
写法 A:派生表(括号嵌套,由内向外读)
1 | SELECT name -- ← 第 2 层:最终要的列 |
执行顺序:① 算出括号里的表 t → ② 对 t 做外层 WHERE/SELECT。
写法 B:CTE(先定义后使用,从上往下读)
1 | -- ========== 第 1 步:给中间结果起名(还没最终输出)========== |
语义与写法 A 完全等价;差别只在书写顺序:CTE 把「里层」提到语句最上面。
▸小数据走一遍
假设 instructor 有:
| name | dept_name | salary |
|---|---|---|
| 张三 | Comp. Sci. | 90000 |
| 李四 | Comp. Sci. | 70000 |
| 王五 | Biology | 95000 |
- 第 1 步 CTE → 临时表只有张三、李四(王五被 dept 滤掉)。
- 第 2 步
salary > 80000→ 只剩张三。
多个 CTE:流水线,不是洋葱括号
逗号分隔多个定义;后面的可以引用前面的,像传送带:
1 | -- 传送带第 1 站:CS 教师 |
| 步骤 | 名字 | 输入 | 输出(相对上例) |
|---|---|---|---|
| 1 | cse |
instructor |
张三、李四 |
| 2 | cse_high |
cse |
张三 |
| 3 | 主查询 | cse_high |
张三 |
CTE vs 视图 vs 子查询
| 存活多久 | 怎么读 | |
|---|---|---|
| 子查询 / 派生表 | 仅本句 | 往往要由内向外抠括号 |
WITH CTE |
仅本句 | 从上往下,先定义后用 |
VIEW 视图 |
建完一直在库里 | 像真表,多语句可反复用 |
递归 CTE:锚点 + 递归臂
用于走树/链表:组织架构、评论回复、账单拆分。方言需支持 WITH RECURSIVE。
固定两段,用 UNION ALL 拼接:
1 | 临时表 = 锚点(起点,只跑一次) |
小组织树
emp
| id | name | manager_id |
|---|---|---|
| 1 | 老板 | NULL |
| 2 | 经理A | 1 |
| 3 | 经理B | 1 |
| 4 | 员工甲 | 2 |
| 5 | 员工乙 | 2 |
树形:老板 → 经理A → 员工甲/乙,以及 老板 → 经理B。
目标:列出 id=1 老板的全部下属(含间接)。
1 | WITH RECURSIVE subordinates AS ( |
按轮次展开(对着 JOIN 条件看)
| 轮次 | s(已有) |
e.manager_id = s.id 对上谁 |
本轮新增 |
|---|---|---|---|
| 锚点 | — | — | (1, 老板, lvl0) |
| 第 1 轮 | 只有老板 id=1 | 经理A、经理B 的 manager_id=1 | (2,A,1)、(3,B,1) |
| 第 2 轮 | 含 1、2、3 | 甲/乙 的 manager_id=2 | (4,甲,2)、(5,乙,2) |
| 第 3 轮 | 含全部 | 无人再挂在已有 id 下 | 空 → 停止 |
最终 SELECT * 得到上述全部累计行(含老板本人;只要下属可加 WHERE lvl > 0)。
▸最容易晕的一点
递归臂里的 JOIN subordinates 不是去查另一张永久表,而是引用本 CTE 到目前为止已算出的行。
每一轮只拿「上一轮新进的行」去扩下一层(实现上可能优化,语义上按「越扩越大」理解即可)。
窗口函数
和 GROUP BY 差在哪
GROUP BY |
窗口 OVER(...) |
|
|---|---|---|
| 行数 | 多行收成更少行 | 行数不变,旁边多挂几列 |
| 要什么 | 「每系均薪」一张汇总表 | 「每个教师仍一行 + 本系均薪/名次」 |
同一批数据对比:
1 | -- GROUP BY:CS 三行 → 收成 1 行 |
语法:从外往里拆
1 | 函数(...) OVER ( |
读一条带窗口的 SELECT 时:先看 FROM 得到明细行 → 再对每一行,按 OVER 规则算出旁注列。
小数据:分层算 RANK 与 AVG
instructor(片段)
| name | dept_name | salary |
|---|---|---|
| 张三 | CS | 90 |
| 李四 | CS | 70 |
| 王五 | CS | 90 |
| 赵六 | EE | 80 |
1 | SELECT |
只盯 CS 组(张三这一行怎么得到 rk、dept_avg):
PARTITION BY dept_name→ 当前窗口成员 = {张三90, 李四70, 王五90}。ORDER BY salary DESC→ 次序:张三与王五并列最高,李四最低。RANK():并列都给 1,下一个空号到 3 → 张三 rk=1,王五 rk=1,李四 rk=3。AVG:$(90+70+90)/3 \approx 83.3$,三行都抄同一值。
逻辑结果:
| name | dept | salary | rk | dept_avg |
|---|---|---|---|---|
| 张三 | CS | 90 | 1 | 83.3… |
| 王五 | CS | 90 | 1 | 83.3… |
| 李四 | CS | 70 | 3 | 83.3… |
| 赵六 | EE | 80 | 1 | 80 |
RANK / DENSE_RANK / ROW_NUMBER 不同在哪:
并列时三种函数给出的「名次」不一样。只盯 CS 三行(薪 90、90、70),并排写:
1 | SELECT |
| name | salary | RANK |
DENSE_RANK |
ROW_NUMBER |
|---|---|---|---|---|
| 张三 | 90 | 1 | 1 | 1(或 2) |
| 王五 | 90 | 1 | 1 | 2(或 1) |
| 李四 | 70 | 3 | 2 | 3 |
怎么记:
| 函数 | 并列时 | 下一名 | 一句话 |
|---|---|---|---|
RANK |
同名次 | 跳号(占掉并列人数) | 奥运式:两人并列第 1 → 下一名是第 3 |
DENSE_RANK |
同名次 | 不跳号 | 密级:两人并列第 1 → 下一名仍是第 2 |
ROW_NUMBER |
强制拆开(各不同号) | 连续 1,2,3… | 只排队编号;并列谁先谁后取决于排序稳定性/额外排序键 |
DENSE_RANK 名字里的 dense =「名次挤在一起、中间不留空号」。
为何第二高薪常用 DENSE_RANK:
薪水:100, 100, 90, 80。
| salary | RANK |
DENSE_RANK |
|---|---|---|
| 100 | 1 | 1 |
| 100 | 1 | 1 |
| 90 | 3 | 2 |
| 80 | 4 | 3 |
WHERE rk = 2配RANK→ 没有第 2 名(被跳掉了),90 是 3。- 配
DENSE_RANK→ 90 的rk = 2,正是「第二高的薪资档」。
1 | -- 第二高的 distinct 薪资档(并列最高时仍能找到下一档) |
和 Top-K 怎么配合:
- 「薪资前三档」(并列都算进同一档)→
DENSE_RANK() ... WHERE rk <= 3。 - 「严格前三人」(只要 3 行)→
ROW_NUMBER() ... WHERE rk <= 3。 - 「前三名,并列占名额且跳号」→
RANK()(两人并列第 1 时,rk <= 3可能留下第 1、1、3 共三人,或更多人视数据而定)。
累计和:窗口帧在干什么
1 | SELECT name, salary, |
假设按 salary 排序后为:李四70 → 赵六80 → 张三90 → 王五90,则 running 依次为 70、150、240、330(具体并列次序依实现/排序稳定性)。
嵌套一层:每组 Top-K
窗口不能直接在同一层 WHERE rk <= 1(WHERE 早于窗口计算)。正确读法是「先开窗,再包一层」:
1 | -- ========== 外层:只留 rk=1 ========== |
常用:ROW_NUMBER、RANK/DENSE_RANK、LAG/LEAD(上一行/下一行)、SUM/AVG/... OVER。
▸选型
- 只要汇总表 →
GROUP BY - 明细 + 旁注 → 窗口
- 每组 Top-K → 内层窗口出
rk,外层再WHERE rk <= K
NULL 与三值逻辑
NULL = 未知/缺失,不是 0,不是 ''。
比较运算遇到 NULL → 结果是 UNKNOWN(第三种逻辑值,不是 TRUE/FALSE)。WHERE / HAVING 只保留结果为 TRUE 的行;UNKNOWN 与 FALSE 一样被丢掉。
| 表达式 | 结果 | 进不进 WHERE |
|---|---|---|
1 = NULL |
UNKNOWN | 不进 |
NULL = NULL |
UNKNOWN | 不进 |
age = age(该行 age 为 NULL) |
UNKNOWN | 不进 |
age IS NULL |
TRUE | 进 |
age IS NOT NULL |
视情况 | 非空才进 |
1 | -- 错:= NULL 永远不是 TRUE,筛不出「空年龄」 |
▸小数据
| name | age |
|---|---|
| 小明 | 20 |
| 小红 | NULL |
WHERE age = 20 → 只有小明。WHERE age <> 20 → 没有小红(NULL <> 20 是 UNKNOWN)。
要「年龄不是 20(含未知)」需另写逻辑,例如 age IS NULL OR age <> 20。
聚集:COUNT(*) 计行;COUNT(age) / AVG(age) 跳过 age 为 NULL 的行。
视图
视图 = 把一段 SELECT 存进库里的名字,之后当表名用。
| CTE | 视图 | |
|---|---|---|
| 寿命 | 本条 SQL 结束即消失 | CREATE 后一直在 |
| 典型用途 | 单条语句内分层 | 多语句复用、授权裁剪 |
1 | -- ========== 第 1 步:定义(执行一次,写入数据字典)========== |
用途:少写重复过滤条件;只把视图的 SELECT 权限授给某角色(看不到其它列/行);基表改名/拆分时少改应用 SQL。
▸视图不是备份
默认不存数据副本;基表变,再查视图结果就变。物化视图是另一话题。
完整性与触发器
声明式约束(主键、外键、CHECK)见 DDL「键与约束」。
触发器 = 对表发生 INSERT/UPDATE/DELETE 时,由引擎自动执行的一段过程。
时机:一层层发生什么
1 | 客户端发出:UPDATE instructor SET salary = ... WHERE ... |
示例:涨薪过大时做处理
1 | CREATE TRIGGER bump_salary |
读法:先看 AFTER UPDATE OF salary(何时)→ 再看 WHEN(哪些行)→ 最后看 BEGIN...END(做什么)。
权限
(DCL)控制谁能对哪个对象做什么操作。
1 | -- ① 建角色(权限的篮子,还不是某个具体登录名) |
层次可记:特权 → 角色 → 用户。原则:够用即可,避免长期 WITH GRANT OPTION 扩散。
SQL 注入
(SQL Injection)
把不可信输入直接拼进 SQL 字符串时,攻击者可改写语句语义。权限管“谁能执行什么”,注入管的是“语句本身是否仍是开发者写的那一句”。面试与安全基线都会问。
定义
应用若用字符串拼接构造查询,例如把登录框里的用户名直接粘进:
1 | "SELECT * FROM users WHERE name = '" + 用户输入 + "'" |
则输入不再只是“数据”,而可能变成 SQL 的一段语法(多一个引号、多一个 OR、多一句语句)。这就是 SQL 注入。
为什么会出现:早期图省事用拼接;模板引擎/ORM 用错;动态表名/排序字段白名单没做好。
经典形态
登录绕过
意图:按姓名查用户。
1 | -- 开发者心里的语句 |
若 name 来自输入且拼成字符串,恶意输入形如:
1 | alice' OR '1'='1 |
拼出的效果等价于(示意):
1 | SELECT * FROM users WHERE name = 'alice' OR '1'='1' AND pass = '...'; |
更常见的教材写法是密码处注入 ' OR '1'='1' --,用注释吃掉后半句。要点只有一个:输入改变了语句结构。
联合查询拖库
在原本只返回少量列的查询后,用 UNION SELECT ... 拼出额外结果集,试图读其他表。前提往往是:注入点在 SELECT 查询中、列数/类型可对齐、错误信息或页面回显可利用。
盲注
页面不回显查询结果,但可通过真/假条件导致的页面差异、时间延迟等推断数据。防御思路与显式注入相同:不让输入变成语法。
▸说明
上文只为理解机制与面试问答。对未授权系统做注入测试违法;实验请用自建库或专门靶场(如 DVWA、本地 Docker 练习环境)。
根因
| 错误做法 | 问题 |
|---|---|
| 字符串拼接 SQL | 数据与代码边界消失 |
仅过滤关键词(禁 OR/SELECT) |
易绕过,黑名单不可靠 |
| 前端校验当唯一防线 | 请求可被直接重放、绕过浏览器 |
| 数据库账号权限过大 | 一旦注入,破坏面从“读一行”扩大到“删库、读系统表” |
防护
参数化查询 / 预编译(首选)
把 SQL 结构固定,用户输入只作为绑定参数,不再参与解析。
1 | -- 语句形态固定,? 或 :name 只代表“值”的占位 |
应用侧(示意,非某一语言绑定):
1 | prepare("SELECT * FROM users WHERE name = ? AND pass = ?") |
JDBC / PDO / MyBatis #{} / ORM 正确用法都属于这一路。注意:有的模板 ${} 仍是拼接,用错等于没防。
其他配套
| 手段 | 作用 |
|---|---|
| 最小权限 | 应用账号不要用 DBA;按需 GRANT,即使注入也难 DROP DATABASE |
| 输入校验 | 长度、类型、白名单(如排序字段只允许 age/name 几个字面量) |
| 避免直接拼动态标识符 | 表名、列名不能参数化时,必须白名单映射,禁止用户字符串原样进 SQL |
| 错误信息不回显 | 生产环境不把堆栈/SQL 原文返回给浏览器,降低探测信息 |
| WAF / 审计 | 辅助层,不能替代参数化 |
▸安全写法对照
危险(拼接)
1
sql = "SELECT * FROM student WHERE sid = '" + sid + "'"
安全(参数化)
1
2sql = "SELECT * FROM student WHERE sid = ?"
bind(sid) -- sid 无论含引号还是 OR,都不会改变 WHERE 结构
面试要点
- 定义:不可信输入进入 SQL 并改变语义。
- 主因:拼接;主防:预编译/参数绑定。
- 黑名单过滤不够;权限最小化能降损。
- ORM 不等于自动安全,仍可能拼字符串或误用拼接占位符。
习题
面试题型清单
下列题目在语句旁用注释标出考点与为何这么写;先自己写再对照。
基础
- 第二高薪(注意并列):窗口
DENSE_RANK或两次MAX子查询。 - 连续登录 N 天:窗口差值 / 自连接日期。
- 每个部门工资前三:
RANK() OVER (PARTITION BY ...)。 - 有经理的员工与无经理的员工:
LEFT JOIN+IS NULL。
1 | -- 考点:分组内 Top-K;DENSE_RANK 并列同名次且不跳号 |
中级嵌套
1 | -- 考点:相关子查询;每人跟“自己所在系”的均薪比,不是全校 |
1 | -- 考点:反存在;LEFT JOIN ... IS NULL 亦可 |
高级:相关 + 分组 + 连接
1 | -- 至少选修了两门同系课程的学生 |
1 | -- 找出没有不合格成绩(无 F / 无空可按题意)且学分总和最高的学生 |
补充例子
第二高薪
1 | SELECT MAX(salary) AS second_high |
1 | SELECT DISTINCT salary |
没有下属的员工
1 | SELECT e.name |
连续出现三次的数字(经典)
1 | SELECT DISTINCT l1.num AS ConsecutiveNums |
部门工资最高的员工
1 | SELECT d.name AS Department, e.name AS Employee, e.salary |
换座(奇数换到下一个,末行奇数不动)
1 | SELECT |
分数排名(RANK 跳号 / DENSE_RANK 不跳号)
1 | -- 并列同分同名次,下一名不留空号(LeetCode 178 常用 DENSE_RANK) |
每月首次登录
1 | SELECT user_id, MIN(login_date) AS first_day |
三角连接:三人互相成为好友(对称边需规范)
1 | SELECT a.person AS p1, b.person AS p2, c.person AS p3 |
用窗口算同比
1 | SELECT ym, revenue, |
ZJU习题
Quiz2 五题
模式:student(sid,name,age,gender,department),club(cid,name,supervisor),member(sid,cid,date)。
(1) CS 且舞蹈社
1 | SELECT student.name |
(2) JL SUN 监督的全部俱乐部之成员
1 | SELECT student.name |
(3) 只有女生的俱乐部
1 | SELECT club.name |
若要求俱乐部非空,追加:
1 | AND EXISTS ( |
(4) 每系“参加过社团”的学生占全校比例
1 | SELECT department, |
分母若改为“本系人数”:
1 | SELECT s.department, |
(5) 年龄差最大的两人
1 | SELECT s1.name, s2.name, ABS(s1.age - s2.age) AS age_difference |
408 向 SQL 句式
查询“从不 / 没有”
1 | -- 没有选任何课的学生 |
查询“至少 / 恰好 / 至多”
1 | SELECT ID |
查询“高于所在组平均”
1 | SELECT * |
用 EXISTS 表达交与差
1 | -- 既选了 A 课又选了 B 课 |
更新中的子查询
1 | UPDATE instructor |
删除“没有参照”的行
1 | DELETE FROM course |
复杂嵌套模板库
模板 A:全称量化(除法)
1 | -- r ÷ s : 选了 s 中全部课程的学生 |
模板 B:双重否定“唯一性”
1 | -- 只开设在一个校区的课程(示意) |
模板 C:排名后取区间
1 | SELECT * FROM ( |
模板 D:缺口补齐(日历 / 连续)
1 | -- 找出断签:前一天登录过且当天未登录(示意) |
1 | SELECT user_id, MIN(dt) AS start_dt, MAX(dt) AS end_dt, COUNT(*) AS days |
易错清单
WHERE里写聚集 → 应放HAVING。SELECT非聚集列未进GROUP BY。NOT IN子查询含 NULL。COUNT(col)与COUNT(*)混淆。- 旧式逗号连接漏连接条件 → 意外笛卡尔积。
UNION与UNION ALL性能与去重差异。- 相关子查询相关性列写错表别名。
- 全称量化少写一层
NOT EXISTS。 ORDER BY别名在部分方言/子查询中不可用。- 事务题与 SQL 题混淆隔离级别默认值(产品相关)。


