☰
从P99 1.2秒到110毫秒:Java导出接口性能优化实战
2026/10/3 9:01:39 网站建设 项目流程

本来没打算这么快写第三篇。前两篇发完后,陆陆续续有读者问同一个问题:“你说的优化点我都知道,可真拿到自己那条慢接口上,还是不知道从哪下手。”这个问题背后的本质是:优化这事最怕的不是没有方案,而是找不到真正的瓶颈。这次我记录一个真实的导出接口优化过程,从接口P99 1.2秒压到110毫秒,中间没动架构、没换数据库、没用玄学参数,纯粹是把JVM、代码层和数据库侧的问题逐个排掉,最后做了一轮扎实的回归验证。这篇更适合那些每天维护老服务、被线上接口性能折腾、想知道“从哪开始排查”的Java后端同学。顺便说一句,这个系列写到第三篇,我最大的变化就是不再急着动代码了,先让证据说话。

1. 从一次导出接口变慢开始:问题表象与排查链路

1.1 表象不在CPU:请求都堵在什么地方了

这次出问题的接口是业务侧非常常见的数据导出:根据筛选条件把最多两万多行数据导出成Excel。之前数据量只有几千行时,接口稳定在120ms左右,所以也没有人特意优化。直到业务侧把查询范围放开到两万行以上,P99从几百毫秒一路飙到1.2秒,部分请求直接超过3秒,再往上走就开始超时报错。

当时的直觉是“数据量变大,SQL变慢了”。可一查监控,数据库慢查询日志里确实有两条SQL耗时超过了1.8秒,但CPU和内存占用都不到预警线,这反而是最让人头疼的情况。没有明显单点,意味着问题大概率是多个因素叠加在一起。我习惯把这种状态叫作“接口假性卡顿”:你看着CPU不高,线程池却可能已经塞满;看着内存不高,GC却可能频繁得可怕。

先把症状记录下来:

  • 导出接口的P99持续走高,但平均响应时间只有300ms左右,说明有大量请求在长尾里。
  • 服务端CPU使用率只有20%~30%,没有打满。
  • GC日志里Minor GC次数从每天几十次涨到每分钟几十次,Young区回收后内存并没有明显回落。
  • 数据库端只有部分查询慢,但通过索引命中的分析发现,有不少慢SQL其实走了全表扫描。

这四条摆在一起,基本可以排除“CPU算力不够”和“数据库负载真的很高”这两个方向,更可能的原因是:JVM频繁GC导致STW,业务线程被阻塞;部分线程在等待数据库连接或锁,整体吞吐下降,接口长尾被拉大。这一步没法直接靠看代码判断,必须动手拿数据。

1.2 Arthas + jstat:把三个嫌疑点同时列出来

排查环境是JDK 8,我一般不会一上来就开VisualVM或者连线到生产环境做堆转储,太重了。首选还是Arthas和JDK自带的jstat,几行命令就能把线程状态、GC频次、方法耗时拉出来。

第一步,先在测试环境复现,然后执行arthas-boot启动Arthas,用dashboard看一下全局状态。重点不是看CPU,而是看线程数和等待状态分布:

$ arthas-boot $ dashboard

dashboard的统计里,线程总数、WAITING和TIMED_WAITING数量会直接暴露问题。当时看到的情况是:业务线程池里有大量线程处于WAITING状态,而且om.sun.management相关线程也频繁活动,说明JVM内部在做GC和内存管理。随后用jstat看一眼GC实时情况:

$ jstat -gcutil <pid> 1000 10

输出里的FGC(Full GC次数)、FGCT(Full GC耗时)、YGC(Young GC次数)会每分钟刷新。当时Young GC频率高得异常,而且每次回收之后Eden使用率依然快速回升,这通常是对象分配过快导致的,不是简单“堆不够大”。

接着用Arthas的trace命令去定位热点方法:

$ trace com.example.export.ExportServiceImpl exportData '#cost > 50'

只关心耗时超过50ms的方法调用,很快发现耗时集中在三块:

  1. ExcelBuilder.build里的字符串拼接与集合写入,占整体耗时的40%左右;
  2. 数据库查询的ResultSet遍历和DTO转换,占30%;
  3. synchronized同步块锁住的输出流写入,占20%。

这个分布很有意思:慢SQL只占了三分之一,另外三分之二是代码层和锁竞争。如果当时只盯着慢查询优化,最后顶多把P99从1.2秒压到800ms,不会有质变。这也是我为什么反复强调:排查阶段不要猜,要让工具告诉你答案。

