👉 這是一個或許對你有用的社群
《專案實戰(影片)》:從書中學,往事上“練” 《網際網路高頻面試題》:面朝簡歷學習,春暖花開 《架構 x 系統設計》:摧枯拉朽,掌控面試高頻場景題 《精進 Java 學習指南》:系統學習,網際網路主流技術棧 《必讀 Java 原始碼專欄》:知其然,知其所以然

👉這是一個或許對你有用的開源專案國產 Star 破 10w+ 的開源專案,前端包括管理後臺 + 微信小程式,後端支援單體和微服務架構。功能涵蓋 RBAC 許可權、SaaS 多租戶、資料許可權、商城、支付、工作流、大屏報表、微信公眾號、ERP、CRM、AI 大模型等等功能:
Boot 多模組架構:https://gitee.com/zhijiantianya/ruoyi-vue-pro Cloud 微服務架構:https://gitee.com/zhijiantianya/yudao-cloud 影片教程:https://doc.iocoder.cn 【國內首批】支援 JDK 17/21 + SpringBoot 3.3、JDK 8/11 + Spring Boot 2.7 雙版本

昨天小夥伴使用Mybaits-Plus開發的專案線上(叢集、K8S )出現了主鍵重複問題,其報錯如下:

Mybatis-Plus啟動時會透過
com.baomidou.mybatisplus.core.toolkit.Sequence
類的getMaxWorkerId()
和getDatacenterId()
方法來初始化workerId和dataCenterId()。protectedlonggetMaxWorkerId(long datacenterId, long maxWorkerId)
{
StringBuilder mpid =
new
StringBuilder();
mpid.append(datacenterId);
String name = ManagementFactory.getRuntimeMXBean().getName();
if
(StringUtils.isNotBlank(name)) {
mpid.append(name.split(
"@"
)[
0
]);
}
return
(
long
)(mpid.toString().hashCode() &
'\uffff'
) % (maxWorkerId +
1L
);
}
protectedlonggetDatacenterId(long maxDatacenterId)
{
...省略部分程式碼...
byte
[] mac = network.getHardwareAddress();
if
(
null
!= mac) {
id = (
255L
& (
long
)mac[mac.length -
2
] |
65280L
& (
long
)mac[mac.length -
1
] <<
8
) >>
6
;
id %= maxDatacenterId +
1L
;
}
return
id;
}
透過程式碼可知,workerID是根據虛擬機器名稱生成,dataCenterId是根據mac地址生成,這2個東西部署在Docker環境中就很有可能重複。
本文不去探討怎麼解決這個問題,而是給你推薦另外一個經過最佳化後的雪花演算法,可以非常方便整合在你專案中並替換掉Mybatis-Plus的ID生成邏輯。
概述
在軟體開發過程中,我們經常會遇到需要生成全域性唯一流水號的場景,例如各種流水號和分庫分表的分散式主鍵ID。特別是在使用MySQL資料庫時,除了要求流水號具有“全域性唯一 ”性外,還需要具備“遞增趨勢 ”,以減少MySQL的資料頁分裂,從而降低資料庫IO壓力並提升伺服器效能。
因此,在專案中通常需要引入一種演算法,能夠生成滿足“全域性唯一 ”、“遞增趨勢 ”和“高效能 ”要求的資料。
關於全域性分散式ID的生成,網上有很多相關文章。其中最常見的方法是藉助第三方開源元件實現,如百度開源的Uidgenerator、滴滴開源的TinyID、美團開源的Leaf以及雪花演算法SnowFlake等。然而,大部分開源元件都需要依賴資料庫或Redis中介軟體來實現,對於非特大型專案來說可能過於繁重。因此,我更傾向於在專案中使用雪花演算法SnowFlake來生成全域性唯一ID。
標準版雪花演算法網上已經有很多解讀文章了,此處就不再贅述了。
然而,標準版的雪花演算法存在 時鐘敏感 問題。由於ID生成與當前作業系統時間戳繫結(利用了時間的單調遞增性),當作業系統的時鐘出現回撥時,生成的ID可能會重複(儘管通常不會人為地回撥時鐘,但伺服器可能會出現偶發的“時鐘漂移”現象)。
如果要要解決這個問題,我們可以在獲取 ID 時記錄當前的時間戳。然後在下一次獲取 ID 時,比較當前時間戳和上次記錄的時間戳。如果發現當前時間戳小於上次記錄的時間戳,說明出現了時鐘回撥現象,此時可以拒絕服務並等待時間戳追上記錄值。
因此,在專案中我們不能直接使用標準版的雪花演算法,而需要尋找一個改良後的方案。
這裡我推薦大家使用開源分散式事務處理元件Seata的改良方案,它完美的解決了雪花演算法時鐘敏感的問題,並且程式碼簡潔,可以非常方便整合在你專案中。
下面讓我們來分析一下Seata改進後的方案。
基於 Spring Boot + MyBatis Plus + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
專案地址:https://github.com/YunaiV/ruoyi-vue-pro 影片教程:https://doc.iocoder.cn/video/
Seata的最佳化方案
在原版雪花演算法中,分散式ID的格式是這樣的。

