ARTICLE DETAIL

资讯详情

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

Java性能优化实战:从代码、SQL到JVM的接口提速指南

Java性能优化实战:从代码、SQL到JVM的接口提速指南 JAVA优化之路走到第三期我还真有点感慨。前两期聊了基础编码规范和JVM入门这期我决定换个更“实战”的角度不追求改架构不追求上线新技术而是专门写那些“看着没什么问题”、但数据一大就开始拖后腿的代码。你线上接口变慢很多时候真不是服务器不行、也不是框架选错而是字符串拼接的锅、集合没预分配的锅、SQL写法不当的锅。这篇文章适合已经有Java基础、想对接口性能做系统排查的开发者我会把真实项目里验证过的优化手段、参数选择背后的取舍逻辑以及踩过的坑全部摊开讲不清楚的地方我尽量用大白话解释清楚。1. 代码级优化别急着调JVM先把最基础的写对1.1 字符串拼接看着无害却最容易积累性能债先聊字符串。很多业务代码里都有这种写法String result ; for (int i 0; i 100000; i) { result item- i; }这个循环跑起来耗时和GC压力都非常可观。很多人以为JVM会帮忙优化确实如果是编译期就能确定的字面量拼接比如str a b编译器会直接合并成一个字符串完全没有运行时开销。但在循环里对变量做每次迭代都会创建一个新的StringBuilder和新的String对象10万次循环就是10万次对象创建老年代对象积累一大片Minor GC频率直接翻倍。我自己测过同样10万次拼接直接和StringBuilder的差距大约在两个数量级左右这数字听着夸张但真就是这样的量级差异。还有两个特别容易被忽视的坑。第一个是StringBuffer误用。StringBuffer的所有公开方法都加上了synchronized如果你写的是单线程代码用StringBuffer等于每个方法调用都白付一次锁开销。我压测过一个内部系统同一个拼接循环StringBuffer比StringBuilder慢接近两倍。很多老代码习惯性写StringBuffer其实单线程场景完全没必要。选型就一句话单线程用StringBuilder多线程共享同一个可变字符串对象才考虑StringBuffer或线程安全方案。第二个是日志打印的坑。这个坑我见过太多次了logger.debug(user info: user.getName() , age: user.getAge());这段代码的问题在于即使当前日志级别是INFOdebug日志不会输出这行字符串拼接也会先执行完。也就是说每调用一次这个接口都会创建好几个中间String对象然后被JVM当垃圾回收掉。接口调用量一上来GC时间白涨。正确做法是使用日志占位符logger.debug(user info: {}, age: {}, user.getName(), user.getAge());占位符模式下当前日志级别不匹配时参数对象只是放进数组不会做任何字符串拼接基本零开销。这个习惯在高频接口里能省不少事。1.2 集合容器的选择和预分配避免扩容风暴集合是Java里用得最多、也最容易被写出性能问题的地方。先说HashMap的容量参数。new HashMap()默认容量是16负载因子是0.75。这句话翻译成人话就是当桶数组里元素数量达到16 * 0.75 12个时HashMap会触发扩容重新计算所有元素的hash并搬到一个更大的数组里。扩容本身是一次比较重的操作如果你的业务场景明确知道要放1000个数据直接new HashMap(1000)看起来没问题但实际上HashMap在你传入1000之后会向上取最近的2的幂也就是分配2048个桶。为什么源码要这么设计因为HashMap在求索引时用的是hash (length - 1)只有数组长度是2的幂这个位运算才能正确覆盖所有索引位置同时也比取模快得多。但更正确的初始化姿势是如果你明确知道元素个数是expectedSize可以这样算容量int capacity (int) (expectedSize / 0.75f) 1; new HashMap(capacity);这样new出来的桶数组在不触发扩容的前提下就能容纳expectedSize个元素。这个写法在很多开源框架里能看到原因就是对扩容开销的规避。ArrayList也是一样。默认容量10满了之后按1.5倍扩容每次扩容是一个数组拷贝。如果你要往一个大概有几千条记录的list里add数据不指定初始容量就会反复扩容、反复拷贝。建议是预估一个差不多的数量就new ArrayList(expectedSize)。注意这里可以照实传ArrayList扩容逻辑不太一样传1000基本就是1000左右误差不大。再一个常见病是List.contains()的循环嵌套。for (Order order : orderList) { if (blacklist.contains(order.getUserCode())) { // 过滤 } }如果orderList有一万条blacklist有五千条这个循环就是一亿次比较每条都要从头遍历list。我线上遇到过一个类似的过滤逻辑耗时3秒多怎么都查不出原因。后来把blacklist换成HashSet时间直接掉到50毫秒以内因为HashSet的contains是O(1)的哈希查找和List的O(n)遍历完全不是一个量级。还有一个小细节很多人没注意。在Comparator里做重复计算也是隐藏的性能杀手list.sort((a, b) - calcScore(a) - calcScore(b));假如calcScore需要从多个关联数据里计算出来sort底层是归并排序比较次数大约为 O(n log n)calcScore被反复调用每排序一万条数据就是几万次重复计算。正确的做法是先算一遍存起来ListItem temp list.stream() .map(item - new Tuple(item, calcScore(item))) .sorted(Comparator.comparingDouble(Tuple::score)) .map(Tuple::item) .collect(Collectors.toList());先算好再排复杂度降了一整个量级代码虽然多几行但性能回报非常值。1.3 排序、异常控制流和热点方法里的隐性开销现代JDK的排序算法本身已经做得很极致了。Arrays.sort对基本类型数组用的是Dual-Pivot快速排序对对象数组用的是TimSort这是一种结合了归并和插入排序的稳定排序性能非常好。所以我的建议是业务代码里不要自己写冒泡排序、插入排序除非是教学场景。自己造的轮子基本不可能超过JDK内置排序而且JDK排序对数据量做了很多自适应优化小数组用插入排序、大数组用TimSort这些细节都是经过大量基准测试验证的。异常控制流这个坑更隐蔽。有人写代码喜欢用try-catch处理“业务正常路径”的分支判断for (int i 0; i N; i) { try { doSomething(i); } catch (Exception e) { // 忽略 } }这个写法问题很大除了代码可读性差异常抛出本身是有栈快照成本的try-catch结构在字节码层面也会引入额外的清理逻辑。循环里一旦频繁抛出异常那性能简直不敢看。正确做法是循环里处理业务逻辑循环外统一捕获异常或者干脆少用异常做控制流用if判断不行吗最后提一嘴循环内重复创建对象。很多接口代码会写“循环里new一个List/Map/SimpleDateFormat”导致高频方法里对象创建压力巨大。SimpleDateFormat尤其坑它线程不安全很多人就每次new一个其实用ThreadLocal包一下或者直接用java.time.DateTimeFormatter既线程安全又避免重复创建。这种小细节改完之后接口的GC压力会有肉眼的下降。2. 数据库层优化慢SQL和JDBC细节决定接口下限2.1 慢SQL定位三板斧和索引失效的经典场景很多Java接口的性能瓶颈根本不在Java代码里而在数据库查询上。一条慢SQL能把整个连接池拖满其他所有请求跟着排队最后表现为“接口慢”“系统卡”。定位慢SQL的思路我总结了三个步骤。第一步开慢查询日志。MySQL里long_query_time 1所有执行时间超过1秒的SQL会记录到慢查询日志。有些公司对慢SQL还有专门的监控平台能看到执行计划、扫描行数、返回行数。没有监控平台的先从慢查询日志看起。第二步拿到慢SQL之后执行EXPLAIN重点看type字段。type字段如果显示ALL那就是全表扫描基本可以断定索引没有命中。还有一个重要指标是rows预估扫描行数行数越大SQL越危险。第三步确认索引失效原因。我见过的高频索引失效场景有三种对索引列做函数操作。比如WHERE DATE(create_time) 2024-01-01这个写法会导致索引失效因为数据库无法用索引直接匹配函数结果。改成范围查询就好WHERE create_time 2024-01-01 00:00:00 AND create_time 2024-01-02 00:00:00隐式类型转换。比如字段类型是varchar查询条件却传了数字MySQL会在列上做一次类型转换索引直接失效。like %keyword前置通配符。开头的%让B树索引无从定位只能全表扫。真要支持模糊查询业务少、数据量可控就接受全表扫业务量大就考虑全文索引或者搜索引擎。还有一个几乎每个业务系统都会遇到的问题N1查询。用ORM框架的时候最常见代码写成循环查字典表for (Order order : orders) { User user userMapper.selectById(order.getUserId()); Store store storeMapper.selectById(order.getStoreId()); // 拼装返回值 }查询100条订单就会额外产生200次SQL。这个现象叫N1是数据库性能杀手。解决办法很简单先取出所有订单的userId和storeId列表做一个IN批量查询再通过Map做内存关联。同样是100条订单总共3次SQL搞定性能差距几十倍起。2.2 JDBC批处理、事务边界批量操作的避坑指南批量插入是日常开发里最常见的需求之一比如批量导入、批量同步、日志落库。我从一个实际案例讲起之前做一个对账模块每天要往库表里插几万条流水最开始程序一条一条insert纯跑完要十几分钟。优化后改批处理几十秒就结束。批处理的典型写法connection.setAutoCommit(false); PreparedStatement ps connection.prepareStatement( INSERT INTO settle_flow (order_id, amount, status) VALUES (?, ?, ?) ); for (int i 0; i flowList.size(); i) { ps.setLong(1, flowList.get(i).getOrderId()); ps.setBigDecimal(2, flowList.get(i).getAmount()); ps.setInt(3, flowList.get(i).getStatus()); ps.addBatch(); if (i % 500 0) { ps.executeBatch(); connection.commit(); } } ps.executeBatch(); connection.commit(); ps.clearBatch();这里每500条提交一次是我实践下来的经验值。为什么不是5000条因为单次批处理提交的事务太大锁持有时间太长一旦中途失败回滚代价也更大。为什么不是50条因为太小的话reduce不了几次网络往返效果不明显。500条是个比较稳的中间值如果你的数据量非常大可以根据实际压测结果调整到1000或2000。这里还有个特别容易被忽略的JDBC参数rewriteBatchedStatementstrue。MySQL的JDBC驱动默认情况下addBatch的SQL还是会一条一条发给服务端所谓的批处理只是减少了客户端发送次数并没有真正减少服务端解析执行的开销。把这个参数加到连接URL上驱动就会在内部把多条insert重写成一条多值insert效果特别明显。我用同一个批处理任务测试加了这个参数耗时能再降一倍以上。很多老项目没开这个参数白亏了性能这个细节强烈建议检查一下。关于事务边界我遇到过一个特别典型的事故有个同事在事务里调了外部HTTP接口外部服务异常超时接口等待10秒数据库事务就一直不提交连接也一直被占用。并发一上来数据库连接池直接耗尽整条业务链路全部雪崩。教训就是事务里只能做数据库操作不能做远程调用、不能等待外部资源、不能执行耗时计算。如果一个方法里既有数据库更新又有外部调用先把外部调用结果拿到再开启事务更新数据库这个顺序非常重要。2.3 连接池参数为什么不是越大越好很多人的第一反应是“数据库连接池调大一点并发能力不就更强了吗”这个直觉是错的。连接池里的每个连接在做数据库查询时是占着数据库服务端线程的。连接数给到几百个高峰期所有连接同时发起查询数据库线程上下文切换开销剧增反而更慢。而且Tomcat默认100个连接每个线程占一个连接线程本身也是吃内存的。业界常用的经验公式连接数 (CPU核心数 * 2) 磁盘数。比如一台4核的数据库服务器磁盘1块那连接池大小在9到15之间就会比较合理。大部分Web应用数据库连接池给到20到30已经足够超出之后不仅性能没有提升反而会带来连接堆积、内存损耗。连接池选型上现在主流是HikariCPSpring Boot默认就是它。它的几个核心参数值得重新审视maximumPoolSize最大连接数按上面的经验公式估算。minimumIdle最小空闲连接数我习惯和maximumPoolSize保持一致避免频繁创建销毁连接。connectionTimeout建议保持30000毫秒如果经常报连接获取超时多半不是超时太短而是连接被慢SQL占满了根因还是要查SQL。maxLifetime建议小于数据库wait_timeout否则连接会被数据库服务端先断开客户端还在用白白报错。这里我多说一句连接池参数本身不是性能瓶颈慢SQL把连接占满才是。所以定位接口慢的问题别死磕连接池大小先查SQL耗时。3. JVM优化参数背后是对应用场景的理解3.1 看懂应用特征再选垃圾回收器JVM优化大概是最容易被“网上的推荐配置”误导的部分。很多人看了一篇《JVM参数调优大全》就照抄到生产环境结果接口反而更卡。原因很简单GC选型要跟应用场景匹配没有一套参数能通吃所有应用。先明确两个场景维度低延迟优先还是高吞吐优先。一个在线接口用户点一下按钮希望200毫秒内出结果这种应用是低延迟优先允许牺牲一点吞吐量换取更短的停顿时间。一个离线批处理任务凌晨跑跑两小时也没人关心这种应用是高吞吐优先停顿几分钟都能接受只要整个任务跑得快。在JDK 8时代默认GC是Parallel Scavenge Parallel Old这是一套追求吞吐量的组合所以在在线Web服务上表现并不好。我处理过一个真实案例一个报表接口偶发响应超过1秒用户反馈明显卡顿。查GC日志发现Full GC停顿高达1.2秒原因是老年代堆积了大量对象Parallel GC在Full GC时是整堆STW的。把GC切换成G1并设置-XX:MaxGCPauseMillis100之后接口的尾部延迟明显好转。选GC的实用建议服务接口类应用JDK 8下优先G1JDK 17G1是默认可以考虑ZGC如果追求更低的停顿。批处理任务、离线计算Parallel就很合适没必要上G1吞吐量反而可能降低。ZGC的停顿时间极短但CPU开销比G1高适用于超大堆、低延迟场景常规业务用不上。这里还要纠正一个常见误解G1是“分代收集”加“Region化内存布局”它把堆划分为很多个大小相等的Region每次GC不用扫描整个堆所以停顿时间可控。理解到这个层面就够了选型的时候才知道它在什么场景下合适。3.2 堆内存配置的常见误区别把堆当服务器内存堆内存参数看似简单但配置不当会直接影响GC频率。第一个误区是-Xms和-Xmx不一致。JVM启动时分配较小的初始堆随着对象变多再逐步扩容扩容的过程本身需要STW在高并发下会造成卡顿。建议-Xms和-Xmx设成同一个值启动时直接顶满分配运行中不再动态扩容。第二个误区是新生代大小的取舍。新生代太小Minor GC就会特别频繁每次GC都有一批短命对象被回收CPU都耗在GC上了。新生代太大意味着老年代留给长生命周期对象的空间变小老年代很快堆满触发Full GC。我一般把新生代设为堆的三分之一左右作为起点。比如堆是4G-Xmn1333m左右先跑着用GC日志观察Minor GC频率和Full GC间隔再做微调。第三个误区是元空间的无视。JDK8之后方法区被移除类的元数据放到了本地内存的Metaspace默认不设置上限的话理论上可以一直增长。如果项目用了CGLIB、Javassist这类动态生成类的框架Metaspace可能膨胀得非常快最终把机器内存吃满。建议显式设置-XX:MaxMetaspaceSize512m之类至少加个保险。另外String.intern这个方法在JDK8之后把字符串放到了堆上的字符串常量池里滥用intern会造成常量池里积压大量无法回收的String对象生产环境我见过因为这个原因导致堆占用异常高的案例普通业务代码不建议主动使用intern。最后说一个更反直觉的建议别盲目把堆调大。堆越大Full GC需要扫描的对象越多GC停顿时间反而越长。如果一个应用经常因为对象创建太多导致GC频繁第一步应该是去看是不是代码里循环创建对象、大对象频繁分配而不是给堆加内存。代码层面的优化空间往往比堆大小大得多。3.3 线上JVM排查工具和两个经典内存问题线上排查工具我强烈建议每个人都装一个Arthas。它是阿里巴巴开源的一个Java诊断工具能在不重启应用的情况下做线上诊断功能非常全。dashboard一眼看到全局包括线程状态、内存使用、GC情况、CPU占用Top N。trace追踪一个方法的调用链路能看到方法内部每一步的耗时。这个在定位接口慢的问题时太好用了。watch观察某个方法的入参、返回值、异常不需要打日志就能看到线上实际数据。jad反编译已经加载的类可以对比线上代码和你本地代码是否一致。我用Arthas排查过无数个线上问题最常用的是trace它能直接告诉我一个接口的耗时都花在了哪一步上而不是靠猜。聊聊两个经典的内存问题这两个坑都是代码层面写出来的排查起来很痛苦。第一个是ThreadLocal内存泄漏。ThreadLocalMap的key是ThreadLocal对象的弱引用value是强引用。线程池里的线程是长期存活复用的ThreadLocal对象一旦不再被外部引用key被回收变成null但value还挂在线程的ThreadLocalMap里永远无法被回收。如果业务代码里每次请求往ThreadLocal里塞了一个大对象又没在finally里remove多来几个线程池中的线程内存就被悄悄占满。典型场景就是拦截器里存用户信息、存traceId用完之后一定要finally中remove。第二个是误调用System.gc()。这听着很离谱但我真的见过。有人写“内存清理”逻辑时直接调用System.gc()以为能释放内存实际上在线上环境会强制触发Full GC把整个应用卡死几秒。如果你看到代码里有System.gc()直接删掉然后去查它的源头。4. 并发优化线程池和锁的选择决定峰值表现4.1 线程池参数计算以及为什么Executors是“坑”线程池是Java并发里最常用的组件但很多人直接用Executors的快捷方法这在生产环境埋了不少雷。先说线程池核心参数怎么定。corePoolSize、maximumPoolSize、workQueue、handler这四个参数是灵魂。CPU密集型任务核心线程数建议设置为CPU核数 1这样能尽量把CPU跑满。IO密集型任务比如大量读写数据库、调用外部接口线程大部分时间在等待IO可以多开一些线程。经验公式是线程数 CPU核数 * (1 等待时间/计算时间)。没有精确数据的情况下可以先按CPU核数的2倍设置然后用压测验证。不过线程数也不是越多越好线程切换开销和内存占用都是成本峰值QPS和线程数通常是先升后降的曲线。说完参数重点说说Executors的坑。Executors.newFixedThreadPool(n)使用的队列是无界的LinkedBlockingQueue容量是Integer.MAX_VALUE。这意味着当任务提交速度超过处理速度时任务会无限堆积在队列里最终导致内存溢出OOM而且线程数不会超过核心线程数系统看起来就是慢慢卡死。我排查过一个线上OOM案例根因就是某定时任务用了newFixedThreadPool某次任务处理特别慢队列里堆积了几千万个任务对象。Executors.newCachedThreadPool()的问题更直接它的最大线程数是Integer.MAX_VALUE任务一来就创建线程极端情况下会创建无数个线程直接把线程栈内存耗尽。正确的做法是直接使用ThreadPoolExecutor构造器并且用有界队列ThreadPoolExecutor pool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadPoolExecutor.CallerRunsPolicy() );这里的CallerRunsPolicy是我在业务比较偏爱的一种拒绝策略当队列满且线程数达到上限时提交任务的那个线程自己执行任务而不是直接把任务丢掉。这样能起到一个“自然限流”的作用线程池忙不过来时调用方线程会付出些许性能代价来执行任务避免了任务无声丢失。AbortPolicy是默认策略任务被拒绝时会抛异常这在核心链路上往往导致业务失败DiscardPolicy静默丢弃容易引发数据不一致DiscardOldestPolicy丢弃最老的任务适合一些时效性强的场景。4.2 锁粒度优化和并发集合的选型锁这个东西不是说你用了就是安全的要用得恰当才行。第一个点是集合选型。业务代码里最常见的错误是还在用Hashtable、Vector这两个类所有方法都加了synchronized读操作也要抢锁并发场景下性能很差。正确选择是ConcurrentHashMap。JDK8之后ConcurrentHashMap做了很大改动放弃JDK7的分段锁改成了CAS synchronized只锁数组中的某一个桶。因为多个线程同时写同一个桶的概率远低于同时写整个map的概率所以并发度大幅提升读操作完全无锁。这个设计思路其实就是“锁粒度最小化”的教科书案例别锁整个对象尽量锁最小范围的数据。第二个点是原子类的选型。AtomicLong在低并发下没有问题但在高并发写同一个计数器的场景下CAS一直竞争失败会不断自旋CPU开销很大。LongAdder内部把累加操作分散到了多个Cell里每个线程写自己的Cell最后通过sum方法汇总适合“读少写多”的统计场景。但需要注意LongAdder用空间换时间内存占用比AtomicLong高如果你读操作远多于写操作比如频繁拿当前计数直接改用AtomicLong会更好。第三个点是synchronized和ReentrantLock的选择。大多数业务场景synchronized就足够了JVM对synchronized做了大量的锁升级优化偏向锁、轻量级锁、重量级锁。只有需要“等待可中断”“公平锁”“多个条件变量”这些个性化需求时才值得用ReentrantLock。但不管用哪种锁核心原则是锁内只放必须保护的操作不要在锁内做IO、不要做远程调用、不要做耗时计算。我之前优化过一个订单推送模块原来整个同步代码块把网络请求也包进去接口慢得吓人。把网络请求移出锁外后并发能力提升了好几倍。5. 缓存优化本地缓存和Redis如何配合达到最佳效果5.1 本地缓存的正确打开方式Caffeine的细节分布式缓存Redis应用非常广但有些场景Redis也不合适。比如系统配置、字典转换、权限树这些数据量不大的基础数据每个请求都去查Redis虽然Redis很快但一次网络往返的消耗通常0.1~0.5毫秒在接口响应时间里的占比也不算小了。如果把这块数据放到本地缓存内存里读数据是纳秒级的完全一个天上一个地下。本地缓存选型现在首选Caffeine。它是用Java 8重写的高性能本地缓存库相比Guava Cache命中率更高、性能更好Spring Boot的默认本地缓存实际上已经切换到了Caffeine。构建一个常用Caffeine缓存的参数CacheString, Dict dictCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .build();maximumSize是缓存项数量的上限expireAfterWrite是写入后过期时间。这里我想重点讲一下expireAfterWrite和refreshAfterWrite的区别这是线上效果差异非常大的两个概念。expireAfterWrite是指缓存项过期后下一次读会触发一次同步加载在加载完成前其他请求会阻塞等待。如果刚好一个热点key在请求高峰时过期同一个key可能有几十个线程同时触发加载直接秒掉对应资源。refreshAfterWrite则是写后刷新窗口窗口到期后并不会让缓存项立即失效而是异步去刷新数据读写请求始终能拿到旧值。这个设计对“允许极短时间不一致”的场景非常好用。我推荐一个组合拳expireAfterWrite结合外部定时刷新任务或者refreshAfterWrite配合一个异步的CacheLoader。这样既能保证数据最终一致又能避免热点缓存过期瞬间的“击穿”问题。5.2 缓存穿透、击穿、雪崩三个词背后的本质和应对这三个是缓存领域最经典的问题我在实际项目里都遇到过每个的解决思路都不一样。缓存穿透指查询一个后台数据本身就不存在的key比如用户输入了一个不存在的订单号缓存里没有数据库里也没有每次查询都直接打到数据库。攻击者可以利用这个特点把数据库瞬间压垮。解法有两个一是把空值也缓存起来设置一个很短的过期时间比如60秒这样同样的空查询被拦截在缓存层二是用布隆过滤器在缓存之前先去布隆过滤器里判断key是否存在不存在就直接返回连缓存都不用查。缓存击穿指某个热点key在大量请求的间隙恰好过期了导致所有请求同时打到数据库。和穿透的区别是击穿命中了一个确实存在的数据只是缓存短暂失效。解法是互斥锁当缓存没有命中时让一个线程去数据库加载数据并写缓存其他线程等这个线程完成后直接读缓存可以用双重检查锁来做。或者干脆给热点key设置永不过期靠后台任务定时刷新。缓存雪崩指大量key在同一时间集中过期导致大量请求同时落到数据库。常见于整点、整分刷新的场景。解法比较简单过期时间加随机值比如5分钟加0到60秒随机数让过期时间自然散开。如果用了Caffeine的refreshAfterWrite本身就是异步刷新不会造成雪崩这也是我推荐它的原因。最后说一句缓存一致性。缓存里的数据是从数据库加载的数据库先更新还是先删缓存这个顺序问题闹过很多笑话。我的实践是先更新数据库再删除缓存。为什么因为先删缓存再更新数据库的话删除成功后、更新数据库前这段时间有一个请求进来查数据发现缓存没有就去数据库里读了旧值又把旧值塞回缓存脏数据就产生了。先更新数据库再删缓存虽然仍然存在一个极短窗口但概率小很多。对一致性要求高的场景可以再加上延迟双删更新数据库后先删一次缓存隔几百毫秒再删一次把中间可能产生的旧缓存给清掉。6. 实战记录一个接口从1.8秒优化到120毫秒的完整过程6.1 问题定位把耗时从黑盒变成彩色之前做一个报表系统订单列表查询接口本来一直挺正常数据量增长到几十万之后线上监控突然报警接口P99从300毫秒涨到了1.8秒。用户反馈点一下要等近两秒明显不可接受。这个优化案例比较典型我把整个定位过程拆开讲。第一步是接上Arthas做线上trace。接口方法名字叫queryOrderList用trace命令直接追踪trace com.company.report.service.OrderQueryService queryOrderList执行完这条命令后Arthas会把方法内每个子调用的耗时实时打印出来。我发现整个接口1.8秒的耗时里有1.2秒都花在了一次数据库查询上。这就把问题范围锁定了。然后开慢查询日志找到那条SQL。EXPLAIN看了一下type是ALLrows显示扫描了20万行。再看WHERE条件发现表上虽然建了联合索引(a, b, c)但查询写的是WHERE status 1 AND DATE(create_time) 2024-06-01create_time字段上套了个DATE函数索引直接失效。全表扫20万行不快才怪。改成WHERE status 1 AND create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00索引命中后SQL耗时从1.2秒掉到了100毫秒以内。这一步优化完之后接口整体还有700毫秒左右继续往下定位。6.2 化解N1和重复查询往缓存层要时间剩下的耗时主要集中在订单列表的“回填”操作。查出来的每条订单要回填用户昵称、商品名称、门店名称这些都在不同的字典表或基础表里。原代码是循环里逐个查for (Order order : orders) { order.setUserName(userMapper.selectById(order.getUserId()).getName()); order.setStoreName(storeMapper.selectById(order.getStoreId()).getStoreName()); }每页20条订单就要额外执行40次SQL。这属于典型的N1。我把这里的逻辑改成批量查询先把所有userId和storeId收集成两个列表分别执行一次IN查询得到Map再在内存里做映射。40次SQL变成2次这块耗时从峰值150毫秒降到了约10毫秒。最后还发现一个很傻的重复查询问题前端每页查询都会重新加载一遍全部字典表比如订单状态字典、支付方式字典这些数据一天都不带变的。我给它们加了一层Caffeine本地缓存TTL设为5分钟之后这些字典查询从数据库SQL变成纯内存读取。这一步又优化了约100毫秒。最终接口耗时稳定在120毫秒左右线上P99从1.8秒降到了150毫秒内。整个优化过程并没有引入任何新框架、没有改G1还是ZGC就是SQL索引修复、N1批量化和本地缓存三板斧。6.3 通用优化方法论先压测再定位再动手从这次实战里我得出一个顺序也是我建议大家在优化时遵循的顺序先定位瓶颈再动手改。不要一上来就调JVM、上缓存。很多系统的慢根因就一条慢SQL把所有连接拖死你调JVM参数根本治标不治本。正确顺序是先拿Arthas或监控工具trace一下接口耗时分布看看CPU时间花在哪、数据库时间花在哪再对症下药。优先级由高到低SQL和数据库层面的问题包括索引失效、N1、慢查询。代码层面的问题比如循环内查库、集合没预分配、字符串滥用。本地缓存和分布式缓存的使用。最后才是JVM参数和GC调优。不要跳级。前面三层不解决JVM调优的意义很有限。7. 常见问题与排查技巧速查表实际操作中很多问题看起来症状一样根因却完全不同。我整理了一份排查速查表遇到相似症状可以先对着查现象可能原因排查手段解决方案接口响应变慢P99飙升SQL索引失效或N1查询Arthas trace EXPLAIN修正索引条件循环查库改批量查询GC总时间增长Young GC频繁循环内创建大量短命对象jstat -gcutil观察YGC频率优化代码减少对象创建必要时调大新生代偶发长时间卡顿RT出现长尾Full GC停顿较大GC日志看停顿时间切换G1或调整堆内存比例CPU飙升但接口TPS不高死循环或正则回溯jstack看线程栈修复代码逻辑替换危险正则内存占用持续上涨最终OOMThreadLocal未remove或缓存未清理jmap dump MAT分析引用链finally中remove ThreadLocal缓存加容量上限数据库连接池被占满报连接超时慢SQL把连接占住事务内做远程调用慢查询日志 DBA看当前连接线程优化SQL事务内移除远程调用再分享几个排查效率极高的技巧。第一在生产环境用Arthas而不是老派人肉看日志。trace命令直接看方法内部耗时watch命令直接看线上参数不需要反复加日志、发重新部署。Arthas的原理是基于Java Instrumentation可以在线attach到目标JVM不修改业务代码。第二GC日志一定要提前打开。JVM参数里加上-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps线上出问题的时候才有数据可查。没有GC日志的问题排查就像黑盒调试全靠猜。JDK 11的写法是-Xlog:gc*:file/data/logs/gc.log注意版本差异。第三压测环境和生产环境差异很大压测没问题的接口不能保证线上没问题。数据库数据量、索引状态、缓存命中率、并发度都不一样。所以压测时别忘了在测试库构造接近生产规模的数据量。最后一条是经验之谈每次优化只改一个变量。比如这周只调JVM参数下周才改SQL。如果同时改很多东西出了问题很难定位是哪个改动导致的。优化本身就是一种排查变量控制得越干净判断越准。Java优化这件事做到后面你会发现拼的不是谁会的API多而是谁更理解JVM和数据库在底层是怎么工作的。你越清楚一个字符串加号背后产生了哪些对象一条SQL为什么没走索引一个线程池队列满了会发生什么你就越不容易被表面的“慢”糊弄过去。几个高价值的优化点往往就藏在那些不明显的地方。如果这篇文章对你排查线上问题有一点帮助那就很值了。
返回列表