数据库这行干久了,你会发现在业务代码里写 SQL 的时间,往往比写 Java、Python 还要多。尤其是“基本查询”这四个字,看着简单,真到面试、上线、排查线上问题的时候,多少人栽在它上面。我见过写了好几年代码的开发,愣是把 LEFT JOIN 当 WHERE 用;也见过把 ORDER BY 条件放到子查询里,结果排序完全失效。所以这次我打算把 MySQL 基本查询从头到尾捋一遍,不讲花架子,直接讲那些你每天都会碰到的语句、原理和坑。
这篇内容适合三类人:刚接触数据库的学生或转行新人;写过增删改查但没系统整理过查询逻辑的初级开发;准备面试想快速再过一遍基础的人。内容会覆盖 SELECT、WHERE、ORDER BY、GROUP BY、JOIN、子查询,以及我在实际操作中遇到的性能陋习和排查方法。你会看到不少可以直接抄走的语句模板,也会看到我踩过的坑。下面我们直接开始。
1. 从零开始的 SELECT:查询的骨架
1.1 先记住 SQL 的执行顺序,别被书写顺序骗了
很多初学者以为 SQL 是从 SELECT 开始执行的,这其实是最大的误解。SQL 的书写顺序和执行顺序并不一致。一条标准查询语句写出来是SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT,但在 MySQL 内部的执行顺序却是FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。
这个顺序为什么重要?因为它直接决定了你能不能在 WHERE 里用别名。比如:
SELECT age + 1 AS new_age FROM user WHERE new_age > 20;这条语句会报错,因为 WHERE 在 SELECT 之前执行,此时new_age这个别名还不存在。正确做法是把age + 1 > 20直接写进 WHERE,或者在外面套一层子查询。理解执行顺序还有一个好处:写复杂语句时,你会天然知道过滤条件该放在哪里。比如分组前的过滤用 WHERE,分组后的过滤用 HAVING,原因也是因为 WHERE 在 GROUP BY 之前执行。
1.2 SELECT 与 FROM 的使用细节
SELECT 是查询的投影操作,决定最终返回哪些列。基本规则很简单,但有两个细节经常有人忽略。
第一,能用SELECT *吗?我建议尽量别用。尤其是生产环境的表,动辄几十个字段,SELECT *会把无关字段全部查出来,增加网络传输压力;如果表结构后面加了字段,线上代码拿到的结果集也跟着变,极容易埋雷。第二,多表查询时,所有字段最好都带表名或别名前缀,比如SELECT u.id, u.name FROM user u。这样写的好处是查询语义清晰,MySQL 优化器也能少一步“猜字段”的环节,更重要的是避免两个表存在同名字段时返回结果错乱。
FROM 里还支持子查询作为临时表,MySQL 规定这种派生表必须起别名,比如FROM (SELECT ...) t。这个技巧在拿到中间结果、继续统计时非常常用,后面讲子查询时我会再展开。
1.3 DISTINCT、别名与基础表达式
DISTINCT 的作用是对结果集去重,但很多人没注意到它是“整行去重”,不是“单列去重”。
SELECT DISTINCT department, city FROM employee;上面这句话的意思是department + city这两个字段的组合不能重复,而不是 department 唯一。如果你只想看有哪些部门,应该用SELECT department FROM employee GROUP BY department或者直接对 department 用 DISTINCT。DISTINCT 还有一个性能小坑:它本质上是排序或哈希去重,数据量大时会额外消耗内存和 CPU,能不用就不要滥用。
别名和表达式也是日常查询的高频操作。SELECT amount * price AS total能直接算金额,CASE WHEN能做条件转换,比如:
SELECT name, CASE WHEN score >= 60 THEN '及格' ELSE '不及格' END AS result FROM student;看到这里你应该已经明白:基本查询不是把 SELECT 背下来就完事,它是有执行顺序、有语义边界、有设计取舍的。后面所有复杂写法,都是在这些问题的基础上叠加。
2. 把数据框进你的范围:WHERE 条件过滤
2.1 比较运算与逻辑组合
WHERE 的核心作用是对表数据进行逐行筛选,保留满足条件的行。最常用的是比较运算符:=、!=、<>、>、<、>=、<=。逻辑组合用 AND、OR、NOT,注意 OR 的优先级低于 AND,所以WHERE a = 1 OR b = 2 AND c = 3实际是WHERE a = 1 OR (b = 2 AND c = 3)。如果拿不准,就加括号,别让读到代码的人猜。
这里有个特别容易踩的坑:字符串和数字比较。如果你在字符串字段上写WHERE phone = 13800001111,MySQL 会把字段值和数字都转成浮点数再比较。一旦字段内容不是纯数字,转换过程就可能让索引失效,甚至出现诡异的结果。正确做法是给字符串加引号:WHERE phone = '13800001111'。
NULL 的过滤也是重灾区。WHERE name = NULL永远查不出数据,因为 NULL 不是一个值,它表示“未知”。判断空值必须用IS NULL或IS NOT NULL。很多人写条件过滤时栽在 NULL 上,往往就是没理解这一点。
2.2 模糊查询 LIKE 和字符集陷阱
LIKE 是做模糊搜索最直接的写法,但它的性能和个人习惯密切相关。LIKE '张%'匹配以“张”开头的字符串,这种写法在字段有普通索引时是有机会走索引的;但LIKE '%张%'因为通配符在最前面,优化器只能全表扫描,数据量一大就会卡。
实际项目里,如果需要频繁做前后模糊匹配,一般建议配合全文索引或者用第三方搜索组件,而不是硬扛 LIKE。如果只是临时查数据,用LIKE '%关键词%'也能接受,但要心里有数。
字符集和排序规则(Collation)也会影响 LIKE 的结果。比如表是utf8mb4_general_ci时,LIKE 匹配一般不区分大小写;如果改成utf8mb4_bin,它就会区分。有些项目从别的数据库迁到 MySQL,发现SELECT * FROM user WHERE name = 'Zhang'能查出zhang,第一反应以为数据出错了,其实是排序规则的问题。判断这个问题很简单:执行SHOW TABLE STATUS LIKE 'user';查看表的 Collation,或者用SHOW FULL COLUMNS FROM user;看字段的 Collation。
2.3 IN、BETWEEN 与 NULL 处理
IN 适合匹配一组固定值,比如WHERE status IN ('open', 'closed')。它的语义清楚,但有两个使用细节:IN 列表里的值太多会降低性能,通常超过几百个就应该考虑用连接或临时表;如果 IN 子查询返回的结果集特别大,优化器可能选择不友好,后面会单独讲 EXISTS。
BETWEEN 是范围过滤的语法糖,WHERE age BETWEEN 18 AND 30等价于age >= 18 AND age <= 30。这里要注意 BETWEEN 是包含边界值的,跟有些语言里的半开区间不一样。边界是日期时更要小心:BETWEEN '2024-01-01' AND '2024-01-31'不包含当天的 23:59:59,需要判断是否应该用datetime < '2024-02-01'这种方式。
NULL 和空字符串是两个概念。NULL 表示没有值,空字符串''是有效值。在统计时,COUNT(col)会忽略 NULL 但不会忽略空字符串,很多数据不一致的问题就从这里来。写过滤条件时,最好明确需求到底是排除 NULL,还是排除空串,还是两个都排除。
3. 让数据有秩序:ORDER BY 排序与 LIMIT 分页
3.1 ORDER BY 多字段排序和字符集问题
排序是查询里最容易“想当然”的一个环节。ORDER BY created_at DESC很简单,但多字段排序时经常有人写反。比如先按部门升序,再按工资降序,正确写法是:
SELECT name, department, salary FROM employee ORDER BY department ASC, salary DESC;这里的关键是:只有当前面字段值相同时,后面的字段才参与排序。如果你写ORDER BY salary DESC, department ASC,结果就是把工资高的排最前,部门字段只在工资相同时起作用,跟预期完全不同。
中文排序的坑也不少。MySQL 的默认排序规则对中文字段通常按拼音排序,但如果你用的是gbk_chinese_ci和utf8mb4_general_ci,不同版本的 MySQL 表现可能不完全一致。更麻烦的是,如果字段里既有中文又有英文、数字,排序结果会很“迷”。遇到严格的排序需求,可以在 ORDER BY 里显式指定:ORDER BY name COLLATE utf8mb4_bin ASC。这个操作能强制按字节序排,但要注意它会失去拼音排序功能。
3.2 LIMIT 分页与性能优化
分页最常见的写法是LIMIT offset, size,也就是跳过前 offset 条数据取 size 条。它的性能问题出现在 offset 很大的时候,比如查第 100 万页,MySQL 还是要先扫描并丢弃前 1000 万条数据,这个过程非常浪费。
一个经典优化方案是“记录上一页最后一条 ID”:
SELECT id, name, created_at FROM user WHERE id > 100000 ORDER BY id ASC LIMIT 20;它用 WHERE 条件缩小范围,让查询能利用主键索引直接定位,速度比LIMIT 100000, 20快得多。如果业务必须用传统分页且表很大,可以考虑“延迟关联”:先只查主键SELECT id FROM user ORDER BY id LIMIT 100000, 20,拿到 20 个 ID 后再 JOIN 回原表取完整数据。这样做能减少第一阶段的回表数量,实测提升非常明显。
3.3 排序与索引的联动
很多人以为 ORDER BY 无非就是最后加一句,其实排序是否走索引,直接决定查询是毫秒级还是秒级。当排序字段和 WHERE 过滤字段能组成联合索引时,MySQL 可以直接按索引顺序读取,不需要额外排序操作,执行计划里的 Extra 列就不会出现Using filesort。
举例子:表里有索引idx_status_created(status, created_at),查询WHERE status = 1 ORDER BY created_at DESC就能从索引里同时完成过滤和排序。但如果写WHERE status = 1 ORDER BY name,name 字段不在索引里,MySQL 就得先把数据查出来,再用临时文件排序,这就是 filesort。
想验证也非常简单,执行EXPLAIN SELECT ...,看到 Extra 列里有Using filesort就要警觉了。这不是说 filesort 一定慢,数据量大时它一定不轻松。所以遇到排序慢,优先想能不能调整索引,而不是一味加内存。
4. 聚合与分组:从明细到统计
4.1 常用聚合函数
聚合函数就是把多行数据计算成一个结果。最常用的是COUNT、SUM、AVG、MAX、MIN。它们各自的细节值得单独说:
COUNT(*)统计的是行数,不管字段是不是 NULL;COUNT(字段)统计的是该字段非 NULL 的行数。SUM(字段)遇到所有值都是 NULL 时,返回 NULL,不是 0。算业绩时最好用IFNULL(SUM(amount), 0)包一层。AVG(字段)会自动忽略 NULL 行,也就是说它不是拿总行数做分母,而是拿非 NULL 行数做分母。MAX和MIN对字符串、日期类型也能用,日期最大就是最晚的日期,字符串比较规则由排序规则决定。
聚合函数配合 WHERE 时执行顺序是:先 WHERE 过滤,再聚合。所以SELECT COUNT(*) FROM orders WHERE status = 'paid'统计的是已支付订单数量,逻辑很清楚。
4.2 GROUP BY 与 HAVING 的使用场景
GROUP BY 的作用是分组统计,它会把同一字段值的行合并成一组,然后每组输出一行结果。分组之后,SELECT 后面能出现的字段只分两类:分组字段本身,或者聚合函数计算结果。比如:
SELECT department, AVG(salary) AS avg_salary FROM employee WHERE status = 'active' GROUP BY department HAVING AVG(salary) > 8000;这条语句先过滤在职员工,再按部门分组算平均工资,最后只保留平均工资大于 8000 的部门。注意HAVING是对分组后的结果做过滤,能用 WHERE 提前过滤的条件就尽量不放在 HAVING 里,因为 HAVING 是在分组后执行,能处理的数据范围已经没那么“友好”了。
MySQL 的sql_mode如果开启了ONLY_FULL_GROUP_BY,你 SELECT 的非聚合列必须出现在 GROUP BY 中,否则直接报错。很多老项目从宽松模式迁到严格模式时会冒出一堆错误,原因就在这里。建议新项目直接保持默认严格模式,强行让自己写出标准 SQL。
4.3 分组统计常见坑
我见过最多的问题是统计“每个用户最近一次登录时间”时,想当然写成:
SELECT user_id, MAX(login_time), login_ip FROM login_log GROUP BY user_id;在严格模式下这直接报错,因为login_ip不在 GROUP BY 里。但即使不报错,返回的 login_ip 也不一定是最近一次登录的那条记录的 IP,因为 MySQL 只保证MAX(login_time)的结果正确,不保证同行其他字段也是“最大值对应的值”。这个语义问题非常隐蔽,正确做法是用子查询先查出每个用户的最大登录时间,再 JOIN 回原表取完整记录。
分组统计另一个坑是 NULL 值会被单独分成一组。比如GROUP BY department,所有 department 为 NULL 的数据会聚合成一组,看起来就像凭空多了一个“空部门”。如果你不想统计 NULL,记得先加WHERE department IS NOT NULL。
5. 多表连接 JOIN:从一行看到另一张表
5.1 理解 JOIN 的几种类型
JOIN 是基本查询里最考验逻辑能力的部分,核心就是“把两张表按某种条件拼起来”。最常见的三种:
INNER JOIN:只返回两表都匹配上的行,其他行丢掉。LEFT JOIN:返回左表全部行,右表没有匹配时用 NULL 填充。RIGHT JOIN:反过来,返回右表全部行,左表没有匹配时用 NULL 填充。
举一个订单和用户的例子。订单表有 user_id,用户表有 id。我想查所有订单以及对应的用户姓名:
SELECT o.order_id, u.name FROM orders o LEFT JOIN users u ON o.user_id = u.id;这里的语义是“订单是主角,即使某些订单的 user_id 在用户表里不存在,订单行也要出现在结果里,只是用户姓名为 NULL”。如果用INNER JOIN,那些没有匹配用户的订单就会消失。两种写法就差几个字母,结果差出好几行,这也是联表查询最容易出 bug 的地方。
MySQL 本身不支持FULL OUTER JOIN,如果业务需要“合并两张表的所有数据,匹配得上就匹配,匹配不上就补 NULL”,可以同时 LEFT JOIN 再 RIGHT JOIN,然后用 UNION 去重。不过这种情况比较少,一般通过表结构设计就能避免。
5.2 ON 与 WHERE 的区别
这个知识点我几乎每次培训都要强调。ON 后面的条件负责“如何连接两张表”,WHERE 负责“连接完成后过滤结果”。两者在 LEFT JOIN 中有着本质区别。
SELECT o.order_id, u.name FROM orders o LEFT JOIN users u ON o.user_id = u.id AND u.status = 'active';上面的查询会返回所有订单,即使关联到的用户 status 不是 active,用户字段照样返回,只是不满足 ON 条件的用户行会被置为 NULL。但如果写成:
SELECT o.order_id, u.name FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.status = 'active';那 LEFT JOIN 的结果还要经过 WHERE 过滤,一旦用户 status 不满足条件,整行订单都被过滤掉,LEFT JOIN 就变成了 INNER JOIN 的效果。很多线上数据异常,排查到最后都是这个原因。
对于 INNER JOIN,ON 和 WHERE 在结果上等价,但为了让语义清楚,最好还是把连接条件放 ON,业务过滤条件放 WHERE。
5.3 JOIN 性能注意事项
多表 JOIN 的性能,比单表查询容易失控。我总结几个基本注意事项:
- 连接字段类型要一致。如果一张表 id 是 INT,另一张表 user_id 是 VARCHAR,JOIN 时 MySQL 要做隐式类型转换,索引大概率失效。
- 小表驱动大表。MySQL 优化器会尽量用小表作为驱动表,但如果你在 JOIN 条件上用了函数或表达式,优化器就没法准确估算。
- 关注执行计划里的 type 和 Extra。最优是
eq_ref或ref,如果出现ALL且是大表,就要考虑加索引。 - 不要 JOIN 太多张表。三张以上大表关联时,数据量会呈指数级膨胀,可以先在应用层拆查询,或者提前汇总。
JOIN 本身没有罪,关键是要让每一步都走索引。每次写 JOIN 之前,先看连接字段是否有索引,这比事后调优省事得多。
6. 子查询与进阶技巧:让查询更灵活
6.1 子查询的三种常见写法
子查询就是嵌套在查询里的查询,常见有三种位置,各有各的用途。
第一种在 WHERE 里:
SELECT name FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100);这种写法适合“先查出符合条件的集合,再用集合过滤外层”。第二种在 FROM 里:
SELECT department, AVG(amount) FROM ( SELECT user_id, department, amount FROM orders o JOIN users u ON o.user_id = u.id ) t GROUP BY department;FROM 子查询相当于先构建一张临时结果表,外层继续做统计。注意 MySQL 对派生表有严格要求:每个派生表都必须有别名,上面例子里的t就是干这个用的。第三种在 SELECT 后面作为标量子查询:
SELECT name, (SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count FROM users;这种写法适合给每一行补充一个聚合值,但要小心:如果子查询返回多行,MySQL 会直接报错,所以通常都会配合聚合函数或者直接限制返回一行。
6.2 EXISTS 与 IN 的选择
WHERE id IN (SELECT ...)和WHERE EXISTS (SELECT ...)并不总是可以互相替代,它们的执行思路不同。
IN 子查询通常会先把子查询结果集算出来,再和外层逐行比较。如果子查询结果集很小,比如只有几十个 id,IN 很高效。但子查询结果集很大时,内存和比较成本都会上升。EXISTS 则是相关子查询,对外层的每一行,都会去判断“是否存在满足条件的行”,只要找到一行就会停止。如果子表上有合适的索引,EXISTS 在外层表数据量大的时候往往表现更好。
实际项目中我一般这样选:子查询结果集小且稳定,用 IN;外层表数据量很大,并且能在子查询关联字段上走索引,用 EXISTS。当然,最靠谱的不是拍脑袋,而是用 EXPLAIN 看执行计划,数据说话。
MySQL 8.0 之后的优化器已经能把不少 IN 子查询改写为更优执行方式,所以也不用过度焦虑,掌握基本取舍原则就够用了。
6.3 UPDATE / DELETE 中的子查询实践
MySQL 查数据的能力强,更新和删除也经常要结合子查询完成。比如要把“没有下过任何订单的用户”标记为无效:
UPDATE users SET status = 'inactive' WHERE id NOT IN (SELECT user_id FROM orders);这个需求很常见,但 MySQL 有个著名的限制:不能在修改一张表的同时直接查询这张表。也就是说,如果你试图删除某张表里某个条件的记录,而子查询也来自同一张表,就会报错:
DELETE FROM t WHERE id IN (SELECT id FROM t WHERE ...); -- ERROR 1093: You can't specify target table 't' for update in FROM clause解决办法是给子查询套一层临时表:
DELETE FROM t WHERE id IN ( SELECT id FROM ( SELECT id FROM t WHERE ... ) tmp );这个“你要改表 t,先从表 t 里把目标 id 捞出来放临时表里,再去更新”的思路,我几乎每个月都会用上。遇到类似报错不要慌,套一层派生表就能绕过去,这也再次印证了 FROM 子查询的重要性。
7. 新手避坑指南:MySQL 基本查询的实战心得
7.1 慢查询的排查思路
基本查询写得再漂亮,跑得慢一样白搭。我遇到查询性能问题,第一步不是猜,而是看执行计划:
EXPLAIN SELECT ...;重点看几个关键列:
- type:至少达到
range,最好到ref或eq_ref,如果出现ALL,基本可以判定是全表扫描。 - key:实际用到的索引,如果为 NULL,说明没走索引。
- rows:预估扫描行数,这个数字越大越危险。
- Extra:出现
Using filesort、Using temporary,都要警惕。
排查出全表扫描的原因后,优先考虑两个方向:WHERE 条件里的字段有没有索引;查询语句有没有在字段上做函数、运算、隐式转换导致索引失效。加索引也不是越多越好,但针对高频查询建联合索引收益非常明显。比如这个查询:
SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;建议索引idx_status_created(status, created_at),能同时覆盖过滤和排序。
7.2 常见错误速查表
新手阶段遇到的报错,很多都有固定解法。我整理了一份速查表,遇到类似问题可以直接对号入座。
| 错误/表现 | 常见原因 | 解决办法 |
|---|---|---|
| Unknown column 'xxx' in 'where clause' | 字段名写错,或者表别名没写对 | 检查字段是否存在,多表查询显式加别名 |
| Error 1055: Expression #... not in GROUP BY | 开启了 ONLY_FULL_GROUP_BY,SELECT 了非分组字段 | 把多余字段移出 SELECT,或改用聚合函数 |
| Error 1093: You can't specify target table for update in FROM clause | 同一张表出现在 UPDATE/DELETE 和子查询中 | 子查询外层再套一层临时表 |
| WHERE name = NULL 查不到数据 | NULL 不能用等号比较 | 改成 IS NULL |
| 字符串字段和数字比较结果诡异 | 隐式类型转换导致索引失效 | 字符串加引号,字段类型保持一致 |
| ORDER BY 没生效 | 子查询内部排序被外层覆盖 | 排序放到最外层,或改用 JOIN / 聚合 |
| 分页到后面越来越慢 | offset 过大 | 用 ID 条件分页或延迟关联优化 |
这些错误不是背下来就行,最好自己在本地复现一遍。我当年就是故意把这些错误 SQL 写出来,逐个看报错信息,后来遇到类似问题一眼就能定位。
7.3 练习建议和扩展方向
基本查询想练扎实,光看文章不够,必须上手敲。我比较推荐三个练习素材:官方文档里的示例库 employees 和 sakila,数据量适中,关联关系齐全;自己随便建两张表,插入几千条测试数据,然后疯狂改写查询;去公司真实业务场景里做只读查询,但要注意权限和敏感数据保护。
练习时要有目的地“换着写”。同一个需求,先用 JOIN 写一版,再用子查询写一版,然后对比执行计划;同一张表,先统计总数,再分组,再带条件分组,再排序分页,把一个场景吃透,远比机械地刷一百道题有用。
如果这篇文章看到这里还觉得不过瘾,下一步值得研究的方向是:索引的工作原理、EXPLAIN 各列含义、慢查询日志分析、窗口函数(MySQL 8.0)。这些知识和基本查询是一脉相承的,查询语法是招式,索引和执行计划是内功,二者叠加才能真正告别“小白”。
最后分享一个我自己的习惯:写完任何一条查询,我都会顺手执行一次 EXPLAIN,哪怕数据量很小。这个动作坚持半年以后,你再写 SQL,就会本能地避开那些全表扫描、超大分页、隐形类型转换的写法。不是因为它多高级,而是因为养成习惯,才能让基础真正变成肌肉记忆。