傳統的單體架構的時候,咱們基本是單庫而後業務單表的結構。每一個業務表的ID通常咱們都是從1增,經過AUTO_INCREMENT=1
設置自增起始值,可是在分佈式服務架構模式下分庫分表的設計,使得多個庫或多個表存儲相同的業務數據。這種狀況根據數據庫的自增ID就會產生相同ID的狀況,不能保證主鍵的惟一性。html
如上圖,若是第一個訂單存儲在 DB1 上則訂單 ID 爲1,當一個新訂單又入庫了存儲在 DB2 上訂單 ID 也爲1。咱們系統的架構雖然是分佈式的,可是在用戶層應是無感知的,重複的訂單主鍵顯而易見是不被容許的。那麼針對分佈式系統如何作到主鍵惟一性呢?java
UUID (Universally Unique Identifier),通用惟一識別碼的縮寫。UUID是由一組32位數的16進制數字所構成,因此UUID理論上的總數爲 16^32=2^128,約等於 3.4 x 10^38。也就是說若每納秒產生1兆個UUID,要花100億年纔會將全部UUID用完。node
生成的UUID是由 8-4-4-4-12
格式的數據組成,其中32個字符和4個連字符' - ',通常咱們使用的時候會將連字符刪除 uuid.toString().replaceAll("-","")
。git
目前UUID的產生方式有5種版本,每一個版本的算法不一樣,應用範圍也不一樣。github
基於時間的UUID - 版本1: 這個通常是經過當前時間,隨機數,和本地Mac地址來計算出來,能夠經過 org.apache.logging.log4j.core.util
包中的 UuidUtil.getTimeBasedUuid()
來使用或者其餘包中工具。因爲使用了MAC地址,所以可以確保惟一性,可是同時也暴露了MAC地址,私密性不夠好。redis
DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基於時間的UUID算法相同,但會把時間戳的前4位置換爲POSIX的UID或GID。這個版本的UUID在實際中較少用到。算法
基於名字的UUID(MD5)- 版本3 基於名字的UUID經過計算名字和名字空間的MD5散列值獲得。這個版本的UUID保證了:相同名字空間中不一樣名字生成的UUID的惟一性;不一樣名字空間中的UUID的惟一性;相同名字空間中相同名字的UUID重複生成是相同的。spring
隨機UUID - 版本4 根據隨機數,或者僞隨機數生成UUID。這種UUID產生重複的機率是能夠計算出來的,可是重複的可能性能夠忽略不計,所以該版本也是被常用的版本。JDK中使用的就是這個版本。sql
基於名字的UUID(SHA1) - 版本5 和基於名字的UUID算法相似,只是散列值計算使用SHA1(Secure Hash Algorithm 1)算法。docker
咱們 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 生成方便,本地生成沒有網絡消耗,可是使用起來也有一些缺點,
是否是必定要基於外界的條件才能知足分佈式惟一ID的需求呢,咱們能不能在咱們分佈式數據庫的基礎上獲取咱們須要的ID?
因爲分佈式數據庫的起始自增值同樣因此纔會有衝突的狀況發生,那麼咱們將分佈式系統中數據庫的同一個業務表的自增ID設計成不同的起始值,而後設置固定的步長,步長的值即爲分庫的數量或分表的數量。
以MySQL舉例,利用給字段設置auto_increment_increment
和auto_increment_offset
來保證ID自增。
假設有三臺機器,則DB1中order表的起始ID值爲1,DB2中order表的起始值爲2,DB3中order表的起始值爲3,它們自增的步長都爲3,則它們的ID生成範圍以下圖所示:
經過這種方式明顯的優點就是依賴於數據庫自身不須要其餘資源,而且ID號單調自增,能夠實現一些對ID有特殊要求的業務。
可是缺點也很明顯,首先它強依賴DB,當DB異常時整個系統不可用。雖然配置主從複製能夠儘量的增長可用性,可是數據一致性在特殊狀況下難以保證。主從切換時的不一致可能會致使重複發號。還有就是ID發號性能瓶頸限制在單臺MySQL的讀寫性能。
Redis實現分佈式惟一ID主要是經過提供像 INCR 和 INCRBY 這樣的自增原子命令,因爲Redis自身的單線程的特色因此能保證生成的 ID 確定是惟一有序的。
可是單機存在性能瓶頸,沒法知足高併發的業務需求,因此能夠採用集羣的方式來實現。集羣的方式又會涉及到和數據庫集羣一樣的問題,因此也須要設置分段和步長來實現。
爲了不長期自增後數字過大能夠經過與當前時間戳組合起來使用,另外爲了保證併發和業務多線程的問題能夠採用 Redis + Lua的方式進行編碼,保證安全。
Redis 實現分佈式全局惟一ID,它的性能比較高,生成的數據是有序的,對排序業務有利,可是一樣它依賴於redis,須要系統引進redis組件,增長了系統的配置複雜性。
固然如今Redis的使用性很廣泛,因此若是其餘業務已經引進了Redis集羣,則能夠資源利用考慮使用Redis來實現。
Snowflake,雪花算法是由Twitter開源的分佈式ID生成算法,以劃分命名空間的方式將 64-bit位分割成多個部分,每一個部分表明不一樣的含義。而 Java中64bit的整數是Long類型,因此在 Java 中 SnowFlake 算法生成的 ID 就是 long 來存儲的。
這樣的劃分以後至關於在一毫秒一個數據中心的一臺機器上可產生4096個有序的不重複的ID。可是咱們 IDC 和機器數確定不止一個,因此毫秒內能生成的有序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 是百度開源基於Java語言實現的惟一ID生成器,是在雪花算法 snowflake 的基礎上作了一些改進。UidGenerator以組件形式工做在應用項目中, 支持自定義workerId位數和初始化策略,適用於docker等虛擬化環境下實例自動重啓、漂移等場景。
在實現上,UidGenerator 提供了兩種生成惟一ID方式,分別是 DefaultUidGenerator 和 CachedUidGenerator,官方建議若是有性能考慮的話使用 CachedUidGenerator 方式實現。
UidGenerator 依然是以劃分命名空間的方式將 64-bit位分割成多個部分,只不過它的默認劃分方式有別於雪花算法 snowflake。它默認是由 1-28-22-13 的格式進行劃分。可根據你的業務的狀況和特色,本身調整各個字段佔用的位數。
其中 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 就是正常的根據時間戳和機器位還有序列號的生成方式,和雪花算法很類似,對於時鐘回撥也只是拋異常處理。僅有一些不一樣,如以秒爲爲單位而再也不是毫秒和支持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 生成方式,是使用 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
CachedUidGenerator採用了雙RingBuffer,Uid-RingBuffer用於存儲Uid、Flag-RingBuffer用於存儲Uid狀態(是否可填充、是否可消費)。
因爲數組元素在內存中是連續分配的,可最大程度利用CPU cache以提高性能。但同時會帶來「僞共享」FalseSharing問題,爲此在Tail、Cursor指針、Flag-RingBuffer中採用了CacheLine 補齊方式。
RingBuffer填充時機
初始化預填充 RingBuffer初始化時,預先填充滿整個RingBuffer。
即時填充 Take消費時,即時檢查剩餘可用slot量(tail - cursor),如小於設定閾值,則補全空閒slots。閾值可經過paddingFactor來進行配置,請參考Quick Start中CachedUidGenerator配置。
週期填充 經過Schedule線程,定時補全空閒slots。可經過scheduleInterval配置,以應用定時填充功能,並指定Schedule時間間隔。
Leaf是美團基礎研發平臺推出的一個分佈式ID生成服務,名字取自德國哲學家、數學家萊布尼茨的著名的一句話:「There are no two identical leaves in the world」,世間不可能存在兩片相同的葉子。
Leaf 也提供了兩種ID生成的方式,分別是 Leaf-segment 數據庫方案和 Leaf-snowflake 方案。
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,大體架構以下圖所示:
同時Leaf-segment 爲了解決 TP999(知足千分之九百九十九的網絡請求所須要的最低耗時)數據波動大,當號段使用完以後仍是會hang在更新數據庫的I/O上,TP999 數據會出現偶爾的尖刺的問題,提供了雙buffer優化。
簡單的說就是,Leaf 取號段的時機是在號段消耗完的時候進行的,也就意味着號段臨界點的ID下發時間取決於下一次從DB取回號段的時間,而且在這期間進來的請求也會由於DB號段沒有取回來,致使線程阻塞。若是請求DB的網絡和DB的性能穩定,這種狀況對系統的影響是不大的,可是假如取DB的時候網絡發生抖動,或者DB發生慢查詢就會致使整個系統的響應時間變慢。
爲了DB取號段的過程可以作到無阻塞,不須要在DB取號段的時候阻塞請求線程,即當號段消費到某個點時就異步的把下一個號段加載到內存中,而不須要等到號段用盡的時候纔去更新號段。這樣作就能夠很大程度上的下降系統的 TP999 指標。詳細實現以下圖所示:
採用雙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方案徹底沿用 snowflake 方案的bit位設計,對於workerID的分配引入了Zookeeper持久順序節點的特性自動對snowflake節點配置 wokerID。避免了服務規模較大時,動手配置成本過高的問題。
Leaf-snowflake是按照下面幾個步驟啓動的:
爲了減小對 Zookeeper的依賴性,會在本機文件系統上緩存一個workerID文件。當ZooKeeper出現問題,剛好機器出現問題須要重啓時,能保證服務可以正常啓動。
上文闡述過在類 snowflake算法上都存在時鐘回撥的問題,Leaf-snowflake在解決時鐘回撥的問題上是經過校驗自身系統時間與 leaf_forever/${self}
節點記錄時間作比較而後啓動報警的措施。
美團官方建議是因爲強依賴時鐘,對時間的要求比較敏感,在機器工做時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年。
參考: