1. 程式人生 > >生成全域性唯一ID的3個思路,來自一個資深架構師的總結

生成全域性唯一ID的3個思路,來自一個資深架構師的總結

標識(ID / Identifier)是無處不在的,生成標識的主體是人,那麼它就是一個命名過程,如果是計算機,那麼它就是一個生成過程。如何保證分散式系統下,並行生成標識的唯一與標識的名稱空間有著密不可分的關係。在世界裡,「潛意識下的名稱空間裡,相對的唯一標識」是普遍存在的,例如:

1.每個人出生的時候,就獲得了一個「相對的唯一標識」——姓名。

2.城市的道路,都基本上採用了唯一的命名(當然這也需要一個過程 )。

顯然,對於每個標識,都需要有一個名稱空間(namespace),來保證其相對唯一性。

可以說,在人的意識裡,對於的實體的描述是基於名字進行的,人們並不希望同名的出現太多,這會在溝通過程中的產生理解困難。

對於人來說,在家庭裡會有小名,在社會中會有正式名字,在社交過程中還會產生綽號。

在中國,對於企業來說,除了企業有名稱之外,還有組織機構程式碼證、有稅務登記證、有工商營業執照,並分別對應三個編號。(當然,目前五證合一也在進行中)。

回到計算機領域,圍繞主機在網路上的地址,在不同的名稱空間中,都會存在一個「相對的唯一標識」用來描述一個實體:

每個乙太網網絡卡,都有一個48-bit 的MAC地址

每個MAC地址,可能有一個或者多個IP地址

每個網絡卡,都可能有一個或者多個IP地址

每個IP地址,都可能有多個域名

當然,每個主機,都會有一個主機名

接續上面的例子,事實上,MAC地址是由 IEEE Standards Association Registration Authority 完成地址段的分配。

對於目前的 1530 個頂級根域(gTLD),以及 IPv4 / IPv6 地址,都由IANA對其進行管理。

上面我通過類比的方式簡單介紹了標識,總結來說它是無處不在的。我們在理解技術裡的ID的同時,一定要聯絡生活中的場景,對比著琢磨和分析。

標識是從一個典型的場景,對客觀事物進行統一編碼的過程。

採用半集中與半自主相結合 的方法,是一種實現「分而治之」十分普遍和有效的設計模式。

標識的唯一性是根據名稱空間緊密相關的。

標識的使用

* 在不同名稱空間中實現標識的轉換*

在中國,對於人名,通常是由公安局出入境管理局完成中文至英文的翻譯,同時,他們會把翻譯結果寫到資料庫中,印到護照上。這中間的翻譯規則,通常是根據中文與漢語拼音、漢語拼音與英文字母的兩次轉換關係完成的。

對於計算機網路,則會有 NAT完成IP地址間的轉換,RAP/RARP完成IP地址與MAC地址的雙向轉換,DNS完成域名至IP地址的轉換。

可是,為什麼需要那麼多不同名稱空間的標識標識一個實體?可能最直觀的回答通常是這樣:

域名為了方便人的記憶與使用

IP地址是為了更廣範圍的計算機互聯

MAC則是為了在物理上保證唯一

OSI開放系統互聯7層模型決定的

人們會在不同的領域(也是名稱空間)中定義自己的命名規範,這可以認為是領域主權的體現,同時伴生的會是一套與相關領域標識的轉換協議

* 結構化與別名效應*

結構化是把資料的元資訊以位置的方式固化是資料中。也就是說,代表某個意義的資訊,一定會出現在一個約定好的位置上。

由於標識是被人經常使用的,那麼在使用過程中會對大腦形成一定的訓練。

人在看到了010-XXXXXXXX,021-XXXXXXXX號碼之後,自然而言會產生條件反射,認為兩者分別代表了北京和上海;同樣的人在看到了139和186之後,分別產生了中國移動以及中國聯通的運營商聯想。

對於使用者,這種場景,數字類似是一個名稱別名。對於程式設計師,這十分接近「資料字典」的設計模式。

* 標識轉換過程的兩面性*

別名和正名,同樣是來自於兩個不同名稱空間的標識,之間自然而然的會進行轉換。

當然,人們也不會忘記去Hack這些轉換協議的設計。

一些是有益的,是實現了更為便利的應用場景。例如:將不同的域名指向相同的IP地址(使用A或者CNAME記錄),並結合相關軟硬體實現「虛擬主機」,達到資源複用的目的。

