'百度美團Java開發如何在高併發分佈式下生成全局ID生成策略'

Java 數據庫 Mac電腦 算法 MySQL 美團網 百度 設計 來一杯82年的Java 2019-08-18
"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

RingBuffer填充時機

  • 初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
  • 即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可通過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
  • 週期填充 通過Schedule線程,定時補全空閒slots。可通過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。

美團Leaf

Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:“There are no two identical leaves in the world”,世間不可能存在兩片相同的葉子。

Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。

Leaf-segment 數據庫方案

Leaf-segment 數據庫方案,是在上文描述的在使用數據庫的方案上,做了如下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,造成數據庫壓力大。改為利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完之後再去數據庫獲取新的號段,可以大大的減輕數據庫的壓力。
  • 各個業務不同的發號需求用 biz_tag字段來區分,每個biz-tag的ID獲取相互隔離,互不影響。如果以後有性能需求需要對數據庫擴容,不需要上述描述的複雜的擴容操作,只需要對biz_tag分庫分表就行。

數據庫表設計如下:

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '業務key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
`description` varchar(256) DEFAULT NULL COMMENT '業務key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

原來獲取ID每次都需要寫數據庫,現在只需要把step設置得足夠大,比如1000。那麼只有當1000個號被消耗完了之後才會去重新讀寫一次數據庫。讀寫數據庫的頻率從1減小到了1/step,大致架構如下圖所示:

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

RingBuffer填充時機

  • 初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
  • 即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可通過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
  • 週期填充 通過Schedule線程,定時補全空閒slots。可通過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。

美團Leaf

Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:“There are no two identical leaves in the world”,世間不可能存在兩片相同的葉子。

Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。

Leaf-segment 數據庫方案

Leaf-segment 數據庫方案,是在上文描述的在使用數據庫的方案上,做了如下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,造成數據庫壓力大。改為利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完之後再去數據庫獲取新的號段,可以大大的減輕數據庫的壓力。
  • 各個業務不同的發號需求用 biz_tag字段來區分,每個biz-tag的ID獲取相互隔離,互不影響。如果以後有性能需求需要對數據庫擴容,不需要上述描述的複雜的擴容操作,只需要對biz_tag分庫分表就行。

數據庫表設計如下:

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '業務key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
`description` varchar(256) DEFAULT NULL COMMENT '業務key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

原來獲取ID每次都需要寫數據庫,現在只需要把step設置得足夠大,比如1000。那麼只有當1000個號被消耗完了之後才會去重新讀寫一次數據庫。讀寫數據庫的頻率從1減小到了1/step,大致架構如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

同時Leaf-segment 為了解決 TP999(滿足千分之九百九十九的網絡請求所需要的最低耗時)數據波動大,當號段使用完之後還是會hang在更新數據庫的I/O上,TP999 數據會出現偶爾的尖刺的問題,提供了雙buffer優化。

簡單的說就是,Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味著號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,並且在這期間進來的請求也會因為DB號段沒有取回來,導致線程阻塞。如果請求DB的網絡和DB的性能穩定,這種情況對系統的影響是不大的,但是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會導致整個系統的響應時間變慢。

為了DB取號段的過程能夠做到無阻塞,不需要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中,而不需要等到號段用盡的時候才去更新號段。這樣做就可以很大程度上的降低系統的 TP999 指標。詳細實現如下圖所示:

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

RingBuffer填充時機

  • 初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
  • 即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可通過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
  • 週期填充 通過Schedule線程,定時補全空閒slots。可通過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。

美團Leaf

Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:“There are no two identical leaves in the world”,世間不可能存在兩片相同的葉子。

Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。

Leaf-segment 數據庫方案

Leaf-segment 數據庫方案,是在上文描述的在使用數據庫的方案上,做了如下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,造成數據庫壓力大。改為利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完之後再去數據庫獲取新的號段,可以大大的減輕數據庫的壓力。
  • 各個業務不同的發號需求用 biz_tag字段來區分,每個biz-tag的ID獲取相互隔離,互不影響。如果以後有性能需求需要對數據庫擴容,不需要上述描述的複雜的擴容操作,只需要對biz_tag分庫分表就行。

