☰
SQL题单实战:单表四套与多表四套,练出真手感
2026/10/14 7:34:44 网站建设 项目流程

简介:这份SQL语句练习题资源面向MySQL初学者与希望巩固查询能力的开发者,围绕单表与多表两大主题各提供四套练习,帮助读者在动手实践中理解SELECT、WHERE、ORDER BY、GROUP BY及各类JOIN、子查询的用法。资源共19个文件,以13个txt文本、5个sql脚本和1个zip压缩包为主,sql文件可直接导入MySQL环境执行,txt则用于记录题目与答案,整体包体约22KB,轻量便于随取随练。目前已有917人学习下载,说明其在SQL入门训练中具有一定参考价值。通过完成单表过滤、排序、聚合统计以及多表内连接、外连接与嵌套子查询等题目,读者能够逐步建立从基础语法到复杂数据整合的完整思路,并借助配套答案核对结果、排查偏差,适合作为课程作业、面试准备或日常刷题的系统性补充材料。

1. 单表四套、多表四套:一套能真正练出手感的 SQL 题单该怎么搭

很多人刷 SQL 题的方式是打开一个在线判题站,从第一题顺着往下做,做完发现“好像会了”,一换真实业务表就写不出来。问题不在题量,在于题单的结构——单表和多表混着刷,简单聚合和窗口函数混着刷,练完脑子里是一团浆糊。我后来给自己和团队新人搭了一套固定结构的题单:单表四套、多表四套,每套只压一个能力点,做完一套就能说清“这类查询我靠什么思路拆”。这套东西不依赖任何特定数据库,MySQL、PostgreSQL、SQLite 都能跑,核心是把“表结构设计 → 题目梯度 → 参考解 → 易错点”四件事绑在一起。如果你正在准备数据岗面试、或者带新人补 SQL 基础,这套题单可以直接抄去改。

2. 先把练习表建对:四张表撑起单表与多表全部场景

题单好不好用,八成取决于表建得对不对。表太简单,多表 JOIN 没东西可练;表太复杂,新手光看字段就劝退。我一般用四张表:一张员工表、一张部门表、一张订单表、一张订单明细表。员工和部门构成一对多,订单和明细构成一对多,员工和订单构成一对多,这样单表聚合、多表 JOIN、子查询、窗口函数全都有落点。

2.1 建表语句与字段设计意图

-- 部门表:多表 JOIN 的“被连接方”,字段少但要有层级 CREATE TABLE dept ( dept_id INT PRIMARY KEY, dept_name VARCHAR(50) NOT NULL, parent_id INT, -- 自关联,用来练自连接 city VARCHAR(30) ); -- 员工表:单表聚合主力,含可空字段用于练 NULL 处理 CREATE TABLE emp ( emp_id INT PRIMARY KEY, emp_name VARCHAR(50) NOT NULL, dept_id INT, salary DECIMAL(10,2), hire_date DATE, manager_id INT -- 自关联,指向 emp_id ); -- 订单表:多表练习的“主表”,含状态字段用于条件聚合 CREATE TABLE orders ( order_id INT PRIMARY KEY, emp_id INT, -- 下单员工 cust_id INT, order_date DATE, status VARCHAR(20) -- paid / cancel / refund ); -- 订单明细表:一对多的“多”方,练分组与去重 CREATE TABLE order_item ( item_id INT PRIMARY KEY, order_id INT, product VARCHAR(50), qty INT, price DECIMAL(10,2) );

字段设计有几个刻意为之的地方。emp.dept_id和emp.manager_id都允许为空,是为了让LEFT JOIN和IS NULL的题目有真实数据可查;orders.status用字符串而不是数字,是为了练CASE WHEN条件聚合;order_item单独拆出来而不是把商品塞进 orders,是为了让“一个订单多个商品”的分组统计有实际意义。这些不是随便定的,每一处都对应后面题单里的一个能力点。

2.2 造数据:让每道题都有“非平凡”结果

