分布式全局唯一ID的實現
前言
上周末考完試,這周正好把工作整理整理,然后也把之前的一些素材,整理一番,也當自己再學習一番。
一方面正好最近看到幾篇這方面的文章,另一方面也是正好工作上有所涉及,所以決定寫一篇這樣的文章。
先是簡單介紹概念和現有解決方案,然后是我對這些方案的總結,最后是我自己項目的解決思路。
概念
在復雜分布式系統中,往往需要對大量的數據和消息進行唯一標識。
如在金融、電商、支付、等產品的系統中,數據日漸增長,對數據分庫分表后需要有一個唯一ID來標識一條數據或消息,數據庫的自增ID顯然不能滿足需求,此時一個能夠生成全局唯一ID的系統是非常必要的。
特點:
- 全局唯一性(核心):作為唯一標識,不可以出現重復ID
- 趨勢遞增:在MySQL InnoDB引擎中使用的是聚集索引,由於多數RDBMS使用B-tree的數據結構來存儲索引數據,在主鍵的選擇上面我們應該盡量使用有序的主鍵保證寫入性能。
- 單調遞增:保證下一個ID一定大於上一個ID,例如事務版本號、IM增量消息、排序等特殊需求。
- 信息安全:如果ID是連續的,惡意用戶的扒取工作就非常容易做了,直接按照順序下載指定URL即可;如果是訂單號就更危險了,競對可以直接知道我們一天的單量。所以在一些應用場景下,會需要ID無規則、不規則。
同時除了對ID號碼自身的要求,業務還對ID號生成系統的可用性要求極高,想象一下,如果ID生成系統癱瘓,這就會帶來一場災難。
運用場景:
分布式全局唯一ID(數據庫的分庫分表后需要有一個唯一ID來標識一條數據或消息;特別一點的如訂單、騎手、優惠券也都需要有唯一ID做標識;MQ中消息的高可用性(確認消息是否發送成功,是否已發送等)等)
其實分布式全局ID是一個比較復雜,重要的分布式問題(什么問題涉及真正的分布式,高並發后都會比較復雜)。常見解決方案有UUID,Snowflake,Flicker,Redis,Zookeeper,Leaf等。
實現方案:
UUID(此處用的Version1:共五個版本,Version1是基於時間的)
生成一個32位16進制字符串(16字節的128位數據,通常以32位長度的字符串表示)(結合機器識別碼(全局唯一的IEEE機器識別號,如果有網卡,從網卡MAC地址獲得,沒有網卡以其他方式獲得),當前時間,一個隨機數)。
優點:
- 性能好;
- 擴展性高;
- 本地生成;
- 無網絡消耗;
- 不需要考慮性能瓶頸;
- 不需要提前商定,各自為政,但絕對不會沖突
缺點:
- 無法保證趨勢遞增(由於數據庫MySQL的InnoDB采用聚簇索引,有序的ID可以保證寫入速度);
- UUID過長(消耗內存,帶寬等。更重要的是如果存儲在數據庫中,作為主鍵建立索引效率低)
適用場景:
不需要考慮空間占用,不需要生成有遞增趨勢的ID,且不在MySQL中存儲。
Snowflake
Twitter開源,生成一個64bit(0和1)字符串(1bit不用,41bit表示存儲時間戳,10bit表示工作機器id(5位數據標示位,5位機器標識位),12bit序列號)
結構:
- 首位符號位:因為ID一般為正數,該值為0.
- 41位時間戳(毫秒級):時間戳並不是當前時間戳,而是存儲時間戳的差值(當前時間戳-起始時間戳(起始時間戳需要程序指定),理論可以適用(1<<41)/(1000x60x60x24x365),69年。
- 10位數據機器位(說白了就是邏輯分片ID,具體實現和機器本身無關系):包括5位數據標識位和5位機器標識位(比如5位機房ID,5位機器ID),理論最多可以部署節點位:1<<10=1024。
- 12位毫秒內的序列:同一節點,同一時刻(同一毫秒內)最多生成ID數1<<12=4096。
最后生成64位Long型數值(這里指,一般Long數據就是64位bit的)。
優點:
- 趨勢遞增,且按照時間有序;
- 性能高,穩定性高,不依賴數據庫等第三方系統;
- 可以按照自身業務特性靈活分配bit位(比如機器位改為15bit,序列位改為7bit)。
缺點:
- 依賴機器時鍾(雖然UUID也根據當前時間,但其非時間部分波動太大了(重新組織措辭)),時鍾回撥會造成暫不可用或重復發號(分布式系統中,每台機器上的時鍾不可能完全同步。在同步各個服務器的時間時,有一定幾率發生時鍾回撥(時間超了,往回撥))
適用場景:
要求高性能,可以不連續,數據類型為long型。
Flicker
主要思路是涉及單獨的庫表,利用數據庫的自增ID+replace_into,來生成全局ID。
前置補充:
replace into跟insert功能類似,不同點在於:replace into首先嘗試插入數據列表中,如果發現表中已經有此行數據(根據主鍵或唯一索引判斷)則先刪除,再插入。否則直接插入新數據。
建表:
create table t_global_id(
id bigint(20) unsigned not null auto_increment,
stub char(1) not null default '',
primary key (id),
unique key stub (stub)
) engine=MyISAM;
(stub:票根,對應需要生成ID的業務方編碼,可以是項目名,表名,甚至是服務器IP地址。
MyISAM(MYSQL5.5.8前默認數據庫存儲引擎,5.5.8及之后默認存儲引擎為InnoDB):(此處應當有MyISAM與InnoDB引擎的區別,乃至其他引擎)基於ISAM類型。不是事務安全(沒有事務隔離??),不支持外鍵,沒有行級鎖。如果執行大量的select,建議MyISAM。
獲取數據:
# 每次業務可以使用以下SQL讀寫MySQL得到ID號
replace into t_golbal_id(stub) values('a');
select last_insert_id();
擴展:為解決單點問題,啟用多台服務器,如MySQL,利用給字段設置auto_increment_increment和auto_increment_offset來保證ID自增(如通過設置起始值與步長,生成奇偶數ID)
優點:
- 非常簡單,充分利用了數據庫系統的功能實現,成本小,有DBA專業維護;
- ID號單調自增,可以實現一些對ID有特殊要求的業務。
缺點:
- 強依賴DB,當DB異常時,整個系統不可用,屬於致命問題(配置主從復制可以盡可能地增加可用性,但是數據一致性在特殊情況下難以保證。主從切換時的不一致可能導致重復發號);
- 水平擴展困難(定義好了起始值,步長和機器台數之后,如果要添加機器就比較麻煩(為什么我想到了REDIS的哈希一致原理));
- ID發號性能瓶頸限制在單台MySQL的讀寫性能。
適用場景:
數據量不大,並發量不大。
Redis
由於Redis的所有命令是單線程的,所以可以利用Redis的原子操作INCR和INCRBY,來生成全局唯一的ID。
擴展:
可以通過集群來提升吞吐量(可以通過為不同Redis節點設置不同的初始值並同意步長,從而利用Redis生成唯一且趨勢遞增的ID)(其實這個方法和Flicker一致,只是利用到了Redis的一些特性,如原子操作,內存數據庫讀寫快等)(Incrby:將key中儲存的數字加上指定的增量值。這是一個“INCR AND GET”的原子操作,業務方可以定義一個自己的key值,通過INCR命令來獲取對應的ID)
優點:
不依賴數據庫,靈活方便,且性能優於基於數據庫的Flicker方案。
缺點:
- 擴展性低,Redis集群需要設置號初始值與步長(與Flicker方案一樣);
- Redis宕機可能生成重復的ID;如果系統中沒有Redis,還需要引入新的組件,增加系統復雜度;
- 需要編碼和配置的工作量比較大。
適用場景:
Redis集群高可用,並發量高。
舉例:
利用Redis來生成每天從0開始的流水號。如訂單號=日期+當日自增長號。可以每天在Redis中生成一個Key,適用INCR進行累加。
zookeeper
通過其znode數據版本來生成序列號,可以生成32位和64位的數據版本號,客戶端可以使用這個版本號來作為唯一的序列號。
小結:很少會使用zookeeper來生成唯一ID。主要是由於需要依賴zookeeper,並且是多步調用API,如果在競爭較大的情況下,需要考慮使用分布式鎖。因此,性能在高並發的分布式環境下,也不甚理想。
Leaf
美團的Leaf分布式ID生成系統,在Flicker策略與Snowflake算法的基礎上做了兩套優化的方案:Leaf-segment數據庫方案(相比Flicker方案每次都要讀取數據庫,該方案改用proxy server批量獲取,且做了雙buffer的優化)與Leaf-snowflake方案(主要針對時鍾回撥問題做了特殊處理。若發生時鍾回撥則拒絕發號,並進行告警)。
MongDB objectID
ObjectID可以算作和snowflake類似方法,通過”時間+機器碼+pid+inc”共12個字節,通過4+3+2+3的方式,最終標識一個24長度的十六進制字符。
理論總結
其實除了上述方案外,還有ins等的方案,但總的來看,方案主要分為兩種:第一有中心(如數據庫,包括MYSQL,REDIS等),其中可以會利用事先的預約來實現集群(起始步長)。第二種就是無中心,通過生成足夠散落的數據,來確保無沖突(如UUID等)。站在這兩個方向上,來看上述方案的利弊就方便多了。
中心化方案:
優點:
- 數據長度相對小一些;
- 數據可以實現自增趨勢等。
缺點:
- 並發瓶頸處理;
- 集群需要實現約定;橫向擴展困難(當然有的方案看起來后兩者沒有那么問題,是因為,這些方案利用其技術特性,早就一定程度上解決了這些問題,如Redis的橫向擴展等)。
非中心化方案:
優點:
- 實現簡單(因為不需要與其他節點存在這方面的約定,耦合);
- 不會出現中心節點帶來的性能瓶頸;
- 擴展性較高(擴展的局限往往集中於數據的離散問題)。
缺點:
- 數據長度較長(畢竟就是通過這一特性來實現無沖突的);
- 無法實現數據的自增長(畢竟是隨機的);
- 依賴數據生成方案的優劣(數據生成方案的優劣會全盤接收,但可以推成出新)。
體悟:
技術是無窮無盡的,我們不僅需要看到其中體現的思想與原則,在學習新技術或方案時,需要明確其中一些特性,優缺點的來源,從而進行有效的總結歸納。
應用角度來說:(一方面想要標示符短,便於處理與存儲,另一方面想要足夠大,而不會產生沖突。呵呵)。最理想就是追求從0開始,每個標示符都被使用,且不重復,而且不用擔心並發。呵呵。完全應該根據當前業務場景來選擇,畢竟業務場景在當前是確定的。如果業務變動較大(比如發展初期,業務增長很快),那就需要考慮擴展性,便於日后進行該模塊的更新與技術方案的替換實現(避免一個系統開發一年,用不到一年,那就尷尬了))。
個人經驗
我曾經做過一個“工業物聯網”系統,該系統系統是分為三個子系統:終端服務器(用於收集終端傳感器數據);企業中控服務器(接收來自多個終端服務器的數據,進行綜合查看與控制);雲平台服務器(提供上雲)。其中就涉及多個終端服務器的傳感器數據辨識問題,這里以傾斜傳感器數據為例。簡述不同終端服務器的傾斜數據的如何實現全局唯一標識。
以企業中控服務器的數據庫作為統一的數據標識來源
簡單說,就是終端服務器發送一個數據到企業中控室,企業中控服務器就將該數據保存到數據庫中,那么每個數據在企業中控服務器數據庫中都有唯一的ID,並且保持了自增。
優點是實現簡單,只需要做好數據收發,與數據的插入工作即可。唯一需要注意的是數據庫插入時注意資源互斥,防止出現數據插入異常問題(Springframework生成的Bean默認時單例的)。
缺點是需要實時收發數據,防止數據丟失,數據積壓,數據的create_time異常等問題。
以UUID等方式生成數據的全局唯一標識
簡單說,就是終端服務器要發送的數據賦予UUID這樣的ID,來確保全局唯一。這樣終端服務器就可以和中控服務器保持同樣且不沖突的ID了。數據的生成是實現在終端服務器的,而中控服務器只是作為數據的保存與調用(通過統一ID調用)。
優點是不需要數據的實時收發,避免系統在弱網絡情況下出現各類異常。
缺點是數據的ID過長,並且無法保持自增。並且在某種程度上帶來了數據復雜度,從而提高了系統復雜度。
落地方案
由於實際業務的需求,如弱網絡,數據交互頻率跨度大等情況。最終我的實現是先由終端服務器在啟動之初,在企業中控服務器注冊TerminalId,作為不同終端服務器的標識。不同終端服務器接收與保存數據時,都會在每條數據中插入TerminalId,便於企業中控服務器的識別。當然,具體實現當中還有一些細節。如終端服務器在注冊時由於網絡等情況注冊失敗,會先建立一個類似UUID的TerminalId來先保存監測數據。當注冊成功時(系統會根據TerminalId的長度等特性來判斷是否注冊失敗,是否需要重新注冊),會重新修改所有數據的TerminalId,再允許數據上傳。
優點是確保了數據在弱網絡情況下的正確性,並且實現了自動注冊等通用模塊的實現。
缺點是最終數據插入企業中控服務器數據庫時,並沒有嚴格實現數據符合實際時間的增長(如某終端服務器由於網絡等情況沒法發送數據,等待一段時間后發送了這段時間堆積的數據),但保持了總體增長的趨勢。
總結
IT沒有銀彈,我們要做的是多去了解現有的技術方案,再產生符合自己需求的技術方案。因為不同的技術方案都因為其使用場景有着各自的特點,而我們需要了解各種特點的技術來源(是什么技術造就了這一特點,或者說是什么架構造就了這一特點等),從而構建出最符合自己需求的技術方案。
沒有最好,只有最適合。