尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

慢接口优化实战:从压测到缓存,QPS提升11倍的完整链路

发布时间:2026/9/19 18:51:12

资讯中心
01
ARTICLE

慢接口优化实战:从压测到缓存,QPS提升11倍的完整链路

慢接口优化实战:从压测到缓存,QPS提升11倍的完整链路
这周的博客没有规划新功能而是把上个迭代留下来的一堆性能问题集中清了一轮。起因很简单联调环境里核心列表接口的响应时间经常冲到 2 秒以上数据库 CPU 一度打满测试在群里喊了几次又卡了。虽然功能逻辑没问题但这种状态根本不敢上线。于是我花了一周时间从压测摸底开始把一条完整的慢接口优化链路走了一遍。最终单实例 QPS 从 280 提升到 3200平均响应时间从 350ms 降到 25msP99 从 1.2s 降到 90ms。这篇文章就把整个过程拆开讲涉及压测工具选型、目标水位计算、慢 SQL 优化、连接池/线程池配置、缓存与异步化改造还有压测过程中踩过的坑。适合正在做后端接口优化、准备性能压测、或者纯粹想把服务调稳的同学参考里面涉及的工具、命令、参数都能直接拿去用。1. 问题发现与整体排查思路1.1 现象与初步定位这个服务的背景不复杂一个典型的 Spring Boot MyBatis MySQL Redis 组合对外提供列表查询和详情查询接口看起来都是常规操作。问题集中出现在列表接口上单次查询大概要查主表、关联三张子表、再做字段拼接SQL 本身有几百行。联调阶段并发量一上来接口响应时间就从正常的 200ms 左右拉高到 2 秒以上偶尔还会超时报错。我先做了个初步排查在接口上临时加了耗时埋点分阶段打印时间。结果很清晰90% 的时间耗在数据库查询上序列化和网络传输占比反而很小。这意味着问题大概率出在 SQL 执行和数据库资源竞争上而不是代码写得多差。当时用 jstack 抓了一次线程栈大量业务线程阻塞在SELECT语句的等待上进一步验证了这个判断。1.2 优化路径设计先修水管再换水泵这次优化我没有直接上手改代码而是先画了一张请求链路图客户端 → Nginx → 应用线程池 → 数据库连接池 → MySQL 执行 SQL → 返回结果。我习惯把这条链路比作水管系统数据库是总闸连接池和线程池是各段水管缓存则相当于蓄水池。排查顺序应该是先看总闸有没有堵住再看各段水管有没有瓶颈最后才考虑是不是水泵本身不够力。按照这个思路我把优化拆成了四步数据库层先看慢 SQL、索引、连接数占用这是成本最低、收益最大的一步。应用层调整线程池、数据库连接池、JVM 参数保证资源能被正确利用。缓存层引入 Redis 和本地缓存把重复查询挡在数据库之前。代码层修掉明显的 N1 查询、循环调用、重复序列化等问题。这个顺序很重要。很多人一上来就调 JVM、上缓存结果数据库还是慢等于把总闸的压力转移到了蓄水池池子再大也撑不住。先把数据库问题处理干净后面的缓存才能真正发挥作用。2. 量化压测没有基线数据的优化都是拍脑袋2.1 压测工具选型在动代码之前我先建了一套可重复的压测方案。这一步比很多人想象的重要因为优化前后必须要有可对比的数字否则你根本不知道改动到底是变好了还是变坏了。压测工具我对比了几个常用方案工具特点适合场景我们的选择wrk轻量、单机就能压出很高并发基于 C 编写快速摸底、单接口压测首选JMeter功能全支持复杂场景和分布式压测有 GUI完整业务链路、多接口组合场景回归时使用Locust基于 Python脚本写起来方便支持分布式需要灵活模拟用户行为的场景备用实际执行时我用 wrk 做第一轮单接口快速摸底因为它的学习成本几乎为零一条命令就能跑出 QPS、平均延迟、P99 等数据。等后面需要模拟真实用户混合请求时再用 JMeter 跑完整的业务场景。这里必须提醒一个坑压测机不要和被压服务部署在同一台机器上。我第一次图省事直接在应用服务器上跑 wrk结果压测本身就把 CPU 吃掉了大半出来的数据完全不能看。压测机至少要独立于应用服务器和数据库服务器。2.2 目标水位计算没有目标的压测只是看热闹。我根据业务预期算了一组目标值过程如下预估日活用户10 万。每个用户高峰期平均调用核心接口20 次。高峰集中在 2 小时7200 秒内。平均 TPS 10万 × 20 / 7200 ≈ 278。考虑到业务高峰存在波动取 5 倍峰值系数约 1390 TPS。再留 2 倍余量目标水位约 28003000 TPS。这个计算并不复杂但很实用。很多团队喜欢拍脑袋定一个我们要支持十万并发结果压测时压不到目标就开始加班其实先把业务模型算清楚目标就落地了。同时我把响应时间目标定为 P99 100ms而不是平均响应时间 100ms。原因很简单平均响应时间很容易被少数极快请求拉低P99 能真实反映最差用户的体验。优化之前这个接口的 P99 是 1.2 秒目标是从这里开始砍。2.3 优化前的基线数据用 wrk 跑出来的基线数据如下为了让结果稳定我每组压测跑 3 次每次持续 5 分钟取中间值指标优化前基线单实例 QPS280平均响应时间350msP99 响应时间1.2s错误率1.5%数据库 CPU 使用率85%应用线程池活跃率95%数据库连接池等待次数频繁压测过程中能明显看到数据库连接池经常处于等待状态活跃线程数接近上限MySQL CPU 几乎被打满。这个基线数据说明服务确实已经到了极限不是臆想出来的性能问题。3. 数据库到应用层逐层拆解核心优化点3.1 慢 SQL 与索引优化第一桶金数据库层是我最有信心的一步因为慢查询日志就摆在那里。打开 MySQL 慢查询日志后top 3 的慢 SQL 里有一个典型的关联查询简化后大概是这样的SELECT a.id, a.order_no, a.status, b.user_name, c.product_name FROM order_info a LEFT JOIN user_info b ON a.user_id b.id LEFT JOIN product_info c ON a.product_id c.id WHERE a.status 1 AND a.create_time 2024-01-01 00:00:00 ORDER BY a.create_time DESC LIMIT 10, 20;用 EXPLAIN 看了一眼执行计划typeALL全表扫描扫描行数超过 80 万三个表都是全表扫描后做 join。直白地说这个查询走了最差路径。优化动作分两步第一在order_info表的status和create_time上建立联合索引让 where 条件能用上索引过滤。这里注意索引字段顺序区分度高的字段放前面status只有几个取值区分度低所以把create_time放前面更合适最终索引设计是idx_create_time_status(create_time, status)。第二给关联字段user_id和product_id补上索引。LEFT JOIN 的驱动表如果关联字段没有索引大概率就是全表扫描。优化后的执行计划变成了typerange扫描行数从 80 万降到几千查询时间从 820ms 直接降到 38ms。这还没完。分页查询本身还有坑LIMIT 100000, 20这种写法即使走了索引MySQL 也需要先把前 10 万条记录扫描出来再丢弃数据量越大越慢。我用延迟关联的方式优化SELECT a.id, a.order_no, a.status, b.user_name, c.product_name FROM (SELECT id FROM order_info WHERE status 1 AND create_time 2024-01-01 00:00:00 ORDER BY create_time DESC LIMIT 10, 20) t JOIN order_info a ON t.id a.id LEFT JOIN user_info b ON a.user_id b.id LEFT JOIN product_info c ON a.product_id c.id;先只查主键再用主键 join 回原表大大减少了回表的数据量。这个技巧对深分页场景特别有效。3.2 连接池与线程池参数没有最优只有合理数据库优化完接下来看连接池和线程池。这个环节很多人容易走极端要么觉得越大越好要么干脆不管用默认值。实际上这两个参数是整条链路上最需要匹配业务的配置。先看数据库连接池。我用的 HikariCP它的 core 配置往往决定了数据库能同时处理多少个请求。假设应用服务器是 4 核 CPU磁盘是 SSDHikariCP 官方给过一个经验公式maximumPoolSize (core_count * 2) effective_spindle_count其中effective_spindle_count指磁盘并行执行能力的数量SSD 通常是 1。按 4 核计算(4 * 2) 1 9取个整设成 10 就差不多了。注意这里算出来的结果是连接数不需要太多因为数据库真正能处理的并发事务是有限的连接数越大上下文切换和锁等待反而更严重。HikariCP 的配置示例spring: datasource: hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000线程池同理。Spring Boot 内置的 Tomcat 线程池默认最大 200但对这个服务来说200 个线程同时去抢 10 个数据库连接大部分线程只能干等着反而增加了上下文切换开销。我把最大线程数调到了 50核心线程 20队列容量 200配合连接池的 10 个连接整体排队模型比之前健康得多。这里要特别强调一个理念线程池和连接池的大小不是独立决定的它们要跟数据库的实际处理能力匹配。线程数太多、连接数太少效果等同于堵车车再多也过不了红绿灯。JVM 这边也做了一轮基础调优。原来默认的 -Xmx 只有 1G压测下频繁 Full GC这里改成 4G并启用 G1 垃圾回收器java -Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis100 -jar application.jarG1 的优势是能控制垃圾回收的暂停时间目标适合响应时间敏感的业务。JVM 调整单独跑了一轮压测QPS 提升不明显但 GC 引发的响应毛刺明显少了。这一步价值更多在稳定性而不是吞吐量。3.3 代码级问题修复N1 是隐蔽的杀手数据库优化完我又回头扫了一遍代码。最典型的问题就是 N1 查询。原来的逻辑是在循环里逐个调用 Mapper 查询子表信息比如先查出 20 条订单再在 for 循环里分别查用户和商品ListOrderInfo orders orderMapper.selectPage(...); for (OrderInfo order : orders) { UserInfo user userMapper.selectById(order.getUserId()); ProductInfo product productMapper.selectById(order.getProductId()); order.setUserName(user.getName()); order.setProductName(product.getName()); }这个写法在数据量小的时候没问题一旦订单变成 20 条并且每条都要查 2 张表数据库就要执行 1 20 20 41 次查询。并发一高每次都跑到数据库连接池和 SQL 执行自然顶不住。改成批量查询之后代码变成了先收集所有 userId 和 productId一次性 IN 查询再在内存中组装ListLong userIds orders.stream().map(OrderInfo::getUserId).toList(); ListLong productIds orders.stream().map(OrderInfo::getProductId).toList(); MapLong, UserInfo userMap userMapper.selectBatchByIds(userIds); MapLong, ProductInfo productMap productMapper.selectBatchByIds(productIds);数据库查询次数从 41 次降到 3 次效果立竿见影。类似的还有循环里调用 Redis 获取单个 key 的情况改成 pipeline 批量获取能节省大量 RTT。这种代码级优化虽然不起眼但往往是最容易被忽略的剂量效应。4. 缓存与异步化把压力挡在数据库之前4.1 Redis 缓存设计穿透、击穿、雪崩三件套数据库优化完单实例 QPS 已经从 280 提升到大概 900离 3000 的目标还有距离。下一步是上缓存。缓存的大头是热数据重复查询一个商品详情或者订单状态在高峰期可能被反复访问几十次每次都打数据库太浪费。我设计的是标准 Cache-Aside 模式代码逻辑大概这样public OrderDetailVO getOrderDetail(Long orderId) { // 1. 先从 Redis 取 String cacheKey order:detail: orderId; String cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return JSON.parseObject(cacheValue, OrderDetailVO.class); } // 2. 缓存未命中查数据库 OrderDetail order orderMapper.selectDetailById(orderId); // 3. 回填缓存设置随机过期时间 int expireSeconds 300 new Random().nextInt(60); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(order), expireSeconds, TimeUnit.SECONDS); return order; }这里有两个关键点。第一过期时间一定要加随机值否则大量 key 同时过期数据库会瞬间被打爆这就是缓存雪崩。第二如果数据库里查不到数据也要缓存一个空值过期时间设短一点否则恶意请求用不存在的 ID 反复打过来每次都穿透到数据库这就是缓存穿透。更严格的防穿透方案可以在查询前面加一层布隆过滤器。我用 Guava 的 BloomFilter 做了个前置判断对于明显不存在的 key 直接返回避免无效 IOBloomFilterLong bloomFilter BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01); if (!bloomFilter.mightContain(orderId)) { return null; }布隆过滤器允许误判但不会漏判它只是用来挡住绝大多数不存在的请求。真正的高并发瞬间回源问题还需要互斥锁解决缓存击穿——某个热点 key 过期瞬间大量请求同时回源时只允许一个去查数据库其他请求等待它回填缓存。我用 Redis 的 SETNX 实现了一个简单的互斥锁把回源请求控制在个位数。4.2 本地缓存 Caffeine减少网络层开销Redis 解决了数据库压力但 Redis 本身也是一次网络 IO大约耗时 0.5ms 到 2ms。对单次请求来说这个时间可以忽略但 QPS 到达几千的时候Redis 连接的吞吐和网络带宽就成了新瓶颈。我引入 Caffeine 做本地缓存把访问频率最高的热点数据放到 JVM 内存里。配置如下CacheString, OrderDetailVO localCache Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(60, TimeUnit.SECONDS) .build();本地缓存最大的优势是零网络开销读取速度是纳秒级。它的代价是每个服务实例各自存一份可能存在一致性延迟。我的做法是只缓存 60 秒内的热点数据业务上能容忍最多 1 分钟的延迟同时通过 Redis 发布订阅清理本地缓存的 key尽可能保证多方一致。一致性要求高的数据千万不要放本地缓存或者只放很短的时间。我在其中一个查询接口上试过 10 分钟过期时间结果数据变更后用户看到的仍是旧值差点惹出事故。做架构设计第一原则是业务能不能接受而不是技术方案本身有多炫。4.3 异步化非核心链路不要阻塞主流程最后一步优化是把非核心逻辑从同步链路中挪走。这个服务原本在主流程里会记录访问日志、发送短信通知、同步统计数据每个操作都会增加几十毫秒的响应时间而且任何一个下游抖动都会拖慢主接口。我的处理方式很直接写日志、发通知、更新统计这类操作全部丢进独立的线程池异步执行。这里用的是 Spring 的Async配了一个专门的自定义线程池Bean(asyncExecutor) public ThreadPoolTaskExecutor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }拒绝策略我选了CallerRunsPolicy意思是线程池满了之后任务不丢弃而是退回给调用线程执行。这样能避免异步任务丢失代价是极端情况下调用线程会被迫多干一点活但总比丢消息强。异步化给接口响应时间带来的收益非常直观核心接口从 300ms 降到了 130ms多出来的时间主要省在这些顺便做的事上。需要特别注意的是异步化之后要接受最终一致性比如用户刚提交订单后短信晚几秒收到这完全没问题但如果是支付结果查询这种强一致的场景绝对不能异步。5. 常见问题与排查技巧实录5.1 压测时连接数打满服务直接卡死第一轮压测规模拉到 1000 并发时服务直接卡住新连接全部超时。当时的现象是应用日志里大量Connection is not available, request timed out报错。排查路径先用netstat -an | grep 3306 | wc -l看数据库连接数发现连接数飙到了 300 多。执行SHOW PROCESSLIST查看连接状态发现大量连接处于Sleep状态真正在跑 SQL 的反而没多少。再用jstack抓应用线程栈发现大量线程阻塞在连接获取阶段。根因是慢 SQL 没优化干净同时连接池的maximum-pool-size之前被调到了 50导致所有线程都在等数据库连接连接又被慢 SQL 占着不放。修复方式把连接池调回 10去掉慢 SQL 的根本原因增加connection-timeout为 3 秒避免无限等下去。这个问题本质上是水池漏得快水管还接得特别粗。5.2 GC 频繁导致请求毛刺有一轮压测平均响应时间看着还行但 P99 和 P999 高得离谱响应时间曲线经常出现刺状凸起。一开始怀疑是外部依赖不稳定看了监控发现是 GC 周期性地停顿导致请求排队。用jstat -gcutil pid 1000看 GC 情况发现 Full GC 每 2 分钟就发生一次每次耗时 1 秒多。原因是堆内存设置太小加上代码里大量创建无用的中间对象。调整策略参数优化前优化后-Xms / -Xmx1g / 1g4g / 4g垃圾回收器ParallelG1MaxGCPauseMillis无100G1NewSizePercent默认10同时顺手优化了代码中循环拼接字符串的方式把大量号拼接改成 StringBuilder 复用。GC 压力降下来之后P999 从 2.3s 降到了 150ms响应曲线的毛刺基本消失。5.3 缓存穿透把数据库打挂加了 Redis 缓存后刚开始效果很好但某一次压测中数据库 CPU 突然飙到 90%。查了 Redis 命中率发现从 95% 掉到了 70%大量请求都在查不存在的 key。分析请求参数后发现压测脚本随机生成的 ID 大量落在不存在的数据范围内每次都直接回源数据库。由于原有逻辑只缓存了查询结果没有缓存空结果导致数据库扛不住。修复方案是三层叠加对空结果进行短时间缓存比如 5 分钟。引入布隆过滤器在进入 Redis 之前先挡住不存在的 ID。网关层做限流防止单 IP 高频发起非法请求。三层都上线后数据库 CPU 稳定在 20% 左右Redis 命中率重新回到 95% 以上。5.4 问题排查速查表把这一周踩过的坑整理成一份速查表以后遇到类似问题可以直接对号入座症状可能原因快速定位手段参考解法接口偶发超时慢 SQL 占满连接池慢查询日志、EXPLAIN加索引、拆 SQL、减少扫描行数QPS 上不去线程池或连接池过小jstack、连接池监控按核数和数据库能力调整参数响应时间毛刺多GC 停顿或外部依赖抖动jstat、Grafana 调用链调 JVM、异步化、增加超时控制数据库 CPU 突然飙高缓存穿透或热点 key 过期Redis 命中率、DB CPU 曲线空值缓存、布隆过滤器、随机过期时间连接数打满连接池过大 SQL 慢SHOW PROCESSLIST收紧连接池、优化慢 SQL6. 优化结果与经验沉淀6.1 优化前后数据对比这一轮优化做了整整一周最后的压测结果如下指标优化前优化后提升幅度单实例 QPS2803200约 11.4 倍平均响应时间350ms25ms92.8%P99 响应时间1.2s90ms92.5%错误率1.5%0%稳定数据库 CPU85%25%大幅下降Redis 命中率未使用95%新增从收益拆解来看贡献最大的是缓存和索引两者加一起贡献了大约 80% 的性能提升连接池调整和异步化贡献了稳定性和响应时间上的改善JVM 调优单独看提升不大但明显减少了响应毛刺。有一个经验值得单独说一定要一次只改一个变量。这周我有一次同时改了 SQL、线程池和缓存结果 QPS 涨了但根本说不清是哪个改动的功劳。后来回退到单变量验证才发现其实线程池那个改动几乎没效果主要收益来自 SQL 索引。做性能优化建议每改一个点就跑一轮压测这样归因最清楚也方便出问题时回滚。6.2 关于这次优化我的真实体会这是我连续写的第十周技术博客也是让我对性能优化这个概念理解最深的一周。以前总觉得性能优化是架构师和 DBA 的活自己把功能写完就完事。这轮实操下来最大的感触是性能问题大多数时候不是玄学而是数据链路中某个环节的必然瓶颈只要愿意用数据说话问题都是可以定位和解决的。最后再分享一个小技巧。压测时不要只盯着 QPS 和平均响应时间我建议同时记录线程池活跃数、数据库连接池等待次数、GC 次数、Redis 命中率这些内部指标。外部指标告诉你服务有多快内部指标告诉你为什么快或者为什么慢。这次如果没有抓住连接池等待和 GC 停顿这两个内部信号估计还得在错误的优化方向上多花两三天。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。