在线代码运行BanShow Run

SQL 练习题

10 道 SQL 练习题,难度从打印输出到分组统计逐步递进,每题都附参考答案,可以直接在浏览器里运行验证,不需要本地环境。

打开 SQL 运行器 →

下面每道题都基于右侧「建表与数据」里的 学生(8 人)和 成绩(23 条,孙浩缺数学)。先把题目自己想一遍再往下看「参考」,卡住了再对照。每段都可以直接复制进编辑器运行。

1. 基础查询与排序

查出一班和二班里年龄不小于 16 岁的学生,显示姓名、班级、年龄,按年龄从大到小排;年龄相同的按姓名升序。最后统计一共选中了几个人。

sql
-- 参考
SELECT 姓名, 班级, 年龄
FROM 学生
WHERE 班级 IN ('一班', '二班') AND 年龄 >= 16
ORDER BY 年龄 DESC, 姓名 ASC;

SELECT COUNT(*) AS 人数
FROM 学生
WHERE 班级 IN ('一班', '二班') AND 年龄 >= 16;

要注意:排序方向要逐列写。ORDER BY 年龄 DESC, 姓名 里第二列默认是升序;写成 ORDER BY 年龄, 姓名 DESC 就变成「年龄升序、姓名降序」,和你想的完全不一样。另外,先写 SELECT 再写一遍 COUNT(*) 有点重复,实际项目里通常是先跑明细确认对了,再换成 COUNT(*) 看总量。

2. 分组统计与 HAVING

按班级统计人数、平均年龄(保留两位小数)、最大年龄,只保留平均年龄不低于 16.5 的班,按人数从多到少排。

sql
-- 参考
SELECT 班级,
       COUNT(*) AS 人数,
       ROUND(AVG(年龄), 2) AS 平均年龄,
       MAX(年龄) AS 最大年龄
FROM 学生
GROUP BY 班级
HAVING AVG(年龄) >= 16.5
ORDER BY 人数 DESC;

要注意:HAVING 里写的是 AVG(年龄) 而不是别名 平均年龄——因为执行顺序是 GROUP BY → HAVING → SELECT,轮到 HAVING 时别名还不存在(SQLite 恰好宽容地允许这么写,但换到别的数据库就报错了,别养成习惯)。WHERE 里则完全不能用聚合函数。

3. 连接查询:把成绩接到学生身上

列出所有分数低于 75 的成绩记录,显示姓名、班级、科目、分数,按分数从低到高排。再单独找出没有数学成绩的学生。

sql
-- 参考
SELECT s.姓名, s.班级, c.科目, c.分数
FROM 学生 s
JOIN 成绩 c ON c.学生id = s.id
WHERE c.分数 < 75
ORDER BY c.分数 ASC, s.姓名;

-- 缺考数学的人
SELECT s.姓名, s.班级
FROM 学生 s
LEFT JOIN 成绩 c ON c.学生id = s.id AND c.科目 = '数学'
WHERE c.id IS NULL;

要注意:第二句里 c.科目 = '数学' 必须写在 ON 里。挪到 WHERE 里(WHERE c.科目 = '数学' AND c.id IS NULL)就自相矛盾了:c.科目 为 NULL 的行过不了 = '数学',结果永远是空的。这是 LEFT JOIN 最经典的一个坑。

4. 总分、平均分与「和全班比」

算出每个学生的总分和平均分,按总分从高到低排。然后再列出高于全班平均分的单科成绩,取前 5 条。

sql
-- 参考
SELECT s.姓名, s.班级,
       COUNT(c.id) AS 科目数,
       SUM(c.分数) AS 总分,
       ROUND(AVG(c.分数), 1) AS 平均分
FROM 学生 s
JOIN 成绩 c ON c.学生id = s.id
GROUP BY s.id, s.姓名, s.班级
ORDER BY 总分 DESC;

SELECT s.姓名, c.科目, c.分数,
       ROUND((SELECT AVG(分数) FROM 成绩), 1) AS 全班平均
FROM 成绩 c
JOIN 学生 s ON s.id = c.学生id
WHERE c.分数 > (SELECT AVG(分数) FROM 成绩)
ORDER BY c.分数 DESC
LIMIT 5;

