如何設計一款永不重複的高性能 分佈式發號器

文章均是摘自《可伸縮服務架構:框架與中間件》,僅作爲讀書筆記

1.1 分佈式系統對發號器的基本需求

   在分佈式系統中,整體的業務被拆分成多個自治的微服務,每個微服務之間話要通過網絡進行通信和交互,由於網絡的不確定性,會給系統帶來各種各樣的不一致問題。爲了避免和解決不一致問題,最重要的模式就是做系統之間的實時覈對和事後覈對,覈對的基礎就是領域對象及系統間的請求要有唯一ID 來標識,這樣在覈對時纔能有據可依。
   需求是所有設計的起點, 一切偏離需求的設計都是“耍流氓”。下面是筆者總結的分佈式系統對發號器的基本需求。
1. 全局唯一
   有些業務系統可以使用相對小範圍的唯一性,例如,如果用戶是唯一的,那麼同一用戶的訂單採用的自增序列在用戶範圍內也是唯一的,但是如果這樣設計,訂單系統就會在邏輯上依賴用戶系統,因此,不如保證ID 在系統範圍內的全局唯一更實用。
分佈式系統保證全局唯一的一個悲觀策略是使用鎖或者分佈式鎖,但是,只要使用了鎖,
就會大大地降低性能。因此,我們決定利用時間的有序性,並且在時間的某個單元下采用自增序列,來達到全局唯一。
2 . 粗略有序
   UUID 的最大問題是無序,任何業務都希望生成的ID 是有序的,但是在分佈式系統中要做到完全有序,就涉及數據的匯聚,當然要用到鎖或者分佈式鎖。考慮到效率,我們只能採用折中的方案: 粗略有序。目前有兩種主流的方案, 一種是秒級有序, 另一種是毫秒級有序。這裏又有一個權衡和取捨,我們決定支持兩種方式,通過配置來決定服務使用其中的某種方式。
3 可分解
   一個ID 在生成之後,其本身帶有很多信息量。在線上排查的時候, 我們通常首先看到的是由, 如果根據m 就能知道它是什麼時候產生的及是從哪裏來的,則這個可反解的ID 能幫我們很多忙。如果在ID 裏有了時間且能反解,在存儲層面就會省下很多傳統的timestamp 類的宇段所佔用的空間了,這也是一舉兩得的設計。
4 . 可製造
   一個系統即使再高可用也不會保證永遠不出問題,那麼出了問題怎麼辦?手工處理。數據被污染了怎麼辦?洗數據。可是在手工處理或者洗數據時, 假如使用了數據庫的自增字段, ID己經被後來的業務覆蓋了,那麼怎麼恢復到系統出問題的時間窗口呢?所以, 我們使用的發號器一定要可複製、可恢復、可製造。
5. 高性能
   不管哪種業務,訂單也好,商品也好,如果有新記錄插入, 那麼一定是業務的核心功能,對性能的要求非常高。ID 的生成取決於網絡I/O 和CPU 的性能, 網絡I/O一般不是瓶頸, 根據經驗, 單臺機器的TPS 應該能達到10000/s 。
可伸縮服務架構,框架與中間件
6. 高可用
   首先,發號器必須是一個對等的集羣,在一臺機器掛掉時,請求必須能夠轉發到其他機器上,重試機制也是必不可少的。然後,如果遠程服務巖機,我們還需要有本地的容錯方案, 本地庫的依賴方式可以作爲高可用的最後一道屏障。
7 . 可伸縮
   在分佈式系統中,我們永遠都不能忽略的是業務量在不斷增長,業務的絕對容量不是衡量系統性能的唯一標準,要知道業務是永遠增長的,所以,對系統的設計不但要考慮能承受的絕對容量,還必須考慮業務量增長的速度。系統的水平伸縮能否滿足業務的增長速度,是衡量系統性能的另一個重要標準。
##1.2 架構設計與核心要點
   設計和實現一款高效的多場景分佈式發號器,從對分佈式多場景發號器的
需求整理開始,挖掘互聯網企業對分佈式發號器的期待和需求,並根據確定的核心需求和特色需求,提出設計的解決方案。考慮到不同的用戶、性能場景、配置模式和環境的差異,我們力求設計一款多功能、多場景、高性能的互聯網發號器,以Java 語言爲基礎, 給出一個發號器的參考實現,讓Java 領域的小夥伴們在不同的環境下可以快速使用和集成發號器服務。本發號器的參考實現作爲一個通用的開源項目,對不同的使用方式提供了相應的用戶嚮導。
###1.2.1 發佈模式

  • 嵌入發佈模式:只適用於Java 客戶端,提供了一個本地的Jar 包, Jar 包是嵌入式的原生服務,需要提前配置本地的機器ID ,但是不依賴於中心服務器。
  • 中心服務器發佈模式:只適用於Java 客戶端,提供一個服務的客戶端Jar 包, Java 程序像調用本地API 一樣來調用,但是依賴於中心的ID 產生服務器。
  • REST 發佈模式:中心服務器通過Restful API 導出服務,供非Java 語言客戶端使用。
    發佈模式最後會被記錄在生成的ID 中。也可參考下面數據結構段的發佈模式的相關細節。

1.2.2 ID 類型

根據時間的位數和序列號的位數, ID 類型可以分爲最大峯值型和最小粒度型。
(1) 最大峯值型: 採用秒級有序,秒級時間佔用30 位,序列號佔用20 位,如表1-1 所示。
這裏寫圖片描述
( 2 ) 最小粒度型: 採用毫秒級有序,毫秒級時間佔用40 位,序列號佔用10 位,如表1 -2所示。
這裏寫圖片描述
   最大峯值型能夠承受更大的峯值壓力, 但是粗略有序的粒度有點大;最小粒度型有較細緻的粒度,但是每個毫秒能承受的理論峯值有限,爲1024 ,如果在同一個毫秒有更多的請求產生,則必須等到下一個毫秒再響應。
   ID 類型在配置時指定, 需要重啓服務才能互相切換。

1.2.3 數據結構

1 . 機器ID
   爲10 位, 1的24次方= 1024 ,也就是說最多支持1000 多個服務器。中心發佈模式和REST 發佈模式一般不會有太多數量的機器, 按照設計每臺機器TPS 爲l 萬/s 計算, 10 臺服務器就可以有10 萬/s 的TPS ,基本可以滿足大部分的業務需求。
   但是考慮到我們在業務服務中可以使用內嵌發佈方式, 對機器ID 的需求量變得更大,所以這裏最多支持1024 個服務器。
2 . 序列號
( 1 ) 最大峯值型: 爲20 位, 理論上每秒內可平均產生2的20次方 = 1 048 57 6 個ID ,爲百萬級別。如果系統的網絡I/O和CPU 足夠強大,則可承受的峯值將達到每毫秒百萬級別。
( 2 )最小粒度型: 爲10 位, 每毫秒內的序列號總計2的10次方= 1024 個,也就是說每毫秒最多產生1000 多個ID , 理論上承受的峯值完全不如最大峯值方案。
3 . 秒級時間/毫秒級時間
( 1 )最大峯值型: 爲30 位, 表示秒級時間, 2的30次方/60/60/24/365 =34,也就是說可以使用30 多年。
( 2 )最小粒度型: 爲40 位, 表示毫秒級時間, 2的40次方/1000/60/60/24/365=34 , 同樣可以使用30多年。
4 . 生成方式
   爲2 位, 用來區分三種發佈模式: 嵌入發佈模式、中心服務器發佈模式、REST 發佈模式。

  • 00 : 嵌入發佈模式。
  • 01 : 中心服務器發佈模式。
  • 02: REST 發佈模式。
  • 03 : 保留未用。

5. ID 類型
爲1 位, 用來區分兩種ID 類型: 最大峯值型和最小粒度型。

  • 0 : 最大峯值型。
  • 1: 最小粒度型。

6 . 版本
爲1 位,用來做擴展位或者擴容時的臨時方案。

  • 0 : 默認值。
  • 1: 表示擴展或者擴容中。用於30 年後擴展使用,或者在30 年後ID 將近用光之時,
    擴展爲秒級時間或者毫秒級時間,來獲得系統的移植時間窗口。其實只要擴展一位,
    就完全可以再用3 0 年。

1.2.4 併發

   對於中心服務器和阻ST 發佈方式, ID 生成的過程涉及網絡I/O 和CPU 操作。ID 的生成基本上是內存到高速緩存的操作,沒有磁盤I/O 操作,網絡1/0 是系統的瓶頸。相對於網絡I/O 來說, CPU 計算速度是瓶頸,因此, ID 產生的服務使用多線程的方式,對於ID 生成過程中的競爭點time 和sequence ,這裏使用了多種實現方式。
   ( 1 )使用concurrent 包的ReentrantLock 進行互斥,這是默認的實現方式,也是追求性能和穩定這兩個目標的妥協方案。
   ( 2 )使用傳統的synchronized 進行互斥,這種方式的性能稍微遜色一些,通過傳入NM 參數-Dvesta.sync.lock.impl.key- rue 來開啓。
   (3 )使用concurrent 包的原子變量進行互斥,這種實現方式的性能非常高,但是在高併發環境下CPU 負載會很高,通過傳入NM 參數-Dvesta.atomic . impl.key=true 來開啓。

1.2.5 機器ID 的分配

   我們將機器ID 分爲兩個區段, 一個區段服務於中心服務器發佈模式和REST 發佈模式,另一個區段服務於嵌入發佈模式。

  • 0-923 : 嵌入發佈模式,預先配置機器ID , 最多支持924 臺內嵌服務器。
  • 924-102 3 : 中心服務器發佈模式和阻ST 發佈模式,最多支持100 臺, 最大支持100× 1萬/s 即100 萬/s 的TPS 。

   如果嵌入式發佈模式、中心服務器發佈模式及阻ST 發佈模式的使用量不符合這個比例,則我們可以動態調整兩個區間的值來適應。
   另外, 各個垂直業務之間具有天生的隔離性, 每個業務都可以使用最多1024 臺服務器。我們實現了3 種機器ID 的分配方式。

  • 通過共享數據庫的方式爲發號器服務池中的每個節點生成唯一的機器ID , 這適合服務
    池中節點比較多的情況。
  • 通過配置發號器服務池中每個節點的IP 的方式確定每個節點的機器ID , 這適合服務池
    中節點比較少的情況。
  • 在Spring 配置文件中直接配置每個節點的機器ID , 這適合測試時使用。

   如果有興趣,則可以自己實現以ZooKeeper 爲基礎的機器ID 的生成器,這也是一種比較合理的實現方式。

1.2.6 時間同步

   運行發號器的服務器需要保證時間的正確性, 這裏使用Linux 的定時任務crontab , 週期性地通過時間服務器虛擬集羣(全球有3000 多臺服務器)來覈准服務器的時間:

ntpdate - u pool.ntp.orgpool.ntp.org

其中, 時間的變動對發號器的影響如下。
(1)調整時間是否會影響ID 的產生?

  • 未重啓機器調慢時間, Vesta 拋出異常, 拒絕產生ID 。重啓機器調快時間, 調整後正常產生ID , 在調整時段內沒有ID 產生。
  • 重啓機器調慢時間, Vesta 將可能產生重複的ID , 系統管理員需要保證不會發生這種情況。重啓機器井調快時間, 調整後正常產生ID , 在調整時段內沒有ID 產生。

( 2 )每4 年一次同步潤秒會不會影響ID 的產生?

  • 原子時鐘和電子時鐘每4 年的誤差爲1 秒,也就是說電子時鐘每4 年會比原子時鐘慢l
    秒, 所以,每隔4 年, 網絡時鐘都會同步一次時間, 但是本地機器Windows 、Linux 等
    不會自動同步時間,需要手工同步,或者使用ntpdate 向網絡時鐘同步。
  • 由於時鐘是調快l 秒的,調整後不影響囚的產生, 所以在調整的l 秒內沒有ID 產生。

1.3.7 設計驗證

本節設計的核心要點如下。
( 1 )根據不同的信息分段構建一個D, 使ID 具有全局唯一、可反解和可製造性等特性。
( 2 )使用秒級別時間或者毫秒級別時間及時間單元內部序列遞增的方法保證ID 粗略有序。
( 3 )對於中心服務器發佈模式和阻ST 發佈模式,我們使用多線程處理。爲了減少多線程間的競爭, 我們對競爭點time 和sequence 使用ReentrantLock 來進行互斥, 由於ReentrantLock內部使用了CAS , 比NM 的synchronized 關鍵字性能更好, 所以在千兆網卡的前提下, 至少可達到1萬/s 的TPS 。
( 4 )由於我們支持中心服務器發佈模式、嵌入式發佈模式和阻ST 發佈模式,所以如果某種模式不可用, 就可以回退到其他發佈模式: 對於生成機器ID ,如果基於數據庫的方式不可用,就可以回退到使用本地預配的機器ID ,從而達到服務的最大可用。
( 5 )由於ID 的設計, 我們最大支持1024 臺服務器,將服務器的機器號分爲兩個區段, 一個從0 開始向上, 一個從1024 開始向下, 並且能夠動態調整分界線, 滿足了可伸縮性。

1.3 如何根據設計實現多場景的發號器

1.3.1 項目結構

首先,我們的多場景發號器支持多種配置模式:嵌入發佈模式、中心服務器發佈模式、REST發佈模式,因此我們對要實現的項目結構做個整體規劃,如圖1-2 所示。
這裏寫圖片描述
對應的項目結構如下:
• vesta- id-generator:所有項目的父項目。
• vesta-id-generator/vesta-intf: 發號器抽象出來的對夕+的接口。
• vesta-id-generator/vesta-service : 實現發號器接口的核心項目。
• vesta-id-generator/vesta-server : 把發號器服務通過Dubbo 服務導出的項目。
• vesta-id-generator/vesta-rest : 通過Spring Boot 啓動的阻ST 模式的發號器服務器。
• vesta-id-generator/vesta-rest-netty :通過Netty 啓動的REST 模式的發號器服務器。
• vesta-id-generator/vesta-client : 導入發號器Dubbo 服務的客戶端項目。
• vesta-id-generator/vesta-sample : 嵌入式部署模式和Dubbo 服務部署模式的使用示例。
• vesta-id-generator/vesta-doc : 包含架構設計文檔、壓測文檔和使用嚮導等文擋。
• vesta-id-generator/deploy-maven . sh : 一鍵發佈發號器依賴Jar 包到Maven 庫。
• vesta-id-generator/make-re l ease . sh : 一鍵打包發號器。
• vesta-id-generator/pom. xml : 發號器的Maven 打包文件。
• vesta-id-generator/LICENSE : 開源協議, 本項目採用Apache License 2 . 0 。
• vesta-id-generator/README. md : 入門嚮導文件。
   我們基於以下原則劃分項目。
   我們開發的發號器要適用於多種用途、多種場景,我們不能簡單地建設一個項目,把所有的需求都堆砌在一起, 需要根據功能職責對項目進行劃分, 因此,我們主要將項目拆分成發號器服務的接口模塊、實現模塊, 針對不同的發佈模式的服務導出項目。
  我們開發的是一個開源項目, 希望該開源項目簡單實用, 使用者下載後根據項目結構即可判斷如何使用。
  我們分離了發號器的接口項目和實現項目,因爲不同場景下的需求不一樣,對於Rest發佈模式,不需要依賴發號器的接口和實現;對於Dubbo 服務的客戶端,只需要依賴發號器的接口即可:對於嵌入式發佈模式,不但需要依賴發號器的接口,還需要依賴它的實現。

1.3.2 服務接口的定義

   多場景發號器的接口實現如下:

public interface IdService {

    public long genId();

    public Id expId(long id);

    public long makeId(long time, long seq);

    public long makeId(long time, long seq, long machine);

    public long makeId(long genMethod, long time, long seq, long machine);

    public long makeId(long type, long genMethod, long time,
                       long seq, long machine);

    public long makeId(long version, long type, long genMethod,
                       long time, long seq, long machine);

    public Date transTime(long time);
}

其中主要包含如下服務方法(按照重要程度排列)。
• genld():這是分佈式發號器的主要API , 用來產生唯一ID 。
• expld(long id):這是產生唯一ID 的反向操作,可以對一個ID 內包含的信息進行解讀,用人可讀的形式來表達。
• makeld( … ):用來僞造某一時間的ID 。
• transTime(long time):該方法用於將整型時間翻譯成格式化時間。

1.3.3 服務接口的實現

   在實現類的設計上,我們設計了兩層結構:抽象類AbstractldServicelmpl 和實體類IdServicelmpl 。抽象類AbstractldServicelmpl 實現那些在任何場景下都不變的邏輯,而可變的邏輯被放到了實體類中實現:實體類IdServicelmpl 則是最通用的實現方式。
源碼如下:
https://gitee.com/robertleepeak/vesta-id-generator

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章