分散式系統中,全域性唯一 ID 的生成是一個老生常談但是非常重要的話題。隨著技術的不斷成熟,大家的分散式全域性唯一 ID 設計與生成方案趨向於趨勢遞增的 ID,這篇文章將結合我們系統中的 ID 針對實際業務場景以及效能儲存和可讀性的考量以及優缺點取捨,進行深入分析。本文並不是為了分析出最好的 ID 生成器,而是分析設計 ID 生成器的時候需要考慮哪些,如何設計出最適合自己業務的 ID 生成器。
首先,先放出我們的全域性唯一 ID 結構:
這個唯一 ID 生成器是放在每個微服務程式裡面的外掛這種架構,不是有那種唯一 ID 生成中心的架構:
- 開頭是時間戳格式化之後的字串,可以直接看出年月日時分秒以及毫秒。由於分散在不同程式裡面,需要考慮不同微服務時間戳不同是否會產生相同 ID 的問題。
- 中間業務欄位,最多 4 個字元。
- 最後是自增序列。這個自增序列通過 Redis 獲取,同時做了分散壓力優化以及叢集 fallback 優化,後面會詳細分析。
序列號的開頭是時間戳格式化之後的字串,由於分散在不同程式裡面,不同程式當前時間可能會有差異,這個差異可能是毫秒或者秒級別的。所以,要考慮 ID 中剩下的部分是否會產生相同的序列。
自增序列由兩部分組成,第一部分是 Bucket,後面是從 Redis 中獲取的對應 Bucket 自增序列,獲取自增序列的虛擬碼是:
1. 獲取當前執行緒 ThreadLocal 的 position,position 初始值為一個隨機數。
2. position += 1,之後對最大 Bucket 大小(即 2^8)取餘,即對 2^8 - 1 取與運算,獲取當前 Bucket。
如果當前 Bucket 沒有被斷路,則執行做下一步,否則重複 2。
如果所有 Bucket 都失敗,則拋異常退出
3. redis 執行: incr sequence_num_key:當前Bucket值,拿到返回值 sequence
4. 如果 sequence 大於最大 Sequence 值,即 2^18, 對這個 Bucket 加鎖(sequence_num_lock:當前Bucket值),
更新 sequence_num_key:當前Bucket值 為 0,之後重複第 3 步。否則,返回這個 sequence
-- 如果 3,4 出現 Redis 相關異常,則將當前 Bucket 加入斷路器,重複步驟 2
在這種演算法下,即使每個例項時間戳可能有差異,只要在最大差異時間內,同一業務不生成超過 Sequence 界限數量的實體,即可保證不會產生重複 ID。
同時,我們設計了 Bucket,這樣在使用 Redis 叢集的情況下,即使某些節點的 Redis 不可用,也不會影響我們生成 ID。
當前 OLTP 業務離不開傳統資料庫,目前最流行的資料庫是 MySQL,MySQL 中最流行的 OLTP 儲存引擎是 InnoDB。考慮業務擴充套件與分散式資料庫設計,InnoDB 的主鍵 ID 一般不採用自增 ID,而是通過全域性 ID 生成器生成。這個 ID 對於 MySQL InnoDB 有哪些效能影響呢?我們通過將 BigInt 型別主鍵和我們這個字串型別的主鍵進行對比分析。
首先,由於 B+ 樹的索引特性,主鍵越是嚴格遞增,插入效能越好。越是混亂無序,插入效能越差。這個原因,主要是 B+ 樹設計中,如果值無序程度很高,資料被離散儲存,造成 innodb 頻繁的頁分裂操作,嚴重降低插入效能。可以通過下面兩個圖的對比看出:
插入有序:
插入無序:
如果插入的主鍵 ID 是離散無序的,那麼每次插入都有可能對於之前的 B+ 樹子節點進行裂變修改,那麼在任一一段時間內,整個 B+ 樹的每一個子分支都有可能被讀取並修改,導致記憶體效率低下。如果主鍵是有序的(即新插入的 id 比之前的 id 要大),那麼只有最新分支的子分支以及節點會被讀取修改,這樣從整體上提升了插入效率。
我們設計的 ID,由於是當前時間戳開頭的,從趨勢上是整體遞增的。基本上能滿足將插入要修改的 B+ 樹節點控制在最新的 B+ 樹分支上,防止樹整體掃描以及修改。
和 SnowFlake 演算法生成的 long 型別數字,在資料庫中即 bigint 對比:bigint,在 InnoDB 引擎行記錄儲存中,無論是哪種行格式,都佔用 8 位元組。我們的 ID,char型別,字元編碼採用 latin1(因為只有字母和數字),佔用 27 位元組,大概是 bigint 的 3 倍多。
- MySQL 的主鍵 B+ 樹,如果主鍵越大,那麼單行佔用空間越多,即 B+ 樹的分支以及葉子節點都會佔用更多空間,造成的後果是:MySQL 是按頁載入檔案到記憶體的,也是按頁處理的。這樣一頁內,可以讀取與操作的資料將會變少。如果資料表欄位只有一個主鍵,那麼 MySQL 單頁(不考慮各種頭部,例如頁頭,行頭,表頭等等)能載入處理的行數, bigint 型別是我們這個主鍵的 3 倍多。但是資料表一般不會只有主鍵欄位,還會有很多其他欄位,其他欄位佔用空間越多,這個影響越小。
- MySQL 的二級索引,葉子節點的值是主鍵,那麼同樣的,單頁載入的葉子節點數量,bigint 型別是我們這個主鍵的 3 倍多。但是目前一般 MySQL 的配置,都是記憶體資源很大的,造成其實二級索引搜尋主要的效能瓶頸並不在於此處,這個 3 倍影響對於大部分查詢可能就是小於毫秒級別的優化提升。相對於我們設計的這個主鍵帶來的可讀性以及便利性來說,是微不足道的。
業務上,其實有很多需要按建立時間排序的場景。比如說查詢一個使用者今天的訂單,並且按照建立時間倒序,那麼 SQL 一般是:
## 查詢數量,為了分頁
select count(1) from t_order where user_id = "userid" and create_time > date(now());
## 之後查詢具體資訊
select * from t_order where user_id = "userid" and create_time > date(now()) order by create_time limit 0, 10;
訂單表肯定會有 user_id 索引,但是隨著業務增長,下單量越來越多導致這兩個 SQL 越來越慢,這時我們就可以有兩種選擇:
- 建立 user_id 和 create_time 的聯合索引來減少掃描,但是大表額外增加索引會導致佔用更多空間並且和現有索引重合有時候會導致 SQL 優化有誤。
- 直接使用我們的主鍵索引進行篩選:
select count(1) from t_order where user_id = "userid" and id > "210821";
select * from t_order where user_id = "userid" and id > "210821" order by id desc limit 0, 10;
但是需要注意的是,第二個 SQL 執行會比建立 user_id 和 create_time 的聯合索引執行原來的 SQL 多一步 Creating sort index
即將命中的資料在記憶體中排序,如果命中量比較小,即大部分使用者在當天的訂單量都是幾十幾百這個級別的,那麼基本沒問題,這一步不會消耗很大。否則還是需要建立 user_id 和 create_time 的聯合索引來減少掃描。
如果不涉及排序,僅僅篩選的話,這樣做基本是沒問題的。
我們不希望使用者通過 ID 得知我們的業務體量,例如我現在下一單拿到 ID,之後再過一段時間再下一單拿到 ID,對比這兩個 ID 就能得出這段時間內有多少單。
我們設計的這個 ID 完全沒有這個問題,因為最後的序列號:
- 所有業務共用同一套序列號,每種業務有 ID 產生的時候,就會造成 Bucket 裡面的序列遞增。
- 序列號同一時刻可能不同執行緒使用的不同的 Bucket,並且結果是位操作,很難看出來那部分是序列號,那部分是 Bucket。
從我們設計的 ID 上,可以直觀的看出這個業務的實體,是在什麼時刻建立出來的:
- 一般客服受理問題的時候,拿到 ID 就能看出來時間,直接去後臺系統對應時間段調取使用者相關操作記錄即可。簡化操作。
- 一般的業務有報警系統,一般報警資訊中會包含 ID,從我們設計的 ID 上就能看出來建立時間,以及屬於哪個業務。
- 日誌一般會被採集到一起,所有微服務系統的日誌都會匯入例如 ELK 這樣的系統中,從搜尋引擎中搜尋出來的資訊,從 ID 就能直觀看出業務以及建立時間。
在給出的專案原始碼地址中的單元測試中,我們測試了通過 embedded-redis 啟動一個本地 redis 的單執行緒,200 執行緒獲取 ID 的效能,並且對比了只操作 redis,只獲取序列以及獲取 ID 的效能,我的破電腦結果如下:
單執行緒
BaseLine(only redis): 200000 in: 28018ms
Sequence generate: 200000 in: 28459ms
ID generate: 200000 in: 29055ms
200執行緒
BaseLine(only redis): 200000 in: 3450ms
Sequence generate: 200000 in: 3562ms
ID generate: 200000 in: 3610ms
微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer: