效能測試中的唯一標識問題研究

FunTester發表於2024-04-07

在效能測試場景中,生成全域性唯一識別符號 (GUID) 是一個常見的需求,主要用於標識每個請求或者事務,以便於追蹤和分析。這是因為在效能測試中,需要對系統的各個功能進行測試,而每個功能都需要有一個唯一的標識來區分。如果不使用全域性唯一標識,則可能會出現重複標識的情況,導致測試結果不準確。

相信對於效能測試 er 來講這些並不陌生,特別在併發場景中使用各類的解決方案。我最近在研究 Go 語言執行緒安全問題的時候也被其他人問到了。所以打算單獨寫一寫唯一標識的主題,本來打算用一篇文章解決,但是在實踐中方案概述、方案實踐以及效能對比幾個部分,內容著實有點多。所以分成了上下兩篇,本篇講述幾種常見方案的概述和程式碼實踐,下一期我會分享幾種方案的效能。

UUID(Universally Unique Identifier)

UUID(通用唯一識別符號)是一種標準化的用於標識資訊的方法。通常用於分散式系統中的唯一標識,以防止不同系統中的資料重複或衝突。它在資料庫記錄、網路通訊、訊息佇列等方面都有廣泛的應用。它是由 128 位二進位制數表示的唯一識別符號,通常以 32 個十六進位制數字的形式表示,每四個數字之間用連字元分隔。UUID 的唯一性主要基於其隨機性和長度,儘管在某些情況下可能會出現重複,但重複的機率非常低。具體有多低呢,我查到資料是這麼說的:每秒產生10億筆UUID,100年後只產生一次重複的機率是50%。如果地球上每個人都各有6億筆GUID,發生一次重複的機率是50%。。我暫時還沒遇到重複的情況,各位遇到請告訴我一下機率。

由於這是個自帶的包,可以使用java.util.UUID類生成 UUID,例如:

UUID uuid = UUID.randomUUID();
String id = uuid.toString();

大概長這樣 245fee40-8b24-47d3-b5e1-09a5e48a08d1。查閱資料過程中,還有多種版本的 UUID,不知道是不是都這個格式。我用的 JDK17,如果又不一樣格式的,興許版本不同導致的。

UUID 的優點包括:

  1. 全域性唯一性:UUID 基於其 128 位的長度和隨機性,可以在全球範圍內保證唯一性,極大地減少了資料衝突的可能性。
  2. 無序性:UUID 是無序的,不受時間和空間的限制,可以在任何地方、任何時間生成,不需要中心化管理。
  3. 高效能:生成 UUID 的速度非常快,幾乎可以瞬間完成,不會造成系統效能瓶頸。
  4. 不可推測性:UUID 是隨機生成的,不可預測,可以有效防止資訊被猜測或破解。
  5. 可擴充套件性:UUID 採用 128 位的長度,可以靈活地擴充套件應用範圍,適用於各種場景。

然而,UUID 也存在一些缺點:

  1. 長度較長:UUID 通常由 32 個十六進位制數字和四個連字元組成,總共 36 個字元,相比其他識別符號(如自增 ID)長度較長,佔用儲存空間較大。
  2. 不易讀:UUID 是一串十六進位制數字,對人類來說不夠友好,不如自增 ID 那樣直觀易讀。
  3. 不連續性:由於 UUID 是隨機生成的,所以其生成的順序是不連續的,不適合作為連續遞增的識別符號。
  4. 碰撞機率:雖然 UUID 的碰撞機率非常低,但隨著資料量的增加,碰撞的可能性也會增加,需要進行適當的處理和預防。

UUID 適用於需要全域性唯一標識且不依賴於中心化管理的場景,但在某些情況下可能會受到長度、可讀性和碰撞機率等因素的限制,需要根據具體情況進行選擇和權衡。如果我們在效能測試結束後清理資料的話,可以很大程式降低 UUID 重複的機率。

Redis/Zookeeper 等分散式服務生成 GUID

在分散式系統中,能夠生成全域性唯一 ID 是一個常見且重要的需求。全域性唯一 ID 不僅可以用於標識分佈在不同節點上的資料記錄,還可以用於追蹤分散式事務、訊息佇列等場景。傳統的基於資料庫自增序列或 UUID 等方式無法滿足分散式環境下的需求,因此需要藉助分散式服務來實現。

