商家后台一个接口 2.3 秒,被投诉了半年
3 月上旬,客服转过来一条工单:某连锁商家反馈"经营概览"页面打开要转好几秒,用了半年一直这样。
我复现了一下,确实慢。这个接口返回商家今日/本月/累计的订单量、销售额、退款率、热销商品 Top5、会员增长数,一共 9 个指标。
第一步永远是耗时拆解,不是拍脑袋优化
我见过太多次这种对话:"这个接口慢,加个缓存吧。"加完之后慢的原因还在,只是被缓存掩盖了。
正确的第一步是把耗时拆开。最简单的是用 Spring 的 StopWatch 临时埋点:
@GetMapping("/overview")
public OverviewVO overview(@RequestParam Long merchantId) {
StopWatch sw = new StopWatch("overview");
sw.start("orderStat"); OverviewStat order = statOrder(merchantId); sw.stop();
sw.start("refundStat"); RefundStat refund = statRefund(merchantId); sw.stop();
sw.start("memberStat"); int members = statMember(merchantId); sw.stop();
sw.start("topItems"); List<Item> top = statTopItems(merchantId); sw.stop();
sw.start("assemble"); OverviewVO vo = assemble(order, refund, members, top); sw.stop();
log.info(sw.prettyPrint());
return vo;
}
跑一次:
StopWatch 'overview': running time = 2318842100 ns
---------------------------------------------
ns % Task name
---------------------------------------------
884213000 038% orderStat
412880000 018% refundStat
188402000 008% memberStat
830441100 036% topItems
3906000 000% assemble
2.32 秒,大头是 orderStat 884 ms 和 topItems 830 ms。
但其实我们线上接了 SkyWalking,不用埋点也能看。用 Arthas 的 trace 能钻到方法内部:
$ trace com.xxx.OverviewController statTopItems '#cost > 100' -n 5
`---ts=2021-03-09 15:22:41;thread_name=http-nio-8080-exec-18;id=2c1;
`---[830.44ms] com.xxx.OverviewController:statTopItems()
+---[0.03ms] com.xxx.ItemService:listByMerchant()
+---[812.88ms] com.xxx.ItemService:getSalesCount() # 这里
`---[17.53ms] com.xxx.ItemService:assembleTop5()
getSalesCount 一次调用 812 ms。翻代码:
public List<ItemSales> getSalesCount(Long merchantId) {
List<Item> items = itemMapper.selectByMerchant(merchantId); // 平均 230 个商品
List<ItemSales> result = new ArrayList<>(items.size());
for (Item item : items) {
// 循环里查数据库!
int cnt = salesMapper.countByItemId(item.getId());
BigDecimal amt = salesMapper.sumAmountByItemId(item.getId());
result.add(new ItemSales(item, cnt, amt));
}
return result;
}
230 个商品,循环里查 460 次数据库,每次 1.7 ms。典型的 N+1 查询。
四个手段,按这个顺序试
我这几年做过的接口优化,用到的手段基本都能归到四类里。判断用哪个,看瓶颈在哪一层。
一、批量化(解决 N+1)
把 460 次单条查询合并成 2 次批量查询:
public List<ItemSales> getSalesCount(Long merchantId) {
List<Item> items = itemMapper.selectByMerchant(merchantId);
List<Long> itemIds = items.stream().map(Item::getId).collect(Collectors.toList());
Map<Long, Integer> cntMap = salesMapper.countByItemIds(itemIds).stream()
.collect(Collectors.toMap(Row::getItemId, Row::getCnt));
Map<Long, BigDecimal> amtMap = salesMapper.sumAmountByItemIds(itemIds).stream()
.collect(Collectors.toMap(Row::getItemId, Row::getAmt));
return items.stream()
.map(i -> new ItemSales(i,
cntMap.getOrDefault(i.getId(), 0),
amtMap.getOrDefault(i.getId(), BigDecimal.ZERO)))
.collect(Collectors.toList());
}
<select id="countByItemIds" resultType="com.xxx.Row">
SELECT item_id, COUNT(*) cnt FROM t_sales
WHERE item_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
GROUP BY item_id
</select>
getSalesCount 从 812 ms 降到 34 ms。这里要注意 IN 里元素别太多,超过 1000 个 MySQL 8.0 的执行计划会变差,我们限制在 500 个一批。
这一步之后整体耗时:2.32 s → 1.51 s。
二、异步化(解决无依赖的串行)
orderStat、refundStat、memberStat、topItems 四块之间没有依赖,全是串行的。用 CompletableFuture 并行(这部分细节我写在《CompletableFuture 在聚合接口中的性能实践》里,这里只给结论):
CompletableFuture<OverviewStat> orderF =
CompletableFuture.supplyAsync(() -> statOrder(merchantId), statPool);
CompletableFuture<RefundStat> refundF =
CompletableFuture.supplyAsync(() -> statRefund(merchantId), statPool);
CompletableFuture<Integer> memberF =
CompletableFuture.supplyAsync(() -> statMember(merchantId), statPool);
CompletableFuture<List<Item>> topF =
CompletableFuture.supplyAsync(() -> statTopItems(merchantId), statPool);
CompletableFuture.allOf(orderF, refundF, memberF, topF).join();
耗时变成最慢的那一个:1.51 s → 884 ms。
异步化有个前提必须检查:这几个操作之间真的没有依赖。我有次把两个查询并行了,结果它们内部共用了同一个 ThreadLocal 里的用户上下文,其中一个清了 ThreadLocal,另一个取到 null。这类问题在压测时不一定能复现,上线后偶发,非常难查。
三、缓存化(解决重复计算)
orderStat 还是 884 ms 的大头,它内部是查订单库统计今日/本月/累计三个数字。这类数据不要求实时,商家看的是"大概的数",延迟 1 分钟完全没问题。
上 Redis 缓存,TTL 60 秒:
public OverviewStat statOrder(Long merchantId) {
String key = "overview:order:" + merchantId;
OverviewStat cached = (OverviewStat) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
OverviewStat stat = doStatOrder(merchantId);
redisTemplate.opsForValue().set(key, stat, 60, TimeUnit.SECONDS);
return stat;
}
命中率 91%(商家会反复刷新页面),平均耗时从 884 ms 降到 81 ms。
对于访问更密集、数据量小的字典类数据,用本地缓存 Caffeine 更划算,省一次网络往返:
@Bean
public Cache<Long, MerchantInfo> merchantCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 开统计,方便看命中率
.build();
}
| 缓存类型 | 单次读取 | 适用 |
|---|---|---|
| 无缓存 | 80~800 ms | 实时性要求高 |
| Caffeine 本地 | 0.05 ms | 小数据量、容忍各实例不一致 |
| Redis | 0.6 ms | 数据量大、要求各实例一致 |
这一步之后:884 ms → 143 ms。
缓存必须同时想清楚三件事:穿透(查不到的 key 也要缓存空值)、击穿(热点 key 过期瞬间用互斥锁重建)、雪崩(TTL 加随机抖动)。我们给所有 TTL 加了 60 + Random.nextInt(20) 的抖动。
四、预计算(解决复杂统计)
剩下的 refundStat 和 memberStat 还是慢,它们要扫描几十万行做聚合。这类需求算得再快也没用,应该干脆不算——提前算好。
@Scheduled(cron = "0 */5 * * * ?") // 每 5 分钟
public void precompute() {
LocalDate today = LocalDate.now();
List<Long> activeMerchants = merchantMapper.listActiveIds();
for (Long id : activeMerchants) {
MerchantDaily stat = computeDailyStat(id, today);
dailyStatMapper.upsert(stat); // 主键冲突就更新
}
}
接口改成直接读结果表:
public RefundStat statRefund(Long merchantId) {
return dailyStatMapper.selectByMerchantAndDate(merchantId, LocalDate.now());
}
耗时从 412 ms 降到 4 ms。
预计算的代价是数据延迟。我们在页面上明确标了"数据更新于 xx:xx",避免商家看到 5 分钟前的数据以为系统出错了。这个提示是产品同学坚持加的,事后看非常必要。
最终结果和优化顺序的经验
| 阶段 | 耗时 | 手段 |
|---|---|---|
| 原始 | 2318 ms | — |
| 批量化 | 1512 ms | 460 次查询 → 2 次 |
| 异步化 | 884 ms | 四块统计并行 |
| 缓存化 | 143 ms | Redis + Caffeine |
| 预计算 | 182 ms(含网络) | 定时任务算好 |
最后一行比上一行略高,是因为预计算之后我把前面加的 Redis 缓存去掉了一部分(结果表本身查询就快,没必要再套一层)。优化不是只做加法,做完一轮要回头清理。
关于四个手段的优先级,我的经验是:
- 先批量化。N+1 查询是纯粹的浪费,没有任何业务代价,收益最大风险最小。
- 再异步化。改动集中在调用编排,风险可控,但要检查 ThreadLocal 和事务边界。
- 然后才考虑缓存。缓存引入了一致性问题,能用前两个手段解决的就别加缓存。
- 预计算放最后。它引入了数据延迟和额外的定时任务,只用在"实时算不动"的场景。
先到这
《接口性能优化的通用方法论》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。