Skip to content

13. 流式系統的哲學

如果一件事物以另一件事物為目的,那麼它的終極目的就不可能只是儲存自身。因此,船長不會把保全受託的船隻當作終極目的,因為船隻另有其目的,也就是航行。

(這段話常被引述為:如果船長的最高目標是保護船隻,他就會讓船永遠停在港口。)

聖托馬斯・阿奎那,《神學大全》(1265—1274)

我們在 第 2 章 中討論了構建 可靠可伸縮可維護 的應用與系統這一目標。這些主題貫穿全書各章:例如,我們討論了許多有助於提升可靠性的容錯演算法、提升可伸縮性的分片,以及提升可維護性的演化與抽象機制。

在本章中,我們將把所有這些想法彙集起來,並特別以 第 12 章 的流處理與事件驅動架構為基礎,形成一套能夠實現上述目標的應用開發哲學。與前幾章相比,本章的立場更為鮮明:它將深入闡述一種特定的哲學,而不是比較多種不同的方法。

資料整合

本書反覆出現的一個主題是:對於任何給定的問題,往往都有若干種解決方案,而每種方案都有不同的優點、缺點與利弊權衡。例如,在 第 4 章 討論儲存引擎時,我們看到了日誌結構儲存、B 樹和列式儲存;在 第 6 章 討論複製時,我們看到了單主、多主和無主複製。

如果你面臨的問題是“我想儲存一些資料,稍後再把它查出來”,那麼並不存在唯一正確的解決方案;不同的方法各自適用於不同的情形。軟體實現通常必須選擇一種具體的方法。要讓一條程式碼路徑既穩健又有良好的效能,本就已經很難;試圖在一個軟體中包辦一切,幾乎註定會得到糟糕的實現。

因此,選擇哪一種軟體工具最合適,同樣取決於具體情形。每一種軟體——即便是所謂的“通用”資料庫——都是針對某種特定的使用模式設計的。

面對如此繁多的選擇,第一個挑戰便是弄清各種軟體產品分別適合什麼情形。供應商不願告訴你自己的軟體不適合哪些工作負載,這完全可以理解;不過,希望前面的章節已經讓你知道應該提出哪些問題,從而讀懂言外之意,更好地理解其中的權衡。

然而,即使你已經完全掌握了工具及其適用情形之間的對應關係,仍然會遇到另一個挑戰:在複雜應用中,資料往往會以許多不同的方式使用。幾乎不可能有一種軟體適合資料的 所有 使用情形,因此,為了提供應用所需的功能,你不可避免地需要把幾種不同的軟體組合起來。

透過衍生資料組合專用工具

例如,為了支援任意關鍵詞查詢,通常需要把 OLTP 資料庫與全文檢索索引整合起來。儘管某些資料庫(例如 PostgreSQL)內建的全文索引功能足以滿足簡單應用的需要 1,但更複雜的檢索功能仍然需要專業的資訊檢索工具。反過來,搜尋索引通常又不適合作為持久的權威記錄系統,因此許多應用都需要組合使用這兩種工具,才能滿足全部需求。

我們在 “保持系統同步” 中談到過資料系統的整合問題。資料表示越多,整合就越困難。除了資料庫和搜尋索引,你可能還需要在分析系統(資料倉庫,或者批處理與流處理系統)中儲存資料副本;維護由原始資料衍生而來的快取或反正規化物件;讓資料經過機器學習、分類、排名或推薦系統;或者根據資料變更傳送通知。

理解資料流

為了滿足不同的訪問模式,如果同一份資料的副本需要儲存在多個儲存系統中,你就必須清楚地界定輸入和輸出:資料最先寫到哪裡?哪些表示是從哪些來源衍生出來的?怎樣才能以正確的格式,把資料送到所有正確的位置?

例如,你可以讓資料首先寫入作為權威記錄系統的資料庫,捕獲該資料庫中的變更(參見 “變更資料捕獲”),再按相同順序把這些變更應用到搜尋索引。如果變更資料捕獲(CDC)是更新索引的唯一途徑,你就可以確信索引完全衍生自權威記錄系統,因而與之保持一致(軟體缺陷除外)。在這個系統中,只有寫入資料庫才能提供新的輸入。

如果允許應用同時直接寫入搜尋索引和資料庫,就會引入 圖 12-4 所示的問題:兩個客戶端併發提交相互衝突的寫入,而兩個儲存系統卻以不同的順序處理它們。此時,無論資料庫還是搜尋索引,都不能“說了算”來決定寫入順序;它們可能作出相互矛盾的決定,並從此永久不一致。

如果能夠把所有使用者輸入都匯入一個系統,由它決定全部寫入的順序,那麼只需按相同順序處理這些寫入,就能更容易地衍生出資料的其他表示。這正是我們在 “共識的實踐” 中見過的狀態機複製方法的一種應用。使用變更資料捕獲還是事件溯源日誌並不是最重要的,真正重要的是先確定一個全序。

根據事件日誌更新衍生資料系統,往往可以做到確定性與冪等性(參見 “冪等性”),因而很容易從故障中恢復。

衍生資料與分散式事務

讓不同資料系統彼此保持一致的經典方法是使用分散式事務,如 “兩階段提交(2PC)” 所述。相比之下,使用衍生資料系統的效果如何?

從抽象層面看,兩者以不同手段實現了相似的目標。分散式事務使用鎖來實現互斥,以此決定寫入順序;而 CDC 與事件溯源則用日誌來排序。分散式事務透過原子提交確保變更恰好生效一次;基於日誌的系統通常依靠確定性重試與冪等性。

兩者最大的區別在於:事務系統通常保證一個值寫入後,立即就能讀到它的最新值(參見 “讀己之寫”)。衍生資料系統則往往非同步更新,因此預設並不保證讀操作所見的資料是最新的。

在一些範圍有限、願意承擔分散式事務成本的環境中,分散式事務已得到成功應用。然而,XA 的容錯性和效能都不理想(參見 “跨不同系統的分散式事務”),這嚴重限制了它的實用性。或許可以設計出一種更好的分散式事務協議,但要讓這種協議獲得廣泛採用,並與現有工具整合,將極具挑戰,短期內也不太可能實現。

由於目前還沒有一種得到廣泛支援的優良分散式事務協議,基於日誌的衍生資料是整合不同資料系統最有前景的方法。不過,讀己之寫等保證確實很有用;一味告訴所有人“最終一致性不可避免——接受現實,學會應付吧”,並無助益(至少在沒有妥善說明 如何 應對時是這樣)。

本章稍後將討論一些在非同步衍生系統之上實現更強保證的方法,努力在分散式事務與基於日誌的非同步系統之間找到一條中間道路。

全序的侷限

對於規模足夠小的系統,構建全序事件日誌完全可行(採用單主複製的資料庫如此流行,便證明了這一點:它們構建的正是這樣的日誌)。不過,隨著系統擴充套件到更大、更複雜的工作負載,侷限便開始顯現:

  • 在大多數情況下,構建全序日誌要求所有事件都經過 單個領導者節點,由它決定順序。如果事件吞吐量超出單臺機器的處理能力,就需要把日誌分片到多臺機器上。此時,兩個不同分片中的事件孰先孰後便不再明確。

  • 如果伺服器分佈在多個 地理上分散 的區域——例如,為了容忍整個資料中心離線——通常會在每個資料中心分別設定領導者,因為網路延遲會使跨資料中心的同步協調效率低下。這意味著,來自兩個不同資料中心的事件之間沒有確定的順序。

  • 當應用以 微服務 形式部署時,一種常見的設計選擇是把每項服務及其持久狀態作為獨立單元部署,不讓服務之間共享持久狀態。當兩個事件分別產生於不同服務時,它們之間沒有確定的順序。

  • 一些應用會在客戶端維護狀態:使用者輸入後立即更新,不等待伺服器確認,甚至在離線時仍能繼續工作。在這樣的應用中,客戶端與伺服器很可能以不同的順序看到事件。

從形式上說,決定事件全序的問題稱為 全序廣播,它等價於共識(參見 “共識的多面性”)。大多數共識演算法針對的是單個節點吞吐量足以處理整個事件流的情形,並沒有提供讓多個節點共同分擔事件排序工作的機制。

排序事件以捕獲因果關係

如果事件之間沒有因果聯絡,缺少全序並不是什麼大問題,因為併發事件可以按任意順序排列。有些情形也很容易處理:例如,同一個物件有多次更新時,只要把特定物件 ID 的所有更新都路由到同一個日誌分片,就能為它們建立全序。然而,因果依賴有時會以更隱蔽的方式出現。

例如,考慮一個社交網路服務,以及兩個曾是情侶、但剛剛分手的使用者。其中一人先把另一人移出好友列表,然後向剩餘好友傳送訊息,抱怨自己的前任。這個使用者的本意是,不讓前任看到這條粗魯的訊息,因為訊息是在好友關係解除之後傳送的。

然而,如果一個系統把好友關係狀態和訊息存放在不同的位置,那麼 解除好友 事件與 傳送訊息 事件之間的順序依賴可能會丟失。如果沒有捕捉到這種因果依賴,負責傳送新訊息通知的服務就可能先處理 傳送訊息 事件、後處理 解除好友 事件,從而錯誤地向前任發出通知。

在這個例子中,通知實際上是訊息與好友列表之間的連線,因此它涉及我們之前討論過的連線時序問題(參見 “連線的時間依賴性”)。遺憾的是,這個問題似乎沒有簡單的答案 23。一些可能的起點包括:

  • 邏輯時間戳無需協調即可提供全序(參見 “ID 生成器和邏輯時鐘”),因而在全序廣播不可行時或許能派上用場。然而,接收方仍需處理亂序到達的事件,而且系統還必須傳遞額外的元資料。

  • 如果能用一條日誌事件記錄使用者作出決定前所看到的系統狀態,併為該事件賦予唯一識別符號,那麼後續事件便可引用這個事件 ID,從而記錄因果依賴 4

  • 衝突解決演算法(參見 “自動解決衝突”)有助於處理以意外順序到達的事件。它們適合用於維護狀態;但如果某個操作會產生外部副作用(例如向用戶傳送通知),這些演算法就無能為力了。

或許未來會出現新的應用開發模式,能夠高效捕捉因果依賴、正確維護衍生狀態,而不必強迫所有事件都經過全序廣播這一瓶頸。

批處理與流處理

資料整合的目標,是確保資料以正確的形式出現在所有正確的位置。為此,需要消費輸入,進行轉換、連線、過濾、聚合、模型訓練與評估,最終再寫入適當的輸出。批處理器和流處理器正是實現這一目標的工具。批處理與流處理的輸出是衍生資料集,例如搜尋索引、物化檢視、向用戶展示的推薦結果、聚合指標,等等。

正如我們在 第 11 章第 12 章 中看到的,批處理與流處理有許多共同原則;兩者最根本的區別在於,流處理器處理的是無界資料集,而批處理的輸入大小已知且有限。

維護衍生狀態

批處理帶有很強的函式式風格(即使程式碼並不是用函數語言程式設計語言編寫的):它鼓勵使用確定性的純函式,其輸出只取決於輸入,除了明確的輸出之外沒有其他副作用;輸入被視為不可變,輸出則只能追加。流處理與之類似,不過它擴充套件了運算元,使其能夠維護受管理且容錯的狀態。

輸入和輸出定義明確的確定性函式,不僅有利於容錯,也能簡化對組織內部資料流的推理 5。無論衍生資料是搜尋索引、統計模型還是快取,都可以把它看成資料管道:從一項事物衍生出另一項事物,讓一個系統的狀態變更經過函式式應用程式碼,再把相應效果施加到衍生系統上。這樣思考大有裨益。

原則上,衍生資料系統也可以同步維護,就像關係資料庫在寫入被索引表的同一事務中,同步更新二級索引一樣。然而,非同步恰恰是基於事件日誌的系統能夠保持穩健的原因:系統某一部分的故障可以被侷限在本地;分散式事務則會在任何一個參與者失敗時中止,因而容易把故障擴散到系統其他部分,將其放大。

