数据库运维9 min read次阅读

索引不是加得越多越好:一次索引重构的记录

打开表结构,14 个索引,其中 6 个是单列索引,还有几个前缀完全重复。这张 2000 万的表写入从 2000 TPS 掉到 600,磁盘占用比数据本身还大——索引比数据多。

索引不是越多越好。每多一个索引,每次 INSERT/UPDATE/DELETE 都要多维护一棵 B+ 树,写入成本实实在在增加,而查询收益要看它到底有没有被用上。

先看看哪些索引根本没用

MySQL 5.6+ 有 performance_schema 可以统计索引使用情况:

SELECT
  object_schema, object_name, index_name,
  count_star, count_read
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'shop' AND object_name = 'orders'
ORDER BY count_star;

count_star = 0 的就是建了从没用过的。注意这个统计是内存里的,实例重启会清零,所以要看稳定运行一段时间之后的数据,别刚重启完就下结论。

另一个方法是慢查询日志配合 EXPLAIN,但那个是"查得慢的",不是"没用的",两者不重叠。

那次最后删了 5 个零使用的索引,写入 TPS 回来了一大截。

联合索引的顺序比数量重要

这是最常被搞错的地方。假设有这两个查询:

-- 查询 A:按用户查订单
SELECT * FROM orders WHERE user_id = 100;

-- 查询 B:按用户 + 状态查
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

很多人会给 user_idstatus 各建一个单列索引。但更好的做法是一个联合索引:

CREATE INDEX idx_user_status ON orders(user_id, status);

这一个索引能同时服务两个查询(因为最左前缀),而且比两个单列索引省空间、写入更快——MySQL 5.6 之后的 index merge 虽然能合并多个单列索引,但效率和可预测性都不如联合索引。

列的顺序怎么定,三条经验:

  1. 等值条件放前面,范围条件放后面。因为范围列后面的列用不上索引。

    -- 查询:user_id = ? AND create_time > ?
    -- 正确顺序
    CREATE INDEX idx_user_time ON orders(user_id, create_time);
    -- 反了的话,create_time 是范围查询,后面的列就失效了
    
  2. 区分度高的放前面(在同为等值条件时)。区分度 = COUNT(DISTINCT col) / COUNT(*),越接近 1 越好。

    SELECT
      COUNT(DISTINCT status) / COUNT(*) AS status_sel,
      COUNT(DISTINCT user_id) / COUNT(*) AS user_sel
    FROM orders;
    

    不过这条要小心:如果查询里某列总是等值匹配,区分度的影响没那么大,优先满足第 1 条。

  3. 考虑排序。如果查询带 ORDER BY,让索引顺序和排序顺序一致,能省掉 filesort:

    -- 查询:WHERE user_id = ? ORDER BY create_time DESC LIMIT 10
    CREATE INDEX idx_user_time ON orders(user_id, create_time);
    -- 索引本身有序,取前 10 条直接扫描,不用排序
    

最左前缀到底卡在哪

联合索引 (a, b, c) 相当于建了 (a)(a,b)(a,b,c) 三个索引的查询能力。但必须从最左边开始连续匹配

-- 索引 (user_id, status, create_time)

WHERE user_id = 1                              -- ✅ 用上 user_id
WHERE user_id = 1 AND status = 'PAID'          -- ✅ 用上前两列
WHERE user_id = 1 AND status='PAID' AND create_time > ?  -- ✅ 三列
WHERE user_id = 1 AND create_time > ?          -- ⚠️ 只用上 user_id(status 断了)
WHERE status = 'PAID'                          -- ❌ 没从最左开始,全表扫
WHERE user_id > 1 AND status = 'PAID'          -- ⚠️ 范围之后失效,只用 user_id

最后一条最容易踩:user_id > 1 是范围,后面 status 就用不上了。如果需要 status 也走索引,考虑调整顺序或者用 IN 把范围变成等值。

还有几种索引直接失效的写法,见到就该改:

WHERE YEAR(create_time) = 2026                 -- ❌ 列上套函数
WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01'   -- ✅

WHERE amount + 0 > 100                         -- ❌ 列参与运算
WHERE amount > 100                             -- ✅

WHERE phone = 13800138000                      -- ❌ 类型不匹配(phone 是 varchar)
WHERE phone = '13800138000'                    -- ✅

WHERE name LIKE '%abc'                         -- ❌ 前缀通配符
WHERE name LIKE 'abc%'                         -- ✅

隐式类型转换那条最隐蔽:phonevarchar,传数字进去,MySQL 会给加转换函数,索引直接废掉,而 SQL 看着完全没问题。

覆盖索引:省掉回表

回表是什么:二级索引的叶子节点存的是主键值,通过二级索引找到主键后,还要再查一次主键索引(聚簇索引)拿完整行数据——这多出来的一次就是回表。

如果索引里已经包含查询需要的全部列,就不用回表了,这叫覆盖索引。EXPLAIN 的 Extra 里会显示 Using index

-- 查询只需要 user_id 和 status
SELECT user_id, status FROM orders WHERE user_id = 100;

-- 索引 (user_id, status) 就是覆盖索引,Extra: Using index

回表的代价:随机 IO。2000 万的表,命中 1 万行就要 1 万次随机主键查找,这在 HDD 上能差出几十倍。把常用查询改成覆盖索引,是投入产出比最高的优化之一。

EXPLAIN 里看到 Using index condition 是另一回事(索引下推 ICP),Using index 才是覆盖索引。

用 EXPLAIN 判断索引有没有生效

EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'\G

看这几列:

看什么
type 从好到坏:system > const > eq_ref > ref > range > index > ALL。至少要到 ref/range,出现 ALL(全表扫)就要处理
key 实际用到的索引。possible_keys 是"候选",key 才是真用的
key_len 实际用了索引的多少字节。联合索引只看这个就知道用到几列
rows 预计扫描行数,和实际差得越多说明统计信息越不准
Extra Using index 覆盖索引;Using filesort 要排序(可能要优化);Using temporary 用了临时表;Using where 正常

统计信息不准时(比如刚大批量导入后),优化器可能选错索引:

ANALYZE TABLE orders;          -- 重新收集统计信息
-- 紧急情况下强制指定(不建议长期用,数据分布变了就失效)
SELECT * FROM orders FORCE INDEX (idx_user_status) WHERE ...;

那次最后怎么改的

14 个索引砍到 5 个:

  • 删掉 5 个零使用的
  • 两个前缀重复的单列索引(user_id(user_id, status))合并成一个联合索引
  • 把一个区分度极低的单列索引(status 只有 3 个值,区分度 0.0001)删掉——这种列单独建索引几乎没用,除非配合覆盖索引
  • 新增两个覆盖索引,覆盖最高频的两个查询

结果:写入 TPS 从 600 回到 1800,磁盘占用降了 40%,那两个高频查询因为覆盖索引反而更快了。

几条经验

  • 建索引之前先想清楚这个查询会不会真的用上它,别凭感觉
  • 单列索引能合并成联合索引的尽量合并,注意顺序
  • 定期(比如每季度)用 performance_schema 查一次零使用索引,及时清理
  • 区分度极低的列(性别、状态)单独建索引基本没意义,做联合索引的后缀列或放进覆盖索引才行
  • 索引是写放大,每加一个都要问一句"写入变慢能接受吗"
  • 主从架构下,从库的查询模式和主库不同,索引可以不一样——但要注意主从切换后会怎样
分享:

相关文章

评论区