INSERT INTO dept VALUES (1,'技术部',NULL,'北京'), (2,'市场部',NULL,'上海'), (3,'后端组',1,'北京'), (4,'前端组',1,'北京'), (5,'品牌组',2,'上海'); INSERT INTO emp VALUES (101,'A同学',3,18000,'2021-03-01',NULL), (102,'B同学',3,22000,'2020-07-15',101), (103,'C同学',4,15000,'2022-01-10',101), (104,'D同学',2,12000,'2021-11-20',NULL), (105,'E同学',5,11000,'2023-02-01',104), (106,'F同学',NULL,9000,'2023-05-01',NULL); -- 无部门,用于 LEFT JOIN 验证 INSERT INTO orders VALUES (1001,101,1,'2024-01-05','paid'), (1002,101,2,'2024-01-06','cancel'), (1003,102,1,'2024-01-07','paid'), (1004,103,3,'2024-02-01','refund'), (1005,104,2,'2024-02-03','paid'); INSERT INTO order_item VALUES (1,1001,'键盘',2,199.00), (2,1001,'鼠标',1,99.00), (3,1002,'显示器',1,899.00), (4,1003,'键盘',3,199.00), (5,1004,'主机',1,3999.00), (6,1005,'鼠标',2,99.00);

数据里埋了几个“钩子”:F 同学没有部门,任何用INNER JOIN写的部门统计都会漏掉他,这是检验 JOIN 类型理解的最好方式;1002 号订单是 cancel 状态,条件聚合题里如果忘了过滤就会算错;1004 是 refund,用来区分“已支付”和“非取消”两种口径。造数据时故意留这些边界,比事后口头讲“注意 NULL”有用得多。

3. 单表四套:从 WHERE 到窗口函数的能力阶梯

单表题最容易刷成“条件反射”,看到“最高”就ORDER BY LIMIT 1,看到“每类”就GROUP BY。四套题的设计逻辑是:第一套只练过滤和排序,第二套练聚合与分组,第三套练子查询和 NULL,第四套练窗口函数。每套五到六题,做完一套再进下一套,不要跳。

3.1 第一套:过滤、排序与去重

这一套只碰WHERE、ORDER BY、DISTINCT、LIMIT,目的是把“我要哪些行”这件事说清楚。

-- 1. 查出薪资高于 15000 的员工,按薪资降序 SELECT emp_name, salary FROM emp WHERE salary > 15000 ORDER BY salary DESC; -- 2. 查出 2021 年之后入职且部门不为空的员工 SELECT emp_name, hire_date FROM emp WHERE hire_date > '2021-12-31' AND dept_id IS NOT NULL; -- 3. 查出所有出现过的部门 id(去重) SELECT DISTINCT dept_id FROM emp WHERE dept_id IS NOT NULL; -- 4. 查出薪资最高的前 3 名员工 SELECT emp_name, salary FROM emp ORDER BY salary DESC LIMIT 3; -- 5. 查出名字里带“同学”且薪资在 10000 到 20000 之间的员工 SELECT emp_name, salary FROM emp WHERE emp_name LIKE '%同学%' AND salary BETWEEN 10000 AND 20000;

参数说明:BETWEEN是闭区间,包含两端;LIMIT 3在并列薪资时结果不稳定,真实业务里要加emp_id做次级排序。第 2 题用IS NOT NULL而不是!= NULL,这是新手最高频的翻车点,!= NULL永远返回空结果,因为 NULL 参与任何比较都是 unknown。

3.2 第二套:聚合、分组与 HAVING

这一套的核心是分清WHERE和HAVING:WHERE在分组前过滤行,HAVING在分组后过滤组。

-- 1. 统计每个部门的员工数和平均薪资 SELECT dept_id, COUNT(*) AS emp_cnt, AVG(salary) AS avg_sal FROM emp WHERE dept_id IS NOT NULL GROUP BY dept_id; -- 2. 统计每个部门薪资大于 12000 的员工数 SELECT dept_id, COUNT(*) AS cnt FROM emp WHERE salary > 12000 AND dept_id IS NOT NULL GROUP BY dept_id; -- 3. 找出平均薪资超过 15000 的部门 SELECT dept_id, AVG(salary) AS avg_sal FROM emp WHERE dept_id IS NOT NULL GROUP BY dept_id HAVING AVG(salary) > 15000; -- 4. 统计每个部门最高和最低薪资的差值 SELECT dept_id, MAX(salary) - MIN(salary) AS gap FROM emp WHERE dept_id IS NOT NULL GROUP BY dept_id; -- 5. 按入职年份统计每年入职人数 SELECT YEAR(hire_date) AS yr, COUNT(*) AS cnt FROM emp GROUP BY YEAR(hire_date) ORDER BY yr;

