單庫單表會撞牆#
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');注意:
- 搬之前先 INSERT 再 DELETE,順序錯就丟資料
- 兩個操作放同事務
- 批次小:一次幾千筆,避免長 lock + 大事務
- 限速:別把 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)的選擇#
最重要的設計決策。原則:
- 業務最頻繁的查詢條件
- 分布盡量均勻
- 熱點不集中
- 變動不頻繁(不能改)
電商常見選擇:
| 場景 | 分片鍵 | 理由 |
|---|---|---|
| 訂單 | 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:
| 方案 | 結構 | 特性 |
|---|---|---|
| Snowflake | timestamp + machine + seq | 趨勢遞增、長度 64-bit |
| UUID | random | 全域唯一但無序、索引差 |
| 號段 | 中央發號器發 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 庫 ── 需要:
- 新建 4 個庫
- 把現有資料按新分片規則 redistribute
- 應用切換到新分片函式
最常用方案:雙倍擴容 + 資料只搬一半:
舊規則:user_id % 4
新規則:user_id % 8
shard 0 (% 4 == 0) → 新規則拆成 shard 0 (% 8 == 0) + shard 4 (% 8 == 4)每個老 shard 拆成兩個新 shard,只搬一半資料即可。配合中介層雙寫 / 灰度切流,可做到不停機。
第 10 章專門講不停機資料遷移。
不要分片的選項#
很多場景不真的需要分片:
- 歸檔夠用 → 永遠別碰分片
- 垂直拆分(不同業務拆不同庫)比水平分片簡單得多
- TiDB / CockroachDB / OceanBase ── NewSQL 自動分片,業務代碼像用單機 MySQL(第 14 章)
- 歷史 OLAP 走 ClickHouse,OLTP 庫保持小
評估順序:
歸檔 → 垂直拆分 → 讀寫分離 → 快取 → NewSQL → 水平分片分片是最後手段。
小結#
- 歸檔是「無痛」擴容的第一手段
- 大量 DELETE 必分批 + 限速
- 分片鍵:選擇業務查詢最頻繁的維度
- 分片演算法:Hash(均勻)、Range(順序)、一致性 hash(彈性擴容)
- 分庫 + 分表 + 預先過量建好物理片
- 跨片 query 需要訂單號編碼或映射表
- 分片是最後手段,先試所有其他選項
下章看 Redis 叢集與 binlog 同步 ── 資料量超大時 cache 怎麼搭。