分散式系統唯一主鍵識別符號ID生成機制比較 - Encore

banq發表於2022-03-26

在構建任何分散式或非分散式系統時,您最終會處理許多資料ID識別符號,從資料庫行一直到生產系統版本的ID識別符號。
決定如何生成識別符號有時非常簡單;例如,您可能只是將一個自動遞增ID的數字作為您的資料庫中的主鍵。

然而,在分散式系統中,讓一個數字從 1 開始並緩慢增加要困難得多。您可以構建一個選舉領導者的系統,並且該領導者負責增加數量 - 但這會給您的系統設計增加很多複雜性,它不會無限擴充套件,因為您仍然會受到吞吐量的限制領導。您仍然可能會遇到腦裂問題,即相同的數字由兩個不同的“領導者”生成兩次
在專案的早期做出正確的決定總是很重要的,因為一旦投入生產,這是系統中最難改變的事情之一。
 

現有選項
如果我們暫時忽略型別安全和人類可讀性要求,那麼我們所要求的已經解決了一千次了,我們不需要在這裡重新發明輪子。讓我們看看一些現有的久經考驗的選項。

  • 資料庫的自動遞增鍵

大多數人的第一反應是自動遞增的主鍵。這解決了可排序的問題,並且沒有碰撞的風險。然而,它只能擴充套件到你的資料庫伺服器所能處理的寫入量的程度。它還增加了一個新的要求,即你生成的每個識別符號都必須有一個匹配的資料庫行。鑑於我們想把這個系統用於我們不儲存在資料庫中的東西,我們很快就排除了這個要求。
 
  • UUIDs

有各種版本的UUIDs,它們都是128位;乍一看,從可擴充套件性、零配置和碰撞風險的角度來看,版本1、2、4似乎很完美。然而,當你深入挖掘時,不同版本的UUIDs開始顯示出疣狀。

版本1和2,儘管它們每秒可以生成大約1600億個識別符號,但它們使用機器的MAC地址,這意味著同一臺機器上的兩個程式如果同時呼叫,可能會生成相同的識別符號。
第4版生成完全隨機的識別符號,具有2122位元的隨機性。這意味著我們非常不可能發生碰撞,並且可以無限擴充套件而不會出現瓶頸。然而,我們失去了按時間順序排列識別符號的能力。
  
  • Snowflake 

Snowflake識別符號是由Twitter首先開發的,通常是64位(儘管有些變種使用128位)。這個方案將ID產生的時間編碼為前41位,然後將例項ID編碼為後10位,最後將序列號編碼為最後的12位。

這給了我們很好的可擴充套件性,因為例項的位元集允許我們同時執行1024個不同的程式,同時知道它們不能產生相同的識別符號,因為程式自己的識別符號是在裡面編碼的 它給我們提供了我們的k-排序,因為上面的位是ID產生的時間。最後,我們在程式內有一個序列號,這使我們能夠在每個程式中每秒產生4096個識別符號。

然而,Snowflake 不符合我們需要零配置的要求,因為例項 ID 必須在每個程式中進行配置。
 

KSUID有點像UUID第四版和Snowflake之間的交叉。它們是160位,其中前32位是識別符號產生的時間(到第二位),然後是128位的隨機資料。這使得它們對我們來說幾乎是理想的;它們是k-排序的,不需要配置,而且沒有碰撞的風險,因為ID的隨機部分有大量的熵。
然而,我們在研究KSUID的過程中發現了一些有趣的事情;KSUID的字串編碼使用BASE-62編碼,因此有大寫和小寫字母;這意味著根據你的字串排序,你可能會對識別符號進行不同的排序--也就是說,我們失去了根據系統進行排序的要求。例如,Postgres將小寫字母排序在大寫字母之前,而大多數演算法將大寫字母排序在小寫字母之前,這可能會導致一些非常討厭的和難以識別的錯誤。(值得注意的是,這對任何同時使用大寫和小寫字母的編碼方案都有影響,所以它不僅僅限於KSUID。)
 

