mysql_lab

从理解到上手 · 真实场景练习

基础 CRUD — 增删改查

SELECT — 查询

从表里取数据。可以选列、加条件、排序。
-- 查所有列
SELECT * FROM users;

-- 指定列 + 条件 + 排序
SELECT name, age FROM users
WHERE age >= 18
ORDER BY age DESC;

INSERT — 插入

INSERT INTO users (name, age, email)
VALUES ('张三', 25, 'zs@mail.com');

UPDATE — 更新

⚠️ 一定要加 WHERE,不然更新整张表。
UPDATE users
SET age = 26, email = 'new@mail.com'
WHERE id = 1;

DELETE — 删除

-- 删一行
DELETE FROM users WHERE id = 1;

-- 清空表(快,但不可回滚,重置自增ID)
TRUNCATE TABLE users;

WHERE 条件大全

WHERE 后面可以接各种条件表达式,这些是最常用的:
-- 比较
WHERE price > 100
WHERE status != 1      -- 也可以写 <>

-- 范围
WHERE age BETWEEN 18 AND 30

-- 多值匹配
WHERE status IN (1, 2, 3)

-- 模糊搜索
WHERE name LIKE '%张%'     -- 包含"张"
WHERE email LIKE '%@gmail.com'  -- 以 @gmail.com 结尾

-- NULL 判断(不能用 = NULL)
WHERE phone IS NULL
WHERE phone IS NOT NULL

-- 组合条件
WHERE age > 18 AND status = 1
WHERE role = 'admin' OR role = 'superadmin'

分页查询 — LIMIT / OFFSET

分页的核心就两个参数:跳过几条(OFFSET)和取几条(LIMIT)。
公式:OFFSET = (页码 - 1) × 每页条数
-- 第 1 页,每页 10 条
SELECT * FROM products LIMIT 10 OFFSET 0;

-- 第 2 页
SELECT * FROM products LIMIT 10 OFFSET 10;

-- 第 5 页
SELECT * FROM products LIMIT 10 OFFSET 40;

-- 简写形式(注意:LIMIT 偏移量, 条数 — 顺序相反!)
SELECT * FROM products LIMIT 40, 10;

JOIN 连接 — 完整讲解

为什么需要 JOIN?

在真实项目中,数据分散在不同的表。比如 users 存用户信息,orders 存订单信息。 你想查「每个用户的订单」就需要把两张表连起来。这就是 JOIN。

先看数据

假设我们有这两张表,下面所有 JOIN 的讲解都用这份数据:
users 表
idnamecity
1张三北京
2李四上海
3王五广州
4赵六深圳
orders 表
iduser_idproduct
1011iPhone
1021AirPods
1032MacBook
10499iPad
注意看:张三下了 2 单,李四下了 1 单,王五和赵六没下过单, 而订单 104 的 user_id=99 在 users 里根本不存在。

INNER JOIN — 只取交集

只返回两边都能匹配上的行。匹配不上的直接丢掉。
SELECT u.name, o.product
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
结果:
nameproduct
张三iPhone
张三AirPods
李四MacBook
王五、赵六(没下单)→ 丢掉
订单 104(user_id=99 不存在)→ 丢掉

LEFT JOIN — 左表为主

左表的每一行都保留。右表匹配不上的列填 NULL。
「左表」就是 FROM 后面的那张表。
SELECT u.name, o.product
FROM users u                  -- ← 这是左表
LEFT JOIN orders o ON u.id = o.user_id;
结果:
nameproduct
张三iPhone
张三AirPods
李四MacBook
王五NULL
赵六NULL
王五、赵六没下过单,但作为左表的行,它们不会丢失,product 列填 NULL。
订单 104(user_id=99)在左表 users 里没有对应行 → 不出现。
LEFT JOIN 最常见的用途:找出「没有关联数据」的行。
比如「没下过单的用户」:LEFT JOIN ... WHERE o.id IS NULL

RIGHT JOIN — 右表为主

和 LEFT JOIN 镜像相反。右表的每一行都保留,左表匹配不上的填 NULL。
「右表」就是 JOIN 后面的那张表。
SELECT u.name, o.product
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;  -- ← orders 是右表
结果:
nameproduct
张三iPhone
张三AirPods
李四MacBook
NULLiPad
订单 104 (iPad, user_id=99) 虽然找不到对应用户,但作为右表它被保留了,name 填 NULL。
王五、赵六(没下单)→ 在右表 orders 中没有对应行 → 不出现。

一图理清三种 JOIN

INNER JOIN
L R
只要交集
LEFT JOIN
L R
左边全要
右边匹配不上填 NULL
RIGHT JOIN
L R
右边全要
左边匹配不上填 NULL
实际开发中 RIGHT JOIN 极少使用。任何 RIGHT JOIN 都可以通过交换表的位置改写为 LEFT JOIN,可读性更好。团队代码里基本只用 LEFT JOIN 和 INNER JOIN。

LEFT JOIN 的经典用法

模式 1:找出「没有关联数据」的行
-- 没下过单的用户
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
模式 2:展示全量 + 关联统计
-- 每个用户的订单数(包括 0 单的用户)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
注意这里用 COUNT(o.id) 而不是 COUNT(*),因为 NULL 不会被 COUNT 计入,所以没下单的用户会正确显示 0。

聚合与分组 — GROUP BY

把行按某一列分成几组,然后对每组做聚合计算。
-- 每个部门有多少人、平均工资
SELECT department,
       COUNT(*) AS headcount,
       AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
常用聚合函数:
COUNT(*)          -- 总行数
COUNT(column)     -- 非 NULL 的行数
SUM(column)       -- 求和
AVG(column)       -- 平均值
MAX(column)       -- 最大值
MIN(column)       -- 最小值

WHERE vs HAVING

WHERE

在分组之前过滤行
不能用聚合函数

HAVING

在分组之后过滤组
可以用聚合函数

-- WHERE 先过滤掉离职员工,再按部门分组,HAVING 只要人数 > 5 的部门
SELECT department, COUNT(*) AS cnt
FROM employees
WHERE status = 'active'
GROUP BY department
HAVING COUNT(*) > 5;

子查询

把一个查询的结果当作另一个查询的条件或数据源。
-- 工资高于公司平均的员工
SELECT * FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

-- 买过 iPhone 的用户
SELECT * FROM users
WHERE id IN (
  SELECT user_id FROM orders WHERE product = 'iPhone'
);

SQL 执行顺序

你写的顺序和数据库执行的顺序不一样。理解这个能避免很多坑(比如为什么 WHERE 里不能用别名)。
1. FROM / JOIN       → 确定数据源、连表
2. WHERE             → 过滤行
3. GROUP BY          → 分组
4. HAVING            → 过滤组
5. SELECT            → 选列 / 计算 / 起别名
6. DISTINCT          → 去重
7. ORDER BY          → 排序(可以用别名,因为 SELECT 已执行)
8. LIMIT / OFFSET   → 分页
Rapid Review

先恢复知识地图,再看业务 SQL

这一页不出题,只负责快速唤醒记忆。先用十分钟扫完核心概念,再阅读下面的业务写法;看到陌生点时,再回到“教学”或“练习”标签页深入。

表设计查询顺序索引EXPLAIN 事务与锁窗口函数业务 SQL

纯文字速查 — 忘了就看这里

01 · 表设计与字段类型

金额用 DECIMAL,不要用 FLOAT;时间点常用 DATETIME,自动时间戳可用 TIMESTAMP。字符串长度可控时用 VARCHAR,大文本再用 TEXT。主键通常使用无业务含义的 BIGINT;业务唯一性由 UNIQUE 约束保证。

02 · NULL 与三值逻辑