我們在 “分片與二級索引” 中看到,二級索引常常跨越分片邊界。帶有二級索引的分片系統,要麼需要把寫入傳送到多個分片(按詞項分割槽的索引),要麼需要把讀取傳送到所有分片(按文件分割槽的索引)。如果非同步維護索引,這種跨分片通訊也最為可靠、最具可伸縮性 6

為應用演化而重新處理資料

維護衍生資料時,批處理和流處理都很有用。流處理可以用很低的延遲把輸入中的變更反映到衍生檢視中;批處理則可以重新處理大量累積的歷史資料,從已有資料集衍生出新的檢視。

尤其是,重新處理現有資料為維護和演化系統、支援新功能與變化後的需求,提供了一種良好機制。如果不能重新處理,模式演化就只能侷限於簡單改動,例如為記錄新增一個新的可選欄位,或者增加一種新的記錄型別。而有了重新處理,就可以把資料集重組為完全不同的模型,從而更好地滿足新需求。

鐵路上的模式遷移

大規模的“模式遷移”也會發生在非計算機系統中。例如,19 世紀英國鐵路建設初期,軌距(兩條鐵軌之間的距離)存在多種相互競爭的標準。為一種軌距建造的列車無法在另一種軌距的軌道上行駛,這限制了鐵路網路可能實現的互聯 7

1846 年終於確定了統一的標準軌距,其他軌距的線路都必須改造——可怎樣才能在不讓鐵路停運幾個月乃至幾年的情況下完成改造?解決辦法是先增加第三條鐵軌,把線路改成 雙軌距混合軌距。這種改造可以逐步進行;完成之後,兩種軌距的列車都能在同一條線路上行駛,各自使用三條鐵軌中的兩條。最終,所有列車都改用標準軌距後,非標準軌距所用的那條鐵軌便可拆除。

以這種方式“重新處理”既有軌道,並讓新舊版本並行存在,就能在數年時間裡逐步改變軌距。儘管如此,這仍是一項昂貴的工程,所以非標準軌距至今依然存在。例如,舊金山灣區的 BART 系統使用的軌距就不同於美國大多數鐵路。

衍生檢視允許系統 漸進式 演化。如果要重組一個數據集,並不需要突然切換完成遷移。你可以把舊模式和新模式作為底層同一份資料上的兩個獨立衍生檢視,並排維護。隨後,先把少量使用者轉向新檢視,以測試其效能並發現缺陷,同時讓大多數使用者繼續使用舊檢視。之後逐步提高訪問新檢視的使用者比例,最終刪除舊檢視 89

這種漸進遷移的妙處在於:如果出了問題,過程中的每個階段都很容易逆轉,始終有一個可用的系統供你退回。由於不可逆損害的風險降低,你可以更有信心地繼續推進,從而更快地改進系統 10

統一批處理與流處理

統一批處理與流處理的一項早期提案是 Lambda 架構 11。它存在不少問題 12,如今已很少使用。較新的系統允許在同一系統中實現批計算(重新處理歷史資料)與流計算(在事件到達時處理事件)13;這種方法有時稱為 Kappa 架構 12

要在一個系統中統一批處理與流處理,需要具備以下功能:

  • 能夠讓歷史事件透過處理近期事件流的同一個處理引擎重放。例如,基於日誌的訊息代理可以重放訊息,一些流處理器也能從分散式檔案系統或物件儲存中讀取輸入。

  • 為流處理器提供恰好一次語義——也就是說,即使實際發生了故障,也要確保輸出與從未發生故障時相同。與批處理一樣,這要求丟棄所有失敗任務的部分輸出。

  • 提供按事件時間而非處理時間劃分視窗的工具,因為重新處理歷史事件時,處理時間沒有任何意義。例如,Apache Beam 提供了表達這類計算的 API,之後可以用 Apache Flink 或 Google Cloud Dataflow 來執行。

分拆資料庫

在最抽象的層面上,資料庫、批處理器、流處理器和作業系統執行著相同的功能:儲存一些資料,並允許你處理和查詢這些資料 1415。資料庫把資料儲存為某種資料模型中的記錄(表中的行、文件、圖中的頂點等),作業系統的檔案系統則把資料儲存在檔案中——但二者本質上都是“資訊管理”系統 16。正如我們在 第 11 章 中看到的,批處理器就像 Unix 的分散式版本。

當然,實際差異仍然很多。例如,許多檔案系統無法很好地處理一個包含 1000 萬個小檔案的目錄,而資料庫中有 1000 萬條小記錄卻完全稀鬆平常。儘管如此,作業系統與資料庫之間的異同仍然值得探究。

Unix 與關係資料庫採用截然不同的哲學來處理資訊管理問題。Unix 認為自己的目標是向程式設計師提供一種合乎邏輯、但層次相當低的硬體抽象;關係資料庫則希望向應用程式設計師提供高層次抽象,隱藏磁碟資料結構、併發、崩潰恢復等複雜問題。Unix 發展出了管道以及本質上只是位元組序列的檔案,資料庫則發展出了 SQL 與事務。

哪種方法更好?當然要看你想要什麼。Unix 的“簡單”,在於它只是硬體資源外面一層相當薄的包裝;關係資料庫的“簡單”,則在於一條簡短的宣告式查詢就能借助大量強大的基礎設施(查詢最佳化、索引、連線方法、併發控制、複製等),而查詢作者無需瞭解實現細節。

這兩種哲學之間的張力已經持續數十年(Unix 和關係模型都出現於 20 世紀 70 年代初),至今仍未消解。例如,可以把 NoSQL 運動理解為:試圖把 Unix 式的低層抽象方法應用於分散式 OLTP 資料儲存領域。

本節試圖調和這兩種哲學,希望能夠兼取二者之長。

組合使用資料儲存技術

在本書的過程中,我們討論了資料庫提供的各種功能及其工作原理,其中包括:

  • 二級索引,使你可以根據欄位值高效搜尋記錄;

  • 物化檢視,一種預先計算的查詢結果快取;

  • 複製日誌,使其他節點上的資料副本保持最新;以及

  • 全文檢索索引,允許在文字中進行關鍵詞搜尋,一些關係資料庫也內建了這種索引 1

第 11 章第 12 章 中,我們也遇到了類似主題。我們談過如何構建全文檢索索引、如何維護物化檢視,以及如何透過變更資料捕獲,把資料庫中的變更復制到衍生資料系統。

資料庫內建的功能,與人們使用批處理器和流處理器構建的衍生資料系統,似乎存在諸多相似之處。

建立索引

想一想,在關係資料庫中執行 CREATE INDEX 建立新索引時會發生什麼。資料庫必須掃描表的一致性快照,取出所有要索引的欄位值,對其排序,再寫出索引。接著,它還必須處理拍攝一致性快照後積壓的所有寫入(假設建立索引時沒有鎖住表,因此寫入可以繼續)。完成之後,每當事務寫入該表,資料庫都必須繼續更新索引。

這個過程與設定新的從庫副本極其相似(參見 “設定新的副本”),也很像在流處理系統中引導變更資料捕獲(參見 “初始快照”)。

每次執行 CREATE INDEX,資料庫本質上都是在重新處理現有資料集,並把索引衍生為既有資料之上的一個新檢視。現有資料可能是狀態快照,而不是曾經發生過的所有變更的日誌,但二者密切相關。

一切的元資料庫

從這個角度看,整個組織的資料流開始像一個巨大的資料庫 5。每當批處理、流處理或 ETL 過程把資料從一種位置和形式傳送到另一種位置和形式,它就像是在充當維護索引或物化檢視的資料庫子系統。

這樣看來,批處理器和流處理器就像觸發器、儲存過程與物化檢視維護演算法的精巧實現;它們維護的衍生資料系統則像不同種類的索引。例如,關係資料庫可能支援 B 樹索引、雜湊索引、空間索引以及其他索引。在新興的衍生資料系統架構中,這些能力不再作為單個整合資料庫產品的功能來實現,而是由各種不同的軟體提供,執行在不同機器上,並由不同團隊管理。

這些發展未來會把我們帶向何方?如果從“沒有任何一種資料模型或儲存格式適合所有訪問模式”這一前提出發,那麼仍有兩條途徑,可以把不同的儲存與處理工具組合成一個協調一致的系統:

聯合資料庫:統一讀取

可以為各種底層儲存引擎和處理方法提供統一的查詢介面——這種方法稱為 聯合資料庫多模儲存 1718。例如,PostgreSQL 的 外部資料封裝器 功能符合這種模式,Trino、Hoptimator 和 Xorq 等聯合查詢引擎也是如此。需要專用資料模型或查詢介面的應用仍可直接訪問底層儲存引擎;而希望組合不同位置資料的使用者,則可以透過聯合介面輕鬆完成。

聯合查詢介面延續了關係模型的傳統:提供帶有高層查詢語言和優雅語義的單一整合系統,但其實現非常複雜。

分拆資料庫:統一寫入

聯合能夠解決跨多個不同系統的只讀查詢問題,卻無法很好地解決這些系統之間的寫入 同步。前面說過,在單個數據庫中,建立一致的索引是一項內建功能。當我們組合多個儲存系統時,同樣需要確保所有資料變更最終到達所有正確的位置,即使發生故障也不例外。讓儲存系統更容易可靠地連線起來(例如透過變更資料捕獲和事件日誌),就像把資料庫的索引維護功能 分拆 出來,使它能夠跨不同技術同步寫入 519

分拆方法遵循 Unix 的傳統:小工具各自做好一件事 20,透過統一的低層 API(管道)通訊,再用更高層的語言(shell)組合起來 14

讓分拆行得通

聯合與分拆是一枚硬幣的兩面:都是用不同元件組成可靠、可伸縮、可維護的系統。聯合只讀查詢需要把一種資料模型對映到另一種資料模型,這需要一些思考,但歸根結底是個相當容易處理的問題。讓多個儲存系統的寫入保持同步,才是更困難的工程問題,因此這裡將重點討論它。

同步寫入的傳統方法要求跨異構儲存系統使用分散式事務 17,但如前所述,這種方法問題重重。單個儲存系統或流處理系統內部的事務是可行的;然而,當資料跨越不同技術之間的邊界時,帶有冪等寫入的非同步事件日誌要穩健、實用得多。

例如,一些流處理器內部使用分散式事務來實現恰好一次語義,而且效果可以很好。然而,如果一項事務需要涉及由不同團隊編寫的系統(例如從流處理器把資料寫入分散式鍵值儲存或搜尋索引),缺少標準化事務協議就會使整合難上加難。帶有冪等消費者的有序事件日誌是一種簡單得多的抽象,因此更有可能跨異構系統實現 5

基於日誌的整合有一項巨大優勢:各個元件之間 鬆散耦合。這種優勢體現在兩個方面:

  1. 在系統層面,非同步事件流使整個系統更能抵禦個別元件中斷或效能下降。如果某個消費者速度很慢或發生故障,事件日誌可以緩衝訊息,讓生產者和其他消費者不受影響地繼續執行。故障消費者修復後可以追趕進度,因此不會漏掉任何資料,而故障也被限制在區域性。相比之下,分散式事務中的同步互動往往會把區域性故障升級為大規模失效。

  2. 在人員層面,分拆資料系統使不同團隊可以彼此獨立地開發、改進和維護不同的軟體元件與服務。專業化讓每個團隊都能專心做好一件事,並透過定義明確的介面與其他團隊的系統互動。事件日誌提供的介面既足夠強大,可以表達相當強的一致性屬性(得益於事件的永續性與順序),又足夠通用,幾乎適用於任何種類的資料。

分拆式系統與整合式系統

即使分拆確實成為未來的方向,它也不會取代現有形態的資料庫——人們仍會一如既往地需要資料庫。流處理器需要資料庫來維護狀態,批處理器與流處理器的輸出也需要資料庫來提供查詢服務。專用查詢引擎對特定工作負載仍然十分重要:例如,資料倉庫中的查詢引擎針對探索式分析查詢進行了最佳化,很擅長處理這類工作負載。

