☰
MySQL DQL单表查询详解:从基础语法到实战应用
2026/10/22 15:41:02 网站建设 项目流程

MySQL的DQL语言,也就是数据查询语言,是数据库操作中使用频率最高的一块内容。如果你正在系统学习MySQL,不管现在翻到的是书里的第六章,还是刚接触数据库查询,单表数据查询永远是最先要啃下来的硬骨头。我见过太多人一上来就研究多表连接、子查询、窗口函数,结果写出来的SQL要么报错,要么跑得特别慢,回头发现连最基本的WHERE过滤都没搞清楚,根子就是单表查询没打牢。

所以这篇内容我就专门把单表单表DQL查询这件事讲透,从SELECT基础语法、字段处理、WHERE条件过滤、排序分页,到聚合函数和分组统计,最后用一份模拟的销售流水表带你走一遍完整实战。适合刚学MySQL的新手,也适合写过一些SQL但总是被细节折磨的同学查漏补缺,看完这些,你至少能扔掉“百度一下SQL写法”的习惯,自己动手写出干净、准确、好维护的查询语句。

1. 查询前置:先把DQL这层窗户纸捅破

1.1 单表查询其实就三件事

很多人看到WHERE、GROUP BY、ORDER BY这些子句就被绕晕了,其实单表查询的本质就三件事:从哪张表取数、取哪些行、怎么展示这些行。

取数用FROM指定表,取哪些行交给WHERE过滤,怎么展示则由SELECT、ORDER BY、GROUP BY这些子句控制。我再把它说得直白一点:你可以把一张表想象成一个装满货品的仓库,FROM决定去哪个仓库,WHERE是在仓库里挑出符合条件的货,SELECT决定了你最后拿走哪几样东西,ORDER BY则是告诉你货按什么顺序排好,GROUP BY是让仓库管理员按类别把货归堆。

这三个层次一旦想清楚,单表查询的整个框架就有了。后面不管遇到什么花哨写法,都是在这个框架上做文章。

1.2 SQL的书写顺序和执行顺序不是一回事

这是个特别容易踩坑的知识点。很多同学背下了SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT的书写顺序,以为数据库就是按这个顺序跑的,其实完全不是。

SQL真正的执行顺序大致是:先从FROM读表,接着由WHERE过滤行,然后GROUP BY分组,再HAVING过滤分组结果,接着SELECT挑选要返回的字段,ORDER BY排序,最后LIMIT限制返回行数。你会发现SELECT其实执行得比较靠后,这就是为什么有时候你在WHERE里不能使用SELECT里定义的别名,比如SELECT salary * 1.1 AS new_salary FROM employee WHERE new_salary > 6000会直接报错,因为WHERE执行时,别名还不存在。

我建议你把这个执行顺序写成小纸条贴在电脑旁边,后面写复杂查询的时候,不管是调错还是优化,都用得上。

1.3 为什么我建议先死磕单表

现在网上各种教程动不动就多表JOIN、子查询嵌套,初学者看着热闹,跟着写出一个复杂查询后成就感拉满,但一旦数据量上来,性能问题接踵而至,然后就开始怀疑人生。

单表查询是所有查询的地基。连接查询本质上是把多张表临时合并成一张宽表,再按单表的方式去处理;子查询本质上是在查询里嵌入另一个单表查询。也就是说,只要单表的各个子句你熟练了,复杂查询就是在这个基础上套壳子。反过来,如果连单表的WHERE都写不利索,多表只会更乱。

我接触过不少转行做开发的同事,他们共同的特点就是前期把单表查询的各种子句练到条件反射的程度,后面学JOIN、窗口函数都特别快。这个阶段看起来笨拙,实际上省下了后面大量的纠错时间。

2. SELECT基础语法与字段处理:先学会把数据取出来

2.1 从最简单的字段选择开始

先准备一张非常简单的员工表,后面大部分例子都用它来演示,你可以直接在本地MySQL里建着玩,也可以用任意一张现成表做替换。