利用 Redis 的INCR命令可以實現一個簡單的分散式 ID 生成器。Redis 是一個高效能的記憶體資料庫,它提供了原子操作命令INCR用於對鍵值進行自增操作。我們可以在 Redis 中設定一個全域性的鍵,每次呼叫INCR命令即可獲取一個唯一的 ID 值。由於 Redis 是單執行緒處理命令,因此可以確保獲取到的 ID 是全域性唯一的。這種方式實現簡單,但需要注意 Redis 的可用性和效能問題。

另一種方式是利用 Zookeeper 的有序臨時節點特性。Zookeeper 是一個分散式協調服務,它允許客戶端建立有序的臨時節點,節點名稱是一個遞增的計數器。我們可以在 Zookeeper 上建立一個根節點,每個客戶端在該節點下建立一個有序臨時節點,臨時節點的名稱就是一個全域性唯一的 ID。這種方式相對複雜,但可靠性和可用性更高,適合於關鍵任務型系統。

這種方式最大的缺點就是需要 N 多次的網路通訊,即使強如 Redis 也很難提供強大的效能,所以直接再次直接放棄了。對於效能要求不甚高的場景來說還是非常好用的。同樣地我在查閱資料中發現也有使用 MySQL 遞增主鍵實現的,效能就更差了,絕對不推薦。

雪花演算法

雪花演算法(Snowflake)是一種用於生成分散式系統中全域性唯一的 ID 的演算法。它由 Twitter 公司設計,採用了時間戳、機器 ID 和序列號等資訊,結合位運算的方式生成 64 位的唯一 ID。其中,時間戳部分用於保證 ID 的唯一性和遞增性,機器 ID 部分用於標識不同的機器,序列號部分用於解決同一毫秒內併發生成 ID 時的衝突。雪花演算法具有高效、高效能、高可用等特點,被廣泛應用於分散式系統中的 ID 生成。

雪花演算法很大程度上彌補了 UUID 的不足,而且使用非常靈活,幾十行程式碼即可完成,還能夠根據實際場景進行定製化,受到了越來越多碼農的喜歡。這裡我分享一個簡單的例子:

package com.funtester.utils;  

public class SnowflakeUtils {  

    private static final long START_TIMESTAMP = 1616489534000L; // 起始時間戳,2021-03-23 00:00:00  

    private long datacenterId; // 資料中心ID  

    private long workerId;     // 機器ID  

    private long sequence = 0L; // 序列號  

    private static final long MAX_WORKER_ID = 31L;// 機器ID最大值  

    private static final long MAX_DATA_CENTER_ID = 31L;// 資料中心ID最大值  

    private static final long SEQUENCE_BITS = 12L;// 序列號位數  

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;// 機器ID左移位數  

    private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_SHIFT;// 資料中心ID左移位數  

