生成分佈式ID算法 -- 雪花算法

一、分佈式ID

集羣高併發情況下如何保證分佈式唯一全局ID生成?

1. 爲什麼需要分佈式全局唯一ID以及分佈式ID的業務需求?

在複雜的分佈式系統中,往往需要對大量的數據和消息進行唯一標識。如在美團點評的金融、支付、餐飲、酒店等產品的系統中數據日漸增長,對數據分庫分表後需要有一個唯一ID來標識一條數據或消息,此時一個能夠生成全局唯一ID的系統是非常必要的。

2. ID生成規則部分硬性要求

  • 全局唯一:不能出現重複的ID號,既然是唯一標識,這是最基本的要求;
  • 趨勢遞增:在MySql的InnoDB引擎中使用的是聚集索引,由於多數RDBMS使用B-tree的數據結構來存儲索引數據,在主鍵的選擇上面我們應該儘量使用有序的主鍵保證寫入性能;
  • 單調遞增:保證下一個ID一定大於上一個ID,例如事務版本號、IM增量消息、排序等特殊需求;
  • 信息安全:如果ID是連續的,惡意用戶的扒取工作就非常容易做了,直接按照順序下載指定URL即可,如果是訂單號就更加危險,競對可以直接知道我們一天的單量;
  • 含時間戳:這樣就能夠在開發中快速瞭解分佈式ID的生成時間。

3. ID生成系統的可用性要求

  • 高可用:發一個獲取分佈式ID的請求,服務器就要保證99.999%的情況下給我創建一個唯一分佈式ID;
  • 低延遲:發一個獲取分佈式ID的請求,服務器就要快,極速;
  • 高QPS:假如併發一口氣10萬個創建分佈式ID請求同時發過來,服務器要頂得住且一下子成功創建10萬個分佈式ID。

二、一般通用方案

1. UUID

  • 是什麼?UUID的標準形式:包含32個16進制數字,以連字號分爲五段,形式爲8-4-4-12的36個字符。

  • 優點:性能非常高,本地生成,沒有網絡消耗。

  • 缺點:入數據庫性能差。

爲什麼無序的UUID會導致入庫性能變差呢?

  • 1)無序,無法預測它的生成順序,不能生成遞增有序的數字。首先分佈式ID一般都會作爲主鍵,但是MySql官方推薦主鍵要儘量越短越好,UUID每一個都很長,所以不是很推薦。

  • 2)主鍵,ID作爲主鍵時在特定的環境下會存在一些問題,比如做DB主鍵的場景下,UUID就非常不適用MySql,官方有明確的建議要儘量越短越好,36個字符長度的UUID不符合要求。

  • 3)索引,B+樹索引的分裂。既然分佈式ID是主鍵,然後主鍵是包含索引的,然後MySql的索引是通過b+樹來實現的,每一次新的UUID數據的插入,爲了查詢的優化,都會對索引底層的b+樹進行修改,因爲UUID數據是無序的,所以每一次UUID數據的插入都會對主鍵底層的b+樹進行很大的修改,這一點很不好。插入完全無序,不但會導致一些中間節點產生分裂,也會白白創造出很多不飽和的節點,這樣大大降低了數據庫插入的性能。

如果只是考慮唯一性,UUID是OK的。


2. 數據庫自增主鍵

  • 單機應用

在分佈式裏,數據庫的自增ID機制的主要原理是:數據庫自增ID和MySql數據庫的replace into實現的。

replace into 跟 insert 功能類似,不同點在於:replace into首先嚐試插入數據列表中,如果發現表中已經有此行數據(根據主鍵或唯一索引)判斷是否存在,若有則先刪除再插入,否則直接插入新數據。REPLACE INTO的含義是插入一條記錄,如果表中唯一索引的值遇到衝突,則替換老數據。

CREATE TABLE t_test (
	id bigint(20) unsigned not null auto_increment primary key,
	stub char(1) not null default '',
	unique key stub (stub)
)

select * from t_test;
replace into t_test (stub) values('a');
select last_insert_id();
  • 集羣分佈式

那數據庫自增ID機制適合作分佈式ID嗎?答案是不太適合的。

1)系統水平擴展比較困難,比如定義好了步長和機器臺數之後,如果要添加機器該怎麼做?假設現在只有一臺機器發號是1到99999,步長是1,這時候需要擴容機器一臺。可以這麼做:把第二臺機器的初始值設置得比第一臺超過很多,貌似還好,現在想象一下如果我們線上有100臺機器,這個時候要擴容該怎麼做?簡直是噩夢,所以系統水平擴展方案複雜難以實現。

