嗨嗨大家,你現在讀到的是遲到一天的第 13 期 CodeFarmer 技術週報。
開始前先簡單更新一下近況,最近因為突然開始一份新工作,這幾週都在忙著找房子、搬家、準備到職文件等,所以週報也不小心遲到了。
昨天女友還提到「你不是當初自己在第零篇時說『每週三看有多少內容就整理完更新出來,寫多少算多少,可以是很粗糙的一兩百字也沒關係』,雖然技術的部份看不懂,但我可是有在看內容」。
但看著昨天草稿裡只有兩張圖的質量,實在是不好意思發出去,所以就默默地撐到今天連假第一天的現在抽空來趕個稿了。
而關於新工作的部份說來話長,簡單說的話新的職稱是軟體工程師,也就是雖然前期會做比較偏前端的任務練練手,但之後可能會像同團隊中其他厲害的同事們一樣,大家都是從全端到機器一條龍自己扛起負責的專案。昨天上班時也人生第一次裝了 IntelliJ 在嘗試把 Java 服務跑起來而查了不少資料,突然由衷地感謝網路上滿滿地佛心初學資源讓我快速上手,首推古古大的這個鐵人系列 — 《Spring Boot 零基礎入門》。
廢話有點太多了拉回正題,以下這篇將接續上一篇繼續來把購票系統剩下的高階設計做完。如果還沒看過前一篇的話,歡迎參考這篇《系統設計面試:設計一個購票系統 (1)》。
Step 4. 提出高階設計並取得認可
4-1. 前情提要
上一篇的高階設計中,架構中已經畫出了幾個部份:
利用 API Gateway 來協助做登入驗證、限流、導流等功能
將搜尋、活動列表、訂票這幾個核心功能切分為各個微服務
選擇採用關聯式資料庫來儲存資料,讓系統在處理一致性問題時更方便
4-2. 關於購票功能
參考上圖,如果看到訂票功能的流程 (參考前篇 3-2),這邊也簡單前情提要下流程:
活動資訊頁上會有開賣倒數,在開賣前按鈕呈現 disabled 狀態
時間到正式開賣後,購票按鈕可按,使用者可在點擊後進入購票頁面
購票頁面第一步會提供可選座位,可能是特定編號或像是演唱會是特定區間票價
使用者選定後,為防止重複購票,此時會鎖住某一張票的 id 一段時間 (舉例像是 10 分鐘),讓使用者進行剩下的資料填寫與付款,若超時則跳提示跳轉回活動頁需重新下訂
使用者填寫付款資訊後經第三方支付服務驗證,成功後即完成購票
這個流程中,如果我們去把資料庫實體的一些欄位列出來,看到 Ticket 的部份其中可能會定義一個 status 的欄位,分別用以下狀態來代表每張票的不同狀態:
available:可選購
reserved:鎖定中
booked:已售出
如此一來,就可以在流程中第 3 步去資料庫撈出所有 available 的票供使用者選擇座位、在第 4 步去進行 reserved 鎖定、第 5 步將完成購買的票設為 booked。
但聰明的你可能會想到一個問題:「那什麼時候要重置狀態?」
如果說再設計另一個 API 在使用者取消購票時去主動觸發重置狀態釋出座位是一種直覺的解法,但使用者的操作百百款,更常發生的是使用者直接關閉網頁、闔上電腦等。
另一種想法可能是那我再加一個新欄位叫 reservedTimestamp 用來記錄鎖定開始的時間,然後每次撈可購買的票時,就同時檢查狀態與這個時間戳已過期的票。雖然這個做法合理且可行,但如果 status 與 reservedTimestamp 的關係沒有定義清楚或用文件說明,可能會讓後續維護者感到困惑,甚至誤用而產生 bug。
因此系統中勢必要有一種做法能定期地去重置這些票的狀態,最直覺的做法可以用個 cron job 來每 10 分鐘掃一次 Ticket 這張表,去針對 reservedTimestamp 過期票的狀態進行重置。影片中提到以一個 mid-level 人選來說這樣設計應該可以算過關了,但如果要評估一個 senior 甚至 staff 的人選可能還太淺。
因為用 cron job 有個很明顯的問題,假設今天是這樣的例子:
系統每 10 分鐘,也就是在 12:00:00、12:10:00、12:20:00 … 等去重置票務狀態
一大堆買家在 12:00:01 進入購票頁面卡位
系統在 12:10:00 去檢查所有票時,因為所有票都只過了 9 分 59 秒,因此沒有重置
其他買家在 12:10:00 到 12:19:59 這期間都會呈現買不到票的狀態
系統在 12:20:00 釋出過期的票
從上面例子會看到在邊界狀況下,雖然我們定義一個人鎖 10 分鐘、系統每 10 分鐘重置一次狀態,但實際上可能會有票被鎖接近 20 分鐘。因此我們需要找另一種更即時的做法 — Redis 分散式鎖 (Distributed Locks)。
4-3. 使用 Redis 分散式鎖解決票的鎖定狀態
參考上圖的設計,我們可以使用 Redis 中的分散式鎖來解決票務狀態的難題。首先可以先拿掉資料庫裡原本 reservedTimestamp 欄位的設計、以及 reserved 這個狀態,改將鎖定的機制靠 Redis lock 來實現。
當今天有一張票進入鎖定狀態 10 分鐘時,我們不會立即更新資料庫的狀態,而是在 Redis 中使用 ticketId 做為 key,並設定該 key 的 TTL (過期時間),也就是說 10 分鐘一到這個鍵值對資料就會立即被從 Redis 中刪掉而釋放出來。
而針對「在訂票頁撈出目前可選購的票」這件事也就可以單純地去用「撈出所有 available 狀態的票並排除在 Redis 中被鎖定中的 ticketId 們」來實現。
另外這邊直接使用分散式鎖的原因是因為 Booking Services 有可能不是單一機器,當今天需要針對熱門活動對 booking services 做水平擴展時,可能會需要確保多台機器間票鎖資訊的一致性。
4-4. 進階提問:如果這個 Redis lock 失效會發生什麼事?
在參考影片中作者提到,他做為 Meta 的面試官時會去進階提問「what happens if this lock goes down?」這個問題。
在系統中有個最簡單的解法就是當這個 Redis lock 掛掉時,馬上去啟用另一個服務起來繼續做事,但這會造成一個問題是當前正在購票的那些買家看似還在 10 分鐘流程內,但可能同時會有另一批買家同時買到一樣 ticketId 的票,但因為資料庫端使用 RDBMS 的關係仍有基本的同步鎖機制的防護,所以會變成在這 10 分鐘內的這些買家先到先得,而晚結帳的買家們就會遇到錯誤而有較差的使用者體驗。
簡單說目前系統的設計在遇到此問題時仍有一致性的保護,但會有短時間使用者雖進到購票頁最後無法完成結帳的問題,如果在需求討論會議中能接受這樣的狀況就會先採用這樣的設計。
小結
以上就是目前購票系統的高階設計部份,原本想一次講完 deep dives,但在研究分散式鎖的時候不小心花了一些時間,就只好繼續開 Part. 3 了,至少今天這期針對購票功能在狀態重置上的解決方案學習到許多做法,雖然目前都還只是紙上談兵,期待未來心有餘力或工作上實際有碰到有些實務經驗時可以再來分享更多。
參考資源
以上就是這期週報的所有內容了,也祝大家連假好好休息玩得愉快,別學我還在這裡卷,先收藏起來連假後再來讀就可以了。若內容有什麼錯誤、問題、討論也都歡迎透過以下管道與我交流,或直接留言與回覆這封電子信我也能收到:
Email:codefarmer.tw@gmail.com