執行多種基礎設施所帶來的複雜性可能是個問題:每種軟體都有學習曲線、配置問題和運維怪癖,因此值得儘量減少部署中的活動部件。對於其設計所針對的工作負載,與用應用程式碼把多個工具拼接起來的系統相比,單一整合軟體產品也可能提供更好、更可預測的效能 21。為並不需要的規模構建系統只是徒費力氣,還可能把你鎖定在僵化的設計中;這實際上是一種過早最佳化。

分拆的目標並不是在特定工作負載的效能上與單個數據庫競爭;它的目標是讓你能夠組合多個不同的資料庫,從而在遠比單一軟體所能覆蓋的工作負載範圍內取得良好效能。它追求的是廣度,而不是深度。

因此,如果有一種技術能夠滿足你的全部需求,最好的選擇很可能就是直接使用該產品,而不是試圖用更低層的元件自行重新實現。只有當沒有任何單一軟體能夠滿足全部需求時,分拆與組合的優勢才會顯現。

用於組合資料系統的工具正在不斷完善:Debezium 可以從許多資料庫中抽取變更流;Kafka 協議正在成為事件流事實上的標準;增量檢視維護引擎(參見 “增量檢視維護”)則讓複雜查詢的快取可以預先計算並持續更新。

圍繞資料流設計應用

底層資料一旦變化便更新衍生資料,這個基本想法並不新鮮。例如,電子表格早已有強大的資料流程式設計能力 22:你可以在一個單元格中放入公式(比如求另一列單元格之和),每當公式的任何輸入發生變化,公式結果都會自動重新計算。這正是我們希望資料系統在系統層面做到的事:資料庫中的一條記錄發生變化時,該記錄的所有索引都應自動更新,所有依賴它的快取檢視或聚合結果也應自動重新整理。你不必操心重新整理過程的技術細節,只需要相信它會正確執行。

因此,大多數資料系統仍有許多地方需要向 1979 年的 VisiCalc 學習 23。與電子表格不同,當今的資料系統必須容錯、可伸縮,並能持久地儲存資料;它們還必須能夠整合不同團隊在不同時間編寫的異構技術,並複用既有庫與服務。指望所有軟體都使用某一種語言、框架或工具開發,並不現實。

本節將進一步展開這些想法,探討如何圍繞資料庫分拆與資料流的理念來構建應用。

應用程式碼作為衍生函式

一個數據集從另一個數據集衍生出來時,需要經過某種轉換函式。例如:

  • 二級索引是一種衍生資料集,其轉換函式很直接:對於基礎表中的每一行或每一份文件,取出要索引的列值或欄位值,再按這些值排序(假設使用按鍵排序的 SSTable 或 B 樹索引)。

  • 全文檢索索引的建立過程,是先應用語言檢測、分詞、詞幹提取或詞形還原、拼寫糾正、同義詞識別等各種自然語言處理函式,再構建用於高效查詢的資料結構(例如倒排索引)。

  • 在機器學習系統中,可以把模型看作是對訓練資料應用各種特徵提取和統計分析函式後得到的衍生物。模型應用於新的輸入資料時,模型輸出衍生自輸入與模型(因此也間接衍生自訓練資料)。

  • 快取通常以使用者介面(UI)將要展示的形式儲存資料聚合結果。因此,填充快取需要知道 UI 引用了哪些欄位;UI 的變更可能要求更新快取填充方式的定義,並重建快取。

二級索引的衍生函式需求極其常見,因此許多資料庫都把它作為核心功能內建其中,只要執行 CREATE INDEX 就能呼叫。對於全文索引,常見語言的基礎語言學功能或許內置於資料庫,但更複雜的功能往往需要針對具體領域調整。在機器學習中,特徵工程出了名地依賴具體應用,常常必須融入應用的使用者互動與部署方式等詳細知識 24

如果建立衍生資料集的函式不像建立二級索引那樣是標準化的套路,就需要用自定義程式碼處理應用特有的部分。而許多資料庫恰恰難以應付這種自定義程式碼。關係資料庫通常支援觸發器、儲存過程和使用者定義函式,可以藉此在資料庫內部執行應用程式碼;但在資料庫設計中,這些能力多少像是事後補上的。

分離應用程式碼與狀態

理論上,資料庫可以像作業系統一樣,成為任意應用程式碼的部署環境。然而實踐證明,資料庫並不適合這個用途。依賴項與包管理、版本控制、滾動升級、可演化性、監控、指標、呼叫網路服務、與外部系統整合——資料庫都無法很好地滿足這些現代應用開發需求。

另一方面,Kubernetes、Docker、Mesos、YARN 等部署和叢集管理工具,正是為執行應用程式碼而專門設計的。它們專注於做好這一件事,因此遠勝於那些只把執行使用者定義函式當作眾多功能之一的資料庫。

今天,大多數 Web 應用都以無狀態服務的形式部署:任意使用者請求都可以路由到任意應用伺服器,伺服器發出響應後便忘掉該請求的一切。這種部署方式很方便,因為伺服器可以隨意增加或移除;不過狀態總得有個去處——通常是資料庫。總體趨勢是把無狀態應用邏輯與狀態管理(資料庫)分開:既不把應用邏輯放進資料庫,也不把持久狀態放進應用 25。函數語言程式設計社群喜歡開玩笑說:“我們信奉 教會(Church)國家(state) 分離。”26

Note

解釋笑話通常會毀掉笑話,不過為了照顧沒聽懂的讀者,這裡還是解釋一下。Church 指數學家阿隆佐・邱奇(Alonzo Church);他創造了 lambda 演算——一種早期計算形式,也是大多數函數語言程式設計語言的基礎。Lambda 演算沒有可變狀態(也就是沒有可以被覆寫的變數),所以也可以說,可變狀態與 Church 的工作彼此分離。

在這種典型的 Web 應用模型中,資料庫充當一種可以透過網路同步訪問的可變共享變數。應用可以讀取和更新這個變數,資料庫則負責將它持久儲存,並提供一定的併發控制與容錯能力。

然而,在大多數程式語言中,你無法訂閱可變變數的變化——只能定期讀取它。與電子表格不同,變數的值發生變化時,讀取者不會收到通知。(你可以在自己的程式碼中實現這種通知,這稱為 觀察者模式,但大多數語言都沒有把這種模式作為內建功能。)

資料庫繼承了這種對待可變資料的被動方式:如果想知道資料庫內容是否發生變化,你通常只能輪詢(即定期重複查詢)。訂閱變更才剛剛開始成為資料庫的一項功能。

資料流:狀態變更與應用程式碼的相互作用

從資料流角度思考應用,意味著要重新協商應用程式碼與狀態管理之間的關係。我們不再把資料庫當作受應用操縱的被動變數,而是更加關注狀態、狀態變更以及處理這些變更的程式碼之間如何相互作用、彼此協作。應用程式碼響應一處的狀態變更,並在另一處觸發狀態變更。

我們已經在變更資料捕獲、Actor 模型、觸發器和增量檢視維護中見過這種想法。分拆資料庫,就是把這一想法應用到主資料庫之外的衍生資料集建立過程:快取、全文檢索索引、機器學習系統或分析系統。為此,我們可以使用流處理與訊息傳遞系統。

維護衍生資料需要以下性質,而基於日誌的訊息代理能夠提供這些性質:

  • 維護衍生資料時,狀態變更的順序往往十分重要(如果多個檢視都衍生自同一事件日誌,它們就必須按相同順序處理事件,才能彼此保持一致)。

  • 容錯必不可少:哪怕只丟失一條訊息,衍生資料集也會與資料來源永久失去同步。訊息傳遞和衍生狀態更新都必須可靠。

穩定的訊息順序和容錯的訊息處理要求相當嚴格,但它們的開銷比分散式事務小得多,運維上也更加穩健。現代流處理器可以大規模提供這些順序與可靠性保證,並允許應用程式碼作為流運算元執行。

這類應用程式碼可以執行任意處理,補足資料庫內建衍生函式通常不具備的能力。就像用管道串聯起來的 Unix 工具一樣,流運算元也可以組合起來,圍繞資料流構建大型系統。每個運算元都以狀態變更流作為輸入,併產生其他狀態變更流作為輸出。

流處理器與服務

目前占主導地位的應用開發風格,是把功能拆分成一組 服務,服務之間透過 REST API 等同步網路請求通訊。與單體應用相比,這種面向服務架構的主要優勢,在於通過鬆散耦合實現組織上的可伸縮性:不同團隊可以分別負責不同服務,從而減少團隊間的協調工作(前提是各項服務能夠獨立部署和更新)。

把流運算元組合成資料流系統,與微服務方法有許多相似之處 2728。不過,兩者底層的通訊機制截然不同:前者使用單向非同步訊息流,後者使用同步的請求/響應互動。

除了 “事件驅動的架構” 中列出的優勢(例如更好的容錯性),資料流系統還可以獲得優於傳統 REST API 或 RPC 的效能。例如,假設顧客購買一件以一種貨幣定價、卻用另一種貨幣付款的商品。要完成貨幣換算,就需要知道當前匯率。這個操作可以透過兩種方式實現 2729

  1. 採用微服務方法時,處理購買操作的程式碼很可能查詢匯率服務或資料庫,以獲得某種貨幣的當前匯率。

  2. 採用資料流方法時,處理購買操作的程式碼會預先訂閱匯率更新流,並在匯率發生變化時把當前匯率記錄到本地資料庫中。真正處理購買操作時,只需查詢本地資料庫。

第二種方法用本地資料庫查詢,取代了對另一項服務的同步網路請求(本地資料庫可能就在同一臺機器上,甚至在同一個程序中)。在微服務方法中,也可以把匯率快取在處理購買操作的服務本地,從而避免同步網路請求。然而,為了讓快取保持新鮮,就必須定期輪詢匯率更新,或者訂閱變更流——這恰好就是資料流方法所做的事。

資料流方法不僅更快,而且面對另一項服務發生故障時也更穩健。最快、最可靠的網路請求,就是根本不發出網路請求!RPC 不復存在,取而代之的是購買事件與匯率更新事件之間的流連線。

這種連線依賴於時間:如果日後重新處理購買事件,匯率早已改變。若想重建原始輸出,就必須獲得當初購買時的歷史匯率。無論是查詢服務還是訂閱匯率更新流,都需要處理這種時間依賴性(參見 “連線的時間依賴性”)。

訂閱變更流,而不是等到需要時才查詢當前狀態,使我們更接近電子表格式的計算模型:某項資料一旦變化,所有依賴它的衍生資料都能迅速更新。時間依賴連線等方面仍有許多懸而未決的問題,但圍繞資料流理念構建應用,是一個很有前景、值得探索的方向。

觀察衍生狀態

從抽象層面看,上一節討論的資料流系統提供了一套建立並持續更新衍生資料集(例如搜尋索引、物化檢視和預測模型)的過程。我們把這個過程稱為 寫路徑:每當有資訊寫入系統,它可能經過多輪批處理和流處理,最終所有衍生資料集都會更新,納入這次寫入的資料。圖 13-1 展示了更新搜尋索引的例子。

圖 13-1. 在搜尋索引中,寫入(文件更新)與讀取(查詢)相遇。

但你最初為什麼要建立衍生資料集?很可能是為了日後查詢。這就是 讀路徑:處理使用者請求時,從衍生資料集中讀取資料,也許再對結果做些處理,最後構造返回給使用者的響應。

寫路徑和讀路徑合在一起,涵蓋了資料的完整旅程:從收集資料的地方,直至資料被消費的地方(很可能由另一個人消費)。寫路徑是預先計算的那一段——也就是資料一到達便立即完成,不管有沒有人要求檢視。讀路徑則只在有人請求時才發生。如果你熟悉函數語言程式設計語言,或許會發現寫路徑類似於立即求值,讀路徑則類似於惰性求值。