2)數據庫壓力還是很大,每次獲取ID都得讀寫一次數據庫,非常影響性能,不符合分佈式ID裏面的延遲低和要高QPS的規則(在高併發下,如果都去數據庫裏獲取ID,那是非常影響性能的)。


3. 基於Redis生成全局ID策略

  • 單機應用,因爲Redis是單線程的天生保證原子性,可以使用原子操作INCR和INCRBY來實現。

  • 集羣分佈式,可以使用Redis集羣來獲取更高的吞吐量,注意:在Redis集羣情況下,同樣和MySql一樣需要設置不同的增長步長,同時key一定要設置有效期。

假如一個集羣中有5臺Redis,可以初始化每臺Redis的值分別是1,2,3,4,5 然後步長都是5,每個redis生成的ID爲:

	A:16111621
	B:27121722
	C:38131823
	D:49141924
	E:510152025

三、雪花算法 - Snowflake

1. 概述

Twitter的分佈式自增ID算法,經測試snowflake每秒能夠生產26萬個自增可排序的ID

特點:

  • twitter的Snowflake算法生成ID能夠按照時間有序生成;

  • Snowflake算法生成ID的結果是一個64bit大小的整數,爲一個Long型(轉換成字符串後長度最多19);

  • 分佈式系統內不會產生ID碰撞並且效率較高。

分佈式系統中,有一些需要使用全局唯一ID的場景,生成ID的基本要求:

  • 在分佈式的環境下必須全局且唯一;

  • 一般都需要單調遞增,因爲一般唯一ID都會存到數據庫,而Innodb的特性就是將內容存儲在主鍵索引樹上的
    葉子節點,而且是從左往右遞增的,所以考慮到數據庫性能,一般生成的ID最好也是單調遞增的。爲了防止ID衝突
    可以使用36位的UUID,但是UUID有一些缺點,首先他相對比較長,另外UUID一般是無序的;

  • 可能還會需要無規則,因爲如果使用唯一ID作爲訂單號這種,爲了不讓別人知道一天的訂單量是多少,就需要無規則。

2. 結構
雪花算法
號段解析:

  • 符號位(1 bit):不使用,因爲二進制中最高位是符號位,1表示負數,0表示整數,生成的ID一般都是正數,所以最高位固定爲0;

  • 時間戳位(41 bit):用來記錄時間戳,毫秒級。41位可以表示2^{41}-1個數字,如果只用來表示正整數(計算機中正數包含0),可以表示的數值範圍是:0至2^{41}-1,減1是因爲可表示的數值範圍是從0開始算的,而不是1。也就是說41位可以表示2^{41}-1個毫秒的值,轉化成單位年差不多是69年;

  • 工作進程位(10bit):工作機器ID,用來記錄工作機器ID。可以部署 2^{10} = 1024個節點,包括5位datacenterId和5位workerId。5位(bit)可以表示的最大正整數是2^{5}-1 = 31,即可以用0、1、2、3 … 31這32個數字,來表示不同的datacenterId或workerId;

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

SnowFlake可以保證所有生成的ID按時間趨勢遞增,整個分佈式系統內不會產生重複ID(因爲有 datacenterId 和 workId來做區分)。


3. 源碼

Java版(Hutool):

/**
 * Twitter的Snowflake 算法<br>
 * 分佈式系統中,有一些需要使用全局唯一ID的場景,有些時候我們希望能使用一種簡單一些的ID,並且希望ID能夠按照時間有序生成。
 *
 * <p>
 * snowflake的結構如下(每部分用-分開):<br>
 *
 * <pre>
 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
 * </pre>
 * <p>
 * 第一位爲未使用(符號位表示正數),接下來的41位爲毫秒級時間(41位的長度可以使用69年)<br>
 * 然後是5位datacenterId和5位workerId(10位的長度最多支持部署1024個節點)<br>
 * 最後12位是毫秒內的計數(12位的計數順序號支持每個節點每毫秒產生4096個ID序號)
 * <p>
 * 並且可以通過生成的id反推出生成時間,datacenterId和workerId
 * <p>
 * 參考:http://www.cnblogs.com/relucent/p/4955340.html
 *
 * @author Looly
 * @since 3.0.1
 */
public class Snowflake implements Serializable {
    private static final long serialVersionUID = 1L;

