我见过太多 Spring Boot 项目:功能没问题、测试全绿、线上也能跑——但一到压测就“像糊在蜂蜜里”。后来我才慢慢意识到:Spring Boot 并不慢,慢的是我们把“默认值”当成了“最佳实践”。
这篇我按“真实踩坑路径”把常见的性能坑拆开讲:从数据库、线程、序列化、启动、JVM、到观测与配置。每一节都给你能落地的动作,你拿去对照自己的项目做一轮体检,效果通常比你想象更明显。
0)先立规矩:别用“感觉”,用“证据”
优化前先把基线立起来,否则你永远在“我觉得快了”。
最少做三件事:
- 指标:P50 / P95 / P99、RPS、CPU、GC Pause、DB QPS、连接池等待时间
- 链路:接口耗时拆分(Controller / Service / DB / 外部调用)
- 对照:同一份压测脚本、同一份数据量、同一份机器规格
如果你现在还没有“应用内部视角”,优先把 Actuator + Micrometer + Prometheus/Grafana 接上,别继续盲飞。
1)数据库:你以为是 “一次查询”,其实是 “101 次查询”
典型症状
列表接口返回 100 条用户,每条用户都要读 orders、roles、tags……然后你写了 getOrders()。
结果 Hibernate 默默给你打出 N+1:
1 次查用户 + N 次查订单 = 延迟直线上升。
解决思路(别只会“全改 EAGER”)
我更推荐三个工具组合,按场景选:
A. EntityGraph:声明式把某次查询变“带上关联”
@Entity@NamedEntityGraph( name = "User.orders", attributeNodes = @NamedAttributeNode("orders"))class User { ... }@EntityGraph("User.orders")List findAllWithOrders(); B. JOIN FETCH:需要强控制时更直接
@Query("select u from User u join fetch u.orders where u.id in :ids")List findAllWithOrders(@Param("ids") List ids); C. BatchSize:必须 lazy,但想“批量懒加载”
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")@BatchSize(size = 25)private List orders; 一句话标准:
- 列表页:宁可 join fetch / entity graph,一次取齐
- 详情页:可以分层 DTO + 精准查询
- 超大数据:分页 + 批量策略,别“全拉爆”
2)连接池:默认 10 个连接,够谁用?
很多人把 HikariCP 当“自带就不用管”,然后线上高峰出现:
- 请求排队、线程卡住
- DB 明明没满,应用却在等连接
默认池大小通常比较保守;在并发请求 + DB 操作偏多的系统里,连接池就是吞吐量阀门。
你可以这样调一个“可用起点”
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 20000 idle-timeout: 300000 max-lifetime: 1200000 leak-detection-threshold: 60000更关键的“思考方式”
池大小不是越大越好:
- 应用端大了:DB 扛不住、锁竞争更惨
- 小了:应用端排队、延迟飙升
一个务实的策略是:
先压测看“等待连接”的比例 → 再逐步调大到“等待基本消失但 DB 仍稳定”的点。
3)缓存:不是“全加 @Cacheable 就快”
我也干过这种事:凡是查 DB 的方法都 @Cacheable。最后发现:
- 热点小对象缓存后更慢(序列化/反序列化、锁、淘汰)
- 数据频繁变更导致缓存抖动
- 缓存穿透把 DB 又打穿
缓存的本质是:用空间换时间,但前提是“命中”和“稳定”。
我自己的规则(很朴素但好用)
只缓存同时满足:
- 获取/计算 > 50ms
- 访问频繁
- 变更不那么频繁
- 能接受短暂不一致
另外,别把“派生字段”单独再缓存一次,应该复用主对象缓存。
4)JSON 序列化:你返回的是“实体”,输出的是“整个世界”
把 JPA Entity 直接当 API Response,Jackson 往往会:
- 序列化一堆你根本不想给前端的字段
- 触发懒加载(又回到 N+1)
- 返回体巨大,网络与序列化双杀
解决方式:DTO。别偷懒。
DTO 不只是性能,它还是 API 契约隔离。
record UserSummaryDTO(Long id, String name) {}@GetMapping("/users/{id}/summary")UserSummaryDTO summary(@PathVariable Long id) { var u = userService.findById(id); return new UserSummaryDTO(u.getId(), u.getName());}5)异步:别让用户等“非关键路径”
注册接口里最常见的慢操作:发邮件、打点、发 MQ、刷缓存……
这些不影响“主流程成功”的动作,就不要阻塞响应。
Spring 的 @Async + 明确线程池,是最简单的提速手段之一。
@EnableAsync@Configurationclass AsyncConfig { @Bean("appExecutor") Executor appExecutor() { var ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(5); ex.setMaxPoolSize(10); ex.setQueueCapacity(200); ex.setThreadNamePrefix("async-"); ex.initialize(); return ex; }}@Async("appExecutor")public void sendWelcomeEmail(...) { ... }额外一句:如果你在 Java 21 + Spring Boot 新版本上,虚拟线程也值得评估,但不要把它当“银弹”。异步边界、IO 依赖、DB 池大小依然决定上限。
6)启动慢:不是“Spring 太重”,是你让它“全加载”
项目一大,启动慢通常来自两件事:
- 扫描范围太大
- Bean 过早初始化(eager)
三个立竿见影的动作
A. 缩小扫描包
@SpringBootApplication(scanBasePackages = { "com.xxx.controller", "com.xxx.service", "com.xxx.config"})class App {}B. 开启 Lazy Init(谨慎:首次调用会变慢)
spring: main: lazy-initialization: trueC. spring-context-indexer(大项目很香)
它会在构建期预计算候选组件,减少运行期扫描与反射开销。
进阶:GraalVM Native Image / CRaC(看你的部署形态)
如果你是大量小服务、弹性伸缩、冷启动敏感:Native Image 的收益往往非常直观,但你要接受构建与兼容性成本。
7)JVM:你以为是“内存泄漏”,其实是“GC 在求救”
很多“内存问题”本质是:我见过太多 Spring Boot 项目:功能没问题、测试全绿、线上也能跑——但一到压测就“像糊在蜂蜜里”。后来我才慢慢意识到:Spring Boot 并不慢,慢的是我们把“默认值”当成了“最佳实践”。
这篇我按“真实踩坑路径”把常见的性能坑拆开讲:从数据库、线程、序列化、启动、JVM、到观测与配置。每一节都给你能落地的动作,你拿去对照自己的项目做一轮体检,效果通常比你想象更明显。
0)先立规矩:别用“感觉”,用“证据”
优化前先把基线立起来,否则你永远在“我觉得快了”。
最少做三件事:
- 指标:P50 / P95 / P99、RPS、CPU、GC Pause、DB QPS、连接池等待时间
- 链路:接口耗时拆分(Controller / Service / DB / 外部调用)
- 对照:同一份压测脚本、同一份数据量、同一份机器规格
如果你现在还没有“应用内部视角”,优先把 Actuator + Micrometer + Prometheus/Grafana 接上,别继续盲飞。
1)数据库:你以为是 “一次查询”,其实是 “101 次查询”
典型症状
列表接口返回 100 条用户,每条用户都要读 orders、roles、tags……然后你写了 getOrders()。
结果 Hibernate 默默给你打出 N+1:
1 次查用户 + N 次查订单 = 延迟直线上升。
解决思路(别只会“全改 EAGER”)
我更推荐三个工具组合,按场景选:
A. EntityGraph:声明式把某次查询变“带上关联”
@Entity@NamedEntityGraph( name = "User.orders", attributeNodes = @NamedAttributeNode("orders"))class User { ... }@EntityGraph("User.orders")List findAllWithOrders(); B. JOIN FETCH:需要强控制时更直接
@Query("select u from User u join fetch u.orders where u.id in :ids")List findAllWithOrders(@Param("ids") List ids); C. BatchSize:必须 lazy,但想“批量懒加载”
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")@BatchSize(size = 25)private List orders; 一句话标准:
- 列表页:宁可 join fetch / entity graph,一次取齐
- 详情页:可以分层 DTO + 精准查询
- 超大数据:分页 + 批量策略,别“全拉爆”
2)连接池:默认 10 个连接,够谁用?
很多人把 HikariCP 当“自带就不用管”,然后线上高峰出现:
- 请求排队、线程卡住
- DB 明明没满,应用却在等连接
默认池大小通常比较保守;在并发请求 + DB 操作偏多的系统里,连接池就是吞吐量阀门。
你可以这样调一个“可用起点”
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 20000 idle-timeout: 300000 max-lifetime: 1200000 leak-detection-threshold: 60000更关键的“思考方式”
池大小不是越大越好:
- 应用端大了:DB 扛不住、锁竞争更惨
- 小了:应用端排队、延迟飙升
一个务实的策略是:
先压测看“等待连接”的比例 → 再逐步调大到“等待基本消失但 DB 仍稳定”的点。
3)缓存:不是“全加 @Cacheable 就快”
我也干过这种事:凡是查 DB 的方法都 @Cacheable。最后发现:
- 热点小对象缓存后更慢(序列化/反序列化、锁、淘汰)
- 数据频繁变更导致缓存抖动
- 缓存穿透把 DB 又打穿
缓存的本质是:用空间换时间,但前提是“命中”和“稳定”。
我自己的规则(很朴素但好用)
只缓存同时满足:
- 获取/计算 > 50ms
- 访问频繁
- 变更不那么频繁
- 能接受短暂不一致
另外,别把“派生字段”单独再缓存一次,应该复用主对象缓存。
4)JSON 序列化:你返回的是“实体”,输出的是“整个世界”
把 JPA Entity 直接当 API Response,Jackson 往往会:
- 序列化一堆你根本不想给前端的字段
- 触发懒加载(又回到 N+1)
- 返回体巨大,网络与序列化双杀
解决方式:DTO。别偷懒。
DTO 不只是性能,它还是 API 契约隔离。
record UserSummaryDTO(Long id, String name) {}@GetMapping("/users/{id}/summary")UserSummaryDTO summary(@PathVariable Long id) { var u = userService.findById(id); return new UserSummaryDTO(u.getId(), u.getName());}5)异步:别让用户等“非关键路径”
注册接口里最常见的慢操作:发邮件、打点、发 MQ、刷缓存……
这些不影响“主流程成功”的动作,就不要阻塞响应。
Spring 的 @Async + 明确线程池,是最简单的提速手段之一。
@EnableAsync@Configurationclass AsyncConfig { @Bean("appExecutor") Executor appExecutor() { var ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(5); ex.setMaxPoolSize(10); ex.setQueueCapacity(200); ex.setThreadNamePrefix("async-"); ex.initialize(); return ex; }}@Async("appExecutor")public void sendWelcomeEmail(...) { ... }额外一句:如果你在 Java 21 + Spring Boot 新版本上,虚拟线程也值得评估,但不要把它当“银弹”。异步边界、IO 依赖、DB 池大小依然决定上限。
6)启动慢:不是“Spring 太重”,是你让它“全加载”
项目一大,启动慢通常来自两件事:
- 扫描范围太大
- Bean 过早初始化(eager)
三个立竿见影的动作
A. 缩小扫描包
@SpringBootApplication(scanBasePackages = { "com.xxx.controller", "com.xxx.service", "com.xxx.config"})class App {}B. 开启 Lazy Init(谨慎:首次调用会变慢)