數據庫表設計如下:

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '業務key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
`description` varchar(256) DEFAULT NULL COMMENT '業務key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

原來獲取ID每次都需要寫數據庫,現在只需要把step設置得足夠大,比如1000。那麼只有當1000個號被消耗完了之後才會去重新讀寫一次數據庫。讀寫數據庫的頻率從1減小到了1/step,大致架構如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

同時Leaf-segment 為了解決 TP999(滿足千分之九百九十九的網絡請求所需要的最低耗時)數據波動大,當號段使用完之後還是會hang在更新數據庫的I/O上,TP999 數據會出現偶爾的尖刺的問題,提供了雙buffer優化。

簡單的說就是,Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味著號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,並且在這期間進來的請求也會因為DB號段沒有取回來,導致線程阻塞。如果請求DB的網絡和DB的性能穩定,這種情況對系統的影響是不大的,但是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會導致整個系統的響應時間變慢。

為了DB取號段的過程能夠做到無阻塞,不需要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中,而不需要等到號段用盡的時候才去更新號段。這樣做就可以很大程度上的降低系統的 TP999 指標。詳細實現如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

採用雙buffer的方式,Leaf服務內部有兩個號段緩存區segment。當前號段已下發10%時,如果下一個號段未更新,則另啟一個更新線程去更新下一個號段。當前號段全部下發完後,如果下個號段準備好了則切換到下個號段為當前segment接著下發,循環往復。

  • 每個biz-tag都有消費速度監控,通常推薦segment長度設置為服務高峰期發號QPS的600倍(10分鐘),這樣即使DB宕機,Leaf仍能持續發號10-20分鐘不受影響。
  • 每次請求來臨時都會判斷下個號段的狀態,從而更新此號段,所以偶爾的網絡抖動不會影響下個號段的更新。

對於這種方案依然存在一些問題,它仍然依賴 DB的穩定性,需要採用主從備份的方式提高 DB的可用性,還有 Leaf-segment方案生成的ID是趨勢遞增的,這樣ID號是可被計算的,例如訂單ID生成場景,通過訂單id號相減就能大致計算出公司一天的訂單量,這個是不能忍受的。

Leaf-snowflake方案

Leaf-snowflake方案完全沿用 snowflake 方案的bit位設計,對於workerID的分配引入了Zookeeper持久順序節點的特性自動對snowflake節點配置 wokerID。避免了服務規模較大時,動手配置成本太高的問題。

Leaf-snowflake是按照下面幾個步驟啟動的:

  • 啟動Leaf-snowflake服務,連接Zookeeper,在leaf_forever父節點下檢查自己是否已經註冊過(是否有該順序子節點)。
  • 如果有註冊過直接取回自己的workerID(zk順序節點生成的int類型ID號),啟動服務。
  • 如果沒有註冊過,就在該父節點下面創建一個持久順序節點,創建成功後取回順序號當做自己的workerID號,啟動服務。
"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

RingBuffer填充時機

  • 初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
  • 即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可通過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
  • 週期填充 通過Schedule線程,定時補全空閒slots。可通過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。

美團Leaf

Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:“There are no two identical leaves in the world”,世間不可能存在兩片相同的葉子。

Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。

Leaf-segment 數據庫方案

Leaf-segment 數據庫方案,是在上文描述的在使用數據庫的方案上,做了如下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,造成數據庫壓力大。改為利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完之後再去數據庫獲取新的號段,可以大大的減輕數據庫的壓力。
  • 各個業務不同的發號需求用 biz_tag字段來區分,每個biz-tag的ID獲取相互隔離,互不影響。如果以後有性能需求需要對數據庫擴容,不需要上述描述的複雜的擴容操作,只需要對biz_tag分庫分表就行。

數據庫表設計如下:

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '業務key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
`description` varchar(256) DEFAULT NULL COMMENT '業務key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