NULL 表示未知,不等于空字符串或 0。判断必须写 IS NULL。聚合时 COUNT(*) 统计行,COUNT(col) 忽略 NULL;计算结果可能为 NULL 时,用 COALESCE 提供默认值。

03 · 查询执行顺序

记住 FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT。WHERE 过滤原始行,HAVING 过滤分组;SELECT 的别名通常不能在 WHERE 中使用。

04 · JOIN 的选择

INNER JOIN 只保留匹配数据;LEFT JOIN 保留左表全集。想找“没有订单的用户”,使用 LEFT JOIN 后判断右表主键 IS NULL。右表过滤条件放错到 WHERE,可能会把 LEFT JOIN 变成 INNER JOIN。

05 · 索引的基本规则

索引适合高选择性的查询、关联、排序字段。联合索引遵守最左前缀;等值条件通常放前面,范围和排序放后面。索引不是越多越好,它会增加写入成本与磁盘占用。

06 · 常见索引失效

  • 对索引列使用函数或计算
  • LIKE 以通配符开头
  • 隐式类型转换
  • 联合索引跳过最左列
  • 数据量太小或返回比例太高,优化器选择全表扫描

07 · EXPLAIN 看什么

先看 type,通常 const/ref/range 优于 ALL;再看 key 是否使用预期索引、rows 预计扫描量、Extra 是否出现 Using filesort 或 Using temporary。EXPLAIN ANALYZE 能看到实际耗时与行数。

08 · 事务与锁

事务满足 ACID。InnoDB 默认隔离级别是 REPEATABLE READ。普通 SELECT 是一致性读;SELECT ... FOR UPDATE 会锁定匹配记录。事务要短,更新顺序要一致,降低死锁概率。

09 · 分页与排序

分页必须有稳定 ORDER BY。深分页 OFFSET 会扫描并丢弃大量记录;数据量大时改用“上一页最后一个排序键”的游标分页,并为排序键建立合适索引。

10 · CTE 与窗口函数

MySQL 8 可用 WITH 拆分复杂查询。ROW_NUMBER 适合分组取 Top N,SUM(...) OVER 适合累计值。窗口函数保留明细行,GROUP BY 会把多行压成一行。

11 · 写入安全

UPDATE、DELETE 先用同样的 WHERE 执行 SELECT 确认范围;关键写入放进事务。幂等写入可依赖唯一键配合 INSERT ... ON DUPLICATE KEY UPDATE,而不是先查再插。

12 · 慢查询排查顺序

先确认 SQL 与参数,再看执行计划、扫描行数和索引;随后检查锁等待、返回数据量与网络。不要一上来就加索引,也不要只看单次测试,真实数据分布会影响执行计划。

一条主线:先保证结果正确,再减少扫描行数,最后控制锁范围与事务时间。好的 SQL 同时考虑正确性、可读性、并发和可恢复性。

基础语法快速回顾 — 两张表串起常用能力

用两张表快速回顾:users(id, name) 和 orders(id, user_id, amount)。先记住基本查询骨架,再依次复习筛选、分页、连接、聚合和数据修改。

基本查询骨架

SELECT 列名
FROM 表名
WHERE 行筛选条件
GROUP BY 分组列
HAVING 分组筛选条件
ORDER BY 排序列 DESC
LIMIT 条数 OFFSET 跳过条数;

常用基础查询

SELECT * FROM users;                         -- 所有列
SELECT id, name FROM users;                  -- 指定列
SELECT DISTINCT name FROM users;             -- 去重
SELECT * FROM users WHERE id = 3;            -- 筛选
SELECT * FROM users WHERE name LIKE '张%';   -- 以“张”开头
SELECT * FROM users WHERE id IN (1, 2, 3);   -- 多个值
SELECT * FROM users WHERE name IS NULL;      -- 判断空值
SELECT * FROM users ORDER BY id DESC;        -- 降序