1.3 先建立“瓶颈清单”再动手,避免越改越乱

很多人拿到慢接口的第一反应是“先加缓存”或者“把锁删掉”,但我建议先列一张瓶颈清单,把所有可疑项和对应验证方式写下来,哪怕有些项到最后没用上,也比盲目改强得多。当时我列出的候选瓶颈大概是这样的:

候选瓶颈验证方式最终判定
JVM堆内存分配不合理,Young区回收频繁jstat观察YGC/FGC、Survivor使用率确认
代码循环内字符串拼接产生垃圾对象Arthas的trace、生成堆快照分析对象大小确认
ArrayList批量写入反复扩容阅读代码 + 对象分配统计确认
共享输出流锁竞争严重线程栈采样,观察线程WAITING状态确认
SQL索引失效或扫描行数过大explain分析SQL执行计划确认
连接池大小与线程池不匹配观察数据库连接池活跃数、等待线程数确认

这张清单最后基本全用上了。它还有个额外作用:防止优化过程中“顺手改掉不该改的东西”。因为优化链路一旦拉长,人的记忆会不可靠,最后靠清单回归,逐项恢复或保留修改,能省很多返工。

2. JVM参数不是越大越好:G1调参与内存比例权衡

2.1 堆内存失衡放大了STW停顿

当时服务的JVM参数是典型的“裸奔状态”:

java -jar -Xms4g -Xmx4g export-service.jar

没有设置年轻代比例,也没有指定垃圾收集器,所以JDK8默认用的是Parallel Scavenge + Parallel Old。这种组合的特点是高吞吐导向,不是低延迟导向,一旦遇到对象分配量大、Young GC频繁的场景,停顿会非常明显。而导出接口恰恰是典型的“短期大量对象堆积”:读取两万行数据,每行都变成若干DTO、字符串,完整构建一个Excel文件,中间产生的临时对象比常规查询高出好几个数量级。

堆大小4G不算小,问题出在比例失衡。默认的新生代和老年代比例是-XX:NewRatio=2,新生代大概是1.3G左右,Eden区更大,但Survivor空间很小。对象分配的时候,如果Survivor放不下,就会直接晋升到老年代,老年代回收一旦启动,停顿时间和内存量直接挂钩。

从jstat里看到的现象也符合这个判断:Young GC频率高,老年代增长很快,虽然有Full GC,但频率还不算离谱,因为服务运行一段时间后老年代被堆满才触发。可每次Young GC都伴随大量对象晋升,这就不正常了——大部分临时对象应该在Survivor里被回收掉,而不是跑进老年代。

2.2 G1参数调整:不是把堆调大,而是让GC节奏可控

因为服务需要长期在线、对响应时间有要求的,我直接把垃圾收集器换成了G1。这里不是G1比Parallel GC更好,而是G1更符合“低延迟、可控STW”的场景。替换后的核心参数是:

java -jar -Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=4m -XX:InitiatingHeapOccupancyPercent=45 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log

解释一下几个关键选择:

  • -Xms和-Xmx保持一致:避免运行期动态扩容,动态扩容需要触发stop-the-world,对线上服务不友好。
  • -XX:G1HeapRegionSize=4m:G1把堆分成等大的Region,Region太小会导致Region数量过多,增大RSet维护开销;Region太大则让混合回收粒度变粗。4G堆和8G堆比较常用4m或8m,需要结合Region数量压测。
  • -XX:MaxGCPauseMillis=50:这是目标停顿,不是硬保证,G1会据此调整YG和Mixed GC的行为。
  • -XX:InitiatingHeapOccupancyPercent=45:老年代占比达到45%时启动并发标记周期。这个值默认也是45%,实际场景里需要观察Mixed GC触发频率再微调。

那段时间我特意留意了压缩指针的问题:Java堆小于32G时默认开启-XX:+UseCompressedOops,对象引用占4字节;如果因为调优把堆直接拉高到32G以上,引用会变成8字节,同量对象占用反而更多,GC扫描成本也会上升。所以不是“内存越大越好”,8G在导出一类服务上已经很舒适。

调完G1参数后,还需要配合代码层的对象分配优化,否则GC参数只能兜底,不能治本。这里有个容易被忽略的逻辑:G1的并发标记周期要CPU参与,如果分配速度太快,占满缓冲区,照样会退化成Full GC。所以JVM参数调优必须和对象分配优化一起做,单调参数大概率无效。

