分布式ID生成系統(tǒng)

文章轉(zhuǎn)載自公眾號“達達京東到家技術(shù)”。

背景

在分布式系統(tǒng)中,經(jīng)常需要對大量的數(shù)據(jù)、消息、http 請求等進行唯一標識,例如:對于分布式系統(tǒng),服務(wù)間相互調(diào)用需要唯一標識,調(diào)用鏈路分析的時候需要使用這個唯一標識。這個時候數(shù)據(jù)庫自增主鍵已經(jīng)不能滿足需求,需要一個能夠生成全局唯一 ID 的系統(tǒng),這個系統(tǒng)需要滿足以下需求:

  • 全局唯一:不能出現(xiàn)重復(fù) ID。
  • 高可用:ID 生成系統(tǒng)是基礎(chǔ)系統(tǒng),被許多關(guān)鍵系統(tǒng)調(diào)用,一旦宕機,會造成嚴重影響。

經(jīng)典方案

1. UUID

UUID 是 Universally Unique Identifier 的縮寫,它是指在一定范圍內(nèi) (從特定的名字空間到全球) 唯一的機器生成的標識符,UUID 是 32位的16 進制數(shù)字,長為 128 位,例如:3F2504E0-4F89-11D3-9A0C-0305E82C3301。

UUID 經(jīng)由一定的算法機器生成,為了保證 UUID 的唯一性,規(guī)范定義了包括網(wǎng)卡 MAC 地址、時間戳、名字空間 (Namespace)、隨機或偽隨機數(shù)、時序等元素,以及從這些元素生成 UUID 的算法。UUID 的復(fù)雜特性在保證了其唯一性的同時,意味著只能由計算機生成。

優(yōu)點:

  • 本地生成 ID,不需要進行遠程調(diào)用,時延低,性能高。

缺點:

  • UUID 過長,很多場景不適用,比如用 UUID 做數(shù)據(jù)庫索引字段。
  • 沒有排序,無法保證趨勢遞增。

2. Flicker方案

這個方案是由 Flickr 團隊提出,主要思路采用了 MySQL 自增長 ID 的機制 (auto_increment + replace into)