XID的是96位。前32位是時間,這意味著我們可以立即得到我們的k-排序。接下來的40位是機器識別符號和程式識別符號;然而,與其他系統不同的是,這些是使用庫自動計算的,不需要我們自己配置什麼。最後的24位是一個序列號,它允許一個程式每秒產生16,777,216個識別符號!

XID給了我們所有的核心要求,它的字串編碼使用32進位制(沒有大寫字母來破壞我們的排序!)。這個字串編碼始終是20個字元,這意味著我們可以在任何marshalling程式碼中使用這一事實進行驗證(例如Postgres對資料庫型別的CHECK約束)。
 

Encore公司的選擇要求

  • 可排序

我們希望能夠對識別符號進行排序,例如當A先被建立時,A<B。然而,我們不需要完全的排序;可以接受的是k-sortable。可排序可以使我們在資料庫中獲得更好的索引效能,使我們能夠輕鬆地按順序遍歷記錄,並提高我們的除錯能力,因為事件的順序可以透過事件識別符號來確定。
  • 可擴充套件性

我們希望有一個能與我們一起擴充套件的系統,沒有瓶頸。我們將使用這個系統來生成痕跡和跨度的識別符號,我們將在其中建立大量的識別符號。
  • 無碰撞風險:

因為我們執行的是一個分散式系統,我們不希望有兩個程式建立相同識別符號的風險。
  • 零配置:

在使用這個系統時,我們不希望在每臺機器或每個程式層面上做任何級別的配置。
  • 型別安全:

我們想要一個系統,其中一個資源的識別符號不能意外地作為另一種型別的資源的識別符號被傳遞或返回。
我們還希望識別符號是。
  1. 合理地小:無論是在記憶體中還是在傳輸過程上。
  2. 人類可讀性。這與型別安全有關;然而,我們希望字串的表示能夠讓人類理解識別符號的建立目的。

 

我們決定選擇的識別符號ID方案
一旦我們決定使用XID作為我們的ID型別的基礎,我們就把重點轉回到我們最後的兩個要求上:型別安全和人類可讀性。

對於前一個要求,我們可以簡單地解決這個問題:

import "github.com/rs/xid"

type AppID xid.ID
type TraceID xid.ID

func NewAppID() AppID { return AppID(xid.New()) }
func NewTraceID() TraceID { return TraceID(xid.New()) }


由於 Go 的型別系統,AppID和TraceID兩者都是不同的型別,因此以下將成為編譯錯誤:

var app AppID = NewTraceID() // this won't compile

這本來是可行的;但是,我們必須為每個具體型別(如AppID)實現所有的marshalling函式(encoding.TextMarshaler, json.Marshaler, sql.Scanner等)。為了儘量減少我們團隊的模板編寫,這將意味著使用程式碼生成器來為我們編寫。

這裡的另一個缺點是我們的系統不僅僅是Go。我們還有一個Typescript前臺和一個Postgres資料庫。這意味著一旦我們將ID編碼成線格式,我們就失去了對型別安全的所有保證,現在在另一個系統中,有可能錯誤地將一種形式的ID用於另一種。

在Go 1.18之前,我們可以透過新增一個包含型別資訊的包裝結構來解決這個問題。

import (
    "fmt"
    "github.com/rs/xid"
)

type EncoreID struct {
    ResourceType string
    ID           xid.ID
}

type AppID *EncoreID
type TraceID *EncoreID

func NewAppID() AppID { return &EncoreID{ "app", xid.New() } }
func NewTraceID() TraceID { return &EncoreID{ "trace", xid.New() } }


 

進入Go泛型
在Go 1.18中,我們可以建立一個抽象的ID型別,然後基於ResourceType建立不同的具體型別,但實際上只是xid。所以從概念上講,我們是這樣開始的。

import (
    "fmt"
    "github.com/rs/xid"
)

type ResourceType struct{}
type App ResourceType
type Trace ResourceType

type ID[T ResourceType] xid.ID

func New[T ResourceType]() ID[T] { return ID[T](xid.New()) }


更多點選標題
 

相關文章