华为云 RDS for MySQL 慢查询治理:从定位到索引优化的完整路径
一、慢是结果,不是原因
先明确一件事:"数据库慢"几乎从来不是数据库的问题。
我们的订单列表接口某天开始变慢。监控上看:RDS 的 CPU 使用率 40%(不算高)、连接数 80(正常范围)、磁盘 IOPS 也没打满。所有指标都在"健康"区间,但接口就是慢。
这种"指标正常但业务慢"的情况最迷惑,因为它把排查方向引向了错误的假设——很多人第一反应是"实例规格不够",然后去升配,花了一笔钱,问题依旧。
正确的切入点是:慢在哪个环节? 一个 HTTP 请求的耗时构成大致是:
总耗时 3000ms
├─ 网络往返 ~20ms
├─ 应用层框架处理 ~50ms
├─ 业务逻辑 ~100ms
└─ SQL 执行 ~2800ms ← 真凶
├─ 等待锁 ?
├─ 磁盘 IO ?
└─ 扫描行数 ?
我们加了分段计时,很快确认 2800ms 里绝大部分是单条 SQL 的执行时间,而且是一条看起来极其普通的查询。
二、第一步:把慢 SQL 捞出来
2.1 确认慢日志是开的
这是最容易被忽略的前置条件。很多实例的 slow_query_log 是关的,或者 long_query_time 设成了 10 秒—— 10 秒以下的慢查询根本不会被记录。
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
建议的生产配置:
| 参数 | 建议值 | 说明 |
|---|---|---|
slow_query_log |
ON | 必须开 |
long_query_time |
0.5 ~ 1 | 别用默认的 10 秒,太宽松 |
log_queries_not_using_indexes |
ON(短期) | 抓全表扫描,日志量大时慎用 |
min_examined_row_limit |
1000 | 过滤掉扫描行数少的,减少噪音 |
华为云 RDS 的控制台可以直接开慢日志,也可以在参数组里改。改完记得重新连接才会生效(部分参数需要新会话)。
2.2 用 DAS 做聚合视图
慢日志是一堆原始记录,逐条看效率极低。数据管理服务(DAS)的价值在于聚合:它把 SQL 按模板归并,直接告诉你哪条 SQL 总耗时最高、执行次数最多。
判断优先级看两个维度:
- 总耗时 = 单次耗时 × 执行次数。单次 50ms 但每秒执行 200 次的 SQL,比单次 2 秒但一天跑一次的危险得多。
- 扫描行数 / 返回行数。这个比值是索引效率的直接体现,比值大于 100 基本就是索引有问题。
我们捞出来的真凶是这条:
SELECT id, order_no, user_id, amount, status, created_at
FROM orders
WHERE user_id = 12345
AND status IN (1, 2, 3)
AND created_at >= '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;
看起来毫无问题,user_id 有索引,还带了 LIMIT。但它执行了 2800ms。
三、第二步:EXPLAIN 到底看哪几列
EXPLAIN 输出十几列,真正需要关注的就四个:type、key、rows、Extra。
EXPLAIN SELECT ...;
| 列 | 我们的结果 | 含义 |
|---|---|---|
type |
ALL |
全表扫描,最差的一档 |
key |
NULL |
一个索引都没用上 |
rows |
约 420 万 | 预估扫描行数 |
Extra |
Using where; Using filesort |
过滤靠 where、排序靠临时文件 |
问题一目了然:user_id 明明有索引,为什么 key 是 NULL?
原因藏在 user_id 的字段类型上——索引列是 varchar,而查询条件里的 12345 是数字。MySQL 遇到类型不一致会做隐式转换,把字符串列转成数字,而函数作用于索引列就会导致索引失效。
这就是索引失效的第一种,也是最隐蔽的一种:隐式类型转换。
四、索引失效的六种典型场景
把这次踩的坑和后续排查遇到的整理在一起,这六条覆盖了绝大多数情况:
4.1 隐式类型转换
-- user_id 是 varchar(32),索引失效
WHERE user_id = 12345
-- 正确写法:加引号
WHERE user_id = '12345'
规则很简单:查询条件的字面量类型必须和列类型一致。数字列传数字、字符串列传字符串。批量检查可以这样扫:
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND DATA_TYPE IN ('varchar', 'char', 'text');
把这些列列出来,然后去代码里搜有没有不加引号的比较。
4.2 对索引列使用函数或运算
-- 索引失效:DATE() 作用于索引列
WHERE DATE(created_at) = '2026-10-29'
-- 改成范围查询
WHERE created_at >= '2026-10-29 00:00:00'
AND created_at < '2026-10-30 00:00:00'
同理还有 WHERE id + 1 = 100、WHERE LEFT(name, 3) = 'abc',都应该改写成对常量的运算。
4.3 违反最左前缀原则
联合索引 (a, b, c) 的可用性:
| 查询条件 | 能否用索引 | 用到哪几列 |
|---|---|---|
a = ? |
✅ | a |
a = ? AND b = ? |
✅ | a, b |
a = ? AND b = ? AND c = ? |
✅ | a, b, c |
b = ? |
❌ | 无法使用 |
a = ? AND c = ? |
⚠️ | 只用 a,c 无法用于检索 |
a > ? AND b = ? |
⚠️ | 只用 a,范围查询后 b 失效 |
最后一行是重点:范围查询会截断联合索引的后续列。所以联合索引的列顺序要按"等值在前、范围在后"排。
4.4 前导通配的 LIKE
WHERE title LIKE '%关键词%' -- 失效
WHERE title LIKE '关键词%' -- 可用
前缀不确定的模糊搜索,索引无能为力。真正的解法是全文检索(内置于 MySQL 的 FULLTEXT,或者外接 Elasticsearch),而不是硬凑索引。
4.5 OR 连接了无索引的列
WHERE user_id = '123' OR remark LIKE '%退款%'
只要 OR 两侧有一个条件用不上索引,整个查询就可能退化成全表扫描。能改 UNION 就改:
SELECT ... WHERE user_id = '123'
UNION
SELECT ... WHERE remark LIKE '%退款%'
4.6 优化器主动放弃索引
这一条最反直觉。有时候索引明明可用,EXPLAIN 却显示不用。原因是优化器算了一笔账:如果符合条件的数据占总行数的比例很高(经验阈值在 20%~30% 附近),走索引需要大量回表,还不如直接顺序全表扫。
这种情况不是 bug,是合理的。硬要它用索引可以 FORCE INDEX,但通常没意义——要优化的是查询逻辑本身,比如减少返回列、缩小范围。
五、第三步:重新设计索引
5.1 先修正类型,再谈索引
第一步是把参数改成字符串:
WHERE user_id = '12345'
改完再 EXPLAIN:
| 列 | 结果 |
|---|---|
type |
ref |
key |
idx_user_id |
rows |
约 1200 |
Extra |
Using where; Using filesort |
扫描行数从 420 万降到 1200,耗时从 2800ms 降到 180ms。一个引号省了 2.6 秒。
但 Using filesort 还在——排序仍然要额外处理。
5.2 用联合索引消灭 filesort
我们的查询模式固定为:按 user_id 过滤、按 status 过滤、按 created_at 倒序、取前 20 条。这正好适合一个联合索引:
ALTER TABLE orders
ADD INDEX idx_user_status_time (user_id, status, created_at);
列顺序的依据是"等值在前、范围次之、排序最后":
user_id:等值,且区分度高 → 第一status:等值,但只有几个值(区分度低)→ 第二created_at:用于排序 → 第三
因为 status 是 IN (1,2,3) 而不是单值等值,created_at 的排序其实无法完全利用索引有序性(MySQL 需要做多段合并)。如果排序压力真的很大,可以考虑把 status 从索引里去掉,或者改用 UNION ALL 拆成三个单值查询再合并——但这会让 SQL 变复杂,要看实际收益。
我们实测的结果是:加了联合索引后 Using filesort 消失(这里是 IN 单值场景的一部分走了索引排序),耗时降到 40ms。
5.3 覆盖索引:让查询完全不回表
EXPLAIN 的 Extra 出现 Using index 就是覆盖索引——查询需要的所有列都在索引里,不需要回表查主键。
我们的查询选了 id, order_no, user_id, amount, status, created_at 六列。如果把它们全放进索引,索引会变得很宽,写入成本上升。所以只把最外层列表需要的字段放进去:
ALTER TABLE orders
ADD INDEX idx_cover_list (user_id, status, created_at, order_no, amount);
这里有个取舍要算清楚:
- 覆盖索引的收益:省掉回表,随机 IO 变顺序 IO,大表上可能有 5~10 倍提升
- 覆盖索引的成本:索引体积变大(占磁盘、占 buffer pool)、每次写入要维护更多索引页
经验判断:如果一个查询 QPS 很高、返回行数很少(比如分页前 20 条),值得为它专门建覆盖索引;如果查询本身不频繁,别为它加宽索引。
六、第四步:验证优化效果
改完不看数据等于没改。我们的对比:
| 指标 | 优化前 | 修正类型后 | 加联合索引后 |
|---|---|---|---|
| 执行耗时 | 2800 ms | 180 ms | 40 ms |
| 扫描行数 | 4,200,000 | 1,200 | 20 |
type |
ALL | ref | ref |
Extra |
Using where; Using filesort | Using where; Using filesort | Using where; Using index |
| 回表 | 是 | 是 | 否 |
验证必须在生产同等数据量下做。 测试库只有几万行数据,任何查询都是毫秒级,看不出差别——这也是为什么很多问题只在线上暴露。
压测时至少观察三个指标的前后变化:接口 P99 延迟、RDS 的 CPU 使用率、buffer pool 命中率。我们的 CPU 从 40% 降到了 18%,这解释了一个之前的疑问:为什么 CPU 40% 却觉得慢——因为慢的不是 CPU,是 IO 和等待。
七、防复发:让下一次更快发现
优化单条 SQL 只是治标,真正要做的是建立机制。
7.1 慢日志定期巡检
写个脚本每天扫一遍慢日志聚合,超过阈值的 SQL 推到群里。关键是设阈值:单次超过 1 秒、或者总耗时进入前 10 的都报出来。
7.2 上线前强制 EXPLAIN
在 CI 里加一道检查:所有新增或修改的 SQL,必须是 type 不为 ALL、rows 小于阈值的。这个检查写成半自动化即可——让开发在 Code Review 时贴 EXPLAIN 结果,比全自动拦截更容易推行。
7.3 监控要盯对的指标
| 指标 | 说明 | 异常信号 |
|---|---|---|
| 慢查询数/分钟 | 最直观 | 突然上涨说明有新 SQL 或数据量变化 |
| 活跃连接数 | 连接池是否够用 | 持续打满说明连接泄漏或查询变慢 |
| InnoDB buffer pool 命中率 | 内存是否够 | 低于 95% 说明热数据读不进内存 |
| 磁盘读 IOPS | IO 是否成为瓶颈 | 与查询量同步上涨说明回表过多 |
7.4 表大了要提前规划
我们的 orders 表 420 万行,还不算大。但按增长趋势两年后会到 5000 万行,那时单表索引维护会很吃力。所以:
- 归档历史数据:一年前的订单迁到归档表或冷存储,主表只留活跃数据。这是最有效的手段,往往比任何索引优化都管用。
- 分区表:按时间范围分区,查询能直接裁剪分区。但要注意分区键必须进主键,改动成本不低。
- 读写分离:报表类的大查询走只读实例,别和交易查询抢资源。华为云 RDS 支持挂只读实例,加一个实例比优化十条 SQL 见效更快。
八、常见问题 Q&A
Q1:加了索引反而变慢了,为什么?
三种可能。一是索引选择性太差(比如给性别建索引,只有两三个值,扫索引还不如扫表);二是优化器估算错误,选错了索引,可以用 ANALYZE TABLE 更新统计信息;三是写入型业务索引太多,每次 INSERT 要维护所有索引页,写放大了。
Q2:rows 的数字准吗?
它是估算值,来自索引统计信息,可能偏差很大。要拿准确数字用 EXPLAIN ANALYZE(MySQL 8.0.18+),它会真正执行并给出实际行数。
Q3:能不能无限加索引?
不能。经验上单表索引控制在 5~6 个以内。每个索引都是一棵 B+ 树,写入时全部要更新,还会占用 buffer pool 空间——索引多了,热数据反而被挤出内存,整体性能下降。
Q4:SELECT * 到底有多坏?
坏在两点:无法利用覆盖索引(必须回表),且传输了不需要的列(占网络和内存)。在大表 + 宽表的场景下,改成显式列名往往能带来数倍提升。
Q5:分页 LIMIT 1000000, 20 很慢怎么办?
这是深分页的经典问题,MySQL 必须先扫描并丢弃前 100 万行。解法是用游标分页:记住上一页最后一条的 id,下一次用 WHERE id > 上一页最后id LIMIT 20。业务上不要求跳页时,这个改法几乎没有副作用。
Q6:什么时候该考虑分库分表?
先穷尽其他手段。单表 2000 万行以内(且索引设计合理、SQL 没有硬伤)通常不需要。真到了瓶颈,优先顺序应该是:归档历史数据 → 读写分离 → 分区表 → 分库分表。分库分表是最后一步,因为它带来的复杂度和运维负担是指数级上升的。
九、总结
这次治理最值得记住的不是那条联合索引,而是一个引号。
排查路径完整走下来:分段计时定位到 SQL → 慢日志聚合找到具体语句 → EXPLAIN 看出索引未命中 → 定位到隐式类型转换 → 修正类型 → 重设索引 → 用数据验证。真正解决问题的关键动作,是把 12345 改成 '12345',后面的优化都是在锦上添花。
这提示了一个更普遍的规律:性能问题往往不是"没做优化",而是"某处的基础假设被打破了"。这次是字段类型和查询参数不匹配,下次可能是数据量越过了某个阈值让优化器换了执行计划,再下次是业务代码新增了一个走不通索引的查询条件。
所以真正可持续的做法,不是遇到慢就加索引,而是建立一套"让慢查询能被看见"的机制。看得见,就治得了。
- 点赞
- 收藏
- 关注作者
评论(0)