性能优化这件事绝大多数人第一反应是“上缓存”“加索引”但真正动起手来又总感觉差口气。前阵子帮朋友排查一个Spring Boot项目的压测瓶颈TPS卡在400左右CPU没跑满内存也不紧张数据库连接池却被打满接口平均响应时间一路飙到1.2秒。折腾了差不多两天最后是把虚拟线程、连接池参数、缓存策略和几个隐藏很深的配置全部调整到位压测结果直接从400 TPS干到了2100 TPS响应时间降到了200毫秒以内。就是这次实操让我下定决心把这套“组合拳”完整地梳理一遍。很多人喜欢追求某个单一神奇配置但Spring Boot的性能优化从来不是靠某个“大招”一步登天的。500%的提升听起来夸张实际是多个优化措施叠加出来的结果底层运行环境换掉、数据层打法调整、缓存命中率提上去、并发模型改造完效果自然是指数级的。这篇就把我从版本选型、JVM参数、数据层优化、缓存策略、异步化改造到压测排查的全过程掰开揉碎讲一遍新手能当落地手册用有经验的也可以对照排查自己项目里的隐患。1. 先把地基打牢版本与运行环境选型1.1 为什么Java 21 Spring Boot 3.5能带来“代际提升”以前我给别人推荐技术栈总是习惯性保守Java 8 Spring Boot 2.x走天下。但说实话如果你还停留在Java 8上聊性能优化天花板是很低的。真正让我下定决心升级的是Java 21正式带来的虚拟线程Virtual Threads。这玩意儿对Spring Boot项目的提升用“革命性”来形容并不夸张。传统上Spring Boot内置的Tomcat处理请求遵循的是Thread-per-Request模型一个请求占一个平台线程。而平台线程是由操作系统调度的重型资源创建和切换成本都不低。当你的服务出现大量IO等待——比如调用数据库、访问远程接口、读写文件——这些线程大部分时间都在阻塞等待CPU利用率自然上不去。虚拟线程则完全不同它是JVM自己调度的轻量级线程创建成本极低阻塞时会自动让出载体线程。换句话说你可以同时开启几千甚至几万个虚拟线程而不会把系统资源耗尽。当你从Java 8升级到Java 21并且在Spring Boot 3.2以上版本3.5更佳中开启虚拟线程后Tomcat就切换到虚拟线程来处理请求。对于典型的IO密集型Web应用吞吐量提升2到5倍是完全可以见到的。我在压测中观察到的数据是之前用平台线程池Tomcat的默认max-threads是200压测到并发300时大量请求在排队等待线程开启虚拟线程后并发500时线程调度依然非常平顺这背后不是某个参数调得好而是并发模型变了。开启方式很简单Spring Boot 3.5甚至把配置精简到了一个开关spring.threads.virtual.enabledtrue如果你的项目用的是Spring Boot 3.2或3.3可以通过自定义Bean的方式启用Bean public TomcatProtocolHandlerCustomizer? protocolHandlerVirtualThreadExecutorCustomizer() { return protocolHandler - protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); }对应的pom.xml里务必确认用的是Tomcat 10.1的嵌入式版本Spring Boot 3.x默认自带基本不用额外操心。补充一句为什么我特意强调Spring Boot 3.5因为从3.5开始Spring官方框架层面已经全面适配了Java 21的虚拟线程对虚拟线程友好的代码路径完善度更高部分中间件和数据库驱动也没有了平台线程绑定的问题。虽然3.2也能用但3.5在低版本JDK兼容、类检测、框架内部锁优化上都做了不少改进生产环境我更推荐直接用3.5。这个选择背后的逻辑很简单Java 21 Spring Boot 3.5 虚拟线程相当于把你服务端的线程模型从“操作系统的重型线程”换成“JVM管理的轻量编排”这是从根上的提升任何局部参数调优都比不上。1.2 JVM参数与启动加速的组合拳除了虚拟线程JVM参数同样藏着不少可操作空间。很多人对JVM优化的印象是堆内存配个-Xms2g -Xmx2g就完事了但如果你的服务是一个典型的Web应用GC暂停时间对接口延迟的影响是会直接被用户感知的。我现在的通用做法是JDK 21环境下默认偏向使用ZGC可通过-XX:UseZGC显式开启或者G1并结合MaxGCPauseMillis调优。ZGC在Java 21里已经相当成熟能把GC停顿控制在几毫秒以内对于追求低延迟的在线业务非常友好。我压测时对比过使用G1在堆内存8G、并发较高的情况下偶尔还会出现几十毫秒的STW切到ZGC之后GC停顿基本都在2ms左右。启动加速方面千万别忽略CDS和AppCDS。简单类比一下JVM每次启动都要把核心类库从头装配一遍而CDS就是把这个“装配过程”的结果保存下来下次启动时直接加载现成的。Spring Boot应用在启动时需要加载大量类和资源AppCDS对启动速度的提升肉眼可见。实操步骤也不复杂# 第一步以存档模式启动一次应用生成共享存档文件 java -XX:ArchiveClassesAtExitmyapp.jsa -jar myapp.jar # 第二步正常启动时直接加载存档 java -XX:SharedArchiveFilemyapp.jsa -jar myapp.jar实测一个中型Spring Boot项目启动时间从5秒左右压到了2秒出头这个优化对开发者本地的调试体验改善很大对生产环境多实例扩容也有实际价值。JVM参数层面我还会搭配这些基础配置java -Xms4g -Xmx4g -XX:UseZGC -XX:AlwaysPreTouch \ -XX:SharedArchiveFilemyapp.jsa -jar myapp.jarAlwaysPreTouch这个参数值得单独说一下它会在启动时让JVM把所有堆内存物理页预先分配并触碰一遍。代价是启动变慢大堆时慢得明显好处是运行期间内存分配更稳定避免因为内存页懒分配导致运行中性能抖动。如果堆内存小于8G我是建议在生产环境用上的。不过这里也有个容易踩的坑AppCDS存档文件和应用依赖的jar版本强相关升级依赖后必须重新生成存档否则启动时会报错或者直接忽略存档。将CDS存档的生成过程集成到CI流水线里每次部署自动刷新是我踩过几次坑之后总结出的最佳实践。1.3 中间件与客户端选型中的隐藏性能成本Spring Boot项目往往不只是自己跑还要连数据库、Redis、消息队列、对象存储等。这些客户端选型如果版本不对或配置不当会成了性能瓶颈的隐形推手。比如数据库连接池Spring Boot 2.x之后官方默认是HikariCP这个没啥可换的重点在参数调校。再比如Redis客户端Java生态里主流的Lettuce和JedisSpring Boot默认集成的是Lettuce。Lettuce基于Netty性能其实不错但有一个常见问题是它在某些版本下对连接数的管理和超时控制不够激进高并发时可能导致连接排队。如果你的Redis操作非常频繁而且出现了Redis连接获取超时的报错不妨检查一下Lettuce的配置。再比如HTTP调用。Spring Boot项目里常见的RestTemplate、WebClient和OpenFeign性能差异也不小。RestTemplate每次调用都会创建一些中间对象在高频调用下吞吐量明显不如WebClient基于Reactor异步非阻塞。而OpenFeign实际上是在接口声明层面做了封装底层可以切换成不同的HTTP客户端。如果你用的是OpenFeign且性能敏感考虑把底层Client从默认的HttpURLConnection切换成HttpClient5或OkHttp选择连接池配置合理的客户端对高并发调用收益很直接。我有一个习惯把Spring Boot本身、数据库驱动、Redis客户端、HTTP客户端这几个关键依赖统一定义在同一个BOM里进行版本管理避免各自独立升级后出现不兼容。版本对不上很多时候不直接报错而是某个底层行为变得异常低效这类问题排查起来特别费劲。2. 数据层优化一条慢SQL毁掉所有性能2.1 数据库连接池参数调校HikariCP的工厂化管理性能优化的第一现场永远绕不开数据库。很多项目的性能瓶颈不在应用服务器而在数据库连接池被耗尽请求全卡在等连接上。Spring Boot默认的HikariCP本身就是性能标杆但默认参数并不一定适合你的业务场景。默认的maximumPoolSize是10对一个小团队内部系统够用了但在生产环境的压力测试下10个连接几乎必然不够。我之前排查过一个案例应用CPU不高、接口RT却居高不下最后一看监控HikariCP活跃连接数长时间接近上限大量线程阻塞在getConnection()上。连接池不是越大越好这一点要特别注意。数据库连接是稀有资源每个连接还要占用数据库端的内存和进程资源。我常用的经验法则maximumPoolSize按((coreCount * 2) effectiveSpindleCount)来估算SSD硬盘场景下effectiveSpindleCount可以按1算更关键的是把minimumIdle和maximumPoolSize设置成一致减少运行期间连接数动态伸缩造成的额外开销。我现在的基准配置如下spring: datasource: hikari: minimum-idle: 20 maximum-pool-size: 20 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 keepalive-time: 300000 connection-test-query: SELECT 1解释一下几个关键参数connection-timeout设成3秒是防止连接池耗尽时请求无限等待max-lifetime一定要小于数据库侧的wait_timeout避免数据库主动断开连接后应用还持有失效连接keepalive-time是HikariCP 4.0.3以后新增的会在连接空闲时间超过该值后主动发送测试查询提前发现死连接。连接数调大后别忘了数据库端的max_connections也要配套调大否则应用层配得再高数据库侧直接拒绝连接也是白搭。MySQL按max_connections上限保守估算一下单实例几百个连接是常态但要留意连接数增长对数据库CPU和内存的消耗。2.2 慢SQL排查从explain到索引设计的实战套路我见过太多“Spring Boot性能差”的案例最后追根溯源都是SQL问题。应用层做得再花哨一条没走索引的全表扫描就能把你的数据库CPU干到100%。所以数据层优化索引和SQL设计永远是第一优先级。挑一个我印象很深的例子一个分页查询接口数据量到了80万行以后用户翻到后面几页就开始卡接口响应时间从20ms直接飙到900ms。当时的SQL长这样SELECT * FROM orders WHERE user_id ? ORDER BY create_time DESC LIMIT 20000, 20;问题很明显深分页导致的offset过大MySQL需要丢弃前20000行。第一反应是改成分页优化用一个覆盖索引先定位起始位置再取数据SELECT * FROM orders WHERE user_id ? AND create_time ( SELECT create_time FROM orders WHERE user_id ? ORDER BY create_time DESC LIMIT 1 OFFSET 19999 ) ORDER BY create_time DESC LIMIT 20;结果并不理想。真正执行explain之后才发现问题的根源是user_id和create_time上的单列索引都不够理想MySQL在优化器里选择了一个选择性一般的索引导致大量回表。最后我直接建了联合索引ALTER TABLE orders ADD INDEX idx_user_create (user_id, create_time);查询直接走覆盖索引响应时间从900ms降到了12ms。这个案例教会我一个道理不要凭直觉优化SQL先看explain关注type、key、rows这几个字段。type如果出现ALL或者index基本就是全表扫描或者全索引扫描赶紧想办法让它走上ref或range。另外再补充两个高频踩坑点。第一是隐式类型转换表里user_id是varchar类型但代码里传的是数字MySQL会放弃索引做全表扫描这个非常隐蔽因为数据量小的时候根本看不出来。第二是函数运算在索引字段上使用函数如WHERE DATE(create_time) 2024-01-01索引直接失效正确的做法是改成范围条件create_time ? AND create_time ?。2.3 MyBatis-Plus的配置细节xml映射、自动映射与分页插件MyBatis-Plus在国内Spring Boot项目里的普及率非常高但很多团队只用到了它的基础CRUD功能配置层面的优化空间被浪费了。搜索热词里有一个很具体的场景xml与Mapper接口放在同一个文件夹下应该如何配置。这是MyBatis-Plus的经典配置问题。当你的Mapper接口和XML文件都放在com.example.mapper这个包下时默认情况下Spring Boot的classpath扫描并不会自动把src/main/java下的XML文件识别为资源文件编译后XML不会出现在classes目录里。解决办法是在pom.xml里显式声明资源目录build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resources /build同时配置MyBatis-Plus的mapper-locationsmybatis-plus: mapper-locations: classpath*:com/example/mapper/*.xml这个组合配置一旦漏了启动时往往会报Invalid bound statement (not found)排查起来很浪费时间。分页插件也是一个经常被忽略的配置。很多团队的Mapper方法里直接写了LIMIT或者压根没用分页插件导致PageHelper那种传统分页方式踩了深分页的坑。MyBatis-Plus的分页插件需要先注册否则Page对象不会自动生效Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注册完以后分页查询直接传入Page对象即可插件会自动拼接LIMIT。但要注意分页插件并不会自动优化深分页问题只是帮你省去了手写LIMIT的麻烦。页数特别深时还是得回归到底层SQL设计上。还有一个隐藏的配置MyBatis-Plus的自动映射。默认情况下下划线命名法跟驼峰命名的自动映射是开启的map-underscore-to-camel-case: true但如果你在XML里写了复杂的自定义查询返回DTO字段映射可能就对不上号。我的习惯是自定义SQL务必在SQL里使用别名显式指定字段名别依赖框架的自动映射兜底。这个习惯能避免大量字段错位的隐蔽bug性能上也能减少不必要的字段映射探测计算。3. 缓存不是银弹但热点数据必须有3.1 Caffeine本地缓存把“计算”降到微秒级缓存是性能优化里最立竿见影的手段但很多人一上来就上Redis忽略了本地缓存的价值。Caffeine就是Java生态里极其优秀的本地缓存库性能比ConcurrentHashMap手动过期管理好太多它内部实现了W-TinyLFU淘汰算法能够在内存有限的情况下保留“真正热”的数据。在Spring Boot项目里集成Caffeine非常简单spring: cache: type: caffeine caffeine: spec: maximumSize10000,expireAfterWrite5m如果你需要更精细的控制可以在配置类里手动定义CacheManagerConfiguration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats()); return cacheManager; } }recordStats()这个配置我很推荐开启配合CaffeineCache的统计信息可以直观看到缓存命中率。我经常用命中率来验证缓存策略是否合理如果命中率长期低于80%说明缓存的key设计或过期时间设置有问题该调就得调。本地缓存最大的短板是多实例环境下的一致性。你有两台应用服务器每台本地缓存各存一份用户修改数据后两台服务器的数据会短暂不一致。对于一致性要求不高的场景比如系统配置、字典表、商品详情的基础信息本地缓存完全够用性能极佳但涉及账户余额、库存这类强一致数据本地缓存就要慎用。3.2 Redis分布式缓存别让缓存穿透打垮你的服务跨实例共享缓存还得靠Redis。但Redis不是一装了之穿透、击穿、雪崩三个问题不处理缓存用得越多事故越大。缓存穿透指的是查询一个必然不存在的数据缓存和数据库都没有请求直接打到DB。最简单有效的方案是缓存空值即使数据不存在也往Redis里放一个空值或特殊标记过期时间可以设置短一些比如30到60秒。如果攻击者用的是随机不存在的ID空值缓存也能拦截一大部分流量。public User getUserById(String id) { String cacheKey user: id; Object cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue null) { User user userMapper.selectById(id); if (user null) { // 缓存空值防止穿透 redisTemplate.opsForValue().set(cacheKey, , Duration.ofSeconds(30)); } else { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), Duration.ofMinutes(30)); } return user; } return JSON.parseObject((String) cacheValue, User.class); }为了做空值缓存字段反序列化时要注意区分“空字符串”和“正常值”别直接判断null。缓存击穿是指一个热点key在缓存失效的瞬间涌入大量请求全部打到DB。解决方案是两个思路一是互斥锁让只有一个请求去数据库查询并重建缓存其他请求等待锁释放后直接读缓存二是逻辑过期时间在缓存value里额外存一个过期时间戳业务读取时判断逻辑过期异步刷新缓存。真实场景下互斥锁实现简单而且可靠是首选。缓存雪崩指大量key在同一时间集中失效导致请求全部落到DB。解决办法很粗暴过期时间加随机偏移量。比如Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextLong(0, 60));这个偏移量成本极低但能有效避免“同一秒内大量缓存同时过期”的连锁反应。3.3 缓存一致性先删缓存还是先更新数据库缓存一致性是分布式系统中绕不开的难题。我在刚接触缓存时习惯性用“先更新数据库再删缓存”的策略在实际高并发场景下发现问题不少。先说一下两种方向各自的坑。先删缓存再更新数据库线程A删了缓存还没来得及更新DB线程B来了发现缓存没有直接查DB查到的是旧数据又把旧数据写进了缓存。结果缓存里的数据就是旧的而且可能会保持很久。先更新数据库再删缓存同样存在一个时间窗口线程A更新DB后还没有删除缓存线程B读到了旧的缓存数据。业界最常用的折中方案是延迟双删先更新数据库再删缓存隔一小段时间比如几百毫秒再删一次缓存。第一次删除是为了让后面的读请求能查到新数据第二次删除是为了处理第一次删除后、缓存重建期间可能写入的旧数据。public void updateUser(User user) { userMapper.updateById(user); redisTemplate.delete(user: user.getId()); // 延迟执行第二次删除 executor.schedule(() - redisTemplate.delete(user: user.getId()), 500, TimeUnit.MILLISECONDS); }注意延迟双删并非完美方案如果第二次删除失败缓存还是会不一致。更彻底的做法是基于Binlog监听比如Canal异步同步缓存变更但这引入了额外组件和运维成本。我们团队目前的取舍标准很简单核心数据用Binlog监听或直接让缓存带有较短的过期时间兜底非核心数据用延迟双删加过期时间就够了。从性能优化的角度看缓存一致性方案的核心诉求是让大部分读请求命中缓存、让缓存更新成本可控、绝不让缓存击穿拖垮DB。4. 异步化与并发治理吞吐量翻倍的关键4.1 用CompletableFuture优化串行调用接口性能差很多时候不是因为单次操作慢而是多个独立操作被硬生生串行执行了。某个聚合接口需要同时从用户服务、订单服务、商品服务取数据如果一个一个查假设每个耗时40ms总耗时就是120ms改成并行总耗时约等于最慢的那个服务假设也是40ms。这个收益是倍增关系完全不需要升级机器配置就能拿到。Spring Boot里做并行调用我习惯用CompletableFuture配合自定义线程池。注意一定不要用Executors.newFixedThreadPool()这种原始方式因为默认的线程池队列是无界的高并发下会堆积大量任务内存可能被撑爆而且线程耗尽后会导致其他业务也被阻塞。Configuration public class AsyncConfig { Bean(businessExecutor) public ThreadPoolTaskExecutor businessExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(2000); executor.setThreadNamePrefix(biz-exec-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后并行调用public OrderDetailVO getOrderDetail(String orderId) { CompletableFutureUserVO userFuture CompletableFuture .supplyAsync(() - userService.getUserByOrderId(orderId), businessExecutor); CompletableFutureListProductVO productFuture CompletableFuture .supplyAsync(() - productService.getProductsByOrderId(orderId), businessExecutor); CompletableFutureAddressVO addressFuture CompletableFuture .supplyAsync(() - addressService.getAddressByOrderId(orderId), businessExecutor); return CompletableFuture.allOf(userFuture, productFuture, addressFuture) .thenApply(v - { // 组装VO return new OrderDetailVO(userFuture.join(), productFuture.join(), addressFuture.join()); }).join(); }有一说一join()这种写法虽然直观但是会阻塞线程。在支持虚拟线程的项目里阻塞的代价大幅度降低所以问题不大但如果你还在用平台线程建议把异步化范围扩大到Controller层用DeferredResult或WebMvcConfigurer的异步支持来释放Tomcat线程。我再多说一句异步化的本质是把“等待”从核心线程中抽离出来让线程去处理其他任务而不是提升单个请求的绝对处理速度。4.2 消息队列削峰填谷而不是把同步调用改成异步就完事除了并行调用另一个常见的吞吐量优化手段是消息队列削峰。典型的场景是秒杀或促销活动瞬时流量可能是平时的几十倍如果直接把所有请求同步打到下游系统下游必然被打垮。消息队列在这里做的事情很简单请求先放进MQ由消费端按自身处理能力拉取和消费实现“削峰填谷”。系统吞吐量不在尖峰处崩溃而是被平滑地消化掉。具体的工程实践里有三个细节需要留意。第一消息队列的中间件选型要跟业务匹配如果是强依赖顺序的消息或者要求消息不丢失选型上要格外注意。第二投递消息时尽量精简消息体不要把整个大对象都塞进去消费端再根据ID回源查询这样可以减轻MQ的存储压力。第三消费端的并发度不是越大越好过大的消费并发可能把下游数据库连接池打满。这里分享一个排错的经典案例某系统引入RocketMQ后消费端一次性拉取了大量消息每条消息处理时都要查一次数据库数据库连接池瞬间被打满消费消息的RT飙升进而导致消息积压。最后将消费端批量大小从100改到20并引入本地缓存处理重复查询才把消费速度稳住。队列本身不解决问题消费端的设计才是关键。4.3 线程池参数调优拒绝策略与等待队列的取舍线程池是并发优化里的“基础功”但很多人的配置是靠默认值走天下这在突发流量下是最容易出事的点。核心参数围绕四个核心线程数、最大线程数、工作队列大小、拒绝策略。CPU密集型任务核心线程数一般配CPU核心数1IO密集型任务可以配CPU核心数 * 2左右或者更高——因为线程大部分时间在等待IO利用更多线程可以把CPU的空闲时间利用起来。在Java 21虚拟线程的场景下这套估算公式几乎可以抛开因为虚拟线程可以开很多而不会带来过高的调度成本和内存开销。队列大小的设置是个博弈问题。队列太小任务会被快速拒绝队列太无界比如默认的LinkedBlockingQueue流量超过线程处理能力后任务会积压到内存溢出。我个人的实践是给线程池一个尽量大的有界队列比如1000~5000但配合“拒绝策略”做兜底而不是依赖无界队列无限堆积。拒绝策略上一般有AbortPolicy抛异常、CallerRunsPolicy调用者执行、DiscardPolicy丢弃、DiscardOldestPolicy丢弃最老的任务。业务场景中我强烈推荐优先考虑CallerRunsPolicy。这个策略的特点是当线程池任务已满时新提交的任务会回退到提交它的线程通常是Tomcat请求线程中执行。虽然会拖慢那个请求但至少不会丢任务、不会大量抛异常也不会让任务无限积压。对大多数在线业务而言慢一点比请求失败要更容易被接受。ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() );5. 压测与性能排查的实操清单5.1 压测工具的选定与指标解读没有压测数据支持的“性能优化”都是自我感动。我的建议是先用压测工具把现状摸清楚再动手优化否则第一轮改完看数据你都不知道提升了多少。工具选型上常见的有JMeter、wrk、ApacheBench和ghzgRPC专用。我个人的习惯是接口调试和复杂链路用JMeter录制方便、学习曲线平缓、支持动态参数和断言日常简单压测用wrk命令行跑一遍很快wrk -t8 -c200 -d60s http://localhost:8080/api/orders-t是线程数-c是并发连接数-d是持续时间。注意wrk的线程数和并发连接数要根据你的压测机和目标机的性能来调整压测机本身不能成为瓶颈。压测要看的指标不只是TPS。重点关注几个值TPS/QPS每秒事务/请求数、平均响应时间、P95和P99延迟、错误率。P99比平均值更能反映真实用户体验因为平均值会被少数极端值拉高或拉低P99则是“99%请求的响应时间都低于这个值”更加稳健。压测过程中的一个常见问题是“压测结果不可复现”。主要原因往往是没有固定压测环境有人在压测过程中跑批、或者线上服务正在被真实用户使用、或者数据库里缓存数据冷热不均。最好在专用的压测环境执行并且把环境规格、压测参数、数据规模都记录下来这样才能保证前后对比是有效的。5.2 一个真实问题排查实录JVM线程阻塞从现象到定位有一次接手一个线上问题某个接口偶发性变慢持续几分钟后自愈。监控上看GC没有明显异常CPU也不高但接口P99从80ms飙到500ms。团队看日志只看到TimeoutException根本定位不到根因。我当时的排查思路先抓线程快照。jstack pid thread_dump.txt用jstack抓两到三次快照间隔5秒观察阻塞线程的堆栈。从快照里发现大量线程阻塞在HashTable.get方法上而且HashTable相关的堆栈反复出现。进一步分析发现某个工具类里为了线程安全用了synchronized修饰的HashTable而所有请求都会读取这个HashTable里的配置数据。在极端并发下synchronized的竞争导致线程排队。后来换成ConcurrentHashMap接口P99立刻回到了80ms左右。这个案例让我记住了几个经验线程快照抓完先看处于BLOCKED和WAITING状态的线程数量如果大面积都是这两种状态基本就是共享资源竞争或锁等待问题。不要只选一次快照就下结论多抓几次对比能更准确定位是周期性线程执行任务导致的瞬时阻塞还是持续性锁竞争。jstack只能看Java线程状态如果是深入GC或堆外内存问题还需要借助jstat、jmap或者在线诊断工具。Arthas是这个阶段的利器能在线反编译、看方法执行耗时、动态修改日志级别排查问题的效率提升不是一点点。5.3 性能优化避坑清单速查表最后把我在实践中反复遇到的“性能优化陷阱”整理成一个表格遇到类似问题可以先对照自查。现象常见原因快速处理方法接口RT升高CPU很低线程阻塞在锁、IO或连接池等待抓jstack看BLOCKED线程的堆栈连接池被耗尽最大连接数太小或慢SQL拖住连接调大maximum-pool-size优先排查慢SQL缓存命中率极低key设计和业务请求不匹配用recordStats()统计重新设计缓存key缓存过期后DB瞬间被打垮缓存击穿或雪崩热点key加互斥锁过期时间加随机偏移数据库CPU占用高全表扫描、索引失效或隐式类型转换执行explain分析SQL修复索引应用启动很慢类加载和JVM初始化开销大使用AppCDS必要时检查是否有大量懒加载Bean初始化线程池任务积压导致内存紧张无界队列 拒绝策略缺失改有界队列配置CallerRunsPolicy接口响应时间中P99远高于P50偶发GC停顿、锁竞争或慢请求拖尾抓GC日志抓线程快照定位尾延迟来源多实例行为不一致各实例本地缓存不一致评估数据一致性要求换Redis或加版本控制这张表的价值在于它能把“性能问题”从一个模糊的大概念快速收敛到具体的检查项上。我自己排查线上问题时碰到一个异常现象最先做的就是翻这张表然后按图索骥去验证。5.4 一个被大多数人忽略的观测点日志与序列化的性能损耗配置调优做得差不多了还有一个容易被当成“小事”但其实影响很大的点日志打印和JSON序列化。日志方面生产环境如果开了DEBUG或者TRACE级别性能断崖式下降是必然的。log4j2的异步日志AsyncLogger比同步日志几十倍的提升在压测环境是能看得见的如果你还在用同步日志在高并发场景下日志写入本身就是锁竞争的热点。我的做法是生产环境用WARNERROR级别关键业务路径上的信息用logger.info但务必走参数化写法避免字符串拼接。序列化方面Jackson是Spring Boot默认的JSON库性能中规中矩。对性能极敏感的接口可以考虑改用fastjson2或gson做局部替换但不建议全链路统一换因为兼容性和安全性需要评估。另外HTTP接口返回体尽可能瘦身字段能用基础类型绝不用包装类能不加JsonInclude(ALWAYS)就别加减少无效数据传输带来的带宽和反序列化开销。说得更直接一点你在接口上少打印一行日志、少序列化一个无用字段在高并发场景下的累计收益可能比盲目升级机器配置还实在。写在最后几个让人印象深刻的实战体会回看这些年折腾Spring Boot性能优化的经历能提升500%的项目绝不是靠书上那种“银弹”策略一次达成的而是一点点排查、一层层优化的结果。我的一个深刻体会是性能优化必须要知道瓶颈在哪里用数据驱动而不是凭感觉乱改。你不能因为别人说Caffeine缓存好就全项目无脑加缓存也不能因为别人说虚拟线程厉害就所有业务都往虚拟线程上迁。每一次改造前先定位瓶颈是数据库查询是线程阻塞是缓存失效还是序列化开销然后针对性地做验证性改动再压测对比数据。这才是可持续的性能优化节奏。另一个体会是每次做性能优化一定要给自己留一套可复现的压测脚本和监控面板。没有压测数据后面的一切讨论都是基于感觉没有监控面板你根本不知道优化上线后是变好了还是变坏了。最后再分享一个小技巧Spring Boot应用加上了虚拟线程、JVM参数调整、Caffeine缓存、连接池调优和异步化改造这一整套之后一定要再压一轮完整测试。我见过太多人只优化了一个点就宣布大功告成结果另一个瓶颈又冒出来整体收益微乎其微。性能优化是系统工程组合拳才有效。希望你也能在自己的项目里把TPS从几百压到几千体验一把性能黑洞被根治的快感。
企业数字化 ERP 产品动态
相关推荐
SpringBoot+Vue健身房管理系统毕业设计开发全解析 1. 项目概述与选题价值分析
1.1 为什么健身房管理系统是毕设“常青树” 又到了毕业设计选题的季节,每年这个时候总有同学在技术选型和题目选择上反复纠结。如果你正在找一个“难度适中、技术栈主流、演示效果好、答辩好讲”的题目,健身房综合管理系统确… · 2026/9/26 11:44:59
HarmonyOS Navigation V2 路由栈管理实践与避坑指南 最近在做 HarmonyOS 应用的导航层改造,把项目从 Navigation V1 整体迁到了 V2 方案,过程中踩了不少坑,也把路由栈管理的一些经典场景重新捋了一遍。这篇就专门聊聊 Navigation(V2) 的架构思路、核心 API 用法、路由栈管理的实践细节ÿ… · 2026/9/26 11:44:52
鸿蒙ArkUI Navigation V2路由栈管理与导航架构实战指南 干过鸿蒙应用开发的朋友应该都有体会:不管项目大小,页面之间怎么跳、返回之后数据怎么带、栈怎么清,永远是绕不开的硬骨头。HarmonyOS 6 的 ArkUI 虽然补了很多能力,但很多人上手 Navigation 组件 V2 时还是懵——网上资料不少&am… · 2026/9/26 11:44:52
异步任务调度器架构实践:从线程池到协程调度与超时控制 前几天我们内部一个叫“ax”的调度模块被新同事翻出来追问了好几次,起因是热词榜上突然挂了个“ax调度”,点进去发现大家说的其实是一类很朴素的问题:一堆异步任务挤在一起,到底怎么排、怎么跑、怎么在超时前收场。我仔细看了一下… · 2026/9/26 12:25:37
WorkBuddy + Flask + SQLite:快速搭建日更站点的实战指南 1. 为什么我选择 WorkBuddy Flask SQLite 这套组合1.1 从“想做个站”到“真的跑起来”之间差了什么很多人第一次冒出“自己建个站”的念头,往往是因为看到了某个很酷的页面,或者手里有一批想展示的数据。但真动手的时候,问题就来了&#x… · 2026/9/26 12:25:37
STM32驱动红外PM2.5传感器实战:硬件滤波、串口抗干扰与数据校验 1. 项目概述:为什么STM32连接红外PM2.5传感器不是“接上线就完事”的简单活你手头有一块STM32F103C8T6最小系统板,淘宝上刚拆封的PMS5003或PMS7003红外PM2.5传感器模块,杜邦线一插,串口一连,串口助手里却只刷出乱码、空… · 2026/9/26 12:25:31
静态排流水原理与工程价值:超标量处理器的编译期调度之路 辩经系列写到第六篇,今天想把“静态排流水”这件事单独拎出来聊透。起因是有人问我:你天天说超标量处理器,那静态排流水到底是什么意思?它和乱序执行是不是就差了“硬件里有没有调度器”这一个东西?这问题看着基础&… · 2026/9/26 12:25:25
流量分析实战:从Wireshark抓包到异常研判的完整方法 上周帮朋友排查一台业务服务器的问题,现象是高峰期CPU直接飙到90%以上,应用侧日志翻来覆去看不出异常。后来我在入口交换机做了个端口镜像,抓了二十分钟流量,真相很快浮出水面——不是应用代码的锅,而是一段异常重试逻… · 2026/9/26 12:25:25
SpringBoot+Vue外卖配送管理系统:从数据库导入到前后端联调避坑指南 简介:基于SpringBootVue的外卖配送管理系统源码与数据库,专为计算机专业毕设及Java后端学习者设计,覆盖前后端分离的完整业务场景。系统按功能模块划分:用户信息管理、优惠券领取、通知提醒、银行卡/微信/支付宝多支付方式&#x… · 2026/9/26 12:25:25
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46