如何在高並發的分布式系統中產生UUID


一、數據庫發號器

每一次都請求數據庫,通過數據庫的自增ID來獲取全局唯一ID
對於小系統來說,這是一個簡單有效的方案,不過也就不符合討論情形中的高並發的場景。
首先,數據庫自增ID需要鎖表
而且,UUID的生成強依賴於數據庫,每次獲取UUID都需要經過一次數據庫的調用,性能損耗很大。
其實,在這種大並發的場景中,數據庫的主鍵都不建議使用數據庫的自增ID。因為雖然這個簡單,但是如果隨便業務發展,需要對原有的數據進行重新分庫分表的時候,可能會產生主鍵沖突,這影響了系統的平滑擴容,容易埋下坑。

二、中間件產生UUID

常用的中間件,以redis和zookeeper為例,都有產生分布式唯一ID的方案,如redis的getAndIncrement,zookeeper的sequenceId。都是分布式UUID的解決方案。
而且redis和zookeeper中間件的性能都很強大,比數據庫要好。
缺點還是,UUID的生成強依賴於中間件,每次獲取UUID都需要一次遠程調用。
依賴遠程調用的缺陷,可以通過一次取批量的方式來解決,據說weibo就是這么做的,從redis中批量取一堆。
強依賴於中間件這件事,總感覺是一個不好的設計。雖然現在的中間件可靠性都比較好,甚至可以做到5個9以上,但是主業務流程強依賴於中間件,還是覺得有那么些不爽。比如強依賴數據庫這個是可以接受的,但是依賴於zookeeper或redis從設計上看不可取。

三、UUID

Universally Unique IDentifier(UUID),這是一個具有rfc標准的uuid,見RFC文檔

4.1.4 Timestamp 時間戳

timestamp是一個60位bit的值。
對於V1版本的UUID來說,這代表着UTC的時間,也就是從1582年的10月15日的00:00:00.00到現在為止經過的時間,單位是100 nanosecond,對於那些沒有可用UTC的系統來說,他們可以用本機時間代替UTC,只要他們在系統中始終保持着這種一致性。但是並不推薦這種做飯,因為本機時間和UTC時間只是一個時區位移的區別而已。
對於V3和V5版本的UUID來說,timestamp是一個60bit的值,由一個name來得到的。
對於V4版本的UUID來說,timestamp是一個60bit的隨機數,或者說偽隨機數。
timestamp中最重要的就是代表版本的那4位,位於time_hi_and_version字段中的第4到第7位,這是用來區分不同版本的,具體的內容參見4.1.3。

4.1.5. Clock Sequence 時鍾序列

對於V1版本的UUID來說,順序號是用來幫助避免當時鍾后退可能帶來的沖突,以及node id發生變化時可能引起的沖突。
比如,當系統由於斷電等原因導致時間倒退時,UUID生成器無法確保是否已經有比當前設置的系統時間更大的UUID已經被生成了,所以始終序列ID需要進行更新,如果知道之前的值的話,更新的操作只需對其進行+1即可,如果不知道的話,應該設置成一個隨機數。
同樣的,如果節點的id變化了的話,比如某一塊網卡從一台機器轉插到另一台,通過重置序列號的方式也能減少產生沖突的可能。如果之前的序列號是已知的話,那么只需要簡單地進行+1即可,當然,這種情況不大可能發生。
序列號的初始值必須是隨機的,這樣才可以減少與系統的相關性,這樣可以更好地保護UUID,防止系統間迅速地切換破壞UUID的唯一性。所以,序列號的初始值一定是要與node id無關的。

4.1.6. Node 節點

對於V1版本的UUID來說,node字段是由IEEE 802的MAC地址組成的,通常是本機的地址,對於那些有多個IEEE 802地址的機器來說,任選一個作為node字段即可。對於那些沒有IEEE地址的機器來說,可以用一個隨機數或者偽隨機數來代替。
對於V4版本的UUID來說,node字段是由隨機數或者偽隨機數構成。

4.4. Algorithms for Creating a UUID from Truly Random orPseudo-Random Numbers

v4版本的UUID設計就是通過隨機數或者是偽隨機數來生成UUID。

