CC 咖啡猫的工作空间 Coding Space

索引优化实践

数据库性能问题 80% 以上源于索引使用不当。本文从实战角度出发,讲解索引优化的常见场景、判断方法与解决方案。


一、慢查询排查流程

1.1 开启慢查询日志

-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询日志(临时)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 超过 1 秒记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

-- 永久配置
-- my.cnf:
-- [mysqld]
-- slow_query_log = 1
-- slow_query_log_file = /var/log/mysql/slow.log
-- long_query_time = 1

1.2 使用 EXPLAIN 分析

EXPLAIN SELECT * FROM orders
WHERE status = 'pending'
  AND created_at > '2024-01-01'
ORDER BY id DESC
LIMIT 100;

关键字段解读

字段 含义 优化目标
type 访问类型 最好是 ref/range,避免 ALL
key 实际使用索引 应有值,避免 NULL
rows 预计扫描行数 越小越好
Extra 附加信息 避免 Using filesortUsing temporary

type 从好到差

const > eq_ref > ref > range > index > ALL
type 说明 场景
const 主键/唯一索引,等值查询 WHERE id = 1
eq_ref 唯一索引关联 JOIN
ref 非唯一索引等值查询 WHERE name = 'zhang'
range 索引范围查询 WHERE id > 100
index 索引全扫描 SELECT id FROM orders
ALL 全表扫描 ❌ 需优化

1.3 PROFILING 查看耗时

SET profiling = 1;

-- 执行查询
SELECT * FROM orders WHERE status = 'pending';

-- 查看耗时
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;

二、索引失效场景与解决

2.1 函数/运算导致索引失效

-- ❌ 函数:索引列参与计算
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
SELECT * FROM orders WHERE LEFT(customer_name, 3) = 'zhang';

-- ✅ 改写:使用函数索引或应用层处理
SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

-- ❌ 运算:字段参与运算
SELECT * FROM orders WHERE price * 0.8 > 100;

-- ✅ 改写:反向计算或冗余字段
SELECT * FROM orders WHERE price > 100 / 0.8;

2.2 类型转换导致索引失效

-- ❌ 隐式类型转换:字符串列用数字查询
phone VARCHAR(20)
SELECT * FROM users WHERE phone = 13800138000;  -- 数字转字符串

-- ✅ 使用字符串字面量
SELECT * FROM users WHERE phone = '13800138000';

-- ❌ 字符集不同导致索引失效
-- 表用 utf8mb4,查询条件用 utf8

2.3 模糊搜索导致索引失效

-- ❌ 左前缀不确定:%开头
SELECT * FROM users WHERE name LIKE '%zhang%';

-- ✅ 右前缀确定:可以用索引
SELECT * FROM users WHERE name LIKE 'zhang%';

-- ✅ 解决方案:全文索引 / ES
-- 全文索引
ALTER TABLE users ADD FULLTEXT INDEX ft_name(name);

-- 使用全文索引
SELECT * FROM users WHERE MATCH(name) AGAINST('zhang san');

2.4 范围查询后索引失效

-- 假设有联合索引 idx_status_created(status, created_at)

-- ✅ 范围前:两个字段都能用索引
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01';

-- ❌ 范围后:created_at 之后列失效
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01' AND customer_id = 100;
-- customer_id 无法使用索引

-- ✅ 解决:调整列顺序,将范围列放最后
-- 或者拆分为多个查询

2.5 OR 导致索引失效

-- ❌ OR 连接,类型不一致
SELECT * FROM orders WHERE status = 'pending' OR price > 100;

-- ✅ 改写:拆分为 UNION
SELECT * FROM orders WHERE status = 'pending'
UNION ALL
SELECT * FROM orders WHERE status != 'pending' AND price > 100;

-- ✅ 或者:为 OR 两边都建索引
ALTER TABLE orders ADD INDEX idx_status(status);
ALTER TABLE orders ADD INDEX idx_price(price);

三、联合索引设计

3.1 联合索引列顺序原则

原则:区分度高的列放前面,等值查询列放范围列前面

-- 场景:查询条件经常是 status = 'pending' AND created_at > '2024-01-01'

-- ❌ 错误顺序:范围列放前面
ALTER TABLE orders ADD INDEX idx_created_status(created_at, status);

-- ✅ 正确顺序:等值列放前面
ALTER TABLE orders ADD INDEX idx_status_created(status, created_at);

3.2 覆盖索引避免回表

-- 假设有联合索引 idx_name_age(name, age)

-- ✅ 覆盖索引:查询列都在索引中,无需回表
SELECT name, age FROM users WHERE name = 'zhang';

-- ❌ 非覆盖索引:需要回表
SELECT * FROM users WHERE name = 'zhang';
SELECT name, email FROM users WHERE name = 'zhang';  -- email 不在索引中

-- 实战优化:如果经常只查 name, age,建覆盖索引
ALTER TABLE users ADD INDEX idx_name_age(name, age);

3.3 索引下推(ICP)优化

MySQL 5.6+ 自动应用 ICP,减少回表:

-- 有联合索引 idx_name_age(name, age)
SELECT * FROM users WHERE name LIKE 'zhang%' AND age = 25;

-- 无 ICP:先按 name 查到所有行,再在 server 层过滤 age
-- 有 ICP:在索引层过滤 age,减少回表

四、慢查询优化实战案例

4.1 案例:分页深度翻页慢

问题

-- 深度分页:查询第 10000 页,每页 10 条
SELECT * FROM orders
ORDER BY id DESC
LIMIT 100000, 10;  -- 需要扫描 100010 行

-- 耗时:3.2 秒

分析

LIMIT offset, size
  - offset 越大,扫描行数越多
  - 需要先扫描到第 100000 行,然后取 10 条
  - 数据库不知道后面有多少行

解决方案 1:延迟关联

-- 先查 ID,再关联
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders
    ORDER BY id DESC
    LIMIT 100000, 10
) AS t2 ON o.id = t2.id;

-- 耗时:0.8 秒(先查主键只返回 10 条,再关联)

解决方案 2:游标分页(推荐)

-- 第一页
SELECT * FROM orders ORDER BY id DESC LIMIT 10;
-- 返回最后一条 id = 100010

-- 第二页:记住上一页最后一条的 id
SELECT * FROM orders
WHERE id < 100010
ORDER BY id DESC
LIMIT 10;

-- 无论翻到第几页,性能稳定

解决方案 3:记录当前位置

-- 用户翻页时携带上一页最后一行的 id
-- 前端:保存当前页最后一条 id
-- 后端:WHERE id < #{lastId}

4.2 案例:COUNT(*) 慢

问题

SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- 耗时:15 秒(几千万行)

分析COUNT(*) 需要扫描所有行才能得到总数。

解决方案

-- 方案1:使用覆盖索引加速
ALTER TABLE orders ADD INDEX idx_status(status);

SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- 耗时:2 秒(只扫描索引)

-- 方案2:异步计数
-- 业务表 + 计数表(计数器)
UPDATE order_stats SET count = count + 1 WHERE date = '2024-01-01';

-- 方案3:缓存总数(允许一定误差)
-- Redis SET order:pending:count 123456 EX 3600

4.3 案例:JOIN 导致的性能问题

问题

SELECT o.id, o.amount, u.name, u.email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
LIMIT 100;
-- 耗时:5 秒

分析EXPLAIN 发现 o 表是全表扫描,没有索引。

解决

-- 1. 确保 JOIN 字段有索引
ALTER TABLE orders ADD INDEX idx_user_id(user_id);

-- 2. 确保 WHERE 条件有索引
ALTER TABLE orders ADD INDEX idx_status(status);

-- 3. 小表驱动大表(MySQL 自动选择,但可强制)
SELECT /*+ STRAIGHT_JOIN */ o.id, u.name
FROM orders o STRAIGHT_JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending';

-- 4. 限制分页(先筛选再 JOIN)
SELECT o.id, o.amount, u.name
FROM (
    SELECT id, user_id, amount
    FROM orders
    WHERE status = 'pending'
    LIMIT 10000
) o
LEFT JOIN users u ON o.user_id = u.id;

