MySQL慢查询调优指南

描述

背景与目的

MySQL慢查询是数据库性能问题的最常见原因。当一条SQL语句执行超过1秒时,就可能影响用户体验;超过10秒时,通常会收到用户投诉;而超过30秒的查询,往往意味着系统存在严重的性能问题。本文从实战角度出发,系统讲解慢查询的发现、分析、定位和优化方法,帮助DBA和运维工程师建立完整的慢查询优化知识体系。

前置知识:本文假设你具备基本的SQL知识,了解MySQL/MariaDB的基本操作,有过实际数据库维护经验。

环境说明:本文基于MySQL 8.0.36(社区版),MariaDB 10.11.x,使用InnoDB存储引擎。命令示例兼容Percona Server 8.0。

1. 慢查询日志配置与开启

1.1 慢查询日志基础

慢查询日志记录执行时间超过指定阈值的SQL语句,是优化工作的起点。

 

# 检查慢查询日志是否开启
mysql -e "SHOW VARIABLES LIKE 'slow_query_log%';"
mysql -e "SHOW VARIABLES LIKE 'long_query_time%';"
mysql -e "SHOW VARIABLES LIKE 'log_output%';"

# 输出示例:
# +---------------------+-------------------------------+
# | Variable_name       | Value                         |
# +---------------------+-------------------------------+
# | slow_query_log      | OFF                          |
# | slow_query_log_file | /var/lib/mysql/mysql-slow.log |
# +---------------------+-------------------------------+
# | Variable_name       | Value                         |
# +---------------------+-------------------------------+
# | long_query_time     | 10.000000                    |
# +---------------------+-------------------------------+

 

1.2 临时开启慢查询日志

 

-- 临时开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log';
SET GLOBAL long_query_time = 1;  -- 超过1秒记录
SET GLOBAL log_queries_not_using_indexes = 'ON';  -- 记录未使用索引的查询

 

1.3 永久配置(my.cnf)

 

[mysqld]
# 慢查询日志开关
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/mysql-slow.log
long_query_time = 1

# 记录未使用索引的查询(生产环境谨慎开启,可能产生大量日志)
log_queries_not_using_indexes = OFF

# 记录管理语句
log_slow_admin_statements = ON

# 记录慢查询到表(mysql.slow_log)
# log_output = 'TABLE'
# 需要时改为FILE或TABLE

# 最小锁定时间(只记录超过此时间的锁定)
# min_examined_row_limit = 1000

 

1.4 配置脚本

 

#!/bin/bash
# script: enable_slow_query_log.sh
# 用途:启用并配置MySQL慢查询日志

SLOW_LOG_FILE="/var/lib/mysql/mysql-slow.log"
LONG_QUERY_TIME=1

echo "=== 启用MySQL慢查询日志 ==="

# 检查MySQL是否运行
if ! systemctl is-active mysql &>/dev/null; then
    echo "MySQL未运行"
    exit 1
fi

# 创建慢查询日志文件
touch "$SLOW_LOG_FILE"
chown mysql:mysql "$SLOW_LOG_FILE"

# 临时启用
mysql <

 

1.5 pt-query-digest工具

Percona Toolkit中的pt-query-digest是分析慢查询日志的神器。

 

# 安装
dnf install percona-toolkit -y

# 分析慢查询日志
pt-query-digest /var/lib/mysql/mysql-slow.log

# 输出前10个最慢的查询
pt-query-digest --limit 20 /var/lib/mysql/mysql-slow.log

# 分析特定时间段(需要日志包含时间戳)
pt-query-digest --since='2026-04-03 0000' --until='2026-04-03 1200' /var/lib/mysql/mysql-slow.log

# 分析特定数据库
pt-query-digest --filter '$event->{db} && $event->{db} eq "mydb"' /var/lib/mysql/mysql-slow.log

# 将分析结果保存到文件
pt-query-digest /var/lib/mysql/mysql-slow.log > /tmp/query_analysis.txt

# 实时分析当前查询(从processlist)
pt-query-digest --processlist h=localhost --interval 1 --run-time 60

 

2. explain执行计划解读

2.1 explain基础用法

 

-- 基础explain
EXPLAIN SELECT * FROM users WHERE id = 1;

-- 更详细的输出
EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;
-- 注意:EXPLAIN ANALYZE只在MySQL 8.0.18+支持

-- 查看JSON格式(更完整)
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1;

 

2.2 explain输出字段详解

 

EXPLAIN SELECT u.name, o.order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active' AND o.create_time > '2026-01-01';

 

输出字段说明

 

+----+-------------+-------+------------+-------+---------------+--------+---------+------+------+----------+-------------+
| id | select_type| table | partitions | type  | possible_keys | key    | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+--------+---------+------+------+----------+-------------+

 

