接著把焦點放在分散式檔案系統的同步議題。若檔案不被共享,同步根本不成問題;但在分散式系統中,一旦效能也成為考量,檔案共享的語意就變得棘手。以下討論幾種最重要的解法。

檔案共享的語意#

兩個以上的使用者同時共享同一檔案時,必須精確定義讀寫語意以避免問題。

UNIX 語意#

在允許行程共享檔案的單處理器系統(如 UNIX)中,語意通常是:read 跟在 write 之後,讀到的就是剛寫入的值;兩次 write 緊接著一次 read,讀到的是最後一次 write 存入的值。實際上系統對所有操作強制一個絕對的時間排序,永遠回傳最新值。這個模型稱為 UNIX 語意(UNIX semantics),容易理解也容易實作。

在分散式系統中,只要只有一台檔案伺服器且用戶端不快取檔案,UNIX 語意不難達成:所有讀寫直接送到伺服器、嚴格循序處理。(唯一的小瑕疵是網路延遲可能讓 write 之後一微秒發出的 read 先到伺服器,因而拿到舊值。)

但實務上,所有檔案請求都得經過單一伺服器的系統效能往往很差。常見解法是讓用戶端把常用檔案放進本地私有快取——問題隨之而來:用戶端 A 在本地修改了快取的檔案,稍後用戶端 B 從伺服器讀同一檔案,會拿到過時的檔案

圖 11-16:(a) 在單處理器上,read 跟在 write 之後時,read 回傳的就是剛寫入的值。(b) 在有快取的分散式系統中,可能回傳過時的值。

作業階段語意#

一條出路是把快取檔案的所有變更立即傳回伺服器——概念簡單但沒效率。另一條路是放寬共享語意,改採新規則:「對已開啟檔案的變更,最初只有修改它的行程(或機器)看得見;檔案關閉時,變更才對其他行程(或機器)可見。」採用這條規則後,前述 B 讀到舊值的行為不變,但被重新定義為正確行為;A 關檔時把副本送回伺服器,之後的讀取自然拿到新值。

這條被廣泛實作的規則稱為作業階段語意(session semantics),多數分散式檔案系統都採用它。

這意味著:雖然理論上這些系統遵循遠端存取模型,多數實作其實利用本地快取,實質上實作的是上傳/下載模型。

採用 session semantics 會引出一個問題:兩個以上的用戶端同時快取並修改同一檔案,結果如何?一種答案是每個檔案關閉時把值送回伺服器,最終結果取決於誰的關檔請求最後被伺服器處理;另一種不太漂亮但容易實作的答案是:最終結果是候選者之一,但不指定是哪一個

不可變檔案#

完全不同的做法是讓所有檔案不可變(immutable):無法開檔寫入,檔案僅有的操作是 create 與 read。能做的是建立全新檔案,並以既有檔案的名稱放進目錄系統,原檔案(至少在該名稱下)從此無法存取。換言之,檔案不能更新,但目錄可以——雖然無法修改檔案 x,卻可以原子性地用新檔案取代 x。一旦決定檔案完全不可變,「一個行程寫、另一個行程讀」的難題便直接消失,設計大幅簡化。

剩下的問題有二:

  • 兩個行程同時嘗試取代同一檔案:與 session semantics 一樣,最佳解法似乎是讓其中一個新檔案勝出——取最後一個或不確定地擇一。
  • 檔案被取代時另一行程正在讀它:一種解法是設法讓讀者繼續使用舊檔案,即使它已不在任何目錄中(類似 UNIX 允許已開檔的行程在檔案被從所有目錄刪除後繼續使用它);另一種是偵測到檔案已變,讓後續讀取失敗。

原子交易#

第四種處理共享檔案的方式是原子交易(atomic transaction):行程先執行某種 BEGIN_TRANSACTION 原語,宣告接下來的操作必須不可分割地執行,接著發出讀寫一個或多個檔案的系統呼叫,完成後執行 END_TRANSACTION。關鍵性質是系統保證交易內的所有呼叫依序執行、不受其他並行交易干擾;多個交易同時啟動時,最終結果等同它們以某種(未定義的)循序次序執行。

四種語意小結:UNIX 語意——每個操作立即對所有行程可見;session semantics——關檔後變更才對其他人可見;不可變檔案——不允許更新,只能整檔取代,簡化共享;交易——所有變更以原子方式全有或全無。

圖 11-17:分散式系統中處理共享檔案的四種方式。

檔案鎖定#

尤其在伺服器無狀態的主從式架構中,共享檔案的存取同步需要額外設施。傳統做法是使用鎖管理者(lock manager),無一例外都遵循集中式鎖定方案。

