索引这种东西,平时建的时候很爽,等线上库跑了一段时间,一堆索引躺在表上,读写都在拖慢,却又不敢乱删。前几年我接手一个订单库,光核心订单表就挂了十三个索引,其中两三个索引我自己都说不清当初是给什么查询建的。删吧,怕误伤;不删吧,INSERT 和 UPDATE 越来越重,磁盘和缓冲池也被白占。
后来我把 MySQL 的 sys schema 用起来,才算是把索引这摊子事彻底摸清了。sys schema 是 MySQL 5.7 开始内置的一套“体检工具”,它把 performance_schema 采集的底层计数器整理成一张张视图,直接回答“哪个索引从来没用过”“哪个索引用了多少次”“哪个索引和别的索引长得几乎一样”这类问题。这篇文章我就围绕怎么用 sys schema 做索引使用分析,把完整操作、底层原理、判断逻辑、删索引的决策方法一次讲透。
1. 索引黑洞问题:索引多,不等于索引都被用上了
先看一个常见的尴尬场景。业务走了三四年,开发换了几轮,每波人接手都习惯性地“加个索引试试”。于是同一张表上聚集了大量索引,比如 idx_status、idx_status_created、idx_status_type、idx_created_status……它们看着各司其职,实际效果高度重叠。
问题在于,索引不是免费的。每一个索引都是表里的一份冗余数据副本,写入一条记录时,所有索引都要同步更新。订单表日增几十万行时,多一个索引,就意味着每次插入和更新都要额外维护一棵 B+ 树。更麻烦的是,InnoDB 的缓冲池是有限的,索引页也占缓冲池空间,索引越多,真正频繁使用的热数据能在内存里留下的比例就越低,磁盘 I/O 就上来了。索引长期不用,相当于把钱存在一个永远不取的账户里,既没利息,还占着银行的金库仓位。
但能不能找到“哪个索引一直没用”的准确答案?靠人工审查 SQL 不现实,线上查询成百上千条,还有各种 ORM 生成的动态语句;靠数据库自身的慢查询日志也不全,因为索引影响的是所有查询,不只是慢查询。这时候就需要基于实际运行统计来判断,而不是靠猜。
sys schema 的价值就在这里:它不是在教你怎么“看”索引,而是让你站在数据库自己的统计视角,知道每个索引真实服务了哪些查询、读写各多少次、累积等待多久。下一步就从 sys schema 的定位说起。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. sys schema 到底藏着什么:它怎么知道索引“用没用”
sys schema 在 MySQL 5.7 里默认就装好了,8.0 里也一样。它本质上是 performance_schema 之上的一层“透视镜”,把原本零散甚至难读的统计表整理成一组 DBA 友好的视图、存储过程和函数。
要理解 sys schema 为什么能分析索引,得先知道它的数据来源。performance_schema 里有几张核心的 I/O 汇总表,其中和索引最相关的是:
performance_schema.table_io_waits_summary_by_index_usageperformance_schema.table_io_waits_summary_by_table
这两张表记录了每个索引的 I/O 等待情况:读了多少次、写了多少次、累积等待了多长时间,以及从未被使用的索引会留下特殊标记(TIMER_WAIT 为 NULL)。sys schema 中的 schema_unused_indexes、schema_index_statistics 等视图,就是直接读取这些底层数据,把“要不要看一眼”的门槛降到了一次 SELECT。
值得注意的是,sys schema 的视图不是魔法,它要求 performance_schema 是开启状态,否则查出来的信息要么为空,要么干脆报错。MySQL 5.7 和 8.0 默认是开启的,但如果你的实例从旧版本升级而来,或者被人为关闭过,就需要先检查。
sys schema 里跟索引分析关系最密切的视图,我常用的是这几个:
| 视图名 | 作用 | 关键输出 |
|---|---|---|
schema_unused_indexes |
找从未使用的索引 | 库名、表名、索引名 |
schema_index_statistics |
看索引的读写累计统计 | 读取/写入/选中行数、等待时间 |
schema_redundant_indexes |
找互相冗余的索引 | 冗余索引名、被冗余的索引名 |
schema_table_statistics_with_buffer |
整表维度,顺带看缓冲池占用 | 表行数、分配的缓冲池大小 |
我最初用 sys schema,就是冲着 schema_unused_indexes 去的,一条 SQL 就能把线上所有库里“吃闲饭”的索引列出来,比手动翻 DDL 高效太多了。但用久了之后,我发现它的价值远不止“找没用的索引”,更关键的是能帮你在删除索引前做完整的证据链判断,这个后面会细说。
2.1 performance_schema 开启状态是前提
在使用 sys schema 前,建议先执行一句确认:
sql复制SHOW VARIABLES LIKE 'performance_schema';
如果结果是 ON,那恭喜,直接往下走。如果是 OFF,说明这个实例没开 performance_schema,需要在配置文件 my.cnf 的 [mysqld] 段落里加上:
ini复制performance_schema = ON
然后重启 MySQL 实例生效。这里有个坑要提醒:performance_schema 不是动态开启的,如果你在 8.0 里执行 SET GLOBAL performance_schema = ON;,会直接报错,因为它只能在实例启动前配置。我见过有人以为改了参数就行,结果忘了重启,统计一直出不来,白排查了半天。
说到重启,就得提第二个坑:performance_schema 的计数器是累计值,实例重启后统计就清零了。所以如果你刚重启完实例,别急着分析索引,等业务跑上一段时间(至少覆盖一个完整的流量周期)再看,否则数据严重失真。这一条对后面所有索引分析都适用。
2.2 索引统计的原始数据长什么样
为了后面不迷茫,先直接看一眼最底层的原始表:
sql复制SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
COUNT_STAR,
COUNT_READ,
COUNT_WRITE,
TIMER_WAIT
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'ecommerce'
ORDER BY COUNT_READ DESC
LIMIT 20;
输出大致是:
code复制OBJECT_SCHEMA | OBJECT_NAME | INDEX_NAME | COUNT_STAR | COUNT_READ | COUNT_WRITE
ecommerce | orders | PRIMARY | 1009832 | 1002910 | 6922
ecommerce | orders | idx_status | 83920 | 83920 | 0
ecommerce | orders | idx_created_at | 44 | 40 | 4
ecommerce | orders | idx_old_crm_id | 0 | 0 | 0
看到 COUNT_READ 为 0 的 idx_old_crm_id,就能明白 sys schema 的“未使用索引”是怎么判定的了——底层就是这类计数器。sys schema 只是帮你把这个判断做成了视图,但如果你要更细地分析,绕到底层看原始数据是必要的,这个技巧后文会专门展开。
3. 三分钟跑出索引体检报告:sys schema 核心视图实操
现在假设 performance_schema 已经开启,业务也跑了一段时间,我们开始正式“体检”。
3.1 第一份报告:找出完全没被用过的索引
sql复制SELECT * FROM sys.schema_unused_indexes;
执行结果类似:
code复制object_schema | object_name | index_name
ecommerce | orders | idx_old_crm_id
ecommerce | users | idx_unionid
ecommerce | payments | idx_callback_seq
执行完先别急着删,这条 SQL 回答的是“从未被使用”的索引,时间范围是数据库启动以来的累计值。如果业务是最近才上线新索引,或者索引服务的是一个平时很少触发但非常重要的低频查询(比如月末对账单统计),它出现在列表里也正常。
这个视图有个缺陷:它只区分“用过”和“没用过”,不区分“用得极少”。一个索引可能被用了 5 次,对一个日流量百万的系统来说,这 5 次基本等于没用,但它不会出现在未使用列表里。所以要继续看第二份报告。
3.2 第二份报告:每个索引的使用频率和读写分布
sql复制SELECT
TABLE_SCHEMA,
TABLE_NAME,
INDEX_NAME,
ROWS_READ,
ROWS_SELECTED,
ROWS_INSERTED,
ROWS_UPDATED,
ROWS_DELETED
FROM sys.schema_index_statistics
WHERE TABLE_SCHEMA = 'ecommerce'
ORDER BY ROWS_READ DESC;
这个视图的信息量非常大。ROWS_READ 代表通过这个索引查找了多少行数据,ROWS_SELECTED 代表最终从表里返回了多少行,ROWS_INSERTED/UPDATED/DELETED 则反映索引本身因为对应的写操作而发生了多少次变更。
注意 ROWS_READ 和 ROWS_SELECTED 的比值很有诊断价值。如果 ROWS_READ 远大于 ROWS_SELECTED,说明这个索引的筛选效率不高——每次查询通过索引读了很多行,但最后留下返回的行很少,这种索引要么创建得不合适,要么存在“碰到索引就全扫”的问题。
举个例子,我在一个用户的订单表上见过 idx_member_status,ROWS_READ 冲到一百万,ROWS_SELECTED 只有几千,查询是 WHERE member_id = ? AND status = ?。索引左前缀只能用到 member_id,但 member_id 下挂的订单多,status 过滤放在索引第二步没有对查找行数产生贡献。最终优化方案是把索引调整成 (member_id, status, created_at),ROWS_READ 立刻降了一个数量级。这个案例说明,只看执行计划的 type 不完全够,结合 sys schema 的实际行数统计更有说服力。
3.3 第三份报告:找冗余索引,消除重复维护
sql复制SELECT * FROM sys.schema_redundant_indexes;
这个视图列出的是“互为冗余”的索引对。比如你有个 idx_status,又有个 idx_status_created_at,那么 idx_status 在绝大多数场景下是冗余的,因为 idx_status_created_at 的最左前缀已经覆盖了 status 列的查找需求。schema_redundant_indexes 会明确指出这个关系:
code复制table_name | redundant_index_name | redundant_index_columns | dominant_index_name | dominant_index_columns
orders | idx_status | status | idx_status_created_at| status,created_at
冗余索引的代价是双份的:写入时两个索引都要更新,查询时优化器还得在多棵 B+ 树之间做选择。更重要的是,如果这两个索引的列顺序设计不当,优化器选了冗余的那个,反而可能让本该走 idx_status_created_at 的查询变慢。
我在一个支付表上见过三重复冗余:idx_trade_no、idx_trade_no_status、idx_trade_no_type_status。前两个完全是第三个的子集,但因为历史代码里有人用 FORCE INDEX 强制指定了 idx_trade_no_status,冗余索引反而被“强行保命”。这类情况下,别只看 sys schema 就删,还要配合检查代码里的 FORCE INDEX / USE INDEX 提示。这个细节后面单独讲。
3.4 第四份报告:从整表维度看索引对内存的压力
有时单个索引看着使用还行,但表整体太大,导致缓冲池吃紧。可以先看整表的统计:
sql复制SELECT * FROM sys.schema_table_statistics_with_buffer
WHERE table_schema = 'ecommerce' \G
这个视图把表的总行数、总 I/O 等待、分配到的缓冲池内存都列出来了。如果一个表频繁被访问,但缓冲池分配相比表大小差很多,那索引的 B+ 树很可能频繁淘汰——这种情况下,减少冗余索引是立竿见影的办法。
不过这里要留意:视图里的 allocated 是当前采样点的大小,不是历史峰值,所以不建议单凭这一列下结论,它更适合做横向对比——看同一张表各索引页占了多大空间,那些“看着没用但占了不少内存”的索引会非常刺眼。这是我后来会用到的判断依据之一。
4. 绕到底层看更细的统计:performance_schema 的隐藏信息
sys schema 视图好用,但它是“加工产品”,在一些精细分析场景下不够用。比如:
- 你想知道某索引当前实例启动以来总共等了多久,而不是只看行数;
- 你想把统计清空,从特定时间点开始累计观察;
- 你想区分没有记录到 index usage的对象(比如全文索引、空间索引在某些版本下统计不全);
- 你想确认某个索引是否包含 NULL 等待时间标记,而不是“COUNT_STAR 等于 0”这么粗粒度。
这时候直接查原始表:
sql复制SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME,
COUNT_STAR,
COUNT_READ,
COUNT_WRITE,
ROUND(SUM_TIMER_WAIT / 1000000000, 2) AS total_wait_ms,
ROUND(AVG_TIMER_WAIT / 1000000000, 4) AS avg_wait_ms
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA NOT IN ('mysql', 'sys', 'performance_schema')
ORDER BY COUNT_STAR DESC
LIMIT 30;
这里 TIMER_WAIT 单位是皮秒,除以 1e9 转成毫秒。注意,如果一个索引在 MySQL 8.0 中从未被使用,它的 TIMER_WAIT 通常是 NULL(或者不产生统计行),而不是 0。判断“是否被用过”最稳的字段其实是这个,而不是简单比较 COUNT_STAR 是否为 0。
还有一个很有用的技巧:你可以通过 TRUNCATE 重置统计,来做一个周期观察。
sql复制TRUNCATE performance_schema.table_io_waits_summary_by_index_usage;
TRUNCATE 之后,所有计数器回归初始状态。然后让系统正常运行 24 小时,再次查询,就能拿到“最近一天”的索引使用情况,而不是累计了几个月的旧数据。这个操作我是用来做上线新索引后的验证的:新索引到底有没有被查询真正走到,24 小时后一查便知。注意 TRUNCATE 表时对 performance_schema 下的表操作,语法和普通表一样,但要确保权限足够。
4.1 服务器重启和统计窗口的影响
提到 statistics 表,就必须说一个我踩过的坑:performance_schema 的 I/O 统计不持久化,实例重启即清零。所以生产环境分析索引前,先确认 Uptime:
sql复制SHOW GLOBAL STATUS LIKE 'Uptime';
如果你的实例才开机半小时,即使查到一个索引“从未使用”,也不能断定它没用——可能只是业务高峰期还没到。最好的做法是在统计覆盖了一个完整业务周后再做删除决策,或者像我后面那样搭建持续采集表。
另外,performance_schema 在并发非常高时,对 COUNT_STAR 等累计字段的更新采用原子递增,会有一些性能开销,但通常远小于慢查询代价,多数业务场景可以忽略。倒是有一种情况要注意:如果你开启了 innodb_monitor_enable 之类的大量统计项,performance_schema 的内存占用会明显上涨,需要留出足够的内存预算。
4.2 主键、唯一索引、普通索引的区别处理
sys schema 分析中,主键永远是特例。PRIMARY 索引的 ROWS_READ 通常最高,因为它承载所有 WHERE id = ? 的查询。不要因为主键使用频率高,就觉得普通索引没用——很多查询是通过普通索引定位到主键,再回表取数据,所以普通索引和主键是配合关系,而不是主键盖过一切。
我见过有人统计完,看见主键读了一千万次,别的索引都只有几万次,就下结论“其他索引没必要”。这是典型的误判。比如一个 idx_user_id,每次登录都走它回表,统计数据可能只有两万次,但这两万次如果没了,登录接口会从 5ms 变成 300ms,业务立刻报警。所以看统计数据,要结合 SQL 访问模式判断,不能只看数值大小。
5. 从统计数据到删除决策:一个索引到底该不该删
sys schema 负责把事实摆出来,真正难的是决策。我总结了三个信号,综合判断能删:
- 从未使用:
schema_unused_indexes命中,且该索引服务的关键查询在代码里搜不到; - 使用频率极低:
ROWS_READ与全表其他索引相比差 2-3 个数量级以上; - 存在冗余:
schema_redundant_indexes判定它是被覆盖的一方。
这三个信号不是孤立满足一条就能删,我建议满足至少两条才算稳妥。比如某个索引虽然“从未使用”,但它是唯一索引(UNIQUE KEY),那就要格外小心——它可能不是为了查询性能而建,而是为了约束数据唯一性,这种索引再“没用”也不能删,删了可能会允许重复数据写入。这里要依赖 information_schema.STATISTICS 里的 NON_UNIQUE 字段判断索引类型。
还有一类不能凭统计删的:外键约束自动创建的索引。MySQL/InnoDB 在创建外键时,如果对应列上没有索引,会自动创建一个索引。这类索引承担的是完整性约束和级联操作,删除它要么直接报错,要么外键失效,后果可能很严重。sys schema 的统计里它会显示为“无查询使用”,但实际是外键在使用。判断方法是在 information_schema.KEY_COLUMN_USAGE 里查看该索引是否被 FOREIGN KEY 引用。
5.1 删除前必须做的三重检查
我自己的流程是这样的,三步缺一不可:
第一步,全文检索代码和存储过程里的索引引用。
bash复制grep -r "idx_old_crm_id" /data/app/ --include="*.xml" --include="*.java" --include="*.sql" -i
搜不到再往下走。要注意,ORM 比如 Hibernate 或 MyBatis 的 XML 文件里,可能出现 FORCE INDEX(idx_old_crm_id),这种东西你在纯 SQL 统计里是看不出来的,但一旦索引删除,SQL 直接报错或者走全表扫描。所以代码搜索是硬条件。
第二步,收集包含该索引的表的 explain 信息。
利用 performance_schema.events_statements_summary_by_digest 找出按调用次数排序的热点 SQL:
sql复制SELECT
SCHEMA_NAME,
DIGEST_TEXT,
COUNT_STAR,
AVG_TIMER_WAIT/1000000000 AS avg_wait_ms
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'ecommerce'
ORDER BY COUNT_STAR DESC
LIMIT 30;
对涉及“疑似删除索引”的表的 SQL,逐个执行 EXPLAIN,确认优化器没有引用该索引。只要有一条热点 SQL 的 possible_keys 里出现过它,就得多留个心眼。
第三步,在低峰期删除,并保留一键回滚脚本。
删除索引:
sql复制ALTER TABLE ecommerce.orders DROP INDEX idx_old_crm_id;
同时把下面的回滚语句保存到 /root/rollback/rollback_orders_idx_old_crm_id.sql:
sql复制ALTER TABLE ecommerce.orders ADD INDEX idx_old_crm_id (old_crm_id);
建议再加上注释,写明原来索引的定义、建索引的日期、初步判断原因。这样万一真的误删了,恢复也就一条命令的事。我见过因为删了索引导致接口超时的,回滚后毫无影响,但如果不留备份,排查半天才能想起来是删索引导致的,那就被动了。
5.2 删索引的时间窗口与并发影响
要知道,ALTER TABLE ... DROP INDEX 在 MySQL 8.0 里虽然支持了在线 DDL(ALGORITHM=INPLACE),但它依旧会拿到表上的元数据锁,并且需要重建一部分索引结构。所以在超高并发业务下,还是建议:
- 在业务低峰期执行;
- 如果表非常大(几亿行),先确认磁盘剩余空间;
- 先删“明显冗余”的索引,再观察 SQL 响应时间,然后再删下一个,避免一刀切导致连锁反应。
MySQL 8.0 中删除索引不一定重建整张表,但依然会修改数据字典,并可能影响 information_schema 的统计信息刷新。这也是“执行计划选错索引”的诱因之一——删完索引后,优化器可能重新选择其他可用索引,执行计划变化可能带来新的性能波动,所以删除后要持续观察至少一整天。
6. 把索引巡检自动化:持续采集,别只看瞬时快照
sys schema 给的是“当前累计值”,而索引使用情况是会演的。有的索引在新功能上线前从未被查询,新功能上线后突然变成最热索引;有的索引刚建时很热,业务调整后迅速冷掉。单次分析只能拍一张照片,做不了趋势判断。所以我的做法是,把 sys schema 的输出定期采集到一个普通业务库里,形成历史趋势。
下面给出一个最简化的采集方案。
6.1 建一张统计历史表
sql复制CREATE TABLE dba_db.index_usage_daily (
id INT AUTO_INCREMENT PRIMARY KEY,
table_schema VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
index_name VARCHAR(64) NOT NULL,
rows_read BIGINT DEFAULT 0,
rows_selected BIGINT DEFAULT 0,
rows_inserted BIGINT DEFAULT 0,
rows_updated BIGINT DEFAULT 0,
rows_deleted BIGINT DEFAULT 0,
-- 记录与上次采集的差值
rows_read_delta BIGINT DEFAULT 0,
collect_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_collect_time (collect_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然后每天定时执行一次插入,比如用 MySQL Event:
sql复制CREATE EVENT ev_collect_index_usage
ON SCHEDULE EVERY 1 DAY
STARTS '2024-01-01 03:00:00'
DO
INSERT INTO dba_db.index_usage_daily
(table_schema, table_name, index_name, rows_read, rows_selected,
rows_inserted, rows_updated, rows_deleted, rows_read_delta)
SELECT
TABLE_SCHEMA,
TABLE_NAME,
INDEX_NAME,
ROWS_READ,
ROWS_SELECTED,
ROWS_INSERTED,
ROWS_UPDATED,
ROWS_DELETED,
ROWS_READ - LAG(ROWS_READ) OVER (ORDER BY table_schema, table_name, index_name)
FROM sys.schema_index_statistics;
注意 LAG 在这里只是示意,实际跨多行做“本次与上次差值”最好用一条专用的 SQL 读取上一次采集值,而不是在插入时临时算。更稳的写法是:
sql复制INSERT INTO dba_db.index_usage_daily
(table_schema, table_name, index_name, rows_read, rows_selected,
rows_inserted, rows_updated, rows_deleted, rows_read_delta)
SELECT
s.TABLE_SCHEMA,
s.TABLE_NAME,
s.INDEX_NAME,
s.ROWS_READ,
s.ROWS_SELECTED,
s.ROWS_INSERTED,
s.ROWS_UPDATED,
s.ROWS_DELETED,
s.ROWS_READ - IFNULL(
(SELECT MAX(rows_read) FROM dba_db.index_usage_daily t
WHERE t.table_schema = s.TABLE_SCHEMA
AND t.table_name = s.TABLE_NAME
AND t.index_name = s.INDEX_NAME),
s.ROWS_READ
) AS delta
FROM sys.schema_index_statistics s;
这个写法的逻辑是:如果一张表/索引是第一次出现在采集中,delta 直接等于当前值;如果已经采集过,delta 就是本次和上次的差。这样就能判断“过去一天里这个索引到底服务了多少次查询”,而不是看一个臃肿的累计值。
6.2 怎么从趋势数据里发现问题
有了历史数据,做分析就非常容易了。比如找“连续七天 delta 为 0”的索引:
sql复制SELECT table_schema, table_name, index_name
FROM dba_db.index_usage_daily
WHERE collect_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY table_schema, table_name, index_name
HAVING SUM(rows_read_delta) = 0
AND SUM(rows_inserted_delta) + SUM(rows_updated_delta) + SUM(rows_deleted_delta) = 0;
这样查出来的是“连续七天完全没被读写碰过”的索引,比只看瞬时累计值可靠得多。我还会再加条件和 schema_redundant_indexes 关联,找出“连续不用且冗余”的高危索引,这种基本就能进入删除候选名单了。
持续采集还有一个好处:新索引上线一周内,如果 delta 持续为 0,就说明这个索引没被任何查询选中。这时候回头查代码,多半是建了没用上,或者查询没走到索引,尽早发现尽早纠正,避免索引长期占用资源。
6.3 巡检频率的取舍建议
我的经验是:
- 核心业务库:每 6 小时采集一次,保留 90 天;
- 普通业务库:每天采集一次,保留 30 天;
- 采集时间点选在业务低峰期之后,避免统计口径受涌峰影响太大。
采集脚本本身很轻,完全可以放在 MySQL Event 里,不用额外引入定时任务系统。但别忘记,performance_schema 的统计在每次实例重启后清零。如果中间重启过,累计值会折断,delta 计算会出现负值。我在采集表里专门加了一列 uptime_seconds,每次采集时把 SHOW GLOBAL STATUS LIKE 'Uptime' 的值存进去。如果发现某次采集的 uptime 比上一次小,就知道实例重启了,对应 delta 需要做特殊处理,不能直接做趋势判断。这个小细节帮我避免了好几次误判。
7. 实际案例复盘:一次从 sys schema 发现的“假热”索引优化
光讲理论不够,分享一个我实际优化的案例。某个会员服务库的 member_visit_log 表,有 2 亿行,有一个 idx_member_visit_time,由 member_id 和 visit_time 组成。看 schema_index_statistics,它的 ROWS_READ 一天就有 300 万,看起来非常热,根本不会出现在未使用索引里。
但我把 ROWS_READ 和 ROWS_SELECTED 放在一起对比时发现了异常:ROWS_READ 300 万,ROWS_SELECTED 却只有 1.8 万。这相当于每次通过这个索引定位到的行数约 166 行,但最终只返回 1 行。明显是索引前缀区分度不够,导致扫描了大量无用行。
进一步用 EXPLAIN 检查业务 SQL:
sql复制EXPLAIN SELECT * FROM member_visit_log
WHERE member_id = 100123 AND visit_time > '2024-05-01'
ORDER BY visit_time DESC LIMIT 10;
结果显示 key=idx_member_visit_time,rows=289。通过索引定位了 289 行,最后只取 10 行,虽然没到全表扫的程度,但因此多回表了近 280 次。在并发量极大的情况下,这就是不必要的 I/O 放大。
这个索引并不是“没用”,而是“不够准”。最终我调整索引顺序为 (visit_time, member_id),因为实际查询中先按 visit_time 段查询、再按 member_id 过滤的场景占大多数。调整后 ROWS_SELECTED 和 ROWS_READ 的比值从 1:166 变成了 1:1.2,几乎每次索引访问都精准命中目标行,数据库整体 Innodb_rows_read 下降明显。
这个案例的结论是:sys schema 不只是帮你删索引,还能帮你发现“貌似有用实则低效”的索引,为索引重构提供数据依据。做索引优化,不能只盯着“没用”和“有用”两分法,还要关注“用得好不好”。
8. 日常索引管理里我坚持的几条习惯
到这儿,sys schema 分析索引的核心用法基本讲完了。最后分享几个我在实际维护中逐渐形成的习惯,不一定适合所有人,但至少帮我避免过不少线上事故。
习惯一:每个季度做一次索引健康巡检。 不要等线上慢了才开始查。每个季度挑个周末低峰,把 schema_unused_indexes、schema_redundant_indexes、schema_index_statistics 三张视图各跑一遍,输出一份索引体检报告。这一件事只需要小半天,却能尽早把坑填上。
习惯二:新索引上线后设置 7 天观察期。 执行完 CREATE INDEX 之后,我给索引备注上创建日期和目的,然后在第 7 天用 schema_index_statistics 检查它的 ROWS_READ。如果接近零,我就回代码里查 SQL 为什么没走到索引,是查询写法问题还是索引设计问题。这样能把“无效索引”挡在上线早期,而不是等它白白占几个月的磁盘空间。
习惯三:写代码的人必须参与索引删除决策。 索引删除看似是 DBA 的事,但 SQL 是开发写的,开发比 DBA 更清楚业务逻辑。拿到 sys schema 的报告后,我会把候选索引列表发给对应业务线的开发确认一遍,“这个索引你们还有没有场景用到”,得到明确答复后再动手。别小看这一步,很多索引是因为某些“数据分析临时查询”在用的,DBA 不看业务代码根本不知道。
习惯四:删除索引前一定同时确认唯一约束和外键。 这一点前面提到过,但值得再说一遍。看 information_schema.STATISTICS.NON_UNIQUE=0,以及 KEY_COLUMN_USAGE 里有没有外键引用。这两类索引承担的是完整性保障,不是性能优化,即使 sys schema 显示从未被使用,也不能单凭统计去删。我有一次差点把一张用户表上的 uk_phone 索引删了,因为它确实没被查询用过,但它是不允许重复手机号注册的唯一约束,删了就会允许重复数据写入。后来查 NON_UNIQUE=0 才发现,赶紧打消了念头。
习惯五:把 sys schema 的巡检结果和慢查询日志做交叉验证。 schema_index_statistics 看的是“某个索引被用了多少次”,慢查询日志看的是“哪些 SQL 慢”。两条数据一交叉,能发现很多隐藏问题。比如某条 SQL 频繁调用、走了索引、但依然慢,这时候可能是索引本身没让 SQL 走最优执行路径,或者存在隐式类型转换导致索引失效。我在 MySQL 8.0 里经常用:
sql复制SELECT * FROM sys.schema_unused_indexes
WHERE object_schema NOT IN ('mysql', 'sys', 'performance_schema');
配合慢查询日志,把“索引用了但是慢”和“索引压根没用”分开处理,比单独看任何一份报告都容易定位问题。
9. 写在最后:索引管理没有终点
我见过太多团队把索引优化当成“上线前的动作”,索引建完就再也不管了。但实际上,索引是数据库里最需要持续维护的资产之一,因为业务查询模式一直在变。今天的新功能查询,可能让一个冷门索引一夜爆热;明天的业务下线,也可能让一个热门索引从此沉寂。
sys schema 这个“体检工具”给我的最大价值,不是告诉我哪个索引该删、哪个索引该留,而是让我和业务方之间有了可以对话的事实依据。以前我说“这个索引没用”,开发会质疑;现在我把 schema_index_statistics 的数据贴出来,开发一眼就明白,剩下的就是商量怎么调整更合理。
如果你还没用过 sys schema,建议今天就在测试环境跑一跑那三条核心 SQL。等到了生产环境,你会发现一个之前从没注意过的“索引真相”:很多你以为是靠它撑着性能的索引,其实已经默默吃灰很久了;而真正扛住系统压力的,往往只是其中少数几个精心设计过的核心索引。
把索引瘦身成一个精简、有效、可解释的集合,比“多建几个索引以防万一”强太多了。这条经验,我踩过坑,才真正明白。