圖 13-1 所示,衍生資料集正是寫路徑與讀路徑相遇之處。它代表著寫入時所需工作量與讀取時所需工作量之間的權衡。

物化檢視與快取

全文檢索索引就是一個很好的例子:寫路徑更新索引,讀路徑在索引中檢索關鍵詞。讀寫兩端都要做些工作。寫入需要更新文件中所有詞項的索引條目;讀取需要搜尋查詢中的每個詞,並應用布林邏輯,找出包含查詢中 所有 詞(AND 運算子)的文件,或者包含每個詞的 任一 同義詞(OR 運算子)的文件。

如果沒有索引,搜尋查詢就必須掃描所有文件(類似 grep);文件數量一多,代價便會極其高昂。沒有索引意味著寫路徑上的工作較少(無需更新索引),但讀路徑上的工作會多得多。

反過來,也可以設想預先計算所有可能查詢的搜尋結果。這樣一來,讀路徑的工作就少了:無需進行布林邏輯計算,只要找到相應查詢的結果並返回即可。然而,寫路徑會昂貴得多:可能提出的搜尋查詢集合是無限的(或者至少隨語料庫中的詞項數量呈指數增長),因此不可能預先計算所有搜尋結果。

另一種選擇是,只為一組固定的最常見查詢預先計算搜尋結果,使這些查詢無需訪問索引便能迅速得到響應;不常見的查詢仍由索引處理。這通常稱為常見查詢的 快取,不過也可以稱為物化檢視:一旦出現應當納入某項常見查詢結果的新文件,它就必須隨之更新。

這個例子說明,索引並不是寫路徑與讀路徑之間唯一可能的邊界。既可以快取常見搜尋結果;文件數量較少時,也可以不用索引,進行類似 grep 的掃描。從這個角度看,快取、索引與物化檢視的作用很簡單:它們移動了讀路徑與寫路徑之間的邊界。我們透過預先計算結果,讓寫路徑多做一些工作,從而節省讀路徑的開銷。

寫路徑與讀路徑之間的工作邊界,實際上正是 “案例研究:社交網路首頁時間線” 中社交網路示例的主題。在那個例子中,我們還看到,名人與普通使用者的讀寫路徑邊界可以劃在不同位置。走過 500 頁,我們又回到了原點!

有狀態、可離線的客戶端

寫路徑與讀路徑之間的邊界很有意思,因為我們可以討論如何移動這條邊界,並探究這種移動在實踐中意味著什麼。下面換一個語境來看看這個想法。

過去,Web 瀏覽器是無狀態客戶端,只有接入網際網路時才能做有用的事(離線時差不多隻能在先前聯網載入的頁面裡上下滾動)。然而,如今的單頁 JavaScript Web 應用具備了許多有狀態能力,包括客戶端使用者介面互動,以及 Web 瀏覽器內的持久化本地儲存。移動應用同樣可以在裝置上儲存大量狀態,大多數使用者互動也無需往返伺服器。

我們在 “同步引擎與本地優先軟體” 中看到,持久本地狀態使一類應用成為可能:使用者無需聯網便可離線工作,有網路連線時再在後臺與遠端伺服器同步 30。移動裝置的蜂窩網路連線有時緩慢而不可靠;如果使用者介面不用等待同步網路請求,而且應用大體上可以離線工作,對使用者而言將是巨大優勢。

當我們擺脫“無狀態客戶端與中央資料庫通訊”這一假設,轉而在終端使用者裝置上維護狀態時,一個充滿新機會的世界便隨之開啟。尤其是,可以把裝置上的狀態視為 伺服器狀態的快取。螢幕上的畫素,是客戶端應用中模型物件的物化檢視;而模型物件,則是遠端資料中心狀態的本地副本 31

將狀態變更推送給客戶端

在典型網頁中,如果你用 Web 瀏覽器載入頁面,隨後伺服器上的資料發生變化,那麼除非重新載入頁面,否則瀏覽器不會得知這一變化。瀏覽器只在某個時間點讀取資料,並假定資料是靜態的——它不會訂閱伺服器更新。因此,瀏覽器中的狀態是一份陳舊快取,除非明確輪詢變更,否則不會更新。(RSS 等基於 HTTP 的 Feed 訂閱協議,其實只是一種基本的輪詢。)

較新的協議已經超越 HTTP 的基本請求/響應模式:伺服器傳送事件(EventSource API)與 WebSocket 提供了通訊通道,讓 Web 瀏覽器可以與伺服器保持一條開啟的 TCP 連線;只要連線仍在,伺服器便能主動向瀏覽器推送訊息。這樣一來,伺服器就能把客戶端本地所存狀態的任何變更主動告訴它,從而降低客戶端狀態的陳舊程度。

用寫路徑與讀路徑模型來說,主動把狀態變更一路推送到客戶端裝置,意味著把寫路徑延伸至終端使用者。客戶端首次初始化時仍需要透過讀路徑取得初始狀態,但此後便可依靠伺服器發來的狀態變更流。我們討論過的流處理與訊息傳遞理念,並不只限於在資料中心內執行:還可以繼續向外延伸,直達終端使用者裝置 32

裝置有時會離線,在此期間無法收到伺服器發出的任何狀態變更通知。不過,這個問題我們已經解決過了:在 “消費者偏移量” 中,我們討論了基於日誌的訊息代理的消費者如何在故障或斷開後重新連線,並確保不漏掉斷線期間到達的任何訊息。同樣的技術也適用於單個使用者:每臺裝置都是一條小型事件流的訂閱者。

端到端事件流

React 與 Elm 等用於開發有狀態客戶端和使用者介面的工具 33,已經能夠在底層狀態發生變化時更新渲染出的使用者介面。把這種程式設計模型進一步擴充套件,讓伺服器也能把狀態變更事件推入客戶端事件管道,是一件非常自然的事。

這樣,狀態變更就能沿端到端的寫路徑流動:從一臺裝置上觸發狀態變更的互動開始,經過事件日誌、多個衍生資料系統與流處理器,一路到達另一臺裝置上觀察該狀態的使用者介面。這些狀態變更可以用相當低的延遲傳播——例如端到端不到一秒。

一些應用(例如即時通訊與線上遊戲)已經採用這種“實時”架構(這裡指低延遲互動,而非響應時間保證)。但我們為什麼不以這種方式構建所有應用?

挑戰在於,無狀態客戶端與請求/響應互動的假設,已經深深嵌入我們的資料庫、庫、框架和協議。許多資料儲存都支援一次請求返回一次響應的讀寫操作,但能夠訂閱變更的卻少得多——也就是讓一次請求隨著時間推移返回一連串響應。

為了把寫路徑一直延伸到終端使用者,我們必須從根本上重新思考許多系統的構建方式:離開請求/響應互動,轉向釋出/訂閱資料流 31。這需要付出努力,但也會帶來響應更靈敏的使用者介面,以及更好的離線支援。

讀也是事件

前面說過,當流處理器把衍生資料寫入某個儲存(資料庫、快取或索引),而這個儲存隨後接受查詢時,它就充當了寫路徑與讀路徑之間的邊界。該儲存允許對資料進行隨機訪問讀取;否則,讀取查詢就必須掃描整條事件日誌。

在許多情況下,資料儲存與流處理系統彼此分離。但別忘了,流處理器也需要維護狀態,才能執行聚合與連線。這種狀態通常隱藏在流處理器內部,不過有些框架也允許外部客戶端查詢它 34,從而讓流處理器本身變成一種簡單的資料庫。

我們再把這個想法推進一步。到目前為止,寫入透過事件日誌進入儲存,而讀取則是短暫的網路請求,直接發往儲存待查資料的節點。這種設計很合理,卻不是唯一選擇。我們也可以把讀取請求表示成事件流,把讀事件和寫事件都送入流處理器;處理器透過向輸出流發出讀取結果,來響應讀事件 35

當讀寫都表示為事件,並路由到同一個流運算元處理時,我們實際上是在讀取查詢流與資料庫之間執行流表連線。讀事件需要傳送到儲存相應資料的資料庫分片,就像批處理器和流處理器執行連線時,需要按同一個鍵對輸入進行協同分割槽一樣。

處理請求與執行連線之間的這種對應關係,是一個非常基礎的概念 36。一次性讀取請求穿過連線運算元後,運算元立刻將它忘掉;訂閱請求則是一項持久連線,與連線另一側過去和未來的事件不斷匹配。

記錄讀事件日誌,在追蹤系統中的因果依賴與資料溯源方面也可能有益:它讓你能夠重建使用者在作出某項決定前看到了什麼。例如,在網上商店中,向顧客顯示的預計送達日期與庫存狀態,很可能會影響他們是否選擇購買一件商品 4。要分析這種關聯,就必須記錄使用者對送貨與庫存狀態查詢的結果。

因此,把讀取請求寫入持久儲存,有助於更好地追蹤因果依賴,但會增加儲存與 I/O 成本。如何最佳化這類系統、降低開銷,仍是一個開放的研究問題 2。不過,如果你本來就出於運維目的,在處理請求時順帶記錄了讀取請求日誌,那麼反過來讓日誌成為請求來源,並不算多麼巨大的改變。

多分片資料處理

對於只涉及單個分片的查詢,透過流傳送查詢並收集響應或許有些小題大做。不過,這個想法開啟了一種可能:利用流處理器已經具備的訊息路由、分片與連線基礎設施,分散式執行需要組合多個分片資料的複雜查詢。

Storm 的分散式 RPC 功能支援這種使用模式。例如,它曾被用於計算社交網路上看過某個 URL 的人數——也就是釋出過該 URL 的所有使用者,其關注者集合的並集 37。由於使用者集合經過分片,這項計算必須組合許多分片的結果。

這種模式的另一個例子是欺詐防範:為了評估某項購買事件是否可能存在欺詐,可以檢查使用者 IP 地址、電子郵件地址、賬單地址、送貨地址等各自的信譽評分。每個信譽資料庫本身都經過分片,因此,為某項購買事件收集這些評分,需要依次連線多個採用不同分片方式的資料集 38

資料倉庫查詢引擎內部的查詢執行圖,也具有類似特徵。如果需要執行這種多分片連線,使用原生提供該功能的資料庫,很可能比藉助流處理器自行實現更簡單。不過,把查詢視為流,仍為構建逼近傳統現成方案能力極限的大規模應用提供了一種選擇。

追求正確性

對於只讀取資料的無狀態服務,出了問題也沒什麼大不了:修復缺陷、重啟服務,一切便恢復正常。資料庫等有狀態系統卻沒這麼簡單:它們的設計目標是(近乎)永久儲存資訊,因此一旦出了問題,影響也可能永遠持續——這意味著我們必須更加仔細地思考 39

我們希望構建既可靠又 正確 的應用(也就是即使面對各種故障,程式的語義仍有明確的定義,也能為人理解)。大約四十年來,原子性、隔離性和永續性等事務屬性,一直是構建正確應用的首選工具。然而,這套基礎並不像看起來那樣牢固——弱隔離級別所引發的困惑便是一例(參見 “弱隔離級別”)。

在某些領域,事務已被徹底拋棄,取而代之的是效能與可伸縮性更好、語義卻混亂得多的模型。人們經常談論 一致性,卻很少把它定義清楚。有些人聲稱,為了更高的可用性,我們應該“擁抱弱一致性”,卻說不清這在實踐中究竟意味著什麼。

對於如此重要的主題,我們的理解與工程方法卻出奇地不可靠。例如,要判斷某個應用在特定事務隔離級別或複製配置下執行是否安全,非常困難 4041。簡單方案在併發度低、沒有故障時往往看似正確,一旦環境要求更高,便會暴露出許多隱蔽缺陷。

例如,Kyle Kingsbury 的 Jepsen 實驗 42 揭示了某些產品宣稱的安全保證,與它們遭遇網路問題和崩潰時的實際行為之間存在巨大差距。即便資料庫等基礎設施產品本身毫無問題,應用程式碼仍須正確使用它們提供的功能;如果配置本就難以理解(弱隔離級別、法定人數配置等都是如此),這個過程很容易出錯。

