前面文章在談論分散式唯一ID生成的時候,有提到雪花演算法,這一次,我們詳細點講解,只講它。
SnowFlake演算法
據國家大氣研究中心的查爾斯·奈特稱,一般的雪花大約由10^19個水分子組成。在雪花形成過程中,會形成不同的結構分支,所以說大自然中不存在兩片完全一樣的雪花,每一片雪花都擁有自己漂亮獨特的形狀。雪花演算法表示生成的id如雪花般獨一無二。
snowflake是Twitter開源的分散式ID生成演算法,結果是一個long型的ID。其核心思想是:使用41bit作為毫秒數,10bit作為機器的ID(5個bit是資料中心,5個bit的機器ID),12bit作為毫秒內的流水號(意味著每個節點在每毫秒可以產生 4096 個 ID),最後還有一個符號位,永遠是0。
核心思想:分散式,唯一。
演算法具體介紹
雪花演算法是 64 位 的二進位制,一共包含了四部分:
- 1位是符號位,也就是最高位,始終是0,沒有任何意義,因為要是唯一計算機二進位制補碼中就是負數,0才是正數。
- 41位是時間戳,具體到毫秒,41位的二進位制可以使用69年,因為時間理論上永恆遞增,所以根據這個排序是可以的。
- 10位是機器標識,可以全部用作機器ID,也可以用來標識機房ID + 機器ID,10位最多可以表示1024臺機器。
- 12位是計數序列號,也就是同一臺機器上同一時間,理論上還可以同時生成不同的ID,12位的序列號能夠區分出4096個ID。
優化
由於41位是時間戳,我們的時間計算是從1970年開始的,只能使用69年,為了不浪費,其實我們可以用時間的相對值,也就是以專案開始的時間為基準時間,往後可以使用69年。獲取唯一ID的服務,對處理速度要求比較高,所以我們全部使用位運算以及位移操作,獲取當前時間可以使用System.currentTimeMillis()
。
時間回撥問題
在獲取時間的時候,可能會出現時間回撥
的問題,什麼是時間回撥問題呢?就是伺服器上的時間突然倒退到之前的時間。
- 人為原因,把系統環境的時間改了。
- 有時候不同的機器上需要同步時間,可能不同機器之間存在誤差,那麼可能會出現時間回撥問題。
解決方案
- 回撥時間小的時候,不生成 ID,迴圈等待到時間點到達。
- 上面的方案只適合時鐘回撥較小的,如果間隔過大,阻塞等待,肯定是不可取的,因此要麼超過一定大小的回撥直接報錯,拒絕服務,或者有一種方案是利用擴充位,回撥之後在擴充位上加1就可以了,這樣ID依然可以保持唯一。但是這個要求我們提前預留出位數,要麼從機器id中,要麼從序列號中,騰出一定的位,在時間回撥的時候,這個位置
+1
。
由於時間回撥導致的生產重複的ID的問題,其實百度和美團都有自己的解決方案了,有興趣可以去看看,下面不是它們官網文件的資訊:
- 百度UIDGenerator:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
- 美團Leaf:https://tech.meituan.com/2019/03/07/open-source-project-leaf.html
- leaf-segment 方案
- 優化:雙buffer + 預分配
- 容災:Mysql DB 一主兩從,異地機房,半同步方式
- 缺點:如果用segment號段式方案:id是遞增,可計算的,不適用於訂單ID生成場景,比如競對在兩天中午12點分別下單,通過訂單id號相減就能大致計算出公司一天的訂單量,這個是不能忍受的。
- leaf-snowflake方案
- 使用Zookeeper持久順序節點的特性自動對snowflake節點配置workerID
- 1.啟動Leaf-snowflake服務,連線Zookeeper,在leaf_forever父節點下檢查自己是否已經註冊過(是否有該順序子節點)。
- 2.如果有註冊過直接取回自己的workerID(zk順序節點生成的int型別ID號),啟動服務。
- 3.如果沒有註冊過,就在該父節點下面建立一個持久順序節點,建立成功後取回順序號當做自己的workerID號,啟動服務。
- 快取workerID,減少第三方元件的依賴
- 由於強依賴時鐘,對時間的要求比較敏感,在機器工作時NTP同步也會造成秒級別的回退,建議可以直接關閉NTP同步。要麼在時鐘回撥的時候直接不提供服務直接返回ERROR_CODE,等時鐘追上即可。或者做一層重試,然後上報報警系統,更或者是發現有時鐘回撥之後自動摘除本身節點並報警
- 使用Zookeeper持久順序節點的特性自動對snowflake節點配置workerID
- leaf-segment 方案
程式碼展示
public class SnowFlake {
// 資料中心(機房) id
private long datacenterId;
// 機器ID
private long workerId;
// 同一時間的序列
private long sequence;
public SnowFlake(long workerId, long datacenterId) {
this(workerId, datacenterId, 0);
}
public SnowFlake(long workerId, long datacenterId, long sequence) {
// 合法判斷
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
// 開始時間戳(2021-10-16 22:03:32)
private long twepoch = 1634393012000L;
// 機房號,的ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
private long datacenterIdBits = 5L;
// 機器ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
private long workerIdBits = 5L;
// 5 bit最多隻能有31個數字,就是說機器id最多隻能是32以內
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 5 bit最多隻能有31個數字,機房id最多隻能是32以內
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
// 同一時間的序列所佔的位數 12個bit 111111111111 = 4095 最多就是同一毫秒生成4096個
private long sequenceBits = 12L;
// workerId的偏移量
private long workerIdShift = sequenceBits;
// datacenterId的偏移量
private long datacenterIdShift = sequenceBits + workerIdBits;
// timestampLeft的偏移量
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
// 序列號掩碼 4095 (0b111111111111=0xfff=4095)
// 用於序號的與運算,保證序號最大值在0-4095之間
private long sequenceMask = -1L ^ (-1L << sequenceBits);
// 最近一次時間戳
private long lastTimestamp = -1L;
// 獲取機器ID
public long getWorkerId() {
return workerId;
}
// 獲取機房ID
public long getDatacenterId() {
return datacenterId;
}
// 獲取最新一次獲取的時間戳
public long getLastTimestamp() {
return lastTimestamp;
}
// 獲取下一個隨機的ID
public synchronized long nextId() {
// 獲取當前時間戳,單位毫秒
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 去重
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
// sequence序列大於4095
if (sequence == 0) {
// 呼叫到下一個時間戳的方法
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 如果是當前時間的第一次獲取,那麼就置為0
sequence = 0;
}
// 記錄上一次的時間戳
lastTimestamp = timestamp;
// 偏移計算
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
private long tilNextMillis(long lastTimestamp) {
// 獲取最新時間戳
long timestamp = timeGen();
// 如果發現最新的時間戳小於或者等於序列號已經超4095的那個時間戳
while (timestamp <= lastTimestamp) {
// 不符合則繼續
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowFlake worker = new SnowFlake(1, 1);
long timer = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
worker.nextId();
}
System.out.println(System.currentTimeMillis());
System.out.println(System.currentTimeMillis() - timer);
}
}
問題分析
1. 第一位為什麼不使用?
在計算機的表示中,第一位是符號位,0表示整數,第一位如果是1則表示負數,我們用的ID預設就是正數,所以預設就是0,那麼這一位預設就沒有意義。
2.機器位怎麼用?
機器位或者機房位,一共10 bit,如果全部表示機器,那麼可以表示1024臺機器,如果拆分,5 bit 表示機房,5bit表示機房裡面的機器,那麼可以有32個機房,每個機房可以用32臺機器。
3. twepoch表示什麼?
由於時間戳只能用69年,我們的計時又是從1970年開始的,所以這個twepoch
表示從專案開始的時間,用生成ID的時間減去twepoch
作為時間戳,可以使用更久。
4. -1L ^ (-1L << x) 表示什麼?
表示 x 位二進位制可以表示多少個數值,假設x為3:
在計算機中,第一位是符號位,負數的反碼是除了符號位,1變0,0變1, 而補碼則是反碼+1:
-1L 原碼:1000 0001
-1L 反碼:1111 1110
-1L 補碼:1111 1111
從上面的結果可以知道,-1L其實在二進位制裡面其實就是全部為1,那麼 -1L 左移動 3位,其實得到 1111 1000
,也就是最後3位是0,再與-1L
異或計算之後,其實得到的,就是後面3位全是1。-1L ^ (-1L << x)
表示的其實就是x位全是1的值,也就是x位的二進位制能表示的最大數值。
5.時間戳比較
在獲取時間戳小於上一次獲取的時間戳的時候,不能生成ID,而是繼續迴圈,直到生成可用的ID,這裡沒有使用擴充位防止時鐘回撥。
6.前端直接使用發生精度丟失
如果前端直接使用服務端生成的long 型別 id,會發生精度丟失的問題,因為 JS 中Number是16位的(指的是十進位制的數字),而雪花演算法計算出來最長的數字是19位的,這個時候需要用 String 作為中間轉換,輸出到前端即可。
秦懷の觀點
雪花演算法其實是依賴於時間的一致性的,如果時間回撥,就可能有問題,一般使用擴充位解決。而只能使用69年這個時間限制,其實可以根據自己的需要,把時間戳的位數設定得更多一點,比如42位可以用139年,但是很多公司首先得活下來。當然雪花演算法也不是銀彈,它也有缺點,在單機上遞增,而多臺機器只是大致遞增趨勢,並不是嚴格遞增的。
沒有最好的設計方案,只有合適和不合適的方案。
【作者簡介】:
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析
,JDBC
,Mybatis
,Spring
,redis
,分散式
,劍指Offer
,LeetCode
等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。