ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot性能优化实战:虚拟线程、连接池与缓存带来500%提速

Spring Boot性能优化实战:虚拟线程、连接池与缓存带来500%提速 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.enabledtrue配置完成后Tomcat 的请求处理线程会自动切换到虚拟线程。我们可以定期从一个管理端点或者直接查看线程状态来确认jcmd pid Thread.dump_to_file -formatjson dump.json然后检索VirtualThread关键字如果出现大量java.lang.VirtualThread就说明虚拟线程已经生效。2.3 一个不能忽略的适配点ThreadLocal 和线程池迁移虚拟线程不能盲目开启有两个坑必须先排查清楚第一个坑是ThreadLocal 的滥用。虚拟线程数量巨大如果业务代码里动不动就往 ThreadLocal 里塞数据比如用户信息、traceId而且忘记 remove内存里就可能有几十万个 ThreadLocalMap 残留这些虚拟线程因为生命周期短、复用做强内存压力反而会比传统线程池更大。所以代码里凡是用了 ThreadLocal 的地方一定要用 try-finally 包裹清理。项目中我顺手做了一个简单的工具类封装推荐你也这么干public final class TraceContext { private static final ThreadLocalString 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/sJDK 17 Spring Boot 3.2 默认200线程385ms920ms约680JDK 21 Spring Boot 3.5 虚拟线程132ms340ms约1680再加本地缓存和 SQL 优化后68ms120ms约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”顶得上“十台缓存服务器”的经典案例项目里有一个接口原来的实现是先查订单表然后遍历订单逐个查询用户表逐个查询商品表。N1 查询问题非常严重订单多的时候单接口跑了 6 秒多。我直接把三次查询合并成两条 SQL第一段批量查订单 用户信息 JoinSELECT 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 CacheSpring Boot 项目最常见的选择是 Caffeine因为它的 API 兼容 Guava Cache但内部数据结构做了大量优化。在同样容量下Caffeine 的读写性能约为 Guava Cache 的 1.5 倍到 3 倍尤其是在高并发写入的场景。加上 Spring Cache 抽象层可以无缝切换我这边的做法是一级本地缓存用 Caffeine二级远程缓存统一走 Redis避免分布式环境中不同实例数据不一致。4.2 基于业务特性设计缓存策略Caffeine 的配置有三个维度必须根据实际业务逐项设置过期策略、最大容量、刷新机制。我这边有两个典型场景第一个场景是系统参数表。改动频率极低查询频率极高非常适合设置一个 30 分钟过期的本地缓存。这种数据全实例各自缓存一份完全没问题反正来源是同一个数据库30 分钟内微小的不一致可以接受。配置如下Bean public CacheString, 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-valuestrueRedis 方案或者手动在 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 层直接使用虚拟线程配合CompletableFutureGetMapping(/home) public ResultHomeVO home(RequestParam Long userId) { CompletableFutureUserInfo userFuture CompletableFuture.supplyAsync(() - userService.getUserInfo(userId)); CompletableFutureListOrderVO orderFuture CompletableFuture.supplyAsync(() - orderService.getRecentOrders(userId)); CompletableFutureListFavoriteVO favFuture CompletableFuture.supplyAsync(() - favoriteService.listFavorites(userId)); CompletableFutureListRecommendVO 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(CompletableFutureT 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延迟求值。另外生产环境日志级别建议定在INFODEBUG留到排查问题时再临时打开而且打开后记得马上关回去。我见过某个项目上线时把日志级别误配成 DEBUG结果磁盘 IO 被打满吞吐量掉了一半。6.2 通过 GC 日志判断是否需要调整堆内存Spring Boot 应用性能问题如果从 JVM 层面排查第一步是看 GC 行为。启动参数中可以加上下面的 GC 日志java -Xlog:gc*:filegc.log:time,uptime,level -jar app.jar如果 GC 日志里频繁出现 Full GC并且每次耗时几百毫秒以上说明堆内存Heap配置不合理或者存在对象泄漏。这个项目优化前默认堆内存是物理内存的 1/4启动参数没显式配置结果并发上来后 Full GC 频繁接口 P99 抖动厉害。后来我按压测结论把堆设成了 2GB当时物理内存 8GBFull GC 的频率降到了几乎没有。这里需要提醒一句大堆不是绝对的性能优化。堆过大GC 扫描时间变长单次停顿反而增加。如果你用的是 G1 GCJDK 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 的事件机制通知其他实例清缓存。最简单的做法是引入一个CacheInvalidatorComponent 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 CompletableFutureT safeAsync(SupplierT supplier, String taskName) { CompletableFutureT 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 → 68msP99 响应时间920ms → 120ms最大吞吐量约 680 req/s → 约 3420 req/s数据库 CPU 使用率高峰 70% → 35%应用服务器数量从原来准备扩展的 6 台缩减到 3 台500%这个数字放在响应时间上比较贴切放在吞吐量上是接近 5 倍的提升。这里面没有一个单点措施能独立达成这个效果是虚拟线程、SQL、缓存、并行化、日志瘦身几个措施的综合收益。这也是我在文章开头不神化任何单一技术的原因。如果后续还想继续压榨性能我会把目光投向这几个方向用 GraalVM 原生镜像缩短冷启动时间并降低内存占用引入更完善的链路追踪比如在分布式环境用 OpenTelemetry 收集 trace把静态资源页面、图片、协议文件搬到 CDN 或对象存储 MinIO 上分担应用压力如果业务再复杂到需要工作流引擎再考虑接 workflow 中间件那时候并行化、异步任务编排又可以再做一轮优化。性能优化是一条没有终点的路关键是每一步都有监控数据作为支撑而不是靠感觉优化。这是我这次优化过程中最重要的一条经验。
返回列表