資料庫效能與維運 ch05 鎖的完整體系與死鎖:兩支批次同時跑就互咬
下一章→
CH 05 看不見的等待

鎖的完整體系與死鎖:兩支批次同時跑就互咬

表鎖與 MDL意向鎖記錄鎖/間隙鎖/臨鍵鎖鎖是加在索引上的隔離等級如何改變加鎖鎖等待 vs 死鎖SHOW ENGINE INNODB STATUS 判讀

前面四章處理的都是「一句 SQL 自己有多貴」。這一章要處理的是另一種完全不同的慢:

這句 SQL 一點都不慢,它只是在等別人。

一句 type=const、rows=1 的 UPDATE,可以卡 40 秒。EXPLAIN 上完美無缺,EXPLAIN ANALYZE 也看不出來(它測的是執行時間,不是等待時間)。前四章教的所有工具,在這一章通通失效——因為問題不在這句 SQL 身上,在另一個交易身上。

而鎖的問題還有兩個讓它特別難處理的性質:

  • 它是間歇性的。同樣的程式碼,併發低的時候完全正常,尖峰時才爆炸。測試環境幾乎不可能重現。
  • 它的成因通常在別的地方。你看到超時的是 A,但真正該修的是那個跑了三分鐘還沒提交的 B。

這一章要建立的是一套完整的心智模型:

  • 鎖的種類:表鎖、MDL、意向鎖、記錄鎖、間隙鎖、臨鍵鎖、插入意向鎖——各自鎖住什麼。
  • 最重要的一條原理:InnoDB 的行鎖是加在索引上的。所以一句沒走索引的 UPDATE,實質上會鎖住整張表。
  • 隔離等級怎麼改變加鎖行為:RR 與 RC 的差別,遠不只是「會不會幻讀」。
  • 鎖等待與死鎖的差別:1205 和 1213 這兩個錯誤碼,指向完全不同的根因與修法。
  • 一個真實場景:兩支批次各自跑都正常,同時跑就互相 deadlock——而它們更新的是同一張表的不同欄位。

交易與 MVCC 的基礎(快照讀 vs 當前讀、隔離等級解決了什麼)在 Java 後端底層 ch02 已經講過,這一章假設你知道那些,專心處理「鎖到底加在哪裡、為什麼會撞在一起、以及怎麼查」。

如果這一章只能記一句話,就是這句。它解釋了鎖問題裡至少八成的意外。

InnoDB 沒有「鎖住某一列資料」這種東西。它鎖的是索引項。所以「這句 SQL 會鎖住什麼」的答案,完全取決於它走了哪個索引、掃過哪些索引項——而不是取決於它最後改了幾列。

推導一次後果

-- memo 上沒有索引
UPDATE orders SET status = 2 WHERE memo = 'X';
-- 實際只有 1 列符合
1
沒有索引可走 → 全表掃描(type=ALL)。
2
掃描過程中,InnoDB 對掃過的每一個聚簇索引項都加鎖——因為它必須先鎖住才能判斷這列符不符合條件。
3
結果:只改 1 列,卻鎖住了整張表的所有列。在這個交易提交之前,其他人幾乎動不了這張表。

所以那句常見的建議「更新語句一定要走索引」,理由不只是快——而是不走索引會把行鎖放大成事實上的表鎖。這是「加索引」在效能之外的第二個理由,而且往往更重要。

REPEATABLE READ 下更糟一點

MySQL 預設的 RR 隔離等級下,掃過的列即使不符合條件也不會馬上放鎖(要到交易結束)。RC 隔離等級則會把不匹配的列提前釋放(semi-consistent read)——這是很多線上系統選擇 RC 的真正原因,不是為了效能,是為了減少鎖衝突。

推論二:走哪個索引,鎖就加在哪

-- idx_merchant (merchant_id),主鍵 id
UPDATE orders SET amount = 0 WHERE merchant_id = 42;

這句會加兩層鎖:先鎖二級索引 idx_merchant 上符合的索引項,再回表鎖對應的聚簇索引記錄。記住這個「兩層、有順序」的細節——它是本章那個死鎖案例的關鍵。

怎麼確認一句 SQL 到底鎖了什麼

-- MySQL 8.0:在交易還沒提交時,直接查目前持有的鎖
SELECT object_name, index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks;

這張表是 8.0 的重要改進(取代了舊的 information_schema.innodb_locks)。不要用推理的,直接查——尤其在寫需要加鎖的批次之前,先在測試環境把它列出來看一次。

先把表級的鎖釐清,因為其中一種是線上事故的常客,而它常常不被當成「鎖」。

① 表鎖(LOCK TABLES)

InnoDB 幾乎用不到,日常可以忽略。真正該注意的是被迫升級成表鎖的行鎖——也就是上一節那個「沒走索引」的情況。

② 意向鎖(IS/IX)

這是一個純粹為了效率而存在的機制,理解它就不會被 performance_schema 裡那堆 IX 嚇到:

假設有人要對整張表加表鎖,它必須確認「表裡沒有任何行鎖」。如果要逐列檢查,那是 O(n)。所以 InnoDB 規定:任何交易要加行鎖之前,必須先在表上加一個意向鎖當旗標。於是想加表鎖的人只要看一眼表上有沒有 IX 就知道了。

  • 意向鎖之間永遠相容(IS/IX 互不衝突),所以它幾乎不會造成等待。
  • 看到 lock_type=TABLE, lock_mode=IX 是正常的,不是問題。

③ MDL(Metadata Lock)——這個才是麻煩

MDL 保護的是表結構,不是資料。規則很簡單:

  • 任何 CRUD 會取得 MDL 讀鎖(彼此相容)。
  • 任何 DDL(ALTER、ANALYZE、加索引)會取得 MDL 寫鎖(與所有人互斥)。
而 MDL 的等待佇列是 FIFO 的——這是關鍵。一個 DDL 因為某個長交易而拿不到 MDL 寫鎖時,它會排隊;而排在它後面的所有新查詢(連 SELECT)也全部跟著排隊,即使那些查詢跟長交易毫無衝突。

結果就是最經典的線上事故:執行了一個「應該很快」的 DDL,整張表瞬間完全不能用。
-- 事故現場長這樣
SHOW PROCESSLIST;
-- id  time  state
-- 101  600  (一個沒提交的交易,可能只是忘了 commit)
-- 202   58  Waiting for table metadata lock   ← ALTER 在排隊
-- 203   57  Waiting for table metadata lock   ← 之後的 SELECT 全部卡住
-- 204   57  Waiting for table metadata lock
-- ...

防禦手法

-- 讓 DDL 等不到就放棄,而不是無限排隊擋住後面所有人
SET SESSION lock_wait_timeout = 5;
ALTER TABLE orders ...;

-- 執行任何 DDL 之前先看有沒有長交易
SELECT * FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10\G

ch02 提過 ANALYZE TABLE 也要 MDL,就是這個機制。DDL 的完整處理方式(線上 DDL 演算法、gh-ost)留到 ch06。

行級鎖有四種,它們的差別在於鎖住的是「記錄」還是「記錄之間的空隙」。

① Record Lock(記錄鎖)

只鎖住一筆索引記錄本身。它擋不住「在它旁邊插入一筆新資料」。

② Gap Lock(間隙鎖)

鎖住兩筆記錄之間的空隙,不鎖記錄本身。它存在的唯一目的是防止幻讀——不讓別人在這個範圍裡插入新資料。

  • 間隙鎖之間互相相容:兩個交易可以同時持有同一個間隙的鎖。它只擋插入,不擋其他間隙鎖。
  • 只在 REPEATABLE READ 存在。RC 隔離等級沒有間隙鎖——這是兩者最大的差別。

③ Next-Key Lock(臨鍵鎖)

記錄鎖 + 它前面的間隙,是左開右閉的區間 (前一筆, 這一筆]。RR 下的預設加鎖單位就是它,所以你以為只鎖一列,實際上連前面那段空隙一起鎖了。

④ Insert Intention Lock(插入意向鎖)

一種特殊的間隙鎖,代表「我想在這個間隙插入資料」。多個交易想在同一個間隙的不同位置插入時互相相容——但只要那個間隙上有別人的間隙鎖,插入就要等。這是插入類死鎖的主角,下面會看到。

臨鍵鎖的三種退化(實務上最需要記的)

InnoDB 會盡量把鎖縮小,規則是:

情境加什麼鎖為什麼
唯一索引 + 等值 + 命中退化成 Record Lock唯一索引保證不會有第二筆,不需要防插入
唯一索引 + 等值 + 沒命中退化成 Gap Lock要防止別人插入這個值——所以「查不到也會鎖」
非唯一索引 + 等值Next-Key Lock + 下一個間隙可能有多筆相同值,範圍兩側都要防
範圍查詢掃過的每個區間都是 Next-Key Lock範圍內都不准插入
第二列是最反直覺、也最常造成意外的一條:SELECT ... WHERE id = 999 FOR UPDATE,即使 999 不存在,也會鎖住一段間隙。「先查有沒有,沒有就插入」這種寫法,正是靠這個機制在高併發下互咬的。

怎麼看到這些鎖

SELECT index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks WHERE object_name = 'orders';

-- lock_mode 的讀法:
--   X,REC_NOT_GAP    純記錄鎖
--   X,GAP            純間隙鎖
--   X                臨鍵鎖(記錄 + 前面的間隙)
--   X,INSERT_INTENTION  插入意向鎖

隔離等級的教科書說法是「解決髒讀/不可重複讀/幻讀」。但在維運的視角,它真正的差別是加鎖範圍——而這決定了系統在高併發下會不會互咬。