雪花演算法主要是利用時間的單調遞增特性,並且與作業系統的時間戳時刻繫結,一旦出現時間“回退”,則打破了時間 “單調遞增”這個前提,所以可能會出現重複。
而在改良後的Seata方案中,其ID格式是這樣的。

透過觀察Seata程式碼,我們可以發現它只是簡單地調整了節點ID和時間戳的位置。那麼這樣做的目的是什麼呢?
答案是透過這種方式解除了演算法與作業系統時間戳的強繫結關係。 生成器僅在初始化時獲取系統時間戳作為初始時間戳,之後不再與系統時間戳同步。生成器的遞增僅由序列號的遞增驅動。例如,當序列號的當前值達到4095時,下一個請求到來時,序列號將溢位12位空間並重新歸零,同時溢位的進位將加到時間戳上,使時間戳+1。因此,時間戳和序列號實際上可以視為一個整體。
這樣,時間戳和序列號在記憶體中是連續儲存的,可以使用一個
AtomicLong
來同時儲存它們。下面是相關核心程式碼的示例:
/**
* timestamp and sequence mix in one Long
* highest 11 bit: not used
* middle 41 bit: timestamp
* lowest 12 bit: sequence
*/
private
AtomicLong timestampAndSequence;
/**
* The number of bits occupied by sequence
*/
privatefinalint
sequenceBits =
12
;
/**
* init first timestamp and sequence immediately
*/
privatevoidinitTimestampAndSequence()
{
long
timestamp = getNewestTimestamp();
long
timestampWithSequence = timestamp << sequenceBits;
this
.timestampAndSequence =
new
AtomicLong(timestampWithSequence);
}
程式碼解釋:
在初始化方法中,獲取當前時間戳 getNewestTimestamp()
以後將其左移12位,留出了序列號的位置。而Long型別轉化成二進位制以後是64位,前11位不使用,中間的41位代表時間戳,後面的12位代表序列號。
最高11位在初始化時就直接確定好,之後不再變化,核心程式碼如下:
/**
* init workerId
*
@param
workerId if null, then auto generate one
*/
privatevoidinitWorkerId(Long workerId)
{
if
(workerId ==
null
) {
workerId = generateWorkerId();
}
if
(workerId > maxWorkerId || workerId <
0
) {
String message = String.format(
"worker Id can't be greater than %d or less than 0"
, maxWorkerId);
thrownew
IllegalArgumentException(message);
}
this
.workerId = workerId << (timestampBits + sequenceBits);
}
/**
* auto generate workerId, try using mac first, if failed, then randomly generate one
*
@return
workerId
*/
privatelonggenerateWorkerId()
{
try
{
return
generateWorkerIdBaseOnMac();
}
catch
(Exception e) {
return
generateRandomWorkerId();
}
}
/**
* use lowest 10 bit of available MAC as workerId
*
@return
workerId
*
@throws
Exception when there is no available mac found
*/
privatelonggenerateWorkerIdBaseOnMac()throws Exception
{
Enumeration<NetworkInterface> all = NetworkInterface.getNetworkInterfaces();
while
(all.hasMoreElements()) {
NetworkInterface networkInterface = all.nextElement();
boolean
loopBack = networkInterface.isLoopback();
boolean
isVirtual = networkInterface.isVirtual();
if
(loopBack || isVirtual) {
continue
;
}
byte
[] mac = networkInterface.getHardwareAddress();
return
((mac[
4
] &
0B11
) <<
8
) | (mac[
5
] &
0xFF
);
}
thrownew
RuntimeException(
"no available mac found"
);
}
程式碼解讀:
演算法規定了節點ID最長為10位,2的10次方是1024,所以可以服務1024臺機器,體現在數字上的取值範圍是為[0,1023); 在原版雪花演算法中,如果未指定節點ID,會擷取本地IPv4地址的低10位作為節點ID,這樣在生成實踐中如果出現IP的第4個位元組和第3個位元組的低2位一樣就會重複。如:192.168.4.10 和 192.168.8.10 新版演算法generateWorkerIdBaseOnMac()是從從本機網絡卡的MAC地址擷取低10位,最後透過(mac[4] & 0B11) << 8) | (mac[5] & 0xFF)保證其取值範圍最大值為1023,演算法有點難懂,分步解釋:mac[4]
和mac[5]
是無符號8位整數的變數,其取值範圍是[0,255)(mac[4] & 0B11)
運算會保留mac[4]
的最後兩位00,01,10,11,也就是取值範圍為 0 到 3。(mac[4] & 0B11) << 8
。左移 8 位相當於乘以 256,所以結果的取值範圍是 0 到 3 * 256 = 0 到 768。(mac[5] & 0xFF)
最大值就是0xFF
, 也就是取值範圍是 0 到 255所以最後結果的取值範圍是從 0 到 768 | 255 = 1023。 計算出節點ID以後,將其左移,this.workerId = workerId << (timestampBits + sequenceBits),這樣就完成了演算法ID的組裝。
最後看看生成ID的演算法
privatefinalint
timestampBits =
41
;
privatefinalint
sequenceBits =
12
;
privatefinallong
timestampAndSequenceMask = ~(-
1L
<< (timestampBits + sequenceBits));
publiclongnextId()
{
// 獲得遞增後的時間戳和序列號
long
next = timestampAndSequence.incrementAndGet();
// 擷取低53位
long
timestampWithSequence = next & timestampAndSequenceMask;
// 跟先前儲存好的高11位進行一個或的位運算
return
workerId | timestampWithSequence;
}
看完Seata雪花演算法的實現邏輯,你覺得怎麼樣呢?反正我只會直呼 ”臥槽,牛皮“~
透過對Seata改良演算法程式碼的解讀,可以知道,演算法生成器僅在啟動時獲取了一次系統時鐘,可以說是弱依賴於作業系統時鐘,這樣在執行期間,生成器不再受時鐘回撥的影響。
同時由於序列號有12位,最大取值範圍是[0,4095]。
如果在當前毫秒下序列號生成到了 4096 ,這個時候序列號回重新歸0,同時讓時間戳+1,也就是 "借用"下一個時間戳的序列號空間,這種超前消費 會不會導致生成器內的時間戳大大超前於系統的時間戳,從而導致重啟時ID重複呢?
理論上有,實際上並不會。 因為要達到這個效果,也就意味著生成器的QPS得持續穩定在4096/ms,約400W/s之上,這得什麼場景才能有這樣的流量呢?(12306在2020年春運期間高峰QPS約為170W/s,2020年雙11淘寶TPS為58.3W/s) 就算有了,瓶頸一定不在生成器這裡。
透過對Seata改良演算法程式碼的解讀,我們可以瞭解到演算法生成器僅在啟動時獲取一次系統時鐘,因此它在執行期間對作業系統時鐘的依賴相對較弱。這意味著生成器不會受到時鐘回撥的影響。
此外,根據序列號的位數為12位,其取值範圍為[0, 4095]。
如果在當前毫秒內序列號生成到了4096,這時序列號會重新歸0,並且時間戳會增加1,即"借用"下一個時間戳的序列號空間。這種超前消費是否會導致生成器內部的時間戳大大超前於系統的時間戳,從而導致在重啟時出現重複的ID呢?
理論上來說,這種情況是有可能發生的。然而,在實際情況下並不會出現這種問題。因為要達到這種效果,也就意味著生成器的每秒請求數(QPS)需要持續穩定在4096次以上,相當於每秒處理約400萬個請求。這樣高的流量場景是非常罕見的,而且即使存在這樣的流量,天塌下來有高個子頂著,一定會是其他元件先出問題。
基於 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
專案地址:https://github.com/YunaiV/yudao-cloud 影片教程:https://doc.iocoder.cn/video/
Seata雪花演算法的 “缺陷”
經過觀察,我們可以發現一個問題:Seata改良版的演算法在單節點內部確實是單調遞增 的,但是在多例項部署時,它不再保證全域性單調遞增 。這是因為節點ID在生成的ID中佔據了高位,因此節點ID較大的生成的ID一定大於節點ID較小的生成的ID,與它們的生成時間先後順序無關。
相比之下,原版雪花演算法將時間戳放在高位,並且始終追隨系統時鐘,可以確保早期生成的ID小於後期生成的ID。只有當兩個節點恰好在同一時間戳生成ID時,兩個ID的大小才由節點ID決定。
從這個角度來看,新版演算法是否存在問題呢?
關於這個問題,官方已經給出了結論:
新版演算法的確不具備全域性的單調遞增性,但這不影響我們的初衷(減少資料庫的頁分裂)。這個結論看起來有點違反直覺,但可以被證明。
現在讓我們來進一步最佳化和解釋這個結論。
B+樹原理
在證明之前我們需要先回顧一下資料庫頁分裂的相關知識(基於B+數索引的MySQL InnoDB引擎)。
在B+樹索引中,主鍵索引的葉子節點除了儲存鍵的值之外,還儲存了資料行的完整記錄。葉子節點之間以雙向連結串列的形式連線在一起。葉子節點在物理儲存上被組織為資料頁,每個資料頁最多可以儲存N條行記錄。

