JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

分散式檔案系統與快取

第 2 篇導覽用遠端程序呼叫,讓網路隱形了。現在我們把那套機制對準一個資料夾:分散式檔案系統讓你能 open() 一個其實住在別台電腦上的檔案,彷彿它就在你自己的磁碟上。問題出在快取——那個讓它變快的把戲,正是那個讓每個人對檔案內容各執一詞的把戲。

一個住在別台機器上的資料夾

在檔案系統那一階,你學到像 /home/jo/notes.txt 這樣的路徑,是沿著一棵目錄與 inode 的樹一段一段解析出來的,而掛載能在某個掛載點,把另一個裝置接進那棵樹裡。分散式檔案系統把最後這個點子拉長、橫跨整個網路:它掛載的,是一個根本不住在你任何一顆磁碟上的資料夾。掛載之後,你筆電上的 /shared 其實是一個坐落在另一個房間——甚至另一棟大樓——某台伺服器上的目錄;然而你的程式照樣對它呼叫老樣子的 open()、read(fd, buf, n) 和 write(),渾然不覺。最有名的例子是 NFS(網路檔案系統),它整個設計目標,正是那份隱形感。

這個把戲是怎麼變出來的?靠的正是第 2 篇導覽那套機制。當你的程式對 /shared 底下某個檔案呼叫 read(),核心會注意到這條路徑穿過了一個 NFS 掛載點,於是它不去碰本地磁碟,而是把請求——「讀取檔案代號 0x9a3f、位移 8192 處的 4 KB」——打包成一個遠端程序呼叫,送往伺服器。引數透過封送被攤平成線路上的位元組,伺服器做一次真正的本地讀取,位元組再一路傳回來。讓這一切乾淨俐落的最關鍵管路,是虛擬檔案系統層:核心本來就把每一個 open()/read()/write() 都導向一個抽象介面,所以 NFS 只是它背後的另一種實作,對你的程式而言,與本地的 ext4 驅動程式毫無分別。

我們為什麼要快取——以及它悄悄欠下的帳

為每一次讀取都送一個 RPC 過網路,慢得殘酷。一次本地磁碟讀取也許只要不到一毫秒;而跨越一個繁忙網路、來回一趟伺服器,可能要花上許多倍的時間,而你光是捲動一個檔案,就可能發出數千次讀取。顯而易見的解法,跟你的 CPU 和本地檔案系統早就到處在用的是同一招:把最近用過的資料留一份副本在身邊。這就是快取。用戶端機器把遠端檔案的一塊,存進它自己的記憶體——它的緩衝快取——於是同樣那些位元組的第二次、第三次、第一千次讀取,都以記憶體的速度在本地被滿足,伺服器甚至根本沒被告知。對於以讀取為主的工作量,這能把一個遲鈍的共享,變成感覺起來就像本地磁碟的東西。

但帳單來了,而它正是這整篇導覽的核心張力。就在第二個用戶端也快取了那個檔案的那一刻,那個唯一真實的檔案,就裂解成了好幾份各自私有的副本。現在用戶端 A 改了一行、存進它自己的快取;用戶端 B 還在讀它自己那份過時的副本,完全不知道有任何東西變了。兩者都相信自己握著「那個」檔案,而它們各執一詞。這就是快取一致性問題,它其實就是本地檔案系統那一階講過的檔案共用語意——只是被殘酷地放大了,因為現在這些副本被一個有延遲、又沒有共享時鐘的網路隔開。快取並沒有製造這場分歧;它只是給了每台機器一個各自私下分歧的據點。

NFS 究竟怎麼讓快取守規矩

那麼,一個真實的系統怎麼阻止這些副本各自漂移呢?NFS 用過最便宜、最古老的辦法是逾時驗證,有時稱為「關閉到開啟一致性」(close-to-open)。它的想法是:別想著做到完美,只要又便宜、又夠好就行。用戶端在一個短窗口內——幾秒鐘——信任它的快取副本,只有在那個窗口過期之後,它才會問伺服器「自我快取以來,這個檔案變了嗎?」,方法是檢查檔案的最後修改時間。若伺服器的時間戳記比較新,用戶端就丟掉過時的副本、重新抓取;若沒有,它就繼續用快取。這是一個刻意的取捨:你接受一個小小的過時窗口,換來不必用驗證訊息淹沒網路。

  1. 某個程式打開 /shared 底下的一個檔案。用戶端檢查它的快取:我已經握著這些位元組了嗎?而我的有效性計時器還在跑嗎?
  2. 快取命中、計時器還新鮮:立刻從本地記憶體把位元組端出來。完全沒有網路訊息——這就是讓快取值回票價的快速路徑。
  3. 快取命中、但計時器過期了:送出一個小小的 RPC,向伺服器詢問檔案目前的最後修改時間,並與快取副本上蓋的時間相比較。
  4. 時間相符:副本仍然有效——重設計時器、在本地端出。時間不符、或本來就快取未命中:透過 RPC 從伺服器抓取新鮮的位元組,並把它們快取起來。

誠實地看待這買到了什麼、又付出了什麼。好處是巨大的速度,以及一個簡單、無狀態的伺服器,它不必記得誰正在快取什麼。代價是 NFS 並非完美一致:在那個幾秒鐘的窗口裡,兩個用戶端真的可能看到同一個檔案的不同內容,而規則只保證:一個用戶端在另一個用戶端關閉檔案之後才打開它,會看到對方的寫入——並不保證同時編輯的人會保持同步。一個更強的方案,用在 AFS 與 SMB 之類的協定裡,是回呼(callback)或租約(lease):伺服器承諾,只要有人一寫入,就主動通知(或收回租約於)每一個正在快取該檔案的用戶端。這給了緊密得多的一致性,但它讓伺服器變成有狀態的——它必須追蹤每一份未結清的快取——而這套帳冊,正會在一個用戶端悄無聲息地當機、永遠不歸還它的租約時,變成一場惡夢。