如果你的應用能夠容忍偶爾以不可預測的方式損壞或丟失資料,事情會簡單得多;或許只要祈求好運,就能勉強應付。反過來,如果你需要更強的正確性保證,可序列化與原子提交是成熟的方法,卻也代價不菲:它們通常只能在單個數據中心內工作(排除了地理分散式架構),還會限制系統能夠達到的規模與容錯能力。

傳統事務方法並不會消失,但要讓應用既正確、又能抵禦故障,事務並不是最終答案。本節將探討在資料流架構語境下思考正確性的幾種方式。

資料庫的端到端原則

應用使用了具有較強安全屬性的資料系統(例如可序列化事務),並不等於它就不會丟失或損壞資料。例如,如果應用缺陷導致它寫入錯誤資料,或者從資料庫刪除資料,可序列化事務也救不了你。這正是支援不可變與僅追加資料的一條理由:如果不讓錯誤程式碼擁有摧毀正確資料的能力,從這類錯誤中恢復就容易得多。

儘管不可變性很有用,但它本身並非萬靈藥。下面來看一個更隱蔽的資料損壞例子。

恰好一次執行操作

“容錯” 中,我們見過 恰好一次(或 等效一次)語義。如果處理訊息時出了問題,可以選擇放棄(丟棄訊息,也就是造成資料丟失),也可以重試。選擇重試,就要承擔一種風險:第一次其實已經成功,只是你沒能得知,於是訊息最終被處理兩次。

處理兩次也是一種資料損壞:我們不希望同一項服務向顧客收取兩次費用(多收費),也不希望計數器遞增兩次(誇大指標)。在這裡,恰好一次 是指這樣安排計算:即便某項操作確實由於故障而重試,最終效果也與從未發生故障時相同。前面已經討論過幾種實現方式。

最有效的方法之一,是讓操作變得 冪等:也就是無論執行一次還是多次,效果都相同。然而,要把本來並不冪等的操作改造成冪等操作,需要額外投入並謹慎處理:你可能需要維護額外的元資料(例如曾經更新過某個值的操作 ID 集合),並確保從一個節點故障切換到另一個節點時使用柵欄機制(參見 “分散式鎖和租約”)。

抑制重複

這種需要抑制重複的模式,還會出現在流處理之外的許多地方。例如,TCP 使用資料包的序列號,讓接收方按正確順序排列資料包,並判斷網路中是否有包丟失或重複。丟失的包會被重傳,重複的包則由 TCP 棧移除,之後資料才交給應用。

不過,這種重複抑制只能在單條 TCP 連線的範圍內發揮作用。假設這條 TCP 連線把客戶端連到資料庫,當前正在執行 例 13-1 中的事務。在許多資料庫中,事務與客戶端連線繫結(如果客戶端傳送多條查詢,資料庫之所以知道它們屬於同一事務,是因為它們都從同一條 TCP 連線發來)。如果客戶端發出 COMMIT 之後、尚未收到資料庫伺服器的迴應之前遭遇網路中斷與連線超時,它便無從知道事務究竟已經提交還是已經中止(圖 9-1)。

例 13-1. 從一個賬戶向另一個賬戶非冪等地轉賬
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;

客戶端可以重新連線資料庫並重試事務,但這已經超出 TCP 重複抑制的範圍。由於 例 13-1 中的事務並不冪等,結果可能實際轉了 $22,而不是預期的 $11。因此,儘管 例 13-1 是說明事務原子性的標準示例,它實際上並不正確,真正的銀行也不會這樣工作 3

兩階段提交協議(參見 “兩階段提交(2PC)”)打破了 TCP 連線與事務之間的一一對應,因為它必須允許事務協調者在網路故障後重新連線資料庫,並告訴資料庫應當提交還是中止懸而未決的事務。這足以保證事務只執行一次嗎?很遺憾,並不足夠。

即使能夠抑制資料庫客戶端與伺服器之間的重複事務,我們仍然要擔心終端使用者裝置與應用伺服器之間的網路。例如,如果終端使用者客戶端是 Web 瀏覽器,它可能透過 HTTP POST 請求向伺服器提交指令。也許使用者的蜂窩資料連線很差:POST 成功發出,但訊號在伺服器響應到達之前變得太弱。

此時,使用者很可能看到錯誤訊息,並手動重試。Web 瀏覽器會警告:“確定要再次提交此表單嗎?”——使用者選擇確定,因為他們本來就希望操作發生。(Post/Redirect/Get 模式 43 可以在正常情況下避免這條警告,卻無法解決 POST 請求超時。)在 Web 伺服器看來,重試是另一次請求;在資料庫看來,它也是另一個事務。通常的去重機制無濟於事。

唯一標識請求

要讓請求經過多跳網路通訊後仍然冪等,只依靠資料庫提供的事務機制是不夠的——必須考慮請求的 端到端 流程。

例如,可以為請求生成唯一識別符號(如 UUID),將它作為隱藏表單欄位放入客戶端應用;也可以對所有相關表單欄位計算雜湊,衍生出請求 ID 3。如果 Web 瀏覽器提交兩次 POST 請求,兩次請求就會攜帶同一個請求 ID。隨後,可以把這個請求 ID 一路傳到資料庫,並檢查給定 ID 的請求永遠只執行一次,如 例 13-2 所示。

例 13-2. 使用唯一 ID 抑制重複請求
ALTER TABLE requests ADD UNIQUE (request_id);

BEGIN TRANSACTION;

INSERT INTO requests
  (request_id, from_account, to_account, amount)
  VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00);

UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;

COMMIT;

例 13-2 依賴 request_id 列上的唯一性約束。如果事務試圖插入一個已經存在的 ID,INSERT 就會失敗,事務隨之中止,避免再次生效。即便隔離級別較弱,關係資料庫通常也能正確維護唯一性約束(而應用層面的“先檢查再插入”在非可序列化隔離下可能失效,如 “寫偏差與幻讀” 所述)。

除了抑制重複請求,例 13-2 中的 requests 表還充當了一種事件日誌,可用於事件溯源或變更資料捕獲。賬戶餘額的更新其實不必與插入事件發生在同一事務中,因為它們是冗餘狀態,可以由下游消費者從請求事件衍生出來——只要事件得到恰好一次處理,而這一點同樣可以藉助請求 ID 強制保證。

端到端原則

抑制重複事務的情形,只是一個更普遍原則的例子。這個原則稱為 端到端原則,由 Saltzer、Reed 和 Clark 於 1984 年提出 44

只有藉助位於通訊系統兩端的應用所掌握的知識與提供的協助,所討論的功能才能得到完整、正確的實現。因此,不可能把這一功能作為通訊系統本身的一項功能來提供。(有時,通訊系統提供的不完整版本可以用來提升效能。)

在我們的例子中,所討論的功能 是重複抑制。TCP 會在 TCP 連線層面抑制重複資料包,一些流處理器則在訊息處理層面提供所謂的恰好一次語義;但如果第一次請求超時,這些機制都不足以防止使用者再次提交同一請求。TCP、資料庫事務和流處理器自身都無法徹底排除這種重複。解決問題需要端到端方案:讓事務識別符號從終端使用者客戶端一路傳到資料庫。

端到端原則同樣適用於檢查資料完整性:乙太網、TCP 與 TLS 內建的校驗和可以檢測網路資料包損壞,卻無法檢測網路連線兩端收發軟體中的缺陷所造成的損壞,也無法檢測儲存資料的磁碟發生的損壞。如果想捕捉所有可能的資料損壞來源,就還需要端到端校驗和。

類似的道理也適用於加密 44:家庭 WiFi 網路的密碼可以阻止他人竊聽你的 WiFi 流量,卻防不住網際網路其他地方的攻擊者;客戶端與伺服器之間的 TLS/SSL 可以抵禦網路攻擊者,卻防不住伺服器本身遭到入侵。只有端到端加密與認證才能抵禦所有這些威脅。

儘管低層功能(TCP 重複抑制、乙太網校驗和、WiFi 加密)單憑自身無法提供所需的端到端功能,它們仍然有用,因為它們降低了高層發生問題的機率。例如,如果沒有 TCP 把資料包重新排成正確順序,HTTP 請求往往會變得支離破碎。我們只需記住:僅靠低層可靠性功能,並不足以保證端到端正確性。

在資料系統中應用端到端思維

這又把我們帶回最初的論點:應用使用了提供較強安全屬性的資料系統(例如可序列化事務),並不代表它一定不會丟失或損壞資料。應用自身同樣必須採取端到端措施,例如抑制重複。

這未免令人遺憾,因為容錯機制很難正確實現。TCP 等低層可靠性機制相當有效,因此剩餘的高層故障很少發生。若能把餘下的高層容錯機制封裝成一種抽象,讓應用程式碼不必操心,那再好不過——但我們似乎還沒有找到合適的抽象。

長期以來,事務一直被視為有用的抽象。正如 第 8 章 所述,它把各種可能的問題(併發寫入、違反約束、崩潰、網路中斷、磁碟故障)壓縮成兩種可能結果:提交或中止。這極大簡化了程式設計模型,但仍然不夠。

事務的代價很高,涉及異構儲存技術時尤其如此(參見 “跨不同系統的分散式事務”)。當我們因為分散式事務代價過高而拒絕使用它時,最終不得不在應用程式碼中重新實現容錯機制。本書大量例子表明,對併發與部分失效進行推理既困難又反直覺,因此大多數應用級機制都無法正確工作,結果便是資料丟失或損壞。

因此,值得探索這樣的容錯抽象:既能輕鬆提供應用特有的端到端正確性,又能在大規模分散式環境中保持良好的效能與運維特性。

強制約束

下面結合資料庫分拆的理念來思考正確性。前面看到,只要把請求 ID 從客戶端一路傳到記錄寫入的資料庫,就能實現端到端重複抑制。那麼,其他型別的約束呢?

我們特別關注 唯一性約束——也就是 例 13-2 所依賴的約束。在 “約束與唯一性保證” 中,我們還見過另外幾種需要強制唯一性的應用功能:使用者名稱或電子郵件地址必須唯一標識一名使用者;檔案儲存服務不能有多個同名檔案;兩個人不能預訂同一個航班座位或劇院座位。

其他約束也十分相似,例如確保賬戶餘額永不為負、售出的商品不超過倉庫庫存,或者會議室的預訂時間不能重疊。強制唯一性的技術通常也能用於這類約束。

唯一性約束需要共識

我們在 第 10 章 中看到,在分散式環境中強制唯一性約束需要共識:如果有多個取值相同的併發請求,系統必須設法決定接受哪一個衝突操作,並以違反約束為由拒絕其餘操作。

達成這種共識最常見的方法,是讓單個節點擔任領導者,負責作出所有決定。只要你不介意讓全部請求匯入單個節點(哪怕客戶端身處地球另一端),而且該節點不發生故障,這種方法就行得通。Raft 等共識演算法則解決了當前領導者失效(或者因網路問題而被認為失效)時,如何安全選舉新領導者並避免腦裂的問題。

唯一性檢查可以根據必須唯一的值進行分片,從而橫向擴充套件。例如,如果要像 例 13-2 那樣按請求 ID 保證唯一性,就可以確保所有具有相同請求 ID 的請求都路由到同一個分片;如果使用者名稱必須唯一,則可以按使用者名稱的雜湊值分片。

不過,非同步多主複製並不適用,因為不同領導者可能併發接受相互衝突的寫入,使值不再唯一。如果希望立即拒絕任何違反約束的寫入,同步協調便不可避免 45

基於日誌的訊息傳遞中的唯一性

共享日誌保證所有消費者以相同順序看到訊息——這種保證在形式上稱為 全序廣播,並且等價於共識(參見 “共識的多面性”)。在使用基於日誌訊息傳遞的資料庫分拆方法中,可以採用非常相似的方式強制唯一性約束。

