你是不是也遇到过这种情况:明明代码逻辑看起来没问题,可一到业务高峰期,接口就慢得像老牛拉车,CPU飙到 100%,数据库连接池被打满,偶尔还来一次 Full GC 把服务卡死几秒。新手第一反应是加机器、加缓存,但资深一点的工程师都知道,问题大概率出在两个地方:JVM 的内存管理,和 SQL 的执行效率。这次就把这两块放一起聊透,用一次线上订单导出功能的真实调优过程,把 JVM 调优实操和 MySQL 慢查询优化的完整链路走一遍。
这次的内容适合谁?刚接触性能优化的后端开发、准备 JVM 和 MySQL 面试的候选人,以及那些被线上性能问题折磨得睡不着觉的运维和架构师。我会把思路、参数、排查命令、SQL 改写细节全部摊开来讲,不藏着掖着,保证你读完能直接用到自己的项目里。
1. 内容整体设计与思路拆解
1.1 这次调优的背景:一个订单导出功能引发的连锁反应
当时业务方反馈了一个问题:运营后台的订单导出功能,导出的数据量一旦超过 5 万条,页面就会一直转圈,等上两三分钟才有响应,偶尔直接报 504 超时。监控平台上看到的现象更直观——应用服务器 CPU 使用率接近 100%,MySQL 的 CPU 也居高不下,慢查询日志里每几分钟就多一条执行时间 10 秒以上的 SQL。
很多人在这种时候容易陷入误区,一上来就想着改代码、加并发、上消息队列。我的建议是:先把现象和数据收集齐,用证据说话。当时的处理顺序是这样的:先看 JVM 的 GC 情况和堆内存占用,再看 MySQL 的慢查询日志和执行计划,两头并进,最终发现两个问题其实是互相放大的——JVM 频繁 Full GC 导致服务响应变慢,慢 SQL 长期占用数据库连接又加剧了应用侧的线程阻塞,两者形成了恶性循环。
1.2 为什么把 JVM 和 MySQL 放在一起调优
很多项目组习惯把应用调优和数据库调优分成两个团队来做,JVM 的问题就找中间件团队,SQL 的问题就扔给 DBA。但实际生产环境中,JVM 和 MySQL 是同一套请求链路上的两个环节,任何一个环节出问题,都会拖累整体性能。
拿这次的订单导出举例:应用层一次性从数据库查出 5 万条记录,这些数据要放进 List 里,再通过 POI 写入 Excel 文件。这个过程中,JVM 堆内存要承载大量订单对象,同时数据库要执行大范围查询和排序。如果只优化 JVM 而不优化 SQL,查询 10 秒的超时时间照样让前端等不起;如果只优化 SQL 而不优化 JVM,那瞬间加载进内存的 5 万条订单对象又会让老年代直接爆掉,触发频繁 Full GC。两个必须配合着调,才能达到"数据库快点出数据、JVM 稳稳装下数据"的理想状态。
1.3 方案选型的底层逻辑:先止损,再优化,最后预防
性能优化的黄金法则是:先止损,再优化,最后预防。止损的意思是先把线上故障压下去,比如先把导出功能的超时时间调大、限制单次导出数量,让系统先恢复正常;优化是我的主要工作,就是本文要讲的这两条主线;预防则是在优化完成后,搭建监控和告警机制,避免问题再次发生。
这套逻辑同样适用于技术选型。JVM 的选择上,当时服务用的 JDK 8,默认的垃圾收集器是 Parallel Scavenge + Parallel Old,这套组合更看重吞吐量,对响应时间不敏感。但订单导出属于典型的 IO 密集 + 大对象分配场景,我更倾向于使用 G1 收集器,它能更好地控制停顿时间。MySQL 侧的选择则是从 InnoDB 存储引擎的特性出发,利用覆盖索引和延迟关联来减少回表次数。这些选择都不是拍脑袋决定的,背后都有明确的依据,后续章节会逐一展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JVM 调优核心细节与实操要点
2.1 先把 JVM 内存模型和堆结构摸清楚
聊 JVM 调优,第一个绕不开的就是内存模型。很多新手一听到 JVM 内存模型就头大,其实可以把它理解成一家餐厅的厨房——堆内存是食材仓库,栈是厨师的操作台,方法区是挂在墙上的菜谱。你往仓库里堆再多的食材,操作台太小,菜还是做不快。JVM 调优的本质,就是给仓库和操作台分配合适的大小,并保证厨师的出菜节奏稳定。
具体到这次场景,重点在堆内存的内部结构。堆内存分三块:新生代(Young)、老年代(Old)和元空间(Metaspace)。新生代里又细分为 Eden 区和两个 Survivor 区,比例默认是 8:1:1。新创建的对象都先进 Eden 区,Eden 满了触发 Minor GC,存活的对象被挪到 Survivor 区。对象每熬过一次 Minor GC,年龄就加 1,默认到 15 岁还活着,就晋升到老年代。老年代存放的是生命周期长的对象,满了之后触发 Major GC / Full GC,而 Full GC 是最伤性能的,因为它要回收整个堆。
我建议你在动手调参之前,先做一件非常关键的事——用 jmap 或者 MAT 查看堆内存里的对象分布。当时我用 jmap -dump 导出了线上环境的堆转储文件,用 MAT(Memory Analyzer Tool)一分析,发现订单对象占了老年代将近 40% 的空间,而且大量订单对象的 byte[] 字段直接把内存顶得满满的。这就解释为什么导出功能一运行,老年代就迅速膨胀、Full GC 的频率直线上升——因为一次性加载 5 万条订单到内存里,还都是大对象,新生代的 Survivor 区根本装不下,直接晋升到了老年代。
2.2 JVM 核心参数逐个拆解:每个参数背后的逻辑
JVM 参数非常多,但真正常用的核心参数就那一批,关键在于理解每个参数的适用场景和背后的权衡。我整理了一份参数清单,都是这次调优实际用到的,每个参数我都会附上调整理由和注意事项。
bash复制# 基础堆内存设置(服务器是 8G 内存)
-Xms4g
-Xmx4g
-Xmn2g
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 垃圾收集器选择
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1HeapRegionSize=16m
# 吞吐与日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc-%t.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/
-Xms 和 -Xmx 是最基础的两个参数,分别设置堆内存的初始值和最大值。我的习惯是直接把它们设置为相同值,比如 4g,这样做的好处是避免 JVM 在运行过程中动态扩容和缩容,减少性能波动。服