字段 说明
id 查询中SELECT的序列号
select_type SELECT类型(SIMPLE/PRIMARY/SUBQUERY等)
table 涉及的表
partitions 涉及的分区
type 连接类型(性能关键)
possible_keys 可能使用的索引
key 实际使用的索引
key_len 索引长度
ref 与索引比较的列
rows 预计扫描的行数
filtered 过滤后剩余的百分比
Extra 附加信息

2.3 type字段详解(性能从优到差)

 

-- system:表只有一行(系统表)
EXPLAIN SELECT * FROM mysql.time_zone_name;

-- const:最多匹配一行(主键或唯一索引)
EXPLAIN SELECT * FROM users WHERE id = 1;

-- eq_ref:JOIN时使用主键或唯一索引
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;

-- ref:使用非唯一索引
EXPLAIN SELECT * FROM orders WHERE status = 'pending';

-- ref_or_null:类似ref,但包含NULL查询
EXPLAIN SELECT * FROM orders WHERE user_id = 1 OR user_id IS NULL;

-- range:使用索引范围查询
EXPLAIN SELECT * FROM orders WHERE create_time BETWEEN '2026-01-01' AND '2026-01-31';

-- index:全索引扫描
EXPLAIN SELECT COUNT(*) FROM orders;

-- ALL:全表扫描(最差)
EXPLAIN SELECT * FROM orders WHERE order_name LIKE '%test%';

 

2.4 Extra字段常见值

 

-- Using index:覆盖索引,无需回表
EXPLAIN SELECT user_id, name FROM users WHERE name = 'John';

-- Using where:使用WHERE过滤
EXPLAIN SELECT * FROM users WHERE status = 'active';

-- Using temporary:使用临时表(性能差)
EXPLAIN SELECT name, COUNT(*) FROM orders GROUP BY name;

-- Using filesort:使用文件排序(性能差)
EXPLAIN SELECT * FROM users ORDER BY create_time DESC;

-- Using index condition:索引条件下推
EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND order_name LIKE 'A%';

-- Using MRR:使用多范围读优化
EXPLAIN SELECT * FROM orders WHERE user_id IN (1, 2, 3);

 

2.5 慢查询分析脚本

 

#!/bin/bash
# script: analyze_slow_queries.sh
# 用途:从慢查询日志提取并分析SQL

SLOW_LOG="/var/lib/mysql/mysql-slow.log"
REPORT_FILE="/tmp/slow_query_report_$(date +%Y%m%d).txt"

echo "=== MySQL慢查询分析报告 ===" > "$REPORT_FILE"
echo "生成时间:$(date)" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"

# 使用pt-query-digest分析
if command -v pt-query-digest &> /dev/null; then
    echo "【1】最慢的10个查询:" >> "$REPORT_FILE"
    pt-query-digest --limit 10 "$SLOW_LOG" >> "$REPORT_FILE" 2>&1

    echo "【2】查询次数最多的SQL:" >> "$REPORT_FILE"
    pt-query-digest --order-by Query_time:sum --limit 10 "$SLOW_LOG" >> "$REPORT_FILE" 2>&1

    echo "【3】未使用索引的查询:" >> "$REPORT_FILE"
    pt-query-digest --filter '$event->{巡查索引} =~ /No index/' "$SLOW_LOG" >> "$REPORT_FILE" 2>&1
else
    echo "pt-query-digest未安装,使用mysqldumpslow" >> "$REPORT_FILE"

    # 备用方案:使用mysqldumpslow
    echo "【1】最慢的10个查询:" >> "$REPORT_FILE"
    mysqldumpslow -t 10 "$SLOW_LOG" >> "$REPORT_FILE" 2>&1
fi

cat "$REPORT_FILE"

 

3. 索引数据结构:B+树原理

3.1 为什么MySQL选择B+树

现代关系数据库索引几乎都使用B+树作为数据结构,原因如下:

磁盘友好

B+树每个节点通常等于一个磁盘页(16KB)

树高通常为3-4层(16KB * 3层 = 数十GB索引)

查询只需3-4次磁盘IO

范围查询高效

叶子节点用双向链表连接

范围查询只需定位起点,顺序扫描即可

与B树的区别

B树所有节点都存储数据

B+树只有叶子节点存储数据,内部节点只存储键

B+树内部节点更小,树高更低

