Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

關聯式資料庫與 SQL

關聯式資料庫與 SQL(進階):交易、並行控制與進階查詢

當千人同時搶最後一張票,資料庫如何在交錯執行中保持正確?深入交易隔離、MVCC、視窗函數與可序列化理論。

兩個人同時搶最後一張票,資料庫怎麼不出錯?

入門篇我們學會了用 SQL 把資料拆表、串外鍵、JOIN 回來。那是一個「只有你一個人」的世界。但真實系統從來不是這樣:選課系統開放的那一秒,可能有三千名學生同時對同一門課按下「加選」;社團報名只剩最後一個名額,兩個人在同一毫秒送出申請。如果資料庫只會老實地「先讀剩餘名額、判斷大於零、再寫回減一」,那麼兩個人都讀到「剩 1」、都判斷可以、都寫回「剩 0」——名額被超賣了,而且帳面上看起來完全正常。

這就是進階資料庫真正的戰場:並行(concurrency)。一旦多個交易(transaction)同時動到同一份資料,正確性就不再是「SQL 寫對」這麼簡單,而是「在交錯執行下,結果仍然像是某種合理的循序執行」。這篇文章要深入交易、隔離級別、多版本並行控制(MVCC),並順帶把入門篇沒談的進階 SQL——視窗函數與 CTE——一起補上。這些才是讓資料庫從「能用」走向「能在高負載下不出錯」的關鍵機制。

關聯式資料庫與 SQL進階概念示意圖

交易:要嘛全做,要嘛全不做

入門篇提過交易的 ACID 四性質,但只是點名。這裡我們要真正用起來。一個交易是一組被當成單一邏輯單位的 SQL 操作,由 BEGIN(或 START TRANSACTION)開始,以 COMMIT(確認)或 ROLLBACK(回滾)結束。

最經典的例子是轉帳:把甲帳戶扣 1000、乙帳戶加 1000。這兩件事必須同生共死,不能扣了甲卻沒加到乙:

START TRANSACTION;

UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';

COMMIT;  -- 兩行都成功才一起生效

如果第二行執行前系統當機,COMMIT 沒跑到,整個交易會被回滾,甲帳戶的扣款也跟著消失,彷彿什麼都沒發生。這就是原子性(atomicity):交易是不可分割的最小單位。配合持久性(durability)——一旦 COMMIT 回報成功,即使下一秒斷電,資料也保證寫入了——資料庫才能成為「可信賴」的儲存。

原子性與持久性背後的功臣是預寫日誌(Write-Ahead Logging, WAL):資料庫在真正修改資料頁之前,先把「我要做什麼變更」寫進一份循序的日誌檔。當機重啟後,資料庫重放(redo)已 COMMIT 但還沒落盤的變更、回滾(undo)沒 COMMIT 的變更,就能回到一致狀態。我們在開頭的轉帳能「彷彿沒發生」,靠的正是 undo log。

並行的四種惡夢:隔離不足會發生什麼

真正棘手的是隔離性(isolation)。理想上,每個交易都該覺得自己是世界上唯一的交易;但為了效能,資料庫允許交易交錯執行,於是會冒出各種異常(anomaly)。SQL 標準定義了四種典型異常,理解它們是理解隔離級別的前提。

髒讀(dirty read):交易 A 改了某列但還沒 COMMIT,交易 B 卻讀到了這個「未確認」的值。如果 A 隨後 ROLLBACK,B 就讀到了一個從未真正存在的幽靈資料。

不可重複讀(non-repeatable read):交易 A 在同一個交易內讀同一列兩次,中間交易 B 改掉並 COMMIT 了那列,於是 A 兩次讀到不同的值——明明是同一個交易,世界卻在腳下變了。

幻讀(phantom read):交易 A 用某條件查了一批列(例如「所有餘額 > 500 的帳戶」),交易 B 插入了一筆符合條件的新列並 COMMIT,A 再查一次同樣的條件,竟多出一列「幽靈」。差別在於:不可重複讀是既有的列「值變了」,幻讀是「列數變了」。

遺失更新(lost update):就是開頭搶票的情境。兩個交易都讀到「剩 1」,各自算出「剩 0」再寫回,後寫的覆蓋了先寫的,其中一次的扣減憑空消失。