然而事情沒那麼簡單:雖然普遍部署中央鎖管理者,鎖定的複雜性來自必須允許對同一檔案的並行存取——因此存在大量不同種類的鎖,鎖的粒度(granularity)也各不相同。以 NFSv4 為例。

概念上 NFSv4 的檔案鎖定很單純,本質上只有四個相關操作。NFSv4 區分讀鎖寫鎖:多個用戶端只要都只讀資料,可同時存取檔案的同一部分;要修改檔案的某部分則需取得獨占的寫鎖。

  • lock:對檔案中一段連續位元組範圍請求讀鎖或寫鎖。這是非阻塞操作:若因衝突的鎖而無法核發,用戶端收到錯誤訊息,之後得自行輪詢伺服器,沒有自動重試。用戶端也可以選擇被放進伺服器維護的 FIFO 排序清單:衝突的鎖一移除,伺服器就把鎖核發給清單頂端的用戶端(前提是它在期限內來輪詢)。這種做法讓伺服器不必主動通知用戶端,同時因依 FIFO 順序核發而對搶不到鎖的用戶端保持公平。
  • lockt:測試是否存在衝突的鎖。例如請求寫鎖前,先測試某段位元組範圍上有無已核發的讀鎖;有衝突時,請求端會被確切告知是誰哪段範圍上造成衝突。它比 lock 更有效率,因為不需嘗試開檔。
  • locku:移除檔案上的鎖。
  • renew:鎖的核發有伺服器決定的期限,也就是帶有租約(lease);用戶端若不續約,伺服器會自動移除該鎖。此做法也用於其他伺服器提供的資源,有助於故障後的回復。renew 即是用戶端請求伺服器續約其鎖(實際上也包括其他資源)。

圖 11-18:NFSv4 中與檔案鎖定相關的操作。

共享保留(share reservation)#

除了上述操作,還有一種隱含的檔案鎖定方式,稱為共享保留(share reservation)。它與鎖定完全獨立,可用來為 Windows 系統實作 NFS。用戶端開檔時指定兩件事:自己需要的存取類型(READ、WRITE 或 BOTH),以及伺服器應拒絕其他用戶端的存取類型(NONE、READ、WRITE 或 BOTH)。伺服器若無法滿足要求,open 就失敗。對已開啟的檔案有兩個狀態變數:**存取狀態(access state)**記錄目前用戶端如何存取檔案,**拒絕狀態(denial state)**記錄不允許新用戶端進行哪些存取。新用戶端開檔時,其請求的存取類型會對照目前的拒絕狀態、其請求的拒絕狀態會對照目前的存取狀態,兩者都通過才成功。

圖 11-19:NFS 中帶有共享保留的 open 操作的結果。

NFSv4 在同步機制上絕非特例。如今公認,只提供「整檔鎖定」這類過於簡單的原語反映的是糟糕的設計;鎖定方案的複雜性多半來自「要允許並行存取共享檔案,就需要細粒度的鎖」。有一些兼顧效能、降低複雜度的嘗試,但情況仍不盡如人意——到頭來,我們或許該為了擴展性徹底重新設計應用程式,而不是繼續修補「想用非分散式時代的方式共享資料」所造成的局面。

Coda 的檔案共享#

NFS 的 session semantics 規定:最後關檔的行程的變更會傳回伺服器,較早的並行 session 的更新則遺失。Coda 檔案系統採取更細膩的做法,其配置方案與 NFS 的 share reservation 有些相似。關鍵背景:用戶端成功開啟檔案 f 時,f 的完整副本會傳到用戶端機器,伺服器記錄該用戶端持有 f 的副本(到此為止類似 NFS 的 open delegation)。

  • 用戶端 A 已為寫入開啟 f 時,用戶端 B 再開 f 會失敗——因為伺服器記錄了 A 可能已修改 f。
  • 若 A 是為讀取開啟 f,則 B 無論要求讀副本還是開檔寫入,都會成功

當 f 的多份副本存放在多個用戶端時,只有一個用戶端能修改 f。該用戶端修改並關檔後,檔案傳回伺服器;其他每個用戶端仍可繼續讀自己的本地副本,儘管副本其實已過時。

這種看似不一致的行為之所以合理,是因為 Coda 把一個 session 視為一個交易。假設 A 為讀取開啟 f(session S_A),B 為寫入開啟 f(session S_B):B 關閉 S_B 時把更新版的 f 傳回伺服器,伺服器隨即向 A 送出失效訊息,A 因此知道自己讀的是舊版 f。但從交易的觀點,這無關緊要——因為 session S_A 可以視為被排程在 session S_B 之前執行。

圖 11-20:Coda 中的交易式語意——兩個並行 session 的時間軸,S_A 可視為排在 S_B 之前執行。