原來獲取ID每次都需要寫數據庫,現在只需要把step設置得足夠大,比如1000。那麼只有當1000個號被消耗完了之後才會去重新讀寫一次數據庫。讀寫數據庫的頻率從1減小到了1/step,大致架構如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

同時Leaf-segment 為了解決 TP999(滿足千分之九百九十九的網絡請求所需要的最低耗時)數據波動大,當號段使用完之後還是會hang在更新數據庫的I/O上,TP999 數據會出現偶爾的尖刺的問題,提供了雙buffer優化。

簡單的說就是,Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味著號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,並且在這期間進來的請求也會因為DB號段沒有取回來,導致線程阻塞。如果請求DB的網絡和DB的性能穩定,這種情況對系統的影響是不大的,但是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會導致整個系統的響應時間變慢。

為了DB取號段的過程能夠做到無阻塞,不需要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中,而不需要等到號段用盡的時候才去更新號段。這樣做就可以很大程度上的降低系統的 TP999 指標。詳細實現如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

採用雙buffer的方式,Leaf服務內部有兩個號段緩存區segment。當前號段已下發10%時,如果下一個號段未更新,則另啟一個更新線程去更新下一個號段。當前號段全部下發完後,如果下個號段準備好了則切換到下個號段為當前segment接著下發,循環往復。

  • 每個biz-tag都有消費速度監控,通常推薦segment長度設置為服務高峰期發號QPS的600倍(10分鐘),這樣即使DB宕機,Leaf仍能持續發號10-20分鐘不受影響。
  • 每次請求來臨時都會判斷下個號段的狀態,從而更新此號段,所以偶爾的網絡抖動不會影響下個號段的更新。

對於這種方案依然存在一些問題,它仍然依賴 DB的穩定性,需要採用主從備份的方式提高 DB的可用性,還有 Leaf-segment方案生成的ID是趨勢遞增的,這樣ID號是可被計算的,例如訂單ID生成場景,通過訂單id號相減就能大致計算出公司一天的訂單量,這個是不能忍受的。

Leaf-snowflake方案

Leaf-snowflake方案完全沿用 snowflake 方案的bit位設計,對於workerID的分配引入了Zookeeper持久順序節點的特性自動對snowflake節點配置 wokerID。避免了服務規模較大時,動手配置成本太高的問題。

Leaf-snowflake是按照下面幾個步驟啟動的:

  • 啟動Leaf-snowflake服務,連接Zookeeper,在leaf_forever父節點下檢查自己是否已經註冊過(是否有該順序子節點)。
  • 如果有註冊過直接取回自己的workerID(zk順序節點生成的int類型ID號),啟動服務。
  • 如果沒有註冊過,就在該父節點下面創建一個持久順序節點,創建成功後取回順序號當做自己的workerID號,啟動服務。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

為了減少對 Zookeeper的依賴性,會在本機文件系統上緩存一個workerID文件。當ZooKeeper出現問題,恰好機器出現問題需要重啟時,能保證服務能夠正常啟動。

上文闡述過在類 snowflake算法上都存在時鐘回撥的問題,Leaf-snowflake在解決時鐘回撥的問題上是通過校驗自身系統時間與 leaf_forever/${self}節點記錄時間做比較然後啟動報警的措施。

"

傳統的單體架構的時候,我們基本是單庫然後業務單表的結構。每個業務表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設置自增起始值,但是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種情況根據數據庫的自增ID就會產生相同ID的情況,不能保證主鍵的唯一性。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

如上圖,如果第一個訂單存儲在 DB1 上則訂單 ID 為1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統的架構雖然是分佈式的,但是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被允許的。那麼針對分佈式系統如何做到主鍵唯一性呢?

UUID

UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,所以UUID理論上的總數為 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年才會將所有UUID用完。

生成的UUID是由 8-4-4-4-12格式的數據組成,其中32個字符和4個連字符' - ',一般我們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")。

