☰
Web性能测试基础概念:指标口径、压测脚本与性能基线
2026/10/28 5:29:30 网站建设 项目流程

简介:《Web性能测试--基础概念》是一份面向初入性能测试领域的开发者与测试人员的入门讲义,围绕性能测试分类、并发用户、吞吐量与响应时间四项核心指标展开,并系统梳理从需求确认、了解架构、分析测试点、工具选型、编写计划、环境搭建,到实施、调优与报告输出的完整流程。文中用「理发店模式」类比并发用户的不同状态,并结合压力测试、负载测试、稳定性测试与竞态条件测试的分类说明,帮助读者建立可实际使用的测试思路框架。资源包共 1 个 PDF 文件,约 531KB,内容紧凑,便于通读和随时查阅。该资源已有 464 人学习,适合作为性能测试起步阶段的系统梳理材料,也可用于团队内部的基础概念培训。读者可从中获得指标定义、测试流程清单与调优切入点的整体认知,为后续借助 JMeter 等工具开展实战打下概念基础。

1. 一次评审会上的三个「快」:Web 性能测试要先统一的是口径

上线前评审,前端打开性能面板说首屏 1.2 秒,后端指着监控说接口 P95 只有 300 毫秒,运维补一句服务器负载不到 20%,而用户群的投诉截图还挂在那里。三个数字都是真的,只是它们说的不是同一件事——Web 性能测试最容易踩的坑,不在工具,而在口径没对齐就开始跑脚本。

这份「Web 性能测试 - 基础概念」要立住的就是这件事:先分清测什么(加载、渲染、交互、接口)、在哪测(实验室、真实用户、压测环境)、测谁(单用户、并发用户、长时间稳态),再谈参数和工具。它适合两类人:从功能测试往性能方向转、需要一套完整骨架的;以及已经有监控、但说不清"慢在哪一层"的团队。后面几章按这个顺序往下推:指标怎么定义、脚本怎么写、场景怎么编、结论怎么不被自己骗。

2. 把「快」拆成三条时间轴:网络、渲染、主线程各量什么

一个页面从输入网址到能点能滑,中间横跨三个完全不同的世界:网络层的往返、渲染层的构建、主线程上的脚本执行。性能问题的绝大多数争论,本质是有人在说网络层、有人在说渲染层。先把这三条轴分开,后面的指标才有意义。

2.1 一次页面打开被切成了几段:分层耗时对照表

把一次加载按 Navigation Timing 的口径切开,是最省事的"分诊"办法。浏览器在导航过程中会按顺序打下一串时间戳,相邻两个相减就是这一段的耗时。下面这张表是我自己在排查时最常摆在手边的:

阶段起止字段正常区间参考异常时先怀疑什么
DNS 解析domainLookupStart→domainLookupEnd内网 0–5ms,公网 10–50ms缓存未命中,反复解析
TCP 建连connectStart→connectEnd同城 10–30ms跨地域部署、丢包重传
TLS 握手secureConnectionStart→connectEnd约 1 个 RTT会话复用没开,每次完整握手
首字节 TTFBrequestStart→responseStart静态 <100ms,动态 <300ms后端排队、慢查询、GC 停顿
内容下载responseStart→responseEnd取决于体积与压缩首包快但下载慢,压缩或带宽问题
DOM 构建responseEnd→domContentLoadedEventEnd与节点数正相关同步脚本阻塞解析、DOM 过大
首屏绘制首个 paint 条目1–2.5sCSS 阻塞、字体未就绪、图片过大
交互响应事件触发→下一帧<200ms长任务、大组件重渲染

这张表的价值在"排除法"。用户说慢,你先看 TTFB:如果 TTFB 只有 80ms 而首屏 3 秒,那后端基本无罪,问题在前端资源加载和渲染;反过来 TTFB 就 1.2 秒,前端优化到极致也是白搭。我见过团队在首屏图片上死磕两周,最后发现是接口里一个没走索引的模糊查询。

注意:TTFB 用responseStart - requestStart算的是服务端处理加首包传输,别用responseStart - startTime,那里面混进了 DNS 和建连,容易把网络问题算到后端头上。