一些卻是有害的,例如,詐騙電話也經常採用改號的方法,讓接聽者誤以為那是來自某個官方的外呼電話。

同樣的,在計算機領域,一樣有DNS劫持、DNS汙染。

有矛就有盾,進行安全性擴充套件的DNSSEC 就是為了對DNS結果,驗證不存在性和校驗資料完整性驗證,不過依然沒有實現全面部署。

* 小結*

在關注如何生成標識的同時,還需要關注標識的易用性和直觀性

不同名稱空間的標識,在互通時需要進行轉換

轉換的過程,可能是一個簡單的規則,也可能是一個獨立第三方服務

標識的唯一性是基本訴求,同時嵌入其他維度的資訊是減少實時關聯查詢的有效手段

思路一:基於資料庫生成

標識的生成方法有很多,有集中式的,分散式的;有後端的,前端的,當然還有人工的。並沒有一種通用的生成方法來適應各種應用場景。

人工生成的確是一種方式,比如電子郵箱,微信ID,各種論壇的賬號。在人想出標識的那一刻,是無法判斷是否是唯一的,對這種生成方式的結果,顯然在錄入時都需要進行唯一性校驗。所以,下面描述的幾種生成方式,是在生成的那一刻就在一個名稱空間內唯一,而不再需要進行唯一性校驗。

而基於資料庫生成,一般包含以下幾種:

MySQL(5.6) AUTO_INCREMENT 特性

Postgres(REL 9.6 Stable) SEQUENCE 特性

Oracle 資料庫的 SEQUENCE 特性,有知道這一特性如何實現的,可以在 知乎 做一下解答。

Flickr Ticket Servers ,同時支援Sharding (文章發表於2010年2月8日,演算法上線於2006年1月13日)。

一般地,這種型別的生成方案,都可以設定其實初始值,以及增量步長。

思路二:基於分散式叢集協調器生成

在不使用資料庫的情況下,通過一個後臺服務對外提供高可用的、固定步長標識生成,則需要分散式的叢集協調器進行。

一般的,主流協調器有兩類:

以Paxos為代表的:ZooKeeper

以Raft為代表的:Consul / Etcd

ZooKeeper的強一致性,是由Paxos協議保證的;Consul的一致性,官方用subtle(微妙的)來形容。它既採用了Gossip管理叢集Membership,也採用了Raft管理Service Catalog。Consul的寫一致性通過Raft保證,但Consul的讀一致性有三種模式,default / consistent / stale, 其中consistent是強一致的。

在步長累計型生成演算法中,最核心的就是保持一個累計值在整個叢集中的「強一致性」。同時,這也會為唯一性標識的生成帶來新的形成瓶頸。

思路三:劃分名稱空間並行生成

似乎對於分散式的ID生成,以TwitterSnowflake為代表的, Flake 系列演算法,經常可以被搜尋引擎找到,但似乎MongoDB的ObjectId演算法,更早地採用了這種思路。MongoDB 1.0 是在2009年8月27日 釋出 的,並且0.9.10(2009年8月24日釋出)和1.0兩個版本沒有差異。

* MongoDB ObjectId*

12-byte MongoDB ObjectId 的結構是:

a 4-byte value representing the seconds since the Unixepoch,

a 3-byte machine identifier,

a 2-byte process id, and

a 3-byte counter, starting with a random value.

可以看出,這個方案所支援的最小劃分粒度是「秒 * 程序例項」,單程序例項的每秒容量是 3-byte (24-bit),也就是接近16777216個ID。

有興趣的,還可以進一步 看程式碼(MonogoDB3.3.x Java Driver) 研究:Timestamp, Machine Identifier、Process Identifier、計數器的初始值分別是如何獲得的:

1.Timestamp

圖片描述

2.MachineIdentifier

圖片描述

3.Process ID

圖片描述

4.COUNTER

圖片描述

此處需要注意的是MongoDB的 NEXT_COUNTER 其初始值是一個隨機數,這是有利於分庫分表的。因為在小併發的條件下,非隨機數的初始值,容易產生 偏庫偏表,不均勻的現象。

Twitter Snowflake

Twitter在2010年6月1日(在Flickr那篇文章釋出不到4個月之後),Ryan King 在Twitter的Blog撰文 寫道:

Ticket Servers方案缺乏順序的保證

考慮過採用UUID,不過128-bit太長了

