业务系统性能改造手册 · 性能基线
构造订单系统的基准负载
把订单系统的请求组合、到达节奏、数据分布和下游行为写成可执行负载模型,并通过阶梯升压和重复实验得到可复现的首轮基线。
一个订单详情接口跑出很高的 TPS,不代表订单系统能承受同样规模的业务流量。真实访问里还混着列表查询、状态轮询和订单提交;有些请求集中打到少量热点订单,有些会触发数据库写入和下游调用。把这些差异抹平,只保留单接口恒定并发,最终测到的往往是一个很难用于改造决策的数字。
小编现阶段的判断是:基准负载要描述业务请求怎样到达系统,而不只是描述压测端开了多少线程。请求组合、到达节奏、数据命中分布和下游行为共同决定服务端实际承担的工作。
上一篇已经固定了环境、数据快照和指标口径。本文继续使用那个简化的订单服务,把负载模型、可执行脚本和首轮报告模板补齐。具体瓶颈归因留到下一篇。
一、先把业务流量翻译成场景
负载设计通常从接口清单开始,但不能停在接口清单。GET /orders/{id} 在两个场景里的含义可能完全不同:用户刚提交订单后的短时轮询,会集中访问新订单;客服查历史订单,则更接近长尾读取。路径相同,缓存命中、索引访问和下游行为都可能不同。
这个示例保留四类请求:
| 业务动作 | 示例接口 | 数据特征 | 主要用途 |
|---|---|---|---|
| 查询订单列表 | GET /orders?userId=... | 用户维度,返回多条记录 | 模拟常规浏览 |
| 查询订单详情 | GET /orders/{id} | 热点与长尾订单混合 | 模拟详情查看 |
| 提交订单 | POST /orders | 新幂等键、新订单数据 | 产生写入和后续状态变化 |
| 查询订单状态 | GET /orders/{id}/status | 偏向刚创建、未完成订单 | 模拟提交后的状态轮询 |
比例不能凭经验随手填写。真实系统可以从网关日志、访问日志或监控中按业务时段统计请求量,再排除健康检查、爬虫、内部补偿等非目标流量。缺少真实数据时,先把比例写成待填变量:
1requestMix:
2 listOrders: ${LIST_WEIGHT}
3 getOrderDetail: ${DETAIL_WEIGHT}
4 createOrder: ${CREATE_WEIGHT}
5 getOrderStatus: ${STATUS_WEIGHT}
6 validation: sum(weights) == 1脚本中可以附一组仅供运行演示的比例,例如 30% / 40% / 10% / 20%,但报告必须标记为“演示值,未由生产流量验证”。它能验证脚本是否可运行,不能代表任何真实业务。
二、并发数解释不了请求怎样到达
固定 100 个虚拟用户不断请求,输入强度会受接口响应时间反向影响:服务端越慢,每个用户下一次发起请求越晚,实际到达率随之下降。它适合模拟“用户完成一次操作后再做下一次操作”的封闭模型,却不适合代表外部流量按固定速率涌入的场景。
订单系统的基准负载需要先选到达模型,再确定并发资源:
- 浏览与人工操作可以使用封闭模型,并加入符合场景的思考时间;
- 网关转发、回调或上游批量任务更适合开放模型,按目标到达率发起请求;
- 无论采用哪一种,都要同时记录目标速率、实际启动速率、完成吞吐和压测端未启动请求。
思考时间属于封闭会话模型。用户从列表点进详情、提交后再查询状态,本来就存在操作间隔,这种脚本应在一次迭代里串联动作,并在动作之间等待。本文的可执行示例选择开放到达模型:每次迭代只产生一个独立业务请求,由 rate 和 timeUnit 控制启动节奏,迭代末尾不再调用 sleep()。两种模型不能靠同一个等待参数混写。
2.1 热点和长尾要由数据选择器制造
从订单 ID 列表中完全均匀随机抽取,通常会高估数据分散程度。订单查询可能明显偏向近期订单、处理中订单或少量热门商品关联订单。负载模型应将数据池分层,而不是在脚本里随手拼随机 ID。
1{
2 "hotOrderIds": ["${HOT_ORDER_ID_1}", "${HOT_ORDER_ID_2}"],
3 "longTailOrderIds": ["${LONG_TAIL_ORDER_ID_1}"],
4 "activeUserIds": ["${USER_ID_1}"],
5 "pendingOrderIds": ["${PENDING_ORDER_ID_1}"]
6}每个集合都应由固定数据快照导出,并保存生成脚本或文件摘要。热点选择概率、近期订单时间范围、订单状态分布来自已有观测;拿不到观测时就标记假设,后续不能把这一轮结果包装成生产容量。
2.2 下游模拟必须属于负载模型
提交订单是否调用下游、下游延迟如何分布、是否返回错误,会直接改变应用线程占用和请求耗时。若上一轮下游固定响应,下一轮却随机抖动,两组数据无法直接比较。
基准场景至少记录下游模拟服务的配置版本、延迟档位、错误档位和响应内容档位。基线阶段可以先用固定配置获得稳定输入;异常和慢下游场景属于后续稳定性治理,不混入这轮稳态结果。
三、让测试数据和脚本本身可重复
压测脚本既是流量发生器,也是实验的一部分。它要能复用数据,正确关联用户与订单,还不能先把压测机拖垮。
订单提交需要唯一的业务幂等键,订单列表需要有效用户,状态查询需要命中处于目标状态的订单。建议在正式采样前生成或导出只读数据池,把写请求所需的唯一值在压测端按运行编号和虚拟用户编号组合生成。不要让每次请求先访问数据库取一个 ID;那会引入额外系统,也可能让数据准备成为隐藏瓶颈。
下面是一份可以直接交给 k6 执行的基准脚本。工具版本暂未确定,应在基线协议里锁定实际版本;接口字段、业务拒绝状态码和数据文件需要按案例工程的真实契约准备。脚本在初始化阶段检查必填参数、权重和数据池,错误时直接退出,不带着坏输入进入采样。
1import http from 'k6/http';
2import { check } from 'k6';
3import { SharedArray } from 'k6/data';
4import exec from 'k6/execution';
5import { Counter } from 'k6/metrics';
6
7const required = [
8 'BASE_URL', 'RUN_ID', 'TARGET_RATE', 'DURATION',
9 'PREALLOCATED_VUS', 'MAX_VUS',
10 'LIST_WEIGHT', 'DETAIL_WEIGHT', 'CREATE_WEIGHT', 'STATUS_WEIGHT',
11 'HOT_PROBABILITY', 'BUSINESS_REJECTED_STATUSES', 'SUMMARY_FILE',
12];
13
14for (const name of required) {
15 if (__ENV[name] === undefined || __ENV[name] === '') {
16 throw new Error(`missing required environment variable: ${name}`);
17 }
18}
19
20function positiveNumber(name, { allowZero = false } = {}) {
21 const value = Number(__ENV[name]);
22 const valid = Number.isFinite(value) && (allowZero ? value >= 0 : value > 0);
23 if (!valid) throw new Error(`${name} must be ${allowZero ? 'non-negative' : 'positive'}`);
24 return value;
25}
26
27const baseUrl = __ENV.BASE_URL.replace(/\/$/, '');
28const targetRate = positiveNumber('TARGET_RATE');
29const preAllocatedVUs = positiveNumber('PREALLOCATED_VUS');
30const maxVUs = positiveNumber('MAX_VUS');
31const hotProbability = positiveNumber('HOT_PROBABILITY', { allowZero: true });
32const weights = {
33 list: positiveNumber('LIST_WEIGHT', { allowZero: true }),
34 detail: positiveNumber('DETAIL_WEIGHT', { allowZero: true }),
35 create: positiveNumber('CREATE_WEIGHT', { allowZero: true }),
36 status: positiveNumber('STATUS_WEIGHT', { allowZero: true }),
37};
38
39if (hotProbability > 1) throw new Error('HOT_PROBABILITY must be between 0 and 1');
40const weightSum = Object.values(weights).reduce((sum, value) => sum + value, 0);
41if (Math.abs(weightSum - 1) > 1e-9) throw new Error(`request weights must sum to 1, got ${weightSum}`);
42if (!Number.isInteger(targetRate) || !Number.isInteger(preAllocatedVUs) || !Number.isInteger(maxVUs)) {
43 throw new Error('TARGET_RATE, PREALLOCATED_VUS and MAX_VUS must be integers');
44}
45if (maxVUs < preAllocatedVUs) throw new Error('MAX_VUS must be >= PREALLOCATED_VUS');
46
47const businessRejectedStatuses = new Set(
48 __ENV.BUSINESS_REJECTED_STATUSES.split(',').map((value) => Number(value.trim())),
49);
50if ([...businessRejectedStatuses].some((value) => !Number.isInteger(value) || value < 400 || value > 499)) {
51 throw new Error('BUSINESS_REJECTED_STATUSES must be comma-separated 4xx codes');
52}
53
54function loadPool(name, path, requiredField) {
55 return new SharedArray(name, () => {
56 const items = JSON.parse(open(path));
57 if (!Array.isArray(items) || items.length === 0) {
58 throw new Error(`${name} data file must contain a non-empty JSON array`);
59 }
60 if (items.some((item) => item === null || typeof item !== 'object' || !item[requiredField])) {
61 throw new Error(`each ${name} item needs ${requiredField}`);
62 }
63 return items;
64 });
65}
66
67const users = loadPool('users', './data/users.json', 'userId');
68const hotOrders = loadPool('hot-orders', './data/hot-orders.json', 'orderId');
69const longTailOrders = loadPool('long-tail-orders', './data/long-tail-orders.json', 'orderId');
70const pendingOrders = loadPool('pending-orders', './data/pending-orders.json', 'orderId');
71
72const succeeded = new Counter('terminal_succeeded');
73const businessRejected = new Counter('terminal_business_rejected');
74const serverError = new Counter('terminal_server_error');
75const timedOut = new Counter('terminal_timed_out');
76const connectionFailed = new Counter('terminal_connection_failed');
77
78export const options = {
79 scenarios: {
80 baseline: {
81 executor: 'constant-arrival-rate',
82 rate: targetRate,
83 timeUnit: '1s',
84 duration: __ENV.DURATION,
85 preAllocatedVUs,
86 maxVUs,
87 gracefulStop: __ENV.GRACEFUL_STOP ?? '30s',
88 },
89 },
90 summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
91};
92
93function pick(items) {
94 return items[Math.floor(Math.random() * items.length)];
95}
96
97function weightedAction() {
98 const value = Math.random();
99 if (value < weights.list) return 'list';
100 if (value < weights.list + weights.detail) return 'detail';
101 if (value < weights.list + weights.detail + weights.create) return 'create';
102 return 'status';
103}
104
105function pickOrderId() {
106 return Math.random() < hotProbability ? pick(hotOrders).orderId : pick(longTailOrders).orderId;
107}
108
109export default function () {
110 const action = weightedAction();
111 let response;
112
113 if (action === 'list') {
114 response = http.get(`${baseUrl}/orders?userId=${pick(users).userId}`, { tags: { action } });
115 } else if (action === 'detail') {
116 response = http.get(`${baseUrl}/orders/${pickOrderId()}`, { tags: { action } });
117 } else if (action === 'status') {
118 response = http.get(`${baseUrl}/orders/${pick(pendingOrders).orderId}/status`, { tags: { action } });
119 } else {
120 const idempotencyKey = `${__ENV.RUN_ID}-${exec.vu.idInTest}-${exec.scenario.iterationInTest}`;
121 response = http.post(
122 `${baseUrl}/orders`,
123 JSON.stringify({ userId: pick(users).userId, items: [{ sku: 'DEMO-SKU', quantity: 1 }] }),
124 {
125 headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
126 tags: { action },
127 },
128 );
129 }
130
131 const terminal = classify(response, action);
132 check(response, { 'request reached one terminal state': () => terminal !== undefined });
133}
134
135function classify(response, action) {
136 const tags = { action };
137
138 // k6 error code 1050 represents an HTTP request timeout.
139 if (response.error_code === 1050) {
140 timedOut.add(1, tags);
141 return 'timed_out';
142 }
143 // status=0 means no HTTP response was observed. Non-timeout transport
144 // failures are grouped as connection_failed for the baseline protocol.
145 if (response.status === 0) {
146 connectionFailed.add(1, tags);
147 return 'connection_failed';
148 }
149 if (response.status >= 200 && response.status < 400) {
150 succeeded.add(1, tags);
151 return 'succeeded';
152 }
153 if (businessRejectedStatuses.has(response.status)) {
154 businessRejected.add(1, tags);
155 return 'business_rejected';
156 }
157
158 serverError.add(1, tags);
159 return 'server_error';
160}
161
162export function handleSummary(data) {
163 return { [__ENV.SUMMARY_FILE]: JSON.stringify(data, null, 2) };
164}数据文件的数组类型、非空和必填字段校验放在 SharedArray 构造回调里,此时 JSON.parse() 的结果仍是普通数组。构造完成后,脚本只使用 SharedArray 官方说明明确支持的 length 和下标访问,不再假设这个 array-like 对象能通过 Array.isArray()。
脚本把每个已启动请求只记入一个终态计数器。k6 错误码中的 1050 归为请求超时;没有获得 HTTP 响应且不是 1050 的传输失败归入 connection_failed;成功、业务拒绝和其余 HTTP 错误再按状态码互斥分类。${BUSINESS_REJECTED_STATUSES} 必须由案例接口契约填写,例如某些项目把库存不足映射为 409,另一些项目会使用不同状态码。这里不能替业务预设答案。
handleSummary() 会把内置指标和这组自定义计数器写入 ${SUMMARY_FILE}。实时明细仍可通过 k6 的输出参数另行保存;报告引用汇总文件和原始输出位置即可。
正式运行前还要观察压测端 CPU、内存、网络和 dropped_iterations 等未能按计划启动的请求。压测端先饱和时,这一档结果应标为无效,而不是记成服务端的吞吐上限。
四、用阶段运行找出可采样区间
一上来打满目标流量,会把启动扰动、连接建立和容量变化揉在一起。这里选择“四段独立执行”,每次调用同一份脚本,只改变速率、时长、运行编号和输出文件。独立的含义也包括数据边界:每次 k6 run 前恢复同一快照并校验,结束后排空在途请求,再恢复并校验一次。
- 预热:使用低于正式负载的输入,直到吞吐和延迟在连续观察窗口内不再明显漂移;完成条件沿用上一篇的基线协议。
- 阶梯升压:逐档增加
TARGET_RATE,每档独立保存结果,观察完成吞吐、P95/P99、错误率和dropped_iterations。 - 稳态采样:选择仍满足实验有效性要求的一档,固定负载并截取正式窗口。
- 恢复:等待在途请求排空,保留结果文件,调用项目提供的安全恢复入口,再运行数据快照校验。恢复动作不塞进 k6 的
teardown();即使压测进程异常退出,外部编排也能明确看到它是否执行成功。
缓存起点也要写进协议。${CACHE_POLICY} 只能取本轮约定的策略,例如 cold 表示恢复后清理案例缓存,primed 表示恢复后按固定清单预热并校验键集合。预热阶段仍用于完成应用类加载、连接建立等运行时准备;它不会替代正式档位自己的数据和缓存准备。
下面的命令展示完整边界。${...} 全部来自本轮实验配置;reset-and-verify.sh 和 wait-for-drain.sh 是项目需要提供并审核的适配脚本。前者接收快照与缓存策略,只有数据库快照、关联数据及缓存状态校验全部通过才返回成功;后者等待在途请求和服务端队列满足基线协议的排空条件。本文不伪造具体数据库清理命令。
1set -eu
2mkdir -p results
3
4# 公共变量建议放入受版本控制但不含凭据的 env 文件,再由执行环境注入。
5run_stage() {
6 stage="$1"
7 rate="$2"
8 duration="$3"
9 run_id="${EXPERIMENT_ID}-${stage}"
10 summary_file="results/${run_id}-summary.json"
11
12 # 每个独立运行都从同一份数据库快照和缓存状态开始。
13 ./reset-and-verify.sh "${SNAPSHOT_ID}" "${CACHE_POLICY}"
14
15 run_status=0
16 k6 run -e RUN_ID="${run_id}" \
17 -e TARGET_RATE="${rate}" -e DURATION="${duration}" \
18 -e SUMMARY_FILE="${summary_file}" baseline.js || run_status=$?
19
20 # k6 成功或失败都要收拢现场;汇总文件若已生成则保留在 results/。
21 drain_status=0
22 ./wait-for-drain.sh "${DRAIN_TIMEOUT}" || drain_status=$?
23 ./reset-and-verify.sh "${SNAPSHOT_ID}" "${CACHE_POLICY}"
24 if [ "${run_status}" -ne 0 ]; then return "${run_status}"; fi
25 if [ "${drain_status}" -ne 0 ]; then return "${drain_status}"; fi
26}
27
28run_stage "warmup" "${WARMUP_RATE}" "${WARMUP_DURATION}"
29
30for rate in ${STAIR_RATES}; do
31 run_stage "stair-${rate}" "${rate}" "${STAIR_DURATION}"
32done
33
34run_stage "steady" "${STEADY_RATE}" "${STEADY_DURATION}"阶梯档位和持续时间没有通用答案。${STAIR_RATES}、${STAIR_DURATION} 等参数根据系统预估容量和预实验调整,最终取值随报告归档。每次执行都还要注入脚本声明的公共变量,例如 BASE_URL、请求权重、热点概率、VU 预算和业务拒绝状态码;缺一项,脚本会在启动阶段报错。
每一档都记录目标发送速率、实际启动速率、完成吞吐和各类失败。只看成功 TPS 会漏掉一个常见问题:负载发生器没有发出计划流量,或者大量请求在采样结束时仍未完成。
run_stage 在每档前后执行同一恢复校验。某档写入的新订单、状态变化和缓存演化不会进入下一档;即使 k6 或排空检查失败,包装器仍尝试完成末尾恢复,随后返回失败并由 set -e 停止后续实验。恢复校验本身失败时也立即停止,不能带着污染状态继续跑。开放到达率执行器按照 rate/timeUnit 启动迭代,迭代时长和可用 VU 决定能否维持目标速率;因此脚本没有末尾 sleep(),而是把 dropped_iterations 与 VU 使用情况一并纳入有效性判断。k6 的 constant-arrival-rate 说明和 VU 分配说明给出了这两个行为的官方定义。
五、首轮报告先回答“能否复现”
首轮运行的目标不是找出系统为什么慢,而是确认负载能够按设计到达,并且重复执行后结果没有失控漂移。报告可以直接引用上一篇的 experiment.id,再增加负载定义和每轮结果:
1baselineLoadReport:
2 experimentId: ${EXPERIMENT_ID}
3 script:
4 tool: k6
5 version: ${K6_VERSION}
6 digest: ${SCRIPT_COMMIT_OR_DIGEST}
7 scenario:
8 requestMix: ${REQUEST_MIX}
9 arrivalModel: open_constant_arrival_rate
10 targetRate: ${TARGET_RATE}
11 hotProbability: ${HOT_PROBABILITY}
12 downstreamProfile: ${DOWNSTREAM_PROFILE}
13 dataSnapshot: ${SNAPSHOT_ID}
14 cachePolicy: ${CACHE_POLICY}
15 runs:
16 - runId: ${RUN_ID}
17 snapshotVerifiedBefore: ${TRUE_OR_FALSE}
18 cacheStateVerifiedBefore: ${TRUE_OR_FALSE}
19 valid: ${TRUE_OR_FALSE}
20 invalidReason: ${NONE_OR_REASON}
21 startedRate: ${ACTUAL_STARTED_RATE}
22 completedThroughput: ${COMPLETED_THROUGHPUT}
23 successThroughput: ${SUCCESS_THROUGHPUT}
24 p95AllCompleted: ${P95}
25 p99AllCompleted: ${P99}
26 errorRate: ${ERROR_RATE}
27 terminalCounts:
28 succeeded: ${COUNT}
29 businessRejected: ${COUNT}
30 serverError: ${COUNT}
31 timedOut: ${COUNT}
32 connectionFailed: ${COUNT}
33 droppedIterations: ${COUNT}
34 notStartedRequests: ${COUNT}
35 inFlightAtCutoff: ${COUNT}
36 drainedAfterRun: ${TRUE_OR_FALSE}
37 snapshotVerifiedAfter: ${TRUE_OR_FALSE}
38 cacheStateVerifiedAfter: ${TRUE_OR_FALSE}
39 resultFiles: ${PATHS}
40 repeatability:
41 comparisonMethod: ${METHOD_DEFINED_BEFORE_REVIEWING_RESULTS}
42 observedVariation: ${PER_RUN_RESULT_OR_RANGE}
43 acceptableForBaseline: ${TRUE_OR_FALSE}
44 reason: ${EVIDENCE_BASED_REASON}这里不填写示范 TPS,因为没有运行环境和原始数据。实际执行时至少保留各轮明细,不要只留平均值。P95/P99、错误率和吞吐波动是否可接受,也应按实验成本与后续决策风险预先定义;样本很少时,原样列出每轮结果通常比制造一个精确的稳定性评分更诚实。
判断一组结果能否成为后续基线,可以检查几件具体的事:请求组合与数据选择是否符合模型;各档目标速率是否真正发出;压测端有没有饱和;数据恢复和下游配置是否一致;重复运行的差异能否由记录中的已知因素解释。任一关键条件失效,就保留失败记录并重跑,不从多轮里挑最好的一次。
走到这一步,我们得到的产物应该是三份可以互相追溯的材料:负载模型说明、带版本标识的压测脚本、引用同一实验协议的首轮报告。它们证明的是“这组业务输入可以重复制造”,还没有证明数据库、线程池或某个下游就是瓶颈。下一篇再沿着负载拐点、调用阶段和资源饱和建立因果证据。