业务系统性能改造手册 · 性能基线

固定性能实验的比较条件

2026-08-053 min read性能基线
摘要

性能改造前先把环境、数据、执行过程和指标定义写成可重建的基线协议;只有比较条件一致,前后差异才有决策价值。

一轮压测跑出 P99 = 800 ms,优化后变成 500 ms,能否据此宣布收益?还不能。第二轮也许换了机器,Redis 已经热起来,数据库数据少了一半,或者统计工具把超时请求排除在延迟分位数之外。数字确实变好了,但变化未必来自代码。

小编现阶段的判断是:性能改造的第一项产物应当是一份能重建测试现场的基线协议。它记录实验输入、执行过程和测量方式。如果协议不能固定这些比较条件,实验结果就只能算一次观察,不能用来决定某项改造是否保留。

本文用一个简化的订单系统作为示例,先把这份协议落到可填写的工程对象上。具体请求组合与到达节奏留到下一篇,瓶颈归因则留到本章第三篇。

一、先画出实验边界

假设有这样一个场景:我们要测试一个存量 Spring Boot 订单服务。它读写 MySQL,使用 Redis,向消息队列投递事件,并调用一个可控的下游模拟服务。这里的“可控”很重要——下游的延迟、错误和响应内容应由测试配置决定,不能碰运气依赖公网服务或共享测试环境。

先画清请求经过的组件:

text
1压测端 2 └─> Spring Boot 订单服务 3 ├─> MySQL 4 ├─> Redis 5 ├─> 消息队列 6 └─> 下游模拟服务

边界内的组件要纳入版本、资源和参数记录;边界外的因素要么隔离,要么作为已知干扰写下来。比如压测端自身的 CPU 已经打满,服务端结果再稳定也无法证明系统达到上限。又如共享 MySQL 同时承载其他测试任务,这轮数据只能标记为受干扰,不能和安静时段的结果直接比较。

实验目标也要写得足够窄。本篇示例可以定义为:“在固定资源和数据快照下,观察指定请求场景的吞吐量、延迟、错误率及关键资源占用。”它不回答系统能承载多少真实用户,也暂不回答哪一层是瓶颈。

二、把会改变结果的条件钉住

性能数据只有在实验边界环境数据与执行过程被固定后才具备比较价值 “环境一样”通常是一句过于宽松的描述。可比较的实验记录至少要让另一位开发者知道:代码运行在哪里、依赖是什么状态、数据从哪里来,以及一轮实验怎样开始和结束。

2.1 运行环境不只是一组 CPU 和内存数字

硬件或虚拟资源需要记录 CPU 配额、内存上限、磁盘类型和网络位置。容器环境还应记录 CPU 限额与节流情况、内存限制;数据库和消息队列如果不在同一台机器,也要记录各自资源,而不能只写应用实例配置。

软件侧记录可部署物的唯一标识,例如 Git 提交号或镜像摘要,以及 Java、Spring Boot、MySQL、Redis、消息队列的实际版本。版本尚未确定时保留字段,不要根据习惯补一个看起来合理的版本号。

参数只收集可能改变本次结果的部分:JVM 启动参数,应用线程池,数据库连接池,客户端超时,Redis 连接配置,消息生产参数和下游模拟配置。完整配置文件可以归档,但基线记录里仍需摘出关键差异,否则复查时很难知道两轮究竟改了什么。

后台干扰同样属于环境。定时任务、日志采集、数据库备份、共享宿主机上的其他任务,都可能制造波动。处理方式有三种:关闭、隔离,或者明确记录发生时段及影响。无法控制的干扰不能假装不存在。

2.2 数据要能恢复,而不只是“准备过”

订单系统的数据会在实验中变化。创建订单会增加行数和索引页,状态更新会改变可查询集合,缓存命中也会随访问逐渐升高。连续跑两轮而不恢复数据,输入条件已经不同。

数据协议应回答这些问题:

  • 数据集使用哪个快照或生成脚本,如何校验版本;
  • 订单、用户等关键实体的规模采用什么字段记录;
  • 订单状态、时间范围、热点键等分布如何描述;
  • 每轮前清空、回滚还是重新导入,恢复成功怎样校验;
  • MySQL 缓冲、Redis 键和应用本地缓存保留还是清理。

规模暂未确定时,可以写 ${ORDER_COUNT} 这样的待填写字段。若文档为了演示出现 100000,必须同时标明“演示值,非实测数据”。比规模更容易漏掉的是分布:同样数量的订单,全部属于一个用户与均匀分散到多个用户,对索引访问和缓存热点的影响并不相同。

每轮恢复完成后,最好执行一组轻量校验,例如关键表行数、各状态数量、测试账号数量以及 Redis 键数量。校验失败就停止实验。否则错误的数据准备会一直污染后面的漂亮图表。

2.3 把一次实验写成可执行流程

应用刚启动时,类加载、连接建立、缓存填充等状态还在变化。直接截取前几分钟数据,常常是在测启动过程。协议需要定义预热完成条件,例如“连续若干观察窗口内吞吐量和延迟不再明显漂移”。这里不硬给统一分钟数,因为不同系统达到稳态的时间并不相同。

正式采样要指定开始条件、采样时长和结束条件。采样期间不得临时发布、修改参数或补数据。发生重启、监控缺口、压测端饱和等异常时,给该轮打上无效标记并保留原因,不要从中挑一段最好看的区间。

单轮结果也不适合直接当基线。协议应预先规定重复次数及接受波动的规则,再完整保留每轮结果。重复多少次、允许多大波动,要结合实验成本和业务决策风险确定;在没有历史数据前,不应凭空给出一个“行业标准”。

三、先定义指标,再打开压测开关

吞吐延迟和错误率必须先定义互斥请求集合与统一分母不能在看到结果后改口径 指标名称相同,统计口径仍可能不同。吞吐量按已发请求、已完成请求还是成功请求计算?延迟是否包含客户端排队和网络时间?超时请求进入错误率后,是否还进入分位数?这些定义必须在看到结果之前确定。

先给请求计数设一套与工具无关的语义。attempted_requests 表示压测计划尝试产生的请求,分为真正启动的 started_requests 与压测端未能启动的 not_started_requests。已经启动的请求在观察截止时,要么进入互斥的终态,要么仍是 in_flight_requests

text
1attempted_requests = started_requests + not_started_requests 2started_requests = completed_requests + in_flight_requests 3completed_requests = succeeded + business_rejected + server_error 4 + timed_out + connection_failed

这里的 completed_requests 指“客户端已观察到终态”,不等于“收到正常响应”。达到协议所记超时阈值的请求统一归入 timed_out,无论当时处于建连还是等待响应阶段;未触发超时、但以 DNS、TCP 或 TLS 错误终止的请求归入 connection_failed。一个请求只能进入一个终态。正式结果应等待在途请求排空;无法排空时,必须保留 in_flight_requests,不能把它们从账上抹掉。

在这套集合关系下,本文建议至少固定下面几类口径:

  • 吞吐量completed_requests / 采样秒数,同时报告成功吞吐、实际启动速率、目标发送速率和未启动数量;不要用目标发送速率冒充完成吞吐。
  • P95/P99 延迟:主口径使用所有能够取得客户端终止耗时的 completed_requests,从发起请求计时到成功、拒绝、服务端异常、超时或连接失败终止;另报成功请求延迟。某类终态无法取得耗时时,要记录缺失数量和工具映射,不能静默排除。
  • 错误率(business_rejected + server_error + timed_out + connection_failed) / completed_requests。若业务拒绝不应视为技术错误,可以另报技术错误率,但不得改写上述总失败率;不同失败类型仍要分别计数。
  • 资源指标:记录采集来源和粒度,包括应用 CPU、内存、线程与连接占用,以及 MySQL、Redis、消息队列和压测端的相关资源。资源数据在本文用于复现实验条件,暂不据此下瓶颈结论。

还有两个容易让对比失真的细节。其一,不同监控系统的时间窗口可能错位,应统一时区并保存实验开始、预热结束、采样开始和采样结束时间。其二,百分位数不宜把多轮数据先取平均再当成总体分位数;保留每轮原始摘要和统计方法,后续才能解释聚合结果。

四、一份可以直接复用的基线协议

下面的 YAML 是记录结构,不绑定具体压测工具。${...} 表示当前项目必须填写的值;example 下的内容只说明字段写法,不代表真实测试结果。

