面試官:說說你們分庫分表后,主鍵是怎么生成的?
在分布式系統的架構設計中,分庫分表是一個無法回避的話題。當數據量達到一定規模,單庫單表已經無法承載業務壓力時,我們就需要考慮將數據分散到多個數據庫和多個表中。然而,這種分散帶來了一個新的挑戰:如何在分庫分表的場景下生成全局唯一且有序的主鍵。
這個問題看似簡單,實際上卻涉及數據庫底層原理、分布式系統設計、并發控制等多個技術領域。這篇文章就深入探討分庫分表場景下的主鍵生成策略,從最基礎的UUID方案,到經典的雪花算法,再到更具創新性的主鍵內嵌分庫分表鍵方案,為你提供一套完整的解決方案。
1. 分庫分表為什么不能用自增主鍵
在深入討論主鍵生成策略之前,我們需要先理解分庫分表的基本概念。嚴格來說,分庫分表包含了三個層面的拆分:分數據源、分庫和分表。在實際生產環境中,這三者往往會組合使用。
舉個例子,某電商平臺的訂單表采用了分庫分表策略,使用了6個主從集群,每個集群包含5個數據庫,每個數據庫又拆分為64張表,整體規模達到了6乘以5乘以64的拆分粒度。當然,根據實際的數據規模和讀寫壓力,也可以采用更簡單的拆分方式,比如共享一個主從集群,只進行分庫或者只進行分表。
1
當系統采用分庫分表后,傳統數據庫的自增主鍵就會遇到問題。在單庫單表的場景下,我們可以直接使用數據庫的自增主鍵功能,比如在MySQL中創建表時指定AUTO_INCREMENT屬性。
-- 訂單表建表語句,使用自增主鍵
CREATE TABLE order_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 自增主鍵
buyer_id BIGINT NOT NULL, -- 買家ID
order_amount DECIMAL(10,2) -- 訂單金額
)然而在分庫分表的場景下,這種自增主鍵機制就會失效。假設我們按照買家ID對2取模的方式將訂單表拆分為兩張表,分別是order_info_0和order_info_1。如果這兩張表都依賴各自的自增機制生成主鍵,那么兩張表可能會生成相同的ID。比如兩張表各自插入第一條記錄時,生成的ID都是1,這就導致了主鍵沖突。
2
因此,在分庫分表場景下,我們需要設計一個能夠生成全局唯一ID的機制。這個機制需要滿足兩個核心要求:首先,生成的ID必須是全局唯一的,不能出現重復;其次,理想情況下ID應該保持遞增特性,因為遞增與否會顯著影響數據庫的插入性能。此外,由于采用分庫分表的系統通常數據量巨大,意味著并發量也很高,所以主鍵生成方案還需要考慮如何支持高并發場景。
2. 面試引導
在實際的技術面試中,如何將話題引導到主鍵生成這個技術點上,也是一門學問。如果你在簡歷中提到了分庫分表的項目經驗,面試官很可能會主動詢問主鍵是如何生成的。如果在面試過程中被問及數據庫自增主鍵的相關問題,你可以主動提及自增主鍵在分庫分表場景下的局限性,這樣面試官自然會追問分庫分表場景下的主鍵生成方案。
當面試官問到這些問題時,如何抓住機會展現自己的技術深度呢?這需要我們提前做好充分的準備。具體來說,需要深入理解市面上常見的主鍵生成策略,準備一個有亮點的、具備微創新性的主鍵生成方案,同時還要記住一些可行的優化思路。接下來,我們將逐一分析這些內容。
3. 常見主鍵生成策略
當面試官詢問分庫分表中如何解決主鍵問題,或者如何設計一個發號器時,他們通常期望你能夠回答出幾種常見的主鍵生成思路,主要包括UUID、數據庫自增和雪花算法。下面我們逐一深入分析這些方案。
3.1 基礎方案:UUID
UUID是最直接的主鍵生成方案,也是面試中必須能夠回答出來的基礎策略。雖然UUID實現簡單,但如果我們想在面試中脫穎而出,就需要深入分析UUID的弊端。
UUID主要有兩個明顯的缺陷。第一個是長度問題,UUID通常占用36個字符,存儲空間較大,不過在實際采用UUID的場景中,這個缺點通常不是主要考慮因素。第二個缺陷更為關鍵,那就是UUID不是遞增的,這個弊端是面試時需要重點闡述的內容。
3.1.1 頁分裂
要講清楚UUID不是遞增的弊端,我們需要先理解為什么數據庫傾向于使用自增主鍵。這里的關鍵詞是頁分裂。
3
數據庫的B+樹索引結構中,數據按照主鍵大小有序存儲在葉子節點上。當我們需要插入一條新記錄時,如果這條記錄的主鍵值恰好位于某個已滿的葉子節點中間,就會觸發頁分裂操作。比如圖中所示,當嘗試在23之后插入25時,由于葉子節點已經放滿,數據庫不得不將這個節點分裂成兩個節點,分別存儲(20,21)和(22,23,25)。更嚴重的是,這種分裂可能會引發連鎖反應,從葉子節點一直向上分裂到根節點,導致整個樹結構都需要調整。
因此,UUID最大的缺陷在于它產生的ID不是遞增的。我們傾向于在數據庫中使用自增主鍵,是因為自增主鍵可以迫使數據庫的B+樹朝著一個方向增長,新數據總是追加到樹的末尾,避免了中間節點的分裂,從而獲得最佳的插入性能。而UUID生成的ID在整體上可以看作是隨機的,這會導致數據頻繁地插入到頁的中間位置,引起更加頻繁的頁分裂操作。在極端情況下,這種分裂可能引發連鎖反應,整棵B+樹的結構都會受到影響,嚴重影響插入性能。
3.1.2 順序讀
除了頁分裂的問題,我們還可以從另一個角度解釋為什么要使用自增主鍵,這個角度就是順序讀。
4
自增主鍵還有一個重要優勢,就是數據會有更大的概率按照主鍵大小有序存儲,兩條主鍵相近的記錄在磁盤上的物理位置也是相近的。在進行范圍查詢時,我們能夠更加充分地利用磁盤的順序讀特性,大幅提升查詢性能。相比之下,使用UUID作為主鍵時,由于ID是隨機的,相近邏輯的數據在物理存儲上可能相距很遠,無法利用順序讀的優勢。
3.1.3 數據的物理存儲
如果你希望在面試官面前展現更深入的數據庫知識,可以進一步解釋數據庫頁分裂的具體機制,這里可以用MySQL的InnoDB引擎來舉例說明。
InnoDB引擎中,每個數據頁按照主鍵大小有序存儲數據行。假設現在有一個數據頁存儲了主鍵為15、16、17、19、20、21的六行數據,并且這一頁已經放滿了。此時需要插入一條主鍵為18的記錄,InnoDB引擎會發現當前頁已經無法容納這條新記錄,于是不得不將原本的頁分裂成兩頁,比如將15、16、17放到一頁,19、20、21放到另一頁,然后將18插入到第一頁中。
這種頁分裂會造成一個嚴重問題:雖然從邏輯上看,存儲15、16、17的頁和存儲19、20、21的頁是相鄰的兩個頁,但在磁盤的物理存儲上,它們可能相距很遠。這會導致后續的范圍查詢需要頻繁地進行磁盤尋道,嚴重影響查詢性能。
5
3.2 數據庫步長自增方案
除了UUID方案,還有一種常見的方案也叫做自增,不過這種自增比較特殊,它是設置了步長的自增。
6
我們可以通過一個具體例子來說明這種方案。假設經過分庫分表后,我們有16張表,那么可以讓每張表按照不同的步長來生成自增ID。比如第一張表生成1、17、33、49這樣的ID序列,第二張表生成2、18、34、50這樣的ID序列,以此類推,每張表的起始值不同,但步長都是16。
這種方案的最大優勢在于實現簡單,應用層基本不需要做任何額外工作,只需要在創建表時指定好不同的起始值和步長即可。雖然生成的ID并不是嚴格全局遞增的,但在單張表內部,ID肯定是遞增的,這在一定程度上保證了插入性能。這個方案的性能主要取決于數據庫本身的性能,應用層無需過多關注。
3.3 雪花算法
除了UUID和數據庫自增,雪花算法是分布式場景下最經典的主鍵生成方案。需要注意的是,在當前的技術面試環境中,僅僅答出雪花算法可能已經不夠突出,我們需要在理解雪花算法的基礎上,找到更多的亮點。
雪花算法的核心思想并不復雜,關鍵在于分段設計。

