
那是一个典型的周四下午线上告警群里突然炸了锅。核心交易接口的P99延迟从80ms一路飙到2.3秒数据库CPU直接打满连接池报出“Connection is not available, request timed out”。我打开慢查询日志一条SQL赫然躺在列表顶端执行耗时4.8秒扫描行数1200万。最讽刺的是这条SQL只是查一个订单状态表里一共才3万条数据。性能问题从来都不是偶然发生的它是一个系统在长期演进中所有坏味道的集中爆发。而这只是整个调优故事的开头。慢查询的真面目索引设计的三大幻觉那条4.8秒的SQL长这样SELECT FROM order_info WHERE status PAID AND create_time 2023-01-01 ORDER BY gmt_modified DESC LIMIT 20。开发同学很委屈我明明建了复合索引(status, create_time)为什么还是慢用EXPLAIN一看type是ALLextra里写着Using filesort。索引失效的原因很简单status字段是varchar类型但传入的参数是字符串没问题问题出在MySQL的优化器认为全表扫描比索引回表更快——因为statusPAID的数据占了全表的60%。这就引出了第一个幻觉“只要建了索引查询就一定快”是新手最大的误区。索引的价值取决于区分度。一个区分度只有40%的字段放在复合索引最左列优化器会直接放弃索引。解决方案是调整索引顺序把create_time放在最前面或者更激进一点只索引create_time让status作为过滤条件在回表后处理。改完这条SQL的执行计划type变成了range扫描行数从1200万降到3.5万耗时从4.8秒降到80毫秒。但真正的坑还在后面。索引不是越密越好每个索引都是写入放大器和内存吸血鬼。那张订单表本来就有6个索引每次插入要更新7棵B树写入耗时反而成了瓶颈。我们在生产环境做过一次实验删掉两个冗余索引比如单独建的status_idx以及和已有复合索引前缀重复的create_time_idx写入性能提升了37%而查询性能几乎没变。调优的第一步永远是做减法把那些“可能有用”的索引砍掉而不是盲目加索引。连接池里的沉默杀手线程堆积如何吃掉所有资源慢查询只是导火索。真正让系统雪崩的是HikariCP连接池参数配置失误。默认maximumPoolSize是10但应用部署在8台机器上每台机器只有一个数据源所以整个集群对数据库的最大并发连接只有80。当一条SQL耗时2秒时80个连接全部被占满新的请求只能排队等待。等待超过30秒后客户端抛ConnectionTimeout异常而每一个超时异常都会触发一次新的重试重试又抢占新的连接形成完美的死循环。我们犯过的错误是盲目调大maximumPoolSize到200结果数据库负载更高因为数据库的线程上下文切换和锁竞争会随着连接数线性恶化最终导致雪崩。正确做法是设置合理的连接池大小公式是core_size TPS (p50_latency network_rtt)在我们的场景里TPS峰值是800p50是80ms算出来需要大约70个连接。但实际我们只配了50个因为真正的瓶颈不在连接数而在于慢查询本身。如果一条SQL要跑500ms给你500个连接也只是把数据库拖死得更快。调优连接池前必须先干掉慢查询否则参数调得再漂亮也是饮鸩止渴。另一个被忽略的坑是leak-detection-threshold。我们曾经遇到一个诡异的症状线上运行三天后连接池的活跃连接数缓慢爬升最终满池。排查下来是某次事务中有一个try-with-resources没有覆盖到数据库连接导致连接泄漏。HikariCP默认不开启泄漏检测我们加上leakDetectionThreshold: 60000后直接从日志里看到了泄漏点。连接池不仅是资源池更是你验证代码质量的放大镜——如果你不确定自己会不会忘关连接就开着泄漏检测上线。同步改异步从线程阻塞到事件风暴慢查询修掉了连接池参数调整了P99降到了150ms但有个新问题浮出水面每当大促流量峰值的瞬间接口延迟还是会出现毛刺最长跳到800ms。用async-profiler抓线程现场发现大量线程阻塞在Future.get()上。原来代码里有一段逻辑先调用订单服务再调用会员服务最后调用配送服务三者串行执行总耗时是三者之和。这里要引入一个性能调优的核心思想把串行改成并行用时间换空间再用线程换时间。我们使用CompletableFuture并行调用三个下游服务总耗时从abc变成了max(a,b,c)。实验数据原链路总耗时300ms并行后降到180ms。但并行也有代价——线程数从1变成3如果你的Tomcat线程池只有200个并行后实际并发容量从200降到了67。所以并行不是免费的午餐它只是把延迟从调用链上搬到了线程池里你必须同时扩大线程池或者使用异步非阻塞模型。更激进的一步是全链路异步化。我们把核心交易链路从Thread-per-request改为WebFlux但这引来了巨大的复杂性。说实话99%的项目根本不需要Reactor式的异步重构因为你的瓶颈不在I/O模型而在于你有太多无意义的同步等待。我们最后只在两个地方用了异步一是发短信和推送通知可以丢给MQ二是调用外部风控服务时做了超时熔断异步回调处理结果。其他业务逻辑全部保持同步因为同步代码可读性高维护成本低。异步是最后的武器不该是默认选项。GC调优为什么老年代一直涨你的对象死在哪当并发量上来以后GC变成了新的头号瓶颈。我们用的是G1收集器默认设置跑了一段时间发现Mixed GC频繁触发每次停顿约120ms而接口要求P99低于200ms一次GC停顿就让好几个请求超时了。用jstat观察FGC次数每小时高达30次老年代占用率一直波动在85%以上。这明显是对象晋升太快。排查思路分三步。第一步看对象分配速率用jmap -histo:live抓最活跃的对象发现有一个ArrayList的byte[]占了大头。顺着引用链找下去是我们在日志工具里把每次请求的完整请求体包括一个大JSON都放进了ThreadLocal用来做日志trace。请求结束后ThreadLocal没有清理下一轮请求又复用同一个线程导致这些大对象一直存活到线程池线程结束全进了老年代。ThreadLocal是内存泄漏的重灾区在高并发下尤其致命因为线程池线程是长期存活的。解决方案是在finally块里加上remove()并在过滤器末尾清理。修完这个FGC次数直接降为0。第二步调整G1的关键参数。我们把-XX:MaxGCPauseMillis从默认的200改成80但发现Mixed GC反而更频繁了因为G1为了追求停顿目标把堆分成更多region扫描更勤。这里有个反直觉的结论GC停顿目标设得越激进整体吞吐量反而越低。我们最终设置为100ms配合-XX:G1NewSizePercent20提高年轻代下限让短命对象在年轻代就被回收晋升率从12%降到4%。第三步是处理大对象。有一个报表功能会一次性加载10万行数据到内存虽然用了分页但每次返回一个巨大的List。我们把它改为流式查询每500行就输出一次到文件内存占用从1.2GB降到200MB。这里想强调一个基本原则GC调优的本质是减少对象的数量和存活时间而不是调JVM参数。参数只是治标对象生命周期管理才是治本。缓存分层让热点数据住在离CPU最近的地方解决完GC延迟仍有大量浪费在远程调用上。接口里每次查询都直连Redis虽然Redis很快0.5ms但当QPS到5000时网络I/O和序列化开销就成了主要成本。我们做了一个三级的缓存分层L1是Caffeine本地缓存L2是RedisL3是数据库。热点参数商品在前端每次请求时L1命中率能到85%平均延迟从3ms降到0.2ms。这里要特别小心缓存一致性。L1缓存是JVM本地的多个实例之间无法同步。我们的做法是当某个key被更新时不直接删本地缓存而是通过Redis Pub/Sub广播一个失效事件所有实例收到后删除本地缓存。这个方案在规模不大时足够好用但如果你有几十个实例Pub/Sub会变成压力源。更优雅的方式是使用Redis 6.x的客户端缓存功能服务端主动通知失效但原理类似。缓存穿透是另一个需要警惕的问题。我们曾遇到过恶意请求查询一个不存在的订单ID每次都会打到数据库。解决方案很简单在缓存里存空值或者用布隆过滤器前置拦截。我们用布隆过滤器把1000万个订单ID放进位数组误判率控制在1%以下。这样无效请求在第一步就被拦截数据库的QPS下降了一个数量级。缓存不是越多越好每个缓存层都要考虑它的失效、穿透、雪崩和容量成本否则你只是把问题从数据库搬到了另一个地方。压测与容量在流量进来之前先让系统死一次所有调优动作做完后我们进行了完整的压测。压测的目标不是看最大QPS能到多少而是找到系统的疲劳点和恢复能力。我们用wrk和locust混合压测逐步增加并发从100到200再到500记录每个阶段的延迟和错误率。一个惊喜的发现是当并发在300以下时P99稳定在150ms当并发到350时P99突然跳到600ms错误率上升。这个断崖点就是系统的临界容量。分析后发现断崖点来自Tomcat线程池满了。我们配置的maxThreads200由于每个请求都会调用一次外部短信服务默认该服务有超时重试3次每次超时5秒导致一个请求最长占用线程15秒。你永远不要低估一个下游服务的超时重试对线程池的杀伤力。我们把短信服务改成异步MQ消息线程释放后临界点从350升到600。然后我们利用Semaphore对短信发送做信号量限流避免消息积压时消费线程无限增长。另一个重要指标是恢复能力。我们故意让某个下游服务挂掉30秒再恢复观察系统行为。正确的表现是失败请求快速失败通过fallback返回默认结果而健康请求不受影响。我们的熔断器用的是Resilience4j配置了failureRateThreshold50%、waitDurationInOpenState10s。测试中发现熔断开启后系统吞吐量下降了20%但这20%的牺牲保住了剩下80%的请求。在性能调优的字典里优雅降级比追求每一个请求的完美成功更重要。最后我们用Java Flight Recorder做了一次生产环境的采样发现有一个线程在ConcurrentHashMap.computeIfAbsent上发生了长时间阻塞原因是该Map的某个桶链长度达到60。这个场景是多个线程并发更新同一个key导致CAS碰撞。我们改用LongAdder做计数器或把这个热点key拆分成16个分片的key冲突才消失。高并发下JDK自带的数据结构不一定安全高效你要知道它们的适用边界比如computeIfAbsent是锁粒度为桶级但桶冲突到一定程度时不如用putIfAbsent加循环重试。从慢查询到高并发这条路上没有银弹。每一个优化点背后都是对数据访问模式、资源管理和系统吞吐量的深刻理解。性能调优的每一个行动都必须以数据为依归以测量为前提。你不需要记住所有命令但你必须养成一个习惯任何改动之前先写压测脚本任何改动之后先看监控指标。慢查询是表层的病征索引失效是肌肉拉伤连接池崩溃是骨折而GC抖动则是内脏出血一次完整的调优是一场从外到里的全身体检。最终系统在你手上会变得皮实、有弹性——它会坦然面对流量洪峰也会在故障后优雅自愈。这才是调优的终极目标不是把某个接口调到极致而是让整个系统具备持续交付和应对未知的能力。那一天的告警群里那位开发同学后来在复盘文档里写道“我们不是战胜了慢查询而是学会了如何倾听系统的呼吸。”这句话值得每一个Java开发者刻在键盘上。