判断 NULL 要用 IS NULL,不能写 = NULL。

分页查询

-- 第 1 页,每页 10 条
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 0;

-- 第 3 页,每页 10 条:跳过 (3 - 1) × 10 = 20 条
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20;

MySQL 也支持 LIMIT 20, 10,含义是“跳过 20 条,取 10 条”。分页要配合 ORDER BY,否则每页的顺序不可靠。

左连接与右连接

-- 保留所有用户;没有订单的用户也会出现,订单列为 NULL
SELECT u.name, o.amount
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id;

-- 保留所有订单;找不到对应用户的订单也会出现,用户列为 NULL
SELECT u.name, o.amount
FROM users AS u
RIGHT JOIN orders AS o ON o.user_id = u.id;

LEFT JOIN 保留左表全部行,RIGHT JOIN 保留右表全部行;匹配不上的另一侧填 NULL。调换表的书写顺序,右连接通常可以改写成左连接。

进阶 01 · INNER JOIN 与多表查询

INNER JOIN 只返回两边能够匹配的行。连接多张表时,每个 JOIN 都写清楚关联条件,并始终给表设置简短别名,避免同名字段含义不清。

-- 查询有订单的用户及其订单金额
SELECT u.id, u.name, o.id AS order_id, o.amount
FROM users AS u
INNER JOIN orders AS o ON o.user_id = u.id
ORDER BY o.id DESC;

-- 统计每位下过单的用户的订单数和总金额
SELECT u.id, u.name,
       COUNT(o.id) AS order_count,
       SUM(o.amount) AS total_amount
FROM users AS u
JOIN orders AS o ON o.user_id = u.id
GROUP BY u.id, u.name;

JOIN 默认就是 INNER JOIN。一对多连接会让用户行重复,这是正常结果;需要“一位用户一行”时,再使用 GROUP BY 聚合。

快速自测:只查询订单金额大于 100 的用户和订单,应把 o.amount > 100 放在哪里?答案:放在 WHERE 中。

进阶 02 · 子查询与 EXISTS

子查询把一个查询的结果交给另一个查询使用。返回单个值时可以直接比较;只关心“是否存在”时优先使用 EXISTS,数据库找到第一条匹配记录后即可停止继续查找。

-- 查询订单金额高于平均订单金额的订单
SELECT id, user_id, amount
FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);

-- 查询至少下过一单的用户
SELECT u.id, u.name
FROM users AS u
WHERE EXISTS (
  SELECT 1
  FROM orders AS o
  WHERE o.user_id = u.id
);

-- 查询从未下过订单的用户
SELECT u.id, u.name
FROM users AS u
WHERE NOT EXISTS (
  SELECT 1 FROM orders AS o WHERE o.user_id = u.id
);

NOT IN 的子查询结果一旦包含 NULL,判断可能全部变成未知;表达“没有关联记录”时,NOT EXISTS 通常更直观稳妥。

快速自测:查询至少有一笔金额大于 500 的订单的用户,只需在 EXISTS 子查询中再添加 AND o.amount > 500。

进阶 03 · CASE 条件表达式

CASE 可以在查询中完成分类、状态翻译和条件统计。它不会修改原数据,只负责根据条件计算当前结果列。

-- 按金额给订单分级
SELECT id, amount,
       CASE
         WHEN amount >= 1000 THEN '大额订单'
         WHEN amount >= 100  THEN '普通订单'
         ELSE '小额订单'
       END AS amount_level
FROM orders;

-- 一次查询统计不同金额区间
SELECT COUNT(*) AS total_orders,
       SUM(CASE WHEN amount >= 1000 THEN 1 ELSE 0 END) AS large_orders,
       SUM(CASE WHEN amount < 1000 THEN amount ELSE 0 END) AS other_amount
FROM orders;

