分佈式唯一 ID 之 Snowflake 算法

一、Snowflake 簡介

1.1 什麼是 Snowflake

Snowflake is a service used to generate unique IDs for objects within Twitter (Tweets, Direct Messages, Users, Collections, Lists etc.). These IDs are unique 64-bit unsigned integers, which are based on time, instead of being sequential. The full ID is composed of a timestamp, a worker number, and a sequence number. When consuming the API using JSON, it is important to always use the field id_str instead of id. This is due to the way Javascript and other languages that consume JSON evaluate large integers. If you come across a scenario where it doesn’t appear that id and id_str match, it’s due to your environment having already parsed the id integer, munging the number in the process. —— developer.twitter.com

Snowflake(雪花) 是一項服務,用於爲 Twitter 內的對象(推文,直接消息,用戶,集合,列表等)生成唯一的 ID。這些 IDs 是唯一的 64 位無符號整數,它們基於時間,而不是順序的。完整的 ID 由時間戳,工作機器編號和序列號組成。當在 API 中使用 JSON 數據格式時,請務必始終使用 id_str 字段而不是 id,這一點很重要。這是由於處理JSON 的 Javascript 和其他語言計算大整數的方式造成的。如果你遇到 id 和 id_str 似乎不匹配的情況,這是因爲你的環境已經解析了 id 整數,並在處理的過程中仔細分析了這個數字。

在 JavaScript 中,Number 基本類型可以精確表示的最大整數是 2^53。因此如果直接使用 Number 來表示 64 位的 Snowflake ID 肯定是行不通的。所以 Twitter 工程師們讓我們務必使用 id_str 字段即通過字符串來表示生成的 ID。當然這個問題不僅僅存在於使用 Snowflake ID 的場景,爲了解決 JavaScript 不能安全存儲和操作大整數的問題,BigInt 這個救星出現了,它是一種內置對象,可以表示大於 2^53 的整數,甚至是任意大的整數。

BigInt 現在處在 ECMAScript 標準化過程中的 第三階段

當它進入第四階段草案,也就是最終標準時, BigInt 將成爲 Javacript 中的第二種內置數值類型。

BigInt 可能會成爲自 ES2015 引入 Symbol 之後,增加的第一個新的內置類型。

1.2 Snowflake 算法

下圖是 Snowflake 算法的 ID 構成圖:

snowflake-64bit.jpg

  • 1 位標識部分,該位不用主要是爲了保持 ID 的自增特性,若使用了最高位,int64_t 會表示爲負數。在 Java 中由於 long 類型的最高位是符號位,正數是 0,負數是 1,一般生成的 ID 爲正整數,所以最高位爲 0;
  • 41 位時間戳部分,這個是毫秒級的時間,一般實現上不會存儲當前的時間戳,而是時間戳的差值(當前時間減去固定的開始時間),這樣可以使產生的 ID 從更小值開始;41 位的時間戳可以使用 69 年,(1L << 41) / (1000L 60 60 24 365) = (2199023255552 / 31536000000) ≈ 69.73 年;
  • 10 位工作機器 ID 部分,Twitter 實現中使用前 5 位作爲數據中心標識,後 5 位作爲機器標識,可以部署 1024 (2^10)個節點;
  • 12 位序列號部分,支持同一毫秒內同一個節點可以生成 4096 (2^12)個 ID;

Snowflake 算法生成的 ID 大致上是按照時間遞增的,用在分佈式系統中時,需要注意數據中心標識和機器標識必須唯一,這樣就能保證每個節點生成的 ID 都是唯一的。我們不一定需要像 Twitter 那樣使用 5 位作爲數據中心標識,另 5 位作爲機器標識,可以根據我們業務的需要,靈活分配工作機器 ID 部分。比如:若不需要數據中心,完全可以使用全部 10 位作爲機器標識;若數據中心不多,也可以只使用 3 位作爲數據中心,7 位作爲機器標識。

二、Snowflake 解惑

以下問題來源於漫漫路博客 - “Twitter-Snowflake,64 位自增ID算法詳解” 評論區

2.1 既然是 64 位,爲何第一位不使用?

首位不用主要是爲了保持 ID 的自增特性,若使用了最高位,int64_t 會表示爲負數。在 Java 中由於 long 類型的最高位是符號位,正數是 0,負數是 1,一般生成的 ID 爲正整數,所以最高位爲 0。

