忘掉 Snowflake,感受一下性能高出 587 倍的全局唯一 ID 生成算法

今天我們來拆解 Snowflake 算法,同時領略百度、美團、騰訊等大廠在全局唯一 ID 服務方面做的設計,接着根據具體需求設計一款全新的全局唯一 ID 生成算法。這還不夠,我們會討論到全局唯一 ID 服務的分佈式 CAP 選擇與性能瓶頸。

已經熟悉 Snowflake 的朋友可以先去看大廠的設計和權衡。

百度 UIDGenertor:https://github.com/baidu/uid-...

美團 Leaf:https://tech.meituan.com/2017...

騰訊 Seqsvr: https://www.infoq.cn/article/...

全局唯一 ID 是分佈式系統和訂單類業務系統中重要的基礎設施。這裏引用美團的描述:

在複雜分佈式系統中,往往需要對大量的數據和消息進行唯一標識。如在美團點評的金融、支付、餐飲、酒店、貓眼電影等產品的系統中,數據日漸增長,對數據分庫分表後需要有一個唯一 ID 來標識一條數據或消息,數據庫的自增 ID 顯然不能滿足需求;特別一點的如訂單、騎手、優惠券也都需要有唯一 ID 做標識。
這時候你可能會問:我還是不懂,爲什麼一定要全局唯一 ID?

我再列舉一個場景,在 MySQL 分庫分表的條件下,MySQL 無法做到依次、順序、交替地生成 ID,這時候要保證數據的順序,全局唯一 ID 就是一個很好的選擇。

在爬蟲場景中,這條數據在進入數據庫之前會進行數據清洗、校驗、矯正、分析等多個流程,這期間有一定概率發生重試或設爲異常等操作,也就是說在進入數據庫之前它就需要有一個 ID 來標識它。

全局唯一 ID 應當具備什麼樣的屬性,才能夠滿足上述的場景呢?
美團技術團隊列出的 4 點屬性我覺得很準確,它們是:

全局唯一性:不能出現重複的 ID 號,既然是唯一標識,這是最基本的要求;
趨勢遞增:在 MySQL InnoDB 引擎中使用的是聚集索引,由於多數 RDBMS 使用 B-tree 的數據結構來存儲索引數據,在主鍵的選擇上面我們應該儘量使用有序的主鍵保證寫入性能;
單調遞增:保證下一個 ID 一定大於上一個 ID,例如事務版本號、IM 增量消息、排序等特殊需求;
信息安全:如果 ID 是連續的,惡意用戶的爬取工作就非常容易做了,直接按照順序下載指定 URL 即可;如果是訂單號就更危險了,競爭對手可以直接知道我們一天的單量。所以在一些應用場景下,會需要 ID 無規則、不規則。
看上去第 3 點和第 4 點似乎還存在些許衝突,這個後面再說。除了以上列舉的 ID 屬性外,基於這個生成算法構建的服務還需要買足高 QPS、高可用性和低延遲的幾個要求。

業內常見的 ID 生成方式有哪些?
大家在唸書的時候肯定都學過 UUID 和 GUID,它們生成的值看上去像這樣:

6F9619FF-8B86-D011-B42D-00C04FC964FF
由於不是純數字組成,這就無法滿足趨勢遞增和單調遞增這兩個屬性,同時在寫入時也會降低寫入性能。上面提到了數據庫自增 ID 無法滿足入庫前使用和分佈式場景下的需求,遂排除。

有人提出了藉助 Redis 來實現,例如訂單號=日期+當日自增長號,自增長通過 INCR 實現。但這樣操作的話又無法滿足編號不可猜測需求。

這時候有人提出了 MongoDB 的 ObjectID,不要忘了它生成的 ID 是這樣的: 5b6b3171599d6215a8007se0,和 UUID 一樣無法滿足遞增屬性,且和 MySQL 一樣要入庫後才能生成。

難道就沒有能打的了嗎?

大名鼎鼎的 Snowflake
Twitter 於 2010 年開源了內部團隊在用的一款全局唯一 ID 生成算法 Snowflake,翻譯過來叫做雪花算法。Snowflake 不借助數據庫,可直接由編程語言生成,它通過巧妙的位設計使得 ID 能夠滿足遞增屬性,且生成的 ID 並不是依次連續的,能夠滿足上面提到的全局唯一 ID 的 4 個屬性。它連續生成的 3 個 ID 看起來像這樣:

563583455628754944
563583466173235200
563583552944996352
Snowflake 以 64 bit 來存儲組成 ID 的4 個部分:

1、最高位佔1 bit,值固定爲 0,以保證生成的 ID 爲正數;

2、中位佔 41 bit,值爲毫秒級時間戳;

3、中下位佔 10 bit,值爲工作機器的 ID,值的上限爲 1024;

4、末位佔 12 bit,值爲當前毫秒內生成的不同 ID,值的上限爲 4096;

Snowflake 的代碼實現網上有很多款,基本上各大語言都能找到實現參考。我之前在做實驗的時候在網上找到一份 Golang 的代碼實現:

代碼可在我的 Gist 查看和下載。