参数说明:COUNT(*)统计所有行,COUNT(dept_id)会跳过 NULL,这两个在“统计无部门员工”时结果完全不同。第 5 题的YEAR()是 MySQL 写法,PostgreSQL 用EXTRACT(YEAR FROM hire_date),SQLite 用strftime('%Y', hire_date),换库时要改。HAVING里可以直接用聚合函数,但不能用SELECT里定义的别名(部分数据库支持,但不要依赖)。

3.3 第三套:子查询、NULL 与条件表达式

这一套开始接触“查询里套查询”,同时把 NULL 的坑集中踩一遍。

-- 1. 查出薪资高于全公司平均薪资的员工 SELECT emp_name, salary FROM emp WHERE salary > (SELECT AVG(salary) FROM emp); -- 2. 查出每个部门薪资最高的员工(相关子查询写法) SELECT e.emp_name, e.dept_id, e.salary FROM emp e WHERE e.salary = ( SELECT MAX(salary) FROM emp WHERE dept_id = e.dept_id ); -- 3. 查出没有部门的员工,以及有部门但部门不存在的员工 SELECT emp_name FROM emp WHERE dept_id IS NULL OR dept_id NOT IN (SELECT dept_id FROM dept); -- 4. 用 CASE WHEN 给薪资分档 SELECT emp_name, salary, CASE WHEN salary >= 20000 THEN '高' WHEN salary >= 12000 THEN '中' ELSE '低' END AS level FROM emp; -- 5. 查出比本部门平均薪资高的员工 SELECT e.emp_name, e.salary FROM emp e WHERE e.salary > ( SELECT AVG(salary) FROM emp WHERE dept_id = e.dept_id );

参数说明:第 3 题的NOT IN有个经典陷阱——如果子查询结果里含 NULL,整个NOT IN返回空。这里dept.dept_id是主键不含 NULL,所以安全;但换成可空列就必须改用NOT EXISTS。第 2 题的相关子查询在数据量大时性能差,第四套会用窗口函数重写。

3.4 第四套:窗口函数与排名

窗口函数是单表题的天花板,也是面试区分度最高的部分。核心是理解PARTITION BY和ORDER BY在窗口里的含义。

-- 1. 给每个部门内员工按薪资排名 SELECT emp_name, dept_id, salary, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk FROM emp WHERE dept_id IS NOT NULL; -- 2. 用窗口函数重写“每部门薪资最高” SELECT emp_name, dept_id, salary FROM ( SELECT emp_name, dept_id, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM emp WHERE dept_id IS NOT NULL ) t WHERE rn = 1; -- 3. 计算每个员工薪资与部门平均薪资的差 SELECT emp_name, dept_id, salary, salary - AVG(salary) OVER (PARTITION BY dept_id) AS diff FROM emp WHERE dept_id IS NOT NULL; -- 4. 按入职时间给全公司员工编号 SELECT emp_name, hire_date, ROW_NUMBER() OVER (ORDER BY hire_date) AS seq FROM emp;

参数说明:RANK()遇并列跳号(1,1,3),DENSE_RANK()不跳号(1,1,2),ROW_NUMBER()强制不并列(1,2,3)。第 2 题用ROW_NUMBER而不是RANK,是因为“取第一名”只要一行,用RANK遇到并列会返回多行。窗口函数不能写在WHERE里,必须套一层子查询,这是语法硬约束。

4. 多表四套:JOIN、子查询、条件聚合与自连接

多表题的难点不在语法,在于“先想清楚要哪些表、用什么键连、连完粒度变没变”。四套题的顺序是:内连接与外连接、多表串联、条件聚合、自连接与层级。

