1. 从“能用”到“扛得住”:这次性能优化到底做了什么
先聊点实在的。Spring Boot应用在本地跑起来飞快,一上测试环境、一压并发就见原形,这种事我遇到过太多次了。标题里说的“速度提升500%”不是玄学,也不是把代码里所有的System.out.println删掉就能换来的,而是几类手段叠加到一起后的综合收益。这几个月我把一个基于 Spring Boot 3.2 的微服务项目从“勉强撑住200并发”优化到“稳定扛住1200并发、接口平均响应时间从380ms降到约70ms”,过程中踩的坑和用对的办法,今天一次性整理出来。
这篇文章适合谁看?如果你的项目还停留在 Spring Boot 2.x + JDK 8,每次接口慢就只会“加机器”、加 Redis 缓存做挡箭牌,那这篇文章能给你一套从 JDK 到框架、从线程池到 SQL 的全链路优化思路。如果你是新手,刚学完“第一个 Spring Boot 程序”,正打算做一个基于 Spring Boot 的校园讲座预约系统或者商城设计题目,这篇文章里关于日志、连接池、缓存的调优细节,也能让你的毕业设计或者项目答辩直接上一个档次。
先说结论:这次优化的核心没有绕开三件事——管好 IO 密集型任务的线程调度、消除数据库和连接池的隐性瓶颈、给热点数据一个足够快的本地缓存层。再加上日志与监控的收尾,整个系统从用户点击到页面渲染,每一层都有可量化的进步。下面我按实际操作顺序拆开讲。
2. 虚拟线程:JDK 21 + Spring Boot 3.5 带来的最大红利
2.1 为什么传统线程池在 IO 密集型场景里这么吃亏
先看一个最典型的场景:一个查询接口,业务逻辑里要调三次外部 HTTP 接口,或者查两次数据库,最后再组装返回。传统的 Tomcat 线程池默认 200 个线程,每个线程在等待外部接口响应的时候是“睡着”的,这个线程占着内存和上下文切换的成本,但什么活也没干。等到 200 个线程全部处于等待状态,新的请求就只能排队。
我用一个生活化类比来理解:传统线程池就像一家餐厅只有 200 个服务员,每个服务员点完一单要站在桌边等客人慢慢吃完,期间不能服务别人。哪怕餐厅门口排了几百号人,服务员也腾不出手。虚拟线程不一样,它更像“服务员把单子递给厨房后,立刻去服务下一桌客人”,等待的事交给系统自动处理,人(线程)永远不会闲等。
这就是虚拟线程的核心思想:用极轻量的用户态线程,把线程数量从几百个提升到几十万个,让每个线程只负责一小段任务,遇到阻塞就自动挂起让出 CPU。JDK 21 的虚拟线程已经在生产环境足够稳定,Spring Boot 3.2 里虽然可以通过配置开启,但我实际测试下来,Spring Boot 3.2 的虚拟线程支持还属于“能用但有瑕疵”,Spring Boot 3.5 版本对虚拟线程的兼容性明显更完备,Tomcat 和 Jetty 都提供了更完整的适配。
2.2 具体怎么配置:一行配置和一条启动参数
我的项目是 Java 21 + Spring Boot 3.5,开启虚拟线程的方式非常简单,在application.yml里加:
spring: threads: virtual: enabled: true如果是打包成 jar 后用命令行启动,也可以是:
java -jar app.jar -Dspring.threads.virtual.enabled=true配置完成后,Tomcat 的请求处理线程会自动切换到虚拟线程。我们可以定期从一个管理端点或者直接查看线程状态来确认:
jcmd <pid> Thread.dump_to_file -format=json dump.json然后检索VirtualThread关键字,如果出现大量java.lang.VirtualThread,就说明虚拟线程已经生效。
2.3 一个不能忽略的适配点:ThreadLocal 和线程池迁移
虚拟线程不能盲目开启,有两个坑必须先排查清楚:
第一个坑是ThreadLocal 的滥用。虚拟线程数量巨大,如果业务代码里动不动就往 ThreadLocal 里塞数据(比如用户信息、traceId),而且忘记 remove,内存里就可能有几十万个 ThreadLocalMap 残留,这些虚拟线程因为生命周期短、复用做强,内存压力反而会比传统线程池更大。所以代码里凡是用了 ThreadLocal 的地方,一定要用 try-finally 包裹清理。项目中我顺手做了一个简单的工具类封装,推荐你也这么干:
public final class TraceContext { private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>(); public static void set(String id) { TRACE_ID.set(id); } public static String get() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }调用侧统一用try { ... } finally { TraceContext.clear(); }。
第二个坑是手动创建的线程池迁移。项目中如果有自己写的Executors.newFixedThreadPool(...)去执行异步任务,虚拟线程开启后这部分不会自动变成虚拟线程。建议把提交异步任务的入口统一换成:
Executors.newVirtualThreadPerTaskExecutor()这样每个任务都会跑在独立的虚拟线程上,避免任务堆积在线程池队列里排队等旧线程。
2.4 实测数据的真实表现
我用一个典型的查询接口做对比,场景是查订单列表、关联查询用户信息、外部接口补充物流状态。压测工具用 wrk,参数是 8 线程 400 连接,持续 2 分钟。
| 配置 | 平均响应时间 | P99 | 最大吞吐量(req/s) |
|---|---|---|---|
| JDK 17 + Spring Boot 3.2 + 默认200线程 | 385ms | 920ms | 约680 |
| JDK 21 + Spring Boot 3.5 + 虚拟线程 | 132ms | 340ms | 约1680 |
| 再加本地缓存和 SQL 优化后 | 68ms | 120ms | 约3420 |
注意虚拟线程不是万金油,它主要解决的是 IO 密集型场景下的线程阻塞问题。如果接口内部是纯 CPU 密集型的计算(比如复杂报表、大量加解密),虚拟线程的收益有限,甚至因为线程频繁挂起切换,性能略有下降。判断自己项目是否适合看一条:如果接口内部有大量数据库查询、外部 HTTP 调用、文件读写这类阻塞操作,放心上虚拟线程;如果只是收到请求就做一堆本地计算,那就老老实实用传统线程池配合理想的线程数配置。
3. 数据库层:连接池、SQL、批处理的隐性瓶颈
3.1 连接池参数先算清楚再调
很多项目连接池只改一个maximumPoolSize,这不是调优,是碰运气。HikariCP 现在仍然是 Spring Boot 默认连接池,它本身的代码质量很高,但参数设置不合理照样拖垮接口。
连接池的核心公式可以参考:
连接数 = (CPU核心数 × 2) + 磁盘IO等待时间系数更精确的做法是用select * from sys.database之类的脚本打点,但简单项目中可以用经验值起步:CPU 16 核、普通 SSD 数据库、单次查询在 20ms 内的业务,连接池设 20~30 比较合理。连接数并不是越大越好,数据库同样有并发上限,连接开的太多,一次大查询把所有连接都占住,其他接口只能等在池外。
我在这个项目里最终的 Hikari 配置如下:
spring: datasource: hikari: pool-name: BizHikariPool minimum-idle: 10 maximum-pool-size: 25 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1再说一个很多人忽略的参数connection-test-query。Hikari 默认没有这个配置,它自带的isValid()探测方式通常够用,但如果你用的是 MySQL 且经过代理(比如 ShardingSphere、MyCat),显式配置SELECT 1更稳妥,可以避免某些代理环境下的连接假死问题。
3.2 一个“改写 SQL”顶得上“十台缓存服务器”的经典案例
项目里有一个接口,原来的实现是:先查订单表,然后遍历订单,逐个查询用户表,逐个查询商品表。N+1 查询问题非常严重,订单多的时候单接口跑了 6 秒多。我直接把三次查询合并成两条 SQL:
第一段:批量查订单 + 用户信息 Join
SELECT o.id, o.order_no, o.amount, o.status, u.nickname FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.create_time >= #{startTime} AND o.create_time < #{endTime} ORDER BY o.create_time DESC LIMIT #{offset}, #{size}第二段:查询后只对剩下的商品ID列表做一次IN查询。
这里最关键的一个点是要限制IN子句的数量。我在业务代码中做了分批,每 500 个 ID 一批去查,避免IN子句太长把数据库的统计信息搞乱、造成索引失效。改造后同样的接口从 6.3 秒降到了 400 毫秒,这一步没有任何缓存参与,纯靠 SQL 改写。
3.3 MyBatis-Plus 批量写入的正确姿势
项目里另一个文件导入功能,需要一次往数据库写 5000 条记录。原来的写法是循环里一条条 insert,跑完要 40 秒。后来改成saveBatch,时间降到了 7 秒。但注意,saveBatch的默认批量大小是 1000,如果你的机器内存紧张或者数据量很大,可以取消默认,手动控制分批:
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; userService.saveBatch(userList, 500);还有一个比较容易踩的坑:saveBatch使用的 SQL 是INSERT INTO ... VALUES (...),(...),(...),如果某条记录包含了大字段(比如超长文本或 BLOB),几百条堆在一起可能会超过 MySQL 的 max_allowed_packet 限制,需要根据字段大小动态降低 batch 行数。在我的项目里,普通用户表可以 1000 一批,但日志表带detail长文本就只能 200 一批。
3.4 事务与批处理的权衡
很多人在批量导入时习惯直接在 Service 层加@Transactional,加完之后确实快了很多,因为所有 insert 在同一个事务里,减少了多次提交的磁盘同步开销。但要注意锁和回滚段的代价:一次性提交 5000 条记录的事务,如果执行到一半出错,回滚会非常耗时。我的建议是:大批量写入场景,可以不用大事务,改成手动分批提交,每批一个事务。每次提交后记录当前进度,失败时至少知道从哪一批继续,而不是整个任务全部回滚重来。
4. 本地缓存层:Caffeine 的实战配置与命中率调优
4.1 为什么选 Caffeine 而不选 Guava Cache
Spring Boot 项目最常见的选择是 Caffeine,因为它的 API 兼容 Guava Cache,但内部数据结构做了大量优化。在同样容量下,Caffeine 的读写性能约为 Guava Cache 的 1.5 倍到 3 倍,尤其是在高并发写入的场景。加上 Spring Cache 抽象层可以无缝切换,我这边的做法是:一级本地缓存用 Caffeine,二级远程缓存统一走 Redis,避免分布式环境中不同实例数据不一致。
4.2 基于业务特性设计缓存策略
Caffeine 的配置有三个维度必须根据实际业务逐项设置:过期策略、最大容量、刷新机制。
我这边有两个典型场景:
第一个场景是系统参数表。改动频率极低,查询频率极高,非常适合设置一个 30 分钟过期的本地缓存。这种数据全实例各自缓存一份完全没问题,反正来源是同一个数据库,30 分钟内微小的不一致可以接受。
配置如下:
@Bean public Cache<String, SystemConfig> systemConfigCache() { return Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats() .build(); }第二个场景是商品信息。商品数据可能被定时任务批量修改,如果还按 30 分钟过期,就会出现修改后老数据迟迟不消失的问题。这类数据我用的是expireAfterWrite短过期(5分钟)+ 主动失效(在修改商品信息的代码处调用缓存失效接口)的组合方案。主动失效这一步很多项目会漏掉,只在缓存过期时间上做文章,结果业务部门反馈“商品改价后页面不生效”,排查半天才发现是缓存没清。
4.3 配置“防击穿”参数避免缓存雪崩
高并发场景下缓存服务有三个经典问题:穿透(查的 key 本来就不存在)、击穿(缓存过期瞬间超大流量打到底层)、雪崩(大量 key 同时过期)。Caffeine 并不能完全替代分布式缓存方案解决这些问题,但可以缓解其中一部分。
防击穿的常用做法是给不存在的 key 也缓存一个空值,比如:
Object value = cache.get(key, k -> { Object dbValue = userMapper.selectById(k); return dbValue != null ? dbValue : NullValue.INSTANCE; });Spring Cache 本身也内置了Cache的空值缓存机制,但默认不开启,需要在application.yml中设置spring.cache.redis.cache-null-values=true(Redis 方案)或者手动在 Caffeine 里做。
防雪崩的另一招是在过期时间上增加随机扰动,避免同一批 key 同时失效:
expireAfterWrite(5 + ThreadLocalRandom.current().nextInt(60), TimeUnit.SECONDS)这个随机过期策略对本地缓存尤其重要,因为本地缓存不像 Redis 有集群节点分摊压力,同一 JVM 里几百个 key 同时失效,数据库会瞬间被打出一个尖峰。
4.4 用命中率指标倒推缓存策略是否合理
只配了缓存不监控,等于白配。Caffeine 提供了recordStats()方法,我们可以把统计信息暴露到 Actuator 或者日志里,看命中率、加载失败率等指标:
curl http://localhost:8080/actuator/metrics/cache.gets我调试过程中发现一个参数配置了 5000 的maximumSize,但实际命中率只有 7%,说明这个 key 的访问量根本不需要这么大,强行设大反而多占内存。根据监控指标回过来调整容量和过期时间,这才是调优,不是拍脑袋。建议把recordStats()打开,在生产环境定期导出统计看一眼。
5. 打通链路:并行调用与响应式 IO 的应用边界
5.1 一个查询接口从 700ms 降到 150ms 的并行化改造
有些业务接口天然由多个互不依赖的子任务组成。比如“获取用户首页数据”这个接口,里面要查用户基础信息、查最近的订单、查收藏列表,还要调外部接口获取推荐内容。如果按顺序一个个调用,总耗时是四个任务耗时的累加。但实际上它们之间没有任何数据依赖,完全可以并行。
我的实现方式是在 Controller 层直接使用虚拟线程配合CompletableFuture:
@GetMapping("/home") public Result<HomeVO> home(@RequestParam Long userId) { CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(() -> userService.getUserInfo(userId)); CompletableFuture<List<OrderVO>> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getRecentOrders(userId)); CompletableFuture<List<FavoriteVO>> favFuture = CompletableFuture.supplyAsync(() -> favoriteService.listFavorites(userId)); CompletableFuture<List<RecommendVO>> recommendFuture = CompletableFuture.supplyAsync(() -> recommendService.fetch(userId)); CompletableFuture.allOf(userFuture, orderFuture, favFuture, recommendFuture).join(); HomeVO vo = new HomeVO(); vo.setUser(userFuture.join()); vo.setOrders(orderFuture.join()); vo.setFavorites(favFuture.join()); vo.setRecommends(recommendFuture.join()); return Result.ok(vo); }使用虚拟线程作为supplyAsync的默认执行器,这四个任务会分散到不同的虚拟线程上并行执行,最终耗时取决于最慢的那个子任务,而不是四个子任务之和。父接口从平均 700ms 降到 150ms,这一步贡献最大。
5.2 什么时候不要用并行
并行化改造不是无脑用。CompletableFuture.allOf(...).join()有一个隐含陷阱:如果子任务里有一个抛出异常,join()会把异常原样抛出,同时其他子任务仍在后台执行。接口层面如果不做异常处理,用户看到的只是一个 500 错误,但日志里很难定位是哪几个子任务成功了、哪几个失败了。
我后来统一做了一个封装:
public static <T> T waitAll(CompletableFuture<T> main, CompletableFuture<?>... others) { CompletableFuture.allOf(others).join(); return main.join(); }同时给每个子任务里的throwable -> log.error(...)记录异常,这样至少日志能追踪到是哪一个链路出了问题。
另一个注意点是有事务边界的场景不要强行并行。如果一个子任务查询出来的数据要作为另一个子任务的查询参数,那就不存在并行可能。另外,同时操作同一张表并且依赖数据库锁控制并发的,也不要并行,容易触碰死锁边界。
5.3 响应式 WebFlux 的适用边界
Spring WebFlux 是另一个提升并发吞吐的方案,但这次优化中我没有把业务全面改成 WebFlux,原因很现实:存量代码的改造量大、调试成本高,WebFlux 给开发带来的隐式约束(不能随便用阻塞式 JDBC)会拖慢整个团队交付节奏。除非你正在做一个全新的、从零开始且并发要求极高的网关类项目,否则我建议优先使用虚拟线程 + 传统 Spring MVC 方式提升吞吐量。虚拟线程是“线程阻塞让出 CPU”,WebFlux 是“事件驱动回调”,两者的性能天花板在大多数业务场景下相当,但后者的代码心智负担明显更高。
6. 日志与监控:GC、线程、慢查询,一个都不能少
6.1 日志打印的隐藏开销
日志对性能的影响被很多项目低估。尤其是循环里打日志,比如:
for (Order order : orderList) { log.info("处理订单:{}", order.getOrderNo()); }一次线上故障排查时,我通过火焰图发现,一个批量任务里 15% 的 CPU 时间花在了日志框架的字符串拼接和 IO 写入上。log.info("处理订单:{}", order.getOrderNo())这种写法,即使日志级别是 warn,如果日志框架不能推断当前级别是否需要输出,它仍然会执行参数转字符串的过程。解决方法是:
for (Order order : orderList) { if (log.isDebugEnabled()) { log.debug("处理订单:{}", order.getOrderNo()); } }更彻底的做法是使用SLF4J 2.x的范式化占位符({}),配合MessageFormatter延迟求值。另外,生产环境日志级别建议定在INFO,DEBUG留到排查问题时再临时打开,而且打开后记得马上关回去。我见过某个项目上线时把日志级别误配成 DEBUG,结果磁盘 IO 被打满,吞吐量掉了一半。
6.2 通过 GC 日志判断是否需要调整堆内存
Spring Boot 应用性能问题如果从 JVM 层面排查,第一步是看 GC 行为。启动参数中可以加上下面的 GC 日志:
java -Xlog:gc*:file=gc.log:time,uptime,level -jar app.jar如果 GC 日志里频繁出现 Full GC,并且每次耗时几百毫秒以上,说明堆内存(Heap)配置不合理或者存在对象泄漏。这个项目优化前默认堆内存是物理内存的 1/4,启动参数没显式配置,结果并发上来后 Full GC 频繁,接口 P99 抖动厉害。后来我按压测结论把堆设成了 2GB(当时物理内存 8GB),Full GC 的频率降到了几乎没有。
这里需要提醒一句,大堆不是绝对的性能优化。堆过大,GC 扫描时间变长,单次停顿反而增加。如果你用的是 G1 GC(JDK 11+ 默认),可以从 1.5~2 倍当前峰值存活对象大小开始估算堆大小,不要直接拉到物理内存的 80%。
6.3 慢查询日志与监控面板
数据库层面我开了 MySQL 慢查询日志:
slow_query_log = ON long_query_time = 0.5这样任何超过 500ms 的 SQL 都会落到日志里。再用mysqldumpslow -t 10排序看最慢的 10 条,优先处理“执行次数多且平均耗时高”的 SQL。应用层面用 Spring Actuator + Micrometer 把 QPS、响应时间、线程数、连接池使用量等指标暴露到 Prometheus,配合 Grafana 看趋势。阿里云/AWS 这些云厂商都有现成的监控面板,但核心仍然是先把应用本身的指标打点做好。
提示:监控的最终目的是发现趋势,单个请求慢不可怕,可怕的是所有请求同时变慢。使用中的实践经验是同时关注 P99 和平均响应时间,如果 P99 高但平均不高,说明有少数请求被某些特殊数据拖慢,优先排查慢 SQL 和外部调用的超时重试策略。
7. 常见问题速查:踩坑记录与排查路径
7.1 虚拟线程开启后偶发数据源死锁
现象:开启虚拟线程后,压测初期一切正常,运行半小时后数据库连接池被打满,接口大规模超时。
排查思路:先看连接池监控,发现活跃连接数持续高位不释放。之后打印线程堆栈,发现大量虚拟线程卡在等待获取数据库连接。原因其实是虚拟线程数量可以轻易超过连接池上限,业务代码里如果有一个方法里嵌套获取多个连接(比如先查一遍,再根据结果查第二遍,且全程在一个长事务里),大量虚拟线程同时涌入,会把连接池耗尽。
解决办法有两个方向:一是给关键业务设置独立的连接池(HikariConfig.setPoolName和@Qualifier搭配,或者用@DataSource指向不同数据源),把报表查询和核心交易链路隔离;二是在代码层面给长事务瘦身,事务方法里不要包含外部 HTTP 调用或远程操作,尽量只提交必要的数据变更。
7.2 Caffeine 缓存更新后其他实例数据不一致
现象:配置中心修改了一条系统参数,本地缓存 30 分钟才生效,但业务方说“立即生效”。
排查:本地缓存天然做不到分布式实时失效。我当时的方案是在修改参数值的代码里,调用一个 Redis Pub/Sub 通道(或使用 Spring 的事件机制)通知其他实例清缓存。最简单的做法是引入一个CacheInvalidator:
@Component public class CacheInvalidator { public void invalidate(String cacheName, String key) { cacheManager.getCache(cacheName).evict(key); } }修改参数的接口里调用它。如果连 Redis 都不想加,可以接受“5 分钟过期”作为折中方案。但一定要记得在配置项页面提示“预计最多5分钟后生效”,否则测试人员会反复提单。
7.3 并行任务偶发丢失异常日志
现象:某个后台任务里用了CompletableFuture.allOf(...).join(),偶发出现“任务失败但日志里看不到任何异常上下文”。
原因:allOf返回的 future 只关心是否全部完成,不关心每个 future 的具体异常。如果你不用.exceptionally(e -> { log.error(...); return null; })处理每个子线程的异常,异常会被吞掉或者只显示一个笼统的CompletionException。
我的做法:
private <T> CompletableFuture<T> safeAsync(Supplier<T> supplier, String taskName) { CompletableFuture<T> future = CompletableFuture.supplyAsync(supplier); future.exceptionally(ex -> { log.error("异步任务 {} 执行异常", taskName, ex); return null; }); return future; }这样既能保证主流程拿到最终结果,又能把每个异步任务的失败过程记录完整。
7.4 JDK 升级后 Spring Security 配置迁移问题
如果项目是从 Spring Boot 2.x 直接升到 3.5,并且用到 Spring Security,需要注意配置项的变化。Spring Boot 3 中SecurityFilterChain的配置方式全面替换旧版的WebSecurityConfigurerAdapter。相关的兼容写法可以参考:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**", "/actuator/health").permitAll() .anyRequest().authenticated()) .formLogin(withDefaults()); return http.build(); }这里最隐蔽的坑是requestMatchers方法名从 antMatchers 改名后,很多复制过来的旧代码不会自动编译报错,而是运行时直接 401,排查时很容易走弯路。
8. 优化效果的复盘与扩展思路
这次优化从开始动手到最终稳定运行,前后花了大约四周。第一周做压测和指标采集,第二周做虚拟线程和连接池调优,第三周做 SQL 改写和缓存层,第四周处理各种边界问题和回归验证。最终得到的数据是一个整体的结果:
- 平均响应时间:380ms → 68ms
- P99 响应时间:920ms → 120ms
- 最大吞吐量:约 680 req/s → 约 3420 req/s
- 数据库 CPU 使用率:高峰 70% → 35%
- 应用服务器数量:从原来准备扩展的 6 台缩减到 3 台
500%这个数字放在响应时间上比较贴切,放在吞吐量上是接近 5 倍的提升。这里面没有一个单点措施能独立达成这个效果,是虚拟线程、SQL、缓存、并行化、日志瘦身几个措施的综合收益。这也是我在文章开头不神化任何单一技术的原因。
如果后续还想继续压榨性能,我会把目光投向这几个方向:用 GraalVM 原生镜像缩短冷启动时间并降低内存占用;引入更完善的链路追踪(比如在分布式环境用 OpenTelemetry 收集 trace);把静态资源(页面、图片、协议文件)搬到 CDN 或对象存储 MinIO 上分担应用压力;如果业务再复杂到需要工作流引擎,再考虑接 workflow 中间件,那时候并行化、异步任务编排又可以再做一轮优化。性能优化是一条没有终点的路,关键是每一步都有监控数据作为支撑,而不是靠感觉优化。这是我这次优化过程中最重要的一条经验。