Snowflake 存在的問題
snowflake 不依賴數據庫,也不依賴內存存儲,隨時可生成 ID,這也是它如此受歡迎的原因。但因爲它在設計時通過時間戳來避免對內存和數據庫的依賴,所以它依賴於服務器的時間。上面我們提到了 Snowflake 的 4 段結構,實際上影響 ID 大小的是較高位的值,由於最高位固定爲 0,遂影響 ID 大小的是中位的值,也就是時間戳。

試想,服務器的時間發生了錯亂或者回撥,這就直接影響到生成的 ID,有很大概率生成重複的 ID 且一定會打破遞增屬性。這是一個致命缺點,你想想,支付訂單和購買訂單的編號重複,這是多麼嚴重的問題!

另外,由於它的中下位和末位 bit 數限制,它每毫秒生成 ID 的上限嚴重受到限制。由於中位是 41 bit 的毫秒級時間戳,所以從當前起始到 41 bit 耗盡,也只能堅持 70 年。

再有,程序獲取操作系統時間會耗費較多時間,相比於隨機數和常數來說,性能相差太遠,這是制約它生成性能的最大因素。

一線企業如何解決全局唯一 ID 問題
長話短說,我們來看看百度、美團、騰訊(微信)是如何做的。

百度團隊開源了 UIDGenerator 算法.

它通過借用未來時間和雙 Buffer 來解決時間回撥與生成性能等問題,同時結合 MySQL 進行 ID 分配。這是一種基於 Snowflake 的優化操作,是一個好的選擇,你認爲這是不是優選呢?

美團團隊根據業務場景提出了基於號段思想的 Leaf-Segment 方案和基於 Snowflake 的 Leaf-Snowflake 方案.

出現兩種方案的原因是 Leaf-Segment 並沒有滿足安全屬性要求,容易被猜測,無法用在對外開放的場景(如訂單)。Leaf-Snowflake 通過文件系統緩存降低了對 ZooKeeper 的依賴,同時通過對時間的比對和警報來應對 Snowflake 的時間回撥問題。這兩種都是一個好的選擇,你認爲這是不是優選呢?

微信團隊業務特殊,它有一個用 ID 來標記消息的順序的場景,用來確保我們收到的消息就是有序的。在這裏不是全局唯一 ID,而是單個用戶全局唯一 ID,只需要保證這個用戶發送的消息的 ID 是遞增即可。

這個項目叫做 Seqsvr,它並沒有依賴時間,而是通過自增數和號段來解決生成問題的。這是一個好的選擇,你認爲這是不是優選呢?

性能高出 Snowflake 587 倍的算法是如何設計的?
在瞭解 Snowflake 的優缺點、閱讀了百度 UIDGenertor、美團 Leaf 和騰訊微信 Seqsvr 的設計後,我希望設計出一款能夠滿足全局唯一 ID 4 個屬性且性能更高、使用期限更長、不受單位時間限制、不依賴時間的全局唯一 ID 生成算法。

這看起來很簡單,但吸收所學知識、設計、實踐和性能優化佔用了我 4 個週末的時間。在我看來,這個算法的設計過程就像是液態的水轉換爲氣狀的霧一樣,遂我給這個算法取名爲薄霧(Mist)算法。接下來我們來看看薄霧算法是如何設計和實現的。

位數是影響 ID 數值上限的主要因素,Snowflake 中下位和末位的 bit 數限制了單位時間內生成 ID 的上限,要解決這個兩個問題,就必須重新設計 ID 的組成。

拋開中位,我們先看看中下位和末位的設計。中下位的 10 bit 的值其實是機器編號,末位 12 bit 的值其實是單位時間(同一毫秒)內生成的 ID 序列號,表達的是這毫秒生成的第 5 個或第 150 個 數值,同時二者的組合使得 ID 的值變幻莫測,滿足了安全屬性。實際上並不需要記錄機器編號,也可以不用管它到底是單位時間內生成的第幾個數值,安全屬性我們可以通過多組隨機數組合的方式實現,隨着數字的遞增和隨機數的變幻,通過 ID 猜順序的難度是很高的。

最高位固定是 0,不需要對它進行改動。我們來看看至關重要的中位,Snowflake 的中位是毫秒級時間戳,既然不打算依賴時間,那麼肯定也不會用時間戳,用什麼呢?我選擇自增數 1,2,3,4,5,...。中位決定了生成 ID 的上限和使用期限,如果沿用 41 bit,那麼上限跟用時間戳的上限相差無幾,經過計算後我選擇採用與 Snowflake 的不同的分段:

縮減中下位和末位的 bit 數,增加中位的 bit 數,這樣就可以擁有更高的上限和使用年限,那上限和年限現在是多久呢?中位數值的上限計算公式爲 int64(1<<47 - 1),計算結果爲 140737488355327 。百萬億級的數值,假設每天消耗 10 億 ID,薄霧算法能用 385+ 年,幾輩子都用不完。

