MySQL查询优化实战:从Select *到慢查询排查与性能调优
2026/9/18 16:50:00 网站建设 项目流程

1. 先把Select * From这条“看家本领”整明白

1.1 一条最简单的查询背后发生了什么

做了这么多年MySQL,我越来越觉得,很多人对Select * From这条语句的理解停留在“它能查出数据”这个层面,至于它背后到底干了什么、什么时候该用它、什么时候不该用它,其实没太想清楚。这里我想先把它掰开揉碎讲一遍。

Select * From是SQL里最基础、最高频的查询语句,没有之一。它的执行过程大体可以分为几个阶段:客户端把SQL文本发送到服务端,经过词法分析和语法解析后,生成一棵解析树,再经过查询优化器生成执行计划,最后通过存储引擎接口去读取数据。Storage Engine(存储引擎)逐行扫描数据页,把命中的记录返回给Server层,Server层再做后续的投影、过滤,最后把结果集发送给客户端。

这个过程听起来简单,但里面藏着很多细节。比如Select *意味着“把这张表的所有列都查出来”,优化器在执行的时候,要先把表的元数据读出来,获得全部的列清单,然后逐个列去组装结果行。如果你只需要其中两列,这个“组装”动作就是白做的。更重要的是,如果表上有组合索引,Select *会导致优化器无法使用“覆盖索引”技术,因为索引里只包含了部分列,要取其余的列就必须回到聚簇索引里去“回表”捞数据,这一来一回就是一次磁盘随机I/O。

我用个生活化的类比解释一下覆盖索引和回表:你可以把聚簇索引理解成一本书的正文页,每一页上什么内容都有;二级索引则相当于书后面的“关键词索引页”,它只有关键词和页码。你想找某个词的解释,如果关键词索引页上已经把解释写全了,就不用翻到正文页,这就是覆盖索引;如果索引页上只有页码,你就得按页码去翻正文,那就是回表。Select *几乎强制你每次都“翻正文”,哪怕你只需要看一个词条。

1.2 生产环境中为什么我不推荐直接写Select *

先说结论:你在本地做调研、临时看数据、验证表结构,随便写Select *没问题,项目代码里最好别这么干。

第一条原因是网络传输。一个表如果有三十个字段,其中二十五个都是text类型的大字段,你只想取idname,结果Select *把几百KB甚至几MB的文本数据全捞出来丢到网络上,一次请求还好,并发一上来,带宽立刻被打满。跨机房调用、微服务之间的数据交互,这种浪费极其致命。

第二条原因是表结构耦合。你写SQL是给现在的表结构用的,可数据库表是会演进的。今天这个表加了两个字段,你代码里的Select *结果集结构就变了。如果你的代码是按列下标取值的,比如很多老项目里用resultSet.getString(3)这种方式拿数据,表结构一变就是线上事故。即使按列名取,额外多传的字段也会白白消耗内存和网络。

第三条原因是会让执行计划变差。这一点和上一条说的回表有关。如果查询条件能命中一个二级索引,而我们只需要索引中的列,优化器会选择直接扫描索引,又快又省。一旦改成Select *,优化器算一下回表的代价,可能宁可选择全表扫描也不用你的索引。我见过不少线上慢查询,排查到最后发现把Select *改成明确列名后,同一个SQL从800ms降到了50ms,原因就在这里。

所以我的习惯是:凡是会跑在业务链路里的SQL,一律写明确列名;只有在控制台里手工排查、开发环境里看数据的时候,才图省事用Select *

1.3 Select输出列可以玩出的花样

Select后面不只能跟*和列名,还可以跟表达式、函数、常量、别名,甚至嵌套子查询。掌握这些写法能省掉很多应用层代码。

-- 别名与计算列 SELECT user_id AS uid, user_name AS 姓名, YEAR(create_time) AS 注册年份, IF(status = 1, '正常', '冻结') AS 状态文案 FROM user_info WHERE create_time >= '2024-01-01';

这里有一个非常实用的小技巧:给字段设置默认值也可以用Select表达式来兜底。比如某个字段允许为NULL,但你查询时希望它为空时显示成0或“未知”。

SELECT user_id, IFNULL(score, 0) AS score, COALESCE(phone, '未绑定') AS phone FROM user_info;

IFNULLCOALESCE都能做空值兜底,区别在于IFNULL只接受两个参数,COALESCE可以接受多个参数,返回第一个非NULL的值。实际工作中我更喜欢COALESCE,因为它的语义更通用,而且从Oracle或PostgreSQL迁过来的SQL几乎不用改。