寫入、失效,以及 CAP 開始咬人之處

讀取是簡單的那一半。寫入則逼出第二個、更尖銳的決定:當一個程式呼叫 write(),你要現在就把改動送往伺服器,還是先留在快取裡、稍後再送?立刻送出叫做寫穿(write-through)——安全,因為伺服器的副本永遠是最新的,但慢,因為每一次寫入都得付一趟網路來回。留在本地、之後再整批沖刷出去叫做寫回(write-back)——快,因為許多次寫入併成一次傳輸,但危險,因為若用戶端在沖刷之前當機,那些你的程式以為已經存好的位元組,就這麼憑空消失了。這正是你的 CPU 快取和本地緩衝快取面對的同一個寫穿對寫回的兩難,只是賭注被抬高了:中間的縫隙現在是一整個網路,而失效現在是另一台你無從檢查的機器。

現在把這個連到整個這一階最深的點子,也就是第 5 篇導覽會把它形式化的那一個。假設用戶端與伺服器之間的網路斷了——一個網路分區。用戶端的快取裡擱著一個待寫入,還有一個使用者在等。它面對一個真正的岔路。它可以拒絕往下走,直到它能搆到伺服器、確認寫入確實落地為止,這讓所有人的視野保持一致,但會在網路中斷期間讓檔案變得不可用。或者,它可以繼續從本地快取接受讀取與寫入,以保持可用,代價是它的副本與伺服器的副本,現在正悄悄地分道揚鑣。這就是縮小版的 CAP 定理:當一個分區把系統劈開時,你必須在一致性與可用性之間做選擇——你無法同時擁有兩者。分散式檔案系統並不例外;它只是通常選擇了可用性與較弱的一致性,並指望你不會注意到。

再一個誠實的角落,因為網路把一個友善的假設變成了陷阱。在本地磁碟上,一個被核心確認過的寫入,對大多數用途而言夠耐久了,而像 read() 這樣的操作,若你拿到錯誤,是可以安全地手動重試的。橫跨網路時,重試就微妙多了。若用戶端送出「附加這一行」、而回覆遺失了,這個附加到底發生了沒有?盲目重試可能會把這行附加兩次。這就是為什麼穩健的遠端操作會被設計成冪等的(idempotent)——寫成做一次和做五次都讓檔案落在同一個狀態——例如「把這些確切的位元組寫到位移 8192 處」,而不是「附加到結尾」。你在第 2 篇導覽中遇過的那個回覆遺失的曖昧,歸根究柢,正是為什麼分散式檔案系統,連自己的操作究竟是什麼意思,都得這麼小心翼翼地思考。

整幅圖景的樣貌

        CLIENT  (your laptop)                 SERVER  (in another room)
  +---------------------------+          +----------------------------+
  | program: read(fd,buf,n)   |          |  real local file system    |
  |        |                  |          |     ext4 / inodes / disk   |
  |   virtual file system     |          |            ^               |
  |        |                  |          |            |  real read    |
  |   NFS client + CACHE  <----+--hit-----+            |               |
  |        | (miss / stale)    |          |    NFS server (the truth)  |
  +--------|------------------+           +------------|---------------+
           |   RPC request  ============NETWORK=======>|
           |   (marshalled bytes)  <===== reply ========|

   cache HIT + timer fresh  ->  served locally, ZERO network messages
   cache MISS or expired    ->  one RPC round trip to the server
   network down (partition) ->  must choose: consistency OR availability
一張草圖看盡整套機制:你的程式透過虛擬檔案系統與本地快取對話;只有快取未命中、或項目過時,才會觸發一個橫跨網路、送往伺服器的 RPC,而伺服器握著那唯一的真相來源。

退一步,看見那個反覆出現的形狀。一個分散式檔案系統,就是你早已熟愛的檔案抽象,透過第 2 篇導覽的 RPC 機制端上桌,由一份快取加速,而這份快取立刻又把分歧問題請了回來,再用一套永遠是妥協的一致性方案打補丁。每一層都是來自更低一階的熟面孔——虛擬檔案系統、快取、掛載、檔案共用語意——只是在一個網路上重新相遇,而正如第 1 篇導覽所警告的,那裡沒有共享的時鐘、沒有共享的記憶體,失效還是部分的。這裡沒有什麼新的物理;它是那些老點子,迎向它們最艱難的考驗。

而它正好替接下來兩篇導覽鋪好了路。注意一下,答案有多常取決於一個關於順序與時間的問題:「我的快取副本比伺服器的舊嗎?」、「這次寫入是發生在那次之前、還是之後?」。沒有共享的時鐘,連比較來自兩台機器的兩個時間戳記,都是不可靠的。第 4 篇導覽會用邏輯時鐘與「先發生於」(happened-before)關係正面迎擊這件事,給我們一個有原則的方式,去為事件排序,而不必信任任何牆上的時鐘。第 5 篇導覽接著把我們在這裡瞥見的「一致性對可用性」那道岔路,化為一條定理,並用共識與複製,搭建出真正的容錯。檔案系統是讓你具體感受到那份痛的地方;那兩篇導覽,則是解藥。