也考慮過採用ZooKeeper所提供的 *Unique Naming* Seuence Nodes 所提供的 Unique Naming 特性,但是效能不能滿足。(個人認為,Sequence Nodes的設計目標是解決分散式鎖的問題,但不解決效能要求極高的ID生成問題,直接應用是一種Hack行為)

在這種情況下,Twitter給出了 64-bit 長的 Snowflake ,它的結構是:

1-bit reserved

41-bit timestamp

10-bit machine id

12-bit sequence

在過了不到4年,2014年的5月31日,Twitter 更新了 Snowflake 的 README,其中陳述了兩個容易被忽視的事實:

"We have retired the initial release of Snowflake..."

"... heavily relies on existing infrastructure atTwitter to run. "

可以看出,這個方案所支援的最小劃分粒度是「毫秒 * 執行緒」,單執行緒(Snowflake 裡對應的概念是 Worker)的每秒容量是12-bit,也就是接近4096。

翻一下Snowflake的歸檔程式碼 (Scala),可以看到:

1.關於初始化Sequence的處理

圖片描述

可以看到此處Snowflake對於 sequence 的賦值為0。

2.關於每秒超過4096個ID生成請求的處理

圖片描述

* noeqd*

2011年11月23日,用Go語言實現的,基於Snowflake的neoqd 出現了。

它的特點是,除了使用Go語言進行了實現,更是把ID生成做成了一個網路服務。支援客戶端向ID生成服務申請ID。它還支援:

簡單預共享Token的客戶端身份證認證(只是加強了那麼一點點的安全性,可以忽略)

支援批量獲取ID,最多256個(因為使用一個byte表示申請個數)

同時,作者還建議使用 Doozerd 一個用Go語言寫的 – a highly-available, completelyconsistent store for small amounts of extremely important data. 進行Machine ID的分配。

(關於 ZooKeeper / Etcd / Consul / Doozerd 的比較,也是可以期待下)

* Boundary Flake*

2012年1月, BoundaryFlake 同樣的,用Erlang語言把Snowflake,變成了一個網路服務,提供128-bit長的ID生成服務。

不過,根據其RoadMap的描述,這個專案並沒100%完成。例如,批量的ID生成,HTTP介面,客戶端Library都列在裡面待實現。

* CruftFlake*

2012年7月, CruftFlake 更顯然的,是想以一個PHP變種身份出現。

它在結構上與Snowflake基本一致,存在兩個區別:

在timestamp上的取值略有區別

可以自行決定是否採用ZooKeeper作為協調器

* 基於LableOrg/java-uniqueid*

2014年7月18日,LableOrg 寫了一個通過ZooKeeper進行協調的,128-bit長的演算法 java-uniqueid。其結構組成依然十分相似:

Timestamp

Sequence counter

Generator IDs

Cluster IDs

* 前臺瀏覽器生成*

這裡的前臺,主要是指以「瀏覽器」為代表的客戶端。

2015年2月16日,Sudhanshu Yadav (看面相像印度人),用Javascript寫了Flake的又一個變種實現 FlakeId 。其核心程式碼是:

圖片描述

它的MachineIdentifier則是作為建構函式的選項引數 options.mid 傳入。

圖片描述

* 沒思路?全自主生成?*

* 選擇UUID?*

可以說,成熟的、全自主生成方案,可能只有 128-bit UUID 一種,具體的說,是UUID Version 4。另外,微軟對它實現,稱之為 GUID 。

一般的,使用的最多的是UUIDVersion 4,很大程度上是因為其依賴的其他服務最少。

這裡,通過python (2.5+) 對UUID的實現,體驗一下UUID的生成效果:

圖片描述

另外,我們看一下網絡卡的MAC地址:

圖片描述

(因為UUID Version 1會洩露網絡卡的MAC地址,所以我對MAC地址做了下小手術)

可以看到UUID Version 1 最後一組數值 985aeb899615 與網絡卡的 MAC地址是一樣一樣的 98:5a:eb:89:96:15。

個人一直認為,採用UUIDVersion 4是一種偷懶的,沒有針對具體應用場景,缺乏必要設計的做法。

一方面,它是依據概率確保無碰撞的,計算的過程與概率上的「生日問題」是一樣的,不再展開。

另一方面,從使用的角度,UUID還有以下缺點:

太長,即便是轉換成36個字元,不利於輸入

過於隨機,沒有規律,在開發除錯、線上故障定位,都容易看花眼。

如果作為資料庫主鍵,對索引不利。

* 基於Hash演算法?*