REPEATABLE READ(MySQL 預設)READ COMMITTED
間隙鎖有沒有(只有記錄鎖)
掃到但不符合的列鎖到交易結束提前釋放
幻讀不會會
鎖衝突機率較高較低
死鎖機率較高(間隙鎖交錯)較低

為什麼很多線上系統改用 RC

不是因為效能,是因為鎖範圍小得多:沒有間隙鎖,而且掃過的不匹配列會提前放掉。前面第一節那個「沒走索引的 UPDATE 鎖住整張表」,在 RC 下衝擊會小很多(掃過即放,只留下真的要改的那列)。

但這是一個有代價的決定,而且代價不在效能上:RC 會有幻讀,而且binlog 必須是 ROW 格式(RC + STATEMENT 格式會造成主從資料不一致)。改隔離等級是影響整個實例語意的決定,不是效能旋鈕——要改,是在系統設計階段改,不是在故障當下改。

快照讀與當前讀(回扣 backend ch02)

哪些語句會加鎖,是很多人搞混的地方:

  • 快照讀(不加鎖):普通的 SELECT。它讀的是 MVCC 快照,所以「讀不會擋住寫、寫不會擋住讀」。
  • 當前讀(加鎖):SELECT ... FOR UPDATE、SELECT ... FOR SHARE、以及所有的 UPDATE/DELETE/INSERT。它們必須讀到最新版本,所以要加鎖。
-- 8.0 新增:拿不到鎖就立刻失敗/跳過,不要傻等 50 秒
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;
SELECT * FROM orders WHERE status = 0 FOR UPDATE SKIP LOCKED;

SKIP LOCKED 特別有用:用資料庫做任務佇列時,多個 worker 各自撈自己的任務、跳過別人正在處理的,不需要外部的分散式鎖。

這兩個錯誤看起來都是「鎖的問題」,但它們的成因與修法幾乎完全相反。看到錯誤碼就要能分辨方向。

ERROR 1205:Lock wait timeout exceeded

  • 意思:我等一個鎖等超過 innodb_lock_wait_timeout(預設 50 秒)。
  • 根因:有人長時間持有鎖不放——通常是一個交易開太久(裡面夾了 HTTP 呼叫、檔案處理、或忘了提交)。
  • 要找的是「誰持有」,不是「誰在等」。
-- 誰擋住誰,一句就看得出來(8.0)
SELECT * FROM sys.innodb_lock_waits\G
-- waiting_pid / waiting_query   誰在等
-- blocking_pid / blocking_query 誰擋著   ← 要處理的是這個

-- 找出開很久的交易
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;
最難查的一種 1205:trx_query 是空的。那代表這個交易「開著但目前沒在跑 SQL」——它拿著鎖,卻在等應用程式做別的事(呼叫外部 API、寫檔案、甚至等使用者輸入)。資料庫這邊完全看不到它在幹嘛,只知道它握著鎖。這是「交易裡不要做 I/O」這條原則的具體理由。

ERROR 1213:Deadlock found

  • 意思:兩個交易互相持有對方要的鎖,形成環。
  • 根因:加鎖順序不一致——與交易長短無關,再短的交易只要順序相反就會死鎖。
  • InnoDB 會用 wait-for graph 主動偵測,選一個「回滾成本較小」的交易犧牲掉,另一個立刻繼續。
關鍵差別:1205 是「等太久」,代表有人卡著不放 → 去找長交易;1213 是「互相環繞」,代表加鎖順序有問題 → 去統一順序。
還有一個實務差別:1213 是可以重試的(另一方已經成功了,重跑通常就過),1205 直接重試多半只是再等 50 秒。

看死鎖現場

SHOW ENGINE INNODB STATUS\G
-- 找 LATEST DETECTED DEADLOCK 這一段:
--   *** (1) TRANSACTION:   誰、在等什麼鎖、SQL 是什麼
--   *** (2) TRANSACTION:   另一個、它持有什麼、又在等什麼
--   *** WE ROLL BACK TRANSACTION (2)   誰被犧牲
這個指令只保留最後一次死鎖。下一次死鎖發生就覆蓋掉了,所以事後追查常常撲空。正式環境一定要先開這個:
SET GLOBAL innodb_print_all_deadlocks = ON;   -- 每次死鎖都寫進 error log
它幾乎沒有成本,但沒開的話,你只能等下一次事故發生時剛好有人在線上。

死鎖偵測本身也有代價

innodb_deadlock_detect 預設開啟。它在每次等待時檢查 wait-for graph,而當大量交易搶同一列時(熱點更新),這個檢查是 O(n²),可能吃掉大量 CPU——症狀是「CPU 很高但每句 SQL 都不慢」(回扣 ch01 的盲點清單)。可以關掉改靠超時,但那是極端場景的選擇,而且關掉之後死鎖會變成 50 秒的卡死,通常更糟。

徵狀

兩支排程批次,各自跑了半年都沒事。某次排程時間被調整後,變成每晚同時跑——從此每晚都有幾十筆 DeadlockLoserDataAccessException,資料處理到一半中斷。

  • 兩支批次更新的是同一張表,但不同欄位,業務上完全不相干。
  • 單獨跑任何一支都正常,重跑也正常。
  • 錯誤是 ERROR 1213(死鎖),不是 1205。
// 批次 A:對帳,依訂單 id 逐批更新
@Transactional
public void reconcile(List<Long> ids) {
    orderRepo.markReconciled(ids);   // UPDATE orders SET reconciled=1 WHERE id IN (...)
}

// 批次 B:結算,依商家逐個更新
@Transactional
public void settle(Long merchantId) {
    orderRepo.applyFee(merchantId);  // UPDATE orders SET fee=... WHERE merchant_id=?
}

診斷

先看死鎖現場(幸好有開 innodb_print_all_deadlocks):

*** (1) TRANSACTION: UPDATE orders SET reconciled=1 WHERE id IN (...)
    WAITING FOR: index PRIMARY of table `shop`.`orders`  lock_mode X locks rec but not gap

*** (2) TRANSACTION: UPDATE orders SET fee=... WHERE merchant_id=8821
    HOLDS THE LOCK(S): index PRIMARY of table `shop`.`orders`
    WAITING FOR: index idx_merchant of table `shop`.`orders`

兩邊都在 orders 上,但一個等 PRIMARY、一個等 idx_merchant。線索就是這個「兩個不同的索引」——回到第一節那條原理:鎖是加在索引上的,而走不同索引就意味著不同的加鎖順序。

根因:加鎖順序相反

1
批次 A 用 WHERE id IN (...) → 走主鍵 → 直接鎖聚簇索引記錄,順序是 id 遞增。
2
批次 B 用 WHERE merchant_id = ? → 走二級索引 → 先鎖 idx_merchant 的索引項,再回表鎖聚簇索引記錄。
3
於是對同一筆訂單:A 的順序是「聚簇索引 →(不需要二級索引)」,B 的順序是「二級索引 → 聚簇索引」。當 A 已經拿到某筆的聚簇索引鎖、B 已經拿到另一筆的二級索引鎖,而兩者接下來要的正好是對方手上的——環就成形了。
兩支批次「更新不同欄位」完全不構成保護。InnoDB 的鎖是列層級的,不是欄位層級——只要碰到同一列,就是同一把鎖。業務上不相干,在鎖的世界裡毫無意義。

為什麼半年都沒事

因為死鎖需要「時間窗重疊」。排程時間錯開時,兩者的加鎖順序再怎麼相反也撞不到一起。這正是鎖問題最麻煩的性質:程式碼裡的缺陷可以潛伏很久,直到某個與程式碼無關的改動(排程時間、資料量、機器變快)把它引爆。

修法(依序)

1
止血:加重試。死鎖是可重試的錯誤(另一方已經成功),Spring 用 @Retryable(retryFor = DeadlockLoserDataAccessException.class) 配退避即可——但這只是讓它不要噴錯,衝突還在。
2
治本:統一加鎖順序。讓兩支批次都以主鍵遞增的順序加鎖:批次 B 改成先 SELECT id FROM orders WHERE merchant_id = ? ORDER BY id 取出主鍵,再用 WHERE id IN (...) 更新。只要所有人都照同一個順序拿鎖,死鎖在數學上就不可能發生。
3
縮小交易:把「一次更新五萬列」拆成「每批 500 列、各自提交」。交易越短,時間窗越小,撞上的機率越低——而且單次回滾的代價也小。
4
錯開時間:這是這次事故的直接誘因,但不要把它當成解法——它只是把炸彈推回去,下次排程調整時會再爆一次。

這個案例的教訓

死鎖不是「併發太高」造成的,是「加鎖順序不一致」造成的。降低併發只是降低撞上的機率,統一順序才是消除它。而順序這件事,看 SQL 是看不出來的——要看它走哪個索引。

把實務上最常見的幾種收在一起。這些在 Code Review 時看得出來,比事後追查便宜太多。

① 交易裡做 I/O

@Transactional
public void pay(Long orderId) {
    Order o = repo.findByIdForUpdate(orderId);   // 拿到行鎖
    paymentGateway.charge(o);                    // ← 呼叫外部 API,2 秒
    o.setStatus(PAID);
}   // 到這裡才放鎖

那 2 秒鐘,這一列完全不能被別人動。而且外部服務一慢,鎖就跟著持有更久——把別人的延遲變成自己的鎖等待。這也正是上面說的「trx_query 是空的」那種最難查的 1205。

② 先查再插(check-then-insert)

if (repo.findByCode(code) == null) {   // 沒查到 → 唯一索引等值未命中 → 加了 Gap Lock
    repo.insert(...);                   // 插入意向鎖,要等別人的間隙鎖
}

兩個交易同時做這件事,就會互相等對方的間隙鎖——經典的插入死鎖。正解是讓資料庫保證唯一性:建唯一索引,直接 INSERT ... ON DUPLICATE KEY UPDATE 或捕捉唯一鍵衝突,不要在應用層先查。

③ 用 SELECT ... FOR UPDATE 當分散式鎖

可以用,但它的生命週期綁在交易上——交易多長,鎖就多長。一旦有人在這個交易裡多做了一件事(見①),鎖範圍就被動擴大。需要跨服務的互斥時,用真正的分散式鎖(Redis/ZooKeeper),語意清楚得多。

④ 沒走索引的 UPDATE / DELETE

第一節那條。批次刪除特別容易中:DELETE FROM logs WHERE created_at < ?,如果 created_at 沒索引,就是掃全表加全表的鎖。

⑤ 大交易

@Transactional
public void importAll(List<Row> rows) {   // 5 萬列在同一個交易裡
    rows.forEach(this::insertOne);
}

三個代價一次付清:鎖持有很久、undo log 爆炸(其他交易的快照讀要沿著它回溯,全體變慢)、回滾很慢(失敗時可能回滾比執行還久)。拆批提交幾乎永遠是對的。

共同點:這五種都不是 SQL 寫得不好,是交易邊界畫得不好。而交易邊界在 Spring 專案裡是一個 @Transactional 註解決定的——它太容易加、也太容易忘記它的代價。(@Transactional 的傳播行為與失效陷阱見 backend ch07。)

鎖問題與前四章最大的不同是:它必須在「案發當下」抓現場,事後多半查不到。所以第一件事是把觀測手段先準備好。

先做的三件事(平常就要做)

-- ① 每次死鎖都留紀錄(不然只看得到最後一次)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- ② 別讓鎖等待卡滿 50 秒(依業務調整,通常 5~10 秒就該失敗)
SET GLOBAL innodb_lock_wait_timeout = 10;

-- ③ 長交易告警:超過 N 秒的交易直接報出來
SELECT trx_mysql_thread_id, trx_started, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10;

案發時的排查順序

1
看錯誤碼分方向:1205 → 找長交易(誰持有);1213 → 找加鎖順序(走了哪些索引)。
2
1205 就查 sys.innodb_lock_waits,直接看 blocking_query 是誰。若 trx_query 為空,去應用端找那個交易裡在做什麼 I/O。
3
1213 就看 LATEST DETECTED DEADLOCK(或 error log),重點看兩邊各自等的是哪個 index——不同索引通常就是順序不一致的證據。
4
確認鎖範圍有沒有被放大:那句 SQL 走索引了嗎?EXPLAIN 一下。沒走索引的 UPDATE 就是事實上的表鎖。
5
最後才考慮調參數或改隔離等級——那是影響全實例的決定,不該是故障當下的第一反應。

什麼時候不該用鎖解決問題

  • 純粹的計數/統計:用 UPDATE ... SET n = n + 1(原子更新)就好,不要 SELECT FOR UPDATE 讀出來再寫回去——後者多鎖了一段時間,還多一次往返。
  • 寫入衝突很少的場景:用樂觀鎖(版本號欄位 + WHERE version = ?,更新 0 列就重試)。它完全不加額外的鎖,衝突多時才會退化。JPA 的 @Version 就是這個。
  • 只是要避免重複執行:用唯一索引讓資料庫擋掉,比用鎖去協調便宜且可靠得多。
悲觀鎖(FOR UPDATE)適合「衝突頻繁、且失敗代價高」的場景;樂觀鎖適合「衝突罕見」的場景。選錯的症狀很明顯:悲觀鎖用在低衝突場景=白付等待成本;樂觀鎖用在高衝突場景=重試風暴。

把這一章收成三句

① 行鎖加在索引上,所以「鎖了什麼」取決於走了哪個索引,不是取決於改了幾列。
② 1205 是有人卡著不放(找長交易),1213 是加鎖順序不一致(統一順序)——方向完全不同。
③ 鎖問題必須抓現場,所以 innodb_print_all_deadlocks 和長交易告警要在平常就開好。

下一章換一個角度:前面五章都在處理「查詢怎麼跑」,但有些成本是在建表那一刻就決定的——主鍵選什麼、欄位用什麼型別,會一路影響到寫入速度、索引大小、甚至一次 DDL 要鎖多久。

這一章的每個手段同樣有帳單,而且有幾筆特別容易被忽略。

五筆帳

  • 改用 RC 隔離等級 → 鎖範圍小很多,但會有幻讀,而且 binlog 必須是 ROW 格式。這是改變系統語意的決定,要在設計階段做。
  • 加重試(處理 1213) → 讓錯誤消失了,但衝突還在,而且被藏起來了。重試次數要有上限、要記錄,否則哪天衝突率上升,你會先看到延遲飆高而不是錯誤。
  • 縮短 innodb_lock_wait_timeout → 失敗變快(好事),但原本能等到的請求現在會直接失敗。要配合應用端的重試策略一起調。
  • 關閉死鎖偵測 → 省下高併發時的 O(n²) 檢查成本,但死鎖從「立刻回滾一個」變成「兩邊都卡到超時」。除非有明確量測,不要動。
  • 樂觀鎖 → 不加鎖,但衝突高時會變成重試風暴,實際吞吐可能比悲觀鎖更差。