3.2 B+树可视化

 

                    [50 | 100 | 200]
                     /     |      
          [<50]   [50-99] [100-199] [>=200]
            |        |        |         |
         数据1     数据2     数据3      数据4
            |        |        |         |
         数据5     数据6     数据7      数据8

        实际B+树结构:

                    [50        | 100       | 200       ]
                     /          |            
          [页1]          [页2]               [页3]
           |               |                    |
    +--+--+--+--+     +--+--+--+--+         +--+--+--+--+
    |  |  |  |  |     |  |  |  |  |         |  |  |  |  |
   数据 数据 数据 数据  数据 数据 数据 数据    数据 数据 数据 数据
    (磁盘页)        (磁盘页)                (磁盘页)

 

3.3 主键索引与普通索引

 

-- users表
CREATE TABLE users (
    id INT PRIMARY KEY,           -- 主键索引(聚集索引)
    name VARCHAR(100),
    email VARCHAR(100),
    age INT,
    status CHAR(1),
    INDEX idx_email (email),      -- 普通索引(非聚集)
    INDEX idx_age_status (age, status)  -- 联合索引
);

-- 主键索引结构(B+树,叶子节点存储完整行数据)
-- id=1 -> [完整行数据]
-- id=2 -> [完整行数据]
-- ...

-- 普通索引结构(B+树,叶子节点存储主键值)
-- email='a@test.com' -> [id=1]
-- email='b@test.com' -> [id=2]
-- ...
-- 查询时需要回表:根据email查到id,再根据id查完整数据

 

4. 索引类型详解

4.1 主键索引

 

-- 创建表时指定主键
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    total_amount DECIMAL(10,2),
    create_time DATETIME
);

-- 修改表添加主键
ALTER TABLE orders ADD PRIMARY KEY (order_id);

-- 复合主键
CREATE TABLE order_items (
    order_id BIGINT,
    item_id BIGINT,
    quantity INT,
    PRIMARY KEY (order_id, item_id)  -- 复合主键
);

 

特点

每个表只能有一个主键

主键值唯一且非空

InnoDB会自动使用主键作为聚集索引

建议使用自增BIGINT作为主键(性能最优)

4.2 唯一索引

 

-- 创建唯一索引
CREATE UNIQUE INDEX idx_email ON users(email);

-- 或在表定义中
CREATE TABLE users (
    id INT PRIMARY KEY,
    email VARCHAR(100) UNIQUE,  -- 自动创建唯一索引
    phone VARCHAR(20),
    UNIQUE INDEX idx_phone (phone)
);

-- 添加唯一索引
ALTER TABLE users ADD UNIQUE INDEX idx_email (email);

 

与主键的区别

主键不允许NULL,唯一索引允许NULL(但只能有一个NULL)

一个表只有一个主键,可以有多个唯一索引

主键自动创建聚集索引,唯一索引是普通索引

4.3 普通索引

 

-- 单列索引
CREATE INDEX idx_name ON users(name);

-- 查看表的所有索引
SHOW INDEX FROM users;

-- 创建索引的完整语法
CREATE INDEX idx_status ON orders(status)
    USING BTREE
    COMMENT '订单状态索引';

 

4.4 前缀索引

适用于VARCHAR或TEXT类型的前N个字符创建索引。

 

-- 取前10个字符
CREATE INDEX idx_email_prefix ON users(email(10));

-- 取前20个字符
CREATE INDEX idx_address_prefix ON users(address(20));

-- 注意事项:
-- 1. 前缀长度选择要足够长,避免过多冲突
-- 2. 前缀索引只支持 =、<、>、LIKE 'xxx%' 查询
-- 3. 不能用于 ORDER BY 和 GROUP BY

 

前缀长度选择参考

 

-- 计算前缀选择性(区分度)
SELECT
    COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) as prefix_5,
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) as prefix_10,
    COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) as prefix_15,
    COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) as prefix_20,
    COUNT(DISTINCT email) / COUNT(*) as full
FROM users;

 

4.5 联合索引

多个列组合成一个索引,最左前缀原则是核心。

 

-- 创建联合索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);

-- 联合索引的B+树结构
-- 按照 (user_id, status, create_time) 顺序构建
-- 排序优先级:user_id > status > create_time

 

联合索引使用条件

 

-- 可以使用索引(全匹配)
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid';
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid' AND create_time > '2026-01-01';

-- 无法使用索引(跳过user_id)
SELECT * FROM orders WHERE status = 'paid';
SELECT * FROM orders WHERE status = 'paid' AND create_time > '2026-01-01';
SELECT * FROM orders WHERE create_time > '2026-01-01';

-- 可以使用索引(范围查询后的列无法使用)
SELECT * FROM orders WHERE user_id = 1 AND status > 'paid';  -- status之后的列无法使用

-- LIKE前缀匹配可以使用
SELECT * FROM orders WHERE user_id = 1 AND create_time LIKE '2026-01%';

 

