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

在性能測試場景中,生成全局唯一標識符(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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章