當單台關係型資料庫的資料量突破數十 TB、寫入 QPS 突破數萬時,單機硬體垂直擴展(Scale-Up)將迅速觸及物理與成本天花板。

此時,系統架構必須走向水平分片——資料庫分片(Database Sharding):將一個邏輯大表拆分為多個物理分片(Shards),分散存儲在不同的資料庫伺服器節點上。

然而,選擇哪種分片演算法(Sharding Algorithm) 是決定整個分散式系統生死存亡的關鍵決策:

  • 選錯演算法可能導致某些節點被熱點流量打爆(資料傾斜);
  • 每次叢集擴容時需要搬遷全量資料;
  • 或者導致跨分片 JOIN 與分散式交易的延遲暴增。

本文先對比 4 大主流分片演算法的底層機制與選型權衡,再往下走到真正決定專案成敗的四件工程落地細節:固定邏輯槽位的再平衡策略、分片鍵與分散式 ID 設計、跨分片交易的補償方案,以及零停機的線上遷移流程。

影片拆解:從一張 User 表走到分庫分表

本文整理自 小白debug〈玩原神學程式設計資料庫分庫是啥?〉。影片把「原神全球同服」當成假想案例,不是米哈遊內部架構披露;以下保留影片的教學順序,再用官方文件補上適用邊界。

影片開場用一個工程師式的失敗笑話:團隊預估全球同服會有巨大玩家量,先把 User 表拆成四張,結果上線最高在線只有 58 人,其中 7 人還是專案成員。笑點不是預估錯,而是提醒「先知道要解決哪個瓶頸,再選分片」。影片接著從 MySQL 的資料頁與 I/O 講起;MySQL 官方文件說明 InnoDB 索引採用 B-tree,索引記錄放在頁面中,預設索引頁大小為 16 KiB,InnoDB physical structure。

垂直分表先處理列寬

假設有 user(id, name, age, equipment, ...)。垂直分表把低頻或較寬的欄位移到另一張表,讓常用欄位的 row 變窄;同一個 16 KiB page 可以放更多 row,查詢只需要載入較少頁面。這個收益只對不需要被移出欄位的查詢成立;若每次都要 JOIN 回去,I/O 不會憑空消失。它解的是「一列太寬、熱查詢攜帶太多資料」,不是把容量分到多台機器。

水平分表再處理列數

水平分表保留相同欄位,把 rows 分到 user_0 ... user_N。影片以每表約 500 萬~2,000 萬列作示例,不是 MySQL 的硬性上限;實際界線要用 row size、索引、工作負載與壓測決定。這也解釋了「分表」與「分庫」的差別:前者仍可在一個資料庫實例內拆表,後者再把不同表群放到不同資料庫與機器。

三種路由取捨

  1. id % N:簡單且通常均勻,但 N 一改就要重算大量 key,擴容牽涉搬遷。
  2. ID range:floor(id / RANGE_SIZE) 可隨資料成長增加新表,卻會讓自增 ID 的最新區間成為寫入熱點。
  3. 先 range、再在區間內取模:保留可成長的邊界,又把單一熱區的寫入拆到多張表;實務上仍需處理路由元資料、擴容與遷移。

非分片鍵查詢為什麼會讀擴散

分片鍵若是 id,WHERE id = ... 能直接決定目的表;但影片的 SELECT * FROM user WHERE name = '小白' 沒有 id,路由層只能對所有實體表發查詢再合併結果。Apache ShardingSphere 的 route engine 也把「帶 shard key」與「不帶 shard key 的 broadcast route」分開描述,Route Engine。

影片提出的折衷是維護一張以 name 為路由鍵的輕量索引表,只保存 name 與原表 primary key。先查索引表拿到 ID 清單,再依 id 查原分片,把無目的的全表扇出縮成只查必要分片;代價是雙表一致性、更新與額外維運。這是全域二級索引的具體化,不是免費優化。

中間層是邊界,不是魔法

把路由放在 ORM/JDBC 函式庫或獨立 Proxy,都能讓業務程式只面對邏輯表。Apache ShardingSphere 的 ShardingSphere-Proxy 以資料庫協定提供透明接入,因此上游語言不必各自內建分片路由;代價是多一個高可用、觀測、連線池與故障切換邊界。MongoDB 官方也提醒:當單機仍能承擔資料與流量時,不要為了「看起來可擴展」先上 sharding,因為它會增加基礎設施與維運複雜度,Sharding FAQ。