目前UUID的產生方式有5種版本,每個版本的算法不同,應用範圍也不同。

  • 基於時間的UUID - 版本1: 這個一般是通過當前時間,隨機數,和本地Mac地址來計算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由於使用了MAC地址,因此能夠確保唯一性,但是同時也暴露了MAC地址,私密性不夠好。
  • DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換為POSIX的UID或GID。這個版本的UUID在實際中較少用到。
  • 基於名字的UUID(MD5)- 版本3 基於名字的UUID通過計算名字和名字空間的MD5散列值得到。這個版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重複生成是相同的。
  • 隨機UUID - 版本4 根據隨機數,或者偽隨機數生成UUID。這種UUID產生重複的概率是可以計算出來的,但是重複的可能性可以忽略不計,因此該版本也是被經常使用的版本。JDK中使用的就是這個版本。
  • 基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法類似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。

我們 Java中 JDK自帶的 UUID產生方式就是版本4根據隨機數生成的 UUID 和版本3基於名字的 UUID,有興趣的可以去看看它的源碼。

public static void main(String[] args) {
//獲取一個版本4根據隨機字節數組的UUID。
UUID uuid = UUID.randomUUID();
System.out.println(uuid.toString().replaceAll("-",""));
//獲取一個版本3(基於名稱)根據指定的字節數組的UUID。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
System.out.println(uuidFromBytes.toString().replaceAll("-",""));
}

得到的UUID結果,

59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6

雖然 UUID 生成方便,本地生成沒有網絡消耗,但是使用起來也有一些缺點,

  • 不易於存儲:UUID太長,16字節128位,通常以36長度的字符串表示,很多場景不適用。
  • 信息不安全:基於MAC地址生成UUID的算法可能會造成MAC地址洩露,暴露使用者的位置。
  • 對MySQL索引不利:如果作為數據庫主鍵,在InnoDB引擎下,UUID的無序性可能會引起數據位置頻繁變動,嚴重影響性能,可以查閱 Mysql 索引原理 B+樹的知識。

數據庫生成

是不是一定要基於外界的條件才能滿足分佈式唯一ID的需求呢,我們能不能在我們分佈式數據庫的基礎上獲取我們需要的ID?

由於分佈式數據庫的起始自增值一樣所以才會有衝突的情況發生,那麼我們將分佈式系統中數據庫的同一個業務表的自增ID設計成不一樣的起始值,然後設置固定的步長,步長的值即為分庫的數量或分表的數量。

以MySQL舉例,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增。

  • auto_increment_offset:表示自增長字段從那個數開始,他的取值範圍是1 .. 65535。
  • auto_increment_increment:表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535。

假設有三臺機器,則DB1中order表的起始ID值為1,DB2中order表的起始值為2,DB3中order表的起始值為3,它們自增的步長都為3,則它們的ID生成範圍如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

通過這種方式明顯的優勢就是依賴於數據庫自身不需要其他資源,並且ID號單調自增,可以實現一些對ID有特殊要求的業務。

但是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製可以儘可能的增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能會導致重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。

使用redis實現

Redis實現分佈式唯一ID主要是通過提供像 INCRINCRBY 這樣的自增原子命令,由於Redis自身的單線程的特點所以能保證生成的 ID 肯定是唯一有序的。

但是單機存在性能瓶頸,無法滿足高併發的業務需求,所以可以採用集群的方式來實現。集群的方式又會涉及到和數據庫集群同樣的問題,所以也需要設置分段和步長來實現。

為了避免長期自增後數字過大可以通過與當前時間戳組合起來使用,另外為了保證併發和業務多線程的問題可以採用 Redis + Lua的方式進行編碼,保證安全。

Redis 實現分佈式全局唯一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,但是同樣它依賴於redis,需要系統引進redis組件,增加了系統的配置複雜性。

當然現在Redis的使用性很普遍,所以如果其他業務已經引進了Redis集群,則可以資源利用考慮使用Redis來實現。

