先讲一个我印象特别深的场景。线上有个订单详情接口,平时P99在60ms左右,某天突然涨到800ms,页面转圈转得用户投诉不断。我上去一看,代码逻辑没有任何改动,数据库也没出明显故障,最后用Arthas一查线程栈才发现,Tomcat线程池被一个下游优惠券服务的慢请求占满了,所有请求排着队等线程,而那个慢请求根本没有设置超时。这种问题典型到不能再典型,但它恰恰说明了一件事:Java接口性能优化,从来不是单纯改几行代码的事,而是一个从链路设计到数据访问再到并发控制的系统工程。
这篇内容适合正在做Java后端、被线上接口RT问题折磨过的新手,也适合想系统化梳理优化思路的资深开发。我不会按教科书的方式给你讲概念,而是按我自己在实战里的优化路径来写:先讲怎么定位瓶颈,再按链路设计、数据层、JVM与并发、异步化、监控压测六个维度拆开,一共15个技巧,每个都结合真实场景说清楚。你能直接拿去用的有代码、有参数、有避坑经验。
1. 优化前先想清楚:瓶颈到底在哪一层
1.1 接口慢,慢在哪个环节
一个接口从客户端发起到拿到响应,中间经过的环节远比你想的多:DNS解析、网络传输、网关路由、应用层业务处理、线程池调度、数据库查询、下游RPC调用、序列化、GC……任何一环出问题,最终表现出来都是“接口慢了”。
很多人的第一个反应是看业务代码,对着一个方法抠来抠去。但你想想,如果问题是数据库索引没命中,业务代码写得再漂亮也没用;如果问题是下游服务超时,你在本应用里优化一百次也无济于事。所以第一步永远是先搞清楚慢在哪一层。
这里我有个习惯,拿到一个变慢的接口,先按“客户端→网关→应用→数据库/下游”这个链条把每个环节的耗时拆一遍。应用内的耗时可以用Arthas的trace命令去看方法级耗时,数据库和下游则通过监控平台看响应时间。拆完之后再决定往哪个方向优化。用餐厅点餐来类比:用户等餐时间长了,可能是收银台慢、可能是后厨慢、可能是传菜慢,你不能只盯着“菜炒得不好吃”这个问题。
1.2 用数据说话,别靠猜
不要用“我感觉”、“我猜”来定位性能问题,一切以数据为准。我最常用的是三个硬指标:RT的P99、QPS、错误率。为什么用P99而不是平均RT?因为平均值太容易被“大多数正常+少数毛刺”给平滑掉。举个例子,某接口平均RT是50ms,听起来很健康,但P99可能是1.5秒,说明每100个请求里就有1个用户卡了整整1.5秒——这种体验问题平均值完全看不出来。
P99之外,还要看CPU使用率、内存占用、GC频率、数据库连接池活跃数、Redis耗时、网络带宽。我见过一个项目,接口变慢的表现是CPU持续在90%以上,数据拉出来才发现是反序列化占了60%的CPU,问题根本不在数据库。所以我的建议是:遇到线上接口变慢,先截取一段典型时间窗口,把上面这些数据全部拉出来,对着看,往往一眼就能锁定嫌疑对象。
1.3 最容易踩的优化误区
误区一的典型表现是:接口慢了,先改JVM参数,把堆内存调大,把GC改回收器。不是说JVM调优没用,而是没有经过GC日志分析就乱调,大概率是白忙一场,甚至调得更差。误区二是没有压测就上线,改完代码直接发布,结果第二天线上数据更差了,连回滚都不知道回滚到哪一步。误区三更隐蔽:只盯着一个点优化。比如花了三天把某个方法的代码优化得很漂亮,结果整个链路里这个方法只占5%的耗时,收益微乎其微。
还有个误区很少人提,就是“为了优化而优化”。有一种优化叫过度设计,把一个普通读接口改成多级缓存加异步加载,复杂度上去了,收益可能就几毫秒。优化的投入产出比一定要算清楚,真正值得优化的,永远是最慢的那一段。
2. 链路设计:从源头减少工作量
2.1 技巧1:响应体瘦身,最容易被忽略的优化
很多人优化接口,盯着代码和SQL看半天,却忽略了一个事实:响应体本身就是性能的一部分。序列化耗时、网络传输耗时、客户端解析耗时,全都和响应体的大小正相关。我经手过一个真事:一个列表接口返回了35KB的数据,前端页面只用了其中5个字段,那35KB里有大量嵌套对象、空字段、冗余信息。后来按场景拆了一个精简版DTO,只保留前端需要的字段,再把Jackson的空值输出关掉,响应体从35KB降到7KB,接口P99从120ms直接降到80ms。
实操上记住三件事。第一,按使用场景拆分DTO,列表页、详情页、移动端各一套,不要一个万能大DTO走天下。第二,配置序列化框架忽略null字段,Jackson可以用JsonInclude.Include.NON_NULL,这一点改动很小但效果明显。第三,内部接口的字段命可以适当地精简,但对外接口的契约要稳定,删除字段一定要走版本迭代流程。这里有个度的问题:瘦身别把语义删没了,接口可读性也很重要。
2.2 技巧2:并行调用替代串行调用,把等待时间重叠起来
接口里最容易出现的隐性浪费,是串行调用多个无依赖的服务。假设一个订单详情接口要查用户信息、订单信息、商品信息、库存信息、优惠券信息,每个下游调用平均50ms,串行就是250ms。但实际上这些信息互相没有依赖,完全可以把它们并行发出,总耗时只有最慢的那个大概50到80ms。
这一块我常用CompletableFuture来做,代码很简洁:
ExecutorService bizPool = Executors.newFixedThreadPool(8); CompletableFuture<UserVO> userFuture = CompletableFuture .supplyAsync(() -> userService.getUser(userId), bizPool); CompletableFuture<OrderVO> orderFuture = CompletableFuture .supplyAsync(() -> orderService.getOrder(orderId), bizPool); CompletableFuture<StockVO> stockFuture = CompletableFuture .supplyAsync(() -> stockService.getStock(skuId), bizPool); CompletableFuture.allOf(userFuture, orderFuture, stockFuture) .get(2, TimeUnit.SECONDS); UserVO user = userFuture.get();这里有三个细节你一定要注意。第一,所有并行任务都要加总超时,allOf().get(2, TimeUnit.SECONDS)如果超时了要处理,避免线程池里堆积未完成任务。第二,一定要用独立的线程池,不要用公共的ForkJoinPool,否则其他接口的任务会互相干扰。第三,有依赖关系的任务不能强行并行,比如B需要A的返回值,那就只能串行,强行并行只会让代码复杂度暴涨、收益为零。
2.3 技巧3:批量查询替代循环调用
说到性能杀手,N+1查询绝对排得上号。最常见写法是:先查一个订单列表,然后在循环里逐个查每个订单的买家信息,列表有100条就查100次数据库。循环里的每次查询都是一次网络往返加一次SQL执行,耗时自然是线性增长。我的习惯是:凡是循环里查数据库,全部改成批量接口。
改造方式很简单,先查出所有订单,收集买家ID集合,然后一次性WHERE id IN (...)查出所有买家,再在内存里组装。MyBatis里我一般这样写:
<select id="selectBatchByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>这里有个经验值要分享:IN子句的id数量我一般控制在500个以内,超过这个量就拆分成多批,再决定是顺序执行还是并行执行。原因很简单,IN数量过大时SQL文本会很长,数据库优化器可能放弃索引走全表扫描,反而更慢。另外,批量查询虽然减少了IO次数,但如果数据量大还是会慢,后续可以结合并行和缓存继续优化。
2.4 技巧4:幂等与防重设计,给重复请求上一道闸
接口幂等性看起来是正确性问题,其实它也直接影响性能。想一想,如果支付回调没有幂等,同一个回调事件被MQ重投三次,订单状态就被更新三次、短信发了三次、积分加了三次,DB压力翻了数倍,接口当然变慢。再比如前端按钮没有做防重复点击,用户手抖点了两次,两次请求一起进来,同样的业务被重复执行。
实现幂等我推荐组合拳:Redis SETNX做前置过滤,数据库唯一索引做最终兜底。
// Redis 前置防重,key 带业务语义 Boolean first = redisTemplate.opsForValue() .setIfAbsent("order:create:" + orderNo, "1", Duration.ofMinutes(30)); if (Boolean.TRUE.equals(first)) { // 执行核心业务 } else { // 重复请求,直接返回成功或提示处理中 }只要Redis的SETNX返回false,说明这个请求已经处理过,直接丢弃,核心业务代码压根不用执行,这就是防重对性能的贡献。但Redis的key有过期时间,过期之后重复请求依然可能进来,所以数据库唯一索引才是最终防线。真实项目里我都是两层全上,Redis拦截99%的重复流量,唯一索引兜底剩下的1%。
3. 数据层优化:绝大多数性能问题的根源
3.1 技巧5:SQL与索引优化
做了这么多年Java后端,我越来越确定一件事:接口性能问题,七成以上出在数据库。数据库慢,接口就不可能快。而数据库慢的最大原因,就是SQL没走对索引。
遇到慢接口,先打开数据库慢查询日志,把long_query_time设为0.3秒,找出Top SQL,逐个执行EXPLAIN分析。重点看这几个字段:type是不是从const降到了ALL,rows是不是扫描了十万行,key是不是干脆没命中索引。我见过一个典型例子,用户表user_phone是varchar类型,SQL里用where user_phone = 13800001111传了个数字,MySQL会把数字隐式转成字符串去比较,导致索引失效,全表扫描。把参数改成字符串类型后,扫描行数从几十万降到一行,接口从2秒变10ms。
其他常见的索引失效场景,我整理一下:索引列上套函数或运算、LIKE前置百分号、OR条件跨字段、组合索引不满足最左前缀。优化手段不外乎改SQL、建索引、加覆盖索引。但我要提醒一句,索引不是越多越好,每多一个索引,写入就多一次维护,存储也多占一份空间,关键是要建立起“按执行计划建索引”的思维,而不是把所有列都建一遍。
3.2 技巧6:消除N+1查询,把查询次数压下来
N+1问题在ORM框架里尤其泛滥。用MyBatis的时候,很多人图省事写嵌套查询,查一个订单列表,每条订单再查一次买家信息。表面上看代码很规整,实际发出去的SQL是1加N条,N是订单条数。100条订单就是101次数据库查询。
我的标准做法是拆成两步:第一步查出订单列表,第二步收集所有买家的ID,用WHERE id IN (...)一次查出所有买家,然后在内存里按ID组装成Map,循环订单列表时直接Map.get。查询次数从101降到2,这一下就能把接口耗时打下来一半还多。
还要提一个隐藏坑:MyBatis的<collection>嵌套查询,如果配置不当,本质上就是N+1。你要打开SQL日志确认一下,看它到底是发了一条JOIN还是发了N条子查询。如果发现是N+1,优先改为两步查询在内存中组装,或者用一条带JOIN的SQL直接查出来。
3.3 技巧7:深分页优化,别让LIMIT成为性能瓶颈
分页是几乎所有业务系统都绕不开的功能,但分页写到后面,性能问题会越来越明显。问题出在MySQL执行LIMIT 1000000, 20时,它得先扫描前100万行,然后丢掉,只返回最后20行。页码越深,扫描量越大,接口自然越慢,而且这个慢是数量级的慢。
我常用的两种优化方案,你按业务场景选。第一种是延迟关联,先用子查询只查主键ID,再回表取完整数据:
SELECT t.* FROM `order` t JOIN ( SELECT id FROM `order` WHERE status = 1 ORDER BY id DESC LIMIT 1000000, 20 ) tmp ON t.id = tmp.id;这种写法让MySQL内侧的子查询先做一次纯索引扫描,速度会快不少。第二种是游标分页,适合按ID或时间滚动的场景,直接WHERE id > lastId ORDER BY id LIMIT 20,每次只往后翻20条,不管翻到多深性能都稳定。游标分页的代价是不能跳页,业务要求能直接跳到第50页的话,只能用延迟关联。
3.4 技巧8:多级缓存,性能优化的最大杠杆
如果说数据层优化是治本,那缓存就是见效最快的“特效药”。我做的每一个高并发读接口,基本都遵循“Caffeine本地缓存L1 + Redis分布式缓存L2 + 数据库兜底”的三级结构。
// 一级缓存:本地 Caffeine User user = localCache.getIfPresent(userId); if (user != null) { return user; } // 二级缓存:Redis String json = redisTemplate.opsForValue().get("user:" + userId); if (json != null) { user = JSON.parseObject(json, User.class); localCache.put(userId, user); return user; } // 三级兜底:数据库 user = userMapper.selectById(userId); redisTemplate.opsForValue().set("user:" + userId, JSON.toJSONString(user), Duration.ofMinutes(30)); localCache.put(userId, user); return user;为什么本地缓存和Redis都用?因为访问Redis也有一次网络开销,要1到2ms,而Caffeine直接读JVM内存,是微秒级。对热点数据来说,本地缓存是真正的杀手锏。但本地缓存也有代价:多实例部署时数据不一致,而且占应用内存。所以本地缓存只放热点小数据,Redis放全量缓存数据。
用缓存最怕的是三个经典问题。穿透:查一个不存在的ID,缓存和数据库都没有,每次请求都打DB,解决方案是布隆过滤器或者缓存空值。击穿:一个热点key刚好过期,瞬间大量请求同时打DB,解决方案是用互斥锁只让一个线程去重建缓存。雪崩:大量key在同一时间过期,数据库被打爆,解决方案是过期时间加随机数,把过期时间打散。
4. JVM与并发优化:把线程和内存用明白
4.1 技巧9:接口专属线程池与参数调优
线程池很多人会用,但用得粗。我强烈建议:关键接口的异步任务、并行任务,全部使用独立线程池,不要和Tomcat的工作线程混在一起。原因很简单,Tomcat的默认线程池是共享的,如果某个接口里所有业务都在公共池里跑,一旦这个接口并发量上来或者下游变慢,整个应用的线程资源都会被占满,其他接口跟着遭殃,这叫线程池饥饿。
独立线程池的参数怎么定?我的经验是分场景。CPU密集型任务,核心线程数设为CPU核数+1;IO密集型任务,核心线程数可以设为CPU核数×2左右,或者用公式N_threads = N_cpu × (1 + waitTime / computeTime)去估算。线程池的队列一定要有界,配合一个合理的拒绝策略,不然任务会无限堆积挤爆内存。
ThreadPoolExecutor ioPool = new ThreadPoolExecutor( 16, // 核心线程数 32, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(2000), // 有界队列 new ThreadFactoryBuilder().setNameFormat("biz-io-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );线程池跑起来之后,一定要监控三个指标:活跃线程数、队列长度、拒绝次数。活跃线程数长期等于最大线程数说明池子不够用;拒绝次数不为零说明流量已经超过处理能力,要么扩容,要么削峰。拒绝策略的选择也要看业务,CallerRunsPolicy会把任务退回到调用线程执行,等于用调用方的线程去兜底,适合不希望丢任务的场景;如果任务本身可以丢弃,就自定义一个丢弃策略并打日志。
4.2 技巧10:序列化方案选型,被低估的CPU杀手
序列化在Java接口里的比重,经常被人低估。一个接口从接收请求到返回响应,请求体和响应体都要序列化反序列化;RPC调用要序列化,Redis读写JSON要序列化,MQ消息也要序列化。序列化方案选错了,CPU会被消耗掉一大块。
我对比一下几个主流方案的实际表现:
| 方案 | 序列化体积 | 速度 | 跨语言 | 适用场景 |
|---|---|---|---|---|
| JSON | 大 | 中 | 好 | HTTP对外接口、日志 |
| Protobuf | 小 | 快 | 好 | 内部RPC、存储 |
| Kryo | 小 | 快 | 差 | Java服务间通信、缓存 |
| Hessian | 中 | 中 | 较差 | Java老项目 |
我实际做过一个改造,把一个频繁调用的内部查询接口从JSON换成Protobuf,响应体从22KB变成6KB,接口平均耗时下降了接近30%,带宽消耗下降70%。Protobuf的优点是压缩率高、解析快,而且自带版本兼容机制。但它有个硬伤,要维护proto定义文件,字段编号一旦上线就不能变,否则老的消费者反序列化会出错。所以我的建议是:对外HTTP接口保留JSON,方便所有客户端接入;内部RPC、高并发读接口,如果流量很大,值得上Protobuf。
还有个更轻量的优化思路,如果你暂时不想引入Protobuf,那至少在JSON层面做两件事:第一,配置序列化框架忽略null字段;第二,检查是否频繁对大对象做toJSONString,有时候这个动作会是CPU和GC的双重瓶颈。
4.3 技巧11:控制GC与对象分配,稳住的不仅仅是内存
GC对接口性能的影响,最直观的表现是RT毛刺。一次Full GC的Stop-The-World停顿可能就是几百毫秒,这段时间内所有请求都在排队。你去看监控图,会发现接口的P99曲线偶尔冒出一个尖峰,时间点和GC日志里的停顿完全吻合。
我的经验是,GC优化优先做“减分配”,而不是“调参数”。减少对象分配的手段很具体:循环内部不要创建大对象,不要用字符串拼接,改成StringBuilder;不要对大对象做深拷贝,用只读视图或者直接操作引用;频繁使用的对象用ThreadLocal复用;大数组、大Buffer用静态或池化管理。还有一个细节:直接内存操作尽量用ByteBuffer.allocateDirect(),避免在堆内和堆外之间来回拷贝,这在涉及网络IO和文件IO时效果明显。
有一个真实案例我印象很深。某接口每次请求里都会对两个很大的对象做深拷贝,再加上循环里频繁JSON.toJSONString,GC频率高得吓人。把深拷贝去掉、改用不可变对象之后,GC次数直接降了一半,接口P99从300ms降到150ms。这个例子说明,很多性能问题根本不在SQL,也不在代码逻辑,而在内存分配策略。代码跑得慢,有时候是JVM在不停地“打扫卫生”而没时间干活。
5. 异步化与容错:让接口不被慢依赖拖死
5.1 技巧12:异步化改造,把非核心逻辑挪出主流程
不是所有逻辑都必须同步完成。积分累加、短信通知、报表统计、消息推送这类操作,用户根本不关心它们什么时候完成,它们只要最终执行到就行。把这些逻辑从接口主流程里挪出去,改成异步执行,接口的RT能立竿见影地降下来。
最常用的异步化手段是消息队列。创建一个订单,主流程只做落库,然后把“订单创建成功”这个消息发给MQ,由下游消费端去做积分、通知、推荐等操作。代码上看很简单:
orderService.createOrder(order); mqTemplate.send("order-create-topic", orderId); return Result.success();但有三个问题必须想清楚。第一,异步化牺牲了强一致性,业务要能接受“最终一致”。如果用户下单后立刻查询积分,可能查不到刚加的积分,这个场景就不适合异步。第二,MQ消息可能丢也可能重复,必须有重试机制和幂等消费。第三,事务边界很容易被忽略:主流程的事务提交了,异步任务才应该执行;如果事务还没提交就把消息发出去,消费者查数据可能查不到。稳妥的做法是使用本地消息表配合定时任务,或者用支持事务消息的MQ。
还有一种轻量级异步,适合不想引入MQ的场景:直接用业务线程池把非核心任务提交出去。但注意,异步任务里的异常一定要捕获处理,不然会直接消失,连日志都没有。
5.2 技巧13:超时与熔断降级,慢依赖不能拖垮整个服务
一个服务变慢,往往不是自己出了问题,而是下游依赖变慢了。更可怕的是慢依赖会传染:某个下游接口从50ms变成3秒,你的接口就跟着等3秒,期间所有请求都占着线程不放,线程池很快被占满,然后你的服务也开始“变慢”,最后上游网关也开始超时重试,整条链路雪崩。
所以给所有下游调用设置超时,是接口性能优化的底线。HTTP客户端和RPC框架都要配连接超时和读超时,连接超时一般是500ms到1秒,读超时按业务容忍度设1秒到3秒,千万不要用默认的“永不超时”。超时之后还要做兜底,比如返回降级数据,而不是把异常抛给上层。
熔断是超时的升级版。我常用Sentinel或者Resilience4j,参数一般这样配:在10秒的滑动窗口内,如果请求失败率达到50%,就触发熔断,熔断持续10秒,10秒后进入半开状态放少量请求试探,如果成功就关闭熔断恢复流量。这套机制的核心思想是:下游已经病了,你就别再疯狂往里打流量了,让它缓一缓。
我踩过一次很深的坑:优惠券服务因为上线bug变得极慢,我们的订单详情接口要调它,当时没有超时和熔断,结果一个1%的慢请求把所有Tomcat线程全部占住,整个订单系统的P99从60ms飙到1秒。最后加上了超时熔断,并且给优惠券模块做了降级兜底,接口P99才恢复正常。从那以后,凡是涉及外部调用的接口,超时熔断成了我的标配。
5.3 技巧14:连接与压缩优化,把网络开销降下来
网络层面的开销常常被忽略,但它在接口耗时里占的比例不低。最明显的是HTTP连接建立,每发起一次HTTPS请求,都要经历DNS解析、TCP握手、TLS握手,光握手就要好几个RTT。如果每次请求都新建连接,性能损耗非常可观。
解法就是连接池复用连接。Java这边用Apache HttpClient或OkHttp都有连接池配置,我一般这样设:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| connectTimeout | 500ms | 建连超时 |
| socketTimeout | 2000ms | 读超时,按业务调整 |
| maxConnTotal | 600 | 总连接数上限 |
| maxConnPerRoute | 100 | 单路由连接数上限 |
| connectionRequestTimeout | 200ms | 从连接池获取连接的超时 |
连接池也不是越大越好,连接数太多反而会增加服务器负担和内存开销,压测出一个合适的值就好。
另一个网络优化手段是HTTP/2,它支持多路复用,同一连接上可以并发多个请求,彻底解决HTTP/1.1的队头阻塞问题。如果你的下游服务支持HTTP/2,尽量升级。
再就是响应体压缩,Gzip压缩JSON文本一般能压掉60%到70%。但要注意,压缩和解压都要耗CPU,小于1KB的响应压缩收益很小,不建议开。我是先压测对比,确定收益大于开销才开Gzip。
6. 压测与监控:优化效果要看得见
6.1 技巧15:压测与监控先行,用数据证明优化有效
没有压测的优化,都不能算数。我见过太多人改完代码直接上线,自我感觉良好,结果线上P99不降反升。原因很简单,你优化的是A点,但真正的问题是B点,不压测你根本不知道A点有没有优化到位。
我的标准流程是:优化前先做一次基准压测,记下RT的P99、QPS、错误率;优化后再做一次相同条件的压测,对比两次的数据,用数据说话。压测工具我用得最多的是JMeter和wrk,wrk适合简单的HTTP接口压测,一条命令就能跑:
wrk -t4 -c200 -d60s --latency http://localhost:8080/api/user/detail-t4是4个线程,-c200是200个并发连接,-d60s是持续60秒,--latency会输出延迟分布。跑完会直接给出QPS和P99,非常直观。复杂场景,比如带登录态的多步操作,就用JMeter跑脚本。
压测有个容易犯的错误:拿生产环境的监控数据当基准。生产环境的流量不均匀,有低峰有高峰,不适合对比。压测最好在独立的压测环境里做,配置和数据量要和线上对齐,至少要压到目标QPS的1.5倍,才算有点参考价值。压测机本身也要注意,别把压测机的CPU压满了,出来的数据就不准了。
6.2 线上问题排查实录:四个真实案例
先讲一个我实际排查过的RT毛刺问题。监控图上P99稳定,但每隔一段时间会出现一个尖峰,持续一两百毫秒。我第一反应是GC影响,直接看GC日志,发现这段时间正好有Full GC发生,停顿时间在300ms左右。再往下查,发现某个统计接口每10分钟会加载一次全量数据到内存做计算,大对象分配直接触发Full GC。把统计逻辑改成异步执行后,毛刺消失,P99曲线平滑下来。这个案例给我的启发是:RT毛刺问题,优先排查GC,再排查定时任务和大对象分配。
第二个案例是Tomcat线程池耗尽。现象是接口超时率上升,日志里大量出现等待线程超时。我用jstack抓线程转储,发现大量线程卡在下游HTTP调用上,进一步看,那个下游服务因为发布问题变慢了,而我们的调用没有超时设置,所有线程都在死等。最后加上了读超时和熔断降级,问题彻底解决。
第三个案例是数据库连接池耗尽。Druid监控面板显示池子里活跃连接数一直顶到上限,但CPU并不高。排查下来是一条报表查询SQL没用上索引,全表扫描跑了快4秒,把连接都占住了。用EXPLAIN确认后,加了联合索引,查询时间降到50ms,连接池恢复健康。这个案例说明,连接池耗尽很多时候不是配置问题,是慢SQL问题。
第四个案例是缓存穿透。某个商品查询接口在搞活动期间缓存命中率骤降,数据库负载飙升。原因是活动页面上线了一批不存在的商品ID,这些ID在缓存和数据库里都没有,每次请求都直接打DB。后来加了布隆过滤器,把不存在的ID在入口处直接拦截,数据库压力降了80%。
6.3 常见问题排查速查表:一看一个准
这几个案例我整理成了一张排查速查表,遇到同类问题可以直接对着查:
| 症状 | 可能原因 | 定位手段 | 处理方向 |
|---|---|---|---|
| RT偶发尖峰毛刺 | Full GC、定时任务、大对象分配 | 查看GC日志、jstat、Arthas | 减少对象分配、异步化、调整GC参数 |
| Tomcat线程池爆满 | 下游慢无超时、业务死等 | jstack线程转储、监控线程池指标 | 设置超时、熔断降级、独立业务线程池 |
| CPU持续高位 | 序列化频繁、正则、循环创建对象 | async-profiler火焰图 | 替换序列化方案、减少对象分配 |
| 数据库连接池耗尽 | 慢SQL、连接泄漏 | Druid监控、EXPLAIN | 索引优化、SQL改写、治理连接泄漏 |
| 接口超时不断 | 下游依赖变慢、重试风暴 | 链路追踪、耗时明细 | 超时熔断、降级兜底、限流 |
| 缓存命中率低 | key设计差、过期时间短 | 缓存监控面板 | 调整TTL、添加随机数、热点key识别 |
这张表我在团队里贴了很长时间,每次线上接口报警,大家先对着表排查一轮,大多数问题都能快速定位。排查性能问题的方法论其实就一句话:先看监控,再抓现场,最后改代码。顺序反了,效率会差很多。
最后再分享一个我个人的习惯。跟我合作过的人都知道,我改性能问题有一个铁律:每次只改一个变量。这次只加缓存,跑一轮压测;下次只换序列化,再跑一轮压测。几个优化点混在一起改,出了问题你根本不知道是哪个改动导致的,回滚也不知道回滚什么。15个技巧里,如果让我只留三个,我会留下监控压测、超时熔断、多级缓存——这三样是防守和基础,其他技巧都是放大器。接口性能优化不是一次性的工作,它是个持续的过程,你的接口每慢一毫秒,用户流失的概率就多一分,希望这些技巧能帮你少走点弯路。