這一章的工具看不到的東西

  • 鎖等待不會出現在慢查詢日誌的「掃描列數」裡。一句等了 40 秒的 UPDATE,rows_examined 可能是 1。所以用「掃描列數」排序慢查詢時,鎖問題會完全隱形。
  • EXPLAIN ANALYZE 測不到等待。它測的是執行時間;而且在正式環境對一句會加鎖的語句下 EXPLAIN ANALYZE,等於真的去搶那把鎖。
  • 應用端的連線池排隊會偽裝成鎖問題。「所有請求都變慢」可能是連線池滿了(backend ch03),而連線池滿了往往又是因為前面有人在等鎖——這兩件事會互相放大,是同一場故障的兩個面向(ch07 會把這條完整串起來)。
最後一點特別重要:鎖等待會透過連線池外溢成全站故障。10 個請求在等鎖 → 10 條連線被佔住 → 連線池耗盡 → 完全無關的 API 也拿不到連線。這就是為什麼「某張表的鎖問題」常常表現成「整個系統掛了」。
InnoDB 的鎖一覽:鎖住什麼、什麼時候出現
鎖層級鎖住什麼什麼時候要注意
意向鎖 IS / IX表只是旗標,標示表內有行鎖彼此相容,看到它是正常的
MDL(metadata lock)表表結構DDL 排隊時會 FIFO 擋住後面所有查詢
Record Lock行一筆索引記錄擋不住旁邊插入新資料
Gap Lock行記錄之間的空隙只在 RR 存在;間隙鎖之間互相相容
Next-Key Lock行記錄 + 前面的間隙(左開右閉)RR 下的預設加鎖單位
Insert Intention Lock行「我想在這個間隙插入」插入類死鎖的主角(先查再插)
1205 與 1213:分辨方向
ERROR 1205 鎖等待超時ERROR 1213 死鎖
意思等鎖超過 innodb_lock_wait_timeout(預設 50 秒)互相持有對方要的鎖,形成環
根因有人長時間持有鎖不放加鎖順序不一致
與交易長短的關係直接相關——長交易是主因無關,再短的交易順序相反也會死鎖
怎麼查sys.innodb_lock_waits 看 blocking_queryLATEST DETECTED DEADLOCK 看兩邊等哪個 index
能不能重試重試多半只是再等一次可以,另一方已成功
修法縮短交易、交易裡不做 I/O統一加鎖順序(例如一律按主鍵遞增)
臨鍵鎖的退化規則(RR 隔離等級)
情境實際加的鎖會擋住什麼實務含意
唯一索引 + 等值 + 命中Record Lock只擋改這一列最理想,鎖範圍最小
唯一索引 + 等值 + 沒命中Gap Lock擋住在這個間隙插入「查不到也會鎖」——先查再插的死鎖來源
非唯一索引 + 等值Next-Key + 下一個間隙擋住相鄰範圍的插入鎖範圍比想像大
範圍查詢掃過的每個區間都是 Next-Key整個範圍不准插入範圍越寬鎖越多;沒走索引=全表
沒有可用索引掃過的每一列都加鎖幾乎等於表鎖「UPDATE 要走索引」的真正理由

練習題 點選選項查看解析

