【從一台 Server 到分散式架構】第 15 篇:Database Sharding——當一顆 DB 不夠用
上一篇(第 14 篇),小明他們面對持續成長的讀取壓力,把單一從庫擴充成了多個 Read Replica,讓「查詢」的流量分散到多台機器上承擔。這個方案奏效了——首頁、課程列表、學習進度的查詢速度都回到了正常水位。
但三個月過去,DBA 小陳又來敲門了。
這次不是讀取變慢,而是兩個更根本的問題:
「主庫的磁碟快滿了。 我們現在有三億筆用戶行為紀錄、兩千萬筆訂單、五千萬個課程章節進度,算一算快到主庫的上限了。」
「而且,寫入開始變慢了。 讀的壓力是靠多個從庫分散掉,但寫入只能打進這一台主庫——現在每秒大概一千五百筆寫入,再多下去,主庫就要吃不消了。」
讀寫分離解決了讀的擴展問題,但它解決不了寫的擴展問題,也解決不了資料量超過單機容量的問題。這兩個問題,都指向同一個解法:Database Sharding(資料庫分片)。
🏗️ 本篇開始前的架構 — 一主多從,讀壓力分散了,但寫入和資料量仍集中在唯一的主庫
讀寫分離已經做了,有多個 Read Replica 分擔讀取,但所有寫入請求還是只能打進同一台 Primary DB。
Sharding 是什麼?
Sharding(分片) 的核心概念很直接:把一張大資料表,依照某個規則切成幾份,分散存到不同的資料庫伺服器上。每一份叫做一個 Shard(分片)。
以訂單表為例:假設有一億筆訂單,沒有 Sharding 就是全部塞在同一台機器上。Sharding 後,可能把訂單切成四份,每份大約兩千五百萬筆,分散到四台機器:
- Shard 1:存
user_id尾數為 0-24 的訂單 - Shard 2:存
user_id尾數為 25-49 的訂單 - Shard 3:存
user_id尾數為 50-74 的訂單 - Shard 4:存
user_id尾數為 75-99 的訂單
這樣,寫入可以分散到四台機器,每台只需要處理四分之一的寫入量;資料量也被切成四份,每台只存四分之一,磁碟壓力大幅下降。
生活化的比喻:圖書館的分館系統
想像一家超大型的圖書館。起初,所有的書放在同一棟建築裡,一個館員管所有的書。隨著藏書量增加到幾百萬本,這一棟建築根本放不下。
圖書館決定開分館:
- 北館:存 A 到 G 開頭的書
- 西館:存 H 到 N 開頭的書
- 南館:存 O 到 T 開頭的書
- 東館:存 U 到 Z 開頭的書
這個「書名首字母」就是分館的依據,在系統裡叫做 Shard Key(分片鍵)。
讀者找書時,先看書名開頭是哪個字母,就去對應的分館。整個圖書館系統的吞吐量,現在是四個分館的加總,而不是一棟建築的上限。
最關鍵的決策:Shard Key 要選哪個?
Shard Key 是 Sharding 最重要的設計決策,選錯了,整個 Sharding 可能幫倒忙。
選擇標準
一個好的 Shard Key,需要同時滿足:
- 均勻分布(Cardinality):資料要能盡量平均地分散到各個 Shard,不能全部集中到某一個 Shard(稱為「熱點 Shard」)。
- 查詢對齊(Query Alignment):最常見的查詢條件最好能直接定位到單一 Shard,不需要跨多個 Shard 查詢。
- 穩定不變(Immutability):Shard Key 的值不應該會改變,因為改變就代表資料要跨 Shard 搬移,非常麻煩。
小明的三個候選
小明他們對幾個欄位進行了評估:
候選一:user_id (用戶 ID)
按 user_id 分片,同一個用戶的所有訂單、行為紀錄都在同一個 Shard 上。
- ✅ 「查某個用戶的所有訂單」這個最常見的查詢,只需要打一個 Shard。
- ✅
user_id不會改變,穩定。 - ⚠️ 如果有少數「超級活躍用戶」(企業帳號、大 V 老師),他們的資料量遠超普通用戶,可能造成某個 Shard 特別大。
候選二:course_id (課程 ID)
按 course_id 分片,同一門課程的所有訂單、評論都在同一個 Shard。
- ✅ 「查某門課的所有學員」這個分析需求,只打一個 Shard。
- ❌ 熱門課程(如某明星講師的課)會產生嚴重的熱點 Shard,造成分布極不均勻。
候選三:order_id 的 Hash 值
對 order_id 做 Hash,根據 Hash 值決定落在哪個 Shard。
- ✅ 分布最均勻,幾乎不會有熱點。
- ❌ 「查某個用戶的所有訂單」就要掃描所有 Shard,因為訂單分散在各處。
小明的決定:
以訂單表為主,選 user_id 作為 Shard Key。原因是用戶查自己的訂單是最高頻的查詢,把同一用戶的資料放在同一個 Shard,能讓這個查詢不需要跨 Shard。超級活躍用戶的問題可以用其他方式應對(例如把他們單獨放到一個特殊 Shard),但跨 Shard 查詢的問題更難解。
Sharding 帶來的新麻煩
Sharding 不是免費的午餐。它解決了容量和寫入擴展的問題,但同時帶來了幾個在單機 DB 時代不存在的難題:
1. 跨 Shard 查詢(Cross-Shard Query)
在單機 DB 裡,你可以輕鬆寫出:
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id
ORDER BY order_count DESC
LIMIT 10;這個「查前十名購課最多的用戶」的 SQL,在 Sharding 之後會變得非常麻煩——資料散在四個 Shard,你必須在應用程式層把四個 Shard 的結果分別查出來,再手動合併、排序。
這種查詢的代價,是單機 DB 的好幾倍。Sharding 後的系統,要盡量避免設計需要跨 Shard 的查詢。
2. 跨 Shard 的 Transaction
在單機 DB,你可以用 Transaction 保證「下訂單 + 扣庫存」要嘛一起成功、要嘛一起失敗。
Sharding 後,如果訂單在 Shard 1、課程庫存在 Shard 3,這個跨 Shard 的 Transaction 需要分散式事務協議(如兩階段提交,2PC)來保證一致性,複雜度和效能代價都大幅上升。
工程上的常見應對是:設計資料模型時,盡量讓相關的資料落在同一個 Shard 上,減少跨 Shard Transaction 的發生頻率。
3. 熱點 Shard(Hotspot)
如果選了一個分布不均的 Shard Key,某個 Shard 可能承擔了遠超其他 Shard 的流量和資料量——這個 Shard 就成了「熱點」,效能問題和原來單機時代一樣嚴重。
比如按月份分片:特定月份(例如雙 11 所在的十一月)的訂單量遠多於其他月份,造成嚴重不均勻。
4. Resharding(重新分片)
一開始切四個 Shard,用了兩年又快不夠用了,需要擴充到八個 Shard。這個過程叫 Resharding:
- 需要把現有的資料重新計算每筆應該落到哪個新 Shard
- 資料搬移過程中,服務可能需要停機或降級
- 非常耗時且風險高
一個設計良好的 Sharding 方案,會提前想好 Resharding 的策略,例如使用一致性 Hash(Consistent Hashing),讓新增 Shard 時只需要搬移少量資料,而不是全部重算。
實際導入的兩大難題:服務如何不停機轉移?資料有關聯怎麼辦?
小明聽完 Sharding 的概念,覺得方向很對,但身為工程師的直覺讓他冒出了兩個更棘手的問題:
- 不停機轉移 (Zero-Downtime Migration):「我們不可能為了 Sharding 把服務停機一整天。要怎麼在服務照常運作的情況下,從單一資料庫轉移到多分片的架構?」
- 跨表關聯 (Handling Relationships):「我們的資料庫充滿了
FOREIGN KEY和JOIN。orders表關聯到users,course_progress也關聯到users和courses。如果資料切散了,這些關聯要怎麼維持?」
這兩個問題非常實際,是 Sharding 能否成功落地的關鍵。
難題一:如何優雅地完成資料庫的「乾坤大挪移」?
不停機轉移的核心思想是:漸進式、可回滾。整個過程不會一步到位,而是像在高速公路上邊開車邊換輪胎,需要精密的步驟。業界常用的策略大致如下:
階段一:雙寫 (Dual Writes)
應用程式的程式碼被修改,寫入請求會同時寫入舊的單體資料庫和新的 Shard 叢集。讀取請求則仍然只從舊資料庫讀取。
- 目的:開始在新叢集裡累積新資料,確保沒有新資料遺失。
- 狀態:舊資料庫仍然是唯一的「真相來源 (Source of Truth)」。新叢集正在「跟上」進度。
階段二:歷史資料遷移 (Backfilling)
執行一個背景批次任務,將舊資料庫裡所有的歷史資料,按照 Shard Key 的規則,複製到對應的 Shard 中。
- 目的:將過去的所有資料搬移到新家。這個過程可能很長,會持續數小時甚至數天。
- 狀態:因為「雙寫」還在進行,所以即使遷移過程很長,新產生的資料也已經在新叢集裡了。
階段三:驗證與切換讀取 (Verify and Switch Reads)
當歷史資料遷移完成後,新舊兩個系統應該有幾乎一樣的資料。
- 驗證:跑腳本比對兩邊的資料,確保一致性。
- 切換讀取:修改應用程式,將讀取請求從舊資料庫,切換到新的 Shard 叢集。可以先切換 10% 的流量,觀察監控指標,沒問題再逐步放大到 100%。
- 目的:驗證新叢集的讀取效能和穩定性。
- 狀態:此時系統是「讀新寫雙邊」。
階段四:切換寫入與清理 (Switch Writes and Cleanup)
在確認讀取完全穩定後,就可以進行最後一步:
- 切換寫入:修改應用程式,讓寫入請求只寫入新的 Shard 叢集。
- 清理:觀察一段時間,確認一切正常後,就可以停止對舊資料庫的寫入,並最終將其下線。
這個過程雖然複雜,但每一步都可以監控、驗證,且大部分步驟出問題時都可以快速回滾(例如把讀取流量切回舊 DB),是相對安全的作法。
難題二:被切開的 JOIN 和 FOREIGN KEY
這個問題的答案,取決於你的 Shard Key 選擇。
情況一:關聯資料被放在同一個 Shard (Co-location)
這是最理想的情況。以 user_id 為 Shard Key,那麼 users、orders、user_profiles 這些跟用戶強相關的表,都可以用 user_id 來分片。結果就是,同一個用戶的所有資料,都會落在同一個 Shard 裡。
在這種情況下,JOIN 和 FOREIGN KEY 在單一 Shard 內部是完全可以運作的!就像一個獨立的小型資料庫一樣。
情況二:關聯資料分散在不同 Shard
如果 orders 按 user_id 分片,但 courses 表因為某些原因沒有分片或是按 course_id 分片,這時要查詢「某用戶買的所有課程的詳細資訊」,就會跨 Shard。
在 Sharding 架構下,資料庫層級的 JOIN 和 FOREIGN KEY 是無法跨 Shard 運作的。你必須在其他地方處理這種關聯:
-
應用層 JOIN (Application-Side Joins):在你的後端服務裡,分別向不同的 Shard 發起查詢,然後在程式碼裡將結果組合起來。例如,先去 Shard 1 查到某用戶的所有
order,拿到course_id列表後,再去courses所在的 Shard 查詢課程資訊。這會增加程式的複雜度和延遲。 -
資料冗餘/去正規化 (Denormalization):一個很常見的取捨是「用空間換時間」。例如,在
orders表裡,除了存course_id,也把course_name、teacher_name這些常用到的欄位也一併存進去。這樣查詢訂單時就不需要再去JOINcourses表了。代價是資料冗餘,以及當課程名稱變更時,需要有機制去同步更新所有相關的訂單紀錄。 -
全域廣播表 (Global Tables):對於那些很少變動、但又需要頻繁被
JOIN的小表(例如:國家列表、課程分類表),可以採用一個策略:在每一個 Shard 都存一份完整的複本。這樣在任何一個 Shard 內部,都可以直接跟這張小表進行JOIN。代價是需要維護多份複本的一致性。
總結來說,Sharding 之後,你需要更刻意地去設計你的資料模型,盡量讓強關聯的資料落在同一個 Shard。對於無法避免的跨 Shard 關聯,就要有意識地在應用層或透過資料冗餘來解決。
分片策略的兩大流派
範圍分片(Range-Based Sharding)
按照某個範圍來分:
user_id1 ~ 1,000,000 → Shard 1user_id1,000,001 ~ 2,000,000 → Shard 2- 以此類推
優點:範圍查詢(例如找出「2025 年底前加入的用戶」)可以只打少數幾個 Shard。 缺點:如果新用戶大量湧入,最新的那個 Shard 會持續被寫入,而舊的 Shard 幾乎沒有新寫入——這叫「寫入熱點」。
Hash 分片(Hash-Based Sharding)
對 Shard Key 做 Hash,再對 Shard 數量取餘數來決定落在哪:
shard_index = hash(user_id) % shard_count
優點:分布非常均勻,幾乎不會有熱點。 缺點:範圍查詢需要打所有 Shard;Resharding 時幾乎所有資料都要重算(可用一致性 Hash 緩解)。
小明他們最終選用了 Hash 分片 + 一致性 Hash,用均勻分布換取未來 Resharding 時的低代價。
什麼時候才需要 Sharding?
這是最重要的一個問題。因為 Sharding 帶來的複雜度是真實存在的,不應該過早引入。
在考慮 Sharding 之前,先確認這些事情都做了:
| 先做的事 | 解決的問題 |
|---|---|
| 索引優化(第 04 篇) | 查詢太慢 |
| Redis 快取(第 05 篇) | 讀取熱點、重複查詢 |
| 讀寫分離 + 多從庫(第 06、14 篇) | 讀取量太大 |
| 升級硬體(垂直擴充) | 單機資源不足(最快的短期解法) |
真正需要 Sharding 的信號:
- 單一 DB 的磁碟容量已達上限,且無法繼續垂直擴充
- 寫入量已超過單機能承受的上限(即使有多個 Read Replica,寫入瓶頸還是在唯一的 Primary)
- 查詢即使有索引和快取仍無法滿足需求
一個粗略的參考:單一 MySQL 在現代硬體上,大約在幾 TB 資料量、每秒數千筆寫入時開始明顯感受到壓力。如果還沒到這個規模,先把前面的方案做好,Sharding 是「不得不」才用的最後手段。
🏗️ 本篇結束後的架構 — 訂單與用戶資料按
user_idHash 切成四個 Shard,每個 Shard 仍有自己的主從複製
注意:每個 Shard 仍然有自己的 Primary + Replica,Sharding 和讀寫分離不是二選一,它們是疊加使用的。
小結與預告
這篇我們學了 Sharding 的核心概念:
- 為什麼需要 Sharding:讀寫分離解決讀的擴展,Sharding 解決寫入上限和資料量超過單機容量的問題。
- Shard Key 選擇:均勻分布、查詢對齊、穩定不變,三個標準缺一不可。
- 兩大分片策略:範圍分片(Range-Based)適合範圍查詢;Hash 分片分布更均勻但範圍查詢代價高。
- Sharding 的新麻煩:跨 Shard 查詢、跨 Shard Transaction、熱點 Shard、Resharding 都是真實存在的代價。
- 何時才需要 Sharding:是最後手段,先把快取、讀寫分離、索引優化都做到位再說。
現在,資料庫的容量和寫入問題暫時解了。但小明的課程平台在成長的過程中,也開始遇到新的需求:除了結構化的訂單和用戶資料,還有全文搜尋、課程評分排行、每分鐘的學習心跳紀錄……這些資料的特性差別很大,同一種資料庫真的能一路承擔所有需求嗎?
下一篇,我們來談帳本不只一種——關聯式、文件型、搜尋引擎、時序資料庫,不同的資料特性,應該對應不同的儲存選型。