五、索引优化 Checklist

5.1 新建表时

✅ 主键用自增 ID(InnoDB 表)
✅ 为 WHERE 条件字段建索引
✅ 为 ORDER BY / GROUP BY 字段建索引
✅ 区分度高的列放联合索引前面
✅ 字符串字段建索引时指定长度
✅ 避免建太多索引(写操作负担)

5.2 SQL 编写时

✅ 使用 EXPLAIN 分析查询
✅ 避免 SELECT *
✅ 等值查询放范围查询前面
✅ 使用 LIMIT 限制结果集
✅ 批量操作替代循环单条
✅ 避免在索引列使用函数
✅ 使用覆盖索引避免回表

5.3 监控指标

✅ 慢查询数量(slow_query_log)
✅ 平均响应时间
✅ 全表扫描比例(Handler_read_rnd_next)
✅ 索引使用率
✅ 锁等待时间

5.4 常见 SQL 性能对比

SQL 类型 推荐写法 不推荐写法
分页 WHERE id > #{lastId} LIMIT 10 LIMIT 100000, 10
统计 覆盖索引 COUNT(status) COUNT(*) 全表扫描
模糊搜索 全文索引 MATCH AGAINST LIKE '%keyword%'
IN 查询 IN (1,2,3) 或拆分为 JOIN 大量 IN 子查询
范围查询 拆分为多个等值查询 大范围 BETWEEN
JOIN 小表驱动大表,有索引 大表 JOIN 大表

六、索引优化工具

6.1 pt-query-digest(Percona Toolkit)

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

# 输出:
# - 查询排名(按执行时间)
# - 查询摘要
# - 索引建议

6.2 MySQL Workbench

图形化工具,可视化查看执行计划、索引使用情况。

6.3 information_schema

-- 查看表的索引
SELECT * FROM information_schema.statistics
WHERE table_schema = 'db_name'
  AND table_name = 'orders'
ORDER BY index_name, seq_in_index;

-- 查看表大小和行数
SELECT table_name, engine, table_rows, data_length, index_length
FROM information_schema.tables
WHERE table_schema = 'db_name'
ORDER BY data_length DESC;

-- 查看未使用索引
SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'db_name'
  AND count_star = 0;

七、最佳实践总结

7.1 索引设计原则

  1. 查询驱动建索引:根据实际慢查询建立索引,不要盲目建
  2. 联合索引优先:多个字段经常一起查询时,建联合索引
  3. 覆盖索引优化:查询尽量使用覆盖索引
  4. 控制索引数量:每个索引增加写操作开销(5-10 个为宜)
  5. 区分度优先:区分度高的字段优先建索引

7.2 日常运维

1. 定期分析慢查询日志
2. 定期检查并删除无用索引
3. 大表加字段加索引使用 pt-online-schema-change
4. 监控索引使用率,删除低效索引
5. EXPLAIN 是优化前必做操作

一些查询优化

-- ❌ 避免 SELECT * SELECT * FROM user; -- ✅ 只查询必要字段 SELECT id, name, age FROM user;

-- ❌ 避免函数操作索引列 SELECT * FROM user WHERE YEAR(create_time) = 2024; -- ✅ 改为范围查询 SELECT * FROM user WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';

-- ❌ 避免隐式类型转换 SELECT * FROM user WHERE phone = 13800138000; -- phone为VARCHAR -- ✅ 保持类型一致 SELECT * FROM user WHERE phone = '13800138000';

-- ❌ 避免负向查询 SELECT * FROM user WHERE status != 0; -- ✅ 改为正向查询 (如业务允许) SELECT * FROM user WHERE status IN (1, 2, 3);

-- ✅ 分页优化 (大数据量) -- 传统:LIMIT 1000000, 10 (扫描1000010行) SELECT * FROM user LIMIT 1000000, 10; -- 优化1:子查询 + 主键 SELECT * FROM user WHERE id >= (SELECT id FROM user LIMIT 1000000, 1) LIMIT 10; -- 优化2:记录上次查询最大id SELECT * FROM user WHERE id > 1000000 LIMIT 10;