微服務 - 分庫分表的自增主鍵 ID 該如何設計?

一. 前言

分佈式 ID 是分佈式系統裏面非常重要的一個組成部分,那麼我們在設計分佈式 ID 的時候,需要考慮什麼問題呢?

二. 來理解分佈式 ID 的原則

2.1 分佈式 ID 的本質是什麼 ?

2.2 有哪些相同性質的問題?

對於分佈式 ID 的實現 ,在某些思路上和很多業務是通用的 ,例如 :

這些碼的生產一般都會包含上述的 2 項原則,一定會要求全局唯一 ,同時根據情況來進行有序或者無序的控制。

其實無序一般也是看起來無序,在底層邏輯的生成上不可能完全無序,否則總會出現衝突的場景。

2.3 分佈式 ID 的根本實現方式是什麼 ?

ID 的生成本質上只需要關注兩個核心 :

來簡單解讀下 ,服務之間的通信很消耗資源 ,所以能不通信實現分佈式 ID 的生成效率是最高的 , 那麼一般會在服務啓動的時候就計算出對應的工作區間。

同時要理解的是 ,鎖往往和性能是對立關係,鎖越多 ,則性能會相對越差,所以如何控制鎖的粒度,則是分佈式 ID 生成的一大核心。

三. 來探討一下實現的思路

3.1 常規的分佈式 ID 算法

可用但是有限制的方案

// 簡易版 - 基於時間戳的ID算法 :
- 方案 : 使用時間戳作爲ID的前綴,然後通過機器的IP地址或MAC地址進行哈希計算得到剩餘部分
- 問題 : 過於簡單 , 只能實現單機毫秒級的併發
// 低效版 - 數據庫自增
- 方案 : 沒有方案 ,交給數據庫來
- 問題 : 性能低 , 不支持分庫分表
// 升級版 - 基於UUID
- 方案 :
    - 基於時間的UUID:主要依賴當前的時間戳及機器mac地址,因此可以保證全球唯一性
    - 分佈式安全的UUID:將版本1的時間戳前四位換爲POSIX的UID或GID
    - 基於隨機數的UUID:基於隨機數或僞隨機數生成
    - 基於名字空間的UUID(MD5版):基於指定的名字空間/名字生成MD5散列值得到
- 問題 : 長度過長 ,無序 ,不可讀

常規的方案 :

雪花算法是由 Twitter 開發的一種分佈式 ID 算法,它由幾部分組成:時間戳、數據中心ID和機器ID以及序列ID。該算法可以保證 ID 的唯一性和穩定性,但需要較爲複雜的計算和管理。

LeaseSet 是一種分佈式 ID 系統,它通過將 ID 劃分爲多個片段,然後將這些片段分配給不同的機器來生成 ID。該算法可以實現高可用和可擴展性,但需要較爲複雜的實現和管理。

雪花 ID 一般是常見的分佈式 ID 的方案 ,很多廠商都有這種算法的變種,操作靈活性能也比較理想。我生成主鍵 ID 時就是這種方案。

而通過分段的方案性能會很高,會在分佈式鎖的基礎上 一次拿多個ID序列 ,然後在本地消耗這些 ID 序列。

比如生成訂單碼的時候 ,會一次性取出 100 個碼,然後本地(單機上)逐步使用這些碼。

雪花ID比較通用 。分段方式性能會更好,有序性會更強,畢竟都是連着的。

四. 分佈式 ID 的簡化方案

不同的業務場景對於分佈式 ID 的要求不同 ,所以這裏不說業務相關的,只談實現流程,也沒有什麼代碼

4.1 爲你的 ID 定義格式

👉 來看一下最常見的雪花算法的格式 :

0                                       41     51         64
+---------------------------------------+------+-----------+
| 時間戳(以毫秒爲單位)                  |機器ID|    遞增數  |
+---------------------------------------+------+-----------+

👉 來解析下里面的一些具體的細節 :

問題一 : 關於時間戳從什麼時候開始

一般我們看到的雪花算法都會以當前時間減去過去一個紀元時間 (參考時間點), 有的可能是 1970 年 1 月 1 日 00:00:00 , 有的可能是上線時間或者一個特殊的時間點

通過這種方式既可以減少整體的長度,讓數據變得緊湊 。又可以混淆 ID 的含義,讓 ID 沒有那麼容易被解析。

比如上面那個案例 , 可以看到最開始還空了 4 位數 ,所以時間戳的總空間是一定夠的

問題二 : 關於機器 ID

機器 ID 的目的主要是爲了區別不同的機器,從而避免在不同機器上面生成的數據衝突,一般都是通過分佈式鎖的方式來啓動時獲取 :

這 3 種方式也是傳統的分佈式鎖的獲取方式 ,通過自增這種實現保證機器 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