2.2 指标口径对照表:FCP、LCP、CLS、INP 各自量的是谁

分层耗时是"过程量",指标是"结果量"。过程量适合定位,结果量适合定 KPI 和门禁。常见的七八个指标经常被混着用,实际分工是这样的:

指标量的东西采集侧常用观察位典型误用
TTFB服务器首字节到达实验室 + 现场现场 P75 < 800ms拿它当整页体验的 KPI
FCP首次出现内容像素实验室< 1.8s只看 FCP,忽略主内容何时出现
LCP视口内最大内容元素绘制完成实验室 + 现场现场 P75 < 2.5s元素判定随视口尺寸变化,不同分辨率不可直接比
CLS布局偏移累计得分现场P75 < 0.1忘了排除用户输入后的窗口
INP交互到下一帧呈现的延迟现场P75 < 200ms用实验室的 TBT 替代 INP 下结论
TBT主线程阻塞总时长实验室< 200ms未做 CPU 降速,测出来偏乐观
TTI可稳定交互的时间点实验室参考值与 INP 混为一谈

选指标的原则是"三个够用":一个管加载(LCP)、一个管交互(INP)、一个管服务端(接口 P95 或 TTFB)。再加一个 CLS 管视觉稳定性,就已经能覆盖九成投诉场景。再多的指标只会让报告变成数字堆砌,评审会上没人看得下去。

还有一个常被忽略的维度:实验室数据和现场数据的差别。实验室是固定设备、固定网络、清空缓存下跑的一次,可复现、适合做回归对比;现场是真实用户的分布,长尾极重、噪声大,适合定阈值。两者的差距往往在 30% 以上,用实验室数据去承诺现场体验,是最常见的翻车方式。

2.3 用标准 API 采一次真实数据:LCP、CLS、长任务一起拿

不用装任何第三方库,浏览器原生的 PerformanceObserver 就能把关键指标采全。下面这段脚本我一般放在页面最靠前的位置(head 内联),保证注册得足够早:

// perf-collect.js —— 采集 LCP / CLS / 长任务 / TTFB,页面隐藏时统一上报 const perf = { lcp: 0, cls: 0, longTasks: 0, ttfb: 0, download: 0 }; new PerformanceObserver((list) => { const entries = list.getEntries(); // LCP 会随页面变化多次回调,取最后一次才是最终值 perf.lcp = entries[entries.length - 1].startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); new PerformanceObserver((list) => { for (const e of list.getEntries()) { // 用户输入后 500ms 内的偏移不计入 CLS,这是定义,不是可调参数 if (!e.hadRecentInput) perf.cls += e.value; } }).observe({ type: 'layout-shift', buffered: true }); new PerformanceObserver((list) => { // 超过 50ms 的任务才算 long task,阈值同样写死在规范里 perf.longTasks += list.getEntries().filter((e) => e.duration > 50).length; }).observe({ type: 'longtask', buffered: true }); window.addEventListener('load', () => { const nav = performance.getEntriesByType('navigation')[0]; perf.ttfb = Math.round(nav.responseStart - nav.requestStart); perf.download = Math.round(nav.responseEnd - nav.responseStart); }); // 页面隐藏时上报,比 unload 可靠,移动端尤其明显 document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'hidden') return; navigator.sendBeacon('/api/perf', JSON.stringify(perf)); });

几个参数必须说清楚,否则采上来的数据是错的:

buffered: true让观察器能拿到"注册之前"已经发生的条目。页面里如果还有别的脚本晚于这段执行,没这个开关就会漏采首屏那批关键条目。

startTime的基准是 navigationStart,不是绝对时间戳。所以 LCP 直接读startTime就是"从导航开始到最大内容绘制"的毫秒数,不需要再减。

hadRecentInput是 CLS 计算里最容易写错的地方。用户点了一下按钮,随后弹出的提示框把内容往下推了 300px,这个偏移不该算到"页面自己抖"的账上。规范给的窗口是 500ms,只读,不能自己改成 200ms 图好看。

