一、慢查询问题定位与方法论
在生产环境中,慢查询是一个常见但十分影响性能的问题。缓存层(如Redis)层层缓存,最终还是有请求打到MySQL。如果数据库层面的查询效率低下,往往会导致连接池拥堵、CPU飙升,甚至引发雪崩效应。
1.1 开启慢查询日志
慢查询定位的第一步是开启日志记录。在MySQL中,可以通过以下命令开启慢查询日志:
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 设置阈值(秒),超过该值被视为慢查询
SET GLOBAL long_query_time = 1;
-- 指定慢查询日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 同时将未使用索引的查询也记录(排查索引缺失问题)
SET GLOBAL log_queries_not_using_indexes = 'ON';
在线分析日志时,建议使用pt-query-digest(Percona Toolkit),这是行业内最常用的慢查询分析工具,可以自动聚合相似查询并统计平均耗时、调用次数和列出最慢的查询:
# 分析慢查询日志文件
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
# 实时连接到MySQL进行流式分析
pt-query-digest --processinfo h=127.0.0.1,u=root,p=password
除此之外,还可以关注Performance Schema系统库,它会自动采集各种方面的监控数据,包括语句执行详情、内存分配、磁盘IO操作等。在MySQL 5.7+中,推荐使用SYS Schema提供的视图进行快速分析,如:statements_with_full_table_scans(列出全表扫描的SQL)、statements_with_runtimes_in_95th_percentile(95分位的慢查询)等。
1.2 实时监控与告警体系
除了事后分析,建议接入Prometheus + Grafana体系监控以下核心指标:
Slow_queries:每分钟慢查询数量
Threads_running:正在运行的线程数,超过CPU核数的2-4倍需警觉
Innodb_buffer_pool_reads:物理磁盘读次数,过高说明缓存命中率差
Handler_read_rnd_next:全表扫描的行读请求次数
二、B+树索引原理与存储结构
InnoDB引擎默认使用B+树作为索引数据结构。B+树的核心优势在于:
全部取值节点放在叶子层,平均查询无论目标在哪层都要走到叶子层,IO次数稳定
叶子节点之间通过链表连接,适合范围查询和顺序扫描
内子节点不存储数据,只存储索引值和指针,因此同一个内子节点能支持更多的分支,导致树更矮
对于一个内子大小为16KB的页,常见的bigint占8字节,int占4字节,指针占6字节,那么每个内子节点约可存16KB / (8B+6B) ≈ 1170个key。假设扇出N=1170,树高度h=3,所能存储的最大记录数为1170³ ≈ 16亿,这已经远超多数业务系统的数据量。
值得注意的是,映射索引的代价是在插入/删除/更新时需要维护索引,而维护索引需要花费内存和磁盘IO。因此过多的索引反而会影响写入性能,同时索引也会占用内存,影响buffer pool的利用效率。
2.1 聚集索引 vs 非聚集索引
InnoDB的主键索引即为聚集索引,叶子节点直接包含行数据。而非聚集索引(二级索引)的叶子节点仅包含索引列和主键值。二级索引查询时如果需要的列不在索引中,需要回表(通过主键值再去聚集索引中遍历获取完整行),这需要额外的一次IO。
三、最左前缀法则与联合索引设计
多字段索引设计里有一个重要原则叫:最左前缀法则。它是指索引的列顺序决定了索引的可用性。在经典的联合索引(a,b,c)中,它的含义相当于给予了(a)、(a,b)、(a,b,c) 三个索引的效果。
说明最左前缀法则的内在原理,得从B+树的存储结构说起。对于联合索引(a,b,c),数据是先按a排序,a相同的记录再按b排序,b相同的再按c排序。因此:
如果你的查询是
WHERE a=1,能用到(a,b,c)索引如果查询分别是
WHERE a=1 AND b=2,能用到(a,b)索引前缀如果查询是
WHERE a=1 AND b=2 AND c=3,能完整使用(a,b,c)索引但
WHERE b=2或WHERE b=2 AND c=3,则不能用到该索引(因为在同一a的前提下b和c有序,知道b而不知道a无法二分查找)
基于最左前缀法则,我们可以得出索引设计的三个实战原则:
最左前缀优先:将高选择性(散列性高)字段放在联合索引的最左侧,以获得最大的索引覆盖面
等值优先:联合索引中,等值过滤字段应放在范围过滤字段前面。如
WHERE a=1 AND b>2 AND c=3,最优索引应为(a,c,b)而非(a,b,c),因为c等值可以走索引而b的范围会中断后续列使用空间节省:尽量让较短字段参与联合索引,减少单索引大小
四、覆盖索引与索引下推(ICP)
覆盖索引(Covering Index)是指查询所需的所有字段能够在索引中直接获得,而无需回表(去主键根页再调一次IO来找出全行数据)。借助覆盖索引,可以显著提升查询性能。
示例
假设有user_table,且在(username, email, age)上建立了联合索引idx_user_age,则:
-- 此查询可覆盖索引,无需回表(Using index)
SELECT username, email FROM users WHERE age > 25;
-- 此查询不能覆盖,因为需要额外的address字段(需要回表)
SELECT address FROM users WHERE age > 25;
索引下推(Index Condition Pushdown,简称ICP)MySQL 5.6开始引入,是对非索引字段的进一步优化方式。
在无ICP的工作流程中,引擎层先根据索引读取满足索引条件的行到内存,再在服务层对其他WHERE条件进行过滤。启用ICP后,存储引擎在读取索引时会先将能判断的条件直接过滤,减少回表次数:
-- 假设有联合索引(zip, lastname, firstname)
SELECT * FROM people WHERE zip='95054' AND lastname LIKE '%etrunia%' AND address LIKE '%Main Street%';
-- 无ICP:根据zip找到所有zip='95054'的记录 → 回表读取完整行 → 服务层过滤lastname和address
-- 有ICP:根据zip找到记录 → 在引擎层先过滤lastname LIKE条件 → 只对满足的记录回表 → 服务层过滤address
可以通过 EXPLAIN 的 Extra 列中看到 Using index condition 表示ICP生效。
五、EXPLAIN执行计划深度解读
EXPLAIN是DBA和开发者手中的利器,读懂执行计划是索引优化的必备技能。核心字段详解如下:
5.1 type列(性能从优到劣)
system/const:主键或唯一索引等值查询,最多返回1行,最优
eq_ref:主键或唯一索引作为连接条件,对于前表的每一行,后表只匹配一行
ref:非唯一索引等值查询,可能匹配多行
range:索引范围扫描(>, <, IN, BETWEEN等),良好
index:全索引扫描(遍历整颗索引树),比全表扫描好一点因为数据有序且通常更少
ALL:全表扫描,最差的性能杀手,需要优化
5.2 key_len计算规则
key_len表示索引使用的字节数,可用于判断联合索引使用了哪些列:
INT占4字节,BIGINT占8字节
VARCHAR(N)字符集UTF8时占3×N字节,再加2字节存储长度
允许NULL的字段额外占1字节
例如联合索引(a INT, b VARCHAR(50) NOT NULL, c INT NULL),查询WHERE a=1 AND b='txt' AND c IS NOT NULL,则key_len = 4 + (50×3+2) + 4 + 1 = 161字节
5.3 Extra列常见值
Using index:覆盖索引,无需回表,性能最佳
Using index condition:ICP生效,在引擎层过滤数据
Using where:使用WHERE条件过滤
Using filesort:需要额外排序,尽量消除
Using temporary:使用了临时表,常见于GROUP BY/DISTINCT,尽量消除
六、索引失效场景与避坑指南
即使是精心设计的索引,以下场景也会导致索引失效:
6.1 对索引列使用函数或表达式
-- 错误:索引失效(对索引列使用函数)
SELECT * FROM users WHERE YEAR(created_at) = 2024;
-- 正确:改写为范围查询
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
6.2 隐式类型转换
-- phone字段是VARCHAR类型
-- 错误:传入数字导致隐式转换,索引失效
SELECT * FROM users WHERE phone = 13800138000;
-- 正确:传入字符串
SELECT * FROM users WHERE phone = '13800138000';
6.3 LIKE以通配符开头
-- 错误:前置通配符导致索引失效
SELECT * FROM articles WHERE title LIKE '%MySQL%';
-- 正确:后置通配符可以利用索引
SELECT * FROM articles WHERE title LIKE 'MySQL%';
6.4 违反最左前缀法则
-- 联合索引(a, b, c)
-- 错误:没有使用最左列a,索引失效
SELECT * FROM t WHERE b = 1 AND c = 2;
-- 正确:从最左列开始
SELECT * FROM t WHERE a = 1 AND c = 2;
6.5 OR条件使用不当
-- a有索引,b无索引,整体走全表扫描
SELECT * FROM t WHERE a = 1 OR b = 2;
-- 优化:改写为UNION ALL
SELECT * FROM t WHERE a = 1
UNION ALL
SELECT * FROM t WHERE b = 2;
6.6 范围条件中断后续列
-- 联合索引(a, b, c)
-- a走索引,b走范围扫描,c列不能走索引(因为b范围后c无序)
SELECT * FROM t WHERE a = 1 AND b > 2 AND c = 3;
-- 优化:调整联合索引顺序为(a, c, b),让等值字段c先于范围字段b
ALTER TABLE t ADD INDEX idx_a_c_b (a, c, b);
七、千万级数据表实战调优案例
案例:电商订单表查询优化
背景:一张5000万行的订单表,查询某用户最近一年已取消订单及其关联商品信息,原始查询耗时12秒。
优化过程:
EXPLAIN分析:发现type=ALL全表扫描,possible_keys有idx_user_id但key=NULL未使用。原因是WHERE子句中对user_id进行了隐式函数转换
改写SQL去除函数:改为直接等值比较,让idx_user_id索引生效,耗时从12秒降至800ms
创建覆盖索引:创建(user_id, status, created_at)联合索引,JOIN时使用product_id覆盖列消除回表,耗时从800ms降至50ms
深度分页优化:
LIMIT 1000000,20全表扫描100万行太慢,改为基于上一页最后ID的游标分页:WHERE id > last_seen_id ORDER BY id LIMIT 20,耗时从50ms降至5ms
最终应用层代码(Go示例):
// 游标分页查询订单
func ListOrdersByUser(ctx context.Context, userID int64, cursor int64, pageSize int) ([]Order, error) {
const query = SELECT order_id, product_id, status, amount, created_at
FROM orders
WHERE user_id = ? AND id > ?
ORDER BY id
LIMIT ?
rows, err := db.QueryContext(ctx, query, userID, cursor, pageSize)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.OrderID, &o.ProductID, &o.Status, &o.Amount, &o.CreatedAt); err != nil {
return nil, err
}
orders = append(orders, o)
}
return orders, nil
}
八、总结:索引优化的核心心法
索引优化没有银弹,但有方法论:
先看EXPLAIN,再改SQL:数据说话,不要凭感觉优化
覆盖索引是王牌:能用覆盖索引解决的,优先级最高
最左前缀放高选择性字段:让索引覆盖面最大化
函数转换是大忌:等值改范围,前置通配符改后置
监控驱动优化:接入慢查询日志和实时监控,早发现早治疗
索引不是越多越好:权衡读写比例,写多的表谨慎加索引
数据库索引优化是一个需要不断实践和总结的过程。建议读者在日常开发中养成查看执行计划的习惯,遇到慢查询时先分析根因,再针对性地优化,避免盲目加索引的陷阱。

发表评论 取消回复