流處理器用單個執行緒依次消費某個日誌分片中的所有訊息。因此,如果日誌按照必須唯一的值來分片,流處理器就能毫無歧義地、確定性地判斷,多項衝突操作中哪一項最先出現在日誌裡。例如,多個使用者試圖註冊同一個使用者名稱時 46

  1. 每個使用者名稱請求都被編碼成一條訊息,並追加到由使用者名稱雜湊值決定的分片。

  2. 流處理器依次讀取日誌中的請求,用本地資料庫記錄哪些使用者名稱已經被佔用。每當有請求申請一個可用使用者名稱,處理器就把該名稱記為已佔用,並向輸出流發出成功訊息;每當請求申請一個已經佔用的使用者名稱,處理器就向輸出流發出拒絕訊息。

  3. 請求使用者名稱的客戶端觀察輸出流,等待與自身請求對應的成功或拒絕訊息。

這一演算法與我們在 第 10 章 中見過的、使用共享日誌實現共識的構造相同。只要增加分片數量,就能輕鬆擴充套件到很高的請求吞吐量,因為每個分片都可以獨立處理。

這種方法不僅適用於唯一性約束,也適用於許多其他約束。其基本原則是:任何可能衝突的寫入都路由到同一個分片,依次處理。衝突的定義可能取決於具體應用,但流處理器可以用任意邏輯來驗證請求。

多分片請求處理

當一項操作涉及多個分片時,要讓它在滿足約束的同時原子執行,問題就更有意思了。例 13-2 可能涉及三個分片:儲存請求 ID 的分片、儲存收款方賬戶的分片,以及儲存付款方賬戶的分片。這三者彼此獨立,沒有理由一定落在同一個分片中。

採用傳統資料庫方法時,執行這項事務需要跨三個分片原子提交;這實質上迫使它與這三個分片中任何一個分片上的其他所有事務形成全序。既然出現了跨分片協調,各分片便無法再獨立處理,吞吐量很可能因此受損。

然而,藉助分片日誌與流處理器,無需跨分片事務也能實現等價的正確性。圖 13-2 展示了一項付款事務:先檢查源賬戶餘額是否充足;若餘額充足,則在扣除手續費的同時,把一筆金額原子地轉入目標賬戶。具體過程如下 47

圖 13-2. 使用事件日誌和流處理器,檢查源賬戶是否有足夠餘額,並把資金原子地轉入目標賬戶和手續費賬戶。

  1. 使用者客戶端為從源賬戶向目標賬戶轉賬的請求賦予唯一請求 ID,再根據源賬戶 ID,把請求追加到相應日誌分片。

  2. 流處理器讀取請求日誌,並維護一個數據庫,其中儲存源賬戶的狀態以及已經處理過的請求 ID。這個資料庫的內容完全衍生自日誌。當流處理器遇到一個從未見過的請求 ID 時,它會在本地資料庫中檢查源賬戶餘額是否足以完成轉賬。

    如果餘額充足,處理器就在本地資料庫中更新源賬戶狀態,預留付款金額,並向另外幾條日誌發出事件:向源賬戶的日誌分片(也就是處理器自己的輸入日誌)發出一條出賬事件,向目標賬戶的日誌分片發出一條入賬事件,再向手續費賬戶的日誌分片發出一條入賬事件。發出的這些事件都包含原始請求 ID。

  3. 出賬事件最終會回到源賬戶處理器(其間可能已經收到一些不相干的事件)。流處理器根據請求 ID 認出,這是先前已經預留的一筆付款,於是現在執行付款,再次更新本地儲存的源賬戶狀態。它會根據請求 ID 忽略重複事件。

  4. 目標賬戶與手續費賬戶的日誌分片分別由獨立的流處理任務消費。它們收到入賬事件後,會更新各自的本地狀態以反映這筆款項,並根據請求 ID 對事件去重。

圖 13-2 把三個賬戶畫在三個不同分片中,但它們也完全可以位於同一個分片——這無關緊要。我們只需要保證:給定賬戶的所有事件都嚴格按照日誌順序處理,並採用至少一次語義,而且流處理器是確定性的。

例如,設想源賬戶處理器在處理付款請求時崩潰。崩潰之前,輸出訊息可能已經發出,也可能尚未發出。處理器從崩潰中恢復後,會再次處理同一個請求(因為採用至少一次語義);由於處理器具有確定性,它仍會對是否允許付款作出相同決定。因此,它會向出賬、入賬和手續費賬戶分片發出帶有相同請求 ID 的同一批輸出訊息。如果這些訊息是重複的,下游消費者會根據請求 ID 將其忽略。

這個系統的原子性不來自任何事務,而來自把初始請求事件寫入源賬戶日誌這一原子操作。一旦那條事件進入日誌,所有下游事件最終也都會寫入——可能要等流處理器從崩潰中恢復,可能還會出現重複,但它們終究會出現。

如果採用恰好一次語義,這個例子實現起來會更容易,因為它能確保流處理器的本地狀態與已經處理的訊息集合保持一致。因此,如果處理器崩潰並重新處理某些訊息,它的本地狀態也會重置到處理這些訊息之前的狀態。

如果 圖 13-2 中的使用者想知道轉賬是否獲批,可以訂閱源賬戶的日誌分片,等待出賬事件。如果希望在餘額不足時明確通知使用者,流處理器可以向該日誌分片發出一條“付款被拒”事件。

透過把多分片事務拆成多個採用不同分片方式的階段,並使用端到端請求 ID,我們實現了相同的正確性屬性(每個請求對付款方與收款方賬戶都恰好應用一次):即使發生故障也不例外,而且無需使用原子提交協議。

及時性與完整性

許多事務系統都有一項便利的性質:一個事務提交後,其寫入立刻對其他事務可見。這項性質的形式化名稱是 嚴格可序列化(參見 “線性一致性與可序列化”)。

把一項操作分拆為流處理器的多個階段後,情況卻並非如此:日誌消費者在設計上就是非同步的,所以傳送者不會等待消費者處理完自己的訊息。不過,客戶端仍可以等待某條訊息出現在輸出流上。例如,圖 13-2 中的使用者可以等待出賬事件或付款被拒事件,這取決於源賬戶中是否有足夠資金。

在這個例子中,檢查源賬戶餘額是否正確,並不取決於發出請求的使用者是否等待結果。等待只是為了同步告知使用者付款是否成功,這項通知與處理請求產生的效果彼此解耦。

更一般地說,一致性 這個術語混合了兩種不同的需求,而它們值得分開考慮:

及時性

及時性是指確保使用者觀察到系統的最新狀態。前面看到,如果使用者從陳舊的資料副本中讀取,可能觀察到不一致的系統狀態(參見 “複製延遲的問題”)。不過,這種不一致只是暫時的,只需等待並重試,最終便會消失。

CAP 定理中的一致性指線性一致性,這是實現及時性的一種強保證。寫後讀一致性 等較弱的及時性屬性同樣有用。

完整性

完整性是指沒有損壞:既不丟失資料,也沒有相互矛盾或虛假的資料。尤其是,如果某個衍生資料集作為底層資料之上的檢視來維護,衍生過程必須正確。例如,資料庫索引必須準確反映資料庫內容——漏掉某些記錄的索引沒什麼用。

如果完整性遭到破壞,不一致就是永久的:大多數情況下,等待和重試無法修復資料庫損壞,必須明確進行檢查和修復。在 ACID 事務語境中,“一致性”通常被理解為某種應用特有的完整性概念。原子性與永續性是維護完整性的重要工具。

用一句口號來說:違反及時性叫“最終一致”,違反完整性則叫“永遠不一致”。

在大多數應用中,完整性都比及時性重要得多。及時性遭到破壞會令人煩惱和困惑,完整性遭到破壞卻可能帶來災難。

例如,信用卡賬單上沒有出現過去 24 小時內完成的一筆交易,並不會令人意外——這類系統存在一定延遲很正常。我們知道銀行會非同步對賬和結算交易,因此這裡的及時性並不重要 3。然而,如果賬單餘額不等於交易總額加上上期賬單餘額(求和出錯),或者一筆交易向你扣了款、商戶卻沒有收到錢(資金憑空消失),問題就非常嚴重。這些都是對系統完整性的破壞。

資料流系統的正確性

ACID 事務通常同時提供及時性保證(例如線性一致性)和完整性保證(例如原子提交)。因此,如果從 ACID 事務的角度看待應用正確性,及時性與完整性之間的區別便無關緊要。

另一方面,本章討論的基於事件的資料流系統有一項有趣性質:它們把及時性與完整性解耦了。非同步處理事件流時,除非明確構建消費者,讓它等到訊息到達後才返回,否則就沒有及時性保證。例如,使用者可以請求一筆付款,隨後在流處理器執行該請求之前讀取自己的賬戶狀態;此時,使用者看不到剛剛請求的付款。

然而,完整性事實上是流式系統的核心。恰好一次等效一次 語義就是維護完整性的一種機制。事件丟失或生效兩次,都可能破壞資料系統的完整性。因此,面對故障時,容錯訊息傳遞與重複抑制(例如冪等操作)是維護資料系統完整性的關鍵。

正如上一節所見,可靠的流處理系統無需分散式事務與原子提交協議也能保持完整性。這意味著它們有望實現同等程度的正確性,同時獲得好得多的效能與運維穩健性。我們透過組合以下機制實現了這種完整性:

  • 把寫入操作的內容表示成一條訊息,使其能夠輕鬆原子寫入——這種方式非常適合事件溯源

  • 使用確定性衍生函式,從這一條訊息衍生出所有其他狀態更新,類似於儲存過程

  • 讓客戶端生成的請求 ID 貫穿所有處理層次,從而實現端到端重複抑制與冪等性

  • 讓訊息不可變,並允許不時重新處理衍生資料,從而更容易從缺陷中恢復

寬鬆解釋約束

如前所述,強制唯一性約束需要共識,通常透過讓某個分片中的所有事件都匯入單個節點來實現。如果希望採用傳統形式的唯一性約束,這項限制便無法避免,流處理也繞不過去。

不過還要意識到:在許多真實應用中,業務需求其實允許違反那些看似硬性約束的規則:

  • 如果顧客訂購的商品超過倉庫庫存,可以補訂貨物,為延誤向顧客道歉,並提供折扣。其實,即便只是叉車碾壞了倉庫裡的部分商品,導致實際庫存少於預期,你也必須這樣處理 3。因此,為了應對叉車事故,道歉工作流本來就必須納入業務流程;對庫存數量設定硬約束或許沒有必要。

  • 同樣,許多航空公司會超賣機票,因為預計有些乘客會誤機;許多酒店也會超賣客房,因為預計有些客人會取消預訂。在這些情形中,企業出於業務考慮故意違反“一座一人”的約束,並設定補償流程(退款、升級、在附近酒店免費提供房間),處理需求超出供給的情況。即便沒有超賣,也需要道歉與補償流程,以應對惡劣天氣或員工罷工造成的航班取消——從這類問題中恢復本來就是正常業務的一部分 3

  • 如果有人取出的金額超過賬戶餘額,銀行可以收取透支費,並要求對方償還欠款。只要限制每日提款總額,銀行承擔的風險就有上限。

  • 在跨組織整合資料的系統中,不一致不可避免,因此必須有修正機制來處理它們。正如 “批處理用例” 所指出的,銀行之間的付款結算就是一個例子。

因此,在許多業務場景中,暫時違反約束、稍後再透過道歉修正,是可以接受的。這種用於糾正錯誤的變更稱為 補償性事務 4849。道歉的代價各不相同(金錢或聲譽上的代價),卻往往很低:已經發出的電子郵件無法撤回,但可以再發一封郵件更正;信用卡不慎扣款兩次,可以退回其中一筆,代價只是手續費,或許再加上一項顧客投訴。ATM 一旦吐出現金,確實無法直接收回;但原則上,如果賬戶已經透支而顧客拒絕還款,可以派催收人員追回欠款。

