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 原創精華
【連載】從 Java 開始效能測試
- 故障測試與 Web 前端
- 服務端功能測試
- 效能測試專題
- Java、Groovy、Go
- 白盒、工具、爬蟲、UI 自動化
- 理論、感悟、影片