上报时机用visibilitychange而不是unload。移动端浏览器对 unload 的触发很不稳定,很多会话到不了那个回调,数据就直接丢了。sendBeacon是异步且不阻塞卸载的,适合这种收尾上报。

3. 压测脚本落地的两条路:协议层控 RPS,浏览器层控渲染

性能测试的脚本从来不是"越像浏览器越好",而是"越贴近你要得出的结论越好"。要验证后端容量,用协议层直连接口;要验证用户端体验,用真实渲染。两条路的负载模型、参数、失败模式完全不同,混着用只会两头不讨好。

3.1 协议层:一个能按固定 RPS 发压的脚本骨架

协议层压测的核心不是"并发多少",而是"按什么速率发、失败怎么记、延迟怎么统计"。下面这段用 Node 内置的 http 模块写,不依赖任何压测框架,胜在每一行都可控:

// load.js —— 固定 RPS 的开放模型压测,例:node load.js --rps 50 --duration 60 const http = require('http'); const { performance } = require('perf_hooks'); // 连接复用,避免每次请求重开 TCP 把端口耗光 const agent = new http.Agent({ keepAlive: true, maxSockets: 512 }); const args = process.argv.slice(2).reduce((acc, cur, i, arr) => { if (cur.startsWith('--')) acc[cur.slice(2)] = arr[i + 1]; return acc; }, {}); const RPS = Number(args.rps || 20); const DURATION = Number(args.duration || 60) * 1000; const TARGET = args.url || 'http://127.0.0.1:8080/api/item?id=1'; const latencies = []; const errors = []; function once() { return new Promise((resolve) => { const t0 = performance.now(); const req = http.get(TARGET, { agent }, (res) => { res.resume(); // 必须消费响应体,否则连接不会被归还 res.on('end', () => { latencies.push(performance.now() - t0); if (res.statusCode >= 400) errors.push(res.statusCode); resolve(); }); }); req.setTimeout(3000, () => req.destroy(new Error('timeout'))); req.on('error', (e) => { latencies.push(performance.now() - t0); // 失败请求的耗时也要计入,否则分位数虚高 errors.push(e.code || 'ERR'); resolve(); }); }); } (async () => { const start = performance.now(); let sent = 0; // 每 10ms 补一批,把速率摊平,避免整秒脉冲 const timer = setInterval(() => { const elapsed = performance.now() - start; const shouldSend = Math.floor((elapsed / 1000) * RPS) - sent; for (let i = 0; i < shouldSend; i++) { sent += 1; once(); } if (elapsed > DURATION) clearInterval(timer); }, 10); await new Promise((r) => setTimeout(r, DURATION + 5000)); // 留 5s 收尾,等尾部请求回来 latencies.sort((a, b) => a - b); const q = (p) => latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))].toFixed(1); console.log(`样本 ${latencies.length} P50 ${q(0.5)}ms P95 ${q(0.95)}ms P99 ${q(0.99)}ms`); console.log(`错误 ${errors.length} 错误率 ${((errors.length / latencies.length) * 100).toFixed(2)}%`); })();

参数逐个交代:--rps是每秒发起的请求数,它和并发数不是一回事,实际并发由"RPS × 响应时间"决定;--duration是施压时长,短于 60 秒的数据基本没有参考价值,因为连接池、JIT、缓存都要时间才稳住;maxSockets决定这个客户端最多能同时开多少连接,设太小的话请求会在客户端排队,测出来的延迟是被自己卡住的;setTimeout(3000)给每个请求封顶,超时按失败记,否则一次挂死会把整个压测拖成无限等待。

res.resume()那句是血泪经验。不消费响应体,socket 不会被释放回连接池,跑几千个请求之后就全是新建连接,最后表现为"服务端很闲但客户端延迟暴涨"。

3.2 浏览器层:什么时候必须上真实渲染,以及限速参数怎么设

协议层测不到的东西,恰好是用户最痛的:CSS 阻塞、JS 执行、图片解码、字体切换、第三方脚本。这些都是渲染路径上的开销,一个 HTTP 200 的接口再快,也救不了一个 2MB 的同步脚本。