    // 開始時間戳
    private final long twepoch;
    // 機器標識位數
    private final long workerIdBits = 5L;
    // 數據中心標識位數
    private final long dataCenterIdBits = 5L;
    //// 最大支持機器節點數0~31,一共32個
    // 最大支持數據中心節點數0~31,一共32個
    @SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
    @SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
    private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits);
    // 序列號12位
    private final long sequenceBits = 12L;
    // 機器節點左移12位
    private final long workerIdShift = sequenceBits;
    // 數據中心節點左移17位
    private final long dataCenterIdShift = sequenceBits + workerIdBits;
    // 時間毫秒數左移22位
    private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
    @SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);// 4095

    private final long workerId;
    private final long dataCenterId;
    private final boolean useSystemClock;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    /**
     * 構造
     *
     * @param workerId     終端ID
     * @param dataCenterId 數據中心ID
     */
    public Snowflake(long workerId, long dataCenterId) {
        this(workerId, dataCenterId, false);
    }

    /**
     * 構造
     *
     * @param workerId         終端ID
     * @param dataCenterId     數據中心ID
     * @param isUseSystemClock 是否使用{@link SystemClock} 獲取當前時間戳
     */
    public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) {
        this(null, workerId, dataCenterId, isUseSystemClock);
    }

    /**
     * @param epochDate        初始化時間起點(null表示默認起始日期),後期修改會導致id重複,如果要修改連workerId dataCenterId,慎用
     * @param workerId         工作機器節點id
     * @param dataCenterId     數據中心id
     * @param isUseSystemClock 是否使用{@link SystemClock} 獲取當前時間戳
     * @since 5.1.3
     */
    public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock) {
        if (null != epochDate) {
            this.twepoch = epochDate.getTime();
        } else {
            // Thu, 04 Nov 2010 01:42:54 GMT
            this.twepoch = 1288834974657L;
        }
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than {}} or less than 0", maxWorkerId));
        }
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
            throw new IllegalArgumentException(String.format("datacenter Id can't be greater than {} or less than 0", maxDataCenterId));
        }
        this.workerId = workerId;
        this.dataCenterId = dataCenterId;
        this.useSystemClock = isUseSystemClock;
    }

    /**
     * 根據Snowflake的ID,獲取機器id
     *
     * @param id snowflake算法生成的id
     * @return 所屬機器的id
     */
    public long getWorkerId(long id) {
        return id >> workerIdShift & ~(-1L << workerIdBits);
    }

    /**
     * 根據Snowflake的ID,獲取數據中心id
     *
     * @param id snowflake算法生成的id
     * @return 所屬數據中心
     */
    public long getDataCenterId(long id) {
        return id >> dataCenterIdShift & ~(-1L << dataCenterIdBits);
    }

    /**
     * 根據Snowflake的ID,獲取生成時間
     *
     * @param id snowflake算法生成的id
     * @return 生成的時間
     */
    public long getGenerateDateTime(long id) {
        return (id >> timestampLeftShift & ~(-1L << 41L)) + twepoch;
    }

    /**
     * 下一個ID
     *
     * @return ID
     */
    public synchronized long nextId() {
        long timestamp = genTime();
        if (timestamp < lastTimestamp) {
            // 如果服務器時間有問題(時鐘後退) 報錯。
            throw new IllegalStateException(String.format("Clock moved backwards. Refusing to generate id for {}ms", (lastTimestamp - timestamp)));
        }
        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence;
    }

    /**
     * 下一個ID(字符串形式)
     *
     * @return ID 字符串形式
     */
    public String nextIdStr() {
        return Long.toString(nextId());
    }

    // ------------------------------------------------------------------------------------------------------------------------------------ Private method start

    /**
     * 循環等待下一個時間
     *
     * @param lastTimestamp 上次記錄的時間
     * @return 下一個時間
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = genTime();
        while (timestamp <= lastTimestamp) {
            timestamp = genTime();
        }
        return timestamp;
    }

    /**
     * 生成時間戳
     *
     * @return 時間戳
     */
    private long genTime() {
        return this.useSystemClock ? SystemClock.now() : System.currentTimeMillis();
    }
}

4. 工程落地經驗

分佈式整合雪花算法:

IdGenerateInvoker.java

/**
 * 分佈式環境下數據庫主鍵的ID生成器
 * 實現了對分佈式友好的Snowflake算法,並且會在Spring IOC容器初始化的時候註冊成爲單例Bean.
 * 如果需要自定義生成策略,請實現本接口並將其實例註冊到容器中,並在{@link PrimaryKey}的strategy屬性指定.
 *
 */
public interface IdGenerateInvoker {

    /**
     * 獲取下一個永不重複的id.
     *
     * @return id.
     */
    Serializable nextId();
}

SnowFlakeIdGenerateInvoker.java

