你以为SQL优化就是加索引?恭喜你错过了真正的性能杀手

2026-07-25 2 0

你以为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页?

五、写在最后

数据库优化是个系统工程,但很多人把它简化成了"加索引"。索引有用,但它是最后一道防线,不是第一反应。

正确的优化顺序应该是:

  1. 先看查询逻辑——有没有 N+1?有没有 SELECT *?有没有不必要的全表扫描?
  2. 再看业务需求——真的需要分页拉全表吗?缓存能用吗?
  3. 然后考虑索引——在逻辑优化完之后,给高频查询加合适的索引
  4. 最后考虑架构——读写分离?分库分表?ElasticSearch?

很多人跳过了前三步直接到第四步,问就是"加机器不行吗"——行,你加机器,DBA跑路。

纸上得来终觉浅,绝知此事要躬行。下次遇到性能问题,先打开 EXPLAIN 看看,别急着加索引。

祝你的数据库活着。(2)


(1)本文适合有至少一次"线上事故"经验的后端开发者。没被数据库教做人的,说明你还没被社会毒打过。欢迎对号入座。
(2)如果你的DBA看到这篇文章想打人,说明你可能说出了他憋了很久不敢说的话。

相关文章

MySQL事务隔离:那些年我把数据库读脏了的故事
别再写100个if-else了:我用策略模式把代码行数砍到脚踝价
API网关不会告诉你的5件事:生产环境教会我的那些”意外”
你的日志在骗你:后端可观测性的七个反直觉真相
还在为部署 AI 工具熬夜?小龙虾帮你躺平上线 🚀
REST很好,但别把它当成宗教来信

发布评论