Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

關聯式資料庫與 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(進階):交易、並行控制與進階查詢〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。