浏览器层压测的正确姿势是"少量会话、重度观测",而不是"大量会话、只看吞吐"。并发一般控制在个位数到几十,靠 DevTools 协议的原始方法把环境调到接近中低端真机:

// browser-metrics.js —— 通过 DevTools 协议驱动无头浏览器,在限速下采一次加载 const WebSocket = require('ws'); const http = require('http'); const PORT = 9222; const TARGET = 'http://127.0.0.1:8080/'; (async () => { // 新建标签页,拿到它的调试通道地址 const tab = await new Promise((resolve, reject) => { http.get(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(TARGET)}`, (r) => { let body = ''; r.on('data', (d) => body += d); r.on('end', () => resolve(JSON.parse(body))); }).on('error', reject); }); const ws = new WebSocket(tab.webSocketDebuggerUrl); let seq = 0; const send = (method, params = {}) => new Promise((resolve) => { const id = ++seq; const onMsg = (raw) => { const msg = JSON.parse(raw); if (msg.id === id) { ws.off('message', onMsg); resolve(msg.result); } }; ws.on('message', onMsg); ws.send(JSON.stringify({ id, method, params })); }); await new Promise((r) => ws.on('open', r)); await send('Page.enable'); await send('Network.enable'); // 4 倍 CPU 降速,约等于中低端安卓机;rate 是倍数,不是百分比 await send('Emulation.setCPUThrottlingRate', { rate: 4 }); // 网络参数单位是 字节/秒:1.6Mbps 下行、750Kbps 上行、150ms 额外 RTT await send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, }); await send('Page.navigate', { url: TARGET }); await new Promise((r) => setTimeout(r, 8000)); // 等 load + 网络空闲;生产上应改成监听事件 const { result } = await send('Runtime.evaluate', { expression: `JSON.stringify({ ttfb: performance.getEntriesByType('navigation')[0].responseStart, lcp: performance.getEntriesByType('largest-contentful-paint').pop()?.startTime || 0 })`, returnByValue: true, }); console.log(result.value); ws.close(); })();

参数上有三个容易设错的地方。rate: 4是"CPU 慢 4 倍",只接受大于等于 1 的整数,设 1 就是不降速;很多人以为设 0.5 是降一半,实际会直接报错。downloadThroughput的单位是字节每秒,写成 1.6 会被当成 1.6 字节每秒,页面直接打不开,正确的换算是 Mbps 除以 8 再乘 1024 的平方。latency是额外叠加的往返延迟,不是总延迟,设 150 意味着在原有网络基础上再加 150ms。

最后那个固定setTimeout(8000)只是演示,实际跑批量得换成监听Page.loadEventFired加网络空闲判定,否则慢页面会被截断、快页面会白等。

3.3 三个旋钮怎么配:并发、RPS、思考时间与利特尔法则

压测脚本能不能得出正确结论,八成取决于负载模型。这里有个绕不开的公式:

并发数 ≈ 目标 RPS × 平均响应时间(秒)

目标 RPS平均响应时间需要的并发现象解读
5050ms3系统很闲,延迟主要是网络
50500ms25后端开始排队,延迟被放大 10 倍
502s100快到拐点,再加压就是雪崩

这三种模型要分清:固定并发(闭环)是"100 个用户不停地发,发完再发下一个",系统变慢时会自动降低发压速率,把容量拐点掩盖掉;固定 RPS(开放)是"每秒必须来 50 个",系统再慢也不减速,才能真正看到排队爆炸。所以常规做法是先用固定并发做阶梯加压找拐点,再用固定 RPS 验证目标水位下的稳定性。

思考时间是最容易被忽略的旋钮。真实用户看完一屏要 3 到 10 秒才点下一个,压测里把这个去掉,等于把压力放大十几倍,测出来的"系统撑不住"其实是脚本太狠。我一般按业务类型给:列表页 3 秒、详情页 5 秒、提交类操作 8 秒,并在报告里写清楚去掉思考时间后的等效倍数。