CREATE TABLE employee ( id INT PRIMARY KEY, name VARCHAR(50), department VARCHAR(20), salary DECIMAL(10, 2), hire_date DATE ); INSERT INTO employee VALUES (1, '张三', '研发部', 8000, '2022-03-15'), (2, '李四', '研发部', 9500, '2021-07-01'), (3, '王五', '市场部', 7000, '2023-01-20'), (4, '赵六', '市场部', 6500, '2023-05-11'), (5, '钱七', '销售部', 12000, '2020-11-30'), (6, '孙八', '销售部', NULL, '2022-09-09');

最基础的一条查询就是挑选指定字段,比如我只需要员工姓名和所在部门:

SELECT name, department FROM employee;

请问,为什么要指定字段而不是无脑SELECT *?很多人觉得SELECT *省事,但它在实际项目里很不受待见。第一,它把不需要的字段也查出来,白白增加网络传输开销;第二,如果表结构后面加了新字段,你的程序接收结果的时候很可能莫名其妙出错;第三,有些数据库优化器在面对SELECT *时无法利用覆盖索引,性能会受影响。平时自己练习怎么舒服怎么来,但在写正式查询时,我习惯按结果需要的字段逐一列出来,这个习惯会让你少踩很多运行时错误。

2.2 去重与别名:让结果更可读

刚建好的员工表里有好几个部门,如果我想知道公司一共有哪些部门,用SELECT department FROM employee会把每个员工的部门都列出来,重复信息很多。这时候用DISTINCT去重:

SELECT DISTINCT department FROM employee;

执行结果是三行:研发部、市场部、销售部。这里有个细节:DISTINCT作用于后面所有字段的组合,也就是说SELECT DISTINCT department, name FROM employee去重的是“部门+姓名”这个组合,而不仅仅是部门。如果你只想去重部门但还想看到其他字段,那种需求通常要配合分组来做,后面再细聊。

别名则是用AS给字段或者表起一个临时的名字,让结果列头更友好。比如:

SELECT name AS '员工姓名', salary AS '月薪' FROM employee;

注意,别名只在当前SQL语句里有效,不会改变表结构本身。在写报表类查询时,别名几乎是必用的,因为数据库里的字段名大多像emp_nm、dept_cd这种缩写风格,直接拿给业务方看谁看得懂。

2.3 字段运算:查询不只能取数,还能“加工”数据

SELECT后面不只是写字段名,也可以写表达式。比如我想看每个员工如果涨薪20%之后是多少:

SELECT name, salary * 1.2 AS salary_after_raise FROM employee;

数值类型可以直接加减乘除,字符串可以用CONCAT做拼接,比如把部门和姓名拼在一起:

SELECT CONCAT(department, '-', name) AS dept_employee FROM employee;

这里我要提醒一个特别容易忽视的坑:如果参与运算的字段里有NULL,结果通常也是NULL。像孙八的工资是NULL,那一行计算出来的salary * 1.2还是NULL,并不会帮你自动跳过。很多新手查出来的数据莫名其妙变少,就是因为NULL被参与运算后变成了空值,被后续条件拦掉了。NULL不是0,也不是空字符串,它代表“不知道”,这个概念在MySQL里非常关键,后面WHERE部分我们还会专门说它。

3. 数据筛选核心:WHERE条件过滤的完整用法

3.1 比较运算符和逻辑组合

如果说SELECT决定了你看哪些列,那WHERE就决定了你能看到哪些行。它是单表查询里最常用、也最需要下功夫的部分。

最基本的WHERE使用比较运算符:>、>=、<、<=、=、!=或<>。比如查研发部工资大于9000的人:

SELECT name, salary FROM employee WHERE department = '研发部' AND salary > 9000;

这里注意两点。字符串类型的比较我前面演示的是单引号,MySQL也支持双引号,但为了兼容性和可读性,建议统一用单引号。逻辑组合方面,AND的优先级高于OR,这意味着WHERE department = '研发部' OR department = '市场部' AND salary > 8000这个条件的真实含义是“研发部的所有人,加上市场部中工资高于8000的人”,很容易让人看岔。为了避免这种歧义,我的习惯是任何混合组合都用括号包起来,比如写成WHERE (department = '研发部' OR department = '市场部') AND salary > 8000,既清晰又不容易出逻辑错误。

除了比较和逻辑,还有三个非常实用的范围类运算符。BETWEEN用来判断在某个区间内,IN用来匹配一组值,IS NULL用来判断空值。我把它们的典型写法整理出来:

需求推荐写法替代写法
工资在8000到10000之间WHERE salary BETWEEN 8000 AND 10000WHERE salary >= 8000 AND salary <= 10000
部门是研发部或销售部WHERE department IN ('研发部', '销售部')WHERE department = '研发部' OR department = '销售部'
工资未知WHERE salary IS NULL不能用WHERE salary = NULL
工资已知WHERE salary IS NOT NULL不能用WHERE salary != NULL

3.2 模糊匹配LIKE:别让前面的通配符拖垮性能

LIKE用于模糊匹配,是搜索类需求的主力。比如我记不清员工叫什么,只记得名字里有“三”,可以这样查:

SELECT name FROM employee WHERE name LIKE '%三%';

%表示任意长度的任意字符,_表示一个任意字符。'张_'匹配“张”开头且总共两个字的字符串,'张%'匹配“张”开头不管后面有什么的字符串。

模糊匹配有几个必须知道的坑。一是%放在开头的话,前面没有常量前缀,数据库只能把整列数据都扫一遍才能判断,比如LIKE '%三',这时候即使name字段建了索引也基本用不上。二是如果想匹配文本里本来就含%或_的字符串,需要用转义符,默认是反斜杠:LIKE '50\%'表示匹配“50%”这个文本。三是LIKE判断的是整个字段,不是子串出现就返回。

实际开发里,但凡搜索接口可能被用户高频调用,我都会提醒一句:能不用前置通配符就不用,性能差距在几百上千万行的表里会被放大到令人崩溃。

3.3 空值判断:NULL带来的迷惑行为

这个坑几乎每个MySQL初学者都会踩一次。你在员工表里写WHERE salary = NULL,返回结果肯定是空,因为NULL不等于任何值,甚至不等于NULL。正确的判断只有两种:IS NULL和IS NOT NULL。

还是用那个例子,我想找出没有填工资的员工:

SELECT name, salary FROM employee WHERE salary IS NULL;

返回的是孙八那行。除了判断空值本身,还要知道NULL在逻辑判断中的表现。WHERE salary > 8000会返回工资大于8000的人,表面上看没问题,但如果你觉得它等价于“salary不是NULL且salary大于8000”,那只是在逻辑上等价,MySQL内部对NULL的处理规则是:NULL参与任何比较运算,结果都是NULL,而NULL在WHERE里被当作False处理。所以凡是写salary > 8000,NULL行会被自然过滤掉,这正好符合大多数场景的预期。

不过反过来就会出问题,比如你想找出“工资不是8000的人”,写WHERE salary != 8000,NULL行还是会消失。如果你明确要把NULL行也算进来,必须显式写WHERE salary IS NULL OR salary != 8000。这一点在数据处理和报表统计时非常容易造成数字对不上,务必留心。

4. 结果整理:ORDER BY排序与LIMIT分页

4.1 把数据按指定顺序理顺

查询结果默认的返回顺序并不是有意义的,它是数据库根据物理存储和执行计划决定的,所以想让结果可读,必须显式排序。ORDER BY可以按一个字段或多个字段排序,默认是升序(ASC),降序要写DESC。

比如按工资从高到低排,工资相同的按入职时间早的在前:

SELECT name, salary, hire_date FROM employee ORDER BY salary DESC, hire_date ASC;

这里要解释一下多列排序的逻辑:先按第一列排,当第一列值相同的时候,才轮到第二列发挥作用。这不是“叠加排序”,而是“分级排序”。很多人在Excel里用过多级排序,理解起来不难。

ORDER BY还有一个很灵活的地方,它可以使用SELECT后面的别名,这与WHERE不一样。比如:

SELECT name, salary * 12 AS annual_salary FROM employee ORDER BY annual_salary DESC;

因为ORDER BY执行顺序在SELECT之后,所以能识别别名。我还经常用这个特性做复杂排序,比如把部门名称用FIELD函数按自定义顺序排。

关于NULL的排序位置各数据库处理不同。MySQL默认认为NULL比任何非NULL值都小,所以升序时NULL跑在最前面,降序时NULL落在最后面。如果你想让NULL固定显示在最下面,可以多写一个辅助列,例如ORDER BY salary IS NULL, salary DESC,这里的salary IS NULL会被当作0或1处理,我个人比较推荐这个写法。

4.2 LIMIT分页:控制返回行数

LIMIT用来限制返回的行数,它有两个常见写法:LIMIT n表示只返回前n行;LIMIT offset, n表示跳过offset行后返回n行。

比如工资排行榜前三名:

SELECT name, salary FROM employee ORDER BY salary DESC LIMIT 3;

再比如分页查询,第一页每页2条就是LIMIT 0, 2,第二页是LIMIT 2, 2,第三页是LIMIT 4, 2。offset的计算公式是(页码 - 1) * 每页条数,这个公式我建议直接背下来,工作中写分页接口几乎天天用。

分页必须配排序,这是一个硬性建议。不排序就做LIMIT 10,返回的“前十条”是数据库随机挑的,翻页时还会出现重复或遗漏。我见过一个真实案例,同事写报表时忘了ORDER BY,分页数据每天跑都对不上,排查了半天才发现是从排序那里开始乱的。

还要记住一个细节:MySQL的LIMIT在写offset和count时,两个参数都要求是非负整数,不能使用表达式、变量在旧版本里也会报错,当然8.0里可以动态准备语句。不过业务上分页页数和条数必须由后端做好校验,防止有人传负数进来。

5. 统计与分组:聚合函数、GROUP BY与HAVING

5.1 聚合函数:把一批行算成一个结果

聚合函数用于把多行数据汇总成单行结果。最常用的五个是COUNT、SUM、AVG、MAX、MIN。比如:

SELECT COUNT(*) AS total_people, AVG(salary) AS avg_salary, MAX(salary) AS max_salary, MIN(salary) AS min_salary FROM employee;

这里有个高频考点也是高频坑:COUNT(*)和COUNT(字段)是完全不一样的。COUNT(*)统计的是行数,包括字段为NULL的行;COUNT(salary)统计的是salary字段非NULL的行数。所以员工表里有6条记录,其中孙八的工资是NULL,COUNT(*)是6,而COUNT(salary)是5。做报表时如果没搞清楚这个区别,统计人数少一个两个是常有的事,关键是一旦数据上了百万,你根本没法一眼看出错在哪。

同理,SUM、AVG、MAX、MIN都会自动忽略NULL值。这通常符合直觉,但有一个衍生问题:AVG函数忽略NULL后求的平均值,跟“把所有NULL当0处理再求平均”的结果不一样。业务逻辑要哪种,你得事先想清楚,不能想当然。

5.2 GROUP BY:按类别分堆统计

分组是单表查询里最容易和日常直觉冲突的部分。GROUP BY会把指定字段值相同的行分到一组,然后配合聚合函数统计各组数据。比如按部门统计员工数和平均工资:

SELECT department, COUNT(*) AS ppl, AVG(salary) AS avg_sal FROM employee GROUP BY department;

执行后大概是:

departmentpplavg_sal
研发部28750
市场部26750
销售部212000

分组之后,SELECT后面能出现的字段非常有限。在大多数关系型数据库里,只能写分组字段和聚合函数。MySQL在5.7之前对这个问题很宽松,你可以随便写一个非分组字段,它返回的是每组里的任意值,这在ONLY_FULL_GROUP_BY这个SQL模式开启前特别容易写出含糊的查询。新版本默认开启了这个模式,非分组字段会直接报错,我反而觉得这是好事,逼你规范了SQL写法。

如果你确实需要每个组内某个详细字段的值,比如每个公司销售得最好的员工名字,那就不要想着在GROUP BY语句里挤出来,老老实实分两步,先用子查询或联表,或者借助分组拼接函数GROUP_CONCAT把多个值拼成一个字段。

5.3 HAVING和WHERE:两个过滤的分工

很多同学对HAVING的意义不理解,总觉得它和WHERE差不多,都是加条件过滤,为什么要多用出来一个?

关键差别在于执行时机。WHERE是在分组之前过滤原始行的,HAVING是在分组之后过滤分组结果的。所以:

  • WHERE不能使用聚合函数,因为当你过滤原始行的时候,分组还没发生,聚合函数还没算出来。
  • HAVING只能和GROUP BY搭配使用,通常用来筛选聚合后的结果。