1. 四大分片演算法核心架構矩陣

四大資料庫分片演算法的路由機制與權衡對照並排比較範圍分片、雜湊模分片、一致性雜湊與目錄查表分片四種演算法的路由公式、資料分佈特點與代表系統。1. RANGE-BASED範圍分片路由公式key ∈ [min, max][1..1000] ➔ Shard 1[1001..2000] ➔ Shard 2特點• 範圍查詢極佳• 資料均勻度差• 擴容僅分裂單一過大分區代表Bigtable / HBase /CockroachDB2. HASH % N雜湊模分片路由公式crc32(key) % N特點• 資料分佈極均勻• 範圍查詢差• N 變動時高比例資料需重分佈代表早期分庫分表方案3. CONSISTENT HASHING一致性雜湊路由公式Hash 環 0 ~ 2³²-1每節點 100~200 個虛擬節點特點• 擴縮容僅遷移約 1/N 資料• 範圍查詢差代表DynamoDB / Cassandra4. DIRECTORY目錄查表分片路由公式lookup(key) ➔ Shard ID路由表存 etcd / Redis特點• 調度最靈活• 可為 VIP 租戶指定專屬分片• 再平衡只改元數據• 路由表本身是熱點與單點代表Vitess VSchema /自研路由服務四大分片演算法對照簡圖桌面版四欄矩陣的直向堆疊版:範圍分片、雜湊模分片、一致性雜湊、目錄查表分片依序列出路由公式與權衡重點。1. 範圍分片 Range-Based路由公式:key ∈ [min, max]• 範圍查詢極佳、資料均勻度差• 擴容僅分裂單一過大分區• 代表:Bigtable / HBase / CockroachDB2. 雜湊模分片 Hash % N路由公式:crc32(key) % N• 資料分佈極均勻、範圍查詢差• N 變動時高比例資料需重分佈• 代表:早期分庫分表方案3. 一致性雜湊 Consistent Hashing路由公式:Hash 環 0 ~ 2³²-1,每節點 100~200 虛擬節點• 擴縮容僅遷移約 1/N 資料、範圍查詢差• 代表:DynamoDB / Cassandra4. 目錄查表分片 Directory路由公式:lookup(key) ➔ Shard ID(存 etcd / Redis)• 調度最靈活,可為 VIP 租戶指定專屬分片• 再平衡只改元數據;路由表本身是熱點與單點• 代表:Vitess VSchema / 自研路由服務
圖 1:四大資料庫分片演算法的路由機制與權衡對照
分片演算法核心路由公式範圍查詢支援 (Range Queries)資料均勻度 (Data Distribution)節點擴容再平衡成本 (Resharding)
範圍分片 (Range)key in [min, max]極佳 (天然支援連續區間掃描)差 (極易產生寫入熱點與傾斜)較低 (僅需分裂單個過大分區)
雜湊模分片 (Hash % N)crc32(key) % N差 (跨多分片發散查詢)極佳 (均勻散列於各節點)災難級 (N 變動時大量資料需重分佈)
一致性雜湊 (Consistent Hash)虛擬節點 Hash 環尋址差極佳 (透過 100~200 個虛擬節點均勻分攤)極低 (僅相鄰節點受影響,搬遷 1/N 數據)
目錄查表分片 (Directory)查詢中心配置表 lookup(key)取決於目錄配置最靈活 (可手動按租戶/地域分配)最低 (僅需修改元數據路由記錄)

2. 各演算法深度剖析

2.1 範圍分片(Range-Based Sharding)

  • 機制:依據分片鍵的值域區間劃分(例如依時間按月分片:2026-01 存 Shard 1,2026-02 存 Shard 2;或依用戶 ID 範圍 1~1,000,000)。
  • 致命缺陷(熱點寫入):若按自增 ID 或時間戳分片,當前所有最新的寫入請求將 100% 湧入最後一個分片,其餘歷史分片閒置,形成嚴重的寫入瓶頸!
  • 代表系統:Google Bigtable、Apache HBase、CockroachDB(採用 Range Split 避免單點)。

2.2 雜湊模分片(Hash-Modulo Sharding)

  • 機制:ShardIndex = hash(user_id) % NodeCount。
  • 優勢:資料被極其均勻地打散到所有節點上。
  • 致命缺陷:模數 N 就是物理節點數,一旦擴容,絕大多數 key 的歸屬都會改變。從 4 台擴到 5 台時,只有 hash % 20 落在同餘區間的 key 留在原地,約 80% 的歷史資料必須跨節點搬遷;即使是最友善的倍增擴容(4 台變 8 台),因為 hash % 8 對 4 取模恆等於 hash % 4,仍有整整 50% 的資料要搬。第 4 節的固定槽位方案就是為了拆開「模數」與「物理節點數」這層綁定。

2.3 一致性雜湊(Consistent Hashing with Virtual Nodes)

  • 機制:將哈希空間組織為 0 ~ 2³²-1 的閉合圓環,每個物理節點在環上映射 100~200 個虛擬節點(Virtual Nodes)。
  • 優勢:新增或移除節點時,只有相鄰的數據需要遷移,其餘節點完全不受影響。
  • 代表系統:Amazon DynamoDB、Apache Cassandra、Redis Cluster。

2.4 目錄查表分片(Directory-Based / Lookup Sharding)

  • 機制:維護一個中心化的路由表(通常存放在高可用 etcd 或 Redis 中),記錄 Account_ID ➔ Shard_12 的顯式映射。
  • 優勢:支援業務層級的客製化調度(例如將 VIP 企業大客戶分配給專屬獨立的高效能分片)。
  • 代價:路由表本身變成每次查詢都要碰的熱路徑與單點,必須有本地快取與變更推送機制;快取失效視窗內的舊路由會把請求打到錯誤分片,因此路由變更通常要搭配版本號與雙讀。

3. 跨分片查詢(Cross-Shard Queries)與 Scatter-Gather 治理

當查詢條件未攜帶 Sharding Key 時(例如 SELECT * FROM orders WHERE status = 'PAID'):

跨分片 Scatter-Gather 查詢執行路徑說明無 Sharding Key 查詢時,分散式中介軟體向所有分片並行散發查詢,各分片各自回傳結果,最終在協調層記憶體中聚合、排序與分頁的流程與延遲風險。SELECT * FROM orders WHERE status = 'PAID'查詢未攜帶 Sharding Key分散式中介軟體(Vitess / ShardingSphere / DBProxy)向所有分片並行散發查詢(Scatter)Shard 1全量掃描Shard 2全量掃描Shard 3全量掃描Shard 4全量掃描記憶體聚合、排序與分頁(Gather)➔ 回傳客戶端⚠ 延遲取決於最慢分片;LIMIT/OFFSET 需在協調層全量重排跨分片 Scatter-Gather 查詢執行路徑簡圖桌面版的直向排列:查詢 ➔ 中介軟體 ➔ 四個分片並行 ➔ 記憶體聚合並回傳,並附延遲代價警語。SELECT * FROM orders WHERE status = 'PAID'查詢未攜帶 Sharding Key分散式中介軟體(Vitess / ShardingSphere / DBProxy)向所有分片並行散發查詢(Scatter)Shard 1Shard 2Shard 3Shard 4記憶體聚合、排序與分頁(Gather)➔ 回傳客戶端⚠ 延遲取決於最慢分片LIMIT/OFFSET 需在協調層全量重排
圖 2:無分片鍵查詢時的 Scatter-Gather 執行路徑與延遲代價

治理原則

  1. 建立全局二級索引(Global Secondary Index, GSI):非同步將 status ➔ order_id 映射儲存在獨立的索引分片中。
  2. 雙分片維度設計(Dual-Write Sharding):例如買家維度與賣家維度透過 CDC 各自存一份分片庫,杜絕低效的 Scatter-Gather。

4. 虛擬分片(Pre-Sharding):固定邏輯槽位與無痛再平衡

第 2.2 節的問題本質不是雜湊不好,而是把路由模數綁死在物理節點數上。虛擬分片的做法只有一句話:路由算到「邏輯槽位」為止,槽位再由一張小小的元數據表映射到物理節點。

