🦏 第19章 经典案例
📄 一句话概括

十道题覆盖五类写法: 单表筛选、排序、分页、模糊匹配、聚合分组。 每道题都只用一到两张表。 做题的顺序不是「先写 SQL」,是「先问三件事」:要哪张表、要不要排序、结果的粒度是什么。 两个高频错法:count(*) 数的是行数不是人数; 100 * 及格数 / 人数 会先做整数除法,得写 100.0。

📝 本章和前面几章的关系

这十道题不是新语法,全部能从前十八章找到出处。 它们的价值在于「连起来用」——真实需求不会告诉你「这题考 group by」。 每道题的输出都能和 Demo 里的数据手工核对,这一点很重要: 六个人、三门课、十八条成绩,全部加起来能对得上,说明 SQL 没写错。

一、做题先问三件事

第一件:要哪张表。 单表题直接 where;要显示名字就 join 上去; 要统计就 group by。 这一步想清楚了,SQL 就写对了一半。 第二件:要不要排序。 只要出现 limit、只要出现「前三」「排名」这类词,就必须 order by。 而且排序字段要能唯一定序,不然翻页会漏人或重复。 第三件:结果的粒度是什么。 一行是一个学生,还是一行是一次成绩,还是一行是一门课。 粒度定错,group by 一定写错——而且数字看着还挺像对的,最难查。 这三件事想清楚了再动手写,比写完再调快得多。

二、第一组:筛选和排序

第 1、2 题都是筛选,区别在第 2 题多问了一层「一班是哪一班」。 where cls_id = 1 是硬编码—— 真项目里不会这么写,因为班级号会变。 改成 join cls … where c.name = '一班', 多写一句,换来的是「班级改名也不影响」。 这种「用名字查而不是用 id 查」的习惯,在需求变更时能救命。 第 3 题考的是日期怎么存。 birth 存成 TEXT 也能正确排序, 前提是格式必须是 YYYY-MM-DD。 因为这种格式的字典序和时间序是一致的—— 2008-01-19 比 2008-07-25 小,字典序也是它小。 换成 2008/1/19 就废了:2008/10/1 的字典序会排在 2008/1/9 前面, 因为第二个字符 0 比 1 小。 这是选日期格式时的一条硬规矩,不是风格问题。

三、第二组:分页和模糊

第 4 题的坑在「以上」两个字: >= 90 和 > 90 在这批数据上结果一样(因为正好没有 90 分整的), 但这只是凑巧。 需求里「90 分以上」包含不包含 90,一定要问清楚。 第 5 题是分页: limit 3 offset 0 就是第一页。 但注意排序字段——总分 如果有并列,两页之间会漏人或重复, 生产代码应该写成 order by 总分 desc, s.id。 第 18 章完整讲过这件事。 第 6 题是模糊匹配: % 匹配任意长度,_ 匹配一个字符。 like '%张%' 用不上索引—— 因为前面有通配符,索引没法定位。 几百行的小表无所谓,几十万行就会明显卡,那时候得靠全文索引或搜索引擎。

四、第三组:聚合和分组

第 7 题是每个学生的汇总, 三个聚合函数一起用:count(*)、sum、round(avg(...), 2)。 round 那个 2 不能省—— 不加它会打出 72.33333333333333 这种一长串,界面上没法看。 第 8 题有个隐藏的坑: count(*) 数的是「成绩行数」,不是「学生数」。 现在十八行、三门课各六人,正好齐。 但一旦有人缺考,count(*) 就会少数一个—— 因为缺考的人根本没有那一行,不是存了个 0。 要数「应该有多少人」,得从 stu 表数。 第 9 题是及格率,那个 100.0 的小数点不能省。 写成 100 的话,100 * 及格数 / 人数 会先做整数除法, 结果全是 0 或 1。 这正是第 18 章那个「8 / 3 给 2」的坑在实际需求里的样子。 第 10 题是分数段统计,case when 从上往下匹配、命中就停, 所以顺序不能乱写——把 score >= 60 放到最前面,90 分也会被判成「中」。 这题还顺带演示了视图的用处:四五个 when 抄在 select 里又长又容易抄错, 写一次视图,以后随时查 v_seg 就行。 改分段标准时也只改一处—— 这才是视图最实在的用处,和「少打字」没关系。

五、十道题跑完,回头看数据

第十题的分段结果是「优 6、中 6、良 5、差 1」,加起来 18, 和总行数对得上。 这是个好习惯:分组统计的结果,各类加起来必须等于总数, 对不上就说明 case 写漏了分支,或者 where 把某些行滤掉了。 七、八、九三题的结果也能互相对:第 7 题六个人的总分加起来是 1462, 除以 18 得 81.22,正是全校平均分——两处数字能对上,说明中间没有漏行或重复。 交叉核对是查 SQL 错误最有效的手段, 比盯着语句看管用得多。 尤其是 join 写错的时候,往往不报错、只是行数变多或变少,只有靠「总数对不对」才能发现。

