作者 lucy · 2026-04-17
⚠️ 声明:本文相关内容仅供参考,实际效果因场景不同可能差异很大,请结合自身情况判断,谨慎参考。
背景
上周五下午,测试环境突然传来噩耗:某个「福利兑换记录列表」接口响应时间超过 30 秒,前端页面直接超时崩溃。项目用的是 Java + 关系型数据库(远程阿里云服务器),用常见 ORM 框架做数据访问。接口逻辑看起来很简单——查询用户兑换记录列表,支持分页和状态筛选。
现象
前端反馈接口偶发超时,F12 打开 Network 看了一下,最慢的时候超过了 45 秒。数据库服务器 CPU 偶尔飙高,但运维排查了一圈说服务器本身没什么问题。初步判断是 SQL 层面的问题。
排查过程
第一步:开启慢查询日志
先查看数据库的慢查询日志配置:
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志(临时生效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2; -- 超过2秒的记录
SET GLOBAL slow_query_log_file = '/var/lib/mysql/slow.log';
第二步:抓到了罪魁祸首
慢查询日志里赫然躺着一堆超时 SQL,核心问题是下面这条:
SELECT * FROM exchange_record
WHERE user_id = 'xxx'
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
这条 SQL 在测试环境执行只需要 0.3 秒,但上了生产环境(有几十倍数据量)直接跑到 28 秒。
第三步:EXPLAIN 分析
EXPLAIN SELECT * FROM exchange_record
WHERE user_id = 'xxx'
AND status = 1
ORDER BY create_time DESC
LIMIT 20;
结果让人倒吸一口凉气:type=ALL(全表扫描),rows=218000(扫了 21 万行)。没有利用任何索引。
第四步:N+1 查询问题
用 Arthas 抓了一下方法调用链路,发现还有第二个问题——N+1 查询。原始代码是这样的:
// Java 代码(简化版)
List<ExchangeRecord> records = recordMapper.selectList(queryWrapper);
for (ExchangeRecord record : records) {
// 循环里逐条查用户信息
UserInfo user = userMapper.selectById(record.getUserId());
record.setUserInfo(user);
// 再查一下商品信息
GoodsInfo goods = goodsMapper.selectById(record.getGoodsId());
record.setGoodsInfo(goods);
}
列表 20 条数据,每个循环里 2 次额外查询,总共 1 + 20×2 = 41 次 数据库请求。这在低并发测试环境感觉不出来,一上生产就原形毕露。
解决方案
① 建立复合索引
根据查询条件 user_id + status + create_time 建复合索引:
-- 建立复合索引(按区分度从高到低排列)
ALTER TABLE exchange_record ADD INDEX idx_user_status_time
(user_id, status, create_time DESC);
索引建立后,同样的 SQL 再跑 EXPLAIN:
type=range; key=idx_user_status_time; rows=18; Extra=Using index condition
从扫 21 万行变成了只扫 18 行,查询时间从 28 秒降到 ~80 毫秒。
② 批量查询替代 N+1
// 一次性查出所有关联数据
List<ExchangeRecord> records = recordMapper.selectList(queryWrapper);
List<Long> userIds = records.stream().map(ExchangeRecord::getUserId).distinct().toList();
List<Long> goodsIds = records.stream().map(ExchangeRecord::getGoodsId).distinct().toList();
// 批量查用户和商品信息(各1次查询)
Map<Long, UserInfo> userMap = userMapper.selectBatchIds(userIds)
.stream().collect(Collectors.toMap(UserInfo::getId, u -> u));
Map<Long, GoodsInfo> goodsMap = goodsMapper.selectBatchIds(goodsIds)
.stream().collect(Collectors.toMap(GoodsInfo::getId, g -> g));
// 内存里组装
records.forEach(r -> {
r.setUserInfo(userMap.get(r.getUserId()));
r.setGoodsInfo(goodsMap.get(r.getGoodsId()));
});
改造后:1 + 1 + 1 = 3 次数据库请求(原来最多 41 次),并发性能大幅提升。
③ 引入本地缓存
商品信息变化不频繁,加上本地缓存(Caffeine)进一步扛并发:
@Cacheable(value = "goods", key = "#goodsId")
public GoodsInfo getGoodsById(Long goodsId) {
return goodsMapper.selectById(goodsId);
}
优化效果
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P99 响应时间 | 30,000 ms | 200 ms | ✅ 150 倍 |
| 数据库 QPS | 高峰期 4000+ | 高峰期 120 | ✅ 降低 97% |
| CPU 使用率 | 偶发 90%+ | 稳定 25% | ✅ 恢复正常 |
| 超时错误率 | 约 12% | 0% | ✅ 完全消除 |
总结与教训
- 索引是最基础的优化:复合索引字段顺序要参考查询条件的区分度,区分度高的放前面。
- 永远警惕 N+1 查询:循环查数据库是性能杀手,批量查 + 内存组装是标准解法。
- 善用缓存:读多写少的数据用缓存扛量,数据库连接是稀缺资源。
- 慢查询日志要长期开:别等问题爆发才想起来开,提前发现才能主动优化。
- EXPLAIN 是基本功:任何慢 SQL 先看执行计划,确认是否有全表扫描。
这次优化从发现到修复上线用了不到两个小时,但效果非常显著。性能问题往往不在「架构设计」这种宏观层面,而藏在每一行写得不注意的 SQL 和每一个顺手写的 for 循环里。写代码时多看一眼数据库调用链路,真的能省很多事后救火的麻烦。
📡 来源:个人实战总结,2026-04-17,来自于技术踩坑记录