华为云 RDS for MySQL 慢查询治理:从定位到索引优化的完整路径

举报
辣椒酱来了 发表于 2026/10/31 15:05:51 2026/10/31
【摘要】 接口从 200ms 退化到 3 秒,业务方催、用户投诉,但打开监控只看到一条平平的 CPU 曲线,不知道慢在哪。本文按「发现→定位→分析→优化→验证→防复发」的顺序,完整还原一次真实的慢查询治理:如何用慢日志和 DAS 找到真凶、EXPLAIN 该看哪几列、索引失效的六种典型场景,以及为什么加了索引之后反而更慢了。

一、慢是结果,不是原因

先明确一件事:"数据库慢"几乎从来不是数据库的问题。

我们的订单列表接口某天开始变慢。监控上看: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',后面的优化都是在锦上添花。

这提示了一个更普遍的规律:性能问题往往不是"没做优化",而是"某处的基础假设被打破了"。这次是字段类型和查询参数不匹配,下次可能是数据量越过了某个阈值让优化器换了执行计划,再下次是业务代码新增了一个走不通索引的查询条件。

所以真正可持续的做法,不是遇到慢就加索引,而是建立一套"让慢查询能被看见"的机制。看得见,就治得了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。