背景
- 主鍵生成效率用數據庫自增效率也是比較高的,為什么要用主鍵生成器呢?是因為需要insert主表和明細表時,明細表有個字段是主表的主鍵作為關聯。所以就需要先生成主鍵填好主表明細表的信息后再一次過在一個事務內插入。或者是產生支付流水號時要全局唯一,所以要先生成后插入,不能靠數據庫主鍵。
- 網上有很多主鍵生成器方式,其中有算法部分和實現部分。算法部分一般就是雪花算法或者以業務編號前綴+年月日形式。
- 一般算法設計沒有問題,而在實現方案上,只是同學利用Redis很多實現起來的都是不高效的,他們沒考慮Redis都是單線程的情況下多個同時請求生成會有等待的時間。下面我們來對比2款實現方式,看看他們的問題點在哪里,還有我的改進實現方案。
目的
- 減少網絡連接Redis的次數,來減少TCP次數。
- 減少因Redis的單線程串行造成的等待
- 兩個進程、docker或者說兩個服務器之間隔離
- 減少Redis內存使用率
點贊再看,關注公眾號:【地藏思維】給大家分享互聯網場景設計與架構設計方案
掘金:地藏Kelvin https://juejin.im/user/5d67da8d6fb9a06aff5e85f7
公眾號:地藏思維
最終實現工具
- Redis: incr、get、set以達到控制進程隔離
- 只連接一次Redis
- LUA腳本保持原子
- syncronize
- 雪花算法以達到不重復鍵
算法場景
算法場景一:雪花算法
生成出來的主鍵使用了long類型,long類型為8字節工64位。可表示的最大值位2^64-1(18446744073709551615,裝換成十進制共20位的長度,這個是無符號的長整型的最大值)。
單常見使用的是long 不是usign long所以最大值為2^63-1(9223372036854775807,裝換成十進制共19的長度,這個是long的長整型的最大值)

- 時間戳這個很容易得,搞個Date轉換成Timestamp就好了。
- 數據中心這個字段,可以人為讀環境變量填寫,畢竟linux服務器不知道你把這個機器放在哪個中心(這個中心是指異地多活的時候說的那個中心)。
- 機器識別號(就是你系統所在的服務器電腦)如何保持唯一是本章內容一個問題。
- 序列編號如果要用自增形式,用那種實現會比較高效率呢?很多阻塞不高效的問題出現在這個地方。
算法場景二:按業務要求規則生成(多數用於流水號,如支付流水)
這種因為long值太大,所以拼接后,會作為String形式。如支付寶的流水號
| 業務號 | 年 | 月 | 日 | 時 | 分 | 秒 | 毫秒 | 自增 |
|---|---|---|---|---|---|---|---|---|
| 000 | 0000 | 00 | 00 | 00 | 00 | 00 | 000 | 000 |
同樣序列編號如果要用自增形式,用那種實現會比較高效率呢?
是否需要加上數據中心、機器識別號來隔離進程呢?
然而上面的都是算法,只是確定了這個主鍵的組成部分,但是因為數據中心位和機器識別位沒有確定,就還是需要看如何實現出進程隔離,自增序列如何做才高效。
實現方案
方案一
網上有一種做法,生成時間戳、自增部分的邏輯都在redis里面做,先提前寫好LUA腳本,然后通過Redis框架eval執行腳本。
腳本內做以下Redis內容:
- 生成時間
- 以時間作為redis的key自增(如主鍵需要數據中心位和系統所在服務器編號可拼在key上),這個步驟得到自增序列。為了保持一毫秒內不重復。
- 設置超時時間。

這種方式其實是有問題的,假設A系統,有4個負載均衡節點,同一個時候,每個節點有10萬個請求生成主鍵。下一秒也有10萬個請求生成主鍵。
因為Redis是單線程處理每個命令,所以是串行的。
無論你用上述算法方案兩種的哪一種,那現在就有40萬個生成主鍵的網絡tcp請求打到redis,一個是網絡TCP數量比較多,產生多次握手,另外一個是串行問題導致系統一直在等結果,所以就會有效率問題。
萬一第一秒的40萬個都沒做完,第二秒的40萬個都在等待了。何況有10個系統都連這個redis呢?redis內存夠用嗎?
方案二
自增部分都在redis里面做。
- 服務生成時間戳
- 獲取數據中心識別號、機器識別號(如果是雪花算法)
- 獲取業務編碼(如果是按業務生成)
- 到redis生成同一毫秒內的自增序列