另外要提醒一句:Select里可以写子查询,也就是“标量子查询”,但性能上要小心。如果外层表有几万行,子查询就会执行几万次,这就是典型的“逐行触发”,数据库压力会非常大。能用Join解决的就别用标量子查询,这一点后面讲多表查询我还会再提。

2. Where条件过滤:查询语句里最考验功力的部分

2.1 条件组合与运算符优先级

光会Select还不行,绝大多数查询都要带Where条件,否则就是把整张表读出来。Where子句是SQL里最体现基本功的地方,这里埋着不少坑。

Where支持的关系运算符包括=><>=<=!=<>,逻辑运算符包括ANDORNOT。多条件组合的时候,AND的优先级高于OR,这一点经常有人忘,导致查询结果和预期不符。

-- 意图:查询“上海或北京”且“状态正常”的用户 -- 错误写法:OR没有加括号,和预期不一致 SELECT * FROM user_info WHERE region = '上海' OR region = '北京' AND status = 1; -- 正确写法 SELECT * FROM user_info WHERE (region = '上海' OR region = '北京') AND status = 1;

第一个SQL实际执行的时候会先算北京 AND 状态正常,再算上海 OR ...,结果把上海所有用户都查出来了,无论他们状态是否正常。这个坑我在代码评审里遇到过不止一次,每次都有人拍脑袋说“我明明写了and啊”。所以条件一多,我建议干脆用括号把逻辑分组写清楚,既能防止优先级搞错,也方便别人阅读。

还有一个容易踩坑的地方是NULL的判断。Where条件里写status = NULL是永远查不出来数据的,因为NULL和任何值比较结果都是UNKNOWN,不是TRUE。必须写成status IS NULL或者status IS NOT NULL。同理,WHERE 列名 != '某个值'也查不出该列为NULL的行,因为NULL != '某个值'结果是UNKNOWN,被过滤掉了。这是个非常经典的隐性Bug,我见过有人在统计“非某状态的记录数”时,把NULL行漏掉,导致数据对不上账。

2.2 模糊查询:LIKE的两种写法要分清

LIKE模糊查询是搜索场景的常客,但很多人不知道它有两种模式。一种是普通模式,%代表任意多个字符,_代表任意单个字符;另一种是转义模式,用ESCAPE关键字来定义转义符。

-- 查名字带“张”的用户 SELECT * FROM user_info WHERE user_name LIKE '%张%'; -- 查名字以“张”开头的用户 SELECT * FROM user_info WHERE user_name LIKE '张%'; -- 查名字第二个字是“三”的用户 SELECT * FROM user_info WHERE user_name LIKE '_三%'; -- 查询包含“%”字面量的数据,需要转义 SELECT * FROM log_table WHERE message LIKE '%50\%%' ESCAPE '\\';

模糊查询的性能问题要特别留意:LIKE '张%'这种前缀匹配,如果列上有索引,是可以走索引的;但LIKE '%张%'这种包含匹配,因为通配符在最前面,索引就失效了,只能全表扫描。数据量小的表无所谓,数据量上千万的时候,一次%关键字%查询能把数据库拖到CPU飙高。这种场景建议改用全文索引,或者引入Elasticsearch一类的搜索引擎来处理。

2.3 In、Exists与Not In的经典陷阱

IN是最常用的集合匹配语法,但有一个具体的报错非常热门:“in查询语句报错”。这个报错的常见原因有三个。

第一个是IN后面跟着一个超大的子查询或列表,比如IN (SELECT ... FROM 另一张大表),有些版本的MySQL会生成效率很差的执行计划,甚至直接报Subquery returns more than 1 row。注意,这个报错的触发场景是子查询返回了多行,但你把它放到了期望单值的地方。比如:

-- 错误:子查询返回多行,无法作为单值比较 SELECT * FROM A WHERE A.id = (SELECT aid FROM B WHERE b.status = 1);

这里如果B表有多行状态为1,就会直接报错。正确写法是把=改成IN

第二个常见原因是IN列表里的元素个数过多。MySQL对IN列表的长度没有硬性上限,但列表太长会带来两个问题:一是SQL文本过长,网络包可能要分片传输;二是优化器处理超大IN列表时,可能退化成低效的执行计划。我个人的经验是超过1000个值就要考虑改写,比如拆成多次查询、用临时表关联,或者改用JOIN

第三个是NOT IN遇到NULL的陷阱。假如子查询的结果里有NULLNOT IN会直接返回空结果,一条数据都查不出来。原因还是NULL参与比较时的UNKNOWN逻辑。处理办法是把NOT IN改写成NOT EXISTS,或者在子查询里显式过滤掉NULL

-- 稳妥写法:NOT EXISTS SELECT * FROM user_info u WHERE NOT EXISTS ( SELECT 1 FROM order_info o WHERE o.user_id = u.user_id AND o.pay_status = 1 );

这里强调一个很实用的替换思路:能用EXISTS的时候尽量别用IN,尤其是子查询表很大的情况。EXISTS是“存在即返回”,只要找到一条满足条件的记录就会短路停止;IN通常会把子查询结果全部物化出来,再做外层匹配。两者语义上可以替换,但性能差距在实际生产中非常明显。

3. 排序、去重与分页:结果集处理的三大高频动作

3.1 Order by排序:别忽略排序字段的索引

排序在SQL里是个“隐形消耗大户”。表面上看只是一句ORDER BY create_time DESC,实际上如果排序字段没有索引,MySQL需要把结果集先放到临时表中,再在内存或磁盘上做排序操作。数据量一大,filesort(文件排序)就会让查询性能急转直下。

排序优化的核心思路很简单:让排序字段尽量走索引。比如:

-- 如果查询条件是 status,排序是 create_time,建议建联合索引 CREATE INDEX idx_status_create_time ON user_info(status, create_time); -- 这样下面的查询可以直接从索引里取到排好序的数据 SELECT user_id, user_name, create_time FROM user_info WHERE status = 1 ORDER BY create_time DESC;

这里有一个索引排序的细节:索引的顺序是(status, create_time)WHERE status = 1定位到一个范围后,create_time天然就是有序的,排序操作直接省掉。反过来,如果索引建的是(create_time, status),那status = 1这个条件会让create_time的顺序被打破,还是要额外排序。

排序时还经常出现中文排序不符合预期的问题。MySQL默认的字符串排序规则是utf8mb4_general_ci,它对中文是按Unicode编码排序的,如果你期望按拼音排序,需要在ORDER BY里指定排序规则:

SELECT * FROM user_info ORDER BY user_name COLLATE utf8mb4_zh_0900_as_cs;

不过这类排序会极大地消耗性能,一般不推荐在数据库层面做,中文排序放到应用层处理更合适。

3.2 Distinct去重的边界条件

SELECT DISTINCT是去重的利器,但它有一个让人容易误判的地方:它是“整行去重”,不是“单列去重”。很多人想查“这张表里有多少个不同的城市”,于是写SELECT DISTINCT city FROM user_info,这个没问题。但如果写成SELECT DISTINCT city, age FROM user_info,它返回的是“城市和年龄组合不重复”的记录,而不是“城市不重复”。

如果想按某一列去重、同时保留其他字段信息,DISTINCT是做不到的,得用GROUP BY配合聚合函数,或者使用窗口函数。比如查每个城市最新的一个用户:

-- 窗口函数写法 SELECT user_id, user_name, city FROM ( SELECT user_id, user_name, city, ROW_NUMBER() OVER (PARTITION BY city ORDER BY create_time DESC) AS rn FROM user_info ) t WHERE t.rn = 1;

这个需求如果不用窗口函数,就得靠子查询加关联,SQL会绕很多。MySQL 8.0开始支持窗口函数,我强烈建议大家把这类写法用起来,它是处理“分组取前N条”这类问题的最简洁方案。

DISTINCTGROUP BY到底怎么选,也有讲究。在两列去重场景下,SELECT DISTINCT a, b FROM tSELECT a, b FROM t GROUP BY a, b结果集是一样的。区别在于GROUP BY可以和聚合函数配套使用,而DISTINCT不行。从执行计划来看,两者的底层处理方式大同小异,实际使用时主要看语义是否清晰。

3.3 Limit分页与深翻页的性能问题

分页查询是前端列表页必不可少的动作,通常写法是:

SELECT * FROM order_list ORDER BY id DESC LIMIT 0, 20;

这种写法在前几百页没问题,但一旦翻到很深的页码,比如LIMIT 100000, 20,MySQL仍然要把前100000条数据全部扫描出来,然后丢弃掉,只返回最后20条。这个过程既消耗I/O又消耗内存,越往后翻越慢。

深分页的优化方案主要有两种。第一种是“延迟关联”,先用覆盖索引查出目标页的主键ID,再关联回原表取完整数据:

SELECT o.* FROM order_list o INNER JOIN ( SELECT id FROM order_list ORDER BY id DESC LIMIT 100000, 20 ) t ON o.id = t.id;

第二种是基于游标的分页,也就是“键值分页”。不记录页码,而是记录上一页最后一条数据的ID,下一页查询时带上这个ID做条件:

SELECT * FROM order_list WHERE id < 100020 ORDER BY id DESC LIMIT 20;

这种方案没有深翻页的“扫描并丢弃”过程,每次查询都能直接定位到起始位置,性能极其稳定。我在实际项目中遇到列表页超过100万条数据的场景,就是靠这个方案解决的。它的代价是前端不能随意跳页,只能“上一页/下一页”地翻,但对大多数业务场景来说完全够用。

4. 聚合函数与分组统计:让查询从“取数”变成“分析”

4.1 常用聚合函数组合的实战写法

聚合函数是SQL从“查数据”进阶到“做统计”的分水岭。最常用的五个是COUNTSUMAVGMAXMIN。这里有几个细节容易被忽视。

COUNT(*)COUNT(列名)的行为不一样:COUNT(*)统计的是记录行数,包括NULL行;COUNT(列名)统计的是该列非NULL值的个数。比如统计“有手机号的用户数”,必须写COUNT(phone),写COUNT(*)就错了。

SUMAVG会忽略NULL值,但如果全是NULLSUM返回NULL而不是0,AVG也返回NULL。这时可以用IFNULLCOALESCE兜底。

聚合函数还有一个高频应用场景是“条件统计”,比如统计订单表中已支付金额和未支付金额。最简洁的写法是用SUM配合IFCASE WHEN

SELECT COUNT(*) AS total_order, SUM(IF(pay_status = 1, order_amount, 0)) AS paid_amount, SUM(IF(pay_status = 0, order_amount, 0)) AS unpaid_amount FROM order_list WHERE create_time >= '2025-01-01';

这种写法只用一次全表扫描就把多个维度的统计做完了,如果拆成三条SQL分三次查,性能完全没法比。

4.2 Group By与Having的关系

GROUP BY是分组统计的核心语法。逻辑上它是把同一个分组键的行归到一起,然后对每个组执行聚合函数。

HAVING是用来过滤分组结果的,它和WHERE最大的区别是:WHERE在分组之前过滤原始行,HAVING在分组之后过滤聚合结果。所以WHERE里不能用聚合函数,HAVING里可以使用。

-- 查出订单数超过5笔的用户 SELECT user_id, COUNT(*) AS order_cnt FROM order_list WHERE pay_status = 1 GROUP BY user_id HAVING COUNT(*) > 5;

这里有一个经典的优化细节:能在WHERE里过滤的条件,不要留到HAVING。因为WHERE提前过滤能减少分组的数据量,而HAVING是在分组全部算完之后才过滤,意味着大量无效数据也参与了聚合计算。

分组查询还有一个容易踩的坑是“SELECT的列必须出现在GROUP BY里或聚合函数中”。在MySQL的ONLY_FULL_GROUP_BY模式下,SELECT user_id, user_name FROM order_list GROUP BY user_id会直接报错。这是SQL标准的要求,防止出现“分组后的非确定性取值”。MySQL 5.7及以上版本默认开启这个模式,老项目如果是5.6版本迁过来的,常常会碰到这类报错。解法就是把user_name也加进GROUP BY,或者用MAX(user_name)等聚合函数包住。

4.3 分组后的拼接与统计技巧

分组场景里有一类需求看似简单,实际写起来很绕:把同一个用户的多条记录拼成一个字符串。MySQL提供了GROUP_CONCAT函数,一行搞定:

-- 把每个用户的订单号拼成一列 SELECT user_id, GROUP_CONCAT(order_no ORDER BY create_time DESC SEPARATOR '、') FROM order_list GROUP BY user_id;

GROUP_CONCAT默认拼接长度限制是1024字节,超过的部分会被截断。处理大量数据拼接时,需要先执行SET SESSION group_concat_max_len = 102400;调整上限,否则结果会莫名缺失。

分组查询还可以配合WITH ROLLUP做小计,但这个语法用得不多,因为它会额外产生一组NULL分组行,应用层处理起来比较别扭,我建议能用程序做的小计就程序做,别用这个扩展语法。

