Java后端性能调优实录 — 从2.5秒到45毫秒的优化之旅

一个订单查询接口的完整优化历程:压测 → 诊断 → SQL优化 → Redis缓存 → 异步化 → JVM调优

性能调优 JMeter Arthas SQL优化 Redis缓存 CompletableFuture JVM调优 G1GC

优化之旅全景图

这是一个真实的订单查询接口优化案例。接口上线后用户反馈"卡得像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,性能损耗小)对订单查询接口进行压力测试:

# 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 # 结果触目惊心:40 req/s 吞吐量,5% 错误率,平均 RT 2.5秒
# 查看 HTML 报告中的关键指标 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/s500 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() 方法内部每个子调用的耗时占比:

# 启动 Arthas 并 attach 到目标 Java 进程 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. # trace 追踪 Controller 方法,找出耗时大头 [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%! # 结论:99.9% 的时间都花在 findOrders() 方法里 # 下一步继续往里 trace

诊断步骤 2:trace 深入 Service 层

# 继续追踪 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% # 惊呆了:selectByUserId 占了 88%! # 一个 SQL 查询耗时 2.2 秒,这绝对不正常

诊断步骤 3:watch 查看实际参数和返回值

watch 命令查看 selectByUserId 到底传了什么参数、返回了多少数据:

# watch 查看方法的入参和返回值大小 [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 条记录,但前端每页只显示 20 条 # 数据库捞了 50 倍的冗余数据,再在 Java 里裁剪 # 这就是为什么 SQL 查询要 2.2 秒
关键发现:接口查了 1000 条订单记录返回给 Java,但前端分页只需要 20 条!这就像去超市买了 1000 瓶水,结果你只需要喝 1 瓶。98% 的数据库 IO 和网络传输都是浪费的。这就是没有分页的后果。

诊断步骤 4:profiler 火焰图分析

用 Arthas 的 async-profiler 生成火焰图,从 CPU 维度确认瓶颈:

# 启动 profiler,采样 30 秒 [arthas@18765]$ profiler start --event cpu Started [cpu] profiling # 等待 30 秒后停止,生成 HTML 火焰图 [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>
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 BatchQueryService.java
// 批量查询替代循环查询(如果不能用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 Workbench — t_order — EXPLAIN 深分页分析
-- Before: 传统 LIMIT 深分页(慢)
EXPLAIN SELECT * FROM t_order
WHERE user_id = 10086 ORDER BY id LIMIT 100000, 20;
id
select_type
table
type
key
rows
Extra
1
SIMPLE
t_order
ALL
NULL
100020
Using filesort
# 执行深分页查询,查看耗时 mysql> SELECT * FROM t_order WHERE user_id = 10086 ORDER BY id LIMIT 100000, 20; 20 rows in set (2.34 sec) # type=ALL 全表扫描,rows=100020 扫了10万行,Using filesort 额外排序 # 难怪要 2.3 秒
MySQL Workbench — 游标分页优化后
-- After: 游标分页(延迟关联),只扫20行
EXPLAIN SELECT o.* FROM t_order o
INNER JOIN (
    SELECT id FROM t_order
    WHERE user_id = 10086 AND id < #{lastId}
    ORDER BY id DESC LIMIT 20
) tmp ON o.id = tmp.id;
id
select_type
table
type
key
rows
Extra
1
SIMPLE
<derived2>
ref
idx_user_id
20
Using index
# 游标分页优化后的查询耗时 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 倍! # 原理:子查询只走覆盖索引 idx_user_id,不回表,先拿到20个id,再JOIN精确取数据

问题3:添加缺失的索引

订单查询的 WHERE 条件是 user_id + status,排序字段是 create_time。需要建立联合索引覆盖这三个字段。

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 BloomFilterConfig.java
📄 CacheGuard.java
-- 添加联合索引:遵循最左前缀原则 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。
MySQL Workbench — 加索引后 EXPLAIN 对比
EXPLAIN SELECT * FROM t_order
WHERE user_id = 10086 AND status = 'PAID'
ORDER BY create_time DESC LIMIT 20;
type
key
key_len
ref
rows
Extra
ref
idx_user_status_time
16
const,const
18
Using index condition
指标优化前优化后提升
查询方式全表扫描 ALL索引查找 ref
扫描行数1,000,0001855555倍
ExtraUsing filesortUsing index condition消除filesort
SQL耗时2500 ms50 ms50倍
第一轮优化阶段性成果:经过 N+1消除 + 分页 + 加索引,接口平均 RT 从 2500ms → 800ms。但 800ms 离目标 100ms 还有差距。每次请求都查数据库是瓶颈所在 — 下一步引入缓存。
📁 order-service
📂 src
📂 main
📂 resources
📂 sql
📄 ddl.sql
📄 index_tuning.sql
// 压测验证第一轮 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策略
首页热门订单Caffeine30秒容量上限1000,LRU淘汰
用户个人订单Redis5分钟 ± 60秒随机TTL防雪崩
订单详情Redis10分钟缓存空值防穿透
恶意查询防护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 验证缓存

# 用 redis-cli 验证缓存是否生效 root@redis-server:~$ redis-cli -h 10.0.0.15 -p 6379 -a mypassword # 查看订单缓存的 key 10.0.0.15:6379> KEYS orders::* 1) "orders::10086:1" 2) "orders::10086:2" 3) "orders::10086:3" # 查看缓存值和TTL 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突然过期互斥锁(双重检查)+ 逻辑过期
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 config
📄 AsyncThreadPoolConfig.java
📄 TaskExecutorConfig.java
// 布隆过滤器防穿透:查询前先判断 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}
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 benchmark
📄 SqlBenchmark.java
📄 RepositoryBenchmark.java
// 自定义线程池,避免使用 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 让消费者处理。

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 event
📄 OrderEventService.java
📄 OrderEventListener.java
// 方案一: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风险)。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 BloomFilterConfig.java
📄 CacheGuard.java
// 方案二: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)

# 查看当前 JVM 启动参数 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 # 查看 GC 日志 — 问题很明显 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 秒! # 每 10 秒一次 Full GC,每次 STW 约 0.5 秒 # 这就是 RT 间歇性飙到 500ms 的原因!
GC问题诊断:Full GC 频繁触发说明老年代空间不足。进一步分析发现:每次查询返回大量 Order 对象(前面 N+1 问题虽然解决了,但大 List 仍然存在),这些大对象超过了 PretenureSizeThreshold,直接进入老年代,快速填满老年代触发 Full GC。另外 ParallelGC 是吞吐量优先,STW 时间长,不适合对延迟敏感的接口。

JVM 参数调优方案

参数Before(默认)After(调优后)调整理由
堆大小 -Xms/-Xmx2g / 2g4g / 4g增大堆空间,减少GC频率
GC算法-XX:+UseParallelGC-XX:+UseG1GCG1擅长处理大堆,STW可控
GC停顿目标无(默认)-XX:MaxGCPauseMillis=100G1尽量把STW控制在100ms内
大对象阈值默认(很小的值)-XX:PretenureSizeThreshold=10M避免小对象进老年代
新生代比例NewRatio=2 (1:2)NewRatio=2 + G1自适应G1自动调整Region
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 mq
📄 OrderMessageProducer.java
📄 OrderMessageConsumer.java
# 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)

# 重启应用后,再次压测并观察 GC 日志 root@prod-server:~$ tail -100 gc.log | grep -E "GC pause|Full" # G1 的 Young GC(替代了原来的 Full GC) 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! # 对比: # Before: 每10秒 Full GC,STW 500ms # After: 每5分钟 G1 GC,STW 30~80ms # Full GC 频率从 10秒/次 → 5分钟/次 # 平均 STW 从 500ms → 80ms # 用 jstat 查看 GC 统计 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 ms45 ms55x ↑
P99 响应时间5950 ms120 ms49x ↑
吞吐量 (QPS)40 req/s800 req/s20x ↑
数据库 QPS5000250降 95%
错误率5.00%0.00%归零
Full GC 频率10秒/次5分钟/次30x ↑
GC STW 时间500 ms80 ms6x ↑
缓存命中率0%95%

优化效果可视化

响应时间优化历程 — 每一轮的效果
初始状态 ████████████████████████████ 2500ms
+SQL优化(N+1+分页+索引) ██████████ 800ms
+Redis多级缓存 ██ 120ms
+CompletableFuture异步化 █ 45ms
+JVM G1GC调优 ▎ 45ms(稳定,无卡顿)

最终压测验证

# 最终压测 — 100 并发持续 60 秒 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