如果你接手过一个跑在Kubernetes里的Spring Boot服务,多半纠结过一件事:Pod的内存Limit到底给多少合适。给少了,JVM还没来得及优雅停机就被OOMKilled,给多了,Node被打满,扩容扩不动,账单还蹭蹭往上涨。更麻烦的是,你调低内存后一压测,接口延迟和GC频率立刻给你脸色看。
这篇文章就是用我实际调优的经验,把“省内存”和“保性能”这对矛盾拆开揉碎,一步步告诉你先查什么、再调什么、最后怎么在K8s层面把配置固化下来。整个过程不需要你重写业务代码,只要调整启动参数、依赖范围和资源声明,就能看到一个比较明显的变化。适合正在做容器化改造、或者已经被OOMKilled和CPU Throttling折磨过的同学参考。
1. 先搞清楚Spring Boot在K8s里的内存到底花在哪了
很多人一上来就调-Xmx,结果调完发现Pod还是被 kill,或者内存看起来没降多少,这是因为你根本没搞明白JVM在容器里的内存构成。Spring Boot应用的内存远不止堆内存那一块,它由堆内内存、堆外内存、元空间、线程栈、JIT编译产物、GC开销、以及Tomcat/Netty的DirectBuffer等组成。在K8s环境里,如果直接裸用-Xmx或者依赖JVM默认值,很容易出现两个极端:Xmx设太大,Pod内存Limit不够,触发OOMKilled;Xmx设太小,堆外内存没算进去,同样OOMKilled。
我需要先引入一个关键概念:容器感知。JDK 8u131+开始支持-XX:+UseCGroupMemoryLimitForHeap,JDK 8u191+默认开启UseContainerSupport,JVM会尝试根据容器cgroup的内存限制来自动计算堆大小。听起来很智能,但实际默认的MaxRAMPercentage是25%,也就是说如果你的Pod Limit是2Gi,JVM默认堆只有512MB左右,剩下1.5Gi留给了堆外和系统,这个比例对大多数Spring Boot应用来说是偏保守的,你其实可以把堆调大一点。
但问题在于,很多人在基础镜像里装的是老版本JDK,或者用了自定义JVM参数把自适应覆盖了。我们要做的第一步,是先搞清楚你的应用内存画像,而不是凭感觉调参。我常用的排查手段有三种:
- 看Pod实际内存。用
kubectl top pod拿到当前内存使用量,多观察几天峰值。 - 看容器内JVM的内存报告。进入Pod执行
jcmd <pid> VM.native_memory,如果没开启NativeMemoryTracking,用jstat -gc和jmap -heap结合看。 - 看GC日志和OOM日志。如果Pod重启,
kubectl describe pod里会有OOMKilled原因,而JVM自己的Heap dump和GC log能告诉你堆内是否真的不够。
我接过一个项目,某团队反馈服务频繁重启,K8s事件里全是OOMKilled,但kubectl top显示内存一直在涨直到Limit。用jcmd VM.native_memory一看,发现堆只用了700MB,但Internal和Native这块占了大头,罪魁祸首是框架里一个底层库默认开了很大的本地缓存,把堆外内存吃满了。这种问题你要是只调-Xmx,怎么调都没用。
所以我建议所有做Spring Boot容器化的人,第一件事不是调参,而是把你服务的“内存分布图”画出来。我整理了一个粗略的表,你在优化前最好先有这个概念:
| 内存区域 | 默认特征 | 常见占用范围 | 调整手段 |
|---|---|---|---|
| 堆内存 | 受Xmx/MaxRAMPercentage控制 | 总内存的50%-70% | 调整-Xmx或MaxRAMPercentage |
| 元空间 | 默认无上限(受容器Limit影响) | 100MB-300MB | -XX:MaxMetaspaceSize |
| 线程栈 | 默认1MB/线程 | 线程数*1MB | -Xss调小 |
| JIT编译产物 | CodeCache默认240MB | 100MB-200MB | -XX:ReservedCodeCacheSize |
| DirectBuffer | 受MaxDirectMemorySize控制 | 网络框架占用 | 显式调小或监控 |
| GC结构 | GC算法决定 | 几十MB到几百MB | GC调优 |
看到这个表你就明白,单纯调堆参数只是其中一环,如果堆外问题不解决,你的Pod内存还是会被占满。接下来我要讲的具体优化步骤,就是围绕这些内存区域逐个击破。
2. JVM参数调优:用比例思维替换固定值思维
这是整个优化里最关键的一步。在物理机或者传统虚拟机时代,我们习惯给JVM设置固定的-Xmx和-Xms,比如-Xmx2g。但到了K8s环境,Pod的规格可能会调整,Node的资源水位也在变化,固定值会导致两个问题:如果Pod Limit比Xmx小,直接启动失败或OOM;如果Pod Limit比Xmx大很多,JVM不会自动多用内存,造成资源浪费。
所以在K8s上,我强烈建议用比例参数替代固定值。JDK 8u191+和所有JDK 11+版本都支持以下三个参数:
-XX:InitialRAMPercentage-XX:MaxRAMPercentage-XX:MinRAMPercentage
这三个参数让JVM根据容器内存Limit自动计算堆大小。比如你设置-XX:MaxRAMPercentage=75.0,Pod Limit为4Gi时,最大堆就是3Gi;如果Pod Limit调整到8Gi,最大堆自动变成6Gi,完全不用改配置。这就是“比例思维”,它让JVM和K8s的资源声明保持同步。
但比例设多少合适?这取决于你的应用类型,我给出几个实战区间:
- 纯API网关/轻量Web服务:堆外内存消耗较少,
MaxRAMPercentage可以设75-80。 - 典型Spring Boot + JPA/Hibernate应用:堆外有Hibernate缓存、Netty缓冲,建议设70-75。
- 大数据量批处理/流处理应用:堆外和DirectBuffer占用大,建议设60-70。
另外,InitialRAMPercentage也建议显式设置,否则JVM启动时只分配很小的堆,然后随着负载上升不断扩容,这个扩容过程会触发多次Full GC,影响启动初期的性能。我一般把InitialRAMPercentage和MaxRAMPercentage设为相同值,让JVM启动时直接分配最大堆,用空间换启动后的稳定性。多占用的只是虚拟内存,不是物理内存,所以没什么好担心的。
还有两个参数很容易被忽略,但在容器环境里特别重要:
第一个是-XX:+AlwaysPreTouch。这个参数让JVM启动时就把堆内存物理页分配好,而不是等到访问时才分配。优点有两个:启动后性能更稳定,不会因为内存页未分配导致运行时毛刺;配合K8s的内存Limit,能提前验证内存是否足够。缺点就是启动时间变长,如果Pod的initialDelaySeconds没调大,可能会导致健康检查失败。我一般建议加上,然后把ReadinessProbe的initialDelaySeconds设为20秒以上。
第二个是-XX:MaxMetaspaceSize。Spring Boot + 各种框架的元空间占用普遍在200MB-350MB之间,如果你不设置上限,它可能随着类加载增多一直涨到容器Limit。但设得太小又会导致OutOfMemoryError: Metaspace。我建议先跑几天观察实际占用,然后在该值基础上加30%-50%冗余作为上限。举个例子,如果观察到Metaspace稳定在250MB,MaxMetaspaceSize可以设为384MB。
线程栈这块,Spring Boot应用默认-Xss1m,一个中型微服务可能有200-400个线程,光线程栈就吃掉三四百MB。如果你确认业务没有深层递归调用,可以把-Xss512k,对绝大多数业务完全够用,能省下大约三分之一的内存。如果是处理高并发IO密集型任务,可以进一步配合虚拟线程(JDK 21+)来降低线程开销,但这是后话了。
我给出一个可以套用的JVM参数模板,你根据自己的容器内存Limit替换百分比数值:
-Xms4096m -Xmx4096m -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=75.0 -XX:+AlwaysPreTouch -XX:MaxMetaspaceSize=384m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:+ExitOnOutOfMemoryError注意,这里我同时写了-Xmx4096m和-XX:MaxRAMPercentage=75.0是因为在实际环境中,有些JDK版本的UseContainerSupport可能会有兼容性问题,用-Xmx作为兜底是个好习惯。但如果你在纯现代JDK(11+)环境,可以只用比例参数。
这类参数配置到最后,其实要解决的是一个“信任”问题:你真的相信JVM会按照你设置的百分比来管理内存吗?我的答案是:不要相信,要验证。调完参数后,至少跑一周观测,用kubectl top和监控面板看内存曲线。如果出现内存不断上涨但堆占用平稳的情况,说明堆外又有新问题了,赶紧用NativeMemoryTracking去查。
3. 应用层瘦身:不写代码也能把内存和CPU降下来
JVM参数调完只是第一步,Spring Boot应用本身的“身材”也很关键。这方面不需要你改业务逻辑,只需要在依赖和配置上做一些取舍,效果却很明显。
依赖精简是第一优先级。很多Spring Boot项目创建时,为了省事把没用的Starter也加上了,比如引入了Spring Data Redis但只是缓存验证码,引入了Apollo配置中心但只用了一个配置项。这些多余的依赖不仅占用类加载内存,还会在启动阶段增加Bean初始化和自动配置的时间,CPU和内存都被浪费了。
我在处理一个模拟项目时,发现它的依赖里有Spring Batch,但整个代码里连一个Job都没写过。删掉这个Starter和相关代码后,启动时间从38秒降到26秒,内存占用降了约120MB。所以动手前,先检查一下你的pom.xml或build.gradle,把所有用不到的依赖标记出来,逐个确认后移除。用spring-boot-starter这种全家桶本身就是偷懒,按需引入才是容器友好的做法。
懒加载也是一个容易被忽视的优化点。Spring Boot默认是启动时立即初始化所有Bean,包括那些还没被请求触发的组件。如果你开启spring.main.lazy-initialization=true,Bean会延迟到第一次被使用时才创建。这个配置能明显缩短启动时间,减少启动峰值内存。但是要注意,懒加载可能导致第一次请求时响应时间变长,因为Bean是在那次请求时才初始化的。所以这个方法更适合对冷启动敏感、但对单次请求延迟不敏感的场景,比如定时任务服务或者后台处理服务。
Jackson和Tomcat的参数也值得调一调。Spring Boot 2.x/3.x默认内嵌Tomcat,它的最大工作线程是200,最大连接数是8192。如果你的服务是内部微服务,调用量不大根本到不了这个水位,可以显式调小,减少线程栈内存和上下文切换开销:
server.tomcat.threads.max=50 server.tomcat.threads.min-spare=10 server.tomcat.max-connections=2000 server.tomcat.accept-count=200Jackson这块,如果你的接口只返回JSON,不需要XML支持,在ObjectMapper上禁用掉不需要的Feature,比如WRITE_DATES_AS_TIMESTAMPS关闭后可以减少日期序列化的CPU开销。能不能压缩响应体也看情况,如果是网关层已经做了压缩,应用层就不要重复压缩了,否则只增加CPU负担和内存使用。
还有一个很常见的性能症结:数据库连接池配置过大。很多项目用HikariCP,默认配置maximumPoolSize=10,但在K8s里一个Pod通常只有250m-500m的CPU配额,10个连接意味着每次数据库操作都要做更多上下文切换。对于使用P99延迟在50ms以内的常规业务,maximumPoolSize=5基本够用,甚至3也够。HikariCP官方文档里说得很明白:连接池大小不是越大越好,而是要根据“单连接处理一个请求的时间”和“每秒请求数”来计算,公式是connections = TPS * 延迟秒数。如果你每秒请求100次,单个查询延迟0.1秒,那10个连接就够了;如果你压测发现连接数到5就稳定了,再往上加只会在数据库端堆积。
这些应用层优化的好处是,它们不跟JVM参数冲突,可以同时做。我自己的习惯是:先做应用层瘦身,再调JVM参数。因为依赖少了、线程少了、连接池小了,JVM的堆外压力自然就小了,这时候你再来算堆内存比例,数据会准得多。
4. 从线程池和异步入手,用更低的CPU换更高吞吐
如果你想“不牺牲性能”地省资源,光靠减内存是不够的,因为CPU资源在K8s里也受配额限制。如果Pod被限制在1核,但代码里线程池开得太大,大量线程在等待锁、等待IO,CPU频繁切换,业务响应反而变慢。这种场景下你要做的是提高线程利用率,而不是削线程数量。
先看普通的@Async线程池。Spring Boot的@EnableAsync配合默认的SimpleAsyncTaskExecutor,它压根就不复用线程,每次执行都会新建线程,这是灾难级的资源浪费。你要么在配置里自己定义线程池,要么用一个固定大小+有界队列的ThreadPoolTaskExecutor。我习惯的定义方式:
@Bean(name = "taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这里有几个参数要说明。corePoolSize我设置为4,是基于Pod CPU配额是1核来算的,核心线程数不超过CPU核数的2倍是经验法则。maxPoolSize设为8,允许一定程度的突发。queueCapacity设1000,等于给任务一个缓冲,但要注意,如果任务大量积压,内存里的任务对象也会增加,所以这个值不能设太大。CallerRunsPolicy很重要,它让线程池满的时候任务由调用线程执行,也就是说,异步变成了同步,虽然会阻塞调用方,但至少不会丢失任务,也不会无限堆积导致OOM。
如果你用的是Java 21+,我还有个好用的优化思路:大量使用Executors.newVirtualThreadPerTaskExecutor()替代平台线程。虚拟线程的栈空间默认只有几KB,而且能轻松创建上万个,IO密集型场景的吞吐量能提升一个量级,内存消耗反而更小。我在一个模拟文件处理服务里做过对比:原来用200个平台线程处理并发IO,CPU时常打满,切到虚拟线程后,线程数上升到几千,但CPU使用率反而下降,因为内核不再需要频繁调度这么多原生线程了。
线程池调优后,还要看一下你的业务代码里有没有“等待型”性能瓶颈。比如用RestTemplate或WebClient调下游接口,如果下游延迟200ms,你同步等待期间线程一直在占着。这种情况用CompletableFuture配合自定义线程池做并行调用,两个下游接口同时请求,总耗时从400ms降到250ms,CPU消耗却差不多,吞吐自然就上去了。
这种异步改造有一定的侵入性,但收益非常直接。我在几个项目里做过的效果对比大概是这样:
| 优化项 | 优化前 | 优化后 | 效果 |
|---|---|---|---|
| @Async线程池 | 每次新建线程 | 固定池+有界队列 | 内存下降约80MB,CPU尖峰减少 |
| 同步调用下游 | 串行,总耗时=各接口之和 | CompletableFuture并行 | 接口耗时降低30%-40% |
| 平台线程池 | 固定200线程 | 虚拟线程(JDK21+) | 内存下降明显,吞吐提升 |
5. K8s层面的资源声明:Requests和Limits要会配
到了这一节,我们才真正回到K8s的语境。JVM参数调得再好,应用层瘦身再成功,如果Pod的resources字段写得不合理,前面做的都白搭。
先说Requests和Limits的区别。requests是调度器用来决定Pod放到哪个Node的依据,也是主流云厂商HPA的计量基准,它表示“这个Pod至少要保证这么多资源”。limits是运行时的硬上限,超过这个值,容器会被杀掉或者被限制CPU。很多人的误区是:只写limits不写requests,或者把两者设成一样。前者会导致调度器不知道你的真实需求,Pod可能会被安排到资源不足的Node上;后者意味着你完全锁死了Pod的CPU和内存,没有弹性空间。
我推荐的配置方式是:requests和limits在内存上可以设相等,在CPU上要留弹性。背后的逻辑是:内存是“可回收”的,JVM堆内存不会主动还给操作系统,如果requests小于limits,Node内存管理器在压力大的时候可能会先回收PageCache,导致Pod性能毛刺;而CPU是“可压缩”的,即使limits设大了,平时不用也不会浪费。所以CPU的requests可以设为limits的一半甚至更低,内存则尽量requests=limits。
一个比较典型的中等规模Spring Boot Pod配置模板:
resources: requests: cpu: 500m memory: 1Gi limits: cpu: 2 memory: 2Gi这个配置的意思是:调度时保证500m CPU和1Gi内存,运行时最多用2核CPU和2Gi内存。配合前面JVM参数里的MaxRAMPercentage=75.0,JVM最大堆就是1.5Gi,剩下的512MB留给堆外和系统,刚刚好。
为什么CPU要这么设?因为Spring Boot应用有个特点:平时CPU使用率不高,但启动时和流量突增时会有明显的CPU尖峰。如果把limits.cpu设得和requests.cpu一样小,启动时直接进入Throttling状态(K8s通过CFS配额限流),表现为请求延迟飙升。如果把limits设大一点,比如2核,但requests只设500m,调度器不会因为这个Pod预留2核,Node上还能塞下其他Pod,而运行时又能利用多核来处理突发流量,这种“小保底、大上限”的配置在成本不变的情况下大幅降低了P99延迟。
这里我要专门提一个坑:Spring Boot应用出现CPU Throttling后,表现为GC变慢、接口时延增大,但是你在Pod里用top看不到CPU跑满。因为CFS的限流机制是,在每100ms的时间窗口里,如果Pod消耗的CPU时间超过配额,之后的执行会被直接挂起。Pod里的容器看自己的CPU使用率可能只有60%,但真实情况是启动阶段想用100%却被限制了。怎么排查?看kubectl describe pod的事件,如果有大量Throttled,或者在监控面板上看container_cpu_cfs_throttled_seconds_total这个指标,就能确认了。
还有一种情况是Pod内存Limit设置过大,导致Node内存碎片化严重,调度器把Pod堆在一起,结果某些Node内存利用率极高,另外一些Node闲置。这种问题靠K8s本身的调度策略很难避免,你能做的就是在requests.memory上写真实值,不要虚高,让调度器有准确的数据做Bin Packing。
至于HPA(Horizontal Pod Autoscaler),如果你用了,requests尤其重要。HPA依据的是Pod的requests资源使用率,而不是limits。如果你把requests写得太小,例如实际需要1Gi却只写了200m,HPA判断利用率的时候会认为很多Pod都在超卖,然后疯狂扩容,结果整个集群资源被无谓地放大,成本飙升。所以在你压测的时候,顺便看一下稳态下Pod的实际CPU/内存使用,把它乘以1.2-1.5倍作为requests,是比较稳妥的做法。
6. 实战调优的压测方法和监控指标,怎么验证真的没牺牲性能
都调完了,怎么证明“没牺牲性能”?用数据说话。我把自己的压测方法分享出来,你可以照着做。
先准备压测工具,我常用的是开源的wrk或k6,别用JMeter那一类太重型的,因为压测机本身的线程资源也会影响结果。如果你只是压单个HTTP接口,wrk足够轻量。
压测前要确认两件事:
- Pod的
requests和limits已经按要求配好,且配置生效。 - JVM参数已经写入启动命令,可以从Pod的yaml或者环境变量里确认。
压测场景我习惯设计三组:
- 基线:原配置,压测10分钟,记录TPS、P99延迟、内存使用。
- 优化后:新配置,同样压测10分钟,记录相同指标。
- 高负载冲击:把并发数调高到1.5倍,观察5分钟,看有没有OOMKilled或者Throttling。
对比数据的时候,重点关注两个指标:P99延迟和GC暂停时间。P99很好理解,就是99%请求的响应时间,它比平均值更能反映长尾性能问题。GC暂停时间可以去监控面板看jvm_gc_pause_seconds,如果优化后GC暂停没有明显恶化,说明堆大小给得不够也没关系,我们并没有牺牲延迟。
还有一个细节:压测时要把JVM的堆转储和GC日志打开。如果是OOM导致的Pod重启,你至少要有heapdump可以分析。没有数据支撑的调优就是瞎调,后面出了问题谁也说不清是哪一步导致的。
整个调优过程中,最容易翻车的地方其实是:你以为降了内存就等于性能一定会下降,于是不敢下手。实际上,很多Spring Boot应用的内存浪费都在堆外和线程那一层,把这些压掉之后,堆内存的回收路径反而更稳定,GC频率也会降低,最终表现可能是内存降了,P99延迟也跟着降了,双赢。
我记录了一个模拟项目的完整调优数据,这里分享给你参考,但注意这只是模拟值,你的数据会因业务而不同:
| 指标 | 调优前 | 调优后 | 变化 |
|---|---|---|---|
| Pod内存Limit | 4Gi | 2Gi | -50% |
| 最大堆 | 默认(约1Gi) | 1.5Gi | +50%可用堆 |
| P99延迟 | 420ms | 260ms | -38% |
| TPS | 820 | 1150 | +40% |
| Full GC次数/小时 | 6 | 0 | 明显改善 |
| 启动时间 | 45秒 | 28秒 | -38% |
你会发现,容量减半之后性能反而上去了。这背后原因不复杂:原来Pod Limit=4Gi,JVM默认MaxRAMPercentage只有25%,堆才1Gi,大部分内存被白白留给堆外却根本用不上,GC还得频繁扫描一堆准垃圾。现在2Gi Limit+75%比例,堆反而有1.5Gi可用,堆外也够用,GC压力小了,延迟自然就下来了。
最后提醒一下,容器里优化的原则是“持续观测,小步快跑”。每次只改一个参数,观察几天,再把监控数据和压测数据放到一起看,而不是一顿猛操作同时改十几个参数,到时候出了问题都不知道是哪一步导致的。调优到后面,你会发现自己对应用的内存画像越来越清晰,下次再开新服务,直接就能估出一个靠谱的资源申请范围。
7. 常见问题和避坑笔记
这一节整理一下我自己踩过、以及帮别人擦过的坑,每一条都对应一个真实场景。
问题1:Pod一直OOMKilled,但代码里没有内存泄漏
这种情况大概率是堆外内存问题,常见引起堆外内存增长的几个对象:Netty的DirectBuffer、JNI引用、线程栈、Metaspace、以及各种框架的本地缓存。排查手段:开启NativeMemoryTracking,等OOM前抓快照,重点看Internal和Native部分。如果Native部分涨得特别快,就去查依赖的底层库是不是有缓存、有连接池、有内存映射文件。我遇到过最离谱的一次,是某消息队列客户端默认把每台Broker的消息Prefetch值设成3000,堆外内存直接被打爆。
问题2:设置了MaxRAMPercentage但感觉没生效
首先确认JDK版本。8u191以下不支持UseContainerSupport,要手动加-XX:+UseCGroupMemoryLimitForHeap。其次,如果你同时设置了-Xmx,-Xmx的优先级更高,MaxRAMPercentage就不生效了。还有,容器里如果识别不到cgroup限制(比如某些运行时环境),JVM会拿宿主机的内存来算,这样MaxRAMPercentage算出来的堆会非常大。确认是否生效,可以用jcmd <pid> VM.flags看实际生效的Xmx。
问题3:CPU Throttling导致接口变慢,但没定位到原因
如果你用的是K8s,要养成看container_cpu_cfs_throttled_seconds_total这个指标的习惯。我通常的做法是在Grafana里给这个指标配一个阈值告警,一旦某段时间Throttled时长超过总运行时间的5%,就自动告警。优化方向有两个:提高limits.cpu,或者降低requests.cpu但保持limits,给Pod更多CPU弹性。如果你用的是Cloud Native版本,也可以考虑开启CPU Manager的Static策略,但这需要Node层面配合,不是每个集群都支持。
问题4:内存降了但性能反而更差了
出现这种情况,第一反应先看GC日志。如果Old区一直在增长,说明堆给得太小,对象晋升后放不下,导致频繁Full GC。第二反应看JVM的MaxRAMPercentage是不是被某个环境变量或者启动脚本覆盖了。第三反应看是不是被Node级别的资源竞争影响了,你的Pod内存是降了,但这台Node上还有其他Pod在抢CPU,压测结果不干净。这种问题得先把压测环境隔离干净,再回头分析。
问题5:启动变慢了
AlwaysPreTouch、大堆初始化、懒加载都会影响启动时间。如果你用K8s的滚动更新,initialDelaySeconds要留足,否则新Pod还没就绪就被K8s杀掉重建,导致发布失败。我的经验是:initialDelaySeconds至少设成压测时从启动到Ready状态的耗时乘以1.5,别为了追求发布速度而把它压得太死。
这些坑你只要提前避开,调优过程会顺利很多。最后再分享一个我每次做容器化改造都会用到的核对清单,照着走基本不会漏:
- 观察3天内存基线,看Pod的requests和limits是否和实际匹配。
- 按比例设置JVM的Heap、Metaspace、线程栈,并开启GC日志。
- 精简依赖,关闭不用的自动配置。
- 压测三组场景,记录基线和优化后数据。
- 同时调整线程池和连接池,确保和CPU配额匹配。
- 配置好HPA的requests基准值。
- 运行1周,靠监控数据验证结果。
按这个顺序走一遍,你大概率能看到一个内存下降、性能不降甚至反升的结果。容器化之后,资源占用是一门动态平衡的艺术,别怕调整,但每一步都要有数据支撑。