一个简单SQL的深度解析

0 阅读5分钟

一个简单SQL的深度解析

从3秒到100ms,我都做了什么


一、噩梦开始:一条"简单"的慢查询

先看这条SQL:

SELECT o.order_id, o.amount, u.name, u.level
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.created_at BETWEEN '2026-01-01' AND '2026-03-31'
ORDER BY o.amount DESC
LIMIT 100;

我的第一反应:这么普通的查询,怎么可能慢?

但EXPLAIN的结果让我后背发凉——它扫描了整整200万行数据。


二、你可能也踩过的坑

类似的困惑你遇到过吗?

  • 明明加了索引,为什么type还是ALL

  • 加了索引之后,Using filesort为什么还在?

  • 用了索引的查询,反而比不用索引还要慢?

    我花了2个小时才彻底搞明白。而当我弄懂之后发现:80%的慢查询,都可以用同一个方法快速定位。

    这个方法就是——读懂EXPLAIN的执行计划。


三、第一次诊断:EXPLAIN暴露了真凶

执行EXPLAIN后:

idselect_typetabletypekeyrowsExtra
1SIMPLEoALLNULL823417Using filesort
1SIMPLEuALLPRIMARY150000Using where

用大白话翻译三个致命问题:

type=ALL → 全表扫描

orders表被从头读到尾,82万行,一行都没放过。

Extra=Using filesort → 额外排序

ORDER BY amount没用到索引,MySQL不得不在内存或磁盘里额外排序。数据量越大,代价越高。

③ 连表走了嵌套循环

对于orders的每一行,都要去users表里查一次。82万次随机IO


四、第一次尝试:加索引,却失败了

我首先给created_at加了索引:

ALTER TABLE orders ADD INDEX idx_created_at (created_at);

再次EXPLAIN,type还是ALL

为什么加了索引没生效?

核心原因:返回数据比例超过20%时,MySQL会认为全表扫描更高效。

返回比例MySQL的决策
< 5%使用索引 ✅
5%-20%临界区,看数据分布
> 20%全表扫描 ❌

我查询的是一个季度的数据,占总数据量约25%,超过了阈值。

验证:强制使用索引

SELECT ... FORCE INDEX (idx_created_at) ...

结果:强制用索引反而更慢了。

对比项使用索引全表扫描
扫描行数235,270798,554
I/O类型随机I/O(23.5万次)顺序I/O(1次大块读)
额外开销索引页+数据页读取仅数据页读取
缓存效率

结论:随机I/O比顺序I/O慢得多。235k次随机访问,不如一次扫完80万行。


五、第二次尝试:覆盖索引,效果显著

既然回表是瓶颈,那就用覆盖索引——把需要的字段都放进索引,避免回表。

ALTER TABLE orders ADD INDEX idx_covering (created_at, amount, user_id);

执行计划变化:

关键字含义效果
Using index覆盖索引生效无需回表 ✅
Using whereServer层过滤WHERE条件部分在Server层处理

耗时:3秒 → 100ms


六、追问:为什么Using filesort还在?

即使有了覆盖索引,Extra里仍然有Using filesort

原因1:覆盖度不够

查询SELECT包含了users表的字段(u.name, u.level),这些不在orders的任何索引里,必须回表。

原因2:范围查询 + 排序的代价评估

WHERE条件是范围查询(BETWEEN),此时amountcreated_at范围内不是全局有序的,MySQL仍然需要额外排序。

如何真正消除Using filesort

需要满足:WHERE条件是等值查询,而不是范围查询。

现实就是:只要ORDER BY的列不是WHERE范围查询的那一列,filesort就躲不掉。


七、方法论:慢查询优化的通用公式

慢查询 → EXPLAIN看执行计划 → 关注三个指标 → 调整索引/SQL → 验证效果

三个核心指标

指标关注什么
type是否为ALL/index?目标是range/ref/eq_ref
rows预估扫描行数,越少越好
Extra是否出现Using filesort/Using temporary

八、专栏预告

接下来我会一步步拆解:

  • EXPLAIN每一列的真正含义(不是背概念,而是结合实际案例)
  • 索引设计:联合索引列顺序怎么排,范围查询为什么"断掉"后面的索引
  • 排序、分组、分页的优化套路
  • 真实慢查询日志分析(pt-query-digest实战)
  • 死锁分析:从日志到复现再到解决

九、互动收尾

你在工作中遇到过最离谱的慢查询是什么?最后怎么解决的?

欢迎在评论区分享,我会选出3位送出 《慢查询排查命令速查表》 (PDF)。

如果这篇文章对你有帮助,点个在看,让更多被慢查询困扰的朋友看到。


测试数据脚本

orders表(约80万行):

CREATE TABLE orders (
    order_id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT,
    amount DECIMAL(10,2),
    created_at DATETIME,
    status TINYINT
);
​
DELIMITER $$
CREATE PROCEDURE insert_orders()
BEGIN
    DECLARE i INT DEFAULT 1;
    WHILE i <= 800000 DO
        INSERT INTO orders (user_id, amount, created_at, status)
        VALUES (
            FLOOR(RAND() * 100000),
            ROUND(RAND() * 10000, 2),
            DATE_ADD('2026-01-01', INTERVAL FLOOR(RAND() * 365) DAY),
            FLOOR(RAND() * 4)
        );
        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;
​
CALL insert_orders();

users表(约10万行):

CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100) NOT NULL,
    level ENUM('bronze','silver','gold','platinum') DEFAULT 'bronze',
    register_date DATE,
    last_login DATETIME,
    status TINYINT DEFAULT 1
);
​
-- 插入10万条数据(存储过程略,详见正文)

插入 users 测试数据

sql

-- 创建存储过程,插入 10 万条用户数据
DELIMITER $$
CREATE PROCEDURE insert_users()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE v_name VARCHAR(50);
    DECLARE v_email VARCHAR(100);
    DECLARE v_level ENUM('bronze', 'silver', 'gold', 'platinum');
    DECLARE v_register_date DATE;
    DECLARE v_last_login DATETIME;
    DECLARE v_status TINYINT;
    
    WHILE i <= 100000 DO
        -- 生成随机姓名
        SET v_name = CONCAT('user_', i);
        
        -- 生成随机邮箱
        SET v_email = CONCAT('user', i, '@test.com');
        
        -- 随机等级(分布:bronze 50%, silver 30%, gold 15%, platinum 5%)
        SET v_level = CASE 
            WHEN RAND() < 0.5 THEN 'bronze'
            WHEN RAND() < 0.8 THEN 'silver'
            WHEN RAND() < 0.95 THEN 'gold'
            ELSE 'platinum'
        END;
        
        -- 随机注册日期(过去3年内)
        SET v_register_date = DATE_SUB(CURDATE(), INTERVAL FLOOR(RAND() * 1095) DAY);
        
        -- 随机最后登录时间(过去30天内)
        SET v_last_login = DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 30) DAY);
        
        -- 随机状态(90%正常,10%禁用)
        SET v_status = IF(RAND() < 0.9, 1, 0);
        
        INSERT INTO users (name, email, level, register_date, last_login, status)
        VALUES (v_name, v_email, v_level, v_register_date, v_last_login, v_status);
        
        SET i = i + 1;
    END WHILE;
END$$
DELIMITER ;
​
-- 执行插入
CALL insert_users();
​
-- 查看插入结果
SELECT COUNT(*) FROM users;
​
​
-- 添加索引(用于后续优化测试)
ALTER TABLE users ADD INDEX idx_level (level);
ALTER TABLE users ADD INDEX idx_register_date (register_date);