3.4.3 测试数据处理
在我们设计的性能测试引擎中,测试数据的处理主要两个方面:一是多线程任务类中数据处理;二是多线程执行类的数据处理。
我们已经在多线程任务类中已经完成了收集功能的设计和开发,接下来开始设计和开发数据汇总功能。
这里有两个设计思路:
- 由多线程任务类结束后将测试数据上报给执行类。
- 由多线程执行类在所有测试任务结束后,主动从每个任务对象中收集数据。
两种思路主要差异就是数据上报/收集功能放在多线程任务类还是执行类。从类功能设计角度讲,应该是放在执行类,这样多线程任务类可以更加简单,使用者在拓展多线程任务类时,可以专注实现拓展功能,而不用过度关注数据处理。把数据上报放在多线程任务类中,这部分时间算在任务执行时间内,可能会影响测试结果的准确性。
所以,我们还需要在执行类的 start()
方法中进行测试数据的收集汇总,下面是在测试任务结束后进行数据收集的代码:
for (int i = 0; i < tasks.size(); i++) {
ThreadTask threadTask = tasks.get(i);
this.executeNumStatistic.addAndGet(threadTask.executeNum); // 累加执行次数
this.errorNumStatistic.addAndGet(threadTask.errorNum); // 累加执行错误次数
this.costTimeStatistic.addAll(threadTask.costTime); // 添加请求时间集合
}
下面我们开始数据的处理,也分成了多线程任务类和多线程执行类两部分。
多线程任务类比较简单,只需要将执行的结果简单输出日志即可,我们把这个功能放在 after()
方法中:
int sum = tasks.stream().mapToInt(f -> f.totalNum).sum();
long costTime = (endTimestamp - startTimestamp) / 1000; // 计算压测时长
System.out.println(String.format("任务执行完毕! 压测时长: %d 秒, 预期执行次数: %d, 实际执行次数 %d, 错误次数 %d, 耗时收集数量: %d", costTime, sum, executeNumStatistic.get(), errorNumStatistic.get(), costTimeStatistic.size())); // 打印任务执行完毕
最后问题来了,执行类中的 costTimeStatistic
存储的海量测试耗时数据怎么处理呢?
- 上策:不收集、不处理。在测试过程中实时将数据收集整理后,上报给专门处理此类数据的服务,然后在页面上实时观察监控信息。最终并汇入测试报告中。
- 中策:收集、三方处理。在测试过程中收集数据,交给第三方处理,第三方可能是本机的软件工具,或者是其他专用的类库,例如 Python 语言很多优秀的制表库。
- 下策:收集、自己处理。在实际的工作中,在施压机端使用压测程序处理测试数据。在实际的工作中,除非迫于无奈,尽量不要采取此下策。首先因为测试数据数量过于庞大,Java 集合类频繁扩容会导致施压机性能受损,其次 Java 并不适合处理这么大量的数据,应当交由更专业的语言和框架。
虽说如此,本地处理测试数据的必要性还是有的,因为你的团队并不一定有良好的监控系统和数据处理系统。很多时候小团队还是非常依赖性能测试人员从本地测试并产出数据,这种设计常见于测试工具。
下面演示下策如何实现。需求是统计测试耗时数据中的最小值、平均值、50 分位值、90 分位值、95 分位值、99 分位值、999 分位值,最大值,并在日志中输出。下面是一个简单的统计方法:
/**
* 统计数据
* @param data
*/
public static void statisticData(List<Integer> data) {
Collections.sort(data); // 排序
int min = data.get(0); // 最小值
int max = data.get(data.size() - 1); // 最大值
int average = (int) data.stream().mapToInt(x -> x).average().getAsDouble(); // 平均值
int p50 = data.get((int) (0.5 * data.size())); // 50 分位值
int p90 = data.get((int) (0.9 * data.size())); // 90 分位值
int p95 = data.get((int) (0.95 * data.size())); // 95 分位值
int p99 = data.get((int) (0.99 * data.size())); // 99 分位值
int p999 = data.get((int) (0.999 * data.size())); // 999 分位值
// 打印统计结果
System.out.println("最小值:" + min);
System.out.println("最大值:" + max);
System.out.println("平均值:" + average);
System.out.println("50 分位值:" + p50);
System.out.println("90 分位值:" + p90);
System.out.println("95 分位值:" + p95);
System.out.println("99 分位值:" + p99);
System.out.println("999 分位值:" + p999);
}
接着,我们需要在执行类中打印测试任务结束任务处,添加这行代码:
DataHandleTool.statisticData(costTimeStatistic);
这样就可以在测试结束后,看到类似如下日志信息:
最小值:20
最大值:1033
平均值:505
50 分位值:513
90 分位值:918
95 分位值:973
99 分位值:1012
999 分位值:1024
是不是觉得笔者漏掉了最重要的功能?
非常正确,性能测试中汇总数据中最重要的就是 TPS,它还有其他外号,诸如:QPS(Queries Per Second)、TPS(Transactions Per Second)以及 RPS(Requests Per Second)等等。
在本地统计 TPS 也有两个思路:一是总执行次数除以总时间;一是线程数除以平均响应耗时。在理想情况下,这两个思路计算得出的 TPS 是一样的,但在实际情况中往往不同。
使用第一种方式获得的总时间,是把前置和后置都计算在总时间内的,也就是在统计 test()
方法执行耗时代码以外的代码执行时间都算在总时间内,这样计算的总时间会偏大,导致计算的 TPS 偏小。
使用第二种方法获得的平均耗时存在同样的问题,但实际效果却相反。一个任务线程除了执行 test()
方法外还执行了其他代码,统计获得的平均响应耗时虽然相对准确,但是通过 1(单位秒)除以平均耗时得出来的 TPS 往往偏大。
两种方法各有千秋,如果你的用例中前置和后置以及循环中 test()
方法外代码耗时比较小,不同方式计算的 TPS 差异不会很大,误差会在可接受范围内。
对于 TPS 的统计,最好的方法还是不要放在本地,去网关侧、服务侧统计。对于小团队而言,缺少必要的监控能力也是常见,所以笔者在框架中会输出两种统计方式获取到的 TPS 数据,并在测试结束后打印日志。
这里计算平均值的这行代码,功能上跟 DataHandleTool#statisticData
方法重合,属于冗余了。我们稍微改造一下 statisticData
方法,让他返回一个对象,将统计信息返回。首先我们需要设计一个统计各个分位值的类,在 DataHandleTool
类新建一个内部类,根据需求实现代码如下:
/**
* 统计数据,各个分位值
*/
public static class Quantile {
public int min;
public int max;
public int average;
public int p50;
public int p90;
public int p95;
public int p99;
public int p999;
public Quantile(int min, int max, int average, int p50, int p90, int p95, int p99, int p999) {
this.min = min;
this.max = max;
this.average = average;
this.p50 = p50;
this.p90 = p90;
this.p95 = p95;
this.p99 = p99;
this.p999 = p999;
}
public void print() {
// 打印统计结果
System.out.println("最小值:" + this.min);
System.out.println("最大值:" + this.max);
System.out.println("平均值:" + this.average);
System.out.println("50 分位值:" + this.p50);
System.out.println("90 分位值:" + this.p90);
System.out.println("95 分位值:" + this.p95);
System.out.println("99 分位值:" + this.p99);
System.out.println("999 分位值:" + this.p999);
}
}
除了几个统计分位属性以外,我们额外增加了一个打印方法。接下来需要重构 DataHandleTool#statisticData
方法:
/**
* 统计数据
* @param data
*/
public static Quantile statisticData(List<Integer> data) {
Collections.sort(data); // 排序
int min = data.get(0); // 最小值
int max = data.get(data.size() - 1); // 最大值
int average = (int) data.stream().mapToInt(x -> x).average().getAsDouble(); // 平均值
int p50 = data.get((int) (0.5 * data.size())); // 50 分位值
int p90 = data.get((int) (0.9 * data.size())); // 90 分位值
int p95 = data.get((int) (0.95 * data.size())); // 95 分位值
int p99 = data.get((int) (0.99 * data.size())); // 99 分位值
int p999 = data.get((int) (0.999 * data.size())); // 999 分位值
return new Quantile(min, max, average, p50, p90, p95, p99, p999);
}
那么执行类 start()
代码数据处理部分的代码就会变成下面这个样子:
/**
* 处理数据,在测试结束后运行
*/
public void handleData() {
for (int i = 0; i < tasks.size(); i++) {
ThreadTask threadTask = tasks.get(i);
this.executeNumStatistic.addAndGet(threadTask.executeNum); // 累加执行次数
this.errorNumStatistic.addAndGet(threadTask.errorNum); // 累加执行错误次数
this.costTimeStatistic.addAll(threadTask.costTime); // 添加请求时间集合
}
int sum = tasks.stream().mapToInt(f -> f.totalNum).sum();
long costTime = (endTimestamp - startTimestamp) / 1000; // 计算压测时长
DataHandleTool.Quantile quantile = DataHandleTool.statisticData(costTimeStatistic);
System.out.println(String.format("测试TPS: %d, 平均耗时: %f", this.tasks.size() * 1000 / quantile.average, quantile.average)); // 打印第一种方法统计的测试TPS
System.out.println(String.format("测试TPS: %d, 总执行次数: %d", executeNumStatistic.get() / costTime, executeNumStatistic.get())); // 打印第二种方法统计的测试TPS
quantile.print(); // 打印请求时间统计结果
System.out.println(String.format("任务执行完毕! 压测时长: %d 秒, 预期执行次数: %d, 实际执行次数 %d, 错误次数 %d, 耗时收集数量: %d", costTime, sum, executeNumStatistic.get(), errorNumStatistic.get(), costTimeStatistic.size())); // 打印任务执行完毕
}
这里笔者新写了一个方法,专门用来进行数据的处理。这样 start()
方法会更加简洁,方便我们下一章节进行高级功能的拓展。
后面在多线程类 after()
方法中添加了打印当前线程执行信息的日志:
/**
* 后置处理方法
*/
public void after() {
System.out.println(String.format("任务执行完毕! 预期执行次数: %d, 实际执行次数 %d, 错误次数 %d, 耗时收集数量: %d", totalNum, executeNum, errorNum, costTime.size())); // 打印任务执行完毕
}
那么在性能测试引擎第一次启动的代码中,after()
方法中需要添加额外的调用父类的 after()
方法的代码:
@Override
public void after() {
super.after();
System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " after testing !"); // 打印后置处理日志
}
书的名字:从 Java 开始做性能测试 。
如果本书内容对你有所帮助,希望各位不吝赞赏,让我可以贴补家用。赞赏两位数可以提前阅读未公开章节。我也会尝试制作本书的视频教程,包括必要的答疑。
FunTester 原创精华