京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

碼農談IT發表於2023-12-07

來源:JavaGuide

今天分享一位讀者去京東面試遇到的面試題:“為什麼要分散式 ID?你專案中是怎麼做的?”。

這篇文章我會說說自己的看法,詳細介紹一下分散式 ID 相關的內容包括分散式 ID 的基本要求以及分散式 ID 常見的解決方案。

內容概覽:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

個人能力有限。如果文章有任何需要補充/完善/修改的地方,歡迎在評論區指出,共同進步!

分散式 ID 介紹

什麼是 ID?

日常開發中,我們需要對系統中的各種資料使用 ID 唯一表示,比如使用者 ID 對應且僅對應一個人,商品 ID 對應且僅對應一件商品,訂單 ID 對應且僅對應一個訂單。

我們現實生活中也有各種 ID,比如身份證 ID 對應且僅對應一個人、地址 ID 對應且僅對應

簡單來說,ID 就是資料的唯一標識

什麼是分散式 ID?

分散式 ID 是分散式系統下的 ID。分散式 ID 不存在與現實生活中,屬於計算機系統中的一個概念。

我簡單舉一個分庫分表的例子。

我司的一個專案,使用的是單機 MySQL 。但是,沒想到的是,專案上線一個月之後,隨著使用人數越來越多,整個系統的資料量將越來越大。單機 MySQL 已經沒辦法支撐了,需要進行分庫分表(推薦 Sharding-JDBC)。

在分庫之後, 資料遍佈在不同伺服器上的資料庫,資料庫的自增主鍵已經沒辦法滿足生成的主鍵唯一了。我們如何為不同的資料節點生成全域性唯一主鍵呢?

這個時候就需要生成分散式 ID了。

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

分散式 ID 需要滿足哪些要求?

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

分散式 ID 作為分散式系統中必不可少的一環,很多地方都要用到分散式 ID。

一個最基本的分散式 ID 需要滿足下面這些要求:

  • 全域性唯一:ID 的全域性唯一性肯定是首先要滿足的!
  • 高效能:分散式 ID 的生成速度要快,對本地資源消耗要小。
  • 高可用:生成分散式 ID 的服務要保證可用性無限接近於 100%。
  • 方便易用:拿來即用,使用方便,快速接入!

除了這些之外,一個比較好的分散式 ID 還應保證:

  • 安全:ID 中不包含敏感資訊。
  • 有序遞增:如果要把 ID 存放在資料庫的話,ID 的有序性可以提升資料庫寫入速度。並且,很多時候 ,我們還很有可能會直接透過 ID 來進行排序。
  • 有具體的業務含義:生成的 ID 如果能有具體的業務含義,可以讓定位問題以及開發更透明化(透過 ID 就能確定是哪個業務)。
  • 獨立部署:也就是分散式系統單獨有一個發號器服務,專門用來生成分散式 ID。這樣就生成 ID 的服務可以和業務相關的服務解耦。不過,這樣同樣帶來了網路呼叫消耗增加的問題。總的來說,如果需要用到分散式 ID 的場景比較多的話,獨立部署的發號器服務還是很有必要的。

分散式 ID 常見解決方案

資料庫

資料庫主鍵自增

這種方式就比較簡單直白了,就是透過關係型資料庫的自增主鍵產生來唯一的 ID。

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

以 MySQL 舉例,我們透過下面的方式即可。

1.建立一個資料庫表。

