要让SQL查询既快又稳,先读执行计划找出真正的瓶颈,然后从索引、查询写法、数据访问量和统计信息四个维度去优化:建合适索引、避免SELECT *、把昂贵的子查询改成JOIN或物化中间结果、限制返回行并分页处理,必要时按批更新与缓存热点数据,持续监控慢查询并基于真实负载迭代改进。

为什么先看执行计划?
很多人一听到“优化”就盲目加索引或改语句,其实真正有用的第一步是看执行计划(EXPLAIN/EXPLAIN ANALYZE)。执行计划告诉你数据库怎样读取数据:是全表扫描、索引范围扫描、还是索引覆盖?如果不知道数据库的具体执行方式,优化就是瞎子摸象。
怎么读一个执行计划(简化步骤)
- 看最耗时的步骤(时间或估计成本),这通常是先优化的目标。
- 看IO方式:是全表扫描(全表读)还是索引扫描(更小的IO)?
- 看行估计与实际行数差异:差别大说明统计信息陈旧或条件选择性估计错了。
- 看联接顺序与联接类型(Nested Loop/Hash/Merge):选择合适的联接方式能大幅改善性能。
四大维度的优化策略
把问题拆成四个部分来做比一次改很多东西要稳妥:索引策略、查询写法、数据访问量(返回行/字段)、运行时环境与统计信息。
1. 索引策略(最常见也最关键)
- 建立覆盖索引:如果索引包含查询所需的所有列,数据库可以仅通过索引返回结果,避免回表(Index Only Scan / Index Covering)。
- 用复合索引注意顺序:WHERE或ORDER BY中先出现的高选择性字段应放在复合索引前面。
- 避免滥用索引:每个索引都会影响写入性能(INSERT/UPDATE/DELETE),索引太多会拖慢写操作。
- 对范围查询和LIKE谨慎设计:前缀匹配可以利用索引,通配符开头(’%abc’)通常不能。
2. 查询写法与重构
小的写法改变常常带来大差异。
- 不要用SELECT *:只选需要的字段,减少数据传输与IO。
- 将子查询改成JOIN(或反之):有时数据库对不同写法的执行计划差异显著,测试哪个更好。
- 避免在WHERE里对列做函数或表达式:例如WHERE DATE(col)=…会阻止索引使用,改为范围判断更友好。
- 使用LIMIT和合理分页:大表分页要用“基于索引的分页”(keyset pagination)而非OFFSET大量跳过。
3. 减少数据访问量
很多场景下问题不是SQL语句多复杂,而是一次性取出的数据太多。
- 分页与分批:导出或后台批处理分批执行,避免一次占满内存。
- 物化中间结果:对于重复使用的复杂聚合,考虑缓存或把结果写入临时表。
- 合理缓存:频繁读取但变化不大的业务数据可以放在缓存层(Redis等),减轻数据库负担。
4. 统计信息与配置
优化器依赖统计信息来估算成本,陈旧统计导致糟糕计划。
- 定期更新统计信息:ANALYZE/UPDATE STATISTICS。
- 配置合理的内存参数:比如work_mem、shared_buffers、innodb_buffer_pool_size等,会直接影响排序、哈希联接和缓存命中率。
- 保持表/索引碎片可控:长期写删会导致页分裂和碎片,必要时重建索引或优化存储。
常见性能问题与具体应对
问题:全表扫描导致慢查询
判断:执行计划里看到大量的Seq Scan/Full Table Scan,且返回行数很少。
应对:
- 添加合适的索引(先确认查询条件的选择性)。
- 如果查询是范围过滤,考虑建立范围友好的索引或分区表。
- 当查询涉及多个筛选字段,使用复合索引而不是多个单列索引(可能导致索引合并成本高)。
问题:ORDER BY + LIMIT但很慢
判断:排序耗时,使用文件排序/外部排序。
应对:
- 如果可能让索引支持ORDER BY(索引列顺序与ORDER BY一致),数据库可以避免显式排序。
- 对于分页深度很大的场景,用基于最后一条记录的keyset分页替代OFFSET。
问题:联接大量表,Nested Loop变慢
判断:执行计划展示多层Nested Loop且内层扫描成本高。
应对:
- 确保用于联接的列有索引。
- 对于较大表之间的联接,Hash Join或Merge Join通常更快,调整内存参数或提示优化器使用不同联接策略。
- 分解复杂查询,先物化部分中间结果再联接也不失为稳妥方式。
实战示例:从慢查询到快查询(MySQL风格)
假设有一张订单表orders,常见查询是查某用户最近的N条已支付订单及关联商品信息。
原始慢查询:
SELECT * FROM orders o JOIN products p ON o.product_id = p.id WHERE o.user_id = 123 AND o.status=’PAID’ ORDER BY o.created_at DESC LIMIT 50;
问题点:
- SELECT * 可能包含大文本字段。
- 如果orders没有合适索引,会全表扫描或先扫描大量行再排序。
改进步骤:
- 只选择需要字段:SELECT o.id,o.product_id,o.created_at,p.name,p.price …
- 建立复合索引:CREATE INDEX idx_orders_user_status_created ON orders(user_id, status, created_at DESC); 这样既限制了user_id+status,又支持按created_at排序。
- 如果产品信息较少变更,可以把常用字段缓存到orders表的冗余列或在业务层缓存,减少JOIN。
| 测试项 | 优化前(ms) | 优化后(ms) |
| 平均响应时间 | 450 | 30 |
| 读取行数 | 12000 | 60 |
工具与监控:让优化有连续性
一个查询优化不是一次性工作,需持续监控。
- 慢查询日志:开启慢查询日志,定期分析Top N慢SQL。
- 执行计划历史:记录不同时间点的执行计划,发现优化器决策变化。
- 性能基线:在发布前后对比慢查询和关键业务的响应时间,避免回归。
- APM与监控:用应用性能监控工具观察SQL在真实请求中的表现,定位端到端瓶颈。
小技巧与防坑清单(工作中常犯的错误)
- 不要盲目copy索引:别把别人的索引直接套用到你自己的业务,不同数据分布会有不同效果。
- 谨慎使用提示(hints):hint可以短期解决问题,但可能掩盖根本原因,数据库版本升级后可能失效。
- 考虑并发和事务:优化单条查询不等于整体性能最优,高并发下锁/事务冲突可能成为瓶颈。
- 避免在写高峰期做大规模DDL:比如建索引或重建表会影响生产写入,优先在低峰期或做在线DDL。
进阶话题:分区与物化视图
当数据量巨大时,单靠索引也不能满足性能需求,这时可以考虑分区和物化视图。
- 分区表:按时间或范围分区可以让查询只扫描相关分区,显著减少IO。
- 物化视图/预计算:把复杂的聚合和联接结果提前计算并定期刷新,适合可接受延迟的场景。
常见命令参考(简洁版)
- 查看执行计划(MySQL):EXPLAIN SELECT …;
- 查看实际执行时间(PostgreSQL):EXPLAIN ANALYZE SELECT …;
- 更新统计信息(MySQL):ANALYZE TABLE table_name;
- 更新统计信息(Postgres):VACUUM ANALYZE table_name;
整理这些要点的时候,我在想,很多时候优化并不只是技术活,也像做菜:火候、配料、时间缺一不可。你可以先把最明显的“盐多了/没加油”问题(比如缺索引或SELECT*)改掉,再慢慢调出更细腻的味道(内存调优、分区、物化视图)。要是遇到具体慢SQL,可以把执行计划贴出来,我可以帮你一步步看它到底哪儿不舒服。就这样,先试着把执行计划当成你的诊断单,慢慢培养对数据库行为的直觉。