1. 程式人生 > 其它 >分散式序號發生器-snowflake雪花演算法

分散式序號發生器-snowflake雪花演算法

全域性唯一ID,目的是讓分散式系統中的所有元素都能有唯一的識別資訊。

1.UUID

UUID概述

UUID(Universally Unique Identifier),通用唯一識別碼。UUID是基於當前時間、計數器(counter)和硬體標識(通常為無線網絡卡的MAC地址)等資料計算生成的。

格式 & 版本

UUID由以下幾部分的組合:

  1. 當前日期和時間,UUID的第一個部分與時間有關,如果你在生成一個UUID之後,過幾秒又生成一個UUID,則第一個部分不同,其餘相同。
  2. 時鐘序列。
  3. 全域性唯一的IEEE機器識別號,如果有網絡卡,從網絡卡MAC地址獲得,沒有網絡卡以其他方式獲得。

UUID 是由一組32位數的16進位制數字所構成,以連字號分隔的五組來顯示,形式為 8-4-4-4-12,總共有 36個字元(即三十二個英數字母和四個連字號)。例如:

aefbbd3a-9cc5-4655-8363-a2a43e6e6c80
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

數字M的表示 UUID 版本,當前規範有5個版本,M可選值為1, 2, 3, 4, 5

數字N的一至四個最高有效位(bit)表示 UUID 變體( variant ),有固定的兩位10xx,因此N只可能取值8, 9, a, b

UUID版本通過M表示,當前規範有5個版本,M可選值為1, 2, 3, 4, 5。這5個版本使用不同演算法,利用不同的資訊來產生UUID,各版本有各自優勢,適用於不同情景。具體使用的資訊

  • version 1, date-time & MAC address

    基於時間的UUID通過計算當前時間戳、隨機數和節點標識:機器MAC地址得到。由於在演算法中使用了MAC地址,這個版本的UUID可以保證在全球範圍的唯一性。但與此同時,使用MAC地址會帶來安全性問題,這就是這個版本UUID受到批評的地方。同時, Version 1沒考慮過一臺機器上起了兩個程序這類的問題,也沒考慮相同時間戳的併發問題,所以嚴格的Version1沒人實現,Version1的變種有Hibernate的CustomVersionOneStrategy.java、MongoDB的ObjectId.java、Twitter的snowflake等。

  • version 2, date-time & group/user id

    DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID演算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。

  • version 3, MD5 hash & namespace

    基於名字的UUID通過計算名字和名字空間的MD5雜湊值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。

  • version 4, pseudo-random number

    根據隨機數,或者偽隨機數生成UUID。

  • version 5, SHA-1 hash & namespace

    和版本3的UUID演算法類似,只是雜湊值計算使用SHA1(Secure Hash Algorithm 1)演算法。

​ 使用較多的是版本1和版本4,其中版本1使用當前時間戳和MAC地址資訊。版本4使用(偽)隨機數資訊,128bit中,除去版本確定的4bit和variant確定的2bit,其它122bit全部由(偽)隨機數資訊確定。若希望對給定的一個字串總是能生成相同的 UUID,使用版本3或版本5。

重複機率

Java中 UUID 使用版本4進行實現,所以由java.util.UUID類產生的 UUID,128個位元中,有122個位元是隨機產生,4個位元標識版本被使用,還有2個標識變體被使用。利用生日悖論,可計算出兩筆 UUID 擁有相同值的機率約為
p(n) ≈ 1 - e-n*n/2x

其中x為 UUID 的取值範圍,n為 UUID 的個數。

以下是以 x = 2122計算出n筆 UUID 後產生碰撞的機率:

n機率
68,719,476,736 = 236 0.0000000000000004 (4 x 10-16)
2,199,023,255,552 = 241 0.0000000000004 (4 x 10-13)
70,368,744,177,664 = 246 0.0000000004 (4 x 10-10)

產生重複 UUID 並造成錯誤的情況非常低,是故大可不必考慮此問題。

機率也與隨機數產生器的質量有關。若要避免重複機率提高,必須要使用基於密碼學上的強偽隨機數產生器來生成值才行。

UUID 是由一組32位數的16進位制數字所構成,是故 UUID 理論上的總數為1632=2128,約等於3.4 x 10123。也就是說若每納秒產生1百萬個 UUID,要花100億年才會將所有 UUID 用完。

