分佈式系統唯一ID方案

初極狹,才通人。復行數十步,豁然開朗






系統唯一ID是我們在工作中經常會遇到的, 不管從事什麼行業都會用到, 耳熟能詳的有根據數據庫的自增ID, 或者UUID等等, 下面我們大概總結一下

數據庫自增長ID

這是一種最常見的方式, 就是依靠數據庫的自增長, 任何數據庫都可以, 具體實現方式, 我們簡單列舉幾個

Mysql

//建表設置id爲主鍵並自增
create table users(id int auto_increment primary key not null,name varchar(10)); 
//修改表id字段類型爲int型+自增屬性
alter table 表名 change id id int auto_increment;

SqlServer

create table users(id int identity(1,1) primary key not null,name varchar(10));

Oracle

create sequence users_id_sql increment by 1 start with 1;//users_id_sql爲序列名
  • 優點
    • 實現比較簡單, 代碼方便, 性能上總的來說也可以接受
    • 並且生成的ID是數字類型的, 對於查詢效率來說是比較友好的, 並且所佔的空間也比較少
  • 缺點
    • 在單點數據庫或者主備模式的情況下, 只有一個主庫可以生成ID, 存在單點風險
    • 一旦出現了性能問題, 這種方案比較難擴展
    • 不同的數據庫的語法或者實現多少會有差異性, 一旦數據庫需要遷移不同版本的時候比較麻煩
  • 優化
    • 這方式可以優化的場合就是針對主庫單點的情況, 首先要避免單點的問題, 然後當存在做個Master庫的時候, 針對於每個庫設置起始數字不一樣, 當時步長是一樣的, 例如一個Master生成1, 3, 5。Master2生成2, 4, 6。這樣就可以有效生成集羣中的唯一ID,也可以大大降低ID生成數據庫操作的負載。

UUID

UUID 是指Universally Unique Identifier,翻譯爲中文是通用唯一識別碼,UUID 的目的是讓分佈式系統中的所有元素都能有唯一的識別信息。UUID 是由一組32位數的16進制數字所構成,是故 UUID 理論上的總數爲1632=2128,約等於3.4 x 10123。也就是說若每納秒產生1百萬個 UUID,要花100億年纔會將所有 UUID 用完。

格式

UUID 的十六個八位字節被表示爲 32個十六進制數字,以連字號分隔的五組來顯示,形式爲 8-4-4-4-12,總共有 36個字符(即三十二個英數字母和四個連字號)。例如:
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

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

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

當前規範的UUID總共有5個版本, 而且上面我們說到了不同版本是通過M進行標示

  • version 1 date-time & MAC address 基於時間的UUID
  • version 2 date-time & group/user id DCE安全的UUID
  • version 3 MD5 hash & namespace 基於名字的UUID(MD5)
  • version 4 pseudo-random number 隨機UUID
  • version 5 SHA-1 hash & namespace 基於名字的UUID(SHA1)

從不同版本的描述也能看出來一些,
版本1和2 比較適用於於分佈式環境, 具有高度的唯一性
版本3和5 比較適用於在一定範圍內保證唯一, 需要或可能會重複生成UUID的環境下.
版本4 是通過隨機數來生成, 在JAVA中是通過僞隨機數生成的一定程度上避免了重複的概率, 但是出現碰撞的概率也是存在的.

  • 優點
    • 可以利用數據庫也可以利用程序生成, 實現簡單, 代碼方便.
    • 生成ID的性能比較好, 基本不會有性能問題
    • 號稱全球唯一, 在遇到數據遷移, 系統數據合併, 或者數據變更的情況下, 可以從容應對
  • 缺點
    • 是一個字符串, 不能進行排序, 當數據量比較大的時候, 查詢效率有問題
    • UUID存儲空間比較大, 如果遇到數據量比較到, 存儲也會有問題

Redis生成ID

當使用數據庫來生成ID性能不夠要求的時候,我們可以嘗試使用Redis來生成ID。這主要依賴於Redis是單線程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作 INCRINCRBY來實現。可以使用Redis集羣來獲取更高的吞吐量。假如一個集羣中有5臺Redis。可以初始化每臺Redis的值分別是1,2,3,4,5,然後步長都是5, 生成的ID爲

  • 1,6,11,16,21

  • 2,7,12,17,22

  • 3,8,13,18,23

  • 4,9,14,19,24

  • 5,10,15,20,25

  • 優點

    • 依賴於Redis, 性能比較高
    • 數字ID天然排序,對分頁或者需要排序的結果很有幫助。
  • 缺點

    • 如果系統中沒有采用Redis作爲緩存的話, 需要額外引入Redis, 增加了複雜度
    • 需要編碼和配置的工作量比較大

Snowflake算法

snowflake是Twitter開源的分佈式ID生成算法,結果是一個long型的ID。其核心思想是:使用41bit作爲毫秒數,10bit作爲機器的ID(5個bit是數據中心,5個bit的機器ID),12bit作爲毫秒內的流水號(意味着每個節點在每毫秒可以產生 4096 個 ID),最後還有一個符號位,永遠是0.

在這裏插入圖片描述
1bit,不用,因爲二進制中最高位是符號位,1表示負數,0表示正數。生成的id一般都是用整數,所以最高位固定爲0。

41bit-時間戳,用來記錄時間戳,毫秒級。

  • 41位可以表示個數字,
  • 如果只用來表示正整數(計算機中正數包含0),可以表示的數值範圍是:0 至 ,減1是因爲可表示的數值範圍是從0開始算的,而不是1。
  • 也就是說41位可以表示個毫秒的值,轉化成單位年則是年

10bit-工作機器id,用來記錄工作機器id。

  • 可以部署在個節點,包括5位datacenterId和5位workerId
  • 5位(bit)可以表示的最大正整數是,即可以用0、1、2、3、…31這32個數字,來表示不同的datecenterId或workerId

12bit-序列號,序列號,用來記錄同毫秒內產生的不同id。

  • 12位(bit)可以表示的最大正整數是,即可以用0、1、2、3、…4094這4095個數字,來表示同一機器同一時間截(毫秒)內產生的4095個ID序號。

由於在Java中64bit的整數是long類型,所以在Java中SnowFlake算法生成的id就是long來存儲的以上是該算法標準的一個格式, 在我們的實際應用中還可以根據自己的業務需求來調整各個模塊所佔用的位數.

public class IdWorker{

    //下面兩個每個5位,加起來就是10位的工作機器id
    private long workerId;    //工作id
    private long datacenterId;   //數據id
    //12位的序列號
    private long sequence;

    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;
    //最大值
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    //序列號id長度
    private long sequenceBits = 12L;
    //序列號最大值
    private long sequenceMask = -1L ^ (-1L << sequenceBits);
    
    //工作id需要左移的位數,12位
    private long workerIdShift = sequenceBits;
   //數據id需要左移位數 12+5=17位
    private long datacenterIdShift = sequenceBits + workerIdBits;
    //時間戳需要左移位數 12+5+5=22位
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    //上次時間戳,初始值爲負數
    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) {
            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生成方案, 其實還有幾種方案, 例如微軟基於UUID實現的GUID,

Zookeeper實現zookeeper主要通過其znode數據版本來生成序列號,可以生成32位和64位的數據版本號,客戶端可以使用這個版本號來作爲唯一的序列號。

通過一些NoSql比如MongoDB中的默認ObjectId(""), 和snowflake算法類似。它設計成輕量型的,不同的機器都能用全局唯一的同種方法方便地生成它。MongoDB 從一開始就設計用來作爲分佈式數據庫,處理多個節點是一個核心要求。使其在分片環境中要容易生成得多。

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