5. 索引失效的典型场景

5.1 函数和运算导致索引失效

 

-- 失效:函数运算
SELECT * FROM orders WHERE YEAR(create_time) = 2026;
SELECT * FROM orders WHERE DATE_FORMAT(create_time, '%Y') = '2026';
SELECT * FROM orders WHERE create_time + INTERVAL 1 DAY > NOW();

-- 正确做法:保持索引列独立
SELECT * FROM orders WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01';

-- 失效:算术运算
SELECT * FROM users WHERE age + 1 = 30;

-- 正确做法
SELECT * FROM users WHERE age = 29;

 

5.2 类型转换导致索引失效

 

-- 失效:字符串列用数字查询(MySQL会隐式转换)
CREATE TABLE test (phone VARCHAR(20));
SELECT * FROM test WHERE phone = 13800138000;  -- 数字自动转字符串,但无法使用索引

-- 正确做法
SELECT * FROM test WHERE phone = '13800138000';

-- 失效:数字列用字符串查询
CREATE TABLE test2 (id INT);
SELECT * FROM test2 WHERE id = '1';  -- 字符串转数字,可以走索引
-- 但反过来:
SELECT * FROM test2 WHERE id = '1abc';  -- 数字转字符串,无法使用索引

 

5.3 LIKE通配符导致索引失效

 

-- 失效:前导通配符
SELECT * FROM orders WHERE order_name LIKE '%test%';
SELECT * FROM orders WHERE order_name LIKE '%test';

-- 生效:后置通配符
SELECT * FROM orders WHERE order_name LIKE 'test%';

-- 优化方案:使用全文索引
ALTER TABLE orders ADD FULLTEXT INDEX ft_order_name (order_name);
SELECT * FROM orders WHERE MATCH(order_name) AGAINST('test');

-- 优化方案:使用Elasticsearch

 

5.4 OR条件导致索引失效

 

-- 失效:OR条件两边都未使用索引
SELECT * FROM users WHERE name = 'John' OR email = 'john@test.com';

-- 生效:确保OR两边都有索引(MySQL 8.0+)
SELECT * FROM users WHERE name = 'John'
UNION ALL
SELECT * FROM users WHERE email = 'john@test.com' AND name <> 'John';

-- 使用IN替代OR(如果有索引)
SELECT * FROM users WHERE name IN ('John', 'Mary', 'Tom');

 

5.5 NOT操作符导致索引失效

 

-- 失效:NOT IN / NOT EXISTS
SELECT * FROM orders WHERE status NOT IN ('paid', 'shipped');
SELECT * FROM orders WHERE status != 'paid';
SELECT * FROM orders WHERE NOT EXISTS (SELECT 1 FROM users WHERE users.id = orders.user_id);

-- 正确做法:尽量使用IN或正向条件
SELECT * FROM orders WHERE status IN ('pending', 'cancelled');

-- 某些情况下可以使用覆盖索引优化
SELECT * FROM orders WHERE id NOT IN (SELECT order_id FROM cancelled_orders);

 

5.6 索引失效检查脚本

 

#!/bin/bash
# script: check_index_usage.sh
# 用途:检查索引使用情况,找出未使用的索引

mysql -e "
-- 检查未使用的索引(需要 PERFORMANCE_SCHEMA 开启)
SELECT
    OBJECT_SCHEMA AS '数据库',
    OBJECT_NAME AS '表名',
    INDEX_NAME AS '索引名',
    SEQ_IN_INDEX AS '索引顺序',
    COLUMN_NAME AS '列名'
FROM information_schema.STATISTICS
WHERE OBJECT_SCHEMA = 'your_database'
ORDER BY OBJECT_NAME, INDEX_NAME, SEQ_IN_INDEX;
"

# 或者使用 pt-index-usage 工具分析慢查询日志
# pt-index-usage /var/lib/mysql/mysql-slow.log --user=root --password=xxx

echo "检查慢查询中的索引使用情况"
echo "建议使用 EXPLAIN 分析可疑查询"

 

6. 深入理解count(*)优化

6.1 count(*) vs count(1) vs count(col)

 

-- 没有任何性能差异(MySQL优化器会统一处理)
SELECT COUNT(*) FROM orders;
SELECT COUNT(1) FROM orders;
SELECT COUNT(primary_key) FROM orders;  -- 主键非NULL,始终有值

-- 有差异的情况
SELECT COUNT(col) FROM orders;  -- col列可能为NULL,需要检查每行

-- 测试验证
EXPLAIN SELECT COUNT(*) FROM orders;  -- type: index, rows: 预估行数
EXPLAIN SELECT COUNT(1) FROM orders;  -- 相同执行计划
EXPLAIN SELECT COUNT(id) FROM orders;  -- 相同执行计划

 