CREATE TABLE `sequence_id` (
  `id` bigint(20unsigned NOT NULL AUTO_INCREMENT,
  `stub` char(10NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `stub` (`stub`)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stub 欄位無意義,只是為了佔位,便於我們插入或者修改資料。並且,給 stub 欄位建立了唯一索引,保證其唯一性。

2.透過 replace into 來插入資料。

BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT LAST_INSERT_ID();
COMMIT;

插入資料這裡,我們沒有使用 insert into 而是使用 replace into 來插入資料,具體步驟是這樣的:

  • 第一步:嘗試把資料插入到表中。

  • 第二步:如果主鍵或唯一索引欄位出現重複資料錯誤而插入失敗時,先從表中刪除含有重複關鍵字值的衝突行,然後再次嘗試把資料插入到表中。

這種方式的優缺點也比較明顯:

  • 優點:實現起來比較簡單、ID 有序遞增、儲存消耗空間小
  • 缺點:支援的併發量不大、存在資料庫單點問題(可以使用資料庫叢集解決,不過增加了複雜度)、ID 沒有具體業務含義、安全問題(比如根據訂單 ID 的遞增規律就能推算出每天的訂單量,商業機密啊!)、每次獲取 ID 都要訪問一次資料庫(增加了對資料庫的壓力,獲取速度也慢)

資料庫號段模式

資料庫主鍵自增這種模式,每次獲取 ID 都要訪問一次資料庫,ID 需求比較大的時候,肯定是不行的。

如果我們可以批次獲取,然後存在在記憶體裡面,需要用到的時候,直接從記憶體裡面拿就舒服了!這也就是我們說的 基於資料庫的號段模式來生成分散式 ID。

資料庫的號段模式也是目前比較主流的一種分散式 ID 生成方式。像滴滴開源的Tinyid[1] 就是基於這種方式來做的。不過,TinyId 使用了雙號段快取、增加多 db 支援等方式來進一步最佳化。

以 MySQL 舉例,我們透過下面的方式即可。

1. 建立一個資料庫表。

CREATE TABLE `sequence_id_generator` (
  `id` int(10NOT NULL,
  `current_max_id` bigint(20NOT NULL COMMENT '當前最大id',
  `step` int(10NOT NULL COMMENT '號段的長度',
  `version` int(20NOT NULL COMMENT '版本號',
  `biz_type`    int(20NOT NULL COMMENT '業務型別',
   PRIMARY KEY (`id`)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

current_max_id 欄位和step欄位主要用於獲取批次 ID,獲取的批次 id 為:current_max_id ~ current_max_id+step

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

version 欄位主要用於解決併發問題(樂觀鎖),biz_type 主要用於表示業務型別。

2. 先插入一行資料。

INSERT INTO `sequence_id_generator` (`id``current_max_id``step``version``biz_type`)
VALUES
 (101000101);

3. 透過 SELECT 獲取指定業務下的批次唯一 ID

SELECT `current_max_id``step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

結果:

id current_max_id step version biz_type
1 0 100 0 101

4. 不夠用的話,更新之後重新 SELECT 即可。

UPDATE sequence_id_generator SET current_max_id = 0+100version=version+1 WHERE version = 0  AND `biz_type` = 101
SELECT `current_max_id``step`,`version` FROM `sequence_id_generator` where `biz_type` = 101

結果:

id current_max_id step version biz_type
1 100 100 1 101

相比於資料庫主鍵自增的方式,資料庫的號段模式對於資料庫的訪問次數更少,資料庫壓力更小。

另外,為了避免單點問題,你可以從使用主從模式來提高可用性。

資料庫號段模式的優缺點:

  • 優點:ID 有序遞增、儲存消耗空間小
  • 缺點:存在資料庫單點問題(可以使用資料庫叢集解決,不過增加了複雜度)、ID 沒有具體業務含義、安全問題(比如根據訂單 ID 的遞增規律就能推算出每天的訂單量,商業機密啊!)

NoSQL

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

一般情況下,NoSQL 方案使用 Redis 多一些。我們透過 Redis 的 incr 命令即可實現對 id 原子順序遞增。

127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
"2"

為了提高可用性和併發,我們可以使用 Redis Cluster。Redis Cluster 是 Redis 官方提供的 Redis 叢集解決方案(3.0+版本)。

除了 Redis Cluster 之外,你也可以使用開源的 Redis 叢集方案Codis[2] (大規模叢集比如上百個節點的時候比較推薦)。

除了高可用和併發之外,我們知道 Redis 基於記憶體,我們需要持久化資料,避免重啟機器或者機器故障後資料丟失。Redis 支援兩種不同的持久化方式:快照(snapshotting,RDB)只追加檔案(append-only file, AOF)。並且,Redis 4.0 開始支援 RDB 和 AOF 的混合持久化(預設關閉,可以透過配置項 aof-use-rdb-preamble 開啟)。

關於 Redis 持久化,我這裡就不過多介紹。不瞭解這部分內容的小夥伴,可以看看 Redis 持久化機制詳解[3]這篇文章。

Redis 方案的優缺點:

  • 優點:效能不錯並且生成的 ID 是有序遞增的
  • 缺點:和資料庫主鍵自增方案的缺點類似

除了 Redis 之外,MongoDB ObjectId 經常也會被拿來當做分散式 ID 的解決方案。

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

MongoDB ObjectId 一共需要 12 個位元組儲存:

  • 0~3:時間戳
  • 3~6:代表機器 ID
  • 7~8:機器程式 ID
  • 9~11:自增值

MongoDB 方案的優缺點:

  • 優點:效能不錯並且生成的 ID 是有序遞增的
  • 缺點:需要解決重複 ID 問題(當機器時間不對的情況下,可能導致會產生重複 ID)、有安全性問題(ID 生成有規律性)

演算法

UUID

UUID 是 Universally Unique Identifier(通用唯一識別符號) 的縮寫。UUID 包含 32 個 16 進位制數字(8-4-4-4-12)。

JDK 就提供了現成的生成 UUID 的方法,一行程式碼就行了。

//輸出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa
UUID.randomUUID()

RFC 4122[4] 中關於 UUID 的示例是這樣的:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

我們這裡重點關注一下這個 Version(版本),不同的版本對應的 UUID 的生成規則是不同的。

5 種不同的 Version(版本)值分別對應的含義(參考維基百科對於 UUID 的介紹[5]):

  • 版本 1 : UUID 是根據時間和節點 ID(通常是 MAC 地址)生成;
  • 版本 2 : UUID 是根據識別符號(通常是組或使用者 ID)、時間和節點 ID 生成;
  • 版本 3、版本 5 : 版本 5 - 確定性 UUID 透過雜湊(hashing)名字空間(namespace)識別符號和名稱生成;
  • 版本 4 : UUID 使用隨機性[6]偽隨機性[7]生成。

下面是 Version 1 版本下生成的 UUID 的示例:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

JDK 中透過 UUIDrandomUUID() 方法生成的 UUID 的版本預設為 4。

UUID uuid = UUID.randomUUID();
int version = uuid.version();// 4

另外,Variant(變體)也有 4 種不同的值,這種值分別對應不同的含義。這裡就不介紹了,貌似平時也不怎麼需要關注。

需要用到的時候,去看看維基百科對於 UUID 的 Variant(變體) 相關的介紹即可。

從上面的介紹中可以看出,UUID 可以保證唯一性,因為其生成規則包括 MAC 地址、時間戳、名字空間(Namespace)、隨機或偽隨機數、時序等元素,計算機基於這些規則生成的 UUID 是肯定不會重複的。

雖然,UUID 可以做到全域性唯一性,但是,我們一般很少會使用它。

比如使用 UUID 作為 MySQL 資料庫主鍵的時候就非常不合適:

  • 資料庫主鍵要儘量越短越好,而 UUID 的消耗的儲存空間比較大(32 個字串,128 位)。
  • UUID 是無順序的,InnoDB 引擎下,資料庫主鍵的無序性會嚴重影響資料庫效能。

最後,我們再簡單分析一下 UUID 的優缺點 (面試的時候可能會被問到的哦!) :

  • 優點:生成速度比較快、簡單易用
  • 缺點:儲存消耗空間大(32 個字串,128 位)、 不安全(基於 MAC 地址生成 UUID 的演算法會造成 MAC 地址洩露)、無序(非自增)、沒有具體業務含義、需要解決重複 ID 問題(當機器時間不對的情況下,可能導致會產生重複 ID)

Snowflake(雪花演算法)

Snowflake 是 Twitter 開源的分散式 ID 生成演算法。Snowflake 由 64 bit 的二進位制數字組成,這 64bit 的二進位制被分成了幾部分,每一部分儲存的資料都有特定的含義:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?
  • sign(1bit):符號位(標識正負),始終為 0,代表生成的 ID 為正數。
  • timestamp (41 bits):一共 41 位,用來表示時間戳,單位是毫秒,可以支撐 2 ^41 毫秒(約 69 年)
  • datacenter id + worker id (10 bits):一般來說,前 5 位表示機房 ID,後 5 位表示機器 ID(實際專案中可以根據實際情況調整)。這樣就可以區分不同叢集/機房的節點。
  • sequence (12 bits):一共 12 位,用來表示序列號。序列號為自增值,代表單臺機器每毫秒能夠產生的最大 ID 數(2^12 = 4096),也就是說單臺機器每毫秒最多可以生成 4096 個 唯一 ID。

在實際專案中,我們一般也會對 Snowflake 演算法進行改造,最常見的就是在 Snowflake 演算法生成的 ID 中加入業務型別資訊。

我們再來看看 Snowflake 演算法的優缺點:

  • 優點:生成速度比較快、生成的 ID 有序遞增、比較靈活(可以對 Snowflake 演算法進行簡單的改造比如加入業務 ID)
  • 缺點:需要解決重複 ID 問題(ID 生成依賴時間,在獲取時間的時候,可能會出現時間回撥的問題,也就是伺服器上的時間突然倒退到之前的時間,進而導致會產生重複 ID)、依賴機器 ID 對分散式環境不友好(當需要自動啟停或增減機器時,固定的機器 ID 可能不夠靈活)。

如果你想要使用 Snowflake 演算法的話,一般不需要你自己再造輪子。有很多基於 Snowflake 演算法的開源實現比如美團 的 Leaf、百度的 UidGenerator(後面會提到),並且這些開源實現對原有的 Snowflake 演算法進行了最佳化,效能更優秀,還解決了 Snowflake 演算法的時間回撥問題和依賴機器 ID 的問題。

並且,Seata 還提出了“改良版雪花演算法”,針對原版雪花演算法進行了一定的最佳化改良,解決了時間回撥問題,大幅提高的 QPS。具體介紹和改進原理,可以參考下面這兩篇文章:

  • Seata 基於改良版雪花演算法的分散式 UUID 生成器分析[8]
  • 在開源專案中看到一個改良版的雪花演算法,現在它是你的了。[9]

開源框架

UidGenerator(百度)

UidGenerator[10] 是百度開源的一款基於 Snowflake(雪花演算法)的唯一 ID 生成器。

不過,UidGenerator 對 Snowflake(雪花演算法)進行了改進,生成的唯一 ID 組成如下:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?
  • sign(1bit):符號位(標識正負),始終為 0,代表生成的 ID 為正數。
  • delta seconds (28 bits):當前時間,相對於時間基點"2016-05-20"的增量值,單位:秒,最多可支援約 8.7 年
  • worker id (22 bits):機器 id,最多可支援約 420w 次機器啟動。內建實現為在啟動時由資料庫分配,預設分配策略為用後即棄,後續可提供複用策略。
  • sequence (13 bits):每秒下的併發序列,13 bits 可支援每秒 8192 個併發。

可以看出,和原始 Snowflake(雪花演算法)生成的唯一 ID 的組成不太一樣。並且,上面這些引數我們都可以自定義。

UidGenerator 官方文件中的介紹如下:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

自 18 年後,UidGenerator 就基本沒有再維護了,我這裡也不過多介紹。想要進一步瞭解的朋友,可以看看 UidGenerator 的官方介紹[11]

Leaf(美團)

Leaf[12] 是美團開源的一個分散式 ID 解決方案 。這個專案的名字 Leaf(樹葉) 起源於德國哲學家、數學家萊布尼茨的一句話:“There are no two identical leaves in the world”(世界上沒有兩片相同的樹葉) 。這名字起得真心挺不錯的,有點文藝青年那味了!

Leaf 提供了 號段模式Snowflake(雪花演算法) 這兩種模式來生成分散式 ID。並且,它支援雙號段,還解決了雪花 ID 系統時鐘回撥問題。不過,時鐘問題的解決需要弱依賴於 Zookeeper(使用 Zookeeper 作為註冊中心,透過在特定路徑下讀取和建立子節點來管理 workId) 。

Leaf 的誕生主要是為了解決美團各個業務線生成分散式 ID 的方法多種多樣以及不可靠的問題。

Leaf 對原有的號段模式進行改進,比如它這裡增加了雙號段避免獲取 DB 在獲取號段的時候阻塞請求獲取 ID 的執行緒。簡單來說,就是我一個號段還沒用完之前,我自己就主動提前去獲取下一個號段(圖片來自於美團官方文章:《Leaf——美團點評分散式 ID 生成系統》[13])。

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

根據專案 README 介紹,在 4C8G VM 基礎上,透過公司 RPC 方式呼叫,QPS 壓測結果近 5w/s,TP999 1ms。

Tinyid(滴滴)

Tinyid[14] 是滴滴開源的一款基於資料庫號段模式的唯一 ID 生成器。

資料庫號段模式的原理我們在上面已經介紹過了。Tinyid 有哪些亮點呢?

為了搞清楚這個問題,我們先來看看基於資料庫號段模式的簡單架構方案。(圖片來自於 Tinyid 的官方 wiki:《Tinyid 原理介紹》[15]

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

在這種架構模式下,我們透過 HTTP 請求向發號器服務申請唯一 ID。負載均衡 router 會把我們的請求送往其中的一臺 tinyid-server。

這種方案有什麼問題呢?在我看來(Tinyid 官方 wiki 也有介紹到),主要由下面這 2 個問題:

  • 獲取新號段的情況下,程式獲取唯一 ID 的速度比較慢。
  • 需要保證 DB 高可用,這個是比較麻煩且耗費資源的。

除此之外,HTTP 呼叫也存在網路開銷。

Tinyid 的原理比較簡單,其架構如下圖所示:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?

相比於基於資料庫號段模式的簡單架構方案,Tinyid 方案主要做了下面這些最佳化:

  • 雙號段快取:為了避免在獲取新號段的情況下,程式獲取唯一 ID 的速度比較慢。Tinyid 中的號段在用到一定程度的時候,就會去非同步載入下一個號段,保證記憶體中始終有可用號段。
  • 增加多 db 支援:支援多個 DB,並且,每個 DB 都能生成唯一 ID,提高了可用性。
  • 增加 tinyid-client:純本地操作,無 HTTP 請求消耗,效能和可用性都有很大提升。

Tinyid 的優缺點這裡就不分析了,結合資料庫號段模式的優缺點和 Tinyid 的原理就能知道。

IdGenerator(個人)

和 UidGenerator、Leaf 一樣,IdGenerator[16] 也是一款基於 Snowflake(雪花演算法)的唯一 ID 生成器。

IdGenerator 有如下特點:

  • 生成的唯一 ID 更短;
  • 相容所有雪花演算法(號段模式或經典模式,大廠或小廠);
  • 原生支援 C#/Java/Go/C/Rust/Python/Node.js/PHP(C 擴充套件)/SQL/ 等語言,並提供多執行緒安全呼叫動態庫(FFI);
  • 解決了時間回撥問題,支援手工插入新 ID(當業務需要在歷史時間生成新 ID 時,用本演算法的預留位能生成 5000 個每秒);
  • 不依賴外部儲存系統;
  • 預設配置下,ID 可用 71000 年不重複。

IdGenerator 生成的唯一 ID 組成如下:

京東二面:為什麼需要分散式ID?你專案中是怎麼做的?
  • timestamp (位數不固定):時間差,是生成 ID 時的系統時間減去 BaseTime(基礎時間,也稱基點時間、原點時間、紀元時間,預設值為 2020 年) 的總時間差(毫秒單位)。初始為 5bits,隨著執行時間而增加。如果覺得預設值太老,你可以重新設定,不過要注意,這個值以後最好不變。
  • worker id (預設 6 bits):機器 id,機器碼,最重要引數,是區分不同機器或不同應用的唯一 ID,最大值由 WorkerIdBitLength(預設 6)限定。如果一臺伺服器部署多個獨立服務,需要為每個服務指定不同的 WorkerId。
  • sequence (預設 6 bits):序列數,是每毫秒下的序列數,由引數中的 SeqBitLength(預設 6)限定。增加 SeqBitLength 會讓效能更高,但生成的 ID 也會更長。

Java 語言使用示例:。

總結

透過這篇文章,我基本上已經把最常見的分散式 ID 生成方案都總結了一波。

除了上面介紹的方式之外,像 ZooKeeper 這類中介軟體也可以幫助我們生成唯一 ID。沒有銀彈,一定要結合實際專案來選擇最適合自己的方案。

不過,本文主要介紹的是分散式 ID 的理論知識。在實際的面試中,面試官可能會結合具體的業務場景來考察你對分散式 ID 的設計,你可以參考這篇文章:分散式 ID 設計指南[17](對於實際工作中分散式 ID 的設計也非常有幫助)。

    參考資料

    [1]

    Tinyid: %E5%8E%9F%E7%90%86%E4%BB%8B%E7%BB%8D

    [2]

    Codis:

    [3]

    Redis 持久化機制詳解:

    [4]

    RFC 4122:

    [5]

    維基百科對於 UUID 的介紹: 通用唯一識別碼

    [6]

    隨機性: 隨機性

    [7]

    偽隨機性: 偽隨機性

    [8]

    Seata 基於改良版雪花演算法的分散式 UUID 生成器分析: https://seata.io/zh-cn/blog/seata-analysis-UUID-generator.html

    [9]

    在開源專案中看到一個改良版的雪花演算法,現在它是你的了。: https://www.cnblogs.com/thisiswhy/p/17611163.html

    [10]

    UidGenerator:

    [11]

    UidGenerator 的官方介紹: /blob/master/README.zh_cn.md

    [12]

    Leaf:

    [13]

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

    [14]

    Tinyid:

    [15]

    《Tinyid 原理介紹》: %E5%8E%9F%E7%90%86%E4%BB%8B%E7%BB%8D

    [16]

    IdGenerator:

    [17]

    分散式 ID 設計指南:

    來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2999086/,如需轉載,請註明出處,否則將追究法律責任。

    相關文章