六、动手 Demo:十道题,一句一道

Demo 按「筛选排序 → 分页模糊 → 聚合分组」三组走,十道题的输出全都打印出来,可以逐条核对。 用的是 19.1 那个库,数据完全一样——所以上面正文里引用到的数字,都能在输出里找到。

# 十道基础题,一句一道;c 是上一个 Demo 建好的库

# 1 列出所有学生
c.execute("SELECT id, name, gender FROM stu ORDER BY id")

# 2 只查一班(不写死班级号)
c.execute("SELECT s.name FROM stu s JOIN cls c ON c.id = s.cls_id "
          "WHERE c.name = '一班'")

# 3 女生按出生日期从大到小
c.execute("SELECT name, birth FROM stu WHERE gender = '女' "
          "ORDER BY birth DESC")

# 4 成绩 90 分以上,按分数降序
c.execute("SELECT stu_id, course_id, score FROM score "
          "WHERE score >= 90 ORDER BY score DESC")

# 5 按总分排名取前三(第一页)
c.execute("SELECT s.name, SUM(sc.score) AS 总分 FROM stu s "
          "JOIN score sc ON sc.stu_id = s.id "
          "GROUP BY s.id ORDER BY 总分 DESC LIMIT 3 OFFSET 0")

# 6 姓名里带「张」的
c.execute("SELECT name FROM stu WHERE name LIKE '%张%'")

# 7 每个学生的总分和平均分
c.execute("SELECT s.name, SUM(sc.score), ROUND(AVG(sc.score), 2) "
          "FROM stu s JOIN score sc ON sc.stu_id = s.id GROUP BY s.id")

# 8 每门课的平均、最高、最低
c.execute("SELECT c.name, ROUND(AVG(sc.score), 2), "
          "MAX(sc.score), MIN(sc.score) FROM score sc "
          "JOIN course c ON c.id = sc.course_id GROUP BY sc.course_id")

# 9 每门课的及格人数和及格率(100.0 那个小数点不能省)
c.execute("SELECT c.name, COUNT(*), "
          "SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END), "
          "ROUND(100.0 * SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) "
          "/ COUNT(*), 1) FROM score sc JOIN course c "
          "ON c.id = sc.course_id GROUP BY sc.course_id")

# 10 按分数段统计(先把 case 做成视图,再分组)
c.execute("CREATE VIEW v_seg AS SELECT stu_id, score, CASE "
          "WHEN score >= 90 THEN '优' WHEN score >= 80 THEN '良' "
          "WHEN score >= 60 THEN '中' ELSE '差' END AS 段 FROM score")
c.execute("SELECT 段, COUNT(*) FROM v_seg GROUP BY 段 ORDER BY 1 DESC")

实跑输出:

--------------------------------------------------------------------
第八十五课:基础问题——十道题过一遍
--------------------------------------------------------------------

  看完题先想「要哪张表」
  **基础题的共同点是「一张表就够」**,
  **最多连一张附表取名字。** **所以做题的第一步不是写 SQL,是问「这题要哪几张表的哪些列」。**
  **下面十道题按难度递增**:
  **先单表筛选、再排序分页、再聚合、最后分组统计。**
  **每一题的输出都能和 Demo 里的数据对上,可以自己核。**

  第一组:筛选和排序
  **第 1 题:列出所有学生的姓名和性别,按学号排**
  SQL> SELECT id, name, gender FROM stu ORDER BY id
  id     name     gender
  -----------------------
  1      张伟     男
  2      李娜     女
  3      王强     男
  4      赵敏     女
  5      孙悦     女
  6      周杰     男
  (6 行)

  **第 2 题:只查一班的学生**
  SQL> SELECT name FROM stu WHERE cls_id = 1
  name
  -----
  张伟
  李娜
  (2 行)
  **`cls_id = 1` 是硬编码。** **真项目里不会这么写**——
  **班级号会变,应该先按名字查出 id,或者直接 `join` 上去:**
  SQL> SELECT s.name FROM stu s JOIN cls c ON c.id = s.cls_id WHERE c.name = '一班'
  name
  -----
  张伟
  李娜
  (2 行)
  **多写一句 `join`,换来的是「班级改名也不影响」。**

  **第 3 题:女生按出生日期从大到小排**
  SQL> SELECT name, birth FROM stu WHERE gender = '女' ORDER BY birth DESC
  name     birth
  --------------------
  孙悦     2008-09-30
  李娜     2008-07-25
  赵敏     2008-01-19
  (3 行)
  **`birth` 存成 `TEXT` 也能正确排序**,
  **因为 `YYYY-MM-DD` 这个格式的字典序和时间序是一致的。** **这是选日期格式时的一条硬规矩**:
  **别用 `2026/1/9` 这种,`2026/10/1` 会排到 `2026/1/9` 前面。**

  第二组:分页和模糊
  **第 4 题:成绩 90 分以上的记录,按分数降序**
  SQL> SELECT stu_id, course_id, score FROM score WHERE score >= 90 ORDER BY score DESC
  stu_id     course_id     score
  -----------------------------------
  5          2             96.00
  2          1             95.00
  5          3             93.00
  1          2             92.00
  2          3             91.00
  5          1             91.00
  (6 行)
  **`>= 90` 还是 `> 90`,得看需求怎么定义「90 分以上」。**
  **Demo 里 90 分整的一条都没有,所以两种写法结果一样**——
  **但这只是这批数据凑巧。** **需求里「以上」包含不包含,一定要问清楚。**

  **第 5 题:按总分排名,取前三名(第 1 页,每页 3 条)**
  SQL> SELECT s.name AS 姓名, SUM(sc.score) AS 总分 FROM stu s JOIN score sc ON sc.stu_id = s.id
       GROUP BY s.id ORDER BY 总分 DESC LIMIT 3 OFFSET 0
  姓名     总分
  ----------------
  孙悦     280.00
  李娜     274.00
  张伟     255.00
  (3 行)
  **`limit 3 offset 0` 就是第一页。** **第 18 章说过:**
  **只要用 `limit`,就必须带 `order by`**,
  **而且排序字段要能唯一定序。** **这里 `总分` 有并列的话,两页之间会漏人或重复**——
  **所以生产代码应该写成 `order by 总分 desc, s.id`。**

  **第 6 题:姓名里带「张」的学生**
  SQL> SELECT name FROM stu WHERE name LIKE '%张%'
  name
  -----
  张伟
  (1 行)
  **`%` 匹配任意长度,`_` 匹配一个字符。** **`like '%张%'` 用不上索引**,
  **数据量大时要靠全文索引或搜索引擎。** **几百行的小表无所谓,几十万行就会明显卡。**

  第三组:聚合和分组
  **第 7 题:每个学生的总分和平均分**
  SQL> SELECT s.name AS 姓名, COUNT(*) AS 门数, SUM(sc.score) AS 总分, ROUND(AVG(sc.score),
       2) AS 平均分 FROM stu s JOIN score sc ON sc.stu_id = s.id GROUP BY s.id
       ORDER BY 总分 DESC
  姓名     门数     总分       平均分
  ------------------------------------
  孙悦     3        280.00     93.33
  李娜     3        274.00     91.33
  张伟     3        255.00     85.00
  赵敏     3        252.00     84.00
  王强     3        217.00     72.33
  周杰     3        184.00     61.33
  (6 行)
  **`round(x, 2)` 是保留两位小数。** **不加它会打出 `72.33333333333333` 这种一长串**,
  **界面上没法看。** **本沙箱和 MySQL 的 `round` 都是四舍五入。**

  **第 8 题:每门课的平均分、最高分、最低分**
  SQL> SELECT c.name AS 课程, COUNT(*) AS 人数, ROUND(AVG(sc.score), 2) AS 平均分,
       MAX(sc.score) AS 最高, MIN(sc.score) AS 最低 FROM score sc JOIN course c
       ON c.id = sc.course_id GROUP BY sc.course_id
  课程     人数     平均分     最高      最低
  ----------------------------------------------
  语文     6        81.83      95.00     60.00
  数学     6        79.67      96.00     58.00
  英语     6        82.17      93.00     66.00
  (3 行)
  **`count(*)` 数的是「成绩行数」,不是「学生数」。** **现在 18 条成绩、三门课各 6 人,正好齐。**
  **但一旦有人缺考,这两个数就不一样了**——
  **缺考的人根本没有那一行,不是存了个 0,`count(*)` 会少数一个。**
  **要数「应该有多少人」,得从 `stu` 表数。**

  **第 9 题:每门课的及格人数和及格率**
  SQL> SELECT c.name AS 课程, COUNT(*) AS 人数,
       SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) AS 及格数,
       ROUND(100.0 * SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) / COUNT(*), 1) AS 及格率
       FROM score sc JOIN course c ON c.id = sc.course_id GROUP BY sc.course_id
  课程     人数     及格数     及格率
  ------------------------------------
  语文     6        6          100.00
  数学     6        5          83.30
  英语     6        6          100.00
  (3 行)
  **`sum(case when … then 1 else 0 end)` 是「数满足条件的行」的标准写法。**
  **`count(case when … then 1 end)` 也行**,**因为 `count` 不数 `NULL`。**
  **注意 `100.0` 那个小数点。** **写成 `100` 的话,`100 * 及格数 / 人数` 会先做整数除法**,
  **结果全是 0 或 1。** **第 18 章那个 `8 / 3` 给 `2` 的坑,在这里会咬人。**

  **第 10 题:按分数段统计人数——优(≥90)、良(80~89)、中(60~79)、差(<60)**
  **这题的 `case` 有四个分支,直接写进 `select` 会把一行撑爆。** **先把它做成视图:**
  | CREATE VIEW v_seg AS SELECT stu_id, score, CASE
  |   WHEN score >= 90 THEN '优' WHEN score >= 80 THEN '良'
  |   WHEN score >= 60 THEN '中' ELSE '差' END AS 段
  | FROM score
  **视图建好之后,分组就变成一句话:**
  SQL> SELECT 段, COUNT(*) AS 人数 FROM v_seg GROUP BY 段 ORDER BY 人数 DESC
  段     人数
  ------------
  优     6
  中     6
  良     5
  差     1
  (4 行)
  **`case when` 从上往下匹配,命中就停。** **所以顺序不能乱写**:
  **把 `score >= 60` 放到最前面,90 分也会被判成「中」。**
  **`group by 段` 用的是别名。** **MySQL 允许在 `group by` 里用 `select` 的别名,SQLite 也允许。**
  **但标准 SQL 不允许**——**换数据库时可能报错,稳妥的写法是把整个 `case` 再抄一遍。**
  **顺便说一句为什么用视图**:**这一串四五个 `when` 抄在 `select` 里,又长又容易抄错**,
  **写一次以后随时查 `v_seg` 就行。** **改分段标准时也只改一处**——
  **这就是视图最实在的用处,和「少打字」没关系。**

  十道题的共同套路
  **第一步永远是「要哪张表」**:**单表题直接写 `where`**,**要名字就连 `join`**,
  **要统计就 `group by`。**
  **第二步是「要不要排序」**:
  **只要有 `limit`、只要有「排名」「前三」这类词,就必须 `order by`。**
  **第三步是「结果的粒度是什么」**:
  **一行是一个学生,还是一行是一次成绩,还是一行是一门课。**
  **粒度定错,`group by` 一定写错,而且数字看着还挺像对的。**

第 2 题的两种写法结果一样(张伟、李娜),但 join 那版不依赖班级号,班级改名照样对。 第 3 题按 birth 降序:孙悦(2008-09-30)、李娜(2008-07-25)、赵敏(2008-01-19)。 降序是「大的在前」,所以出生的晚的排前面。 第 4 题六条 90 分以上,最高 96(孙悦数学)。 注意没有 90 分整的记录——所以 > 90 和 >= 90 在这批数据上恰好一样。 第 7 题六个人都是 3 门课,总分从 280 到 184。 六个总分加起来 1462,除以 18 得 81.22——就是这批数据的全校平均分。 第 9 题数学及格率 83.30%(6 人里 5 人及格),语文和英语都是 100%。 那个 100.0 的小数点要是漏了,这里会变成 100、83、100。 第 10 题四段加起来 18:优 6、中 6、良 5、差 1。 对得上总数,说明 case 的分支没有漏。

30 秒实验

  1. 把第 9 题的 100.0 改成 100,及格率会变成什么?
  2. 第 7 题加一个 having count(*) = 3,结果会变吗?为什么?
  3. 第 5 题把 order by 总分 desc 改成 order by 姓名,「前三名」还是不是那三个人?

常见误区

  1. where cls_id = 1 硬编码班级号。 班级号会变,应该按名字 join 查。
  2. count(*) 当「学生数」用。 它数的是行数,缺考的人没有行,会被少数。
  3. 及格率写成 100 * 及格数 / 人数。 整数相除先砍小数,结果只剩 0 或 1,要写 100.0。
自测三问(点开对答案) 1. `birth` 存成 `TEXT` 为什么还能正确排序? —— 因为 `YYYY-MM-DD` 这种定长补零格式的字典序和时间序一致。 换成 `2008/1/9` 这种不补零的写法就会排错。 2. 分组统计的结果要怎么自查? —— 把各类的计数加起来,必须等于总数。对不上说明 `case` 漏了分支,或者 `where` 多滤了行。 3. 为什么 `where cls_id = 1` 不如 `join cls ... where c.name = '一班'`? —— 前者把班级号写死了,班级号一变就查不到;后者按名字查,班级改名、编号重排都不影响。

十道基础题过了,下一篇上难度:19.3 高级问题——九道题,题题有坑。

相关笔记