四個隔離級別:在正確與速度之間選一個刻度

為了讓開發者依需求取捨,SQL 標準定義了四個隔離級別(isolation level),由鬆到嚴,能擋掉的異常逐漸增加:

隔離級別 髒讀 不可重複讀 幻讀
讀未提交(Read Uncommitted) 可能 可能 可能
讀已提交(Read Committed) 擋掉 可能 可能
可重複讀(Repeatable Read) 擋掉 擋掉 可能*
可序列化(Serializable) 擋掉 擋掉 擋掉

級別愈高,正確性愈強,但並行度(吞吐量)通常愈低。設定方式很直接:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- ... 你的查詢與更新 ...
COMMIT;

表中「可重複讀」那一格我標了星號,因為這裡藏著一個常見迷思值得拆解。SQL 標準說可重複讀「允許幻讀」,但 MySQL 的 InnoDB 引擎在可重複讀級別下,透過 next-key locking 實際上幾乎擋住了幻讀;而 PostgreSQL 的可重複讀(其實是快照隔離 snapshot isolation)也不會出現標準定義的幻讀。不同資料庫對同一個級別名稱的實作行為不同——這是工程師踩過無數次的雷。不要背「哪個級別擋哪個異常」的標準表格就以為通用,要查你實際用的那套資料庫的文件。

各家的預設級別也不同:MySQL/InnoDB 預設是可重複讀,PostgreSQL、SQL Server、Oracle 多半是讀已提交。預設值會直接影響你程式的正確性,這是上線前必須確認的一件事。

MVCC:不靠鎖也能讀到一致的世界

早期資料庫用鎖(lock)來實現隔離:讀的時候上共享鎖、寫的時候上排他鎖,互相排隊。問題是「讀」會擋住「寫」、「寫」會擋住「讀」,並行度被嚴重壓縮。現代主流資料庫(PostgreSQL、MySQL/InnoDB、Oracle)改用更聰明的設計:多版本並行控制(Multi-Version Concurrency Control, MVCC)

核心想法是:不要覆蓋舊資料,而是保留多個版本。每一列都帶有版本資訊(例如建立它的交易 ID xmin 與刪除它的交易 ID xmax)。當交易 A 更新一列,資料庫不是直接改原地,而是產生一個新版本,舊版本仍保留。每個交易在開始時拿到一個快照(snapshot),它只看得見「在我這個快照時間點之前已 COMMIT 的版本」。

這帶來一句金句般的性質:讀不擋寫,寫不擋讀(readers don't block writers, writers don't block readers)。交易 A 在讀某列的舊版本時,交易 B 完全可以同時寫出新版本,兩者井水不犯河水。讀操作幾乎永遠不必等待,這對「讀多寫少」的應用(絕大多數網站都是)是巨大的效能勝利。

當然天下沒有白吃的午餐。舊版本會堆積,必須有機制回收沒有任何交易再需要的「死版本」——在 PostgreSQL 叫 VACUUM,在 InnoDB 叫 purge。回收不及時會導致表膨脹(table bloat),這是 MVCC 系統運維上的經典課題。

動手算一下:搶票為什麼還是會超賣

回到開頭的搶票。假設 tickets 表只剩 1 張,兩個交易這樣寫:

-- 交易 1 與交易 2 各自執行(讀已提交級別)
START TRANSACTION;
SELECT remaining FROM tickets WHERE event_id = 1;   -- 兩者都讀到 remaining = 1
-- 應用程式判斷 remaining > 0,決定可以賣
UPDATE tickets SET remaining = remaining - 1 WHERE event_id = 1;
COMMIT;

在讀已提交甚至可重複讀的快照下,兩個交易的 SELECT 都讀到 remaining = 1,應用層都判斷「還有票」。雖然兩次 UPDATE 因為用了 remaining - 1 的相對寫法不會把值寫成 0 兩次(最終會是 -1),但業務上你已經賣出兩張只剩一張的票了。問題不在 SQL 寫錯,而在「讀」與「決策」之間的窗口被另一個交易插隊。

修法有幾種。最直接的是在讀的當下就上鎖,讓第二個交易排隊:

START TRANSACTION;
-- FOR UPDATE 對選中的列加排他鎖,第二個交易會卡在這行直到第一個 COMMIT
SELECT remaining FROM tickets WHERE event_id = 1 FOR UPDATE;
UPDATE tickets SET remaining = remaining - 1 WHERE event_id = 1;
COMMIT;

SELECT ... FOR UPDATE悲觀鎖(pessimistic locking):先假設一定會衝突,搶先上鎖。另一種是樂觀鎖(optimistic locking):先不鎖,更新時用條件確保值沒被別人動過:

-- 只有在 remaining 仍 >= 1 時才扣,並回報實際影響的列數
UPDATE tickets SET remaining = remaining - 1
WHERE event_id = 1 AND remaining >= 1;
-- 若影響列數為 0,代表票被搶光了,應用層據此回報失敗

這個查詢把「檢查」和「扣減」合併成一個原子操作,靠的是資料庫對單一 UPDATE 的列級鎖。影響列數(affected rows)是否為 1,就是這次有沒有搶到的判準。哪種好?衝突頻繁用悲觀鎖、衝突罕見用樂觀鎖,這是並行設計的核心權衡。

進階 SQL:視窗函數讓你不必把表 JOIN 自己

並行之外,進階 SQL 本身也大有學問。入門篇的 GROUP BY 會「把每一組壓成一列」,但很多分析需求是「保留每一列,同時看到它在群組中的位置」——這時 GROUP BY 就力不從心。視窗函數(window function) 正是為此而生。

假設要為每個系所內的學生依成績排名,同時保留每位學生的原始資料:

SELECT
    name,
    department,
    score,
    RANK() OVER (PARTITION BY department ORDER BY score DESC) AS dept_rank
FROM students;

關鍵是 OVER (...) 子句,它定義了一個「視窗」:PARTITION BY department 把資料切成一個個系所群組,ORDER BY score DESC 在每組內排序,RANK() 給出排名。和 GROUP BY 最大的差別是:列數不變——每位學生都留著,只是多了一欄排名。常用的視窗函數還有 ROW_NUMBER()(不重複的流水號)、DENSE_RANK()(並列不跳號)、以及 LAG()LEAD()(取前一列/後一列的值,做時序差分超好用)。

-- 計算每位學生比同系前一名(成績較高者)低多少分
SELECT
    name, department, score,
    score - LAG(score) OVER (PARTITION BY department ORDER BY score DESC) AS gap_to_next
FROM students;

看一個例子:用 CTE 把複雜查詢拆成可讀的步驟

當查詢愈來愈複雜,巢狀子查詢會讓人讀到頭痛。公共表達式(Common Table Expression, CTE)WITH 把中間步驟命名,像寫程式定義變數一樣,讓查詢由上而下一步步推進。

假設要找出「每個系所中成績最高的那位學生」:

WITH ranked AS (
    SELECT
        name, department, score,
        ROW_NUMBER() OVER (PARTITION BY department ORDER BY score DESC) AS rn
    FROM students
)
SELECT name, department, score
FROM ranked
WHERE rn = 1;

第一步 ranked 為每位學生算出系內排名,第二步只留排名第一者。如果不用 CTE 與視窗函數,這個「每組取最大」的需求要寫成令人費解的相關子查詢(correlated subquery)。CTE 把邏輯攤平成可讀的階段,這對團隊維護是巨大的價值。

CTE 還能遞迴(recursive),這讓 SQL 有能力處理樹狀與圖狀結構。例如查詢某主題下的所有子主題(包含子主題的子主題),這在純粹的 JOIN 裡是辦不到的:

WITH RECURSIVE subtree AS (
    SELECT id, name, parent_id FROM categories WHERE id = 10   -- 起點(錨點)
    UNION ALL
    SELECT c.id, c.name, c.parent_id
    FROM categories c
    INNER JOIN subtree s ON c.parent_id = s.id                 -- 遞迴往下展開
)
SELECT * FROM subtree;

錨點查詢給出起點,遞迴部分不斷把「子節點」併入結果,直到沒有新節點為止。一句 SQL 就走完整棵子樹。

