接口性能优化实战——从压测发现瓶颈到代码优化的完整案例
接口性能优化实战——从压测发现瓶颈到代码优化的完整案例
适读人群:Java后端工程师、后端技术负责人 | 阅读时长:约16分钟 | 核心价值:通过一个真实的完整案例,展示从压测发现问题到代码优化落地的完整过程
一个搜索接口的性能优化历程
2022年初,我们负责的商品搜索接口,压测结果如下:
- 50并发:P99 = 380ms,TPS = 420,错误率 = 0
- 100并发:P99 = 2100ms,TPS = 380,错误率 = 1.2%
- 200并发:P99 = 8900ms,TPS = 280,错误率 = 15%
TPS没有随着并发增加而增加,反而下降了。从50并发到200并发,TPS从420降到280,系统明显遇到了瓶颈。
业务要求这个接口支撑500并发,P99 < 300ms,TPS > 2000。
我花了大约两周时间,把P99从2100ms降到了180ms,TPS从380提升到了2800。
完整过程记录如下。
第一轮排查:系统资源层
第一步看资源监控:
压测50并发时:CPU 35%,内存正常,网络IO正常 压测100并发时:CPU 78%,内存正常,网络IO正常 压测200并发时:CPU 95%+,频繁GC
CPU成为瓶颈,进一步排查是什么在消耗CPU。
# 找到CPU消耗最高的线程
top -H -p $(pgrep -f 'java.*SearchService')
# 记录CPU最高的几个线程的NID(十六进制)
# 例:PID 12345,十六进制是 0x3039
# 在线程Dump里找到对应线程
jcmd $(pgrep -f SearchService) Thread.print | grep -A 30 "nid=0x3039"发现CPU最高的线程都在执行同一段代码:com.example.search.ProductSearchServiceImpl.buildSearchResponse
第二轮排查:Arthas trace
# 启动Arthas
java -jar arthas-boot.jar $(pgrep -f SearchService)
# trace这个方法,采样100ms内的调用
trace com.example.search.ProductSearchServiceImpl buildSearchResponse输出:
`---[45.678ms] com.example.search.ProductSearchServiceImpl.buildSearchResponse()
+---[0.012ms] buildBaseInfo()
+---[0.089ms] fillProductCategory()
+---[38.234ms] fillProductImages() ← 占了84%时间!
+---[5.891ms] fillProductStock()
+---[0.234ms] buildResponse()fillProductImages()占了84%的时间,深挖这个方法:
trace com.example.search.ProductSearchServiceImpl fillProductImages输出:
`---[38.234ms] fillProductImages()
+---循环20次:
| +---[1.8ms] imageService.getImagesByProductId(productId) ← N+1查询!发现了:搜索返回20个商品,对每个商品单独查一次图片,共20次数据库查询。
第三轮优化:消灭N+1查询
原代码:
public List<ProductVO> fillProductImages(List<ProductVO> products) {
for (ProductVO product : products) {
// N+1问题:每次循环都查一次数据库
List<ProductImage> images = imageService.getImagesByProductId(product.getId());
product.setImages(images);
}
return products;
}优化后:
public List<ProductVO> fillProductImages(List<ProductVO> products) {
if (products.isEmpty()) return products;
// 1次查询取回所有产品的图片
List<Long> productIds = products.stream()
.map(ProductVO::getId)
.collect(Collectors.toList());
List<ProductImage> allImages = imageService.getImagesByProductIds(productIds);
// 按productId分组
Map<Long, List<ProductImage>> imageMap = allImages.stream()
.collect(Collectors.groupingBy(ProductImage::getProductId));
// 填充
products.forEach(product ->
product.setImages(imageMap.getOrDefault(product.getId(), Collections.emptyList()))
);
return products;
}修改后重新压测:
- 100并发:P99 = 420ms,TPS = 780,错误率 = 0
- 进步明显,但P99还是超标
第四轮排查:Redis缓存命中率
再次trace,现在fillProductStock()变成了最慢的部分:
`---[8.234ms] fillProductImages() # 优化后只要8ms了
`---[15.891ms] fillProductStock() # 现在变成最慢的查看库存查询代码,发现有Redis缓存,但命中率只有12%:
public Integer getStockCount(Long productId) {
String key = "stock:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return Integer.parseInt(cached);
}
// 缓存未命中,查数据库
Integer stock = stockDao.getStock(productId);
// 缓存1分钟
redisTemplate.opsForValue().set(key, stock.toString(), 1, TimeUnit.MINUTES);
return stock;
}缓存TTL只有1分钟,而商品库存数据更新不频繁(大多数情况下每小时更新一次)。缓存失效太快,大量请求穿透到数据库。
优化:
public Integer getStockCount(Long productId) {
String key = "stock:" + productId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return Integer.parseInt(cached);
}
Integer stock = stockDao.getStock(productId);
// 缓存时间改为30分钟,加随机抖动避免缓存雪崩
int ttl = 1800 + ThreadLocalRandom.current().nextInt(600); // 30-40分钟
redisTemplate.opsForValue().set(key, stock.toString(), ttl, TimeUnit.SECONDS);
return stock;
}同时把单次查库存改为批量查询(和图片一样的N+1优化):
// 批量查询Redis,一次网络往返取多个key
public Map<Long, Integer> batchGetStockCounts(List<Long> productIds) {
List<String> keys = productIds.stream()
.map(id -> "stock:" + id)
.collect(Collectors.toList());
List<String> cachedValues = redisTemplate.opsForValue().multiGet(keys);
Map<Long, Integer> result = new HashMap<>();
List<Long> missedIds = new ArrayList<>();
for (int i = 0; i < productIds.size(); i++) {
if (cachedValues.get(i) != null) {
result.put(productIds.get(i), Integer.parseInt(cachedValues.get(i)));
} else {
missedIds.add(productIds.get(i));
}
}
if (!missedIds.isEmpty()) {
Map<Long, Integer> dbResult = stockDao.batchGetStock(missedIds);
// 批量写入缓存
Map<String, String> toCache = new HashMap<>();
dbResult.forEach((id, stock) -> {
result.put(id, stock);
int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);
toCache.put("stock:" + id, stock.toString());
});
// 用pipeline批量写入,减少网络往返
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
toCache.forEach((key, value) ->
connection.setEx(key.getBytes(), 1800, value.getBytes())
);
return null;
});
}
return result;
}重新压测:
- 100并发:P99 = 210ms,TPS = 1450,错误率 = 0
- 200并发:P99 = 380ms,TPS = 1680,错误率 = 0
继续优化。
第五轮优化:Elasticsearch查询优化
再次trace,buildBaseInfo()开始出现在慢方法里:
`---[210ms] buildSearchResponse()
`---[165ms] buildBaseInfo() ← 这次变成最慢的
`---[163ms] esClient.search() ← ES查询慢检查Elasticsearch查询:
// 原始查询
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("title", keyword))
.filter(QueryBuilders.termQuery("status", 1))
);
sourceBuilder.size(20);
// 注意:没有指定_source过滤,返回所有字段!_source没有过滤,ES把每条记录的所有字段(包括商品详细描述、多语言字段等大字段)都返回了,网络传输数据量巨大。
优化:只取需要的字段:
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("title", keyword))
.filter(QueryBuilders.termQuery("status", 1))
);
// 只返回必要字段,减少网络传输
sourceBuilder.fetchSource(
new String[]{"id", "title", "price", "saleCount", "categoryId"}, // 包含
new String[]{"description", "richText", "keywords"} // 排除大字段
);
sourceBuilder.size(20);ES查询时间从163ms降到28ms。
最终结果:
- 200并发:P99 = 185ms,TPS = 2650,错误率 = 0
- 500并发:P99 = 290ms,TPS = 2820,错误率 = 0.02%
目标达成:P99 < 300ms,TPS > 2000。
优化效果对比汇总
| 轮次 | 主要操作 | 100并发P99 | TPS |
|---|---|---|---|
| 初始 | 无 | 2100ms | 380 |
| 第一轮 | 消灭图片N+1查询 | 420ms | 780 |
| 第二轮 | Redis缓存+批量查库存 | 210ms | 1450 |
| 第三轮 | ES查询_source过滤 | 52ms | 2650 |
每一轮优化都有10倍以内的提升,关键是找对了瓶颈。
踩坑实录
坑1:优化后缓存雪崩导致DB瞬间压力
现象: 第二轮优化上线后,起初运行正常。但每天凌晨3点左右,数据库CPU会突然飙升到100%,持续约2分钟,期间错误率升到5%。
原因: 缓存统一在凌晨2点通过定时任务清除"过期商品数据",所有商品库存缓存同时失效。凌晨3点访问高峰(爬虫)来临,大量请求同时穿透到数据库,造成缓存雪崩。
解法: 加随机TTL抖动(代码里已体现):1800 + ThreadLocalRandom.current().nextInt(600),让缓存在30-40分钟内均匀失效,不会同时失效。
坑2:ES _source过滤导致某个下游服务空指针
现象: 上线ES过滤后,购物车服务偶发NullPointerException,错误信息指向"获取商品封面图"。
原因: 购物车服务复用了搜索接口的响应,取coverImage字段。但我在过滤时把coverImage也排除了(它在大字段里),导致购物车服务拿到null。
解法: 排查清楚所有消费搜索接口响应的下游,确认每个字段的使用方,谨慎决定哪些字段可以过滤。最终把coverImage加回到包含列表。
总结
这次优化的完整路径:系统资源层(CPU满)→ Arthas trace(找慢方法)→ 消灭N+1查询 → 缓存策略优化 → ES查询优化。
核心原则:每一步优化前先定位到具体瓶颈,用数据验证优化效果。 不要靠直觉猜。