比如我想找出平均工资超过8000的部门:

SELECT department, AVG(salary) AS avg_sal FROM employee GROUP BY department HAVING avg_sal > 8000;

这个HAVING筛选的是分组之后的平均工资,条件里出现的avg_sal实际是别名的引用。有些数据库不允许在HAVING里直接用别名,但MySQL允许,这也是MySQL比较随和的地方。

写分组统计时,我常用的一条铁律是:能用WHERE提前过滤掉的数据,绝不要留到HAVING。因为先WHERE过滤,数据量变小了,分组的开销自然更小。比如你要统计“2023年入职的员工按部门人数”,那就应该先WHERE hire_date >= '2023-01-01' AND hire_date < '2024-01-01',再从结果里分组,而不是先全表分组再HAVING日期条件。

6. 结合实战:从一份销售流水表写出完整查询

6.1 先准备一张带时间字段和金额字段的表

理论讲了这么多,我带你实打实做一遍。假设现在有一张订单表,记录了某公司每笔销售订单,结构如下:

CREATE TABLE orders ( order_id INT PRIMARY KEY, product_name VARCHAR(50), category VARCHAR(20), amount DECIMAL(10, 2), order_date DATE ); INSERT INTO orders VALUES (1, '机械键盘', '外设', 399, '2023-01-15'), (2, '无线鼠标', '外设', 129, '2023-01-16'), (3, '27寸显示器', '显示设备', 1599, '2023-01-22'), (4, '机械键盘', '外设', 399, '2023-02-10'), (5, '笔记本电脑', '整机', 6999, '2023-02-14'), (6, 'USB扩展坞', '配件', 259, '2023-03-02'), (7, '27寸显示器', '显示设备', 1599, '2023-03-18'), (8, '无线鼠标', '外设', 129, '2023-03-21');

6.2 把“按月份统计各类别销售额”拆开写

这个需求看起来简单,实际动手时逻辑链条是这样的:

第一步,确定数据来源。表就是orders,所有数据都在这张表里。

第二步,确定分组维度。要的是“各类别”“各月份”,所以分组字段应该是月份和类别。月份不能直接用order_date分,因为日期精确到了天,得用函数把它转成月份格式,MySQL里可以写DATE_FORMAT(order_date, '%Y-%m')。

第三步,确定统计值。销售额是每行amount的合计,所以聚合函数用SUM。

第四步,考虑要不要过滤。需求没说只查某段时间,所以不加WHERE。但一般报表习惯限定一个范围,假设我只想统计2023年上半年的数据,这里就该先WHERE。

于是SQL写出来是:

SELECT DATE_FORMAT(order_date, '%Y-%m') AS order_month, category, SUM(amount) AS total_amount FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2023-07-01' GROUP BY order_month, category ORDER BY order_month, total_amount DESC;

这里有一个MySQL特定的执行细节:GROUP BY后面我用了order_month这个别名。因为MySQL允许GROUP BY引用SELECT中的别名,它会在内部进行替换,这在其他数据库里并不通用,更稳妥的写法是GROUP BY DATE_FORMAT(order_date, '%Y-%m'), category。个人习惯是,GROUP BY里尽量写原始表达式,避免某些模式下报错。

6.3 再做一版“每个类别里金额最高的两笔订单”

这种需求属于分组取TopN,单表查询要稍微绕一下。在MySQL 8.0里可以上窗口函数,但单表基础阶段我更希望你用LIMIT和UNION的组合来理解思路。

思路是先查出所有类别有哪些,再分别取出每个类别金额最高的两笔,最后拼起来:

(SELECT category, product_name, amount FROM orders WHERE category = '外设' ORDER BY amount DESC LIMIT 2) UNION ALL (SELECT category, product_name, amount FROM orders WHERE category = '显示设备' ORDER BY amount DESC LIMIT 2) UNION ALL (SELECT category, product_name, amount FROM orders WHERE category = '整机' ORDER BY amount DESC LIMIT 2) UNION ALL (SELECT category, product_name, amount FROM orders WHERE category = '配件' ORDER BY amount DESC LIMIT 2);