要注意:孙浩只考了两科,所以他的总分天然比别人低一截——按总分排名对他不公平。真实项目里遇到「每个人的样本数不一样」时,要排的是平均值而不是总和,这一点比 SQL 语法本身重要得多。上面第一句特意把 科目数 也查出来,就是为了让人一眼看出谁的样本数不对。

5. 行转列:把三科变成三列

把每个学生的成绩从「一人三行」变成「一人一行、三科三列」,缺考的那一格显示为空。

sql
-- 参考
SELECT s.姓名, s.班级,
       MAX(CASE WHEN c.科目 = '语文' THEN c.分数 END) AS 语文,
       MAX(CASE WHEN c.科目 = '数学' THEN c.分数 END) AS 数学,
       MAX(CASE WHEN c.科目 = '英语' THEN c.分数 END) AS 英语,
       SUM(c.分数) AS 总分
FROM 学生 s
LEFT JOIN 成绩 c ON c.学生id = s.id
GROUP BY s.id, s.姓名, s.班级
ORDER BY s.id;

要注意:三个关键点。① 必须用 LEFT JOIN,用 JOIN 的话孙浩整行都会消失(他没有匹配到数学那一行不影响,但如果某个学生一条成绩都没有,JOIN 会把他整个人丢掉)。② CASE WHEN ... THEN ... END 不写 ELSE 时默认返回 NULL,聚合函数会忽略 NULL,所以 MAX 的作用只是「从这一组里挑出那唯一的一个非空值」,换成 MIN 或 SUM 结果一样。③ 这是所有关系型数据库通用的写法,SQLite 没有 PIVOT 语法。

6. 每科前三名

按科目分别排名,取出每科的前三名,显示科目、姓名、分数和名次。分数相同的要并列同一名次。

sql
-- 参考
SELECT 科目, 姓名, 分数, 名次
FROM (
  SELECT c.科目, s.姓名, c.分数,
         RANK() OVER (PARTITION BY c.科目 ORDER BY c.分数 DESC) AS 名次
  FROM 成绩 c
  JOIN 学生 s ON s.id = c.学生id
)
WHERE 名次 <= 3
ORDER BY 科目, 名次, 姓名;

要注意:窗口函数不能直接写在 WHERE 里,因为 WHERE 执行时窗口还没算出来,必须先用子查询包一层再筛。三种排名的区别:

sql
SELECT s.姓名, c.分数,
       ROW_NUMBER() OVER (ORDER BY c.分数 DESC) AS 行号_不并列,
       RANK()       OVER (ORDER BY c.分数 DESC) AS 排名_并列跳号,
       DENSE_RANK() OVER (ORDER BY c.分数 DESC) AS 密集排名_并列不跳号
FROM 成绩 c JOIN 学生 s ON s.id = c.学生id
WHERE c.科目 = '数学'
ORDER BY c.分数 DESC;

要「并列也算前三」就用 RANK(可能出现 4 个人),要「严格只取 3 行」就用 ROW_NUMBER。

7. 每个学生最强的一科

找出每个人分数最高的那一科,显示姓名、科目、分数,按分数从高到低排。

sql
-- 参考
SELECT 姓名, 科目 AS 最强科目, 分数
FROM (
  SELECT s.姓名, c.科目, c.分数,
         ROW_NUMBER() OVER (PARTITION BY s.id ORDER BY c.分数 DESC, c.id) AS rn
  FROM 成绩 c
  JOIN 学生 s ON s.id = c.学生id
)
WHERE rn = 1
ORDER BY 分数 DESC, 姓名;

要注意:ORDER BY c.分数 DESC 后面补的 c.id 是用来打破平局的。赵敏语文 99、数学 97、英语 95 没有并列,但万一某个学生两科分数一样,不加这个 tie-breaker,ROW_NUMBER 每次运行给出哪一科都可能不同——这种「结果不稳定」的 bug 上线之后极难复现。分组取 Top 1 是这个套路的标准应用,把 rn = 1 改成 rn <= 3 就是分组取 Top 3。

