背景:大促前的压测暴露了长尾
八月底大促压测,核心的下单查询接口平均 RT 只有 120ms,看着挺好,但 P99 飙到 800ms,监控里偶发还有 1.5 秒的尖刺。平均好看掩盖了长尾,而用户体验恰恰被那 1% 的慢请求毁掉。我接了这活,目标是把 P99 压到 150ms 以内。
全链路耗时分析:火焰图定位
先用 Arthas 抓了线上火焰图,又用 SkyWalking 拉单条慢 trace,把一次请求拆开:
| 阶段 | 耗时 | 性质 |
|---|---|---|
| 参数校验 | 2ms | 本地 |
| 查用户基本信息 | 35ms | 串行 RPC |
| 查订单列表 | 180ms | 串行 DB |
| 查优惠券 | 120ms | 串行 RPC |
| 查库存 | 90ms | 串行 RPC |
| 拼装返回 | 5ms | 本地 |
总耗时 432ms(慢请求里 DB 和 RPC 会排队到更高)。关键发现:这五个查询彼此无依赖,却全串行执行,纯属浪费。
串行改并行:CompletableFuture 编排
把无依赖的查询并行化,用 CompletableFuture 统一汇聚:
CompletableFuture<User> f1 = supplyAsync(() -> userService.get(uid), pool);
CompletableFuture<List<Order>> f2 = supplyAsync(() -> orderService.list(uid), pool);
CompletableFuture<Coupon> f3 = supplyAsync(() -> couponService.get(uid), pool);
CompletableFuture<Stock> f4 = supplyAsync(() -> stockService.get(sku), pool);
CompletableFuture.allOf(f1, f2, f3, f4).join();
// 取结果拼装
注意要指定自定义线程池,别用 ForkJoinPool.commonPool()——那会和业务线程抢,且默认并行度受 CPU 数限制。改完后理论耗时从「求和」变「取最大」,432ms 降到约 180ms。
缓存改造:热点先行
用户基本信息和库存是读多写少的热点,加了一层 Redis:
- 用户信息缓存 60 秒,命中率 92%,砍掉 35ms RPC。
- 库存用本地 Caffeine 缓存 5 秒,避免每次打 Redis,又不过期太久。
缓存要处理穿透和抖动:库存接口在缓存失败时降级回查 DB,不把缓存 miss 变成雪崩。
批量化:减少往返
订单列表原本是按用户逐页拉,再在内存里按 sku 聚合。改成一次 IN 查询 + 数据库侧 GROUP BY,SQL 往返从 12 次降到 1 次,这部分从 180ms 降到 40ms。
结果对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均 RT | 120ms | 95ms |
| P99 | 800ms | 120ms |
| P999 尖刺 | 1500ms | 210ms |
并行化贡献最大(约 60% 的 P99 下降),缓存和批量化补齐长尾。大促当天该接口零超时告警。
写在后面
现在回头看,《一次核心链路的性能优化:P99 从 800ms 到 120ms》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。