跳到主要內容

系統設計

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

主題 · MVCC、鎖等待與死鎖

MVCC、鎖等待與死鎖

資料庫怎麼讓好幾筆交易同時進行:MVCC(Multi-Version Concurrency Control,多版本並行控制)讓讀取不擋寫入,寫入彼此之間則靠鎖排隊;兩筆交易互相等對方的鎖,就是死鎖,只能犧牲其中一筆。

情境
用誰的快照看
1/11
T1 begin
版本鏈(以 T1 的快照來看)
A
  1. 100
    建立 T0
B
  1. 200
    建立 T0
看得到看不到中止的交易寫的
等待圖
T1T2T3
  • T1 · 進行中
  • T2 · 尚未開始
  • T3 · 尚未開始

T1 開始,拍下快照:之後整段期間都只看得到「現在之前已經提交」的資料。

模型照 PostgreSQL 的 REPEATABLE READ:每筆交易一張快照,版本記著建立它和刪除它的交易,只有寫入要拿列鎖,等待會形成環時就偵測為死鎖。T0 是寫入初始資料的交易。

亮起來的是這一步執行的程式碼
type Version = { value: number; createdBy: number; deletedBy: number | null };
type Snapshot = { xmax: number; active: number[] };
class MvccDb {
rows = new Map<string, Version[]>();
status = new Map<number, string>([[0, "committed"]]);
snapshots = new Map<number, Snapshot>();
locks = new Map<string, number>();
waitsFor = new Map<number, number>();
nextTx = 1;
constructor(initial: Record<string, number>) {
for (const [row, value] of Object.entries(initial)) this.rows.set(row, [{ value, createdBy: 0, deletedBy: null }]);
}
begin(): number {
const tx = this.nextTx++;
const active = [...this.status].filter(([, s]) => s === "active").map(([id]) => id);
this.status.set(tx, "active");
this.snapshots.set(tx, { xmax: tx, active });
return tx;
}
// Does tx see what "other" did? Its own work, or a commit before its snapshot.
sees(tx: number, other: number | null): boolean {
if (other === null) return false;
if (other === tx) return true;
const snap = this.snapshots.get(tx)!;
return this.status.get(other) === "committed" && other < snap.xmax && !snap.active.includes(other);
}
read(tx: number, row: string): number | null {
const versions = this.rows.get(row) ?? [];
for (let i = versions.length - 1; i >= 0; i--) {
const v = versions[i];
if (this.sees(tx, v.createdBy) && !this.sees(tx, v.deletedBy)) return v.value;
}
return null;
}
write(tx: number, row: string, value: number): string {
const holder = this.locks.get(row);
if (holder !== undefined && holder !== tx) {
for (let at: number | undefined = holder; at !== undefined; at = this.waitsFor.get(at)) {
if (at === tx) return "deadlock";
}
this.waitsFor.set(tx, holder);
return "wait";
}
this.waitsFor.delete(tx);
const versions = this.rows.get(row)!;
let live = versions.length - 1;
while (live >= 0 && this.status.get(versions[live].createdBy) === "aborted") live--;
const current = versions[live];
if (current.createdBy === tx) {
current.value = value;
return "ok";
}
if (!this.sees(tx, current.createdBy)) return "conflict";
this.locks.set(row, tx);
current.deletedBy = tx;
versions.push({ value, createdBy: tx, deletedBy: null });
return "ok";
}
finish(tx: number, status: string): number[] {
if (status === "aborted") {
for (const versions of this.rows.values())
for (const v of versions) if (v.deletedBy === tx) v.deletedBy = null;
}
this.status.set(tx, status);
const waiters = [...this.waitsFor].filter(([, h]) => h === tx).map(([w]) => w);
for (const [row, h] of [...this.locks]) if (h === tx) this.locks.delete(row);
this.waitsFor.delete(tx);
for (const w of waiters) this.waitsFor.delete(w);
return waiters;
}
vacuum(): number {
const active = [...this.status].filter(([, s]) => s === "active").map(([id]) => id);
let removed = 0;
for (const [row, versions] of this.rows) {
const kept = versions.filter((v) => {
if (this.status.get(v.createdBy) === "aborted") return false;
const d = v.deletedBy;
const dead = d !== null && this.status.get(d) === "committed" && active.every((t) => this.sees(t, d));
return !dead;
});
removed += versions.length - kept.length;
this.rows.set(row, kept);
}
return removed;
}
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 每交易一張快照,模擬 REPEATABLE READ/snapshot isolation、列鎖與 first updater wins;不等於 serializable。死鎖犧牲形成環的交易,未包含 predicate lock、完整 SQL、WAL 或真實 VACUUM I/O。

小挑戰

T1 的快照仍讀到 100,T2 已提交 150。保留已提交的新值,讓 VACUUM 能清掉舊版本。

清理前的動作
VACUUM 清掉的版本
0

調整選項,再檢查結果。

提示

VACUUM 必須保留任何進行中的快照還看得到的版本。

查看解答

結束長時間讀取的 T1,再執行 VACUUM。中止其他交易不會釋放 T1 的快照。

什麼時候用

