你以为加了索引就能飞?SQL优化路上的那些自我感动
做后端开发的,谁没加过几个索引呢?慢查询来了,加索引。超时报警了,加索引。领导问为什么慢,加索引。
结果呢?该慢还是慢,数据库CPU还是飘红,有些表加完索引反而更慢了。
今天说点反直觉的——很多人以为在做SQL优化,实际上在搞破坏。或者说,在用正确的方法做错误的事情。
玄学一:索引越多越好
我见过一个表40多个索引的,每一个都是"需要"的。开发人员振振有词:这个字段要查,那个字段要排序,另一个字段要过滤。
听起来没问题。但你知道InnoDB的索引原理吗?每加一个索引,INSERT的时候就要多写一份B+树。UPDATE的时候每个索引都要更新。索引不是免费的,是要付出代价的。
更骚的操作是:建了个联合索引(idx_a_b_c, a, b, c),然后单独又建了idx_a。问就是"怕查单个字段慢"。实际上联合索引的最左前缀原则已经覆盖了这个场景,多余的索引就是在吃存储和更新开销。
正确的思路:索引要按查询模式来设计,不是按字段堆。如果你的查询是WHERE a = ? AND b > ? ORDER BY c,那就建(a, b, c)的联合索引,但只建这一个,而不是拆成三个单独索引。
玄学二:EXPLAIN看没看?看了,但看错了
说"我已经看过EXPLAIN了"的后端,十个有八个其实没看懂。常见错误:看到type是index就觉得快了,实际上index只是全表扫描的另一种形式。
看EXPLAIN要关注这几个字段:
- type:至少要到ref,最好是const。ALL是全表扫描,index是全索引扫描,都不算快
- key:实际用的索引名字,别光看possible_keys多
- rows:扫描行数,100万行和100行的差距是质变
- Extra:这个字段最容易忽略但信息量最大。Using filesort、Using temporary就是性能杀手
很多人口中的"已经优化过了",只是因为没看到全表扫描。实际上Extra里赫然写着Using temporary; Using filesort,只是不知道这俩玩意儿代表什么意思。
玄学三:分页偏移量大,就该用LIMIT OFFSET
分页是经典难题。很多人直觉上就这么写:
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 10000
一千条的时候还行,一万条开始卡,十万条直接超时。为什么?因为数据库要扫描10020行,然后扔掉前10000行,只留20行。
更深层的问题是:LIMIT OFFSET的深度分页是没法优化的,索引也救不了。只要OFFSET足够大,每一页都会越来越慢。
正确的方案是游标分页:
SELECT * FROM orders WHERE id < #{last_id} ORDER BY id DESC LIMIT 20
这一页查完记住最后一个id,下一页用WHERE id < 那个id。无论翻多少页,时间都是稳定的O(1)。当然,前端得改分页交互,但这是值得的。
如果你的产品经理拒绝改前端交互怎么办?那就想办法让用户别翻那么深。Google搜索为什么只有前1000条?是有道理的。
玄学四:JOIN要少用,能拆就拆
有一种说法在江湖上流传:JOIN性能很差,要少用。解决方案是拆成多条SQL,在代码里拼结果。
这个说法对了一半。JOIN确实可能产生大表笛卡尔积,但问题在于"拆成多条SQL"也不一定快——你的代码跑多一次网络Round Trip,数据库少做一次优化器和Join执行的开销,总体未必省钱。
更重要的是:如果Join的两张表都有合适的索引,数据库执行器可以流水线执行,比你拆成两条SQL再在内存里聚合要快得多。
真实场景是:JOIN慢通常不是因为JOIN本身,而是因为关联字段没索引,或者参与JOIN的表太大了没建条件过滤。优化JOIN的第一步不是拆开,而是检查JOIN条件。
当然,那种SELECT * FROM large_table CROSS JOIN small_table(没有ON条件)的骚操作,确实该拆。那是业务逻辑问题,不是JOIN的问题。
玄学五:COUNT(*) = 0就是空表,可以安全删数据
这条很多人可能不服气。COUNT(*)明明很准啊?
问题在于MVCC。InnoDB是支持多版本并发控制的,普通SELECT不加锁是读快照,而COUNT(*)是实时统计行数,需要计算当前可见的版本。
高并发场景下,一个大表的COUNT(*)可能跑几十秒,因为它需要遍历所有可见版本。而WHERE条件的SELECT如果能利用索引,直接定位到一条记录就返回,快得多。
更坑的是,有些运维脚本会执行DELETE FROM table WHERE COUNT(*) > 0这种操作——以为删起来会很轻量,结果把数据库打死了。
如果只是想判断是否有数据,用SELECT 1 LIMIT 1比COUNT(*)快。不关心具体有多少行,只关心有没有行的时候,别用COUNT(*)。
玄学六:覆盖索引就是最优解,无脑怼上去
覆盖索引确实是个好东东——查询的字段都在索引里,不用回表,直接从索引里返回结果,爽得很。
但很多人不知道的是:如果你的索引是(idx_a, idx_b),但WHERE条件是WHERE b = ?,这个索引用不上。因为最左前缀原则要求查询条件必须包含idx_a。
另一种翻车现场:WHERE a > 100 ORDER BY b。这个查询覆盖索引用不了,因为a是范围查找,索引在a>100之后的顺序是乱的,无法保证b的有序。
覆盖索引是好东西,但要用对场景:
- 等值查找的字段放前面
- ORDER BY的字段放后面
- 范围查找通常会中断索引的有序性
写SQL的时候脑子里要有一张索引B+树的图,想象数据是怎么排列的。索引设计本质上是排列数据,让查询能沿着路径高效走下去。
玄学七:SQL优化是DBA的事,业务代码不用管
这大概是流传最广的误解。业务工程师觉得:SQL嘛,交给DBA优化就好了,我写业务逻辑就行。
实际上DBA能优化的是索引配置和服务器参数,真正的病根往往在SQL本身。你一个SELECT *把五十个字段都拉出来,然后让DBA优化这个慢查询?对不起,无解。
或者WHERE条件里用了函数:WHERE YEAR(created_at) = 2026。这种写法索引直接失效,再牛的DBA也救不了。
正确姿势是:写业务代码的时候就脑子里过一遍SQL:查询条件有没有索引?返回字段是不是必要的?有没有隐式类型转换?有没有函数调用?这些DBA管不了,得自己管。
最后说个真实的段子:有个项目慢查询告警,DBA加了两个索引,优化了三天没效果。后来定位到问题是一个工程师写的SQL:WHERE phone = #{phone},而phone参数传的是字符串"13800138000",数据库做隐式转换,索引失效。改成显式转换类型后,0.3秒变0.01秒。DBA内心:???
总结一下
SQL优化这事儿,说难也难说简单也简单。难在很多坑藏得很深,简单在套路就那些。
记住几个原则:
- 索引是手段不是目的,不是越多越好
- EXPLAIN要真看懂,别光看个type列
- 深度分页是性能杀手,能不用LIMIT OFFSET就不用
- JOIN不是洪水猛兽,滥用才是
- COUNT(*)不一定是最好用的判断方式
- 覆盖索引要用对场景,不是无脑怼
- SQL优化是每个人的责任,不只是DBA的
下次遇到慢查询,别急着加索引。先问自己一句:这个SQL,真的写对了吗?
毕竟,加索引治标不治本,写好SQL才是真的优化。