4. 压测剧本怎么编:从访问日志挑链路、管登录态、造数据

脚本能跑通不等于场景编对了。真实的性能事故,多数不是"压不上去",而是"压错了地方"——压了一条没人用的接口,或者压了一条本身就走缓存的路径,结论自然乐观得离谱。

4.1 从访问日志里挑出值得压的 5 条链路

别凭感觉选接口。用一周的访问日志做一次二八分析,比开三次评审会都有用:

-- 按路径聚合,找出累计占比 80% 的接口作为压测链路 WITH agg AS ( SELECT regexp_replace(url, '\\?.*$', '') AS path, -- 去掉查询串,按路径归并 count(*) AS pv, count(DISTINCT uid) AS uv, approx_percentile(rt_ms, 0.95) AS p95_ms -- 日志量大,用近似分位数 FROM access_log WHERE dt BETWEEN '2025-01-01' AND '2025-01-07' -- 覆盖一个完整工作周 AND url NOT LIKE '/static/%' -- 剔除静态资源 AND ua NOT LIKE '%bot%' -- 剔除爬虫,否则 PV 虚高 GROUP BY 1 ) SELECT path, pv, uv, p95_ms, round(100.0 * sum(pv) OVER (ORDER BY pv DESC) / sum(pv) OVER (), 2) AS cum_pct FROM agg ORDER BY pv DESC LIMIT 20;

看最后一列cum_pct,找到它跨过 80% 的那一行,前面这些接口就是压测的主链路。有几个参数必须调对:窗口期至少七天,只取工作日会把周末的流量特征漏掉;ua过滤要根据自己的日志格式改,移动端和小程序的 UA 特征不同,误杀会让结果失真;approx_percentile换成你所在数仓对应的函数名,用精确分位数在亿级日志上会跑到天亮。

纯按 PV 排完还不算完,要叠加业务权重。下单、支付这类低频但高价值的接口,PV 可能排不进前二十,但它是"一挂就上新闻"的那种,必须单独纳入。我的习惯是最终定 5 条:3 条按 PV 选出的主力接口,1 条下单链路,1 条登录链路。

4.2 参数化与登录态:token 池为什么不能只准备一个

把 token 写死在脚本里,是新手最常犯的错。所有虚拟用户共用一个会话,服务端那块会话缓存会被反复命中,测出来的响应时间比真实情况好一大截;更糟的是有些系统会对同一会话加锁,压测直接变成串行执行,怎么加并发都上不去。

正确的做法是准备一个账号池,让每个虚拟用户固定绑一个账号:

// session.js —— 账号池:登录一次,按虚拟用户切片使用,临近过期异步刷新 const pool = { tokens: [], ready: null }; async function login(user) { const res = await fetch('http://127.0.0.1:8080/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ user, pass: 'perf-only-pass' }), }); const { token, expireAt } = await res.json(); return { token, expireAt }; } function ensurePool(size = 50) { if (!pool.ready) { pool.ready = Promise.all( Array.from({ length: size }, (_, i) => login(`perf_user_${String(i).padStart(3, '0')}`)) ).then((list) => { pool.tokens = list; return list; }); } return pool.ready; } async function tokenFor(vuId) { await ensurePool(); const slot = pool.tokens[vuId % pool.tokens.length]; // 固定绑定,不用随机 if (slot.expireAt - Date.now() < 120000) { Object.assign(slot, await login(`perf_user_${String(vuId).padStart(3, '0')}`)); } return slot.token; } // 唯一值统一走这个函数,避免多条数据撞主键 let seq = 0; const uniq = (vuId) => `${vuId}-${Date.now()}-${++seq}`;

size要大于等于并发虚拟用户数。账号数少于并发数时,多个用户会共用同一个会话,会话表上的行锁会把压测变成排队测试。提前 120 秒刷新是为了避开"token 刚好在压测中途过期"这种玄学问题,登录请求本身也会给服务端带来额外压力,得算进总流量里。

4.3 测试数据的量级、分布和清理

