Skip to content

SQL 基础 ​

S3-001必学入门约 90 分钟JDK 21企业开发基础 · MySQL 与数据建模

1. 本节定位 ​

SQL 是"与数据库对话的语言"。本节把最常用的四类语句走一遍:建表(DDL)、增删改(DML)、查询(DQL)。

查询部分是本节的重点——SQL 的书写顺序与执行顺序不一致,理解这一点能解释后面很多"为什么这么写不行"的问题。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 SQL 的四个分类,并知道每类包含哪些语句;
  • 写出建表、插入、查询、更新、删除五类基本语句;
  • 说出 SQL 的书写顺序与执行顺序,并用它解释"为什么 WHERE 里不能用聚合函数";
  • 用 GROUP BY + 聚合函数做统计,用 HAVING 过滤分组结果;
  • 用 JOIN 关联两张表,用子查询做条件筛选;
  • 避开 NULL 比较、笛卡尔积、UPDATE 忘写 WHERE 这三个高频陷阱。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

SQL 是后端开发的"第二语言":接口的每一次数据读写,最终都会变成一条 SQL。

而它有一类独特的风险——写错了不会报错,但结果完全不对:

sql
-- 想更新一条记录,忘了 WHERE
UPDATE product SET stock = 0;          -- 全表库存清零("删库跑路"的经典来源)

-- 想查"库存为空的商品",用了 = NULL
SELECT * FROM product WHERE stock = NULL;    -- 永远返回 0 行(NULL 不能用 = 比较)

-- 想做关联查询,忘了 JOIN 条件
SELECT * FROM orders, product;         -- 笛卡尔积:行数 = 订单数 × 商品数

这类错误语法完全合法——数据库不会阻止你,只会忠实地执行。这就是为什么 SQL 基础必须打牢。

5. 核心概念 ​

5.1 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 tTRUNCATE TABLE t
分类DMLDDL
能否带 WHERE✅❌(只能全表)
能否回滚✅(在事务里)❌(隐式提交)
速度慢(逐行删 + 记日志)快(直接重建)
自增 ID不重置重置为 1

5.2 书写顺序 ≠ 执行顺序(本节最重要的知识点) ​

书写顺序:

sql
SELECT   列
FROM     表
WHERE    行条件
GROUP BY 分组列
HAVING   分组条件
ORDER BY 排序列
LIMIT    数量;

执行顺序(数据库实际的处理顺序):

text
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 步,分组已完成

记忆口诀:"先找表,再筛行;分完组,筛组;最后选列、排序、限量。"

5.3 NULL 的三值逻辑 ​

NULL 表示"未知",不是 0、也不是空字符串。任何与 NULL 的比较结果都是"未知"(不是真也不是假):

sql
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》 的思考方式一致)。

5.4 聚合与分组 ​

函数作用注意
COUNT(*)行数包含 NULL 行
COUNT(col)非 NULL 的个数跳过 NULL
COUNT(DISTINCT col)去重后的个数常用
SUM / AVG / MIN / MAX求和/平均/最值都跳过 NULL

GROUP BY 的规则:出现在 SELECT 里的非聚合列,必须出现在 GROUP BY 里。

sql
-- 正确
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 ✓

5.5 JOIN:关联两张表 ​

类型含义结果
INNER JOIN两边都匹配的行只要交集
LEFT JOIN左表全部 + 右表匹配的左表没匹配的,右表列为 NULL
RIGHT JOIN右表全部 + 左表匹配的少见(可用 LEFT JOIN 互换)
sql
SELECT o.id, p.name, o.quantity
FROM orders o
INNER JOIN product p ON p.id = o.product_id;

三个要点:

  1. ON 条件不能忘——忘了就变成笛卡尔积(行数相乘);
  2. LEFT JOIN 常用于"包含没有关联数据的记录"(如"查出所有商品,包括没有订单的");
  3. 用 WHERE p.id IS NULL 可以找"没有匹配的"记录(如"从未被下单的商品")。

5.6 子查询 ​

sql
SELECT name FROM product
WHERE id IN (SELECT DISTINCT product_id FROM orders);

子查询的三种典型位置:

位置例子用途
WHERE ... IN (子查询)上面那条按另一个表的结果筛选
FROM (子查询)FROM (SELECT ...) AS t把查询结果当临时表
SELECT (子查询) AS colSELECT (SELECT COUNT(*) FROM orders)取一个标量值

性能提示:IN (子查询) 在大数据量下可能较慢,很多时候 JOIN 更快(优化器能更好地处理)。两者语义等价时优先 JOIN(《执行计划》会讲怎么判断)。

6. 递进示例 ​

6.1 最小示例:一条查询 ​

sql
SELECT name, price_in_cents FROM product WHERE category = '外设' ORDER BY price_in_cents DESC LIMIT 1;

6.2 业务示例:统计每个分类的商品数与库存 ​

