
圖片來源:ChatGPT 生成
這次開發系統的過程中,大多數都是使用樂觀鎖來避免 race condition 的情況 (參閱 閒談軟體設計:樂觀鎖 ),但樂觀鎖的使用有前提條件:要有資料本身能做為鎖的載體。但不是所有的情境都能滿足這個條件。
這次的情境比較特別,一邊使用者可能在一個範圍內批次新增或修改多筆資源 S,而另一邊背景的 Worker 則會讀取一個範圍內的資源 S 作為運算的輸入,此時會希望當批次寫入仍在進行中時,Worker 要等到批次寫入完成再讀取,不然可能會用到錯的資料進行運算。反之,Worker 在讀取資料時,會希望批次處理等到 Worker 處理完後再寫入。樂觀鎖和序列交易
在這個情境下,無法找到合適的樂觀鎖載體,因為 Worker 只讀取資料,沒有更新任何資訊,也就無法比較版本是否有更動過,更何況,批次的操作中有新增資源的情況,這讓 Worker 無法提前知道資料的 ID 來鎖定。因此,這個情境下樂觀鎖無法上派上用場。
那 PostgreSQL 的 Serializable Isolation Level 的交易管理有機會解決這問題嗎?有機會,但處理方式需要調整,首先,讀寫兩端都要開啟 Serializable Isolation Level 的交易,接著,讀寫兩端都需要處理重試,因為PostgreSQL 在 Serializable Isolation Level 下並不會強制讓交易排隊,而是允許交易先平行執行,並在 commit 時檢查是否存在 serialization anomaly。如果發現衝突,就會中止其中一個交易並要求重試。
那如果是鎖整張 table 呢?可以,但這會讓系統的效能大幅降低,如果沒有特殊的需求,通常不會使用。
在一般的情況下,直接把錯誤傳回前端,讓使用者重試是可接受的選項,但由於這回批次寫入的資料如果讓使用者重試,體驗恐怕不會太好,若不想重試似乎要請出悲觀鎖。
分散式鎖
在過去學習作業系統或是平行處理時,當要處理 race condition,通常都會用 mutex lock 確保只有一個程序進入 critical section,其他程序要等到先進入 critical section 完成後才能進入。如果是單程序的 Java 應用程式,用語言內建的 synchronized 關鍵字,就能簡單地實現多執行緒的 mutex lock。
但在雲端環境呢?就需要分散式鎖,一般來說,很多人會想到用 Redis,確實在一開始也想過直接用 Redis,畢竟目前的系統裡,也有佈署 Redis,但後來想想,當時佈署 Redis 是為了使用 Redis Pub/Sub,所以用 k8s 簡單地佈署了只有一個 instance 的 Redis,唯一的 instance 如果重啟,那 lock 就失效了,似乎會是個風險。
那有其他選擇嗎?花了一點時間研究後,發現了一個有趣而且不用佈署任何新機器的解:PostgreSQL Advisory Lock。
PostgreSQL Advisory Lock
PostgreSQL Advisory Lock 是 PostgreSQL 內建的一種鎖,使用方式很簡單,只需要在 transaction 內執行 SELECT pg_advisory_xact_lock(key),key 是一個 64 位元整數,由應用層決定 key,只要是相同的 key,PostgreSQL 就會確保未取得鎖的程序等待已取得鎖的程序結束。
必須在 transaction 內使用,而且會隨著 transaction 結束 (commit 或 rollback),自動釋放 lock,很簡單就能避免忘記釋放 lock 的情況,相當方便,之前竟然不知道有這東西,太可惜了。
馬上將這個技術封裝並提供一個類似 Java synchronized 的抽象,在原有的 Sql2oTransactionManager 加入 synchronize 函式,第一個參數是 key,第二個參數便是取得鎖後要執行的內容:
runInSession確保作為參數的 lambda 會在一個 transaction 執行,不管那個 transaction 是既有的或是新建立的obtainLock則是將key透過hashtextextended雜湊程 64 位元的整數。- 在取得 lock 後才執行
runnable
整個實作相當直覺簡單。
有了這個函式後,在 Worker 就可以將初始化的動作包進第二個參數,在批次更新的部分,也將寫入的部分包進第二個參數,只要 key 相同,就會確保只會有一個程序執行,另一個程序會等待先執行的程序結束後才執行。
到此,批次寫入和 Worker 讀取能按照順序執行了,也替系統添加了一個分散式鎖的實作,之後有需要時,能直接使用。
Advisory Lock 也是有限制的,要進入 critical section 的程式都要先試著取得鎖,這和多數 mutex lock 的使用是一樣的。另外,key 的範圍要小心,如果 key 鎖定的範圍太大,也會讓系統效能大幅下降,要盡量避免。
另外,這邊使用 pg_advisory_xact_lock 而非 pg_advisory_lock 就是讓 lock 的生命週期與 transaction 綁定,並免忘記釋放 lock 的問題,若真的要使用 pg_advisory_lock,請切記做好 lock 的生命週期管理。
最後,如果是不想等待,可以使用 pg_try_advisory_xact_lock,若取得鎖失敗後馬上返回 false,由應用層決定該如何處理。
小結
在需要「一組操作完成後另一組操作才能開始」的情境下,樂觀鎖或 Serializable Isolation Level 交易不一定能滿足這需求,特別是當操作涉及範圍查詢或可能新增未知資料時。若既有系統有 PostgreSQL 的情況下,可以透過 PostgreSQL 提供的Advisory Lock,加上能用 key 決定鎖的顆粒度,在不引入額外基礎設施的情況下,是相當方便的一個悲觀鎖選項。


















