SQL 练习题(12 题)
SQL 不是"看完就会"的——它得在真实数据上跑过,才能记住语法、理解执行顺序、培养"用查询表达需求"的思维。这一页给 12 道 SQL 语句实战题,分三级——基础 6 题(SELECT / WHERE / ORDER BY / LIMIT / INSERT / UPDATE)、进阶 4 题(JOIN / GROUP BY / 子查询 / 索引)、实战 2 题(电商查询 / 数据清洗)。每题含题目、提示、可折叠的参考答案。
使用建议:打开 sqliteonline.com 或 db-fiddle.com,把答案里的建表语句粘进去,自己跑一遍。改改 WHERE 条件、换换 JOIN 类型,看结果怎么变——SQL 是"试出来"的。
基础(6 题):增删改查基本功
这一组覆盖最常用的 CRUD:查列、过滤、排序、分页、插入、更新。把这些练熟,你能应付日常 80% 的数据库操作。
第 1 题:SELECT 查询列
题目:建一张 orders 订单表后:(a) 查全部行;(b) 只查 id, user_id, amount 三列;(c) 给列起中文别名;(d) 查所有下过单的不重复用户 id。
提示:SELECT * FROM 查全部(生产别用,浪费);指定列名查需要的;AS 起别名;DISTINCT 去重。
参考答案
-- 建一张订单表演示(实际项目里表已存在)
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'paid',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 查全部列、全部行(* 在调试时用,生产别用,浪费带宽)
SELECT * FROM orders;
-- 只查需要的列(推荐,索引也能用上)
SELECT id, user_id, amount FROM orders;
-- 给列起别名(AS 可省略)
SELECT id AS 订单号, amount AS 金额 FROM orders;
-- 去重:看所有下过单的用户(不重复)
SELECT DISTINCT user_id FROM orders;第 2 题:WHERE 条件过滤
题目:写 WHERE 查询:(a) 金额大于 100 的订单;(b) 已支付且金额 ≥ 50 的订单;(c) 金额在 100-500 之间;(d) 状态是 paid/shipped/delivered 之一;(e) 姓"张"的用户;(f) 未发货(shipped_at 为空)的订单。
提示:比较运算符;AND/OR 用括号分组;BETWEEN 范围(闭区间);IN (...) 集合;LIKE '张%' 模糊匹配;IS NULL 判空(不能用 = NULL)。
参考答案
-- 比较运算:= != > >= < <=
SELECT * FROM orders WHERE amount > 100;
-- 逻辑组合:AND / OR / NOT(多个条件用括号分组)
SELECT * FROM orders
WHERE amount > 100 AND status = 'paid';
SELECT * FROM orders
WHERE (status = 'paid' OR status = 'shipped')
AND amount >= 50;
-- 范围:BETWEEN(闭区间,含两端)
SELECT * FROM orders
WHERE amount BETWEEN 100 AND 500;
-- 集合:IN
SELECT * FROM orders
WHERE status IN ('paid', 'shipped', 'delivered');
-- 模糊匹配:LIKE(% 任意多字符,_ 单个字符)
SELECT * FROM users WHERE name LIKE '张%'; -- 姓张的
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- 空值判断:必须用 IS NULL,不能用 = NULL
SELECT * FROM orders WHERE shipped_at IS NULL;第 3 题:ORDER BY 排序
题目:按金额升序、降序排;多列排序(先按状态升序,同状态再按金额降序);把 NULL 排在最后。
提示:ASC 升序(默认)、DESC 降序;多列逗号分隔,按顺序优先;ORDER BY col IS NULL, col 把 NULL 排后。
参考答案
-- 按金额升序(ASC 是默认,可省略)
SELECT * FROM orders ORDER BY amount ASC;
-- 按金额降序(贵的在前)
SELECT * FROM orders ORDER BY amount DESC;
-- 多列排序:先按状态升序,同状态再按金额降序
SELECT * FROM orders
ORDER BY status ASC, amount DESC;
-- 按表达式排序(常见:按金额从大到小)
SELECT id, amount FROM orders
ORDER BY amount DESC;
-- 把 NULL 排在最后(MySQL 里 NULL 默认最小)
SELECT * FROM orders
ORDER BY shipped_at IS NULL, shipped_at;第 4 题:LIMIT 分页
题目:取金额前 3 的高价订单;实现每页 10 条的分页,取第 2 页;并说明深分页(LIMIT 1000000, 10)为什么慢,怎么用游标分页优化。
提示:LIMIT N 取前 N 条;LIMIT 偏移 条数 或 LIMIT N OFFSET M 分页;深分页慢是因为要扫"偏移+条数"行再丢掉前面,用 WHERE id > 上一页最后id LIMIT N 游标分页。
参考答案
-- 取金额前 3 的高价订单(排序 + 限量)
SELECT * FROM orders
ORDER BY amount DESC
LIMIT 3;
-- 分页:每页 10 条,取第 2 页(LIMIT 偏移 条数)
-- 偏移 = (页码 - 1) × 每页条数 = (2-1)×10 = 10
SELECT * FROM orders
ORDER BY id ASC
LIMIT 10 OFFSET 10;
-- 简写:LIMIT 10, 10(逗号前是偏移,后是条数)
-- 深分页的性能坑:偏移很大时(如 LIMIT 1000000, 10)
-- 数据库要扫 1000010 行再丢掉前 100 万行,很慢
-- 优化:用"游标分页"(记住上一页最后一条 id)
SELECT * FROM orders
WHERE id > 1000010 -- 上一页最后一条的 id
ORDER BY id ASC
LIMIT 10;第 5 题:INSERT 插入
题目:插入单条全字段、插入部分字段(其余用默认值)、批量插入 3 条;再用 ON DUPLICATE KEY UPDATE 实现"插入冲突时改成更新"。
提示:INSERT INTO 表 (列) VALUES (...);部分列省略的用默认值;批量用 VALUES (...),(...),(...) 性能远高于循环单条;MySQL 用 ON DUPLICATE KEY UPDATE 处理冲突,PostgreSQL 用 ON CONFLICT DO UPDATE。
参考答案
-- 插入一条(全字段)
INSERT INTO orders (user_id, amount, status)
VALUES (42, 199.00, 'paid');
-- 插入一条(部分字段,其余用默认值)
INSERT INTO orders (user_id, amount)
VALUES (42, 99.00);
-- id 自增、status 默认 'paid'、created_at 默认当前时间
-- 批量插入(性能比循环单条高得多)
INSERT INTO orders (user_id, amount, status) VALUES
(42, 199.00, 'paid'),
(43, 88.50, 'paid'),
(44, 12.00, 'pending');
-- 插入 + 冲突处理(MySQL:ON DUPLICATE KEY UPDATE)
-- 唯一键冲突时改成更新而不是报错
INSERT INTO users (id, name, email)
VALUES (1, '小明', 'xm@new.com')
ON DUPLICATE KEY UPDATE email = VALUES(email);
-- PostgreSQL 用 ON CONFLICT:
-- INSERT INTO users (id, name, email) VALUES (...)
-- ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email;第 6 题:UPDATE 更新
题目:更新单条订单状态为已发货;批量把超过 1 天未处理的 pending 改成 cancelled;全场打 9 折。说明为什么 UPDATE 前要先 SELECT 一遍。
提示:UPDATE 表 SET ... WHERE ...——WHERE 必须带,漏了就全表更新(经典事故);用表达式 amount = amount * 0.9 计算更新;正式操作前先 SELECT 同条件看影响范围;可开 sql_safe_updates 防漏 WHERE。
参考答案
-- 更新单条(WHERE 必须带,否则全表更新!事故重灾区)
UPDATE orders
SET status = 'shipped', shipped_at = NOW()
WHERE id = 1001;
-- 批量更新:所有 pending 状态超过 1 天的改成 cancelled
UPDATE orders
SET status = 'cancelled'
WHERE status = 'pending'
AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY);
-- 基于另一列计算更新:全场打 9 折
UPDATE orders
SET amount = amount * 0.9
WHERE status = 'paid' AND amount > 1000;
-- 💡 救命习惯:正式 UPDATE 前先 SELECT 一遍确认影响范围
SELECT * FROM orders
WHERE status = 'pending'
AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY);
-- 看清楚了再把它换成 UPDATE
-- 安全网:MySQL 默认是 safe update 关着的,
-- 可以 SET sql_safe_updates = 1; 让没 WHERE 的 UPDATE/DELETE 直接报错进阶(4 题):多表与统计
这一组是后端面试的高频考点:多表连接、分组统计、子查询、索引。能熟练写出 JOIN 和 GROUP BY,你就能解决大部分真实业务查询。
第 7 题:JOIN 多表连接
题目:有 orders 和 users 两张表,写 INNER JOIN 查每笔订单的用户名;再写 LEFT JOIN 查"所有用户(含从没下过单的)";最后写三表连接(订单+用户+商品明细)。
提示:JOIN ... ON 连接条件;INNER JOIN 只保留两边都匹配的;LEFT JOIN 保留左表全部、右表不匹配填 NULL;给表起短别名(o、u、p)少打字。
参考答案
-- 两张表:orders(订单) 和 users(用户)
-- 需求:查每笔订单的用户名(订单表只有 user_id)
-- INNER JOIN:只保留两边都能匹配上的行(最常用)
SELECT o.id, u.name, o.amount
FROM orders o
INNER JOIN users u ON o.user_id = u.id;
-- LEFT JOIN:保留左表全部行,右表匹配不上填 NULL
-- 比如"所有用户,包括从没下过单的"
SELECT u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- 没下过单的用户 order_id 是 NULL
-- 三表连接:订单 + 用户 + 商品明细
SELECT o.id, u.name, p.title, oi.quantity
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;
-- 给表起短别名(o、u、p)是惯例,JOIN 多表时少打字第 8 题:GROUP BY 分组统计
题目:按订单状态分组,统计每组的订单数、总金额、平均金额;再找出"总消费超过 1000 的用户"(分组后再过滤)。说明 WHERE 和 HAVING 的区别,以及 SQL 各子句的执行顺序。
提示:聚合函数 COUNT/SUM/AVG/MAX/MIN;GROUP BY 分组;HAVING 分组后过滤(WHERE 是分组前)。执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。
参考答案
-- 按状态分组,统计每个状态的订单数和总金额
SELECT
status,
COUNT(*) AS 订单数,
SUM(amount) AS 总金额,
AVG(amount) AS 平均金额,
MAX(amount) AS 最大金额,
MIN(amount) AS 最小金额
FROM orders
GROUP BY status;
-- 输出:
-- paid 120 45200.00 376.67 2000.00 12.00
-- shipped 80 31000.00 387.50 1800.00 30.00
-- 分组后再过滤用 HAVING(WHERE 是分组前过滤,HAVING 是分组后)
-- 找"总消费超过 1000 的用户"
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE status = 'paid' -- 先过滤未支付
GROUP BY user_id
HAVING SUM(amount) > 1000 -- 再筛总消费大的
ORDER BY total DESC;
-- 记住执行顺序:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT第 9 题:子查询
题目:用三种子查询:(a) 找金额高于平均金额的订单(子查询在 WHERE);(b) 用 IN 找下过大单的用户;(c) 相关子查询找每个用户金额最高的订单。再用 CTE(WITH)改写让查询更清晰。
提示:子查询可放 WHERE、SELECT、FROM;相关子查询依赖外层逐行执行较慢;WITH 名 AS (子查询) CTE 把复杂查询拆开,可读性更好。
参考答案
-- 子查询:查询里嵌查询
-- 需求:找出"金额高于平均金额"的订单
-- 写法 1:子查询放 WHERE 里
SELECT * FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);
-- 写法 2:IN 子查询,找"下过金额 > 1000 订单的用户"
SELECT name FROM users
WHERE id IN (
SELECT DISTINCT user_id FROM orders WHERE amount > 1000
);
-- 写法 3:相关子查询(子查询依赖外层,逐行执行,较慢)
-- 查每个用户自己金额最高的那笔订单
SELECT * FROM orders o
WHERE amount = (
SELECT MAX(amount) FROM orders
WHERE user_id = o.user_id
);
-- 推荐替代:CTE(WITH 子句)更清晰
WITH big_orders AS (
SELECT * FROM orders WHERE amount > 1000
)
SELECT u.name, COUNT(*) AS 大单数
FROM big_orders b
JOIN users u ON b.user_id = u.id
GROUP BY u.name;第 10 题:索引
题目:给 orders.user_id 建普通索引;建 (user_id, status) 复合索引;给 users.email 建唯一索引;用 EXPLAIN 验证查询是否走了索引;说明索引为什么不是越多越好。
提示:CREATE INDEX / UNIQUE INDEX;复合索引遵循"最左前缀";EXPLAIN 看 key 列(用到的索引)和 rows 列(扫描行数);索引加速读但拖慢写,读多写少多建、写多读少慎建。
参考答案
-- 没有索引时,WHERE user_id = 42 要扫全表(慢)
-- 加上索引后,数据库用 B+ 树快速定位(快几个数量级)
-- 1. 建普通索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 2. 建复合索引(多列,顺序很关键,遵循"最左前缀")
-- 适合同时按 user_id + status 查询
CREATE INDEX idx_user_status ON orders(user_id, status);
-- 3. 建唯一索引(同时保证不重复,业务唯一键用它)
CREATE UNIQUE INDEX uniq_email ON users(email);
-- 4. 用 EXPLAIN 看查询有没有走索引(必会!)
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
-- key 列显示用到的索引名;rows 列显示预估扫描行数
-- 如果 key 是 NULL 或 rows 巨大,说明没走索引要优化
-- 5. 删除索引
DROP INDEX idx_user_id ON orders;
-- ⚠️ 索引不是越多越好:写操作(INSERT/UPDATE)要同步维护索引,
-- 会变慢。读多写少的表多建索引,写多的表谨慎实战(2 题):真实业务场景
这两题模拟真实数据工作:电商的多表统计报表,和脏数据清洗。这是数据分析师、后端工程师天天做的事。
第 11 题:电商查询实战
题目:给定电商四张表(users / products / orders / order_items),写查询:(a) 每个城市的总销售额;(b) 销量 Top 10 商品;(c) 近 7 天每天的销售曲线;(d) 复购率(下过 2 次以上订单的用户占比)。
提示:城市销售用 JOIN + GROUP BY 城市;Top 商品在 order_items 上聚合;DATE(created_at) 按天分组;DATE_SUB(CURDATE(), INTERVAL 7 DAY) 取近 7 天;复购率用子查询统计每人订单数再 CASE WHEN 算比例。
参考答案
-- 电商数据库表结构(简化):
-- users(id, name, city)
-- products(id, title, price, stock)
-- orders(id, user_id, amount, status, created_at)
-- order_items(id, order_id, product_id, quantity, price)
-- 1. 每个城市的总销售额(用户 JOIN 订单,按城市分组)
SELECT u.city,
COUNT(DISTINCT o.id) AS 订单数,
SUM(o.amount) AS 总销售额
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY u.city
ORDER BY 总销售额 DESC;
-- 2. 销量 Top 10 的商品(订单明细 JOIN 商品,按销量排)
SELECT p.title,
SUM(oi.quantity) AS 销量,
SUM(oi.quantity * oi.price) AS 销售额
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.id, p.title
ORDER BY 销量 DESC
LIMIT 10;
-- 3. 近 7 天每天的销售曲线(按天分组,出报表)
SELECT DATE(o.created_at) AS 日期,
COUNT(*) AS 订单数,
SUM(o.amount) AS 销售额
FROM orders o
WHERE o.created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND o.status = 'paid'
GROUP BY DATE(o.created_at)
ORDER BY 日期;
-- 4. 复购率:下过 2 次及以上订单的用户占比
SELECT
ROUND(
COUNT(CASE WHEN 订单数 >= 2 THEN 1 END) * 100.0
/ COUNT(*), 2
) AS 复购率百分比
FROM (
SELECT user_id, COUNT(*) AS 订单数
FROM orders WHERE status = 'paid'
GROUP BY user_id
) t;第 12 题:数据清洗
题目:发现用户表有脏数据:(a) 找重复邮箱;(b) 删重复每组只保留 id 最小那条;(c) 找手机号为空的;(d) 标准化手机号去空格短横线;(e) 找金额异常的订单(负数或超大);(f) 用 CASE 给金额分档;(g) 清理 email 首尾空格并转小写。
提示:重复用 GROUP BY HAVING COUNT>1;删重复用子查询套临时表(MySQL 限制);TRIM/LOWER/REPLACE 清理字符串;CASE WHEN ... THEN ... END 打标签;UPDATE 前先 SELECT 确认。
参考答案
-- 场景:从用户表里发现脏数据,要清洗后再分析
-- 1. 找重复邮箱(同一邮箱多条记录)
SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- 输出:
-- xm@example.com 3
-- 2. 删重复,每组只保留 id 最小的那条
DELETE FROM users
WHERE id NOT IN (
SELECT id FROM (
SELECT MIN(id) AS id FROM users GROUP BY email
) tmp
);
-- 注意:MySQL 不让直接在 DELETE 里子查询同一张表,套一层临时表绕过
-- 3. 找缺失值:phone 为空字符串或 NULL 的用户
SELECT id, name, phone FROM users
WHERE phone IS NULL OR phone = '';
-- 4. 标准化手机号:去掉空格和短横线,统一成 11 位
UPDATE users
SET phone = REPLACE(REPLACE(phone, ' ', ''), '-', '')
WHERE phone IS NOT NULL;
-- 5. 找异常值:金额为负或异常巨大的订单
SELECT * FROM orders
WHERE amount < 0 OR amount > 100000;
-- 6. 用 CASE 给数据打标签(金额分档)
SELECT
id, amount,
CASE
WHEN amount < 100 THEN '小额'
WHEN amount < 1000 THEN '中额'
WHEN amount < 5000 THEN '大额'
ELSE '超大额'
END AS 金额档位
FROM orders;
-- 7. 字符串清洗:去掉首尾空格,统一小写
UPDATE users
SET email = TRIM(LOWER(email));练习建议
用在线工具就练。SQL 不用装数据库——sqliteonline.com、db-fiddle.com 打开浏览器就能跑。把每题的建表和查询粘进去,改改条件、JOIN 类型、聚合函数,看结果差异。
试着扩展:做完第 7 题 JOIN,试试自连接(同一张表 JOIN 自己,找"员工和经理");做完第 8 题 GROUP BY,加上 ROLLUP 出小计/合计;做完第 11 题电商,加一个"购物车 abandoned 率"统计。
更多练习:推荐 LeetCode 数据库 刷题;SQLZoo 交互式教程;系统学习看 MySQL 官方文档。
← 上一篇 SQL 实战项目
返回 SQL 教程目录