一文詳解分散式 ID

fuxing.發表於2024-06-19

前言

分散式系統中,我們經常需要對資料、訊息等進行唯一標識,這個唯一標識就是分散式 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_idstep用於獲取批次的 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).

相關文章