6.2 count(*) 在不同引擎的实现

InnoDB引擎

count(*) 需要全表扫描或索引扫描

使用主键索引扫描最快(因为主键索引B+树叶子节点包含完整数据)

如果有WHERE条件,需要过滤后计数

 

-- 最快的count(*)(使用主键索引)
SELECT COUNT(*) FROM orders;  -- 全表计数

-- 最快的count(*),带条件
SELECT COUNT(*) FROM orders WHERE status = 'paid';  -- 需要扫描status索引

 

6.3 count(*) 优化技巧

 

-- 场景:需要同时统计多个条件的数量
-- 低效:多次全表扫描
SELECT COUNT(*) FROM orders WHERE status = 'paid';
SELECT COUNT(*) FROM orders WHERE status = 'pending';
SELECT COUNT(*) FROM orders WHERE status = 'cancelled';

-- 高效:一次扫描,多个统计
SELECT
    SUM(status = 'paid') AS paid_count,
    SUM(status = 'pending') AS pending_count,
    SUM(status = 'cancelled') AS cancelled_count,
    COUNT(*) AS total_count
FROM orders;

-- 或者使用 GROUP BY
SELECT status, COUNT(*) as cnt FROM orders GROUP BY status;

 

6.4 大表count(*)优化

 

-- 创建计数器表(适合实时性要求不高的场景)
CREATE TABLE order_stats (
    stat_date DATE PRIMARY KEY,
    total_orders BIGINT DEFAULT 0,
    paid_orders BIGINT DEFAULT 0,
    pending_orders BIGINT DEFAULT 0
);

-- 定时更新统计(使用事件调度器)
DELIMITER $$
CREATE EVENT e_update_order_stats
ON SCHEDULE EVERY 1 HOUR
DO
BEGIN
    INSERT INTO order_stats (stat_date, total_orders, paid_orders, pending_orders)
    SELECT
        CURRENT_DATE,
        COUNT(*),
        SUM(status = 'paid'),
        SUM(status = 'pending')
    FROM orders
    ON DUPLICATE KEY UPDATE
        total_orders = VALUES(total_orders),
        paid_orders = VALUES(paid_orders),
        pending_orders = VALUES(pending_orders);
END$$
DELIMITER ;

-- 使用近似值(准实时场景)
SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'orders';
-- 注意:TABLE_ROWS是估算值,可能有50%误差

 

7. 分页优化:深度分页问题

7.1 深度分页问题原理

 

-- 问题查询:偏移量越大,越慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;

-- 执行过程:
-- 1. 读取前1000020行
-- 2. 丢弃前1000000行
-- 3. 返回20行
-- 偏移量100万,需要扫描100万+20行

 

7.2 优化方案1:使用主键ID游标

 

-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 20;

-- 得到 last_id = 1000

-- 第二页:使用上一页的最大ID
SELECT * FROM orders
WHERE id > 1000
ORDER BY id
LIMIT 20;

-- 进阶:支持任意跳转
-- 假设用户想跳到第50000页,每页20条
-- 最后一页的id需要从数据库获取,或使用其他定位方式

 

7.3 优化方案2:延迟关联

 

-- 原始查询(慢)
SELECT * FROM orders
WHERE status = 'paid'
ORDER BY create_time DESC
LIMIT 100000, 20;

-- 优化:先查ID,再关联获取完整数据
SELECT o.*
FROM orders o
INNER JOIN (
    SELECT id FROM orders
    WHERE status = 'paid'
    ORDER BY create_time DESC
    LIMIT 100000, 20
) t ON o.id = t.id;

 

7.4 优化方案3:范围查询

 

-- 如果有连续的自增ID,可以利用范围查询
-- 用户在第500页,看到的最后一条ID是 10000

-- 查询第501页
SELECT * FROM orders
WHERE id > 10000
ORDER BY id
LIMIT 20;

-- 结合条件
SELECT * FROM orders
WHERE id > 10000 AND status = 'paid'
ORDER BY id
LIMIT 20;

 

7.5 优化方案4:记录总数缓存

 

-- 不显示精确总数,只显示"上一页/下一页"
-- 适合Feed流等场景

-- 获取每页数据
SELECT * FROM orders
WHERE id < 10000
ORDER BY id DESC
LIMIT 20;

-- 检查是否有更多
SELECT COUNT(*) FROM orders WHERE id < 10000;
-- 如果 > 20,说明还有下一页

 

7.6 分页优化脚本

 

#!/bin/bash
# script: test_pagination_performance.sh
# 用途:测试不同分页方式的性能

