唯一ID生成演算法剖析,看看這篇就夠了

  • 2019 年 10 月 31 日
  • 筆記

本文轉載自騰訊技術工程

在業務開發中,大量場景需要唯一ID來進行標識:用戶需要唯一身份標識;商品需要唯一標識;消息需要唯一標識;事件需要唯一標識…等等,都需要全局唯一ID,尤其是分散式場景下。

唯一ID有哪些特性或者說要求呢?按照我的分析有以下特性:

  • 唯一性:生成的ID全局唯一,在特定範圍內衝突概率極小
  • 有序性:生成的ID按某種規則有序,便於資料庫插入及排序
  • 可用性:可保證高並發下的可用性
  • 自主性:分散式環境下不依賴中心認證即可自行生成ID
  • 安全性:不暴露系統和業務的資訊

一般來說,常用的唯一ID生成方法有這些:

UUID:

  • 基於時間戳&時鐘序列生成
  • 基於名字空間/名字的散列值 (MD5/SHA1) 生成
  • 基於隨機數生成

資料庫自增ID:

  • 多台機器不同初始值、同步長自增
  • 批量快取自增ID

雪花演算法

  • 時鐘回撥解決方案
  • 本文便分別對這些演算法進行講解及分析。

UUID

UUID全稱為:Universally Unique IDentifier(通用唯一識別碼),有的地方也稱作GUID(Globally Unique IDentifier),實際上GUID指微軟對於UUID標準的實現的實現。

UUID演算法的目的是為了生成某種形式的全局唯一ID來標識系統中的任一元素,尤其在分散式環境下,該ID需要不依賴中心認證即可自動生成全局唯一ID。

其優勢有:

  • 無需網路,單機自行生成
  • 速度快,QPS高(支援100ns級並發)
  • 各語言均有相應實現庫供直接使用

而缺點為:

  • String存儲,占空間,DB查詢及索引效率低
  • 無序,可讀性差
  • 根據實現方式不同可能泄露資訊

1.UUID的格式

UUID的標準形式為32個十六進位數組成的字元串,且分隔為五個部分,如:

467e8542-2275-4163-95d6-7adc205580a9

各部分的數字個數為:8-4-4-4-12

2.UUID版本

根據需要不同,標準提供了不同的UUID版本以供使用,分別對應於不同的UUID生成規則:

  • 版本1 – 基於時間的UUID:主要依賴當前的時間戳及機器mac地址,因此可以保證全球唯一性
  • 版本2 – 分散式安全的UUID:將版本1的時間戳前四位換為POSIX的UID或GID,很少使用
  • 版本3 – 基於名字空間的UUID(MD5版):基於指定的名字空間/名字生成MD5散列值得到,標準不推薦
  • 版本4 – 基於隨機數的UUID:基於隨機數或偽隨機數生成,
  • 版本5 – 基於名字空間的UUID(SHA1版):將版本3的散列演算法改為SHA1

3.UUID各版本優缺點

版本1 – 基於時間的UUID:

  • 優點:能基本保證全球唯一性
  • 缺點:使用了Mac地址,因此會暴露Mac地址和生成時間

版本2 – 分散式安全的UUID:

  • 優點:能保證全球唯一性
  • 缺點:很少使用,常用庫基本沒有實現

版本3 – 基於名字空間的UUID(MD5版):

  • 優點:不同名字空間或名字下的UUID是唯一的;相同名字空間及名字下得到的UUID保持重複。
  • 缺點:MD5碰撞問題,只用於向後兼容,後續不再使用

版本4 – 基於隨機數的UUID:

  • 優點:實現簡單
  • 缺點:重複幾率可計算

版本5 – 基於名字空間的UUID(SHA1版):

  • 優點:不同名字空間或名字下的UUID是唯一的;相同名字空間及名字下得到的UUID保持重複。
  • 缺點:SHA1計算相對耗時

總得來說:

  • 版本 1/2 適用於需要高度唯一性且無需重複的場景;
  • 版本 3/5 適用於一定範圍內唯一且需要或可能會重複生成UUID的環境下;
  • 版本 4 適用於對唯一性要求不太嚴格且追求簡單的場景。

4.UUID結構及生成規則

以版本1 – 基於時間的UUID為例先梳理UUID的結構:

UUID為32位的十六機制數,因此實際上是16-byte (128-bit),各位分別為:

時間值:在基於時間的UUID中,時間值是一個60位的整型值,對應UTC的100ns時間間隔計數,因此其支援支援一台機器每秒生成10M次。在UUID中,將這60位放置到了15~08這8-byte中(除了09位有4-bit的版本號內容)。

