為什麼要換資料庫#
不換是上策,但有些情況沒辦法不換:
- MySQL 撐不住寫入量 → 換 TiDB / CockroachDB
- 需要強分散式事務 → 從 MySQL 分片走向 NewSQL
- 雲端遷移 → 自建 MySQL 換 RDS / Aurora
- 預算壓力 → 商用 Oracle 換 PostgreSQL
- 業務型態改變 → 從關聯型換文檔型
最痛的需求:不能停機。電商停 1 小時可能損失百萬。
本章寫的方法論不限於 MySQL ── 換 ES、換 Redis、換 Kafka 都通用。
換庫的本質#
從舊儲存(A)切換到新儲存(B),中間既要:
- 資料完整搬遷
- 新寫入不丟失
- 服務不中斷
- 任何時刻可回滾
經典五階段#
1. 雙寫設置
舊庫 = source of truth
新庫接受寫入但只是「跟隨」
2. 全量同步
把舊庫歷史資料導入新庫
3. 增量追平
雙寫期間,新庫追上舊庫的所有寫入
4. 灰度切讀
讀流量逐步切到新庫(1% → 10% → 50% → 100%)
觀察、發現問題回滾
5. 切寫
寫入主庫切到新庫,舊庫變從庫一段時間
觀察穩定 → 下線舊庫步驟 1:雙寫架構#
最好的做法是改一次代碼就完成從單寫到雙寫:
class DBProxy:
def __init__(self):
self.old = OldDB()
self.new = NewDB()
self.config = config
def write(self, sql, params):
result = self.old.execute(sql, params) # 舊庫主,必成功
try:
self.new.execute(sql, params) # 新庫副,可失敗
except Exception as e:
log.warning(f"new DB write failed: {e}")
metrics.incr("new_db_write_fail")
return result關鍵:
- 舊庫成功才算業務成功
- 新庫失敗只記錄、不影響業務
- 可以 catch up(跨服務同步補回)
步驟 2:全量同步#
把舊庫存量資料導入新庫。方法選擇:
方案 A:dump + import#
mysqldump --single-transaction old_db > full.sql
# 傳到新庫 host
mysql new_db < full.sql簡單但鎖時間長(雖然 --single-transaction 較好)、新庫匯入慢。
方案 B:流式同步工具#
old MySQL ─binlog─→ Debezium ─→ Kafka ─→ consumer → new DB
↑
從某個 binlog position
全量先 snapshot 再 streamingDebezium 內建 snapshot mode:先全表掃一遍、再從某個 binlog 位置開始增量。全量與增量無縫銜接,不會漏寫。
方案 C:拷貝實體檔案#
對相同型號(MySQL → MySQL)可考慮 xtrabackup 物理複製。最快但目標必須是同類型 DB。
步驟 3:增量追平與比對#
雙寫執行一段時間後,新庫應與舊庫一致。但實際常有偏差:
- 雙寫順序問題
- 新庫寫入失敗未補
- schema 差異引發隱式轉換
寫一個比對程式#
def compare(table, primary_key):
old_rows = old.execute(f"SELECT * FROM {table} WHERE updated_at > ? ORDER BY {primary_key}")
new_rows = new.execute(f"SELECT * FROM {table} WHERE updated_at > ? ORDER BY {primary_key}")
missing_in_new = []
different = []
for old_row in old_rows:
new_row = new_rows.get(old_row[primary_key])
if not new_row:
missing_in_new.append(old_row)
elif old_row != new_row:
different.append((old_row, new_row))
return missing_in_new, different實務上寫一個跑得快的版本:
- 按 primary key 分批比對(避免一次拉太多)
- 用 checksum 比對欄位,快速判斷哪些 row 不同
- 跑得起 → 用 spark 或類似工具批次
補償程序#
比對發現差異 → 用舊庫的值修補新庫:
def reconcile(table, missing, different):
for row in missing:
new.upsert(table, row)
for old_row, new_row in different:
new.update(table, primary_key, old_row)跑直到比對發現差異 = 0。
注意:比對時間段要動態 ── 比對中業務還在寫,所以「永遠不會 0」(只要新寫入舊庫先進、新庫慢一點就有暫態差異)。需要選擇合理的「時間窗口外的差異 = 0」作為通過標準。
步驟 4:灰度切讀#
雙寫穩、比對 OK 後,開始切讀流量。
用戶分流#
按 user_id mod N 灰度:
def read(user_id, sql):
if user_id % 100 < gradual_percent: # 1, 5, 10, 50, 100
return new.execute(sql)
return old.execute(sql)從 1% 開始,觀察新庫穩定性與業務指標。漸進到 100%。
Shadow 流量#
同一請求同時打兩邊,比對結果:
def read_shadow(sql):
old_result = old.execute(sql)
new_result = new.execute(sql)
if old_result != new_result:
log.warning(...)
return old_result # 仍以舊庫為準比直接切讀更保守,能在不影響用戶的前提下發現新庫問題。
步驟 5:切寫#
最關鍵的一步。讀流量 100% 走新庫穩定後:
- 應用層雙寫改成「新庫主、舊庫副」
- 觀察一段時間
- 應用完全切到新庫
- 舊庫保持作為災備一段時間(幾天到一週)
- 確認穩定 → 下線舊庫
切寫的瞬間有風險:應用實例不會同時切換。設計:
- 組態中心控制切換 flag
- 每個實例讀到新 flag 後切
- 中間有一段時間「部分實例寫新、部分寫舊」── 最好讓兩邊都同步
最穩的做法:永遠雙寫到切完所有實例,再把舊庫關。
回滾#
任何階段都要能回滾:
| 階段 | 回滾動作 |
|---|---|
| 雙寫期 | 改 flag,停雙寫,恢復單寫舊庫 |
| 全量導入 | 失敗就重來、不影響業務 |
| 灰度切讀 | 改 flag,立刻把 0% 切回去 |
| 切寫 | 切回舊庫;但這時新庫已有些寫入,需要逆向補償到舊庫 |
最後一步的回滾最痛 ── 一旦切寫,新庫成為新真相。回滾要把新庫期間的寫入逆向同步回舊庫。所以切寫之前要極度信心。
風險控制清單#
切換之前要驗證:
- 新庫資料量、行數對得上
- 隨機抽樣資料逐欄位比對 OK
- 業務測試(端到端)在新庫跑過
- 性能壓測新庫達標
- DR 驗證(killing master 後切換)
- 回滾流程實演過
- 監控覆蓋全(QPS、延遲、錯誤率、不一致)
- On-call 排班、應急 runbook
- 業務同步:什麼時段切、需要什麼決策權
工具#
| 場景 | 工具 |
|---|---|
| MySQL → MySQL | gh-ost、pt-osc、Vitess、Debezium |
| MySQL → PostgreSQL | pgloader、AWS DMS |
| MySQL → TiDB | TiDB DM、pump/drainer |
| MySQL → ES | Logstash、Debezium + ES sink |
| 跨雲端 | AWS DMS、阿里 DTS、騰訊 DTS |
| 通用 | Debezium + Kafka + 自寫 sink |
最通用:Debezium + Kafka + 自製 sink。值得花時間掌握,因為換什麼都用得上。
一個失敗故事#
某電商從 MySQL 換到 TiDB,過程:
- 雙寫一個月、比對通過
- 灰度切讀到 100%、穩定
- 切寫
- 大促來了,TiDB 在熱點 region 出現性能問題(彼時版本 unique index 在熱點下表現不好)
- 業務崩了 30 分鐘
- 緊急切回 MySQL ── 但 30 分鐘的寫入只在 TiDB
- 寫了 reverse sync 把 TiDB 增量導回 MySQL
- 三天後 TiDB 修了 bug,再切回去
教訓:
- 大促前不要切
- 切寫後 7 天的觀察期太重要
- 反向 sync 必須提前準備好
DDL 變更也是一種「換庫」#
加欄位、改型別、加索引 ── 對大表是同等級工程。gh-ost 與 pt-osc 把同一套思路用在表結構變更:
gh-ost 流程:
1. 創建 _table_gho(影子表,新 schema)
2. 從原表 copy 全量資料到 _table_gho
3. 從 binlog 應用增量變更到 _table_gho
4. cut-over:lock + rename,原表變 _table_gho_del、_table_gho 變正式名
5. 刪除舊表跟換庫流程同構,只是兩邊都在同一個 MySQL 實例。
小結#
換庫五階段:
- 雙寫設置(舊主新副)
- 全量同步(snapshot + 增量)
- 增量追平 + 比對 + 補償
- 灰度切讀(1% → 100%)
- 切寫 + 觀察 + 下線舊庫
關鍵原則:
- 每一步都可回滾
- 比對程序與補償程序與雙寫一起準備
- 監控覆蓋整個鏈路
- 大促/敏感期不切
這套方法論不只用於換 DB,泛用到任何持久化系統的遷移。
下章看物件儲存 ── S3 風格的分散式系統怎麼存大檔。