mysql -e "
-- 测试不同偏移量的查询时间
SET profiling = 1;

-- 浅分页
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 10000, 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 50000, 20;
SELECT SQL_NO_CACHE * FROM orders ORDER BY id LIMIT 100000, 20;

SHOW PROFILES;
"

 

8. 慢查询案例分析

8.1 案例1:订单统计查询

问题SQL

 

-- 原始慢查询
SELECT
    DATE(create_time) AS order_date,
    COUNT(*) AS order_count,
    SUM(total_amount) AS total_amount,
    user_name
FROM orders
WHERE create_time >= '2026-01-01'
GROUP BY DATE(create_time), user_name
ORDER BY order_date DESC;

 

问题分析

GROUP BY 和 ORDER BY 字段不一致

user_name 未被索引覆盖

缺少合适的索引

优化后

 

-- 创建索引
ALTER TABLE orders ADD INDEX idx_create_time (create_time);

-- 优化SQL
SELECT
    DATE(create_time) AS order_date,
    COUNT(*) AS order_count,
    SUM(total_amount) AS total_amount
FROM orders
WHERE create_time >= '2026-01-01'
GROUP BY DATE(create_time)
ORDER BY order_date DESC;

 

8.2 案例2:用户行为分析

问题SQL

 

-- 原始查询(20秒)
SELECT
    u.id,
    u.name,
    COUNT(DISTINCT o.id) AS order_count,
    COUNT(DISTINCT e.id) AS event_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN events e ON u.id = e.user_id
WHERE u.register_time >= '2026-01-01'
GROUP BY u.id, u.name;

 

优化方案

 

-- 创建索引
ALTER TABLE users ADD INDEX idx_register_time (register_time);
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
ALTER TABLE events ADD INDEX idx_user_id (user_id);

-- 改写SQL:分解为多个简单查询
SELECT
    u.id,
    u.name,
    COALESCE(o.order_count, 0) AS order_count,
    COALESCE(e.event_count, 0) AS event_count
FROM users u
LEFT JOIN (
    SELECT user_id, COUNT(*) AS order_count
    FROM orders
    GROUP BY user_id
) o ON u.id = o.user_id
LEFT JOIN (
    SELECT user_id, COUNT(*) AS event_count
    FROM events
    GROUP BY user_id
) e ON u.id = e.user_id
WHERE u.register_time >= '2026-01-01';

 

8.3 案例3:分页导出

问题SQL

 

-- 导出100万条数据,每次20条,需要50000次
SELECT * FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 1000000, 20;

 

优化方案

 

-- 方案1:使用主键范围
-- 第一次查询
SELECT id FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 20;
-- 得到最大ID:last_id = 1000000

-- 后续查询
SELECT * FROM orders
WHERE status = 'completed' AND id < 1000000
ORDER BY create_time DESC
LIMIT 20;

-- 方案2:使用临时表分批处理
CREATE TEMPORARY TABLE temp_export_ids (
    id BIGINT PRIMARY KEY
);

-- 分批插入ID
INSERT INTO temp_export_ids
SELECT id FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 100000;

-- 分批导出
SELECT o.* FROM orders o
INNER JOIN temp_export_ids t ON o.id = t.id
ORDER BY o.create_time DESC;

 

8.4 慢查询分析报告脚本

 

#!/bin/bash
# script: slow_query_report.sh
# 用途:生成慢查询分析报告

DB_NAME="orders_db"
REPORT_FILE="/tmp/mysql_slow_report_$(date +%Y%m%d).txt"

echo "=== MySQL慢查询分析报告 ===" > "$REPORT_FILE"
echo "数据库:${DB_NAME}" >> "$REPORT_FILE"
echo "生成时间:$(date)" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"

# 1. 慢查询统计
echo "【1】慢查询统计(最近24小时):" >> "$REPORT_FILE"
mysql -D "$DB_NAME" -e "
SELECT
    COUNT(*) AS total_slow_queries,
    AVG(query_time) AS avg_query_time,
    MAX(query_time) AS max_query_time,
    COUNT(DISTINCT db) AS affected_databases
FROM mysql.slow_log
WHERE start_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR);
" >> "$REPORT_FILE" 2>&1

echo "" >> "$REPORT_FILE"

# 2. 最慢的10个查询
echo "【2】最慢的10个查询:" >> "$REPORT_FILE"
mysql -D "$DB_NAME" -e "
SELECT
    query_time,
    rows_sent,
    rows_examined,
    LEFT(query_sql, 100) AS sql_preview,
    last_seen
FROM (
        SELECT
            query_time,
            rows_sent,
            rows_examined,
            query_sql,
            MAX(start_time) AS last_seen
        FROM mysql.slow_log
        WHERE start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
        GROUP BY query_sql
        ORDER BY query_time DESC
        LIMIT 10
    ) t;
