資料庫複製
一台主資料庫負責寫,好幾台副本分擔讀。代價是副本會落後:剛寫進去的資料,從副本讀可能還看不到。
確認時機
2
100 ms
寫入,以及每台副本何時套用讀取:最新讀取:舊資料
寫入確認 p50/p99
2.0 ms / 2.0 ms
讀到舊資料
15.5%
讀不到自己剛寫的
88%
落在主資料庫的讀取
0%
非同步:寫入後中位數 2.0 ms 收到確認,最慢的 1% 要 2.0 ms。所有讀取裡有 15.5% 讀到的版本比已經確認過的還舊;使用者寫完 30 ms 後讀回自己剛寫的資料,有 88% 沒看到。打開「讀自己寫的」可以修好第二個數字,而且不必讓寫入變慢。
假設:每秒 10 次寫入、100 次讀取;主資料庫自己寫入要 2 ms;每台副本在 5 ms 加上一段指數分佈的延遲後依序套用;使用者收到成功後 30 ms 讀回自己寫的資料。不在「讀自己寫的」時間窗內的讀取,輪流分給各副本。
亮起來的是這一步執行的程式碼
const LOCAL_SEC = 0.002; // the primary's own write interface Replica { // Ships a write; returns the time the replica will have applied it. send(key: string, version: number, value: string, now: number): number;} class Primary { private version = 0; private data = new Map<string, { version: number; value: string }>(); // How many replicas must confirm before the client hears back: // 0 = asynchronous, 1 = semi-synchronous, all = synchronous. constructor(private replicas: Replica[], private waitFor: number) {} // Returns when the client is told the write succeeded. write(key: string, value: string, now: number): number { const version = ++this.version; this.data.set(key, { version, value }); const applied = this.replicas.map((r) => r.send(key, version, value, now)); if (this.waitFor === 0) return now + LOCAL_SEC; applied.sort((a, b) => a - b); return Math.max(now + LOCAL_SEC, applied[this.waitFor - 1]); }} class ReadRouter { private lastWrite = new Map<number, number>(); private next = 0; constructor(private replicaCount: number, private readYourWrites: boolean, private windowSec: number) {} wrote(user: number, now: number): void { this.lastWrite.set(user, now); } // 0 = the primary, 1..n = a replica. route(user: number, now: number): number { const last = this.lastWrite.get(user); if (this.readYourWrites && last !== undefined && now - last < this.windowSec) { return 0; } return 1 + (this.next++ % this.replicaCount); }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 單一主節點寫入、副本延後收到版本;故障與延遲來自模型。未涵蓋完整選主、split-brain 防護或 durable commit 協定。
什麼時候用
- 讀遠多於寫:一台主資料庫扛寫入,讀取分給好幾台副本。
- 要撐過一台機器壞掉:主資料庫掛了,把一台副本升上來接手。
- 要在別的地區放一份資料,讓那裡的讀取更快、或當作災難備援。
和其他主題的關係
- 延伸閱讀
- 分割與分片
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 寫入 R 是副本數:每台送一次;同步時還要等最慢的一台 | O(R) | O(R) |
| 讀取 只問一台;讀取量可以靠加副本分擔 | O(1) | O(1) |
| 決定讀哪一台 | O(1) | O(1) |
空間:O(R·D),D 是資料量;每台副本都有一份完整的資料
Big O 實測:n 變大時步數怎麼長
數的是:n 台副本、平均延遲 50 ms 時,一次寫入要等多久(ms)
| Big O | n = 4 | n = 16 | n = 64 | n = 256 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 同步:等最慢的一台 | O(log n) | 109 | 174 | 242 | 311 | ×2.8 (×4.0) |
| 非同步:只等主資料庫 | O(1) | 2 | 2 | 2 | 2 | ×1.0 (×1.0) |
同步寫入等的是 n 個隨機延遲裡最大的那個,它隨副本數像 log n 一樣慢慢變大;而複製訊息本身是每台一則,總共 O(n)。
和其他做法比
| 寫入 p50 | 寫入 p99 | 讀到舊資料 | 讀不到自己剛寫的 | 主資料庫承擔的讀取 | |
|---|---|---|---|---|---|
| 非同步 | 2.0 ms | 2.0 ms | 15.5% | 88% | 0% |
| 非同步+讀自己寫的 | 2.0 ms | 2.0 ms | 4.0% | 0% | 44% |
| 半同步 | 79.6 ms | 278 ms | 6.6% | 39% | 0% |
| 同步 | 184 ms | 538 ms | 0.0% | 0% | 0% |
2 台副本、平均複製延遲 100 ms,同一串讀寫。同步把舊資料降到零,代價是每次寫入都等最慢的副本;「讀自己寫的」只修好使用者最在意的那一種舊資料,寫入不必等,但主資料庫要多扛一部分讀取。p50/p99 是第 50/99 百分位(percentile):這裡只把寫入送出到收到確認的延遲由快到慢排好,取 50%、99% 位置的值,不混入讀取請求。
真實世界裡的它
- MySQL、PostgreSQL 的 primary/replica(以前叫 master/slave),預設是非同步。
- MySQL 的 semi-sync 外掛、PostgreSQL 的
synchronous_commit和synchronous_standby_names。 - Amazon Aurora、Google Spanner 等雲端資料庫把複製藏在儲存層裡,但同樣的取捨還在。
取捨與陷阱
- 非同步複製在主資料庫掛掉時會掉資料:已經跟使用者說成功、但還沒傳到副本的寫入,升級副本後就不見了。
- 「讀自己寫的」只保證同一個人看得到自己的寫入;兩個人之間、或同一個人換了裝置,仍可能看到不同版本。
- 副本的延遲平常只有幾毫秒,但遇到大批寫入或長查詢時可能落後幾分鐘;要監控 replication lag,落後太多就把讀取移開。