在系统上线前做了一轮压测,结果直接暴露了三层问题:服务端接口在 300 并发下 CPU 飙到 90%,数据库连接池被打满,Redis 缓存命中率却不到 40%。换句话说,机器买了不少,钱没花在刀刃上,流量一上来照样扛不住。这篇文章就把我这次压测与成本优化的完整过程拆开来讲,覆盖服务端、数据库、缓存三个层面的协同排查,以及最终如何把成本降下来的几个敏感点。
这篇文章适合谁看?一类是刚接手系统性能优化、对 jmeter 压测步骤还不熟的后端开发;另一类是负责预算和技术选型,想搞清楚资源到底浪费在哪儿的架构师或技术负责人。我会把常用的压测方案、数据库和缓存层的排查方法、以及成本决策的依据都铺开讲,尽量做到拿来就能用。
1. 整体设计:先定压测目标,再谈优化动作
1.1 压测不是在测极限,而是在找资源冗余
很多人一上来就把线程数拉到 1000,看系统会不会挂。这个做法不能说错,但对成本优化没什么帮助。我理解的压测核心目标有三个:确定性能基线、摸清容量上限、定位资源浪费点。
性能基线解决的是“正常情况下系统应该多快”的问题,比如核心接口 P99 响应时间是多少毫秒;容量上限解决的是“在可接受延迟内最多扛多少并发”的问题;资源浪费点才是成本优化真正关心的,比如 4 核 8G 的实例只用了 10% 的 CPU、数据库 200 个连接里大部分在空闲等待,这些都是白花花的钱。
所以压测场景的设计很重要。我在这次项目中设计了三个场景:单接口峰值测试,服务于接口级别的容量评估;混合链路测试,模拟真实用户同时调用多个接口;阶梯加压测试,用来观察系统在哪个并发区间开始劣化。
1.2 工具选型:为什么是 jmeter 而不是其他方案
压测工具我用过不少:Apache Bench 适合快速看单接口响应,wrk 适合测纯 HTTP 服务的极限吞吐,但要说场景编排和数据关联能力,jmeter 还是最顺手。而且团队里其他成员多多少少接触过,不需要额外培训成本。
jmeter 的关键优势在于它的线程组模型和监听器体系。线程组可以精确控制并发数、Ramp-Up 时间和循环次数;聚合报告能直接给出平均响应时间、吞吐量、错误率这些核心指标。如果需要在压测过程中动态调整参数,还可以配合 BeanShell 或 JSR223 脚本。另一个好处是它能在 Docker 单机环境下跑,不需要单独搭压测集群,成本上很友好。
这次我用 Docker 启动了一个独立的 jmeter 容器做压测机,宿主机是 8 核 16G,按经验 300 并发以内完全够用。如果压测目标是更高的并发,建议用多台压测机分布式执行,否则压测机自己先成了瓶颈,测出来的数据没有参考价值。
提示:压测机的性能一定要远高于被测服务,否则结果反映的是压测机的上限,而不是目标系统的上限。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端压测:从 jmeter 到定位瓶颈的完整链路
2.1 jmeter 压测的基础步骤
先给刚接触压测的同学梳理一套完整的 jmeter 压测流程,后面所有的分析都以这套流程为基础。
第一步,创建测试计划,添加线程组。线程数代表并发用户数,这次我从 100 开始递增到 500,每档跑 10 分钟,观察系统是否有累积性劣化。Ramp-Up 时间设成 30 秒,让并发逐步建立,避免瞬间流量把服务打崩,这不是真实场景,也没有分析价值。
第二步,配置 HTTP 请求默认值。协议、服务器 IP、端口、编码这些公共参数统一填在这里,后续每个请求只需要关注路径和参数。如果接口需要登录态,加一个 HTTP Cookie 管理器,配合一次登录请求拿到 token 后自动传递。
第三步,添加查看结果树和聚合报告。查看结果树用于调试请求是否成功,聚合报告用于查看最终统计数据。注意查看结果树在生产压测时能关就关,保存响应体对内存消耗很大,压测机自己反而会变成瓶颈。
第四步,正式压测前用少量线程先跑一遍,确认参数传递正确、响应码符合预期,再放开并发。
2.2 服务端指标采集:CPU、内存、IO、GC 一个都不能少
压测过程中,光看 jmeter 的报告是不够的。响应时间变长了,到底是服务端 CPU 不够,还是数据库慢了,还是网络有延迟,必须通过服务端指标来判断。
我一般会开三块监控:系统层面的 top/vmstat/iostat,用于看 CPU 使用率、内存余量、磁盘 IO;JVM 层面的 jstat 和 GC 日志,用于检查 GC 频率和停顿时间;应用层面的 Arthas,用于在线诊断方法级耗时的分布。如果环境允许,直接用 Prometheus 加 Grafana 搭一套监控,效果更好,数据能长期留存对比。
服务端 CPU 使用率持续超过 80%,说明计算资源已经接近饱和。这时要结合负载均衡的 CPU 核数来判断,4 核机器跑到 80% 和 8 核机器跑到 80% 代表的余量完全不同。内存方面主要关注两点,一是 JVM 堆内存是否频繁触发 Full GC,二是操作系统本身有没有用 swap。磁盘 IO 在高并发写入场景下容易成为盲区,比如日志框架异步落盘没配好,压测一上来 IO 就飙满,接口 TP99 直接翻倍。
2.3 用 Arthas 定位慢方法的实战记录
压测到 400 并发时,聚合报告显示某个查询接口的 TP99 从 60ms 涨到了 800ms,但 SRE 那边反馈服务端 CPU 只有 30%。CPU 不高但接口变慢,说明耗时不在这台机器的计算上,大概率是等外部资源。
我登到一台测试机,用 Arthas 的 trace 命令跟踪了这个接口的完整调用链。结果很快定位到耗时集中在某个 Redis 的批量查询方法上,平均耗时 650ms。进一步查 Redis 服务端监控,发现是大 Value 导致的网络传输耗时过高,单个 key 的 value 有接近 2MB,一次 MGET 要拉好几 MB 的数据,网络开销自然降不下来。
这里我的优化方案是把大 Value 拆分成多个小 key,按业务维度重新组织数据结构,接口响应时间直接降回了 80ms 以内。压测的价值就在这里:没有压测根本不会有人注意到这个接口在极端情况下的网络开销问题。
2.4 服务端线程池调优的几个参数
线程池配置是服务端压测中很常见的一个优化点。很多框架的默认配置偏向保守,比如默认核心线程数 10,最大并发 200。在压测场景下,线程数太少会导致请求排队,太多则会导致频繁上下文切换,CPU 浪费在调度而不是执行上。
我的经验是核心线程数参考业务的 IO 等待时间占比来定。如果接口大部分时间在等数据库或缓存返回,可以适当调高线程数,例如从 10 调到 50;如果是 CPU 密集型计算,线程数接近 CPU 核数反而更好。另外要关注任务队列的长度设置,如果队列过长,请求在队列里排队的时间会掩盖真实处理耗时,前端表现就是接口时不时卡一下。
配套的还有连接池调优。服务端调用的下游数据库连接池、Redis 连接池,大小要跟服务端线程数匹配。否则线程数调上去了,连接池不够用,线程照样会阻塞在获取连接上。
3. 数据库压测与优化:连接池、慢 SQL、死锁一个都不能放过
3.1 先从压测结果反推数据库瓶颈
服务端的计算资源明明很空闲,压测却上不去,十有八九是数据库出问题了。数据库瓶颈最典型的表现是连接数暴涨、活跃连接数高、慢查询日志刷屏。
我用 jmeter 对写入接口做了 10 分钟的压测,数据库连接数直接冲到上限 200,其中 150 多个连接处于 Sleep 状态。这属于典型的连接池配置不合理:应用侧的最大连接数设置过大,而数据库侧的上限又没有做对应调整,导致连接大量堆积。这里不只是性能问题,还是成本问题,因为如果你按这个峰值去申请数据库实例规格,意味着多付很多钱。
解决方案分成两步。第一步,梳理各个服务对数据库的真实并发需求,把连接池最大连接数压到合理范围。第二步,给数据库侧设置连接超时和空闲回收策略,避免无效连接长期占用。
3.2 慢 SQL 排查:慢查询日志 + EXPLAIN + profiling
排查数据库性能问题,我习惯按照下面这个顺序操作。
先打开慢查询日志。MySQL 中可以用以下命令确认当前状态:
sql复制SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time%';
如果没开启,可以在测试环境临时开启:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
压测结束后用 mysqldumpslow 工具分析慢查询日志,按执行次数和总耗时排序,找出最需要优化的几个 SQL。拿到具体的慢 SQL 后,用 EXPLAIN 看执行计划,重点观察 type 字段和 key 字段。type 从好到差依次是 system、const、eq_ref、ref、range、index、ALL,如果看到 ALL,说明这条 SQL 在做全表扫描,必须处理。
再补充一个 profiling 的方法。如果 EXPLAIN 看不出明显问题,可以通过 profiling 查看 SQL 执行各阶段耗时:
sql复制SET profiling = 1;
-- 执行你的慢 SQL
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
这个方法能精确看到 SQL 执行过程中是语法解析耗时、表锁等待耗时还是数据拷贝耗时占了大头。我第一次用 profiling 时就发现一条 SQL 明明走了索引,但 Sending data 阶段耗时异常高,进一步查是 SELECT 出来的字段数太多,导致网络传输和数据拷贝时间长,优化方式是只查必要字段并把大字段拆到单独的表里。
3.3 联合索引的设计:为什么不能到处建索引
优化慢 SQL 时一个常见的反模式是:看到查询慢就加索引,加完这个查询快了,但整个库的写入性能反而下降。原因是索引不是免费的,每次写入都要同步维护索引结构,索引越多,写入放大越严重。在压测中,这种问题会被放大:读接口的响应时间降低了,但写入接口的吞吐量跌了两成。
合理做法是合并索引。举个例子,业务查询经常同时用 user_id 和 status 过滤,那就建一个 (user_id, status) 的联合索引,而不是分别建 user_id 和 status 两个单列索引。联合索引要遵循最左前缀原则,把区分度高的字段放前面。还有一点,能用覆盖索引就不要回表,查询的字段如果都在索引里,InnoDB 就可以直接返回结果,省去回表开销。
压测中我还犯过一个错误:给一张 2000 万行的表加了索引,结果加索引操作本身就花了十几分钟,期间业务写入全部阻塞。生产环境做大表加索引一定要用在线变更工具,比如 pt-online-schema-change,避免直接锁表。
3.4 数据库死锁的排查与 MySQL 死锁日志解读
热词里有人搜“数据库死锁”,说明这是压测中的高频问题。我们这次压测就复现了死锁。两个事务同时更新同一组资源,但加锁顺序相反,典型的死锁场景。
排查死锁最直接的方式是利用 MySQL 的死锁日志。发生死锁后执行:
sql复制SHOW ENGINE INNODB STATUS;
重点关注 LATEST DETECTED DEADLOCK 部分,里面会显示两个事务各自持有和等待的锁。我那次看到的就是事务 A 持有主键 id=1 的锁等待 id=2,事务 B 持有 id=2 的锁等待 id=1,形成了一个闭环。
解决方案有两种思路。第一种是调整业务逻辑,让所有更新操作按固定顺序加锁,比如统一先更新小 id 再更新大 id。第二种是缩短事务的执行时间,减少锁持有时间。比如把大事务拆成多个小事务,涉及的更新操作尽量提前到事务开头执行。我当时是两种方案都做了,死锁从压测中的每小时十几次降到了零。
3.5 数据库同步与压测的隐形关联
热词里提到的“数据库同步工具”和“主数据库无法访问”让我想起一个压测中的隐形坑。如果系统做了主从同步,压测会把大量写请求打到主库上,从库同步延迟会明显增大。如果业务读取走了从库,同步延迟一高,压测结果会出现一种奇怪的现象:写入接口性能正常,读取接口却出现大量超时和数据不一致的报错。
压测前要确认主从同步延迟的监控项,压测结束后继续观察一段时间,确认延迟恢复。还有一种情况是主库磁盘满了导致同步中断,这类问题在压测中更容易暴露。我习惯在压测前检查主从状态,用 SHOW SLAVE STATUS 查看 Seconds_Behind_Master 的值,正常为 0 或接近 0,压测过程中这个值异常升高就要警惕。
4. 缓存层治理:命中率、一致性和成本的关系
4.1 缓存命中率为什么是成本指标
缓存命中率不只是一个技术指标,它直接影响成本。同样的并发量下,命中率 90% 和命中率 40%,对数据库的压力差距是数量级的。数据库实例规格如果需要按峰值负载申请,缓存命中率低的地方,成本会成倍上升。
这次压测暴露的问题让我印象很深。一个用户信息接口,Redis 缓存 key 设置了 5 分钟过期,但压测时命中率只有 35%。原因有两个:一是部分 key 在每次请求时拼入了随机参数,导致缓存永远命中不了;二是缓存过期时间太短,数据刚被缓存就过期了,紧接着的请求全部回源数据库。
找到问题后,我把缓存 key 的生成规则统一,去除随机参数;同时根据业务容忍度设计了不同层级的过期时间,核心用户信息延长到 30 分钟,非核心的次要信息保持 5 分钟。调整之后命中率提升到 92%,数据库 QPS 压力降了将近一半。
4.2 缓存穿透、击穿、雪崩的应对方案
缓存穿透、击穿、雪崩这三个问题,在压测中很容易被放大。穿透指查询一个不存在的数据,缓存和数据库都没有,请求每次都会打到数据库。解决办法除了使用布隆过滤器拦截不存在 key 之外,更简单的方案是缓存空值,设置一个较短的过期时间。
击穿指某个热点 key 失效时,大量并发请求同时回源。我遇到过的情况是秒杀商品的库存 key 过期,瞬间几千个请求直接打到数据库。解决思路有两个:一是让热点 key 不过期,由后台任务主动更新;二是用互斥锁控制回源并发,只允许一个线程去数据库加载,其他线程等待结果。
雪崩指大量 key 同时过期,导致数据库压力瞬间飙高。这种问题的根源是缓存过期时间设置太整齐,解决时在过期时间上加入随机值,比如 300 秒加上 0 到 60 秒的随机偏移,让失效时间分散开。这样既保证了数据更新频率,又避免了同时失效。
4.3 缓存一致性:先更新数据库还是先删缓存
热词里多次出现“缓存一致”,说明这是很多团队的痛点。双写一致性最常用的方案是 Cache Aside Pattern,也就是读的时候先读缓存,没命中再读数据库并写回缓存;写的时候先更新数据库,再删除缓存。
“先更新数据库再删缓存”存在一个窗口期:更新数据库后、删除缓存前,如果用户来读,读到的是旧的缓存数据。但对大多数业务,这个窗口期只有几毫秒,可以接受。相对地,“先删缓存再更新数据库”的风险更大,因为删完缓存后、更新数据库前,另一个请求会把旧数据读回去写进缓存,这样就可能持续读到旧数据。
更进一步的一致性是延时双删,更新数据库后删除一次缓存,等几百毫秒再删一次,处理并发场景下缓存被脏数据回填的情况。但要提醒一句,延时双删里的“延时”是经验值,和业务逻辑的执行时长强相关,需要配合实际压测结果来调整。
如果对一致性要求更高,可以引入 binlog 订阅方案,比如用 Canal 监听 MySQL binlog,数据变更后主动失效或更新缓存。这个方案的优点是业务代码无侵入,但引入了额外组件,成本和运维复杂度都需要评估。
4.4 Redis 内存淘汰策略与成本敏感点
Redis 的内存配置直接关系成本。默认情况下如果没设置 maxmemory,Redis 可能把内存打满,使用太多实例内存,在容器环境下会造成节点不稳定。设置 maxmemory 之后,还需要选择淘汰策略。
常用的几个策略中,noeviction 是不淘汰,内存满后写操作直接报错;allkeys-lru 是淘汰最久没用的 key;volatile-lru 是只在设置了过期时间的 key 中淘汰最久没用的。我通常建议根据业务容忍度选择 allkeys-lru,如果不希望热点 key 被淘汰,可以把热点 key 设置成不过期并单独保护。
内存成本还和 key 的存储设计有关。一个很常见的误区是使用 String 类型存一个小的对象,但 key 名又长又重复,比如 user:info:{user_id}:{platform},1 亿个用户就要浪费大量内存。更经济的做法是使用 Hash 结构,将多个字段放在同一个 key 下,或者缩短 key 名。不要小看这点空间,缓存层的内存成本往往占了整体运行成本的一大部分。
5. 成本优化敏感点:压测数据如何指导资源决策
5.1 用压测数据画资源水位线,而不是靠经验拍脑袋
很多团队申请机器资源时的方式是人肉估算,大概觉得“线上流量会涨,那就加两台机器吧”。压测数据本身是最客观的成本决策依据。
我这次用压测产出的数据整理了一张资源水位表。把核心接口的 QPS、响应时间、CPU 使用率、内存使用率、数据库连接数、缓存命中率汇总起来,再对照当前的实例规格,就能看出来哪些资源是冗余的,哪些资源是不够的。比如压测时某个服务 CPU 始终只有 20%,即便在双倍流量下也只有 40%,那就完全不需要给这个服务扩容。
反过来,如果压测时 CPU 到 90% 而 QPS 还达不到业务目标,说明单机能力不足,需要扩容而不是调优。这两者方向完全不同,前者省钱,后者花钱,用数据说话才能避免决策失误。
5.2 三个最容易忽略的成本敏感点
第一个敏感点是实例规格的性能模型差异。同样是 4 核 8G,不同规格的 CPU 主频、网络带宽、突发性能能力都不同。压测时我测过一个服务,在普通共享型实例上跑到 300 并发响应时间就明显劣化,换到独享型实例后同样并发下 CPU 占用下降 30%。如果对延迟敏感,宁愿把实例数减少但规格升级,投入产出比可能更高。
第二个敏感点是存储和备份的成本。压测过程中我发现数据库的备份策略是全量备份每天执行一次,磁盘占用很大。优化方案是在压测数据支撑下,对核心表保留每日全量备份,非核心表改成每周全量加每日增量,磁盘成本直接降了 40%。
第三个敏感点是冷热数据分离。很多系统的历史数据访问频率很低,但是跟热数据放在同一个实例里,白白占内存和磁盘。压测数据显示,超过 3 个月的历史订单在被压测的接口中根本没有被访问到。把这些数据迁移到冷存储或归档表里,数据库的整体压力明显下降,实例规格也可以做降配。
5.3 容量规划里的水位线冗余度怎么定
压测得出了容量上限,但并不是说容量上限等于生产环境的规划目标。生产环境要考虑流量突刺和故障转移,必须预留部分冗余。常见的做法是设定一个目标水位,比如 CPU 使用率平时控制在 40% 以下,高峰期不超过 70%,这样即使某个实例挂了,流量转移到其他实例后也不会被打爆。
成本优化不是把水位线压得越低越好,而是找到性能和成本的平衡点。一个实例长期跑在 80% 以上,看似省了机器钱,但一旦流量波动,响应时间会迅速劣化,甚至会拖垮整个集群。我的经验是对于弹性扩容能力强的系统,可以把水位线放宽到 60% 上下,因为高峰来了立刻扩;对于扩容链路长的系统,水位线要留得更宽,留 50% 以下比较稳妥。
6. 常见问题排查与避坑实录
6.1 压测常见问题速查表
这里整理一下压测和优化过程中最常遇到的几个问题,方便大家直接对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 并发升高后响应时间陡增 | 线程池排队严重 | 查看线程池活跃线程数和队列长度 |
| CPU 使用率高但 QPS 上不去 | 代码存在大量计算或序列化 | 用 Arthas trace 定位热点方法 |
| 接口偶发超时,但平均耗时正常 | GC 停顿或网络抖动 | 查看 GC 日志,检查网络监控 |
| 数据库连接数打满 | 连接池泄漏或配置过大 | 查看活跃连接数,检查代码中连接释放逻辑 |
| 慢查询日志大量出现 | 索引缺失或 SQL 写法问题 | EXPLAIN 分析,必要时重写 SQL |
| 缓存命中率低 | key 设计不合理或过期时间太短 | 分析缓存 key 生成规则,调整过期策略 |
| 压测机 CPU 先打满 | 压测本机性能不足 | 分布式压测或调低并发 |
6.2 排查技巧一:先区分瓶颈在哪个层级再动手
压测时发现接口慢,不要急着改代码。先按“客户端到服务端、服务端到数据库、服务端到缓存”的链路逐层定位。一种简单的方式是临时把某次请求的缓存逻辑短路掉,看响应时间变化,如果去掉缓存后响应时间没有明显变化,说明本来命中的缓存就不多,问题很可能在缓存设计上。
6.3 排查技巧二:不要忽略压测工具本身带来的误差
jmeter 聚合报告的数据,在压测机性能不足时会出现明显的失真。我遇到过压测机在 300 并发后 CPU 跑满,导致所有接口响应时间都增加的现象,但这并不是被测服务的问题。遇到这种情况,先看压测机的资源消耗,如果已经很高,要么换更大的压测机,要么用多台压测机分担压力。
6.4 排查技巧三:缓存命中率要用日志说话
监控面板上的缓存命中率可以从全局看问题,但要定位具体业务场景的问题,还得靠日志。我在压测时给缓存操作加了一条请求维度日志,打印了本次请求的缓存 key、是否命中、回源耗时。通过对比日志和压测报告,定位到有一个业务接口的缓存 key 里带了时间戳,导致每次请求都回源,这个从全局监控里很难看出来,从日志里一眼就发现了。
7. 写在最后的几点实操感受
压测和成本优化这件事,做一轮容易,做出真正有价值的结果不容易。关键是不要把压测当成一个一次性的活动,而是把它变成常态化的质量保障手段。每轮压测的数据都要归档,跟上一轮做对比,这样才能发现系统有没有劣化的趋势。成本优化也是一样,不是这轮省了多少就结束了,而是要持续观察优化后的系统在实际流量下的表现。
我自己在多次压测中最大的体会是:性能问题的根因很少只出现在单层,服务端、数据库、缓存是互相牵引的关系。服务端线程数调优了,数据库压力就变大了;数据库索引优化了,缓存可能就不需要那么大的命中率了。最终的成本方案也必须基于这种联动关系来考虑,否则就会出现省了数据库的钱,却要花更多钱在服务端扩容上的尴尬局面。
最后再分享一个小技巧:在压测环境里把监控数据的保留周期拉长,建议至少保留三个月。因为单纯看一次压测结果,很难判断当时的资源消耗是常态还是偶发,有了历史数据做对比,很多决策会更有底气。