2.3 参数改完先看GC节奏,而不是直接看接口耗时

调参之后我先跑了三轮压测,每轮5分钟,然后打开gc.log观察三点:

  • Full GC是否明显减少;
  • Young GC的平均耗时是否稳定在几十毫秒内;
  • cause字段是不是经常出现Allocation Failure,这个出现太频繁意味着对象分配压力还是在。

从结果看,Full GC基本消失,Mixed GC偶尔触发,Young GC平均停顿在30ms左右。结合接口P99,已经从1.2s降到600ms左右,说明JVM侧的瓶颈解掉了大半。没有一步到位压到110ms,是因为代码层的四个热点还没处理,GC虽然不堵了,但线程池和锁还在消耗时间。

3. 代码层被忽略的三个瓶颈:字符串拼接、集合扩容、锁竞争

3.1 循环内的字符串拼接,正在悄悄制造海量临时对象

Arthas的trace很快把第一个热点指到了ExcelBuilder.build。打开代码,发现一个很典型的问题:在循环里用+拼接字符串。

String content = ""; for (Row row : rows) { content += row.getA() + "," + row.getB() + "," + row.getC() + "\n"; }

Java 8之后编译器会默认把+=转成StringBuilder.append(),但关键问题是:每次循环迭代都会new一个StringBuilder出来,而且这个StringBuilder的初始容量未必足够,循环次数一多,扩容、复制、临时对象全来了。两万行数据,就会创建两万多个临时对象,而且这些对象生命周期极短,正好是Young GC的“燃料”。

把这段改成显式StringBuilder之后,效果非常明显:

StringBuilder content = new StringBuilder(2_000_000); for (Row row : rows) { content.append(row.getA()).append(',').append(row.getB()).append(',').append(row.getC()).append('\n'); }

这里还顺手做了一件更关键的事:给StringBuilder一个合理的初始容量。两万行、每行大约80字节,估算下来1.6MB到2MB足够,直接给2_000_000的初始容量,可以避免几乎所有的扩容复制。如果你对数据行数没把握,也可以在循环外根据rows.size() * 96估算一个初值,宁可多给一点,也不要它在循环里反复扩容。

类似的手法在DTO转换时也很常见。比如循环里反复调用new String、String.format,这些都是对象分配大户。排查这类问题时,可以盯住两点:一个是在循环里有没有new对象,另一个是对象是否只在当前迭代活一次。能用基本类型就用基本类型,能重用builder就重用builder,不要在循环体内创建生命周期很短的“大家伙”。

3.2 ArrayList扩容与Map遍历:看起来无所谓,量大就成问题

第二个热点是数据从数据库取出后,批量塞到ArrayList里,再遍历转成DTO列表。原代码是:

List<DTO> dtoList = new ArrayList<>(); for (Entity entity : entityList) { dtoList.add(convert(entity)); }

这段代码问题不大,但有个隐蔽点:ArrayList默认容量是10,当插入元素超过容量时,会按照原来容量的一半进行扩容。两万行数据意味着从10到20000之间要经历十几次扩容,每一次扩容都是一次Arrays.copyOf,把原来数组复制到新数组。数据量小的时候无所谓,数据量一大,这部分就是纯浪费。

我之前测过:同样往ArrayList里塞两万个对象,不预设容量的耗时比预设容量高出约20%。优化方法非常简单:

List<DTO> dtoList = new ArrayList<>(entityList.size());

这条规则同样适用于HashMap。大家常背“HashMap默认容量16,负载因子0.75”,但很少有人意识到:当插入数量超过12个时,HashMap就会扩容到32;再超过24时又扩容到64。如果你提前知道要放进多少个元素,直接new HashMap<>(expectedSize)就能避免大量不必要的rehash和链表转树判断。

还有一个经常被忽略的点:遍历Map时,如果只需要key或者value,尽量用keySet或values,不要entrySet之后取后者再getValue。entrySet本身没问题,但如果在循环里对每个entry都做一次map.get(entry.getKey()),等于把一半的时间浪费在重复查找上。导出DTO转换里我看到过这种写法,每次循环都二次查询Map,数据量一大也够受的。

3.3 锁竞争改造:导出线程池与输出缓冲

第三个热点是synchronized锁住了输出流写入。整体逻辑是这样的:多个线程并行处理Excel的分片数据,然后共享一个OutputStream做最终写入。为了线程安全,原代码直接在外面加了synchronized:

synchronized (outputStream) { outputStream.write(bytes); }

这在数据量小时看不出问题,可一旦数据量大,两个线程同时写完分片后要抢锁,写数据就是串行化。从Arthas的thread命令看到大量WAITING状态,原因就在这里。仔细想想,这个锁的粒度太大了:写入之前的分片计算完全可以在锁外做,真正需要同步的只是“把计算完的字节写入目标流”这一小段。

优化方式是把“每个线程独立构建自己的分片缓冲”,最后统一合并到输出:

ExecutorService executor = Executors.newFixedThreadPool(4); List<Future<byte[]>> futures = new ArrayList<>(partCount); for (Part part : parts) { futures.add(executor.submit(() -> buildPartBytes(part))); } try (ByteArrayOutputStream merged = new ByteArrayOutputStream()) { for (Future<byte[]> future : futures) { merged.write(future.get()); } }

这样旁路掉了共享输出流的锁竞争,每个线程只操作自己的ByteArrayOutputStream,最后归并。另一种更简单粗暴的方案是给每个分片一个独立的写入文件,最后合并文件,但代码改动量偏大,在线服务里不划算。

这里也顺带提一下AtomicLong和LongAdder的选择。如果只是做计数器,高并发下AtomicLong的CAS竞争很严重,JDK8里的LongAdder通过分段计数降低竞争,适合统计等待时间、成功次数这类场景。导出生成过程中的统计项我用LongAdder替换了AtomicLong,虽然不是主要瓶颈,但属于“顺手优化”。

4. 数据库侧配套优化:慢SQL、索引失效与连接池参数

4.1 explain查出来的三个SQL问题

导出接口里最核心的一条SQL是从主表关联若干张附表,取出筛选数据。慢日志里耗时超过1.8秒,用explain看执行计划,暴露了三个问题:

第一,驱动表选反了。EXPLAIN结果里第一行是type: ALL,全表扫描。主表筛选条件能过滤掉大部分数据,却变成被驱动表,实际上是另一张小表在前,导致主表全扫。

第二,关联字段存在隐式类型转换。附表关联字段是varchar,主表关联字段是bigint,两者关联时MySQL需要对字符串列做转换才能比较,索引直接失效。这一步是慢SQL最常见的隐形杀手:明明字段上建了索引,但类型不对,索引就是用不上。

第三,LIKE '%xxx%'前置通配符。导出界面的关键字搜索用了like,而且通配符在开头,这类查询基本只能扫整列。如果确实需要模糊匹配,一是换成前缀通配符,二是考虑走搜索引擎或全文索引,而不是在业务表上硬扛。

当时的SQL简化后长这样:

SELECT a.*, b.name, c.status FROM main_table a LEFT JOIN sub_table b ON a.sub_id = b.id LEFT JOIN type_table c ON c.id = a.type_id WHERE a.user_id = ? AND a.title LIKE '%keyword%' ORDER BY a.create_time DESC

改法是分两步:

SELECT a.*, b.name, c.status FROM ( SELECT id FROM main_table WHERE user_id = ? AND title LIKE 'keyword%' ORDER BY create_time DESC LIMIT ? ) t JOIN main_table a ON a.id = t.id LEFT JOIN sub_table b ON a.sub_id = b.id LEFT JOIN type_table c ON c.id = a.type_id ORDER BY a.create_time DESC;

核心思想是“先缩小主表范围,再关联取数”,避免一开始就带着大字段做全表关联。这里也顺手把无意义的SELECT *换成了明确需要的列,减少网络传包和对象转换成本。执行计划从ALL变成range,扫描行数从两万多降到几百行,SQL耗时从1.8秒降到120ms左右。

4.2 批量提交:避免一次事务写两万条

导出功能里含有一个“生成本次导出文件记录”的操作,原来每写一条记录就提交一次,两万行相当于两万次事务提交。数据库的每次事务提交都有fsync成本,网络往返更不用提。优化后改成每500条批量提交一次:

Connection conn = dataSource.getConnection(); try (PreparedStatement ps = conn.prepareStatement(sql)) { int batchCount = 0; for (DataItem item : list) { ps.setLong(1, item.getId()); ... ps.addBatch(); if (++batchCount % 500 == 0) { ps.executeBatch(); } } ps.executeBatch(); }