5. 多表联接:Join背后到底是怎样一种运算

5.1 Inner Join、Left Join、Right Join的语义边界

多表联查是SQL里最难啃但又绕不开的部分,也是面试中常考的重点。很多人对JOIN的理解停留在“把两张表拼起来”,至于拼完之后哪些行保留、哪些行丢弃,往往说不清楚。

我用最直白的话解释一下:INNER JOIN(内连接)返回的是两张表都能匹配上的记录;LEFT JOIN(左连接)以左表为基准,左表全部保留,右表没有匹配的行就用NULL填充;RIGHT JOIN正好反过来,以右表为基准。MySQL里我几乎没见过有人用RIGHT JOIN,因为把表顺序换一下,RIGHT JOIN就能改写成LEFT JOIN,可读性反而更好。

-- 查询每个用户以及他们的订单 -- 用户表全部保留,没有订单的用户订单字段为NULL SELECT u.user_id, u.user_name, o.order_no, o.order_amount FROM user_info u LEFT JOIN order_list o ON o.user_id = u.user_id;

这里要特别提醒一个关联条件里的坑:ON条件和WHERE条件的执行顺序不一样。ON里的条件决定“怎么匹配”,WHERE里的条件决定“匹配完成后保留哪些行”。对LEFT JOIN来说,如果WHERE里写了右表字段的过滤条件,左连接的效果就被破坏了,变成了INNER JOIN,因为不满足条件的右表行对应的左表行也被过滤掉了。

-- 这个查询会把没有订单的用户过滤掉,LEFT JOIN白写了 SELECT u.user_id, o.order_no FROM user_info u LEFT JOIN order_list o ON o.user_id = u.user_id WHERE o.order_amount > 100;

如果确实要保留“无订单”的用户同时只展示满足金额的订单,条件应该放在ON里:

SELECT u.user_id, o.order_no FROM user_info u LEFT JOIN order_list o ON o.user_id = u.user_id AND o.order_amount > 100;

这两个SQL看起来差不多,执行结果差之千里。这种“LEFT JOINWHERE悄悄变成INNER JOIN”的问题,在我做过的数据修复脚本里出现过好几次,每次都是查出来的记录数比预期少,回头一查才发现问题出在条件位置。

5.2 联表查询最常见的三个坑

第一个坑是关联字段类型不一致导致索引失效。比如A表的user_idVARCHAR(20),B表的user_idBIGINT,两张表做关联时,MySQL需要做隐式类型转换,这一转换就可能让索引失效,全表扫描警告亮起来。联表查询的性能优化,第一步永远是确认关联字段是否同类型、是否有索引。

第二个坑是重复数据导致的笛卡尔积膨胀。如果关联字段在右表中不是唯一的,一行左记录会匹配出多行右记录,结果集行数会成倍膨胀。比如user_info LEFT JOIN order_list,一个用户有10个订单,这个用户就会输出10行。如果在写SQL之前没想清楚关联字段的粒度,查出来的结果里出现大量重复行,就会下意识去加DISTINCT,但DISTINCT加错场景反而掩盖了逻辑问题。正确做法是先确认“这一列在关联表里是否唯一”。

第三个坑是关联了太多表导致性能断崖式下跌。N张表关联,优化器需要在庞大的连接顺序组合里找最优解。表一多,执行计划就可能变得非常离谱。我见过有同事一次JOIN了8张表,整条SQL跑了30多秒。排查后发现其中有两张表是可以通过合并冗余字段省掉的。联表数最好不要超过3张,超过这个阈值先审视能不能拆成多次查询或建宽表。

6. 与查询密切相关的实用语句:更新、子查询与存储过程

6.1 Update与子查询联用的正确姿势

查询语句的学习不能只停留在SELECT,因为实际生产环境里,你经常需要用查询的结果去更新数据。UPDATE配合子查询是最常见的组合。

-- 把“上海”用户的积分统一加100 UPDATE user_info SET score = score + 100 WHERE user_id IN ( SELECT user_id FROM user_region WHERE region = '上海' );

MySQL有一个限制:UPDATE的目标表不能同时出现在FROM子查询里,否则会报You can't specify target table for update in FROM clause。比如你想把“积分最高的用户”标记为VIP,会写:

-- MySQL直接报错 UPDATE user_info SET is_vip = 1 WHERE user_id = ( SELECT user_id FROM user_info ORDER BY score DESC LIMIT 1 );