版本號:版本號即上文所說的五個版本,在五個版本的UUID中,都總是在該位置標識版本,佔據 4-bit,分別以下列數字表示:

因此版本號這一位的取值只會是1,2,3,4,5

變體值:表明所依賴的標準(X表示可以是任意值):

時鐘序列:在基於時間的UUID中,時鐘序列佔據了07~06位的14-bit。不同於時間值,時鐘序列實際上是表示一種邏輯序列,用於標識事件發生的順序。在此,如果前一時鐘序列已知,則可以通過自增來實現時鐘序列值的改變;否則,通過(偽)隨機數來設置。主要用於避免因時間值向未來設置或節點值改變可能導致的UUID重複問題。

節點值:在基於時間的UUID中,節點值佔據了05~00的48-bit,由機器的MAC地址構成。如果機器有多個MAC地址,則隨機選其中一個;如果機器沒有MAC地址,則採用(偽)隨機數。

了解了基於時間的UUID結構及生成規則後,再看看其他版本的UUID生成規則:

  • 版本2 – 分散式安全的UUID:

將基於時間的UUID中時間戳前四位換為POSIX的UID或GID,其餘保持一致。

  • 版本3/5 – 基於名字空間的UUID (MD5/SHA1):
  1. 將命名空間 (如DNS、URL、OID等) 及名字轉換為位元組序列;
  2. 通過MD5/SHA1散列演算法將上述位元組序列轉換為16位元組哈希值 (MD5散列不再推薦,SHA1散列的20位只使用其15~00位);
  3. 將哈希值的 3~0 位元組置於UUID的15~12位;
  4. 將哈希值的 5~4 位元組置於UUID的11~10位;
  5. 將哈希值的 7~6 位元組置於UUID的09~08位,並用相應版本號覆蓋第9位的高4位 (同版本1位置);
  6. 將哈希值的 8 位元組置於UUID的07位,並用相應變體值覆蓋其高2位 (同版本1位置);
  7. 將哈希值的 9 位元組置於UUID的06位 (原時鐘序列位置);
  8. 將哈希值的 15~10 位元組置於UUID的05~00位 (原節點值位置)。
  • 版本4 – 基於隨機數的UUID:
  1. 生成16byte隨機值填充UUID。重複機率與隨機數產生器的品質有關。若要避免重複率提高,必須要使用基於密碼學上的假隨機數產生器來生成值才行;
  2. 將變體值及版本號填到相應位置。

5.多版本偽碼

// 版本 1 - 基於時間的UUID:  gen_uuid() {      struct uuid uu;        // 獲取時間戳      get_time(&clock_mid, &uu.time_low);      uu.time_mid = (uint16_t) clock_mid; // 時間中間位      uu.time_hi_and_version = ((clock_mid >> 16) & 0x0FFF) | 0x1000; // 時間高位 & 版本號        // 獲取時鐘序列。在libuuid中,嘗試取時鐘序列+1,取不到則隨機;在python中直接使用隨機      get_clock(&uu.clock_seq);// 時鐘序列+1 或 隨機數      uu.clock_seq |= 0x8000;// 時鐘序列位 & 變體值        // 節點值      char node_id[6];      get_node_id(node_id);// 根據mac地址等獲取節點id      uu.node = node_id;      return uu;  }    // 版本4 - 基於隨機數的UUID:  gen_uuid() {      struct uuid uu;      uuid_t buf;        random_get_bytes(buf, sizeof(buf));// 獲取隨機出來的uuid,如libuuid根據進程id、當日時間戳等進行srand隨機        uu.clock_seq = (uu.clock_seq & 0x3FFF) | 0x8000;// 變體值覆蓋      uu.time_hi_and_version = (uu.time_hi_and_version & 0x0FFF) | 0x4000;// 版本號覆蓋      return uu;  }    // 版本5 - 基於名字空間的UUID(SHA1版):  gen_uuid(name) {      struct uuid uu;      uuid_t buf;        sha_get_bytes(name, buf, sizeof(buf));// 獲取name的sha1散列出來的uuid        uu.clock_seq = (uu.clock_seq & 0x3FFF) | 0x8000;// 變體值覆蓋      uu.time_hi_and_version = (uu.time_hi_and_version & 0x0FFF) | 0x5000;// 版本號覆蓋      return uu;  }

(左滑查看完整程式碼)

資料庫自增ID

