ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java性能优化实战:测量、定位、优化三步走

Java性能优化实战:测量、定位、优化三步走

1. 性能优化的第一原则:让数据说话,别让直觉背锅

Java性能优化这个题目,看起来是个技术活,但说到底它是个方法论问题。我见过太多同行,一听到"接口慢""CPU高"就立刻打开代码,凭着直觉把循环改成并行、把HashMap换成ConcurrentHashMap,折腾一整天,指标纹丝不动,甚至更差了。这种"猜测驱动优化"是性能优化里最大的坑,没有之一。

性能优化的正确顺序永远是:测量 → 定位 → 优化 → 再测量。不管你是刚接手一个老系统的新人,还是已经写了七八年Java的老手,只要跳过第一步直接开改,就一定会走弯路。这篇文章就是要跟你聊聊,我在这条路上踩过的坑、总结出来的实战技巧,以及几个值得反复咀嚼的案例。文章会覆盖从代码细节、并发模型、内存管理到线上排查的完整链路,适合正在为系统性能发愁的Java工程师,也适合刚入门不久、想建立正确优化思路的后端开发者。

先记住一句话:没有数据的优化都是耍流氓。你想优化接口,就得先知道慢在哪里。是想说"整个接口平均响应500ms",还是"P99响应时间到了3秒"?是数据库查询占了大头,还是在等待外部接口返回?是线程池打满了在排队,还是JVM在做Full GC?这些问题的答案决定了你该往哪个方向努力。

我个人的习惯是,优化前先回答三个问题:可观测性做没做好?能不能拿到每个环节的耗时数据?压测环境跟生产环境像不像?如果这三点都没着落,那就先别急着"优化",先把监控补齐再说。实测下来,一个接口慢,90%的场景靠链路追踪和性能剖析工具就能一眼定位,根本不用猜。

1.1 性能优化最容易踩的坑:凭感觉动手

举一个很典型的例子。有一次我给某团队看一段业务代码,里面有个循环,每次循环都做了字符串拼接。代码大概长这样:

String result = ""; for (Order order : orderList) { result = result + order.getId() + ","; }

很多人的第一反应是:"这还不简单?换成StringBuilder。"然后改完一看,接口从1200ms变成了1150ms,提升微乎其微。因为在这个场景里,orderList可能只有几十条,字符串拼接根本就不是瓶颈。真正慢的是循环里调用的那个getOrderInfo()方法,它在底层逐个查了数据库。

这个例子说明什么?性能优化的第一步不是动手改代码,而是先搞清楚瓶颈到底在哪个环节。你盯着一段"看起来低效"的代码去优化,很可能是在给系统美容,而不是在做性能手术。

正确姿势是按照调用链逐段加耗时统计,或者用性能剖析工具去看CPU都在忙什么、线程都在等什么。搞清楚了再动手,一次改动能看到一次真实的收益,这种正反馈才是性能优化能持续做下去的动力。

1.2 先测量后优化的工具链:JMH、async-profiler、Arthas

测量工具我常用的有三类,各有所长:

JMH用于微基准测试,也就是测单个方法、单段逻辑的性能。比如你想比较两种序列化方式的吞吐,或者想知道HashMap的初始容量设置多大更划算,用JMH能得到可信数据。它的关键优势是解决了JVM的预热、死代码消除、黑编译等问题,直接写main方法来测是不准的。