数据量对性能的影响是非线性的。一张十万行的表和一个亿行的表,同一个 SQL 的执行计划可能完全不同——索引选择性变了,优化器会从走索引改成全表扫。所以测试库的数据量至少要和生产同数量级,最好是十分之一起步。

数据对象量级怎么定造数方式清理策略
用户表与生产同数量级,至少 1/10脚本批量插入,索引先建后灌按用户名前缀删除
订单表目标 QPS × 时长 × 1.5从生产脱敏导出后随机扰动时间按分区整块 drop
热点商品少量 ID 被高频访问固定 ID 集合,20 个左右无需清理
缓存冷启动与热态各测一轮重启或主动失效测完清空再跑下一轮

几个细节容易翻车:造数时如果自增主键连续插入,页分裂和热点会集中在最后一页,测出来的写性能比真实情况差;订单时间如果全堆在同一天,按时间范围查询的索引用不上,会得到"索引失效"的假结论;从生产导出的数据要脱敏,但状态字段(订单状态、用户等级)的分布必须保留,否则查询会走完全不同的分支。

注意:压测数据一定要带可识别前缀,并且清理脚本提前写好跑通。我见过不止一次压测数据混进测试环境的常规报表,第二天的运营数据全线飘红。

5. 排查与避坑:六个让压测结论翻车的现场

压测跑完拿到报告,最容易犯的错是"数字好看就收工"。下面这六个现场,每一个我都真实遇到过,且每一个都会让结论反向。

5.1 平均响应 180ms 却被天天投诉:长尾被平均值吃掉了

现象:报告上平均响应 180ms,P95 也才 400ms,看起来一切正常,但用户投诉没停过。

原因:平均值是被大量极快请求拉低的。100 个请求里 95 个 50ms、5 个 3 秒,平均值是 197ms,看起来很美,但那 5 个人就是投诉来源。而且很多压测脚本在失败时直接丢弃样本,等于把最慢的那批从统计里删掉了。

解决:报告里只认 P95 和 P99,平均值最多作为参考。同时在统计代码里确保失败请求的耗时也进样本——上面那段脚本里req.on('error')也 push 了一条延迟,就是为了这个。要更细的话,把延迟画成直方图,看是不是双峰分布,双峰通常意味着有请求走了慢路径(缓存未命中、回源、锁等待)。

5.2 加压到一半曲线变平:压测机自己先满了

现象:并发从 100 加到 200,RPS 几乎不涨,延迟还翻倍,看起来是服务端到瓶颈了。

原因:压测机的 CPU 或网络带宽先被打满,压力根本没发出去。常见的信号是压测机top里 node 进程跑到 100% 以上,或者网卡出向带宽触顶。

解决:先在压测机旁边挂一个监控,记录发压进程的 CPU、内存、网卡出向速率。经验值是发压进程 CPU 超过 70% 就要考虑分布式发压,或者换更轻的协议层工具。另一个办法是做一次"空跑"——把目标地址指向一个本地空服务,看同样 RPS 下客户端能跑多快,这就是你这台机器的天花板。

5.3 跑到几千请求后大量报错:端口耗尽与连接未复用

现象:压测前期很平稳,跑到 3000 个请求之后开始出现连接超时、EADDRNOTAVAIL之类的错误,重启脚本又好了。

原因:客户端每次请求都新建 TCP 连接,短时间积累大量 TIME_WAIT 状态的连接,本地可用端口被占满。默认的临时端口范围只有几万个,扣掉保留段,能用的更少。

解决:开启 keep-alive 并复用 agent(示例脚本里的keepAlive: true就是干这个的),把maxSockets设成并发上限而不是无限大;同时确认服务端没有主动关闭短连接。还要检查响应体是否被完整消费——不读完响应,连接不会被归还,效果和不开 keep-alive 一样。

5.4 测试环境飞快、生产一压就崩:缓存、数据量、日志级别

现象:测试环境 500 并发稳稳的,生产上 200 并发就开始超时。

原因:三个最典型的差异同时存在。测试环境数据量小,索引全在内存里,查询都是毫秒级;测试环境缓存命中率被压测本身拉得极高(同一个 ID 反复请求);生产开着 INFO 级日志和链路追踪,每个请求多几毫秒的磁盘和序列化开销。

