冷飯新炒:理解Sonwflake演算法的實現原理
Snowflake
(雪花)是Twitter
開源的高效能ID
生成演算法(服務)。
上圖是Snowflake
的Github
倉庫,master
分支中的REAEMDE
檔案中提示:初始版本於2010
年釋出,基於Apache Thrift
,早於Finagle
(這裡的Finagle
是Twitter
上用於RPC
服務的構建模組)釋出,而Twitter
內部使用的Snowflake
是一個完全重寫的程式,在很大程度上依靠Twitter
上的現有基礎架構來執行。
而2010
年釋出的初版Snowflake
原始碼是使用Scala
語言編寫的,歸檔於scala_28
分支。換言之,「大家目前使用的Snowflake
演算法原版或者改良版已經是十年前(當前是2020
scala_28
分支中有介紹該演算法的動機和要求,這裡簡單摘錄一下:
「動機:」
Cassandra
中沒有生成順序ID
的工具,Twitter
由使用MySQL
轉向使用Cassandra
的時候需要一種新的方式來生成ID
(印證了架構不是設計出來,而是基於業務場景迭代出來)。
「要求:」
- 高效能:每秒每個程序至少產生
10K
個ID
,加上網路延遲響應速度要在2ms
內。 - 順序性:具備按照時間的自增趨勢,可以直接排序。
- 緊湊性:保持生成的
ID
的長度在64 bit
或更短。 - 高可用:
ID
生成方案需要和儲存服務一樣高可用。
下面就Snowflake
的原始碼分析一下他的實現原理。
Snowflake方案簡述
Snowflake
在初版設計方案是:
- 時間:
41 bit
長度,使用毫秒級別精度,帶有一個自定義epoch
,那麼可以使用大概69
年。 - 可配置的機器
ID
:10 bit
長度,可以滿足1024
個機器使用。 - 序列號:
12 bit
長度,可以在4096
個數字中隨機取值,從而避免單個機器在1 ms
內生成重複的序列號。
但是在實際原始碼實現中,Snowflake
把10 bit
的可配置的機器ID
拆分為5 bit
的Worker ID
(這個可以理解為原來的機器ID
)和5 bit
的Data Center ID
(資料中心ID
),詳情見IdWorker.scala
:
也就是說,支援配置最多32
ID
和最多32
個數據中心ID
:
由於演算法是Scala
語言編寫,是依賴於JVM
的語言,返回的ID
值為Long
型別,也就是64 bit
的整數,原來的演算法生成序列中只使用了63 bit
的長度,要返回的是無符號數,所以在高位補一個0
(佔用1 bit
),那麼加起來整個ID
的長度就是64 bit
:
其中:
41 bit
毫秒級別時間戳的取值範圍是:[0, 2^41 - 1]
=>0 ~ 2199023255551
,一共2199023255552
個數字。5 bit
機器ID
的取值範圍是:[0, 2^5 - 1]
=>0 ~ 31
,一共32
個數字。5 bit
資料中心ID
的取值範圍是:[0, 2^5 - 1]
=>0 ~ 31
,一共32
個數字。12 bit
序列號的取值範圍是:[0, 2^12 - 1]
=>0 ~ 4095
,一共4096
個數字。
那麼理論上可以生成2199023255552 * 32 * 32 * 4096
個完全不同的ID
值。
Snowflake
演算法還有一個明顯的特徵:「依賴於系統時鐘」。41 bit
長度毫秒級別的時間來源於系統時間戳,所以必須保證系統時間是向前遞進,不能發生「時鐘回撥」(通說來說就是不能在同一個時刻產生多個相同的時間戳或者產生了過去的時間戳)。一旦發生時鐘回撥,Snowflake
會拒絕生成下一個ID
。
位運算知識補充
Snowflake
演算法中使用了大量的位運算。由於整數的補碼才是在計算機中的儲存形式,Java
或者Scala
中的整型都使用補碼錶示,這裡稍微提一下原碼和補碼的知識。
- 原碼用於閱讀,補碼用於計算。
- 正數的補碼與其原碼相同。
- 負數的補碼是除最高位其他所有位取反,然後加
1
(反碼加1
),而負數的補碼還原為原碼也是使用這個方式。 +0
的原碼是0000 0000
,而-0
的原碼是1000 0000
,補碼只有一個0
值,用0000 0000
表示,這一點很重要,補碼的0
沒有二義性。
簡單來看就是這樣:
*[+11]原碼=[00001011]補碼=[00001011]
*[-11]原碼=[10001011]補碼=[11110101]
*[-11]的補碼計算過程:
原碼10001011
除了最高位其他位取反11110100
加111110101(補碼)
使用原碼、反碼在計算的時候得到的不一定是準確的值,而使用補碼的時候計算結果才是正確的,記住這個結論即可,這裡不在舉例。由於Snowflake
的ID
生成方案中,除了最高位,其他四個部分都是無符號整數,所以四個部分的整數「使用補碼進行位運算的效率會比較高,也只有這樣才能滿足Snowflake高效能設計的初衷」。Snowflake
演算法中使用了幾種位運算:異或(^
)、按位與(&
)、按位或(|
)和帶符號左移(<<
)。
異或
異或的運算規則是:0^0=0
0^1=1
1^0=1
1^1=0
,也就是位不同則結果為1,位相同則結果為0。主要作用是:
- 特定位翻轉,也就是一個數和
N
個位都為1
的數進行異或操作,這對應的N
個位都會翻轉,例如0100 & 1111
,結果就是1011
。 - 與
0
項異或,則結果和原來的值一致。 - 兩數的值互動:
a=a^b
b=b^a
a=a^b
,這三個操作完成之後,a
和b
的值完成交換。
這裡推演一下最後一條:
*[+11]原碼=[00001011]補碼=[00001011]a
*[-11]原碼=[10001011]補碼=[11110101]b
a=a^b00001011
11110101
---------^
11111110
b=b^a11110101
---------^
00001011(十進位制數:11)b
a=a^b11111110
---------^
11110101(十進位制數:-11)a
按位與
按位與的運算規則是:0^0=0
0^1=0
1^0=0
1^1=1
,只有對應的位都為1的時候計算結果才是1,其他情況的計算結果都是0。主要作用是:
- 清零,如果想把一個數清零,那麼和所有位為
0
的數進行按位與即可。 - 取一個數中的指定位,例如要取
X
中的低4
位,只需要和zzzz...1111
進行按位與即可,例如取1111 0110
的低4
位,則11110110 & 00001111
即可得到00000110
。
按位或
按位與的運算規則是:0^0=0
0^1=1
1^0=1
1^1=1
,只要有其中一個位存在1則計算結果是1,只有兩個位同時為0的情況下計算結果才是0。主要作用是:
- 對一個數的部分位賦值為
1
,只需要和對應位全為0
的數做按位或操作就行,例如1011 0000
如果低4
位想全部賦值為1
,那麼10110000 | 00001111
即可得到1011 1111
。
帶符號左移
帶符號左移的運算子是<<
,一般格式是:M << n
。作用如下:
M
的二進位制數(補碼)向左移動n
位。- 左邊(高位)移出部分直接捨棄,右邊(低位)移入部分全部補
0
。 - 移位結果:相當於
M
的值乘以2
的n
次方,並且0、正、負數通用。 - 移動的位數超過了該型別的最大位數,那麼編譯器會對移動的位數取模,例如
int
移位33
位,實際上只移動了33 % 2 = 1
位。
推演過程如下(假設n = 2
):
*[+11]原碼=[00001011]補碼=[00001011]
*[-11]原碼=[10001011]補碼=[11110101]
*[+11<<2]的計算過程
補碼00001011
左移2位00001011
舍高補低00101100
十進位制數2^2+2^3+2^5=44
*[-11<<2]的計算過程
補碼11110101
左移2位11110101
舍高補低11010100
原碼10101100(補碼除最高位其他所有位取反再加1)
十進位制數-(2^2+2^3+2^5)=-44
可以寫個main
方法驗證一下:
publicstaticvoidmain(String[]args){
System.out.println(-11<<2);//-44
System.out.println(11<<2);//44
}
組合技巧
利用上面提到的三個位運算子,相互組合可以實現一些高效的計算方案。
「計算n個bit能表示的最大數值:」
Snowflake
演算法中有這樣的程式碼:
//機器ID的位長度
privatevalworkerIdBits=5L;
//最大機器ID->31
privatevalmaxWorkerId=-1L^(-1L<<workerIdBits);
這裡的運算元是-1L ^ (-1L << 5L)
,整理運算子的順序,再使用64 bit
的二進位制數推演計算過程如下:
*[-1]的補碼1111111111111111111111111111111111111111111111111111111111111111
左移5位1111111111111111111111111111111111111111111111111111111111100000
[-1]的補碼1111111111111111111111111111111111111111111111111111111111111111
異或-----------------------------------------------------------------------^
結果的補碼0000000000000000000000000000000000000000000000000000000000011111(十進位制數2^0+2^1+2^2+2^3+2^4=31)
這樣就能計算出5 bit
能表示的最大數值n
,n
為整數並且0 <= n <= 31
,即0、1、2、3...31
。Worker ID
和Data Center ID
部分的最大值就是使用這種組合運算得出的。
「用固定位的最大值作為Mask避免溢位:」
Snowflake
演算法中有這樣的程式碼:
varsequence=0L
......
privatevalsequenceBits=12L
//這裡得到的是sequence的最大值4095
privatevalsequenceMask=-1L^(-1L<<sequenceBits)
......
sequence=(sequence+1)&sequenceMask
最後這個運算元其實就是sequence = (sequence + 1) & 4095
,假設sequence
當前值為4095
,推演一下計算過程:
*[4095]的補碼0000000000000000000000000000000000000000000000000000011111111111
[sequence+1]的補碼0000000000000000000000000000000000000000000000000000100000000000
按位與-----------------------------------------------------------------------&
計算結果0000000000000000000000000000000000000000000000000000000000000000(十進位制數:0)
可以編寫一個main
方法驗證一下:
publicstaticvoidmain(String[]args){
intmask=4095;
System.out.println(0&mask);//0
System.out.println(1&mask);//1
System.out.println(2&mask);//2
System.out.println(4095&mask);//4095
System.out.println(4096&mask);//0
System.out.println(4097&mask);//1
}
也就是x = (x + 1) & (-1L ^ (-1L << N))
能保證最終得到的x
值不會超過N
,這是利用了按位與中的"取指定位"的特性。
Snowflake演算法實現原始碼分析
Snowflake
雖然用Scala
語言編寫,語法其實和Java
差不多,當成Java
程式碼這樣閱讀就行,下面閱讀程式碼的時候會跳過一些日誌記錄和度量統計的邏輯。先看IdWorker.scala
的屬性值:
//定義基準紀元值,這個值是北京時間2010-11-0409:42:54,估計就是2010年初版提交程式碼時候定義的一個時間戳
valtwepoch=1288834974657L
//初始化序列號為0
varsequence=0L//TODOafter2.8makethisaconstructorparamwithadefaultof0
//機器ID的最大位長度為5
privatevalworkerIdBits=5L
//資料中心ID的最大位長度為5
privatevaldatacenterIdBits=5L
//最大的機器ID值,十進位制數為為31
privatevalmaxWorkerId=-1L^(-1L<<workerIdBits)
//最大的資料中心ID值,十進位制數為為31
privatevalmaxDatacenterId=-1L^(-1L<<datacenterIdBits)
//序列號的最大位長度為12
privatevalsequenceBits=12L
//機器ID需要左移的位數12
privatevalworkerIdShift=sequenceBits
//資料中心ID需要左移的位數=12+5
privatevaldatacenterIdShift=sequenceBits+workerIdBits
//時間戳需要左移的位數=12+5+5
privatevaltimestampLeftShift=sequenceBits+workerIdBits+datacenterIdBits
//序列號的掩碼,十進位制數為4095
privatevalsequenceMask=-1L^(-1L<<sequenceBits)
//初始化上一個時間戳快照值為-1
privatevarlastTimestamp=-1L
//下面的程式碼塊為引數校驗和初始化日誌列印,這裡不做分析
if(workerId>maxWorkerId||workerId<0){
exceptionCounter.incr(1)
thrownewIllegalArgumentException("workerIdcan'tbegreaterthan%dorlessthan0".format(maxWorkerId))
}
if(datacenterId>maxDatacenterId||datacenterId<0){
exceptionCounter.incr(1)
thrownewIllegalArgumentException("datacenterIdcan'tbegreaterthan%dorlessthan0".format(maxDatacenterId))
}
log.info("workerstarting.timestampleftshift%d,datacenteridbits%d,workeridbits%d,sequencebits%d,workerid%d",
timestampLeftShift,datacenterIdBits,workerIdBits,sequenceBits,workerId)
接著看演算法的核心程式碼邏輯:
//同步方法,其實就是protectedsynchronizedlongnextId(){......}
protected[snowflake]defnextId():Long=synchronized{
//獲取系統時間戳(毫秒)
vartimestamp=timeGen()
//高併發場景,同一毫秒內生成多個ID
if(lastTimestamp==timestamp){
//確保sequence+1之後不會溢位,最大值為4095,其實也就是保證1毫秒內最多生成4096個ID值
sequence=(sequence+1)&sequenceMask
//如果sequence溢位則變為0,說明1毫秒內併發生成的ID數量超過了4096個,這個時候同1毫秒的第4097個生成的ID必須等待下一毫秒
if(sequence==0){
//死迴圈等待下一個毫秒值,直到比lastTimestamp大
timestamp=tilNextMillis(lastTimestamp)
}
}else{
//低併發場景,不同毫秒中生成ID
//不同毫秒的情況下,由於外層方法保證了timestamp大於或者小於lastTimestamp,而小於的情況是發生了時鐘回撥,下面會丟擲異常,所以不用考慮
//也就是隻需要考慮一種情況:timestamp>lastTimestamp,也就是當前生成的ID所在的毫秒數比上一個ID大
//所以如果時間戳部分增大,可以確定整數值一定變大,所以序列號其實可以不用計算,這裡直接賦值為0
sequence=0
}
//獲取到的時間戳比上一個儲存的時間戳小,說明時鐘回撥,這種情況下直接丟擲異常,拒絕生成ID
//個人認為,這個方法應該可以提前到vartimestamp=timeGen()這段程式碼之後
if(timestamp<lastTimestamp){
exceptionCounter.incr(1)
log.error("clockismovingbackwards.Rejectingrequestsuntil%d.",lastTimestamp);
thrownewInvalidSystemClock("Clockmovedbackwards.Refusingtogenerateidfor%dmilliseconds".format(lastTimestamp-timestamp));
}
//lastTimestamp儲存當前時間戳,作為方法下次被呼叫的上一個時間戳的快照
lastTimestamp=timestamp
//度量統計,生成的ID計數器加1
genCounter.incr()
//X=(系統時間戳-自定義的紀元值)然後左移22位
//Y=(資料中心ID左移17位)
//Z=(機器ID左移12位)
//最後ID=X|Y|Z|計算出來的序列號sequence
((timestamp-twepoch)<<timestampLeftShift)|
(datacenterId<<datacenterIdShift)|
(workerId<<workerIdShift)|
sequence
}
//輔助方法:獲取系統當前的時間戳(毫秒)
protecteddeftimeGen():Long=System.currentTimeMillis()
//輔助方法:獲取系統當前的時間戳(毫秒),用死迴圈保證比傳入的lastTimestamp大,也就是獲取下一個比lastTimestamp大的毫秒數
protecteddeftilNextMillis(lastTimestamp:Long):Long={
vartimestamp=timeGen()
while(timestamp<=lastTimestamp){
timestamp=timeGen()
}
timestamp
}
最後一段邏輯的位操作比較多,但是如果熟練使用位運算操作符,其實邏輯並不複雜,這裡可以畫個圖推演一下:
四個部分的整數完成左移之後,由於空缺的低位都會補充了0
,基於按位或的特性,所有低位只要存在1
,那麼對應的位就會填充為1
,由於四個部分的位不會越界分配,所以這裡的本質就是:「四個部分左移完畢後最終的數字進行加法計算」。
Snowflake演算法改良
Snowflake
演算法有幾個比較大的問題:
- 低併發場景會產生連續偶數,原因是低併發場景系統時鐘總是走到下一個毫秒值,導致序列號重置為
0
。 - 依賴系統時鐘,時鐘回撥會拒絕生成新的
ID
(直接丟擲異常)。 Woker ID
和Data Center ID
的管理比較麻煩,特別是同一個服務的不同叢集節點需要保證每個節點的Woker ID
和Data Center ID
組合唯一。
這三個問題美團開源的Leaf
提供瞭解決思路,下圖擷取自com.sankuai.inf.leaf.snowflake.SnowflakeIDGenImpl
:
對應的解決思路是(不進行深入的原始碼分析,有興趣可以閱讀以下Leaf
的原始碼):
- 序列號生成新增隨機源,會稍微減少同一個毫秒內能產生的最大
ID
數量。 - 時鐘回撥則進行一定期限的等待。
- 使用
Zookeeper
快取和管理Woker ID
和Data Center ID
。
Woker ID
和Data Center ID
的配置是極其重要的,對於同一個服務(例如支付服務)叢集的多個節點,必須配置不同的機器ID
和資料中心ID
或者同樣的資料中心ID
和不同的機器ID
(「簡單說就是確保Woker ID
和Data Center ID
的組合全域性唯一」),否則在高併發的場景下,在系統時鐘一致的情況下,很容易在多個節點產生相同的ID
值,所以一般的部署架構如下:
管理這兩個ID
的方式有很多種,或者像Leaf
這樣的開源框架引入分散式快取進行管理,再如筆者所在的創業小團隊生產服務比較少,直接把Woker ID
和Data Center ID
硬編碼在服務啟動指令碼中,然後把所有服務使用的Woker ID
和Data Center ID
統一登記在團隊內部知識庫中。
自實現簡化版Snowflake
如果完全不考慮效能的話,也不考慮時鐘回撥、序列號生成等等問題,其實可以把Snowflake
的位運算和異常處理部分全部去掉,使用Long.toBinaryString()
方法結合字串按照Snowflake
演算法思路拼接出64 bit
的二進位制數,再通過Long.parseLong()
方法轉化為Long
型別。編寫一個main
方法如下:
publicclassMain{
privatestaticfinalStringHIGH="0";
/**
*2020-08-0100:00:00
*/
privatestaticfinallongEPOCH=1596211200000L;
publicstaticvoidmain(String[]args){
longworkerId=1L;
longdataCenterId=1L;
longseq=4095;
StringtimestampString=leftPadding(Long.toBinaryString(System.currentTimeMillis()-EPOCH),41);
StringworkerIdString=leftPadding(Long.toBinaryString(workerId),5);
StringdataCenterIdString=leftPadding(Long.toBinaryString(dataCenterId),5);
StringseqString=leftPadding(Long.toBinaryString(seq),12);
Stringvalue=HIGH+timestampString+workerIdString+dataCenterIdString+seqString;
longnum=Long.parseLong(value,2);
System.out.println(num);//某個時刻輸出為3125927076831231
}
privatestaticStringleftPadding(Stringvalue,intmaxLength){
intdiff=maxLength-value.length();
StringBuilderbuilder=newStringBuilder();
for(inti=0;i<diff;i++){
builder.append("0");
}
builder.append(value);
returnbuilder.toString();
}
}
然後把程式碼規範一下,編寫出一個簡版Snowflake
演算法實現的工程化程式碼:
//主鍵生成器介面
publicinterfacePrimaryKeyGenerator{
longgenerate();
}
//簡易Snowflake實現
publicclassSimpleSnowflakeimplementsPrimaryKeyGenerator{
privatestaticfinalStringHIGH="0";
privatestaticfinallongMAX_WORKER_IDwww.hongtuupt.cn=31;
privatestaticfinallongMIN_WORKER_ID=0;
privatestaticfinallongMAX_DC_ID=31;
privatestaticfinallongMIN_DC_ID=0;
privatestaticfinallongMAX_SEQUENCE=4095;
/**
*機器ID
*/
privatefinallongworkerId;
/**
*資料中心ID
*/
privatefinalwww.laiyuefeng.com longdataCenterId;
/**
*基準紀元值
*/
privatefinallongepoch;
privatelongsequence=0L;
privatelonglastTimestamp=-1L;
publicSimpleSnowflake(longworkerId,longdataCenterId,longepoch){
this.workerId=workerId;
this.dataCenterId=dataCenterId;
this.epoch=epoch;
checkArgs();
}
privatevoidcheckArgs(){
if(!(MIN_WORKER_ID<=workerId&&workerId<=MAX_WORKER_ID)www.yachengyl.cn){
thrownewIllegalArgumentException("Workeridmustbein[0,31]");
}
if(!(MIN_DC_ID<=dataCenterId&&dataCenterId<=MAX_DC_ID)){
thrownewIllegalArgumentException("Datacenteridmustbein[0,31]");
}
}
@Override
publicsynchronizedlonggenerate(www.feihongyul.cn){
longtimestamp=System.currentTimeMillis();
//時鐘回撥
if(timestamp<lastTimestamp){
thrownewIllegalStateException("Clockmovedbackwards");
}
//同一毫秒內併發
if(lastTimestamp==timestamp){
sequence=sequence+1;
if(sequence==MAX_SEQUENCE){
timestamp=untilNextMillis(lastTimestamp);
sequence=0L;
}
}else{
//下一毫秒重置sequence為0
sequence=0L;
}
lastTimestamp=timestamp;
//41位時間戳字串,不夠位數左邊補"0"
StringtimestampString=leftPadding(Long.toBinaryString(timestamp-epoch),41);
//