Administrator
发布于 2021-03-11 / 3112 阅读
38

接口性能优化的通用方法论

商家后台一个接口 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

二、异步化(解决无依赖的串行)

orderStatrefundStatmemberStattopItems 四块之间没有依赖,全是串行的。用 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小数据量、容忍各实例不一致
Redis0.6 ms数据量大、要求各实例一致

这一步之后:884 ms → 143 ms

缓存必须同时想清楚三件事:穿透(查不到的 key 也要缓存空值)、击穿(热点 key 过期瞬间用互斥锁重建)、雪崩(TTL 加随机抖动)。我们给所有 TTL 加了 60 + Random.nextInt(20) 的抖动。

四、预计算(解决复杂统计)

剩下的 refundStatmemberStat 还是慢,它们要扫描几十万行做聚合。这类需求算得再快也没用,应该干脆不算——提前算好。

@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 ms460 次查询 → 2 次
异步化884 ms四块统计并行
缓存化143 msRedis + Caffeine
预计算182 ms(含网络)定时任务算好

最后一行比上一行略高,是因为预计算之后我把前面加的 Redis 缓存去掉了一部分(结果表本身查询就快,没必要再套一层)。优化不是只做加法,做完一轮要回头清理。

关于四个手段的优先级,我的经验是:

  1. 先批量化。N+1 查询是纯粹的浪费,没有任何业务代价,收益最大风险最小。
  2. 再异步化。改动集中在调用编排,风险可控,但要检查 ThreadLocal 和事务边界。
  3. 然后才考虑缓存。缓存引入了一致性问题,能用前两个手段解决的就别加缓存。
  4. 预计算放最后。它引入了数据延迟和额外的定时任务,只用在"实时算不动"的场景。

先到这

《接口性能优化的通用方法论》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考