多个 WHEN 按书写顺序判断,匹配第一项后就停止。范围条件应从更具体或更高的阈值开始,否则前面的宽泛条件会提前截获数据。

快速自测:把没有订单的用户显示为“未消费”,可以对左连接后的 o.id 写 CASE WHEN o.id IS NULL THEN '未消费' ELSE '已消费' END。

进阶 04 · 日期、字符串与空值处理

假设 orders 再增加 created_at 和 remark 字段。业务查询经常需要处理时间范围、拼接展示字段和提供默认值;筛选索引列时,应尽量让原字段保持不变。

-- 查询 2026 年 9 月的订单:使用左闭右开区间
SELECT id, user_id, amount
FROM orders
WHERE created_at >= '2026-09-01'
  AND created_at <  '2026-10-01';

-- 格式化日期、拼接说明,并给 NULL 设置默认值
SELECT id,
       DATE_FORMAT(created_at, '%Y-%m-%d') AS order_date,
       CONCAT('订单金额:', amount) AS summary,
       COALESCE(remark, '无备注') AS remark
FROM orders;

筛选时间时,created_at >= 起点 AND created_at < 终点 能覆盖带时分秒的数据。避免写 DATE(created_at) = ...,对索引列套函数可能让普通索引无法直接用于范围定位。

快速自测:查询最近 7 天订单,可以写 created_at >= CURRENT_DATE - INTERVAL 7 DAY。

进阶 05 · 索引与 EXPLAIN

索引帮助 MySQL 少扫描数据。设计联合索引时,从实际查询出发,把常用等值条件放在前面,再考虑范围、排序和需要覆盖的列。

-- 支持“某用户按订单 id 倒序查看订单”
CREATE INDEX idx_orders_user_id_id
ON orders (user_id, id);

EXPLAIN
SELECT id, amount
FROM orders
WHERE user_id = 10
ORDER BY id DESC
LIMIT 20;

阅读执行计划时先看 type 是否为 ALL,再看 key 是否命中预期索引、rows 预计扫描多少行,以及 Extra 中是否出现 Using filesort 或 Using temporary。索引越多,写入维护成本也越高。

-- MySQL 8:实际执行查询并展示每个步骤的真实耗时和行数
EXPLAIN ANALYZE
SELECT id, amount FROM orders WHERE user_id = 10;

快速自测:联合索引是 (user_id, id) 时,只按 id 查询能否稳定使用它?不一定,因为跳过了最左列 user_id。

进阶 06 · 事务、回滚与行锁

事务把多条 SQL 视为一个整体:全部成功就 COMMIT,任何一步失败就 ROLLBACK。需要“先读取、再根据结果修改”时,可以用 FOR UPDATE 锁住将要修改的记录。

START TRANSACTION;

-- 锁定订单 1001,其他事务暂时不能修改这条记录
SELECT id, amount
FROM orders
WHERE id = 1001
FOR UPDATE;

UPDATE orders
SET amount = amount + 20
WHERE id = 1001;

COMMIT;
-- 发生异常时执行 ROLLBACK;

FOR UPDATE 必须在事务中使用才有实际意义。事务应尽量短,不要在持锁期间等待网络请求或人工操作;多个业务流程按一致顺序锁定记录,可以减少死锁。

快速自测:第二条 UPDATE 失败后,能否继续 COMMIT 第一条修改?通常不应这样做,应捕获异常并执行 ROLLBACK,让业务保持完整。

进阶 07 · CTE 与窗口函数

MySQL 8 可以用 WITH 把复杂查询拆成有名字的步骤。窗口函数在保留每一行的同时完成排名、累计或前后行比较,不会像 GROUP BY 那样把多行合并成一行。

-- 先汇总每位用户的消费,再按总金额排名
WITH user_totals AS (
  SELECT u.id, u.name,
         COUNT(o.id) AS order_count,
         COALESCE(SUM(o.amount), 0) AS total_amount
  FROM users AS u
  LEFT JOIN orders AS o ON o.user_id = u.id
  GROUP BY u.id, u.name
)
SELECT id, name, order_count, total_amount,
       DENSE_RANK() OVER (ORDER BY total_amount DESC) AS amount_rank
