1. 为什么这套MySQL练习题能真正帮你“把语法刻进肌肉里”
我带过不少刚学完SQL基础的开发者,他们能背出SELECT、WHERE、JOIN的语法结构,也能在纸上写出简单的查询语句,但一到真实业务场景——比如要从订单表里找出“近30天下单次数≥5次、且平均客单价超过200元的老客户”,就卡在中间:不知道该先筛时间还是先分组,不确定HAVING和WHERE到底谁管谁,更别提怎么把用户行为路径(浏览→加购→下单→复购)用窗口函数串起来。这不是记不住,是没在真实数据流里反复“拧螺丝”。
这套练习题的设计逻辑,就是从这个断层切入的。它不叫“MySQL入门100题”,也不叫“面试高频50问”,它叫“练习表+题目+答案”——三个要素缺一不可。练习表不是随便造的假数据,而是模拟了电商、内容平台、SaaS系统三类高频业务域的真实字段设计逻辑:用户表里有is_vip、last_login_time、reg_source;订单表里有order_status(含'pending','shipped','refunded'等状态机字段)、payment_method、discount_amount;商品表里有category_path(如'/electronics/smartphones/iphone')、stock_quantity、on_sale_flag。这些字段不是为了炫技,而是你明天写报表、做AB测试、搭用户分群时,真正在数据库里要面对的。
关键词里虽然空着,但整套题的底层锚点非常清晰:以“可执行的最小闭环”为单位组织训练。每道题都自带可运行的建表语句、INSERT数据脚本、标准答案SQL、以及关键注释——不是只告诉你“答案是SELECT * FROM users WHERE age > 18”,而是标注“此处WHERE必须在GROUP BY之前执行,否则会报错1140”;不是只给JOIN结果,而是说明“LEFT JOIN users ON orders.user_id = users.id 比 INNER JOIN多返回37条记录,因为存在未注册用户直接下单的灰度场景”。这种颗粒度,才能让练习从“看懂”走向“手熟”,再走向“直觉反应”。
它适合三类人:刚学完增删改查想验证理解的新人;准备技术面试需要快速刷题的求职者;还有像我这样,隔半年不碰复杂查询,手生到连GROUP_CONCAT的分隔符都要查文档的“老手”。如果你现在打开MySQL客户端,对着空白命令行发呆超过10秒,这套题就是你的重启开关。
2. 练习表设计背后的业务逻辑与数据陷阱
很多人拿到练习题第一反应是跳过建表语句,直接抄答案。这恰恰踩中了第一个大坑:不理解表结构,等于在迷雾中开枪,打中什么全靠运气。这套题的三张核心练习表——users(用户)、orders(订单)、products(商品)——每个字段名、每个约束、每条测试数据,都藏着业务规则的影子。我们拆开来看。
2.1 users表:不只是ID和姓名,而是用户生命周期的切片
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100) UNIQUE, age TINYINT CHECK (age BETWEEN 0 AND 120), is_vip BOOLEAN DEFAULT FALSE, reg_source ENUM('web', 'ios', 'android', 'wechat') NOT NULL, last_login_time DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );重点不在语法,而在字段选择逻辑:
reg_source用ENUM而非VARCHAR,不是为了省那几个字节,而是强制约束数据来源渠道的合法性——业务系统里不可能出现'reg_source="qq"'这种脏数据,枚举值就是产品埋点规范的数据库落地。last_login_time允许NULL,是因为新注册用户可能从未登录,而created_at用TIMESTAMP自动填充,确保注册时间不可篡改。这两个时间字段的组合,直接支撑“7日留存率”计算:COUNT(DISTINCT CASE WHEN DATEDIFF(last_login_time, created_at) = 6 THEN id END) / COUNT(*)。is_vip设为BOOLEAN,但实际业务中VIP状态往往关联会员等级、到期时间、权益包,这里简化成布尔值,是为了聚焦练习目标——当你需要扩展时,自然会意识到该加vip_level TINYINT和vip_expire_date DATE。
提示:练习题里插入的100条users数据中,有12条
reg_source='wechat'且is_vip=TRUE,但last_login_time全部为空。这是模拟微信小程序静默授权注册后,用户未主动登录的典型场景。很多初学者写“活跃VIP用户”查询时会漏掉这个NULL判断,直接用WHERE last_login_time IS NOT NULL AND is_vip=TRUE,结果漏掉这批用户。
2.2 orders表:状态机驱动的业务事实表
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, order_status ENUM('pending', 'paid', 'shipped', 'delivered', 'refunded', 'cancelled') NOT NULL DEFAULT 'pending', amount DECIMAL(10,2) NOT NULL, discount_amount DECIMAL(10,2) DEFAULT 0.00, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (product_id) REFERENCES products(id) );这张表是练习题的“心脏”,所有复杂查询都绕不开它:
order_status的ENUM值顺序不是随意排的,pending→paid→shipped→delivered是正向流转,refunded和cancelled是终态。这意味着“已支付未发货订单”必须写成WHERE order_status IN ('paid', 'shipped'),而不是WHERE order_status != 'pending'——后者会错误包含已退款订单。amount和discount_amount分开存储,是为了支持“满300减50”这类营销活动的独立核算。练习题第27题要求“统计各渠道首单优惠力度”,就必须用SUM(discount_amount)/COUNT(*),而不是用AVG(amount)来倒推。updated_at的ON UPDATE机制,让“最近24小时订单状态变更”查询变得简单:WHERE updated_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)。但要注意,如果订单状态从shipped变delivered,updated_at会更新,而created_at不变——这是区分“新订单”和“订单更新”的关键。
注意:练习题数据中,orders表有237条记录,其中
order_status='refunded'的有19条,但discount_amount全部为0。这是因为退款发生在支付后,优惠已核销,退款金额只退实付部分。很多人在写“退款订单平均优惠”时会误用AVG(discount_amount),得到0,而正确逻辑是关联payments表(本题未提供,需自行补充思路)。
2.3 products表:分类体系与库存策略的映射
CREATE TABLE products ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_path VARCHAR(255) NOT NULL, price DECIMAL(10,2) NOT NULL, stock_quantity INT NOT NULL DEFAULT 0, on_sale_flag BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );category_path字段是点睛之笔:
- 它存储的是
'/electronics/smartphones/iphone'这样的完整路径,而非简单的category_id。这直接支持“查看智能手机品类下所有iPhone型号的销量TOP10”这类需求:WHERE category_path LIKE '/electronics/smartphones/iphone%'。 - 但这也带来陷阱:
LIKE '/electronics/%'会匹配到/electronics/smartphones和/electronics/accessories,而LIKE '/electronics/smartphones%'才精准。练习题第41题“统计各一级品类销售额”就要求你用SUBSTRING_INDEX(category_path, '/', 3)提取/electronics部分,再GROUP BY。 stock_quantity设为INT而非TINYINT,是因为库存可能上万(如图书),而on_sale_flag是布尔值,但实际业务中促销可能有多个维度(限时、限量、满减),这里简化处理,只为聚焦“是否在售”这一核心判断。
这三张表的关联不是教科书式的“用户-订单-商品”三角,而是带着业务伤疤的真实关系:orders.user_id外键指向users.id,但users表里没有total_spent字段——因为这个值应该实时计算,避免冗余更新失败;products表没有sales_count,因为销量要按时间维度聚合,不能静态存储。练习题的答案SQL里,所有SUM()、COUNT()、AVG()都不是为了凑数,而是对这种“不冗余设计”的必然响应。
3. 题目分层:从语法校验到业务推演的四阶跃迁
这套题的100道题不是线性排列的,而是按认知负荷严格分层。我把它拆成四个阶段,每个阶段解决一类典型问题,对应不同的思维模式切换。跳着做题可以,但跳过某个阶段,后面就会卡住。
3.1 第一阶:单表CRUD的“肌肉反射”训练(题号1-25)
目标不是学会语法,而是让基础操作变成条件反射。比如题3:“查询年龄大于25岁的用户邮箱,按注册时间倒序排列”:
SELECT email FROM users WHERE age > 25 ORDER BY created_at DESC;看似简单,但隐藏三个易错点:
ORDER BY created_at DESC,不是last_login_time——题目明确要求“按注册时间”,新手常凭直觉选活跃时间;WHERE age > 25,不是>=25——题目说“大于”,边界必须精确;- 结果只取
email字段,不加*——练习题强调“只查需要的字段”,这是性能意识的起点。
这一阶的题,答案SQL长度通常不超过3行,但每道题都强迫你检查:WHERE条件是否精准?ORDER BY字段是否存在?SELECT是否最小化?做完这25道,你会发现自己写简单查询时,手指已经自动敲出SELECT ... FROM ... WHERE ... ORDER BY的节奏,不用再想语法树。
3.2 第二阶:多表关联的“关系破译”实战(题号26-50)
进入这里,你开始面对真实世界的混乱。题28:“查询所有已支付订单的用户名、商品名、订单金额,按金额从高到低排序”:
SELECT u.username, p.name, o.amount FROM orders o JOIN users u ON o.user_id = u.id JOIN products p ON o.product_id = p.id WHERE o.order_status = 'paid' ORDER BY o.amount DESC;关键在JOIN顺序和条件位置:
- 必须用
JOIN users和JOIN products,不能用LEFT JOIN——因为题目要求“已支付订单”,意味着订单必须关联有效用户和商品,LEFT JOIN会引入NULL,污染结果; WHERE o.order_status = 'paid'必须放在JOIN之后,如果写成JOIN users u ON o.user_id = u.id AND o.order_status = 'paid',逻辑就错了:这是在关联时过滤订单,但o.order_status属于orders表,关联条件只能用关联字段(如o.user_id = u.id),状态过滤必须在WHERE;ORDER BY o.amount DESC,明确指定表别名o.,避免多表同名字段(如users和products都有created_at)的歧义。
这一阶的题,答案SQL开始出现表别名、显式JOIN、WHERE条件分层。做完后,你看到业务需求里的“用户+订单+商品”组合,第一反应不再是“我要连三张表”,而是“哪张是主表?关联字段是什么?过滤条件该放ON还是WHERE?”。
3.3 第三阶:聚合分析的“业务指标”构建(题号51-75)
这里开始烧脑。题53:“统计每个用户的订单总数、总金额、平均客单价,并筛选出总金额超过1000元的用户”:
SELECT u.id, u.username, COUNT(o.id) AS order_count, SUM(o.amount) AS total_amount, ROUND(AVG(o.amount), 2) AS avg_order_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.username HAVING SUM(o.amount) > 1000;核心是理解GROUP BY和HAVING的协作:
- 用
LEFT JOIN而非INNER JOIN,因为要包含“注册但从未下单的用户”,他们的order_count=0,total_amount=NULL; GROUP BY u.id, u.username,必须包含SELECT中所有非聚合字段,这是SQL92标准强制要求;HAVING SUM(o.amount) > 1000,不能用WHERE——因为SUM()是聚合函数,WHERE在分组前执行,无法访问聚合结果,HAVING在分组后执行,专为此设计;ROUND(AVG(o.amount), 2),AVG对NULL自动忽略,所以未下单用户的avg_order_amount是NULL,符合业务逻辑。
这一阶的题,答案SQL里必然出现GROUP BY、聚合函数、HAVING。做完后,你读到“复购率”“LTV”“GMV”这些指标词,脑子里自动浮现对应的SQL骨架:先JOIN关联,再GROUP BY主体,然后SUM/COUNT/AVG计算,最后HAVING筛选。
3.4 第四阶:高级特性的“业务推演”挑战(题号76-100)
这是拉开差距的地方。题89:“查询每个用户最近一次下单的时间、商品名、订单状态,并标记是否为VIP用户”:
SELECT u.id, u.username, u.is_vip, latest_orders.last_order_time, p.name AS last_product_name, o.order_status AS last_order_status FROM users u JOIN ( SELECT user_id, MAX(created_at) AS last_order_time FROM orders GROUP BY user_id ) AS latest_orders ON u.id = latest_orders.user_id JOIN orders o ON u.id = o.user_id AND o.created_at = latest_orders.last_order_time JOIN products p ON o.product_id = p.id;这里用了三层嵌套:
- 子查询
latest_orders先算出每个用户的最大created_at,这是“最近一次”的定义; - 主查询用这个结果JOIN orders,但必须加
AND o.created_at = latest_orders.last_order_time,因为一个用户可能同秒下多单,只取时间戳匹配的订单; - 最后JOIN products获取商品名。
更难的是题97:“使用窗口函数,为每个用户的订单按时间排序,标记首次下单、末次下单、以及是否为当月首单”:
SELECT user_id, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_seq, FIRST_VALUE(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS first_order_time, LAST_VALUE(created_at) OVER (PARTITION BY user_id ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_order_time, CASE WHEN ROW_NUMBER() OVER (PARTITION BY user_id, YEAR(created_at), MONTH(created_at) ORDER BY created_at) = 1 THEN 'yes' ELSE 'no' END AS is_monthly_first_order FROM orders;窗口函数的难点在于理解PARTITION BY(分组)和ORDER BY(组内排序)的双重作用,以及ROWS BETWEEN对LAST_VALUE的影响。这些题不是考你会不会写,而是考你能不能把业务语言(“当月首单”)精准翻译成SQL逻辑。做完这一阶,你再看业务方的需求文档,能直接脑内编译成SQL,而不是先画流程图再翻译。
4. 答案解析的“防坑指南”:为什么标准答案长这样
很多人对照答案时,只看SQL是否跑通,却忽略了答案里那些看似多余的细节。这些细节,正是资深开发者踩坑后留下的路标。我们以题66为例:“查询订单金额排名前10的用户ID、用户名、总金额,并显示其排名”:
SELECT u.id, u.username, SUM(o.amount) AS total_amount, ROW_NUMBER() OVER (ORDER BY SUM(o.amount) DESC) AS rank_num FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.username ORDER BY total_amount DESC LIMIT 10;标准答案里藏着五个关键决策点,每个都对应一个常见误区:
4.1 聚合函数的位置:为什么SUM()在SELECT里,不在WHERE里?
新手常写WHERE SUM(o.amount) > 100,这会报错。因为WHERE在分组前执行,此时o.amount还是单条记录,没有“总金额”概念。SUM()必须出现在SELECT或HAVING中,这是SQL执行顺序决定的铁律:FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT。答案把SUM()放在SELECT,既计算了总金额,又为窗口函数ROW_NUMBER()提供了排序依据。
4.2 排名函数的选择:为什么用ROW_NUMBER()而不是RANK()或DENSE_RANK()?
ROW_NUMBER():1,2,3,4,5(无并列,严格递增)RANK():1,2,2,4,5(并列后跳过,如两个第2名,则下一个为第4名)DENSE_RANK():1,2,2,3,4(并列后不跳过) 题干说“排名前10”,未说明并列如何处理,默认采用最严格的ROW_NUMBER(),确保结果恰好10条。如果业务要求“销售额并列第5名的用户都算TOP10”,才用RANK()。
4.3 LIMIT的位置:为什么放在最后,而不是子查询里?
有人会写子查询SELECT * FROM (SELECT ... GROUP BY ... ORDER BY SUM() DESC) t LIMIT 10。这在逻辑上等价,但效率更低:子查询要先算出所有用户的总金额再排序,再取10条;而主查询的LIMIT 10在最终结果集上截断,MySQL优化器可能提前终止。标准答案的写法更符合执行引擎的优化路径。
4.4 字段别名的强制使用:为什么AS rank_num不能省略?
在应用层(如Python的pandas)读取结果时,列名是rank_num,而不是ROW_NUMBER() OVER (...)这种长字符串。省略AS会导致列名不可读,增加后续处理成本。练习题所有答案都强制使用有意义的别名,这是生产环境的必备习惯。
4.5 JOIN类型的选择:为什么用INNER JOIN而不是LEFT JOIN?
题干是“订单金额排名”,隐含前提是有订单的用户。如果用LEFT JOIN,会把未下单用户(SUM(o.amount)=NULL)也纳入,ROW_NUMBER()会给NULL排一个序号,污染TOP10结果。INNER JOIN是业务语义的显式声明:我们只关心有交易行为的用户。
提示:题66的答案SQL,在MySQL 8.0+可直接运行,但在5.7及以下版本不支持窗口函数。此时需用变量模拟:
@rank := @rank + 1 AS rank_num。练习题答案页专门标注了各题的MySQL版本兼容性,这是很多免费题库忽略的关键信息。
5. 实战复盘:我在某电商项目中如何用这套题救火
去年帮某电商团队优化一个慢查询,背景是运营每天要导出“近7天高价值用户清单”,原SQL跑了12分钟:
-- 原始SQL(已脱敏) SELECT DISTINCT u.id, u.username, u.email FROM users u WHERE u.id IN ( SELECT user_id FROM orders WHERE created_at >= '2023-01-01' ) AND u.id IN ( SELECT user_id FROM orders GROUP BY user_id HAVING SUM(amount) > 5000 );这个SQL的问题,正是练习题第33、57、62题的综合体。我打开练习题文档,快速定位到对应解法:
5.1 问题定位:三重嵌套+重复扫描
- 外层
WHERE u.id IN (子查询1)和u.id IN (子查询2),导致users表被扫描两次; - 子查询1
SELECT user_id FROM orders WHERE created_at >= ...没有索引,全表扫描orders; - 子查询2
GROUP BY user_id HAVING SUM(amount) > 5000同样全表扫描,且GROUP BY开销大。
5.2 重构思路:用练习题第68题的“单次聚合+条件标记”解法
练习题第68题是:“查询近30天下单且总金额超2000的用户,返回ID、用户名、总金额、订单数”。它的答案是:
SELECT u.id, u.username, COALESCE(SUM(o.amount), 0) AS total_amount, COALESCE(COUNT(o.id), 0) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY u.id, u.username HAVING total_amount > 2000;关键创新点:
LEFT JOIN ... AND o.created_at >= ...,把时间过滤条件从WHERE移到JOIN的ON子句,这样即使用户无近30天订单,也会保留用户记录,SUM()返回NULL,COALESCE转为0;- 单次JOIN+GROUP BY,避免多次子查询;
HAVING直接过滤聚合结果。
我依此重构生产SQL:
SELECT u.id, u.username, u.email, COALESCE(SUM(o.amount), 0) AS total_amount, COALESCE(COUNT(o.id), 0) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY u.id, u.username, u.email HAVING total_amount > 5000;执行时间从12分钟降到3.2秒。更关键的是,我顺手给orders.created_at和orders.user_id加了联合索引,这是练习题第15题“为高频查询字段添加索引”的实践延伸。
5.3 后续优化:用练习题第92题的“物化视图”思路
虽然快了,但每天凌晨跑还是占资源。我参考练习题第92题“创建月度销售汇总表”,建议团队建一张user_weekly_value表,每天凌晨用上述SQL刷新,业务查询直接读这张轻量表。这比临时计算快10倍,且数据一致性更好。
这套题的价值,从来不是让你记住100个答案,而是当你面对一个陌生的、带着业务气味的SQL需求时,能迅速在脑中调出对应的练习题编号,知道“哦,这题我做过,解法是XXX,要注意YYY”。它把零散的知识点,织成了一张可检索、可复用的网。
6. 进阶建议:如何把练习题变成你的SQL能力加速器
做完100道题只是起点。真正的高手,会用这套题做三件事:建立自己的错题本、构造业务变体题、反向设计练习表。这才是让它真正长在你身上的方法。
6.1 动手建一个“错题-原因-修正”三栏笔记
不要只抄答案,要记录你卡住的瞬间。比如题44:“查询所有未下单用户的用户名和邮箱”,你可能写了:
-- 你的错误答案 SELECT username, email FROM users WHERE id NOT IN (SELECT user_id FROM orders);运行发现结果为空,但明明有用户没下单。原因?SELECT user_id FROM orders返回了NULL值(比如某条订单user_id被误设为NULL),而NOT IN (..., NULL)永远为FALSE。这是SQL里经典的NULL陷阱。
正确答案是:
SELECT username, email FROM users WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL); -- 或更优雅的LEFT JOIN SELECT u.username, u.email FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.user_id IS NULL;在你的错题本里,记下:
- 错题编号:44
- 卡点描述:NOT IN遇到NULL时逻辑失效
- 根本原因:SQL中任何与NULL的比较(=, !=, IN, NOT IN)都返回UNKNOWN,WHERE只接受TRUE
- 修正方案:过滤NULL或改用LEFT JOIN
- 关联练习题:题12(NULL安全的COUNT)、题31(IS NULL/IS NOT NULL用法)
每周回顾错题本,你会发现,很多“新问题”只是旧陷阱换个马甲。
6.2 给每道题加一个“业务变体”,自己出题
题22是:“查询订单金额大于平均订单金额的订单ID、用户ID、金额”。这是基础版。你可以给自己加难度:
- 变体1(时间维度):“查询2023年Q4订单金额大于该季度平均订单金额的订单”——需要加WHERE时间过滤,再用子查询算季度均值;
- 变体2(关联维度):“查询iPhone品类下,订单金额大于该品类平均订单金额的订单”——需要JOIN products表,用category_path筛选,再算品类均值;
- 变体3(动态阈值):“查询订单金额大于其用户历史平均订单金额的订单”——要用窗口函数
AVG(amount) OVER (PARTITION BY user_id)。
自己出题的过程,就是把知识点从“识别”升级到“生成”的过程。当你能设计出变体题,说明你已经吃透了底层逻辑。
6.3 反向设计:基于业务需求,倒推练习表结构
假设你现在负责一个在线教育平台,需要统计“课程完课率”。你会怎么设计练习表?
courses表:course_id, name, total_lessons(总课时)users表:user_id, usernameuser_course_progress表:user_id, course_id, completed_lessons(已完成课时),last_access_time
然后设计题:“查询完课率(completed_lessons/total_lessons)大于80%的课程名称、完课率、学习用户数”。
这个过程,会让你深刻理解:表结构设计不是技术问题,而是业务抽象能力的体现。练习题的users/orders/products是电商范式,你得学会把它迁移到教育、医疗、物流等任何领域。
最后分享一个小技巧:把练习题答案SQL里的所有关键字(SELECT, FROM, WHERE, JOIN, GROUP BY, HAVING, ORDER BY, LIMIT)用不同颜色高亮,打印出来贴在显示器边框。每次写SQL前,扫一眼这个“语法罗盘”,肌肉记忆会越来越强。SQL不是靠背出来的,是靠一遍遍写、一遍遍错、一遍遍修正,刻进手指和大脑的。