虛擬分片固定邏輯槽位與無痛再平衡路由層固定以 crc32(key) % 1024 算出 slot_id,模數永遠是 1024。擴容前 1024 個槽位平分給 4 台物理節點,擴容後同樣 1024 個槽位改分給 8 台節點,只需搬移一半槽位到新節點,路由公式與模數完全不變。路由層(永不改變)shard_keycrc32(key) % 1024slot_id模數固定為槽位總數 1024,不隨物理節點數變動擴容前:1024 槽位 ➔ 4 台物理節點(每台 256 個 slot)Node Aslot 0–255Node Bslot 256–511Node Cslot 512–767Node Dslot 768–1023只搬移一半槽位到 E/F/G/H,路由公式與模數完全不變擴容後:同樣 1024 槽位 ➔ 8 台物理節點A0–127E128–255B256–383F384–511C512–639G640–767D768–895H896–1023相較 hash % N 由 4 台直接倍增到 8 台需搬遷約 50% 資料(非倍增的擴容如 4 ➔ 5 更高達 80%),槽位方案只搬移計畫內的 slot 區間虛擬分片固定邏輯槽位簡圖桌面版三層直向重排:路由層固定模數 1024 ➔ 擴容前 4 節點均分槽位 ➔ 擴容後 8 節點僅搬移一半槽位,模數不變。路由層(永不改變)shard_key → crc32(key) % 1024→ slot_id模數固定為槽位總數 1024,不隨物理節點數變動擴容前:1024 槽位 → 4 台節點• Node A|slot 0–255• Node B|slot 256–511• Node C|slot 512–767• Node D|slot 768–1023每台承載 256 個 slot擴容後:同樣 1024 槽位 → 8 台節點• A 0–127• E 128–255• B 256–383• F 384–511• C 512–639• G 640–767• D 768–895• H 896–1023只搬移一半槽位到 E/F/G/H,模數不變相較 hash % N 由 4 台直接倍增到 8 台需搬遷約 50% 資料(非倍增擴容如 4→5更高達 80%),槽位方案只搬移計畫內區間
圖 3:固定邏輯槽位(Fixed Slots)讓叢集由 4 台擴容到 8 台時不必變更路由模數

4.1 槽位數要開多少

Redis Cluster 的答案是 16,384 個 slot(CRC16(key) mod 16384)。這個數字不是隨便挑的:叢集節點間的心跳訊息要攜帶自己負責的 slot bitmap,16,384 bits 就是 2 KB,換成 65,536 個 slot 直接膨脹到 8 KB(bitmap 稀疏時壓縮也幫不上忙),而 Redis 官方認為叢集節點數實務上不會超過 1,000 台,16K 已經綽綽有餘。

自建分庫分表時,同一組取捨可以歸納成三條:

  • 槽位數就是物理節點數的上限。開 1,024 個 slot 代表這套架構最多只能擴到 1,024 台;一旦要突破就得重來一次全量遷移,這正是虛擬分片想避免的事。
  • 每台節點至少要拿到數十個 slot,均分才有意義。4 台機器配 1,024 個 slot(每台 256 個)是舒服的起點;4 台配 8 個 slot 則失去彈性。
  • 槽位數建議取 2 的冪,讓 % 1024 能退化成位元遮罩,同時未來倍增擴容剛好對半切區間。

4.2 路由與再平衡

-- 路由元數據:只有槽位數量這麼多列,可以整張常駐應用層記憶體
CREATE TABLE shard_slot_map (
  slot_id     SMALLINT UNSIGNED NOT NULL,  -- 0 ~ 1023
  node_id     VARCHAR(32)       NOT NULL,  -- 目前負責這個槽位的物理節點
  state       ENUM('stable', 'migrating') NOT NULL DEFAULT 'stable',
  target_node VARCHAR(32)       NULL,      -- 搬遷中的目的節點
  version     BIGINT UNSIGNED   NOT NULL,  -- 每次變更遞增,供客戶端快取比對
  PRIMARY KEY (slot_id)
);
# 概念片段:應用層路由。模數永遠是 SLOT_COUNT,與物理節點數無關
SLOT_COUNT = 1024

def route(shard_key: str, slot_map: dict[int, dict]) -> str:
    slot_id = zlib.crc32(shard_key.encode()) % SLOT_COUNT
    slot = slot_map[slot_id]
    # 搬遷中的槽位讀取要能容忍資料在兩邊:先問來源,miss 再問目的節點
    return slot["node_id"] if slot["state"] == "stable" else slot["target_node"]

擴容時要做的事只有:挑出要搬的 slot 區間、把 state 改成 migrating、逐 slot 複製資料、驗證後把 node_id 指向新節點。% 1024 這行程式碼從頭到尾沒有改過,這才是「無痛」的真正來源。