yaml
1experiment: 2 id: ${EXPERIMENT_ID} 3 purpose: ${ONE_SENTENCE_PURPOSE} 4 owner: ${OWNER} 5 changeUnderTest: baseline 6 timezone: ${TIMEZONE} 7 8boundary: 9 service: order-service 10 included: 11 - mysql 12 - redis 13 - message-queue 14 - downstream-stub 15 excluded: [] 16 17artifacts: 18 gitCommit: ${GIT_COMMIT} 19 imageDigest: ${IMAGE_DIGEST} 20 configArchive: ${CONFIG_ARCHIVE_PATH} 21 22loadGenerator: 23 tool: ${TOOL_NAME} 24 version: ${TOOL_VERSION} 25 scriptCommitOrDigest: ${SCRIPT_COMMIT_OR_DIGEST} 26 location: ${HOST_OR_ZONE} 27 cpuLimit: ${CPU_LIMIT} 28 memoryLimit: ${MEMORY_LIMIT} 29 networkInterface: ${NETWORK_INTERFACE} 30 saturationEvidence: 31 cpuMetric: ${METRIC_QUERY_OR_FILE} 32 memoryMetric: ${METRIC_QUERY_OR_FILE} 33 notStartedMetric: ${TOOL_METRIC_MAPPING} 34 35environment: 36 application: 37 location: ${HOST_OR_ZONE} 38 cpuLimit: ${CPU_LIMIT} 39 memoryLimit: ${MEMORY_LIMIT} 40 disk: ${DISK_TYPE_AND_LIMIT} 41 javaVersion: ${JAVA_VERSION} 42 springBootVersion: ${SPRING_BOOT_VERSION} 43 jvmArgs: ${JVM_ARGS} 44 dependencies: 45 mysql: 46 version: ${MYSQL_VERSION} 47 location: ${HOST_OR_ZONE} 48 cpuLimit: ${CPU_LIMIT} 49 memoryLimit: ${MEMORY_LIMIT} 50 disk: ${DISK_TYPE_AND_LIMIT} 51 keyParameters: ${PARAMETER_SNAPSHOT_OR_REFERENCE} 52 redis: 53 version: ${REDIS_VERSION} 54 location: ${HOST_OR_ZONE} 55 cpuLimit: ${CPU_LIMIT} 56 memoryLimit: ${MEMORY_LIMIT} 57 keyParameters: ${PARAMETER_SNAPSHOT_OR_REFERENCE} 58 messageQueue: 59 productAndVersion: ${MQ_PRODUCT_AND_VERSION} 60 location: ${HOST_OR_ZONE} 61 cpuLimit: ${CPU_LIMIT} 62 memoryLimit: ${MEMORY_LIMIT} 63 disk: ${DISK_TYPE_AND_LIMIT} 64 keyParameters: ${PARAMETER_SNAPSHOT_OR_REFERENCE} 65 network: 66 topology: ${TOPOLOGY_FILE_OR_DESCRIPTION} 67 loadGeneratorToApp: ${ROUTE_OR_ZONE_RELATION} 68 appToDependencies: ${ROUTE_OR_ZONE_RELATION} 69 shapingOrProxy: ${NONE_OR_CONFIGURATION_REFERENCE} 70 downstreamStub: 71 versionOrDigest: ${VERSION_OR_DIGEST} 72 location: ${HOST_OR_ZONE} 73 cpuLimit: ${CPU_LIMIT} 74 memoryLimit: ${MEMORY_LIMIT} 75 latencyProfile: ${CONFIGURATION_REFERENCE} 76 errorProfile: ${CONFIGURATION_REFERENCE} 77 responseProfile: ${CONFIGURATION_REFERENCE} 78 keyParameters: 79 serverThreads: ${SERVER_THREADS} 80 datasourceMaxPoolSize: ${DB_POOL_SIZE} 81 requestTimeout: ${REQUEST_TIMEOUT} 82 interference: 83 scheduledJobs: ${DISABLED_OR_RECORDED} 84 sharedWorkloads: ${NONE_OR_DESCRIPTION} 85 86data: 87 snapshotId: ${SNAPSHOT_ID_OR_SCRIPT_COMMIT} 88 scale: 89 orders: ${ORDER_COUNT} 90 users: ${USER_COUNT} 91 distributionFile: ${DISTRIBUTION_FILE} 92 resetCommand: ${SAFE_RESET_PROCEDURE_REFERENCE} 93 validation: 94 - ${VALIDATION_QUERY_AND_EXPECTATION} 95 cachePolicy: ${CLEAR_OR_RETAIN_WITH_REASON} 96 97execution: 98 loadScenario: ${SCENARIO_ID} 99 startedAt: ${ISO_8601_TIME} 100 warmup: 101 startedAt: ${ISO_8601_TIME} 102 endedAt: ${ISO_8601_TIME} 103 completionRule: ${STABLE_STATE_RULE} 104 sampling: 105 startedAt: ${ISO_8601_TIME} 106 endedAt: ${ISO_8601_TIME} 107 duration: ${DURATION} 108 repetitions: ${REPETITIONS} 109 invalidationRules: 110 - load-generator-saturated 111 - application-restarted 112 - metrics-gap 113 recoveryBetweenRuns: ${RECOVERY_PROCEDURE} 114 115metrics: 116 requestSets: 117 attemptedRequests: started_requests + not_started_requests 118 startedRequests: completed_requests + in_flight_requests 119 completedRequests: >- 120 succeeded + business_rejected + server_error 121 + timed_out + connection_failed 122 terminalStatesAreMutuallyExclusive: true 123 toolMapping: ${TOOL_METRICS_TO_REQUEST_SETS} 124 throughput: 125 definition: completed_requests / sampling_seconds 126 alsoReport: 127 - succeeded / sampling_seconds 128 - started_requests / sampling_seconds 129 - target_send_rate 130 - not_started_requests 131 latency: 132 clock: client 133 start: request_attempt_started 134 end: terminal_state_observed 135 percentiles: [p95, p99] 136 primarySample: all_completed_requests_with_observable_terminal_duration 137 secondarySample: succeeded 138 timeoutThreshold: ${TIMEOUT_THRESHOLD} 139 missingDurationRule: record_count_and_tool_mapping 140 errorRate: 141 denominator: completed_requests 142 numerator: business_rejected + server_error + timed_out + connection_failed 143 categories: [business_rejected, server_error, timed_out, connection_failed] 144 resources: 145 scrapeInterval: ${INTERVAL} 146 sources: ${METRICS_SOURCE_LIST} 147 148runs: 149 - runId: ${RUN_ID} 150 valid: ${TRUE_OR_FALSE} 151 invalidReason: ${NONE_OR_REASON} 152 startedAt: ${ISO_8601_TIME} 153 warmupEndedAt: ${ISO_8601_TIME} 154 samplingStartedAt: ${ISO_8601_TIME} 155 samplingEndedAt: ${ISO_8601_TIME} 156 counts: 157 attemptedRequests: ${COUNT} 158 startedRequests: ${COUNT} 159 notStartedRequests: ${COUNT} 160 completedRequests: ${COUNT} 161 inFlightRequests: ${COUNT} 162 succeeded: ${COUNT} 163 businessRejected: ${COUNT} 164 serverError: ${COUNT} 165 timedOut: ${COUNT} 166 connectionFailed: ${COUNT} 167 summary: ${SUMMARY_FILE_OR_OBJECT} 168 rawData: 169 location: ${STORAGE_LOCATION} 170 retentionUntil: ${ISO_8601_TIME} 171 queryOrDownload: ${SAFE_QUERY_OR_DOWNLOAD_REFERENCE} 172 checksum: ${CHECKSUM} 173 174records: 175 facts: 176 - ${DIRECTLY_OBSERVED_OR_VERIFIED_ITEM} 177 assumptions: 178 - ${ASSUMPTION_USED_TO_DESIGN_EXPERIMENT} 179 judgmentsToVerify: 180 - ${HYPOTHESIS_FOR_LATER_EXPERIMENT} 181 182example: 183 note: 演示字段格式,以下内容不是实测结果 184 fact: 采样期间应用进程未重启 185 assumption: 当前数据分布可代表待验证的业务场景 186 judgmentToVerify: 缓存状态可能影响尾延迟,需另做单变量实验

