生成全域性唯一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操作。不過,這不代表這個身份證號是有效的,也有可能是一個無效,但符合校驗規則的身份證號。
由於標識的長度有限,能夠加入的冗餘資訊較少,一般的基於公鑰密碼體制的簽名機制,都難以在一個短標識中嵌入。
普元公眾號: