為什麼要換資料庫#

不換是上策,但有些情況沒辦法不換:

  • MySQL 撐不住寫入量 → 換 TiDB / CockroachDB
  • 需要強分散式事務 → 從 MySQL 分片走向 NewSQL
  • 雲端遷移 → 自建 MySQL 換 RDS / Aurora
  • 預算壓力 → 商用 Oracle 換 PostgreSQL
  • 業務型態改變 → 從關聯型換文檔型

最痛的需求:不能停機。電商停 1 小時可能損失百萬。

本章寫的方法論不限於 MySQL ── 換 ES、換 Redis、換 Kafka 都通用。

換庫的本質#

從舊儲存(A)切換到新儲存(B),中間既要:

  1. 資料完整搬遷
  2. 新寫入不丟失
  3. 服務不中斷
  4. 任何時刻可回滾

經典五階段#

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 再 streaming

Debezium 內建 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% 走新庫穩定後:

  1. 應用層雙寫改成「新庫主、舊庫副
  2. 觀察一段時間
  3. 應用完全切到新庫
  4. 舊庫保持作為災備一段時間(幾天到一週)
  5. 確認穩定 → 下線舊庫

切寫的瞬間有風險:應用實例不會同時切換。設計:

  • 組態中心控制切換 flag
  • 每個實例讀到新 flag 後切
  • 中間有一段時間「部分實例寫新、部分寫舊」── 最好讓兩邊都同步

最穩的做法:永遠雙寫到切完所有實例,再把舊庫關。

回滾#

任何階段都要能回滾:

階段回滾動作
雙寫期改 flag,停雙寫,恢復單寫舊庫
全量導入失敗就重來、不影響業務
灰度切讀改 flag,立刻把 0% 切回去
切寫切回舊庫;但這時新庫已有些寫入,需要逆向補償到舊庫

最後一步的回滾最痛 ── 一旦切寫,新庫成為新真相。回滾要把新庫期間的寫入逆向同步回舊庫。所以切寫之前要極度信心

風險控制清單#

切換之前要驗證:

  • 新庫資料量、行數對得上
  • 隨機抽樣資料逐欄位比對 OK
  • 業務測試(端到端)在新庫跑過
  • 性能壓測新庫達標
  • DR 驗證(killing master 後切換)
  • 回滾流程實演過
  • 監控覆蓋全(QPS、延遲、錯誤率、不一致)
  • On-call 排班、應急 runbook
  • 業務同步:什麼時段切、需要什麼決策權

工具#

場景工具
MySQL → MySQLgh-ost、pt-osc、Vitess、Debezium
MySQL → PostgreSQLpgloader、AWS DMS
MySQL → TiDBTiDB DM、pump/drainer
MySQL → ESLogstash、Debezium + ES sink
跨雲端AWS DMS、阿里 DTS、騰訊 DTS
通用Debezium + Kafka + 自寫 sink

最通用:Debezium + Kafka + 自製 sink。值得花時間掌握,因為換什麼都用得上。

一個失敗故事#

某電商從 MySQL 換到 TiDB,過程:

  1. 雙寫一個月、比對通過
  2. 灰度切讀到 100%、穩定
  3. 切寫
  4. 大促來了,TiDB 在熱點 region 出現性能問題(彼時版本 unique index 在熱點下表現不好)
  5. 業務崩了 30 分鐘
  6. 緊急切回 MySQL ── 但 30 分鐘的寫入只在 TiDB
  7. 寫了 reverse sync 把 TiDB 增量導回 MySQL
  8. 三天後 TiDB 修了 bug,再切回去

教訓:

  • 大促前不要切
  • 切寫後 7 天的觀察期太重要
  • 反向 sync 必須提前準備好

DDL 變更也是一種「換庫」#

加欄位、改型別、加索引 ── 對大表是同等級工程。gh-ostpt-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 實例。

小結#

換庫五階段:

  1. 雙寫設置(舊主新副)
  2. 全量同步(snapshot + 增量)
  3. 增量追平 + 比對 + 補償
  4. 灰度切讀(1% → 100%)
  5. 切寫 + 觀察 + 下線舊庫

關鍵原則:

  • 每一步都可回滾
  • 比對程序與補償程序與雙寫一起準備
  • 監控覆蓋整個鏈路
  • 大促/敏感期不切

這套方法論不只用於換 DB,泛用到任何持久化系統的遷移。

下章看物件儲存 ── S3 風格的分散式系統怎麼存大檔。