你以为SQL优化就是加索引?恭喜你错过了真正的性能杀手
做过几年后端开发的,谁没被问过"这个接口慢怎么优化"?十个有九个第一反应是"加个索引吧"。
兄弟,你可能连问题在哪都没搞清楚。
我见过太多团队,数据库CPU飙到90%,DBA疯狂加索引,结果问题依旧。最后发现罪魁祸首是代码里一个 N+1查询,或者某个憨憨一口气查了全表20万行只为了在前端显示前10条。
今天聊点真实的——那些教科书不教、但线上会教你做人(1)的SQL优化盲区。
一、索引不是万能药,滥用索引反而是灾难
很多人把索引当神器。有性能问题?加索引!查询变慢?加索引!数据库卡了?批量加索引!
好家伙,你这是养蛊呢?
索引是有代价的。每次 INSERT/UPDATE/DELETE,所有相关的索引都得维护。这意味着你用空间换时间(读快了),但写操作变慢了。如果你读写比是7:3还好,要是是个写密集型业务,索引加越多死得越快。
更骚的是联合索引的顺序。很多人知道"最左前缀原则",但写出来的索引长这样:
KEY idx_a_b_c (status, created_at, user_id)
然后查询条件是 WHERE user_id = ? AND status = ?。
这个索引对这查询毫无用处,因为 user_id 在中间断了。最左前缀原则不是说从左边开始就行,而是你查询的条件必须从最左边开始连续匹配。
所以你辛辛苦苦建的索引,线上跑了三个月,一查 EXPLAIN,type 是 ALL,全表扫描。你还纳闷:我明明加了索引啊?
正确的做法:每个重要查询都跑 EXPLAIN,别猜,用证据说话。
二、N+1查询——隐藏的数据库杀手
这是我认为在实际项目中造成最多性能问题的元凶,没有之一。
什么是N+1?简单说就是:你先查了N条记录,然后循环里对每条记录又单独查了一次详情。
看看这段"优雅"的代码:
// 查出100个用户
List<User> users = userMapper.listAll();
// 打印每个用户的订单数量
for (User user : users) {
int orderCount = orderMapper.countByUserId(user.getId());
System.out.println(user.getName() + "有" + orderCount + "个订单");
}
这段代码执行了多少次SQL?答案是101次。1次查用户,100次查订单。
100个用户看起来不多?好,现在想象一个列表页要展示订单相关的用户数据,线上高峰期一秒100个请求,每个请求触发101次SQL...你的数据库连接池在燃烧。
怎么解?JOIN一下或者用 IN 语句批量查:
// 优化后:2次SQL解决
List<User> users = userMapper.listAll();
List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList());
Map<Long, Integer> orderCountMap = orderMapper.countByUserIds(userIds);
// 然后直接用Map.get(user.getId())取值
从101次查询变成2次,性能提升50倍,数据库负载直接降一个数量级。这就是区别。
三、越"智能"的ORM越容易写出烂SQL
现在很多ORM框架设计得很"人性化",人性化到你自己都不知道生成了什么SQL。
我见过一个实际案例:某个列表接口超时,排查发现 ORM 生成的 SQL 是这样的:
SELECT * FROM orders
LEFT JOIN users ON orders.user_id = users.id
LEFT JOIN products ON orders.product_id = products.id
LEFT JOIN categories ON products.category_id = categories.id
WHERE orders.status = 'paid'
ORDER BY orders.created_at DESC
LIMIT 20;
这查询本身不复杂,但问题在哪?
SELECT * 。
orders表有80个字段,users表有40个,products表有60个,categories表有15个。四表关联返回了多少数据?每一行是一个"幽灵行"——你要的只是分页的20条,但数据库引擎为了这20条实际上扫描了全表。
改成只查需要的字段:
SELECT orders.id, orders.amount, orders.created_at,
users.name, users.email,
products.name AS product_name,
categories.name AS category_name
FROM orders
LEFT JOIN users ON orders.user_id = users.id
LEFT JOIN products ON orders.product_id = products.id
LEFT JOIN categories ON products.category_id = categories.id
WHERE orders.status = 'paid'
ORDER BY orders.created_at DESC
LIMIT 20;
数据量从可能的几MB/行变成几百字节/行,查询时间从3秒变成30毫秒。
记住:永远不要在生产环境用 SELECT *,这不是最佳实践,这是基本素养。
四、分页的坑——你的OFFSET在谋杀你的数据库
经典分页怎么写?
SELECT * FROM logs WHERE created_at > '2024-01-01'
ORDER BY id DESC LIMIT 100 OFFSET 10000
看起来很正常对吧?
问题在于 OFFSET 的语义:数据库要把前10010行都扫描一遍,然后扔掉前10000行,返回第10001到10100行。
当 OFFSET 是10000的时候,你其实扫描了10010行,但只要了100行。扫描/返回比是100:1,这效率简直是在侮辱数据库。
更可怕的是业务增长:今天 OFFSET 10000 跑得还行,下个月表大了变慢,再下个月直接超时。
优化的思路是游标分页(Keyset Pagination):
-- 第一页
SELECT * FROM logs WHERE created_at > '2024-01-01'
ORDER BY id DESC LIMIT 100
-- 后续每一页:记住上一页最后一条的id
SELECT * FROM logs WHERE created_at > '2024-01-01'
AND id < #{lastId}
ORDER BY id DESC LIMIT 100
不管翻到第几页,每页查询都是固定的少量数据扫描,性能恒定。这就是Twitter、微博这些大厂的分页实现方式。
代价是:无法跳页(不能直接到第50页)。但说实话,你的用户有多少人会翻到第50页?
五、写在最后
数据库优化是个系统工程,但很多人把它简化成了"加索引"。索引有用,但它是最后一道防线,不是第一反应。
正确的优化顺序应该是:
- 先看查询逻辑——有没有 N+1?有没有 SELECT *?有没有不必要的全表扫描?
- 再看业务需求——真的需要分页拉全表吗?缓存能用吗?
- 然后考虑索引——在逻辑优化完之后,给高频查询加合适的索引
- 最后考虑架构——读写分离?分库分表?ElasticSearch?
很多人跳过了前三步直接到第四步,问就是"加机器不行吗"——行,你加机器,DBA跑路。
纸上得来终觉浅,绝知此事要躬行。下次遇到性能问题,先打开 EXPLAIN 看看,别急着加索引。
祝你的数据库活着。(2)
(1)本文适合有至少一次"线上事故"经验的后端开发者。没被数据库教做人的,说明你还没被社会毒打过。欢迎对号入座。
(2)如果你的DBA看到这篇文章想打人,说明你可能说出了他憋了很久不敢说的话。