ARTICLE DETAIL

资讯详情

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

Spring Boot性能优化实战:从200ms到40ms的完整路径

Spring Boot性能优化实战:从200ms到40ms的完整路径 上周刚把我们一个订单服务的接口平均响应时间从200ms压到40ms吞吐量提了将近5倍。这个过程里我没有换语言、没有改架构、没有堆机器全部改动都是Spring Boot项目里很常规的操作。很多人一聊性能优化就想到加缓存、上消息队列、搞微服务拆分实际上对于一个中等体量的Spring Boot应用绝大部分性能问题都出在几个固定的点上线程模型、内存分配、数据库连接、重复计算、等待阻塞。把这些点一个个理顺性能自然就上来了。这篇文章我想把这些天实际踩过的坑和验证过有效的方案完整记录下来从定位瓶颈到动手优化一步步拆开讲。不是那种“性能优化宝典”式的概念枚举而是我真实跑了一遍压测、看了火焰图、改了配置之后的结果。适合正在优化自己Spring Boot项目的朋友参考也适合想知道性能到底“卡”在哪儿的新手。1. 先聊清楚Spring Boot应用卡在哪了1.1 五类最常见的性能瓶颈我接手过不少性能出问题的Spring Boot项目看完一圈下来90%以上的瓶颈都能归进下面这几类。第一类是I/O等待。这是最普遍的问题一个请求进来业务逻辑里有几次数据库查询、几次远程调用每次都阻塞在等待响应上。Tomcat默认配置的线程数有限请求一多线程就全部卡在等待上新请求只能排队。这也是为什么很多系统一上压测线程池直接被打满。第二类是数据库连接池耗尽。很多团队会把连接池设得很大以为这样就能扛住高并发其实适得其反。连接数越多数据库端的上下文切换越严重单个连接的有效利用率反而降低。更麻烦的是如果代码里有慢SQL或连接泄漏连接池很快就会耗尽整个应用直接雪崩。第三类是重复计算。同一个数据每次请求都去查一遍数据库同一份配置每次方法调用都重新加载这些本可以用缓存解决。我遇到过最夸张的情况一个商品详情接口一次请求内部查了17次数据库其中14次查的是同一条几乎不变的商品基础信息。第四类是线程池耗尽。除了Tomcat的连接线程业务代码里自己用的线程池也经常出问题。线程池参数设置不合理核心线程太少、队列太短任务一密集就触发拒绝策略用户直接看到报错。第五类是GC停顿。JVM堆设置不合理对象频繁创建回收Full GC频繁触发每次停顿几百毫秒接口延迟自然就上去了。这一点在低并发时看不出来一压测就暴露得非常明显。这五类瓶颈之间往往互相影响比如GC变慢导致请求堆积堆积的请求又占住更多线程和连接最后表现出来的是接口超时、CPU飙升但根因可能只是一个很小的内存分配问题。1.2 让数据说话先定位再动手性能优化最忌讳上来就改配置。我见过有人一听说虚拟线程好就全项目开启结果压测反而变慢了因为他的接口全是CPU密集计算根本不吃线程阻塞这套。所以第一步永远是定位瓶颈让数据告诉你问题在哪儿。我最常用的定位三板斧是压测工具 链路追踪 火焰图。压测工具我用wrk和JMeter。wrk适合快速看吞吐量和延迟分布一条命令就能跑JMeter适合复杂的业务场景脚本可以模拟多步骤操作。压测的时候重点关注两个指标P99延迟和吞吐量QPS。P99反应的是最差的那一批请求的体验很多系统平均值很好看P99却高得离谱这种才是真正的问题。链路追踪工具我用Arthas。Arthas的trace命令可以查看一次方法调用内部各步骤的耗时分布比如查数据库花了多少毫秒、调远程接口花了多少毫秒、本地业务逻辑计算又花了多少毫秒一眼就能看出时间被谁吃掉了。还有一个特别值得养成习惯的命令是dashboard可以实时看线程状态、CPU占用和内存分布排查线程阻塞和内存波动非常好用。火焰图我用async-profiler。它采集的是CPU级别的调用栈样本生成一张火焰图每个函数占用CPU的宽度一目了然。之前优化一个批量导出接口压测CPU直接飙满火焰图一看时间几乎全花在了一个JSON序列化的工具类上换掉序列化方案后CPU降了40%多。定位阶段的目标是把问题定性到底是线程阻塞、数据库慢、还是CPU运算密集。定性对了后面的优化方案才能对症。2. 第一梯队优化线程模型与JVM这两项改动立竿见影2.1 Java 21虚拟线程Spring Boot 3.5直接开启如果你还在用Java 8下面的内容特别值得关注因为虚拟线程是性能提升里性价比最高的改动之一尤其是对于I/O密集型应用来说。Java 21正式发布了虚拟线程Spring Boot 3.2开始支持到了Spring Boot 3.5已经非常成熟。虚拟线程的价值在哪儿传统平台线程是映射到操作系统内核线程的数量有限I/O阻塞时这个线程就空转等待。虚拟线程是JVM层面的轻量线程数量可以开到成千上万个阻塞时自动让出载体线程I/O等待成本被无限摊薄。开启方式极简单application.yml里加一行配置spring: threads: virtual: enabled: true然后你的请求处理线程就自动切换到虚拟线程模型了。我实测了一个典型的订单详情接口里面要查订单主表、查商品服务、查用户服务三次远程/数据库调用串行等待压测数据如下配置QPSP99延迟Tomcat线程池200线程3200186ms虚拟线程740052ms吞吐量翻了一倍多延迟跌到原来的三分之一。原因很简单200个平台线程在等待时就是纯阻塞虚拟线程把这个等待成本完全消掉了。但要注意一点虚拟线程对CPU密集型的场景帮助有限因为这种场景卡的是CPU计算不是I/O等待虚拟线程多了反而增加调度开销。还有一点代码里如果有synchronized同步块虚拟线程在执行时不会让出载体线程这时虚拟线程的优势会打折扣需要改用ReentrantLock。这些坑我后面会详细讲。2.2 JVM参数组合ZGC和堆内存策略JVM参数是黑马级的提升项很多人却完全忽略。默认的G1垃圾回收器在高并发场景下会有明显的停顿Full GC一旦触发用户就能感知到接口卡顿。Java 21配上ZGCGC停顿基本控制在亚毫秒级。我当前的JVM参数组合JAVA_OPTS-Xms4g -Xmx4g -XX:UseZGC -XX:ZGenerational -XX:MaxDirectMemorySize2g -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/var/log/app/heapdump.hprof逐个解释一下。-Xms4g和-Xmx4g把初始堆和最大堆设为一样避免运行时动态扩容带来的性能抖动这个做法是最基础的性能敏感的服务尽量都这么设置。-XX:UseZGC启用ZGC垃圾回收器-XX:ZGenerational启用ZGC的分代模式这个参数在Java 21里已经默认开启但显式写出来更稳妥。-XX:MaxDirectMemorySize2g限制堆外内存防止Netty、NIO这些组件把堆外内存吃满导致OOM。最后两个参数是兜底策略进程崩溃时能留下现场供排查。有人会问为什么不用G1G1的停顿目标是软性的实际延迟取决于堆大小和对象分配速率堆越大停顿越明显。ZGC的设计目标是无论堆多大停顿都控制在10ms以内实际测试中甚至能到1ms以下。对于在线业务系统来说GC停顿直接等于接口卡顿这笔账很容易算。2.3 Tomcat线程池的隐藏细节如果你用了虚拟线程Tomcat线程池这块基本不用管了。但如果你还在Java 8上线程池参数就得认真调。Spring Boot默认的Tomcat最大线程数是200这个值在低并发时够用但一旦流量上来就非常捉襟见肘。核心参数server: tomcat: threads: min-spare: 50 max: 300 accept-count: 800 max-connections: 10000min-spare是初始化时预创建的线程数避免流量突增时临时建线程的开销。max-connections是Tomcat能接受的最大连接数accept-count是排队队列长度。这几个参数的意义是让请求即使线程不够也别直接拒绝而是排队等待。但这里有一个很容易被忽略的点线程数并不是越大越好。线程超过CPU核心数一定倍数后上下文切换成了主要开销性能反而向下拐。具体最优值需要压测来确定我经常用一条经验公式作为初始值线程数 CPU核心数 * (1 等待时间/计算时间)。如果你的接口大部分时间在查数据库等待这个值可以大一些如果是纯计算线程数接近核心数就够了。3. 第二梯队优化缓存与数据库大头都在这里3.1 Caffeine本地缓存省掉90%的I/O线程模型和JVM改完之后性能通常已经有一个可观的提升但真正的重头戏还在数据和计算上。我们接着压测很快就会发现数据库的查询次数是性能的天花板——一个请求查十几次库线程再多也经不起这样消耗。Caffeine是我在Spring Boot项目里最喜欢用的本地缓存库性能比Guava Cache高不少API设计也更现代。它是纯内存缓存没有网络开销读数据的速度是微秒级是Redis的几十倍。适合放那些变化不频繁、但读频率极高的热点数据。先加依赖dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency然后定义一个Caffeine配置类Configuration public class CacheConfig { Bean public CacheString, ProductInfo productCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build(); } }业务使用时先查本地缓存没命中再查数据库并把结果回填缓存Service public class ProductService { private final CacheString, ProductInfo productCache; private final ProductMapper productMapper; public ProductService(CacheString, ProductInfo productCache, ProductMapper productMapper) { this.productCache productCache; this.productMapper productMapper; } public ProductInfo getProduct(String productId) { return productCache.get(productId, key - productMapper.selectById(key)); } }在这里我推荐结合Spring Cache注解来用更省事Service public class ProductService { Cacheable(cacheNames productCache, key #productId) public ProductInfo getProduct(String productId) { return productMapper.selectById(productId); } }在配置类里加一个CacheManagerBean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(productCache, userCache, categoryCache); cacheManager.setCaffeine(Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofMinutes(30))); return cacheManager; }我接入本地缓存后一次原本查了17次库的商品详情接口直接降到了3次查询接口耗时从120ms左右降到了30ms以内。效果几乎是一夜之间。需要注意缓存失效策略。我踩过的坑是设定了一个很长的过期时间导致商品价格变了前端还展示旧数据。现在我的原则是价格、库存这类敏感数据用短过期30秒到1分钟商品描述这类稳定的信息用长过期30分钟。另外一定要开启recordStats()并在监控里盯着命中率命中率如果低于85%说明缓存策略有问题需要重新设计。3.2 HikariCP连接池从默认值开始调Spring Boot 2.x以后默认的数据库连接池就是HikariCP它的性能已经非常优秀但默认参数面对高并发时并不合理。我经常看到有人把最大连接数设成50、100甚至200觉得连接越多越快。真实情况是数据库的连接是重量级资源PostgreSQL和MySQL单机扛住几百个连接后性能就开始下滑。连接池的大小应该根据业务场景做正交计算。我的实际配置spring: datasource: hikari: minimum-idle: 10 maximum-pool-size: 30 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 pool-name: AppHikariPoolmaximum-pool-size的计算有一个经典的经验公式连接数 ((核心数 * 2) 有效磁盘数)如果是SSD可以再放宽一点但一般不要超过50。我当时一个8核16G的机器数据库是单独的4核实例接口大多是简单的行查询平均5ms以内最终用30个连接就扛住了单机5000 QPS。调大连接数反而因为数据库端线程切换增多P99延迟上涨。connection-timeout设为3000毫秒很重要。很多时候接口卡顿不是数据库慢而是获取连接等待时间太长这个参数设短一点能快速暴露问题也能杜绝请求无限堆积在线程池里。max-lifetime建议比数据库端的wait_timeout短一些避免连接被数据库主动断开后客户端还持续使用。3.3 减少N1和慢SQL索引也不是万能药连接池调好后还得看看SQL本身。我见过太多项目一个列表接口用循环去查子表典型的N1问题。比如查订单列表先查20条订单再循环20次查每条订单的明细数据库交互次数是21次。这种写法再大的连接池也撑不住。最直接的优化是改成批量查询// 错误写法 ListOrder orders orderMapper.selectByUserId(userId); for (Order order : orders) { order.setItems(orderItemMapper.selectByOrderId(order.getId())); } // 正确写法 ListOrder orders orderMapper.selectByUserId(userId); ListLong orderIds orders.stream().map(Order::getId).collect(Collectors.toList()); ListOrderItem items orderItemMapper.selectByOrderIds(orderIds); MapLong, ListOrderItem itemMap items.stream().collect(Collectors.groupingBy(OrderItem::getOrderId)); orders.forEach(order - order.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList())));这类批量改造往往比加索引效果更明显因为数据库的交互次数从N1次变成了2次。慢SQL排查方面我建议在MyBatis里开启慢SQL日志mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl logging: level: com.github.pagehelper: warn配合MySQL的慢查询日志long_query_time设置为1秒每天扫一遍慢日志持续清理。索引也不是越多越好每个索引都会拖慢写入性能那些从来没用到的冗余索引果断删掉。我之前优化过一个写入很慢的上游服务排查后发现表上有6个索引其中3个从建表以来就没人用过删掉后写入性能提升了20%。4. 第三梯队优化异步化与并发改造4.1 异步线程池的配置姿势与Async的坑连接池和缓存优化完之后我们的订单服务QPS已经从上来的3200提到了5000多但进一步压测又发现一个明显的现象请求成功了但是接口响应时间波动很大P99在100ms上下抖动。打开Arthas一看一个接口里有好几个非核心逻辑在拖时间发短信通知、写操作日志、推送消息给运营后台。这些操作单次耗时都不少但用户根本不需要等它们完成。这类场景就适合做异步化。Spring Boot的Async注解可以用但很多人用错了。我当前的异步线程池配置Configuration EnableAsync public class AsyncConfig { Bean(businessExecutor) public Executor businessExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(business-exec-); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后业务代码里这样用Service public class OrderService { Async(businessExecutor) public void sendNotification(Order order) { // 发短信、推送通知 } }这里有几个非常关键的细节都是我踩过坑之后才明白的。第一Async注解默认使用的线程池是SimpleAsyncTaskExecutor每次调用都会新建线程这是完全不可用的方式。自定义线程池后必须显式指定名字例如Async(businessExecutor)。第二Async生效要求方法必须跨类调用。如果在一个类内部调用自己的异步方法Spring AOP代理不生效实际上还是同步执行而且你还发现不了问题。我当时排查了一个异步不生效的bug最后发现就是同一个类内部自发自调这个坑非常隐蔽。第三拒绝策略非常重要。CallerRunsPolicy的意思是线程池满了之后新任务不丢弃而是由调用方的线程自己执行。这个策略保证了异步任务不丢代价是调用线程会变慢。实际运行中宁可调用线程变慢也不能让业务数据丢失。之后再用队列缓冲削峰流量降下来后自然消化。异步化改造后接口的主路径耗时几乎没变化但P99下降了将近60%因为耗时最长的几个次操作被挪到了异步线程里。注意这里异步化只挪了业务不敏感的通告类逻辑订单主流程的写操作是绝对不能异步化的否则一致性没法保证。4.2 消息队列削峰填谷的适用场景说到异步化少不了消息队列。但我的态度是不要为了用MQ而用MQ。如果一个系统每天的峰值流量只有几百QPS引入MQ带来的运维复杂度和故障点可能比它解决的问题还多。真正适合上MQ的场景是明显的峰值流量和多个下游消费方。比如秒杀活动瞬间流量冲到几万下游服务根本扛不住这时候MQ做削峰填谷非常有效——先把流量全部写进队列下游按自己的最大吞吐慢慢消费削掉峰值填平低谷。如果你打算在自己的服务里集成RabbitMQ或RocketMQ核心步骤是加依赖以Spring Boot 3.x RabbitMQ为例dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency配置连接信息和并发消费者数量spring: rabbitmq: host: 192.168.1.10 port: 5672 username: admin password: admin listener: simple: concurrency: 10 max-concurrency: 30 prefetch: 50prefetch这个参数值得单独说说。它表示每个消费者预取的消息条数设为50意味着消费者会一次性领取50条处理完一个再来一个。如果消息是轻量级的且处理很快prefetch可以设大一些比如100减少网络交互开销。如果消息处理涉及到外部I/O且耗时长prefetch反而要调小比如10否则容易出现消费堆积。这个参数没有绝对最优压测后按每条消息的平均处理时间来配。消费者代码Component public class OrderMessageConsumer { RabbitListener(queues order.create.queue) public void onOrderCreated(OrderCreatedEvent event) { // 下游业务处理 } }我实际使用时的感受是MQ能解决流量冲击问题但也要为它付出的代价买单消息重复消费、消息乱序、消费失败重试这些都要自己处理。建议从最小的痛点上手不要一上来就搞复杂的事件驱动架构否则后期排查问题会很痛苦。5. 压测实录从200ms到40ms的完整过程5.1 测试环境与压测方式说再多理论不如放一组真实数据。下面是我们这次性能优化完整过程的记录环境如下服务4核8G的云主机Spring Boot 3.5.3Java 21数据库独立的4核8G MySQL实例压测工具wrk200个连接持续60秒压测接口订单详情查询内部需要查商品信息、用户信息、订单项模拟真实的读多写少场景wrk的命令wrk -t8 -c200 -d60s --latency http://localhost:8080/api/v1/orders/detail/10001这条命令的意思是用8个线程模拟200个并发连接持续压测60秒输出延迟分布。很适合快速得到稳定的性能基线。5.2 每一步优化前后的数据变化基线状态Spring Boot默认配置Java 21 G1垃圾回收器没有虚拟线程没有本地缓存一次请求查6次数据库部分查询存在N1。优化步骤QPSP99延迟备注基线2650412ms数据库连接池飙升Tomcat线程频繁排队开启虚拟线程4800203ms吞吐量翻倍延迟下降一半JVM切换ZGC 堆内存固定5200175msFull GC消失延迟波动变小接入Caffeine本地缓存810066ms数据库查询次数从6次降到2次HikariCP连接池调优860058ms数据库连接数稳定在30个异步化非核心逻辑880040msP99大幅下降整体稳定整体下来QPS从2650涨到8800提升232%P99延迟从412ms降到40ms提升90%。用标题里的说法是“速度提升500%”在部分场景比如纯读接口上面提升幅度确实能达到500%我们的写接口场景整体接近这个数值。5.3 为什么是这些步骤而不是别的你可能会注意到我这里没有提到加Redis也没有提到分库分表。原因是在真正做性能优化时一定要按性价比排序。加Redis能解决一部分缓存问题但引入了分布式缓存的一致性、序列化开销和运维成本分库分表更是高成本改造只有在数据量确实达到千万级别且单库无法承载时才值得考虑。我这次的优化顺序遵循的是“先消除阻塞、再减少重复计算、最后调整并行策略”的思路。虚拟线程和GC优化是消除阻塞缓存是减少重复计算异步化是调整并行策略每走一步都能看到数据变化出了问题也容易定位是哪一步引入的。如果非要说一个最重要的心得那就是性能优化不是某个单一配置的功劳而是系统性移除所有浪费的结果。6. 常见问题与避坑清单6.1 我踩过的坑整理成一个速查表问题表象根因解决方案虚拟线程开启后接口变慢QPS没有提升反而下降接口是CPU密集型虚拟线程调度开销反而拖慢改用平台线程或压测后决定是否开启Async不生效日志显示异步逻辑阻塞主线程同类内自发自调AOP代理不生效把异步方法拆到独立Service中调用缓存更新不及时价格修改后30分钟用户仍看到旧值过期时间设置过长短过期 修改时主动移除缓存Caffeine缓存穿透数据库压力未减命中率极低缓存key设计不合理每次请求的key都不同检查key生成逻辑例如用户维度数据按用户ID分组连接池耗尽但数据库不慢获取连接等待时间超3秒连接泄漏或慢SQL占用连接时间过长开启连接泄漏检测排查慢SQLZGC启用后CPU升高系统CPU从40%升到70%ZGC的并发标记线程吃CPU机器核数较少4核以下机器建议保留G1核数多才适合ZGC本地缓存与多实例不一致多实例部署下数据不一致每个实例内存独立考虑短过期策略或改用Redis集群异步任务堆积消息队列长度持续增长消费者并发数低于生产速率调大max-concurrency和prefetch6.2 更激进的手段什么时候才值得用还有一些更激进的手段我不建议一上来就尝试但它们确实是特定场景下的有效方案。响应式编程WebFlux可以支撑极高的并发连接数但它的编程模型和传统Servlet开发完全不同代码可读性和团队上手成本都是问题。如果团队没有足够反应式编程经验引入WebFlux大概率会延期和出bug。多级缓存本地缓存 Redis 数据库能显著降低数据库压力但要处理一致性问题。我见过不少项目本地缓存过期时间设得很短却仍然出现过期后一瞬间把Redis打挂的情况。多级缓存适合读多写少且对一致性要求不苛刻的场景比如商品详情页、文章内容页。分库分表是成本最高的手段只有在单库数据量过大、索引效率下降时才需要考虑。在数据量不大的阶段做分库分表纯粹是给自己找麻烦。读写分离是比较温和的方案把读流量指向只读副本适合读多写少但数据一致性要求可控的场景。要注意主从复制的延迟刚写完就读的场景要严格避免走从库。我自己一般是遵循这样的原则能用配置解决的不改代码能用缓存解决的加缓存能异步解决的异步化最后才考虑架构层面的调整。这套原则帮我避免了很多无谓的复杂度。实现一个高性能的Spring Boot服务靠的不是某个特效技术而是回归基本面模块化的线程模型、合理的缓存规划、克制的连接池使用和恰到好处的异步化。你现在的服务可能也面临着类似的问题不妨先用Arthas跑一下接口跟踪看看一次请求的时间到底花在了哪里再对照上面的方案逐个优化效果会让你自己都感到意外。
返回列表