优化数据库查询性能的五个实用技巧

周末凌晨三点,你刚被运维电话叫醒,生产数据库CPU飙升到99%,一条原本跑几十毫秒的查询现在需要3分钟。你盯着慢查询日志里那个熟悉又陌生的SQL,焦虑地翻看执行计划——全表扫描、没有索引、嵌套循环几百万次。这不是故事,这是很多开发者的日常。优化数据库查询不是锦上添花,而是系统存活的基本功。我整理五个经过实战锤炼的技巧,每个都对应一个真实事故或性能瓶颈,希望你能拿来就用。

索引的哲学:少即是多,但不是不作为

很多人觉得“加了索引查询一定变快”,于是给每个字段都建上单列索引,结果写操作变慢、磁盘空间爆炸,查出来的执行计划反而走了全表扫描。索引不是越多越好,而是越精准越好。一个复合索引如果顺序设计错误,可能比没有索引还糟糕——因为数据库花了额外时间去读索引却只过滤掉少量数据。

核心原则:将区分度最高的列放在复合索引的最左端。比如查询条件WHERE status = 1 AND created_at > ‘2024-01-01’,如果status只有两个值(0和1),区分度极低,而created_at几乎每条记录都不同。这时应该把created_at放在索引首位。另外避免在一个复合索引中包含过多列,建议不超过4-5列。索引的B+树层数每增加一层,随机IO的成本翻倍,而多列索引会加速层数增长。

还有一个反常识的点:有时候删除无用索引比新建索引优化效果更显著。我见过一个系统上同时有 (a,b)、(a)、(b,a) 三个索引,MySQL优化器选择执行计划时反而困惑,索性全表扫描。删除重复索引后,查询耗时从800ms降到15ms。定期用pt-duplicate-key-checkerinformation_schema检查冗余索引,是运维的隐形福利。

隐式转换:你写的SQL在“偷偷”做全表扫描

几年前我处理过一个线上事故:某订单查询接口耗时暴增10倍。排查发现条件WHERE order_id = 12345中的order_id字段是varchar类型,但传了整数12345。MySQL在执行时会把字段隐式转换为数字,导致索引失效,触发全表扫描。类型转换是造成索引失效的三大罪魁祸首之一(另外两个是函数作用于索引列和LIKE以通配符开头)。

类似地,WHERE date(created_at) = ‘2024-01-01’这种写法也等于让索引列被包裹在函数里。想让索引生效,就绝不能让索引列参与表达式或函数计算。正确做法:WHERE created_at >= ‘2024-01-01’ AND created_at < ‘2024-01-02’。这种“范围查询”不仅能用上索引,还能让优化器做索引合并或范围扫描。

另一个容易忽略的场景是字符集或排序规则不一致。比如关联两个表的字符串字段,一个用utf8mb4_general_ci,另一个用utf8mb4_unicode_ci,虽然肉眼一样,但数据库会认为不同,结果放弃索引进行全表哈希匹配。保持字段字符集和排序规则一致,是跨表关联查询的氧气。可以在建表时统一使用utf8mb4_unicode_ciutf8mb4_0900_ai_ci

覆盖索引:让查询“不用回表”

当查询所需的全部列都包含在一个索引中时,数据库只需扫描索引B+树就能返回结果,完全跳过数据行。这叫覆盖索引,是降低磁盘IO的核武器。每次回表相当于一次随机读,而索引扫描是顺序读,速度差距可达10倍。例如你经常按user_id查询namestatus,那么建一个复合索引(user_id, name, status),即使条件里只有user_id,优化器也可能只扫描索引。

但覆盖索引不能滥用。索引列越多,写入时更新的成本越高。推荐做法:先分析业务中最频繁的慢查询,合理“作弊”。比如一个排行榜查询SELECT id, score, rank FROM users WHERE status=1 ORDER BY score DESC LIMIT 10,如果建一个(status, score, id, rank)的复合索引,就可以完全避免排序和回表。

另有一种进阶用法:延迟关联(Deferred Join)。场景:需要全表分页SELECT FROM blog WHERE type=2 ORDER BY created_at DESC LIMIT 5000,20。由于 LIMIT OFFSET 很大,MySQL会扫描5000行然后丢弃,浪费大量随机IO。优化方法:先使用覆盖索引快速定位需要的主键集合,再关联原表取全量数据。SQL改写为:

SELECT b. FROM blog b INNER JOIN (SELECT id FROM blog WHERE type=2 ORDER BY created_at DESC LIMIT 5000,20) tmp USING (id);

这里内层子查询只需扫描(type, created_at, id)这个小索引,外层用主键回表,速度提升非常明显。延迟关联的核心逻辑是:用索引的局部性代替数据行的随机性。对于大数据量分页,这几乎是必备技巧。

分页不能只靠OFFSET:游标分页拯救响应时间

传统LIMIT M OFFSET N在 N 很大时效率极低,因为数据库必须生成全部结果,然后丢弃前面N行。OFFSET 的本质是“查了再扔”,浪费的计算量随偏移量线性增长。如果你翻到第1000页,可能整张表都被扫描了一遍。真正的优化方案是游标分页:通过记住上一页的最后一条记录的排序字段值,用条件WHERE sort_field > last_value取下一页。

比如博客列表按id降序排列,第一页SELECT FROM posts ORDER BY id DESC LIMIT 20,返回的最小id是1000。第二页就查WHERE id < 1000 ORDER BY id DESC LIMIT 20游标分页的搜索效率恒定,与页码无关,因为索引可以立即定位到条件位置。唯一的代价是前端需要传递上一次的排序字段值,但不能跳页。如果业务必须支持跳页(比如页码输入框),可以结合“先取偏移量再定位”的方式:SELECT id FROM posts ORDER BY id DESC LIMIT 1 OFFSET 1000先拿到第1000页的起始ID,然后用WHERE id <= 该ID分页。虽然还是有OFFSET,但只扫描索引不扫描数据行,速度也快很多。

另外,对于实时性要求不高的分页,可以考虑物化分页:后台定时生成每页数据的快照,前端直接读取静态结果。比如排行榜每小时更新一次,直接用缓存存好第1到第100页的数据。查询要快,就不要每次都问数据库要最新的——缓存和预计算是数据库的替身

利用EXPLAIN做“慢查询”微创手术

很多开发者把EXPLAIN当作摆设,只看type=ALL或者rows=很大就完了。EXPLAIN的输出是一部慢查询的解剖图,每个字段都有丰富的救生信息。比如Extra列显示Using filesort说明没有用到索引排序,Using temporary说明使用了临时表,通常是GROUP BY或DISTINCT没有合适的索引。这时你需要检查排序字段和分组字段是否在同一个复合索引里。

另一个关键点是key_len:它表示索引使用的字节数。key_len越大,说明索引利用的列越多,但也意味着索引宽度大。如果发现possible_keys有多个候选,但实际key是另一个,说明优化器判断了一个更优的代价。你还可以用EXPLAIN FORMAT=JSON查看更详细的代价分析。

但最容易被忽视的是filtered字段(MySQL 5.7+)。它表示在索引条件下,过滤后剩余行数的百分比。filtered很低(比如1%)而rows很大的时候,意味着索引虽然用上了,但过滤效果不佳,需要考虑添加更精炼的条件或者重建索引。索引不只看有没有用,更要看有没有用到位。我习惯在建完索引后跑一个EXPLAIN,对比修改前后的rowstype,确保优化是可量化的。

如果遇到type=ref或者range但速度仍然慢,可能是索引选择性的问题。比如查询WHERE city=‘北京’在千万级表中返回了30万行,即使是索引范围扫描,回表30万次也够呛。这时需要结合索引下推(Index Condition Pushdown)位图索引的思路:MySQL 5.6+支持ICP,可以在存储引擎层用索引字段进行部分过滤,减少回表次数。你可以在Extra中看到Using index condition,这是优化器自动做的。如果你的MySQL版本较老或者关掉了ICP(optimizer_switch='index_condition_pushdown=off'),赶紧打开它。

最后说一句:没有银弹,但有系统方法

数据库查询优化不是一次性的活,而是一套持续改进的流程。每个技巧都对应一个原理:减少数据访问量、减少随机IO、充分利用缓存和索引结构。我建议团队建立这样的习惯:每次上线新功能前,先预估数据量,用EXPLAIN验证核心查询;生产环境中开启慢查询日志,并设置long_query_time=1(或更小),定期分析;每周至少花一小时分析TOP10慢查询,并尝试用上述技巧优化。

记住一个残酷的事实:索引也好,缓存也罢,都是对数据分布和查询模式的妥协。当数据量从百万到千万,从千万到亿时,原先完美的索引可能瞬间失效——因为索引的深度变大了,回表开销非线性增长。这时需要考虑分区表、读写分离、甚至分布式架构。但无论架构怎么演变,上述五个技巧(精准索引、避免隐式转换、覆盖索引、游标分页、EXPLAIN分析)是任何数据量级下的基本功,它们能帮你节省80%的“立即修复”时间。

最后分享一个我亲身经历的教训:曾经为了优化一个接口,我把索引列从3个增加到7个,结果插入时间涨了5倍,查询速度反而下降了。后来用EXPLAIN发现优化器因为索引宽度变大,放弃了原索引而走了另一条更慢的路径。优化的最高境界是“知止”——知道什么时候该加索引,什么时候该删索引,什么时候该改业务逻辑。没有银弹,只有持续学习和实验。希望这些技巧能让你在下一次凌晨三点,安心睡个好觉。