雪花算法-Snowflake

Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每個部分代表不同的含義。而 Java中64bit的整數是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。

  • 第1位佔用1bit,其值始終是0,可看做是符號位不使用。
  • 第2位開始的41位是時間戳,41-bit位可表示2^41個數,每個數代表毫秒,那麼雪花算法可用的時間年限是(1L<<41)/(1000L360024*365)=69 年的時間。
  • 中間的10-bit位可表示機器數,即2^10 = 1024臺機器,但是一般情況下我們不會部署這麼臺機器。如果我們對IDC(互聯網數據中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機器。這樣就可以表示32個IDC,每個IDC下可以有32臺機器,具體的劃分可以根據自身需求定義。
  • 最後12-bit位是自增序列,可表示2^12 = 4096個數。

這樣的劃分之後相當於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。但是我們 IDC 和機器數肯定不止一個,所以毫秒內能生成的有序ID數是翻倍的。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

Snowflake 的Twitter官方原版是用Scala寫的,對Scala語言有研究的同學可以去閱讀下,以下是 Java 版本的寫法。

package com.jajian.demo.distribute;

/**

* Twitter_Snowflake<br>

* SnowFlake的結構如下(每部分用-分開):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位標識,由於long基本類型在Java中是帶符號的,最高位是符號位,正數是0,負數是1,所以id一般是正數,最高位是0<br>

* 41位時間截(毫秒級),注意,41位時間截不是存儲當前時間的時間截,而是存儲時間截的差值(當前時間截 - 開始時間截)

* 得到的值),這裡的的開始時間截,一般是我們的id生成器開始使用的時間,由我們程序來指定的(如下下面程序IdWorker類的startTime屬性)。41位的時間截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的數據機器位,可以部署在1024個節點,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒內的計數,12位的計數順序號支持每個節點每毫秒(同一機器,同一時間截)產生4096個ID序號<br>

* 加起來剛好64位,為一個Long型。<br>

* SnowFlake的優點是,整體上按照時間自增排序,並且整個分佈式系統內不會產生ID碰撞(由數據中心ID和機器ID作區分),並且效率較高,經測試,SnowFlake每秒能夠產生26萬ID左右。

*/

public class SnowflakeDistributeId {

// ==============================Fields===========================================

/**

* 開始時間截 (2015-01-01)

*/

private final long twepoch = 1420041600000L;

/**

* 機器id所佔的位數

*/

private final long workerIdBits = 5L;

/**

* 數據標識id所佔的位數

*/

private final long datacenterIdBits = 5L;

/**

* 支持的最大機器id,結果是31 (這個移位算法可以很快的計算出幾位二進制數所能表示的最大十進制數)

*/

private final long maxWorkerId = -1L ^ (-1L << workerIdBits);

/**

* 支持的最大數據標識id,結果是31

*/

private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);

/**

* 序列在id中佔的位數

*/

private final long sequenceBits = 12L;

/**

* 機器ID向左移12位

*/

private final long workerIdShift = sequenceBits;

/**

* 數據標識id向左移17位(12+5)

*/

private final long datacenterIdShift = sequenceBits + workerIdBits;

/**

* 時間截向左移22位(5+5+12)

*/

private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;

/**

* 生成序列的掩碼,這裡為4095 (0b111111111111=0xfff=4095)

*/

private final long sequenceMask = -1L ^ (-1L << sequenceBits);

/**

* 工作機器ID(0~31)

*/

private long workerId;

/**

* 數據中心ID(0~31)

*/

private long datacenterId;

/**

* 毫秒內序列(0~4095)

*/

private long sequence = 0L;

/**

* 上次生成ID的時間截

*/

private long lastTimestamp = -1L;

//==============================Constructors=====================================

/**

* 構造函數

*

* @param workerId 工作ID (0~31)

* @param datacenterId 數據中心ID (0~31)

*/

public SnowflakeDistributeId(long workerId, long datacenterId) {

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));

}

this.workerId = workerId;

this.datacenterId = datacenterId;

}

// ==============================Methods==========================================

/**

* 獲得下一個ID (該方法是線程安全的)

*

* @return SnowflakeId

*/