B+樹的特性要求左邊的節點的鍵值小於右邊節點的鍵值。如果現在要插入一條ID為25的記錄,會發生什麼呢?(假設每個資料頁只能容納4條記錄)答案是會導致頁分裂,如下圖所示:

頁分裂對IO操作不友好,需要建立新的資料頁,並複製和轉移舊資料頁中的部分記錄。因此,我們應該儘量避免頁分裂的發生。
如果你想直觀地瞭解B+樹節點分裂的過程,建議訪問以下網站:B+ Tree Visualization -> https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html
理想的情況下 ,主鍵ID最好是順序遞增的(例如把主鍵設定為
auto_increment
),這樣就只會在當前資料頁放滿了的時候,才需要新建下一頁,雙向連結串列永遠是順序尾部增長的,不會有中間的節點發生分裂的情況。最糟糕的情況下 ,主鍵ID是隨機無序生成的(例如java中一個UUID字串),這種情況下,新插入的記錄會隨機分配到任何一個數據頁,如果該頁已滿,就會觸發頁分裂。
如果主鍵ID由標準版雪花演算法生成,最好的情況下,是每個時間戳內只有一個節點在生成ID,這時候演算法的效果等同於理想情況的順序遞增,即跟
auto_increment
無差。最壞的情況下,是每個時間戳內所有節點都在生成ID,這時候演算法的效果接近於無序(但仍比UUID的完全無序要好得多,因為workerId只有10位決定了最多隻有1024個節點)。實際生產中,演算法的效果取決於業務流量,併發度越低,演算法越接近理想情況。在理想情況下,主鍵ID最好是按順序遞增的(例如使用
auto_increment
設定主鍵),這樣只有在當前資料頁已滿時才需要建立下一頁,雙向連結串列的增長總是在尾部進行的,不會導致中間節點的分裂。在最糟糕的情況下,主鍵ID是隨機無序生成的(例如在Java中使用UUID字串),這種情況下,新插入的記錄會被隨機分配到任意一個數據頁,如果該頁已滿,則觸發頁分裂。
這也是為什麼不推薦使用UUID作為主鍵ID的原因,UUID會導致頻繁出現頁裂變,影響資料庫效能。
如果主鍵ID由標準版雪花演算法生成,最理想的情況是每個時間戳內只有一個節點生成ID,這種情況下演算法的效果與理想情況的順序遞增相同,即與
auto_increment
沒有區別。最糟糕的情況是每個時間戳內的所有節點都在生成ID,這種情況下演算法的效果接近於無序(但仍比完全無序的UUID要好得多,因為workerId只有10位,限制了節點數量最多為1024個)。在實際生產環境中,演算法的效果取決於業務流量,較低的併發度會使演算法接近理想情況。那麼,Seata改良版的雪花演算法又是如何呢?
Seata 改良演算法會導致頻繁頁裂變嗎?
新版演算法從全域性角度來看,生成的ID是無序的。然而,對於每個節點而言,它所生成的ID序列是嚴格單調遞增的。由於節點ID是有限的,因此最多可以劃分出1024個子序列,每個子序列都是單調遞增的。
對於資料庫而言,在初始階段接收到的ID可能是無序的,來自各個子序列的ID會混合在一起。假設節點ID的值是遞增的,初始階段的效果如下圖所示:

