最近在工作中用到了分佈式唯一Id,於是乎翻閱了很多文章,學習到了很多實現方案,還有許多大廠的實現方案,廢話不多說,我們來學習一下。
何爲分佈式Id
Id是數據的唯一標識,比較傳統、我們熟知做法是利用UUID或者數據庫的自增Id,由於UUID是無序性、沒有絲毫可讀性的,所以對於數據庫來說,存儲UUID來作爲主鍵Id,是沒有任何意義且查詢性能極差的,相比較還是數據庫的自增Id比較合適,但是隨着數據量的不斷增加,增加到配置主從庫也扛不住的時候,就必須要對數據進行分表了吧,那麼問題就出現了,一旦分表,每個表中的數據都會進行自增長,很有可能出現Id衝突。這時,就需要一個單獨的機制來負責生成唯一Id,生成出來的Id就叫做分佈式Id。下面就來分析一下分佈式Id的多種生成機制。
UUID和數據庫自增長Id上面已經簡單說過了,UUID就是無序、無意義的字符串,用作分佈式Id的話可讀性差且效率低下;使用數據庫自增長Id的話, 需要一個單獨的數據庫實例,雖然可行,但是數據量大的時候性能會差,而且訪問量激增時,數據庫一旦扛不住,嗝屁了,下線了,後果可想而知,用它來實現分佈式服務風險比較大,也不推薦!
數據庫多主模式
上邊說了數據庫自增長Id的方式不推薦,其實是單數據庫實例不推薦,那我們可以優化嘛,白的不行整啤的對不對,單的既然不行,那就搞個集羣嘛,換成主從模式集羣。如果害怕單主節點掛掉沒法用,那就做雙主模式集羣,說白了就是兩個數據庫實例,都讓它去單獨的生產自增Id,一個掛了還有另一個。是不是可靠多了。
那這就會有人問,兩個數據庫實例的自增ID都從1開始,不還是會有可能造成Id重複嘛,是的,不過有辦法解決:
設置起始值和增長步長。
咳咳,開個題外話:
mysql中有自增長字段,在做數據庫的主主同步時需要設置自增長的兩個相關配置:auto_increment_offset和auto_increment_increment。 auto_increment_offset表示自增長字段從那個數開始,他的取值範圍是1 .. 65535 auto_increment_increment表示自增長字段每次遞增的量,其默認值是1,取值範圍是1 .. 65535 在主主同步配置時,需要將兩臺服務器的auto_increment_increment增長量都配置爲2,而要把auto_increment_offset分別配置爲1和2. 這樣纔可以避免兩臺服務器同時做更新時自增長字段的值之間發生衝突。
那麼我們可以這樣配置:
數據庫1:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步長
數據庫2:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步長
那麼兩個數據庫實例返回的主鍵Id就是:
1、3、5、7、9 2、4、6、8、10
這種方式其實在公司體量比較小、訪問量也不大的時候,完全可以扛得住,實現扛不住還可以再擴容增加數據庫節點,兩個不行就仨,下面是三個數據庫的時候:
但是,這種實現方案有一定的缺陷:
當已經存在兩個數據庫實例,要新增一個實例的時候,我們要人工去修改前兩個實例的自增長步長爲3,這是需要時間的,而且,前兩個實例還在不停自增長,對於實例3的起始值我們可能要定得大一點,必須要比前兩的實例自增長Id都要大好多才行,否則還是會出現重複Id;在修改步長的時候也很有可能會出現重複Id,要解決這個問題,可能需要停機,這可以說是麻煩的。
號段模式
號段模式可以理解成從數據庫批量獲取Id,這樣的話,可以將批量獲取的Id緩存到本地,那麼也就不需要每次都去訪問數據庫,將大大提供業務應用獲取Id的效率。 具體方案是每次從數據庫取出一個號段範圍,例如 (1,1000] 代表1000個Id,具體的分佈式Id服務將本號段生成1~1000的自增Id並加載到內存,依次返回即可,而不需要每次都請求數據庫,一直到本地自增到1000時,也就是當前號段已經被用完時,纔去數據庫重新獲取下一號段。我們可以這樣去設計數據庫表:
CREATE TABLE id_generator ( id int(10) NOT NULL, current_max_id bigint(20) NOT NULL COMMENT '當前最大id', increment_step int(10) NOT NULL COMMENT '號段的長度', biz_type VARCHAR (64) NOT NULL COMMENT '業務類型', version int(20) NOT NULL COMMENT '版本號', PRIMARY KEY ('id') ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
- current_max_id代表當前最大的可用id
- increment_step代表號段的長度,可以根據每個業務的qps來設置一個合理的長度
- 這裏我們增加了biz_type,這個代表業務類型,不同的業務的id隔離
- version是一個樂觀鎖,每次更新都加上version,能夠保證併發更新的正確性
那麼我們可以通過如下幾個步驟來獲取一個可用的號段:
-
A.查詢當前的current_max_id信息:select id, current_max_id, increment_step, version from id_generator where biz_type='test';
- B.計算新的current_max_id: new_max_id = current_max_id + increment_step
- C.更新DB中的current_max_id :update id_generator set current_max_id =#{new_max_id} , verison=version+1 where id=#{id} and current_max_id=#{current_max_id} and version=#{version};
- D.如果更新成功,則可用號段獲取成功,新的可用號段爲(current_max_id, new_max_id]
- E.如果更新失敗,則號段可能被其他線程獲取,回到步驟A,進行重試
爲了提供數據庫層的高可用,需要對數據庫使用多主模式進行部署,對於每個數據庫來說要保證生成的號段不重複,這就需要利用最開始的思路,再在剛剛的id_generator表中增加起始值和步長,比如如果現在是兩臺數據庫實例,那麼
數據庫1將生成號段(1,1001],自增的時候序列爲1,3,4,5,7....
數據庫2將生成號段(2,1002],自增的時候序列爲2,4,6,8,10...
號段模式的好處就是不會頻繁操作數據庫,對數據庫的壓力會小一些。
redis模式
使用redis來生成分佈式Id,其實和利用數據庫自增Id類似,可以利用redis中的incr命令來實現原子性的自增與返回,比如:
127.0.0.1:6379> set seq_id 1 // 初始化自增ID爲1 OK 127.0.0.1:6379> incr seq_id // 增加1,並返回 (integer) 2 127.0.0.1:6379> incr seq_id // 增加1,並返回 (integer) 3
使用redis的性能是非常好的,另外redis是單線程的,沒有線程安全問題,能保證Id趨勢遞增,但是如果redis需要遷移的話,需要保證遷移過程中的數據一致性,難度較大,而且還要考慮持久化的問題,redis支持RDB和AOF兩種持久化的方式,如果使用RDB方式,持久化相當於定時打一個快照進行持久化,如果打完快照後,又自增了幾次,還沒來得及做下一次快照持久化,這個時候redis掛掉了,重啓redis後會出現Id重複;如果使用AOF方式, 持久化相當於對每條寫命令進行持久化,如果redis掛掉了,不會出現Id重複的現象,但是這種方式重啓redis恢復數據時間過長。
雪花算法(Snowflake)模式
雪花算法(Snowflake)是twitter開源的分佈式ID生成算法,是一種算法,所以它和上面的幾種分佈式Id的生成機制不太一樣,它不依賴數據庫。 核心思想是:分佈式ID固定是一個long型的數字,一個long型佔8個字節,也就是64個bit,原始snowflake算法中對於bit的分配如下圖:
圖片來源於網絡
- 第一個bit位(1bit): 是標識部分,Java中long的最高位是符號位代表正負,正數是0,負數是1,一般生成Id都爲正數,所以固定爲0
- 時間戳部分(41bit): 毫秒級的時間 ,一般不會存儲當前的時間戳,而是時間戳的差值(當前時間-固定的開始時間),這樣可以使產生的Id從更小值開始;41位的時間戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
- 工作機器id(10bit):也被叫做workId,這個可以靈活配置,機房標識或者機器號組合都可以,10位可以部署1024個節點
- 序列號部分(12bit),自增值,支持支持同一毫秒內同一個節點可以生成4096個Id
根據這個算法的邏輯,只需要將這個算法用編程語言實現出來,封裝爲一個工具方法,那麼各個業務系統可以直接使用該工具方法來獲取分佈式ID,只需保證每個業務系統有自己的工作機器id即可,而不需要單獨去搭建一個獲取分佈式Id的應用。
snowflake算法實現起來也並不難,提供一個github上用Java實現的:https://github.com/beyondfengyu/SnowFlake
這裏用Python實現一個:
import time import logging # 64位ID的劃分 WORKER_ID_BITS = 5 DATACENTER_ID_BITS = 5 SEQUENCE_BITS = 12 # 最大取值計算 MAX_WORKER_ID = -1 ^ (-1 << WORKER_ID_BITS) # 2**5-1 0b11111 MAX_DATACENTER_ID = -1 ^ (-1 << DATACENTER_ID_BITS) # 移位偏移計算 WOKER_ID_SHIFT = SEQUENCE_BITS DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS # 序號循環掩碼 SEQUENCE_MASK = -1 ^ (-1 << SEQUENCE_BITS) # Twitter元年時間戳 TWEPOCH = 1288834974657 class IdWorker(object): """ 用於生成Id """ def __init__(self, datacenter_id, worker_id, sequence=0): """ 初始化 :param datacenter_id: 數據中心(機器區域)id :param worker_id: 機器id :param sequence: 起始序號 """ # sanity check if worker_id > MAX_WORKER_ID or worker_id < 0: raise ValueError('worker_id值越界') if datacenter_id > MAX_DATACENTER_ID or datacenter_id < 0: raise ValueError('datacenter_id值越界') self.worker_id = worker_id self.datacenter_id = datacenter_id self.sequence = sequence self.last_timestamp = -1 # 上次計算的時間戳 def _gen_timestamp(self): """ 生成整數時間戳 :return:int timestamp """ return int(time.time() * 1000) def get_id(self): """ 獲取新ID :return: """ timestamp = self._gen_timestamp() # 時鐘回撥 if timestamp < self.last_timestamp: logging.error('clock is moving backwards. Rejecting requests until {}'.format(self.last_timestamp)) raise Exception("clock is moving backwards. Rejecting requests until {}".format(self.last_timestamp)) if timestamp == self.last_timestamp: self.sequence = (self.sequence + 1) & SEQUENCE_MASK if self.sequence == 0: timestamp = self._til_next_millis(self.last_timestamp) else: self.sequence = 0 self.last_timestamp = timestamp new_id = ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) | (self.datacenter_id << DATACENTER_ID_SHIFT) | \ (self.worker_id << WOKER_ID_SHIFT) | self.sequence return new_id def _til_next_millis(self, last_timestamp): """ 等到下一毫秒 """ timestamp = self._gen_timestamp() while timestamp <= last_timestamp: timestamp = self._gen_timestamp() return timestamp if __name__ == '__main__': worker = IdWorker(1, 2, 0) for i in range(10): print(worker.get_id())
不過在大廠裏,其實並沒有直接使用雪花算法(snowflake),而是進行了改造,因爲snowflake算法中最難實踐的就是工作機器id,原始的snowflake算法需要人工去爲每臺機器去指定一個機器id,並配置在某個地方從而讓snowflake從此處獲取機器id。但是在大廠裏,機器是很多的,人力成本太大且容易出錯,所以大廠對snowflake進行了改造。
百度(uid-generator)
github地址:uid-generator
uid-generator使用的就是snowflake算法,只是在生產機器id,也叫做workId時與原始的snowflake算法有所不同。uid-generator中的workId是由uid-generator自動生成的,並且考慮到了應用部署在docker上的情況,在uid-generator中用戶可以自己去定義workId的生成策略,默認提供的策略是:應用啓動時由數據庫分配。說的簡單一點就是:應用在啓動時會往數據庫表(uid-generator需要新增一個WORKER_NODE表)中去插入一條數據,數據插入成功後返回的該數據對應的自增唯一id就是該機器的workId,而數據由host,port組成。
對於uid-generator中的workId,佔用了22個bit位,時間佔用了28個bit位,序列化佔用了13個bit位,需要注意的是,和原始的snowflake不太一樣,時間的單位是秒,而不是毫秒,workId也不一樣,同一個應用每重啓一次就會消費一個workId。
具體可參考https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
美團(Leaf)
github地址:Leaf
There are no two identical leaves in the world.(世界上沒有兩片完全相同的樹葉。) — 萊布尼茨
用葉子作爲分佈式唯一Id框架的名字,可以說非常恰當了。
美團的Leaf非常全面,即支持號段模式,也支持snowflake模式。號段模式這裏就不介紹了,和上面的分析類似,github文檔傳送門:中文文檔 | English Document 。 具體Leaf 設計文檔見: leaf 美團分佈式ID生成服務 。
Leaf中的snowflake模式和原始snowflake算法的不同點,也主要在workId的生成,Leaf中workId是基於ZooKeeper的順序Id來生成的,每個應用在使用Leaf-snowflake時,在啓動時都會都在Zookeeper中生成一個順序Id,相當於一臺機器對應一個順序節點,也就是一個workId。
滴滴(Tinyid)
Tinyid是滴滴出行的產物,Github地址:https://github.com/didi/tinyid, Tinyid也是用Java開發,基於數據庫號段算法實現,Tinyid擴展了leaf-segment算法,支持了多db(master),同時提供了java-client(sdk)使id生成本地化,獲得了更好的性能與可用性。Tinyid在滴滴客服部門使用,均通過tinyid-client方式接入,每天生成億級別的id。
這裏就不詳細介紹了,文檔很全https://github.com/didi/tinyid/wiki 。
總結
本文只是簡單介紹一下每種分佈式Id實現方式,想詳細瞭解或者使用的,還是要系統的去評估每種方式的優缺點及自己的具體業務需求,比如我,綜合比較了一番,認真評估了一下業務需求,決定使用redis模式實現,由於產品方的影響,又加了過期時間,每天自動歸零,前綴加"年月日"組合而成,哈哈哈,告辭。