概述#

並行性(concurrency)是軟體開發中最棘手的面向之一。只要有多個行程或執行緒操作同一份資料,就會遇到並行性問題。並行性難以思考、難以測試,而且無論怎麼做,總似乎遺漏了某些情境。

企業應用程式開發中一個諷刺之處是:雖然少有軟體分支比企業應用更需要處理並行,但開發者卻往往對其關注較少。這是因為交易管理器(transaction managers)提供了一個框架,幫助避開了大部分棘手的並行問題——只要將所有資料操作放在交易內,通常就不會出大問題。

然而,並行問題無法完全被忽略,原因有二:

  • 許多系統互動無法被放進單一資料庫交易中,這迫使我們必須管理跨越多筆交易的並行,稱為離線並行(offline concurrency)
  • 應用程式伺服器本身的行程並行——支援多執行緒處理多個請求

Concurrency Problems#

並行性的兩個基本問題:

Lost Updates(遺失更新)#

最容易理解的問題。例如 Martin 編輯一個檔案,同時 David 也在修改同一個檔案。David 先完成並儲存,但 Martin 讀取檔案時並未包含 David 的更新,因此當 Martin 儲存時,覆蓋了 David 的版本,導致 David 的更新永遠遺失。

Inconsistent Read(不一致讀取)#

當你讀取的兩項資訊各自正確,但放在一起卻不正確。例如 Martin 想知道某個套件中有多少類別,他先看了子套件 A 有 7 個,此時 David 更新了程式碼,Martin 再看子套件 B 時看到 8 個,合計 15——但實際上在 David 更新前是 12 個,更新後是 17 個,15 從來不是正確答案。

這兩個問題都導致正確性(correctness/safety)的失敗。然而,如果只追求正確性,可以安排同一時間只有一人操作資料,這又會降低活性(liveness)——即系統能同時進行多少並行活動。在正確性與活性之間取得平衡,是並行設計的核心挑戰。

Execution Contexts#

系統中的處理發生在某個執行上下文(execution context)中,重要的上下文包括:

  • Request(請求):來自外界的單次呼叫,軟體對其進行處理並可選擇性地回傳回應
  • Session(工作階段):客戶端與伺服器之間的長期互動,可能包含一系列請求
  • Process(行程):較重量級的執行上下文,對內部資料提供良好隔離
  • Thread(執行緒):較輕量的活動代理,多個執行緒可在同一行程內運作,但共享記憶體,因此容易產生並行問題
  • Transaction(交易):將多個請求組合在一起,視為單一請求處理

執行上下文的困難在於它們之間的對應關係不盡理想。理論上每個 session 應與一個行程有排他關係,但實務上很少能做到。目前常見的做法是讓一個行程一次只處理一個請求,這能避開許多並行問題。

Isolation and Immutability#

企業應用中解決並行問題的兩個特別重要的方案:

隔離(Isolation)#

將資料分區,使每筆資料只能被一個活動代理存取。好的並行設計在於建立隔離區域,讓盡可能多的程式運行在不需擔心並行的區域中。

不可變性(Immutability)#

只有在共享的資料可以被修改時,才會產生並行問題。透過辨識某些資料是不可變的(或幾乎不會改變的),可以放寬對它的並行控管,並廣泛共享。另一種做法是將只讀的應用程式分離出來,使用複製的資料來源,從而放寬所有並行控制。

Optimistic and Pessimistic Concurrency Control#

當資料是可變且無法隔離時,有兩種並行控制方式:

Optimistic Locking(樂觀鎖定)#

允許所有人自由複製檔案並編輯。在提交時才進行衝突偵測。例如 David 先完成提交,Martin 提交時系統偵測到衝突,Martin 需要自行解決。本質上是衝突偵測(conflict detection)。

Pessimistic Locking(悲觀鎖定)#

先取得鎖定的人阻止其他人編輯。例如 Martin 先 checkout,David 就必須等到 Martin 完成。本質上是衝突預防(conflict prevention)。

選擇的關鍵在於衝突的頻率與嚴重性

  • 衝突稀少或後果不嚴重 → 樂觀鎖定(提供更好的並行性,較容易實作)
  • 衝突後果對使用者很痛苦 → 悲觀鎖定

防止不一致讀取#

悲觀鎖定使用讀取鎖(shared lock)與寫入鎖(exclusive lock)來處理。多人可同時持有讀取鎖,但只要有人持有讀取鎖就不能取得寫入鎖;一旦有人持有寫入鎖,其他人完全無法存取。

樂觀鎖定通常透過版本標記(version marker,如時間戳或序列計數器)來偵測衝突。

Deadlock(死鎖)#

悲觀鎖定的特有問題。例如 Martin 鎖定了 Customer 檔案並開始編輯 Order 檔案,David 鎖定了 Order 檔案並需要編輯 Customer 檔案——兩人都無法繼續。