# 數(shù)據(jù)表
CREATE TABLE Ticket64 (
id bigint(20) unsigned NOT NULL auto_increment,
stub har(1) NOT NULL default '',
PRIMARY KEY(id)
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
# 每次業(yè)務(wù)使用下列SQL讀寫MySQL得到ID號
REPLACE INTO Tickets64(stub) VALUES ('a');
SELECT LAST_INSERT_ID();

replace into 跟 insert 功能類似,不同點在于:replace into 首先嘗試插入數(shù)據(jù)到表中,如果發(fā)現(xiàn)表中已經(jīng)有此行數(shù)據(jù) (根據(jù)主鍵或者唯 - 索引判斷) 則先刪除此行數(shù)據(jù),然后插入新的數(shù)據(jù), 否則直接插入新數(shù)據(jù)。

為了避免單點故障,最少需要兩個數(shù)據(jù)庫實例,通過區(qū)分 auto_increment 的起始值和步長來生成奇偶數(shù)的 ID。

Server1:
auto-increment-increment  = 2
auto-increment-offset = 1

Server2:
auto-increment-increment = 2
auto-increment-offset = 2

優(yōu)點:

  • 充分借助數(shù)據(jù)庫的自增 ID 機制,可靠性高,生成有序的 ID。

缺點:

  • ID 生成性能依賴單臺數(shù)據(jù)庫讀寫性能。

  • 依賴數(shù)據(jù)庫,當數(shù)據(jù)庫異常時整個系統(tǒng)不可用。

  • 對于依賴 MySQL 性能問題,可用如下方案解決:

在分布式環(huán)境中我們可以部署多臺機器,每臺設(shè)置不同的初始值,并且步長為機器臺數(shù),比如部署 N 臺,每臺的初始值就為 0,1,2,3...N-1,步長為 N。

分布式ID生成器部署

以上方案雖然解決了性能問題,但是也存在很大的局限性:

  • 系統(tǒng)擴容困難:系統(tǒng)定義好步長之后,增加機器之后調(diào)整步長困難。

  • 數(shù)據(jù)庫壓力大:每次獲取一個 ID 都必須讀寫一次數(shù)據(jù)庫。

3. snowflake方案

這種方案生成一個 64bit 的數(shù)字,64bit 被劃分成多個段,分別表示時間戳、機器編碼、序號。

snowflake-64bit

ID為64bit 的long 數(shù)字,由三部分組成:

  • 41位的時間序列(精確到毫秒,41位的長度可以使用69年)。

  • 10位的機器標識(10位的長度最多支持部署1024個節(jié)點)。

  • 12位的計數(shù)順序號(12位的計數(shù)順序號支持每個節(jié)點每毫秒產(chǎn)生4096個ID序號)。

優(yōu)點:

  • 時間戳在高位,自增序列在低位,整個ID是趨勢遞增的,按照時間有序。

  • 性能高,每秒可生成幾百萬ID。

  • 可以根據(jù)自身業(yè)務(wù)需求靈活調(diào)整bit位劃分,滿足不同需求。

缺點:
依賴機器時鐘,如果機器時鐘回撥,會導(dǎo)致重復(fù)ID生成。

在單機上是遞增的,但是由于涉及到分布式環(huán)境,每臺機器上的時鐘不可能完全同步,有時候會出現(xiàn)不是全局遞增的情況。

4. TDDL 序列生成方式

TDDL 是阿里的分庫分表中間件,它里面包含了全局數(shù)據(jù)庫 ID 的生成方式,主要思路:

  • 使用數(shù)據(jù)庫同步ID信息。
  • 每次批量取一定數(shù)量的可用ID在內(nèi)存中,使用完后,再請求數(shù)據(jù)庫重新獲取下一批可用ID,每次獲取的可用ID數(shù)量由步長控制,實際業(yè)務(wù)中可根據(jù)使用速度進行配置。
  • 每個業(yè)務(wù)可以給自己的序列起個唯一的名字,隔離各個業(yè)務(wù)系統(tǒng)的ID。
## 數(shù)據(jù)表設(shè)計
seqName    varchar(100)    序列名稱,主鍵
cur_value    bigint(20)         當前值
step             int                    步長,根據(jù)實際情況設(shè)置

優(yōu)點:

  • 相比Flicker方案,大大降低數(shù)據(jù)庫寫壓力,數(shù)據(jù)庫不再是性能瓶頸。
  • 相比Flicker方案,生成ID性能大幅度提高,因為獲取一個可用號段后在內(nèi)存中直接分配,相對于每次讀取數(shù)據(jù)庫性能提高了幾個量級。
  • 不同業(yè)務(wù)不同的ID需求可以用seqName字段區(qū)分,每個seqName的ID獲取相互隔離,互不影響。

缺點:

  • 強依賴數(shù)據(jù)庫,當數(shù)據(jù)庫異常時整個系統(tǒng)不可用。

發(fā)號器實現(xiàn)方案

綜合對比以上四種實現(xiàn)方案,以及我們的業(yè)務(wù)需求,最后決定采用第三種方案。
主要原因:

  • 業(yè)務(wù)需求:業(yè)務(wù)要求生成的 ID 要有遞增趨勢,全局唯一,并且為數(shù)字。
  • 系統(tǒng)考慮:第三種方案性能高,穩(wěn)定性高,對外部資源依賴少。
    依據(jù)實際業(yè)務(wù)需求和系統(tǒng)規(guī)劃,對算法進行局部調(diào)整,實現(xiàn)了發(fā)號器 snowflake 方案。

發(fā)號器 snowflake 方案

發(fā)號器 snowflake 方案中對 bit 的劃分做了如下調(diào)整:

  • 36 bit 時間戳,使用時間秒
  • 5 bit 機器編碼
  • 22 bit 序號

機器編碼維護:
機器編碼是不同機器之間產(chǎn)生唯一 ID 的重要依據(jù),不能重復(fù),一旦重復(fù),就會導(dǎo)致有相同機器編碼的服務(wù)器生成的 ID 大量重復(fù)。 如果部署的機器只是少量的,可以人工維護,如果大量,手動維護成本高,考慮到自動部署、運維等等問題,機器編碼最好由系統(tǒng)自動維護,有以下兩個方案可供選擇:

  • 使用 MySQL 自增 ID 特性,用數(shù)據(jù)表,存儲機器的 mac 地址或者 ip 來維護。
  • 使用 ZooKeeper 持久順序節(jié)點的特性。

這里我們使用 ZooKeeper 持久順序節(jié)點特性來配置維護 WORKID發(fā)號器的啟動順序如下:

  • 啟動發(fā)號器服務(wù),連接 ZooKeeper, 檢查根節(jié)點 id_generator 是否存在,如果不存在就創(chuàng)建系統(tǒng)根節(jié)點。
  • 檢查根節(jié)點下當前機器是否已經(jīng)注冊過 (是否有該順序子節(jié)點)。
  • 如果有注冊,直接取回自己的 WORKID。如果沒注冊,在根節(jié)點下創(chuàng)建一個持久順序節(jié)點,取回順序號做 WORKID。

一旦取回 WORKID,緩存在本地文件中,后續(xù)直接使用,不再與 ZooKeeper 進行任何交互,此方案對 ZooKeeper 依賴極小。

發(fā)號器

時鐘問題:

snowflake方案依賴系統(tǒng)時鐘,如果機器時鐘回撥,就有可能生成重復(fù)ID,為了保證ID唯一性,必須解決時鐘回撥問題。

可以采取以下幾種方案解決時鐘問題:

  • 關(guān)閉系統(tǒng)NTP同步,這樣就不會產(chǎn)生時鐘調(diào)整。

  • 系統(tǒng)做出判斷,在時鐘回撥這段時間,不生成ID,直接返回ERROR_CODE,直到時鐘追上,恢復(fù)服務(wù)。

//發(fā)生回撥,本次最新時間小于上次時間
if (timestamp < this.lastTimestamp) {
        throw new GeneratIdException("時鐘回撥,拒絕生成ID");
}
  • 系統(tǒng)做出判斷,如果遇到超過容忍限度的回撥,上報報警系統(tǒng),并把自身從集群節(jié)點中摘除
// 發(fā)生回撥,本次時間小于上次時間
if (timestamp < this.lastTimestamp) {
        long delay = lastTimestamp - timestamp;
        // 如果偏差比較小,則等待
        if (delay < 10) {
            Thread.sleep(delay);
        }
        timestamp = this.timeGen();
       // 如果還沒好,報警
       if (timestamp < this.lastTimestamp) {
           timeCallBackProcess(timestamp, this.lastTimestamp);
       } else {
           // 重新分配ID
           long id = nextSeqId();
       }
}
  • 統(tǒng)做兼容處理,由于nfp網(wǎng)絡(luò)回撥都是幾十毫秒到幾百毫秒,極少數(shù)到秒級別,這種回撥會產(chǎn)生以下幾種結(jié)果:
    系統(tǒng)中緩存最近幾秒內(nèi)最后的發(fā)號序號(具體范圍請根據(jù)實際需要確定),存儲格式為:時間秒-序號。
    • 前秒數(shù)不變: 當前是8:30秒100毫秒,ntp回撥50毫秒,當前時間變成8:30秒50毫秒,這個時候秒數(shù)沒變,我們算法的時間戳部分不會產(chǎn)生重復(fù),就不影響系統(tǒng)繼續(xù)發(fā)號
    • 當前秒數(shù)向前:當前是8:30秒800毫秒,ntp 向前調(diào)整300毫秒,當前時間變成8:31秒100毫秒,由于這個時間還沒發(fā)過號,不會生成重復(fù)的ID
    • 當前秒數(shù)向后:當前是8:30秒100毫秒,ntp回撥150毫秒,當前時間變成8:29秒950毫秒,這個時候秒發(fā)生回退,就可能產(chǎn)生重復(fù)ID。產(chǎn)生重復(fù)的原因在于秒回退后,算法的時間戳部分使用了已經(jīng)用過的時間戳,但是算法的序號部分,并沒有回退到29秒那個時間對應(yīng)的序號,依然使用當前的序號,如果序號也同時回退到29秒時間戳所對應(yīng)的最后序號,就不會重復(fù)發(fā)號。解決方案如下:
Map<Long, Long> map = new ConcurrenthashMap<Long, Long>();
// 發(fā)生回撥,本次更新時間小于上次時間
    if (timestampSec < this.lastTimestampSec) {
    // 有緩存
        if (map.get(timestampSec) != null) {
            this.sequence = map.get(timestampSec);
            this.nextId();
            map.put(timestampSec, this.sequence);
        } else {
            throw new GeneratIdException("時鐘回退,拒絕生成ID");
        }
    }

閏秒處理:

閏秒,是指為保持協(xié)調(diào)世界時接近于世界時時刻,由國際計量局統(tǒng)一規(guī)定在年底或年中(也可能在季末)對協(xié)調(diào)世界時增加或減少1秒的調(diào)整。由于地球自轉(zhuǎn)的不均勻性和長期變慢性(主要由潮汐摩擦引起的),會使世界時(民用時)和原子時之間相差超過到±0.9秒時,就把協(xié)調(diào)世界時向前撥1秒(負閏秒,最后一分鐘為59秒)或向后撥1秒(正閏秒,最后一分鐘為61秒),閏秒一般加在公歷年末或公歷六月末。

在閏秒產(chǎn)生的時候系統(tǒng)會出現(xiàn)秒級時間調(diào)整,下面我們來分析閏秒對發(fā)號器的影響:

  • 負閏秒:當前23:59:58的下一秒就是第二天的00:00:00,00:00:00 這個時間我們還沒產(chǎn)生過ID,不會產(chǎn)生重復(fù)的,對發(fā)號器沒影響。

  • 正閏秒:當天23:59:59的下一秒當記為23:59:60,然后才是第二天的00:00:00。由于我們系統(tǒng)時間戳部分取的從某個時間點(1970年1月1日)到現(xiàn)在的秒數(shù),是一個數(shù)字,只要這個數(shù)字不重復(fù),就不會產(chǎn)生重復(fù)的ID。如果在閏秒發(fā)生一段時間后ntp時間同步(為了規(guī)避閏秒風(fēng)險,很多公司閏秒前關(guān)閉ntp同步,閏秒后打開ntp同步),這個時候系統(tǒng)時鐘回撥,可以使用解決時鐘回撥的方案進行處理。

部署結(jié)構(gòu)

為了實現(xiàn)高可用,避免單點故障,系統(tǒng)部署采用集群水平部署,前置使用Nginx做負載均衡,發(fā)號器使用Spring Boot框架,web服務(wù)器使用Spring Boot內(nèi)嵌Tomcat, 發(fā)號器和Nginx之間進行心跳檢測。


部署結(jié)構(gòu)

Tomcat調(diào)優(yōu)

使用APR

Tomcat支持三種接收請求的處理方式:BIO、NIO、APR, 性能上 BIO<NIO<APR。APR簡單理解,就是從操作系統(tǒng)級別解決異步IO問題,大幅度的提高服務(wù)器的處理和響應(yīng)性能,也是Tomcat運行高并發(fā)應(yīng)用的首選模式。使用APR首先要安裝系統(tǒng)依賴庫,接著在Spring Boot程序中增加ARP配置開啟APR(這里有一個配置變量來控制是否開啟)


使用APR

開發(fā)中遇到的問題

整個開發(fā)過程都非常順利,測試的時候tps也很高,心情很愉快,世界很美好,突然一個意外出現(xiàn),發(fā)現(xiàn)存在full gc現(xiàn)象,有內(nèi)存溢出? 于是分析了好幾遍程序,也沒找到明顯的線索,只能開始jvm調(diào)試旅程。

pingpoint 監(jiān)控圖:

pingpoint監(jiān)控圖

(上圖中紅色部署表示full gc)
JVM調(diào)試最直接的就是獲取full gc時的jvm dump文件,以及gc log進行分析:

為了獲取dump文件,在jvm參數(shù)中加上:

Jvm參數(shù)

參數(shù)介紹
參數(shù)介紹

配置上面的虛擬機參數(shù)后,虛擬機gc的時候會把gc相關(guān)信息輸出到文件gc.log中,full gc前后,會生成當時虛擬機的內(nèi)存dump文件。從pingpoint監(jiān)控圖中可以看出full gc是發(fā)生在持久區(qū)域。

使用jmap 工具,獲取JVM堆內(nèi)存信息如下:
jmap -heap pid


jmap

從上圖可以看出,使用的堆內(nèi)存很少,總的堆內(nèi)存只有0.84% 使用,其它使用指標也都在正常范圍,系統(tǒng)裝載的類也不多,沒有內(nèi)存泄露。

繼續(xù)分析gc log:

gc log

從gc log 中尋找線索:

gc log

這里發(fā)現(xiàn)了以下線索:

  • 從 [Full GC (Metadata GC Threshold)看出,的確產(chǎn)生了full gc,原因 Metadata GC Threshold。

  • [Metaspace: 34773K->34773K(1081344K)] full gc前后metaspace的size沒有變化說明此區(qū)域已經(jīng)滿了,釋放不出內(nèi)存。

  • 仔細分析gc log,發(fā)現(xiàn)2次full gc記錄,第一次full gc [Metaspace: 20897K->20897K(1069056K),這個值比第2次的要小很多。


兩次full gc原因都是 Metadata GC Threshold類型,說明pingpoint監(jiān)控到的full gc是元空間引發(fā)的full gc,并非內(nèi)存泄露引起,但是這個值才34m,距離最大值1081m,還有很大空間,為什么會full gc?

經(jīng)過查閱官方資料,發(fā)現(xiàn)MetaspaceSize的默認大小是21807104b,也就是21296k,而發(fā)生GC的時候,元空間已經(jīng)使用了34722K,從而產(chǎn)生full gc。

方法區(qū):

方法區(qū)也是所有線程共享。主要用于存儲類的信息、常量池、方法數(shù)據(jù)、方法代碼等。方法區(qū)邏輯上屬于堆的一部分,但是為了與堆進行區(qū)分,通常又叫“非堆”。其實,移除永久代的工作從JDK1.7就開始了。JDK1.7中,存儲在永久代的部分數(shù)據(jù)就已經(jīng)轉(zhuǎn)移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并沒完全移除,譬如符號引用(Symbols)轉(zhuǎn)移到了native heap,字符串常量轉(zhuǎn)移到了java heap,類的靜態(tài)變量(class statics)轉(zhuǎn)移到了java heap。

在JDK8中,classe metadata(the virtual machines internal presentation of Java class),被存儲在叫做Metaspace的native memory。一些新的flags被加入:-XX:MetaspaceSize,class metadata的初始空間配額,以bytes為單位,達到該值就會觸發(fā)垃圾收集進行類型卸載,同時GC會對該值進行調(diào)整:如果釋放了大量的空間,就適當?shù)慕档驮撝?;如果釋放了很少的空間,就會在不超過MaxMetaspaceSize(如果設(shè)置了的話)的情況下,適當?shù)奶岣咴撝怠?/p>

在虛擬機參數(shù)中增加MetaspaceSize初始化大小,-XX:MetaspaceSize=128m,重新啟動項目,不再有full gc出現(xiàn)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 本文主要介紹在一個分布式系統(tǒng)中, 怎么樣生成全局唯一的 ID 一, 問題描述 在分布式系統(tǒng)存在多個 Shard 的...
    hanayona閱讀 2,145評論 0 5
  • 轉(zhuǎn)載:細聊分布式ID生成方法 一、需求緣起 幾乎所有的業(yè)務(wù)系統(tǒng),都有生成一個記錄標識的需求,例如: (1)消息標識...
    meng_philip123閱讀 2,643評論 0 17
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,695評論 19 139
  • http://www.99banzou.com/product/78578.html 像我這樣的人 (Live) ...
    腦子閱讀 1,305評論 0 0
  • 這是一盤黑暗的料理,每一個見到的人都是觸目驚心,他們不知道該如何下嘴,更不敢評判,只因為他們不敢,但一道嫵媚的...
    雨中落塵閱讀 342評論 0 0

友情鏈接更多精彩內(nèi)容