public synchronized long nextId() {

long timestamp = timeGen();

//如果當前時間小於上一次ID生成的時間戳,說明系統時鐘回退過這個時候應當拋出異常

if (timestamp < 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;

//毫秒內序列溢出

if (sequence == 0) {

//阻塞到下一個毫秒,獲得新的時間戳

timestamp = tilNextMillis(lastTimestamp);

}

}

//時間戳改變,毫秒內序列重置

else {

sequence = 0L;

}

//上次生成ID的時間截

lastTimestamp = timestamp;

//移位並通過或運算拼到一起組成64位的ID

return ((timestamp - twepoch) << timestampLeftShift) //

| (datacenterId << datacenterIdShift) //

| (workerId << workerIdShift) //

| sequence;

}

/**

* 阻塞到下一個毫秒,直到獲得新的時間戳

*

* @param lastTimestamp 上次生成ID的時間截

* @return 當前時間戳

*/

protected long tilNextMillis(long lastTimestamp) {

long timestamp = timeGen();

while (timestamp <= lastTimestamp) {

timestamp = timeGen();

}

return timestamp;

}

/**

* 返回以毫秒為單位的當前時間

*

* @return 當前時間(毫秒)

*/

protected long timeGen() {

return System.currentTimeMillis();

}

}

測試的代碼如下

public static void main(String[] args) {

SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);

for (int i = 0; i < 1000; i++) {

long id = idWorker.nextId();

// System.out.println(Long.toBinaryString(id));

System.out.println(id);

}

}

雪花算法提供了一個很好的設計思想,雪花算法生成的ID是趨勢遞增,不依賴數據庫等第三方系統,以服務的方式部署,穩定性更高,生成ID的性能也是非常高的,而且可以根據自身業務特性分配bit位,非常靈活。

但是雪花算法強依賴機器時鐘,如果機器上時鐘回撥,會導致發號重複或者服務會處於不可用狀態。如果恰巧回退前生成過一些ID,而時間回退後,生成的ID就有可能重複。官方對於此並沒有給出解決方案,而是簡單的拋錯處理,這樣會造成在時間被追回之前的這段時間服務不可用。

很多其他類雪花算法也是在此思想上的設計然後改進規避它的缺陷,後面介紹的百度 UidGenerator 和 美團分佈式ID生成系統 Leaf 中snowflake模式都是在 snowflake 的基礎上演進出來的。

百度-UidGenerator

百度的 UidGenerator 是百度開源基於Java語言實現的唯一ID生成器,是在雪花算法 snowflake 的基礎上做了一些改進。UidGenerator以組件形式工作在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啟、漂移等場景。

在實現上,UidGenerator 提供了兩種生成唯一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議如果有性能考慮的話使用 CachedUidGenerator 方式實現。

UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的情況和特點,自己調整各個字段佔用的位數。

  • 第1位仍然佔用1bit,其值始終是0。
  • 第2位開始的28位是時間戳,28-bit位可表示2^28個數,這裡不再是以毫秒而是以秒為單位,每個數代表秒則可用(1L<<28)/ (360024365) ≈ 8.51 年的時間。
  • 中間的 workId (數據中心+工作機器,可以其他組成方式)則由 22-bit位組成,可表示 2^22 = 4194304個工作ID。
  • 最後由13-bit位構成自增序列,可表示2^13 = 8192個數。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

其中 workId (機器 id),最多可支持約420w次機器啟動。內置實現為在啟動時由數據庫分配(表名為 WORKER_NODE),默認分配策略為用後即棄,後續可提供複用策略。

DROP TABLE IF EXISTS WORKER_NODE;

CREATE TABLE WORKER_NODE

(

ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',

HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',

PORT VARCHAR(64) NOT NULL COMMENT 'port',

TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',

LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',

MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',

CREATED TIMESTAMP NOT NULL COMMENT 'created time',

PRIMARY KEY(ID)

)

COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 實現

DefaultUidGenerator 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很相似,對於時鐘回撥也只是拋異常處理。僅有一些不同,如以秒為為單位而不再是毫秒和支持Docker等虛擬化環境。

protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}

如果你要使用 DefaultUidGenerator 的實現方式的話,以上劃分的佔用位數可通過 spring 進行參數配置。

<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
<property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
<!-- Specified bits & epoch as your demand. No specified the default value will be used -->
<property name="timeBits" value="29"/>
<property name="workerBits" value="21"/>
<property name="seqBits" value="13"/>
<property name="epochStr" value="2016-09-20"/>
</bean>

CachedUidGenerator 實現

而官方建議的性能較高的 CachedUidGenerator 生成方式,是使用 RingBuffer 緩存生成的id。數組每個元素成為一個slot。RingBuffer容量,默認為Snowflake算法中sequence最大值(2^13 = 8192)。可通過 boostPower 配置進行擴容,以提高 RingBuffer 讀寫吞吐量。

Tail指針、Cursor指針用於環形數組上讀寫slot:

  • Tail指針 表示Producer生產的最大序號(此序號從0開始,持續遞增)。Tail不能超過Cursor,即生產者不能覆蓋未消費的slot。當Tail已趕上curosr,此時可通過rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指針 表示Consumer消費到的最小序號(序號序列與Producer序列相同)。Cursor不能超過Tail,即不能消費未生產的slot。當Cursor已趕上tail,此時可通過rejectedTakeBufferHandler指定TakeRejectPolicy
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。

由於數組元素在內存中是連續分配的,可最大程度利用CPU cache以提升性能。但同時會帶來「偽共享」FalseSharing問題,為此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

RingBuffer填充時機

  • 初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
  • 即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可通過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
  • 週期填充 通過Schedule線程,定時補全空閒slots。可通過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。

美團Leaf

Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:“There are no two identical leaves in the world”,世間不可能存在兩片相同的葉子。

Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。

Leaf-segment 數據庫方案

Leaf-segment 數據庫方案,是在上文描述的在使用數據庫的方案上,做了如下改變:

  • 原方案每次獲取ID都得讀寫一次數據庫,造成數據庫壓力大。改為利用proxy server批量獲取,每次獲取一個segment(step決定大小)號段的值。用完之後再去數據庫獲取新的號段,可以大大的減輕數據庫的壓力。
  • 各個業務不同的發號需求用 biz_tag字段來區分,每個biz-tag的ID獲取相互隔離,互不影響。如果以後有性能需求需要對數據庫擴容,不需要上述描述的複雜的擴容操作,只需要對biz_tag分庫分表就行。

數據庫表設計如下:

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '業務key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '當前已經分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步長,也是動態調整的最小步長',
`description` varchar(256) DEFAULT NULL COMMENT '業務key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

原來獲取ID每次都需要寫數據庫,現在只需要把step設置得足夠大,比如1000。那麼只有當1000個號被消耗完了之後才會去重新讀寫一次數據庫。讀寫數據庫的頻率從1減小到了1/step,大致架構如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

同時Leaf-segment 為了解決 TP999(滿足千分之九百九十九的網絡請求所需要的最低耗時)數據波動大,當號段使用完之後還是會hang在更新數據庫的I/O上,TP999 數據會出現偶爾的尖刺的問題,提供了雙buffer優化。

簡單的說就是,Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味著號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,並且在這期間進來的請求也會因為DB號段沒有取回來,導致線程阻塞。如果請求DB的網絡和DB的性能穩定,這種情況對系統的影響是不大的,但是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會導致整個系統的響應時間變慢。

為了DB取號段的過程能夠做到無阻塞,不需要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中,而不需要等到號段用盡的時候才去更新號段。這樣做就可以很大程度上的降低系統的 TP999 指標。詳細實現如下圖所示:

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

