🤖
AI审核中

从96%到70%

  Java   20分钟   127浏览   1评论
AI
AI智能摘要
正在分析文章内容

最近做了一次很实用的部署优化:把原来四个独立运行的 Spring Boot 项目,合并成一个 Maven 多模块工程,最终只部署一个 total-app.jar

上线后的效果立竿见影:服务器整体内存占用从原来的 96% 左右,降到了 70% 上下。

这篇文章记录下这次优化的背景、实现方式、踩过的坑,以及背后的取舍。如果你也在一台小服务器上运行着多个 Java 项目,并且经常被内存逼到墙角,这个方案或许能提供一些思路。

先说结论

这次操作不是微服务改造,也不是业务重构,纯粹是一次部署形态的调整。

原来是这样:

chat.jar         -> 一个 JVM
puke.jar         -> 一个 JVM
zepp-steps.jar   -> 一个 JVM
blog.jar         -> 一个 JVM

现在是这样:

total-app.jar    -> 一个 JVM
├── chat ApplicationContext
├── puke ApplicationContext
├── zepp ApplicationContext
└── blog ApplicationContext

四个应用仍然保留自己的端口、配置和独立的 Spring 上下文,但它们都运行在同一个 JVM 进程里了。

最终收益:

指标 合并前 合并后
Java 进程数 4 个 1 个
部署包 4 个 jar 1 个 total-app.jar
服务器内存占用 约 96% 约 70%
对外端口 保持不变 保持不变
Nginx 配置 原配置 基本不用改

内存下降的核心原因很简单:少跑了三个 JVM 底座

为什么四个 Spring Boot 项目会这么吃内存

这四个项目分别是:

应用 功能 端口
chat AI 对话系统 8083
puke 打牌记账应用 8085
WeChatSteps Zepp / 微信步数工具 8080
blog 博客系统 8091

最初采用独立部署,是很自然的选择——每个项目自己打包,独立启动,互不影响。哪个出问题了,就单独重启哪个。这种方式简单、清晰,隔离性也好。

但独立部署有一个绕不开的成本:每个 Spring Boot 应用都是一个完整的 Java 进程。

一个 Java 进程可不仅仅在运行业务代码,它还包括:

  • JVM 运行时开销
  • Heap 堆内存
  • Metaspace 元空间
  • JIT 编译器和 Code Cache
  • Spring 容器内各种 Bean
  • 内置 Tomcat
  • 数据库连接池
  • 日志框架
  • 线程栈
  • 各类 starter 与自动配置组件

四个项目各自启动,这些基础成本就硬生生地重复了四份。

更要命的是,这几个项目技术栈高度重叠,都在用 Spring Boot、MyBatis、MySQL 驱动、同款日志框架等。这些依赖原本完全可以共享,但在四个独立进程里,每个 JVM 都要自己加载一遍。

所以服务器内存吃紧,并不是某个业务模块写得有多烂。问题往往很朴素:项目多了,JVM 底座重复得厉害

为什么没有直接做微服务

有人可能会问:把四个项目塞进一个 JVM,这不是和微服务背道而驰吗?

是的,从服务拆分的角度看,这是一次“反向操作”。但架构选择绝不能脱离实际资源。

当前场景有这几个特点:

  1. 服务器资源有限,内存尤其吃紧。
  2. 四个项目访问量都不算大。
  3. 项目之间没有复杂的弹性扩容需求。
  4. 眼下的痛点很明确——内存被打满,而不是服务治理。
  5. 希望对外端口和访问入口尽量保持不变。

在这种条件下,继续强调“每个应用必须独立进程”,成本反而比收益高得多。

所以我选了一个更务实的方案:业务边界保留,部署进程合并

也就是说,不把四个项目揉成一个巨无霸业务系统,而是让它们呆在同一个 JVM 里各自启动。每个应用依然是自己的 Spring ApplicationContext,依然监听自己的端口,只是不用再为每个应用单独养一个 JVM 了。

合并后的工程结构

合并后,项目变成了一个标准的 Maven 多模块工程:

total
├── pom.xml          (父 POM,统一依赖和版本)
├── blog
├── chat
├── puke
├── WeChatSteps
└── launcher         (启动模块)

其中:

  • blogchatpukeWeChatSteps 保留原汁原味的业务代码。
  • launcher 负责统一启动,并最终打包出可执行 jar。

最终只有一个产物:

launcher/target/total-app.jar

核心启动方式

关键代码在 TotalLauncher 类里。

它没有搞什么复杂的调度逻辑,也没有引入额外框架,只是用 SpringApplicationBuilder 依次启动四个 Spring Boot 应用:

public class TotalLauncher {

