美團分散式ID生成框架Leaf原始碼分析及優化改進
阿新 • • 發佈:2020-05-11
最近做了一個面試題解答的開源專案,大家可以看一看,如果對大家有幫助,希望大家幫忙給一個star,謝謝大家了!
《面試指北》專案地址:https://github.com/NotFound9/interviewGuide
![image.png](https://images.xiaozhuanlan.com/photo/2020/8b3ecb7421d51428aa780db13232543c.)
本文主要是對美團的分散式ID框架Leaf的原理進行介紹,針對Leaf原專案中的一些issue,對Leaf專案進行功能增強,問題修復及優化改進,改進後的專案地址在這裡:
Leaf專案改進計劃 https://github.com/NotFound9/Leaf
# Leaf原理分析
## Snowflake生成ID的模式
7849276-4d1955394baa3c6d.png
![](https://user-gold-cdn.xitu.io/2020/5/4/171dec925bdac001?w=792&h=307&f=png&s=53650)
snowflake演算法對於ID的位數是上圖這樣分配的:
1位的符號位+41位時間戳+10位workID+12位序列號
加起來一共是64個二進位制位,正好與Java中的long型別的位數一樣。
美團的Leaf框架對於snowflake演算法進行了一些位數調整,位數分配是這樣:
最大41位時間差+10位的workID+12位序列化
雖然看美團對Leaf的介紹文章裡面說
`Leaf-snowflake方案完全沿用snowflake方案的bit位設計,即是“1+41+10+12”的方式組裝ID號。`
其實看程式碼裡面是沒有專門設定符號位的,如果timestamp過大,導致時間差佔用42個二進位制位,時間差的第一位為1時,可能生成的id轉換為十進位制後會是負數:
```java
//timestampLeftShift是22,workerIdShift是12
long id = ((timestamp - twepoch) << timestampLeftShift) | (workerId << workerIdShift) | sequence;
```
#### 時間差是什麼?
因為時間戳是以1970年01月01日00時00分00秒作為起始點,其實我們一般取的時間戳其實是起始點到現在的時間差,如果我們能確定我們取的時間都是某個時間點以後的時間,那麼可以將時間戳的起始點改成這個時間點,Leaf專案中,如果不設定起始時間,預設是2010年11月4日09:42:54,這樣可以使得支援的最大時間增長,Leaf框架的支援最大時間是起始點之後的69年。
#### workID怎麼分配?
Leaf使用Zookeeper作為註冊中心,每次機器啟動時去Zookeeper特定路徑/forever/下讀取子節點列表,每個子節點儲存了IP:Port及對應的workId,遍歷子節點列表,如果存在當前IP:Port對應的workId,就使用節點資訊中儲存的workId,不存在就建立一個永久有序節點,將序號作為workId,並且將workId資訊寫入本地快取檔案workerID.properties,供啟動時連線Zookeeper失敗,讀取使用。因為workId只分配了10個二進位制位,所以取值範圍是0-1023。
#### 序列號怎麼生成?
序列號是12個二進位制位,取值範圍是0到4095,主要保證同一個leaf服務在同一毫秒內,生成的ID的唯一性。
序列號是生成流程如下:
1.當前時間戳與上一個ID的時間戳在同一毫秒內,那麼對sequence+1,如果sequence+1超過了4095,那麼進行等待,等到下一毫秒到了之後再生成ID。
2.當前時間戳與上一個ID的時間戳不在同一毫秒內,取一個100以內的隨機數作為序列號。
```java
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
//seq 為0的時候表示是下一毫秒時間開始對seq做隨機
sequence = RANDOM.nextInt(100);
timestamp = tilNextMillis(lastTimestamp);
}
} else {
//如果是新的ms開始
sequence = RANDOM.nextInt(100);
}
lastTimestamp = timestamp;
```
## segment生成ID的模式
5e4ff128.png
![](https://user-gold-cdn.xitu.io/2020/5/4/171dec9886f1191d?w=743&h=513&f=png&s=53559)
這種模式需要依賴MySQL,表字段biz_tag代表業務名,max_id代表該業務目前已分配的最大ID值,step代表每次Leaf往資料庫請求時,一次性分配的ID數量。
大致流程就是每個Leaf服務在記憶體中有兩個Segment例項,每個Segement儲存一個分段的ID,
一個Segment是當前用於分配ID,有一個value屬性儲存這個分段已分配的最大ID,以及一個max屬性這個分段最大的ID。
另外一個Segement是備用的,當一個Segement用完時,會進行切換,使用另一個Segement進行使用。
當一個Segement的分段ID使用率達到10%時,就會觸發另一個Segement去DB獲取分段ID,初始化好分段ID供之後使用。
```
Segment {
private AtomicLong value = new AtomicLong(0);
private volatile long max;
private volatile int step;
}
SegmentBuffer {
private String key;
private Segment[] segments; //雙buffer
private volatile int currentPos; //當前的使用的segment的index
private volatile boolean nextReady; //下一個segment是否處於可切換狀態
private volatile boolean initOk; //是否初始化完成
private final AtomicBoolean threadRunning; //執行緒是否在執行中
private final ReadWriteLock lock;
private volatile int step;
private volatile int minStep;
private volatile long updateTimestamp;
}
```
## Leaf專案改進
目前Leaf專案存在的問題是
Snowflake生成ID相關:
#### 1.註冊中心只支援Zookeeper
而對於一些小公司或者專案組,其他業務沒有使用到Zookeeper的話,為了部署Leaf服務而維護一個Zookeeper叢集的代價太大。所以原專案中有issue在問”怎麼支援非Zookeeper的註冊中心“,由於一般專案中使用MySQL的概率會大很多,所以增加了使用MySQL作為註冊中心,本地配置作為註冊中心的功能。
#### 2.潛在的時鐘回撥問題
由於啟動前,伺服器時間調到了以前的時間或者進行了回撥,連線Zookeeper失敗時會使用本地快取檔案workerID.properties中的workerId,而沒有校驗該ID生成的最大時間戳,可能會造成ID重複,對這個問題進行了修復。
#### 3.時間差過大時,生成id為負數
因為缺少對時間差的校驗,當時間差過大,轉換為二進位制數後超過41位後,在生成ID時會造成溢位,使得符號位為1,生成id為負數。
Segement生成ID相關:
沒有太多問題,主要是根據一些issue對程式碼進行了效能優化。
### 具體改進如下:
### Snowflake生成ID相關的改進:
#### 1.針對Leaf原專案中的[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84),增加zk_recycle模式(註冊中心為zk,workId迴圈使用)
#### 2.針對Leaf原專案中的[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100),增加MySQL模式(註冊中心為MySQL)
#### 3.針對Leaf原專案中的[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100),增加Local模式(註冊中心為本地專案配置)
#### 4.針對Leaf原專案中的[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84),修復啟動時時鐘回撥的問題
#### 5.針對Leaf原專案中的[issue#106](https://github.com/Meituan-Dianping/Leaf/issues/106),修復時間差過大,超過41位溢位,導致生成的id負數的問題
### Segement生成ID相關的改進:
#### 1.針對Leaf原專案中的[issue#68](https://github.com/Meituan-Dianping/Leaf/issues/68),優化SegmentIDGenImpl.updateCacheFromDb()方法。
#### 2.針對Leaf原專案中的 [issue#88](https://github.com/Meituan-Dianping/Leaf/issues/88),使用位運算&替換取模運算
## snowflake演算法生成ID的相關改進
Leaf專案原來的註冊中心的模式(我們暫時命令為zk_normal模式)
使用Zookeeper作為註冊中心,每次機器啟動時去Zookeeper特定路徑下讀取子節點列表,如果存在當前IP:Port對應的workId,就使用節點資訊中儲存的workId,不存在就建立一個永久有序節點,將序號作為workId,並且將workId資訊寫入本地快取檔案workerID.properties,供啟動時連線Zookeeper失敗,讀取使用。
### 1.針對Leaf原專案中的[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84),增加zk_recycle模式(註冊中心為zk,workId迴圈使用)
#### 問題詳情:
[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84):workid是否支援回收?
SnowflakeService模式中,workid是否支援回收?分散式環境下,每次重新部署可能就換了一個ip,如果沒有回收的話1024個機器標識很快就會消耗完,為什麼zk不用臨時節點去儲存呢,這樣能動態感知服務上下線,對workid進行管理回收?
#### 解決方案:
開發了zk_recycle模式,針對使用snowflake生成分散式ID的技術方案,原本是使用Zookeeper作為註冊中心為每個服務根據IP:Port分配一個固定的workId,workId生成範圍為0到1023,workId不支援回收,所以在Leaf的原專案中有人提出了一個issue[#84 workid是否支援回收?](https://github.com/Meituan-Dianping/Leaf/issues/84),因為當部署Leaf的服務的IP和Port不固定時,如果workId不支援回收,當workId超過最大值時,會導致生成的分散式ID的重複。所以增加了workId迴圈使用的模式zk_recycle。
#### 如何使用zk_recycle模式?
在Leaf/leaf-server/src/main/resources/leaf.properties中新增以下配置
```
//開啟snowflake服務
leaf.snowflake.enable=true
//leaf服務的埠,用於生成workId
leaf.snowflake.port=
//將snowflake模式設定為zk_recycle,此時註冊中心為Zookeeper,並且workerId可複用
leaf.snowflake.mode=zk_recycle
//zookeeper的地址
leaf.snowflake.zk.address=localhost:2181
```
啟動LeafServerApplication,呼叫/api/snowflake/get/test就可以獲得此種模式下生成的分散式ID。
```
curl domain/api/snowflake/get/test
1256557484213448722
```
#### zk_recycle模式實現原理
按照上面的配置在leaf.properties裡面進行配置後,
```java
if(mode.equals(SnowflakeMode.ZK_RECYCLE)) {//註冊中心為zk,對ip:port分配的workId是課迴圈利用的模式
String zkAddress = properties.getProperty(Constants.LEAF_SNOWFLAKE_ZK_ADDRESS);
RecyclableZookeeperHolder holder = new RecyclableZookeeperHolder(Utils.getIp(),port,zkAddress);
idGen = new SnowflakeIDGenImpl(holder);
if (idGen.init()) {
logger.info("Snowflake Service Init Successfully in mode " + mode);
} else {
throw new InitException("Snowflake Service Init Fail");
}
}
```
此時SnowflakeIDGenImpl使用的holder是RecyclableZookeeperHolder的例項,workId是可迴圈利用的,RecyclableZookeeperHolder工作流程如下:
1.首先會在未使用的workId池(zookeeper路徑為/snowflake/leaf.name/recycle/notuse/)中生成所有workId。
2.然後每次伺服器啟動時都是去未使用的workId池取一個新的workId,然後放到正在使用的workId池(zookeeper路徑為/snowflake/leaf.name/recycle/inuse/)下,將此workId用於Id生成,並且定時上報時間戳,更新zookeeper中的節點資訊。
3.並且定時檢測正在使用的workId池,發現某個workId超過最大時間沒有更新時間戳的workId,會把它從正在使用的workId池移出,然後放到未使用的workId池中,以供workId迴圈使用。
4.並且正在使用這個很長時間沒有更新時間戳的workId的伺服器,在發現自己超過最大時間,還沒有上報時間戳成功後,會停止id生成服務,以防workId被其他伺服器迴圈使用,導致id重複。
### 2.針對Leaf原專案中的[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100),增加MySQL模式(註冊中心為MySQL)
#### 問題詳情:
[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100):如何使用非zk的註冊中心?
#### 解決方案:
開發了mysql模式,這種模式註冊中心為MySQL,針對每個ip:port的workid是固定的。
#### 如何使用這種mysql模式?
需要先在資料庫執行專案中的leaf_workerid_alloc.sql,完成建表,然後在Leaf/leaf-server/src/main/resources/leaf.properties中新增以下配置
```
//開啟snowflake服務
leaf.snowflake.enable=true
//leaf服務的埠,用於生成workId
leaf.snowflake.port=
//將snowflake模式設定為mysql,此時註冊中心為Zookeeper,workerId為固定分配
leaf.snowflake.mode=mysql
//mysql資料庫地址
leaf.jdbc.url=
leaf.jdbc.username=
leaf.jdbc.password=
```
啟動LeafServerApplication,呼叫/api/snowflake/get/test就可以獲得此種模式下生成的分散式ID。
```java
curl domain/api/snowflake/get/test
1256557484213448722
```
#### 實現原理
使用上面的配置後,此時SnowflakeIDGenImpl使用的holder是SnowflakeMySQLHolder的例項。實現原理與Leaf原專案預設的模式,使用Zookeeper作為註冊中心,每個ip:port的workid是固定的實現原理類似,只是註冊,獲取workid,及更新時間戳是與MySQL進行互動,而不是Zookeeper。
```java
if (mode.equals(SnowflakeMode.MYSQL)) {//註冊中心為mysql
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(properties.getProperty(Constants.LEAF_JDBC_URL));
dataSource.setUsername(properties.getProperty(Constants.LEAF_JDBC_USERNAME));
dataSource.setPassword(properties.getProperty(Constants.LEAF_JDBC_PASSWORD));
dataSource.init();
// Config Dao
WorkerIdAllocDao dao = new WorkerIdAllocDaoImpl(dataSource);
SnowflakeMySQLHolder holder = new SnowflakeMySQLHolder(Utils.getIp(), port, dao);
idGen = new SnowflakeIDGenImpl(holder);
if (idGen.init()) {
logger.info("Snowflake Service Init Successfully in mode " + mode);
} else {
throw new InitException("Snowflake Service Init Fail");
}
}
```
### 3.針對Leaf原專案中的[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100),增加Local模式(註冊中心為本地專案配置)
#### 問題詳情:
[issue#100](https://github.com/Meituan-Dianping/Leaf/issues/100):如何使用非zk的註冊中心?
#### 解決方案:
開發了local模式,這種模式就是適用於部署Leaf服務的IP和Port基本不會變化的情況,就是在Leaf專案中的配置檔案leaf.properties中顯式得配置某某IP:某某Port對應哪個workId,每次部署新機器時,將IP:Port的時候在專案中新增這個配置,然後啟動時專案會去讀取leaf.properties中的配置,讀取完寫入本地快取檔案workId.json,下次啟動時直接讀取workId.json,最大時間戳也每次同步到機器上的快取檔案workId.json中。
#### 如何使用這種local模式?
在Leaf/leaf-server/src/main/resources/leaf.properties中新增以下配置
```
//開啟snowflake服務
leaf.snowflake.enable=true
//leaf服務的埠,用於生成workId
leaf.snowflake.port=
#註冊中心為local的的模式
#leaf.snowflake.mode=local
#leaf.snowflake.local.workIdMap=
#workIdMap的格式是這樣的{"Leaf服務的ip:埠":"固定的workId"},例如:{"10.1.46.33:8080":1,"10.1.46.33:8081":2}
```
啟動LeafServerApplication,呼叫/api/snowflake/get/test就可以獲得此種模式下生成的分散式ID。
```java
curl domain/api/snowflake/get/test
1256557484213448722
```
#### 4.針對Leaf原專案中的[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84),修復啟動時時鐘回撥的問題
##### 問題詳情:
[issue#84](https://github.com/Meituan-Dianping/Leaf/issues/84):因為當使用預設的模式(我們暫時命令為zk_normal模式),註冊中心為Zookeeper,workId不可複用,上面介紹了這種模式的工作流程,當Leaf服務啟動時,連線Zookeeper失敗,那麼會去本機快取中讀取workerID.properties檔案,讀取workId進行使用,但是由於workerID.properties中只存了workId資訊,沒有儲存上次上報的最大時間戳,所以沒有進行時間戳判斷,所以如果機器的當前時間被修改到之前,就可能會導致生成的ID重複。
##### 解決方案:
所以增加了更新時間戳到本地快取的機制,每次在上報時間戳時將時間戳同時寫入本機快取workerID.properties,並且當使用本地快取workerID.properties中的workId時,對時間戳進行校驗,當前系統時間戳<快取中的時間戳時,才使用這個workerId。
```java
//連線失敗,使用本地workerID.properties中的workerID,並且對時間戳進行校驗。
try {
Properties properties = new Properties();
properties.load(new FileInputStream(new File(PROP_PATH.replace("{port}", port + ""))));
Long maxTimestamp = Long.valueOf(properties.getProperty("maxTimestamp"));
if (maxTimestamp!=null && System.currentTimeM