/**
 * ======分佈式數據庫主鍵ID生成器基於SnowFlake算法的實現.
 * 本實現類的對象將會在程序啓動的時候自動在Spring IOC容器中註冊爲Bean.
 * 如果想在您的持久化過程中由系統自動設置主鍵字段的值,只需要在具體的POJO字段上添加@PrimaryKey註解,詳見{@link PrimaryKey}.
 * 其默認打開,並且在未指定具體id生成算法的前提下,它將自動使用SnowFlake算法生成id,在持久化之前自動設置值.
 * <p>
 * SnowFlake算法的實現依賴於workerId和dataCenterId兩個值的,系統默認使用5L作爲其初始值.
 * 如果您想要在分佈式環境使用本算法的實現,建議您在application.yml中配置這2個屬性的值.
 */
@Component
public class SnowFlakeIdGenerateInvoker implements IdGenerateInvoker {

    @Autowired
    private SnowFlakeConfig snowFlakeConfig;

    private Snowflake snowflake;

    @PostConstruct
    public void init() {
        long wokerid = snowFlakeConfig.getWorkerId();
        long dataCenterId = snowFlakeConfig.getDataCenterId();
        this.snowflake = new Snowflake(wokerid, dataCenterId);
    }

    @Override
    public synchronized Serializable nextId() {
        return snowflake.nextId();
    }
}

SnowFlakeConfig.java

  • 公司workerId使用位長爲8位,取的是機器IP地址後三位;
  • dataCenterId使用位長爲兩位,配置在配置文件中。
/**
 * 關於SnowFlake的配置.
 * WorkerId和DataCenterId的值請保證集羣中的每個節點不相同.
 * WorkerId取ip地址最後三位,dataCenterId配置在properties.
 */
@Component
@Validated
@Slf4j
@ConfigurationProperties(prefix = "snowflake")
public class SnowFlakeConfig {

    @Value("${spring.application.name}")
    private String appName;

    private long workerId;

    @NotNull
    private long dataCenterId;

    @PostConstruct
    public void init() {
        try {
            String ipAddr = NetUtil.getHostIpAddr();
            log.info("current ip :{}", ipAddr);
            if (!StringUtils.isEmpty(ipAddr)) {
                String[] ipStep = ipAddr.split("\\.");
                this.workerId = Long.valueOf(ipStep[3]);
            } else {
                log.warn("ip address parse error.");
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        log.info("Snowflake:[workerId:{} from ip address tail, dataCenterId:{} from properties] of micro service [{}] has been initialized."
                , workerId, dataCenterId, appName == null ? "" : appName.toUpperCase());
    }

    public long getWorkerId() {
        return workerId;
    }

    public void setWorkerId(long workerId) {
        this.workerId = workerId;
    }

    public long getDataCenterId() {
        return dataCenterId;
    }

    public void setDataCenterId(long dataCenterId) {
        this.dataCenterId = dataCenterId;
    }
}

application.properties

spring.application.name=ncs-case

# snowflake配置
#snowflake.worker-id=
snowflake.data-center-id=2

5. 雪花算法優缺點

優點:

  • 毫秒數在高位,自增序列在低位,整個ID都是趨勢遞增的;

  • 不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的;

  • 可以根據自身業務特性分配bit位,非常靈活。

缺點:

  • 依賴機器時鐘,如果機器時鐘回撥,會導致重複ID生成;

  • 在單機上是遞增的,但是由於設計到分佈式環境,每臺機器上的時鐘不可能完全同步,有時候會出現不是全局遞增的情況(此缺點可以認爲無所謂,一般分佈式ID只要求趨勢遞增,並不會嚴格要求遞增,90%的需求都只要求趨勢遞增)。

補充解決時鐘回撥思路:因爲機器的原因會發生時間回撥,我們的雪花算法是強依賴機器時間的,如果時間發生回撥,有可能會生成重複的ID,在我們上面的nextId中用當前時間和上一次的時間進行判斷,如果當前時間小於上一次的時間那麼肯定是發生了回撥,普通的算法會直接拋出異常。這裏我們可以對其進行優化,一般分爲兩個情況:

  • 如果時間回撥時間較短,比如配置5ms以內,那麼可以直接等待一定的時間,讓機器時間追上來;
  • 如果時間的回撥時間較長,我們不能接受這麼長的阻塞等待,那麼又有兩個策略,直接拒絕,拋出異常,打日誌,或者通知RD時鐘回滾。

四、其他方式

  • 百度開源的分佈式唯一ID生成器UidGenerator

  • 美團點評分佈式ID生成系統 Leaf

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