5. 分片鍵與分散式 ID:把路由資訊寫進主鍵的代價

5.1 Snowflake 變體與嵌入 Shard ID

標準 Snowflake 的 64-bit 佈局是:1 bit 保留正負號 + 41 bit 毫秒時間戳(自訂紀元起約可用 69 年)+ 10 bit 機器識別(常拆成 5 bit 資料中心 + 5 bit 工作節點)+ 12 bit 序列號(單機單毫秒 4,096 個 ID)。

常見的變體是把中間那 10 bit 改成 Shard ID 或 Tenant ID,讓「以主鍵查單筆」的請求可以直接從 ID 位元解出目標分片,完全跳過中心化查表。這在讀多寫少、以主鍵為主要存取路徑的系統上省下的是每次查詢一趟網路往返。

另一個容易被忽略的坑是時鐘回撥:NTP 校時把系統時間往回調時,同一毫秒可能重新發出已用過的序列號。生產級實作必須在偵測到 now < last_timestamp 時直接拒絕發號並告警,或保留備用 bit 切換到另一個邏輯時鐘序列,絕不能默默繼續發。

5.2 複合分片鍵

tenant_id:created_at 這類複合鍵同時處理兩個維度:tenant_id 保證同一租戶的資料落在同一分片(跨租戶查詢本來就不該存在),created_at 則讓冷熱資料能按時間階梯歸檔、舊分區直接掛到廉價儲存或整段刪除。

它換來的問題是:租戶大小天生不均。一個佔全站 30% 流量的大客戶會把它所在的那個分片打爆,這正是下一節要處理的事。


6. 跨分片交易:2PC、TCC、Saga 與 Outbox

分庫分表之後,「扣 A 分片的餘額、加 B 分片的餘額」不再是一個本地交易。

跨分片交易的四種方案對決架構圖並列比較 2PC/XA、TCC、Saga 與 Outbox Pattern + CDC 四種跨分片交易方案的執行流程、一致性等級與工程代價。1 · 2PC / XA兩階段提交執行流程1. 協調者發起交易2. Prepare:各分片上鎖3. 各分片投票 Yes4. Commit 全部提交一致性等級強一致(Strict)代價 / Trade-off• 協調者是單點故障• 持鎖時間 = 網路 RTT• 慢分片拖累 Tail Latency• 崩潰時參與者卡 In-Doubt2 · TCCTry-Confirm-Cancel執行流程1. Try:預留資源2. 全部成功 ➔ Confirm3. 任一失敗 ➔ Cancel 釋放一致性等級最終一致(預留期不可見)代價 / Trade-off• 三介面需業務自行實作• 需處理空回滾 (Empty)• 需處理懸掛 (Suspension)• 需自行保證冪等3 · Saga長活事務執行流程正向:T1 ➔ T2 ➔ T3失敗時反向補償補償:C3 ➔ C2 ➔ C1一致性等級最終一致,中間狀態可見代價 / Trade-off• 需設計語意補償動作• 例如退款而非回滾• 無隔離性 (No Isolation)• 業務層需自行防髒讀4 · Outbox + CDCOutbox Pattern執行流程1. 本地交易寫業務表+Outbox2. Debezium 讀取 Binlog3. 發布至 Kafka4. 更新全域索引/讀庫一致性等級最終一致,至少投遞一次代價 / Trade-off• 消費端須實作冪等• 存在複製延遲 (Repl. Lag)• 需維運 CDC 管線高併發線上路徑優先選 Saga / TCC;2PC 留給低頻、強一致的後台作業跨分片交易四種方案簡圖以直向卡片依序呈現 2PC/XA、TCC、Saga、Outbox Pattern + CDC 的流程、一致性等級與代價,為桌面版四欄比較圖的重新編排。1 · 2PC / XA(兩階段提交)流程:協調者➔Prepare上鎖➔全數Yes➔Commit一致性:強一致(Strict)代價:協調者單點,持鎖= RTT拖慢 Tail Latency,崩潰卡 In-Doubt2 · TCC(Try-Confirm-Cancel)流程:Try預留➔全成功Confirm/失敗Cancel一致性:最終一致(預留期不可見)代價:三介面需業務自行實作須處理空回滾、懸掛與冪等3 · Saga(長活事務)流程:T1➔T2➔T3,失敗反向 C3➔C2➔C1 補償一致性:最終一致,中間狀態可見代價:需設計語意補償(退款非回滾)無隔離性,業務層需自行防髒讀4 · Outbox Pattern + CDC流程:本地交易寫業務表+Outbox➔CDC➔更新讀庫一致性:最終一致,至少投遞一次代價:消費端須冪等,有複製延遲需維運 CDC 管線
圖 4:跨分片交易的四種方案 — 一致性強度與工程代價的取捨

6.1 為什麼高併發場景放棄 2PC

XA / 兩階段提交在語意上最乾淨,但代價集中在一個地方:鎖的持有時間從「本地交易時長」變成「網路往返時長」。Prepare 階段各分片就已經上鎖,要一直握到協調者收齊全部投票並廣播 Commit 為止。只要有一個分片 GC 停頓或網路抖動,所有參與者的鎖都跟著延長,Tail Latency 直接被最慢的那個節點決定。更糟的是協調者在 Prepare 之後、Commit 之前崩潰,參與者會卡在 in-doubt 狀態,鎖不釋放也不能自行決定——這時候只能等協調者恢復或人工介入。

結論很直接:2PC 適合低頻、強一致、可以接受高延遲的後台作業(對帳、批次結算),不適合放在線上交易路徑。

6.2 TCC 與 Saga 的分工

TCC(Try-Confirm-Cancel) 用業務層的「預留」取代資料庫層的鎖:Try 階段凍結庫存或凍結額度(資料寫進去但對外不可見),全部成功才 Confirm 轉正,任一失敗就 Cancel 釋放。它比 Saga 多一層隔離性,代價是每個參與方都要實作三個介面,而且必須自己處理三個經典異常——空回滾(Try 還沒到就先收到 Cancel)、懸掛(Cancel 先於 Try 抵達,Try 之後不能再執行)、重複請求的冪等。

Saga 更輕:正向交易一路做下去,失敗時反向執行語意補償。要注意補償不是回滾——退款不是「撤銷扣款」,它是一筆新的、會被對帳系統看見的業務事實。Saga 沒有隔離性,中間狀態對外可見,訂單可能短暫出現「已付款但庫存未扣」,這要靠業務層的狀態機與前端展示規則吸收。

6.3 Outbox Pattern + CDC

前兩者處理的是「多個分片的業務動作」;Outbox 處理的是另一個問題:寫資料庫和發訊息不能放在同一個交易裡。直接在本地交易後面呼叫 kafka.send(),交易提交成功而訊息發送失敗(或反過來)就產生了不一致。

-- 業務表與事件表在同一個分片、同一個本地交易內寫入
BEGIN;
  UPDATE orders SET status = 'PAID' WHERE order_id = 10086;
  INSERT INTO outbox (event_id, aggregate_id, event_type, payload, created_at)
  VALUES (UUID(), 10086, 'OrderPaid', '{"order_id":10086}', NOW(6));
COMMIT;
-- 之後由 Debezium 讀 binlog,把 outbox 的新增列投遞到 Kafka

因為兩張表在同一個分片內,本地交易的原子性就足夠保證「業務改了,事件一定也在」。Debezium 讀取 binlog 把 outbox 的新增列投遞到 Kafka,下游再去更新全域二級索引或異構讀庫。代價是投遞語意為「至少一次」,消費端必須冪等(用 event_id 建去重表),以及要多維運一條 CDC 管線與它的複製延遲。


7. 資料傾斜與熱點鍵(Celebrity Key)防禦

雜湊分片保證的是鍵的數量均勻,不保證每個鍵的流量均勻。一個百萬粉絲的帳號、一場大促銷的活動 ID,都是單一 key 打爆單一分片的典型。

