十分鐘了解分布式系統中生成唯一ID
分布式系統中生成唯一ID在后臺開發是經常遇到的架構設計,當然方案有很多,比如通過redis或者數據庫實現自增。但是如果依賴redis或者數據庫,會導致單點問題,在架構上反而需要考慮點更多,那怎么解決呢?
首先分布式唯一ID需要支持如下:
- 全局唯一:必須保證生成的ID是全局性唯一的;
- 有序性:生成的ID是有序,方便追溯和排序操作;
- 可用性:需要保證高并發下的可用性,除了對ID號碼自身的要求,業務還對ID生成系統的可用性要求極高;
- 自主性:分布式環境下不依賴中心認證即可自行生成ID;
- 安全性:不暴露系統和業務的信息;
方案:
- 單機生成方式
- 第三方模塊生成方式
- 號段生成方式
單機生成方式
1、UUID
UUID(Universally Unique Identifier,即通用唯一標識碼)算法的目的是生成某種形式的全局唯一ID來標識系統中的任一元素,尤其是在分布式環境下,UUID可以不依賴中心認證即可自動生成全局唯一ID。UUID的標準形式為32個十六進制數組成的字符串,且分割為五個部分,例如:執行:cat /proc/sys/kernel/random/uuid,輸出:70048d49-6ef3-4ba6-84c4-1e6e37ec2f4a。
缺點:
- 生成是隨機的,無法做到順序生成;
- 性能雖然高,但是輸出的格式不一定符合業務要求,無法比較大小;
2、Snowflake
snowflake(雪花算法)是一個開源的分布式ID生成算法,結果是一個long型的ID。snowflake算法將64bit劃分為多段,分開來標識機器、時間等信息,其中格式如下:
0 |00000...0000|000...0000|000000000000|
1bit| 41bit時間戳 |10bit機器號|12bit序列遞增|
- 1bit保留位:方便擴展;
- 41bit時間戳:可以標識毫秒時間戳(最長支持69年),結合遞增bit使用,可以保證有序,不過我覺得如果qps沒有超過4000,使用秒時間戳也可以;
- 10bit機器號:可以支持1024個機器ID,用于標識不同的機器;
- 12bit序列遞增:支持4096個序列遞增,可以支持同一臺機器同一毫秒內生成4096個ID;
snowflake算法優勢是支持遞增,可以根據自己的算法改造使用bit位,不過存在如下缺點:
- 強依賴時間同步,如果某臺機器的時鐘出現回撥,遞增就不準確;
- ID不能完全支持全局遞增,需要依賴定義的機器號;
第三方模塊生成方式
通過mysql,redis,zk或者ticket server實現架構如下:
架構
1、Mysql
前面提到依賴mysql也可以實現序列號,mysql的auto_increment可以保證全局唯一,不過需要依賴數據庫,性能上會有影響。
當然提升性能的方式就是將mysql設置主從模式,但是只是為了序列號生成,部署多個mysql實例確實有些浪費。
2、Redis
redis同樣可以實現遞增,而且可以保證原子,比如通過incr或者incrby,雖然性能比mysql要好很多,我測試下來4c8g情況下可以支持10W+qps,不過存在單點維護問題。
3、Zookeeper
利用zookeeper的znode也可以生成序列號,可以生成32位和64位的數據版本號,客戶端可以使用這個版本號來作為唯一的序列號,不過zk的性能比較差,在高并發場景下基本不建議采用。
4、Ticket Server
Ticket Server類似單獨的票據服務,可以通過自己的邏輯生成唯一序列號,比如實現上可以使用原子遞增,或者根據各個業務的特性進行適配。
不過要實現完整的容災體系下可持久的服務工作量是不小的,對于沒有太多特殊需求的場景,更建議依賴redis或者mysql。
號段生成方式
1、大廠方案:美團Leaf-segment和Leaf-snowflake方案
1.1 Leaf-segment
具體技術介紹:https://tech.meituan.com/2017/04/21/mt-leaf.html。Leaf-segment主要解決思路是:對直接用數據庫自增ID充當分布式ID的一種優化,減少對數據庫的訪問頻率,每次獲取不是獲取一個ID,而是獲取一個號段,同時獲取號段以后,將數據持久化到數據庫中,這樣可以解決分布式的搶占或者持久化問題,即使DB出現問題,也可以通過Master-Slave來解決。
Leaf-segment架構
1.2 Leaf-snowflake
Leaf-snowflake繼續使用snowflake方案,主要解決了時鐘不同步的問題,其中中間10bit機器號定義為WorkerID,Leaf-snowflake是按照下面幾個步驟啟動的:
- 啟動Leaf-snowflake服務,連接Zookeeper,在leaf_forever父節點下檢查自己是否已經注冊過(是否有該順序子節點);
- 如果有注冊過直接取回自己的workerID(zk順序節點生成的int類型ID號),啟動服務;
- 如果沒有注冊過,就在該父節點下面創建一個持久順序節點,創建成功后取回順序號當做自己的workerID號,啟動服務;
- 檢查服務啟動是否寫過ZooKeeper leaf_forever節點,并進行如下處理:
若寫過,則用自身系統時間與leaf_forever/節點記錄時間做比較,若小于{self}時間則認為機器時間發生了大步長回撥,服務啟動失敗并報警;
若未寫過,證明是新服務節點,直接創建持久節點leaf_forever/${self}并寫入自身系統時間,接下來綜合對比其余Leaf節點的系統時間來判斷自身系統時間是否準確,具體做法是取leaf_temporary下的所有臨時節點(所有運行中的Leaf-snowflake節點)的服務IP:Port,然后通過RPC請求得到所有節點的系統時間,計算sum(time)/nodeSize;
若abs( 系統時間-sum(time)/nodeSize ) < 閾值,認為當前系統時間準確,正常啟動服務,同時寫臨時節點leaf_temporary/${self} 維持租約;
否則認為本機系統時間發生大步長偏移,啟動失敗并報警;
每隔一段時間(3s)上報自身系統時間寫入leaf_forever/${self};
Leaf-snowflake架構
2、大廠方案:滴滴Tinyid和百度UidGenerator
2.1 滴滴Tinyid
開源方案:https://github.com/didi/tinyid。Tinyid和美團的Leaf-segment方案類似,從數據庫批量的獲取自增ID,每次從數據庫取出一個號段范圍,例如:(1,1000]代表1000個ID,業務服務將號段在本地生成1~1000的自增ID并加載到內存。Tinyid會將可用號段加載到內存中,并在內存中生成ID,可用號段在首次獲取ID時加載,如當前號段使用達到一定比例時,系統會異步的去加載下一個可用號段,以此保證內存中始終有可用號段,以便在發號服務宕機后一段時間內還有可用ID。
Tinyid架構
2.2 百度UidGenerator
百度UidGenerator是基于snowflake方案改造,旨在解決時鐘回撥,workerid不夠等問題。
百度UidGenerator
- 1bit保留位:方便擴展;
- 28bit時間戳:指當前時間與epoch時間的時間差,單位為秒,比如2024-01-01 00:00:00上線,那時間就是當前時間戳-1704038400;
- 22bit節點id:22bit可以支持4194304臺機器,同時這個數據是持久化到DB,保持每次新增或者重啟都會自增;
- 13bit序列遞增:支持每秒生成8192個自增序列號(未調整boostPower情況下);
- UidGenerator優化點還包括:
RingBuffer:UidGenerator不再在每次取ID時都實時計算分布式ID,而是利用RingBuffer數據結構預先生成若干個分布式ID并保存,引入boostPower可以控制每秒生成ID的上限能力;
時間遞增:UidGenerator的時間類型是AtomicLong,且通過incrementAndGet()方法獲取下一次的時間,從而脫離了對服務器時間的依賴,也就不會有時鐘回撥的問題;
3、大廠方案:容災
微信內部也是通過獨立的seqsvr提供序列號生成,主要面對場景是微信中的消息版本,其中特性和挑戰如下。
(1)兩個特性:
- 遞增的32位整型;
- 每個用戶都有自己的32位sequence空間;
(2)面臨兩個挑戰:
- 1、分布式場景;
- 2、每秒千萬級別的QPS;
- 3、每個Uin都需要存儲max-seqid,存儲量大,也帶來容災問題;
如何解決分布式場景下的問題?
提供兩層:StoreSvr和AllocSvr,分別是存儲層和緩存中間層,分層后就能利用堆機器就可以解決問題;
圖片
每秒千萬級別的QPS?
實現方案和美團Leaf-segment類似,每次提供一批seqid,這樣從千萬級別的qps就變成千級別的qps,不過不保證序列號是連續的,但是能保證是遞增的。
每個Uin都需要存儲max-seqid,存儲量大?
每個用戶需要加載一個max_seq(32bit),如果uin是2^32個,則需要存儲數據大小為16GB,這樣系統啟動時候加載就會很慢,微信如何解決?通過區分Set,同一批共享同一個max_seqid,這樣就減少加載的數據量。
容災如何實現?
seqsvr服務雖然簡單,解決了上述高性能的問題,但是要保證高可靠性還是非常難,我查了一下內部資料和infoQ一樣,實現架構可以參考:https://www.infoq.cn/article/wechat-serial-number-generator-architecture。seqsvr最核心的點是什么呢?每個 uin 的sequence申請要遞增不回退,但是約束條件是:任意時刻任意 uin 有且僅有一臺 AllocSvr 提供服務,就可以比較容易地實現sequence遞增不回退的要求。
(1)容災1.0
容災1.0
- 一主一備:每個Set都是一主一備兩臺 AllocSvr ,主機出故障時,仲裁服務切換主備,原來的主機下線變成備機,原備機變成主機后加載 uid 號段提供服務;
- 引入仲裁服務:探測 AllocSvr 的狀態,決定每個 uin 走到哪一臺 AllocSvr ,同時解決備機切換的問題;
但是上述面臨問題:
- 主從容災擴縮容麻煩;
- 單個Set的資源不一定能支撐高并發請求;
(2)容災2.0
通過提供 Client 路由表方式解決訪問 AllocSvr 切換的問題,執行步驟如下:
- Client 根據本地共享內存緩存的路由表,選擇對應的 AllocSvr,如果路由表不存在,隨機選擇一臺 AllocSvr;
- 對選中的 AllocSvr 發起請求,請求帶上本地路由表的版本號;
- AllocSvr 收到請求,除了處理 sequence 邏輯外,判斷 Client 帶上版本號是否最新,如果是舊版則在響應包中附上最新的路由表;
- Client 收到響應包,除了處理 sequence 邏輯外,判斷響應包是否帶有新路由表,如果有,更新本地路由表,并決策是否返回第 1 步重試;
容災2.0
總結
以上就是一些場景下生成分布式唯一ID的方案選擇,分布式唯一ID的架構雖然簡單,但是如果要實現高性能高可用,還是需要根據業務場景來考慮。所以說簡單的事情要做好并非易事,但是在這些年的工作中總是會有很多人為了追求效率,總想找到捷徑而放棄架構的基本演進路徑方法論....
參考
(1)https://learning-guide.gitbook.io/system-design-interview/chapter-07-design-a-unique-id-generator-in-distributed-systems(2)https://tech.meituan.com/2017/04/21/mt-leaf.html(3)https://www.infoq.cn/article/wechat-serial-number-generator-architecture