JMeter CI 集成实战——Jenkins/GitHub Actions 自动化性能回归
JMeter CI 集成实战——Jenkins/GitHub Actions 自动化性能回归
适读人群:DevOps工程师、测试工程师、后端开发 | 阅读时长:约14分钟 | 核心价值:把性能测试融入CI/CD流水线,实现每次发版前自动化性能回归,用数据守住性能基线
那次上线后才发现性能退化的痛
2021年Q3,团队迭代很快,每两周发一个版本。有一次例行发版,上线后用户开始反馈"下单好慢"。
我们赶紧看监控,P99从正常的230ms涨到了680ms,TPS也下降了约30%。
回滚之后排查代码,发现是一个同事在优化商品查询时,无意中把一个批量查询改成了循环单次查询。原来1次查询出10个商品,现在变成循环10次查询,在单接口测试时差别不明显,但在真实并发场景下直接导致数据库连接池打满。
整个问题排查花了两天,那次发版延迟了一周。
更可气的是:如果我们在CI/CD里有自动化性能测试,这次代码合并时跑一遍回归,P99超标就拦住这次合并,根本不会到生产。
从那以后,我开始建设性能回归体系:每次代码合并自动跑性能测试,P99超基线20%就失败,阻止合并。
自动化性能回归的整体思路
性能回归的核心不是"每次跑一遍压测",而是:
- 建立性能基线:当前版本的P99/TPS是多少
- 每次变更后自动测试:用相同脚本、相同条件跑
- 自动对比结果:超基线阈值就报警/失败
- 结果可追溯:每次跑测的数据要存下来,可以看趋势
关键在于第3步——如何定义"超出"。我用的标准是:
- P99增加 > 20%:性能退化,流水线失败
- TPS下降 > 15%:吞吐量下降,流水线失败
- 错误率 > 0.5%:稳定性问题,流水线失败
Jenkins 集成方案
安装必要插件
Jenkins插件:
- Performance Plugin(解析JMeter结果,生成报告)
- HTML Publisher(发布HTML报告)
- Pipeline(流水线支持)JMeter 脚本的CI友好改造
原来的脚本里线程数、持续时间都是硬编码,CI里需要灵活配置:
<!-- 用属性替代硬编码 -->
<ThreadGroup>
<intProp name="ThreadGroup.num_threads">${__P(threads,50)}</intProp>
<intProp name="ThreadGroup.ramp_time">${__P(rampup,30)}</intProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<intProp name="ThreadGroup.duration">${__P(duration,120)}</intProp>
</ThreadGroup>CI环境用较小的并发(50线程,2分钟),本地完整测试用200线程30分钟。这样CI跑得快(3分钟以内),不阻塞流水线。
Jenkinsfile 完整示例
pipeline {
agent {
label 'performance-node' // 有JMeter的专用节点
}
parameters {
string(name: 'TARGET_ENV', defaultValue: 'test', description: '目标环境')
string(name: 'THREADS', defaultValue: '50', description: '并发线程数')
string(name: 'DURATION', defaultValue: '120', description: '持续时间(秒)')
string(name: 'P99_THRESHOLD', defaultValue: '500', description: 'P99阈值(ms)')
}
environment {
JMETER_HOME = '/opt/apache-jmeter-5.6.3'
RESULTS_DIR = "${WORKSPACE}/results"
REPORT_DIR = "${WORKSPACE}/report"
SCRIPT_PATH = "${WORKSPACE}/scripts/order_test.jmx"
}
stages {
stage('准备测试环境') {
steps {
sh '''
mkdir -p ${RESULTS_DIR} ${REPORT_DIR}
# 从对象存储拉取最新测试数据
aws s3 cp s3://your-bucket/testdata/users.csv /data/users.csv
aws s3 cp s3://your-bucket/testdata/products.csv /data/products.csv
echo "Test data prepared: $(wc -l < /data/users.csv) users"
'''
}
}
stage('等待服务就绪') {
steps {
script {
def targetUrl = "https://test-api.example.com/actuator/health"
timeout(time: 3, unit: 'MINUTES') {
waitUntil {
def response = sh(
script: "curl -s -o /dev/null -w '%{http_code}' ${targetUrl}",
returnStdout: true
).trim()
return response == '200'
}
}
}
}
}
stage('执行性能测试') {
steps {
sh '''
${JMETER_HOME}/bin/jmeter \
-n \
-t ${SCRIPT_PATH} \
-Jthreads=${THREADS} \
-Jduration=${DURATION} \
-Jrampup=30 \
-Jtarget_host=test-api.example.com \
-l ${RESULTS_DIR}/result.jtl \
-e -o ${REPORT_DIR} \
-j ${RESULTS_DIR}/jmeter.log \
2>&1 | tee ${RESULTS_DIR}/console.log
'''
}
}
stage('分析结果') {
steps {
script {
// 用Python解析JTL文件,提取关键指标
def analysisScript = '''
import csv
import sys
import json
from collections import defaultdict
def analyze_jtl(jtl_file, p99_threshold, tps_threshold=100):
results = defaultdict(list)
with open(jtl_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
label = row.get('label', 'unknown')
elapsed = int(row.get('elapsed', 0))
success = row.get('success', 'true').lower() == 'true'
timestamp = int(row.get('timeStamp', 0))
results[label].append({
'elapsed': elapsed,
'success': success,
'timestamp': timestamp
})
report = {}
failed = False
for label, samples in results.items():
elapsed_times = sorted([s['elapsed'] for s in samples])
total = len(samples)
errors = sum(1 for s in samples if not s['success'])
error_rate = errors / total * 100
p99_index = int(total * 0.99)
p95_index = int(total * 0.95)
p50_index = int(total * 0.50)
p99 = elapsed_times[p99_index] if p99_index < total else elapsed_times[-1]
p95 = elapsed_times[p95_index] if p95_index < total else elapsed_times[-1]
p50 = elapsed_times[p50_index] if p50_index < total else elapsed_times[-1]
# 计算TPS
timestamps = [s['timestamp'] for s in samples]
duration_sec = (max(timestamps) - min(timestamps)) / 1000
tps = total / duration_sec if duration_sec > 0 else 0
report[label] = {
'total': total,
'error_rate': round(error_rate, 2),
'p50': p50,
'p95': p95,
'p99': p99,
'tps': round(tps, 1)
}
# 阈值检查
if p99 > p99_threshold:
print(f"FAIL: {label} P99={p99}ms > threshold={p99_threshold}ms")
failed = True
if error_rate > 0.5:
print(f"FAIL: {label} error_rate={error_rate}% > 0.5%")
failed = True
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if not failed else 1
sys.exit(analyze_jtl(
sys.argv[1],
int(sys.argv[2])
))
'''
writeFile file: 'analyze.py', text: analysisScript
def exitCode = sh(
script: "python3 analyze.py ${RESULTS_DIR}/result.jtl ${params.P99_THRESHOLD}",
returnStatus: true
)
if (exitCode != 0) {
currentBuild.result = 'FAILURE'
error("Performance test FAILED: metrics exceeded thresholds")
}
}
}
}
stage('发布报告') {
steps {
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: "${REPORT_DIR}",
reportFiles: 'index.html',
reportName: "JMeter Performance Report - ${BUILD_NUMBER}"
])
// 保存JTL到对象存储,用于历史趋势对比
sh """
aws s3 cp ${RESULTS_DIR}/result.jtl \
s3://your-bucket/perf-results/\$(date +%Y%m%d)/build_${BUILD_NUMBER}.jtl
"""
// 通知钉钉/企业微信
sh """
curl -X POST 'https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"性能测试完成 - Build #${BUILD_NUMBER}\\nStatus: ${currentBuild.result}\\nReport: ${BUILD_URL}JMeter_Performance_Report/"}}'
"""
}
}
}
post {
always {
archiveArtifacts artifacts: 'results/**', allowEmptyArchive: true
}
failure {
emailext(
subject: "性能测试失败 - ${JOB_NAME} #${BUILD_NUMBER}",
body: "性能测试未通过,请查看报告:${BUILD_URL}",
to: "team@example.com"
)
}
}
}GitHub Actions 集成方案
# .github/workflows/performance-test.yml
name: Performance Regression Test
on:
pull_request:
branches: [ main, release/* ]
schedule:
- cron: '0 2 * * *' # 每天凌晨2点跑一次完整压测
jobs:
performance-test:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
# 如果需要起测试环境服务
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test123
MYSQL_DATABASE: testdb
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Install JMeter
run: |
JMETER_VERSION=5.6.3
wget -q https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz
tar -xzf apache-jmeter-${JMETER_VERSION}.tgz -C /opt/
echo "/opt/apache-jmeter-${JMETER_VERSION}/bin" >> $GITHUB_PATH
- name: Install JMeter plugins
run: |
# 安装bzm-parallel插件
wget -q -O /opt/apache-jmeter-5.6.3/lib/ext/bzm-parallel-0.11.jar \
https://repo1.maven.org/maven2/kg/apc/bzm-parallel/0.11/bzm-parallel-0.11.jar
- name: Start application under test
run: |
# 启动被测服务(后台运行)
java -jar target/app.jar --spring.profiles.active=test &
# 等待服务就绪
for i in {1..30}; do
if curl -s http://localhost:8080/actuator/health | grep -q "UP"; then
echo "Service is ready"
break
fi
echo "Waiting for service... ($i/30)"
sleep 5
done
- name: Prepare test data
run: |
mkdir -p /tmp/testdata
python3 scripts/generate_test_data.py --count 50000 --output /tmp/testdata/users.csv
- name: Run JMeter performance test
run: |
mkdir -p /tmp/results /tmp/report
jmeter \
-n \
-t scripts/performance/order_test.jmx \
-Jthreads=30 \
-Jduration=90 \
-Jrampup=15 \
-Jtarget_host=localhost \
-Jtarget_port=8080 \
-l /tmp/results/result.jtl \
-e -o /tmp/report/ \
-j /tmp/results/jmeter.log
- name: Check performance thresholds
run: |
python3 scripts/check_thresholds.py \
--jtl /tmp/results/result.jtl \
--p99-threshold 800 \
--error-rate-threshold 0.5 \
--output /tmp/results/summary.json
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const summary = JSON.parse(fs.readFileSync('/tmp/results/summary.json', 'utf8'));
let comment = '## 性能测试结果\n\n';
comment += '| 接口 | TPS | P50 | P95 | P99 | 错误率 | 状态 |\n';
comment += '|------|-----|-----|-----|-----|--------|------|\n';
for (const [label, metrics] of Object.entries(summary)) {
const status = metrics.passed ? '✅' : '❌';
comment += `| ${label} | ${metrics.tps} | ${metrics.p50}ms | ${metrics.p95}ms | ${metrics.p99}ms | ${metrics.error_rate}% | ${status} |\n`;
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
- name: Upload test reports
uses: actions/upload-artifact@v4
if: always()
with:
name: jmeter-report-${{ github.sha }}
path: |
/tmp/report/
/tmp/results/result.jtl
/tmp/results/summary.json
retention-days: 30
- name: Fail if thresholds exceeded
run: |
python3 -c "
import json, sys
summary = json.load(open('/tmp/results/summary.json'))
failed = any(not m['passed'] for m in summary.values())
sys.exit(1 if failed else 0)
"踩坑实录
坑1:CI环境压测结果波动很大
现象: 同一份代码,同一个脚本,在CI里跑三次,P99分别是240ms、680ms、310ms,波动非常大,没法定阈值。
原因: CI环境资源是共享的,其他Job在同时运行,占用CPU和网络资源,导致压测结果不稳定。
解法: 性能测试需要独立的机器,不能和其他CI任务共享资源。在Jenkins里设label 'performance-node',在GitHub Actions里用self-hosted的专属Runner。同时,被测服务和压测机不能在同一台机器,否则互相抢资源。
坑2:CI压测时数据库是空的导致结果失真
现象: CI里接口P99是15ms,比生产快了10倍。上线后P99是180ms。
原因: CI环境数据库是在Docker里临时起的,只有几百条测试数据,所有查询都在内存里,没有触发任何磁盘IO。
解法: CI压测应该连测试环境数据库,而不是临时起的空数据库。或者在测试前用脚本往数据库里插入足量随机数据(至少100万条)。
坑3:P99阈值设太严导致流水线频繁失败
现象: P99阈值设300ms,但CI环境的P99天然就比生产高30%(因为CI环境配置低),每次流水线都失败,开发团队开始习惯性忽略性能测试的失败状态。
原因: 阈值设置要考虑CI环境的基准,不能直接用生产SLA。
解法: 第一次在CI环境跑测,记录当时的P99作为基线(比如350ms)。之后的阈值是"相比基线增加不超过20%",即420ms。这样检测的是相对退化,不是绝对值。
总结
性能测试集成CI的价值不在于每次跑出完美的数字,而在于发现性能退化——在代码合并前发现,而不是在生产故障后发现。
核心三件事:脚本参数化支持CI调用,结果自动解析对比阈值,失败时阻止合并并通知团队。
下一篇换个工具,进入Gatling实战,看看Scala DSL的压测脚本怎么写。
