今天我們來拆解 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 的風采後,相信你一定會有其他巧妙的想法,歡迎在評論區留言,我們一起交流進步!
夜幕團隊成立於 2019 年,團隊包括崔慶才(靜覓)、周子淇(Loco)、陳祥安(CXA)、唐軼飛(大魚|BruceDone)、馮威(妄為)、蔡晉(悅來客棧的老闆)、戴煌金(鹹魚)、張冶青(MarvinZ)、韋世東(Asyncins|奎因)和文安哲(sml2h3)。
涉獵的程式語言包括但不限於 Python、Rust、C++、Go,領域涵蓋爬蟲、深度學習、服務研發、逆向工程、軟體安全等。團隊非正亦非邪,只做認為對的事情,請大家小心。