spring: main: lazy-initialization: trueC. spring-context-indexer(大项目很香)
它会在构建期预计算候选组件,减少运行期扫描与反射开销。
进阶:GraalVM Native Image / CRaC(看你的部署形态)
如果你是大量小服务、弹性伸缩、冷启动敏感:Native Image 的收益往往非常直观,但你要接受构建与兼容性成本。
7)JVM:你以为是“内存泄漏”,其实是“GC 在求救”
很多“内存问题”本质是:
- heap 太小导致频繁 GC
- 参数不匹配 Spring Boot 的分配特征
- 线上默认值并不等于稳定值
一个可用起点(示例):
java -jar app.jar \ -Xms512m -Xmx2g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+UseStringDeduplication8)生产配置:最“便宜”的 25% 性能提升
很多性能提升不是改代码,而是改几行配置:Tomcat 线程、Hibernate 批处理、日志级别。
server: tomcat: threads: max: 200 min-spare: 10 connection-timeout: 20000 accept-count: 100spring: jpa: properties: hibernate: jdbc: batch_size: 20 order_inserts: true order_updates: true show-sql: falselogging: level: org.springframework: WARN org.hibernate: WARN com.yourapp: INFO最后一段我想强调的
性能优化的核心不是“技巧”,而是“把默认值当成可质疑对象”。
Spring Boot 帮你把项目跑起来,但“跑得快、跑得稳、跑得省”,得靠你理解它默认做了什么、什么时候不适合你、以及你用数据证明改变是有效的。
- heap 太小导致频繁 GC
- 参数不匹配 Spring Boot 的分配特征
- 线上默认值并不等于稳定值
一个可用起点(示例):
java -jar app.jar \ -Xms512m -Xmx2g \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+UseStringDeduplication8)生产配置:最“便宜”的 25% 性能提升
很多性能提升不是改代码,而是改几行配置:Tomcat 线程、Hibernate 批处理、日志级别。
server: tomcat: threads: max: 200 min-spare: 10 connection-timeout: 20000 accept-count: 100spring: jpa: properties: hibernate: jdbc: batch_size: 20 order_inserts: true order_updates: true show-sql: falselogging: level: org.springframework: WARN org.hibernate: WARN com.yourapp: INFO最后一段我想强调的
性能优化的核心不是“技巧”,而是“把默认值当成可质疑对象”。
Spring Boot 帮你把项目跑起来,但“跑得快、跑得稳、跑得省”,得靠你理解它默认做了什么、什么时候不适合你、以及你用数据证明改变是有效的。
如果这篇内容对你有帮助,欢迎点赞 、收藏 ⭐、转发给需要的朋友
我会持续分享:
- ☕ Java 核心与高阶实战
- AI / Agent / 前沿技术落地
- 真实项目经验 & 架构思考
- ️ 企业数字化与产品实践
关注我,一起把“技术”真正用在项目和业务里。
你的每一次支持,都是我持续输出高质量内容的最大动力。