熱點鍵防禦:鹽化與分級路由上半部呈現單一熱點鍵 user_123 把所有寫入集中打到單一分片、其餘分片閒置的問題;下半部左側是鹽化 Salting,把鍵加上隨機後綴後均勻分散到四個分片,換取寫入吞吐放大但需承擔讀取扇出的代價;下半部右側是分級路由 Tiered Routing,依租戶是否為 VIP 決定走共用雜湊分片池或專屬高規格節點。問題:單一熱點鍵打爆單一分片寫入請求user_123Shard 1閒置Shard 2100% 寫入集中CPU 飽和Shard 3閒置Shard 4閒置解法一:鹽化(Salting)user_123 + 隨機後綴#0 ~ #9user_123#7S1S2S3S4寫入吞吐放大 10 倍讀取需併發查 10 個桶再聚合,讀放大是代價解法二:分級路由(Tiered Routing)是否為 VIP 租戶?否是crc32(key) % 1024lookup(tenant_id)共用雜湊分片池(長尾租戶)專屬高規格實體節點(單一大租戶獨佔)熱點鍵防禦簡圖桌面版三個區塊改為直向排列:熱點集中的問題、鹽化打散寫入、分級路由隔離大租戶。問題:熱點集中寫入 user_123• 100% 寫入全打到 Shard 2,CPU 飽和• Shard 1 / 3 / 4 完全閒置• 單一分片成為系統瓶頸與單點• 需要打散鍵或改變路由策略解法一:鹽化打散user_123#7• 鍵加上隨機後綴 #0 ~ #9• 均勻分散到四個分片• 寫入吞吐放大 10 倍• 讀取需併發查 10 桶再聚合,讀放大是代價解法二:分級路由是否為 VIP 租戶?• 否 ➔ crc32(key) % 1024• 進入共用雜湊分片池(長尾租戶)• 是 ➔ lookup(tenant_id)• 導向專屬高規格實體節點• 單一大租戶獨佔資源,隔離長尾干擾• 兩種手段可並用:長尾鹽化、大戶隔離
圖 5:熱點鍵的兩種防禦 — 鹽化打散寫入,分級路由隔離大租戶

7.1 先量測,再加鹽

鹽化(Salting)是在分片鍵後追加隨機後綴,把 user_123 拆成 user_123#0 ~ user_123#9,寫入均勻打散到 10 個桶,讀取時併發查全部 10 個桶再聚合:

# 寫入:隨機挑一個桶,寫入吞吐放大 SALT_BUCKETS 倍
SALT_BUCKETS = 10
write(f"{user_id}#{random.randrange(SALT_BUCKETS)}", event)

# 讀取:扇出查詢所有桶再合併,讀放大也是 SALT_BUCKETS 倍
rows = merge(read(f"{user_id}#{i}") for i in range(SALT_BUCKETS))

這筆交易的方向很明確:用讀放大換寫入吞吐。它適合寫多讀少、且讀取可以接受聚合的場景(計數器、流水、時間線寫入);不適合需要以單鍵做強一致讀或條件更新的場景,因為跨桶已經沒有原子性可言。

真正的前提是要先知道誰是熱點。沒有 proxy 層或中介軟體的 per-key QPS 取樣,加鹽只是憑感覺對某幾個 key 動手;而熱點會隨活動與時間漂移,靜態寫死的鹽化清單很快就會過期。務實的做法是把熱點名單放進可熱更新的配置,由監控觸發而不是寫死在程式碼裡。

7.2 分級路由(Tiered Routing)

另一條路是承認租戶天生不平等,直接分層調度:長尾租戶走 crc32(key) % 1024 進共用雜湊分片池,簽了 SLA 的大型 VIP 租戶走目錄查表導向專屬的高規格實體節點。

這其實是第 1 節「雜湊」與「目錄」兩種演算法的混用,而不是二選一。它的好處不只是效能隔離,還有維運上的:大租戶的備份、升級、故障影響範圍都被關在自己的節點裡,不會波及其他人。代價是路由邏輯多一個分支,而且「什麼時候把一個租戶從共用池升級成專屬節點」變成一個需要有人負責的營運決策。


8. 零停機線上遷移(Live Migration)四步法

不論是換分片演算法、槽位再平衡,還是把大租戶搬去專屬節點,實際執行的流程都是同一套。