" >> "$REPORT_FILE" 2>&1

echo "" >> "$REPORT_FILE"

# 3. 未使用索引的查询
echo "【3】未使用索引的查询统计:" >> "$REPORT_FILE"
mysql -D "$DB_NAME" -e "
SELECT
    LEFT(query_sql, 100) AS sql_preview,
    COUNT(*) AS exec_count,
    AVG(query_time) AS avg_time
FROM mysql.slow_log
WHERE start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
  AND query_sql LIKE '%Using%filesort%'
GROUP BY LEFT(query_sql, 100)
ORDER BY exec_count DESC
LIMIT 10;
" >> "$REPORT_FILE" 2>&1

cat "$REPORT_FILE"

 

9. SQL改写技巧

9.1 IN改写为EXISTS/JOIN

 

-- 低效:IN子查询
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

-- MySQL 5.6+会自动优化为JOIN,但显式写法更清晰
SELECT DISTINCT u.*
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.amount > 1000;

-- EXISTS改写
SELECT * FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.id AND o.amount > 1000
);

 

9.2 OR改写为UNION

 

-- 低效:OR条件
SELECT * FROM products
WHERE category = 'electronics' OR brand = 'Apple';

-- 高效:UNION
SELECT * FROM products WHERE category = 'electronics'
UNION
SELECT * FROM products WHERE brand = 'Apple' AND category != 'electronics';

-- 或者使用UNION ALL(如果确认不重复)
SELECT * FROM products WHERE category = 'electronics'
UNION ALL
SELECT * FROM products WHERE brand = 'Apple' AND category != 'electronics';

 

9.3 LIKE改写

 

-- 低效:前导通配符
SELECT * FROM products WHERE name LIKE '%iphone%';

-- 优化方案1:全文索引
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
SELECT * FROM products WHERE MATCH(name) AGAINST('+iphone' IN BOOLEAN MODE);

-- 优化方案2:Elasticsearch
-- 将数据同步到ES,使用ES搜索

-- 优化方案3:使用虚拟列+索引
ALTER TABLE products ADD COLUMN name_first_char CHAR(1) GENERATED AS (LEFT(name, 1)) STORED;
CREATE INDEX idx_name_first_char ON products(name_first_char);
SELECT * FROM products WHERE name_first_char = 'i' AND name LIKE 'i%iphone%';

 

9.4 COUNT(DISTINCT)优化

 

-- 低效:多个COUNT DISTINCT
SELECT
    COUNT(DISTINCT user_id) AS user_count,
    COUNT(DISTINCT product_id) AS product_count,
    COUNT(DISTINCT category_id) AS category_count
FROM orders;

-- 高效:使用子查询
SELECT
    (SELECT COUNT(DISTINCT user_id) FROM orders) AS user_count,
    (SELECT COUNT(DISTINCT product_id) FROM orders) AS product_count,
    (SELECT COUNT(DISTINCT category_id) FROM orders) AS category_count;

-- 或者使用SQL_CALC_FOUND_ROWS(已废弃)
SELECT SQL_CALC_FOUND_ROWS * FROM orders LIMIT 20;
SELECT FOUND_ROWS();

 

10. 表结构设计优化

10.1 选择合适的数据类型

 

-- 使用最小数据类型
TINYINT vs INT vs BIGINT
-- TINYINT: -128 to 127 (无符号0-255)
-- INT: -2B to 2B (无符号0-4B)
-- BIGINT: -9 Quintillion to 9 Quintillion

-- 使用DECIMAL而非FLOAT/DOUBLE(金融场景)
DECIMAL(10,2) vs FLOAT vs DOUBLE
-- DECIMAL精确存储,适合金额
-- FLOAT/DOUBLE近似值,有精度丢失风险

-- VARCHAR vs CHAR
CHAR(10)     -- 固定长度,不足右补空格,适合定长如手机号
VARCHAR(255) -- 可变长度,适合大多数字符串

-- 日期类型选择
DATE      -- '2026-01-01' 精确到天
DATETIME  -- '2026-01-01 1200' 精确到秒
TIMESTAMP -- 4字节,时间戳,适合记录创建/更新时间

 

10.2 范式化与反范式化

 

-- 第三范式(3NF)设计
-- 订单表:只存user_id,不存用户详细信息
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    user_id INT NOT NULL,
    total_amount DECIMAL(10,2),
    create_time DATETIME,
    INDEX idx_user_id (user_id)
);

