資料庫應用經典問題 — Double Booking Problem

我們在日常生活中應該很常會遇到以上圖片中的狀況,不論是看電影、看球賽、看演唱會…等活動之前,都需要做一件非常重要的事 — 搶座位。

如果活動相對冷門倒不會有什麼問題,但是如果這個活動非常熱門,需要在開賣的瞬間就立刻訂票,票卷也會在瞬間被秒殺的話,身為服務的開發者就需要更謹慎的避免異常狀況的發生。

如果一個活動的票卷有機會在一瞬間被秒殺的話,那麼代表著一件事情— 服務會在即短時間內承受大量的流量請求,所以身為服務開發者,必須確保應用可以承受大量的請求,避免服務 Crash 的狀況發生。不過筆者今天要談的並不是服務如何承受大量的流量請求,而是另一個資料庫的經典問題 — Double Booking Problem。

什麼是 Double Booking Problem ?

想像一下在有許多 Concurrent Requests 的狀況下,如果有多位使用者幾乎同時選取了某個電影院的座位,然而服務這邊卻沒有做好處理,讓每一位使用者都以為自己搶到了座位(實際上他們每一位搶到的都是同一個位置),等到了現場後才發現有好多拿著同樣座位票卷的人看著那一個座位面面相覷,不知道到底誰才是真正搶到了該座位,這樣的狀況是不是非常尷尬呢?

面對幾乎同時發出的 requests 的狀況下,服務沒有很好的處理先後順序,造成多人同時取得一個物件的擁有權,並進而造成更多的問題,這就是 Double Booking Problem。

如何避免 Double Booking Problem ?

首先一般來說要處理購票行為會有兩種作法,第一種是先透過 SELECT 搜尋出想要更新的 row,再針對該 row 去做更新。第二種則是直接透過 UPDATE 語句更新欄位。

狀況一:先 SELECT 後 UPDATE

讓我們先看看第一種狀況,以下的方式可以解決 Double Booking Problem,關鍵就在於 SELECT 語句後的「FOR UPDATE」這個設定。(本篇文章皆以 PostgresSQL 為例,其他資料庫的處理方式也許會不同,但學習背後的原理才是本篇文章的目的)

上面的例子如果移除 「FOR UPDATE」,當兩個幾乎同時的 request 進來時,因為 PostgresSQL transaction 的隔離層級預設是 Read Committed,在另一個幾乎同時發生的 transaction 尚未被 commit 的狀況下,SELECT 語句有可能都成功執行(都認為某個位置還尚未被下訂),並且接著分別執行後面的 UPDATE 語句,造成 Double Booking Problem。

而只要在 SELECT 語句後加上 「FOR UPDATE」就可以解決 Double Booking Problem,雖然非常簡單,但我們還是應該理解資料庫在背後到底幫我們做了什麼。

FOR UPDATE 是某些資料庫才有資源的一個 row lock 功能,當執行該行時,資料庫會在要 query 的 row 上 pin 上一個 exclusive lock,transaction 在取得資料的 exclusive lock 後就可以對資料做寫入的動作,這個時候其他 transaction 則不能讀取,也不能寫入資料,必須等到鎖被釋放後才能繼續執行。(不熟悉 shared lock 或 exclusive lock 的讀者可以參考這篇文章)

如此一來,就可以解決 Double Booking Problem,因為不管再接近同時的 transaction,對於電腦來說一定都可以判斷出一個先後順序,先執行 SELECT FOR UPDATE 的 transaction 會拿到 lock,另一個 transaction 執行SELECT 時則會因為該 row 上有 exclusive lock 而被迫等待,直到前一個 transaction commit 後,後者的 SELECT 才會真正執行,但這時 isbooked 欄位已經被前一個 transaction 更改成 1 了,就會回傳「seat already booked」的錯誤。

狀況二:直接執行 UPDATE

另一種常見的處理訂票的方式是不用先執行 SELECT,而是直接執行 SQL UPDATE 語句,條件語句除了指定 id 以外還有 isbooked 這個欄位必須是 0

經過測試後其實可以發現這樣的寫法在 PostgresSQL 中是不會造成 Double Booking Problem 的,比較慢的 transaction 會顯示 UPDATE 失敗(其他資料庫筆者就不確定了),雖然語句簡單,也避免了 Double Booking Problem,不過我們還是應該了解為什麼這樣的寫法可以避免這個問題,資料庫在背後到底幫我們做了哪些事情?

假設有兩個 requests 幾乎同一時間進入伺服器,當然對電腦來說還是能排列出先後順序,較快執行到 UPDATE 語句的 transaction 一樣會給予該 row 一個 exclusive lock,因此另一個 transaction 執行到 UPDATE 語句時雖然透過 where 條件語句找到想要更新的 row,卻因為該 row 已被上鎖而必須等待。值得思考的是,較慢的 transaction 在執行 UPDATE 的 where 條件語句時該 row 的 isbooked 應該還是為 0(因為較快的 transaction 尚未 commit),所以符合搜尋條件,但當較快的 transaction commit 時,剛剛被迫等待的 transaction 理應要在鎖被釋放後直接執行 UPDATE 並造成 Double Booking Problem 或是覆寫掉原本搶到票的 owner name 才對,為什麼資料庫會直接讓 UPDATE 語句失敗呢?

其實 PostgresSQL 在背後做了一些事,如果隔離層級設在 read committed (PostgresSQL 預設層級),在第一個 transaction commit 後,第二個 transaction 在真正執行 UPDATE 前會重新抓取最新 commit 後的資料,因爲此時 isbooked 已被更新為 1,不符合查詢語句條件,因此 UPDATE 才會 fail,避免了 Double Booking Problem 的發生。

結論與反思

其實這是一個簡單的問題,透過簡單的語法就可以避免發生重大的錯誤,但是筆者認為重點其實在於有沒有了解背後的原理、理解資料庫背後做了哪些操作。當我們理解越多,就越能避免不預期錯誤的發生,也可以在問題發生時迅速找到根源並解決它。儘管每種資料庫的語法或是運作邏輯稍有不同,但當基礎夠清晰,我相信找到對應的解法都不會太困難或花太多時間。身為一個 Web 開發者,理應要了解時常與我們共舞的資料庫的底層運作機制,這正是筆者目前在加強的部分,希望本篇簡短的文章能夠對讀者有些許的幫助囉!

--

--

什麼都想學的雜食性軟體工程師 🇹🇼 (https://github.com/kylemocode) 合作與聯繫 📪 oldmo860617@gmail.com

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
莫力全 Kyle Mo

莫力全 Kyle Mo

什麼都想學的雜食性軟體工程師 🇹🇼 (https://github.com/kylemocode) 合作與聯繫 📪 oldmo860617@gmail.com