4.1 第一套:INNER JOIN 与 LEFT JOIN 的取舍

-- 1. 查出每个员工的姓名和部门名(含无部门员工) SELECT e.emp_name, d.dept_name FROM emp e LEFT JOIN dept d ON e.dept_id = d.dept_id; -- 2. 查出每个员工的姓名和部门名(只保留有部门的) SELECT e.emp_name, d.dept_name FROM emp e INNER JOIN dept d ON e.dept_id = d.dept_id; -- 3. 统计每个部门的员工数,没有员工的部门显示 0 SELECT d.dept_name, COUNT(e.emp_id) AS cnt FROM dept d LEFT JOIN emp e ON d.dept_id = e.dept_id GROUP BY d.dept_name; -- 4. 找出没有员工的部门 SELECT d.dept_name FROM dept d LEFT JOIN emp e ON d.dept_id = e.dept_id WHERE e.emp_id IS NULL;

参数说明:第 3 题用COUNT(e.emp_id)而不是COUNT(*),因为LEFT JOIN后无员工部门会产生一行e.emp_id为 NULL 的记录,COUNT(*)会算成 1,COUNT(e.emp_id)才是 0。这是多表聚合最隐蔽的坑。第 4 题是“反连接”的标准写法,比NOT IN更安全。

4.2 第二套:三表串联与粒度控制

-- 1. 查出每笔订单的下单员工名和订单金额(订单金额=明细汇总) SELECT o.order_id, e.emp_name, SUM(i.qty * i.price) AS amount FROM orders o JOIN emp e ON o.emp_id = e.emp_id JOIN order_item i ON o.order_id = i.order_id GROUP BY o.order_id, e.emp_name; -- 2. 查出每个员工负责的订单总金额 SELECT e.emp_name, SUM(i.qty * i.price) AS total FROM emp e JOIN orders o ON e.emp_id = o.emp_id JOIN order_item i ON o.order_id = i.order_id GROUP BY e.emp_name; -- 3. 查出没有下过订单的员工 SELECT e.emp_name FROM emp e LEFT JOIN orders o ON e.emp_id = o.emp_id WHERE o.order_id IS NULL;

参数说明:第 1 题GROUP BY必须同时包含o.order_id和e.emp_name,只写order_id在严格模式下会报错。三表 JOIN 的顺序影响可读性但不影响结果(优化器会重排),习惯上从“主表”开始往外连。第 2 题如果员工有多个订单,SUM会把所有明细加总,这正是想要的;但如果误把emp和order_item直接连,会漏掉订单这一层,金额算错。

4.3 第三套:条件聚合与状态统计

-- 1. 统计每个员工的已支付订单数和取消订单数 SELECT e.emp_name, SUM(CASE WHEN o.status = 'paid' THEN 1 ELSE 0 END) AS paid_cnt, SUM(CASE WHEN o.status = 'cancel' THEN 1 ELSE 0 END) AS cancel_cnt FROM emp e LEFT JOIN orders o ON e.emp_id = o.emp_id GROUP BY e.emp_name; -- 2. 统计每个部门已支付订单的总金额 SELECT d.dept_name, COALESCE(SUM(i.qty * i.price), 0) AS amount FROM dept d LEFT JOIN emp e ON d.dept_id = e.dept_id LEFT JOIN orders o ON e.emp_id = o.emp_id AND o.status = 'paid' LEFT JOIN order_item i ON o.order_id = i.order_id GROUP BY d.dept_name; -- 3. 查出既下过 paid 又下过 cancel 的员工 SELECT e.emp_name FROM emp e JOIN orders o ON e.emp_id = o.emp_id GROUP BY e.emp_name HAVING SUM(CASE WHEN o.status = 'paid' THEN 1 ELSE 0 END) > 0 AND SUM(CASE WHEN o.status = 'cancel' THEN 1 ELSE 0 END) > 0;

参数说明:第 2 题把o.status = 'paid'写在LEFT JOIN的ON里而不是WHERE里,这是关键——写在WHERE里会把LEFT JOIN退化成INNER JOIN,没有订单的部门直接消失。COALESCE把 NULL 转成 0,避免前端显示空。第 3 题用HAVING做“同时满足两个条件”的筛选,比自连接更直观。

