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


分布式唯一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年我寫了什么?

開源編程筆記


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM