MVCC、鎖等待與死鎖
資料庫怎麼讓好幾筆交易同時進行:MVCC(Multi-Version Concurrency Control,多版本並行控制)讓讀取不擋寫入,寫入彼此之間則靠鎖排隊;兩筆交易互相等對方的鎖,就是死鎖,只能犧牲其中一筆。
情境
用誰的快照看
1/11
T1 begin
版本鏈(以 T1 的快照來看)
A
- 100建立 T0
B
- 200建立 T0
看得到看不到中止的交易寫的
等待圖
- 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 都是這樣做的。
- 交易要以固定的方式拿鎖:每筆交易都依同樣的順序(例如依主鍵由小到大)鎖列,就不會互相等成環。
- 預期會被中止:死鎖和序列化失敗都會讓交易中止,程式要能整筆重試。
和其他主題的關係
- 由這些組成
- ACID 與交易隔離等級
時間與空間複雜度(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 O | n = 10 | n = 100 | n = 1,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 更新之前就開始的交易 | O(n) | 11 | 101 | 1,001 | ×91 (×100) |
| 更新之後才開始的交易 | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
新的快照第一個就找到最新版本;舊的快照要一路往回走過每一個它看不到的新版本。所以跑很久的交易不只卡住 vacuum,自己的讀取也越來越慢。
和其他做法比
| 完成 | 被擋住的時間(tick) | 死鎖中止 | 寫入衝突中止 | 留下的版本 | |
|---|---|---|---|---|---|
| 讀多(90% 讀) · MVCC | 38 / 40 | 17 | 0 | 2 | 71 |
| 讀多(90% 讀) · 兩階段鎖定 | 39 / 40 | 273 | 1 | 0 | 50 |
| 讀寫各半 · MVCC | 18 / 40 | 256 | 0 | 22 | 122 |
| 讀寫各半 · 兩階段鎖定 | 34 / 40 | 857 | 6 | 0 | 50 |
| 搶少數幾列 · MVCC | 36 / 40 | 25 | 1 | 3 | 27 |
| 搶少數幾列 · 兩階段鎖定 | 35 / 40 | 957 | 5 | 0 | 8 |
同一批隨機交易(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。