优化之旅全景图
这是一个真实的订单查询接口优化案例。接口上线后用户反馈"卡得像PPT",我们从压测出发,经过四轮优化,最终将响应时间从 2500ms 降到 45ms,提升了 55倍。
优化时间线 — 每一轮都基于数据驱动
Day 1
发现问题 2500ms
→
Arthas诊断
→
SQL优化 800ms
Day 2
Redis缓存 120ms
→
异步化 45ms
→
JVM调优 稳定
第一阶段 发现问题 — 压测发现瓶颈
Day 1 上午 09:30
场景背景:用户反馈"订单页面打不开"
订单中心上线第二周,客服收到大量投诉:"我的订单页面加载不出来"、"点一下要等好几秒"。运营同学反馈大促期间订单列表接口平均响应时间 2.5秒,部分用户甚至等 5 秒以上。
工程师的直觉判断: 用户反馈"慢",但"慢"是个模糊概念。性能调优的第一步永远是 量化 — 用数据说话,而不是靠感觉。我们需要用压测工具把"慢"变成具体的数字:RT、QPS、错误率。
性能调优 = 汽车改装 🏎️
你不会盲目给车加涡轮。正确的顺序是:先上测功机测出当前马力(压测)→ 找到动力损失点(定位瓶颈)→ 针对性改装(优化)→ 再上测功机验证效果。性能调优也是同样的科学流程,每一轮优化都要有"前测"和"后测"的数据对比。
第一步:JMeter 压力测试
使用 JMeter CLI 模式(无GUI,性能损耗小)对订单查询接口进行压力测试:
root@perf-test:~$ jmeter -n -t order_list_test.jmx -l result.jtl -e -o ./report
Creating summariser <summary>
Created the tree successfully using order_list_test.jmx
Starting standalone test @ Fri Jun 13 09:35:22 CST 2025 (1718246122999)
Waiting for possible Shutdown/StopTest/HeapDump/ThreadDump message on port 4445
summary + 400 in 00:00:10 = 40.0 req/s Avg: 2501 Min: 820 Max: 5932 Err: 20 (5.00%)
summary + 3600 in 00:01:30 = 40.0 req/s Avg: 2515 Min: 790 Max: 6120 Err: 180 (5.00%)
summary = 4000 in 00:01:40 = 40.0 req/s Avg: 2512 Min: 790 Max: 6120 Err: 200 (5.00%)
Tidying up ... @ Fri Jun 13 09:37:02 CST 2025 (1718246222834)
... end of run
root@perf-test:~$ cat ./report/statistics.json | python3 -m json.tool
{
"Total": {
"sampleCount": 4000,
"errorCount": 200,
"errorPct": 5.0,
"meanResTime": 2512.45,
"minResTime": 790,
"maxResTime": 6120,
"pct1ResTime": 5000.0, // P90
"pct2ResTime": 5800.0, // P95
"pct3ResTime": 5950.0, // P99
"throughput": 40.02,
"receivedKBytesPerSec": 125.6,
"sentKBytesPerSec": 8.3
}
}
| 指标 | 当前值 | 目标值 | 差距 |
| 平均响应时间 (Avg RT) | 2512 ms | < 100 ms | 差 25 倍 |
| P99 响应时间 | 5950 ms | < 200 ms | 差 30 倍 |
| 吞吐量 (Throughput) | 40 req/s | 500 req/s | 差 12.5 倍 |
| 错误率 (Error Rate) | 5.00% | < 0.1% | 超标 50 倍 |
压测要点:JMeter 压测时务必用 CLI 模式(-n 参数),GUI 模式自身会消耗大量资源,导致压测结果不准确。压测脚本要模拟真实场景:逐步加压(Ramp-up),而不是瞬间打满。推荐线程组配置:100并发,10秒Ramp-up,持续60秒。
第二步:查看原始代码(问题代码)
来看看这个"慢如蜗牛"的接口原始实现:
OrderController.java — order-service — IntelliJ IDEA
OrderController.java
OrderService.java
OrderMapper.java
📁 controller
📄 OrderController
📄 UserController
📁 service
📄 OrderService
📁 mapper
📄 OrderMapper
1@RestController
2@RequestMapping("/api/orders")
3public class OrderController {
4
5 @Autowired
6 private OrderService orderService;
7
8 @GetMapping("/list")
9 public Result<List<OrderVO>> list(Long userId) {
10 // TODO: 这里很慢,需要优化
11 List<OrderVO> orders = orderService.findOrders(userId);
12 return Result.success(orders);
13 }
14}
UTF-8
LF
Java
⚠ 3 warnings
Line 10: Col 9
OrderService.java — 问题代码全貌
1@Service
2public class OrderServiceImpl implements OrderService {
3
4 @Autowired private OrderMapper orderMapper;
5 @Autowired private ProductMapper productMapper;
6 @Autowired private LogisticsMapper logisticsMapper;
7
8 public List<OrderVO> findOrders(Long userId) {
9 // 问题1: 查询所有订单,没有分页!
10 List<Order> orders = orderMapper.selectByUserId(userId);
11
12 List<OrderVO> result = new ArrayList<>();
13 for (Order order : orders) {
14 // 问题2: N+1查询!循环内调用 selectById
15 Product p = productMapper.selectById(order.getProductId());
16 Logistics l = logisticsMapper.selectByOrderId(order.getId());
17
18 // 问题3: 循环内创建对象,转换效率低
19 OrderVO vo = new OrderVO();
20 vo.setOrderNo(order.getOrderNo());
21 vo.setProductName(p.getName());
22 vo.setStatus(order.getStatus());
...
23 result.add(vo);
24 }
25 // 问题4: 无缓存,每次都查数据库
26 return result;
27 }
28}
问题代码扫描:一眼看过去至少有4个问题 — ① 没分页,一次查全部订单;② 循环里查数据库(N+1问题);③ 循环里不断new对象;④ 没有任何缓存。但光看代码还不够,下一步要用 Arthas 精确测量每一步到底耗时多少。
第二阶段 定位瓶颈 — Arthas 深度诊断
Day 1 上午 10:30
光知道"慢"还不够,需要精确知道 哪里慢。Arthas 是阿里巴巴开源的 Java 诊断利器,可以在不修改代码、不重启应用的情况下,实时追踪方法调用链耗时。
Arthas 安装:下载 arthas-boot.jar,用 java -jar arthas-boot.jar 启动,选择目标 Java 进程即可 attach 上去。它通过字节码增强技术实现方法级监控,对性能影响极小(约5%),是线上问题诊断的神器。
诊断步骤 1:trace 追踪 Controller 入口
使用 trace 命令追踪 OrderController.list() 方法内部每个子调用的耗时占比:
root@prod-server:~$ java -jar arthas-boot.jar
[INFO] arthas-boot version: 3.7.1
[INFO] Found existing java process, please choose one and hit RETURN.
* [1]: 18765 order-service.jar
[2]: 15234 user-service.jar
[3]: 14890 gateway.jar
1
[INFO] Try to attach process 18765
[INFO] Attach process 18765 success.
[INFO] arthas-client connected.
[arthas@18765]$ trace com.example.controller.OrderController list -n 5 --skipJDKMethod true
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 120 ms, listenerId: 1
`---[2512.45ms] com.example.controller.OrderController:list()
+---[0.02ms] com.example.dto.Result:success()
`---[2510.83ms] com.example.service.OrderService:findOrders() // 占 99.9%!
诊断步骤 2:trace 深入 Service 层
[arthas@18765]$ trace com.example.service.OrderService findOrders -n 3 --skipJDKMethod true
Affect(class count: 1 , method count: 1) cost in 98 ms, listenerId: 2
`---[2498.12ms] com.example.service.OrderServiceImpl:findOrders()
+---[2198.35ms] com.example.mapper.OrderMapper:selectByUserId() // 88%!SQL查询
+---[180.50ms] com.example.mapper.ProductMapper:selectById() // 7.2%
+---[95.20ms] com.example.mapper.LogisticsMapper:selectByOrderId() // 3.8%
`---[24.07ms] com.example.converter.OrderConverter:toVO() // 1%
诊断步骤 3:watch 查看实际参数和返回值
用 watch 命令查看 selectByUserId 到底传了什么参数、返回了多少数据:
[arthas@18765]$ watch com.example.mapper.OrderMapper selectByUserId "{params, returnObj.size()}" -x 2
Press Q or Ctrl+C to abort.
Affect(class count: 1 , method count: 1) cost in 85 ms, listenerId: 3
method=com.example.mapper.OrderMapper.selectByUserId location=AtExit
ts=2025-06-13 10:35:22; [cost=2198.35ms] result=@ArrayList[
@Object[][
@Long[10086], // 参数: userId = 10086
],
@Integer[1000], // 返回了 1000 条订单记录!!!
]
关键发现:接口查了 1000 条订单记录返回给 Java,但前端分页只需要 20 条!这就像去超市买了 1000 瓶水,结果你只需要喝 1 瓶。98% 的数据库 IO 和网络传输都是浪费的。这就是没有分页的后果。
诊断步骤 4:profiler 火焰图分析
用 Arthas 的 async-profiler 生成火焰图,从 CPU 维度确认瓶颈:
[arthas@18765]$ profiler start --event cpu
Started [cpu] profiling
[arthas@18765]$ profiler stop --format html --file /tmp/flame.html
Profiling total: 30001238 ns (30.00 secs)
OK, write to file: /tmp/flame.html
#
# com.example.mapper.OrderMapper.selectByUserId ████████████████████ 65.2%
# └─ MyBatis PreparedStatement.execute() ████████████████████ 60.8%
# └─ MysqlIO.sqlQueryDirect() ████████████████████ 58.3%
# └─ ResultSetImpl.next() (循环1000次) ████████████████ 45.1%
# com.example.converter.OrderConverter.toVO ████████ 12.4%
# GC (ParallelScavengeHeap) ██████ 8.3%
调用链耗时占比 — Arthas trace 结果可视化
OrderController.list
2512ms (100%)
→
OrderService.findOrders
2498ms (99.9%)
OrderMapper.selectByUserId
2198ms (88%)
→
ProductMapper.selectById
180ms (7.2%)
→
LogisticsMapper
95ms (3.8%)
→
OrderConverter.toVO
24ms (1%)
瓶颈定位:SQL查询占88%,是首要优化目标
瓶颈总结
1
SQL查询返回过多数据(N+1问题 + 未分页)
查询返回1000条记录但只需20条;循环内查关联表导致100条订单产生300次SQL(N+1问题)。
2
对象转换效率低
循环中重复创建 OrderVO 对象,手动逐字段 set,效率低于 MapStruct 批量转换。
3
每次查询都查数据库(无缓存)
订单数据读多写少,完全适合缓存。当前每次请求都打到数据库,DB 承受不住。
4
串行调用RPC(异步化前置)
物流信息、商品信息、评价信息串行查询,三者无依赖关系,完全可以并行。
第三阶段 SQL优化 — 第一轮优化
Day 1 下午 14:00
根据 Arthas 诊断结果,SQL查询占88%,是首要优化目标。我们分三个子问题逐一解决。
问题1:N+1查询 → 批量IN查询
N+1问题:查询 N 条订单的主查询是 1 次 SQL,然后循环 N 次查询关联的商品信息,总共 N+1 次 SQL。当 N=100 时就是 101 次数据库往返。
diff --git a/src/main/resources/mapper/OrderMapper.xml b/src/main/resources/mapper/OrderMapper.xml
index 1a2b3c4..5d6e7f8 100644
--- a/OrderMapper.xml
+++ b/OrderMapper.xml
--- Before: 循环查询(N+1问题)---
<select id="selectByUserId" resultType="Order">
SELECT * FROM t_order WHERE user_id = #{userId}
</select>
<!-- 然后在 Java 里循环调用 100 次:-->
<!-- productMapper.selectById(order.getProductId()) -->
<!-- logisticsMapper.selectByOrderId(order.getId()) -->
+++ After: JOIN 一次查完 + 批量IN
<select id="selectOrderListPage" resultType="OrderVO">
SELECT o.id, o.order_no, o.status, o.create_time,
p.name AS product_name, p.price AS product_price,
l.company AS logistics_company, l.tracking_no
FROM t_order o
LEFT JOIN t_product p ON o.product_id = p.id
LEFT JOIN t_logistics l ON o.id = l.order_id
WHERE o.user_id = #{userId}
<if test="status != null">
AND o.status = #{status}
</if>
ORDER BY o.id DESC
LIMIT #{offset}, #{pageSize}
</select>
// 批量查询替代循环查询(如果不能用JOIN的场景)
// Before: 循环100次 = 100次SQL
for (Order order : orders) {
Product p = productMapper.selectById(order.getProductId()); // 100次!
}
// After: 批量IN查询 = 1次SQL
List<Long> productIds = orders.stream().map(Order::getProductId).distinct().collect(Collectors.toList());
List<Product> products = productMapper.selectBatchIds(productIds); // 1次!
Map<Long, Product> productMap = products.stream().collect(Collectors.toMap(Product::getId, Function.identity()));
面试金句
N+1 查询问题 = 查 N 条主数据后,循环查 N 次关联数据 = N+1 次 SQL。解决方案:(1) JOIN 一条搞定 (2) 批量 IN 查询替代循环 (3) MyBatis 延迟加载 + batchSize。面试加分:说明 N+1 是 ORM 框架(MyBatis/Hibernate)的通病,关键在于减少网络往返次数。
N+1检测技巧:开启 MyBatis 的 SQL 日志 (logging.level.com.example.mapper=DEBUG),如果一个接口请求触发了大量连续的 ==> Preparing: SELECT 日志,基本就是 N+1 问题。也可以用 p6spy 插件统计每个请求的 SQL 执行次数。
问题2:深分页优化
深分页问题:当用户翻到很后面的页码时,LIMIT 100000, 20 让 MySQL 先扫描 100020 行,然后丢弃前 100000 行,只返回最后 20 行。越往后翻越慢。
mysql> SELECT * FROM t_order WHERE user_id = 10086 ORDER BY id LIMIT 100000, 20;
20 rows in set (2.34 sec)
mysql> SELECT o.* FROM t_order o INNER JOIN (
-> SELECT id FROM t_order WHERE user_id = 10086 AND id < 500000 ORDER BY id DESC LIMIT 20
-> ) tmp ON o.id = tmp.id;
20 rows in set (0.048 sec)
# 从 2.34 秒 → 48 毫秒,提升 48 倍!
问题3:添加缺失的索引
订单查询的 WHERE 条件是 user_id + status,排序字段是 create_time。需要建立联合索引覆盖这三个字段。
-- 添加联合索引:遵循最左前缀原则
ALTER TABLE t_order ADD INDEX idx_user_status_time (user_id, status, create_time);
-- 索引设计原则:
-- 1. 区分度高的字段放前面(user_id 区分度高)
-- 2. 等值查询字段在前,范围查询字段在后
-- 3. 排序字段放最后,利用索引天然有序避免 filesort
专业定义
布隆过滤器 = 概率型数据结构,bit 数组 + k 个 hash 函数。有误判无漏判。1亿元素约100MB。
| 指标 | 优化前 | 优化后 | 提升 |
| 查询方式 | 全表扫描 ALL | 索引查找 ref | — |
| 扫描行数 | 1,000,000 | 18 | 55555倍 |
| Extra | Using filesort | Using index condition | 消除filesort |
| SQL耗时 | 2500 ms | 50 ms | 50倍 |
第一轮优化阶段性成果:经过 N+1消除 + 分页 + 加索引,接口平均 RT 从 2500ms → 800ms。但 800ms 离目标 100ms 还有差距。每次请求都查数据库是瓶颈所在 — 下一步引入缓存。
// 压测验证第一轮 SQL 优化效果 — 用 JMH 做微基准测试
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
public class OrderQueryBenchmark {
private OrderService orderService;
@Benchmark
public List<OrderVO> benchmarkQueryOrder() {
// 优化前:2500ms | 优化后(SQL+分页+索引):800ms
return orderService.findOrders(10086L, 1);
}
}
// 运行: mvn exec:java -Dexec.mainClass=OrderQueryBenchmark
// JMH 会预热、多次运行、计算误差,给出可信的基准数据
专业定义
联合索引最左前缀原则 = 索引 (a, b, c) 可用于 WHERE a=? / WHERE a=? AND b=? / WHERE a=? AND b=? AND c=?,但不能用于 WHERE b=?(跳过了 a)。底层:B+Tree 按 a→b→c 排序,跳过 a 就无法利用有序性。选择性高的列放前面。
第四阶段 Redis缓存优化 — 第二轮优化
Day 2 上午 09:00
SQL优化后单次查询从 2.2秒降到 50ms,但每次请求都查数据库,DB 仍然扛不住高并发。订单数据是典型的"读多写少"场景 — 适合用多级缓存。
多级缓存 = 你的钱放在哪里 💰
口袋里的现金(Caffeine本地缓存):随取随用,最快(纳秒级),但容量小。
银行卡 / 支付宝(Redis分布式缓存):要去银行取一下(毫秒级),容量大,所有人共享。
金库 / 保险柜(MySQL数据库):最安全但最慢(百毫秒级),存放真正的持久化数据。
花钱时先掏口袋,口袋没有再去银行取,这样大部分消费都很快。
多级缓存架构设计
请求流转:优先查近的缓存,最后才查数据库
用户请求
→
Caffeine
本地缓存
~0.01ms
未命中→
Redis
分布式缓存
~2ms
未命中→
MySQL
数据库
~50ms
命中率:Caffeine 60% + Redis 35% = 总命中率 95%,只有 5% 的请求打到数据库
缓存策略设计
| 数据类型 | 缓存层 | TTL | 策略 |
| 首页热门订单 | Caffeine | 30秒 | 容量上限1000,LRU淘汰 |
| 用户个人订单 | Redis | 5分钟 ± 60秒 | 随机TTL防雪崩 |
| 订单详情 | Redis | 10分钟 | 缓存空值防穿透 |
| 恶意查询防护 | BloomFilter | 永久 | 布隆过滤器拦截不存在的key |
缓存配置代码实现
CacheConfig.java — 多级缓存配置
1@Configuration
2@EnableCaching
3public class CacheConfig {
4
5 // Caffeine 本地缓存配置
6 @Bean
7 public CaffeineCacheManager caffeineCacheManager() {
8 CaffeineCacheManager manager = new CaffeineCacheManager();
9 manager.setCaffeine(Caffeine.newBuilder()
10 .expireAfterWrite(30, TimeUnit.SECONDS)
11 .maximumSize(1000)
12 .recordStats());
13 return manager;
14 }
15
16 // Redis 序列化配置
17 @Bean
18 public RedisCacheConfiguration cacheConfiguration() {
19 return RedisCacheConfiguration.defaultCacheConfig()
20 .serializeValuesWith(SerializationPair
21 .fromSerializer(new GenericJackson2JsonRedisSerializer()))
22 .entryTtl(Duration.ofMinutes(5));
23 }
24}
diff --git a/OrderServiceImpl.java b/OrderServiceImpl.java
--- a/OrderServiceImpl.java (加入缓存注解)
+++ b/OrderServiceImpl.java
+++ 使用 Spring Cache 注解 + 自定义随机TTL
@Cacheable(value = "orders", key = "#userId + ':' + #page", unless = "#result == null")
public List<OrderVO> findOrders(Long userId, int page) {
// 缓存未命中才执行查询逻辑
List<Order> orders = orderMapper.selectOrderListPage(userId, page);
return OrderConverter.batchToVO(orders);
}
// 订单状态变更时,清除相关缓存(Cache Aside Pattern)
@CacheEvict(value = "orders", key = "#order.userId + ':*'")
@Transactional
public void updateOrderStatus(Order order) {
orderMapper.updateStatus(order); // 先更新DB
// @CacheEvict 会自动删除缓存(再删缓存)
}
Redis CLI 验证缓存
root@redis-server:~$ redis-cli -h 10.0.0.15 -p 6379 -a mypassword
10.0.0.15:6379> KEYS orders::*
1) "orders::10086:1"
2) "orders::10086:2"
3) "orders::10086:3"
10.0.0.15:6379> GET orders::10086:1
"[{\"id\":1,\"orderNo\":\"ORD20250613001\",\"status\":\"PAID\",...}]"
10.0.0.15:6379> TTL orders::10086:1
(integer) 247 // 剩余 247 秒过期(随机TTL防止缓存雪崩)
10.0.0.15:6379> INFO stats
# Stats
keyspace_hits:152340 // 命中 15 万次
keyspace_misses:8012 // 未命中 8 千次
命中命中率: 95.0% // 152340 / (152340 + 8012) = 95%
缓存一致性策略(Cache Aside)
Cache Aside Pattern — 最常用的缓存一致性方案
读请求
→
查缓存
命中→
直接返回
读请求
→
查缓存
未命中→
查数据库
→
写入缓存
→
返回
写请求
→
先更新DB
→
再删缓存
为什么是"删缓存"而不是"更新缓存"?更新缓存有并发问题:线程A先更新DB、线程B后更新DB,但线程B先更新缓存、线程A后更新缓存,导致缓存和DB不一致。删除缓存是幂等操作,下次读的时候自然会从DB重建最新值。"先更新DB再删缓存"虽然极端场景下仍有小概率不一致,但复杂度远低于其他方案,是业界主流选择。
防雪崩、防穿透、防击穿
| 问题 | 场景 | 解决方案 |
| 缓存雪崩 | 大量key同时过期 | TTL加随机偏移(5min ± 60s) |
| 缓存穿透 | 查询不存在的数据 | 缓存空值(NULL)+ 布隆过滤器 |
| 缓存击穿 | 热点key突然过期 | 互斥锁(双重检查)+ 逻辑过期 |
// 布隆过滤器防穿透:查询前先判断 key 是否可能存在
@Autowired
private BloomFilter<Long> orderBloomFilter;
public OrderVO getOrder(Long orderId) {
// 1. 布隆过滤器先过滤
if (!orderBloomFilter.mightContain(orderId)) {
return null; // 一定不存在,直接返回
}
// 2. 查缓存
OrderVO cached = redisTemplate.opsForValue().get("order:" + orderId);
if (cached != null) return cached;
// 3. 缓存空值防穿透
Order order = orderMapper.selectById(orderId);
if (order == null) {
redisTemplate.opsForValue().set("order:" + orderId, "NULL", 5, TimeUnit.MINUTES);
return null;
}
return OrderConverter.toVO(order);
}
面试金句
CompletableFuture 自定义线程池 = 默认 ForkJoinPool.commonPool() 共享池有隐患。用 supplyAsync(supplier, customExecutor) 传入自定义线程池。
第二轮优化成果:引入多级缓存后,接口平均 RT 从 800ms → 120ms。95% 的请求被缓存拦截,数据库 QPS 从 5000 降到 250(降低95%)。但 120ms 里还有优化空间 — 物流、评价等 RPC 调用是串行的。
第五阶段 异步化优化 — 第三轮优化
Day 2 下午 14:00
缓存命中后 RT 降到 120ms,但缓存未命中时仍需 800ms。Arthas trace 显示:订单状态查询、物流信息查询、评价信息查询是串行调用的,三者无依赖关系,完全可以并行。
异步化 = 快递配送链 📦
串行调用 = 一个人从头走到尾:快递员从北京出发→送到上海→再送到杭州→再送到深圳,一个人跑完全程,耗时是所有路程之和。
并行调用 = 分段接力同时进行:北京出发后,同时通知上海、杭州、深圳的快递员开始准备,三路同时走,总耗时取决于最慢的那一路。CompletableFuture 就是帮你"同时发三路快递"的工具。
串行 vs 并行调用对比
三个RPC调用:订单状态(100ms) + 物流信息(100ms) + 评价信息(100ms)
Before: 串行调用 — 总耗时 = 100 + 100 + 100 = 300ms
订单状态 100ms
物流信息 100ms
评价信息 100ms
总耗时: 300ms(三段首尾相连)
After: 并行调用 — 总耗时 = max(100, 100, 100) = 100ms
订单状态 100ms
物流信息 100ms(并行)
评价信息 100ms(并行)
总耗时: 100ms(三段同时进行,取最长的)
CompletableFuture 并行改造
OrderService.java — CompletableFuture 并行调用
1// Before: 串行调用(3次RPC = 300ms)
2public OrderDetailVO getOrderDetail(Long orderId) {
3 Order order = orderRpc.getOrder(orderId); // 100ms
4 Logistics log = logisticsRpc.getByOrderId(orderId); // 100ms
5 Review review = reviewRpc.getByOrderId(orderId); // 100ms
6 return buildVO(order, log, review); // 总计 300ms
7}
8
9// After: 并行调用(3次RPC并行 = 100ms)
10public OrderDetailVO getOrderDetailAsync(Long orderId) {
11 CompletableFuture<Order> orderFuture = CompletableFuture
12 .supplyAsync(() -> orderRpc.getOrder(orderId), executor);
13
14 CompletableFuture<Logistics> logFuture = CompletableFuture
15 .supplyAsync(() -> logisticsRpc.getByOrderId(orderId), executor);
16
17 CompletableFuture<Review> reviewFuture = CompletableFuture
18 .supplyAsync(() -> reviewRpc.getByOrderId(orderId), executor);
19
20 // 等待三个任务全部完成,然后组装结果
21 CompletableFuture.allOf(orderFuture, logFuture, reviewFuture).join();
22
23 return buildVO(orderFuture.join(), logFuture.join(), reviewFuture.join());
24 // 总计: max(100, 100, 100) = 100ms
25}
// 自定义线程池,避免使用 ForkJoinPool.commonPool()(共享线程池有隐患)
@Bean("orderAsyncExecutor")
public ThreadPoolTaskExecutor orderAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("order-async-");
// 拒绝策略: 由调用线程执行(不丢任务)
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
专业定义
JMH 微基准测试 = Java 官方性能测试框架,避免 JIT 优化(死代码消除、常量折叠)导致的误差。@BenchmarkMode(Mode.AverageTime) 平均耗时,@Warmup 预热迭代,@Measurement 正式迭代。面试加分:说明为什么不用 System.nanoTime()——JIT 会优化掉没有副作用的代码。
非核心操作异步化(MQ解耦)
操作日志、埋点统计、消息推送等非核心操作,不需要同步等待结果。用 Spring 事件机制 + @Async 异步处理,或直接发 MQ 让消费者处理。
// 方案一:Spring 事件 + @Async(轻量级,适合单机)
@Service
public class OrderService {
@Autowired
private ApplicationEventPublisher eventPublisher;
public OrderVO createOrder(OrderDTO dto) {
Order order = saveOrder(dto);
// 发布事件,不阻塞当前线程
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return OrderConverter.toVO(order);
}
}
@Component
public class OrderEventListener {
@Async("orderAsyncExecutor")
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
recordOperationLog(event); // 操作日志
sendNotification(event); // 消息推送
updateStatistics(event); // 埋点统计
}
}
专业定义
Spring 事件 + @Async = 单机轻量级异步。必须配合自定义线程池,否则用默认 SimpleAsyncTaskExecutor(每次创建新线程→OOM风险)。
// 方案二:RocketMQ 异步处理(分布式,可靠投递)
@Autowired
private RocketMQTemplate mqTemplate;
public OrderVO createOrder(OrderDTO dto) {
Order order = saveOrder(dto);
// 发送 MQ 消息,不阻塞
mqTemplate.asyncSend("order-topic:created", order, new SendCallback() {
@Override
public void onSuccess(SendResult result) { log.info("MQ发送成功"); }
@Override
public void onException(Throwable e) { log.error("MQ发送失败", e); }
});
return OrderConverter.toVO(order);
}
// MQ 消费者异步处理
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "log-group")
@Component
public class OrderLogConsumer implements RocketMQListener<Order> {
@Override
public void onMessage(Order order) {
recordOperationLog(order);
}
}
专业定义
布隆过滤器 (Bloom Filter) = 概率型数据结构,用 bit 数组 + k 个 hash 函数判断元素是否存在。特点:可能有误判(说存在可能不存在),但不会漏判(说不存在一定不存在)。空间效率极高(1亿元素约100MB)。面试加分:误判率与 bit 数组大小、hash 函数个数的关系。
第三轮优化成果:CompletableFuture 并行调用将 RPC 总耗时从 300ms 降到 100ms;非核心操作异步化后,主链路又省了 50ms。接口平均 RT 从 120ms → 45ms。接下来解决最后一个问题 — GC 导致的间歇性卡顿。
CompletableFuture 注意事项:① 务必传入自定义线程池,不要用默认的 ForkJoinPool.commonPool()(它被所有 CompletableFuture 共享,一旦任务多就会互相影响);② 用 .exceptionally() 或 .handle() 处理异常,否则异常会被吞掉;③ 设置超时 .orTimeout(2, TimeUnit.SECONDS),防止某个 RPC 挂了拖垮整个接口。
第六阶段 JVM调优 — 第四轮优化
Day 2 下午 16:00
前四轮优化后 RT 降到 45ms,但压测时发现间歇性卡顿 — 每隔10秒左右 RT 会突然飙到 500ms 以上。查 GC 日志后发现:每隔10秒触发一次 Full GC,STW(Stop The World)约 500ms。
分析 GC 日志(Before)
root@prod-server:~$ jinfo -flags 18765 | grep -E "GC|Heap"
-XX:InitialHeapSize=2147483648
-XX:MaxHeapSize=2147483648
-XX:+UseParallelGC // 使用 Parallel GC(吞吐量优先)
-XX:NewRatio=2 // 老年代:年轻代 = 2:1
# java -Xms2g -Xmx2g -XX:+UseParallelGC -jar order-service.jar
root@prod-server:~$ tail -100 gc.log | grep "Full GC"
2025-06-13T16:00:10.234+0800: [Full GC (Ergonomics) [PSYoungGen: 261120K->0K]
[ParOldGen: 1572864K->1204224K] 1833984K->1204224K, 0.5234567 secs]
[Times: user=1.84 sys=0.03, real=0.52 secs] // STW 0.52 秒!
2025-06-13T16:00:20.891+0800: [Full GC (Ergonomics) [PSYoungGen: 261120K->0K]
[ParOldGen: 1425408K->1098752K] 1686528K->1098752K, 0.4892341 secs]
[Times: user=1.71 sys=0.02, real=0.49 secs] // STW 0.49 秒!
GC问题诊断:Full GC 频繁触发说明老年代空间不足。进一步分析发现:每次查询返回大量 Order 对象(前面 N+1 问题虽然解决了,但大 List 仍然存在),这些大对象超过了 PretenureSizeThreshold,直接进入老年代,快速填满老年代触发 Full GC。另外 ParallelGC 是吞吐量优先,STW 时间长,不适合对延迟敏感的接口。
JVM 参数调优方案
| 参数 | Before(默认) | After(调优后) | 调整理由 |
| 堆大小 -Xms/-Xmx | 2g / 2g | 4g / 4g | 增大堆空间,减少GC频率 |
| GC算法 | -XX:+UseParallelGC | -XX:+UseG1GC | G1擅长处理大堆,STW可控 |
| GC停顿目标 | 无(默认) | -XX:MaxGCPauseMillis=100 | G1尽量把STW控制在100ms内 |
| 大对象阈值 | 默认(很小的值) | -XX:PretenureSizeThreshold=10M | 避免小对象进老年代 |
| 新生代比例 | NewRatio=2 (1:2) | NewRatio=2 + G1自适应 | G1自动调整Region |
# Before: JVM 启动参数(问题配置)
java -Xms2g -Xmx2g -XX:+UseParallelGC -jar order-service.jar
# After: JVM 启动参数(调优后)
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:PretenureSizeThreshold=10485760 \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/var/log/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M \
-jar order-service.jar
# 参数解释:
# -Xms4g -Xmx4g 堆大小4G,Xms=Xmx避免动态扩容抖动
# -XX:+UseG1GC G1垃圾收集器,适合4G以上大堆,STW可控
# MaxGCPauseMillis=100 告诉G1尽量把每次GC停顿控制在100ms内
# PretenureSizeThreshold 大于10M的对象才直接进老年代(避免短命大对象)
专业定义
RocketMQ 异步处理 = 可靠投递 + 顺序消息 + 事务消息。生产者→Broker 持久化→消费者 ACK。
调优后 GC 日志(After)
root@prod-server:~$ tail -100 gc.log | grep -E "GC pause|Full"
2025-06-13T16:30:12.345+0800: [GC pause (G1 Evacuation Pause) (young)
(initial-marking), 0.0324567 secs]
[Times: user=0.12 sys=0.01, real=0.03 secs] // STW 仅 32ms!
2025-06-13T16:35:45.678+0800: [GC pause (G1 Evacuation Pause) (mixed)
(to-space exhausted), 0.0789234 secs]
[Times: user=0.31 sys=0.02, real=0.08 secs] // STW 仅 80ms!
# Full GC 频率从 10秒/次 → 5分钟/次
# 平均 STW 从 500ms → 80ms
root@prod-server:~$ jstat -gcutil 18765 1000 5
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 45.23 62.34 38.12 94.56 91.23 124 3.842 0 0.000 3.842
0.00 45.23 68.91 38.12 94.56 91.23 124 3.842 0 0.000 3.842
0.00 45.23 75.45 38.45 94.56 91.23 124 3.842 0 0.000 3.842
FGC=0 表示没有 Full GC!G1 用混合回收替代了 Full GC
JVM GC = 环卫工人打扫城市 🧹
Parallel GC(原来):让全城停工,所有环卫工人一起出动扫一遍 — 扫得干净但停工时间太长(STW 500ms)。
G1 GC(调优后):把城市分成 2048 个小区域(Region),每次只清扫几个区域,停工时间短(STW 30~80ms),但清扫频率高一些。对于追求低延迟的 Web 服务,G1 显然更合适。
第四轮优化成果:切换 G1GC + 调大堆后,Full GC 频率从 10秒/次降到 5分钟/次,STW 从 500ms 降到 80ms。间歇性卡顿消失,P99 RT 从 200ms 稳定到 120ms。至此,四轮优化全部完成。
G1GC 适用场景:JDK 9 开始 G1 是默认 GC。如果你的堆大于 4GB、对延迟敏感(要求 STW < 200ms),首选 G1。JDK 11+ 还可以考虑 ZGC(STW < 1ms)或 Shenandoah,但生产环境建议等生态成熟再迁移。切记:GC 调优是最后一招,先优化代码和架构,调 JVM 参数解决不了根本问题。
第七阶段 最终结果对比
Day 2 晚上 20:00
经过四轮优化,订单查询接口从 "慢如蜗牛" 变成 "快如闪电"。下面是完整的优化前后数据对比。
核心指标全面对比
| 核心指标 | 优化前 | 优化后 | 提升倍数 |
| 平均响应时间 | 2500 ms | 45 ms | 55x ↑ |
| P99 响应时间 | 5950 ms | 120 ms | 49x ↑ |
| 吞吐量 (QPS) | 40 req/s | 800 req/s | 20x ↑ |
| 数据库 QPS | 5000 | 250 | 降 95% |
| 错误率 | 5.00% | 0.00% | 归零 |
| Full GC 频率 | 10秒/次 | 5分钟/次 | 30x ↑ |
| GC STW 时间 | 500 ms | 80 ms | 6x ↑ |
| 缓存命中率 | 0% | 95% | — |
优化效果可视化
响应时间优化历程 — 每一轮的效果
初始状态
████████████████████████████ 2500ms
+SQL优化(N+1+分页+索引)
██████████ 800ms
+CompletableFuture异步化
█ 45ms
+JVM G1GC调优
▎ 45ms(稳定,无卡顿)
最终压测验证
root@perf-test:~$ jmeter -n -t order_list_test.jmx -l result_final.jtl
summary + 800 in 00:00:01 = 800.0 req/s Avg: 45 Min: 20 Max: 135 Err: 0 (0.00%)
summary + 47200 in 00:00:59 = 800.0 req/s Avg: 44 Min: 18 Max: 150 Err: 0 (0.00%)
summary = 48000 in 00:01:00 = 800.0 req/s Avg: 44 Min: 18 Max: 150 Err: 0 (0.00%)
Tidying up ... @ Fri Jun 13 20:15:00 CST 2025
... end of run
# 吞吐量: 800 req/s(目标 500,超出 60%)
# 平均 RT: 44ms(目标 100,优于 2.2 倍)
# P99 RT: 120ms(目标 200,优于 1.6 倍)
# 错误率: 0.00%(目标 0.1%,完美归零)
性能调优方法论总结
1
先量化,再优化 — 用 JMeter/wrk 压测拿到基线数据,没有"前测"就不要谈"提升多少倍"。
2
先定位,再动手 — 用 Arthas trace/watch/profiler 找到真正的瓶颈点,而不是凭感觉猜。二八定律:80% 的耗时往往集中在 20% 的代码。
3
先数据库,再缓存 — SQL 优化是根本(索引、分页、N+1),缓存是加速器。不要试图用缓存掩盖烂 SQL。
4
先架构,再 JVM — CompletableFuture 并行、异步解构优先,JVM 调优是最后的微调手段。GC 调优解决不了架构问题。
5
每一轮都要验证 — 改完就压测对比,确认有效再进入下一轮。避免同时改多处,否则不知道是哪个优化起了作用。
性能调优的终极心法 🎯
性能调优不是"玄学",而是测量 → 分析 → 优化 → 验证的科学循环。永远相信数据,不要相信直觉。记住了:没有测量就没有优化(No measurement, no optimization)。每次优化后用数据证明它有效,这样你才能向团队和老板证明你的工作价值。
性能调优工具箱推荐:
压测: JMeter / wrk / Gatling |
JVM诊断: Arthas / jstack / jmap / jstat / MAT |
APM监控: SkyWalking / Pinpoint / Arms |
火焰图: async-profiler / JFR |
GC分析: GCEasy.io / GCViewer