1. 程式人生 > 實用技巧 >07丨案例:編寫最簡單的效能指令碼

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介面的區別有這麼幾處:

  1. 要把Path改為/pa/add;
  2. 輸入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指令碼時,要注意以下幾點:

  1. 要知道請求的型別,我們選擇的型別和後端介面的實現型別要是一致的。
  2. 業務的成功要有明確的業務判斷(在下面的TCP中,我們再加斷言來判斷)。
  3. 判斷問題時,請求的邏輯路徑要清晰。

編寫完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指令碼就完成了。後面我們就可以根據需要,增強指令碼了,加個引數化、關聯、檢查點等等。

總結

其實這篇文章只想告訴你一件事情,手工編寫指令碼,從基礎上說,是非常簡單的,只是有三點需要特別強調:

  1. 涉及到業務規則和邏輯判斷之後,編寫指令碼就複雜了起來。但是瞭解業務規則是做指令碼的前提條件,也是效能測試工程師的第一步。
  2. 編寫指令碼的時候,要知道後端的邏輯。這裡的意思不是說,你一開始寫指令碼的時候,就要去讀後端的程式碼,而是說你在遇到問題的時候,要分析整個鏈路上每個環節使用到了什麼技術,以便快速地分析判斷。
  3. 寫指令碼是以最簡為最佳,用不著故意複雜。

指令碼的細節功能有很多,而現在我們可以看到市場上的書籍也好,文件也好,基本上是在教人如何用工具,很少會從前到後地說明一個數據從哪發到哪,誰來處理這樣的邏輯。