1. 问题背景:一条诡异的慢SQL
那天下午3点,监控系统突然发出刺耳的警报声——某核心接口响应时间从平均200ms飙升到8秒以上。作为当值DBA,我立刻登录Grafana查看监控大盘,发现一条原本执行良好的统计SQL突然变成了性能杀手。
这条SQL负责统计用户近30天的行为数据,在生产环境已经稳定运行了半年多。其执行计划原本完美利用了user_id和create_time的联合索引,平均执行时间保持在150ms左右。但此刻它却变成了全表扫描,单次执行耗时超过5秒,导致接口超时率暴涨。
关键现象:SQL语句本身没有任何变更,执行计划却突然从索引扫描变成了全表扫描,且发生在业务高峰期。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初步排查:执行计划分析
2.1 获取问题SQL的执行计划
我第一时间从慢查询日志中抓取了这条SQL的完整文本和执行计划:
sql复制SELECT
user_id,
COUNT(DISTINCT item_id) AS browse_count,
SUM(CASE WHEN is_purchased = 1 THEN 1 ELSE 0 END) AS purchase_count
FROM user_behavior
WHERE
user_id = 123456
AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id;
使用EXPLAIN命令查看执行计划:
sql复制EXPLAIN SELECT ... [同上SQL];
得到的执行计划显示:
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | user_behavior | ALL | idx_user_time | NULL | NULL | NULL | 9830412 | Using where |
这个结果非常反常——明明存在idx_user_time(user_id, create_time)这个联合索引,优化器却选择了全表扫描(type=ALL)。
2.2 执行计划关键指标解读
- type=ALL:最差的情况,全表扫描
- possible_keys=idx_user_time:优化器识别到了可用索引
- key=NULL:最终没有使用任何索引
- rows=9830412:需要扫描近千万行数据
3. 深入分析:索引失效的六大可能原因
3.1 原因一:索引统计信息不准确
MySQL的优化器依赖统计信息来选择执行计划。我首先检查了索引统计信息:
sql复制SHOW INDEX FROM user_behavior;
发现idx_user_time的Cardinality值明显偏低(实际该列有数百万不同值,但统计信息显示只有几百)。这会导致优化器低估索引的选择性。
解决方案:
sql复制ANALYZE TABLE user_behavior;
更新统计信息后,再次检查执行计划,问题依旧。
3.2 原因二:隐式类型转换
仔细检查WHERE条件:
sql复制WHERE user_id = 123456 AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
发现user_id在表结构中定义为VARCHAR(32),但SQL中使用了数字123456。这会导致MySQL进行隐式类型转换,使索引失效。
验证方法:
sql复制-- 使用正确类型查询
EXPLAIN SELECT ... WHERE user_id = '123456' AND ...;
修改后执行计划仍然显示全表扫描,排除此原因。
3.3 原因三:函数操作导致索引失效
检查时间条件:
sql复制create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
这个写法不会导致索引失效,因为是对常量值计算。但为排除可能性,我尝试:
sql复制-- 使用变量预先计算
SET @start_time = DATE_SUB(NOW(), INTERVAL 30 DAY);
EXPLAIN SELECT ... WHERE create_time >= @start_time;
执行计划无变化。
3.4 原因四:索引列参与运算
检查是否有类似user_id + 0 = 123456这样的表达式,但本SQL中没有这种情况。
3.5 原因五:使用了OR条件
SQL中没有使用OR条件,排除此可能性。
3.6 原因六:索引选择性不足
计算user_id=123456的选择性:
sql复制SELECT
COUNT(*) AS total,
SUM(CASE WHEN user_id = '123456' THEN 1 ELSE 0 END) AS match_count,
SUM(CASE WHEN user_id = '123456' THEN 1 ELSE 0 END)/COUNT(*) AS selectivity
FROM user_behavior;
结果显示匹配行数占比不到0.01%,选择性足够高,理论上应该使用索引。
4. 突破性发现:索引跳跃扫描的边界条件
4.1 MySQL优化器的成本计算
通过optimizer_trace深入分析优化器的决策过程:
sql复制SET optimizer_trace="enabled=on";
EXPLAIN SELECT ... [原SQL];
SELECT * FROM information_schema.optimizer_trace;
在trace输出中,发现关键信息:
json复制{
"range_analysis": {
"table_scan": {
"rows": 9830412,
"cost": 1.97e6
},
"potential_range_indexes": [
{
"index": "idx_user_time",
"usable": true,
"key_parts": ["user_id", "create_time"]
}
],
"skip_scan_range": {
"potential_skip_scan_indexes": [
{
"index": "idx_user_time",
"usable": false,
"cause": "query_references_nonkey_column"
}
]
}
}
}
4.2 根本原因定位
最终发现是MySQL 8.0的优化器新特性"Index Skip Scan"在某些边界条件下的bug。当同时满足:
- 表数据量超过500万行
- 查询条件中的非索引列(is_purchased)参与聚合
- 系统负载较高时
优化器会错误地跳过索引扫描。这解释了为什么问题突然出现——当表数据量增长到临界点后触发了这个边界条件。
5. 解决方案与验证
5.1 临时解决方案:强制使用索引
sql复制SELECT
user_id,
COUNT(DISTINCT item_id) AS browse_count,
SUM(CASE WHEN is_purchased = 1 THEN 1 ELSE 0 END) AS purchase_count
FROM user_behavior FORCE INDEX(idx_user_time)
WHERE
user_id = '123456'
AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id;
执行时间立即恢复到200ms以内。
5.2 长期解决方案:优化索引设计
新增一个覆盖索引:
sql复制ALTER TABLE user_behavior ADD INDEX idx_cover(user_id, create_time, item_id, is_purchased);
这样优化器更倾向于选择这个更完整的索引,避免了Skip Scan的边界条件。
5.3 执行计划验证
新索引的执行计划:
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | user_behavior | range | idx_user_time,idx_cover | idx_cover | 132 | NULL | 243 | Using index |
6. 经验总结与最佳实践
6.1 生产环境索引使用检查清单
-
定期检查索引统计信息:
sql复制SELECT table_name, index_name, stat_value FROM mysql.innodb_index_stats WHERE database_name = 'your_db'; -
监控索引使用情况:
sql复制SELECT * FROM sys.schema_index_statistics WHERE table_schema = 'your_db'; -
使用optimizer_trace分析复杂查询:
sql复制SET optimizer_trace="enabled=on"; EXPLAIN SELECT ...; SELECT * FROM information_schema.optimizer_trace;
6.2 索引设计黄金法则
-
遵循最左前缀原则:将高选择性列放在联合索引左侧
-
避免过度索引:每个额外索引都会增加写操作开销
-
考虑覆盖索引:包含所有查询字段的索引可以避免回表
-
警惕隐式类型转换:确保查询条件与列类型完全匹配
6.3 值得注意的MySQL版本特性
-
MySQL 8.0的Index Skip Scan:
- 适用于低基数列的查询优化
- 在某些边界条件下可能导致性能下降
- 可通过
optimizer_switch控制
-
直方图统计信息:
sql复制ANALYZE TABLE user_behavior UPDATE HISTOGRAM ON user_id, create_time; -
不可见索引:
sql复制ALTER TABLE user_behavior ALTER INDEX idx_name INVISIBLE;
这次排查经历让我深刻体会到,即使是最基础的索引问题,在复杂的生产环境中也可能隐藏着意想不到的陷阱。关键在于建立系统化的排查思路,并善用MySQL提供的各种诊断工具。
