前言
分散式系統中,我們經常需要對資料、訊息等進行唯一標識,這個唯一標識就是分散式 ID,那麼我們如何設計它呢?本文將詳細講述分散式 ID 及其生成方案。
一、為什麼需要分散式 ID
目前大部分的系統都已是分散式系統,所以在這種場景的業務開發中,經常會需要唯一 ID 對資料進行標識,比如使用者身份標識、訊息標識等等。
並且在資料量達到一定規模後,大部分的系統也需要進行分庫分表,這種場景下單庫的自增 ID 已達不到我們的預期。所以我們需要分散式 ID 來對各種場景的資料進行唯一標識。
二、分散式 ID 的特性
主要特性:
- 全域性唯一:分散式 ID 最基本要求,必須全域性唯一。
- 高可用:高併發下要保證 ID 的生成效率,避免影響系統。
- 易用性:使用簡單,可快速接入。
除此之外根據不同場景還有:
- 有序性:資料庫場景下的主鍵 ID,有序性可便於資料寫入和排序。
- 安全性:無規則 ID,一般用於避免業務資訊洩露場景,如訂單量。
二、分散式 ID 常見生成方案
1. UUID
UUID(Universally Unique Identifier),即通用唯一識別碼。UUID 的目的是讓分散式系統中的所有元素都能有唯一的識別資訊。
UUID 是由128位二進位制陣列成,通常表示為32個十六進位制字元,形如:
550e8400-e29b-41d4-a716-446655440000
這個字串由五個部分組成,以連字元-
分隔開,具體如下:
部分 | 大小 | 說明 |
---|---|---|
時間戳 | 32 bits | UUID的前32位表示當前的時間戳。 |
時鐘序列和隨機數 | 16 bits | 用於保證在同一時刻生成的UUID的唯一性。 |
變體標識 | 4 bits | 標識 UUID 的變體,通常為固定值,表示是由 RFC 4122 定義。 |
版本號 | 4 bits | 標識UUID的版本,常見版本有1、3、4和5 |
節點 | 48 bits | 在版本 1 中,這部分包含生成 UUID 的計算機的唯一標識。 |
主要的 UUID 版本及其生成規則:
版本 | 場景 | 生成規則 |
---|---|---|
版本 1 | 基於時間和節點 | 由當前的時間戳和節點資訊生成。包括時間戳、時鐘序列、節點標識。 |
版本 2 | 基於DCE安全識別符號 | 類似版本 1,但在時間戳部分包含 POSIX UID/GID 資訊。 |
版本 3 | 基於名字和雜湊值(MD5 版) | 由名稱空間和名字的MD5雜湊生成。 |
版本 4 | 完全隨機生成 | 透過隨機或偽隨機生成128位數字。 |
版本 5 | 基於名字和雜湊值(SHA-1 版) | 透過隨機或偽隨機生成128位數字。 |
Java中 UUID 對版本 4 進行了實現:
public static void main (String[] args) {
// 預設版本 4
System.out.println(UUID.randomUUID());
// 版本 3,由名稱空間和名字的MD5雜湊生成,相同名稱空間結果相同
// 如下,"fuxing"返回的UUID一直為8b9b6bc3-90c8-37ef-bbef-0ed0c552718f
System.out.println(UUID.nameUUIDFromBytes("fuxing".getBytes()));
}
優點: 本地生成,沒有網路消耗,效能非常高。
缺點:
- 佔用空間大,32 個字串,128 位。
- 不安全:版本 1 可能會造成 mac 地址洩露。
- 無序,非自增。
- 機器時間不對,可能造成 UUID 重複。
2. 資料庫自增 ID
實現簡單,解釋透過資料庫表中的主鍵 ID 自增來生成唯一標識。如下,維護一個 MySQL 表來生成 ID。
CREATE TABLE `unique_id` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`value` char(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
當需要生成分散式 ID 時,向表中插入資料並返回主鍵 ID,這裡 value 無含義,只是為了佔位,方便插入資料。
優點: 實現簡單,基本滿足業務需求,且天然有序。
缺點:
- 資料庫自身的單點故障和資料一致性問題。
- 不安全,比如可根據自增來判斷訂單量。
- 高併發場景可能會受限於資料庫瓶頸。
那麼有沒有辦法解決資料庫自增 ID 的缺點呢?
透過水平拆分的方案,將表設定到不同的資料庫中,設定不同的起始值和步長,這樣可以有效的生成叢集中的唯一 ID,也大大降低 ID 生成資料庫操作的負載,示例如下。
unique_id_1
配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步長
unique_id_2
配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步長
這個還是需要根據自己的業務需求來,水平擴充套件的叢集數量要符合自己的資料量,因為當設定的叢集數量不足以滿足高併發時,再次進行擴容叢集會很麻煩。多臺機器的起始值和步長都需要重新配置。
3. 資料庫號段模式
號段模式是當下分散式 ID 生成器的主流實現方式之一,比如美團 Leaf-segment、滴滴 Tinyid、微信序列號等都使用的該方案,下面的大廠中介軟體中會展開說明。
號段模式也是基於資料庫的自增 ID,資料庫自增 ID 是一次性獲取一個分散式 ID,號段模式可以理解成從資料庫批次獲取 ID,然後將 ID 快取在本地,以此來提高業務獲取 ID 的效率。
例如,每次從資料庫獲取 ID 時,獲取一個號段,如(1,1000],這個範圍表示 1000 個 ID,業務應用在請求獲取 ID 時,只需要在本地從 1 開始自增並返回,而不用每次去請求資料庫,一直到本地自增到 1000 時,才去資料庫重新獲取新的號段,後續流程迴圈往復。
表結構可進行如下設計:
CREATE TABLE `id_generator` (
`id` int(10) NOT NULL,
`max_id` bigint(20) NOT NULL COMMENT '當前最大id',
`step` int(10) NOT NULL COMMENT '號段的步長',
`version` int(20) NOT NULL COMMENT '版本號',
`biz_type` int(20) NOT NULL COMMENT '業務場景',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
其中max_id
和step
用於獲取批次的 ID,version
是一個樂觀鎖,保證併發時資料的正確性。
比如,我們新增一條表資料如下。
id | max_id | step | version | biz_type |
---|---|---|---|---|
1 | 100 | 1000 | 0 | 001 |
然後我們可以使用該號段批次生成的 ID,當max_id = 1000
,則執行 update 操作生成新的號段。新的號段的 SQL。
UPDATE
id_generator
SET
max_id = #{max_id+ step},
version = version + 1
WHERE
version = #{version} AND biz_type = 001;
優點: ID 有序遞增、儲存消耗空間小。
缺點:
- 資料庫自身的單點故障和資料一致性問題。
- 不安全,比如可根據自增來判斷訂單量。
- 高併發場景可能會受限於資料庫瓶頸。
缺點同樣可以透過叢集的方式進行最佳化,也可以如Tinyid 採用雙快取進行最佳化,下面的大廠中介軟體中會展開說明。
4. 基於Redis
當使用資料庫來生成 ID 效能不夠的時候,可以嘗試使用 Redis 來生成 ID。原理則是利用 Redis 的原子操作 INCR 和 INCRBY 來實現。
命令示例:
命令 | 說明 | 示例 |
---|---|---|
INCR | 讓一個整形的key自增1 | redis> SET mykey "10" "OK" redis> INCR mykey (integer) 11 |
INCRBY | 讓一個整形的key自增並指定步長 | redis> SET mykey "10" "OK" redis> INCRBY mykey 5 (integer) 15 |
優點: 不依賴於資料庫,使用靈活,支援高併發。
缺點:
- 系統須引入 Redis 資料庫。
- 不安全,比如可根據自增來判斷訂單量。
- Redis 出現單點故障問題,可能會丟資料導致 ID 重複。
5. 雪花演算法
雪花演算法(Snowflake)是 twitter 公司內部分散式專案採用的 ID 生成演算法。結果是一個 long 型的 ID。Snowflake 演算法將 64bit 劃分為多段,分開來標識機器、時間等資訊,具體組成結構如下圖所示:
結構說明:
結構 | 大小 | 說明 |
---|---|---|
符號位 | 1 bit | 0 表示正數,1 表示負數。 |
時間戳 | 41 bits | 儲存的是當前時間戳 - 開始時間戳 ,最長 69 年。 |
機器位 | 10 bits | 前 5位 datacenterId,後 5 位 workerId ,最多表示 1024 臺。 |
序列號 | 12 bits | 毫秒內的流水號,意味著每個節點在每毫秒可以產生 4096 個 ID。 |
優點:
穩定性高,不依賴於資料庫等第三方系統。
使用靈活方便,可以根據業務需求的特性來調整演算法中的 bit 位。
單機上 ID 單調自增,毫秒數在高位,自增序列在低位,整體上按照時間自增排序。
缺點:
- 強依賴機器時鐘,如果機器上時鐘回撥,可能導致發號重複或者服務處於不可用狀態。
- ID 可能不是全域性遞增,雖然 ID 在單機上是遞增的。
- Redis 出現單點故障問題,可能會丟資料導致 ID 重複。
演算法實現(Java):
package util;
import java.util.Date;
/**
* @ClassName: SnowFlakeUtil
* @Author: jiaoxian
* @Date: 2022/4/24 16:34
* @Description:
*/
public class SnowFlakeUtil {
private static SnowFlakeUtil snowFlakeUtil;
static {
snowFlakeUtil = new SnowFlakeUtil();
}
// 初始時間戳(紀年),可用雪花演算法服務上線時間戳的值
// 1650789964886:2022-04-24 16:45:59
private static final long INIT_EPOCH = 1650789964886L;
// 時間位取&
private static final long TIME_BIT = 0b1111111111111111111111111111111111111111110000000000000000000000L;
// 記錄最後使用的毫秒時間戳,主要用於判斷是否同一毫秒,以及用於伺服器時鐘回撥判斷
private long lastTimeMillis = -1L;
// dataCenterId佔用的位數
private static final long DATA_CENTER_ID_BITS = 5L;
// dataCenterId佔用5個位元位,最大值31
// 0000000000000000000000000000000000000000000000000000000000011111
private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS);
// dataCenterId
private long dataCenterId;
// workId佔用的位數
private static final long WORKER_ID_BITS = 5L;
// workId佔用5個位元位,最大值31
// 0000000000000000000000000000000000000000000000000000000000011111
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
// workId
private long workerId;
// 最後12位,代表每毫秒內可產生最大序列號,即 2^12 - 1 = 4095
private static final long SEQUENCE_BITS = 12L;
// 掩碼(最低12位為1,高位都為0),主要用於與自增後的序列號進行位與,如果值為0,則代表自增後的序列號超過了4095
// 0000000000000000000000000000000000000000000000000000111111111111
private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
// 同一毫秒內的最新序號,最大值可為 2^12 - 1 = 4095
private long sequence;
// workId位需要左移的位數 12
private static final long WORK_ID_SHIFT = SEQUENCE_BITS;
// dataCenterId位需要左移的位數 12+5
private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
// 時間戳需要左移的位數 12+5+5
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;
/**
* 無參構造
*/
public SnowFlakeUtil() {
this(1, 1);
}
/**
* 有參構造
* @param dataCenterId
* @param workerId
*/
public SnowFlakeUtil(long dataCenterId, long workerId) {
// 檢查dataCenterId的合法值
if (dataCenterId < 0 || dataCenterId > MAX_DATA_CENTER_ID) {
throw new IllegalArgumentException(
String.format("dataCenterId 值必須大於 0 並且小於 %d", MAX_DATA_CENTER_ID));
}
// 檢查workId的合法值
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException(String.format("workId 值必須大於 0 並且小於 %d", MAX_WORKER_ID));
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
}
/**
* 獲取唯一ID
* @return
*/
public static Long getSnowFlakeId() {
return snowFlakeUtil.nextId();
}
/**
* 透過雪花演算法生成下一個id,注意這裡使用synchronized同步
* @return 唯一id
*/
public synchronized long nextId() {
long currentTimeMillis = System.currentTimeMillis();
System.out.println(currentTimeMillis);
// 當前時間小於上一次生成id使用的時間,可能出現伺服器時鐘回撥問題
if (currentTimeMillis < lastTimeMillis) {
throw new RuntimeException(
String.format("可能出現伺服器時鐘回撥問題,請檢查伺服器時間。當前伺服器時間戳:%d,上一次使用時間戳:%d", currentTimeMillis,
lastTimeMillis));
}
if (currentTimeMillis == lastTimeMillis) {
// 還是在同一毫秒內,則將序列號遞增1,序列號最大值為4095
// 序列號的最大值是4095,使用掩碼(最低12位為1,高位都為0)進行位與執行後如果值為0,則自增後的序列號超過了4095
// 那麼就使用新的時間戳
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
currentTimeMillis = getNextMillis(lastTimeMillis);
}
} else { // 不在同一毫秒內,則序列號重新從0開始,序列號最大值為4095
sequence = 0;
}
// 記錄最後一次使用的毫秒時間戳
lastTimeMillis = currentTimeMillis;
// 核心演算法,將不同部分的數值移動到指定的位置,然後進行或執行
// <<:左移運算子, 1 << 2 即將二進位制的 1 擴大 2^2 倍
// |:位或運算子, 是把某兩個數中, 只要其中一個的某一位為1, 則結果的該位就為1
// 優先順序:<< > |
return
// 時間戳部分
((currentTimeMillis - INIT_EPOCH) << TIMESTAMP_SHIFT)
// 資料中心部分
| (dataCenterId << DATA_CENTER_ID_SHIFT)
// 機器表示部分
| (workerId << WORK_ID_SHIFT)
// 序列號部分
| sequence;
}
/**
* 獲取指定時間戳的接下來的時間戳,也可以說是下一毫秒
* @param lastTimeMillis 指定毫秒時間戳
* @return 時間戳
*/
private long getNextMillis(long lastTimeMillis) {
long currentTimeMillis = System.currentTimeMillis();
while (currentTimeMillis <= lastTimeMillis) {
currentTimeMillis = System.currentTimeMillis();
}
return currentTimeMillis;
}
/**
* 獲取隨機字串,length=13
* @return
*/
public static String getRandomStr() {
return Long.toString(getSnowFlakeId(), Character.MAX_RADIX);
}
/**
* 從ID中獲取時間
* @param id 由此類生成的ID
* @return
*/
public static Date getTimeBySnowFlakeId(long id) {
return new Date(((TIME_BIT & id) >> 22) + INIT_EPOCH);
}
public static void main(String[] args) {
SnowFlakeUtil snowFlakeUtil = new SnowFlakeUtil();
long id = snowFlakeUtil.nextId();
System.out.println(id);
Date date = SnowFlakeUtil.getTimeBySnowFlakeId(id);
System.out.println(date);
long time = date.getTime();
System.out.println(time);
System.out.println(getRandomStr());
}
}
四、大廠中介軟體
1. 美團 Leaf
Leaf 的官方文件,簡介和特性可訪問了解,這裡我將對 Leaf 的兩種方案,Leaf segment 和 Leaf-snowflake 進行。
1.1. Leaf segment
基於資料庫號段模式的 ID 生成方案,上面我們介紹到普通的號段模式有一些缺點:
- 資料庫自身的單點故障和資料一致性問題。
- 不安全,比如可根據自增來判斷訂單量。
- 高併發場景可能會受限於資料庫瓶頸。
那麼 Leaf 是如何做的呢?Leaf 採用了預分發的方式生成ID,也就是在 DB 之上掛 n 個 Leaf Server,每個Server啟動時,都會去 DB 拿固定長度的 ID List。
這樣就做到了完全基於分散式的架構,同時因為ID是由記憶體分發,所以也可以做到很高效,處理流程圖如下:
其中:
- Leaf Server 1:從 DB 載入號段[1,1000]。
- Leaf Server 2:從 DB 載入號段[1001,2000]。
- Leaf Server 3:從 DB 載入號段[2001,3000]。
使用者透過輪詢的方式呼叫 Leaf Server 的各個服務,所以某一個 Client 獲取到的ID序列可能是:1,1001,2001,2,1002,2002。當某個 Leaf Server 號段用完之後,下一次請求就會從 DB 中載入新的號段,這樣保證了每次載入的號段是遞增的。
為了解決在更新DB號段的時出現的耗時和阻塞服務的問題,Leaf 採用了非同步更新的策略,同時透過雙快取的方式,保證無論何時DB出現問題,都能有一個 Buffer 的號段可以正常對外提供服務,只要 DB 在一個 Buffer 的下發的週期內恢復,就不會影響整個 Leaf 的可用性。
除此之外,Leaf 還透過動態調整步長,解決由於步長固定導致的快取中的 ID 被過快消耗問題,以及步長設定過大導致的號段 ID 跨度過大問題,具體公式可去官方文件中瞭解。
對於資料一致性問題,Leaf 目前是透過中介軟體 Zebra 加 MHA 做的主從切換。
1.2. Leaf Snowflake
Leaf-snowflake 方案沿用 Snowflake 方案的 bit 位設計。
對於 workerID 的分配:當服務叢集較小時,透過配置即可;服務叢集較大時,基於 zookeeper 持久順序節點的特性引入 zookeeper 元件配置 workerID。架構如下圖所示:
2. 百度 UidGenerator
開源地址
UidGenerator 方案是基於 Snowflake 演算法的 ID 生成器,其對雪花演算法的 bit 位的分配做了微調。
結構說明(引數可在 Spring Bean 配置中進行配置):
結構 | 大小 | 說明 |
---|---|---|
符號位 | 1 bit | 最高位始終是 0。 |
增量秒 | 28 bits | 表示自客戶紀元(2016-05-20)以來的增量秒。最大時間為 8.7 年。 |
工作節點 | 22 bits | 表示工作節點 ID,最大值為 4.2 百萬個。 |
序列號 | 13 bits | 表示一秒鐘內的序列,預設情況下每秒最多 8192 個。 |
UidGenerator 方案包含兩種實現方式,DefaultUidGenerator 和 CachedUidGenerator ,效能要求高的情況下推薦 CachedUidGenerator。
3. 滴滴 Tinyid
Tinyid 官方文件
Tinyid 方案是在 Leaf-segment 的演算法基礎上升級而來,不僅支援了資料庫多主節點模式,還提供了 tinyid-client 客戶端的接入方式,使用起來更加方便。
Tinyid 也是採用了非同步加雙快取策略,首先可用號段載入到記憶體中,並在記憶體中生成 ID,可用號段在首次獲取 ID 時載入,號段用到一定程度的時候,就去非同步載入下一個號段,保證記憶體中始終有可用號段,則可避免效能波動。
實現原理如下所示:
五、結語
本文對分散式 ID 以及其場景的生成方案做了介紹,還針對一下大廠的中介軟體進行簡單分析,中介軟體的接入程式碼本文並沒有做詳細介紹,但是官方文件的連結都帖子了每個子標題下,其中都有詳細介紹。
文中還針對每個生成方案的優缺點作出了說明,具體的使用可針對優缺點加上業務需求來進行選型。
參考:
[1] 騰訊技術工程. 分散式唯一 ID 生成方案淺談.
[2] 孟斯特. UUID 介紹.
[3] 文丑顏不良啊. 雪花演算法(SnowFlake).