三个月前,我们的 Spring Boot 微服务内存消耗惊人,基本占用 2GB,高负载时甚至飙升至 3GB。我们的 AWS 账单也因此雪上加霜。运维团队在 Slack 上阴阳怪气地抱怨“或许该优化一下了”。
最糟糕的是什么?我竟然觉得这很正常。“Java 就是会占用内存,没办法,”我告诉自己。谁都知道 Java 很耗内存,对吧?
错误的。
大错特错。
经过两周的分析、试验,以及偶尔想把笔记本电脑扔出窗外的崩溃时刻,我终于把这项服务的大小降到了 200MB。不是通过切换语言,也不是重写所有代码,而是通过真正理解到底发生了什么。
这不是一篇理论文章。这是我实际操作的,有真实的数据、真实的错误和真实的结果。
一切的开端——“糟了”的那一刻
星期一上午9点47分。咖啡还很烫。
我们的运维主管在后端渠道发布了这条消息:
“各位,我们的用户服务 pod 在生产环境中不断被 OOMKilled 终止。这周已经是第三次了。有人能帮忙看看吗?”
附件:Kubernetes 控制面板显示我们的服务每隔几个小时就会重启一次。内存使用量持续攀升,直到……崩溃。
我检查了资源限制。我们将其设置为 2.5GB,因为“Java 需要空间”。但服务达到了这个限制,然后被 OOM killer 终止了。
我的第一反应是:“不如把上限提高到 4GB。”
我的第二个想法是:“等等,这太蠢了。一个简单的用户 CRUD 服务为什么需要 2GB 的内存?”
于是我开始挖掘。
我发现了什么(剧透:情况不太妙)
首先,我在本地启动了该服务,并附加了 JVisualVM。如果你从未对 Java 应用程序进行过性能分析,JVisualVM 就是你的最佳帮手。它包含在 JDK 中,而且是免费的。一定要用它。
我看到的情景让我觉得自己像个白痴:
堆内存:已用 1.2GB,已分配 1.8GB; 非堆内存: 400MB(主要用于元空间和代码缓存); 已加载类: 18,000+; 线程: 200 个活动线程
作为背景介绍,这项服务的功能如下:
- 用户 CRUD 操作
- JWT 身份验证
- 使用 Redis 进行一些缓存
- 将事件发布到 RabbitMQ
就是这样。没有机器学习。没有图像处理。没有复杂的算法。
18000 个类?200 个主题?就为了这些?
情况非常不妙。
问题一:我们正在加载整个宇宙
Spring Boot 让添加依赖项变得异常简单。太简单了。
我们的pom.xml长这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId></dependency><!-- ... 23 more starters -->每个 Spring Boot “启动器”都会引入大量传递依赖项,其中很多我们并没有使用。
我跑mvn dependency:tree过去发现:
- 我们从未使用过的Jackson模块
- 我们不需要 Hibernate 验证器
- 我们之前没有利用的 Tomcat 内置功能
- 微米测量指标库(适用于所有监测系统的所有指标库)
解决方案:
我逐一检查了每个依赖项,并问道:“我们真的会用到它吗?”
移除了不必要的启动器。排除了不需要的传递依赖项。使用spring-boot-starter-web排除规则删除了未使用的组件。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-undertow</artifactId></dependency>嵌入式服务器已从 Tomcat 切换到 Undertow。Undertow 的内存占用更小。
结果:内存使用量降至 1.6GB。虽然不算理想,但总比没有好。
问题 2:Hibernate 缓存了所有内容……
我们的 JPA 实体@OneToMany之间到处都存在关联关系。而且我们在获取数据策略方面也不够谨慎。
典型的 N+1 问题,但更糟。Hibernate 的二级缓存保存着我们查询过一次之后再也没用过的实体。
我在内存中找到了几个小时前运行的查询所遗留的实体。
解决方案:
首先,我完全禁用了 Hibernate 的二级缓存。反正我们已经在使用 Redis 进行缓存了,没必要重复使用缓存层。
spring: jpa: properties: hibernate: cache: use_second_level_cache: false然后我检查了我们的 JPA 代码库,并添加了明确的获取策略:
// Bad (loads everything)@Query("SELECt u FROM User u")List<User> findAll();// Good (loads only what we need)@Query("SELECt u FROM User u")@EntityGraph(attributePaths = {"roles"})List<User> findAllWithRoles();已添加@Transactional(readonly = true)到所有读取操作中。这会告诉 Hibernate 不要跟踪实体的更改,从而节省内存。
结果:内存降至 1.2GB。有所进展。
问题 3:连接池过大
我们的 HikariCP 配置(Spring Boot 的默认连接池)设置为自动配置默认值。
默认值为:
- 最大连接数:10 个连接
- 最低空闲连接数:10 个连接
我们在 Kubernetes 中运行了 8 个 pod。对于一个通常每秒处理 50 个请求的服务来说,这意味着 80 个数据库连接。
我们的数据库总共可能只需要 20 个连接。
解决方案:
spring: datasource: hikari: maximum-pool-size: 5 minimum-idle: 2 connection-timeout: 20000 idle-timeout: 300000每个连接都会占用内存。通过减小连接池的大小,我们在所有实例中节省了约 100MB 的内存。
结果:内存降至 1.1GB。
问题 4:Jackson 对所有内容进行了两次反序列化。
我们的代码中存在一个奇怪的错误,我们既手动反序列化请求体,又让 Spring 自动执行反序列化操作。
// 重复序列化@PostMapping("/users")public ResponseEntity<User> createUser(@RequestBody String json) { ObjectMapper mapper = new ObjectMapper(); User user = mapper.readValue(json, User.class); // Why??? return ResponseEntity.ok(userService.create(user));}这是从一些旧代码中遗留下来的。有人复制粘贴了它,然后就传播开了。
解决方案:
// 让sb来处理他@PostMapping("/users")public ResponseEntity<User> createUser(@RequestBody User user) { return ResponseEntity.ok(userService.create(user));}同时配置 Jackson,使其仅在绝对必要时才存储类型信息:
spring: jackson: #仅保留不为空属性的输出 default-property-inclusion: non_null serialization: write-dates-as-timestamps: false结果:内存占用降至 950MB。我们现在开始烹饪了。
问题 5:线程池失控
还记得我提到的那200个活跃主题帖吗?大部分都处于闲置状态。
Spring Boot 会创建线程池,用于:
- Tomcat/Undertow 请求处理
- 异步任务执行
- 计划任务
- 数据库连接池线程
默认线程池大小非常大,对于我们的使用场景来说过于大了。
解决方案:
server: undertow: threads: io: 4 worker: 20spring: task: execution: pool: core-size: 2 max-size: 4 queue-capacity: 100每个线程都会消耗内存(默认线程栈大小为 1MB)。将线程数从 200 个减少到约 30 个,又节省了 170MB 内存。
结果:内存使用量降至 780MB。
问题 6:执行器端点积压数据
Spring Boot Actuator 在监控方面非常出色,但它也会在内存中存储大量数据。
HTTP 跟踪、指标历史记录、线程转储——默认情况下都保存在内存中。
我们曾将 Actuator 配置为将最近 1000 个 HTTP 请求存储在内存中。为什么?没人知道。
解决方案:
management: endpoint: health: show-details: when-authorized endpoints: web: exposure: include: health,info,metrics metrics: export: simple: enabled: false trace: http: enabled: false只暴露实际使用的端点。如果已经有完善的日志记录机制,就不要将 HTTP 跟踪信息存储在内存中。
结果:内存使用量降至 650MB。
问题7:日志记录过于冗长
生产环境中的日志配置设置为 DEBUG 级别。
我知道。别评判我。这是有人深夜打漏洞时干的,结果忘了撤销。
调试日志会创建大量的字符串对象。这些对象会占用内存。尤其是在记录每一个数据库查询时(这都要“感谢”Hibernate)。
解决方案:
logging: level: root: INFO com.yourcompany: INFO org.hibernate.SQL: WARN org.hibernate.type.descriptor.sql: WARN已将日志级别更改为 INFO。减少了 Hibernate 的 SQL 日志记录。保留了用于调试的关键日志。
同时配置 Logback 使用具有合理缓冲区大小的异步 appender:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>512</queueSize> <discardingThreshold>0</discardingThreshold> <appender-ref ref="CONSOLE" /></appender>结果:内存使用量降至 500MB。
问题 8:JVM 本身存在过度分配内存的问题
最后是JVM设置。我们完全没有调整它们,只是让Java自动决定。
默认 JVM 行为:分配 25% 的系统 RAM 作为最大堆内存,或 1GB,以较大者为准。
我们的 Kubernetes pod 内存限制为 2.5GB。JVM 分配了 1.8GB 用于堆内存,只剩下 700MB 用于其他所有用途(非堆内存、操作系统、缓冲区)。
解决方案:
添加了显式 JVM 标志:
java -Xms256m -Xmx384m \ -XX:MaxmetaspaceSize=128m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:+UseStringDeduplication \ -jar app.jar让我来详细解释一下:
-Xms256m初始堆大小。从小规模开始,根据需要逐步增加。 -Xmx384m最大堆大小。强制提高效率。 -XX:MaxmetaspaceSize=128m限制类元数据空间。 -XX:+UseG1GC使用 G1 垃圾回收器(更适合低延迟)。 -XX:MaxGCPauseMillis=200目标 GC 暂停时间。 -XX:+UseStringDeduplication减少重复字符串的开销。
结果:内存稳定在 200MB。我的天。
实际结果
优化前:
- 内存使用量:基准值 2GB,负载下 3GB
- 每月 AWS 费用(8 个 Pod):约 450 美元
- OOMKilled 事件:每周 3-5 次
- 启动时间:12 秒
优化后:
- 内存使用量:基准值 200MB,负载下 350MB
- 每月 AWS 费用(8 个 Pod):约 180 美元
- OOMKilled 事件:过去 8 周内 0 起
- 启动时间:6 秒
我们还把设备数量从 8 个减少到 4 个,因为我们不再需要那么多的容量了。
单个微服务每年可节省约 3,000 美元。
我们有 12 个微服务。
你自己算算吧。
我从惨痛经历中学到的东西
第一课:默认配置之所以是默认配置是有原因的——它们是安全的,但不是最优的。
Spring Boot 无法了解你的具体使用场景。它提供的默认设置虽然适用于大多数情况,但会浪费资源。
第二课:依赖是有代价的。
每添加一个库都会增加内存使用量。务必谨慎。阅读文档。了解你引入的是什么。
第三课:尽早建立个人形象,经常建立个人形象。
我本该六个月前就介绍这项服务。那样就能帮我们省下数千美元和无数个小时的事故响应时间。
第四课:线程池不是免费的。
线程数越多并不代表性能越好。应根据实际工作负载调整线程数。
第 5 课: JVM 很聪明,但它没有预知能力。
使用正确的标志位进行引导,不要让它随意分配内存。
真正有用的工具
JVisualVM——免费,JDK 自带,非常适合堆转储和性能分析。如果您还没用过,那就从今天开始吧。
Maven依赖插件——mvn dependency:tree准确显示你正在导入的内容。运行它。你会大吃一惊。然后清理掉。
Spring Boot Actuator和/metrics端点/health有助于识别性能瓶颈。但切记不要让它在内存中积压大量数据。
如果你认真对待 Spring Boot 优化,那么Spring Boot 故障排除速查表已经帮了我无数次。它涵盖了常见的内存问题、配置陷阱和调试策略。
为了准备面试(因为面试中会问到这些内容),《Grokking the Spring Boot Interview》和《250+ Spring Certification Practice Questions》涵盖了面试官实际会问到的概念。
如果你正在构建生产环境的 Spring 服务,Spring Boot 生产环境检查清单(免费)包含了我在发布前会仔细检查的所有项目。内存配置、连接池、日志记录——所有这些如果忽略都会在以后给你带来麻烦的事情。
令人不安的真相
大多数 Spring Boot 应用运行的内存都是实际所需内存的 2-3 倍。
并非因为 Spring Boot 不好。而是因为开发者(包括过去的我)往往在出现问题之前,不会质疑默认设置,不会进行性能分析,也不会进行优化。
你的服务可能不需要2GB内存。我的就不需要。
但你不仔细看是不会知道的。
接下来你应该做什么
如果你在生产环境中运行 Spring Boot:
第一步:进行性能分析。现在就做。用 JVisualVM、YourKit 或其他任何工具。看看实际发生了什么。
步骤二:运行mvn dependency:tree并检查每个依赖项。删除不需要的依赖项。
步骤 3:检查你的 application.yml 文件。你是否使用了默认值?根据你的实际使用情况进行调整。
第四步:显式设置 JVM 标志。不要让 Java 自动猜测。
第五步:监控一周。检查是否存在内存泄漏或异常模式。
这并非过早优化,而是负责任的工程设计。
我实际使用的一些工具
多年来,我意识到我一直在反复解决同样的问题——优化服务、设置样板代码、调试生产问题。
所以我开发了一些工具,这些工具我在构建实际产品(而非演示产品)时会用到:
如果你在生产环境中使用 Spring Boot,Spring Boot 微服务样板是一个不错的入门选择,它开箱即用,包含了正确的内存配置。
对于 Python ETL 工作(因为并非所有工作都是用 Java 完成的),《Python 生产环境速查表》涵盖了在实际系统中真正重要的内容。
如果您正在构建移动习惯跟踪应用程序,Expo Habit App Boilerplate内置了离线支持和完善的架构。
我不是在兜售梦想——我只是在出售那些我厌倦了从头开始重建的东西。
最后想说的话
将内存使用量从 2GB 减少到 200MB 并不是一个大的解决方案,而是十个小的改进措施,每个措施都逐步减少浪费。
大多数开发人员不到万不得已不会做这项工作。除非AWS账单吓到他们。除非生产环境中的服务崩溃。
不要像大多数开发者那样。
分析您的服务。质疑您的默认设置。防患于未然,及时优化。
你的钱包(以及你的运营团队)会感谢你的。
轮到你了:你做过的最大规模的内存优化是什么?或者,你把最尴尬的默认配置留在生产环境中是什么?
请在评论区留言。我们来分享一下战地故事。
如果你觉得这篇文章有用,或许也能帮其他人每年省下3000美元。分享出去吧!
现在去分析一下某个东西。你很可能会讨厌你发现的东西。
