一、慢查询问题定位与方法论

在生产环境中,慢查询是一个常见但十分影响性能的问题。缓存层(如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=2WHERE b=2 AND c=3,则不能用到该索引(因为在同一a的前提下b和c有序,知道b而不知道a无法二分查找)

基于最左前缀法则,我们可以得出索引设计的三个实战原则:

  1. 最左前缀优先:将高选择性(散列性高)字段放在联合索引的最左侧,以获得最大的索引覆盖面

  2. 等值优先:联合索引中,等值过滤字段应放在范围过滤字段前面。如WHERE a=1 AND b>2 AND c=3,最优索引应为(a,c,b)而非(a,b,c),因为c等值可以走索引而b的范围会中断后续列使用

  3. 空间节省:尽量让较短字段参与联合索引,减少单索引大小


四、覆盖索引与索引下推(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

可以通过 EXPLAINExtra 列中看到 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秒。

优化过程:

  1. EXPLAIN分析:发现type=ALL全表扫描,possible_keys有idx_user_id但key=NULL未使用。原因是WHERE子句中对user_id进行了隐式函数转换

  2. 改写SQL去除函数:改为直接等值比较,让idx_user_id索引生效,耗时从12秒降至800ms

  3. 创建覆盖索引:创建(user_id, status, created_at)联合索引,JOIN时使用product_id覆盖列消除回表,耗时从800ms降至50ms

  4. 深度分页优化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
}

八、总结:索引优化的核心心法

索引优化没有银弹,但有方法论:

  1. 先看EXPLAIN,再改SQL:数据说话,不要凭感觉优化

  2. 覆盖索引是王牌:能用覆盖索引解决的,优先级最高

  3. 最左前缀放高选择性字段:让索引覆盖面最大化

  4. 函数转换是大忌:等值改范围,前置通配符改后置

  5. 监控驱动优化:接入慢查询日志和实时监控,早发现早治疗

  6. 索引不是越多越好:权衡读写比例,写多的表谨慎加索引

数据库索引优化是一个需要不断实践和总结的过程。建议读者在日常开发中养成查看执行计划的习惯,遇到慢查询时先分析根因,再针对性地优化,避免盲目加索引的陷阱。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部