用UNION ALL而不是UNION,是因为这里不会产生重复行,UNION额外做去重会浪费资源。

拿到结果后,验证方法也很重要。我习惯先把LIMIT去掉跑一遍,数一下每个类别一共几条,确认没写漏条件;再单独执行每个括号里的子查询看排序是否符合预期。整个过程不要嫌麻烦,宁可多试几次,也比最后报表数字对不上再回头查强。

6.4 验证查询结果和调整顺序的技巧

执行完上面的分组查询,你看到的结果应该是每个月份、每个类别一行,销售额和预期一致即可。如果发现某个月份缺了某个类别,不要先怀疑SQL,先看看原始表里那段时间到底有没有这类产品的订单。没有数据的情况下,单表查询不会凭空给你补一行,这是它和Excel透视表最大的不同。Excel透视表默认会展示所有行列交叉,而SQL只显示实际存在的分组组合。遇到这种需求的时候,你需要先准备一张包含所有类别和月份的维表,再用外连接补齐,这块我们先不在单表范围内展开,但要心里有数。

7. 单表查询高频问题排查与个人建议

7.1 我总结的一份高频坑位速查表

下面这些问题是新手问过我次数最多的,也是我自己早期反复踩过的,整理成一张速查表方便你自查。

现象排查方向
查询结果比预期少几行检查NULL是否被比较运算符或!=意外过滤
WHERE使用了SELECT别名报错把别名改为原始表达式,记住WHERE执行先于SELECT
分页数据重复或遗漏检查ORDER BY是否缺失,排序字段是否有并列值
GROUP BY后SELECT非分组字段报错配合聚合函数使用,或改用子查询/窗口函数
HAVING单独使用报错HAVING必须与GROUP BY搭配,否则语义不成立
分组统计结果少了一部分确认COUNT用的是*还是字段,NULL是否被忽略
LIKE搜索特别慢看看前置通配符是否导致索引失效,必要时改用全文索引
ORDER BY排序结果NULL位置不对用辅助表达式手动控制NULL展示位置

7.2 我写单表查询的几个习惯

最后分享几个我自己长期坚持的操作习惯。

第一,拿到需求先写草稿。不管需求看起来多简单,我都先想三件事:查哪张表、要哪些字段、要过滤什么条件。然后把SQL一行一个子句地写出来,优先保证语义正确,再谈优化。

第二,每步验证影响行数。运行查询前,我会先用简化的条件确认记录范围。比如先跑SELECT COUNT(*) FROM orders WHERE order_date >= '2023-01-01',确认数据量符合预期,再跑完整查询,这样能把“结果对不对”和“结果为什么对”分离开。

第三,格式化SQL。关键字大写、子句分行、缩进对齐,这些看似表面的习惯,实际能在排错时显著提高效率。我自己看过太多挤在一行的长SQL,别说调试,读都读不通。把SQL写得像一段排版良好的代码,比任何技巧都重要。

第四,别怕用慢查询日志和EXPLAIN。单表查询阶段你可能还不太需要看执行计划,但至少养成习惯,对稍微复杂的WHERE条件就看一眼EXPLAIN SELECT ...,确认用到了哪条索引,有没有全表扫描。这个习惯越早养越好,它会在你未来的职业生涯里不断放大价值。

单表查询看起来简单,但它从来不是背几条语法就能真掌握的东西。我个人的体会是,你每踩一个坑,对SQL的理解就会深一层。NULL的陷阱、WHERE和HAVING的分工、COUNT(*)与COUNT(字段)的区别,这些听起来不起眼的细节,恰恰是日常工作中报表对不上账、接口查出脏数据的真正源头。

最后再分享一个小经验:平时练习时不要只拿网上的现成表练手,找个自己熟悉的数据集,比如把一年的个人记账、读书清单或者健身记录导进MySQL,然后每天换着角度从里面“问问题”——上个月开销最大的是哪一类,平均每天运动时长是多少,哪个商家的消费次数最多。这种带着真实目的的练习,比照着教程敲一百遍语法都管用。MySQL的DQL单表查询,往小了说是学会几个子句,往大了说是建立你对数据的基本掌控力,把这一步走扎实,后面再往多表、复杂报表走,你会觉得整个世界都顺了。

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

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

立即咨询