FROM user_totals
ORDER BY amount_rank, id;

ROW_NUMBER() 给每行唯一序号;RANK() 遇到并列会跳号;DENSE_RANK() 遇到并列不会跳号。窗口中的 PARTITION BY 可以让排名在每个分组内重新开始。

快速自测:要计算每位用户自己的订单金额累计值,可写 SUM(amount) OVER (PARTITION BY user_id ORDER BY id)。

统计与分组

SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 2
ORDER BY total DESC;

WHERE 筛选分组之前的行,HAVING 筛选分组之后的结果。常用统计函数有 COUNT()、SUM()、AVG()、MAX()、MIN()。

增加、修改与删除

INSERT INTO users (name) VALUES ('小王');

UPDATE users SET name = '王同学' WHERE id = 3;

DELETE FROM users WHERE id = 3;

执行 UPDATE 和 DELETE 前要仔细检查 WHERE;省略它会影响整张表。生产环境建议先用相同条件执行 SELECT,确认影响范围。

30 秒自测:查出没有订单的用户

思路是保留全部用户,再筛选右表没有匹配成功的行。

SELECT u.*
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
WHERE o.id IS NULL;

业务 SQL 展示 — 可以借鉴的写法

Order Center

订单列表:游标分页,避免深 OFFSET

按创建时间倒序加载下一页;created_at 相同时用 id 保证顺序稳定。

SELECT id, order_no, user_id, pay_amount, status, created_at
FROM orders
WHERE user_id = ?
  AND status IN ('PAID', 'SHIPPED', 'FINISHED')
  AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
多个状态一起查询时,推荐先评估索引 (user_id, created_at, id),让范围扫描与排序复用索引,status 再做过滤。第一页不传游标条件,下一页传上一页最后一条记录的 created_at 与 id。
Inventory

扣减库存:在一条 UPDATE 中完成校验

防止“先查库存再扣减”产生并发超卖。

UPDATE sku_stock
SET available_stock = available_stock - ?,
    version = version + 1,
    updated_at = NOW()
WHERE sku_id = ?
  AND available_stock >= ?;

-- 应用层检查 affected_rows:1 表示成功,0 表示库存不足或商品不存在
条件更新具有原子性。若还要写库存流水,应把扣减与流水 INSERT 放在同一个短事务中。
Payment

支付回调:用唯一键实现幂等

同一个第三方交易号可能被重复通知,数据库必须保证只入账一次。

INSERT INTO payment_records
  (payment_no, order_id, channel_trade_no, amount, status, paid_at)
VALUES
  (?, ?, ?, ?, 'SUCCESS', ?) AS incoming
ON DUPLICATE KEY UPDATE
  status = IF(status = 'SUCCESS', status, incoming.status),
  paid_at = COALESCE(paid_at, incoming.paid_at);
为 channel_trade_no 建 UNIQUE 索引。幂等性的最后防线应落在数据库约束上。
Analytics

月度经营报表:条件聚合一次完成

同一次扫描里计算订单量、付费用户数、实收和退款额。

SELECT DATE_FORMAT(created_at, '%Y-%m') AS month,
       COUNT(*) AS order_count,
       COUNT(DISTINCT CASE WHEN status IN ('PAID','FINISHED') THEN user_id END)
         AS paid_user_count,
       SUM(CASE WHEN status IN ('PAID','FINISHED') THEN pay_amount ELSE 0 END)
         AS paid_amount,
       SUM(CASE WHEN status = 'REFUNDED' THEN refund_amount ELSE 0 END)
         AS refund_amount
FROM orders
WHERE created_at >= '2026-01-01'
  AND created_at <  '2027-01-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;
