跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 資料庫複製

資料庫複製

一台主資料庫負責寫,好幾台副本分擔讀。代價是副本會落後:剛寫進去的資料,從副本讀可能還看不到。

確認時機
2
100 ms
寫入,以及每台副本何時套用讀取:最新讀取:舊資料
主資料庫副本 1副本 22.0s2.2s2.4s2.6s2.8s寫入 v20,2.04 秒寫入 v21,2.12 秒寫入 v22,2.13 秒寫入 v23,2.14 秒寫入 v24,2.14 秒寫入 v25,2.26 秒寫入 v26,2.35 秒寫入 v27,2.65 秒寫入 v28,2.72 秒寫入 v29,2.72 秒寫入 v30,2.77 秒寫入 v31,2.78 秒寫入 v32,2.79 秒讀取,2.00 秒:v13讀取,2.00 秒:v7讀取,2.01 秒:v0讀取,2.02 秒:v0讀取,2.04 秒:v0讀取,2.04 秒:v7讀取,2.04 秒:v0,舊資料讀取,2.04 秒:v12讀取,2.05 秒:v0讀取,2.05 秒:v0讀取,2.07 秒:v11讀回自己寫的,2.07 秒:v19,舊資料讀取,2.08 秒:v0讀取,2.08 秒:v17讀取,2.08 秒:v18讀取,2.08 秒:v0讀取,2.10 秒:v0讀取,2.10 秒:v5讀取,2.11 秒:v17讀取,2.12 秒:v0讀取,2.12 秒:v0讀取,2.14 秒:v12讀取,2.14 秒:v0讀回自己寫的,2.15 秒:v0,舊資料讀取,2.16 秒:v0讀回自己寫的,2.16 秒:v11,舊資料讀取,2.16 秒:v0讀取,2.17 秒:v0,舊資料讀回自己寫的,2.17 秒:v0,舊資料讀回自己寫的,2.17 秒:v0,舊資料讀取,2.19 秒:v13讀取,2.19 秒:v0讀取,2.20 秒:v23讀取,2.22 秒:v9讀取,2.24 秒:v20讀取,2.24 秒:v11,舊資料讀取,2.26 秒:v7讀取,2.26 秒:v0讀取,2.26 秒:v7讀取,2.28 秒:v14讀取,2.28 秒:v5讀回自己寫的,2.29 秒:v0,舊資料讀取,2.29 秒:v17讀取,2.29 秒:v0讀取,2.31 秒:v24讀取,2.34 秒:v9讀取,2.35 秒:v9讀取,2.35 秒:v7讀取,2.36 秒:v9讀取,2.36 秒:v0,舊資料讀取,2.37 秒:v0讀取,2.38 秒:v0讀回自己寫的,2.38 秒:v0,舊資料讀取,2.38 秒:v5讀取,2.39 秒:v0讀取,2.39 秒:v7讀取,2.41 秒:v17讀取,2.41 秒:v20讀取,2.41 秒:v0讀取,2.43 秒:v0讀取,2.43 秒:v0讀取,2.44 秒:v0讀取,2.45 秒:v5讀取,2.45 秒:v0讀取,2.46 秒:v9讀取,2.46 秒:v20讀取,2.47 秒:v7讀取,2.47 秒:v0,舊資料讀取,2.49 秒:v20讀取,2.50 秒:v14讀取,2.50 秒:v0讀取,2.51 秒:v18讀取,2.52 秒:v5讀取,2.54 秒:v0,舊資料讀取,2.54 秒:v5讀取,2.55 秒:v0讀取,2.56 秒:v18讀取,2.56 秒:v9讀取,2.58 秒:v5讀取,2.59 秒:v0讀取,2.59 秒:v0讀取,2.62 秒:v0讀取,2.63 秒:v0讀取,2.65 秒:v9讀取,2.65 秒:v18讀取,2.66 秒:v0讀回自己寫的,2.68 秒:v27讀取,2.69 秒:v17讀取,2.69 秒:v14讀取,2.69 秒:v5讀取,2.70 秒:v7讀取,2.70 秒:v23讀取,2.71 秒:v0讀取,2.71 秒:v26讀取,2.72 秒:v20讀取,2.72 秒:v0,舊資料讀取,2.72 秒:v22,舊資料讀取,2.73 秒:v23讀取,2.73 秒:v22,舊資料讀取,2.74 秒:v18讀取,2.74 秒:v18讀回自己寫的,2.75 秒:v22,舊資料讀回自己寫的,2.75 秒:v0,舊資料讀取,2.78 秒:v5讀取,2.78 秒:v27讀取,2.79 秒:v17
寫入確認 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 On = 4n = 16n = 64n = 256成長倍數:實測(理論)
同步:等最慢的一台O(log n)109174242311×2.8 (×4.0)
非同步:只等主資料庫O(1)2222×1.0 (×1.0)

同步寫入等的是 n 個隨機延遲裡最大的那個,它隨副本數像 log n 一樣慢慢變大;而複製訊息本身是每台一則,總共 O(n)。

和其他做法比

寫入 p50寫入 p99讀到舊資料讀不到自己剛寫的主資料庫承擔的讀取
非同步2.0 ms2.0 ms15.5%88%0%
非同步+讀自己寫的2.0 ms2.0 ms4.0%0%44%
半同步79.6 ms278 ms6.6%39%0%
同步184 ms538 ms0.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,落後太多就把讀取移開。