  • 讀多寫少、而且不能讓報表擋住交易:MVCC 讓讀取完全不用等寫入,也不會擋寫入。PostgreSQL、MySQL InnoDB、Oracle 都是這樣做的。
  • 交易要以固定的方式拿鎖:每筆交易都依同樣的順序(例如依主鍵由小到大)鎖列,就不會互相等成環。
  • 預期會被中止:死鎖和序列化失敗都會讓交易中止,程式要能整筆重試。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
讀一列
v 是這一列還留著的版本數:舊快照要從最新的往回找
O(1)O(v)
寫一列
等鎖時要沿著等待鏈檢查會不會成環,t 是等待中的交易數
O(1)O(t)
Vacuum
V 是所有版本,a 是進行中的交易數
O(V)O(V·a)

空間:O(V),每次更新都多一個版本,要靠 vacuum 清掉

Big O 實測:n 變大時步數怎麼長

數的是:讀一列時檢查的版本數(這一列已經被更新 n 次、還沒 vacuum)

Big On = 10n = 100n = 1,000成長倍數:實測(理論)
更新之前就開始的交易O(n)111011,001×91 (×100)
更新之後才開始的交易O(1)111×1.0 (×1.0)

新的快照第一個就找到最新版本;舊的快照要一路往回走過每一個它看不到的新版本。所以跑很久的交易不只卡住 vacuum,自己的讀取也越來越慢。

和其他做法比

完成被擋住的時間(tick)死鎖中止寫入衝突中止留下的版本
讀多(90% 讀) · MVCC38 / 40170271
讀多(90% 讀) · 兩階段鎖定39 / 402731050
讀寫各半 · MVCC18 / 40256022122
讀寫各半 · 兩階段鎖定34 / 408576050
搶少數幾列 · MVCC36 / 40251327
搶少數幾列 · 兩階段鎖定35 / 40957508

同一批隨機交易(40 筆、每筆 6 個讀寫、最多 8 筆同時進行)跑兩次:MVCC 的讀取不加鎖;兩階段鎖定(2PL,Two-Phase Locking)的讀取要拿共享鎖、一直握到提交,所以讀寫會互相擋。讀多時 MVCC 幾乎不用等;寫多時 MVCC 改成用「先更新者勝」中止交易:等待少了,但要重試的變多。中止的交易只計數、不重試。

真實世界裡的它

  • PostgreSQL 每列記著 xmin(建立它的交易)和 xmax(刪除它的交易),和這裡的模型一樣;舊版本由 autovacuum 清掉。
  • MySQL InnoDB 把舊版本放在 undo log,讀取時沿著 undo 鏈還原;長交易會讓 undo log 一直長大。
  • 兩個資料庫都會自動偵測死鎖並中止其中一筆,錯誤訊息分別是 deadlock detected 和 Deadlock found when trying to get lock。

取捨與陷阱

  • 一筆忘了結束的交易(例如開著的 psql 視窗)會讓 vacuum 什麼都清不掉,資料表越長越大,讀取也越來越慢。
  • MVCC 只讓讀不擋寫;寫入彼此之間還是要排隊,也還是會死鎖。
  • 快照隔離不等於可序列化:兩筆交易各自讀對方要改的資料再各自寫入(write skew),兩邊都能成功卻違反規則;要用 SERIALIZABLE 或 SELECT … FOR UPDATE。