微服務 - 分庫分表的自增主鍵 ID 該如何設計?
一. 前言
分佈式 ID 是分佈式系統裏面非常重要的一個組成部分,那麼我們在設計分佈式 ID 的時候,需要考慮什麼問題呢?
-
❓簡單結構下是怎麼實現 ID 的控制的?
-
單實例系統 :通過時間戳,系統內自增,上鎖等方式保證 ID 的唯一性
-
單數據庫實例 : 通過自增字段(不談性能)實現 ID 的唯一
-
❓微服務 + 分庫分表了 ,又該如何進行控制?
-
問題 : 單個系統沒辦法
直接感知
到其他系統的 ID 情況,哪怕通信也要付出極大的代價
二. 來理解分佈式 ID 的原則
2.1 分佈式 ID 的本質是什麼 ?
-
全局唯一 : 要保證的是在任何場景下,任何系統,任何庫,同一業務場景中生成的 ID 一定是唯一的
-
遞增 : 有的文章裏面會談到單調遞增和趨勢遞增,這講到的是 2 個維度:
-
一個要求 ID 是有序增長的(
趨勢遞增 ,用於排序
) -
一個是要求 ID 是正向增長的(
單調遞增 ,下一個一定比上一個大
) -
無規則 : 無規則是指不能按照 MySQL 主鍵自增這種方式進行 + 1 自增,簡單的自增方式會帶來安全層面的風險
2.2 有哪些相同性質的問題?
對於分佈式 ID 的實現 ,在某些思路上和很多業務是通用的 ,例如 :
-
訂單編號的生成邏輯 :有序 + 反映時間
-
券碼的生成邏輯 : 有序 + 不可推測
-
動態碼 : 付款碼,會員動態碼等等
這些碼的生產一般都會包含上述的 2 項原則,一定會要求全局唯一 ,同時根據情況來進行有序或者無序的控制。
其實無序一般也是看起來無序,在底層邏輯的生成上不可能完全無序
,否則總會出現衝突的場景。
2.3 分佈式 ID 的根本實現方式是什麼 ?
ID 的生成本質上只需要關注兩個核心 :
-
區域的劃分 :我們需要保證每臺機器是一個固定的區域 ,一般稱之爲機器 ID
-
鎖的控制 : 不止是不同服務之間的分佈式鎖,還包括同一個服務的線程鎖
來簡單解讀下 ,服務之間的通信很消耗資源
,所以能不通信實現分佈式 ID 的生成效率是最高的 , 那麼一般會在服務啓動的時候
就計算出對應的工作區間。
同時要理解的是 ,鎖往往和性能是對立關係
,鎖越多 ,則性能會相對越差,所以如何控制鎖的粒度
,則是分佈式 ID 生成的一大核心。
三. 來探討一下實現的思路
3.1 常規的分佈式 ID 算法
可用但是有限制的方案
// 簡易版 - 基於時間戳的ID算法 :
- 方案 : 使用時間戳作爲ID的前綴,然後通過機器的IP地址或MAC地址進行哈希計算得到剩餘部分
- 問題 : 過於簡單 , 只能實現單機毫秒級的併發
// 低效版 - 數據庫自增
- 方案 : 沒有方案 ,交給數據庫來
- 問題 : 性能低 , 不支持分庫分表
// 升級版 - 基於UUID
- 方案 :
- 基於時間的UUID:主要依賴當前的時間戳及機器mac地址,因此可以保證全球唯一性
- 分佈式安全的UUID:將版本1的時間戳前四位換爲POSIX的UID或GID
- 基於隨機數的UUID:基於隨機數或僞隨機數生成
- 基於名字空間的UUID(MD5版):基於指定的名字空間/名字生成MD5散列值得到
- 問題 : 長度過長 ,無序 ,不可讀
常規的方案 :
- ❓ 基於雪花算法的 ID 算法:
雪花算法是由 Twitter 開發的一種分佈式 ID 算法,它由幾部分組成:時間戳、數據中心ID和機器ID以及序列ID
。該算法可以保證 ID 的唯一性和穩定性,但需要較爲複雜的計算和管理。
- ❓ 基於 LeaseSet 的 ID 算法
LeaseSet 是一種分佈式 ID 系統,它通過將 ID 劃分爲多個片段
,然後將這些片段分配給不同的機器來生成 ID。該算法可以實現高可用和可擴展性,但需要較爲複雜的實現和管理。
- ❓ 總結一下 :
雪花 ID 一般是常見的分佈式 ID 的方案 ,很多廠商都有這種算法的變種
,操作靈活性能也比較理想。我生成主鍵 ID 時就是這種方案。
而通過分段的方案性能會很高,會在分佈式鎖的基礎上
一次拿多個ID序列
,然後在本地消耗這些 ID 序列。
比如生成訂單碼的時候 ,會一次性取出 100 個碼,然後本地(單機上)逐步使用這些碼。
雪花ID比較通用 。分段方式性能會更好,有序性會更強,畢竟都是連着的。
四. 分佈式 ID 的簡化方案
不同的業務場景對於分佈式 ID 的要求不同 ,所以這裏不說業務相關的,只談實現流程,也沒有什麼代碼
4.1 爲你的 ID 定義格式
-
起始位 : 起始位一般都是 1 ,在日常使用中一般不會變動,除非系統發生了整體的重做又需要保留之前數據時 ,纔可以考慮通過這個來進行擴展
-
總長度 : 位數一般基於具體的業務場景 , 和關聯的內容以及數據總量息息相關。
-
案例一 : 內含時間戳 ,一般 ID 裏面會包含一個毫秒精度的時間戳,具體看業務
-
案例二 : 插入隨機數 ,在併發比較高的情況下 ,則需要通過隨機數減少 ID 衝突的概率
-
總結 : 一般情況下都是 64 位的純數字 ,短了信息少,併發低。 長了浪費空間,浪費性能,庫不支持。
-
機器 ID : 用來描述對應的服務器 , 一般支持 1024 位(也就是 1024 臺服務器,大多數情況下夠用了)
👉 來看一下最常見的雪花算法的格式 :
0 41 51 64
+---------------------------------------+------+-----------+
| 時間戳(以毫秒爲單位) |機器ID| 遞增數 |
+---------------------------------------+------+-----------+
👉 來解析下里面的一些具體的細節 :
❓問題一 : 關於時間戳從什麼時候開始
一般我們看到的雪花算法都會以當前時間
減去過去一個紀元時間
(參考時間點), 有的可能是 1970 年 1 月 1 日 00:00:00
, 有的可能是上線時間
或者一個特殊的時間點
。
通過這種方式既可以減少整體的長度,讓數據變得緊湊 。又可以混淆 ID 的含義,讓 ID 沒有那麼容易被解析。
比如上面那個案例 , 可以看到最開始還空了 4 位數 ,所以時間戳的總空間是一定夠的
❓問題二 : 關於機器 ID
機器 ID 的目的主要是爲了區別不同的機器,從而避免在不同機器上面生成的數據衝突,一般都是通過分佈式鎖的方式來啓動時獲取 :
-
Redis 原子獲取 : 適用於服務器 Pod 數不高的場景,啓動不頻繁,流程比較規範的情況下,用 Redis 完全是足夠的
-
數據庫自增 : 一般這些 ID 的獲取都是在系統啓動時完成,所以數據庫裏面通過自增鍵去做也能實現我們的需求
-
Zookeeper : Zookeeper 持久順序節點的特性也可以實現類似的功能
這 3 種方式也是傳統的分佈式鎖的獲取方式 ,通過自增
這種實現保證機器 ID 每個都不一樣。
但是需要避免下面幾個問題 :
-
集羣過大或者不同業務使用了同一個表生成 (機器 ID 一般就 1024 ,超過了需要從 0 開始,一般是取模的方式)
-
避免某個節點長期不重啓帶來的機器 ID 衝突
-
避免某個機器頻繁的重啓導致機器 ID 被擊穿
❓問題三 : 關於 sequence 序列 ID
重點一 : sequence ID 是先要比對時間戳的 ,時間戳一樣這個值纔會增加
重點二 : 注意併發的影響 ,要麼在生成 ID 的方法上添加 synchronized 控制併發 ,要麼使用原子變量
❓問題四 : 時鐘回撥 切記切記
由於上述的分佈式 ID 是基於時間來實現的,這種方案最大的問題在於時鐘回撥
,如果服務器的時間回滾了,而機器又沒重啓 ,就可能會出現 ID 的衝突。
也有相關的解決方案 ,最常見的就是啓動時校驗時鐘,比較其他的機器上的時間
,方案就不詳述了。
再一個就是換種思路 ,時間不是依賴的系統時間 ,而是一個自增的時間位
。 這個是百度那邊的一種算法,下一章單獨講。
最後總結: ❗❗❗❗
位數不是絕對 ,在保持 64 位總長度的情況下 ,機器 ID 和 最後的自增數都可以隨便調節。
包括整體的 64 位也不是完全絕對的 ,業務不同比 64 小几位也完全是可以的。
4.2 簡單的實現方案
// S1 : 不管用什麼方案 ,Redis 原子自增什麼的,拿到一個 機器ID
public long getMechineId() {
// 僞代碼,方案自尋
return redisService.incr("MECHINE_ID:" + prefixName);
}
// S2 : 構建分佈式ID
private static Long lastTimestamp;
// 注意併發問題 ,加鎖或者原子變量
public static synchronized long buildId(){
long timestamp = System.currentTimeMillis();
// 如果時間一致 ,則需要增加 sequenceID
if (lastTimestamp == timestamp) {
sequence = sequence + 1L & 1023L;
} else {
// 瞎寫的 ,目的就是拿到一個 ID ,從 0 開始也可以
sequence = (long)random.nextInt(128);
}
// 設置時間戳爲最新時間戳
lastTimestamp = timestamp;
// tenantCode 是初始位 ,可以是0 ,也可以是 1
// 如果爲 0 則可能導致 ID 長度不統一 ,所以這裏要根據具體的情況去設置
// - 這裏偏移多少位取決於後續的 sequence 想要留多少空間 ,只要時間戳偏移不要超過總數就行
// - MechineId 留了 10 位 ,也就是 1024 個機器
// - sequence 留了 12位 ,也就是每毫秒 4095 個
return tenantCode << 60 | timestamp - 1288834974657L << 22 | getMechineId() << 12 | sequence;
}
// S3 : 入庫時使用
略 ,這就不用說了吧 ,寫數據庫的時候設置到ID裏面就行了
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/MilazC5iqsZgoJCQmgeBfA