# Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

# 為什麼要讀寫分離？

大多數的 Web 應用都是 「讀多寫少」（例如：看文的人多，發文的人少,Heavy Read System）。當所有的請求都塞給同一台資料庫時，磁碟 I/O 和連線數會成為瓶頸。

*   Master (主庫)： 負責寫入 (Insert/Update/Delete)，確保數據一致性。
    
*   Slave (從庫)： 負責讀取 (Select)，可以有多個從庫來分擔讀取壓力。
    

### 為什麼讀寫分離能提升效能？

*   磁碟 I/O 分散： 寫入操作通常涉及索引更新和交易日誌，非常吃磁碟性能。將讀取抽離，可以避免 SELECT 語句與 INSERT/UPDATE 爭搶資源。
    
*   併發連線數： 每台資料庫能承載的連線數有限，多台 Slave 代表能支撐更多的使用者同時在線瀏覽。
    

## 路由實作：由誰來決定 SQL 去哪裡

實務上，讀寫分離的「路由控制」有兩種主流架構，各有優缺點：

*   應用層控制 (Application-side Routing)：
    
    *   作法： 在程式碼或框架設定中定義 `read` 與 `write` 連線（例如 Laravel 的 `database.php`）。
        
    *   優點： 效能最高（少一層網路跳轉）、部署簡單。
        
    *   缺點： 業務代碼與基礎設施耦合；當 Slave 增加或異動時，所有應用程式都得更新設定。
        
*   代理層控制 (Proxy-side Routing)：
    
    *   作法： 引入 **ProxySQL** 或 **MySQL Router**。程式只連向 Proxy，由 Proxy 根據 SQL 語法（SELECT vs UPDATE）自動轉發。
        
    *   優點： 支援連線池 (Connection Pooling)、動態下線壞掉的 Slave、對程式碼透明。
        
    *   缺點： 增加系統複雜度與網路延遲 (Latency)。
        

# 資料庫複寫的原理

*   非同步複寫 (Asynchronous)： 主庫寫完立即回傳成功，再慢慢同步給從庫。效能最好，但有數據遺失風險(如果 Master 剛寫完就當機，資料可能還沒傳到 Slave，導致資料遺失)
    
*   半同步複寫 (Semi-synchronous)： 主庫寫完後，至少等一個從庫確認收到資料才回傳。平衡了效能與安全性。
    
*   Binary Log (Binlog)： 這是 MySQL 同步的核心，從庫就是透過讀取主庫的 Binlog 來重演 (Replay) 資料操作。
    

## 複寫底層的細節：Binlog 格式的影響

Slave 是怎麼同步的？是同步 SQL 語句還是同步數據結果？這會影響同步的準確性與效能：

*   Statement-Based Replication (SBR)： 同步 SQL 語句。
    
    *   坑： 如果 SQL 裡有 `NOW()` 或 `UUID()`，Slave 執行的結果會跟 Master 不一樣。
        
*   Row-Based Replication (RBR)： 同步實際變更的數據行。
    
    *   優點： 最安全，保證資料一致。
        
    *   缺點： 當大量更新時，Binlog 會噴發，佔用頻寬。
        
*   Mixed-Based Replication： 自動判斷。
    

在現代生產環境中，建議優先選用 Row-Based (RBR)。雖然它在大量更新（如 `UPDATE users SET status = 1`）時會產生巨大的 Log，但它能保證 100% 的資料精確度，避免 `RAND()` 或 `NOW()` 造成的同步問題。

# 快取一致性挑戰

還記得我們在L21中快取的章節有充到：如果在主從分離的極端架構下，為了避免快取到尚未更新的從庫(舊資料)，快取有兩個調整策略：延遲雙刪 & CDC。

這裡再提供一個方式 - 強制讀主庫： 如果快取過期時間很短，或者不希望快取邏輯太複雜，就會更依賴 「強制讀主庫」 來保證寫入後的第一次讀取是正確的。

## 強制讀主庫（Master-only Read）

核心邏輯：繞過讀寫分離的自動調度邏輯。

這通常發生在對「資料即時性」要求極高的場景，例如：使用者剛刷完卡、剛改完密碼，或者在交易（Transaction）程序中。

### 框架層級實作

這是最常見的做法，透過後端框架提供的 API，手動指定當前的 Query 使用「寫入連線」

```php
$user = User::onWriteConnection()->find(userId);
```

在Laravel中還有一個特殊的選項：`sticky`

當sticky = true時，可用於允許立即讀取在目前請求週期內寫入資料庫的記錄。如果啟用了sticky選項，並且在目前請求週期內對資料庫執行了「寫入」操作，則任何後續的「讀取」操作都會使用「寫入」連線。

Laravel 官方文件沒告訴你的事情：在 Laravel 中，一旦進入 transaction，所有讀取會自動 fallback 到 write connection，因為 transaction state 僅存在於 write PDO。

在 `ManagesTransactions.php` 中，可以看到 `function beginTransaction` 呼叫了`createTransaction`

而createTransaction的code如下：

```php
//ManagesTranstions
protected function createTransaction()
    {
        if ($this->transactions == 0) {
            $this->reconnectIfMissingConnection();

            try {
                $this->getPdo()->beginTransaction();
            } catch (Throwable $e) {
                $this->handleBeginTransactionException($e);
            }
        } elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
            $this->createSavepoint();
        }
    }
```

重點就在於getPdo，在`Connection.php` 中：

`$this->pdo`存放master connection

`$this->readPdo`存放slave connection

另外 在 `getReadPdo()` 也會發現 如果有transition會回傳master connection，因此在整個transaction的期間內，所有DB操作都會到master connection

```php
if ($this->transactions > 0) {
	return $this->getPdo();
}
```

### SQL Hint 層級 (Proxy/Driver Level)

如果是使用 ProxySQL、MySQL Router 或特定的資料庫中間件，它們通常支援在 SQL 語句中加入「註解（Comment）」作為 Hint，告訴代理層這條 SQL 不准分流。

```sql
/*+ MASTER */ SELECT * FROM users WHERE id = 123;
```

代理伺服器（如 ProxySQL）會解析這段註釋，即使它是 `SELECT` 開頭，也會被強制路由到配置好的 `Writer Group`。這對「程式碼與架構解耦」非常有幫助，開發者只需改 SQL 字串。

### 黏性主庫策略(Sticky Master / Session-based)

實作邏輯：

1.  當使用者進行 寫入操作（如 POST/PUT/PATCH）時，後端在 Response 加入一個短期有效的 Cookie 或在 Redis 紀錄一個 Flag（例如 `recently_updated:{user_id}`，有效期間 2 秒）。
    
2.  在接下來的請求中，Middleware（中介層） 檢查該 Flag 是否存在。
    
3.  如果存在，該 Request 內的所有讀取自動切換到 Master 連線；如果不存在，則回歸正常的讀寫分離模式。
