主题
界面
主题
SQL 是"与数据库对话的语言"。本节把最常用的四类语句走一遍:建表(DDL)、增删改(DML)、查询(DQL)。
查询部分是本节的重点——SQL 的书写顺序与执行顺序不一致,理解这一点能解释后面很多"为什么这么写不行"的问题。
完成本节后,你应该能够:
GROUP BY + 聚合函数做统计,用 HAVING 过滤分组结果;JOIN 关联两张表,用子查询做条件筛选;NULL 比较、笛卡尔积、UPDATE 忘写 WHERE 这三个高频陷阱。SQL 是后端开发的"第二语言":接口的每一次数据读写,最终都会变成一条 SQL。
而它有一类独特的风险——写错了不会报错,但结果完全不对:
-- 想更新一条记录,忘了 WHERE
UPDATE product SET stock = 0; -- 全表库存清零("删库跑路"的经典来源)
-- 想查"库存为空的商品",用了 = NULL
SELECT * FROM product WHERE stock = NULL; -- 永远返回 0 行(NULL 不能用 = 比较)
-- 想做关联查询,忘了 JOIN 条件
SELECT * FROM orders, product; -- 笛卡尔积:行数 = 订单数 × 商品数这类错误语法完全合法——数据库不会阻止你,只会忠实地执行。这就是为什么 SQL 基础必须打牢。
| 分类 | 全称 | 包含 | 例子 |
|---|---|---|---|
| DDL | 数据定义语言 | 建/改/删结构 | CREATE / ALTER / DROP / TRUNCATE |
| DML | 数据操纵语言 | 增/改/删数据 | INSERT / UPDATE / DELETE |
| DQL | 数据查询语言 | 查询 | SELECT |
| DCL | 数据控制语言 | 权限 | GRANT / REVOKE |
日常开发中 90% 的时间在 DML 与 DQL 上;DDL 通常由建表脚本或迁移工具管理;DCL 由 DBA 负责。
一个容易混淆的点:DELETE 与 TRUNCATE 都能"清空表",但归属不同:
DELETE FROM t | TRUNCATE TABLE t | |
|---|---|---|
| 分类 | DML | DDL |
| 能否带 WHERE | ✅ | ❌(只能全表) |
| 能否回滚 | ✅(在事务里) | ❌(隐式提交) |
| 速度 | 慢(逐行删 + 记日志) | 快(直接重建) |
| 自增 ID | 不重置 | 重置为 1 |
书写顺序:
SELECT 列
FROM 表
WHERE 行条件
GROUP BY 分组列
HAVING 分组条件
ORDER BY 排序列
LIMIT 数量;执行顺序(数据库实际的处理顺序):
1. FROM ← 先确定数据从哪来(含 JOIN)
2. WHERE ← 过滤行
3. GROUP BY ← 分组
4. HAVING ← 过滤分组
5. SELECT ← 计算要输出的列(别名在这里才产生)
6. ORDER BY ← 排序
7. LIMIT ← 截断这个顺序能解释三条"反直觉"的规则:
| 规则 | 原因 |
|---|---|
WHERE 里不能用聚合函数(如 WHERE COUNT(*) > 1) | WHERE 执行时还没分组,聚合值不存在 |
WHERE 里不能用 SELECT 的别名 | 别名在第 5 步才产生,而 WHERE 是第 2 步 |
HAVING 里可以用聚合函数 | 它在第 4 步,分组已完成 |
记忆口诀:"先找表,再筛行;分完组,筛组;最后选列、排序、限量。"
NULL 表示"未知",不是 0、也不是空字符串。任何与 NULL 的比较结果都是"未知"(不是真也不是假):
SELECT * FROM product WHERE stock = NULL; -- 永远 0 行(不会报错!)
SELECT * FROM product WHERE stock IS NULL; -- 正确写法
SELECT * FROM product WHERE stock IS NOT NULL; -- 非空三个容易踩的点:
| 写法 | 结果 | 说明 |
|---|---|---|
WHERE col = NULL | 永远不成立 | 必须用 IS NULL |
COUNT(*) vs COUNT(col) | 可能不同 | COUNT(*) 数行数;COUNT(col) 跳过 NULL |
SUM(col) 遇到 NULL | 跳过 | 但如果全为 NULL,结果是 NULL 而不是 0(用 COALESCE 兜底) |
一条实践建议:建表时尽量加 NOT NULL + 默认值——能不加 NULL 就不加,因为**"可能为空"会让每一处使用都得考虑它**(这与 《Optional》 的思考方式一致)。
| 函数 | 作用 | 注意 |
|---|---|---|
COUNT(*) | 行数 | 包含 NULL 行 |
COUNT(col) | 非 NULL 的个数 | 跳过 NULL |
COUNT(DISTINCT col) | 去重后的个数 | 常用 |
SUM / AVG / MIN / MAX | 求和/平均/最值 | 都跳过 NULL |
GROUP BY 的规则:出现在 SELECT 里的非聚合列,必须出现在 GROUP BY 里。
-- 正确
SELECT category, COUNT(*) FROM product GROUP BY category;
-- 错误(在严格模式下):name 既不是聚合,也不在 GROUP BY 里
SELECT category, name, COUNT(*) FROM product GROUP BY category;为什么:分组后每一组可能有多行,name 有多个值——数据库不知道你要哪一个。
MySQL 的历史行为:早期版本默认允许上面的"错误"写法(返回随机一行的 name)——这比报错更危险(结果不可预测)。MySQL 5.7 之后默认开启 ONLY_FULL_GROUP_BY ✓
| 类型 | 含义 | 结果 |
|---|---|---|
INNER JOIN | 两边都匹配的行 | 只要交集 |
LEFT JOIN | 左表全部 + 右表匹配的 | 左表没匹配的,右表列为 NULL |
RIGHT JOIN | 右表全部 + 左表匹配的 | 少见(可用 LEFT JOIN 互换) |
SELECT o.id, p.name, o.quantity
FROM orders o
INNER JOIN product p ON p.id = o.product_id;三个要点:
ON 条件不能忘——忘了就变成笛卡尔积(行数相乘);LEFT JOIN 常用于"包含没有关联数据的记录"(如"查出所有商品,包括没有订单的");WHERE p.id IS NULL 可以找"没有匹配的"记录(如"从未被下单的商品")。SELECT name FROM product
WHERE id IN (SELECT DISTINCT product_id FROM orders);子查询的三种典型位置:
| 位置 | 例子 | 用途 |
|---|---|---|
WHERE ... IN (子查询) | 上面那条 | 按另一个表的结果筛选 |
FROM (子查询) | FROM (SELECT ...) AS t | 把查询结果当临时表 |
SELECT (子查询) AS col | SELECT (SELECT COUNT(*) FROM orders) | 取一个标量值 |
性能提示:IN (子查询) 在大数据量下可能较慢,很多时候 JOIN 更快(优化器能更好地处理)。两者语义等价时优先 JOIN(《执行计划》会讲怎么判断)。
SELECT name, price_in_cents FROM product WHERE category = '外设' ORDER BY price_in_cents DESC LIMIT 1;SELECT category,
COUNT(*) AS product_count,
SUM(stock) AS total_stock
FROM product
GROUP BY category
HAVING SUM(stock) > 50; -- 只看库存充足的分组UPDATE 忘写 WHERE UPDATE product SET stock = 0; -- 全表清零这是最危险的一类 SQL 错误——语法正确、执行成功、结果灾难。
防护措施:
| 措施 | 说明 |
|---|---|
先用 SELECT 验证条件 | 把 UPDATE ... WHERE ... 的条件写成 SELECT,确认影响的行数符合预期 |
| 在事务里执行 | BEGIN → UPDATE → 确认行数 → COMMIT / ROLLBACK |
| 生产环境用只读账号 + 审批 | 从权限层面降低误操作的影响 |
| 开启安全更新模式 | MySQL Workbench 默认要求 UPDATE 必须带 WHERE(或主键) |
文件:labs/sql-basics/src/main/java/cn/kb/learn/sql/SqlBasicsLesson.java(节选)
public class SqlBasicsLesson {
public static void main(String[] args) {
try (SqlRunner db = SqlRunner.open()) {
System.out.println("=== 1. DDL:创建表 ===");
db.execute("""
CREATE TABLE product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
price_in_cents INT NOT NULL,
stock INT NOT NULL DEFAULT 0
)
""");
System.out.println("=== 2. INSERT:插入数据 ===");
db.execute("INSERT INTO product (name, category, price_in_cents, stock) VALUES "
+ "('键盘', '外设', 19900, 10), "
+ "('鼠标', '外设', 9900, 25), "
+ "('显示器', '显示', 89900, 5), "
+ "('鼠标垫', '外设', 1900, 100)");
System.out.println("=== 3. SELECT:查询全部 ===");
System.out.print(db.query("SELECT id, name, category, price_in_cents, stock FROM product ORDER BY id"));
System.out.println("=== 4. WHERE + ORDER BY:条件查询 ===");
System.out.print(db.query("""
SELECT name, price_in_cents FROM product
WHERE category = '外设' AND price_in_cents > 2000
ORDER BY price_in_cents DESC
"""));
System.out.println("=== 5. 聚合 + GROUP BY ===");
System.out.print(db.query("""
SELECT category,
COUNT(*) AS product_count,
SUM(stock) AS total_stock
FROM product
GROUP BY category
ORDER BY category
"""));
System.out.println("=== 6. HAVING:过滤分组结果 ===");
System.out.print(db.query("""
SELECT category, SUM(stock) AS total_stock
FROM product GROUP BY category HAVING SUM(stock) > 50
"""));
System.out.println("=== 7. UPDATE + DELETE ===");
int updated = db.execute("UPDATE product SET stock = stock - 3 WHERE name = '键盘'");
int deleted = db.execute("DELETE FROM product WHERE stock = 0");
System.out.println("=== 8. JOIN ===");
System.out.print(db.query("""
SELECT o.id AS order_id, p.name AS product_name, o.quantity
FROM orders o INNER JOIN product p ON p.id = o.product_id
ORDER BY o.id
"""));
System.out.println("=== 9. 子查询 ===");
System.out.print(db.query("""
SELECT name, price_in_cents FROM product
WHERE id IN (SELECT DISTINCT product_id FROM orders)
ORDER BY id
"""));
}
}
}运行命令:
cd labs
./mvnw -pl sql-basics compile exec:exec -Dexec.mainClass=cn.kb.learn.sql.SqlBasicsLesson预期输出:
=== 1. DDL:创建表 ===
表 product 已创建
=== 2. INSERT:插入数据 ===
插入后共 4 行
=== 3. SELECT:查询全部 ===
id | name | category | price_in_cents | stock
----------------------------------------------
1 | 键盘 | 外设 | 19900 | 10
2 | 鼠标 | 外设 | 9900 | 25
3 | 显示器 | 显示 | 89900 | 5
4 | 鼠标垫 | 外设 | 1900 | 100
(共 4 行)
=== 4. WHERE + ORDER BY:条件查询 ===
name | price_in_cents
----------------------
键盘 | 19900
鼠标 | 9900
(共 2 行)
加上 LIMIT 1 只取最贵的一个:
name | price_in_cents
----------------------
键盘 | 19900
(共 1 行)
=== 5. 聚合 + GROUP BY:按分类统计 ===
category | product_count | total_stock | min_price | max_price
---------------------------------------------------------------
外设 | 3 | 135 | 1900 | 19900
显示 | 1 | 5 | 89900 | 89900
(共 2 行)
=== 6. HAVING:过滤分组结果 ===
category | total_stock
-----------------------
外设 | 135
(共 1 行)
=== 7. UPDATE + DELETE:修改与删除 ===
UPDATE 影响行数:1
键盘库存现在是:7
DELETE 影响行数:0
=== 8. JOIN:两张表关联查询 ===
order_id | product_name | quantity | amount_in_cents
-----------------------------------------------------
1 | 键盘 | 2 | 39800
2 | 键盘 | 1 | 19900
3 | 显示器 | 1 | 89900
(共 3 行)
=== 9. 子查询:查出有订单的商品 ===
name | price_in_cents
----------------------
键盘 | 19900
显示器 | 89900
(共 2 行)四处值得对照:
| 输出 | 含义 |
|---|---|
第 4 步:加了 LIMIT 1 后从 2 行变 1 行 | LIMIT 在排序之后执行,所以拿到的是"最贵的那个" |
第 5 步:GROUP BY 后 4 行变 2 行 | 分组把多行"折叠"成每组一行 |
第 6 步:HAVING 从 2 组筛到 1 组 | WHERE 筛行,HAVING 筛组 |
第 7 步:DELETE 影响行数:0 | 因为按键库存变成 7 了,没有 stock = 0 的行——UPDATE 与 DELETE 的顺序有影响 |
db.query(...) 内部断点,观察 resultSet.next() 逐行读取的过程——数据库返回的是游标,不是一次性把所有行塞进内存;db.execute("UPDATE ...") 处观察返回值 1(Statement.getUpdateCount())——这就是"影响行数"的来源,生产代码里常用它做"是否更新成功"的判断;ORDER BY price_in_cents DESC 删掉,观察返回顺序变成"插入顺序"——说明不写 ORDER BY 时顺序是不保证的;ON p.id = o.product_id 删掉,观察行数从 3 变成 orders 行数 × product 行数——亲眼看到笛卡尔积。WHERE 里用聚合函数 SELECT category FROM product WHERE COUNT(*) > 1 GROUP BY category; -- 报错原因:WHERE 在分组之前执行(见 5.2 的执行顺序)。修复:改成 HAVING COUNT(*) > 1。
WHERE 里用 SELECT 的别名 SELECT price_in_cents / 100 AS priceYuan FROM product WHERE priceYuan > 100; -- 报错原因:别名在第 5 步才产生,WHERE 是第 2 步。修复:WHERE price_in_cents > 10000,或把查询包一层子查询。
= NULL 判断空值 SELECT * FROM product WHERE stock = NULL; -- 永远 0 行,且不报错修复:IS NULL / IS NOT NULL。
JOIN 忘了 ON 条件 SELECT * FROM orders o JOIN product p; -- 笛卡尔积判断方法:结果行数等于两张表行数的乘积,就是忘了关联条件。
UPDATE / DELETE 忘写 WHERE 见 6.3 节。这是最危险的一类错误——防护措施见 6.3 的表格。
GROUP BY 缺少非聚合列 SELECT category, name, COUNT(*) FROM product GROUP BY category; -- 严格模式下报错修复:把 name 加到 GROUP BY,或用聚合函数包起来(如 MIN(name)、GROUP_CONCAT(name))。
COUNT(列) 与 COUNT(*) 一样 差别在 NULL:COUNT(*) 数所有行;COUNT(col) 只数 col 非 NULL 的行。
排查思路:"为什么线上统计的数字比预期少" → 检查是不是用了 COUNT(可能为空的列)。
目标:把九类语句各写一遍,并亲手制造三个陷阱。
步骤:
GROUP BY + MAX);ORDER BY 删掉,连跑三次,观察顺序是否变化;JOIN ... ON 去掉,记录返回的行数,验证它等于"订单数 × 商品数";SELECT * FROM product WHERE category = NULL,观察返回 0 行(改成 IS NULL 再试);LEFT JOIN 查出"所有商品,包括从未被下单的",并进一步筛出"从未被下单的商品"。验收方式:能说出第 3 步顺序是否稳定、第 4 步的行数是否等于乘积。
扩展任务:给 product 与 orders 写一条 SQL,查出"每个商品的订单总金额"(提示:LEFT JOIN + GROUP BY + SUM(quantity * price_in_cents),注意 COALESCE 兜底 NULL)。
基础题
WHERE 里不能用聚合函数?代码阅读题
SELECT category, COUNT(*) AS cnt, SUM(stock) AS total
FROM product
WHERE price_in_cents > 1000
GROUP BY category
HAVING SUM(stock) > 50
ORDER BY total DESC
LIMIT 1;WHERE 与 HAVING 各自过滤的是什么?如果把它们互换会怎样?编程题
orders(id, user_id, order_no, created_at)、order_item(order_id, product_id, quantity, unit_price_in_cents);综合任务
提示 1(方向):第 4 题的顺序是"FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT"。
提示 2(关键点):第 6 题 a) 的"含没有订单的用户"需要 LEFT JOIN;d) 的"从未被下单"需要 LEFT JOIN + IS NULL。
提示 3(接近答案):第 7 题的防护思路是"让危险操作无法执行"而不是"执行时小心":只读账号、事务包裹、安全更新模式、SQL 评审、先在从库试跑。
SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT;执行顺序是 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。这个差异解释了三类"反直觉"的规则(见 5.2)。WHERE;HAVING。WHERE 在 GROUP BY 之前执行——WHERE 执行时数据还没分组,聚合值根本不存在。顺带一个理解技巧:把 SQL 想成"流水线",数据从 FROM 进来,依次经过各道工序。WHERE 这道工序在"分组"之前,所以它看不到分组的结果。
第 4 题:执行顺序
1. FROM product
2. WHERE price_in_cents > 1000
3. GROUP BY category
4. HAVING SUM(stock) > 50
5. SELECT category, COUNT(*), SUM(stock)
6. ORDER BY total DESC
7. LIMIT 1注意 ORDER BY total:这里的 total 是 SELECT 里定义的别名——它能用,因为 ORDER BY 在 SELECT 之后执行 ✓(这也解释了为什么 WHERE 里不能用别名)。
第 5 题:
| 子句 | 过滤对象 | 执行时机 |
|---|---|---|
WHERE | 行(分组前) | 第 2 步 |
HAVING | 分组(分组后) | 第 4 步 |
互换会怎样:
-- 把聚合条件放进 WHERE:报错(聚合值还不存在)
WHERE SUM(stock) > 50;
-- 把行条件放进 HAVING:语法上可以,但语义变了
HAVING price_in_cents > 1000;第二点值得展开:HAVING 里写"行条件"在某些数据库里能跑,但语义不同——它是在分组后判断,而分组后的 price_in_cents 是一个"多值列"(除非它也在 GROUP BY 里)。MySQL 严格模式下会报错 ✓
实践建议:行条件一律写 WHERE,组条件一律写 HAVING——不只是为了正确,也是为了让执行计划更优(WHERE 先过滤能减少分组的数据量)。
-- a) 每个用户的订单数(含没有订单的用户)
SELECT u.id,
u.username,
COUNT(o.id) AS order_count
FROM app_user u
LEFT JOIN shop_order o ON o.user_id = u.id
GROUP BY u.id, u.username
ORDER BY u.id;
-- 注意:COUNT(o.id) 而不是 COUNT(*),因为 LEFT JOIN 不匹配时 o.id 为 NULL
-- COUNT(*) 会数成 1(把"没有订单"算成 1 条),这是经典错误
-- b) 每个订单的总金额
SELECT o.order_no,
SUM(i.quantity * i.unit_price_in_cents) AS amount_in_cents
FROM shop_order o
LEFT JOIN order_item i ON i.order_id = o.id
GROUP BY o.id, o.order_no
ORDER BY o.order_no;
-- c) 消费金额最高的前 3 个用户
SELECT u.username,
SUM(i.quantity * i.unit_price_in_cents) AS total_amount
FROM app_user u
JOIN shop_order o ON o.user_id = u.id
JOIN order_item i ON i.order_id = o.id
GROUP BY u.id, u.username
ORDER BY total_amount DESC
LIMIT 3;
-- d) 从未被下单的商品
SELECT p.name
FROM product p
LEFT JOIN order_item i ON i.product_id = p.id
WHERE i.product_id IS NULL;
-- e) 每个月的订单总数与总金额
SELECT SUBSTRING(CAST(o.created_at AS CHAR), 1, 7) AS month,
COUNT(DISTINCT o.id) AS order_count,
SUM(i.quantity * i.unit_price_in_cents) AS total_amount
FROM shop_order o
LEFT JOIN order_item i ON i.order_id = o.id
GROUP BY SUBSTRING(CAST(o.created_at AS CHAR), 1, 7)
ORDER BY month;
-- 真实 MySQL 里更简洁:DATE_FORMAT(o.created_at, '%Y-%m') 或 EXTRACT(YEAR_MONTH FROM o.created_at)三个值得记住的点:
| 点 | 说明 |
|---|---|
a) 用 COUNT(o.id) 而不是 COUNT(*) | LEFT JOIN 不匹配时右表列为 NULL;COUNT(*) 会把它数成 1 行 |
d) 用 LEFT JOIN ... IS NULL 找"没有关联的" | 这是"反连接"的标准写法(比 NOT IN 更高效,且不受 NULL 影响) |
| e) 日期格式化是方言差异最大的地方 | H2 用 SUBSTRING,MySQL 用 DATE_FORMAT——这类函数在不同数据库里差别很大 |
SQL 安全操作规范(核心内容):
## 六类"语法合法但结果危险"的错误
| # | 错误 | 危害 | 检测方法 | 防护 |
|---|------|------|---------|------|
| 1 | UPDATE/DELETE 忘 WHERE | 全表被改/被删 | 先写成 SELECT 看行数 | 安全更新模式 + 事务 + 评审 |
| 2 | JOIN 忘 ON | 笛卡尔积,行数爆炸 | 行数 = 两表行数之积 | 写 JOIN 立刻写 ON |
| 3 | WHERE col = NULL | 返回空结果(不报错) | 检查是否有 = NULL | 用 IS NULL;列尽量 NOT NULL |
| 4 | COUNT(可空列) 当行数用 | 统计数字偏小 | 对比 COUNT(*) | 明确 COUNT(*) 与 COUNT(col) 的区别 |
| 5 | GROUP BY 缺列(非严格模式) | 返回随机行的值 | 看是否开启 ONLY_FULL_GROUP_BY | 开启严格模式 |
| 6 | 类型隐式转换 | 索引失效、结果错误 | 看 EXPLAIN 的 type 列 | 参数类型与列类型一致 |
## 用流程与工具防护(而不是"小心一点")
1. **权限层面**:应用账号只有 DML 权限,没有 DROP/ALTER 权限
2. **执行层面**:
- 危险操作先在**从库**试跑
- 必须包在**事务**里(能 ROLLBACK)
- 开启 **safe updates**(MySQL Workbench / 命令行参数)
3. **流程层面**:
- 生产 SQL **必须评审**(至少一人看过)
- 变更走**迁移脚本**(可追溯、可回滚),而不是手工敲
4. **工具层面**:
- 用 **SQL 审核工具**(如 Archery、Yearning)拦截高危语句
- CI 里对迁移脚本做静态检查
## 生产环境执行 SQL 的检查清单
执行前逐条确认:
- [ ] 这是**在从库试跑过**的语句吗?
- [ ] 有 `WHERE` 吗?`WHERE` 条件命中**索引**吗?
- [ ] 预计影响**多少行**?(用 SELECT 数一遍)
- [ ] 在**事务**里吗?知道怎么 `ROLLBACK` 吗?
- [ ] 有**备份**吗?(大变更前先备份)
- [ ] 执行时间**预估**多久?会不会锁表影响线上?
- [ ] 有人**陪着看**吗?(高危操作不单独执行)核心思路:不要依赖注意力,要依赖机制。
人的注意力一定会衰减(尤其是在赶进度时),而机制不会。所以:
GROUP BY + HAVING + JOIN + 子查询;NULL 不能用 = 比较,知道 COUNT(*) 与 COUNT(col) 的区别;UPDATE / DELETE 忘写 WHERE 的危害与防护措施;《表设计与约束》:约束是数据库层的最后一道防线。