
深夜十一点你的接口响应时间从50ms飙到800ms监控面板上的红线像心电图一样刺眼。你翻遍日志发现既没有报错也没有慢SQL可用户就是反馈“转圈圈”。别急着怪服务器SpringBoot接口的每一次“慢半拍”背后都藏着一个你亲手埋下的雷。你以为的慢其实是连接池在“堵车”大多数SpringBoot项目默认使用HikariCP它确实快但快不等于无限。当你的接口同时被300个线程调用而连接池最大连接数只有10时剩下的290个请求只能排队等待。连接池不是越大越好但更致命的是你从不监控连接池的活跃数。我见过一个项目数据库连接池被一个定时任务占满结果所有业务接口集体超时。日志里没有异常只有一堆“connection is not available”的警告你还以为是网络抖动。检查你的HikariCP配置maximum-pool-size是否合理connection-timeout是否设成了默认的30秒如果你的接口平均执行时间200ms连接池最多只能支撑50并发超过这个数排队时间就会指数级上升。建议用arthas或者actuator实时查看活跃连接数如果经常打满就该优化SQL或者加索引而不是盲目扩池子。事务边界越长锁等待越久很多开发喜欢在Service层加Transactional一加就是整个方法。你想想一个方法里先查用户再查订单再调远程接口最后更新库存整个过程都握着一个数据库事务。事务意味着锁锁意味着阻塞。当两个请求同时更新同一条数据时后到的那个必须等前一个提交而你的“前一个”正在等待一个慢速的第三方API返回。这不是接口慢这是你的业务逻辑把数据库锁成了串行执行。正确的做法是让事务短到只包含必要的数据库写操作。查询不需要事务远程调用绝不能包在事务里。把事务拆成“读-计算-写”三段如果你用的是Spring用TransactionTemplate手动控制边界比注解更灵活。有一次我把一个“必现超时3秒”的接口救回来只是把远程调用移出了事务耗时直接从3秒降到40ms——你以为的慢接口其实是事务在替别人背锅。懒加载不是银弹它可能让你多查一百次JPA的懒加载是个温柔的陷阱。你在Service里查出一个列表返回给View层时只要触发一次getOrder()Hibernate就悄悄发一条SQL。如果你的列表有100条记录这就是100次查询。N1问题是最隐蔽的慢它不会让某一次查询变慢但会让整体响应时间乘以N。你以为数据库压力不大实际上每秒钟有几千条看似无害的小查询在悄悄打满数据库。对付N1第一是在需要关联数据时显式使用join fetch或EntityGraph提前把需要的数据一次性查出来。第二是禁止在实体上使用“万能查询”方法比如findAll这种。没有银弹只有你对自己数据访问路径的完全掌控。写代码时多问一句这里会触发几次SQL如果心里没数建议开启log4jdbc或者Hibernate的show_sql看看真实执行次数保证你吓一跳。序列化慢才是被忽视的时间杀手你的接口返回JSONSpringBoot默认用Jackson。Jackson够快但如果你在实体类里用了大量JsonFormat、自定义序列化器或者干脆把Object类型当返回值那就等着性能崩吧。最慢的序列化是反射加上无意义的深拷贝。我见过有人把整个数据库表结构映射的实体直接返回给前端里面包含几十个字段其中一半是byte[]或者是LargeObject。前端只需要三个字段你序列化了三百个。优化建议DTO就是用来隔离内部实体的武器别偷懒直接用实体当返回对象。用record或者手写轻量DTO只保留需要的字段。如果接口响应体超过1MB检查是不是序列化了大字段。另外如果你还在用Fastjson这种性能不稳定的库趁早换掉Jackson或者Gson在Spring生态里足够好。序列化耗时往往被忽略但当你接口返回10MB数据时序列化占掉的CPU时间比数据库查询还多。线程池排队你以为的并发其实是排队SpringBoot的接口默认跑在Tomcat线程池里默认200个线程。当你的某个接口调用了外部API而这个外部API平均耗时3秒那么这3秒内Tomcat线程就被占着。一个慢外部依赖就能拖垮整个Web应用。你不信做个小实验用for循环并发发100个请求每个请求内部Thread.sleep(2000)你会发现后面的请求排队时间越来越长。这不是Spring的锅这是线程池的经典问题。对策很清晰所有外部调用必须设置超时时间比如用RestTemplate等配置connectTimeout和readTimeout。其次使用异步调用或线程隔离比如把外部API调用丢给单独的线程池执行主线程用CompletableFuture控制超时。更狠一点用熔断器Resilience4j或者Sentinel当外部接口故障率超过阈值直接快速失败而不是让所有线程死等。GC停顿让接口的尾巴变得又长又粗JVM垃圾回收是另一个隐藏的慢。你的接口平时很快但每隔几分钟就有一个“毛刺”这就是GC在作祟。尤其是CMS或G1的Full GC停顿时间可能长达几百毫秒到几秒。如果你的老年代持续增长说明有对象在泄漏或者缓存设计不合理。如何判断用jstat -gcutil看FGC次数和时间如果FGC频繁先检查是否有大对象比如把整个Excel文件读进byte[]再检查静态Map是不是无限增长。最容易引发GC性能问题的不是业务代码而是日志——你用log.info打印了一个超大的对象比如整个请求体或者响应体这会瞬间产生大量垃圾对象。生产环境日志级别别用DEBUG别打大对象日志越安静接口越快。Redis缓存用了也是白用缓存能救缓接口但缓存用错了反而更慢。比如你用一个StringRedisTemplate存JSON每次读取都做序列化反序列化。如果缓存命中率低于50%你的Redis就是浪费连接资源。更大的问题在于缓存穿透一个恶意用户故意查询一个不存在的ID每次绕过缓存直接打数据库数据库瞬间就瘫了。缓存穿透不是慢是雪崩的前奏。解决方案对空值也做缓存缓存null值或者用布隆过滤器把不存在的ID提前拦掉。还有缓存雪崩大量key在同一时间过期瞬间所有请求打到数据库。解决办法是给过期时间加随机抖动比如3600 Random.nextInt(600)。记住缓存永远只是加速器不是兜底方案。如果你的接口慢先确认缓存是否真的在“挡子弹”如果缓存命中率居高不下那就该优化业务逻辑了。索引失效——你建的索引是假的SQL慢查询有时不是没索引而是索引失效。比如你在where条件里写了date_format(create_time,%Y-%m-%d) 2024-01-01这会让索引完全失效。对字段做函数运算数据库就抛弃你的索引全表扫描。还有隐式类型转换phone 13812345678而phone是varcharMySQL会隐式转换索引照样失效。让你明白一个残酷的事实不是所有where条件都适合建索引但一旦建立了索引就要确保能用上。用explain看执行计划如果type是ALL说明全表扫了。优化字符集不一致的关联查询比如一张表utf8一张表utf8mb4也会导致索引失效。慢SQL优化是个无底洞但最基本的别在索引列上做计算别让类型不匹配。日志IO——你每打一条log接口就慢一微秒在高并发下日志写入磁盘是同步的操作。如果你用log4j2默认配置每个接口打印几十行日志每行都执行一次IO磁盘跟不上你的线程就阻塞在写日志上。日志不是业务代码但它的消耗不亚于一次数据库查询。我用perf分析过某个慢接口发现CPU有30%花在日志格式化上。改进策略生产环境改为异步日志AsyncAppender并且日志级别调成WARN。除非排查问题否则别在业务代码里打印循环体中的debug信息。另外不要在日志里拼接字符串用{}占位符让框架懒处理。日志是给救命用的不是给刷屏用的。真正能救你的是结构化日志和慢请求日志而不是几百条“进入service了”“退出service了”。压测不可靠真相藏在调用链里你是不是只在本地用Postman转发几次就宣布“接口不慢”你测的是响应速度不是吞吐极限。真正的性能问题必须用压测暴露——用wrk或者JMeter以100并发持续压5分钟然后看TP99。TP99才是用户的真实体验平均响应时间会欺骗你。如果你的TP99是2秒而平均只有200ms说明有1%的请求在拖后腿往往是GC停顿、连接池排队、或者外部依赖抖动。这时候打开分布式链路追踪看每个Span的耗时分布。慢在哪一步一目了然。别再用“肯定”来猜用数据说话。如果你们没有全链路追踪现在就上SkyWalking这是最快定位接口慢的手段没有之一。别让“慢半拍”成为你的天花板SpringBoot本身只是农夫山泉——它提供机制不提供性能保证。你的接口慢百分之八十是编码习惯和环境配置造成的。从连接池、事务边界、序列化、到GC、日志、缓存、索引每个细节都可能让接口从“快”变成“慢半拍”。最快的优化不是加机器而是砍掉多余的阻塞。改掉一个坏习惯可能省下一台服务器。现在去检查你的代码里有没有上面任何一个坑改完再测一次TP99你会回来感谢我的。