ARTICLE DETAIL

资讯详情

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

Spring Boot性能优化实战:从连接池到缓存实现500%提速

Spring Boot性能优化实战:从连接池到缓存实现500%提速 1. 先看真相Spring Boot慢在哪儿500%的优化空间从哪来我接过一个真实的项目一个基于Spring Boot MyBatis的多商户商城后端线上接口平均响应时间800ms高峰期CPU飙升到90%首页商品接口甚至要3秒才能出数据。老板给的期限是两周目标很直接把核心接口压进200ms以内。当时团队里有人提议换语言、换框架、上微服务我全给否了。原因很简单绝大多数Spring Boot应用没那么脆弱性能上不去通常不是框架本身的锅而是开箱即用直接上线留下的债。把连接池、JVM参数、缓存策略和SQL建模这四层逐一拾掇干净别说500%某些场景下我从3秒优化到200ms整整15倍的提升都见过。1.1 95%的Spring Boot慢都慢在它没上场之前很多人一听Spring Boot性能提升第一反应是改框架配置、换Web容器、加异步注解。但实际上一个典型的慢接口时间基本都耗在四处地方。第一是数据库。一个业务接口背后往往挂着三五个SQL每个SQL全表扫描扫几十万行五个SQL加起来可能吃掉响应时间的70%以上。尤其用了MyBatis的项目XML里写着一堆select *查回来还要在内存里做关联、过滤接口能不慢吗。第二是连接池。默认的HikariCP配置很保守最大连接数10连接超时30秒。一旦并发一上来线程全堵在等连接上CPU利用率反而不高但响应时间直线上升。这个场景我见过太多次了。第三是JVM和容器。默认的堆内存大小、默认的CMS或G1参数、默认的Tomcat线程池都是安全配置不是高性能配置。一个8核16G的机器跑一个Spring Boot应用只用默认参数等于开跑车挂一档。第四是缓存缺失。同一个热点数据每次请求都去查库明明数据一天都不变却让数据库扛了上百万次无意义的查询。所以当有人问我Spring Boot性能提升500%到底怎么做到我的回答从来都是不是做一个惊天动地的改动也不是换个更快的框架而是把上面这四层里每一层都做对。1.2 性能基线先行没有数据的优化都是耍流氓接手优化工作的第一步永远是先跑压测把基线打出来。没有基线你根本不知道优化有没有效果方向对不对很容易陷入感觉快了一点的错觉里。我的做法是先在测试环境部署一套和线上配置一致的包然后挑出核心链路里的三到五个接口用压测工具去打。工具方面我个人推荐JMeter和wrk二选一。JMeter适合做复杂场景编排、断言和聚合报告wrk适合快速压测单个HTTP接口性能高命令行一条就能跑。压测的时候指标我最看重四个TP9999%的请求都在这个时间以内这是用户体验的真实感受平均响应时间很容易被少数慢请求拉高反而失真。QPS系统每秒能处理的请求数体现吞吐能力。错误率压测过程中5xx、超时请求占比必须为0或者无限接近0。CPU和GC压测过程中观察CPU是否吃满GC频率和停顿时间是否异常。跑完一轮把基线数据记录下来。比如我当时记录的基线是商品详情接口TP99为2400msQPS 85错误率0.2%购物车接口TP99为1800ms。有了这几个数字后面的每一步优化都能拿出对照数据说话。再说一句扎心的如果你的应用在压测一开始就出现大量超时和连接拒绝先别急着调优大概率是连接池和线程池本身就撑不住这个并发量后面我会专门讲。2. 数据库连接与MyBatis层最容易被忽视的隐性瓶颈2.1 连接池配置为什么是第一个要动的手术HikariCP是Spring Boot的默认连接池它的代码质量很高但默认参数是为不出错设计的不是为高性能设计的。默认最大连接数10对大多数业务系统来说这个数字在并发稍微上来一点的时候就成了瓶颈。有个朴素的道理很多人没想明白数据库连接数不是越多越好。每一个连接背后都是一个数据库服务端的线程连接数过大数据库光维护连接上下文就要消耗大量CPU和内存反而拖垮整体性能。所以调优的目标是找到一个既能扛住峰值并发又不让数据库过载的平衡点。我常用的估算方法是单条SQL平均执行时间乘以峰值QPS再除以数据库单连接可承受的并发能力。举个例子一个接口峰值QPS是500单请求背后平均有3条SQL每条SQL平均耗时5ms那么需要的连接数大约是500乘以3乘以0.005算下来7.5留出20%的缓冲配10到12就够。我的生产配置一般长这样spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 pool-name: MyAppHikariCP这里connection-timeout我特意调到了3000ms也就是3秒。很多人不理解为啥调这么短其实这是故意快速失败。如果连接池被占满且排队超过3秒与其让用户傻等10秒然后超时不如尽早报错让调用方做降级处理。这也是性能优化里一个很重要的思路——快速失败好过缓慢死亡。2.2 慢SQL与大结果集一次查询拖垮整个接口的典型现场连接池调好了接下来就要收拾SQL。我见过最离谱的一条慢SQL是一个订单列表接口MyBatis的XML里写了个select * from orders where user_id #{id}没有索引订单表600万行每次执行1.8秒。就这一条SQL直接把整个接口的TP99干到了2秒以上。MyBatis项目排查慢SQL我建议按这个链路走第一开启慢SQL日志。在application.yml里配上mybatis: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl然后在日志配置里把com.yourproject.mapper这个包的日志级别调到DEBUG或者直接用p6spy这类SQL日志组件能实时看到每条SQL的执行耗时和完整参数。第二拿到慢SQL之后到数据库里执行EXPLAIN看type这一列。如果是ALL说明全表扫描如果是index说明扫了整棵索引树通常也意味着索引设计不合理如果是ref或const基本就是正常的索引命中查询。第三按EXPLAIN的结果去建索引然后重新压测对比执行时间。慢SQL之外还有一个隐蔽问题大结果集。select *返回全字段甚至把TEXT、BLOB这类大字段也查出来传输时间、内存占用都上来了。我的习惯是接口需要什么字段就查什么字段永远不用select *。这个习惯在优化阶段能省下很多事在业务初期就该养成。2.3 MyBatis懒加载、批量操作与一级缓存实用策略MyBatis的一级缓存是SqlSession级别的默认开启但Spring整合之后每次请求都会新建SqlSession一级缓存基本形同虚设指望它优化性能不现实。二级缓存我一般建议关闭尤其在多商户这类并发写频繁的业务系统里缓存失效问题处理不好会带来脏数据属于负优化。真正能提升性能的是批量操作。拿多商户商城来说批量插入商品SKU时千万别在Java代码里for循环一条条insert那会产生N次数据库往返。正确做法是在Mapper里写批量插入insert idbatchInsertSkus INSERT INTO sku (product_id, name, price, stock) VALUES foreach collectionlist itemitem separator, (#{item.productId}, #{item.name}, #{item.price}, #{item.stock}) /foreach /insert一条SQL把所有数据插进去性能差距可以达到一个数量级。MyBatis还有ExecutorType.BATCH可以在Spring配置里开适合大批量写入场景但要注意批量提交时的内存占用和事务边界。关联查询这块我强烈建议在XML里把不必要的association和collection懒加载关掉能一次JOIN查出来的就别嵌套查询。MyBatis的嵌套查询每多一层就是一个额外的SQL往返。接口响应时间就是这么一分一秒累积起来的。3. JVM与容器用参数把硬件能力真正释放出来3.1 堆内存与GC选择给应用一个适配生命周期的内存模型Spring Boot应用的JVM参数很多人都是直接复制网上一份通用推荐配置粘上去根本不理解为什么这么配。我要说的是JVM参数没有万能配方但有几个原则是通用的。首先堆内存不要随便设一个巨大值。堆设得太大GC一次Full GC的时间就长停顿就明显堆太小频繁触发GCCPU都在做无用功。一个4核8G的机器部署Spring Boot单体应用我建议堆内存稳定在2G到3G之间-Xms和-Xmx设为相同值避免运行期动态扩容带来的性能抖动。java -Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis100 -jar app.jarGC选择上JDK 8之后的Spring Boot应用我统一推荐G1。它把堆分成多个Region优先回收垃圾最多的区域停顿可控。MaxGCPauseMillis设到100配合G1NewSizePercent和G1MaxNewSizePercent调整新生代大小能让GC停顿控制在百毫秒以内。有一个参数特别容易被忽略-XX:StringTableSize。字符串常量表默认大小是60013如果应用里生成了大量动态字符串比如分布式链路追踪的traceId、用户Token这个表会频繁扩容产生大量Hash碰撞。我一般调到100万以上对高并发系统效果很明显。3.2 Tomcat与Undertow线程模型、并发参数与端口配置常见误区Spring Boot默认内嵌Tomcat它的线程池参数直接决定请求排队情况。默认配置max-threads200、accept-count100在突发流量下容易造成请求大量堆积响应时间飙升。我调Tomcat线程池时喜欢按这个思路来先确认应用本身是CPU密集型还是IO密集型。对于常见的数据库读写型业务接口属于IO密集型线程数可以适当调大但也不是越大越好。线程太多上下文切换开销会把性能吃干净。server: port: 8080 tomcat: threads: max: 400 min-spare: 40 accept-count: 200 max-connections: 20000 connection-timeout: 5000这里max-connections我特意调成20000这是Tomcat能同时接受的TCP连接数。在高并发场景下真正限制吞吐的不是线程数而是连接排队策略。如果accept-count太小超出的连接直接拒绝客户端会收到Connection Refused。端口配置也有讲究。默认8080容易被人扫到我用Spring Boot时经常改成其他端口比如server.port8081或者--server.port9999。改端口可以用配置也可以用启动参数VSCode开发Spring Boot时想在调试配置里覆盖端口直接给启动类加上--server.port9999就行。3.3 一些不用改代码的启动参数优化清单下面这份清单是我在实际项目里经常用的全部来自线上验证可以直接抄作业java -server \ -Xms2g -Xmx2g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:InitiatingHeapOccupancyPercent45 \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/jvm/ \ -XX:ErrorFile/data/logs/jvm/hs_err_pid%p.log \ -Djava.awt.headlesstrue \ -jar app.jar --server.port8081InitiatingHeapOccupancyPercent设为45意思是有45%老空间占用时就开始启动并发标记比默认的45稍低或持平让GC更早介入避免老空间满了紧急Full GC。HeapDumpOnOutOfMemoryError这个参数非常建议加上它能在OOM时自动dump堆快照。没有这个线上内存溢出了只能干瞪眼有了它就能用MAT分析内存对象分布精准定位查询中是否有大对象、缓存是否膨胀。我接手过的项目里至少有一半的性能事故靠这个参数找到了根因。-Djava.awt.headlesstrue主要针对依赖图片处理、生成验证码的Spring Boot应用没有实际显示设备的情况下避免因为图形环境问题崩溃。4. 缓存层实现500%提速的最短路径4.1 本地缓存与Redis缓存的分工逻辑性能优化做到这一步如果数据库和JVM都收拾干净了响应时间可能从800ms降到了300ms。想再往前走一大步就必须上缓存。说实话缓存是核武器这个说法最名正言顺的部分——一个设计得当的缓存策略比前面所有调优加起来都快。缓存我先分两类说清楚。第一类是本地缓存比如Caffeine它在应用进程内直接存数据读取速度是纳秒级的没有任何网络开销。适合存那些每个节点自己很确定的数据比如系统配置、数据字典、网关权限元数据。第二类是Redis跨进程共享适合存多实例需要统一访问的数据比如用户会话信息、热点商品详情。很多人一开始就把所有数据全塞进Redis这是不对的。Redis再快也有网络IO单次读取大概是0.5ms到1ms而Caffeine本地缓存读取是几十纳秒差了三个数量级。正确做法是两级缓存先查Caffeine没命中再查RedisRedis也没有才查数据库。4.2 热点数据缓存改造实战以一个多商户商城商品接口为例拿我之前优化的多商户商城举例。商品详情接口一个月内同一个SKU的信息基本不变但每天要被刷几十万次。原始方案是每次请求查MySQL不仅慢还把数据库的连接和IO吃满了。改造方案三步走第一步引入Caffeine本地缓存设置过期时间和最大条目数Configuration public class CacheConfig { Bean public CacheString, ProductVO productLocalCache() { return Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(10_000) .build(); } }第二步引入Redis作为二级缓存用Spring Cache抽象或手动封装都行看团队习惯。手动封装的好处是可选控制锁、可精细控制序列化方式我更喜欢手动。第三步查询逻辑改成先本地、再Redis、最后数据库数据库查回来之后回填两级缓存并设置过期时间。改完之后TP99从2400ms降到了120ms。压测QPS从85涨到了400多将近5倍。这就是标题里速度提升500%最直接的来源。4.3 缓存一致性缓存穿透、击穿、雪崩的防护细节缓存上线只是开始一致性才是真正的考验。我在商城项目中踩过三个典型的坑想给大家提个醒。第一个是缓存穿透。用户疯狂刷一个不存在的商品ID缓存没有数据库也没有每次都穿透到数据库。防护手段是布隆过滤器或者缓存空值。缓存空值简单实用查不到数据时往Redis里放一个null值过期时间设短一点比如60秒。短时间内相同请求就不会压到数据库。第二个是缓存击穿。某个热点key在过期瞬间被大量请求同时打到数据库数据库瞬间被击穿。这个场景我在双十一大促演练时遇到过解决方式是互斥锁加逻辑过期。把请求数据库的代码包在分布式锁里只有一个线程去查库其他线程等待然后直接从缓存取。第三个是缓存雪崩。大量key同时过期或者Redis整个节点宕了请求全部打到数据库。解决办法是设置过期时间时加一个随机偏移量让过期时间分布开避免同一秒集体失效。Redis集群方面主从加哨兵或Cluster是基础的不能跳过。还有一个很关键的实战经验更新数据库后缓存怎么办。我的原则是先更新数据库再删除缓存而不是先更新缓存。原因是并发下先写缓存容易产生脏数据删除缓存则简单可靠下次读取时自然重新加载。5. 索引与SQL建模别再让数据库全表扫描5.1 复合索引的创建逻辑与最左前缀原则缓存能扛掉80%的热点读流量但剩下的20%读请求以及所有写请求还是要数据库自己扛。这时候索引的设计就成了最后一道大闸。我见过太多项目索引乱建一张表几十个单列索引SQL一执行一个都用不上。复合索引是这里面的重点。以商城订单表为例常见的查询场景是查某个商户在某段时间内的已支付订单SQL大概长这样SELECT * FROM orders WHERE merchant_id ? AND status PAID AND create_time BETWEEN ? AND ?这种查询适合建一个复合索引ALTER TABLE orders ADD INDEX idx_merchant_status_time (merchant_id, status, create_time);索引的字段顺序有讲究基本原则是等值查询的字段放前面范围查询的字段放后面。因为范围查询一旦用上后面的索引字段就没办法走索引了这是一个核心的知识点。merchant_id是等值条件放最前面status也是等值条件放中间create_time是范围条件放最后。这样三个条件都能充分利用索引的快速定位能力。最左前缀原则也必须牢记。复合索引(a, b, c)能命中a、a,b、a,b,c三种查询组合但是直接查b或者b,c是走不了索引的。所以建复合索引前要统计业务里最常见的查询条件组合把最常用的等值字段排最左。5.2 深分页性能修复从limit offset到游标分页分页性能问题在数据量上来之后会变得非常刺眼。很多系统的订单列表、商品列表用的是LIMIT offset, size当用户翻到第100页时offset是990MySQL要扫描前面990条记录然后扔掉越翻越慢。数据量10万以内还好上了百万级就会拖垮整个接口。我的项目中就遇到过这个问题。商户后台翻看历史订单翻到80多页的时候接口响应时间直接超5秒。排查后发现是深分页。修复方案是改成游标分页也就是基于索引的翻页逻辑。前端传的不是页码而是上一页最后一条记录的create_time或id。SQL变成SELECT * FROM orders WHERE merchant_id ? AND status PAID AND id #{lastId} ORDER BY id DESC LIMIT 20;这样每次都只扫描20条记录永远不会出现深分页问题。缺点是前端状态逻辑要改没法直接跳转到任意页。这个取舍在实际业务中要权衡很多系统其实根本不需要跳页微信小程序的下拉加载到底就是典型的游标分页形态。5.3 用EXPLAIN拆解一条慢查询的完整过程慢SQL日志里找到一条耗时1.8秒的查询最简单直接的诊断工具是EXPLAIN。我来演示一个完整的拆解过程。原始查询是SELECT * FROM order_items WHERE order_id 10086 AND seller_id 500 ORDER BY created_at DESC;执行EXPLAIN后关键列如下列名值分析typeALL全表扫描possible_keysidx_order_id理论上可用索引keyNULL实际没用到任何索引rows5800000扫描了580万行ExtraUsing filesort还要做文件排序order_id明明有索引为什么没走原因有两个。当时表里50万单量但order_id选择性并不高一个订单下可能几百条子订单更关键的是查询条件里还有seller_id单列索引idx_order_id扫描回表成本太高优化器直接把索引放弃了。修复方法是建一个综合索引ALTER TABLE order_items ADD INDEX idx_seller_order_time (seller_id, order_id, created_at);联合查询包含排序字段这样ORDER BY created_at也可以走索引而不用filesort。优化后同一查询耗时从1.8秒降到15ms这个提升不比缓存层低。6. 压测验证与持续监控提速500%之后的复盘6.1 用压测数据说话优化前后对比报告怎么做优化做得再漂亮没有数据支撑老板和同行都不会认可。我每次性能优化项目收尾都要老老实实做一份对比报告。报告的模板基本固定不同项目换个参数和数据就能用。压测环境保持完全一致同一台测试机、同一份数据量、同一套压测脚本、相同并发数。否则数据没有可比性报告里就要注明测试环境差异。对比报告我按三个维度组织指标优化前优化后提升幅度商品详情TP992400ms120ms20倍商品详情QPS854204.9倍订单页P951800ms250ms7.2倍平均GC停顿180ms45ms4倍CPU峰值占用90%55%39%报告看完之后一定要给出每一项优化到底解决了什么问题的说明。连接池调整解决并发连接排队缓存解决热点数据重复查询索引解决深分页和全表扫描JVM参数解决GC瓶颈。每一步优化都能对上成绩这样报告才有说服力。6.2 上线后的监控指标与性能回归预警优化完了不是终点上线后必须把监控补齐否则哪天性能回退了你都发现不了。我习惯盯的指标有五类QPS、RT响应时间、错误率、GC频率和耗时、数据库连接数。监控工具我推荐本地部署Prometheus Grafana或者直接用云厂商的APM服务。Spring Boot应用接入监控很简单引入micrometer-registry-prometheus依赖暴露/actuator/prometheus端点Prometheus定期抓取即可。这套方案我在VSCode里本地跑通过一次配置不算复杂。预警规则我一般这么设TP99超过基线1.5倍持续5分钟就算预警错误率超过0.5%需要立刻处理垃圾回收Full GC次数超过每小时1次就需要查内存。有了这些预警性能问题是能在用户投诉之前就被发现的。6.3 一次完整性能优化周期的时间安排与团队协作总结一下完整执行流程。我做的优化一般按两周安排前三天做基线压测和瓶颈定位中间五天做代码与配置改造重点覆盖连接池、SQL、缓存三层后面四天做迭代压测和参数微调最后两天整理复盘报告和监控配置。团队分工上如果人少就一个人负责全栈排查与改造核心依赖DBA帮忙审SQL和索引人多就拆两条线后端人员负责JVM、缓存与Tomcat数据人员负责SQL、索引与连接池。最忌讳的是大家各改各的改完不做整体压测结果把性能从一个瓶颈挪到了另一个瓶颈。我最后想说的是性能优化没有一劳永逸。业务在涨数据在涨代码在变今天调好的参数可能三个月后就不合适了。但只要你把基线—定位—优化—验证—监控这个循环建立起来每一次优化都不靠猜你的系统就能一直跑在舒适区里。
返回列表