解决:压测前把测试库数据量补到同数量级,缓存分冷热两轮测,日志级别调到和生产一致。更稳的办法是做一次生产影子流量对比,取 1% 的真实请求打到新版本上,看 P99 的差异,而不是全量压测。

5.5 脚本跑通、业务全错:关联参数写死

现象:压测报告显示一切正常,错误率 0%,但事后查业务数据,下单成功的记录一条都没有。

原因:脚本只看了 HTTP 200,没校验业务返回码。更隐蔽的是关联参数写死——上一步返回的订单号被硬编码进下一步,服务端永远返回"订单不存在",而接口本身返回 200 加一个业务错误码。

解决:断言必须落到业务字段上,不能只看状态码。所有跨请求的参数都从响应里提取(正则或 JSONPath),并且第一步就先跑 10 个用户验证数据链路是否闭环。我在脚本里固定加一条自检:跑完检查数据库里新增的业务记录数是否等于成功请求数,对不上就不出报告。

5.6 施压 5 分钟就下结论:稳态和内存泄漏根本没测到

现象:压测 5 分钟数据漂亮,上线两天后服务开始周期性重启。

原因:5 分钟只覆盖了"热身期",连接池没满、JIT 没完全优化、GC 还没进入稳定节奏,更测不出内存泄漏和连接泄漏——这些问题的典型表现是缓慢上升,要 30 分钟以上才看得出来。

解决:容量测试跑 10 到 15 分钟看拐点,稳态测试至少 30 分钟、最好 2 小时,期间盯着堆内存、连接数、线程数的时间序列曲线。判断标准不是"绝对值高不高",而是"有没有单调上升不回落"。这条曲线比任何单个指标都更能预测线上事故。

6. 从一次性压测到一条能守住的基线

压测报告躺在共享盘里没人看,是这行最常见的浪费。真正有用的做法是把每次结果沉淀成一条基线,让下一次变更能自动比对。基线成立有三个前提:同环境、同脚本、同数据量。缺一个,比较就没有意义。

我一般把结果存成两个 JSON,一个基线文件、一个本次结果,然后用二十行脚本做门禁:

// gate.js —— 本次结果对比基线,任一规则不通过则退出码 1 const base = require('./baseline.json'); const cur = require('./result.json'); const checks = [ ['P95 涨幅超过 15%', () => cur.p95 <= base.p95 * 1.15], ['P99 涨幅超过 20%', () => cur.p99 <= base.p99 * 1.2], ['错误率超过 0.5%', () => cur.errorRate <= 0.005], ['LCP P75 超过 2.5s', () => cur.lcpP75 <= 2500], ['吞吐低于基线 90%', () => cur.rps >= base.rps * 0.9], ]; const failed = checks.filter(([, fn]) => !fn()).map(([msg]) => msg); if (failed.length) { console.error(`性能门禁未通过:\n- ${failed.join('\n- ')}`); process.exit(1); } console.log(`通过,P95 ${cur.p95}ms(基线 ${base.p95}ms)`);

为什么用百分比而不是绝对值:环境噪声本身就有一成左右的波动,卡死绝对值会导致门禁天天红,最后所有人都学会点"忽略"。15% 和 20% 这两个数是我在几个项目里试出来比较平衡的,波动小的服务可以收到 10%。

基线本身也要维护。服务升级、数据量翻倍、依赖版本变化,都会让旧基线失效。我通常每季度或者每次大版本前重跑一次稳态测试,确认新基线的合法性,并在变更单里附上前后对比图。

说个我自己的习惯,这些年一直没改:不管多小的改动,只要碰了接口或首屏资源,我都会在发布前跑一次 30 分钟的稳态压测,把 P95 曲线和堆内存曲线截两张图贴在变更单里。不是为了走流程,而是因为性能问题从来不是"突然"出现的,它总是在某次没人注意的小改动里埋下伏笔,然后在流量最高的时候兑现。后悔药没有,基线有。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询