日期使用左闭右开范围,避免在 WHERE 中写 YEAR(created_at),这样更容易利用 created_at 索引。
CRM

沉默用户召回:NOT EXISTS 表达“最近没有行为”

找出注册满 30 天,并且最近 90 天没有有效订单的用户。

SELECT u.id, u.mobile, u.created_at
FROM users u
WHERE u.status = 'ACTIVE'
  AND u.created_at < NOW() - INTERVAL 30 DAY
  AND NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
      AND o.status IN ('PAID', 'FINISHED')
      AND o.created_at >= NOW() - INTERVAL 90 DAY
  );
NOT EXISTS 比“NOT IN 子查询”更稳妥,后者遇到 NULL 容易产生意外结果。订单表建议索引 (user_id, status, created_at)。
Ranking

每个品类销量前三:窗口函数分组排名

先汇总商品销量,再在每个品类内部排名。

WITH product_sales AS (
  SELECT p.category_id, p.id AS product_id, p.name,
         SUM(oi.quantity) AS sales_qty
  FROM order_items oi
  JOIN orders o ON o.id = oi.order_id AND o.status = 'FINISHED'
  JOIN products p ON p.id = oi.product_id
  WHERE o.created_at >= CURRENT_DATE - INTERVAL 30 DAY
  GROUP BY p.category_id, p.id, p.name
), ranked AS (
  SELECT product_sales.*,
         ROW_NUMBER() OVER (
           PARTITION BY category_id ORDER BY sales_qty DESC, product_id
         ) AS rn
  FROM product_sales
)
SELECT category_id, product_id, name, sales_qty
FROM ranked
WHERE rn <= 3
ORDER BY category_id, rn;
这是“分组取 Top N”的标准形态。ROW_NUMBER 不保留并列;业务需要并列名次时改用 RANK 或 DENSE_RANK。
Data Quality

清理重复数据:保留最新一条

手机号重复时保留 id 最大的记录,删除其余重复行。

DELETE u_old
FROM users u_old
JOIN users u_new
  ON u_old.mobile = u_new.mobile
 AND u_old.id < u_new.id
WHERE u_old.mobile IS NOT NULL;

-- 清理完成后立即补上约束
ALTER TABLE users ADD UNIQUE KEY uk_users_mobile (mobile);
生产环境先用同样的 JOIN 写 SELECT 核对删除范围,并做好备份;大表应分批删除,避免长事务和大量 undo log。
Transaction

账户转账:固定锁顺序,缩短事务

锁定双方账户后再校验余额和更新,所有并发请求采用相同的账号排序。

START TRANSACTION;

SELECT account_id, balance
FROM accounts
WHERE account_id IN (?, ?)
ORDER BY account_id
FOR UPDATE;

UPDATE accounts SET balance = balance - ? WHERE account_id = ?;
UPDATE accounts SET balance = balance + ? WHERE account_id = ?;
INSERT INTO transfer_logs(request_no, from_id, to_id, amount)
VALUES (?, ?, ?, ?);

COMMIT;
request_no 应有 UNIQUE 索引用于防重。真实代码还要在余额不足、影响行数异常或写流水失败时 ROLLBACK。

上线前 SQL 自检清单

01 查询结果是否正确处理 NULL 和重复行?
02 UPDATE / DELETE 是否有准确 WHERE?
03 JOIN 条件是否完整,是否造成行数膨胀?
04 分页是否有稳定排序,是否存在深 OFFSET?
05 EXPLAIN 的 key、rows、Extra 是否合理?
06 联合索引顺序是否匹配过滤与排序?
07 写操作是否幂等,唯一约束是否兜底?
08 事务是否足够短,锁顺序是否一致?

// 场景设定

你是一家在线教育公司的后端开发。公司的核心业务数据库有以下几张表。
先阅读表结构,然后完成下面的需求。每道题都是真实开发中你一定会遇到的查询。