這樣的方案心里是在想,把精確到毫秒后,以自增序列之前的那一排數字作為key,請求到redis,incr就好了,這樣就可以不同毫秒之間就可以同時incr了吧?
這個方案其實也是有同樣上述方案一問題的,多機器同時訪問一個redis。
雖然redis要做的命令變少了,但是因為redis是單線程的,可能第一秒內有10萬個incr進redis,導致第二秒10萬個進來incr的時候,也是由於redis的單線程而等待着的。既然這樣也是等待,還不如直接在系統里面syncronize內存的等待還少一次redis網絡連接呢。
討論
-
解決這個問題有人說,可不可以使用合並請求,然后用redis的pipeline一次過丟一堆命令到redis,這樣就可以減少tcp連接的次數了。
然而即使只有一個tcp請求,但是也是有很多個命令要一個redis去處理,只是減少tcp而已,用請求合並還要搞個定時任務去做呢,這個定時任務的間隔時間還要特別短,非常影響CPU。 -
也會有說,那就加多幾個redis吧。讓系統訪問redis時,帶上一個redis識別號。這樣就能達到多個線程處理了。
如:有三台redis,分別請求三個redis時,都帶上一個號叫分片號,表明是哪台redis生成的。
| redis機器 | 自增序列帶上下面的數字作為前綴 |
|---|---|
| A | 0 |
| B | 1 |
| C | 2 |
這樣也不是不可以,只是說還是每次訪問Redis有網絡連接的消耗和redis單線程處理讓系統等待。
方案三:改進實現,只訪問一次Redis確定雪花算法中的機器識別號,然后系統各自生成。
實現概述
這個方案是基於內存實現,沒有為了統一自增序列而每次網絡連接訪問redis,也不用負載均衡4個節點而導致的4個節點的redis命令都丟給一個redis。只需要系統的4個負載均衡節點自己內部完成,這樣就把redis單線程的缺點改為4個進程自己完成,想要增加效率,只需要增加機器就可以了,不用多次依賴中間件。
| 時間戳 | 數據中心號 | 機器識別號 | 自增序列 |
|---|---|---|---|
| 41個位 | 5個位 | 5個位 | 12個位 |
-
時間戳由系統生成沒有疑問
-
數據中心號從環境變量里面讀取
-
為了不同的進程,意思也是為了相同的系統,但是不同的負載均衡節點之間相互隔離,保證每個負載節點生成的雪花算法結果都是不一樣的。所以必須帶上機器識別號,即使是使用按業務規則生成的算法方案,也是需要添加機器識別號的。獲取識別后就可以保存在靜態變量,並初始化雪花算法實例。(訪問Redis的就只有這一步,在系統啟動的時候完成,后續不用再訪問redis)
-
確定第三步后,按照雪花算法生成主鍵的邏輯,都在java系統里面做。自增序列在java系統里實現,不通過redis。
所以我們對系統啟動時的代碼和需要生成主鍵時的代碼分開來看。
主要關鍵點
在於機器識別號生成必須不相同,所以生成機器識別號的邏輯是在redis,而redis部分必須要用LUA腳本實現,保持原子性。
代碼流程
1~11 步是系統啟動的時候做的。
12~16 步是在系統跑起來后,要生成主鍵的時候觸發的。
如何保持不同的進程之間隔離,在第5到第10步,請留意。