處理死鎖的技巧包括:

  • 偵測死鎖並選擇一個受害者(victim)放棄其工作
  • 為每個鎖設定逾時
  • 要求在開始時一次取得所有鎖
  • 強制按順序取得鎖
  • 當有人已持有鎖時,後來者自動成為受害者

在企業應用開發中,建議採用非常簡單且保守的死鎖防範策略。產生不必要的受害者,通常比遺漏死鎖情境的後果好得多。

Transactions#

交易(transaction)是處理企業應用並行性的首要工具。

ACID 特性#

軟體交易通常以 ACID 特性描述:

  • Atomicity(原子性):交易邊界內的每一步要麼全部成功完成,要麼全部回滾。沒有「部分完成」的概念。
  • Consistency(一致性):系統資源在交易開始與結束時,必須處於一致且不損壞的狀態。
  • Isolation(隔離性):單一交易的結果在該交易成功提交之前,不得對其他開啟的交易可見。
  • Durability(持久性):已提交交易的結果必須被永久保存,能夠承受任何形式的系統當機。

交易資源與長交易#

一般建議是不要讓交易跨越多個請求。跨越多個請求的交易稱為長交易(long transaction),大多數交易系統處理長交易的效率很差,會將資料庫變成主要瓶頸。

常見做法是在請求開始時啟動交易,在請求結束時完成它(request transaction)。一個變體是盡可能晚開啟交易(late transaction),在讀取時不開啟交易,只在需要更新時才開啟。

降低交易隔離等級以提升活性#

SQL 標準定義了四個隔離等級:

隔離等級Dirty ReadUnrepeatable ReadPhantom
Read Uncommitted允許允許允許
Read Committed不允許允許允許
Repeatable Read不允許不允許允許
Serializable不允許不允許不允許

為確保正確性,應使用 Serializable 等級,但它嚴重影響活性。不同交易可以使用不同的隔離等級,應逐一評估每個交易在活性與正確性之間的權衡。

業務交易 vs. 系統交易#

  • 系統交易(system transaction):由 RDBMS 和交易監控器支援的交易
  • 業務交易(business transaction):從使用者角度出發的交易,如登入、選擇帳戶、設定付款、按下確認——期望具有與 ACID 相同的特性

業務交易往往跨越多個請求,不適合用單一長系統交易來實作。這意味著你必須將業務交易拆解為一系列短系統交易,並自行在系統交易之間支撐 ACID 特性——這就是離線並行(offline concurrency)問題。

Patterns for Offline Concurrency Control#

處理離線並行問題的首選是 Optimistic Offline Lock,它本質上是在業務交易之間使用樂觀並行控制。這是首選,因為它較容易撰寫且提供最佳活性。其限制是衝突只有在業務交易嘗試提交時才會被發現,有時這種「太晚發現」的痛苦令人難以接受。

替代方案是 Pessimistic Offline Lock,能更早發現衝突,但較難撰寫且降低活性。

使用以下模式可以大幅簡化管理:

  • Coarse-Grained Lock:管理一組物件的並行,而非逐一管理每個物件
  • Implicit Lock:讓開發者不需直接管理鎖,避免遺忘導致的 bug

關於並行性的一個常見誤解是它純粹是技術決策。實際上,樂觀或悲觀控制的選擇會影響整個使用者體驗,Pessimistic Offline Lock 的設計需要大量來自使用者的領域輸入,Coarse-Grained Lock 也需要領域知識來做出好的選擇。

Application Server Concurrency#

應用程式伺服器的行程並行是另一種形式的並行議題:伺服器如何同時處理多個請求?與離線並行不同的是,應用伺服器並行不涉及交易。

處理方式的選擇:

  • Process-per-session:每個 session 運行在自己的行程中。狀態完全隔離,但資源消耗大。
  • Process-per-request(池化):行程池化,每個行程一次處理一個請求但可服務不同 session。隔離幾乎一樣好,且需要的行程少很多。
  • Thread-per-request:每個請求由單一行程中的一個執行緒處理。比行程更有效率,但執行緒之間沒有隔離,任何執行緒都能存取任何資料。

作者認為 process-per-request 有很多值得推薦之處。雖然比 thread-per-request 效率低,但擴展性同樣好,且健壯性更佳——一個執行緒出問題可能拖垮整個行程,而 process-per-request 將損害限制在單一行程。

如果使用 thread-per-request,最重要的是建立隔離區域:讓執行緒在開始處理請求時建立新物件,並確保這些物件不會被放到其他執行緒可見的地方(如靜態變數)。需要全域記憶體時,使用 Registry 模式來實作看似靜態變數但實際使用 thread-specific storage 的機制。

昂貴的物件(如資料庫連線)可放入明確的物件池中,在需要時取得、用完歸還,並且這些操作需要同步化。