@Benchmark @BenchmarkMode(Mode.Throughput) @Fork(value = 1, warmups = 1) public void testConcatWithStringBuilder(Blackhole blackhole) { // 待测逻辑 }

async-profiler是采样型性能分析工具,最擅长的是生成火焰图。线上CPU飙高的时候,用它抓一张火焰图,哪个方法占用了最多CPU一目了然,不需要埋点,开销很小,非常适合生产环境。

Arthas是我在线上排查问题时的首选。它能够动态查看方法调用耗时、查看线程栈、反编译线上代码,甚至能够直接在线执行一些诊断命令。很多"偶发超时""CPU忽高忽低"的问题,靠Arthas就能在几分钟内缩小范围。

这三件套配合起来,测量层的拼图基本就齐了:JMH负责实验室数据,async-profiler负责CPU热度,Arthas负责线上问题诊断。后面讲案例的时候我会再详细展开怎么用。

2. 代码层面的性能陷阱:高频小套路也要讲方法

很多人以为代码层面的优化是"雕虫小技",不如架构调优来得高级。但我的经验是,很多性能问题恰恰是从一行不起眼的代码开始发酵的。尤其在业务系统里,那些被反复调用的基础方法、工具类、公共组件,一旦写得不讲究,量变引发质变,整体性能就会很难看。

这部分的优化逻辑很简单:把高频路径上的代码写干净,把无意义的开销消灭掉。具体从字符串处理、集合使用、日志和异常三个高频场景说起。

2.1 字符串处理:StringBuilder、正则与split的代价

字符串是Java里最常用的对象之一,也是性能问题的高发区。最常见的问题有三个。

第一个是循环里做字符串拼接。Java编译器虽然会把+优化为StringBuilder,但每次循环都会新建一个StringBuilder对象,循环次数多的时候,对象分配和垃圾回收的开销就上来了。正确做法是在循环外声明一个StringBuilder,循环内复用。

第二个是正则表达式。String.matches()每次调用都会编译一次正则,这个开销远比你想象的大。如果同一个正则要在循环里匹配1000次,那就应该预编译成Pattern对象,然后复用Matcher。我在实际项目里见过一个接口,光是正则编译就占了CPU的15%。

第三个是String.split()。它在内部使用了正则表达式,对于简单的分隔符,比如逗号、竖线,其实有更轻量的实现方式。比如自己遍历字符,或者用第三方库里专门优化的split。不过这块收益通常没那么夸张,除非确实测出来是瓶颈,否则不用太纠结。

还有一个容易被忽略的点:字符串对象的创建频率。每次调toString()、每次做JSON序列化,都会产生大量短命对象,给年轻代带来压力。像下面这种写法,在高频方法里就要避免:

// 不推荐:每次都创建新对象 String msg = "user:" + userId + "|order:" + orderId; // 推荐:只有在确实需要时才拼接 if (log.isDebugEnabled()) { String msg = "user:" + userId + "|order:" + orderId; log.debug(msg); }

这些小技巧在教科书里都有,但真正在工程里能坚持写对的人不多。我们优化的目标不是把每行代码都改到性能最优,而是把高频路径上的代码改到足够干净。

2.2 集合选型:初始容量、遍历方式与数据结构选择

集合用得好不好,直接决定了内存占用和访问效率。这里不谈基本原理,只说几个实战中容易踩的问题。

HashMap的初始容量别懒省事。很多人写new HashMap<>(),然后在循环里put几百上千条数据。HashMap默认容量是16,扩容阈值是0.75,意味着插入12个元素就会引发一次扩容,扩容要重新计算hash、搬移数组,次数多了开销很明显。如果你知道大概的数据量,直接指定初始容量。比如要放1000条数据,用new HashMap<>(1344)(1000/0.75向上取整),可以避免扩容。一个简单的经验值是:目标容量 / 0.75 + 1。

LinkedList不是不好,但要分清场景。它的随机访问是O(n)的,如果代码里经常调用get(index),那性能会非常难看。反过来,如果你是频繁地在头部插入删除,那ArrayList反而吃亏。很多人无脑选ArrayList,或者无脑选LinkedList,都是不对的,要按访问模式来定。

遍历方式也别轻视。在Map上遍历,用entrySet()要比分别用keySet()再get()快,因为减少了又一次hash查找。这在数据量大的时候差距很明显。另外,能用for-each就用for-each,避免手动iterator()时多一次方法调用(当然现代JIT会把它优化掉,但规范一点总没错)。

还有一点很多人没意识到:不要用keySet()去交换Map的键和值,除非你真的需要。因为这意味着把整个Map的内容重新散列到新Map里,内存和时间都翻倍。如果只是需要根据value找key,考虑维护一个反向映射,或者用现成的双向Map组件。

2.3 日志与异常:业务代码里沉默的吞性能大户

日志和异常这两个东西,对性能的影响是"润物细无声"的。它们不会让系统立刻崩掉,但会把吞吐拉低一截。

日志方面,最常见的浪费是字符串拼接发生在日志级别判断之前。比如:

log.info("order={}, user={}, items={}", order.getId(), user.getName(), getItemsDetail(order));

即使日志级别是WARN,getItemsDetail(order)这个方法的调用也会执行,如果这个方法里做了数据库查询或者复杂计算,那这个"写日志"的操作实际上变成了一次隐蔽的脏活。正确做法是用参数化日志,让日志框架自己判断,或者先用isDebugEnabled()判断再拼参数。

更大的坑是在高频循环里打日志。哪怕日志框架再快,高频IO的消耗也会吃掉性能。我之前排查过一个系统,明明业务很简单,CPU却居高不下,最后发现是一个聚合任务每秒在INFO级别输出几百条日志,每条日志还要做JSON序列化。把日志级别调高、把无用的日志删掉之后,CPU直接降了40%。这个调优成本几乎是零,收益却大得吓人。

异常方面,创建异常对象是一个非常重的操作,因为要抓取完整线程栈。很多代码喜欢用异常来做流程控制,比如:

try { return userMap.get(userId); } catch (NullPointerException e) { return defaultUser; }

这既丑陋又低效。正确的做法是先判断containsKey(),或者直接用getOrDefault()。还有,在循环里抛异常再捕获,是灾难级的写法,遇到这种情况一定要重构。异常的定位应该是"真正的意外情况",不是"常规逻辑分支"。

3. 并发、内存与IO:三大核心维度的工程调优

代码层面的优化是微观层面,真正能对系统性能产生质变影响的,是并发设计、内存管理和数据访问这三大维度。这些地方的优化往往需要结合业务场景来做判断,没有放之四海而皆准的参数,但有一些共通的思路和底线。

3.1 线程池参数不是抄来的,是算出来的

线程池是Java并发的基础设施,但我发现很多团队线程池参数是"抄"来的,要么抄网上的默认配置,要么抄别的项目的。这其实是个隐患。不同业务系统的IO密集度和CPU密集度完全不同,硬套模板很容易出问题。

线程池设计的核心就一句话:线程数应该跟任务的阻塞特性匹配。计算密集型任务,线程数可以设为CPU核心数+1,因为线程基本不等待,多了反而增加上下文切换。IO密集型任务,线程可以多一点,因为大部分时间都在等IO(网络、数据库、文件),并发度可以提高。经验公式是:

// CPU密集型 corePoolSize = CPU核数 + 1 // IO密集型 corePoolSize = CPU核数 * (1 + 等待时间 / 计算时间)

这个等待时间和计算时间的比例不好精确测量,但可以估算。比如一个任务里,数据库查询占了80%,纯计算只占20%,那线程数大概可以给到CPU核数的5倍左右。眼尖的读者会发现,IO密集型的线程数可以远大于核数,这不是错误,因为等待期间的线程不占CPU。

更重要的两个参数是队列类型和拒绝策略。很多生产事故都出在这两个参数上。

ThreadPoolExecutor的执行流程是:先跑核心线程,核心线程满了就进队列,队列满了才开非核心线程,都满了走拒绝策略。如果队列是LinkedBlockingQueue且不设上限,那非核心线程永远没有机会启动,任务会无限积压。看起来"系统很稳",实际上延迟越来越高,最终把内存也拖垮。

我推荐的组合是:有界队列 + 合适的拒绝策略。有界队列的容量根据业务能接受的积压量来定,拒绝策略优先用CallerRunsPolicy,它不会丢弃任务,而是让提交任务的线程自己执行,起到背压作用,让上游感知到系统已经过载。AbortPolicy直接抛异常,DiscardPolicy会静默丢任务,都要非常谨慎。

线程池大小不是越大越好,我的体会是宁可稍小一点让任务排队,也不要开太大导致上下文切换和资源争抢。线程池的调整一定要配合压测,不能光靠理论推算。

3.2 锁竞争与并发数据结构:从synchronized到无锁化

并发场景下的性能优化,本质上是减少线程之间的竞争。锁竞争是性能杀手,当多个线程争抢同一个锁时,线程会经历阻塞、唤醒、上下文切换,每一次都是昂贵的操作。

Java的synchronized在JDK 6之后做了大量优化,从偏向锁到轻量级锁再到重量级锁,很多情况下性能是不差的。但它在高竞争场景下仍然会有瓶颈。替代方案有几种,各有适用场景:

**读写锁ReentrantReadWriteLock**适用于读多写少的场景。比如一个配置缓存,大部分请求是读,偶尔更新。用读写锁能让多个读线程并行访问,只有写线程才互斥。

**StampedLock**是更高性能的读写锁变体,它支持乐观读,读线程完全不加锁,只在写线程发生时短暂冲突。对付那种"读非常多、写极少"的场景非常有效。不过它不可重入,使用时要小心,容易踩坑。

并发容器方面,ConcurrentHashMap是我见过用得最多的选择,它在读操作上完全不阻塞,写操作只锁对应的桶,并发度很高。CopyOnWriteArrayList适合读多写极少的场景,写时复制,读时无锁,但每次写都复制整个数组,代价不小。ConcurrentLinkedQueue是无锁队列,适合生产者消费者模型,但不如LinkedBlockingQueue容易控制边界。

还有一个经验是:尽量避免使用全局锁,把锁的粒度降到最小。能用分段锁就不用单一锁,能只在关键路径上加锁就不要把整个方法锁住。锁的范围越小,线程等待的概率越低,性能自然就上去了。如果在业务代码里发现synchronized整个方法,大概率是可以优化的。

无锁化是个更高级的思路,通过CAS原子操作或者基于LongAdder等新工具来替代传统锁。这些方案读多写多的情况下反而可能更高效,但实现的复杂度会高不少。我建议先从降低锁竞争做起,无锁化留到确有必要的时候再做。

3.3 对象分配与GC调优:别一上来就动JVM参数

提到性能优化,很多人第一反应是调JVM堆大小、换GC算法。但说实话,GC调优应该是性能优化的最后一步,而不是第一步。GC本身只是清理垃圾对象的工具,如果业务代码本身创建了大量多余对象,再好的GC也扛不住。

我见过一个项目,并发上不去,监控显示Full GC频繁,于是团队把堆从4G调到了16G,结果GC反而更慢了。为什么?堆变大,垃圾对象更多,每次GC扫描的时间更长,停顿也更久。真正的解法是减少对象的创建。

减少对象分配是有套路的:

一是复用对象,而不是重复创建。比如日期格式化、加密算法,能做成静态常量就做成静态常量。SimpleDateFormat本身线程不安全,但可以用ThreadLocal包装后复用,比每次new一个快得多。

二是避免自动装箱。在高频循环里,int转Integer、long转Long都会产生新对象。能用基本类型就不要用包装类型,能用原始数组就不要用List。这一点在数据处理量大的场景特别明显。

三是善用不可变对象与分段大对象。大对象直接进入老年代,一旦积累下来就很难被回收。如果一个订单对象塞进了大量字段,很多字段其实用不上,那就要考虑精简,或者用更扁平的数据结构。

顺便说一句,堆内存里的对象越多,GC的吞吐量就越低,这是个数学问题,不是调优技巧能解决的。

真正需要手动调GC参数的时候,也请遵循一个原则:每个参数都改一次、测一次,观察吞吐量和停顿时间的变化。G1是当前主流的默认垃圾回收器,适合大堆、停顿时间可控的场景。CMS已经过时,不要去新项目里用。如果有低延迟需求,可以考虑ZGC,但ZGC在高吞吐场景下不一定占优,得实测。

从工程角度说,先做到对象分配克制、再谈GC调优,这个顺序是不能反的。否则你调了半天参数,内存压力还是被业务代码源源不断地制造出来,治标不治本。

3.4 数据访问与IO优化:连接池、SQL与网络超时的坑

数据访问和IO往往是系统性能的最终瓶颈,因为外部依赖的延迟远比本地计算高。数据库一次查询要几毫秒到几十毫秒,一次RPC调用要几十毫秒,一次磁盘IO还要数毫秒,这些时间和CPU纳秒级的操作完全不在一个数量级。

先说数据库访问。最常见的问题是N+1查询:先查出一个列表,然后循环里再逐条查详情。这个问题的解法很简单,用IN查询一次性取出所有详情,或者用LEFT JOIN连表查询。但JOIN也不是万能的,如果两边数据量都很大,JOIN会非常耗时。我习惯的做法是:数据量小,用JOIN;数据量大且分页,分别查询再内存组装。

SQL索引失效是另一个高频问题。比如给varchar字段加索引,查询时却传了数字类型,MySQL会做隐式转换,索引直接失效。再比如在索引列上做函数运算、模糊查询前置通配符,都会让索引变成摆设。这部分建议用EXPLAIN逐个验证执行计划,别指望数据库自己优化。

再讲数据库连接池。连接池大小不是越大越好。连接数太多,数据库服务端要维护大量连接,上下文切换加剧;连接数太少,请求排队等待连接,吞吐下降。一个经验值是:连接数 = ((核心线程数 * 2) + 有效磁盘数) 左右,但这跟具体数据库规格强相关,要实测。用HikariCP的话,官方文档推荐的核心思想是:连接池宁可小一点,也不要大,因为连接空闲是资源的浪费。

网络调用的超时设置是被小看的问题。很多接口没有设置超时时间,或者设置得非常大,比如60秒。下游一旦变慢,上游线程全部卡在等待上,线程池被打满,整个应用失去响应,这就是典型的线程耗尽事故。我的建议是:外部依赖调用的超时时间要分级,内网普通接口设200ms,慢查询接口设1秒,外部第三方接口最多3秒,并且要配好重试策略(幂等前提下)。这类超时设置的优化往往比加服务器更有效。

4. 真实案例复盘:三个典型性能攻坚战

理论讲得再多,不如看一个完整案例来得直观。下面这三个案例是不同维度的性能问题,都具有一定的代表性,分别是SQL与代码层面的查询优化、线程池参数错误导致的故障、以及GC停顿引发的偶发超时。他们都有一个共同特征:先通过数据定位根因,再针对性优化,最终用压测验证结果。

4.1 案例一:某订单查询接口从3秒到300毫秒

这个接口的功能很简单:根据用户ID查询用户的订单列表,附带订单的商品明细。接口最初版本响应时间大约在2.8到3.2秒之间,业务方已经忍无可忍。接手后的第一件事不是看代码,而是看调用链。

我用Arthas的trace命令对接口做链路追踪,很快发现耗时分布极其不均匀:其中约2.4秒花在数据库查询上,剩下的400多毫秒花在结果组装上。然后继续看SQL执行情况,发现两条问题:

第一,查询订单主表时用的条件列user_id没有索引,导致全表扫描。看表结构发现这个表已经有三百万行数据,全表扫描几乎要把整个表读一遍。解决方法是给user_id加普通索引,这条SQL从800ms降到了30ms。

第二,查询商品明细时使用了循环内逐条查询,也就是经典的N+1问题。一次查询10个订单,就要执行10次商品明细查询,也就是10次数据库往返。解决方法是改成一次IN查询批量取出,然后内存中组装。这条改动把明细查询总耗时从1.5秒降到了150ms。

接下来看那400ms的结果组装,发现代码里做了大量的对象转换,每个订单对象要转成DTO,再转成VO,循环里还调用了一次远程配置服务。我把远程配置结果改成了本地缓存,对象转换也减少了中间层,400ms降到了100ms左右。

最后压测结果:接口平均响应时间280ms,P99在450ms左右,整体达到了预期目标。这个案例看起来不难,但核心思路值得记住:先用数据定位哪个环节慢,再针对性地改,不要一开始就重写整个接口。每一步改动都有实测数据支撑,改完立刻验证,基本不会走弯路。

4.2 案例二:批量任务处理中线程池参数不当导致系统假死

某个批处理系统负责每天跑数据同步,把外部系统的数据拉回来处理后写入数据库。刚上线的时候一切正常,跑了几天后开始出现"系统假死"的报警,CPU不高、内存正常,但任务就是不推进。

接到排查任务后用Arthas的thread命令查看线程栈,发现大量工作线程都阻塞在一个LinkedBlockingQueue.take()调用上,说明线程池没有足够的任务可以处理,但外部显示数据积压严重。进一步检查线程池配置,发现是这样写的:

ThreadPoolExecutor executor = new ThreadPoolExecutor( 10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>() );

核心线程10个,最大线程10个,队列无上限。问题就出在这个无上限队列上。在数据量平峰时,10个线程够用,任务都进了队列,处理速度正常。但在数据量高峰期,任务源源不断进入队列,线程池永远不会创建更多线程(因为队列没满,没到触发扩容的条件),所有任务都在排队,10个线程像蜗牛一样处理。看起来"系统假死",其实是处理能力被队列容量给卡死了。

修复方案是:把无界队列改为有界队列,容量设为5000,线程数提高到30(数据同步是典型的IO密集型任务,等待时间远大于计算时间),拒绝策略使用CallerRunsPolicy。参数调整后用历史数据重放压测,处理速度提升了3倍,积压的数据在半小时内清空。

这个案例给出的教训很直白:线程池的队列容量不是越大越好,无界队列会掩盖系统过载的事实。有界队列虽然可能触发拒绝策略,但配合CallerRunsPolicy反而能形成天然的背压机制,让任务提交方减缓节奏,避免系统被拖垮。

4.3 案例三:接口偶发超时与GC停顿的关联排查

这是一个线上偶发超时问题,很让人头疼。某个核心接口平时响应只要50ms,但从监控看,每天有几十次请求会超时,超时时间集中在几百毫秒到一两秒不等。由于发生频率不高,排查难度很大。

我先用Arthas的trace分析了接口的调用链,发现正常路径上各个环节耗时都很稳定,唯独偶尔会有一次异常长的停顿。再配合dashboard命令观察JVM状态,发现在停顿时间点附近,出现了Full GC的标记,停顿时间长达800ms到1.2秒。这下基本定位到了:偶发超时不是业务逻辑变慢,而是GC停顿把整个应用冻结了。

继续分析GC日志,发现老年代占用率每次Full GC前都很高,而且有不少大对象直接进入老年代。排查代码后发现,某次需求中引入的一个缓存组件,每次请求都会创建一个大数组(约5MB)用于临时存储,请求量一高,老年代很快就满了。

解决方式有三步:第一,把临时大数组改为池化复用,不再每次创建;第二,给缓存组件设置合理的过期策略,避免无效对象长期驻留;第三步,配合调整了G1的MaxGCPauseMillis参数,把目标停顿时间从默认的200ms改为100ms,并适当增大年轻代。修改后,Full GC频率从每十分钟一次降到每小时不到一次,偶发超时基本消失。

这个案例最有价值的地方在于:性能问题可以藏在任何一层。看起来是接口慢,实际是GC;看起来是GC,实际是业务代码无节制地创建大对象。排查过程必须一步一步来,从现象往下挖,直到找到最根本的源头。

5. 常见性能问题排查速查与避坑清单

这一节把平时工作中最常遇到的性能问题按"现象-排查思路-常用工具-典型原因"梳理成表,方便遇到问题时快速对照。这部分更像一张地图,帮你在面对问题时找到方向,然后深入到对应工具和技术细节里。

5.1 CPU飙升、内存上涨与线程阻塞排查速查

现象常用排查手段典型原因应对方向
CPU 使用率居高不下top+async-profiler火焰图死循环、正则高频执行、频繁Full GC、序列化开销大分析火焰图找热点方法,优化算法、增加缓存、减少对象创建
接口偶发超时Arthastrace+ GC日志Full GC停顿、外部依赖慢、线程池饱和检查GC日志,设置合理超时时间,观察线程池积压情况
线程阻塞严重Arthasthread+ jstack锁竞争、数据库连接池耗尽、下游服务不返回检查线程栈,优化锁粒度,调连接池容量,拆分外部调用
内存持续上涨jmap/Arthasheap dump内存泄漏、缓存无上限、大对象堆积分析堆转储,定位泄漏对象,增加缓存淘汰策略
吞吐量上不去压测工具观察QPS与TP99线程数过少、锁竞争激烈、数据库瓶颈结合压测调整线程池参数,优化SQL与索引,拆分热点数据

这张表不是万能的,但能帮你在陷入泥潭时快速恢复方向感。实际的排查过程往往是多个手段交替使用,比如先看CPU再抓线程栈,先看GC日志再找代码热点,一环扣一环。

我还想强调一个实用习惯:保存每次压测的基线数据。很多团队优化接口前后没有记录数据,改完不知道提升多少,下次再优化时又得从头测起。我会把每次优化的环境参数、压测并发数、平均响应时间、TP99、QPS等存成一个简单的表格或文档,长此以往就形成了一张性能演进地图,比任何优化文档都有说服力。

5.2 性能优化避坑清单:来自一线的经验沉淀

最后把这几年调优路上踩过的坑整理一下,有些是技术问题,有些是流程问题,但它们都实实在在地拖慢过我的进度。

别只关注平均值,要看P99和P999。平均值很容易被少数慢请求拉高,但P99才能反映真实用户体验。一个接口平均100ms但P99是800ms,问题依然严重,必须精确分析P99里发生了什么。

压测环境要尽量贴近生产。数据集大小、数据分布、并发模型都不同,压测结果可能完全不可信。最典型的是数据量少了几个数量级,什么慢SQL都测不出来,一上生产就原形毕露。

一次只改一个变量。性能优化的基本原则是控制变量。同时改了SQL和缓存和线程池,出了问题你根本不知道是哪一步引入的。哪怕再着急,也要一个改动一轮压测地来。

优化后必须回归测试。有些优化会改变业务行为,比如缓存过期策略调整可能导致数据不一致,线程池参数调整可能引发拒绝策略触发。性能与正确性永远是并行的,不能为了性能破坏业务逻辑。

不是所有代码都值得优化。对于一年只跑一次的任务,用不着为它抠性能。性能优化的ROI要算清楚:优化高频路径、核心接口、关键链路,比优化冷门边缘代码有价值得多。

我自己在做性能优化时的体会是,真正拉开工程师差距的不是会不会用某个工具,而是能不能在正确的时间优化正确的地方。这个判断力来自对业务的熟悉程度,也来自对性能原理的深入理解。希望这篇文章能帮你建立一套系统化的优化思路,下次再遇到性能问题,先测量、再定位、后动手,高效且扎实地解决问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进