解决办法是套一层派生表,让MySQL感知不到直接引用:

UPDATE user_info SET is_vip = 1 WHERE user_id = ( SELECT uid FROM ( SELECT user_id AS uid FROM user_info ORDER BY score DESC LIMIT 1 ) tmp );

这种“套娃”写法看起来丑,但它是MySQL在语法限制下的标准解法,工作中经常要写。

6.2 给字段设置默认值及常见的修改场景

热搜词里有一条非常具体:“sql select查询语句 给某一个字段设置默认值”。这也说明了实际开发中的高频需求。在MySQL里有三种“默认值”相关的操作,很多人把它们的适用场景搞混了。

第一种是建表时指定列的默认值:

CREATE TABLE test_user ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50), status TINYINT NOT NULL DEFAULT 0, score INT NOT NULL DEFAULT 0 );

第二种是修改已有表的列默认值:

ALTER TABLE test_user ALTER COLUMN score SET DEFAULT 100; ALTER TABLE test_user ALTER COLUMN score DROP DEFAULT;

第三种是在SELECT查询结果中给字段设置展示层的默认值,这个前面也提过,用IFNULLCOALESCE实现:

SELECT user_name, IFNULL(score, 0) FROM test_user;

有一点要注意:修改列默认值这个操作在MySQL 8.0里虽然加了INSTANT算法,但不同版本行为不完全一致。大表执行ALTER TABLE MODIFY COLUMN会触发表重建,耗时可能以小时计。在8.0里可以用ALTER TABLE ... ALTER COLUMN ... SET DEFAULT这种语法来绕过表重建,只改元数据,速度快得多。这算是我在实际运维里总结的一个小经验。

6.3 存储过程中声明变量的常见要点

存储过程和查询语句的关系也很密切:你要写存储过程,就绕不开SELECT ... INTODECLARECURSOR这些语法。热搜词里“mysql声明存储过程”、“mysql存储过程”、“mysql声明变量”都是典型的学习需求。

存储过程中的变量声明有几个容易踩的点。第一个是变量声明必须放在存储过程体的开头,不能夹在BEGIN ... END块的中间。第二个是变量名前最好加v_前缀做区分,避免和列名冲突。第三个是SELECT ... INTO只能给一个或多个变量赋值,它要求查询结果必须是一条记录,多一条报错,少一条则变量保持原值。

DELIMITER // CREATE PROCEDURE get_user_score(IN p_uid INT, OUT p_score INT) BEGIN DECLARE v_score INT DEFAULT 0; SELECT score INTO v_score FROM user_info WHERE user_id = p_uid; SET p_score = v_score; END // DELIMITER ;

调用方式:

CALL get_user_score(1001, @user_score); SELECT @user_score;

存储过程的痛点在于难以调试和维护。我在实际项目中只把存储过程用在两类场景:一是定时任务的ETL批处理,二是复杂报表的汇总计算。偏业务的查询逻辑我尽量放在应用层,因为应用层有版本控制、有单元测试、有代码审查,存储过程这些都没有。可一旦需要写存储过程,语法细节还是要扎实掌握,不然线上写错一个变量名,往往要重启整个存储过程排查半天。

7. 查询性能调优:从慢SQL日志到索引优化

7.1 先定位到慢SQL再谈优化

很多刚接触数据库优化的人一上来就问“怎么建索引”,但我的经验是,优化的第一步永远是先定位问题SQL。MySQL天生就带了一个慢查询日志工具,不借助任何第三方组件就能找到那些需要优化的查询。

MySQL 8.0里通过一组参数控制慢查询日志:

-- 查看当前慢日志配置 SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time'; -- 开启慢日志 SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;

long_query_time单位是秒,我一般设成1,超过1秒的SQL都记录下来。日志文件路径可以用SHOW VARIABLES LIKE 'slow_query_log_file'查看。线上数据库不能随便重启,但修改GLOBAL级别的变量是即时生效的。

更好的方式是打开log_queries_not_using_indexes,把“没走索引的查询”也记下来。这个开关开一天,你会看到很多平时根本不注意的全表扫描SQL,它们平时都在沉默地消耗数据库资源。

7.2 Explain执行计划怎么看重点

拿到一条慢SQL后,下一步就是用EXPLAIN去看执行计划。这是MySQL优化里最核心的技能。