0 / 10
01 / 10
UPDATE orders SET status=2 WHERE memo='X',memo 沒有索引,實際只有 1 列符合。這句會鎖住什麼?
A 只鎖那 1 列
B 掃過的每一列都會被加鎖,實質上等於鎖住整張表
C 只鎖 memo 索引上的那一項
D 不加任何鎖,因為 UPDATE 是快照讀
解析
InnoDB 的行鎖是加在索引項上的。沒有索引可走就是全表掃描,而它必須先鎖住才能判斷每一列符不符合條件。所以「UPDATE 要走索引」的理由不只是快,而是不走索引會把行鎖放大成事實上的表鎖。
02 / 10
為什麼一個「應該很快」的 ALTER TABLE 會讓整張表完全不能用,連 SELECT 都卡住?
A 因為 ALTER 會鎖住所有資料列
B 因為 DDL 要拿 MDL 寫鎖;被長交易擋住時它會排隊,而 MDL 佇列是 FIFO,排在它後面的所有新查詢也一起卡住
C 因為 ALTER 會清空查詢快取
D 因為 ALTER 期間資料表會被複製
解析
關鍵不是 ALTER 本身,是 MDL 佇列的 FIFO 特性——它自己等不到,還會擋住後面所有無辜的查詢。防禦方式是執行 DDL 前先檢查 information_schema.innodb_trx 有沒有長交易,並設定較短的 lock_wait_timeout 讓 DDL 等不到就放棄。
03 / 10
SELECT * FROM orders WHERE id = 999 FOR UPDATE,而 id=999 不存在(id 是主鍵)。會發生什麼?
A 不加任何鎖,因為沒有資料
B 加 Gap Lock 鎖住該值所在的間隙,擋住別人插入 id=999
C 加 Record Lock
D 報錯
解析
唯一索引等值查詢沒命中時,臨鍵鎖退化成間隙鎖——「查不到也會鎖」。這正是「先查有沒有、沒有就插入」這種寫法在高併發下互相咬住的機制:兩個交易各自持有間隙鎖,然後都要等對方的間隙才能插入。
04 / 10
REPEATABLE READ 與 READ COMMITTED 在維運視角最重要的差別是什麼?
A RC 的查詢速度比較快
B RC 沒有間隙鎖,而且掃到但不符合的列會提前釋放——鎖範圍小很多,衝突與死鎖機率都較低
C RC 不需要 binlog
D RR 不支援 SELECT FOR UPDATE
解析
很多線上系統改用 RC 不是為了效能,是為了減少鎖衝突。但代價不在效能上:RC 會有幻讀,而且 binlog 必須是 ROW 格式(RC + STATEMENT 會造成主從不一致)。這是改變系統語意的決定,要在設計階段做,不是故障當下改。
05 / 10
收到 ERROR 1205 (Lock wait timeout exceeded),第一步該找什麼?
A 找出加鎖順序不一致的兩段程式
B 找出長時間持有鎖的那個交易(sys.innodb_lock_waits 的 blocking_query、innodb_trx 裡開很久的交易)
C 調大 innodb_buffer_pool_size
D 增加重試次數
解析
1205 的根因是「有人卡著不放」,要找的是持有者而不是等待者。特別難查的一種是 trx_query 為空——代表那個交易開著但沒在跑 SQL,正在等應用程式做外部 I/O。加鎖順序是 1213 的方向,兩者不要混。
06 / 10
關於 ERROR 1213(死鎖),下列何者正確?
A 它是併發太高造成的,降低併發就能解決
B 根因是加鎖順序不一致,與交易長短無關;而且它是可重試的,因為另一方已經成功了
C 它需要手動 kill 其中一個交易
D 它只會發生在 REPEATABLE READ
解析
InnoDB 會用 wait-for graph 主動偵測並回滾成本較小的那個交易。降低併發只是降低撞上的機率,統一加鎖順序(例如一律按主鍵遞增取鎖)才是消除它。重試是合理的止血手段,但要記得衝突本身還在。
07 / 10
兩支批次更新同一張表的「不同欄位」,為什麼還會死鎖?
A 因為 MySQL 的鎖是欄位層級的
B 因為 InnoDB 的鎖是列層級的——只要碰到同一列就是同一把鎖,業務上不相干毫無意義
C 因為兩支批次用了同一個連線
D 因為欄位型別不同
解析
本章案例的核心:批次 A 走主鍵直接鎖聚簇索引;批次 B 走二級索引,先鎖索引項再回表鎖聚簇索引。走不同索引就是不同的加鎖順序,於是形成環。而「更新不同欄位」完全不構成保護。
08 / 10
SHOW ENGINE INNODB STATUS 查死鎖時,最需要注意的限制是什麼?
A 它需要 SUPER 權限
B 它只保留最後一次死鎖,下一次發生就覆蓋掉——所以要先開 innodb_print_all_deadlocks 寫進 error log
C 它會鎖住整個實例
D 它只在 RC 隔離等級下有輸出
解析
事後追查死鎖常常撲空就是因為這個。innodb_print_all_deadlocks 幾乎沒有成本,但沒開的話你只能等下一次事故剛好有人在線上。這屬於「平常就要準備好的觀測手段」,鎖問題必須抓現場。
09 / 10
下面哪一段程式最容易造成難以診斷的鎖等待?
A 在交易裡用 UPDATE ... SET n = n + 1 做原子計數
B 在交易裡先 SELECT ... FOR UPDATE 拿到行鎖,接著呼叫外部支付 API,最後才更新狀態
C 用唯一索引配 INSERT ... ON DUPLICATE KEY UPDATE
D 用 SELECT ... FOR UPDATE SKIP LOCKED 撈任務
解析
交易裡做外部 I/O,會把別人的延遲變成自己的鎖持有時間,而且在資料庫端看到的 trx_query 是空的——完全看不出它在幹嘛。這正是「交易裡不要做 I/O」這條原則的具體理由,也是最難查的那種 1205。
10 / 10
為什麼「某張表的鎖問題」常常表現成「整個系統掛了」?
A 因為 InnoDB 會自動升級成實例級的鎖
B 因為等鎖的請求會佔住連線不放,連線池耗盡後,完全無關的 API 也拿不到連線
C 因為鎖會導致 Buffer Pool 被清空
D 因為死鎖偵測會消耗所有 CPU
解析
鎖等待會透過連線池外溢:10 個請求等鎖就是 10 條連線被佔住。這也是為什麼鎖問題與連線池問題常常同時出現、互相放大——它們是同一場故障的兩個面向。另外要注意:等鎖不會出現在慢查詢的 rows_examined 裡,用掃描列數排序時鎖問題完全隱形。