假設此時出現了一個worker1-seq2的ID,由於資料頁已經存滿,會觸發一次頁分裂,如下圖所示:

然而,分裂之後發生了一件有趣的事情。對於worker1而言,後續的seq3、seq4由於可以直接放入資料頁,不會再觸發頁分裂。而seq5只需要像順序遞增一樣,在新建的頁中進行連結。值得注意的是,由於worker1的後續ID都比worker2的ID小,它們不會被分配到worker2及其之後的節點,因此不會導致後續節點的頁分裂。同樣地,由於是單調遞增,它們也不會被分配到worker1當前節點的前面,因此不會導致前面節點的頁分裂。
在這裡,我們稱具有這種性質的子序列達到了穩態,意味著該子序列已經"穩定"下來,其後續增長只會發生在子序列的尾部,而不會引起其他節點的頁分裂。同樣的情況也可以推廣到其他子序列上。無論初始階段資料庫接收到的ID有多麼混亂,在有限次頁分裂之後,雙向連結串列總能達到這樣一個穩定的終態:

到達終態後,後續的ID只會在該ID所屬的子序列上進行順序增長,而不會造成頁分裂。該狀態下的順序增長與
auto_increment
的順序增長的區別是,前者有1024個增長位點(各個子序列的尾部),後者只有尾部一個。小結
綜上所述,改進版的雪花演算法雖然不具備全域性單調遞增的特性,但在同一節點下能夠保持單調遞增。此外,經過幾次資料頁分裂後,它會達到一個穩定狀態,不會頻繁觸發資料庫的頁分裂。同時,該演算法仍然滿足高效能和全域性唯一的要求。因此,完全可以將改進版的雪花演算法引入到專案中使用。
然而,需要注意的是,在實際業務系統中,最好將此演算法應用於那些需要長期儲存資料的場景,而對於需要頻繁刪除的表則不太適用。
這是因為該演算法利用前期的頁分裂,逐漸將不同子序列分離,從而實現演算法的收斂到穩定狀態。如果頻繁刪除資料,會觸發資料庫的頁合併操作,這會阻礙資料的收斂。在極端情況下,剛剛分離的資料可能會立即發生頁合併,導致資料無法保持穩定狀態。因此,在使用改進版的雪花演算法時需要謹慎考慮業務需求和資料操作的頻率。
歡迎加入我的知識星球,全面提升技術能力。
👉 加入方式,“長按”或“掃描”下方二維碼噢:

星球的內容包括:專案實戰、面試招聘、原始碼解析、學習路線。





文章有幫助的話,在看,轉發吧。
謝謝支援喲 (*^__^*)