零停機分片線上遷移四步法展示 Dual-Write 雙寫、Backfill 歷史回填、Verify 校驗與CDC追平、Cutover切換四個階段,每階段皆標示讀流量、寫流量與回滾方式,最後強調舊庫寫入是最後才關閉的回滾保險。STEP 1Dual-Write 雙寫讀流量• 舊分片寫流量• 舊分片 + 新分片• 同步雙寫(雙邊寫入)• 新庫寫入失敗僅告警• 不阻斷主流程回滾方式• 關閉新庫寫入即可• 舊庫仍是唯一真實來源STEP 2Backfill 歷史回填讀流量• 舊分片(不受影響)寫流量• 離線批次依主鍵掃描• 舊庫資料寫入新庫• 以 updated_at/版本號比對• 新庫版本較舊才覆蓋回滾方式• 清空新庫重跑• 不影響雙寫與線上讀STEP 3Verify 校驗與 CDC 追平讀流量• 舊分片(尚未切換)寫流量/校驗• CDC (Debezium) 追齊• 回填期間的增量落差• 全量 checksum 比對• 抽樣 diff,設差異率門檻回滾方式• 修正後重跑校驗• 尚未影響線上讀STEP 4Cutover 切換讀流量• 灰度切至新分片• 1% → 10% → 100%寫流量• 觀察期通過後• 停用舊庫寫入• 雙寫降級為單寫回滾方式• 讀流量切回舊分片• 雙寫未關閉前皆可秒回舊庫的寫入是最後才關的,它是整段遷移唯一的回滾保險零停機分片線上遷移四步法簡圖桌面版四階段時間軸的直向排列:Dual-Write 雙寫 ➔ Backfill 歷史回填 ➔ Verify 校驗與 CDC 追平 ➔ Cutover 切換,各階段皆標示讀流量、寫流量與回滾方式。STEP 1 · Dual-Write 雙寫讀:舊分片寫:舊分片 + 新分片,同步雙寫(新庫寫入失敗僅告警,不阻斷)回滾:關閉新庫寫入即可STEP 2 · Backfill 歷史回填讀:舊分片(不受影響)寫:離線批次依主鍵回填新庫以 updated_at/版本號比對, 新庫版本較舊才覆蓋回滾:清空新庫重跑STEP 3 · Verify 校驗與 CDC 追平讀:舊分片(尚未切換)寫:CDC (Debezium) 追齊增量落差全量 checksum + 抽樣 diff, 設定允許差異率門檻回滾:修正後重跑校驗,尚未影響線上讀STEP 4 · Cutover 切換讀:灰度切新分片 1% → 10% → 100%寫:觀察期通過後停用舊庫寫入, 雙寫降級為單寫回滾:讀流量切回舊分片(雙寫未關閉前皆可秒回)舊庫的寫入是最後才關的,它是整段遷移唯一的回滾保險
圖 6:零停機分片遷移四步法 — 每一階段都保留可秒回的回滾路徑

步驟一 · 雙寫(Dual-Write):應用層或 Proxy 對新舊分片同時寫入。新庫的寫入失敗只告警不阻斷主流程——這個階段新庫還沒有任何流量依賴它,讓它拖垮線上寫入是本末倒置。

步驟二 · 回填(Backfill):離線批次依主鍵分批掃描舊庫寫入新庫。唯一的關鍵是不能蓋掉雙寫寫進去的新資料:以 updated_at 或版本號比對,只在新庫該列版本較舊時才覆蓋。分批大小要控制在不影響線上讀的範圍,通常搭配限速。

步驟三 · 校驗與 CDC 追平(Verify):回填期間累積的增量落差由 CDC 追齊,再執行全量 checksum 分批比對加抽樣逐欄 diff。這個階段可以另外開一條影子讀:線上讀仍以舊庫結果回應,同時非同步讀新庫比對,把差異記成指標——它比離線 checksum 更能反映真實查詢路徑上的問題。

步驟四 · 切換(Cutover):讀流量灰度切至新分片,1% ➔ 10% ➔ 100%,每一級都留觀察期。全量讀切換後再觀察一段時間,最後才停用舊庫寫入,把雙寫降級為單寫。


9. 總結

先選演算法:

  • 時間序列日誌與時序數據:採用 Range 分片(搭配生命週期自動歸檔刪除)。
  • 大規模分散式 NoSQL KV:採用 一致性雜湊(Consistent Hashing)。
  • 傳統大型電商/SaaS 業務分庫分表:採用 雜湊分片搭配 1,024 個固定邏輯槽位,並為大租戶保留目錄查表的分級路由旁路。

再處理落地細節,這四件事決定專案會不會在上線半年後翻車:

  1. 路由模數綁定的是槽位數,不是物理節點數——否則每次擴容都是一場全量遷移。
  2. 要嵌進主鍵的是邏輯槽位,不是物理分片編號——嵌了物理編號等於宣告這筆資料永遠不能搬。
  3. 線上路徑用 Saga / TCC / Outbox,2PC 留給低頻後台作業——鎖持有時間從本地交易長度變成網路 RTT 是高併發承受不起的。
  4. 遷移時舊庫的寫入最後才關——它是整段流程唯一的回滾保險。