道歉的代價能否接受,是一項業務決策。如果能夠接受,那麼“寫入資料前先檢查全部約束”的傳統模型就限制過多。完全可以先樂觀地執行寫入,再事後檢查約束。對於那些一旦出錯便很難挽回的事情,仍可以確保在執行前完成驗證;但這並不意味著,連資料寫入之前也必須先做驗證。

這些應用 確實 需要完整性:誰也不希望預訂記錄丟失,或因借貸不匹配而讓資金憑空消失。但是,它們在強制約束時 並不需要 及時性:如果售出的商品超過倉庫庫存,事後道歉並補救即可。這與我們在 “處理寫入衝突” 中討論的衝突解決方法相似。

避免協調的資料系統

我們現在已經做了兩個有趣的觀察:

  1. 資料流系統無需原子提交、線性一致性或同步的跨分片協調,也能維護衍生資料的完整性保證。

  2. 儘管嚴格的唯一性約束需要及時性與協調,許多應用其實可以接受寬鬆約束:只要完整性始終得到維護,約束可以暫時遭到違反,稍後再修復。

把這兩點結合起來就意味著:資料流系統無需協調,便可為許多應用提供資料管理服務,同時仍給出強有力的完整性保證。這種 避免協調 的資料系統極具吸引力:與需要同步協調的系統相比,它們能獲得更好的效能與容錯能力 45

例如,這類系統可以採用多主配置,分佈在多個數據中心,並在區域之間非同步複製。任何一個數據中心都能獨立於其他資料中心繼續執行,因為不需要跨區域同步協調。這樣的系統只提供較弱的及時性保證——不引入協調就不可能實現線性一致性——卻仍能提供強有力的完整性保證。

在這種情況下,可序列化事務作為維護衍生狀態的一環仍然有用,不過可以把它限制在自己擅長的小範圍內 6。不再需要 XA 等異構分散式事務。仍然可以在確有需要之處引入同步協調(例如在執行無法恢復的操作之前,強制實施嚴格約束);但如果應用中只有一小部分需要協調,就沒必要讓所有部分都付出協調成本 32

也可以換個角度看協調與約束:它們減少了因不一致而道歉的次數,卻也可能降低系統性能和可用性,從而增加因服務中斷而道歉的次數。道歉次數不可能降到零,但可以根據自身需要尋找最佳平衡點——既不會出現太多不一致,也不會遇到太多可用性問題。

信任但驗證

前面關於正確性、完整性與容錯的所有討論,都建立在一組假設之上:某些事情可能出錯,另一些事情不會。我們把這些假設稱為 系統模型(參見 “系統模型與現實”)。例如,我們應當假設程序可能崩潰、機器可能突然斷電、網路可能任意延遲或丟棄訊息;但也可能假設,寫入磁碟的資料經過 fsync 後不會丟失、記憶體中的資料不會損壞、CPU 的乘法指令總能返回正確結果。

這些假設相當合理,因為絕大多數時候它們都成立;如果必須時刻擔心計算機會算錯,我們將寸步難行。傳統系統模型以二元方式看待故障:假設有些事情可能發生,另一些事情絕不可能發生。現實卻更像是機率問題:有些事情更常見,有些事情較少見。真正的問題是,違反假設的情況是否頻繁到我們會在實踐中遇見。

我們已經看到,資料可能在記憶體中損壞(參見 “硬體與軟體故障”),可能在磁碟上損壞(參見 “複製與永續性”),也可能在網路中損壞(參見 “弱形式的撒謊”)。或許我們應該更加重視這一點?當系統規模足夠大,再小機率的事情也會發生。

面對軟體缺陷時維護完整性

除了這類硬體問題,軟體缺陷也始終是一項風險,而低層的網路、記憶體或檔案系統校驗和捕捉不到它們。即便廣泛使用的資料庫軟體也存在缺陷:例如,過去某些版本的 MySQL 未能正確維護唯一性約束 50,PostgreSQL 的可序列化隔離級別過去也曾出現寫偏差異常 51。MySQL 與 PostgreSQL 都是穩健且口碑良好的資料庫,經過許多人多年的實戰檢驗;不夠成熟的軟體,情況很可能糟得多。

儘管人們投入大量精力仔細設計、測試和審查,缺陷仍會悄然混入。它們雖然罕見,最終也會被發現和修復,卻仍然存在一段可能損壞資料的視窗期。

至於應用程式碼,我們必須假設其中有更多缺陷,因為大多數應用接受的審查與測試,遠遠不及資料庫程式碼。許多應用甚至沒有正確使用資料庫提供的完整性維護功能,例如外來鍵或唯一性約束 25

ACID 意義上的一致性,建立在這樣一種想法之上:資料庫從一致狀態開始,事務再把它從一個一致狀態轉變為另一個一致狀態。因此,我們期望資料庫始終處於一致狀態。然而,只有假設事務沒有缺陷,這種說法才有意義。如果應用以某種錯誤方式使用資料庫——例如不安全地採用弱隔離級別——資料庫的完整性便無法保證。

不要盲信承諾

硬體和軟體都不總能達到理想狀態,因此資料損壞遲早似乎不可避免。至少,我們應該有辦法發現數據已經損壞,從而修復它,並努力追查錯誤來源。檢查資料完整性的過程稱為 審計

正如 “不可變事件的優點” 所述,審計並不只適用於財務應用。不過,可審計性在金融領域格外重要,恰恰因為人人都知道錯誤難免發生,也都認可能夠發現並修復問題的必要性。

成熟系統同樣傾向於考慮小機率故障的可能性,並管理這種風險。例如,HDFS 和 Amazon S3 等大規模儲存系統不會完全信任磁碟:它們執行後臺程序,不斷回讀檔案、與其他副本比較,並把檔案從一個磁碟移動到另一個磁碟,以降低靜默損壞的風險 5253

如果想確認資料仍然存在,就必須真正讀取並檢查。絕大多數時候資料依然完好;但萬一不是,你肯定希望越早發現越好。同理,不時嘗試從備份恢復也很重要——否則,你可能直到資料已經丟失、為時已晚,才發現備份根本無法使用。不要盲信一切都在正常工作。

HDFS 與 S3 仍然必須假設磁碟在絕大多數時候能夠正確工作——這個假設很合理,卻不同於假設磁碟 始終 正確工作。然而,目前採用這種“信任,但要驗證”方式持續自我審計的系統並不多。許多系統假定正確性保證是絕對的,完全沒有為罕見的資料損壞預作安排。未來,我們或許會看到更多 自我驗證自我審計 系統:它們不斷檢查自身完整性,而不是依賴盲目信任 54

為可審計性而設計

如果一個事務修改了資料庫中的多個物件,事後很難看出這項事務究竟意味著什麼。即便捕獲了事務日誌,各個表裡的插入、更新與刪除也未必能清楚說明,為什麼 要執行這些修改。當初決定作出這些修改的應用邏輯呼叫轉瞬即逝,無法重現。

相比之下,基於事件的系統可以提供更好的可審計性。在事件溯源方法中,系統的使用者輸入被表示為一條不可變事件,由此產生的所有狀態更新都衍生自這條事件。衍生過程可以做到確定性與可重複性,因此,用同一版本的衍生程式碼處理同一份事件日誌,便會得到相同的狀態更新。

明確表示資料流,能讓 資料溯源 清晰得多,從而使完整性檢查更切實可行。對於事件日誌,可以用雜湊檢查事件儲存是否遭到損壞;對於任何衍生狀態,可以重新運行當初從事件日誌衍生它的批處理器與流處理器,檢查是否得到同樣結果,甚至還可以並行執行一條冗餘的衍生流程。

具有確定性且定義明確的資料流,也讓系統執行過程更容易除錯和追蹤,從而查明系統 為什麼 做了某件事 455。如果發生意外,能夠重現導致意外事件的確切情境將極有價值——這是一種時間旅行式除錯能力。

再談端到端原則

如果無法完全相信系統中的每個元件都不會造成損壞——每一件硬體都不出故障,每一段軟體都沒有缺陷——那麼至少必須定期檢查資料完整性。如果不檢查,往往要等損壞造成下游影響、為時已晚時才會發現;那時追查問題將困難得多,代價也高得多。

檢查資料系統完整性,最好採用端到端方式:完整性檢查涵蓋的系統越多,處理流程某個階段的損壞就越不容易逃過檢查。如果能夠檢查整條衍生資料管道端到端的正確性,那麼路徑上的所有磁碟、網路、服務與演算法,也都隱含在檢查範圍之內。

持續進行端到端完整性檢查,會增強你對系統正確性的信心,從而讓你行動得更快 56。審計與自動化測試一樣,提高了迅速發現缺陷的機率,因而降低系統變更或採用新儲存技術造成損害的風險。如果不再害怕作出改變,就能更好地推動應用演化,以滿足不斷變化的需求。

可審計資料系統的工具

目前,很少有資料系統把可審計性當作首要目標。一些應用會實現自己的審計機制,例如把所有變更記錄到獨立的審計表;然而,要保證審計日誌與資料庫狀態的完整性仍然很難。可以藉助硬體安全模組定期簽名,讓事務日誌不可篡改,但這並不能保證一開始進入日誌的就是正確事務。

Bitcoin 或 Ethereum 等區塊鏈,是帶有密碼學一致性檢查的共享僅追加日誌;其中儲存的交易是事件,智慧合約基本上就是流處理器。它們使用的共識協議確保所有節點對同一事件序列達成一致。與 第 10 章 的共識協議不同,區塊鏈具有拜占庭容錯能力:即便某些參與節點的資料已經損壞,系統仍能繼續工作,因為各個副本會不斷互相檢查完整性。

對於大多數應用,區塊鏈的開銷太高,並不實用。不過,其中一些密碼學工具也能用於更輕量的環境。例如,默克爾樹 57 是由雜湊構成的樹,可以高效證明某條記錄出現在某個資料集中(也能證明其他一些事情)。證書透明性 使用經過密碼學驗證的僅追加日誌與默克爾樹,檢查 TLS/SSL 證書的有效性 5859;它讓每條日誌由單個領導者負責,從而無需共識協議。

未來,證書透明性與分散式賬本所採用的完整性檢查和審計算法,或許會在一般資料系統中得到更廣泛的應用。要讓它們擁有與不帶密碼學審計的系統相同的可伸縮性,並把效能損失儘可能壓低,還需要投入一些工作;但這些技術仍然值得關注。

本章小結

本章以流處理理念為基礎,討論了設計資料系統的新方法。我們從一個觀察出發:沒有任何一種工具能夠高效服務所有可能的用例,因此應用必然需要組合多種不同的軟體來實現目標。我們討論了如何利用批處理與事件流,讓資料變更在不同系統之間流動,從而解決這一 資料整合 問題。

在這種方法中,某些系統被指定為權威記錄系統,其他資料則透過轉換衍生自它們。這樣,我們就能維護索引、物化檢視、機器學習模型、統計摘要等。衍生與轉換過程保持非同步和鬆散耦合,可以防止一處的問題擴散到系統中無關的部分,從而增強整個系統的穩健性與容錯能力。

把資料流表示為從一個數據集到另一個數據集的轉換,也有助於應用演化:如果要修改某個處理步驟——例如改變索引或快取的結構——只需讓新的轉換程式碼重新處理整個輸入資料集,再次衍生出輸出。同樣,如果出現問題,也可以修復程式碼並重新處理資料來恢復。

這些過程與資料庫內部既有的工作十分相似,因此,我們把資料流應用重新表述為對資料庫元件的 分拆,並透過組合這些鬆散耦合的元件來構建應用。

觀察底層資料的變更,就能更新衍生狀態;下游消費者還可以繼續觀察衍生狀態本身。我們甚至可以讓這種資料流一路抵達顯示資料的終端使用者裝置,從而構建能夠動態更新、反映資料變化,並且離線時仍可工作的使用者介面。

