跳到主内容

接口性能优化实录:一个商品详情接口从 3s 到 200ms 的全链路复盘 ​

问题背景:一次告警引出的耗时迷宫 ​

大促前压测,商品详情接口 /api/item/{id} 的 P99 耗时冲到 3.1 秒,单机 200 并发直接把 Tomcat 线程池打满,上游购物车接口跟着超时。团队的第一反应是「加机器」,但加了一倍机器 P99 仍然 2.8 秒——这不是容量问题,是单请求耗时问题。性能优化的第一原则由此确立:先测量,再动手。没有耗时分布的优化全是凭感觉加缓存,很可能在错误的地方花掉两周。

排查路径:把 3 秒拆成四块账单 ​

链路追踪打开这个接口的瀑布图,3 秒被拆得清清楚楚:数据库查询 1.9 秒(占了六成)、下游营销服务 RPC 0.7 秒、JSON 序列化 0.3 秒、其他(排队、GC)0.2 秒。再点开数据库明细,单次请求竟然产生了 23 条 SQL——商品主表 1 条、SKU 12 条、规格 10 条,典型的 N+1 查询;营销服务那 0.7 秒,是在拿到商品信息后才串行调用优惠券列表,两次调用其实毫无依赖。定位结束,四个优化点按收益排序:消灭 N+1、RPC 并行化、多级缓存、响应瘦身。同步用 async-profiler 采集了 CPU 与内存火焰图,排除 GC 风暴与锁竞争,确认主要矛盾在 IO 往返而非计算——这一步避免了一个常见误区:看到 CPU 高就盲目加机器。预期每项收益都有压测数字背书,而不是拍脑袋。

实操步骤:四步改造,每步都有数据验证 ​

第一步:N+1 合并批量化。 循环查 SKU 改成一次 IN 查询 + 内存组装:

java
// 改造前:每行明细一条 SQL,12 个 SKU 就是 12 次往返
// 改造后:一次 IN 查询取回全部 SKU,内存中按 itemId 分组
List<Long> itemIds = items.stream().map(Item::getId).toList();
Map<Long, List<Sku>> skuMap = skuMapper.selectByItemIds(itemIds).stream()
        .collect(Collectors.groupingBy(Sku::getItemId));
items.forEach(i -> i.setSkuList(skuMap.getOrDefault(i.getId(), List.of())));

同时给 item_id 补上覆盖索引,主表查询从全表扫描回到索引查找。这一步做完,SQL 数从 23 条降到 3 条,DB 耗时从 1.9 秒降到 220 毫秒。

第二步:无依赖 RPC 并行化。 商品信息与优惠券列表改用 CompletableFuture 并发请求,并给营销服务单独配置线程池防止互相拖垮:

java
CompletableFuture<ItemResp> itemF = CompletableFuture
        .supplyAsync(() -> itemService.detail(id), bizPool);
CompletableFuture<List<Coupon>> couponF = CompletableFuture
        .supplyAsync(() -> couponService.listByItem(id), rpcPool);
// 两个无依赖调用并行执行,耗时从 0.7s 收敛为较慢一路的耗时
return itemF.thenCombine(couponF, ItemDetail::of)
        .get(800, TimeUnit.MILLISECONDS);   // 总超时兜底,防止并行变挂起

第三步:多级缓存,读接口的终极答案。 详情页数据更新频率低、读多写少,上 Caffeine 本地缓存 + Redis 集中缓存的二级结构:

java
// 本地缓存:容量 1 万、写后 10 秒过期,吸收热点流量
Cache<Long, ItemDetail> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(10))
        .build();

public ItemDetail detail(long id) {
    ItemDetail hit = local.getIfPresent(id);
    if (hit != null) return hit;
    // 本地未命中走 Redis(Cache Aside:先读缓存,未命中读库回填)
    ItemDetail cached = redis.get("item:" + id);
    if (cached != null) {
        local.put(id, cached);
        return cached;
    }
    ItemDetail fresh = loadFromDb(id);
    redis.setEx("item:" + id, fresh, Duration.ofMinutes(5));
    local.put(id, fresh);
    return fresh;
}

缓存带来的命中率稳定在 96% 以上,DB 耗时占比几乎归零;商品改价走发布订阅主动失效本地缓存,保证 10 秒内最终一致。

排查中还有一个隐藏加分项:连接池与超时收敛。历史遗留的 HTTP 客户端连接池上限只有 10,高峰期大量请求排队等连接,等待时间全部计入了「下游 RPC 耗时」——链路追踪看到的 0.7 秒里有 0.4 秒其实在排队。统一治理后:按下游划分独立连接池与超时预算,数据库连接池按核心数估参并压测校准,所有下游调用显式设置超时,默认无超时的调用一律禁止。超时收敛的另一面是重试退避:下游超时不再立即重试三次,而是指数退避加随机抖动,避免把下游的瞬时抖动放大成持续的流量尖峰。

第四步:响应瘦身。 详情接口历史遗留字段 47 个,大促页只用到 18 个;去掉未用字段、开启 gzip 后响应体从 86KB 降到 31KB,序列化耗时同步减半。移动端弱网下,这一步的用户体感比服务端数字更明显。

压测对比:P99 从 3.1s → 200ms,SQL 从 23 条/请求 → 0.2 条/请求(缓存命中后),单机 QPS 从 210 → 2400,机器不增反减。每步优化都保留独立的压测数据,回滚时有据可依。

优化收尾之后,团队把这次的经验固化成了性能预算机制,防止数字悄悄回退:核心接口上线前必须过压测门槛(P99 预算 300 毫秒,超出即阻塞发布);链路追踪上的耗时分布按周例行 review,缓慢劣化在演变成事故前就能被发现;任何引入新下游依赖的 PR,必须声明预算占用并同步调整该接口的超时配置。性能劣化从来不是一天发生的,缺的只是发现它的例行动作。

避坑总结 ​

  1. 先建基线再优化,火焰图优先于猜测。 没有耗时分布的「加缓存」可能缓存了最不慢的环节,收益归零还引入一致性问题。
  2. 并行化必须配独立线程池。 与其他任务共享池时,慢下游会占满线程反噬主流程;线程池要设队列上限与拒绝策略,超时必须有兜底值。
  3. 缓存引入的一致性问题要有预案。 明确每个缓存的失效方式(TTL、主动失效、订阅 binlog),热点 key 提前做本地缓存防击穿,空值缓存防穿透。
  4. 优化后必须回归压测与灰度放量。 收益数字要在与线上一致的流量模型下复现,直接全量上线等于用生产环境当压测环境。
  5. 警惕优化带来的隐性回归。 批量查询的数据量要设上限,防止一次 IN 查询拉回十万行;缓存的序列化版本兼容要考虑,否则发版瞬间缓存对象反序列化失败会引发雪崩。

收尾 ​

这次复盘最值得沉淀的不是任何单项技巧,而是流程:链路追踪拆耗时账单 → 按收益排序 → 每步压测验证 → 灰度上线。多数性能问题的答案都不在「更快的机器」里,而在「更少的往返、更短的串行、更少的字节」里。把这套流程写成团队的性能治理 SOP,下一个 3 秒接口出现时,优化周期可以缩短到三天。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。