分布式理論:分布式 ID 方案、雪花算法與時(shí)鐘回?fù)軉栴}
一、為什么需要全局唯一ID
傳統(tǒng)的單體架構(gòu)的時(shí)候,我們基本是單庫然后業(yè)務(wù)單表的結(jié)構(gòu)。每個(gè)業(yè)務(wù)表的ID一般我們都是從1增,通過AUTO_INCREMENT=1設(shè)置自增起始值,但是在分布式服務(wù)架構(gòu)模式下分庫分表的設(shè)計(jì),使得多個(gè)庫或多個(gè)表存儲相同的業(yè)務(wù)數(shù)據(jù)。這種情況根據(jù)數(shù)據(jù)庫的自增ID就會(huì)產(chǎn)生相同ID的情況,不能保證主鍵的唯一性。
如上圖,如果第一個(gè)訂單存儲在 DB1 上則訂單 ID 為1,當(dāng)一個(gè)新訂單又入庫了存儲在 DB2 上訂單 ID 也為1。我們系統(tǒng)的架構(gòu)雖然是分布式的,但是在用戶層應(yīng)是無感知的,重復(fù)的訂單主鍵顯而易見是不被允許的。那么針對分布式系統(tǒng)如何做到主鍵唯一性呢?
二、UUID
UUID (Universally Unique Identifier),通用唯一識別碼的縮寫。UUID是由一組32位數(shù)的16進(jìn)制數(shù)字所構(gòu)成,所以UUID理論上的總數(shù)為 16^32=2^128,約等于 3.4 x 10^38。也就是說若每納秒產(chǎn)生1兆個(gè)UUID,要花100億年才會(huì)將所有UUID用完。
生成的UUID是由 8-4-4-4-12格式的數(shù)據(jù)組成,其中32個(gè)字符和4個(gè)連字符' - ',一般我們使用的時(shí)候會(huì)將連字符刪除 uuid.toString().replaceAll("-","")。
目前UUID的產(chǎn)生方式有5種版本,每個(gè)版本的算法不同,應(yīng)用范圍也不同。
- 基于時(shí)間的UUID - 版本1:這個(gè)一般是通過當(dāng)前時(shí)間,隨機(jī)數(shù),和本地Mac地址來計(jì)算出來,可以通過 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()來使用或者其他包中工具。由于使用了MAC地址,因此能夠確保唯一性,但是同時(shí)也暴露了MAC地址,私密性不夠好。
- DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基于時(shí)間的UUID算法相同,但會(huì)把時(shí)間戳的前4位置換為POSIX的UID或GID。這個(gè)版本的UUID在實(shí)際中較少用到。
- 基于名字的UUID(MD5)- 版本3 基于名字的UUID通過計(jì)算名字和名字空間的MD5散列值得到。這個(gè)版本的UUID保證了:相同名字空間中不同名字生成的UUID的唯一性;不同名字空間中的UUID的唯一性;相同名字空間中相同名字的UUID重復(fù)生成是相同的。
- 隨機(jī)UUID - 版本4 根據(jù)隨機(jī)數(shù),或者偽隨機(jī)數(shù)生成UUID。這種UUID產(chǎn)生重復(fù)的概率是可以計(jì)算出來的,但是重復(fù)的可能性可以忽略不計(jì),因此該版本也是被經(jīng)常使用的版本。JDK中使用的就是這個(gè)版本。
- 基于名字的UUID(SHA1) - 版本5 和基于名字的UUID算法類似,只是散列值計(jì)算使用SHA1(Secure Hash Algorithm 1)算法。
雖然 UUID 生成方便,本地生成沒有網(wǎng)絡(luò)消耗,但是使用起來也有一些缺點(diǎn),
- 不易于存儲:UUID太長,16字節(jié)128位,通常以36長度的字符串表示,很多場景不適用。
- 信息不安全:基于MAC地址生成UUID的算法可能會(huì)造成MAC地址泄露,暴露使用者的位置。
- 對MySQL索引不利:如果作為數(shù)據(jù)庫主鍵,在InnoDB引擎下,UUID的無序性可能會(huì)引起數(shù)據(jù)位置頻繁變動(dòng),嚴(yán)重影響性能。
三、中間層
從為什么需要全局唯一ID里面可知,是因?yàn)楂@取ID的源從以前的一個(gè)變成了多個(gè),所以才會(huì)導(dǎo)致重復(fù),那我們只需要做一個(gè)公共的獲取ID的中間層就可以了,像利用公共的Mqsql、公共的Redis、公共的MongoDB、公共的ID生成服務(wù)組件都是這樣的道理。
總的來說有三種方案:
(1) 單純遞增
比如:Mysql的自增、redis的INCR、mongoDB的自增等,優(yōu)點(diǎn)是簡單易實(shí)現(xiàn),缺點(diǎn)是競爭過大、吞吐量低、可用性差。
(2) 步長遞增
以Mysql舉例,假設(shè)現(xiàn)在根據(jù)業(yè)務(wù)分了4個(gè)庫了,代表有4個(gè)表,分別是T1、T2、T3、T4,我們設(shè)置它們的起始值為1,2,3,4,步長為4,那它們id則為下所示:
T1:1,5,9,13,17
T2:2,6,10,14,18
T3:3,7,11,15,19
T4:4,8,12,16,20
可見一樣也不會(huì)重復(fù),優(yōu)點(diǎn)是打破單調(diào)遞增的局限、性能相比較好,缺點(diǎn)不利于拓展,特殊情況可能會(huì)導(dǎo)致重復(fù)。
(3) 號段模式
同樣以Mysql為例,以上ID都是一個(gè)一個(gè)的分發(fā),自然導(dǎo)致了并發(fā)可能過大的問題,此模式就是為了降低對中間層的訪問次數(shù),一次性發(fā)放一批ID給客戶端緩存,用完了在重新獲取,這就是這模式的核心思想,具體以實(shí)際為準(zhǔn)。
四、雪花算法-Snowflake
Snowflake,雪花算法是由Twitter開源的分布式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個(gè)部分,每個(gè)部分代表不同的含義。而 Java中64bit的整數(shù)是Long類型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。
- 第1位占用1bit,其值始終是0,可看做是符號位不使用。
- 第2位開始的41位是時(shí)間戳,41-bit位可表示2^41個(gè)數(shù),每個(gè)數(shù)代表毫秒,那么雪花算法可用的時(shí)間年限是(1L<<41)/(1000L360024*365)=69 年的時(shí)間。
- 中間的10-bit位可表示機(jī)器數(shù),即2^10 = 1024臺機(jī)器,但是一般情況下我們不會(huì)部署這么臺機(jī)器。如果我們對IDC(互聯(lián)網(wǎng)數(shù)據(jù)中心)有需求,還可以將 10-bit 分 5-bit 給 IDC,分5-bit給工作機(jī)器。這樣就可以表示32個(gè)IDC,每個(gè)IDC下可以有32臺機(jī)器,具體的劃分可以根據(jù)自身需求定義。
- 最后12-bit位是自增序列,可表示2^12 = 4096個(gè)數(shù)。
這樣的劃分之后相當(dāng)于在一毫秒一個(gè)數(shù)據(jù)中心的一臺機(jī)器上可產(chǎn)生4096個(gè)有序的不重復(fù)的ID。但是我們 IDC 和機(jī)器數(shù)肯定不止一個(gè),所以毫秒內(nèi)能生成的有序ID數(shù)是翻倍的。
雪花算法提供了一個(gè)很好的設(shè)計(jì)思想,雪花算法生成的ID是趨勢遞增,不依賴數(shù)據(jù)庫等第三方系統(tǒng),以服務(wù)的方式部署,穩(wěn)定性更高,生成ID的性能也是非常高的,而且可以根據(jù)自身業(yè)務(wù)特性分配bit位,非常靈活。
但是雪花算法強(qiáng)依賴機(jī)器時(shí)鐘,如果機(jī)器上時(shí)鐘回?fù)?,?huì)導(dǎo)致發(fā)號重復(fù)或者服務(wù)會(huì)處于不可用狀態(tài)。如果恰巧回退前生成過一些ID,而時(shí)間回退后,生成的ID就有可能重復(fù)。官方對于此并沒有給出解決方案,而是簡單的拋錯(cuò)處理,這樣會(huì)造成在時(shí)間被追回之前的這段時(shí)間服務(wù)不可用。并發(fā)足夠大的情況下,ID會(huì)用完,因?yàn)樽栽鲂蛄袝?huì)達(dá)到上限。
以上每種模式都有自己的缺點(diǎn),實(shí)際上大多是以上幾種模式的結(jié)合來運(yùn)用的,比如美團(tuán)的Leaf、百度的UidGenerator,所以大方向是兩個(gè)方案:
- 綜合后的緩存+號段
- 雪花的改良,天然的唯一性。
五、如何解決時(shí)鐘回?fù)軉栴}
時(shí)鐘回?fù)埽褐傅氖窍到y(tǒng)時(shí)鐘被向后調(diào)整到之前的時(shí)間,也就是在某一瞬間,系統(tǒng)時(shí)間突然跳回到之前的某個(gè)時(shí)間點(diǎn);
這對雪花來說無疑是致命!
這個(gè)問題談不上解決,只能說規(guī)避或者保證唯一的解決方案:
(1) 拒絕策略
這個(gè)很簡單,就是檢測到時(shí)鐘回?fù)芎?,拒絕ID的生成。
(2) 使用物理時(shí)鐘和邏輯時(shí)鐘結(jié)合的方式
物理時(shí)鐘是指系統(tǒng)硬件上的時(shí)鐘,它具有不同步的風(fēng)險(xiǎn)。而邏輯時(shí)鐘則是指根據(jù)本地時(shí)鐘和網(wǎng)絡(luò)時(shí)間協(xié)議(NTP)獲取的網(wǎng)絡(luò)時(shí)鐘計(jì)算出來的時(shí)間戳,它具有更好的同步性能。
在使用邏輯時(shí)鐘時(shí),可以將本地的邏輯時(shí)鐘與 NTP 服務(wù)提供的時(shí)間進(jìn)行比較,并使用兩個(gè)時(shí)鐘之間的差值來確定當(dāng)前的本地時(shí)間。如果本地時(shí)鐘發(fā)生回?fù)?,可以通過記錄回?fù)艿男畔⒁约疤幚砘負(fù)芮昂蟮臅r(shí)間變化來避免ID重復(fù)或生成失敗。
(3) 時(shí)間后移消費(fèi)
每次產(chǎn)生ID的時(shí)候保存一次時(shí)間,發(fā)生時(shí)間回?fù)艿臅r(shí)候,取出緩存的時(shí)間+1s,缺點(diǎn)是時(shí)間可能會(huì)錯(cuò)位。