ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL单表查询从入门到实战:WHERE、分组、排序与性能优化

MySQL单表查询从入门到实战:WHERE、分组、排序与性能优化 平时带新人的时候我经常遇到一种情况很多人一上来就直奔多表联查、索引优化、存储过程这些高级话题结果连一层WHERE条件都写得漏洞百出。等到写JOIN的时候过滤顺序搞混、分组统计报错、NULL判断翻车最后回头补课才发现单表查询才是真正的分水岭——它不是select * from 表这么简单而是一整套关于怎么把一堆数据变成你想要的那一小撮的思维方法。这篇手把手教程我会从建库建表开始一直讲到分组统计和实战案例把MySQL单表查询这条线一次讲透。适合完全零基础的小白也适合学过但一直没系统梳理过的人。1. 为什么我把单表查询放在MySQL入门的第一课1.1 单表查询到底在学什么SQL按功能可以分成四类DDL定义、DML操作、DCL控制、DQL查询。其中查询也就是DQL是日常使用频率最高的部分没有之一。你写后端接口要查库做报表要查库排查线上问题也要查库几乎所有的数据工作都是从查出来开始的。更关键的是单表查询决定你对数据库的思维模型正不正确。很多人学SQL最大的障碍不是记不住语法而是想不到数据是怎么被一步步筛选出来的。我常说一句话表是集合查询是筛子。数据库里一张表就是一堆行的集合你的每一条查询就是在用不同粗细的筛子把这堆行筛成你想要的样子。筛选逻辑想清楚了后面学多表JOIN无非是把两个筛子叠在一起用本质没有变。很多人直接跳过单表去学JOIN和子查询写出来的SQL经常三个毛病一是不知道WHERE和HAVING到底谁先执行二是SELECT里乱放不存在的字段三是排序分页结果不稳定。这些问题根子都在单表查询的基本功上。所以如果你刚接触MySQL或者学了一阵子总觉得差点意思我建议你跟着这篇文章把单表查询重新过一遍顺便把实战中最常见的坑也记下来。1.2 环境准备本地库和Docker二选一动手之前得先有个能跑的MySQL。如果你电脑上已经装好了用Navicat、DBeaver或者命令行客户端连上就可以跳过这一小节。没装的话我推荐两条路方案一Docker最快推荐docker run -d --name mysql8 -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ mysql:8.0启动后连接docker exec -it mysql8 mysql -uroot -p123456Windows上如果开了Docker Desktop端口占用、内存不够导致的启动失败比较常见。遇到启动失败先看日志docker logs mysql8基本都是端口被占或者配置文件挂载出错。方案二本地安装社区版从MySQL官网下载Community Server安装包一路默认配置就行。要注意MySQL 8默认的认证插件是caching_sha2_password如果你用很老版本的工具连接可能会报Unable to load authentication plugin。这时候要么升级客户端要么登录后创建用户时改成mysql_native_password。查了一圈热词里也有不少mysql ssl连接错误的提问如果你用命令行连接时遇到SSL相关的报错一般是在连接串里显式指定跳过SSL或者使用mysql --ssl0这类参数验证通过后再做数据库操作。两条路选一条跑通就行。后面所有SQL我都基于mysql:8.0来写8.0以下版本个别地方有差异但单表查询的主体语法是通用的。2. 建库建表与造数查询前先把地基打牢2.1 一张贴近实际的订单表单表查询练得好不好很大程度上取决于你有没有一张像样的表。我见过很多人随便建个两列的测试表查来查去就那么几行数据最后什么都练不出来。所以这一节我们直接建一张电商订单表字段稍微多一点后面所有的查询例子都能在这张表上跑。先建库注意字符集用utf8mb4否则中文可能出问题CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE shop;然后建订单表CREATE TABLE t_orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_name VARCHAR(50) NOT NULL COMMENT 下单用户, product_name VARCHAR(100) NOT NULL COMMENT 商品名称, category VARCHAR(50) NOT NULL COMMENT 商品分类, quantity INT NOT NULL DEFAULT 1 COMMENT 购买数量, unit_price DECIMAL(10,2) NOT NULL COMMENT 商品单价, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待支付 1已支付 2已发货 3已完成 4已取消, order_time DATETIME NOT NULL COMMENT 下单时间, PRIMARY KEY (id), KEY idx_category (category), KEY idx_order_time (order_time) ) ENGINEInnoDB COMMENT订单表;这里有三个我特意埋的点新人容易踩第一表名不能叫order。ORDER是MySQL的保留字和ORDER BY冲突。直接叫order建表不会报错但查询和插入时到处要加反引号非常麻烦。我习惯表名加t_前缀既避开保留字又一眼看出是表。第二DECIMAL(10,2)是用来存金额的。很多人图省事用DOUBLE或FLOAT这是大忌。浮点数存金额会有精度误差比如0.10.2算出来可能是0.30000000000000004。金额类业务字段一律用DECIMAL这是前辈用真金白银换来的教训。第三ENGINEInnoDB是默认存储引擎。MySQL 5.5之后默认就是InnoDB但为了明确写上更好它支持事务、行级锁和外键约束这也是实战环境最常用的引擎。2.2 插入模拟数据并做基础校验表建好了往里造数据。我给了一张12行的订单样本覆盖了不同用户、不同分类、不同状态和时间方便后面各种查询练习INSERT INTO t_orders (order_no, user_name, product_name, category, quantity, unit_price, total_amount, order_status, order_time) VALUES (NO20250101001, 小明, 机械键盘, 数码配件, 1, 399.00, 399.00, 1, 2025-01-01 10:15:00), (NO20250101002, 小丽, 无线鼠标, 数码配件, 1, 129.00, 129.00, 2, 2025-01-01 14:20:00), (NO20250101003, 老王, 办公椅, 办公用品, 2, 459.00, 918.00, 3, 2025-01-02 09:05:00), (NO20250101004, 小明, 速溶咖啡, 食品, 3, 45.00, 135.00, 1, 2025-01-02 16:40:00), (NO20250101005, 张三, 台灯, 家居, 1, 89.00, 89.00, 0, 2025-01-03 11:30:00), (NO20250101006, 小丽, 机械键盘, 数码配件, 1, 399.00, 399.00, 4, 2025-01-03 20:12:00), (NO20250101007, 老王, 笔记本支架, 数码配件, 2, 69.00, 138.00, 1, 2025-01-04 08:45:00), (NO20250101008, 李四, 保温杯, 家居, 2, 79.00, 158.00, 1, 2025-01-04 18:30:00), (NO20250101009, 张三, 签字笔, 办公用品, 5, 12.00, 60.00, 2, 2025-01-05 10:00:00), (NO20250101010, 小明, 咖啡豆, 食品, 1, 168.00, 168.00, 0, 2025-01-05 21:18:00), (NO20250101011, 李四, 双肩包, 数码配件, 1, 259.00, 259.00, 1, 2025-01-06 09:30:00), (NO20250101012, 老王, 显示器支架, 办公用品, 1, 329.00, 329.00, 1, 2025-01-06 15:45:00);插完之后先做两个检查。第一是看总行数SELECT COUNT(*) FROM t_orders;返回12说明插入成功。第二是随机看几行数据确认没有乱码SELECT * FROM t_orders LIMIT 5;如果中文显示成???十有八九是连接字符集没对。命令行连接时可以执行SET NAMES utf8mb4;图形工具一般在连接配置里改字符集。3. 查询的底层逻辑SELECT不是你想的那样执行3.1 一条SQL的真实执行顺序很多人写查询习惯从上往下读先看SELECT后面跟了什么列再看FROM哪张表。但数据库执行一条查询的顺序根本不是书写顺序。完整语法是这样SELECT [DISTINCT] 列1, 列2, ... FROM 表名 [WHERE 筛选条件] [GROUP BY 分组字段] [HAVING 分组后的筛选条件] [ORDER BY 排序字段] [LIMIT 偏移量, 行数];真正的执行顺序是顺序关键字作用类比1FROM确认从哪张表取数先确定菜摊2WHERE对每一行做条件筛选挑出合格的菜3GROUP BY把筛选后的行分组按品类码放4HAVING对分组结果做筛选只留下够分量的那几堆5SELECT决定最终输出哪些列决定装袋时看哪几样6ORDER BY对最终结果排序按重量摆顺序7LIMIT只取若干行只要前几个袋子为什么要强调这个顺序因为你遇到为什么这里不能用别名为什么WHERE里不能写聚合函数这类问题时答案都藏在执行顺序里。举个例子很多人问WHERE里为什么不能用SELECT里取的别名因为WHERE执行时SELECT还没执行你的别名在数据库中还不存在。类似的WHERE在分组之前执行所以WHERE条件不能写SUM(total_amount) 1000这种聚合条件。这两个问题是面试高频题也是写SQL时最容易纠结的问题。3.2 WHERE条件过滤从全表到目标行先来最基础的。查所有已支付订单SELECT order_no, user_name, total_amount FROM t_orders WHERE order_status 1;结果会返回4行NO20250101001、NO20250101004、NO20250101007、NO20250101008、NO20250101011、NO20250101012我看了一下应该是6行1/4/7/8/11/12都是status1。WHERE后面能用的运算符我整理了一份清单运算符含义例子 ! 等于、不等于order_status 1 比较total_amount 200BETWEEN ... AND ...范围包含两端total_amount BETWEEN 200 AND 500IN (...)集合匹配order_status IN (1,2,3)LIKE模糊匹配product_name LIKE %键盘%IS NULL / IS NOT NULL空值判断remark IS NULLAND / OR / NOT逻辑组合order_status 1 AND total_amount 200新手第一坑NULL不能用等号判断。你写WHERE remark NULL永远查不到数据因为MySQL里NULL NULL的结果不是TRUE而是NULLNULL当作条件时等同于不成立。正确写法是IS NULL。第二坑AND的优先级高于OR。写WHERE order_status 1 OR order_status 2 AND total_amount 200实际执行的是order_status 1 OR (order_status 2 AND total_amount 200)而不是你想的(order_status 1 OR order_status 2) AND total_amount 200。条件一多老老实实加括号。第三坑日期也是可以比大小的。查2025年1月2日当天的订单直接写SELECT order_no, order_time FROM t_orders WHERE order_time 2025-01-02 00:00:00 AND order_time 2025-01-03 00:00:00;注意不要用BETWEEN 2025-01-02 00:00:00 AND 2025-01-02 23:59:59这种写法万一有订单的时间是23:59:59.500就被漏掉了。用大于等于当天零点、小于明天零点这种左闭右开区间是最稳妥的。3.3 排序与分页让结果真正可读SQL里数据默认没有顺序概念想按某列排序必须用ORDER BYSELECT order_no, total_amount, order_time FROM t_orders ORDER BY total_amount DESC, order_time ASC;这段SQL的含义是先按total_amount降序排如果金额相同再按order_time升序排。多字段排序在实际场景非常多见比如按金额从大到小金额相同的按时间从早到晚正好对应后台订单列表的需求。排序有两个容易忽略的坑。第一如果排序列是VARCHAR类型排序走的是字符串排序而不是数字排序。比如NO20250101002和NO20250101011比较时字符串会一位一位比02确实比11小但如果编号变成NO20250101100字符串排序就会认为它比NO20250101002小因为第12位字符1小于2。设计编号时想按字典序保持有序就统一补零到等长。第二ORDER BY最好配着LIMIT用。没有明确排序的前几条是没有意义的因为结果可能每次都不一样。分页是老生常谈。MySQL的LIMIT支持两种写法-- 取前5条 SELECT * FROM t_orders LIMIT 5; -- 跳过5条取接下来10条等价于第6~15条 SELECT * FROM t_orders LIMIT 5, 10; -- 或者 SELECT * FROM t_orders LIMIT 10 OFFSET 5;分页公式是固定的LIMIT (当前页-1) * 每页条数, 每页条数。比如每页10条第三页就是LIMIT 20, 10。注意LIMIT第一个参数是从0开始计数的第6条对应的偏移量是5而不是6。4. 从会查到会统计函数、分组与聚合4.1 常用函数字符串、日期、数值一套带走会了SELECT WHERE ORDER BY你已经能应付简单的取数了。但现实需求往往需要加工字段这时候就要用函数。字符串函数比如把用户名和商品拼成一句话SELECT order_no, CONCAT(user_name, 购买了, product_name) AS purchase_desc FROM t_orders WHERE order_status 1;函数里还有几个高频的SUBSTRING(str, start, len)做截取注意MySQL里下标从1开始LENGTH()返回字节数而CHAR_LENGTH()返回字符数utf8mb4下一个中文占3个字节这两个函数结果经常不一样TRIM()去掉首尾空格REPLACE(str, from_str, to_str)做替换。日期函数最常用的是DATE_FORMAT和DATEDIFF。比如把下单时间格式化成年月日SELECT order_no, DATE_FORMAT(order_time, %Y-%m-%d) AS order_date FROM t_orders;DATEDIFF(order_time, NOW())能算出某个时间和当前时间的差值天数做超时未支付提醒时会用到。条件函数强烈建议尽早掌握。最简单的IF(条件, 值1, 值2)SELECT order_no, IF(order_status 4, 已取消, 未取消) AS cancel_flag FROM t_orders;多个判断用CASE WHEN比如把状态码翻译成中文SELECT order_no, CASE order_status WHEN 0 THEN 待支付 WHEN 1 THEN 已支付 WHEN 2 THEN 已发货 WHEN 3 THEN 已完成 WHEN 4 THEN 已取消 ELSE 未知 END AS status_text FROM t_orders;这段在报表场景里几乎必用建议直接背下来。数值函数里注意ROUND(amount, 2)做四舍五入FLOOR()向下取整。另外很多新人会遇到字段 int 5这种写法的问题MySQL里如果字段本身就是整型quantity 5就是正常的算术运算但如果是字符串和数字做比较MySQL会隐式地把字符串转成数字再比较。隐式转换经常导致索引失效后面第6章会重点讲。4.2 分组统计的正确姿势光会取字段还不够需求往往是统计一下。按分类统计订单数和总金额这是所有报表类需求的雏形SELECT category, COUNT(*) AS order_count, SUM(total_amount) AS total_sales FROM t_orders GROUP BY category;GROUP BY的意思是把所有category相同的行归成一组每组输出一行。配上聚合函数效果就很直观COUNT(*)统计组内行数COUNT(字段)统计非NULL的行数SUM(字段)求和AVG(字段)求平均MAX(字段)、MIN(字段)取最大最小。执行顺序上WHERE先筛行GROUP BY再分组所以只统计已支付订单可以写成SELECT category, COUNT(*) AS order_count, SUM(total_amount) AS total_sales FROM t_orders WHERE order_status IN (1, 2, 3) GROUP BY category;这一步非常关键过滤是发生在分组之前的。分组还有一个高频报错就是MySQL 5.7之后默认开启的ONLY_FULL_GROUP_BY模式。下面这条SQL在很多人机器上直接报1055错误-- 报错product_name不在GROUP BY中 SELECT product_name, COUNT(*) FROM t_orders GROUP BY category;为什么报错因为category相同的一组里可能有多个不同的product_name数据库不知道该输出哪个。正确的做法是要么GROUP BY product_name要么用MAX(product_name)这种聚合函数把它包起来。这个报错是面试和实战的高频坑记住一句话SELECT中的非聚合列必须出现在GROUP BY里。4.3 为什么有了WHERE还要用HAVINGWHERE在分组前筛行HAVING在分组后筛组。两者定位完全不同。比如要找出总销售额超过300元的分类SELECT category, SUM(total_amount) AS total_sales FROM t_orders WHERE order_status IN (1, 2, 3) GROUP BY category HAVING total_sales 300;这里如果试图写成WHERE SUM(total_amount) 300MySQL会直接报错因为WHERE执行时分组还没产生聚合函数自然不可用。而HAVING可以引用聚合结果甚至可以引用SELECT中的别名total_sales因为HAVING的执行顺序排在SELECT之后。我见过太多人把HAVING当成高级WHERE来用在HAVING里写一堆普通字段条件比如HAVING user_name 小明。这在逻辑上没错但效率很差因为普通字段条件完全应该提前在WHERE里筛掉让分组处理的行数更少。记住一个原则能用WHERE过滤的不要放到HAVING。还有一个易混淆点DISTINCT去重和GROUP BY的关系。SELECT DISTINCT category FROM t_orders和SELECT category FROM t_orders GROUP BY category结果几乎一样但语义不同。查询有哪些分类用DISTINCT更直观SELECT DISTINCT category FROM t_orders;5. 一个订单查询需求从0到1的完整演变5.1 需求还原先写第一版前面语法都学过了但很多人合在一起就不知道怎么用了。这一节我拿一个真实的运营需求来串一遍。需求场景运营同事想要一份2025年1月1日到1月6日期间的销售数据列表管理员能看到所有订单默认最新订单排前面。第一版查询很简单SELECT order_no, user_name, product_name, order_status, total_amount, order_time FROM t_orders ORDER BY order_time DESC;先把数据查出来让运营看到最新动态。你可能会问为什么不用SELECT *这里埋个习惯尽量按需取列。SELECT *会把表里所有列都取出来不仅传输数据量大而且如果表里塞了个大字段比如remark TEXT一次普通列表查询会白白浪费大量IO。而且一旦表结构加了列SELECT *的结果集结构也会变很多后端程序会因此出bug。5.2 需求加码逐个叠加过滤条件第二天运营说只想看到已支付之后的订单不要待支付和取消的。于是加上WHERESELECT order_no, user_name, product_name, order_status, total_amount, order_time FROM t_orders WHERE order_status IN (1, 2, 3) ORDER BY order_time DESC;第三天运营又说只要数码配件和办公用品两个分类、单笔金额在200元以上。再叠条件SELECT order_no, user_name, product_name, category, order_status, total_amount, order_time FROM t_orders WHERE order_status IN (1, 2, 3) AND category IN (数码配件, 办公用品) AND total_amount 200 ORDER BY order_time DESC;你看复杂查询没有魔法本质就是一层层往上叠加筛子。每加一个条件结果集就更聚焦一步。第四天运营说想看每个分类的销售额汇总并且要已支付的数据按销售额从高到低排最好能列出来。这一步从明细切换到统计SELECT category, COUNT(*) AS order_count, SUM(total_amount) AS total_sales FROM t_orders WHERE order_status IN (1, 2, 3) GROUP BY category ORDER BY total_sales DESC;注意total_sales是SELECT里的别名能不能用在ORDER BY里能因为ORDER BY的执行顺序在SELECT之后。这和WHERE里不能引用别名正好相反完整的执行顺序表能帮你理清这些关系。5.3 加入聚合筛选和分页形成完整报表第五天运营说销售额低于100元的分类不用看。这时候就要HAVING上场SELECT category, COUNT(*) AS order_count, SUM(total_amount) AS total_sales FROM t_orders WHERE order_status IN (1, 2, 3) GROUP BY category HAVING total_sales 100 ORDER BY total_sales DESC;最后运营还想要一个分页报表每页显示3条。因为总分类就4个左右分页效果在数据量小的时候看不出差别但思路要记住SELECT category, COUNT(*) AS order_count, SUM(total_amount) AS total_sales FROM t_orders WHERE order_status IN (1, 2, 3) GROUP BY category HAVING total_sales 100 ORDER BY total_sales DESC LIMIT 0, 3;从第一版到最终版你会发现每一步都只加了一点点东西。这也是我带新人时反复强调的复杂SQL不要试图一次写对先跑通再逐步加条件。你用WHERE把行筛掉用GROUP BY把行归组用HAVING把组筛掉用ORDER BY把结果排好用LIMIT截断一条报表查询就拼出来了。6. 新人最容易踩的坑报错、异常与性能隐患6.1 三个高频报错逐个拆解报错1055ONLY_FULL_GROUP_BY前面已经遇到过我再从排查角度讲一遍。错误信息大概是Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column意思是SELECT后面某个列既不在GROUP BY里也不是聚合函数。解决办法就两条把这个列加到GROUP BY里或者用聚合函数包住它。不建议为了消除报错去改sql_mode那只是把问题往后推。报错1064语法错误最常见的原因是列名或表名撞上了保留字。比如你把表命名成order后面每个SQL几乎都要写反引号SELECT * FROM order;虽然能跑但哪次忘了写反引号就会报1064。我的建议是命名时彻底避开保留字新建表时t_前缀就是干这个用的。如果你在写SQL时不确定某个单词是不是保留字用反引号包起来永远是最稳的。报错1146Table doesnt exist这个报错还挺迷惑人的明明表能看到却提示不存在。大概率是大小写问题。MySQL在Linux下默认区分表名大小写在Windows下默认不区分。本地开发用的Windows建的表名是T_Orders代码里写t_orders本地跑得好好的一部署到Linux服务器就报1146。数据库名、表名、字段名的命名规范在项目一开始就要定下来我个人的习惯是统一小写加下划线从源头上杜绝这类问题。6.2 三个影响查询性能的坏习惯新手阶段写的SQL性能问题往往比语法问题更隐蔽。我总结了三个最常见的坏习惯一WHERE里对字段做函数运算-- 低效对order_time字段用了函数索引失效 SELECT * FROM t_orders WHERE YEAR(order_time) 2025; -- 更优保持字段原样用范围比较 SELECT * FROM t_orders WHERE order_time 2025-01-01 00:00:00 AND order_time 2026-01-01 00:00:00;前者的写法虽然语义相同但MySQL无法直接利用idx_order_time索引因为每个数据都得先计算YEAR(order_time)才能判断。让字段裸着进条件索引才能生效。坏习惯二LIKE前置通配符-- 低效以%开头索引失效 SELECT * FROM t_orders WHERE product_name LIKE %键盘%; -- 能走索引的写法 SELECT * FROM t_orders WHERE product_name LIKE 机械%;实际情况里搜索需求往往就是模糊搜中间所以LIKE %关键字%要慎用。如果业务确实需要全文搜索可以考虑全文索引或者专业的搜索组件而不是硬扛LIKE。坏习惯三不做限制的全表大查询很多新人写后台接口列表直接SELECT * FROM t_orders然后把全表丢给前端。数据量小的时候无所谓到了百万级就是灾难。列表查询一定要带上LIMIT最好再加上WHERE把范围圈小。数据量大之后连COUNT(*)都要小心因为InnoDB的COUNT(*)是要逐行数的和MyISAM的秒回完全不是一个概念。6.3 学会EXPLAIN让查询看得见判断一条查询到底怎么被执行的最直接的手段是EXPLAINEXPLAIN SELECT order_no, user_name, total_amount FROM t_orders WHERE order_status 1 ORDER BY order_time DESC LIMIT 10;返回结果里重点看几个字段字段关注点type全表扫描一般是ALL走索引常见的是ref、range、constkey实际用到的索引名NULL表示没走索引rowsMySQL估算需要扫描的行数越小越好Extra如果出现Using filesort说明排序没走索引数据量大时要留意对单表查询的新手来说EXPLAIN不一定每次都要用但你要养成一个习惯遇到慢查询第一件事不是猜而是EXPLAIN一把。用人的肉眼去感觉SQL为什么慢效率太低。我现在排查线上慢查询流程永远是先看执行计划再从执行顺序倒推是哪个环节扫了太多行。关于排序还有一个很容易被忽略的点ORDER BY如果和WHERE用了不同的索引MySQL可能要先查出满足条件的数据再单独排序表现就是Using filesort。数据量小没关系但一旦表大了这个临时排序会吃掉大量内存和磁盘。所以在设计表的时候就要考虑哪些字段组合经常一起出现在查询条件里给它们建立联合索引。这个属于索引优化的范畴等单表查询熟练之后下一步就该往这个方向深入了。最后分享一个我在实际排查中反复用到的笨办法当一条SQL结果不对先别急着怀疑数据库把WHERE条件逐个去掉每去一个条件跑一次观察结果集怎么变化。这个逐段剥离法看起来土但排查效率极高尤其适合理清多条件组合的查询。单表查询这条路走稳了后面多表JOIN、子查询、窗口函数学起来会轻松很多因为你已经知道数据库这一步一步到底在做什么了。
返回列表