8. 用事务安全地增删数据

新加一个学生「周涛」(三班,17 岁,武汉,id = 9)和他的三科成绩(语文 74、数学 83、英语 91),要求要么全写进去、要么一条都不写。写完查一遍确认。然后再演示一次「删错了怎么撤销」。

sql
-- 参考
BEGIN;
INSERT INTO 学生 (id, 姓名, 班级, 年龄, 城市) VALUES (9, '周涛', '三班', 17, '武汉');
INSERT INTO 成绩 (学生id, 科目, 分数) VALUES (9, '语文', 74), (9, '数学', 83), (9, '英语', 91);
COMMIT;

SELECT s.姓名, c.科目, c.分数
FROM 学生 s JOIN 成绩 c ON c.学生id = s.id
WHERE s.id = 9
ORDER BY c.科目;

-- 反例:手滑清空了成绩表,用 ROLLBACK 撤销
BEGIN;
DELETE FROM 成绩;
ROLLBACK;

SELECT COUNT(*) AS 成绩条数还在吗 FROM 成绩;

要注意:DELETE FROM 成绩 不带 WHERE 就是清空整张表。真实项目里执行任何 UPDATE / DELETE 之前,都应该**先把 WHERE 换成 SELECT COUNT(*) 跑一遍**,确认命中的行数是你预期的那个数字。另外,事务里只要有一句报错,SQLite 会自动整体回滚,不会留下改了一半的数据——这正是事务存在的意义。本站的库在整个页面会话里是持续的,所以这里删掉的数据后面的查询也看不到;点「重置数据库」或刷新页面才会回到初始的示例数据。

9. 建一张班级表并关联起来

新建一张 班级 表(名称做主键,班主任必填,教室可空,人数上限默认 40 且必须大于 0),插入三个班的班主任信息,然后把 学生 表按班级汇总成一张人数表,两张表连起来看。

sql
-- 参考
DROP TABLE IF EXISTS 班级;
CREATE TABLE 班级 (
  名称     TEXT PRIMARY KEY,
  班主任   TEXT NOT NULL,
  教室     TEXT,
  人数上限 INTEGER NOT NULL DEFAULT 40 CHECK (人数上限 > 0)
);

INSERT INTO 班级 (名称, 班主任, 教室) VALUES
  ('一班', '王老师', 'A101'),
  ('二班', '李老师', 'A102'),
  ('三班', '张老师', 'B201');

-- CREATE TABLE AS SELECT:把查询结果直接存成一张新表
DROP TABLE IF EXISTS 班级人数;
CREATE TABLE 班级人数 AS
SELECT 班级 AS 名称, COUNT(*) AS 实到人数 FROM 学生 GROUP BY 班级;

SELECT b.名称, b.班主任, b.教室, n.实到人数, b.人数上限,
       b.人数上限 - n.实到人数 AS 还能再收
FROM 班级 b
LEFT JOIN 班级人数 n ON n.名称 = b.名称
ORDER BY b.名称;

要注意:CREATE TABLE ... AS SELECT(俗称 CTAS)建出来的表不带任何约束——没有主键、没有 NOT NULL、CHECK 也全没了,连列的类型都是重新推断的(上面 id 在 学生 里是 INTEGER PRIMARY KEY,到了新表里就只剩一个 INT)。它适合做临时汇总表或数据迁移的中转,不适合当正式的业务表。验证一下:

sql
DROP TABLE IF EXISTS 副本;
CREATE TABLE 副本 AS SELECT id, 姓名 FROM 学生;

-- 直接看 SQLite 存下来的建表语句:约束一个都没有
SELECT sql AS 建表语句 FROM sqlite_master WHERE name = '副本';
PRAGMA table_info(副本);            -- pk 那一列全是 0,notnull 也全是 0

-- 后果:重复的 id 和空的姓名都能塞进去,没人拦
INSERT INTO 副本 (id, 姓名) VALUES (1, NULL), (1, '又一个 id=1');
SELECT * FROM 副本 WHERE id = 1;

