ARTICLE DETAIL

资讯详情

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

Spring Boot 3.x接口性能优化实战:从500ms到50ms

Spring Boot 3.x接口性能优化实战:从500ms到50ms 我先给自己定个调这不是一篇讲“Spring Boot 性能优化技巧合集”的水文而是一份完整的、从头到尾的案例记录。项目背景很简单——一个商品聚合详情接口单次响应从 500ms 压到 50ms十倍的性能提升。整个过程涉及到 JVM 参数调整、数据库索引重构、缓存策略选定、并发模型切换以及 Spring Boot 3.x 特有的虚拟线程和 GraalVM 相关尝试。这篇文章会完整还原当时遇到的问题、定位思路、每一步的改动理由和最终效果也会把中间踩过的坑单独列出来。1. 项目现状与性能瓶颈定位1.1 业务背景一个“聚合”出来的接口老朋友公司的电商后台有个商品详情页接口前端一次请求要拿到商品基础信息、实时库存、销量、优惠券可用状态、评价摘要这五类数据。不是什么高并发的 C 端大流量接口但业务方反馈说“页面转圈超过半秒”体验不好。于是项目目标是把这个接口的 P99 响应时间从 500ms 左右优化到 50ms 以内。这个接口的原始实现我已经看过代码了问题很明显五个数据来源全部在 Controller 层串行调用每个子调用各自查一次库或者调一次 RPC而且没有任何缓存。算下来商品基础信息 80ms库存服务 RPC 120ms销量统计数据库聚合查询 150ms优惠券服务 90ms评价摘要 60ms——正好凑够 500ms 左右。先说明一点性能优化这件事一定是在“真实流量模型”下谈才有意义。这里是典型的后台管理系统接口读多写少高峰期 QPS 也就几百不存在极端并发下的数据一致性难题所以优化的空间和策略都可以大胆一些。1.2 瓶颈拆解优化前必须做的 3 个测量拿到这个需求之后我没有直接动手改代码。你要优化一个接口第一件事不是凭感觉去猜“哪里慢”而是做三个测量链路追踪数据确认这 500ms 到底花在哪个环节。公司用的 SkyWalking直接能看到每个 Span 的耗时分布。上面那五个子服务的耗时就是这么拆出来的。数据库慢查询日志开启 MySQL 的slow_query_log阈值设为 1 秒跑了半小时就抓到了好几条和销量统计相关的全表扫描 SQL。JVM 与 GC 日志确认有没有频繁的 Full GC 或者锁竞争导致的线程阻塞。这个接口当时用的是 JDK 17 Spring Boot 3.x Tomcat 默认配置GC 并不是主要问题。做完这三步结论很清楚耗时分布极其均匀没有单个“毒点”但叠加效应明显。这就是最典型的“温水煮青蛙”式性能问题——每个环节都只慢一点点合起来就不可接受了。1.3 优化目标设定50ms 是怎么算出来的接口响应时间从 500ms 优化到 50ms不是拍脑袋定的指标而是基于“前端可感知的即时响应”底线来算的。用户体验研究中100ms 以内是“无缝操作”的感知阈值50ms 是一个比较安全的工程余量。另外要留一点余量给网络传输和设备性能损耗。后端接口如果稳定在 50ms 以内那前端用户从点击到看到内容大概在 100ms 左右这已经是一个相当流畅的体验了。2. Spring Boot 3.x 下的优化基础环境2.1 为什么前置条件是 Spring Boot 3.x 而不是 2.x这个项目既然标题里写了 Spring Boot 3.x我有必要先解释一下为什么要基于 3.x 来做优化以及它提供了哪些 2.x 没有的性能底子。JDK 17 起步Spring Boot 3.x 强制要求 JDK 17这意味着默认开启 G1 垃圾回收器同时 JIT 编译器对现代硬件做了更多适配。相比 JDK 8JDK 17 在字符串处理、集合类操作、锁优化方面都有明显提升。Servlet 与 Reactive 双路支持Spring Boot 3.x 底层是 Spring Framework 6它对于异步编程模型WebFlux和传统 Servlet 栈Spring MVC都保持支持。优化的时候可以根据接口实际情况选择线程模型。虚拟线程Project Loom这是 JDK 21 正式引入的能力但 Spring Boot 3.x 的某些早期版本已经可以开启虚拟线程支持。共享线程池的切换对 IO 密集型的聚合接口来说是个“大杀器”。GraalVM Native Image 支持启动速度提升明显但这个项目的场景是长驻服务不太关心启动速度所以这个特性只是了解了一下并没有真正用上。后面会单独说明为什么没选。2.2 基础 JVM 参数优化不折腾 GC但要管住线程池很多团队拿到 Spring Boot 项目第一反应是调 JVM 的堆内存和 GC 参数。这个项目我反而不建议做太激进的 GC 优化因为 G1 在小堆场景下表现已经不错重点应该放在线程池配置上。Tomcat 默认最大线程数是 200acceptCount是 100。这个接口是 IO 密集型如果每个请求里面有 5 次串行子调用一个请求占用的线程时间就是 5 倍。优化前 500ms 响应意味着每个 Tomcat 线程在高峰期被占用的时间很长线程池很容易被打满。这里其实藏着一个关键矛盾表面看是接口响应慢本质是线程资源周转率太低。所以在动手改业务代码之前我先把容器的线程模型调整了server: tomcat: threads: max: 300 min-spare: 50 accept-count: 200 max-connections: 10000 connection-timeout: 3000调大线程池和连接数不是万能的这里配合下面的异步化改造才有意义。如果只有串行调用你就算把线程池调到 1000 也没用一个请求还是占着线程等那 500ms。注意如果后面改用虚拟线程这个配置就得反过来调小甚至直接不用调整。虚拟线程的本质是“线程即协程”不再受限于操作系统线程数量。3. 数据库层优化从 150ms 到 10ms 的关键战役3.1 SQL 分析与索引重构前面测量到销量统计这个子查询耗时 150msDB 慢查询日志印证了我的猜测sales_statistics表按goods_id查询实时销量但是这张表的索引只有主键业务字段上挂的唯一索引是(goods_id, sale_date)的组合索引。问题在于 SQL 写法SELECT goods_id, SUM(sale_count) as total_sale, COUNT(DISTINCT user_id) as sale_user_count FROM sales_statistics WHERE goods_id ? AND sale_date DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY goods_id;虽然索引命中在(goods_id, sale_date)上但是COUNT(DISTINCT user_id)会触发大量的索引回表和临时表排序。150ms 的耗时主要花在user_id的去重统计上。这里分两步优化第一步改造 SQL 结构。把sale_user_count购买人数从实时计算改成异步汇总。实时接口只查SUM(sale_count)购买人数的统计交给定时任务每 5 分钟跑一次。这一步直接把 SQL 从“聚合去重”降级成了“简单过滤累加”耗时从 150ms 直降到 40ms 左右。第二步优化索引。既然 SQL 已经简化为SELECT SUM(sale_count) FROM sales_statistics WHERE goods_id ? AND sale_date ?给这张表建了一个覆盖索引ALTER TABLE sales_statistics ADD INDEX idx_goods_date_count (goods_id, sale_date, sale_count);这一步利用了索引覆盖扫描连回表都省了。查询耗时继续降低到 15ms 左右。经验先把 SQL 写对再谈索引。很多人反着来先加了一堆索引然后再优化 SQL结果索引冗余严重写入性能被拖累不说查询也没快多少。3.2 分页与连接池的隐藏拖累销量统计这个查询每天的写入量不小单表数据量已经过亿分页是跑不掉的。但实际操作中我留意到项目里还有好几个地方用了COUNT(*)做全表统计这在亿级表上是灾难。虽然这不直接影响当前接口的响应时间但它拖慢了共享数据库连接池的周转间接影响了所有接口的性能。运维层面我又顺带检查了 HikariCP 的配置。日志里确认了最大连接数是 10其实一个管理后台系统并不需要那么多数据库连接。连接数越少单连接能得到的数据库资源越充足长尾查询的表现会更好。改后的 HikariCP 配置spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 18000003.3 一个容易被忽略的坑数据库端的缓存命中率MySQL 的 InnoDB Buffer Pool 是数据库内存优化的核心区域。去看了一眼配置发现 buffer pool 只有 512MB但服务器物理内存空着 16GB。这就是典型的“买了好马不给好草”。调整思路很简单把innodb_buffer_pool_size调到 4GB同时确保innodb_flush_log_at_trx_commit保持默认值不变。这里要注意innodb_flush_log_at_trx_commit2确实能带来写入性能提升但会带来最多 1 秒的数据丢失风险后台系统里不值当冒这个险。我最后只调大了 Buffer Pool没动刷盘策略。这个调整单独看可能不明显但配合索引和 SQL 优化一起做销量查询在内存命中率更高的时候最终耗时稳定到了 10ms 以内。4. 缓存策略设计50ms 目标的起飞关键4.1 缓存分层本地缓存 分布式缓存双层架构数据库优化做到位之后销量查询到了 10ms 左右但整体接口耗时还卡在 300ms 多剩下的主要是三个 RPC库存 120ms、优惠券 90ms、评价摘要 60ms。这三个都是外部依赖无法直接改对方的服务所以只能缓存。缓存设计我采用的是“两层缓存”Caffeine 本地缓存 Redis 分布式缓存。为什么不用两层只用一层如果只做 Redis 缓存每次查询还有一次网络开销大概 2ms~3ms在高并发下这个叠加并不小更重要的是如果接口被热点数据打着Redis 的压力会非常大。如果只用本地缓存应用重启或者分布式多节点部署后缓存一致性很难维护。所以在查询路径上我是这么设计的// 伪代码两级缓存查询逻辑 public ProductStock queryStock(String goodsId) { // 第一级本地缓存最快 ProductStock localCache caffeineCache.getIfPresent(goodsId); if (localCache ! null) { return localCache; } // 第二级Redis 分布式缓存 String redisKey stock: goodsId; String json redisTemplate.opsForValue().get(redisKey); if (json ! null) { // 从 Redis 拿到的数据反序列化后回填本地缓存 ProductStock stock JSON.parseObject(json, ProductStock.class); caffeineCache.put(goodsId, stock); return stock; } // 第三级回源 RPC 调用 ProductStock stock stockClient.getStock(goodsId); // 回填两级缓存并设置合理的过期时间 redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(stock), 5, TimeUnit.MINUTES); caffeineCache.put(goodsId, stock); return stock; }Caffeine 的配置我选择了maximumSize5000expireAfterWrite30s。这个时间需要控制得比较短因为库存数据是实时变化的过期时间太长容易让用户看到错误的库存。Redis 层的过期时间设为 5 分钟是为了在 RPC 抖动时兜底。注意两级缓存的过期时间必须呈现“梯度递减”本地缓存 分布式缓存 原始数据更新频率。否则会出现本地缓存已经过期、Redis 还是旧值的情况。4.2 不是所有接口都适合缓存三个子服务的差异化处理库存、优惠券、评价摘要三个 RPC 虽然都做了缓存但策略完全不同。库存服务实时性要求中等库存数量允许最多几秒钟的误差所以我采用的过期时间较短30s 本地缓存 5 分钟 Redis同时做了一个逻辑当库存低于安全阈值比如少于 10 件时强制走实时 RPC 查询防止缓存中的“旧库存”导致超卖。优惠券服务状态变化不频繁但是影响用户决策用户不希望看到一张“已过期”的优惠券还在列表里。所以缓存 TTL 设得比库存长但监听优惠券状态变更事件事件触发时主动删除缓存而不是等缓存过期。评价摘要几乎不存在实时性要求只有评分和评价数量两个数字。本地缓存 5 分钟Redis 缓存 15 分钟。这里没有做主动失效因为评价摘要的变化不会引起用户投诉。这部分的实现没有太多黑科技关键是想清楚“哪些数据更新快、哪些更新慢、哪些业务上允许看到旧值”。缓存方案没有银弹只有场景适配。4.3 缓存穿透、击穿、雪崩的处理思考写缓存代码的时候我会顺手把三个经典问题一起处理掉不然上线就是给自己埋雷。缓存穿透查一个不存在的商品ID缓存和数据库都查不到每次都打到 RPC 上。对策是缓存空值TTL 设为 60 秒同时在代码层面对 ID 格式不合法的情况做快速拒绝。缓存击穿如果一个爆款商品的缓存同时过期请求全部回源。这里的核心就是两个动作互斥锁只允许一个线程回源查库其他线程等待和逻辑过期热点数据不做物理过期而是在 value 中带上过期时间后台异步刷新。缓存雪崩大量 key 同时过期会拖垮数据源。解决方法是给 TTL 加一个随机扰动。比如优惠券的缓存 TTL 是 5 分钟我实际设置的是5分钟 random(0~60秒)让过期时间“抖动”开。5. 并发模型切换从串行到并行再到虚拟线程5.1 并行编排 CompletableFuture 的正确用法缓存解决了几个 RPC 的耗时但还有一个大问题如果缓存全部失效5 个子调用仍然是串行的接口响应依然要 300ms。所以必须做“并行编排”。我采用的是CompletableFuture配合自定义线程池。为什么不直接用默认的ForkJoinPool.commonPool()因为那是一个全局共享池如果业务代码里有并行流或者 NIO 操作也依赖它就可能在高峰期出现互相饥饿。生产环境里任何线程池都建议单独定义明确拒绝“隐式共享”。private final ExecutorService rpcThreadPool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 30L, TimeUnit.SECONDS, // 空闲时间 new ArrayBlockingQueue(200), // 等待队列 new ThreadFactoryBuilder().setNameFormat(rpc-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略谁提交谁执行 );并发的编排逻辑就是一个简单的allOf加超时控制CompletableFutureProductInfo productFuture CompletableFuture.supplyAsync(() - goodsClient.getInfo(goodsId), rpcThreadPool); CompletableFutureStockInfo stockFuture CompletableFuture.supplyAsync(() - stockService.getStock(goodsId), rpcThreadPool); CompletableFutureCouponInfo couponFuture CompletableFuture.supplyAsync(() - couponService.getCoupon(goodsId), rpcThreadPool); CompletableFuture.allOf(productFuture, stockFuture, couponFuture) .get(200, TimeUnit.MILLISECONDS); // 整体超时兜底这里有一个很关键的设计必须设置整体超时。如果其中一个 RPC 卡死get()不加超时会一直阻塞线程把整个接口拖垮。200ms 的超时意味着即使某个子调用异常慢我们也不会让用户等待太久。还有一个细节CallerRunsPolicy这个拒绝策略在并发高的时候会退回调用方线程执行这样可以消耗请求线程的时间来扛住压力但不会丢失任务。对这种非极端场景来说比AbortPolicy直接抛异常要温和得多。并行化改造完成后即使 5 个调用全部回源接口的耗时也从“5 个耗时相加”变成了“5 个耗时中的最大值 调度开销”理论上最快可以到 160ms 左右以最慢的库存 120ms 为瓶颈。5.2 Spring Boot 3.2 虚拟线程并发优化的另一个思路上面的并行编排需要使用一个自定义线程池这是经典的“异步非阻塞”模型。但 Spring Boot 3.x 时代还有一个更优雅的方案虚拟线程。虚拟线程的底层原理是 JVM 自己管理一堆轻量级线程直接跑在载体线程上。当虚拟线程发生 IO 阻塞时JVM 会主动把它从载体线程上摘下来让载体线程去跑别的任务。用大白话说就是“操作系统线程不再被 IO 等待白白占着”。在 Spring Boot 里开启虚拟线程只需要两个配置spring: threads: virtual: enabled: true如果用的是 Spring Boot 3.2 及以上版本这一项就够了。用了虚拟线程之后上面那一大段CompletableFuture并行的代码可以反而不需要了——每个请求从 Tomcat 的虚拟线程池里拿一个虚拟线程在虚拟线程里做同步串行调用阻塞也没关系反正虚拟线程占用的成本极低。在实际测试中两种方案的表现如下方案回源耗时资源配置代码复杂度传统线程池 CompletableFuture约160ms需要维护自定义池参数高虚拟线程 同步调用约180ms无需调参低虚拟线程路径下多出来的 20ms 主要是因为 JVM 调度的开销以及没有做真正意义上的并发调用——只是线程不阻塞了但调用本身还是依次发给下游服务的。最后我在生产环境选了“混合方案”保留传统线程池并行编排作为默认路径虚拟线程作为高并发场景下的调配方案开关控制。考虑到这个项目 QPS 不高也没有必要激进地切换到虚拟线程去赌调度器稳定性。5.3 异步化改造日志、审计、通知都不能拖主链路接口优化不只是主查询逻辑很多时候响应时间都浪费在“顺手记录”这类动作上。这个接口原来在返回数据之前还会生成一条操作日志、调用一次审计接口、偶尔发送一条消息通知。这些都是同步的加起来也要 20~30ms。这块的优化属于典型的“小钱也要捡”全部改成异步。Spring Boot 里的做法很简单——用Async注解配合异步线程池Async(asyncExecutor) public void saveOperationLog(OperationLog log) { // 异步写库 }但这里要注意Async默认会使用SimpleAsyncTaskExecutor——这个执行器是“来多少任务开多少线程”完全不做资源管控属于生产环境禁用级别。我在配置里显式定义了一个有界的 async 线程池核心线程 5最大线程 10队列容量 500拒绝策略由AbortPolicy自研改造为“拉黑该任务并打日志”。这么做的好处是主链路完全不受非核心动作影响日志和审计就算慢也只是它的调用线程等不会阻塞用户请求。6. 最终优化效果与经验总结6.1 优化前后的数据对比所有优化上线后压测和线上监控数据对比如下指标优化前优化后提升幅度接口 P50 响应486ms42ms约 11.5 倍接口 P99 响应523ms61ms约 8.6 倍数据库平均 TPS21001350下降 35%压力明显降低下游 RPC 调用量5次/请求0.2次/请求缓存命中后大幅减少这个结果不难理解除了异步化和并行化降低了线程等待时间更重要的是缓存的引入让“回源”次数断崖式下降。在缓存命中率超过 90% 的情况下接口的平均耗时完全可以压到几十毫秒以内。值得说明的是数据库 TPS 下降并不是坏事恰恰说明数据库层的无效计算减少了给其他核心业务留出了更多 IO 和 CPU 资源。6.2 性能优化方法论先测量后动手再验证这次优化结束之后我做了一个内部复盘沉淀了几条方法论没有数据支撑的优化都是“玄学调参”。不先看链路追踪、慢查询日志和 JVM 监控数据就没有资格谈优化对象。按“投入产出比”排优先级。数据库 SQL 和索引优化是成本最低、效果最明显的其次是缓存设计再次是并发模型最后才是 JVM 参数微调。不要为了优化而优化。每次改动都要有可量化的指标防止把代码改得“看起来高端”但没任何实际收益。回源必须要做降级和超时控制。一旦缓存全部失效下游抖动会导致接口雪崩。没有兜底的优化方案等于把系统放在悬崖边上。6.3 踩过的坑与避坑建议最后分享几个实操中踩过的坑每一条都是真金白银换来的教训第一个坑Redis 缓存导致的数据一致性短暂返回旧值。库存 RPC 回源成功之后如果先更新 Redis 再更新本地缓存会有一个时间窗口内两层缓存不一致。解决办法是先更新 Redis再将本地缓存主动淘汰掉下一次请求再重新加载。另外淘汰本地缓存时不要用put覆盖旧值要用invalidate否则并发场景下可能会把新值覆盖成旧值。第二个坑HikariCP 连接池太小引发雪崩。最初为了“控制资源占用”把最大连接数设成了 10。结果压测时数据库连接直接被占满其他接口的查询全部排队。后来调成 20 并且给核心链路单独配置了一个DataSource才算把问题解决。连接池大小的选择要结合活跃查询数和单查询耗时估算不要拍脑袋。第三个坑CompletableFuture 的get()忘记加超时。加了超时之后还有一个细节需要注意超时后子线程并不会自动取消必须要显式调用future.cancel(true)去中断。另外在等待时使用get(200, TimeUnit.MILLISECONDS)会抛TimeoutException一定要在 catch 里做降级返回而不是直接让主流程失败。第四个坑虚拟线程开启后的事务管理问题。在虚拟线程下使用Transactional没有报错但在某些 JDBC 驱动的旧版本下连接释放逻辑会异常。生产环境切换虚拟线程建议先把所有第三方驱动升级到较新版本然后做完整的回归测试。如果没有时间验证就先保持传统线程模型不要贪新。6.4 后续还可以怎么扩展这次优化虽然完成了 500ms 到 50ms 的目标但距离“极致”还有一段路有些方向我也留着没动后续有精力可以做一个是引入Caffeine 的异步刷新机制让热点缓存在过期之前自动后台续期避免某一时刻的大面积回源。另一个是Redis 缓存序列化方式的调优——目前还在用 JSON如果改成 Kryo 或者 Protobuf可以进一步减少序列化和反序列化时间主要收益会体现在带宽和 GC 压力上。第三个方向是缓存冷启动预热上线后先触发一次全量热 key 加载避免活动开始时大量请求打到数据库。我在实际项目中最大的体会是接口性能优化不是我一开始想的那样“放几个大招”而是一个系统性工程。每一步改动的收益看着都不大——省 20ms、省 30ms、省 80ms——但叠加起来十倍提升就出来了。与其迷信某个“神器框架”或“万能参数”不如回归到链路追踪、数据库索引、缓存设计、并发控制这几个基本功上扎扎实实地抠细节。希望这份记录能帮你少走一些弯路也欢迎在评论区交流你们的优化案例。
返回列表