4.4 第四套:自连接与层级查询

-- 1. 查出每个员工及其直属上级的姓名 SELECT e.emp_name AS emp, m.emp_name AS manager FROM emp e LEFT JOIN emp m ON e.manager_id = m.emp_id; -- 2. 查出每个部门的上级部门名 SELECT d.dept_name AS dept, p.dept_name AS parent FROM dept d LEFT JOIN dept p ON d.parent_id = p.dept_id; -- 3. 查出和上级同部门的员工 SELECT e.emp_name FROM emp e JOIN emp m ON e.manager_id = m.emp_id WHERE e.dept_id = m.dept_id; -- 4. 查出薪资高于其上级的员工 SELECT e.emp_name, e.salary, m.emp_name AS manager, m.salary AS mgr_sal FROM emp e JOIN emp m ON e.manager_id = m.emp_id WHERE e.salary > m.salary;

参数说明:自连接必须给同一张表起两个别名,否则列名歧义。第 1 题用LEFT JOIN是为了保留没有上级的员工(如 A 同学)。第 4 题是自连接的典型应用——同一张表内比较两行,用窗口函数也能做,但自连接更直观。层级超过两层时自连接就不够了,需要递归 CTE,那是另一个话题。

5. 避坑与排查:这五类错误我见过太多次

5.1 现象:LEFT JOIN 后统计数量偏大

原因:COUNT(*)把LEFT JOIN产生的 NULL 行也算进去了。解决:统计被连接表的行数时用COUNT(被连接表主键),或者先聚合再连接。

5.2 现象:NOT IN 子查询返回空结果

原因:子查询结果里含 NULL,NOT IN遇到 NULL 整个条件变 unknown。解决:改用NOT EXISTS,或在子查询里加WHERE col IS NOT NULL。

5.3 现象:GROUP BY 报错“列不在 GROUP BY 中”

原因:SELECT里出现了既不在聚合函数里、也不在GROUP BY里的列。解决:把该列加进GROUP BY,或者用MAX()/MIN()包起来。MySQL 旧版本默认宽松模式会放过,但结果不确定,不要依赖。

5.4 现象:窗口函数写在 WHERE 里报语法错误

原因:窗口函数的执行顺序在WHERE之后,不能直接过滤。解决:套一层子查询,在外层WHERE里过滤窗口列。

5.5 现象:多表 JOIN 后金额翻倍

原因:一对多连接后行数膨胀,再聚合就重复计算。解决:先确认连接后的粒度,必要时先在子查询里聚合再连接,或者用DISTINCT去重(但要看业务口径)。

6. 把题单用出效果:我的三个习惯

第一,每套题做完不急着看答案,先把自己的解和“标准解”对比执行计划。同样一道“每部门薪资最高”,相关子查询和窗口函数的EXPLAIN结果差别很大,看懂了才知道什么时候该用哪个。第二,造数据时故意留边界——NULL、重复、并列、零值,这些在真实业务里一定会遇到,练习时不见,上线就翻车。第三,把四套单表题和四套多表题当成“体检表”,哪一套卡住了就回去补那一块,不要从头再刷一遍。

-- 验证题单是否覆盖到位的自检查询:统计每张表的行数和 NULL 分布 SELECT 'emp' AS tbl, COUNT(*) AS rows_cnt, SUM(CASE WHEN dept_id IS NULL THEN 1 ELSE 0 END) AS null_dept FROM emp UNION ALL SELECT 'orders', COUNT(*), SUM(CASE WHEN status IS NULL THEN 1 ELSE 0 END) FROM orders;

这个自检查询能快速看出练习数据里 NULL 的分布,如果某张表一个 NULL 都没有,说明边界覆盖不够,需要补数据。我一般会在题单交付前跑一遍,确认每张表都有“非平凡”的行。

最后说个血泪经验:题单的价值不在题目本身,而在你做完之后能不能用自己的话把“这类问题的拆解步骤”讲出来。讲不出来,就是还没会。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询