採用雙buffer的方式,Leaf服務內部有兩個號段緩存區segment。當前號段已下發10%時,如果下一個號段未更新,則另啟一個更新線程去更新下一個號段。當前號段全部下發完後,如果下個號段準備好了則切換到下個號段為當前segment接著下發,循環往復。

  • 每個biz-tag都有消費速度監控,通常推薦segment長度設置為服務高峰期發號QPS的600倍(10分鐘),這樣即使DB宕機,Leaf仍能持續發號10-20分鐘不受影響。
  • 每次請求來臨時都會判斷下個號段的狀態,從而更新此號段,所以偶爾的網絡抖動不會影響下個號段的更新。

對於這種方案依然存在一些問題,它仍然依賴 DB的穩定性,需要採用主從備份的方式提高 DB的可用性,還有 Leaf-segment方案生成的ID是趨勢遞增的,這樣ID號是可被計算的,例如訂單ID生成場景,通過訂單id號相減就能大致計算出公司一天的訂單量,這個是不能忍受的。

Leaf-snowflake方案

Leaf-snowflake方案完全沿用 snowflake 方案的bit位設計,對於workerID的分配引入了Zookeeper持久順序節點的特性自動對snowflake節點配置 wokerID。避免了服務規模較大時,動手配置成本太高的問題。

Leaf-snowflake是按照下面幾個步驟啟動的:

  • 啟動Leaf-snowflake服務,連接Zookeeper,在leaf_forever父節點下檢查自己是否已經註冊過(是否有該順序子節點)。
  • 如果有註冊過直接取回自己的workerID(zk順序節點生成的int類型ID號),啟動服務。
  • 如果沒有註冊過,就在該父節點下面創建一個持久順序節點,創建成功後取回順序號當做自己的workerID號,啟動服務。
百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

為了減少對 Zookeeper的依賴性,會在本機文件系統上緩存一個workerID文件。當ZooKeeper出現問題,恰好機器出現問題需要重啟時,能保證服務能夠正常啟動。

上文闡述過在類 snowflake算法上都存在時鐘回撥的問題,Leaf-snowflake在解決時鐘回撥的問題上是通過校驗自身系統時間與 leaf_forever/${self}節點記錄時間做比較然後啟動報警的措施。

百度美團Java開發如何在高併發分佈式下生成全局ID生成策略

美團官方建議是由於強依賴時鐘,對時間的要求比較敏感,在機器工作時NTP同步也會造成秒級別的回退,建議可以直接關閉NTP同步。要麼在時鐘回撥的時候直接不提供服務直接返回ERROR_CODE,等時鐘追上即可。或者做一層重試,然後上報報警系統,更或者是發現有時鐘回撥之後自動摘除本身節點並報警。

在性能上官方提供的數據目前 Leaf 的性能在4C8G 的機器上QPS能壓測到近5w/s,TP999 1ms。

總結

以上基本列出了所有常用的分佈式ID生成方式,其實大致分類的話可以分為兩類:

一種是類DB型的,根據設置不同起始值和步長來實現趨勢遞增,需要考慮服務的容錯性和可用性。

另一種是類snowflake型,這種就是將64位劃分為不同的段,每段代表不同的涵義,基本就是時間戳、機器ID和序列數。這種方案就是需要考慮時鐘回撥的問題以及做一些 buffer的緩衝設計提高性能。

而且可通過將三者(時間戳,機器ID,序列數)劃分不同的位數來改變使用壽命和併發數。

例如對於併發數要求不高、期望長期使用的應用,可增加時間戳位數,減少序列數的位數. 例如配置成{"workerBits":23,"timeBits":31,"seqBits":9}時, 可支持28個節點以整體併發量14400 UID/s的速度持續運行68年。

對於節點重啟頻率頻繁、期望長期使用的應用, 可增加工作機器位數和時間戳位數, 減少序列數位數. 例如配置成{"workerBits":27,"timeBits":30,"seqBits":6}時, 可支持37個節點以整體併發量2400 UID/s的速度持續運行34年。

關注我:私信回覆“555”獲取往期Java高級架構資料、源碼、筆記、視頻Dubbo、Redis、Netty、zookeeper、Spring cloud、分佈式、高併發等架構技術往期架構視頻

"

相關推薦

推薦中...