講分散式唯一id,這篇文章很實在

秦懷雜貨店發表於2021-11-09

分散式唯一ID介紹

分散式系統全域性唯一的 id 是所有系統都會遇到的場景,往往會被用在搜尋,儲存方面,用於作為唯一的標識或者排序,比如全域性唯一的訂單號,優惠券的券碼等,如果出現兩個相同的訂單號,對於使用者無疑將是一個巨大的bug。

在單體的系統中,生成唯一的 id 沒有什麼挑戰,因為只有一臺機器一個應用,直接使用單例加上一個原子操作自增即可。而在分散式系統中,不同的應用,不同的機房,不同的機器,要想生成的 ID 都是唯一的,確實需要下點功夫。

一句話總結:

分散式唯一ID是為了給資料進行唯一標識。

分散式唯一ID的特徵

分散式唯一ID的核心是唯一性,其他的都是附加屬性,一般來說,一個優秀的全域性唯一ID方案有以下的特點,僅供參考:

  • 全域性唯一:不可以重複,核心特點!
  • 大致有序或者單調遞增:自增的特性有利於搜尋,排序,或者範圍查詢等
  • 高效能:生成ID響應要快,延遲低
  • 高可用:要是隻能單機,掛了,全公司依賴全域性唯一ID的服務,全部都不可用了,所以生成ID的服務必須高可用
  • 方便使用:對接入者友好,能封裝到開箱即用最好
  • 資訊保安:有些場景,如果連續,那麼很容易被猜到,攻擊也是有可能的,這得取捨。

分散式唯一ID的生成方案

UUID直接生成

寫過 Java 的朋友都知道,有時候我們寫日誌會用到一個類 UUID,會生成一個隨機的ID,去作為當前使用者請求記錄的唯一識別碼,只要用以下的程式碼:

String uuid = UUID.randomUUID();

用法簡單粗暴,UUID的全稱其實是Universally Unique IDentifier,或者GUID(Globally Unique IDentifier),它本質上是一個 128 位的二進位制整數,通常我們會表示成為 32 個 16 進位制陣列成的字串,幾乎不會重複,2 的 128 次方,那是無比龐大的數字。

以下是百度百科說明:

UUID由以下幾部分的組合:

(1)UUID的第一個部分與時間有關,如果你在生成一個UUID之後,過幾秒又生成一個UUID,則第一個部分不同,其餘相同。

(2)時鐘序列。

(3)全域性唯一的IEEE機器識別號,如果有網路卡,從網路卡MAC地址獲得,沒有網路卡以其他方式獲得。

UUID的唯一缺陷在於生成的結果串會比較長。關於UUID這個標準使用最普遍的是微軟的GUID(Globals Unique Identifiers)。在ColdFusion中可以用CreateUUID()函式很簡單地生成UUID,其格式為:xxxxxxxx-xxxx- xxxx-xxxxxxxxxxxxxxxx(8-4-4-16),其中每個 x 是 0-9 或 a-f 範圍內的一個十六進位制的數字。而標準的UUID格式為:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12),可以從cflib 下載CreateGUID() UDF進行轉換。 [2]

(4)在 hibernate(Java orm框架)中, 採用 IP-JVM啟動時間-當前時間右移32位-當前時間-內部計數(8-8-4-8-4)來組成UUID

要想重複,兩臺完全相同的虛擬機器,開機時間一致,隨機種子一致,同一時間生成uuid,才有極小的概率會重複,因此我們可認為,理論上會重複,實際不可能重複!!!

uuid優點:

  • 效能好,效率高
  • 不用網路請求,直接本地生成
  • 不同的機器個幹個的,不會重複

uuid 這麼好,難不成是銀彈?當然缺點也很突出:

  • 沒辦法保證遞增趨勢,沒法排序
  • uuid太長了,儲存佔用空間大,特別落在資料庫,對建立索引不友好
  • 沒有業務屬性,這東西就是一串數字,沒啥意義,或者說規律

當然也有人想要改進這傢伙,比如不可讀性改造,用uuid to int64,把它轉成 long 型別:

byte[] bytes = Guid.NewGuid().ToByteArray();
return BitConverter.ToInt64(bytes, 0);

又比如,改造無序性,比如 NHibernateComb 演算法,把 uuid 的前 20 個字元保留下來,後面 12 個字元用 guid 生成的時間,時間是大致有序的,是一種小改進。

點評:UUID不存在資料庫當索引,作為一些日誌,上下文的識別,還是挺香的,但是要是這玩意用來當訂單號,真是令人崩潰

資料庫自增序列

單機的資料庫

資料庫的主鍵本身就擁有一個自增的天然特性,只要設定ID為主鍵並且自增,我們就可以向資料庫中插入一條記錄,可以返回自增的ID,比如以下的建表語句:

CREATE DATABASE `test`;
use test;
CREATE TABLE id_table (
    id bigint(20) unsigned NOT NULL auto_increment, 
    value char(10) NOT NULL default '',
    PRIMARY KEY (id),
) ENGINE=MyISAM;

插入語句:

insert into id_table(value)  VALUES ('v1');

優點:

  • 單機,簡單,速度也很快
  • 天然自增,原子性
  • 數字id排序,搜尋,分頁都比較有利

缺點也很明顯:

  • 單機,掛了就要提桶跑路了
  • 一臺機器,高併發也不可能

叢集的資料庫

既然單機高併發和高可用搞不定,那就加機器,搞叢集模式的資料庫,既然叢集模式,如果有多個master,那肯定不能每臺機器自己生成自己的id,這樣會導致重複的id。

這個時候,每臺機器設定起始值步長,就尤為重要。比如三臺機器V1,V2,V3:

統一步長:3
V1起始值:1
V2起始值:2
V3起始值:3

生成的ID:

V1:1, 4, 7, 10...
V2:2, 5, 8, 11...
V3:3, 6, 9, 12...

設定命令列可以使用:

set @@auto_increment_offset = 1;     // 起始值
set @@auto_increment_increment = 3;  // 步長

這樣確實在master足夠多的情況下,高效能保證了,就算有的機器當機了,slave 也可以補充上來,基於主從複製就可以,可以大大降低對單臺機器的壓力。但是這樣做還是有缺點:

  • 主從複製延遲了,master當機了,從節點切換成為主節點之後,可能會重複發號。
  • 起始值和步長設定好之後,要是後面需要增加機器(水平擴充),要調整很麻煩,很多時候可能需要停機更新

批量號段式資料庫

上面的訪問資料庫太頻繁了,併發量一上來,很多小概率問題都可能發生,那為什麼我們不直接一次性拿出一段id呢?直接放在記憶體裡,以供使用,用完了再申請一段就可以了。同樣也可以保留叢集模式的優點,每次從資料庫取出一個範圍的id,比如3臺機器,發號:

每次取1000,每臺步長3000
V1:1-1000,3001-4000,
V2:1001-2000,4001-5000
V3:2001-3000,5001-6000

當然,如果不搞多臺機器,也是可以的,一次申請10000個號碼,用樂觀鎖實現,加一個版本號,

CREATE TABLE id_table (
  id int(10) NOT NULL,
  max_id bigint(20) NOT NULL COMMENT '當前最大id',
  step int(20) NOT NULL COMMENT '號段的步長',
  version int(20) NOT NULL COMMENT '版本號',
  `create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) 

只有用完的時候,才會重新去資料庫申請,競爭的時候樂觀鎖保證只能一個請求成功,其他的直接等著別人取出來放在應用記憶體裡面,再取就可以了,取的時候其實就是一個update操作:

update id_table set max_id = #{max_id+step}, version = version + 1 where version = # {version}

重點:

  • 批量獲取,減少資料庫請求
  • 樂觀鎖,保證資料準確
  • 獲取只能從資料庫中獲取,批量獲取可以做成非同步定時任務,發現少於某個閾值,自動補充

Redis自增

redis有一個原子命令incr,原子自增,redis速度快,基於記憶體:

127.0.0.1:6379> set id 1
OK
127.0.0.1:6379> incr id      
(integer) 2

當然,redis 如果單機有問題,也可以上叢集,同樣可以用初始值 + 步長,可以用 INCRBY 命令,搞幾臺機器基本能抗住高併發。

優點:

  • 基於記憶體,速度快
  • 天然排序,自增,有利於排序搜尋

缺點:

  • 步長確定之後,增加機器也比較難調整
  • 需要關注持久化,可用性等,增加系統複雜度

redis持久化如果是RDB,一段時間打一個快照,那麼可能會有資料沒來得及被持久化到磁碟,就掛掉了,重啟可能會出現重複的ID,同時要是主從延遲,主節點掛掉了,主從切換,也可能出現重複的ID。如果使用AOF,一條命令持久化一次,可能會拖慢速度,一秒鐘持久化一次,那麼就可能最多丟失一秒鐘的資料,同時,資料恢復也會比較慢,這是一個取捨的過程。

Zookeeper生成唯一ID

zookeeper其實是可以用來生成唯一ID的,但是大家不用,因為效能不高。znode有資料版本,可以生成32或者64位的序列號,這個序列號是唯一的,但是如果競爭比較大,還需要加分散式鎖,不值得,效率低。

美團的Leaf

下面均來自美團的官方文件:https://tech.meituan.com/2019/03/07/open-source-project-leaf.html

Leaf在設計之初就秉承著幾點要求:

  1. 全域性唯一,絕對不會出現重複的ID,且ID整體趨勢遞增。
  2. 高可用,服務完全基於分散式架構,即使MySQL當機,也能容忍一段時間的資料庫不可用。
  3. 高併發低延時,在CentOS 4C8G的虛擬機器上,遠端呼叫QPS可達5W+,TP99在1ms內。
  4. 接入簡單,直接通過公司RPC服務或者HTTP呼叫即可接入。

文件裡面講得很清晰,一共有兩個版本:

  • V1:預分發的方式提供ID,也就是前面說的號段式分發,表設計也差不多,意思就是批量的拉取id

image-20211012002835752

這樣做的缺點就是更新號段的時候,耗時比較高,還有就是如果這時候當機或者主從複製,就不可用。

優化:

  • 1.先做了一個雙Buffer優化,就是非同步更新,意思就是搞兩個號段出來,一個號段比如被消耗10%的時候,就開始分配下一個號段,有種提前分配的意思,而且非同步執行緒更新

  • 2.上面的方案,號段可能固定,跨度可能太大或者太小,那就做成動態變化,根據流量來決定下一次的號段的大小,動態調整

  • V2:Leaf-snowflake,Leaf提供了Java版本的實現,同時對Zookeeper生成機器號做了弱依賴處理,即使Zookeeper有問題,也不會影響服務。Leaf在第一次從Zookeeper拿取workerID後,會在本機檔案系統上快取一個workerID檔案。即使ZooKeeper出現問題,同時恰好機器也在重啟,也能保證服務的正常執行。這樣做到了對第三方元件的弱依賴,一定程度上提高了SLA。

snowflake(雪花演算法)

snowflake 是 twitter 公司內部分散式專案採用的 ID 生成演算法,開源後廣受歡迎,它生成的ID是 Long 型別,8個位元組,一共64位,從左到右:

  • 1位:不使用,二進位制中最高位是為1都是負數,但是要生成的唯一ID都是正整數,所以這個1位固定為0。
  • 41位:記錄時間戳(毫秒),這個位數可以用 $(2^{41}-1) / (1000 * 60 * 60 * 24 * 365) = 69$年
  • 10位:記錄工作機器的ID,可以機器ID,也可以機房ID + 機器ID
  • 12位:序列號,就是某個機房某臺機器上這一毫秒內同時生成的 id 序號

那麼每臺機器按照上面的邏輯去生成ID,就會是趨勢遞增的,因為時間在遞增,而且不需要搞個分散式的,簡單很多。

可以看出 snowflake 是強依賴於時間的,因為時間理論上是不斷往前的,所以這一部分的位數,也是趨勢遞增的。但是有一個問題,是時間回撥,也就是時間突然間倒退了,可能是故障,也可能是重啟之後時間獲取出問題了。那我們該如何解決時間回撥問題呢?

  • 第一種方案:獲取時間的時候判斷,如果小於上一次的時間戳,那麼就不要分配,繼續迴圈獲取時間,直到時間符合條件。
  • 第二種方案:上面的方案只適合時鐘回撥較小的,如果間隔過大,阻塞等待,肯定是不可取的,因此要麼超過一定大小的回撥直接報錯,拒絕服務,或者有一種方案是利用擴充位,回撥之後在擴充位上加1就可以了,這樣ID依然可以保持唯一。

Java程式碼實現:

public class SnowFlake {

    // 資料中心(機房) id
    private long datacenterId;
    // 機器ID
    private long workerId;
    // 同一時間的序列
    private long sequence;

    public SnowFlake(long workerId, long datacenterId) {
        this(workerId, datacenterId, 0);
    }

    public SnowFlake(long workerId, long datacenterId, long sequence) {
        // 合法判斷
        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 = 1420041600000L;

    // 機房號,的ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
    private long datacenterIdBits = 5L;

    // 機器ID所佔的位數 5個bit 最大:11111(2進位制)--> 31(10進位制)
    private long workerIdBits = 5L;

    // 5 bit最多隻能有31個數字,就是說機器id最多隻能是32以內
    private long maxWorkerId = -1L ^ (-1L << workerIdBits);

    // 5 bit最多隻能有31個數字,機房id最多隻能是32以內
    private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

    // 同一時間的序列所佔的位數 12個bit 111111111111 = 4095  最多就是同一毫秒生成4096個
    private long sequenceBits = 12L;

    // workerId的偏移量
    private long workerIdShift = sequenceBits;

    // datacenterId的偏移量
    private long datacenterIdShift = sequenceBits + workerIdBits;

    // timestampLeft的偏移量
    private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

    // 序列號掩碼 4095 (0b111111111111=0xfff=4095)
    // 用於序號的與運算,保證序號最大值在0-4095之間
    private long sequenceMask = -1L ^ (-1L << sequenceBits);

    // 最近一次時間戳
    private long lastTimestamp = -1L;


    // 獲取機器ID
    public long getWorkerId() {
        return workerId;
    }


    // 獲取機房ID
    public long getDatacenterId() {
        return datacenterId;
    }


    // 獲取最新一次獲取的時間戳
    public long getLastTimestamp() {
        return lastTimestamp;
    }


    // 獲取下一個隨機的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));
        }

        // 去重
        if (lastTimestamp == timestamp) {

            sequence = (sequence + 1) & sequenceMask;

            // sequence序列大於4095
            if (sequence == 0) {
                // 呼叫到下一個時間戳的方法
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 如果是當前時間的第一次獲取,那麼就置為0
            sequence = 0;
        }

        // 記錄上一次的時間戳
        lastTimestamp = timestamp;

        // 偏移計算
        return ((timestamp - twepoch) << timestampLeftShift) |
                (datacenterId << datacenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        // 獲取最新時間戳
        long timestamp = timeGen();
        // 如果發現最新的時間戳小於或者等於序列號已經超4095的那個時間戳
        while (timestamp <= lastTimestamp) {
            // 不符合則繼續
            timestamp = timeGen();
        }
        return timestamp;
    }

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

    public static void main(String[] args) {
        SnowFlake worker = new SnowFlake(1, 1);
        long timer = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            worker.nextId();
        }
        System.out.println(System.currentTimeMillis());
        System.out.println(System.currentTimeMillis() - timer);
    }

}
  

百度 uid-generator

換湯不換藥,百度開發的,基於Snowflake演算法,不同的地方是可以自己定義每部分的位數,也做了不少優化和擴充:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md

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

秦懷の觀點

不管哪一種uid生成器,保證唯一性是核心,在這個核心上才能去考慮其他的效能,或者高可用等問題,總體的方案分為兩種:

  • 中心化:第三方的一箇中心,比如 Mysql,Redis,Zookeeper
    • 優點:趨勢自增
    • 缺點:增加複雜度,一般得叢集,提前約定步長之類
  • 無中心化:直接本地機器上生成,snowflake,uuid
    • 優點:簡單,高效,沒有效能瓶頸
    • 缺點:資料比較長,自增屬性較弱

沒有哪一種是完美的,只有符合業務以及當前體量的方案,技術方案裡面,沒有最優解。

【作者簡介】
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java原始碼解析JDBCMybatisSpringredis分散式劍指OfferLeetCode等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。

劍指Offer全部題解PDF

2020年我寫了什麼?

開源程式設計筆記

相關文章