單庫單表會撞牆#

MySQL InnoDB 一張表多大才會慢?沒有絕對答案,但常見的牆:

維度大致警戒線
行數1000 萬 ~ 5000 萬 起變慢
表大小50 ~ 100 GB
並發單表 1000+ QPS 寫入
索引大小單索引 > 10 GB

撞牆原因:

  • B+ tree 高度增加 → IO 次數增加
  • buffer pool 裝不下熱資料 → 頻繁 disk read
  • 索引維護成本指數增長
  • ALTER 一張大表幾小時起跳

兩條路:歸檔 vs 分片#

歸檔(archiving)#

把「冷資料」搬到別處:

hot table (orders) ──老資料──→ cold table (orders_archive)
                              或 → 列式儲存(ClickHouse)
                              或 → 物件儲存

特性:

  • 資料模型不變
  • 應用主要查詢仍走熱表
  • 偶爾查歷史走冷表

分片(sharding)#

把同性質資料切到多個物理位置:

orders ─────┬──→ orders_0 (user_id % 4 == 0)
            ├──→ orders_1 (user_id % 4 == 1)
            ├──→ orders_2 (user_id % 4 == 2)
            └──→ orders_3 (user_id % 4 == 3)

特性:

  • 永久擴容
  • 應用要會「找到對的 shard」
  • 跨 shard 操作複雜化

實務:先歸檔(足夠 80% 場景),不行再分片。分片是不歸路 ── 進入後運維複雜度大幅提升。

歸檔:訂單歷史的搬遷#

電商「2 年前的訂單還會被查嗎」── 偶爾。所以:

orders (主表):最近 6 個月的活躍訂單
orders_archive:6 個月以前的歷史訂單

搬遷流程:

-- 每天凌晨跑
INSERT INTO orders_archive
SELECT * FROM orders
WHERE created_at < NOW() - INTERVAL 180 DAY
  AND status IN ('completed', 'cancelled');   -- 只搬最終態

DELETE FROM orders
WHERE created_at < NOW() - INTERVAL 180 DAY
  AND status IN ('completed', 'cancelled');

注意:

  1. 搬之前先 INSERT 再 DELETE,順序錯就丟資料
  2. 兩個操作放同事務
  3. 批次小:一次幾千筆,避免長 lock + 大事務
  4. 限速:別把 IO 跑滿

大量 DELETE 的陷阱#

DELETE FROM orders WHERE created_at < ?;

100 萬行的 DELETE:

  • 鎖整段範圍(gap lock 在 RR 下)
  • undo log 暴漲
  • binlog 暴漲(row 模式下每行一條 event)
  • 主從複製延遲嚴重
  • buffer pool 被「將被刪的頁」佔滿

正確做法:分批小步走

-- pseudo code
while True:
    rows = DELETE FROM orders
           WHERE created_at < ?
           ORDER BY id LIMIT 1000;
    if rows == 0: break;
    sleep(0.1);   # 給主庫喘氣,給從庫追上

或更好:

SELECT MIN(id), MAX(id) FROM orders WHERE created_at < ?;
-- 然後在 [min_id, max_id] 範圍內以 batch 刪

應用層查詢統一#

歸檔後,業務查詢可能跨兩張表。封裝抽象:

def query_orders(user_id, days_back):
    if days_back <= 180:
        return query_hot(user_id, days_back)
    return query_hot(user_id, days_back) + query_archive(user_id, days_back)

或用 view:

CREATE VIEW orders_all AS
SELECT * FROM orders
UNION ALL
SELECT * FROM orders_archive;

view 對應用透明,但性能未必好(優化器可能無法 push down where 到所有 union 部分)。

分庫分表:什麼時候開始#

訊號:

  • 歸檔後熱表仍持續變大
  • 寫入 QPS 接近主庫上限
  • 單表 ALTER 已經跑數小時
  • 需要更高吞吐(業務暴增)

別太早分。一旦分了:

  • 跨片 join 很痛
  • 跨片事務需要分散式事務
  • DDL 變更是 N 倍工作
  • 唯一索引必須包含分片鍵

很多公司過早分庫,一年後業務萎縮,留下複雜架構難回頭。

分片鍵(sharding key)的選擇#

最重要的設計決策。原則:

  1. 業務最頻繁的查詢條件
  2. 分布盡量均勻
  3. 熱點不集中
  4. 變動不頻繁(不能改)

電商常見選擇:

場景分片鍵理由
訂單user_id多數查詢「我的訂單」
商品spu_id商品詳情頁
評論spu_id「該商品的所有評論」
物流order_id配送單關聯訂單
行為日誌user_id用戶行為分析

訂單為什麼不選 order_id?因為訂單頁要查「我的所有訂單」── 用 user_id 才能單片內完成,避免廣播查詢。

分片演算法#

1. Hash 分片#

shard = hash(key) % N

優點:均勻。 缺點:擴容時 N 變動,所有資料要重新分布(rehash)。

實務上選 N 為 2 的次方、或預先建好多 shard、或用一致性 hash。

2. Range 分片#