这里注意,executeBatch()并不是真正的单次提交,要配合conn.setAutoCommit(false)和手动conn.commit()才能把批量操作合并成少次数事务。如果连接是从HikariCP拿的,用完还要记得恢复autoCommit,否则连接归还连接池后状态是错的。

很多人调优到这个阶段就停了,因为接口响应时间已经明显下降。但其实还有一个更隐蔽的坑:线程池和数据库连接池存在数量错配。

4.3 线程池与连接池的匹配关系

导出接口使用了业务线程池,核心线程数配置的是50,而HikariCP的maximumPoolSize只有10。线程池里的线程每来一个请求就去连接池拿连接,很快10个连接全被占满,剩下的40个线程只能在连接池门口排队等待。

当时用HikariCP的监控看到active达到10,pending一直有值,数据库连接池仿佛成了全系统的“共享阻塞队列”。优化并不是简单把连接池调大,因为数据库的max_connections也有限。正确做法是让线程池和连接池互相匹配:如果业务是IO密集型,线程池核心数可以略多,但连接池不能随之无限扩大。我给了一个比较保守的方案:

spring: datasource: hikari: maximum-pool-size: 15 minimum-idle: 5 connection-timeout: 3000 pool-name: ExportHikariPool

线程池核心线程数从50降到20,因为真正需要并发写的不是50个线程,而是有限的几个分片任务。这里没有固定公式,完全靠压测:把连接池从10调到15,线程池从50降到20,观察接口P99和连接等待时间。结果不仅没变慢,反而因为减少了线程上下文切换和排队,P99又降了一些。

5. 回归验证:压测、预热与监控缺一不可

5.1 压测不能只看平均值,还要看长尾和GC

所有代码和参数改完后,进入回归验证阶段。优化前我跑了一轮压测作为基线,优化后再跑一轮,条件保持一致:并发100,持续5分钟,数据量两万行。

指标优化前优化后
P991200ms110ms
P95980ms85ms
平均响应300ms52ms
Young GC次数/分钟60次+8次
Full GC次数有,且频率上升基本为0
数据库慢查询次数15+0

这里特别提醒一句:Java服务压测必须预热。JIT编译需要时间,第一次跑压测时方法还是解释执行,性能数据很难看。正确流程是先跑一两分钟“预请求”,让热点方法触发JIT编译,然后再正式压测。我当时就是先并发跑了几轮,等Arthas里trace显示的方法耗时稳定后再记录数据。如果不预热,优化前和优化后的对比根本没有参考价值,甚至会把JIT编译优化当成“性能提升了”。

5.2 GC日志和Arthas的二次确认:防止“假优化”

压测通过后,我没有直接上线,而是用gc.log和Arthas做了二次确认。打开日志,关注三个字段:

  • Young GC耗时是否稳定;
  • Pause Full是否消失;
  • 是否有Allocation Failure频繁触发。

同时用Arthas的dashboard确认线程分布:WAITING状态的线程从优化前的几十个降到了个位数,CPU也重新变得平滑。如果压测数据好看但GC日志仍然异常,我会很怀疑优化是否有效果。因为接口可能只是“碰巧跑得快”,运行一两个小时后老年代涨上来,Full GC又把性能打回原形。

5.3 这次优化沉淀下来的操作清单

整个案例做完后,我把流程整理成了自己的排查模板,也推荐给团队里刚接触性能优化的同学:

  1. 先看监控:CPU、内存、GC、慢SQL、线程池,一个都不能少。
  2. 再用Arthas或JFR定位热点方法,列出瓶颈清单,不猜。
  3. JVM参数只调“有必要”的部分,并在压测环境验证GC曲线。
  4. 代码层重点检查循环内new对象、集合不预设容量、锁粒度太大。
  5. SQL侧跑explain,确认索引、关联顺序、类型转换、通配符。
  6. 最后统一压测,预热后再比较优化前后的P99、P95和GC次数。

这次优化最大的收获不是把接口压到了110ms,而是让我彻底相信了一件事:Java服务慢,很多时候不是被一个“大问题”卡住,而是被一堆“小问题”叠在一起拖慢的。每个问题单独看都不起眼,但连起来就是用户感知到的超时和卡顿。写到这里,我其实挺想感谢那次线上事故,它逼着我把零散的优化知识串成了一条完整的排查链路,这也是我为何在“JAVA优化之路”第三篇里没有继续堆参数,而是完完整整记录了一次排查与改造成果。后续如果再遇到类似问题,我会优先打开GC日志和线程栈,而不是第一时间去改代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询