    public static void main(String[] args) {
        start(com.example.aichat.AiChatApplication.class, "chat", args);
        start(com.puke.CardAccountingApplication.class, "puke", args);
        start(com.zepp.ZeppStepsApplication.class, "zepp", args);
        start(com.zou.blog.BlogApplication.class, "blog", args);
        System.out.println(">>> 四个应用全部启动完成 (chat:8083, puke:8085, zepp:8080, blog:8091)");
    }

    private static void start(Class<?> source, String configName, String[] args) {
        System.out.println(">>> 正在启动 [" + configName + "] ...");
        new SpringApplicationBuilder(source)
                .web(WebApplicationType.SERVLET)
                .properties("spring.config.name=" + configName)
                .run(args);
    }
}

这里最关键的配置是 spring.config.name

四个项目原来很可能都叫 application.yml,合并到同一个 jar 里自然会互相覆盖。因此我把配置文件拆成了四份,清晰对应:

应用 配置文件
chat chat.yml
puke puke.yml
WeChatSteps zepp.yml
blog blog.yml

启动时通过 spring.config.name 指定对应的配置文件名,这样四个 Spring 上下文就能井水不犯河水地读取自己的配置了。

改造过程中最容易踩的坑

这个方案听起来就是“加一个启动器,把四个项目依赖进来”,但真正动手时,坑主要集中在 classpath 和默认配置上。

1. 依赖版本必须统一

以前四个项目独立运行,依赖版本不一致也能相安无事。比如 A 项目用 Spring Boot 2.6,B 项目用 2.7,只要不在一个进程里,就不会直接冲突。

合并后就不一样了。同一个 JVM、同一个 classpath,一个依赖通常只能存在一个最终版本。于是父 pom.xml 必须站出来统一关键依赖,例如:

  • Spring Boot 统一到 2.7.18
  • Java 统一为 1.8
  • MySQL 驱动统一为 8.0.33
  • MyBatis / MyBatis-Plus 统一版本
  • fastjson 等公共库统一版本

这一步不要求一步到位升级到最新,优先级是:四个项目都能稳定跑在同一套依赖下

2. 资源目录不能重名

多个 Spring Boot 项目合并时,资源目录很容易“撞车”。

比如大家可能都有:

mapper/
templates/
static/
application.yml
logback.xml

独立部署时这些目录各走各的,但放进同一个 jar 后,默认路径就可能互相干扰。这次我做了几处手动隔离:

  • blog/mapper 调整为 mapper-blog
  • chat/mapper 调整为 mapper-chat
  • puke/templates 调整为 templates-puke
  • blog 的日志配置改为 logback-blog.xml

资源路径的隔离是这类合并里非常关键的一步。否则很可能启动没报错,但运行时加载的却是另一个项目的 mapper 或模板,排查起来非常痛苦。

3. Spring Boot 自动配置会“串场”

Spring Boot 的自动配置在单应用里很贴心,但在多应用同 JVM 场景下,就得格外小心了。

比如某个项目引入了 Spring Security,另一个项目原本并不需要。独立部署时 classpath 天然隔离,相安无事;合并后,所有依赖都在同一进程里,自动配置就可能“串”到不该去的地方。

容易踩坑的地方还包括:

  • Spring Security
  • JPA
  • Cache
  • Redis
  • WebSocket
  • 数据源自动配置

解决方式是在各自的 yml 里按需配置 spring.autoconfigure.exclude,明确排除不属于本应用的自动配置。

原则很简单:以前靠“进程隔离”天然解决的问题,合并后就得靠“显式配置”重新建立起边界。

4. 日志是全局问题

日志配置同样需要特别留意。在多个独立 JVM 中,每个应用有自己的日志体系;但合并到一个 JVM 后,日志框架本身更接近全局状态。某个应用的 logback 配置不小心就可能影响整个进程。

这次我把 blog 的日志配置改名为 logback-blog.xml,并将 root 级别从 DEBUG 调整到 INFO,避免合并后日志爆炸。

处理日志时,不要光看能不能启动,更要检查:日志是否写到了正确目录、日志量是否可控、不同应用的日志是否方便区分。

部署方式

打包命令:

mvn -DskipTests package

最终产物就是 launcher/target/total-app.jar

服务器启动命令:

nohup java -Xms256m -Xmx1200m -XX:MaxMetaspaceSize=320m -jar total-app.jar > total-app.log 2>&1 &

启动前一定要先停掉原来的四个 Java 进程,否则端口冲突会直接报错。

合并后的四个访问入口依然保持不变:

应用 地址
AI 对话系统 http://localhost:8083/
打牌记账 http://localhost:8085/
Zepp / 微信步数工具 http://localhost:8080/
博客系统 http://localhost:8091/

