前几天有个刚转后端的朋友问我,MySQL的查询到底该怎么上手。他之前折腾了一堆安装配置的东西,数据库倒是跑起来了,可真到写SQL查数据的时候反而发懵。这个问题其实很典型——很多人把精力全花在环境搭建上,反而忽略了最核心的查询操作。我当年也是这样,踩了不少坑才慢慢理清楚。其实MySQL的简单查询没那么玄乎,核心就是SELECT那一套语法,再配上WHERE、ORDER BY、LIMIT这些常用子句,能覆盖日常开发中八成以上的数据提取需求。这篇文章我就把自己在实际项目里用到的查询思路和细节整理出来,从最基础的SELECT开始,一步步拆解到分组、连接查询,顺便把那些特别容易踩的坑也一并交代清楚。
这篇文章不挑读者,哪怕你刚装好MySQL、连表结构都没建过,只要照着文中的例子敲一遍,也能很快上手。如果已经写过一些SQL,那重点看“为什么”的部分——比如WHERE和HAVING到底该用谁、LEFT JOIN和INNER JOIN怎么选,这些都是只看官方文档不一定能领会的实操经验。
1. 查询之前,先备好一张能折腾的表
1.1 建表和准备测试数据
学查询最忌讳的就是在空库上瞎敲命令,没有数据你根本感知不到查询结果的变化。所以我强烈建议先建一张结构稍微复杂一点的表,把常见的数据类型都包含进去,这样后续每个查询效果都能看得清清楚楚。我这里拿一个学生成绩记录的经典场景来演示,既贴近教学又适合工作后用。
CREATE DATABASE IF NOT EXISTS school_demo DEFAULT CHARSET utf8mb4; USE school_demo; CREATE TABLE student_score ( id INT PRIMARY KEY AUTO_INCREMENT, stu_name VARCHAR(50) NOT NULL COMMENT '学生姓名', course VARCHAR(50) NOT NULL COMMENT '课程名称', score DECIMAL(5,2) NOT NULL COMMENT '考试成绩', class_no VARCHAR(20) COMMENT '班级编号', exam_date DATE COMMENT '考试日期', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这张表包含了INT、VARCHAR、DECIMAL、DATE、TIMESTAMP这些常用类型,基本能模拟真实业务场景。接下来插入一批测试数据。注意DECIMAL(5,2)表示最多5位数字,其中保留2位小数,所以最大值是999.99,插入成绩时别超了。
INSERT INTO student_score (stu_name, course, score, class_no, exam_date) VALUES ('张伟', '数学', 92.5, 'A01', '2024-10-15'), ('李娜', '数学', 78.0, 'A01', '2024-10-15'), ('王强', '数学', 85.5, 'B01', '2024-10-15'), ('刘洋', '数学', 91.0, 'B01', '2024-10-15'), ('张伟', '语文', 88.0, 'A01', '2024-10-16'), ('李娜', '语文', 96.5, 'A01', '2024-10-16'), ('王强', '语文', 72.0, 'B01', '2024-10-16'), ('刘洋', '语文', 83.5, 'B01', '2024-10-16'), ('张伟', '英语', 80.0, 'A01', '2024-10-17'), ('李娜', '英语', 89.5, 'A01', '2024-10-17'), ('王强', '英语', 65.0, 'B01', '2024-10-17'), ('刘洋', '英语', 93.0, 'B01', '2024-10-17');1.2 先看看表里到底存了什么
数据插完之后,先用最基础的SELECT确认一下。这一步看似简单,其实很多人会忽略,导致后面写了半天查询,结果表里的数据跟自己预期完全不一样。
SELECT * FROM student_score;这条语句会把表里所有行、所有列都捞出来。*代表全部列,在数据量小的时候看一眼没问题,但到了生产环境,千万、上亿行的表用SELECT *就是在给自己挖坑。原因后面细说,这里先养成习惯:任何时候写查询,脑子里都要过一遍“我真的需要这么多列吗”。
2. 从SELECT开始,理解查询是怎么执行的
2.1 SELECT子句的完整语法骨架
很多人学SQL的痛点在于:明明知道关键字,但不知道它们的排序规则。MySQL的SELECT完整语法其实有一条固定的骨架,理解了这个骨架,写查询就像套模板一样简单:
SELECT 列名或表达式 FROM 表名 [WHERE 过滤条件] [GROUP BY 分组列] [HAVING 分组后的过滤条件] [ORDER BY 排序列] [LIMIT 行数限制];注意方括号里的内容都是可选子句,但它们在SQL中出现的顺序不能乱。这就像做菜时先洗菜、再切菜、最后下锅,锅都没热你不可能先把汤盛出来。MySQL在执行时也是按逻辑顺序处理的:先定位表,再逐行过滤,然后分组,接着过滤分组结果,之后排序,最后截取指定行数。理解这个顺序,能解释很多让人困惑的现象,比如“为什么WHERE里不能用聚合函数”这种经典问题。
2.2 列的选择与简单运算
查询不一定只能选表的原始列,还可以对列做加减乘除运算,甚至可以起别名。比如我想看每个学生的“得分率”,也就是成绩占总分(假设满分100分)的百分比:
SELECT stu_name, course, score, score / 100 * 100 AS score_rate FROM student_score;这里的AS score_rate就是给计算结果起一个别名。别名在后续的ORDER BY、HAVING里都能直接用,但要注意一个很多人都会犯的错:别名能不能在WHERE里直接用?答案是——不能。原因还是上面那个执行顺序:WHERE是在SELECT确定别名之前执行的,MySQL解析到WHERE时根本不知道score_rate是个什么东西。这算是最经典的SQL认知误区之一了。
2.3 去重的正确姿势:DISTINCT
课堂上经常能看到这样的需求:“我想知道有多少个学生参加了考试”。如果用SELECT stu_name FROM student_score,张伟会出现三行,因为它有三门课的成绩记录。这时候就要用DISTINCT去重:
SELECT DISTINCT stu_name FROM student_score;DISTINCT会对后面所有列的组合去重,也就是说如果你写SELECT DISTINCT stu_name, course,那只有当两行的学生姓名和课程都完全相同时才会被合并。还有一个细节值得注意:MySQL还支持SELECT DISTINCTROW,效果一样,只是写法不同,平时用DISTINCT就够了。另外,之前看过有人问“MySQL的OR能去重吗”,这是两码事,OR是逻辑运算符,用来组合条件,它不会改变返回的行数,去重必须靠DISTINCT或GROUP BY。
3. WHERE条件过滤:精准定位数据
3.1 比较运算符和逻辑运算符
查询的核心价值不在“捞全部数据”,而是快速筛出想要的子集。WHERE子句就是干这个的。最基础的就是比较运算符:大于、小于、等于、不等于、大于等于、小于等于,在MySQL里分别对应>、<、=、!=或<>、>=、<=。
比如我想看所有数学成绩大于等于85分的记录:
SELECT stu_name, course, score FROM student_score WHERE course = '数学' AND score >= 85;这里用了AND连接两个条件,表示两个条件必须同时满足。OR则是满足任意一个即可。还有一个容易忽略的坑:字符串比较和数字比较的规则不同。虽然你写score >= 85没问题,但如果你把成绩列定义成VARCHAR类型,然后存了'85'这样的字符串,MySQL会做隐式类型转换,可能引发你很费解的排序或比较结果。所以建表时选对数据类型,才是好查询的第一步。
3.2 区间查询:BETWEEN和IN的细节
查询某个范围内的数据,用BETWEEN ... AND ...比写两个比较表达式更直观。它包含边界值,属于闭区间。比如想查80到90分之间的记录:
SELECT stu_name, course, score FROM student_score WHERE score BETWEEN 80 AND 90;这条语句等价于score >= 80 AND score <= 90。很多人记不住闭区间这个特性,导致排查问题时以为数据丢了,其实是漏掉了边界值。同样,查询一组离散值可以用IN。例如“查询A01和B01两个班级的学生成绩”:
SELECT * FROM student_score WHERE class_no IN ('A01', 'B01');IN后面跟的是一组值,只要匹配其中任意一个就返回。它比写多个OR简洁得多,而且可读性更好。但有一点要提醒:如果IN列表里包含NULL,匹配行为会有些微妙,不过新手阶段先记住“IN是匹配列表中的值”就够了。
3.3 模糊查询LIKE与NULL的判断
业务里经常需要“搜索”某个关键字。比如你想知道哪些学生姓“张”:
SELECT * FROM student_score WHERE stu_name LIKE '张%';这里的%是通配符,代表任意长度的任意字符,所以'张%'能匹配“张伟”“张三丰”等所有以张开头的字符串。另一个通配符是下划线_,它只匹配一个字符。比如'_张'能匹配“小张”、“老张”,但匹配不了“大老张”。
关于模糊查询,我要分享一个实际踩过的坑:LIKE查询如果以%开头,比如'%张%',MySQL通常无法使用索引,数据量大时会全表扫描,性能下降非常明显。但这不是说不能用,关键是心里要有数。至于NULL的查询,很多人会写WHERE score = NULL,但这永远查不到数据。NULL表示“不知道、不存在”,它不能跟任何值用等号比较。正确写法是IS NULL或者IS NOT NULL:
SELECT * FROM student_score WHERE class_no IS NULL;4. 排序与限量:让查询结果有章法
4.1 ORDER BY的排序逻辑
数据库表里的数据默认是没有顺序概念的,它们按存储顺序存放,插入顺序不等于查询顺序。想让结果有序,必须明确指定ORDER BY子句。比如按成绩从高到低排名:
SELECT stu_name, course, score FROM student_score ORDER BY score DESC;DESC表示降序,ASC表示升序。默认不写时是ASC。我见过不少人误以为默认是主键排序或插入顺序,其实在MySQL里,没有ORDER BY时结果顺序是不保证的,尤其数据量一大或者走了不同执行计划,顺序可能完全变样。所以只要你对顺序有要求,就必须显式写出ORDER BY。
4.2 多列排序怎么排
单列排序很容易理解,多列排序的规则是:先按第一列排,第一列相同时再按第二列排。比如你想先按课程排,再按成绩降序:
SELECT stu_name, course, score FROM student_score ORDER BY course ASC, score DESC;执行结果是数学在前,语文其次,英语最后;每门课程内部,成绩从高到低排列。这个逻辑跟字典排序很像:先比首字母,首字母相同再比第二个字母。这里没有技巧,只有一条铁律——ORDER BY后面的列顺序,决定了排序的优先级。
4.3 LIMIT的两种用法和翻页陷阱
LIMIT用来限制返回行数。最简单的用法是只给一个参数,代表返回前N行。比如只查成绩最高的那一行:
SELECT stu_name, course, score FROM student_score ORDER BY score DESC LIMIT 1;LIMIT还有两个参数的写法:LIMIT 偏移量, 行数。偏移量表示跳过多少行。比如跳过前2行,取接下来3行:
SELECT stu_name, course, score FROM student_score ORDER BY score DESC LIMIT 2, 3;这个写法在分页场景很常见,但要注意一个巨大的坑:LIMIT 100000, 20这种翻到很后面的写法,MySQL依然要扫描前面十万行,性能极差。公司里遇到深分页问题,通常会改成基于游标的查询,用WHERE id > 上一次最大id的方式去翻页。新手阶段可以先记住:LIMIT的偏移量越大越慢,不要为了图省事直接搞一个天文数字。
5. 聚合与分组:从明细数据中提炼结论
5.1 聚合函数:COUNT、SUM、AVG、MAX、MIN
查询操作远远不止“把数据取出来”,很多时候还需要对数据做统计汇总。这时候聚合函数就该登场了。所谓聚合函数,就是把多行的数据压缩成一个结果值。常用的有五个:
COUNT(*)或COUNT(列名):统计行数SUM(列名):求和AVG(列名):求平均值MAX(列名):求最大值MIN(列名):求最小值
比如我想知道一共有多少条考试记录:
SELECT COUNT(*) AS total_records FROM student_score;想求数学平均分:
SELECT AVG(score) AS avg_score FROM student_score WHERE course = '数学';这里有一个细节需要强调:COUNT(*)和COUNT(score)并不完全等价。COUNT(*)统计所有行,包括NULL;COUNT(列名)只统计该列不为NULL的行。如果某列允许NULL,你统计行数时用错函数,结果会差很多。这个区别在生产环境特别致命,比如统计用户数时,如果email列允许为空,COUNT(email)会漏掉一大批人。
5.2 GROUP BY的分组逻辑
聚合函数单独使用,是把整张表当成一组。但你经常需要按某个维度分组统计,比如“每个学生的平均分”。这就需要GROUP BY:
SELECT stu_name, AVG(score) AS avg_score FROM student_score GROUP BY stu_name;这条语句会先把数据按学生姓名分成若干组,然后对每组分别计算平均值。执行逻辑是这样:MySQL扫描完所有数据后,根据GROUP BY的列把相同的行聚在一起,每组输出一行结果。所以SELECT后面出现的列,要么是分组列本身,要么是聚合函数计算的结果,不能随便混入其他列。比如下面这个写法在大多数场景下就是错误的:
SELECT stu_name, course, AVG(score) FROM student_score GROUP BY stu_name;因为course没有出现在GROUP BY里,MySQL无法确定同组内有多个不同课程时到底该显示哪一门。虽然MySQL某些版本下允许这种查询(默认非严格模式可能不报错),但它返回的值完全是随机且不可靠的,这种写法要坚决避免。
5.3 HAVING和WHERE的分工差异
分组之后如果想再过滤,能用WHERE吗?答案是不能。WHERE是在分组之前逐行过滤的,而分组后产生的聚合结果,比如AVG(score),在WHERE阶段根本不存在。想过滤聚合结果,必须用HAVING。经典例子:找出平均分大于85的学生:
SELECT stu_name, AVG(score) AS avg_score FROM student_score GROUP BY stu_name HAVING avg_score > 85;我见过不少人把HAVING和WHERE混用,要么该用HAVING时用WHERE导致报错,要么WHERE能解决的事非要绕道HAVING。记住这句口诀就能少踩90%的坑:先有行过滤(WHERE),后有组过滤(HAVING)。能用WHERE提前过滤掉的记录,就别留到HAVING阶段才处理,这样可以减少分组的数据量,提升查询效率。
6. 多表连接的查询思路
6.1 INNER JOIN内连接怎么用
前面所有例子都在单张表里操作,但实际开发中,数据往往是分散在多张表里的。比如考试成绩表可能只存了学生ID,学生的姓名、班级、年级等信息在另一张学生信息表里。这时候要查某个学生的所有信息和成绩,就得用连接查询。
先准备一张学生信息表:
CREATE TABLE student_info ( stu_id INT PRIMARY KEY, stu_name VARCHAR(50), class_no VARCHAR(20), enroll_year YEAR ); INSERT INTO student_info (stu_id, stu_name, class_no, enroll_year) VALUES (1, '张伟', 'A01', 2023), (2, '李娜', 'A01', 2023), (3, '王强', 'B01', 2022), (4, '刘洋', 'B01', 2022);注意这张表里有stu_id,但student_score表里没有,所以真要关联还得改表结构。为了演示,我重新建一张成绩表,带上stu_id:
CREATE TABLE student_score_new ( id INT PRIMARY KEY AUTO_INCREMENT, stu_id INT, course VARCHAR(50), score DECIMAL(5,2), exam_date DATE ); INSERT INTO student_score_new (stu_id, course, score, exam_date) VALUES (1, '数学', 92.5, '2024-10-15'), (2, '数学', 78.0, '2024-10-15'), (3, '数学', 85.5, '2024-10-15'), (4, '数学', 91.0, '2024-10-15'), (1, '语文', 88.0, '2024-10-16'), (2, '语文', 96.5, '2024-10-16'), (3, '语文', 72.0, '2024-10-16'), (4, '语文', 83.5, '2024-10-16');现在查一下每位学生的姓名、班级、课程和成绩:
SELECT si.stu_name, si.class_no, sc.course, sc.score FROM student_info AS si INNER JOIN student_score_new AS sc ON si.stu_id = sc.stu_id;INNER JOIN只返回两张表能匹配上的数据。意思是:学生信息表里有,成绩表里也有,两者通过stu_id对上了,才会出现在结果中。如果某个学生没有成绩记录,他就不会出现在结果里。“匹配不上就丢掉”是内连接最核心的特点。
6.2 LEFT JOIN和RIGHT JOIN的区别
LEFT JOIN是连接查询里另一个高频用法。它的特点是:左表(FROM后面那张表)的行全部保留,右表只有匹配上的行才出现,匹配不上就用NULL填充。比如我想把学生信息表的数据全列出来,没考试的人也保留,只是成绩显示为空:
SELECT si.stu_name, si.class_no, sc.course, sc.score FROM student_info AS si LEFT JOIN student_score_new AS sc ON si.stu_id = sc.stu_id;这条语句执行后,即使某个学生没有考试成绩,他依然会出现在结果里,course和score显示为NULL。这个场景在工作中很常见——比如统计“哪些人没打卡”“哪些订单没支付”,本质上都是LEFT JOIN后查右表关联字段是否为NULL。
RIGHT JOIN恰好相反,右表全保留,左表匹配不上就用NULL。但因为RIGHT JOIN能通过交换表顺序改写为LEFT JOIN,所以实际项目中很少直接用RIGHT JOIN,团队协作风格也倾向于统一用LEFT JOIN,避免别人阅读时绕圈子。
6.3 连接查询的字段限定技巧
写连接查询时,最烦的问题就是两张表都有同名字段。比如student_info有stu_name,student_score_new里没有stu_name,但两边可能都有id之类的字段,直接写SELECT id时MySQL会报“字段不明确”的错误。所以连接查询里必须养成写表别名、用“别名.列名”引用字段的习惯。
SELECT si.stu_name, sc.course, sc.score FROM student_info AS si LEFT JOIN student_score_new AS sc ON si.stu_id = sc.stu_id WHERE sc.score IS NULL;这段代码的意思就是:找出有学生信息但没有任何成绩记录的人。在排查数据缺失问题时特别有用。记住一个判断技巧——只要你是为了“补全信息”,优先考虑LEFT JOIN;只是为了“求交集”,用INNER JOIN就够了。
7. 查询操作的常见报错与排查手册
7.1 那些一眼看去莫名其妙的报错
查询写多了,总会遇到报错。我这里整理几个新手最常碰到的情况,全是亲身踩过的坑:
| 报错信息 | 常见原因 | 正确姿势 |
|---|---|---|
Unknown column 'xxx' in 'where clause' | 列名拼错了,或者单引号用错了 | 检查列名拼写,字段名用反引号,字符串值用单引号 |
... isn't in GROUP BY | SELECT的列没出现在GROUP BY中 | 只选择分组列或聚合函数结果 |
Invalid use of group function | WHERE里用了聚合函数 | 聚合条件移到HAVING中去 |
You have an error in your SQL syntax | 语法顺序错乱,或关键字拼写错误 | 按完整语法骨架逐段检查 |
Duplicate column name 'id' | 连接查询中两个表的同名列都被选中 | 使用别名,明确指定某个表的列 |
这类报错其实并不可怕,关键是要学会看报错的位置提示。MySQL一般会指出near 'xxx'附近有问题,重点检查这个位置前后的关键字顺序和拼写。
7.2 查询结果与预期不符的排查思路
比报错更让人抓狂的是:SQL能跑,结果却不正确。最常见的几个隐性坑,我依次说一遍。
第一个坑是NULL参与运算的规则。任何值和NULL比较,结果都是UNKNOWN,最终过滤时UNKNOWN被视为不满足条件。比如你写WHERE score = NULL查不出任何数据,就是这个原因。
第二个坑是字符串比较的隐式转换。如果字段是VARCHAR类型但存的是数字,和数值比较时MySQL会尝试把字符串转成数字,转换失败的可能返回0或跳过该行,结果跟你本意完全不一样。
第三个坑是DISTINCT和ORDER BY的组合。有时候你以为去重后按某列排序,但DISTINCT作用在所有SELECT列上,如果SELECT了多个列,排序结果会出乎意料。遇到这种场景,先分析结果集再排序。
第四个坑是LIMIT和ORDER BY一起用时,如果排序不唯一(比如成绩相同),返回哪些行是没有固定保证的。要确保一致性和可预期性,可以再加一列唯一字段参与排序。
7.3 顺手说几个查询优化的底层经验
虽然这篇文章的主题是简单查询,但有几个和查询相关的性能概念还是要尽早建立认知。第一个是索引。对经常出现在WHERE和JOIN条件里的字段建索引,能让查询速度大幅提升,尤其数据量过百万后差异非常明显。比如上面的查询如果经常用stu_id关联,那就应当在student_score_new的stu_id字段上建索引:
CREATE INDEX idx_stu_id ON student_score_new(stu_id);第二个是避免SELECT *。显式列出你需要的列,一方面减少网络传输数据量,另一方面也方便别人读懂你的查询意图。第三个是尽量避免在WHERE的字段上做函数运算,比如WHERE YEAR(exam_date) = 2024,这会导致索引失效,应该改写成WHERE exam_date >= '2024-01-01' AND exam_date < '2025-01-01'这样的范围条件。
8. 我的实操体会:从“能跑”到“可靠”
写这篇文章的过程中,我又重新审视了一遍自己这些年用MySQL写查询的习惯。有个感触特别深:很多人(包括曾经的我)喜欢从网上抄一段SQL,跑通就算完事,但完全不知道这条SQL为什么这样写、隐含了什么前提。比如最简单的SELECT * FROM table LIMIT 10,在数据量小的时候毫无问题,可一旦表里数据涨到千万级,这种写法就可能让数据库服务器卡顿甚至把内存打满。
所以我的建议一直很朴素:把基础查询的每个环节吃透,比背一百个冷门函数都管用。你现在花半小时把这个文章里的每条SQL亲手敲一遍,把执行结果和预期对照一下,之后再遇到业务上的查询需求,就不会慌。尤其是WHERE、GROUP BY、HAVING、ORDER BY这几个子句的配合关系,真的值得多花时间琢磨清楚——它们是所有复杂报表、数据统计分析的最小积木。
另外分享一个我常用的验证小技巧:写完一条比较复杂的统计查询后,先加一个COUNT(*)看看总行数是否符合预期,再用LIMIT 5抽查几行明细数据。通过“总数比对”加“明细抽查”双重校验,能排查掉绝大多数逻辑漏洞。这个习惯帮我避免过好几次线上统计出错的尴尬场面。最后再补充一句:MySQL查询入门不难,难的是懂得每一条命令背后到底发生了什么。带着这个意识去学,你会比大多数人都走得稳。