Java實現
/**
 * Static factory to retrieve a type 4 (pseudo randomly generated) UUID.
 * 使用靜態工廠來獲取版本4(偽隨機數生成器)的 UUID
 * The {@code UUID} is generated using a cryptographically strong pseudo
 * 這個UUID生成使用了強加密的偽隨機數生成器(PRNG)
 * random number generator.
 *
 * @return  A randomly generated {@code UUID}
 */
public static UUID randomUUID() {
    SecureRandom ng = Holder.numberGenerator;

    byte[] randomBytes = new byte[16];
    ng.nextBytes(randomBytes);
    randomBytes[6]  &= 0x0f;  /* clear version        */
    randomBytes[6]  |= 0x40;  /* set to version 4     */
    randomBytes[8]  &= 0x3f;  /* clear variant        */
    randomBytes[8]  |= 0x80;  /* set to IETF variant  */
    return new UUID(randomBytes);
}

/**
 * Static factory to retrieve a type 3 (name based) {@code UUID} based on
 * the specified byte array.
 * 靜態工廠對版本3的實現,對於給定的字串(name)總能生成相同的UUID
 * @param  name
 *         A byte array to be used to construct a {@code UUID}
 *
 * @return  A {@code UUID} generated from the specified array
 */
public static UUID nameUUIDFromBytes(byte[] name) {
    MessageDigest md;
    try {
        md = MessageDigest.getInstance("MD5");
    } catch (NoSuchAlgorithmException nsae) {
        throw new InternalError("MD5 not supported", nsae);
    }
    byte[] md5Bytes = md.digest(name);
    md5Bytes[6]  &= 0x0f;  /* clear version        */
    md5Bytes[6]  |= 0x30;  /* set to version 3     */
    md5Bytes[8]  &= 0x3f;  /* clear variant        */
    md5Bytes[8]  |= 0x80;  /* set to IETF variant  */
    return new UUID(md5Bytes);
}
生成UUID
// Java語言實現
import java.util.UUID;

public class UUIDProvider{
    public static void main(String[] args) {
        // 利用偽隨機數生成版本為4,變體為9的UUID
        System.out.println(UUID.randomUUID());
        
        // 對於相同的名稱空間總是生成相同的UUID,版本為3,變體為9
        // 名稱空間為"xxx"時生成的UUID總是為f561aaf6-ef0b-314d-8208-bb46a4ccb3ad
        System.out.println(UUID.nameUUIDFromBytes("xxx".getBytes()));
    }
} 
優點
  • 簡單,程式碼方便。
  • 生成ID效能非常好,基本不會有效能問題。本地生成,沒有網路消耗。
  • 全球唯一,在遇見資料遷移,系統資料合併,或者資料庫變更等情況下,可以從容應對。
缺點
  • 採用無意義字串,沒有排序,無法保證趨勢遞增。
  • UUID使用字串形式儲存,資料量大時查詢效率比較低
  • 儲存空間比較大,如果是海量資料庫,就需要考慮儲存量的問題。

2.雪花演算法(twitter/snowflake)

雪花演算法概述

SnowFlake 演算法,是 Twitter 開源的分散式 id 生成演算法。其核心思想就是:使用一個 64 bit 的 long 型的數字作為全域性唯一 id。在分散式系統中的應用十分廣泛,且ID 引入了時間戳,基本上保持自增的。其原始版本是scala版,後面出現了許多其他語言的版本如Java、C++等。

格式
  • 1bit - 首位無效符

  • 41bit - 時間戳(毫秒級)

    • 41位可以表示241-1個數字;
    • 241-1毫秒,換算成年就是表示 69 年的時間
  • 10bit - 工作機器id

    • 5bit - datacenterId機房id
    • 5bit - workerId機器 id
  • 12bit - 序列號

    序列號,用來記錄同一個datacenterId中某一個機器上同毫秒內產生的不同id。

特點(自增、有序、適合分散式場景)
  • 時間位:可以根據時間進行排序,有助於提高查詢速度。
  • 機器id位:適用於分散式環境下對多節點的各個節點進行標識,可以具體根據節點數和部署情況設計劃分機器位10位長度,如劃分5位表示程序位等。
  • 序列號位:是一系列的自增id,可以支援同一節點同一毫秒生成多個ID序號,12位的計數序列號支援每個節點每毫秒產生4096個ID序號

snowflake演算法可以根據專案情況以及自身需要進行一定的修改