眾多的Hash演算法,例如「MD5 / SHA-1 / SHA-2 / SHA-3」,都看可以對內容進行摘要計算,形成一個定長的Hash值。

這些Hash演算法,都會存在一個Hash衝突的問題,以及碰撞攻擊的問題。

以UUID類似,其文字化之後的隨機特徵,不太適合應用在ID生成方面。

標識生成總結

人工生成的標識,在相同的名稱空間裡,需要後續唯一性驗證才能保證唯一。

由計算機生成,在低併發的場景下,適合通過一個服務集中生成,並保障此服務的高可用性。

由計算機生成,在高併發的場景下,適合通過一個保障名稱空間獨立的命名規範下,由多個服務並行生成。

採用步長和增長相結合的生成演算法,本質上都是對某個狀態進行累積的結果。

對於取模進行分庫分表的場景,初始化值隨機有利於均勻分佈。

(MongoDB 的 ObjectId 更是Flake系列演算法的鼻祖,並在初始值上進行了隨機化處理)

設計一個「合適」的標識

1.區分實體和關係

實體是點,而關係是線。

一般而言,面向實體的標識生成速度,要小於面向關係的生成速度。

具體的例子,以電商為例:買家、賣家、商品這些實體的錄入速度,要遠比訂單生成小的多。也因此,主資料要比交易資料穩定的多。

並且,關係還可能包含層次關係,進而體現為一個依賴樹。

面向實體的標識

面向實體的標識,更多的與概念相關(名稱)、與形態相關(型號),有很多的人為因素參與,隨機因素有限,命名的主體也來自於人。

對於實體制造,為任意一個產品進行標識,大致會分為六個方面:品牌、品類、品名,型號、批號、產品序列號。

對於前四者,更多的是人為的進行命名。例如,給定中文,找到對應英文,再進行縮寫。

對於批號,則會增加一些時間因素,以關聯到產品的生產時間。例如,採用20160925表示具體某一天,或者採用201640表示具體某一週。(一般來說,同一個批號的產品,所使用的原材料是也是同一批。)

對於產品序列號,最簡單的是採用自然數法進行編號。

這一類的標識,在分散式系統下,在系統併發量小,叢集規模小的情況下,可以採用基於資料庫或者協調器的生成方案。

面向關係的標識

自然的,關係源於兩個或兩個以上的實體之間所進行的某一個活動,並且具有一定的時效性。

常見的關係的表現形式有:交易流水號,會話標識

這一類的標識,在分散式系統下,在系統併發量大,應當採用基於服務的內建生成方案。唯一依賴的是在例項部署時、啟動前,為期分配唯一的Machine Identifier。這個Machine Identifier可以交由以強一致性保證的協調器完成。

當然,在系統併發量小的情況下,任然可以採用基於資料庫的生成方案,因為沒有協調器叢集的參與,系統整體的複雜度更低,更利於維護。

2.標識的容量

任何採用文字所表達的標識,最終在計算機裡,都會根據一定的格式,被轉換為位元組byte進行處理,這個過程稱之為「序列化」。 這種序列化方式,本質上是一種編碼方式。

變長編碼

一般來說,採用變長的編碼方式,主要的目的是為了應對不可預期大小的資訊量。

常見的有TLV(Type-Length-Value) 方式。 Google的 Protocol Buffers 非常有意思地採用了 Base 128 Varints的編碼方式。

本質上,一個 URI 也是一個變長標識,它可以標識一個功能,也可以標識一個虛擬實體。

RESTful是對此類命名方式的一種實踐方式,也是對 URI和HTTP協議組合之後,「表徵力」的一個深入挖掘。

定長編碼

在回顧一下前文所提到的IPv4地址,它似乎、可能、或許會在2019年 完全枯竭, 因為它只有32-bit。相比之下,MAC地址有48-bit,IPv6有128-bit。即便是它們都沒那麼容易枯竭,但也不代表由於人為因素,導致無法有效使用。

再回想下,每個人的身份證、手機號碼,都是採用定長的形式進行編碼。

選擇定長有利於預先分配計算機資源,不管是記憶體、檔案系統,還是資料庫。同時,對於人的心理來說,可預期性大大增強了。

標識的名稱空間

名稱空間有三個層面:

異構切分:對於不同的場景和視角,以樹形進行層次劃分。

同構切分:對於異構切分的結果,切分出不同的分片。

時間切分:對於同一個分片,在不同時間點上的狀態。

一般地:

首先,採用並行無狀態的生成演算法,一般都採用時間作為首要的名稱空間,並且此名稱空間的實效性小於生成者的重啟時間

其次,採用生成器例項自身的標識作為次要名稱空間,以保證各個生成器的時間即便是不同步也不會產生重複標識

同時,需要注意的是,這可能導致唯一標識產生,大段跳躍,原因有:

單位時間的併發量遠小於子名稱空間的容量

生成器重啟

標識的冗餘

不管標識是在執行時的記憶體出現,還是記錄到資料庫中或者檔案裡,它都需要佔用硬體資源。

還是拿身份證舉例,一方面,一個18個字元長度的身份證,那麼需要18個位元組進行儲存。18個位元組意味著144-bit,比IPv6的128bit還長。

如果簡單的標識全世界每個人,以目前全地球超過70億人口的總量,那麼33個bit就足夠了。

採用這種冗餘設計的原因,一方面是「半集中,半自主」和現實的行政、地域結構對齊,另一方面是實現關聯資訊的整合。

小結

標識編碼後的長度,則決定了一個標識方案的整體容量。

在一個統一的名稱空間內,有多個標識生成者並行生成時,需要劃分獨立的子名稱空間,以保證生成的標識在整個名稱空間內唯一。

單個名稱空間的標識,承載的資訊量有限,在標識的使用過程中,需要擴充套件與包含一些其他視角的資訊以進行冗餘。

3.標識的文字相容

和人工取名字不一樣,自動生成ID的主體,是計算機本身,但使用這個ID的主體,有兩個:人和計算機。

對於計算機,最擅長處理的是結構化陣列、條形碼或者二維碼;而對人,最擅長使用的是文字、圖形或者視訊。

一般而言,在大量的RESTful設計的應用,其URI中會包含大量的ID,用來標識使用者、商品、訂單等等,它們經常會出現在URI中。

以ASCII編碼為基礎的各種文字化編碼演算法,從Base16開始,正常的有Base32,Base64,Base58,Base85等等。

其中,Base16是最為「位元組友好」的,因為不需要進行任何Padding操作,就可以以把 4-bit/half-byte 轉換為 [0-9a-f] 這十六個字元,因此Base16還有別名:Hex。另外對於鍵盤輸入,這16個英文字母,又是相對純數字之外,最方便的。

而Base32, Base64等等,都需要Padding。因為Base32是每5-bit 進行分組編碼,Base64則是 6-bit ,都無法直接對齊一個 byte(8-bit)。

另外,Base16還對 URI 友好,不需要進行任何的 URLEncode/Decode操作。

以64-bit長的ID為例,它既可以轉化為 long,也可以Base16成為16個字元的HexString,同時它大小寫不敏感。

相比之下,如果採用Base64的文字化方案,其長度雖然少了5個字元,為11個,但其大小寫敏感,不利於人機互動的輸入,還會包含URI不友好,還會被轉義為「 %3D」的符號「=」。

一個精巧的標識文字化演算法,並不應該簡單的把一個二進位制值轉為HexString。在日誌裡,應該有相應的解碼演算法,解析出符合人類閱讀的字元,比如:精確到秒、且帶格式時間,生成改標識的主體,等等。

4.標識的安全性

標識的資訊洩露

採用連續,或者固定步長的標識,容易從一個標識猜測其他標識的存在性。

常見的例子有:

通過區域網掃描工具,掃描某個子網的活動的IP地址 

通過埠掃描工具,掃描一個目標主機開放的埠,以初步確定主機作業系統型別

另外,在物聯網領域,如果採用的EPC編碼,那麼很容易通過連續編碼,估計某個產品的具體產量。

標識的自校驗能力

還是使用身份證號這個例子,根據國家標準(GB11643-1999),身份證號的前17位為本體碼,最後1位為校驗碼。也就是說,它是通過前17位進行數學公式計算之後獲得,主要目的是用於檢驗錄入過程是否產生差錯。

這樣設計的好處是,每當輸入完18位身份證號後,可以直接判斷一個身份證號,是否在邏輯上是「合規的」,對於系統而言不用查詢資料庫,可以減少IO操作。不過,這不代表這個身份證號是有效的,也有可能是一個無效,但符合校驗規則的身份證號。

由於標識的長度有限,能夠加入的冗餘資訊較少,一般的基於公鑰密碼體制的簽名機制,都難以在一個短標識中嵌入。

普元公眾號:

圖片描述