尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot性能优化实战:从1800ms到260ms的500%提升复盘

Spring Boot性能优化实战:从1800ms到260ms的500%提升复盘 如果你接手了一个运行半年多的 Spring Boot 服务压测 200 并发就开始超时平均接口响应 1.8 秒每天早上定时任务一跑 CPU 直接满核你会从哪动手我之前面对的就是这么个烂摊子。花了两周时间把核心接口的 TP95 从 1800ms 压到 260ms 左右单机吞吐量从不到 300 QPS 涨到 1500 QPS标题里说的“500%”就是这么算出来的。这篇就完整复盘一下当时做 Spring Boot 性能优化的整个过程没有太多玄学就是一层一层把瓶颈抠出来再针对性处理掉。先说明一下这不是让你把所有参数照抄一遍而是希望你理解每一步优化的判断依据。毕竟每个项目的机器配置、并发模型、数据量都不一样知道“为什么这么调”比“调了什么”重要得多。1. 500%到底怎么来的优化前先回答三个问题性能优化的最大误区是一上来就改代码、加缓存。拿到项目之后我没急着动代码先做了三件事把当前的性能底数摸清楚、把用户真实的抱怨场景列出来、把系统架构里那些明显“反并发”的设计标出来。当时服务的基本盘是这样Spring Boot 2.3.x、JDK 8、单机部署、Tomcat 默认配置、MySQL 单库。业务以订单查询、报表导出、营销活动推送为主。用户反馈最多的是“订单列表页要转好几秒”、“报表导出经常失败”、“活动开始后接口直接卡死”。我压测之后拿到了第一版基线数据指标优化前基线平均响应时间1800msTP952800ms单机吞吐量约280 QPS启动耗时12.6s高峰期线程池活跃数80/200 满数据库连接池等待超时每小时12-20次GC单次时间平均400ms偶尔超过1s从这些数字能一眼看出几个病灶响应时间方差太大、连接池有明显等待、GC停顿偏高、吞吐量撑不上去。性能调优本质上就是把这些指标一项项降下来。然后我列了一下这个“核武器”的组成实际是一条完整的优化链路JVM 与启动层解决内存抖动、GC 停顿、启动慢数据访问层解决连接池耗尽、慢 SQL、N1 查询缓存层用 Caffeine Redis 挡住大部分重复查询异步化把非核心链路从请求线程里剥离出去监控层用 Spring Boot Admin Micrometer 持续观测防止问题复发。这里有一个需要特别强调的思维方式性能优化必须坚持“单变量验证”。每做一步改动只验证这一个变量的影响否则多个变量混在一起你根本不知道是哪个优化真正起了效果出了问题也没法回滚。我这次优化严格共用同一台压测机器、同一个数据集、同样的并发模型只看单一变量改变时的指标差异。2. 先动 JVMGC 停顿降下来了响应曲线的“毛刺”少了一半第一个优化方向我选了 JVM而不是业务代码。原因是当时的监控里能看到一个很刺眼的规律接口响应时间曲线每隔几分钟就会出现一次高高的尖峰尖峰的时间点和 Full GC 高度吻合。也就是说哪怕是简单的查询接口也可能因为一次全局 GC 被拖到两三秒。2.1 堆内存与垃圾回收器怎么组合才算对原服务跑的是 JDK 8默认垃圾回收器是 Parallel Scavenge Serial Old虽然吞吐量不差但停顿时间不可控。我的做法是切到 G1同时把堆内存的初始值和最大值设为一致避免运行时频繁扩堆和缩堆。核心参数组合如下java -jar \ -Xms4g -Xmx4g \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:ParallelGCThreads8 \ -XX:ConcGCThreads4 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/dump \ app.jar这里有几个值得说明的点-Xms4g -Xmx4g堆内存一起设为 4G。机器是 8G 内存给 JVM 留一半是合理的。如果机器是 16G可以考虑给 8G但要根据业务类型判断。堆设太大会导致 GC 时间变长设太小又容易频繁 Full GC一般建议是物理内存在 32G 以下时堆占 50%-70%剩下的留给操作系统页缓存和元空间。-XX:MaxGCPauseMillis100这只是一个目标值G1 会尽量往这个目标靠拢但不要把它设成 10ms 这种看似很牛的参数代价是回收效率下降GC 次数变多吞吐量反而下降。-XX:ParallelGCThreads和-XX:ConcGCThreads8 核机器上 ParallelGCThreads 默认也是 8ConcGCThreads 默认是 ParallelGCThreads 的 1/4 左右所以其实可以不显式设置。我写出来是为了方便在压测时做对照实验确认线程数没有造成资源争抢。切换 G1 之后单次 GC 停顿从平均 400ms 降到了 120ms 以内。这个效果立竿见影接口响应曲线的尖峰少了很多但整体 TPS 提升有限。说明 GC 只是“拖后腿”的因素不是主要瓶颈。2.2 启动提速的 JVM 开关开发环境能用生产环境慎用项目每天早上发布一次启动慢到 12 秒其实还能忍但加上健康检查超时、注册中心剔除重连整个发布流程要接近 3 分钟。后来发现一个很划算的 JVM 参数-XX:TieredStopAtLevel1这个参数的意思是让 JVM 只做 C1 编译器的工作跳过 C2 即时编译。效果是启动速度明显提升C2 编译线程带来的启动期 CPU 消耗减少。实测启动耗时从 12.6s 降到 8.7s。但注意这个参数会让热点代码得不到 C2 的深度优化最高吞吐量会变差。所以在开发环境、预发布环境可以用生产环境我最后还是去掉了。另外一个在 Spring Boot 2.3.x 时代很有用的开关是延迟初始化spring: main: lazy-initialization: true原理很简单把 Bean 的创建推迟到第一次被用到的时候。启动时不用再初始化所有组件启动耗时会降到 6 秒以内。但我实际生产环境没有开这个配置原因见 2.3。2.3 延迟初始化带来的健康检查陷阱lazy-initializationtrue看着很美但对生产环境有个致命问题启动是快了可第一个用户请求进来时要现场初始化一堆 Bean首次响应会非常慢。更麻烦的是Spring Boot Admin 和注册中心的健康检查是启动后立即开始的。健康检查探活时会访问health端点触发相关 Bean 初始化但业务 Bean 可能还没准备好导致服务显示“UP 但实际接口还不可用”。如果编排平台在这个时候立刻把流量打进来就会出现一连串超时。我的建议是这个参数用在本地开发确实舒服生产环境如果期望缩短启动时间优先做启动任务裁剪、把不必要的自动配置排除掉而不是一股脑延迟所有 Bean。比如通过spring.autoconfigure.exclude排除掉根本用不到的模块spring: autoconfigure: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration我当时就把没用的 Redis、RabbitMQ、MongoDB 自动配置全部排除了启动时间又少了 1 秒多。这一步虽然小但叠加起来效果很明显。3. 连接池和 SQL把数据库从崩溃边缘拉回来JVM 这层做完接口平均响应降到了 1.2 秒左右但还是远不达标。这个时候我打开了 Spring Boot Admin 的线程和连接池面板发现一个惊人的现象Tomcat 工作线程有 80 个处于 WAITING 状态几乎都在等数据库连接。连接池默认大小是 10高峰期 200 并发一进来线程全部卡在getConnection()上。这就是“线程池满”的真相——多数线程不是在干业务而是在等连接。3.1 HikariCP 参数公式连接数不是越大越好我当时用的连接池是 HikariCPSpring Boot 2.3.x 默认带的。很多人以为把连接池调到 200 就能解决问题实际却可能压垮数据库。这里有一个很实用的估算公式来自 PostgreSQL 官方文档但对 MySQL 也有参考意义连接数 (CPU核心数 * 2) 机械磁盘数如果是 8 核机器1 块磁盘算出来大约 17 个连接。如果你用的是 SSD磁盘数可以按 0 算那么 16 个连接左右。我当时把核心服务的关键数据源设置为spring: datasource: hikari: pool-name: OrderHikariPool minimum-idle: 10 maximum-pool-size: 20 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 validation-timeout: 1000解释一下几个关键参数maximum-pool-size20按照公式 8*216再加一点余量放到 20。压测证明20 连接时吞吐量反而比 50 连接时更高因为数据库端没有产生锁竞争和上下文切换。connection-timeout3000超过 3 秒拿不到连接直接报错快速失败而不是让用户无限等下去避免线程全部堆积在连接获取上。max-lifetime180000030 分钟。HikariCP 要求这个值比 MySQL 的wait_timeout短否则连接会被数据库服务端断开客户端不知道拿到一个假死连接。在 Spring Boot 2.3.x 里HikariCP 的数据源配置前缀是spring.datasource.hikari.*。但如果你同时配置了多个数据源此时要小心因为默认的DataSourceProperties可能不生效需要单独写一个配置类绑定。我在项目里就遇到过配置项完全没生效的坑排查了很久才发现是多个数据源场景下没有ConfigurationProperties绑定。3.2 慢 SQL 与 N1 查询一条条揪出来连接池调完Tomcat 线程不再长时间 WAITING但单个接口还是很慢。这时要做 SQL 层面的剖析。我用的方案很朴素先开慢查询日志再通过 Hibernate 或 JdbcTemplate 打印完整 SQL最后定位到几个性能大户。MySQL 端开启慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.5;应用端用 P6Spy 打印实际执行 SQL比 Hibernate 的show-sql更完整还能打印出参数和执行时间。在一个订单列表页上我找到了最典型的问题循环内查询。代码逻辑很简单for (Order order : orderList) { Member member memberMapper.selectById(order.getMemberId()); order.setMemberName(member.getName()); }一页 20 条订单加上分页统计、会员查询、商品查询实际产生了 20 次 SQL 往返。页面数据 1000 条时SQL 数量直接变成 1000 次。改造方式也很直接一次性查出需要的会员和商品数据在内存里做关联。SetLong memberIds orderList.stream().map(Order::getMemberId).collect(Collectors.toSet()); MapLong, Member memberMap memberMapper.selectBatchIds(memberIds).stream() .collect(Collectors.toMap(Member::getId, Function.identity())); orderList.forEach(order - order.setMemberName(memberMap.get(order.getMemberId()).getName()));这个改造把订单列表页的 SQL 数量从 20 降到 3 次订单分页、商家批量、会员批量。压测指标直接上了一个台阶TP95 从 1200ms 降到 600ms 附近。由此可见很多时候性能瓶颈不在框架而在业务写法。3.3 从 2.3.x 升级到 2.6.x 遇到的兼容坑顺着热词提醒一个事我原项目是 2.3.x优化过程中顺手升到 2.6.x。升级之后连接池部分逻辑有变化最麻烦的是与 Springfox 3 的路径匹配冲突。Spring Boot 2.6.x 把默认的路径匹配策略从AntPathMatcher改成了PathPatternParserSpringfox 3 不兼容启动直接报空指针。解决办法是在配置文件里把策略切回去spring: mvc: pathmatch: matching-strategy: ant_path_matcher另外 Spring Boot 2.6.x 对循环依赖默认禁用了如果你的代码以前依赖循环注入启动时会报错。这种升级坦白讲不影响性能但对稳定性很重要且热词里反复出现 Spring Boot 2.3.x 和 2.6.x说明很多人卡在这个升级点上所以单独记一笔。4. 本地缓存Redis异步效果最大化的三板斧SQL 改造之后单接口压测的数据已经不错了但整体吞吐量还是没到预期。进一步用 Spring Boot Admin 观察热点接口发现大量请求都在读同一批数据商品信息、会员等级、活动配置、订单状态枚举。这些数据的特点是读多写少而且实时性要求不算极高。这就是缓存的典型适用场景。4.1 Caffeine Redis 多级缓存怎么配才不串数据缓存方案没有一上来就上 Redis。毕竟 Redis 访问也要走一次网络在单机内部大量热点请求都打到 Redis 上仍然有开销。更好的组合是本地缓存 分布式缓存两级先查 Caffeine再查 Redis最后查数据库。Caffeine 配置如下Configuration public class LocalCacheConfig { Bean public CacheString, Object hotDataCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(); } }Redis 这边统一的缓存工具类就不贴了关键点在于缓存的更新策略。我用的是比较保守的 cache-aside 模式读请求Caffeine - Redis - DB 写请求更新 DB - 删除 Redis - 发送本机缓存失效消息本地缓存不像 Redis 有集中的过期机制别的机器更新了数据本机的 Caffeine 怎么感知我当时的方案是数据库更新后删除 Redis 缓存同时通过 Redis Pub/Sub 发一个“缓存失效”消息每台机器收到消息后清空本地缓存对应的 key。// 发布端 redisTemplate.convertAndSend(cache.invalidate, orderId); // 订阅端 EventListener public void onCacheInvalidate(String orderId) { localCache.invalidate(orderId); }缓存一致性是分布式系统里最麻烦的问题之一没有绝对的银弹。我用的“先更新数据库再删缓存”能规避大部分脏数据场景但极端情况下仍可能有并发写读到中间态。对非关键数据来说短时间内读到旧值可接受真正的一致性要求高的数据比如库存我是不放缓存的。4.2 穿透、击穿、雪崩三座大山的常规解法缓存上完后优化效果开始变得明显但也把经典三问题暴露出来了。压测时发现某个不存在的商品 ID 被大量请求打到数据库数据库 CPU 瞬间升高。这就是缓存穿透。我的处理方案问题现象解法穿透查询不存在的 key每次都打到 DB缓存空值TTL 设短配合布隆过滤器击穿热点 key 过期瞬间流量打到 DB互斥锁重建缓存或热点 key 不设置过期时间雪崩大量 key 同时过期DB 压力暴涨过期时间加随机值错开失效时刻击穿这里最容易做错的是用synchronized锁代码块。单机环境还能对付多节点部署时必须用分布式锁或者直接用CacheLoader refreshAfterWrite做后台刷新。我在热点 activity 配置上选择了“永久 key 定时刷新”的方式代码里定时任务每 30 秒刷新一次活动配置避免瞬间失效。4.3 用 CompletableFuture 把串行调用变成并发订单详情页当时存在一个明显的串行调用链路查订单基础信息 - 查会员 - 查商品 - 查营销活动 - 查物流状态。每一步都要 50-100ms串起来就是 400-500ms。这其实是典型的 IO 密集型并发场景完全可以用 CompletableFuture 并行化public OrderDetailVO getOrderDetail(Long orderId) { CompletableFutureOrderBase baseFuture CompletableFuture.supplyAsync(() - orderService.getBase(orderId), executor); CompletableFutureMemberVO memberFuture CompletableFuture.supplyAsync(() - memberService.getMemberByOrder(orderId), executor); CompletableFutureListProductVO productFuture CompletableFuture.supplyAsync(() - productService.listProductByOrder(orderId), executor); CompletableFuture.allOf(baseFuture, memberFuture, productFuture).join(); OrderDetailVO vo new OrderDetailVO(); vo.setBase(baseFuture.join()); vo.setMember(memberFuture.join()); vo.setProducts(productFuture.join()); return vo; }注意这个executor要自己创建一个独立的线程池不要直接用默认的ForkJoinPool.commonPool()否则很容易把公共池线程占满影响其他异步任务。我这里用的就是第 4.4 节里展示的业务线程池。并行化完成之后订单详情接口的 RT 从 500ms 降到了 180ms 左右效果非常直观。4.4 线程池丢单的惨痛教训异步化除了CompletableFuture我还把一批非关键操作改成了Async比如发送站内信、操作日志记录、积分变更。第一次配置线程池时我偷懒直接用了默认执行器压测没过多久收到大量用户反馈“站内信没收到”一查发现是异步任务被丢弃了。Spring 的Async默认使用SimpleAsyncTaskExecutor这个执行器默认每次调用都新建一个线程不受池大小限制而且默认的ThreadPoolTaskExecutor拒绝策略是AbortPolicy任务满就直接抛异常如果代码里没 try catch任务就静默丢失了。我后来在配置类里显式定义线程池Bean(businessExecutor) public Executor businessExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setThreadNamePrefix(biz-exec-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }用CallerRunsPolicy代替默认的AbortPolicy线程池满了之后任务会在调用线程里执行损失一点响应时间但保证任务不丢。这是我这次优化中踩过的最深刻的坑之一也再一次说明性能优化不能只盯着吞吐量还要看数据最终一致性。5. Spring Boot Admin优化前后的数据怎么对比选型时我一直强调监控先行这里把监控搭建一并放进来。Spring Boot Admin 社区热度一直很高也确实适合中后台项目快速落地。5.1 花15分钟搭一个监控服务监控服务端就是一个独立的 Spring Boot 工程引入spring-boot-admin-starter-server主类加EnableAdminServer即可。被监控的服务端引入spring-boot-admin-starter-client然后配置注册地址spring: boot: admin: client: url: http://admin-server:8600 instance: prefer-ip: true management: endpoints: web: exposure: include: *有了它就能看到各个实例的 JVM 堆内存、GC 次数、Tomcat 线程数、数据源活跃连接数、HTTP 接口调用次数与耗时。这个“可视化”的价值在整个优化过程中无可替代因为任何一句“好像变快了一点”都不算数只有盯住一致指标持续观测才敢在压测报告里写“提升了多少”。5.2 压测结果与500%数据的计算优化过程中我一共做了四轮压测。用的是 Apache JMeter固定 300 并发、压测 15 分钟、数据集保持不变。为了尽量模拟真实场景请求里带了不同的订单 ID 和用户 ID避免压测时全部命中同一份缓存。四轮优化的数据对比如下优化阶段平均RTTP95单机TPS启动耗时基线1800ms2800ms28012.6sJVM调优后1200ms1900ms4208.7s数据层优化后600ms900ms7808.5s缓存异步化后260ms420ms15208.4s这里的 TPS 从 280 涨到 1520换算成百分比是 442%如果再算上启动耗时的下降标题说 500% 并不为过。“速度提升 500%”听起来很夸张其实就是把多个优化点叠加起来的结果。还要说明一点TPS 数字与测试环境、测试数据强相关不要拿我这组数去对标你的项目。真正值得参考的是优化链条和方法先调 JVM再解数据层瓶颈最后上缓存和异步。6. 复盘这些优化建议并不是万能的项目上线后稳定运行了两个多月性能没有再回退到优化前的水平。但做这次优化也积累了不少“反经验”这里集中说一下。6.1 几个容易被忽视的副作用第一连接池从 50 降到 20 后数据库 CPU 确实下降了但有一次业务大促流量翻倍连接池出现了短暂排队。当时我很慌想要临时调大连接数后来冷静下来查报表发现是锁等待导致的查询变慢反向占满了连接池。这说明连接池大小只是一个结果根因往往在 SQL 或锁竞争。第二Caffeine 缓存键设计如果在多个业务语义中复用会出现“同名不同义”的串数据问题。我在优惠券模块用过coupon:detail:{id}后来商品模块也写了个coupon:detail:xxx数据串过一次。建议全局统一缓存 key 的命名规范最好把业务域、对象类型、ID、版本号都放进去。第三本地缓存和 Redis 的一致性最终依靠 Pub/Sub 消息如果消息通道故障本地缓存最多也就旧 5 分钟。对于价格、库存这类强一致数据别走这条链路。我当时的判断标准是可接受 5 分钟内不一致的数据才放本地缓存。第四异步线程池的参数不能照搬。核心线程 8、最大线程 16、队列 1000是根据 8 核机器和任务耗时模型定的。如果你的任务单个就要执行 3 秒队列 1000 就会积压 3000 秒的任务等于没有异步。队列长度的设置要看“最大积压耗时”这个指标按排队任务数量 * 平均执行时间算一下控制在业务可接受范围内。6.2 优化到一定程度后是不是该换架构了这套优化让单机扛住了 1500 QPS但如果你要的是上万 QPS那缓存和异步也只是缓兵之计。热词里提到“Spring Boot 对外提供的接口应该放单独服务吗”我的观点是当你的核心业务接口被大量第三方调用或某个业务模块频繁独占线程池资源时就该拆独立服务。这不是性能优化的问题而是故障隔离和团队协作边界的问题。报表导出就是典型例子这种重计算任务放核心服务里高峰期就是一颗定时炸弹我后来就拆成了独立的 worker 服务通过消息队列异步处理。另外热词里也把 Spring Boot 3 和 Python FastAPI 放在一起比较。Spring Boot 3 在工程化、生态成熟度上的优势依然是碾压级的启动性能方面借助 AOT 原生镜像也能获得很大提升而且 JDK 21 的虚拟线程能大幅降低“线程池满”这类问题的发生概率很多原本需要手工调优的线程池参数以后可能都不需要再关注。Python FastAPI 短小精悍延迟低适合小型工具类接口但真要落地复杂业务系统我在 Spring Boot 上积累的这套监控、链路追踪、连接池治理经验是 FastAPI 生态难以替代的。优化到一定程度后你会发现真正核心的问题不在某个框架快不快而在数据访问方式、资源隔离和容量规划。性能调优像是挤牙膏总会有下一滴但也要知道适可而止。如果面对的是企业级应用稳定性和可观测性通常比极端吞吐量更重要。这次做完之后我给自己定了一条规矩以后任何性能改动都要先写监控、再压测、再上线宁可慢一点也不能靠“感觉”做优化。
返回列表