雪花算法采用64位來表示一個ID,其中1位保留未使用,41位表示時間戳,10位作為機器ID,12位作為序列號。這種設計保證了ID的唯一性:時間戳是遞增的,不同時刻產生的ID肯定不同;機器ID是不同的,同一時刻不同機器產生的ID肯定不同;同一時刻同一機器上,可以通過序列號來區分不同的ID。
基本解釋清楚之后,我們可以從多個方向來展現技術深度,你可以根據自己掌握知識的程度來選擇合適的方向。
3.3.1 亮點一:靈活調整分段設計
第一個方向是深入討論每個字段的含義和長度,關鍵點是根據實際需求自定義各個字段的含義和長度。
8
大多數情況下,如果自己設計類似的算法,每個字段的含義和長度都是可以靈活控制的。比如時間戳的41位可以調整得更短或更長,39位也能表示十幾年,對于大多數業務場景來說已經足夠。機器ID雖然名稱上是機器ID,但實際上指的是算法實例,而不是物理機器。比如一臺物理機器可以部署多個進程,每個進程的機器ID是不同的;或者進一步細分,機器ID的前半部分表示物理機器,后半部分可以表示該機器上用于產生ID的進程、線程或協程。甚至機器ID也可以不表示機器,而是引入特定的業務含義。序列號的長度同樣可以根據實際并發需求進行調整。
總結來說,雪花算法可以看作是一種設計思想,借助時間戳和分段機制,我們可以自由切割ID的不同比特位,賦予其不同的含義,靈活設計符合自己業務場景的ID生成算法。
3.3.2 亮點二:序列號耗盡的處理策略
無論怎么設計雪花算法,序列號長度都有可能不夠用。比如標準的12位序列號,在并發量極高的場景下,有可能在某個特定時刻,同一臺機器上的序列號全部用完。
10
顯然,理論上確實存在這種可能性,所以我們需要準備解決方案。解決思路其實并不復雜。如果12位不夠用,可以增加序列號的位數,這部分位數可以從時間戳中拿出來。如果還不夠,可以讓業務方等待到下一個時間戳,時間戳變化后自然又可以生成新的ID了,這實際上是一種變相的限流機制。
一般來說,可以考慮加長序列號的長度,比如縮減時間戳的位數,將節省出來的位數分配給序列號。當然也可以更直接地將64位的ID擴展為128位,甚至更多,這樣序列號就可以有三四十位,即便是超大規模的系統也不可能用完。不過,徹底的兜底方案還是要有的。我們可以考慮引入類似限流的做法,在當前時刻的ID已經耗盡之后,讓業務方等待下一個時間戳。由于時間戳通常是毫秒級的,業務方最多只需要等待一毫秒。
image
這里面試官可能會繼續追問,讓業務方等待會有什么問題?
確實讓業務方等待確實是一個非常不妥的方案,因為這可能導致大量業務線程或協程阻塞,導致線程池或協程池耗盡。不過如果是偶發性的序列號不夠,問題不大,因為阻塞的業務方很快就能拿到ID。如果序列號耗盡不是偶發性的,而是長期存在的問題,那么就需要考慮從業務角度進行切割,不同業務使用不同的ID生成器,不要共享。或者,最終還是采用96位或128位的ID,一勞永逸地解決問題。
3.3.3 亮點三:數據堆積問題的解決
假設有這樣一個場景:你的分庫分表策略是按照ID對64取模來進行的,如果業務非常低頻,以至于每個時刻都只生成了尾號為7的ID,那么是不是所有數據都會分到同一張表中呢?
11
確實會出現這種情況,不過解決方案也很簡單。第一種方案是在每個時刻使用隨機數作為序列號的起點,而不是每次都從0開始計數。第二種方案是使用上一個時刻的序列號作為起點,比如上一個時刻的序列號只增長到5,那么下一個時刻的序列號就從6開始。如果上一個時刻的序列號已經很大了,就可以退化為從0開始。
看起來第一種方案比較合理常規,但是相比之下第二種實際上更加可控,性能也更好。
因為在低頻場景下,很容易出現序列號幾乎沒有增長的情況,從而導致數據在經過分庫分表后只落到某一張表中。為了解決這個問題,可以讓序列號部分不再從0開始增長,而是從一個隨機數開始增長。還有一個策略是序列號從上一時刻的序列號開始增長,但如果上一時刻序列號已經很大了,就可以退化為從0開始增長。這樣比隨機數更可控,性能也更好。
3.4 進階方案:主鍵內嵌分庫分表鍵
到這里,其實前面的回答已經頗具亮點,已經可以讓面試官對你刮目相看了。接下來,我們還可以更進一步,讓面試官加深印象,直接把對技術深度的追求做到極致。這里秀才直接給出一個更具創新性的方案:主鍵內嵌分庫分表鍵。
12
其實分庫分表之后最麻煩的就是分庫分表的鍵和主鍵并不是同一個。比如在C端訂單的分庫分表中,我們可以采用買家ID來進行分庫分表。但在一些業務場景中,比如查看訂單詳情,可能是根據主鍵或者訂單編號來查找的。
這里我們可以考慮借鑒雪花算法的設計思想,將主鍵生成策略和分庫分表鍵結合在一起,也就是說在主鍵內部嵌入分庫分表鍵。例如,我們可以這樣設計訂單ID的生成策略,假設分庫分表使用的是買家ID的后六位。第一段依舊采用時間戳,第二段換成買家ID的后六位,第三段采用隨機數。
在一般情況下,我們都是用買家ID來查詢對應的訂單信息。但在其他場景下,比如我們只有一個訂單ID,這時候可以取出訂單ID中嵌入的買家ID后六位,來判斷數據存儲在哪個庫、哪個表。類似的設計還有答題記錄按照答題者ID來分庫分表,但答題記錄ID本身可以嵌入這個答題者ID中用于分庫分表的部分。
這一類解決方案的核心思想是不拘泥于雪花算法每一段的固定含義。比如第二段可以使用具備業務含義的ID,第三段可以自增,也可以隨機。只要我們最終能夠保證ID生成大體上是全局遞增的,并且是獨一無二的就可以。
3.4.1 ID遞增性
假如面試官進一步追問:你這個方案能夠保證主鍵嚴格遞增嗎?
這個確實是保證不了的,但它能夠做到大體上是遞增的,這樣效果其實并不怎么影響。比如,同一時刻如果有兩個用戶來創建訂單,其中用戶ID為876543的先創建,用戶ID為123456的后創建,那么很顯然用戶ID為123456的會產生一個比用戶ID為876543更小的訂單ID。又或者同一時刻一個買家創建了兩個訂單,但第三段是隨機數,第一次隨機到567,第二次隨機到234,那么顯然第一次產生的ID會更大。
但是這并不妨礙我們認為,隨著時間推移,后一時刻產生的ID肯定要比前一時刻產生的ID要大。這樣一來,雖然性能比不上完全嚴格遞增的主鍵,但比完全隨機的主鍵要好得多。
3.4.2 ID唯一性
如果面試官進一步追問這個方案能不能保證ID唯一,又該怎么回答呢?
同樣,這個方案也不能保證00%的絕對唯一。其根本原因就在于ID的第三部分我們引入了隨機數。既然是隨機,理論上就存在兩次生成同樣數字的可能。不過,我們要知道,這種情況在現實中發生的概率是微乎其微的。
一個沖突ID的產生,需要同時滿足幾個的條件:必須是同一個用戶,在同一毫秒內,發起了兩次訂單創建,并且這兩次請求生成的隨機數部分還必須完全一致。
我們可以從兩個角度來分析這個概率:
- 從業務角度看:一個正常用戶在同一毫秒內手動下兩個訂單,這在操作上幾乎是不可能的。如果說是惡意攻擊者,那他們的訂單失敗了也無所謂。
- 從數學角度看:即便我們考慮共享賬號等特殊情況,真的有用戶在同一毫秒發起了兩個請求,那也要看隨機數是否會碰撞。假設我們的隨機數范圍是0到10萬,那么兩次都抽到同一個數字的概率也只有十萬分之一。
所以,這是一個概率極低、但理論上存在的問題。關鍵在于我們如何應對它。這個解決方案非常成熟,核心就是重新生成主鍵。
具體操作是,我們依賴數據庫的主鍵唯一性約束。當我們的程序插入數據時,如果數據庫返回了主鍵沖突的錯誤,說明這個極小概率事件真的發生了。此時,我們的代碼邏輯會捕獲這個特定錯誤,立刻重新生成一個新的ID,然后再次嘗試插入。這個過程對用戶是完全透明的。
3.5 發號性能優化
在掌握了前面幾種主鍵生成策略之后,如果希望進一步提升系統在極端并發下的穩定性,可以將思考范圍從“如何設計一個 ID”擴展到“如何讓 ID 服務在大規模場景中持續高效運行”。在實際生產環境里,一個發號體系往往不僅依賴于算法本身,還需要在獲取流程上做工程化優化,以抵御瞬時流量沖擊、降低網絡往返次數以及減少數據庫更新壓力。
這一類優化并不改變 ID 的結構,但能夠顯著提升服務的整體吞吐能力,通常會從以下幾個方面協同組合。
首先,將 ID 的申請粒度從單個擴展為一段,使得調用方一次拿到一整批可用區間,在本地按需消耗。這樣可以將原本的高頻訪問壓縮成低頻事件,一個批次可能抵消數百到上千次真實請求,從根本上減輕發號服務的負載。
其次是將取號操作前置化。業務端在真正需要之前就準備好一定數量的可用 ID,當本地余量下降到閾值時,異步補齊下一段。這樣在絕大多數情況下,業務線程無需等待發號器響應,延遲基本可視為零。只有在緩沖耗盡且新段尚未到達的極端情況,業務才會短暫阻塞。
此外,為減少同一時刻大量線程同時訪問發號器帶來的資源浪費,可以在客戶端加入類似 singleflight 的協作邏輯:某一時間窗口內如果出現多個取號需求,僅由一個線程代表整個進程發起請求,其他線程等待即可。進一步的優化是讓代表線程在獲取時“多取一點”,在未來的短時間窗口內直接滿足后續線程的請求。
在此之上,為了避免進程級緩存的鎖競爭,還可以為各個線程建立更細粒度的局部緩存區。當線程從全局段中領取一個較小的片段存放在自己的本地緩沖后,其后續的 ID 消耗都不再涉及進程級同步結構,從而避免爭搶,提高整體并發能力。最終形成遠端服務、進程緩沖、線程緩沖的多級體系,使系統在峰值流量下依然保持平穩。
這一方案的核心思想是通過分層緩沖、協同調度和局部化分發,將原本集中式的獲取過程解耦拆分,使真正與發號器交互的請求數量被壓縮到極低。它并不依賴某一種具體算法,而是一套兼容所有生成策略的工程機制,適用于絕大多數對穩定性、低延遲和高并發具有要求的業務場景。
4. 小結
從單庫單表的自增主鍵到分庫分表場景下的全局ID生成,這不僅是一個技術選型問題,更是對系統架構理解深度的體現。UUID雖簡單但犧牲了性能,數據庫步長自增折中實用,雪花算法經典但需靈活調整,而主鍵內嵌分庫分表鍵的方案則將設計思維推向了極致。面試中,掌握這些方案的原理只是基礎,真正的亮點在于你能否理解B+樹頁分裂的底層機制,能否根據業務場景靈活設計分段策略,以及能否在唯一性、遞增性與實現復雜度之間找到最優平衡點。記住,沒有完美的方案,只有最適合當前業務的方案——這正是分庫分表這個看似簡單的主鍵問題,卻能夠區分出初級與資深工程師的一個重要原因。



































