在這篇"MySQL Lock : Table Lock與Row Lock",我們在兩個連線中,用指令展示了各種類型的鎖定,但是似乎還是有點不夠白話。
因此寫了一個演唱會搶票實作練習,來看看不同的鎖定,會造成什麼結果?
先看結論,如下圖 :
三個演唱會的售票統計中,可以看到 :
在使用 Select ... Lock in share mode 中,搶到鎖定權的順利買到票,但是有人成為 Deadlock。而其他兩種情況 : 不使用鎖定與使用 Select ... for update 鎖定,搶票都沒有出現錯誤。
(1) 不使用鎖定的搶票,容易產生「競爭條件 Race Condition」。
所謂競爭條件,不是指資料庫壞掉,也不是指 SQL 寫錯,而是多個使用者幾乎在同一時間操作同一筆資料,導致每個人都根據「自己當下看到的狀態」做判斷。
例如剩下 1 張票時,A 使用者讀到還有 1 張票,B 使用者也在幾乎同一時間讀到還有 1 張票。兩個人都認為「我可以買」,於是都進入扣票流程。
問題不在於他們能不能讀到票數,而是讀完票數之後,到真正扣票完成之前,中間有一段空窗期。在這段時間內,別人也可能讀到同一份舊資料,並且也開始執行扣票。
結果就可能出現「超賣 overselling」。
這裡要特別注意,這不是「髒讀 Dirty Read」。髒讀是指一個交易讀到另一個尚未 commit 的交易資料。而搶票超賣更精準來說,是「競爭條件 Race Condition」,也可能進一步造成「遺失更新 Lost Update」或「訂單成功數大於實際票數」的問題。
更白話地說:
大家不是讀到髒資料,而是大家太快、太同時,讀到了同一份尚未被正確保護的舊狀態。
(2) 使用 SELECT ... LOCK IN SHARE MODE 共享鎖,在搶票流程中容易產生「死鎖 Deadlock」。
共享鎖 Shared Lock 的意思是:「我可以讀這筆資料,也允許其他人一起讀,但暫時不希望別人修改它。」
所以當多個使用者同時執行:
SELECT tickets FROM events WHERE event_id = 1 LOCK IN SHARE MODE;
A 使用者可以取得共享鎖,B 使用者也可以取得共享鎖。因為共享鎖和共享鎖彼此相容,所以大家都可以同時讀到票數。
問題出現在下一步。
搶票流程不是只有讀資料,讀完之後還要扣票。也就是接著會執行:
UPDATE events SET tickets = tickets - 1 WHERE event_id = 1;
但是 UPDATE 需要的是「獨佔鎖 Exclusive Lock」。獨佔鎖的意思是:「我要修改這筆資料,其他人不能同時讀取並鎖定它,也不能同時修改它。」
這時候就麻煩了。
A 已經拿到共享鎖,B 也已經拿到共享鎖。
A 想把共享鎖升級成獨佔鎖,但它必須等 B 釋放共享鎖。
B 也想把共享鎖升級成獨佔鎖,但它也必須等 A 釋放共享鎖。
於是 A 等 B,B 等 A,兩邊互相等待,這就形成死鎖 Deadlock。
資料庫不會讓它們永遠等下去。像 InnoDB 通常會偵測到死鎖,然後選其中一個交易當「犧牲者 victim」,把它 rollback,讓另一個交易可以繼續完成。
所以 LOCK IN SHARE MODE 並不是完全不能用,而是不適合「讀完馬上要更新同一筆資料」的搶票流程。
它比較適合這種情境:
「我要讀這筆資料,而且在我讀取與檢查期間,不希望別人修改它。」
例如檢查某筆主資料是否存在、確認某個狀態不要被別人更改,但自己不一定要立刻更新這筆資料。可是搶票不是單純讀取,搶票的本質是「讀完之後立刻扣庫存」,所以共享鎖就不是最理想的選擇。
(3) 使用 SELECT ... FOR UPDATE 獨佔鎖,才比較符合搶票流程。
SELECT ... FOR UPDATE 的意思是:「我現在要讀這筆資料,而且我接下來準備修改它,所以請先幫我把它鎖起來。」
例如:
SELECT tickets FROM events WHERE event_id = 1 FOR UPDATE;
當 A 使用者先執行這段 SQL,A 會取得這筆資料的獨佔鎖。只要 A 的交易還沒有 commit 或 rollback,其他人就不能再對同一筆資料取得更新鎖定。
這時候 B 使用者也想搶票,B 也執行 SELECT ... FOR UPDATE,但 B 不會立刻讀到同一份舊票數,而是會進入等待狀態。
等 A 完成扣票並 commit 之後,B 才能繼續執行。這時候 B 讀到的票數,已經是 A 扣完之後的最新結果。
所以流程會變成:
A 先鎖定票數。
A 檢查還有票。
A 建立訂單。
A 扣票。
A commit。
B 才能繼續讀取最新票數。
B 看到票數已經變少,甚至可能看到已經沒有票。
B 再根據最新狀態決定是否可以購票。
這樣就不會發生大家同時看到同一張票、然後大家都以為自己買得到的問題。
也就是說,SELECT ... FOR UPDATE 把搶票流程從「大家同時看,同時搶」變成「排隊處理,先搶先贏」。
搶票真正要保護的,不只是「讀取票數」這個動作,而是整個區間:
從讀取票數開始,
到判斷是否有票,
到建立訂單,
到扣除票數,
最後 commit 完成。
這整段過程必須被包在同一個交易 Transaction 裡面,而且必須用正確的鎖定方式保護。
比較合理的流程應該是:
BEGIN;
先用 SELECT ... FOR UPDATE 鎖定該場次的票數。
接著檢查票數是否大於 0。
如果有票,就建立訂單,並且扣除票數。
最後 COMMIT;。
如果沒有票,就不建立訂單,直接 ROLLBACK; 或結束交易。
所以搶票的核心不是「能不能讀到票數」。
真正的核心是:
「讀到票數之後,到扣票完成之前,有沒有人可以插隊修改同一筆資料。」
如果這段期間沒有鎖好,就會超賣。
如果用共享鎖,可能大家都讀得到,但更新時互相卡住,造成死鎖。
如果用獨佔鎖,資料庫會讓使用者依序處理,每個人看到的都是前一個交易完成後的最新票數。
因此,在搶票、庫存扣除、限量商品購買、名額報名這類場景中,SELECT ... FOR UPDATE 通常會比 LOCK IN SHARE MODE 更適合。
0 留言