-- 用户表:用户详细信息
CREATE TABLE users (
    user_id INT PRIMARY KEY,
    user_name VARCHAR(100),
    email VARCHAR(200),
    phone VARCHAR(20)
);

-- 如果需要关联查询,使用JOIN
SELECT o.*, u.user_name
FROM orders o
JOIN users u ON o.user_id = u.user_id;

-- 反范式化:冗余数据提升查询性能
-- 适合读多写少、实时性要求不高的场景
CREATE TABLE orders_denormalized (
    order_id BIGINT PRIMARY KEY,
    user_id INT NOT NULL,
    user_name VARCHAR(100),  -- 冗余存储,避免JOIN
    total_amount DECIMAL(10,2),
    create_time DATETIME
);

 

10.3 分区表

 

-- 按日期分区
CREATE TABLE orders (
    order_id BIGINT,
    user_id INT,
    total_amount DECIMAL(10,2),
    create_time DATETIME,
    PRIMARY KEY (order_id, create_time)  -- 必须包含分区键
) PARTITION BY RANGE (YEAR(create_time)) (
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p2026 VALUES LESS THAN (2027),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

-- 查询特定分区的数据(只扫描目标分区)
SELECT * FROM orders WHERE create_time >= '2026-01-01' AND create_time < '2026-02-01';

-- 查看分区信息
SELECT * FROM information_schema.PARTITIONS WHERE TABLE_NAME = 'orders';

 

10.4 分库分表

 

-- 按用户ID哈希分表(逻辑上)
-- 实际实现依赖中间件如 ShardingSphere、MyCAT

-- 示例:订单表拆分为4张表
orders_0, orders_1, orders_2, orders_3

-- 分片规则:user_id % 4
-- 查询时需要指定分片键
SELECT * FROM orders_1 WHERE user_id = 123;

-- 全局表(数据量小,所有分片都冗余存储)
CREATE TABLE categories (
    id INT PRIMARY KEY,
    name VARCHAR(100)
);  -- 复制到所有分片

 

11. 总结:索引使用避坑指南

11.1 索引设计原则

 

【核心原则】
1. 为WHERE、ORDER BY、GROUP BY的列创建索引
2. 索引列尽量选择区分度高的列
3. 联合索引遵循最左前缀原则
4. 避免在索引列上使用函数或运算
5. 控制索引数量(每个索引占用磁盘空间)

【创建索引的场景】
- 主键自动有索引,无需额外创建
- 外键列创建索引(提升JOIN性能)
- 经常作为查询条件的列
- 经常需要排序的列
- 区分度高的列(cardinality高)

【避免创建索引的场景】
- 区分度低的列(如性别、状态)
- 更新频繁的列
- 表数据量很小

 

11.2 慢查询优化Checklist

 

【第一步:发现】
□ 确认慢查询日志已开启
□ 找到最慢的查询
□ 记录查询时间和影响行数

【第二步:分析】
□ 使用EXPLAIN分析执行计划
□ 检查type列(避免ALL/index)
□ 检查Extra列(避免Using filesort/temporary)
□ 确认索引是否被使用

【第三步:优化】
□ 添加/修改索引
□ 改写SQL语句
□ 优化表结构
□ 减少查询范围

【第四步:验证】
□ 重新执行查询,对比时间
□ 再次使用EXPLAIN确认
□ 监控慢查询日志确认改善

 

11.3 常用优化命令速查

操作 SQL
查看慢查询配置 SHOW VARIABLES LIKE 'slow_query%';
开启慢查询日志 SET GLOBAL slow_query_log = ON;
设置阈值 SET GLOBAL long_query_time = 1;
分析执行计划 EXPLAIN SELECT ...
查看索引 SHOW INDEX FROM table_name;
创建索引 CREATE INDEX idx_name ON table(col);
删除索引 DROP INDEX idx_name ON table;
查看表状态 SHOW TABLE STATUS LIKE 'table_name';

11.4 EXPLAIN结果速判

 

【type - 从优到差】
const > eq_ref > ref > range > index > ALL

【Extra - 需要优化的标志】
Using filesort        需要优化
Using temporary       需要优化
Using where           可能需要优化
Using index           覆盖索引,好
Using index condition  ICP,可接受

 

参考信息

版本信息

MySQL:8.0.36(社区版)

Percona Server:8.0.36

MariaDB:10.11.x

存储引擎:InnoDB(默认)

操作系统:Rocky Linux 9.4

工具推荐

Percona Toolkit:pt-query-digest、pt-index-usage

MySQL Workbench:图形化EXPLAIN

performance_schema:查询性能分析

sys schema:性能诊断视图

参考文档

MySQL 8.0 Reference Manual: Optimization

High Performance MySQL, 3rd Edition

MySQL Internals Manual

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分