中下位和末位都是 8 bit,數值上限是 255,即開閉區間是 [0, 255]。這兩段如果用隨機數進行填充,對應的組合方式有 256 * 256 種,且每次都會變化,猜測難度相當高。由於不像 Snowflake 那樣需要計算末位的序列號,遂薄霧算法的代碼並不長,具體代碼可在我的 GitHub 倉庫找到:

聊聊性能問題,獲取時間戳是比較耗費性能的,不獲取時間戳速度當然快了,那 500+ 倍是如何得來的呢?以 Golang 爲例(我用 Golang 做過實驗),Golang 隨機數有三種生成方式:

基於固定數值種子的隨機數;
將會變換的時間戳作爲種子的隨機數;
大數真隨機;
基於固定數值種子的隨機數每次生成的值都是一樣的,是僞隨機,不可用在此處。將時間戳作爲種子以生成隨機數是目前 Golang 開發者的主流做法,實測性能約爲 8800 ns/op。

大數真隨機知道的人比較少,實測性能 335ns/op,由此可見性能相差近 30 倍。大數真隨機也有一定的損耗,如果想要將性能提升到頂點,只需要將中下位和末位的隨機數換成常數即可,常數實測性能 15ns/op,是時間戳種子隨機數的 587 倍。

要注意的是,將常數放到中下位和末位的性能是很高,但是猜測難度也相應下降。
薄霧算法的依賴問題
薄霧算法爲了避開時間依賴,不得不依賴存儲,中位自增的數值只能在內存中存活,遂需要依賴存儲將自增數值存儲起來,避免因爲宕機或程序異常造成重複 ID 的事故。

看起來是這樣,但它真的是依賴存儲嗎?

你想想,這麼重要的服務必定要求高可用,無論你用 Twitter 還是百度或者美團、騰訊微信的解決方案,在架構上一定都是高可用的,高可用一定需要存儲。在這樣的背景下,薄霧算法的依賴其實並不是額外的依賴,而是可以與架構完全融合到一起的設計。

薄霧算法和 Redis 的結合
既然提出了薄霧算法,怎麼能不提供真實可用的工程實踐呢?在編寫完薄霧算法之後,我就開始了工程實踐的工作,將薄霧算法與 KV 存儲結合到一起,提供全局唯一 ID 生成服務。這裏我選擇了較爲熟悉的 Redis,Mist 與 Redis 的結合,我爲這個項目取的名字爲 Medis。

性能高並不是編造出來的,我們看看它 Jemeter 壓測參數和結果:

以上是 Medis README 中給出的性能測試截圖,在大基數條件下的性能約爲 2.5w/sec。這麼高的性能除了薄霧算法本身高性能之外,Medis 的設計也作出了很大貢獻:

使用 Channel 作爲數據緩存,這個操作使得發號服務性能提升了 7 倍;
採用預存預取的策略保證 Channel 在大多數情況下都有值,從而能夠迅速響應客戶端發來的請求;
用 Gorouting 去執行耗費時間的預存預取操作,不會影響對客戶端請求的響應;
採用 Lrange Ltrim 組合從 Redis 中批量取值,這比循環單次讀取或者管道批量讀取的效率更高;
寫入 Redis 時採用管道批量寫入,效率比循環單次寫入更高;
Seqence 值的計算在預存前進行,這樣就不會耽誤對客戶端請求的響應,雖然薄霧算法的性能是納秒級別,但併發高的時候也造成一些性能損耗,放在預存時計算顯然更香;
得益於 Golang Echo 框架和 Golang 本身的高性能,整套流程下來我很滿意,如果要追求極致性能,我推薦大家試試 Rust;
Medis 服務啓動流程和接口訪問流程圖下所示:

感興趣的朋友可以下載體驗一下,啓動 Medis 根目錄的 server.go 後,訪問 http://localhost:1558/sequence 便能拿到全局唯一 ID。

高可用架構和分佈式性能
分佈式 CAP (一致性、可用性、分區容錯性)已成定局,這類服務通常追求的是可用性架構(AP)。由於設計中採用了預存預取,且要保持整體順序遞增,遂單機提供訪問是優選,即分佈式架構下的性能上限就是提供服務的那臺主機的單機性能。

你想要實現分佈式多機提供服務?

這樣的需求要改動 Medis 的邏輯,同時也需要改動各應用之間的組合關係。如果要實現分佈式多機同時提供服務,那麼就要廢棄 Redis 和 Channel 預存預取機制,接着放棄 Channel 而改用即時生成,這樣便可以同時使用多個 Server,但性能的瓶頸就轉移到了 KV 存儲(這裏是 Redis),性能等同於單機 Redis 的性能。你可以採用 ETCD 或者 Zookeeper 來實現多 KV,但這不是又回到了 CAP 原點了嗎?

至於怎麼選擇,可根據實際業務場景和需求與架構進行討論,選擇一個適合的方案進行部署即可。

領略了 Mist 和 Medis 的風采後,相信你一定會有其他巧妙的想法,歡迎在評論區留言,我們一起交流進步!

作者:夜幕鎮嶽
鏈接:https://segmentfault.com/a/1190000023087471
來源:SegmentFault 思否
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

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