資料庫自增ID可能是大家最熟悉的一種唯一ID生成方式,其具有使用簡單,滿足基本需求,天然有序的優點,但也有缺陷:

  • 並發性不好
  • 資料庫寫壓力大
  • 資料庫故障後不可使用
  • 存在數量泄露風險

因此這裡給出兩種優化方案。

1. 資料庫水平拆分,設置不同的初始值和相同的步長

如圖所示,可保證每台資料庫生成的ID是不衝突的,但這種固定步長的方式也會帶來擴容的問題,很容易想到當擴容時會出現無ID初始值可分的窘境,解決方案有:

  • 根據擴容考慮決定步長
  • 增加其他位標記區分擴容

這其實都是在需求與方案間的權衡,根據需求來選擇最適合的方式。

2.批量生成一批ID

如果要使用單台機器做ID生成,避免固定步長帶來的擴容問題,可以每次批量生成一批ID給不同的機器去慢慢消費,這樣資料庫的壓力也會減小到N分之一,且故障後可堅持一段時間。

如圖所示,但這種做法的缺點是伺服器重啟、單點故障會造成ID不連續。還是那句話,沒有最好的方案,只有最適合的方案。

雪花演算法

定義一個64bit的數,對指定機器 & 同一時刻 & 某一併發序列,是唯一的,其極限QPS約為400w/s。其格式為:

將64 bit分為了四部分。其中時間戳有時間上限(69年)。機器id只有10位,能記錄1024台機器,常用前幾位表示數據中心id,後幾位表示數據中心內的機器id。序列號用來對同一個毫秒之內的操作產生不同的ID,最多4095個。

這種結構是雪花演算法提出者Twitter的分法,但實際上這種演算法使用可以很靈活,根據自身業務的並發情況、機器分布、使用年限等,可以自由地重新決定各部分的位數,從而增加或減少某部分的量級。比如百度的UidGenerator、美團的Leaf等,都是基於雪花演算法做一些適合自身業務的變化。

由於雪花演算法是強依賴於時間的,在分散式環境下,如果發生時鐘回撥,很可能會引起id衝突的問題。解決方案有:

  • 將ID生成交給少量伺服器,並關閉時鐘同步。
  • 直接報錯,交給上層業務處理。
  • 如果回撥時間較短,在耗時要求內,比如5ms,那麼等待回撥時長後再進行生成。
  • 如果回撥時間很長,那麼無法等待,可以勻出少量位(1~2位)作為回撥位,一旦時鐘回撥,將回撥位加1,可得到不一樣的ID,2位回撥位允許標記三次時鐘回撥,基本夠使用。如果超出了,可以再選擇拋出異常。

方案對比

可以發現,常用的分散式唯一ID生成思路基本是利用一個長串數字或字元串,將其分割成多個部分,分別記錄時間資訊、機器/名字資訊、隨機資訊、序列資訊等。時間資訊部分決定了該策略能使用的時長,機器/名字資訊支援了分散式環境下的獨自生成唯一ID與識別能力,序列資訊保證了事件的順序記錄以及同一時間單位下的並發數,而隨機資訊則加大了ID整體的不可識別性。

實際上如果現有的方法依然不能滿足,我們完全可以依據自身業務和發展需求,來自行決定使用何種策略生成唯一ID。各種方案都有其優缺點,技術的使用沒有絕對的好壞之分,主要在於是否適合使用場景:

  • 要求生成全局唯一且不會重複ID,不關心順序 —— 使用基於時間的UUID(如遊戲聊天室中不同用戶的身份ID)
  • 要求生成唯一ID,具有名稱不可變性,可重複生成 —— 使用基於名稱哈希的UUID(如基於不可變資訊生成的用戶ID,若不小心刪除,仍可根據資訊重新生成同一ID)
  • 要求生成有序且自然增長的ID —— 使用資料庫自增ID(如各業務操作流水ID,高並發下可參考優化方案)
  • 要求生成數值型無序定長ID —— 使用雪花演算法(如對存儲空間、查詢效率、傳輸數據量等有較高要求的場景)

對於最初我們定義的唯一ID特性,各方案的對比如下:

從衝突率、QPS和演算法時間複雜度來比較的話:

參考

UUID演算法分析

關於UUID的二三事

UUID百度百科

UUID唯一資源命名空間的來龍去脈

UUID是如何保證唯一性的?

如果再有人問你分散式 ID,這篇文章丟給他

分散式唯一ID的幾種生成方案

UidGenerator-百度

Leaf——美團點評分散式ID生成系統

分散式系統:Lamport 邏輯時鐘