-- 想让它有约束,就得老老实实 CREATE TABLE 之后再用 INSERT INTO ... SELECT 灌数据
DROP TABLE IF EXISTS 正式副本;
CREATE TABLE 正式副本 (id INTEGER PRIMARY KEY, 姓名 TEXT NOT NULL);
INSERT INTO 正式副本 (id, 姓名) SELECT id, 姓名 FROM 学生;
PRAGMA table_info(正式副本);
sql
-- 同样一条脏数据,这次被拦住了:NOT NULL constraint failed: 正式副本.姓名
INSERT INTO 正式副本 (id, 姓名) VALUES (1, NULL);

10. 数据质量检查

这份数据里故意留了问题。用 SQL 把它们全找出来:谁缺考了、缺的是哪一科;有没有分数越界(小于 0 或大于 100)的记录;有没有城市为空的学生;有没有重名的学生。

sql
-- 参考

-- 一、每个学生应该考 3 科,少于 3 科就是有缺考
SELECT s.姓名, s.班级,
       COUNT(c.id) AS 实际科目数,
       3 - COUNT(c.id) AS 缺考科目数
FROM 学生 s
LEFT JOIN 成绩 c ON c.学生id = s.id
GROUP BY s.id, s.姓名, s.班级
HAVING COUNT(c.id) < 3;

-- 二、具体缺的是哪一科:造一张"所有科目"的小表去叉乘,再用 NOT EXISTS 反查
SELECT s.姓名, k.科目 AS 缺考科目
FROM 学生 s
CROSS JOIN (
  SELECT '语文' AS 科目
  UNION ALL SELECT '数学'
  UNION ALL SELECT '英语'
) AS k
WHERE NOT EXISTS (
  SELECT 1 FROM 成绩 c WHERE c.学生id = s.id AND c.科目 = k.科目
)
ORDER BY s.id, k.科目;

-- 三、其它几类脏数据,用 UNION ALL 汇总成一张问题清单
SELECT '分数越界' AS 问题, s.姓名, c.科目 || ' = ' || c.分数 AS 详情
FROM 成绩 c JOIN 学生 s ON s.id = c.学生id
WHERE c.分数 < 0 OR c.分数 > 100
UNION ALL
SELECT '城市为空', 姓名, '城市 = ' || COALESCE(城市, 'NULL')
FROM 学生 WHERE 城市 IS NULL OR trim(城市) = ''
UNION ALL
SELECT '重名', 姓名, COUNT(*) || ' 个人同名'
FROM 学生 GROUP BY 姓名 HAVING COUNT(*) > 1
UNION ALL
SELECT '成绩条数不对', s.姓名, '应有 3 条,实有 ' || COUNT(c.id) || ' 条'
FROM 学生 s LEFT JOIN 成绩 c ON c.学生id = s.id
GROUP BY s.id, s.姓名
HAVING COUNT(c.id) <> 3;

要注意:第二问是这道题的重点。LEFT JOIN 只能告诉你「少了几条」,说不出「少的是哪一条」——因为不存在的东西是不会出现在结果里的。解决办法是先造出一张"理论上应该有的组合"的表(学生 × 科目),再去反查哪些组合在 成绩 里找不到。这个「造全集 + NOT EXISTS 反查」的套路在做数据核对时几乎天天用。

第三问里,UNION ALL 要求每个 SELECT 的列数一致(这里都是 3 列),最终列名取自第一个 SELECT。COUNT(*) 是数字,要和文本拼在一起必须先 ||,SQLite 会自动转换,但显式写 CAST(COUNT(*) AS TEXT) 意图更清楚。

做完之后

把上面的题改一改,是最好的练习方式:

  • 第 5 题:加一列「是否全科及格」(三科都 ≥ 60 显示 是,否则 否)。提示:MIN(CASE WHEN c.分数 >= 60 THEN 1 ELSE 0 END)。
  • 第 6 题:改成「每科最后三名」,看看要不要动 ORDER BY 的方向。
  • 第 7 题:改成「每个学生最弱的一科」,再和最强的一科比一比差多少分。
  • 第 10 题:加一条检查——找出「某科分数比他自己平均分低 20 分以上」的学生和科目。

换一门语言