2900 字
约 9 分钟
17
S16-X16 · 性能测试的设计与结果解读
2024-12-31
2026-11-09

压测最常见的失败不是"压不上去",而是压出了一堆数字,没人能据此做决定。平均响应时间 80ms、TPS 3000,然后呢?这个服务该扩几台、瓶颈在哪、能不能扛住下一个大促,全答不上来。

原因多半在设计阶段就定了:没有 SLA、没有负载模型、只压了一个接口、数据只有十个 ID。这篇按"指标 → 负载模型 → 脚本 → 读结果 → 找瓶颈"拆开讲。


1. 四种性能测试,各自回答一个问题

类型 负载形态 回答的问题 典型时长
基准 baseline 单机、低并发 当前版本的下限量级 分钟级
负载(load) 目标并发/到达率 预期流量下是否达标 十几分钟
压力(stress) 阶梯递增直到崩 容量上限在哪、怎么崩 半小时级
稳定性(soak) 目标负载长时间恒定 有无泄漏、衰减 数小时到一天

四者不能混:用压力测试的数据去回答"能不能扛住日常流量",通常会高估;用基准数据去回答容量上限,通常会低估。


2. 先定义指标,再压

指标 采集点 为什么不能用平均值
延迟分位数 p50/p90/p95/p99 服务端 + 客户端各一份 平均值会把长尾藏起来
吞吐(req/s 或 rpm) 服务端 客户端统计会被自身瓶颈污染
错误率(按状态码与异常类型分) 服务端 + 网关 只看 5xx 会漏掉业务错误
资源占用(CPU、内存、连接池、线程池队列) JVM/容器指标 没有资源数据就找不到瓶颈
下游影响(DB QPS、慢查询、缓存命中率) 下游监控 压测常把下游打崩而自己"看起来很好"
# Spring Boot 3.3.x:让服务端输出可聚合的分位数直方图
management:
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      slo:
        http.server.requests: 100ms,300ms,1s

没有 SLA 的数字无法判断好坏。SLA 要可判定:"500 req/s 下 p95 < 300ms 且错误率 < 0.1%",而不是"要快"。


3. 负载模型的两种,差别在排队

模型 参数 客户端行为 风险
闭合(closed) 并发用户数 / VU 数 上一个请求回来才发下一个 会低估排队:响应变慢时自动降速
开放(open) 到达率(req/s) 按固定速率发,不等响应 更接近真实入口流量;需要预留 VU 或丢弃

真实用户是闭合的(点一下等结果),入口流量是开放的(按到达率进来)。只测闭合模型,系统变慢会让压测机跟着变慢,于是压不到真正的饱和点——这就是"线上比压测差"最常见的解释。

// k6:开放模型,按到达率驱动
export const options = {
  scenarios: {
    open_load: {
      executor: 'constant-arrival-rate',
      rate: 500, timeUnit: '1s',
      duration: '10m',
      preAllocatedVUs: 200,   // 预留不足会报 dropped_iterations
      maxVUs: 800,
    },
  },
};

dropped_iterations 非零说明压测机没跟上目标速率,此时测的是压测机上限,不是被测服务。


4. 场景三要素:比例、思考时间、数据分布

要素 设错的表现 正确做法
业务比例 只压最热的查询接口,写路径没压 按生产访问日志的接口占比配权重
思考时间 零间隔连续请求,缓存全命中 sleep(1~3s) 或按真实会话间隔
数据分布 同一批 ID 反复打,命中率虚高 从 ID 池随机取,池子远大于缓存
import { sleep } from 'k6';
import exec from 'k6/execution';

export default function () {
  const r = Math.random();
  if (r < 0.70) {
    http.get(`${BASE}/api/items/${pickId()}`);          // 读
  } else if (r < 0.95) {
    http.post(`${BASE}/api/orders`, JSON.stringify(pickOrder()),
              { headers: { 'Content-Type': 'application/json' } });
  } else {
    http.get(`${BASE}/api/search?q=${pickKeyword()}`);  // 搜索,最贵
  }
  sleep(1 + Math.random() * 2);
}

比例别拍脑袋:从网关访问日志按路径聚类统计,比"我们觉得读多写少"可靠。


5. 脚本要点:参数化、关联、断言、超时

要点 做法 不做会怎样
参数化 数据池 + 随机/轮询取值,池子大于缓存 缓存全命中,测出假的高吞吐
关联 从响应提取 token/ID 传给下一步 第二步全部 401/404,测的是错误路径
断言 校验状态码与关键字段,而非只算耗时 500 也算"成功",延迟好看但全错
超时 显式设置请求超时 挂起连接占满 VU,压测机自己先崩
分组 用 group 标注业务流程 结果里只有一条混合曲线,无法归因
import { check } from 'k6';
import { Trend } from 'k6/metrics';

const orderLatency = new Trend('order_latency', true);

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<300', 'p(99)<800'],
    http_req_failed: ['rate<0.001'],
    order_latency: ['p(95)<500'],
  },
};

export default function () {
  const res = http.post(`${BASE}/api/orders`, body, params);
  orderLatency.add(res.timings.duration);
  check(res, {
    'status is 201': (r) => r.status === 201,
    'has orderId': (r) => !!r.json('orderId'),
  });
}

阈值让压测有结论:跑完直接"通过/不通过",而不是留一堆图让人自由解读。


6. 预热与冷启动

首轮数据几乎总不可用:JIT 未编译、连接池为空、缓存为空、Bean 刚初始化。处理方式:

  • 正式压测前跑 1–3 分钟预热,这段单独标记、不进统计;
  • "冷启动"与"稳态"都要有结论,但不能混在一条曲线里;
  • 容器化环境注意 CPU 配额与 CPU 节流(cpu.throttled),冷启动阶段的抖动常被误读为代码问题。

7. 结果解读的四个陷阱

陷阱 表现 正确读法
用平均值掩盖长尾 平均 80ms,但 p99 是 3s 先看分位数曲线,平均值只作参考
忽略错误率只看延迟 延迟很漂亮,因为大量请求超时后快速失败 延迟与错误率必须同图看
压测机自身成为瓶颈 CPU 打满、dropped_iterations 上升 压测机与被测服务分开,压测机资源留 50% 余量
跨环境数据不可比 开发机 4 核 vs 生产 32 核 只比同环境、同数据量、同版本的数

再补一条:客户端与服务端两套延迟都要看,差值就是网络与排队。服务端 50ms、客户端 400ms,瓶颈在链路或客户端连接池。


8. 找拐点:阶梯加压

export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-arrival-rate',
      startRate: 50, timeUnit: '1s',
      preAllocatedVUs: 200, maxVUs: 2000,
      stages: [
        { duration: '2m', target: 200 },   // 预热段
        { duration: '5m', target: 800 },   // 线性加压
        { duration: '5m', target: 1600 },
        { duration: '3m', target: 1600 },  // 保持,观察是否衰减
      ],
    },
  },
};

读法:把吞吐与 p95 画在一起。吞吐还涨而 p95 抬升,是进入排队;吞吐不涨而 p95 陡升,就是拐点;吞吐回落说明已开始拒绝或超时。拐点负载是容量上限候选值,再留余量。


9. 瓶颈定位:按饱和度,不按猜测

顺序 看什么 饱和信号
1 应用 线程池队列、GC 停顿、http.server.requests 分位数 队列积压、Full GC 频繁
2 数据库 活跃连接数、慢查询、锁等待、Buffer Pool 命中 连接池耗尽、Threads_running 顶到上限
3 中间件 Redis 命中率与延迟、MQ 积压、ES 段合并 命中率骤降、p99 抬升
4 网络/基础设施 带宽、重传、容器 CPU 节流 重传率上升、throttled 时间增长

原则:先找"哪个资源的饱和度先到 100%",而不是先怀疑某段代码。资源没饱和而延迟高,才回头看锁竞争、GC、外部调用。


10. 稳定性测试:短跑测不出来的东西

观察项 短跑(10 分钟) 长跑(数小时)
内存 看不到泄漏 老年代只涨不落即为泄漏候选
连接池 尺寸稳定 泄漏表现为活跃连接数单调上升
缓存 命中率虚高 膨胀后触发淘汰,延迟出现周期性尖刺
日志/磁盘 无感 日志爆量写满磁盘,服务假死
定时任务叠加 未触发 整点批处理与压测叠加,延迟突增

长跑必须固定负载,否则分不清"加压变慢"与"泄漏变慢"。下游监控要同步看:稳定性问题常先在下游暴露。


11. 排错

现象 原因 处理
目标速率上不去,dropped_iterations 上升 压测机能力不足或 maxVUs 太小 提高 VU 上限、拆分压测机、减少脚本内计算
延迟很低但错误率高 快速失败被算作"快" 断言状态码与业务字段,阈值加 http_req_failed
压测机 CPU 打满 压测机与被测服务同机 / 脚本过重 分离机器;把参数化数据改用 SharedArray
吞吐上不去,服务端 CPU 也不高 客户端连接池或 DNS 解析成瓶颈 复用连接、预热 DNS、减少 TLS 握手
压测时下游监控告警 只关注被测服务 把下游指标纳入同一张看板
首轮数据比稳态差很多 冷启动、JIT、空缓存 加预热段并在结果里剔除
长跑后期延迟周期性尖刺 缓存淘汰、GC、定时任务 看 GC 日志与任务时间表,用固定负载复现

12. 边界:压测给不了什么

问题 压测能否回答 该用什么
容量上限量级 能(同环境、同数据分布) 阶梯加压
线上真实流量下的表现 不能 全链路压测、线上影子流量
某段代码为什么慢 不能 profiler、火焰图、GC 日志
故障注入下的行为 不能 混沌实验
成本与容量的最优配比 不能 容量规划 + 成本模型

一条经验:压测回答"给定条件下能扛多少",不回答"为什么"。 前者靠场景设计,后者靠观测工具;混在一次压测里,两边都做不好。


13. 自测

  1. 闭合模型为什么会低估排队?开放模型用什么参数驱动?
  2. 没有 SLA 的压测数字,为什么无法判断好坏?
  3. 数据只有同一批 ID,会得到什么样的假结论?
  4. 延迟很好看但错误率很高,可能的原因是什么?
  5. 吞吐不再上涨而 p95 陡升,说明到了什么点?
  6. 定位瓶颈时,为什么先看资源饱和度而不是先看代码?
S16-X16 · 性能测试的设计与结果解读
https://www.clxhxhhr.top/posts/5971/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。