系統啟動時
-
你的系統啟動完后觸發
-
java句柄讀取本機的IP地址
-
在LUA腳本中調用 redis.call('GET',dmkey); 以數據中心+IP地址作為Key來GET,獲取一個數字作為機器識別號。如果能獲取到,就證明不是第一次訪問,就可以返回給系統
-
如果獲取不到,以一個固定字符串“_idgenerator”作為key,觸發incr
-
因原子性,所以incr得一個數字,用這個來作為本次線程訪問redis得出的machineId機器識別號。
-
然后以數據中心號和ip地址 拼接后作為key,調用redis的set key, value為剛剛的machineId。
這樣就可以讓相同的數據中心,並且相同的IP地址在下次直接get到機器識別號。 -
這個號碼保存在靜態成員變量里面,這樣就不用每次生成主鍵的時候都需要去訪問,因為數據中心+IP地址是恆定的。
(注意這幾步必須在LUA里面實現,如果在java代碼里面實現,很有可能會incr出來的號,在set key那一步被其他機器覆蓋了)
截止到流程圖的11步結束:這些redis的邏輯都只需要在服務啟動的時候觸發一次就好了(這里完成目的的第一點減少網絡連接)。因為觸發一次后就可以保存在代碼靜態變量里面。
根據ip來確定出機器識別號后,這樣生成主鍵的過程都是保持進程間隔離的。完成目的的第三點,數據隔離。而且利用redis保證了原子性,機器識別號不會重復。
代碼貼不全,大家明白思路就好,具體實現在下方Gitee。
private String LUA_SCRIPT = "/redis-script.lua";
List<Object> keys = new ArrayList<>(2);
private static final AtomicReference<String> ENQUEUE_LUA_SHA = new AtomicReference<>();
@Override
public void afterPropertiesSet() throws Exception {
// TODO 讀取properties配置獲得datacenterId
Long datacenterId = 0L;
// 本方法內執行命令獲得ip作為
String ip = InetAddress.getLocalHost().getHostAddress();
String luaContent = null;
ClassPathResource resource = new ClassPathResource(LUA_SCRIPT);
try {
luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
} catch (IOException e) {
e.printStackTrace();
}
String sha = redissonClient.getScript().scriptLoad(luaContent);
ENQUEUE_LUA_SHA.compareAndSet(null, sha);
keys.add(datacenterId);
keys.add(ip);
// 通過LUA腳本獲得機器識別號
Long machineId = (Long) this.redissonClient.getScript().eval(Mode.READ_WRITE, ENQUEUE_LUA_SHA.get(),
ReturnType.INTEGER, keys, null);
// 保存機器識別號並初始化雪花算法實例(后續再看里面邏輯,只需要知道是保存machineId就行)
Snowflake.getInstance(machineId, datacenterId);
}
-- need redis 3.2+
local prefix = '__idgenerator_';
local datacenterId = KEYS[1];
local ip = KEYS[2];
if datacenterId == nil then
datacenterId = 0
end
if ip == nil then
return -1
end
local dmkey= prefix ..'_' .. datacenterId ..'_' .. ip;
local machineId ;
machineId = redis.call('GET',dmkey);
if machineId!=nil then
return machineId
end
machineId = tonumber(redis.call('INCRBY', prefix, 1));
if machineId > 0 then
redis.call('SET',dmkey,machineId);
return machineId;
end
當需要生成主鍵時
- 開啟syncronize
- 根據雪花算法或者按業務規則算法生成時間戳
- 如果你有異地多活就要在環境變量讀取數據中心id
- 主要是讀取系統啟動后獲取到的machineId
- 依賴syncronize,自增序列+1。
代碼貼不全,大家明白思路就好,具體實現在下方Gitee。
public synchronized long nextId() {
long timestamp = timeGen();
// 獲取當前毫秒數
// 如果服務器時間有問題(時鍾后退) 報錯。
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format(
"Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
// 如果上次生成時間和當前時間相同,在同一毫秒內
if (lastTimestamp == timestamp) {
// sequence自增,因為sequence只有12bit,所以和sequenceMask相與一下,去掉高位
sequence = (sequence + 1) & sequenceMask;
// 判斷是否溢出,也就是每毫秒內超過4095,當為4096時,與sequenceMask相與,sequence就等於0
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
// 自旋等待到下一毫秒
}
} else {
sequence = 0L;
// 如果和上次生成時間不同,重置sequence,就是下一毫秒開始,sequence計數重新從0開始累加
}
lastTimestamp = timestamp;
// 最后按照規則拼出ID。
// 000000000000000000000000000000000000000000 00000 00000 000000000000
// time datacenterId workerId sequence
// return ((timestamp - twepoch) << timestampLeftShift) | (datacenterId <<
// datacenterIdShift)
// | (workerId << workerIdShift) | sequence;
// 因為雪花算法沒那么多位置給workerId 如果要改,那就要改雪花算法數據中心id和workerId的占位位置
long longStr = ((timestamp - twepoch) << timestampLeftShift) | (datacenterId << datacenterIdShift)
| (workerId << workerIdShift) | sequence;
// System.out.println(longStr);
return longStr;
}
}
在這一步實現自增加一,內存態完成,無須依賴redis。
這里完成目的的第二點,不再需要依賴redis的單線程來做等待。改為由系統那么多個負載均衡節點並行處理,因為反正在redis中incr都是內存態的也是串行的。
並且生成的主鍵變量都是局部變量,用完就銷毀,不需要存放於redis增加redis壓力。
完成目的4減少Redis內存使用率。
但是要注意,因為machineId只能站5位,所以最大是31,如果到32了,就會變0了。因為雪花算法沒那么多位置給machineId 如果要改,那就要改雪花算法數據中心id和machineId的占位數量。
總結
到這里,我們基於雪花算法,用Redis做控制進程的隔離,只需要一次連接,保證不同的服務負載節點上生成的主鍵不一致,來減少網絡TCP連接的訪問。也利用了syncronize來保證自增序列不重復的方式,來減少Redis單線程處理的等待時間。
代碼樣例
代碼太多長,不貼那么多了,看Gitee吧
https://gitee.com/kelvin-cai/IdGenerator
歡迎關注公眾號,文章更快一步
我的公眾號 :地藏思維

掘金:地藏Kelvin
簡書:地藏Kelvin
我的Gitee: 地藏Kelvin https://gitee.com/kelvin-cai
