在项目中如何优化SQL慢查询?

慢查询优化的本质是:让数据库用更少的行、更少的随机 I/O、更短的锁持有时间,完成同样的业务语义。

项目里做优化,优先按“定位口径统一 → 找到真实瓶颈 → 用索引与改写降低扫描与回表 → 控制事务与并发 → 验证回归”这条线推进,避免靠猜。

统一定位口径:慢在数据库还是慢在链路

项目现场常见“SQL 慢”的误判来自链路抖动与等待堆积。定位前先把口径对齐到可复现、可量化的指标。

  • 关键时间拆分
    • 执行时间:数据库真正执行 SQL 的耗时(含 CPU、I/O、锁等待)。
    • 排队时间:连接池排队、线程池排队、数据库并发打满后的等待。
    • 传输与反序列化时间:结果集过大导致网络与应用端开销显著。
  • 先看“慢的形态”
    • 偶发尖刺:多为锁冲突、buffer pool 抖动、计划漂移、冷数据 I/O。
    • 稳定偏慢:多为缺索引、索引不命中、扫描行数过大、排序/分组开销大。
    • 随数据增长变慢:典型是谓词不可用索引、分页深、统计信息滞后、分区策略缺失。

用可证据的方式锁定根因:EXPLAIN 与运行时信息

优化不是“加索引试试”,而是围绕执行计划与运行时指标做取舍。

  • 执行计划关注点(以 MySQL/InnoDB 为例)
    • type:从好到差通常是 const/ref/range 优于 index 优于 ALL(全表扫描)。
    • rows:预估扫描行数,越大越危险;和真实返回行数差距大时,统计信息可能失真。
    • Extra:
      • Using filesort:排序未走索引,可能触发磁盘排序或临时表。
      • Using temporary:分组/去重产生临时表,数据量大时非常慢。
      • Using index:覆盖索引(不回表)通常更快。
  • 运行时补充证据
    • 慢日志(slow log)+ EXPLAIN ANALYZE(支持的版本)看真实耗时分布。
    • SHOW ENGINE INNODB STATUS、performance_schema 看锁等待与热点。
    • 观察 buffer pool 命中率与磁盘读,判断是 CPU 绑定还是 I/O 绑定。

索引优化:让过滤与连接“先缩小集合”

大部分慢查询来自“扫描太多行”。索引策略要围绕 WHERE、JOIN、ORDER BY、GROUP BY 的组合语义设计。

1)复合索引按“过滤优先、排序靠后”的原则排

  • 经验法则
    • 等值过滤列在前,范围过滤列在后;ORDER BY 能与索引顺序一致则放在范围列之后往往失效。
    • 能显著减少行数的列优先(高选择性)。
  • 常见示例
1
WHERE tenant_id=? AND status=? AND created_at BETWEEN ? AND ? ORDER BY created_at DESC
    * 候选索引:`(tenant_id, status, created_at)`,减少扫描并支持按 created_at 范围与排序(与具体版本、排序方向相关)。
  • 误区
    • 只给单列索引,导致优化器无法同时利用多个条件,最终仍大范围扫描。
    • 给低选择性列(如性别、布尔值)单独建索引,收益低且写入成本高。

2)覆盖索引:用更窄的索引减少回表

回表:通过二级索引找到主键后,再回到聚簇索引取其它列。回表次数大时很慢。

  • 做法
    • 将查询必要列尽量放进索引(在 MySQL 中索引列本身构成覆盖,不同于部分数据库的 INCLUDE 语法)。
    • 控制索引宽度,避免把大字段(TEXT/BLOB)放入索引。
  • 适用场景
    • 列表页只展示少量字段:SELECT id, name, created_at ... 很适合做覆盖索引。

3)JOIN 索引:驱动表小、被驱动表走索引

  • 核心点
    • JOIN 条件列必须有索引,尤其是被驱动表的关联键。
    • 让过滤更强的一侧做驱动表,先过滤再 JOIN,减少嵌套循环次数。
  • 典型坑
    • JOIN 条件上对列做函数/类型转换,索引失效(如 DATE(created_at)=...CAST(id AS CHAR)=...)。

SQL 改写:减少“不可索引”的表达式与不必要的工作

索引不是万能的,很多慢查询需要改写语义等价但更“可执行”。

  • 避免对索引列做函数与隐式转换
    • 坏:WHERE DATE(created_at)='2026-01-13'
    • 好:WHERE created_at >= '2026-01-13 00:00:00' AND created_at < '2026-01-14 00:00:00'
  • 用 EXISTS 替代不必要的 DISTINCT/去重 JOIN
    • 当目的只是“是否存在关联记录”,EXISTS 往往更省。
  • 控制返回列与结果集大小
    • 禁用 SELECT * 在高频接口中尤为关键;字段越多,I/O、网络与反序列化越重。
  • 深分页改造
    • 坏:LIMIT 100000, 20 会扫描并丢弃大量行。
    • 好:基于“游标/最后一条记录”的 Keyset Pagination:WHERE (created_at,id) < (?,?) ORDER BY created_at DESC, id DESC LIMIT 20(需要相应复合索引)。
  • OR 条件拆分
    • OR 常导致全表扫描;可改为 UNION ALL 分支分别走索引,再在应用或外层聚合去重(视业务语义)。

事务与锁:慢的另一半来自等待

线上慢查询经常不是“算得慢”,而是“等得久”。

  • 缩短事务
    • 减少事务内的业务逻辑与远程调用;把“读-算-写”拆成更短的写事务。
  • 降低锁冲突
    • 热点行更新(计数器、余额、库存)用分片计数、批量合并、异步化等方式降冲突。
    • 避免范围更新误伤:无索引的 UPDATE ... WHERE 会锁大量行甚至升级为更重的锁行为。
  • 隔离级别与一致性取舍
    • 在允许的业务场景用更弱的一致性策略(如读写分离、快照读),换取更低锁等待。

架构层“止血”:把不可避免的重查询移出主库

当 SQL 已较优但业务压力仍大,改造重点转向“减少查询次数、减少主库压力”。

  • 缓存
    • 热点读用 Redis/本地缓存,关键是失效策略与一致性边界清晰。
  • 预聚合与物化
    • 报表类 GROUP BY 大查询用汇总表、物化视图(或离线/准实时 ETL)。
  • 分区与分库分表
    • 时间序列数据用分区表降低单次扫描范围;超大表用水平拆分控制单表尺寸与索引高度。
  • 读写分离
    • 将读流量导向只读副本,注意主从延迟对“读己之写”的影响。

验证与回归:用数据证明“更快且更稳”

优化后必须验证性能与副作用,避免“某条 SQL 快了,整体更慢”。

  • 必做检查点
    • 执行计划是否稳定(避免参数变化导致计划漂移)。
    • QPS 上升时 P95/P99 是否改善,锁等待是否下降。
    • 新增索引对写入延迟、磁盘、备份窗口的影响。
    • 线上灰度观察:错误率、CPU、I/O、连接数、慢日志条数。

常见“高收益”清单(项目里最常见的前 10 类)

  • 缺少 JOIN/WHERE 关键索引导致 type=ALL
  • 复合索引顺序不当,过滤不生效或排序走 filesort
  • 深分页 LIMIT offset
  • SELECT * 与超大结果集
  • 索引列函数/隐式转换
  • OR 导致全表扫描
  • GROUP BY/ORDER BY 触发临时表与磁盘排序
  • 长事务与热点更新导致锁等待
  • 统计信息过期或参数敏感导致计划漂移
  • 冷数据查询引发大量随机 I/O(可用分区、冷热分离、归档缓解)