接下來,我們討論了如何保證所有這些處理在發生故障時仍然正確。透過非同步處理事件、使用端到端請求識別符號使操作冪等,並非同步檢查約束,就能以可伸縮的方式實現強完整性保證。客戶端可以等待檢查透過,也可以不等待便繼續執行,但要承擔約束遭到違反、事後必須道歉的風險。這種方法比使用分散式事務的傳統方法更可伸縮、更穩健,也更符合許多業務流程的實際運作方式。

圍繞資料流構建應用並非同步檢查約束,可以避免大部分協調,建立既能維護完整性、又有良好效能的系統,即使處於地理分散式環境或發生故障也不例外。最後,我們簡要討論了如何透過審計驗證資料完整性、發現損壞,並指出區塊鏈所用的技術也與事件驅動系統頗為相似。

腳註

參考文獻


  1. Rachid Belaid. Postgres Full-Text Search is Good Enough! rachbelaid.com, July 2015. Archived at perma.cc/ZVP9-YDCB ↩︎ ↩︎

  2. Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, Wyatt Lloyd, and Kaushik Veeraraghavan. Challenges to Adopting Stronger Consistency at Scale. At 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015. ↩︎ ↩︎

  3. Pat Helland and Dave Campbell. Building on Quicksand. At 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  4. Jessica Kerr. Provenance and Causality in Distributed Systems. jessitron.com, September 2016. Archived at perma.cc/DTD2-F8ZM ↩︎ ↩︎ ↩︎

  5. Jay Kreps. The Log: What Every Software Engineer Should Know About Real-Time Data’s Unifying Abstraction. engineering.linkedin.com, December 2013. Archived at perma.cc/2JHR-FR64 ↩︎ ↩︎ ↩︎ ↩︎

  6. Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎ ↩︎

  7. Lionel A. Smith. The Broad Gauge Story. Journal of the Monmouthshire Railway Society, Summer 1985. Archived at perma.cc/DDK9-JA6X ↩︎

  8. Jacqueline Xu. Online Migrations at Scale. stripe.com, February 2017. Archived at perma.cc/ZQY2-EAU2 ↩︎

  9. Flavio Santos and Robert Stephenson. Changing the Wheels on a Moving Bus — Spotify’s Event Delivery Migration. engineering.atspotify.com, October 2021. Archived at perma.cc/5C4V-G8EV ↩︎

  10. Molly Bartlett Dishman and Martin Fowler. Agile Architecture. At O’Reilly Software Architecture Conference, March 2015. ↩︎

  11. Nathan Marz and James Warren. Big Data: Principles and Best Practices of Scalable Real-Time Data Systems. Manning, 2015. ISBN: 978-1-617-29034-3 ↩︎

  12. Jay Kreps. Questioning the Lambda Architecture. oreilly.com, July 2014. Archived at perma.cc/PGH6-XUCH ↩︎ ↩︎

  13. Raul Castro Fernandez, Peter Pietzuch, Jay Kreps, Neha Narkhede, Jun Rao, Joel Koshy, Dong Lin, Chris Riccomini, and Guozhang Wang. Liquid: Unifying Nearline and Offline Big Data Integration. At 7th Biennial Conference on Innovative Data Systems Research (CIDR), January 2015. ↩︎

  14. Dennis M. Ritchie and Ken Thompson. The UNIX Time-Sharing System. Communications of the ACM, volume 17, issue 7, pages 365–375, July 1974. doi:10.1145/361011.361061 ↩︎ ↩︎

  15. Wes McKinney. The Road to Composable Data Systems: Thoughts on the Last 15 Years and the Future. wesmckinney.com, September 2023. Archived at perma.cc/J9SJ-886N ↩︎

  16. Eric A. Brewer and Joseph M. Hellerstein. CS262a: Advanced Topics in Computer Systems. Lecture notes, University of California, Berkeley, cs.berkeley.edu, August 2011. Archived at perma.cc/TE79-LGWU ↩︎

  17. Michael Stonebraker. The Case for Polystores. wp.sigmod.org, July 2015. Archived at perma.cc/G7J2-KR45 ↩︎ ↩︎

  18. Jennie Duggan, Aaron J. Elmore, Michael Stonebraker, Magda Balazinska, Bill Howe, Jeremy Kepner, Sam Madden, David Maier, Tim Mattson, and Stan Zdonik. The BigDAWG Polystore System. ACM SIGMOD Record, volume 44, issue 2, pages 11–16, June 2015. doi:10.1145/2814710.2814713 ↩︎

  19. David B. Lomet, Alan Fekete, Gerhard Weikum, and Mike Zwilling. Unbundling Transaction Services in the Cloud. At 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. ↩︎

  20. Martin Kleppmann and Jay Kreps. Kafka, Samza and the Unix Philosophy of Distributed Data. IEEE Data Engineering Bulletin, volume 38, issue 4, pages 4–14, December 2015. ↩︎

  21. John Hugg. Winning Now and in the Future: Where Volt Active Data Shines. voltactivedata.com, March 2016. Archived at perma.cc/44MP-3MWM ↩︎

  22. Felienne Hermans. Spreadsheets Are Code. At Code Mesh, November 2015. ↩︎

  23. Dan Bricklin and Bob Frankston. VisiCalc: Information from Its Creators. danbricklin.com. Archived at archive.org ↩︎

  24. D. Sculley, Gary Holt, Daniel Golovin, Eugene Davydov, Todd Phillips, Dietmar Ebner, Vinay Chaudhary, and Michael Young. Machine Learning: The High-Interest Credit Card of Technical Debt. At NIPS Workshop on Software Engineering for Machine Learning (SE4ML), December 2014. Archived at https://perma.cc/M3MD-U7WL ↩︎

  25. Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity. At ACM International Conference on Management of Data (SIGMOD), June 2015. doi:10.1145/2723372.2737784 ↩︎ ↩︎

  26. Guy Steele. Re: Need for Macros (Was Re: Icon). email to ll1-discuss mailing list, people.csail.mit.edu, December 2001. Archived at perma.cc/K9X8-CJ65 ↩︎

  27. Ben Stopford. Microservices in a Streaming World. At QCon London, March 2016. ↩︎ ↩︎

  28. Adam Bellemare. Building Event-Driven Microservices, 2nd Edition. O’Reilly Media, 2025. ↩︎

  29. Christian Posta. Why Microservices Should Be Event Driven: Autonomy vs Authority. blog.christianposta.com, May 2016. Archived at perma.cc/E6N9-3X92 ↩︎

  30. Alex Feyerke. Designing Offline-First Web Apps. alistapart.com, December 2013. Archived at perma.cc/WH7R-S2DS ↩︎

  31. Martin Kleppmann. Turning the Database Inside-out with Apache Samza. at Strange Loop, September 2014. Archived at perma.cc/U6E8-A9MT ↩︎ ↩︎

  32. Sebastian Burckhardt, Daan Leijen, Jonathan Protzenko, and Manuel Fähndrich. Global Sequence Protocol: A Robust Abstraction for Replicated Shared State. At 29th European Conference on Object-Oriented Programming (ECOOP), July 2015. doi:10.4230/LIPIcs.ECOOP.2015.568 ↩︎ ↩︎

  33. Evan Czaplicki and Stephen Chong. Asynchronous Functional Reactive Programming for GUIs. At 34th ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), June 2013. doi:10.1145/2491956.2462161 ↩︎

  34. Eno Thereska, Damian Guy, Michael Noll, and Neha Narkhede. Unifying Stream Processing and Interactive Queries in Apache Kafka. confluent.io, October 2016. Archived at perma.cc/W8JG-EAZF ↩︎

  35. Frank McSherry. Dataflow as Database. github.com, July 2016. Archived at perma.cc/384D-DUFH ↩︎

  36. Peter Alvaro. I See What You Mean. At Strange Loop, September 2015. ↩︎

  37. Nathan Marz. Trident: A High-Level Abstraction for Realtime Computation. blog.x.com, August 2012. Archived at archive.org ↩︎

  38. Edi Bice. Low Latency Web Scale Fraud Prevention with Apache Samza, Kafka and Friends. At Merchant Risk Council MRC Vegas Conference, March 2016. Archived at perma.cc/T3H5-QN3R ↩︎

  39. Charity Majors. The Accidental DBA. charity.wtf, October 2016. Archived at perma.cc/6ANP-ARB6 ↩︎

  40. Arthur J. Bernstein, Philip M. Lewis, and Shiyong Lu. Semantic Conditions for Correctness at Different Isolation Levels. At 16th International Conference on Data Engineering (ICDE), February 2000. doi:10.1109/ICDE.2000.839387 ↩︎

  41. Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan. Automating the Detection of Snapshot Isolation Anomalies. At 33rd International Conference on Very Large Data Bases (VLDB), September 2007. ↩︎

  42. Kyle Kingsbury. Jespen: Distributed Systems Safety Research. jepsen.io↩︎

  43. Michael Jouravlev. Redirect After Post. theserverside.com, August 2004. Archived at archive.org ↩︎

  44. Jerome H. Saltzer, David P. Reed, and David D. Clark. End-to-End Arguments in System Design. ACM Transactions on Computer Systems, volume 2, issue 4, pages 277–288, November 1984. doi:10.1145/357401.357402 ↩︎ ↩︎

  45. Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Coordination Avoidance in Database Systems. Proceedings of the VLDB Endowment, volume 8, issue 3, pages 185–196, November 2014. doi:10.14778/2735508.2735509 ↩︎ ↩︎

  46. Alex Yarmula. Strong Consistency in Manhattan. blog.x.com, March 2016. Archived at archive.org ↩︎

  47. Martin Kleppmann, Alastair R. Beresford, and Boerge Svingen. Online Event Processing: Achieving consistency where distributed transactions have failed. Communications of the ACM, volume 62, issue 5, pages 43-49, May 2019. doi:10.1145/3312527 ↩︎

  48. Jim Gray. The Transaction Concept: Virtues and Limitations. At 7th International Conference on Very Large Data Bases (VLDB), September 1981. Archived at perma.cc/8VPT-N5H6 ↩︎

  49. Hector Garcia-Molina and Kenneth Salem. Sagas. At ACM International Conference on Management of Data (SIGMOD), May 1987. doi:10.1145/38713.38742 ↩︎

  50. Annamalai Gurusami and Daniel Price. Bug #73170: Duplicates in Unique Secondary Index Because of Fix of Bug#68021. bugs.mysql.com, July 2014. Archived at perma.cc/P6BV-W7JJ ↩︎

  51. Gary Fredericks. Postgres Serializability Bug. github.com, September 2015. Archived at perma.cc/N8UP-2822 ↩︎

  52. Xiao Chen. HDFS DataNode Scanners and Disk Checker Explained. blog.cloudera.com, December 2016. Archived at perma.cc/6S36-X98L ↩︎

  53. Daniel Persson. How does Ceph scrubbing work? youtube.com, March 2022. ↩︎

  54. Jay Kreps. Getting Real About Distributed System Reliability. blog.empathybox.com, March 2012. Archived at perma.cc/9B5Q-AEBW ↩︎

  55. Martin Fowler. The LMAX Architecture. martinfowler.com, July 2011. Archived at perma.cc/5AV4-N6RJ ↩︎

  56. Sam Stokes. Move Fast with Confidence. five-eights.com, July 2016. Archived at perma.cc/J8C6-DHXB ↩︎

  57. Ralph C. Merkle. A Digital Signature Based on a Conventional Encryption Function. At CRYPTO ‘87, August 1987. doi:10.1007/3-540-48184-2_32 ↩︎

  58. Ben Laurie. Certificate Transparency. ACM Queue, volume 12, issue 8, pages 10-19, August 2014. doi:10.1145/2668152.2668154 ↩︎

  59. Mark D. Ryan. Enhanced Certificate Transparency and End-to-End Encrypted Mail. At Network and Distributed System Security Symposium (NDSS), February 2014. doi:10.14722/ndss.2014.23379 ↩︎

最後更新於