EXPLAIN SELECT user_id, order_no, amount FROM order_list o INNER JOIN user_info u ON o.user_id = u.user_id WHERE o.create_time >= '2025-01-01' ORDER BY o.create_time DESC;

执行计划里要重点看的列我整理了一次:

列名重点关注内容
type访问类型,最好的是consteq_ref,其次是refrange,最差是ALL(全表扫描)
key实际使用的索引名,为NULL说明没走索引
rows预估扫描行数,这个数字越大越危险
Extra出现Using filesort说明要额外排序,出现Using temporary说明用了临时表,都要警惕

type这一列我从强到弱排个序:system>const>eq_ref>ref>range>index>ALL。生产中如果看到ALL,先核实数据量:表只有几千行,全表扫描无所谓;表有几千万行还全表扫描,那就是事故。

7.3 索引创建的原则与实际经验

索引是MySQL性能优化的核心,但“乱建索引”比“没有索引”更可怕。每个索引都会占用额外的磁盘空间,拖慢写入速度,优化器选错索引还会导致查询性能异常。我给出一套相对稳妥的原则:

  • 频繁出现在WHERE条件的列优先建索引;
  • 联合索引的字段顺序按“区分度从高到低”排列,区分度高的列放在前面;
  • 常用排序的列可以加入联合索引,避免filesort
  • 索引列不要参与函数计算,比如WHERE YEAR(create_time) = 2025会让索引失效,应改成WHERE create_time >= '2025-01-01' AND create_time < '2026-01-01'
  • 一个表的索引数量控制在5个以内。

关于联合索引,有一个“最左前缀原则”必须讲透:索引(a, b, c)可以支持WHERE a = ?WHERE a = ? AND b = ?WHERE a = ? AND b = ? AND c = ?这些查询,但不能支持WHERE b = ?WHERE c = ?,因为b和c不是从最左边开始的。很多人面试时背得出这句话,实际写SQL时还是会踩坑。比如索引建了(status, create_time),查询条件只写了WHERE create_time >= '2025-01-01',这个索引就用不上,必须把status条件也补上。

8. 日常操作中的高频报错与排查经验

8.1 按命中率整理的热门报错处理

这些年接触过大量MySQL报错,有些出现频率极高,每次都要花时间排查。我把最经典的几个整理成了一份速查表,这些内容也和热搜词高度吻合。

报错信息常见原因解决方案
Subquery returns more than 1 row子查询返回多行,但外层用了=比较改用INEXISTS
You can't specify target table for update in FROM clauseUPDATE的子查询中引用了目标表套一层派生表
Unknown column in 'where clause'列名拼写错误或表别名写错检查字段名和表别名
Incorrect integer value: 'abc' for column 'status'插入或查询时字符串和整数类型不匹配检查参数类型,别让应用层传错
Packet for query is too large (xxxx > 4194304)单次SQL文本超过max_allowed_packet限制调大max_allowed_packet参数
Can't connect to MySQL server (10061)服务未启动或端口未开放检查服务状态、防火墙、端口号

这里重点说一个和端口相关的热门话题:“mysql端口号”。MySQL默认端口是3306,很多人遇到连不上的问题,第一反应是密码错了,但排查后发现是服务端口没开放。云服务器上部署MySQL时,安全组规则里要放行3306端口,同时检查服务器本机防火墙。MySQL配置文件中修改端口的参数是port=3307,改完记得重启服务。

另一个高频问题是“mysql的初始密码是什么”。MySQL 5.7及之前版本,用mysqld --initialize-insecure安装时,本地root用户初始密码为空,直接回车就能登录。MySQL 8.0用mysqld --initialize安装时,会生成一个临时随机密码,记录在错误日志里,通常在数据目录下的hostname.err文件中,搜索temporary password关键字能找到。

8.2 不同数据库方言的识别与适配

热搜词里有一条“postgresql sqllite mysql”,说明不少人在同时接触不同的数据库。确实,后端开发中经常需要分辨SQL方言,尤其是SELECT相关语法在不同数据库里差异不小。

举个例子:热搜词里有一条“select top 1000 * from [dbo].[dc_ods_rkyxjc_lotdatacollection]”,这种SELECT TOP[方括号]的写法是SQL Server的方言,MySQL不但不支持TOP,也不会用方括号包裹表名,MySQL里表名用反引号。另一个词条“event filter with query 'select * from __instancemodificationevent within 60'”是Windows WMI的查询语法,也不是标准SQL。碰到这类问题,先确认目标数据库类型再写具体语法,这是最基本的排查思路。

