07丨案例:編寫最簡單的效能指令碼
通常我們會遇到要手寫指令碼的時候,就要針對一些介面編寫指令碼。這時候,我們需要知道介面規範和後臺的資料是什麼。而有些效能測試工程師寫指令碼時,並不知道後端的邏輯,只知道實現指令碼,事實上,只知道實現指令碼是遠遠不夠的。
在這一篇文章中,我不打算講複雜的內容,只想針對新手寫一步步的操作,描述最簡單的指令碼編寫。如果你已經具有豐富的指令碼編寫經驗,會覺得本文很簡單。
我沒有打算把JMeter的功能點一一羅列出來,作為一個性能測試的專欄,不寫一下指令碼的實現似乎不像個樣子。在指令碼實現中,我們最常用的協議就是HTTP和TCP了吧,所以在今天的內容裡,我簡單地說一下如何編寫HTTP和TCP指令碼,以應測試主題。
我先畫個圖說明一下。
這樣的圖做效能的人一定要知道,相信很多人也畫的出來。
我們知道HTTP是應用層的協議之一,現在很多場景都在用它,並且是用的HTTP1.1的版本,對應的是RFC2616,當然還有補充協議RFC7231、6265。
HTTP中只規定了傳輸的規則,規定了請求、響應、連線、方法、狀態定義等。我們寫指令碼的時候,必須符合這些規則。比如為什麼要在指令碼中定義個Header?Header裡為什麼要那樣寫?這些在RFC中都說得明明白白了。
還有一點也需要注意,HTTP是通過Socket來使用TCP的,Socket做為套接層API,它本身不是協議,只規定了API。
而我們通常在JMeter中寫TCP指令碼,就是直接呼叫Socket層的API。TCP指令碼和HTTP指令碼最大的區別就是,TCP指令碼中傳送和接收的內容完全取決於Socket server是怎麼處理的,並沒有通用的規則。所以指令碼中也就只有根據具體的專案來發揮了。
手工編寫HTTP指令碼
服務端程式碼邏輯說明
我們先自己編寫一小段服務端程式碼的邏輯。現在用Spring Boot寫一個示例,其實就是分分鐘的事情。我們做效能測試的人至少要知道訪問的是什麼東西。
Controller關鍵程式碼如下:
@RestController @RequestMapping(value = "pa") public class PAController { @Autowired private PAService paService; //查詢 @GetMapping("/query/{id}") public ResultVO<User> getById(@PathVariable("id") String id) { User user = paService.getById(id); return ResultVO.<User>builder().success(user).build(); } }
Service關鍵程式碼如下:
public User getById(String id) {
return mapper.selectByPrimaryKey(id);
}
用MyBatis元件實現對Mapper的操作。由於不是基礎開發教程,這裡只是為了說明邏輯,如果你感興趣的話,可以自己編寫一個介面示例。
邏輯呼叫關係如下:
資料庫中表的資訊如下:
我們先看這個介面的訪問邏輯:JMeter——SprintBoot的應用——MySQL。
1.編寫JMeter指令碼
1.1 建立執行緒組
首先建立一個執行緒組,配置如下:
在這個執行緒組中,有幾個關鍵配置,我來一一說明一下。
Number of Threads(users):我們都知道這是JMeter中的執行緒數,也可以稱之為使用者數。但是在第2篇文章中,我已經說得非常明確了,這個執行緒數是產生TPS的,而一個執行緒產生多少TPS,取決於系統的響應時間有多快。所以我們用TPS這個概念來承載系統的負載能力,而不是用這裡的執行緒數。
Ramp-up Period(in seconds):遞增時間,以秒為單位。指的就是上面配置的執行緒數將在多長時間內會全部遞增完。如果我們配置了100執行緒,這裡配置為10秒,那麼就是100/(10s*1000ms)=1執行緒/100ms;如果我們配置了10執行緒,這裡配置為1秒,則是10/1000=1執行緒/100ms。這時我們要注意了哦,在10執行緒啟動的這個階段中,對伺服器的壓力是一樣的。示意圖如下:
Loop Count這個值指的是一個執行緒中指令碼迭代的次數。這裡你需要注意,這個值和後面的Scheduler有一個判斷關係,下面我們會提到。
Delay Thread creation until needed:這個含義從字面看不是特別清楚。這裡有一個預設的知識點,那就是JMeter所有的執行緒是一開始就建立完成的,只是遞增的時候會按照上面的規則遞增。如果選擇了這個選項,則不會在一開始建立所有執行緒,只有在需要時才會建立。這一點和LoadRunner中的初始化選項類似。只是不知道你有沒有注意過,基本上,我們做效能測試的工程師,很少有選擇這個選項的。選與不選之間,區別到底是什麼呢?
如果不選擇,在啟動場景時,JMeter會用更多的CPU來建立執行緒,它會影響前面的一些請求的響應時間,因為壓力機的CPU在做其他事情嘛。
如果選擇了的話,就會在使用時再建立,CPU消耗會平均一些,但是這時會有另一個隱患,就是會稍微影響正在跑的執行緒。這個選項,選擇與否,取決於壓力機在執行過程中,它能產生多大的影響。如果你的執行緒數很多,一旦啟動,壓力機的CPU都被消耗在建立執行緒上了,那就可以考慮選擇它,否則,可以不選擇。
Scheduler Configuration:這裡有一句重要的話,If Loop Count is not -1 or Forever, duration will be min(Duration, Loop Count * iteration duration)
。舉例來說,如果設定了Loop Count 為100,而響應時間是0.1秒,那麼Loop Count * iteration duration(這個就是響應時間) = 100 * 0.1 = 10秒
。
即便設定了Scheduler的Duration為100秒,執行緒仍然會以10秒為結束點。
如果沒有設定Scheduler的Duration,那麼你會看到,在JMeter執行到10秒時,控制檯中會出現如下資訊:
2019-11-26 10:39:20,521 INFO o.a.j.t.JMeterThread: Thread finished: Thread Group 1-10
有些人不太理解這一點,經常會設定迭代次數,同時又設定Scheduler中的Duration。而對TPS來說,就會產生這樣的圖:
場景沒執行完,結果TPS全掉下去了,於是開始查後端系統,其實和後端沒有任何關係。
1.2 建立HTTP Sampler
1.2.1 GET介面
看上圖,我將Method選擇為GET。為什麼要選擇它?往上看我們的介面註解,這是一個GetMapping,所以這裡要選擇GET。
再看path中,這裡是/pa/query/0808050c-0ae0-11ea-af5f-00163e124cff
,對應著“/query/{id}”
。
然後執行:
User user = paService.getById(id);
返回執行結果:
return ResultVO.<User>builder().success(user).build();
為什麼要解釋這一段呢?
做開發的人可能會覺得,你這個解釋毫無意義呀,程式碼裡已經寫得很清楚了。事實上,在我的工作經歷中,會發現很多做效能測試指令碼的,實際上並不知道後端採用了什麼樣的技術,實現的是什麼樣的邏輯。
所以還是希望你可以自己寫一些demo,去了解一些邏輯,然後在排除問題的時候,就非常清楚了。
接著我們執行指令碼,就得到了如下結果:
這樣一個最簡單的GET指令碼就做好了。
前面我們提到過,URL中的ID是0808050c-0ae0-11ea-af5f-00163e124cff,這個資料來自於資料庫中的第一條。
如果我們隨便寫一個數據,會得到什麼結果呢?
你會看到,結果一樣得到了200的code,但是這個結果明顯就不對了,明明沒有查到,還是返回了成功。
所以說,業務的成功,只能靠業務來判斷。這裡只是查詢成功了,沒返回資料也是查詢成功了。我將在後面給你說明如何加斷言。
1.2.2 POST介面
下面我將Method改為POST,POST介面與GET介面的區別有這麼幾處:
- 要把Path改為/pa/add;
- 輸入JSON格式的Body Data。
執行起來,檢視下結果。
你會發現上來就錯了,提示如下:
"status":415,"error":"Unsupported Media Type","message":"Content type 'text/plain;charset=UTF-8' not supported"
這裡你需要注意,無論遇到什麼問題,都要針對問題來處理。當看不懂問題資訊時,先查資料,想辦法看懂。這是處理問題的關鍵,我發現很多做效能測試的新同學,一旦碰到問題就懵了,暈頭轉向地瞎嘗試。
我經常對我的團隊成員說,先看懂問題,再處理問題,別瞎蒙!
上面這個問題其實提示得很清楚:“不支援的媒體型別”。這裡就兩個資訊,一個是Content type,一個是charset。它們是JMeter中HTTP Header裡預設自帶的。我們要傳送的是JSON資料,而JMeter預設是把它當成text發出去的,這就出現了問題。所以我們要加一個Header,將Content type指定為JSON。
加一個HTTP Header,如下所示:
如果你不知道加什麼樣的Header,建議你用HTTP抓包工具抓一個看一看,比如說用Charles,抓到如下資訊:
這時你就會知道頭裡的Content-Type原來是application/json;charset=UTF-8
。這裡的charset=UTF-8可以不用寫,因為它和預設的一樣。
這時再回放,你就會看到如下結果:
到此,一個POST指令碼就完成了。是不是很簡單。
在這裡,我需要跟你強調的是,手工編寫HTTP指令碼時,要注意以下幾點:
- 要知道請求的型別,我們選擇的型別和後端介面的實現型別要是一致的。
- 業務的成功要有明確的業務判斷(在下面的TCP中,我們再加斷言來判斷)。
- 判斷問題時,請求的邏輯路徑要清晰。
編寫完HTTP指令碼時,我們再來看一下如何編寫TCP指令碼。
手工編寫TCP指令碼
服務端程式碼邏輯說明
我在這裡寫一個非常簡單的服務端接收執行緒(如果你是開發,不要笑話,我只是為了說明指令碼怎麼寫)。
package demo.socket;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class SocketReceiver {
//定義初始
public static final int corePoolSize = 5;
//定義最大執行緒池
public static final int maximumPoolSize = 5;
//定義socket佇列長度
public static final int blockingQueue = 50;
/**
* 初始化並啟動服務
*/
public void init() {
//定義執行緒池
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, 0L,
TimeUnit.MILLISECONDS, new ArrayBlockingQueue(blockingQueue));
//定義serverSocket
ServerSocket serverSocket = null;
try {
//啟動serverSocket
serverSocket = new ServerSocket(Constants.PORT);
//輸出服務啟動地址
System.out.println("服務已啟動:" + serverSocket.getLocalSocketAddress().toString());
//接收資訊並傳遞給執行緒池
while (true) {
Socket socket = serverSocket.accept();
executor.submit(new Handler(socket));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (serverSocket != null) {
try {
serverSocket.close(); //釋放serverSocket
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
//處理請求類
class Handler implements Runnable {
private Socket socket;
public Handler(Socket socket) {
this.socket = socket;
}
public void run() {
try {
// 接收客戶端的資訊
InputStream in = socket.getInputStream();
int count = 0;
while (count == 0) {
count = in.available();
}
byte[] b = new byte[count];
in.read(b);
String message = new String(b);
System.out.println(" receive request: " + socket.getInetAddress() + " " + message);
// 睡2秒模擬思考時間,這裡是為了模擬伺服器端的業務處理時間
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 向客戶端傳送確認訊息
//定義輸出流outer
OutputStream outer = socket.getOutputStream();
//將客戶端傳送的資訊加上確認資訊ok
String response = message + " is OK";
//將輸入資訊儲存到b_out中
byte[] b_out = response.getBytes();
//寫入輸入流
outer.write(b_out);
//推送輸入流到客戶端
outer.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
// 關閉socket
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
//程式入口
public static void main(String[] args) {
//定義服務端
SocketReceiver receiver = new SocketReceiver();
//啟動服務端
receiver.init();
}
}
編寫JMeter指令碼
首先建立TCP Sampler。右鍵點選Thread Group - Add - Sampler - TCP Sampler即可建立。
輸入配置和要傳送的資訊。
IP地址和埠是必須要輸入的。對於建立一個TCP協議的JMeter指令碼來說,簡單地說,過程就是這樣的:建立連線 - 發資料 - 關閉連線。
就這樣,這個手工的指令碼就完成了。
你可能會問,就這麼簡單嗎?是的,手工編寫就是這麼簡單。
但是(對嘛,但是才是重點),通常我們在建立TCP協議的指令碼時,都是根據業務介面規範來說的,複雜點其實不在指令碼本身上,而是在介面的規則上。
新增斷言
我回放了一下指令碼,發現如下情況:
都執行對了呀,為什麼下面的沒有返回資訊呢?這種情況下只有第一個請求有返回資訊,但是下面也沒有報錯。這裡就需要注意了。
測試工具的成功,並不等於業務的成功。
所以我們必須要做的就是響應斷言,也就是返回值的判斷。在JMeter中,斷言有以下這些:
因為今天的文章不是工具的教程,所以我不打算全講一遍。這裡我只用最基礎的響應斷言。什麼是斷言呢?
斷言指的就是伺服器端有一個業務成功的標識,會傳遞給客戶端,客戶端判斷是否正常接收到了這個標識的過程。
在這裡我添加了一個斷言,用以判斷伺服器是否返回了OK。 你要注意這個“OK”是從哪來的哦,它是從服務端的這一行程式碼中來的。
String response = message + " is OK";
請注意,這個斷言的資訊,一是可以判斷出業務的正確性。我在工作中發現有些人用頁面中一些並不必要的文字來判斷,這樣就不對了,我們應該用有業務含義的判斷標識。
如果我們再次回放指令碼,你會發現除了第一個請求,後面9個請求都錯了。
所以,在做指令碼時,請你一定要注意,斷言是必須要加的。
長短連線的問題
既然有錯,肯定是要處理。我們檢視一下JMeter的控制檯錯誤資訊:
2019-11-26 09:51:51,587 ERROR o.a.j.p.t.s.TCPSampler:
java.net.SocketException: Broken pipe (Write failed)
at java.net.SocketOutputStream.socketWrite0(Native Method) ~[?:1.8.0_111]
at java.net.SocketOutputStream.socketWrite(SocketOutputStream.java:109) ~[?:1.8.0_111]
at java.net.SocketOutputStream.write(SocketOutputStream.java:141) ~[?:1.8.0_111]
at org.apache.jmeter.protocol.tcp.sampler.TCPClientImpl.write(TCPClientImpl.java:78) ~[ApacheJMeter_tcp.jar:5.1.1 r1855137]
at org.apache.jmeter.protocol.tcp.sampler.TCPSampler.sample(TCPSampler.java:401) [ApacheJMeter_tcp.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:622) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:546) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:486) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:253) [ApacheJMeter_core.jar:5.1.1 r1855137]
at java.lang.Thread.run(Thread.java:745) [?:1.8.0_111]
從字面上來看,就是通道瓦塔(被破壞)了,Broken pipe。這個提示表明客戶端上沒有這個連線了,而JMeter還以為有這個連結,於是接著用這個連結來發,顯然是找不到這個通道,於是就報錯了。
這是一個典型的壓力工具這邊的問題。
而服務端,只收到了一條請求。
為什麼會報這個錯呢?因為我們程式碼是短連結的,服務端處理完之後,就把這個連結給斷掉了。
這裡是壓力機上的抓包資訊:
//從這裡開始,上面已經看到了有Fin(結束)包了,後面還在發Push(傳送資料)包。顯然是通不了,還被服務端啪啪抽了兩次reset。
11:58:07.042915 IP localhost.57677 > 60.205.107.9.m-oap: Flags [P.], seq 34:67, ack 41, win 4119, options [nop,nop,TS val 163718903 ecr 2122793206], length 33
11:58:07.046075 IP localhost.57677 > 60.205.107.9.m-oap: Flags [FP.], seq 67:331, ack 41, win 4119, options [nop,nop,TS val 163718906 ecr 2122793206], length 264
11:58:07.076393 IP 60.205.107.9.m-oap > localhost.57677: Flags [R], seq 3986768192, win 0, length 0
11:58:07.079156 IP 60.205.107.9.m-oap > localhost.57677: Flags [R], seq 3986768192, win 0, length 0
服務端的抓包資訊:
//服務端也是沒有辦法,只能在看到了Push包之後,給回了個Reset包。
11:58:07.047001 IP 124.64.16.240.bones > 7dgroup1.enc-eps-mc-sec: Flags [P.], seq 34:67, ack 41, win 4119, options [nop,nop,TS val 163718903 ecr 2122793206], length 33
11:58:07.047077 IP 7dgroup1.enc-eps-mc-sec > 124.64.16.240.bones: Flags [R], seq 3986768192, win 0, length 0
11:58:07.054757 IP 124.64.16.240.bones > 7dgroup1.enc-eps-mc-sec: Flags [FP.], seq 67:331, ack 41, win 4119, options [nop,nop,TS val 163718906 ecr 2122793206], length 264
11:58:07.054844 IP 7dgroup1.enc-eps-mc-sec > 124.64.16.240.bones: Flags [R], seq 3986768192, win 0, length 0
這是為什麼呢?因為在JMeter中,預設是複用TCP連線的,但是在我們這個示例中,服務端並沒有儲存這個連線。所以,我們應該在指令碼中,把下圖中的Re-use connection給去掉。
這時再回放指令碼,你就會發現10次迭代全都對了。如下圖所示:
但是,這裡還有一個知識點,希望你注意。短連線的時候,必然會產生更多的TCP連線的建立和銷燬,對效能來說,這會讓系統變得緩慢。
所以你可以看到上面10條迭代全都對了的同時,響應時間也增加了。
可能會有人問,那這怎麼辦呢?長短連線的選擇取決於業務的需要,如果必須用短連結,那可能就需要更多的CPU來支撐;要是長連線,就需要更多的記憶體來支撐(用以儲存TCP連線)。
根據業務需要,我們選擇一個合適的就好。
TCP連線超時
這個問題,應該說非常常見,我們這裡只做問題的現象說明和解決,不做原理的探討。原理的部分,我會在監控和分析部分加一說明。
下面這個錯誤,屬於典型的主機連不上。
java.net.ConnectException: Operation timed out (Connection timed out)
at java.net.PlainSocketImpl.socketConnect(Native Method) ~[?:1.8.0_111]
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350) ~[?:1.8.0_111]
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206) ~[?:1.8.0_111]
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188) ~[?:1.8.0_111]
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392) ~[?:1.8.0_111]
at java.net.Socket.connect(Socket.java:589) ~[?:1.8.0_111]
at org.apache.jmeter.protocol.tcp.sampler.TCPSampler.getSocket(TCPSampler.java:168) [ApacheJMeter_tcp.jar:5.1.1 r1855137]
at org.apache.jmeter.protocol.tcp.sampler.TCPSampler.sample(TCPSampler.java:384) [ApacheJMeter_tcp.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:622) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:546) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:486) [ApacheJMeter_core.jar:5.1.1 r1855137]
at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:253) [ApacheJMeter_core.jar:5.1.1 r1855137]
at java.lang.Thread.run(Thread.java:745) [?:1.8.0_111]
time out是個如果你理解了邏輯,就覺得很簡單,如果沒理解邏輯,就覺得非常複雜的問題。
要想解決這個問題,就要先確定服務端是可以正常連通的。
如果不能正常連通,那麼通常都是IP不正確、埠不正確、防火牆阻止之類的問題。解決了網路連通性的問題,就可以解決connection timed out的問題。
編寫LoadRunner指令碼
針對上面這個示例,如果你要想編寫一個LoadRunner的示例指令碼,也是簡單到不行。
首先建立一個空的winsock指令碼,複製下面程式碼到action裡面。
//建立socket1
lrs_create_socket("socket1", "TCP", "RemoteHost=60.205.10.9:5567", LrsLastArg);
//走socket1, 傳送buf1中定義的資料
lrs_send ("socket1", "buf1", LrsLastArg );
//走socket1,接收資料儲存在buf2中
lrs_receive("socket1", "buf2", LrsLastArg);
//關掉socket1
lrs_close_socket("socket1");
從上面的資訊就可以看到,socket1這個標識是我們操作的基礎。如果你在一個指令碼中想處理兩個socket,也是可以的,只要控制好你的標識不會亂就行。
接著再將下面的內容複製到data.ws裡面。
send buf1 5
"12345"
recv buf2 10
你可能會問,這個recv怎麼不寫返回的值是什麼?
當你手寫socket指令碼的時候,都還沒有執行,你怎麼知道返回值是什麼呢?所以這裡,可以不用寫。
而recv 後面的10是指接收10個位元組。如果多了怎麼辦?截掉?!不會的,LoadRunner還是會把所有資訊全部接收並儲存下來,除非你提前定義了擷取字元長度的函式。
最後看下我們回放的結果:
Action.c(6): lrs_create_socket(socket1, TCP, ...)
Action.c(7): lrs_send(socket1, buf1)
Action.c(8): lrs_receive(socket1, buf2)
Action.c(8): Mismatch in buffer's length (expected 10 bytes, 11 bytes actually received, difference in 1 bytes)
================================EXPECTED BUFFER================================
===============================================================================
================================RECEIVED BUFFER================================
"12345 is OK"
===============================================================================
Action.c(8): callRecv:11 bytes were received
Action.c(9): lrs_close_socket(socket1)
看,指令碼正常執行了,只是報了一個Mismatch,這是因為我們定義了buf2 是10位元組,而我們實際上接收了11位元組,所以這裡給出了Mismatch。
到此,一個LoadRunner的手工TCP指令碼就完成了。後面我們就可以根據需要,增強指令碼了,加個引數化、關聯、檢查點等等。
總結
其實這篇文章只想告訴你一件事情,手工編寫指令碼,從基礎上說,是非常簡單的,只是有三點需要特別強調:
- 涉及到業務規則和邏輯判斷之後,編寫指令碼就複雜了起來。但是瞭解業務規則是做指令碼的前提條件,也是效能測試工程師的第一步。
- 編寫指令碼的時候,要知道後端的邏輯。這裡的意思不是說,你一開始寫指令碼的時候,就要去讀後端的程式碼,而是說你在遇到問題的時候,要分析整個鏈路上每個環節使用到了什麼技術,以便快速地分析判斷。
- 寫指令碼是以最簡為最佳,用不著故意複雜。
指令碼的細節功能有很多,而現在我們可以看到市場上的書籍也好,文件也好,基本上是在教人如何用工具,很少會從前到後地說明一個數據從哪發到哪,誰來處理這樣的邏輯。