user_id 1 ~ 1M     → shard 0
user_id 1M ~ 2M    → shard 1
...

優點:擴容簡單(加新 shard,舊 shard 不動)。 缺點:

  • 熱點集中(新用戶都打到最新 shard)
  • 跨 shard 範圍掃描需要協調

3. 一致性 hash#

把 hash 環切 N 段。新增 shard 只搬鄰近段的資料。

   shard 0
       ↓
[●─────────────●]
 ↑            ↑
 ●            shard 1
shard 3       ↓
       ●
   shard 2

擴容代價:1/N 的資料搬遷,比簡單 hash 好得多。

4. 查表分片#

每個 key 在中央表查屬於哪個 shard:

key → routing table → shard

最靈活(隨意搬),但 routing table 自己要 HA。

5. Compound(複合鍵)#

<user_id, time> 兩維分片,先按 user_id 分 N、再按 time 分 M → N×M 個物理 shard。適合用戶 + 時間都是查詢維度的場景。

分片粒度:庫 vs 表#

切法行為
分表同一個庫多張表 orders_0 ~ orders_15
分庫多個庫各一張表,每庫獨立 instance
分庫 + 分表多個庫,每庫多張表

分表只解決單表大小問題,不解決寫入 QPS(仍同庫單實例)。 分庫解決了 QPS 但運維量大。

實務:分庫 + 分表。例如 32 個庫 × 32 張表 = 1024 個物理片,預先建好(不要一開始 4 庫 4 表 之後再 rehash)。

跨片操作的成本#

跨片查詢#

-- 沒帶 user_id(分片鍵)
SELECT * FROM orders WHERE order_no = 'ABC123';

要打 N 個 shard、合併結果(fan-out + scatter-gather)。

對策:

  • 訂單號編碼分片資訊order_no 包含 user_id 後 N 位 → 解碼即知 shard
  • 維護「訂單號 → user_id」映射表(小表,可全 cache)

跨片 join#

幾乎不可行。對策:

  • 應用層 join:先取一邊、再批次取另一邊
  • 寬表反正規化:把常 join 的欄位 denormalize 到主表
  • 跨片資料同步到 ES / OLAP,那邊做 join

跨片事務#

第 4 章的分散式事務都派上用場。優先思考能否用「同片」設計避開。

全域唯一 ID#

分片後 DB 自增 ID 失效(每片都從 1 開始)。需要全域唯一 ID:

方案結構特性
Snowflaketimestamp + machine + seq趨勢遞增、長度 64-bit
UUIDrandom全域唯一但無序、索引差
號段中央發號器發 batch 給應用 cache趨勢遞增、有單點
Redis incr中央化 Redis簡單但 Redis HA 是責任

電商一般用 Snowflake 或變體(讓 timestamp 在前以便排序)。

DDL 變更:N 倍工作#

分了 32 個庫後加一個欄位 ── 要在 32 個庫各執行一次 ALTER。每次幾分鐘到幾小時。

工具:

  • pt-online-schema-change(Percona)
  • gh-ost(GitHub)

兩者都用「建影子表 + 慢慢同步 + 切換」實作零停機 ALTER。但對 32 庫仍要協調 + 監控全程。

實務:對分片資料庫的 schema 設計極為保守 ── 寧可一開始留 reserved 欄位也不要事後加欄位。

擴容時的資料搬遷#

要從 4 庫擴到 8 庫 ── 需要:

  1. 新建 4 個庫
  2. 把現有資料按新分片規則 redistribute
  3. 應用切換到新分片函式

最常用方案:雙倍擴容 + 資料只搬一半

舊規則:user_id % 4
新規則:user_id % 8

shard 0 (% 4 == 0) → 新規則拆成 shard 0 (% 8 == 0) + shard 4 (% 8 == 4)

每個老 shard 拆成兩個新 shard,只搬一半資料即可。配合中介層雙寫 / 灰度切流,可做到不停機。

第 10 章專門講不停機資料遷移。

不要分片的選項#

很多場景不真的需要分片:

  1. 歸檔夠用 → 永遠別碰分片
  2. 垂直拆分(不同業務拆不同庫)比水平分片簡單得多
  3. TiDB / CockroachDB / OceanBase ── NewSQL 自動分片,業務代碼像用單機 MySQL(第 14 章)
  4. 歷史 OLAP 走 ClickHouse,OLTP 庫保持小

評估順序:

歸檔 → 垂直拆分 → 讀寫分離 → 快取 → NewSQL → 水平分片

分片是最後手段。

小結#

  • 歸檔是「無痛」擴容的第一手段
  • 大量 DELETE 必分批 + 限速
  • 分片鍵:選擇業務查詢最頻繁的維度
  • 分片演算法:Hash(均勻)、Range(順序)、一致性 hash(彈性擴容)
  • 分庫 + 分表 + 預先過量建好物理片
  • 跨片 query 需要訂單號編碼或映射表
  • 分片是最後手段,先試所有其他選項

下章看 Redis 叢集與 binlog 同步 ── 資料量超大時 cache 怎麼搭。