你正盯着线上报警,CPU飙升到90%,请求响应从5毫秒变成了5秒。重启大法救急后,你心里清楚:问题还在,只是暂时躲起来了。SpringBoot项目性能调优从来不是“没事找事”,而是当用户量从一千涨到一万、从一万涨到十万时,那个你不得不面对的硬仗。
很多人觉得调优就是改几行配置、加个缓存,其实远远不够。下面这八个实用技巧,是我在多个高并发项目里反复验证过的,它们能帮你从“能用”跨入“好用”的门槛。
线程池配置——别让你的CPU“干等”
SpringBoot默认使用Tomcat作为内嵌容器,其线程池最大线程数(server.tomcat.max-threads)默认是200。听起来很多?如果你的每个请求都阻塞在数据库I/O上,200个线程很快就全卡住了,新的请求只能排队——这就是你看到响应时间飙升的根源。
线程池不是越大越好。线程过多会导致上下文切换频繁,反而降低吞吐量。一个实用的黄金法则是:线程池大小 = CPU核心数 × (1 + 等待时间/计算时间)。对于I/O密集型的业务(如数据库查询、RPC调用),等待时间远大于计算时间,可以适当放大线程数,但不要超过500。同时,记得配置accept-count和max-connections,让排队发生在连接层面而非线程层面。
我见过一个项目,把max-threads从200提到500后,接口吞吐量反而下降了30%。原因是线程数超过临界点后,CPU大部分时间都在做线程切换,真正处理业务的时间变少了。调优必须配合监控,而不是盲目加数字。使用ThreadPoolExecutor的RejectedExecutionHandler策略,选择CallerRunsPolicy可以在极端情况下降级,让提交任务的线程自己执行——这是最后的防线。
连接池调优——数据库连接的“高速公路”
SpringBoot默认用HikariCP,但很多人的配置就只是改了URL和密码。连接池的性能影响有多大?一个典型场景:你的应用有100个线程,连接池最大设成了20,那么80个线程会排队等待数据库连接。如果每次请求数据库耗时50ms,那么排队时间可能超过200ms。
连接池的核心参数不是最大连接数,而是最小空闲连接数和超时时间。minimum-idle设得太小,流量高峰期会频繁创建连接,造成建连开销;设得太大,又会浪费内存。我建议:让最小空闲连接等于最大连接数的70%左右,这样既能应对突发流量,又不会在低峰期闲置过多资源。connection-timeout务必设为一个合理的值(如30000ms),否则当数据库挂掉时,应用会一直阻塞下去。
另一个容易踩的坑是max-lifetime。HikariCP默认30分钟,但多数数据库(如MySQL)会主动关闭空闲超过8小时的连接。如果max-lifetime大于数据库的wait_timeout,就会拿到已经断开的连接,抛出异常。请确保HikariCP的max-lifetime小于数据库的wait_timeout,并且留出几分钟余量。
缓存策略——让数据访问飞起来
SpringBoot最常见的性能问题就是重复查询数据库。一个商品详情页,每次刷新都要查一次数据库,100个人并发就100次查询——数据库不崩才怪。引入缓存是性价比最高的优化手段。
但缓存不是万能的。很多人用了Spring Cache就以为万事大吉,却忽略了缓存穿透、缓存雪崩和缓存击穿这三个恶魔。缓存穿透:查询一个不存在的数据——每次都要穿透到数据库。解决方案:缓存空值(设置较短的过期时间)或使用布隆过滤器。缓存雪崩:大量key同时过期——瞬间涌入数据库。解决方案:过期时间加随机值,避免集中失效。缓存击穿:热点key失效时遭遇高并发——上千个线程同时去重建缓存。解决方案:使用互斥锁(synchronized或Redisson分布式锁)让只有一个线程去加载数据。
在SpringBoot中,推荐用Caffeine作为本地缓存,它可以设置最大容量和基于访问时间的过期策略。对于分布式缓存,Redis+Redisson是标配。但有一点必须记住:缓存的命中率低于70%时,你该反思是不是缓存的粒度或过期时间设置不合理。不要为了“用缓存而缓存”,每次写入缓存都问问自己:这个数据真的适合缓存吗?
JVM参数调优——给应用“瘦身”和“加油”
SpringBoot应用跑在JVM上,JVM参数就是它的“引擎调校”。很多人只在启动脚本里写了-Xmx和-Xms,这远远不够。我见过一个项目,堆内存只有256M,却要处理每分钟上万笔交易——频繁的Full GC直接导致应用假死。
首要原则:-Xms和-Xmx设为相同值,避免运行时动态调整堆大小带来的性能开销。然后,根据应用特点选择垃圾回收器。JDK8默认的ParallelGC适合吞吐量优先的场景(如后台批处理),但Web应用更看重响应时间,推荐使用G1GC(JDK9+默认)。G1GC的-XX:MaxGCPauseMillis目标设为100ms左右,配合-XX:InitiatingHeapOccupancyPercent=45来提前触发并发标记。
另一个容易被忽视的点是元空间(Metaspace)。SpringBoot大量使用CGLIB和反射,如果-XX:MaxMetaspaceSize太小,会导致频繁的Full GC。建议设为256M以上。此外,打印GC日志是调优的第一步:-Xloggc:/path/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps。通过分析GC日志,你能一目了然地看出是老年代满了还是新生代晋升太快。
异步化与消息队列——解耦与削峰
你的接口里可能同时包含下单、发送短信、写入日志、更新统计四个步骤。如果全部同步执行,接口响应时间就是这四个步骤的总和。而大部分情况下,短信发送失败不应该导致下单失败——异步化是提升接口吞吐的利器。
SpringBoot原生支持@Async注解,但要注意两点:第一,必须自定义线程池,避免使用默认的SimpleAsyncTaskExecutor(它每次都会创建新线程)。第二,让异步任务和主业务共用同一个Spring事务上下文可能会有问题——使用@Transactional(propagation = Propagation.REQUIRES_NEW)或者将异步任务独立成无状态服务。
对于更复杂的场景(秒杀、大促),消息队列(RocketMQ、Kafka)是必备武器。削峰填谷:将瞬时的高流量请求先放入消息队列,后端消费者按自己的处理能力慢慢消费。注意消费端的批量拉取和手动ACK,防止消息积压后丢失。消息队列不是银弹,它引入了分布式一致性挑战。如果业务要求强一致性,请谨慎使用。
压缩与静态资源优化——减少网络传输
SpringBoot应用如果提供REST API,响应体大小直接影响网络传输时间。一个返回100KB JSON的接口,在千兆内网可能只需几毫秒,但如果是移动端4G网络,50ms以上的延迟很正常。开启Gzip压缩能显著减少传输流量。
在application.yml中配置server.compression.enabled=true,server.compression.mime-types=application/json,text/html,server.compression.min-response-size=1024(小于1KB的不压缩)。注意,压缩会消耗CPU,但现代服务器CPU通常不是瓶颈,而网络带宽往往是。压缩后JSON体积能减少60%-80%,性价比极高。
对于前端静态资源(JS、CSS),SpringBoot默认使用缓存头,但你可以进一步优化:开启spring.resources.cache.cachecontrol.max-age=31536000,并配合版本号(spring.resources.chain.strategy.content.enabled=true)确保更新时能强制刷新。CDN加速:如果静态资源部署在应用服务器上,建议迁移到OSS+CDN,这能让用户从最近的节点获取数据,大幅降低延迟。
日志异步输出与级别控制——别让日志拖垮性能
日志是定位问题的刚需,但也是性能杀手。很多线上事故的根因竟然是“日志太多导致磁盘I/O饱和”。一个微服务每秒打印几千行DEBUG日志,日志框架处理这些I/O操作可能会占据20%以上的CPU。
生产环境禁止使用DEBUG级别。日志级别设为WARN或ERROR,只记录真正有用的信息。如果需要临时排查,可以通过动态修改日志级别(如Actuator的/loggers端点)来开启,用完立刻恢复。日志输出必须异步化:Logback的AsyncAppender、Log4j2的AsyncLogger都是标配。注意设置queueSize(默认256)和neverBlock(true),当队列满时直接丢弃日志而不是阻塞业务线程。
还有一个容易被忽略的点:使用占位符而不是字符串拼接。log.info("user {} login", userId)比log.info("user " + userId + " login")性能高得多——前者在日志级别不满足时不会执行拼接操作。如果你在循环中打印日志,务必加上if (log.isDebugEnabled())判断。让日志成为轻量级的“后手”,而不是拖累主流程的“猪队友”。
数据库查询优化——慢SQL是性能杀手
无论缓存做得多好,总有请求要打到数据库。一个慢SQL可以拖垮整个应用。在数据库层面,索引是王道。但索引不是越多越好,过多的索引会拖慢写入和更新。你需要通过慢查询日志(long_query_time=1)找出执行时间超过1秒的SQL,然后用EXPLAIN分析执行计划,重点关注type(不要是ALL全表扫描)和rows。
SpringBoot中,JPA/Hibernate的N+1查询问题很典型。一对多关联时,如果不配置@BatchSize或使用@EntityGraph,查询一条主记录会引发N条子记录查询。请务必开启SQL日志(spring.jpa.show-sql=true)并检查实际执行的SQL条数。一个简单的解决办法是改用JOIN FETCH或者设置@OneToMany(fetch = FetchType.LAZY)配合@Transactional。
对于复杂统计查询,不要用ORM框架,直接写原生SQL或使用MyBatis。分页查询是大忌:limit 1000000,20会先扫描100万行再抛弃,性能极差。使用游标分页或覆盖索引来优化。记住:数据库查询每减少一次,应用性能就上一个台阶。能不查就不查,能批量查就别逐条查。
性能调优不是一锤子买卖。你今天改了一个线程池参数,明天流量翻倍,可能又需要调整。建立持续的性能监控体系(Prometheus + Grafana),关注TP99、GC频率、连接池使用率等核心指标。当业务规模增长时,这些技巧只是你的工具箱,真正重要的是你对系统瓶颈的判断力。
最后送你一句话:调优要从整个请求链路去看,而不是只盯着某个局部。数据库慢了,你加大线程池只会让数据库更慢;缓存命中率低了,你优化JVM也挽救不了。从用户请求进入Web容器开始,到返回响应结束,每一个环节都像多米诺骨牌——最小的那块决定了整座塔的稳定性。