    private static final long TIMESTAMP_LEFT_SHIFT = DATA_CENTER_ID_SHIFT + DATA_CENTER_ID_SHIFT;// 時間戳左移位數  

    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);// 序列號掩碼  

    private long lastTimestamp = -1L;  

    public SnowflakeUtils(long datacenterId, long workerId) {  
        if (datacenterId > MAX_DATA_CENTER_ID || datacenterId < 0) {  
            throw new IllegalArgumentException("Datacenter ID can't be greater than " + MAX_DATA_CENTER_ID + " or less than 0");  
        }  
        if (workerId > MAX_WORKER_ID || workerId < 0) {  
            throw new IllegalArgumentException("Worker ID can't be greater than " + MAX_WORKER_ID + " or less than 0");  
        }  
        this.datacenterId = datacenterId;  
        this.workerId = workerId;  
    }  

    /**  
     * 獲取下一個ID  
     *     * @return  
     */  
    public synchronized long nextId() {  
        long timestamp = System.currentTimeMillis();  
        if (timestamp < lastTimestamp) {  
            throw new RuntimeException("Clock moved backwards. Refusing to generate id for " + (lastTimestamp - timestamp) + " milliseconds");  
        }  
        if (lastTimestamp == timestamp) {  
            sequence = (sequence + 1) & SEQUENCE_MASK;  
            if (sequence == 0) {  
                timestamp = nextMillis(lastTimestamp);  
            }  
        } else {  
            sequence = 0L;  
        }  
        lastTimestamp = timestamp;  
        long l = ((timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT_SHIFT) | (datacenterId << DATA_CENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;  
        return l & Long.MAX_VALUE;  
    }  

    /**  
     * 獲取下一個時間戳  
     *  
     * @param lastTimestamp  
     * @return  
     */  
    private long nextMillis(long lastTimestamp) {  
        long timestamp = System.currentTimeMillis();  
        while (timestamp <= lastTimestamp) {  
            timestamp = System.currentTimeMillis();  
        }  
        return timestamp;  
    }  

}

使用的方法如下:

public static void main(String[] args) {
       SnowflakeUtils snowflake = new SnowflakeUtils(1, 1); // 建立雪花演算法例項,資料中心ID為1,機器ID為1
       for (int i = 0; i < 5; i++) {
           System.out.println("Next ID: " + snowflake.nextId());
       }
   }

結果大概長這個樣子:

Next ID: 3282842653393162240
Next ID: 3307893926320410624
Next ID: 3307893926320410625
Next ID: 3307893926320410626
Next ID: 3307893926320410627

我在 com.funtester.utils.SnowflakeUtils#nextId 方法的最後一行,加上了 l & Long.MAX_VALUE 為了獲取一個正的值。

執行緒獨享變數

在非併發場景當中,我們要想獲取一個全域性唯一的識別符號,最簡單的就是來一個 i++ ,但這樣並不能保障併發場景中的執行緒安全。儘管如此,我們依舊可以透過之前分享過的 將共享變獨享 的思路改造一下,將每一個執行緒都分配一個 int i ,然後線上程內 i++ 保障數值的唯一性。然後再給每一個執行緒進行唯一性標記,這個在之前分享執行緒工廠類時候提到過。如果遇到分散式場景,抄襲一下前面成熟框架的方法,增加唯一的機器碼標識即可。

下面是我使用的單機版本程式碼:


// 建立threadlocal物件  
static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {  

    @Override  
    protected Integer initialValue() {  
        return 0  
    }  
}  

public static void main(String[] args) {  
    setPoolMax(3)  
    for (int i = 0; i < 10; i++) {  
        fun {  
            increase()// 增加1  
            System.out.println(Thread.currentThread().getName() + " threadLocal.get() = " + threadLocal.get());// 列印threadLocal值  
        }  
    }  
}  

/**  
 * 增加1  
 * @return  
 */  
static def increase() {  
    threadLocal.set(threadLocal.get() + 1)  
}

輸出結果長這個樣子:

F-3  threadLocal.get() = 1
F-2  threadLocal.get() = 1
F-1  threadLocal.get() = 1
F-2  threadLocal.get() = 2
F-1  threadLocal.get() = 2
F-3  threadLocal.get() = 2
F-2  threadLocal.get() = 3
F-1  threadLocal.get() = 3
F-3  threadLocal.get() = 3
F-2  threadLocal.get() = 4

基本是實現了設計需求。缺點就是 java.lang.ThreadLocal 可能會導致記憶體溢位。這一點在效能測試當中可以忽略,因為用例執行完之後,JVM自然也是要關閉的,如果是單 JVM 的效能測試服務,可以將 java.lang.ThreadLocal 物件設計成類成員屬性規避記憶體溢位的問題。

執行緒共享變數

這個思路就簡單了:新建一個全域性執行緒安全的變數,每次獲取一個值之後,安全地遞增 1,這樣一下子就解決了所有問題,是所有方案裡面最簡單使用的。方案的程式碼

演示程式碼如下:

// 定義全域性變數,用於執行緒安全遞增計數  
static AtomicInteger index = new AtomicInteger(0)  

public static void main(String[] args) {  
    setPoolMax(3)  
    for (int i = 0; i < 10; i++) {  
        fun {  
            println "遞增結果: ${index.incrementAndGet()}"  
        }  
    }  
}

輸出結果:

遞增結果: 2
遞增結果: 3
遞增結果: 1
遞增結果: 4
遞增結果: 5
遞增結果: 6
遞增結果: 7
遞增結果: 8
遞增結果: 9
遞增結果: 10

相信個性化的方案不止一種,如果你也有一些有趣的方案,歡迎一起交流分享。

  • 2021 年原創合集
  • 2022 年原創合集
  • 2023 年原創合集
  • 服務端功能測試
  • 效能測試專題
  • Java、Groovy、Go、Python
  • 單元&白盒&工具合集
  • 測試方案&BUG&爬蟲&UI 自動化
  • 測試理論雞湯
  • 社群風采&影片合集
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章