3.4.3 测试数据处理

在我们设计的性能测试引擎中,测试数据的处理主要两个方面:一是多线程任务类中数据处理;二是多线程执行类的数据处理。

我们已经在多线程任务类中已经完成了收集功能的设计和开发,接下来开始设计和开发数据汇总功能。

这里有两个设计思路:

  1. 由多线程任务类结束后将测试数据上报给执行类
  2. 由多线程执行类在所有测试任务结束后,主动从每个任务对象中收集数据

两种思路主要差异就是数据上报/收集功能放在多线程任务类还是执行类。从类功能设计角度讲,应该是放在执行类,这样多线程任务类可以更加简单,使用者在拓展多线程任务类时,可以专注实现拓展功能,而不用过度关注数据处理。把数据上报放在多线程任务类中,这部分时间算在任务执行时间内,可能会影响测试结果的准确性。

所以,我们还需要在执行类的 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 存储的海量测试耗时数据怎么处理呢?

虽说如此,本地处理测试数据的必要性还是有的,因为你的团队并不一定有良好的监控系统和数据处理系统。很多时候小团队还是非常依赖性能测试人员从本地测试并产出数据,这种设计常见于测试工具。

下面演示下策如何实现。需求是统计测试耗时数据中的最小值、平均值、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 原创精华

【连载】从 Java 开始性能测试


↙↙↙阅读原文可查看相关链接,并与作者交流