1.3 机器准备:CVM 规格与 JVM 调优
订单中心第一台生产机器是 8 核 16G 的 CVM,JVM 参数照着网上的模板抄,堆给 8G,老年代占一半。压测时发现一个奇怪现象:单机 QPS 到 800 就上不去,CPU 才 40%,GC 日志倒是刷得飞起。这一节讲我们怎么把机器和 JVM 调到匹配的。
场景:下单链路的内存画像
下单链路的内存特征很典型:短对象多、峰值集中。每个请求产生订单对象、明细对象、序列化后的 JSON 字符串、MyBatis 的 ResultSetHandler 中间结果,这些东西朝生夕灭。大促瞬间一千个并发请求同时进来,年轻代瞬间被填满,Minor GC 频率拉高,如果年轻代太小,存活对象被过早推进老年代,Full GC 就来了。
我们的画像数据:日常 Minor GC 每 3 秒一次,大促时每 300 毫秒一次,Full GC 日常一天两次,压测时十分钟三次。这个频率显然不健康。
方案:规格与参数的匹配
先定机器规格。订单中心是典型的「中等 CPU、较大内存、吃磁盘 IO」的服务,对照 CVM 的规格族:
| 场景 | 推荐规格 | 堆配置 | 备注 |
|---|---|---|---|
| 日常 8 万单 | 8 核 16G | 8G 堆 | 年轻代 3G |
| 大促 50 万单 | 16 核 32G | 12G 堆 | 两台做负载均衡 |
| 对账批处理 | 8 核 16G | 10G 堆 | IO 优化型实例 |
机器是一次性投入,选规格宁可留三分余量。新用户特惠期拿的价格大概是平时的六折,我们一次买了两年:腾讯云云服务器特惠。
再定 JVM 参数。调完之后的参数和对应的理由:
| 参数 | 值 | 理由 |
|---|---|---|
| -Xms / -Xmx | 相同值 | 避免堆动态伸缩引发停顿 |
| -Xmn | 3G | 下单短对象多,年轻代给足 |
| -XX:+UseG1GC | 开启 | 16G 堆下 G1 停顿更可控 |
| -XX:MaxGCPauseMillis | 100ms | 目标停顿,配合下单超时 800ms |
| -XX:+HeapDumpOnOutOfMemoryError | 开启 | 留现场 |
配置:启动脚本
参数调整落在启动脚本里,用环境变量注入方便不同环境切换:
JAVA_OPTS="-Xms8g -Xmx8g -Xmn3g -XX:+UseG1GC"
JAVA_OPTS="$JAVA_OPTS -XX:MaxGCPauseMillis=100"
JAVA_OPTS="$JAVA_OPTS -XX:+HeapDumpOnOutOfMemoryError"
JAVA_OPTS="$JAVA_OPTS -XX:HeapDumpPath=/data/logs/heapdump.hprof"
JAVA_OPTS="$JAVA_OPTS -Xlog:gc*:file=/data/logs/gc.log:time,uptime"
nohup java $JAVA_OPTS -jar order-center.jar > /dev/null 2>&1 &
GC 日志和 heapdump 都落到 /data/logs,这个目录同时是 CLS 采集器监听的位置,日志直接进 CLS 而不留存本地磁盘。机器上的日志配置见后面的章节,这里先埋个线。
踩坑:三次调优经历
第一次是堆不是越大越好。最早给了 12G 堆,心想内存大总没错,结果 G1 的混合回收停顿随堆增长,下单超时反而变多。调到 8G 堆加 3G 年轻代,停顿从 200ms 降到 60ms。大对象(批量对账的 List)走 G1 的 Humongous 分配,超过_region 一半就特殊处理,堆太大反而让回收成本上升。
第二次是元空间被忽视。一次发布后服务起不来,OOM 在 MetaSpace。原因是 MyBatis 动态 SQL 生成大量代理类,默认元空间无限增长但机器内存有限,当时是 ClassLoader 泄漏。加了 -XX:MaxMetaspaceSize=256m 后立显报错,也好排查。
第三次是 swap 的坑。机器内存吃紧时系统开始用 swap,JVM 的停顿会飙到秒级。后来在 CVM 控制台看到监控曲线才发现。处理办法是关掉 swap(业务内存可控的前提下),并给机器加内存告警,内存使用超过 85% 就提前扩容,不等 OOM。
另外一个小经验:压测一定要在和生产同规格的机器上做。我们在 4 核测试机上压出的 2000 QPS,换到生产 8 核机只有 700,因为测试机的 IO 和网络带宽限制完全不同。云服务器的网络收发包配额也是隐形天花板,小规格实例的 PPS 上限会先于 CPU 到达。
进阶实战:压测与容量的配比
JVM 与机器调好之后,下一个问题是「这台机器到底能扛多少」。我们的做法是定期做小规模压测,把单机容量标出来,再按流量峰值倒推需要的实例数。压测的数据直接决定 CVM 的采购计划,避免拍脑袋买多或买少。
| 压测档位 | 单机 QPS | 实例数 | 峰值余量 |
|---|---|---|---|
| 日常 8 万单 | 800 | 4 台 | 3 倍 |
| 大促 50 万单 | 2000 | 8 台 | 2 倍 |
| 春晚级红包雨 | 4000 | 16 台 | 1.5 倍 |
压测时还要盯一个隐形指标:机器的网络收发包 PPS。小规格实例的 PPS 配额比 CPU 先打满的现象在高并发小报文场景特别明显,此时加 CPU 没用,只能升规格。另外监控 CVM 控制台的网卡带宽利用率,超过 60% 就要规划带宽升级,不要等打满再处理。
速查清单
机器与 JVM 调优的四条纪律:
| 纪律 | 说明 |
|---|---|
| 看日志再调参 | GC 日志与监控先于参数调整 |
| 堆匹配对象画像 | 短对象多则年轻代给足 |
| 停顿匹配超时 | GC 停须小于接口超时的十分之一 |
| 关闭 swap | 内存告警替代 swap 兜底 |
小结
机器与 JVM 调优的核心是匹配而非豪华:堆大小匹配对象画像,GC 停顿匹配接口超时,规格匹配流量峰值并留三分余量。调优顺序永远是先看 GC 日志再调参数,别盲抄模板。下一节把技术栈版本和云产品的对应对齐。
常见误解:JVM 调优的三个陷阱
第一个陷阱是「调优就是调参数」。大部分性能问题不是 JVM 参数不对,而是代码本身的问题。一次线上延迟突然升高,团队花了一下午调 G1 参数毫无效果,最后定位到是一个 SQL 漏了索引。此后我们立了规矩:调 JVM 参数之前先看三样东西——慢 SQL 排行、接口耗时分布、GC 日志,确认是 GC 问题再动手。
| 陷阱 | 正确姿势 |
|---|---|
| 盲目调参 | 先定位再调 |
| 堆越大越好 | 匹配对象画像 |
| 加机器解决一切 | 先找瓶颈点 |
第二个陷阱是「堆越大越好」。这个观点在前面的踩坑里提过半次,值得展开。大堆的代价是回收停顿长、Humongous 对象多、Full GC 一旦发生就是灾难级停顿。订单系统的甜点区在 8 到 16G 堆,再大不如横向加实例。实例多还有一个好处:单实例故障影响面小,灰度发布也能更从容。
第三个陷阱是「加机器是解决性能问题的通用答案」。如果瓶颈是行锁或者慢 SQL,加机器毫无帮助,反而掩盖问题让根因更难查。我们的纪律是先定位瓶颈类型:CPU 型、IO 型、锁型、网络型分别对应不同解法,加实例只在 CPU 型且单实例接近瓶颈时才是正确答案。
团队协作:运行时的观测约定
机器与 JVM 调优的成果需要被看见,我们因此定了一套观测约定:每台实例启动后十秒内,关键指标必须出现在监控大盘上——GC 频率、堆使用率、线程池水位、接口 P99。约定实现简单但效果显著:新实例扩出去后,看一眼大盘就知道它是否健康接入了流量,而不是等到用户投诉。
| 观测项 | 阈值 | 告警 |
|---|---|---|
| Full GC 频次 | 每小时超过 2 次 | P2 |
| 堆使用率 | 超过 85% | P2 |
| 线程池等待 | 队列超过 100 | P1 |
| 接口 P99 | 超过 800ms | P2 |
约定里还包括「启动自检」:实例启动时把 JVM 参数写入日志,打上构建版本号。排查问题时第一眼就能确认机器跑的是不是预期的配置。这个习惯救过一次急:某次线上异常,发现有一台机器被手工用旧 jar 启动了,版本号一对便知。可观测性不是高大上的概念,就是约定的累积。