这一点很重要。端口不变,意味着 Nginx、域名转发、外部访问方式基本都不用动

为什么内存能从 96% 降到 70%

这次优化并不是让业务对象凭空消失,也不是把四个 Spring 容器变成了一个。四个 ApplicationContext 仍然存在,四个应用自己的 Bean、连接池、线程池也一个不少。

真正降下来的,是 JVM 级别的重复成本

合并前,每个应用都要独自扛起:

  • 一套 JVM 运行时
  • 一份公共依赖类加载
  • 一份元空间开销
  • 一份 JIT 和 Code Cache
  • 一份进程级线程与本地内存开销
  • 一套完整的 Spring Boot 启动底座

合并后,这些底层成本在很大程度上由同一个 JVM 共享。尤其是公共框架类、JVM 自身、JIT、元空间和进程管理开销,不用再重复四份。

所以内存从约 96% 降到约 70%,不是因为某段代码被神奇优化了,而是因为部署模型变了

这也是很多小服务器跑多个 Java 应用时容易忽略的问题:你以为自己只是多跑了几个小项目,实际上是多跑了好几个完整的 JVM。

单 JVM 聚合的代价

这个方案有效,但不是银弹。

它最大的代价是故障隔离变弱了

以前四个项目是四个进程,一个挂了,其他项目大概率还能继续服务。现在它们同在一个 JVM 里,一旦某个应用出现严重问题,就可能把整个 total-app 拖垮。

需要清醒地接受这些风险:

  • 一个应用内存泄漏,会挤压整个 JVM 的生存空间。
  • 一次 Full GC,会连坐同进程内的所有应用。
  • 某个应用触发 System.exit,可能直接结束整个进程。
  • 依赖版本冲突会立刻变成全局问题。
  • 日志、缓存、自动配置需要统一管理和约束。
  • 发布节奏被绑定:改一个应用也得重新发布整个 jar。

所以我不建议把它当作一种“更先进的架构”。它不是更先进,只是在当前约束下更合适的选择。

如果你的业务已经出现明显的高并发、独立扩容、强隔离或高可用的要求,那就应该果断拆开部署,甚至走向容器化、服务化。

但如果你面对的是:

  • 一台小服务器
  • 几个中小型 Spring Boot 项目
  • 内存长期紧张
  • 访问量不极端
  • 运维希望尽量简单
  • 外部入口不想大改

那么单 JVM 聚合就是一个非常现实、直接有效的优化方案。

后续还可以继续优化什么

这次合并解决的是“多 JVM 重复底座”的问题。如果还想继续压榨内存,可以从下面这些方向入手:

  1. 调小各应用数据库连接池,避免默认连接数过大。
  2. 梳理线程池、定时任务、WebSocket 线程,减少不必要的线程占用。
  3. 增加 JVM 监控,观察 Heap、Metaspace、GC 次数和停顿时间。
  4. 利用 Arthas、jcmd、jmap 等工具排查大对象和内存泄漏。
  5. total-app 配上 systemd 或其他守护方式,异常退出后自动拉起。
  6. 为四个端口分别做健康检查,避免只看进程是否存活。
  7. 保留单模块启动方式,方便本地只调试其中一个应用。

一些常用的排查命令:

# 查看服务器内存概况
free -h

# 查看 Java 进程内存占用
ps -eo pid,rss,vsz,cmd | grep java

# 查看 JVM 启动参数
jcmd <pid> VM.flags

# 查看堆信息
jcmd <pid> GC.heap_info

# 查看 GC 概况(每1秒刷新)
jstat -gcutil <pid> 1000

总结

这次优化最有价值的地方,不在于用了什么复杂的技术,而在于找准了问题的本质

服务器内存从 96% 降到 70%,不是因为重写了业务,也不是因为换了框架,只是把四个独立 JVM 的重复运行成本合并掉了。

对于资源有限的小服务器来说,这种优化非常实际。它保留了四个应用的业务边界,尽量维持了原有的访问方式,同时显著降低了内存压力。

当然,单 JVM 聚合牺牲了一部分隔离性。它更适合中小项目、自用项目、轻量生产环境,或者访问量可控的多应用部署场景。等未来流量、稳定性、扩容诉求上来,再把核心应用拆出去独立部署,也完全来得及。

架构不是越拆越好,也不是越合越好。真正重要的是,在当前阶段,用最少的复杂度解决最痛的问题

这次的问题是内存快被打满了。

所以答案很简单:先把四个 JVM 合成一个。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
召田最帥boy   湖南省长沙市芙蓉区

吓哭了,连滑动条都做了优化kuse

AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点