这份记录可以放进版本库,并让压测报告引用 experiment.id、提交号和数据快照。大体积监控原始数据不一定适合直接提交,但要留下存储位置、保留期和查询方式。配置中如含密码、令牌或内部地址,应先脱敏,基线协议只记录安全的引用。

4.1 事实、假设和待验证判断不能混写

“应用使用 2 核 CPU 限额”可以由部署配置核验,属于事实。“这批订单能代表常见数据分布”通常是实验设计假设。“尾延迟来自数据库连接等待”则是待验证判断。

三者混在一起,后续很容易把推测当成结论。记录时可以采用这条简单标准:能从配置、日志、快照或采集数据直接核验的,放进事实;为了开展实验而暂时接受的前提,放进假设;需要通过改单一变量来证实或推翻的,放进待验证判断。

本文只负责把判断写进协议。怎样用负载复现业务行为,以及怎样建立瓶颈证据链,后面的文章再处理。

五、怎样判断两轮结果能不能比较

准备比较改造前后数据时,先不要计算改善比例,逐项对照协议:

  1. 部署物之外是否还有参数、资源或依赖版本发生变化;
  2. 两轮是否从同一数据快照和缓存策略开始;
  3. 预热完成条件、采样窗口和重复规则是否一致;
  4. 请求场景与下游模拟配置是否相同;
  5. 指标定义、采集来源和错误处理规则是否改变;
  6. 是否存在压测端饱和、共享资源争用或监控缺口。

发现差异不意味着实验一定报废。可以先判断差异是否正是本轮要研究的唯一变量;若不是,就恢复条件后重跑,或者将结果明确标成“不可直接比较”。不要靠解释把两组不同实验拼成一条趋势线。

基线协议也不是冻结所有现实条件。它服务于相对比较:每次只主动改变计划中的变量,其余条件保持可核验的一致。新变量无法隔离时,就把限制写进结论,缩小结论的适用范围。

到这里,我们还没有得到订单系统的 TPS,也没有证明任何瓶颈。得到的是更基础的东西:任何人都能据此重建测试现场,并知道哪些数字可以放在一起比较。下一步才是在这套协议约束下定义订单系统的基准负载。