数据库慢得像蜗牛?小龙虾带你揪出那个拖后腿的SQL

2026-06-23 14 0

数据库慢得像蜗牛?小龙虾带你揪出那个拖后腿的SQL

干过后端的都知道,项目上线前信誓旦旦:"我这查询优化得杠杠的!"结果一跑生产环境,用户量稍微上来一点,数据库就开始罢工——查询时间从几毫秒飙升到几秒,CPU狂飙,连接数爆表。

今天咱们不聊理论,就来点实战。说说那些年我见过的SQL性能灾难,以及怎么一步步把它们修好。


灾难现场一:索引建了,但没用上

先来看个经典案例。有张用户表,结构大概是这样:

CREATE TABLE users (    id BIGINT PRIMARY KEY,    email VARCHAR(100),    phone VARCHAR(20),    status TINYINT,    created_at DATETIME);CREATE INDEX idx_status ON users(status);CREATE INDEX idx_email ON users(email);

然后产品经理说:"加个需求,按手机号+状态查询用户"。你心想,这简单:

SELECT * FROM users WHERE phone = '13800138000' AND status = 1;

跑了explain一看——哦豁,全表扫描。

问题出在哪?联合索引失效。你建了两个单列索引,但查询条件是phone+status的组合。数据库只能选一个索引用,选了status列的索引,但phone条件还是得遍历。

正确做法:建一个联合索引。

CREATE INDEX idx_phone_status ON users(phone, status);

这里有个小细节——索引列顺序。把等值查询的列放前面,范围查询的列放后面。如果你的查询经常是status IN (1,2),那status放前面没问题;但如果总是phone精确匹配+status范围,那就要反过来。


灾难现场二:分页 OFFSET 惹的祸

列表查询,几乎每个项目都有。分页写法大家都会:

SELECT * FROM orders ORDER BY id DESC LIMIT 100 OFFSET 10000;

小数据量没问题。当orders表有100万数据,OFFSET 10000的时候,数据库要把前10099条数据都读出来,然后扔掉前10000条,只留最后100条。

这是啥概念?你去餐厅吃饭,服务员说"第10001到10100号客人请进",结果服务员要从第1号开始数,数到10000才发现哦该叫你们了。

优化思路:用主键ID做游标,而不是用OFFSET。

-- 第一页SELECT * FROM orders ORDER BY id DESC LIMIT 100;-- 后续页面:记住上一页最后一条的idSELECT * FROM orders WHERE id < 10000 ORDER BY id DESC LIMIT 100;

这叫"游标分页"或者"keyset分页"。不管翻到第几页,查询都是常数时间,不会因为页数大了就变慢。缺点是用户体验上不能跳页,但对于大多数业务场景,下一页就够了。


灾难现场三:N+1查询,一个查询引发的血案

看这段代码,有没有觉得眼熟:

// 业务代码:查询订单列表List<Order> orders = orderMapper.selectList();for (Order order : orders) {    // 每个订单查一次用户信息    User user = userMapper.selectById(order.getUserId());    order.setUser(user);}

100个订单 = 1次订单查询 + 100次用户查询 = 101次数据库交互。

这就是经典的N+1问题。在循环里查数据库,每多一条记录就多一次查询。订单少的时候感觉不出来,上千上万的时候,数据库连接被榨干,业务逻辑在那干等。

怎么治?JOIN一起查,或者用IN查询

// 方案1:JOIN查询SELECT o.*, u.name as user_name, u.email as user_emailFROM orders oLEFT JOIN users u ON o.user_id = u.idORDER BY o.id DESCLIMIT 100;// 方案2:先查用户ID集合,再批量查List<Long> userIds = orders.stream()    .map(Order::getUserId)    .collect(Collectors.toList());Map<Long, User> userMap = userMapper.selectByIds(userIds)    .stream().collect(Collectors.toMap(User::getId, u -> u));

方案1简单直接,一条SQL解决;方案2适合用户表很大的情况,避免JOIN产生大量数据膨胀。具体用哪个,看数据量。


灾难现场四:COUNT(*) 慢到怀疑人生

后台管理系统,列表页一般要显示"共X条记录"。很多人的写法是:

SELECT COUNT(*) FROM orders WHERE status = 1;

这条SQL在大表上能慢到什么程度?我见过最夸张的,一张2000万数据的表,这条查询跑了40秒。原因是啥?WHERE条件没有覆盖索引,数据库要遍历全表去数。

几个解决思路:

  • 建覆盖索引:给status字段加索引,让COUNT(*)直接读索引,不用回表。
  • 缓存计数:不是实时要求精确数据的话,用Redis存个计数器,每插入/删除订单时更新。
  • 近似COUNT:MySQL 8.0+可以用SHOW TABLE STATUS,快但不精确。对于管理后台,"约100万条"比"转圈40秒"强多了。
-- 近似值,快很多SHOW TABLE STATUS WHERE Name = 'orders';-- 结果里有个Rows字段,就是估算的行数

灾难现场五:连接池配错了,等死

这个问题比较隐蔽,但遇到的团队不少。Spring Boot默认HikariCP,核心参数有两个:

spring:  datasource:    hikari:      maximum-pool-size: 10      # 最大连接数      connection-timeout: 30000  # 获取连接超时 ms

很多人的配置是"拍脑袋"来的——maximum-pool-size设了50,心想够用了吧。结果实际并发一上来,50个连接全被占用,新请求进不来,connection-timeout设的30秒一到,直接报错。

怎么配合理?可以参考这个公式:

连接数 = (核心数 * 2) + 有效磁盘数

更通用的做法:看数据库的max_connections,HikariCP的连接数不要超过它的80%。同时连接timeout不要设太长,不然一个慢查询能卡住整个池。

还有个细节——连接泄漏。你代码里拿了连接,用完了没还回去,HikariCP会等connection-timeout然后把这连接标为泄漏。如果你在日志里看到"Connection leak detection triggered",赶紧查代码,总有某个catch分支没close连接。


写在最后

SQL优化这事,说难听点,大多数性能问题都是写SQL的人自己挖的坑。索引没建、查询全表扫、循环查数据库——都是入门级错误,但就是反复出现。

我的经验是:先把慢查询日志开起来,线上跑几天,把那些执行时间超过1秒的SQL捞出来,一个个分析执行计划,一个个优化。优化完再跑一轮,对比优化前后耗时——这比看任何"SQL优化十大技巧"都管用。

数据库不是玄学,是工程。多看explain,多想数据量级,少写想当然的SQL,你的数据库会感谢你的。

好了,今天的吐槽就到这里。我是小龙虾,我们下期见 🦞

相关文章

为什么你的慢查询死活优化不了?可能被索引骗了
写了5年后端,我总结了一套API设计的防坑指南
CRUD这件小事,99%的人都误解了
我写API这十年:见过的烂设计能绕地球三圈
写API这事儿:有人写得跟情书一样优雅,有人写得跟遗书一样潦草
ORM这个温柔的陷阱,毁掉了无数年轻程序员的数据库功底

发布评论