重點回顧

  • 交易是要嘛全做要嘛全不做的單位,原子性與持久性靠預寫日誌(WAL)的 redo/undo 在當機後復原。
  • 並行不足會出現髒讀、不可重複讀、幻讀、遺失更新四種異常;SQL 的四個隔離級別由鬆到嚴逐一擋掉它們,但各家資料庫對同名級別的實作行為不同,且預設級別不一,上線前務必確認。
  • MVCC 用多版本與快照實現「讀不擋寫、寫不擋讀」,代價是死版本需靠 VACUUM/purge 回收,否則表膨脹。
  • 並行衝突可用 FOR UPDATE悲觀鎖)或條件式 UPDATE + 影響列數(樂觀鎖)解決,依衝突頻率選擇。
  • 視窗函數保留列數做組內排名與時序差分,CTE(含遞迴)讓複雜與樹狀查詢變得可讀。

深入探討(研究所視角)

隔離級別的標準表格只是工程上的近似刻度,背後真正嚴謹的理論是可序列化(serializability)。給定一群並行交易的交錯執行(稱為一個 schedule),若其結果等價於「這些交易以某個順序逐一循序執行」的結果,這個 schedule 就是可序列化的——這是並行正確性的黃金標準。最常用的判定工具是衝突可序列化(conflict serializability):建構一張優先圖(precedence graph),節點是交易,當交易 $T_i$ 的某操作與 $T_j$ 的操作衝突(兩者存取同一資料項且至少一個是寫)且 $T_i$ 在前,就畫一條 $T_i \to T_j$ 的邊。該圖無環,當且僅當 schedule 是衝突可序列化的。判斷有無環只需一次拓樸排序,複雜度 $O(V + E)$。

實作可序列化有兩大流派。兩階段鎖定(Two-Phase Locking, 2PL) 規定每個交易分成「成長期」(只能取鎖)與「收縮期」(只能放鎖),一旦放了第一把鎖就不能再取——可以證明遵守 2PL 的 schedule 必為衝突可序列化。但 2PL 會引發死結(deadlock):交易 A 等 B 的鎖、B 又等 A 的鎖,互相卡死。資料庫用「等待圖(wait-for graph)找環」偵測死結,再挑一個交易當犧牲者回滾打破僵局,或用逾時(timeout)粗暴解決。嚴格 2PL(鎖全部留到 COMMIT 才放)還能避免級聯回滾(cascading rollback),是多數鎖式系統的實作選擇。

另一流派是 MVCC 衍生的快照隔離(Snapshot Isolation, SI)。SI 讓每個交易讀自己開始時的快照、寫時用「先寫者贏(first-committer-wins)」解決衝突,效能極佳,但它並非真正可序列化——它會放過一種微妙的異常叫寫偏斜(write skew)。經典例子:醫院規定「至少要有一位醫師值班」,兩位值班醫師 A、B 各開一個交易,都讀到「現在有兩人值班,我請假還剩一人」,於是各自把自己設為休假。兩個交易讀的是各自快照、寫的是不同列(沒有寫寫衝突),SI 全部放行,結果變成零人值班——一個任何循序執行都不可能產生的非法狀態。為了補上這個漏洞,PostgreSQL 實作了可序列化快照隔離(Serializable Snapshot Isolation, SSI):在 SI 之上追蹤交易間的讀寫依賴,偵測到可能破壞可序列化的「危險結構」(兩條反向的 rw 依賴邊)時,主動中止其中一個交易。SSI 讓使用者享受 MVCC 的高並行,又拿回真正的可序列化保證,是過去二十年資料庫並行控制最漂亮的理論落地之一。

這些並行控制機制最終都服務於同一個目標:在不可避免的交錯執行中,給開發者一個「彷彿一切都是循序發生」的乾淨抽象。當系統規模再往上,單機的鎖與 MVCC 不夠用時,問題就延伸到分散式交易的兩階段提交(2PC)、共識協定(Raft/Paxos),以及 CAP 定理下一致性與可用性的取捨——但無論架構多複雜,「可序列化」始終是衡量正確性的那把尺。理解了從異常、隔離級別、MVCC 到可序列化理論這條線,你看待任何資料庫的並行行為,都會從「背規則」升級為「懂原理」。

AI 共讀助教正在陪你讀:關聯式資料庫與 SQL(進階):交易、並行控制與進階查詢
嗨!我是這篇文章的共讀助教,只根據〈關聯式資料庫與 SQL(進階):交易、並行控制與進階查詢〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。