2.2 怎麼生成 41 位的時間戳?

41 位的時間戳,這個是毫秒級的時間,一般實現上不會存儲當前的時間戳,而是時間戳的差值(當前時間減去固定的開始時間)。41 位只是預留位(主要目的是約定使用年限,固定的開始時間),不用的位數填 0 就好了。

2.3 工作機器 id 如果使用 MAC 地址的話,怎麼轉成 10 bit?

網絡中每臺設備都有一個唯一的網絡標識,這個地址叫 MAC 地址或網卡地址,由網絡設備製造商生產時寫在硬件內部。MAC 地址則是 48 位的(6 個字節),通常表示爲 12 個 16 進制數,每 2 個 16 進制數之間用冒號隔開,如08:00:20:0A:8C:6D 就是一個 MAC 地址。

具體如下圖所示,其前 3 字節表示OUI(Organizationally Unique Identifier),是 IEEE (電氣和電子工程師協會)區分不同的廠家,後 3 字節由廠家自行分配。

mac-address.png

(圖片來源 - 百度百科)

很明顯 Mac 地址是 48 位,而我們的工作機器 ID 部分只有 10 位,因此並不能直接使用 Mac 地址作爲工作機器 ID。若要選用 Mac 地址的話,還需使用一個額外的工作機器 ID 分配器,用來實現 ID 與 Mac 地址間的唯一映射。

2.4 怎麼生成 12 bit 的序列號?

序列號不需要全局維護,在 Java 中可以使用 AtomicInteger(保證線程安全) 從 0 開始自增。當序列號超過了 4096,序列號在這一毫秒就用完了,等待下一個毫秒歸 0 重置就可以了。

三、Snowflake 優缺點

理論上 Snowflake 方案的 QPS 約爲 409.6w/s(1000 * 2^12),這種分配方式可以保證在任何一個 IDC 的任何一臺機器在任意毫秒內生成的 ID 都是不同的。

3.1 優點

  • 毫秒數在高位,自增序列在低位,整個 ID 都是趨勢遞增的。趨勢遞增的目的是:在 MySQL InnoDB 引擎中使用的是聚集索引,由於多數 RDBMS 使用 B-tree 的數據結構來存儲索引數據,在主鍵的選擇上面我們應該儘量使用有序的主鍵保證寫入性能。
  • 不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成 ID 的性能也是非常高的。
  • 可以根據自身業務特性分配 bit 位,非常靈活。

3.2 缺點

  • 強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。

除了時鐘回撥問題之外,Snowflake 算法會存在併發限制,當然對於這些問題,以本人目前的 Java 功力根本解決不了。但這並不影響我們使用它。在實際項目中我們可以使用基於 Snowflake 算法的開源項目,比如百度的 UidGenerator 或美團的 Leaf。下面我們簡單介紹一下這兩個項目,感興趣的小夥伴可以自行查閱相關資料。

3.3 UidGenerator

UidGenerator 是 Java 實現的,基於 Snowflake 算法的唯一 ID 生成器。UidGenerator 以組件形式工作在應用項目中,支持自定義 workerId 位數和初始化策略,從而適用於 docker 等虛擬化環境下實例自動重啓、漂移等場景。 在實現上,UidGenerator 通過借用未來時間來解決 sequence 天然存在的併發限制;採用 RingBuffer 來緩存已生成的 UID,並行化 UID 的生產和消費,同時對 CacheLine 補齊,避免了由 RingBuffer 帶來的硬件級「僞共享」問題。最終單機 QPS 可達 600 萬。

依賴版本:Java8 及以上版本,MySQL (內置 WorkerID 分配器,啓動階段通過 DB 進行分配;如自定義實現,則 DB非必選依賴)。

3.4 Leaf

Leaf 最早期需求是各個業務線的訂單 ID 生成需求。在美團早期,有的業務直接通過 DB 自增的方式生成 ID,有的業務通過 Redis 緩存來生成 ID,也有的業務直接用 UUID 這種方式來生成 ID。以上的方式各自有各自的問題,因此我們決定實現一套分佈式 ID 生成服務來滿足需求。具體 Leaf 設計文檔見: Leaf 美團分佈式ID生成服務

目前 Leaf 覆蓋了美團點評公司內部金融、餐飲、外賣、酒店旅遊、貓眼電影等衆多業務線。在 4C8G VM 基礎上,通過公司 RPC 方式調用,QPS 壓測結果近5w/s,TP999 1ms。

四、Snowflake 算法實現

在前面 Snowflake 知識的基礎上,現在我們來分析一下 Github 上 beyondfengyu 大佬基於 Java 實現的 SnowFlake,完整代碼如下:

/**
 * twitter的snowflake算法 -- java實現
 * 
 * @author beyond
 * @date 2016/11/26
 */
public class SnowFlake {

    /**
     * 起始的時間戳
     */
    private final static long START_STMP = 1480166465631L;

    /**
     * 每一部分佔用的位數
     */
    private final static long SEQUENCE_BIT = 12; //序列號佔用的位數
    private final static long MACHINE_BIT = 5;   //機器標識佔用的位數
    private final static long DATACENTER_BIT = 5;//數據中心佔用的位數

    /**
     * 每一部分的最大值
     */
    private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
    private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
    private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);

    /**
     * 每一部分向左的位移
     */
    private final static long MACHINE_LEFT = SEQUENCE_BIT;
    private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
    private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;

    private long datacenterId;  //數據中心
    private long machineId;     //機器標識
    private long sequence = 0L; //序列號
    private long lastStmp = -1L;//上一次時間戳

    public SnowFlake(long datacenterId, long machineId) {
        if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
            throw new IllegalArgumentException(
              "datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
        }
        if (machineId > MAX_MACHINE_NUM || machineId < 0) {
            throw new IllegalArgumentException(
              "machineId can't be greater than MAX_MACHINE_NUM or less than 0");
        }
        this.datacenterId = datacenterId;
        this.machineId = machineId;
    }

    /**
     * 產生下一個ID
     *
     * @return
     */
    public synchronized long nextId() {
        long currStmp = getNewstmp();
        if (currStmp < lastStmp) {
            throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
        }

        if (currStmp == lastStmp) {
            //相同毫秒內,序列號自增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            //同一毫秒的序列數已經達到最大
            if (sequence == 0L) {
                currStmp = getNextMill();
            }
        } else {
            //不同毫秒內,序列號置爲0
            sequence = 0L;
        }

        lastStmp = currStmp;

        return (currStmp - START_STMP) << TIMESTMP_LEFT //時間戳部分
                | datacenterId << DATACENTER_LEFT       //數據中心部分
                | machineId << MACHINE_LEFT             //機器標識部分
                | sequence;                             //序列號部分
    }

    private long getNextMill() {
        long mill = getNewstmp();
        while (mill <= lastStmp) {
            mill = getNewstmp();
        }
        return mill;
    }

    private long getNewstmp() {
        return System.currentTimeMillis();
    }

    public static void main(String[] args) {
        SnowFlake snowFlake = new SnowFlake(2, 3);

        for (int i = 0; i < (1 << 12); i++) {
            System.out.println(snowFlake.nextId());
        }

    }
}

在詳細分析之前,我們先來回顧一下 Snowflake 算法的 ID 構成圖:

snowflake-64bit.jpg

4.1 ID 位分配

首位不用,默認爲 0。41bit(第2-42位)時間戳,是相對時間戳,通過當前時間戳減去一個固定的歷史時間戳生成。在 SnowFlake 類定義了一個 long 類型的靜態變量 START_STMP,它的值爲 1480166465631L:

/**
 * 起始的時間戳:Sat Nov 26 2016 21:21:05 GMT+0800 (中國標準時間)
 */
private final static long START_STMP = 1480166465631L;

接着繼續定義三個 long 類型的靜態變量,來表示序列號和工作機器 ID 的佔用位數:

/**
 * 每一部分佔用的位數
 */
private final static long SEQUENCE_BIT = 12; //序列號佔用的位數
private final static long MACHINE_BIT = 5;   //機器標識佔用的位數
private final static long DATACENTER_BIT = 5;//數據中心佔用的位數

此外還定義了每一部分的最大值,具體如下:

/**
 * 每一部分的最大值
 */
private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT); // 31
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT); // 31
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT); // 4095

4.2 構造函數

SnowFlake 類的構造函數,該構造函數含有 datacenterId 和 machineId 兩個參數,它們分別表示數據中心 id 和機器標識:

private long datacenterId;  //數據中心
private long machineId;     //機器標識

