一次列表接口从30秒到200毫秒的优化实战

作者 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,来自于技术踩坑记录

发表评论

苏ICP备18039580号-2