點擊卡片翻面查看答案,共 13 張。

QUESTION
InnoDB 的行鎖到底鎖在什麼東西上?這條原理的最大後果是什麼?
點擊翻面
ANSWER
鎖加在索引項上,不是資料列上。後果:沒走索引的 UPDATE/DELETE 會對掃過的每一列加鎖,實質等於表鎖——這是「更新語句一定要走索引」的真正理由。
點擊翻回
QUESTION
意向鎖(IS/IX)是做什麼的?看到它要緊張嗎?
點擊翻面
ANSWER
純粹為了效率:讓想加表鎖的人不必逐列檢查有沒有行鎖,看表上有沒有 IX 就知道。意向鎖之間永遠相容,看到它是正常的。
點擊翻回
QUESTION
MDL 為什麼是線上事故的常客?
點擊翻面
ANSWER
DDL 要 MDL 寫鎖,被長交易擋住時會排隊,而 MDL 佇列是 FIFO——排在它後面的所有新查詢(連 SELECT)也一起卡住。於是「一個很快的 DDL」讓整張表瞬間不能用。
點擊翻回
QUESTION
Record Lock、Gap Lock、Next-Key Lock 差在哪?
點擊翻面
ANSWER
Record 只鎖記錄(擋不住旁邊插入);Gap 只鎖記錄之間的空隙(防幻讀,只在 RR 存在,間隙鎖之間互相相容);Next-Key = 記錄 + 前面的間隙,左開右閉,是 RR 下的預設加鎖單位。
點擊翻回
QUESTION
唯一索引等值查詢「沒命中」時會加什麼鎖?為什麼重要?
點擊翻面
ANSWER
退化成 Gap Lock——查不到也會鎖住那段間隙。這是「先查有沒有、沒有就插入」在高併發下互咬的機制:雙方各持間隙鎖,又都要等對方的間隙才能插入。
點擊翻回
QUESTION
RR 與 RC 在鎖行為上的兩個具體差別?
點擊翻面
ANSWER
①RC 沒有間隙鎖(只有記錄鎖)②RC 會把掃到但不符合條件的列提前釋放。所以 RC 鎖範圍小很多、衝突與死鎖機率較低;代價是會有幻讀,且 binlog 必須是 ROW 格式。
點擊翻回
QUESTION
哪些語句是當前讀(會加鎖)?
點擊翻面
ANSWER
SELECT ... FOR UPDATE、SELECT ... FOR SHARE,以及所有 UPDATE / DELETE / INSERT。普通 SELECT 是快照讀,不加鎖(讀不擋寫、寫不擋讀)。
點擊翻回
QUESTION
ERROR 1205 與 1213 分別指向什麼根因?
點擊翻面
ANSWER
1205 鎖等待超時=有人長時間持有鎖不放,去找長交易;1213 死鎖=加鎖順序不一致,去統一順序。1213 可以重試(另一方已成功),1205 重試多半只是再等一次。
點擊翻回
QUESTION
最難查的那種 1205 長什麼樣?
點擊翻面
ANSWER
innodb_trx 裡 trx_query 是空的——交易開著但沒在跑 SQL,它拿著鎖在等應用程式做外部 I/O(呼叫 API、寫檔案)。資料庫這邊完全看不到它在幹嘛。
點擊翻回
QUESTION
為什麼「更新不同欄位」的兩支批次還是會死鎖?
點擊翻面
ANSWER
InnoDB 的鎖是列層級不是欄位層級,碰到同一列就是同一把鎖。而走主鍵的批次直接鎖聚簇索引、走二級索引的批次先鎖索引項再回表——加鎖順序相反,環就成形了。
點擊翻回
QUESTION
查死鎖前一定要先開什麼?為什麼?
點擊翻面
ANSWER
innodb_print_all_deadlocks = ON。因為 SHOW ENGINE INNODB STATUS 只保留最後一次死鎖,下一次發生就覆蓋掉,事後追查常常撲空。它幾乎沒有成本。
點擊翻回
QUESTION
應用層最常寫出鎖問題的五種寫法?
點擊翻面
ANSWER
①交易裡做外部 I/O ②先查再插(check-then-insert)③用 FOR UPDATE 當分散式鎖 ④沒走索引的 UPDATE/DELETE ⑤大交易。共同點是交易邊界畫得不好,不是 SQL 寫得不好。
點擊翻回
QUESTION
為什麼鎖問題常常表現成「整個系統掛了」?
點擊翻面
ANSWER
等鎖的請求會佔住連線不放,連線池耗盡之後,完全無關的 API 也拿不到連線。而且鎖等待不會出現在慢查詢的 rows_examined 裡——用掃描列數排序時它完全隱形。
點擊翻回