分页语法的差异也值得写一笔:

数据库分页写法
MySQLLIMIT 0, 20LIMIT 20 OFFSET 0
PostgreSQLLIMIT 20 OFFSET 0
SQL ServerOFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY
OracleFETCH FIRST 20 ROWS ONLY

我在多数据库兼容项目里的经验是:不要在SQL里写数据库特有的方言,能用标准SQL就用标准SQL。比如分页先查出来再在应用层截断,虽然会浪费一点网络传输,但换来的可维护性非常高。

8.3 常用图形化工具的效率小技巧

“mysql workbench使用教程”和“navicat连接mysql”这两条热搜说明很多人还是习惯用图形化客户端操作MySQL。图形化工具确实能提升日常开发和排查的效率,但使用上有些小技巧。

用Workbench连接MySQL时,最常见的问题是连接不上8.0版本的数据库,因为8.0默认的认证插件改成了caching_sha2_password,老版本的Workbench或Navicat可能不支持。解决办法有两个:一是升级客户端到支持该插件的最新版本;二是在创建用户时显式指定旧的认证插件:

CREATE USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';

在图形化工具里执行SQL时,有几点建议:大表查询一定要加LIMIT,防止工具把几十万行全部拉下来导致界面卡死;执行UPDATEDELETE之前,先开启事务,确认影响行数后再提交;SELECT耗时长的语句,先EXPLAIN看执行计划再跑。

另外一个很多人没注意到的点是:Workbench里点击表的“编辑行数据”时,它默认执行的是SELECT * FROM table_name LIMIT 1000,这1000行的限制可以在Edit -> Preferences -> SQL Editor里调整,但我不建议调太大,编辑模式下拉太多数据容易误操作,数据修复类的操作尽量用SQL语句来执行。

9. 关于MySQL安装与版本选型的一点经验补充

热搜词里“mysql安装教程”、“mysql下载官网”、“mysql安装配置教程”出现了很多次,说明新手阶段最大的障碍就是“第一次把环境跑起来”。我在这里把安装流程里最容易被卡住的地方集中讲一遍。

首先明确版本选型。MySQL官网下载页提供多个版本,企业版需要授权,社区版完全免费。两个大版本的选择问题:5.7是经典稳定版,兼容性好,生态成熟;8.0是当前推荐版本,性能更好,支持窗口函数和公共表表达式,但有很多细节和5.7不一样。新项目我建议直接用8.0,老项目升级要谨慎,特别是检查驱动版本和SQL语法兼容性。

安装过程里最容易出问题的是Linux环境下的初始化步骤。mysqld --initialize执行完会生成临时密码,而mysqld --initialize-insecure会创建空密码的root用户。很多教程里只写了前者,结果新手拿着临时密码登录时,因为包含特殊字符又不好复制,反而折腾半天。我自己的做法是开发环境用--initialize-insecure,登录后马上设置密码:

mysql -u root ALTER USER 'root'@'localhost' IDENTIFIED BY '新密码'; FLUSH PRIVILEGES;

另一个高频问题是配置文件路径。MySQL的配置文件名是my.cnf(Linux)或my.ini(Windows),配置文件的加载顺序可以用以下命令查看:

mysql --help | grep 'my.cnf'

修改配置文件后重启服务:Linux用systemctl restart mysqld,Windows在服务管理器里重启MySQL服务即可。改端口、改max_allowed_packet、设字符集这类需求都走配置文件,不建议每次都改全局变量,因为重启后全局变量会恢复默认值。

安装还会遇到“mysql的初始密码是什么”这个问题,这部分之前已经讲过,8.0版本初始化后的随机密码在错误日志中,日志文件名是主机名.err,可以用grep 'temporary password' 主机名.err快速定位。

我个人在实际操作中还有一个体会:无论是初学还是老手,手边常备一个MySQL官方文档和一个本地的测试库非常有帮助。SQL语句的执行计划和索引优化,很多知识点光看书是记不牢的,只有在真实的千万级数据表上跑一次EXPLAIN,亲眼看到typeALL变成ref,把一条慢查询优化到几百毫秒,才会真正理解索引为什么能快、什么时候会失效。这份对Select * From以及一系列查询语句的理解,就是在无数次“写SQL、看执行计划、改索引”的循环里一点一点积累起来的。

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

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

立即咨询