public SnowFlake(long datacenterId, long machineId) {
        if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
            throw new IllegalArgumentException(
              "datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
        }
        if (machineId > MAX_MACHINE_NUM || machineId < 0) {
            throw new IllegalArgumentException(
              "machineId can't be greater than MAX_MACHINE_NUM or less than 0");
        }
        this.datacenterId = datacenterId;
        this.machineId = machineId;
}

4.3 生成 id

在 SnowFlake 類的實現中,在創建完 SnowFlake 對象之後,可以通過調用 nextId 方法來獲取 ID。有的小夥伴可能對位運算不太清楚,這裏先簡單介紹一下 nextId 方法中,所用到的位運算知識。

按位與運算符(&)

參加運算的兩個數據,按二進制位進行 “與” 運算,它的運算規則:

0&0=0;  0&1=0;  1&0=0;  1&1=1;

即兩位同時爲 1,結果才爲 1,否則爲 0。

  • 清零:如果想將一個單元清零,只需要將它與一個各位都爲零的數值相與即可。
  • 取一個數指定位的值:若需獲取某個數指定位的值,只需把該數與指定位爲 1,其餘位爲 0 所對應的數相與即可。

按位或運算(|)

參加運算的兩個對象,按二進制位進行 “或” 運算,它的運算規則:

0|0=0; 0|1=1; 1|0=1; 1|1=1;

即僅當兩位都爲 0 時,結果才爲 0。

左移運算符 <<

將一個運算對象的各二進制位全部左移若干位(左邊的二進制位丟棄,右邊補 0)。若左移時捨棄的高位不包含1,則每左移一位,相當於該數乘以 2。

在瞭解完位運算的相關知識後,我們再來看一下 nextId 方法的具體實現:

/**
 * 產生下一個ID
 *
 * @return
 */
public synchronized long nextId() {
  // 獲取當前的毫秒數:System.currentTimeMillis(),該方法產生一個當前的毫秒,這個毫秒
  // 其實就是自1970年1月1日0時起的毫秒數。
  long currStmp = getNewstmp();
  
  // private long lastTimeStamp = -1L; 表示上一次時間戳
  // 檢測是否出現時鐘回撥
  if (currStmp < lastStmp) {
     throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
  }

  // 相同毫秒內,序列號自增
  if (currStmp == lastStmp) {
     // private long sequence = 0L; 序列號
     // MAX_SEQUENCE =      4095 111111111111
     // MAX_SEQUENCE + 1 = 4096 1000000000000
     sequence = (sequence + 1) & MAX_SEQUENCE;
     // 同一毫秒的序列數已經達到最大
       if (sequence == 0L) {
           // 阻塞到下一個毫秒,獲得新的時間戳
           currStmp = getNextMill();
       }
     } else {
            //不同毫秒內,序列號置爲0
            sequence = 0L;
   }

   lastStmp = currStmp;
   
   // MACHINE_LEFT = SEQUENCE_BIT; -> 12
   // DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT; -> 17
   // TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT; -> 22
   return (currStmp - START_STMP) << TIMESTMP_LEFT //時間戳部分
                | datacenterId << DATACENTER_LEFT       //數據中心部分
                | machineId << MACHINE_LEFT             //機器標識部分
                | sequence;                             //序列號部分
}

現在我們來看一下使用方式:

public static void main(String[] args) {
  SnowFlake snowFlake = new SnowFlake(2, 3);
  for (int i = 0; i < (1 << 12); i++) {
    System.out.println(snowFlake.nextId());
  }
}

現在我們已經可以利用 SnowFlake 對象生成唯一 ID 了,那這個唯一 ID 有什麼用呢?這裏舉一個簡單的應用場景,即基於 SnowFlake 的短網址生成器,其主要思路是使用 SnowFlake 算法生成一個整數,然後對該整數進行 62 進制編碼最終生成一個短地址 URL。對短網址生成器感興趣的小夥伴,可以參考 徐劉根 大佬在碼雲上分享的工具類

最後我們來簡單總結一下,本文主要介紹了什麼是 Snowflake(雪花)算法、Snowflake 算法 ID 構成圖及其優缺點,最後詳細分析了 Github 上 beyondfengyu 大佬基於 Java 實現的 SnowFlake。在實際項目中,建議大家選用基於 Snowflake 算法成熟的開源項目,如百度的 UidGenerator 或美團的 Leaf

五、參考資源

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章