业务系统性能改造手册 · 性能基线
固定性能实验的比较条件
性能改造前先把环境、数据、执行过程和指标定义写成可重建的基线协议;只有比较条件一致,前后差异才有决策价值。
一轮压测跑出 P99 = 800 ms,优化后变成 500 ms,能否据此宣布收益?还不能。第二轮也许换了机器,Redis 已经热起来,数据库数据少了一半,或者统计工具把超时请求排除在延迟分位数之外。数字确实变好了,但变化未必来自代码。
小编现阶段的判断是:性能改造的第一项产物应当是一份能重建测试现场的基线协议。它记录实验输入、执行过程和测量方式。如果协议不能固定这些比较条件,实验结果就只能算一次观察,不能用来决定某项改造是否保留。
本文用一个简化的订单系统作为示例,先把这份协议落到可填写的工程对象上。具体请求组合与到达节奏留到下一篇,瓶颈归因则留到本章第三篇。
一、先画出实验边界
假设有这样一个场景:我们要测试一个存量 Spring Boot 订单服务。它读写 MySQL,使用 Redis,向消息队列投递事件,并调用一个可控的下游模拟服务。这里的“可控”很重要——下游的延迟、错误和响应内容应由测试配置决定,不能碰运气依赖公网服务或共享测试环境。
先画清请求经过的组件:
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:
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 下的内容只说明字段写法,不代表真实测试结果。
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 限额”可以由部署配置核验,属于事实。“这批订单能代表常见数据分布”通常是实验设计假设。“尾延迟来自数据库连接等待”则是待验证判断。
三者混在一起,后续很容易把推测当成结论。记录时可以采用这条简单标准:能从配置、日志、快照或采集数据直接核验的,放进事实;为了开展实验而暂时接受的前提,放进假设;需要通过改单一变量来证实或推翻的,放进待验证判断。
本文只负责把判断写进协议。怎样用负载复现业务行为,以及怎样建立瓶颈证据链,后面的文章再处理。
五、怎样判断两轮结果能不能比较
准备比较改造前后数据时,先不要计算改善比例,逐项对照协议:
- 部署物之外是否还有参数、资源或依赖版本发生变化;
- 两轮是否从同一数据快照和缓存策略开始;
- 预热完成条件、采样窗口和重复规则是否一致;
- 请求场景与下游模拟配置是否相同;
- 指标定义、采集来源和错误处理规则是否改变;
- 是否存在压测端饱和、共享资源争用或监控缺口。
发现差异不意味着实验一定报废。可以先判断差异是否正是本轮要研究的唯一变量;若不是,就恢复条件后重跑,或者将结果明确标成“不可直接比较”。不要靠解释把两组不同实验拼成一条趋势线。
基线协议也不是冻结所有现实条件。它服务于相对比较:每次只主动改变计划中的变量,其余条件保持可核验的一致。新变量无法隔离时,就把限制写进结论,缩小结论的适用范围。
到这里,我们还没有得到订单系统的 TPS,也没有证明任何瓶颈。得到的是更基础的东西:任何人都能据此重建测试现场,并知道哪些数字可以放在一起比较。下一步才是在这套协议约束下定义订单系统的基准负载。