建库语句(可以复制到 MySQL 直接执行)

CREATE DATABASE IF NOT EXISTS edu_platform;
USE edu_platform;

-- 用户表
CREATE TABLE users (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  nickname    VARCHAR(50) NOT NULL,
  email       VARCHAR(100) UNIQUE,
  role        ENUM('student', 'teacher', 'admin') DEFAULT 'student',
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 课程表
CREATE TABLE courses (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  title       VARCHAR(100) NOT NULL,
  teacher_id  INT,
  category    VARCHAR(30),
  price       DECIMAL(10,2) DEFAULT 0,
  FOREIGN KEY (teacher_id) REFERENCES users(id)
);

-- 选课/购买记录表
CREATE TABLE enrollments (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  user_id     INT NOT NULL,
  course_id   INT NOT NULL,
  paid_amount DECIMAL(10,2),
  status      ENUM('active', 'refunded', 'expired') DEFAULT 'active',
  enrolled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (course_id) REFERENCES courses(id)
);

-- 学习记录表
CREATE TABLE study_logs (
  id          INT PRIMARY KEY AUTO_INCREMENT,
  user_id     INT NOT NULL,
  course_id   INT NOT NULL,
  duration    INT DEFAULT 0,          -- 学习时长(秒)
  logged_at   DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (course_id) REFERENCES courses(id)
);

-- ── 插入示例数据 ──

INSERT INTO users (nickname, email, role) VALUES
('小明',   'ming@edu.com',    'student'),
('小红',   'hong@edu.com',    'student'),
('张老师', 'zhang@edu.com',   'teacher'),
('李老师', 'li@edu.com',      'teacher'),
('王同学', 'wang@edu.com',    'student'),
('刘同学', 'liu@edu.com',     'student'),
('赵管理', 'zhao@edu.com',    'admin'),
('新用户', 'new@edu.com',     'student');

INSERT INTO courses (title, teacher_id, category, price) VALUES
('MySQL 入门到精通',   3, '数据库',  99.00),
('Redis 实战',         3, '数据库',  149.00),
('Spring Boot 开发',   4, '后端',    199.00),
('Vue3 全家桶',        4, '前端',    169.00),
('算法面试通关',       3, '算法',    299.00),
('Docker 入门',        NULL, '运维',  0.00);

INSERT INTO enrollments (user_id, course_id, paid_amount, status, enrolled_at) VALUES
(1, 1, 99.00,  'active',   '2024-03-01 10:00:00'),
(1, 3, 199.00, 'active',   '2024-03-15 14:00:00'),
(2, 1, 99.00,  'active',   '2024-04-01 09:00:00'),
(2, 4, 169.00, 'refunded', '2024-04-10 11:00:00'),
(5, 1, 99.00,  'active',   '2024-05-01 08:00:00'),
(5, 2, 149.00, 'active',   '2024-05-10 16:00:00'),
(5, 5, 299.00, 'expired', '2024-01-15 20:00:00'),
(6, 3, 199.00, 'active',   '2024-06-01 12:00:00');

INSERT INTO study_logs (user_id, course_id, duration, logged_at) VALUES
(1, 1, 3600,  '2024-03-02 10:00:00'),
(1, 1, 5400,  '2024-03-03 14:00:00'),
(1, 3, 1800,  '2024-03-16 10:00:00'),
(2, 1, 7200,  '2024-04-02 09:00:00'),
(5, 1, 2700,  '2024-05-02 10:00:00'),
(5, 2, 4500,  '2024-05-11 16:00:00');

表关系速览

users.id  ──<  courses.teacher_id      老师 → 课程
users.id  ──<  enrollments.user_id     学生 → 选课记录
courses.id ──< enrollments.course_id   课程 → 选课记录
users.id  ──<  study_logs.user_id      学生 → 学习记录
courses.id ──< study_logs.course_id    课程 → 学习记录