ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

性能测试到调优实战:JMeter、JVM、MySQL与OOM排查

性能测试到调优实战:JMeter、JVM、MySQL与OOM排查 做性能这行有句话我挺认同压测本身不产生价值压完之后你动手改了什么、改完之后指标动了多少才真正产生价值。这些年前前后后处理过不少线上毛刺、接口超时、数据库被打满的事故复盘回头再看性能测试—性能分析—调优这三步里最容易被糊弄过去的恰恰是中间那一步。很多人脚本一跑看着聚合报告上平均响应时间还行就收工了真到线上出事的时候P99 那条尾巴才是最要命的。这篇东西不打算堆概念我想把一次完整的性能分析和调优按我自己的习惯顺序摊开说怎么定指标口径、怎么搭 Jmeter 脚本、拿到数据怎么读、JVM 和 MySQL 两个大头怎么调、OOM 怎么分类处理、批量操作怎么优化最后顺带聊聊 IDEA 这种本地环境把 CPU 吃满怎么收拾。写给谁看写过接口但没正经做过压测的后端、被服务变慢这个问题追着跑的同学、准备性能相关面试的还有运维和测试里想往性能方向转的人都能从里面挑到能直接抄的东西。有经验的朋友可以直接跳到关心的章节我尽量把每一步背后的理由讲清楚而不是只丢一堆参数出来。1. 先把口径定死性能测试到底在测什么1.1 三个绑在一起的数字TPS、RT、错误率性能测试的产出物说到底就是三个数字吞吐TPS 或 QPS、响应时间RT、错误率。这三个是绑死的单独看任何一个都会得出错误结论。有人跟你说我们的接口能扛 2000 QPS你第一件事该反问的是在这个 QPS 下 RT 是多少、错误率是几个点、连续压了多长时间、数据量和线上是不是一个量级。缺一个维度这个数字就没法信。RT 本身也不是一个数是一条分布。均值特别能骗人P95、P99 才有参考价值。我见过一个查询接口平均响应 80ms看着挺体面但 P99 是 2.3 秒意思是每一百个请求里就有一个人要等两秒多。线上用户骂的基本都是这条尾巴贡献的。所以定指标的时候我会写清楚四个门槛平均 RT、P95、P99、错误率上限再配一个持续时间要求比如在 500 TPS 压力下持续 30 分钟P99 不超过 300ms错误率低于 0.1%。这样一条验收线才是有意义的目标。再说容量怎么估。这里有个特别实用的小定律并发数 目标 TPS × 平均 RT秒。举个例子你希望订单接口扛住 500 TPS平均 RT 是 50ms那理论并发线程数就是 500 × 0.05 25。注意这只是算数层面的下限实际因为 RT 会随压力上升加上网络和处理抖动真实需要的并发往往要比这个大通常我会按 1.5 到 2 倍配线程数然后慢慢往下探真正的拐点在哪里。提示别把并发用户数和线程数混为一谈。Jmeter 里的线程数只是施压工具的手段真正的业务并发是 Lerp 之后的有效请求数两者在 RT 短的时候差距会非常明显。1.2 分层压测别一上来就压全链路一上来就压全链路基本都是自找麻烦。链路里任何一个薄弱环节先挂你就得花大量时间去排除到底是谁的问题。我习惯按四层往上爬。单接口基准层脱离上下游直接测单个接口的极限能力目的是找到这个接口在什么并发下开始劣化拿到一张基准曲线。这一层主要用来建立信心和基线。第二层是模块压测把一组相关接口按业务比例混在一起压比如下单链路里的查商品—查库存—下单—支付回调这一层能暴露事务和锁的问题。第三层是链路压测接上真实的中间件和数据库这一步最容易出问题也最接近线上。第四层是稳定性压测用目标压力的 70% 到 80% 连续跑 8 小时甚至更久专门抓内存泄漏、连接泄漏、日志写满、句柄耗尽这类压一会儿没事压久了就炸的问题。场景的流量比例一定要贴近线上真实分布不能所有接口都按 1:1 压。真实业务里查的量往往是写的几十倍如果你用 1:1 去压读的瓶颈还没暴露写的瓶颈先被放大了几十倍最后调出来的东西根本不是线上要面对的东西。1.3 环境和数据准备这一步省不得压测环境尽量独立数据量尽量贴近线上。我吃过最大的亏就是在只有十万条数据的库上压出漂亮的曲线上线之后数据涨到八千万条同一条 SQL 直接从 20ms 变成 1.2 秒。SQL 的执行计划是随数据量变化的索引选择也会变测试环境的数据量不上去压测结论就没有迁移价值。数据准备还要注意热点分布。真实的用户行为是有倾斜的比如百分之九十的请求集中在百分之十的热点商品上。如果你用完全均匀的随机数据去压缓存命中率高得离谱压出来的性能比线上好一大截。所以准备数据的时候我会特意把一部分热点数据重复出现模拟真实倾斜。2. Jmeter 从零搭到能跑的完整步骤2.1 元件树怎么搭才不乱Jmeter 的脚本结构其实就是一棵元件树搭得乱后面维护就是灾难。标准结构我一般是这么放的Test Plan 下面挂线程组线程组下面挂一个事务控制器事务控制器里面放 HTTP 请求默认值、用户定义的变量、CSV 数据文件设置、HTTP 信息头管理器然后再挂具体的请求和断言最后在线程组同级挂监听器。HTTP 请求默认值要写死协议、域名、端口、编码这样每个具体的请求里就不用重复填了后面换环境只要改这一处。HTTP 信息头管理器统一放 Content-Type、Token 这类公共头但要小心Jmeter 的头管理器作用域是按树形结构继承的如果把带 Token 的头管理器放在线程组顶层所有请求都会带上某些接口会因此报错这个坑我踩过不止一次。事务控制器的作用是把一组请求合并成一个事务来统计。比如下单这个动作实际包含了校验、扣库存、建单、发消息四个请求你关心的是整个下单动作的耗时那就把它们塞进一个事务控制器勾上 Generate parent sample报告里就只统计这一个事务的 RT不然你面对的是四个分散的数字没法判断业务层面的好坏。线程组本身也有讲究。Ramp-up 时间要给足让压力平滑爬升我一般用目标线程数 / 期望爬升秒数比如 200 个线程想 100 秒爬到位Ramp-up 就填 100。如果直接 0 秒起 200 线程那一瞬间就是尖峰冲击测出来的是尖峰能力不是稳态能力两者结论完全不同。循环次数建议勾永远配合调度器设置持续时间这样更好控制压测时长。2.2 参数化和关联脚本能不能复用的关键写死的脚本只能跑一次。参数化就是把变化的输入抽出来最常用的就是 CSV Data Set Config。准备一个 csv 文件一行一条数据配置里填好文件路径、变量名、分隔符勾上 Recycle on EOF 和 Stop thread on EOF就能让每个线程循环读不同的数据。这里有个细节Sharing mode 要选Current thread不然多个线程会争抢同一个文件指针出现数据错乱。关联是另一个必学的点就是从一个请求的响应里提取数据喂给下一个请求。比如登录接口返回的 token后面所有请求都要带。Jmeter 里用 JSON 提取器或者正则提取器都行JSON 提取器更直观写个 JSONPath 表达式就行。提取出来的变量默认是线程级的跨线程组共享就得用 __setProperty 和 __P 这类函数配合属性来做。断言也是必须加的。我见过太多脚本跑完错误率显示 0%仔细一看根本没加响应断言只要 HTTP 状态码是 200 就算通过。业务接口挂了但返回 200 且 body 里写着 error脚本一样给你算成功。所以至少加一个响应断言检查返回内容里包含某个关键字段。响应时间断言也可以加超过阈值就标红后面分析报告更直接。注意CSV 参数化文件路径尽量用绝对路径或者放在 bin 目录下的相对路径脚本换机器跑的时候路径问题是最常见的报错来源。2.3 非 GUI 压测和报告解读GUI 模式只用来调试脚本真正压测必须走命令行。原因很直接Jmeter 的图形界面本身非常吃资源监听器实时渲染会抢占大量 CPU 和内存你压出来的瓶颈可能是压测机自己撑不住了而不是被测系统的问题。命令行的标准姿势是这样jmeter -n -t order_flow.jmx -l result.jtl -e -o ./html_report-n表示非 GUI 模式-t指定脚本-l是结果文件-e -o表示压测结束后生成 HTML 报告到指定目录。注意-o指定的目录必须不存在或者是空的否则 Jmeter 会直接报错退出这是新手最容易卡住的地方跑之前先rm -rf ./html_report。如果一台机器压不出目标压力就要上分布式。原理是一台控制机调度多台执行机所有执行机同时压。配置上关键两点执行机要启动 jmeter-server控制机的配置文件里要把 remote_hosts 配上所有执行机的地址脚本和 CSV 参数文件要在每台机器上放同一路径。压测前先用少量线程验证所有执行机都连通并且返回一致不然可能出现只有部分机器在压的情况。拿到 HTML 报告之后重点看几个地方。Response Time Over Time 这条曲线能看出系统是否稳定如果曲线随着时间缓慢上台阶多半是有资源没释放如果中途突然出现一个台阶然后维持高位可能是触发了某种降级或者锁竞争。Transactions Per Second 这条曲线上面如果出现规律的锯齿通常是 GC 造成的周期性停顿。还有 Response Time Percentiles 那张表重点关注 P90、P95、P99 这三列。最后看 Errors 面板错误一旦出现一定要先把错误类型搞清楚是连接被拒绝、超时还是业务异常不同类型的处理方向完全不同。3. 拿到数据怎么读从曲线定位瓶颈3.1 RT 和 TPS 的三种典型形态压测曲线其实就那几种形态认熟了判断会快很多。第一种TPS 随并发升高而升高到达某个点之后变平甚至下降RT 开始加速上升。这个拐点就是系统当前的容量上限说明某个资源已经饱和了。第二种TPS 一直上不去从一开始就很低RT 却很高。这说明系统根本没有被压满而是在等某个东西典型的等待锁、等待连接池、等待外部接口。第三种TPS 忽高忽低RT 剧烈抖动这种一般和 GC、缓存集中失效、定时任务抢资源有关。怎么快速区分是资源饱和还是在等待看 CPU 利用率。如果 TPS 上不去而 CPU 只用到三四成基本可以断定是在等别再去调 JVM 参数了先去找是谁把请求卡住了。3.2 主机层观测几个命令就够用压测过程中主机层面的观测我用得最多的就这几个命令基本能覆盖九成场景。top看整体重点看 load average、CPU 各项占比、内存和 swap。这里有个关键知识点waiowait高说明 CPU 在等磁盘 IOsi软中断高往往和网络有关sy系统态占比高可能是频繁的系统调用比如线程切换过多。这些数字指向的方向完全不同别一看到 CPU 高就当应用问题去查。vmstat 1看进程、内存、swap 和 IO 的概览。重点看 r 列运行队列长度和 b 列阻塞进程数如果 r 长期大于 CPU 核数说明 CPU 是瓶颈b 长期不为零说明有进程在等 IO。pidstat -p pid -u 1看单个进程的 CPU 使用pidstat -p pid -w 1看上下文切换pidstat -p pid -d 1看进程级磁盘 IO。上下文切换异常高常见的原因是锁竞争激烈或者线程池配置不合理。iostat -x 1看磁盘重点看 %util 和 await。%util 接近 100% 说明磁盘被压满await 明显偏高说明 IO 排队严重。ss -s和netstat -an | awk /^tcp/ {S[$NF]} END {for(a in S) print a, S[a]}看 TCP 连接状态分布。TIME_WAIT 太多、SYN 队列溢出、CLOSE_WAIT 堆积都能从这些统计里看出来。CLOSE_WAIT 大量堆积基本就是你应用侧没正确关闭连接八成是代码里忘了关资源。3.3 一次完整的定位走位说个我印象比较深的案例。订单查询接口压到 320 TPS 就上不去了RT 的 P99 从 60ms 一路涨到 900ms。先看 CPU应用服务器四核只用了 40%数据库服务器也只用了 30%两边都不饱和。那就说明是在等。先看数据库连接池配置的最大连接数是 20。再看应用日志里的连接获取耗时平均 30ms 左右。算一下就明白了20 个连接每个请求平均占用连接 30ms理论上每秒能处理的请求上限就是 20 / 0.03 ≈ 666但 RT 里还包含其他开销加上连接池获取本身有排队实际卡在 320 就很合理了。处理方式分两步。第一步把连接池从 20 调到 50TPS 立刻上到 520说明方向是对的。第二步看慢查询日志发现有一条按时间范围查订单的 SQL 没走索引扫描行数上百万把它占用的连接时间从 30ms 压到 5ms再压TPS 直接上到 1400 多。这个案例的教训是连接池不是越大越好池子大了只是把排队从应用挪到了数据库真正该做的是缩短单个请求占用连接的时间。4. JVM 调优先量再调别拍脑袋4.1 工具选型jps 和 jstat 是第一步JVM 调优最忌讳的就是不看数据直接改参数。我见过有人一上来就把堆从 4G 加到 16G结果 GC 单次停顿从 50ms 涨到 400ms问题反而更严重因为堆大了一次 Full GC 要扫描的对象更多。工具链里第一步永远是jps。它能列出当前机器上所有 Java 进程和主类名加上参数还能看到启动参数jps -lvm-l显示完整主类-v显示 JVM 参数-m显示传给 main 的参数。运维给的机器上跑了一堆服务先确认你要分析的是哪个 pid别搞错了对象。确认 pid 之后用jstat看 GC 情况这是最快能看出问题的一步jstat -gcutil pid 1000 20这条命令每秒输出一次共输出 20 次。重点看几列E 表示 Eden 区使用率O 表示老年代使用率FGC 是 Full GC 次数FGCT 是 Full GC 累计耗时。判读逻辑很直接如果 O 列在每次 Full GC 之后能回落到很低的位置说明只是对象生命周期长属于正常如果 O 每次 Full GC 之后都在上升慢慢逼近 100%那就是内存泄漏得去抓堆快照。再看 FGC 的增长速度如果几分钟就几十次 Full GC那 GC 开销肯定已经吃掉大量 CPU 了。还有一个jstat -gcnew pid 1000能看新生代各区域的详细情况包括 Survivor 区的使用和对象晋升年龄调新生代大小的时候要用到。4.2 抓快照jmap 和 jstack 的正确用法怀疑内存问题就抓堆快照jmap -dump:formatb,file/tmp/heap.hprof pid如果只是想快速看看谁占内存不想生成几个 G 的文件用jmap -histo:live pid | head -30-histo:live会先触发一次 Full GC 再统计好处是统计的是存活对象坏处是这次 Full GC 会造成一次明显停顿线上执行要谨慎尽量挑低峰期。输出的前几名里如果看到某个业务类实例数量异常庞大基本就锁定方向了。CPU 高的问题用jstackjstack -l pid /tmp/stack.txt-l会额外输出锁的附加信息能看出死锁和哪些线程在等哪把锁。找 CPU 热点线程的完整走位是这样先用top -Hp pid找出占用 CPU 最高的线程 id然后把它转成十六进制printf %x\n tid再在 jstack 输出里搜这个十六进制值就能定位到具体哪一行代码在烧 CPU。这个套路看起来笨但极其有效。注意单个 jstack 只能反映某一瞬间要连抓三到五次看哪个栈反复出现那才是真热点只看一次很容易被某个恰好正在执行的普通线程带偏。4.3 可视化工具jconsole、VisualVM 和 Arthasjconsole和VisualVM是 JDK 自带的图形化工具直接连本地或远程进程能看内存曲线、线程数曲线、GC 活动。VisualVM 还能装插件看堆快照和采样 CPU。这两个工具适合在本地或者测试环境用线上一般不会给你开图形界面。真正在线上做在线诊断Arthas是我现在的主力工具。它的好处是不用重启、不用改代码、不用加参数attach 上去就能看。启动方式很简单java -jar arthas-boot.jar然后选进程号。常用命令我列一下都是高频的dashboard看整体包括线程、内存、GC 的实时面板。thread -n 3列出最忙的三个线程及其栈定位 CPU 热点一步到位。thread -b找出阻塞其他线程的那个线程处理接口卡死类问题特别好用。trace com.xxx.service.OrderService query追踪方法调用链上每一层的耗时哪个环节慢一目了然。watch可以观察方法的入参和返回值排查为什么这个参数走进来结果不对很高效。heapdump直接导堆快照。profiler start配合profiler stop --format html能生成火焰图做整体性能画像非常直观。注意Arthas 的 trace 命令在高频方法上使用会带来额外的性能开销线上执行前一定加上-n限制次数比如trace -n 5看完就退出别挂着不管。4.4 堆和 GC 参数怎么定参数不是抄来的是算出来的。基本盘是这样-Xms8g -Xmx8g -Xmn3g -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/-Xms和-Xmx设成一样避免运行期堆反复伸缩带来的额外开销和抖动。-Xmn设置新生代新生代太小会导致对象过早晋升到老年代加剧 Full GC太大又会让单次 Young GC 的停顿变长。一个常用的经验值是把新生代设在整堆的三分之一到一半之间然后根据 Survivor 区的存活情况微调。-XX:HeapDumpOnOutOfMemoryError这个是必加的出了 OOM 自动留现场不然事后复盘只能靠猜非常痛苦。GC 日志也是必开的。JDK 8 上是-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 9 以后换成了统一日志框架-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tagsGC 日志的价值在于能算出真实的对象分配速率和晋升速率这两个数字是调整堆大小的核心依据。光看监控图上的 GC 次数信息量远远不够。5. OOM 报错分类与对应的处理方式5.1 堆内溢出先分清是泄漏还是不够用java.lang.OutOfMemoryError: Java heap space是最常见的一种但它的病因有两种处理方式完全相反。第一种是内存泄漏。判断方法是看 Full GC 之后老年代能不能回落。如果反复 Full GC老年代占用还在持续上升那就是有对象被意外持有无法回收。这时候去抓堆快照用分析工具打开按 retained size 排序找到占用最大的对象及其引用链。我遇到过的典型场景包括静态 Map 当缓存用但没有淘汰策略一路往里塞ThreadLocal 用完不 remove线程池复用时数据越积越多监听器注册了没注销对象一直被事件源引用着。第二种是真的不够用。这时候 Full GC 之后老年代是能明显回落的只是业务量上来之后回收速度赶不上分配速度。这种情况要么加堆要么降低对象分配速率比如优化大对象、减少不必要的临时对象、把大批量查询改成流式处理。加堆只是缓解如果分配速率本身有问题加多少都会被吃满。5.2 非堆内存Metaspace、直接内存和线程OutOfMemoryError: Metaspace一般是类加载过多导致的。常见诱因是用了大量动态代理、反射生成类或者热部署反复加载类而没有卸载。处理方式是先看加载的类数量jstat -class pid能看到已加载类数。如果类数一直在涨那就是有类加载器泄漏。参数上-XX:MaxMetaspaceSize可以设一个大一点的上限但这只是兜底根因还得从类加载器下手。OutOfMemoryError: Direct buffer memory是堆外内存的问题。NIO 的 ByteBuffer.allocateDirect 分配的内存不在堆里堆快照看不到。如果堆内存看着很健康进程的物理内存却一直涨那就要怀疑这里。可以加上-XX:MaxDirectMemorySize设个上限更重要的是检查代码里 DirectByteBuffer 有没有及时释放。OutOfMemoryError: unable to create new native thread是线程创建不出来。先查系统层面的限制ulimit -u看进程数上限cat /proc/sys/kernel/threads-max看系统总线程数限制。应用层面通常是线程池没有用有界队列任务堆积之后无限创建线程把系统资源耗尽。线程池的正确姿势是用有界队列加合理的拒绝策略别用无界的 LinkedBlockingQueue。5.3 GC overhead limit exceeded 和数组过大GC overhead limit exceeded这个名字容易误解它不是说内存不够而是说 GC 花了超过 98% 的时间却只回收了不到 2% 的堆空间。本质还是内存不足或泄漏处理思路和堆溢出一样先看老年代能否回落再决定是查泄漏还是加内存。但要注意这个错误触发的时机往往比堆溢出更早出现它说明系统已经在 GC 上挣扎很久了服务的实际可用性早就跌到谷底了。Requested array size exceeds VM limit是申请数组长度超过上限多数情况是代码里算长度的时候出了整数溢出比如从数据库查 count 得到一个异常大的值然后直接拿它去 new 数组。这类问题通常伴随数据异常先查数据再查代码。排查 OOM 有个通用流程我总结成一张表报错类型首要怀疑点关键排查手段Java heap space泄漏或分配速率过高堆快照对比、jstat 看老年代回落GC overhead limit exceeded与堆溢出同源同上优先看 GC 耗时占比Metaspace类加载器泄漏jstat -class 看类加载数趋势Direct buffer memory堆外内存未释放进程物理内存对比堆大小unable to create new native thread线程数超限ulimit、线程池队列配置Requested array size整数溢出或脏数据查数据来源与长度计算逻辑6. MySQL 性能调优的实操路径6.1 从慢查询日志到执行计划数据库调优的第一步是打开慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL log_queries_not_using_indexes ON;long_query_time设 1 秒只是个起点压测阶段可以设得更激进比如 0.1 秒先把可疑的都捞出来再逐条分析。日志文件用mysqldumpslow或者pt-query-digest做聚合后者更好用能按总耗时、执行次数、平均耗时排序帮你快速分出哪些 SQL 是真正的重灾区。注意别只盯着单次耗时最长的一条单次 2 秒但一天执行一次的 SQL危害远不如一条单次 20ms 但每秒执行一万次的 SQL。拿到具体 SQL 之后就是看执行计划EXPLAIN SELECT id, order_no FROM orders WHERE user_id 123 AND create_time 2024-01-01;重点看三列。type 列体现访问方式从好到坏大概是 system、const、eq_ref、ref、range、index、ALL看到 ALL 就是全表扫描必须处理。key 列是实际使用的索引如果显示 NULL说明没走索引。rows 列是预估扫描行数这个数越大代价越高。还有一个很容易被忽略的 Extra 列出现 Using filesort 说明有额外排序出现 Using temporary 说明用了临时表这两个在数据量大时都是性能杀手。6.2 索引设计的几条硬规矩索引这块我把踩过的坑总结成几条规矩。最左前缀原则必须记牢联合索引 (a, b, c) 能支持 a、ab、abc 的查询但单独查 b 或者查 bc 是用不上的。所以联合索引的字段顺序要按照查询频率和区分度来排区分度高的放前面。不要在索引列上做运算或者用函数。WHERE DATE(create_time) 2024-01-01这种写法会让索引失效改成WHERE create_time 2024-01-01 AND create_time 2024-01-02才能用上。前导模糊匹配也会失效LIKE %keyword走不了索引LIKE keyword%可以。如果确实需要全文检索那就上专门的检索方案别硬靠 LIKE 撑。索引不是越多越好。每个索引都会占用空间并且拖慢写入速度因为每次写都要维护所有相关索引。我见过一张表上建了十几个索引查询是快了但写入的 TPS 只有个位数。一般来说单表索引控制在五个以内比较合理并且要定期用pt-index-usage这类工具检查有没有从未被使用的冗余索引。还有一个高频问题是隐式类型转换。如果 user_id 是 varchar 类型你写WHERE user_id 123传了个数字MySQL 会把字段转成数字来比较索引直接失效。这类问题特别隐蔽执行计划里看着用了索引但实际扫描行数和全表差不多。6.3 参数调优和连接池配比参数层面innodb_buffer_pool_size是重中之重它缓存数据和索引页命中率直接决定查询快还是慢。一般设成物理内存的 50% 到 70%如果是专用数据库服务器可以到 75%。设置完要看缓冲池命中率正常应该在 99% 以上。max_connections要和应用侧的连接池匹配。这里有个常见的坑应用侧连接池总数乘以应用实例数超过了数据库的 max_connections就会出现偶尔连不上数据库。比如 10 个应用实例每个连接池 50总共 500数据库却只配了 300那必然有 200 个连接请求会失败。配比原则是数据库的 max_connections 要略大于应用侧连接池总和留出管理连接的余量。连接池本身也不是越大越好。连接数过大会让数据库的线程切换开销急剧上升反而降低整体吞吐。经验值是可以先用CPU 核数 × 2 磁盘数作为起点然后压测慢慢试出最优点。同时要配好连接的有效性检测避免拿到已经被数据库断掉的死连接。6.4 批量操作的调优思路批量场景是性能问题的高发区不管是批量插入、批量更新还是数据同步任务都很容易把数据库压垮。批量插入的第一条优化是合并 SQL。单条插入几千次每次一次网络往返开销巨大。改成多值插入INSERT INTO orders (order_no, user_id, amount) VALUES (A001, 1, 100), (A002, 2, 200), (A003, 3, 300);分批提交每批五百到一千条比较合适太小网络开销占比高太大又容易造成大事务锁持有时间长还可能触发主从延迟。用 JDBC 的话记得在连接串里加上rewriteBatchedStatementstrue否则框架层面的批量提交实际上还是被拆成一条条发出去的这一点很多人不知道白白做了无用功。批量更新可以用INSERT ... ON DUPLICATE KEY UPDATE或者REPLACE INTO但要注意REPLACE INTO实际是删除加插入会触发自增主键跳号也会影响基于 binlog 的同步。批量任务还有一个通用原则分片处理加限流。把大批量拆成小批次每批之间稍微 sleep 一下或者在应用层做并发控制只开固定数量的工作线程。这样做虽然总耗时可能略长但目标系统的负载曲线是平滑的不会造成尖刺。生产环境里平稳比快更重要。提示跑批量任务前先确认 binlog 格式和主从延迟监控。大事务在主库上执行几秒钟从库可能要追几分钟这段时间读到旧数据的业务会出问题。7. IDEA 占用 CPU 过高怎么排查和处理7.1 先排查再动手开发机上的 IDE 卡顿虽然不影响线上但直接影响效率而且排查思路和线上性能分析是相通的值得单独说一说。IDEA 突然 CPU 跑满第一步是打开内置的活动监视器通过 Help 菜单里的 Diagnostic Tools 找到 Show CPU Usage 之类的入口能看到当前占用最高的线程和它在做什么。绝大多数情况会指向索引重建、代码检查、编译任务这三类。如果活动监视器里看到的是 Indexing 相关线程那就是索引问题如果是 Kotlin 编译线程或者 IncrementalCompiler那就是编译慢如果是 Inspection 相关那就是代码检查在跑。还有一个更彻底的办法就是用 jps 和 jstack因为 IDEA 本身也是 JVM 进程。用 jps 找到 IDEA 的 pidjstack 抓一份栈看哪个线程在 RUNNABLE 并且占用高栈里会直接显示是哪个模块在工作。这个方法和排查线上服务完全一样练熟了在两边都适用。7.2 常见诱因和处理清单诱因一项目目录太大导致索引慢。比如把 log 目录、node_modules、target、build 这些生成目录也纳入了索引范围。处理方式是右键目录选择 Mark Directory as标成 Excluded。这个操作对索引速度的提升立竿见影尤其是前端项目里 node_modules 动辄几万个文件排除掉之后索引时间能缩短一大半。诱因二内存给得太小。IDEA 默认堆大小可能只有 750M 到 1G项目一大就频繁 GC表现出来就是界面卡、CPU 高。改法是在 Help 里的 Edit Custom VM Options 里调整-Xms1g -Xmx4g -XX:ReservedCodeCacheSize512mXmx 给到 4G 通常够用机器内存充足可以到 8G。注意 ReservedCodeCacheSize 也要调大JIT 编译后的代码缓存放不下的时候编译线程会反复工作这也是 CPU 高的一个隐蔽原因。诱因三插件太多或者某个插件有问题。禁用掉不用的插件尤其是那种会实时扫描代码、实时连远程服务的插件。判断方法很简单启动时用安全模式如果安全模式下流畅那就是插件的问题一个个启用来定位。诱因四实时代码检查过重。Settings 里的 Inspections 可以整体降低检查级别或者关掉那些开销大的检查项。另外在编辑大文件的时候可以临时开启 Power Save Mode它会关闭代码检查和自动补全虽然少了些便利但 CPU 压力会明显下降。诱因五自动编译和热部署。Spring Boot 项目的自动重启、前端的热更新这些在文件频繁变动的时候会反复触发编译。如果项目本身编译就慢可以把自动编译关掉改成手动触发。诱因六杀毒软件或文件同步工具在扫描项目目录。这个最容易漏表现是 IDEA 的工作线程其实不忙但整个机器 CPU 高。可以在杀毒软件里把项目目录和 IDEA 的缓存目录加入排除列表效果往往出乎意料地好。8. 常见问题速查与踩过的坑8.1 问题速查表现象最可能的原因优先排查动作TPS 上不去但 CPU 不高等锁、等连接、等外部接口看线程栈、连接池指标、下游耗时RT 曲线周期性抖动GC 停顿或缓存集中失效看 GC 日志频率、缓存过期策略压测跑一会儿后大量超时连接泄漏或句柄耗尽看 CLOSE_WAIT 数量、ulimit 配置平均 RT 正常但 P99 很高少量慢请求拖尾查慢查询日志、看单请求的耗时分布服务运行几天后 OOM内存泄漏两次堆快照对比按 retained size 排序线程数持续增长线程池无界队列检查线程池构造参数和拒绝策略数据库连接偶尔失败连接池总数超 max_connections核对应用实例数与池大小的乘积索引建了但没生效隐式转换或函数包裹看 EXPLAIN 的 key 和 rows 列8.2 几条只有踩过才记得住的经验第一条压测报告一定要看错误类型不能只看错误率数字。错误率 0.5% 听起来不高但如果这 0.5% 全是数据库连接超时那说明系统已经处在崩溃边缘了只是因为压测时间短还没全面爆发。第二条调优要一次只改一个变量。我见过有人一次改了堆大小、连接池、线程池、缓存策略四样结果性能提升了但根本不知道是哪一项起了作用下次遇到类似问题还是不会。一次一个变量记录下来形成自己的调优经验库这才是长期收益。第三条改完一定要回归。把压测脚本原封不动再跑一遍确认指标真的变好了。有时候你以为的优化实际只是被其他因素掩盖了比如机器当时恰好负载低。没有同条件对比任何结论都不牢靠。第四条监控要压在测之前。出了问题再去加监控往往已经错过了现场。压测开始前就把应用指标、JVM 指标、数据库指标、系统指标全部接上压测过程中实时看着出现问题的那一瞬间数据全在复盘效率完全不一样。第五条别忽视日志本身的性能开销。日志级别开到 DEBUG 在高并发下的开销非常可观字符串拼接、对象序列化、磁盘 IO加起来能吃掉相当一部分 TPS。压测的时候如果发现性能比预期差去确认一下日志级别和异步日志配置有时候答案就这么简单。我自己的习惯是每做完一次调优就在文档里留一条记录压测条件是什么、观察到什么现象、做了什么改动、改完指标变成多少。几年下来这份记录成了我最值钱的东西遇到新问题的时候翻一翻类似的现象基本都能找到方向。性能这活儿没什么一步登天的窍门靠的就是一次次把数据和动作对应起来慢慢形成直觉。
返回列表