1. 程式人生 > >美團分散式ID生成框架Leaf原始碼分析及優化改進

美團分散式ID生成框架Leaf原始碼分析及優化改進

最近做了一個面試題解答的開源專案,大家可以看一看,如果對大家有幫助,希望大家幫忙給一個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