算法有以下規則:
1、最重要的兩位,第6位和第7位,clock_seq_hi_and_reserved,分別設置成0和1.
2、最重要的四位,第12位到第15位,time_hi_and_version,設置成4.1.3描述的內容,0100。
3、其他的位設置成隨機數或偽隨機數即可。

總結

從定義中了解了V1和V4這兩種比較有代表性的UUID生成規則,實際的生產應用中,V1好像並沒有嚴格的實現。而V4這種基本都是偽隨機數的做法,JDK的UUID就是這么干的。
這種完全隨機的做法,好處是不用再依賴了,但是可讀性較差,而且如果使用其作為主鍵的話,數據庫中的索引會經常需要進行改動。

四、SnowFlake

snowflake算法是twitter所使用的生成UUID的算法。為了滿足Twitter每秒上萬條消息的請求,每條消息都必須分配一條唯一的id,且這些id還需要根據時間基本有序。

如圖所示,這里第1位不可用,前41位表示時間,中間10位用來表示工作機器的id,后12位的序列號.
其中時間比較好理解,工作機器id則是機器標識,序列號是一個自增序列。有多少位表示在這一個單位時間內,此機器最多可以支持2^12個並發。在進入下一個時間單位后,序列號歸0。

當然,這些字段的排序和定義也不一定要完全與他一致。比如第一位也可以使用起來,workerid還可以分成其他。
要保證根據時間大致有序,所以高位用來保存時間的內容是不可避免了,由於很多操作系統本身只支持毫秒級的時間,所以時間單位使用毫秒級就已經足夠了。
這三個字段的長度分配分別與如下指標相關:系統設計可用時間、系統所包含的機器數量、系統設計的單機QPS。所以可以根據系統的實際情況,靈活進行調整。
worker id這個字段,為了不沖突,可以進行統一分配管理,也可以通過服務注冊等方式來進行動態管理。當然第一種分配管理這種把work id寫入到代碼或者配置中的方式顯然不可取,如果是小系統可以進行簡單粗暴地redis的getAndIncrement進行處理,反正位數多,不怕浪費。

參照代碼實現如下

sequenceMask = ~(-1L << sequenceBits);

public synchronized long nextId() {
	long currentTimeMillis = System.currentTimeMillis();
	if (currentTimeMillis < lastTimeMillis) {
		throw new RuntimeException(String.format("clock is moving backwards.
		Rejecting requests until %d.", lastTimeMillis));
	}

	if (currentTimeMillis == lastTimeMillis) {
		sequence = (sequence + 1) & sequenceMask;
		if (sequence == 0) {
			for (; currentTimeMillis <= lastTimeMillis; ) {
				currentTimeMillis = System.currentTimeMillis();
			}
		}
	} else {
		sequence = 0;
	}

	lastTimeMillis = currentTimeMillis;
	return ((currentTimeMillis - TWEPOCH) << timeLShift) |
		(dataCenterId << dataCenterLShift) |
		(workerId << workerLShift) |
		sequence;
}

五、實踐中的問題

workid

如何確定自己的workid一定就是唯一的呢?或者說,處於工作中的所有workid都是不一樣的
使用數據庫,沒辦法回收
使用zk臨時節點,容易出現多個相同的workid同時工作

時鍾錯亂

即使我們已經保證了workid是唯一的,但是時間也是影響id生成的因素之一,如果發生了機器重啟后,使用相同的workid,但是時間發生了回退的話,還是有可能會出現產生重復的id。

無狀態 ----> 有狀態

使用一個中心節點了管理workid的租期,租期包含workid的值,以及有效的時間。
使用者發現自己的租期快到的時候,有兩種選擇,直接關閉,或者選擇續租,如果續租成功,則繼續使用,等待下一次租期截止的到來。
如果沒有續租,則在租期到之前停止服務,除非再次獲取了租期,可以是不同的workid
這樣,中心節點就比較重要了,而且租期本身包含時間信息,所以也不擔心客戶節點時鍾倒退。
當然,中心節點的穩定性則比較重要。

總體設計 talk is cheap,code is here

1、worker_id是有限的資源,為了充分利用,使用了租期interval的概念,nterval包含了startTime和endTime,每台機器持有的都是worker_id的一個時間段。
2、在一個interval的endTime這個時刻過去后,表明此worker_id的這個租期已經失效了。新的租用請求,可以通過獲取新的interval來復用此worker_id,但是必須滿足新的this.startTime > prev.endTime
3、雖然client機器的時鍾不可靠,不能相信,但是相對時間還是准確的,所以我們就對snowflake中的timestamp這個參數進行一個adjust。
timestamp = client.system.currentTime() - client.rentTimestamp + interval.starTime
其中client.rentTimestamp是機器去發起租用的那個時刻的client.system.curentTime()
為什么這樣做,因為如果不是以這個時間為准的話,如果以workerId的申請者client的時間為准。則很有可能出現的情況是,復用了此workerId的另一台機器,時鍾比之前用過此workId的機器慢,從而導致timestamp重復,進而產生重復的UUID。

4、完整流程圖如下

租期不宜過長

想象一下,如果租期過長,則會導致這些id暫時都屬於不可用狀態,所以當機器重啟的時候,workerId必須使用新的,從而導致workId增速過快,而workerId的有限的資源。
極端情況下,遇到服務crash,需要不斷重啟的情況,則會耗盡workerId。最終因為workerId耗盡,導致服務啟動不起來,這是絕對不允許的。
后期會考慮在服務關閉的時候主動發起一個取消租期的請求,當然這個請求也和IP一樣,是盡力去取消,取消不了就算了。

Generator時鍾回退

如果機器在正常的運行中出現了回退,我們在內存中保存了lastTime,則檢查發現時間回退之后,在時鍾追上原有時間之前的那段時間會拒絕服務。
如果機器程序重啟后出現了回退,我們會在程序啟動的時候去重新申請id,如果之前使用的id租期還未結束,會使用新的id,這避免了重復id的產生。
如果之前使用的id的租期已經結束了,則由可能會出現復用原來workerId的情況,但是在我們這里的snowflake算法的時間戳中,我們並不是以本機的時間為准,而是租期的startTime + ( System.currentTime() - System.租用動作發起時間)。所以這也避免了重復id的產生。

Rent Server時鍾回退

如果中心節點只有一台,這台機器發生了時間回退。則有可能在client節點租用wokerid的時候會找不到可以租用的workerid,從導致client節點的uuid服務無法工作,但是也不會出現重復uuid的情況。正如前文所說的,server服務的可用性比較重要,不能使用單點進行部署。

如果server有多台機器,且時鍾不一致,且都在同時提供服務的情況下。
做一個最壞的假設,B機器比A機器快了一天。我們假定A機器的時間是正常的。
client_a通過A獲取了worker_id為1的租期,時間是從今天9點到10點,而client_b通過B去獲取worker_id的時候,B機器時間已經到了明天,所以它認為1這個worker_id是可用的,於是把1分配給了機器client_b。
這樣就出現兩台client機器共用同一個id的情況,只不過一個用的是今天的timestamp,另一個用的是明天的timestamp。
同樣的,這里不會產生重復的uuid,但是會破壞uuid的大致有序性。但是換一個思路,大致有序性就是靠機器時間來保證的。如果使用原有的做法,兩台不相干的機器,時間不同,worker_id也不同,也會破壞大致有序性。所以這里的破壞大致有序性並不是因為引入了rent server所導致的,而且保證rent server機器的時間一致,比保證多台機器同時有序簡單多了,所以說這個也是可以接受的。

但是這里會有一個問題,那就是A機器的租約到期后,想續租,結果發現續租不上了,因為續租我們使用的是CAS去更新,但是剛才這種情況,續租會失敗,這時候client端必須處理這種續租不上時應該先將uuid置為不可用,然后發現新的租約請求。

綜上所述,可以容忍rent server機器時鍾不一致的場景.

Generator初始化與續租

續租的時機

因為網絡會有延遲的存在,所以得留一定的buffer,提前進行續租,續租成功后,需要更新租期結束時間。
續租如果失敗了,會發起租用新id的請求,租用新id成功后,需要更新workerId,rentTimestamp,intervalStartTime,intervalEndTime。
這兩個動作都是通過一個后台線程定時去執行的,我們都是選擇1s執行一次,buffer選擇的時間是5s。

getNextId()

在getNextId()的方法內,如果發現當前時間,距離租用發起時間,到現在的時間間隔已經超過租期了,會拒絕生成uuid,拋出runtime exception。

初始化

初始化的時候,應該先獲取workerId再服務,雖然不大應該在構造方法里使用阻塞方式去構造,但是uuid這么關鍵的東西不能提供服務,啟動了服務感覺也沒啥大用,所以最后還是選擇了在構造方法中傳入rent server的信息,在構造方法中使用rpc去獲取worker_id等信息。
在demo代碼中做的比較簡陋,沒有做集群。

選擇workerId的策略

最初的選擇策略是租期已經結束的workerId中選擇數值最小的,如果沒有租期已經結束的workerId的話,那么就找出所有workerId中數值最大的,然后+1,並新增這個workId,並將其標記為租期已結束。當然,還得考慮id不能超過2 ^ workerIdBits。
原本的意思是想讓workId的順序規則一點,但是這種情況就是並發出現的時候特別容易沖突,導致租用id失敗。所以還是選擇了使用隨機的策略,在選擇租期結束的id和選擇一個id新插入的過程中,都使用了隨機的策略,成功率有了顯著的上升。測試的數據如下:

使用size為50的線程池去執行10000個請求,每個請求的租期都是1s。
實際中不會有這么多機器同時去申請worker_id的情況發生,而且我們目前設置的worker_id_bits也只有8位,也就是說在同一個namespace中最多支持256台機器同時工作。所以這個測試只是用於說明選擇策略這個問題。

[ INFO] 2017-04-26 19:09:03.287 [Client.java:98] get intervals end
[ INFO] 2017-04-26 19:09:03.293 [Client.java:99] ========== analysis begin ==========
[ INFO] 2017-04-26 19:09:03.299 [Client.java:129] total get interval size=3582
[ INFO] 2017-04-26 19:09:03.299 [Client.java:130] error interval size=0

成功率只有1/3左右
在將線程池大小調節到10的情況下,數據如下

[ INFO] 2017-04-26 19:17:57.403 [Client.java:98] get intervals end
[ INFO] 2017-04-26 19:17:57.407 [Client.java:99] ========== analysis begin ==========
[ INFO] 2017-04-26 19:17:57.436 [Client.java:129] total get interval size=4831
[ INFO] 2017-04-26 19:17:57.436 [Client.java:130] error interval size=0

使用隨機策略后,成功率上升到95%

[ INFO] 2017-04-26 19:53:42.019 [Client.java:98] get intervals end
[ INFO] 2017-04-26 19:53:42.023 [Client.java:99] ========== analysis begin ==========
[ INFO] 2017-04-26 19:53:42.030 [Client.java:129] total get interval size=9555
[ INFO] 2017-04-26 19:53:42.030 [Client.java:130] error interval size=0

Code

talk is cheap,code is here
這里實現了一個基於thrift協議的rent server demo,client使用的是SnowFlakeIdGen,使用方法見TestUUIDGenerator

todo:

1、服務必須使用集群的方式,這里還沒做,應該直接傳入servicename,然后根據服務發現去調用,后續再client斷加上,而對於服務端,可以直接水平擴容。
2、TWEPOCH應該在rent server端配置,而非在client端配置,否則關於時間的工作又白做了。
3、server端的namespace的管理應該寫的優雅一點
4、分層也很混亂,在Dao里雜糅了許多業務邏輯

寫在最后

其實說到底,合適的才是最好的。
花了這么多精力,做了一個分發workerId的server,解決的只是workid復用的問題和時間回退的問題,rent server的可用性和穩定性又成了瓶頸。
如果機器數量很好,可以在配置里找個一一對應,workid可以就是和每一台機器一一對應的;時間回退也只在極少數的情況下發生。當業務不到一定的量時,選擇合理合適的最重要。

參考文檔

snowflake

江南白衣

lanindex.com


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM