為什麼讀寫分離#

電商讀寫比通常 9:1 ~ 100:1。單庫扛不住讀流量時,讀寫分離是擴容的第一步:

        Application
             │
        ┌────┴────┐
        ▼         ▼
   write to     read from
     主庫      從庫1, 從庫2, ...
        │         ▲
        └─binlog──┘

主庫負責寫,從庫負責讀。讀流量水平擴展只要加從庫。

MySQL 主備複製細節(binlog 三種格式、雙 M、循環複製)已在 02-database/05 高可用架構 寫過。本章聚焦「實戰怎麼用、怎麼處理副作用」。

用戶端 vs 中介層#

兩種實作位置:

用戶端方案#

應用直接維護 master / replicas 連接池:

class DB:
    def __init__(self):
        self.master = create_pool(master_url)
        self.replicas = [create_pool(url) for url in replica_urls]

    def execute(self, sql):
        if is_write(sql):
            return self.master.execute(sql)
        return random.choice(self.replicas).execute(sql)

典型工具:Sharding-JDBC、TDDL。

優點:性能好(無 proxy 跳轉)。 缺點:每個應用都要配,加減節點要重啟。

中介層方案#

放一個 proxy:應用連 proxy,proxy 看 SQL 決定路由。

典型工具:MyCat、ProxySQL、Vitess。

優點:應用無感知,運維集中。 缺點:proxy 自己要 HA,增加一跳延遲。

選擇:小團隊用戶端方案;大型多語言團隊中介層。

副作用 1:複製延遲#

主寫了,從庫要幾十 ms 到幾秒才同步到。如果同個請求剛寫完馬上讀:

def buy(user_id, sku_id):
    db.master.execute("INSERT INTO orders ...")
    return db.replica.execute("SELECT * FROM orders WHERE user_id=?", user_id)
    # ← 讀不到剛寫的 order!

解 1:強制走主庫#

對「寫後立刻讀」的查詢,明確標記走 master:

db.master.execute("SELECT ...")  # 顯式

或框架支持「最近 N 秒內走主庫」hint。

解 2:判讀請求類型#

CRUD 動作中:

  • 寫 → 主
  • 寫後讀(同一 session)→ 主
  • 一般讀 → 從

這個策略需要 session 範圍管理,框架支持。

解 3:等位點(GTID/binlog 位置)#

寫之後拿到 GTID,讀請求帶這個 GTID,從庫追上才回應:

SELECT WAIT_FOR_EXECUTED_GTID_SET('xxx', 1);  -- 1 秒超時
SELECT ...

精確但延遲高。

實務:90% 用方案 1 + 2 就夠。

副作用 2:Read-after-write 在 session 內#

def update_user_name(user_id, name):
    db.master.execute("UPDATE users SET name=%s WHERE id=%s", name, user_id)
    return db.master.execute("SELECT * FROM users WHERE id=%s", user_id)  # 必走主

判斷標準:「這個 session 剛剛寫過 → 之後的讀走主」。可以用 thread-local 變數紀錄:

class SessionRouting:
    last_write_at = None

    def execute(sql):
        if is_write(sql):
            self.last_write_at = now()
            return master.exec(sql)
        if self.last_write_at and now() - self.last_write_at < 5:
            return master.exec(sql)
        return replica.exec(sql)

5 秒內走主、之後走從。

副作用 3:從庫掛了#

一個從庫掛了 → 應用不能繼續往那個 IP 送流量。需要:

  • 健康檢查(每 N 秒 ping)
  • 自動踢除壞節點
  • 重新加入時 catch up 完成才接流量

ProxySQL、HAProxy 都做這件事。用戶端方案要自己實作或用 service mesh。

複製狀態機(Replicated State Machine)#

主備複製的理論基礎:「對相同的初始狀態,按相同順序執行相同操作 → 狀態一致」。

master:
  state_0 ─op_1→ state_1 ─op_2→ state_2 ─op_3→ ...

replica:
  state_0 ─op_1→ state_1 ─op_2→ state_2 ─op_3→ ...   ← 同樣的 op 序列

關鍵:

  1. deterministic operations(確定性操作)── 同樣輸入產同樣輸出
  2. 同序執行:op_1 必須在 op_2 之前

這就是為什麼 MySQL row-based binlog 比 statement-based 安全 ── statement 對 NOW()RAND() 等非確定函式會在不同實例產生不同結果。

複製狀態機是所有分散式儲存的基礎模型:

系統對應概念
MySQL 主備binlog 序列
Redisrepl-backlog(傳播 commands)
Raft / Paxoslog entry 序列
Kafkapartition 內 message 序列
etcdRaft log
SpannerPaxos log per shard

理解了 RSM 一個,理解了所有的分散式日誌複製。

半同步複製#

純非同步複製:master 寫完 binlog 立刻回應,replica 是否同步是後話 → master 突然掛了,binlog 還沒傳出去 → 資料丟。

半同步:master 等至少一個 replica 確認收到 binlog 才回應。

client → master: write
master → replica: send binlog
master ← replica: ack received
master → client: success

代價:寫延遲 + 網路 RTT。但提供「至少一個從庫有資料」保證。

GitHub 的 OrcheStrator + 半同步 + 自動切換是經典設定。

從庫的延遲監控#

關鍵指標:Seconds_Behind_Master

SHOW SLAVE STATUS\G
-- Seconds_Behind_Master: 0

但這個值不準:它只計算「執行 SQL_thread 落後多少」,不計入 IO_thread 還沒收到的部分。

更可靠:用 heartbeat 表:

-- 主庫每秒寫
UPDATE heartbeat SET ts = NOW() WHERE id = 1;

-- 從庫測
SELECT NOW() - ts FROM heartbeat WHERE id = 1;

差距即為實際延遲。

多從庫的負載分配#

簡單做法:random 或 round-robin。

進階:

  • 權重:給性能好的機器多分流量
  • 延遲感知:延遲高的少給流量
  • 地域感知:ap-east 應用優先 ap-east 從庫
  • 業務分層:報表類查詢走專屬 reporting replica,避免影響線上查詢

從庫的特殊用途#

從庫不只是讀流量分散,還可:

  • 報表 / OLAP:跑大查詢不影響主庫
  • 備份來源:對從庫做 dump,不影響主庫 IO
  • 延遲從庫:故意設定 1 小時延遲,誤刪可從這邊救
  • 災難備援:跨機房從庫
  • schema 變更測試:先改從庫看效果

故障切換(failover)#

主庫掛了怎麼辦?

手動切換#

DBA 操作:選一個從庫 promote 成新主、其他從庫指向新主、修改應用設定。

自動切換(HA)#

工具:Orchestrator、MHA、群集化方案(MySQL Group Replication、Galera)。

關鍵問題:

  1. 如何判定主掛了:純 ping 不夠,因為網路抖動 ≠ 真死。需要 quorum 判斷
  2. 避免 split brain:兩個主同時自認 master → 雙寫災難。靠 fencing 或 majority quorum
  3. 資料丟失最小化:選 binlog 最新的從庫 promote
  4. 應用感知:透過 VIP / DNS / proxy 切換,應用無痛

主流方案:MySQL 8.0 InnoDB Cluster(原生)、Vitess(YouTube/Slack 用)。

主主複製(雙寫)的陷阱#

「兩個都當主,互相同步」── 看似簡單,但:

  • 自增主鍵衝突:兩邊 INSERT 同一個 ID
    • 解:兩邊用不同 step / offset
  • 同行併發更新衝突:兩邊都改同一筆 → 不知誰贏
  • 複製延遲下的不一致:A 寫了還沒到 B、B 也寫了
  • 唯一索引衝突:兩邊各 INSERT 同一個 unique value

實務上不建議業務雙寫。雙 M 結構通常是「同時只有一邊接寫流量」── 另一個是熱備。

群集化複製:Galera / Group Replication#

避免雙寫陷阱的 ── 同步多寫

  • 寫之前先在所有節點達成共識
  • 衝突在提交前偵測

Galera Cluster:第三方,xtrabackup 出身。 MySQL Group Replication(GR):8.0+ 原生。

特性:

  • 強一致性
  • 任一節點都可寫
  • 多數派提交(容錯一個節點掛)

代價:

  • 寫延遲 = 跨網路共識
  • 大事務性能差
  • 複雜度高

適合:多機房強一致需求。不適合:寫密集、低延遲需求。

小結#

  • 讀寫分離:擴容讀流量的標準動作
  • 副作用:複製延遲 → 寫後立讀走主、session 內走主、必要時等位點
  • 複製狀態機是所有分散式日誌複製的理論基礎
  • 半同步:用一點延遲換資料安全
  • 故障切換:手動可控、自動快但要 fencing
  • 業務雙寫一般不建議;強一致用 Group Replication / Galera

下章看「進一步擴容」── 分庫分表,當單庫單表不夠的時候。