在性能測試場景中,生成全局唯一標識符(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的優點包括:
- 全局唯一性:UUID基於其128位的長度和隨機性,可以在全球範圍內保證唯一性,極大地減少了數據衝突的可能性。
- 無序性:UUID是無序的,不受時間和空間的限制,可以在任何地方、任何時間生成,不需要中心化管理。
- 高性能:生成UUID的速度非常快,幾乎可以瞬間完成,不會造成系統性能瓶頸。
- 不可推測性:UUID是隨機生成的,不可預測,可以有效防止信息被猜測或破解。
- 可擴展性:UUID採用128位的長度,可以靈活地擴展應用範圍,適用於各種場景。
然而,UUID也存在一些缺點:
- 長度較長:UUID通常由32個十六進制數字和四個連字符組成,總共36個字符,相比其他標識符(如自增ID)長度較長,佔用存儲空間較大。
- 不易讀:UUID是一串十六進制數字,對人類來說不夠友好,不如自增ID那樣直觀易讀。
- 不連續性:由於UUID是隨機生成的,所以其生成的順序是不連續的,不適合作爲連續遞增的標識符。
- 碰撞概率:雖然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
相信個性化的方案不止一種,如果你也有一些有趣的方案,歡迎一起交流分享。