sql
SELECT category,
       COUNT(*)   AS product_count,
       SUM(stock) AS total_stock
FROM product
GROUP BY category
HAVING SUM(stock) > 50;         -- 只看库存充足的分组

6.3 反例:UPDATE 忘写 WHERE ​

sql
UPDATE product SET stock = 0;                  -- 全表清零

这是最危险的一类 SQL 错误——语法正确、执行成功、结果灾难。

防护措施:

措施说明
先用 SELECT 验证条件把 UPDATE ... WHERE ... 的条件写成 SELECT,确认影响的行数符合预期
在事务里执行BEGIN → UPDATE → 确认行数 → COMMIT / ROLLBACK
生产环境用只读账号 + 审批从权限层面降低误操作的影响
开启安全更新模式MySQL Workbench 默认要求 UPDATE 必须带 WHERE(或主键)

7. 完整可运行代码 ​

文件:labs/sql-basics/src/main/java/cn/kb/learn/sql/SqlBasicsLesson.java(节选)

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
                    """));
        }
    }
}

运行命令:

powershell
cd labs
./mvnw -pl sql-basics compile exec:exec -Dexec.mainClass=cn.kb.learn.sql.SqlBasicsLesson

预期输出:

text
=== 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 的顺序有影响

8. Debug 观察点 ​

  1. 在 db.query(...) 内部断点,观察 resultSet.next() 逐行读取的过程——数据库返回的是游标,不是一次性把所有行塞进内存;
  2. 在 db.execute("UPDATE ...") 处观察返回值 1(Statement.getUpdateCount())——这就是"影响行数"的来源,生产代码里常用它做"是否更新成功"的判断;
  3. 把第 4 步的 ORDER BY price_in_cents DESC 删掉,观察返回顺序变成"插入顺序"——说明不写 ORDER BY 时顺序是不保证的;
  4. 把第 8 步的 ON p.id = o.product_id 删掉,观察行数从 3 变成 orders 行数 × product 行数——亲眼看到笛卡尔积。

9. 常见错误 ​

错误 1:WHERE 里用聚合函数 ​

sql
SELECT category FROM product WHERE COUNT(*) > 1 GROUP BY category;    -- 报错

原因:WHERE 在分组之前执行(见 5.2 的执行顺序)。修复:改成 HAVING COUNT(*) > 1。

错误 2:WHERE 里用 SELECT 的别名 ​

sql
SELECT price_in_cents / 100 AS priceYuan FROM product WHERE priceYuan > 100;   -- 报错

原因:别名在第 5 步才产生,WHERE 是第 2 步。修复:WHERE price_in_cents > 10000,或把查询包一层子查询。

错误 3:用 = NULL 判断空值 ​

sql
SELECT * FROM product WHERE stock = NULL;      -- 永远 0 行,且不报错

修复:IS NULL / IS NOT NULL。

错误 4:JOIN 忘了 ON 条件 ​

sql
SELECT * FROM orders o JOIN product p;         -- 笛卡尔积

判断方法:结果行数等于两张表行数的乘积,就是忘了关联条件。

错误 5:UPDATE / DELETE 忘写 WHERE ​

见 6.3 节。这是最危险的一类错误——防护措施见 6.3 的表格。

错误 6:GROUP BY 缺少非聚合列 ​

sql
SELECT category, name, COUNT(*) FROM product GROUP BY category;    -- 严格模式下报错

修复:把 name 加到 GROUP BY,或用聚合函数包起来(如 MIN(name)、GROUP_CONCAT(name))。

错误 7:以为 COUNT(列) 与 COUNT(*) 一样 ​

差别在 NULL:COUNT(*) 数所有行;COUNT(col) 只数 col 非 NULL 的行。

排查思路:"为什么线上统计的数字比预期少" → 检查是不是用了 COUNT(可能为空的列)。

10. 动手实验 ​

目标:把九类语句各写一遍,并亲手制造三个陷阱。

步骤:

  1. 运行第 7 节的命令,核对输出;
  2. 加一条查询:找出"每个分类里最贵的商品"(提示:GROUP BY + MAX);
  3. 制造陷阱一:把第 4 步的 ORDER BY 删掉,连跑三次,观察顺序是否变化;
  4. 制造陷阱二:把第 8 步的 JOIN ... ON 去掉,记录返回的行数,验证它等于"订单数 × 商品数";
  5. 制造陷阱三:写 SELECT * FROM product WHERE category = NULL,观察返回 0 行(改成 IS NULL 再试);
  6. 加一个统计:用 LEFT JOIN 查出"所有商品,包括从未被下单的",并进一步筛出"从未被下单的商品"。

验收方式:能说出第 3 步顺序是否稳定、第 4 步的行数是否等于乘积。

扩展任务:给 product 与 orders 写一条 SQL,查出"每个商品的订单总金额"(提示:LEFT JOIN + GROUP BY + SUM(quantity * price_in_cents),注意 COALESCE 兜底 NULL)。

11. 练习题 ​

基础题

  1. 判断:SQL 的书写顺序与执行顺序一致。(对 / 错)
  2. 填空:过滤行用 ______;过滤分组用 ______。
  3. 简答:为什么 WHERE 里不能用聚合函数?

代码阅读题

sql
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;
  1. 这条语句的执行顺序是什么?(把各子句按实际执行顺序排列)
  2. WHERE 与 HAVING 各自过滤的是什么?如果把它们互换会怎样?

编程题

  1. 给一个"订单统计"写查询:
    • 表结构:orders(id, user_id, order_no, created_at)、order_item(order_id, product_id, quantity, unit_price_in_cents);
    • 写出以下查询:
      • a) 每个用户的订单数(含没有订单的用户,显示 0);
      • b) 每个订单的总金额;
      • c) 消费金额最高的前 3 个用户;
      • d) 从未被下单的商品;
      • e) 每个月的订单总数与总金额。
    • 每一条都实际跑一遍并记录输出。

综合任务

  1. 写一份"SQL 安全操作规范":
    • 列出 6 类"语法合法但结果危险"的 SQL 错误;
    • 每类给出:错误示例、危害、检测方法、防护措施;
    • 说明:在团队协作中,如何用流程 + 工具(而不只是"小心一点")来防止这些错误?
    • 额外要求:设计一个"生产环境执行 SQL 的检查清单"(执行前必须确认什么)。
分级提示

提示 1(方向):第 4 题的顺序是"FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT"。

提示 2(关键点):第 6 题 a) 的"含没有订单的用户"需要 LEFT JOIN;d) 的"从未被下单"需要 LEFT JOIN + IS NULL。

提示 3(接近答案):第 7 题的防护思路是"让危险操作无法执行"而不是"执行时小心":只读账号、事务包裹、安全更新模式、SQL 评审、先在从库试跑。

12. 参考答案 ​

基础题
  1. 错。书写顺序是 SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT;执行顺序是 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。这个差异解释了三类"反直觉"的规则(见 5.2)。
  2. WHERE;HAVING。
  3. 因为 WHERE 在 GROUP BY 之前执行——WHERE 执行时数据还没分组,聚合值根本不存在。

顺带一个理解技巧:把 SQL 想成"流水线",数据从 FROM 进来,依次经过各道工序。WHERE 这道工序在"分组"之前,所以它看不到分组的结果。

代码阅读题(第 4、5 题)

第 4 题:执行顺序

text
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 步

互换会怎样:

sql
-- 把聚合条件放进 WHERE:报错(聚合值还不存在)
WHERE SUM(stock) > 50;

-- 把行条件放进 HAVING:语法上可以,但语义变了
HAVING price_in_cents > 1000;

第二点值得展开:HAVING 里写"行条件"在某些数据库里能跑,但语义不同——它是在分组后判断,而分组后的 price_in_cents 是一个"多值列"(除非它也在 GROUP BY 里)。MySQL 严格模式下会报错 ✓

实践建议:行条件一律写 WHERE,组条件一律写 HAVING——不只是为了正确,也是为了让执行计划更优(WHERE 先过滤能减少分组的数据量)。

编程题(第 6 题)
sql
-- 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——这类函数在不同数据库里差别很大
综合任务(第 7 题)

SQL 安全操作规范(核心内容):

markdown
## 六类"语法合法但结果危险"的错误

| # | 错误 | 危害 | 检测方法 | 防护 |
|---|------|------|---------|------|
| 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` 吗?
- [ ] 有**备份**吗?(大变更前先备份)
- [ ] 执行时间**预估**多久?会不会锁表影响线上?
- [ ] 有人**陪着看**吗?(高危操作不单独执行)

核心思路:不要依赖注意力,要依赖机制。

人的注意力一定会衰减(尤其是在赶进度时),而机制不会。所以:

  • 能靠权限限制的,不要靠自觉;
  • 能靠工具拦截的,不要靠评审;
  • 能靠流程固化的,不要靠记忆。

13. 掌握标准 ​

  • [ ] 能说出 SQL 的四个分类与各自的语句;
  • [ ] 能背出 SQL 的执行顺序,并用它解释"WHERE 里不能用聚合";
  • [ ] 能写 GROUP BY + HAVING + JOIN + 子查询;
  • [ ] 知道 NULL 不能用 = 比较,知道 COUNT(*) 与 COUNT(col) 的区别;
  • [ ] 知道 UPDATE / DELETE 忘写 WHERE 的危害与防护措施;
  • [ ] 能识别"结果行数异常"通常意味着笛卡尔积或条件写错。

14. 扩展资源 ​

15. 下一步学习 ​

《表设计与约束》:约束是数据库层的最后一道防线。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引