Twitter演算法實現

Twitter演算法實現(Scala)

Java演算法實現
public class IdWorker{

    //10bit的工作機器id
    private long workerId;    // 5bit
    private long datacenterId;   // 5bit

    private long sequence; // 12bit 序列號

    public IdWorker(long workerId, long datacenterId, long sequence){
        // sanity check for workerId
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
        }
        System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
                timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);

        this.workerId = workerId;
        this.datacenterId = datacenterId;
        this.sequence = sequence;
    }

    //初始時間戳
    private long twepoch = 1288834974657L;

    //長度為5位
    private long workerIdBits = 5L;
    private long datacenterIdBits = 5L;
    //最大值 -1 左移 5,得結果a,-1 異或 a:利用位運算計算出5位能表示的最大正整數是多少。
    private long maxWorkerId = -1L ^ (-1L << workerIdBits); //31
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); // 31
    //序列號id長度
    private long sequenceBits = 12L;
    //序列號最大值
    private long sequenceMask = -1L ^ (-1L << sequenceBits); //4095

    //workerId需要左移的位數,12位
    private long workerIdShift = sequenceBits; //12
    //datacenterId需要左移位數 
    private long datacenterIdShift = sequenceBits + workerIdBits; // 12+5=17
    //時間戳需要左移位數 
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; // 12+5+5=22

    //上次時間戳,初始值為負數
    private long lastTimestamp = -1L;

    public long getWorkerId(){
        return workerId;
    }

    public long getDatacenterId(){
        return datacenterId;
    }

    public long getTimestamp(){
        return System.currentTimeMillis();
    }

    //下一個ID生成演算法
    public synchronized long nextId() {
        long timestamp = timeGen();

        //獲取當前時間戳如果小於上次時間戳,則表示時間戳獲取出現異常
        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        //獲取當前時間戳如果等於上次時間戳(同一毫秒內),則在序列號加一;否則序列號賦值為0,從0開始。
        if (lastTimestamp == timestamp) {
            // 通過位與運算保證計算的結果範圍始終是 0-4095
            sequence = (sequence + 1) & sequenceMask; 
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }

        //將上次時間戳值重新整理
        lastTimestamp = timestamp;

        /**
         * 返回結果:
         * (timestamp - twepoch) << timestampLeftShift) 表示將時間戳減去初始時間戳,再左移相應位數
         * (datacenterId << datacenterIdShift) 表示將資料id左移相應位數
         * (workerId << workerIdShift) 表示將工作id左移相應位數
         * | 是按位或運算子,例如:x | y,只有當x,y都為0的時候結果才為0,其它情況結果都為1。
         * 因為個部分只有相應位上的值有意義,其它位上都是0,所以將各部分的值進行 | 運算就能得到最終拼接好的id
         */
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    //獲取時間戳,並與上次時間戳比較
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    //獲取系統時間戳
    private long timeGen(){
        return System.currentTimeMillis();
    }

    //---------------測試---------------
    public static void main(String[] args) {
        IdWorker worker = new IdWorker(1,1,1);
        for (int i = 0; i < 30; i++) {
            System.out.println(worker.nextId());
        }
    }

}
優點
  • 毫秒數在高位,自增序列在低位,整個ID都是趨勢遞增的。
  • 不依賴資料庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的效能也是非常高的。
  • 可以根據自身業務特性分配bit位,非常靈活。
缺點
  • 雪花演算法在單機系統上ID是遞增的,但是在分散式系統多節點的情況下,所有節點的時鐘並不能保證不完全同步,所以有可能會出現不是全域性遞增的情況。如果系統時間被回撥,或者改變,可能會造成id衝突或者重複。

3.利用資料庫的auto_increment特性

以MySQL舉例,利用給欄位設定auto_increment_increment和auto_increment_offset來保證ID自增,每次業務使用下列SQL讀寫MySQL得到ID號

優點
  • 非常簡單,利用現有資料庫系統的功能實現,成本小,有DBA專業維護。
  • ID號單調自增,可以實現一些對ID有特殊要求的業務。
缺點
  • 強依賴DB,當DB異常時整個系統不可用,屬於致命問題。配置主從複製可以儘可能的增加可用性,但是資料一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。
  • ID發號效能瓶頸限制在單臺MySQL的讀寫效能
  • 分表分庫,資料遷移合併等比較麻煩
Keep moving forwards~