設計資料密集型應用(第二版)
設計資料密集型應用(第二版)
- 作者:Martin Kleppmann
- 譯者:馮若航 / Vonng 與 DDIA 中文翻譯貢獻者
- 原作:Designing Data-Intensive Applications, Second Edition
不懂資料庫的全棧工程師不是好架構師。
—— 馮若航 / Vonng
本書從資料模型與儲存引擎出發,逐步進入複製、分片、事務、一致性、批處理與流處理,最後回到技術責任與社會影響。它關注的不是某一款產品,而是設計、實現和評價現代資料系統時可以反覆使用的思維框架。
第二版中文譯文按原書十四章持續校訂。線上版本提供章節樹導航、穩定圖表編號與交叉引用、全文搜尋、順序閱讀、整書列印以及適合工具讀取的 Markdown / llms.txt 輸出。
閱讀本書
出版與授權說明
本譯文僅供學習研究參考,不追求任何經濟利益,不得公開傳播發行或用於商業用途。譯者保留譯文署名權,其他權利以原作者與出版社的主張為準。有能力閱讀英文的讀者請購買正版支援作者與出版社。
序言
如果近幾年從業於軟體工程,特別是伺服器端和後端系統開發,那麼你很有可能已經被大量關於資料儲存和處理的時髦詞彙轟炸過了: NoSQL!大資料!Web-Scale!分片!最終一致性!ACID!CAP 定理!雲服務!MapReduce!實時!
在最近十年中,我們看到了很多有趣的進展,關於資料庫,分散式系統,以及在此基礎上構建應用程式的方式。這些進展有著各種各樣的驅動力:
- 谷歌、雅虎、亞馬遜、臉書、領英、微軟和推特等網際網路公司正在和巨大的流量 / 資料打交道,這迫使他們去創造能有效應對如此規模的新工具。
- 企業需要變得敏捷,需要低成本地檢驗假設,需要透過縮短開發週期和保持資料模型的靈活性,快速地響應新的市場洞察。
- 免費和開源軟體變得非常成功,在許多環境中比商業軟體和定製軟體更受歡迎。
- 處理器主頻幾乎沒有增長,但是多核處理器已經成為標配,網路也越來越快。這意味著並行化程度只增不減。
- 即使你在一個小團隊中工作,現在也可以構建分佈在多臺計算機甚至多個地理區域的系統,這要歸功於譬如亞馬遜網路服務(AWS)等基礎設施即服務(IaaS)概念的踐行者。
- 許多服務都要求高可用,因停電或維護導致的服務不可用,變得越來越難以接受。
資料密集型應用(data-intensive applications) 正在透過使用這些技術進步來推動可能性的邊界。一個應用被稱為 資料密集型 的,如果 資料是其主要挑戰(資料量,資料複雜度或資料變化速度)—— 與之相對的是 計算密集型,即處理器速度是其瓶頸。
幫助資料密集型應用儲存和處理資料的工具與技術,正迅速地適應這些變化。新型資料庫系統(“NoSQL”)已經備受關注,而訊息佇列,快取,搜尋索引,批處理和流處理框架以及相關技術也非常重要。很多應用組合使用這些工具與技術。
這些生意盎然的時髦詞彙體現出人們對新的可能性的熱情,這是一件好事。但是作為軟體工程師和架構師,如果要開發優秀的應用,我們還需要對各種層出不窮的技術及其利弊權衡有精準的技術理解。為了獲得這種洞察,我們需要深挖時髦詞彙背後的內容。
幸運的是,在技術迅速變化的背後總是存在一些持續成立的原則,無論你使用了特定工具的哪個版本。如果你理解了這些原則,就可以領會這些工具的適用場景,如何充分利用它們,以及如何避免其中的陷阱。這正是本書的初衷。
本書的目標是幫助你在飛速變化的資料處理和資料儲存技術大觀園中找到方向。本書並不是某個特定工具的教程,也不是一本充滿枯燥理論的教科書。相反,我們將看到一些成功資料系統的樣例:許多流行應用每天都要在生產中滿足可伸縮性、效能、以及可靠性的要求,而這些技術構成了這些應用的基礎。
我們將深入這些系統的內部,理清它們的關鍵演算法,討論背後的原則和它們必須做出的權衡。在這個過程中,我們將嘗試尋找 思考 資料系統的有效方式 —— 不僅關於它們 如何 工作,還包括它們 為什麼 以這種方式工作,以及哪些問題是我們需要問的。
閱讀本書後,你能很好地決定哪種技術適合哪種用途,並瞭解如何將工具組合起來,為一個良好應用架構奠定基礎。本書並不足以使你從頭開始構建自己的資料庫儲存引擎,不過幸運的是這基本上很少有必要。你將獲得對系統底層發生事情的敏銳直覺,這樣你就有能力推理它們的行為,做出優秀的設計決策,並追蹤任何可能出現的問題。
本書的目標讀者
如果你開發的應用具有用於儲存或處理資料的某種伺服器 / 後端系統,而且使用網路(例如,Web 應用、移動應用或連線到網際網路的感測器),那麼本書就是為你準備的。
本書是為軟體工程師,軟體架構師,以及喜歡寫程式碼的技術經理準備的。如果你需要對所從事系統的架構做出決策 —— 例如你需要選擇解決某個特定問題的工具,並找出如何最好地使用這些工具,那麼這本書對你尤有價值。但即使你無法選擇你的工具,本書仍將幫助你更好地瞭解所使用工具的長處和短處。
你應當具有一些開發 Web 應用或網路服務的經驗,且應當熟悉關係型資料庫和 SQL。任何你瞭解的非關係型資料庫和其他與資料相關工具都會有所幫助,但不是必需的。對常見網路協議如 TCP 和 HTTP 的大概理解是有幫助的。程式語言或框架的選擇對閱讀本書沒有任何不同影響。
如果以下任意一條對你為真,你會發現這本書很有價值:
- 你想了解如何使資料系統可伸縮,例如,支援擁有數百萬使用者的 Web 或移動應用。
- 你需要提高應用程式的可用性(最大限度地減少停機時間),保持穩定執行。
- 你正在尋找使系統在長期執行過程易於維護的方法,即使系統規模增長,需求與技術也發生變化。
- 你對事物的運作方式有著天然的好奇心,並且希望知道一些主流網站和線上服務背後發生的事情。這本書打破了各種資料庫和資料處理系統的內幕,探索這些系統設計中的智慧是非常有趣的。
有時在討論可伸縮的資料系統時,人們會說:“你又不在谷歌或亞馬遜,別操心可伸縮性了,直接上關係型資料庫”。這個陳述有一定的道理:為了不必要的伸縮性而設計程式,不僅會浪費不必要的精力,並且可能會把你鎖死在一個不靈活的設計中。實際上這是一種 “過早最佳化” 的形式。不過,選擇合適的工具確實很重要,而不同的技術各有優缺點。我們將看到,關聯式資料庫雖然很重要,但絕不是資料處理的終章。
本書涉及的領域
本書並不會嘗試告訴讀者如何安裝或使用特定的軟體包或 API,因為已經有大量文件給出了詳細的使用說明。相反,我們會討論資料系統的基礎 —— 各種原則與利弊權衡,並探討了不同產品所做出的不同設計決策。
在電子書中包含了線上資源全文的連結。所有連結在出版時都進行了驗證,但不幸的是,由於網路的自然規律,連結往往會頻繁地破損。如果你遇到連結斷開的情況,或者正在閱讀本書的列印副本,可以使用搜尋引擎查詢參考文獻。對於學術論文,你可以在 Google 學術中搜尋標題,查詢可以公開獲取的 PDF 檔案。或者,你也可以在 https://github.com/ept/ddia-references 中找到所有的參考資料,我們在那兒維護最新的連結。
我們主要關注的是資料系統的 架構(architecture),以及它們被整合到資料密集型應用中的方式。本書沒有足夠的空間覆蓋部署、運維、安全、管理等領域 —— 這些都是複雜而重要的主題,僅僅在本書中用粗略的註解討論這些對它們很不公平。每個領域都值得用單獨的書去講。
本書中描述的許多技術都被涵蓋在 大資料(Big Data) 這個時髦詞的範疇中。然而 “大資料” 這個術語被濫用,缺乏明確定義,以至於在嚴肅的工程討論中沒有用處。這本書使用歧義更小的術語,如 “單節點” 之於 “分散式系統”,或 “線上 / 互動式系統” 之於 “離線 / 批處理系統”。
本書對 自由和開源軟體(FOSS) 有一定偏好,因為閱讀、修改和執行原始碼是瞭解某事物詳細工作原理的好方法。開放的平臺也可以降低供應商壟斷的風險。然而在適當的情況下,我們也會討論專利軟體(閉源軟體,軟體即服務 SaaS,或一些在文獻中描述過但未公開發行的公司內部軟體)。
本書綱要
本書分為三部分:
在 第一部分 中,我們會討論設計資料密集型應用所賴的基本思想。我們從 第一章 開始,討論我們實際要達到的目標:可靠性、可伸縮性和可維護性;我們該如何思考這些概念;以及如何實現它們。在 第二章 中,我們比較了幾種不同的資料模型和查詢語言,看看它們如何適用於不同的場景。在 第三章 中將討論儲存引擎:資料庫如何在磁碟上擺放資料,以便能高效地再次找到它。第四章 轉向資料編碼(序列化),以及隨時間演化的模式。
在 第二部分 中,我們從討論儲存在一臺機器上的資料轉向討論分佈在多臺機器上的資料。這對於可伸縮性通常是必需的,但帶來了各種獨特的挑戰。我們首先討論複製(第五章)、分割槽 / 分片(第六章)和事務(第七章)。然後我們將探索關於分散式系統問題的更多細節(第八章),以及在分散式系統中實現一致性與共識意味著什麼(第九章)。
在 第三部分 中,我們討論那些從其他資料集派生出一些資料集的系統。派生資料經常出現在異構系統中:當沒有單個資料庫可以把所有事情都做的很好時,應用需要整合幾種不同的資料庫、快取、索引等。在 第十章 中我們將從一種派生資料的批處理方法開始,然後在此基礎上建立在 第十一章 中討論的流處理。最後,在 第十二章 中,我們將所有內容彙總,討論在將來構建可靠、可伸縮和可維護的應用程式的方法。
參考文獻與延伸閱讀
本書中討論的大部分內容已經在其它地方以某種形式出現過了 —— 會議簡報、研究論文、部落格文章、程式碼、BUG 跟蹤器、郵件列表以及工程習慣中。本書總結了不同來源資料中最重要的想法,並在文字中包含了指向原始文獻的連結。如果你想更深入地探索一個領域,那麼每章末尾的參考文獻都是很好的資源,其中大部分可以免費線上獲取。
O‘Reilly Safari
Safari (formerly Safari Books Online) is a membership-based training and reference platform for enterprise, government, educators, and individuals.
Members have access to thousands of books, training videos, Learning Paths, interac‐ tive tutorials, and curated playlists from over 250 publishers, including O’Reilly Media, Harvard Business Review, Prentice Hall Professional, Addison-Wesley Pro‐ fessional, Microsoft Press, Sams, Que, Peachpit Press, Adobe, Focal Press, Cisco Press, John Wiley & Sons, Syngress, Morgan Kaufmann, IBM Redbooks, Packt, Adobe Press, FT Press, Apress, Manning, New Riders, McGraw-Hill, Jones & Bartlett, and Course Technology, among others.
For more information, please visit http://oreilly.com/safari.
聯絡我們
有關本書的評論和問題,請聯絡出版社:
O’Reilly Media, Inc. 1005 Gravenstein Highway North Sebastopol, CA 95472 800-998-9938(美國或加拿大) 707-829-0515(國際或本地) 707-829-0104(傳真)
我們為本書提供了網頁,會在上面列出勘誤、示例以及任何補充資訊。你可以訪問:http://bit.ly/designing-data-intensive-apps。
如需發表評論或提出技術問題,請傳送郵件至:bookquestions@oreilly.com。
有關 O’Reilly 圖書、課程、會議和新聞的更多資訊,請訪問:http://www.oreilly.com。
- Facebook: http://facebook.com/oreilly
- Twitter: http://twitter.com/oreillymedia
- YouTube: http://www.youtube.com/oreillymedia
致謝
本書融合了學術研究和工業實踐的經驗,融合並系統化了大量其他人的想法與知識。在計算領域,我們往往會被各種新鮮花樣所吸引,但我認為前人完成的工作中,有太多值得我們學習的地方了。本書有 800 多處引用:文章、部落格、講座、文件等,對我來說這些都是寶貴的學習資源。我非常感謝這些材料的作者分享他們的知識。
我也從與人交流中學到了很多東西,很多人花費了寶貴的時間與我討論想法並耐心解釋。特別感謝 Joe Adler, Ross Anderson, Peter Bailis, Márton Balassi, Alastair Beresford, Mark Callaghan, Mat Clayton, Patrick Collison, Sean Cribbs, Shirshanka Das, Niklas Ekström, Stephan Ewen, Alan Fekete, Gyula Fóra, Camille Fournier, Andres Freund, John Garbutt, Seth Gilbert, Tom Haggett, Pat Hel‐ land, Joe Hellerstein, Jakob Homan, Heidi Howard, John Hugg, Julian Hyde, Conrad Irwin, Evan Jones, Flavio Junqueira, Jessica Kerr, Kyle Kingsbury, Jay Kreps, Carl Lerche, Nicolas Liochon, Steve Loughran, Lee Mallabone, Nathan Marz, Caitie McCaffrey, Josie McLellan, Christopher Meiklejohn, Ian Meyers, Neha Narkhede, Neha Narula, Cathy O’Neil, Onora O’Neill, Ludovic Orban, Zoran Perkov, Julia Powles, Chris Riccomini, Henry Robinson, David Rosenthal, Jennifer Rullmann, Matthew Sackman, Martin Scholl, Amit Sela, Gwen Shapira, Greg Spurrier, Sam Stokes, Ben Stopford, Tom Stuart, Diana Vasile, Rahul Vohra, Pete Warden, 以及 Brett Wooldridge.
更多人透過審閱草稿並提供反饋意見在本書的創作過程中做出了無價的貢獻。我要特別感謝 Raul Agepati, Tyler Akidau, Mattias Andersson, Sasha Baranov, Veena Basavaraj, David Beyer, Jim Brikman, Paul Carey, Raul Castro Fernandez, Joseph Chow, Derek Elkins, Sam Elliott, Alexander Gallego, Mark Grover, Stu Halloway, Heidi Howard, Nicola Kleppmann, Stefan Kruppa, Bjorn Madsen, Sander Mak, Stefan Podkowinski, Phil Potter, Hamid Ramazani, Sam Stokes, 以及 Ben Summers。當然對於本書中的任何遺留錯誤或難以接受的見解,我都承擔全部責任。
為了幫助這本書落地,並且耐心地處理我緩慢的寫作和不尋常的要求,我要對編輯 Marie Beaugureau,Mike Loukides,Ann Spencer 和 O’Reilly 的所有團隊表示感謝。我要感謝 Rachel Head 幫我找到了合適的術語。我要感謝 Alastair Beresford,Susan Goodhue,Neha Narkhede 和 Kevin Scott,在其他工作事務之外給了我充分地創作時間和自由。
特別感謝 Shabbir Diwan 和 Edie Freedman,他們非常用心地為各章配了地圖。他們提出了不落俗套的靈感,創作了這些地圖,美麗而引人入勝,真是太棒了。
最後我要表達對家人和朋友們的愛,沒有他們,我將無法走完這個將近四年的寫作歷程。你們是最棒的。
I 資料系統基礎
本書前五章介紹了資料系統底層的基礎概念,無論是在單臺機器上執行的單點資料系統,還是分佈在多臺機器上的分散式資料系統都適用。
- 第一章 將介紹 資料系統架構中的利弊權衡。我們將討論不同型別的資料系統(例如,分析型與事務型),以及它們在雲環境中的執行方式。
- 第二章 將介紹非功能性需求的定義。可靠性,可伸縮性和可維護性,這些詞彙到底意味著什麼?如何實現這些目標?
- 第三章 將對幾種不同的 資料模型和查詢語言 進行比較。從程式設計師的角度看,這是資料庫之間最明顯的區別。不同的資料模型適用於不同的應用場景。
- 第四章 將深入 儲存引擎 內部,研究資料庫如何在磁碟上擺放資料。不同的儲存引擎針對不同的負載進行最佳化,選擇合適的儲存引擎對系統效能有巨大影響。
- 第五章 將對幾種不同的 資料編碼 進行比較。特別研究了這些格式在應用需求經常變化、模式需要隨時間演變的環境中表現如何。
第二部分 將專門討論在 分散式資料系統 中特有的問題。
1. 資料系統架構中的權衡
2. 定義非功能性需求
3. 資料模型與查詢語言
4. 儲存與檢索
5. 編碼與演化
1 資料系統架構中的權衡
沒有解決方案,只有權衡取捨。[…] 你只能盡力做出最佳權衡,也只能期望如此。
Thomas Sowell,与 Fred Barnes 的訪談(2005 年)
如今,資料是許多應用開發的核心。隨著 Web 應用、移動應用、軟體即服務(SaaS)和雲服務的普及,在共享的伺服器端資料基礎設施中儲存眾多使用者的資料,已經司空見慣。使用者活動、業務交易、裝置和感測器產生的資料都需要儲存下來,以供分析。使用者與應用互動時,既會讀取已儲存的資料,也會產生更多資料。
少量資料可以在單臺機器上儲存和處理,通常比較容易應付。然而,隨著資料量或查詢速率增長,資料就需要分佈到多臺機器上,由此帶來許多挑戰。應用需求變得更加複雜之後,把所有資料放在一個系統裡也不再夠用,往往需要組合多個能力各異的儲存或處理系統。
如果資料管理是開發應用時面臨的主要挑戰之一,我們就稱這種應用為 資料密集型(data-intensive)應用 1。計算密集型(compute-intensive)系統的難點在於如何將某項極其龐大的計算並行化;而對資料密集型應用來說,我們通常更關心如何儲存和處理海量資料、如何管理資料變更、如何在故障和併發面前保證一致性,以及如何維持服務的高可用性。
這類應用通常由一些標準構件搭建而成,它們提供各種常用功能。例如,許多應用都需要:
- 儲存資料,以便自己或其他應用日後能夠再次找到(資料庫,database)
- 記住開銷昂貴的操作結果,加快讀取速度(快取,cache)
- 允許使用者按關鍵字搜尋資料,或以各種方式過濾資料(搜尋索引,search index)
- 在事件和資料變更發生後立即處理(流處理,stream processing)
- 定期處理累積的大批次資料(批處理,batch processing)
構建應用時,我們通常會選用幾個軟體系統或服務(例如資料庫和 API),再用應用程式碼把它們拼接起來。如果你的用途恰好是這些資料系統原本就為之設計的,整個過程可能相當容易。
但是,隨著應用要實現的目標越來越高,挑戰也隨之而來。資料庫系統種類繁多,特性各異,適用目的也各不相同——該選擇哪一種?快取有不同的做法,搜尋索引也有多種構建方式,諸如此類——該如何權衡?你必須判斷哪些工具和方法最適合手頭的任務;而當一項工作無法由單個工具獨力完成時,把多個工具組合起來也可能很困難。
本書將幫助你決定採用哪些技術,以及如何組合這些技術。正如你將看到的,並不存在一種從根本上優於其他方案的做法;每種方案都有利有弊。本書會教你提出恰當的問題來評估和比較資料系統,從而找出最能滿足特定應用需求的方案。
我們的旅程從當今組織使用資料的一些典型方式開始。這裡的許多思想源自 企業軟體(enterprise software),也就是大型組織(例如大公司和政府機構)的軟體需求與工程實踐。因為在過去,只有大型組織才擁有如此龐大的資料量,需要複雜的技術方案;只要資料量足夠小,用電子表格儲存就行了!不過近年來,小公司和初創企業管理海量資料、構建資料密集型系統,也已變得十分普遍。
資料系統的一項關鍵挑戰是:不同的人需要用資料做截然不同的事情。在一家公司裡,你和你的團隊有自己的一套優先事項;另一個團隊即使在處理同一份資料,也可能有完全不同的目標。而且,這些目標未必得到明確表達,因而很容易造成誤解,並引發對正確方案的爭論。
為了幫助你瞭解有哪些選擇,本章將比較幾組相對的概念,並探討它們之間的權衡:
- 事務型系統與分析型系統有何區別(“分析型與事務型系統”);
- 雲服務與自託管系統各有哪些利弊(“雲服務與自託管”);
- 何時應當從單節點系統轉向分散式系統(“分散式與單節點系統”);以及
- 如何在業務需求與使用者權利之間取得平衡(“資料系統、法律與社會”)。
此外,本章還會提供閱讀本書其餘部分所需的術語。
本書討論的許多內容都與 後端開發(backend development)有關。以 Web 應用為例,執行在瀏覽器中的客戶端程式碼稱為 前端(frontend),處理使用者請求的伺服器端程式碼則稱為 後端(backend)。移動應用與前端相似:它們負責提供使用者介面,並且常常經由網際網路與伺服器端後端通訊。前端有時也會在使用者裝置上管理本地資料 2,但資料基礎設施面臨的最大挑戰往往在後端:前端只需處理一個使用者的資料,而後端要代表 所有 使用者管理資料。
後端服務通常可以透過 HTTP(有時是 WebSocket)訪問。它一般由一些應用程式碼組成:這些程式碼在一個或多個資料庫中讀寫資料,有時也與快取、訊息佇列等其他資料系統互動;這些系統可以統稱為 資料基礎設施(data infrastructure)。應用程式碼通常是 無狀態(stateless)的,也就是說,處理完一個 HTTP 請求後,它就會忘掉有關該請求的一切。任何需要在請求之間持久儲存的資訊,都必須存放在客戶端或伺服器端的資料基礎設施中。
分析型與事務型系統
如果你在企業中從事資料系統工作,很可能會遇到幾類與資料打交道的人。第一類是 後端工程師(backend engineer),負責構建處理資料讀取和更新請求的服務。這些服務通常直接面向外部使用者,或透過其他服務間接為外部使用者提供功能(參見“微服務與無伺服器”);有時也只供組織內其他部門使用。
除了管理後端服務的團隊,通常還有兩類人需要訪問組織的資料:業務分析師(business analyst)根據組織的活動生成報表,幫助管理層做出更好的決策,這就是 商業智慧(BI);資料科學家(data scientist)則從資料中尋找新的洞見,或者利用資料分析和機器學習/AI 構建面向使用者的產品功能,例如電商網站上“購買了 X 的人也購買了 Y”的推薦、風險評分或垃圾郵件過濾等預測分析,以及搜尋結果排名。
業務分析師和資料科學家使用的工具不同,工作方式也不同,但仍有一些共同之處:兩者都要進行 分析(analytics),也就是檢視使用者和後端服務產生的資料,但通常不會修改這些資料(糾正錯誤或許除外)。他們可能會建立衍生資料集,以某種方式處理原始資料。由此形成了兩類相互分離的系統——本書將始終沿用這種區分:
- 事務型系統(operational system)由建立資料的後端服務和資料基礎設施組成,例如直接為外部使用者提供服務。應用程式碼根據使用者執行的操作,讀取和修改資料庫中的資料。
- 分析型系統(analytical system)服務於業務分析師和資料科學家。它們儲存事務型系統資料的只讀副本,並針對分析所需的資料處理方式進行最佳化。
正如下一節將要說明的,事務型系統與分析型系統往往有充分的理由彼此分離。隨著這兩類系統日趨成熟,又出現了兩個專業角色:資料工程師(data engineer)與 分析工程師(analytics engineer)。資料工程師懂得如何整合事務型系統和分析型系統,並對組織的資料基礎設施承擔更廣泛的責任 3。分析工程師則對資料進行建模和轉換,使其更便於組織內的業務分析師和資料科學家使用 4。
許多工程師專攻事務型或分析型系統中的一類。不過,本書會同時涵蓋兩者,因為它們都在組織的資料生命週期中扮演重要角色。我們將深入探討為內部和外部使用者提供服務所需的資料基礎設施,幫助你更好地與分界線另一側的同事合作。
事務處理與分析的特徵
在商業資料處理的早期,每次寫入資料庫通常都對應一筆 商業交易:完成一筆銷售、向供應商下訂單、發放員工工資,等等。後來,資料庫的應用擴充套件到不涉及金錢往來的領域,事務(transaction)這個名稱卻沿用下來,用來指構成一個邏輯單元的一組讀寫操作。
第 8 章會詳細探討“事務”的含義。本章則寬泛地用這個詞指代低延遲的讀寫操作。
儘管資料庫開始處理形形色色的資料——社交媒體帖子、遊戲中的操作、地址簿聯絡人,等等——基本訪問模式仍與處理商業交易相似。事務型系統通常按某個鍵查詢少量記錄(稱為 點查詢,point query),再根據使用者輸入插入、更新或刪除記錄。由於這些應用具有互動性,這種訪問模式稱為 聯機事務處理(OLTP)。
與此同時,資料庫也越來越多地用於分析,而分析的訪問模式與 OLTP 大相徑庭。分析查詢通常會掃描海量記錄,計算計數、總和或平均值等聚合統計量,而不是把一條條記錄返回給使用者。例如,連鎖超市的業務分析師可能想回答下面的問題:
- 我們每家商店在一月份的總收入是多少?
- 在我們最近的促銷期間,我們比平時多賣出了多少香蕉?
- 哪個品牌的嬰兒食品最常與 X 品牌尿布一起購買?
這類查詢產生的報表是商業智慧的重要依據,可以幫助管理層決定下一步行動。為了把這種資料庫使用模式與事務處理區分開來,人們稱之為 聯機分析處理(OLAP)5。OLTP 與分析之間的界線並不總是涇渭分明,但表 1-1列出了二者的一些典型特徵。
| 屬性 | 事務型系統(OLTP) | 分析型系統(OLAP) |
|---|---|---|
| 主要讀取模式 | 點查詢(按鍵讀取單條記錄) | 聚合大量記錄 |
| 主要寫入模式 | 建立、更新和刪除單條記錄 | 批次匯入(ETL)或事件流 |
| 人類使用者示例 | Web 或移動應用的終端使用者 | 為決策提供支援的內部分析師 |
| 機器用途示例 | 檢查某項操作是否獲得授權 | 檢測欺詐或濫用模式 |
| 查詢型別 | 由應用預先定義的一組固定查詢 | 分析師可以任意查詢 |
| 資料表示的內容 | 資料的最新狀態(當前時點) | 一段時間內發生的事件歷史 |
| 資料集規模 | GB 至 TB | TB 至 PB |
OLAP 中的 聯機(online)含義並不明確;它大概是指查詢並非只用於預定義報表,分析師還會以互動方式使用 OLAP 系統進行探索性查詢。
事務型系統一般不允許使用者自行編寫 SQL 查詢並提交給資料庫執行,否則使用者可能讀取或修改自己無權訪問的資料。使用者也可能寫出執行開銷很高的查詢,影響其他使用者使用資料庫。因此,OLTP 系統主要執行寫在應用程式碼中的一組固定查詢,只在維護或排查故障時偶爾執行一次性自定義查詢。分析資料庫則不同:它通常允許使用者自由手寫任意 SQL 查詢,也可以透過 Tableau、Looker 或 Microsoft Power BI 等資料視覺化或儀表盤工具自動生成查詢。
還有一類系統專為分析型負載(即聚合大量記錄的查詢)而設計,卻嵌入在面向使用者的產品中。這類用途稱為 產品分析(product analytics)或 實時分析(real-time analytics),為此設計的系統包括 Pinot、Druid 和 ClickHouse 6。
資料倉儲
起初,同一套資料庫既用於事務處理,也用於分析查詢。事實證明,SQL 在這方面非常靈活,兩類查詢都能勝任。不過到了 20 世紀 80 年代末和 90 年代初,企業開始不再使用 OLTP 系統進行分析,轉而在一個獨立的資料庫系統上執行分析查詢。這個獨立的資料庫稱為 資料倉儲(data warehouse)。
一家大型企業可能擁有幾十乃至上百個聯機事務處理系統:支撐面向客戶的網站,控制實體店的銷售終端(收銀系統),跟蹤倉庫庫存,規劃車輛路線,管理供應商和員工,以及執行許多其他任務。每個系統都很複雜,都需要專門的團隊維護,因此最終大多彼此獨立執行。
通常不宜讓業務分析師和資料科學家直接查詢這些 OLTP 系統,原因有以下幾點:
- 所需資料可能散落在多個事務型系統中,很難透過一條查詢組合這些資料集;這個問題稱為 資料孤島(data silo)。
- 適合 OLTP 的模式和資料佈局不太適合分析(參見“星型與雪花型:分析模式”)。
- 分析查詢的開銷可能很高;在 OLTP 資料庫上執行它們,會影響其他使用者的效能。
- 出於安全或合規方面的考慮,OLTP 系統可能位於一個不允許使用者直接訪問的獨立網路中。
相比之下,資料倉儲 是一個獨立的資料庫,分析師可以盡情查詢,而不會影響 OLTP 系統的執行 7。正如第 4 章將要介紹的,資料倉儲的儲存方式往往與 OLTP 資料庫截然不同,以便針對分析中常見的查詢型別進行最佳化。
資料倉儲儲存著企業各個 OLTP 系統中資料的只讀副本。資料先從 OLTP 資料庫中抽取出來(透過定期轉儲或連續的更新流),再轉換成便於分析的模式並加以清理,最後載入資料倉儲。把資料送入資料倉儲的這一過程稱為 提取—轉換—載入(ETL),如圖 1-1所示。有時也會對調 轉換 與 載入 兩步的順序,即先載入資料,再在資料倉儲內轉換;這便成了 ELT。

有時,ETL 流程的資料來源是外部 SaaS 產品,例如客戶關係管理(CRM)、電子郵件營銷或信用卡處理系統。此時,你無法直接訪問原始資料庫,只能透過軟體供應商的 API 獲取資料。把這些外部系統的資料匯入自己的資料倉儲,就能進行 SaaS API 本身無法支援的分析。針對 SaaS API 的 ETL,通常由 Fivetran、Singer 或 AirByte 等專業資料聯結器服務實現。
有些資料庫系統提供 混合事務/分析處理(HTAP),目標是在單個系統中同時支援 OLTP 和分析,無需透過 ETL 把資料從一個系統送入另一個系統 8 9。然而,許多 HTAP 系統內部仍由一個 OLTP 系統和一個獨立的分析系統耦合而成,只是共同隱藏在統一介面之後。因此,要理解這類系統的工作原理,二者的區別依然重要。
此外,即使已有 HTAP,由於事務型系統和分析型系統的目標與需求不同,把它們分開依然很常見。特別是,每個事務型系統各自擁有資料庫通常被視為良好實踐(參見“微服務與無伺服器”),這樣可能產生數百個獨立的事務型資料庫;而企業通常只設一個資料倉儲,以便業務分析師在一條查詢中組合多個事務型系統的資料。
因此,HTAP 並不能取代資料倉儲。它適合的是另一類場景:同一個應用既要執行掃描大量資料行的分析查詢,又要低延遲地讀取和更新單條記錄。例如,欺詐檢測就可能具有這樣的工作負載 10。
事務型系統與分析型系統的分離,體現了一種更廣泛的趨勢:隨著工作負載要求越來越高,系統也日益專門化,針對特定負載進行最佳化。通用系統應付少量資料綽綽有餘;但規模越大,系統往往越專門化 11。
從資料倉儲到資料湖
資料倉儲通常採用 關係 資料模型,並透過 SQL 查詢(參見第 3 章),有時還會配合專門的商業智慧軟體。這種模型很適合業務分析師所需的查詢,卻不太適合資料科學家的需求;他們可能需要完成下面的工作:
- 把資料轉換成適合訓練機器學習模型的形式。這通常要把資料庫表中的行列轉換為數值向量或矩陣,其中的數值稱為 特徵(feature)。以儘可能提高訓練後模型效能的方式完成這種轉換,稱為 特徵工程(feature engineering);它通常需要編寫難以用 SQL 表達的定製程式碼。
- 對文字資料(例如商品評論)應用自然語言處理技術,嘗試從中提取結構化資訊(例如作者表達的情緒,或提到了哪些主題)。同樣,他們也可能要用計算機視覺技術從照片中提取結構化資訊。
儘管人們一直嘗試為 SQL 資料模型加入機器學習運算元 12,也在關係模型的基礎上構建高效的機器學習系統 13,許多資料科學家仍不願在資料倉儲這樣的關聯式資料庫中工作。他們往往更喜歡 pandas、scikit-learn 等 Python 資料分析庫,R 等統計分析語言,以及 Spark 等分散式分析框架 14。我們將在“資料框、矩陣與陣列”中進一步討論這些工具。
因此,組織需要以適合資料科學家使用的形式提供資料。解決方案是 資料湖(data lake):一個集中的資料儲存庫,儲存一切可能對分析有用的資料副本,這些資料透過 ETL 流程從事務型系統取得。資料湖與資料倉儲的區別在於,它只儲存檔案,並不強制規定檔案格式或資料模型。資料湖中的檔案可以是一批資料庫記錄,以 Avro 或 Parquet 等檔案格式編碼(參見第 5 章);也完全可以是文字、影象、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列,或任何其他型別的資料 15。資料湖不僅更加靈活,而且往往比關係資料儲存更便宜,因為它可以使用物件儲存等廉價通用的檔案儲存(參見“雲原生系統架構”)。
ETL 流程已經泛化為 資料管道(data pipeline);在某些情況下,資料湖成為事務型系統通往資料倉儲途中的一站。資料湖以事務型系統產生的“原始”形態儲存資料,不把它們轉換成關係資料倉儲的模式。這種做法的好處是,每個資料消費者都可以把原始資料轉換成最適合自己需求的形式。它有一個詼諧的名字——壽司原則(sushi principle):“原始資料更好”16。
除了把資料從資料湖載入獨立的資料倉儲,也可以直接針對資料湖中的檔案執行典型的資料倉儲工作負載(SQL 查詢和業務分析),並與資料科學和機器學習工作負載並存。這種架構稱為 資料湖倉(data lakehouse);它需要在資料湖的檔案儲存之上增加查詢執行引擎和後設資料層(例如模式管理)17。
Apache Hive、Spark SQL、Presto 和 Trino 都採用了這種方法。
超越資料湖
隨著分析實踐日益成熟,組織也越來越重視分析系統與資料管道的管理和運維,例如 DataOps 宣言就體現了這種趨勢 18。其中包括治理、隱私,以及遵守 GDPR、CCPA 等法規的問題;我們將在“資料系統、法律與社會”和“立法與自律”中討論這些問題。
此外,分析資料的提供形式越來越多,不僅包括檔案和關係表,還包括事件流(參見第 12 章)。採用基於檔案的分析時,可以定期(例如每天)重新執行分析,以響應資料的變化;流處理則能讓分析系統快得多,通常在幾秒內便對事件作出響應。具體是否值得采用流處理,要看應用對時效性的要求。例如,它可以用來識別並阻止潛在的欺詐或濫用活動。
有時,分析系統的輸出還會提供給事務型系統,這個過程有時稱為 反向 ETL(reverse ETL)19。例如,在分析系統中訓練好的機器學習模型可以部署到生產環境,向終端使用者生成“購買了 X 的人也購買了 Y”之類的推薦。分析系統中這類投入實際應用的輸出也稱為 資料產品(data product)20。機器學習模型可以藉助 TFX、Kubeflow 或 MLflow 等專用工具部署到事務型系統。
權威記錄系統與衍生資料
除了區分事務型系統與分析型系統,本書還區分 權威記錄系統(system of record)與 衍生資料系統(derived data system)。這組術語很有用,可以幫助你理清資料在系統中的流向:
- 權威記錄系統
- 權威記錄系統也稱 權威資料來源(source of truth),儲存某類資料的權威或 規範(canonical)版本。新資料到來時,例如使用者輸入,首先寫入這裡。每項事實只表示一次,通常採用 正規化(normalized)表示(參見“正規化、反正規化與連線”)。如果其他系統與權威記錄系統的資料不一致,那麼按照定義,應以權威記錄系統中的值為準。
- 衍生資料系統
- 衍生系統中的資料,是以另一個系統中的現有資料為基礎,經過某種轉換或處理而得到的結果。衍生資料即使丟失,也可以從原始資料來源重新建立。快取就是一個典型例子:若資料在快取中,便可直接返回;若快取中沒有所需資料,則可以退回底層資料庫讀取。反正規化值、索引、物化檢視、轉換後的資料表示,以及在資料集上訓練的模型,也都屬於這一類。
從技術上講,衍生資料是 冗餘(redundant)的,因為它複製了現有資訊。但是,要讓讀查詢獲得良好效能,這種冗餘往往不可或缺。你可以從同一個資料來源衍生出多個不同的資料集,從不同“視角”觀察資料。
分析型系統通常屬於衍生資料系統,因為它們消費的是在別處建立的資料。事務型服務則可能同時包含權威記錄系統和衍生資料系統:權威記錄系統是資料首先寫入的主資料庫,衍生資料系統則是加快常見讀取操作的索引和快取,尤其適用於權威記錄系統無法高效回答的查詢。
大多數資料庫、儲存引擎和查詢語言,本身並不天然是權威記錄系統或衍生系統。資料庫只是工具,如何使用由你決定。一個系統究竟屬於哪一類,取決於它在應用中的用法,而不是採用了什麼工具。明確哪些資料衍生自哪些其他資料,可以讓原本令人困惑的系統架構變得清晰。
如果一個系統的資料衍生自另一個系統,那麼每當權威記錄系統中的原始資料發生變化,就需要有相應流程更新衍生資料。遺憾的是,許多資料庫在設計時都假定應用只會使用這一個資料庫,因而很難整合多個系統並傳播這類更新。我們將在“資料整合”中討論 資料整合(data integration)的各種方法;藉助這些方法,可以組合多個資料系統,完成單個系統無法獨力完成的任務。
至此,我們對分析與事務處理的比較告一段落。下一節要討論的另一項權衡,你可能已經見過許多人反覆爭論。
雲服務與自託管
無論組織要做什麼,最先遇到的問題之一總是:應當由內部完成,還是外包出去?應當自建,還是購買?
歸根結底,這取決於業務的優先事項。管理學中通常認為,屬於組織核心能力或競爭優勢的事情應當在內部完成;非核心、例行或司空見慣的事情則應交給供應商 21。舉一個極端的例子:大多數公司都不會自行發電(能源公司除外,這裡也不考慮應急備用電源),因為從電網購電更加便宜。
對軟體來說,有兩個重要決定:由誰來構建,又由誰來部署。兩項工作都可以按不同程度外包,從而形成圖 1-2所示的一條連續譜。一個極端是完全定製的軟體,由你自行編寫並在內部執行;另一個極端是廣泛使用的雲服務或軟體即服務(SaaS)產品,由外部供應商開發和運維,你只能透過 Web 介面或 API 使用。

這條連續譜的中間,是由你 自託管(self-hosted)的現成軟體(可以是開源軟體,也可以是商業軟體),也就是由你親自部署。例如,下載 MySQL 並安裝到一臺由你掌控的伺服器上。這臺伺服器可以是你自己的硬體——通常稱為 本地部署(on-premises),即使它實際位於租用的資料中心機架裡,並不真的在你的自有場所——也可以是雲中的虛擬機器,即 基礎設施即服務(IaaS)。這條連續譜上還有更多中間位置,例如採用開源軟體,但執行自己修改過的版本。
與這條連續譜相互獨立的另一個問題是:無論在雲端還是本地,究竟要 如何 部署服務,例如是否採用 Kubernetes 之類的編排框架。不過,部署工具的選擇不在本書討論範圍內,因為還有其他因素對資料系統架構的影響更大。
雲服務的利弊
使用雲服務而不是自行執行同類軟體,本質上就是把軟體的運維外包給雲服務商。採用雲服務既有充分的支援理由,也有充分的反對理由。雲服務商聲稱,與自建基礎設施相比,使用它們的服務能夠節省時間和金錢,還能讓你行動得更快。
雲服務究竟是否比自託管更便宜、更省事,很大程度上取決於你掌握的技能和系統承受的工作負載。如果你已經熟悉所需系統的部署和運維,而且負載相當容易預測(也就是所需機器數量不會劇烈波動),那麼購買自己的機器並自行執行軟體,往往更加便宜 22 23。
反過來,如果你需要一個自己還不會部署和運維的系統,那麼採用雲服務,通常比從頭學習如何自行管理更加容易、快捷。如果還必須專門招聘和培訓人員來維護、運維這個系統,成本可能會非常高。使用雲服務時仍然需要運維團隊(參見“雲時代的運維”),但把基礎系統管理外包出去,可以讓團隊專注於更高層次的問題。
把系統運維外包給專門經營這項服務的公司,也可能得到更好的服務,因為供應商在服務眾多客戶的過程中積累了豐富的運維經驗。另一方面,如果由你自行運維,就能針對自己的特定工作負載配置和調優服務;雲服務商不太可能願意為你進行這樣的定製。
如果系統負載隨時間大幅波動,雲服務尤其有價值。假如機器按峰值負載配置,但計算資源在大多數時候都處於閒置狀態,系統的成本效益就會很差。在這種情況下,雲服務的優勢在於,可以更加容易地隨著需求變化增加或減少計算資源。
例如,分析型系統的負載通常變化極大:要快速執行一項大型分析查詢,需要同時動用大量計算資源;但查詢完成後,這些資源就會閒置,直到使用者發出下一項查詢。預定義查詢(例如日報)可以排隊並排程執行,從而平滑負載;但對互動式查詢而言,越是希望迅速得到結果,工作負載的波動就越大。如果資料集龐大到必須投入大量計算資源才能快速查詢,使用雲服務可以節省成本,因為閒置資源可以歸還服務商,而不必任其空置。資料集較小時,這種差別就沒那麼顯著。
雲服務最大的缺點是你無法掌控它:
- 如果服務缺少你需要的功能,你只能客氣地詢問供應商是否願意新增;通常無法自己動手實現。
- 如果服務宕機,你只能等它恢復。
- 如果你的某種使用方式觸發了缺陷或效能問題,診斷起來會非常困難。對於自行執行的軟體,你可以從作業系統獲取效能指標和除錯資訊,從而瞭解它的行為,還可以檢視伺服器日誌;但服務由供應商託管時,你通常無法接觸這些內部資訊。
- 如果服務停止運營、價格高到無法接受,或供應商以你不喜歡的方式改動產品,你也只能任其擺佈——繼續執行舊版本通常不可行,因此只能被迫遷移到其他服務 24。如果存在提供相容 API 的替代服務,這種風險會小一些;但是許多雲服務並沒有標準 API,切換成本很高,因而產生了供應商鎖定問題。
- 你必須相信雲服務商能夠保護資料安全,這會增加遵守隱私和安全法規的難度。
儘管存在這些風險,組織在雲服務之上構建新應用,或者採用只在系統某些部分使用雲服務的混合方案,還是越來越普遍。不過,雲服務不會取代所有內部資料系統:許多老系統誕生在雲端計算之前;只要某項服務有現有雲服務無法滿足的特殊需求,內部系統仍然不可或缺。例如,高頻交易等對延遲極其敏感的應用,就需要完全掌控硬體。
雲原生系統架構
雲端計算不僅採用了不同的經濟模式——訂閱服務,而不是購買硬體和軟體許可證,再自行執行軟體——它的興起也從技術層面深刻影響了資料系統的實現方式。雲原生(cloud-native)一詞用來描述專為利用雲服務優勢而設計的架構。
原則上,幾乎任何可以自託管的軟體都能以雲服務的形式提供;事實上,許多流行的資料系統如今都有相應的託管服務。然而,從一開始就按雲原生思路設計的系統已經展現出若干優勢:在相同硬體上效能更好,故障恢復更快,能夠迅速調整計算資源以匹配負載,並且可以支援更大的資料集 25 26 27。表 1-2列出了兩類系統的一些例子。
雲服務的分層
許多自託管資料系統對執行環境的要求非常簡單:在 Linux 或 Windows 等常規作業系統上執行,把資料存成檔案系統中的檔案,並透過 TCP/IP 等標準網路協議通訊。少數系統依賴 GPU(用於機器學習)或 RDMA 網絡卡等特殊硬體,但總體而言,自託管軟體使用的都是十分通用的計算資源:CPU、記憶體、檔案系統和 IP 網路。
在雲中,這類軟體可以執行在基礎設施即服務(IaaS)環境裡,使用一臺或多臺虛擬機器(也稱為 例項,instance),每臺例項分配一定數量的 CPU、記憶體、磁碟和網路頻寬。與物理機器相比,雲例項的開通速度更快,可選規格也更多;但除此之外,它們與傳統計算機相似:你可以隨意執行任何軟體,也要自行負責管理。
與之相對,雲原生服務的關鍵思想是:不僅使用作業系統管理的計算資源,還要在低層雲服務的基礎上構建更高層的服務。例如:
- Amazon S3、Azure Blob Storage 和 Cloudflare R2 等 物件儲存(object storage)服務用於儲存大型檔案。它們的 API 比普通檔案系統更受限,只提供基本的檔案讀寫;但好處是隱藏了底層物理機器。服務會自動把資料分佈到許多機器上,你不必擔心其中某臺機器的磁碟空間耗盡。即使某些機器或其磁碟徹底損壞,資料也不會丟失。
- 許多其他服務又建立在物件儲存和其他雲服務之上。例如,Snowflake 是一種雲端分析資料庫(資料倉儲),依靠 S3 儲存資料 27;還有一些服務進一步構建在 Snowflake 之上。
計算機領域的抽象一向如此:該選擇哪一層,並沒有唯一正確的答案。一般來說,層次越高的抽象,往往越面向特定用例。如果你的需求恰好符合某個高層系統的設計場景,那麼直接使用現成系統,通常比自己用低層系統搭建省心得多,也足以滿足需要。反過來,如果沒有任何高層系統符合需求,那就只能用低層元件自行構建。
儲存與計算的分離
在傳統計算中,磁碟儲存被視為持久儲存:我們假定資料一旦寫入磁碟,就不會丟失。為了容忍單塊硬碟故障,人們通常使用 RAID(獨立磁碟冗餘陣列),在連線到同一臺機器的多塊磁碟上儲存資料副本。RAID 既可以由硬體實現,也可以由作業系統透過軟體實現;對於訪問檔案系統的應用來說,這一切都是透明的。
雲中的計算例項(虛擬機器)也可以連線本地磁碟,但雲原生系統通常更願意把它們當作臨時快取,而不是長期儲存。原因在於:一旦相應例項發生故障,本地磁碟就無法訪問;為了適應負載變化而把例項換成另一臺物理機上的更大或更小規格時,本地磁碟同樣無法訪問。
作為本地磁碟的替代方案,雲服務還提供虛擬磁碟儲存,可以從一個例項解除安裝,再掛載到另一個例項上,例如 Amazon EBS、Azure 託管磁碟和 Google Cloud 持久磁碟。這種虛擬磁碟並不是真正的物理磁碟,而是由另一組機器提供的雲服務,用來模擬磁碟的行為——也就是 塊裝置(block device),其中每個塊通常為 4 KiB。這項技術讓傳統的磁碟軟體可以在雲中執行,但塊裝置模擬會引入額外開銷;如果系統從一開始便針對雲設計,這些開銷本來可以避免 25。它還使應用對網路異常極為敏感,因為虛擬塊裝置上的每次 I/O 實際上都是一次網路呼叫 28。
為了解決這個問題,雲原生服務通常避開虛擬磁碟,轉而建立在針對特定工作負載最佳化的專用儲存服務之上。S3 等物件儲存服務適合長期儲存較大的檔案,大小從數百 KB 到數 GB 不等。資料庫中的單行或單個值通常遠小於這個範圍;因此,雲資料庫通常在一個獨立服務中管理較小的值,並把包含許多個值的較大資料塊存入物件儲存 26 29。我們將在第 4 章介紹相應的實現方法。
在傳統系統架構中,同一臺計算機同時負責儲存(磁碟)和計算(CPU 與記憶體);而在雲原生系統中,這兩項職責在一定程度上相互分離,或者說被 解耦(decoupled)了 9 27 30 31。例如,S3 只負責儲存檔案;如果要分析其中的資料,就必須在 S3 之外執行分析程式碼。這也意味著資料需要透過網路傳輸,我們將在“分散式與單節點系統”中進一步討論。
此外,雲原生系統往往採用 多租戶(multitenant)模式:它並不為每個客戶單獨分配一臺機器,而是由同一項服務在共享硬體上處理多個客戶的資料和計算 32。
多租戶可以提高硬體利用率,更容易實現可伸縮性,也便於雲服務商管理;但要保證一個客戶的活動不影響其他客戶的效能或安全,就必須經過周密的工程設計 33。
雲時代的運維
傳統上,管理組織伺服器端資料基礎設施的人員稱為 資料庫管理員(DBA)或 系統管理員(sysadmin)。近年來,許多組織嘗試把軟體開發與運維角色融入同一個團隊,讓團隊共同負責後端服務和資料基礎設施;DevOps 理念推動了這一趨勢。站點可靠性工程師(SRE)則是 Google 對這一理念的實踐 34。
運維的職責是確保服務可靠地交付給使用者,包括配置基礎設施和部署應用;同時保障生產環境穩定,包括監控和診斷任何可能影響可靠性的問題。對自託管系統來說,傳統運維有大量工作落在單臺機器上,例如容量規劃(監控可用磁碟空間,並在耗盡前增加磁碟)、開通新機器、在機器之間遷移服務,以及安裝作業系統補丁。
許多雲服務透過 API 隱藏了實際承載服務的一臺臺機器。例如,雲端儲存不再提供固定容量的磁碟,而是採用 按量計費(metered billing):你無需提前規劃容量,就可以儲存資料,然後按實際佔用的空間付費。此外,即使個別機器發生故障,許多雲服務仍然保持高可用性(參見“可靠性與容錯”)。
關注點從單臺機器轉向服務,也伴隨著運維角色的變化。可靠地提供服務這一高層目標沒有改變,但流程和工具已經演變。DevOps/SRE 理念更強調:
- 自動化——採用可重複的流程,而不是手工執行一次性任務;
- 採用臨時性的虛擬機器和服務,而不是長時間執行的伺服器;
- 支援頻繁更新應用;
- 從事故中吸取教訓;以及
- 即使人員來去更替,也要保留組織對系統的知識 35。
隨著雲服務興起,運維角色也發生了分化:基礎設施公司的運維團隊專攻如何面向大量客戶提供可靠服務;雲服務客戶則力求把投入基礎設施的時間和精力降到最低 36。
雲服務的客戶仍然需要運維,只是關注的方面有所不同,例如為一項任務選擇最合適的服務、整合不同服務,以及從一項服務遷移到另一項服務。儘管按量計費消除了傳統意義上的容量規劃,你依然必須清楚哪些資源被用在什麼地方,免得為並不需要的雲資源白白花錢:容量規劃變成了財務規劃,效能最佳化變成了成本最佳化 37。
而且,雲服務仍有資源上限或 配額(quota),例如併發執行的程序數上限。你必須提前瞭解並做好規劃,不能等到撞上限額才處理 38。
採用雲服務或許比自行執行基礎設施更加容易、快捷,但學習如何使用仍有成本,有時還得設法繞過它的限制。隨著供應商越來越多,面向各種用例的雲服務層出不窮,如何整合不同服務成了一項格外棘手的挑戰 39 40。
ETL(參見“資料倉儲”)只是其中一部分,事務型雲服務同樣需要彼此整合。目前還缺少簡化這類整合的標準,因此往往要投入大量手工工作。
還有一些運維工作無法完全外包給雲服務,例如維護應用及其依賴庫的安全,管理自有服務之間的互動,監控服務負載,以及追查效能下降或服務中斷等問題的根源。雲端計算固然正在改變運維的角色,但運維仍與以往任何時候一樣重要。
分散式與單節點系統
由多臺機器透過網路通訊而構成的系統,稱為 分散式系統(distributed system)。參與分散式系統的每個程序稱為一個 節點(node)。採用分散式系統可能出於以下各種原因:
- 固有的分散式系統
- 如果一項應用涉及兩個或更多相互互動的使用者,而每個使用者都使用自己的裝置,那麼這個系統不可避免地是分散式的:裝置之間只能透過網路通訊。
- 雲服務之間的請求
- 如果資料儲存在一個服務中,卻要由另一個服務處理,就必須透過網路把資料從一個服務傳到另一個服務。
- 容錯/高可用性
- 如果應用需要在一臺機器(乃至多臺機器、網路或整個資料中心)發生故障時繼續執行,可以用多臺機器提供冗餘:一臺發生故障後,由另一臺接管。參見“可靠性與容錯”以及第 6 章關於複製的討論。
- 可伸縮性
- 如果資料量或計算需求增長到單臺機器無力承擔,或許可以把負載分散到多臺機器上。參見“可伸縮性”。
- 延遲
- 如果使用者遍佈世界各地,你可能希望在全球多個區域部署伺服器,讓每個使用者都由地理位置較近的伺服器提供服務。這樣一來,使用者就不必等待網路資料包繞過半個地球才得到響應。參見“描述效能”。
- 彈性
- 如果應用有時繁忙、有時空閒,雲端部署可以隨著需求擴大或縮小,讓你只為正在使用的資源付費。這在單臺機器上很難做到:即使大部分時間幾乎沒有負載,仍要按最大負載預先配置機器。
- 使用專用硬體
- 系統的各個部分可以採用與各自工作負載相匹配的硬體。例如,物件儲存可以使用磁碟很多、CPU 很少的機器;資料分析系統可以使用 CPU 和記憶體很多、卻沒有磁碟的機器;機器學習系統則可以使用配備 GPU 的機器——訓練深度神經網路及執行其他機器學習任務時,GPU 的效率遠高於 CPU。
- 法律合規
- 一些國家制定了資料駐留法律,要求有關本國管轄範圍內人員的資料,必須在該國境記憶體儲和處理 41。這類規定的適用範圍不盡相同:有些只針對醫療或金融資料,有些則更加寬泛。因此,如果一項服務的使用者分佈在多個這樣的司法管轄區,就不得不把使用者資料分散到多個地點的伺服器上。
- 可持續性
- 如果作業在何時、何地執行具有一定靈活性,就可以選擇可再生電力充足的時間和地點,並避開電網負荷緊張的時候。這樣既能減少碳排放,也能利用價格低廉的電力 42 43。
這些理由既適用於自行編寫的服務(應用程式碼),也適用於由資料庫等現成軟體組成的服務。
分散式系統的問題
分散式系統也有缺點。每一個經過網路的請求和 API 呼叫,都必須面對失敗的可能:網路可能中斷,服務可能過載或崩潰,任何請求都有可能超時而收不到響應。此時,我們不知道服務究竟有沒有收到請求,貿然重試未必安全。我們將在第 9 章詳細討論這些問題。
儘管資料中心網路很快,呼叫另一個服務仍然遠遠慢於在同一程序中呼叫函式 44。
處理海量資料時,與其把資料從儲存位置傳到另一臺機器上處理,往往不如把計算帶到已經儲存資料的機器上來得快 45。
節點更多也不一定更快:有些情況下,一臺計算機上簡單的單執行緒程式,效能可以顯著勝過擁有 100 多個 CPU 核心的叢集 46。
分散式系統往往很難排查故障:如果系統響應緩慢,怎樣才能找出問題在哪裡?用於診斷分散式系統問題的技術統稱為 可觀測性(observability)47 48。它收集系統的執行資料,並允許人們查詢這些資料,從而既能分析高層指標,也能追查單個事件。OpenTelemetry、Zipkin 和 Jaeger 等 追蹤(tracing)工具可以記錄哪個客戶端為了什麼操作呼叫了哪個伺服器,以及每次呼叫花了多長時間 49。
資料庫提供了多種保證資料一致性的機制,我們將在第 6 章和第 8 章中看到。但是,當每個服務都有自己的資料庫時,如何保持不同服務之間的資料一致,就成了應用自身的問題。分散式事務(參見第 8 章)是一種可能的手段,但很少用於微服務,因為它與服務彼此獨立的目標背道而馳,而且許多資料庫根本不支援分散式事務 50。
出於以上種種原因,只要一項工作能在單臺機器上完成,通常就比搭建分散式系統簡單得多,也便宜得多 23 46 51。CPU 越來越快,記憶體和磁碟的容量越來越大,硬體也越來越可靠。再加上 DuckDB、SQLite 和 KùzuDB 等單節點資料庫,如今許多工作負載都可以在單個節點上執行。我們將在第 4 章進一步探討這個話題。
微服務與無伺服器
把系統分佈到多臺機器上,最常見的做法是將它劃分為客戶端和伺服器,由客戶端向伺服器傳送請求。這種通訊最常使用 HTTP,我們將在“流經服務的資料流:REST 與 RPC”中進一步討論。同一個程序既可以是伺服器(處理傳入的請求),也可以是客戶端(向其他服務發出請求)。
這種構建應用的方式傳統上稱為 面向服務架構(SOA);近年來,這一思想又進一步演化為 微服務(microservices)架構 52 53。在這種架構中,每項服務都有明確的用途(例如 S3 的用途就是檔案儲存);服務透過 API 向客戶端開放能力,由客戶端經網路呼叫;每項服務還由一個團隊負責維護。這樣一來,複雜應用就可以拆分成多項相互互動的服務,分別由不同團隊管理。
把複雜軟體拆成多項服務有幾個優點:各項服務可以獨立更新,減少團隊之間的協調;每項服務可以獲得符合自身需要的硬體資源;實現細節隱藏在 API 後面,服務負責人可以自由改變實現,而不影響客戶端。在資料儲存方面,通常每項服務都有自己的資料庫,服務之間不共享資料庫。否則,整個資料庫結構實際上都會成為服務 API 的一部分,很難再行修改;而且,一項服務發出的查詢也可能拖累其他服務的效能。
另一方面,服務多了本身也會滋生複雜度:每項服務都需要相應基礎設施,用來部署新版本、根據負載調整硬體資源、收集日誌、監控服務健康狀況,並在出現問題時向值班工程師告警。Kubernetes 等 編排(orchestration)框架為這類基礎設施提供了基礎能力,因此成了部署服務的常用方式。在開發過程中測試一項服務也可能很麻煩,因為它所依賴的其他服務必須一併執行。
微服務 API 也很難演化。呼叫 API 的客戶端會期待其中存在某些欄位;隨著業務需求改變,開發者或許想要增刪 API 欄位,但這可能導致客戶端出錯。更糟的是,這類問題往往要到開發週期後期,更新後的服務 API 部署到預釋出或生產環境時才被發現。OpenAPI 和 gRPC 等 API 描述標準有助於管理客戶端 API 與伺服器 API 之間的關係,我們將在第 5 章中進一步討論。
微服務主要是用技術手段解決人員問題:讓不同團隊無需彼此協調,也能獨立推進工作。這對大公司很有價值;但在團隊不多的小公司裡,微服務很可能只是沒有必要的額外開銷,此時最好採用最簡單的方式實現應用 52。
無伺服器(serverless),又稱 函式即服務(FaaS),是另一種服務部署方式:它把基礎設施管理進一步外包給雲服務商 33。使用虛擬機器時,你必須明確決定何時啟動或關閉例項;無伺服器模型則由雲服務商根據發往服務的請求,自動分配和釋放硬體資源 54。這種部署方式把更多運維負擔轉移給雲服務商,並允許按使用量靈活計費,不再按機器例項收費。為了提供這些好處,許多無伺服器基礎設施會限制函式執行時長和執行時環境,而且函式第一次呼叫時可能啟動緩慢。“無伺服器”這個名稱也容易誤導:每次執行無伺服器函式仍然要用到一臺伺服器,只是下一次執行可能換到另一臺。此外,BigQuery 和各種 Kafka 產品也採用了“無伺服器”這個說法,用來表示服務能夠自動伸縮,並按使用量而不是機器例項收費。
正如雲端儲存用按量計費取代了容量規劃(提前決定購買多少磁碟),無伺服器模式也把按量計費帶到了程式碼執行:你只需為應用程式碼實際執行的時間付費,不必提前配置資源。
雲端計算與超級計算
雲端計算並非構建大規模計算系統的唯一方式,另一條路線是 高效能運算(HPC),也稱為 超級計算(supercomputing)。儘管二者有所重疊,但與雲端計算和企業資料中心繫統相比,HPC 的側重點通常不同,採用的技術也不一樣。差異包括:
- 超級計算機通常用於計算密集型的科學計算任務,例如天氣預報、氣候建模、分子動力學(模擬原子和分子的運動)、複雜最佳化問題,以及求解偏微分方程。雲端計算則更常用於線上服務、業務資料系統,以及其他需要以高可用性響應使用者請求的系統。
- 超級計算機通常執行大型批處理作業,並不時把計算狀態作為檢查點寫入磁碟。如果某個節點發生故障,一種常見做法是直接停止整個叢集的工作負載,修復故障節點,再從最近的檢查點重新開始計算 55 56。雲服務通常不能這樣停掉整個叢集,因為服務必須持續響應使用者,儘量減少中斷。
- 超級計算機的節點通常透過共享記憶體和遠端直接記憶體訪問(RDMA)通訊,既有高頻寬,又有低延遲,但前提是系統使用者彼此高度信任 57。在雲端計算中,網路和機器常由互不信任的組織共享,因此需要更強的安全機制,例如資源隔離(使用虛擬機器等)、加密和認證。
- 雲資料中心網路通常以 IP 和乙太網為基礎,採用 Clos 拓撲來提供較高的對分頻寬——這是衡量網路整體效能的常用指標 55 58。超級計算機則常採用專門的網路拓撲,例如多維網格和環面 59;對於通訊模式已知的 HPC 工作負載,這類拓撲可以提供更好的效能。
- 雲端計算允許節點分佈在多個地理區域;超級計算機通常假定所有節點都彼此鄰近。
大規模分析系統有時也具備超級計算的一些特徵;如果你在這個領域工作,瞭解這些技術會很有價值。不過,本書主要關注必須持續可用的服務,正如“可靠性與容錯”所討論的那樣。
資料系統、法律與社會
本章到目前為止已經說明,資料系統架構不僅受技術目標和要求影響,也受其所服務組織中人類需求的影響。越來越多的資料系統工程師開始認識到,只滿足自己所在企業的需求還不夠:我們也對整個社會負有責任。
其中一個特別值得關注的問題,是儲存個人及其行為資料的系統。自 2018 年起,《通用資料保護條例》(GDPR)賦予許多歐洲國家的居民更大的個人資料控制權和更多法律權利;世界各地的不少國家和地區也採用了類似的隱私法規,例如《加州消費者隱私法案》(CCPA)。《歐盟人工智慧法案》 等針對 AI 的法規,又進一步限制了個人資料的使用方式。
即使在沒有直接受到監管的領域,人們也越來越清楚地認識到計算機系統對個人和社會的影響。社交媒體改變了人們獲取新聞的方式,進而影響政治觀點,甚至可能左右選舉結果。自動化系統也越來越多地作出對個人影響深遠的決定,例如誰能獲得貸款或保險,誰能得到工作面試的機會,以及誰會成為犯罪嫌疑人 60。
每一個參與這類系統的人,都有責任考慮它們的倫理影響,並確保系統遵守相關法律。並非人人都要成為法律和倫理專家,但具備基本的法律與倫理常識,與掌握分散式系統的基礎知識同樣重要。
法律因素正在影響資料系統設計最根本的部分 61。例如,GDPR 賦予個人要求刪除其資料的權利,有時稱為 被遺忘權(right to be forgotten)。然而,正如本書將要介紹的,許多資料系統的設計依賴僅追加日誌等不可變結構;一個本應不可變的檔案,要怎樣從中間刪除某些資料?如果資料已經納入衍生資料集(參見“權威記錄系統與衍生資料”),例如成為機器學習模型的訓練資料,又該如何刪除?回答這些問題帶來了新的工程挑戰。
目前,還沒有明確指南說明哪些具體技術或系統架構算是“符合 GDPR”。法規有意不規定特定技術,因為技術進步可能很快就會使這些規定過時。法律文字給出的只是有待解釋的高層原則。因此,如何遵守隱私法規並沒有簡單答案;不過,在討論本書的一些技術時,我們會從這個角度加以審視。
一般來說,我們之所以儲存資料,是因為相信它的價值高於儲存成本。但不要忘記,儲存成本不只是付給 Amazon S3 或其他服務的賬單。成本效益分析還應計入這些風險:資料一旦洩露,或遭到攻擊者竊取、破壞,可能承擔法律責任並蒙受聲譽損失;如果資料的儲存和處理不符合法律規定,還可能產生訴訟費用和罰款 51。
政府或警方也可能強迫企業交出資料。如果資料可能暴露某些在當地被法律定為犯罪的行為——例如,在一些中東和非洲國家,同性戀會受到刑事處罰;在美國一些州,尋求墮胎也可能受到刑事追究——那麼儲存這些資料會給使用者帶來切實的安全風險。例如,位置資料很容易暴露一個人曾前往墮胎診所;哪怕只是一段使用者 IP 地址的歷史日誌,也可能洩露其大致位置。
把所有風險都考慮在內之後,合理的結論可能是:某些資料根本不值得儲存,因此應當刪除。資料最小化(data minimization)原則(有時也用德語 Datensparsamkeit 表示)與“大資料”理念背道而馳;後者傾向於先存下大量資料,指望它們將來也許會派上用場 62。不過,資料最小化符合 GDPR:個人資料只能為具體、明確的目的而收集,日後不得用於其他目的,也不得在超出原定目的所需期限後繼續保留 63。
企業同樣開始重視隱私和安全問題。信用卡公司要求支付處理企業遵守嚴格的支付卡行業(PCI)標準;支付處理商要頻繁接受獨立審計機構的評估,驗證是否持續合規。軟體供應商面臨的審查也日趨嚴格,許多采購方如今要求供應商符合服務組織控制(SOC)第 2 類標準。與 PCI 合規一樣,供應商要透過第三方審計來驗證是否符合要求。
總而言之,必須在業務需求與資料被收集、處理的人們的需求之間取得平衡。這個話題遠不止於此;第 14 章將深入討論倫理與法律合規問題,包括偏見和歧視。
總結
本章的主題是理解權衡:許多問題並沒有唯一正確的答案,而是有幾種不同的做法,各有利弊。我們探討了影響資料系統架構的一些最重要的選擇,也介紹了閱讀本書其餘部分所需的術語。
首先,我們區分了事務型系統(事務處理,即 OLTP)與分析型系統(OLAP),看到兩者不僅管理著訪問模式不同的各類資料,服務的群體也不相同。我們還認識了資料倉儲與資料湖,它們透過 ETL 接收事務型系統送來的資料。第 4 章將會說明,由於要服務的查詢型別不同,事務型系統與分析型系統的內部資料佈局往往大相徑庭。
接著,我們把出現時間較晚的雲服務,與此前長期主導資料系統架構的自託管軟體正規化作了比較。哪種方式成本效益更高,很大程度上取決於具體情況;但不可否認的是,雲原生方法正在深刻改變資料系統的架構,例如將儲存與計算分離。
雲系統天然就是分散式系統,我們也簡要考察了分散式系統與單機方案之間的一些權衡。有些場景無法避免分散式;但只要系統還能保留在一臺機器上,就不宜急著將其分散式化。第 9 章將更詳細地討論分散式系統帶來的挑戰。
最後,我們看到,資料系統架構不僅由部署系統的企業需求決定,也受隱私法規影響;這些法規保護著資料處理所涉及人員的權利,而許多工程師很容易忽視這一點。如何把法律要求轉化為技術實現,目前還沒有得到充分理解;但在閱讀本書後續內容時,務必始終把這個問題放在心上。
參考文獻
Richard T. Kouzes, Gordon A. Anderson, Stephen T. Elbert, Ian Gorton, and Deborah K. Gracio. The Changing Paradigm of Data-Intensive Computing. IEEE Computer, volume 42, issue 1, January 2009. doi:10.1109/MC.2009.26 ↩︎
Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: you own your data, in spite of the cloud. At 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019. doi:10.1145/3359591.3359737 ↩︎
Joe Reis and Matt Housley. Fundamentals of Data Engineering. O’Reilly Media, 2022. ISBN: 9781098108304 ↩︎
Rui Pedro Machado and Helder Russa. Analytics Engineering with SQL and dbt. O’Reilly Media, 2023. ISBN: 9781098142384 ↩︎
Edgar F. Codd, S. B. Codd, and C. T. Salley. Providing OLAP to User-Analysts: An IT Mandate. E. F. Codd Associates, 1993. Archived at perma.cc/RKX8-2GEE ↩︎
Chinmay Soman and Neha Pawar. Comparing Three Real-Time OLAP Databases: Apache Pinot, Apache Druid, and ClickHouse. startree.ai, April 2023. Archived at perma.cc/8BZP-VWPA ↩︎
Surajit Chaudhuri and Umeshwar Dayal. An Overview of Data Warehousing and OLAP Technology. ACM SIGMOD Record, volume 26, issue 1, pages 65–74, March 1997. doi:10.1145/248603.248616 ↩︎
Fatma Özcan, Yuanyuan Tian, and Pinar Tözün. Hybrid Transactional/Analytical Processing: A Survey. At ACM International Conference on Management of Data (SIGMOD), May 2017. doi:10.1145/3035918.3054784 ↩︎
Adam Prout, Szu-Po Wang, Joseph Victor, Zhou Sun, Yongzhu Li, Jack Chen, Evan Bergeron, Eric Hanson, Robert Walzer, Rodrigo Gomes, and Nikita Shamgunov. Cloud-Native Transactions and Analytics in SingleStore. At International Conference on Management of Data (SIGMOD), June 2022. doi:10.1145/3514221.3526055 ↩︎ ↩︎
Chao Zhang, Guoliang Li, Jintao Zhang, Xinning Zhang, and Jianhua Feng. HTAP Databases: A Survey. IEEE Transactions on Knowledge and Data Engineering, April 2024. doi:10.1109/TKDE.2024.3389693 ↩︎
Michael Stonebraker and Uğur Çetintemel. ‘One Size Fits All’: An Idea Whose Time Has Come and Gone. At 21st International Conference on Data Engineering (ICDE), April 2005. doi:10.1109/ICDE.2005.1 ↩︎
Jeffrey Cohen, Brian Dolan, Mark Dunlap, Joseph M. Hellerstein, and Caleb Welton. MAD Skills: New Analysis Practices for Big Data. Proceedings of the VLDB Endowment, volume 2, issue 2, pages 1481–1492, August 2009. doi:10.14778/1687553.1687576 ↩︎
Dan Olteanu. The Relational Data Borg is Learning. Proceedings of the VLDB Endowment, volume 13, issue 12, August 2020. doi:10.14778/3415478.3415572 ↩︎
Matt Bornstein, Martin Casado, and Jennifer Li. Emerging Architectures for Modern Data Infrastructure: 2020. future.a16z.com, October 2020. Archived at perma.cc/LF8W-KDCC ↩︎
Martin Fowler. DataLake. martinfowler.com, February 2015. Archived at perma.cc/4WKN-CZUK ↩︎
Bobby Johnson and Joseph Adler. The Sushi Principle: Raw Data Is Better. At Strata+Hadoop World, February 2015. ↩︎
Michael Armbrust, Ali Ghodsi, Reynold Xin, and Matei Zaharia. Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics. At 11th Annual Conference on Innovative Data Systems Research (CIDR), January 2021. ↩︎
DataKitchen, Inc. The DataOps Manifesto. dataopsmanifesto.org, 2017. Archived at perma.cc/3F5N-FUQ4 ↩︎
Tejas Manohar. What is Reverse ETL: A Definition & Why It’s Taking Off. hightouch.io, November 2021. Archived at perma.cc/A7TN-GLYJ ↩︎
Simon O’Regan. Designing Data Products. towardsdatascience.com, August 2018. Archived at perma.cc/HU67-3RV8 ↩︎
Camille Fournier. Why is it so hard to decide to buy? skamille.medium.com, July 2021. Archived at perma.cc/6VSG-HQ5X ↩︎
David Heinemeier Hansson. Why we’re leaving the cloud. world.hey.com, October 2022. Archived at perma.cc/82E6-UJ65 ↩︎
Nima Badizadegan. Use One Big Server. specbranch.com, August 2022. Archived at perma.cc/M8NB-95UK ↩︎ ↩︎
Steve Yegge. Dear Google Cloud: Your Deprecation Policy is Killing You. steve-yegge.medium.com, August 2020. Archived at perma.cc/KQP9-SPGU ↩︎
Alexandre Verbitski, Anurag Gupta, Debanjan Saha, Murali Brahmadesam, Kamal Gupta, Raman Mittal, Sailesh Krishnamurthy, Sandor Maurice, Tengiz Kharatishvili, and Xiaofeng Bao. Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases. At ACM International Conference on Management of Data (SIGMOD), pages 1041–1052, May 2017. doi:10.1145/3035918.3056101 ↩︎ ↩︎ ↩︎
Panagiotis Antonopoulos, Alex Budovski, Cristian Diaconu, Alejandro Hernandez Saenz, Jack Hu, Hanuma Kodavalla, Donald Kossmann, Sandeep Lingam, Umar Farooq Minhas, Naveen Prakash, Vijendra Purohit, Hugh Qu, Chaitanya Sreenivas Ravella, Krystyna Reisteter, Sheetal Shrotri, Dixin Tang, and Vikram Wakade. Socrates: The New SQL Server in the Cloud. At ACM International Conference on Management of Data (SIGMOD), pages 1743–1756, June 2019. doi:10.1145/3299869.3314047 ↩︎ ↩︎ ↩︎
Midhul Vuppalapati, Justin Miron, Rachit Agarwal, Dan Truong, Ashish Motivala, and Thierry Cruanes. Building An Elastic Query Engine on Disaggregated Storage. At 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), February 2020. ↩︎ ↩︎ ↩︎ ↩︎
Nick Van Wiggeren. The Real Failure Rate of EBS. planetscale.com, March 2025. Archived at perma.cc/43CR-SAH5 ↩︎
Colin Breck. Predicting the Future of Distributed Systems. blog.colinbreck.com, August 2024. Archived at perma.cc/K5FC-4XX2 ↩︎
Gwen Shapira. Compute-Storage Separation Explained. thenile.dev, January 2023. Archived at perma.cc/QCV3-XJNZ ↩︎
Ravi Murthy and Gurmeet Goindi. AlloyDB for PostgreSQL under the hood: Intelligent, database-aware storage. cloud.google.com, May 2022. Archived at archive.org ↩︎
Jack Vanlightly. The Architecture of Serverless Data Systems. jack-vanlightly.com, November 2023. Archived at perma.cc/UDV4-TNJ5 ↩︎
Eric Jonas, Johann Schleier-Smith, Vikram Sreekanti, Chia-Che Tsai, Anurag Khandelwal, Qifan Pu, Vaishaal Shankar, Joao Carreira, Karl Krauth, Neeraja Yadwadkar, Joseph E. Gonzalez, Raluca Ada Popa, Ion Stoica, David A. Patterson. Cloud Programming Simplified: A Berkeley View on Serverless Computing. arxiv.org, February 2019. ↩︎ ↩︎
Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy. Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media, 2016. ISBN: 9781491929124 ↩︎
Thomas Limoncelli. The Time I Stole $10,000 from Bell Labs. ACM Queue, volume 18, issue 5, November 2020. doi:10.1145/3434571.3434773 ↩︎
Charity Majors. The Future of Ops Jobs. acloudguru.com, August 2020. Archived at perma.cc/GRU2-CZG3 ↩︎
Boris Cherkasky. (Over)Pay As You Go for Your Datastore. medium.com, September 2021. Archived at perma.cc/Q8TV-2AM2 ↩︎
Shlomi Kushchi. Serverless Doesn’t Mean DevOpsLess or NoOps. thenewstack.io, February 2023. Archived at perma.cc/3NJR-AYYU ↩︎
Erik Bernhardsson. Storm in the stratosphere: how the cloud will be reshuffled. erikbern.com, November 2021. Archived at perma.cc/SYB2-99P3 ↩︎
Benn Stancil. The data OS. benn.substack.com, September 2021. Archived at perma.cc/WQ43-FHS6 ↩︎
Maria Korolov. Data residency laws pushing companies toward residency as a service. csoonline.com, January 2022. Archived at perma.cc/CHE4-XZZ2 ↩︎
Severin Borenstein. Can Data Centers Flex Their Power Demand? energyathaas.wordpress.com, April 2025. Archived at https://perma.cc/MUD3-A6FF ↩︎
Bilge Acun, Benjamin Lee, Fiodar Kazhamiaka, Aditya Sundarrajan, Kiwan Maeng, Manoj Chakkaravarthy, David Brooks, and Carole-Jean Wu. Carbon Dependencies in Datacenter Design and Management. ACM SIGENERGY Energy Informatics Review, volume 3, issue 3, pages 21–26. doi:10.1145/3630614.3630619 ↩︎
Kousik Nath. These are the numbers every computer engineer should know. freecodecamp.org, September 2019. Archived at perma.cc/RW73-36RL ↩︎
Joseph M. Hellerstein, Jose Faleiro, Joseph E. Gonzalez, Johann Schleier-Smith, Vikram Sreekanti, Alexey Tumanov, and Chenggang Wu. Serverless Computing: One Step Forward, Two Steps Back. At Conference on Innovative Data Systems Research (CIDR), January 2019. ↩︎
Frank McSherry, Michael Isard, and Derek G. Murray. Scalability! But at What COST? At 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015. ↩︎ ↩︎
Cindy Sridharan. Distributed Systems Observability: A Guide to Building Robust Systems. Report, O’Reilly Media, May 2018. Archived at perma.cc/M6JL-XKCM ↩︎
Charity Majors. Observability — A 3-Year Retrospective. thenewstack.io, August 2019. Archived at perma.cc/CG62-TJWL ↩︎
Benjamin H. Sigelman, Luiz André Barroso, Mike Burrows, Pat Stephenson, Manoj Plakal, Donald Beaver, Saul Jaspan, and Chandan Shanbhag. Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Technical Report dapper-2010-1, April 2010. Archived at perma.cc/K7KU-2TMH ↩︎
Rodrigo Laigner, Yongluan Zhou, Marcos Antonio Vaz Salles, Yijian Liu, and Marcos Kalinowski. Data management in microservices: State of the practice, challenges, and research directions. Proceedings of the VLDB Endowment, volume 14, issue 13, pages 3348–3361, September 2021. doi:10.14778/3484224.3484232 ↩︎
Jordan Tigani. Big Data is Dead. motherduck.com, February 2023. Archived at perma.cc/HT4Q-K77U ↩︎ ↩︎
Sam Newman. Building Microservices, second edition. O’Reilly Media, 2021. ISBN: 9781492034025 ↩︎ ↩︎
Chris Richardson. Microservices: Decomposing Applications for Deployability and Scalability. infoq.com, May 2014. Archived at perma.cc/CKN4-YEQ2 ↩︎
Mohammad Shahrad, Rodrigo Fonseca, Íñigo Goiri, Gohar Chaudhry, Paul Batum, Jason Cooke, Eduardo Laureano, Colby Tresness, Mark Russinovich, Ricardo Bianchini. Serverless in the Wild: Characterizing and Optimizing the Serverless Workload at a Large Cloud Provider. At USENIX Annual Technical Conference (ATC), July 2020. ↩︎
Luiz André Barroso, Urs Hölzle, and Parthasarathy Ranganathan. The Datacenter as a Computer: Designing Warehouse-Scale Machines, third edition. Morgan & Claypool Synthesis Lectures on Computer Architecture, October 2018. doi:10.2200/S00874ED3V01Y201809CAC046 ↩︎ ↩︎
David Fiala, Frank Mueller, Christian Engelmann, Rolf Riesen, Kurt Ferreira, and Ron Brightwell. Detection and Correction of Silent Data Corruption for Large-Scale High-Performance Computing,” at International Conference for High Performance Computing, Networking, Storage and Analysis (SC), November 2012. doi:10.1109/SC.2012.49 ↩︎
Anna Kornfeld Simpson, Adriana Szekeres, Jacob Nelson, and Irene Zhang. Securing RDMA for High-Performance Datacenter Storage Systems. At 12th USENIX Workshop on Hot Topics in Cloud Computing (HotCloud), July 2020. ↩︎
Arjun Singh, Joon Ong, Amit Agarwal, Glen Anderson, Ashby Armistead, Roy Bannon, Seb Boving, Gaurav Desai, Bob Felderman, Paulie Germano, Anand Kanagala, Jeff Provost, Jason Simmons, Eiichi Tanda, Jim Wanderer, Urs Hölzle, Stephen Stuart, and Amin Vahdat. Jupiter Rising: A Decade of Clos Topologies and Centralized Control in Google’s Datacenter Network. At Annual Conference of the ACM Special Interest Group on Data Communication (SIGCOMM), August 2015. doi:10.1145/2785956.2787508 ↩︎
Glenn K. Lockwood. Hadoop’s Uncomfortable Fit in HPC. glennklockwood.blogspot.co.uk, May 2014. Archived at perma.cc/S8XX-Y67B ↩︎
Cathy O’Neil: Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy. Crown Publishing, 2016. ISBN: 9780553418811 ↩︎
Supreeth Shastri, Vinay Banakar, Melissa Wasserman, Arun Kumar, and Vijay Chidambaram. Understanding and Benchmarking the Impact of GDPR on Database Systems. Proceedings of the VLDB Endowment, volume 13, issue 7, pages 1064–1077, March 2020. doi:10.14778/3384345.3384354 ↩︎
Martin Fowler. Datensparsamkeit. martinfowler.com, December 2013. Archived at perma.cc/R9QX-CME6 ↩︎
Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation). Official Journal of the European Union L 119/1, May 2016. ↩︎
2 定義非功能性需求

網際網路做得太好了,以至於大多數人將它看作像太平洋這樣的自然資源,而不是什麼人工產物。上一次出現這種規模且幾乎不出錯的技術,是什麼時候?
艾倫・凱, 接受 Dr Dobb’s Journal 採訪(2012 年)
構建應用程式時,你會面對一張需求清單。其中排在最前面的,很可能是應用必須提供的功能:需要哪些頁面和按鈕,每項操作要完成什麼,才能實現軟體的目的。這些就是 功能需求(functional requirements)。
此外,你可能還有一些 非功能性需求(nonfunctional requirements):例如,應用應當快速、可靠、安全、符合法律規定,而且易於維護。這些要求未必會明確寫下來,因為它們看似理所當然,但其重要性絲毫不亞於應用的功能:一個慢得讓人無法忍受,或是很不可靠的應用,幾乎等於不存在。
安全性等許多非功能性需求超出了本書的範圍。不過,本章會討論其中幾項,並幫助你準確表述自己的系統需要達到什麼要求:
- 如何定義和衡量系統的 效能(performance;參見“描述效能”);
- 服務 可靠(reliable)意味著什麼——也就是即使出了問題,仍能繼續正確工作(參見“可靠性與容錯”);
- 隨著系統負載增長,能否高效增加計算能力,使系統具備 可伸縮性(scalability;參見“可伸縮性”);以及
- 如何讓系統在長期使用中更易維護(參見“可維護性”)。
後續章節深入討論資料密集型系統的實現細節時,還會用到本章引入的術語。不過,抽象定義讀起來難免枯燥。為了讓這些概念更加具體,我們先從一個社交網路服務的實現案例講起,以此說明效能與可伸縮性在實踐中意味著什麼。
案例研究:社交網路首頁時間線
假設你的任務是實現一個類似 X(原 Twitter)的社交網路,使用者可以發帖,也可以關注其他使用者。這裡的實現會比真實服務簡單得多 1 2 3,但足以說明大規模系統會遇到的一些問題。
假設使用者每天釋出 5 億條帖子,平均每秒 5,700 條;偶爾,發帖速率會飆升至每秒 150,000 條 4。再假設每位使用者平均關注 200 人,也有 200 名關注者(實際分佈範圍非常廣:大多數人只有寥寥幾名關注者,而巴拉克・奧巴馬等少數名人則有超過一億名關注者)。
表示使用者、帖子與關注關係
假設我們把所有資料都儲存在關聯式資料庫中,如圖 2-1所示:一張表儲存使用者,一張表儲存帖子,還有一張表儲存關注關係。

假設這個社交網路需要支援的主要讀操作是 首頁時間線(home timeline),用來展示你所關注的人最近釋出的帖子(為簡單起見,我們忽略廣告、來自未關注使用者的推薦帖,以及其他擴充套件功能)。可以用下面這條 SQL 查詢來獲取某位使用者的首頁時間線:
執行這條查詢時,資料庫先用 follows 表找出 current_user 關注的所有人,再查詢這些使用者最近釋出的帖子,並按時間戳排序,取出其中最新的 1,000 條。
帖子講究時效,因此假設某人發帖之後,我們希望其關注者能在 5 秒內看到。一個辦法是,只要使用者線上,客戶端就每隔 5 秒重複執行一次上述查詢(這稱為 輪詢(polling))。如果同時線上且已登入的使用者有 1,000 萬,就意味著每秒要執行 200 萬次查詢。即使延長輪詢間隔,這個數字仍然很大。
而且,這條查詢本身開銷不小:如果你關注了 200 人,資料庫就要分別取出這 200 人最近釋出的帖子,再把這些列表合併起來。每秒 200 萬次時間線查詢,意味著資料庫每秒要查詢某個發帖者的近期帖子 4 億次——這是個驚人的數字,而且還只是平均情況。有些使用者關注了數萬個賬戶,為他們執行這條查詢的代價極高,也很難保證速度。
時間線的物化與更新
怎樣才能做得更好?首先,與其讓客戶端輪詢,不如由伺服器把新帖子主動推送給當前線上的關注者。其次,可以預先計算上述查詢的結果,使首頁時間線請求直接由快取提供。
設想我們為每位使用者儲存一個資料結構,其中裝著他們的首頁時間線,也就是他們所關注的人最近釋出的帖子。每當有人發帖,我們就找出他的所有關注者,把這條帖子插入每位關注者的首頁時間線,就像把信投進一個個郵箱。這樣,使用者登入時只需把預先計算好的時間線交給他們即可。若要接收時間線上的新帖通知,客戶端只需訂閱不斷加入其首頁時間線的帖子流。
這種方法的缺點是,每次發帖都要完成更多工作,因為首頁時間線是需要隨之更新的 衍生資料(derived data)。整個過程如圖 2-2所示。當一個初始請求引發多個下游請求時,我們用 扇出(fan-out) 來表示請求數量被放大的倍數。

以每秒 5,700 條帖子的速率計算,如果每條帖子平均要送達 200 名關注者(也就是扇出係數為 200),每秒就要完成略多於 100 萬次首頁時間線寫入。這個數字依然很大,但與另一種方案每秒 4 億次按發帖者查詢近期帖子的操作相比,已經節省了很多工作。
如果某個特殊事件使發帖速率驟增,我們無須立刻完成所有時間線投遞;可以先把投遞任務放入佇列,並接受帖子暫時要過一會兒才會出現在關注者的時間線上。即使出現這種負載高峰,時間線仍然可以快速載入,因為讀取只需訪問快取。
這種預先計算並不斷更新查詢結果的過程稱為 物化(materialization),時間線快取就是一個 物化檢視(materialized view) 的例子(我們會在“維護物化檢視”中進一步討論這個概念)。物化檢視加快了讀取,代價則是寫入時要做更多工作。對大多數使用者而言,寫入成本並不高,但社交網路還必須考慮一些極端情況:
- 如果一位使用者關注了非常多的賬戶,而且這些賬戶發帖頻繁,那麼寫入其物化時間線的速率會很高。不過,這位使用者多半也不會讀完時間線中的所有帖子,因此可以丟棄其中一部分時間線寫入,只向使用者展示所關注賬戶釋出帖子的一個樣本 5。
- 如果一位擁有海量關注者的名人發帖,我們就要完成大量工作,把這條帖子插入數百萬人的首頁時間線。在這種情況下,丟棄部分寫入是不可接受的。一種解決辦法是把名人帖子與其他人的帖子分開處理:不必費力把名人帖子加入數百萬條時間線,而是將其單獨儲存,等讀取時再與物化時間線合併。即使採用這類最佳化,社交網路要承載名人賬戶仍可能需要大量基礎設施 6。
描述效能
討論軟體效能時,通常會考慮兩類主要指標:
- 響應時間(response time)
- 從使用者發出請求到收到所需響應所經過的時間。計量單位是秒(或毫秒、微秒)。
- 吞吐量(throughput)
- 系統每秒處理的請求數或資料量。對於給定的硬體資源,系統能處理的吞吐量存在上限,也就是 最大吞吐量(maximum throughput)。計量單位通常寫成“每秒多少個……”。
在社交網路案例中,“每秒帖子數”和“每秒時間線寫入數”是吞吐量指標;“載入首頁時間線所需的時間”和“帖子送達關注者所需的時間”則是響應時間指標。
吞吐量和響應時間之間往往存在聯絡,圖 2-3勾勒了線上服務中二者的一種典型關係。請求吞吐量較低時,服務的響應時間也很短;隨著負載增大,響應時間隨之上升。這是 排隊(queueing)造成的:當請求到達負載很高的系統時,CPU 很可能正在處理先前的請求,新來的請求只好等到前一個處理完畢。當吞吐量逐漸逼近硬體的處理極限時,排隊延遲會急劇增加。

當系統瀕臨過載、吞吐量已被推到極限附近時,有時會陷入惡性迴圈:系統效率越來越低,因而變得更加過載。例如,等待處理的請求排起長隊,響應時間可能因此增長到客戶端超時並重發請求。請求速率隨之進一步上升,讓問題愈演愈烈——這就是 重試風暴(retry storm)。即使負載隨後下降,系統也可能一直停留在過載狀態,直到重啟或以其他方式重置。這種現象稱為 亞穩態故障(metastable failure),它可能導致生產系統嚴重中斷 7 8。
為了避免重試壓垮服務,可以在客戶端逐漸延長並隨機擾動連續重試之間的等待時間(指數退避,exponential backoff 9 10)。還可以採用 熔斷器(circuit breaker)11 12 或 令牌桶(token bucket)演算法 13,暫時停止向最近曾返回錯誤或發生超時的服務傳送請求。伺服器也可以在察覺自己接近過載時主動拒絕請求(負載卸除,load shedding 14),並在響應中要求客戶端降低傳送速率(背壓,backpressure 1 15)。排隊演算法和負載均衡演算法的選擇同樣會產生影響 16。
在各項效能指標中,使用者通常最關心響應時間;吞吐量則決定了所需的計算資源(例如伺服器數量),從而決定處理特定工作負載的成本。如果吞吐量可能增長到超出當前硬體的處理能力,就需要擴充容量。如果增加計算資源能夠顯著提高系統的最大吞吐量,我們就稱這個系統具有 可伸縮性(scalability)。
本節主要關注響應時間;我們會在“可伸縮性”一節回頭討論吞吐量與可伸縮性。
延遲與響應時間
“延遲”和“響應時間”有時會被混為一談,但本書將按下面的特定含義使用這幾個術語(如圖 2-4所示):
- 響應時間(response time)是客戶端看到的時間,其中包括系統各處產生的全部延誤。
- 服務時間(service time)是服務真正用於處理使用者請求的時間。
- 排隊延遲(queueing delay)可能發生在流程中的多個位置。例如,請求到達後,也許必須等到 CPU 空閒才能開始處理;如果同一臺機器上的其他任務正在透過出站網路介面傳送大量資料,響應資料包也可能先在緩衝區中等待。
- 延遲(latency)泛指請求沒有得到實際處理的時間,也就是請求處於 潛伏(latent) 狀態的時間。具體來說,網路延遲(network latency)或 網路時延(network delay)是請求和響應在網路中傳輸所花的時間。

在圖 2-4中,時間從左向右流逝;參與通訊的每個節點用一條水平線表示,請求或響應訊息則畫成從一個節點指向另一個節點的粗斜箭頭。本書後面還會經常遇到這種圖示方式。
即使反覆傳送同一個請求,每次的響應時間也可能相差很大。許多因素都會帶來隨機的額外延遲,例如:上下文切換到後臺程序、網路丟包和 TCP 重傳、垃圾回收暫停、缺頁迫使系統從磁碟讀取資料、伺服器機架的機械振動 17,等等。我們會在“超時和無界延遲”中進一步討論這個問題。
響應時間的波動很大一部分往往來自排隊延遲。伺服器同時能處理的任務數量有限(例如受 CPU 核數限制),因此只需少數幾個慢請求,就足以阻礙後續請求,這種現象稱為 隊頭阻塞(head-of-line blocking)。即使後續請求本身的服務時間很短,客戶端看到的總體響應時間仍會很長,因為它們要等待先前的請求完成。排隊延遲不屬於服務時間,所以在客戶端測量響應時間十分重要。
平均值、中位數與分位數
由於每次請求的響應時間都不一樣,我們不能只把它看成一個數字,而應將其視為一組可測量數值的 分佈(distribution)。在圖 2-5中,每根灰色柱條代表一次服務請求,柱條的高度表示這次請求所花的時間。大多數請求都相當快,但偶爾會出現耗時長得多的 異常值(outlier)。網路延遲的變化也稱為 抖動(jitter)。

服務通常會報告 平均(mean)響應時間,也就是 算術平均值(arithmetic mean):把所有響應時間相加,再除以請求數。平均響應時間有助於估算吞吐量的上限 18。不過,如果你想知道“典型”的響應時間,平均值就不是很好的指標,因為它沒有告訴你究竟有多少使用者實際經歷了這樣的等待。
通常,採用 分位數(percentile) 更合適。將響應時間從快到慢排列,中位數(median)就是位於正中間的值。例如,如果響應時間的中位數是 200 毫秒,就意味著一半請求用時不到 200 毫秒,另一半則需要更長時間。因此,如果想知道使用者通常要等多久,中位數是個很好的指標。中位數也稱為 第 50 分位數,有時縮寫為 p50。
為了弄清異常值究竟有多糟,可以觀察更高的分位數。常用的有第 95、99 和 99.9 分位數,分別縮寫為 p95、p99 和 p999。它們對應這樣一個響應時間閾值:分別有 95%、99% 或 99.9% 的請求快於這個閾值。例如,如果第 95 分位數的響應時間是 1.5 秒,就意味著每 100 個請求中,有 95 個用時不到 1.5 秒,另外 5 個則需要 1.5 秒或更久。圖 2-5對此作了說明。
響應時間的高分位數也稱為 尾延遲(tail latencies),它們十分重要,因為會直接影響使用者對服務的體驗。例如,亞馬遜用第 99.9 分位數來描述內部服務的響應時間要求,儘管它只影響每 1,000 個請求中的一個。這是因為,響應最慢的客戶往往是在賬戶中積累了最多資料的人——他們購買過很多商品,也就是最有價值的客戶 19。確保網站對這些客戶同樣快速,是維持其滿意度的重要手段。
另一方面,亞馬遜認為,針對第 99.99 分位數(每 10,000 個請求中最慢的一個)進行最佳化,成本過高,收益又不足。降低極高分位數處的響應時間非常困難,因為它很容易受到不可控隨機事件的影響,而且越往後收益越小。
從直覺上說,速度快的服務當然比慢的服務更受使用者歡迎 20。然而,要獲得可靠資料,量化延遲對使用者行為的影響,卻出人意料地困難。
一些經常被引用的資料並不可靠。2006 年,Google 報告稱,搜尋結果的響應時間從 400 毫秒增加到 900 毫秒,與流量和收入下降 20% 存在相關性 21。然而,Google 在 2009 年的另一項研究中又報告說,延遲增加 400 毫秒,只使每天的搜尋次數減少了 0.6% 22;同年,Bing 發現載入時間增加 2 秒會使廣告收入減少 4.3% 23。這些公司似乎沒有公開過更新的資料。
Akamai 一項較新的研究 24 聲稱,響應時間增加 100 毫秒,會使電子商務網站的轉化率最多下降 7%。然而仔細檢視就會發現,同一項研究還顯示,載入速度 非常快 的頁面也與較低的轉化率相關!這個看似矛盾的結果可以這樣解釋:載入最快的往往是沒有有用內容的頁面,例如 404 錯誤頁面。該研究沒有嘗試把頁面內容的影響與載入時間的影響區分開來,因此其結果恐怕沒有什麼意義。
Yahoo 的一項研究 25 在控制搜尋結果質量的前提下,比較了載入較快和較慢的搜尋結果的點選率。研究發現,當快、慢響應相差 1.25 秒或更久時,快速搜尋獲得的點選量會多出 20%~30%。
響應時間指標的應用
如果後端服務要為一次終端使用者請求執行多次呼叫,那麼高分位數尤其重要。即使這些呼叫並行發出,終端使用者請求仍要等待其中最慢的一次完成。正如圖 2-6所示,只需一個慢呼叫,就足以拖慢整個終端使用者請求。即使後端呼叫中只有很小一部分速度較慢,一次終端使用者請求所需的後端呼叫越多,其中出現慢呼叫的機率也就越大,最終會有更高比例的使用者請求變慢。這種效應稱為 尾部延遲放大(tail latency amplification) 26。

分位數經常出現在 服務級別目標(SLO) 和 服務級別協議(SLA) 中,用來規定服務應達到的效能和可用性 27。例如,一項 SLO 可能要求服務的中位響應時間低於 200 毫秒、第 99 分位數的響應時間低於 1 秒,並要求至少 99.9% 的有效請求得到非錯誤響應。SLA 則是一份合同,規定未達到 SLO 時會怎樣處理(例如,客戶可能有權獲得退款)。這至少是其基本思路;在實踐中,要為 SLO 和 SLA 定義良好的可用性指標並不簡單 28 29。
如果想在服務的監控儀表板上加入響應時間分位數,就需要持續、高效地計算這些指標。例如,可以維護一個滾動視窗,記錄最近 10 分鐘內所有請求的響應時間;每隔一分鐘,計算視窗中各個數值的中位數和其他分位數,並把這些指標繪製在圖表上。
最簡單的實現是儲存時間視窗內所有請求的響應時間列表,並每分鐘對它排序一次。如果這樣做效率太低,也有一些演算法能夠以極低的 CPU 和記憶體開銷,算出相當準確的分位數近似值。用於估算分位數的開源庫包括 HdrHistogram、t-digest 30 31、OpenHistogram 32 和 DDSketch 33。
注意,對分位數取平均值——例如為了降低時間解析度,或合併多臺機器的資料——在數學上沒有意義。聚合響應時間資料的正確方法是把直方圖相加 34。
可靠性與容錯
每個人對於一個東西是否可靠,都有直觀的判斷。人們對可靠軟體的典型期望包括:
- 應用程式表現出使用者所期望的功能。
- 允許使用者犯錯,或以出乎意料的方式使用軟體。
- 在預期的負載和資料量下,效能足以滿足所需的使用場景。
- 系統能防止未經授權的訪問和濫用。
如果把這些合在一起稱為“正確工作”,那麼 可靠性(reliability) 可以粗略理解為“即使出了問題,也能繼續正確工作”。為了更準確地描述所謂“出了問題”,我們要區分 故障(fault) 和 失效(failure) 35 36 37:
- 故障
- 系統的某個 部分 停止正常工作。例如,單塊硬碟發生故障、單臺機器崩潰,或者系統所依賴的外部服務中斷。
- 失效
- 整個系統 停止向使用者提供所需的服務;換言之,系統沒有達到服務級別目標(SLO)。
故障與失效之所以容易混淆,是因為二者其實是同一件事,只是觀察的層次不同。例如,如果一塊硬碟停止工作,我們會說這塊硬碟失效了:若整個系統只有這一塊硬碟,系統也就停止了提供所需服務。然而,如果我們談論的是一個包含許多硬碟的系統,那麼單塊硬碟失效從整個系統的角度看只是一項故障;只要資料在另一塊硬碟上還有副本,整個系統就可能容忍這項故障。
容錯
如果某些故障發生時,系統仍能繼續向使用者提供所需服務,我們就稱它是 容錯的(fault-tolerant)。如果系統無法容忍某個部分出現故障,這個部分就稱為 單點故障(SPOF),因為它一旦發生故障,就會升級為整個系統的失效。
以社交網路案例為例,扇出過程中可能發生這樣一種故障:負責更新物化時間線的某臺機器崩潰或變得不可用。要讓這個過程具備容錯能力,就必須確保另一臺機器能接手這項任務,既不漏掉任何本應投遞的帖子,也不會重複投遞。(這個思想稱為 恰好一次語義(exactly-once semantics),我們會在“資料庫的端到端原則”中詳細討論。)
容錯能力總是針對特定型別、特定數量的故障而言。例如,一個系統也許最多能容忍兩塊硬碟同時失效,或三個節點中有一個崩潰。要求系統容忍任意數量的故障沒有意義:如果所有節點都崩潰了,任何辦法都無濟於事。如果整個地球(以及上面的所有伺服器)都被黑洞吞噬,要容忍這項故障就得把網站託管到太空中——祝你好運,看看這筆預算能不能獲批。
反直覺的是,在這類容錯系統中,透過故意觸發故障來 提高 故障率有時反而是合理的,例如毫無預警地隨機殺死某個程序。這稱為 故障注入(fault injection)。許多嚴重缺陷其實源於糟糕的錯誤處理 38;故意製造故障,可以讓容錯機制不斷得到演練和檢驗,從而增強我們的信心:當故障自然發生時,系統確實能夠正確處理。混沌工程(chaos engineering) 就是一門透過故障注入等實驗來增強人們對容錯機制信心的學科 39。
雖然比起預防故障,我們通常更傾向於容忍故障,但有時預防確實勝於治療(例如根本無藥可救時)。安全問題就是如此:如果攻擊者已經攻破系統並獲得敏感資料,這件事無法撤銷。不過,本書主要討論的是能夠補救的故障型別,下面幾節會作進一步說明。
硬體與軟體故障
說到系統失效的原因,人們很容易首先想到硬體故障:
- 每年大約有 2%~5% 的機械硬碟發生故障 40 41;因此,在擁有 10,000 塊硬碟的儲存叢集中,平均每天應該會有一塊硬碟失效。近期資料表明硬碟越來越可靠,但故障率仍不容忽視 42。
- 每年大約有 0.5%~1% 的固態硬碟(SSD)發生故障 43。少量位元錯誤會自動得到糾正 44,但每塊硬碟大約每年仍會發生一次無法糾正的錯誤,即使硬碟相當新(也就是磨損很少)也不例外;這個錯誤率高於機械硬碟 45 46。
- 電源、RAID 控制器和記憶體模組等其他硬體元件也會發生故障,只是沒有硬碟那麼頻繁 47 48。
- 大約每 1,000 臺機器中,就有一臺的某個 CPU 核心偶爾會算出錯誤結果,原因很可能是製造缺陷 49 50 51。錯誤計算有時會導致崩潰,有時卻只是讓程式返回錯誤的結果。
- RAM 中的資料也可能損壞,原因既可能是宇宙射線等隨機事件,也可能是永久性的物理缺陷。即使採用糾錯碼(ECC)記憶體,一年內仍會有超過 1% 的機器遇到無法糾正的錯誤,通常會導致機器崩潰,並且需要更換受影響的記憶體模組 52。此外,某些病態的記憶體訪問模式很可能導致位元翻轉 53。
- 整個資料中心可能變得不可用(例如停電或網路配置錯誤),甚至遭到永久摧毀(例如火災、洪水或地震 54)。太陽噴發大量帶電粒子所形成的太陽風暴,會在長距離導線中感應出很強的電流,可能破壞電網和海底網路電纜 55。這類大規模失效雖然少見,但如果服務不能容忍整個資料中心的丟失,後果可能是災難性的 56。
這些事件相當少見,因此在小型系統中,只要故障硬體很容易更換,通常不必為之過分擔心。然而,在大規模系統裡,硬體故障發生得足夠頻繁,已經成了系統正常執行的一部分。
透過冗餘容忍硬體故障
面對不可靠的硬體,我們通常首先想到為各個硬體元件增加冗餘,以降低整個系統的失效率。磁碟可以組成 RAID(把資料分散到同一臺機器的多塊磁碟上,使單塊磁碟失效不至於造成資料丟失);伺服器可以配備雙路電源和可熱插拔的 CPU;資料中心可以用電池和柴油發電機提供備用電源。這些冗餘措施往往能讓一臺機器連續執行多年而不中斷。
當各個元件的故障彼此獨立時,冗餘最有效;所謂獨立,就是一項故障的發生不會改變另一項故障發生的機率。然而,實踐表明,元件失效之間往往存在顯著的相關性 41 57 58;整個伺服器機架乃至整個資料中心不可用的情況,仍然比我們希望的更加常見。
硬體冗餘可以提高單臺機器的正常執行時間。不過,正如“分散式與單節點系統”中所述,採用分散式系統還有其他好處,例如能夠容忍整個資料中心中斷。因此,雲系統往往不那麼強調單臺機器的可靠性,而是力求在軟體層面容忍節點故障,讓服務實現高可用。雲提供商用 可用區(availability zone) 來標明哪些資源在物理上位於同一處;與地理位置分散的資源相比,同一地點的資源更可能同時失效。
本書討論的容錯技術,旨在容忍整臺機器、整個機架或整個可用區的丟失。它們通常允許一個資料中心內的機器,在另一個資料中心內的機器發生故障或變得不可達時接替其工作。我們會在第 6 章、第 10 章以及本書其他多處討論這類容錯技術。
能夠容忍整臺機器丟失的系統,在運維上也有優勢。如果需要重啟機器(例如安裝作業系統安全補丁),單伺服器系統必須安排停機;而多節點容錯系統可以逐個重啟節點來安裝補丁,不影響向使用者提供服務。這稱為 滾動升級(rolling upgrade),我們會在第 5 章進一步討論。
軟體故障
儘管硬體失效之間可能存在較弱的相關性,但大體上仍然相互獨立。例如,一塊硬碟失效以後,同一臺機器上的其他硬碟很可能還能繼續正常工作一段時間。相比之下,軟體故障往往高度相關,因為許多節點通常執行同一套軟體,也就帶有同樣的缺陷 59 60。這類故障更難預見,而且比互不相關的硬體故障更容易造成系統失效 47。例如:
- 一個軟體缺陷在特定情況下導致所有節點同時失效。例如,2012 年 6 月 30 日的一次閏秒觸發了 Linux 核心中的缺陷,使許多 Java 應用程式同時掛起,大量網際網路服務隨之中斷 61。另一個例子是,由於韌體缺陷,某些型號的 SSD 會在恰好執行 32,768 小時(不到 4 年)後突然全部失效,盤上的資料再也無法恢復 62。
- 某個失控程序耗盡 CPU 時間、記憶體、磁碟空間、網路頻寬或執行緒等共享而有限的資源 63。例如,程序在處理大型請求時消耗了過多記憶體,可能被作業系統殺死;客戶端庫中的缺陷也可能產生遠高於預期的請求量 64。
- 系統所依賴的某項服務變慢、失去響應,或開始返回內容損壞的響應。
- 不同系統之間的互動產生湧現行為,而每個系統單獨測試時都不會出現這種行為 65。
- 發生級聯失效:一個元件的問題導致另一個元件過載並變慢,後者繼而又拖垮下一個元件 66 67。
引發這類軟體故障的缺陷往往會潛伏很久,直到一組不同尋常的條件將其觸發。這時人們才發現,軟體原來對執行環境作出了某種假設;這個假設通常都成立,卻最終會出於某種原因不再成立 68 69。
軟體中的系統性故障沒有速效藥,但許多小措施都能有所幫助:認真思考系統中的假設和互動,開展徹底的測試,隔離程序,允許程序崩潰並重啟,避免重試風暴之類的反饋環路(參見“當過載系統無法恢復時”),並在生產環境中度量、監控和分析系統行為。
人類與可靠性
軟體系統由人設計和構建,維持系統執行的運維人員同樣也是人。與機器不同,人類不只是照章行事;他們的長處正是能夠發揮創造力、隨機應變,把工作完成。不過,這一特點也會帶來不可預測性:即使出發點再好,人也會犯錯,有時還會導致系統失效。例如,一項針對大型網際網路服務的研究發現,運維人員修改配置是服務中斷的首要原因,而硬體故障(伺服器或網路)只在 10%~25% 的中斷中起了作用 70。
人們很容易把這類問題歸結為“人為錯誤”,並幻想透過更嚴格的流程和更嚴密的規則來約束人的行為,從而解決問題。然而,把錯誤歸咎於個人往往適得其反。所謂“人為錯誤”其實並不是事故的根本原因,而是人與技術共同構成的 社會技術系統(sociotechnical system)出了問題的一種症狀;身處其中的人只是在竭盡所能地完成工作 71。複雜系統也常常表現出湧現行為,元件之間出人意料的互動同樣可能導致失效 72。
多種技術手段都能減小人為失誤的影響,包括:徹底測試(既包括手寫測試,也包括用大量隨機輸入進行的 屬性測試,property-based testing)38;提供回滾機制,以便迅速撤銷配置變更;逐步釋出新程式碼;提供詳細而清晰的監控,以及用於診斷生產問題的可觀測性工具(參見“分散式系統的問題”);精心設計介面,使“做正確的事”更加容易,“做錯誤的事”更加困難。
不過,這些措施都要投入時間和金錢。在日常經營的現實壓力下,組織往往優先考慮能夠創造收入的工作,而不是提高自身抵禦失誤能力的措施。如果必須在開發更多功能和開展更多測試之間選擇,許多組織選擇功能也不難理解。既然作出了這樣的選擇,當本可避免的錯誤不可避免地發生時,再去責怪犯錯的人便毫無道理——真正的問題在於組織如何設定優先順序。
越來越多的組織開始形成 無責覆盤(blameless postmortem) 的文化:事故發生後,鼓勵所有參與者毫無保留地講清事情經過,不必擔心受到懲罰;這樣,組織中的其他人才能從中學習,避免今後再發生類似問題 73。覆盤過程也許會發現,業務優先順序需要調整,長期遭到忽視的領域需要投入,相關人員的激勵機制需要改變,或者還有其他系統性問題需要提請管理層關注。
一般而言,調查事故時應當警惕過分簡單的答案。“鮑勃部署這項變更時應該更加小心”無助於解決問題,“我們必須用 Haskell 重寫後端”同樣如此。管理層應當抓住機會,從每天使用這個社會技術系統的一線人員那裡瞭解它究竟如何運作,再根據這些反饋採取措施加以改進 71。
可靠性並不只對核電站和空中交通管制系統重要;人們同樣期望更平常的應用能夠可靠工作。商務應用程式中的缺陷會降低生產率(如果報告的數字有誤,還會帶來法律風險),電子商務網站中斷則可能造成鉅額收入損失,並損害企業聲譽。
對許多應用來說,暫時中斷幾分鐘乃至幾小時尚可容忍 74,但永久丟失或損壞資料卻會是一場災難。設想一位家長把孩子所有的照片和影片都儲存在你的照片應用中 75。如果資料庫突然損壞,他們會有什麼感受?他們知道怎樣從備份恢復嗎?
英國郵局的 Horizon 醜聞,是不可靠的軟體傷害人的另一個例子。1999 至 2019 年間,數百名經營英國郵局網點的人被判盜竊或欺詐罪,只因會計軟體顯示他們的賬目存在短缺。最終人們發現,其中許多短缺其實源於軟體缺陷,此後已有許多判決被撤銷 76。這場或許是英國歷史上最大的司法不公之所以發生,是因為英格蘭法律假定計算機能夠正確執行,因此也假定計算機產生的證據可靠,除非有人能提出反證 77。軟體工程師也許會覺得“軟體沒有任何缺陷”的想法十分可笑,但那些因為不可靠的計算機系統而被錯誤定罪、遭到監禁、宣告破產,甚至自殺的人,卻無法從中得到絲毫安慰。
在有些情況下,我們可能會為了降低開發成本而犧牲可靠性(例如為尚未驗證的市場開發產品原型)。但我們必須清楚地意識到自己何時正在走捷徑,並始終牢記可能造成的後果。
可伸縮性
系統今天能可靠執行,並不意味著將來也一定能夠可靠執行。系統退化的一個常見原因是負載增加:也許併發使用者從 10,000 人增長到了 100,000 人,或者從 100 萬人增長到了 1,000 萬人;也許系統現在處理的資料量比過去大得多。
可伸縮性(scalability) 是描述系統應對負載增長能力的術語。討論可伸縮性時,人們有時會說:“你又不是 Google 或 Amazon。別再擔心規模問題了,用關聯式資料庫就好。”這句話是否適用於你,要看你構建的究竟是哪一類應用。
如果你正在構建一個目前使用者不多的新產品,例如初創公司的新業務,壓倒一切的工程目標通常是讓系統儘可能簡單、靈活;這樣,隨著你逐漸瞭解客戶的需求,就能輕鬆修改和調整產品功能 78。在這種環境中,為將來或許才需要的假想規模憂心忡忡,只會適得其反:往好裡說,對可伸縮性的投入是白費力氣和過早最佳化;往壞裡說,它會把你困在一個不靈活的設計中,讓應用更難演化。
這是因為,可伸縮性並不是一個一維的標籤。簡單地說“X 是可伸縮的”或“Y 無法伸縮”毫無意義。討論可伸縮性,真正要考慮的是下面這些問題:
*“如果系統按某種方式增長,我們有哪些應對選項?” *“我們如何增加計算資源來承載額外負載?” *“按當前的增長預期,什麼時候會達到現有架構的極限?”
如果應用大受歡迎,因而需要處理不斷增長的負載,你會逐漸知道效能瓶頸在哪裡,也就清楚系統需要沿著哪些維度擴充套件。到那時,再開始認真考慮可伸縮性技術也不遲。
描述負載
首先,我們需要簡明地描述系統當前的負載;只有這樣,才能繼續討論增長問題(例如負載翻倍會發生什麼)。這種描述通常是某項吞吐量指標,例如服務每秒收到的請求數、每天新增多少 GB 資料,或每小時完成的購物車結賬次數。有時,我們關心的是某個變數的峰值,例如“案例研究:社交網路首頁時間線”中的同時線上使用者數。
負載往往還有其他統計特徵,它們同樣會影響訪問模式,進而影響系統對可伸縮性的要求。例如,你可能需要知道資料庫的讀寫比例、快取命中率,或每位使用者擁有的資料項數量(例如社交網路案例中的關注者人數)。有時平均情況最重要,有時瓶頸卻由少數極端情況主導。一切都取決於具體應用的細節。
描述好系統負載以後,就可以研究負載增加時會發生什麼。我們可以從兩個角度來看:
- 以某種方式增加負載,而系統資源(CPU、記憶體、網路頻寬等)保持不變,系統效能會受到什麼影響?
- 以某種方式增加負載,而你希望效能保持不變,需要增加多少資源?
通常,我們的目標是在滿足 SLA 效能要求(參見“響應時間指標的應用”)的同時,儘可能降低系統的執行成本。所需的計算資源越多,成本就越高。某些硬體也許比另一些更具價效比,而隨著新型硬體出現,這些因素也會隨時間變化。
如果資源增加一倍,就能在效能不變的情況下處理兩倍負載,我們稱系統具備 線性可伸縮性(linear scalability),這通常是一件好事。偶爾,由於規模經濟或峰值負載分佈得更加均勻,不到兩倍的資源也能處理兩倍的負載 79 80。更常見的情況是,成本增長得比線性更快,造成這種低效的原因可能有很多。例如,系統擁有大量資料時,即使寫入請求本身大小相同,處理一次寫入所需的工作也可能多於資料量較小時。
共享記憶體、共享磁碟與無共享架構
增加服務硬體資源最簡單的辦法,就是把服務遷移到更強大的機器上。單個 CPU 核心的速度已經不再顯著提高,但你仍可以買到(或在雲上租用)配有更多 CPU 核心、更大 RAM 和更多磁碟空間的機器。這種方法稱為 縱向擴充套件(vertical scaling),也叫 向上擴充套件(scaling up)。
在單臺機器上執行多個程序或執行緒,可以獲得並行處理能力。同一程序中的所有執行緒都能訪問同一塊 RAM,因此這種方法也稱為 共享記憶體架構(shared-memory architecture)。共享記憶體方案的問題在於,成本增長得比線性更快:硬體資源多一倍的高階機器,價格通常遠遠不止兩倍;受各種瓶頸限制,一臺規模翻倍的機器又往往處理不了兩倍的負載。
另一種方案是 共享磁碟架構(shared-disk architecture):多臺機器分別擁有獨立的 CPU 和 RAM,卻把資料儲存在共同訪問的一組磁碟陣列中,機器與磁碟透過高速網路連線,例如 網路附加儲存(NAS) 或 儲存區域網路(SAN)。這種架構過去常用於本地部署的資料倉儲工作負載,但資源爭用和加鎖開銷限制了共享磁碟方案的可伸縮性 81。
相比之下,無共享架構(shared-nothing architecture) 82(也稱為 水平擴充套件(horizontal scaling) 或 向外擴充套件(scaling out))已經廣受歡迎。這種方案採用包含多個節點的分散式系統,每個節點都擁有自己的 CPU、RAM 和磁碟;節點之間的一切協調,都透過普通網路在軟體層完成。
無共享架構的優勢是:它有望實現線性伸縮;可以使用任何價效比最好的硬體,在雲端尤其如此;負載增減時更容易調整硬體資源;還可以把系統分佈到多個資料中心和地域,以獲得更強的容錯能力。缺點則是必須顯式進行分片(參見第 7 章),並且要面對分散式系統的全部複雜性(參見第 9 章)。
一些雲原生資料庫系統把儲存和事務執行拆分成不同的服務(參見“儲存與計算的分離”),讓多個計算節點共享同一項儲存服務。這個模型與共享磁碟架構有幾分相似,但避開了老式系統的可伸縮性問題:儲存服務提供的不是檔案系統(NAS)或塊裝置(SAN)抽象,而是一套針對資料庫具體需求設計的專用 API 83。
可伸縮性原則
大規模系統的架構通常高度依賴具體應用,不存在一套通用、放之四海而皆準的可伸縮架構(俗稱 萬金油(magic scaling sauce))。例如,處理每秒 100,000 個請求、每個請求 1 kB 的系統,與每分鐘只處理 3 個請求、每個請求卻有 2 GB 的系統,看起來會截然不同——儘管二者的資料吞吐量同為 100 MB/s。
而且,適合某個負載水平的架構,多半應付不了十倍於此的負載。如果你正在開發一個快速增長的服務,很可能每當負載增加一個數量級,就需要重新考慮架構。應用需求本身也很可能不斷變化,因此提前為超過一個數量級之後的伸縮需求作規劃,通常並不值得。
可伸縮性有一項很好的通用原則:把系統拆分成較小的元件,使它們大體上能夠彼此獨立地執行。這是微服務(參見“微服務與無伺服器”)、分片(第 7 章)、流處理(第 12 章)和無共享架構背後的共同原則。不過,真正的挑戰在於判斷哪些東西應該放在一起,哪些東西應該拆開。其他書籍介紹了微服務的設計準則 84;本書則會在第 7 章討論無共享系統中的分片。
另一項好原則是,不要讓系統變得比必要的更加複雜。如果單機資料庫足以完成任務,它很可能比複雜的分散式配置更可取。自動伸縮系統會根據需求自動增加或移除資源,的確很酷;但如果負載相當可預測,手動伸縮的系統在運維中也許更少出現意外(參見“運維:自動/手動再平衡”)。由 5 個服務組成的系統比由 50 個服務組成的系統更簡單。優秀的架構通常務實地混合了多種方案。
可維護性
軟體不會磨損,也不會像機械裝置那樣發生材料疲勞,因此它不會以同樣的方式損壞。不過,應用程式的需求經常變化,軟體執行的環境也會變化(例如依賴項和底層平臺),而且軟體中總有缺陷需要修復。
眾所周知,軟體的大部分成本不在最初的開發階段,而在持續的維護階段,包括修復缺陷、保持系統正常執行、調查失效、適配新的平臺、為新的使用場景進行修改、償還技術債和新增新功能 85 86。
然而,維護工作本身也很困難。一個成功執行多年的系統,很可能仍在使用如今已經沒有多少工程師瞭解的過時技術,例如大型機和 COBOL 程式碼;隨著人員離開組織,關於系統為何如此設計、怎樣設計的組織知識可能已經丟失;維護者也許不得不修正前人留下的錯誤。而且,計算機系統往往與它所支撐的組織緊密交織在一起,這意味著維護這樣的 遺留(legacy) 系統既是人的問題,也是技術問題 87。
我們今天構建的每個系統,只要足夠有價值、能夠長期存續,終有一天都會成為遺留系統。為了儘量減輕以後維護軟體的人所承受的痛苦,我們設計軟體時就應該考慮維護問題。雖然無法事先斷定哪些決定會在未來製造維護難題,但本書會特別關注幾項具有廣泛適用性的原則:
- 可運維性(operability)
- 便於組織保持系統平穩執行。
- 簡單性(simplicity)
- 採用人們熟知且前後一致的模式和結構,避免不必要的複雜度,使新工程師也能輕鬆理解系統。
- 可演化性(evolvability)
- 便於工程師將來修改系統,在需求變化時調整和擴充套件系統,以適應事先沒有預料到的使用場景。
可運維性:讓運維更輕鬆
我們已經在“雲時代的運維”中討論過運維的作用,並且看到,要實現可靠運維,人的流程至少與軟體工具同等重要。事實上,有人認為:“良好的運維往往能繞開糟糕(或不完整)軟體的侷限,但即使軟體很好,糟糕的運維也無法讓它可靠執行” 60。
大規模系統由成千上萬臺機器組成,純靠人工維護,成本高得難以承受,因此自動化必不可少。然而,自動化是一把雙刃劍:總會有一些邊緣情況(例如罕見的故障場景)需要運維團隊人工干預。自動化無法處理的恰恰是最複雜的問題,所以自動化程度越高,反而越需要一支技能 更強 的運維團隊來解決這些問題 88。
而且,自動化系統一旦出錯,往往比依靠運維人員手工完成某些操作的系統更難排查。因此,對可運維性而言,自動化並非總是越多越好。一定程度的自動化依然很重要,最佳平衡點則取決於具體應用和組織的情況。
良好的可運維性意味著讓日常工作更加輕鬆,使運維團隊能把精力集中在高價值的任務上。資料系統可以透過多種方式簡化日常工作 89:
- 允許監控工具檢查系統的關鍵指標,並支援可觀測性工具(參見“分散式系統的問題”),以便深入瞭解系統執行時的行為。許多商業工具和開源工具都能在這方面提供幫助 90。
- 避免依賴任何一臺機器,使機器可以下線維護,而整個系統仍能不間斷地執行。
- 提供良好的文件和易於理解的操作模型(“如果我做 X,就會發生 Y”)。
- 提供良好的預設行為,同時允許管理員在必要時覆蓋預設設定。
- 在適當的時候自動修復,同時也允許管理員在必要時手動控制系統狀態。
- 表現出可預測的行為,儘量避免出人意料。
簡單性:管理複雜度
小型軟體專案可以擁有簡單討喜、富有表現力的程式碼;但隨著專案不斷擴大,程式碼往往變得非常複雜,難以理解。這種複雜度拖慢了每一個需要在系統上工作的人,進一步增加了維護成本。一個陷入複雜泥潭的軟體專案有時被稱為 大泥球(big ball of mud) 91。
當複雜度使維護變得困難時,預算和進度安排往往都會超支。修改複雜軟體也更容易引入缺陷:系統越難理解和推理,開發人員就越容易忽略隱藏的假設、無意的後果和意外的互動 69。反過來,降低複雜度可以極大提高軟體的可維護性,因此簡單性應該成為我們構建系統時的一項關鍵目標。
簡單的系統更容易理解,因此我們應該儘可能用最簡單的辦法解決給定的問題。可惜,說起來容易,做起來卻很難。事物是否簡單往往取決於主觀品味,並不存在衡量簡單性的客觀標準 92。例如,一個系統可能把複雜實現隱藏在簡單介面背後,另一個系統的實現本身很簡單,卻向使用者暴露了更多內部細節——究竟哪一個更簡單?
人們曾嘗試把複雜度分成 本質複雜度(essential complexity) 和 偶然複雜度(accidental complexity) 兩類,以此對複雜度進行推理 93。按照這種思路,本質複雜度是應用程式問題領域所固有的,而偶然複雜度只因工具的侷限而產生。不幸的是,這種區分也有缺陷,因為隨著工具不斷演進,本質複雜度與偶然複雜度之間的界限也會發生變化 94。
管理複雜度最好的工具之一是 抽象(abstraction)。一個好的抽象可以把大量實現細節隱藏在乾淨、簡單易懂的外觀之下,也可以廣泛用於各種不同的應用。複用抽象不僅比一遍遍重新實現類似功能更加高效,也能帶來更高質量的軟體,因為抽象元件的質量得到改進,所有使用它的應用都會從中受益。
例如,高階程式語言是一種抽象,隱藏了機器碼、CPU 暫存器和系統呼叫。SQL 也是一種抽象,隱藏了複雜的磁碟和記憶體資料結構、其他客戶端發出的併發請求,以及崩潰後產生的不一致。當然,使用高階語言程式設計時,我們仍然用到了機器碼;只不過沒有 直接 使用它,因為程式語言的抽象讓我們不必考慮這些細節。
為了降低應用程式程式碼的複雜度,可以藉助 設計模式(design pattern)95 和 領域驅動設計(DDD) 96 等方法來構建抽象。本書討論的不是這類應用專用的抽象,而是資料庫事務、索引和事件日誌等通用抽象;你可以在它們之上構建應用。如果你想採用 DDD 等方法,也可以把它們實現於本書所述的基礎之上。
可演化性:讓變化更容易
系統的需求永遠不變,基本是不可能的。更可能的情況是,需求總在變化:你瞭解了新的事實,出現了事先未曾預料的使用場景,業務優先順序發生變化,使用者要求新功能,新平臺取代舊平臺,法律或監管要求改變,系統增長迫使架構發生變化,等等。
在組織流程方面,敏捷(Agile) 工作模式為適應變化提供了框架。敏捷社群還發展出了適合在頻繁變化的環境中開發軟體的技術工具和流程,例如測試驅動開發(TDD)和重構。本書則會尋找一些辦法,在由多個特性各異的應用程式或服務組成的系統層面上提高敏捷性。
修改資料系統、使其適應不斷變化的需求有多容易,與系統的簡單性和抽象密切相關:松耦合、簡單的系統通常比緊耦合、複雜的系統更容易修改。這個概念如此重要,因此我們用一個不同的詞來指代資料系統層面的敏捷性:可演化性(evolvability) 97。
大型系統中的某些操作不可逆,因此必須極為謹慎地執行;這是讓變更變得困難的一個主要因素 98。例如,假設你要從一個資料庫遷移到另一個資料庫:如果新系統出了問題卻無法切回舊系統,風險就遠高於能夠輕鬆回退的情況。儘量減少不可逆性,可以提高系統的靈活性。
總結
本章考察了幾種非功能性需求:效能、可靠性、可伸縮性和可維護性。在討論這些主題的過程中,我們還遇到了貫穿全書都要用到的一些原則和術語。本章從社交網路首頁時間線的實現案例入手,說明了系統規模增大時會出現的部分挑戰。
我們討論了如何衡量效能(例如採用響應時間分位數)、如何衡量系統負載(例如採用吞吐量指標),以及怎樣在 SLA 中使用這些指標。可伸縮性與此密切相關:負載增加時,怎樣確保效能保持不變。我們看到了一些關於可伸縮性的通用原則,例如把一項任務拆分成彼此可以獨立執行的較小部分;後續章節還會深入探討實現可伸縮性的技術細節。
為了實現可靠性,可以採用容錯技術,使系統即使有某個元件(例如硬碟、機器或另一項服務)發生故障,仍能繼續提供服務。我們考察了可能發生的各種硬體故障,並把它們與軟體故障區分開來;軟體故障往往高度相關,因此更難處理。提高可靠性的另一方面,是增強系統抵禦人為失誤的能力;我們還看到,無責覆盤可以幫助組織從事故中學習。
最後,我們討論了可維護性的幾個方面,包括為運維團隊的工作提供支援、管理複雜度,以及讓應用程式的功能更容易隨時間演化。實現這些目標沒有簡單的答案,但採用人們熟知、能夠提供實用抽象的構件來搭建應用程式,確實會有所幫助。本書餘下部分將介紹一系列已經在實踐中證明頗有價值的構件。
參考文獻
Mike Cvet. How We Learned to Stop Worrying and Love Fan-In at Twitter. At QCon San Francisco, December 2016. ↩︎ ↩︎
Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012. Archived at perma.cc/V9G5-KLYK ↩︎
Twitter. Twitter’s Recommendation Algorithm. blog.twitter.com, March 2023. Archived at perma.cc/L5GT-229T ↩︎
Raffi Krikorian. New Tweets per second record, and how! blog.twitter.com, August 2013. Archived at perma.cc/6JZN-XJYN ↩︎
Jaz Volpert. When Imperfect Systems are Good, Actually: Bluesky’s Lossy Timelines. jazco.dev, February 2025. Archived at perma.cc/2PVE-L2MX ↩︎
Samuel Axon. 3% of Twitter’s Servers Dedicated to Justin Bieber. mashable.com, September 2010. Archived at perma.cc/F35N-CGVX ↩︎
Nathan Bronson, Abutalib Aghayev, Aleksey Charapko, and Timothy Zhu. Metastable Failures in Distributed Systems. At Workshop on Hot Topics in Operating Systems (HotOS), May 2021. doi:10.1145/3458336.3465286 ↩︎
Marc Brooker. Metastability and Distributed Systems. brooker.co.za, May 2021. Archived at perma.cc/7FGJ-7XRK ↩︎
Marc Brooker. Exponential Backoff And Jitter. aws.amazon.com, March 2015. Archived at perma.cc/R6MS-AZKH ↩︎
Marc Brooker. What is Backoff For? brooker.co.za, August 2022. Archived at perma.cc/PW9N-55Q5 ↩︎
Michael T. Nygard. Release It!, 2nd Edition. Pragmatic Bookshelf, January 2018. ISBN: 9781680502398 ↩︎
Frank Chen. Slowing Down to Speed Up – Circuit Breakers for Slack’s CI/CD. slack.engineering, August 2022. Archived at perma.cc/5FGS-ZPH3 ↩︎
Marc Brooker. Fixing retries with token buckets and circuit breakers. brooker.co.za, February 2022. Archived at perma.cc/MD6N-GW26 ↩︎
David Yanacek. Using load shedding to avoid overload. Amazon Builders’ Library, aws.amazon.com. Archived at perma.cc/9SAW-68MP ↩︎
Matthew Sackman. Pushing Back. wellquite.org, May 2016. Archived at perma.cc/3KCZ-RUFY ↩︎
Dmitry Kopytkov and Patrick Lee. Meet Bandaid, the Dropbox service proxy. dropbox.tech, March 2018. Archived at perma.cc/KUU6-YG4S ↩︎
Haryadi S. Gunawi, Riza O. Suminto, Russell Sears, Casey Golliher, Swaminathan Sundararaman, Xing Lin, Tim Emami, Weiguang Sheng, Nematollah Bidokhti, Caitie McCaffrey, Gary Grider, Parks M. Fields, Kevin Harms, Robert B. Ross, Andree Jacobson, Robert Ricci, Kirk Webb, Peter Alvaro, H. Birali Runesha, Mingzhe Hao, and Huaicheng Li. Fail-Slow at Scale: Evidence of Hardware Performance Faults in Large Production Systems. At 16th USENIX Conference on File and Storage Technologies, February 2018. ↩︎
Marc Brooker. Is the Mean Really Useless? brooker.co.za, December 2017. Archived at perma.cc/U5AE-CVEM ↩︎
Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, Gunavardhan Kakulapati, Avinash Lakshman, Alex Pilchin, Swaminathan Sivasubramanian, Peter Vosshall, and Werner Vogels. Dynamo: Amazon’s Highly Available Key-Value Store. At 21st ACM Symposium on Operating Systems Principles (SOSP), October 2007. doi:10.1145/1294261.1294281 ↩︎
Kathryn Whitenton. The Need for Speed, 23 Years Later. nngroup.com, May 2020. Archived at perma.cc/C4ER-LZYA ↩︎
Greg Linden. Marissa Mayer at Web 2.0. glinden.blogspot.com, November 2005. Archived at perma.cc/V7EA-3VXB ↩︎
Jake Brutlag. Speed Matters for Google Web Search. services.google.com, June 2009. Archived at perma.cc/BK7R-X7M2 ↩︎
Eric Schurman and Jake Brutlag. Performance Related Changes and their User Impact. Talk at Velocity 2009. ↩︎
Akamai Technologies, Inc. The State of Online Retail Performance. akamai.com, April 2017. Archived at perma.cc/UEK2-HYCS ↩︎
Xiao Bai, Ioannis Arapakis, B. Barla Cambazoglu, and Ana Freire. Understanding and Leveraging the Impact of Response Latency on User Behaviour in Web Search. ACM Transactions on Information Systems, volume 36, issue 2, article 21, April 2018. doi:10.1145/3106372 ↩︎
Jeffrey Dean and Luiz André Barroso. The Tail at Scale. Communications of the ACM, volume 56, issue 2, pages 74–80, February 2013. doi:10.1145/2408776.2408794 ↩︎
Alex Hidalgo. Implementing Service Level Objectives: A Practical Guide to SLIs, SLOs, and Error Budgets. O’Reilly Media, September 2020. ISBN: 1492076813 ↩︎
Jeffrey C. Mogul and John Wilkes. Nines are Not Enough: Meaningful Metrics for Clouds. At 17th Workshop on Hot Topics in Operating Systems (HotOS), May 2019. doi:10.1145/3317550.3321432 ↩︎
Tamás Hauer, Philipp Hoffmann, John Lunney, Dan Ardelean, and Amer Diwan. Meaningful Availability. At 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), February 2020. ↩︎
Ted Dunning. The t-digest: Efficient estimates of distributions. Software Impacts, volume 7, article 100049, February 2021. doi:10.1016/j.simpa.2020.100049 ↩︎
David Kohn. How percentile approximation works (and why it’s more useful than averages). timescale.com, September 2021. Archived at perma.cc/3PDP-NR8B ↩︎
Heinrich Hartmann and Theo Schlossnagle. Circllhist — A Log-Linear Histogram Data Structure for IT Infrastructure Monitoring. arxiv.org, January 2020. ↩︎
Charles Masson, Jee E. Rim, and Homin K. Lee. DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees. Proceedings of the VLDB Endowment, volume 12, issue 12, pages 2195–2205, August 2019. doi:10.14778/3352063.3352135 ↩︎
Baron Schwartz. Why Percentiles Don’t Work the Way You Think. solarwinds.com, November 2016. Archived at perma.cc/469T-6UGB ↩︎
Walter L. Heimerdinger and Charles B. Weinstock. A Conceptual Framework for System Fault Tolerance. Technical Report CMU/SEI-92-TR-033, Software Engineering Institute, Carnegie Mellon University, October 1992. Archived at perma.cc/GD2V-DMJW ↩︎
Felix C. Gärtner. Fundamentals of fault-tolerant distributed computing in asynchronous environments. ACM Computing Surveys, volume 31, issue 1, pages 1–26, March 1999. doi:10.1145/311531.311532 ↩︎
Algirdas Avižienis, Jean-Claude Laprie, Brian Randell, and Carl Landwehr. Basic Concepts and Taxonomy of Dependable and Secure Computing. IEEE Transactions on Dependable and Secure Computing, volume 1, issue 1, January 2004. doi:10.1109/TDSC.2004.2 ↩︎
Ding Yuan, Yu Luo, Xin Zhuang, Guilherme Renna Rodrigues, Xu Zhao, Yongle Zhang, Pranay U. Jain, and Michael Stumm. Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems. At 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2014. ↩︎ ↩︎
Casey Rosenthal and Nora Jones. Chaos Engineering. O’Reilly Media, April 2020. ISBN: 9781492043867 ↩︎
Eduardo Pinheiro, Wolf-Dietrich Weber, and Luiz Andre Barroso. Failure Trends in a Large Disk Drive Population. At 5th USENIX Conference on File and Storage Technologies (FAST), February 2007. ↩︎
Bianca Schroeder and Garth A. Gibson. Disk failures in the real world: What does an MTTF of 1,000,000 hours mean to you? At 5th USENIX Conference on File and Storage Technologies (FAST), February 2007. ↩︎ ↩︎
Andy Klein. Backblaze Drive Stats for Q2 2021. backblaze.com, August 2021. Archived at perma.cc/2943-UD5E ↩︎
Iyswarya Narayanan, Di Wang, Myeongjae Jeon, Bikash Sharma, Laura Caulfield, Anand Sivasubramaniam, Ben Cutler, Jie Liu, Badriddine Khessib, and Kushagra Vaid. SSD Failures in Datacenters: What? When? and Why? At 9th ACM International on Systems and Storage Conference (SYSTOR), June 2016. doi:10.1145/2928275.2928278 ↩︎
Alibaba Cloud Storage Team. Storage System Design Analysis: Factors Affecting NVMe SSD Performance (1). alibabacloud.com, January 2019. Archived at archive.org ↩︎
Bianca Schroeder, Raghav Lagisetty, and Arif Merchant. Flash Reliability in Production: The Expected and the Unexpected. At 14th USENIX Conference on File and Storage Technologies (FAST), February 2016. ↩︎
Jacob Alter, Ji Xue, Alma Dimnaku, and Evgenia Smirni. SSD failures in the field: symptoms, causes, and prediction models. At International Conference for High Performance Computing, Networking, Storage and Analysis (SC), November 2019. doi:10.1145/3295500.3356172 ↩︎
Daniel Ford, François Labelle, Florentina I. Popovici, Murray Stokely, Van-Anh Truong, Luiz Barroso, Carrie Grimes, and Sean Quinlan. Availability in Globally Distributed Storage Systems. At 9th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2010. ↩︎ ↩︎
Kashi Venkatesh Vishwanath and Nachiappan Nagappan. Characterizing Cloud Computing Hardware Reliability. At 1st ACM Symposium on Cloud Computing (SoCC), June 2010. doi:10.1145/1807128.1807161 ↩︎
Peter H. Hochschild, Paul Turner, Jeffrey C. Mogul, Rama Govindaraju, Parthasarathy Ranganathan, David E. Culler, and Amin Vahdat. Cores that don’t count. At Workshop on Hot Topics in Operating Systems (HotOS), June 2021. doi:10.1145/3458336.3465297 ↩︎
Harish Dattatraya Dixit, Sneha Pendharkar, Matt Beadon, Chris Mason, Tejasvi Chakravarthy, Bharath Muthiah, and Sriram Sankar. Silent Data Corruptions at Scale. arXiv:2102.11245, February 2021. ↩︎
Diogo Behrens, Marco Serafini, Sergei Arnautov, Flavio P. Junqueira, and Christof Fetzer. Scalable Error Isolation for Distributed Systems. At 12th USENIX Symposium on Networked Systems Design and Implementation (NSDI), May 2015. ↩︎
Bianca Schroeder, Eduardo Pinheiro, and Wolf-Dietrich Weber. DRAM Errors in the Wild: A Large-Scale Field Study. At 11th International Joint Conference on Measurement and Modeling of Computer Systems (SIGMETRICS), June 2009. doi:10.1145/1555349.1555372 ↩︎
Yoongu Kim, Ross Daly, Jeremie Kim, Chris Fallin, Ji Hye Lee, Donghyuk Lee, Chris Wilkerson, Konrad Lai, and Onur Mutlu. Flipping Bits in Memory Without Accessing Them: An Experimental Study of DRAM Disturbance Errors. At 41st Annual International Symposium on Computer Architecture (ISCA), June 2014. doi:10.1145/2678373.2665726 ↩︎
Tim Bray. Worst Case. tbray.org, October 2021. Archived at perma.cc/4QQM-RTHN ↩︎
Sangeetha Abdu Jyothi. Solar Superstorms: Planning for an Internet Apocalypse. At ACM SIGCOMM Conferene, August 2021. doi:10.1145/3452296.3472916 ↩︎
Adrian Cockcroft. Failure Modes and Continuous Resilience. adrianco.medium.com, November 2019. Archived at perma.cc/7SYS-BVJP ↩︎
Shujie Han, Patrick P. C. Lee, Fan Xu, Yi Liu, Cheng He, and Jiongzhou Liu. An In-Depth Study of Correlated Failures in Production SSD-Based Data Centers. At 19th USENIX Conference on File and Storage Technologies (FAST), February 2021. ↩︎
Edmund B. Nightingale, John R. Douceur, and Vince Orgovan. Cycles, Cells and Platters: An Empirical Analysis of Hardware Failures on a Million Consumer PCs. At 6th European Conference on Computer Systems (EuroSys), April 2011. doi:10.1145/1966445.1966477 ↩︎
Haryadi S. Gunawi, Mingzhe Hao, Tanakorn Leesatapornwongsa, Tiratat Patana-anake, Thanh Do, Jeffry Adityatama, Kurnia J. Eliazar, Agung Laksono, Jeffrey F. Lukman, Vincentius Martin, and Anang D. Satria. What Bugs Live in the Cloud? At 5th ACM Symposium on Cloud Computing (SoCC), November 2014. doi:10.1145/2670979.2670986 ↩︎
Jay Kreps. Getting Real About Distributed System Reliability. blog.empathybox.com, March 2012. Archived at perma.cc/9B5Q-AEBW ↩︎ ↩︎
Nelson Minar. Leap Second Crashes Half the Internet. somebits.com, July 2012. Archived at perma.cc/2WB8-D6EU ↩︎
Hewlett Packard Enterprise. Support Alerts – Customer Bulletin a00092491en_us. support.hpe.com, November 2019. Archived at perma.cc/S5F6-7ZAC ↩︎
Lorin Hochstein. awesome limits. github.com, November 2020. Archived at perma.cc/3R5M-E5Q4 ↩︎
Caitie McCaffrey. Clients Are Jerks: AKA How Halo 4 DoSed the Services at Launch & How We Survived. caitiem.com, June 2015. Archived at perma.cc/MXX4-W373 ↩︎
Lilia Tang, Chaitanya Bhandari, Yongle Zhang, Anna Karanika, Shuyang Ji, Indranil Gupta, and Tianyin Xu. Fail through the Cracks: Cross-System Interaction Failures in Modern Cloud Systems. At 18th European Conference on Computer Systems (EuroSys), May 2023. doi:10.1145/3552326.3587448 ↩︎
Mike Ulrich. Addressing Cascading Failures. In Betsy Beyer, Jennifer Petoff, Chris Jones, and Niall Richard Murphy (ed). Site Reliability Engineering: How Google Runs Production Systems. O’Reilly Media, 2016. ISBN: 9781491929124 ↩︎
Harri Faßbender. Cascading failures in large-scale distributed systems. blog.mi.hdm-stuttgart.de, March 2022. Archived at perma.cc/K7VY-YJRX ↩︎
Richard I. Cook. How Complex Systems Fail. Cognitive Technologies Laboratory, April 2000. Archived at perma.cc/RDS6-2YVA ↩︎
David D. Woods. STELLA: Report from the SNAFUcatchers Workshop on Coping With Complexity. snafucatchers.github.io, March 2017. Archived at archive.org ↩︎ ↩︎
David Oppenheimer, Archana Ganapathi, and David A. Patterson. Why Do Internet Services Fail, and What Can Be Done About It? At 4th USENIX Symposium on Internet Technologies and Systems (USITS), March 2003. ↩︎
Sidney Dekker. The Field Guide to Understanding ‘Human Error’, 3rd Edition. CRC Press, November 2017. ISBN: 9781472439055 ↩︎ ↩︎
Sidney Dekker. Drift into Failure: From Hunting Broken Components to Understanding Complex Systems. CRC Press, 2011. ISBN: 9781315257396 ↩︎
John Allspaw. Blameless PostMortems and a Just Culture. etsy.com, May 2012. Archived at perma.cc/YMJ7-NTAP ↩︎
Itzy Sabo. Uptime Guarantees — A Pragmatic Perspective. world.hey.com, March 2023. Archived at perma.cc/F7TU-78JB ↩︎
Michael Jurewitz. The Human Impact of Bugs. jury.me, March 2013. Archived at perma.cc/5KQ4-VDYL ↩︎
Mark Halper. How Software Bugs led to ‘One of the Greatest Miscarriages of Justice’ in British History. Communications of the ACM, January 2025. doi:10.1145/3703779 ↩︎
Nicholas Bohm, James Christie, Peter Bernard Ladkin, Bev Littlewood, Paul Marshall, Stephen Mason, Martin Newby, Steven J. Murdoch, Harold Thimbleby, and Martyn Thomas. The legal rule that computers are presumed to be operating correctly – unforeseen and unjust consequences. Briefing note, benthamsgaze.org, June 2022. Archived at perma.cc/WQ6X-TMW4 ↩︎
Dan McKinley. Choose Boring Technology. mcfunley.com, March 2015. Archived at perma.cc/7QW7-J4YP ↩︎
Andy Warfield. Building and operating a pretty big storage system called S3. allthingsdistributed.com, July 2023. Archived at perma.cc/7LPK-TP7V ↩︎
Marc Brooker. Surprising Scalability of Multitenancy. brooker.co.za, March 2023. Archived at perma.cc/ZZD9-VV8T ↩︎
Ben Stopford. Shared Nothing vs. Shared Disk Architectures: An Independent View. benstopford.com, November 2009. Archived at perma.cc/7BXH-EDUR ↩︎
Michael Stonebraker. The Case for Shared Nothing. IEEE Database Engineering Bulletin, volume 9, issue 1, pages 4–9, March 1986. ↩︎
Panagiotis Antonopoulos, Alex Budovski, Cristian Diaconu, Alejandro Hernandez Saenz, Jack Hu, Hanuma Kodavalla, Donald Kossmann, Sandeep Lingam, Umar Farooq Minhas, Naveen Prakash, Vijendra Purohit, Hugh Qu, Chaitanya Sreenivas Ravella, Krystyna Reisteter, Sheetal Shrotri, Dixin Tang, and Vikram Wakade. Socrates: The New SQL Server in the Cloud. At ACM International Conference on Management of Data (SIGMOD), pages 1743–1756, June 2019. doi:10.1145/3299869.3314047 ↩︎
Sam Newman. Building Microservices, second edition. O’Reilly Media, 2021. ISBN: 9781492034025 ↩︎
Nathan Ensmenger. When Good Software Goes Bad: The Surprising Durability of an Ephemeral Technology. At The Maintainers Conference, April 2016. Archived at perma.cc/ZXT4-HGZB ↩︎
Robert L. Glass. Facts and Fallacies of Software Engineering. Addison-Wesley Professional, October 2002. ISBN: 9780321117427 ↩︎
Marianne Bellotti. Kill It with Fire. No Starch Press, April 2021. ISBN: 9781718501188 ↩︎
Lisanne Bainbridge. Ironies of automation. Automatica, volume 19, issue 6, pages 775–779, November 1983. doi:10.1016/0005-1098(83)90046-8 ↩︎
James Hamilton. On Designing and Deploying Internet-Scale Services. At 21st Large Installation System Administration Conference (LISA), November 2007. ↩︎
Dotan Horovits. Open Source for Better Observability. horovits.medium.com, October 2021. Archived at perma.cc/R2HD-U2ZT ↩︎
Brian Foote and Joseph Yoder. Big Ball of Mud. At 4th Conference on Pattern Languages of Programs (PLoP), September 1997. Archived at perma.cc/4GUP-2PBV ↩︎
Marc Brooker. What is a simple system? brooker.co.za, May 2022. Archived at perma.cc/U72T-BFVE ↩︎
Frederick P. Brooks. No Silver Bullet – Essence and Accident in Software Engineering. In The Mythical Man-Month, Anniversary edition, Addison-Wesley, 1995. ISBN: 9780201835953 ↩︎
Dan Luu. Against essential and accidental complexity. danluu.com, December 2020. Archived at perma.cc/H5ES-69KC ↩︎
Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley Professional, October 1994. ISBN: 9780201633610 ↩︎
Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley Professional, August 2003. ISBN: 9780321125217 ↩︎
Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson. Analyzing Software Evolvability. at 32nd Annual IEEE International Computer Software and Applications Conference (COMPSAC), July 2008. doi:10.1109/COMPSAC.2008.50 ↩︎
Enrico Zaninotto. From X programming to the X organisation. At XP Conference, May 2002. Archived at perma.cc/R9AR-QCKZ ↩︎
3 資料模型與查詢語言

我的語言的邊界,意味著我的世界的邊界。
路德維希・維特根斯坦,《邏輯哲學論》(1922)
資料模型可能是軟體開發中最重要的部分了,因為它們的影響如此深遠:不僅僅影響著軟體的編寫方式,而且影響著我們的 解題思路。
多數應用使用層層疊加的資料模型構建。對於每層資料模型的關鍵問題是:它是如何用低一層資料模型來 表示 的?例如:
- 作為一名應用開發人員,你觀察現實世界(裡面有人員、組織、貨物、行為、資金流向、感測器等),並採用物件或資料結構,以及操控那些資料結構的 API 來進行建模。那些結構通常是特定於應用程式的。
- 當要儲存那些資料結構時,你可以利用通用資料模型來表示它們,如 JSON 或 XML 文件、關聯式資料庫中的表,或者圖中的頂點和邊。這些資料模型正是本章的主題。
- 構建資料庫軟體的工程師決定如何以記憶體、磁碟或網路上的位元組來表示文件、關係或圖資料。這類表示形式使資料有可能以各種方式來查詢、搜尋、操縱和處理。我們將在 第 4 章 討論這些儲存引擎的設計。
- 在更低的層次上,硬體工程師已經想出了使用電流、光脈衝、磁場或者其他東西來表示位元組的方法。
一個複雜的應用程式可能會有更多的中間層次,比如基於 API 的 API,不過基本思想仍然是一樣的:每個層都透過提供一個明確的資料模型來隱藏更低層次中的複雜性。這些抽象允許不同的人群有效地協作,例如資料庫廠商的工程師和使用資料庫的應用程式開發人員。
實踐中廣泛使用著幾種不同的資料模型,通常各有用途。某些型別的資料和查詢在一種模型中很容易表達,在另一種模型中卻很彆扭。本章將比較 關係模型(relational model)、文件模型(document model)、圖資料模型(graph data model)、事件溯源(event sourcing)和 資料框(dataframe),探討其中的權衡。我們還將簡要介紹操作這些模型的查詢語言,幫助你判斷何時應該使用哪種模型。
本章中的許多查詢語言(如 SQL、Cypher、SPARQL 或 Datalog)都是 宣告式(declarative)的。在宣告式查詢語言中,你只需指定所需資料的模式——結果必須符合哪些條件,以及資料應如何轉換(例如排序、分組和聚合)——而不必說明 如何 實現這一目標。資料庫系統的查詢最佳化器決定使用哪些索引和連線演算法,以及以何種順序執行查詢的各個部分。
相比之下,使用大多數程式語言時,你必須寫出一套 演算法(algorithm),告訴計算機以特定順序執行哪些操作。宣告式查詢語言通常比顯式演算法更加簡潔,也更容易編寫;但更重要的是,它隱藏了查詢引擎的實現細節,使資料庫系統可以在無須對查詢做任何修改的情況下提升效能 1。
例如,資料庫或許能跨多個 CPU 核心和多臺機器並行執行一條宣告式查詢,而你無須操心如何實現這種並行 2。若是手寫演算法,自行實現這種並行執行將費不少功夫。
關係模型與文件模型
如今最廣為人知的資料模型或許是 SQL 所採用的關係模型,它由 Edgar Codd 於 1970 年提出 3:資料被組織成 關係(relation,SQL 稱之為 表,table),每個關係都是由 元組(tuple,SQL 稱之為 行,row)構成的無序集合。
關係模型最初只是一項理論提議,當時許多人懷疑它能否得到高效實現。然而到了 20 世紀 80 年代中期,對於大多數需要儲存和查詢具有某種規則結構的資料的人來說,關聯式資料庫管理系統(RDBMS)和 SQL 已成為首選工具。幾十年過去,關係資料仍主導著許多資料管理場景,例如商業分析(參見 “星型與雪花型:分析模式”)。
多年來,資料儲存和查詢領域湧現過許多彼此競爭的方法。20 世紀 70 年代至 80 年代初,網狀模型(network model)和 層次模型(hierarchical model)是關係模型的主要對手,但最終都敗下陣來。物件資料庫在 20 世紀 80 年代末至 90 年代初興起後又銷聲匿跡;XML 資料庫於 21 世紀初出現,卻始終只在少數場景中得到採用。關係模型的每個競爭者都曾盛極一時,但無一長久 4。反倒是 SQL 在關係模型這個核心之上不斷吸收其他資料型別,例如增加了對 XML、JSON 和圖資料的支援 5。
到了 2010 年代,NoSQL 成了試圖撼動關聯式資料庫統治地位的最新流行語。NoSQL 並非某項特定技術,而是圍繞新資料模型、模式靈活性、可伸縮性和開源許可模式形成的一組寬泛理念。另一些資料庫則以 NewSQL 自居,力圖在保留傳統關聯式資料庫的資料模型和事務保證的同時,提供 NoSQL 系統的可伸縮性。NoSQL 和 NewSQL 的理念深刻影響了資料系統的設計;不過,隨著這些原則被廣泛吸收,兩個術語本身已漸漸淡出。
NoSQL 運動留下的一項持久影響,是通常以 JSON 表示資料的 文件模型(document model)廣受歡迎。這個模型最初由 MongoDB、Couchbase 等專用文件資料庫推廣,如今大多數關聯式資料庫也已加入 JSON 支援。關係表的模式常被視為嚴格而僵化,相比之下,JSON 文件被認為更加靈活。
文件資料與關係資料孰優孰劣,已經引發過大量爭論。下面來看看其中幾個關鍵問題。
物件關係不匹配
如今,大量應用開發使用物件導向的程式語言,這也引出了針對 SQL 資料模型的一項常見批評:資料若儲存在關係表中,就需要一個笨拙的轉換層,在應用程式碼中的物件與資料庫的表、行、列模型之間來回轉換。兩種模型之間的這種脫節,有時稱為 阻抗不匹配(impedance mismatch)。
阻抗不匹配 一詞借自電子學。每個電路的輸入和輸出都有一定的阻抗(對交流電的阻力)。把一個電路的輸出接到另一個電路的輸入時,若兩邊阻抗匹配,連線處的功率傳輸就能達到最大;阻抗不匹配則可能造成訊號反射等問題。
物件關係對映(ORM)
ActiveRecord、Hibernate 等物件關係對映(ORM)框架減少了轉換層所需的樣板程式碼,但也時常遭到批評 6。常見問題包括:
- ORM 本身很複雜,也無法徹底掩蓋兩種模型的差異,開發者最終仍得同時考慮資料的關係表示和物件表示。
- ORM 一般只用於開發 OLTP 應用(參見 “事務處理與分析的特徵”)。為了讓資料可供分析,資料工程師仍須面對底層的關係表示,因此採用 ORM 並不意味著關係模式的設計不再重要。
- 許多 ORM 只面向關係型 OLTP 資料庫。若組織還使用搜尋引擎、圖資料庫、NoSQL 系統等多種資料系統,ORM 提供的支援可能遠遠不夠。
- 有些 ORM 會自動生成關係模式,但生成的模式對直接訪問關係資料的使用者未必友好,在底層資料庫上也可能效率不佳。要定製 ORM 生成模式與查詢的方式,往往相當複雜,甚至會抵消採用 ORM 原本想獲得的好處。
- 使用 ORM 很容易在無意中寫出低效查詢,例如觸發 N+1 查詢問題(N+1 query problem)7。假設你要在頁面上顯示使用者評論列表:先用一條查詢取回 N 條評論,每條都含有作者 ID;為了顯示作者姓名,還要用這個 ID 查詢使用者表。手寫 SQL 時,你大概會直接在查詢中連線使用者表,讓每條評論連同作者姓名一起返回;使用 ORM 時,卻可能對 N 條評論逐條查詢使用者表,最終一共執行 N+1 條資料庫查詢。這比在資料庫內完成連線要慢得多。為避免這個問題,你可能必須明確要求 ORM 在獲取評論的同時一併取回作者資訊。
不過,ORM 也自有其優勢:
- 對適合關係模型的資料來說,持久化的關係表示與記憶體中的物件表示之間總要進行某種轉換,ORM 可以減少這類轉換所需的樣板程式碼。複雜查詢或許仍須繞開 ORM 處理,但簡單、重複的場景正適合交給它。
- 有些 ORM 可以快取資料庫查詢結果,從而減輕資料庫負載。
- ORM 還可以協助管理模式遷移和其他資料庫管理工作。
用於一對多關係的文件資料模型
並非所有資料都適合用關係形式表示。下面用一個例子看看關係模型的侷限。圖 3-1 展示了如何用關係模式表示一份簡歷(LinkedIn 個人資料)。整份資料由唯一識別符號 user_id 標識;first_name 和 last_name 等欄位對每位使用者只出現一次,因此可以建模為 users 表中的列。
大多數人的職業生涯中都不止有一份工作(即多個職位),每個人的教育經歷數量也不相同,聯絡方式更可能有任意多項。這些 一對多關係(one-to-many relationship)可以這樣表示:把職位、教育經歷和聯絡資訊分別放在單獨的表中,再透過外來鍵引用 users 表,如 圖 3-1 所示。

同一份資訊還可以表示為 JSON 文件,如 示例 3-1 所示。這種形式可能更為自然,也更貼近應用程式碼中的物件結構。
一些開發者認為,JSON 模型減輕了應用程式碼與儲存層之間的阻抗不匹配。不過,正如我們將在 第 5 章 看到的,JSON 用作資料編碼格式時也有不少問題。沒有模式常被視為它的一項優勢,我們將在 “文件模型中的模式靈活性” 進一步討論。
與 圖 3-1 的多表模式相比,JSON 表示具有更好的 區域性(參見 “讀寫的資料區域性”)。在關係模型的例子中,要取回一份個人資料,要麼執行多次查詢(按 user_id 分別查詢每張表),要麼在 users 表及其下屬表之間進行繁瑣的多路連線 8。而在 JSON 表示中,所有相關資訊都集中在一處,查詢既簡單又快捷。
個人資料與職位、教育經歷、聯絡資訊之間的一對多關係,在資料中形成了一棵樹;JSON 表示則明確呈現了這種樹狀結構(見 圖 3-2)。

正規化、反正規化與連線
前一節的 示例 3-1 用 ID 表示 region_id,而沒有直接寫成純文字字串 "Washington, DC, United States"。為什麼要這樣做?
如果使用者介面提供自由文字框讓使用者填寫地區,那麼把輸入直接存成文字字串很合理。不過,預先提供標準化的地理區域列表,讓使用者透過下拉選單或自動補全來選擇,也有不少好處:
- 所有個人資料中的格式和拼寫保持一致
- 避免同名地點造成歧義(若字串只有 “Washington”,究竟是指華盛頓特區,還是華盛頓州?)
- 便於更新——名稱只儲存在一處,將來如有變動(例如因政治事件而更改城市名稱),只改一處便能全域性生效
- 支援本地化——網站翻譯成其他語言時,可以本地化這份標準列表,讓地區名稱以瀏覽者使用的語言顯示
- 改善搜尋——例如,地區列表可以記錄華盛頓位於美國東海岸這一事實(單看
"Washington, DC"字串無法得知),於是搜尋美國東海岸的人時也能匹配這份資料
選擇儲存 ID 還是文字字串,實質上是在決定是否 正規化(normalization)。使用 ID 時,資料更加正規化:對人有意義的資訊(如 Washington, DC 這段文字)只儲存一份,其他地方都用僅在資料庫內有意義的 ID 來引用它。若直接儲存文字,這段有意義的資訊就會複製到每條使用它的記錄中;這樣的表示便是 反正規化(denormalized)的。
ID 的好處在於,它本身對人沒有意義,因而永遠不必改變:即使 ID 所標識的資訊發生了變化,ID 仍可保持不變。凡是對人有意義的資訊,將來都有可能需要修改;一旦這類資訊被複制,所有冗餘副本就都得隨之更新。這不僅需要更多程式碼、寫入操作和磁碟空間,還會帶來不一致的風險——有些副本已經更新,另一些卻沒有。
正規化表示也有代價:每次顯示含有 ID 的記錄時,都要多做一次查詢,把 ID 解析成人能讀懂的資訊。在關係資料模型中,這項工作透過 連線(join)完成,例如:
文件資料庫既能儲存正規化資料,也能儲存反正規化資料,但人們往往把它與反正規化聯絡在一起。一方面,JSON 資料模型很容易加入額外的反正規化欄位;另一方面,許多文件資料庫對連線支援較弱,採用正規化表示很不方便。有些文件資料庫完全不支援連線,只能在應用程式碼中自行完成:先取回含有 ID 的文件,再發起第二次查詢,用 ID 找到另一個文件。MongoDB 也可以透過聚合管道中的 $lookup 運算元執行連線:
正規化的權衡
在簡歷示例中,region_id 欄位引用了標準化的地區集合,organization(任職的公司或政府機構)和 school_name(就讀的學校)卻只是字串。這是一種反正規化表示:許多人或許曾在同一家公司工作,但這些記錄並沒有透過 ID 關聯起來。
那麼,是否應該把組織和學校也建模為實體,讓個人資料引用其 ID,而不是直接寫下名稱?主張用 ID 引用地區的理由在這裡同樣成立。比如,假設除了名稱之外,我們還想顯示學校或公司的徽標:
- 採用反正規化表示時,每個人的個人資料都要包含徽標圖片的 URL。這樣固然讓 JSON 文件自給自足,但徽標一旦更換,就必須找出舊 URL 出現的每個地方並逐一更新,十分麻煩 9。
- 採用正規化表示時,可以建立一個代表組織或學校的實體,只在其中儲存一次名稱、徽標 URL,也許再加上簡介、動態訊息等屬性。所有提及該組織的簡歷只引用其 ID,更新徽標便易如反掌。
一般來說,正規化資料寫入較快(因為只有一個副本),查詢卻較慢(因為需要連線);反正規化資料通常讀取較快(連線更少),寫入代價卻更高(要更新更多副本,也佔用更多磁碟空間)。把反正規化看作一種衍生資料或許很有幫助(參見 “權威記錄系統與衍生資料”),因為你必須建立相應流程來更新那些冗餘的資料副本。
除了更新本身的成本,還要考慮程序在更新中途崩潰時,資料庫能否保持一致。支援原子事務的資料庫(參見 “原子性”)更容易維護一致性,但並非所有資料庫都能為跨多個文件的操作提供原子性。也可以利用流處理來保證一致性,我們將在 “保持系統同步” 討論這種方法。
正規化往往更適合讀寫都要迅速完成的 OLTP 系統;分析系統通常更適合反正規化資料,因為它們會批次更新,首要關心的是隻讀查詢的效能。此外,中小規模系統通常也更適合正規化資料模型:既不必費心維持多個副本的一致,執行連線的成本也還可以接受。不過到了超大規模,連線的代價就可能成為問題。
社交網路案例研究中的反正規化
在 “案例研究:社交網路首頁時間線” 中,我們比較了正規化表示(圖 2-1)和反正規化表示(預計算並物化的時間線)。在那個例子裡,連線 posts 與 follows 的成本太高,於是物化時間線充當了連線結果的快取;把新帖子扇出到關注者的時間線,正是維持這種反正規化表示一致的手段。
不過,X(原 Twitter)的物化時間線實際上並不儲存每條帖子的正文。每個條目只儲存帖子 ID、發帖使用者的 ID,以及少量用於識別轉帖和回覆的附加資訊 11。換句話說,它大致相當於預先算好了下面這條查詢的結果:
因此,每次讀取時間線時,服務仍要做兩次連線:按帖子 ID 取回正文,以及點贊數、回覆數等統計資訊;再按發帖使用者 ID 取回其使用者名稱、頭像和其他資料。這個把 ID 補全為人類可讀資訊的過程稱為 補全 ID(hydrating IDs),本質上就是在應用程式碼中完成連線 11。
預計算時間線之所以只存 ID,是因為 ID 所指向的資料變化很快:熱門帖子的點贊數和回覆數每秒都可能變化多次,有些使用者也會經常更換使用者名稱或頭像。時間線在展示時應呈現最新的點贊數和頭像,所以把這些資訊反正規化到物化時間線中並不合理,而且還會大幅增加儲存成本。
這個例子說明,讀取資料時需要執行連線,並不像有些說法所稱的那樣,會妨礙我們構建高效能、可伸縮的服務。補全帖子 ID 和使用者 ID 其實相當容易伸縮:這項工作很適合並行執行,而且成本既不取決於你關注了多少賬戶,也不取決於有多少人關注你。
如果要判斷應用中的某項資料是否應該反正規化,社交網路案例表明答案並不顯而易見:可伸縮性最好的方案,可能是把一部分資料反正規化,同時讓另一部分保持正規化。你必須仔細權衡資訊的變化頻率與讀寫成本;而成本又可能由極端情況主導,例如社交網路中關注或被關注人數異常多的使用者。正規化與反正規化本身無所謂好壞,不過是在讀寫效能和實現成本之間作取捨。
多對一與多對多關係
圖 3-1 中的 positions 和 education 是一對多(或一對少)關係:一份簡歷有多個職位,但每個職位只屬於一份簡歷。相比之下,region_id 欄位表示 多對一(many-to-one)關係:許多人住在同一個地區,而我們假設任一時刻每個人只住在一個地區。
如果進一步把組織和學校建模為實體,讓簡歷透過 ID 引用它們,就會出現 多對多(many-to-many)關係:一個人曾在多個組織任職,一個組織也有多名現任或前任員工。在關係模型中,這類關係通常用 關聯表(associative table,也稱 連線表,join table)表示,如 圖 3-3 所示:每個職位把一個使用者 ID 與一個組織 ID 關聯起來。

多對一和多對多關係很難塞進一個自包含的 JSON 文件,它們更適合正規化表示。示例 3-2 給出了文件模型中的一種方案,圖 3-4 則以圖示說明:每個虛線框內的資料可以組成一份文件,但指向組織和學校的連結最好表示為對其他文件的引用。

多對多關係通常需要從“兩個方向”查詢:既要找出某人任職過的所有組織,也要找出曾在某組織任職的所有人。一種做法是在關係兩端都儲存 ID 引用:簡歷列出此人任職過的各個組織 ID,組織文件也列出提及該組織的簡歷 ID。由於同一關係儲存了兩份,這是一種反正規化表示,兩邊可能彼此不一致。
正規化表示只在一處儲存關係,再依靠 二級索引(secondary index;將在 第 4 章 討論)從兩個方向高效查詢。圖 3-3 的關係模式中,可以讓資料庫分別為 positions 表的 user_id 列和 org_id 列建立索引。
在 示例 3-2 的文件模型中,資料庫則需要索引 positions 陣列內各物件的 org_id 欄位。許多文件資料庫以及支援 JSON 的關聯式資料庫,都能為文件內部的值建立這種索引。
星型與雪花型:分析模式
資料倉儲(參見 “資料倉儲”)通常採用關係模型,其表結構有幾種廣泛使用的慣例:星型模式(star schema)、雪花模式(snowflake schema)、維度建模(dimensional modeling)12,以及 一張大表(OBT)。這些結構針對業務分析師的需求進行了最佳化,ETL 過程則負責把事務型系統中的資料轉換成這種模式。
圖 3-5 中的示例模式,可能出現在一家食品零售商的資料倉儲中。模式的中心是所謂的 事實表(fact table;本例中名為 fact_sales)。事實表的每一行代表在特定時間發生的事件;在這裡,每行代表客戶購買了一件產品。如果分析的是網站流量而不是零售量,那麼每行可能代表一次頁面瀏覽或一次使用者點選。

通常會把每項事實記錄為獨立事件,因為這樣能為日後的分析保留最大的靈活性。不過,這也意味著事實表可能變得極其龐大。大型企業的資料倉儲可能儲存著許多 PB 的交易歷史,其中大部分都以事實表表示。
事實表中的一些列是屬性,例如產品的售價和從供應商處購入的成本(據此可以計算利潤率)。另一些列是指向其他表的外來鍵引用,這些表稱為 維度表(dimension table)。由於事實表的每一行表示一個事件,各個維度便代表事件發生的物件、內容、地點、時間、方式和原因。
例如,圖 3-5 中的一個維度是售出的產品。dim_product 表中的每一行代表一種待售產品,包括庫存單位(SKU)、產品描述、品牌名稱、類別、脂肪含量、包裝尺寸等。fact_sales 表的每一行都用外來鍵表明該筆交易售出了哪種產品。查詢往往要連線多個維度表。
甚至日期和時間也常用維度表表示,以便編碼公共假期等額外資訊,讓查詢可以區分節假日與平日的銷售情況。
圖 3-5 展示的便是星型模式。這個名稱源自表關係的視覺化形狀:事實表位於中央,周圍環繞著維度表;連線這些表的線條就像星星的光芒。
這個模板的變體稱為 雪花模式,其中的維度會進一步分解成子維度。例如,可以為品牌和產品類別分別建表,讓 dim_product 的每一行以外來鍵引用品牌與類別,而不再把它們作為字串直接存入 dim_product 表。雪花模式比星型模式更正規化,但星型模式通常更受青睞,因為分析師使用起來更簡單 12。
典型資料倉儲中的表通常非常寬:事實表往往超過 100 列,有時甚至有數百列。維度表也可能很寬,因為它們會收錄所有可能與分析相關的後設資料。例如,dim_store 表可能記錄每家商店提供哪些服務、是否設有店內麵包房、店面面積、首次開業日期、最近一次改造時間,以及離最近的高速公路有多遠,等等。
星型模式和雪花模式主要由多對一關係構成,例如許多筆銷售對應同一種產品、同一家商店。這些關係表現為事實表指向維度表的外來鍵,或維度表指向子維度表的外來鍵。原則上也可以存在其他型別的關係,但為了簡化查詢,通常會把它們反正規化。例如,顧客一次買了幾種不同產品時,這筆多商品交易不會得到顯式表示;事實表只是為每件商品各存一行,而這些事實恰好具有相同的顧客 ID、商店 ID 和時間戳。
有些資料倉儲模式更進一步,完全省去維度表,把維度資訊放進事實表的反正規化列中——實質上就是預先計算事實表與維度表的連線。這種方法稱為 一張大表(OBT);它雖然佔用更多儲存空間,有時卻能讓查詢更快 13。
在分析場景中,這樣反正規化通常不成問題,因為資料往往是一份不會再改變的歷史記錄(偶爾糾正錯誤除外)。反正規化在 OLTP 系統中帶來的資料一致性問題和寫入開銷,在分析系統裡沒有那麼緊迫。
何時使用哪種模型
支援文件資料模型的主要論據是模式靈活性、因區域性而擁有更好的效能,以及對於某些應用程式而言,它更接近應用程式使用的物件模型。關係模型則以更好地支援連線、多對一和多對多關係作為回應。下面逐一詳細考察這些論點。
如果應用程式中的資料具有類似文件的結構(即一對多關係樹,通常一次性載入整棵樹),那麼使用文件模型可能是個好主意。把類似文件的結構 拆散 到多個表中(如 圖 3-1 中的 positions、education 和 contact_info),可能導致繁瑣的模式和不必要的複雜應用程式程式碼。
文件模型有一定的侷限性:例如,不能直接引用文件中的巢狀專案,而是需要說“使用者 251 的職位列表中的第二項”。如果確實需要引用巢狀專案,關係模型更合適,因為任何專案都能透過自身 ID 被直接引用。
有些應用允許使用者自行安排專案順序。例如,在待辦事項清單或問題跟蹤器中,使用者可以拖放任務來重新排序。文件模型很適合這類應用,只需把專案(或專案 ID)按順序存入 JSON 陣列即可。關聯式資料庫沒有表示這種可重新排序列表的標準方式,只能藉助各種技巧:按整數列排序(在中間插入時需要重新編號)、用 ID 構成連結串列,或者使用分數索引 14 15 16。
文件模型中的模式靈活性
大多數文件資料庫以及關聯式資料庫中的 JSON 支援,都不會強制文件中的資料採用何種模式。關聯式資料庫的 XML 支援通常帶有可選的模式驗證。沒有模式意味著可以向文件中新增任意鍵和值;讀取時,客戶端也無法確定文件究竟會包含哪些欄位。
文件資料庫有時稱為 無模式(schemaless),但這具有誤導性,因為讀取資料的程式碼通常假定某種結構——即存在隱式模式,只是不由資料庫強制執行 17。一個更準確的術語是 讀時模式(schema-on-read,資料結構是隱含的,只有讀取時才會解釋),與之相對的是 寫時模式(schema-on-write,關聯式資料庫的傳統做法:模式是明確的,資料庫確保寫入的所有資料都符合模式)18。
讀時模式類似於程式語言中的動態(執行時)型別檢查,而寫時模式類似於靜態(編譯時)型別檢查。就像靜態與動態型別檢查孰優孰劣一直爭議不斷 19,資料庫是否應該強制執行模式也是個見仁見智的問題,通常並沒有絕對的對錯。
當應用程式想要改變資料格式時,兩種方法的區別尤其明顯。例如,假設原先把每位使用者的全名儲存在一個欄位中,現在想把名和姓分開儲存 20。在文件資料庫中,只需開始寫入帶有新欄位的文件,並在應用程式中加入程式碼來處理舊文件即可。例如:
這種方法的缺點是,應用中每個讀取資料庫的部分,從此都必須處理可能在很久以前寫入的舊格式文件。另一方面,在寫時模式資料庫中,通常會執行下面這樣的 遷移:
在大多數關聯式資料庫中,即使面對大表,新增帶預設值的列也又快又穩妥。不過,在大表上執行 UPDATE 可能很慢,因為每一行都必須重寫;其他模式操作(例如修改某列的資料型別)通常也需要複製整張表。
有多種工具可以在後臺完成這類模式變更而無須停機 21 22 23 24,但在大型資料庫上進行這種遷移,運維起來依然頗具挑戰。要避開複雜遷移,可以只快速新增一個預設值為 NULL 的 first_name 列,然後在讀取時填充它,就像使用文件資料庫那樣。
如果集合中的專案因為某種原因並不都具有相同結構(即資料是異構的),讀時模式更具優勢。例如:
- 存在許多不同型別的物件,將每種物件分別放進一張表並不現實。
- 資料結構由你無法控制、且隨時可能改變的外部系統決定。
在上述情況下,模式可能弊大於利,無模式文件反而是更自然的資料模型。但是,如果所有記錄都應具有相同結構,那麼模式就是記錄並強制這種結構的有效機制。我們將在 第 5 章 更詳細地討論模式和模式演化。
讀寫的資料區域性
文件通常以單個連續字串的形式儲存,編碼為 JSON、XML 或其二進位制變體(如 MongoDB 的 BSON)。如果應用程式經常需要訪問整個文件(例如把它渲染到網頁上),這種 儲存區域性 會帶來效能優勢。如果資料像 圖 3-1 那樣分散在多張表中,就需要多次查詢索引才能檢索完整,可能產生更多磁碟尋道並花費更長時間。
區域性優勢只適用於同時需要文件絕大部分內容的情況。即使只訪問大型文件的一小部分,資料庫通常也要載入整個文件,這會造成浪費;更新時一般還要重寫整個文件。因此,通常建議讓文件保持較小,並避免頻繁地對文件做小幅更新。
不過,為了區域性而把相關資料儲存在一起,並非文件模型的專利。例如,Google 的 Spanner 資料庫在關係模型中也提供同樣的區域性屬性,允許模式宣告某張表的行應交錯(巢狀)在父表之中 25。Oracle 也用名為 多表索引叢集表(multi-table index cluster table)的功能提供類似能力 26。由 Google Bigtable 推廣、並被 HBase 和 Accumulo 等系統採用的 寬列(wide-column)資料模型,則以 列族(column family)概念達到相似的區域性管理目的 27。
文件的查詢語言
關聯式資料庫和文件資料庫的另一個區別,在於查詢所用的語言或 API。大多數關聯式資料庫使用 SQL,文件資料庫則五花八門:有些只允許按主鍵進行鍵值訪問,有些還提供二級索引來查詢文件內部的值,還有些配有功能豐富的查詢語言。
XML 資料庫通常使用 XQuery 和 XPath;它們支援包括跨文件連線在內的複雜查詢,還能把結果格式化為 XML 28。JSON Pointer 29 和 JSONPath 30 則為 JSON 提供了與 XPath 相當的功能。
MongoDB 的聚合管道就是一種面向 JSON 文件集合的查詢語言;我們在 “正規化、反正規化與連線” 中已經見過它用於連線的 $lookup 運算元。
再看一個例子來體會這種語言,這次考察分析中尤為常見的聚合。假設你是一名海洋生物學家,每當在海中看到動物,就向資料庫新增一條觀察記錄。現在你想生成一份報告,說明每個月觀察到多少條鯊魚。在 PostgreSQL 中,可以像這樣表述查詢:
❶:date_trunc('month', timestamp) 函式確定 timestamp 所在的日曆月份,並返回代表該月起點的另一個時間戳。換句話說,它把時間戳向下舍入到最近的月份。
這個查詢首先過濾觀察記錄,只保留鯊魚科的物種;然後按觀察發生的日曆月份分組;最後,把該月所有觀察記錄中的動物數量相加。同一個查詢可以用 MongoDB 的聚合管道表示如下:
聚合管道語言的表達能力與 SQL 的一個子集相當,不過它採用基於 JSON 的語法,而不是 SQL 那種接近英語句式的語法;這種差異或許只是口味問題。
文件和關聯式資料庫的融合
文件資料庫和關聯式資料庫最初採用截然不同的資料管理方法,但隨著時間推移,兩者變得越來越相似 31。關聯式資料庫增加了對 JSON 型別和查詢運算元的支援,也能夠為文件內部的屬性建立索引;MongoDB、Couchbase、RethinkDB 等文件資料庫,則增加了連線、二級索引和宣告式查詢語言。
這種融合對應用程式開發人員來說是件好事,因為關係模型和文件模型能夠在同一個資料庫中結合使用時,最能發揮各自所長。許多文件資料庫需要以關係模型的方式引用其他文件,許多關聯式資料庫也有些部分會受益於模式靈活性。關係模型與文件模型的混合是一種強大的組合。
Codd 對關係模型的原始描述 3 實際上允許關係模式中出現類似 JSON 的結構,他稱之為 非簡單域(nonsimple domain)。其思想是,一行中的值不一定只是數字或字串之類的原始資料型別,也可以是巢狀的關係(表),因此可以把任意巢狀的樹結構作為一個值。這與三十多年後加入 SQL 的 JSON 或 XML 支援非常相似。
圖資料模型
如我們之前所見,關係的型別是區分不同資料模型的一項重要特徵。如果應用程式中的關係大多是一對多關係(樹狀結構資料),而記錄之間很少存在其他關係,那麼文件模型是合適的。
但是,如果多對多關係在資料中十分常見呢?關係模型可以處理簡單的多對多關係,但隨著資料之間的連線變得越來越複雜,將資料建模為圖就顯得更加自然。
一個圖由兩種物件組成:頂點(vertices,也稱為 節點,即 nodes,或 實體,即 entities)和 邊(edges,也稱為 關係,即 relationships,或 弧,即 arcs)。多種資料都可以建模為圖,典型的例子包括:
- 社交圖
- 頂點是人,邊表示哪些人相互認識。
- 網頁圖
- 頂點是網頁,邊表示指向其他頁面的 HTML 連結。
- 道路或鐵路網路
- 頂點是交叉點,邊表示它們之間的道路或鐵路線。
可以把許多眾所周知的演算法運用到這些圖上。例如,地圖導航應用會搜尋道路網路中兩點之間的最短路徑;PageRank 可以用在網頁圖上,判斷網頁的流行程度,進而決定它在搜尋結果中的排名 32。
圖可以用幾種不同的方式表示。在 鄰接表(adjacency list)模型中,每個頂點都儲存與它相隔一條邊的相鄰頂點 ID。另一種方式是 鄰接矩陣(adjacency matrix):這是一個二維陣列,每行、每列各對應一個頂點;行頂點與列頂點之間沒有邊時,值為 0,有邊時則為 1。鄰接表適合圖遍歷,鄰接矩陣則適合機器學習(參見 “資料框、矩陣與陣列”)。
在剛才給出的例子中,圖裡的所有頂點都表示同一種事物,分別是人、網頁或道路交叉口。不過,圖並不侷限於這種 同質(homogeneous)資料:圖還有一項同樣強大的用途,就是以一致的方式在單個資料庫中儲存截然不同的物件。例如:
- Facebook 維護著一個包含許多不同型別頂點和邊的圖:頂點表示人、地點、事件、簽到和使用者評論;邊表示哪些人是朋友、某次簽到發生在哪裡、誰評論了哪篇帖子、誰參加了哪場活動,等等 33。
- 搜尋引擎用知識圖譜來記錄查詢中經常出現的組織、人物、地點等實體的事實 34。這些資訊來自對網站的抓取與文字分析;Wikidata 等網站也會以結構化形式釋出圖資料。
有幾種不同但彼此相關的方式,可以用來組織和查詢圖中的資料。本節將討論 屬性圖(property graph)模型(由 Neo4j、Memgraph、KùzuDB 35 等系統實現 36)和 三元組儲存(triple store)模型(由 Datomic、AllegroGraph、Blazegraph 等系統實現)。兩種模型的表達能力相當接近,Amazon Neptune 等圖資料庫還同時支援二者。
我們還將介紹四種圖查詢語言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 對圖查詢的支援。其他圖查詢語言還有 Gremlin 等 37,不過這裡選取的幾種已足以給出一幅有代表性的全景。
為了說明這些語言和模型,本節將以 圖 3-6 為貫穿全節的例子。它可以取自社交網路或家譜資料庫:圖中有兩個人,來自愛達荷州的 Lucy 和來自法國聖洛的 Alain。他們已經結婚,現居倫敦。每個人和每個地點都表示為頂點,彼此之間的關係則表示為邊。這個例子將展示一些在圖資料庫中很容易、在其他模型中卻很難表達的查詢。

屬性圖
在 屬性圖(也稱 帶標籤屬性圖,labeled property graph)模型中,每個頂點包括:
- 唯一識別符號
- 一個標籤(字串),描述該頂點所表示的物件型別
- 一組出邊
- 一組入邊
- 一組屬性(鍵值對)
每條邊包括:
- 唯一識別符號
- 邊的起點(尾部頂點,即 tail vertex)
- 邊的終點(頭部頂點,即 head vertex)
- 一個標籤,描述兩個頂點之間的關係型別
- 一組屬性(鍵值對)
可以把圖儲存看成兩個關係表:一張儲存頂點,另一張儲存邊,如 示例 3-3 所示(該模式使用 PostgreSQL 的 jsonb 資料型別儲存每個頂點或每條邊的屬性)。每條邊都儲存頭部頂點和尾部頂點;如果想找出某個頂點的所有入邊或出邊,可以分別按 head_vertex 或 tail_vertex 查詢 edges 表。
這個模型有幾個重要特點:
- 任何頂點都可以透過邊連線到任何其他頂點。沒有模式限制哪些事物可以關聯,哪些不可以。
- 給定任意頂點,都能高效地找到它的入邊和出邊,從而雙向 遍歷 圖——即沿著一系列頂點構成的路徑前後移動。(這正是 示例 3-3 同時為
tail_vertex和head_vertex列建立索引的原因。) - 為不同型別的頂點和關係使用不同的標籤,就可以在一個圖中儲存多種不同的資訊,同時仍保持清晰的資料模型。
邊表就像我們在 “多對一與多對多關係” 看到的多對多關聯表(連線表),只不過經過了泛化,可以在同一張表中儲存許多不同型別的關係。標籤和屬性也可以建立索引,以便高效查詢具有某種屬性的頂點或邊。
圖模型有一項侷限:一條邊只能關聯兩個頂點,而關係模型中的連線表可以在一行中儲存多個外來鍵引用,從而表示三元甚至更高元的關係。在圖中,可以為連線表的每一行另建一個頂點,再用邊把它與其他頂點相連;也可以使用 超圖 來表示這類關係。
這些特性為資料建模提供了很大的靈活性,如 圖 3-6 所示。圖中有一些傳統關係模式難以表達的事物,例如不同國家採用不同的行政區劃結構(法國有 省 和 大區,美國有 縣 和 州)、國中之國這樣的歷史怪事(暫且忽略主權國家與民族錯綜複雜的關係),以及粒度不一的資料(Lucy 現在的住所具體到城市,而出生地只記錄到州)。
可以想象,這個圖還能夠擴充套件出關於 Lucy、Alain 或其他人的許多事實。例如,可以用它表示食物過敏:為每種過敏原增加一個頂點,用人與過敏原之間的邊表示過敏,再把過敏原連線到一組說明哪些食物含有哪些物質的頂點。這樣就能寫一條查詢,找出每個人可以安全食用的東西。圖在可演化性方面很有優勢:隨著應用程式不斷增加功能,可以輕鬆擴充套件圖來適應資料結構的變化。
Cypher 查詢語言
Cypher 是屬性圖的查詢語言,最初為 Neo4j 圖資料庫而創,後來以 openCypher 之名發展為開放標準 38。除了 Neo4j,Memgraph、KùzuDB 35、Amazon Neptune、Apache AGE(資料儲存在 PostgreSQL 中)等系統也支援 Cypher。它以電影《駭客帝國》中的角色命名,與密碼學中的密碼並無關係 39。
示例 3-4 展示了把 圖 3-6 左側部分插入圖資料庫的 Cypher 查詢,圖的其餘部分也可以用同樣方式加入。每個頂點都有一個 usa 或 idaho 之類的符號名稱。該名稱不會存入資料庫,只在查詢內部用來建立頂點之間的邊。箭頭記法 (idaho) -[:WITHIN]-> (usa) 會建立一條標籤為 WITHIN 的邊,以 idaho 為尾節點、usa 為頭節點。
把 圖 3-6 的所有頂點和邊加入資料庫後,就可以提出一些有趣的問題。例如:找出所有從美國移居歐洲的人的姓名。更確切地說,我們要找出同時具有一條指向美國境內某地的 BORN_IN 邊,以及一條指向歐洲境內某地的 LIVES_IN 邊的頂點,並返回這些頂點的 name 屬性。
示例 3-5 展示了如何用 Cypher 表述這個查詢。MATCH 子句使用同樣的箭頭記法在圖中尋找模式:(person) -[:BORN_IN]-> () 匹配由一條 BORN_IN 邊相連的任意兩個頂點;這條邊的尾部頂點繫結到變數 person,頭部頂點則不命名。
這條查詢可以這樣解讀:
找出滿足以下 兩個 條件的所有頂點(稱為
person):
person頂點有一條指向某個頂點的BORN_IN出邊。從那裡沿著一系列WITHIN出邊前進,最終能到達一個型別為Location、name屬性為"United States"的頂點。- 同一個
person頂點還有一條LIVES_IN出邊。沿著該邊,再沿一系列WITHIN出邊前進,最終能到達一個型別為Location、name屬性為"Europe"的頂點。對每個這樣的
person頂點,返回其name屬性。
執行這條查詢有幾種可行的方式。上面的描述暗示,可以先掃描資料庫中的所有人,逐一檢查出生地和居住地,只返回符合條件的人。
等價地,也可以從兩個 Location 頂點開始反向查詢。如果 name 屬性建有索引,就能高效找到代表美國和歐洲的兩個頂點。然後沿著所有 WITHIN 入邊,分別找出美國和歐洲境內的所有地點(州、地區、城市等)。最後,再沿這些地點頂點的 BORN_IN 或 LIVES_IN 入邊找到相應的人。
SQL 中的圖查詢
示例 3-3 表明,可以在關聯式資料庫中表示圖資料。但是,如果圖資料採用關係結構儲存,還能使用 SQL 查詢它嗎?
答案是肯定的,但有些困難。圖查詢每遍歷一條邊,實際上都相當於與 edges 表連線一次。在關聯式資料庫中,通常事先就知道查詢需要哪些連線;而在圖查詢中,找到目標頂點之前可能要遍歷數量不定的邊,也就是說,連線次數無法預先確定。
在我們的例子中,Cypher 查詢裡的 () -[:WITHIN*0..]-> () 模式便是如此。一個人的 LIVES_IN 邊可能指向任何層級的地點:街道、城市、區、地區、州,等等。城市可能 WITHIN 某個地區,地區又 WITHIN 某個州,州再 WITHIN 某個國家。LIVES_IN 邊可能直接指向待查地點,也可能還隔著好幾層地點層次。
Cypher 用 :WITHIN*0.. 非常簡潔地表達了這一點:“沿著 WITHIN 邊走零次或多次”。它類似於正規表示式中的 * 運算子。
從 SQL:1999 開始,可以用所謂的 遞迴公用表表示式(recursive common table expression,WITH RECURSIVE 語法)在查詢中表示長度可變的遍歷路徑。示例 3-6 用這種技術在 SQL 中寫出了同一個查詢——查詢從美國移居歐洲者的姓名。只不過,與 Cypher 相比,它的語法十分笨拙。
❶:首先找到 name 屬性為 "United States" 的頂點,把它作為 in_usa 頂點集的第一個元素。
❷:從 in_usa 集合中的頂點出發,沿所有 within 入邊反向前進,把到達的頂點加入同一集合,直到所有 within 入邊都被訪問。
❸:從 name 屬性為 "Europe" 的頂點出發,執行同樣的操作,建立 in_europe 頂點集。
❹:對 in_usa 集合中的每個頂點,沿 born_in 入邊找到出生在美國境內某地的人。
❺:同理,對 in_europe 集合中的每個頂點,沿 lives_in 入邊找到居住在歐洲的人。
❻:最後,透過連線讓“出生在美國的人”與“居住在歐洲的人”兩個集合取交集。
同一個查詢用 Cypher 只需 4 行,用 SQL 卻要寫 31 行,這恰恰說明選對資料模型和查詢語言會帶來多大差別。而這還只是開始;還有更多細節需要考慮,例如如何處理環,以及選擇廣度優先還是深度優先遍歷 40。
Oracle 為遞迴查詢提供了另一套 SQL 擴充套件,稱為 層次查詢(hierarchical query)41。
不過,情況可能正在改善:在本書寫作時,已有計劃把一種名為 GQL 的圖查詢語言加入 SQL 標準 42 43,其語法借鑑了 Cypher、GSQL 44 和 PGQL 45。
三元組儲存與 SPARQL
三元組儲存模型大體上與屬性圖模型相同,只是用不同的詞彙描述同樣的思想。不過它仍然值得單獨討論,因為三元組儲存有許多現成的工具和語言,可以成為構建應用程式時的寶貴補充。
在三元組儲存中,所有資訊都以非常簡單的三部分陳述來儲存:(主語、謂語、賓語)。例如,在三元組(Jim、喜歡、香蕉)中,Jim 是主語,喜歡 是謂語(動詞),香蕉 是賓語。
三元組的主語相當於圖中的一個頂點,賓語則是以下兩者之一:
- 字串、數字等原始資料型別的值。這時,三元組的謂語和賓語相當於主語頂點上某項屬性的鍵和值。例如,沿用 圖 3-6 的例子,三元組(lucy、birthYear、1989)相當於頂點
lucy擁有屬性{"birthYear": 1989}。 - 圖中的另一個頂點。這時,謂語相當於圖中的邊,主語是尾部頂點,賓語是頭部頂點。例如,在(lucy、marriedTo、alain)中,lucy 和 alain 都是頂點,謂語 marriedTo 則是連線二者的邊標籤。
示例 3-7 把 示例 3-4 中的同一份資料寫成了三元組,使用的格式稱為 Turtle,它是 Notation3(N3)的一個子集 48。
在這個例子中,圖的頂點寫成 _:someName。名稱在當前檔案之外沒有任何意義;之所以需要它,只是為了分辨哪些三元組引用了同一個頂點。當謂語表示邊時,賓語是另一個頂點,如 _:idaho :within _:usa;當謂語表示屬性時,賓語則是字串字面量,如 _:usa :name "United States"。
一遍遍重複同一個主語顯得相當囉嗦,好在可以用分號連續陳述關於同一主語的多件事。這使 Turtle 格式頗為清晰易讀,如 示例 3-8 所示。
三元組儲存的一部分研究與開發,源於 語義網 的推動。這項始於 21 世紀初的嘗試,希望資料不僅以供人閱讀的網頁釋出,也以標準化、機器可讀的格式釋出,從而促進整個網際網路範圍內的資料交換。最初設想的語義網並未成功 49 50,但這個專案仍留下了若干具體技術:JSON-LD 等 連結資料 標準 51、生物醫學中使用的 本體 52、Facebook 的 Open Graph 協議 53(用於展開連結預覽 54)、Wikidata 等知識圖譜,以及由 schema.org 維護的結構化資料標準詞彙表。
三元組儲存也是一項走出語義網原始場景、在別處找到用武之地的技術:即使你對語義網毫無興趣,三元組仍可以成為很好的應用內部資料模型。
RDF 資料模型
示例 3-8 使用的 Turtle 語言,實際上是對 資源描述框架(RDF,Resource Description Framework)資料進行編碼的一種方式 55;RDF 是專為 語義網(Semantic Web)設計的資料模型。RDF 資料也可以採用其他編碼,例如用更為冗長的 XML 表示,如 示例 3-9 所示。Apache Jena 等工具可以在不同 RDF 編碼之間自動轉換。
RDF 有一些奇特之處,因為它是為網際網路範圍的資料交換而設計的。三元組的主語、謂語和賓語通常都是 URI。例如,謂語可能寫成 <http://my-company.com/namespace#within> 或 <http://my-company.com/namespace#lives_in>,而不只是 WITHIN 或 LIVES_IN。這樣設計是為了讓不同來源的資料可以合併:即使別人賦予 within 或 lives_in 不同含義,也不會發生衝突,因為對方的謂語實際是 <http://other.org/foo#within> 和 <http://other.org/foo#lives_in>。
從 RDF 的角度看,URL <http://my-company.com/namespace> 不一定真能解析出什麼內容,它不過是個名稱空間。為避免與 http:// URL 混淆,本節示例使用 urn:example:within 之類不可解析的 URI。好在只須在檔案開頭宣告一次字首,後面便不必再操心。
SPARQL 查詢語言
SPARQL 是一種面向 RDF 資料模型的三元組儲存查詢語言 56。(它是 SPARQL Protocol and RDF Query Language 的縮寫,讀作 “sparkle”。)SPARQL 早於 Cypher;Cypher 的模式匹配借鑑了 SPARQL,因此兩者看起來十分相似。
之前那條查詢從美國移居歐洲者的查詢,用 SPARQL 表示時和用 Cypher 一樣簡潔(見 示例 3-10)。
二者結構十分相似。下面兩個表示式是等價的(SPARQL 中的變數以問號開頭):
因為 RDF 不區分屬性與邊,而是把二者都當作謂語,所以匹配屬性也可以使用同一種語法。在下面的表示式中,變數 usa 會繫結到任意 name 屬性為字串 "United States" 的頂點:
Amazon Neptune、AllegroGraph、Blazegraph、OpenLink Virtuoso、Apache Jena 以及其他多種三元組儲存都支援 SPARQL 36。
Datalog:遞迴關係查詢
Datalog 是比 SPARQL 和 Cypher 更古老的語言,源自 20 世紀 80 年代的學術研究 57 58 59。它在軟體工程師中不太知名,主流資料庫也很少支援;但它表達能力很強,尤其擅長複雜查詢,理應得到更多關注。Datomic、LogicBlox、CozoDB 以及 LinkedIn 的 LIquid 60 等幾種小眾資料庫,都使用 Datalog 作為查詢語言。
Datalog 實際上基於關係資料模型,而不是圖模型;之所以把它放在圖資料庫一節,是因為 Datalog 尤其擅長對圖進行遞迴查詢。
Datalog 資料庫由 事實 組成,每項事實對應關係表中的一行。假設有一張儲存地點的 location 表,包含 ID、name 和 type 三列;“美國是一個國家”這項事實就可以寫成 location(2, "United States", "country"),其中 2 是美國的 ID。一般來說,table(val1, val2, …) 表示 table 中有這樣一行:第一列為 val1,第二列為 val2,依此類推。
示例 3-11 展示了如何用 Datalog 寫出 圖 3-6 左側的資料。圖中的邊(within、born_in 和 lives_in)表示為兩列的連線表。例如,Lucy 的 ID 是 100,愛達荷州的 ID 是 3,因此“Lucy 出生在愛達荷州”這項關係表示為 born_in(100, 3)。
定義好資料之後,就可以寫出與之前相同的查詢,如 示例 3-12 所示。它看起來與 Cypher 或 SPARQL 中的等價查詢頗為不同,但不必因此望而卻步。Datalog 是 Prolog 的一個子集;如果學過電腦科學,你或許見過這種程式語言。
Cypher 和 SPARQL 一上來便使用 SELECT,Datalog 卻每次只向前邁一小步。我們透過定義 規則,從底層事實派生出新的虛擬表。這些派生表類似於(虛擬的)SQL 檢視:它們並不儲存在資料庫中,卻可以像儲存事實的表一樣接受查詢。
示例 3-12 定義了三個派生表:within_recursive、migrated 和 us_to_europe。每條規則中 :- 符號之前的部分,定義了虛擬表的名稱與各列。例如,migrated(PName, BornIn, LivingIn) 是一張三列表,分別包含姓名、出生地名稱和居住地名稱。
虛擬表的內容由規則中 :- 符號之後的部分定義,它會嘗試在各表中找出匹配特定模式的行。例如,person(PersonID, PName) 可以匹配 person(100, "Lucy") 這一行,此時變數 PersonID 繫結為 100,PName 繫結為 "Lucy"。只要系統能為 :- 右側的 所有 模式找到匹配,規則便可以應用。應用規則的效果,就好像把 :- 左側的內容加入資料庫,並將其中的變數替換為各自匹配的值。
因此,可以按下面的方式應用規則(如 圖 3-7 所示):
- 資料庫中存在
location(1, "North America", "continent"),所以規則 1 可以應用,生成within_recursive(1, "North America")。 - 資料庫中存在
within(2, 1),上一步又生成了within_recursive(1, "North America"),所以規則 2 可以應用,生成within_recursive(2, "North America")。 - 資料庫中存在
within(3, 2),上一步又生成了within_recursive(2, "North America"),所以再次應用規則 2,生成within_recursive(3, "North America")。
反覆應用規則 1 和規則 2,within_recursive 虛擬表就能告訴我們,資料庫中的哪些地點位於北美(或任何其他地點)之內。

圖 3-7. 使用 示例 3-12 中的 Datalog 規則確定愛達荷州在北美。
接下來,規則 3 可以找出出生在 BornIn、現居 LivingIn 的人。規則 4 以 BornIn = 'United States' 和 LivingIn = 'Europe' 呼叫規則 3,只返回符合條件者的姓名。最後查詢虛擬表 us_to_europe,Datalog 系統便會給出與先前 Cypher 和 SPARQL 查詢相同的答案。
Datalog 需要一種不同於本章其他查詢語言的思維方式。它允許逐條規則地搭建複雜查詢,讓一條規則引用其他規則,就像把程式碼拆成彼此呼叫的函式。函式可以遞迴,Datalog 規則也同樣可以呼叫自身;示例 3-12 的規則 2 正是如此,由此實現了 Datalog 查詢中的圖遍歷。
GraphQL
GraphQL 也是一種查詢語言,但它在設計上比本章介紹的其他語言限制更多。GraphQL 的用途,是讓執行在使用者裝置上的客戶端軟體(例如移動應用或 JavaScript Web 應用的前端)請求一份特定結構的 JSON 文件,其中恰好包含渲染使用者介面所需的欄位。藉助 GraphQL 介面,開發者可以迅速修改客戶端程式碼中的查詢,而無須改動服務端 API。
GraphQL 的靈活性並非沒有代價。採用 GraphQL 的組織通常需要一套工具,把 GraphQL 查詢轉換成對內部服務的請求,而這些內部服務往往使用 REST 或 gRPC(參見 第 5 章)。此外還要應對授權、限流和效能等問題 61。GraphQL 查詢來自不受信任的來源,因此查詢語言本身也受到嚴格限制:它不允許任何執行成本可能很高的操作,否則使用者就能大量提交昂貴查詢,對伺服器發動拒絕服務攻擊。具體來說,GraphQL 不允許遞迴查詢(Cypher、SPARQL、SQL 和 Datalog 都允許),也不能隨意給出“查詢出生在美國、現居歐洲的人”這樣的搜尋條件,除非服務所有者特意提供了相應搜尋功能。
儘管如此,GraphQL 仍然很有用。示例 3-13 展示了如何用它實現 Discord 或 Slack 這類群聊應用。查詢請求使用者有權訪問的所有頻道,並取回每個頻道的名稱和最近 50 條訊息。對每條訊息,它請求時間戳、正文,以及傳送者姓名和頭像 URL。若某條訊息是對另一條訊息的回覆,查詢還會請求原訊息的正文與傳送者姓名;介面可以用較小字號把這些內容顯示在回覆上方,作為上下文。
示例 3-14 給出了 示例 3-13 中查詢的一種可能響應。響應是一份與查詢結構相呼應的 JSON 文件:請求了哪些屬性,它就不多不少地返回哪些屬性。這樣一來,伺服器無須預先知道客戶端渲染介面需要什麼;客戶端直接提出所需內容即可。例如,這條查詢沒有請求 replyTo 訊息傳送者的頭像 URL。若介面後來要顯示該頭像,客戶端只需在查詢中加入 imageUrl 屬性,無須修改伺服器。
示例 3-14 把訊息傳送者的姓名和頭像 URL 直接嵌入訊息物件。同一個使用者若傳送多條訊息,這些資訊會在每條訊息中重複。原則上當然可以減少這種重複,但 GraphQL 選擇接受更大的響應,以換取根據資料渲染介面時更加簡單。
replyTo 欄位也是如此:示例 3-14 中的第二條訊息回覆了第一條,因此第一條訊息的內容(“Hey!…”)和傳送者 Aaliyah 又在 replyTo 下重複了一遍。也可以只返回被回覆訊息的 ID,但如果該 ID 不在這次返回的最近 50 條訊息之中,客戶端就得向伺服器再發一次請求。直接複製內容,處理起來簡單得多。
伺服器端資料庫可以用更加正規化的形式儲存資料,並在處理查詢時執行必要的連線。例如,伺服器可以把訊息正文與傳送者的使用者 ID、被回覆訊息的 ID 存在一起;收到上述查詢時,再解析這些 ID,找出它們引用的記錄。不過,客戶端只能要求伺服器執行 GraphQL 模式中明確開放的連線。
儘管 GraphQL 的響應看起來很像文件資料庫返回的結果,而且名稱中還帶有 “graph”,它其實可以構建在任何資料庫之上,無論是關聯式資料庫、文件資料庫還是圖資料庫。
事件溯源與 CQRS
在我們迄今討論過的所有資料模型中,資料都以寫入時的形式接受查詢——無論它是 JSON 文件、表中的行,還是圖中的頂點和邊。然而在複雜應用中,有時很難找到一種資料表示,能夠滿足所有查詢和展現資料的需求。在這種情況下,可以用一種形式寫入資料,再從中派生出多種針對不同讀取方式最佳化的表示。
我們在 “權威記錄系統與衍生資料” 中已經見過這種思路,ETL(參見 “資料倉儲”)就是一種派生過程。現在讓我們再往前走一步。既然無論如何都要由一種資料表示派生出另一種,那就可以分別選用針對寫入和讀取最佳化的表示。如果只需最佳化資料寫入,絲毫不必考慮查詢效率,你會如何對資料建模?
也許,寫入資料最簡單、最快且表意最清楚的方式,就是寫入 事件日誌(event log):每次寫入資料時,都將它編碼成一個自包含的字串(也許是 JSON),其中帶有時間戳,再追加到事件序列中。日誌中的事件是 不可變的(immutable):你永遠不會修改或刪除它們,只會向日志追加更多事件(後來的事件可以取代早先事件的效力)。事件可以包含任意屬性。
圖 3-8 給出了一個可能來自會議管理系統的例子。會議管理是一個複雜的業務領域:不僅個人參會者可以報名並用信用卡付款,企業也可以批次預訂座位,以發票結算,再把座位分配給個人。演講者、贊助商和志願者等人可能要佔用一些預留座位。預訂還可能取消;與此同時,會議組織者又可能因為更換場地,而改變活動的容量。這些事情疊加在一起,哪怕只是計算還有多少空餘座位,也會變成一項頗具挑戰的查詢。

在 圖 3-8 中,會議狀態的每次變化(例如組織者開放報名,或參會者報名和取消報名),首先都會被儲存為事件。每當日誌追加一個事件,幾個 物化檢視(materialized view,也稱為 投影,projection,或 讀模型,read model)也會隨之更新,以反映該事件帶來的影響。在這個會議示例中,可以有一個物化檢視彙總每筆預訂狀態的所有相關資訊,另一個計算會議組織者儀表盤所需的圖表,第三個則為製作參會者胸牌的印表機生成檔案。
以事件作為權威資料來源,並把每次狀態變化都表達為事件,這種思路稱為 事件溯源(event sourcing)62 63。維護獨立的讀取最佳化表示,並從寫入最佳化的表示中派生它們,這種原則稱為 命令查詢責任分離(Command Query Responsibility Segregation,CQRS)64。這些術語源自領域驅動設計(DDD)社群,不過類似的思路由來已久,例如 狀態機複製(state machine replication;參見 “使用共享日誌”)。
當來自使用者的請求剛到達時,它還是一個 命令(command),首先需要驗證。只有在命令已經執行且確認有效之後(例如,請求的預訂有足夠的空餘座位),它才會成為事實,相應的事件也才會追加到日誌中。因此,事件日誌中應當只有有效事件;消費事件日誌來構建物化檢視的元件,不允許拒絕事件。
以事件溯源的方式對資料建模時,建議用過去時來命名事件(例如“座位已預訂”),因為事件記錄的是已經發生的事實。即使使用者後來更改或取消預訂,他們曾經預訂過的事實依然成立;更改或取消是之後另行追加的事件。
事件溯源與星型模式的事實表(參見 “星型與雪花型:分析模式”)有一個相似之處:它們都是過去所發生事件的集合。不過,事實表中每一行的列都相同,事件溯源中則可以有許多型別不同、屬性各異的事件。此外,事實表是無序集合,而事件溯源中事件的先後順序很重要:如果一筆預訂先成立、後取消,顛倒順序處理這兩個事件就毫無意義。
事件溯源和 CQRS 有幾個優點:
- 對系統開發者來說,事件能更清楚地表達某件事 為什麼 發生。例如,理解“預訂已取消”這個事件,要比理解“
bookings表第 4001 行的active列已設為false,seat_assignments表中與該預訂相關的三行已被刪除,payments表中又插入了一行表示退款”容易得多。物化檢視處理取消事件時,依然可能會對這些行執行修改;但由事件驅動這些更新,其原因就清楚得多。 - 事件溯源的一項關鍵原則,是物化檢視應以可重現的方式從事件日誌中派生:你應當隨時能夠刪除物化檢視,然後用同一套程式碼,按同樣的順序處理同樣的事件,從而重新計算出檢視。如果檢視維護程式碼存在錯誤,只需刪除檢視,再用修正後的程式碼重算。錯誤也更容易查詢,因為你可以反覆重新執行檢視維護程式碼,並檢查它的行為。
- 你可以維護多個物化檢視,分別針對應用所需的特定查詢進行最佳化。它們可以與事件儲存在同一個資料庫中,也可以根據需要存入不同的資料庫。這些檢視可以使用任何資料模型,也可以透過反正規化來加快讀取。只要服務重啟時可以從事件日誌重算檢視,甚至可以只把它保留在記憶體中,根本不做持久化。
- 如果決定以新方式展現現有資訊,可以很容易地根據現有事件日誌構建新的物化檢視。新增新的事件型別,或給現有事件型別新增新屬性(舊事件保持不變),就能讓系統演化並支援新功能。還可以在現有事件後觸發新的行為,例如參會者取消預訂後,將座位提供給等候名單中的下一個人。
- 如果誤寫了某個事件,可以把它刪除,再重建不包含該事件的檢視。相比之下,在直接更新和刪除資料的資料庫中,已經提交的事務往往很難撤銷。因此,事件溯源可以減少系統中不可逆操作的數量,讓變更更容易(參見 “可演化性:讓變化更容易”)。
- 事件日誌還可以作為審計日誌,記錄系統中發生的一切。在必須提供審計能力的受監管行業中,這一點很有價值。
然而,事件溯源和 CQRS 也有缺點:
- 涉及外部資訊時必須小心。例如,假設事件中有一個以某種貨幣計價的價格,而某個檢視需要把它兌換成另一種貨幣。由於匯率會波動,處理事件時再從外部資料來源獲取匯率就有問題:換一天重新計算物化檢視,得到的結果就可能不同。為了讓事件處理邏輯具有確定性,要麼把匯率寫入事件本身,要麼提供一種方法,能查詢事件時間戳所對應的歷史匯率,並確保同一時間戳始終返回同一結果。
- 事件不可變這一要求,在事件包含使用者個人資料時會帶來問題,因為使用者可能行使自己的權利(例如 GDPR 規定的權利),要求刪除個人資料。如果每個使用者各有一份事件日誌,只需刪除該使用者的整份日誌;但如果日誌中的事件關係到多個使用者,這種做法就行不通。可以嘗試把個人資料存在事件之外,或者用一把金鑰加密,以便日後刪除金鑰;但這也會讓按需重算派生狀態變得更困難。
- 如果處理事件會帶來外部可見的副作用,那麼重新處理事件時必須小心。例如,你大概不會希望每次重建物化檢視時,都再發一遍確認郵件。
事件溯源可以構建在任何資料庫之上,不過也有一些系統是專為這種模式設計的,例如 EventStoreDB、MartenDB(基於 PostgreSQL)和 Axon Framework。也可以使用 Apache Kafka 之類的訊息代理來儲存事件日誌,並透過流處理器使物化檢視保持最新;我們將在 “變更資料捕獲與事件溯源” 中再次討論這些主題。
唯一一項重要要求是:事件儲存系統必須保證,所有物化檢視都以事件在日誌中出現的順序處理它們。正如我們將在 第 10 章 中看到的,這在分散式系統中並不總是一件容易的事。
資料框、矩陣與陣列
本章迄今介紹的資料模型,通常既用於事務處理,也用於分析(參見 “分析型與事務型系統”)。還有一些資料模型常見於分析或科學場景,卻很少出現在 OLTP 系統中:資料框(dataframe),以及矩陣等多維數值陣列。
R 語言、Python 的 pandas 庫、Apache Spark、ArcticDB 和 Dask 等系統,都支援資料框這種資料模型。資料科學家經常用它為訓練機器學習模型準備資料;它也廣泛用於資料探索、統計分析和資料視覺化等場景。
乍看之下,資料框很像關聯式資料庫中的表,也像電子表格。它支援一系列類似關係運算子的批次操作:例如,對所有行應用某個函式,按條件篩選行,按某些列分組並聚合其他列,以及按某個鍵連線兩個資料框中的行(關聯式資料庫中的 連線,在資料框中通常稱為 合併,merge)。
資料框通常不是透過 SQL 之類的宣告式查詢來操作,而是透過一系列命令逐步修改其結構和內容。這恰好符合資料科學家的典型工作流程:一點點地“整理”資料,直至它變成一種適合回答當前問題的形式。這些操作通常在資料科學傢俬有的資料集副本上進行,而且往往就在本機上;不過最終結果也可能會分享給其他使用者。
資料框 API 提供的許多操作遠遠超出關聯式資料庫的能力,其使用方式也往往與典型的關係資料建模大不相同 65。例如,資料框的一種常見用途,是把資料從類似關係模型的表示轉換為矩陣或多維陣列,而許多機器學習演算法期望的輸入正是這種形式。
圖 3-9 展示了一個簡單的轉換示例。左側是一張關係表,記錄不同使用者給各種電影打出的分數(1 到 5 分);右側則把這些資料轉換成了矩陣,每一列代表一部電影,每一行代表一位使用者(類似電子表格中的 資料透視表,pivot table)。這個矩陣是 稀疏(sparse)的,也就是說,很多使用者與電影的組合都沒有資料,但這並不礙事。矩陣可能有成千上萬列,不太適合放在關聯式資料庫中;資料框以及 Python 的 NumPy 等支援稀疏陣列的庫,卻能輕鬆處理這類資料。

矩陣只能包含數字,因此需要用各種技術把非數值資料轉換為矩陣中的數字。例如:
- 日期(圖 3-9 的示例矩陣中省略了日期)可以按比例縮放為某個合適範圍內的浮點數。
- 對於只能從一小組固定值中取值的列(例如電影資料庫中的電影型別),通常採用 獨熱編碼(one-hot encoding):為每個可能的值建立一列(“喜劇”一列、“劇情”一列、“恐怖”一列,依此類推);對於代表某部電影的每一行,在對應其型別的列中填 1,其餘列填 0。這種表示也很容易推廣到同時屬於多種型別的電影。
資料一旦變成數值矩陣,就適合進行線性代數運算,而線性代數正是許多機器學習演算法的基礎。例如,圖 3-9 中的資料可以用在向使用者推薦其可能喜歡的電影的系統中。資料框十分靈活,可以讓資料從關係形式逐步演變為矩陣表示,同時讓資料科學家自行掌控哪種表示最適合達成資料分析或模型訓練的目標。
還有一些資料庫專門儲存大型多維數值陣列,例如 TileDB 66。這類系統稱為 陣列資料庫(array database),最常用於科學資料集,例如地理空間測量資料(規則間隔網格上的柵格資料)、醫學影像或天文望遠鏡的觀測結果 67。金融行業也用資料框表示 時間序列資料(time-series data),例如資產價格以及按時間記錄的交易 68。
總結
資料模型是一個巨大的課題,本章只是快速瀏覽了各種不同的模型。我們沒有足夠的篇幅詳述每個模型,但希望這份概覽足以引起你的興趣,促使你進一步瞭解最適合應用需求的模型。
關係模型 儘管已有半個多世紀的歷史,但對許多應用來說仍然是一種重要的資料模型——尤其是在資料倉儲和商業分析領域,關係型星型或雪花型模式與 SQL 查詢無處不在。不過,關係資料的幾種替代方案也在其他領域流行起來:
- 文件模型 主要關注自包含的 JSON 資料文件,而且文件之間的關係非常稀少。
- 圖資料模型 用於相反的場景:任意事物都可能與其他一切事物相關,查詢可能需要跨越多跳才能找到感興趣的資料(這類查詢可以用 Cypher、SPARQL 或 Datalog 中的遞迴查詢來表達)。
- 資料框 把關係資料推廣到擁有大量列的情形,在資料庫與多維陣列之間架起了橋樑;而多維陣列正是許多機器學習、統計分析和科學計算的基礎。
一個模型可以用另一個模型來模擬——例如,圖資料可以在關聯式資料庫中表示——但結果往往很彆扭,正如 SQL 對遞迴查詢的支援所表明的那樣。
因此,人們為每種資料模型開發了各式各樣的專用資料庫,提供針對該模型最佳化的查詢語言和儲存引擎。另一方面,資料庫也在不斷加入對其他資料模型的支援,向相鄰領域擴充套件:例如,關聯式資料庫透過 JSON 列支援文件資料,文件資料庫加入類似關係模型的連線,而 SQL 對圖資料的支援也在逐步改善。
我們還討論了 事件溯源:它把資料表示為不可變事件的僅追加日誌,這種形式可能很適合為複雜業務領域中的活動建模。僅追加日誌有利於資料寫入(正如我們將在 第 4 章 中看到的);為了支援高效查詢,CQRS 會把事件日誌轉換為針對讀取最佳化的物化檢視。
非關係資料模型的一個共同點是,它們通常不會將儲存的資料強制約束為特定模式,這可以使應用更容易適應不斷變化的需求。但是應用很可能仍會假定資料具有一定的結構;區別僅在於模式是 明確的(寫入時強制)還是 隱含的(讀取時假定)。
雖然我們已經覆蓋了很多層面,但仍有一些資料模型沒有提到。舉幾個簡單的例子:
- 研究基因組資料的研究人員通常需要執行 序列相似性搜尋,這意味著取一個很長的字串(代表一個 DNA 分子),在一個包含大量相似但不完全相同的字串的資料庫中尋找匹配。這裡描述的資料庫都不能處理這種用法,這就是研究人員編寫了 GenBank 69 等專用基因組資料庫軟體的原因。
- 許多金融系統以採用複式記賬法的 賬本 作為資料模型。這類資料可以用關聯式資料庫表示,但也有 TigerBeetle 這樣專攻此類資料模型的資料庫。加密貨幣和區塊鏈通常基於分散式賬本,並把價值轉移也內建在資料模型之中。
- 全文檢索 可以說是一種經常與資料庫配合使用的資料模型。資訊檢索是一個很大的專業課題,本書不會深入介紹,但我們將在 “全文檢索” 中談到搜尋索引與向量搜尋。
我們暫且說到這裡。在下一章中,我們將討論在 實現 本章描述的資料模型時會遇到的一些權衡。
參考文獻
Jamie Brandon. Unexplanations: query optimization works because sql is declarative. scattered-thoughts.net, February 2024. Archived at perma.cc/P6W2-WMFZ ↩︎
Joseph M. Hellerstein. The Declarative Imperative: Experiences and Conjectures in Distributed Logic. Tech report UCB/EECS-2010-90, Electrical Engineering and Computer Sciences, University of California at Berkeley, June 2010. Archived at perma.cc/K56R-VVQM ↩︎
Edgar F. Codd. A Relational Model of Data for Large Shared Data Banks. Communications of the ACM, volume 13, issue 6, pages 377–387, June 1970. doi:10.1145/362384.362685 ↩︎ ↩︎
Michael Stonebraker and Joseph M. Hellerstein. What Goes Around Comes Around. In Readings in Database Systems, 4th edition, MIT Press, pages 2–41, 2005. ISBN: 9780262693141 ↩︎
Markus Winand. Modern SQL: Beyond Relational. modern-sql.com, 2015. Archived at perma.cc/D63V-WAPN ↩︎
Martin Fowler. OrmHate. martinfowler.com, May 2012. Archived at perma.cc/VCM8-PKNG ↩︎
Vlad Mihalcea. N+1 query problem with JPA and Hibernate. vladmihalcea.com, January 2023. Archived at perma.cc/79EV-TZKB ↩︎
Jens Schauder. This is the Beginning of the End of the N+1 Problem: Introducing Single Query Loading. spring.io, August 2023. Archived at perma.cc/6V96-R333 ↩︎
William Zola. 6 Rules of Thumb for MongoDB Schema Design. mongodb.com, June 2014. Archived at perma.cc/T2BZ-PPJB ↩︎ ↩︎
Sidney Andrews and Christopher McClister. Data modeling in Azure Cosmos DB. learn.microsoft.com, February 2023. Archived at archive.org ↩︎
Raffi Krikorian. Timelines at Scale. At QCon San Francisco, November 2012. Archived at perma.cc/V9G5-KLYK ↩︎ ↩︎
Ralph Kimball and Margy Ross. The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, 3rd edition. John Wiley & Sons, July 2013. ISBN: 9781118530801 ↩︎ ↩︎
Michael Kaminsky. Data warehouse modeling: Star schema vs. OBT. fivetran.com, August 2022. Archived at perma.cc/2PZK-BFFP ↩︎
Joe Nelson. User-defined Order in SQL. begriffs.com, March 2018. Archived at perma.cc/GS3W-F7AD ↩︎
Evan Wallace. Realtime Editing of Ordered Sequences. figma.com, March 2017. Archived at perma.cc/K6ER-CQZW ↩︎
David Greenspan. Implementing Fractional Indexing. observablehq.com, October 2020. Archived at perma.cc/5N4R-MREN ↩︎
Martin Fowler. Schemaless Data Structures. martinfowler.com, January 2013. ↩︎
Amr Awadallah. Schema-on-Read vs. Schema-on-Write. At Berkeley EECS RAD Lab Retreat, Santa Cruz, CA, May 2009. Archived at perma.cc/DTB2-JCFR ↩︎
Martin Odersky. The Trouble with Types. At Strange Loop, September 2013. Archived at perma.cc/85QE-PVEP ↩︎
Conrad Irwin. MongoDB—Confessions of a PostgreSQL Lover. At HTML5DevConf, October 2013. Archived at perma.cc/C2J6-3AL5 ↩︎
Percona Toolkit Documentation: pt-online-schema-change. docs.percona.com, 2023. Archived at perma.cc/9K8R-E5UH ↩︎
Shlomi Noach. gh-ost: GitHub’s Online Schema Migration Tool for MySQL. github.blog, August 2016. Archived at perma.cc/7XAG-XB72 ↩︎
Shayon Mukherjee. pg-osc: Zero downtime schema changes in PostgreSQL. shayon.dev, February 2022. Archived at perma.cc/35WN-7WMY ↩︎
Carlos Pérez-Aradros Herce. Introducing pgroll: zero-downtime, reversible, schema migrations for Postgres. xata.io, October 2023. Archived at archive.org ↩︎
James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012. ↩︎
Donald K. Burleson. Reduce I/O with Oracle Cluster Tables. dba-oracle.com. Archived at perma.cc/7LBJ-9X2C ↩︎
Fay Chang, Jeffrey Dean, Sanjay Ghemawat, Wilson C. Hsieh, Deborah A. Wallach, Mike Burrows, Tushar Chandra, Andrew Fikes, and Robert E. Gruber. Bigtable: A Distributed Storage System for Structured Data. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006. ↩︎
Priscilla Walmsley. XQuery, 2nd Edition. O’Reilly Media, December 2015. ISBN: 9781491915080 ↩︎
Paul C. Bryan, Kris Zyp, and Mark Nottingham. JavaScript Object Notation (JSON) Pointer. RFC 6901, IETF, April 2013. ↩︎
Stefan Gössner, Glyn Normington, and Carsten Bormann. JSONPath: Query Expressions for JSON. RFC 9535, IETF, February 2024. ↩︎
Michael Stonebraker and Andrew Pavlo. What Goes Around Comes Around… And Around…. ACM SIGMOD Record, volume 53, issue 2, pages 21–37. doi:10.1145/3685980.3685984 ↩︎
Lawrence Page, Sergey Brin, Rajeev Motwani, and Terry Winograd. The PageRank Citation Ranking: Bringing Order to the Web. Technical Report 1999-66, Stanford University InfoLab, November 1999. Archived at perma.cc/UML9-UZHW ↩︎
Nathan Bronson, Zach Amsden, George Cabrera, Prasad Chakka, Peter Dimov, Hui Ding, Jack Ferris, Anthony Giardullo, Sachin Kulkarni, Harry Li, Mark Marchukov, Dmitri Petrov, Lovro Puzar, Yee Jiun Song, and Venkat Venkataramani. TAO: Facebook’s Distributed Data Store for the Social Graph. At USENIX Annual Technical Conference (ATC), June 2013. ↩︎
Natasha Noy, Yuqing Gao, Anshu Jain, Anant Narayanan, Alan Patterson, and Jamie Taylor. Industry-Scale Knowledge Graphs: Lessons and Challenges. Communications of the ACM, volume 62, issue 8, pages 36–43, August 2019. doi:10.1145/3331166 ↩︎
Xiyang Feng, Guodong Jin, Ziyi Chen, Chang Liu, and Semih Salihoğlu. KÙZU Graph Database Management System. At 3th Annual Conference on Innovative Data Systems Research (CIDR 2023), January 2023. ↩︎ ↩︎
Maciej Besta, Emanuel Peter, Robert Gerstenberger, Marc Fischer, Michał Podstawski, Claude Barthels, Gustavo Alonso, Torsten Hoefler. Demystifying Graph Databases: Analysis and Taxonomy of Data Organization, System Designs, and Graph Queries. arxiv.org, October 2019. ↩︎ ↩︎
Apache TinkerPop 3.6.3 Documentation. tinkerpop.apache.org, May 2023. Archived at perma.cc/KM7W-7PAT ↩︎
Nadime Francis, Alastair Green, Paolo Guagliardo, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Stefan Plantikow, Mats Rydberg, Petra Selmer, and Andrés Taylor. Cypher: An Evolving Query Language for Property Graphs. At International Conference on Management of Data (SIGMOD), pages 1433–1445, May 2018. doi:10.1145/3183713.3190657 ↩︎
Emil Eifrem. Twitter correspondence, January 2014. Archived at perma.cc/WM4S-BW64 ↩︎
Francesco Tisiot. Explore the new SEARCH and CYCLE features in PostgreSQL® 14. aiven.io, December 2021. Archived at perma.cc/J6BT-83UZ ↩︎
Gaurav Goel. Understanding Hierarchies in Oracle. towardsdatascience.com, May 2020. Archived at perma.cc/5ZLR-Q7EW ↩︎
Alin Deutsch, Nadime Francis, Alastair Green, Keith Hare, Bei Li, Leonid Libkin, Tobias Lindaaker, Victor Marsault, Wim Martens, Jan Michels, Filip Murlak, Stefan Plantikow, Petra Selmer, Oskar van Rest, Hannes Voigt, Domagoj Vrgoč, Mingxi Wu, and Fred Zemke. Graph Pattern Matching in GQL and SQL/PGQ. At International Conference on Management of Data (SIGMOD), pages 2246–2258, June 2022. doi:10.1145/3514221.3526057 ↩︎
Alastair Green. SQL… and now GQL. opencypher.org, September 2019. Archived at perma.cc/AFB2-3SY7 ↩︎
Alin Deutsch, Yu Xu, and Mingxi Wu. Seamless Syntactic and Semantic Integration of Query Primitives over Relational and Graph Data in GSQL. tigergraph.com, November 2018. Archived at perma.cc/JG7J-Y35X ↩︎
Oskar van Rest, Sungpack Hong, Jinha Kim, Xuming Meng, and Hassan Chafi. PGQL: a property graph query language. At 4th International Workshop on Graph Data Management Experiences and Systems (GRADES), June 2016. doi:10.1145/2960414.2960421 ↩︎
Amazon Web Services. Neptune Graph Data Model. Amazon Neptune User Guide, docs.aws.amazon.com. Archived at perma.cc/CX3T-EZU9 ↩︎
Cognitect. Datomic Data Model. Datomic Cloud Documentation, docs.datomic.com. Archived at perma.cc/LGM9-LEUT ↩︎
David Beckett and Tim Berners-Lee. Turtle – Terse RDF Triple Language. W3C Team Submission, March 2011. ↩︎
Sinclair Target. Whatever Happened to the Semantic Web? twobithistory.org, May 2018. Archived at perma.cc/M8GL-9KHS ↩︎
Gavin Mendel-Gleason. The Semantic Web is Dead – Long Live the Semantic Web! terminusdb.com, August 2022. Archived at perma.cc/G2MZ-DSS3 ↩︎
Manu Sporny. JSON-LD and Why I Hate the Semantic Web. manu.sporny.org, January 2014. Archived at perma.cc/7PT4-PJKF ↩︎
University of Michigan Library. Biomedical Ontologies and Controlled Vocabularies, guides.lib.umich.edu/ontology. Archived at perma.cc/Q5GA-F2N8 ↩︎
Facebook. The Open Graph protocol, ogp.me. Archived at perma.cc/C49A-GUSY ↩︎
Matt Haughey. Everything you ever wanted to know about unfurling but were afraid to ask /or/ How to make your site previews look amazing in Slack. medium.com, November 2015. Archived at perma.cc/C7S8-4PZN ↩︎
W3C RDF Working Group. Resource Description Framework (RDF). w3.org, February 2004. ↩︎
Steve Harris, Andy Seaborne, and Eric Prud’hommeaux. SPARQL 1.1 Query Language. W3C Recommendation, March 2013. ↩︎
Todd J. Green, Shan Shan Huang, Boon Thau Loo, and Wenchao Zhou. Datalog and Recursive Query Processing. Foundations and Trends in Databases, volume 5, issue 2, pages 105–195, November 2013. doi:10.1561/1900000017 ↩︎
Stefano Ceri, Georg Gottlob, and Letizia Tanca. What You Always Wanted to Know About Datalog (And Never Dared to Ask). IEEE Transactions on Knowledge and Data Engineering, volume 1, issue 1, pages 146–166, March 1989. doi:10.1109/69.43410 ↩︎
Serge Abiteboul, Richard Hull, and Victor Vianu. Foundations of Databases. Addison-Wesley, 1995. ISBN: 9780201537710, available online at webdam.inria.fr/Alice ↩︎
Scott Meyer, Andrew Carter, and Andrew Rodriguez. LIquid: The soul of a new graph database, Part 2. engineering.linkedin.com, September 2020. Archived at perma.cc/K9M4-PD6Q ↩︎
Matt Bessey. Why, after 6 years, I’m over GraphQL. bessey.dev, May 2024. Archived at perma.cc/2PAU-JYRA ↩︎
Dominic Betts, Julián Domínguez, Grigori Melnik, Fernando Simonazzi, and Mani Subramanian. Exploring CQRS and Event Sourcing. Microsoft Patterns & Practices, July 2012. ISBN: 1621140164, archived at perma.cc/7A39-3NM8 ↩︎
Greg Young. CQRS and Event Sourcing. At Code on the Beach, August 2014. ↩︎
Greg Young. CQRS Documents. cqrs.wordpress.com, November 2010. Archived at perma.cc/X5R6-R47F ↩︎
Devin Petersohn, Stephen Macke, Doris Xin, William Ma, Doris Lee, Xiangxi Mo, Joseph E. Gonzalez, Joseph M. Hellerstein, Anthony D. Joseph, and Aditya Parameswaran. Towards Scalable Dataframe Systems. Proceedings of the VLDB Endowment, volume 13, issue 11, pages 2033–2046. doi:10.14778/3407790.3407807 ↩︎
Stavros Papadopoulos, Kushal Datta, Samuel Madden, and Timothy Mattson. The TileDB Array Data Storage Manager. Proceedings of the VLDB Endowment, volume 10, issue 4, pages 349–360, November 2016. doi:10.14778/3025111.3025117 ↩︎
Florin Rusu. Multidimensional Array Data Management. Foundations and Trends in Databases, volume 12, numbers 2–3, pages 69–220, February 2023. doi:10.1561/1900000069 ↩︎
Ed Targett. Bloomberg, Man Group team up to develop open source “ArcticDB” database. thestack.technology, March 2023. Archived at perma.cc/M5YD-QQYV ↩︎
Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, James Ostell, and David L. Wheeler. GenBank. Nucleic Acids Research, volume 36, database issue, pages D25–D30, December 2007. doi:10.1093/nar/gkm929 ↩︎
4 儲存與檢索

人生的苦惱之一,就是每個人給事物起的名字總有那麼一點不對。於是,世上的一切都變得比換個名字時更難理解。計算機最主要的用途,並不是通常所謂做算術的“計算”。[……] 它們主要是檔案系統。
理查德・費曼, 特立獨行的思考 研討會(1985 年)
一個資料庫在最基礎的層次上需要完成兩件事情:當你把資料交給資料庫時,它應當把資料儲存起來;而後當你向資料庫要資料時,它應當把資料返回給你。
在 第 3 章 中,我們討論了資料模型和查詢語言,即你將資料交給資料庫時採用的格式,以及日後向資料庫取回資料時使用的介面。在本章中,我們會從資料庫的視角來討論同樣的問題:資料庫如何儲存我們提供的資料,以及如何在我們需要時重新找到資料。
作為應用開發者,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會從頭開始實現自己的 儲存引擎(storage engine),但是你 確實 需要從許多可用的儲存引擎中選擇一個適合應用的。為了讓儲存引擎能在你的工作負載上執行良好,你也需要大致瞭解它在底層究竟做了什麼。
尤其需要注意,針對事務型工作負載(OLTP)最佳化的儲存引擎,與針對分析型工作負載最佳化的儲存引擎之間存在巨大差異(這種區別已在 “分析型與事務型系統” 中介紹)。本章首先考察 OLTP 儲存引擎的兩大類:寫出不可變資料檔案的 日誌結構(log-structured)儲存引擎,以及像 B 樹 這樣就地更新資料的儲存引擎。鍵值儲存(key-value store)和 二級索引(secondary index)都可以採用這兩類結構。
稍後在 “分析型資料儲存” 中,我們會討論一類針對分析最佳化的儲存引擎;在 “多維索引與全文索引” 中,還會簡要介紹用於文字檢索等複雜查詢的索引。
OLTP 系統的儲存與索引
世界上最簡單的資料庫可以用兩個 Bash 函式實現:
這兩個函式實現了鍵值儲存。呼叫 db_set key value,會將 key 和 value 存入資料庫。鍵和值(幾乎)可以是任意內容,例如值可以是一個 JSON 文件。隨後呼叫 db_get key,就會查詢與這個鍵關聯的最新值並將其返回。
麻雀雖小,五臟俱全:
底層儲存格式非常簡單:一個文字檔案,每行包含一條以逗號分隔的鍵值對(忽略轉義問題的話,大致與 CSV 檔案類似)。每次呼叫 db_set 都會向檔案末尾追加記錄。多次更新一個鍵時,舊版本的值不會被覆蓋——因而要找到最新值,就必須檢視這個鍵在檔案中最後一次出現的位置(所以 db_get 中使用了 tail -n 1):
db_set 函式對於如此簡單的實現其實有著相當不錯的效能,因為在檔案末尾追加寫入通常非常高效。與 db_set 所做的事情類似,許多資料庫在內部使用 日誌(log),也就是僅追加的資料檔案。真正的資料庫還要處理更多問題(例如併發寫入、回收磁碟空間以免日誌無限增長,以及崩潰恢復時處理只寫了一部分的記錄),但基本原理是一樣的。日誌極其有用,我們還會在本書中多次遇到它。
日誌 這個詞通常指應用日誌,即應用程式輸出的、描述正在發生之事的文字。本書在更普遍的意義上使用 日誌 一詞:磁碟上僅追加的記錄序列。它不一定供人閱讀,也可能採用二進位制格式,只供資料庫系統內部使用。
另一方面,如果資料庫中有大量記錄,db_get 函式的效能就會非常糟糕。每次查詢一個鍵,db_get 都必須從頭到尾掃描整個資料庫檔案,尋找這個鍵。用演算法的語言來說,查詢開銷是 O(n):如果資料庫中的記錄數 n 翻了一倍,查詢時間也要翻一倍。這就不好了。
為了高效查詢資料庫中特定鍵的值,我們需要一種資料結構:索引(index)。本章將介紹一系列索引結構,並比較它們之間的差異。索引背後的大致思想,是以某種特定方式組織資料(例如按某個鍵排序),從而更快地定位想要的資料。如果想以幾種不同的方式搜尋同一份資料,那麼也許需要在資料的不同部分建立多個索引。
索引是從主資料衍生出的 額外 結構。許多資料庫允許新增和刪除索引,這不會影響資料庫的內容,只會影響查詢效能。維護額外結構會產生開銷,特別是在寫入時。寫入效能很難超過簡單地向檔案末尾追加,因為追加是最簡單的寫入操作。任何型別的索引通常都會拖慢寫入速度,因為每次寫入資料時還必須更新索引。
這是儲存系統中一項重要的權衡:精心選擇的索引能加快讀查詢,但每個索引都會佔用額外的磁碟空間,並拖慢寫入速度,有時影響還相當顯著 1。因此,資料庫通常不會預設索引所有內容,而是要求編寫應用程式或管理資料庫的人,根據自己對典型查詢模式的瞭解手動選擇索引。這樣便可以選出給應用帶來最大收益的索引,同時避免不必要的寫入開銷。
日誌結構儲存
首先假設你仍想把資料儲存在 db_set 寫入的僅追加檔案中,只是希望加快讀取速度。最簡單的索引策略是保留一個記憶體中的雜湊對映,其中每個鍵都對映到資料檔案裡的一個位元組偏移量,指明該鍵最新的值位於何處,如 圖 4-1 所示。

每當向檔案追加新的鍵值對時,還要更新雜湊對映,使它指向剛剛寫入的資料。查詢一個值時,先用雜湊對映找到日誌檔案中的偏移量,再尋道至該位置讀取即可。如果資料檔案的這一部分已經在檔案系統快取中,讀取甚至完全不需要磁碟 I/O。
這種方法快得多,但仍有幾個問題:
- 被新值覆蓋的舊日誌條目仍佔著磁碟空間,始終得不到釋放;只要繼續寫入資料庫,磁碟空間遲早會耗盡。
- 雜湊對映沒有持久化,資料庫重啟時必須重建。例如,可以掃描整個日誌檔案,找出每個鍵最新的位元組偏移量。如果資料量很大,重啟就會十分緩慢。
- 雜湊表必須能放進記憶體。原則上可以在磁碟上維護雜湊表,可惜磁碟雜湊表很難有良好的效能:它需要大量隨機訪問 I/O,裝滿後的擴容代價很高,而且解決雜湊衝突需要煩瑣的邏輯 2。
- 範圍查詢效率不高。例如,無法輕鬆掃描
10000到19999之間的所有鍵,只能在雜湊對映中逐個查詢。
SSTable 檔案格式
實踐中,資料庫索引很少採用雜湊表,更常見的做法是把資料儲存在 按鍵排序 的結構中 3。排序字串表(Sorted String Table,簡稱 SSTable)便是一例,如 圖 4-2 所示。這種檔案格式同樣儲存鍵值對,但保證鍵值對按鍵排序,而且每個鍵在檔案中只出現一次。

這樣便不必在記憶體中保留所有鍵。可以把 SSTable 中的鍵值對分成若干個幾千位元組大小的 塊(block),索引只儲存每個塊的第一個鍵。這種只收錄部分鍵的索引稱為 稀疏索引(sparse index)。索引儲存在 SSTable 的一個獨立區域中,可以採用不可變 B 樹、字典樹或其他能快速查詢特定鍵的資料結構 4。
以 圖 4-2 為例,一個塊的第一個鍵是 handbag,下一個塊的第一個鍵是 handsome。假設要查詢沒有出現在稀疏索引中的 handiwork。根據排序關係可知,handiwork 必定在 handbag 與 handsome 之間。因此,可以尋道至 handbag 的偏移量,再從那裡開始掃描檔案,直到找到 handiwork;如果一直掃到下一個塊仍未找到,就說明檔案中沒有這個鍵。幾千位元組的資料塊很快就能掃描完。
此外,每個記錄塊都可以壓縮(圖 4-2 中的陰影區域)。除了節省磁碟空間,壓縮還能減少 I/O 頻寬的使用,代價只是多耗費一點 CPU 時間。
構建和合並 SSTable
SSTable 檔案格式比僅追加日誌更利於讀取,卻讓寫入變得困難。不能直接向檔案末尾追加,否則檔案就不再有序(除非鍵碰巧按升序寫入)。如果每次在檔案中間插入一個鍵都要重寫整個 SSTable,寫入成本又會高得無法接受。
解決辦法是採用 日誌結構 方法,將僅追加日誌與排序檔案結合起來:
- 收到寫入時,將其加入記憶體中的有序對映資料結構,例如紅黑樹、跳錶 5 或字典樹 6。這類資料結構可以按任意順序插入鍵、高效查詢鍵,並按排序順序讀出鍵。這個記憶體資料結構稱為 記憶體表(memtable)。
- 當記憶體表超過某個閾值(通常為幾兆位元組)時,按排序順序將它寫成磁碟上的 SSTable 檔案。這個新的 SSTable 檔案稱為資料庫最新的 段(segment),它與較舊的段分別存放在獨立檔案中,每個段都有自己的索引。向磁碟寫出新段期間,資料庫可以繼續向新的記憶體表例項寫入;SSTable 寫完後,舊記憶體表佔用的記憶體即可釋放。
- 讀取某個鍵的值時,先在記憶體表和磁碟上最新的段中查詢。如果沒有找到,就依次檢視更舊的段,直到找到這個鍵或查完最舊的段。如果任何段中都沒有這個鍵,它就不存在於資料庫中。
- 後臺不時執行合併與壓實過程,將段檔案合併起來,並丟棄已經覆蓋或刪除的值。
段的合併類似於 歸併排序 演算法 5,如 圖 4-3 所示。並行讀取各個輸入檔案,比較每個檔案當前的第一個鍵,把排序最靠前的鍵複製到輸出檔案,然後不斷重複。如果同一個鍵出現在多個輸入檔案中,只保留較新的值。這樣生成的新段仍按鍵排序,每個鍵只保留一個值;由於可以逐鍵遍歷 SSTable,整個過程只需要很少的記憶體。

為了避免資料庫崩潰時丟失記憶體表中的資料,儲存引擎還會在磁碟上儲存一個單獨的日誌,每次寫入都會立即追加到這個日誌。日誌不按鍵排序,但這並不重要,因為它的唯一用途是在崩潰後恢復記憶體表。每當記憶體表寫成 SSTable 後,日誌中相應的部分便可丟棄。
如果要刪除一個鍵及其關聯的值,必須向資料檔案追加一種稱為 墓碑(tombstone)的特殊刪除記錄。日誌段合併時,墓碑會指示合併過程丟棄這個鍵此前的所有值。墓碑一旦合併進最舊的段,自身也就可以刪除。
這裡描述的演算法,本質上就是 RocksDB 7、Cassandra、ScyllaDB 和 HBase 8 所採用的演算法。這些系統都受 Google Bigtable 論文 9 的啟發;SSTable 和 memtable 兩個術語正是由該論文提出的。
這一演算法最初發表於 1996 年,名為 日誌結構合併樹(Log-Structured Merge-Tree,簡稱 LSM 樹)10,它建立在更早的日誌結構檔案系統研究之上 11。因此,凡是以合併、壓實有序檔案為基本原理的儲存引擎,通常都稱為 LSM 儲存引擎。
在 LSM 儲存引擎中,段檔案會一次寫完(或者由記憶體表寫出,或者由若干現有段合併而成),此後便不可變。段的合併與壓實可以由後臺執行緒完成;在此期間,舊段檔案仍可繼續處理讀取請求。合併完成後,讀取請求切換到新的合併段,舊段檔案即可刪除。
段檔案不一定要儲存在本地磁碟上;它們也非常適合寫入物件儲存。例如,SlateDB 和 Delta Lake 就採用這種方法 12。
段檔案不可變,也讓崩潰恢復變得更簡單:如果寫出記憶體表或合併段時發生崩潰,資料庫只需刪除未完成的 SSTable,再重新開始即可。用於持久化記憶體表寫入的日誌,可能因寫到一半時崩潰或磁碟已滿而留下不完整記錄;通常可以藉助日誌中的校驗和檢測這些記錄,並丟棄損壞或不完整的條目。我們將在 第 8 章 進一步討論永續性與崩潰恢復。
布隆過濾器
在 LSM 儲存中,讀取很久以前才更新過的鍵,或讀取根本不存在的鍵,可能十分緩慢,因為儲存引擎必須檢查多個段檔案。為了加快這類讀取,LSM 儲存引擎通常會為每個段配備一個 布隆過濾器(Bloom filter)13,用來快速、近似地判斷某個鍵是否出現在某個 SSTable 中。
圖 4-4 展示了一個包含兩個鍵、長度為 16 位的布隆過濾器(實際的鍵和位都會多得多)。對 SSTable 中的每個鍵計算雜湊函式,會得到一組數字,再將這些數字解釋為位陣列中的下標 14。把這些位置上的位設為 1,其餘位保持為 0。例如,鍵 handbag 的雜湊結果是 (2, 9, 4),於是將第 2、9、4 位設為 1。得到的點陣圖會與稀疏鍵索引一起存入 SSTable。它會佔用一點額外空間,不過布隆過濾器通常遠小於 SSTable 的其餘部分。

要判斷某個鍵是否出現在 SSTable 中,只需對它計算同樣的雜湊,再檢查對應下標處的位。例如,圖 4-4 查詢的是鍵 handheld,其雜湊結果為 (6, 11, 2)。其中第 2 位為 1,另外兩位為 0。所有 CPU 都支援的位運算可以極快地完成這些檢查。
只要其中至少一位為 0,就能確定 SSTable 中絕對沒有這個鍵。如果查詢對應的位全為 1,這個鍵很可能在 SSTable 中,但也可能只是其他鍵碰巧把這些位全都設成了 1。這種看似存在、實際卻不存在的情況稱為 假陽性(false positive)。
假陽性的機率取決於鍵的數量、每個鍵設定多少位,以及布隆過濾器的總位數。可以藉助線上計算器為應用選擇合適的引數 15。粗略地說,SSTable 中每個鍵分配 10 位布隆過濾器空間,假陽性機率約為 1%;此後每個鍵再多分配 5 位,機率就會降低一個數量級。
對 LSM 儲存引擎來說,假陽性不會造成正確性問題:
- 如果布隆過濾器判斷鍵 不存在,就可以放心跳過這個 SSTable,因為其中一定沒有該鍵。
- 如果布隆過濾器判斷鍵 存在,還要查詢稀疏索引、解碼鍵值對塊,確認鍵是否真的在其中。如果遇到假陽性,不過是多做了一點無用功,並無其他危害;繼續搜尋下一個更舊的段即可。
壓實策略
LSM 儲存的一項重要設計細節,是何時執行壓實,以及每次壓實應包含哪些 SSTable。許多基於 LSM 的儲存系統都允許配置壓實策略,常見選擇包括 16 17:
- 按大小分層壓實(size-tiered compaction)
- 將較新、較小的 SSTable 逐步合併到較舊、較大的 SSTable 中。儲存舊資料的 SSTable 可能變得非常大,合併時需要大量臨時磁碟空間。這種策略的優點是能應對很高的寫入吞吐量。
- 分級壓實(leveled compaction)
- 將鍵範圍拆分到較小的 SSTable 中,並把較舊的資料移入不同的“層級”。這樣可以更增量地進行壓實,所需磁碟空間也少於按大小分層策略。分級壓實的讀取效率更高,因為儲存引擎只需檢查較少的 SSTable,就能判斷其中是否含有所需的鍵。
根據經驗,如果工作負載以寫入為主、讀取很少,按大小分層壓實通常表現更好;如果讀取佔主導,分級壓實通常更好。若少量鍵經常寫入,而大量鍵很少寫入,分級壓實也可能更有優勢 18。
儘管其中有許多微妙之處,LSM 樹的基本思想——儲存一系列在後臺合併的 SSTable——簡單而有效。我們將在 “比較 B 樹與 LSM 樹” 中更詳細地討論其效能特徵。
許多資料庫以服務形式執行,透過網路接收查詢;但也有一些 嵌入式(embedded)資料庫並不提供網路 API。它們是與應用程式碼執行在同一程序中的庫,通常讀寫本地磁碟上的檔案,應用則透過普通函式呼叫與之互動。RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB 都是嵌入式儲存引擎 19。
嵌入式資料庫在移動應用中十分常見,可用於儲存本地使用者的資料。在後端,如果資料小到單機足以容納,併發事務又不多,嵌入式資料庫也可能是合適的選擇。例如在多租戶系統中,如果每個租戶的資料量都很小,且彼此完全隔離(即不需要查詢多個租戶的合併資料),就可以考慮為每個租戶分別執行一個嵌入式資料庫例項 20。
本章討論的儲存與檢索方法,既適用於嵌入式資料庫,也適用於客戶端—伺服器資料庫。在 第 6 章 和 第 7 章 中,我們將討論如何把資料庫擴充套件到多臺機器。
B 樹
日誌結構方法很流行,但它不是鍵值儲存的唯一形式。按鍵讀寫資料庫記錄時,使用最廣泛的結構是 B 樹。
B 樹自 1970 年問世 21,不到 10 年就被稱為“無處不在”22,很好地經受住了時間的考驗。時至今日,它仍然是幾乎所有關聯式資料庫中的標準索引實現,許多非關聯式資料庫也使用 B 樹。
與 SSTable 一樣,B 樹按鍵儲存有序的鍵值對,因而能夠高效地查詢鍵值和執行範圍查詢。但相似之處也到此為止:B 樹有著截然不同的設計理念。
前面看到的日誌結構索引把資料庫分成大小可變的 段(segment),通常每段為幾兆位元組或更大;段只寫入一次,此後便不可變。相比之下,B 樹把資料庫分成大小固定的 塊 或 頁,並允許就地覆蓋頁。傳統的頁大小是 4 KiB,不過 PostgreSQL 目前預設使用 8 KiB,MySQL 預設使用 16 KiB。
每一頁都有頁號作為標識,因此一頁可以引用另一頁——類似於指標,只不過位於磁碟而非記憶體。如果所有頁都儲存在同一個檔案中,頁號乘以頁大小,就是該頁在檔案中的位元組偏移量。利用這些頁引用可以構造一棵頁組成的樹,如 圖 4-5 所示。

其中一頁被指定為 B 樹的 根(root);在索引中查詢鍵時,總是從這裡開始。根頁包含若干個鍵和對子頁的引用。每個子頁負責一段連續的鍵範圍,引用之間的鍵標示出相鄰範圍的邊界。(這種結構有時稱為 B+ 樹,不過這裡不必把它與其他 B 樹變體區分開來。)
在 圖 4-5 的例子中,我們要尋找鍵 251,因此沿著邊界 200 與 300 之間的頁引用向下走。接下來的一頁結構相似,只是把 200–300 進一步劃分成更小的子範圍。最終會到達包含各個鍵的 葉頁(leaf page);葉頁或者直接儲存每個鍵的值,或者儲存指向值所在頁的引用。
B 樹一頁中對子頁的引用數稱為 分支因子(branching factor)。例如,圖 4-5 中的分支因子為 6。實踐中的分支因子取決於頁引用和範圍邊界所需的空間,不過通常可達幾百。
如果要更新 B 樹中已有鍵的值,就先找到包含該鍵的葉頁,再用含有新值的版本覆蓋磁碟上的這一頁。如果要新增新鍵,則找到範圍涵蓋該鍵的頁,並把鍵加入其中。如果頁內沒有足夠的空閒空間容納新鍵,就把它拆成兩個半滿的頁,並更新父頁,以反映鍵範圍的新劃分。

在 圖 4-6 中,我們想插入鍵 334,但負責 333–345 範圍的頁已經裝滿。於是把它拆成兩頁:一頁負責 333–337(幷包含新鍵),另一頁負責 337–344。父頁也必須更新,增加對兩個子頁的引用,並以 337 作為二者的邊界。如果父頁容不下新的引用,它也要拆分;這種拆分可能一路向上傳播到樹根。根頁拆分時,則在其上方建立一個新根。刪除鍵還可能需要合併節點,處理起來更加複雜 5。
這個演算法可以確保樹始終 平衡(balanced):包含 n 個鍵的 B 樹深度總是 O(log n)。大多數資料庫只需要三四層深的 B 樹,因此不必沿著很多頁引用就能找到目標頁。(一棵四層深、頁大小為 4 KiB、分支因子為 500 的樹,最多可以儲存 250 TB 資料。)
使 B 樹可靠
B 樹最基本的底層寫操作,是用新資料覆寫磁碟上的頁,並假定覆寫不會改變頁的位置:也就是說,頁被覆寫後,所有指向它的引用仍然有效。這與 LSM 樹一類日誌結構索引形成鮮明對比;後者只向檔案追加寫入(並最終刪除過時檔案),從不就地修改檔案。
一次覆寫多個頁——例如拆分頁時——是很危險的操作。如果資料庫只寫完其中一部分就崩潰,最終會留下損壞的樹(例如出現不屬於任何父頁的 孤兒頁,orphan page)。如果硬體不能原子地寫入整頁,還可能留下只寫了一部分的頁,這稱為 頁撕裂(torn page)23。
為了讓資料庫能夠從崩潰中恢復,B 樹實現通常會在磁碟上維護一個額外的資料結構:預寫日誌(write-ahead log,WAL)。這是一個僅追加檔案;對 B 樹的每項修改,都必須先寫入 WAL,才能應用到樹本身的頁上。資料庫在崩潰後重新啟動時,會用這個日誌把 B 樹恢復到一致狀態 2 24。檔案系統中的對應機制稱為 日誌機制(journaling)。
為了提高效能,B 樹實現通常不會立刻把每個修改過的頁寫入磁碟,而是先把 B 樹頁在記憶體中緩衝一段時間。此時,預寫日誌還負責確保崩潰時不丟資料:只要資料已寫入 WAL,並透過 fsync() 系統呼叫刷到磁碟,它就是持久的,因為資料庫能夠在崩潰後將它恢復出來 25。
B 樹變體
由於 B 樹已經存在很久,多年來發展出了許多變體。這裡只舉幾例:
- 一些資料庫(如 LMDB)不覆寫頁,也不靠 WAL 進行崩潰恢復,而是採用寫時複製方案 26。修改過的頁寫入新的位置,併為樹中的父頁建立新版本,使其指向新位置。這種方法對併發控制也很有用,我們將在 “快照隔離與可重複讀” 中看到。
- 可以不儲存完整的鍵,而只儲存其縮寫,以節省頁內空間。尤其在樹的內部頁中,鍵只需包含足夠的資訊,能夠充當鍵範圍之間的邊界即可。一頁容納的鍵越多,樹的分支因子就越大,層數也越少。
- 為了加快按排序順序掃描鍵範圍,有些 B 樹實現會盡量安排樹的佈局,使葉頁在磁碟上也按順序出現,從而減少磁碟尋道。不過隨著樹不斷增長,這種順序很難維持。
- 還可以向樹中加入額外的指標。例如,讓每個葉頁引用左右相鄰的兄弟頁,便能按順序掃描鍵,而不必反覆跳回父頁。
比較 B 樹與 LSM 樹
根據經驗,LSM 樹更適合寫入密集型應用,而 B 樹的讀取通常更快 27 28。不過,基準測試結果往往對工作負載的細節非常敏感。只有使用自己的實際工作負載測試系統,比較才有意義。此外,LSM 樹與 B 樹並非嚴格的二選一:儲存引擎有時會融合兩種方法的特點,例如維護多棵 B 樹,再用 LSM 風格將它們合併。本節將簡要討論衡量儲存引擎效能時值得考慮的幾個方面。
讀取效能
在 B 樹中查詢鍵,需要在樹的每一層讀取一頁。由於層數通常很少,B 樹讀取一般很快,效能也比較容易預測。LSM 儲存引擎往往需要檢查處於不同壓實階段的多個 SSTable,不過布隆過濾器能減少實際需要執行的磁碟 I/O 次數。兩種方法都可能有良好表現;究竟哪種更快,取決於儲存引擎的具體實現和工作負載。
B 樹本身有序,因此範圍查詢簡單而快速。LSM 儲存也能利用 SSTable 的排序,但必須並行掃描所有段,再把結果合併起來。布隆過濾器對範圍查詢無能為力,因為不可能計算範圍內每個潛在鍵的雜湊;所以在 LSM 儲存中,範圍查詢的成本高於點查詢 29。
在日誌結構儲存引擎中,高寫入吞吐量可能在記憶體表填滿時引發延遲尖峰。如果資料來不及寫入磁碟——或許因為壓實速度趕不上新增寫入——就會出現這種情況。包括 RocksDB 在內的許多儲存引擎會在此時施加 背壓(backpressure):暫停所有讀寫,直到記憶體表寫入磁碟 30 31。
至於讀取吞吐量,現代 SSD(尤其是 NVMe)可以並行處理許多相互獨立的讀取請求。LSM 樹和 B 樹都能提供很高的讀取吞吐量,但儲存引擎必須經過精心設計,才能利用這種並行能力 32。
順序與隨機寫入
使用 B 樹時,如果應用寫入的鍵散佈在整個鍵空間,產生的磁碟操作也會四處分散,因為儲存引擎要覆寫的頁可能位於磁碟任何位置。日誌結構儲存引擎則一次寫出整個段檔案——無論是把記憶體表寫出,還是壓實現有段——其大小遠遠超過 B 樹中的一頁。
這種數量多、規模小而位置分散的寫入模式(如 B 樹)稱為 隨機寫入(random write);數量少、規模較大的寫入模式(如 LSM 樹)則稱為 順序寫入(sequential write)。磁碟的順序寫入吞吐量通常高於隨機寫入,因此在相同硬體上,日誌結構儲存引擎一般能承受比 B 樹更高的寫入吞吐量。這種差異在機械硬碟(HDD)上尤其顯著;如今多數資料庫使用固態硬碟(SSD),差距有所縮小,但依然不可忽略(參見 “SSD 上的順序與隨機寫入”)。
在機械硬碟(HDD)上,順序寫入遠快於隨機寫入:隨機寫入必須把磁頭機械地移到新位置,還要等待碟片上的目標區域轉到磁頭下方,耗時可達數毫秒——以計算機的時間尺度看,簡直是一生。如今,SSD(固態硬碟)已經在許多場景中取代 HDD,其中也包括 NVMe(Non-Volatile Memory Express,即透過 PCI Express 匯流排連線的快閃記憶體);它們沒有這種機械限制。
儘管如此,SSD 的順序寫入吞吐量仍然高於隨機寫入。快閃記憶體每次可以讀寫一頁(通常為 4 KiB),卻只能按塊擦除(通常為 512 KiB)。同一個塊中,有些頁可能仍儲存有效資料,另一些頁中的資料則已經無用。擦除整個塊之前,控制器必須先把含有有效資料的頁搬到其他塊;這個過程稱為 垃圾回收(GC)33。
順序寫入每次寫入更大塊的資料,所以一個完整的 512 KiB 塊很可能都屬於同一個檔案;日後刪除這個檔案時,可以直接擦除整個塊,不必執行 GC。隨機寫入則更容易讓一個塊混雜有效頁和無效頁,因此在擦除塊之前,GC 必須完成更多搬運工作 34 35 36。
GC 佔用的寫入頻寬無法再供應用使用;而且 GC 帶來的額外寫入會加劇快閃記憶體磨損。因此,隨機寫入比順序寫入更快地損耗 SSD。
寫放大
無論採用哪種儲存引擎,應用發出的一次寫請求都會轉化為底層磁碟上的多次 I/O。對 LSM 樹而言,一個值首先寫入日誌以確保永續性;記憶體表寫入磁碟時又寫一次;此後每次所在的鍵值對參與壓實,還要再次寫入。(如果值遠大於鍵,可以把鍵和值分開存放,只壓實包含鍵和值引用的 SSTable,以降低這項開銷 37。)
B 樹索引也必須把每份資料至少寫兩次:一次寫入預寫日誌,一次寫入樹頁本身。為了確保 B 樹能在崩潰或斷電後正確恢復,有時即使頁內只有幾個位元組發生變化,也必須寫出整頁 38 39。
把某個工作負載實際寫入磁碟的總位元組數,除以不帶索引、只寫僅追加日誌時所需的位元組數,得到的比值就是 寫放大(write amplification)。(寫放大有時也按 I/O 操作次數而非位元組數定義。)在寫入密集型應用中,瓶頸可能是資料庫向磁碟寫入的速度。此時寫放大越高,在有限磁碟頻寬內每秒能處理的寫入就越少。
LSM 樹和 B 樹都有寫放大問題。孰優孰劣取決於許多因素,例如鍵和值的長度,以及覆蓋現有鍵與插入新鍵各有多頻繁。對典型工作負載而言,LSM 樹往往具有更低的寫放大,因為它不必寫出整頁,還可以壓縮 SSTable 中的資料塊 40。這也是 LSM 儲存引擎適合寫入密集型工作負載的原因之一。
寫放大除了影響吞吐量,也關係到 SSD 的磨損:儲存引擎的寫放大越低,SSD 損耗得就越慢。
測量儲存引擎的寫入吞吐量時,實驗必須執行足夠長的時間,才能顯現寫放大的影響。剛開始向空 LSM 樹寫入時,壓實尚未發生,全部磁碟頻寬都可供新寫入使用。隨著資料庫增長,新寫入便不得不與壓實共享磁碟頻寬。
磁碟空間使用
B 樹可能隨著時間推移逐漸 碎片化。例如,刪除大量鍵之後,資料庫檔案裡可能留下許多 B 樹不再使用的頁。以後向 B 樹新增資料時可以複用這些空閒頁,但它們位於檔案中間,很難歸還給作業系統,所以仍會佔用檔案系統空間。因此,資料庫需要後臺程序搬移並重新整理這些頁,例如 PostgreSQL 的清理(vacuum)程序 25。
碎片化對 LSM 樹來說問題較小,因為壓實過程本來就會定期重寫資料檔案,而且 SSTable 中不存在留有空閒空間的頁。此外,SSTable 中的鍵值對塊更便於壓縮,所以生成的磁碟檔案通常小於 B 樹。已經覆蓋的鍵和值會繼續佔用空間,直到在壓實中移除;不過採用分級壓實時,這項開銷相當低 40 41。按大小分層壓實(參見 “壓實策略”)會佔用更多磁碟空間,尤其是在壓實過程中臨時佔用的空間。
如果需要刪除某些資料並確信它們確實已經消失——例如為了遵守資料保護法規——磁碟上並存的多份副本也會帶來麻煩。在大多數 LSM 儲存引擎中,已刪除的記錄仍可能留在較高層級,直到代表刪除操作的墓碑傳遍所有壓實層級;這一過程可能持續很久。有些專門的儲存引擎設計可以更快地傳播刪除操作 42。
另一方面,SSTable 段檔案的不可變性很適合為資料庫建立時間點快照(例如用於備份,或複製一份資料庫進行測試):只需寫出記憶體表,再記下當時存在哪些段檔案。只要不刪除屬於快照的檔案,就完全不必實際複製它們。B 樹會覆寫頁,因此很難如此高效地建立快照。
多列索引與二級索引
到目前為止,我們只討論了鍵值索引,它們類似於關係模型中的 主鍵 索引。主鍵唯一標識關係表中的一行、文件資料庫中的一個文件,或圖資料庫中的一個頂點。資料庫裡的其他記錄可以透過主鍵(或 ID)引用這一行、文件或頂點,而索引負責解析這種引用。
二級索引 也很常見。在關聯式資料庫中,可以用 CREATE INDEX 命令在同一張表上建立多個二級索引,從而按主鍵以外的列進行搜尋。例如,圖 3-1(見 第 3 章)中的各張表很可能都要在 user_id 列上建立二級索引,以便找出屬於同一使用者的所有行。
二級索引很容易用鍵值索引構建。主要區別在於,二級索引中的被索引值不一定唯一,也就是說,同一個索引條目下可能對應許多行(或文件、頂點)。有兩種解決辦法:把索引中的值做成匹配行識別符號的列表(類似全文索引的倒排列表);或者在每個索引項後附加行識別符號,使它成為唯一項。B 樹一類就地更新的儲存引擎和日誌結構儲存都可以實現二級索引。
在索引中儲存值
索引中的鍵是查詢要搜尋的內容,而值可以採用以下幾種形式:
- 如果實際資料(行、文件或頂點)直接儲存在索引結構中,這就是 聚簇索引。例如,MySQL 的 InnoDB 儲存引擎總是把表的主鍵作為聚簇索引;SQL Server 則允許每張表指定一個聚簇索引 43。
- 另一種選擇是讓值引用實際資料:它可以是相應行的主鍵(InnoDB 的二級索引便是如此),也可以直接引用磁碟上的位置。後一種情況下,存放行的地方稱為 堆檔案(heap file);堆檔案中的資料沒有特定順序,可以是僅追加的,也可以記錄已刪除的行,以便日後用新資料覆寫。例如,Postgres 就使用堆檔案 44。
- 兩者之間的折中稱為 覆蓋索引(covering index)或 包含列的索引(index with included columns):完整的行仍儲存在堆檔案或主鍵聚簇索引中,但索引也會儲存表的 部分 列 45。這樣,一些查詢只用索引就能得到答案,不必再解析主鍵或訪問堆檔案;此時稱該索引 覆蓋 了查詢。覆蓋索引可以加快某些查詢,但重複資料會佔用更多磁碟空間,也會拖慢寫入。
到目前為止討論的索引,都只是把單個鍵對映到值。如果需要同時查詢表中的多個列(或文件中的多個欄位),請參見 “多維索引與全文索引”。
更新值但不改變鍵時,只要新值不大於舊值,堆檔案就能就地覆寫記錄。如果新值更大,情況會複雜一些:記錄可能必須移到堆內空間足夠的新位置。此時,要麼更新所有索引,使其指向記錄在堆中的新位置;要麼在舊位置留下一個轉發指標 2。
全記憶體儲存
本章到目前為止討論的資料結構,都是對磁碟侷限的應對。與主記憶體相比,磁碟處理起來很麻煩。無論是磁性硬碟還是 SSD,要想獲得良好的讀寫效能,都必須仔細安排資料在磁碟上的佈局。不過我們能容忍這種麻煩,是因為磁碟有兩項顯著優勢:它是持久的(斷電後內容不會丟失),而且每 GB 的成本低於 RAM。
隨著 RAM 越來越便宜,磁碟的每 GB 成本優勢正在減弱。許多資料集本來就沒有那麼大,因此完全可以把它們全部放進記憶體,必要時還可以分佈到多臺機器上。於是,記憶體資料庫 發展了起來。
有些記憶體鍵值儲存(如 Memcached)僅用於快取,機器重啟時丟失資料也無妨。但另一些記憶體資料庫以永續性為目標,可以藉助特殊硬體(如電池供電的 RAM)、把變更日誌寫入磁碟、定期把快照寫入磁碟,或把記憶體狀態複製到其他機器來實現。
記憶體資料庫重啟時,需要從磁碟或透過網路從副本重新載入狀態(使用特殊硬體時除外)。它雖然寫磁碟,卻仍然屬於記憶體資料庫,因為磁碟只是用作確保永續性的僅追加日誌,所有讀取都由記憶體處理。寫入磁碟還有運維上的好處:外部工具可以輕鬆備份、檢查和分析磁碟上的檔案。
VoltDB、SingleStore 和 Oracle TimesTen 等產品是採用關係模型的記憶體資料庫。供應商宣稱,省去管理磁碟資料結構的全部開銷後,它們可以大幅提高效能 46 47。RAMCloud 則是一個具備永續性的開源記憶體鍵值儲存,對記憶體和磁碟中的資料都採用日誌結構方法 48。
Redis 和 Couchbase 透過非同步寫入磁碟提供弱永續性。
反直覺的是,記憶體資料庫的效能優勢並不來自省去了磁碟讀取。如果記憶體足夠,即便基於磁碟的儲存引擎也可能從不真正讀取磁碟,因為作業系統本來就會把最近使用的磁碟塊快取在記憶體中。記憶體資料庫之所以更快,是因為它省去了把記憶體資料結構編碼成可寫入磁碟的形式這一開銷 49。
除了效能之外,記憶體資料庫還有一個有趣之處:它可以提供很難用磁碟索引實現的資料模型。例如,Redis 為優先佇列、集合等各種資料結構提供了類似資料庫的介面。由於所有資料都放在記憶體中,實現起來相對簡單。
分析型資料儲存
資料倉儲最常採用關係資料模型,因為 SQL 通常很適合分析查詢。許多圖形化資料分析工具可以生成 SQL 查詢、視覺化查詢結果,並讓分析師透過 下鑽、切片與切塊 等操作探索資料。
表面上,資料倉儲與關係型 OLTP 資料庫十分相似,因為二者都有 SQL 查詢介面。然而,它們的內部實現可能大相徑庭,因為兩者針對的查詢模式完全不同。如今,許多資料庫供應商只專注於事務處理或分析工作負載中的一種,而非兩者兼顧。
Microsoft SQL Server、SAP HANA 和 SingleStore 等資料庫在同一產品中同時支援事務處理和資料倉儲。不過,這些混合事務/分析處理(HTAP)資料庫(已在 “資料倉儲” 中介紹)正日益演變成兩套彼此獨立的儲存與查詢引擎,只是恰好透過同一個 SQL 介面訪問 50 51 52 53。
雲資料倉儲
Teradata、Vertica 和 SAP HANA 等資料倉儲供應商,既以商業許可證銷售本地部署的資料倉儲,也提供雲端解決方案。隨著越來越多的客戶遷往雲端,Google Cloud BigQuery、Amazon Redshift 和 Snowflake 等新一代雲資料倉儲也得到廣泛採用。與傳統資料倉儲不同,雲資料倉儲會利用物件儲存、無伺服器計算平臺等可伸縮的雲基礎設施。
雲資料倉儲通常能更好地整合其他雲服務,也更具彈性。例如,許多雲資料倉儲支援自動攝取日誌,並且可以輕鬆接入 Google Cloud Dataflow、Amazon Web Services Kinesis 等資料處理框架。它們把查詢計算與儲存層解耦,因此也更具彈性 54。資料持久儲存在物件儲存而非本地磁碟上,於是儲存容量和查詢計算資源可以分別調整,正如 “雲原生系統架構” 所介紹的那樣。
Apache Hive、Trino 和 Apache Spark 等開源資料倉儲也隨著雲端計算共同演進。分析資料儲存遷入物件儲存上的資料湖之後,開源資料倉儲開始拆分、解耦 55。過去整合在 Apache Hive 這類單一系統中的功能,如今往往由以下獨立元件實現:
- 查詢引擎
- Trino、Apache DataFusion 和 Presto 等查詢引擎負責解析 SQL 查詢,將其最佳化成執行計劃,再針對資料執行。查詢通常需要由分散式任務並行處理。有些查詢引擎內建了任務執行能力,另一些則藉助 Apache Spark 或 Apache Flink 等第三方執行框架。
- 儲存格式
- 儲存格式規定如何把表中的行編碼成檔案裡的位元組;檔案通常儲存在物件儲存或分散式檔案系統中 12。查詢引擎可以訪問這些資料,使用同一資料湖的其他應用也可以。Parquet、ORC、Lance 和 Nimble 都屬於這類格式,下一節還會進一步介紹。
- 表格式
- 以 Apache Parquet 等儲存格式寫出的檔案通常不可變。為了支援插入和刪除行,還要使用 Apache Iceberg 或 Databricks Delta 等表格式。表格式用一種檔案格式來定義哪些檔案構成一張表,以及這張表採用什麼模式。它還可以提供時間旅行(查詢表在過去某個時刻的狀態)、垃圾回收乃至事務等高階功能。
- 資料目錄
- 正如表格式定義哪些檔案組成一張表,資料目錄定義哪些表組成一個資料庫。目錄用於建立、重新命名和刪除表。與儲存格式和表格式不同,Snowflake Polaris、Databricks Unity Catalog 等資料目錄通常作為獨立服務執行,並透過 REST 介面接受查詢。Apache Iceberg 也提供目錄,既可以嵌入客戶端執行,也可以作為獨立程序執行。查詢引擎讀寫表時會使用目錄資訊。傳統上,目錄與查詢引擎整合在一起;將二者解耦之後,資料發現和資料治理系統(見 “資料系統、法律與社會”)也可以訪問目錄中的後設資料。
列式儲存
正如 “星型與雪花型:分析模式” 所述,資料倉儲通常採用關係模式:一張巨大的事實表透過外來鍵引用各張維度表。如果事實表有數萬億行、數 PB 資料,如何高效儲存和查詢就成了嚴峻挑戰。維度表通常小得多(只有數百萬行),所以本節將重點討論事實表的儲存。
儘管事實表通常有 100 多列,但典型的資料倉儲查詢一次只訪問其中 4、5 列(分析查詢很少需要 "SELECT *")52。以 示例 4-1 為例:它會訪問大量行(2024 年中每一筆水果或糖果銷售記錄),卻只需要 fact_sales 表中的三列:date_key、product_sk 和 quantity。其他所有列都被查詢忽略了。
怎樣才能高效執行這個查詢?
大多數 OLTP 資料庫都以 面向行(row-oriented)的方式佈置儲存:表中同一行的所有值相鄰存放。文件資料庫也很相似,通常把整個文件存成一段連續的位元組序列。圖 4-1 的 CSV 示例就是如此。
為了處理 示例 4-1 這樣的查詢,可以在 fact_sales.date_key 和(或)fact_sales.product_sk 上建立索引,告訴儲存引擎去哪裡尋找某一天或某種產品的所有銷售記錄。但面向行的儲存引擎仍要把這些完整的行(每行有 100 多個屬性)從磁碟載入記憶體,逐一解析,再過濾掉不滿足條件的行。這個過程可能十分耗時。
面向列(column-oriented,或 列式,columnar)儲存背後的想法很簡單:不要把同一行中的所有值放在一起,而要把同一 列 中的所有值放在一起 56。每列分別儲存後,查詢只需讀取和解析自己用到的列,能省下大量工作。圖 4-7 用 圖 3-5 中事實表的擴充套件版本展示了這一原理。

面向列的儲存佈局要求每一列都按相同的行順序儲存資料。因此,要重新拼出完整的一行,可以分別取出每列中的第 23 項,把它們組合成表的第 23 行。
實際上,列式儲存引擎並不會把完整的一列(可能有數萬億行)一次存放在一起。它會把表切成若干個包含數千乃至數百萬行的資料塊,再在每個塊內分別儲存各列的值 60。許多查詢只關注特定日期範圍,因此常讓每個塊包含某個時間戳範圍內的行。查詢只需在與目標日期範圍重疊的塊中,載入自己需要的列。
如今,幾乎所有分析資料庫都採用列式儲存 60:從 Snowflake 61 這樣的大型雲資料倉儲,到 DuckDB 62 這樣的單節點嵌入式資料庫,再到 Pinot 63、Druid 64 等產品分析系統。Parquet、ORC 65 66、Lance 67 和 Nimble 68 等儲存格式,以及 Apache Arrow 65 69、pandas/NumPy 70 等記憶體分析格式,也都採用列式佈局。InfluxDB IOx 71、TimescaleDB 72 等時間序列資料庫同樣以列式儲存為基礎。
列壓縮
除了只從磁碟載入查詢需要的列,還可以壓縮資料,進一步降低對磁碟吞吐量和網路頻寬的需求。幸運的是,列式儲存通常很適合壓縮。
看看 圖 4-7 中各列的值序列:它們往往相當重複,這是很適合壓縮的訊號。根據列中資料的不同,可以選用不同的壓縮技術。其中對資料倉儲特別有效的一種是 點陣圖編碼,如 圖 4-8 所示。

通常,一列中不同值的數量遠小於總行數(例如,零售商可能有數十億筆銷售交易,卻只有 100,000 種產品)。可以把一個具有 n 個不同值的列轉換成 n 張獨立點陣圖:每個不同值對應一張點陣圖,每一行對應其中一位。如果該行取這個值,對應位就是 1,否則為 0。
一種選擇是按每行一位直接儲存這些點陣圖。不過,點陣圖中通常有大量的 0,也就是十分 稀疏。這時還可以使用遊程編碼:統計連續出現的 0 或 1 的數量,並存下這個數字,如 圖 4-8 底部所示。Roaring 點陣圖會在兩種點陣圖表示之間切換,總是選用更緊湊的一種 73。這樣可以極其高效地編碼一整列。
像這樣的點陣圖索引非常適合資料倉儲中常見的查詢型別。例如:
WHERE product_sk IN (31, 68, 69):- 載入
product_sk = 31、product_sk = 68和product_sk = 69對應的三張點陣圖,再計算三者的按位 或,這個操作可以非常高效地完成。 WHERE product_sk = 30 AND store_sk = 3:- 載入
product_sk = 30和store_sk = 3對應的點陣圖,再計算按位 與。之所以可行,是因為各列中的行順序相同:一列點陣圖中的第 k 位,與另一列點陣圖中的第 k 位對應同一行。
點陣圖也可以回答圖查詢,例如找出社交網路中所有“被使用者 X 關注、同時又關注使用者 Y”的使用者 74。列式資料庫還有許多其他壓縮方案,參見參考文獻 75。
不要把列式資料庫與 寬列(wide-column,又稱 列族,column-family)資料模型混為一談。寬列模型的一行可以有數千列,各行也不必擁有相同的列 9。儘管名字相似,寬列資料庫其實是面向行的,因為它會把同一行的所有值存放在一起。Google Bigtable、Apache Accumulo 和 HBase 都屬於寬列模型。
列儲存中的排序順序
在列式儲存中,行的儲存順序並不一定重要。最簡單的方式是按插入順序存放,因為插入新行時只需向每一列追加資料。不過,也可以像此前處理 SSTable 那樣,為資料指定某種順序,並把這種順序用作索引機制。
注意,分別對每一列獨立排序毫無意義,因為那樣就再也不知道不同列中的哪些項屬於同一行。我們之所以能夠重建一行,正是因為一列中的第 k 項與另一列中的第 k 項屬於同一行。
因此,即使資料按列儲存,排序也必須以整行為單位。資料庫管理員可以根據自己對常見查詢的瞭解,選擇表按哪些列排序。例如,如果查詢經常針對“上個月”這樣的日期範圍,就可以把 date_key 作為第一個排序鍵。這樣查詢只需掃描上個月的行,比掃描所有行快得多。
對於第一排序列中取值相同的行,可以用第二列進一步決定順序。例如,如果 圖 4-7 以 date_key 為第一排序鍵,那麼把 product_sk 作為第二排序鍵或許很有用:同一天、同一種產品的所有銷售記錄就會在儲存中相鄰。這有利於在特定日期範圍內按產品分組或篩選銷售記錄的查詢。
排序的另一個好處是有助於列壓縮。如果主排序列沒有太多不同的值,排序後會形成很長的連續序列,同一個值反覆出現。使用簡單的遊程編碼——就像 圖 4-8 中的點陣圖一樣——即可把這一列壓縮到幾千位元組,即使表中有數十億行也不例外。
這種壓縮效果在第一排序鍵上最強。第二、第三排序鍵會更加雜亂,不會出現那麼長的重複值序列。排序優先順序更低的列基本呈隨機順序,壓縮效果可能不佳。不過,只要前幾列經過排序,整體上依然受益。
寫入列式儲存
我們在 “事務處理與分析的特徵” 中看到,資料倉儲的讀取通常要聚合大量行;列式儲存、壓縮和排序都能加快這類讀查詢。資料倉儲的寫入則往往是批次匯入資料,通常透過 ETL 流程完成。
對列式儲存而言,在有序表的中間插入單獨一行非常低效,因為從插入位置開始,所有壓縮列都必須重寫。但一次批次寫入很多行,可以分攤重寫這些列的成本,因而效率很高。
批次寫入通常採用日誌結構方法。所有寫入先進入一個面向行、有序的記憶體儲存。積累足夠多的寫入後,再與磁碟上的列編碼檔案合併,併成批寫入新檔案。舊檔案保持不可變,新檔案一次寫成,所以物件儲存很適合儲存這些檔案。
查詢必須同時檢查磁碟上的列資料和記憶體中的近期寫入,再把兩部分結果合併起來。查詢執行引擎會向使用者隱藏這項區別。在分析師看來,插入、更新或刪除的資料會立刻反映在後續查詢中。Snowflake、Vertica、Apache Pinot、Apache Druid 等許多系統都是這樣做的 61 63 64 76。
查詢執行:編譯與向量化
複雜的分析型 SQL 查詢會被分解成一個 查詢計劃(query plan),其中包含多個稱為 運算元(operator)的執行階段;這些運算元可能分佈到多臺機器上並行執行。查詢規劃器可以決定選用哪些運算元、以什麼順序執行,以及每個運算元在哪裡執行,從而完成大量最佳化。
在每個運算元內部,查詢引擎都要對列中的值執行各種操作,例如找出值屬於某個集合的所有行(或許是連線的一部分),或者判斷值是否大於 15。查詢引擎還要同時檢視同一行的多個列,例如找出產品是香蕉、門店又恰好是目標門店的所有銷售記錄。
資料倉儲查詢需要掃描數百萬行,因此不僅要關注從磁碟讀取的資料量,還要關注執行複雜運算元所需的 CPU 時間。最簡單的運算元就像程式語言直譯器:遍歷每一行時,檢視錶示查詢的資料結構,弄清要對哪些列做什麼比較或計算。遺憾的是,這種方式對許多分析場景來說太慢。實踐中出現了兩種高效執行查詢的方法 77:
- 查詢編譯(query compilation)
- 查詢引擎根據 SQL 查詢生成執行程式碼。程式碼逐行迭代,讀取相關列中的值,完成所需的比較或計算;如果條件滿足,就把必要的值複製到輸出緩衝區。隨後,查詢引擎把生成的程式碼編譯成機器碼(往往藉助 LLVM 等現有編譯器),再對已經載入記憶體的列編碼資料執行。這種程式碼生成方式類似 Java 虛擬機器(JVM)等執行時採用的即時(JIT)編譯。
- 向量化處理(vectorized processing)
- 查詢仍然採用解釋執行,而非編譯執行;但它不再逐行迭代,而是成批處理一列中的許多值,從而提高速度。資料庫內建一組固定的預定義運算元,向運算元傳入引數,就會得到一批結果 50 75。
例如,把 product_sk 列和“香蕉”的 ID 傳給相等比較運算元,會得到一張點陣圖:輸入列的每個值對應一位,是香蕉則為 1。再把 store_sk 列和目標門店的 ID 傳給同一個運算元,得到另一張點陣圖。最後把兩張點陣圖傳給“按位與”運算元,如 圖 4-9 所示。結果點陣圖中,特定門店售出的每一筆香蕉都對應一個 1。

這兩種方法的實現大不相同,但都已投入實際使用 77。它們都能利用現代 CPU 的特點,獲得優異效能:
- 優先順序訪問記憶體而非隨機訪問,減少快取未命中 78;
- 把大部分工作放在緊湊的內層迴圈中(指令少且沒有函式呼叫),使 CPU 指令流水線保持忙碌,並避免分支預測錯誤;
- 利用多執行緒和單指令多資料(SIMD)指令等並行機制 79 80;
- 直接處理壓縮資料,不先解碼成另一種記憶體表示,從而省去記憶體分配和複製的成本。
物化檢視與多維資料集
我們曾在 “時間線的物化與更新” 中遇到 物化檢視(materialized view)。在關係資料模型中,它是一種類似表的物件,內容是某個查詢的結果。物化檢視是實際寫入磁碟的查詢結果副本,而虛擬檢視只是編寫查詢的快捷方式。從虛擬檢視讀取時,SQL 引擎會即時把它展開成底層查詢,再處理展開後的查詢。
底層資料變化時,物化檢視也必須隨之更新。有些資料庫可以自動完成這項工作,Materialize 等系統則專門負責維護物化檢視 81。更新檢視會增加寫入工作量,但如果工作負載反覆執行相同查詢,物化檢視可以改善讀取效能。
物化聚合(materialized aggregate)是一類對資料倉儲很有用的物化檢視。如前所述,資料倉儲查詢經常使用 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX 等聚合函式。如果許多查詢都使用相同的聚合,每次重新處理原始資料就太浪費了,何不把最常用的計數或總和快取起來?多維資料集(data cube,或 OLAP 多維資料集,OLAP cube)會建立一個按不同維度分組的聚合網格,正是為了實現這種快取 82。圖 4-10 展示了一個例子。

暫且假設每條事實記錄只外來鍵引用兩張維度表——圖 4-10 中是 date_key 和 product_sk。這樣可以畫出一張二維表,一條軸表示日期,另一條軸表示產品。每個單元格儲存具有相應“日期—產品”組合的所有事實記錄中,某項屬性(如 net_price)的聚合值(如 SUM)。再沿每一行或每一列應用同樣的聚合,就能得到減少一個維度的彙總結果:不考慮日期的產品銷售額,或者不考慮產品的逐日銷售額。
一般而言,事實往往不止兩個維度。圖 3-5 中就有日期、產品、門店、促銷和客戶五個維度。五維超立方體很難想象,但原理仍然一樣:每個單元格儲存特定“日期—產品—門店—促銷—客戶”組合的銷售額,隨後可以沿每個維度反覆彙總這些值。
物化多維資料集的優點,是某些查詢會變得非常快,因為結果實際上已經預先計算好了。例如,要知道昨天每家門店的總銷售額,只需檢視相應維度上的彙總值,不必掃描數百萬行。
缺點是多維資料集不如直接查詢原始資料靈活。例如,價格不是其中一個維度,就無法計算有多大比例的銷售額來自售價超過 100 美元的商品。因此,大多數資料倉儲會儘可能保留原始資料,只把多維資料集等聚合用作某些查詢的效能最佳化手段。
多維索引與全文索引
本章前半部分介紹的 B 樹和 LSM 樹,可以對單個屬性執行範圍查詢。例如,如果鍵是使用者名稱,就能用它們建立索引,高效找出所有以 L 開頭的名字。但有時,只按一個屬性搜尋並不夠用。
最常見的多列索引稱為 聯合索引(concatenated index)。它把一列接在另一列之後,將多個欄位組合成一個鍵;欄位的連線順序由索引定義指定。這就像老式紙質電話簿提供的索引:從(姓、名)對映到電話號碼。由於索引按這個順序排列,可以找出某個姓氏對應的所有人,也可以找出特定 姓—名 組合對應的所有人。但如果只想查詢某個名字對應的所有人,這個索引就毫無用處。
多維索引(multidimensional index)則允許同時查詢多個列,這對地理空間資料尤其重要。例如,餐廳搜尋網站的資料庫可能儲存了每家餐廳的經緯度。使用者檢視地圖時,網站需要找出當前矩形地圖區域內的所有餐廳。這就需要下面這樣的二維範圍查詢:
在緯度和經度列上建立聯合索引,無法高效回答這個查詢:它只能返回某一緯度範圍內的所有餐廳(經度任意),或者某一經度範圍內的所有餐廳(緯度可以是南北兩極之間的任意位置),卻無法同時約束兩者。
一種辦法是使用空間填充曲線把二維位置轉換成單個數字,再建立普通的 B 樹索引 83。更常見的做法是使用 R 樹、Bkd 樹 84 等專門的空間索引;它們對空間進行劃分,讓鄰近的資料點儘量落在同一棵子樹中。例如,PostGIS 使用 PostgreSQL 的通用搜尋樹索引設施,以 R 樹實現地理空間索引 85。另一種選擇是使用規則排布的三角形、正方形或六邊形網格 86。
多維索引並不只用於地理位置。例如,電子商務網站可以在(紅、綠、藍)三個維度上建立索引,以搜尋特定顏色範圍內的產品;天氣觀測資料庫也可以在(日期、溫度)上建立二維索引,高效找出 2013 年中溫度介於 25~30℃ 的所有觀測記錄。使用一維索引,要麼必須掃描 2013 年的所有記錄(不考慮溫度)再按溫度篩選,要麼反過來處理。二維索引則可以同時按時間戳和溫度縮小結果範圍 87。
全文檢索
全文檢索(full-text search)允許按關鍵詞搜尋一組文字文件(網頁、產品描述等),關鍵詞可以出現在文字中的任意位置 88。資訊檢索是一門龐大而專門的學科,往往還要針對具體語言進行處理。例如,一些亞洲語言書寫時不會在詞與詞之間新增空格或標點,因此把文字切分成詞需要藉助模型,判斷哪些字元序列構成一個詞。全文檢索還經常需要匹配相似但不完全相同的詞(例如拼寫錯誤或同一個詞的不同語法形式),以及同義詞。這些問題都超出了本書的範圍。
不過從核心原理看,全文檢索也可以視為一種多維查詢:文字中可能出現的每個詞(即一個 詞項,term)都是一個維度。包含詞項 x 的文件在維度 x 上取值為 1,不包含 x 則取值為 0。搜尋提到“紅蘋果”的文件,就是同時尋找 紅 維度和 蘋果 維度都為 1 的文件。這樣一來,維度數可能非常龐大。
許多搜尋引擎使用 倒排索引(inverted index)回答這類查詢。它是一種鍵值結構:鍵是詞項,值是所有包含該詞項的文件 ID 列表,即 倒排列表(postings list)。如果文件 ID 是連續數字,倒排列表也可以表示成 圖 4-8 那樣的稀疏點陣圖:如果 ID 為 n 的文件包含詞項 x,那麼詞項 x 的點陣圖中第 n 位就是 1 89。
現在,查詢同時包含詞項 x 和 y 的所有文件,就類似於用向量化資料倉儲查詢尋找滿足兩個條件的行(圖 4-9):載入 x 和 y 對應的兩張點陣圖,再計算按位與。即使點陣圖經過遊程編碼,這個操作也可以高效完成。
Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就採用這種方法 90。它把從詞項到倒排列表的對映儲存在類似 SSTable 的有序檔案中,再使用本章前面介紹的日誌結構方法,在後臺合併這些檔案 91。PostgreSQL 的 GIN 索引也透過倒排列表支援全文檢索,以及 JSON 文件內部的索引 92 93。
除了把文字切分成詞,還可以找出所有長度為 n 的子串,稱為 n-gram。例如,字串 "hello" 的 3-gram(n = 3)是 "hel"、"ell" 和 "llo"。如果為所有 3-gram 建立倒排索引,就能搜尋任意長度至少為三個字元的子串。這樣的索引甚至支援在搜尋查詢中使用正規表示式,缺點是體積相當大 94。
為了應對文件或查詢中的拼寫錯誤,Lucene 可以搜尋與目標詞相差一定編輯距離的詞(編輯距離為 1,表示增加、刪除或替換了一個字母)95。它把詞項集合儲存成一個以鍵中字元為邊的有限狀態自動機,結構類似 字典樹(trie)96;再將其轉換成 萊文斯坦自動機(Levenshtein automaton),從而高效搜尋給定編輯距離以內的詞 97。
向量嵌入
語義搜尋不只處理同義詞和拼寫錯誤,還試圖理解文件表達的概念和使用者的意圖。例如,幫助中心有一頁標題是“取消訂閱”,那麼使用者搜尋“如何關閉賬戶”或“終止合同”時也應當找到它:這些說法用詞完全不同,意思卻十分接近。
為了理解文件的語義,也就是它表達的含義,語義搜尋索引會使用嵌入模型,把文件轉換成由浮點陣列成的向量,稱為 向量嵌入(vector embedding)。這個向量表示多維空間中的一個點,每個浮點數表示文件在某一維座標軸上的位置。如果輸入文件的語義相似,嵌入模型就會生成在多維空間中彼此接近的向量。
我們在 “查詢執行:編譯與向量化” 中見過 向量化處理 一詞。語義搜尋中的“向量”含義不同:向量化處理所說的向量,是一批可以用專門最佳化的程式碼處理的位元;嵌入模型所說的向量,則是一列浮點數,用來表示多維空間中的位置。
例如,一篇介紹農業的維基百科頁面,其三維向量嵌入可能是 [0.1, 0.22, 0.11]。介紹蔬菜的頁面應該離它很近,向量或許是 [0.13, 0.19, 0.24]。介紹星型模式的頁面則可能得到 [0.82, 0.39, -0.74],距離相對很遠。只看數字也能發現,前兩個向量比第三個更接近。
實際的嵌入模型使用大得多的向量,往往包含 1,000 多個數字,但原理相同。我們不會試圖理解每個數字各自代表什麼;它們只是嵌入模型用來指向抽象多維空間中某個位置的方式。搜尋引擎透過餘弦相似度、歐幾里得距離等距離函式衡量向量間的距離。餘弦相似度計算兩個向量夾角的餘弦,判斷它們有多接近;歐幾里得距離則計算空間中兩點間的直線距離。
Word2Vec 98、BERT 99 和 GPT 100 等許多早期嵌入模型都處理文字資料,通常以神經網路實現。後來,研究者又為影片、音訊和影象建立了嵌入模型。近來,模型架構進一步走向 多模態(multimodal):同一個模型可以為文字、影象等多種模態生成向量嵌入。
使用者輸入查詢時,語義搜尋引擎會把查詢及其相關上下文(例如使用者位置)交給嵌入模型,生成查詢的向量嵌入。隨後,搜尋引擎還必須透過向量索引,找出向量嵌入與查詢相似的文件。
向量索引儲存一組文件的向量嵌入。查詢時傳入查詢本身的向量嵌入,索引會返回向量最接近查詢向量的文件。前面介紹的 R 樹不適合維度很高的向量,因此需要專門的向量索引,例如:
- 平面索引(Flat indexes)
- 向量原樣儲存在索引中。查詢必須讀取每個向量,並測量它與查詢向量的距離。平面索引結果精確,但逐一計算查詢與每個向量的距離很慢。
- 倒排檔案(IVF)索引
- 把向量空間聚類成若干向量分割槽,以減少必須比較的向量數;這些分割槽稱為 質心(centroid)。IVF 索引比平面索引更快,卻只能給出近似結果:查詢向量和某個文件向量可能十分接近,卻恰好落入不同分割槽。查詢 IVF 索引時,首先要指定 探測數(probes),也就是需要檢查多少個分割槽。探測數越大,查詢越準確,但也越慢,因為必須比較更多向量。
- 分層可導航小世界(HNSW)
- HNSW 索引維護向量空間的多個層級,如 圖 4-11 所示。每層都表示成一張圖:節點代表向量,邊表示向量彼此接近。查詢先在節點很少的最頂層找到最近向量,再進入下一層的同一節點;下一層連線更密集,查詢沿邊尋找更接近查詢向量的向量。這個過程不斷重複,直至最底層。與 IVF 一樣,HNSW 也是近似索引。

許多流行的向量資料庫都實現了 IVF 和 HNSW 索引。Facebook 的 Faiss 庫為兩者提供了許多變體 101,PostgreSQL 的 pgvector 也同時支援這兩種索引 102。IVF 和 HNSW 演算法的完整細節超出了本書範圍,不過介紹它們的論文是很好的參考資料 103 104。
總結
在本章中,我們試圖深入瞭解資料庫是如何處理儲存與檢索的。把資料存入資料庫時會發生什麼?日後再次查詢這些資料時,資料庫又會做什麼?
“分析型與事務型系統” 介紹了事務處理(OLTP)與分析(OLAP)的區別。本章進一步看到,針對 OLTP 最佳化的儲存引擎,與針對分析最佳化的儲存引擎大不相同:
- OLTP 系統針對大量請求最佳化;每個請求只讀寫少量記錄,並且需要迅速響應。記錄通常透過主鍵或二級索引訪問,這些索引一般是從鍵到記錄的有序對映,也支援範圍查詢。
- 資料倉儲等分析型系統,針對需要掃描大量記錄的複雜讀查詢最佳化。它們通常採用經過壓縮的列式儲存佈局,儘可能減少查詢要從磁碟讀取的資料量;並透過查詢的即時編譯或向量化,儘可能減少處理資料所耗費的 CPU 時間。
在 OLTP 方面,我們看到了兩個主要思想流派的儲存引擎:
- 日誌結構學派只允許向檔案追加資料和刪除過時檔案,從不更新已經寫出的檔案。SSTable、LSM 樹、RocksDB、Cassandra、HBase、ScyllaDB、Lucene 等都屬於這一類。一般而言,日誌結構儲存引擎能提供很高的寫入吞吐量。
- 就地更新學派把磁碟視為一組大小固定、可以覆寫的頁。B 樹是這種理念最典型的代表,所有主流關係型 OLTP 資料庫和許多非關聯式資料庫都使用它。根據經驗,B 樹通常更適合讀取,其讀取吞吐量高於日誌結構儲存,響應時間也更短。
隨後,我們考察了能夠同時搜尋多個條件的索引:R 樹等多維索引可以同時按經度和緯度搜尋地圖上的點;全文檢索索引則可以搜尋同一段文字中出現的多個關鍵詞。最後,向量資料庫用於對文字文件及其他媒體進行語義搜尋;它把資料表示成高維向量,再透過比較向量相似度找出相似文件。
作為應用開發者,如果掌握了這些有關儲存引擎內部機制的知識,就能更好地判斷哪種工具最適合自己的應用。需要調整資料庫的調優引數時,這種理解也讓你能夠設想引數調高或調低會產生怎樣的影響。
儘管本章無法讓你成為某一種儲存引擎的調優專家,但希望它已經提供了足夠的概念和詞彙,讓你能夠讀懂自己所選資料庫的文件。
參考文獻
Nikolay Samokhvalov. How partial, covering, and multicolumn indexes may slow down UPDATEs in PostgreSQL. postgres.ai, October 2021. Archived at perma.cc/PBK3-F4G9 ↩︎
Goetz Graefe. Modern B-Tree Techniques. Foundations and Trends in Databases, volume 3, issue 4, pages 203–402, August 2011. doi:10.1561/1900000028 ↩︎ ↩︎ ↩︎
Evan Jones. Why databases use ordered indexes but programming uses hash tables. evanjones.ca, December 2019. Archived at perma.cc/NJX8-3ZZD ↩︎
Branimir Lambov. CEP-25: Trie-indexed SSTable format. cwiki.apache.org, November 2022. Archived at perma.cc/HD7W-PW8U. Linked Google Doc archived at perma.cc/UL6C-AAAE ↩︎
Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein: Introduction to Algorithms, 3rd edition. MIT Press, 2009. ISBN: 978-0-262-53305-8 ↩︎ ↩︎ ↩︎
Branimir Lambov. Trie Memtables in Cassandra. Proceedings of the VLDB Endowment, volume 15, issue 12, pages 3359–3371, August 2022. doi:10.14778/3554821.3554828 ↩︎
Dhruba Borthakur. The History of RocksDB. rocksdb.blogspot.com, November 2013. Archived at perma.cc/Z7C5-JPSP ↩︎
Matteo Bertozzi. Apache HBase I/O – HFile. blog.cloudera.com, June 2012. Archived at perma.cc/U9XH-L2KL ↩︎
Fay Chang, Jeffrey Dean, Sanjay Ghemawat, Wilson C. Hsieh, Deborah A. Wallach, Mike Burrows, Tushar Chandra, Andrew Fikes, and Robert E. Gruber. Bigtable: A Distributed Storage System for Structured Data. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006. ↩︎ ↩︎
Patrick O’Neil, Edward Cheng, Dieter Gawlick, and Elizabeth O’Neil. The Log-Structured Merge-Tree (LSM-Tree). Acta Informatica, volume 33, issue 4, pages 351–385, June 1996. doi:10.1007/s002360050048 ↩︎
Mendel Rosenblum and John K. Ousterhout. The Design and Implementation of a Log-Structured File System. ACM Transactions on Computer Systems, volume 10, issue 1, pages 26–52, February 1992. doi:10.1145/146941.146943 ↩︎
Michael Armbrust, Tathagata Das, Liwen Sun, Burak Yavuz, Shixiong Zhu, Mukul Murthy, Joseph Torres, Herman van Hovell, Adrian Ionescu, Alicja Łuszczak, Michał Świtakowski, Michał Szafrański, Xiao Li, Takuya Ueshin, Mostafa Mokhtar, Peter Boncz, Ali Ghodsi, Sameer Paranjpye, Pieter Senster, Reynold Xin, and Matei Zaharia. Delta Lake: High-Performance ACID Table Storage over Cloud Object Stores. Proceedings of the VLDB Endowment, volume 13, issue 12, pages 3411–3424, August 2020. doi:10.14778/3415478.3415560 ↩︎ ↩︎
Burton H. Bloom. Space/Time Trade-offs in Hash Coding with Allowable Errors. Communications of the ACM, volume 13, issue 7, pages 422–426, July 1970. doi:10.1145/362686.362692 ↩︎
Adam Kirsch and Michael Mitzenmacher. Less Hashing, Same Performance: Building a Better Bloom Filter. Random Structures & Algorithms, volume 33, issue 2, pages 187–218, September 2008. doi:10.1002/rsa.20208 ↩︎
Thomas Hurst. Bloom Filter Calculator. hur.st, September 2023. Archived at perma.cc/L3AV-6VC2 ↩︎
Chen Luo and Michael J. Carey. LSM-based storage techniques: a survey. The VLDB Journal, volume 29, pages 393–418, July 2019. doi:10.1007/s00778-019-00555-y ↩︎
Subhadeep Sarkar and Manos Athanassoulis. Dissecting, Designing, and Optimizing LSM-based Data Stores. Tutorial at ACM International Conference on Management of Data (SIGMOD), June 2022. Slides archived at perma.cc/93B3-E827 ↩︎
Mark Callaghan. Name that compaction algorithm. smalldatum.blogspot.com, August 2018. Archived at perma.cc/CN4M-82DY ↩︎
Prashanth Rao. Embedded databases (1): The harmony of DuckDB, KùzuDB and LanceDB. thedataquarry.com, August 2023. Archived at perma.cc/PA28-2R35 ↩︎
Hacker News discussion. Bluesky migrates to single-tenant SQLite. news.ycombinator.com, October 2023. Archived at perma.cc/69LM-5P6X ↩︎
Rudolf Bayer and Edward M. McCreight. Organization and Maintenance of Large Ordered Indices. Boeing Scientific Research Laboratories, Mathematical and Information Sciences Laboratory, report no. 20, July 1970. doi:10.1145/1734663.1734671 ↩︎
Douglas Comer. The Ubiquitous B-Tree. ACM Computing Surveys, volume 11, issue 2, pages 121–137, June 1979. doi:10.1145/356770.356776 ↩︎
Alex Miller. Torn Write Detection and Protection. transactional.blog, April 2025. Archived at perma.cc/G7EB-33EW ↩︎
C. Mohan and Frank Levine. ARIES/IM: An Efficient and High Concurrency Index Management Method Using Write-Ahead Logging. At ACM International Conference on Management of Data (SIGMOD), June 1992. doi:10.1145/130283.130338 ↩︎
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩︎ ↩︎
Howard Chu. LDAP at Lightning Speed. At Build Stuff ’14, November 2014. Archived at perma.cc/GB6Z-P8YH ↩︎
Manos Athanassoulis, Michael S. Kester, Lukas M. Maas, Radu Stoica, Stratos Idreos, Anastasia Ailamaki, and Mark Callaghan. Designing Access Methods: The RUM Conjecture. At 19th International Conference on Extending Database Technology (EDBT), March 2016. doi:10.5441/002/edbt.2016.42 ↩︎
Ben Stopford. Log Structured Merge Trees. benstopford.com, February 2015. Archived at perma.cc/E5BV-KUJ6 ↩︎
Mark Callaghan. The Advantages of an LSM vs a B-Tree. smalldatum.blogspot.co.uk, January 2016. Archived at perma.cc/3TYZ-EFUD ↩︎
Oana Balmau, Florin Dinu, Willy Zwaenepoel, Karan Gupta, Ravishankar Chandhiramoorthi, and Diego Didona. SILK: Preventing Latency Spikes in Log-Structured Merge Key-Value Stores. At USENIX Annual Technical Conference, July 2019. ↩︎
Igor Canadi, Siying Dong, Mark Callaghan, et al. RocksDB Tuning Guide. github.com, 2023. Archived at perma.cc/UNY4-MK6C ↩︎
Gabriel Haas and Viktor Leis. What Modern NVMe Storage Can Do, and How to Exploit it: High-Performance I/O for High-Performance Storage Engines. Proceedings of the VLDB Endowment, volume 16, issue 9, pages 2090-2102. doi:10.14778/3598581.3598584 ↩︎
Emmanuel Goossaert. Coding for SSDs. codecapsule.com, February 2014. ↩︎
Jack Vanlightly. Is sequential IO dead in the era of the NVMe drive? jack-vanlightly.com, May 2023. Archived at perma.cc/7TMZ-TAPU ↩︎
Alibaba Cloud Storage Team. Storage System Design Analysis: Factors Affecting NVMe SSD Performance (2). alibabacloud.com, January 2019. Archived at archive.org ↩︎
Xiao-Yu Hu and Robert Haas. The Fundamental Limit of Flash Random Write Performance: Understanding, Analysis and Performance Modelling. dominoweb.draco.res.ibm.com, March 2010. Archived at perma.cc/8JUL-4ZDS ↩︎
Lanyue Lu, Thanumalayan Sankaranarayana Pillai, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. WiscKey: Separating Keys from Values in SSD-conscious Storage. At 4th USENIX Conference on File and Storage Technologies (FAST), February 2016. ↩︎
Peter Zaitsev. Innodb Double Write. percona.com, August 2006. Archived at perma.cc/NT4S-DK7T ↩︎
Tomas Vondra. On the Impact of Full-Page Writes. 2ndquadrant.com, November 2016. Archived at perma.cc/7N6B-CVL3 ↩︎
Mark Callaghan. Read, write & space amplification - B-Tree vs LSM. smalldatum.blogspot.com, November 2015. Archived at perma.cc/S487-WK5P ↩︎ ↩︎
Mark Callaghan. Choosing Between Efficiency and Performance with RocksDB. At Code Mesh, November 2016. Video at youtube.com/watch?v=tgzkgZVXKB4 ↩︎
Subhadeep Sarkar, Tarikul Islam Papon, Dimitris Staratzis, Zichen Zhu, and Manos Athanassoulis. Enabling Timely and Persistent Deletion in LSM-Engines. ACM Transactions on Database Systems, volume 48, issue 3, article no. 8, August 2023. doi:10.1145/3599724 ↩︎
Lukas Fittl. Postgres vs. SQL Server: B-Tree Index Differences & the Benefit of Deduplication. pganalyze.com, April 2025. Archived at perma.cc/XY6T-LTPX ↩︎
Drew Silcock. How Postgres stores data on disk – this one’s a page turner. drew.silcock.dev, August 2024. Archived at perma.cc/8K7K-7VJ2 ↩︎
Joe Webb. Using Covering Indexes to Improve Query Performance. simple-talk.com, September 2008. Archived at perma.cc/6MEZ-R5VR ↩︎
Michael Stonebraker, Samuel Madden, Daniel J. Abadi, Stavros Harizopoulos, Nabil Hachem, and Pat Helland. The End of an Architectural Era (It’s Time for a Complete Rewrite). At 33rd International Conference on Very Large Data Bases (VLDB), September 2007. ↩︎
VoltDB Technical Overview White Paper. VoltDB, 2017. Archived at perma.cc/B9SF-SK5G ↩︎
Stephen M. Rumble, Ankita Kejriwal, and John K. Ousterhout. Log-Structured Memory for DRAM-Based Storage. At 12th USENIX Conference on File and Storage Technologies (FAST), February 2014. ↩︎
Stavros Harizopoulos, Daniel J. Abadi, Samuel Madden, and Michael Stonebraker. OLTP Through the Looking Glass, and What We Found There. At ACM International Conference on Management of Data (SIGMOD), June 2008. doi:10.1145/1376616.1376713 ↩︎
Per-Åke Larson, Cipri Clinciu, Campbell Fraser, Eric N. Hanson, Mostafa Mokhtar, Michal Nowakiewicz, Vassilis Papadimos, Susan L. Price, Srikumar Rangarajan, Remus Rusanu, and Mayukh Saubhasik. Enhancements to SQL Server Column Stores. At ACM International Conference on Management of Data (SIGMOD), June 2013. doi:10.1145/2463676.2463708 ↩︎ ↩︎
Franz Färber, Norman May, Wolfgang Lehner, Philipp Große, Ingo Müller, Hannes Rauhe, and Jonathan Dees. The SAP HANA Database – An Architecture Overview. IEEE Data Engineering Bulletin, volume 35, issue 1, pages 28–33, March 2012. ↩︎
Michael Stonebraker. The Traditional RDBMS Wisdom Is (Almost Certainly) All Wrong. Presentation at EPFL, May 2013. ↩︎ ↩︎
Adam Prout, Szu-Po Wang, Joseph Victor, Zhou Sun, Yongzhu Li, Jack Chen, Evan Bergeron, Eric Hanson, Robert Walzer, Rodrigo Gomes, and Nikita Shamgunov. Cloud-Native Transactions and Analytics in SingleStore. At ACM International Conference on Management of Data (SIGMOD), June 2022. doi:10.1145/3514221.3526055 ↩︎
Tino Tereshko and Jordan Tigani. BigQuery under the hood. cloud.google.com, January 2016. Archived at perma.cc/WP2Y-FUCF ↩︎
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/6L2M-GTJX ↩︎
Michael Stonebraker, Daniel J. Abadi, Adam Batkin, Xuedong Chen, Mitch Cherniack, Miguel Ferreira, Edmond Lau, Amerson Lin, Sam Madden, Elizabeth O’Neil, Pat O’Neil, Alex Rasin, Nga Tran, and Stan Zdonik. C-Store: A Column-oriented DBMS. At 31st International Conference on Very Large Data Bases (VLDB), pages 553–564, September 2005. ↩︎
Julien Le Dem. Dremel Made Simple with Parquet. blog.twitter.com, September 2013. ↩︎
Sergey Melnik, Andrey Gubarev, Jing Jing Long, Geoffrey Romer, Shiva Shivakumar, Matt Tolton, and Theo Vassilakis. Dremel: Interactive Analysis of Web-Scale Datasets. At 36th International Conference on Very Large Data Bases (VLDB), pages 330–339, September 2010. doi:10.14778/1920841.1920886 ↩︎
Joe Kearney. Understanding Record Shredding: storing nested data in columns. joekearney.co.uk, December 2016. Archived at perma.cc/ZD5N-AX5D ↩︎
Jamie Brandon. A shallow survey of OLAP and HTAP query engines. scattered-thoughts.net, September 2023. Archived at perma.cc/L3KH-J4JF ↩︎ ↩︎
Benoit Dageville, Thierry Cruanes, Marcin Zukowski, Vadim Antonov, Artin Avanes, Jon Bock, Jonathan Claybaugh, Daniel Engovatov, Martin Hentschel, Jiansheng Huang, Allison W. Lee, Ashish Motivala, Abdul Q. Munir, Steven Pelley, Peter Povinec, Greg Rahn, Spyridon Triantafyllis, and Philipp Unterbrunner. The Snowflake Elastic Data Warehouse. At ACM International Conference on Management of Data (SIGMOD), pages 215–226, June 2016. doi:10.1145/2882903.2903741 ↩︎ ↩︎
Mark Raasveldt and Hannes Mühleisen. Data Management for Data Science Towards Embedded Analytics. At 10th Conference on Innovative Data Systems Research (CIDR), January 2020. ↩︎
Jean-François Im, Kishore Gopalakrishna, Subbu Subramaniam, Mayank Shrivastava, Adwait Tumbde, Xiaotian Jiang, Jennifer Dai, Seunghyun Lee, Neha Pawar, Jialiang Li, and Ravi Aringunram. Pinot: Realtime OLAP for 530 Million Users. At ACM International Conference on Management of Data (SIGMOD), pages 583–594, May 2018. doi:10.1145/3183713.3190661 ↩︎ ↩︎
Fangjin Yang, Eric Tschetter, Xavier Léauté, Nelson Ray, Gian Merlino, and Deep Ganguli. Druid: A Real-time Analytical Data Store. At ACM International Conference on Management of Data (SIGMOD), June 2014. doi:10.1145/2588555.2595631 ↩︎ ↩︎
Chunwei Liu, Anna Pavlenko, Matteo Interlandi, and Brandon Haynes. Deep Dive into Common Open Formats for Analytical DBMSs. Proceedings of the VLDB Endowment, volume 16, issue 11, pages 3044–3056, July 2023. doi:10.14778/3611479.3611507 ↩︎ ↩︎
Xinyu Zeng, Yulong Hui, Jiahong Shen, Andrew Pavlo, Wes McKinney, and Huanchen Zhang. An Empirical Evaluation of Columnar Storage Formats. Proceedings of the VLDB Endowment, volume 17, issue 2, pages 148–161. doi:10.14778/3626292.3626298 ↩︎
Weston Pace. Lance v2: A columnar container format for modern data. blog.lancedb.com, April 2024. Archived at perma.cc/ZK3Q-S9VJ ↩︎
Yoav Helfman. Nimble, A New Columnar File Format. At VeloxCon, April 2024. ↩︎
Wes McKinney. Apache Arrow: High-Performance Columnar Data Framework. At CMU Database Group – Vaccination Database Tech Talks, December 2021. ↩︎
Wes McKinney. Python for Data Analysis, 3rd Edition. O’Reilly Media, August 2022. ISBN: 9781098104023 ↩︎
Paul Dix. The Design of InfluxDB IOx: An In-Memory Columnar Database Written in Rust with Apache Arrow. At CMU Database Group – Vaccination Database Tech Talks, May 2021. ↩︎
Carlota Soto and Mike Freedman. Building Columnar Compression for Large PostgreSQL Databases. timescale.com, March 2024. Archived at perma.cc/7KTF-V3EH ↩︎
Daniel Lemire, Gregory Ssi‐Yan‐Kai, and Owen Kaser. Consistently faster and smaller compressed bitmaps with Roaring. Software: Practice and Experience, volume 46, issue 11, pages 1547–1569, November 2016. doi:10.1002/spe.2402 ↩︎
Jaz Volpert. An entire Social Network in 1.6GB (GraphD Part 2). jazco.dev, April 2024. Archived at perma.cc/L27Z-QVMG ↩︎
Daniel J. Abadi, Peter Boncz, Stavros Harizopoulos, Stratos Idreos, and Samuel Madden. The Design and Implementation of Modern Column-Oriented Database Systems. Foundations and Trends in Databases, volume 5, issue 3, pages 197–280, December 2013. doi:10.1561/1900000024 ↩︎ ↩︎
Andrew Lamb, Matt Fuller, Ramakrishna Varadarajan, Nga Tran, Ben Vandiver, Lyric Doshi, and Chuck Bear. The Vertica Analytic Database: C-Store 7 Years Later. Proceedings of the VLDB Endowment, volume 5, issue 12, pages 1790–1801, August 2012. doi:10.14778/2367502.2367518 ↩︎
Timo Kersten, Viktor Leis, Alfons Kemper, Thomas Neumann, Andrew Pavlo, and Peter Boncz. Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask. Proceedings of the VLDB Endowment, volume 11, issue 13, pages 2209–2222, September 2018. doi:10.14778/3275366.3284966 ↩︎ ↩︎
Forrest Smith. Memory Bandwidth Napkin Math. forrestthewoods.com, February 2020. Archived at perma.cc/Y8U4-PS7N ↩︎
Peter Boncz, Marcin Zukowski, and Niels Nes. MonetDB/X100: Hyper-Pipelining Query Execution. At 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005. ↩︎
Jingren Zhou and Kenneth A. Ross. Implementing Database Operations Using SIMD Instructions. At ACM International Conference on Management of Data (SIGMOD), pages 145–156, June 2002. doi:10.1145/564691.564709 ↩︎
Kevin Bartley. OLTP Queries: Transfer Expensive Workloads to Materialize. materialize.com, August 2024. Archived at perma.cc/4TYM-TYD8 ↩︎
Jim Gray, Surajit Chaudhuri, Adam Bosworth, Andrew Layman, Don Reichart, Murali Venkatrao, Frank Pellow, and Hamid Pirahesh. Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals. Data Mining and Knowledge Discovery, volume 1, issue 1, pages 29–53, March 2007. doi:10.1023/A:1009726021843 ↩︎
Frank Ramsak, Volker Markl, Robert Fenk, Martin Zirkel, Klaus Elhardt, and Rudolf Bayer. Integrating the UB-Tree into a Database System Kernel. At 26th International Conference on Very Large Data Bases (VLDB), September 2000. ↩︎
Octavian Procopiuc, Pankaj K. Agarwal, Lars Arge, and Jeffrey Scott Vitter. Bkd-Tree: A Dynamic Scalable kd-Tree. At 8th International Symposium on Spatial and Temporal Databases (SSTD), pages 46–65, July 2003. doi:10.1007/978-3-540-45072-6_4 ↩︎
Joseph M. Hellerstein, Jeffrey F. Naughton, and Avi Pfeffer. Generalized Search Trees for Database Systems. At 21st International Conference on Very Large Data Bases (VLDB), September 1995. ↩︎
Isaac Brodsky. H3: Uber’s Hexagonal Hierarchical Spatial Index. eng.uber.com, June 2018. Archived at archive.org ↩︎
Robert Escriva, Bernard Wong, and Emin Gün Sirer. HyperDex: A Distributed, Searchable Key-Value Store. At ACM SIGCOMM Conference, August 2012. doi:10.1145/2377677.2377681 ↩︎
Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze. Introduction to Information Retrieval. Cambridge University Press, 2008. ISBN: 978-0-521-86571-5, available online at nlp.stanford.edu/IR-book ↩︎
Jianguo Wang, Chunbin Lin, Yannis Papakonstantinou, and Steven Swanson. An Experimental Study of Bitmap Compression vs. Inverted List Compression. At ACM International Conference on Management of Data (SIGMOD), pages 993–1008, May 2017. doi:10.1145/3035918.3064007 ↩︎
Adrien Grand. What is in a Lucene Index? At Lucene/Solr Revolution, November 2013. Archived at perma.cc/Z7QN-GBYY ↩︎
Michael McCandless. Visualizing Lucene’s Segment Merges. blog.mikemccandless.com, February 2011. Archived at perma.cc/3ZV8-72W6 ↩︎
Lukas Fittl. Understanding Postgres GIN Indexes: The Good and the Bad. pganalyze.com, December 2021. Archived at perma.cc/V3MW-26H6 ↩︎
Jimmy Angelakos. The State of (Full) Text Search in PostgreSQL 12. At FOSDEM, February 2020. Archived at perma.cc/J6US-3WZS ↩︎
Alexander Korotkov. Index support for regular expression search. At PGConf.EU Prague, October 2012. Archived at perma.cc/5RFZ-ZKDQ ↩︎
Michael McCandless. Lucene’s FuzzyQuery Is 100 Times Faster in 4.0. blog.mikemccandless.com, March 2011. Archived at perma.cc/E2WC-GHTW ↩︎
Steffen Heinz, Justin Zobel, and Hugh E. Williams. Burst Tries: A Fast, Efficient Data Structure for String Keys. ACM Transactions on Information Systems, volume 20, issue 2, pages 192–223, April 2002. doi:10.1145/506309.506312 ↩︎
Klaus U. Schulz and Stoyan Mihov. Fast String Correction with Levenshtein Automata. International Journal on Document Analysis and Recognition, volume 5, issue 1, pages 67–85, November 2002. doi:10.1007/s10032-002-0082-8 ↩︎
Tomas Mikolov, Kai Chen, Greg Corrado, and Jeffrey Dean. Efficient Estimation of Word Representations in Vector Space. At International Conference on Learning Representations (ICLR), May 2013. doi:10.48550/arXiv.1301.3781 ↩︎
Jacob Devlin, Ming-Wei Chang, Kenton Lee, and Kristina Toutanova. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. At Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies, volume 1, pages 4171–4186, June 2019. doi:10.18653/v1/N19-1423 ↩︎
Alec Radford, Karthik Narasimhan, Tim Salimans, and Ilya Sutskever. Improving Language Understanding by Generative Pre-Training. openai.com, June 2018. Archived at perma.cc/5N3C-DJ4C ↩︎
Matthijs Douze, Maria Lomeli, and Lucas Hosseini. Faiss indexes. github.com, August 2024. Archived at perma.cc/2EWG-FPBS ↩︎
Varik Matevosyan. Understanding pgvector’s HNSW Index Storage in Postgres. lantern.dev, August 2024. Archived at perma.cc/B2YB-JB59 ↩︎
Dmitry Baranchuk, Artem Babenko, and Yury Malkov. Revisiting the Inverted Indices for Billion-Scale Approximate Nearest Neighbors. At European Conference on Computer Vision (ECCV), pages 202–216, September 2018. doi:10.1007/978-3-030-01258-8_13 ↩︎
Yury A. Malkov and Dmitry A. Yashunin. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. IEEE Transactions on Pattern Analysis and Machine Intelligence, volume 42, issue 4, pages 824–836, April 2020. doi:10.1109/TPAMI.2018.2889473 ↩︎
5 編碼與演化

唯變所適。
以弗所的赫拉克利特,引自柏拉圖《克拉提魯斯》(公元前 360 年)
應用程式不可避免地會隨時間而變化。隨著新產品推出、對使用者需求的理解日益深入,或者商業環境發生變化,應用程式總要增添或修改功能。在 第 2 章 中,我們介紹了 可演化性(evolvability)的概念:應該盡力構建能夠靈活適應變化的系統(參見“可演化性:讓變化更容易”)。
在大多數情況下,修改應用程式的功能也意味著需要更改其儲存的資料:可能需要記錄新的欄位或記錄型別,也可能需要以新的方式呈現現有資料。
我們在 第 3 章 中討論的資料模型,採用不同的方法來應對這種變化。關聯式資料庫通常假定資料庫中的所有資料都遵循同一個模式:儘管可以透過模式遷移(即 ALTER 語句)來更改模式,但任何時刻都只有一個模式有效。相比之下,讀時模式(即“無模式”)資料庫不會強制使用某個模式,因此資料庫中可以混合存放在不同時間寫入的新舊資料格式(參見“文件模型中的模式靈活性”)。
當資料格式或模式發生變化時,通常也需要相應地修改應用程式程式碼(例如,為記錄新增新欄位,然後讓應用程式開始讀寫該欄位)。但在大型應用程式中,程式碼變更往往無法瞬間完成:
- 對於服務端應用程式,可能需要執行 滾動升級(rolling upgrade,也稱為 逐步釋出,staged rollout):每次只把新版本部署到少數幾個節點,確認執行正常後,再逐步部署到所有節點。這樣無需中斷服務即可上線新版本,有利於更頻繁地釋出,也讓系統更容易演化。
- 對於客戶端應用程式,是否升級只能任由使用者決定,而使用者可能很長時間都不安裝更新。
這意味著,新舊版本的程式碼以及新舊資料格式,可能同時存在於系統中。系統要繼續順利執行,就需要保持雙向相容:
- 向後相容(backward compatibility)
- 較新的程式碼可以讀取由較舊程式碼寫入的資料。
- 向前相容(forward compatibility)
- 較舊的程式碼可以讀取由較新程式碼寫入的資料。
向後相容通常不難實現:新程式碼的作者知道舊程式碼寫入的資料格式,因此可以顯式地處理它(必要時,只要保留讀取舊資料的舊程式碼即可)。向前相容則可能棘手得多,因為舊程式碼必須忽略新版本程式碼新增的部分。
向前相容還有一個難點,如 圖 5-1 所示。假設你在記錄模式中新增了一個欄位,新程式碼建立了一條包含這個欄位的記錄,並把它存入資料庫。隨後,尚不瞭解這個新欄位的舊版程式碼讀出記錄,做了更新,又將其寫回。在這種情況下,通常希望舊程式碼把新欄位原樣保留下來,即使它無法解釋該欄位。但如果記錄被解碼成一個不會顯式保留未知欄位的模型物件,資料就可能丟失,如 圖 5-1 所示。

本章將介紹幾種資料編碼格式,包括 JSON、XML、Protocol Buffers 和 Avro。我們尤其關注這些格式如何應對模式變化,以及如何支援新舊資料與新舊程式碼共存。隨後,我們會討論這些格式如何用於儲存和通訊,包括資料庫、Web 服務、REST API、遠端過程呼叫(RPC)、工作流引擎,以及 actor 和訊息佇列等事件驅動系統。
編碼資料的格式
程式通常(至少)使用兩種形式的資料:
- 在記憶體中,資料儲存在物件、結構體、列表、陣列、雜湊表、樹等資料結構中。這些結構通常使用指標,針對 CPU 的高效訪問與操作進行了最佳化。
- 如果要將資料寫入檔案或透過網路傳送,就必須將其編碼成某種自包含的位元組序列(例如 JSON 文件)。由於指標對其他程序沒有意義,這種位元組序列表示通常與記憶體中的資料結構大不相同。
因此,需要在兩種表示之間進行轉換。從記憶體表示轉換為位元組序列,稱為 編碼(encoding,也稱為 序列化,serialization,或 編組,marshalling);反過來則稱為 解碼(decoding,也稱為 解析,parsing,反序列化,deserialization,或 解組,unmarshalling)。
遺憾的是,序列化 一詞也用於事務的語境,而且含義完全不同(參見 第 8 章)。雖然“序列化”可能更常用,但為了避免一詞多義,本書在這裡始終使用 編碼。
也有一些情況不需要編碼和解碼。例如,“查詢執行:編譯與向量化” 中介紹過,資料庫可以直接操作從磁碟載入的壓縮資料。還有一些 零複製(zero-copy)資料格式,例如 Cap’n Proto 和 FlatBuffers;它們既可用於執行時,也可直接用於磁碟或網路上的資料,無需顯式的轉換步驟。
不過,大多數系統仍需在記憶體物件與扁平的位元組序列之間轉換。這是個極其常見的問題,因而有數不清的庫和編碼格式可供選擇。下面先來簡要概覽一下。
特定語言的格式
許多程式語言都內建了將記憶體物件編碼成位元組序列的功能。例如,Java 有 java.io.Serializable,Python 有 pickle,Ruby 有 Marshal,等等。此外還有許多第三方庫,例如 Java 的 Kryo。
這些編碼庫非常方便,只需很少的額外程式碼就能儲存和恢復記憶體物件。但是,它們也有一些深層次的問題:
- 這類編碼通常與某種程式語言緊密繫結,其他語言很難讀取。如果用這類編碼儲存或傳輸資料,就可能在很長一段時間內把自己鎖定在當前語言上,也很難與其他組織的系統整合——它們使用的語言可能與你不同。
- 為了恢復出相同型別的物件,解碼過程必須能夠例項化任意類,這經常成為安全漏洞的來源 1。如果攻擊者能讓應用程式解碼任意位元組序列,就可能借此例項化任意類,進而實施遠端執行任意程式碼之類的惡意操作 2 3。
- 這些庫通常事後才考慮資料的版本管理。它們只求快速、方便地編碼資料,往往忽略向前相容和向後相容這些棘手的問題 4。
- 效率——包括編碼或解碼所耗的 CPU 時間,以及編碼結果的大小——往往也是事後才考慮的。例如,Java 的內建序列化就因效能糟糕、編碼臃腫而臭名昭著 5。
因此,除非資料只作非常短暫的使用,否則採用語言內建的編碼通常不是個好主意。
JSON、XML 及其二進位制變體
說到可由多種程式語言讀寫的標準編碼,JSON 和 XML 是最顯眼的候選者。它們廣為人知、廣受支援,也幾乎同樣“廣受憎惡”。XML 經常因為過於冗長和不必要的複雜而受到批評 6。JSON 的流行,主要得益於 Web 瀏覽器的內建支援,以及它相對於 XML 的簡單性。CSV 是另一種流行的語言無關格式,但它只能表示不含巢狀的表格資料。
JSON、XML 和 CSV 都是文字格式,因而具有一定的人類可讀性(儘管它們的語法一直是熱門爭議話題)。除了表面的語法問題,它們還有一些不易察覺的麻煩:
數字 的編碼有很多模糊之處。在 XML 和 CSV 中,無法區分數值和碰巧只由數字組成的字串,除非藉助外部模式。JSON 雖然區分字串和數值,卻不區分整數與浮點數,也沒有規定精度。
處理大數時,這會造成問題。例如,大於 2⁵³ 的整數無法用 IEEE 754 雙精度浮點數精確表示;如果某種語言像 JavaScript 一樣使用浮點數來解析數值,這些大整數就會失真 7。X(原 Twitter)使用 64 位數字標識每條帖子,便是一個實際例子。為了繞過 JavaScript 應用程式無法正確解析這類數字的問題,其 API 返回的 JSON 會把帖子 ID 包含兩次:一次作為 JSON 數值,另一次作為十進位制字串 8。
JSON 和 XML 對 Unicode 字串(即人類可讀的文字)支援得很好,卻不支援二進位制字串(即不帶字元編碼的位元組序列)。二進位制字串非常有用,人們通常把二進位制資料用 Base64 編碼成文字來繞過這一限制,再由模式說明該值應按 Base64 解讀。這個辦法雖然管用,卻有些取巧,而且會讓資料體積增加 33%。
XML 模式和 JSON 模式功能強大,因而學習和實現起來都相當複雜。數字、二進位制字串等資料的正確解釋依賴模式中的資訊,所以不使用 XML/JSON 模式的應用程式可能不得不硬編碼相應的編碼與解碼邏輯。
CSV 沒有任何模式,每行每列的含義完全由應用程式自行定義。如果應用程式變更增加了一行或一列,就必須手工處理這種變化。CSV 本身也相當含糊:如果值中包含逗號或換行符,該怎麼辦?儘管轉義規則已有正式規範 9,卻不是所有解析器都正確實現了它。
儘管有這些缺陷,JSON、XML 和 CSV 對許多用途來說已經足夠好。它們很可能會繼續流行,尤其是作為資料交換格式——也就是把資料從一個組織傳送給另一個組織。在這種情況下,只要大家能就格式達成一致,格式是否美觀或高效往往並不重要。畢竟,讓不同組織對 任何事情 達成一致,就已經壓倒了大多數其他考量。
JSON 模式
當資料需要在系統間交換或寫入儲存時,JSON 模式已經成為廣泛採用的資料建模方式。它出現在許多地方:作為 OpenAPI Web 服務規範的一部分用於 Web 服務(參見“Web 服務”);用於 Confluent Schema Registry、Red Hat Apicurio Registry 等模式登錄檔;也用於資料庫,例如 PostgreSQL 的 pg_jsonschema 驗證擴充套件,以及 MongoDB 的 $jsonSchema 驗證語法。
JSON 模式規範提供了許多功能。它包含字串、數值、整數、物件、陣列、布林值和空值等標準基本型別,還另有一套驗證規範,開發者可以用它給欄位附加約束。例如,可以規定 port 欄位的最小值為 1、最大值為 65535。
JSON 模式可以採用開放或封閉的內容模型。開放內容模型允許出現模式未定義的任意欄位,而且這些欄位可以是任意資料型別;封閉內容模型則只允許出現顯式定義的欄位。在 JSON 模式中,把 additionalProperties 設為 true 就會啟用開放內容模型,而這恰好是預設值。因此,JSON 模式通常是在定義 不允許什麼(也就是已定義欄位上的無效值),而不是窮舉模式中 允許什麼。
開放內容模型功能強大,但也可能相當複雜。假設你想定義一個從整數(例如 ID)到字串的對映。JSON 沒有對映或字典型別,只有“物件”型別;物件的鍵必須是字串,值則可以是任意型別。此時可以藉助 JSON 模式,用 patternProperties 和 additionalProperties 約束物件,規定鍵只能由數字組成、值只能是字串,如 示例 5-1 所示。
除了開放和封閉內容模型以及驗證器,JSON 模式還支援條件式 if/else 模式邏輯、命名型別、遠端模式引用等諸多功能。這些能力造就了一門十分強大的模式語言,卻也讓模式定義變得龐雜難用。解析遠端模式、推斷條件規則,或者以向前或向後相容的方式演化模式,都可能很有挑戰 10。XML 模式也有類似的問題 11。
二進位制編碼
JSON 比 XML 簡潔,但兩者與二進位制格式相比仍然很佔空間。於是,人們開發出了大量 JSON 的二進位制編碼(例如 MessagePack、CBOR、BSON、BJSON、UBJSON、BISON、Hessian 和 Smile)以及 XML 的二進位制編碼(例如 WBXML 和 Fast Infoset)。這些格式更緊湊,有時解析也更快,因此在各自的細分領域得到應用;但沒有一種像文字版 JSON 和 XML 那樣普及 12。
其中一些格式擴充套件了資料型別集合,例如區分整數和浮點數,或者支援二進位制字串;除此之外,它們仍然沿用 JSON/XML 的資料模型。尤其是,它們沒有規定模式,所以必須把所有物件欄位名都寫進編碼後的資料。也就是說,對 示例 5-2 中的 JSON 文件進行二進位制編碼時,某處仍須包含 userName、favoriteNumber 和 interests 這些字串。
下面來看 MessagePack,它是 JSON 的一種二進位制編碼。圖 5-2 展示了用 MessagePack 編碼 示例 5-2 中的 JSON 文件後得到的位元組序列。開頭幾個位元組的含義如下:
- 第一個位元組
0x83表示接下來是一個物件(高四位 =0x80),其中有三個欄位(低四位 =0x03)。(如果物件超過 15 個欄位,欄位數無法裝進四位,就會改用另一種型別識別符號,並用兩個或四個位元組編碼欄位數。) - 第二個位元組
0xa8表示接下來是一個字串(高四位 =0xa0),長度為八個位元組(低四位 =0x08)。 - 接下來的八個位元組,是以 ASCII 編碼的欄位名
userName。由於前面已經給出長度,不需要再用標記或轉義來指示字串在哪裡結束。 - 再往後的七個位元組,以字首
0xa6加六個字母的方式編碼字串值Martin,後續內容依此類推。
二進位制編碼共 66 位元組,只比去掉空白後的文字 JSON(81 位元組)略小一點。各種 JSON 二進位制編碼在這方面都差不多。如此有限的空間節省(外加也許更快的解析速度),是否值得犧牲人類可讀性,並不好說。
接下來我們會看到,同一條記錄其實可以只用 32 位元組編碼,效果好得多。

Protocol Buffers
Protocol Buffers(protobuf)是 Google 開發的二進位制編碼庫。它與最初由 Facebook 開發的 Apache Thrift 很相似 13;本節關於 Protocol Buffers 的大部分內容也適用於 Thrift。
Protocol Buffers 要求任何待編碼的資料都有模式。要用 Protocol Buffers 編碼 示例 5-2 中的資料,可以用 Protocol Buffers 的介面定義語言(IDL)這樣描述模式:
Protocol Buffers 自帶程式碼生成工具。它接收上述模式定義,生成用各種程式語言實現該模式的類,應用程式可以呼叫生成的程式碼來編碼或解碼符合模式的記錄。與 JSON 模式相比,Protocol Buffers 的模式語言非常簡單:它只定義記錄的欄位及其型別,不支援對欄位取值施加其他約束。
使用 Protocol Buffers 編碼器對 示例 5-2 進行編碼,需要 33 位元組,如 圖 5-3 所示 14。

與 圖 5-2 類似,每個欄位都有型別註解,用來說明它是字串、整數還是其他型別;必要時還會給出長度,例如字串長度。資料中的字串(“Martin”“daydreaming”“hacking”)也和之前一樣編碼為 ASCII——準確地說,是 UTF-8。
與 圖 5-2 相比,最大的區別在於這裡沒有欄位名(userName、favoriteNumber、interests)。編碼資料包含的是數字形式的 欄位標籤(field tag)(1、2 和 3),也就是模式定義中的那些數字。欄位標籤好比欄位的別名:無需寫出欄位名,就能以緊湊的方式指出所說的是哪個欄位。
Protocol Buffers 把欄位型別和標籤號塞進同一個位元組,進一步節省了空間。它還使用變長整數:數字 1337 編碼成兩個位元組,每個位元組的最高位表示後面是否還有更多位元組。這樣,-64 到 63 之間的數字用一個位元組編碼,-8192 到 8191 之間的數字用兩個位元組編碼,依此類推;數字越大,佔用的位元組就越多。
Protocol Buffers 沒有顯式的列表或陣列資料型別。interests 欄位上的 repeated 修飾符表示該欄位包含一組值,而不是單個值。在二進位制編碼中,列表元素只是同一欄位標籤在同一條記錄中重複出現。
欄位標籤與模式演化
前面說過,模式不可避免地會隨時間改變,這稱為 模式演化(schema evolution)。Protocol Buffers 如何在保持向後和向前相容的同時處理模式變更?
從示例可以看出,一條編碼後的記錄,就是各個已編碼欄位的拼接。每個欄位由標籤號(示例模式中的 1、2、3)標識,並帶有資料型別註解(例如字串或整數)。如果某個欄位沒有值,就直接從編碼記錄中省略。由此可見,欄位標籤對編碼資料的含義至關重要。模式中的欄位名可以修改,因為編碼資料從不引用欄位名;但欄位標籤不能修改,否則現有的所有編碼資料都會失效。
可以向模式中新增新欄位,只要給它分配一個新的標籤號。舊程式碼並不知道新增的標籤號;當它讀取新程式碼寫入的資料、遇到無法識別的新欄位時,只需忽略該欄位即可。藉助資料型別註解,解析器能夠判斷要跳過多少位元組;同時還應保留未知欄位,以免出現 圖 5-1 所示的問題。這樣就保持了向前相容:舊程式碼仍能讀取新程式碼寫入的記錄。
向後相容又如何呢?只要每個欄位的標籤號唯一,新程式碼就總能讀取舊資料,因為標籤號的含義沒有改變。如果新模式增加了一個欄位,而讀取的舊資料尚不包含它,就會填入預設值:例如,字串欄位填入空字串,數值欄位填入零。
刪除欄位與新增欄位類似,只是向後相容和向前相容的考量正好相反。已經用過的標籤號絕不能再次使用,因為某處可能仍有帶著舊標籤號的資料,而新程式碼必須忽略這個欄位。可以在模式定義中把用過的標籤號標記為保留,確保以後不會忘記。
欄位的資料型別能否改變?某些型別可以,詳情需查閱文件,但值有可能被截斷。例如,假設把一個 32 位整數改成 64 位整數。新程式碼可以輕鬆讀取舊程式碼寫入的資料,因為解析器能用零補齊缺少的位。但如果舊程式碼讀取新程式碼寫入的資料,它仍會用 32 位變數儲存這個值;一旦解碼後的 64 位值裝不進 32 位,就會被截斷。
Avro
Apache Avro 是另一種二進位制編碼格式,與 Protocol Buffers 有著頗為有趣的差異。它於 2009 年作為 Hadoop 的子專案啟動,起因是 Protocol Buffers 不適合 Hadoop 的使用場景 15。
Avro 也使用模式來規定待編碼資料的結構。它有兩種模式語言:一種是供人編輯的 Avro IDL,另一種基於 JSON,更便於機器讀取。與 Protocol Buffers 一樣,Avro 的模式語言只規定欄位及其型別,不支援 JSON 模式那樣複雜的驗證規則。
用 Avro IDL 編寫的示例模式可能如下所示:
等價的 JSON 表示如下:
首先請注意,模式中沒有標籤號。如果用這個模式編碼示例記錄(示例 5-2),Avro 的二進位制編碼只有 32 位元組,是目前所見編碼中最緊湊的。圖 5-4 展示了這個位元組序列的組成。
仔細檢視這個位元組序列,會發現其中沒有任何內容標識欄位或資料型別;編碼僅僅是各個值的拼接。字串就是長度字首加上 UTF-8 位元組,但編碼資料本身並不說明它是字串——它同樣可能是整數或其他任何東西。整數則使用變長編碼。

要解析二進位制資料,必須按照欄位在模式中出現的順序逐一讀取,並由模式告知每個欄位的資料型別。這意味著,讀取資料的程式碼只有使用與寫入程式碼 完全相同的模式,才能正確解碼二進位制資料。讀寫雙方的模式只要有任何不一致,解碼結果就會出錯。
那麼,Avro 如何支援模式演化?
寫入者模式與讀取者模式
當應用程式要編碼資料——例如寫入檔案或資料庫,或者透過網路傳送——它會使用自己所知版本的模式;這個模式可能已經編譯進應用程式。這稱為 寫入者模式(writer’s schema)。
當應用程式要解碼資料——例如從檔案或資料庫讀取,或者從網路接收——它會使用兩個模式:一個是與編碼時完全相同的寫入者模式,另一個是可能有所不同的 讀取者模式(reader’s schema),如 圖 5-5 所示。讀取者模式定義了應用程式程式碼期望每條記錄包含哪些欄位,以及這些欄位的型別。

如果讀寫雙方的模式相同,解碼很簡單。如果不同,Avro 會並排比較寫入者模式與讀取者模式,把資料從前者轉換成後者,從而協調其中的差異。Avro 規範 16 17 精確定義了這一解析過程,圖 5-6 給出了示意。
例如,寫入者模式和讀取者模式中的欄位順序不同並不成問題,因為模式解析會按欄位名配對。讀取程式碼如果遇到只存在於寫入者模式、卻不在讀取者模式中的欄位,就將其忽略;如果讀取程式碼需要某個欄位,而寫入者模式中沒有同名欄位,就填入讀取者模式宣告的預設值。

模式演化規則
對 Avro 而言,向前相容意味著可以用新版模式寫入、用舊版模式讀取;反過來,向後相容意味著可以用舊版模式寫入、用新版模式讀取。
為了保持相容,只能新增或刪除帶預設值的欄位(示例 Avro 模式中的 favoriteNumber 欄位,預設值就是 null)。例如,假設新增了一個帶預設值的欄位,於是該欄位存在於新模式、卻不存在於舊模式。當採用新模式的讀取者讀取舊模式寫入的記錄時,就會為缺少的欄位填入預設值。
如果新增的欄位沒有預設值,新讀取者就無法讀取舊寫入者產生的資料,因而破壞向後相容。如果刪除的欄位沒有預設值,舊讀取者就無法讀取新寫入者產生的資料,因而破壞向前相容。
在某些程式語言中,任何變數都可以預設取 null,Avro 卻並非如此:如果希望欄位允許為 null,就必須使用 聯合型別(union type)。例如,union { null, long, string } field; 表示 field 可以是數字、字串或 null。而且,只有當 null 是聯合型別的第一個分支時,才能把它用作預設值。這種寫法比預設所有內容都可為 null 略顯冗長,卻明確說明了什麼可以、什麼不可以為 null,從而有助於避免錯誤 18。
只要 Avro 能完成相應的型別轉換,就可以更改欄位的資料型別。欄位名也能更改,不過稍微麻煩一些:讀取者模式可以為欄位名宣告別名,從而讓舊寫入者模式中的欄位名與別名匹配。因此,更改欄位名向後相容,卻不向前相容。同樣,給聯合型別增加一個分支向後相容,卻不向前相容。
但什麼是寫入者模式?
到目前為止,我們一直略過一個重要問題:讀取者如何知道某段資料是用哪個寫入者模式編碼的?不能把整個模式塞進每條記錄,因為模式很可能比編碼後的資料大得多,那樣二進位制編碼省下的空間就全白費了。
答案取決於 Avro 的使用場景。舉幾個例子:
- 包含大量記錄的大檔案
- Avro 的一種常見用途,是儲存包含數百萬條記錄的大檔案,所有記錄都使用同一個模式編碼(我們會在 第 11 章 討論這種情況)。此時,檔案的寫入者只需在檔案開頭寫入一次寫入者模式。Avro 為此規定了一種檔案格式,稱為物件容器檔案。
- 逐條寫入記錄的資料庫
- 在資料庫中,不同記錄可能在不同時間用不同的寫入者模式寫入,不能假定所有記錄都採用同一個模式。最簡單的解決方案,是在每條編碼記錄的開頭放一個版本號,並在資料庫中維護模式版本列表。讀取者取出記錄後先提取版本號,再從資料庫取得該版本對應的寫入者模式,用它解碼記錄的其餘部分。
例如,Apache Kafka 的 Confluent Schema Registry 19 和 LinkedIn 的 Espresso 20 就採用這種做法。
- 透過網路連線傳送記錄
- 當兩個程序透過雙向網路連線通訊時,可以在建立連線時協商模式版本,並在連線的整個生命週期中使用這個模式。Avro RPC 協議(參見“流經服務的資料流:REST 與 RPC”)就是這樣工作的。
無論採用哪種方式,維護模式版本資料庫都很有用:它既是文件,也讓你有機會檢查模式相容性 21。版本號可以是簡單遞增的整數,也可以是模式的雜湊值。
動態生成的模式
與 Protocol Buffers 相比,Avro 的一個優點是模式中沒有任何標籤號。但這為什麼重要?在模式中維護幾個數字,能有什麼問題?
區別在於,Avro 對 動態生成 的模式更友好。假設你想把關聯式資料庫的內容轉儲到檔案,並希望使用二進位制格式,避開前面提到的 JSON、CSV、XML 等文字格式的問題。使用 Avro 時,可以很容易地從關係模式生成 Avro 模式(採用前面展示過的 JSON 表示),再用它編碼資料庫內容,把所有資料轉儲到 Avro 物件容器檔案中 22。可以為每張資料庫表生成一個記錄模式,讓表中的每一列對應記錄中的一個欄位,資料庫列名則對映為 Avro 欄位名。
如果資料庫模式發生變化,例如表中增加一列、刪除一列,只要根據更新後的資料庫模式生成新的 Avro 模式,再用新模式匯出資料即可。資料匯出過程無需關注具體發生了什麼模式變更,每次執行時照常完成模式轉換就行。讀取新資料檔案的人會看到記錄欄位發生了變化,但由於欄位按名稱標識,更新後的寫入者模式仍能與舊讀取者模式匹配。
相比之下,如果用 Protocol Buffers 完成這項工作,欄位標籤很可能必須手工分配:資料庫模式每次變化,管理員都得手工更新資料庫列名到欄位標籤的對映。(這也許能夠自動化,但模式生成器必須格外謹慎,不能再次分配以前用過的標籤號。)動態生成模式從來就不是 Protocol Buffers 的設計目標,卻是 Avro 的設計目標之一。
模式的優點
正如我們所見,Protocol Buffers 和 Avro 都用模式描述二進位制編碼格式。它們的模式語言比 XML 模式或 JSON 模式簡單得多;後兩者支援更細緻的驗證規則,例如“這個欄位的字串值必須匹配某個正規表示式”,或者“這個欄位的整數值必須介於 0 和 100 之間”。Protocol Buffers 和 Avro 實現起來更簡單,使用起來也更簡單,因此已經支援相當廣泛的程式語言。
這些編碼背後的思想絕不新鮮。例如,它們與 ASN.1 有許多共同之處。ASN.1 是一門模式定義語言,早在 1984 年便首次標準化 23 24;它曾用於定義各種網路協議,其二進位制編碼 DER 至今仍用於編碼 SSL 證書(X.509)25。與 Protocol Buffers 類似,ASN.1 也用標籤號支援模式演化 26。不過,ASN.1 非常複雜,文件也很糟糕,因此可能並不適合新的應用程式。
許多資料系統也為自身資料實現了某種專有二進位制編碼。例如,大多數關聯式資料庫都有自己的網路協議,用於接收查詢並返回響應。這些協議通常只適用於特定資料庫,由資料庫廠商提供驅動程式(例如採用 ODBC 或 JDBC API),把資料庫網路協議中的響應解碼成記憶體資料結構。
由此可見,儘管 JSON、XML 和 CSV 等文字格式非常普遍,基於模式的二進位制編碼同樣是可行的選擇,而且具備一些很好的性質:
- 它們可以比各種“二進位制 JSON”變體緊湊得多,因為編碼資料中不必包含欄位名。
- 模式本身就是一種很有價值的文件。解碼時必須使用模式,所以可以確信它與資料保持同步;而手工維護的文件很容易與實際情況脫節。
- 維護模式資料庫,可以在部署任何變更之前檢查它是否保持向前和向後相容。
- 對於靜態型別程式語言的使用者,從模式生成程式碼很有用,因為這樣可以在編譯時進行型別檢查。
總而言之,模式演化提供了與無模式/讀時模式 JSON 資料庫相同的靈活性(參見“文件模型中的模式靈活性”),同時還能為資料提供更強的保證和更好的工具。
資料流的模式
本章開頭曾經說過,每當你想把資料傳送給不共享記憶體的另一個程序——例如透過網路傳送資料,或者將資料寫入檔案——都需要先把它編碼成位元組序列。隨後,我們討論了完成這項工作所用的各種編碼。
我們還討論了向前相容與向後相容。兩者對可演化性都很重要:它們允許獨立升級系統的不同部分,不必一次改動所有內容,從而讓變更更容易。相容性描述的是編碼資料的程序與解碼資料的程序之間的關係。
這個概念相當抽象,因為資料可以透過許多方式從一個程序流向另一個程序。究竟由誰編碼,又由誰解碼?本章餘下部分將探討幾種最常見的程序間資料流:
- 透過資料庫(參見“流經資料庫的資料流”)
- 透過服務呼叫(參見“流經服務的資料流:REST 與 RPC”)
- 透過工作流引擎(參見“持久化執行與工作流”)
- 透過非同步訊息(參見“事件驅動的架構”)
流經資料庫的資料流
在資料庫中,寫入資料庫的程序負責編碼資料,讀取資料庫的程序負責解碼。也許始終只有一個程序訪問資料庫,此時讀取者只不過是同一程序的後續版本——可以把向資料庫存入資料看作 給未來的自己傳送訊息。
顯然,這裡必須保持向後相容,否則未來的你就無法解碼過去寫下的資料。
一般來說,多個不同程序同時訪問資料庫很常見。這些程序可能屬於不同的應用程式或服務,也可能只是同一服務的多個例項,為可伸縮性或容錯而並行執行。無論哪種情況,只要應用程式還在變化,訪問資料庫的程序就很可能有些執行新版程式碼,有些仍在執行舊版程式碼。例如,滾動升級期間,一部分例項已經更新,其他例項還沒有。
這意味著,資料庫中的某個值可能由 較新 版本的程式碼寫入,隨後卻被仍在執行的 較舊 版本讀取。因此,資料庫通常也需要向前相容。
不同時間寫入的不同值
資料庫通常允許隨時更新任何值。因此,同一個資料庫裡可能既有五毫秒前寫入的值,也有五年前寫入的值。
部署新版應用程式時——至少對服務端應用程式而言——可能只需幾分鐘就能用新版本完全替換舊版本。但資料庫內容不是這樣:除非顯式重寫,五年前的資料仍會以最初的編碼留在那裡。這種現象有時概括為:資料比程式碼更長壽。
當然,可以把資料重寫(即 遷移)到新模式,但對大型資料集來說代價高昂,所以大多數資料庫都會盡量避免。大多數關聯式資料庫允許某些簡單的模式變更,例如增加一個預設值為 null 的新列,而不必重寫已有資料。讀取舊行時,如果磁碟上的編碼資料缺少某一列,資料庫便為它填入 null。
因此,模式演化讓整個資料庫看上去彷彿都用同一個模式編碼,儘管底層儲存中可能混有按各個歷史版本模式編碼的記錄。
更複雜的模式變更——例如把單值屬性改成多值屬性,或者把一部分資料移到另一張表中——仍然需要重寫資料,而且通常要在應用程式層完成 27。如何在這類遷移中保持向前和向後相容,至今仍是一個研究問題 28。
歸檔儲存
也許你會不時為資料庫製作快照,用於備份或者載入到資料倉儲中(參見“資料倉儲”)。這時,即使源資料庫的原始編碼混合了不同時期的多個模式版本,資料轉儲通常也會統一使用最新模式編碼。反正資料總要複製一遍,不妨讓副本採用一致的編碼。
資料轉儲一次寫成,此後不再修改,因此 Avro 物件容器檔案之類的格式很適合。這裡也是把資料編碼成適合分析的列式格式(例如 Parquet)的好機會(參見“列壓縮”)。
在 第 11 章 中,我們會進一步討論歸檔儲存中資料的用途。
流經服務的資料流:REST 與 RPC
當多個程序需要透過網路通訊時,可以採用幾種不同的組織方式。最常見的方式包含兩個角色:客戶端(client)和 伺服器(server)。伺服器透過網路公開 API,客戶端連線伺服器並向 API 發出請求。伺服器公開的這個 API 稱為 服務(service)。
Web 正是這樣工作的:客戶端(Web 瀏覽器)向 Web 伺服器發出請求,用 GET 請求下載 HTML、CSS、JavaScript、圖片等內容,用 POST 請求向伺服器提交資料。這個 API 由一套標準化的協議和資料格式組成,包括 HTTP、URL、SSL/TLS、HTML 等。因為 Web 瀏覽器、Web 伺服器和網站作者基本都遵循這些標準,所以理論上可以用任何 Web 瀏覽器訪問任何網站。
Web 瀏覽器並不是唯一的客戶端。例如,執行在移動裝置或桌面計算機上的原生應用程式經常與伺服器通訊,瀏覽器中的客戶端 JavaScript 應用程式也可以發出 HTTP 請求。這種情況下,伺服器返回的通常不是供人閱讀的 HTML,而是便於客戶端程式碼進一步處理的編碼資料,最常見的是 JSON。HTTP 雖然可以作為傳輸協議,但構建在它之上的 API 仍然由應用程式自行定義,客戶端與伺服器必須就 API 的細節達成一致。
在某些方面,服務很像資料庫:它們通常允許客戶端提交和查詢資料。不過,資料庫允許使用 第 3 章 討論過的查詢語言發起任意查詢,服務公開的卻是應用程式專用 API,只接受服務業務邏輯(應用程式程式碼)預先規定的輸入,也只產生預先規定的輸出 29。這種限制帶來了一定程度的封裝:服務可以細粒度地約束客戶端能做什麼、不能做什麼。
面向服務架構或微服務架構的一項關鍵設計目標,是讓服務可以獨立部署和演化,從而使應用程式更容易修改和維護。一條常見原則是:每項服務由一個團隊負責,這個團隊應當能夠頻繁釋出服務的新版本,而不必與其他團隊協調。因此,伺服器和客戶端的新舊版本同時執行是意料之中的,雙方使用的資料編碼必須跨服務 API 版本保持相容。
Web 服務
如果以 HTTP 作為與服務通訊的底層協議,就稱為 Web 服務(Web service)。Web 服務常用於構建面向服務或微服務架構(前文“微服務與無伺服器”已經討論過)。不過,“Web 服務”這個名字並不十分貼切,因為它不只用於 Web,還出現在其他幾種場景中。例如:
- 執行在使用者裝置上的客戶端應用程式(例如移動裝置上的原生應用,或者瀏覽器中的 JavaScript Web 應用)透過 HTTP 請求服務。這些請求通常經由公共網際網路傳輸。
- 作為面向服務或微服務架構的一部分,一項服務請求同一組織擁有的另一項服務;兩者通常位於同一個資料中心。
- 一項服務請求另一組織擁有的服務,通常經由網際網路完成。這種方式用於不同組織的後端系統交換資料,包括線上服務提供的公共 API,例如信用卡支付系統,以及用來共享訪問使用者資料的 OAuth。
最流行的服務設計理念是 REST,它建立在 HTTP 的原則之上 30 31。REST 強調簡單的資料格式,以 URL 標識資源,並利用 HTTP 的功能進行快取控制、身份認證和內容型別協商。遵循 REST 原則設計的 API 稱為 RESTful API。
呼叫 Web 服務 API 的程式碼必須知道應該請求哪個 HTTP 端點、應傳送什麼格式的資料,以及預期得到什麼響應。即使服務遵循 RESTful 設計原則,客戶端也得透過某種途徑獲知這些細節。服務開發者通常使用介面定義語言(IDL)來定義並記錄 API 端點和資料模型,隨後再逐步演化它們。其他開發者可以根據服務定義判斷如何發起請求。最流行的兩種服務 IDL 是 OpenAPI(也稱為 Swagger 32)和 gRPC。OpenAPI 用於收發 JSON 資料的 Web 服務,而 gRPC 服務收發 Protocol Buffers 資料。
開發者通常用 JSON 或 YAML 編寫 OpenAPI 服務定義,參見 示例 5-3。服務定義可以描述端點、文件、版本、資料模型等許多內容。gRPC 的定義看起來與此相似,但採用 Protocol Buffers 的服務定義語法。
即使選定了設計理念和 IDL,開發者仍要編寫程式碼來實現服務的 API 呼叫。通常可以採用服務框架來簡化這項工作。Spring Boot、FastAPI 和 gRPC 等框架讓開發者只需編寫每個 API 端點的業務邏輯,由框架負責路由、指標、快取、身份認證等事務。示例 5-4 給出了 示例 5-3 所定義服務的一種 Python 實現。
許多框架把服務定義與伺服器程式碼結合在一起。以流行的 Python 框架 FastAPI 為例,開發者先用程式碼編寫伺服器,框架再自動生成 IDL;gRPC 等框架則反過來,先編寫服務定義,再生成伺服器程式碼的腳手架。兩種方式都能根據服務定義生成多種語言的客戶端庫和 SDK。除了生成程式碼,Swagger 等 IDL 工具還可以生成文件、驗證模式變更是否相容,並提供圖形介面,供開發者查詢和測試服務。
遠端過程呼叫(RPC)的問題
Web 服務只是透過網路發起 API 請求這一系列技術的最新化身。此前許多技術都曾被大肆炒作,卻存在嚴重問題:Enterprise JavaBeans(EJB)和 Java 的遠端方法呼叫(RMI)侷限於 Java;分散式元件物件模型(DCOM)侷限於 Microsoft 平臺;公共物件請求代理架構(CORBA)過度複雜,又不支援向後或向前相容 33。SOAP 和 WS-* Web 服務框架試圖實現跨廠商互操作,卻同樣飽受複雜性和相容性問題困擾 34 35 36。
所有這些技術都建立在 遠端過程呼叫(remote procedure call,RPC)的思想之上,而 RPC 早在 20 世紀 70 年代便已出現 37。RPC 模型試圖讓遠端網路服務請求,看起來就像在同一程序內呼叫程式語言中的函式或方法一樣(這種抽象稱為 位置透明性,location transparency)。RPC 乍看十分方便,這種思路卻有根本性的缺陷 38 39。網路請求與本地函式呼叫大不相同:
- 本地函式呼叫是可預測的,成功還是失敗只取決於你能控制的引數。網路請求卻不可預測:請求或響應可能因網路問題而丟失,遠端機器也可能很慢或不可用,這些情況都完全不受你控制。網路問題很常見,必須預先做好準備,例如重試失敗的請求。
- 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永遠不返回(因為陷入死迴圈或程序崩潰)。網路請求還有另一種結果:它可能因 超時(timeout)而返回,卻沒有結果。此時你根本不知道發生了什麼;如果遠端服務沒有響應,就無法判斷請求究竟有沒有送達(第 9 章 會更詳細地討論這個問題)。
- 重試失敗的網路請求時,原請求可能其實已經成功,只是響應丟失了。此時重試會讓同一操作執行多次,除非協議內建了去重機制,也就是 冪等性(idempotence)40。本地函式呼叫沒有這個問題(參見“冪等性”)。
- 本地函式每次呼叫通常耗時相近。網路請求不僅比函式呼叫慢得多,延遲還會劇烈波動:順利時可能不到一毫秒就完成;網路擁塞或遠端服務過載時,同一個操作卻可能花上好幾秒。
- 呼叫本地函式時,可以高效傳遞指向本地記憶體物件的引用(指標)。發起網路請求時,所有引數都必須編碼成可以透過網路傳送的位元組序列。對於數字、短字串等不可變基本值,這不成問題;但資料量一大,或者涉及可變物件,麻煩很快就會出現。
- 客戶端和服務可能由不同的程式語言實現,因此 RPC 框架必須在語言之間轉換資料型別。各語言的型別並不完全相同,轉換結果可能十分難看——例如前面提到過,JavaScript 無法準確表示大於 2⁵³ 的整數(參見“JSON、XML 及其二進位制變體”)。如果單個程序只使用一種語言,就沒有這個問題。
這些差異表明,沒必要強求遠端服務看起來像程式語言中的本地物件,因為兩者從根本上就是不同的東西。REST 的一部分吸引力,正是它把網路上的狀態傳輸視為有別於函式呼叫的過程。
負載均衡器、服務發現和服務網格
所有服務都透過網路通訊,因此客戶端必須知道目標服務的地址,這個問題稱為 服務發現(service discovery)。最簡單的做法,是把執行服務的 IP 地址和埠配置到客戶端中。這樣確實能工作,但伺服器一旦離線、遷移到另一臺機器或負載過高,就必須手工重新配置客戶端。
為了提高可用性和可伸縮性,一項服務通常會在不同機器上執行多個例項,任一例項都能處理傳入的請求。把請求分攤到這些例項上的過程稱為 負載均衡(load balancing)41。負載均衡和服務發現有許多實現方案:
硬體負載均衡器(hardware load balancer)是安裝在資料中心的專用裝置。客戶端只連線一個主機和埠,裝置再把傳入連線路由到執行該服務的某臺伺服器。此類負載均衡器會在連線下游伺服器時檢測網路故障,並將流量轉移到其他伺服器。
軟體負載均衡器(software load balancer)的行為與硬體負載均衡器大體相同,只是不需要專用裝置。Nginx 和 HAProxy 等軟體負載均衡器就是可以安裝在普通機器上的應用程式。
域名系統(DNS) 用於在網際網路上解析域名,例如開啟網頁時就會用到。它允許一個域名關聯多個 IP 地址,從而實現負載均衡。客戶端可以配置為按域名而非 IP 地址連線服務,再由客戶端的網路層在建立連線時選擇某個 IP 地址。這種方法的缺點是,DNS 原本就允許變更經過較長時間才完全傳播,而且會快取 DNS 條目。如果伺服器頻繁啟動、停止或遷移,客戶端可能拿到過期的 IP 地址,而該地址上已經沒有伺服器執行。
服務發現系統(service discovery system)不使用 DNS,而是透過集中式登錄檔跟蹤哪些服務端點可用。新服務例項啟動時,會向發現系統註冊自己,宣告正在監聽的主機和埠,以及分片歸屬資訊(參見 第 7 章)、資料中心位置等相關後設資料。隨後,服務定期向發現系統傳送心跳,表示自己仍然可用。
客戶端要連線服務時,先向發現系統查詢可用端點列表,再直接連線某個端點。與 DNS 相比,服務發現更適合例項頻繁變化的動態環境。發現系統還會向客戶端提供更多服務後設資料,使客戶端能做出更明智的負載均衡決策。
服務網格(service mesh)是一種更複雜的負載均衡方案,把軟體負載均衡器與服務發現結合起來。傳統軟體負載均衡器執行在獨立機器上,服務網格的負載均衡器則通常部署為程序內客戶端庫,或者部署為伴隨客戶端和伺服器的程序或“邊車”容器。客戶端應用程式連線本機的服務負載均衡器,後者再連線伺服器一側的負載均衡器,最終把連線路由到本機的伺服器程序。
這種拓撲雖然複雜,卻有不少優點。客戶端和伺服器應用程式都只需建立本地連線,因此連線加密可以完全由負載均衡器處理,讓應用程式不必面對 SSL 證書和 TLS 的複雜性。服務網格還提供了強大的可觀測性,能夠實時跟蹤服務間的呼叫關係、檢測故障、監測流量負載等。
哪種方案合適,取決於組織自身的需求。在使用 Kubernetes 等編排器的高度動態環境中,組織往往會選擇 Istio 或 Linkerd 等服務網格。資料庫、訊息傳遞系統等專用基礎設施,可能需要量身定製的負載均衡器。對於更簡單的部署,軟體負載均衡器通常就足夠了。
RPC 的資料編碼與演化
為了實現可演化性,RPC 客戶端與伺服器必須能夠獨立修改和部署。與上一節討論的資料庫資料流相比,服務資料流可以作一個簡化假設:先更新所有伺服器,再更新所有客戶端通常是合理的。因此,請求只需向後相容,響應只需向前相容。
RPC 方案的向後與向前相容性質,取決於它所採用的編碼:
- gRPC(Protocol Buffers)和 Avro RPC 可以按照各自編碼格式的相容規則演化。
- RESTful API 通常用 JSON 編碼響應,請求引數則通常採用 JSON、URI 編碼或表單編碼。增加可選請求引數,或者給響應物件增加新欄位,通常都被視為保持相容的變更。
RPC 經常用於跨組織邊界通訊,這讓服務相容性變得更加困難:服務提供者通常無法控制客戶端,也不能強迫它們升級。因此,相容性必須維持很長時間,甚至可能永遠維持下去。如果不得不作出破壞相容性的變更,服務提供者往往只好同時維護多個版本的服務 API。
對於 API 應當如何版本化——也就是客戶端如何表明自己想使用哪個 API 版本——業界並無共識 42。RESTful API 的常見做法,是在 URL 或 HTTP Accept 標頭中加入版本號。如果服務用 API 金鑰識別具體客戶端,還可以在伺服器端記錄該客戶端請求的 API 版本,並透過單獨的管理介面更新版本選擇 43。
持久化執行與工作流
按照定義,基於服務的架構由多項服務組成,每項服務負責應用程式的一部分。以支付處理應用為例,它要從信用卡扣款,再把資金存入銀行賬戶。系統很可能分別用不同服務負責欺詐檢測、信用卡整合、銀行系統整合等工作。
在這個例子中,處理一筆付款需要多次服務呼叫。支付處理服務可能先呼叫欺詐檢測服務檢查風險,再呼叫信用卡服務扣款,最後呼叫銀行服務把扣下的款項存入賬戶,如 圖 5-7 所示。這一系列步驟稱為 工作流(workflow),其中每一步稱為 任務(task)。工作流通常定義成一張任務圖,其定義可以使用通用程式語言、領域特定語言(DSL),也可以使用業務流程執行語言(BPEL)之類的標記語言 44。
不同的工作流引擎對任務有不同稱呼。例如,Temporal 使用 活動(activity)一詞,另一些引擎則稱之為 持久函式(durable function)。名稱雖異,概念相同。

工作流由 工作流引擎(workflow engine)執行或執行。引擎決定每項任務何時執行、在哪臺機器上執行、任務失敗時該怎麼辦(例如執行任務的機器崩潰),以及允許多少任務並行執行等。
工作流引擎通常由編排器和執行器組成:編排器負責排程,執行器負責真正執行任務。工作流被觸發後,執行便開始。如果使用者定義了按時間執行的計劃,例如每小時執行一次,編排器可以自行觸發工作流;Web 服務等外部來源,甚至人,也可以觸發工作流。一旦觸發,執行器就會受命執行任務。
工作流引擎種類繁多,面向的使用場景也各不相同。Airflow、Dagster 和 Prefect 等引擎與資料系統整合,用於編排 ETL 任務。Camunda 和 Orkes 等引擎提供圖形化工作流表示,例如 圖 5-7 中的 BPMN,讓非工程師也能更方便地定義和執行工作流。Temporal 和 Restate 等引擎則提供 持久化執行(durable execution)。
持久化執行
對於需要事務語義的服務架構,持久化執行框架已經成為一種流行的構建方式。在支付示例中,我們希望每筆付款都恰好處理一次。但工作流執行期間一旦發生故障,就可能出現信用卡已經扣款,銀行賬戶卻沒有收到相應款項的情況。在基於服務的架構中,無法簡單地把這兩項任務包進一個資料庫事務;況且,系統可能還要與我們無法充分控制的第三方支付閘道器互動。
持久化執行框架可以為工作流提供 恰好一次語義(exactly-once semantics)。任務失敗後,框架會重新執行它,但會跳過失敗前已經成功完成的 RPC 呼叫或狀態變更:框架表面上再次發起呼叫,實際上卻直接返回上一次呼叫的結果。這之所以可行,是因為框架把所有 RPC 和狀態變更都記錄在預寫日誌(WAL)之類的持久儲存中 45 46。示例 5-5 展示了用 Temporal 定義支援持久化執行的工作流。
Temporal 之類的框架並非沒有難題。外部服務——例如示例中的第三方支付閘道器——仍然必須提供冪等 API,開發者也必須記得為呼叫使用唯一 ID,以防重複執行 47。此外,持久化執行框架會按順序記錄每次 RPC 呼叫,因此要求後續執行以同樣的順序發起同樣的呼叫。這讓程式碼變更十分脆弱:僅僅調整函式呼叫順序,就可能引入未定義行為 48。與其修改現有工作流的程式碼,更安全的做法是單獨部署一個新版本,讓已有工作流的重執行繼續使用舊版程式碼,只有新啟動的工作流才使用新版 49。
同樣,持久化執行框架要求以確定性的方式重放所有程式碼,也就是相同輸入必須產生相同輸出。因此,隨機數生成器、系統時鐘等非確定性程式碼會帶來問題 48。框架通常會為這類庫函式提供自己的確定性實現,但開發者必須記得使用。有些框架還提供靜態分析工具,用於檢查是否引入了非確定性行為,例如 Temporal 的 workflowcheck。
讓程式碼具有確定性是個強大的思想,但要可靠地做到這一點並不容易。我們會在“確定性的力量”中再次討論這個話題。
事件驅動的架構
最後,我們來簡要介紹 事件驅動架構(event-driven architecture),這是編碼資料在程序間流動的另一種方式。請求在這裡稱為 事件(event)或 訊息(message);與 RPC 不同,傳送者通常不會等待接收者處理事件。事件一般也不會透過直接網路連線發給接收者,而是先經過一個臨時儲存訊息的中介,稱為 訊息代理(message broker),也叫 事件代理(event broker)、訊息佇列(message queue)或 面向訊息的中介軟體(message-oriented middleware) 50。
與直接使用 RPC 相比,訊息代理有幾個優點:
- 接收者不可用或過載時,它可以充當緩衝區,從而提高系統可靠性。
- 它可以自動向崩潰後恢復的程序重新傳遞訊息,避免訊息丟失。
- 它不需要服務發現,因為傳送者不必直接連線接收者的 IP 地址。
- 它可以把同一條訊息傳送給多個接收者。
- 它從邏輯上解耦傳送者與接收者:傳送者只管釋出訊息,不必關心誰來消費。
透過訊息代理進行的通訊是 非同步的(asynchronous):傳送者不等待訊息送達,只管發出訊息,然後就將其忘掉。不過,也可以讓傳送者在另一條通道上等待響應,從而實現類似同步 RPC 的模型。
訊息代理
過去,訊息代理領域主要由 TIBCO、IBM WebSphere 和 webMethods 等公司的商業企業軟體佔據;後來 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 等開源實現逐漸流行。近年來,Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub 等雲服務也得到廣泛採用。我們會在“訊息傳遞系統”中更詳細地比較它們。
具體的傳遞語義因實現和配置而異,但最常見的是以下兩種訊息分發模式:
- 一個程序把訊息加入某個命名 佇列(queue),代理再把訊息交給該佇列的一個 消費者(consumer)。如果有多個消費者,其中只有一個會收到這條訊息。
- 一個程序把訊息釋出到某個命名 主題(topic),代理再把訊息交給該主題的所有 訂閱者(subscriber)。如果有多個訂閱者,每個都會收到這條訊息。
訊息代理通常不強制使用特定資料模型:訊息只是附帶少量後設資料的位元組序列,因此可以採用任何編碼格式。常見做法是使用 Protocol Buffers、Avro 或 JSON,並在訊息代理旁部署模式登錄檔,用來儲存所有有效的模式版本並檢查相容性 19 21。也可以使用 AsyncAPI——面向訊息傳遞、與 OpenAPI 對應的規範——來規定訊息模式。
不同訊息代理對訊息永續性的保證各不相同。許多代理會把訊息寫入磁碟,以免代理崩潰或重啟時丟失訊息。不過,與資料庫不同,許多訊息代理會在訊息被消費後自動刪除它。也有些代理可以配置為無限期儲存訊息;要採用事件溯源,就必須這樣做(參見“事件溯源與 CQRS”)。
如果消費者把訊息重新發布到另一個主題,就要注意保留未知欄位,以免出現前面討論資料庫時所說的問題(圖 5-1)。
分散式 actor 框架
Actor 模型(actor model)是一種用於單程序併發的程式設計模型。它不直接處理執行緒以及隨之而來的競態條件、鎖和死鎖,而是把邏輯封裝在 actor 中。每個 actor 通常代表一個客戶端或實體,可以擁有不與其他 actor 共享的本地狀態,並透過收發非同步訊息與其他 actor 通訊。訊息傳遞並無保證:在某些錯誤場景下,訊息會丟失。由於每個 actor 一次只處理一條訊息,所以無需操心執行緒問題,而框架可以獨立排程每個 actor。
Akka、Orleans 51 和 Erlang/OTP 等 分散式 actor 框架(distributed actor framework),用這種程式設計模型把應用程式擴充套件到多個節點。無論傳送者和接收者位於同一節點還是不同節點,都使用同一種訊息傳遞機制。如果雙方位於不同節點,訊息會被透明地編碼成位元組序列,透過網路傳送,再由另一端解碼。
位置透明性在 actor 模型中比在 RPC 中效果更好,因為 actor 模型本來就假設訊息可能丟失,即使訊息只在單個程序內傳遞也一樣。網路延遲固然可能高於程序內延遲,但在 actor 模型中,本地通訊與遠端通訊之間的根本差異要小得多。
分散式 actor 框架本質上把訊息代理與 actor 程式設計模型整合在同一個框架中。不過,要對基於 actor 的應用程式進行滾動升級,仍然必須考慮向前和向後相容:訊息可能從執行新版程式碼的節點發往執行舊版程式碼的節點,也可能反過來。採用本章討論的某種編碼,就能實現這種相容性。
總結
本章介紹了幾種把資料結構轉換成網路位元組流或磁碟位元組流的方法。我們看到,編碼細節影響的不只是效率,更重要的是,它還會影響應用程式的架構,以及未來如何演化。
許多服務尤其需要支援滾動升級:新版本逐步部署到少數節點,而不是一次覆蓋所有節點。滾動升級讓服務無需停機就能釋出新版本,因而鼓勵頻繁釋出小版本,而不是很久才釋出一次大版本;它也能降低部署風險,使有問題的版本在影響大量使用者之前就被發現並回滾。這些性質大大提升了 可演化性,也就是修改應用程式的容易程度。
在滾動升級期間,或者出於其他種種原因,必須假設不同節點會執行不同版本的應用程式程式碼。因此,系統中流動的所有資料都應採用能夠保持向後相容(新程式碼可以讀取舊資料)和向前相容(舊程式碼可以讀取新資料)的編碼。
我們討論了幾種資料編碼格式及其相容性質:
- 程式語言專用的編碼只能用於單一語言,而且往往無法提供向前和向後相容。
- JSON、XML 和 CSV 等文字格式非常普遍,其相容性取決於具體用法。它們可以配合可選的模式語言;這些模式有時很有幫助,有時反而成為障礙。文字格式對資料型別的規定有些模糊,因此必須留意數值和二進位制字串等問題。
- Protocol Buffers 和 Avro 等由模式驅動的二進位制格式,能夠以明確定義的向前、向後相容語義進行緊湊而高效的編碼。模式既可充當文件,也可為靜態型別語言生成程式碼。不過,這類格式也有缺點:資料必須先解碼,才能供人閱讀。
我們還討論了幾種資料流模式,藉此說明資料編碼在哪些場景中十分重要:
- 在資料庫中,寫入資料庫的程序編碼資料,讀取資料庫的程序解碼資料。
- 在 RPC 和 REST API 中,客戶端編碼請求,伺服器解碼請求並編碼響應,最後由客戶端解碼響應。
- 在使用訊息代理或 actor 的事件驅動架構中,節點透過互發訊息來通訊;傳送者編碼訊息,接收者解碼訊息。
由此可以得出結論:只要稍加留意,向後相容、向前相容和滾動升級都完全能夠實現。願你的應用程式演化迅速,部署頻繁。
參考文獻
CWE-502: Deserialization of Untrusted Data. Common Weakness Enumeration, cwe.mitre.org, July 2006. Archived at perma.cc/26EU-UK9Y ↩︎
Steve Breen. What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability. foxglovesecurity.com, November 2015. Archived at perma.cc/9U97-UVVD ↩︎
Patrick McKenzie. What the Rails Security Issue Means for Your Startup. kalzumeus.com, January 2013. Archived at perma.cc/2MBJ-7PZ6 ↩︎
Brian Goetz. Towards Better Serialization. openjdk.org, June 2019. Archived at perma.cc/UK6U-GQDE ↩︎
Eishay Smith. jvm-serializers wiki. github.com, October 2023. Archived at perma.cc/PJP7-WCNG ↩︎
XML Is a Poor Copy of S-Expressions. wiki.c2.com, May 2013. Archived at perma.cc/7FAN-YBKL ↩︎
Julia Evans. Examples of floating point problems. jvns.ca, January 2023. Archived at perma.cc/M57L-QKKW ↩︎
Matt Harris. Snowflake: An Update and Some Very Important Information. Email to Twitter Development Talk mailing list, October 2010. Archived at perma.cc/8UBV-MZ3D ↩︎
Yakov Shafranovich. RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files. IETF, October 2005. ↩︎
Andy Coates. Evolving JSON Schemas - Part I and Part II. creekservice.org, January 2024. Archived at perma.cc/MZW3-UA54 and perma.cc/GT5H-WKZ5 ↩︎
Pierre Genevès, Nabil Layaïda, and Vincent Quint. Ensuring Query Compatibility with Evolving XML Schemas. INRIA Technical Report 6711, November 2008. ↩︎
Tim Bray. Bits On the Wire. tbray.org, November 2019. Archived at perma.cc/3BT3-BQU3 ↩︎
Mark Slee, Aditya Agarwal, and Marc Kwiatkowski. Thrift: Scalable Cross-Language Services Implementation. Facebook technical report, April 2007. Archived at perma.cc/22BS-TUFB ↩︎
Martin Kleppmann. Schema Evolution in Avro, Protocol Buffers and Thrift. martin.kleppmann.com, December 2012. Archived at perma.cc/E4R2-9RJT ↩︎
Doug Cutting, Chad Walters, Jim Kellerman, et al. [PROPOSAL] New Subproject: Avro. Email thread on hadoop-general mailing list, lists.apache.org, April 2009. Archived at perma.cc/4A79-BMEB ↩︎
Apache Software Foundation. Apache Avro 1.12.0 Specification. avro.apache.org, August 2024. Archived at perma.cc/C36P-5EBQ ↩︎
Apache Software Foundation. Avro schemas as LL(1) CFG definitions. avro.apache.org, August 2024. Archived at perma.cc/JB44-EM9Q ↩︎
Tony Hoare. Null References: The Billion Dollar Mistake. Talk at QCon London, March 2009. ↩︎
Confluent, Inc. Schema Registry Overview. docs.confluent.io, 2024. Archived at perma.cc/92C3-A9JA ↩︎ ↩︎
Aditya Auradkar and Tom Quiggle. Introducing Espresso—LinkedIn’s Hot New Distributed Document Store. engineering.linkedin.com, January 2015. Archived at perma.cc/FX4P-VW9T ↩︎
Jay Kreps. Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2). confluent.io, February 2015. Archived at perma.cc/8UA4-ZS5S ↩︎ ↩︎
Gwen Shapira. The Problem of Managing Schemas. oreilly.com, November 2014. Archived at perma.cc/BY8Q-RYV3 ↩︎
John Larmouth. ASN.1 Complete. Morgan Kaufmann, 1999. ISBN: 978-0-122-33435-1. Archived at perma.cc/GB7Y-XSXQ ↩︎
Burton S. Kaliski Jr. A Layman’s Guide to a Subset of ASN.1, BER, and DER. Technical Note, RSA Data Security, Inc., November 1993. Archived at perma.cc/2LMN-W9U8 ↩︎
Jacob Hoffman-Andrews. A Warm Welcome to ASN.1 and DER. letsencrypt.org, April 2020. Archived at perma.cc/CYT2-GPQ8 ↩︎
Lev Walkin. Question: Extensibility and Dropping Fields. lionet.info, September 2010. Archived at perma.cc/VX8E-NLH3 ↩︎
Jacqueline Xu. Online migrations at scale. stripe.com, February 2017. Archived at perma.cc/X59W-DK7Y ↩︎
Geoffrey Litt, Peter van Hardenberg, and Orion Henry. Project Cambria: Translate your data with lenses. Technical Report, Ink & Switch, October 2020. Archived at perma.cc/WA4V-VKDB ↩︎
Pat Helland. Data on the Outside Versus Data on the Inside. At 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005. ↩︎
Roy Thomas Fielding. Architectural Styles and the Design of Network-Based Software Architectures. PhD Thesis, University of California, Irvine, 2000. Archived at perma.cc/LWY9-7BPE ↩︎
Roy Thomas Fielding. REST APIs must be hypertext-driven.” roy.gbiv.com, October 2008. Archived at perma.cc/M2ZW-8ATG ↩︎
OpenAPI Specification Version 3.1.0. swagger.io, February 2021. Archived at perma.cc/3S6S-K5M4 ↩︎
Michi Henning. The Rise and Fall of CORBA. Communications of the ACM, volume 51, issue 8, pages 52–57, August 2008. doi:10.1145/1378704.1378718 ↩︎
Pete Lacey. The S Stands for Simple. harmful.cat-v.org, November 2006. Archived at perma.cc/4PMK-Z9X7 ↩︎
Stefan Tilkov. Interview: Pete Lacey Criticizes Web Services. infoq.com, December 2006. Archived at perma.cc/JWF4-XY3P ↩︎
Tim Bray. The Loyal WS-Opposition. tbray.org, September 2004. Archived at perma.cc/J5Q8-69Q2 ↩︎
Andrew D. Birrell and Bruce Jay Nelson. Implementing Remote Procedure Calls. ACM Transactions on Computer Systems (TOCS), volume 2, issue 1, pages 39–59, February 1984. doi:10.1145/2080.357392 ↩︎
Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall. A Note on Distributed Computing. Sun Microsystems Laboratories, Inc., Technical Report TR-94-29, November 1994. Archived at perma.cc/8LRZ-BSZR ↩︎
Steve Vinoski. Convenience over Correctness. IEEE Internet Computing, volume 12, issue 4, pages 89–92, July 2008. doi:10.1109/MIC.2008.75 ↩︎
Brandur Leach. Designing robust and predictable APIs with idempotency. stripe.com, February 2017. Archived at perma.cc/JD22-XZQT ↩︎
Sam Rose. Load Balancing. samwho.dev, April 2023. Archived at perma.cc/Q7BA-9AE2 ↩︎
Troy Hunt. Your API versioning is wrong, which is why I decided to do it 3 different wrong ways. troyhunt.com, February 2014. Archived at perma.cc/9DSW-DGR5 ↩︎
Brandur Leach. APIs as infrastructure: future-proofing Stripe with versioning. stripe.com, August 2017. Archived at perma.cc/L63K-USFW ↩︎
Alexandre Alves, Assaf Arkin, Sid Askary, et al. Web Services Business Process Execution Language Version 2.0. docs.oasis-open.org, April 2007. ↩︎
What is a Temporal Service? docs.temporal.io, 2024. Archived at perma.cc/32P3-CJ9V ↩︎
Stephan Ewen. Why we built Restate. restate.dev, August 2023. Archived at perma.cc/BJJ2-X75K ↩︎
Keith Tenzer and Joshua Smith. Idempotency and Durable Execution. temporal.io, February 2024. Archived at perma.cc/9LGW-PCLU ↩︎
What is a Temporal Workflow? docs.temporal.io, 2024. Archived at perma.cc/B5C5-Y396 ↩︎ ↩︎
Jack Kleeman. Solving durable execution’s immutability problem. restate.dev, February 2024. Archived at perma.cc/G55L-EYH5 ↩︎
Srinath Perera. Exploring Event-Driven Architecture: A Beginner’s Guide for Cloud Native Developers. wso2.com, August 2023. Archived at archive.org ↩︎
Philip A. Bernstein, Sergey Bykov, Alan Geller, Gabriel Kliot, and Jorgen Thelin. Orleans: Distributed Virtual Actors for Programmability and Scalability. Microsoft Research Technical Report MSR-TR-2014-41, March 2014. Archived at perma.cc/PD3U-WDMF ↩︎
II 分散式資料
一個成功的技術,現實的優先順序必須高於公關,你可以糊弄別人,但糊弄不了自然規律。
—— 羅傑斯委員會報告(1986)
在本書的 第一部分 中,我們討論了資料系統的各個方面,但僅限於資料儲存在單臺機器上的情況。 現在我們到了 第二部分,進入更高的層次,並提出一個問題:如果 多臺機器 參與資料的儲存和檢索,會發生什麼?
你可能會出於各種各樣的原因,希望將資料庫分佈到多臺機器上:
- 可伸縮性
- 如果你的資料量、讀取負載、寫入負載超出單臺機器的處理能力,可以將負載分散到多臺計算機上。
- 容錯 / 高可用性
- 如果你的應用需要在單臺機器(或多臺機器,網路或整個資料中心)出現故障的情況下仍然能繼續工作,則可使用多臺機器,以提供冗餘。一臺故障時,另一臺可以接管。
- 延遲
- 如果在世界各地都有使用者,你也許會考慮在全球範圍部署多個伺服器,從而每個使用者可以從地理上最近的資料中心獲取服務,避免了等待網路資料包穿越半個世界。
伸縮至更高的負載
如果你需要的只是伸縮至更高的 負載(load),最簡單的方法就是購買更強大的機器(有時稱為 垂直伸縮,即 vertical scaling,或 向上伸縮,即 scale up)。許多處理器,記憶體和磁碟可以在同一個作業系統下相互連線,快速的相互連線允許任意處理器訪問記憶體或磁碟的任意部分。在這種 共享記憶體架構(shared-memory architecture) 中,所有的元件都可以看作一臺單獨的機器。
在大型機中,儘管任意處理器都可以訪問記憶體的任意部分,但總有一些記憶體區域與一些處理器更接近(稱為 非均勻記憶體訪問(nonuniform memory access, NUMA) 1)。為了有效利用這種架構特性,需要對處理進行細分,以便每個處理器主要訪問臨近的記憶體,這意味著即使表面上看起來只有一臺機器在執行,分割槽(partitioning) 仍然是必要的。
共享記憶體方法的問題在於,成本增長速度快於線性增長:一臺有著雙倍處理器數量,雙倍記憶體大小,雙倍磁碟容量的機器,通常成本會遠遠超過原來的兩倍。而且可能因為存在瓶頸,並不足以處理雙倍的載荷。
共享記憶體架構可以提供有限的容錯能力,高階機器可以使用熱插拔的元件(不關機更換磁碟,記憶體模組,甚至處理器)—— 但它必然囿於單個地理位置的桎梏。
另一種方法是 共享磁碟架構(shared-disk architecture),它使用多臺具有獨立處理器和記憶體的機器,但將資料儲存在機器之間共享的磁碟陣列上,這些磁碟透過快速網路連線。這種架構用於某些資料倉儲,但競爭和鎖定的開銷限制了共享磁碟方法的可伸縮性 2。
網路附屬儲存(Network Attached Storage, NAS),或 儲存區網路(Storage Area Network, SAN)
無共享架構
相比之下,無共享架構 3(shared-nothing architecture,有時被稱為 水平伸縮,即 horizontal scaling,或 向外伸縮,即 scaling out)已經相當普及。 在這種架構中,執行資料庫軟體的每臺機器 / 虛擬機器都稱為 節點(node)。每個節點只使用各自的處理器,記憶體和磁碟。節點之間的任何協調,都是在軟體層面使用傳統網路實現的。
無共享系統不需要使用特殊的硬體,所以你可以用任意機器 —— 比如價效比最好的機器。你也許可以跨多個地理區域分佈資料從而減少使用者延遲,或者在損失一整個資料中心的情況下倖免於難。 隨著雲端虛擬機器部署的出現,即使是小公司,現在無需 Google 級別的運維,也可以實現異地分散式架構。
在這一部分裡,我們將重點放在無共享架構上。它不見得是所有場景的最佳選擇,但它是最需要你謹慎從事的架構。 如果你的資料分佈在多個節點上,你需要意識到這樣一個分散式系統中約束和權衡 —— 資料庫並不能魔術般地把這些東西隱藏起來。
雖然分散式無共享架構有許多優點,但它通常也會給應用帶來額外的複雜度,有時也會限制你可用資料模型的表達力。 在某些情況下,一個簡單的單執行緒程式可以比一個擁有超過 100 個 CPU 核的叢集表現得更好 4。另一方面,無共享系統可以非常強大。接下來的幾章,將詳細討論分散式資料會帶來的問題。
複製 vs 分割槽
資料分佈在多個節點上有兩種常見的方式:
- 複製(Replication)
- 在幾個不同的節點上儲存資料的相同副本,可能放在不同的位置。複製提供了冗餘:如果一些節點不可用,剩餘的節點仍然可以提供資料服務。複製也有助於改善效能。第六章 將討論複製。
- 分割槽 (Partitioning)
- 將一個大型資料庫拆分成較小的子集(稱為 分割槽,即 partitions),從而不同的分割槽可以指派給不同的 節點(nodes,亦稱 分片,即 sharding)。第七章 將討論分割槽。
複製和分割槽是不同的機制,但它們經常同時使用。如 圖 II-1 所示。

理解了這些概念,就可以開始討論在分散式系統中需要做出的困難抉擇。第八章 將討論 事務(Transaction),這對於瞭解資料系統中可能出現的各種問題,以及我們可以做些什麼很有幫助。 第九章 和 第十章 將討論分散式系統的根本侷限性。
在本書的 第三部分 中,將討論如何將多個(可能是分散式的)資料儲存整合為一個更大的系統,以滿足複雜的應用需求。但首先,我們來聊聊分散式的資料。
6. 複製
7. 分片
8. 事務
9. 分散式系統的麻煩
10. 一致性與共識
參考
Ulrich Drepper: “What Every Programmer Should Know About Memory,” akka‐dia.org, November 21, 2007. ↩︎
Ben Stopford: “Shared Nothing vs. Shared Disk Architectures: An Independent View,” benstopford.com, November 24, 2009. ↩︎
Michael Stonebraker: “The Case for Shared Nothing,” IEEE Database EngineeringBulletin, volume 9, number 1, pages 4–9, March 1986. ↩︎
Frank McSherry, Michael Isard, and Derek G. Murray: “Scalability! But at What COST?,” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS),May 2015. ↩︎
6 複製

與可能出錯的東西比,“不可能”出錯的東西最顯著的特點就是:一旦真的出錯,通常就徹底玩完了。
—— 道格拉斯・亞當斯,《基本無害》(1992)
複製(replication)意味著在透過網路連線的多臺機器上保留相同資料的副本。正如 “分散式與單節點系統” 中所討論的,我們希望複製資料,可能出於以下原因:
- 使資料在地理上更接近使用者(從而降低訪問延遲)
- 即使系統的一部分發生故障,系統仍能繼續工作(從而提高可用性)
- 增加能夠處理讀查詢的機器數量(從而提高讀取吞吐量)
本章假設資料集足夠小,每臺機器都能儲存整個資料集的副本。在 第 7 章 中,我們將放寬這一假設,討論單臺機器無法容納的大型資料集如何進行 分片(sharding,也稱 分割槽,partitioning)。再往後的章節會討論複製資料系統中可能出現的各種故障,以及應對這些故障的方法。
如果要複製的資料不隨時間變化,複製就很簡單:只需把資料複製到每個節點一次,便大功告成。複製的全部難點都在於處理被複制資料的 變更,這正是本章的主題。我們將討論三類在節點之間複製變更的演算法:單主複製(single-leader replication)、多主複製(multi-leader replication)和 無主複製(leaderless replication)。幾乎所有分散式資料庫都採用其中一種。三者各有利弊,本章將逐一詳述。
複製需要考慮許多權衡,例如採用 同步複製(synchronous replication)還是 非同步複製(asynchronous replication),以及如何處理失效的副本。這些往往都是資料庫的配置選項;具體細節因資料庫而異,但不同實現背後的基本原理大體相通。本章將討論這些選擇帶來的後果。
資料庫複製算得上是老生常談:自 20 世紀 70 年代有人開始研究以來,其基本原理並沒有太大變化 1,因為網路的根本約束也一直未變。即便如此,最終一致性(eventual consistency)等概念仍常常引起困惑。在 “複製延遲的問題” 中,我們會更精確地說明最終一致性,並討論 讀己之寫(read-your-writes)、單調讀(monotonic reads)等保證。
你可能會問:有了複製,是否還需要備份?答案是肯定的,因為二者目的不同。副本會迅速把一個節點上的寫入反映到其他節點,而備份儲存的是資料在過去某一時刻的快照,以便恢復到先前狀態。如果不慎刪除了某些資料,複製幫不上忙,因為刪除操作也會傳播到所有副本;要恢復這些資料,仍然需要備份。
事實上,複製與備份往往相輔相成。正如 “設定新的副本” 中將要看到的,備份有時是建立複製的一環;反過來,歸檔複製日誌也可以成為備份流程的一部分。
有些資料庫會在內部維護過去狀態的不可變快照,相當於一種內建備份。不過,這意味著舊版本和當前狀態要儲存在同一類儲存介質上。資料量很大時,把舊資料的備份放在針對低頻訪問最佳化的物件儲存中,往往比放在主儲存中便宜;主儲存只需保留資料庫的當前狀態。
單主複製
每個儲存資料庫複製的節點都稱為一個 副本(replica)。存在多個副本時,一個問題不可避免:如何確保所有資料最終都出現在所有副本上?
資料庫的每次寫入都必須由每個副本處理,否則各副本就會包含不同的資料。最常見的解決方案稱為 基於領導者的複製(leader-based replication),也稱 主備複製(primary-backup)或 主動/被動複製(active/passive)。其工作原理如下(見 圖 6-1):
- 其中一個副本被指定為 領導者(leader,也稱 主庫,primary,或 源,source 2)。客戶端要寫入資料庫時,必須把請求發給領導者;領導者首先將新資料寫入本地儲存。
- 其他副本稱為 追隨者(follower,也稱 只讀副本,read replica,備庫,secondary,或 熱備,hot standby)。領導者每次把新資料寫入本地儲存,也會把資料變更作為 複製日誌(replication log)或 變更流(change stream)傳送給所有追隨者。每個追隨者取得領導者的日誌,按照領導者處理寫入的相同順序應用所有寫入,從而更新本地的資料庫副本。
- 客戶端讀取資料庫時,可以查詢領導者,也可以查詢任意追隨者;但只有領導者接受寫入(從客戶端的角度看,追隨者是隻讀的)。

如果資料庫做了分片(見 第 7 章),每個分片都有一個領導者。不同分片的領導者可以位於不同節點,但每個分片仍必須有且只有一個領導者。在 “多主複製” 中,我們將討論另一種模型:同一分片可以同時有多個領導者。
單主複製應用極為廣泛。它是 PostgreSQL、MySQL、Oracle Data Guard 3 和 SQL Server Always On 可用性組 4 等許多關聯式資料庫的內建功能;MongoDB、DynamoDB 5 等文件資料庫,Kafka 等訊息代理,DRBD 等複製塊裝置,以及一些網路檔案系統也採用這種方式。Raft 等許多共識演算法同樣以單個領導者為基礎;CockroachDB 6、TiDB 7、etcd、RabbitMQ 法定人數佇列等系統用它來實現複製,並在原領導者失效時自動選舉新領導者(第 10 章 將更詳細地討論共識)。
較早的資料中可能會出現 主從複製(master–slave replication)一詞。它與基於領導者的複製含義相同,但如今普遍認為這種說法具有冒犯性,應當避免使用 8。
同步複製與非同步複製
複製系統的一個重要細節是,複製究竟 同步(synchronous)進行還是 非同步(asynchronous)進行。(在關聯式資料庫中,這通常是一個配置項;其他系統往往固定採用其中一種。)
設想 圖 6-1 中的情形:某網站使用者更新個人頭像。客戶端在某個時刻向領導者發出更新請求,不久後領導者收到請求;領導者隨後在某個時刻把資料變更轉發給追隨者,並最終通知客戶端更新成功。圖 6-2 展示了其中一種可能的時序。

在 圖 6-2 的例子中,發往追隨者 1 的複製是 同步 的:領導者必須等到追隨者 1 確認收到寫入,才能向使用者報告成功,也才能讓其他客戶端看到這次寫入。發往追隨者 2 的複製則是 非同步 的:領導者發出訊息,但不等待追隨者響應。
圖中追隨者 2 處理訊息前有一段明顯的延遲。通常複製相當快:大多數資料庫系統不到一秒便能把變更應用到追隨者,但它們並不保證複製一定能在多久內完成。有時追隨者可能落後領導者幾分鐘甚至更久,例如追隨者正從失效中恢復、系統在容量極限附近執行,或節點間網路出現問題。
同步複製的優點是,追隨者保證擁有與領導者一致的最新資料副本。領導者突然失效時,可以確信資料仍可從追隨者取得。缺點是,如果同步追隨者沒有響應——無論因為崩潰、網路故障還是其他原因——寫入就無法繼續。領導者必須阻塞所有寫入,直至同步副本重新可用。
因此,把所有追隨者都設為同步並不現實:任意一個節點停機都會拖垮整個系統。實踐中,資料庫所謂的同步複製,通常是指 一個 追隨者同步,其餘追隨者非同步。如果同步追隨者不可用或過慢,就把某個非同步追隨者切換為同步。這樣可以保證至少有兩個節點持有最新資料:領導者和一個同步追隨者。這種配置有時也稱為 半同步(semi-synchronous)。
有些系統會同步更新 多數 副本(例如含領導者在內的 5 個副本中更新 3 個),其餘少數副本非同步更新。這就是 法定人數 的一個例子,我們會在 “讀寫仲裁” 中進一步討論。採用共識協議自動選舉領導者的系統經常使用多數法定人數,第 10 章 將再次談到這個問題。
有時,基於領導者的複製會配置為完全非同步。如果領導者失效且無法恢復,所有尚未複製到追隨者的寫入都會丟失。也就是說,即使已經向客戶端確認成功,寫入仍不保證持久。不過,完全非同步配置也有一個優點:即使所有追隨者都已落後,領導者仍可繼續處理寫入。
削弱永續性聽起來不像是划算的取捨,但非同步複製依然應用廣泛,尤其是在追隨者很多或分佈於不同地理位置時 9。我們會在 “複製延遲的問題” 中再談這一點。
設定新的副本
有時需要設定新的追隨者,也許是為了增加副本數量,也許是為了替換失效的節點。怎樣才能確保新追隨者拿到領導者資料的準確副本?
簡單地把資料檔案從一個節點複製到另一個節點通常並不夠:客戶端一直在向資料庫寫入,資料始終處於變化之中,普通的檔案複製會在不同時間點讀到資料庫的不同部分,所得結果可能毫無意義。
可以鎖住資料庫,讓磁碟上的檔案保持一致,但這樣資料庫將無法接受寫入,違背了高可用的目標。好在設定追隨者通常不需要停機。從概念上講,其過程如下:
- 取得領導者資料庫在某個時刻的一致快照;如果可能,不要鎖住整個資料庫。大多數資料庫都提供這一功能,因為備份同樣需要它。有些情況下需要藉助第三方工具,例如 MySQL 的 Percona XtraBackup。
- 把快照複製到新的追隨者節點。
- 追隨者連線領導者,請求從快照生成之後發生的所有資料變更。這要求快照與領導者複製日誌中的準確位置相關聯。不同系統對這個位置有不同稱呼:PostgreSQL 稱之為 日誌序列號(log sequence number,LSN);MySQL 則有 binlog 位點(binlog coordinates)和 全域性事務識別符號(global transaction identifiers,GTIDs)兩套機制。
- 追隨者處理完快照之後積壓的資料變更時,就稱它已經 趕上進度。此後,它可以繼續隨時處理領導者產生的資料變更。
設定追隨者的實際步驟因資料庫而異。有些系統完全自動完成這一過程,另一些系統則需要管理員手工執行一套頗為晦澀的多步驟流程。
還可以把複製日誌歸檔到物件儲存;再定期把整個資料庫的快照儲存到物件儲存,就形成了一套很好的資料庫備份與災難恢復方案。建立新追隨者時,步驟 1 和 2 也可以透過從物件儲存下載這些檔案來完成。例如,WAL-G 為 PostgreSQL、MySQL 和 SQL Server 提供了這種功能,Litestream 則為 SQLite 提供了類似功能。
物件儲存不只能用於歸檔。許多資料庫已經開始用 Amazon Web Services S3、Google Cloud Storage、Azure Blob Storage 等物件儲存為線上查詢提供資料。把資料庫資料存入物件儲存有很多好處:
- 物件儲存比其他雲端儲存方案便宜。雲資料庫因而可以把查詢頻率較低的資料放到成本更低、延遲更高的儲存中,同時用記憶體、SSD 和 NVMe 儲存工作集。
- 物件儲存還提供多可用區、雙地區或多地區複製,並給出很高的永續性保證;資料庫也由此可以避開跨可用區網路費用。
- 資料庫可以利用物件儲存的 條件寫入 功能——本質上是一種 比較並設定(CAS)操作——來實現事務和領導者選舉 10 11。
- 把多個資料庫的資料放在同一物件儲存中,可以簡化資料整合,尤其是在使用 Apache Parquet、Apache Iceberg 等開放格式時。
這些好處把事務、領導者選舉和複製的責任轉交給物件儲存,從而大幅簡化資料庫架構。
以物件儲存實現複製的系統也必須面對一些權衡。尤其是,物件儲存的讀寫延遲遠高於本地磁碟或 EBS 之類的虛擬塊裝置。許多雲服務商還按 API 呼叫次數收費,迫使系統把讀寫合併成批次以降低成本,而批處理又會進一步增加延遲。此外,許多物件儲存沒有標準的檔案系統介面,未整合物件儲存的系統便無法直接利用它。使用者空間檔案系統(FUSE)等介面允許運維人員把物件儲存桶掛載成檔案系統,應用程式不必知道資料實際位於物件儲存中。然而,許多物件導向儲存的 FUSE 介面並不支援非順序寫入、符號連結等 POSIX 功能,而系統可能依賴這些功能。
不同系統處理這些權衡的方式各不相同。有些採用 分層儲存 架構,把不常訪問的資料放到物件儲存,把新資料或常用資料放在 SSD、NVMe 乃至記憶體等更快的儲存介質上。另一些系統以物件儲存作為主儲存層,但另用 Amazon EBS、Neon Safekeepers 12 等低延遲儲存系統儲存 WAL。近來,一些系統走得更遠,採用 零磁碟架構(ZDA):所有資料都持久化到物件儲存,磁碟和記憶體只用作快取。節點因此無需儲存持久狀態,運維也大為簡化。WarpStream、Confluent Freight、Buf 的 Bufstream 和 Redpanda Serverless 都是採用零磁碟架構構建的 Kafka 相容系統。幾乎所有現代雲資料倉儲也採用類似架構,向量搜尋引擎 Turbopuffer 和雲原生 LSM 儲存引擎 SlateDB 亦是如此。
處理節點故障
系統中的任何節點都可能停機,既可能是故障意外所致,也可能是計劃內維護,例如重啟機器以安裝核心安全補丁。能夠在服務不中斷的情況下逐個重啟節點,對運維和維護大有裨益。因此,我們的目標是:即使個別節點失效,整個系統仍能繼續執行,並儘可能減小節點停機的影響。
如何用基於領導者的複製實現高可用?
追隨者失效:追趕恢復
每個追隨者都會在本地磁碟上記錄從領導者收到的資料變更。如果追隨者崩潰後重啟,或領導者與追隨者之間的網路暫時中斷,恢復起來相對容易:追隨者可以從日誌中得知故障發生前處理的最後一個事務。它隨後連線領導者,請求自己斷開期間發生的所有資料變更。應用完這些變更後,它便趕上領導者,可以像以前一樣繼續接收資料變更流。
追隨者恢復在概念上很簡單,效能上卻可能很棘手。如果資料庫寫入吞吐量很高,或追隨者離線很久,需要追趕的寫入可能非常多。追趕期間,正在恢復的追隨者和領導者都會承受很高負載——領導者還要把積壓的寫入傳送給追隨者。
所有追隨者確認處理完某段寫入日誌後,領導者便可將其刪除。但如果某個追隨者長時間不可用,領導者必須作出選擇:要麼一直保留日誌,等追隨者恢復並趕上進度,但要冒領導者磁碟空間耗盡的風險;要麼刪除尚未得到該追隨者確認的日誌,這樣追隨者恢復上線後就無法靠日誌追趕,只能從備份恢復。
領導者失效:故障切換
領導者失效處理起來更加棘手:必須把一個追隨者提升為新領導者,重新配置客戶端以便把寫入發給新領導者,並讓其他追隨者開始消費新領導者的資料變更。這個過程稱為 故障切換(failover)。
故障切換可以手工完成——通知管理員領導者已經失效,再由管理員執行必要步驟選出新領導者;也可以自動完成。自動故障切換通常包括以下步驟:
- 確認領導者失效。 可能出問題的地方很多:崩潰、斷電、網路故障等等。沒有萬無一失的辦法判斷究竟出了什麼問題,因此大多數系統只是使用超時:節點之間頻繁往返傳遞訊息,如果某個節點在一段時間內(例如 30 秒)沒有響應,就認為它已經失效。(因計劃維護而主動下線領導者則不適用這一規則。)
- 選擇新的領導者。 可以透過選舉完成(由剩餘副本中的多數選出領導者),也可以由事先指定的 控制器節點 任命 13。最合適的候選者通常是從舊領導者收到資料變更最多、資料最新的副本,這樣可以儘量減少資料損失。讓所有節點就新領導者達成一致是一個共識問題,第 10 章 將詳細討論。
- 重新配置系統以使用新領導者。 客戶端現在必須把寫請求發給新領導者(參見 “請求路由”)。如果舊領導者恢復上線,它可能仍以為自己是領導者,沒有意識到其他副本已經迫使它退位。系統必須保證舊領導者轉為追隨者,並承認新領導者。
故障切換過程中有很多地方可能出錯:
- 如果使用非同步複製,新領導者可能沒有收到舊領導者失效前的全部寫入。選出新領導者後,原領導者若重新加入叢集,那些未複製的寫入該怎麼辦?與此同時,新領導者可能已經收到與之衝突的寫入。最常見的辦法是直接丟棄舊領導者尚未複製的寫入,這意味著你原以為已經提交的寫入其實並未持久儲存。
- 如果資料庫內容還需要與資料庫之外的儲存系統協調,丟棄寫入尤其危險。例如,GitHub 曾發生過一起事故 14:一個資料過時的 MySQL 追隨者被提升為領導者。資料庫用自增計數器為新行分配主鍵;新領導者的計數器落後於舊領導者,因而重複使用了舊領導者已經分配過的一些主鍵。這些主鍵同時用於 Redis 儲存,主鍵重用造成 MySQL 與 Redis 資料不一致,最終使一些私有資料洩露給了錯誤的使用者。
- 在某些故障場景下(見 第 9 章),可能有兩個節點都認為自己是領導者。這種情況稱為 腦裂(split brain),非常危險:如果兩個領導者都接受寫入,而系統又沒有衝突解決流程(參見 “多主複製”),資料很可能丟失或損壞。有些系統設有保險機制,一旦發現兩個領導者便關閉其中一個節點;但機制設計不當時,也可能把兩個節點都關閉 15。而且,等系統發現腦裂並關閉舊節點時,可能已經為時過晚,資料早已損壞。
- 宣佈領導者失效前,超時應該設為多長?超時越長,領導者確實失效時恢復所需的時間就越長;超時太短,又容易觸發不必要的故障切換。例如,短暫的負載尖峰可能使節點響應時間超過超時值,網路抖動也可能延遲資料包。如果系統已經飽受高負載或網路問題困擾,不必要的故障切換隻會讓情況更糟。
透過限制或關閉舊領導者來防止腦裂,稱為 柵欄(fencing),更形象的說法是 爆彼之頭(Shoot The Other Node In The Head,STONITH)。“分散式鎖和租約” 將更詳細地討論柵欄機制。
這些問題沒有簡單的解決方案。因此,即使軟體支援自動故障切換,一些運維團隊還是更願意手工執行。
故障切換最重要的是選出一個資料最新的追隨者作為新領導者。採用同步或半同步複製時,應選擇舊領導者確認寫入前所等待的那個追隨者;採用非同步複製時,則可以選擇日誌序列號最大的追隨者。這樣能儘量減少故障切換時的資料損失:丟失幾分之一秒內的寫入或許尚可容忍,選中一個落後數天的追隨者卻可能是災難性的。
節點失效、不可靠的網路,以及圍繞副本一致性、永續性、可用性和延遲所作的權衡,都是分散式系統中的根本問題。第 9 章 和 第 10 章 將進一步深入討論。
複製日誌的實現
基於領導者的複製在底層究竟如何工作?實踐中採用了幾種不同的複製方式,下面逐一簡要介紹。
基於語句的複製
最簡單的情況下,領導者會記錄自己執行的每個寫入請求(即 語句),並把這份語句日誌傳送給追隨者。對於關聯式資料庫,這意味著每條 INSERT、UPDATE 或 DELETE 語句都會轉發給追隨者;每個追隨者解析並執行這條 SQL 語句,就像它直接來自客戶端一樣。
雖然聽起來很合理,但這種複製方式有許多可能出錯的地方:
- 任何呼叫非確定性函式的語句,都可能在各副本上產生不同的值,例如用
NOW()取得當前日期和時間,或用RAND()取得隨機數。 - 如果語句使用自增列,或依賴資料庫中的現有資料(例如
UPDATE … WHERE <some condition>),就必須在每個副本上按完全相同的順序執行,否則可能產生不同結果。有多個事務併發執行時,這會成為限制。 - 帶有副作用的語句(例如觸發器、儲存過程、使用者定義函式),可能在各副本上產生不同的副作用,除非這些副作用完全確定。
這些問題可以繞開。例如,領導者在記錄語句時,可以用固定的返回值替換非確定性函式呼叫,從而讓所有追隨者得到相同的值。按固定順序執行確定性語句,與 “事件溯源與 CQRS” 中介紹的事件溯源模型很相似。這種方法也稱為 狀態機複製(state machine replication);“使用共享日誌” 將討論其理論基礎。
MySQL 5.1 以前使用基於語句的複製。由於日誌相當緊湊,如今有時仍會採用這種方式;不過預設情況下,只要語句中存在任何非確定性,MySQL 就會切換到稍後介紹的基於行的複製。VoltDB 也採用基於語句的複製,並要求事務必須是確定性的,以保證安全 16。然而,實踐中很難確保確定性,因此許多資料庫更傾向於其他複製方式。
預寫日誌(WAL)傳輸
我們在 第 4 章 中看到,B 樹儲存引擎需要預寫日誌才能可靠工作:每次修改都先寫入 WAL,以便崩潰後把樹恢復到一致狀態。WAL 包含把索引和堆恢復到一致狀態所需的全部資訊,因此同一份日誌也能用來在另一個節點上建立副本:領導者除了把日誌寫入磁碟,還透過網路把它傳送給追隨者。追隨者處理日誌後,就會構建出與領導者完全相同的檔案副本。
PostgreSQL、Oracle 等資料庫採用這種複製方式 17 18。其主要缺點是,日誌在非常低的層次描述資料:WAL 記錄了哪個磁碟塊中的哪些位元組發生變化。因此,複製與儲存引擎緊密耦合。資料庫從一個版本升級到另一個版本、儲存格式隨之改變時,通常無法在領導者和追隨者上執行不同版本的資料庫軟體。
這看起來只是一個微不足道的實現細節,卻可能給運維帶來巨大影響。如果複製協議允許追隨者執行比領導者更新的軟體版本,就可以先升級追隨者,再執行故障切換,讓一個升級後的節點成為新領導者,從而實現資料庫軟體的零停機升級。如果複製協議不允許版本不一致——WAL 傳輸往往如此——這類升級就需要停機。
邏輯(基於行)日誌複製
另一種方法是讓複製和儲存引擎使用不同的日誌格式,從而使複製日誌與儲存引擎的內部實現解耦。這種複製日誌稱為 邏輯日誌(logical log),以區別於儲存引擎的(物理,physical)資料表示。
關聯式資料庫的邏輯日誌通常由一系列記錄組成,以行的粒度描述對資料庫表的寫入:
- 插入一行時,日誌包含所有列的新值。
- 刪除一行時,日誌包含足以唯一標識被刪行的資訊,通常是主鍵;如果表沒有主鍵,則需要記錄所有列的舊值。
- 更新一行時,日誌包含足以唯一標識被更新行的資訊,以及所有列的新值(或者至少包含所有發生變化的列的新值)。
修改多行的事務會生成多條這樣的日誌記錄,隨後再跟一條表示事務已經提交的記錄。MySQL 配置為基於行的複製時,除了 WAL 之外,還會維護一份稱為 binlog 的獨立邏輯複製日誌。PostgreSQL 則把物理 WAL 解碼成行插入、更新和刪除事件,以實現邏輯複製 19。
由於邏輯日誌與儲存引擎的內部實現解耦,更容易保持向後相容,因而領導者與追隨者可以執行不同版本的資料庫軟體,也就能以極少的停機時間升級到新版本 20。
邏輯日誌格式也更容易由外部應用程式解析。如果要把資料庫內容傳送到外部系統,例如送入資料倉儲做離線分析,或構建自定義索引和快取 21,這一點會很有用。這種技術稱為 變更資料捕獲(change data capture,CDC),我們會在 “變更資料捕獲” 中再次談到它。
複製延遲的問題
容忍節點失效只是使用複製的一個原因。正如 “分散式與單節點系統” 中提到的,其他原因還包括可伸縮性(處理單臺機器無法承受的請求量)和延遲(把副本放在地理上更靠近使用者的位置)。
基於領導者的複製要求所有寫入經過一個節點,但只讀查詢可以發往任意副本。對於讀多寫少的工作負載——線上服務往往如此——有一種很有吸引力的方案:建立許多追隨者,把讀請求分散到這些追隨者上。這樣既能減輕領導者的負載,也能由附近的副本處理讀請求。
在這種 讀擴充套件 架構中,只需增加追隨者,便可提高只讀請求的處理能力。不過,這種辦法實際上只適用於非同步複製。如果嘗試同步複製到所有追隨者,任意一個節點失效或網路中斷都會讓整個系統無法寫入。節點越多,越可能有某個節點停機,因此完全同步的配置會極不可靠。
不幸的是,應用程式從 非同步(asynchronous)追隨者讀取時,如果追隨者落後,就可能看到過時的資訊。資料庫於是顯得不一致:同時在領導者和追隨者上執行同一查詢,結果可能不同,因為追隨者尚未反映所有寫入。這種不一致只是暫時的——如果停止寫入並等待一段時間,追隨者最終會趕上領導者,恢復一致。因此,這種現象稱為 最終一致性(eventual consistency)22。
“最終”一詞有意保持模糊:一般而言,副本能落後多久並沒有上限。正常執行時,從寫入發生在領導者上,到變更反映在追隨者上,兩者之間的延遲——即 複製延遲——可能只有幾分之一秒,在實踐中難以察覺。但當系統在容量極限附近執行或網路出現問題時,延遲很容易增至幾秒甚至幾分鐘。
延遲一旦大到這種程度,由此造成的不一致就不再只是理論問題,而會成為應用程式面臨的真實問題。本節將重點介紹複製延遲容易引發的三類問題,並概述一些解決辦法。
讀己之寫
許多應用程式允許使用者提交資料,隨後檢視自己提交的內容。它可能是客戶資料庫中的一條記錄、討論主題下的一條評論,或其他類似內容。新資料必須寫入領導者,但使用者檢視時可以從追隨者讀取。如果資料經常讀取、很少寫入,這種做法尤其合適。
非同步複製在這裡會產生問題,如 圖 6-3 所示:使用者剛寫入資料不久便檢視它時,新資料可能尚未到達該副本。在使用者看來,自己剛剛提交的資料彷彿丟失了,當然會感到不滿。

這種情況下需要 寫後讀一致性(read-after-write consistency),也稱 讀己之寫一致性(read-your-writes consistency)23。它保證使用者重新載入頁面時,總能看到自己提交的更新。至於其他使用者則不作保證:他們的更新可能過一段時間才會出現。但至少使用者可以確信,自己的輸入已經正確儲存。
如何在基於領導者的複製系統中實現寫後讀一致性?有多種辦法,例如:
- 讀取使用者可能修改過的內容時,從領導者或同步更新的追隨者讀取;其他內容則從非同步更新的追隨者讀取。這要求系統無需實際查詢,就能判斷某項內容是否可能被修改。例如,社交網路中的個人資料通常只能由本人編輯。因此可以制定一條簡單規則:使用者自己的資料總是從領導者讀取,其他使用者的資料則從追隨者讀取。
- 如果應用程式中的大部分內容都可能由使用者編輯,上述辦法就沒有效果,因為幾乎所有內容都得從領導者讀取,讀擴充套件也就失去了意義。這時可以採用其他標準來決定是否從領導者讀取。例如,記錄最近一次更新時間,此後一分鐘內的所有讀取都發往領導者 25。也可以監控追隨者的複製延遲,不向落後領導者超過一分鐘的追隨者傳送查詢。
- 客戶端可以記住自己最近一次寫入的時間戳,系統再確保為該使用者處理讀取的副本至少已經反映到這個時間戳。如果副本不夠新,就換一個副本處理讀取,或讓查詢等待該副本趕上進度 26。這個時間戳既可以是 邏輯時間戳(例如表示寫入順序的日誌序列號),也可以來自實際的系統時鐘;後一種情況下,時鐘必須準確同步,參見 “不可靠的時鐘”。
- 如果副本分佈於多個地區(為了靠近使用者或提高可用性),還會增加一層複雜性:凡是必須由領導者處理的請求,都要路由到領導者所在的地區。
同一使用者透過多個裝置訪問服務時,例如同時使用桌面瀏覽器和移動應用,還會出現另一種複雜情況。這時可能需要提供 跨裝置 寫後讀一致性:使用者在一臺裝置上輸入資訊,隨後在另一臺裝置上檢視時,應當看到剛才輸入的內容。
還需要考慮以下問題:
- 依賴“記住使用者最近一次更新時間戳”的方法會變得更難,因為一臺裝置上執行的程式碼不知道另一臺裝置做了哪些更新。這些後設資料需要集中儲存。
- 如果副本分佈在不同地區,來自不同裝置的連線不保證會路由到同一地區。例如,使用者的桌上型電腦使用家庭寬頻,手機使用蜂窩網路,兩臺裝置的網路路徑可能完全不同。如果方案要求從領導者讀取,可能首先要把該使用者所有裝置發出的請求都路由到同一地區。
本書用 地區(region)表示同一地理位置上的一個或多個資料中心。雲服務商通常會在同一地理區域設定多個資料中心,每個資料中心稱為一個 可用區(availability zone),簡稱 區(zone)。因此,一個雲地區由多個可用區組成。每個可用區都是位於獨立物理設施中的資料中心,有自己的供電、製冷等基礎設施。
同一地區內的可用區之間由高速網路連線,延遲足夠低,因此大多數分散式系統可以把節點分散在多個可用區執行,就像它們位於同一個區一樣。多可用區配置可以抵禦某個可用區整體下線,卻無法抵禦一個地區內所有可用區均不可用的地區級中斷。要在地區級中斷時繼續執行,分散式系統必須跨多個地區部署,而這可能帶來更高延遲、更低吞吐量和更高的雲網路費用。我們將在 “多主複製拓撲” 中進一步討論這些權衡。這裡暫且記住:本書所說的地區,是同一地理位置上的一組可用區或資料中心。
單調讀
從非同步追隨者讀取時可能發生的第二種異常,是使用者可能會看到 時光倒流。
使用者連續幾次從不同副本讀取時,就可能發生這種情況。例如,圖 6-4 中,使用者 2345 連續執行兩次相同查詢:第一次查詢複製延遲較小的追隨者,第二次查詢延遲更大的追隨者。(使用者重新整理網頁、而每個請求被隨機路由到不同伺服器時,這種場景很容易出現。)第一次查詢返回了使用者 1234 最近新增的評論,第二次卻什麼也沒返回,因為落後的追隨者還沒有收到這次寫入。實際上,第二次查詢觀察到的系統狀態比第一次更早。如果第一次查詢本就沒有結果,倒也問題不大,因為使用者 2345 多半不知道使用者 1234 剛剛新增過評論;但如果評論先出現又消失,就非常令人困惑。

單調讀 22 保證這種異常不會發生。它弱於強一致性,卻強於最終一致性。讀取資料時仍可能看到舊值;單調讀只保證同一使用者順序執行多次讀取時,不會看到時間倒退——一旦讀到較新的資料,以後就不會再讀到更舊的資料。
實現單調讀的一種辦法,是確保每個使用者始終從同一個副本讀取(不同使用者可以選擇不同副本)。例如,可以根據使用者 ID 的雜湊選擇副本,而不是隨機選擇。如果該副本失效,則需要把使用者的查詢重新路由到其他副本。
一致字首讀
第三種複製延遲異常違反了因果關係。設想 Poons 先生和 Cake 夫人有下面這段簡短對話:
- Poons 先生
- Cake 夫人,你能看到多遠的未來?
- Cake 夫人
- 通常大約十秒鐘,Poons 先生。
這兩句話之間存在因果依賴:Cake 夫人聽到 Poons 先生的問題,然後作出回答。
現在設想第三個人透過追隨者旁聽這段對話。Cake 夫人的話經過複製延遲較小的追隨者,Poons 先生的話經過延遲更大的追隨者(見 圖 6-5)。於是,這位旁聽者會聽到:
- Cake 夫人
- 通常大約十秒鐘,Poons 先生。
- Poons 先生
- Cake 夫人,你能看到多遠的未來?
在旁聽者看來,Cake 夫人還沒聽到 Poons 先生提問,就已經回答了問題。這種通靈能力令人印象深刻,卻也十分費解 27。

防止這種異常需要另一種保證:一致字首讀(consistent prefix reads)22。它保證,如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看到它們以相同順序出現。
這在分片(分割槽)資料庫中尤其容易成為問題,我們將在 第 7 章 討論這類資料庫。如果資料庫始終按相同順序應用寫入,讀取就總會看到一致字首,這種異常也不會發生。然而,在許多分散式資料庫中,不同分片彼此獨立執行,沒有全域性寫入順序。使用者讀取資料庫時,可能看到一部分處於較舊狀態,另一部分卻處於較新狀態。
一種解決辦法是確儲存在因果關係的寫入落在同一個分片,但有些應用程式無法高效做到這一點。還有一些演算法會顯式跟蹤因果依賴,我們會在 “先發生”關係與併發 中再次討論。
複製延遲的解決方案
使用最終一致的系統時,值得認真考慮:如果複製延遲增加到幾分鐘甚至幾小時,應用程式會怎樣表現?如果答案是“沒有影響”,那當然很好;如果會給使用者帶來糟糕體驗,就必須把系統設計成能夠提供寫後讀之類的更強保證。複製本來是非同步的,卻假裝它是同步的,遲早會釀成問題。
如前所述,應用程式可以提供比底層資料庫更強的保證,例如把某些讀取發往領導者或同步更新的追隨者。不過,在應用程式程式碼中處理這些問題既複雜又容易出錯。
對應用程式開發者而言,最簡單的程式設計模型,是選擇一個能為副本提供強一致性保證(例如線性一致性,見 第 10 章)和 ACID 事務(見 第 8 章)的資料庫。這樣就可以基本忽略複製帶來的挑戰,把資料庫看作只有一個節點。2010 年代初興起的 NoSQL 運動曾宣揚一種觀點:這些特性會限制可伸縮性,大規模系統不得不接受最終一致性。
此後,許多資料庫開始在提供強一致性和事務的同時,保留分散式資料庫在容錯、高可用和可伸縮性方面的優勢。正如 “關係模型與文件模型” 中提到的,為與 NoSQL 區分,這一趨勢稱為 NewSQL(儘管重點並不在 SQL 本身,而在可伸縮事務管理的新方法)。
如今雖已有可伸縮的強一致分散式資料庫,一些應用程式仍有充分理由選擇一致性保證較弱的其他複製方式:面對網路中斷時,它們的韌性可能更強,開銷也低於事務型系統。本章餘下部分將繼續探討這些方法。
多主複製
到目前為止,本章只討論了使用單個領導者的複製架構。雖然這是常見做法,但還有一些值得關注的選擇。
單主複製有一個主要缺點:所有寫入都必須經過唯一的領導者。無論出於什麼原因,只要連線不上領導者——例如客戶端與領導者之間的網路中斷——就無法寫入資料庫。
單主複製模型可以自然地擴充套件為允許多個節點接受寫入。複製仍以同樣方式進行:每個處理寫入的節點都必須把資料變更轉發給其他所有節點。我們把這種配置稱為 多主複製(multi-leader replication),也稱 主動/主動複製(active/active)或 雙向複製(bidirectional replication)。在這種配置中,每個領導者同時也是其他領導者的追隨者。
與單主複製一樣,多主複製也可以選擇同步或非同步。假設有兩個領導者 A 和 B,現在要向 A 寫入。如果寫入必須從 A 同步複製到 B,那麼兩者之間的網路一旦中斷,在網路恢復前就無法向 A 寫入。這種同步多主複製提供的模型與單主複製極為相似:也就是說,這等同於把 B 設為領導者,由 A 把所有寫請求轉發給 B 執行。
因此,本節不再深入討論同步多主複製,而把它視為等同於單主複製。以下討論集中於非同步多主複製:即使某個領導者與其他領導者之間的連線中斷,它仍可處理寫入。
跨地域執行
在單個地區內使用多主配置通常沒有多少意義,因為所得好處很少能抵消額外的複雜性。不過,在某些場景中,這種配置確實合理。
設想一個資料庫在多個地區都有副本,也許是為了在整個地區失效時仍能執行,也許是為了在地理上更接近使用者。這種部署稱為 地理分散式(geographically distributed)、跨地域分散式(geo-distributed)或 跨地域複製(geo-replicated)。採用單主複製時,領導者必須位於其中 一個 地區,所有寫入都要經過該地區。
在多主配置中,每個 地區都可以有一個領導者。圖 6-6 展示了這種架構:每個地區內部使用常規的領導者—追隨者複製(追隨者可以位於與領導者不同的可用區);地區之間,則由各地區的領導者把變更復制給其他地區的領導者。

下面比較單主與多主配置在多地區部署中的表現:
- 效能
- 在單主配置中,每次寫入都必須透過網際網路發往領導者所在的地區。這可能顯著增加寫入延遲,甚至違背多地區部署的初衷。在多主配置中,每次寫入都能在本地地區處理,再非同步複製到其他地區。地區間的網路延遲因而對使用者不可見,感知到的效能可能更好。
- 容忍地區停機
- 在單主配置中,如果領導者所在的地區不可用,可以透過故障切換把另一個地區的追隨者提升為領導者。在多主配置中,各地區可以彼此獨立地繼續執行;離線地區恢復上線後,複製會趕上進度。
- 容忍網路問題
- 即使地區之間使用專用連線,其流量也可能不如同一地區內不同可用區之間、或同一可用區內部的流量可靠。單主配置對地區間鏈路的問題極為敏感:一個地區的客戶端要向另一個地區的領導者寫入,就必須透過這條鏈路發出請求,並等待響應後才能完成操作。
非同步複製的多主配置更能容忍網路問題:網路暫時中斷期間,每個地區的領導者仍可獨立處理寫入。
- 一致性
- 單主系統可以提供可序列化事務等強一致性保證,我們會在 第 8 章 討論。多主系統最大的缺點,是它所能提供的一致性要弱得多。例如,無法保證銀行賬戶餘額不會變成負數,也無法保證使用者名稱唯一:不同領導者完全可能分別處理單獨看來合法的寫入(從賬戶支付一筆錢、註冊某個使用者名稱),但把它們與另一個領導者上的寫入合在一起,就違反了約束。
這是分散式系統的一項根本限制 28。如果必須強制執行這類約束,最好採用單主系統。不過,正如 “處理寫入衝突” 中將要看到的,對於不需要這類約束的大量應用程式,多主系統仍能提供有用的一致性屬性。
多主複製不如單主複製常見,但 MySQL、Oracle、SQL Server、YugabyteDB 等許多資料庫仍提供支援。有時它以外部附加元件的形式出現,例如 Redis Enterprise、EDB Postgres Distributed 和 pglogical 29。
許多資料庫的多主複製都是後來加裝的功能,因而常有細微的配置陷阱,也會與其他資料庫功能產生出人意料的相互作用。例如,自增鍵、觸發器和完整性約束都可能帶來問題。因此,多主複製往往被視為應當儘量避開的危險領域 30。
多主複製拓撲
複製拓撲(replication topology)描述寫入從一個節點傳播到另一個節點時所經過的通訊路徑。如果只有兩個領導者,如 圖 6-9 所示,那麼只有一種合理的拓撲:領導者 1 必須把所有寫入傳送給領導者 2,反之亦然。領導者超過兩個時,則有多種拓撲可供選擇。圖 6-7 給出了三個例子。

最通用的是 圖 6-7(c) 所示的 全對全(all-to-all)拓撲,每個領導者都會把寫入傳送給其他所有領導者。不過,實際系統也會採用限制更多的拓撲。例如在 環形拓撲(circular topology)中,每個節點從一個節點接收寫入,再把這些寫入連同自己的寫入轉發給另一個節點。另一種常見拓撲呈 星形(star topology):指定一個根節點,由它把寫入轉發給所有其他節點。星形拓撲還可以推廣成樹形。
不要把星形網路拓撲與 星型模式 混為一談;後者描述的是資料模型結構,參見 “星型與雪花型:分析模式”。
在環形和星形拓撲中,一次寫入可能要經過多個節點才能到達所有副本。因此,節點必須轉發從其他節點收到的資料變更。為避免無限複製迴圈,每個節點都有唯一識別符號;複製日誌中的每次寫入都會標記自己經過的全部節點 31。節點收到帶有自身識別符號的資料變更時,會直接忽略它,因為這說明自己已經處理過該變更。
不同拓撲的問題
環形和星形拓撲有一個問題:只要一個節點失效,就可能中斷其他節點之間的複製訊息流;在該節點修復前,其他節點無法相互通訊。雖然可以重配拓撲以繞過失效節點,但大多數部署都需要手工完成這一操作。連線更密集的拓撲(例如全對全)容錯性更好,因為訊息可以沿不同路徑傳播,避開單點故障。
另一方面,全對全拓撲也有問題。尤其是,不同網路鏈路的速度可能不同(例如受到網路擁塞影響),導致某些複製訊息“超越”另一些訊息,如 圖 6-8 所示。

在 圖 6-8 中,客戶端 A 在領導者 1 上向表中插入一行,客戶端 B 隨後在領導者 3 上更新該行。但領導者 2 可能以相反順序收到這兩次寫入:先收到更新(在它看來,這是要更新資料庫中並不存在的行),稍後才收到本應先發生的插入。
這也是一個因果關係問題,與 “一致字首讀” 中看到的情況相似。更新依賴先前的插入,因此必須保證所有節點先處理插入,再處理更新。僅僅給每次寫入附加時間戳並不夠,因為不能相信各節點的時鐘同步得足以讓領導者 2 正確排列這些事件(見 第 9 章)。
要正確排列這些事件,可以採用本章稍後介紹的 版本向量(version vector;見 “檢測併發寫入”)。不過,許多多主複製系統並未使用可靠的更新排序技術,因而容易遇到 圖 6-8 所示的問題。如果使用多主複製,應當瞭解這些風險,仔細閱讀文件,並充分測試資料庫,確認它確實提供了你以為它會提供的保證。
同步引擎與本地優先軟體
應用程式需要在斷網時繼續工作,是另一個適合多主複製的場景。
以手機、膝上型電腦和其他裝置上的日曆應用為例。無論裝置有沒有聯網,你都需要隨時檢視會議(發出讀請求)和新增會議(發出寫請求)。離線期間所作的變更,應在裝置下次上線時與伺服器及其他裝置同步。
這種情況下,每臺裝置都有一個充當領導者的本地資料庫副本,可以接受寫入;各裝置上的日曆副本之間則透過非同步多主複製過程進行同步。複製延遲可能長達數小時甚至數天,取決於裝置何時重新聯網。
從架構上看,這種配置相當於把地區間多主複製推到極致:每臺裝置都是一個“地區”,它們之間的網路連線極不可靠。
實時協作、離線優先和本地優先應用
此外,許多現代 Web 應用還提供 實時協作 功能,例如用於文件和電子表格的 Google Docs 與 Sheets、用於圖形設計的 Figma,以及用於專案管理的 Linear。這些應用之所以響應迅速,是因為使用者輸入會立即反映在介面上,無需等待與伺服器的一次網路往返;一位使用者所作的編輯也會以很低的延遲呈現給協作者 32 33 34。
這同樣形成了多主架構:每個開啟共享檔案的瀏覽器標籤頁都是一個副本,對檔案所作的更新會非同步複製到其他開啟該檔案的使用者裝置上。即使應用程式不支援離線編輯,只要多個使用者可以不等伺服器響應便各自編輯,它就已經是多主系統。
離線編輯與實時協作需要相似的複製基礎設施:應用程式必須捕獲使用者對檔案作出的所有變更,線上時立即發給協作者,離線時則先儲存在本地,稍後再傳送。同時,應用程式還要接收協作者的變更,將其合併到使用者的本地檔案副本,並更新介面以顯示最新版本。多個使用者併發修改檔案時,還可能需要用衝突解決邏輯合併這些變更。
支援這一過程的軟體庫稱為 同步引擎(sync engine)。這個想法由來已久,但“同步引擎”一詞近來才受到關注 35 36 37。允許使用者離線時繼續編輯檔案的應用程式稱為 離線優先(offline-first)應用 38,它可以用同步引擎來實現。本地優先軟體(local-first software)則不僅要支援離線優先,還要保證即使軟體開發者關閉所有線上服務,協作應用仍能繼續工作 39。一種實現方式是採用開放標準的同步協議,並讓多個服務提供商都能支援這一協議 40。例如,Git 就是本地優先的協作系統(雖然它不支援實時協作),因為可以透過 GitHub、GitLab 或其他任意程式碼倉庫託管服務進行同步。
同步引擎的利弊
如今構建 Web 應用的主流方式,是讓客戶端只保留極少的持久狀態;每當需要顯示新資料或更新資料時,就向伺服器發出請求。使用同步引擎時則相反:客戶端持有持久狀態,與伺服器的通訊移到後臺進行。這種方式有多項優點:
- 資料在本地,使用者介面的響應速度可以遠快於等待服務呼叫返回資料。有些應用追求在圖形系統的 下一幀 響應使用者輸入:對於重新整理率為 60 Hz 的顯示器,這意味著要在 16 毫秒內完成渲染。
- 允許使用者離線工作很有價值,尤其是在連線時斷時續的移動裝置上。使用同步引擎後,應用程式無需另設離線模式:離線不過是網路延遲變得非常大。
- 與在應用程式碼中顯式呼叫服務相比,同步引擎簡化了前端應用的程式設計模型。正如 “遠端過程呼叫(RPC)的問題” 中所述,每次服務呼叫都要處理錯誤。例如,更新伺服器資料的請求失敗後,使用者介面必須以某種方式反映錯誤。同步引擎讓應用直接讀寫幾乎不會失敗的本地資料,從而形成更具宣告性的程式設計風格 41。
- 要實時顯示其他使用者所作的編輯,需要接收變更通知,並據此高效更新使用者介面。同步引擎與 響應式程式設計(reactive programming)模型結合,是實現這一功能的好辦法 42。
如果能事先下載使用者可能需要的全部資料,並持久儲存在客戶端,同步引擎的效果最好。這樣一來,需要時就能離線訪問;但也意味著,如果使用者可以訪問的資料量非常大,同步引擎便不適用。例如,下載使用者自己建立的全部檔案通常沒問題(單個使用者一般不會產生那麼多資料),下載一個電子商務網站的全部商品目錄則多半不合理。
Lotus Notes 在 20 世紀 80 年代率先採用了同步引擎的思想 43,儘管當時並未使用這個名稱;日曆等特定應用的同步功能也已存在多年。如今有不少通用同步引擎,其中一些依賴專有後端服務,例如 Google Firestore、Realm 或 Ditto;另一些提供開源後端,適合構建本地優先軟體,例如 PouchDB/CouchDB、Automerge 或 Yjs。
多人影片遊戲也需要立即響應玩家的本地操作,再與透過網路非同步收到的其他玩家操作協調。在遊戲開發術語中,與同步引擎對應的部分稱為 網路程式碼(netcode)。網路程式碼所用的技術針對遊戲需求高度定製 44,無法直接移植到其他軟體,因此本書不再展開。
處理寫入衝突
多主複製最大的難題——無論是地理分散式的服務端資料庫,還是終端使用者裝置上的本地優先同步引擎——都是不同領導者上的併發寫入可能彼此衝突,需要解決。
例如,圖 6-9 展示了兩個使用者同時編輯一個維基頁面。使用者 1 把頁面標題從 A 改為 B,使用者 2 則獨立地把標題從 A 改為 C。兩位使用者的變更都成功應用到各自的本地領導者,但非同步複製這些變更時,系統發現了衝突。單主資料庫不會遇到這個問題。

我們稱 圖 6-9 中的兩次寫入為 併發寫入,因為最初執行寫入時,兩者都不知道對方。它們在物理時間上是否真的同時發生並不重要;如果寫入發生在離線期間,兩者甚至可能相隔很久。真正重要的是,一次寫入發生時,另一次寫入是否已經生效。
我們會在 “檢測併發寫入” 中討論資料庫如何判斷兩次寫入是否併發。現在先假定衝突已經能夠檢測,接下來考慮怎樣解決才最合適。
衝突避免
一種策略是從一開始就避免衝突。例如,如果應用程式能確保某條記錄的所有寫入都經過同一個領導者,那麼即使整個資料庫採用多主複製,也不會產生衝突。同步引擎客戶端離線更新時無法使用這種辦法,但在跨地域複製的服務端系統中有時可行 30。
例如,在使用者只能編輯自己資料的應用程式中,可以保證某位使用者的請求總是路由到同一個地區,並使用該地區的領導者讀寫。不同使用者可以有不同的“主”地區(也許根據與使用者的地理距離來選擇),但從任一使用者的角度看,本質上仍是單主配置。
不過,有時需要改變某條記錄的指定領導者:可能是一個地區不可用,必須把流量改送另一個地區;也可能是使用者搬到了別處,現在離另一個地區更近。如果使用者恰好在指定領導者切換期間執行寫入,就可能發生衝突,必須用下面某種方法解決。因此,只要允許改變領導者,衝突避免就可能失效。
再舉一個衝突避免的例子。假設要插入新記錄,並用自增計數器生成唯一 ID。系統有兩個領導者時,可以讓一個只生成奇數,另一個只生成偶數。這樣兩個領導者便不會併發地把同一個 ID 分配給不同記錄。“ID 生成器和邏輯時鐘” 將討論其他 ID 分配方案。
最後寫入者勝(丟棄併發寫入)
如果無法避免衝突,最簡單的解決辦法是給每次寫入附加時間戳,並始終採用時間戳最大的值。例如在 圖 6-9 中,假設使用者 1 寫入的時間戳大於使用者 2。兩個領導者都會判定頁面的新標題應為 B,並丟棄把標題設為 C 的寫入。如果兩次寫入碰巧擁有相同時間戳,還可以比較值來決定勝者(例如字串可以選擇字母順序在前的值)。
這種方法稱為 最後寫入者勝(last write wins,LWW),因為時間戳最大的寫入被視為“最後”一次寫入。不過,這個名稱容易誤導:當兩次寫入像 圖 6-9 中那樣併發時,根本無從定義哪次較早、哪次較晚,因此併發寫入的時間戳順序實質上是隨機的。
所以,LWW 的真正含義是:同一條記錄在不同領導者上併發寫入時,隨機挑選其中一次作為勝者,其他寫入則靜默丟棄,即使它們都已在各自的領導者上成功處理。這樣固然能讓所有副本最終收斂到一致狀態,代價卻是資料丟失。
如果能夠避免衝突——例如只插入以 UUID 等唯一鍵標識的記錄,而且從不更新——LWW 就沒有問題。但如果要更新現有記錄,或不同領導者可能插入鍵相同的記錄,就必須判斷丟失更新對應用程式是否可以接受。不能接受時,應採用下面介紹的其他衝突解決方式。
LWW 還有一個問題:如果寫入時間戳來自實時時鐘(例如 Unix 時間戳),系統會對時鐘同步極為敏感。假如一個節點的時鐘快於其他節點,再嘗試覆蓋該節點寫入的值時,新寫入的時間戳可能反而更小,因而被忽略,儘管它顯然發生得更晚。使用 邏輯時鐘 可以解決這個問題,參見 “ID 生成器和邏輯時鐘”。
手動衝突解決
如果不願隨機丟棄某些寫入,下一個選擇是手工解決衝突。你可能熟悉 Git 等版本控制系統中的做法:兩個分支上的提交修改了同一檔案的同一行,合併分支時就會產生合併衝突,必須先解決衝突才能完成合並。
在資料庫中,讓一次衝突阻塞整個複製過程,直到有人解決,顯然並不現實。資料庫通常會儲存一條記錄的所有併發寫入值——例如 圖 6-9 中的 B 和 C。這些值有時稱為 兄弟值(siblings)。下次查詢該記錄時,資料庫返回 全部 值,而不只是最新的一個。隨後可以任意選擇解決辦法:在應用程式碼中自動處理(例如把 B 與 C 拼成“B/C”),或詢問使用者;最後再向資料庫寫回一個新值,消解衝突。
CouchDB 等系統採用這種衝突解決方式,但它也有不少問題:
- 資料庫 API 會發生變化。例如,維基頁面標題原本只是字串,現在卻變成一組字串;通常只有一個元素,發生衝突時卻可能有多個。應用程式碼處理這種資料會相當彆扭。
- 讓使用者手工合併兄弟值,無論對應用開發者還是使用者都是沉重負擔:開發者必須製作衝突解決介面,使用者則可能不明白自己為什麼要做這件事、又該做什麼。很多情況下,自動合併比打擾使用者更合適。
- 自動合併兄弟值如果不夠謹慎,也會產生意外結果。例如,亞馬遜購物車過去允許併發更新,再把任一兄弟值中出現的所有商品都保留下來,也就是取購物車的並集。如果顧客在一個兄弟值中刪除商品,而另一個兄弟值仍含有該商品,已經刪除的商品便會意外重現 45。圖 6-10 展示了這種情況:裝置 1 刪除 Book,裝置 2 同時刪除 DVD,合併衝突後兩件商品卻都回來了。
- 如果多個節點同時觀察到衝突並各自解決,解決過程本身還可能引入新衝突,而且各節點給出的結果可能不一致。例如,如果沒有固定排列順序,一個節點可能把 B 和 C 合併成“B/C”,另一個卻合併成“C/B”;再合併“B/C”與“C/B”時,結果可能變成“B/C/C/B”之類的怪東西。

自動衝突解決
對許多應用程式而言,處理衝突的最佳方式,是用演算法自動把併發寫入合併成一致狀態。自動衝突解決可以保證所有副本 收斂 到同一狀態:只要處理過相同的一組寫入,各副本的狀態就相同,與寫入到達的順序無關。
LWW 是衝突解決演算法的一個簡單例子。針對不同資料型別,人們還開發了更複雜的合併演算法,目標是儘可能保留所有更新的預期效果,從而避免資料丟失:
- 如果資料是文字(例如維基頁面的標題或正文),可以檢測相鄰版本之間插入或刪除了哪些字元。合併結果會保留任一兄弟值中的所有插入和刪除。如果使用者在同一位置併發插入文字,可以按確定性的順序排列,確保所有節點得到相同結果。
- 如果資料是元素集合,無論像待辦事項列表一樣有序,還是像購物車一樣無序,都可以像合併文字那樣跟蹤插入和刪除。為避免 圖 6-10 中的購物車異常,演算法會記住 Book 和 DVD 已被刪除,因此合併結果為 Cart = {Soap}。
- 如果資料是可以遞增或遞減的整數計數器(例如社交媒體帖子的點贊數),合併演算法可以計算各兄弟值上分別發生了多少次遞增和遞減,再正確相加,既不重複計數,也不丟失更新。
- 如果資料是鍵值對映,可以對同一個鍵下的值採用其他某種衝突解決演算法;不同鍵上的更新則可以彼此獨立地處理。
衝突解決並非無所不能。例如,如果規定一個列表最多包含五個元素,而多位使用者併發新增元素,使總數超過五個,那麼唯一的選擇就是丟掉其中一些。即便如此,自動衝突解決仍足以構建許多實用應用。一旦決定構建可協作的離線優先或本地優先應用,衝突解決就不可避免,而自動化往往是最佳選擇。
CRDT 與操作變換
實現自動衝突解決時,通常使用兩類演算法:無衝突複製資料型別(CRDT)46 和 操作變換(OT)47。二者的設計理念與效能特徵不同,但都能自動合併前面提到的各種資料。
圖 6-11 展示了 OT 和 CRDT 分別如何合併文字的併發更新。假設兩個副本起初都儲存文字“ice”。一個副本在開頭插入字母“n”,得到“nice”;與此同時,另一個副本在末尾插入感嘆號,得到“ice!”。

兩類演算法用不同方式得到合併結果“nice!”:
- OT
- 記錄字元插入或刪除位置的索引:“n”插入索引 0,“!”插入索引 3。然後兩個副本交換操作。在索引 0 插入“n”可以原樣應用;但如果直接在狀態“nice”的索引 3 插入“!”,結果會變成錯誤的“nic!e”。因此,必須根據已經應用的併發操作變換每個操作的索引。這裡,為了計入較小索引處插入的“n”,需要把“!”的插入位置變換為索引 4。
- CRDT
- 大多數 CRDT 不使用索引,而是給每個字元分配唯一且不可變的 ID,再據此確定插入和刪除位置。例如在 圖 6-11 中,“i”的 ID 是 1A,“c”的 ID 是 2A,依此類推。插入感嘆號時,生成的操作既包含新字元的 ID(4B),也包含插入位置之前那個現有字元的 ID(3A)。要插在字串開頭,就把前驅字元 ID 設為“nil”。同一位置的併發插入按字元 ID 排列。這樣無需變換操作,也能保證各副本收斂。
許多演算法都建立在這些思路的不同變體上。列表和陣列可以採用類似方法,把字元換成列表元素;鍵值對映等其他資料型別也很容易加入。OT 與 CRDT 在效能和功能上各有取捨,但也可以把二者的優點結合到同一種演算法中 48。
OT 最常用於文字的實時協作編輯,例如 Google Docs 32;CRDT 則用於 Redis Enterprise、Riak、Azure Cosmos DB 等分散式資料庫 49。面向 JSON 資料的同步引擎既可以用 CRDT 實現(如 Automerge、Yjs),也可以用 OT 實現(如 ShareDB)。
什麼是衝突?
有些衝突顯而易見。在 圖 6-9 的例子中,兩次寫入併發修改同一條記錄的同一個欄位,把它設成兩個不同的值。毫無疑問,這就是衝突。
另一些衝突則更為微妙,不易發現。以會議室預訂系統為例,它記錄哪個房間在什麼時間由哪組人預訂。應用程式必須確保同一時刻每個房間只分配給一組人,也就是說,同一房間的預訂不能重疊。如果兩項不同預訂在同一時間佔用同一房間,就會產生衝突。即使應用程式在允許預訂前檢查空閒情況,只要兩次預訂分別在不同領導者上進行,仍可能發生衝突。
這個問題沒有現成的簡短答案,不過在接下來的章節中,我們會逐步加深理解。第 8 章 將給出更多衝突示例;“排序事件以捕獲因果關係” 則會討論在複製系統中可伸縮地檢測與解決衝突的方法。
無主複製
本章此前討論的單主複製與多主複製,都基於同一個思路:客戶端把寫請求發給某個節點(領導者),再由資料庫系統負責把寫入複製到其他副本。領導者決定處理寫入的順序,追隨者則按相同順序應用領導者的寫入。
另一些資料儲存系統採取了不同辦法:放棄領導者概念,允許任何副本直接接受客戶端寫入。最早的一些複製資料系統採用的就是無主模型 1 50,但在關聯式資料庫佔據主導地位的年代,這個思路幾乎被遺忘。2007 年,亞馬遜把它用於內部的 Dynamo 系統 45,無主架構由此再度流行。Riak、Cassandra 和 ScyllaDB 都是受 Dynamo 啟發、採用無主複製模型的開源資料儲存,因此這類資料庫也稱為 Dynamo 風格(Dynamo-style)資料庫。
在一些無主實現中,客戶端直接把寫入發給多個副本;另一些實現則由協調者節點代客戶端完成這件事。不過,與有領導者的資料庫不同,協調者不會強制規定寫入順序。我們將看到,這項設計差異會深刻影響資料庫的使用方式。
當節點故障時寫入資料庫
假設一個資料庫有三個副本,其中一個暫時不可用,也許正在重啟以安裝系統更新。在單主配置中,要繼續處理寫入,可能需要執行故障切換(參見 “處理節點故障”)。
無主配置則根本沒有故障切換。圖 6-12 展示了此時的情形:客戶端(使用者 1234)把寫入並行發給三個副本;兩個可用副本接受寫入,不可用副本則錯過了它。假設三個副本中有兩個確認就足以判定寫入成功:使用者 1234 收到兩個 ok 響應後,系統便認為寫入成功,客戶端直接忽略有一個副本漏掉寫入這一事實。

現在設想不可用的節點恢復上線,客戶端開始從它讀取。該節點停機期間發生的寫入都沒有儲存在這裡,因此從它讀取時,響應中可能包含 陳舊(過時)的值。
為解決這個問題,客戶端讀取資料庫時,不能只把請求發給一個副本:讀請求也要並行發往多個節點。不同節點可能給出不同響應,例如一個返回最新值,另一個返回陳舊值。
要分辨哪些響應是最新的、哪些已經過時,每個寫入的值都必須帶有版本號或時間戳,類似 “最後寫入者勝(丟棄併發寫入)” 中介紹的做法。客戶端收到多個讀取結果時,採用時間戳最大的值——即使只有一個副本返回該值,其他幾個副本都返回舊值。更多細節參見 “檢測併發寫入”。
追趕錯過的寫入
複製系統應當保證所有資料最終都會複製到每個副本。不可用節點恢復上線後,怎樣補上停機期間錯過的寫入?Dynamo 風格的資料儲存會使用以下幾種機制:
- 讀修復(read repair)
- 客戶端並行讀取多個節點時,可以發現陳舊響應。例如在 圖 6-12 中,使用者 2345 從副本 3 得到版本 6 的值,從副本 1 和副本 2 得到版本 7 的值。客戶端發現副本 3 的值已經過時,於是把較新的值寫回這個副本。對於經常讀取的值,這種方法很有效。
- 提示移交(hinted handoff)
- 某個副本不可用時,另一個副本可以替它儲存寫入,並把這些寫入記錄為 提示。原本應接收這些寫入的副本恢復後,儲存提示的副本會將它們傳送過去,然後刪除提示。即使某些值從未被讀取、無法透過讀修復更新,這個 移交 過程也能讓副本趕上進度。
- 反熵(anti-entropy)
- 此外,還有一個後臺程序定期查詢副本之間的資料差異,把缺失的資料從一個副本複製到另一個。與基於領導者的複製日誌不同,這個 反熵過程(anti-entropy process)並不按特定順序複製寫入,資料得到複製之前可能有很長延遲。
讀寫仲裁
在 圖 6-12 的例子中,寫入只在三個副本中的兩個上完成,我們仍判定它成功。如果只有一個副本接受寫入呢?這個下限究竟能壓到多低?
如果能保證每次成功寫入至少儲存在三個副本中的兩個上,那麼最多隻有一個副本是陳舊的。因此,只要讀取至少兩個副本,就可以確信其中至少一個是最新的。即使第三個副本停機或響應緩慢,讀取仍能返回最新值。
更一般地說,假設有 n 個副本,每次寫入必須得到 w 個節點確認才算成功,每次讀取則至少查詢 r 個節點。(上述例子中,n = 3、w = 2、r = 2。)只要 w + r > n,讀取時就有望得到最新值,因為所查詢的 r 個節點中,至少有一個必然是最新的。遵守這些 r、w 取值的操作稱為 仲裁讀(quorum read)和 仲裁寫(quorum write)50。可以把 r 和 w 看作一次讀或寫要成立所需的最低票數。
在 Dynamo 風格的資料庫中,引數 n、w、r 通常都可以配置。常見做法是讓 n 取奇數(通常為 3 或 5),並令 w = r = (n + 1) / 2(向上取整);不過也可以根據需要調整。例如,寫少讀多的工作負載可能適合設為 w = n、r = 1。這樣讀取更快,缺點是隻要一個節點失效,所有資料庫寫入都會失敗。
叢集中的節點數可以多於 n,但任意給定值只儲存在 n 個節點上。這樣就能對資料集進行分片,支援單個節點無法容納的資料集。我們會在 第 7 章 繼續討論分片。
仲裁條件 w + r > n 使系統能夠按以下方式容忍節點不可用:
- 如果 w < n,有一個節點不可用時仍可處理寫入。
- 如果 r < n,有一個節點不可用時仍可處理讀取。
- 當 n = 3、w = 2、r = 2 時,可以像 圖 6-12 那樣容忍一個節點不可用。
- 當 n = 5、w = 3、r = 3 時,可以容忍兩個節點不可用,如 圖 6-13 所示。
通常,讀寫請求總會並行傳送到全部 n 個副本。引數 w 和 r 決定要等待多少個節點,也就是在判定讀或寫成功之前,n 個節點中必須有多少個報告成功。

如果可用節點少於所需的 w 或 r,寫入或讀取就會返回錯誤。節點不可用可能有許多原因:節點停機(崩潰或斷電)、執行操作時出錯(磁碟已滿,無法寫入)、客戶端與節點之間網路中斷,等等。我們只關心節點是否返回成功響應,無需區分故障的具體型別。
仲裁一致性的侷限
如果有 n 個副本,並選擇滿足 w + r > n 的 w 和 r,通常可以期望每次讀取都返回某個鍵最近寫入的值。這是因為寫入所涉及的節點集合與讀取所涉及的節點集合必然有交集;也就是說,讀取的節點中至少有一個儲存著最新值,如 圖 6-13 所示。
r 和 w 通常取節點的多數(多於 n/2),因為這樣既能保證 w + r > n,又能容忍最多 n/2(向下取整)個節點失效。不過,法定人數並不一定非得是多數;真正重要的是,讀操作與寫操作所用的節點集合至少有一個共同節點。法定人數還可以有其他安排,為分散式演算法的設計提供一定靈活性 51。
也可以把 w 和 r 設得更小,使 w + r ≤ n,即不滿足仲裁條件。此時讀寫請求仍會發往 n 個節點,只是操作成功所需的成功響應更少。
w 和 r 越小,越容易讀到陳舊值,因為讀操作更可能沒有覆蓋儲存最新值的節點。好處則是延遲更低、可用性更高:網路中斷導致許多副本不可達時,系統仍有更大機會繼續處理讀寫。只有可達副本數低於 w 或 r 時,資料庫才會分別變得不可寫或不可讀。
然而,即使 w + r > n,仍有一些邊緣情況會讓一致性屬性變得難以理解,例如:
- 如果儲存新值的節點失效,又從儲存舊值的副本恢復資料,儲存新值的副本數可能降到 w 以下,破壞仲裁條件。
- 再平衡期間,一部分資料會從一個節點遷移到另一個節點(見 第 7 章),各節點對“某個值的 n 個副本應由哪些節點儲存”可能看法不一,使讀仲裁與寫仲裁不再相交。
- 如果讀操作與寫操作併發,讀取可能看見併發寫入的值,也可能看不見。尤其是,某次讀取可能看到新值,後續讀取卻看到舊值,參見 “線性一致性與仲裁”。
- 如果一次寫入在部分副本上成功、在其餘副本上失敗(例如某些節點磁碟已滿),總成功數少於 w,那麼整體寫入會判定失敗,但成功副本上的寫入不會回滾。這意味著即使系統報告寫入失敗,後續讀取仍可能返回這次寫入的值 52。
- 如果資料庫用實時時鐘的時間戳判斷寫入的新舊(例如 Cassandra 和 ScyllaDB),另一個時鐘較快的節點只要寫過同一個鍵,後續寫入就可能被靜默丟棄。我們在 “最後寫入者勝(丟棄併發寫入)” 中已經見過這個問題,還會在 “對同步時鐘的依賴” 中進一步討論。
- 如果兩次寫入併發發生,一個副本可能先處理其中一次,另一個副本則先處理另一次,由此產生衝突,與多主複製中的情況相似(參見 “處理寫入衝突”)。我們會在 “檢測併發寫入” 中再談這個問題。
因此,仲裁看似保證讀取返回最近寫入的值,實際卻沒有那麼簡單。Dynamo 風格資料庫通常針對能夠容忍最終一致性的場景最佳化。引數 w 和 r 可以調節讀到陳舊值的機率 53,卻不宜被當作絕對保證。
監控陳舊性
從運維角度看,監控資料庫返回的結果是否最新非常重要。即使應用程式能夠容忍陳舊讀取,也必須瞭解複製是否健康。如果複製大幅落後,系統應發出告警,以便調查網路故障、節點過載等原因。
採用基於領導者的複製時,資料庫通常會暴露覆制延遲指標,供監控系統採集。這是因為寫入在領導者和追隨者上按相同順序應用,每個節點都有自己在複製日誌中的位置,也就是已經在本地應用了多少次寫入。用領導者當前位置減去追隨者當前位置,便可度量複製延遲。
無主複製系統沒有固定的寫入應用順序,監控起來更加困難。副本為了移交而儲存的提示數量可以作為一項健康指標,卻很難作出有意義的解釋 54。最終一致性有意給出了一項模糊保證,但為了可運維性,必須能夠量化“最終”究竟有多遠。
單主與無主複製的效能
基於單個領導者的複製系統能夠提供強一致性保證,而無主系統很難甚至不可能做到。不過,正如 “複製延遲的問題” 中所見,在基於領導者的複製系統裡,如果從非同步更新的追隨者讀取,同樣可能得到陳舊值。
從領導者讀取可以保證響應最新,卻存在效能問題:
- 讀取吞吐量受領導者處理能力限制;相比之下,讀擴充套件可以把讀取分散到非同步更新的副本,但這些副本可能返回陳舊值。
- 領導者失效後,必須等系統檢測到故障並完成故障切換,才能繼續處理請求。即使故障切換很快,響應時間的短暫上升也會被使用者察覺;如果耗時很長,系統就會在此期間不可用。
- 系統對領導者的效能問題極其敏感。如果領導者因過載或資源爭用而響應緩慢,使用者的響應時間也會立即增加。
無主架構的一大優點,是面對這些問題時韌性更強。系統無需故障切換,而且請求原本就會並行發往多個副本,因此一個副本變慢或不可用,對響應時間的影響很小:客戶端只需採用響應較快的其他副本所返回的結果。採用最快響應的做法稱為 請求對沖(request hedging),可以顯著降低尾延遲 55。
無主系統之所以有這種韌性,根本原因是它不區分正常情況和故障情況。這對於處理所謂的 灰色失效(gray failure)尤其有利:節點並未徹底停機,卻處於降級狀態,處理請求異常緩慢 56;節點單純過載時也是如此(例如節點離線一段時間後,靠提示移交恢復可能產生大量額外負載)。基於領導者的系統必須判斷情況是否嚴重到需要故障切換,而故障切換本身又可能帶來進一步中斷;無主系統根本不需要作出這項判斷。
當然,無主系統也可能遇到效能問題:
- 即使不需要執行故障切換,也必須由一個副本發現另一個副本不可用,才能替它儲存錯過寫入的提示。不可用副本恢復後,移交過程還要把這些提示發給它。在系統本已承壓時,這會給副本增加額外負載 54。
- 副本越多,法定人數越大,請求完成前必須等待的響應也越多。即使只等待最快的 r 或 w 個副本,即使所有請求並行發出,更大的 r 或 w 仍會提高遇到慢副本的機率,從而增加總體響應時間(參見 “響應時間指標的應用”)。
- 大範圍網路中斷使客戶端與大量副本斷開時,可能根本無法組成法定人數。有些無主資料庫允許任何可達副本接受寫入,即使它不屬於該鍵通常所在的副本集合(Riak 和 Dynamo 稱之為 寬鬆仲裁,sloppy quorum 45;Cassandra 和 ScyllaDB 稱之為 一致性級別 ANY)。後續讀取不保證能看到這次寫入,但對某些應用而言,這仍好過寫入直接失敗。
多主複製抵禦網路中斷的能力甚至可能強於無主複製,因為讀寫只需與一個領導者通訊,而領導者可以與客戶端位於同一地區。不過,一個領導者上的寫入會非同步傳播給其他領導者,讀取結果因而可能任意陳舊。仲裁讀寫提供了一種折中:既有良好的容錯能力,也有很高機率讀到最新資料。
多地區操作
我們此前把跨地區複製作為多主複製的一個用例(見 “多主複製”)。無主複製同樣適合多地區執行,因為它本來就是為了容忍相互衝突的併發寫入、網路中斷和延遲尖峰而設計的。
Cassandra 和 ScyllaDB 在常規無主模型中實現多地區支援:客戶端把寫入直接發往所有地區的副本,並可選擇多種一致性級別,規定請求至少得到多少響應才算成功。例如,可以要求所有地區的全部副本共同組成一個法定人數,也可以要求每個地區各自組成法定人數,或只要求客戶端所在地區達到法定人數。本地法定人數無需等待其他地區的慢請求,但也更容易返回陳舊結果。
Riak 則把客戶端與資料庫節點之間的所有通訊限制在本地地區,因此 n 表示一個地區內的副本數。資料庫叢集之間的跨地區複製在後臺非同步進行,方式與多主複製相似。
檢測併發寫入
與多主複製一樣,無主資料庫允許對同一個鍵併發寫入,由此產生需要解決的衝突。衝突可能在寫入發生時出現,但並非總是如此;它也可能到讀修復、提示移交或反熵階段才被發現。
問題在於,網路延遲會變化,系統還可能部分失效,所以事件抵達不同節點的順序可能不同。例如,圖 6-14 展示了客戶端 A 和 B 同時寫入三節點資料儲存中的鍵 X:
- 節點 1 收到 A 的寫入,但由於短暫中斷,一直沒有收到 B 的寫入。
- 節點 2 先收到 A 的寫入,再收到 B 的寫入。
- 節點 3 先收到 B 的寫入,再收到 A 的寫入。

如果每個節點一收到客戶端寫請求就直接覆蓋鍵的值,各節點將永久不一致,如 圖 6-14 最後的 get 請求所示:節點 2 認為 X 的最終值是 B,其他節點卻認為是 A。
為了達到最終一致,各副本必須收斂到同一個值。可以採用 “處理寫入衝突” 中討論過的任意衝突解決機制,例如 Cassandra 和 ScyllaDB 使用的最後寫入者勝、手工解決,或 “CRDT 與操作變換” 中介紹且 Riak 使用的 CRDT。
最後寫入者勝很容易實現:給每次寫入附加時間戳,時間戳較大的值總是覆蓋較小的值。但時間戳無法告訴你兩個值究竟是否衝突:它們可能是併發寫入的,也可能先後寫入。如果要顯式解決衝突,系統必須更仔細地檢測併發寫入。
“先發生”關係與併發
怎樣判斷兩個操作是否併發?先看幾個例子來建立直覺:
- 在 圖 6-8 中,兩次寫入併不併發:A 的插入 先發生於 B 的遞增,因為 B 所遞增的值正是 A 插入的值。換句話說,B 的操作建立在 A 的操作之上,所以 B 必然發生得更晚。也可以說,B 因果依賴 於 A。
- 圖 6-14 中的兩次寫入則是併發的:每個客戶端開始操作時,都不知道另一個客戶端也在操作同一個鍵。因此,兩次操作之間沒有因果依賴。
如果操作 B 知道 A、依賴 A,或以某種方式建立在 A 之上,就稱操作 A 先發生於(happens before)操作 B。一項操作是否先發生於另一項操作,是定義併發的關鍵。事實上,只要兩個操作誰也不先發生於另一個——也就是說,誰都不知道對方——就可以稱它們 併發(concurrent)57。
因此,對於任意兩個操作 A 與 B,只有三種可能:A 先發生於 B;B 先發生於 A;或者 A 與 B 併發。我們需要一種演算法判斷兩次操作是否併發。如果一項操作先發生於另一項,後發生的操作就應覆蓋先前操作;如果兩者併發,則出現了需要解決的衝突。
兩項操作似乎只有在“同一時刻”發生時才應稱為併發,實際上它們在物理時間上是否重疊並不重要。由於分散式系統中的時鐘問題,判斷兩件事是否恰好同時發生相當困難,我們會在 第 9 章 進一步討論。
定義併發時,精確時間並不重要:只要兩項操作彼此都不知道對方,就稱它們併發,無論它們實際發生在什麼物理時刻。人們有時把這個原理與物理學中的狹義相對論聯絡起來 57。狹義相對論提出,資訊傳播不可能超過光速。因此,如果相隔一定距離的兩個事件之間,時間差短於光傳播這段距離所需的時間,它們就不可能相互影響。
在計算機系統中,即使按光速計算,一項操作原則上來得及影響另一項,兩者仍可能併發。例如,當時網路很慢或已經中斷,兩項操作即使相隔一段時間,仍會因網路問題而彼此無法知曉。
捕獲先發生關係
下面看一種演算法,它可以判斷兩項操作是併發的,還是一項先發生於另一項。為簡單起見,先從只有一個副本的資料庫開始。弄清單副本的做法後,再推廣到擁有多個副本的無主資料庫。
圖 6-15 展示了兩個客戶端併發地向同一個購物車新增商品。(如果這個例子太無聊,也可以設想兩名空中交通管制員併發地把飛機加入各自正在監視的空域。)購物車最初為空,兩個客戶端先後共向資料庫發出五次寫入:
- 客戶端 1 把
milk加入購物車。這是該鍵的第一次寫入,伺服器成功儲存它並分配版本 1;然後把值和版本號一起返回給客戶端。 - 客戶端 2 把
eggs加入購物車,卻不知道客戶端 1 同時加入了milk(它以為eggs是購物車中唯一的商品)。伺服器為這次寫入分配版本 2,把eggs和milk儲存為兩個獨立的值(兄弟值),再把 兩個 值連同版本號 2 一起返回給客戶端。 - 客戶端 1 不知道客戶端 2 的寫入,又想加入
flour,因此它認為購物車內容應為[milk, flour]。它把這個值連同伺服器此前給出的版本號 1 一起傳送。伺服器可以根據版本號判斷:[milk, flour]取代了先前的[milk],卻與[eggs]併發。因此,伺服器為[milk, flour]分配版本 3,覆蓋版本 1 的[milk],保留版本 2 的[eggs],並把剩下的兩個值都返回給客戶端。 - 與此同時,客戶端 2 想加入
ham,並不知道客戶端 1 剛剛加入flour。客戶端 2 在上一次響應中收到了[milk]和[eggs],於是將兩者合併,再加入ham,形成新值[eggs, milk, ham]。它把這個值連同先前的版本號 2 一起發給伺服器。伺服器判斷版本 2 可以覆蓋[eggs],但與[milk, flour]併發;剩下的兩個值便是版本 3 的[milk, flour]和版本 4 的[eggs, milk, ham]。 - 最後,客戶端 1 想加入
bacon。它此前在版本 3 的響應中收到[milk, flour]和[eggs],於是合併二者,加入bacon,把最終值[milk, flour, eggs, bacon]連同版本號 3 發給伺服器。這個值覆蓋[milk, flour]([eggs]已在上一步被覆蓋),卻與[eggs, milk, ham]併發,因此伺服器保留這兩個併發值。

圖 6-15 中各操作之間的資料流,在 圖 6-16 中以圖形表示。箭頭指出哪項操作 先發生於 另一項,也就是說,後發生的操作 知道 或 依賴 先發生的操作。在這個例子裡,客戶端從未完全掌握伺服器上的最新資料,因為始終有另一項操作併發進行。但值的舊版本最終會被覆蓋,而且不會丟失任何寫入。

請注意,伺服器僅憑版本號就能判斷兩項操作是否併發,無需解釋值本身,因此值可以是任意資料結構。演算法如下:
- 伺服器為每個鍵維護一個版本號;每次寫入該鍵時遞增版本號,並把新版本號與寫入值一同儲存。
- 客戶端讀取一個鍵時,伺服器返回所有兄弟值(即尚未被覆蓋的全部值)以及最新版本號。客戶端寫入前必須先讀取。
- 客戶端寫入一個鍵時,必須帶上前一次讀取所得的版本號,還必須把上次讀取收到的所有值合併起來,例如使用 CRDT,或詢問使用者。寫請求的響應與讀取相似,也會返回所有兄弟值,因此可以像購物車例子那樣連續執行多次寫入。
- 伺服器收到帶有特定版本號的寫入時,可以覆蓋版本號不高於它的所有值,因為這些值已被合併進新值;版本號更高的值則必須保留,因為它們與傳入寫入併發。
寫入帶上前一次讀取所得的版本號,就說明這次寫入基於哪個先前狀態。如果寫入不含版本號,它就與其他所有寫入併發,因而不會覆蓋任何內容,只會作為後續讀取返回的值之一。
版本向量
圖 6-15 的例子只有一個副本。如果沒有領導者,而且多個副本都能接受寫入,演算法需要怎樣改變?
圖 6-15 用一個版本號捕獲操作之間的依賴關係,但多個副本併發接受寫入時,一個版本號就不夠了。此時必須針對每個鍵,給 每個副本 分別維護版本號。副本處理寫入時遞增自己的版本號,同時記錄自己見過的其他副本版本號。這些資訊表明哪些值應當覆蓋,哪些值應作為兄弟值保留。
所有副本的版本號集合稱為 版本向量 58。這種思路有若干變體,其中最值得關注的也許是 點化版本向量(dotted version vector)59 60,Riak 2.0 採用了這種變體 61 62。這裡不展開細節;它的工作方式與購物車例子非常相似。
與 圖 6-15 中的版本號一樣,讀取時資料庫副本會把版本向量發給客戶端,隨後寫入時客戶端必須再把它帶回資料庫。(Riak 把版本向量編碼成一個字串,稱為 因果上下文,causal context。)版本向量讓資料庫能夠區分覆蓋寫入和併發寫入。
版本向量還保證:先從一個副本讀取,再把寫入發給另一個副本,是安全的。這樣做可能產生兄弟值,但只要正確合併兄弟值,就不會丟失資料。
總結
本章考察了複製問題。複製有多種用途:
- 高可用性
- 即使一臺或多臺機器、一個可用區乃至整個地區停機,系統仍能繼續執行
- 離線執行
- 網路中斷時,應用程式仍能繼續工作
- 延遲
- 把資料放在地理上靠近使用者的位置,讓使用者能夠更快地與之互動
- 可伸縮性
- 把讀取分散到多個副本,處理超出單臺機器能力的讀取量
複製的目標看似簡單——在多臺機器上保留相同資料的副本——實際卻極其棘手。它要求我們仔細考慮併發、所有可能出錯的環節,以及怎樣應對故障造成的後果。至少要處理節點不可用和網路中斷,而且這還沒有算上軟體缺陷或硬體錯誤引起的靜默資料損壞等更隱蔽的故障。
我們討論了三種主要的複製方式:
- 單主複製
- 客戶端把所有寫入發給一個節點(領導者),領導者再把資料變更事件流傳送給其他副本(追隨者)。讀取可以在任意副本上執行,但追隨者返回的結果可能陳舊。
- 多主複製
- 客戶端把每次寫入發給多個領導者中的一個,任意領導者都能接受寫入。各領導者相互傳送資料變更事件流,也會將其傳送給追隨者。
- 無主複製
- 客戶端把每次寫入發給多個節點,並行讀取多個節點,從而發現並修復持有陳舊資料的節點。
每種方式都有優缺點。單主複製很流行,因為它相對容易理解,又能提供強一致性。多主複製和無主複製面對節點故障、網路中斷和延遲尖峰時韌性更強,代價是必須解決衝突,而且只能提供較弱的一致性保證。
複製可以同步,也可以非同步;發生故障時,這項選擇會深刻影響系統行為。系統平穩執行時,非同步複製可能很快,但仍必須弄清複製延遲增大或伺服器失效時會發生什麼。如果領導者失效,而你把一個非同步更新的追隨者提升為新領導者,最近提交的資料可能丟失。
我們考察了複製延遲可能造成的幾種反常現象,並討論了幾種一致性模型,以便判斷應用程式在複製延遲下應有怎樣的行為:
- 寫後讀一致性
- 使用者應當總能看到自己提交的資料。
- 單調讀
- 使用者看到某一時刻的資料後,不應在稍後又看到更早時刻的資料。
- 一致字首讀
- 使用者看到的資料狀態應符合因果關係,例如以正確順序看到問題及其回答。
最後,我們討論了多主複製和無主複製如何讓所有副本最終收斂到一致狀態:用版本向量或類似演算法檢測哪些寫入併發,再用 CRDT 等衝突解決演算法合併併發寫入的值。最後寫入者勝和手工解決衝突也是可選方案。
本章一直假設每個副本都儲存整個資料庫的完整複製,但對大型資料集而言,這並不現實。下一章將介紹 分片,使每臺機器只需儲存一部分資料。
參考文獻
B. G. Lindsay, P. G. Selinger, C. Galtieri, J. N. Gray, R. A. Lorie, T. G. Price, F. Putzolu, I. L. Traiger, and B. W. Wade. Notes on Distributed Databases. IBM Research, Research Report RJ2571(33471), July 1979. Archived at perma.cc/EPZ3-MHDD ↩︎ ↩︎
Kenny Gryp. MySQL Terminology Updates. dev.mysql.com, July 2020. Archived at perma.cc/S62G-6RJ2 ↩︎
Oracle Corporation. Oracle (Active) Data Guard 19c: Real-Time Data Protection and Availability. White Paper, oracle.com, March 2019. Archived at perma.cc/P5ST-RPKE ↩︎
Microsoft. What is an Always On availability group? learn.microsoft.com, September 2024. Archived at perma.cc/ABH6-3MXF ↩︎
Mostafa Elhemali, Niall Gallagher, Nicholas Gordon, Joseph Idziorek, Richard Krog, Colin Lazier, Erben Mo, Akhilesh Mritunjai, Somu Perianayagam, Tim Rath, Swami Sivasubramanian, James Christopher Sorenson III, Sroaj Sosothikul, Doug Terry, and Akshat Vig. Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service. At USENIX Annual Technical Conference (ATC), July 2022. ↩︎ ↩︎
Rebecca Taft, Irfan Sharif, Andrei Matei, Nathan VanBenschoten, Jordan Lewis, Tobias Grieger, Kai Niemi, Andy Woods, Anne Birzin, Raphael Poss, Paul Bardea, Amruta Ranade, Ben Darnell, Bram Gruneir, Justin Jaffray, Lucy Zhang, and Peter Mattis. CockroachDB: The Resilient Geo-Distributed SQL Database. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1493–1509, June 2020. doi:10.1145/3318464.3386134 ↩︎
Dongxu Huang, Qi Liu, Qiu Cui, Zhuhe Fang, Xiaoyu Ma, Fei Xu, Li Shen, Liu Tang, Yuxing Zhou, Menglong Huang, Wan Wei, Cong Liu, Jian Zhang, Jianjun Li, Xuelian Wu, Lingyu Song, Ruoxi Sun, Shuaipeng Yu, Lei Zhao, Nicholas Cameron, Liquan Pei, and Xin Tang. TiDB: a Raft-based HTAP database. Proceedings of the VLDB Endowment, volume 13, issue 12, pages 3072–3084. doi:10.14778/3415478.3415535 ↩︎
Mallory Knodel and Niels ten Oever. Terminology, Power, and Inclusive Language in Internet-Drafts and RFCs. IETF Internet-Draft, August 2023. Archived at perma.cc/5ZY9-725E ↩︎
Buck Hodges. Postmortem: VSTS 4 September 2018. devblogs.microsoft.com, September 2018. Archived at perma.cc/ZF5R-DYZS ↩︎
Gunnar Morling. Leader Election With S3 Conditional Writes. www.morling.dev, August 2024. Archived at perma.cc/7V2N-J78Y ↩︎
Vignesh Chandramohan, Rohan Desai, and Chris Riccomini. SlateDB Manifest Design. github.com, May 2024. Archived at perma.cc/8EUY-P32Z ↩︎
Stas Kelvich. Why does Neon use Paxos instead of Raft, and what’s the difference? neon.tech, August 2022. Archived at perma.cc/SEZ4-2GXU ↩︎
Dimitri Fontaine. An introduction to the pg_auto_failover project. tapoueh.org, November 2021. Archived at perma.cc/3WH5-6BAF ↩︎
Jesse Newland. GitHub availability this week. github.blog, September 2012. Archived at perma.cc/3YRF-FTFJ ↩︎
Mark Imbriaco. Downtime last Saturday. github.blog, December 2012. Archived at perma.cc/M7X5-E8SQ ↩︎
John Hugg. ‘All In’ with Determinism for Performance and Testing in Distributed Systems. At Strange Loop, September 2015. ↩︎
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩︎
Amit Kapila. WAL Internals of PostgreSQL. At PostgreSQL Conference (PGCon), May 2012. Archived at perma.cc/6225-3SUX ↩︎
Amit Kapila. Evolution of Logical Replication. amitkapila16.blogspot.com, September 2023. Archived at perma.cc/F9VX-JLER ↩︎
Aru Petchimuthu. Upgrade your Amazon RDS for PostgreSQL or Amazon Aurora PostgreSQL database, Part 2: Using the pglogical extension. aws.amazon.com, August 2021. Archived at perma.cc/RXT8-FS2T ↩︎
Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, David Callies, Abhishek Choudhary, Laurent Demailly, Thomas Fersch, Liat Atsmon Guz, Andrzej Kotulski, Sachin Kulkarni, Sanjeev Kumar, Harry Li, Jun Li, Evgeniy Makeev, Kowshik Prakasam, Robbert van Renesse, Sabyasachi Roy, Pratyush Seth, Yee Jiun Song, Benjamin Wester, Kaushik Veeraraghavan, and Peter Xie. Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services. At 12th USENIX Symposium on Networked Systems Design and Implementation (NSDI), May 2015. ↩︎
Douglas B. Terry. Replicated Data Consistency Explained Through Baseball. Microsoft Research, Technical Report MSR-TR-2011-137, October 2011. Archived at perma.cc/F4KZ-AR38 ↩︎ ↩︎ ↩︎
Douglas B. Terry, Alan J. Demers, Karin Petersen, Mike J. Spreitzer, Marvin M. Theher, and Brent B. Welch. Session Guarantees for Weakly Consistent Replicated Data. At 3rd International Conference on Parallel and Distributed Information Systems (PDIS), September 1994. doi:10.1109/PDIS.1994.331722 ↩︎ ↩︎
Werner Vogels. Eventually Consistent. ACM Queue, volume 6, issue 6, pages 14–19, October 2008. doi:10.1145/1466443.1466448 ↩︎
Simon Willison. Reply to: “My thoughts about Fly.io (so far) and other newish technology I’m getting into”. news.ycombinator.com, May 2022. Archived at perma.cc/ZRV4-WWV8 ↩︎
Nithin Tharakan. Scaling Bitbucket’s Database. atlassian.com, October 2020. Archived at perma.cc/JAB7-9FGX ↩︎
Terry Pratchett. Reaper Man: A Discworld Novel. Victor Gollancz, 1991. ISBN: 978-0-575-04979-6 ↩︎
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 ↩︎
Yaser Raja and Peter Celentano. PostgreSQL bi-directional replication using pglogical. aws.amazon.com, January 2022. Archived at https://perma.cc/BUQ2-5QWN ↩︎
Robert Hodges. If You *Must* Deploy Multi-Master Replication, Read This First. scale-out-blog.blogspot.com, April 2012. Archived at perma.cc/C2JN-F6Y8 ↩︎ ↩︎
Lars Hofhansl. HBASE-7709: Infinite Loop Possible in Master/Master Replication. issues.apache.org, January 2013. Archived at perma.cc/24G2-8NLC ↩︎
John Day-Richter. What’s Different About the New Google Docs: Making Collaboration Fast. drive.googleblog.com, September 2010. Archived at perma.cc/5TL8-TSJ2 ↩︎ ↩︎
Evan Wallace. How Figma’s multiplayer technology works. figma.com, October 2019. Archived at perma.cc/L49H-LY4D ↩︎
Tuomas Artman. Scaling the Linear Sync Engine. linear.app, June 2023. ↩︎
Amr Saafan. Why Sync Engines Might Be the Future of Web Applications. nilebits.com, September 2024. Archived at perma.cc/5N73-5M3V ↩︎
Isaac Hagoel. Are Sync Engines The Future of Web Applications? dev.to, July 2024. Archived at perma.cc/R9HF-BKKL ↩︎
Sujay Jayakar. A Map of Sync. stack.convex.dev, October 2024. Archived at perma.cc/82R3-H42A ↩︎
Alex Feyerke. Designing Offline-First Web Apps. alistapart.com, December 2013. Archived at perma.cc/WH7R-S2DS ↩︎
Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: You own your data, in spite of the cloud. At ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019, pages 154–178. doi:10.1145/3359591.3359737 ↩︎
Martin Kleppmann. The past, present, and future of local-first. At Local-First Conference, May 2024. ↩︎
Conrad Hofmeyr. API Calling is to Sync Engines as jQuery is to React. powersync.com, November 2024. Archived at perma.cc/2FP9-7WJJ ↩︎
Peter van Hardenberg and Martin Kleppmann. PushPin: Towards Production-Quality Peer-to-Peer Collaboration. At 7th Workshop on Principles and Practice of Consistency for Distributed Data (PaPoC), April 2020. doi:10.1145/3380787.3393683 ↩︎
Leonard Kawell, Jr., Steven Beckhardt, Timothy Halvorsen, Raymond Ozzie, and Irene Greif. Replicated document management in a group communication system. At ACM Conference on Computer-Supported Cooperative Work (CSCW), September 1988. doi:10.1145/62266.1024798 ↩︎
Ricky Pusch. Explaining how fighting games use delay-based and rollback netcode. words.infil.net and arstechnica.com, October 2019. Archived at perma.cc/DE7W-RDJ8 ↩︎
Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, Gunavardhan Kakulapati, Avinash Lakshman, Alex Pilchin, Swaminathan Sivasubramanian, Peter Vosshall, and Werner Vogels. Dynamo: Amazon’s Highly Available Key-Value Store. At 21st ACM Symposium on Operating Systems Principles (SOSP), October 2007. doi:10.1145/1323293.1294281 ↩︎ ↩︎ ↩︎ ↩︎
Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski. A Comprehensive Study of Convergent and Commutative Replicated Data Types. INRIA Research Report no. 7506, January 2011. ↩︎
Chengzheng Sun and Clarence Ellis. Operational Transformation in Real-Time Group Editors: Issues, Algorithms, and Achievements. At ACM Conference on Computer Supported Cooperative Work (CSCW), November 1998. doi:10.1145/289444.289469 ↩︎
Joseph Gentle and Martin Kleppmann. Collaborative Text Editing with Eg-walker: Better, Faster, Smaller. At 20th European Conference on Computer Systems (EuroSys), March 2025. doi:10.1145/3689031.3696076 ↩︎
Dharma Shukla. Azure Cosmos DB: Pushing the frontier of globally distributed databases. azure.microsoft.com, September 2018. Archived at perma.cc/UT3B-HH6R ↩︎
David K. Gifford. Weighted Voting for Replicated Data. At 7th ACM Symposium on Operating Systems Principles (SOSP), December 1979. doi:10.1145/800215.806583 ↩︎ ↩︎
Heidi Howard, Dahlia Malkhi, and Alexander Spiegelman. Flexible Paxos: Quorum Intersection Revisited. At 20th International Conference on Principles of Distributed Systems (OPODIS), December 2016. doi:10.4230/LIPIcs.OPODIS.2016.25 ↩︎
Joseph Blomstedt. Bringing Consistency to Riak. At RICON West, October 2012. ↩︎
Peter Bailis, Shivaram Venkataraman, Michael J. Franklin, Joseph M. Hellerstein, and Ion Stoica. Quantifying eventual consistency with PBS. The VLDB Journal, volume 23, pages 279–302, April 2014. doi:10.1007/s00778-013-0330-1 ↩︎
Colin Breck. Shared-Nothing Architectures for Server Replication and Synchronization. blog.colinbreck.com, December 2019. Archived at perma.cc/48P3-J6CJ ↩︎ ↩︎
Jeffrey Dean and Luiz André Barroso. The Tail at Scale. Communications of the ACM, volume 56, issue 2, pages 74–80, February 2013. doi:10.1145/2408776.2408794 ↩︎
Peng Huang, Chuanxiong Guo, Lidong Zhou, Jacob R. Lorch, Yingnong Dang, Murali Chintalapati, and Randolph Yao. Gray Failure: The Achilles’ Heel of Cloud-Scale Systems. At 16th Workshop on Hot Topics in Operating Systems (HotOS), May 2017. doi:10.1145/3102980.3103005 ↩︎
Leslie Lamport. Time, Clocks, and the Ordering of Events in a Distributed System. Communications of the ACM, volume 21, issue 7, pages 558–565, July 1978. doi:10.1145/359545.359563 ↩︎ ↩︎
D. Stott Parker Jr., Gerald J. Popek, Gerard Rudisin, Allen Stoughton, Bruce J. Walker, Evelyn Walton, Johanna M. Chow, David Edwards, Stephen Kiser, and Charles Kline. Detection of Mutual Inconsistency in Distributed Systems. IEEE Transactions on Software Engineering, volume SE-9, issue 3, pages 240–247, May 1983. doi:10.1109/TSE.1983.236733 ↩︎
Nuno Preguiça, Carlos Baquero, Paulo Sérgio Almeida, Victor Fonte, and Ricardo Gonçalves. Dotted Version Vectors: Logical Clocks for Optimistic Replication. arXiv:1011.5808, November 2010. ↩︎
Giridhar Manepalli. Clocks and Causality - Ordering Events in Distributed Systems. exhypothesi.com, November 2022. Archived at perma.cc/8REU-KVLQ ↩︎ ↩︎
Sean Cribbs. A Brief History of Time in Riak. At RICON, October 2014. Archived at perma.cc/7U9P-6JFX ↩︎
Russell Brown. Vector Clocks Revisited Part 2: Dotted Version Vectors. riak.com, November 2015. Archived at perma.cc/96QP-W98R ↩︎
Carlos Baquero. Version Vectors Are Not Vector Clocks. haslab.wordpress.com, July 2011. Archived at perma.cc/7PNU-4AMG ↩︎
Reinhard Schwarz and Friedemann Mattern. Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail. Distributed Computing, volume 7, issue 3, pages 149–174, March 1994. doi:10.1007/BF02277859 ↩︎
7 分片

顯然,我們必須跳出電腦指令序列的窠臼,不能讓計算機受制於此。我們必須陳述定義、指明優先順序、描述資料;我們必須闡明關係,而不是編寫過程。
Grace Murray Hopper,未來的計算機及其管理(1962)
分散式資料庫通常透過兩種方式在節點間分佈資料:
- 在多個節點上儲存相同資料的副本:這就是 複製(replication),我們已在 第 6 章 中討論過。
- 如果不想讓每個節點都儲存全部資料,可以將大規模資料集拆成更小的 分片(shard) 或 分割槽(partition),再把不同分片存放到不同節點上。本章討論的就是分片。
通常情況下,每條資料(每條記錄、每行或每個文件)屬於且僅屬於一個分片。實現這一點有多種方法,本章將深入討論其中幾種。實際上,每個分片都是自己的小型資料庫,儘管有些資料庫支援同時涉及多個分片的操作。
分片通常與複製結合使用,使得每個分片的副本儲存在多個節點上。這意味著,即使每條記錄只屬於一個分片,它仍然可以儲存在多個不同的節點上以獲得容錯能力。
一個節點可能儲存多個分片。如果使用單主複製模型,則分片和複製的組合可能如 圖 7-1 所示。每個分片的領導者被分配給一個節點,追隨者被分配給其他節點。每個節點可能是某些分片的領導者,同時是其他分片的追隨者,但每個分片仍然只有一個領導者。

我們在 第 6 章 中討論的關於資料庫複製的所有內容,同樣適用於分片的複製。大多數情況下,分片方案與複製方案可以獨立選擇;為簡單起見,本章將忽略複製。
本章所謂的 分片,在不同軟體中有許多不同名稱:Kafka 稱其為 分割槽(partition),CockroachDB 稱為 範圍(range),HBase 和 TiDB 稱為 區域(region),Bigtable 和 YugabyteDB 稱為 表分片(tablet),Cassandra、ScyllaDB 和 Riak 稱為 虛節點(vnode),Couchbase 則稱為 虛桶(vBucket)——這裡只列舉了其中幾種。
一些資料庫把分割槽和分片視為兩個不同概念。例如在 PostgreSQL 中,分割槽是把一張大表拆成儲存在同一臺機器上的多個檔案(這樣做有若干好處,比如可以極快地刪除整個分割槽);分片則是把資料集拆分到多臺機器上 1 2。而在許多其他系統中,分割槽不過是分片的另一個名稱。
分割槽 一詞相當直白,分片 這個叫法卻有些出人意料。一種說法認為,它源於線上角色扮演遊戲《網路創世紀》(Ultima Online):遊戲裡一塊魔法水晶碎裂成許多片,每塊碎片都映照出一份遊戲世界 3。於是,分片 後來指一組並行遊戲伺服器中的一臺,並進一步沿用到了資料庫領域。另一種說法是,shard 原本是 System for Highly Available Replicated Data(高可用複製資料系統)的首字母縮寫;據說這是 20 世紀 80 年代的一種資料庫,但其詳情已湮沒在歷史中。
順便說一句,分割槽與 網路分割槽(network partition,也稱 netsplit)毫無關係;後者是節點間網路發生的一類故障,我們將在 第 9 章 中討論。
分片的利與弊
對資料庫進行分片,主要是為了獲得 可伸縮性(scalability):當資料量或寫入吞吐量大到單個節點無法承受時,分片可以把資料和寫入分散到多個節點上。(如果瓶頸是讀取吞吐量,則未必需要分片,可以採用 第 6 章 介紹的 讀擴充套件,read scaling。)
事實上,分片是實現 水平擴充套件(horizontal scaling,也稱 橫向擴充套件,scale-out 架構)的主要手段之一,正如 “共享記憶體、共享磁碟與無共享架構” 所述:系統不必換用更大的機器,而是透過增加更多(較小的)機器來擴充容量。如果能合理劃分工作負載,讓每個分片承擔大致相等的份額,就可以把這些分片分配給不同機器,並行處理其中的資料和查詢。
複製可以提供容錯和離線執行能力,因而無論規模大小都有用;分片卻是一種重量級方案,主要適用於大規模場景。如果資料量和寫入吞吐量仍可由單臺機器處理(如今單機的能力可不容小覷!),通常最好避免分片,堅持使用單分片資料庫。
之所以這樣建議,是因為分片往往會增加複雜性。通常需要選擇一個 分割槽鍵(partition key),據此決定每條記錄應放入哪個分片;分割槽鍵相同的記錄都會進入同一分片 4。這個選擇十分重要:如果知道記錄在哪個分片,訪問就很快;如果不知道,就只能低效地搜尋所有分片,而且日後很難更改分片方案。
因此,分片通常很適合鍵值資料,因為可以直接按鍵分片;關係資料則比較棘手,因為你可能需要透過二級索引搜尋,或連線散落在不同分片中的記錄。我們將在 “分片與二級索引” 中進一步討論這個問題。
分片還有一個問題:一次寫入可能需要更新多個不同分片中的相關記錄。單節點事務相當普遍(參見 第 8 章),但要保證多個分片之間的一致性,就需要 分散式事務(distributed transaction)。正如 第 8 章 將要說明的,有些資料庫支援分散式事務,但這類事務通常比單節點事務慢得多,可能成為整個系統的瓶頸;還有些系統根本不支援分散式事務。
有些系統甚至會在單臺機器上使用分片,通常是在每個 CPU 核心上執行一個單執行緒程序,以利用 CPU 的並行能力;或者利用 非一致性記憶體訪問(NUMA)架構,因為其中某些記憶體區域離特定 CPU 比其他 CPU 更近 5。例如,Redis、VoltDB 和 FoundationDB 都採用每個核心一個程序的方式,並依靠分片把負載分攤到同一臺機器的各個 CPU 核心上 6。
面向多租戶的分片
軟體即服務(SaaS)產品和雲服務通常採用 多租戶(multitenant)模式,每個租戶對應一個客戶。同一租戶可以有多個使用者賬號,但每個租戶擁有一份自成一體、與其他租戶隔離的資料集。例如在電子郵件營銷服務中,每家註冊企業通常都是一個獨立租戶,因為各家企業的簡報訂閱資訊、投遞資料等彼此無關。
多租戶系統有時透過分片來實現:可以為每個租戶分配一個獨立分片,也可以把多個小租戶歸入一個較大的分片。這些分片可以是物理上相互獨立的資料庫(我們曾在 “嵌入式儲存引擎” 中提到),也可以是一個更大邏輯資料庫中能夠單獨管理的組成部分 7。用分片實現多租戶有以下優點:
- 資源隔離
- 如果某個租戶執行計算開銷很大的操作,只要它與其他租戶位於不同分片,其他租戶的效能就不太容易受到影響。
- 許可權隔離
- 如果訪問控制邏輯存在漏洞,只要各租戶的資料集在物理上彼此隔離,意外讓一個租戶訪問另一租戶資料的可能性就會降低。
- 單元化架構
- 分片不僅可以用在資料儲存層,也可以用來劃分執行應用程式碼的服務。在 單元化架構(cell-based architecture)中,為一組特定租戶服務的應用與儲存會組成一個自包含的 單元(cell),不同單元大體可以彼此獨立地執行。這種方法能夠實現 故障隔離(fault isolation):一個單元裡的故障只影響該單元,不會殃及其他單元中的租戶 8。
- 按租戶備份和恢復
- 分別備份每個租戶的分片,就能從備份中恢復某個租戶的狀態,而不影響其他租戶。租戶意外刪除或覆蓋重要資料時,這一能力很有用 9。
- 法規合規性
- GDPR 等資料隱私法規賦予個人訪問並刪除關於自己的全部儲存資料的權利。如果每個人的資料都存放在獨立分片中,實現這一權利就只需對相應分片執行簡單的資料匯出和刪除操作 10。
- 資料駐留
- 如果資料駐留法規要求某個租戶的資料必須存放在特定司法管轄區,那麼區域感知資料庫可以把該租戶的分片分配到指定區域。
- 逐步推出模式變更
- 模式遷移(前文已在 “文件模型中的模式靈活性” 中討論)可以逐步推出,每次只遷移一個租戶。這樣能在問題波及所有租戶之前將其發現,從而降低風險,不過很難以事務方式完成 11。
使用分片實現多租戶的主要挑戰是:
- 這種做法假定每個租戶的資料量都足夠小,能裝進單個節點。如果某個租戶大到一臺機器容納不下,就還得在租戶內部繼續分片,於是問題又回到了為了可伸縮性而分片 12。
- 如果小租戶很多,為每個租戶單獨建立分片的開銷可能過大。可以把多個小租戶合併到一個較大的分片裡,但隨著租戶成長,又會遇到如何把它從一個分片遷移到另一個分片的問題。
- 如果日後需要支援跨租戶關聯資料的功能,那麼跨多個分片連線資料會使這些功能更難實現。
鍵值資料的分片
假設你有大量資料並且想要分片,如何決定在哪些節點上儲存哪些記錄呢?
分片的目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分擔資料和負載,那麼理論上,10 個節點應該能夠處理單個節點 10 倍的資料量和 10 倍的讀寫吞吐量(暫時忽略複製)。此外,在新增或移除節點時,我們希望能夠 再平衡(rebalance)負載,使它均勻分佈在增加後的 11 個節點上,或移除節點後剩餘的 9 個節點上。
如果分片不公平,某些分片承載的資料或查詢比其他分片更多,我們就稱其為 傾斜(skew)。傾斜會大幅降低分片的效果。在極端情況下,全部負載都可能集中到一個分片上,10 個節點中有 9 個閒置,瓶頸卻卡在唯一繁忙的節點上。負載高得不成比例的分片稱為 熱分片(hot shard)或 熱點(hot spot);如果某個鍵的負載特別高(例如社交網路中的名人賬號),則稱為 熱鍵(hot key)。
因此,我們需要一種演算法,以記錄的分割槽鍵為輸入,指出這條記錄屬於哪個分片。在鍵值儲存中,分割槽鍵通常就是鍵或鍵的第一部分;在關係模型中,它可以是表中的某一列,不一定非得是主鍵。為了緩解熱點,這種演算法還必須便於再平衡。
按鍵的範圍分片
一種分片方法,是為每個分片指定一段連續的分割槽鍵範圍(從某個最小值到某個最大值),就像紙質百科全書的各卷,如 圖 7-2 所示。在這個例子中,詞條標題就是分割槽鍵。如果知道各範圍之間的邊界,就能找到鍵範圍涵蓋該標題的卷,輕鬆確定詞條所在的分片,並從書架上取下正確的書。

各段鍵範圍不一定等寬,因為資料本身很可能分佈不均。例如在 圖 7-2 中,第 1 卷收錄以 A 和 B 開頭的單詞,第 12 卷卻收錄以 T、U、V、W、X、Y 和 Z 開頭的單詞。如果簡單地規定每兩個字母一卷,有些卷就會比其他卷厚得多。為了均勻分佈資料,分片邊界必須根據資料進行調整。
分片邊界既可以由管理員手工選擇,也可以由資料庫自動確定。例如,Vitess(MySQL 的分片層)採用手動的鍵範圍分片;Bigtable、其開源版本 HBase、MongoDB 的範圍分片選項、CockroachDB、RethinkDB 和 FoundationDB 則採用自動方式 6。YugabyteDB 同時支援手動和自動拆分表分片。
每個分片內部都按順序儲存鍵,例如使用 B 樹或 SSTable(參見 第 4 章)。這樣很容易執行範圍掃描,也可以把鍵當作聯合索引,在一次查詢中獲取多條相關記錄(參見 “多維索引與全文索引”)。例如,某個應用程式儲存感測器網路的資料,並以測量時間戳作為鍵;範圍掃描在這裡就非常有用,可以輕鬆取出某個月份的全部讀數。
鍵範圍分片的缺點是,如果大量寫入集中在相鄰的鍵上,很容易形成熱分片。例如,鍵若是時間戳,分片就對應不同時間範圍,比如每個月一個分片。遺憾的是,如果感測器在產生測量值時就立即寫入資料庫,所有寫入都會落到同一個分片(本月的分片)中。結果該分片可能被寫入壓垮,其他分片卻無所事事 13。
為了避免感測器資料庫出現這個問題,鍵的第一部分就不能只用時間戳。例如,可以在每個時間戳前加上感測器 ID,使鍵先按感測器 ID、再按時間戳排序。只要有許多感測器同時工作,寫入負載就會更均勻地分佈到各個分片。代價是,要獲取多個感測器在某段時間內的測量值,現在必須為每個感測器分別執行一次範圍查詢。
再平衡鍵範圍分片資料
首次建立資料庫時,還沒有資料可供劃定鍵範圍。一些資料庫(如 HBase 和 MongoDB)允許在空資料庫上配置一組初始分片,這稱為 預拆分(pre-splitting)。採用這種辦法,必須事先大致瞭解鍵將如何分佈,才能選出合適的鍵範圍邊界 14。
此後,隨著資料量和寫入吞吐量增長,採用鍵範圍分片的系統會把現有分片拆成兩個或更多較小分片,每個新分片都儲存原鍵範圍中的一段連續子範圍;這些較小的分片隨後可以分散到多個節點上。如果大量資料被刪除,幾個相鄰且已經變小的分片也可能需要合併成一個較大的分片。這個過程類似於 B 樹頂層發生的變化(參見 “B 樹”)。
對於自動管理分片邊界的資料庫,分片的拆分通常由以下情況觸發:
- 分片達到配置的大小(例如 HBase 預設為 10 GB);或者
- 在某些系統中,寫入吞吐量持續高於某個閾值。因此,即使一個熱分片儲存的資料不多,也可能被拆分,以便將其寫入負載分佈得更加均勻。
鍵範圍分片的優點是,分片數量能夠隨資料量調整。資料很少時,只需少量分片,開銷也很小;資料量巨大時,每個分片的大小仍會被限制在可配置的上限之內 15。
這種方法的缺點是,拆分分片代價很高:必須把其中的全部資料重寫到新檔案裡,類似於日誌結構儲存引擎的壓實操作。需要拆分的分片往往本就處於高負載,拆分開銷還會雪上加霜,甚至使它徹底過載。
按鍵的雜湊分片
如果希望相鄰(但不同)的分割槽鍵進入同一個分片,鍵範圍分片就很有用,時間戳便是一個例子。如果不關心分割槽鍵是否相鄰(例如多租戶應用中的租戶 ID),常見做法是先計算分割槽鍵的雜湊值,再將它對映到分片。
好的雜湊函式可以把傾斜的資料均勻打散。假設有一個接受字串輸入的 32 位雜湊函式,每輸入一個新字串,它都會返回一個看似隨機、介於 0 和 2³² − 1 之間的數。即使輸入字串非常相似,所得雜湊值也會均勻分佈在這個範圍內(不過相同輸入總會產生相同輸出)。
用於分片的雜湊函式不必具備密碼學強度:例如 MongoDB 使用 MD5,Cassandra 和 ScyllaDB 則使用 Murmur3。許多程式語言都內建了用於雜湊表的簡單雜湊函式,但它們未必適合分片。例如,Java 的 Object.hashCode() 和 Ruby 的 Object#hash 可能會讓同一個鍵在不同程序中得到不同雜湊值,因此不能用於分片 16。
雜湊取模節點數
算出鍵的雜湊值之後,該如何選擇儲存它的分片?你首先想到的也許是讓雜湊值對系統中的節點數 取模(許多程式語言使用 % 運算子)。例如,hash(key) % 10 會返回 0 到 9 之間的數;如果把雜湊值寫成十進位制,hash % 10 就是它的末位數字。假設有 10 個節點,編號為 0 到 9,這似乎是把鍵分配到節點的簡單辦法。
模 N 方法的問題在於,只要節點數 N 發生變化,大多數鍵就必須從一個節點移到另一個節點。圖 7-3 展示了三個節點增加到四個時的情況。再平衡之前,節點 0 儲存雜湊值為 0、3、6、9 等的鍵;加入第四個節點之後,雜湊值為 3 的鍵移到節點 3,雜湊值為 6 的鍵移到節點 2,雜湊值為 9 的鍵移到節點 1,依此類推。

模 N 很容易計算,卻會導致極其低效的再平衡,因為大量記錄在節點之間進行了不必要的遷移。我們需要一種只移動必要資料的辦法。
固定數量的分片
一種簡單而常用的解決方案,是建立遠多於節點數的分片,再給每個節點分配多個分片。例如,一個執行在 10 節點叢集上的資料庫可以從一開始就劃分成 1,000 個分片,每個節點分得 100 個。鍵會存入編號為 hash(key) % 1,000 的分片,而系統另行記錄每個分片存放在哪個節點上。
如果向叢集加入一個節點,系統可以把現有節點上的一部分分片重新分配給新節點,直到分片再次均勻分佈。圖 7-4 展示了這一過程。移除節點時,則反向執行同樣的操作。

在這種模型中,只有完整的分片在節點之間移動,成本低於拆分分片。分片的數量不會改變,鍵所指定的分片也不會改變;唯一改變的是分片所在的節點。這種變更並非即時——在網路上傳輸大量資料需要時間——所以傳輸期間發生的讀寫,仍按原有的分片到節點對映處理。
分片數量通常會選成一個因數很多的數字,使資料集能夠均勻分配到多種不同規模的節點叢集中,例如不必要求節點數是 2 的冪 4。甚至還可以照顧叢集中的硬體差異:給效能更強的節點分配更多分片,讓它們承擔更大比例的負載。
Citus(PostgreSQL 的分片層)、Riak、Elasticsearch 和 Couchbase 等系統都採用這種分片方法。只要首次建立資料庫時能較準確地估計所需分片數,它就很好用:此後可以輕鬆增刪節點,不過節點數不能超過分片數。
如果發現最初配置的分片數不合適——例如系統規模已經大到所需節點數超過分片數——就必須執行代價高昂的重新分片。這個過程要拆開每個分片、寫出新檔案,並佔用大量額外磁碟空間。有些系統不允許在資料庫繼續接受寫入時重新分片,因此很難在不停機的情況下改變分片數量。
如果資料集總量變化很大(例如開始時很小,隨後可能增長許多倍),選擇合適的分片數就很困難。由於每個分片包含總資料量的固定比例,其大小會隨叢集中的資料總量同比增長。分片太大,再平衡和從節點失效中恢復都會十分昂貴;分片太小,又會帶來過多管理開銷。分片大小不大不小、“恰到好處”時效能最佳,但在分片數固定而資料集大小不斷變化時,這一狀態很難維持。
按雜湊範圍分片
如果無法事先預測需要多少分片,最好採用一種能讓分片數量輕鬆適應工作負載的方案。前述鍵範圍分片具備這一性質,但大量寫入集中到相鄰鍵時容易形成熱點。一種解決辦法是將鍵範圍分片與雜湊函式結合,使每個分片包含一段 雜湊值 範圍,而不是一段 鍵 範圍。
圖 7-5 展示了一個 16 位雜湊函式,它會返回 0 到 65,535 = 2¹⁶ − 1 之間的數(實際使用的雜湊通常至少有 32 位)。即使輸入鍵十分相似(例如連續的時間戳),它們的雜湊值也會均勻分佈在這個範圍內。於是,可以為每個分片分配一段雜湊值範圍:例如 0 到 16,383 歸分片 0,16,384 到 32,767 歸分片 1,依此類推。

與鍵範圍分片一樣,雜湊範圍分片也可以在分片過大或負載過重時將其拆分。這個操作依然昂貴,但可以按需執行,因此分片數量會隨資料量調整,而不是預先固定不變。
它相對於鍵範圍分片的缺點,是無法高效地對分割槽鍵執行範圍查詢,因為範圍內的鍵如今散佈在所有分片中。不過,如果鍵由兩列或更多列組成,而分割槽鍵只是其中第一列,仍然可以對第二列及之後的列高效執行範圍查詢:只要範圍查詢中的所有記錄擁有相同分割槽鍵,它們就會落在同一個分片中。
BigQuery、Snowflake 和 Delta Lake 等資料倉儲也支援類似的索引方式,只是術語有所不同。例如在 BigQuery 中,分割槽鍵決定記錄屬於哪個分割槽,而“聚簇列”決定記錄在分割槽內的排序方式。Snowflake 會自動把記錄分配給“微分割槽”,但允許使用者為表定義聚簇鍵。Delta Lake 同時支援手動與自動分配分割槽,也支援聚簇鍵。對資料進行聚簇,不僅能改善範圍掃描的效能,還能提高壓縮率和過濾效率。
YugabyteDB 和 DynamoDB 採用雜湊範圍分片 17,MongoDB 也把它作為一種可選方案。Cassandra 和 ScyllaDB 則採用這種方法的一個變體,如 圖 7-6 所示:它們把雜湊值空間劃分成若干範圍,範圍數與節點數成正比(圖 7-6 中每個節點有 3 個範圍;實際預設值是 Cassandra 每個節點 8 個、ScyllaDB 每個節點 256 個),各範圍之間的邊界隨機選定。這樣有些範圍會比其他範圍大,但每個節點擁有多個範圍之後,這些不均衡往往能相互抵消 15 18。

新增或移除節點時,系統會相應增刪範圍邊界,並拆分或合併分片 19。在 圖 7-6 的例子中,加入節點 3 之後,節點 1 把自己兩個範圍中的一部分交給節點 3,節點 2 也把一個範圍中的一部分交給節點 3。這樣,新節點便能分得大致公平的一份資料,同時避免在節點間傳輸不必要的資料。
一致性雜湊
一致性雜湊(consistent hashing)演算法是一種雜湊函式,它把鍵對映到指定數量的分片,並滿足兩個性質:
- 對映到各個分片的鍵數大致相等;
- 分片數量改變時,儘可能少地在分片之間遷移鍵。
注意,這裡的 一致性 與副本一致性(參見 第 6 章)或 ACID 一致性(參見 第 8 章)毫無關係;它描述的是讓一個鍵儘量留在原分片中的傾向。
Cassandra 和 ScyllaDB 的分片演算法與一致性雜湊的原始定義相似 20,此外還有人提出了多種其他一致性雜湊演算法 21,例如 最高隨機權重(highest random weight,也稱 約會雜湊,rendezvous hashing)22 和 跳躍一致性雜湊(jump consistent hash)23。採用 Cassandra 的演算法時,加入一個節點會把少量現有分片拆成若干子範圍;採用約會雜湊或跳躍一致性雜湊時,新節點得到的則是此前散佈在所有其他節點上的一個個鍵。哪種方式更合適,取決於具體應用。
傾斜的工作負載與緩解熱點
一致性雜湊可以保證鍵大致均勻地分佈到各節點,卻不能保證實際負載也同樣均勻。如果工作負載高度傾斜——也就是某些分割槽鍵下的資料量遠大於其他鍵,或者某些鍵的請求速率遠高於其他鍵——仍然可能有些伺服器不堪重負,另一些伺服器卻幾乎閒置。
例如在社交媒體網站上,一個擁有數百萬粉絲的名人做出某個舉動,可能引發一場活動風暴 24,進而產生針對同一個鍵的大量讀寫(分割槽鍵也許是該名人的使用者 ID,也許是眾人正在評論的事件 ID)。
這種情況需要更加靈活的分片策略 25 26。如果系統按鍵範圍(或雜湊範圍)定義分片,就可以把一個熱鍵單獨放進一個分片,甚至給它分配一臺專用機器 27。
也可以在應用層補償傾斜。例如,如果已知某個鍵非常熱,一種簡單辦法是在鍵的開頭或末尾新增隨機數。只需兩位十進位制隨機數,就能把針對該鍵的寫入均勻拆成 100 個不同的鍵,讓它們分佈到不同分片。
不過,寫入分散到不同鍵之後,讀取就得付出額外代價:必須從全部 100 個鍵讀取資料,再把結果合併起來。熱鍵分散後,每個分片承受的讀取量並沒有減少,降低的只有寫入負載。這種技術還需要額外的記錄工作:只有少數熱鍵值得新增隨機數;對於寫入吞吐量很低的絕大多數鍵,這樣做只會徒增開銷。因此,還需要記錄哪些鍵已被拆分,並設計一個流程,把普通鍵轉換成需要特殊管理的熱鍵。
負載還會隨時間變化,使問題更加複雜。例如,某條突然爆火的社交媒體帖子可能連續幾天承受很高負載,之後又很快歸於平靜。此外,有些鍵是寫入熱點,有些則是讀取熱點,二者需要採用不同的處理策略。
一些系統(尤其是面向大規模場景設計的雲服務)能夠自動處理熱分片;例如,Amazon 把相關機制稱為 熱度管理(heat management)28 或 自適應容量(adaptive capacity)17。這些系統的具體工作方式超出了本書的討論範圍。
運維:自動/手動再平衡
關於再平衡有一個此前略過的重要問題:自動還是手動進行?
有些系統無需人工介入,會自動決定何時拆分分片、何時把分片從一個節點遷移到另一個節點;另一些系統則要求管理員顯式配置分片。兩者之間也有折中方案:例如,Couchbase 和 Riak 會自動生成建議的分片分配,但必須由管理員確認提交後才會生效。
全自動再平衡很方便,因為日常維護所需的運維工作更少;這樣的系統甚至可以自動伸縮,以適應工作負載的變化。DynamoDB 等雲資料庫宣稱,能夠在幾分鐘內自動增刪分片,應對負載的大幅升降 17 29。
然而,自動分片管理也可能難以預測。再平衡代價很高,因為它要重新路由請求,並在節點間遷移大量資料。如果處理不夠謹慎,這一過程可能使網路或節點過載,拖累其他請求的效能。系統在再平衡期間還必須繼續處理寫入;如果已經接近最大寫入吞吐量,分片拆分的速度甚至可能趕不上新寫入到達的速度 29。
這種自動化機制如果再與自動失效檢測結合,可能十分危險。假設某個節點過載,暫時無法及時響應請求;其他節點據此斷定它已經失效,於是自動對叢集進行再平衡,把負載從該節點移走。這會給其他節點和網路施加額外負載,讓局面進一步惡化,甚至引發級聯失效:其他節點也相繼過載,並被錯誤地判定為已經宕機。
出於這個原因,讓人參與再平衡過程是一件好事。這比全自動流程慢,但有助於防止運維意外。
請求路由
我們已經討論了如何把資料集分片到多個節點,以及如何在增刪節點時再平衡這些分片。現在來看下一個問題:如果想讀寫某個特定的鍵,怎樣知道應該連線哪個節點——也就是哪個 IP 地址和埠?
這個問題稱為 請求路由(request routing),與前文 “負載均衡器、服務發現和服務網格” 討論的 服務發現(service discovery)十分相似。二者最大的區別在於:執行應用程式碼的服務例項通常是無狀態的,負載均衡器可以把請求發給任意例項;而在分片資料庫中,某個鍵的請求只能交給持有該鍵所在分片副本的節點處理。
因此,請求路由必須瞭解鍵到分片、以及分片到節點的對映。概括來說,有以下幾種辦法(如 圖 7-7 所示):
- 允許客戶端連線任意節點(例如透過輪詢負載均衡器)。如果該節點恰好持有請求涉及的分片,就直接處理請求;否則,它把請求轉發給正確的節點,收到響應後再轉交給客戶端。
- 客戶端的所有請求都先傳送到一個路由層,由路由層判斷哪個節點應當處理每個請求,再相應地轉發。路由層本身並不處理請求,只充當一個能夠感知分片的負載均衡器。
- 讓客戶端了解分片方式以及分片到節點的分配關係。這樣,客戶端無須經過任何中間層,就能直接連線到正確的節點。

在所有情況下,都有一些關鍵問題:
- 由誰決定每個分片應當放在哪個節點?最簡單的辦法是由單一協調者來決定,但如果執行協調者的節點宕機,怎樣讓協調者具備容錯能力?如果協調者能夠故障切換到另一個節點,又該如何防止發生腦裂(參見 “處理節點故障”),讓兩個協調者作出相互矛盾的分片分配?
- 負責路由的元件(可以是某個資料庫節點、路由層或客戶端)怎樣得知分片到節點的分配發生了變化?
- 分片從一個節點遷移到另一個節點時,會有一段切換期:新節點已經接管,但發往舊節點的請求可能仍在途中。應當如何處理這些請求?
許多分散式資料系統依靠 ZooKeeper、etcd 等獨立協調服務來記錄分片分配,如 圖 7-8 所示。這些服務使用共識演算法(參見 第 10 章)實現容錯並防止腦裂。每個節點都在 ZooKeeper 中註冊,ZooKeeper 維護分片到節點的權威對映;路由層或能夠感知分片的客戶端等其他參與者,可以訂閱 ZooKeeper 中的資訊。只要分片易主,或有節點加入、退出,ZooKeeper 就會通知路由層,使其路由資訊保持最新。

例如,HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配,Kubernetes 使用 etcd 記錄每個服務例項的執行位置。MongoDB 的架構與之相似,不過它依靠自有的 配置伺服器(config server)實現,並以 mongos 守護程序作為路由層。Kafka、YugabyteDB 和 TiDB 則使用內建的 Raft 共識協議實現這項協調功能。
Cassandra、ScyllaDB 和 Riak 採用另一種辦法:節點之間透過 流言協議(gossip protocol)傳播叢集狀態的變化。它提供的一致性比共識協議弱得多,因而可能出現腦裂,使叢集的不同部分對同一個分片持有不同的節點分配。無主資料庫可以容忍這種情況,因為它們本就只提供較弱的一致性保證(參見 “仲裁一致性的侷限”)。
無論使用路由層還是把請求傳送給隨機節點,客戶端仍然要先找到可供連線的 IP 地址。IP 地址的變化沒有分片到節點的分配那麼頻繁,因此通常用 DNS 就足夠了。
以上請求路由主要關注如何為單個鍵找到對應分片,這最適用於分片的 OLTP 資料庫。分析型資料庫通常也會分片,但其查詢執行方式截然不同:查詢一般不是在單個分片中執行,而是要並行聚合並連線來自許多分片的資料。我們將在 “JOIN 與 GROUP BY” 中討論這類並行查詢執行技術。
分片與二級索引
到目前為止討論的分片方案,都要求客戶端知道待訪問記錄的分割槽鍵。這在鍵值資料模型中最容易做到:分割槽鍵是主鍵的第一部分(或整個主鍵),因此可以據此確定分片,並把讀寫請求路由到負責該鍵的節點。
涉及二級索引時,情況會複雜得多(另見 “多列索引與二級索引”)。二級索引通常不能唯一標識一條記錄,而是用來搜尋某個特定值出現在哪裡:例如,查詢使用者 123 的所有操作、所有包含單詞 hogwash 的文章,或所有顏色為 red 的汽車。
鍵值儲存通常沒有二級索引,但它是關聯式資料庫的基礎能力,在文件資料庫中也十分常見,更是 Solr、Elasticsearch 等全文檢索引擎的 立身之本。二級索引的問題在於,它無法乾淨利落地對映到分片。對帶有二級索引的資料庫進行分片,主要有兩種辦法:本地索引和全域性索引。
本地二級索引
假設你正在運營一個二手車交易網站(如 圖 7-9 所示)。每條車輛資訊都有唯一 ID,並以該 ID 作為分割槽鍵進行分片(例如,ID 0 到 499 歸分片 0,ID 500 到 999 歸分片 1,依此類推)。
如果要讓使用者搜尋車輛,並按顏色與品牌篩選,就需要在 color 和 make 上建立二級索引(在文件資料庫中它們是欄位,在關聯式資料庫中則是列)。宣告索引後,資料庫會自動維護它。例如,每增加一輛紅色汽車,所在分片就會自動把它的 ID 加入索引條目 color:red 對應的 ID 列表。正如 第 4 章 所述,這種 ID 列表也稱為 倒排列表(postings list)。

如果資料庫只支援鍵值模型,你也許會想在應用程式碼中建立值到 ID 的對映,自行實現二級索引。如果選擇這條路,務必萬分小心,確保索引與底層資料始終一致。競態條件和間歇性寫入失敗(有些變更儲存成功,另一些卻沒有)很容易讓兩者失去同步——參見 “多物件事務的需求”。
在這種索引方式中,每個分片都完全獨立:各自維護自己的二級索引,只覆蓋本分片中的記錄,而不關心其他分片儲存了什麼資料。每次寫入資料庫——新增、刪除或更新記錄——只需處理包含該記錄的分片。因此,這種二級索引稱為 本地索引(local index);在資訊檢索領域,它也稱為 按文件分割槽的索引(document-partitioned index)30。
讀取本地二級索引時,如果已經知道目標記錄的分割槽鍵,就只需在對應分片上搜尋。如果只想獲得 部分 結果而不要求全部,也可以把請求發給任意分片。
但是,如果需要全部結果,又事先不知道這些記錄的分割槽鍵,就必須把查詢傳送到所有分片,再合併返回結果,因為匹配的記錄可能散佈在每個分片中。在 圖 7-9 中,分片 0 和分片 1 都有紅色汽車。
這種查詢分片資料庫的方式,會讓二級索引上的讀取查詢變得相當昂貴。即使並行查詢所有分片,也很容易出現尾部延遲放大(參見 “響應時間指標的應用”)。它還會限制應用的可伸縮性:增加分片能容納更多資料,但如果每次查詢仍要由所有分片處理,查詢吞吐量並不會隨之提高。
儘管如此,本地二級索引依然應用廣泛 31:MongoDB、Riak、Cassandra 32、Elasticsearch 33、SolrCloud 和 VoltDB 34 都採用這種索引。
全域性二級索引
除了讓每個分片各自維護本地二級索引,也可以構建一個覆蓋所有分片資料的 全域性索引(global index)。不過,不能只把這個索引存放在單個節點上,否則它很可能成為瓶頸,使分片失去意義。因此全域性索引本身也必須分片,但可以採用與主鍵索引不同的分片方式。
圖 7-10 展示了它可能採用的形式:來自所有分片的紅色汽車 ID 都列在索引的 color:red 條目下;索引本身則經過分片,以字母 a 到 r 開頭的顏色歸分片 0,以 s 到 z 開頭的顏色歸分片 1。汽車品牌索引也以類似方式分片,邊界位於 f 與 h 之間。

這種索引也稱為 按詞項分割槽(term-partitioned)30。回顧 “全文檢索”:在全文檢索中,詞項(term)是文字中可供搜尋的關鍵字;這裡我們把它推廣為二級索引中任何可供搜尋的值。
全域性索引以詞項作為分割槽鍵,因此查詢某個詞項或值時,可以直接確定需要查詢哪個分片。和前面一樣,每個分片可以包含一段連續的詞項範圍(如 圖 7-10 所示),也可以根據詞項的雜湊值把詞項分配到各個分片。
全域性索引的優點是,如果查詢只有一個條件(如 color = red),只需讀取一個分片就能取得倒排列表。不過,如果想要的不只是 ID,而是完整記錄,仍需讀取負責儲存這些 ID 的所有分片。
如果查詢包含多個條件或詞項(例如搜尋某種顏色且屬於某個品牌的汽車,或搜尋同一段文字中同時出現的多個單詞),這些詞項很可能分屬不同分片。為了計算兩個條件的邏輯 AND,系統必須找出同時出現在兩個倒排列表中的 ID。倒排列表較短時並不難;但如果列表很長,透過網路傳輸它們再計算交集,速度就可能很慢 30。
全域性二級索引的另一個難題,是寫入比本地索引複雜:寫入一條記錄可能影響索引的多個分片(文件中的每個詞項都可能位於不同分片)。因此,二級索引很難與底層資料保持同步。一種辦法是使用分散式事務,以原子方式更新儲存主記錄的分片及其二級索引分片(參見 第 8 章)。
CockroachDB、TiDB 和 YugabyteDB 都使用全域性二級索引;DynamoDB 則同時支援本地和全域性二級索引。在 DynamoDB 中,寫入會非同步反映到全域性索引,因此從全域性索引讀到的結果可能是陳舊的(類似於 “複製延遲的問題”)。儘管如此,如果讀取吞吐量高於寫入吞吐量,而且倒排列表不太長,全域性索引仍然很有用。
總結
在本章中,我們探討了將大資料集劃分成更小子集的不同方法。資料量非常大的時候,在單臺機器上儲存和處理不再可行,而分片則十分必要。
分片的目標是在多臺機器上均勻分佈資料和查詢負載,避免出現熱點(負載不成比例的節點)。這需要選擇適合資料的分片方案,並在將節點新增到叢集或從叢集刪除時重新平衡分片。
我們討論了兩種主要的分片方法:
鍵範圍分片:鍵按順序排列,每個分片擁有從某個最小值到某個最大值之間的所有鍵。有序儲存的優點是能夠高效執行範圍查詢,但如果應用經常訪問排序位置彼此接近的鍵,就可能形成熱點。
採用這種方法時,分片過大之後通常會把鍵範圍拆成兩個子範圍,從而動態地再平衡分片。
雜湊分片:先對每個鍵應用雜湊函式,每個分片擁有一段雜湊值範圍(也可以採用其他一致性雜湊演算法,把雜湊對映到分片)。這種方法破壞了鍵的順序,使範圍查詢效率降低,卻可能讓負載分佈得更加均勻。
按雜湊分片時,通常會預先建立固定數量的分片,給每個節點分配多個分片;增刪節點時,再把整個分片從一個節點遷移到另一個節點。也可以像鍵範圍分片那樣拆分分片。
常見做法是以鍵的第一部分作為分割槽鍵(即用來確定分片),再按鍵的其餘部分對分片內的記錄排序。這樣,對於分割槽鍵相同的記錄,仍然可以高效執行範圍查詢。
我們還討論了分片與二級索引的相互作用。二級索引也需要分片,有兩種方法:
- 本地二級索引:二級索引與主鍵及其值儲存在同一個分片。因此寫入時只需更新一個分片,但查詢二級索引時必須讀取所有分片。
- 全域性二級索引:根據索引值採用另一套分片方式。二級索引條目可以引用來自主鍵任意分片的記錄。寫入記錄時,可能需要更新多個二級索引分片;但讀取倒排列表時,只需訪問一個分片(獲取實際記錄仍然要讀取多個分片)。
最後,我們討論了如何把查詢路由到正確的分片,以及如何透過協調服務記錄分片到節點的分配關係。
從設計上說,每個分片大體獨立執行——正因如此,分片資料庫才能伸縮到多臺機器。然而,需要寫入多個分片的操作會變得很棘手:例如,一個分片寫入成功,另一個分片卻失敗時會怎樣?我們將在接下來的章節中回答這個問題。
參考文獻
Claire Giordano. Understanding partitioning and sharding in Postgres and Citus. citusdata.com, August 2023. Archived at perma.cc/8BTK-8959 ↩︎
Brandur Leach. Partitioning in Postgres, 2022 edition. brandur.org, October 2022. Archived at perma.cc/Z5LE-6AKX ↩︎
Raph Koster. Database “sharding” came from UO? raphkoster.com, January 2009. Archived at perma.cc/4N9U-5KYF ↩︎
Garrett Fidalgo. Herding elephants: Lessons learned from sharding Postgres at Notion. notion.com, October 2021. Archived at perma.cc/5J5V-W2VX ↩︎ ↩︎
Ulrich Drepper. What Every Programmer Should Know About Memory. akkadia.org, November 2007. Archived at perma.cc/NU6Q-DRXZ ↩︎
Jingyu Zhou, Meng Xu, Alexander Shraer, Bala Namasivayam, Alex Miller, Evan Tschannen, Steve Atherton, Andrew J. Beamon, Rusty Sears, John Leach, Dave Rosenthal, Xin Dong, Will Wilson, Ben Collins, David Scherer, Alec Grieser, Young Liu, Alvin Moore, Bhaskar Muppana, Xiaoge Su, and Vishesh Yadav. FoundationDB: A Distributed Unbundled Transactional Key Value Store. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457559 ↩︎ ↩︎
Marco Slot. Citus 12: Schema-based sharding for PostgreSQL. citusdata.com, July 2023. Archived at perma.cc/R874-EC9W ↩︎
Robisson Oliveira. Reducing the Scope of Impact with Cell-Based Architecture. AWS Well-Architected white paper, Amazon Web Services, September 2023. Archived at perma.cc/4KWW-47NR ↩︎
Gwen Shapira. Things DBs Don’t Do - But Should. thenile.dev, February 2023. Archived at perma.cc/C3J4-JSFW ↩︎
Malte Schwarzkopf, Eddie Kohler, M. Frans Kaashoek, and Robert Morris. Position: GDPR Compliance by Construction. At Towards Polystores that manage multiple Databases, Privacy, Security and/or Policy Issues for Heterogenous Data (Poly), August 2019. doi:10.1007/978-3-030-33752-0_3 ↩︎
Gwen Shapira. Introducing pg_karnak: Transactional schema migration across tenant databases. thenile.dev, November 2024. Archived at perma.cc/R5RD-8HR9 ↩︎
Arka Ganguli, Guido Iaquinti, Maggie Zhou, and Rafael Chacón. Scaling Datastores at Slack with Vitess. slack.engineering, December 2020. Archived at perma.cc/UW8F-ALJK ↩︎
Ikai Lan. App Engine Datastore Tip: Monotonically Increasing Values Are Bad. ikaisays.com, January 2011. Archived at perma.cc/BPX8-RPJB ↩︎
Enis Soztutar. Apache HBase Region Splitting and Merging. cloudera.com, February 2013. Archived at perma.cc/S9HS-2X2C ↩︎
Eric Evans. Rethinking Topology in Cassandra. At Cassandra Summit, June 2013. Archived at perma.cc/2DKM-F438 ↩︎ ↩︎
Martin Kleppmann. Java’s hashCode Is Not Safe for Distributed Systems. martin.kleppmann.com, June 2012. Archived at perma.cc/LK5U-VZSN ↩︎
Mostafa Elhemali, Niall Gallagher, Nicholas Gordon, Joseph Idziorek, Richard Krog, Colin Lazier, Erben Mo, Akhilesh Mritunjai, Somu Perianayagam, Tim Rath, Swami Sivasubramanian, James Christopher Sorenson III, Sroaj Sosothikul, Doug Terry, and Akshat Vig. Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service. At USENIX Annual Technical Conference (ATC), July 2022. ↩︎ ↩︎ ↩︎
Brandon Williams. Virtual Nodes in Cassandra 1.2. datastax.com, December 2012. Archived at perma.cc/N385-EQXV ↩︎
Branimir Lambov. New Token Allocation Algorithm in Cassandra 3.0. datastax.com, January 2016. Archived at perma.cc/2BG7-LDWY ↩︎
David Karger, Eric Lehman, Tom Leighton, Rina Panigrahy, Matthew Levine, and Daniel Lewin. Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web. At 29th Annual ACM Symposium on Theory of Computing (STOC), May 1997. doi:10.1145/258533.258660 ↩︎
Damian Gryski. Consistent Hashing: Algorithmic Tradeoffs. dgryski.medium.com, April 2018. Archived at perma.cc/B2WF-TYQ8 ↩︎
David G. Thaler and Chinya V. Ravishankar. Using name-based mappings to increase hit rates. IEEE/ACM Transactions on Networking, volume 6, issue 1, pages 1–14, February 1998. doi:10.1109/90.663936 ↩︎
John Lamping and Eric Veach. A Fast, Minimal Memory, Consistent Hash Algorithm. arxiv.org, June 2014. ↩︎
Samuel Axon. 3% of Twitter’s Servers Dedicated to Justin Bieber. mashable.com, September 2010. Archived at perma.cc/F35N-CGVX ↩︎
Gerald Guo and Thawan Kooburat. Scaling services with Shard Manager. engineering.fb.com, August 2020. Archived at perma.cc/EFS3-XQYT ↩︎
Sangmin Lee, Zhenhua Guo, Omer Sunercan, Jun Ying, Thawan Kooburat, Suryadeep Biswal, Jun Chen, Kun Huang, Yatpang Cheung, Yiding Zhou, Kaushik Veeraraghavan, Biren Damani, Pol Mauri Ruiz, Vikas Mehta, and Chunqiang Tang. Shard Manager: A Generic Shard Management Framework for Geo-distributed Applications. 28th ACM SIGOPS Symposium on Operating Systems Principles (SOSP), pages 553–569, October 2021. doi:10.1145/3477132.3483546 ↩︎
Scott Lystig Fritchie. A Critique of Resizable Hash Tables: Riak Core & Random Slicing. infoq.com, August 2018. Archived at perma.cc/RPX7-7BLN ↩︎
Andy Warfield. Building and operating a pretty big storage system called S3. allthingsdistributed.com, July 2023. Archived at perma.cc/6S7P-GLM4 ↩︎
Rich Houlihan. DynamoDB adaptive capacity: smooth performance for chaotic workloads (DAT327). At AWS re:Invent, November 2017. ↩︎ ↩︎
Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze. Introduction to Information Retrieval. Cambridge University Press, 2008. ISBN: 978-0-521-86571-5, available online at nlp.stanford.edu/IR-book ↩︎ ↩︎ ↩︎
Michael Busch, Krishna Gade, Brian Larson, Patrick Lok, Samuel Luckenbill, and Jimmy Lin. Earlybird: Real-Time Search at Twitter. At 28th IEEE International Conference on Data Engineering (ICDE), April 2012. doi:10.1109/ICDE.2012.149 ↩︎
Nadav Har’El. Indexing in Cassandra 3. github.com, April 2017. Archived at perma.cc/3ENV-8T9P ↩︎
Zachary Tong. Customizing Your Document Routing. elastic.co, June 2013. Archived at perma.cc/97VM-MREN ↩︎
Andrew Pavlo. H-Store Frequently Asked Questions. hstore.cs.brown.edu, October 2013. Archived at perma.cc/X3ZA-DW6Z ↩︎
8 事務

有些作者聲稱,通用的兩階段提交代價太高,會帶來效能或可用性問題。我們認為,與其讓應用程式設計師始終為缺少事務而另想辦法,不如等事務被過度使用並形成瓶頸時,再由他們處理相應的效能問題。
James Corbett 等人,Spanner:Google 的全球分散式資料庫(2012)
在資料系統的殘酷現實中,許多事情都可能出錯:
- 資料庫軟體或硬體隨時可能發生故障(包括寫操作進行到一半時)。
- 應用程式可能在任意時刻崩潰(包括一系列操作的中間)。
- 網路中斷可能會意外切斷應用程式與資料庫之間,或資料庫節點彼此之間的連線。
- 多個客戶端可能會同時寫入資料庫,覆蓋彼此的更改。
- 客戶端可能讀取到無意義的資料,因為資料只更新了一部分。
- 客戶端之間的競態條件可能導致令人驚訝的錯誤。
要做到可靠,系統就必須處理這些故障,確保它們不會導致整個系統災難性地失效。然而,實現容錯機制的工作量很大:既要審慎考慮所有可能出錯的情況,又要經過大量測試,才能確保解決方案真正有效。
數十年來,事務(transaction)一直是簡化這些問題的首選機制。應用程式透過事務將多個讀寫操作組合成一個邏輯單元。從概念上講,事務中的所有讀寫被當作一次操作執行:整個事務要麼成功(提交,commit),要麼失敗(中止,abort;回滾,rollback)。如果失敗,應用程式可以安全重試。有了事務,應用程式的錯誤處理就簡單多了,因為它不必擔心部分失效——即無論出於何種原因,有些操作成功、有些操作失敗。
如果你與事務打了多年交道,可能會覺得這一切理所當然,但事務並非自然法則。人們創造事務自有其目的:簡化 訪問資料庫的 應用程式設計模型(programming model)。有了事務,應用程式便可以不去考慮某些潛在的錯誤場景和併發問題,因為資料庫會代為處理這些問題(我們稱之為 安全保證,safety guarantee)。
並非所有應用程式都需要事務;有時弱化事務保證,甚至徹底放棄事務也有好處(例如為了提高效能或可用性)。有些安全屬性也可以在沒有事務的情況下實現。另一方面,事務能夠避免許多麻煩:例如,郵局 Horizon 醜聞(參見“可靠性有多重要?”)背後的技術原因,很可能就是底層會計系統缺少 ACID 事務1。
怎樣判斷自己是否需要事務?要回答這個問題,首先必須確切理解事務能提供哪些安全保證,以及需要為此付出什麼代價。事務乍看簡單,其中卻有許多微妙而重要的細節。
本章將考察許多可能出錯的案例,並探討資料庫用來防範這些問題的演算法。我們尤其會深入 併發控制(concurrency control)領域,討論可能出現的各種競態條件,以及資料庫如何實現 讀已提交(read committed)、快照隔離(snapshot isolation)和 可序列化(serializable)等隔離級別。
無論對單節點資料庫還是分散式資料庫,併發控制都很重要。在本章稍後的“分散式事務”一節中,我們將考察 兩階段提交(two-phase commit,2PC)協議,以及在分散式事務中實現原子性所面臨的挑戰。
事務到底是什麼?
如今,幾乎所有關係型資料庫和一部分非關聯式資料庫都支援事務。它們大多沿襲 1975 年由第一個 SQL 資料庫 IBM System R 開創的風格2 3 4。儘管一些實現細節發生了變化,但 50 年來總體思路幾乎原封未動:MySQL、PostgreSQL、Oracle 和 SQL Server 等資料庫的事務支援與 System R 異乎尋常地相似。
2000 年代後期,非關係型(NoSQL)資料庫開始流行。它們試圖打破關係型資料庫的既有格局:不僅提供新的資料模型選擇(參見第 3 章),還預設內建複製(第 6 章)和分片(第 7 章)。事務成了這場運動的主要犧牲品:這一代資料庫中,許多徹底放棄了事務,或者重新定義了“事務”一詞,用它來描述一套遠弱於以往認識的保證。
圍繞 NoSQL 分散式資料庫的炒作催生了一種流行觀點:事務從根本上無法伸縮,任何大規模系統若想保持良好效能和高可用性,都必須放棄事務。近來事實已經證明這種觀點是錯誤的。CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等所謂的“NewSQL”資料庫表明,事務系統同樣可以擴充套件到很大的資料量和很高的吞吐量。這些系統將分片與共識協議(第 10 章)結合起來,從而在大規模場景下提供強 ACID 保證。
不過,這也不意味著每個系統都必須支援事務。和其他技術設計選擇一樣,事務既有優勢,也有侷限。要理解其中的權衡,就必須深入瞭解事務究竟能提供哪些保證——無論是在正常執行時,還是在各種極端但切實可能發生的情況下。
ACID 的含義
事務提供的安全保證,通常用眾所周知的首字母縮略詞 ACID 來描述,分別代表 原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和 永續性(Durability)。Theo Härder 和 Andreas Reuter 於 1983 年提出了這一術語9,試圖為資料庫的容錯機制建立一套精確的表述。
然而在實踐中,不同資料庫對 ACID 的實現並不相同。例如,我們很快就會看到,隔離性 的含義存在許多歧義10。總體思路固然合理,魔鬼卻藏在細節裡。如今,一個系統即便聲稱自己“符合 ACID”,你也很難知道它究竟提供哪些保證。遺憾的是,ACID 基本上已經淪為營銷術語。
(不符合 ACID 標準的系統有時稱為 BASE,即 基本可用(Basically Available)、軟狀態(Soft State)和 最終一致性(Eventual Consistency)11。這個定義比 ACID 還要模糊。BASE 唯一說得通的定義似乎就是“非 ACID”;換句話說,它幾乎可以任人解釋。)
下面深入瞭解原子性、一致性、隔離性和永續性的定義,以便進一步釐清事務的含義。
原子性
一般而言,原子 是指不可再分的事物。這個詞在計算機的不同領域中含義相近,卻又有細微差別。例如在多執行緒程式設計中,如果一個執行緒執行原子操作,另一個執行緒就不可能看到該操作只完成一半的結果。系統只能處於操作之前或之後的狀態,而不會處於兩者之間。
相比之下,ACID 中的原子性與併發 無關。它並不描述多個程序同時訪問相同資料時會發生什麼;那是字母 I 所代表的 隔離性 要處理的問題(參見“隔離性”)。
ACID 的原子性描述的是另一類情況:客戶端想執行多次寫入,卻在其中一些寫入處理完畢後遇到故障——例如程序崩潰、網路連線中斷、磁碟空間耗盡,或者違反了某項完整性約束。如果這些寫入被組合到一個原子事務中,而故障使事務無法完成(提交),那麼事務就會 中止,資料庫必須丟棄或撤銷該事務此前完成的所有寫入。
如果沒有原子性,在多項變更執行到一半時發生錯誤,應用程式很難知道哪些變更已經生效、哪些尚未生效。它可以再試一次,卻可能把同一項變更執行兩遍,造成重複或錯誤的資料。原子性簡化了這個問題:如果事務已經中止,應用程式便能確定它沒有改變任何內容,因此可以安全重試。
遇到錯誤時中止事務,並丟棄該事務的所有寫入,正是 ACID 原子性的定義性特徵。或許稱為 可中止性(abortability)比 原子性 更貼切,但既然“原子性”是慣用說法,本書仍沿用這一術語。
一致性
一致性 這個詞被嚴重濫用:
- 在第 6 章中,我們討論了 副本一致性,以及非同步複製系統中的 最終一致性 問題(參見“複製延遲的問題”)。
- 資料庫的 一致快照(例如用於備份)是整個資料庫在某一時刻的快照。更準確地說,它與先發生關係(happens-before relation)一致(參見“‘先發生’關係與併發”):如果快照包含某個時刻寫入的值,那麼它也會反映發生在這次寫入之前的所有寫入。
- 一致性雜湊 是某些系統在再平衡時採用的一種分片方法(參見“一致性雜湊”)。
- 在 CAP 定理中(參見第 10 章),一致性 一詞指的是 線性一致性(參見“線性一致性”)。
- 在 ACID 的語境下,一致性 指的是由具體應用定義的資料庫“良好狀態”。
遺憾的是,同一個詞竟有至少五種不同的含義。
ACID 一致性的基本思想是:關於資料的某些陳述(即 不變式,invariant)必須始終成立。例如,在會計系統中,所有賬戶的貸記與借記必須始終相抵。如果事務開始時資料庫滿足這些不變式,事務中的所有寫入又能維持其有效性,就可以確信不變式始終成立。(不變式在事務執行期間可以暫時被打破,但到事務提交時必須重新得到滿足。)
如果希望由資料庫強制執行不變式,就需要把它們宣告為模式中的 約束(constraint)。外來鍵約束、唯一性約束或檢查約束(限制單行中可以出現的值)常用來表達特定型別的不變式。更複雜的一致性要求有時也可以用觸發器或物化檢視來表達12。
不過,資料庫通常提供的約束可能很難、甚至根本無法表達複雜的不變式。此時,應用程式就有責任正確定義事務,使其維持一致性。如果應用寫入了違反不變式的錯誤資料,卻沒有事先宣告這些不變式,資料庫也無從阻止。因此,ACID 中的 C 往往取決於應用程式如何使用資料庫,並非資料庫自身的屬性。
隔離性
大多數資料庫都會同時接受多個客戶端的訪問。如果各客戶端讀寫資料庫的不同部分,自然沒有問題;但如果它們訪問相同的資料庫記錄,就可能遇到併發問題(競態條件)。
圖 8-1給出了這類問題的一個簡單例子。假設兩個客戶端同時遞增資料庫中的一個計數器。每個客戶端都要讀取當前值,將其加 1,再把新值寫回去(假設資料庫沒有內建的遞增操作)。在圖 8-1中,計數器經過兩次遞增,本應從 42 變成 44,卻因競態條件最終只變成了 43。

ACID 意義上的 隔離性 是指併發執行的事務彼此隔離,不能相互干擾。經典資料庫教科書將隔離性形式化為 可序列化:每個事務都可以假裝自己是整個資料庫中唯一正在執行的事務。資料庫保證,所有事務提交後的結果與它們 序列 執行(逐個執行)的結果相同,儘管實際上這些事務可能是併發執行的13。
然而,可序列化是有效能代價的。實踐中,許多資料庫採用弱於可序列化的隔離形式,也就是允許併發事務在有限範圍內相互干擾。一些流行資料庫(例如 Oracle)甚至沒有實現可序列化:Oracle 雖有一個名為“可序列化”的隔離級別,實際實現的卻是保證更弱的 快照隔離10 14。因此,某些競態條件仍有可能發生。我們將在“弱隔離級別”中考察快照隔離和其他隔離形式。
永續性
資料庫系統的目的,是提供一個可以放心存放資料而無須擔心丟失的安全場所。永續性 作出這樣的承諾:事務一旦成功提交,其寫入的任何資料都不會遺失,即使發生硬體故障或資料庫崩潰也不例外。
在單節點資料庫中,永續性通常意味著資料已經寫入硬碟或 SSD 等非易失性儲存。普通檔案寫入通常會先在記憶體中緩衝,稍後才送往磁碟,突然斷電時便可能丟失;因此,許多資料庫使用 fsync() 系統呼叫,確保資料確實已經落盤。資料庫通常還有預寫日誌或類似機制(參見“使 B 樹可靠”),以便在寫入中途崩潰後恢復。
在複製資料庫中,永續性可能意味著資料已經成功複製到一定數量的節點。資料庫必須等到這些寫入或複製操作完成,才能報告事務已成功提交。不過,正如“可靠性與容錯”所說,完美的永續性並不存在:如果所有硬碟和備份同時被毀,資料庫顯然也無能為力。
歷史上,永續性意味著寫入歸檔磁帶;後來,它指寫入磁碟或 SSD;近來,這個概念又被擴充套件到複製。哪種實現更好?
事實是,沒有一種方案十全十美:
- 如果資料寫入磁碟後機器宕機,那麼即使資料沒有丟失,在修好機器或把磁碟轉移到另一臺機器之前也無法訪問。複製系統則可以繼續保持可用。
- 關聯故障——例如停電,或某個缺陷使所有節點在遇到特定輸入時崩潰——可能一次擊垮所有副本(參見“可靠性與容錯”),使僅存在記憶體中的資料全部丟失。因此,即使是複製資料庫,寫入磁碟仍然很重要。
- 在非同步複製系統中,領導者不可用時,最近的寫入可能丟失(參見“處理節點故障”)。
- 突然斷電時,SSD 尤其可能違背它本應提供的保證:甚至
fsync也不一定能正常發揮作用15。和其他軟體一樣,磁碟韌體也會有缺陷16 17;例如,某種缺陷會讓硬碟在恰好執行 32,768 小時後失效18。此外,fsync很難正確使用,就連 PostgreSQL 也曾錯誤使用它長達 20 多年19 20 21。 - 儲存引擎與檔案系統實現之間的微妙互動,可能引發難以追查的缺陷,使磁碟檔案在崩潰後損壞22 23。一個副本上的檔案系統錯誤有時還會蔓延到其他副本24。
- 磁碟上的資料可能在無人察覺的情況下逐漸損壞25。如果損壞已經持續了一段時間,副本和近期備份也可能同樣受損。此時只能嘗試從更早的歷史備份中恢復資料。
- 一項 SSD 研究發現,驅動器在投入使用後的頭四年裡,有 30% 到 80% 會出現至少一個壞塊,而且只有一部分壞塊能由韌體糾正26。機械硬碟的壞扇區率較低,但徹底失效的機率高於 SSD。
- 磨損嚴重(經歷了大量寫入/擦除週期)的 SSD 斷電後,可能在數週至數月內開始丟失資料,具體時間取決於溫度27。磨損程度較低的驅動器則沒有這麼嚴重28。
實踐中,沒有任何一種技術能提供絕對保證,只有寫入磁碟、複製到遠端機器和備份等降低風險的手段——它們可以、也應該組合使用。一如既往,面對任何理論上的“保證”,都應保持適度懷疑。
單物件與多物件操作
回顧一下,在 ACID 中,原子性和隔離性描述了客戶端在同一事務中進行多次寫入時,資料庫應當如何處理:
- 原子性
- 如果一系列寫入執行到一半時發生錯誤,事務就應中止,之前完成的寫入也應丟棄。換句話說,資料庫以“全有或全無”的保證,免去你對部分失效的擔憂。
- 隔離性
- 併發執行的事務不應相互干擾。例如,一個事務進行了多次寫入,另一個事務就應當要麼看到全部寫入,要麼一項也看不到,而不能只看到其中一部分。
這些定義假設你想同時修改多個物件(行、文件或記錄)。當多項資料需要保持同步時,通常就要用到這種 多物件事務。圖 8-2展示了一個電子郵件應用的例子。要顯示使用者的未讀郵件數,可以執行如下查詢:

不過,郵件數量很多時,這條查詢可能太慢,於是你決定把未讀郵件數另存到一個單獨的欄位中(這是一種反正規化,參見“正規化、反正規化與連線”)。這樣,每當新郵件到達時,就必須遞增未讀計數器;每當郵件被標為已讀時,也必須遞減該計數器。
在圖 8-2中,使用者 2 遇到了異常:郵箱列表中已經出現一封未讀郵件,未讀計數卻仍為零,因為計數器尚未遞增。(如果電子郵件應用中的錯誤計數無關緊要,不妨把未讀計數換成客戶賬戶餘額,把郵件換成支付事務。)隔離性可以防止這個問題:它保證使用者 2 要麼同時看到新插入的郵件和更新後的計數,要麼兩者都看不到,而不會看到不一致的中間狀態。
圖 8-3說明了為何需要原子性:如果事務進行到一半時發生錯誤,郵箱內容和未讀計數可能失去同步。在原子事務中,如果計數器更新失敗,事務就會中止,已經插入的郵件也會回滾。

多物件事務需要某種方式來確定哪些讀寫操作屬於同一個事務。在關係型資料庫中,這通常以客戶端到資料庫伺服器的 TCP 連線為依據:同一條連線上,BEGIN TRANSACTION 與 COMMIT 語句之間的所有操作都屬於同一個事務。如果 TCP 連線中斷,事務就必須中止。
另一方面,許多非關係型資料庫並沒有這種組合操作的機制。即便提供了多物件 API(例如鍵值儲存可能提供一次更新多個鍵的 multi-put 操作),也不一定具備事務語義:命令可能只更新了其中一些鍵,另一些卻失敗了,使資料庫停留在部分更新的狀態。
單物件寫入
原子性和隔離性也適用於單個物件的變更。例如,假設你正在向資料庫寫入一份 20 KB 的 JSON 文件:
- 如果前 10 KB 傳送完畢後網路連線中斷,資料庫會不會存下一段無法解析的 10 KB JSON 殘片?
- 如果資料庫覆蓋磁碟上的舊值時突然斷電,最後會不會把新舊值拼接在一起?
- 如果另一個客戶端在寫入過程中讀取該文件,會不會看到只更新了一部分的值?
這些問題會讓人無所適從。因此,幾乎所有儲存引擎都力求在單個節點的單個物件(例如鍵值對)層面提供原子性和隔離性。原子性可以透過日誌和崩潰恢復來實現(參見“使 B 樹可靠”),隔離性則可以透過為每個物件加鎖來實現(同一時刻只允許一個執行緒訪問物件)。
有些資料庫還提供更複雜的原子操作,例如遞增操作,從而不必執行圖 8-1中的讀取—修改—寫入迴圈。同樣常見的還有 條件寫入:只有當該值未被其他人併發修改時,寫入才會發生(參見“條件寫入(比較並設定)”)。它類似於共享記憶體併發中的比較並設定或比較並交換(CAS)操作。
嚴格來說,原子遞增(atomic increment)中的“原子”採用的是多執行緒程式設計中的含義。在 ACID 的語境下,它其實應稱為 隔離遞增(isolated increment)或 可序列化遞增(serializable increment),但通常並不這樣叫。
這些單物件操作很有用,因為它們能避免多個客戶端同時寫入同一物件時發生丟失更新(參見“防止丟失更新”)。但它們並不是通常意義上的事務。例如,Cassandra 和 ScyllaDB 的“輕量級事務”功能,以及 Aerospike 的“強一致性”模式,都能在單個物件上提供線性一致的讀取和條件寫入(參見“線性一致性”),卻不為多個物件之間的操作提供保證。
多物件事務的需求
我們真的需要多物件事務嗎?能否只靠鍵值資料模型和單物件操作來實現任何應用程式?
有些用例只需插入、更新或刪除單個物件就足夠了。但在許多其他場景中,必須協調對多個不同物件的寫入:
- 在關係資料模型中,一個表中的行經常透過外來鍵引用另一個表中的行。與之類似,在圖資料模型中,一個頂點會透過邊指向其他頂點。多物件事務可以確保這些引用始終有效:插入若干相互引用的記錄時,外來鍵必須正確且保持最新,否則資料便失去意義。
- 在文件資料模型中,需要一同更新的欄位通常位於同一份文件內,而文件被視為單個物件,因此更新一份文件無須多物件事務。不過,缺乏連線功能的文件資料庫也鼓勵反正規化(參見“何時使用哪種模型”)。當反正規化的資訊需要更新時——例如圖 8-2中的情形——就必須一次更新多份文件。事務在這裡很有用,可以防止反正規化資料彼此失去同步。
- 在具有二級索引的資料庫中(純鍵值儲存以外的資料庫幾乎都屬於此列),每次修改值時也要更新索引。從事務的角度看,這些索引是不同的資料庫物件。例如,沒有事務隔離時,一條記錄可能出現在某個索引中,卻沒有出現在另一個索引中,因為第二個索引尚未更新(參見“分片與二級索引”)。
這類應用即使沒有事務也能實現。不過,沒有原子性,錯誤處理就會複雜得多;缺少隔離性,又可能引發併發問題。我們將在“弱隔離級別”中討論這些問題,並在“衍生資料與分散式事務”中探討替代方案。
處理錯誤和中止
事務的一個關鍵特性是:遇到錯誤時,可以中止並安全重試。ACID 資料庫遵循這樣的原則:只要原子性、隔離性或永續性保證有遭到破壞的風險,資料庫寧可徹底放棄事務,也不會讓它停留在半完成狀態。
不過,並非所有系統都遵循這一原則。尤其是採用無主複製的資料儲存(參見“無主複製”),更多是以“盡力而為”的方式工作。概括來說就是:“資料庫能做多少便做多少;如果遇到錯誤,也不會撤銷已經完成的操作。”因此,從錯誤中恢復成了應用程式的責任。
錯誤不可避免,許多軟體開發者卻寧願只考慮一切順利的正常路徑,不願面對錯誤處理的複雜細節。例如,Rails 的 ActiveRecord 和 Django 等流行的物件關係對映(ORM)框架都不會重試已中止的事務——錯誤通常會以異常的形式沿呼叫棧向上傳播,於是使用者輸入被丟棄,只收到一條錯誤訊息。這很可惜,因為中止的意義恰恰在於允許安全重試。
儘管重試已中止的事務是一種簡單有效的錯誤處理機制,卻並非完美無缺:
- 事務可能實際上已經成功,只是在伺服器向客戶端確認提交成功時網路中斷,導致客戶端等待超時。此時重試會讓事務執行兩遍,除非應用層另有去重機制。
- 如果錯誤源於過載或併發事務之間的嚴重爭用,重試只會雪上加霜。要避免這種反饋迴圈,可以限制重試次數、採用指數退避,並將過載相關錯誤與其他錯誤區別處理(參見“當過載系統無法恢復時”)。
- 只有瞬態錯誤(例如死鎖、違反隔離、臨時網路中斷或故障切換)才值得重試;遇到永久性錯誤(例如違反約束)時,重試毫無意義。
- 如果事務還會在資料庫之外產生副作用,那麼即使事務中止,副作用也可能已經發生。例如,事務每重試一次就再發一封郵件,顯然不是你想要的結果。如果要確保幾個不同的系統共同提交或共同中止,兩階段提交可以提供幫助(參見“兩階段提交(2PC)”)。
- 如果客戶端程序在重試期間崩潰,它本想寫入資料庫的資料便會丟失。
弱隔離級別
如果兩個事務不訪問相同的資料,或者兩者都是隻讀事務,它們就可以安全地並行執行,因為彼此並無依賴。只有當一個事務讀取的資料正被另一個事務併發修改,或者兩個事務試圖同時修改相同資料時,才會出現併發問題(競態條件)。
併發缺陷很難透過測試發現,因為它們只有在時序碰巧不利時才會觸發。這種時序問題可能極少發生,通常也難以重現。併發行為本身同樣難以推理,尤其是在大型應用中,你未必知道還有哪些程式碼正在訪問資料庫。即使每次只有一個使用者,應用開發也已經不容易;面對許多併發使用者則更為困難,因為任何資料都可能隨時發生意料之外的變化。
正因如此,資料庫長期以來一直試圖透過 事務隔離(transaction isolation)嚮應用開發者隱藏併發問題。理論上,隔離讓你可以假裝併發根本不存在:可序列化 隔離意味著,資料庫保證事務產生的效果與 序列 執行(即逐個執行、毫無併發)相同。
遺憾的是,實踐中的隔離並沒有這麼簡單。可序列化隔離有效能代價,許多資料庫不願為此買單10。因此,系統普遍採用較弱的隔離級別,只防範 一部分 而非全部併發問題。這些隔離級別更難理解,也可能引發微妙的錯誤,卻仍在實踐中廣泛使用29。
弱事務隔離導致的併發缺陷絕非紙上談兵:它們曾造成鉅額資金損失30 31 32,招致財務審計人員介入調查33,也曾破壞客戶資料34。這類問題曝光後,人們常會說:“處理財務資料就該使用 ACID 資料庫!”但這沒有抓住要害。即使是許多通常被視為“ACID”的流行關係型資料庫,也採用弱隔離,因而未必能防止這些問題。
順帶一提,銀行體系很大一部分依賴透過安全 FTP 交換的文字檔案35。在這種環境中,審計跟蹤和某些由人參與的反欺詐措施,其實比 ACID 屬性更重要。
這些例子還凸顯了一個要點:即使併發問題在正常執行中很少發生,也必須考慮攻擊者向 API 刻意傳送一波高度併發的請求,專門利用併發缺陷的可能性30。因此,要構建可靠且安全的應用,就必須系統性地防範這類缺陷。
本節將考察實踐中使用的幾種弱隔離級別(即非可序列化隔離),詳細討論哪些競態條件可能發生、哪些不會發生,以便你判斷哪一種適合自己的應用。隨後,我們再深入討論可序列化(參見“可序列化”)。這裡將透過例子作非形式化的說明;嚴格定義及其性質分析可在學術文獻中找到36 37 38 39。
讀已提交
最基本的事務隔離級別是 讀已提交(read committed),它提供兩項保證:
- 從資料庫讀取時,只能看到已經提交的資料(沒有 髒讀,dirty read)。
- 向資料庫寫入時,只能覆蓋已經提交的資料(沒有 髒寫,dirty write)。
有些資料庫還支援更弱的 讀未提交 隔離級別。它能防止髒寫,卻不能防止髒讀。下面詳細討論這兩項保證。
沒有髒讀
設想一個事務已經向資料庫寫入資料,卻尚未提交或中止。另一個事務能否看到這些未提交的資料?如果能,這就是 髒讀3。
執行在讀已提交隔離級別下的事務必須防止髒讀。這意味著,一個事務的所有寫入都要等到該事務提交時,才會同時對其他事務可見。圖 8-4中,使用者 1 已將 x 設為 3,但由於其事務尚未提交,使用者 2 的 get x 仍返回舊值 2。

防止髒讀有以下幾個好處:
- 如果事務要更新多行,髒讀會讓另一個事務只看到其中一部分更新。例如在圖 8-2中,使用者看到了新到的未讀郵件,卻沒有看到更新後的計數器。這就是對郵件的髒讀。資料庫呈現出部分更新的狀態,不僅令人困惑,還可能誘使其他事務作出錯誤決定。
- 如果事務中止,其所有寫入都需要回滾(如圖 8-3)。允許髒讀,就意味著事務可能讀到後來被回滾、從未真正提交進資料庫的資料。凡是讀取過未提交資料的事務也必須一併中止,從而造成所謂的 級聯中止。
沒有髒寫
如果兩個事務併發更新資料庫中的同一行,會發生什麼?寫入次序雖然無法預知,但我們通常認為後一次寫入會覆蓋前一次寫入。
可是,如果前一次寫入屬於尚未提交的事務,後一次寫入覆蓋了這個未提交值,又會怎樣?這就是 髒寫36。執行在讀已提交隔離級別下的事務必須防止髒寫;通常的做法是推遲第二次寫入,直到執行第一次寫入的事務提交或中止。
防止髒寫可以避免某些併發問題:
- 如果事務更新多行,髒寫可能產生非常糟糕的結果。圖 8-5展示了一家二手車交易網站:Aaliyah 和 Bryce 同時購買同一輛車。買車涉及兩次資料庫寫入:網站上的商品資訊要更新為買家的姓名,銷售發票也要開給買家。在圖 8-5所示的情況下,車輛最終賣給了 Bryce(他對
listings表的更新勝出),發票卻開給了 Aaliyah(她對invoices表的更新勝出)。讀已提交可以防止這種事故。 - 不過,讀已提交 不能 防止圖 8-1中兩個計數器遞增操作之間的競態條件。這裡的第二次寫入發生在第一個事務提交之後,所以並非髒寫。結果仍然有誤,只是原因不同;“防止丟失更新”將介紹如何安全地執行這類計數器遞增操作。

實現讀已提交
讀已提交是一種非常流行的隔離級別,也是 Oracle Database、PostgreSQL、SQL Server 等許多資料庫的預設設定10。
資料庫防止髒寫最常見的辦法是使用行級鎖:事務想修改某一行(或文件等其他物件)時,必須先取得該行的鎖,並一直持有到事務提交或中止。任何一行在同一時刻只能由一個事務持鎖;另一個事務若想寫入同一行,就必須等前一個事務提交或中止後,才能取得鎖並繼續執行。在讀已提交模式(或更強的隔離級別)下,資料庫會自動完成這些加鎖操作。
那麼怎樣防止髒讀?一種辦法仍是使用同一把鎖:要求任何想讀取某行的事務都短暫取得該鎖,讀完後立即釋放。這樣,當該行包含未提交的髒值時就無法讀取,因為執行寫入的事務仍持有這把鎖。
然而,要求讀操作加鎖在實踐中效果不佳。一個長期執行的寫事務,可能迫使許多隻讀事務一直等到它結束。這不僅損害只讀事務的響應時間,也不利於可運維性:應用某個部分一旦變慢,等待鎖便可能引發連鎖反應,波及完全不相干的其他部分。
儘管如此,仍有一些資料庫使用鎖來防止髒讀,例如 IBM Db2,以及將 read_committed_snapshot=off 的 Microsoft SQL Server29。
更常見的防髒讀辦法如圖 8-4所示:對於每一行寫入,資料庫同時保留舊的已提交值,以及當前持有寫鎖的事務寫入的新值。該事務進行期間,其他事務讀取這一行時只會得到舊值;等新值提交後,才改為讀取新值(詳見“多版本併發控制(MVCC)”)。
快照隔離與可重複讀
乍看之下,人們很容易以為讀已提交已經具備了事務所需的一切:它允許中止(原子性所必需),避免讀取事務未完成的結果,也防止併發寫入相互混雜。的確,這些功能很有用,提供的保證也比完全沒有事務的系統強得多。
然而,在這個隔離級別下,併發缺陷仍可能以許多方式出現。圖 8-6就展示了讀已提交可能遇到的一個問題。

假設 Aaliyah 在銀行有 1,000 美元存款,分別存在兩個賬戶中,每個賬戶 500 美元。現在有一筆事務從其中一個賬戶向另一個賬戶轉賬 100 美元。如果她偏偏在轉賬事務處理的同時檢視賬戶餘額,可能會先看到一個賬戶尚未收到轉入款項時的餘額(500 美元),再看到另一個賬戶已經轉出款項後的餘額(400 美元)。在 Aaliyah 看來,兩個賬戶總共只剩 900 美元——彷彿有 100 美元憑空消失了。
這種異常稱為 讀偏差(read skew),是 不可重複讀(nonrepeatable read)的一種:如果 Aaliyah 在轉賬事務結束後再次讀取賬戶 1 的餘額,會得到 600 美元,與上一次查詢看到的值不同。讀已提交隔離允許讀偏差,因為 Aaliyah 讀到各賬戶餘額的那一刻,它們確實都已經提交。
遺憾的是,偏差(skew)一詞有多重含義:前文曾用它指 存在熱點的不均衡工作負載(參見“傾斜的工作負載與緩解熱點”),這裡指的則是 時序異常。
對 Aaliyah 來說,這個問題不會持續太久:幾秒鐘後重新整理網上銀行頁面,她多半就能看到一致的賬戶餘額。但有些場景無法容忍哪怕暫時的不一致:
- 備份
- 備份需要複製整個資料庫,大型資料庫可能要花上幾個小時。備份期間,資料庫仍在不斷接受寫入。結果,備份的一部分可能包含較舊版本的資料,另一部分卻包含較新版本。一旦從這樣的備份恢復,原本暫時的不一致(例如憑空消失的錢)就會固化下來。
- 分析查詢和完整性檢查
- 有時需要執行掃描資料庫大部分內容的查詢。這類查詢在分析場景中很常見(參見“分析型與事務型系統”),也可能用於定期執行完整性檢查,確認一切正常(即監測資料損壞)。如果查詢在不同時間點觀察資料庫的不同部分,返回的結果很可能毫無意義。
快照隔離36是解決這個問題最常用的辦法。其核心思想是,每個事務都從資料庫的 一致快照 讀取資料——也就是說,事務只看到它開始時資料庫中已經提交的全部資料。即使其他事務後來修改了資料,它看到的仍是那個特定時刻的舊資料。
快照隔離對於備份、分析等長期執行的只讀查詢是一大福音。如果查詢所處理的資料在執行期間不斷變化,查詢結果就很難解釋;如果事務看到的是凍結在某個時刻的資料庫一致快照,理解起來就容易多了。
快照隔離是一項常見功能:PostgreSQL、採用 InnoDB 儲存引擎的 MySQL、Oracle、SQL Server 等資料庫都支援它的某種變體,儘管各系統的具體行為有所不同29 40 41。Oracle、TiDB 和 Aurora DSQL 等資料庫甚至把快照隔離作為自身最高的隔離級別。
多版本併發控制(MVCC)
和讀已提交一樣,快照隔離通常用寫鎖來防止髒寫(參見“實現讀已提交”)。因此,一個寫事務可以阻塞另一個寫入同一行的事務。不過,讀取無須取得任何鎖。從效能角度看,快照隔離的一項關鍵原則是:讀不阻塞寫,寫也不阻塞讀。這樣,資料庫可以一邊在一致快照上執行長期讀查詢,一邊正常處理寫入,二者不會爭用鎖。
為實現快照隔離,資料庫把圖 8-4中的防髒讀機制推廣開來。資料庫不再只為每行保留兩個版本(已提交版本,以及覆蓋它但尚未提交的新版本),而是可能需要保留多個不同的已提交版本,因為正在執行的不同事務可能要觀察資料庫在不同時刻的狀態。由於同一行的多個版本並存,這項技術稱為 多版本併發控制(multi-version concurrency control,MVCC)。
圖 8-7展示了 PostgreSQL 如何基於 MVCC 實現快照隔離40 42 43(其他實現與之類似)。事務啟動時會獲得一個唯一且單調遞增的事務 ID(txid)。事務寫入資料庫的任何資料,都會以寫入者的事務 ID 標記。(嚴格來說,PostgreSQL 的事務 ID 是 32 位整數,大約經過 40 億個事務就會溢位;vacuum 程序負責清理,確保溢位不會影響資料。)

表中每一行都有一個 inserted_by 欄位,儲存將該行插入表中的事務 ID;還有一個初始為空的 deleted_by 欄位。事務刪除某行時,並不會立即將其從資料庫中移除,而只是把 deleted_by 設為請求刪除的事務 ID,以此標記刪除。等到確定再也沒有事務能訪問這些已刪除資料時,資料庫的垃圾收集程序才會移除帶刪除標記的行並釋放空間。
更新在內部會轉換成一次刪除和一次插入44。例如,在圖 8-7中,事務 13 從賬戶 2 扣除 100 美元,把餘額從 500 美元改為 400 美元。此時 accounts 表實際上包含賬戶 2 的兩行:餘額 500 美元的行由事務 13 標記為刪除,餘額 400 美元的行則由事務 13 插入。
一行的所有版本都存放在同一個資料庫堆中(參見“在索引中儲存值”),不論寫入它們的事務是否已經提交。同一行的各個版本組成連結串列,可以從最新版本連到最舊版本,也可以反向連線,使查詢能在內部遍歷該行的所有版本45 46。
觀察一致快照的可見性規則
事務讀取資料庫時,系統根據事務 ID 判斷哪些行版本可見、哪些不可見。只要審慎定義可見性規則,資料庫就能嚮應用程式呈現一致快照。其工作方式大致如下43:
- 每個事務開始時,資料庫列出當時仍在進行(尚未提交或中止)的所有其他事務。即使這些事務後來提交,其寫入也一律忽略。這樣便能看到不受其他事務隨後提交影響的一致快照。
- 事務 ID 較晚的事務(即在當前事務開始之後啟動,因而不在上述進行中事務列表裡)所做的寫入一律忽略,無論它們是否已經提交。
- 已中止事務的寫入一律忽略,無論中止發生在何時。這樣一來,事務中止後無須立刻從儲存中刪除它寫入的行,因為可見性規則會將它們過濾掉,垃圾收集程序可以稍後再清理。
- 其餘寫入均對應用程式的查詢可見。
這些規則既適用於插入行,也適用於刪除行。在圖 8-7中,事務 12 讀取賬戶 2 時看到的餘額是 500 美元:刪除 500 美元餘額的是事務 13,而根據規則 2,事務 12 看不到事務 13 執行的刪除;同理,插入的 400 美元餘額也尚不可見。
換句話說,只有同時滿足以下兩個條件,一行才可見:
- 讀事務開始時,插入該行的事務已經提交。
- 該行沒有標記為刪除;或者雖已標記為刪除,但請求刪除的事務在讀事務開始時尚未提交。
長期執行的事務可能一直使用同一個快照,繼續讀取在其他事務看來早已被覆蓋或刪除的值。資料庫從不原地更新值,而是在每次變更時插入新版本,因而只需很小的開銷就能提供一致快照。
索引與快照隔離
多版本資料庫中的索引如何工作?最常見的做法是,每個索引項指向與其匹配的某個行版本(最舊或最新版本)。每個行版本可以引用下一個更舊或更新的版本。使用索引的查詢必須沿行版本遍歷,找到既對當前事務可見、值又符合查詢條件的那一行。垃圾收集移除不再對任何事務可見的舊行版本時,也可以一併刪除相應的索引項。
許多實現細節都會影響多版本併發控制的效能45 46。例如,如果同一行的不同版本可以容納在同一個頁面中,PostgreSQL 會透過最佳化避免更新索引40。另一些資料庫則不儲存修改後行的完整副本,只儲存版本間的差異以節省空間。
CouchDB、Datomic 和 LMDB 採用另一種方法。它們同樣使用 B 樹(參見“B 樹”),但採用 不可變(寫時複製)的變體:更新時不覆蓋樹頁面,而是為每個被修改的頁面建立新副本。從其父頁面一直到樹根,也都會複製並更新,以指向新版子頁面。未受寫入影響的頁面無須複製,可由新樹繼續共享47。
採用不可變 B 樹時,每個寫事務(或一批事務)都會建立一個新的 B 樹根;某個特定的樹根,就是它建立時資料庫的一致快照。由於後續寫入無法修改現有 B 樹,只能建立新的樹根,因此無須根據事務 ID 過濾行。這種方法同樣需要後臺程序執行壓縮和垃圾收集。
快照隔離、可重複讀和命名混淆
MVCC 是資料庫常用的實現技術,也經常用來實現快照隔離。不過,不同資料庫有時會用不同術語指代同一件事:例如,快照隔離在 PostgreSQL 中稱為“可重複讀(repeatable read)”,在 Oracle 中稱為“可序列化”29。反過來,不同系統也會用同一個術語表示不同事物:PostgreSQL 的“可重複讀”指快照隔離,MySQL 的“可重複讀”卻是一個一致性弱於快照隔離的 MVCC 實現41。
之所以會出現這種命名混亂,是因為 SQL 標準沒有快照隔離的概念。該標準建立在 System R 於 1975 年定義的隔離級別之上3,而那時快照隔離尚未問世。標準定義的是表面上與快照隔離相似的“可重複讀”。PostgreSQL 的快照隔離符合這項標準要求,於是將它稱為“可重複讀”,從而可以宣稱符合標準。
遺憾的是,SQL 標準對隔離級別的定義存在缺陷:含糊、不精確,也沒有做到標準理應具備的實現無關性36。好幾種資料庫都聲稱實現了可重複讀,實際提供的保證卻相去甚遠,儘管它名義上已經標準化29。研究文獻給出了可重複讀的正式定義37 38,但大多數實現都不符合這個定義。更添混亂的是,IBM Db2 還用“可重複讀”來指可序列化10。
結果,誰也說不清“可重複讀”究竟是什麼意思。
防止丟失更新
到目前為止,讀已提交和快照隔離主要保證的是:存在併發寫入時,只讀事務能夠看到什麼。我們基本沒有討論兩個事務併發寫入的問題,只講過一種特定的寫—寫衝突——髒寫(參見“沒有髒寫”)。
併發寫事務之間還可能出現其他幾類值得關注的衝突,其中最著名的便是 丟失更新(lost update)。圖 8-1以兩個併發遞增計數器的事務為例,展示了這個問題。
應用程式從資料庫讀取一個值,修改後再寫回去,這個過程稱為 讀取—修改—寫入迴圈(read-modify-write cycle),可能發生丟失更新。如果兩個事務併發執行這樣的迴圈,其中一項修改可能丟失,因為後一次寫入沒有包含前一個事務的修改。(有時也說後一次寫入 抹掉 了前一次寫入。)這種模式會出現在許多場景中:
- 遞增計數器或更新賬戶餘額(讀取當前值、計算新值,再把新值寫回);
- 區域性修改複雜值,例如向 JSON 文件內的列表新增元素(解析文件、作出修改,再把修改後的文件寫回);
- 兩個使用者同時編輯一個 wiki 頁面,各自把整頁內容傳送給伺服器來儲存修改,從而覆蓋資料庫中的現有內容。
這個問題非常普遍,因此人們提出了多種解決方案48。
原子寫操作
許多資料庫提供 原子寫操作(atomic write operation),使應用程式不必自行實現讀取—修改—寫入迴圈。如果業務邏輯可以用這些操作表達,它們通常是最佳方案。例如,下面這條語句在大多數關係型資料庫中都能安全地併發執行:
類似地,MongoDB 等文件資料庫提供原子操作,供應用區域性修改 JSON 文件;Redis 則提供修改優先佇列等資料結構的原子操作。並非所有寫入都容易表示成原子操作——例如,更新 wiki 頁面涉及任意文字編輯,可以用“CRDT 與操作變換”介紹的演算法處理——但只要能夠使用,原子操作通常就是最佳選擇。
原子操作通常這樣實現:讀取物件時取得 排他鎖(exclusive lock),使其他事務在更新完成之前無法讀取該物件。另一種辦法則是強制所有原子操作都在單個執行緒上執行。
遺憾的是,物件關係對映(ORM)框架很容易讓人無意中寫出不安全的讀取—修改—寫入迴圈,而沒有使用資料庫提供的原子操作49 50 51。由此產生的缺陷往往十分隱蔽,很難透過測試發現。
顯式鎖定
如果資料庫內建的原子操作無法滿足需求,還可以由應用程式 顯式鎖定(explicit locking)即將更新的物件,再執行讀取—修改—寫入迴圈。其他事務若試圖併發更新或鎖定同一物件,就必須等前一個迴圈完成。
以多人遊戲為例,幾個玩家都可以移動同一個棋子。這時原子操作可能還不夠,因為應用還要確保走法符合遊戲規則,而其中一些邏輯很難合理地寫成資料庫查詢。此時可以加鎖,防止兩個玩家同時移動同一個棋子,如示例 8-1所示。
❶
FOR UPDATE子句表示資料庫應當鎖定這條查詢返回的所有行。
這個辦法行得通,但必須仔細推敲應用邏輯才能用對。程式碼中很容易漏掉某處必要的加鎖,進而引入競態條件。
此外,鎖定多個物件會帶來死鎖風險:兩個或更多事務彼此等待對方釋放鎖。許多資料庫會自動檢測死鎖,並中止其中一個事務,讓系統得以繼續推進。應用程式只需重試這個被中止的事務即可。
自動檢測丟失更新
原子操作和鎖透過強制讀取—修改—寫入迴圈依次執行來防止丟失更新。另一種思路是允許這些迴圈並行執行;事務管理器一旦檢測到丟失更新,便中止相應事務,迫使它重試整個迴圈。
這種方法的一項優勢是,資料庫可以結合快照隔離高效完成檢查。事實上,PostgreSQL 的可重複讀、Oracle 的可序列化和 SQL Server 的快照隔離,都會自動檢測丟失更新並中止發生衝突的事務。MySQL/InnoDB 的可重複讀則不會檢測丟失更新29 41。一些作者認為36 38,資料庫只有能夠防止丟失更新,才算提供快照隔離;按照這個定義,MySQL 並沒有提供快照隔離。
自動檢測丟失更新是一項很實用的功能,因為應用程式碼無須使用任何特殊資料庫功能。開發者可能忘記加鎖或使用原子操作而引入缺陷,自動檢測則不容易遺漏。不過,應用程式仍須負責重試被中止的事務。
條件寫入(比較並設定)
不提供事務的資料庫有時會提供 條件寫入 操作(前文“單物件寫入”已經提到):只有當值自上次讀取後未發生變化,才允許更新,從而防止丟失更新。如果當前值與先前讀到的值不符,更新便不生效,應用必須重試讀取—修改—寫入迴圈。它相當於資料庫版本的原子 比較並設定(compare-and-set)或 比較並交換(compare-and-swap,CAS)指令,許多 CPU 都支援此類指令。
例如,為防止兩個使用者同時更新同一個 wiki 頁面,可以嘗試如下寫法:只有當頁面內容在使用者開始編輯後沒有變化時,更新才會發生。
如果內容已經變化,不再與 'old content' 匹配,更新就不會生效;因此需要檢查更新結果,必要時重試。也可以不比較完整內容,而是使用一個每次更新都遞增的版本號列,只在當前版本號未變化時應用更新。這種方法有時稱為 樂觀鎖定(optimistic locking)52。
如果另一個事務併發修改了 content,按照 MVCC 可見性規則,新內容可能並不可見(參見“觀察一致快照的可見性規則”)。許多 MVCC 實現會針對這種場景作出例外:其他事務寫入的值即便在快照中不可見,計算 UPDATE 和 DELETE 查詢的 WHERE 子句時仍然可見。
衝突解決與複製
在複製資料庫中(參見第 6 章),防止丟失更新又多了一個維度:資料副本分佈在多個節點上,並可能在不同節點被併發修改,因此還需採取額外措施。
鎖和條件寫入操作都假定存在唯一的最新資料副本。但採用多主複製或無主複製的資料庫,通常允許多項寫入併發發生,再非同步複製到其他節點,因而無法保證存在唯一的最新副本。所以,基於鎖或條件寫入的技術並不適用於這種場景。(“線性一致性”將更深入地討論這個問題。)
這類複製資料庫通常採用另一種辦法,正如“處理寫入衝突”所述:允許併發寫入產生同一個值的多個衝突版本(也稱為 兄弟值,siblings),再由應用程式碼或特殊資料結構事後解決衝突併合並版本。
如果更新是可交換的——即在不同副本上按不同順序應用,仍會得到相同結果——那麼合併衝突值便可防止丟失更新。遞增計數器、向集合新增元素都是可交換操作。這正是“CRDT 與操作變換”介紹的 CRDT 背後的思想。不過,條件寫入等操作無法變成可交換操作。
另一方面,最後寫入者勝(LWW)這種衝突解決辦法很容易丟失更新,詳見“最後寫入者勝(丟棄併發寫入)”。遺憾的是,許多複製資料庫都預設採用 LWW。
寫偏差與幻讀
前面介紹了 髒寫 和 丟失更新:不同事務併發寫入相同物件時,可能出現這兩種競態條件。要避免資料損壞,就必須防止它們發生——可以由資料庫自動防範,也可以手動使用鎖、原子寫操作等保護措施。
然而,併發寫入之間的潛在競態條件遠不止這些。本節將考察一些更為微妙的衝突。
先來看一個例子:你正在編寫一款供醫生管理醫院值班安排的應用。醫院通常希望任何時候都有幾位醫生值班,但底線是至少必須有一位。醫生可以放棄自己的班次(例如本人也生病了),前提是同一班次至少還有一位同事繼續值班53 54。
假設某個班次只有 Aaliyah 和 Bryce 兩位值班醫生。兩人都身體不適,決定請假,偏偏又幾乎同時點選了退出值班的按鈕。接下來發生的事情如圖 8-8所示。

每個事務都先檢查當前是否至少有兩位醫生值班;如果是,應用就認為其中一位可以安全退出。由於資料庫採用快照隔離,兩次檢查都返回 2,兩個事務於是都進入下一階段。Aaliyah 更新自己的記錄,退出值班;Bryce 也作出同樣的更新。兩個事務都成功提交,結果卻沒有任何醫生值班,違反了“至少一位醫生值班”的要求。
寫偏差的特徵
這種異常稱為 寫偏差(write skew)36。它既不是髒寫,也不是丟失更新,因為兩個事務更新的是不同物件——分別是 Aaliyah 和 Bryce 的值班記錄。這裡的衝突不那麼明顯,卻確實是一種競態條件:如果兩個事務依次執行,第二位醫生就無法退出值班。只有併發執行事務,才會出現這種異常行為。
可以把寫偏差看作丟失更新的推廣:兩個事務讀取相同的一組物件,隨後各自更新其中一些物件(不同事務可以更新不同物件),就可能發生寫偏差。若不同事務碰巧更新同一個物件,則會表現為髒寫或丟失更新,具體取決於時序。
防止丟失更新的辦法有很多,處理寫偏差時可選手段卻少得多:
單物件原子操作無濟於事,因為這裡涉及多個物件。
一些快照隔離實現能夠自動檢測丟失更新,但遺憾的是,這對寫偏差也無濟於事。PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化以及 SQL Server 的快照隔離,都不會自動檢測寫偏差29。要自動防止寫偏差,必須採用真正的可序列化隔離(參見“可序列化”)。
有些資料庫允許配置由資料庫強制執行的約束,例如唯一性約束、外來鍵約束或對特定值的限制。但要表達“至少有一位醫生值班”,就需要涉及多個物件的約束。大多數資料庫沒有內建支援這類約束,不過可以嘗試用觸發器或物化檢視來實現,正如“一致性”所述12。
如果無法使用可序列化隔離,這裡的次優選擇大概是顯式鎖定事務所依賴的行。在醫生值班的例子中,可以這樣寫:
❶ 與之前一樣,
FOR UPDATE要求資料庫鎖定這條查詢返回的所有行。
寫偏差的更多例子
寫偏差乍看似乎很深奧,一旦意識到它的存在,就會發現它可能出現在許多場景中。再看幾個例子:
- 會議室預訂系統
- 假設你要保證同一間會議室在同一時段不能被重複預訂55。有人發起預訂時,先檢查是否存在衝突(即同一房間是否有時間範圍重疊的預訂);若沒有,再建立會議(參見示例 8-2)。
範例 8-2 會議室預訂系統試圖避免重複預訂(在快照隔離下並不安全) 遺憾的是,快照隔離無法阻止另一個使用者併發插入衝突的會議。要保證安排不會衝突,還是得采用可序列化隔離。
- 多人遊戲
- 在示例 8-1中,我們用鎖防止丟失更新,也就是確保兩名玩家不能同時移動同一個棋子。但這把鎖無法阻止玩家把兩個不同棋子移動到棋盤上的同一位置,也無法阻止其他違反規則的走法。視具體規則而定,有時可以使用唯一性約束;否則就很容易發生寫偏差。
- 宣告使用者名稱
- 在要求使用者名稱唯一的網站上,兩個使用者可能同時嘗試用同一個名字建立賬戶。可以用事務先檢查使用者名稱是否已被佔用,如果沒有,再用它建立賬戶。但和前面的例子一樣,這在快照隔離下並不安全。好在唯一性約束就能輕鬆解決這個問題:第二個嘗試註冊該使用者名稱的事務會因違反約束而中止。
- 防止重複消費
- 允許使用者消費金錢或積分的服務,必須確保使用者的支出不超過餘額。一種實現方式是先在使用者賬戶中插入暫定支出項,再列出賬戶中的所有專案,檢查合計餘額是否仍為正數。發生寫偏差時,兩個支出項可能被併發插入,合在一起使餘額變成負數,兩個事務卻都沒有察覺對方。
導致寫偏差的幻讀
這些例子都遵循相似的模式:
SELECT查詢搜尋符合某項條件的行,以檢查是否滿足要求(至少有兩位醫生值班、該會議室在相應時段沒有預訂、棋盤上的目標位置沒有其他棋子、使用者名稱尚未佔用、賬戶仍有餘額)。- 應用程式碼根據第一次查詢的結果決定下一步:繼續執行,或者向使用者報告錯誤並中止。
- 如果決定繼續,應用便向資料庫寫入(
INSERT、UPDATE或DELETE),然後提交事務。
這次寫入改變了步驟 2 作出決定時所依據的前提。換句話說,寫入提交後再次執行步驟 1 的 SELECT,結果就會不同,因為符合搜尋條件的行集合已經改變(少了一位值班醫生、會議室在該時段已有預訂、棋盤上的目標位置已被剛移入的棋子佔據、使用者名稱已被佔用,或賬戶餘額已經減少)。
這些步驟也可以採用不同順序。例如,可以先寫入,再執行 SELECT 查詢,最後根據查詢結果決定中止還是提交。
在醫生值班的例子中,步驟 3 修改的是步驟 1 返回的某一行,因此可以在步驟 1 中鎖定這些行(SELECT FOR UPDATE),使事務安全並避免寫偏差。但其餘四個例子不同:它們檢查的是 不存在 符合某項條件的行,隨後的寫入則 新增 了一行,使其符合相同條件。如果步驟 1 的查詢沒有返回任何行,SELECT FOR UPDATE 便無處加鎖56。
一個事務的寫入改變了另一個事務中搜尋查詢的結果,這種現象稱為 幻讀(phantom)4。快照隔離可以避免只讀查詢中的幻讀,但在上述讀寫事務中,幻讀可能引發格外棘手的寫偏差。ORM 生成的 SQL 也很容易遇到寫偏差50 51。
物化衝突
如果幻讀的問題在於沒有物件可供加鎖,或許可以人為地在資料庫中引入一個鎖物件?
以會議室預訂為例,可以建立一張房間與時段表。表中每一行對應某個房間的某個特定時段(例如 15 分鐘),提前為未來六個月內所有可能的房間與時段組合建好行。
現在,建立預訂的事務可以鎖定(SELECT FOR UPDATE)表中與目標房間和時段對應的行。取得鎖後,再照常檢查重疊預訂並插入新預訂。請注意,這張附加表並不儲存預訂資訊——它純粹是一組鎖,用來防止同一房間、同一時間範圍內的預訂被併發修改。
這種方法稱為 物化衝突(materializing conflicts):它把幻讀轉化為資料庫中一組具體行上的鎖衝突14。遺憾的是,怎樣物化衝突既難設計又容易出錯,而且讓併發控制機制滲入應用資料模型也很不美觀。因此,物化衝突只能在別無選擇時作為最後手段;大多數情況下,可序列化隔離要好得多。
可序列化
本章已經介紹了幾類容易出現競態條件的事務。讀已提交和快照隔離可以防止其中一部分,卻無法防止其他型別,寫偏差和幻讀尤其棘手。當前的處境著實令人沮喪:
- 隔離級別難以理解,不同資料庫的實現又不一致(例如“可重複讀”的含義相差很大)。
- 單看應用程式碼,很難判斷它在特定隔離級別下執行是否安全。大型應用尤其如此,因為你未必知道還有哪些操作可能併發發生。
- 沒有好用的工具可以幫助檢測競態條件。原則上靜態分析或許能派上用場33,研究成果卻尚未進入實際應用。併發問題通常具有非確定性,只有時序碰巧不利時才會出現,因此也很難透過測試發現。
這並不是新問題——自 20 世紀 70 年代引入弱隔離級別以來,情況一直如此3。研究人員給出的答案始終很簡單:使用 可序列化 隔離!
可序列化是最強的隔離級別。它保證即使事務實際並行執行,最終結果也與它們 序列 執行(逐個執行、毫無併發)相同。因此,只要事務單獨執行時行為正確,資料庫就能保證它們併發執行時仍然正確。換句話說,資料庫可以防止 所有 可能的競態條件。
既然可序列化遠勝於弱隔離帶來的混亂,為什麼大家不都使用它?要回答這個問題,就要看看可序列化有哪些實現方式,以及它們的效能如何。目前提供可序列化的大多數資料庫都採用以下三種技術之一,本章餘下內容將逐一探討:
- 嚴格按照序列順序執行事務(參見“實際序列執行”);
- 兩階段鎖定(參見“兩階段鎖定(2PL)”),幾十年來這是唯一可行的選擇;
- 可序列化快照隔離等樂觀併發控制技術(參見“可序列化快照隔離(SSI)”)。
實際序列執行
避免併發問題最簡單的辦法,就是徹底消除併發:在單個執行緒上按照序列順序,每次只執行一個事務。這樣便完全繞開了檢測和防止事務衝突的問題,而得到的隔離按定義就是可序列化的。
這個想法看似顯而易見,資料庫設計者卻直到 2000 年代才認定,用單執行緒迴圈執行事務切實可行57。此前 30 年間,多執行緒併發一直被視為取得良好效能的必要條件,究竟是什麼變化讓單執行緒執行成為可能?
兩項進展促成了這次重新思考:
- RAM 已經足夠便宜,許多用例都能把整個活躍資料集放進記憶體(參見“全記憶體儲存”)。事務所需的全部資料都在記憶體中時,執行速度遠快於等待從磁碟載入資料。
- 資料庫設計者意識到,OLTP 事務通常很短,只執行少量讀寫(參見“分析型與事務型系統”)。長期執行的分析查詢則通常只讀,可以在序列執行迴圈之外,基於一致快照執行(使用快照隔離)。
VoltDB/H-Store、Redis 和 Datomic 等系統採用了序列執行事務的方式58 59 60。為單執行緒執行而設計的系統有時反而比支援併發的系統效能更好,因為它省去了加鎖的協調開銷。不過,其吞吐量受限於單個 CPU 核。要充分利用這條執行緒,事務的組織方式必須不同於傳統形式。
將事務封裝在儲存過程中
資料庫誕生之初,人們設想事務可以涵蓋使用者活動的整個流程。例如,預訂機票要經過多個階段:搜尋航線、票價和空餘座位;確定行程;預訂每一段航班的座位;填寫乘客資訊;最後付款。資料庫設計者認為,如果把整個流程作為一個事務原子提交,會非常理想。
遺憾的是,人類作出決定和響應的速度很慢。如果資料庫事務要等待使用者輸入,資料庫就必須支援數量可能極其龐大的併發事務,而且其中大部分都處於空閒狀態。絕大多數資料庫無法高效做到這一點,因此幾乎所有 OLTP 應用都會避免在事務中互動等待使用者,以保持事務簡短。在 Web 應用中,這意味著事務要在同一個 HTTP 請求內提交,不能跨越多個請求;新的 HTTP 請求會啟動新的事務。
即使已經把人從關鍵路徑中移除,事務仍往往採用互動式客戶端/伺服器模式,逐條執行語句。應用發出查詢、讀取結果,再根據第一次查詢的結果決定是否發出下一條查詢,如此往復。查詢與結果不斷在應用程式碼所在的機器和資料庫伺服器之間來回傳遞。
這種互動式事務把大量時間耗在應用與資料庫之間的網路通訊上。如果資料庫禁止併發、每次只處理一個事務,吞吐量會低得可怕:資料庫大部分時間都在等待應用發出當前事務的下一條查詢。採用這種模式的資料庫必須併發處理多個事務,才能獲得合理效能。
因此,單執行緒序列處理事務的系統不允許互動式多語句事務。應用要麼只使用單語句事務,要麼提前把整個事務的程式碼作為 儲存過程(stored procedure)提交給資料庫61。
互動式事務與儲存過程的差別如圖 8-9所示。只要事務所需的全部資料都在記憶體中,儲存過程就能快速執行,無須等待任何網路或磁碟 I/O。

儲存過程的利弊
儲存過程早已存在於關係型資料庫中,並從 1999 年起成為 SQL 標準(SQL/PSM)的一部分。不過,它們因種種原因名聲不佳:
- 傳統上,每家資料庫廠商都有自己的儲存過程語言(Oracle 使用 PL/SQL,SQL Server 使用 T-SQL,PostgreSQL 使用 PL/pgSQL 等)。這些語言沒有跟上通用程式語言的發展,今天看來頗為醜陋陳舊,也缺少大多數程式語言擁有的庫生態。
- 在資料庫中執行的程式碼難以管理:與應用伺服器相比,它更難除錯,不便進行版本控制和部署,測試更麻煩,也難以接入採集指標的監控系統。
- 資料庫通常比應用伺服器對效能更加敏感,因為一個資料庫例項往往由許多應用伺服器共享。一個寫得很差的儲存過程(例如佔用大量記憶體或 CPU 時間),可能比應用伺服器上同樣糟糕的程式碼造成更大麻煩。
- 在允許租戶自行編寫儲存過程的多租戶系統中,讓不受信任的程式碼與資料庫核心執行在同一個程序裡,會帶來安全風險62。
不過,這些問題並非無法克服。現代儲存過程實現已經放棄 PL/SQL,轉而採用現有的通用程式語言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,Redis 使用 Lua,MongoDB 使用 JavaScript。
當應用邏輯不便嵌入其他位置時,儲存過程也很有用。例如,使用 GraphQL 的應用可能透過 GraphQL 代理直接開放資料庫。若代理不支援複雜的驗證邏輯,就可以用儲存過程把這類邏輯直接嵌入資料庫;如果資料庫不支援儲存過程,則只能在代理與資料庫之間另行部署驗證服務。
儲存過程配合記憶體資料,使所有事務都在單執行緒上執行成為可行方案。只要儲存過程無須等待 I/O,又能避開其他併發控制機制的開銷,單執行緒也可以達到相當可觀的吞吐量。
VoltDB 還利用儲存過程進行復制:它不把事務寫入從一個節點複製到另一個節點,而是在每個副本上執行同一個儲存過程。因此,VoltDB 要求儲存過程必須是 確定性的(deterministic),即在不同節點執行時產生相同結果。例如,事務若要使用當前日期和時間,就必須透過特殊的確定性 API 獲取(關於確定性操作的更多細節,參見“持久化執行與工作流”)。這種方法稱為 狀態機複製(state machine replication),我們將在第 10 章再次討論它。
分片
序列執行所有事務大大簡化了併發控制,卻也把資料庫的事務吞吐量限制在單臺機器、單個 CPU 核的處理能力以內。只讀事務可以透過快照隔離在別處執行;但對寫入吞吐量很高的應用而言,單執行緒事務處理器可能成為嚴重瓶頸。
要擴充套件到多個 CPU 核和多個節點,可以像 VoltDB 那樣對資料分片(參見第 7 章)。如果能讓每個事務只讀寫單個分片內的資料,那麼每個分片都可以擁有自己的事務處理執行緒,彼此獨立執行。此時可以為每個 CPU 核分配一個分片,使事務吞吐量隨 CPU 核數線性增長59。
但凡事務需要訪問多個分片,資料庫就必須在所有涉及的分片之間進行協調。儲存過程要在這些分片上步調一致地執行,才能保證整個系統的可序列化。
跨分片事務有額外的協調開銷,因此遠慢於單分片事務。VoltDB 報告的跨分片寫入吞吐量約為每秒 1,000 次,比單分片吞吐量低幾個數量級,而且無法靠增加機器來提升61。近來的研究探索了讓多分片事務更具可伸縮性的辦法63。
事務能否限制在單個分片內,很大程度上取決於應用所用的資料結構。簡單的鍵值資料通常很容易分片;具有多個二級索引的資料則很可能需要大量跨分片協調(參見“分片與二級索引”)。
序列執行總結
在滿足特定約束的情況下,序列執行事務已經成為實現可序列化隔離的一種可行辦法:
- 每個事務都必須短小迅速,因為一個慢事務就足以拖住所有事務處理。
- 這種辦法最適合活躍資料集能夠裝進記憶體的場景。不常訪問的資料可以移到磁碟,但單執行緒事務一旦需要訪問這些資料,系統就會變得很慢。
- 寫入吞吐量必須足夠低,能由單個 CPU 核處理;否則,事務必須能夠分片執行,並且不需要跨分片協調。
- 跨分片事務雖然可行,其吞吐量卻很難擴充套件。
兩階段鎖定(2PL)
大約 30 年間,資料庫領域只有一種得到廣泛應用的可序列化演算法:兩階段鎖定(two-phase locking,2PL)。它有時也稱為 強嚴格兩階段鎖定(strong strict two-phase locking,SS2PL),以區別於 2PL 的其他變體。
兩階段 鎖定(2PL)與兩階段 提交(2PC)是完全不同的兩回事。2PL 提供可序列化隔離,2PC 則為分散式資料庫提供原子提交(參見“兩階段提交(2PC)”)。為免混淆,最好把它們當成彼此獨立的概念,不必理會名稱上這種不巧的相似。
前文已經看到,鎖通常用於防止髒寫(參見“沒有髒寫”):若兩個事務併發寫入同一物件,鎖會迫使第二個寫入者等待,直到第一個事務提交或中止後才能繼續。
兩階段鎖定與此類似,但加鎖要求嚴格得多。只要沒有寫入,多個事務可以併發讀取同一個物件;但只要有事務想寫入(修改或刪除)物件,就必須取得獨佔訪問權:
- 如果事務 A 已讀取某個物件,而事務 B 想寫入該物件,B 必須等 A 提交或中止後才能繼續。(這樣可以確保 B 不會在 A 不知情的情況下改變物件。)
- 如果事務 A 已寫入某個物件,而事務 B 想讀取該物件,B 必須等 A 提交或中止後才能繼續。(2PL 不允許像圖 8-4那樣讀取物件的舊版本。)
在 2PL 中,寫會阻塞其他寫,也會阻塞讀;反過來,讀同樣會阻塞寫。快照隔離則奉行“讀不阻塞寫,寫也不阻塞讀”(參見“多版本併發控制(MVCC)”),這正是快照隔離與兩階段鎖定的關鍵區別。另一方面,2PL 提供可序列化,能夠防止前面討論的所有競態條件,包括丟失更新和寫偏差。
兩階段鎖定的實現
MySQL(InnoDB)和 SQL Server 的可序列化隔離級別,以及 Db2 的可重複讀隔離級別,都採用 2PL29。
資料庫透過為每個物件設定一把鎖來阻塞讀寫操作。鎖可以處於 共享模式(shared mode)或 獨佔模式(exclusive mode),這也稱為 多讀者單寫者(multi-reader single-writer)鎖。具體規則如下:
- 事務要讀取物件,必須先以共享模式取得鎖。多個事務可以同時持有共享鎖;但如果另一個事務已持有該物件的獨佔鎖,讀事務就必須等待。
- 事務要寫入物件,必須先以獨佔模式取得鎖。此時不允許其他事務以任何模式持鎖;只要物件上已有鎖,寫事務就必須等待。
- 事務先讀後寫時,可以把共享鎖升級為獨佔鎖。升級規則與直接取得獨佔鎖相同。
- 事務取得鎖後,必須一直持有到事務結束(提交或中止)。這正是“兩階段”名稱的由來:第一階段在事務執行期間獲取鎖,第二階段在事務結束時一次釋放所有鎖。
由於大量使用鎖,很容易出現事務 A 等待事務 B 釋放鎖、事務 B 又反過來等待事務 A 的情況,這稱為 死鎖(deadlock)。資料庫會自動檢測事務之間的死鎖並中止其中一個,讓其他事務能夠繼續推進;被中止的事務則由應用程式重試。
兩階段鎖定的效能
兩階段鎖定最大的缺點,也是它自 20 世紀 70 年代以來始終沒有普及到所有資料庫的原因,就是效能:在兩階段鎖定下,事務吞吐量和查詢響應時間都明顯遜於弱隔離。
這部分源於獲取和釋放大量鎖的開銷,更主要的原因則是併發度降低。按照設計,只要兩個併發事務執行的操作有任何可能引發競態條件,其中一個就必須等另一個完成。
例如,一個事務需要讀取整張表,用於備份、分析查詢或完整性檢查(參見“快照隔離與可重複讀”),就必須為整張表取得共享鎖。讀事務首先要等待所有正在寫表的事務結束;隨後,在它讀取整張表期間(大表可能耗時很久),所有想寫入該表的事務又會被阻塞,直至這個大型只讀事務提交。實際上,資料庫會在很長一段時間內無法接受寫入。
因此,採用 2PL 的資料庫延遲可能相當不穩定;工作負載一旦存在爭用,高百分位延遲就可能非常糟糕(參見“描述效能”)。僅僅一個慢事務,或者一個訪問大量資料、取得許多鎖的事務,就可能讓系統其餘部分陷入停頓。
基於鎖的讀已提交也會發生死鎖,但在 2PL 可序列化隔離下,死鎖往往更為頻繁(具體取決於事務的訪問模式)。這又會帶來額外的效能問題:事務因死鎖而中止、重試時,必須從頭重做全部工作。若死鎖頻繁,浪費的計算量會相當可觀。
謂詞鎖
前面對鎖的描述略過了一個微妙卻重要的細節。“導致寫偏差的幻讀”介紹過 幻讀:一個事務改變了另一個事務的搜尋查詢結果。提供可序列化隔離的資料庫必須防止幻讀。
在會議室預訂的例子中,如果一個事務已經搜尋某個房間在特定時段內的現有預訂(參見示例 8-2),另一個事務就不能併發插入或更新同一房間、同一時段的預訂。(併發預訂其他房間,或者預訂同一房間但互不影響的其他時段,則沒有問題。)
怎樣實現這一點?從概念上講,需要使用 謂詞鎖(predicate lock)4。它的工作方式類似前面介紹的共享鎖和獨佔鎖,卻不屬於某個特定物件(例如表中的一行),而是屬於所有符合某項搜尋條件的物件,例如:
謂詞鎖限制訪問如下:
- 事務 A 想讀取符合某項條件的物件(如上面的
SELECT),必須先在查詢條件上取得共享模式的謂詞鎖。如果事務 B 已經對任何符合條件的物件持有獨佔鎖,A 就要等 B 釋放鎖後才能查詢。 - 事務 A 想插入、更新或刪除物件,必須先檢查舊值或新值是否符合任何已有謂詞鎖。如果發現事務 B 持有匹配的謂詞鎖,A 就要等 B 提交或中止後才能繼續。
關鍵在於,謂詞鎖甚至適用於資料庫中尚不存在、未來卻可能加入的物件(即幻讀)。兩階段鎖定若包含謂詞鎖,資料庫就能防止各種形式的寫偏差和其他競態條件,使隔離達到可序列化。
索引範圍鎖
遺憾的是,謂詞鎖效能不佳:活躍事務持有大量鎖時,檢查是否有匹配的鎖會非常耗時。因此,大多數採用 2PL 的資料庫實際實現的是 索引範圍鎖(index-range lock,也稱為 next-key locking),即謂詞鎖的一種簡化近似54 64。
把謂詞放寬到匹配更大的物件集合,是一種安全的簡化。例如,要鎖定 123 號房間從中午 12 點到下午 1 點的預訂,可以近似為鎖定 123 號房間全天所有預訂;也可以近似為鎖定中午 12 點到下午 1 點所有房間的預訂。這樣做是安全的,因為任何符合原始謂詞的寫入,必然也符合近似後的條件。
會議室預訂資料庫很可能在 room_id 列上建有索引,或在 start_time 和 end_time 上建有索引(否則前述查詢面對大型資料庫會非常慢):
- 假設索引建在
room_id上,資料庫用它查詢 123 號房間的現有預訂。資料庫可以直接把共享鎖附加到這條索引項上,表示某個事務已搜尋過 123 號房間的預訂。 - 如果資料庫使用時間索引查詢現有預訂,則可以把共享鎖附加到索引中的一段值域上,表示某個事務已經搜尋過與 2025 年 1 月 1 日中午 12 點至下午 1 點重疊的預訂。
無論採用哪種索引,搜尋條件的近似都會附著在其中一個索引上。另一個事務若想插入、更新或刪除同一房間和/或重疊時段的預訂,就必須更新索引的同一部分,於是會碰到共享鎖,被迫等到鎖釋放。
這樣便能有效防止幻讀和寫偏差。索引範圍鎖不如謂詞鎖精確,可能會鎖住比維持可序列化嚴格所需更大範圍的物件;但它的開銷低得多,是一種很好的折中。
如果沒有合適的索引可供附加範圍鎖,資料庫可以退回到對整張表加共享鎖。這樣會阻塞所有其他事務寫表,效能很差,卻是安全的後備方案。
可序列化快照隔離(SSI)
到目前為止,資料庫併發控制的前景顯得頗為黯淡:一邊是效能不佳的兩階段鎖定,以及伸縮性不佳的序列執行;另一邊是效能雖好,卻容易出現丟失更新、寫偏差、幻讀等競態條件的弱隔離。可序列化隔離與良好效能從根本上就無法兼得嗎?
看來並非如此。一種名為 可序列化快照隔離(serializable snapshot isolation,SSI)的演算法能夠提供完整的可序列化,與快照隔離相比只付出很小的效能代價。SSI 相對較新,最早於 2008 年提出53 65。
如今,SSI 及類似演算法已經用於單節點資料庫(PostgreSQL 的可序列化隔離級別54、SQL Server 的記憶體 OLTP/Hekaton66 和 HyPer67)、分散式資料庫(CockroachDB5 和 FoundationDB8),以及 BadgerDB 等嵌入式儲存引擎。
悲觀併發控制與樂觀併發控制
兩階段鎖定屬於所謂的 悲觀(pessimistic)併發控制機制。它遵循這樣的原則:只要有任何出錯的可能(例如另一個事務持有鎖),就先等待,直到局面重新安全後再行動。這類似於多執行緒程式設計中用來保護資料結構的 互斥(mutual exclusion)。
從某種意義上說,序列執行把悲觀推到了極致:本質上,每個事務在執行期間都像是對整個資料庫(或一個資料庫分片)持有獨佔鎖。作為補償,每個事務都要儘快執行,使這把“鎖”只持有很短時間。
相比之下,可序列化快照隔離是一種 樂觀(optimistic)併發控制技術。這裡的“樂觀”是指:即使出現潛在危險,也不阻塞事務,而是讓它繼續執行,寄望最終不會出問題。等事務準備提交時,資料庫再檢查是否發生了壞事(即隔離是否遭到破壞);若有,就中止並重試事務。只有執行結果可序列化的事務才能提交。
樂觀併發控制是個古老的思想68,其利弊也已爭論多年69。存在嚴重爭用(許多事務訪問相同物件)時,它表現很差,因為很大比例的事務都不得不中止。若系統已經接近最大吞吐量,重試事務帶來的額外負載還會進一步拖累效能。
不過,如果系統有充足的餘量,事務之間爭用又不嚴重,樂觀併發控制往往優於悲觀方案。可交換的原子操作還能降低爭用:例如,多個事務併發遞增計數器時,應用這些增量的順序無關緊要(只要同一事務沒有讀取該計數器),因此所有併發增量都可以執行而不會衝突。
顧名思義,SSI 建立在快照隔離之上:事務中的所有讀取都來自資料庫的一致快照(參見“快照隔離與可重複讀”)。在此基礎上,SSI 加入演算法來檢測讀寫之間的可序列化衝突,並決定應當中止哪些事務。
基於過時前提的決策
前面討論快照隔離中的寫偏差時(參見“寫偏差與幻讀”),我們反覆看到一種模式:事務從資料庫讀取資料、檢查查詢結果,再根據結果決定執行某項操作(向資料庫寫入)。然而在快照隔離下,等事務提交時,原查詢結果可能已經過時,因為資料在此期間可能被修改。
換句話說,事務根據某個 前提 行動——即事務開始時為真的事實,例如“當前有兩名醫生值班”。等到事務準備提交時,原始資料可能已經變化,原先的前提不再成立。
應用發出查詢(例如“當前有多少醫生值班?”)時,資料庫並不知道應用邏輯會如何使用查詢結果。為確保安全,資料庫必須假定:查詢結果(即前提)一有變化,該事務中的寫入就可能失效。也就是說,事務中的查詢與寫入之間可能存在因果依賴。為了提供可序列化隔離,資料庫必須檢測事務是否可能依據過時前提行事,並在這種情況下中止事務。
資料庫怎樣判斷查詢結果可能已經變化?需要考慮兩種情況:
- 檢測是否讀取了過時的 MVCC 物件版本(未提交的寫入發生在讀取之前);
- 檢測是否有寫入影響了先前的讀取(寫入發生在讀取之後)。
檢測陳舊的 MVCC 讀取
回想一下,快照隔離通常透過多版本併發控制實現(MVCC;參見“多版本併發控制(MVCC)”)。事務從 MVCC 資料庫的一致快照讀取時,會忽略其他事務在快照生成時尚未提交的所有寫入。
在圖 8-10中,事務 43 看到 Aaliyah 的 on_call = true,因為修改其值班狀態的事務 42 尚未提交。但等事務 43 準備提交時,事務 42 已經提交。這意味著,讀取一致快照時被忽略的寫入現在已經生效,事務 43 的前提不再成立。如果寫入者插入的是此前不存在的資料,情況還會更複雜(參見“導致寫偏差的幻讀”)。“檢測影響先前讀取的寫入”將介紹 SSI 如何檢測幻寫。

為防止這種異常,資料庫必須跟蹤事務何時因 MVCC 可見性規則而忽略了其他事務的寫入。等事務準備提交時,再檢查這些被忽略的寫入是否已經提交;若是,就必須中止該事務。
為什麼非要等到提交?為什麼不在發現過時讀取時立刻中止事務 43?如果事務 43 是隻讀事務,就沒有寫偏差風險,無須中止;而在它讀取時,資料庫還不知道它稍後是否會寫入。此外,事務 42 可能最終中止,也可能在事務 43 提交時仍未提交,於是這次讀取到頭來並未過時。透過避免不必要的中止,SSI 保留了快照隔離支援長期讀取一致快照的優點。
檢測影響先前讀取的寫入
第二種情況,是資料被讀取後又遭另一個事務修改,如圖 8-11所示。

討論兩階段鎖定時,我們介紹過索引範圍鎖(參見“索引範圍鎖”),資料庫可以藉此鎖定所有符合某項查詢條件的行,例如 WHERE shift_id = 1234。SSI 可以使用類似技術,區別在於 SSI 的鎖不會阻塞其他事務。
在圖 8-11中,事務 42 和 43 都查詢了班次 1234 的值班醫生。如果 shift_id 上有索引,資料庫就能利用索引項 1234 記錄事務 42 和 43 讀過這項資料。(沒有索引時,也可以在表級別跟蹤。)這些資訊只需保留一段時間:等事務結束(提交或中止),並且所有與之併發的事務也結束後,資料庫就可以忘掉它讀過哪些資料。
事務寫入資料庫時,必須在索引中查詢近期讀過受影響資料的其他事務。這個過程類似為受影響的鍵範圍取得寫鎖,但它不會一直阻塞到讀事務提交,而是像警戒線一樣:只負責通知相關事務,它們讀過的資料可能已經過時。
在圖 8-11中,事務 43 通知事務 42:它先前讀過的資料已經過時;事務 42 也反過來通知事務 43。事務 42 率先提交併獲得成功:雖然事務 43 的寫入影響了事務 42,但事務 43 尚未提交,所以寫入還未生效。等事務 43 準備提交時,事務 42 的衝突寫入已經提交,因此事務 43 必須中止。
可序列化快照隔離的效能
一如既往,許多工程細節會影響演算法的實際表現。例如,需要權衡以多細的粒度跟蹤事務讀寫。如果資料庫詳細記錄每個事務的活動,就能精確判斷哪些事務應當中止,但記賬開銷可能很大;較粗粒度的跟蹤更快,卻可能中止一些本來無須中止的事務。
有些情況下,事務讀到被另一事務覆蓋的資訊也沒有關係:結合其他事件,有時可以證明執行結果仍然可序列化。PostgreSQL 利用這一理論來減少不必要的中止14 54。
與兩階段鎖定相比,可序列化快照隔離的主要優勢是:事務無須因等待另一個事務持有的鎖而阻塞。與快照隔離一樣,寫不阻塞讀,讀也不阻塞寫。這項設計原則讓查詢延遲更加可預測,波動也更小。尤其是隻讀查詢可以無須任何鎖、直接執行在一致快照上,對讀取密集型工作負載很有吸引力。
與序列執行相比,可序列化快照隔離不受單個 CPU 核吞吐量的限制。例如,FoundationDB 把可序列化衝突檢測分散到多臺機器上,因而可以擴充套件到很高的吞吐量。即使資料分片存放在多臺機器上,事務仍可讀寫多個分片,同時保證可序列化隔離。
與非可序列化的快照隔離相比,檢查可序列化違規會帶來一些效能開銷。這項開銷究竟有多大,仍有爭議:有人認為可序列化檢查得不償失70;也有人認為如今可序列化效能已經足夠好,沒必要再採用較弱的快照隔離67。
中止率會顯著影響 SSI 的總體效能。一個長期讀寫資料的事務很可能遇到衝突而中止,因此 SSI 要求讀寫事務相當短小(長期執行的只讀事務沒有問題)。不過,與兩階段鎖定或序列執行相比,SSI 對慢事務沒有那麼敏感。
分散式事務
前幾節重點討論了實現隔離性(即 ACID 中的 I)所需的併發控制。前面介紹的演算法既適用於單節點資料庫,也適用於分散式資料庫。儘管併發控制演算法在伸縮時會遇到挑戰(例如為 SSI 執行分散式可序列化檢查),分散式併發控制的總體思路仍與單節點併發控制相似8。
轉向分散式事務後,一致性和永續性也沒有太大變化;但原子性需要格外小心。
對於只在單個資料庫節點上執行的事務,原子性通常由儲存引擎實現。客戶端請求資料庫節點提交時,資料庫先將事務寫入持久化(通常寫入預寫日誌;參見“使 B 樹可靠”),再向磁碟日誌追加一條提交記錄。如果資料庫在此過程中崩潰,節點重啟後便從日誌恢復事務:若提交記錄在崩潰前已經成功落盤,事務視為已提交;否則,該事務的所有寫入都會回滾。
因此在單節點上,事務能否提交,關鍵取決於資料持久化落盤的 順序:先寫資料,再寫提交記錄22。決定事務提交還是中止的關鍵時刻,就是磁碟完成提交記錄寫入的一刻:在此之前,事務仍可能因崩潰而中止;在此之後,即使資料庫崩潰,事務也已經提交。換言之,是一個單獨的裝置——連線在特定節點上的某塊磁碟的控制器——使提交具備原子性。
可是,如果事務涉及多個節點呢?例如,分片資料庫中可能有多物件事務,也可能有全域性二級索引,其中索引項和主資料位於不同節點(參見“分片與二級索引”)。大多數“NoSQL”分散式資料儲存不支援這類分散式事務,但不少分散式關係型資料庫支援。
此時,僅僅向所有節點傳送提交請求,讓每個節點獨立提交事務並不夠。如圖 8-12所示,提交很容易在一部分節點上成功,卻在另一部分節點上失敗:
- 某些節點可能檢測到約束違規或衝突,因而必須中止,其他節點卻能成功提交;
- 某些提交請求可能在網路中丟失,最終因超時而中止,其他請求卻順利抵達;
- 某些節點可能在提交記錄完全寫入前崩潰,恢復時回滾事務,其他節點卻成功提交。

如果有些節點提交、有些節點中止,節點之間便會出現不一致。而事務一旦在某個節點上提交,即便後來發現它在另一個節點上中止,也不能再撤回。因為資料提交後,在 讀已提交 或更強隔離下就會對其他事務可見。例如,在圖 8-12中,當使用者 1 發現資料庫 1 提交失敗時,使用者 2 已經在資料庫 2 讀到了同一事務寫入的資料。如果事後再中止使用者 1 的事務,就連使用者 2 的事務也必須撤銷,因為它所依據的資料被追溯宣佈為從未存在。
更好的辦法是確保參與事務的節點要麼全部提交,要麼全部中止,絕不允許兩種結果混雜。這就是所謂的 原子提交(atomic commitment)問題。
兩階段提交(2PC)
兩階段提交是一種跨多個節點實現原子事務提交的演算法,也是分散式資料庫中的經典演算法13 71 72。有些資料庫在內部使用 2PC;它也以 XA 事務73的形式開放給應用程式(例如 Java 事務 API 就支援 XA),或者透過 WS-AtomicTransaction 用於 SOAP Web 服務74 75。
2PC 的基本流程如圖 8-13所示。單節點事務只需一次提交請求,2PC 則把提交或中止過程分成兩個階段,名稱也由此而來。

2PC 引入了一個單節點事務中通常沒有的元件:協調者(coordinator,也稱為 事務管理器,transaction manager)。協調者通常實現為一個庫,與發起事務的應用執行在同一程序中(例如嵌入 Java EE 容器);也可以作為獨立程序或服務執行。Narayana、JOTM、BTM 和 MSDTC 都屬於此類協調者。
使用 2PC 時,分散式事務照常開始:應用在多個資料庫節點上讀寫資料,這些節點稱為事務的 參與者(participant)。應用準備提交時,協調者進入第一階段,向每個參與者傳送 準備(prepare)請求,詢問它是否能夠提交,並收集各參與者的響應:
- 如果所有參與者都回答“是”,表示已準備好提交,協調者就在第二階段發出 提交 請求,真正執行提交;
- 如果任何參與者回答“否”,協調者就在第二階段向所有節點傳送 中止 請求。
這個過程有點像西方傳統婚禮:牧師分別詢問新娘和新郎是否願意與對方結婚,通常兩人都會回答“我願意”。收到雙方確認後,牧師宣佈二人結為夫妻——事務提交,這一喜訊也廣播給所有來賓。如果任何一方沒有回答“願意”,儀式就會中止76。
系統承諾
僅憑上面的簡短描述,或許還看不出為什麼兩階段提交能夠保證原子性,跨多個節點的一階段提交卻不能。畢竟在兩階段流程中,準備和提交請求同樣可能丟失。2PC 究竟有何不同?
要理解它為何有效,需要把過程進一步拆解:
- 應用要啟動分散式事務時,先向協調者申請一個全域性唯一的事務 ID。
- 應用在每個參與者上啟動一個單節點事務,併為其附上這個全域性事務 ID。所有讀寫都在這些單節點事務中完成。如果此階段出現任何問題(例如節點崩潰或請求超時),協調者或任何參與者都可以中止事務。
- 應用準備提交時,協調者向所有參與者傳送帶全域性事務 ID 的準備請求。任何一個請求失敗或超時,協調者都會向所有參與者傳送該事務 ID 的中止請求。
- 參與者收到準備請求後,必須確保自己在任何情況下都一定能提交事務。這既包括把全部事務資料寫入磁碟(崩潰、斷電或磁碟空間耗盡都不能成為以後拒絕提交的理由),也包括檢查衝突和約束違規。節點向協調者回答“是”,就等於承諾:只要收到要求,事務一定能夠無誤提交。換句話說,參與者尚未真正提交,卻已經放棄了自行中止事務的權利。
- 協調者收到所有準備請求的響應後,便對提交還是中止作出最終決定(只有所有參與者都投“是”才會提交)。協調者必須把決定寫入磁碟上的事務日誌,這樣即使隨後崩潰,恢復後也知道自己作過什麼決定。這一時刻稱為 提交點(commit point)。
- 協調者的決定一旦落盤,就向所有參與者傳送提交或中止請求。請求若失敗或超時,協調者必須不斷重試,直到成功為止。此時再無回頭路:如果決定提交,就必須執行到底,無論需要重試多少次。參與者若在此期間崩潰,恢復後也必須提交事務——既然它已經投了“是”,就不能反悔。
因此,協議中有兩個關鍵的“不歸點”:參與者投“是”時,承諾自己日後一定能夠提交(儘管協調者仍可決定中止);協調者一旦作出決定,該決定便不可撤銷。正是這套承諾保證了 2PC 的原子性。(單節點原子提交把這兩件事合併成一步:將提交記錄寫入事務日誌。)
回到婚禮的比喻:說出“我願意”之前,你或準配偶都可以用“絕對不行!”之類的回答中止事務;一旦說出“我願意”,就不能撤回。即使說完後昏了過去,沒有聽見牧師宣佈“你們現在結為夫妻”,也不會改變事務已經提交的事實。恢復意識後,可以向牧師查詢全域性事務 ID 的狀態,確認自己是否已經成婚;也可以等待牧師再次傳送提交請求——在你昏迷期間,他一直都在重試。
協調者失效
我們已經討論過 2PC 期間參與者或網路發生故障時會怎樣:任何準備請求失敗或超時,協調者都會中止事務;任何提交或中止請求失敗,協調者都會無限重試。但協調者自身崩潰時會發生什麼,就沒那麼清楚了。
如果協調者在傳送準備請求前失效,參與者可以安全中止事務。但參與者一旦收到準備請求並投出“是”,便不能再單方面中止,必須等待協調者告知事務究竟提交還是中止。如果協調者此時崩潰或網路發生故障,參與者只能等待。這種狀態下的事務稱為 存疑(in doubt)或 不確定(uncertain)事務。
圖 8-14展示了這種情況。在圖中的例子裡,協調者實際決定提交,資料庫 2 也收到了提交請求;但協調者還沒來得及把提交請求發給資料庫 1 就崩潰了,所以資料庫 1 不知道該提交還是中止。超時對此也無濟於事:資料庫 1 若在超時後自行中止,就會與已經提交的資料庫 2 不一致;自行提交同樣不安全,因為另一個參與者可能已經中止。

收不到協調者的訊息,參與者就無從知道應提交還是中止。原則上,參與者可以相互通訊,瞭解各自如何投票並達成某種協議,但這並不屬於 2PC 協議。
完成 2PC 的唯一辦法,就是等待協調者恢復。這正是協調者必須先把提交或中止決定寫入磁碟事務日誌,再向參與者傳送請求的原因。協調者恢復後,透過讀取事務日誌來確定所有存疑事務的狀態;協調者日誌中沒有提交記錄的事務一律中止。因此,2PC 的提交點歸根結底是協調者上的一次普通單節點原子提交。
三階段提交
由於 2PC 可能卡住並一直等待協調者恢復,兩階段提交也稱為 阻塞式(blocking)原子提交協議。理論上可以設計 非阻塞式(nonblocking)原子提交協議,使它在節點發生故障時不會卡住;但要在實踐中做到這一點並不容易。
有人提出 三階段提交(three-phase commit,3PC)作為 2PC 的替代方案13 77。然而,3PC 假設網路延遲有上界、節點響應時間也有上界;大多數現實系統都存在無界網路延遲和程序暫停(參見第 9 章),3PC 在其中無法保證原子性。
實踐中更好的辦法,是用容錯共識協議替代單節點協調者。第 10 章將介紹如何做到這一點。
跨不同系統的分散式事務
分散式事務和兩階段提交的名聲譭譽參半。一方面,它們提供了難以用其他方式實現的重要安全保證;另一方面,它們又因引發運維問題、嚴重損害效能,以及作出力所不及的承諾而備受批評78 79 80 81。許多雲服務正是出於這些運維問題,選擇不實現分散式事務82。
某些分散式事務實現會帶來沉重的效能代價。兩階段提交的固有開銷,大多來自崩潰恢復所需的額外強制刷盤(fsync)和額外網路往返。
不過,我們不應就此全盤否定分散式事務,而應仔細審視,從中汲取重要經驗。首先要明確“分散式事務”究竟指什麼,因為人們經常混淆兩種截然不同的分散式事務:
- 資料庫內部分散式事務
- 一些分散式資料庫(即標準配置就使用複製和分片的資料庫)支援資料庫節點之間的內部事務。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB,以及 MySQL Cluster 的 NDB 儲存引擎都支援內部事務。此時,參與事務的所有節點執行的都是同一種資料庫軟體。
- 異構分散式事務
- 異構 事務的參與者來自兩種或更多不同技術,例如不同廠商的兩種資料庫,甚至還包括訊息代理等非資料庫系統。即使底層系統截然不同,跨系統分散式事務仍必須保證原子提交。
資料庫內部事務無須與其他系統相容,因而可以採用任意協議,並針對具體技術進行最佳化。所以,資料庫內部的分散式事務通常運轉良好;跨異構技術的事務則困難得多。
恰好一次訊息處理
異構分散式事務可以用強有力的方式整合不同系統。例如,當且僅當處理訊息的資料庫事務成功提交,才確認訊息佇列中的那條訊息已經處理。實現方法是在同一個事務中原子提交 訊息確認 和 資料庫寫入。有了分散式事務,即使訊息代理與資料庫是執行在不同機器上的兩種互不相關的技術,也能做到這一點。
如果訊息傳遞或資料庫事務任一失敗,兩者都會中止,訊息代理稍後便可安全地重新投遞。透過原子提交訊息及其處理副作用,可以確保訊息實際上 恰好處理一次,即使成功之前重試了好幾次。每次中止都會丟棄未完成事務產生的所有副作用。這就是 恰好一次語義(exactly-once semantics)。
不過,只有事務影響的所有系統都能採用同一種原子提交協議,這類分散式事務才有可能實現。例如,假設處理訊息的副作用是傳送郵件,而郵件伺服器不支援兩階段提交;一旦訊息處理失敗並重試,郵件就可能傳送兩次甚至更多次。反之,如果事務中止時,訊息處理產生的所有副作用都能回滾,那麼處理過程便可安全重試,就像什麼也沒有發生一樣。
本章稍後還會回到恰好一次語義。現在先來看看支援這類異構分散式事務的原子提交協議。
XA 事務
X/Open XA(eXtended Architecture,即擴充套件架構的縮寫)是跨異構技術實現兩階段提交的標準73。它於 1991 年釋出,如今已得到廣泛實現:許多傳統關係型資料庫(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和訊息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)都支援 XA。
XA 並不是網路協議,而只是一套與事務協調者互動的 C API。其他語言也提供相應繫結。例如,在 Java EE 應用中,XA 事務透過 Java 事務 API(JTA)實現;許多使用 Java 資料庫連線(JDBC)的資料庫驅動,以及使用 Java 訊息服務(JMS)API 的訊息代理驅動都支援 JTA。
XA 假定應用透過網路驅動或客戶端庫,與參與者資料庫或訊息服務通訊。驅動支援 XA,意味著它會呼叫 XA API,判斷某項操作是否屬於分散式事務;若是,就把必要資訊傳送給資料庫伺服器。驅動還會暴露回撥介面,供協調者要求參與者準備、提交或中止。
事務協調者負責實現 XA API。標準沒有規定具體實現方式;實踐中,協調者通常只是一個載入到事務發起應用同一程序中的庫,而不是獨立服務。它跟蹤事務中的所有參與者,要求它們準備後透過驅動回撥收集響應,並用本地磁碟日誌記錄每個事務的提交或中止決定。
如果應用程序崩潰,或執行應用的機器宕機,協調者也會隨之消失。所有持有已準備但尚未提交事務的參與者,都會陷入存疑狀態。協調者日誌位於應用伺服器的本地磁碟上,因此必須重啟這臺伺服器,再由協調者庫讀取日誌,恢復各事務的提交或中止結果。之後,協調者才能透過資料庫驅動的 XA 回撥,要求參與者提交或中止。資料庫伺服器無法直接聯絡協調者,因為所有通訊都必須經過客戶端庫。
存疑時持有鎖
為什麼要如此在意存疑事務?難道系統其他部分不能照常工作,暫且忽略這些最終總會清理掉的事務嗎?
問題出在 鎖 上。正如“讀已提交”所述,資料庫事務通常會對修改的每一行取得行級獨佔鎖,以防止髒寫。如果還要求可序列化隔離,那麼採用兩階段鎖定的資料庫也必須為事務 讀取 的每一行取得共享鎖。
事務提交或中止之前,資料庫不能釋放這些鎖(如圖 8-13中的陰影部分所示)。所以使用兩階段提交時,事務必須在整個存疑期間持鎖。協調者若崩潰後要花 20 分鐘重啟,鎖就會持有 20 分鐘;協調者日誌若因故徹底丟失,鎖甚至會永遠保留——至少要一直等到管理員手動解決問題。
持鎖期間,其他事務無法修改這些行;視隔離級別而定,甚至連讀取也可能被阻塞。因此,其他事務無法若無其事地繼續執行——只要訪問同一份資料,就會卡住。應用程式的大部分功能都可能因此不可用,直至存疑事務得到解決。
從協調者失效中恢復
理論上,協調者崩潰重啟後,應該能從日誌完整恢復狀態,並解決所有存疑事務。但實踐中確實會出現 孤立的 存疑事務83 84:協調者由於某種原因無法確定事務結果,例如軟體缺陷導致事務日誌丟失或損壞。這些事務無法自動解決,只能一直留在資料庫中,持有鎖並阻塞其他事務。
重啟資料庫伺服器也解決不了問題。正確的 2PC 實現必須跨重啟保留存疑事務的鎖,否則就有破壞原子性保證的風險。這種局面十分棘手。
唯一的出路,是由管理員手動決定提交還是回滾。管理員必須檢查每個存疑事務的所有參與者,確定是否已有參與者提交或中止,再把同樣的結果應用到其餘參與者。這項工作可能耗費大量人力,而且往往要在嚴重生產中斷期間,承受巨大的精神與時間壓力來完成——否則協調者也不會陷入如此糟糕的狀態。
許多 XA 實現都留有一個稱為 啟發式決策(heuristic decision)的緊急出口:即使協調者沒有給出明確決定,也允許參與者單方面中止或提交存疑事務73。需要直說的是,這裡的“啟發式”只是“很可能破壞原子性”的委婉說法,因為它違背了兩階段提交的承諾體系。因此,啟發式決策只用於擺脫災難性局面,絕不能作為常規手段。
XA 事務的問題
單節點協調者是整個系統的單點故障。把協調者放進應用伺服器同樣有問題,因為協調者本地磁碟上的日誌會成為持久系統狀態的關鍵部分,其重要性不亞於資料庫本身。
原則上,XA 事務的協調者可以像其他重要資料庫一樣實現高可用和複製。遺憾的是,這仍解決不了 XA 的一個根本問題:它沒有提供協調者與事務參與者直接通訊的辦法。二者只能透過發起事務的應用程式碼,以及應用用來呼叫參與者的資料庫驅動進行通訊。
因此,即使複製了協調者,應用程式碼依然是單點故障。要解決這個問題,必須徹底重構應用程式碼的執行方式,使其可複製或可重啟,形式上可能類似持久化執行(參見“持久化執行與工作流”)。但實踐中似乎沒有工具真正採用這種方案。
另一個問題是,XA 必須相容形形色色的資料系統,因而只能取它們的最低公分母。例如,它無法檢測橫跨不同系統的死鎖,因為這需要一套標準協議,讓系統交換每個事務正在等待哪些鎖;它也無法配合 SSI 使用(參見“可序列化快照隔離(SSI)”),因為 SSI 需要一套跨系統識別衝突的協議。
這些問題在一定程度上是跨異構技術執行事務的固有困難。然而,讓多個異構資料系統彼此保持一致仍是一個真實而重要的需求,所以必須另尋解決方案。辦法的確存在,下一節和“衍生資料與分散式事務”將作介紹。
資料庫內部的分散式事務
如前所述,橫跨多種異構儲存技術的分散式事務,與系統內部的分散式事務大不相同。在後者中,所有參與節點都是同一資料庫的分片,執行相同軟體。內部的分散式事務是 CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等“NewSQL”資料庫的標誌性特徵。Kafka 等訊息代理也支援內部的分散式事務85。
這些系統中有許多使用兩階段提交,來保證寫入多個分片的事務具備原子性,卻不會遇到 XA 事務的那些問題。因為其分散式事務無須對接其他技術,所以能夠避開最低公分母陷阱;系統設計者可以自由採用更可靠、更快速的協議。
XA 最嚴重的幾個問題可以這樣解決:
- 複製協調者;主協調者崩潰時,自動故障切換到另一個協調者節點;
- 允許協調者和資料分片直接通訊,不再經過應用程式碼;
- 複製參與事務的分片,降低因某個分片故障而不得不中止事務的風險;
- 將原子提交協議與分散式併發控制協議結合起來,由後者支援跨分片死鎖檢測和一致讀取。
協調者和資料庫分片通常採用共識演算法複製。第 10 章將介紹怎樣用共識演算法實現分散式事務的原子提交。這些演算法無需人工干預,就能自動從發生故障的節點切換到另一個節點,在容忍故障的同時繼續保證強一致性屬性。
分散式事務提供哪種隔離級別,取決於具體系統;但跨分片實現快照隔離和可序列化快照隔離都是可行的。其工作原理詳見本章末尾引用的論文。
再談恰好一次訊息處理
“恰好一次訊息處理”介紹過,分散式事務的一個重要用途,是保證某項操作恰好生效一次,即使處理期間發生崩潰、不得不重試也不例外。如果能跨訊息代理和資料庫原子提交事務,就可以做到:當且僅當訊息成功處理、處理產生的資料庫寫入也成功提交時,才向代理確認訊息。
不過,要實現恰好一次語義,其實並不需要這樣的分散式事務。下面的替代方案只要求資料庫本身支援事務:
- 假設每條訊息都有唯一 ID,資料庫中另有一張表,記錄已經處理過的訊息 ID。從代理取得訊息並開始處理時,先在資料庫中啟動新事務,再檢查訊息 ID。如果資料庫中已有相同 ID,就知道該訊息已經處理過,可以向代理確認並丟棄這條訊息。
- 如果資料庫中尚無該訊息 ID,就將其加入表中,然後處理訊息。處理過程可能在同一個事務中對資料庫執行更多寫入。訊息處理完成後,提交資料庫事務。
- 資料庫事務成功提交後,便可向代理確認訊息。
- 訊息成功向代理確認後,就知道代理不會再次嘗試處理同一條訊息,因此可以另開一個事務,從資料庫刪除該訊息 ID。
如果訊息處理器在提交資料庫事務前崩潰,事務會中止,訊息代理隨後重試。如果它在提交後、向代理確認前崩潰,代理同樣會重試;但重試時能在資料庫中看到訊息 ID,於是直接丟棄該訊息。如果它在確認後、從資料庫刪除訊息 ID 前崩潰,資料庫只會殘留一條舊訊息 ID,除了佔用少量空間,不會造成其他危害。重試也可能發生在原資料庫事務中止之前——例如訊息處理器與資料庫之間的通訊中斷——此時訊息 ID 表上的唯一性約束應能防止兩個併發事務插入相同 ID。
因此,實現恰好一次處理只需要資料庫內部的事務;這個用例並不要求資料庫與訊息代理之間具備原子性。把訊息 ID 記入資料庫,可以讓訊息處理具備 冪等性(idempotence),因而能夠安全重試而不重複產生副作用。Kafka Streams 等流處理框架也用類似辦法實現恰好一次語義,詳見“容錯”。
不過,資料庫內部的分散式事務仍有助於擴充套件這類模式。例如,訊息 ID 可以存放在一個分片上,訊息處理所更新的業務資料放在其他分片上,再由內部事務保證跨分片提交的原子性。
總結
事務是一層抽象,讓應用程式可以假裝某些併發問題,以及某些軟硬體故障並不存在。種類繁多的錯誤都被簡化成一次 事務中止,應用程式只需重試即可。
本章考察了許多事務有助於防範的問題。並非所有應用都會遇到其中每一種問題:訪問模式非常簡單的應用(例如只讀寫單條記錄),沒有事務或許也能應付。但面對更複雜的訪問模式,事務可以大幅減少需要考慮的潛在錯誤場景。
沒有事務,程序崩潰、網路中斷、斷電、磁碟空間耗盡、意外併發等各種錯誤場景,都可能以不同方式造成資料不一致。例如,反正規化資料很容易與源資料失去同步。缺少事務時,複雜而相互影響的訪問究竟會給資料庫帶來什麼後果,很難推理。
本章尤其深入地探討了併發控制。我們介紹了幾種廣泛使用的隔離級別,特別是 讀已提交、快照隔離(有時稱為 可重複讀)和 可序列化,並透過各種競態條件來刻畫它們。表 8-1彙總了這些內容:
| 隔離級別 | 髒讀 | 讀偏差 | 幻讀 | 丟失更新 | 寫偏差 |
|---|---|---|---|---|---|
| 讀未提交 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 讀已提交 | ✓ 防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 快照隔離 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ? 視情況 | ✗ 可能 |
| 可序列化 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 |
- 髒讀
- 一個客戶端在另一個客戶端的寫入提交前就讀到了這些資料。讀已提交及更強的隔離級別可以防止髒讀。
- 髒寫
- 一個客戶端覆蓋另一個客戶端已經寫入、但尚未提交的資料。幾乎所有事務實現都會防止髒寫。
- 讀偏差
- 客戶端在不同時間點觀察資料庫的不同部分。有些讀偏差也稱為 不可重複讀。防止這個問題最常用的辦法是快照隔離,它允許事務讀取與某個特定時刻對應的一致快照,通常以 多版本併發控制(MVCC)實現。
- 丟失更新
- 兩個客戶端併發執行讀取—修改—寫入迴圈,其中一個覆蓋另一個的寫入,卻沒有合併對方的修改,因而造成資料丟失。有些快照隔離實現會自動防止這種異常,另一些則需要手動加鎖(
SELECT FOR UPDATE)。 - 寫偏差
- 事務讀取資料,根據看到的值作出決定,再把決定寫入資料庫。但真正寫入時,作出決定所依據的前提已經不再成立。只有可序列化隔離能夠防止這種異常。
- 幻讀
- 事務讀取符合某項搜尋條件的物件,另一個客戶端隨後寫入,改變了搜尋結果。快照隔離可以防止直接的幻讀,但寫偏差場景中的幻讀需要特殊處理,例如使用索引範圍鎖。
弱隔離級別能防止其中一部分異常,卻把其他異常留給應用開發者手動處理(例如顯式加鎖)。只有可序列化隔離能防範所有這些問題。我們討論了三種實現可序列化事務的辦法:
- 嚴格按序列順序執行事務
- 如果每個事務都能極快完成(通常藉助儲存過程),而且事務吞吐量足夠低,能由單個 CPU 核處理,或者事務能夠分片執行,那麼這是一種簡單有效的選擇。
- 兩階段鎖定
- 幾十年來,這一直是實現可序列化的標準方法,但由於效能不佳,許多應用會避開它。
- 可序列化快照隔離(SSI)
- 一種相對較新的演算法,避開了前兩種方案的大部分缺點。它採用樂觀方式,允許事務不受阻塞地繼續執行;事務準備提交時再接受檢查,若執行結果不可序列化,就會中止。
最後,我們研究了如何用兩階段提交,為跨多個節點的事務實現原子性。如果所有節點都執行相同的資料庫軟體,分散式事務往往能良好運轉;但一旦橫跨不同儲存技術(使用 XA 事務),2PC 就會問題重重:它對協調者和驅動事務的應用程式碼中的故障非常敏感,與併發控制機制也難以配合。所幸,冪等性可以在不要求跨異構儲存原子提交的情況下,保證恰好一次語義。後續章節還會進一步討論這個主題。
本章的示例採用關係資料模型。不過,正如“多物件事務的需求”所說,無論採用哪一種資料模型,事務都是一項很有價值的資料庫功能。
參考文獻
Steven J. Murdoch. What went wrong with Horizon: learning from the Post Office Trial. benthamsgaze.org, July 2021. Archived at perma.cc/CNM4-553F ↩︎
Donald D. Chamberlin, Morton M. Astrahan, Michael W. Blasgen, James N. Gray, W. Frank King, Bruce G. Lindsay, Raymond Lorie, James W. Mehl, Thomas G. Price, Franco Putzolu, Patricia Griffiths Selinger, Mario Schkolnick, Donald R. Slutz, Irving L. Traiger, Bradford W. Wade, and Robert A. Yost. A History and Evaluation of System R. Communications of the ACM, volume 24, issue 10, pages 632–646, October 1981. doi:10.1145/358769.358784 ↩︎
Jim N. Gray, Raymond A. Lorie, Gianfranco R. Putzolu, and Irving L. Traiger. Granularity of Locks and Degrees of Consistency in a Shared Data Base. in Modelling in Data Base Management Systems: Proceedings of the IFIP Working Conference on Modelling in Data Base Management Systems, edited by G. M. Nijssen, pages 364–394, Elsevier/North Holland Publishing, 1976. Also in Readings in Database Systems, 4th edition, edited by Joseph M. Hellerstein and Michael Stonebraker, MIT Press, 2005. ISBN: 978-0-262-69314-1 ↩︎ ↩︎ ↩︎ ↩︎
Kapali P. Eswaran, Jim N. Gray, Raymond A. Lorie, and Irving L. Traiger. The Notions of Consistency and Predicate Locks in a Database System. Communications of the ACM, volume 19, issue 11, pages 624–633, November 1976. doi:10.1145/360363.360369 ↩︎ ↩︎ ↩︎
Rebecca Taft, Irfan Sharif, Andrei Matei, Nathan VanBenschoten, Jordan Lewis, Tobias Grieger, Kai Niemi, Andy Woods, Anne Birzin, Raphael Poss, Paul Bardea, Amruta Ranade, Ben Darnell, Bram Gruneir, Justin Jaffray, Lucy Zhang, and Peter Mattis. CockroachDB: The Resilient Geo-Distributed SQL Database. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1493–1509, June 2020. doi:10.1145/3318464.3386134 ↩︎ ↩︎ ↩︎
Dongxu Huang, Qi Liu, Qiu Cui, Zhuhe Fang, Xiaoyu Ma, Fei Xu, Li Shen, Liu Tang, Yuxing Zhou, Menglong Huang, Wan Wei, Cong Liu, Jian Zhang, Jianjun Li, Xuelian Wu, Lingyu Song, Ruoxi Sun, Shuaipeng Yu, Lei Zhao, Nicholas Cameron, Liquan Pei, and Xin Tang. TiDB: a Raft-based HTAP database. Proceedings of the VLDB Endowment, volume 13, issue 12, pages 3072–3084. doi:10.14778/3415478.3415535 ↩︎ ↩︎
James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012. ↩︎ ↩︎
Jingyu Zhou, Meng Xu, Alexander Shraer, Bala Namasivayam, Alex Miller, Evan Tschannen, Steve Atherton, Andrew J. Beamon, Rusty Sears, John Leach, Dave Rosenthal, Xin Dong, Will Wilson, Ben Collins, David Scherer, Alec Grieser, Young Liu, Alvin Moore, Bhaskar Muppana, Xiaoge Su, and Vishesh Yadav. FoundationDB: A Distributed Unbundled Transactional Key Value Store. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457559 ↩︎ ↩︎ ↩︎ ↩︎
Theo Härder and Andreas Reuter. Principles of Transaction-Oriented Database Recovery. ACM Computing Surveys, volume 15, issue 4, pages 287–317, December 1983. doi:10.1145/289.291 ↩︎
Peter Bailis, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. HAT, not CAP: Towards Highly Available Transactions. At 14th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2013. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Armando Fox, Steven D. Gribble, Yatin Chawathe, Eric A. Brewer, and Paul Gauthier. Cluster-Based Scalable Network Services. At 16th ACM Symposium on Operating Systems Principles (SOSP), October 1997. doi:10.1145/268998.266662 ↩︎
Tony Andrews. Enforcing Complex Constraints in Oracle. tonyandrews.blogspot.co.uk, October 2004. Archived at archive.org ↩︎ ↩︎
Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman. Concurrency Control and Recovery in Database Systems. Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at microsoft.com. ↩︎ ↩︎ ↩︎
Alan Fekete, Dimitrios Liarokapis, Elizabeth O’Neil, Patrick O’Neil, and Dennis Shasha. Making Snapshot Isolation Serializable. ACM Transactions on Database Systems, volume 30, issue 2, pages 492–528, June 2005. doi:10.1145/1071610.1071615 ↩︎ ↩︎ ↩︎
Mai Zheng, Joseph Tucek, Feng Qin, and Mark Lillibridge. Understanding the Robustness of SSDs Under Power Fault. At 11th USENIX Conference on File and Storage Technologies (FAST), February 2013. ↩︎
Laurie Denness. SSDs: A Gift and a Curse. laur.ie, June 2015. Archived at perma.cc/6GLP-BX3T ↩︎
Adam Surak. When Solid State Drives Are Not That Solid. blog.algolia.com, June 2015. Archived at perma.cc/CBR9-QZEE ↩︎
Hewlett Packard Enterprise. Bulletin: (Revision) HPE SAS Solid State Drives - Critical Firmware Upgrade Required for Certain HPE SAS Solid State Drive Models to Prevent Drive Failure at 32,768 Hours of Operation. support.hpe.com, November 2019. Archived at perma.cc/CZR4-AQBS ↩︎
Craig Ringer et al. PostgreSQL’s handling of fsync() errors is unsafe and risks data loss at least on XFS. Email thread on pgsql-hackers mailing list, postgresql.org, March 2018. Archived at perma.cc/5RKU-57FL ↩︎
Anthony Rebello, Yuvraj Patel, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Can Applications Recover from fsync Failures? At USENIX Annual Technical Conference (ATC), July 2020. ↩︎
Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Crash Consistency: Rethinking the Fundamental Abstractions of the File System. ACM Queue, volume 13, issue 7, pages 20–28, July 2015. doi:10.1145/2800695.2801719 ↩︎
Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications. At 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2014. ↩︎ ↩︎
Chris Siebenmann. Unix’s File Durability Problem. utcc.utoronto.ca, April 2016. Archived at perma.cc/VSS8-5MC4 ↩︎
Aishwarya Ganesan, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Redundancy Does Not Imply Fault Tolerance: Analysis of Distributed Storage Reactions to Single Errors and Corruptions. At 15th USENIX Conference on File and Storage Technologies (FAST), February 2017. ↩︎
Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. An Analysis of Data Corruption in the Storage Stack. At 6th USENIX Conference on File and Storage Technologies (FAST), February 2008. ↩︎
Bianca Schroeder, Raghav Lagisetty, and Arif Merchant. Flash Reliability in Production: The Expected and the Unexpected. At 14th USENIX Conference on File and Storage Technologies (FAST), February 2016. ↩︎
Don Allison. SSD Storage – Ignorance of Technology Is No Excuse. blog.korelogic.com, March 2015. Archived at perma.cc/9QN4-9SNJ ↩︎
Gordon Mah Ung. Debunked: Your SSD won’t lose data if left unplugged after all. pcworld.com, May 2015. Archived at perma.cc/S46H-JUDU ↩︎
Martin Kleppmann. Hermitage: Testing the ‘I’ in ACID. martin.kleppmann.com, November 2014. Archived at perma.cc/KP2Y-AQGK ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Todd Warszawski and Peter Bailis. ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications. At ACM International Conference on Management of Data (SIGMOD), May 2017. doi:10.1145/3035918.3064037 ↩︎ ↩︎
Tristan D’Agosta. BTC Stolen from Poloniex. bitcointalk.org, March 2014. Archived at perma.cc/YHA6-4C5D ↩︎
bitcointhief2. How I Stole Roughly 100 BTC from an Exchange and How I Could Have Stolen More! reddit.com, February 2014. Archived at archive.org ↩︎
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. ↩︎ ↩︎
Michael Melanson. Transactions: The Limits of Isolation. michaelmelanson.net, November 2014. Archived at perma.cc/RG5R-KMYZ ↩︎
Edward Kim. How ACH works: A developer perspective — Part 1. engineering.gusto.com, April 2014. Archived at perma.cc/7B2H-PU94 ↩︎
Hal Berenson, Philip A. Bernstein, Jim N. Gray, Jim Melton, Elizabeth O’Neil, and Patrick O’Neil. A Critique of ANSI SQL Isolation Levels. At ACM International Conference on Management of Data (SIGMOD), May 1995. doi:10.1145/568271.223785 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Atul Adya. Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions. PhD Thesis, Massachusetts Institute of Technology, March 1999. Archived at perma.cc/E97M-HW5Q ↩︎ ↩︎
Peter Bailis, Aaron Davidson, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Highly Available Transactions: Virtues and Limitations. At 40th International Conference on Very Large Data Bases (VLDB), September 2014. ↩︎ ↩︎ ↩︎
Natacha Crooks, Youer Pu, Lorenzo Alvisi, and Allen Clement. Seeing is Believing: A Client-Centric Specification of Database Isolation. At ACM Symposium on Principles of Distributed Computing (PODC), pages 73–82, July 2017. doi:10.1145/3087801.3087802 ↩︎
Bruce Momjian. MVCC Unmasked. momjian.us, July 2014. Archived at perma.cc/KQ47-9GYB ↩︎ ↩︎ ↩︎
Peter Alvaro and Kyle Kingsbury. MySQL 8.0.34. jepsen.io, December 2023. Archived at perma.cc/HGE2-Z878 ↩︎ ↩︎ ↩︎
Egor Rogov. PostgreSQL 14 Internals. postgrespro.com, April 2023. Archived at perma.cc/FRK2-D7WB ↩︎
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩︎ ↩︎
Rohan Reddy Alleti. Internals of MVCC in Postgres: Hidden costs of Updates vs Inserts. medium.com, March 2025. Archived at perma.cc/3ACX-DFXT ↩︎
Andy Pavlo and Bohan Zhang. The Part of PostgreSQL We Hate the Most. cs.cmu.edu, April 2023. Archived at perma.cc/XSP6-3JBN ↩︎ ↩︎
Yingjun Wu, Joy Arulraj, Jiexi Lin, Ran Xian, and Andrew Pavlo. An empirical evaluation of in-memory multi-version concurrency control. Proceedings of the VLDB Endowment, volume 10, issue 7, pages 781–792, March 2017. doi:10.14778/3067421.3067427 ↩︎ ↩︎
Nikita Prokopov. Unofficial Guide to Datomic Internals. tonsky.me, May 2014. ↩︎
Daniil Svetlov. A Practical Guide to Taming Postgres Isolation Anomalies. dansvetlov.me, March 2025. Archived at perma.cc/L7LE-TDLS ↩︎
Nate Wiger. An Atomic Rant. nateware.com, February 2010. Archived at perma.cc/5ZYB-PE44 ↩︎
James Coglan. Reading and writing, part 3: web applications. blog.jcoglan.com, October 2020. Archived at perma.cc/A7EK-PJVS ↩︎ ↩︎
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 ↩︎ ↩︎
Jaana Dogan. Things I Wished More Developers Knew About Databases. rakyll.medium.com, April 2020. Archived at perma.cc/6EFK-P2TD ↩︎
Michael J. Cahill, Uwe Röhm, and Alan Fekete. Serializable Isolation for Snapshot Databases. At ACM International Conference on Management of Data (SIGMOD), June 2008. doi:10.1145/1376616.1376690 ↩︎ ↩︎
Dan R. K. Ports and Kevin Grittner. Serializable Snapshot Isolation in PostgreSQL. At 38th International Conference on Very Large Databases (VLDB), August 2012. ↩︎ ↩︎ ↩︎ ↩︎
Douglas B. Terry, Marvin M. Theimer, Karin Petersen, Alan J. Demers, Mike J. Spreitzer and Carl H. Hauser. Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System. At 15th ACM Symposium on Operating Systems Principles (SOSP), December 1995. doi:10.1145/224056.224070 ↩︎
Hans-Jürgen Schönig. Constraints over multiple rows in PostgreSQL. cybertec-postgresql.com, June 2021. Archived at perma.cc/2TGH-XUPZ ↩︎
Michael Stonebraker, Samuel Madden, Daniel J. Abadi, Stavros Harizopoulos, Nabil Hachem, and Pat Helland. The End of an Architectural Era (It’s Time for a Complete Rewrite). At 33rd International Conference on Very Large Data Bases (VLDB), September 2007. ↩︎
John Hugg. H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures. At Data @Scale Boston, November 2014. ↩︎
Robert Kallman, Hideaki Kimura, Jonathan Natkins, Andrew Pavlo, Alexander Rasin, Stanley Zdonik, Evan P. C. Jones, Samuel Madden, Michael Stonebraker, Yang Zhang, John Hugg, and Daniel J. Abadi. H-Store: A High-Performance, Distributed Main Memory Transaction Processing System. Proceedings of the VLDB Endowment, volume 1, issue 2, pages 1496–1499, August 2008. ↩︎ ↩︎
Rich Hickey. The Architecture of Datomic. infoq.com, November 2012. Archived at perma.cc/5YWU-8XJK ↩︎
John Hugg. Debunking Myths About the VoltDB In-Memory Database. dzone.com, May 2014. Archived at perma.cc/2Z9N-HPKF ↩︎ ↩︎
Xinjing Zhou, Viktor Leis, Xiangyao Yu, and Michael Stonebraker. OLTP Through the Looking Glass 16 Years Later: Communication is the New Bottleneck. At 15th Annual Conference on Innovative Data Systems Research (CIDR), January 2025. ↩︎
Xinjing Zhou, Xiangyao Yu, Goetz Graefe, and Michael Stonebraker. Lotus: scalable multi-partition transactions on single-threaded partitioned databases. Proceedings of the VLDB Endowment (PVLDB), volume 15, issue 11, pages 2939–2952, July 2022. doi:10.14778/3551793.3551843 ↩︎
Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton. Architecture of a Database System. Foundations and Trends in Databases, volume 1, issue 2, pages 141–259, November 2007. doi:10.1561/1900000002 ↩︎
Michael J. Cahill. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, July 2009. Archived at perma.cc/727J-NTMP ↩︎
Cristian Diaconu, Craig Freedman, Erik Ismert, Per-Åke Larson, Pravin Mittal, Ryan Stonecipher, Nitin Verma, and Mike Zwilling. Hekaton: SQL Server’s Memory-Optimized OLTP Engine. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1243–1254, June 2013. doi:10.1145/2463676.2463710 ↩︎
Thomas Neumann, Tobias Mühlbauer, and Alfons Kemper. Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 677–689, May 2015. doi:10.1145/2723372.2749436 ↩︎ ↩︎
D. Z. Badal. Correctness of Concurrency Control and Implications in Distributed Databases. At 3rd International IEEE Computer Software and Applications Conference (COMPSAC), November 1979. doi:10.1109/CMPSAC.1979.762563 ↩︎
Rakesh Agrawal, Michael J. Carey, and Miron Livny. Concurrency Control Performance Modeling: Alternatives and Implications. ACM Transactions on Database Systems (TODS), volume 12, issue 4, pages 609–654, December 1987. doi:10.1145/32204.32220 ↩︎
Marc Brooker. Snapshot Isolation vs Serializability. brooker.co.za, December 2024. Archived at perma.cc/5TRC-CR5G ↩︎
B. G. Lindsay, P. G. Selinger, C. Galtieri, J. N. Gray, R. A. Lorie, T. G. Price, F. Putzolu, I. L. Traiger, and B. W. Wade. Notes on Distributed Databases. IBM Research, Research Report RJ2571(33471), July 1979. Archived at perma.cc/EPZ3-MHDD ↩︎
C. Mohan, Bruce G. Lindsay, and Ron Obermarck. Transaction Management in the R* Distributed Database Management System. ACM Transactions on Database Systems, volume 11, issue 4, pages 378–396, December 1986. doi:10.1145/7239.7266 ↩︎
X/Open Company Ltd. Distributed Transaction Processing: The XA Specification. Technical Standard XO/CAE/91/300, December 1991. ISBN: 978-1-872-63024-3, archived at perma.cc/Z96H-29JB ↩︎ ↩︎ ↩︎
Ivan Silva Neto and Francisco Reverbel. Lessons Learned from Implementing WS-Coordination and WS-AtomicTransaction. At 7th IEEE/ACIS International Conference on Computer and Information Science (ICIS), May 2008. doi:10.1109/ICIS.2008.75 ↩︎
James E. Johnson, David E. Langworthy, Leslie Lamport, and Friedrich H. Vogt. Formal Specification of a Web Services Protocol. At 1st International Workshop on Web Services and Formal Methods (WS-FM), February 2004. doi:10.1016/j.entcs.2004.02.022 ↩︎
Jim Gray. The Transaction Concept: Virtues and Limitations. At 7th International Conference on Very Large Data Bases (VLDB), September 1981. ↩︎
Dale Skeen. Nonblocking Commit Protocols. At ACM International Conference on Management of Data (SIGMOD), April 1981. doi:10.1145/582318.582339 ↩︎
Gregor Hohpe. Your Coffee Shop Doesn’t Use Two-Phase Commit. IEEE Software, volume 22, issue 2, pages 64–66, March 2005. doi:10.1109/MS.2005.52 ↩︎
Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎
Jonathan Oliver. My Beef with MSDTC and Two-Phase Commits. blog.jonathanoliver.com, April 2011. Archived at perma.cc/K8HF-Z4EN ↩︎
Oren Eini (Ahende Rahien). The Fallacy of Distributed Transactions. ayende.com, July 2014. Archived at perma.cc/VB87-2JEF ↩︎
Clemens Vasters. Transactions in Windows Azure (with Service Bus) – An Email Discussion. learn.microsoft.com, July 2012. Archived at perma.cc/4EZ9-5SKW ↩︎
Ajmer Dhariwal. Orphaned MSDTC Transactions (-2 spids). eraofdata.com, December 2008. Archived at perma.cc/YG6F-U34C ↩︎
Paul Randal. Real World Story of DBCC PAGE Saving the Day. sqlskills.com, June 2013. Archived at perma.cc/2MJN-A5QH ↩︎
Guozhang Wang, Lei Chen, Ayusman Dikshit, Jason Gustafson, Boyang Chen, Matthias J. Sax, John Roesler, Sophie Blee-Goldman, Bruno Cadonna, Apurva Mehta, Varun Madan, and Jun Rao. Consistency and Completeness: Rethinking Distributed Stream Processing in Apache Kafka. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457556 ↩︎
9 分散式系統的麻煩

意外這東西挺有意思:你沒碰上之前,它就從來不會發生。
A.A. 米爾恩,《小熊維尼和老灰驢的家》(1928)
正如 “可靠性與容錯” 中所討論的,要使一個系統可靠,就要確保即使出了問題(即發生故障),整個系統仍能繼續工作。然而,要預見並處理所有可能的故障並非易事。開發者很容易把注意力主要放在正常路徑上(畢竟大多數時候一切都執行良好!),而忽略會帶來大量邊界情況的故障。
如果希望系統在發生故障時依然可靠,就必須從根本上轉變思維方式,把注意力放在各種可能出錯的地方,即使出錯的機率很低。某件事只有百萬分之一的機率出錯並不意味著可以不管:系統足夠大時,百萬分之一的事件每天都會發生。經驗豐富的系統運維人員會告訴你,任何 可能 出錯的事情,終究都會 出錯。
而且,使用 分散式系統(distributed system)與在單臺計算機上編寫軟體有著根本區別——最主要的區別,就是事情有了許多新奇而刺激的出錯方式 1 2。本章將帶你領略實踐中會遇到的問題,並幫助你理解哪些東西可以依賴,哪些不可以。
為了理解我們面對的挑戰,接下來讓我們把悲觀主義發揮到極致,考察分散式系統裡可能出錯的種種事情。我們將討論網路問題(“不可靠的網路”),以及時鐘和時序問題(“不可靠的時鐘”)。這些問題造成的後果往往令人迷失方向,因此我們還要探討如何認識分散式系統的狀態,以及如何推斷已經發生過的事情(“知識、真相和謊言”)。隨後在 第 10 章 中,我們將透過一些例子看看,面對這些故障時如何實現容錯。
故障與部分失效
當你在一臺計算機上編寫程式時,它通常會以相當可預測的方式執行:要麼正常工作,要麼徹底罷工。有缺陷的軟體可能會讓人覺得計算機偶爾也會“狀態不好”(而重啟往往能解決問題),但這通常只是軟體寫得糟糕所造成的表象。
從根本上說,單臺計算機上的軟體不應該時靈時不靈:只要硬體正常,同樣的操作總會產生同樣的結果(也就是 確定性的,deterministic)。如果硬體出了問題(例如記憶體損壞或聯結器鬆動),後果通常是整個系統失效(例如核心恐慌、“藍色畫面宕機”或無法啟動)。一臺執行著良好軟體的計算機,通常要麼功能完好,要麼完全失效,而不會停留在兩者之間。
這是計算機設計中的有意選擇:發生內部故障時,我們寧願讓計算機徹底崩潰,也不願讓它返回錯誤結果,因為後者既難處理又容易造成混亂。於是,計算機把其底層模糊而混亂的物理現實隱藏起來,呈現出一個以數學般的完美方式執行的理想化系統模型。CPU 指令每次都會做同樣的事情;寫入記憶體或磁碟的資料會原樣保留,不會隨機損壞。正如 “硬體與軟體故障” 中所討論的,事實並非真的如此——現實中,資料的確可能在沒有任何警告的情況下損壞,CPU 偶爾也會悄無聲息地給出錯誤結果——只不過這些情況足夠罕見,通常可以忽略。
當軟體執行在透過網路連線的多臺計算機上時,情況就截然不同了。分散式系統中的故障要頻繁得多,我們再也無法視而不見,只能直面物理世界混亂的現實。而在物理世界中,可能出錯的事情多得驚人,下面這段軼事便是一個寫照 3:
在我有限的從業經歷中,我處理過單個資料中心(DC)內長期存在的網路分割槽、PDU(配電單元)故障、交換機故障、整個機架意外斷電重啟、整個資料中心骨幹網故障、整個資料中心停電,以及一名低血糖司機開著福特皮卡撞進資料中心的 HVAC(供暖、通風與空調)系統。而我甚至還不是運維人員。
—— 柯達黑爾
在分散式系統中,即使其他部分工作正常,系統的某些部分也很可能以不可預知的方式發生故障。這叫作 部分失效(partial failure)。棘手之處在於,部分失效是 非確定性的(nondeterministic):任何涉及多個節點及其網路的操作,有時能夠成功,有時卻會莫名其妙地失敗。正如我們稍後會看到的,你甚至可能根本 不知道 某件事究竟成功了沒有!
正是這種非確定性和部分失效的可能性,讓分散式系統如此難以駕馭 4。不過,如果分散式系統能夠容忍部分失效,也會由此獲得強大的能力。例如,你可以執行滾動升級:每次重啟一個節點來安裝軟體更新,同時讓整個系統始終不間斷地工作。因此,藉助容錯,我們可以用不可靠的元件構建出比單節點系統更可靠的分散式系統。
不過,在實現容錯之前,我們需要進一步瞭解究竟要容忍哪些故障。應當考慮儘可能廣泛的故障——包括那些看來不太可能發生的故障——並在測試環境中人為製造這些情形,觀察系統會怎樣反應。在分散式系統中,多一些懷疑、悲觀和偏執總會有所回報。
不可靠的網路
正如 “共享記憶體、共享磁碟與無共享架構” 中所討論的,本書關注的分散式系統大多是 無共享系統,也就是一組透過網路連線的機器。網路是這些機器彼此通訊的唯一途徑——我們假定每臺機器都有自己的記憶體和磁碟,一臺機器無法直接訪問另一臺機器的記憶體或磁碟,只能透過網路向服務傳送請求。即使儲存本身是共享的(例如 Amazon S3),機器也仍然要透過網路與共享儲存服務通訊。
網際網路和資料中心裡的大多數內部網路(通常是乙太網)都是 非同步分組網路(asynchronous packet network)。在這種網路中,一個節點可以向另一個節點傳送訊息(即資料包),但網路既不保證訊息何時到達,也不保證它一定能夠到達。如果你發出請求並等待響應,可能會發生許多問題(其中一些如 圖 9-1 所示):
- 請求可能已經丟失(或許有人拔掉了網線)。
- 請求可能還在佇列中等待,稍後才會送達(或許網路或接收方過載了)。
- 遠端節點可能已經失效(或許它崩潰了,或是被關閉了)。
- 遠端節點可能只是暫時停止響應(或許它正經歷一次漫長的垃圾回收暫停;參見 “程序暫停”),稍後又會恢復響應。
- 遠端節點可能已經處理了請求,但響應在網路中丟失了(或許某臺網路交換機配置有誤)。
- 遠端節點可能已經處理了請求,但響應被延遲,稍後才會送達(或許網路或你自己的機器過載了)。

傳送方甚至無法判斷資料包是否送達:唯一的辦法是由接收方發回響應訊息,而這個響應同樣可能丟失或延遲。在非同步網路中,這些情形無法區分:你掌握的唯一資訊只是“尚未收到響應”。向另一個節點傳送請求卻沒有收到響應時,不可能 判斷原因究竟是什麼。
處理這個問題的慣常辦法是設定 超時(timeout):等待一段時間後便放棄,並假定響應不會再來。然而,即使發生超時,你仍然不知道遠端節點是否收到了請求(如果請求仍在某處排隊,那麼即便傳送方已經放棄,它仍可能在稍後送達接收方)。
TCP 的侷限性
網路資料包有大小上限(通常只有幾千位元組),但許多應用程式需要傳送無法裝進單個資料包的訊息,例如請求和響應。這類應用程式通常使用 TCP(傳輸控制協議)建立一條 連線(connection),把較大的資料流拆成一個個資料包,再在接收端重新組裝起來。
下面關於 TCP 的大部分討論,也適用於較新的替代方案 QUIC、WebRTC 使用的流控制傳輸協議(SCTP)、BitTorrent 的 uTP 協議,以及其他傳輸協議。關於它與 UDP 的比較,參見 “TCP 與 UDP”。
TCP 常被說成能提供“可靠”的傳輸,這裡的“可靠”是指:它能檢測並重傳丟失的資料包,發現順序錯亂的資料包並將其恢復為正確順序,還能用簡單的校驗和檢測資料包損壞。TCP 也會判斷應當以多快的速度傳送資料,既儘可能快速傳輸,又不至於壓垮網路或接收節點;這叫作 擁塞控制(congestion control)、流量控制(flow control)或 背壓(backpressure)5。
當你把資料寫入套接字來“傳送”時,資料其實不會立即發出,只會先進入作業系統管理的緩衝區。擁塞控制演算法判斷目前有能力傳送資料包後,才會從緩衝區取出一個資料包大小的資料,交給網路介面。資料包會經過若干交換機和路由器,最終由接收節點的作業系統把資料放進接收緩衝區,並向傳送方發回確認包。直到這時,接收端作業系統才會通知應用程式又有資料到達 6。
既然 TCP 提供了“可靠性”,是不是就不必再操心網路不可靠了?遺憾的是,並非如此。如果在一定的超時時間內沒有收到確認,TCP 會認定資料包必定已經丟失,但它同樣無法判斷丟掉的究竟是發出的資料包,還是返回的確認包。TCP 雖然可以重發,卻不能保證重發的資料包一定能夠到達。如果網線被拔了,TCP 可沒法替你把它插回去。最終,經過可配置的超時時間後,TCP 會放棄重試,並嚮應用程式報告錯誤。
如果 TCP 連線因錯誤而關閉——或許是遠端節點崩潰了,也可能是網路中斷了——你無法知道遠端節點究竟處理了多少資料 6。即使 TCP 已經確認某個資料包送達,也只說明遠端節點的作業系統核心收到了它;應用程式仍可能在處理這份資料之前就崩潰。如果要確認請求成功,必須由應用程式本身明確返回成功響應 7。
儘管如此,TCP 依然非常有用,因為它讓我們能夠方便地收發無法裝進單個資料包的訊息。建立 TCP 連線後,還可以透過同一條連線傳送多個請求和響應。通常的做法是先傳送一個頭部,註明緊隨其後的訊息有多少位元組,再傳送訊息本身。HTTP 和許多 RPC 協議(參見 “流經服務的資料流:REST 與 RPC”)都是這樣工作的。
實踐中的網路故障
我們建設計算機網路已有幾十年,按說早該找到讓網路可靠的辦法了。遺憾的是,我們至今仍未成功。系統性研究與大量軼事證據都表明,即使在由一家公司運營的資料中心這類受控環境中,網路問題也可能頻繁得出人意料 8:
- 一項針對中型資料中心的研究發現,每月大約會發生 12 次網路故障,其中一半會斷開一臺機器,另一半則會斷開整個機架 9。
- 另一項研究測量了架頂交換機、匯聚交換機和負載均衡器等元件的失效率 10。研究發現,增加冗餘網路裝置並不能像預想的那樣大幅減少故障,因為它防不住人為錯誤(例如交換機配置錯誤),而人為錯誤正是停機的一大主因。
- 廣域光纖鏈路的中斷曾被歸咎於奶牛 11、海狸 12 和鯊魚 13(不過隨著海底電纜的防護改善,鯊魚咬壞電纜的事件已經越來越少 14)。當然,人類也難辭其咎:誤配置 15、盜割電纜 16 和蓄意破壞 17 都曾造成事故。
- 在不同雲區域之間的通訊中,高百分位數的往返時間最長可達數 分鐘 18。即使在同一個資料中心內,交換機軟體升級出現問題並觸發網路拓撲重配置時,資料包也可能延遲一分鐘以上 19。因此,我們必須假定訊息可能遭到任意長時間的延遲。
- 有時通訊只會部分中斷,能否連通取決於通訊雙方是誰。例如,A 與 B 可以通訊,B 與 C 也可以通訊,但 A 與 C 卻無法通訊 20 21。還有些故障更出人意料,比如某個網路介面有時會丟棄所有入站資料包,卻仍能正常發出資料包 22:網路鏈路在一個方向上可用,並不保證反方向也可用。
- 即使網路中斷只持續了很短時間,其後續影響也可能遠遠長於最初的問題 8 20 23。
當網路的一部分因網路故障而與其餘部分隔絕時,這種情況有時稱為 網路分割槽(network partition)或 網路分裂(netsplit),但它與其他型別的網路中斷並沒有本質區別。網路分割槽與儲存系統的分片無關,後者有時也稱為 分割槽(參見 第 7 章)。
即使你的環境很少遇到網路故障,故障 有可能 發生這一事實也意味著軟體必須能夠處理它。只要透過網路通訊,就有可能失敗——這一點無可迴避。
如果沒有明確定義並測試網路故障的處理方式,後果可能糟到沒有下限。例如,即使網路已經恢復,叢集仍可能陷入死鎖,從此無法再處理請求 24;甚至還可能刪掉你的全部資料 25。一旦軟體落入設計者未曾預料的情形,它就可能做出任何出人意料的事情。
處理網路故障不一定意味著要 容忍 它。如果網路通常相當可靠,那麼在發生網路問題時直接向使用者顯示錯誤訊息,也可以是一種合理策略。不過,你必須知道軟體會如何應對網路問題,並確保系統能夠從中恢復。可以考慮有意觸發網路問題,測試系統會如何反應(這叫作 故障注入,fault injection;參見 “故障注入”)。
故障檢測
許多系統需要自動檢測發生故障的節點。例如:
- 負載均衡器需要停止向已經宕機的節點傳送請求(也就是把它 移出輪詢池)。
- 在採用單主複製的分散式資料庫中,如果領導者失效,就需要把某個追隨者提升為新的領導者(參見 “處理節點故障”)。
遺憾的是,網路的不確定性讓人很難判斷節點是否仍在工作。在某些特定情況下,你也許能收到明確表明某處出了問題的反饋:
- 如果執行節點的機器可以訪問,但目標埠沒有程序監聽(例如程序已經崩潰),作業系統會返回
RST或FIN資料包,從而關閉或拒絕 TCP 連線。 - 如果節點程序崩潰(或被管理員終止),但節點的作業系統仍在執行,可以由指令碼把崩潰訊息通知其他節點,讓另一個節點不必等待超時便能迅速接管。例如,HBase 就採用這種方式 26。
- 如果你能訪問資料中心裡網路交換機的管理介面,就可以查詢交換機,在硬體層面檢測鏈路故障(例如遠端機器是否已經斷電)。但如果你透過網際網路連線、身處無法訪問交換機本身的共享資料中心,或是網路問題使管理介面也無法訪問,這種辦法就行不通了。
- 如果路由器確信你要連線的 IP 地址不可達,它可能會返回一個 ICMP“目標不可達”資料包。不過,路由器也沒有什麼神奇的故障檢測能力——它同樣受到網路中其他參與者所面對的那些限制。
能夠迅速得知遠端節點宕機固然有用,但不能指望總有這樣的反饋。發生問題時,你或許會從協議棧的某一層收到錯誤響應,但通常必須假定自己什麼響應也收不到。你可以重試幾次,等待超時;如果超時時間內一直沒有回覆,最終便宣告該節點已經死亡。
超時和無界延遲
如果超時是檢測故障的唯一可靠方法,那麼超時時間應該設為多長?遺憾的是,這個問題沒有簡單答案。
較長的超時意味著要等很久才能宣告節點死亡(在此期間,使用者可能只能等待,或不斷看到錯誤訊息)。較短的超時可以更快發現故障,卻更容易把只是暫時變慢的節點(例如節點或網路出現負載峰值)誤判為死亡。
過早宣告節點死亡會帶來麻煩:如果節點其實仍然活著,並且正在執行某個操作(例如傳送電子郵件),此時另一個節點又接管了它的工作,同一個操作最終可能執行兩次。我們將在 “知識、真相和謊言”、第 10 章 以及 “資料庫的端到端原則” 中更詳細地討論這個問題。
宣告節點死亡後,它承擔的職責就要轉移給其他節點,這會額外增加其他節點和網路的負擔。如果系統本來就在高負載下勉力支撐,過早宣告節點死亡可能讓局面進一步惡化。尤其可能出現這樣的情況:節點其實沒有死,只是過載導致響應緩慢;把它的負載轉移給其他節點,又可能引發級聯失效(極端情況下,所有節點互相宣告對方死亡,整個系統徹底停止工作——參見 “當過載系統無法恢復時”)。
設想一個虛構的系統,其網路保證資料包的最大延遲:每個資料包要麼在時間 d 以內送達,要麼丟失,絕不會在超過 d 之後才送達。再假定我們能夠保證,任何尚未失效的節點總會在時間 r 以內處理完請求。在這種情況下,每個成功的請求都能保證在 2 d + r 以內收到響應;如果過了這麼久仍未收到響應,就可以斷定網路或遠端節點沒有正常工作。假如這些保證真的成立,那麼 2 d + r 就是合理的超時時間。
遺憾的是,我們使用的大多數系統都不具備上述任何一項保證:非同步網路具有 無界延遲(unbounded delay;也就是說,它會盡快嘗試送達資料包,但資料包所需的傳輸時間沒有上限),大多數伺服器實現也不能保證一定在某個最長時間內處理完請求(參見 “響應時間保證”)。對故障檢測而言,系統僅僅在大多數時候執行得很快還不夠:如果超時時間設得很短,一次短暫的往返時間尖峰就足以打亂整個系統。
網路擁塞與排隊
開車出行時,交通擁堵往往是造成行程時間波動的最大因素。同樣,在計算機網路上,資料包延遲的變化通常也是排隊造成的 27:
- 如果多個節點同時向同一個目的地傳送資料包,網路交換機就必須讓這些資料包排隊,再逐一送入通往目的地的網路鏈路(如 圖 9-2 所示)。網路鏈路繁忙時,資料包可能需要等待一段時間才能獲得傳送機會,這叫作 網路擁塞(network congestion)。如果流入的資料太多,交換機佇列被塞滿,資料包就會被丟棄,因而必須重發——即使網路本身仍在正常工作。
- 資料包抵達目標機器時,如果所有 CPU 核心都在忙,作業系統會把來自網路的請求放進佇列,直到應用程式有能力處理為止。等待時間取決於機器的負載,可能任意之長 28。
- 在虛擬化環境中,當另一臺虛擬機器佔用某個 CPU 核心時,正在執行的作業系統常常會暫停數十毫秒。在此期間,虛擬機器無法消費任何網路資料,因此虛擬機器監控器會將流入的資料排隊(緩衝)29,進一步增大網路延遲的波動。
- 如前所述,為避免網路過載,TCP 會限制資料的傳送速率。這意味著資料甚至還沒進入網路,就已經在傳送方額外排了一次隊。

此外,當 TCP 檢測到資料包丟失並自動重傳時,應用程式雖然不會直接看到丟包,卻會感受到由此造成的延遲:先等待超時,再等待重傳的資料包得到確認。
一些對延遲敏感的應用程式(例如視訊會議和 IP 語音,即 VoIP)使用 UDP 而非 TCP。這是在可靠性與延遲波動之間做出的權衡:UDP 不進行流量控制,也不重傳丟失的資料包,因而避開了網路延遲發生波動的部分原因(不過,它仍會受到交換機排隊和排程延遲的影響)。
如果資料一旦延遲就不再有價值,UDP 是很好的選擇。例如在 VoIP 通話中,等到音訊該從揚聲器播放時,通常已經來不及重傳丟失的資料包。這種情況下,重傳毫無意義——應用程式只能用靜音填補丟失資料包對應的時間片(聽起來就是聲音短暫中斷),然後繼續播放後面的音訊。真正的重試發生在人這一層。(“能再說一遍嗎?剛才聲音斷了一下。”)
上述因素都會造成網路延遲的波動。當系統接近最大容量時,排隊延遲的變化範圍尤其大:擁有充足餘量的系統可以輕鬆排空佇列,而利用率很高的系統則可能迅速積起長隊。
在公共雲和多租戶資料中心中,許多客戶共享同一批資源:網路鏈路和交換機是共享的,甚至每臺機器的網路介面和 CPU(使用虛擬機器時)也是共享的。處理大量資料時,可能耗盡網路鏈路的全部容量,使其達到 飽和(saturation)。你既無法控制,也無法瞭解其他客戶如何使用共享資源;如果附近某個 吵鬧的鄰居(noisy neighbor)正在大量消耗資源,網路延遲就可能劇烈波動 30 31。
在這樣的環境中,只能透過實驗來選擇超時時間:長期測量大量機器之間網路往返時間的分佈,確定延遲通常會有多大波動。然後結合應用程式自身的特點,在故障檢測的延遲與過早超時的風險之間做出適當權衡。
更好的辦法是不用固定配置的超時時間,而讓系統持續測量響應時間及其波動(抖動,jitter),再根據觀測到的響應時間分佈自動調整超時。Phi 累積故障檢測器 32 就採用了這種方法,Akka 和 Cassandra 等系統都在使用它 33。TCP 的重傳超時也以類似方式工作 5。
同步網路與非同步網路
如果可以指望網路在某個固定的最長延遲內送達資料包,而且絕不丟包,分散式系統就會簡單得多。為什麼不能在硬體層面解決這個問題,讓網路變得可靠,從而使軟體不必再操心呢?
要回答這個問題,不妨把資料中心網路與傳統的固定電話網路(非蜂窩網路,也非 VoIP)比較一下。傳統電話網路極其可靠:音訊幀延遲和通話中斷都很罕見。電話通話需要持續保持較低的端到端延遲,並提供足夠頻寬來傳輸語音取樣。如果計算機網路也能具備類似的可靠性與可預測性,豈不是很好?
透過電話網路撥號時,網路會建立一條 電路(circuit):從一名通話者到另一名通話者的整條路徑上,都會為這次通話分配固定且有保證的頻寬。這條電路會一直保留到通話結束 34。例如,ISDN 網路以每秒 4,000 幀的固定速率執行。建立通話時,每一幀都會在兩個方向上各分配 16 位空間。因此在整個通話期間,雙方都能保證每 250 微秒恰好傳送 16 位音訊資料 35。
這類網路是 同步的(synchronous):即使資料要經過多臺路由器,也不會排隊,因為每一跳都已經為這次通話預留了 16 位的空間。由於無需排隊,網路的最大端到端延遲是固定的。我們稱之為 有界延遲(bounded delay)。
我們不能簡單地讓網路延遲可預測嗎?
注意,電話網路中的電路與 TCP 連線截然不同:電路會預留固定數量的頻寬,只要電路存在,其他人就無法使用這部分頻寬;TCP 連線的資料包則會伺機利用當時可用的任意網路頻寬。你可以交給 TCP 一塊大小不定的資料(例如電子郵件或網頁),它會盡力在最短時間內傳輸完畢。TCP 連線空閒時不會佔用頻寬,偶爾傳送的保活包除外。
如果資料中心網路和網際網路採用電路交換,那麼建立電路時就可以同時確定有保證的最大往返時間。但它們並非如此:乙太網和 IP 都是分組交換協議,資料包會排隊,因而網路延遲沒有上界。這些協議中根本沒有“電路”的概念。
為什麼資料中心網路和網際網路要使用分組交換?因為它們針對 突發流量(bursty traffic)做了最佳化。音訊或視訊通話在整個通話期間每秒傳輸的位數相當穩定,很適合使用電路。相比之下,請求網頁、傳送電子郵件或傳輸檔案並沒有特定的頻寬要求——我們只希望它們儘快完成。
如果要透過電路傳輸檔案,就必須先猜測應當分配多少頻寬。猜得太低,傳輸速度會慢得毫無必要,同時還有網路容量閒置;猜得太高,電路又無法建立,因為網路不能為無法保證頻寬分配的電路放行。因此,用電路傳輸突發資料既浪費網路容量,又使傳輸無謂地變慢。TCP 則不同,它會根據可用的網路容量動態調整資料傳輸速率。
人們也曾嘗試構建兼具電路交換與分組交換的混合網路。非同步傳輸模式(Asynchronous Transfer Mode,ATM)在 20 世紀 80 年代曾與乙太網競爭,但除了電話網路的核心交換機外,並未得到廣泛採用。InfiniBand 與之有些相似 36:它在鏈路層實現端到端流量控制,減少了網路排隊的必要,不過鏈路擁塞仍可能帶來延遲 37。如果謹慎運用 服務質量(quality of service,QoS,即資料包的優先順序與排程)和 准入控制(admission control,即對傳送方限速),就可以在分組網路上類比電路交換,或提供統計意義上的有界延遲 27 34。低延遲、低損耗和可伸縮吞吐量(L4S)等新型網路演算法,試圖從客戶端和路由器兩端緩解部分排隊與擁塞控制問題。Linux 的流量控制器(TC)也允許應用程式為實現 QoS 而重新安排資料包的優先順序。
更一般地說,可以把延遲的波動看作動態劃分資源的結果。
假設兩臺電話交換機之間有一條線路,最多可以承載 10,000 路併發通話。經這條線路交換的每條電路都會佔用一個通話槽位。因此,可以把這條線路看作一項最多由 10,000 名併發使用者共享的資源。資源以 靜態 方式劃分:即使現在整條線路上只有你這一通電話,另外 9,999 個槽位全部空閒,你的電路得到的仍然只是那份固定頻寬,與線路滿載時一模一樣。
相比之下,網際網路以 動態 方式共享網路頻寬。傳送方彼此爭搶,都想盡快把自己的資料包送上線路;網路交換機則隨時決定接下來傳送哪個資料包,也就是如何分配頻寬。這種方法的缺點是會造成排隊,優點則是能最大限度地利用線路。線路的成本是固定的,利用率越高,經由它傳送的每個位元組就越便宜。
CPU 也有類似的情況:如果多個執行緒動態共享一個 CPU 核心,那麼當另一個執行緒正在執行時,某個執行緒有時就必須在作業系統的執行佇列中等待,因而可能暫停長短不一的時間 38。不過,與給每個執行緒靜態分配固定數量的 CPU 週期相比,這樣做能更充分地利用硬體(參見 “響應時間保證”)。為了提高硬體利用率,雲平臺也會在同一臺物理機器上執行來自不同客戶的多臺虛擬機器。
在某些環境中,只要靜態劃分資源(例如採用專用硬體並分配獨佔頻寬),就能提供延遲保證。但代價是資源利用率降低——換句話說,也就是成本更高。動態劃分資源的多租戶模式利用率更好,因此更便宜,代價則是延遲會發生波動。
網路延遲會發生波動並非自然法則,只是成本與收益權衡的結果。
不過,多租戶資料中心、公共雲以及經由網際網路的通訊,目前都沒有啟用這樣的服務質量機制。現有部署技術無法讓我們對網路的延遲或可靠性作出任何保證:必須假定網路擁塞、排隊和無界延遲都會發生。因此,超時時間沒有所謂“正確”的取值,只能透過實驗來確定。
網際網路服務提供商之間的對等互聯協議,以及透過邊界閘道器協議(BGP)建立路由的方式,比 IP 本身更像電路交換。在這個層面上,確實可以買到專用頻寬。不過,網際網路路由工作在網路層面,而不是主機之間的單條連線層面,其時間尺度也長得多。
不可靠的時鐘
時鐘和時間都很重要。應用程式會以各種方式依賴時鐘,回答下面這些問題:
- 這個請求已經超時了嗎?
- 這項服務響應時間的第 99 百分位數是多少?
- 過去五分鐘裡,這項服務平均每秒處理多少次查詢?
- 使用者在我們的網站上停留了多長時間?
- 這篇文章是什麼時候釋出的?
- 提醒郵件應該在哪一天、什麼時間發出?
- 這個快取條目什麼時候過期?
- 日誌檔案裡這條錯誤訊息的時間戳是什麼?
問題 1~4 測量的是 持續時間(duration;例如從傳送請求到收到響應之間的時間間隔),問題 5~8 描述的則是 時間點(point in time;在某個具體日期和時刻發生的事件)。
在分散式系統中,時間是個棘手的問題,因為通訊並非瞬間完成:訊息從一臺機器經網路傳到另一臺機器需要時間。收到訊息的時刻必然晚於發出訊息的時刻,但由於網路延遲會發生變化,我們不知道究竟晚了多少。涉及多臺機器時,這一事實有時會讓事件的先後順序難以確定。
此外,網路中的每臺機器都有自己的時鐘,而且它是實實在在的硬體裝置,通常是石英晶體振盪器。這些裝置並不十分精確,因此每臺機器都各有一套時間觀念,可能比其他機器走得稍快或稍慢。時鐘可以在一定程度上同步:最常用的機制是網路時間協議(NTP),它根據一組伺服器報告的時間來校準計算機時鐘 39;這些伺服器又從 GPS 接收器等更精確的時間源取得時間。
單調時鐘與日曆時鐘
現代計算機至少配有兩種不同的時鐘:日曆時鐘(time-of-day clock)和 單調時鐘(monotonic clock)。它們雖然都用來度量時間,卻服務於不同目的,因此必須加以區分。
日曆時鐘
日曆時鐘所做的,正是人們直覺上認為時鐘應該做的事:按照某種曆法返回當前日期和時間(也稱為 牆上時鐘時間,wall-clock time)。例如,Linux 的 clock_gettime(CLOCK_REALTIME) 和 Java 的 System.currentTimeMillis() 返回從 紀元(epoch)至今經過的秒數(或毫秒數):這裡的紀元是格里高利曆中的 1970 年 1 月 1 日 UTC 零時,且不計閏秒。有些系統使用其他日期作為參考點。(Linux 雖然把這個時鐘稱為 實時 時鐘,但它與實時作業系統毫無關係,參見 “響應時間保證”。)
日曆時鐘通常會與 NTP 同步,因此理想情況下,一臺機器上的某個時間戳與另一臺機器上的同一時間戳表示同一時刻。不過,日曆時鐘也有各種怪異之處,下一節將進一步說明。尤其是當本地時鐘比 NTP 伺服器快得太多時,它可能會被強制重置,看起來就像突然跳回了過去。這樣的跳變,以及閏秒造成的類似跳變,使日曆時鐘不適合測量已經過去了多長時間 40。
夏令時(DST)開始或結束時,日曆時鐘也可能跳變;只要始終採用沒有夏令時的 UTC 時區,就能避開這類跳變。歷史上,日曆時鐘的解析度也相當粗糙,例如舊版 Windows 系統的時鐘每次會向前跳 10 毫秒 41。在較新的系統上,這已經不是什麼大問題。
單調時鐘
單調時鐘適合測量持續時間(時間間隔),例如超時時間或服務響應時間。Linux 的 clock_gettime(CLOCK_MONOTONIC)、clock_gettime(CLOCK_BOOTTIME) 42 和 Java 的 System.nanoTime() 都屬於單調時鐘。之所以叫“單調”,是因為它保證只會向前走(日曆時鐘卻可能突然跳回過去)。
你可以先讀取一次單調時鐘,做些事情,稍後再讀一次。兩次讀數之 差 就是其間經過的時間——它更像秒錶,而不是掛鐘。不過,單調時鐘的 絕對 讀數沒有任何意義:它可能表示計算機啟動至今的納秒數,也可能採用其他任意起點。尤其不能比較兩臺不同計算機的單調時鐘讀數,因為它們表示的並不是同一回事。
在有多個 CPU 插槽的伺服器上,每顆 CPU 都可能有自己的計時器,而且未必與其他 CPU 同步 43。作業系統會補償其中的差異,盡力讓應用程式執行緒看到單調遞增的時鐘,即使執行緒在不同 CPU 之間排程也是如此。不過,對這樣的單調性保證最好還是有所保留 44。
如果 NTP 發現計算機的本地石英鐘比 NTP 伺服器走得更快或更慢,可以調節單調時鐘向前推進的速率,這叫作對時鐘進行 漸進校準(slewing)。預設情況下,NTP 最多可以把時鐘速率加快或減慢 0.05%,但不能讓單調時鐘突然向前或向後跳變。單調時鐘通常有很好的解析度:在大多數系統上,它能測量微秒級甚至更短的時間間隔。
在分散式系統中,用單調時鐘測量經過的時間(例如超時)通常沒有問題,因為這不要求不同節點的時鐘彼此同步,而且測量中的輕微誤差也不會造成太大影響。
時鐘同步和準確性
單調時鐘不需要同步,但日曆時鐘只有根據 NTP 伺服器或其他外部時間源進行校準才有用。遺憾的是,讓時鐘顯示正確時間的手段遠沒有想象中那麼可靠和準確——硬體時鐘與 NTP 都可能反覆無常。下面只是其中幾個例子:
- 計算機裡的石英鐘並不十分精確,它會發生 漂移(走得比應有速度更快或更慢),而漂移程度又會隨機器溫度變化。Google 假定其伺服器的時鐘漂移最高可達 200 ppm(百萬分之二百)45。這相當於每隔 30 秒與伺服器重新同步一次的時鐘會漂移 6 毫秒,而每天才同步一次的時鐘會漂移 17 秒。即使其他一切都正常,時鐘漂移也會限制所能達到的最佳精度。
- 如果計算機時鐘與 NTP 伺服器相差太大,它可能拒絕同步,也可能強制重置本地時鐘 39。在重置前後觀察時間的應用程式,可能會看到時間向後倒退,或突然向前跳躍。
- 如果防火牆意外阻斷了節點與 NTP 伺服器的通訊,這項錯誤配置可能很長時間都無人察覺;在此期間,漂移不斷累積,不同節點的時鐘最終可能相差甚遠。軼事證據表明,實踐中確實發生過這種情況。
- NTP 同步的準確性不可能優於網路延遲。因此,在資料包延遲波動不定的擁塞網路上,它的準確性必然有限。一項實驗表明,經由網際網路同步所能達到的最小誤差為 35 毫秒 46,而網路延遲偶爾出現尖峰時,誤差會達到一秒左右。具體取決於配置,過大的網路延遲甚至可能讓 NTP 客戶端徹底放棄同步。
- 有些 NTP 伺服器本身不正確或配置有誤,報告的時間能相差數小時 47 48。NTP 客戶端會查詢多臺伺服器並忽略離群值,以減輕這類錯誤的影響。即便如此,把系統的正確性押在某個網際網路陌生人報給你的時間上,多少還是令人不安。
- 閏秒會讓一分鐘變成 59 秒或 61 秒,從而打亂那些設計時未考慮閏秒的系統對時序所作的假設 49。閏秒已經導致許多大型系統崩潰 40 50,足見關於時鐘的錯誤假設有多麼容易悄然混入系統。處理閏秒的最佳辦法,或許是讓 NTP 伺服器“撒謊”:把閏秒調整分攤到一整天內逐漸完成,這稱為 平滑處理(smearing)51 52;不過實踐中各 NTP 伺服器的實際行為並不一致 53。好在從 2035 年起將不再使用閏秒,這個問題也會隨之消失。
- 在虛擬機器中,硬體時鐘也是虛擬化的,這給需要精確計時的應用程式帶來了額外挑戰 54。多個虛擬機器共享 CPU 核心時,一臺虛擬機器執行,其他虛擬機器就可能暫停數十毫秒。從應用程式的視角看,這種暫停表現為時鐘突然向前跳躍 29。如果虛擬機器暫停了幾秒,其時鐘隨後可能比實際時間慢幾秒,但 NTP 仍可能報告時鐘幾乎完全同步 55。
- 如果軟體執行在你無法完全控制的裝置上(例如移動裝置或嵌入式裝置),那麼裝置的硬體時鐘可能根本不值得信任。有些使用者會故意把硬體時鐘設定成錯誤的日期和時間,例如藉此在遊戲中作弊 56。因此,時鐘可能被設到離譜的過去或未來。
只要足夠重視時鐘精度,並願意投入大量資源,的確可以做到非常精確。例如,歐洲針對金融機構的 MiFID II 法規要求所有高頻交易基金把時鐘與 UTC 的誤差控制在 100 微秒以內,以便排查“閃崩”等市場異常,並幫助發現市場操縱 57。
藉助專用硬體(GPS 接收器和/或原子鐘)、精確時間協議(PTP),再輔以審慎的部署與監控,就能達到這樣的精度 58 59。不過,只依賴 GPS 也有風險,因為 GPS 訊號很容易受到干擾;某些地方(例如軍事設施附近)甚至經常發生這種情況 60。一些雲服務商已經開始為虛擬機器提供高精度時鐘同步 61。即便如此,時鐘同步仍需格外小心。如果 NTP 守護程序配置有誤,或防火牆阻斷了 NTP 流量,漂移造成的時鐘誤差很快就會變得很大。
對同步時鐘的依賴
時鐘的問題在於,它看似簡單易用,陷阱卻多得驚人:一天未必恰好有 86,400 秒,日曆時鐘可能倒著走,一個節點所認為的時間也可能與另一個節點相差很大。
本章前面討論過網路丟包和任意延遲資料包的問題。儘管網路在絕大多數時候表現良好,設計軟體時仍必須假定網路偶爾會發生故障,並妥善處理這些故障。時鐘也是如此:它們大多數時候都走得好好的,但健壯的軟體必須做好應對錯誤時鐘的準備。
部分問題在於,錯誤的時鐘很容易無人察覺。如果機器的 CPU 有缺陷,或網路配置有誤,它多半會徹底無法工作,因此問題很快就會暴露並得到修復。反之,如果石英鐘有缺陷,或 NTP 客戶端配置有誤,那麼即使時鐘逐漸偏離現實越來越遠,大多數事情看上去仍然執行正常。如果某段軟體依賴精確同步的時鐘,最終結果更可能是隱蔽而細微的資料丟失,而不是一場驚天動地的崩潰 62 63。
因此,如果使用的軟體要求時鐘同步,就必須仔細監控所有機器之間的時鐘偏差。凡是時鐘與其他節點偏離太遠的節點,都應當被宣告死亡並移出叢集。這樣的監控可以確保在故障時鐘造成太大破壞以前及時發現它。
用於事件排序的時間戳
下面來看一種很容易讓人想依賴時鐘、卻十分危險的情形:為多個節點上的事件排序 64。例如,兩個客戶端都向分散式資料庫寫入時,誰先到達?哪次寫入更新?
圖 9-3 展示了在採用多主複製的資料庫中,日曆時鐘的一種危險用法(這個例子與 圖 6-8 類似)。客戶端 A 在節點 1 上寫入 x = 1;這次寫入複製到節點 3;客戶端 B 在節點 3 上將 x 遞增(此時 x = 2);最後,兩次寫入都複製到節點 2。

在 圖 9-3 中,寫入複製到其他節點時,會按照寫入起源節點的日曆時鐘附上時間戳。這個例子中的時鐘同步已經非常好:節點 1 與節點 3 的偏差不到 3 毫秒,實踐中恐怕很難達到這麼好的水平。
遞增操作建立在先前寫入的 x = 1 之上,因此我們自然會認為 x = 2 這次寫入應該具有更大的時間戳。遺憾的是,圖 9-3 中並非如此:寫入 x = 1 的時間戳是 42.004 秒,寫入 x = 2 的時間戳卻是 42.003 秒。
正如 “最後寫入者勝(丟棄併發寫入)” 所討論的,解決不同節點併發寫入值之間衝突的一種辦法是 最後寫入者勝(LWW):對於同一個鍵,只保留時間戳最大的寫入,丟棄所有時間戳更早的寫入。在 圖 9-3 的例子中,節點 2 收到這兩個事件後,會錯誤地斷定 x = 1 才是更新的值,並丟棄對 x = 2 的寫入,於是遞增操作便丟失了。
要避免這個問題,可以確保每當覆蓋一個值時,新值的時間戳一定高於被覆蓋值,即使這個時間戳已經超前於寫入者的本地時鐘。不過,這樣就要付出額外讀取的代價,先找出當前最大的時間戳。Cassandra 和 ScyllaDB 等系統希望在一次往返中寫入所有副本,因此它們直接使用客戶端時鐘生成的時間戳,並採用最後寫入者勝策略 62。這種做法存在一些嚴重問題:
- 資料庫寫入可能莫名其妙地消失:在走得較慢的節點追上走得較快的節點之前,它無法覆蓋後者先前寫入的值 63 65。這樣可能在不向應用程式報告任何錯誤的情況下,悄無聲息地丟棄任意數量的資料。
- LWW 無法區分短時間內接連發生的順序寫入(在 圖 9-3 中,客戶端 B 的遞增操作顯然發生在客戶端 A 的寫入 之後)與真正的併發寫入(兩個寫入者都不知道對方的寫入)。為了避免違反因果關係,還需要版本向量等額外的因果關係追蹤機制(參見 “檢測併發寫入”)。
- 兩個節點可能各自獨立生成具有相同時間戳的寫入,特別是時鐘解析度只有毫秒時。解決這類衝突還需要一個額外的決勝值(簡單地取一個很大的隨機數即可),但這種做法同樣可能違反因果關係 62。
因此,儘管保留最“新”的值並丟棄其他值,看上去是很誘人的衝突解決辦法,但必須意識到,“新”的定義取決於本地日曆時鐘,而它很可能並不準確。即使時鐘經過嚴密的 NTP 同步,也可能出現這樣的情況:資料包在時間戳 100 毫秒時發出(按傳送方的時鐘),卻在時間戳 99 毫秒時到達(按接收方的時鐘)——看起來彷彿資料包還沒發出就已經到達,這當然不可能。
能否把 NTP 同步做得足夠精確,從而徹底避免這類錯誤排序?恐怕不能。除了石英鐘漂移等其他誤差源以外,NTP 的同步精度本身就受網路往返時間限制。若要保證順序正確,時鐘誤差必須顯著小於網路延遲,而這是不可能做到的。
所謂的 邏輯時鐘(logical clock)66 以遞增計數器為基礎,而不是以振盪的石英晶體為基礎,因此是為事件排序時更安全的選擇(參見 “檢測併發寫入”)。邏輯時鐘既不度量一天中的時刻,也不度量經過了多少秒,只記錄事件之間的相對順序(一個事件發生在另一個事件之前還是之後)。與之相對,日曆時鐘和單調時鐘度量真實流逝的時間,因此也稱為 物理時鐘(physical clock)。我們將在 “ID 生成器和邏輯時鐘” 中更詳細地討論邏輯時鐘。
帶置信區間的時鐘讀數
機器的日曆時鐘也許能以微秒甚至納秒為解析度提供讀數,但測量得如此精細,並不表示讀數真的精確到了這個程度。事實上,它大機率沒有這麼準。前面說過,即使每分鐘都與區域網中的 NTP 伺服器同步一次,不精確的石英鐘也很容易漂移數毫秒。若使用公共網際網路上的 NTP 伺服器,最理想的精度大概也只有數十毫秒;遇到網路擁塞時,誤差很容易飆升到 100 毫秒以上。
因此,不應把一次時鐘讀數理解為一個精確的時間點,它更像是落在某個置信區間內的一段時間範圍。例如,系統也許有 95% 的把握認為當前時刻位於這一分鐘的第 10.3 秒至第 10.5 秒之間,但無法知道得更精確 67。如果時間只能確定到 ±100 毫秒,那麼時間戳中精確到微秒的那些數字基本毫無意義。
不確定性的邊界可以根據時間源來計算。如果計算機直接連線著 GPS 接收器或原子鐘,預期誤差範圍取決於裝置本身;對 GPS 而言,還取決於衛星訊號的質量。如果從伺服器獲取時間,不確定性大致等於:自上次與伺服器同步以來石英鐘的預期漂移,加上 NTP 伺服器自身的不確定性,再加上與伺服器之間的網路往返時間——這是第一步近似,並且假定伺服器值得信任。
遺憾的是,大多數系統都不會暴露這種不確定性。例如,呼叫 clock_gettime() 時,返回值並不會告訴你時間戳的預期誤差,因此你無從得知它的置信區間究竟是五毫秒,還是五年。
不過也有例外:Google Spanner 的 TrueTime API 45 和 Amazon 的 ClockBound 都會明確報告本地時鐘的置信區間。查詢當前時間時,得到的是兩個值:[earliest, latest],分別表示 最早可能 與 最晚可能 的時間戳。根據對不確定性的計算,時鐘能夠斷定真實的當前時間位於這個區間內。區間的寬度取決於多種因素,其中包括本地石英鐘距離上次與更精確的時間源同步已經過去多久。
用於全域性快照的同步時鐘
在 “快照隔離與可重複讀” 中,我們討論了 多版本併發控制(MVCC)。對於既要支援短小快速的讀寫事務,又要支援大型、長時間執行的只讀事務(例如備份或分析)的資料庫來說,MVCC 是一項非常有用的功能。它讓只讀事務能夠看到資料庫在某個特定時間點的一致狀態,也就是一個 快照,同時又不必鎖住或干擾讀寫事務。
一般來說,MVCC 需要單調遞增的事務 ID。如果某次寫入發生在快照之後(也就是說,寫入的事務 ID 大於快照的事務 ID),那麼快照事務就看不到這次寫入。在單節點資料庫上,用一個簡單的計數器就足以生成事務 ID。
可是,當資料庫分佈在許多機器上,甚至橫跨多個資料中心時,生成全域性單調遞增的事務 ID(覆蓋所有分片)就很困難,因為這需要協調。事務 ID 還必須反映因果關係:如果事務 B 讀取或覆蓋了事務 A 先前寫入的值,B 的事務 ID 就必須大於 A,否則快照將不一致。面對大量短小快速的事務,在分散式系統中生成事務 ID 會成為難以承受的瓶頸。(我們將在 “ID 生成器和邏輯時鐘” 中討論這類 ID 生成器。)
能不能直接把同步日曆時鐘產生的時間戳當作事務 ID?如果時鐘同步能做得足夠好,時間戳的確具備所需屬性:越晚的事務,時間戳越大。當然,問題依舊在於時鐘精度的不確定性。
Spanner 正是以這種方式實現跨資料中心的快照隔離 68 69。它利用 TrueTime API 報告的時鐘置信區間,依據的是下面這個觀察:假設有兩個置信區間,每個區間都由最早和最晚的可能時間戳組成(A = [A最早, A最晚],B = [B最早, B最晚]),如果兩個區間不重疊(即 A最早 < A最晚 < B最早 < B最晚),那麼 B 必定發生在 A 之後,不存在任何疑問。只有當兩個區間發生重疊時,我們才無法確定 A 與 B 的先後順序。
為了確保事務時間戳能夠反映因果關係,Spanner 會在提交讀寫事務之前,刻意等待相當於置信區間長度的一段時間。這樣一來,任何可能讀到該資料的事務都會發生在足夠晚的時刻,使它們的置信區間不再重疊。為了儘量縮短等待,Spanner 需要把時鐘不確定性控制得儘可能小;為此,Google 在每個資料中心都部署了 GPS 接收器或原子鐘,從而把時鐘同步誤差控制在大約 7 毫秒以內 45。
嚴格來說,Spanner 並非一定要使用原子鐘和 GPS 接收器:真正重要的是獲得置信區間,精確的時間源只是幫助縮小這個區間。其他系統也開始採用類似方法。例如,YugabyteDB 在 AWS 上執行時可以利用 ClockBound 70,還有若干系統也開始在不同程度上依賴時鐘同步 71 72。
程序暫停
再來看一個在分散式系統中危險使用時鐘的例子。假設某個資料庫的每個分片都只有一個領導者,而且只有領導者可以接受寫入。一個節點怎麼知道自己仍是領導者(沒有被其他節點宣告死亡),因而可以安全地接受寫入呢?
一種辦法是由領導者向其他節點取得一份 租約(lease),它類似於帶有超時的鎖 73。任何時刻只能有一個節點持有租約。因此,節點拿到租約後,就知道自己在租約到期以前的一段時間內仍是領導者。為保持領導者身份,節點必須在租約到期前定期續租。如果節點失效,就會停止續租;租約到期後,另一個節點便可接管。
可以想象,請求處理迴圈大致如下:
這段程式碼有什麼問題?首先,它依賴同步時鐘:租約到期時間由另一臺機器設定(例如以當前時間加 30 秒來計算),卻要與本地系統時鐘比較。如果兩臺時鐘的偏差超過幾秒,這段程式碼的行為就會變得古怪。
其次,即使把協議改成只使用本地單調時鐘,仍然存在另一個問題:程式碼假定讀取時間(System.currentTimeMillis())與處理請求(process(request))之間只會經過極短時間。通常這段程式碼確實執行得很快,預留 10 秒足以確保租約不會在請求處理到一半時過期。
可是,如果程式執行時意外暫停了呢?例如,假設執行緒執行到 lease.isValid() 附近時停了 15 秒,隨後才恢復。在處理請求時,租約很可能早已過期,另一個節點也已接任領導者。然而,沒有任何東西會告訴這個執行緒它剛才停了那麼久;直到迴圈進入下一輪,它才會發現租約已經過期——而在此之前,它可能已經處理了請求,做出了不安全的操作。
認為執行緒可能暫停這麼久,是否合理?很遺憾,完全合理。造成長時間暫停的原因多種多樣:
- 多個執行緒爭用鎖、佇列等共享資源時,執行緒可能把大量時間花在等待上。換用 CPU 核心更多的機器甚至可能讓這類問題進一步惡化,而且爭用問題往往很難診斷 74。
- 許多程式語言執行時(例如 Java 虛擬機器)都帶有 垃圾回收器(GC),偶爾需要停止所有正在執行的執行緒。過去,這類 STW GC 暫停 有時會讓程式停上幾分鐘 75!現代 GC 演算法已經大大緩解了這個問題,但 GC 暫停仍可能相當明顯(參見 “限制垃圾回收的影響”)。
- 在虛擬化環境中,虛擬機器可以被 掛起(暫停所有程序並把記憶體內容儲存到磁碟),隨後再 恢復(還原記憶體內容並從原處繼續執行)。這種暫停可能發生在程序執行的任何時刻,持續時間也沒有上限。這個功能有時用於把虛擬機器從一臺宿主機 實時遷移 到另一臺宿主機而無需重啟;在這種情況下,暫停多久取決於程序寫入記憶體的速率 76。
- 在膝上型電腦和手機等終端使用者裝置上,執行也可能隨時掛起並恢復,例如使用者合上膝上型電腦螢幕時。
- 當作業系統切換到另一個執行緒,或者虛擬機器監控器切換到另一臺虛擬機器時,當前執行緒可能停在程式碼中的任意位置。對虛擬機器而言,被其他虛擬機器佔用的 CPU 時間稱為 竊取時間(steal time)。如果機器負載很高——也就是有很長的執行緒佇列在等待執行——暫停的執行緒可能要過一陣子才能再次獲得執行機會。
- 如果應用程式執行同步磁碟訪問,執行緒可能暫停下來,等待緩慢的磁碟 I/O 操作完成 77。在許多語言中,即使程式碼沒有明確讀寫檔案,磁碟訪問也可能出人意料地發生。例如,Java 類載入器會在類第一次使用時才載入類檔案,而這可能出現在程式執行的任何時刻。I/O 暫停與 GC 暫停甚至可能相互疊加 78。如果所謂的磁碟其實是網路檔案系統或網路塊裝置(例如 Amazon EBS),I/O 延遲還會受到網路延遲波動的影響 31。
- 如果作業系統允許 換頁到磁碟(分頁),一次簡單的記憶體訪問也可能觸發缺頁錯誤,必須從磁碟把某個頁面載入記憶體。在這項緩慢的 I/O 操作完成以前,執行緒會一直暫停。如果記憶體壓力很大,還可能需要先把另一個頁面換出到磁碟。極端情況下,作業系統會把大部分時間耗在記憶體頁面的換入換出上,幾乎不做實際工作,這叫作 抖動(thrashing)。為了避免這種情況,伺服器通常會禁用分頁——與其冒著發生抖動的風險,不如終止一個程序來釋放記憶體。
- 向 Unix 程序傳送
SIGSTOP訊號也會令其暫停,例如在 shell 中按 Ctrl-Z。這個訊號會立即停止給程序分配 CPU 週期,直到SIGCONT令它恢復;隨後,它會從先前停下的位置繼續執行。即使你的環境通常不用SIGSTOP,運維人員也可能不小心發出這個訊號。
上述任何事件都可能在任意位置 搶佔 正在執行的執行緒,過一段時間再讓它恢復,而執行緒對此毫無察覺。這個問題類似於保證單機多執行緒程式碼的執行緒安全:不能對時序作任何假定,因為上下文切換與並行執行隨時都可能發生。
在單臺機器上編寫多執行緒程式碼時,我們有不少成熟工具來保證執行緒安全:互斥鎖、訊號量、原子計數器、無鎖資料結構、阻塞佇列等等。遺憾的是,這些工具不能直接套用到分散式系統,因為分散式系統沒有共享記憶體,只有經由不可靠網路傳遞的訊息。
分散式系統中的節點必須假定:自己的執行可能在任意時刻暫停很長時間,哪怕正處於函式執行途中。暫停期間,外部世界仍在繼續運轉,甚至可能因為這個節點遲遲沒有響應而宣告它死亡。最終節點恢復執行時,甚至意識不到自己曾經“睡著”,直到稍後再次讀取時鐘。
響應時間保證
如上所述,在許多程式語言與作業系統中,執行緒和程序都可能暫停任意長的時間。不過,只要投入足夠努力,這些暫停的原因 確實可以 消除。
有些軟體執行在這樣的環境中:如果不能在規定時間內響應,就可能造成嚴重損害。控制飛機、火箭、機器人、汽車及其他實體裝置的計算機,必須快速而且可預測地響應感測器輸入。在這些系統中,軟體必須趕在明確規定的 截止時間(deadline)之前響應;錯過截止時間,就可能導致整個系統失效。這樣的系統稱為 硬實時(hard real-time)系統。
在嵌入式系統中,實時 是指系統經過精心設計與測試,能夠在任何情況下滿足規定的時序保證。這與 Web 領域對 實時 一詞較為寬泛的用法形成對比:後者通常只是指伺服器向客戶端推送資料或進行流處理,並沒有嚴格的響應時間約束(參見 第 12 章)。
例如,汽車的車載感測器檢測到碰撞正在發生時,你絕不會希望安全氣囊因為控制系統恰好遭遇 GC 暫停而延遲彈出。
要在系統中提供實時保證,需要軟體棧的每一層共同支援:需要 實時作業系統(RTOS),保證在規定的時間間隔內為程序分配 CPU 時間;庫函式必須說明最壞情況下的執行時間;動態記憶體分配可能要受到限制,甚至完全禁止(雖然存在實時垃圾回收器,應用程式仍必須確保不會給 GC 安排太多工作);此外還要進行海量測試與測量,驗證系統確實滿足保證。
這些要求不僅帶來大量額外工作,也嚴重限制了可用的程式語言、庫和工具,因為大多數語言和工具都不提供實時保證。正因如此,實時系統開發極其昂貴,最常用於安全攸關的嵌入式裝置。而且,“實時”並不等於“高效能”——事實上,實時系統的吞吐量可能更低,因為及時響應必須優先於一切(另見 “延遲與資源利用率”)。
對於大多數伺服器端資料處理系統,實時保證既不經濟,也不合適。因此,這些系統只能承受非實時環境帶來的程序暫停與時鐘不穩定。
限制垃圾回收的影響
垃圾回收曾是造成程序暫停的最大原因之一 79。好在 GC 演算法已經有了長足進步:如今,經過適當調優的回收器通常只會暫停幾毫秒。Java 執行時提供併發標記清除(CMS)、垃圾優先(G1)、Z 垃圾回收器(ZGC)、Epsilon 和 Shenandoah 等回收器,分別針對高頻建立物件、大型堆等不同記憶體使用特徵進行最佳化。相比之下,Go 提供的是一種更簡單、嘗試自我最佳化的併發標記清除垃圾回收器。
如果必須徹底避免 GC 暫停,可以選擇完全沒有垃圾回收器的語言。例如,Swift 使用自動引用計數來判斷何時能夠釋放記憶體;Rust 和 Mojo 則透過型別系統追蹤物件的生命週期,讓編譯器判斷記憶體需要保留多久。
也可以繼續使用帶垃圾回收的語言,同時減輕暫停造成的影響。一種做法是把 GC 暫停看作節點短暫的計劃內停機:某個節點進行垃圾回收時,由其他節點處理客戶端請求。如果執行時能夠提前告知應用程式節點即將進行 GC 暫停,應用程式就可以停止向該節點傳送新請求,等待它處理完尚未完成的請求,然後趁沒有請求進行時執行 GC。這種技巧能向客戶端隱藏 GC 暫停,並降低響應時間的高百分位數 80 81。
這種思路還有一個變體:只讓垃圾回收器處理容易快速回收的短命物件,並定期重啟程序,趕在長期存活物件積累到需要執行一次完整 GC 之前 79 82。每次可以只重啟一個節點,並在計劃重啟前先把流量從該節點遷走,就像滾動升級一樣(參見 第 5 章)。
這些措施無法徹底杜絕垃圾回收暫停,卻能切實減輕其對應用程式的影響。
知識、真相和謊言
到目前為止,本章已經考察了分散式系統與單機程式的不同之處:系統沒有共享記憶體,只能透過延遲不定的不可靠網路來傳遞訊息,還可能遭遇部分失效、不可靠的時鐘和程序暫停。
如果還不熟悉分散式系統,這些問題帶來的後果會讓人極度迷失方向。網路中的一個節點不可能 確切知道 其他節點的任何事情,只能根據收到(或沒有收到)的訊息作出猜測。一個節點只有與另一個節點交換訊息,才能得知對方處於什麼狀態,例如儲存了哪些資料、是否正常執行等等。如果遠端節點沒有響應,就無從得知它的狀態,因為網路問題與節點自身的問題無法可靠地區分。
關於這類系統的討論已經近乎哲學:在系統中,我們究竟知道什麼為真、什麼為假?如果感知和測量的機制都不可靠,我們又能在多大程度上確信自己的認知 83?軟體系統是否應當服從我們認為物理世界必然遵循的法則,例如因果律?
好在我們不必一路追問到生命的意義。在分散式系統中,可以明確寫出對系統行為所作的假設(即 系統模型,system model),再把實際系統設計成符合這些假設。我們還可以證明某個演算法在特定系統模型中能夠正確執行。這意味著,即使底層系統模型只提供極少保證,也仍然可以實現可靠的行為。
不過,要讓軟體在不可靠的系統模型中表現良好,絕非輕而易舉。本章餘下部分將進一步探討分散式系統中的知識與真相,幫助我們思考可以作出哪些假設,以及希望提供哪些保證。在 第 10 章 中,我們將繼續考察一些分散式演算法:它們在特定假設下提供特定保證。
多數派原則
設想一個存在非對稱故障的網路:某個節點可以收到發給它的所有訊息,但它發出的訊息都會丟失或延遲 22。這個節點明明工作得完全正常,也在接收其他節點的請求,可其他節點就是聽不見它的回應。等到超時後,其他節點由於一直收不到回覆,便宣告它已經死亡。接下來的場面如同噩夢:這個半失聯的節點被拖向墓地,一路掙扎高喊“我還沒死!”——可惜誰也聽不見它的呼喊,送葬隊伍仍以堅忍不拔的決心繼續前進。
在一個沒那麼噩夢般的場景中,半失聯的節點也許會發現自己發出的訊息得不到其他節點的確認,於是意識到網路必定出了故障。然而,其他節點仍會錯誤地宣告它死亡,而它對此無能為力。
第三種場景是,假設某個節點暫停執行一分鐘。在此期間,它既不處理請求,也不傳送響應。其他節點一邊等待、一邊重試,終於失去耐心,宣告它死亡並把它抬上靈車。最終暫停結束,節點的執行緒若無其事地繼續執行。其他節點驚訝地看到,那個本應死去的節點突然精神抖擻地從棺材裡探出頭來,興高采烈地與旁人聊天。剛恢復時,這個節點甚至不知道整整一分鐘已經過去,也不知道自己已經被宣告死亡——在它看來,距離上次和其他節點交談彷彿只過了一瞬間。
這些故事告訴我們,節點未必能相信自己對處境的判斷。分散式系統不能只依賴某一個節點,因為節點隨時可能失效,使系統陷入僵局而無法恢復。因此,許多分散式演算法依賴 法定人數(quorum),也就是讓多個節點投票(參見 “讀寫仲裁”):一項決策必須獲得若干節點的最低票數,藉此減少對任何單個節點的依賴。
宣告節點死亡的決定也是如此。如果達到法定人數的節點宣告另一個節點已經死亡,那麼即使它覺得自己還活得好好的,也必須被視為死亡。單個節點必須服從法定人數作出的決定並下臺。
最常見的法定人數,是超過節點總數一半的絕對多數,當然也可以採用其他形式。多數法定人數讓系統能在少數節點發生故障時繼續工作:三個節點可以容忍一個故障節點,五個節點則可以容忍兩個。與此同時,它仍然是安全的,因為系統中只能形成一個多數派,不可能同時出現兩個作出衝突決定的多數派。我們將在 第 10 章 討論 共識演算法 時,更詳細地介紹法定人數的用法。
分散式鎖和租約
分散式應用程式中的鎖與租約很容易被誤用,也是程式缺陷的常見來源 84。下面來看一種具體的出錯方式。
在 “程序暫停” 中,我們看到租約是一種會超時的鎖:如果原持有者停止響應(可能是因為它崩潰了、暫停太久,或與網路斷開),租約就可以交給新的持有者。當系統要求某種東西只能有一個時,就可以使用租約。例如:
- 只允許一個節點擔任資料庫分片的領導者,以避免腦裂(參見 “處理節點故障”)。
- 只允許一個事務或客戶端更新特定資源或物件,以免併發寫入將其損壞。
- 一項大型處理作業中的每個輸入檔案只應由一個節點處理,避免多個節點重複執行同一份工作而白白浪費計算資源。
值得仔細想一想:如果多個節點同時相信自己持有租約——也許是程序暫停造成的——會發生什麼?對第三個例子而言,後果不過是浪費一些計算資源,並不嚴重;但在前兩個例子中,資料可能丟失或損壞,嚴重得多。
例如,圖 9-4 展示了鎖實現錯誤導致的資料損壞。(這並非純粹的理論問題:HBase 曾經就有這個缺陷 85 86。)假設你想確保某個儲存服務裡的檔案一次只能由一個客戶端訪問,因為多個客戶端同時寫入會損壞檔案。於是,你要求客戶端在訪問檔案之前,先向鎖服務取得租約。這類鎖服務通常用共識演算法來實現,我們將在 第 10 章 中進一步討論。

問題正是 “程序暫停” 所討論的情形:持有租約的客戶端如果暫停太久,租約就會過期。另一個客戶端此時可以取得同一檔案的租約,並開始寫入。等暫停的客戶端恢復後,它誤以為自己的租約依然有效,也繼續寫入檔案。於是便出現了腦裂:兩個客戶端的寫入彼此衝突,最終損壞檔案。
圖 9-5 展示了另一個後果類似的問題。這個例子裡沒有程序暫停,只有客戶端 1 崩潰。就在崩潰前,客戶端 1 向儲存服務發出了一項寫請求,但請求在網路中延遲了很久。(回想 “實踐中的網路故障”,資料包有時會延遲一分鐘以上。)等寫請求抵達儲存服務時,租約早已超時,客戶端 2 已經取得租約併發出了自己的寫入。結果便是類似 圖 9-4 的資料損壞。

用柵欄機制隔離殭屍與延遲請求
殭屍(zombie)一詞有時用來形容這樣的原租約持有者:它還不知道自己已經失去租約,仍把自己當作當前持有者行事。既然無法徹底杜絕殭屍,就必須確保它們不能以腦裂的形式造成任何破壞。這稱為用 柵欄機制(fencing)隔離殭屍。
有些系統試圖透過關停殭屍來隔離它,例如斷開它的網路連線 9、透過雲服務商的管理介面關閉虛擬機器,甚至直接切斷機器電源 87。這種做法稱為 STONITH,即“擊斃另一個節點”。遺憾的是,它有幾個問題:無法防範 圖 9-5 所示的超長網路延遲;所有節點可能彼此關停 19;而等到殭屍被發現並關閉時,也許早已為時過晚,資料已經損壞。
圖 9-6 展示了一種更健壯的柵欄機制,既能防範殭屍,也能防範延遲請求。

假設鎖服務每次授予鎖或租約時,還會返回一個 柵欄令牌(fencing token)。這是一個每次授予鎖都會增大的數字,例如由鎖服務負責遞增。隨後可以要求客戶端每次向儲存服務傳送寫請求時,都必須帶上自己當前的柵欄令牌。
在 圖 9-6 中,客戶端 1 取得租約及令牌 33,隨後卻長時間暫停,導致租約過期。客戶端 2 接著取得租約及令牌 34(令牌值始終遞增),並向儲存服務傳送帶令牌 34 的寫請求。稍後,客戶端 1 恢復執行,也向儲存服務傳送寫請求,其中帶著自己的令牌 33。然而,儲存服務記得自己已經處理過令牌值更高(34)的寫入,因此會拒絕令牌 33 的請求。剛取得租約的客戶端必須立刻向儲存服務執行一次寫入;一旦這次寫入完成,所有殭屍都會被隔離在外。
如果使用 ZooKeeper 作為鎖服務,可以把事務 ID zxid 或節點版本 cversion 用作柵欄令牌 85。在 etcd 中,修訂號與租約 ID 共同發揮類似作用 89。Hazelcast 的 FencedLock API 則會顯式生成柵欄令牌 90。
這種機制要求儲存服務能夠檢查寫入所攜帶的令牌是否已經過時。另一種辦法是讓服務支援類似原子比較並設定(CAS)的寫入:只有從當前客戶端上次讀取物件以後,沒有其他客戶端寫過該物件,寫入才會成功。物件儲存服務就支援這類檢查:Amazon S3 稱之為 條件寫入(conditional write),Azure Blob Storage 稱之為 條件標頭(conditional header),Google Cloud Storage 則稱之為 請求前置條件(request precondition)。
多副本隔離
如果客戶端只需寫入一個支援這類條件寫入的儲存服務,那麼鎖服務多少有些多餘 91 92,因為完全可以直接依託該儲存服務來分配租約 93。不過,有了柵欄令牌以後,也可以把它用於多個服務或副本,確保原租約持有者在所有這些服務上都被隔離。
例如,假設儲存服務是一個採用最後寫入者勝來解決衝突的無主複製鍵值儲存(參見 “無主複製”)。在這樣的系統中,客戶端直接向每個副本傳送寫入,各副本根據客戶端分配的時間戳,自行決定是否接受寫入。
如 圖 9-7 所示,可以把寫入者的柵欄令牌放在時間戳最高有效的若干位或數字中。這樣就能確保,新租約持有者生成的任何時間戳,都大於原租約持有者生成的所有時間戳,即使原持有者的寫入實際發生得更晚。

在 圖 9-7 中,客戶端 2 的柵欄令牌為 34,因此它生成的所有以 34… 開頭的時間戳,都大於客戶端 1 生成的任何以 33… 開頭的時間戳。客戶端 2 成功寫入達到法定人數的一組副本,但無法連線副本 3。因此,殭屍客戶端 1 稍後嘗試寫入時,這次寫入雖然會被副本 1 和 2 忽略,卻可能在副本 3 上成功。這不成問題,因為後續的仲裁讀會優先選擇客戶端 2 那個時間戳更大的寫入,而讀修復或反熵過程最終會覆蓋客戶端 1 寫入的值。
從這些例子可以看出,假定任何時刻只有一個節點持有租約並不安全。所幸,只要稍加留意,就可以藉助柵欄令牌防止殭屍與延遲請求造成任何破壞。
拜占庭故障
柵欄令牌能夠發現並阻止 無意間 出錯的節點,例如尚未發現租約已經過期的節點。但如果節點蓄意破壞系統保證,只需傳送帶有偽造柵欄令牌的訊息,就能輕易繞過這種防護。
本書假定節點雖然不可靠,卻是誠實的:它們可能因為故障而響應緩慢或永不響應,也可能因為 GC 暫停或網路延遲而持有過時狀態;但我們假定,只要節點 確實 作出響應,它說的就是“真話”——至少據它自己所知,它是在遵守協議規則。
如果節點有可能“撒謊”,也就是發出任意錯誤或損壞的響應,分散式系統問題就會困難得多。例如,一個節點可能在同一輪選舉中投出多張互相矛盾的票。這種行為叫作 拜占庭故障(Byzantine fault),而在這種互不信任的環境中達成共識的問題,則稱為 拜占庭將軍問題(Byzantine Generals Problem)94。
拜占庭將軍問題是所謂 兩將軍問題(Two Generals Problem)95 的推廣。兩將軍問題設想,兩名軍隊將領必須就作戰計劃達成一致,但他們駐紮在不同地點,只能派信使互通訊息,而信使有時會遲到或失蹤(就像網路資料包一樣)。我們將在 第 10 章 中討論這個 共識(consensus)問題。
在拜占庭版本的問題中,需要達成一致的將軍有 n 名,但其中混入了若干叛徒。大多數將軍都忠誠可靠,會傳送真實訊息;叛徒卻可能傳送虛假訊息,企圖欺騙並迷惑其他人。而誰是叛徒,事先無從得知。
拜占庭原是古希臘的一座城市,後來成為君士坦丁堡,所在地就是今天土耳其的伊斯坦布林。沒有任何歷史證據表明,拜占庭的將軍比其他地方的將軍更愛陰謀詭計。這個名稱其實來自 拜占庭式 一詞在政治語境中的含義:過度複雜、官僚而詭詐;這種用法遠在計算機出現以前便已存在 96。Lamport 想選擇一個不會冒犯讀者的國籍,而別人勸他最好不要把問題叫作 阿爾巴尼亞將軍問題 97。
如果某些節點發生故障、不遵守協議,或有惡意攻擊者干擾網路時,系統仍能繼續正確執行,就稱這個系統具有 拜占庭容錯(Byzantine fault tolerance,BFT)能力。這類問題在某些特定情形下確實很重要。例如:
- 在航空航天環境中,輻射可能破壞計算機記憶體或 CPU 暫存器裡的資料,使節點以任意且不可預測的方式回應其他節點。系統失效的代價極其高昂——例如飛機墜毀導致機上人員全部遇難,或火箭撞上國際空間站——因此飛行控制系統必須能夠容忍拜占庭故障 98 99。
- 在有多方參與的系統中,部分參與者可能企圖欺騙或詐騙其他人。此時,節點不能輕信另一個節點發來的訊息,因為訊息可能帶有惡意。比特幣等加密貨幣及其他區塊鏈,就可以看作一種無需依賴中央權威,讓彼此不信任的各方就某筆交易是否發生達成一致的方式 100。
不過,對本書討論的系統而言,通常可以放心假定不存在拜占庭故障。資料中心裡的所有節點都受你的組織控制,因而有望值得信任;輻射水平也足夠低,記憶體損壞並非主要問題——儘管人們已經在考慮把資料中心送入軌道 101。多租戶系統中的租戶彼此並不信任,但系統依靠防火牆、虛擬化和訪問控制策略把租戶相互隔離,而不是使用拜占庭容錯。讓系統實現拜占庭容錯的協議代價很高 102,而具有容錯能力的嵌入式系統又依賴硬體層面的支援 98。對大多數伺服器端資料系統來說,部署拜占庭容錯方案的成本高得並不現實。
Web 應用程式的確必須預料到,Web 瀏覽器等由終端使用者控制的客戶端可能作出任意乃至惡意的行為。這也正是輸入驗證、資料清理和輸出轉義如此重要的原因,例如它們可以防止 SQL 注入與跨站指令碼攻擊。不過,我們通常不會為此使用拜占庭容錯協議,只需讓伺服器充當權威,決定哪些客戶端行為可以接受、哪些不可以。在沒有這種中央權威的點對點網路中,拜占庭容錯才更為重要 103 104。
軟體缺陷也可以看作拜占庭故障,但如果所有節點部署的都是同一套軟體,拜占庭容錯演算法也救不了你。大多數拜占庭容錯演算法要求超過三分之二的節點正常執行(例如四個節點中最多只能有一個發生故障)。若想用這種辦法防範軟體缺陷,就得準備同一軟體的四種獨立實現,並寄希望於缺陷只出現在其中一種實現裡。
同理,如果某種協議能保護我們免受漏洞、安全失陷和惡意攻擊,當然很有吸引力。遺憾的是,這同樣不現實:在大多數系統中,攻擊者既然能夠攻陷一個節點,多半也能攻陷所有節點,因為它們很可能執行相同的軟體。因此,身份認證、訪問控制、加密和防火牆等傳統機制,仍然是抵禦攻擊者的主要手段。
弱形式的謊言
雖然我們通常假定節點是誠實的,但仍值得在軟體中加入一些機制,防範較弱形式的“撒謊”,例如硬體問題、軟體缺陷或配置錯誤產生的無效訊息。這些機制算不上完整的拜占庭容錯,因為它們擋不住意志堅定的攻擊者;但作為提高可靠性的手段,它們既簡單又務實。例如:
- 硬體問題,或作業系統、驅動程式、路由器等元件中的缺陷,確實可能損壞網路資料包。TCP 與 UDP 內建的校驗和通常能夠發現損壞的資料包,但偶爾也會漏檢 105 106 107。一般只需一些簡單措施就足以防範這類損壞,例如在應用層協議中加入校驗和。TLS 加密連線也能抵禦資料損壞。
- 可公開訪問的應用程式必須仔細清理所有使用者輸入,例如檢查數值是否落在合理範圍內,並限制字串長度,防止攻擊者透過分配巨量記憶體來造成拒絕服務。防火牆後的內部服務也許可以放寬輸入檢查,但協議解析器仍然最好保留基本校驗 105。
- NTP 客戶端可以配置多個伺服器地址。同步時,客戶端會聯絡所有伺服器,估算各自的誤差,並檢查大多數伺服器是否就某個時間範圍達成一致。只要大部分伺服器正常,配置有誤、報告錯誤時間的 NTP 伺服器就會被識別為離群值,並排除在同步過程之外 39。與只依賴一臺伺服器相比,使用多臺伺服器能讓 NTP 更加健壯。
系統模型與現實
人們已經設計了許多演算法來解決分散式系統問題。例如,我們將在 第 10 章 中考察共識問題的解決方案。要真正有用,這些演算法必須能夠容忍本章討論的分散式系統中的各種故障。
演算法的編寫方式不應過度依賴其執行環境中具體的硬體與軟體配置。這又要求我們以某種方式,把系統中預期會發生的故障型別形式化。為此,我們定義 系統模型(system model):它是一種抽象,用來說明演算法可以作出哪些假設。
關於時序假設,通常使用以下三種系統模型:
- 同步模型(synchronous model)
- 同步模型假定網路延遲、程序暫停和時鐘誤差都有上界。這並不表示時鐘完全同步,也不表示網路延遲為零;它只是意味著,我們知道網路延遲、暫停和時鐘漂移永遠不會超過某個固定上限 108。對大多數實際系統而言,同步模型並不現實,因為正如本章所討論的,無界延遲和暫停確實可能發生。
- 部分同步模型(partially synchronous model)
- 部分同步是指系統在 大多數時候 都像同步系統一樣執行,但偶爾會突破網路延遲、程序暫停和時鐘漂移的界限 108。對許多系統來說,這是一個現實的模型:絕大多數時候,網路與程序表現得相當規矩,否則我們什麼事情也做不成;但我們也必須正視這樣一個事實——任何時序假設都有可能偶爾失效。發生這種情況時,網路延遲、程序暫停和時鐘誤差都可能變得任意之大。
- 非同步模型(asynchronous model)
- 在這種模型中,演算法不能作出任何時序假設——事實上,它甚至沒有時鐘可用(因而也不能使用超時)。有些演算法可以針對非同步模型來設計,但這種模型限制極大。
除了時序問題,還必須考慮節點失效。常見的節點系統模型包括:
- 崩潰停止故障(crash-stop fault)
- 在 崩潰停止(crash-stop,或 故障停止,fail-stop)模型中,演算法可以假定節點只有一種失效方式:崩潰 109。節點可能在任意時刻突然停止響應,此後便永遠消失,再也不會回來。
- 崩潰恢復故障(crash-recovery fault)
- 我們假定節點可能在任意時刻崩潰,也可能在一段未知時間後重新開始響應。在崩潰恢復模型中,節點擁有能在崩潰後保留資料的穩定儲存(即非易失性磁碟儲存),但記憶體中的狀態會丟失。
- 效能下降和功能不全
- 除了崩潰與重啟,節點還可能變慢:它們也許仍能響應健康檢查,卻慢得無法完成任何實際工作。例如,千兆網路介面可能因為驅動程式缺陷,吞吐量突然跌到 1 Kb/s 110;面臨記憶體壓力的程序可能把大部分時間花在垃圾回收上 111;磨損的 SSD 可能表現得極不穩定;高溫、聯結器鬆動、機械振動、電源問題、韌體缺陷等也會影響硬體 112。這類情形稱為 跛行節點(limping node)、灰色失效(gray failure)或 慢失效(fail-slow)113,甚至可能比徹底失效的節點更難處理。還有一種相關問題:程序不再執行原本應做的某些工作,其他功能卻仍在繼續,例如後臺執行緒崩潰或死鎖時 114。
- 拜占庭(任意)故障
- 節點可能做出任何行為,包括像上一節所述那樣欺騙其他節點。
對現實系統建模時,帶崩潰恢復故障的部分同步模型通常最有用。它允許出現無界網路延遲、程序暫停和慢節點。那麼,分散式演算法該如何應對這種模型呢?
定義演算法的正確性
要定義一個演算法怎樣才算 正確,可以描述它必須具備哪些 屬性。例如,排序演算法的輸出具有這樣一項屬性:對輸出列表中的任意兩個不同元素,位於左側的元素都小於位於右側的元素。這不過是用形式化語言定義一份列表何謂“有序”。
同理,我們也可以列出分散式演算法應具備的屬性,以此定義它怎樣才算正確。例如,如果演算法要為鎖生成柵欄令牌(參見 “用柵欄機制隔離殭屍與延遲請求”),我們可能要求它滿足以下屬性:
- 唯一性
- 任意兩個柵欄令牌請求都不能返回相同的值。
- 單調序列
- 如果請求 x 返回令牌 t**x,請求 y 返回令牌 t**y,而且 x 在 y 開始以前已經完成,那麼 t**x < t**y。
- 可用性
- 請求柵欄令牌且沒有崩潰的節點,最終會收到響應。
如果演算法在某個系統模型允許發生的所有情形下,始終滿足這些屬性,就可以說它在該系統模型中是正確的。然而,如果所有節點都崩潰,或所有網路延遲都突然變成無限長,那麼任何演算法都不可能完成工作。面對一個允許系統徹底失效的模型,我們怎樣才能仍然給出有用的保證呢?
安全性與活性
為了說清這個問題,有必要區分兩類不同的屬性:安全性(safety)與 活性(liveness)。在剛才的例子中,唯一性 和 單調序列 屬於安全屬性,可用性 則屬於活性屬性。
兩者究竟有何區別?一個明顯線索是,活性屬性的定義中往往含有“最終”二字。(沒錯,你已經猜到了:最終一致性 就是一項活性屬性 115。)
安全性常被非正式地定義為 壞事不會發生,活性則是 好事最終會發生。不過,最好不要過度解讀這種通俗說法,因為“好”與“壞”屬於價值判斷,並不太適合用來描述演算法。安全性與活性的嚴格定義要精確得多 116:
- 如果安全屬性遭到違反,我們可以指出它在哪一個具體時刻被破壞。例如,唯一性遭到破壞時,可以找到返回重複柵欄令牌的那次具體操作。安全屬性一旦被違反,就無法撤銷——損害已經造成。
- 活性屬性正好相反:它在某個時刻可能尚未成立(例如節點已經發出請求,卻還沒收到響應),但未來始終還有滿足它的希望(也就是最終收到響應)。
區分安全屬性與活性屬性的一個好處,是能幫助我們應對棘手的系統模型。對分散式演算法,通常要求安全屬性在系統模型允許的所有情形下都 始終 成立 108。也就是說,即使所有節點崩潰,或整個網路失效,演算法仍必須保證絕不返回錯誤結果,因而安全屬性依舊得到滿足。
而對活性屬性,我們可以附加條件。例如,可以規定只有在多數節點尚未崩潰、且網路最終能從中斷中恢復時,請求才必須得到響應。部分同步模型的定義要求系統最終回到同步狀態——也就是說,任何一次網路中斷都只能持續有限時間,隨後會得到修復。
將系統模型對映到現實世界
安全屬性、活性屬性和系統模型,對推理分散式演算法的正確性非常有用。但當演算法真正落地實現時,現實世界混亂的一面又會回來找麻煩;這時便清楚地看到,系統模型只是對現實的簡化抽象。
例如,崩潰恢復模型中的演算法通常假定穩定儲存裡的資料能挺過崩潰。但如果磁碟資料損壞了,或因硬體錯誤、配置錯誤而被清空,會發生什麼 117?如果伺服器存在韌體缺陷,重啟時明明硬碟連線無誤,卻無法識別它們,又會怎樣 118?
法定人數演算法(參見 “讀寫仲裁”)依賴節點記住自己聲稱已經儲存的資料。如果節點會患上“失憶症”,忘掉先前儲存的資料,就會破壞法定人數條件,進而破壞演算法的正確性。或許還需要定義一種新系統模型:假定穩定儲存在崩潰後通常得以保留,但偶爾也會丟失。可這樣的模型又會變得更加難以推理。
演算法的理論描述可以直接宣告,假定某些事情絕不會發生——在非拜占庭系統中,我們確實必須對哪些故障可能發生、哪些不可能發生作出假設。然而,真實實現也許仍要包含程式碼,處理那些理論上“不可能”發生的事情;哪怕處理方式歸結為 printf("Sucks to be you") 和 exit(666),也就是把殘局留給人類運維人員收拾 119。(這正是電腦科學與軟體工程之間的一項區別。)
這並不是說理論化、抽象化的系統模型毫無價值——恰恰相反。它們極其有助於把真實系統的複雜性提煉成一組可管理、可推理的故障,使我們能夠理解問題,並嘗試用系統化的方法加以解決。
形式化方法和隨機測試
怎樣知道一個演算法確實滿足所需屬性?併發、部分失效與網路延遲會產生海量潛在狀態。我們需要保證這些屬性在每一種可能狀態下都成立,還要確保沒有遺漏任何邊界情況。
一種辦法是對演算法進行形式化驗證:用數學語言描述演算法,再運用證明技術,證明它在系統模型允許的所有情形下都滿足所需屬性。證明演算法正確,並不表示它在真實系統中的 實現 必然始終行為正確。不過,這是非常好的一步,因為理論分析能夠發現演算法中的隱患;這些問題在真實系統中可能潛伏很久,直到某些異常情況打破了你的假設(例如時序假設)才突然發作。
把理論分析與經驗性測試結合起來,驗證實現的行為是否符合預期,是一種穩妥做法。基於屬性的測試(property-based testing)、模糊測試(fuzz testing)和確定性模擬測試(deterministic simulation testing,DST)等技術都利用隨機化,在各種不同情形下測試系統。Amazon Web Services 等公司已經成功地把這些技術組合運用於許多產品 120 121。
模型檢查與規範語言
模型檢查器(model checker)是幫助驗證演算法或系統行為是否符合預期的工具。演算法規範要用 TLA+、Gallina 或 FizzBee 等專門設計的語言來編寫。藉助這類語言,我們可以專注於演算法行為,不必糾纏於程式碼實現細節。模型檢查器隨後會系統地嘗試各種可能發生的情況,利用模型來驗證不變數是否在演算法的所有狀態中都成立。
嚴格來說,模型檢查無法證明演算法的不變數在每一種可能狀態下都成立,因為大多數現實演算法的狀態空間是無限的。要真正驗證所有狀態,需要給出形式化證明;這雖然可行,但通常比執行模型檢查器困難得多。因此,使用模型檢查器時,通常要把演算法模型縮減成一個可以完全驗證的近似版本,或是給執行設定某種上限(例如限制最多可以傳送多少條訊息)。這樣一來,只會在更長執行過程中出現的缺陷就無法被發現。
即便如此,模型檢查器仍然在易用性與發現隱蔽缺陷的能力之間取得了很好的平衡。CockroachDB、TiDB、Kafka 和許多其他分散式系統,都使用模型規範來發現並修復缺陷 122 123 124。例如,研究人員藉助 TLA+ 證明,檢視戳複製(VR)的文字描述存在歧義,可能導致資料丟失 125。
按照設計,模型檢查器執行的並非實際程式碼,而是一個只描述協議核心思想的簡化模型。這樣更容易系統地探索狀態空間,但也帶來規範與實現逐漸偏離的風險 126。可以檢查模型與真實實現的行為是否等價,不過這需要在真實實現中插樁 127。
故障注入
許多缺陷只有在機器或網路發生失效時才會觸發。故障注入(fault injection)是一種有效(有時也相當嚇人)的技術,用來驗證系統實現遇到問題時是否仍會按預期工作。思路很簡單:向正在執行的系統環境注入故障,再觀察系統如何反應。注入的故障可以是網路失效、機器崩潰、磁碟損壞、程序暫停——凡是你能想到的計算機出錯方式都可以嘗試。
故障注入測試通常在與系統實際生產環境十分相似的環境中執行;有些團隊甚至直接在生產環境中注入故障。Netflix 透過 Chaos Monkey 工具推廣了這種做法 128。在生產環境中注入故障通常稱為 混沌工程(chaos engineering),我們在 “可靠性與容錯” 中已經討論過。
執行故障注入測試時,首先要部署被測系統,以及故障注入協調者和指令碼。協調者負責決定注入哪些故障、何時注入;本地或遠端指令碼則負責讓單個節點或程序發生失效。注入指令碼會利用許多不同工具來觸發故障:Linux 程序可以用 kill 命令暫停或終止,磁碟可以用 umount 解除安裝,網路連線可以透過防火牆規則中斷。檢查故障注入期間以及之後的系統行為,就能確認系統是否一如預期。
觸發不同失效所需的工具五花八門,因此故障注入測試寫起來頗為繁瑣。常見做法是採用 Jepsen 之類的故障注入框架來簡化流程。這類框架整合了多種作業系統,並預置了大量故障注入器 129。Jepsen 在許多廣泛使用的系統中都成功發現過關鍵缺陷,成效極為顯著 130 131。
確定性模擬測試
確定性模擬測試(DST)也已成為模型檢查與故障注入的一種流行補充。它探索狀態空間的方式與模型檢查器相似,但測試的是真實程式碼,而不是模型。
在 DST 中,模擬器會自動執行大量隨機化的系統執行。模擬期間的網路通訊、I/O 和時鐘計時都由模擬元件取代,讓模擬器能夠精確控制各種時序與失效場景下,所有事情發生的先後順序。這樣一來,模擬器所能探索的情形遠多於手寫測試或故障注入。如果測試失敗,還可以重新執行,因為模擬器知道觸發失敗的準確操作序列;故障注入則無法對系統實施如此細粒度的控制。
DST 要求模擬器能夠控制網路延遲等一切非確定性來源。要讓程式碼變得確定,通常採用以下三種策略之一:
- 應用程式層
- 有些系統從一開始就以便於確定性執行程式碼為目標進行構建。例如,FoundationDB 是 DST 領域的先驅之一,它基於名為 Flow 的非同步通訊庫構建。Flow 為開發者提供了一個接入點,可以把確定性網路模擬注入系統 132。類似地,TigerBeetle 是一款原生支援 DST 的線上事務處理(OLTP)資料庫。它把系統狀態建模為狀態機,所有狀態變更都在單一事件迴圈內發生。再配合時鐘等確定性模擬原語,這種架構便能以確定性方式執行 133。
- 執行時層
- 帶非同步執行時與常用庫的程式語言,為引入確定性提供了接入點。可以用單執行緒執行時,強制所有非同步程式碼順序執行。例如,FrostDB 修改 Go 執行時,讓 goroutine 依次執行 134。Rust 的 madsim 庫也採用類似方式。Madsim 為 Tokio 非同步執行時 API、AWS S3 庫、Kafka 的 Rust 庫以及許多其他元件提供確定性實現。應用程式只需換用確定性的庫與執行時,無需修改自身程式碼,就能獲得確定性的測試執行。
- 機器層
- 除了在執行時修改程式碼,也可以讓整臺機器變得確定。這個過程十分精細:機器必須對所有通常帶有非確定性的呼叫給出確定性響應。Antithesis 等工具透過構建定製的虛擬機器監控器來實現這一點,由它把通常非確定性的操作替換成確定性操作。從時鐘到網路再到儲存,一切都必須納入考慮。完成後,開發者就能在虛擬機器監控器內的一組容器中執行整個分散式系統,得到一個完全確定的分散式系統。
DST 的優勢不止是能夠重放。Antithesis 等工具發現較為罕見的行為時,會把一次測試執行分叉成多個子執行,藉此嘗試探索應用程式中的更多程式碼路徑。確定性測試通常使用模擬時鐘與網路呼叫,因此執行速度可以快於現實中的時間流逝。例如,TigerBeetle 的時間抽象可以模擬網路延遲和超時,而無需真的等待足夠長時間讓超時觸發。這類技術讓模擬器能夠以更快速度探索更多程式碼路徑。
非確定性正是本章所討論的所有分散式系統難題的核心:併發、網路延遲、程序暫停、時鐘跳變和崩潰都會以不可預測的方式發生,而且系統每次執行時都可能不同。反過來說,如果能讓系統變得確定,許多事情都會大為簡化。
事實上,讓事物具有確定性是個簡單而強大的思想,在分散式系統設計中反覆出現。除了確定性模擬測試,前面幾章還介紹過多種利用確定性的方式:
- 事件溯源的一項關鍵優勢(參見 “事件溯源與 CQRS”),是可以確定性地重放事件日誌,重新構建派生的物化檢視。
- 工作流引擎(參見 “持久化執行與工作流”)依賴確定性的工作流定義,藉此提供持久化執行語義。
- 我們將在 “使用共享日誌” 中討論的 狀態機複製,會在每個副本上獨立執行相同的確定性事務序列,從而複製資料。我們已經見過這種思想的兩個變體:基於語句的複製(參見 “複製日誌的實現”),以及透過儲存過程序列執行事務(參見 “儲存過程的利弊”)。
不過,要讓程式碼具有徹底的確定性,仍須十分小心。即使已經消除了所有併發,並用確定性模擬替換 I/O、網路通訊、時鐘和隨機數生成器,系統裡仍可能殘留非確定性。例如,在某些程式語言中,遍歷雜湊表元素的順序可能不確定;是否會觸及資源上限(記憶體分配失敗、棧溢位)同樣具有非確定性。
總結
本章討論了分散式系統中可能發生的各種問題,其中包括:
- 每當試圖透過網路傳送資料包時,它都可能丟失或遭到任意延遲。響應同樣可能丟失或延遲,因此只要沒有收到響應,你就無從知道訊息是否送達。
- 即使已經盡力配置 NTP,一個節點的時鐘仍可能與其他節點嚴重不同步,也可能突然向前或向後跳變。依賴這樣的時鐘非常危險,因為你多半無法可靠估計其置信區間。
- 程序可能在執行到任意位置時暫停很長時間,被其他節點宣告死亡;隨後它又恢復執行,卻完全沒有意識到自己曾經暫停。
可能出現這類 部分失效,正是分散式系統的決定性特徵。只要軟體試圖完成任何涉及其他節點的事情,就可能偶爾失敗、莫名其妙地變慢,或徹底不作響應(最終超時)。在分散式系統中,我們會把容忍部分失效的能力構建到軟體裡,使整個系統即使有部分元件損壞,也能繼續工作。
要容忍故障,第一步是 檢測 故障,可就連這一步也很困難。大多數系統都沒有準確判斷節點是否已經失效的機制,因此多數分散式演算法只能依靠超時來判斷遠端節點是否仍然可用。然而,超時無法區分網路失效與節點失效,而網路延遲的波動有時又會讓系統錯誤地懷疑某個節點已經崩潰。跛行節點更加難以處理:它們仍在響應,卻慢得根本做不了任何有用的工作。
即便檢測出了故障,要讓系統容忍它也並不容易:機器之間既沒有全域性變數,沒有共享記憶體,沒有公共知識,也沒有其他形式的共享狀態 83。節點甚至無法就“現在幾點”達成一致,更不用說更深刻的問題了。資訊從一個節點流向另一個節點的唯一途徑,就是經由不可靠網路傳送。重大決策不能安全地交給單個節點,因此我們需要協議來召集其他節點參與,並爭取讓達到法定人數的節點達成一致。
如果你習慣於在單臺計算機那種數學般完美的理想環境中編寫軟體——同一個操作總會確定性地返回相同結果——那麼轉向分散式系統混亂的物理現實,難免會感到震驚。反過來,分散式系統工程師常常認為,只要能在單臺計算機上解決,問題就微不足道 4。事實上,如今單臺計算機確實能做很多事情。如果能夠避免開啟潘多拉魔盒,把工作簡單地留在一臺機器上,例如使用嵌入式儲存引擎(參見 “嵌入式儲存引擎”),通常就值得這樣做。
不過,正如 “分散式與單節點系統” 所討論的,可伸縮性並不是採用分散式系統的唯一理由。容錯與低延遲(把資料放在地理位置更靠近使用者的地方)同樣重要,而這些目標無法靠單個節點實現。分散式系統的力量在於,原則上它可以在服務層面永不停機,因為所有故障和維護都能在節點層面處理。(當然在實踐中,如果把一項錯誤配置釋出到所有節點,分散式系統照樣會被徹底擊垮。)
本章還稍稍岔開話題,探討網路、時鐘和程序的不可靠性是否屬於無可避免的自然法則。答案是否定的:網路可以提供硬實時響應保證與有界延遲,只不過代價極其高昂,硬體資源的利用率也會隨之降低。大多數非安全攸關係統都會在便宜而不可靠與昂貴而可靠之間選擇前者。
本章始終在討論問題,呈現出一幅頗為黯淡的圖景。下一章將轉向解決方案,討論一些專為應對分散式系統問題而設計的演算法。
參考文獻
Mark Cavage. There’s Just No Getting Around It: You’re Building a Distributed System. ACM Queue, volume 11, issue 4, pages 80-89, April 2013. doi:10.1145/2466486.2482856 ↩︎
Jay Kreps. Getting Real About Distributed System Reliability. blog.empathybox.com, March 2012. Archived at perma.cc/9B5Q-AEBW ↩︎
Coda Hale. You Can’t Sacrifice Partition Tolerance. codahale.com, October 2010. https://perma.cc/6GJU-X4G5 ↩︎
Jeff Hodges. Notes on Distributed Systems for Young Bloods. somethingsimilar.com, January 2013. Archived at perma.cc/B636-62CE ↩︎ ↩︎
Van Jacobson. Congestion Avoidance and Control. At ACM Symposium on Communications Architectures and Protocols (SIGCOMM), August 1988. doi:10.1145/52324.52356 ↩︎ ↩︎
Bert Hubert. The Ultimate SO_LINGER Page, or: Why Is My TCP Not Reliable. blog.netherlabs.nl, January 2009. Archived at perma.cc/6HDX-L2RR ↩︎ ↩︎
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 ↩︎
Peter Bailis and Kyle Kingsbury. The Network Is Reliable. ACM Queue, volume 12, issue 7, pages 48-55, July 2014. doi:10.1145/2639988.2655736 ↩︎ ↩︎
Joshua B. Leners, Trinabh Gupta, Marcos K. Aguilera, and Michael Walfish. Taming Uncertainty in Distributed Systems with Help from the Network. At 10th European Conference on Computer Systems (EuroSys), April 2015. doi:10.1145/2741948.2741976 ↩︎ ↩︎
Phillipa Gill, Navendu Jain, and Nachiappan Nagappan. Understanding Network Failures in Data Centers: Measurement, Analysis, and Implications. At ACM SIGCOMM Conference, August 2011. doi:10.1145/2018436.2018477 ↩︎
Urs Hölzle. But recently a farmer had started grazing a herd of cows nearby. And whenever they stepped on the fiber link, they bent it enough to cause a blip. x.com, May 2020. Archived at perma.cc/WX8X-ZZA5 ↩︎
CBC News. Hundreds lose internet service in northern B.C. after beaver chews through cable. cbc.ca, April 2021. Archived at perma.cc/UW8C-H2MY ↩︎
Will Oremus. The Global Internet Is Being Attacked by Sharks, Google Confirms. slate.com, August 2014. Archived at perma.cc/P6F3-C6YG ↩︎
Jess Auerbach Jahajeeah. Down to the wire: The ship fixing our internet. continent.substack.com, November 2023. Archived at perma.cc/DP7B-EQ7S ↩︎
Santosh Janardhan. More details about the October 4 outage. engineering.fb.com, October 2021. Archived at perma.cc/WW89-VSXH ↩︎
Tom Parfitt. Georgian woman cuts off web access to whole of Armenia. theguardian.com, April 2011. Archived at perma.cc/KMC3-N3NZ ↩︎
Antonio Voce, Tural Ahmedzade and Ashley Kirk. ‘Shadow fleets’ and subaquatic sabotage: are Europe’s undersea internet cables under attack? theguardian.com, March 2025. Archived at perma.cc/HA7S-ZDBV ↩︎
Shengyun Liu, Paolo Viotti, Christian Cachin, Vivien Quéma, and Marko Vukolić. XFT: Practical Fault Tolerance beyond Crashes. At 12th USENIX Symposium on Operating Systems Design and Implementation (OSDI), November 2016. ↩︎
Mark Imbriaco. Downtime last Saturday. github.blog, December 2012. Archived at perma.cc/M7X5-E8SQ ↩︎ ↩︎
Tom Lianza and Chris Snook. A Byzantine failure in the real world. blog.cloudflare.com, November 2020. Archived at perma.cc/83EZ-ALCY ↩︎ ↩︎
Mohammed Alfatafta, Basil Alkhatib, Ahmed Alquraan, and Samer Al-Kiswany. Toward a Generic Fault Tolerance Technique for Partial Network Partitioning. At 14th USENIX Symposium on Operating Systems Design and Implementation (OSDI), November 2020. ↩︎
Marc A. Donges. Re: bnx2 cards Intermittantly Going Offline. Message to Linux netdev mailing list, spinics.net, September 2012. Archived at perma.cc/TXP6-H8R3 ↩︎ ↩︎
Troy Toman. Inside a CODE RED: Network Edition. signalvnoise.com, September 2020. Archived at perma.cc/BET6-FY25 ↩︎
Kyle Kingsbury. Call Me Maybe: Elasticsearch. aphyr.com, June 2014. perma.cc/JK47-S89J ↩︎
Salvatore Sanfilippo. A Few Arguments About Redis Sentinel Properties and Fail Scenarios. antirez.com, October 2014. perma.cc/8XEU-CLM8 ↩︎
Nicolas Liochon. CAP: If All You Have Is a Timeout, Everything Looks Like a Partition. blog.thislongrun.com, May 2015. Archived at perma.cc/FS57-V2PZ ↩︎
Matthew P. Grosvenor, Malte Schwarzkopf, Ionel Gog, Robert N. M. Watson, Andrew W. Moore, Steven Hand, and Jon Crowcroft. Queues Don’t Matter When You Can JUMP Them! At 12th USENIX Symposium on Networked Systems Design and Implementation (NSDI), May 2015. ↩︎ ↩︎
Theo Julienne. Debugging network stalls on Kubernetes. github.blog, November 2019. Archived at perma.cc/K9M8-XVGL ↩︎
Guohui Wang and T. S. Eugene Ng. The Impact of Virtualization on Network Performance of Amazon EC2 Data Center. At 29th IEEE International Conference on Computer Communications (INFOCOM), March 2010. doi:10.1109/INFCOM.2010.5461931 ↩︎ ↩︎
Brandon Philips. etcd: Distributed Locking and Service Discovery. At Strange Loop, September 2014. ↩︎
Steve Newman. A Systematic Look at EC2 I/O. blog.scalyr.com, October 2012. Archived at perma.cc/FL4R-H2VE ↩︎ ↩︎
Naohiro Hayashibara, Xavier Défago, Rami Yared, and Takuya Katayama. The ϕ Accrual Failure Detector. Japan Advanced Institute of Science and Technology, School of Information Science, Technical Report IS-RR-2004-010, May 2004. Archived at perma.cc/NSM2-TRYA ↩︎
Jeffrey Wang. Phi Accrual Failure Detector. ternarysearch.blogspot.co.uk, August 2013. perma.cc/L452-AMLV ↩︎
Srinivasan Keshav. An Engineering Approach to Computer Networking: ATM Networks, the Internet, and the Telephone Network. Addison-Wesley Professional, May 1997. ISBN: 978-0-201-63442-6 ↩︎ ↩︎
Othmar Kyas. ATM Networks. International Thomson Publishing, 1995. ISBN: 978-1-850-32128-6 ↩︎
Mellanox Technologies. InfiniBand FAQ, Rev 1.3. network.nvidia.com, December 2014. Archived at perma.cc/LQJ4-QZVK ↩︎
Jose Renato Santos, Yoshio Turner, and G. (John) Janakiraman. End-to-End Congestion Control for InfiniBand. At 22nd Annual Joint Conference of the IEEE Computer and Communications Societies (INFOCOM), April 2003. Also published by HP Laboratories Palo Alto, Tech Report HPL-2002-359. doi:10.1109/INFCOM.2003.1208949 ↩︎
Jialin Li, Naveen Kr. Sharma, Dan R. K. Ports, and Steven D. Gribble. Tales of the Tail: Hardware, OS, and Application-level Sources of Tail Latency. At ACM Symposium on Cloud Computing (SOCC), November 2014. doi:10.1145/2670979.2670988 ↩︎
Ulrich Windl, David Dalton, Marc Martinec, and Dale R. Worley. The NTP FAQ and HOWTO. ntp.org, November 2006. ↩︎ ↩︎ ↩︎
John Graham-Cumming. How and why the leap second affected Cloudflare DNS. blog.cloudflare.com, January 2017. Archived at archive.org ↩︎ ↩︎
David Holmes. Inside the Hotspot VM: Clocks, Timers and Scheduling Events – Part I – Windows. blogs.oracle.com, October 2006. Archived at archive.org ↩︎
Joran Dirk Greef. Three Clocks are Better than One. tigerbeetle.com, August 2021. Archived at perma.cc/5RXG-EU6B ↩︎
Oliver Yang. Pitfalls of TSC usage. oliveryang.net, September 2015. Archived at perma.cc/Z2QY-5FRA ↩︎
Steve Loughran. Time on Multi-Core, Multi-Socket Servers. steveloughran.blogspot.co.uk, September 2015. Archived at perma.cc/7M4S-D4U6 ↩︎
James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012. ↩︎ ↩︎ ↩︎
M. Caporaloni and R. Ambrosini. How Closely Can a Personal Computer Clock Track the UTC Timescale Via the Internet? European Journal of Physics, volume 23, issue 4, pages L17–L21, June 2012. doi:10.1088/0143-0807/23/4/103 ↩︎
Nelson Minar. A Survey of the NTP Network. alumni.media.mit.edu, December 1999. Archived at perma.cc/EV76-7ZV3 ↩︎
Viliam Holub. Synchronizing Clocks in a Cassandra Cluster Pt. 1 – The Problem. blog.rapid7.com, March 2014. Archived at perma.cc/N3RV-5LNL ↩︎
Poul-Henning Kamp. The One-Second War (What Time Will You Die?) ACM Queue, volume 9, issue 4, pages 44–48, April 2011. doi:10.1145/1966989.1967009 ↩︎
Nelson Minar. Leap Second Crashes Half the Internet. somebits.com, July 2012. Archived at perma.cc/2WB8-D6EU ↩︎
Christopher Pascoe. Time, Technology and Leaping Seconds. googleblog.blogspot.co.uk, September 2011. Archived at perma.cc/U2JL-7E74 ↩︎
Mingxue Zhao and Jeff Barr. Look Before You Leap – The Coming Leap Second and AWS. aws.amazon.com, May 2015. Archived at perma.cc/KPE9-XMFM ↩︎
Darryl Veitch and Kanthaiah Vijayalayan. Network Timing and the 2015 Leap Second. At 17th International Conference on Passive and Active Measurement (PAM), April 2016. doi:10.1007/978-3-319-30505-9_29 ↩︎
VMware, Inc. Timekeeping in VMware Virtual Machines. vmware.com, October 2008. Archived at perma.cc/HM5R-T5NF ↩︎
Victor Yodaiken. Clock Synchronization in Finance and Beyond. yodaiken.com, November 2017. Archived at perma.cc/9XZD-8ZZN ↩︎
Mustafa Emre Acer, Emily Stark, Adrienne Porter Felt, Sascha Fahl, Radhika Bhargava, Bhanu Dev, Matt Braithwaite, Ryan Sleevi, and Parisa Tabriz. Where the Wild Warnings Are: Root Causes of Chrome HTTPS Certificate Errors. At ACM SIGSAC Conference on Computer and Communications Security (CCS), pages 1407–1420, October 2017. doi:10.1145/3133956.3134007 ↩︎
European Securities and Markets Authority. MiFID II / MiFIR: Regulatory Technical and Implementing Standards – Annex I. esma.europa.eu, Report ESMA/2015/1464, September 2015. Archived at perma.cc/ZLX9-FGQ3 ↩︎
Luke Bigum. Solving MiFID II Clock Synchronisation With Minimum Spend (Part 1). catach.blogspot.com, November 2015. Archived at perma.cc/4J5W-FNM4 ↩︎
Oleg Obleukhov and Ahmad Byagowi. How Precision Time Protocol is being deployed at Meta. engineering.fb.com, November 2022. Archived at perma.cc/29G6-UJNW ↩︎
John Wiseman. gpsjam.org, July 2022. ↩︎
Josh Levinson, Julien Ridoux, and Chris Munns. It’s About Time: Microsecond-Accurate Clocks on Amazon EC2 Instances. aws.amazon.com, November 2023. Archived at perma.cc/56M6-5VMZ ↩︎
Kyle Kingsbury. Call Me Maybe: Cassandra. aphyr.com, September 2013. Archived at perma.cc/4MBR-J96V ↩︎ ↩︎ ↩︎
John Daily. Clocks Are Bad, or, Welcome to the Wonderful World of Distributed Systems. riak.com, November 2013. Archived at perma.cc/4XB5-UCXY ↩︎ ↩︎
Marc Brooker. It’s About Time! brooker.co.za, November 2023. Archived at perma.cc/N6YK-DRPA ↩︎
Kyle Kingsbury. The Trouble with Timestamps. aphyr.com, October 2013. Archived at perma.cc/W3AM-5VAV ↩︎
Leslie Lamport. Time, Clocks, and the Ordering of Events in a Distributed System. Communications of the ACM, volume 21, issue 7, pages 558–565, July 1978. doi:10.1145/359545.359563 ↩︎
Justin Sheehy. There Is No Now: Problems With Simultaneity in Distributed Systems. ACM Queue, volume 13, issue 3, pages 36–41, March 2015. doi:10.1145/2733108 ↩︎
Murat Demirbas. Spanner: Google’s Globally-Distributed Database. muratbuffalo.blogspot.co.uk, July 2013. Archived at perma.cc/6VWR-C9WB ↩︎
Dahlia Malkhi and Jean-Philippe Martin. Spanner’s Concurrency Control. ACM SIGACT News, volume 44, issue 3, pages 73–77, September 2013. doi:10.1145/2527748.2527767 ↩︎
Franck Pachot. Achieving Precise Clock Synchronization on AWS. yugabyte.com, December 2024. Archived at perma.cc/UYM6-RNBS ↩︎
Spencer Kimball. Living Without Atomic Clocks: Where CockroachDB and Spanner diverge. cockroachlabs.com, January 2022. Archived at perma.cc/AWZ7-RXFT ↩︎
Murat Demirbas. Use of Time in Distributed Databases (part 4): Synchronized clocks in production databases. muratbuffalo.blogspot.com, January 2025. Archived at perma.cc/9WNX-Q9U3 ↩︎
Cary G. Gray and David R. Cheriton. Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency. At 12th ACM Symposium on Operating Systems Principles (SOSP), December 1989. doi:10.1145/74850.74870 ↩︎
Daniel Sturman, Scott Delap, Max Ross, et al. Roblox Return to Service. corp.roblox.com, January 2022. Archived at perma.cc/8ALT-WAS4 ↩︎
Todd Lipcon. Avoiding Full GCs with MemStore-Local Allocation Buffers. slideshare.net, February 2011. Archived at https://perma.cc/CH62-2EWJ ↩︎
Christopher Clark, Keir Fraser, Steven Hand, Jacob Gorm Hansen, Eric Jul, Christian Limpach, Ian Pratt, and Andrew Warfield. Live Migration of Virtual Machines. At 2nd USENIX Symposium on Symposium on Networked Systems Design & Implementation (NSDI), May 2005. ↩︎
Mike Shaver. fsyncers and Curveballs. shaver.off.net, May 2008. Archived at archive.org ↩︎
Zhenyun Zhuang and Cuong Tran. Eliminating Large JVM GC Pauses Caused by Background IO Traffic. engineering.linkedin.com, February 2016. Archived at perma.cc/ML2M-X9XT ↩︎
Martin Thompson. Java Garbage Collection Distilled. mechanical-sympathy.blogspot.co.uk, July 2013. Archived at perma.cc/DJT3-NQLQ ↩︎ ↩︎
David Terei and Amit Levy. Blade: A Data Center Garbage Collector. arXiv:1504.02578, April 2015. ↩︎
Martin Maas, Tim Harris, Krste Asanović, and John Kubiatowicz. Trash Day: Coordinating Garbage Collection in Distributed Systems. At 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2015. ↩︎
Martin Fowler. The LMAX Architecture. martinfowler.com, July 2011. Archived at perma.cc/5AV4-N6RJ ↩︎
Joseph Y. Halpern and Yoram Moses. Knowledge and common knowledge in a distributed environment. Journal of the ACM (JACM), volume 37, issue 3, pages 549–587, July 1990. doi:10.1145/79147.79161 ↩︎ ↩︎
Chuzhe Tang, Zhaoguo Wang, Xiaodong Zhang, Qianmian Yu, Binyu Zang, Haibing Guan, and Haibo Chen. Ad Hoc Transactions in Web Applications: The Good, the Bad, and the Ugly. At ACM International Conference on Management of Data (SIGMOD), June 2022. doi:10.1145/3514221.3526120 ↩︎
Flavio P. Junqueira and Benjamin Reed. ZooKeeper: Distributed Process Coordination. O’Reilly Media, 2013. ISBN: 978-1-449-36130-3 ↩︎ ↩︎
Enis Söztutar. HBase and HDFS: Understanding Filesystem Usage in HBase. At HBaseCon, June 2013. Archived at perma.cc/4DXR-9P88 ↩︎
SUSE LLC. SUSE Linux Enterprise High Availability 15 SP6 Administration Guide, Section 12: Fencing and STONITH. documentation.suse.com, March 2025. Archived at perma.cc/8LAR-EL9D ↩︎
Mike Burrows. The Chubby Lock Service for Loosely-Coupled Distributed Systems. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006. ↩︎
Kyle Kingsbury. etcd 3.4.3. jepsen.io, January 2020. Archived at perma.cc/2P3Y-MPWU ↩︎
Ensar Basri Kahveci. Distributed Locks are Dead; Long Live Distributed Locks! hazelcast.com, April 2019. Archived at perma.cc/7FS5-LDXE ↩︎
Martin Kleppmann. How to do distributed locking. martin.kleppmann.com, February 2016. Archived at perma.cc/Y24W-YQ5L ↩︎
Salvatore Sanfilippo. Is Redlock safe? antirez.com, February 2016. Archived at perma.cc/B6GA-9Q6A ↩︎
Gunnar Morling. Leader Election With S3 Conditional Writes. www.morling.dev, August 2024. Archived at perma.cc/7V2N-J78Y ↩︎
Leslie Lamport, Robert Shostak, and Marshall Pease. The Byzantine Generals Problem. ACM Transactions on Programming Languages and Systems (TOPLAS), volume 4, issue 3, pages 382–401, July 1982. doi:10.1145/357172.357176 ↩︎
Jim N. Gray. Notes on Data Base Operating Systems. in Operating Systems: An Advanced Course, Lecture Notes in Computer Science, volume 60, edited by R. Bayer, R. M. Graham, and G. Seegmüller, pages 393–481, Springer-Verlag, 1978. ISBN: 978-3-540-08755-7. Archived at perma.cc/7S9M-2LZU ↩︎
Brian Palmer. How Complicated Was the Byzantine Empire? slate.com, October 2011. Archived at perma.cc/AN7X-FL3N ↩︎
Leslie Lamport. My Writings. lamport.azurewebsites.net, December 2014. Archived at perma.cc/5NNM-SQGR ↩︎
John Rushby. Bus Architectures for Safety-Critical Embedded Systems. At 1st International Workshop on Embedded Software (EMSOFT), October 2001. doi:10.1007/3-540-45449-7_22 ↩︎ ↩︎
Jake Edge. ELC: SpaceX Lessons Learned. lwn.net, March 2013. Archived at perma.cc/AYX8-QP5X ↩︎
Shehar Bano, Alberto Sonnino, Mustafa Al-Bassam, Sarah Azouvi, Patrick McCorry, Sarah Meiklejohn, and George Danezis. SoK: Consensus in the Age of Blockchains. At 1st ACM Conference on Advances in Financial Technologies (AFT), October 2019. doi:10.1145/3318041.3355458 ↩︎
Ezra Feilden, Adi Oltean, and Philip Johnston. Why we should train AI in space. White Paper, starcloud.com, September 2024. Archived at perma.cc/7Y3S-8UB6 ↩︎
James Mickens. The Saddest Moment. USENIX ;login, May 2013. Archived at perma.cc/T7BZ-XCFR ↩︎
Martin Kleppmann and Heidi Howard. Byzantine Eventual Consistency and the Fundamental Limits of Peer-to-Peer Databases. arxiv.org, December 2020. doi:10.48550/arXiv.2012.00472 ↩︎
Martin Kleppmann. Making CRDTs Byzantine Fault Tolerant. At 9th Workshop on Principles and Practice of Consistency for Distributed Data (PaPoC), April 2022. doi:10.1145/3517209.3524042 ↩︎
Evan Gilman. The Discovery of Apache ZooKeeper’s Poison Packet. pagerduty.com, May 2015. Archived at perma.cc/RV6L-Y5CQ ↩︎ ↩︎
Jonathan Stone and Craig Partridge. When the CRC and TCP Checksum Disagree. At ACM Conference on Applications, Technologies, Architectures, and Protocols for Computer Communication (SIGCOMM), August 2000. doi:10.1145/347059.347561 ↩︎
Evan Jones. How Both TCP and Ethernet Checksums Fail. evanjones.ca, October 2015. Archived at perma.cc/9T5V-B8X5 ↩︎
Cynthia Dwork, Nancy Lynch, and Larry Stockmeyer. Consensus in the Presence of Partial Synchrony. Journal of the ACM, volume 35, issue 2, pages 288–323, April 1988. doi:10.1145/42282.42283 ↩︎ ↩︎ ↩︎
Richard D. Schlichting and Fred B. Schneider. Fail-stop processors: an approach to designing fault-tolerant computing systems. ACM Transactions on Computer Systems (TOCS), volume 1, issue 3, pages 222–238, August 1983. doi:10.1145/357369.357371 ↩︎
Thanh Do, Mingzhe Hao, Tanakorn Leesatapornwongsa, Tiratat Patana-anake, and Haryadi S. Gunawi. Limplock: Understanding the Impact of Limpware on Scale-out Cloud Systems. At 4th ACM Symposium on Cloud Computing (SoCC), October 2013. doi:10.1145/2523616.2523627 ↩︎
Josh Snyder and Joseph Lynch. Garbage collecting unhealthy JVMs, a proactive approach. Netflix Technology Blog, netflixtechblog.medium.com, November 2019. Archived at perma.cc/8BTA-N3YB ↩︎
Haryadi S. Gunawi, Riza O. Suminto, Russell Sears, Casey Golliher, Swaminathan Sundararaman, Xing Lin, Tim Emami, Weiguang Sheng, Nematollah Bidokhti, Caitie McCaffrey, Gary Grider, Parks M. Fields, Kevin Harms, Robert B. Ross, Andree Jacobson, Robert Ricci, Kirk Webb, Peter Alvaro, H. Birali Runesha, Mingzhe Hao, and Huaicheng Li. Fail-Slow at Scale: Evidence of Hardware Performance Faults in Large Production Systems. At 16th USENIX Conference on File and Storage Technologies, February 2018. ↩︎
Peng Huang, Chuanxiong Guo, Lidong Zhou, Jacob R. Lorch, Yingnong Dang, Murali Chintalapati, and Randolph Yao. Gray Failure: The Achilles’ Heel of Cloud-Scale Systems. At 16th Workshop on Hot Topics in Operating Systems (HotOS), May 2017. doi:10.1145/3102980.3103005 ↩︎
Chang Lou, Peng Huang, and Scott Smith. Understanding, Detecting and Localizing Partial Failures in Large System Software. At 17th USENIX Symposium on Networked Systems Design and Implementation (NSDI), February 2020. ↩︎
Peter Bailis and Ali Ghodsi. Eventual Consistency Today: Limitations, Extensions, and Beyond. ACM Queue, volume 11, issue 3, pages 55-63, March 2013. doi:10.1145/2460276.2462076 ↩︎
Bowen Alpern and Fred B. Schneider. Defining Liveness. Information Processing Letters, volume 21, issue 4, pages 181–185, October 1985. doi:10.1016/0020-0190(85)90056-0 ↩︎
Flavio P. Junqueira. Dude, Where’s My Metadata? fpj.me, May 2015. Archived at perma.cc/D2EU-Y9S5 ↩︎
Scott Sanders. January 28th Incident Report. github.com, February 2016. Archived at perma.cc/5GZR-88TV ↩︎
Jay Kreps. A Few Notes on Kafka and Jepsen. blog.empathybox.com, September 2013. perma.cc/XJ5C-F583 ↩︎
Marc Brooker and Ankush Desai. Systems Correctness Practices at AWS. Queue, Volume 22, Issue 6, November/December 2024. doi:10.1145/3712057 ↩︎
Andrey Satarin. Testing Distributed Systems: Curated list of resources on testing distributed systems. asatarin.github.io. Archived at perma.cc/U5V8-XP24 ↩︎
Jack Vanlightly. Verifying Kafka transactions - Diary entry 2 - Writing an initial TLA+ spec. jack-vanlightly.com, December 2024. Archived at perma.cc/NSQ8-MQ5N ↩︎
Siddon Tang. From Chaos to Order — Tools and Techniques for Testing TiDB, A Distributed NewSQL Database. pingcap.com, April 2018. Archived at perma.cc/5EJB-R29F ↩︎
Nathan VanBenschoten. Parallel Commits: An atomic commit protocol for globally distributed transactions. cockroachlabs.com, November 2019. Archived at perma.cc/5FZ7-QK6J ↩︎
Jack Vanlightly. Paper: VR Revisited - State Transfer (part 3). jack-vanlightly.com, December 2022. Archived at perma.cc/KNK3-K6WS ↩︎
Hillel Wayne. What if the spec doesn’t match the code? buttondown.com, March 2024. Archived at perma.cc/8HEZ-KHER ↩︎
Lingzhi Ouyang, Xudong Sun, Ruize Tang, Yu Huang, Madhav Jivrajani, Xiaoxing Ma, Tianyin Xu. Multi-Grained Specifications for Distributed System Model Checking and Verification. At 20th European Conference on Computer Systems (EuroSys), March 2025. doi:10.1145/3689031.3696069 ↩︎
Yury Izrailevsky and Ariel Tseitlin. The Netflix Simian Army. netflixtechblog.com, July, 2011. Archived at perma.cc/M3NY-FJW6 ↩︎
Kyle Kingsbury. Jepsen: On the perils of network partitions. aphyr.com, May, 2013. Archived at perma.cc/W98G-6HQP ↩︎
Kyle Kingsbury. Jepsen Analyses. jepsen.io, 2024. Archived at perma.cc/8LDN-D2T8 ↩︎
Rupak Majumdar and Filip Niksic. Why is random testing effective for partition tolerance bugs? Proceedings of the ACM on Programming Languages (PACMPL), volume 2, issue POPL, article no. 46, December 2017. doi:10.1145/3158134 ↩︎
FoundationDB project authors. Simulation and Testing. apple.github.io. Archived at perma.cc/NQ3L-PM4C ↩︎
Alex Kladov. Simulation Testing For Liveness. tigerbeetle.com, July 2023. Archived at perma.cc/RKD4-HGCR ↩︎
Alfonso Subiotto Marqués. (Mostly) Deterministic Simulation Testing in Go. polarsignals.com, May 2024. Archived at perma.cc/ULD6-TSA4 ↩︎
10 一致性與共識

古諺有云:“出海切勿帶兩隻航海鍾;要麼帶一隻,要麼帶三隻。”
弗雷德裡克・P・布魯克斯,《人月神話:軟體工程隨筆》(1995)
正如第 9 章所述,分散式系統裡可能出錯的事情很多。要讓服務在這些故障發生時仍能正確執行,就必須設法容忍故障。
複製(replication)是實現容錯最有力的工具之一。然而,正如第 6 章所示,把同一份資料複製到多個副本,也帶來了不一致的風險。讀請求可能由尚未追上進度的副本處理,返回陳舊結果;如果多個副本都能接受寫入,還必須解決不同副本上併發寫入的值之間的衝突。從總體上看,處理這類問題有兩種彼此競爭的思路:
- 最終一致性(eventual consistency)
- 這種思路把系統採用複製這一事實暴露給應用,由應用開發者處理隨之而來的不一致與衝突。採用“多主複製”和“無主複製”的系統常常使用這種方式。
- 強一致性(strong consistency)
- 這種思路認為,應用不應操心複製的內部細節,系統應當表現得彷彿只有一個節點。它讓應用開發者的工作簡單得多,代價則是更強的一致性會損害效能,而且有些故障在最終一致系統中尚可容忍,卻會令強一致系統停擺。
一如既往,哪種方式更好取決於具體應用。如果應用允許使用者離線修改資料,那麼正如“同步引擎與本地優先軟體”所述,最終一致性不可避免。然而,應用要正確處理最終一致性也很困難。如果各副本位於通訊快速而可靠的資料中心,強一致性的成本通常可以接受,因而往往更合適。
本章將深入討論強一致性,重點考察三個方面:
- “強一致性”這個說法相當含糊,因此我們先給出一個更精確的目標:線性一致性(linearizability)。
- 接著討論 ID 和時間戳的生成。這個問題看似與一致性無關,實際上二者關係密切。
- 最後探討分散式系統如何既實現線性一致性,又保持容錯能力;答案在於 共識(consensus)演算法。
在此過程中,我們會看到,分散式系統中什麼可以做到、什麼無法做到,受到一些根本限制。
本章討論的內容素以難以正確實現而著稱。一個系統在沒有故障時執行良好並不難,難的是它可能在某種設計者未曾考慮的不利故障組合下徹底崩潰。為幫助我們推理這些邊界情況,研究者發展出了大量理論;藉助這些理論,我們才能構建真正穩健的容錯系統。
本章只能淺嘗輒止:我們會採用非形式化的直觀解釋,避開演算法的繁瑣細節、形式化模型和證明。如果你打算認真從事共識系統或類似基礎設施的工作,就必須深入掌握相關理論,否則很難讓系統真正可靠。與往常一樣,本章參考文獻可以作為進一步學習的起點。
線性一致性
要讓複製資料庫儘可能簡單易用,最好讓它表現得彷彿根本沒有複製。這樣,使用者就不必操心複製延遲、衝突和其他不一致問題:既能獲得容錯的好處,又不必承擔思考多個副本所帶來的複雜性。
這就是 線性一致性(linearizability)1(也稱為 原子一致性,atomic consistency 2,強一致性,strong consistency,即時一致性,immediate consistency,或 外部一致性,external consistency 3)背後的思想。線性一致性的精確定義相當微妙,本節餘下部分會逐步展開;其基本思想,是讓系統看起來彷彿只有一份資料,所有操作都原子地作用於這份資料。有了這種保證,即使實際上存在多個副本,應用也無需關心它們。
在線性一致的系統中,只要一個客戶端成功完成寫入,此後所有客戶端從資料庫讀取時,都必須能看到剛寫入的值。要維持“只有一份資料”的假象,就必須保證讀到的是最近寫入的最新值,而不是來自陳舊快取或副本的舊值。換句話說,線性一致性是一種 新鮮度保證(recency guarantee)。下面用一個不滿足線性一致性的系統來說明這一點。

圖 10-1展示了一個不滿足線性一致性的體育網站 4。Aaliyah 和 Bryce 坐在同一個房間裡,都在用手機關注自己喜愛球隊的比賽結果。終場比分剛剛公佈,Aaliyah 重新整理頁面,看到了獲勝方,便興奮地告訴 Bryce。Bryce 將信將疑地按下自己手機上的 重新整理,但請求被路由到一個落後的資料庫副本,於是頁面仍顯示比賽正在進行。
如果兩人同時重新整理,得到不同結果倒不那麼令人意外,因為誰也不知道伺服器究竟在什麼時刻處理了各自的請求。然而 Bryce 知道,自己是在聽見 Aaliyah 喊出終場比分 之後 才按下重新整理按鈕、發起查詢的,因此他有理由期待查詢結果至少不比 Aaliyah 看到的更舊。結果卻返回了陳舊資料,這就違反了線性一致性。
什麼使系統具有線性一致性?
為了更好地理解線性一致性,我們再看幾個例子。圖 10-2展示了三個客戶端如何併發讀寫線性一致資料庫中的同一個物件 x。在分散式系統理論中,x 稱為 暫存器(register);在實際系統裡,它可以是鍵值儲存中的一個鍵、關聯式資料庫中的一行,或文件資料庫中的一個文件。

為簡單起見,圖 10-2只展示客戶端看到的請求,不涉及資料庫內部。每根橫條代表客戶端發出的一次請求:左端是請求發出的時刻,右端是客戶端收到響應的時刻。由於網路延遲變化不定,客戶端不知道資料庫究竟何時處理了請求,只知道處理一定發生在請求發出與響應到達之間。
在這個例子中,暫存器有兩種型別的操作:
- read(x) ⇒ v 表示客戶端請求讀取暫存器 x,資料庫返回值 v。
- write(x, v) ⇒ r 表示客戶端請求把暫存器 x 設為 v,資料庫返回響應 r(可以是 ok 或 error)。
在圖 10-2中,x 的初始值為 0,客戶端 C 發出寫請求,要把它改為 1。在此期間,客戶端 A 和 B 不斷輪詢資料庫,讀取最新值。它們可能得到哪些響應?
- 客戶端 A 的第一次讀取在寫入開始前就已完成,因此必然返回舊值 0。
- 客戶端 A 的最後一次讀取在寫入完成後才開始,因此在線性一致的資料庫中必然返回新值 1,因為該讀取一定是在寫入之後處理的。
- 凡是時間上與寫操作重疊的讀取,都可能返回 0 或 1,因為我們不知道資料庫處理讀取時,寫入究竟是否已經生效。這些讀取與寫入是 併發 的。
但這還不足以完整描述線性一致性。如果與寫入併發的讀取可以任意返回舊值或新值,那麼在寫入期間,讀者可能看到值在新舊之間來回跳變。這不符合我們對“只有一份資料”的系統的預期。
要使系統滿足線性一致性,還需要增加一條約束,如圖 10-3所示。

在線性一致的系統中,可以設想在寫操作起止之間存在某個時刻,x 的值在那一刻原子地從 0 變為 1。因此,只要某個客戶端已經讀到新值 1,此後所有讀取也都必須返回 1,即使寫操作本身尚未結束。
圖 10-3用箭頭標出了這種時序依賴。客戶端 A 最先讀到新值 1;A 的讀取剛一返回,B 就開始了新的讀取。由於 B 的讀取嚴格晚於 A 的讀取,它也必須返回 1,哪怕 C 的寫入仍在進行。(這與圖 10-1中 Aaliyah 和 Bryce 的情形相同:Aaliyah 已經讀到新值之後,Bryce 也理應讀到新值。)
還可以進一步細化時序圖,把每個操作視為在某個時刻原子生效 5,如圖 10-4這個更複雜的例子所示。除了 read 和 write,圖中又加入了第三種操作:
- cas(x, v old, v new) ⇒ r 表示客戶端請求執行原子 比較並設定(compare-and-set)操作(參見“條件寫入(比較並設定)”)。如果暫存器 x 的當前值等於 v old,就原子地把它改為 v new;否則保持不變並返回錯誤。r 是資料庫的響應(ok 或 error)。
圖 10-4在每次操作的橫條內畫了一根豎線,表示我們認為該操作實際生效的時刻。把這些標記依次連起來,必須得到暫存器的一條合法讀寫序列——每次讀取都應返回最近一次寫入所設定的值。
線性一致性要求,連線這些操作標記的線只能沿時間向前移動(從左向右),絕不能倒退。這項要求保證了前面所說的新鮮度:一旦新值已經被寫入或讀到,此後的讀取就都必須看到這個值,直至它再次被覆蓋。

圖 10-4中有幾個細節值得注意:
- 客戶端 B 先發出讀取 x 的請求,隨後 D 請求把 x 設為 0,A 又請求把 x 設為 1;但 B 最終讀到了 1,也就是 A 寫入的值。這沒有問題:它說明資料庫先處理 D 的寫入,再處理 A 的寫入,最後才處理 B 的讀取。這個順序雖然不同於請求發出的順序,卻仍然合法,因為三次請求彼此併發。也許 B 的讀請求在網路中耽擱了一會兒,直到兩次寫入之後才抵達資料庫。
- 客戶端 B 在 A 收到資料庫確認“寫入 1 成功”的響應之前,就已經讀到了 1。這也沒有問題,只說明資料庫發給 A 的 ok 響應在網路中有所延遲。
- 這個模型不作任何事務隔離假設,其他客戶端隨時都可能修改值。例如,C 先讀到 1,隨後又讀到 2,是因為兩次讀取之間 B 修改了該值。原子的比較並設定(cas)操作可以檢查某個值是否已被其他客戶端併發修改:B 和 C 的 cas 請求成功,而 D 的 cas 請求失敗,因為資料庫處理該請求時,x 已經不再等於 0。
- 客戶端 B 最後一次讀取(陰影橫條)不滿足線性一致性。該讀取與 C 的 cas 寫入併發,後者把 x 從 2 改為 4。若沒有其他請求,B 返回 2 原本是允許的;但在 B 開始讀取之前,客戶端 A 已經讀到了新值 4,因此 B 不能再讀到比 A 更舊的值。這仍然是圖 10-1中 Aaliyah 與 Bryce 的同一種情形。
以上就是線性一致性的直觀含義,形式化定義 1 對此有更精確的描述。可以記錄所有請求與響應的時序,再檢查它們能否排成一條合法的順序序列,以此檢驗系統行為是否滿足線性一致性;只是這種檢驗的計算成本很高 6 7。
正如事務除了可序列化之外還有各種“弱隔離級別”,複製系統除了線性一致性之外,也有許多較弱的一致性模型 8。我們在“複製延遲的問題”中見過的 寫後讀、單調讀 和 一致字首讀,就是這類較弱保證。線性一致性不僅包含所有這些保證,還提供得更多。本章將集中討論線性一致性——實際系統中常用的最強一致性模型。
線性一致性很容易與“可序列化”混淆,因為兩個名稱看起來都像是在說“可以排成某種順序”。但二者是完全不同的保證,必須加以區分:
- 可序列化
- 可序列化是事務的一項隔離屬性;每個事務可以讀寫 多個物件(行、文件或記錄)。它保證事務的行為等同於按 某種 序列順序執行:先完整執行一個事務,再完整執行下一個事務,彼此不交錯。這個序列順序可以不同於事務實際執行的順序 9。
- 線性一致性
- 線性一致性是對暫存器(即 單個物件)讀寫的保證。它不會把操作組合成事務,因此無法防止“寫偏差與幻讀”這類涉及多個物件的問題。但線性一致性是一項 新鮮度 保證:如果一個操作在另一個操作開始前已經結束,那麼後一個操作必須觀察到至少與前一個操作同樣新的狀態。可序列化沒有這項要求,例如它允許陳舊讀取 10。
(順序一致性 又是另外一回事 8,但我們不會在這裡討論它。)
資料庫可以同時提供可序列化與線性一致性,這種組合稱為 嚴格可序列化(strict serializability)或 強單副本可序列化(strong one-copy serializability,strong-1SR)11 12。單節點資料庫通常滿足線性一致性。對於採用“可序列化快照隔離(SSI)”等樂觀方法的分散式資料庫,情況要複雜一些。例如,CockroachDB 提供可序列化以及一定的讀取新鮮度保證,卻不提供嚴格可序列化 13,因為後者要求事務之間進行代價高昂的協調 14。
也可以把較弱的隔離級別與線性一致性組合,或把較弱的一致性模型與可序列化組合。事實上,一致性模型與隔離級別在很大程度上可以獨立選擇 15 16。
依賴線性一致性
線性一致性在什麼情況下有用?檢視體育比賽的終場比分也許只是個無關緊要的例子:結果陳舊幾秒,通常不會造成實際損失。然而在少數領域,線性一致性卻是系統正確工作的必要條件。
鎖定與領導者選舉
採用單主複製的系統必須確保領導者確實只有一個,而不是同時出現多個領導者(腦裂)。一種選舉辦法是使用租約:每個啟動的節點都嘗試獲取租約,成功者成為領導者 17。無論底層機制如何實現,都必須滿足線性一致性,絕不能讓兩個不同節點同時獲得同一份租約。
Apache ZooKeeper 18、etcd 等協調服務經常用於實現分散式租約和領導者選舉。它們藉助共識演算法,以容錯方式提供線性一致的操作(本章稍後會討論這些演算法)。要正確實現租約和領導者選舉,還有許多微妙細節,例如“分散式鎖和租約”所述的柵欄問題。Apache Curator 等庫在 ZooKeeper 之上封裝了更高層的慣用方案,可以減輕這項工作;而所有這些協調任務的根基,仍是線性一致的儲存服務。
嚴格來說,ZooKeeper 的寫入滿足線性一致性,但讀取可能陳舊,因為它不保證讀請求一定由當前領導者處理 18。從版本 3 開始,etcd 預設提供線性一致的讀取。
一些分散式資料庫還會在細得多的粒度上使用分散式鎖,例如 Oracle Real Application Clusters(RAC)19。RAC 為每個磁碟頁設定一把鎖,多個節點共享同一套磁碟儲存。由於這些線性一致的鎖位於事務執行的關鍵路徑上,RAC 部署通常會使用專用的叢集互連網路,讓資料庫節點彼此通訊。
約束與唯一性保證
唯一性約束在資料庫中十分常見。例如,使用者名稱或電子郵件地址必須唯一標識一位使用者;檔案儲存服務也不能同時存在路徑和檔名完全相同的兩個檔案。如果要在寫入時強制執行這種約束——也就是說,兩個人併發建立同名使用者或檔案時,必須讓其中一人收到錯誤——就需要線性一致性。
這種情形其實與鎖很相似:使用者註冊服務時,可以看作在獲取所選使用者名稱的“鎖”。這個操作也很像原子的比較並設定:只要使用者名稱尚未被佔用,就把它設為認領該名稱的使用者 ID。
類似的問題還有很多:確保銀行賬戶餘額永不為負,商品銷量不超過倉庫庫存,或不讓兩個人同時訂到同一航班、同一劇場的同一個座位。這些約束都要求存在一個由所有節點共同認可的最新值,例如賬戶餘額、庫存數量或座位佔用狀態。
實際應用有時可以寬鬆處理此類約束。例如航班超售後,可以把旅客轉到另一趟航班,併為造成的不便提供補償。在這種情況下,線性一致性未必必要;“及時性與完整性”會進一步討論這種寬鬆解釋的約束。
不過,關聯式資料庫裡常見的硬性唯一約束確實需要線性一致性。外來鍵約束、屬性約束等其他約束則可以不依賴線性一致性來實現 20。
跨通道時序依賴
請注意圖 10-1中的一個細節:如果 Aaliyah 沒有喊出比分,Bryce 就不會知道自己的查詢結果已經陳舊。他只會在幾秒後再次重新整理,並最終看到終場比分。之所以能察覺違反線性一致性的現象,只是因為系統裡還有一條額外的通訊通道——Aaliyah 的聲音傳到了 Bryce 耳中。
計算機系統中也會出現類似情形。假設某網站允許使用者上傳影片,後臺程序會把影片轉碼為畫質較低的版本,以便透過慢速網路流式播放。系統架構和資料流如圖 10-5所示。
影片轉碼器必須收到明確指令才會執行轉碼作業,這條指令由 Web 伺服器透過訊息佇列傳送(參見“訊息傳遞系統”)。Web 伺服器不會把整段影片塞進佇列,因為大多數訊息代理是為短訊息設計的,而影片可能有數十兆位元組甚至更大。它會先把影片寫入檔案儲存服務,確認寫入完成後,再將轉碼指令放入佇列。

如果檔案儲存服務滿足線性一致性,這套系統就能正常工作;否則便可能出現競態條件:訊息佇列(圖 10-5中的步驟 3 和 4)也許比儲存服務內部的複製傳播得更快。這樣一來,轉碼器獲取原始影片時(步驟 5),可能讀到檔案的舊版本,甚至什麼也讀不到。如果它轉碼了舊版本,檔案儲存中的原始影片與轉碼版本就會永久不一致。
問題的根源在於,Web 伺服器與轉碼器之間存在兩條不同的通訊通道:檔案儲存和訊息佇列。沒有線性一致性提供的新鮮度保證,兩條通道之間就可能發生競態。這與圖 10-1完全類似:一條通道是資料庫複製,另一條則是從 Aaliyah 嘴裡到 Bryce 耳中的現實聲音。
能接收推送通知的移動應用也可能遇到類似競態:應用收到通知後會向伺服器獲取相關資料;如果讀請求可能落到滯後的副本上,推送通知也許很快就到了,緊隨其後的資料讀取卻看不到通知所指的更新。
線性一致性不是避免這類競態的唯一辦法,卻是最容易理解的一種。如果額外的通訊通道由你控制——訊息佇列屬於這種情況,Aaliyah 和 Bryce 之間的交流則不屬於——也可以採用與“讀己之寫”類似的替代方案,只是系統會更加複雜。
實現線性一致性系統
看過線性一致性的幾個用途後,接下來思考如何實現一個提供線性一致語義的系統。
線性一致性本質上要求系統“表現得彷彿只有一份資料,而且所有操作都原子地作用於它”,因此最簡單的實現就是真的只儲存一份資料。可惜這種方式無法容錯:儲存這份資料的節點一旦失效,資料就會丟失,至少也會在節點恢復前無法訪問。
讓我們重新審視第 6 章介紹的複製方法,看看它們能否實現線性一致性:
- 單主複製(可能線性一致)
- 在單主複製系統中,領導者儲存用於寫入的主副本,其他節點上的追隨者則維護備份。只要所有讀寫都由領導者處理,通常就有可能滿足線性一致性。但這依賴一個前提:你必須確切知道誰是領導者。正如“分散式鎖和租約”所述,節點完全可能誤以為自己仍是領導者;如果這個自以為是的領導者繼續處理請求,就很可能破壞線性一致性 21。採用非同步複製時,故障切換甚至可能丟失已經提交的寫入,同時違反永續性與線性一致性。
對單主資料庫進行分片、讓每個分片擁有各自的領導者,不會影響線性一致性,因為它只保證單個物件。跨分片事務則是另一個問題(參見“分散式事務”)。
- 共識演算法(很可能線性一致)
- 有些共識演算法本質上是增加了自動領導者選舉和故障切換的單主複製。它們經過精心設計以避免腦裂,因而能夠安全實現線性一致的儲存。例如,ZooKeeper 使用 Zab 共識演算法 22,etcd 使用 Raft 23。不過,系統採用了共識,並不等於它的所有操作都滿足線性一致性:如果某個節點處理讀取前沒有確認自己仍是領導者,那麼在剛剛選出新領導者時,它就可能返回陳舊結果。
- 多主複製(非線性一致)
- 多主複製系統通常不滿足線性一致性,因為多個節點會併發處理寫入,再把結果非同步複製到其他節點。因此,它們可能產生需要“處理寫入衝突”的併發寫入。
- 無主複製(很可能不滿足線性一致性)
- 對於採用無主複製的系統(Dynamo 風格,參見“無主複製”),有人聲稱,只要要求仲裁讀寫滿足 w + r > n,就能得到“強一致性”。這取決於具體演算法以及“強一致性”的定義,但通常並不準確。
Cassandra 和 ScyllaDB 等系統採用基於日曆時鐘的“最後寫入者勝”來解決衝突,這幾乎肯定不滿足線性一致性,因為時鐘偏差使時間戳無法保證與事件的實際順序一致(參見“對同步時鐘的依賴”)。即使採用仲裁讀寫,仍然可能出現違反線性一致性的行為,下一節會給出例子。
線性一致性與仲裁
直覺上,Dynamo 風格模型中的仲裁讀寫似乎應當滿足線性一致性。但當網路延遲變化不定時,仍然可能出現競態,如圖 10-6所示。

在圖 10-6中,x 的初始值為 0。一個寫入客戶端把請求發往全部三個副本(n = 3,w = 3),要將 x 更新為 1。與此同時,客戶端 A 從兩個節點讀取,達到讀法定人數(r = 2),並在其中一個節點上看到了新值 1;同樣與寫入併發的客戶端 B,則從另外兩個節點讀取,兩個節點都返回舊值 0。
儘管滿足法定人數條件 w + r > n,這次執行仍不滿足線性一致性:B 的請求開始於 A 的請求完成之後,卻返回了舊值,而 A 已經讀到新值。(這又是圖 10-1中 Aaliyah 和 Bryce 的情形。)
可以讓 Dynamo 風格的法定人數讀寫滿足線性一致性,但要犧牲效能:讀取方必須先同步完成“追趕錯過的寫入”所述的讀修復,再把結果返回應用 24;寫入方則必須在寫入前先讀取達到法定人數的節點的最新狀態,取得此前所有寫入中的最大時間戳,並確保新寫入使用更大的時間戳 25 26。Riak 因為效能代價而不執行同步讀修復。Cassandra 的仲裁讀確實會等待讀修復完成 27,但它使用日曆時鐘生成時間戳,因而仍然不滿足線性一致性。
而且,這種方式只能實現線性一致的讀寫;無法實現線性一致的比較並設定,因為後者需要共識演算法 28。
總之,最穩妥的假設是:採用 Dynamo 風格複製的無主系統即使使用仲裁讀寫,也不提供線性一致性。
線性一致性的代價
既然有些複製方式能夠提供線性一致性,有些不能,我們就有必要更仔細地考察它的利弊。
第 6 章已經討論過不同複製方式的適用場景。例如,對多地區複製而言,多主複製往往是不錯的選擇(參見“跨地域執行”)。圖 10-7展示了這樣一種部署。

考慮兩個地區之間網路中斷時會發生什麼。假設各地區內部的網路仍然正常,客戶端也能訪問本地區域,但兩個地區彼此無法通訊。這種情況稱為 網路分割槽(network partition)。
在多主資料庫中,每個地區都可以繼續正常工作:一地的寫入原本就非同步複製到另一地,因此網路中斷期間只需暫存排隊,連線恢復後再相互交換。
若採用單主複製,領導者必然位於其中一個地區。所有寫入和線性一致讀取都必須發給領導者;因此,連線到追隨者所在地區的客戶端,必須跨地區同步地把讀寫請求傳送到領導者所在地區。
單主配置下,一旦地區間網路中斷,追隨者所在地區的客戶端便無法聯絡領導者,因而既不能寫入資料庫,也不能執行線性一致讀取。它們仍可從追隨者讀取,但結果可能陳舊,不滿足線性一致性。如果應用要求線性一致的讀寫,那麼所有無法聯絡領導者的地區都會在網路中斷期間變得不可用。
能直接連線領導者所在地區的客戶端不受影響,應用在那裡仍可正常工作;但只能訪問追隨者所在地區的客戶端會一直停擺,直到網路鏈路修復。
CAP 定理
這個問題並非單主複製與多主複製特有:任何線性一致的資料庫,無論怎樣實現,都會面對同樣的困境。它也不限於多地區部署;任何不可靠的網路都可能發生這種情況,即使是在同一地區內。具體權衡如下:
- 如果應用 要求 線性一致性,而網路故障使一部分副本與其他副本失去聯絡,那麼斷開的副本就不能繼續處理請求:它們只能等待網路恢復,或立即返回錯誤;無論哪種方式,都變得 不可用。這種選擇有時稱為 CP(網路分割槽時保持一致)。
- 如果應用 不要求 線性一致性,就可以讓各副本在彼此斷開時仍獨立處理請求,例如採用多主複製。這樣,應用在網路故障期間仍然 可用,但行為不滿足線性一致性。這種選擇稱為 AP(網路分割槽時保持可用)。
因此,不要求線性一致性的應用可以更好地容忍網路問題。這一認識通常稱為 CAP 定理 29 30 31 32,由 Eric Brewer 於 2000 年命名,不過早在 20 世紀 70 年代,分散式資料庫設計者就已瞭解這種權衡 33 34 35。
CAP 最初只是一個沒有精確定義的經驗法則,目的是引發對資料庫權衡的討論。當時許多分散式資料庫都專注於在共享儲存的機器叢集上提供線性一致語義 19;CAP 則鼓勵資料庫工程師探索更廣闊的分散式無共享系統設計空間,而後者更適合承載大規模 Web 服務 36。CAP 推動了這種觀念轉變,也幫助催生了 NoSQL 運動以及 21 世紀頭十年中期湧現的大批新型資料庫技術。
CAP 有時被概括成 一致性、可用性、分割槽容錯性,三者擇二。遺憾的是,這種說法會誤導人 32:網路分割槽是一類故障,並不是可以自由取捨的選項——不管你願不願意,它都會發生。
網路正常時,系統完全可以同時提供一致性(線性一致性)與全面可用性;發生網路故障時,才必須在線性一致性和全面可用性之間取捨。因此,更準確的說法是:發生分割槽時,要麼一致,要麼可用 37。網路越可靠,面臨這種選擇的次數就越少,但它終究無法徹底避免。
CP/AP 分類還有幾個缺陷 4。其中的 一致性 被嚴格定義為線性一致性,定理對較弱的一致性模型隻字未提;可用性 的形式化定義 30 也不符合這個詞的通常含義 38。許多通常認為高度可用、具備容錯能力的系統,其實並不滿足 CAP 那套特殊的可用性定義。還有些系統設計者出於充分理由,既不提供線性一致性,也不提供 CAP 所假設的那種可用性,因此既不能歸為 CP,也不能歸為 AP 39 40。
總而言之,圍繞 CAP 的誤解與混淆太多,它無助於我們更好地理解系統,因此最好避免使用 CAP 這一框架。
形式化的 CAP 定理 30 適用範圍極窄:它只考察一種一致性模型(線性一致性)和一種故障(網路分割槽;Google 的資料表明,網路分割槽造成的事故不到 8% 41),對網路延遲、節點失效以及其他權衡都沒有說明。因此,CAP 雖然在歷史上影響深遠,對實際系統設計卻幾乎沒有指導價值 4 38。
有人嘗試把 CAP 推廣到更一般的情形。例如,PACELC 原則 指出,即便網路正常,系統設計者也可能為了降低延遲而削弱一致性 39 40 42:發生網路分割槽(P)時,需要在可用性(A)與一致性(C)之間選擇;否則(E),沒有分割槽時,則可能在低延遲(L)與一致性(C)之間選擇。不過,這個定義繼承了 CAP 的若干問題,例如對一致性和可用性的定義仍然有違直覺。
分散式系統領域還有許多更有意義的不可能性結論 43,CAP 也早已被更精確的結果取代 44 45。如今,它主要只剩下歷史意義。
線性一致性與網路延遲
線性一致性雖然很有用,實際滿足它的系統卻少得出人意料。甚至現代多核 CPU 上的 RAM 也不滿足線性一致性 46:一個 CPU 核上的執行緒寫入某個記憶體地址後,另一個核上的執行緒稍晚讀取同一地址,也不保證能看見前一個執行緒寫入的值,除非使用 記憶體屏障 或 柵欄 47。
原因在於,每個 CPU 核都有自己的快取和儲存緩衝區。預設情況下,記憶體訪問會先經過快取,修改再非同步寫回主存。訪問快取遠快於訪問主存 48,因此這種機制對現代 CPU 的效能不可或缺。但系統裡也因此出現了多份資料——主存中一份,各級快取裡可能還有幾份——而且它們非同步更新,於是失去了線性一致性。
為什麼要作這種取捨?用 CAP 定理解釋多核處理器的記憶體一致性模型毫無意義:在同一臺計算機內,我們通常假定通訊可靠,也不指望某個 CPU 核與計算機其他部分斷開後還能正常工作。這裡犧牲線性一致性是為了 效能,而不是容錯 39。
許多不提供線性一致性保證的分散式資料庫也是如此:主要目的是提高效能,而非增強容錯 42。線性一致性很慢,而且始終如此,並非只在網路故障時才慢。
有沒有更高效的線性一致儲存實現?答案似乎是否定的。Attiya 和 Welch 49 證明:要獲得線性一致性,讀寫請求的響應時間至少與網路延遲的不確定程度成正比。在大多數計算機網路這種延遲高度不穩定的環境裡(參見“超時和無界延遲”),線性一致讀寫的響應時間必然很高。不存在更快的線性一致性演算法,而較弱的一致性模型卻可以快得多,因此這種取捨對延遲敏感型系統十分重要。“及時性與完整性”將討論如何在不犧牲正確性的前提下繞開線性一致性。
ID 生成器和邏輯時鐘
許多應用在建立資料庫記錄時,都要為其分配某種唯一 ID,作為以後引用該記錄的主鍵。單節點資料庫通常使用自增整數,它的優點是隻需 64 位即可儲存;如果能確定記錄數永遠不會超過 40 億,甚至可以只用 32 位,不過這樣做頗有風險。
自增 ID 還有一個好處:ID 順序可以反映記錄的建立順序。例如,圖 10-8中的聊天應用會在訊息發出時為其分配自增 ID。按 ID 遞增順序展示訊息,對話就能保持合理的先後關係:Aaliyah 的問題得到 ID 1,而 Bryce 隨後的回答得到更大的 ID 3。

這種單節點 ID 生成器也是一個線性一致系統。每次取 ID 都會原子地遞增計數器並返回遞增前的值,這稱為 獲取並增加(fetch-and-add)操作。線性一致性保證:如果 Aaliyah 的訊息在 Bryce 開始發訊息之前已經發布完成,那麼 Bryce 的 ID 必須更大。圖 10-8中 Aaliyah 與 Caleb 的訊息彼此併發,因此線性一致性不規定二者的 ID 順序,只要求它們互不相同。
記憶體中的單節點 ID 生成器很容易實現:直接使用 CPU 提供的原子遞增指令,就能讓多個執行緒安全地更新同一個計數器。要把計數器做成持久的稍微麻煩一些,否則節點崩潰重啟後計數器會復位,產生重複 ID。但更棘手的問題是:
- 單節點 ID 生成器不具備容錯能力,因為這個節點本身就是單點故障。
- 如果要在另一個地區建立記錄,僅僅為了取得 ID,可能就得跨越半個地球往返一次,速度很慢。
- 寫入吞吐量很高時,這個節點可能成為瓶頸。
ID 生成器還有幾種替代方案:
- 分片 ID 分配
- 可以讓多個節點分別分配 ID,例如一個只生成偶數,另一個只生成奇數。更一般地,可以在 ID 中預留若干位來儲存分片編號。這種 ID 仍然緊湊,卻失去了順序含義:看到 ID 為 16 和 17 的兩條聊天訊息,並不能斷定訊息 16 先發出,因為兩個 ID 來自不同節點,而其中一個節點的進度可能領先於另一個。
- 預分配 ID 塊
- 單節點生成器不必逐個發放 ID,也可以一次分配一整塊。例如,節點 A 取得 1 到 1,000,節點 B 取得 1,001 到 2,000;此後各節點可在自己的區間內獨立發號,快用完時再申請下一塊。但這種方案同樣無法保證正確順序:一條訊息可能先從 1,001 到 2,000 的區間取得 ID,而稍後另一節點發出的訊息卻從 1 到 1,000 的區間取得了更小的 ID。
- 隨機 UUID
- 可以採用 通用唯一識別符號(UUID),也稱 全域性唯一識別符號(GUID)。它最大的好處是,任意節點都能在本地生成,無需通訊;代價是佔用更多空間(128 位)。UUID 有多個版本,最簡單的第 4 版本質上是一個足夠長的隨機數,兩個節點碰巧選中同一值的機率微乎其微。可惜這類 ID 的順序也是隨機的,比較兩個 ID 無法判斷哪個更新。
- 為日曆時鐘時間戳補充唯一性資訊
- 如果各節點透過 NTP 讓日曆時鐘大致準確,可以把時間戳放在 ID 的高位,再用額外資訊填充其餘位,保證即使時間戳相同,完整 ID 仍然唯一。例如,可以加入分片編號和分片內自增序列號,或一段足夠長的隨機值。第 7 版 UUID 50、Twitter Snowflake 51、ULID 52、Hazelcast Flake ID 生成器、MongoDB ObjectID 等許多方案都採用這種思路 50。這類 ID 生成器既可以在應用程式碼中實現,也可以放在資料庫內部 53。
這些方案都能生成唯一 ID——至少碰撞機率低到幾乎可以忽略——但它們提供的順序保證遠弱於單節點自增方案。
正如“用於事件排序的時間戳”所述,日曆時鐘時間戳至多隻能給出近似順序:如果較早的寫入讀取了略快的時鐘,較晚的寫入讀取了略慢的時鐘,時間戳順序就可能與事件實際順序相反。非單調時鐘還可能突然跳變,甚至讓同一節點生成的時間戳順序出錯。因此,基於日曆時鐘的 ID 生成器通常不滿足線性一致性。
使用原子鐘或 GPS 接收器進行高精度時鐘同步,可以減少這種順序錯亂。但如果無需特殊硬體,也能生成唯一且順序正確的 ID,當然更好。這正是 邏輯時鐘(logical clock)要解決的問題。
邏輯時鐘
在“不可靠的時鐘”中,我們討論了日曆時鐘和單調時鐘。二者都屬於 物理時鐘,度量的是經過了多少秒(或毫秒、微秒等)。
分散式系統還經常使用另一類時鐘,稱為 邏輯時鐘(logical clock)。物理時鐘是計算流逝秒數的硬體裝置;邏輯時鐘則是一種計算已發生事件數量的演算法。因此,邏輯時間戳不能告訴你現在是幾點,卻 可以 相互比較,判斷哪個較早、哪個較晚。
邏輯時鐘的要求通常是:
- 時間戳緊湊(只有幾個位元組)且唯一;
- 任意兩個時間戳都可以比較,也就是構成 全序;
- 時間戳順序與因果關係 一致:如果操作 A 先於 B 發生,那麼 A 的時間戳小於 B 的時間戳。(我們在“‘先發生’關係與併發”中討論過因果關係。)
單節點 ID 生成器滿足這些要求,而前面幾種分散式 ID 生成器不滿足因果順序要求。
Lamport 時間戳
幸運的是,有一種簡單方法可以生成與因果關係 一致 的邏輯時間戳,並將其用作分散式 ID。這就是 Leslie Lamport 於 1978 年提出的 Lamport 時鐘 54;介紹它的論文如今已成為分散式系統領域引用次數最多的論文之一。
圖 10-9展示了 Lamport 時鐘如何用於圖 10-8中的聊天示例。每個節點都有唯一識別符號;圖 10-9使用“Aaliyah”“Bryce”和“Caleb”作為標識,實際系統則可以使用隨機 UUID 等值。此外,每個節點維護一個計數器,記錄自己處理過多少次操作。Lamport 時間戳就是一個二元組(計數器,節點 ID)。不同節點的計數器值有時相同,但加入節點 ID 後,每個時間戳仍然唯一。

節點每生成一次時間戳,都會先遞增本地計數器並使用新值。節點每次看到其他節點生成的時間戳時,如果其中的計數器值大於自己的本地值,就把本地計數器向前推進到同一個值。
在圖 10-9中,Aaliyah 發出自己的訊息時尚未看見 Caleb 的訊息,Caleb 也一樣。假設兩人的計數器初始值都是 0,他們各自將其遞增為 1,並把新值附在訊息上。Bryce 收到這兩條訊息後,把自己的計數器推進到 1;隨後他回覆 Aaliyah 的訊息,再把本地計數器遞增為 2,並將 2 附在回覆上。
比較兩個 Lamport 時間戳時,先比較計數器值。例如,(2, “Bryce”) 大於 (1, “Aaliyah”),也大於 (1, “Caleb”)。若計數器相同,再按通常的字串字典序比較節點 ID。因此,本例中的時間戳順序為 (1, “Aaliyah”) < (1, “Caleb”) < (2, “Bryce”)。
混合邏輯時鐘
Lamport 時間戳很適合表示事件發生順序,但也有一些侷限:
- 它與物理時間沒有直接關係,所以無法據此查詢某個具體日期釋出的所有訊息;物理時間必須另行儲存。
- 如果兩個節點從不通訊,一個節點的計數器增長就永遠不會反映到另一個節點上。因此,不同節點在大致同一時刻生成的事件,計數器值可能相差懸殊。
混合邏輯時鐘(hybrid logical clock,HLC)兼具物理日曆時鐘的優點與 Lamport 時鐘的順序保證 55。它像物理時鐘一樣以秒或微秒計數;又像 Lamport 時鐘一樣,在看到其他節點更大的時間戳時,把自己的本地值向前推進到對方的時間戳。因此,如果某個節點的時鐘偏快,其他節點與它通訊後也會相應地把時鐘向前推進。
混合邏輯時鐘每次生成時間戳時也會遞增,從而保證始終單調向前,即使底層物理時鐘因 NTP 校正而向後跳變也不例外。因此,混合邏輯時鐘可能略微領先於底層物理時鐘;演算法會盡量把這項誤差控制在最小範圍內。
因此,混合邏輯時間戳幾乎可以像普通日曆時間戳一樣使用,同時還多了一項性質:其順序與先發生關係一致。它不依賴特殊硬體,只要求各時鐘大致同步。CockroachDB 就使用了混合邏輯時鐘。
Lamport/混合邏輯時鐘 vs. 向量時鐘
在“多版本併發控制(MVCC)”中,我們討論過快照隔離的一種常見實現:為每個事務分配事務 ID,讓它看見 ID 較小的事務所做的寫入,同時隱藏 ID 較大的事務所做的寫入。Lamport 時鐘和混合邏輯時鐘很適合生成這些事務 ID,因為它們可以保證快照與因果關係一致 56。
多個時間戳併發生成時,這些演算法會任意規定它們之間的順序。因此,只看兩個時間戳,通常無法判斷二者是併發生成,還是一個先於另一個發生。(在圖 10-9中,因為 Aaliyah 與 Caleb 的訊息具有相同計數器值,可以斷定它們彼此併發;但計數器值不同時,就無法作出這種判斷。)
如果需要判斷記錄是否併發建立,就得采用另一種演算法,例如 向量時鐘(vector clock)。它的缺點是時間戳大得多,可能需要為系統中的每個節點儲存一個整數。有關併發檢測的更多細節,參見“檢測併發寫入”。
線性一致的 ID 生成器
Lamport 時鐘與混合邏輯時鐘雖然提供了實用的順序保證,卻仍弱於前面那種線性一致的單節點 ID 生成器。回想一下,線性一致性要求:只要請求 A 在請求 B 開始前已經完成,B 的 ID 就必須更大,即使二者從未彼此通訊。Lamport 時鐘只能保證節點新生成的時間戳大於它此前 見過 的所有時間戳,對未曾見過的時間戳則無法作出任何保證。
圖 10-10展示了不滿足線性一致性的 ID 生成器會造成什麼問題。假設某個社交網站的使用者 A 想把一張難為情的照片只分享給朋友。A 的賬戶原本公開;A 先在膝上型電腦上把賬戶設為私密,隨後又用手機上傳照片。這些操作由 A 依次完成,因此 A 有理由認為,上傳的照片會受新的私密賬戶許可權保護。

賬戶許可權與照片儲存在兩個獨立資料庫中(也可能是同一資料庫的不同分片),並假設二者都用 Lamport 時鐘或混合邏輯時鐘為寫入分配時間戳。照片資料庫沒有讀取賬戶資料庫,因此它的本地計數器可能稍微落後,導致照片上傳得到的時間戳反而小於賬戶設定更新的時間戳。
接著,假設一位並非 A 好友的訪客正在瀏覽 A 的個人資料,而這次讀取由實現快照隔離的 MVCC 處理。訪客讀取的時間戳可能大於照片上傳,卻小於賬戶許可權更新。系統於是認定,在這個快照中賬戶仍是公開的,並把本不該讓訪客看到的照片顯示出來。
這個問題有幾種可能的修復方式。也許照片資料庫應在寫入前讀取使用者賬戶狀態,但開發者很容易漏掉這項檢查。如果 A 的所有操作都來自同一臺裝置,裝置上的應用或許能跟蹤該使用者最近一次寫入的時間戳;可本例中使用者同時使用膝上型電腦和手機,事情就沒那麼簡單。
這裡最簡單的解決方案,是使用線性一致的 ID 生成器,保證照片上傳取得的 ID 一定大於賬戶許可權變更的 ID。
實現線性一致的 ID 生成器
確保 ID 分配滿足線性一致性的最簡單辦法,確實是使用單個節點。這個節點只需在收到請求時原子遞增計數器並返回結果,同時持久化計數器值,以免崩潰重啟後產生重複 ID,再用單主複製實現容錯。實際系統也採用這種方案:受 Google Percolator 57 啟發,TiDB/TiKV 將其稱為 時間戳預言機(timestamp oracle)。
可以透過批次預留來避免每次請求都寫盤和複製。ID 生成器先寫入一條記錄,宣告某一批 ID;這條記錄持久化並完成複製後,節點便可按順序把其中的 ID 發給客戶端。在這一批快要用完前,再提前持久化並複製下一批。這樣,節點崩潰重啟或故障切換到追隨者時,可能會跳過一些 ID,卻絕不會發出重複或順序錯誤的 ID。
ID 生成器很難分片:多個分片獨立發號後,就無法再保證整體順序滿足線性一致性。它也很難分佈到多個地區,因此地理分散式資料庫中的所有 ID 請求,都得發往某個固定地區的節點。好在 ID 生成器的工作十分簡單,單個節點就能承擔很高的請求吞吐量。
如果不想採用單節點 ID 生成器,還有 Google Spanner 的做法,參見“用於全域性快照的同步時鐘”。它依賴一種物理時鐘:返回的不是單個時間戳,而是一個時間戳區間,表示時鐘讀數的不確定性;系統會等待這個不確定區間完全過去之後再返回結果。
只要不確定區間確實可靠——真實物理時間始終落在區間內——這個過程同樣能保證:若一個請求在另一個請求開始前完成,後一個請求就取得更大的時間戳。它無需節點間通訊就能實現線性一致的 ID 分配;即使請求來自不同地區,也能正確排序,不必等待跨地區往返。代價是必須有相應的軟硬體支援,既要讓時鐘高度同步,又要計算必要的不確定區間。
使用邏輯時鐘強制約束
在“約束與唯一性保證”中,我們看到,線性一致的比較並設定可以用來實現分散式鎖、唯一性約束等構造。於是自然會問:邏輯時鐘或線性一致的 ID 生成器,是否也足以實現這些功能?
答案是:還不夠。若多個節點都在爭搶同一把鎖或註冊同一個使用者名稱,可以用邏輯時鐘為請求分配時間戳,並選出時間戳最小者。如果時鐘滿足線性一致性,那麼此後所有請求生成的時間戳都更大,也就不可能再出現比當前勝者更小的時間戳。
可惜問題還有一半沒有解決:節點怎樣知道自己的時間戳就是最小的?要做到確定無疑,它必須收到 每一個 可能生成時間戳的其他節點的訊息 54。只要其中一個節點失效,或因網路問題無法聯絡,整個系統就會停滯,因為誰也無法排除那個節點擁有更小時間戳的可能。這顯然不是我們想要的容錯系統。
要以容錯方式實現鎖、租約等構造,需要比邏輯時鐘或 ID 生成器更強的工具:我們需要共識。
共識
本章已經見過好幾個例子:只用單個節點時,事情十分簡單;一旦還要求容錯,就會困難得多:
- 只設一個領導者,並讓所有讀寫都由它處理,資料庫便可以具有線性一致性。可是,如果這個領導者失效,怎樣才能在避免腦裂的同時完成故障切換?怎樣確保某個自認為仍是領導者的節點,其實沒有在此期間被投票罷免?
- 單節點上的線性一致 ID 生成器,不過是一個帶有原子“獲取並增加”指令的計數器;但如果這個節點崩潰了呢?
- 原子比較並設定(CAS)操作用途廣泛:例如,多個程序爭搶鎖或租約時決定誰能獲得它,或者確保給定名稱的檔案或使用者具有唯一性。在單個節點上,CAS 可能只需一條 CPU 指令;但怎樣才能讓它容錯?
事實證明,這些都是同一個分散式系統基本問題的不同例項:共識(consensus)。共識是分散式計算中最重要、最基本的問題之一;同時,它也出了名地難以正確實現 58 59,許多系統都曾在這裡栽過跟頭。至此,我們已經討論過複製(第 6 章)、事務(第 8 章)、系統模型(第 9 章)以及線性一致性(本章),終於可以正面處理共識問題了。
最著名的共識演算法包括檢視戳複製(Viewstamped Replication,VSR)60 61、Paxos 58 62 63 64、Raft 23 65 66 和 Zab 18 22 67。這些演算法有不少相似之處,但並不完全相同 68 69。它們採用非拜占庭系統模型:網路通訊可以被任意延遲或丟棄,節點可以崩潰、重啟或斷開連線;但除此之外,演算法假定節點都會正確遵守協議,不會採取惡意行為。
另一些共識演算法可以容忍一部分拜占庭節點,也就是不正確遵守協議的節點(例如,它們會向不同節點傳送相互矛盾的訊息)。這類演算法通常假定,出現拜占庭故障的節點少於三分之一 26 70。這樣的 拜占庭容錯(BFT)共識演算法會用於區塊鏈 71。不過,正如 “拜占庭故障” 所述,BFT 演算法不在本書的討論範圍之內。
你可能聽說過 FLP 結果 72——這個名字取自三位作者 Fischer、Lynch 和 Paterson。它證明:只要存在節點崩潰的可能,就沒有一種演算法能保證 始終 達成共識。分散式系統必須假定節點可能崩潰,這豈不是意味著可靠的共識根本不可能?可我們現在又在討論實現共識的演算法,這究竟是怎麼回事?
首先,FLP 並沒有說共識永遠無法達成,只是說我們無法保證共識演算法 每次都能 終止。此外,FLP 的證明針對非同步系統模型中的確定性演算法(見 “系統模型與現實”),也就是說,演算法不能使用任何時鐘或超時。只要允許演算法根據超時懷疑另一個節點可能已經崩潰——哪怕這種懷疑偶爾會出錯——共識就變得可以解決 73。甚至只需允許演算法使用隨機數,也足以繞過這個不可能性結果 74。
因此,儘管 FLP 的共識不可能性結果在理論上極為重要,分散式系統在實踐中通常仍能達成共識。
共識的多面性
共識可以用幾種不同的方式表達:
- 單值共識(single-value consensus)與原子 比較並設定 操作非常相似,可以用來實現鎖、租約和唯一性約束。
- 構建 僅追加日誌(append-only log)同樣需要共識;這個問題通常形式化為 全序廣播(total order broadcast)。有了日誌,就可以構建 狀態機複製(state machine replication)、基於領導者的複製、事件溯源以及其他許多有用的機制。
- 多資料庫或多分片事務的 原子提交(atomic commitment),要求所有參與者就是否提交或中止事務達成一致。
下面很快就會逐一探討這些形式。事實上,這幾個問題彼此等價:只要有一種演算法能解決其中一個問題,就可以把它轉換成其他任意一種問題的解法。這是一個相當深刻、甚至有些出人意料的洞見!也正因為如此,儘管它們表面上截然不同,我們仍可以把它們統統歸入“共識”這一範疇。
單值共識
共識的標準表述涉及讓多個節點就單個值達成一致。例如:
- 採用單主複製的資料庫初次啟動時,或現有領導者失效時,可能有多個節點同時試圖成為領導者。同樣,多個節點也可能競相獲取同一把鎖或同一份租約。共識可以幫助它們決定由誰勝出。
- 如果幾個人同時試圖預訂飛機上的最後一個座位、劇院裡的同一個座位,或用相同的使用者名稱註冊賬戶,共識演算法可以確定哪一個請求應當成功。
更一般地說,一個或多個節點可以 提議 某個值,而共識演算法從中 決定 一個值。在上述例子中,每個節點都可以提議自己的 ID,演算法則決定哪個節點 ID 應當成為新的領導者、租約持有者或機票/戲票的購買者。在這種形式化定義下,共識演算法必須滿足以下屬性 26:
- 一致同意
- 任意兩個節點都不會作出不同的決定。
- 完整性
- 節點一旦決定了某個值,就不能再決定另一個值來改變主意。
- 有效性
- 如果某個節點決定了值 v,那麼 v 必須由某個節點提議過。
- 終止
- 每個未崩潰的節點最終都能決定某個值。
如果需要決定多個值,可以為每個值分別執行一個共識演算法例項。例如,可以為劇院中每個可預訂的座位分別執行一次共識,從而為每個座位作出一項決定(確定一位買家)。
一致同意與完整性定義了共識的核心思想:所有節點都決定相同的結果,而且一旦作出決定,就不能再改變主意。有效性排除了平凡的解法:例如,無論節點提議什麼,演算法都一律決定 null;這種演算法滿足一致同意與完整性,卻不滿足有效性。
如果不關心容錯,滿足前三個屬性很容易:只需把一個節點硬編碼為“獨裁者”,由它作出所有決定即可。但這個節點一旦失效,系統便再也無法作出任何決定——這與沒有故障切換的單主複製如出一轍。所有困難都源於容錯這一要求。
終止屬性形式化了容錯的含義。它實質上要求共識演算法不能永遠無所作為——換句話說,演算法必須取得進展。即使一部分節點失效,其餘節點仍須作出決定。(終止是一種活性屬性,另外三種則是安全屬性,見 “安全性與活性”。)
如果崩潰的節點還有可能恢復,當然可以等它回來。然而,共識必須保證,即使某個節點突然消失而且永遠不再回來,系統仍能作出決定。(不要只想象軟體崩潰;想象一場地震引發山體滑坡,徹底摧毀了節點所在的資料中心。必須假定這個節點被埋在 30 英尺深的泥土下,再也不會重新上線。)
當然,如果 所有 節點都已崩潰,無一仍在執行,那麼任何演算法都不可能決定任何事情。演算法能容忍的故障數量存在上限:事實上,可以證明,任何共識演算法都要求至少多數節點正常執行,才能保證終止 73。這組多數節點可以安全地構成法定人數(見 “讀寫仲裁”)。
因此,終止屬性以少於一半節點崩潰或不可達為前提。不過,即使多數節點失效,或網路出現嚴重問題,大多數共識演算法仍能保證安全屬性——一致同意、完整性與有效性——始終成立 75。所以,大規模中斷可能令系統無法處理請求,卻不能迫使共識系統作出彼此矛盾的決定,從而破壞其正確性。
比較並設定作為共識
比較並設定(CAS)操作會檢查某個物件的當前值是否等於預期值。如果相等,它就以原子方式把物件更新為新值;否則保持物件不變並返回錯誤。
有了容錯且線性一致的 CAS 操作,就很容易解決共識問題:先把物件設為空值;每個想提議某個值的節點都呼叫 CAS,把預期值設為空,把新值設為自己要提議的值(假定該值非空)。物件最終被設成什麼值,共識決定的就是什麼值。
反過來,有了共識的解法,也可以實現 CAS:每當一個或多個節點想以相同的預期值執行 CAS 時,就透過共識協議提議各次 CAS 呼叫中的新值,再把物件設為共識所決定的值。新值未獲選中的 CAS 呼叫返回錯誤。預期值不同的 CAS 呼叫,則分別執行共識協議。
由此可見,CAS 與共識彼此等價 28 73。兩者在單個節點上都很簡單,難點都在於如何實現容錯。我們曾在 “以物件儲存為後端的資料庫” 中見過分散式環境下的 CAS:物件儲存的條件寫入操作只有在當前客戶端上次讀取之後,同名物件沒有被其他客戶端建立或修改時,才允許寫入發生。
不過,線性一致的讀寫暫存器不足以解決共識。FLP 結果表明,在非同步崩潰停止模型中,確定性演算法無法解決共識 72;但我們在 “線性一致性與仲裁” 中看到,線性一致的暫存器可以在這一模型下透過法定人數讀寫來實現 24 25 26。由此可以推知,線性一致的暫存器無法解決共識。
共享日誌作為共識
我們已經見過多種日誌,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 日誌條目(log entry),任何讀取者都會以相同順序看到相同的條目。有時,只有一個寫入者有權向日志追加新條目;而在 共享日誌(shared log)中,多個節點都可以請求追加條目。單主複製就是一個例子:任何客戶端都可以請求領導者執行寫入,領導者把寫入追加到複製日誌,隨後所有追隨者都按照與領導者相同的順序應用這些寫入。
更形式化地說,共享日誌支援兩種操作:請求把一個值加入日誌,以及讀取日誌條目。它必須滿足以下屬性:
- 最終追加
- 如果某個節點請求把一個值加入日誌,而且該節點沒有崩潰,那麼它最終必須能在某條日誌條目中讀到這個值。
- 可靠交付
- 日誌條目不會丟失:如果某個節點讀到了某條日誌條目,那麼每個未崩潰的節點最終也必須讀到它。
- 僅追加
- 節點一旦讀到某條日誌條目,該條目便不可再改變;新條目只能追加在它之後,不能插入它之前。節點重新讀取日誌時,必須以初次讀取時的相同順序看到相同條目,即使它曾經崩潰並重啟也不例外。
- 一致性
- 如果兩個節點都讀到了某條日誌條目 e,那麼在 e 之前,它們必須以相同順序讀到完全相同的日誌條目序列。
- 有效性
- 如果某個節點讀到了一條包含某值的日誌條目,那麼此前必有某個節點請求把這個值加入日誌。
有了共享日誌的實現,就很容易解決共識問題:每個想提議某個值的節點都請求把它加入日誌,而第一條日誌條目中讀出的值就是決定值。由於所有節點都按相同順序讀取日誌條目,它們必然會就哪個值最先交付達成一致 28。
反過來,有了共識的解法,也可以實現共享日誌。具體細節稍顯複雜,但基本思路如下 73:
- 為日誌中每個未來的條目預留一個槽位,併為每個槽位分別執行一個共識演算法例項,以決定該條目應當包含什麼值。
- 節點想向日志加入某個值時,就為一個尚未決定的槽位提議這個值。
- 共識演算法為某個槽位作出決定,而且此前所有槽位也都已有決定後,就把決定值追加為新的日誌條目;其後所有已經連續作出決定的槽位,其決定值也一併追加到日誌。
- 如果提議值沒有被某個槽位選中,想加入這個值的節點就改為向後面的槽位重新提議。
這說明,共識等價於全序廣播,也等價於共享日誌。沒有故障切換的單主複製不滿足活性要求,因為領導者一旦崩潰,系統就會停止交付訊息。一如既往,真正的挑戰在於如何安全、自動地完成故障切換。
獲取並增加作為共識
我們在 “線性一致的 ID 生成器” 中看到,線性一致的 ID 生成器距離解決共識只差一步,卻終究還差一點。這樣的 ID 生成器可以用“獲取並增加”操作實現:它以原子方式遞增計數器,並返回計數器的舊值。
有了 CAS 操作,實現獲取並增加很容易:先讀取計數器的值,再執行一次 CAS,把預期值設為剛才讀到的值,把新值設為舊值加一。如果 CAS 失敗,就從頭重試,直到成功為止。存在爭用時,這種實現不如原生的獲取並增加操作高效,但兩者在功能上等價。既然共識可以實現 CAS,自然也可以實現獲取並增加。
反過來,如果有了容錯的獲取並增加操作,能否解決共識?假設計數器初值為零,每個想提議某個值的節點都呼叫獲取並增加來遞增計數器。由於這項操作具有原子性,其中一個節點會讀到初始值零,其他節點讀到的值則都至少已經遞增過一次。
現在規定,讀到零的節點勝出,其提議值成為決定值。這對讀到零的節點當然沒問題,其他節點卻陷入了困境:它們知道自己沒有勝出,卻不知道其他節點中究竟誰贏了。勝者可以發訊息告知其他節點,但如果它還沒來得及傳送訊息就崩潰了呢?其餘節點將一直懸而未決,無法決定任何值,共識也就不能終止。它們也不能改選另一個節點,因為讀到零的節點日後仍可能恢復,並有充分理由決定自己提議的值。
有一個例外:我們能夠確定提議值的節點不超過兩個。此時,兩個節點可以先互相傳送各自的提議值,再分別執行獲取並增加。讀到零的節點決定自己的值,讀到一的節點則決定另一個節點的值。這樣就解決了兩個節點之間的共識問題,因此我們說獲取並增加的 共識數(consensus number)為二 28。相比之下,CAS 和共享日誌可以在任意數量的節點提議值時解決共識,所以它們的共識數為 ∞(無窮大)。
原子提交作為共識
在 “分散式事務” 中,我們見過 原子提交(atomic commitment)問題:參與分散式事務的所有資料庫或分片,要麼全都提交事務,要麼全都中止。我們還見過 兩階段提交(two-phase commit,2PC)演算法,它依賴一個構成單點故障的協調者。
共識與原子提交是什麼關係?乍看之下,兩者十分相似——都要求節點達成某種一致。不過,它們之間存在一項重要區別:共識可以決定任意一個被提議的值;原子提交則要求,只要 任何 參與者投票中止,演算法就 必須 中止。更準確地說,原子提交必須滿足以下屬性 78:
- 一致同意
- 任意兩個節點都不會決定不同的結果。
- 完整性
- 節點一旦決定了某個結果,就不能再決定另一個結果來改變主意。
- 有效性
- 如果某個節點決定提交,那麼此前所有節點都必須投票提交;只要任何節點投票中止,所有節點都必須中止。
- 非平凡性
- 如果所有節點都投票提交,而且沒有發生通訊超時,那麼所有節點都必須決定提交。
- 終止
- 每個未崩潰的節點最終都能決定提交或中止。
有效性確保事務只有在所有節點都同意時才能提交;非平凡性則確保演算法不能簡單地一律中止(但只要節點間發生任何通訊超時,它就允許中止)。另外三項屬性與共識基本相同。
有了共識的解法,可以用多種方式解決原子提交 78 79。其中一種做法如下:準備提交事務時,每個節點把自己的提交或中止票傳送給其他所有節點。某個節點如果收到自己以及所有其他節點的提交票,就透過共識演算法提議“提交”;如果收到中止票或遇到超時,就透過共識演算法提議“中止”。節點得知共識演算法的決定後,再據此提交或中止事務。
在這種演算法中,只有所有節點都投票提交,才可能有人提議“提交”。只要有任何節點投票中止,共識演算法收到的所有提議都會是“中止”。如果所有節點都投票提交,但部分通訊發生超時,就可能有些節點提議“中止”,另一些節點提議“提交”;這時最終提交還是中止並不重要,只要所有節點採取相同的決定即可。
反過來,有了容錯的原子提交協議,也可以解決共識。每個想提議某個值的節點都在一組法定人數節點上發起事務,並在每個節點上執行一次單節點 CAS:如果暫存器尚未被另一個事務設值,就將其設為本節點的提議值。CAS 成功時節點投票提交,否則投票中止。如果原子提交協議決定提交這個事務,其值就成為共識的決定值;如果原子提交中止,提議節點就用一個新事務重試。
由此可見,原子提交與共識也彼此等價。
共識的實踐
前面已經看到,單值共識、CAS、共享日誌與原子提交彼此等價:其中任意一個問題的解法,都可以轉換為其他問題的解法。這個理論洞見很有價值,卻沒有回答一個實際問題:共識有這麼多種表述,實踐中究竟哪一種最有用?
答案是:大多數共識系統都提供共享日誌,也就是全序廣播。Raft、檢視戳複製和 Zab 直接提供共享日誌;Paxos 提供的是單值共識,但實踐中,採用 Paxos 的系統大多使用名為 Multi-Paxos 的擴充套件,它同樣提供共享日誌。
使用共享日誌
共享日誌與資料庫複製十分契合:如果每條日誌條目都代表一次資料庫寫入,而每個副本都以相同順序、使用確定性邏輯處理相同的寫入,那麼所有副本最終都會處於一致狀態。這個思想稱為 狀態機複製(state machine replication)80,也是我們在 “事件溯源與 CQRS” 中見過的事件溯源原理。共享日誌對流處理也很有用,我們將在 第 12 章 看到這一點。
類似地,共享日誌還可以實現可序列化事務。正如 “實際序列執行” 所述,如果每條日誌條目都代表一個將以儲存過程形式執行的確定性事務,而且每個節點都以相同順序執行這些事務,那麼事務就是可序列化的 81 82。
採用強一致性模型的分片資料庫,通常會為每個分片分別維護一份日誌。這樣做提高了可伸縮性,卻也限制了資料庫能跨分片提供的一致性保證(例如一致快照與外來鍵引用)。跨分片的可序列化事務並非不能實現,但需要額外協調 83。
共享日誌的另一個強大之處在於,它很容易改造成其他形式的共識:
- 前面已經說明怎樣用它實現單值共識和 CAS:只需決定日誌中最先出現的值。
- 如果需要許多個單值共識例項(例如,劇院裡每個被多人爭訂的座位各一個例項),可以在日誌條目中寫入座位號,並以包含該座位號的第一條日誌條目作為決定。
- 如果需要原子的獲取並增加操作,可以把要加到計數器上的數字寫進日誌條目;計數器的當前值就是截至目前所有日誌條目中數字的總和。還可以對日誌條目簡單計數,以此生成柵欄令牌(見 “用柵欄機制隔離殭屍與延遲請求”)。例如,在 ZooKeeper 中,這個序列號稱為
zxid18。
從單主複製到共識
前面已經看到,如果由一個“獨裁者”節點作出決定,單值共識就很容易;類似地,如果只有一個領導者有權向共享日誌追加條目,共享日誌也很容易實現。問題在於:這個節點失效時,怎樣才能實現容錯?
傳統的單主複製資料庫並沒有解決這個問題,而是把領導者故障切換留給管理員手工操作。人類的反應速度終究有限,這種方式難免造成相當長的停機時間,也不滿足共識的終止屬性。共識要求演算法能夠自動選出新的領導者。(並非所有共識演算法都有領導者,但常用演算法通常都有 84。)
然而,這裡有一個難題。前面討論腦裂時說過,所有節點必須就誰是領導者達成一致,否則兩個不同節點可能都自認為是領導者,進而作出彼此矛盾的決定。如此看來,選舉領導者需要共識,解決共識又需要領導者。怎樣才能跳出這個先有雞還是先有蛋的困局?
事實上,共識演算法並不要求任何時刻都只有一個領導者。它們提供的是稍弱一些的保證:協議定義一個 紀元編號(epoch number;Paxos 稱為 投票編號,ballot number,檢視戳複製稱為 檢視編號,view number,Raft 稱為 任期編號,term number),並保證每個紀元內的領導者是唯一的。
如果一個節點在指定的超時時間內始終沒有收到現任領導者的訊息,因而認為它已經失效,這個節點就可能發起投票,選舉新的領導者。這次選舉會取得一個大於以往所有紀元的新紀元編號。如果兩個不同紀元的領導者發生衝突——也許前任領導者其實並未失效——紀元編號較高的領導者說了算。
領導者要向共享日誌追加下一條記錄,必須先確認不存在紀元編號更高的其他領導者,否則後者可能會追加不同的條目。它可以向一組法定人數節點收集選票,這組節點通常是多數,但並非總是如此 85。只有在不知道任何更高紀元領導者的情況下,節點才會投贊成票。
因此,這裡需要兩輪投票:第一輪選舉領導者;第二輪對領導者提議追加的下一條日誌條目進行表決。兩輪投票的法定人數必須相互重疊:如果某項提議表決透過,投贊成票的節點中,至少要有一個參加過最近一次成功的領導者選舉 85。所以,如果提議表決透過,而且投票過程沒有發現編號更高的紀元,現任領導者便可以斷定,沒有紀元編號更高的領導者當選,因而可以安全地把提議條目追加到日誌 26 86。
這兩輪投票表面上很像兩階段提交,實際上卻是兩種截然不同的協議。在共識演算法中,任何節點都可以發起選舉,而且只需一組法定人數節點響應;在 2PC 中,只有協調者能請求投票,並且必須從 每個 參與者那裡得到“同意”票,事務才能提交。
共識的微妙之處
Raft、Multi-Paxos、Zab 和檢視戳複製都採用這一基本結構:先由一組法定人數節點投票選舉領導者;此後,領導者想追加的每一條日誌條目,還要經過另一組法定人數節點投票 68 69。每條新日誌條目都要同步複製到一組法定人數節點,才會向發起寫入的客戶端確認成功。這樣即使現任領導者失效,日誌條目也不會丟失。
不過,魔鬼藏在細節裡,而這些演算法的差異也恰恰體現在細節中。例如,舊領導者失效並選出新領導者後,演算法必須保證,新領導者會保留舊領導者在失效前已經追加的所有日誌條目。Raft 的做法是:只有日誌至少與多數追隨者一樣新鮮的節點,才有資格成為新領導者 69。Paxos 則允許任何節點成為新領導者,但要求它先從其他節點補齊日誌,才能開始追加自己的新條目。
如果希望共識演算法嚴格保證 “共享日誌作為共識” 中列出的屬性,那麼新領導者在處理任何寫入或線性一致讀取之前,必須掌握所有已經確認的日誌條目。這是保證上述屬性的必要條件。如果一個資料陳舊的節點成為新領導者,它可能會改寫舊領導者已經寫入的日誌條目,從而違反共享日誌的僅追加屬性。
有些系統會選擇削弱共識屬性,以便更快地從領導者失效中恢復。例如,Kafka 可以啟用 非同步副本選舉(unclean leader election),允許任何副本成為領導者,即使它沒有追上最新進度。另外,在採用非同步複製的資料庫中,領導者失效時,根本無法保證任何追隨者已經趕上最新進度。
放棄“新領導者必須掌握最新資料”這一要求,或許能提高效能與可用性,卻也如同在薄冰上行走,因為共識理論已經不再適用。沒有故障時,系統固然可以正常工作;但一旦遇到 第 9 章 討論的種種問題,就很容易造成大量資料丟失或損壞。
另一個微妙之處是:舊領導者在失效前已經提議了某條日誌條目,但追加該條目的投票尚未結束,演算法應當如何處理。關於這些細節,可以參閱本章末尾的參考文獻 23 69 86。
對於用共識演算法做複製的資料庫,不僅寫入要轉成日誌條目並複製到一組法定人數節點。如果還要保證線性一致讀取,讀取也必須像寫入一樣經過法定人數投票,以確認那個自認為是領導者的節點確實仍掌握最新資料。etcd 的線性一致讀取就是這樣實現的。
大多數共識演算法的標準形式都假定節點集合固定不變:節點可以下線後重新上線,但哪些節點有權投票,在叢集建立時便已確定。實踐中,卻經常需要在系統配置中新增新節點或移除舊節點。共識演算法因此擴充套件出了 重新配置 功能。向系統增加新地區,或把系統從一個位置遷往另一個位置時,這項功能尤其有用:可以先加入新節點,再移除舊節點。
共識的利弊
共識演算法雖然複雜而微妙,卻是分散式系統領域的一項重大突破。共識本質上就是“正確實現的單主複製”:領導者失效時自動進行故障切換;即使面對 第 9 章 討論的所有問題,也能保證已提交的資料不會丟失,系統絕不會發生腦裂。
既然帶自動故障切換的單主複製本質上就是共識的一種定義,那麼,任何提供自動故障切換、卻沒有采用經過驗證的共識演算法的系統,都很可能不安全 87。當然,採用經過驗證的共識演算法,並不能保證整個系統一定正確——錯誤仍可能潛伏在許多其他角落——但至少是一個良好的開端。
儘管如此,共識並未無處不在,因為它的好處也有代價。共識系統總要有嚴格多數節點才能執行:要容忍一個節點故障,至少需要三個節點;要容忍兩個節點故障,至少需要五個。每項操作都必須與一組法定人數節點通訊,因此不能靠增加節點來提高吞吐量(實際上,每增加一個節點,演算法反而會變慢)。如果網路分割槽把一部分節點同其餘節點隔開,只有多數派所在的一側能夠取得進展,另一側則會阻塞。
共識系統通常依靠超時來檢測失效節點。在網路延遲變化很大的環境中,尤其是跨多個地理區域部署的系統,超時時間很難調好:設得太長,故障恢復會耗時很久;設得太短,又會觸發大量不必要的領導者選舉,導致效能極差——系統花在選舉領導者上的時間,可能比花在有用工作上的還多。
有些共識演算法對網路問題格外敏感。例如,Raft 已被發現存在一些棘手的邊界情況 88 89:即使整個網路都執行正常,只要有一條特定鏈路始終不可靠,領導權就可能在兩個節點之間來回跳轉,或者現任領導者不斷被迫辭職,導致系統實際上永遠無法取得進展。怎樣設計對不可靠網路更穩健的演算法,至今仍是一個開放的研究問題。
如果系統既希望高可用,又不願承擔共識的成本,真正可行的選擇只有改用較弱的一致性模型,例如 第 6 章 討論的無主複製或多主複製。這些方法通常不提供線性一致性;不過,對於並不需要線性一致性的應用,這已經足夠。
協調服務
任何想提供線性一致操作的分散式資料庫,都能從共識演算法中受益;許多現代分散式資料庫確實也用共識演算法做複製。不過,有一類系統尤其倚重共識:ZooKeeper、etcd、Consul 等 協調服務(coordination service)。它們表面上與普通鍵值儲存相似,卻不像大多數資料庫那樣以通用資料儲存為目標。
協調服務的用途,是協調另一個分散式系統中的多個節點。例如,Kubernetes 依賴 etcd;Spark 和 Flink 在高可用模式下,則依賴後臺執行的 ZooKeeper。協調服務只儲存少量足以全部放入記憶體的資料(同時仍會寫入磁碟以保證永續性),再用容錯共識演算法把這些資料複製到多個節點。
協調服務以 Google 的 Chubby 鎖服務為藍本 17 58,把共識演算法與幾項對構建分散式系統格外有用的功能結合在一起:
- 鎖與租約
- 前面已經看到,共識系統可以實現具備容錯能力的原子比較並設定(CAS)操作。協調服務以此實現鎖和租約:多個節點併發爭搶同一份租約時,只有一個能夠成功。
- 柵欄機制
- 正如 “分散式鎖和租約” 所述,以租約保護某項資源時,需要用 柵欄機制 防止客戶端在程序暫停或網路嚴重延遲時相互干擾。共識系統可以為每條日誌條目分配單調遞增的 ID,以此生成柵欄令牌(ZooKeeper 使用
zxid和cversion,etcd 使用修訂號)。 - 故障檢測
- 客戶端與協調服務維持一個長期會話,並定期交換心跳,確認對方是否仍然存活。即使連線暫時中斷或某臺伺服器失效,客戶端持有的租約仍然有效;但如果心跳中斷的時間超過租約超時,協調服務就會認定客戶端已經失效,並釋放其租約。(ZooKeeper 把這種隨會話到期自動消失的條目稱為 臨時節點,ephemeral node。)
- 變更通知
- 客戶端可以要求協調服務在某些鍵發生變化時主動傳送通知。這樣一來,客戶端便能得知另一個客戶端何時加入叢集(根據它寫入協調服務的值),或何時失效(它的會話超時,臨時節點隨之消失),無須再頻繁輪詢服務來發現變化。
故障檢測與變更通知本身並不需要共識;但把它們同確實需要共識的原子操作、柵欄機制組合起來,對分散式協調就格外有用。
應用與基礎設施通常都有超時時間、執行緒池大小等配置引數。有時,人們會把這類配置資料以鍵值對形式存入協調服務。程序啟動時載入最新配置,並訂閱此後的變更通知。配置變化後,程序可以立即採用新設定,也可以透過重啟來載入最新設定。
配置管理本身並不需要協調服務的共識能力;不過,如果系統本來就在執行協調服務,順便利用它的通知功能會很方便。另一種辦法是讓程序定期從檔案或 URL 輪詢配置更新,從而不必依賴專門的協調服務。
將工作分配給節點
如果某個程序或服務有多個例項,需要從中選出一個領導者或主例項,協調服務就很有用。領導者失效時,應當由其他某個節點接管。這對單主資料庫必不可少,也適用於作業排程器等有狀態系統。
另一種場景是分配分片資源(資料庫、訊息流、檔案儲存、分散式 Actor 系統等):系統需要決定每個分片交給哪個節點。新節點加入叢集時,需要把一部分分片從現有節點移到新節點,以重新平衡負載;節點被移除或失效時,則要由其他節點接手它的工作。
審慎組合協調服務中的原子操作、臨時節點與通知機制,就可以完成這類任務。實現得當時,應用能在無需人工干預的情況下自動從故障中恢復。即使已經有 Apache Curator 之類基於 ZooKeeper 客戶端 API 提供高層工具的庫,這仍然不是一件容易的事;但無論如何,都遠勝於從頭實現所需的共識演算法——後者極易埋下錯誤。
專用協調服務還有一個優點:無論依賴它協調的分散式系統有多少節點,協調服務本身都可以只執行在一組固定節點上,通常是三個或五個。例如,一個儲存系統擁有數千個分片;若讓數千個節點共同執行共識演算法,效率會低得驚人。把共識“外包”給少量執行協調服務的節點,要合理得多。
協調服務管理的資料通常變化緩慢,例如“IP 地址為 10.1.1.23 的節點是分片 7 的領導者”這類分配關係,往往幾分鐘或幾小時才會變一次。協調服務並非用來儲存每秒可能變化數千次的資料;這類資料更適合交給常規資料庫。或者,也可以用 Apache BookKeeper 90 91 之類的工具,複製服務內部快速變化的狀態。
服務發現
ZooKeeper、etcd 和 Consul 也經常用於 服務發現(service discovery),即找出應當連線哪個 IP 地址才能訪問特定服務(見 “負載均衡器、服務發現和服務網格”)。雲環境中的虛擬機器不斷建立和銷燬,通常無法預先知道服務的 IP 地址。常見做法是讓服務在啟動時把自己的網路端點註冊到服務登錄檔,供其他服務查詢。
用協調服務做服務發現很方便:故障檢測與變更通知功能,讓客戶端很容易跟蹤服務例項的增減。如果系統已經用協調服務管理租約、鎖或領導者選舉,繼續用它做服務發現也順理成章,因為它本來就知道哪個節點應當接收服務請求。
不過,服務發現使用共識往往有些殺雞用牛刀。這個場景通常不要求線性一致性;高可用與低延遲反而更加重要,因為一旦服務發現不可用,整個系統都會停擺。因此,更常見的做法是快取服務發現資訊,並接受結果可能略有陳舊。例如,基於 DNS 的服務發現就透過多層快取獲得良好的效能與可用性。
為支援這類用途,ZooKeeper 提供了 觀察者(observer)節點。這些副本會接收日誌、維護一份 ZooKeeper 資料副本,卻不參與共識演算法的投票。觀察者上的讀取可能陳舊,因而不具備線性一致性;但即使網路中斷,讀取仍可繼續,而且快取還能提高系統所能支援的讀取吞吐量。
總結
本章探討了容錯系統中的強一致性:它是什麼,又該怎樣實現。我們深入研究了強一致性的一種常用形式化定義——線性一致性。它要求複製資料表現得彷彿只有一個副本,而且所有操作都以原子方式作用於這個副本。當某些資料在讀取時必須是最新的,或需要解決競態條件時(例如多個節點併發建立同名檔案),線性一致性就很有用。
線性一致性很有吸引力,因為它易於理解:資料庫的行為就像單執行緒程式中的一個變數。但它的缺點是速度慢,網路延遲較大時尤其如此。許多複製演算法都無法保證線性一致性,哪怕乍看之下似乎能夠提供強一致性。
接著,我們把線性一致性的概念應用到 ID 生成器。單節點自增計數器具有線性一致性,卻不能容錯。許多分散式 ID 生成方案也無法保證,ID 的順序與事件實際發生的順序一致。Lamport 時鐘、混合邏輯時鐘等邏輯時鐘,可以給出與因果關係一致的順序,卻不具備線性一致性。
由此,我們引出了共識:達成共識,就是以一種所有節點都認同結果、而且事後不能改變主意的方式作出決定。許多問題實際上都可以歸約為共識,而且彼此等價——換句話說,只要有一個問題的解法,就能把它轉換成其他所有問題的解法。這些等價問題包括:
- 線性一致的比較並設定操作
- 暫存器必須根據當前值是否等於操作給出的引數,以原子方式 決定 是否設定新值。
- 鎖和租約
- 多個客戶端併發爭搶鎖或租約時,鎖會 決定 由哪一個成功獲取。
- 唯一性約束
- 多個事務併發建立鍵相同、彼此衝突的記錄時,約束必須 決定 允許哪一個,又讓哪一個因違反約束而失敗。
- 共享日誌
- 多個節點併發請求向日志追加條目時,日誌會 決定 條目的追加順序。全序廣播也與之等價。
- 原子事務提交
- 參與分散式事務的所有資料庫節點,必須作出相同的 決定:提交事務,或是中止事務。
- 線性一致的獲取並增加操作
- 這項操作可以實現 ID 生成器。多個節點可以併發呼叫它,而它會 決定 各節點遞增計數器的順序。這種方法實際上只能解決兩個節點之間的共識,前面幾種則適用於任意數量的節點。
如果只有一個節點,或者願意把決策權交給一個節點,所有這些問題都很簡單。單主資料庫正是如此:全部決策權都歸領導者所有,這也是此類資料庫能夠提供線性一致操作、唯一性約束、複製日誌等功能的原因。
可是,一旦這個領導者失效,或網路中斷令它不可達,系統便無法繼續取得進展,只能等待人工完成故障切換。Raft、Paxos 等廣泛使用的共識演算法,本質上就是內建了自動領導者選舉與故障切換的單主複製。
共識演算法經過精心設計,能保證故障切換期間不會丟失任何已經提交的寫入,也不會讓系統陷入多個節點同時接受寫入的腦裂狀態。為此,每次寫入和每次線性一致讀取都必須得到一組法定人數節點(通常是多數節點)的確認。這個過程代價不菲,跨地理區域時尤其如此;但若想獲得共識所提供的強一致性與容錯能力,這項代價無法避免。
ZooKeeper、etcd 等協調服務同樣建立在共識演算法之上。它們提供鎖、租約、故障檢測和變更通知等功能,有助於管理分散式應用的狀態。如果要做的事情可以歸約為共識,而且還必須容錯,最好使用協調服務。它不能保證實現一定正確,卻很可能幫上忙。
共識演算法複雜而微妙,但背後有一套自 20 世紀 80 年代以來不斷發展的豐富理論體系。這套理論讓我們能夠構建出這樣的系統:既容忍 第 9 章 討論的各種故障,又保證資料不被破壞。這是分散式系統工程的一項非凡成就;本章末尾的參考文獻列出了其中若干重要工作。
不過,共識並不總是正確的工具。有些系統並不需要它所提供的強一致性,以較弱的一致性換取更高可用性和更好效能,反而更加合適。在這些場景中,人們通常採用 第 6 章 討論過的無主複製或多主複製;本章介紹的邏輯時鐘,對這類系統也很有幫助。
參考文獻
Maurice P. Herlihy and Jeannette M. Wing. Linearizability: A Correctness Condition for Concurrent Objects. ACM Transactions on Programming Languages and Systems (TOPLAS), volume 12, issue 3, pages 463–492, July 1990. doi:10.1145/78969.78972 ↩︎ ↩︎
Leslie Lamport. On interprocess communication. Distributed Computing, volume 1, issue 2, pages 77–101, June 1986. doi:10.1007/BF01786228 ↩︎
David K. Gifford. Information Storage in a Decentralized Computer System. Xerox Palo Alto Research Centers, CSL-81-8, June 1981. Archived at perma.cc/2XXP-3JPB ↩︎
Martin Kleppmann. Please Stop Calling Databases CP or AP. martin.kleppmann.com, May 2015. Archived at perma.cc/MJ5G-75GL ↩︎ ↩︎ ↩︎
Kyle Kingsbury. Call Me Maybe: MongoDB Stale Reads. aphyr.com, April 2015. Archived at perma.cc/DXB4-J4JC ↩︎
Kyle Kingsbury. Computational Techniques in Knossos. aphyr.com, May 2014. Archived at perma.cc/2X5M-EHTU ↩︎
Kyle Kingsbury and Peter Alvaro. Elle: Inferring Isolation Anomalies from Experimental Observations. Proceedings of the VLDB Endowment, volume 14, issue 3, pages 268–280, November 2020. doi:10.14778/3430915.3430918 ↩︎
Paolo Viotti and Marko Vukolić. Consistency in Non-Transactional Distributed Storage Systems. ACM Computing Surveys (CSUR), volume 49, issue 1, article no. 19, June 2016. doi:10.1145/2926965 ↩︎ ↩︎
Peter Bailis. Linearizability Versus Serializability. bailis.org, September 2014. Archived at perma.cc/386B-KAC3 ↩︎
Daniel Abadi. Correctness Anomalies Under Serializable Isolation. dbmsmusings.blogspot.com, June 2019. Archived at perma.cc/JGS7-BZFY ↩︎
Peter Bailis, Aaron Davidson, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Highly Available Transactions: Virtues and Limitations. Proceedings of the VLDB Endowment, volume 7, issue 3, pages 181–192, November 2013. doi:10.14778/2732232.2732237, extended version published as arXiv:1302.0309 ↩︎
Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman. Concurrency Control and Recovery in Database Systems. Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at microsoft.com. ↩︎
Andrei Matei. CockroachDB’s consistency model. cockroachlabs.com, February 2021. Archived at perma.cc/MR38-883B ↩︎
Murat Demirbas. Strict-serializability, but at what cost, for what purpose? muratbuffalo.blogspot.com, August 2022. Archived at perma.cc/T8AY-N3U9 ↩︎
Ben Darnell. How to talk about consistency and isolation in distributed DBs. cockroachlabs.com, February 2022. Archived at perma.cc/53SV-JBGK ↩︎
Daniel Abadi. An explanation of the difference between Isolation levels vs. Consistency levels. dbmsmusings.blogspot.com, August 2019. Archived at perma.cc/QSF2-CD4P ↩︎
Mike Burrows. The Chubby Lock Service for Loosely-Coupled Distributed Systems. At 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006. ↩︎ ↩︎
Flavio P. Junqueira and Benjamin Reed. ZooKeeper: Distributed Process Coordination. O’Reilly Media, 2013. ISBN: 978-1-449-36130-3 ↩︎ ↩︎ ↩︎ ↩︎
Murali Vallath. Oracle 10g RAC Grid, Services & Clustering. Elsevier Digital Press, 2006. ISBN: 978-1-555-58321-7 ↩︎ ↩︎
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 ↩︎
Kyle Kingsbury. Call Me Maybe: etcd and Consul. aphyr.com, June 2014. Archived at perma.cc/XL7U-378K ↩︎
Flavio P. Junqueira, Benjamin C. Reed, and Marco Serafini. Zab: High-Performance Broadcast for Primary-Backup Systems. At 41st IEEE International Conference on Dependable Systems and Networks (DSN), June 2011. doi:10.1109/DSN.2011.5958223 ↩︎ ↩︎
Diego Ongaro and John K. Ousterhout. In Search of an Understandable Consensus Algorithm. At USENIX Annual Technical Conference (ATC), June 2014. ↩︎ ↩︎ ↩︎
Hagit Attiya, Amotz Bar-Noy, and Danny Dolev. Sharing Memory Robustly in Message-Passing Systems. Journal of the ACM, volume 42, issue 1, pages 124–142, January 1995. doi:10.1145/200836.200869 ↩︎ ↩︎
Nancy Lynch and Alex Shvartsman. Robust Emulation of Shared Memory Using Dynamic Quorum-Acknowledged Broadcasts. At 27th Annual International Symposium on Fault-Tolerant Computing (FTCS), June 1997. doi:10.1109/FTCS.1997.614100 ↩︎ ↩︎
Christian Cachin, Rachid Guerraoui, and Luís Rodrigues. Introduction to Reliable and Secure Distributed Programming, 2nd edition. Springer, 2011. ISBN: 978-3-642-15259-7, doi:10.1007/978-3-642-15260-3 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Niklas Ekström, Mikhail Panchenko, and Jonathan Ellis. Possible Issue with Read Repair? Email thread on cassandra-dev mailing list, October 2012. ↩︎
Maurice P. Herlihy. Wait-Free Synchronization. ACM Transactions on Programming Languages and Systems (TOPLAS), volume 13, issue 1, pages 124–149, January 1991. doi:10.1145/114005.102808 ↩︎ ↩︎ ↩︎ ↩︎
Armando Fox and Eric A. Brewer. Harvest, Yield, and Scalable Tolerant Systems. At 7th Workshop on Hot Topics in Operating Systems (HotOS), March 1999. doi:10.1109/HOTOS.1999.798396 ↩︎
Seth Gilbert and Nancy Lynch. Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services. ACM SIGACT News, volume 33, issue 2, pages 51–59, June 2002. doi:10.1145/564585.564601 ↩︎ ↩︎ ↩︎
Seth Gilbert and Nancy Lynch. Perspectives on the CAP Theorem. IEEE Computer Magazine, volume 45, issue 2, pages 30–36, February 2012. doi:10.1109/MC.2011.389 ↩︎
Eric A. Brewer. CAP Twelve Years Later: How the ‘Rules’ Have Changed. IEEE Computer Magazine, volume 45, issue 2, pages 23–29, February 2012. doi:10.1109/MC.2012.37 ↩︎ ↩︎
Susan B. Davidson, Hector Garcia-Molina, and Dale Skeen. Consistency in Partitioned Networks. ACM Computing Surveys, volume 17, issue 3, pages 341–370, September 1985. doi:10.1145/5505.5508 ↩︎
Paul R. Johnson and Robert H. Thomas. RFC 677: The Maintenance of Duplicate Databases. Network Working Group, January 1975. ↩︎
Michael J. Fischer and Alan Michael. Sacrificing Serializability to Attain High Availability of Data in an Unreliable Network. At 1st ACM Symposium on Principles of Database Systems (PODS), March 1982. doi:10.1145/588111.588124 ↩︎
Eric A. Brewer. NoSQL: Past, Present, Future. At QCon San Francisco, November 2012. ↩︎
Adrian Cockcroft. Migrating to Microservices. At QCon London, March 2014. ↩︎
Martin Kleppmann. A Critique of the CAP Theorem. arXiv:1509.05393, September 2015. ↩︎ ↩︎
Daniel Abadi. Problems with CAP, and Yahoo’s little known NoSQL system. dbmsmusings.blogspot.com, April 2010. Archived at perma.cc/4NTZ-CLM9 ↩︎ ↩︎ ↩︎
Daniel Abadi. Hazelcast and the Mythical PA/EC System. dbmsmusings.blogspot.com, October 2017. Archived at perma.cc/J5XM-U5C2 ↩︎ ↩︎
Eric Brewer. Spanner, TrueTime & The CAP Theorem. research.google.com, February 2017. Archived at perma.cc/59UW-RH7N ↩︎
Daniel J. Abadi. Consistency Tradeoffs in Modern Distributed Database System Design. IEEE Computer Magazine, volume 45, issue 2, pages 37–42, February 2012. doi:10.1109/MC.2012.33 ↩︎ ↩︎
Nancy A. Lynch. A Hundred Impossibility Proofs for Distributed Computing. At 8th ACM Symposium on Principles of Distributed Computing (PODC), August 1989. doi:10.1145/72981.72982 ↩︎
Prince Mahajan, Lorenzo Alvisi, and Mike Dahlin. Consistency, Availability, and Convergence. University of Texas at Austin, Department of Computer Science, Tech Report UTCS TR-11-22, May 2011. Archived at perma.cc/SAV8-9JAJ ↩︎
Hagit Attiya, Faith Ellen, and Adam Morrison. Limitations of Highly-Available Eventually-Consistent Data Stores. At ACM Symposium on Principles of Distributed Computing (PODC), July 2015. doi:10.1145/2767386.2767419 ↩︎
Peter Sewell, Susmit Sarkar, Scott Owens, Francesco Zappa Nardelli, and Magnus O. Myreen. x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors. Communications of the ACM, volume 53, issue 7, pages 89–97, July 2010. doi:10.1145/1785414.1785443 ↩︎
Martin Thompson. Memory Barriers/Fences. mechanical-sympathy.blogspot.co.uk, July 2011. Archived at perma.cc/7NXM-GC5U ↩︎
Ulrich Drepper. What Every Programmer Should Know About Memory. akkadia.org, November 2007. Archived at perma.cc/NU6Q-DRXZ ↩︎
Hagit Attiya and Jennifer L. Welch. Sequential Consistency Versus Linearizability. ACM Transactions on Computer Systems (TOCS), volume 12, issue 2, pages 91–122, May 1994. doi:10.1145/176575.176576 ↩︎
Kyzer R. Davis, Brad G. Peabody, and Paul J. Leach. Universally Unique IDentifiers (UUIDs). RFC 9562, IETF, May 2024. ↩︎ ↩︎
Ryan King. Announcing Snowflake. blog.x.com, June 2010. Archived at archive.org ↩︎
Alizain Feerasta. Universally Unique Lexicographically Sortable Identifier. github.com, 2016. Archived at perma.cc/NV2Y-ZP8U ↩︎
Rob Conery. A Better ID Generator for PostgreSQL. bigmachine.io, May 2014. Archived at perma.cc/K7QV-3KFC ↩︎
Leslie Lamport. Time, Clocks, and the Ordering of Events in a Distributed System. Communications of the ACM, volume 21, issue 7, pages 558–565, July 1978. doi:10.1145/359545.359563 ↩︎ ↩︎
Sandeep S. Kulkarni, Murat Demirbas, Deepak Madeppa, Bharadwaj Avva, and Marcelo Leone. Logical Physical Clocks. 18th International Conference on Principles of Distributed Systems (OPODIS), December 2014. doi:10.1007/978-3-319-14472-6_2 ↩︎
Manuel Bravo, Nuno Diegues, Jingna Zeng, Paolo Romano, and Luís Rodrigues. On the use of Clocks to Enforce Consistency in the Cloud. IEEE Data Engineering Bulletin, volume 38, issue 1, pages 18–31, March 2015. Archived at perma.cc/68ZU-45SH ↩︎
Daniel Peng and Frank Dabek. Large-Scale Incremental Processing Using Distributed Transactions and Notifications. At 9th USENIX Conference on Operating Systems Design and Implementation (OSDI), October 2010. ↩︎
Tushar Deepak Chandra, Robert Griesemer, and Joshua Redstone. Paxos Made Live – An Engineering Perspective. At 26th ACM Symposium on Principles of Distributed Computing (PODC), June 2007. doi:10.1145/1281100.1281103 ↩︎ ↩︎ ↩︎
Will Portnoy. Lessons Learned from Implementing Paxos. blog.willportnoy.com, June 2012. Archived at perma.cc/QHD9-FDD2 ↩︎
Brian M. Oki and Barbara H. Liskov. Viewstamped Replication: A New Primary Copy Method to Support Highly-Available Distributed Systems. At 7th ACM Symposium on Principles of Distributed Computing (PODC), August 1988. doi:10.1145/62546.62549 ↩︎
Barbara H. Liskov and James Cowling. Viewstamped Replication Revisited. Massachusetts Institute of Technology, Tech Report MIT-CSAIL-TR-2012-021, July 2012. Archived at perma.cc/56SJ-WENQ ↩︎
Leslie Lamport. The Part-Time Parliament. ACM Transactions on Computer Systems, volume 16, issue 2, pages 133–169, May 1998. doi:10.1145/279227.279229 ↩︎
Leslie Lamport. Paxos Made Simple. ACM SIGACT News, volume 32, issue 4, pages 51–58, December 2001. Archived at perma.cc/82HP-MNKE ↩︎
Robbert van Renesse and Deniz Altinbuken. Paxos Made Moderately Complex. ACM Computing Surveys (CSUR), volume 47, issue 3, article no. 42, February 2015. doi:10.1145/2673577 ↩︎
Diego Ongaro. Consensus: Bridging Theory and Practice. PhD Thesis, Stanford University, August 2014. Archived at perma.cc/5VTZ-2ADH ↩︎
Heidi Howard, Malte Schwarzkopf, Anil Madhavapeddy, and Jon Crowcroft. Raft Refloated: Do We Have Consensus? ACM SIGOPS Operating Systems Review, volume 49, issue 1, pages 12–21, January 2015. doi:10.1145/2723872.2723876 ↩︎
André Medeiros. ZooKeeper’s Atomic Broadcast Protocol: Theory and Practice. Aalto University School of Science, March 2012. Archived at perma.cc/FVL4-JMVA ↩︎
Robbert van Renesse, Nicolas Schiper, and Fred B. Schneider. Vive La Différence: Paxos vs. Viewstamped Replication vs. Zab. IEEE Transactions on Dependable and Secure Computing, volume 12, issue 4, pages 472–484, September 2014. doi:10.1109/TDSC.2014.2355848 ↩︎ ↩︎
Heidi Howard and Richard Mortier. Paxos vs Raft: Have we reached consensus on distributed consensus?. At 7th Workshop on Principles and Practice of Consistency for Distributed Data (PaPoC), April 2020. doi:10.1145/3380787.3393681 ↩︎ ↩︎ ↩︎ ↩︎
Miguel Castro and Barbara H. Liskov. Practical Byzantine Fault Tolerance and Proactive Recovery. ACM Transactions on Computer Systems, volume 20, issue 4, pages 396–461, November 2002. doi:10.1145/571637.571640 ↩︎
Shehar Bano, Alberto Sonnino, Mustafa Al-Bassam, Sarah Azouvi, Patrick McCorry, Sarah Meiklejohn, and George Danezis. SoK: Consensus in the Age of Blockchains. At 1st ACM Conference on Advances in Financial Technologies (AFT), October 2019. doi:10.1145/3318041.3355458 ↩︎
Michael J. Fischer, Nancy Lynch, and Michael S. Paterson. Impossibility of Distributed Consensus with One Faulty Process. Journal of the ACM, volume 32, issue 2, pages 374–382, April 1985. doi:10.1145/3149.214121 ↩︎ ↩︎
Tushar Deepak Chandra and Sam Toueg. Unreliable Failure Detectors for Reliable Distributed Systems. Journal of the ACM, volume 43, issue 2, pages 225–267, March 1996. doi:10.1145/226643.226647 ↩︎ ↩︎ ↩︎ ↩︎
Michael Ben-Or. Another Advantage of Free Choice: Completely Asynchronous Agreement Protocols. At 2nd ACM Symposium on Principles of Distributed Computing (PODC), August 1983. doi:10.1145/800221.806707 ↩︎
Cynthia Dwork, Nancy Lynch, and Larry Stockmeyer. Consensus in the Presence of Partial Synchrony. Journal of the ACM, volume 35, issue 2, pages 288–323, April 1988. doi:10.1145/42282.42283 ↩︎
Xavier Défago, André Schiper, and Péter Urbán. Total Order Broadcast and Multicast Algorithms: Taxonomy and Survey. ACM Computing Surveys, volume 36, issue 4, pages 372–421, December 2004. doi:10.1145/1041680.1041682 ↩︎
Hagit Attiya and Jennifer Welch. Distributed Computing: Fundamentals, Simulations and Advanced Topics, 2nd edition. John Wiley & Sons, 2004. ISBN: 978-0-471-45324-6, doi:10.1002/0471478210 ↩︎
Rachid Guerraoui. Revisiting the Relationship Between Non-Blocking Atomic Commitment and Consensus. At 9th International Workshop on Distributed Algorithms (WDAG), September 1995. doi:10.1007/BFb0022140 ↩︎ ↩︎
Jim N. Gray and Leslie Lamport. Consensus on Transaction Commit. ACM Transactions on Database Systems (TODS), volume 31, issue 1, pages 133–160, March 2006. doi:10.1145/1132863.1132867 ↩︎
Fred B. Schneider. Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial. ACM Computing Surveys, volume 22, issue 4, pages 299–319, December 1990. doi:10.1145/98163.98167 ↩︎
Alexander Thomson, Thaddeus Diamond, Shu-Chun Weng, Kun Ren, Philip Shao, and Daniel J. Abadi. Calvin: Fast Distributed Transactions for Partitioned Database Systems. At ACM International Conference on Management of Data (SIGMOD), May 2012. doi:10.1145/2213836.2213838 ↩︎
Mahesh Balakrishnan, Dahlia Malkhi, Ted Wobber, Ming Wu, Vijayan Prabhakaran, Michael Wei, John D. Davis, Sriram Rao, Tao Zou, and Aviad Zuck. Tango: Distributed Data Structures over a Shared Log. At 24th ACM Symposium on Operating Systems Principles (SOSP), November 2013. doi:10.1145/2517349.2522732 ↩︎
Mahesh Balakrishnan, Dahlia Malkhi, Vijayan Prabhakaran, Ted Wobber, Michael Wei, and John D. Davis. CORFU: A Shared Log Design for Flash Clusters. At 9th USENIX Symposium on Networked Systems Design and Implementation (NSDI), April 2012. ↩︎
Vasilis Gavrielatos, Antonios Katsarakis, and Vijay Nagarajan. Odyssey: the impact of modern hardware on strongly-consistent replication protocols. At 16th European Conference on Computer Systems (EuroSys), April 2021. doi:10.1145/3447786.3456240 ↩︎
Heidi Howard, Dahlia Malkhi, and Alexander Spiegelman. Flexible Paxos: Quorum Intersection Revisited. At 20th International Conference on Principles of Distributed Systems (OPODIS), December 2016. doi:10.4230/LIPIcs.OPODIS.2016.25 ↩︎ ↩︎
Martin Kleppmann. Distributed Systems lecture notes. University of Cambridge, October 2024. Archived at perma.cc/SS3Q-FNS5 ↩︎ ↩︎
Kyle Kingsbury. Call Me Maybe: Elasticsearch 1.5.0. aphyr.com, April 2015. Archived at perma.cc/37MZ-JT7H ↩︎
Heidi Howard and Jon Crowcroft. Coracle: Evaluating Consensus at the Internet Edge. At Annual Conference of the ACM Special Interest Group on Data Communication (SIGCOMM), August 2015. doi:10.1145/2829988.2790010 ↩︎
Tom Lianza and Chris Snook. A Byzantine failure in the real world. blog.cloudflare.com, November 2020. Archived at perma.cc/83EZ-ALCY ↩︎
Ivan Kelly. BookKeeper Tutorial. github.com, October 2014. Archived at perma.cc/37Y6-VZWU ↩︎
Jack Vanlightly. Apache BookKeeper Insights Part 1 — External Consensus and Dynamic Membership. medium.com, November 2021. Archived at perma.cc/3MDB-8GFB ↩︎
III 派生資料
在本書的 第一部分 和 第二部分 中,我們自底向上地把所有關於分散式資料庫的主要考量都過了一遍。從資料在磁碟上的佈局,一直到出現故障時分散式系統一致性的侷限。但所有的討論都假定了應用中只用了一種資料庫。
現實世界中的資料系統往往更為複雜。大型應用程式經常需要以多種方式訪問和處理資料,沒有一個資料庫可以同時滿足所有這些不同的需求。因此應用程式通常組合使用多種元件:資料儲存、索引、快取、分析系統等等,並實現在這些元件中移動資料的機制。
本書的最後一部分,會研究將多個不同資料系統(可能有著不同資料模型,並針對不同的訪問模式進行最佳化)整合為一個協調一致的應用架構時,會遇到的問題。軟體供應商經常會忽略這一方面的生態建設,並聲稱他們的產品能夠滿足你的所有需求。在現實世界中,整合不同的系統是實際應用中最重要的事情之一。
記錄系統和派生資料系統
從高層次上看,儲存和處理資料的系統可以分為兩大類:
- 權威記錄系統(System of record)
- 記錄系統,也被稱為 真相源(source of truth),持有資料的權威版本。當新的資料進入時(例如,使用者輸入)首先會記錄在這裡。 每個事實正正好好表示一次(表示通常是 正規化的,即 normalized)。如果其他系統和 記錄系統 之間存在任何差異,那麼記錄系統中的值是正確的(根據定義)。
- 派生資料系統(Derived data systems)
- 派生系統 中的資料,通常是另一個系統中的現有資料以某種方式進行轉換或處理的結果。如果丟失派生資料,可以從原始來源重新建立。 典型的例子是 快取(cache):如果資料在快取中,就可以由快取提供服務;如果快取不包含所需資料,則降級由底層資料庫提供。反正規化的值,索引和物化檢視亦屬此類。在推薦系統中,預測彙總資料通常派生自使用者日誌。
從技術上講,派生資料是 冗餘的(redundant),因為它重複了已有的資訊。但是派生資料對於獲得良好的只讀查詢效能通常是至關重要的。它通常是反正規化的。可以從單個源頭派生出多個不同的資料集,使你能從不同的 “視角” 洞察資料。
並不是所有的系統都在其架構中明確區分 記錄系統 和 派生資料系統,但是這是一種有用的區分方式,因為它明確了系統中的資料流:系統的哪一部分具有哪些輸入和哪些輸出,以及它們如何相互依賴。
大多數資料庫,儲存引擎和查詢語言,本質上既不是記錄系統也不是派生系統。資料庫只是一個工具:如何使用它取決於你自己。記錄系統和派生資料系統之間的區別不在於工具,而在於應用程式中的使用方式。
透過梳理資料的派生關係,可以清楚地理解一個令人困惑的系統架構。這將貫穿本書的這一部分。
章節概述
我們將從 第十一章 開始,研究例如 MapReduce 這樣 面向批處理(batch-oriented) 的資料流系統。對於建設大規模資料系統,我們將看到,它們提供了優秀的工具和思想。 第十二章 將把這些思想應用到 流式資料(data streams) 中,使我們能用更低的延遲完成同樣的任務。第十三章 將探討如何使用這些工具來構建可靠、可伸縮和可維護的應用。第十四章 將以倫理、隱私與社會影響為主題,為全書收束。
索引
11. 批處理
12. 流處理
13. 流式系統的哲學
14. 做正確的事情
11 批處理

帶有太強個人色彩的系統無法成功。當最初的設計完成並且相對穩健時,不同的人開始以自己的方式進行試驗,真正的考驗才開始。
高德納
到目前為止,本書的大部分內容都在討論 請求(request)和 查詢(query),以及相應的 響應(response)或 結果(result)。許多現代資料系統都預設採用這種處理方式:你請求某樣東西,或者發出一條指令,系統便儘量快速地給出答案。
瀏覽器請求網頁、服務呼叫遠端 API,以及資料庫、快取、搜尋索引等許多系統,都是這樣工作的。我們稱它們為 線上系統(online system)。這類系統通常以響應時間作為主要效能指標,而且往往需要具備容錯能力,才能保證高可用性。
然而,有些計算規模太大,或者需要處理的資料太多,無法放在一次互動式請求中完成。例如,你可能需要訓練 AI 模型,把大量資料從一種形式轉換成另一種形式,或者在非常大的資料集上進行分析。我們把這類任務稱為 批處理(batch processing)作業,相應的系統有時也稱為 離線系統(offline system)。
批處理作業讀取一組輸入資料(只讀),併產生一組輸出資料(每次執行都從頭生成)。它通常不會像讀寫事務那樣修改現有資料。因此,輸出是由輸入 衍生(derived)而來的(參見“權威記錄系統與衍生資料”):如果對輸出不滿意,只需把它刪除,調整作業邏輯,再執行一次。把輸入視為不可變資料,並避免產生副作用(例如寫入外部資料庫),不僅能讓批處理作業獲得良好的效能,還會帶來其他好處:
如果程式碼中引入了錯誤,導致輸出有誤或遭到破壞,只要回滾到先前版本的程式碼並重新執行作業,輸出便能恢復正確。更簡單的辦法是把舊輸出儲存在另一個目錄中,需要時直接切換回去。大多數物件儲存和開放表格式(參見“雲資料倉儲”)都支援這種稱為 時間旅行(time travel)的功能。大多數支援讀寫事務的資料庫卻不具備這種性質:如果錯誤程式碼把壞資料寫進資料庫,回滾程式碼並不能修復已經寫入的資料。這種從錯誤程式碼中恢復的能力稱為 容忍人為失誤(human fault tolerance)1。
由於回滾很容易,功能開發可以比“犯錯就會造成不可逆損害”的環境推進得更快。這種 儘量減少不可逆操作 的原則有利於敏捷軟體開發 2。
同一組檔案可以作為許多不同作業的輸入,其中也包括監控作業:它們計算指標,並檢查某項作業的輸出是否具備預期特徵,例如將其與上一次執行的輸出比較,衡量兩者之間的差異。
批處理框架能夠高效利用計算資源。雖然 OLTP 資料庫和應用伺服器等線上資料系統也能成批處理資料,但完成同樣工作所需的資源可能昂貴得多。
批處理也會帶來一些挑戰。在大多數框架中,只有整個作業執行完畢,其他作業才能繼續處理它的輸出。批處理還可能效率不高:輸入資料發生任何變化——哪怕只有一個位元組——都意味著批處理作業必須重新處理整個輸入資料集。儘管存在這些侷限,批處理仍在許多場景中證明了自己的價值,我們將在“批處理用例”中再次討論這些場景。
一項批處理作業可能要執行很長時間:幾分鐘、幾小時,甚至幾天。作業也可能按固定週期排程執行,例如每天一次。其主要效能指標通常是吞吐量,即單位時間內能夠處理多少資料。有些批處理系統遇到故障時只會中止並重新啟動整個作業;另一些則具備容錯能力,即使某些節點崩潰,作業也能順利完成。
批處理之外還有一種選擇,即 流處理。流處理作業不會在處理完當前輸入後結束,而是繼續監視輸入,並在輸入發生變化後不久加以處理。我們將在第 12 章討論流處理。
線上系統與批處理系統之間的界限並不總是分明:一條長時間執行的資料庫查詢看起來就很像批處理過程。不過,批處理有一些獨特的性質,使其成為構建可靠、可伸縮且可維護應用的重要構件。例如,它經常用於 資料整合,也就是把多個資料系統組合起來,完成單個系統無法獨自完成的工作。“資料倉儲”中討論的 ETL 就是一例。
現代批處理深受 MapReduce 影響。Google 於 2004 年發表了這種批處理演算法 3,隨後 Hadoop、CouchDB 和 MongoDB 等多個開源資料系統都實現了它。MapReduce 是一種相當底層的程式設計模型,沒有資料倉儲中的並行查詢執行引擎那麼精巧 4 5。剛問世時,MapReduce 讓普通商用硬體能夠達到的處理規模邁上了一個新臺階;如今它已經基本過時,Google 也不再使用 6 7。
如今,批處理更多由 Spark、Flink 之類的框架或資料倉儲查詢引擎完成。與 MapReduce 一樣,這些系統高度依賴分片(參見第 7 章)和並行執行,但它們的快取和執行策略精巧得多。隨著系統逐漸成熟,運維方面的問題基本得到解決,關注點也轉向了易用性。資料流 API、查詢語言和資料框 API 如今都得到了廣泛支援,作業與工作流編排同樣日趨成熟。Oozie、Azkaban 等以 Hadoop 為中心的工作流排程器,已經被 Airflow、Dagster 和 Prefect 等更通用的方案所取代;後者支援各種批處理框架和雲資料倉儲。
雲端計算已經十分普及,批處理的儲存層也正從 HDFS、GlusterFS 和 CephFS 等分散式檔案系統(DFS)轉向 S3 之類的物件儲存。BigQuery、Snowflake 等可伸縮的雲資料倉儲,則進一步模糊了資料倉儲與批處理系統之間的界限。
為了直觀地理解批處理,本章先從單臺機器上的標準 Unix 工具入手,再研究如何把資料處理擴充套件到分散式系統中的多臺機器。我們將看到,分散式批處理框架與作業系統很相似,同樣擁有排程器和檔案系統。隨後,我們會考察編寫批處理作業時使用的幾種處理模型,最後討論常見的批處理用例。
使用 Unix 工具的批處理
假設有一臺 Web 伺服器,每處理一個請求,它都會在日誌檔案末尾追加一行。以 nginx 預設的訪問日誌格式為例,其中一行可能如下所示:
216.58.210.78 - - [27/Jun/2025:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
200 3377 "https://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X
10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
(這其實是一行,只是為了便於閱讀才在這裡折成了多行。)這一行包含許多資訊。要解釋它,需要先檢視日誌格式的定義:
$remote_addr - $remote_user [$time_local] "$request"
$status $body_bytes_sent "$http_referer" "$http_user_agent"
這行日誌表示:在 UTC 時間 2025 年 6 月 27 日 17:55:11,伺服器收到了來自客戶端 IP 地址 216.58.210.78、請求檔案 /css/typography.css 的請求。使用者沒有經過身份認證,因此 $remote_user 被設為連字元(-)。響應狀態碼為 200(即請求成功),響應大小為 3,377 位元組。瀏覽器是 Chrome 137;它之所以載入這個檔案,是因為網址 * https://martin.kleppmann.com/* 的頁面引用了該檔案。
解析日誌聽起來或許像是一個刻意編造的例子,實際上卻是許多現代科技公司的關鍵工作,從廣告資料管道到支付處理無所不包。事實上,日誌處理正是 MapReduce 得以迅速普及、並推動“大資料”浪潮的重要原因之一。
簡單日誌分析
許多工具都能讀取這些日誌檔案,生成漂亮的網站流量報告。不過為了練習,我們來用基本的 Unix 工具自行構建一個。假設你想找出網站上最受歡迎的五個頁面,可以在 Unix shell 中執行下面的命令:
讀取日誌檔案。(嚴格來說,這裡的
cat並非必需,因為可以把輸入檔案直接作為引數傳給awk。不過這樣寫能讓線性管道顯得更清楚。)按空白字元把每一行拆成欄位,只輸出第七個欄位,而它恰好就是請求的 URL。在前面的示例中,這個 URL 是 /css/typography.css。
按字母順序
sort請求 URL 列表。如果某個 URL 被請求了 n 次,那麼排序後的檔案中就會連續出現 n 個相同的 URL。uniq命令透過檢查相鄰兩行是否相同,濾掉輸入中重複的行。-c選項讓它同時輸出計數器:對於每個不同的 URL,它會報告該 URL 在輸入中出現了多少次。第二個
sort按每行開頭的數字(-n)排序,也就是按 URL 的請求次數排序;然後以逆序(-r)返回結果,讓最大的數字排在最前面。最後,
head只輸出輸入的前五行(-n 5),並丟棄其餘內容。
這一系列命令的輸出大致如下:
4189 /favicon.ico
3631 /2016/02/08/how-to-do-distributed-locking.html
2124 /2020/11/18/distributed-systems-and-elliptic-curves.html
1369 /
915 /css/typography.css
如果你不熟悉 Unix 工具,前面的命令列可能顯得有些晦澀,但它的能力非常強大。它能在幾秒鐘內處理數 GB 的日誌,而且可以輕鬆修改分析方式來滿足需要。例如,如果不希望報告中包含 CSS 檔案,只需把 awk 引數改為 '$7 !~ /\.css$/ {print $7}';如果想統計最常出現的客戶端 IP 地址,而不是最常訪問的頁面,則把引數改為 '{print $1}',以此類推。
本書沒有足夠篇幅詳細介紹 Unix 工具,但它們非常值得學習。令人驚訝的是,僅用 awk、sed、grep、sort、uniq 和 xargs 的某種組合,幾分鐘內就能完成許多資料分析,而且效能也出奇地好 8。
命令鏈與自定義程式
除了使用 Unix 命令鏈,你也可以寫一個簡單的程式來完成同樣的工作。例如,Python 程式可能如下所示:
counts是一個雜湊表,儲存每個 URL 出現次數的計數器;計數器的預設值為 0。從每一行日誌中取出按空白字元分隔的第七個欄位,作為 URL(因為 Python 陣列從 0 開始計數,所以陣列下標是 6)。
把當前日誌行中 URL 對應的計數器加一。
按計數器的值對雜湊表內容降序排列,並取出前五項。
輸出這五項。
這個程式沒有 Unix 管道那麼簡潔,但也相當容易讀懂;喜歡哪一種,部分取決於個人偏好。不過,除了表面的語法差異,兩者的執行流程也大不相同。如果在大檔案上執行這項分析,區別就會顯現出來。
排序與記憶體聚合
Python 指令碼在記憶體中維護一個 URL 雜湊表,把每個 URL 對映到它出現的次數。Unix 管道沒有這樣的雜湊表,而是依靠排序 URL 列表;同一個 URL 出現多次時,它只是在列表中重複多次。
哪種方法更好?這取決於不同 URL 的數量。對於大多數中小型網站,你大概可以把所有不同的 URL 及其計數器放進 1 GB 左右的記憶體。這個作業的 工作集(working set;即作業需要隨機訪問的記憶體量)只取決於不同 URL 的數量:即使日誌中同一個 URL 出現了一百萬次,雜湊表所需的空間仍然只是一個 URL 加一個計數器。只要工作集足夠小,記憶體雜湊表就能很好地工作——即便在膝上型電腦上也是如此。
另一方面,如果作業的工作集大於可用記憶體,排序方法就有一個優勢:它可以高效利用磁碟。這與“日誌結構儲存”中討論的原理相同:先在記憶體中對資料塊排序,並把它們作為段檔案寫入磁碟,再將多個有序段合併成一個更大的有序檔案。歸併排序採用順序訪問模式,在磁碟上表現很好(參見“順序與隨機寫入”)。
GNU Coreutils(Linux)中的 sort 工具會把無法裝入記憶體的資料自動溢寫到磁碟,還會自動利用多個 CPU 核並行排序 9。這意味著前面的簡單 Unix 命令鏈可以輕鬆擴充套件到大型資料集,而不會耗盡記憶體。此時的瓶頸很可能是從磁碟讀取輸入檔案的速度。
Unix 工具的侷限在於,它們只能在單臺機器上執行。如果資料集大到無法裝入單機記憶體或本地磁碟,就會遇到問題——這正是分散式批處理框架的用武之地。
分散式系統中的批處理
執行前面 Unix 工具示例的機器,需要由幾個元件協同處理日誌資料:
透過作業系統的檔案系統介面訪問的儲存裝置。
決定程序何時執行、如何為其分配 CPU 資源的排程器。
一系列 Unix 程式,其
stdin和stdout透過管道連線在一起。
分散式資料處理框架中也存在同樣的元件。事實上,你可以把 分散式處理框架(distributed processing framework)看作一種分散式作業系統:它們擁有檔案系統和作業排程器,程式則透過檔案系統或其他通訊通道相互傳送資料。
分散式檔案系統
作業系統提供的檔案系統由若干層組成:
最底層的塊裝置驅動程式直接與磁碟通訊,讓上層能夠讀寫原始資料塊。
塊裝置層之上是頁快取,它把最近訪問的資料塊儲存在記憶體中,以加快再次訪問的速度。
檔案系統層封裝了塊 API,把大檔案拆分成塊,並維護 inode、目錄和檔案等後設資料。例如,ext4 和 XFS 是 Linux 上常用的兩種實現。
最後,作業系統透過名為虛擬檔案系統(VFS)的統一 API,把不同檔案系統暴露給應用。無論底層採用哪一種檔案系統,應用都可以用同樣的方式讀寫資料。
分散式檔案系統(distributed filesystem)的工作方式與此非常相似。檔案同樣被拆分成塊,只不過這些塊分佈在許多機器上。分散式檔案系統的塊通常比本地檔案系統大得多:HDFS(Hadoop 分散式檔案系統)的預設塊大小為 128 MB,JuiceFS 和許多物件儲存使用 4 MB 的塊,而 ext4 的塊只有 4,096 位元組。塊越大,需要跟蹤的後設資料就越少;對於 PB 級資料集,這種差異十分顯著。相對於讀取資料塊所需的時間,大塊也能降低尋道開銷所佔的比例。
大多數物理儲存裝置無法寫入不完整的資料塊,因此即使資料沒有填滿一個塊,作業系統也必須為寫入使用整個塊。分散式檔案系統的塊更大,而且通常構建在作業系統檔案系統之上,所以沒有這種要求。例如,一個 900 MB 的檔案採用 128 MB 的分塊時,會由 7 個佔用 128 MB 的塊和 1 個佔用 4 MB 的塊組成。
讀取分散式檔案系統中的塊,需要向叢集中存放該塊的機器發出網路請求。每臺機器都執行一個守護程序,並公開一套 API,讓遠端程序能夠讀寫以檔案形式儲存在其本地檔案系統中的資料塊。HDFS 把這些守護程序稱為 DataNode,GlusterFS 則稱為 glusterfsd。本書統一把它們稱為 資料節點(data node)。
分散式檔案系統還實現了分散式版本的頁快取。由於資料塊以檔案形式存放在資料節點上,讀寫操作會經過每個資料節點的作業系統,其中就包含記憶體頁快取。因此,經常讀取的資料塊會被快取在資料節點的記憶體中。有些分散式檔案系統還實現了更多快取層,例如 JuiceFS 提供的客戶端快取和本地磁碟快取。
ext4 和 XFS 等檔案系統會跟蹤空閒空間、檔案塊位置、目錄結構和許可權設定等儲存後設資料。分散式檔案系統同樣需要記錄檔案分散在哪些機器上、具有什麼許可權等資訊。Hadoop 透過 NameNode 服務維護叢集後設資料;DeepSeek 的 3FS 則使用後設資料服務,並把資料持久化到 FoundationDB 之類的鍵值儲存中。
檔案系統層之上是 VFS;在批處理中,與之最接近的是分散式檔案系統的協議。分散式檔案系統必須公開某種協議或介面,讓批處理系統能夠讀寫檔案。它充當一套可插拔介面:只要實現該協議,任何分散式檔案系統都可以接入。例如,MinIO、Cloudflare R2、Tigris 和 Backblaze B2 等許多儲存系統都採用了 Amazon S3 API。支援 S3 的批處理系統,也就能夠使用其中任何一種儲存。
有些分散式檔案系統提供與 POSIX 相容的介面,在作業系統的 VFS 看來,它們與其他檔案系統並無不同。這類系統通常透過使用者空間檔案系統(FUSE)或網路檔案系統(NFS)協議接入 VFS。NFS 或許是最著名的分散式檔案系統協議。它最初的用途,是讓多個客戶端讀寫單臺伺服器上的資料。近來,AWS Elastic File System(EFS)和 Archil 等檔案系統提供了可伸縮得多、但仍與 NFS 相容的分散式實現。NFS 客戶端依舊只連線一個端點,不過這些系統會在內部與分散式後設資料服務和資料節點通訊,完成資料讀寫。
分散式檔案系統以 無共享 原則為基礎(參見“共享記憶體、共享磁碟與無共享架構”),不同於網路附加儲存(NAS)和儲存區域網路(SAN)架構採用的 共享磁碟 方法。共享磁碟儲存由集中式儲存裝置實現,往往需要定製硬體和光纖通道等專用網路基礎設施。相比之下,無共享方法不需要特殊硬體,只需用普通的資料中心網路連線計算機即可。
許多分散式檔案系統構建在普通商用硬體上。這種硬體比較便宜,但故障率也高於企業級硬體。為了容忍機器和磁碟故障,檔案塊會複製到多臺機器上。這樣也便於排程器更均勻地分配工作負載,因為任務可以在任何存有其輸入資料副本的節點上執行。這裡的複製可以像第 6 章所述,在多臺機器上儲存若干份完整副本;也可以採用 Reed–Solomon 碼等 糾刪碼(erasure coding),以低於完整複製的儲存開銷恢復丟失的資料 10 11 12。這些技術與 RAID 很相似,後者在連線到同一臺機器的多個磁碟之間提供冗餘;區別在於,分散式檔案系統透過普通的資料中心網路訪問和複製檔案,不需要特殊硬體。
物件儲存
Amazon S3、Google Cloud Storage、Azure Blob Storage 和 OpenStack Swift 等 物件儲存(object storage)服務,已經成為批處理作業中分散式檔案系統的常用替代方案。事實上,兩者的界限有些模糊。正如上一節和“以物件儲存為後端的資料庫”中所述,使用者空間檔案系統(FUSE)驅動程式可以讓使用者把 S3 之類的物件儲存當作檔案系統使用。JuiceFS 和 Ceph 等分散式檔案系統實現也同時提供物件儲存與檔案系統 API。不過,兩類系統的 API、效能和一致性保證可能大相徑庭。即使某個系統看似實現了所需的 API,採用之前也必須仔細確認它的實際行為符合預期。
物件儲存中的每個物件都有一個 URL,例如 s3://my-photo-bucket/2025/04/01/birthday.png。URL 的主機部分(my-photo-bucket)表示存放物件的儲存桶(bucket),後面的部分則是物件的 鍵(本例中為 /2025/04/01/birthday.png)。儲存桶的名稱在全域性範圍內唯一,而每個物件的鍵在所屬儲存桶內必須唯一。
物件透過 get 呼叫讀取,透過 put 呼叫寫入。與檔案系統中的檔案不同,物件寫入後是不可變的;若要更新,只能像鍵值儲存一樣,透過 put 完整重寫整個物件。Azure Blob Storage 和 S3 Express One Zone 支援追加寫入,但大多數物件儲存都不支援。物件儲存也沒有 fopen、fseek 之類的檔案控制代碼 API。
物件看起來似乎按目錄組織,但這有些誤導,因為物件儲存根本沒有目錄的概念。路徑結構只是一種約定,斜槓本身也是物件鍵的一部分。這種約定允許你按特定字首請求物件列表,實現類似目錄列表的效果。不過,按字首列出物件與檔案系統列出目錄有兩點區別:
字首
list操作類似於 Unix 系統上的遞迴ls -R:它會返回所有以該字首開頭的物件,其中也包括子路徑下的物件。物件儲存中不可能存在空目錄。假如刪除
s3://my-photo-bucket/2025/04/01下的所有物件,那麼對s3://my-photo-bucket/2025/04呼叫list時,01就不會再出現。常見的做法是用一個零位元組物件來表示空目錄,例如建立空物件s3://my-photo-bucket/2025/04/01,這樣即使所有子物件都已刪除,它仍會保留下來。
分散式檔案系統通常支援硬連結、符號連結、檔案鎖和原子重新命名等常見檔案操作,物件儲存則沒有這些功能。它們一般不支援連結和鎖,重新命名也不是原子的,而是先把物件複製到新鍵,再刪除舊物件。如果想重新命名一個“目錄”,就必須逐一重新命名其中的每個物件,因為目錄名本身是物件鍵的一部分。
第 4 章討論的鍵值儲存針對小值(通常只有幾 KB)以及頻繁、低延遲的讀寫進行了最佳化。相比之下,分散式檔案系統和物件儲存通常針對大物件(數 MB 至數 GB)和頻率較低、規模較大的讀取進行了最佳化。不過最近,物件儲存也開始支援更加頻繁、規模更小的讀寫。例如,S3 Express One Zone 如今可以達到個位數毫秒延遲,其定價模型也更接近鍵值儲存。
分散式檔案系統與物件儲存還有一個區別:HDFS 等分散式檔案系統可以把計算任務安排在存有特定檔案副本的機器上執行。任務就能直接讀取本地檔案,無需透過網路傳輸;如果任務的可執行程式碼遠小於它要讀取的檔案,這樣可以節省大量頻寬。物件儲存通常把儲存與計算分開。這種做法可能消耗更多頻寬,但現代資料中心網路的速度很快,因而往往可以接受。儲存與計算解耦後,CPU、記憶體等計算資源還可以獨立於儲存容量進行伸縮。
分散式作業編排
作業系統的類比同樣適用於作業編排。執行 Unix 批處理作業時,總要有某個元件真正執行 awk、sort、uniq 和 head 這些程序。它必須把一個程序的輸出傳到另一個程序的輸入,為每個程序分配記憶體,在 CPU 上公平地排程並執行各程序的指令,實施記憶體和 I/O 邊界,等等。在單臺機器上,這些工作由作業系統核心負責;在分散式環境中,它們則由作業編排器承擔。
批處理框架會向編排器的排程器傳送執行作業的請求。啟動作業的請求包含如下後設資料:
要執行的任務數量;
每項任務所需的記憶體、CPU 和磁碟資源;
作業識別符號;
訪問憑據;
輸入、輸出資料等作業引數;
GPU 或磁碟型別等必要的硬體資訊;
作業可執行程式碼所在的位置。
Kubernetes 和 Hadoop YARN(Yet Another Resource Negotiator)13 等編排器會結合這些資訊與叢集後設資料,透過下列元件執行作業:
- 任務執行器(Task Executor)
叢集中的每個節點都執行著執行器守護程序,例如 YARN 的 NodeManager 或 Kubernetes 的 kubelet。執行器負責執行作業任務,傳送心跳來表明自己仍然存活,並跟蹤節點上的任務狀態與資源分配。執行器收到啟動任務的請求後,會取得作業的可執行程式碼,並執行命令來啟動任務。然後,它會監視程序,直到程序結束或失敗,再相應更新任務狀態後設資料。
許多執行器還會與作業系統配合,同時提供安全隔離與效能隔離。例如,YARN 和 Kubernetes 都使用 Linux cgroups。這樣既能防止任務訪問無權訪問的資料,也能防止任務過度使用資源、影響同一節點上其他任務的效能。
- 資源管理器(Resource Manager)
編排器的資源管理器儲存每個節點的後設資料,包括可用硬體(CPU、GPU、記憶體和磁碟等)、任務狀態、網路位置、節點狀態以及其他相關資訊。因此,資源管理器能夠提供叢集當前狀態的全域性檢視。資源管理器的中心化特性可能同時形成可伸縮性和可用性瓶頸。YARN 使用 ZooKeeper、Kubernetes 使用 etcd 來儲存叢集狀態(參見“協調服務”)。
- 排程器(Scheduler)
編排器通常有一個中心化的排程子系統,負責接收啟動、停止作業或查詢作業狀態的請求。例如,排程器可能收到一項請求:使用特定的 Docker 映象,在配有某種 GPU 的節點上啟動一項包含 10 個任務的作業。排程器根據請求中的資訊和資源管理器儲存的狀態,決定把哪些任務放在哪些節點上執行。隨後,它會把分配結果通知任務執行器,由執行器開始執行任務。
雖然各個編排器使用的術語不盡相同,但幾乎所有編排系統中都能找到這些元件。
有些排程決策需要由應用專用的排程器來完成,以便考慮特定需求,例如在查詢量達到某個閾值時自動擴充套件只讀副本。中心排程器與應用專用排程器共同決定任務的最佳執行方式。YARN 把這種子排程器稱為 ApplicationMaster,Kubernetes 則稱為 operator。
資源分配
排程器在作業編排中扮演著格外棘手的角色:面對需求相互競爭的作業,它必須找出分配叢集有限資源的最佳方式。從根本上說,排程決策需要在公平與效率之間取得平衡。
設想一個由五個節點組成的小型叢集,總共有 160 個 CPU 核可用。叢集排程器收到了兩項作業請求,每項都希望使用 100 個核來完成工作。怎樣排程才最好?
排程器可以同時為每項作業執行 80 個任務,等先前的任務完成後,再啟動兩項作業各自剩餘的 20 個任務。
排程器也可以先執行一項作業的所有任務,等到有 100 個核可用時,再開始執行第二項作業。這種策略稱為 成組排程(gang scheduling)。
一項作業請求會比另一項先到。排程器必須決定是把 100 個核全部分配給先到的作業,還是留下一些資源,為尚未到來的作業做準備。
這個例子非常簡單,卻已經暴露出許多艱難的權衡。以成組排程為例:如果排程器不斷預留 CPU 核,直到 100 個核能夠同時使用,那麼一些節點就會閒置,叢集的資源利用率也會下降;如果其他作業也試圖預留 CPU 核,甚至還可能發生死鎖。
另一方面,如果排程器只是等待 100 個核空閒,其他作業又可能在此期間搶走這些核。叢集或許會在很長時間內都湊不出 100 個可用核,從而導致 飢餓(starvation)。排程器還可以 搶佔(preempt)第一項作業的部分任務,終止它們來為第二項作業騰出空間。不過,搶佔任務同樣會降低叢集效率,因為被終止的任務稍後需要重新啟動、重新執行。
現在再設想一下,排程器必須為數百乃至數百萬項這樣的作業請求作出分配決策。要找到最優解似乎根本不可行。事實上,這個問題是 NP-hard 的;也就是說,除了規模最小的例子之外,計算最優解所需的時間長得令人無法接受 14 15。
因此,實際的排程器會採用啟發式方法,作出雖非最優、但還算合理的決策。常見演算法包括先進先出(FIFO)、主導資源公平(DRF)、優先順序佇列、基於容量或配額的排程,以及各種裝箱演算法。這些演算法的細節超出了本書範圍,但排程確實是一個十分有趣的研究領域。
工作流排程
本章開頭的 Unix 工具示例由若干命令串聯而成。分散式批處理也經常採用相同的模式:一項作業的輸出需要成為另一項或多項作業的輸入,而一項作業又可能有多個輸入,分別由其他作業產生。這種作業結構稱為 工作流(workflow),也稱為作業的 有向無環圖(directed acyclic graph,DAG)。
在“持久化執行與工作流”中,我們見過能夠持久執行一系列步驟的工作流引擎,這些步驟通常會發出 RPC。在批處理的語境中,“工作流”有不同含義:它是一系列批處理過程,每個過程都接收輸入資料、產生輸出資料,通常不會向外部服務發出 RPC。持久化執行引擎通常會比批處理系統在每個請求中處理更少的資料,不過兩者之間的界限並不十分清晰。
採用多項作業組成的工作流可能有幾個原因:
如果一項作業的輸出需要成為多項其他作業的輸入,而且這些下游作業由不同團隊維護,最好先讓第一項作業把輸出寫到一個所有下游作業都能讀取的位置。每當資料更新時,消費它的作業就可以安排執行,也可以按照其他時間表執行。
你可能需要把資料從一種處理工具傳給另一種。例如,一項 Spark 作業把資料輸出到 HDFS,隨後由 Python 指令碼觸發 Trino SQL 查詢(參見“雲資料倉儲”),繼續處理 HDFS 檔案,並把結果輸出到 S3。
有些資料管道本身就需要多個處理階段。例如,某個階段需要按一個鍵分片資料,而下一階段需要按另一個鍵分片,那麼第一個階段就可以按照第二階段需要的方式對輸出資料分片。
在 Unix 工具示例中,連線一項命令輸出與另一項命令輸入的管道只使用一個很小的記憶體緩衝區,並不會把資料寫入檔案。如果緩衝區已滿,生產資料的程序就必須等待,直到消費程序從緩衝區中讀走一部分資料,才能繼續輸出——這是一種 背壓。Spark、Flink 等批處理執行引擎支援類似的模型,可以把一項任務的輸出直接傳給另一項任務;如果兩項任務執行在不同機器上,資料就透過網路傳輸。
不過在工作流中,更常見的做法是讓一項作業把輸出寫入分散式檔案系統或物件儲存,再由下一項作業從那裡讀取。這樣可以使作業彼此解耦,在不同時間執行。如果一項作業有多個輸入,工作流排程器通常要等到產生這些輸入的所有作業都成功完成,才會執行消費這些輸入的作業。
YARN ResourceManager 等編排框架中的排程器,以及 Spark 的內建排程器,都不會管理完整的工作流;它們只按單項作業進行排程。為了處理多次作業執行之間的依賴,人們開發了 Airflow、Dagster 和 Prefect 等工作流排程器。維護大量批處理作業時,這些排程器提供了非常有用的管理功能。許多資料管道的工作流通常包含 50 至 100 項作業;在大型組織中,還可能有許多團隊執行不同的作業或工作流,跨越多個系統讀取彼此的輸出。管理這樣複雜的資料流,離不開相應的工具支援。
故障處理
批處理作業往往會執行很長時間。一項包含許多並行任務、長時間執行的作業,很可能會在途中遇到至少一次任務失敗。正如“硬體與軟體故障”和“不可靠的網路”中所討論的,這可能由許多原因造成,其中包括硬體故障(普通商用硬體上尤其常見)和網路中斷。
任務無法完成的另一個原因,是排程器可能有意搶佔(終止)它。當系統設定多個優先順序時,搶佔尤其有用:低優先順序任務執行成本較低,高優先順序任務則要付出更高成本。只要還有空閒計算容量,就可以執行低優先順序任務;但如果一項高優先順序任務到來,低優先順序任務隨時可能遭到搶佔。這類價格較低的低優先順序虛擬機器,在 Amazon EC2 中稱為 競價例項(spot instance),在 Azure 中稱為 競價虛擬機器(spot virtual machine),在 Google Cloud 中則稱為 可搶佔例項(preemptible instance)16。
批處理通常用於時效性要求不高的作業,因此很適合使用低優先順序任務和競價例項,以降低執行成本。實質上,這些作業利用了原本會被閒置的計算資源,從而提高叢集利用率。不過,這也意味著排程器更可能終止這些任務:搶佔發生的頻率要高於硬體故障 17。
由於批處理作業每次執行都從頭生成輸出,任務失敗要比線上系統中的故障容易處理:系統可以刪除失敗執行留下的不完整輸出,再把任務安排到另一臺機器上重新執行。不過,只因一項任務失敗就重跑整個作業會非常浪費。因此,MapReduce 及其後繼系統讓並行任務彼此獨立,從而可以按單項任務的粒度重試工作 3。
如果一項任務的輸出要在工作流中成為另一項任務的輸入,容錯就會更加棘手。MapReduce 的辦法是始終把這類中間資料寫回分散式檔案系統,並等待寫入任務順利完成,之後才允許其他任務讀取資料。即使在搶佔頻繁的環境中,這種做法也能正常工作,但它要向分散式檔案系統寫入大量資料,效率可能很低。
Spark 把中間資料儲存在記憶體中,必要時“溢寫”到本地磁碟,只把最終結果寫入分散式檔案系統。它還會跟蹤中間資料的計算過程,以便在資料丟失時重新計算 18。Flink 採用另一種方法,定期為任務狀態的快照建立檢查點 19。我們將在“資料流引擎”中再次討論這個話題。
批處理模型
我們已經瞭解了分散式環境如何排程批處理作業。現在把注意力轉向批處理框架實際處理資料的方式。最常見的兩種模型是 MapReduce 和資料流引擎。雖然在實踐中,資料流引擎已經基本取代 MapReduce,但理解 MapReduce 的工作原理仍然很有用,因為許多現代批處理框架都深受它的影響。
MapReduce 和資料流引擎已經逐漸支援多種程式設計模型,其中包括底層程式設計 API、關係查詢語言和資料框 API。豐富的選擇使應用工程師、分析工程師、業務分析師,甚至不具備技術背景的員工,都能夠出於各種用途處理企業資料。我們將在“批處理用例”中討論這些用途。
MapReduce
MapReduce 的資料處理模式與“簡單日誌分析”中的 Web 伺服器日誌示例非常相似:
讀取一組輸入檔案,並把它們拆分成 記錄。在 Web 伺服器日誌示例中,每條記錄就是日誌的一行(也就是說,
\n是記錄分隔符)。在 Hadoop MapReduce 中,輸入檔案儲存在 HDFS 之類的分散式檔案系統,或 S3 之類的物件儲存中。系統可以使用多種檔案格式,例如 Apache Parquet(列式格式,參見“列式儲存”)或 Apache Avro(行式格式,參見“Avro”)。對每條輸入記錄呼叫 mapper 函式,從中提取鍵和值。在 Unix 工具示例中,mapper 函式就是
awk '{print $7}':它提取 URL($7)作為鍵,並把值留空。按鍵對所有鍵值對排序。在日誌示例中,這一步由第一個
sort命令完成。呼叫 reducer 函式,遍歷排序後的鍵值對。如果同一個鍵出現多次,排序會讓這些記錄在列表中彼此相鄰,因此不必在記憶體中儲存大量狀態,就能輕鬆合併這些值。在 Unix 工具示例中,reducer 由
uniq -c命令實現,它負責統計具有相同鍵的相鄰記錄數。
這四個步驟可以由一項 MapReduce 作業完成。第 2 步(map)與第 4 步(reduce)需要編寫定製的資料處理程式碼。第 1 步(把檔案拆成記錄)由輸入格式解析器負責。第 3 步的 sort 在 MapReduce 中是隱式的——mapper 的輸出總會在傳給 reducer 之前排序,所以不需要自行實現。排序是批處理中的基礎演算法,我們將在“混洗資料”中再次討論。
要建立一項 MapReduce 作業,需要實現 mapper 和 reducer 兩個回撥函式,其行為如下:
- Mapper
每條輸入記錄都會呼叫一次 mapper,其任務是從輸入記錄中提取鍵和值。對於每條輸入,它可以生成任意數量的鍵值對(也可以一個都不生成)。mapper 不會把某條輸入記錄的狀態保留到下一條,因此每條記錄都能獨立處理。
- Reducer
MapReduce 框架接收 mapper 產生的鍵值對,收集屬於同一個鍵的所有值,再用一個遍歷該值集合的迭代器呼叫 reducer。reducer 可以生成輸出記錄,例如同一 URL 的出現次數。
在 Web 伺服器日誌示例中,第 5 步還有第二個 sort 命令,用於按請求次數排列 URL。在 MapReduce 中,如果需要第二個排序階段,可以再編寫一項 MapReduce 作業,把第一項作業的輸出作為第二項的輸入。從這個角度看,mapper 的作用是準備資料,把它轉換成適合排序的形式;reducer 的作用則是處理已經排好序的資料。
MapReduce 雖然用於批處理,其程式設計模型卻來自函數語言程式設計。Lisp 最早引入了 map 和 reduce(也稱 fold),把它們作為列表上的高階函式;後來,Python、Rust 和 Java 等主流語言也採用了這些函式。包括 SQL 提供的操作在內,許多常見的資料處理操作都可以建立在 MapReduce 之上。兩個函式乃至函數語言程式設計整體所具備的一些重要性質,恰好能為 MapReduce 所用。map 與 reduce 可以相互組合,這非常適合資料處理(正如 Unix 示例所示)。map 還天然易於並行,因為每項輸入都獨立處理;reduce 則可以並行處理不同的鍵。
實際上,使用原始 MapReduce API 實現複雜的處理作業非常困難而且費力——例如,作業使用的任何連線演算法都必須從頭實現 20。與較新的批處理器相比,MapReduce 的速度也很慢。其中一個原因是,基於檔案的 I/O 無法形成作業流水線:上游作業完成之前,下游作業不能開始處理它的輸出。
資料流引擎
為了解決 MapReduce 的一些問題,人們開發了幾種新的分散式批處理執行引擎,其中最著名的是 Spark 18 21 和 Flink 19。它們的設計方式各有不同,卻有一個共同點:把整個工作流作為一項作業處理,而不是拆成彼此獨立的子作業。
這些系統顯式建模了資料流經多個處理階段的過程,因此稱為 資料流引擎(dataflow engine)。與 MapReduce 一樣,它們提供底層 API,透過反覆呼叫使用者定義的函式,每次處理一條記錄;同時也提供 連線(join)和 分組(grouping)等高層運算元。它們透過對輸入分片來並行執行工作,並把一項任務的輸出複製到另一項任務,作為後者的輸入;如果兩項任務執行在不同機器上,複製就透過網路完成。與 MapReduce 不同,運算元不必嚴格交替扮演 map 和 reduce 的角色,而可以以更加靈活的方式組合。
這些資料流 API 通常使用關係模型風格的構件來表達計算:按照某個欄位的值連線資料集,按鍵對元組分組,根據某項條件過濾資料,以及透過計數、求和等函式聚合元組。這些操作在內部透過下一節討論的混洗演算法實現。
這類處理引擎建立在 Dryad 22 和 Nephele 23 等研究系統的基礎之上。與 MapReduce 模型相比,它們有幾個優點:
排序之類代價高昂的工作只需在確有必要之處執行,不必預設出現在每個 map 階段與 reduce 階段之間。
如果一連串運算元都不改變資料集的分片方式(例如 map 或 filter),它們就可以合併成一項任務,從而減少複製資料的開銷。
工作流中的所有連線和資料依賴都經過顯式宣告,因此排程器可以總覽全域性,瞭解各處需要哪些資料,並據此最佳化資料區域性。例如,它可以嘗試把消費某項資料的任務放在產生該資料的任務所在機器上,這樣便能透過共享記憶體緩衝區交換資料,無需透過網路複製。
運算元之間的中間狀態通常只需儲存在記憶體中,或寫入本地磁碟;這樣所需的 I/O 少於寫入分散式檔案系統或物件儲存,因為後者還要把資料複製到多臺機器上,並在每個副本所在機器上寫入磁碟。MapReduce 已經對 mapper 的輸出採用了這種最佳化,資料流引擎則把這個思想推廣到了所有中間狀態。
運算元可以在輸入準備就緒後立即開始執行,無需等前一階段全部結束,下一階段才能開始。
現有程序可以重複用於執行新的運算元,從而減少啟動開銷;相比之下,MapReduce 會為每項任務啟動一個新的 JVM。
資料流引擎能夠實現與 MapReduce 工作流相同的計算,而且得益於上述最佳化,執行速度通常快得多。
混洗資料
我們已經看到,本章開頭的 Unix 工具示例和 MapReduce 都以排序為基礎。批處理器必須能夠對 PB 級的資料集排序,而這些資料不可能裝入一臺機器。因此,它們需要一種輸入和輸出都經過分片的分散式排序演算法,這種演算法稱為 混洗(shuffle)。
“shuffle” 這個詞容易引起誤解:把一副撲克牌洗牌,會得到隨機順序;這裡所說的混洗卻會產生排好序的結果,其中不含任何隨機性。
混洗是批處理器的一項基礎演算法,連線與聚合都要使用它。MapReduce、Spark、Flink、Daft、Dataflow 和 BigQuery 24 都實現了可伸縮的高效能混洗演算法,以處理大型資料集。下面以 Hadoop MapReduce 中的混洗為例 25,不過本節的概念也適用於其他系統。
圖 11-1 展示了一項 MapReduce 作業中的資料流。假設作業的輸入已經分片,三個分片分別標為 m 1、m 2 和 m 3。例如,每個分片可以是 HDFS 中的單獨檔案,也可以是物件儲存中的單獨物件。同一資料集的全部分片,可以集中放在同一個 HDFS 目錄中;在物件儲存的儲存桶裡,它們也可以使用相同的鍵字首。

框架會為每個輸入分片啟動一項單獨的 map 任務。任務讀取分配給它的檔案,每次把一條記錄傳給 mapper 回撥函式。計算的 reduce 端同樣經過分片。map 任務的數量由輸入分片數決定,而 reduce 任務的數量則由作業作者配置,兩者可以不同。
mapper 的輸出由鍵值對組成。框架必須保證:如果兩個不同的 mapper 輸出了相同的鍵,這些鍵值對最終會由同一個 reducer 任務處理。為此,每個 mapper 都會在本地磁碟上為每個 reducer 分別建立一個輸出檔案。例如,圖 11-1中的檔案 m 1, r 2 由 mapper 1 建立,包含發往 reducer 2 的資料。mapper 輸出一個鍵值對時,通常會對鍵進行雜湊,據此決定把它寫入哪個 reducer 檔案,這與“按鍵的雜湊分片”相似。
mapper 在寫入這些檔案的同時,還會在每個檔案中按鍵排列鍵值對。這裡可以採用“日誌結構儲存”中見過的技術:先在記憶體的有序資料結構中收集一批鍵值對,再把它們寫成有序段檔案,然後逐步將較小的段合併成較大的段。
每個 mapper 完成後,reducer 會連線到它,並把屬於自己的有序鍵值對檔案複製到本地磁碟。reduce 任務取得所有 mapper 輸出中屬於自己的那一份後,會像歸併排序一樣合併這些檔案,同時保持排序順序。這樣一來,即使具有相同鍵的鍵值對來自不同的 mapper,最終也會彼此相鄰。隨後,每個鍵都會呼叫一次 reducer 函式,並向它傳入一個迭代器,用於返回該鍵對應的所有值。
reducer 函式產生的記錄會順序寫入檔案,每項 reduce 任務對應一個檔案。圖 11-1中的 r 1、r 2 和 r 3 就構成了作業輸出資料集的三個分片,它們會被寫回分散式檔案系統或物件儲存。
MapReduce 在 map 與 reduce 階段之間執行混洗,而現代資料流引擎和雲資料倉儲則要精巧得多。BigQuery 等系統最佳化了混洗演算法,儘量把資料儲存在記憶體中,或把資料寫入外部排序服務 24。這類服務既能加快混洗,又能透過複製混洗資料來提高容錯能力。
JOIN 與 GROUP BY
下面來看有序資料如何簡化分散式連線與聚合。為便於說明,我們仍以 MapReduce 為例,不過這些概念適用於大多數批處理系統。
圖 11-2 展示了批處理作業中一個典型的連線示例。左側是一份事件日誌,記錄已登入使用者在網站上的操作,這些記錄稱為 活動事件(activity event),也叫 點選流資料(clickstream data);右側則是使用者資料庫。這個例子可以看作星型模式的一部分(參見“星型與雪花型:分析模式”):事件日誌是事實表,使用者資料庫則是其中一張維度表。

如果想結合使用者資料庫中的資訊來分析活動事件——例如利用使用者資料中的出生日期,瞭解某些頁面更受年輕使用者還是年長使用者歡迎——就需要連線這兩張表。假設兩張表都大到必須分片,該如何計算這種連線?
可以利用 MapReduce 的一個性質:無論鍵值對最初位於哪個分片,混洗都會把具有相同鍵的鍵值對集中到同一個 reducer。這裡可以把使用者 ID 作為鍵。因此,我們可以編寫一個 mapper 遍歷使用者活動事件,以使用者 ID 為鍵,輸出頁面瀏覽的 URL,如圖 11-3所示。另一個 mapper 逐行遍歷使用者資料庫,提取使用者 ID 作為鍵、使用者出生日期作為值。

混洗隨後確保 reducer 函式能夠同時訪問某位使用者的出生日期,以及該使用者的所有頁面瀏覽事件。MapReduce 甚至可以安排記錄的順序,讓 reducer 總是先看到使用者資料庫中的記錄,接著再按時間戳順序看到活動事件;這種技術稱為 二次排序(secondary sort)25。
這樣,reducer 就能輕鬆完成實際的連線邏輯。第一個值應當是出生日期,reducer 先把它儲存在區域性變數中,再遍歷具有相同使用者 ID 的活動事件,輸出每個瀏覽過的 URL 以及瀏覽者的出生日期。reducer 會一次處理某個使用者 ID 的全部記錄,所以任何時刻只需在記憶體中儲存一條使用者記錄,也完全不必發出網路請求。這種演算法稱為 排序合併連線(sort-merge join),因為 mapper 的輸出按鍵排序,而 reducer 隨後會合並連線兩側的有序記錄列表。
工作流中的下一項 MapReduce 作業可以繼續計算每個 URL 的瀏覽者年齡分佈。它首先用 URL 作為鍵混洗資料;排序完成後,reducer 會遍歷同一個 URL 的所有頁面瀏覽記錄(其中包含瀏覽者的出生日期),為各個年齡段維護瀏覽次數計數器,併為每條頁面瀏覽記錄遞增相應的計數器。這樣就實現了 分組(group by)操作和聚合。
查詢語言
多年來,分散式批處理的執行引擎日趨成熟。如今,基礎設施已經足夠穩健,可以在超過 10,000 臺機器的叢集上儲存和處理數 PB 資料。既然如此規模的批處理系統如何實際執行已經大體得到解決,人們就把注意力轉向了程式設計模型的改進。
MapReduce、資料流引擎和雲資料倉儲都採用 SQL 作為批處理的通用語言。這是很自然的選擇:傳統資料倉儲本來就使用 SQL,資料分析和 ETL 工具也已經支援 SQL,而且開發者和分析師無不熟悉 SQL。
與手寫 MapReduce 作業相比,查詢語言介面除了所需程式碼更少,還有一個明顯的優點:它們可以互動使用。你可以編寫分析查詢,再從終端或圖形介面執行。這種互動查詢方式很高效也很自然,業務分析師、產品經理、銷售和財務團隊等人員都可以藉此在批處理環境中探索資料。儘管它不是經典形式的批處理,SQL 支援仍然使探索性查詢成為分散式批處理系統的一項適用場景。
高階查詢語言不僅提高了使用系統的人所能達到的生產率,也能從機器層面提高作業執行效率。正如“雲資料倉儲”所述,查詢引擎負責把 SQL 查詢轉換成在叢集中執行的批處理作業。從查詢轉換到語法樹、再轉換成物理運算元的過程,讓引擎有機會最佳化查詢。Hive、Trino、Spark 和 Flink 等查詢引擎都提供基於代價的查詢最佳化器,可以分析連線輸入的特徵,自動決定哪一種演算法最適合眼前的任務。最佳化器甚至可以改變連線的順序,以儘量減少中間狀態 19 26 27 28。
SQL 是最流行的通用批處理查詢語言,不過其他語言仍用於一些專門場景。Apache Pig 是一種以關係運算元為基礎的語言,允許使用者逐步描述資料管道,而不是把所有邏輯寫成一個龐大的 SQL 查詢。資料框(見下一節)具有類似特徵,Morel 則是受 Pig 影響的一種較新語言。還有一些使用者採用 jq、JMESPath 或 JsonPath 等 JSON 查詢語言。
在“圖資料模型”中,我們討論了如何用圖來建模資料,以及如何用圖查詢語言遍歷圖中的邊和頂點。許多圖處理框架也支援透過查詢語言進行批計算,例如 Apache TinkerPop 的 Gremlin。我們將在“批處理用例”中進一步討論圖處理的用例。
過去,資料倉儲執行在專用硬體裝置上,為關係資料提供 SQL 分析查詢。相比之下,MapReduce 等批處理框架的目標是提供更強的可伸縮性與靈活性:它們支援使用通用程式語言編寫處理邏輯,並允許讀寫任意資料格式。
隨著時間推移,兩者變得越來越相似。現代批處理框架如今支援用 SQL 編寫批處理作業,並透過 Parquet 等列式儲存格式和經過最佳化的查詢執行引擎,在關係查詢上取得了良好效能(參見“查詢執行:編譯與向量化”)。與此同時,資料倉儲遷移到雲端後,變得更加可伸縮(參見“雲資料倉儲”),並實現了許多與分散式批處理框架相同的排程、容錯和混洗技術。其中許多系統也使用分散式檔案系統。
正如批處理系統採用 SQL 作為處理模型一樣,雲資料倉儲也採用了資料框等其他處理模型(見下一節)。例如,Google Cloud BigQuery 提供 BigQuery DataFrames 庫,Snowflake 的 Snowpark 則與 pandas 整合。Airflow、Prefect 和 Dagster 等批處理工作流編排器同樣能夠整合雲資料倉儲。
當然,並非所有批處理作業都容易用 SQL 表達。PageRank 等迭代圖演算法、複雜的機器學習以及許多其他任務,都很難用 SQL 編寫。AI 資料處理包含影象、影片和音訊等非關係型多模態資料,同樣很難用 SQL 完成。
此外,雲資料倉儲不擅長某些工作負載。使用列式儲存格式時,逐行計算的效率較低;在這種情況下,最好使用資料倉儲的其他 API,或者改用批處理系統。雲資料倉儲也往往比其他批處理系統昂貴。大型作業改在 Spark 或 Flink 等批處理系統上執行,可能更加經濟。
歸根結底,應該用批處理系統還是資料倉儲來處理資料,取決於成本、便利性、實現難度、可用性等因素。大多數大型企業都擁有多套資料處理系統,因而可以靈活作出選擇;小型公司則往往只靠一套系統就已足夠。
資料框
隨著資料科學家和統計學家開始使用分散式批處理框架進行機器學習,他們發現現有的處理模型用起來很麻煩,因為他們習慣的是 R 和 pandas 中的資料框模型(參見“資料框、矩陣與陣列”)。資料框與關聯式資料庫中的表相似:它由許多行組成,同一列中的所有值都具有相同型別。使用者不必編寫一條龐大的 SQL 查詢,而是呼叫與關係運算元對應的函式,執行過濾、連線、排序、分組等操作。
最初,資料框操作一般在本機記憶體中執行,因而只能處理單臺機器能夠容納的資料集。資料科學家希望繼續使用熟悉的資料框 API,與批處理環境中的大型資料集互動。Spark、Flink 和 Daft 等分散式資料處理框架為滿足這種需求,也採用了資料框 API。不過,本地資料框通常帶有索引並且有序,分散式資料框一般卻不具備這些性質 29。因此,把程式遷移到批處理框架後,其效能可能出人意料。
資料框 API 看起來與資料流 API 相似,但具體實現各不相同。pandas 會在資料框方法被呼叫時立即執行操作;Apache Spark 則會先把所有資料框 API 呼叫轉換成查詢計劃,經過查詢最佳化,再在分散式資料流引擎上執行工作流。這樣便能改善效能。
Daft 等框架甚至同時支援客戶端與服務端計算:規模較小的記憶體操作在客戶端執行,規模較大的資料集和計算則在服務端執行。Apache Arrow 等列式儲存格式提供了統一的資料模型,可由客戶端與服務端的執行引擎共享。
批處理用例
瞭解批處理如何工作之後,我們來看看它在各種應用中怎樣發揮作用。批處理作業非常適合成批處理大型資料集,卻不適合低延遲場景。因此,只要資料量很大、資料新鮮度又不重要,通常就能看到批處理作業。聽起來這似乎是一個很大的侷限,但事實證明,相當多的資料處理都符合這一模型:
會計與庫存核對通常成批進行,企業藉此檢查交易是否與銀行賬戶和庫存相符 30。
製造業的需求預測由週期執行的批處理作業計算 31。
許多金融系統同樣以批處理為基礎。例如,美國的銀行網路幾乎完全依靠批處理作業執行 34。
下面幾節將討論幾乎每個行業都能見到的一些批處理用例。
提取—轉換—載入(ETL)
“資料倉儲”介紹了 ETL 和 ELT:資料處理管道從生產資料庫抽取資料,對其進行轉換,再把結果載入到下游系統。本節用“ETL”同時指代 ETL 與 ELT 工作負載。這類工作負載經常由批處理作業完成,尤其是在下游系統為資料倉儲時。
批處理作業天然具有並行性,因而非常適合資料轉換。許多資料轉換工作負載都易於並行:過濾資料、投影欄位,以及其他許多常見的資料倉儲轉換,都可以並行完成。
批處理環境還配有穩健的工作流排程器,能夠輕鬆安排、編排和除錯 ETL 資料管道作業。發生故障時,排程器通常會重試作業,以消除可能出現的暫時性問題。作業若反覆失敗,就會被標記為失敗,開發者可以很容易地看到資料管道中的哪項作業停止了工作。Airflow 等排程器甚至內建了 MySQL、PostgreSQL、Snowflake、Spark 和 Flink 等數十種流行系統的資料來源、資料接收端及查詢運算元。排程器與資料處理系統的緊密整合簡化了資料整合。
我們還看到,批處理作業出了問題之後很容易排查和修復;這一點在除錯資料管道時尤其有用。可以直接檢查有問題的檔案,找出錯誤所在;修復 ETL 批處理作業後,再重新執行即可。例如,輸入檔案可能不再包含轉換作業所要使用的某個欄位。資料工程師發現欄位缺失後,可以更新轉換邏輯,或者修改產生該輸入的作業。
過去,資料管道通常由一支資料工程團隊統一管理,因為要求開發產品功能的其他團隊編寫和管理複雜的批處理資料管道並不公平。近來,批處理模型和後設資料管理的改進,使組織中的工程師更容易參與和管理自己的資料管道。資料網格(data mesh)35 36、資料契約(data contract)37 和 資料編織(data fabric)38 等實踐提供了標準與工具,幫助團隊安全地釋出資料,供組織中的任何人使用。
如今,資料管道與分析查詢不僅開始共享處理模型,也開始共享執行引擎。許多批處理 ETL 作業與讀取其輸出的分析查詢,會執行在同一套系統上。資料管道轉換和分析查詢都以 SparkSQL、Trino 或 DuckDB 查詢來執行,已經十分常見。這樣的架構進一步模糊了應用工程、資料工程、分析工程和業務分析之間的界限。
分析
在“分析型與事務型系統”中,我們看到分析查詢(OLAP)經常掃描大量記錄,並執行分組與聚合。這樣的工作負載可以與其他批處理工作負載一起,在批處理系統中執行。分析師編寫 SQL 查詢,由查詢引擎執行,並讀寫分散式檔案系統或物件儲存。表與檔案之間的對映、名稱和型別等表後設資料,則透過 Apache Iceberg 等表格式和 Unity 等目錄服務管理(參見“雲資料倉儲”)。這種架構稱為 資料湖倉(data lakehouse)39。
與 ETL 一樣,SQL 查詢介面的改進使許多組織如今也使用 Spark 等批處理框架進行分析。這類查詢模式分為兩種:
預聚合查詢(pre-aggregated query):把資料彙總成 OLAP 多維資料集或資料集市,以加快查詢(參見“物化檢視與多維資料集”)。預聚合資料可以在資料倉儲中查詢,也可以推送到 Apache Druid 或 Apache Pinot 等專用的實時 OLAP 系統。預聚合通常按固定週期進行,這類工作負載由“工作流排程”中討論的工作流排程器管理。
即席查詢(ad hoc query):使用者執行查詢來回答特定業務問題、調查使用者行為、除錯執行故障,以及完成其他許多工作。在這種場景中,響應時間很重要。分析師會反覆執行查詢,在得到響應、進一步瞭解正在研究的資料後,繼續調整查詢。能夠快速執行查詢的批處理框架,可以減少分析師的等待時間。
SQL 支援還讓批處理框架能夠與電子表格和 Tableau、Power BI、Looker、Apache Superset 等資料視覺化工具整合。例如,Tableau 提供 SparkSQL 和 Presto 聯結器;Apache Superset 則支援 Trino、Hive、Spark SQL、Presto 等許多最終會執行批處理作業來查詢資料的系統。
機器學習
機器學習(ML)經常用到批處理。資料科學家、機器學習工程師和 AI 工程師使用批處理框架來探索資料規律、轉換資料並訓練機器學習模型。常見用途包括:
- 特徵工程:對原始資料進行過濾和轉換,使其能夠用於訓練模型。預測模型通常要求輸入數值資料,因此工程師必須把文字或離散值等其他形式的資料轉換成所需格式。
- 模型訓練:訓練資料是批處理過程的輸入,訓練所得的模型權重則是輸出。
- 批次推理:訓練好的模型可以對大量資料成批進行預測,適用於資料集很大而又不要求實時返回結果的情況,其中也包括在測試資料集上評估模型的預測效果。
批處理框架為這些場景提供了專門的工具。例如,Apache Spark 的 MLlib 和 Apache Flink 的 FlinkML 都內建了豐富的特徵工程工具、統計函式和分類器。
推薦引擎、排序系統等機器學習應用也大量使用圖處理(參見“圖資料模型”)。許多圖演算法可以表述為:每次沿一條邊遍歷,將一個頂點與相鄰頂點連線起來以傳播某些資訊,並不斷重複,直到滿足某項條件——例如沒有更多的邊可以繼續遍歷,或者某項指標已經收斂。
批次同步並行(bulk synchronous parallel,BSP)計算模型 40 已經成為批次處理圖資料時的常用模型。Apache Giraph 20、Spark 的 GraphX API 和 Flink 的 Gelly API 41 等系統都實現了這一模型。它也稱為 Pregel 模型,因為 Google 的 Pregel 論文推廣了這種圖處理方法 42。
批處理也是 大語言模型(LLM)資料準備與訓練的重要組成部分。網站等原始文字輸入通常存放在分散式檔案系統或物件儲存中,必須先經過預處理才能用於訓練。適合交給批處理框架完成的預處理步驟包括:
- 從 HTML 中提取純文字,並修復格式損壞的文字;
- 檢測並刪除質量低劣、內容無關或重複的文件;
- 對文字進行分詞(拆分成單詞),再將其轉換成嵌入向量,也就是每個單詞的數值表示。
Kubeflow、Flyte 和 Ray 等批處理框架就是為這類工作負載設計的。例如,OpenAI 在 ChatGPT 的訓練過程中就使用了 Ray 43。這些框架內建了與 PyTorch、TensorFlow、XGBoost 等大語言模型和 AI 庫的整合,並直接支援特徵工程、模型訓練、批次推理和微調(針對特定用例調整基礎模型)等操作。
最後,資料科學家還經常在 Jupyter 或 Hex 等互動式筆記本中對資料進行實驗。筆記本由若干 單元格(cell)組成,每個單元格都是一小段 Markdown、Python 或 SQL。按順序執行這些單元格,可以生成電子表格、圖表或資料。許多筆記本透過資料框 API 使用批處理系統,或者用 SQL 查詢這類系統。
對外提供衍生資料
批處理作業經常用於構建預計算或衍生資料集,例如商品推薦、面向使用者的報表,以及機器學習模型所需的特徵。這些資料集通常由生產資料庫、鍵值儲存或搜尋引擎對外提供。不論使用哪種系統,預計算資料最終都必須從批處理系統的分散式檔案系統或物件儲存,回到承載線上流量的資料庫中。
最直觀的選擇或許是直接在批處理作業中呼叫資料庫客戶端庫,逐條寫入資料庫伺服器。這種方法確實能用——前提是防火牆規則允許批處理環境直接訪問生產資料庫——但基於以下幾個原因,它並不是個好主意:
- 每條記錄都發起一次網路請求,要比批處理任務的正常吞吐量慢幾個數量級。即使客戶端庫支援批次寫入,效能也很可能不理想。
- 批處理框架通常會並行執行許多工。如果所有任務都以批處理應有的速率同時寫入同一個輸出資料庫,資料庫很容易不堪重負,查詢效能也可能隨之下降,繼而給系統其他部分帶來執行故障 44。
- 批處理作業通常會對作業輸出提供乾淨利落的“全有或全無”保證:如果作業成功,那麼即使途中有些任務失敗並經過重試,結果也等同於每個任務都恰好一次執行所產生的輸出;如果整個作業失敗,則不會產生任何輸出。然而,從作業內部寫入外部系統會產生無法用這種方式隱藏的外部可見副作用。這樣一來,就不得不考慮尚未完整的作業結果被其他系統看到的問題;任務失敗並重新啟動時,也可能重複寫入失敗執行已經產生的輸出。
更好的方案是讓批處理作業把預計算資料集推送到 Kafka 主題之類的流中,我們將在第 12 章中進一步討論這種做法。Elasticsearch 等搜尋引擎、Apache Pinot 和 Apache Druid 等實時 OLAP 系統、Venice 等衍生資料儲存 45,以及 ClickHouse 等雲資料倉儲,都內建了從 Kafka 攝取資料的能力。讓資料經過流系統,可以緩解上述問題中的一部分:
- 流系統針對順序寫入進行了最佳化,因此更適合批處理作業的大批次寫入負載;
- 流系統還可以在批處理作業與生產資料庫之間充當緩衝區。下游系統可以限制自己的讀取速率,確保仍有足夠能力承載線上流量;
- 同一個批處理作業的輸出可以由多個下游系統消費;
- 流系統可以充當批處理環境與生產網路之間的安全邊界:它可以部署在所謂的 DMZ(隔離區)網路中,位於批處理網路與生產網路之間。
不過,讓資料經過流並不會自動解決前面提到的“全有或全無”保證問題。為此,批處理作業必須在完成時通知下游系統:作業已經完成,資料現在可以對外提供。流消費者則必須能夠在收到完成通知之前,讓接收到的資料對查詢保持不可見,就像採用 讀已提交(read committed)隔離級別時未提交的事務一樣(參見“讀已提交”)。
另一種模式在資料庫初始化時更為常見:直接在批處理作業 內部 構建一個全新的資料庫,再把分散式檔案系統、物件儲存或本地檔案系統中的檔案批次載入到該資料庫中。許多資料系統都提供了相應的批次匯入工具,例如 TiDB 的 Lightning 工具,以及 Apache Pinot 和 Apache Druid 的 Hadoop 匯入作業。RocksDB 也提供了從批處理作業批次匯入 SST 的 API。
透過批處理構建資料庫並批次匯入資料,速度非常快,也更便於系統在不同資料集版本之間進行原子切換。另一方面,由批處理作業構建全新資料庫時,要增量更新資料集會比較困難。如果同時需要初始化和增量載入,通常會採用混合方案。例如,Venice 支援混合儲存,既可以進行基於行的批次更新,也可以切換整個資料集。
本章小結
本章探討了批處理系統的設計與實現。我們先從經典的 Unix 工具鏈(awk、sort、uniq 等)入手,以此說明排序、計數等基本的批處理原語。
接著,我們把規模擴大到分散式批處理系統。我們看到,批處理式 I/O 以不可變、有界的輸入資料集為處理物件並生成輸出資料,因而可以在不產生副作用的情況下重跑和除錯。為了處理檔案,批處理框架主要由三部分組成:決定作業在何時何地執行的編排層,持久化資料的儲存層,以及實際處理資料的計算層。
我們瞭解了分散式檔案系統與物件儲存如何透過按塊複製、快取和後設資料服務來管理大檔案,以及現代批處理框架如何透過可插拔 API 與這些系統互動。我們還討論了編排器如何在大型叢集中排程任務、分配資源和處理故障,並比較了排程單個作業的作業編排器,與管理一組依賴圖作業整個生命週期的工作流編排器。
我們考察了幾種批處理模型,首先是 MapReduce 及其經典的 map 和 reduce 函式,隨後轉向 Spark、Flink 等資料流引擎;它們的資料流 API 更易使用,效能也更好。為了理解批處理作業如何擴充套件,我們還介紹了混洗(shuffle)演算法——這項基礎操作使分組、連線和聚合成為可能。
隨著批處理系統逐漸成熟,關注點也轉向了易用性。SQL 等高階查詢語言和資料框 API 降低了批處理作業的使用門檻,也讓它們更容易最佳化。查詢最佳化器會把宣告式查詢轉換成高效的執行計劃。
最後我們回顧了批處理常見用例:
- ETL 資料管道:透過定時工作流在不同系統之間提取、轉換和載入資料;
- 分析:批處理作業既支援預聚合的儀表板,也支援即席查詢;
- 機器學習:批處理作業負責準備和處理大規模訓練資料集;
- 用批處理輸出填充面向生產流量的系統:通常經由流或批次載入工具,把衍生資料提供給使用者。
下一章我們將轉向流處理,其中的輸入是 無界的(unbounded):作業依然存在,但它的輸入是永無止境的資料流。由於任何時刻都可能有更多工作到來,作業永遠不會完成。我們會看到,流處理與批處理在某些方面相似,但“流是無界的”這一假設也會在很大程度上改變系統的構建方式。
腳註
參考文獻
Nathan Marz. How to Beat the CAP Theorem. nathanmarz.com, October 2011. Archived at perma.cc/4BS9-R9A4 ↩︎
Molly Bartlett Dishman and Martin Fowler. Agile Architecture. At O’Reilly Software Architecture Conference, March 2015. ↩︎
Jeffrey Dean and Sanjay Ghemawat. MapReduce: Simplified Data Processing on Large Clusters. At 6th USENIX Symposium on Operating System Design and Implementation (OSDI), December 2004. ↩︎ ↩︎
Shivnath Babu and Herodotos Herodotou. Massively Parallel Databases and MapReduce Systems. Foundations and Trends in Databases, volume 5, issue 1, pages 1–104, November 2013. doi:10.1561/1900000036 ↩︎
David J. DeWitt and Michael Stonebraker. MapReduce: A Major Step Backwards. Originally published at databasecolumn.vertica.com, January 2008. Archived at perma.cc/U8PA-K48V ↩︎
Henry Robinson. The Elephant Was a Trojan Horse: On the Death of Map-Reduce at Google. the-paper-trail.org, June 2014. Archived at perma.cc/9FEM-X787 ↩︎
Urs Hölzle. R.I.P. MapReduce. After having served us well since 2003, today we removed the remaining internal codebase for good. twitter.com, September 2019. Archived at perma.cc/B34T-LLY7 ↩︎
Adam Drake. Command-Line Tools Can Be 235x Faster than Your Hadoop Cluster. aadrake.com, January 2014. Archived at perma.cc/87SP-ZMCY ↩︎
sort: Sort text files. GNU Coreutils 9.7 Documentation, Free Software Foundation, Inc., 2025. ↩︎Michael Ovsiannikov, Silvius Rus, Damian Reeves, Paul Sutter, Sriram Rao, and Jim Kelly. The Quantcast File System. Proceedings of the VLDB Endowment, volume 6, issue 11, pages 1092–1101, August 2013. doi:10.14778/2536222.2536234 ↩︎
Andrew Wang, Zhe Zhang, Kai Zheng, Uma Maheswara G., and Vinayakumar B. Introduction to HDFS Erasure Coding in Apache Hadoop. blog.cloudera.com, September 2015. Archived at archive.org ↩︎
Andy Warfield. Building and operating a pretty big storage system called S3. allthingsdistributed.com, July 2023. Archived at perma.cc/7LPK-TP7V ↩︎
Vinod Kumar Vavilapalli, Arun C. Murthy, Chris Douglas, Sharad Agarwal, Mahadev Konar, Robert Evans, Thomas Graves, Jason Lowe, Hitesh Shah, Siddharth Seth, Bikas Saha, Carlo Curino, Owen O’Malley, Sanjay Radia, Benjamin Reed, and Eric Baldeschwieler. Apache Hadoop YARN: Yet Another Resource Negotiator. At 4th Annual Symposium on Cloud Computing (SoCC), October 2013. doi:10.1145/2523616.2523633 ↩︎
Richard M. Karp. Reducibility Among Combinatorial Problems. Complexity of Computer Computations. The IBM Research Symposia Series. Springer, 1972. doi:10.1007/978-1-4684-2001-2_9 ↩︎
J. D. Ullman. NP-Complete Scheduling Problems. Journal of Computer and System Sciences, volume 10, issue 3, June 1975. doi:10.1016/S0022-0000(75)80008-0 ↩︎
Gilad David Maayan. The complete guide to spot instances on AWS, Azure and GCP. datacenterdynamics.com, March 2021. Archived at archive.org ↩︎
Abhishek Verma, Luis Pedrosa, Madhukar Korupolu, David Oppenheimer, Eric Tune, and John Wilkes. Large-Scale Cluster Management at Google with Borg. At 10th European Conference on Computer Systems (EuroSys), April 2015. doi:10.1145/2741948.2741964 ↩︎
Matei Zaharia, Mosharaf Chowdhury, Tathagata Das, Ankur Dave, Justin Ma, Murphy McCauley, Michael J. Franklin, Scott Shenker, and Ion Stoica. Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing. At 9th USENIX Symposium on Networked Systems Design and Implementation (NSDI), April 2012. ↩︎ ↩︎
Paris Carbone, Stephan Ewen, Seif Haridi, Asterios Katsifodimos, Volker Markl, and Kostas Tzoumas. Apache Flink™: Stream and Batch Processing in a Single Engine. Bulletin of the IEEE Computer Society Technical Committee on Data Engineering, volume 38, issue 4, December 2015. Archived at perma.cc/G3N3-BKX5 ↩︎ ↩︎ ↩︎
Mark Grover, Ted Malaska, Jonathan Seidman, and Gwen Shapira. Hadoop Application Architectures. O’Reilly Media, 2015. ISBN: 978-1-491-90004-8 ↩︎ ↩︎
Jules S. Damji, Brooke Wenig, Tathagata Das, and Denny Lee. Learning Spark, 2nd Edition. O’Reilly Media, 2020. ISBN: 978-1492050049 ↩︎
Michael Isard, Mihai Budiu, Yuan Yu, Andrew Birrell, and Dennis Fetterly. Dryad: Distributed Data-Parallel Programs from Sequential Building Blocks. At 2nd European Conference on Computer Systems (EuroSys), March 2007. doi:10.1145/1272996.1273005 ↩︎
Daniel Warneke and Odej Kao. Nephele: Efficient Parallel Data Processing in the Cloud. At 2nd Workshop on Many-Task Computing on Grids and Supercomputers (MTAGS), November 2009. doi:10.1145/1646468.1646476 ↩︎
Hossein Ahmadi. In-memory query execution in Google BigQuery. cloud.google.com, August 2016. Archived at perma.cc/DGG2-FL9W ↩︎ ↩︎
Tom White. Hadoop: The Definitive Guide, 4th edition. O’Reilly Media, 2015. ISBN: 978-1-491-90163-2 ↩︎ ↩︎
Fabian Hüske. Peeking into Apache Flink’s Engine Room. flink.apache.org, March 2015. Archived at perma.cc/44BW-ALJX ↩︎
Mostafa Mokhtar. Hive 0.14 Cost Based Optimizer (CBO) Technical Overview. hortonworks.com, March 2015. Archived on archive.org ↩︎
Michael Armbrust, Reynold S. Xin, Cheng Lian, Yin Huai, Davies Liu, Joseph K. Bradley, Xiangrui Meng, Tomer Kaftan, Michael J. Franklin, Ali Ghodsi, and Matei Zaharia. Spark SQL: Relational Data Processing in Spark. At ACM International Conference on Management of Data (SIGMOD), June 2015. doi:10.1145/2723372.2742797 ↩︎
Kaya Kupferschmidt. Spark vs Pandas, part 2 – Spark. towardsdatascience.com, October 2020. Archived at perma.cc/5BRK-G4N5 ↩︎
Ammar Chalifah. Tracking payments at scale. bolt.eu.com, June 2025. Archived at perma.cc/Q4KX-8K3J ↩︎
Nafi Ahmet Turgut, Hamza Akyıldız, Hasan Burak Yel, Mehmet İkbal Özmen, Mutlu Polatcan, Pinar Baki, and Esra Kayabali. Demand forecasting at Getir built with Amazon Forecast. aws.amazon.com.com, May 2023. Archived at perma.cc/H3H6-GNL7 ↩︎
Jason (Siyu) Zhu. Enhancing homepage feed relevance by harnessing the power of large corpus sparse ID embeddings. linkedin.com, August 2023. Archived at archive.org ↩︎
Avery Ching, Sital Kedia, and Shuojie Wang. Apache Spark @Scale: A 60 TB+ production use case. engineering.fb.com, August 2016. Archived at perma.cc/F7R5-YFAV ↩︎
Edward Kim. How ACH works: A developer perspective — Part 1. engineering.gusto.com, April 2014. Archived at perma.cc/F67P-VBLK ↩︎
Zhamak Dehghani. How to Move Beyond a Monolithic Data Lake to a Distributed Data Mesh. martinfowler.com, May 2019. Archived at perma.cc/LN2L-L4VC ↩︎
Chris Riccomini. What the Heck is a Data Mesh?! cnr.sh, June 2021. Archived at perma.cc/NEJ2-BAX3 ↩︎
Chad Sanderson, Mark Freeman, B. E. Schmidt. Data Contracts. O’Reilly Media, 2025. ISBN: 9781098157623 ↩︎
Daniel Abadi. Data Fabric vs. Data Mesh: What’s the Difference? starburst.io, November 2021. Archived at perma.cc/RSK3-HXDK ↩︎
Michael Armbrust, Ali Ghodsi, Reynold Xin, and Matei Zaharia. Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics. At 11th Annual Conference on Innovative Data Systems Research (CIDR), January 2021. ↩︎
Leslie G. Valiant. A Bridging Model for Parallel Computation. Communications of the ACM, volume 33, issue 8, pages 103–111, August 1990. doi:10.1145/79173.79181 ↩︎
Stephan Ewen, Kostas Tzoumas, Moritz Kaufmann, and Volker Markl. Spinning Fast Iterative Data Flows. Proceedings of the VLDB Endowment, volume 5, issue 11, pages 1268-1279, July 2012. doi:10.14778/2350229.2350245 ↩︎
Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski. Pregel: A System for Large-Scale Graph Processing. At ACM International Conference on Management of Data (SIGMOD), June 2010. doi:10.1145/1807167.1807184 ↩︎
Richard MacManus. OpenAI Chats about Scaling LLMs at Anyscale’s Ray Summit. thenewstack.io, September 2023. Archived at perma.cc/YJD6-KUXU ↩︎
Jay Kreps. Why Local State is a Fundamental Primitive in Stream Processing. oreilly.com, July 2014. Archived at perma.cc/P8HU-R5LA ↩︎
Félix GV. Open Sourcing Venice – LinkedIn’s Derived Data Platform. linkedin.com, September 2022. Archived at archive.org ↩︎
12 流處理

有效的複雜系統總是從簡單的系統演化而來。反之亦然:從零設計的複雜系統沒一個能有效工作的。
—— 約翰・加爾,Systemantics(1975)
在 第 11 章 中,我們討論了批處理技術:它以一組檔案作為輸入,並生成一組新的輸出檔案。輸出是 衍生資料(derived data) 的一種形式;也就是說,如有必要,可以再次執行批處理來重新建立這份資料集。我們看到,這個簡單而強大的思想可以用來構建搜尋索引、推薦系統和分析系統,等等。
然而,第 11 章 始終建立在一個重要假設之上:輸入是 有界的(bounded),也就是大小已知且有限,因此批處理知道何時已經讀完輸入。例如,作為 MapReduce 核心環節的排序操作必須先讀完全部輸入,才能開始生成輸出。因為最後一條輸入記錄可能恰好擁有最小的鍵,因而需要成為第一條輸出記錄,所以不能提前開始輸出。
實際上,許多資料都是 無界的(unbounded),因為它們會隨時間推移陸續到達:使用者昨天和今天產生了資料,明天還會繼續產生更多資料。只要你的企業還在經營,這個過程就不會結束,因此從任何有意義的角度來看,資料集都永遠不會“完整”1。所以,批處理程式不得不人為地按固定時長切分資料,例如每天結束時處理當天的資料,或每小時結束時處理這一小時的資料。
每日批處理的問題在於,輸入中的變化要到一天後才會反映到輸出中,對許多沒有耐心的使用者來說實在太慢。為了縮短延遲,可以更頻繁地執行處理——比如每秒結束時處理這一秒的資料——也可以完全拋開固定的時間切片,改為連續處理,每個事件一發生就立即處理。這就是 流處理(stream processing) 的基本思想。
一般來說,“流”是指隨時間推移逐漸可用的資料。這個概念出現在許多地方:Unix 的 stdin 和 stdout、程式語言中的惰性列表2、檔案系統 API(例如 Java 的 FileInputStream)、TCP 連線、透過網際網路傳輸的音訊和影片,等等。
本章將把 事件流(event stream) 作為一種資料管理機制來考察:它是上一章批次資料的無界、增量處理版本。我們首先討論如何表示、儲存流,以及如何透過網路傳輸流;接著在“資料庫與流”中研究流與資料庫的關係;最後在“流處理”中探討持續處理這些流的方法和工具,以及如何用它們構建應用。
傳遞事件流
在批處理領域,作業的輸入和輸出都是檔案(可能位於分散式檔案系統上)。那麼,流處理領域中的對應物是什麼?
當輸入是檔案(即位元組序列)時,第一個處理步驟通常是把它解析成一系列記錄。在流處理的語境中,記錄更常被稱為 事件(event),但兩者本質上是同一種東西:一個小巧、自包含、不可變的物件,記錄某個時刻發生的某件事。事件通常帶有時間戳,表示按照日曆時鐘來看它發生在何時(參見“單調時鐘與日曆時鐘”)。
例如,事件可能來自使用者的某個操作,如瀏覽頁面或完成購買;也可能來自機器,如溫度感測器的定期測量值或 CPU 利用率指標。在“使用 Unix 工具的批處理”示例中,Web 伺服器日誌的每一行就是一個事件。
事件可以編碼成文字字串、JSON,或 第 5 章 討論的某種二進位制格式。編碼後,你就可以儲存事件,例如把它追加到檔案、插入關係表,或寫入文件資料庫;也可以透過網路把事件傳送到另一個節點進行處理。
在批處理中,檔案寫入一次,之後可能由多個作業讀取。與之類似,在流處理術語中,事件由 生產者(producer)(也稱為 釋出者(publisher) 或 傳送者(sender))生成一次,之後可能由多個 消費者(consumer)(也稱為 訂閱者(subscriber) 或 接收者(recipient))處理3。在檔案系統中,檔名標識一組相關記錄;在流式系統中,相關事件通常被歸入同一個 主題(topic) 或 流(stream)。
原則上,檔案或資料庫足以把生產者和消費者連線起來:生產者將生成的每個事件寫入資料儲存,而各個消費者定期輪詢資料儲存,檢查自上次執行以來出現了哪些新事件。這實質上就是每日結束時處理當天資料的批處理程式所做的事情。
然而,當我們轉向低延遲的持續處理時,如果資料儲存並非為這種用法而設計,輪詢的代價就會很高。輪詢越頻繁,返回新事件的請求比例越低,額外開銷也就越大。更好的做法是在出現新事件時通知消費者。
傳統資料庫對這種通知機制的支援並不好。關聯式資料庫通常提供 觸發器(trigger),可以對變更作出響應(例如向表中插入一行),但觸發器能做的事情非常有限,在資料庫設計中多少有些事後補充的意味4。因此,人們開發了專門用於傳遞事件通知的工具。
訊息傳遞系統
向消費者通知新事件的一種常見方法是使用 訊息傳遞系統(messaging system):生產者傳送包含事件的訊息,再由系統將訊息推送給消費者。我們之前在“事件驅動的架構”中提到過這類系統,現在來進一步瞭解其細節。
在生產者與消費者之間建立 Unix 管道或 TCP 連線等直接通訊通道,是實現訊息傳遞系統的一種簡單方法。不過,大多數訊息傳遞系統都擴充套件了這個基本模型。Unix 管道和 TCP 恰好連線一個傳送者與一個接收者,而訊息傳遞系統允許多個生產者節點向同一主題傳送訊息,也允許多個消費者節點接收一個主題中的訊息。
在這種 釋出/訂閱 模型中,不同系統採取了五花八門的方法,沒有一種答案適合所有用途。要區分這些系統,下面兩個問題尤其有用:
如果生產者傳送訊息的速度超過消費者的處理速度,會發生什麼? 大體上有三種選擇:系統可以丟棄訊息、把訊息快取在佇列中,或施加 背壓(backpressure)(也稱為 流量控制(flow control),即阻塞生產者,令其暫停傳送更多訊息)。例如,Unix 管道和 TCP 都採用背壓:它們有一個固定大小的小緩衝區;如果緩衝區已滿,傳送者就會被阻塞,直到接收者從中取走資料(參見“網路擁塞與排隊”)。
如果訊息被快取在佇列中,就必須弄清楚佇列不斷增長會有什麼後果:佇列大到記憶體容納不下時,系統會崩潰,還是會把訊息寫入磁碟?如果寫入磁碟,磁碟訪問會怎樣影響訊息傳遞系統的效能5?磁碟寫滿時又會發生什麼6?
如果節點崩潰或暫時離線,會發生什麼——是否會丟失訊息? 和資料庫一樣,要實現永續性,可能需要以某種方式組合使用磁碟寫入與複製(參見側欄“複製與永續性”),而這會付出代價。如果能夠容忍偶爾丟失訊息,那麼在同樣的硬體上,通常可以獲得更高的吞吐量和更低的延遲。
能否容忍訊息丟失,很大程度上取決於應用。例如,對於定期傳送的感測器讀數和指標,偶爾缺失一個資料點或許並不重要,因為不久後還會發來更新值。不過要小心:如果大量訊息被丟棄,你可能無法立即察覺指標已經失真7。如果你要對事件計數,可靠傳遞就重要得多,因為每丟失一條訊息,計數器就會出現一份誤差。
我們在 第 11 章 探討的批處理系統有一個很好的特性:它們提供了很強的可靠性保證。失敗的任務會自動重試,失敗任務產生的部分輸出會自動丟棄。因此,最終輸出與從未發生故障時相同,這簡化了程式設計模型。本章後面將考察如何在流處理環境中提供類似的保證。
直接從生產者傳遞給消費者
許多訊息傳遞系統讓生產者與消費者直接透過網路通訊,不經過任何中間節點:
UDP 組播廣泛用於金融行業中的股票行情等資料流,因為這些場景十分看重低延遲8。儘管 UDP 本身並不可靠,但應用層協議可以恢復丟失的資料包(生產者必須記住已傳送的資料包,以便按需重傳)。
ZeroMQ 和 nanomsg 等無代理訊息庫採用類似的方法,透過 TCP 或 IP 組播實現釋出/訂閱訊息傳遞。
StatsD 等指標收集代理9使用不可靠的 UDP 訊息,從網路中的所有機器收集指標並進行監控。(在 StatsD 協議中,只有收到全部訊息,計數器指標才是準確的;使用 UDP 意味著這些指標至多隻能是近似值10。另見“TCP 與 UDP”。)
如果消費者在網路上公開了一項服務,生產者可以直接發起 HTTP 或 RPC 請求(參見“流經服務的資料流:REST 與 RPC”),把訊息推送給消費者。Webhook11就建立在這一思想之上:把一個服務的回撥 URL 註冊到另一個服務中,後者每逢事件發生便向該 URL 發出請求。
儘管這些直接訊息傳遞系統在各自的目標場景中表現良好,但應用程式碼通常必須自行考慮訊息丟失的可能性。它們能容忍的故障十分有限:即使協議可以檢測並重傳網路中丟失的資料包,通常仍會假設生產者和消費者始終線上。
消費者離線時,可能會錯過其不可達期間傳送的訊息。有些協議允許生產者重試失敗的訊息傳遞,但如果生產者崩潰,丟失了本應重試的訊息緩衝區,這種辦法就可能失效。
訊息代理
一種廣泛使用的替代方案是透過 訊息代理(message broker)(也稱為 訊息佇列(message queue))傳送訊息。訊息代理實質上是一類針對訊息流最佳化的資料庫12。它作為伺服器執行,生產者和消費者則作為客戶端連線到它。生產者把訊息寫入代理,消費者透過讀取代理來接收訊息。
資料集中到代理之後,系統更容易容忍客戶端時而連線、時而斷開乃至崩潰,永續性問題也轉移給了代理。有些訊息代理只把訊息儲存在記憶體中,另一些則會根據配置把訊息寫入磁碟,以免代理崩潰時丟失訊息。面對緩慢的消費者,它們通常允許佇列無限增長,而不是丟棄訊息或施加背壓,不過具體行為也可能取決於配置。
排隊還有一個後果:消費者通常是 非同步的。生產者傳送訊息時,一般只等待代理確認訊息已被快取,並不等待消費者完成處理。訊息會在未來某個無法確定的時間點傳遞給消費者——往往不到一秒,但如果佇列中積壓了大量訊息,也可能晚得多。
訊息代理與資料庫的對比
有些訊息代理甚至可以透過 XA 或 JTA 參與兩階段提交協議(參見“跨不同系統的分散式事務”)。這一功能使它們在性質上與資料庫十分相似,不過訊息代理與資料庫之間仍有一些重要的實際差異:
資料庫通常會一直儲存資料,直到有人顯式刪除;而有些訊息代理會在訊息成功傳遞給消費者後自動將其刪除。這類訊息代理不適合長期儲存資料。
正因為訊息會很快刪除,大多數訊息代理假定工作集相當小,也就是佇列很短。如果消費者緩慢,導致代理必須緩衝大量訊息(記憶體容納不下時還可能溢寫到磁碟),每條訊息的處理時間就會延長,總體吞吐量也可能下降5。
資料庫通常支援二級索引,並能透過查詢語言以多種方式搜尋資料;訊息代理通常只支援訂閱與某種模式匹配的一組主題。兩者本質上都讓客戶端選擇自己關心的資料子集,但資料庫提供的查詢功能通常強大得多。
查詢資料庫時,結果通常以某一時刻的資料快照為依據;如果另一個客戶端隨後寫入資料庫並改變了查詢結果,第一個客戶端不會知道先前的結果已經過時,除非再次執行查詢或輪詢變更。相比之下,訊息代理不支援任意查詢,也不允許修改已經發出的訊息,但會在資料發生變化時(即有新訊息可用時)通知客戶端。
這是訊息代理的傳統形態,JMS13、AMQP14 等標準對它作出了規範,RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO Enterprise Message Service、IBM MQ、Azure Service Bus 和 Google Cloud Pub/Sub 等軟體則實現了這種形態15。儘管也可以把資料庫用作佇列,但要把效能調到理想水平並不容易16。
多個消費者
多個消費者讀取同一主題中的訊息時,主要有兩種訊息傳遞模式,如 圖 12-1 所示:
- 負載均衡
每條訊息只傳遞給 一個 消費者,因此多個消費者可以分擔處理該主題訊息的工作。代理可以任意把訊息分配給消費者。如果訊息處理成本很高,而你希望透過增加消費者來並行處理,便適合採用這種模式。(在 AMQP 中,可以讓多個客戶端消費同一個佇列來實現負載均衡;在 JMS 中,這稱為 共享訂閱(shared subscription)。)
- 扇出
每條訊息都傳遞給 所有 消費者。扇出讓幾個相互獨立的消費者都能“收聽”同一份訊息廣播,彼此互不影響——相當於流處理版本的“幾個不同批處理作業讀取同一個輸入檔案”。(JMS 的主題訂閱和 AMQP 的交換器繫結提供了這一功能。)

這兩種模式可以結合使用,例如 Kafka 的 消費者組(consumer group) 功能。消費者組訂閱某個主題後,該主題中的每條訊息都會傳送給組內的一個消費者(在組內消費者之間進行負載均衡)。如果兩個不同的消費者組訂閱同一主題,那麼每條訊息都會傳送給各組中的一個消費者(在消費者組之間實現扇出)。
確認應答與重新傳遞
消費者隨時可能崩潰,因此代理可能已經把訊息傳遞給消費者,但消費者尚未處理,或只處理了一部分便發生崩潰。為了確保訊息不會丟失,訊息代理採用 確認應答(acknowledgment):客戶端處理完訊息後,必須明確告知代理,代理才能將訊息從佇列中移除。
如果客戶端連線關閉或超時,而代理尚未收到確認應答,代理便假定訊息沒有得到處理,並把它重新傳遞給另一個消費者。(請注意,訊息可能 實際上已經 處理完畢,只是確認應答在網路中丟失了。除非該操作具有冪等性,或不要求恰好一次語義,否則必須使用原子提交協議來處理這種情況,詳見“恰好一次訊息處理”。)
重新傳遞與負載均衡結合後,會對訊息順序產生一個有趣的影響。在 圖 12-2 中,消費者通常按照生產者傳送訊息的順序進行處理。但是,消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。尚未確認的訊息 m3 隨後被重新傳遞給消費者 1,於是消費者 1 按照 m4、m3、m5 的順序處理訊息。因此,m3 和 m4 的傳遞順序與生產者 1 的傳送順序不同。

即使訊息代理試圖保持訊息順序(JMS 和 AMQP 標準都作此要求),負載均衡與重新傳遞的結合仍不可避免地會使訊息發生重排。要避免這個問題,可以為每個消費者使用單獨的佇列,也就是不使用負載均衡。如果訊息彼此完全獨立,重排並無大礙;但如果訊息之間存在因果依賴,順序就可能十分重要,本章後面將看到這一點。
重新傳遞還可能浪費資源、造成資源飢餓,甚至永久阻塞一條流。常見情形是生產者沒有正確序列化訊息,例如編碼成 JSON 的物件缺少必填鍵。任何讀到這條訊息的消費者都會期待該鍵存在,並在發現缺失時失敗。由於沒有發出確認應答,代理會再次傳送訊息,使另一個消費者也隨之失敗,如此無限迴圈。如果代理提供嚴格的順序保證,後續處理將完全無法推進。允許訊息重排的代理仍能繼續處理其他訊息,卻會把資源浪費在永遠得不到確認的訊息上。
死信佇列(dead letter queue,DLQ) 用來處理這類問題。系統不再把訊息留在當前佇列中無限重試,而是將其移到另一條佇列,讓消費者能夠繼續前進17、18。通常會對死信佇列設定監控——佇列中出現任何訊息都意味著發生了錯誤。檢測到新訊息後,操作員可以決定永久丟棄它、手動修改後重新生產這條訊息,或修復消費者程式碼,使其能夠正確處理訊息。大多數佇列系統都提供 DLQ;如今,Apache Pulsar 等基於日誌的訊息系統以及 Kafka Streams 等流處理系統也開始支援它19。
基於日誌的訊息代理
透過網路傳送資料包或向網路服務發出請求,通常都是轉瞬即逝的操作,不會留下永久痕跡。儘管可以透過抓包和日誌記錄把這些操作永久儲存下來,但我們通常不會這樣看待它們。AMQP/JMS 風格的訊息代理繼承了這種臨時訊息傳遞的思路:即使把訊息寫入磁碟,也會在訊息傳遞給消費者後很快將其刪除。
資料庫和檔案系統的思路恰恰相反:凡是寫入資料庫或檔案的內容,通常都應該永久儲存,至少要儲存到有人明確決定再次刪除它為止。
這種思路上的差異會極大地影響衍生資料的建立方式。正如 第 11 章 所說,批處理的一項關鍵特性是可以反覆執行,試驗不同的處理步驟,又不用擔心損壞輸入,因為輸入是隻讀的。AMQP/JMS 風格的訊息傳遞卻並非如此:如果確認應答會讓代理刪除訊息,那麼接收訊息就是一種破壞性操作。你無法重新執行同一個消費者,並期望得到同樣的結果。
向訊息傳遞系統加入新消費者時,它通常只能接收註冊之後發出的訊息;先前的訊息早已消失,無法恢復。相比之下,檔案和資料庫可以隨時加入新客戶端,並讀取任意久遠的資料,只要應用沒有明確覆蓋或刪除這些資料。
為什麼不能把兩者結合起來,既採用資料庫的持久儲存方式,又具備訊息傳遞的低延遲通知能力?這就是 基於日誌的訊息代理(log-based message broker) 的思想。近年來,這類系統已經十分流行。
使用日誌進行訊息儲存
日誌就是磁碟上一系列僅追加的記錄。我們此前在 第 4 章 討論日誌結構儲存引擎和預寫日誌時、在 第 6 章 討論複製時,以及在 第 10 章 把日誌作為一種共識形式來討論時,都見過這種結構。
同一種結構也可以用來實現訊息代理:生產者把訊息追加到日誌末尾,以此傳送訊息;消費者順序讀取日誌,以此接收訊息。消費者讀到日誌末尾後,便等待新訊息追加的通知。用於監視檔案新增內容的 Unix 工具 tail -f,實質上的工作方式與此相同。
為了把吞吐量擴充套件到單塊磁碟的能力之上,可以對日誌進行 分片(即 第 7 章 所說的分片)。不同分片可以託管在不同機器上,每個分片都是一份獨立於其他分片讀寫的日誌。一個主題可以定義為一組承載相同型別訊息的分片。圖 12-3 展示了這種方法。
在每個分片中——Kafka 把它稱為 分割槽(partition)——代理會為每條訊息分配一個單調遞增的序列號,也就是 偏移量(offset)(圖 12-3 方框中的數字就是訊息偏移量)。分割槽是僅追加的,因此這樣的序列號有明確含義:分割槽內的訊息具有全序,而不同分割槽之間沒有順序保證。

Apache Kafka20 和 Amazon Kinesis Streams 都是以這種方式工作的基於日誌的訊息代理。Google Cloud Pub/Sub 的架構與之相似,但公開的是 JMS 風格的 API,而不是日誌抽象15。儘管這些訊息代理會把所有訊息寫入磁碟,但透過跨多臺機器分片,仍能達到每秒數百萬條訊息的吞吐量,並透過複製訊息實現容錯21、22。
日誌與傳統訊息傳遞的比較
基於日誌的方法自然支援扇出式訊息傳遞,因為多個消費者可以各自讀取日誌而互不影響——讀取訊息不會把它從日誌中刪除。要在一組消費者之間實現負載均衡,代理可以把整個分片分配給消費者組中的節點,而不是把一條條訊息分別分配給各個消費者客戶端。
隨後,每個客戶端會消費所分配分片中的 全部 訊息。消費者得到一個日誌分片後,通常會採用直截了當的單執行緒方式,順序讀取其中的訊息。這種粗粒度負載均衡有一些缺點:
分擔主題消費工作的節點數,最多隻能等於該主題的日誌分片數,因為同一分片內的訊息都傳遞給同一個節點。(也可以設計一種讓兩個消費者共同處理一個分片的負載均衡方案:兩者都讀取完整訊息集,但一個只處理偏移量為偶數的訊息,另一個只處理偏移量為奇數的訊息。另一種辦法是把訊息處理分派給執行緒池,但這會使消費者偏移量的管理更加複雜。一般來說,最好還是用單執行緒處理一個分片,並透過增加分片來提高並行度。)
如果某一條訊息處理得很慢,就會阻塞該分片中後續訊息的處理,這是一種隊頭阻塞(參見“描述效能”)。
因此,如果訊息處理成本很高,你希望以訊息為單位並行處理,而且訊息順序並不十分重要,那麼 JMS/AMQP 風格的訊息代理更合適。反之,如果訊息吞吐量很高,每條訊息都能迅速處理,而且順序非常重要,基於日誌的方法就表現出色23、24。不過,兩種架構之間的界線正在變得模糊:Kafka 等基於日誌的訊息系統如今也支援 JMS/AMQP 風格的消費者組,讓多個消費者能夠接收同一分割槽中的訊息25、26。
由於分片日誌通常只能保持單個分片內部的訊息順序,所有必須以一致順序處理的訊息都需要路由到同一個分片。例如,應用可能要求與某個特定使用者有關的事件始終以固定順序出現。可以根據事件的使用者 ID 選擇分片來實現這一點;換句話說,把使用者 ID 作為 分割槽鍵(partition key)。
消費者偏移量
順序消費一個分片,很容易判斷哪些訊息已經處理:偏移量小於消費者當前偏移量的訊息都已處理,偏移量更大的訊息則尚未讀到。因此,代理無需跟蹤每條訊息的確認應答,只需定期記錄消費者偏移量。這樣既減少了簿記開銷,也帶來了批次處理和流水線化的機會,有助於提高基於日誌系統的吞吐量。不過,如果消費者發生故障,它會從上次記錄的偏移量恢復,而不是從自己實際讀到的最新位置恢復,因此可能會再次讀到一些訊息。
這種偏移量其實與單主資料庫複製中常見的 日誌序列號 非常相似,我們曾在“設定新的副本”中討論過它。在資料庫複製中,日誌序列號允許追隨者斷開連線後重新連線到領導者,並且不跳過任何寫入就恢復複製。這裡採用的原理完全相同:訊息代理扮演領導者資料庫的角色,消費者則像追隨者。
如果消費者節點發生故障,消費者組會把它的分片分配給另一個節點,後者從最後記錄的偏移量開始消費。如果原消費者已經處理了後續訊息,卻尚未記錄相應的偏移量,那麼重啟後這些訊息會被再次處理。本章後面將討論如何應對這一問題。
磁碟空間使用
如果永遠只向日志追加內容,磁碟空間終有耗盡之時。為了回收空間,日誌實際上會被切成若干段,舊段會不時被刪除或移入歸檔儲存。(我們將在“日誌壓實”中討論一種更複雜的空間回收方法。)
這意味著,如果緩慢的消費者跟不上訊息產生的速度,落後到其偏移量指向已經刪除的日誌段,就會錯過一些訊息。實際上,日誌實現了一個大小有限的緩衝區,填滿後便丟棄舊訊息,也就是 迴圈緩衝區(circular buffer) 或 環形緩衝區(ring buffer)。不過,由於這個緩衝區位於磁碟上,它可以相當大。
讓我們粗略估算一下。在本書寫作時,典型的大容量硬碟為 20 TB,順序寫入吞吐量為 250 MB/s。如果始終以最高速度寫入訊息,大約 22 小時後磁碟就會寫滿,必須開始刪除最舊的訊息。這意味著,即使有許多機器和許多磁碟,磁碟日誌也總能緩衝至少 22 小時的訊息,因為增加磁碟會同時增加可用空間和總寫入頻寬。實際部署很少用滿磁碟的全部寫入頻寬,所以日誌通常可以儲存數天乃至數週的訊息。
許多基於日誌的訊息代理如今會把訊息存入物件儲存,以擴大儲存容量,這與“以物件儲存為後端的資料庫”中討論的做法相似。Apache Kafka 和 Redpanda 等訊息代理透過分層儲存從物件儲存提供較舊的訊息;WarpStream、Confluent Freight 和 Bufstream 等系統則把全部資料都存入物件儲存。除了成本效益,這種架構也簡化了資料整合:物件儲存中的訊息以 Iceberg 表儲存,批處理作業和資料倉儲作業可以直接在這些資料上執行,無需先把資料複製到另一個系統。
當消費者跟不上生產者時
在“訊息傳遞系統”開頭,我們討論了消費者跟不上生產者傳送訊息的速度時可採取的三種辦法:丟棄訊息、緩衝訊息或施加背壓。按照這種分類,基於日誌的方法屬於緩衝,它提供了一個很大但大小固定的緩衝區,其上限由可用磁碟空間決定。
如果消費者遠遠落後,需要的訊息已經早於磁碟保留範圍,它就無法再讀取這些訊息——也就是說,代理實際上會丟棄超出緩衝容量的舊訊息。你可以監控消費者落後日誌頭部多遠,並在落後過多時發出告警。由於緩衝區很大,通常有足夠時間讓運維人員修復緩慢的消費者,並在它開始漏掉訊息前追上進度。
即使某個消費者確實落後太多並開始漏掉訊息,也只有它自己受到影響,不會干擾其他消費者的服務。這是一項很大的運維優勢:你可以出於開發、測試或除錯目的,試驗性地消費生產日誌,不必太擔心干擾生產服務。消費者關閉或崩潰後便不再消耗資源,唯一留下的只是它的偏移量。
這種行為也與傳統訊息代理形成鮮明對比。在傳統代理中,必須小心刪除消費者已經關閉的佇列,否則這些佇列會繼續積累不再需要的訊息,擠佔仍在活動的消費者可用的記憶體。
重播舊訊息
前面提到,對於 AMQP/JMS 風格的訊息代理,處理並確認訊息是一種破壞性操作,因為這會使代理刪除訊息。而在基於日誌的訊息代理中,消費訊息更像讀取檔案:它是一種不會改變日誌的只讀操作。
除了消費者本身產生的輸出,處理訊息唯一的副作用就是消費者偏移量向前移動。但偏移量由消費者控制,所以必要時很容易調整。例如,可以用昨天的偏移量啟動一份消費者副本,把輸出寫到另一個位置,從而重新處理過去一天的訊息。你可以任意重複這個過程,每次換用不同的處理程式碼。
這一特性讓基於日誌的訊息傳遞更像上一章的批處理:透過可重複執行的轉換過程,把衍生資料與輸入資料明確分開。它為試驗提供了更多空間,也更容易從錯誤和程式缺陷中恢復,因此很適合用來整合組織內部的資料流27。
資料庫與流
前面我們對訊息代理與資料庫做過一些比較。傳統上,它們被視為兩類不同的工具,但基於日誌的訊息代理已經成功地把資料庫中的思想用於訊息傳遞。反過來也一樣:我們可以把訊息傳遞和流中的思想用於資料庫。
一種做法是用 事件流充當儲存資料的權威記錄系統(參閱 “權威記錄系統與衍生資料”)。這正是我們在 “事件溯源與 CQRS” 中討論過的 事件溯源(event sourcing):不再用更新和刪除來改變資料模型,而是把每次狀態變化建模成不可變事件,寫入僅追加日誌;所有為讀取最佳化的物化檢視都從這些事件中衍生出來。基於日誌的訊息代理採用僅追加儲存,還能以低延遲通知消費者有新事件到來,因此很適合事件溯源——只要將其配置為永不刪除舊事件。
不過,你不必走到採用事件溯源這一步;即便資料模型是可變的,事件流對資料庫仍然很有用。事實上,每次資料庫寫入都是一個可以捕獲、儲存和處理的事件。資料庫與流的聯絡不只是日誌在磁碟上的物理儲存形式,而是更為根本。
例如,複製日誌(參閱 “複製日誌的實現”)就是資料庫寫入事件組成的流,由領導者在處理事務時生成。追隨者把這股寫入流應用到自己的資料庫副本上,最終得到同一份資料的準確副本。複製日誌中的事件描述的正是已經發生的資料變化。
我們還在 “使用共享日誌” 中遇到過 狀態機複製(state machine replication)原理:如果每個事件都代表一次資料庫寫入,並且每個副本都以相同順序處理相同事件,那麼所有副本最終都會達到相同狀態(這裡假定事件處理是確定性的操作)。這又是事件流的一個例子!
本節先考察異構資料系統中會出現的一個問題,再探討如何把事件流的思想引入資料庫來解決它。
保持系統同步
正如全書反覆說明的,沒有一個系統能夠滿足所有資料儲存、查詢和處理需求。實踐中,大多數稍具規模的應用都要組合多種技術才能滿足要求:例如用 OLTP 資料庫處理使用者請求,用快取加速常見請求,用全文索引處理搜尋查詢,再用資料倉儲進行分析。每個系統都有自己的資料副本,並採用為自身用途最佳化的表示形式。
相同或相關的資料分散在不同位置,就必須彼此保持同步:資料庫中的某個專案更新後,快取、搜尋索引和資料倉儲也要隨之更新。資料倉儲通常透過 ETL 流程完成同步(參閱 “資料倉儲”):先取得資料庫的完整副本,轉換資料,再批次載入進資料倉儲——換言之,這是一個批處理過程。同樣,我們在 “批處理用例” 中看到,搜尋索引、推薦系統以及其他衍生資料系統也可以透過批處理來建立。
如果定期轉儲完整資料庫太慢,有時會改用 雙寫(dual write):資料發生變化時,應用程式碼顯式寫入每個系統,例如先寫資料庫,再更新搜尋索引,最後使相應的快取項失效(也可能併發執行這些寫入)。
然而,雙寫存在一些嚴重問題,其中之一就是 圖 12-4 所示的競態條件。在這個例子中,兩個客戶端併發更新專案 X:客戶端 1 想把值設為 A,客戶端 2 想把值設為 B。兩個客戶端都先把新值寫入資料庫,再寫入搜尋索引。由於時序不巧,請求交錯執行:資料庫先收到客戶端 1 將值設為 A 的寫入,再收到客戶端 2 將值設為 B 的寫入,因此資料庫中的最終值為 B;搜尋索引卻先收到客戶端 2 的寫入,再收到客戶端 1 的寫入,因此最終值為 A。雖然沒有發生任何錯誤,兩個系統卻永久地不一致了。

除非另外採用併發檢測機制,例如我們在 “檢測併發寫入” 中討論的版本向量,否則你甚至不會察覺發生過併發寫入——一個值只會悄無聲息地覆蓋另一個值。
雙寫的另一個問題是,其中一次寫入可能失敗,另一次卻成功。這屬於容錯問題而不是併發問題,但同樣會使兩個系統彼此不一致。要保證兩次寫入要麼都成功,要麼都失敗,就要解決代價高昂的原子提交問題(參閱 “兩階段提交(2PC)”)。
如果只有一個採用單主複製的資料庫,那麼領導者會決定寫入順序,狀態機複製就能在資料庫的各個副本之間正常工作。然而,圖 12-4 中並不存在唯一的領導者:資料庫可能有自己的領導者,搜尋索引也可能有自己的領導者,但兩者誰也不追隨誰,因而可能發生衝突(參閱 “多主複製”)。
如果真能只有一個領導者——例如資料庫——並讓搜尋索引成為資料庫的追隨者,情況就會好得多。但實踐中能做到嗎?
變更資料捕獲
大多數資料庫的複製日誌長期以來都被視為內部實現細節,而不是公共 API。客戶端理應透過資料庫的資料模型和查詢語言進行查詢,而不是解析複製日誌,嘗試從中提取資料。
幾十年來,許多資料庫根本沒有提供文件化的方式來獲取寫入其中的變更日誌。因此,要取得資料庫中發生的所有變化,再將其複製到搜尋索引、快取或資料倉儲等其他儲存技術中,一直非常困難。
近年來,變更資料捕獲(change data capture,CDC)越來越受關注。它是這樣一個過程:觀察寫入資料庫的所有資料變化,將其提取成可以複製到其他系統的形式28。如果變化一經寫入就立即以流的形式提供出來,CDC 尤其有用。
例如,你可以捕獲資料庫中的變化,並持續把相同變化應用到搜尋索引。只要按同一順序應用變更日誌,搜尋索引中的資料就有望與資料庫保持一致。搜尋索引以及其他衍生資料系統,都只是變更流的消費者。
圖 12-5 展示了 CDC 如何解決 圖 12-4 中的併發問題。將 X 分別設為 A 和 B 的兩個請求雖然併發到達資料庫,但資料庫會決定某種執行順序,並按該順序把它們寫入複製日誌。搜尋索引再按相同順序取得並應用這些變化。如果還需要把資料送往資料倉儲等其他系統,只須再為 CDC 事件流新增一個消費者。

變更資料捕獲的實現
按照 “權威記錄系統與衍生資料” 中的說法,我們可以把日誌消費者稱為 衍生資料系統(derived data system):搜尋索引和資料倉儲中儲存的資料,不過是權威記錄系統中資料的另一種檢視。變更資料捕獲機制確保權威記錄系統中的所有變化也會反映到衍生資料系統中,使衍生系統持有準確的資料副本。
實質上,變更資料捕獲讓一個資料庫成為領導者(即從中捕獲變化的資料庫),讓其他系統成為追隨者。基於日誌的訊息代理能夠保持訊息順序,避免 圖 12-2 中的亂序問題,因此很適合把變更事件從源資料庫傳送到衍生系統。
邏輯複製日誌可以用來實現變更資料捕獲(參閱 “邏輯(基於行)的日誌複製”),不過需要應對模式變更、恰當建模更新等挑戰。開源專案 Debezium 正是為解決這些問題而生。它為 MySQL、PostgreSQL、Oracle、SQL Server、Db2、Cassandra 以及其他許多資料庫提供了 源聯結器(source connector)。這些聯結器接入資料庫複製日誌,以標準事件模式呈現其中的變化;隨後便可轉換訊息並將其寫入下游資料庫。Kafka Connect 框架也為各種資料庫提供了更多 CDC 聯結器。Maxwell 透過解析 binlog 為 MySQL 提供類似功能29;GoldenGate 為 Oracle 提供類似功能;pgcapture 則面向 PostgreSQL。
與訊息代理一樣,變更資料捕獲通常也是非同步的:權威記錄資料庫提交變化之前,並不會等待消費者應用該變化。這種設計在運維上的好處是,增加一個緩慢的消費者不會對權威記錄系統造成太大影響;缺點則是複製延遲的所有問題同樣存在(參閱 “複製延遲的問題”)。
初始快照
如果擁有資料庫有史以來的全部變更日誌,就可以透過重播日誌來重建資料庫的完整狀態。然而,永久保留所有變化往往會佔用太多磁碟空間,重播也會耗時過久,因此日誌通常需要截斷。
例如,構建新的全文索引需要整個資料庫的完整副本——只應用最近的變更日誌還不夠,因為其中缺少最近沒有更新過的專案。因此,如果沒有完整的歷史日誌,就需要從一個一致的快照開始,正如 “設定新的副本” 中所討論的那樣。
資料庫快照必須與變更日誌中的某個已知位置或偏移量對應,這樣才能知道快照處理完畢後應從何處開始應用變化。有些 CDC 工具整合了快照功能,有些則需要手工完成。Debezium 使用 Netflix 的 DBLog 水位線演算法提供增量快照30、31。
日誌壓實
如果只能保留有限的日誌歷史,那麼每增加一個新的衍生資料系統,都要重新執行一遍快照流程。不過,日誌壓實(log compaction)提供了一個很好的替代方案。
我們在 “日誌結構儲存” 中討論日誌結構儲存引擎時介紹過日誌壓實(示例見 圖 4-3)。原理很簡單:儲存引擎定期查詢日誌中鍵相同的記錄,丟棄重複項,只保留每個鍵的最新更新。這可能會顯著縮小日誌段,因此在壓實過程中也可以合併日誌段,如 圖 12-6 所示。整個過程在後臺執行。

在日誌結構儲存引擎中,帶有特殊空值的更新(稱為 墓碑,tombstone)表示某個鍵已被刪除,並使該鍵在日誌壓實時被移除。但只要一個鍵沒有被覆蓋或刪除,它就會永久留在日誌中。壓實後的日誌所需磁碟空間只取決於資料庫當前的內容,而與資料庫有史以來發生過多少次寫入無關。如果同一個鍵經常被覆蓋,舊值最終會被垃圾回收,只留下最新值。
同樣的思路也適用於基於日誌的訊息代理和變更資料捕獲。如果 CDC 系統保證每次變化都有主鍵,而且對某個鍵的每次更新都會取代該鍵的舊值,那麼只保留這個鍵最近一次寫入就足夠了。
這樣一來,每當需要重建搜尋索引等衍生資料系統時,都可以讓新消費者從經過日誌壓實的主題的偏移量 0 開始,依次掃描日誌中的所有訊息。日誌保證包含資料庫中每個鍵的最新值(也可能包含一些舊值)——換言之,無須再次對 CDC 源資料庫建立快照,便可由此取得資料庫內容的完整副本。
Apache Kafka 支援日誌壓實。正如本章後面將會看到的,它使訊息代理不僅可以傳送臨時訊息,還能用作持久儲存。
變更流的 API 支援
如今,大多數主流資料庫都把變更流作為一等介面公開出來,而不再依賴過去那種事後加裝、逆向工程得到的 CDC。MySQL、PostgreSQL 等關聯式資料庫通常透過自身副本所使用的同一份複製日誌傳送變化。大多數雲廠商也為自家產品提供 CDC 方案:例如,Datastream 可以流式訪問 Google Cloud 的關聯式資料庫和資料倉儲。
即便 Cassandra 這類最終一致、基於法定人數的資料庫,如今也支援變更資料捕獲。正如 “線性一致性與仲裁” 中所述,客戶端必須把寫入持久化到多數節點,該寫入才被視為可見。法定人數寫入很難支援 CDC,因為沒有唯一的權威資料來源可供訂閱;資料是否可見,取決於每個讀取者選擇的一致性級別。Cassandra 繞開了這個問題:它不提供統一的變更流,而是公開每個節點的原始日誌段。消費資料的系統必須讀取每個節點的原始日誌段,再自行決定如何將其合併成一股流,做法很像法定人數讀取者32。
Kafka Connect33 把許多資料庫系統的變更資料捕獲工具與 Kafka 整合起來。變更事件進入 Kafka 之後,既可以用來更新搜尋索引等衍生資料系統,也可以送入本章後面討論的流處理系統。
變更資料捕獲與事件溯源
我們來比較一下變更資料捕獲與事件溯源。與變更資料捕獲相似,事件溯源也把應用狀態的所有變化存成變更事件日誌。兩者最大的區別在於抽象層次不同:
在變更資料捕獲中,應用以可變方式使用資料庫,可以隨意更新和刪除記錄。變更日誌從資料庫底層提取(例如解析複製日誌),從而確保提取出的寫入順序與實際寫入順序一致,避免 圖 12-4 中的競態條件。
在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。事件儲存只允許追加,通常不鼓勵或禁止更新、刪除事件。事件旨在反映應用層發生的事情,而不是底層的狀態變化。
哪一種更好取決於具體情況。對於原本沒有采用事件溯源的應用,改用事件溯源是一項重大變化,也會帶來 “事件溯源與 CQRS” 中討論的各種利弊。相比之下,CDC 可以用很少的改動接入現有資料庫——寫入資料庫的應用甚至可能根本不知道 CDC 正在執行。
變更資料捕獲看起來比事件溯源更容易採用,但它也有自己的一系列挑戰。
在微服務架構中,一個資料庫通常只由一個服務訪問。其他服務透過該服務的公共 API 與之互動,一般不會直接訪問資料庫。這樣,資料庫就成為該服務的內部實現細節,開發者可以改變資料庫模式而不影響公共 API。
然而,CDC 系統複製資料時通常會沿用上游資料庫的模式,這會讓這些模式變成公共 API,必須像服務的公共 API 一樣加以管理。如果開發者刪除資料庫表中的一列,依賴該欄位的下游消費者就會崩潰。這類挑戰一直存在於資料流水線中,但過去通常只影響資料倉儲 ETL。CDC 往往以資料流實現,其他生產服務也可能是消費者,因此破壞這些消費者可能導致面向客戶的故障34。人們通常用資料契約來防止這類破壞。
將內部模式與外部模式解耦的一種常見方法是採用 發件箱模式(outbox pattern)。發件箱是具有獨立模式的表;CDC 系統對外公開這些表,而不是資料庫中的內部領域模型35、36。這樣,開發者便可按需修改內部模式,而保持發件箱表不變。這看起來像雙寫——它確實就是雙寫。不過,兩次寫入都留在同一個系統(資料庫)中,可以出現在同一個事務裡,因此發件箱避開了 “保持系統同步” 中討論的問題。
不過,發件箱也有一些權衡。開發者仍須維護內部模式與發件箱模式之間的轉換,這可能並不容易。發件箱還會增加資料庫寫入底層儲存的資料量,可能引發效能問題。
與變更資料捕獲一樣,重播事件日誌可以重建系統當前狀態。不過,兩者處理日誌壓實的方式不同:
記錄更新的 CDC 事件通常包含記錄的完整新版本,因此一個主鍵的當前值完全由該主鍵的最新事件決定,日誌壓實可以丟棄同一主鍵的舊事件。
事件溯源的建模層次更高:事件通常表達使用者操作的意圖,而不是該操作引發狀態更新的具體機制。後續事件通常不會覆蓋先前事件,因此需要完整的事件歷史才能重建最終狀態,不能用同樣的方式壓實日誌。
使用事件溯源的應用通常會儲存由事件日誌衍生出的當前狀態快照,以免反覆處理完整日誌。不過,這只是一種效能最佳化,用來加快讀取和崩潰恢復;系統的設計意圖仍是永久儲存所有原始事件,並能在需要時重新處理完整的事件日誌。我們將在 “不變性的侷限” 中討論這一假設。
狀態、流和不變性
我們在 第 11 章 中看到,批處理受益於輸入檔案的不變性:你可以在現有輸入檔案上執行實驗性的處理作業,而不必擔心損壞這些檔案。正是不變性原則賦予了事件溯源和變更資料捕獲如此強大的能力。
我們通常認為資料庫儲存著應用的當前狀態——這種表示針對讀取進行了最佳化,一般也最便於處理查詢。狀態的本質就在於它會變化,所以資料庫除了插入資料,還支援更新和刪除。這與不變性又如何相容?
只要狀態會變化,它就是一段時間內各種事件改變它的結果。例如,當前可用座位列表取決於已經處理過的預訂;當前賬戶餘額取決於賬戶的貸記與借記;Web 伺服器的響應時間圖,則是所有已發生 Web 請求各自響應時間的聚合。
無論狀態如何變化,總有一系列事件導致這些變化。事情可以做了再撤銷,但那些事件確實發生過這一事實不會改變。關鍵在於,可變狀態與不可變事件的僅追加日誌並不矛盾,而是一枚硬幣的兩面。所有變化構成的日誌——即 變更日誌(changelog)——表示了狀態如何隨時間演化。
如果你偏愛數學,可以說應用狀態是事件流對時間的積分,而變更流是狀態對時間的微分,如 圖 12-7 所示37、38。這個類比有其侷限(例如,狀態的二階導數似乎沒有什麼意義),但它是思考資料的一個實用起點。

只要持久儲存變更日誌,狀態便可以重現。如果把事件日誌視為權威記錄系統,把所有可變狀態都看作由它衍生而來,系統中的資料流就更容易推理。正如 Jim Gray 和 Andreas Reuter 在 1992 年所說39:
從根本上說,根本沒有必要保留資料庫;日誌已經包含了全部資訊。之所以要儲存資料庫(即日誌末尾所對應的當前狀態),只是為了提升檢索操作的效能。
日誌壓實是銜接日誌與資料庫狀態的一種方式:它只保留每條記錄的最新版本,丟棄已經被覆蓋的版本。
不可變事件的優點
資料庫中的不變性是一個古老的觀念。例如,會計師幾個世紀以來一直在財務記賬中運用不變性。一筆交易發生後,會被記入僅追加的 分類賬(ledger);分類賬本質上就是事件日誌,描述貨幣、商品或服務的轉手。損益表、資產負債表等賬目,則是彙總分類賬中的交易而衍生出來的40。
如果出了差錯,會計師不會刪除或修改分類賬中的錯誤交易,而是另加一筆交易來抵消錯誤,例如退還一筆誤收的費用。錯誤交易會永遠保留在分類賬中,因為它對審計可能十分重要。如果根據錯誤分類賬得出的錯誤數字已經公佈,下一個會計期間的數字就會包含相應的更正。這在會計工作中再正常不過41。
這種可審計性對金融系統尤其重要,但許多不受嚴格監管的系統也能從中受益。如果你不慎部署了一段有缺陷的程式碼,把錯誤資料寫進資料庫,而這段程式碼還能以破壞性的方式覆蓋資料,恢復起來會困難得多。有了不可變事件的僅追加日誌,診斷事情經過並從問題中恢復就容易得多。同樣,客服人員也可以利用審計日誌診斷客戶的請求和投訴。
不可變事件所包含的資訊也比當前狀態更加豐富。例如在購物網站上,顧客可能先把一件商品加入購物車,隨後又將其移除。從履行訂單的角度看,第二個事件抵消了第一個事件;但從分析角度看,知道顧客曾考慮購買某件商品、後來又放棄,也許很有用。或許他們日後會購買,或許他們找到了替代品。這些資訊會留在事件日誌中;如果資料庫在商品移出購物車時就刪除相應記錄,資訊也會隨之丟失。
從同一事件日誌中派生多個檢視
此外,把可變狀態與不可變事件日誌分離之後,還可以從同一份事件日誌衍生出幾種面向不同讀取方式的表示。這就像一股流有多個消費者一樣(圖 12-5):例如,分析資料庫 Druid 會以這種方式直接從 Kafka 攝取資料,Kafka Connect 的匯聚聯結器則可以把 Kafka 中的資料匯出到各種資料庫和索引33。
在事件日誌與資料庫之間加入顯式的轉換步驟,應用也更容易隨時間演進。如果要引入一項新功能,用新的方式呈現現有資料,可以利用事件日誌為新功能構建一個獨立的、針對讀取最佳化的檢視,與現有系統並行執行,而不必修改現有系統。在許多情況下,新舊系統並行執行比在現有系統中執行複雜的模式遷移更加容易。等讀取方全部切換到新系統、不再需要舊系統之後,只須關閉舊系統並回收資源即可42、43。
把資料寫成一種針對寫入最佳化的形式,再按需轉換成多種針對讀取最佳化的表示,這正是我們在 “事件溯源與 CQRS” 中見過的 命令查詢職責分離(command query responsibility segregation,CQRS)模式。它不一定要求採用事件溯源:同樣可以從 CDC 事件流構建多個物化檢視44。
傳統的資料庫與模式設計方法建立在一個謬誤之上:資料必須按將來查詢它時所採用的形式寫入。如果能把針對寫入最佳化的事件日誌轉換成針對讀取最佳化的應用狀態,正規化與反正規化之爭(參閱 “正規化、反正規化與連線”)就基本失去了意義。完全可以在讀取最佳化檢視中對資料做反正規化,因為轉換過程提供了讓檢視與事件日誌保持一致的機制。
在 “案例研究:社交網路首頁時間線” 中,我們討論過社交網路的主頁時間線:它快取著某位使用者關注的人最近釋出的帖子,就像郵箱一樣。這也是針對讀取最佳化的狀態:主頁時間線高度反正規化,因為你的帖子會複製到每位關注者的時間線中。不過,扇出服務會讓這些重複狀態與新帖子、新的關注關係保持同步,使這種重複仍然可控。
併發控制
CQRS 最大的缺點是事件日誌的消費者通常以非同步方式執行。因此,使用者可能剛剛向日志寫入資料,隨即讀取某個衍生檢視,卻發現該寫入還沒有反映到檢視中。我們曾在 “讀己之寫” 中討論過這個問題及其可能的解決辦法。
一種解決辦法是在把事件追加到日誌時,同步更新讀取檢視。這要麼需要在事件日誌與衍生檢視之間執行分散式事務,要麼需要某種機制,等待事件反映到檢視中。這兩種方法通常都不切實際,因此檢視一般還是非同步更新。
另一方面,從事件日誌衍生當前狀態也簡化了併發控制的某些方面。之所以經常需要多物件事務(參閱 “單物件與多物件操作”),是因為一次使用者操作往往要修改多個不同位置的資料。採用事件溯源後,可以把一個事件設計成對使用者操作的自包含描述。這樣,使用者操作只需在一個位置完成一次寫入——把事件追加到日誌——很容易保證其原子性。
如果事件日誌和應用狀態採用相同的分片方式(例如,處理分片 3 中某位客戶的事件時,只需更新應用狀態的分片 3),那麼簡單的單執行緒日誌消費者無須對寫入做任何併發控制——從設計上看,它一次只會處理一個事件(另見 “實際序列執行”)。日誌在每個分片中定義了事件的序列順序,從而消除了併發帶來的不確定性27。如果一個事件涉及多個狀態分片,就需要多做一些工作,我們將在 第 13 章 中討論。
許多並未採用事件溯源模型的系統,同樣依靠不變性來控制併發:各種資料庫在內部使用不可變資料結構或多版本資料來支援時間點快照(參閱 “索引與快照隔離”)。Git、Mercurial、Fossil 等版本控制系統也依靠不可變資料儲存檔案的版本歷史。
不變性的侷限
永久儲存所有變化的不可變歷史,究竟在多大程度上可行?答案取決於資料集的變動量。有些工作負載以新增資料為主,很少更新或刪除,很容易做成不可變的。另一些工作負載則在相對較小的資料集上頻繁更新和刪除;這時,不可變歷史可能膨脹到難以承受,碎片化也可能成為問題,而壓實和垃圾回收的效能會直接影響系統能否穩健執行45、46。
除了效能原因,有時還必須出於行政或法律原因刪除資料,哪怕這有悖於不變性。例如,歐盟《通用資料保護條例》(GDPR)等隱私法規要求應使用者請求刪除其個人資訊和錯誤資訊;意外洩露敏感資訊後,也可能需要控制影響範圍。
在這些情況下,只在日誌末尾追加一個事件,表示先前的資料應視為已刪除,並不足夠——你真正想做的是改寫歷史,假裝那些資料從未寫入。Datomic 把這種功能稱為 切除(excision)47,Fossil 版本控制系統中也有一個類似概念,稱為 排斥(shunning)48。
真正刪除資料出乎意料地困難49,因為副本可能存在於許多地方。例如,儲存引擎、檔案系統和 SSD 往往把資料寫到新位置,而不是在原地覆蓋41;備份通常還會被刻意設計成不可變,以防意外刪除或損壞。
一種允許刪除不可變資料的方法是 密碼學粉碎(crypto-shredding)50:把將來可能需要刪除的資料加密儲存;需要清除時,忘掉加密金鑰。加密後的資料仍然存在,但已經無人能夠使用。從某種意義上說,這只是轉移了問題:實際資料現在不可變了,儲存金鑰的地方卻是可變的。
此外,還必須事先決定哪些資料共用一把金鑰,何時要改用不同金鑰。這項決定非常重要,因為以後只能選擇粉碎某把金鑰加密的全部資料,或者一項也不粉碎,不能只刪除其中一部分。如果為每個資料項分別儲存一把金鑰,金鑰儲存會變得與主資料儲存一樣龐大,難以管理。可穿刺加密(puncturable encryption)等更複雜的方案51可以選擇性撤銷一把金鑰的部分解密能力,但尚未得到廣泛應用。
總體而言,刪除更像是“讓資料更難取回”,而不是真正“讓資料無法取回”。儘管如此,有時仍必須嘗試,我們將在 “立法與自律” 中看到這一點。
流處理
到目前為止,本章已經討論了流從何而來(使用者活動事件、感測器和資料庫寫入),以及如何傳輸流(直接傳遞訊息、透過訊息代理傳遞,以及使用事件日誌)。
接下來要討論的是,拿到一股流之後能用它做什麼——也就是如何處理它。大體上有三種選擇:
取出事件中的資料,寫入資料庫、快取、搜尋索引或類似的儲存系統,再供其他客戶端查詢。如 圖 12-5 所示,這是一種讓資料庫與系統其他部分的變化保持同步的好辦法,尤其是在流消費者是唯一寫入資料庫的客戶端時。寫入儲存系統,相當於以流式方式完成 “批處理用例” 中討論的工作。
以某種方式把事件推送給使用者,例如傳送告警郵件或推送通知,或者把事件流式傳送到實時儀表板上加以視覺化。這種情況下,人是流的最終消費者。
處理一股或多股輸入流,產生一股或多股輸出流。一股流可能先後經過由多個處理階段組成的流水線,最終才到達某個輸出(即選項 1 或 2)。
本章餘下部分將討論第三種選擇:處理流併產生其他衍生流。執行這種流處理的程式碼稱為 運算元(operator)或 作業(job)。它與 第 11 章 中討論的 Unix 程序和 MapReduce 作業關係密切,資料流模式也很相似:流處理器以只讀方式消費輸入流,再以僅追加方式把輸出寫到另一個位置。
流處理器中的分片和並行化模式,也與 第 11 章 介紹的 MapReduce 和資料流引擎非常相似,因此這裡不再贅述。轉換、過濾記錄等基本對映操作的工作方式也相同。
流與批處理作業有一個關鍵區別:流永遠不會結束。這個區別會帶來許多後果。正如本章開頭所說,對無界資料集進行排序沒有意義,因此不能使用排序合併連線(參閱 “JOIN 與 GROUP BY”)。容錯機制也必須改變:一個只執行了幾分鐘的批處理作業發生任務故障時,大可從頭重啟該任務;但一個流作業已經執行了幾年,崩潰後再從頭開始,通常不可行。
流處理的應用
長期以來,流處理一直用於監控:組織希望在特定事情發生時收到警報。例如:
欺詐檢測系統需要判斷信用卡的使用模式是否出現意外變化,並在信用卡可能被盜時將其凍結。
交易系統需要觀察金融市場的價格變化,並按照指定規則執行交易。
製造系統需要監控工廠內機器的狀態,一旦發生故障便迅速查明問題。
軍事與情報系統需要追蹤潛在侵略者的活動,發現攻擊跡象時發出警報。
這類應用需要相當複雜的模式匹配與關聯分析。不過,流處理也逐漸出現了其他用途。本節將簡要比較其中幾種應用。
複合事件處理
複合事件處理(complex event processing,CEP)是一種在 20 世紀 90 年代發展起來的事件流分析方法,特別適合需要搜尋特定事件模式的應用52。正如正規表示式可以在字串中搜尋特定的字元模式,CEP 允許你指定規則,在流中搜尋特定的事件模式。
CEP 系統通常使用 SQL 等高階宣告式查詢語言或圖形使用者介面,描述應該檢測哪些事件模式。這些查詢會提交給處理引擎;引擎消費輸入流,並在內部維護一個狀態機來執行所需的匹配。一旦找到匹配,引擎便發出一個 複合事件(complex event,名稱由此而來),其中包含檢測到的事件模式詳情53。
這類系統中,查詢與資料的關係恰好和普通資料庫相反。資料庫通常持久儲存資料,把查詢視為臨時物件:查詢到來時,資料庫搜尋與之匹配的資料,查詢完成後便將其忘掉。CEP 引擎卻反過來長期儲存查詢;每個事件到來時,引擎都會檢查迄今所見的事件是否形成了與某個常駐查詢相匹配的模式54。
CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。Flink、Spark Streaming 等分散式流處理器也支援使用 SQL 對流執行宣告式查詢。
流分析
流處理的另一個用途是對流進行 分析。CEP 與流分析的邊界並不清晰,但一般來說,分析不太關心尋找特定的事件序列,而更關注大量事件上的聚合與統計指標,例如:
測量某類事件的速率(每個時間間隔發生多少次);
計算某段時間內一個值的滾動平均數;
將當前統計值與先前時間段對比(例如檢測趨勢,或在某項指標與上週同一時間相比異常偏高或偏低時發出警報)。
這類統計值通常在固定時間區間內計算。例如,你可能想知道過去 5 分鐘內某項服務平均每秒收到多少次查詢,以及這段時間內響應時間的第 99 百分位點。在幾分鐘內取平均,可以抹平相鄰秒之間無關緊要的波動,同時仍能及時反映流量模式的變化。用於聚合的時間區間稱為 視窗(window),我們將在 “時間推理” 中詳細討論。
流分析系統有時會使用機率演算法,例如用布隆過濾器(我們在 “布隆過濾器” 中見過)判斷集合成員關係,用 HyperLogLog55 估計基數,以及用各種演算法估計百分位點(參閱 “計算百分位點”)。機率演算法給出近似結果,但與精確演算法相比,流處理器所需記憶體少得多。近似演算法的這種用途有時讓人誤以為流處理系統總是有損而不精確,其實不然:流處理本身並沒有任何近似性,使用機率演算法只是一項最佳化56。
許多開源分散式流處理框架都以分析為設計目標,例如 Apache Storm、Spark Streaming、Flink、Samza、Apache Beam 和 Kafka Streams57。託管服務則包括 Google Cloud Dataflow 和 Azure Stream Analytics。
維護物化檢視
我們已經看到,資料庫的變更流可以用來維護快取、搜尋索引和資料倉儲等衍生資料系統,使它們與源資料庫保持同步。這些都是維護物化檢視的例子:從某個資料集衍生出另一種檢視,以便高效查詢,並在底層資料變化時更新檢視37。
同樣,在事件溯源中,應用狀態透過應用事件日誌來維護;這裡的應用狀態也是一種物化檢視。與流分析不同,只考慮某個時間視窗內的事件通常不夠:除了日誌壓實可能丟棄的過時事件,構建物化檢視可能需要任意時間段內的 所有 事件。實際上,你需要一個一直延伸到時間開端的視窗。
原則上,任何流處理器都可以用於維護物化檢視。不過,有些面向分析的框架假定自己主要處理持續時間有限的視窗,而永久維護事件與這種假設背道而馳。Kafka Streams 和 Confluent 的 ksqlDB 建立在 Kafka 的日誌壓實支援之上,能夠支援這類用途58。
資料庫似乎很適合維護物化檢視——畢竟,它們本來就是用來儲存資料集完整副本的,而且許多資料庫也支援物化檢視。我們在 “物化檢視與多維資料集” 中看到,資料倉儲常見的分析查詢可以物化成 OLAP 多維資料集。
遺憾的是,資料庫通常透過批處理作業,或 PostgreSQL 的 REFRESH MATERIALIZED VIEW 之類的按需請求來重新整理物化檢視表。檢視會定期重新計算,而不是在源資料更新時隨之更新。這種方式有兩個重大缺點,因而不適合透過流處理維護檢視:
效率低下:每次更新檢視都要重新處理所有資料,儘管絕大多數資料很可能沒有變化。
資料不夠新鮮:只有等到下一次計劃更新重新執行查詢,源資料的變化才會反映到物化檢視中。
如果資料很容易分割槽,而且計算天然適合增量執行,也可以編寫資料庫觸發器來高效更新物化檢視。例如,如果物化檢視維護每日銷售總收入,那麼每發生一筆新銷售,只需更新相應日期的那一行。在少數場景中可以定製這樣的解決方案,但許多 SQL 查詢很難高效地轉換為增量計算,甚至根本無法轉換。
增量檢視維護(incremental view maintenance,IVM)是解決上述問題的一種更通用方法。IVM 技術把 SQL 等關係語言轉換成能夠執行增量計算的運算元。IVM 演算法不再處理整個資料集,而只重新計算和更新發生變化的資料38、59、60。這樣,檢視計算的效率大幅提升,更新也可以更頻繁地執行,顯著改善資料新鮮度。
Materialize61、RisingWave、ClickHouse 和 Feldera 等資料庫都採用 IVM 技術,提供高效的增量物化檢視。這些資料庫攝取事件流,實時提供物化檢視。最近的事件快取在記憶體中,並定期用於更新磁碟上的物化檢視。讀取時則把最近事件與已經物化的資料合併起來,提供一個統一的實時檢視。由於讀取通常用 SQL 表達,而物化檢視往往以 OLAP 風格的格式儲存,這些系統也支援 第 11 章 所討論的大規模資料倉儲式查詢。
在流上搜尋
CEP 可以搜尋由多個事件構成的模式;除此以外,有時還需要按照全文搜尋查詢等複雜條件來搜尋單個事件。
例如,媒體監測服務可以訂閱媒體機構釋出的新聞文章和節目源,搜尋任何提及目標公司、產品或話題的新聞。為此,需要預先制定搜尋查詢,再不斷讓新聞條目流與該查詢進行匹配。一些網站也有類似功能:例如,房地產網站的使用者可以要求網站在市場上出現符合其搜尋條件的新房源時通知他們。Elasticsearch 的 percolator 功能62 就是實現這類流式搜尋的一種選擇。
傳統搜尋引擎先為文件建立索引,再在索引上執行查詢。搜尋資料流卻把這個過程顛倒過來:查詢被儲存下來,文件像 CEP 中的事件一樣逐一流過這些查詢。最簡單的做法是讓每份文件測試每條查詢,但查詢數量很大時會變慢。為了最佳化這個過程,也可以像索引文件那樣索引查詢,從而縮小可能匹配的查詢集合63。
事件驅動架構與 RPC
在 “事件驅動的架構” 中,我們討論過用訊息傳遞系統代替 RPC,也就是把它用作服務之間的通訊機制,Actor 模型便是一例。這些系統同樣以訊息和事件為基礎,但我們通常不會把它們視為流處理器:
Actor 框架主要用於管理相互通訊的模組如何併發和分散式執行,而流處理主要是一種資料管理技術。
Actor 之間的通訊往往是短暫的一對一通訊,而事件日誌持久存在,並有多個訂閱者。
Actor 可以採用任意方式通訊,包括迴圈的請求/響應模式;流處理器通常組成無環流水線,每股流都是某個特定作業的輸出,並從一組明確定義的輸入流衍生而來。
不過,類 RPC 系統與流處理之間也有一些交叉。例如,Apache Storm 有一項稱為 分散式 RPC 的功能,可以把使用者查詢分派給一組同時處理事件流的節點。這些查詢會與輸入流中的事件交錯處理,結果再彙總並返回給使用者(另見 “多分片資料處理”)。
Actor 框架也可以用來處理流。不過,許多這類框架無法保證發生崩潰時訊息仍能送達;除非另外實現重試邏輯,否則處理過程不具備容錯能力。
時間推理
流處理器經常要應對時間問題,尤其是用於分析時,往往會使用“過去五分鐘的平均值”這樣的時間視窗。“過去五分鐘”聽起來似乎清楚明確,實際上卻出人意料地棘手。
批處理任務會迅速處理大量歷史事件。如果需要按時間劃分結果,批處理就必須檢視每個事件中嵌入的時間戳。檢視執行批處理的機器系統時鐘毫無意義,因為任務的執行時間與事件的實際發生時間沒有關係。
一個批處理任務可能幾分鐘內就讀完一整年的歷史事件;大多數情況下,我們關心的是這一年的歷史時間線,而不是幾分鐘的處理時間。此外,使用事件中的時間戳還可以讓處理具有確定性:針對同一輸入重新執行同一個過程,會得到相同的結果。
另一方面,許多流處理框架使用處理機器的本地系統時鐘(即 處理時間,processing time)來劃分視窗64。這種方法簡單明了;如果事件建立與事件處理之間的延遲短到可以忽略,也很合理。但只要處理延遲比較顯著——也就是事件實際發生後過了一段明顯可感的時間才得到處理——這種方法就會失效。
事件時間與處理時間
很多原因都會導致處理延遲:排隊、網路故障、效能問題使訊息代理或處理器發生爭用、流消費者重啟,以及故障恢復或修復程式碼缺陷後重新處理過去的事件。
訊息延遲還會使訊息以不可預測的順序到達。例如,假設使用者先發出一個 Web 請求,由 Web 伺服器 A 處理;隨後發出第二個請求,由伺服器 B 處理。A 和 B 各自發出事件,描述自己處理的請求,但 B 的事件先於 A 的事件到達訊息代理。於是,流處理器先看到 B 的事件,再看到 A 的事件,而它們實際發生的順序恰好相反。
不妨拿《星球大戰》系列電影來類比:第四部於 1977 年上映,第五部於 1980 年上映,第六部於 1983 年上映;隨後依次是 1999、2002 和 2005 年上映的第一、二、三部,以及 2015、2017 和 2019 年上映的第七、八、九部65。如果按上映順序觀看,你處理這些電影的順序就與故事的敘事順序不同(集數好比事件時間戳,觀看日期則是處理時間)。人類能夠應對這種不連續性,但流處理演算法必須專門設計,才能處理這類時間與順序問題。
混淆 事件時間(event time)與處理時間會產生錯誤資料。例如,假設有一個流處理器用來測量請求速率(統計每秒請求數)。重新部署流處理器時,它可能停機一分鐘,恢復執行後再處理積壓的事件。如果按處理時間計算速率,處理積壓期間看起來會突然出現異常的請求尖峰,而真實的請求速率其實一直很穩定(圖 12-8)。

處理滯留事件
按事件時間定義視窗時,有一個棘手的問題:你永遠無法確定某個視窗的所有事件是否已經到齊,還是仍有一些事件尚未到達。
例如,假設把事件分成一分鐘的視窗,以統計每分鐘的請求數。你已經統計了一批時間戳落在本小時第 37 分鐘的事件;隨著時間推移,新到事件現在大多落在第 38 和第 39 分鐘。究竟什麼時候才能宣佈第 37 分鐘的視窗已經結束,並輸出計數器的值?
如果一段時間內沒有再看到屬於某個視窗的新事件,可以讓它超時並宣佈視窗就緒。然而,某些事件可能仍快取在另一臺機器上,因網路中斷而延遲。你必須能夠處理這些在視窗宣佈完成後才到達的 滯留事件(straggler event)。大體上有兩種選擇1:
忽略滯留事件,因為正常情況下,它們可能只佔所有事件的很小一部分。可以把丟棄事件的數量作為指標跟蹤;如果開始丟棄大量資料,就發出警報。
釋出 更正(correction):為視窗釋出一個包含滯留事件的更新值。可能還需要撤回先前的輸出。
有時可以用一條特殊訊息表示:“從現在起,不會再有時間戳早於 t 的訊息。”消費者可以利用它觸發視窗66。然而,如果多臺機器上的多個生產者都在生成事件,各自擁有不同的最小時間戳閾值,消費者就必須分別跟蹤每個生產者。這時,增加或移除生產者會更加棘手。
你用的是誰的時鐘?
如果事件會在系統中的多個位置緩衝,為事件賦予時間戳就更加困難。例如,考慮一個向伺服器上報使用指標的移動應用。使用者可能在裝置離線時使用該應用;此時應用會把事件快取在裝置本地,等下次連上網際網路時再傳送給伺服器,而這可能已是幾小時甚至幾天之後。對於流的消費者來說,這些事件看起來就像延遲極久的滯留事件。
這種情況下,事件時間戳其實應該是使用者互動發生的時間,以移動裝置的本地時鐘為準。然而,使用者控制的裝置時鐘往往不可信,因為它可能被無意或有意地設成錯誤時間(參閱 “時鐘同步和準確性”)。伺服器收到事件的時間以伺服器時鐘為準;由於伺服器在你的控制之下,這個時間更可能準確,卻不能很好地描述使用者互動。
為了校正不準確的裝置時鐘,一種方法是記錄三個時間戳67:
根據裝置時鐘,事件發生的時間;
根據裝置時鐘,事件發往伺服器的時間;
根據伺服器時鐘,伺服器收到事件的時間。
用第三個時間戳減去第二個,可以估算裝置時鐘與伺服器時鐘之間的偏移量(假設相對於所需的時間戳精度,網路延遲可以忽略)。再把這一偏移量應用到事件時間戳上,就能估算事件真正發生的時間——這裡還要假設,從事件發生到事件發往伺服器期間,裝置時鐘的偏移量沒有變化。
這個問題並非流處理獨有,批處理在時間推理上面臨一模一樣的問題。只是在流式環境中,我們更能意識到時間正在流逝,所以問題也更顯眼。
視窗的型別
明確了如何確定事件時間戳之後,下一步就是決定如何定義時間視窗。視窗可用於聚合,例如統計事件數量,或計算視窗內各個值的平均數。以下幾類視窗比較常見64、68:
- 滾動視窗(tumbling window)
滾動視窗的長度固定,每個事件恰好屬於一個視窗。例如,使用一分鐘的滾動視窗時,時間戳介於
10:03:00和10:03:59的所有事件歸入一個視窗,介於10:04:00和10:04:59的事件歸入下一個視窗,依此類推。實現一分鐘滾動視窗時,可以把每個事件的時間戳向下取整到最近的整分鐘,以確定它屬於哪個視窗。- 跳躍視窗(hopping window)
跳躍視窗的長度也固定,但允許視窗重疊,以起到一定的平滑作用。例如,一個長度為五分鐘、跳躍步長為一分鐘的視窗,先包含
10:03:00到10:07:59之間的事件;下一個視窗包含10:04:00到10:08:59之間的事件,依此類推。可以先計算一分鐘的滾動視窗,再聚合相鄰的多個視窗,從而實現這個跳躍視窗。- 滑動視窗(sliding window)
滑動視窗包含彼此間隔不超過某段時間的所有事件。例如,五分鐘的滑動視窗會同時覆蓋發生在
10:03:39和10:08:12的事件,因為兩者相差不到五分鐘。注意,五分鐘的滾動視窗或跳躍視窗使用固定邊界,不一定會把這兩個事件放在同一個視窗中。實現滑動視窗時,可以維護一個按時間排序的事件緩衝區,並在舊事件過期、離開視窗時將其移除。- 會話視窗(session window)
會話視窗與其他視窗不同,沒有固定的持續時間。它把同一使用者在時間上彼此接近的事件歸為一組,並在使用者有一段時間沒有活動後結束視窗(例如,30 分鐘內沒有任何事件)。網站分析經常需要劃分會話。
視窗操作通常需要維護臨時狀態。有些情況下,無論視窗多大、發生多少事件,狀態的大小都是固定的:例如,計數操作不論視窗大小和事件數量如何,都只需要一個計數器。另一方面,滑動視窗以及下一節討論的流連線,都必須緩衝事件,直到視窗結束。因此,視窗很大或流吞吐量很高時,流處理器可能需要儲存大量臨時狀態。無論這些狀態儲存在記憶體還是磁碟上,都必須確保執行流處理任務的機器有足夠容量來容納它們。
流連線
在 “JOIN 與 GROUP BY” 中,我們討論過批處理作業如何按鍵連線資料集,以及這種連線為何是資料流水線的重要組成部分。流處理把資料流水線推廣到對無界資料集的增量處理,因此也同樣需要對流執行連線。
不過,流中隨時可能出現新事件,使流連線比批處理作業中的連線更具挑戰。為了看清這個問題,我們把連線分成三類:流—流連線(stream-stream join)、流—表連線(stream-table join)和 表—表連線(table-table join)。下面各用一個例子來說明。
流流連線(視窗連線)
假設網站提供搜尋功能,而你想發現最近的 URL 搜尋趨勢。每當有人輸入搜尋查詢,就記錄一個包含查詢及返回結果的事件;每當有人點選某項搜尋結果,又記錄一個點選事件。為了計算搜尋結果中每個 URL 的點選率,必須把搜尋行為與點選行為的事件結合起來;它們可以透過相同的會話 ID 關聯。廣告系統也需要類似的分析69。
如果使用者放棄搜尋,點選也許永遠不會發生;即使發生,搜尋與點選之間的間隔也可能相差懸殊:通常只有幾秒,但也可能長達數天或數週——例如使用者搜尋之後忘記了這個瀏覽器標籤頁,過了很久才回來點選某個結果。網路延遲不一,甚至可能讓點選事件先於搜尋事件到達。你可以為連線選擇適當的視窗,例如只連線相隔不超過一小時的搜尋與點選。
請注意,把搜尋詳情嵌入點選事件並不等同於連線兩類事件:這樣只能瞭解使用者點選搜尋結果的情況,卻無法瞭解使用者沒有點選任何結果的搜尋。衡量搜尋質量需要準確的點選率,因此搜尋事件和點選事件缺一不可。
為了實現這類連線,流處理器需要維護 狀態(state),例如按會話 ID 索引過去一小時內的所有事件。每當搜尋事件或點選事件到來,就把它加入相應索引,同時檢查另一個索引,看看相同會話 ID 的另一事件是否已經到達。找到匹配時,發出一個事件,說明哪項搜尋結果被點選;如果搜尋事件過期時仍未看到匹配的點選事件,則發出一個事件,說明哪些搜尋結果沒有被點選。
流表連線(流擴充)
在 “JOIN 與 GROUP BY”(圖 11-2)中,我們見過批處理作業連線兩個資料集的例子:一組使用者活動事件和一個使用者檔案資料庫。很自然地,可以把使用者活動事件視為一股流,在流處理器中持續執行同樣的連線:輸入是包含使用者 ID 的活動事件流,輸出則是活動事件流,其中的使用者 ID 已經補充了相應的使用者檔案資訊。這個過程有時稱為用資料庫中的資訊 擴充(enrich)活動事件。
執行這項連線時,流處理器要逐一檢視活動事件,在資料庫中查詢事件裡的使用者 ID,再把檔案資訊加入活動事件。資料庫查詢可以透過查詢遠端資料庫來實現;不過,正如 “JOIN 與 GROUP BY” 中所討論的,這類遠端查詢很可能速度緩慢,還可能使資料庫過載58。
另一種做法是把資料庫副本載入流處理器,在本地查詢,免去網路往返。由於資料庫的本地副本可能是記憶體雜湊表(如果足夠小),也可能是本地磁碟上的索引,因此這項技術稱為 雜湊連線(hash join)。
它與批處理作業的區別在於:批處理作業把資料庫某個時間點的快照用作輸入;流處理器卻長期執行,而資料庫內容很可能隨時間變化,所以流處理器中的本地副本必須持續更新。變更資料捕獲可以解決這個問題:除了活動事件流,流處理器還可以訂閱使用者檔案資料庫的變更日誌。每當建立或修改檔案時,流處理器就更新本地副本。這樣,我們實際上得到了兩股流之間的連線:活動事件與檔案更新。
流—表連線其實與流—流連線很相似。最大的區別在於,對錶的變更日誌流執行連線時,使用的是一個回溯至“時間開端”的視窗(概念上是無限視窗),其中記錄的新版本會覆蓋舊版本;對另一股輸入流,連線可能根本不維護視窗。
表表連線(維護物化檢視)
考慮 “案例研究:社交網路首頁時間線” 中討論的社交網路時間線。我們說過,使用者檢視主頁時間線時,如果遍歷他所關注的所有人,找出他們最近釋出的帖子再合併,代價實在太高。
我們需要的是時間線快取:為每位使用者準備一個“收件箱”,帖子釋出時便寫入其中,這樣讀取時間線只需查詢一次。物化並維護這個快取,需要處理以下事件:
使用者 u 釋出新帖子時,把帖子加入所有關注 u 的使用者的時間線。
使用者刪除一篇帖子或刪除整個賬戶時,從所有使用者的時間線中移除相應帖子。
使用者 u
1開始關注使用者 u2時,把 u2最近的帖子加入 u1的時間線。使用者 u
1取消關注使用者 u2時,從 u1的時間線中移除 u2的帖子。
要在流處理器中維護這項快取,需要一股帖子事件流(釋出和刪除),以及一股關注關係事件流(關注和取消關注)。流處理器還要維護一個資料庫,記錄每位使用者的關注者集合,以便新帖子到來時知道應該更新哪些時間線。
也可以換個角度來看:這項流處理維護著一個連線兩張表(帖子和關注關係)的查詢物化檢視,大致如下:
流之間的連線直接對應查詢中的表連線。時間線實際上就是查詢結果的快取,每當底層表發生變化時都會更新。
連線的時間依賴性
這裡介紹的三類連線(流—流、流—表和表—表)有許多共同點:流處理器都要根據連線的一側維護某種狀態(搜尋和點選事件、使用者檔案或關注者列表),再在連線另一側的訊息到來時查詢該狀態。
維護狀態的事件順序十分重要——先關注再取消關注,與順序相反的結果不同。在 Kafka 這樣的分片事件日誌中,同一個分片(分割槽)內的事件順序能夠保持,但不同流或不同分片之間通常沒有順序保證。
這就帶來一個問題:如果不同流上的事件發生時間相近,應該按什麼順序處理?在流—表連線的例子中,如果使用者更新了檔案,哪些活動事件應該與舊檔案連線(在檔案更新前處理),哪些應該與新檔案連線(在檔案更新後處理)?換句話說,如果狀態隨時間變化,而你要與這個狀態連線,究竟應該使用哪個時間點的狀態?
這種時間依賴會出現在許多地方。例如,銷售商品時要為發票採用正確的稅率;稅率取決於國家或州、產品型別和銷售日期,因為稅率會不時變化。把銷售記錄與稅率表連線時,通常需要採用銷售發生時的稅率;如果正在重新處理歷史資料,它可能與當前稅率不同。
如果不同流之間的事件順序不確定,連線也會變得不確定70。這意味著,即使針對同一輸入重新執行同一個作業,也不一定得到相同結果:再次執行時,各輸入流上的事件可能以不同方式交錯。
在資料倉儲中,這個問題稱為 緩慢變化維度(slowly changing dimension,SCD),通常為所連線記錄的每個具體版本賦予唯一識別符號來解決。例如,每次稅率變化時,都給新稅率分配一個新識別符號;發票則包含銷售時所用稅率的識別符號71、72。這樣連線就具有確定性,但也無法再做日誌壓實,因為必須保留表中記錄的所有版本。另一種方法是反正規化資料,把適用稅率直接放入每個銷售事件中。
容錯
本章最後一節來看看流處理器如何容忍故障。我們在 第 11 章 中看到,批處理框架相當容易實現容錯:一項任務失敗後,只須在另一臺機器上重新啟動,並丟棄失敗任務的輸出。之所以能透明地重試,是因為輸入檔案不可變,每項任務都把輸出寫入獨立檔案,而且只有任務成功完成後,輸出才會變得可見。
具體來說,批處理的容錯方式可以確保:即使某些任務實際上失敗過,批處理作業的輸出也與沒有發生過任何問題時相同。看起來每條輸入記錄都只處理了恰好一次——沒有記錄被跳過,也沒有記錄被處理兩遍。重新啟動任務意味著記錄實際上可能處理了多次,但輸出中可見的效果卻像只處理了一次。這個原則稱為 恰好一次語義(exactly-once semantics),不過 等效一次(effectively-once)或許是更貼切的叫法73。
流處理也面臨同樣的容錯問題,卻沒那麼容易解決:不能等任務完成後才讓輸出可見,因為流是無限的,永遠也處理不完。
微批處理與檢查點
一種解決辦法是把流分成小塊,把每一塊當作一個微型批處理。這種方法稱為 微批處理(microbatching),Spark Streaming 就採用了它74。批次通常約為一秒,這是效能權衡的結果:批次越小,排程與協調開銷越大;批次越大,流處理器的結果變得可見之前,延遲就越長。
微批處理還隱式提供了一個大小等於批次大小的滾動視窗——它按處理時間而非事件時間戳劃分。需要更大視窗的作業,必須顯式地把狀態從一個微批次傳遞到下一個微批次。
Apache Flink 採用一種變體:定期生成滾動的狀態檢查點,並將其寫入持久儲存75、76。如果流運算元崩潰,可以從最近的檢查點重新啟動,並丟棄從上一個檢查點到崩潰之間產生的所有輸出。檢查點由訊息流中的屏障觸發,類似於微批次之間的邊界,但不會強制規定視窗大小。
在流處理框架內部,微批處理與檢查點都能提供與批處理相同的恰好一次語義。然而,一旦輸出離開流處理器——例如寫入資料庫、向外部訊息代理傳送訊息或傳送電子郵件——框架便無法丟棄失敗微批次的輸出。這時,重啟失敗任務會使外部副作用發生兩次,僅靠微批處理或檢查點不足以避免這個問題。
再談原子提交
為了在發生故障時營造恰好一次處理的效果,必須確保處理事件產生的所有輸出和副作用,當且僅當 處理成功時才生效。這些影響包括:發給下游運算元或外部訊息傳遞系統的訊息(包括電子郵件或推送通知)、資料庫寫入、運算元狀態的變化,以及對輸入訊息的確認應答(包括推進基於日誌的訊息代理中的消費者偏移量)。
這些事情要麼全部以原子方式發生,要麼一件也不發生,不能彼此失去同步。這種做法聽起來似曾相識,是因為我們曾在分散式事務和兩階段提交的語境下討論過它(參閱 “恰好一次訊息處理”)。
我們在 第 10 章 中討論了 XA 等傳統分散式事務實現的問題。不過,在限制更嚴格的環境中,可以高效實現這樣的原子提交機制。Google Cloud Dataflow66、75、VoltDB77 和 Apache Kafka78、79 都採用了這種方法。與 XA 不同,這些實現不會試圖跨異構技術提供事務,而是由流處理框架同時管理狀態變化與訊息傳遞,把事務留在框架內部。還可以在單個事務中處理多條輸入訊息,從而分攤事務協議的開銷。
冪等性
我們的目標是丟棄所有失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方法;另一種方法是依靠 冪等性(idempotence),正如 “持久化執行與工作流” 中所見80。
冪等操作可以執行多次,效果與只執行一次相同。例如,刪除鍵值儲存中的一個鍵是冪等的(再次刪除不會產生進一步影響);遞增計數器卻不是冪等的(再執行一次遞增,值就增加了兩次)。
即使操作本身並非天然冪等,通常也可以用一點額外後設資料讓它變得冪等。例如,消費 Kafka 訊息時,每條訊息都有一個持久、單調遞增的偏移量。向外部資料庫寫入值時,可以把觸發最近一次寫入的訊息偏移量與值一併寫入。這樣便能判斷某項更新是否已經應用,避免再次執行同一更新。
Storm 的 Trident 也用類似思路處理狀態。依靠冪等性包含幾個假設:重啟失敗任務時,必須以相同順序重播相同訊息(基於日誌的訊息代理可以做到);處理過程必須具有確定性;而且不能有其他節點併發更新同一個值81、82。
從一個處理節點故障切換到另一個節點時,可能還需要採用柵欄機制(參閱 “分散式鎖和租約”),以防某個被認為已經死亡、實際仍然存活的節點造成干擾。儘管有這麼多限制,冪等操作仍是實現恰好一次語義的有效方式,而且開銷很小。
失敗後重建狀態
任何需要狀態的流處理過程——例如計數器、平均值、直方圖等視窗聚合,以及連線所用的表和索引——都必須確保發生故障後能夠恢復狀態。
一種選擇是把狀態儲存在遠端資料儲存中並進行復制,不過為每條訊息查詢一次遠端資料庫可能很慢。另一種選擇是把狀態儲存在流處理器本地,再定期複製。這樣,流處理器從故障中恢復時,新任務可以讀取狀態副本,繼續處理而不丟失資料。
例如,Flink 定期捕獲運算元狀態快照,將其寫入分散式檔案系統等持久儲存75、76;Kafka Streams 則把狀態變化傳送到一個啟用了日誌壓實的專用 Kafka 主題,以類似變更資料捕獲的方式複製狀態83。VoltDB 會在多個節點上冗餘處理每條輸入訊息,以此複製狀態(參閱 “實際序列執行”)。
有些情況下,甚至不必複製狀態,因為可以根據輸入流重建。例如,如果狀態只是較短視窗上的聚合,重播該視窗對應的輸入事件也許足夠快。如果狀態是由變更資料捕獲維護的資料庫本地副本,也可以從經過日誌壓實的變更流重建資料庫。
不過,所有這些權衡都取決於底層基礎設施的效能特徵:有些系統的網路延遲可能低於磁碟訪問延遲,網路頻寬也可能與磁碟頻寬相當。不存在適用於所有情況的理想權衡;隨著儲存和網路技術演進,本地狀態與遠端狀態各自的優勢也可能發生變化。
本章小結
本章討論了事件流、它們的用途,以及如何處理它們。從某種意義上說,流處理與 第 11 章 討論的批處理非常相似,只不過流處理持續作用於無界(永不終止)的流,而不是大小固定的輸入84。從這個角度看,訊息代理和事件日誌就是檔案系統的流式對應物。
我們花了一些篇幅比較兩類訊息代理:
- AMQP/JMS 風格的訊息代理
代理把每條訊息分別分配給消費者;消費者成功處理一條訊息後,就對它進行確認應答。訊息得到確認後,代理會將其刪除。這種方法適合用作非同步 RPC(另見 “事件驅動的架構”),例如用於任務佇列:訊息處理的確切順序並不重要,處理完成後也無須返回重讀舊訊息。
- 基於日誌的訊息代理
代理把一個分片中的所有訊息分配給同一個消費者節點,並始終按相同順序傳遞訊息。系統透過分片實現並行;消費者則為已處理的最後一條訊息偏移量建立檢查點,以此跟蹤進度。代理把訊息保留在磁碟上,因此必要時可以跳回去重新讀取舊訊息。
基於日誌的方法與資料庫的複製日誌(參閱 第 6 章)和日誌結構儲存引擎(參閱 第 4 章)有相似之處。正如 第 10 章 所述,它也是共識的一種形式。我們看到,這種方法尤其適合那些消費輸入流,並生成衍生狀態或衍生輸出流的流處理系統。
至於流從何而來,我們討論了幾種可能:使用者活動事件、定期提供讀數的感測器,以及資料來源(例如金融市場資料),都天然適合表示成流。把資料庫寫入視為流同樣很有用:可以透過變更資料捕獲隱式取得變更日誌——即資料庫所有變化的歷史——也可以透過事件溯源顯式取得。日誌壓實讓流可以保留資料庫內容的完整副本。
把資料庫表示成流,為系統整合帶來了強大能力。消費變更日誌並把變化應用到衍生系統,就能讓搜尋索引、快取、分析系統等衍生資料系統持續保持最新。甚至可以從頭開始消費變更日誌,一直追到當前時刻,從而基於現有資料構建全新的檢視。
以流的形式維護狀態、重播訊息的能力,也是各種流處理框架實現流連線和容錯技術的基礎。我們討論了流處理的幾種用途,包括搜尋事件模式(複合事件處理)、計算視窗聚合(流分析),以及讓衍生資料系統保持最新(物化檢視)。
隨後,我們討論了流處理器進行時間推理時的困難,包括處理時間與事件時間戳的區別,以及如何處理那些在視窗本以為已經完成後才姍姍來遲的滯留事件。
我們區分了流處理過程中可能出現的三類連線:
- 流—流連線
兩股輸入流都由活動事件組成,連線運算元會在某個時間視窗內尋找彼此相關的事件。例如,它可以匹配同一使用者在 30 分鐘內執行的兩個操作。如果想尋找同一股流中的相關事件,連線的兩側實際上也可以是同一股流,這稱為 自連線(self-join)。
- 流—表連線
一股輸入流由活動事件組成,另一股則是資料庫變更日誌。變更日誌用來讓資料庫的本地副本保持最新。對於每個活動事件,連線運算元查詢資料庫,並輸出經過擴充的活動事件。
- 表—表連線
兩股輸入流都是資料庫變更日誌。這種情況下,任意一側的每次變化都與另一側的最新狀態連線。結果是兩張表連線所得物化檢視的變更流。
最後,我們討論了流處理器實現容錯和恰好一次語義的技術。與批處理一樣,必須丟棄失敗任務產生的部分輸出。但流處理過程長期執行並持續產生輸出,無法簡單地丟棄所有輸出。因此,需要微批處理、檢查點、事務或冪等寫入等機制,在更細的粒度上進行恢復。
腳註
參考文獻
Tyler Akidau, Robert Bradshaw, Craig Chambers, Slava Chernyak, Rafael J. Fernández-Moctezuma, Reuven Lax, Sam McVeety, Daniel Mills, Frances Perry, Eric Schmidt, and Sam Whittle. The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing. Proceedings of the VLDB Endowment, volume 8, issue 12, pages 1792–1803, August 2015. doi:10.14778/2824032.2824076 ↩︎ ↩︎
Harold Abelson, Gerald Jay Sussman, and Julie Sussman. Structure and Interpretation of Computer Programs, 2nd edition. MIT Press, 1996. ISBN: 978-0-262-51087-5, archived at archive.org/details/sicp_20211010 ↩︎
Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec. The Many Faces of Publish/Subscribe. ACM Computing Surveys, volume 35, issue 2, pages 114–131, June 2003. doi:10.1145/857076.857078 ↩︎
Don Carney, Uğur Çetintemel, Mitch Cherniack, Christian Convey, Sangdon Lee, Greg Seidman, Michael Stonebraker, Nesime Tatbul, and Stan Zdonik. Monitoring Streams – A New Class of Data Management Applications. At 28th International Conference on Very Large Data Bases (VLDB), August 2002. doi:10.1016/B978-155860869-6/50027-5 ↩︎
Matthew Sackman. Pushing Back. wellquite.org, May 2016. Archived at perma.cc/3KCZ-RUFY ↩︎ ↩︎
Thomas Figg (tef). how (not) to write a pipeline. cohost.org, June 2023. Archived at perma.cc/A3V8-NYCM ↩︎
Vicent Martí. Brubeck, a statsd-Compatible Metrics Aggregator. github.blog, June 2015. Archived at perma.cc/TP3Q-DJYM ↩︎
Seth Lowenberger. MoldUDP64 Protocol Specification V 1.00. nasdaqtrader.com, July 2009. Archived at https://perma.cc/7CRQ-QBD7 ↩︎
Ian Malpass. Measure Anything, Measure Everything. codeascraft.com, February 2011. Archived at archive.org ↩︎
Dieter Plaetinck. 25 Graphite, Grafana and statsd Gotchas. grafana.com, March 2016. Archived at perma.cc/3NP3-67U7 ↩︎
Jeff Lindsay. Web Hooks to Revolutionize the Web. progrium.com, May 2007. Archived at perma.cc/BF9U-XNX4 ↩︎
Jim N. Gray. Queues Are Databases. Microsoft Research Technical Report MSR-TR-95-56, December 1995. Archived at arxiv.org ↩︎
Mark Hapner, Rich Burridge, Rahul Sharma, Joseph Fialli, Kate Stout, and Nigel Deakin. JSR-343 Java Message Service (JMS) 2.0 Specification. jms-spec.java.net, March 2013. Archived at perma.cc/E4YG-46TA ↩︎
Sanjay Aiyagari, Matthew Arrott, Mark Atwell, Jason Brome, Alan Conway, Robert Godfrey, Robert Greig, Pieter Hintjens, John O’Hara, Matthias Radestock, Alexis Richardson, Martin Ritchie, Shahrokh Sadjadi, Rafael Schloming, Steven Shaw, Martin Sustrik, Carl Trieloff, Kim van der Riet, and Steve Vinoski. AMQP: Advanced Message Queuing Protocol Specification. Version 0-9-1, November 2008. Archived at perma.cc/6YJJ-GM9X ↩︎
Architectural overview of Pub/Sub. cloud.google.com, 2025. Archived at perma.cc/VWF5-ABP4 ↩︎ ↩︎
Aris Tzoumas. Lessons from scaling PostgreSQL queues to 100k events per second. rudderstack.com, July 2025. Archived at perma.cc/QD8C-VA4Y ↩︎
Robin Moffatt. Kafka Connect Deep Dive – Error Handling and Dead Letter Queues. confluent.io, March 2019. Archived at perma.cc/KQ5A-AB28 ↩︎
Dunith Danushka. Message reprocessing: How to implement the dead letter queue. redpanda.com. Archived at perma.cc/R7UB-WEWF ↩︎
Damien Gasparina, Loic Greffier, and Sebastien Viale. KIP-1034: Dead letter queue in Kafka Streams. cwiki.apache.org, April 2024. Archived at perma.cc/3VXV-QXAN ↩︎
Jay Kreps, Neha Narkhede, and Jun Rao. Kafka: A Distributed Messaging System for Log Processing. At 6th International Workshop on Networking Meets Databases (NetDB), June 2011. Archived at perma.cc/CSW7-TCQ5 ↩︎
Jay Kreps. Benchmarking Apache Kafka: 2 Million Writes Per Second (On Three Cheap Machines). engineering.linkedin.com, April 2014. Archived at archive.org ↩︎
Kartik Paramasivam. How We’re Improving and Advancing Kafka at LinkedIn. engineering.linkedin.com, September 2015. Archived at perma.cc/3S3V-JCYJ ↩︎
Philippe Dobbelaere and Kyumars Sheykh Esmaili. Kafka versus RabbitMQ: A comparative study of two industry reference publish/subscribe implementations. At 11th ACM International Conference on Distributed and Event-based Systems (DEBS), June 2017. doi:10.1145/3093742.3093908 ↩︎
Kate Holterhoff. Why Message Queues Endure: A History. redmonk.com, December 2024. Archived at perma.cc/6DX8-XK4W ↩︎
Andrew Schofield. KIP-932: Queues for Kafka. cwiki.apache.org, May 2023. Archived at perma.cc/LBE4-BEMK ↩︎
Jack Vanlightly. The advantages of queues on logs. jack-vanlightly.com, October 2023. Archived at perma.cc/WJ7V-287K ↩︎
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 ↩︎ ↩︎
Andy Hattemer. Change Data Capture is having a moment. Why? materialize.com, September 2021. Archived at perma.cc/AL37-P53C ↩︎
Prem Santosh Udaya Shankar. Streaming MySQL Tables in Real-Time to Kafka. engineeringblog.yelp.com, August 2016. Archived at perma.cc/5ZR3-2GVV ↩︎
Andreas Andreakis, Ioannis Papapanagiotou. DBLog: A Watermark Based Change-Data-Capture Framework. October 2020. Archived at arxiv.org ↩︎
Jiri Pechanec. Percolator. debezium.io, October 2021. Archived at perma.cc/EQ8E-W6KQ ↩︎
Debezium maintainers. Debezium Connector for Cassandra. debezium.io. Archived at perma.cc/WR6K-EKMD ↩︎
Neha Narkhede. Announcing Kafka Connect: Building Large-Scale Low-Latency Data Pipelines. confluent.io, February 2016. Archived at perma.cc/8WXJ-L6GF ↩︎ ↩︎
Chris Riccomini. Kafka change data capture breaks database encapsulation. cnr.sh, November 2018. Archived at perma.cc/P572-9MKF ↩︎
Gunnar Morling. “Change Data Capture Breaks Encapsulation”. Does it, though? decodable.co, November 2023. Archived at perma.cc/YX2P-WNWR ↩︎
Gunnar Morling. Revisiting the Outbox Pattern. decodable.co, October 2024. Archived at perma.cc/M5ZL-RPS9 ↩︎
Ashish Gupta and Inderpal Singh Mumick. Maintenance of Materialized Views: Problems, Techniques, and Applications. IEEE Data Engineering Bulletin, volume 18, issue 2, pages 3–18, June 1995. Archived at archive.org ↩︎ ↩︎ ↩︎
Mihai Budiu, Tej Chajed, Frank McSherry, Leonid Ryzhyk, Val Tannen. DBSP: Incremental Computation on Streams and Its Applications to Databases. SIGMOD Record, volume 53, issue 1, pages 87–95, March 2024. doi:10.1145/3665252.3665271 ↩︎ ↩︎
Jim Gray and Andreas Reuter. Transaction Processing: Concepts and Techniques. Morgan Kaufmann, 1992. ISBN: 9781558601901 ↩︎
Martin Kleppmann. Accounting for Computer Scientists. martin.kleppmann.com, March 2011. Archived at perma.cc/9EGX-P38N ↩︎
Pat Helland. Immutability Changes Everything. At 7th Biennial Conference on Innovative Data Systems Research (CIDR), January 2015. ↩︎ ↩︎
Martin Kleppmann. Making Sense of Stream Processing. Report, O’Reilly Media, May 2016. Archived at perma.cc/RAY4-JDVX ↩︎
Kartik Paramasivam. Stream Processing Hard Problems – Part 1: Killing Lambda. engineering.linkedin.com, June 2016. Archived at archive.org ↩︎
Stéphane Derosiaux. CQRS: What? Why? How? sderosiaux.medium.com, September 2019. Archived at perma.cc/FZ3U-HVJ4 ↩︎
Baron Schwartz. Immutability, MVCC, and Garbage Collection. xaprb.com, December 2013. Archived at archive.org ↩︎
Daniel Eloff, Slava Akhmechet, Jay Kreps, et al. Re: Turning the Database Inside-out with Apache Samza. Hacker News discussion, news.ycombinator.com, March 2015. Archived at perma.cc/ML9E-JC83 ↩︎
Datomic Documentation: Excision. Cognitect, Inc., docs.datomic.com. Archived at perma.cc/J5QQ-SH32 ↩︎
Fossil Documentation: Deleting Content from Fossil. fossil-scm.org, 2025. Archived at perma.cc/DS23-GTNG ↩︎
Jay Kreps. The irony of distributed systems is that data loss is really easy but deleting data is surprisingly hard. x.com, March 2015. Archived at perma.cc/7RRZ-V7B7 ↩︎
Brent Robinson. Crypto shredding: How it can solve modern data retention challenges. medium.com, January 2019. Archived at https://perma.cc/4LFK-S6XE ↩︎
Matthew D. Green and Ian Miers. Forward Secure Asynchronous Messaging from Puncturable Encryption. At IEEE Symposium on Security and Privacy, May 2015. doi:10.1109/SP.2015.26 ↩︎
David C. Luckham. What’s the Difference Between ESP and CEP? complexevents.com, June 2019. Archived at perma.cc/E7PZ-FDEF ↩︎
Arvind Arasu, Shivnath Babu, and Jennifer Widom. The CQL Continuous Query Language: Semantic Foundations and Query Execution. The VLDB Journal, volume 15, issue 2, pages 121–142, June 2006. doi:10.1007/s00778-004-0147-z ↩︎
Julian Hyde. Data in Flight: How Streaming SQL Technology Can Help Solve the Web 2.0 Data Crunch. ACM Queue, volume 7, issue 11, December 2009. doi:10.1145/1661785.1667562 ↩︎
Philippe Flajolet, Éric Fusy, Olivier Gandouet, and Frédéric Meunier. HyperLogLog: The Analysis of a Near-Optimal Cardinality Estimation Algorithm. At Conference on Analysis of Algorithms (AofA), June 2007. doi:10.46298/dmtcs.3545 ↩︎
Jay Kreps. Questioning the Lambda Architecture. oreilly.com, July 2014. Archived at perma.cc/2WY5-HC8Y ↩︎
Ian Reppel. An Overview of Apache Streaming Technologies. ianreppel.org, March 2016. Archived at perma.cc/BB3E-QJLW ↩︎
Jay Kreps. Why Local State is a Fundamental Primitive in Stream Processing. oreilly.com, July 2014. Archived at perma.cc/P8HU-R5LA ↩︎ ↩︎
RisingWave Labs. Deep Dive Into the RisingWave Stream Processing Engine - Part 2: Computational Model. risingwave.com, November 2023. Archived at perma.cc/LM74-XDEL ↩︎
Frank McSherry, Derek G. Murray, Rebecca Isaacs, and Michael Isard. Differential dataflow. At 6th Biennial Conference on Innovative Data Systems Research (CIDR), January 2013. ↩︎
Andy Hattemer. Incremental Computation in the Database. materialize.com, March 2020. Archived at perma.cc/AL94-YVRN ↩︎
Shay Banon. Percolator. elastic.co, February 2011. Archived at perma.cc/LS5R-4FQX ↩︎
Alan Woodward and Martin Kleppmann. Real-Time Full-Text Search with Luwak and Samza. martin.kleppmann.com, April 2015. Archived at perma.cc/2U92-Q7R4 ↩︎
Tyler Akidau. The World Beyond Batch: Streaming 102. oreilly.com, January 2016. Archived at perma.cc/4XF9-8M2K ↩︎ ↩︎
Stephan Ewen. Streaming Analytics with Apache Flink. At Kafka Summit, April 2016. Archived at perma.cc/QBQ4-F9MR ↩︎
Tyler Akidau, Alex Balikov, Kaya Bekiroğlu, Slava Chernyak, Josh Haberman, Reuven Lax, Sam McVeety, Daniel Mills, Paul Nordstrom, and Sam Whittle. MillWheel: Fault-Tolerant Stream Processing at Internet Scale. Proceedings of the VLDB Endowment, volume 6, issue 11, pages 1033–1044, August 2013. doi:10.14778/2536222.2536229 ↩︎ ↩︎
Alex Dean. Improving Snowplow’s Understanding of Time. snowplow.io, September 2015. Archived at perma.cc/6CT9-Z3Q2 ↩︎
Azure Stream Analytics: Windowing functions. Microsoft Azure Reference, learn.microsoft.com, July 2025. Archived at archive.org ↩︎
Rajagopal Ananthanarayanan, Venkatesh Basker, Sumit Das, Ashish Gupta, Haifeng Jiang, Tianhao Qiu, Alexey Reznichenko, Deomid Ryabkov, Manpreet Singh, and Shivakumar Venkataraman. Photon: Fault-Tolerant and Scalable Joining of Continuous Data Streams. At ACM International Conference on Management of Data (SIGMOD), June 2013. doi:10.1145/2463676.2465272 ↩︎
Ben Kirwin. Doing the Impossible: Exactly-Once Messaging Patterns in Kafka. ben.kirw.in, November 2014. Archived at perma.cc/A5QL-QRX7 ↩︎
Pat Helland. Data on the Outside Versus Data on the Inside. At 2nd Biennial Conference on Innovative Data Systems Research (CIDR), January 2005. ↩︎
Ralph Kimball and Margy Ross. The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling, 3rd edition. John Wiley & Sons, 2013. ISBN: 978-1-118-53080-1 ↩︎
Viktor Klang. I’m coining the phrase ’effectively-once’ for message processing with at-least-once + idempotent operations. x.com, October 2016. Archived at perma.cc/7DT9-TDG2 ↩︎
Matei Zaharia, Tathagata Das, Haoyuan Li, Scott Shenker, and Ion Stoica. Discretized Streams: An Efficient and Fault-Tolerant Model for Stream Processing on Large Clusters. At 4th USENIX Conference in Hot Topics in Cloud Computing (HotCloud), June 2012. ↩︎
Kostas Tzoumas, Stephan Ewen, and Robert Metzger. High-Throughput, Low-Latency, and Exactly-Once Stream Processing with Apache Flink. ververica.com, August 2015. Archived at archive.org ↩︎ ↩︎ ↩︎
Paris Carbone, Gyula Fóra, Stephan Ewen, Seif Haridi, and Kostas Tzoumas. Lightweight Asynchronous Snapshots for Distributed Dataflows. arXiv:1506.08603 [cs.DC], June 2015. ↩︎ ↩︎
Ryan Betts and John Hugg. Fast Data: Smart and at Scale. Report, O’Reilly Media, October 2015. Archived at perma.cc/VQ6S-XQQY ↩︎
Neha Narkhede and Guozhang Wang. Exactly-Once Semantics Are Possible: Here’s How Kafka Does It. confluent.io, June 2019. Archived at perma.cc/Q2AU-Q2ED ↩︎
Jason Gustafson, Flavio Junqueira, Apurva Mehta, Sriram Subramanian, and Guozhang Wang. KIP-98 – Exactly Once Delivery and Transactional Messaging. cwiki.apache.org, November 2016. Archived at perma.cc/95PT-RCTG ↩︎
Pat Helland. Idempotence Is Not a Medical Condition. Communications of the ACM, volume 55, issue 5, page 56, May 2012. doi:10.1145/2160718.2160734 ↩︎
Jay Kreps. Re: Trying to Achieve Deterministic Behavior on Recovery/Rewind. Email to samza-dev mailing list, September 2014. Archived at perma.cc/7DPD-GJNL ↩︎
E. N. (Mootaz) Elnozahy, Lorenzo Alvisi, Yi-Min Wang, and David B. Johnson. A Survey of Rollback-Recovery Protocols in Message-Passing Systems. ACM Computing Surveys, volume 34, issue 3, pages 375–408, September 2002. doi:10.1145/568522.568525 ↩︎
Adam Warski. Kafka Streams – How Does It Fit the Stream Processing Landscape? softwaremill.com, June 2016. Archived at perma.cc/WQ5Q-H2J2 ↩︎
Stephan Ewen, Fabian Hueske, and Xiaowei Jiang. Batch as a Special Case of Streaming and Alibaba’s contribution of Blink. flink.apache.org, February 2019. Archived at perma.cc/A529-SKA9 ↩︎
13 流式系統的哲學

如果一件事物以另一件事物為目的,那麼它的終極目的就不可能只是儲存自身。因此,船長不會把保全受託的船隻當作終極目的,因為船隻另有其目的,也就是航行。
(這段話常被引述為:如果船長的最高目標是保護船隻,他就會讓船永遠停在港口。)
聖托馬斯・阿奎那,《神學大全》(1265—1274)
我們在 第 2 章 中討論了構建 可靠(reliable)、可伸縮(scalable)、可維護(maintainable)的應用與系統這一目標。這些主題貫穿全書各章:例如,我們討論了許多有助於提升可靠性的容錯演算法、提升可伸縮性的分片,以及提升可維護性的演化與抽象機制。
在本章中,我們將把所有這些想法彙集起來,並特別以 第 12 章 的 流處理(stream processing)與 事件驅動架構(event-driven architecture)為基礎,形成一套能夠實現上述目標的應用開發哲學。與前幾章相比,本章的立場更為鮮明:它將深入闡述一種特定的哲學,而不是比較多種不同的方法。
資料整合
本書反覆出現的一個主題是:對於任何給定的問題,往往都有若干種解決方案,而每種方案都有不同的優點、缺點與利弊權衡。例如,在 第 4 章 討論儲存引擎時,我們看到了日誌結構儲存、B 樹和列式儲存;在 第 6 章 討論複製時,我們看到了單主、多主和無主複製。
如果你面臨的問題是“我想儲存一些資料,稍後再把它查出來”,那麼並不存在唯一正確的解決方案;不同的方法各自適用於不同的情形。軟體實現通常必須選擇一種具體的方法。要讓一條程式碼路徑既穩健又有良好的效能,本就已經很難;試圖在一個軟體中包辦一切,幾乎註定會得到糟糕的實現。
因此,選擇哪一種軟體工具最合適,同樣取決於具體情形。每一種軟體——即便是所謂的“通用”資料庫——都是針對某種特定的使用模式設計的。
面對如此繁多的選擇,第一個挑戰便是弄清各種軟體產品分別適合什麼情形。供應商不願告訴你自己的軟體不適合哪些工作負載,這完全可以理解;不過,希望前面的章節已經讓你知道應該提出哪些問題,從而讀懂言外之意,更好地理解其中的權衡。
然而,即使你已經完全掌握了工具及其適用情形之間的對應關係,仍然會遇到另一個挑戰:在複雜應用中,資料往往會以許多不同的方式使用。幾乎不可能有一種軟體適合資料的 所有 使用情形,因此,為了提供應用所需的功能,你不可避免地需要把幾種不同的軟體組合起來。
透過衍生資料組合專用工具
例如,為了支援任意關鍵詞查詢,通常需要把 OLTP 資料庫與全文檢索索引整合起來。儘管某些資料庫(例如 PostgreSQL)內建的全文索引功能足以滿足簡單應用的需要 1,但更複雜的檢索功能仍然需要專業的資訊檢索工具。反過來,搜尋索引通常又不適合作為持久的權威記錄系統,因此許多應用都需要組合使用這兩種工具,才能滿足全部需求。
我們在 “保持系統同步” 中談到過資料系統的整合問題。資料表示越多,整合就越困難。除了資料庫和搜尋索引,你可能還需要在分析系統中儲存資料副本,例如資料倉儲、批處理(batch processing)系統或流處理系統;維護由原始資料衍生而來的快取或反正規化物件;讓資料經過機器學習、分類、排名或推薦系統;或者根據資料變更傳送通知。
理解資料流
為了滿足不同的訪問模式,如果同一份資料的副本需要儲存在多個儲存系統中,你就必須清楚地界定輸入和輸出:資料最先寫到哪裡?哪些表示是從哪些來源衍生出來的?怎樣才能以正確的格式,把資料送到所有正確的位置?
例如,你可以讓資料首先寫入作為權威記錄系統的資料庫,捕獲該資料庫中的變更(參見 “變更資料捕獲”),再按相同順序把這些變更應用到搜尋索引。如果變更資料捕獲(CDC)是更新索引的唯一途徑,你就可以確信索引完全衍生自權威記錄系統,因而與之保持一致(軟體缺陷除外)。在這個系統中,只有寫入資料庫才能提供新的輸入。
如果允許應用同時直接寫入搜尋索引和資料庫,就會引入 圖 12-4 所示的問題:兩個客戶端併發提交相互衝突的寫入,而兩個儲存系統卻以不同的順序處理它們。此時,無論資料庫還是搜尋索引,都不能“說了算”來決定寫入順序;它們可能作出相互矛盾的決定,並從此永久不一致。
如果能夠把所有使用者輸入都匯入一個系統,由它決定全部寫入的順序,那麼只需按相同順序處理這些寫入,就能更容易地衍生出資料的其他表示。這正是我們在 “共識的實踐” 中見過的狀態機複製方法的一種應用。使用變更資料捕獲還是事件溯源日誌並不是最重要的,真正重要的是先確定一個全序。
根據事件日誌更新衍生資料系統,往往可以做到 確定性(determinism)與 冪等性(idempotence;參見 “冪等性”),因而很容易從故障中恢復。
衍生資料與分散式事務
讓不同資料系統彼此保持一致的經典方法是使用分散式事務,如 “兩階段提交(2PC)” 所述。相比之下,使用衍生資料系統的效果如何?
從抽象層面看,兩者以不同手段實現了相似的目標。分散式事務使用鎖來實現互斥,以此決定寫入順序;而 CDC 與事件溯源則用日誌來排序。分散式事務透過原子提交確保變更恰好生效一次;基於日誌的系統通常依靠確定性重試與冪等性。
兩者最大的區別在於:事務系統通常保證一個值寫入後,立即就能讀到它的最新值(參見 “讀己之寫”)。衍生資料系統則往往非同步更新,因此預設並不保證讀操作所見的資料是最新的。
在一些範圍有限、願意承擔分散式事務成本的環境中,分散式事務已得到成功應用。然而,XA 的容錯性和效能都不理想(參見 “跨不同系統的分散式事務”),這嚴重限制了它的實用性。或許可以設計出一種更好的分散式事務協議,但要讓這種協議獲得廣泛採用,並與現有工具整合,將極具挑戰,短期內也不太可能實現。
由於目前還沒有一種得到廣泛支援的優良分散式事務協議,基於日誌的衍生資料是整合不同資料系統最有前景的方法。不過,讀己之寫等保證確實很有用;一味告訴所有人“最終一致性不可避免——接受現實,學會應付吧”,並無助益(至少在沒有妥善說明 如何 應對時是這樣)。
本章稍後將討論一些在非同步衍生系統之上實現更強保證的方法,努力在分散式事務與基於日誌的非同步系統之間找到一條中間道路。
全序的侷限
對於規模足夠小的系統,構建全序事件日誌完全可行(採用單主複製的資料庫如此流行,便證明了這一點:它們構建的正是這樣的日誌)。不過,隨著系統擴充套件到更大、更複雜的工作負載,侷限便開始顯現:
在大多數情況下,構建全序日誌要求所有事件都經過 單個領導者節點,由它決定順序。如果事件吞吐量超出單臺機器的處理能力,就需要把日誌分片到多臺機器上。此時,兩個不同分片中的事件孰先孰後便不再明確。
如果伺服器分佈在多個 地理上分散 的區域——例如,為了容忍整個資料中心離線——通常會在每個資料中心分別設定領導者,因為網路延遲會使跨資料中心的同步協調效率低下。這意味著,來自兩個不同資料中心的事件之間沒有確定的順序。
當應用以 微服務 形式部署時,一種常見的設計選擇是把每項服務及其持久狀態作為獨立單元部署,不讓服務之間共享持久狀態。當兩個事件分別產生於不同服務時,它們之間沒有確定的順序。
一些應用會在客戶端維護狀態:使用者輸入後立即更新,不等待伺服器確認,甚至在離線時仍能繼續工作。在這樣的應用中,客戶端與伺服器很可能以不同的順序看到事件。
從形式上說,決定事件全序的問題稱為 全序廣播(total order broadcast),它等價於共識(參見 “共識的多面性”)。大多數共識演算法針對的是單個節點吞吐量足以處理整個事件流的情形,並沒有提供讓多個節點共同分擔事件排序工作的機制。
排序事件以捕獲因果關係
如果事件之間沒有因果聯絡,缺少全序並不是什麼大問題,因為併發事件可以按任意順序排列。有些情形也很容易處理:例如,同一個物件有多次更新時,只要把特定物件 ID 的所有更新都路由到同一個日誌分片,就能為它們建立全序。然而,因果依賴有時會以更隱蔽的方式出現。
例如,考慮一個社交網路服務,以及兩個曾是情侶、但剛剛分手的使用者。其中一人先把另一人移出好友列表,然後向剩餘好友傳送訊息,抱怨自己的前任。這個使用者的本意是,不讓前任看到這條粗魯的訊息,因為訊息是在好友關係解除之後傳送的。
然而,如果一個系統把好友關係狀態和訊息存放在不同的位置,那麼 解除好友 事件與 傳送訊息 事件之間的順序依賴可能會丟失。如果沒有捕捉到這種因果依賴,負責傳送新訊息通知的服務就可能先處理 傳送訊息 事件、後處理 解除好友 事件,從而錯誤地向前任發出通知。
在這個例子中,通知實際上是訊息與好友列表之間的連線,因此它涉及我們之前討論過的連線時序問題(參見 “連線的時間依賴性”)。遺憾的是,這個問題似乎沒有簡單的答案 2、3。一些可能的起點包括:
邏輯時間戳無需協調即可提供全序(參見 “ID 生成器和邏輯時鐘”),因而在全序廣播不可行時或許能派上用場。然而,接收方仍需處理亂序到達的事件,而且系統還必須傳遞額外的後設資料。
如果能用一條日誌事件記錄使用者作出決定前所看到的系統狀態,併為該事件賦予唯一識別符號,那麼後續事件便可引用這個事件 ID,從而記錄因果依賴 4。
衝突解決演算法(參見 “自動解決衝突”)有助於處理以意外順序到達的事件。它們適合用於維護狀態;但如果某個操作會產生外部副作用(例如向使用者傳送通知),這些演算法就無能為力了。
或許未來會出現新的應用開發模式,能夠高效捕捉因果依賴、正確維護衍生狀態,而不必強迫所有事件都經過全序廣播這一瓶頸。
批處理與流處理
資料整合(data integration)的目標,是確保資料以正確的形式出現在所有正確的位置。為此,需要消費輸入,進行轉換、連線、過濾、聚合、模型訓練與評估,最終再寫入適當的輸出。批處理器和流處理器正是實現這一目標的工具。批處理與流處理的輸出是衍生資料集,例如搜尋索引、物化檢視、向使用者展示的推薦結果、聚合指標,等等。
正如我們在 第 11 章 和 第 12 章 中看到的,批處理與流處理有許多共同原則;兩者最根本的區別在於,流處理器處理的是無界資料集,而批處理的輸入大小已知且有限。
維護衍生狀態
批處理帶有很強的函式式風格(即使程式碼並不是用函數語言程式設計語言編寫的):它鼓勵使用確定性的 純函式(pure function),其輸出只取決於輸入,除了明確的輸出之外沒有其他 副作用(side effect);輸入被視為不可變,輸出則只能追加。流處理與之類似,不過它擴充套件了運算元,使其能夠維護受管理且容錯的狀態。
輸入和輸出定義明確的確定性函式,不僅有利於容錯,也能簡化對組織內部資料流的推理 5。無論衍生資料是搜尋索引、統計模型還是快取,都可以把它看成資料管道:從一項事物衍生出另一項事物,讓一個系統的狀態變更經過函式式應用程式碼,再把相應效果施加到衍生系統上。這樣思考大有裨益。
原則上,衍生資料系統也可以同步維護,就像關聯式資料庫在寫入被索引表的同一事務中,同步更新二級索引一樣。然而,非同步恰恰是基於事件日誌的系統能夠保持穩健的原因:系統某一部分的故障可以被侷限在本地;分散式事務則會在任何一個參與者失敗時中止,因而容易把故障擴散到系統其他部分,將其放大。
我們在 “分片與二級索引” 中看到,二級索引常常跨越分片邊界。帶有二級索引的分片系統,要麼需要把寫入傳送到多個分片(按詞項分割槽的索引),要麼需要把讀取傳送到所有分片(按文件分割槽的索引)。如果非同步維護索引,這種跨分片通訊也最為可靠、最具可伸縮性 6。
為應用演化而重新處理資料
維護衍生資料時,批處理和流處理都很有用。流處理可以用很低的延遲把輸入中的變更反映到衍生檢視中;批處理則可以重新處理大量累積的歷史資料,從已有資料集衍生出新的檢視。
尤其是,重新處理現有資料為維護和演化系統、支援新功能與變化後的需求,提供了一種良好機制。如果不能重新處理,模式演化就只能侷限於簡單改動,例如為記錄新增一個新的可選欄位,或者增加一種新的記錄型別。而有了重新處理,就可以把資料集重組為完全不同的模型,從而更好地滿足新需求。
大規模的“模式遷移”也會發生在非計算機系統中。例如,19 世紀英國鐵路建設初期,軌距(兩條鐵軌之間的距離)存在多種相互競爭的標準。為一種軌距建造的列車無法在另一種軌距的軌道上行駛,這限制了鐵路網路可能實現的互聯 7。
1846 年終於確定了統一的標準軌距,其他軌距的線路都必須改造——可怎樣才能在不讓鐵路停運幾個月乃至幾年的情況下完成改造?解決辦法是先增加第三條鐵軌,把線路改成 雙軌距 或 混合軌距。這種改造可以逐步進行;完成之後,兩種軌距的列車都能在同一條線路上行駛,各自使用三條鐵軌中的兩條。最終,所有列車都改用標準軌距後,非標準軌距所用的那條鐵軌便可拆除。
以這種方式“重新處理”既有軌道,並讓新舊版本並行存在,就能在數年時間裡逐步改變軌距。儘管如此,這仍是一項昂貴的工程,所以非標準軌距至今依然存在。例如,舊金山灣區的 BART 系統使用的軌距就不同於美國大多數鐵路。
衍生檢視允許系統 漸進式 演化。如果要重組一個資料集,並不需要突然切換完成遷移。你可以把舊模式和新模式作為底層同一份資料上的兩個獨立衍生檢視,並排維護。隨後,先把少量使用者轉向新檢視,以測試其效能並發現缺陷,同時讓大多數使用者繼續使用舊檢視。之後逐步提高訪問新檢視的使用者比例,最終刪除舊檢視 8、9。
這種漸進遷移的妙處在於:如果出了問題,過程中的每個階段都很容易逆轉,始終有一個可用的系統供你退回。由於不可逆損害的風險降低,你可以更有信心地繼續推進,從而更快地改進系統 10。
統一批處理與流處理
統一批處理與流處理的一項早期提案是 Lambda 架構 11。它存在不少問題 12,如今已很少使用。較新的系統允許在同一系統中實現批計算(重新處理歷史資料)與流計算(在事件到達時處理事件)13;這種方法有時稱為 Kappa 架構 12。
要在一個系統中統一批處理與流處理,需要具備以下功能:
能夠讓歷史事件透過處理近期事件流的同一個處理引擎重放。例如,基於日誌的訊息代理可以重放訊息,一些流處理器也能從分散式檔案系統或物件儲存中讀取輸入。
為流處理器提供 恰好一次語義(exactly-once semantics)——也就是說,即使實際發生了故障,也要確保輸出與從未發生故障時相同。與批處理一樣,這要求丟棄所有失敗任務的部分輸出。
提供按事件時間而非處理時間劃分視窗的工具,因為重新處理歷史事件時,處理時間沒有任何意義。例如,Apache Beam 提供了表達這類計算的 API,之後可以用 Apache Flink 或 Google Cloud Dataflow 來執行。
分拆資料庫
在最抽象的層面上,資料庫、批處理器、流處理器和作業系統執行著相同的功能:儲存一些資料,並允許你處理和查詢這些資料 14、15。資料庫把資料儲存為某種資料模型中的記錄(表中的行、文件、圖中的頂點等),作業系統的檔案系統則把資料儲存在檔案中——但二者本質上都是“資訊管理”系統 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 樹索引、雜湊索引、空間索引以及其他索引。在新興的衍生資料系統架構中,這些能力不再作為單個整合資料庫產品的功能來實現,而是由各種不同的軟體提供,執行在不同機器上,並由不同團隊管理。
這些發展未來會把我們帶向何方?如果從“沒有任何一種資料模型或儲存格式適合所有訪問模式”這一前提出發,那麼仍有兩條途徑,可以把不同的儲存與處理工具組合成一個協調一致的系統:
- 聯合資料庫:統一讀取
可以為各種底層儲存引擎和處理方法提供統一的查詢介面——這種方法稱為 聯合資料庫(federated database)或 多模儲存(polystore)17、18。例如,PostgreSQL 的 外部資料封裝器(foreign data wrapper)功能符合這種模式,Trino、Hoptimator 和 Xorq 等聯合查詢引擎也是如此。需要專用資料模型或查詢介面的應用仍可直接訪問底層儲存引擎;而希望組合不同位置資料的使用者,則可以透過聯合介面輕鬆完成。
聯合查詢介面延續了關係模型的傳統:提供帶有高層查詢語言和優雅語義的單一整合系統,但其實現非常複雜。
- 分拆資料庫:統一寫入
聯合能夠解決跨多個不同系統的只讀查詢問題,卻無法很好地解決這些系統之間的寫入 同步。前面說過,在單個資料庫中,建立一致的索引是一項內建功能。當我們組合多個儲存系統時,同樣需要確保所有資料變更最終到達所有正確的位置,即使發生故障也不例外。讓儲存系統更容易可靠地連線起來(例如透過變更資料捕獲和事件日誌),就像把資料庫的索引維護功能 分拆 出來,使它能夠跨不同技術同步寫入 5、19。
分拆方法遵循 Unix 的傳統:小工具各自做好一件事 20,透過統一的低層 API(管道)通訊,再用更高層的語言(shell)組合起來 14。
讓分拆行得通
聯合與分拆是一枚硬幣的兩面:都是用不同元件組成可靠、可伸縮、可維護的系統。聯合只讀查詢需要把一種資料模型對映到另一種資料模型,這需要一些思考,但歸根結底是個相當容易處理的問題。讓多個儲存系統的寫入保持同步,才是更困難的工程問題,因此這裡將重點討論它。
同步寫入的傳統方法要求跨異構儲存系統使用分散式事務 17,但如前所述,這種方法問題重重。單個儲存系統或流處理系統內部的事務是可行的;然而,當資料跨越不同技術之間的邊界時,帶有冪等寫入的非同步事件日誌要穩健、實用得多。
例如,一些流處理器內部使用分散式事務來實現恰好一次語義,而且效果可以很好。然而,如果一項事務需要涉及由不同團隊編寫的系統(例如從流處理器把資料寫入分散式鍵值儲存或搜尋索引),缺少標準化事務協議就會使整合難上加難。帶有冪等消費者的有序事件日誌是一種簡單得多的抽象,因此更有可能跨異構系統實現 5。
基於日誌的整合有一項巨大優勢:各個元件之間 鬆散耦合(loose coupling)。這種優勢體現在兩個方面:
在系統層面,非同步事件流使整個系統更能抵禦個別元件中斷或效能下降。如果某個消費者速度很慢或發生故障,事件日誌可以緩衝訊息,讓生產者和其他消費者不受影響地繼續執行。故障消費者修復後可以追趕進度,因此不會漏掉任何資料,而故障也被限制在區域性。相比之下,分散式事務中的同步互動往往會把區域性故障升級為大規模失效。
在人員層面,分拆資料系統使不同團隊可以彼此獨立地開發、改進和維護不同的軟體元件與服務。專業化讓每個團隊都能專心做好一件事,並透過定義明確的介面與其他團隊的系統互動。事件日誌提供的介面既足夠強大,可以表達相當強的一致性屬性(得益於事件的永續性與順序),又足夠通用,幾乎適用於任何種類的資料。
分拆式系統與整合式系統
即使分拆確實成為未來的方向,它也不會取代現有形態的資料庫——人們仍會一如既往地需要資料庫。流處理器需要資料庫來維護狀態,批處理器與流處理器的輸出也需要資料庫來提供查詢服務。專用查詢引擎對特定工作負載仍然十分重要:例如,資料倉儲中的查詢引擎針對探索式分析查詢進行了最佳化,很擅長處理這類工作負載。
執行多種基礎設施所帶來的複雜性可能是個問題:每種軟體都有學習曲線、配置問題和運維怪癖,因此值得儘量減少部署中的活動部件。對於其設計所針對的工作負載,與用應用程式碼把多個工具拼接起來的系統相比,單一整合軟體產品也可能提供更好、更可預測的效能 21。為並不需要的規模構建系統只是徒費力氣,還可能把你鎖定在僵化的設計中;這實際上是一種過早最佳化。
分拆的目標並不是在特定工作負載的效能上與單個資料庫競爭;它的目標是讓你能夠組合多個不同的資料庫,從而在遠比單一軟體所能覆蓋的工作負載範圍內取得良好效能。它追求的是廣度,而不是深度。
因此,如果有一種技術能夠滿足你的全部需求,最好的選擇很可能就是直接使用該產品,而不是試圖用更低層的元件自行重新實現。只有當沒有任何單一軟體能夠滿足全部需求時,分拆與組合的優勢才會顯現。
用於組合資料系統的工具正在不斷完善:Debezium 可以從許多資料庫中抽取變更流;Kafka 協議正在成為事件流事實上的標準;增量檢視維護引擎(參見 “增量檢視維護”)則讓複雜查詢的快取可以預先計算並持續更新。
圍繞資料流設計應用
底層資料一旦變化便更新衍生資料,這個基本想法並不新鮮。例如,電子表格早已有強大的資料流程式設計能力 22:你可以在一個單元格中放入公式(比如求另一列單元格之和),每當公式的任何輸入發生變化,公式結果都會自動重新計算。這正是我們希望資料系統在系統層面做到的事:資料庫中的一條記錄發生變化時,該記錄的所有索引都應自動更新,所有依賴它的快取檢視或聚合結果也應自動重新整理。你不必操心重新整理過程的技術細節,只需要相信它會正確執行。
因此,大多數資料系統仍有許多地方需要向 1979 年的 VisiCalc 學習 23。與電子表格不同,當今的資料系統必須容錯、可伸縮,並能持久地儲存資料;它們還必須能夠整合不同團隊在不同時間編寫的異構技術,並複用既有庫與服務。指望所有軟體都使用某一種語言、框架或工具開發,並不現實。
本節將進一步展開這些想法,探討如何圍繞資料庫分拆與資料流的理念來構建應用。
應用程式碼作為衍生函式
一個資料集從另一個資料集衍生出來時,需要經過某種轉換函式。例如:
二級索引是一種衍生資料集,其轉換函式很直接:對於基礎表中的每一行或每一份文件,取出要索引的列值或欄位值,再按這些值排序(假設使用按鍵排序的 SSTable 或 B 樹索引)。
全文檢索索引的建立過程,是先應用語言檢測、分詞、詞幹提取或詞形還原、拼寫糾正、同義詞識別等各種自然語言處理函式,再構建用於高效查詢的資料結構(例如倒排索引)。
在機器學習系統中,可以把模型看作是對訓練資料應用各種特徵提取和統計分析函式後得到的衍生物。模型應用於新的輸入資料時,模型輸出衍生自輸入與模型(因此也間接衍生自訓練資料)。
快取通常以使用者介面(UI)將要展示的形式儲存資料聚合結果。因此,填充快取需要知道 UI 引用了哪些欄位;UI 的變更可能要求更新快取填充方式的定義,並重建快取。
二級索引的衍生函式需求極其常見,因此許多資料庫都把它作為核心功能內建其中,只要執行 CREATE INDEX 就能呼叫。對於全文索引,常見語言的基礎語言學功能或許內建於資料庫,但更複雜的功能往往需要針對具體領域調整。在機器學習中,特徵工程出了名地依賴具體應用,常常必須融入應用的使用者互動與部署方式等詳細知識 24。
如果建立衍生資料集的函式不像建立二級索引那樣是標準化的套路,就需要用自定義程式碼處理應用特有的部分。而許多資料庫恰恰難以應付這種自定義程式碼。關聯式資料庫通常支援觸發器、儲存過程和使用者定義函式,可以藉此在資料庫內部執行應用程式碼;但在資料庫設計中,這些能力多少像是事後補上的。
分離應用程式碼與狀態
理論上,資料庫可以像作業系統一樣,成為任意應用程式碼的部署環境。然而實踐證明,資料庫並不適合這個用途。依賴項與包管理、版本控制、滾動升級、可演化性、監控、指標、呼叫網路服務、與外部系統整合——資料庫都無法很好地滿足這些現代應用開發需求。
另一方面,Kubernetes、Docker、Mesos、YARN 等部署和叢集管理工具,正是為執行應用程式碼而專門設計的。它們專注於做好這一件事,因此遠勝於那些只把執行使用者定義函式當作眾多功能之一的資料庫。
今天,大多數 Web 應用都以無狀態服務的形式部署:任意使用者請求都可以路由到任意應用伺服器,伺服器發出響應後便忘掉該請求的一切。這種部署方式很方便,因為伺服器可以隨意增加或移除;不過狀態總得有個去處——通常是資料庫。總體趨勢是把無狀態應用邏輯與狀態管理(資料庫)分開:既不把應用邏輯放進資料庫,也不把持久狀態放進應用 25。函數語言程式設計社群喜歡開玩笑說:“我們信奉 教會(Church) 與 國家(state) 分離。”26
解釋笑話通常會毀掉笑話,不過為了照顧沒聽懂的讀者,這裡還是解釋一下。Church 指數學家阿隆佐・邱奇(Alonzo Church);他創造了 lambda 演算——一種早期計算形式,也是大多數函數語言程式設計語言的基礎。Lambda 演算沒有可變狀態(也就是沒有可以被覆寫的變數),所以也可以說,可變狀態與 Church 的工作彼此分離。
在這種典型的 Web 應用模型中,資料庫充當一種可以透過網路同步訪問的可變共享變數。應用可以讀取和更新這個變數,資料庫則負責將它持久儲存,並提供一定的併發控制與容錯能力。
然而,在大多數程式語言中,你無法訂閱可變變數的變化——只能定期讀取它。與電子表格不同,變數的值發生變化時,讀取者不會收到通知。(你可以在自己的程式碼中實現這種通知,這稱為 觀察者模式,observer pattern,但大多數語言都沒有把這種模式作為內建功能。)
資料庫繼承了這種對待可變資料的被動方式:如果想知道資料庫內容是否發生變化,你通常只能輪詢(即定期重複查詢)。訂閱變更才剛剛開始成為資料庫的一項功能。
資料流:狀態變更與應用程式碼的相互作用
從資料流角度思考應用,意味著要重新協商應用程式碼與狀態管理之間的關係。我們不再把資料庫當作受應用操縱的被動變數,而是更加關注狀態、狀態變更以及處理這些變更的程式碼之間如何相互作用、彼此協作。應用程式碼響應一處的狀態變更,並在另一處觸發狀態變更。
我們已經在變更資料捕獲、Actor 模型、觸發器和增量檢視維護中見過這種想法。分拆資料庫,就是把這一想法應用到主資料庫之外的衍生資料集建立過程:快取、全文檢索索引、機器學習系統或分析系統。為此,我們可以使用流處理與訊息傳遞系統。
維護衍生資料需要以下性質,而基於日誌的訊息代理能夠提供這些性質:
維護衍生資料時,狀態變更的順序往往十分重要(如果多個檢視都衍生自同一事件日誌,它們就必須按相同順序處理事件,才能彼此保持一致)。
容錯必不可少:哪怕只丟失一條訊息,衍生資料集也會與資料來源永久失去同步。訊息傳遞和衍生狀態更新都必須可靠。
穩定的訊息順序和容錯的訊息處理要求相當嚴格,但它們的開銷比分散式事務小得多,運維上也更加穩健。現代流處理器可以大規模提供這些順序與可靠性保證,並允許應用程式碼作為流運算元執行。
這類應用程式碼可以執行任意處理,補足資料庫內建衍生函式通常不具備的能力。就像用管道串聯起來的 Unix 工具一樣,流運算元也可以組合起來,圍繞資料流構建大型系統。每個運算元都以狀態變更流作為輸入,併產生其他狀態變更流作為輸出。
流處理器與服務
目前占主導地位的應用開發風格,是把功能拆分成一組 服務,服務之間透過 REST API 等同步網路請求通訊。與單體應用相比,這種面向服務架構的主要優勢,在於透過鬆散耦合實現組織上的可伸縮性:不同團隊可以分別負責不同服務,從而減少團隊間的協調工作(前提是各項服務能夠獨立部署和更新)。
把流運算元組合成資料流系統,與微服務方法有許多相似之處 27、28。不過,兩者底層的通訊機制截然不同:前者使用單向非同步訊息流,後者使用同步的請求/響應互動。
除了 “事件驅動的架構” 中列出的優勢(例如更好的容錯性),資料流系統還可以獲得優於傳統 REST API 或 RPC 的效能。例如,假設顧客購買一件以一種貨幣定價、卻用另一種貨幣付款的商品。要完成貨幣換算,就需要知道當前匯率。這個操作可以透過兩種方式實現 27、29:
採用微服務方法時,處理購買操作的程式碼很可能查詢匯率服務或資料庫,以獲得某種貨幣的當前匯率。
採用資料流方法時,處理購買操作的程式碼會預先訂閱匯率更新流,並在匯率發生變化時把當前匯率記錄到本地資料庫中。真正處理購買操作時,只需查詢本地資料庫。
第二種方法用本地資料庫查詢,取代了對另一項服務的同步網路請求(本地資料庫可能就在同一臺機器上,甚至在同一個程序中)。在微服務方法中,也可以把匯率快取在處理購買操作的服務本地,從而避免同步網路請求。然而,為了讓快取保持新鮮,就必須定期輪詢匯率更新,或者訂閱變更流——這恰好就是資料流方法所做的事。
資料流方法不僅更快,而且面對另一項服務發生故障時也更穩健。最快、最可靠的網路請求,就是根本不發出網路請求!RPC 不復存在,取而代之的是購買事件與匯率更新事件之間的流連線。
這種連線依賴於時間:如果日後重新處理購買事件,匯率早已改變。若想重建原始輸出,就必須獲得當初購買時的歷史匯率。無論是查詢服務還是訂閱匯率更新流,都需要處理這種時間依賴性(參見 “連線的時間依賴性”)。
訂閱變更流,而不是等到需要時才查詢當前狀態,使我們更接近電子表格式的計算模型:某項資料一旦變化,所有依賴它的衍生資料都能迅速更新。時間依賴連線等方面仍有許多懸而未決的問題,但圍繞資料流理念構建應用,是一個很有前景、值得探索的方向。
觀察衍生狀態
從抽象層面看,上一節討論的資料流系統提供了一套建立並持續更新衍生資料集(例如搜尋索引、物化檢視和預測模型)的過程。我們把這個過程稱為 寫路徑(write path):每當有資訊寫入系統,它可能經過多輪批處理和流處理,最終所有衍生資料集都會更新,納入這次寫入的資料。圖 13-1 展示了更新搜尋索引的例子。

但你最初為什麼要建立衍生資料集?很可能是為了日後查詢。這就是 讀路徑(read path):處理使用者請求時,從衍生資料集中讀取資料,也許再對結果做些處理,最後構造返回給使用者的響應。
寫路徑和讀路徑合在一起,涵蓋了資料的完整旅程:從收集資料的地方,直至資料被消費的地方(很可能由另一個人消費)。寫路徑是預先計算的那一段——也就是資料一到達便立即完成,不管有沒有人要求檢視。讀路徑則只在有人請求時才發生。如果你熟悉函數語言程式設計語言,或許會發現寫路徑類似於立即求值,讀路徑則類似於惰性求值。
如 圖 13-1 所示,衍生資料集正是寫路徑與讀路徑相遇之處。它代表著寫入時所需工作量與讀取時所需工作量之間的權衡。
物化檢視與快取
全文檢索索引就是一個很好的例子:寫路徑更新索引,讀路徑在索引中檢索關鍵詞。讀寫兩端都要做些工作。寫入需要更新文件中所有詞項的索引條目;讀取需要搜尋查詢中的每個詞,並應用布林邏輯,找出包含查詢中 所有 詞(AND 運算子)的文件,或者包含每個詞的 任一 同義詞(OR 運算子)的文件。
如果沒有索引,搜尋查詢就必須掃描所有文件(類似 grep);文件數量一多,代價便會極其高昂。沒有索引意味著寫路徑上的工作較少(無需更新索引),但讀路徑上的工作會多得多。
反過來,也可以設想預先計算所有可能查詢的搜尋結果。這樣一來,讀路徑的工作就少了:無需進行布林邏輯計算,只要找到相應查詢的結果並返回即可。然而,寫路徑會昂貴得多:可能提出的搜尋查詢集合是無限的(或者至少隨語料庫中的詞項數量呈指數增長),因此不可能預先計算所有搜尋結果。
另一種選擇是,只為一組固定的最常見查詢預先計算搜尋結果,使這些查詢無需訪問索引便能迅速得到響應;不常見的查詢仍由索引處理。這通常稱為常見查詢的 快取(cache),不過也可以稱為物化檢視:一旦出現應當納入某項常見查詢結果的新文件,它就必須隨之更新。
這個例子說明,索引並不是寫路徑與讀路徑之間唯一可能的邊界。既可以快取常見搜尋結果;文件數量較少時,也可以不用索引,進行類似 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。
我們希望構建既可靠又 正確 的應用(也就是即使面對各種故障,程式的語義仍有明確的定義,也能為人理解)。大約四十年來,原子性、隔離性和永續性等事務屬性,一直是構建正確應用的首選工具。然而,這套基礎並不像看起來那樣牢固——弱隔離級別所引發的困惑便是一例(參見 “弱隔離級別”)。
在某些領域,事務已被徹底拋棄,取而代之的是效能與可伸縮性更好、語義卻混亂得多的模型。人們經常談論 一致性,卻很少把它定義清楚。有些人聲稱,為了更高的可用性,我們應該“擁抱弱一致性”,卻說不清這在實踐中究竟意味著什麼。
對於如此重要的主題,我們的理解與工程方法卻出奇地不可靠。例如,要判斷某個應用在特定事務隔離級別或複製配置下執行是否安全,非常困難 40、41。簡單方案在併發度低、沒有故障時往往看似正確,一旦環境要求更高,便會暴露出許多隱蔽缺陷。
例如,Kyle Kingsbury 的 Jepsen 實驗 42 揭示了某些產品宣稱的安全保證,與它們遭遇網路問題和崩潰時的實際行為之間存在巨大差距。即便資料庫等基礎設施產品本身毫無問題,應用程式碼仍須正確使用它們提供的功能;如果配置本就難以理解(弱隔離級別、法定人數配置等都是如此),這個過程很容易出錯。
如果你的應用能夠容忍偶爾以不可預測的方式損壞或丟失資料,事情會簡單得多;或許只要祈求好運,就能勉強應付。反過來,如果你需要更強的正確性保證,可序列化與原子提交是成熟的方法,卻也代價不菲:它們通常只能在單個資料中心內工作(排除了地理分散式架構),還會限制系統能夠達到的規模與容錯能力。
傳統事務方法並不會消失,但要讓應用既正確、又能抵禦故障,事務並不是最終答案。本節將探討在資料流架構語境下思考正確性的幾種方式。
資料庫的端到端原則
應用使用了具有較強安全屬性的資料系統(例如可序列化事務),並不等於它就不會丟失或損壞資料。例如,如果應用缺陷導致它寫入錯誤資料,或者從資料庫刪除資料,可序列化事務也救不了你。這正是支援不可變與僅追加資料的一條理由:如果不讓錯誤程式碼擁有摧毀正確資料的能力,從這類錯誤中恢復就容易得多。
儘管不可變性很有用,但它本身並非萬靈藥。下面來看一個更隱蔽的資料損壞例子。
恰好一次執行操作
在 “容錯” 中,我們見過 恰好一次(或 等效一次)語義。如果處理訊息時出了問題,可以選擇放棄(丟棄訊息,也就是造成資料丟失),也可以重試。選擇重試,就要承擔一種風險:第一次其實已經成功,只是你沒能得知,於是訊息最終被處理兩次。
處理兩次也是一種資料損壞:我們不希望同一項服務向顧客收取兩次費用(多收費),也不希望計數器遞增兩次(誇大指標)。在這裡,恰好一次 是指這樣安排計算:即便某項操作確實由於故障而重試,最終效果也與從未發生故障時相同。前面已經討論過幾種實現方式。
最有效的方法之一,是讓操作變得 冪等:也就是無論執行一次還是多次,效果都相同。然而,要把本來並不冪等的操作改造成冪等操作,需要額外投入並謹慎處理:你可能需要維護額外的後設資料(例如曾經更新過某個值的操作 ID 集合),並確保從一個節點故障切換到另一個節點時使用柵欄機制(參見 “分散式鎖和租約”)。
抑制重複
這種需要抑制重複的模式,還會出現在流處理之外的許多地方。例如,TCP 使用資料包的序列號,讓接收方按正確順序排列資料包,並判斷網路中是否有包丟失或重複。丟失的包會被重傳,重複的包則由 TCP 棧移除,之後資料才交給應用。
不過,這種重複抑制只能在單條 TCP 連線的範圍內發揮作用。假設這條 TCP 連線把客戶端連到資料庫,當前正在執行 例 13-1 中的事務。在許多資料庫中,事務與客戶端連線繫結(如果客戶端傳送多條查詢,資料庫之所以知道它們屬於同一事務,是因為它們都從同一條 TCP 連線發來)。如果客戶端發出 COMMIT 之後、尚未收到資料庫伺服器的回應之前遭遇網路中斷與連線超時,它便無從知道事務究竟已經提交還是已經中止(圖 9-1)。
例 13-1. 從一個賬戶向另一個賬戶非冪等地轉賬
客戶端可以重新連線資料庫並重試事務,但這已經超出 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 抑制重複請求
例 13-2 依賴 request_id 列上的唯一性約束。如果事務試圖插入一個已經存在的 ID,INSERT 就會失敗,事務隨之中止,避免再次生效。即便隔離級別較弱,關聯式資料庫通常也能正確維護唯一性約束(而應用層面的“先檢查再插入”在非可序列化隔離下可能失效,如 “寫偏差與幻讀” 所述)。
除了抑制重複請求,例 13-2 中的 requests 表還充當了一種事件日誌,可用於事件溯源或變更資料捕獲。賬戶餘額的更新其實不必與插入事件發生在同一事務中,因為它們是冗餘狀態,可以由下游消費者從請求事件衍生出來——只要事件得到恰好一次處理,而這一點同樣可以藉助請求 ID 強制保證。
端到端原則
抑制重複事務的情形,只是一個更普遍原則的例子。這個原則稱為 端到端原則(end-to-end argument),由 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:
每個使用者名稱請求都被編碼成一條訊息,並追加到由使用者名稱雜湊值決定的分片。
流處理器依次讀取日誌中的請求,用本地資料庫記錄哪些使用者名稱已經被佔用。每當有請求申請一個可用使用者名稱,處理器就把該名稱記為已佔用,並向輸出流發出成功訊息;每當請求申請一個已經佔用的使用者名稱,處理器就向輸出流發出拒絕訊息。
請求使用者名稱的客戶端觀察輸出流,等待與自身請求對應的成功或拒絕訊息。
這一演算法與我們在 第 10 章 中見過的、使用共享日誌實現共識的構造相同。只要增加分片數量,就能輕鬆擴充套件到很高的請求吞吐量,因為每個分片都可以獨立處理。
這種方法不僅適用於唯一性約束,也適用於許多其他約束。其基本原則是:任何可能衝突的寫入都路由到同一個分片,依次處理。衝突的定義可能取決於具體應用,但流處理器可以用任意邏輯來驗證請求。
多分片請求處理
當一項操作涉及多個分片時,要讓它在滿足約束的同時原子執行,問題就更有意思了。例 13-2 可能涉及三個分片:儲存請求 ID 的分片、儲存收款方賬戶的分片,以及儲存付款方賬戶的分片。這三者彼此獨立,沒有理由一定落在同一個分片中。
採用傳統資料庫方法時,執行這項事務需要跨三個分片原子提交;這實質上迫使它與這三個分片中任何一個分片上的其他所有事務形成全序。既然出現了跨分片協調,各分片便無法再獨立處理,吞吐量很可能因此受損。
然而,藉助分片日誌與流處理器,無需跨分片事務也能實現等價的正確性。圖 13-2 展示了一項付款事務:先檢查源賬戶餘額是否充足;若餘額充足,則在扣除手續費的同時,把一筆金額原子地轉入目標賬戶。具體過程如下 47:

使用者客戶端為從源賬戶向目標賬戶轉賬的請求賦予唯一請求 ID,再根據源賬戶 ID,把請求追加到相應日誌分片。
流處理器讀取請求日誌,並維護一個資料庫,其中儲存源賬戶的狀態以及已經處理過的請求 ID。這個資料庫的內容完全衍生自日誌。當流處理器遇到一個從未見過的請求 ID 時,它會在本地資料庫中檢查源賬戶餘額是否足以完成轉賬。
如果餘額充足,處理器就在本地資料庫中更新源賬戶狀態,預留付款金額,並向另外幾條日誌發出事件:向源賬戶的日誌分片(也就是處理器自己的輸入日誌)發出一條出賬事件,向目標賬戶的日誌分片發出一條入賬事件,再向手續費賬戶的日誌分片發出一條入賬事件。發出的這些事件都包含原始請求 ID。
出賬事件最終會回到源賬戶處理器(其間可能已經收到一些不相干的事件)。流處理器根據請求 ID 認出,這是先前已經預留的一筆付款,於是現在執行付款,再次更新本地儲存的源賬戶狀態。它會根據請求 ID 忽略重複事件。
目標賬戶與手續費賬戶的日誌分片分別由獨立的流處理任務消費。它們收到入賬事件後,會更新各自的本地狀態以反映這筆款項,並根據請求 ID 對事件去重。
圖 13-2 把三個賬戶畫在三個不同分片中,但它們也完全可以位於同一個分片——這無關緊要。我們只需要保證:給定賬戶的所有事件都嚴格按照日誌順序處理,並採用至少一次語義,而且流處理器是確定性的。
例如,設想源賬戶處理器在處理付款請求時崩潰。崩潰之前,輸出訊息可能已經發出,也可能尚未發出。處理器從崩潰中恢復後,會再次處理同一個請求(因為採用至少一次語義);由於處理器具有確定性,它仍會對是否允許付款作出相同決定。因此,它會向出賬、入賬和手續費賬戶分片發出帶有相同請求 ID 的同一批輸出訊息。如果這些訊息是重複的,下游消費者會根據請求 ID 將其忽略。
這個系統的原子性不來自任何事務,而來自把初始請求事件寫入源賬戶日誌這一原子操作。一旦那條事件進入日誌,所有下游事件最終也都會寫入——可能要等流處理器從崩潰中恢復,可能還會出現重複,但它們終究會出現。
如果採用恰好一次語義,這個例子實現起來會更容易,因為它能確保流處理器的本地狀態與已經處理的訊息集合保持一致。因此,如果處理器崩潰並重新處理某些訊息,它的本地狀態也會重置到處理這些訊息之前的狀態。
如果 圖 13-2 中的使用者想知道轉賬是否獲批,可以訂閱源賬戶的日誌分片,等待出賬事件。如果希望在餘額不足時明確通知使用者,流處理器可以向該日誌分片發出一條“付款被拒”事件。
透過把多分片事務拆成多個採用不同分片方式的階段,並使用端到端請求 ID,我們實現了相同的正確性屬性(每個請求對付款方與收款方賬戶都恰好應用一次):即使發生故障也不例外,而且無需使用原子提交協議。
及時性與完整性
許多事務系統都有一項便利的性質:一個事務提交後,其寫入立刻對其他事務可見。這項性質的形式化名稱是 嚴格可序列化(strict serializability;參見 “線性一致性與可序列化”)。
把一項操作分拆為流處理器的多個階段後,情況卻並非如此:日誌消費者在設計上就是非同步的,所以傳送者不會等待消費者處理完自己的訊息。不過,客戶端仍可以等待某條訊息出現在輸出流上。例如,圖 13-2 中的使用者可以等待出賬事件或付款被拒事件,這取決於源賬戶中是否有足夠資金。
在這個例子中,檢查源賬戶餘額是否正確,並不取決於發出請求的使用者是否等待結果。等待只是為了同步告知使用者付款是否成功,這項通知與處理請求產生的效果彼此解耦。
更一般地說,一致性(consistency)這個術語混合了兩種不同的需求,而它們值得分開考慮:
- 及時性(timeliness)
及時性是指確保使用者觀察到系統的最新狀態。前面看到,如果使用者從陳舊的資料副本中讀取,可能觀察到不一致的系統狀態(參見 “複製延遲的問題”)。不過,這種不一致只是暫時的,只需等待並重試,最終便會消失。
CAP 定理中的一致性指線性一致性,這是實現及時性的一種強保證。寫後讀一致性(read-after-write consistency)等較弱的及時性屬性同樣有用。
- 完整性(integrity)
完整性是指沒有損壞:既不丟失資料,也沒有相互矛盾或虛假的資料。尤其是,如果某個衍生資料集作為底層資料之上的檢視來維護,衍生過程必須正確。例如,資料庫索引必須準確反映資料庫內容——漏掉某些記錄的索引沒什麼用。
如果完整性遭到破壞,不一致就是永久的:大多數情況下,等待和重試無法修復資料庫損壞,必須明確進行檢查和修復。在 ACID 事務語境中,“一致性”通常被理解為某種應用特有的完整性概念。原子性與永續性是維護完整性的重要工具。
用一句口號來說:違反及時性叫“最終一致”,違反完整性則叫“永遠不一致”。
在大多數應用中,完整性都比及時性重要得多。及時性遭到破壞會令人煩惱和困惑,完整性遭到破壞卻可能帶來災難。
例如,信用卡賬單上沒有出現過去 24 小時內完成的一筆交易,並不會令人意外——這類系統存在一定延遲很正常。我們知道銀行會非同步對賬和結算交易,因此這裡的及時性並不重要 3。然而,如果賬單餘額不等於交易總額加上上期賬單餘額(求和出錯),或者一筆交易向你扣了款、商戶卻沒有收到錢(資金憑空消失),問題就非常嚴重。這些都是對系統完整性的破壞。
資料流系統的正確性
ACID 事務通常同時提供及時性保證(例如線性一致性)和完整性保證(例如原子提交)。因此,如果從 ACID 事務的角度看待應用正確性,及時性與完整性之間的區別便無關緊要。
另一方面,本章討論的基於事件的資料流系統有一項有趣性質:它們把及時性與完整性解耦了。非同步處理事件流時,除非明確構建消費者,讓它等到訊息到達後才返回,否則就沒有及時性保證。例如,使用者可以請求一筆付款,隨後在流處理器執行該請求之前讀取自己的賬戶狀態;此時,使用者看不到剛剛請求的付款。
然而,完整性事實上是流式系統的核心。恰好一次(exactly-once)或 等效一次(effectively-once)語義就是維護完整性的一種機制。事件丟失或生效兩次,都可能破壞資料系統的完整性。因此,面對故障時,容錯訊息傳遞與重複抑制(例如冪等操作)是維護資料系統完整性的關鍵。
正如上一節所見,可靠的流處理系統無需分散式事務與原子提交協議也能保持完整性。這意味著它們有望實現同等程度的正確性,同時獲得好得多的效能與運維穩健性。我們透過組合以下機制實現了這種完整性:
把寫入操作的內容表示成一條訊息,使其能夠輕鬆原子寫入——這種方式非常適合事件溯源
使用確定性衍生函式,從這一條訊息衍生出所有其他狀態更新,類似於儲存過程
讓客戶端生成的請求 ID 貫穿所有處理層次,從而實現端到端重複抑制與冪等性
讓訊息不可變,並允許不時重新處理衍生資料,從而更容易從缺陷中恢復
寬鬆解釋約束
如前所述,強制唯一性約束需要共識,通常透過讓某個分片中的所有事件都匯入單個節點來實現。如果希望採用傳統形式的唯一性約束,這項限制便無法避免,流處理也繞不過去。
不過還要意識到:在許多真實應用中,業務需求其實允許違反那些看似硬性約束的規則:
如果顧客訂購的商品超過倉庫庫存,可以補訂貨物,為延誤向顧客道歉,並提供折扣。其實,即便只是叉車碾壞了倉庫裡的部分商品,導致實際庫存少於預期,你也必須這樣處理 3。因此,為了應對叉車事故,道歉工作流本來就必須納入業務流程;對庫存數量設定硬約束或許沒有必要。
同樣,許多航空公司會超賣機票,因為預計有些乘客會誤機;許多酒店也會超賣客房,因為預計有些客人會取消預訂。在這些情形中,企業出於業務考慮故意違反“一座一人”的約束,並設定補償流程(退款、升級、在附近酒店免費提供房間),處理需求超出供給的情況。即便沒有超賣,也需要道歉與補償流程,以應對惡劣天氣或員工罷工造成的航班取消——從這類問題中恢復本來就是正常業務的一部分 3。
如果有人取出的金額超過賬戶餘額,銀行可以收取透支費,並要求對方償還欠款。只要限制每日提款總額,銀行承擔的風險就有上限。
在跨組織整合資料的系統中,不一致不可避免,因此必須有修正機制來處理它們。正如 “批處理用例” 所指出的,銀行之間的付款結算就是一個例子。
因此,在許多業務場景中,暫時違反約束、稍後再透過道歉修正,是可以接受的。這種用於糾正錯誤的變更稱為 補償性事務(compensating transaction)48、49。道歉的代價各不相同(金錢或聲譽上的代價),卻往往很低:已經發出的電子郵件無法撤回,但可以再發一封郵件更正;信用卡不慎扣款兩次,可以退回其中一筆,代價只是手續費,或許再加上一項顧客投訴。ATM 一旦吐出現金,確實無法直接收回;但原則上,如果賬戶已經透支而顧客拒絕還款,可以派催收人員追回欠款。
道歉的代價能否接受,是一項業務決策。如果能夠接受,那麼“寫入資料前先檢查全部約束”的傳統模型就限制過多。完全可以先樂觀地執行寫入,再事後檢查約束。對於那些一旦出錯便很難挽回的事情,仍可以確保在執行前完成驗證;但這並不意味著,連資料寫入之前也必須先做驗證。
這些應用 確實 需要完整性:誰也不希望預訂記錄丟失,或因借貸不匹配而讓資金憑空消失。但是,它們在強制約束時 並不需要 及時性:如果售出的商品超過倉庫庫存,事後道歉並補救即可。這與我們在 “處理寫入衝突” 中討論的衝突解決方法相似。
避免協調的資料系統
我們現在已經做了兩個有趣的觀察:
資料流系統無需原子提交、線性一致性或同步的跨分片協調,也能維護衍生資料的完整性保證。
儘管嚴格的唯一性約束需要及時性與協調,許多應用其實可以接受寬鬆約束:只要完整性始終得到維護,約束可以暫時遭到違反,稍後再修復。
把這兩點結合起來就意味著:資料流系統無需協調,便可為許多應用提供資料管理服務,同時仍給出強有力的完整性保證。這種 避免協調(coordination-avoiding)的資料系統極具吸引力:與需要同步協調的系統相比,它們能獲得更好的效能與容錯能力 45。
例如,這類系統可以採用多主配置,分佈在多個資料中心,並在區域之間非同步複製。任何一個資料中心都能獨立於其他資料中心繼續執行,因為不需要跨區域同步協調。這樣的系統只提供較弱的及時性保證——不引入協調就不可能實現線性一致性——卻仍能提供強有力的完整性保證。
在這種情況下,可序列化事務作為維護衍生狀態的一環仍然有用,不過可以把它限制在自己擅長的小範圍內 6。不再需要 XA 等異構分散式事務。仍然可以在確有需要之處引入同步協調(例如在執行無法恢復的操作之前,強制實施嚴格約束);但如果應用中只有一小部分需要協調,就沒必要讓所有部分都付出協調成本 32。
也可以換個角度看協調與約束:它們減少了因不一致而道歉的次數,卻也可能降低系統效能和可用性,從而增加因服務中斷而道歉的次數。道歉次數不可能降到零,但可以根據自身需要尋找最佳平衡點——既不會出現太多不一致,也不會遇到太多可用性問題。
信任但驗證
前面關於正確性、完整性與容錯的所有討論,都建立在一組假設之上:某些事情可能出錯,另一些事情不會。我們把這些假設稱為 系統模型(system model;參見 “系統模型與現實”)。例如,我們應當假設程序可能崩潰、機器可能突然斷電、網路可能任意延遲或丟棄訊息;但也可能假設,寫入磁碟的資料經過 fsync 後不會丟失、記憶體中的資料不會損壞、CPU 的乘法指令總能返回正確結果。
這些假設相當合理,因為絕大多數時候它們都成立;如果必須時刻擔心計算機會算錯,我們將寸步難行。傳統系統模型以二元方式看待故障:假設有些事情可能發生,另一些事情絕不可能發生。現實卻更像是機率問題:有些事情更常見,有些事情較少見。真正的問題是,違反假設的情況是否頻繁到我們會在實踐中遇見。
我們已經看到,資料可能在記憶體中損壞(參見 “硬體與軟體故障”),可能在磁碟上損壞(參見 “複製與永續性”),也可能在網路中損壞(參見 “弱形式的撒謊”)。或許我們應該更加重視這一點?當系統規模足夠大,再小機率的事情也會發生。
面對軟體缺陷時維護完整性
除了這類硬體問題,軟體缺陷也始終是一項風險,而低層的網路、記憶體或檔案系統校驗和捕捉不到它們。即便廣泛使用的資料庫軟體也存在缺陷:例如,過去某些版本的 MySQL 未能正確維護唯一性約束 50,PostgreSQL 的可序列化隔離級別過去也曾出現寫偏差異常 51。MySQL 與 PostgreSQL 都是穩健且口碑良好的資料庫,經過許多人多年的實戰檢驗;不夠成熟的軟體,情況很可能糟得多。
儘管人們投入大量精力仔細設計、測試和審查,缺陷仍會悄然混入。它們雖然罕見,最終也會被發現和修復,卻仍然存在一段可能損壞資料的視窗期。
至於應用程式碼,我們必須假設其中有更多缺陷,因為大多數應用接受的審查與測試,遠遠不及資料庫程式碼。許多應用甚至沒有正確使用資料庫提供的完整性維護功能,例如外來鍵或唯一性約束 25。
ACID 意義上的一致性,建立在這樣一種想法之上:資料庫從一致狀態開始,事務再把它從一個一致狀態轉變為另一個一致狀態。因此,我們期望資料庫始終處於一致狀態。然而,只有假設事務沒有缺陷,這種說法才有意義。如果應用以某種錯誤方式使用資料庫——例如不安全地採用弱隔離級別——資料庫的完整性便無法保證。
不要盲信承諾
硬體和軟體都不總能達到理想狀態,因此資料損壞遲早似乎不可避免。至少,我們應該有辦法發現資料已經損壞,從而修復它,並努力追查錯誤來源。檢查資料完整性的過程稱為 審計(auditing)。
正如 “不可變事件的優點” 所述,審計並不只適用於財務應用。不過,可審計性在金融領域格外重要,恰恰因為人人都知道錯誤難免發生,也都認可能夠發現並修復問題的必要性。
成熟系統同樣傾向於考慮小機率故障的可能性,並管理這種風險。例如,HDFS 和 Amazon S3 等大規模儲存系統不會完全信任磁碟:它們執行後臺程序,不斷回讀檔案、與其他副本比較,並把檔案從一個磁碟移動到另一個磁碟,以降低靜默損壞的風險 52、53。
如果想確認資料仍然存在,就必須真正讀取並檢查。絕大多數時候資料依然完好;但萬一不是,你肯定希望越早發現越好。同理,不時嘗試從備份恢復也很重要——否則,你可能直到資料已經丟失、為時已晚,才發現備份根本無法使用。不要盲信一切都在正常工作。
HDFS 與 S3 仍然必須假設磁碟在絕大多數時候能夠正確工作——這個假設很合理,卻不同於假設磁碟 始終 正確工作。然而,目前採用這種“信任,但要驗證”方式持續自我審計的系統並不多。許多系統假定正確性保證是絕對的,完全沒有為罕見的資料損壞預作安排。未來,我們或許會看到更多 自我驗證(self-validating)或 自我審計(self-auditing)系統:它們不斷檢查自身完整性,而不是依賴盲目信任 54。
為可審計性而設計
如果一個事務修改了資料庫中的多個物件,事後很難看出這項事務究竟意味著什麼。即便捕獲了事務日誌,各個表裡的插入、更新與刪除也未必能清楚說明,為什麼 要執行這些修改。當初決定作出這些修改的應用邏輯呼叫轉瞬即逝,無法重現。
相比之下,基於事件的系統可以提供更好的可審計性。在事件溯源方法中,系統的使用者輸入被表示為一條不可變事件,由此產生的所有狀態更新都衍生自這條事件。衍生過程可以做到確定性與可重複性,因此,用同一版本的衍生程式碼處理同一份事件日誌,便會得到相同的狀態更新。
明確表示資料流,能讓 資料溯源(data provenance)清晰得多,從而使完整性檢查更切實可行。對於事件日誌,可以用雜湊檢查事件儲存是否遭到損壞;對於任何衍生狀態,可以重新執行當初從事件日誌衍生它的批處理器與流處理器,檢查是否得到同樣結果,甚至還可以並行執行一條冗餘的衍生流程。
具有確定性且定義明確的資料流,也讓系統執行過程更容易除錯和追蹤,從而查明系統 為什麼 做了某件事 4、55。如果發生意外,能夠重現導致意外事件的確切情境將極有價值——這是一種時間旅行式除錯能力。
再談端到端原則
如果無法完全相信系統中的每個元件都不會造成損壞——每一件硬體都不出故障,每一段軟體都沒有缺陷——那麼至少必須定期檢查資料完整性。如果不檢查,往往要等損壞造成下游影響、為時已晚時才會發現;那時追查問題將困難得多,代價也高得多。
檢查資料系統完整性,最好採用端到端方式:完整性檢查涵蓋的系統越多,處理流程某個階段的損壞就越不容易逃過檢查。如果能夠檢查整條衍生資料管道端到端的正確性,那麼路徑上的所有磁碟、網路、服務與演算法,也都隱含在檢查範圍之內。
持續進行端到端完整性檢查,會增強你對系統正確性的信心,從而讓你行動得更快 56。審計與自動化測試一樣,提高了迅速發現缺陷的機率,因而降低系統變更或採用新儲存技術造成損害的風險。如果不再害怕作出改變,就能更好地推動應用演化,以滿足不斷變化的需求。
可審計資料系統的工具
目前,很少有資料系統把可審計性當作首要目標。一些應用會實現自己的審計機制,例如把所有變更記錄到獨立的審計表;然而,要保證審計日誌與資料庫狀態的完整性仍然很難。可以藉助硬體安全模組定期簽名,讓事務日誌不可篡改,但這並不能保證一開始進入日誌的就是正確事務。
Bitcoin 或 Ethereum 等區塊鏈,是帶有密碼學一致性檢查的共享僅追加日誌;其中儲存的交易是事件,智慧合約基本上就是流處理器。它們使用的共識協議確保所有節點對同一事件序列達成一致。與 第 10 章 的共識協議不同,區塊鏈具有拜占庭容錯能力:即便某些參與節點的資料已經損壞,系統仍能繼續工作,因為各個副本會不斷互相檢查完整性。
對於大多數應用,區塊鏈的開銷太高,並不實用。不過,其中一些密碼學工具也能用於更輕量的環境。例如,默克爾樹 57 是由雜湊構成的樹,可以高效證明某條記錄出現在某個資料集中(也能證明其他一些事情)。證書透明性 使用經過密碼學驗證的僅追加日誌與默克爾樹,檢查 TLS/SSL 證書的有效性 58、59;它讓每條日誌由單個領導者負責,從而無需共識協議。
未來,證書透明性與分散式賬本所採用的完整性檢查和審計演算法,或許會在一般資料系統中得到更廣泛的應用。要讓它們擁有與不帶密碼學審計的系統相同的可伸縮性,並把效能損失儘可能壓低,還需要投入一些工作;但這些技術仍然值得關注。
本章小結
本章以流處理理念為基礎,討論了設計資料系統的新方法。我們從一個觀察出發:沒有任何一種工具能夠高效服務所有可能的用例,因此應用必然需要組合多種不同的軟體來實現目標。我們討論了如何利用批處理與事件流,讓資料變更在不同系統之間流動,從而解決這一 資料整合 問題。
在這種方法中,某些系統被指定為權威記錄系統,其他資料則透過轉換衍生自它們。這樣,我們就能維護索引、物化檢視、機器學習模型、統計摘要等。衍生與轉換過程保持非同步和鬆散耦合,可以防止一處的問題擴散到系統中無關的部分,從而增強整個系統的穩健性與容錯能力。
把資料流表示為從一個資料集到另一個資料集的轉換,也有助於應用演化:如果要修改某個處理步驟——例如改變索引或快取的結構——只需讓新的轉換程式碼重新處理整個輸入資料集,再次衍生出輸出。同樣,如果出現問題,也可以修復程式碼並重新處理資料來恢復。
這些過程與資料庫內部既有的工作十分相似,因此,我們把資料流應用重新表述為對資料庫元件的 分拆,並透過組合這些鬆散耦合的元件來構建應用。
觀察底層資料的變更,就能更新衍生狀態;下游消費者還可以繼續觀察衍生狀態本身。我們甚至可以讓這種資料流一路抵達顯示資料的終端使用者裝置,從而構建能夠動態更新、反映資料變化,並且離線時仍可工作的使用者介面。
接下來,我們討論了如何保證所有這些處理在發生故障時仍然正確。透過非同步處理事件、使用端到端請求識別符號使操作冪等,並非同步檢查約束,就能以可伸縮的方式實現強完整性保證。客戶端可以等待檢查透過,也可以不等待便繼續執行,但要承擔約束遭到違反、事後必須道歉的風險。這種方法比使用分散式事務的傳統方法更可伸縮、更穩健,也更符合許多業務流程的實際運作方式。
圍繞資料流構建應用並非同步檢查約束,可以避免大部分協調,建立既能維護完整性、又有良好效能的系統,即使處於地理分散式環境或發生故障也不例外。最後,我們簡要討論了如何透過審計驗證資料完整性、發現損壞,並指出區塊鏈所用的技術也與事件驅動系統頗為相似。
腳註
參考文獻
Rachid Belaid. Postgres Full-Text Search is Good Enough! rachbelaid.com, July 2015. Archived at perma.cc/ZVP9-YDCB ↩︎ ↩︎
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. ↩︎ ↩︎
Pat Helland and Dave Campbell. Building on Quicksand. At 4th Biennial Conference on Innovative Data Systems Research (CIDR), January 2009. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Jessica Kerr. Provenance and Causality in Distributed Systems. jessitron.com, September 2016. Archived at perma.cc/DTD2-F8ZM ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎ ↩︎
Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎ ↩︎
Lionel A. Smith. The Broad Gauge Story. Journal of the Monmouthshire Railway Society, Summer 1985. Archived at perma.cc/DDK9-JA6X ↩︎
Jacqueline Xu. Online Migrations at Scale. stripe.com, February 2017. Archived at perma.cc/ZQY2-EAU2 ↩︎
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 ↩︎
Molly Bartlett Dishman and Martin Fowler. Agile Architecture. At O’Reilly Software Architecture Conference, March 2015. ↩︎
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 ↩︎
Jay Kreps. Questioning the Lambda Architecture. oreilly.com, July 2014. Archived at perma.cc/PGH6-XUCH ↩︎ ↩︎
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. ↩︎
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 ↩︎ ↩︎
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 ↩︎
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 ↩︎
Michael Stonebraker. The Case for Polystores. wp.sigmod.org, July 2015. Archived at perma.cc/G7J2-KR45 ↩︎ ↩︎
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 ↩︎
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. ↩︎
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. ↩︎
John Hugg. Winning Now and in the Future: Where Volt Active Data Shines. voltactivedata.com, March 2016. Archived at perma.cc/44MP-3MWM ↩︎
Felienne Hermans. Spreadsheets Are Code. At Code Mesh, November 2015. ↩︎
Dan Bricklin and Bob Frankston. VisiCalc: Information from Its Creators. danbricklin.com. Archived at archive.org ↩︎
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 ↩︎
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 ↩︎ ↩︎
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 ↩︎
Ben Stopford. Microservices in a Streaming World. At QCon London, March 2016. ↩︎ ↩︎
Adam Bellemare. Building Event-Driven Microservices, 2nd Edition. O’Reilly Media, 2025. ↩︎
Christian Posta. Why Microservices Should Be Event Driven: Autonomy vs Authority. blog.christianposta.com, May 2016. Archived at perma.cc/E6N9-3X92 ↩︎
Alex Feyerke. Designing Offline-First Web Apps. alistapart.com, December 2013. Archived at perma.cc/WH7R-S2DS ↩︎
Martin Kleppmann. Turning the Database Inside-out with Apache Samza. at Strange Loop, September 2014. Archived at perma.cc/U6E8-A9MT ↩︎ ↩︎
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 ↩︎ ↩︎
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 ↩︎
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 ↩︎
Frank McSherry. Dataflow as Database. github.com, July 2016. Archived at perma.cc/384D-DUFH ↩︎
Peter Alvaro. I See What You Mean. At Strange Loop, September 2015. ↩︎
Nathan Marz. Trident: A High-Level Abstraction for Realtime Computation. blog.x.com, August 2012. Archived at archive.org ↩︎
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 ↩︎
Charity Majors. The Accidental DBA. charity.wtf, October 2016. Archived at perma.cc/6ANP-ARB6 ↩︎
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 ↩︎
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. ↩︎
Kyle Kingsbury. Jespen: Distributed Systems Safety Research. jepsen.io. ↩︎
Michael Jouravlev. Redirect After Post. theserverside.com, August 2004. Archived at archive.org ↩︎
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 ↩︎ ↩︎
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 ↩︎ ↩︎
Alex Yarmula. Strong Consistency in Manhattan. blog.x.com, March 2016. Archived at archive.org ↩︎
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 ↩︎
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 ↩︎
Hector Garcia-Molina and Kenneth Salem. Sagas. At ACM International Conference on Management of Data (SIGMOD), May 1987. doi:10.1145/38713.38742 ↩︎
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 ↩︎
Gary Fredericks. Postgres Serializability Bug. github.com, September 2015. Archived at perma.cc/N8UP-2822 ↩︎
Xiao Chen. HDFS DataNode Scanners and Disk Checker Explained. blog.cloudera.com, December 2016. Archived at perma.cc/6S36-X98L ↩︎
Daniel Persson. How does Ceph scrubbing work? youtube.com, March 2022. ↩︎
Jay Kreps. Getting Real About Distributed System Reliability. blog.empathybox.com, March 2012. Archived at perma.cc/9B5Q-AEBW ↩︎
Martin Fowler. The LMAX Architecture. martinfowler.com, July 2011. Archived at perma.cc/5AV4-N6RJ ↩︎
Sam Stokes. Move Fast with Confidence. five-eights.com, July 2016. Archived at perma.cc/J8C6-DHXB ↩︎
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 ↩︎
Ben Laurie. Certificate Transparency. ACM Queue, volume 12, issue 8, pages 10-19, August 2014. doi:10.1145/2668152.2668154 ↩︎
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 ↩︎
14 做正確的事情
拿世間的美好、醜陋與殘酷餵養 AI 系統,卻幻想它只會映照美好。
Vinay Uday Prabhu、Abeba Birhane,大規模資料集:計算機視覺的一場皮洛士式勝利?(2020)
在本書的最後一章,讓我們退後一步。全書至此,我們考察了形形色色的資料系統架構,權衡了它們的利弊,也探討了構建可靠、可伸縮、可維護應用的技術。然而,我們一直略過了討論中一個重要而根本的部分,現在該把它補上了。
每個系統都是為某種目的而建;我們採取的每個行動,都會產生預期和非預期的後果。目的也許只是賺錢,但對世界造成的影響可能遠遠超出最初的目的。身為構建這些系統的工程師,我們有責任仔細思考這些後果,並有意識地選擇自己希望生活在怎樣的世界中。
我們常把資料當作抽象事物來談論,但請記住,許多資料集記錄的是人:他們的行為、興趣與身份。我們必須懷著人性與尊重對待這類資料。使用者也是人,人的尊嚴至高無上 1。
軟體開發越來越多地涉及重大的倫理抉擇。ACM《倫理與職業行為準則》等指南可以幫助軟體工程師處理這些問題 2;然而在實踐中,它們很少得到討論、應用和落實。因此,工程師和產品經理有時會輕率地對待隱私,以及產品可能造成的負面後果 3、4。
技術本身並無善惡,關鍵在於如何使用它,以及它會給人帶來什麼影響。搜尋引擎這樣的軟體系統如此,槍支這樣的武器也一樣。軟體工程師不能只關注技術而無視其後果;倫理責任同樣要由我們承擔。倫理思考並不容易,卻重要得不容迴避。
可是,什麼算“好”、什麼算“壞”,並沒有明確的定義;計算領域的大多數人甚至不討論這個問題 5。不同於計算領域的許多概念,倫理學的核心概念沒有固定、確定的精確含義,而是需要解釋,並且這種解釋可能帶有主觀性 6。倫理不是照著清單逐項打勾,確認自己已經合規;它是一個參與式、迭代式的反思過程,需要與相關各方對話,並對結果負責 7。
預測分析
例如,預測分析(predictive analytics)正是人們熱衷於大資料和 AI 的主要原因之一。用資料分析預測天氣或疾病傳播是一回事 8;預測一名已定罪者是否可能再犯、貸款申請人是否可能違約,或保險客戶是否可能提出高額索賠,則是另一回事 9。後一類預測會直接影響個人的生活。
支付網路當然希望阻止欺詐交易,銀行希望避免不良貸款,航空公司希望避免劫機,企業也希望避免僱到能力不足或不可信賴的人。從它們的角度看,錯失商機的代價不大,不良貸款或問題員工造成的損失卻高得多,因此組織自然希望謹慎行事。拿不準時,拒絕總比答應穩妥。
然而,隨著演算法決策越來越普遍,被某種演算法標為高風險的人——無論判斷正確與否——可能接連遭到這樣的拒絕。一個人若被系統性地排除在就業、航空出行、保險、房屋租賃、金融服務以及社會生活的其他關鍵領域之外,其自由會受到極大限制,以至於這種處境被稱為“演算法監獄(algorithmic prison)”10。在尊重人權的國家,刑事司法制度堅持無罪推定;自動化系統卻可能在沒有任何罪證、幾乎無從申訴的情況下,系統性地、任意地剝奪一個人參與社會的機會。
偏見與歧視
演算法作出的決定不一定比人類更好,也不一定更差。每個人都可能心存偏見,即便主動設法克服也不例外;歧視性做法還可能沉澱為文化與制度。人們寄希望於以資料而非人的主觀判斷與直覺為依據,從而作出更公平的決定,也給傳統制度中經常被忽視的人更多機會 11。
開發預測分析和 AI 系統時,我們不只是用軟體規定何時同意、何時拒絕,把人的決策自動化;甚至連規則本身也交給系統從資料中推斷。然而,這些系統學到的模式並不透明:即使資料中確實存在某種相關性,我們也未必知道原因。如果演算法的輸入帶有系統性偏見,系統很可能會學到這種偏見,並在輸出中將其放大 12。
許多國家的 反歧視法(anti-discrimination law)禁止根據族裔、年齡、性別、性取向、殘障或信仰等 受保護特徵(protected characteristic)區別對待他人。個人資料中的其他特徵或許可以分析,但如果它們與受保護特徵相關,又該怎麼辦?例如,在種族隔離的社群中,一個人的郵政編碼,甚至 IP 地址,都可以有力地預測其種族。如此看來,相信演算法能夠以帶有偏見的資料為輸入,卻產出公平公正的結果,實在荒謬 13、14。然而,資料驅動決策的支持者似乎經常預設這種信念;有人諷刺這種態度說,“機器學習就像為偏見洗錢” 15。
預測分析系統不過是在外推過去;如果過去充滿歧視,它們就會固化並放大這種歧視 16。要讓未來比過去更好,需要道德想象力,而這隻有人類才能提供 17。資料與模型應該是我們的工具,而不是我們的主人。
責任與問責
自動化決策引出了 責任(responsibility)與 問責(accountability)問題 17。如果人犯了錯,可以追究其責任,受決定影響的人也可以申訴。演算法同樣會犯錯,但出了問題,誰來負責 18?自動駕駛汽車造成事故,責任由誰承擔?自動信用評分演算法若系統性地歧視某一種族或宗教的人,他們有沒有救濟途徑?如果機器學習系統作出的決定受到司法審查,你能否向法官解釋演算法是怎樣得出這一決定的?任何人都不應把責任推給演算法,藉此逃避問責。
信用評級機構是收集個人資料並據此作出決定的一個早期例子。糟糕的信用評分會給生活帶來困難,但至少信用分通常基於當事人實際借貸歷史中的相關事實,記錄有誤時也能更正——儘管評級機構一般不會讓更正變得容易。相比之下,基於機器學習的評分演算法通常使用範圍廣得多的輸入資料,而且更加不透明,因而很難看清某項決定是如何得出的,也很難判斷某個人是否受到了不公平或歧視性對待 19。
信用分概括的是“你過去表現如何?”,預測分析通常依據的卻是“誰與你相似,以及與你相似的人過去表現如何?”。拿他人的行為來類比一個人,實際上就是在製造刻板印象,例如根據居住地——它往往是種族和社會經濟階層的近似指標——給人歸類。那些被分錯類別的人怎麼辦?而且,如果錯誤資料導致了錯誤決定,當事人幾乎無從尋求救濟 17。
許多資料本質上都是統計性的。這意味著,即使總體機率分佈正確,落到個案上也很可能出錯。例如,假如你所在國家的平均預期壽命是 80 歲,並不意味著你會在 80 歲生日當天去世。僅憑平均值和機率分佈,很難判斷某一個人究竟能活到多大年紀。同樣,預測系統給出的是機率性結果,放到具體個案上完全可能出錯。
盲目相信資料在決策中至高無上,不僅是一種妄想,而且非常危險。隨著資料驅動決策日益普及,我們需要弄清楚如何讓演算法透明並接受問責,如何避免強化既有偏見,以及如何在演算法不可避免地犯錯時加以糾正。
我們還要弄清楚如何防止資料被用來傷害人,並讓它發揮積極作用。例如,資料分析可以揭示人們生活中的財務與社會特徵。一方面,這種能力可用於集中援助與支援,幫助最需要幫助的人;另一方面,掠奪性企業有時也會用它識別弱勢群體,再向他們兜售高息貸款和毫無價值的大學文憑等高風險產品 17、20。
反饋迴圈
即便是推薦系統這類對人們生活的影響沒有那麼直接、深遠的預測應用,也有一些難題必須正視。當服務越來越善於預測使用者想看什麼時,最終可能只向人們展示他們已經認同的觀點,形成滋生刻板印象、錯誤資訊與社會極化的迴音室。我們已經看到了社交媒體迴音室對競選活動的影響。
當預測分析開始左右人們的生活時,自我強化的 反饋迴圈(feedback loop)會造成尤其惡劣的問題。例如,假設僱主用信用分評估求職者。你原本工作能力很強,信用記錄也很好,卻因一場自己無力控制的變故突然陷入財務困境。幾次賬單逾期之後,信用分隨之下降,找到工作的機會也越來越少。失業又把你推向貧困,進一步拉低評分,讓工作變得更加難找 17。這是有毒假設造成的惡性迴圈,卻披著數學嚴謹性與資料客觀性的外衣。
另一個反饋迴圈的例子是:經濟學家發現,德國的加油站引入演算法定價後,市場競爭反而減弱,消費者支付的價格隨之上漲,因為演算法學會了合謀 21。
我們無法總是預見這種反饋迴圈何時出現。不過,只要思考整個系統——不僅包括計算機化的部分,也包括與之互動的人——許多後果仍然可以預判。這種方法稱為 系統思維(systems thinking)22。我們可以嘗試理解,資料分析系統會如何響應不同的行為、結構或特徵。它會鞏固並放大人與人之間既有的差異,例如讓富者愈富、貧者愈貧,還是會努力消除不公?而且,即使懷有最好的初衷,也必須提防意料之外的後果。
隱私與追蹤
預測分析用資料自動作出關於人的決定,隨之帶來種種問題;除此之外,收集資料本身也存在倫理問題。收集資料的組織與資料被收集的人之間,究竟是什麼關係?
如果系統只儲存使用者明確輸入的資料,是因為使用者希望它以某種方式儲存和處理這些資料,那麼系統就是在為使用者提供服務:使用者是客戶。然而,如果系統只是在使用者做其他事情時,順帶追蹤並記錄其活動,雙方的關係就沒有那麼清楚了。服務不再只是照使用者的吩咐行事,而是有了自己的利益,並且這種利益可能與使用者的利益衝突。
對於許多線上服務面向使用者的功能,追蹤行為資料已經越來越重要:追蹤使用者點選了哪些搜尋結果,有助於改進結果排名;推薦“喜歡 X 的人也喜歡 Y”,能幫助使用者發現有趣而有用的事物;A/B 測試和使用者流程分析,則能幫助判斷如何改進使用者介面。這些功能需要在一定程度上追蹤使用者行為,而使用者也能從中受益。
然而,根據公司的商業模式,追蹤往往不會止步於此。如果一項服務靠廣告維持,廣告主才是真正的客戶,使用者的利益便退居其次。追蹤的資料越來越細,分析觸及的範圍越來越廣,資料也會長期保留,以便為營銷目的建立每個人的詳細畫像。
此時,公司與被收集資料的使用者之間,便呈現出一種截然不同的關係。使用者得到免費服務,又被誘導儘量多地參與其中;追蹤使用者主要不是為了服務這個人,而是為了滿足出資維持服務的廣告主的需求。用一個含義更陰暗的詞來描述這種關係再合適不過:監視(surveillance)。
監視
讓我們做一個思想實驗:把 資料 一詞換成 監視,再看看那些常見說法聽起來是否依然悅耳 23。例如:“在我們這個監視驅動的組織中,我們收集實時監視流,並把它們存入監視倉庫。我們的監視科學家運用高階分析和監視處理來獲得新的洞見。”
對於《設計監視密集型應用》這本書而言,這個思想實驗顯得格外尖銳,甚至帶有本書少見的論戰色彩;但要強調這一點,就需要這樣的措辭。在試圖讓軟體“吞噬世界”的過程中 24,我們建成了人類有史以來規模最大的群體監視基礎設施。我們正在迅速走近這樣一個世界:每處有人居住的空間裡,都至少有一個連線網際網路的麥克風——智慧手機、智慧電視、聲控助理裝置、嬰兒監視器,甚至使用雲端語音識別的兒童玩具。許多這類裝置的安全記錄糟糕透頂 25。
與過去相比,新的變化在於數字化使大規模收集個人資料變得輕而易舉。我們的位置與行蹤、社會關係與通訊、購物與支付以及健康狀況,幾乎都無法擺脫監視。實施監視的組織最終可能比當事人自己更瞭解這個人——例如,在當事人尚未察覺時,就發現其疾病或經濟問題。
即使是過去最極權、最壓迫的政權,也只能夢想在每個房間裡裝上麥克風,強迫每個人時刻攜帶能夠追蹤位置和行蹤的裝置。數字技術帶來的好處如此之大,以至於今天的我們竟自願接受了這個全面監視的世界。區別只在於,資料是由企業收集來為我們提供服務,而不是由試圖控制我們的政府機構收集 26。
並非所有資料收集都一定稱得上監視,但從監視的角度審視它,有助於理解我們與資料收集者的關係。為什麼人們似乎樂於接受企業的監視?也許你覺得自己沒什麼可隱瞞——換句話說,你完全順應現有的權力結構,不屬於邊緣化的少數群體,也不必擔心遭受迫害 27。但並非人人都有這樣的幸運。又或許是因為目的看起來無害:不是公然脅迫、逼人服從,只是提供更好的推薦與更個性化的營銷。然而,結合上一節對預測分析的討論,這兩者之間的界線就沒有那麼清楚了。
我們已經看到,汽車在未經駕駛者同意的情況下追蹤駕駛行為,而這些資料又會影響汽車保險費率 28;健康保險的承保條件也可能取決於人們是否佩戴健身追蹤裝置。當監視被用來決定保險、就業等關係人生的重要事項時,它就不再顯得無害。此外,資料分析還能揭示出乎意料的私密資訊:例如,智慧手錶或健身追蹤器中的運動感測器,可以相當準確地推斷你正在輸入什麼,甚至包括密碼 29。感測器會越來越精確,分析演算法也只會越來越強。
同意與選擇自由
我們或許會說,使用者自願選擇使用追蹤其活動的服務,也接受了服務條款與隱私政策,因此已經 同意(consent)收集資料。我們甚至可以聲稱,使用者用自己提供的資料換取了有價值的服務,而追蹤是提供服務所必需的。毫無疑問,社交網路、搜尋引擎以及其他各種免費線上服務的確對使用者很有價值——但這種說法存在問題。
首先應該問清楚,追蹤究竟在哪種意義上不可或缺。有些追蹤的確直接用於改進面向使用者的功能:例如,追蹤搜尋結果的點選率,可以提升搜尋引擎的結果排名與相關性;追蹤顧客經常一起購買的商品,可以幫助網店推薦相關產品。然而,如果追蹤使用者互動是為了推薦內容,或是為廣告建立使用者畫像,就很難說這是否真正符合使用者的利益——還是因為廣告在為服務買單,追蹤才變得“必要”?
其次,使用者幾乎不知道自己把哪些資料送進了我們的資料庫,也不知道這些資料會如何儲存和處理;大多數隱私政策更善於遮掩,而不是說明真相。如果不瞭解自己的資料將被如何處置,使用者便不可能作出有意義的同意。一個使用者的資料往往還會透露其他人的資訊,而這些人既不是服務的使用者,也沒有接受任何條款。我們在本書這一部分討論的衍生資料集,可能把整個使用者群的資料與行為追蹤資料、外部資料來源組合起來;這恰恰是使用者不可能真正理解的資料。
而且,從使用者身上抽取資料是一個單向過程,既不是真正互惠的關係,也不是公平的價值交換。雙方沒有對話,使用者也不能就提供多少資料、換取什麼服務進行協商:服務與使用者之間的關係高度不對稱,完全是一邊倒的。條件由服務制定,而不是由使用者決定 30、31。
歐盟《通用資料保護條例》(GDPR)要求,同意必須是“自願作出、具體、知情且明確無誤的”;使用者還必須能夠“拒絕或撤回同意而不受不利影響”,否則就不能算“自願作出”。任何徵求同意的請求,都必須“採用易於理解、便於獲取的形式,並使用清晰明白的語言”。此外,“沉默、預先勾選的選框或無行動均不構成同意” 32。同意並不是合法處理個人資料的唯一依據;例如,合法利益(legitimate interest)也允許出於防範欺詐等目的使用某些資料 33。
你也許會說,不願接受監視的使用者只要選擇不使用這項服務即可。但這種選擇同樣算不上自由:如果一項服務普及到“被大多數人視為參與基本社會生活所必需” 30,就不能合理地要求人們退出這項服務——使用它實際上已成為強制要求。例如,在多數西方社會中,隨身攜帶智慧手機、透過社交網路與人交往、使用 Google 查詢資訊,都已經成為常態。尤其是在一項服務具有網路效應時,選擇 不 使用它需要付出社會代價。
因為追蹤政策而拒絕使用一項服務,說來容易,做到卻很難。這些平臺就是專門為吸引使用者而設計的;許多平臺還採用遊戲機制和賭博中常見的手法,讓使用者不斷回來 34。即使使用者能夠擺脫這些誘導,拒絕參與也只是少數特權者才有的選擇:他們既有時間和知識去理解隱私政策,也承擔得起可能錯失社會交往或職業機會的代價。對於處境不那麼優越的人,並不存在真正的選擇自由,監視變得無從逃避。
隱私與資料使用
有時人們聲稱“隱私已死”,理由是一些使用者願意把生活中的種種事情釋出到社交媒體上,其中有日常瑣事,也有極其私密的內容。然而,這種說法是錯誤的,源於對 隱私(privacy)一詞的誤解。
擁有隱私並不意味著把一切都藏起來,而是有權自由選擇向誰透露什麼、公開什麼、保密什麼。隱私權是一種決定權:在每一種情境中,它都讓每個人自行決定,要站在從保密到透明這條光譜的什麼位置 30。這是個人自由與自主的重要組成部分。
例如,罹患罕見病的人也許非常樂意把自己的私密醫療資料交給研究人員,只要這些資料有望幫助開發治療方法。關鍵在於,這個人可以選擇誰能訪問資料,以及資料用於什麼目的。如果病情資訊可能妨礙他們獲得醫療保險、就業機會或其他重要權益,他們大概會謹慎得多。
透過監視基礎設施從人們身上抽取資料時,隱私權未必遭到削弱,而是轉移到了資料收集者手中。獲取資料的公司實際上是在說:“相信我們會妥善使用你的資料。”這意味著,決定透露什麼、保密什麼的權利,從個人手中轉到了公司手中。
這些公司反過來又會將大部分監視結果秘而不宣,因為公開真相會令人毛骨悚然,也會損害它們的商業模式——這種模式依靠比其他公司更瞭解人來獲利。使用者的私密資訊只會間接顯露出來,例如以廣告定向工具的形式,供廣告主鎖定某類特定人群,比如患有某種疾病的人。
即使無法從某條廣告所針對的人群中重新識別出某個使用者,他們也已經失去了決定是否披露某些私密資訊的自主權。不再是使用者根據個人意願決定向誰透露什麼,而是由公司行使這項隱私權,目標則是讓利潤最大化。
許多公司追求的,只是不要讓人 覺得 它們令人不安。它們避而不談資料收集究竟有多麼侵犯隱私,只顧管理使用者的觀感。可就連觀感也常常管理得一團糟:例如,某件事在事實上也許沒有錯,但如果會喚起痛苦的回憶,使用者可能根本不願再次看到它 35。對任何資料,我們都應該預料它可能是錯的、不可取的,或者在某些情境下並不合時宜,併為處理這些問題建立機制。何謂“不可取”或“不合時宜”,當然要靠人來判斷;除非我們明確要求演算法尊重人的需要,否則演算法根本不懂這些概念。身為這些系統的工程師,我們必須保持謙遜,承認這類問題可能發生,並預先做好準備。
線上服務的隱私設定允許使用者控制自己的哪些資料可以被其他使用者看見。這只是把部分控制權交還給使用者的起點。無論使用者如何設定,服務本身仍能不受限制地訪問資料,並可按照隱私政策的許可任意使用。即使服務承諾不把資料賣給第三方,通常也會賦予自己不受限制的權利,在內部處理和分析資料,而這些處理往往遠遠超出使用者能夠直接看到的範圍。
如此大規模地把隱私權從個人轉移給企業,在歷史上前所未有 30。監視從來都有,但過去代價高昂、依賴人工,既不能自動化,也無法大規模擴充套件。信任關係也一直存在,例如病人與醫生、被告與律師之間的關係;但在這些情形下,資料的使用受到嚴格的倫理、法律與監管約束。網際網路服務卻讓人們可以在未經有效同意的情況下積累海量敏感資訊,又在使用者不瞭解私密資料去向的情況下,大規模加以利用。
資料資產與權力
行為資料是使用者與服務互動的副產品,因此有時被稱為“資料廢氣”(data exhaust),彷彿這些資料只是毫無價值的廢料。從這個角度看,行為分析和預測分析就像一種回收利用,從本來會被丟棄的資料中提取價值。
更準確的看法也許恰好相反:從經濟角度看,如果定向廣告為服務提供收入,那麼產生行為資料的使用者活動就可以視為一種勞動 36。甚至還可以說,使用者與之互動的應用,不過是引誘使用者不斷把更多個人資訊送入監視基礎設施的手段 30。線上服務所承載的可貴的人類創造力與社會關係,就這樣被資料抽取機器無情地利用。
個人資料是寶貴的資產,資料經紀商的存在就是明證。這是一個行事隱秘、見不得光的行業:它購買、彙總、分析和推斷極具侵擾性的個人資料,再將其轉售,主要用於營銷 20。初創公司的估值往往取決於使用者數和“眼球”數量——也就是它們的監視能力。
資料既然有價值,自然有很多人想得到它。公司當然想要——這正是它們收集資料的原因。政府也想拿到這些資料:透過秘密交易、脅迫、法律強制,或者乾脆竊取 37。一家公司破產時,收集的個人資料也是會被變賣的資產之一。而且,資料很難保護周全,洩露事件頻繁得令人不安。
這些現象使批評者認為,資料不僅是一種資產,更是一種“有毒資產” 37,至少也是“危險物質” 38。資料或許不是什麼“新黃金”或“新石油”,而是“新鈾” 39。即使自信能夠防止資料遭到濫用,每次收集資料時,我們仍需權衡它的好處與落入惡人之手的風險:計算機系統可能被罪犯或敵對國家的情報機構攻破;資料可能被內鬼洩露;公司可能落入不認同我們價值觀、不擇手段的管理層手中;國家也可能被一個強迫我們交出資料時毫無顧忌的政權接管。
收集資料時,我們不能只考慮今天的政治環境,還必須考慮未來所有可能出現的政府。沒有人能保證今後當選的每一屆政府都會尊重人權與公民自由;因此,“部署日後可能助長警察國家的技術,有違健全的公民社會規範” 40。
俗話說,“知識就是力量”。不僅如此,“審視他人而讓自己免受審視,是最重要的權力形式之一” 41。這正是極權政府渴求監視的原因:它賦予政府控制民眾的力量。今天的科技公司雖然沒有公開謀求政治權力,但積累的資料與知識仍然賦予它們左右我們生活的巨大力量;其中很大一部分都在暗中發揮作用,不受公眾監督 42。
回顧工業革命
資料是資訊時代的決定性特徵。網際網路、資料儲存與處理,以及軟體驅動的自動化,正在深刻影響全球經濟與人類社會。資訊科技已經改變了我們的日常生活和社會組織,並且很可能在未來幾十年繼續帶來根本變化,這自然讓人聯想到工業革命 17、26。
工業革命源於技術與農業的重大進步,帶來了持續的經濟增長,長遠來看也顯著提高了生活水平。然而,它同樣伴隨著嚴重問題:煙塵和化工過程造成可怕的空氣汙染,工業與生活廢棄物也嚴重汙染水源。工廠主生活奢華,城市工人卻往往居住在簡陋的房屋中,長時間在惡劣條件下勞動。僱用童工十分普遍,甚至讓兒童在礦井中從事危險而低薪的工作。
人們花了很長時間,才建立起環境保護法規、工作場所安全規程、童工禁令和食品衛生檢查等保障措施。工廠不能再把廢料排入河流、出售受汙染的食品或剝削工人,經營成本無疑會隨之上升。然而,整個社會從這些法規中受益匪淺,今天幾乎沒有人願意回到它們出現之前的年代 17。
正如工業革命有著必須治理的黑暗面,邁向資訊時代的過程也帶來了我們必須正視並解決的重大問題 43、44。資料的收集與使用就是其中之一。用 Bruce Schneier 的話來說 26:
資料是資訊時代的汙染問題,保護隱私則是環境挑戰。幾乎所有計算機都會產生資訊。它堆積在周圍,開始潰爛。如何處理它——如何控制它,又如何處置它——是資訊經濟健康發展的核心。今天,我們回望工業時代最初的幾十年,會疑惑祖先為何在急於建設工業世界時無視汙染;同樣,我們的後代也會回望資訊時代最初的幾十年,並根據我們如何應對資料收集與濫用的挑戰來評價我們。
我們應該努力讓他們感到驕傲。
立法與自律
資料保護法或許能夠幫助維護個人權利。例如,歐盟 GDPR 規定,個人資料必須“為特定、明確且合法的目的而收集,不得以與這些目的不相容的方式作進一步處理”;而且,資料必須“相對於處理目的而言充分、相關,並僅限於必要範圍” 32。
然而,資料最小化(data minimization)原則與大資料的哲學針鋒相對。大資料追求儘可能多地收集資料,將其與其他資料集組合,透過實驗和探索獲得新的洞見。探索意味著把資料用於未曾預料的目的,這恰好與收集資料時必須宣告的“特定、明確”目的相反。GDPR 雖然給線上廣告行業帶來了一些影響 45,執行力度卻一直很弱 46,而且似乎沒有促使整個科技行業的文化與實踐發生多大改變。
收集大量個人資料的公司反對監管,認為它會增加負擔、妨礙創新。這種反對在一定程度上不無道理。例如,共享醫療資料顯然會帶來隱私風險,卻也蘊藏著機會:如果資料分析能幫助我們改進診斷、找到更好的治療方法,可以挽救多少生命 47?監管過度也許會阻礙這類突破。要在潛在機會與風險之間找到平衡並不容易 41。
歸根結底,科技行業對待個人資料的文化必須改變。我們應該停止把使用者視為有待最佳化的指標,記住他們是值得尊重、擁有尊嚴和自主權的人。我們應該自覺約束收集與處理資料的做法,建立並維繫依賴我們軟體的人們對我們的信任 48。我們也有責任讓終端使用者瞭解其資料如何被使用,而不是繼續把他們矇在鼓裡。
我們應該讓每個人保有隱私——也就是對自己資料的控制權——而不是用監視手段把這種控制權奪走。個人控制自身資料的權利,就像國家公園裡的自然環境:如果我們不明確地保護、照料它,它就會遭到破壞。這將成為一場公地悲劇,最終所有人都會受害。無處不在的監視並非不可避免,我們仍然來得及阻止它。
第一步是不要永久保留資料,而要在不再需要時儘快清除,並從一開始就儘量少收集 48、49。手中沒有的資料,就不可能洩露、被盜,也不可能被政府強迫交出。總的來說,我們必須改變文化與態度。身為技術從業者,如果不考慮自己工作的社會影響,就是沒有盡到本職 50。
總結
至此,我們來到了全書的終點。這一路討論了許多內容:
在 第 1 章 中,我們對比了分析型系統與事務型系統、雲服務與自託管、分散式系統與單節點系統,並討論了如何在業務需求與使用者需求之間取得平衡。
在 第 2 章 中,我們介紹了如何定義效能、可靠性、可伸縮性和可維護性等非功能性需求。
在 第 3 章 中,我們考察了關係模型、文件模型、圖模型、事件溯源和資料框等一系列資料模型,還介紹了 SQL、Cypher、SPARQL、Datalog 與 GraphQL 等查詢語言的示例。
在 第 4 章 中,我們討論了用於 OLTP 的儲存引擎(LSM 樹和 B 樹)、用於分析的列式儲存,以及用於資訊檢索的全文索引與向量索引。
在 第 5 章 中,我們考察了將資料物件編碼為位元組的不同方式,以及如何隨著需求變化支援演化。我們還比較了資料透過資料庫、服務呼叫、工作流引擎和事件驅動架構在程序之間流動的幾種方式。
在 第 6 章 中,我們研究了單主複製、多主複製與無主複製之間的權衡,也討論了寫後讀一致性等一致性模型,以及讓客戶端可以離線工作的同步引擎。
在 第 7 章 中,我們深入討論了分片,包括再平衡策略、請求路由和二級索引。
在 第 8 章 中,我們討論了事務的永續性、讀已提交、快照隔離和可序列化等隔離級別的實現方式,以及如何保證分散式事務的原子性。
在 第 9 章 中,我們梳理了分散式系統中的根本問題,包括網路故障與延遲、時鐘誤差、程序暫停和崩潰,並看到這些問題為何讓正確實現一把看似簡單的鎖都變得十分困難。
在 第 10 章 中,我們詳細討論了各種形式的共識,以及共識所實現的一致性模型——線性一致性。
在 第 11 章 中,我們深入研究了批處理,從簡單的 Unix 工具鏈,一直講到使用分散式檔案系統或物件儲存的大規模分散式批處理器。
在 第 12 章 中,我們把批處理推廣為流處理,並討論了底層訊息代理、變更資料捕獲、容錯,以及流連線等處理模式。
在 第 13 章 中,我們探討了一套流式系統的哲學,使彼此不同的資料系統更容易整合、系統更容易演化,應用也更容易伸縮。
最後,在本章中,我們退後一步,審視了構建資料密集型應用所涉及的一些倫理問題。我們看到,資料雖然可以用來行善,也可能造成嚴重傷害:作出深刻影響個人生活、卻難以申訴的決定,導致歧視與剝削,讓監視成為常態,並暴露私密資訊。我們還面臨資料洩露的風險;即使出於善意使用資料,也可能造成意料之外的後果。
軟體和資料正在給世界帶來如此巨大的影響。身為工程師,我們必須牢記,自己有責任為希望生活其中的世界而努力——一個懷著人性與尊重對待每個人的世界。讓我們一起朝著這個目標前進。
腳註
參考文獻
David Schmudde. What If Data Is a Bad Idea?. schmud.de, August 2024. Archived at perma.cc/ZXU5-XMCT ↩︎
ACM Code of Ethics and Professional Conduct. Association for Computing Machinery, acm.org, 2018. Archived at perma.cc/SEA8-CMB8 ↩︎
Igor Perisic. Making Hard Choices: The Quest for Ethics in Machine Learning. linkedin.com, November 2016. Archived at perma.cc/DGF8-KNT7 ↩︎
John Naughton. Algorithm Writers Need a Code of Conduct. theguardian.com, December 2015. Archived at perma.cc/TBG2-3NG6 ↩︎
Ben Green. “Good” isn’t good enough. At NeurIPS Joint Workshop on AI for Social Good, December 2019. Archived at perma.cc/H4LN-7VY3 ↩︎
Deborah G. Johnson and Mario Verdicchio. Ethical AI is Not about AI. Communications of the ACM, volume 66, issue 2, pages 32–34, January 2023. doi:10.1145/3576932 ↩︎
Marc Steen. Ethics as a Participatory and Iterative Process. Communications of the ACM, volume 66, issue 5, pages 27–29, April 2023. doi:10.1145/3550069 ↩︎
Logan Kugler. What Happens When Big Data Blunders? Communications of the ACM, volume 59, issue 6, pages 15–16, June 2016. doi:10.1145/2911975 ↩︎
Miri Zilka. Algorithms and the criminal justice system: promises and challenges in deployment and research. At University of Cambridge Security Seminar Series, March 2023. ↩︎
Bill Davidow. Welcome to Algorithmic Prison. theatlantic.com, February 2014. Archived at archive.org ↩︎
Don Peck. They’re Watching You at Work. theatlantic.com, December 2013. Archived at perma.cc/YR9T-6M38 ↩︎
Leigh Alexander. Is an Algorithm Any Less Racist Than a Human? theguardian.com, August 2016. Archived at perma.cc/XP93-DSVX ↩︎
Jesse Emspak. How a Machine Learns Prejudice. scientificamerican.com, December 2016. perma.cc/R3L5-55E6 ↩︎
Rohit Chopra, Kristen Clarke, Charlotte A. Burrows, and Lina M. Khan. Joint Statement on Enforcement Efforts Against Discrimination and Bias in Automated Systems. ftc.gov, April 2023. Archived at perma.cc/YY4Y-RCCA ↩︎
Maciej Cegłowski. The Moral Economy of Tech. idlewords.com, June 2016. Archived at perma.cc/L8XV-BKTD ↩︎
Greg Nichols. Artificial Intelligence in healthcare is racist. zdnet.com, November 2020. Archived at perma.cc/3MKW-YKRS ↩︎
Cathy O’Neil. Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy. Crown Publishing, 2016. ISBN: 978-0-553-41881-1 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Julia Angwin. Make Algorithms Accountable. nytimes.com, August 2016. Archived at archive.org ↩︎
Bryce Goodman and Seth Flaxman. European Union Regulations on Algorithmic Decision-Making and a ‘Right to Explanation’. At ICML Workshop on Human Interpretability in Machine Learning, June 2016. Archived at arxiv.org/abs/1606.08813 ↩︎
A Review of the Data Broker Industry: Collection, Use, and Sale of Consumer Data for Marketing Purposes. Staff Report, United States Senate Committee on Commerce, Science, and Transportation, commerce.senate.gov, December 2013. Archived at perma.cc/32NV-YWLQ ↩︎ ↩︎
Stephanie Assad, Robert Clark, Daniel Ershov, and Lei Xu. Algorithmic Pricing and Competition: Empirical Evidence from the German Retail Gasoline Market. Journal of Political Economy, volume 132, issue 3, pages 723-771, March 2024. doi:10.1086/726906 ↩︎
Donella H. Meadows and Diana Wright. Thinking in Systems: A Primer. Chelsea Green Publishing, 2008. ISBN: 978-1-603-58055-7 ↩︎
Daniel J. Bernstein. Listening to a “big data”/“data science” talk. Mentally translating “data” to “surveillance”: “...everything starts with surveillance...” x.com, May 2015. Archived at perma.cc/EY3D-WBBJ ↩︎
Marc Andreessen. Why Software Is Eating the World. a16z.com, August 2011. Archived at perma.cc/3DCC-W3G6 ↩︎
J. M. Porup. ‘Internet of Things’ Security Is Hilariously Broken and Getting Worse. arstechnica.com, January 2016. Archived at archive.org ↩︎
Bruce Schneier. Data and Goliath: The Hidden Battles to Collect Your Data and Control Your World. W. W. Norton, 2015. ISBN: 978-0-393-35217-7 ↩︎ ↩︎ ↩︎
The Grugq. Nothing to Hide. grugq.tumblr.com, April 2016. Archived at perma.cc/BL95-8W5M ↩︎
Federal Trade Commission. FTC Takes Action Against General Motors for Sharing Drivers’ Precise Location and Driving Behavior Data Without Consent. ftc.gov, January 2025. Archived at perma.cc/3XGV-3HRD ↩︎
Tony Beltramelli. Deep-Spying: Spying Using Smartwatch and Deep Learning. Masters Thesis, IT University of Copenhagen, December 2015. Archived at arxiv.org/abs/1512.05616 ↩︎
Shoshana Zuboff. Big Other: Surveillance Capitalism and the Prospects of an Information Civilization. Journal of Information Technology, volume 30, issue 1, pages 75–89, April 2015. doi:10.1057/jit.2015.5 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Michiel Rhoen. Beyond Consent: Improving Data Protection Through Consumer Protection Law. Internet Policy Review, volume 5, issue 1, March 2016. doi:10.14763/2016.1.404 ↩︎
Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016. Official Journal of the European Union, L 119/1, May 2016. ↩︎ ↩︎
UK Information Commissioner’s Office. What is the ’legitimate interests’ basis? ico.org.uk. Archived at perma.cc/W8XR-F7ML ↩︎
Tristan Harris. How a handful of tech companies control billions of minds every day. At TED2017, April 2017. ↩︎
Carina C. Zona. Consequences of an Insightful Algorithm. At GOTO Berlin, November 2016. ↩︎
Imanol Arrieta Ibarra, Leonard Goff, Diego Jiménez Hernández, Jaron Lanier, and E. Glen Weyl. Should We Treat Data as Labor? Moving Beyond ‘Free’. American Economic Association Papers Proceedings, volume 1, issue 1, December 2017. ↩︎
Bruce Schneier. Data Is a Toxic Asset, So Why Not Throw It Out? schneier.com, March 2016. Archived at perma.cc/4GZH-WR3D ↩︎ ↩︎
Cory Scott. Data is not toxic - which implies no benefit - but rather hazardous material, where we must balance need vs. want. x.com, March 2016. Archived at perma.cc/CLV7-JF2E ↩︎
Mark Pesce. Data is the new uranium – incredibly powerful and amazingly dangerous. theregister.com, November 2024. Archived at perma.cc/NV8B-GYGV ↩︎
Bruce Schneier. Mission Creep: When Everything Is Terrorism. schneier.com, July 2013. Archived at perma.cc/QB2C-5RCE ↩︎
Lena Ulbricht and Maximilian von Grafenstein. Big Data: Big Power Shifts? Internet Policy Review, volume 5, issue 1, March 2016. doi:10.14763/2016.1.406 ↩︎ ↩︎
Ellen P. Goodman and Julia Powles. Facebook and Google: Most Powerful and Secretive Empires We’ve Ever Known. theguardian.com, September 2016. Archived at perma.cc/8UJA-43G6 ↩︎
Judy Estrin and Sam Gill. The World Is Choking on Digital Pollution. washingtonmonthly.com, January 2019. Archived at perma.cc/3VHF-C6UC ↩︎
A. Michael Froomkin. Regulating Mass Surveillance as Privacy Pollution: Learning from Environmental Impact Statements. University of Illinois Law Review, volume 2015, issue 5, August 2015. Archived at perma.cc/24ZL-VK2T ↩︎
Pengyuan Wang, Li Jiang, and Jian Yang. The Early Impact of GDPR Compliance on Display Advertising: The Case of an Ad Publisher. Journal of Marketing Research, volume 61, issue 1, April 2023. doi:10.1177/00222437231171848 ↩︎
Johnny Ryan. Don’t be fooled by Meta’s fine for data breaches. The Economist, May 2023. Archived at perma.cc/VCR6-55HR ↩︎
Jessica Leber. Your Data Footprint Is Affecting Your Life in Ways You Can’t Even Imagine. fastcompany.com, March 2016. Archived at archive.org ↩︎
Maciej Cegłowski. Haunted by Data. idlewords.com, October 2015. Archived at archive.org ↩︎ ↩︎
Sam Thielman. You Are Not What You Read: Librarians Purge User Data to Protect Privacy. theguardian.com, January 2016. Archived at archive.org ↩︎
Jez Humble. It’s a cliché that people get into tech to “change the world”. So then, you have to actually consider what the impact of your work is on the world. The idea that you can or should exclude societal and political discussions in tech is idiotic. It means you’re not doing your job. x.com, April 2021. Archived at perma.cc/3NYS-MHLC ↩︎
術語表
請注意:本術語表的定義刻意保持簡短,旨在傳達核心概念,而非覆蓋術語的全部細節。更多內容請參閱正文對應章節。
非同步(asynchronous)
不等待某件事完成(例如透過網路把資料傳送到另一個節點),且不假設它會在多長時間內完成。參見“同步與非同步複製”、“同步網路與非同步網路”和“系統模型與現實”。
原子(atomic)
- 在併發語境下:指一個操作看起來在某個單一時刻生效,其他併發程序不會看到它處於“半完成”狀態。另見 isolation。
- 在事務語境下:指一組寫入要麼全部提交、要麼全部回滾,即使發生故障也不例外。參見“原子性”和“兩階段提交(2PC)”。
背壓(backpressure)
當接收方跟不上時,強制傳送方降速。也稱為 flow control。參見“系統過載後無法恢復時會發生什麼”。
批處理(batch process)
以一個固定(通常較大)資料集為輸入、產出另一份資料且不修改輸入的計算。參見第 11 章。
有界(bounded)
具有已知上限或大小。例如可用於描述網路延遲(參見“超時與無界延遲”)和資料集(參見第 12 章導言)。
拜占庭故障(Byzantine fault)
節點以任意錯誤方式行為,例如向不同節點傳送相互矛盾或惡意訊息。參見“拜占庭故障”。
快取(cache)
透過記住近期訪問資料來加速後續讀取的元件。快取通常不完整:若未命中,需要回源到更慢但完整的底層資料儲存。
CAP 定理(CAP theorem)
一個在實踐中經常被誤解、且不太有直接指導價值的理論結果。參見“CAP 定理”。
因果關係(causality)
當一件事“先於”另一件事發生時產生的事件依賴關係。例如後續事件對先前事件的響應、建立在先前事件之上,或必須結合先前事件理解。參見“happens-before 關係與併發”。
共識(consensus)
分散式計算中的基本問題:讓多個節點就某件事達成一致(例如誰是主節點)。這比直覺上要困難得多。參見“共識”。
資料倉儲(data warehouse)
將多個 OLTP 系統的資料彙總並整理後,用於分析場景的資料庫。參見“資料倉儲”。
宣告式(declarative)
描述“想要什麼性質”,而非“如何一步步實現”。在資料庫查詢中,最佳化器接收宣告式查詢並決定最佳執行方式。參見“術語:宣告式查詢語言”。
反正規化(denormalize)
在已正規化資料集中引入一定冗餘(常見形式為快取或索引)以換取更快讀取。反正規化值可看作預計算結果,類似物化檢視。參見“正規化、反正規化與連線”。
派生資料(derived data)
透過可重複流程由其他資料生成的資料集,必要時可重新計算。通常用於加速某類讀取。索引、快取、物化檢視都屬於派生資料。參見“記錄系統與派生資料”。
確定性(deterministic)
一個函式在相同輸入下總產生相同輸出,不依賴隨機數、當前時間、網路互動等不可預測因素。參見“確定性的力量”。
分散式(distributed)
系統在多個透過網路連線的節點上執行。其典型特徵是 部分失效:一部分壞了,另一部分仍在工作,而軟體往往難以精確知道哪裡壞了。參見“故障與部分失效”。
永續性(durable)
以你相信不會丟失的方式儲存資料,即使發生各種故障。參見“永續性”。
ETL
Extract-Transform-Load(提取-轉換-載入):從源資料庫抽取資料,轉成更適合分析查詢的形式,再載入到資料倉儲或批處理系統。參見“資料倉儲”。
故障切換(failover)
在單主系統中,將主角色從一個節點切到另一個節點的過程。參見“處理節點故障”。
容錯(fault-tolerant)
出現故障(如機器崩潰、鏈路故障)後仍可自動恢復。參見“可靠性與容錯”。
流量控制(flow control)
見 backpressure。
追隨者(follower)
不直接接收客戶端寫入、僅應用來自主節點變更的副本。也稱 secondary、read replica 或 hot standby。參見“單主複製”。
全文檢索(full-text search)
按任意關鍵詞搜尋文字,通常支援近似拼寫、同義詞等能力。全文索引是支援此類查詢的一種 secondary index。參見“全文檢索”。
圖(graph)
由 vertices(可引用物件,也稱 nodes 或 entities)和 edges(頂點間連線,也稱 relationships 或 arcs)組成的資料結構。參見“圖狀資料模型”。
雜湊(hash)
把輸入對映成看似隨機數字的函式。相同輸入總得相同輸出;不同輸入通常輸出不同,但也可能碰撞(collision)。參見“按鍵的雜湊分片”。
冪等(idempotent)
可安全重試的操作:執行多次與執行一次效果相同。參見“冪等性”。
索引(index)
一種可高效檢索“某欄位取某值”的記錄的資料結構。參見“OLTP 的儲存與索引”。
隔離性(isolation)
在事務語境下,併發事務相互干擾的程度。Serializable 最強,也常用更弱隔離級別。參見“隔離性”。
連線(join)
把具有關聯關係的記錄拼在一起。常見於一個記錄引用另一個記錄(外來鍵、文件引用、圖邊)時,查詢需要取到被引用物件。參見“正規化、反正規化與連線”和“JOIN 與 GROUP BY”。
領導者(leader)
當資料或服務跨多個節點複製時,被指定為可接受寫入的副本。可透過協議選舉或管理員指定。也稱 primary 或 source。參見“單主複製”。
線性一致(linearizable)
表現得像系統裡只有一份資料副本,且由原子操作更新。參見“線性一致性”。
區域性(locality)
一種效能最佳化:把經常被一起訪問的資料放在一起。參見“讀寫的資料區域性”。
鎖(lock)
保證同一時刻只有一個執行緒/節點/事務訪問某資源的機制;其他訪問者需等待鎖釋放。參見“兩階段鎖(2PL)”和“分散式鎖與租約”。
日誌(log)
只追加寫入的資料檔案。WAL 用於崩潰恢復(參見“讓 B 樹可靠”);log-structured 儲存把日誌作為主儲存格式(參見“日誌結構儲存”);replication log 用於主從複製(參見“單主複製”);event log 可表示資料流(參見“基於日誌的訊息代理 ”)。
物化(materialize)
把計算結果提前算出並寫下來,而不是按需即時計算。參見“事件溯源與 CQRS”。
節點(node)
執行在某臺計算機上的軟體例項,透過網路與其他節點協作完成任務。
正規化(normalized)
資料結構中儘量避免冗餘與重複。正規化資料庫裡某資料變化時通常只改一處,不需多處同步。參見“正規化、反正規化與連線”。
OLAP
Online Analytic Processing(線上分析處理):典型訪問模式是對大量記錄做聚合(如 count/sum/avg)。參見“事務系統與分析系統”。
OLTP
Online Transaction Processing(線上事務處理):典型訪問模式是快速讀寫少量記錄,通常按鍵索引。參見“事務系統與分析系統”。
分片(sharding)
把單機裝不下的大資料集或計算拆成更小部分並分散到多臺機器上。也稱 partitioning。參見第 7 章。
百分位(percentile)
透過統計多少值高於/低於某閾值來描述分佈。例如某時段 95 分位響應時間為 t,表示 95% 請求耗時小於 t,5% 更長。參見“描述效能”。
主鍵(primary key)
唯一標識一條記錄的值(通常為數字或字串)。在很多應用中由系統在建立時生成(順序或隨機),而非使用者手工指定。另見 secondary index。
法定票數(quorum)
一個操作被判定成功前所需的最少投票節點數。參見“讀寫法定票數”。
再平衡(rebalance)
為均衡負載,把資料或服務從一個節點遷移到另一個節點。參見“鍵值資料的分片”。
複製(replication)
在多個節點(replicas)上儲存同一份資料,以便部分節點不可達時仍可訪問。參見第 6 章。
模式(schema)
對資料結構(欄位、型別等)的描述。資料是否符合模式可在生命週期不同階段檢查(參見“文件模型中的模式靈活性”),模式也可隨時間演進(參見第 5 章)。
二級索引(secondary index)
與主儲存並行維護的附加結構,用於高效檢索滿足某類條件的記錄。參見“多列索引與二級索引”和“分片與二級索引”。
可序列化(serializable)
一種 isolation 保證:多個事務併發執行時,行為等價於某個序列順序逐個執行。參見“可序列化”。
無共享(shared-nothing)
一種架構:獨立節點(各自 CPU、記憶體、磁碟)透過普通網路連線;相對的是共享記憶體或共享磁碟架構。參見“共享記憶體、共享磁碟與無共享架構”。
偏斜(skew)
- 分片負載不均:某些分片請求/資料很多,另一些很少。也稱 hot spots。參見“負載偏斜與熱點消除”。
- 一種時序異常,導致事件呈現為非預期的非順序。參見“快照隔離與可重複讀”中的讀偏斜、“寫偏斜與幻讀”中的寫偏斜、以及“用於事件排序的時間戳”中的時鐘偏斜。
腦裂(split brain)
兩個節點同時認為自己是領導者,可能破壞系統保證。參見“處理節點故障”和“少數服從多數”。
儲存過程(stored procedure)
把事務邏輯編碼到資料庫伺服器端執行,使事務過程中無需與客戶端來回通訊。參見“實際序列執行”。
流處理(stream process)
持續執行的計算:消費無窮事件流併產出結果。參見第 12 章。
同步(synchronous)
asynchronous 的反義詞。
記錄系統(system of record)
持有某類資料主權威版本的系統,也稱 source of truth。資料變更首先寫入這裡,其他資料集可由其派生。參見“記錄系統與派生資料”。
超時(timeout)
最簡單的故障檢測方式之一:在一定時間內未收到響應即判定超時。但無法確定是遠端節點故障還是網路問題導致。參見“超時與無界延遲”。
全序(total order)
一種可比較關係(如時間戳),任意兩者都能判定大小。若存在不可比較元素,則稱 partial order(偏序)。
事務(transaction)
把多次讀寫封裝為一個邏輯單元,以簡化錯誤處理與併發問題。參見第 8 章。
兩階段提交(two-phase commit, 2PC)
保證多個資料庫節點對同一事務要麼都 atomically 提交、要麼都中止的演算法。參見“兩階段提交(2PC)”。
兩階段鎖(two-phase locking, 2PL)
實現 serializable isolation 的演算法:事務對讀寫資料加鎖並持有到事務結束。參見“兩階段鎖(2PL)”。
無界(unbounded)
沒有已知上限或大小。與 bounded 相反。
索引
符號
- 3FS(分散式檔案系統), 分散式檔案系統
A
- 中止(事務), 事務, 原子性
- 級聯, 沒有髒讀
- 在兩階段提交中, 兩階段提交(2PC)
- 樂觀併發控制的效能, 可序列化快照隔離的效能
- 重試已中止的事務, 處理錯誤和中止
- 抽象, 雲服務的分層, 簡單性:管理複雜度, 資料模型與查詢語言, 事務, 總結
- 意外複雜性, 簡單性:管理複雜度
- 問責制, 責任與問責
- 會計(財務資料), 總結, 不可變事件的優點
- Accumulo(資料庫)
- ACID 屬性(事務), ACID 的含義
- 確認(訊息), 確認與重新傳遞
- active/active replication(見 multi-leader replication)
- active/passive replication(見 基於領導者的複製)
- ActiveMQ(訊息系統), 訊息代理, 訊息代理與資料庫的對比
- 分散式事務支援, XA 事務
- ActiveRecord(物件關係對映器), 物件關係對映(ORM), 處理錯誤和中止
- activity (workflows)(見 workflow engines)
- Actor 模型, 分散式 actor 框架
- (另見 event-driven architecture)
- 與流處理的比較, 事件驅動架構與 RPC
- 自適應容量, 偏斜的工作負載與緩解熱點
- Advanced Message Queuing Protocol(見 AMQP)
- 航空航天系統, 拜占庭故障
- Aerospike(資料庫)
- 強一致性模式, 單物件寫入
- AGE(圖資料庫), Cypher 查詢語言
- 彙總
- 資料立方體和已實現檢視, 物化檢視與資料立方體
- 分批處理, 排序與記憶體聚合
- 流程中, 流分析
- 聚合管道(MongoDB), 正規化、反正規化與連線, 文件的查詢語言
- 敏捷, 可演化性:讓變化更容易
- 最小化不可逆性, 批處理, 應用演化後重新處理資料
- 充滿自信地快速前進, 端到端原則重現
- 一致意見, 單值共識, 原子提交作為共識
- (另見 共識)
- AI (artificial intelligence)(見 machine learning)
- AI Act (European Union), 資料系統、法律與社會
- Airbyte, 資料倉儲
- Airflow(工作流排程器), 持久化執行與工作流, 批處理, 工作流排程
- 雲資料倉整合, 查詢語言
- 用於 ETL, 提取-轉換-載入(ETL)
- 阿卡邁
- 響應時間研究, 平均值、中位數與百分位點
- 演算法
- 演算法正確性, 定義演算法的正確性
- B樹, B 樹-B 樹變體
- 分散式系統, 系統模型與現實
- 歸併排序, 構建和合並 SSTable, 混洗資料
- 排程, 資源分配
- SSTable 與 LSM 樹, SSTable 檔案格式-壓實策略
- 全互聯複製拓撲, 多主複製拓撲
- AllegroGraph(資料庫), 圖資料模型
- SPARQL 查詢語言, SPARQL 查詢語言
- ALTER TABLE 語句(SQL), 文件模型中的模式靈活性, 編碼與演化
- 亞馬遜
- Dynamo(見 Dynamo(資料庫))
- 響應時間研究, 平均值、中位數與百分位點
- Amazon Web Services (AWS)
- Aurora(見 Aurora(雲資料庫))
- ClockBound(見 ClockBound(時間同步))
- 正確性測試, 形式化方法和隨機測試
- DynamoDB(見 DynamoDB(資料庫))
- EBS(見 EBS(虛擬塊裝置))
- Kinesis(見 Kinesis(訊息系統))
- Neptune(見 Neptune(圖資料庫))
- 網路可靠性, 實踐中的網路故障
- S3(見 S3(物件儲存))
- 放大
- AMQP(高階訊息佇列協議), 訊息代理與資料庫的對比
- (另見 messaging systems)
- 比較基於日誌的郵件, 日誌與傳統的訊息傳遞相比, 重播舊訊息
- 訊息順序, 確認與重新傳遞
- 分析系統, 分析型與事務型系統
- 分析, 分析型與事務型系統-記錄系統與派生資料
- 與事務處理的比較, 事務處理與分析的特徵
- 資料正常化, 正規化的權衡
- data warehousing(見 data warehousing)
- predictive(見 predictive analytics)
- 與批次處理的關係, 分析(Analytics)-分析(Analytics)
- 計劃, 星型與雪花型:分析模式-星型與雪花型:分析模式
- 快速隔離查詢, 快照隔離與可重複讀
- 流式分析, 流分析
- 分析工程, 分析型與事務型系統
- 反熵, 追趕錯過的寫入
- Antithesis(確定性模擬測試), 確定性模擬測試
- Apache Accumulo(見 Accumulo)
- Apache ActiveMQ(見 ActiveMQ)
- Apache AGE(見 AGE)
- Apache Arrow(見 Arrow(資料格式))
- Apache Avro(見 Avro)
- Apache Beam(見 Beam)
- Apache BookKeeper(見 BookKeeper)
- Apache Cassandra(見 Cassandra)
- Apache Curator(見 Curator)
- Apache DataFusion(見 DataFusion(查詢引擎))
- Apache Druid(見 Druid(資料庫))
- Apache Flink(見 Flink(處理框架))
- Apache HBase(見 HBase)
- Apache Iceberg(見 Iceberg(表格式))
- Apache Jena(見 Jena)
- Apache Kafka(見 Kafka)
- Apache Lucene(見 Lucene)
- Apache Oozie(見 Oozie(工作流排程器))
- Apache ORC(見 ORC(資料格式))
- Apache Parquet(見 Parquet(資料格式))
- Apache Pig(查詢語言), 查詢語言
- Apache Pinot(見 Pinot(資料庫))
- Apache Pulsar(見 Pulsar)
- Apache Qpid(見 Qpid)
- Apache Samza(見 Samza)
- Apache Solr(見 Solr)
- Apache Spark(見 Spark;見 Spark(處理框架))
- Apache Storm(見 Storm)
- Apache Superset(見 Superset(資料視覺化軟體))
- Apache Thrift(見 Thrift)
- Apache ZooKeeper(見 ZooKeeper)
- Apama (流式分析), 複合事件處理
- append-only files(見 logs)
- Application Programming Interfaces (APIs), 資料模型與查詢語言
- 用於改變流, 變更流的 API 支援
- 分散式事務, XA 事務
- 服務費用, 流經服務的資料流:REST 與 RPC-RPC 的資料編碼與演化
- (另見 services)
- 可演化性, RPC 的資料編碼與演化
- RESTful, Web 服務
- application state(見 國家)
- approximate search(見 similarity search)
- 檔案儲存、資料庫資料, 歸檔儲存
- arcs(見 edges)
- ArcticDB(資料庫), 資料框、矩陣與陣列
- 算術平均值, 平均值、中位數與百分位點
- 陣列
- Arrow(資料格式), 列式儲存, DataFrames
- artificial intelligence(見 machine learning)
- ASCII text, Protocol Buffers
- ASN.1 (schema language), 模式的優點
- 關聯表格, 多對一與多對多關係, 屬性圖
- 同步網路, 不可靠的網路, 術語表
- 同步複製, 同步複製與非同步複製, 術語表
- 故障資料損失, 領導者故障:故障轉移
- 從同步跟蹤器讀取, 複製延遲的問題
- 有多個領導, 多主複製
- 非同步傳輸模式, 我們不能簡單地使網路延遲可預測嗎?
- 原子廣播, 共享日誌作為共識
- 原子鐘, 帶置信區間的時鐘讀數, 用於全域性快照的同步時鐘
- (另見 clocks)
- 原子性, 術語表
- 原子自增, 單物件寫入
- 比較和設定, 條件寫入(比較並設定), 什麼使系統具有線性一致性?
- (另見 比較和設定)
- 異常資料, 正規化的權衡
- 獲取和新增/遞增, ID 生成器和邏輯時鐘, 共識, 獲取並增加作為共識
- 寫入操作, 原子寫操作
- 原子性, 原子性, 單物件與多物件操作, 術語表
- 可審計性, 信任但驗證-用於可審計資料系統的工具
- 設計, 為可審計性而設計
- 自動審計系統, 不要盲目信任承諾
- 透過不可改變性, 不可變事件的優點
- 可審計資料系統工具, 用於可審計資料系統的工具
- Aurora(雲資料庫), 雲原生系統架構
- Aurora DSQL(資料庫)
- 快速隔離支援, 快照隔離與可重複讀
- 自動縮放, 運維:自動/手動再平衡
- Automerge (CRDT library), 同步引擎的利弊
- 可用性, 可靠性與容錯
- 可用區, 透過冗餘容忍硬體故障, 讀己之寫
- Avro(資料格式), Avro-動態生成的模式
- 動態生成的計劃, 動態生成的模式
- 物件容器檔案, 但什麼是寫入者模式?, 歸檔儲存
- 讀者決定作家的計劃, 但什麼是寫入者模式?
- 計劃演變, 寫入者模式與讀取者模式
- 批次處理中的用途, MapReduce
- awk (Unix 工具) (英語)., 簡單日誌分析, 簡單日誌分析, 分散式作業編排
- Axon Framework, 事件溯源與 CQRS
- Azkaban(工作流排程器), 批處理
- Azure Blob Storage(物件儲存), 雲服務的分層, 設定新的副本
- 有條件的標題, 隔離殭屍程序和延遲請求
- Azure managed disks, 儲存與計算的分離
- Azure SQL DB(資料庫), 雲原生系統架構
- Azure Storage, 物件儲存
- Azure Synapse Analytics(資料庫), 雲原生系統架構
- Azure Virtual Machines
- 現場虛擬機器, 故障處理
B
- B樹(指數), B 樹-B 樹變體
- B+ trees, B 樹變體
- 分支因子, B 樹
- comparison to LSM-trees, 比較 B 樹與 LSM 樹-磁碟空間使用
- 崩潰恢復, 使 B 樹可靠
- 透過分割頁面增長, B 樹
- 不可變變種, B 樹變體, 索引與快照隔離
- 與硬分裂相似, 再平衡鍵範圍分片資料
- 變體, B 樹變體
- B2(物件儲存), 分散式檔案系統
- Backblaze B2(見 B2(物件儲存))
- 後端, 資料系統架構中的權衡
- 返回, 指數, 描述效能, 處理錯誤和中止
- 背壓, 描述效能, 讀取效能, 訊息傳遞系統, 術語表
- 備份
- 向後相容, 編碼與演化
- BadgerDB(資料庫)
- 可序列事務, 可序列化快照隔離(SSI)
- BASE, contrast to ACID, ACID 的含義
- 擊打彈殼(Unix), OLTP 系統的儲存與索引
- 批處理, 批處理-本章小結, 術語表
- 方案規劃和職能規劃, MapReduce
- 惠益, 批處理
- 結合流處理, 統一批處理和流處理
- 與流處理的比較, 流處理
- 資料流引擎, 資料流引擎-資料流引擎
- 過失容忍, 故障處理, 訊息傳遞系統
- 資料整合, 批處理與流處理-統一批處理和流處理
- 圖表和迭代處理, 機器學習
- high-level APIs and languages, 查詢語言-查詢語言
- 雲資料倉儲中, 查詢語言
- 在分散式系統中, 分散式系統中的批處理
- 加入和分組, JOIN 與 GROUP BY-JOIN 與 GROUP BY
- 限制, 批處理
- 基於日誌的資訊和, 重播舊訊息
- 保持衍生狀態, 維護派生狀態
- 衡量業績, 批處理
- 模式, 批處理模型
- 資源分配, 資源分配-資源分配
- 資源管理員, 分散式作業編排
- 排程器, 分散式作業編排
- 服務衍生資料, 對外提供派生資料-對外提供派生資料
- 移動資料, 混洗資料-混洗資料
- 任務執行, 分散式作業編排
- 使用大小寫, 批處理用例-對外提供派生資料
- 使用 Unix 工具(例如), 使用 Unix 工具的批處理-排序與記憶體聚合
- 批處理框架
- 與作業系統的比較, 分散式系統中的批處理
- Beam (資料流庫), 統一批處理和流處理
- BERT (language model), 向量嵌入
- 偏向, 偏見與歧視
- bidirectional replication(見 multi-leader replication)
- 泥漿大球, 簡單性:管理複雜度
- 大資料
- 對資料最小化, 資料系統、法律與社會, 立法與自律
- BigQuery(資料庫), 雲原生系統架構, 雲資料倉儲, 批處理
- Bigtable(資料庫)
- 硬化計劃, 按鍵的範圍分片
- 儲存佈局, 構建和合並 SSTable
- 平板(硬化), 分片
- 寬柱資料模型, 讀寫的資料區域性, 列壓縮
- 二進位制資料編碼, 二進位制編碼-模式的優點
- 二進位制編碼
- binary strings, lack of support in JSON and XML, JSON、XML 及其二進位制變體
- 比特幣(催眠幣), 用於可審計資料系統的工具
- 點陣圖索引, 列壓縮
- BitTorrent uTP protocol, TCP 的侷限性
- Bkd-樹木(指數), 多維索引與全文索引
- 無咎死後, 人類與可靠性
- Blazegraph(資料庫), 圖資料模型
- SPARQL 查詢語言, SPARQL 查詢語言
- blob storage(見 object storage)
- 塊, 分散式檔案系統
- 塊裝置(磁碟), 儲存與計算的分離
- 塊鏈, 總結
- 拜占庭斷層承受力, 拜占庭故障, 共識, 用於可審計資料系統的工具
- 阻止原子承諾, 三階段提交
- Bloom 過濾器(演算法), 布隆過濾器, 讀取效能, 流分析
- BookKeeper (replicated log), 將工作分配給節點
- 邊框資料集, 流處理, 術語表
- (另見 batch processing)
- 受限延遲, 術語表
- 廣播
- 全序廣播(見 shared logs)
- 無中介訊息, 直接從生產者傳遞給消費者
- 粗糙(計量聚合器), 直接從生產者傳遞給消費者
- BTM (transaction coordinator), 兩階段提交(2PC)
- 緩衝
- Bufstream(訊息系統), 設定新的副本
- Bufstream(訊息系統), 磁碟空間使用
- 新建或購買, 雲服務與自託管
- 快速網路交通模式, 我們不能簡單地使網路延遲可預測嗎?
- 商業分析員, 分析型與事務型系統, 從資料倉儲到資料湖
- 商業資料處理, 事務處理與分析的特徵
- 商業情報, 分析型與事務型系統-資料倉儲
- Business Process Execution Language (BPEL), 持久化執行與工作流
- Business Process Model and Notation (BPMN), 持久化執行與工作流
- 例項, 持久化執行與工作流
- 位元組序列,編碼資料, 編碼資料的格式
- 拜占庭斷層, 拜占庭故障-弱形式的謊言, 系統模型與現實, 術語表
- 拜占庭容錯系統, 拜占庭故障
- Byzantine Generals Problem, 拜占庭故障
- 協商一致演算法和, 共識, 用於可審計資料系統的工具
C
- 快取, 全記憶體儲存, 術語表
- 意見, 物化檢視與資料立方體
- 作為衍生資料, 記錄系統與派生資料, 組合使用資料儲存技術-分拆系統與整合系統
- in CPUs, 查詢執行:編譯與向量化, 線性一致性與網路延遲
- 無效和贍養費, 保持系統同步, 維護物化檢視
- 線性一致性, 線性一致性
- 雲中的本地磁碟, 儲存與計算的分離
- 日曆同步, 同步引擎與本地優先軟體, 同步引擎的利弊
- California Consumer Privacy Act (CCPA), 資料系統、法律與社會
- Camunda(工作流程引擎), 持久化執行與工作流
- (資料), 記錄系統與派生資料
- CAP定理, CAP 定理-CAP 定理, 術語表
- 能力規劃, 雲時代的運維
- Cap’n Proto(資料格式), 編碼資料的格式
- 碳排放, 分散式與單節點系統
- 級聯中止, 沒有髒讀
- 連鎖失敗, 軟體故障, 運維:自動/手動再平衡, 超時和無界延遲
- Cassandra(資料庫)
- 資料變更捕獲, 資料變更捕獲的實現, 變更流的 API 支援
- 壓縮戰略, 壓實策略
- consistency level ANY, 單主與無主複製的效能
- 雜湊變硬, 按鍵的雜湊分片, 按雜湊範圍分片
- 最後寫成的解決衝突, 檢測併發寫入
- 無領導複製, 無主複製
- 輕量事務, 單物件寫入
- 線性,缺少, 實現線性一致性系統
- 日誌結構儲存, 構建和合並 SSTable
- 多區域支助, 多地區操作
- 二級指數, 本地二級索引
- 使用時鐘, 仲裁一致性的侷限, 用於事件排序的時間戳
- 節點(硬化), 分片
- 貓(Unix 工具), 簡單日誌分析
- 目錄, 雲資料倉儲
- 因果關係, 版本向量
- (另見 causal dependencies)
- 因果關係, “先發生"關係與併發-版本向量
- 捕獲, 版本向量, 排序事件以捕獲因果關係, 讀也是事件
- 按總訂單, 全序的限制
- 事務中, 基於過時前提的決策
- 向朋友傳送訊息(例如), 排序事件以捕獲因果關係
- 捕獲, 版本向量, 排序事件以捕獲因果關係, 讀也是事件
- 因果關係, 術語表
- 因果順序
- 與, 邏輯時鐘
- 與, 邏輯時鐘-使用邏輯時鐘強制約束
- 發生關係前, “先發生"關係與併發
- 在可序列事務中, 基於過時前提的決策-檢測影響先前讀取的寫入
- 與時鐘不符, 用於事件排序的時間戳
- 命令要抓取的事件, 排序事件以捕獲因果關係
- 違反《公約》的行為, 一致字首讀, 不同拓撲的問題, 用於事件排序的時間戳
- 帶有同步時鐘, 用於全域性快照的同步時鐘
- 因果順序
- 基於單元格的架構, 面向多租戶的分片
- 複合事件處理(見 複合事件處理)
- CephFS(分散式檔案系統), 批處理, 物件儲存
- 證書透明性, 用於可審計資料系統的工具
- c組, 分散式作業編排
- 資料變更捕獲, 邏輯(基於行)日誌複製, 資料變更捕獲
- 變更流的 API 支援, 變更流的 API 支援
- 比較事件來源, 資料變更捕獲與事件溯源
- 執行, 資料變更捕獲的實現
- 初始快照, 初始快照
- 日誌壓縮, 日誌壓縮
- 更改日誌, 狀態、流和不變性
- 混亂工程, 容錯, 故障注入
- 檢查站
- 斷路器(限制重試), 描述效能
- 電路交換網路, 同步與非同步網路
- 迴圈緩衝器, 磁碟空間使用
- 迴圈複製地形, 多主複製拓撲
- Citus(資料庫)
- 雜湊變硬, 固定數量的分片
- ClickHouse(資料庫), 事務處理與分析的特徵, 雲原生系統架構
- 增量檢視維護, 維護物化檢視
- 點選流資料,分析, JOIN 與 GROUP BY
- 客戶
- 電話服務, 流經服務的資料流:REST 與 RPC
- 離線, 同步引擎與本地優先軟體, 有狀態、可離線的客戶端
- 推動狀態更改到, 將狀態變更推送給客戶端
- 請求路由, 請求路由
- ClockBound(時間同步), 帶置信區間的時鐘讀數
- use in YugabyteDB, 用於全域性快照的同步時鐘
- 時鐘, 不可靠的時鐘-限制垃圾回收的影響
- 原子鐘, 帶置信區間的時鐘讀數, 用於全域性快照的同步時鐘
- 信任間隔, 帶置信區間的時鐘讀數-用於全域性快照的同步時鐘
- 全球快照, 用於全域性快照的同步時鐘
- 混合邏輯時鐘, 混合邏輯時鐘
- logical(見 logical clocks)
- 偏斜, 最後寫入勝利(丟棄併發寫入), 仲裁一致性的侷限, 對同步時鐘的依賴-帶置信區間的時鐘讀數, 實現線性一致性系統
- 殺人, 單調時鐘
- 同步和準確性, 時鐘同步和準確性-時鐘同步和準確性
- synchronization using GPS, 不可靠的時鐘, 時鐘同步和準確性, 帶置信區間的時鐘讀數, 用於全域性快照的同步時鐘
- 時間與單調時鐘, 單調時鐘與日曆時鐘
- 時間標記事件, 你用的是誰的時鐘?
- 雲服務, 雲服務與自託管-雲端計算與超級計算
- 雲內, 雲原生系統架構-雲時代的運維
- 雲飛
- R2(見 R2(物件儲存))
- 組合索引, 在索引中儲存值
- 分組(記錄順序), 按雜湊範圍分片
- CockroachDB(資料庫)
- 基於共識的複製, 單主複製
- 一致性模式, 什麼使系統具有線性一致性?
- 鍵程硬化, 分片, 按鍵的範圍分片
- 可序列事務, 可序列化快照隔離(SSI)
- 硬化二級指數, 全域性二級索引
- 事務, 事務到底是什麼?, 資料庫內部的分散式事務
- 使用模型檢查, 模型檢查與規範語言
- 程式碼生成
- 用於查詢執行, 查詢執行:編譯與向量化
- 帶有協議緩衝, Protocol Buffers
- 協作編輯, 實時協作、離線優先和本地優先應用
- 列家庭(大表), 讀寫的資料區域性, 列壓縮
- 面向列的儲存, 列式儲存-查詢執行:編譯與向量化
- comma-separated values(見 CSV)
- 命令查詢責任分離, 事件溯源與 CQRS-事件溯源與 CQRS, 從同一事件日誌中派生多個檢視
- 命令(活動來源), 事件溯源與 CQRS
- 執行(事務), 事務
- 原子提交, 分散式事務-再談恰好一次訊息處理
- (另見 原子性)
- 讀作承諾隔離, 讀已提交
- three-phase commit (3PC), 三階段提交
- 兩階段提交, 兩階段提交(2PC)-協調器故障
- 原子提交, 分散式事務-再談恰好一次訊息處理
- 通用業務, 衝突解決與複製
- 壓實(Compaction)
- 比較和設定, 條件寫入(比較並設定), 什麼使系統具有線性一致性?
- 相容性, 編碼與演化, 資料流的模式
- 電話服務, RPC 的資料編碼與演化
- 編碼格式的屬性, 總結
- 使用資料庫, 流經資料庫的資料流-歸檔儲存
- 補償事務, 不可變事件的優點, 寬鬆地解釋約束
- 彙編, 查詢執行:編譯與向量化
- 複合事件處理, 複合事件處理
- 複雜度
- 理論模型中的蒸餾, 將系統模型對映到現實世界
- 重要和意外事項, 簡單性:管理複雜度
- 使用抽象來隱藏, 資料模型與查詢語言
- 管理, 簡單性:管理複雜度
- composing data systems(見 unbundling databases)
- 壓縮
- in SSTables, SSTable 檔案格式
- 計算密集型應用程式, 資料系統架構中的權衡
- 電腦遊戲, 同步引擎的利弊
- 縮寫索引, 多維索引與全文索引
- 在雜湊硬化系統中, 按雜湊範圍分片
- 併發
- 演員程式設計模式, 分散式 actor 框架, 事件驅動架構與 RPC
- (另見 event-driven architecture)
- 事務隔離薄弱時出現的錯誤, 弱隔離級別
- 解決衝突, 處理寫入衝突-處理寫入衝突
- 定義, 處理寫入衝突
- 檢測並行寫作, 檢測併發寫入-版本向量
- 雙寫、 問題, 保持系統同步
- 發生關係前, “先發生"關係與併發
- 在複製系統中, 複製延遲的問題-版本向量, 線性一致性-線性一致性與網路延遲
- 丟失更新, 防止丟失更新
- 多版本併發控制, 多版本併發控制(MVCC), 用於全域性快照的同步時鐘
- 樂觀併發控制, 悲觀併發控制與樂觀併發控制
- 行動命令, 什麼使系統具有線性一致性?
- 透過事件日誌減少, 併發控制, 資料流:應用程式碼與狀態變化的互動
- 時間和相對性, “先發生"關係與併發
- 事務隔離, 隔離性
- 寫偏差, 寫偏差與幻讀-物化衝突
- 演員程式設計模式, 分散式 actor 框架, 事件驅動架構與 RPC
- 有條件寫入, 條件寫入(比較並設定)
- 會議管理系統(例如), 事件溯源與 CQRS
- conflict-free replicated datatypes (CRDTs), CRDT 與操作變換
- 衝突
- 撤銷, 衝突避免
- 因果關係, “先發生"關係與併發
- 衝突檢測
- 分散式事務, XA 事務的問題
- 在基於日誌的系統中, 唯一性約束需要達成共識
- in serializable snapshot isolation (SSI), 檢測影響先前讀取的寫入
- 在兩階段提交中, 系統性的承諾
- 解決衝突
- 透過中止事務, 悲觀併發控制與樂觀併發控制
- 透過道歉, 寬鬆地解釋約束
- 最後寫入勝利, 用於事件排序的時間戳
- 使用原子操作, 衝突解決與複製
- 確定什麼是衝突, 處理寫入衝突, 基於日誌訊息傳遞中的唯一性
- 無領導複製, 檢測併發寫入
- 丟失更新, 防止丟失更新-衝突解決與複製
- 實現, 物化衝突
- 決議, 處理寫入衝突-處理寫入衝突
- 自動, 自動衝突解決
- 無頭系統, 檢測併發寫入
- 最後寫入勝利, 最後寫入勝利(丟棄併發寫入)
- 使用自定義邏輯, 手動衝突解決, 捕獲先發生關係
- 兄弟值, 手動衝突解決, 捕獲先發生關係
- 合併, 捕獲先發生關係
- 寫偏差, 寫偏差與幻讀-物化衝突
- 調和
- Freight(訊息系統), 設定新的副本, 磁碟空間使用
- 計劃登記, JSON 模式, 但什麼是寫入者模式?
- 擁堵(網路)
- 撤銷, TCP 的侷限性
- 限制時鐘的準確性, 帶置信區間的時鐘讀數
- 排隊延遲, 網路擁塞和排隊
- 共識, 共識-總結, 術語表
- consent (GDPR), 同意與選擇自由
- 一致性, 一致性, 及時性與完整性
- 跨越不同資料庫, 領導者故障:故障轉移, 保持系統同步, 從同一事件日誌中派生多個檢視, 派生資料與分散式事務
- 因果關係, 一致字首讀, 不同拓撲的問題, 排序事件以捕獲因果關係
- 一致字首讀, 一致字首讀-一致字首讀
- 一致的快照, 設定新的副本, 快照隔離與可重複讀-快照隔離、可重複讀和命名混淆, 用於全域性快照的同步時鐘, 初始快照, 建立索引
- (另見 snapshots)
- 崩潰恢復, 使 B 樹可靠
- enforcing constraints(見 constraints)
- 最終, 複製延遲的問題
- (另見 最終一致性)
- in ACID transactions, 一致性, 維護完整性,儘管軟體有Bug
- 在 CAP 定理中, CAP 定理
- 領袖選舉, 共識的微妙之處
- 微服務, 分散式系統的問題
- 線性一致性, 複製延遲的解決方案, 線性一致性-線性一致性與網路延遲
- 含義, 一致性
- 單調讀, 單調讀-單調讀
- 二級指數, 多物件事務的需求, 索引與快照隔離, 理解資料流, 建立索引
- 讀後寫, 讀己之寫-讀己之寫
- 在衍生資料系統中, 派生資料與分散式事務
- strong(見 線性一致性)
- 及時性和完整性, 及時性與完整性
- 使用法定人數, 仲裁一致性的侷限, 線性一致性與仲裁
- 連續的雜湊, 一致性雜湊
- 一致字首讀, 一致字首讀
- 限制(資料庫), 一致性, 寫偏差的特徵
- 領事(協調處), 協調服務
- 用於服務發現, 服務發現
- 消費者(資訊流), 訊息代理, 傳遞事件流
- content models (JSON Schema), JSON 模式
- 引數
- 事務之間, 處理錯誤和中止
- 遮蔽執行緒, 程序暫停
- 樂觀併發控制的效能, 悲觀併發控制與樂觀併發控制
- 雙相鎖定, 兩階段鎖定的效能
- 上下文開關, 延遲與響應時間, 程序暫停
- 收斂, 自動衝突解決-CRDT 與操作變換
- 協調
- 協調者, 兩階段提交(2PC)
- 複製寫(B- 樹), B 樹變體, 索引與快照隔離
- 公共物件請求代理體系結構, 遠端過程呼叫(RPC)的問題
- coronal mass ejection(見 solar storm)
- 正確性
- 資料腐敗
- 餘弦相似性(語義搜尋), 向量嵌入
- Couchbase(資料庫)
- 文件資料模型, 關係模型與文件模型
- 永續性, 全記憶體儲存
- 雜湊變硬, 固定數量的分片
- 加入支援, 文件和關聯式資料庫的融合
- 再平衡, 運維:自動/手動再平衡
- vBuckets(硬化), 分片
- CouchDB(資料庫)
- 耦合(鬆緊), 可演化性:讓變化更容易
- 覆蓋索引, 在索引中儲存值
- CozoDB(資料庫), Datalog:遞迴關係查詢
- CPUs
- 快取一致性和記憶體障礙, 線性一致性與網路延遲
- 緩衝和管道, 查詢執行:編譯與向量化
- 計算錯誤的結果, 硬體與軟體故障
- SIMD instructions, 查詢執行:編譯與向量化
- 斷層和斷層, 系統模型與現實
- CRDTs(見 conflict-free replicated datatypes)
- CREATE INDEX statement (SQL), 多列索引與二級索引, 建立索引
- 信用評級機構, 責任與問責
- 加密重新整理, 事件溯源與 CQRS, 不變性的侷限性
- 密碼, 總結
- 密碼學
- CSV (comma-separated values), OLTP 系統的儲存與索引, JSON、XML 及其二進位制變體
- Curator (ZooKeeper recipes), 鎖定與領導者選舉, 將工作分配給節點
- Cypher(查詢語言), Cypher 查詢語言
- comparison to SPARQL, SPARQL 查詢語言
D
- Daft(處理框架)
- DataFrames, DataFrames
- 移動資料, 混洗資料
- Dagster(工作流排程器), 持久化執行與工作流, 批處理, 工作流排程
- 雲資料倉整合, 查詢語言
- 儀表板(業務情報), 事務處理與分析的特徵
- Dask(處理框架), 資料框、矩陣與陣列
- 資料目錄, 雲資料倉儲
- 資料聯結器, 資料倉儲
- 資料合同, 提取-轉換-載入(ETL)
- 資料變更捕獲, 資料變更捕獲與事件溯源
- data corruption(見 corruption of data)
- 資料方塊, 物化檢視與資料立方體
- 資料工程, 分析型與事務型系統
- 資料結構, 提取-轉換-載入(ETL)
- data formats(見 編碼)
- 資料基礎設施, 資料系統架構中的權衡
- 資料整合, 資料整合-統一批處理和流處理, 本章小結
- 批次和流處理, 批處理與流處理-統一批處理和流處理
- 保持衍生狀態, 維護派生狀態
- 後處理資料, 應用演化後重新處理資料
- 統一, 統一批處理和流處理
- 透過解開資料庫, 分拆資料庫-多分割槽資料處理
- 與聯邦資料庫的比較, 一切的後設資料庫
- 透過生成資料合併工具, 組合使用派生資料的工具-排序事件以捕獲因果關係
- 衍生資料與分散式事務, 派生資料與分散式事務
- 總訂單的限制, 全序的限制
- 命令事件捕獲因果關係, 排序事件以捕獲因果關係
- 關於資料流的推理, 理解資料流
- 需求, 記錄系統與派生資料
- 使用批次處理, 批處理, 提取-轉換-載入(ETL)
- 批次和流處理, 批處理與流處理-統一批處理和流處理
- 資料湖, 從資料倉儲到資料湖
- 資料湖區, 雲資料倉儲, 分析(Analytics)
- data locality(見 區域性)
- 資料網格, 提取-轉換-載入(ETL)
- 資料最小化, 資料系統、法律與社會, 立法與自律
- 資料模型, 資料模型與查詢語言-總結
- DataFrames and arrays, 資料框、矩陣與陣列
- 類似圖表的模型, 圖資料模型-GraphQL
- 資料日誌語言, Datalog:遞迴關係查詢-Datalog:遞迴關係查詢
- 屬性圖, 屬性圖
- RDF and triple-stores, 三元組儲存與 SPARQL-SPARQL 查詢語言
- 關係模型對文件模型, 關係模型與文件模型-文件和關聯式資料庫的融合
- 支援多個, 事件溯源與 CQRS
- 資料管道, 從資料倉儲到資料湖, 記錄系統與派生資料, 提取-轉換-載入(ETL)
- 資料產品, 超越資料湖
- data protection regulations(見 GDPR)
- 資料居住法, 分散式與單節點系統, 面向多租戶的分片
- 資料科學, 分析型與事務型系統, 從資料倉儲到資料湖
- 資料倉, 資料倉儲
- 資料系統
- 資料儲存, 資料倉儲, 術語表
- 資料密集型應用, 資料系統架構中的權衡
- 資料庫管理員, 雲時代的運維
- 內部分散式事務, 跨不同系統的分散式事務, 資料庫內部的分散式事務, 原子提交再現
- 資料庫
- 歸檔儲存, 歸檔儲存
- 信件經紀人的比較, 訊息代理與資料庫的對比
- 資料流, 流經資料庫的資料流
- 端到端引數, 端到端原則-在資料系統中應用端到端思考
- 檢查完整性, 端到端原則重現
- 與事件流的關係, 資料庫與流-不變性的侷限性
- (另見 changelogs)
- 變更流的 API 支援, 變更流的 API 支援, 應用程式碼和狀態的分離
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- 事件溯源, 資料變更捕獲與事件溯源
- 保持系統同步, 保持系統同步-保持系統同步
- 不可改變事件哲學, 狀態、流和不變性-不變性的侷限性
- 分拆, 分拆資料庫-多分割槽資料處理
- 資料中心
- 資料流動, 資料流的模式-分散式 actor 框架, 圍繞資料流設計應用-流處理器和服務
- 資料流系統的正確性, 資料流系統的正確性
- 資料流引擎, 資料流引擎
- 與流處理的比較, 流處理
- DataFrames, DataFrames
- 批次處理框架中的支援, 批處理
- 事件驅動, 事件驅動的架構-分散式 actor 框架
- 關於, 理解資料流
- 透過資料庫, 流經資料庫的資料流
- 透過服務, 流經服務的資料流:REST 與 RPC-RPC 的資料編碼與演化
- workflow engines(見 workflow engines)
- DataFrames, 資料框、矩陣與陣列
- 執行, DataFrames
- 分批處理, DataFrames
- 在筆記本中, 機器學習
- 批次處理框架中的支援, 批處理
- DataFusion(查詢引擎), 雲資料倉儲
- Datalog(查詢語言), Datalog:遞迴關係查詢-Datalog:遞迴關係查詢
- 資料流(變化資料捕獲), 變更流的 API 支援
- 資料型別
- binary strings in XML and JSON, JSON、XML 及其二進位制變體
- 無衝突, CRDT 與操作變換
- 在 Avro 編碼中, Avro
- 在協議緩衝中, 欄位標籤與模式演化
- numbers in XML and JSON, JSON、XML 及其二進位制變體
- 日期和日期, 資料系統、法律與社會
- Datomic(資料庫)
- B-樹木儲存, 索引與快照隔離
- 資料模型, 圖資料模型, 三元組儲存與 SPARQL
- 資料日誌查詢語言, Datalog:遞迴關係查詢
- 切除, 不變性的侷限性
- 事務語言, 儲存過程的利弊
- 事務的序列執行, 實際序列執行
- Daylight Saving Time (DST), 日曆時鐘
- Db2(資料庫)
- 資料變更捕獲, 資料變更捕獲的實現
- DBA (database administrator), 雲時代的運維
- 僵局, 顯式鎖定
- Debezium(變化資料捕獲), 資料變更捕獲的實現
- 卡桑德拉島, 變更流的 API 支援
- 資料整合, 分拆系統與整合系統
- 宣告語言, 資料模型與查詢語言, 術語表
- 並同步引擎, 同步引擎的利弊
- 資料日誌, Datalog:遞迴關係查詢
- 文件資料庫中, 文件和關聯式資料庫的融合
- recursive SQL queries, SQL 中的圖查詢
- SPARQL, SPARQL 查詢語言
- DeepSeek
- 3FS(見 3FS)
- 延遲
- 刪除資料, 不變性的侷限性
- in LSM storage, 磁碟空間使用
- 法律依據, 資料系統、法律與社會
- Delta Lake(表格式), 構建和合並 SSTable, 雲資料倉儲
- 硬化和叢集, 按雜湊範圍分片
- 非軍事區(聯網), 對外提供派生資料
- 非正常化(資料表示), 正規化、反正規化與連線-多對一與多對多關係, 術語表
- 在衍生資料系統中, 記錄系統與派生資料
- in event sourcing/CQRS, 事件溯源與 CQRS
- 社會網路案例研究, 社交網路案例研究中的反正規化
- 實際意見, 物化檢視與資料立方體
- 更新衍生資料, 單物件與多物件操作, 多物件事務的需求, 組合使用派生資料的工具
- 相對於正常化, 從同一事件日誌中派生多個檢視
- 衍生資料, 記錄系統與派生資料, 流處理, 術語表
- 批處理, 批處理
- 事件溯源與 CQRS, 事件溯源與 CQRS
- 從變化資料抓取, 資料變更捕獲的實現
- 透過日誌維護匯出狀態, 資料庫與流-變更流的 API 支援, 狀態、流和不變性-併發控制
- 透過對流的訂閱來觀察, 端到端的事件流
- 批次和流處理的產出, 批處理與流處理
- 透過應用程式程式碼, 應用程式碼作為派生函式
- 相對於已分配事務, 派生資料與分散式事務
- 設計模式, 簡單性:管理複雜度
- 決定性行動, 儲存過程的利弊, 故障與部分失效, 術語表
- 確定性模擬測試(DST), 確定性模擬測試
- DevOps, 雲時代的運維
- 維度表, 星型與雪花型:分析模式
- dimensional modeling(見 star schemas)
- directed acyclic graphs (DAG)
- 工作流程, 工作流排程
- (另見 workflow engines)
- 工作流程, 工作流排程
- 髒讀, 沒有髒讀
- 髒字(事務隔離), 沒有髒寫
- 分類
- 儲存和計算, 儲存與計算的分離
- discord(分組聊天)
- GraphQL example, GraphQL
- 歧視, 偏見與歧視
- disks(見 hard disks)
- 分散式行為者框架, 分散式 actor 框架
- 分散式檔案系統, 分散式檔案系統-分散式檔案系統
- 已分發分類賬, 總結
- 分散式系統, 分散式系統的麻煩-總結, 術語表
- distributed transactions(見 transactions)
- Django(網路框架), 處理錯誤和中止
- DMZ (demilitarized zone), 對外提供派生資料
- DNS (Domain Name System), 請求路由, 服務發現
- 用於負載平衡, 負載均衡器、服務發現和服務網格
- Docker (集裝箱管理器), 應用程式碼和狀態的分離
- 文件資料模型, 關係模型與文件模型-文件和關聯式資料庫的融合
- 比較關係模式, 何時使用哪種模型-文件和關聯式資料庫的融合
- 多物件事務, 需要, 多物件事務的需求
- 硬化二級指數, 分片與二級索引
- 相對關係模式
- 模式的趨同, 文件和關聯式資料庫的融合
- 資料位置, 讀寫的資料區域性
- document-partitioned indexes(見 local secondary indexes)
- 領域驅動設計, 簡單性:管理複雜度, 事件溯源與 CQRS
- 點版向量, 版本向量
- 雙重登入簿記, 總結
- DRBD (Distributed Replicated Block Device), 單主複製
- 漂移(小時), 時鐘同步和準確性
- Druid(資料庫), 事務處理與分析的特徵, 列式儲存, 從同一事件日誌中派生多個檢視
- 處理寫入, 寫入列式儲存
- 預彙總, 分析(Analytics)
- 服務衍生資料, 對外提供派生資料
- Dryad(資料流引擎), 資料流引擎
- 雙寫、 問題, 保持系統同步
- DuckDB(資料庫), 分散式系統的問題, 壓實策略
- 面向列的儲存, 列式儲存
- 用於 ETL, 提取-轉換-載入(ETL)
- 減少重複,消除, 抑制重複
- 永續性, 使 B 樹可靠, 永續性, 術語表
- 持久執行, 持久化執行與工作流
- 依賴決定性因素, 確定性模擬測試
- Restate(見 Restate (workflow engine))
- Temporal(見 Temporal (workflow engine))
- durable functions(見 workflow engines)
- 時間(時間), 不可靠的時鐘
- 用單音鍾測量, 單調時鐘
- 動態輸入語言
- 類比於閱讀時的圖案, 文件模型中的模式靈活性
- Dynamo(資料庫), 無主複製
- Dynamo-style databases(見 leaderless replication)
- DynamoDB(資料庫)
- 自動縮放, 運維:自動/手動再平衡
- 雜湊變硬, 按雜湊範圍分片
- 基於領導者的複製, 單主複製
- 硬化二級指數, 全域性二級索引
E
- EBS(虛擬塊裝置), 儲存與計算的分離
- 比較物件儲存, 設定新的副本
- ECC(見 error-correcting codes)
- EDB Postgres Distributed(資料庫), 跨地域執行
- 邊緣(圖), 圖資料模型
- 屬性圖模型, 屬性圖
- 編輯距離(全文搜尋), 全文檢索
- 有效即時語義, 容錯, 恰好執行一次操作
- (另見 恰好一次語義)
- 維護完整性, 資料流系統的正確性
- Elastic Compute Cloud (EC2)
- 現場例項, 故障處理
- 彈性, 分散式與單節點系統
- 彈性搜尋(搜尋伺服器)
- 精靈(程式語言), 端到端的事件流
- ELT (extract-load-transform), 資料倉儲
- 與批次處理的關係, 提取-轉換-載入(ETL)
- 嚴重平行(演算法)
- 提取-轉換-載入(ETL)(見 ETL)
- MapReduce, MapReduce
- (另見 MapReduce)
- 嵌入式儲存引擎, 壓實策略
- 嵌入(顯示器), 向量嵌入
- 編碼(資料格式), 編碼與演化-模式的優點
- Avro, Avro-動態生成的模式
- binary variants of JSON and XML, 二進位制編碼
- 相容性, 編碼與演化
- 電話服務, RPC 的資料編碼與演化
- 使用資料庫, 流經資料庫的資料流-歸檔儲存
- 定義, 編碼資料的格式
- JSON, XML, and CSV, JSON、XML 及其二進位制變體
- 語言特定格式, 特定語言的格式
- 計劃的價值, 模式的優點
- Protocol Buffers, Protocol Buffers-欄位標籤與模式演化
- 資料說明, 編碼資料的格式
- 端到端原則, 端到端原則-在資料系統中應用端到端思考
- 濃縮(流), 流表連線(流擴充)
- Enterprise JavaBeans (EJB), 遠端過程呼叫(RPC)的問題
- 企業軟體, 資料系統架構中的權衡
- entities(見 vertices)
- 電子儲存, 儲存與計算的分離
- 時代(協商一致演算法), 從單主複製到共識
- 時代(Unix 時間戳), 日曆時鐘
- 清除編碼(錯誤校正), 分散式檔案系統
- 錯誤處理
- 錯誤更正程式碼, 硬體與軟體故障, 分散式檔案系統
- Esper (CEP engine), 複合事件處理
- 基本複雜性, 簡單性:管理複雜度
- 協調事務, 協調服務-服務發現
- 生成柵欄標誌, 隔離殭屍程序和延遲請求, 協調服務
- 線性操作, 實現線性一致性系統, 共識的微妙之處
- 鎖和領袖選舉, 鎖定與領導者選舉
- 用於服務發現, 負載均衡器、服務發現和服務網格, 服務發現
- 用於硬性轉讓, 請求路由
- 使用 Raft 演算法, 單主複製
- 伊特魯姆(塊鏈), 用於可審計資料系統的工具
- 乙太網(網路), 雲端計算與超級計算, 不可靠的網路, 我們不能簡單地使網路延遲可預測嗎?
- 道德操守, 做正確的事情-立法與自律
- ETL, 資料倉儲, 保持系統同步, 術語表
- 與批次處理的關係, 提取-轉換-載入(ETL)-提取-轉換-載入(ETL)
- 使用批次處理, 批處理
- 歐幾利得距離(語義搜尋), 向量嵌入
- European Union
- AI Act(見 AI Act)
- GDPR(見 GDPR)
- 事件溯源, 事件溯源與 CQRS-事件溯源與 CQRS
- 並更改資料捕獲, 資料變更捕獲與事件溯源
- 與變化資料捕獲的比較, 資料變更捕獲與事件溯源
- 不可更改性和可審計性, 狀態、流和不變性, 為可審計性而設計
- 大型可靠資料系統, 操作識別符號, 資料流系統的正確性
- 依賴決定性因素, 確定性模擬測試
- event streams(見 streams)
- 事件驅動的架構, 事件驅動的架構-分散式 actor 框架
- 分散式行為者框架, 分散式 actor 框架
- 事件, 傳遞事件流
- EventSource (browser API), 將狀態變更推送給客戶端
- EventStoreDB(資料庫), 事件溯源與 CQRS
- 最終一致性, 複製, 複製延遲的問題, 安全性與活性
- 證據
- 資料用作, 人類與可靠性
- 可演化性, 可演化性:讓變化更容易, 編碼與演化
- 電話服務, RPC 的資料編碼與演化
- 事件溯源, 事件溯源與 CQRS
- 圖表結構資料, 屬性圖
- 資料庫, 文件模型中的模式靈活性, 流經資料庫的資料流-歸檔儲存, 從同一事件日誌中派生多個檢視, 應用演化後重新處理資料
- 後處理資料, 應用演化後重新處理資料, 統一批處理和流處理
- Avro 的策略進化, 寫入者模式與讀取者模式
- 協議緩衝的策略演變, 欄位標籤與模式演化
- 閱讀時的圖謀, 文件模型中的模式靈活性, 編碼與演化, 模式的優點
- 恰好一次語義, 恰好一次訊息處理, 再談恰好一次訊息處理, 容錯, 恰好執行一次操作
- 獨佔模式, 兩階段鎖定的實現
- 指數備份, 描述效能, 處理錯誤和中止
- ext4 (file system), 分散式檔案系統
- eXtended Architecture transactions(見 XA 事務)
- ETL(見 提取-轉換-載入(ETL))
F
- 臉書
- 事實
- 事實表(星圖), 星型與雪花型:分析模式
- 在資料日誌中, Datalog:遞迴關係查詢
- 如果來源, 事件溯源與 CQRS
- 慢故障, 系統模型與現實
- 失敗停止模式, 系統模型與現實
- 故障切換, 領導者故障:故障轉移, 術語表
- (另見 基於領導者的複製)
- 無領導複製,沒有, 當節點故障時寫入資料庫
- 領袖選舉, 分散式鎖和租約, 共識, 從單主複製到共識
- 潛在問題, 領導者故障:故障轉移
- 失敗
- 費斯(媒介指數), 向量嵌入
- 假陽性(Bloom 過濾器), 布隆過濾器
- 扇出, 時間線的物化與更新, 多個消費者
- 斷層注射, 容錯, 實踐中的網路故障, 故障注入
- 斷層隔離, 面向多租戶的分片
- 過失容忍, 可靠性與容錯-人類與可靠性, 術語表
- 錯誤
- 特性工程(機器學習), 從資料倉儲到資料湖
- 聯邦資料庫, 一切的後設資料庫
- Feldera(資料庫)
- 增量檢視維護, 維護物化檢視
- 圍欄, 線性一致性與網路延遲
- 屏障, 領導者故障:故障轉移, 隔離殭屍程序和延遲請求-多副本隔離
- 獲取和新增
- 與協商一致的關係, 獲取並增加作為共識
- 纖維通道(網路), 分散式檔案系統
- 欄位標記(協議緩衝), Protocol Buffers-欄位標籤與模式演化
- Figma (圖形軟體), 實時協作、離線優先和本地優先應用
- filesystem in userspace (FUSE), 設定新的副本, 分散式檔案系統
- 在物件儲存中, 物件儲存
- 財務資料
- 五特蘭, 資料倉儲
- FizzBee (specification language), 模型檢查與規範語言
- 平面指數(媒介指數), 向量嵌入
- FlatBuffers(資料格式), 編碼資料的格式
- Flink(處理框架), 批處理, 資料流引擎
- 流量控制, TCP 的侷限性, 訊息傳遞系統, 術語表
- FLP result (on consensus), 共識
- Flyte(工作流排程器), 機器學習
- 追隨者, 單主複製, 術語表
- (另見 基於領導者的複製)
- 正式方法, 形式化方法和隨機測試-確定性模擬測試
- 轉發相容性, 編碼與演化
- 前進衰變(演算法), 響應時間指標的應用
- 化石(版本控制系統), 併發控制
- 避免, 不變性的侷限性
- FoundationDB(資料庫)
- 一致性模式, 什麼使系統具有線性一致性?
- 確定性模擬測試, 確定性模擬測試
- 鍵程硬化, 按鍵的範圍分片
- 程序/核心模式, 分片的利與弊
- 可序列事務, 可序列化快照隔離(SSI), 可序列化快照隔離的效能
- 事務, 事務到底是什麼?, 資料庫內部的分散式事務
- 分數索引, 何時使用哪種模型
- 碎裂(B樹), 磁碟空間使用
- 框架(計算機圖形), 同步引擎的利弊
- 前端 (網頁開發), 資料系統架構中的權衡
- FrostDB(資料庫)
- 確定性模擬測試(DST), 確定性模擬測試
- fsync (系統呼叫), 使 B 樹可靠, 永續性
- 全文檢索, 全文檢索, 術語表
- Function as a Service (FaaS), 微服務與無伺服器
- 職能方案擬訂
- inspiration for MapReduce, MapReduce
- 職能要求, 定義非功能性需求
- FUSE(見 filesystem in userspace (FUSE))
- 模糊, 形式化方法和隨機測試
- fuzzy search(見 similarity search)
G
- Gallina(特寫語言), 模型檢查與規範語言
- 遊戲開發, 同步引擎的利弊
- 垃圾收集
- 加油站演算法定價, 反饋迴路
- GDPR (regulation), 資料系統、法律與社會, 不變性的侷限性
- GenBank (genome database), 總結
- General Data Protection Regulation(見 GDPR (regulation))
- 基因組分析, 總結
- geographic distribution(見 regions (geographic distribution))
- 地理空間指數, 多維索引與全文索引
- Git(版本控制系統), 併發控制
- 本地第一軟體, 實時協作、離線優先和本地優先應用
- 合併衝突, 手動衝突解決
- GitHub, postmortems, 領導者故障:故障轉移, 領導者故障:故障轉移, 將系統模型對映到現實世界
- 全球二級指數, 全域性二級索引, 總結
- globally unique identifiers(見 UUIDs)
- GlusterFS(分散式檔案系統), 批處理, 分散式檔案系統, 物件儲存
- GNU Coreutils (Linux), 排序與記憶體聚合
- Go(程式語言)
- 垃圾收集, 限制垃圾回收的影響
- GoldenGate (change data capture), 資料變更捕獲的實現
- (另見 Oracle)
- 谷歌
- BigQuery(見 BigQuery(資料庫))
- Bigtable(見 Bigtable(資料庫))
- Chubby(鎖服務), 協調服務
- Cloud Storage(物件儲存), 設定新的副本, 物件儲存
- 請求先決條件, 隔離殭屍程序和延遲請求
- Compute Engine
- 預設例項, 故障處理
- 資料流(流程處理)
- 資料流(流處理器), 流分析, 原子提交再現, 統一批處理和流處理
- (另見 Beam)
- 資料流(變化資料捕獲), 變更流的 API 支援
- Docs(協作編輯), 實時協作、離線優先和本地優先應用, CRDT 與操作變換
- 操作轉換, CRDT 與操作變換
- Dremel(查詢引擎), 列式儲存
- Firestore(資料庫), 同步引擎的利弊
- MapReduce (batch processing), 批處理
- (另見 MapReduce)
- Percolator(事務系統), 實現線性一致的 ID 生成器
- 永續性磁碟(雲服務), 儲存與計算的分離
- Pub/Sub(訊息系統), 訊息代理, 訊息代理與資料庫的對比, 使用日誌進行訊息儲存
- 響應時間研究, 平均值、中位數與百分位點
- 工作表(協作電子表格), 實時協作、離線優先和本地優先應用, CRDT 與操作變換
- Spanner(見 Spanner(資料庫))
- TrueTime (clock API), 帶置信區間的時鐘讀數
- 流言協議, 請求路由
- 治理, 超越資料湖
- 政府對資料的使用, 資料作為資產與權力
- GPS (Global Positioning System)
- 用於時鐘同步, 不可靠的時鐘, 時鐘同步和準確性, 帶置信區間的時鐘讀數, 用於全域性快照的同步時鐘
- GPT (language model), 向量嵌入
- GPU (graphics processing unit), 雲服務的分層, 分散式與單節點系統
- gradual rollout(見 rolling upgrades)
- GraphQL(查詢語言), GraphQL
- 驗證, 儲存過程的利弊
- 圖表, 術語表
- 作為資料模型, 圖資料模型-GraphQL
- 屬性圖, 屬性圖
- RDF and triple-stores, 三元組儲存與 SPARQL-SPARQL 查詢語言
- DAGs(見 directed acyclic graphs)
- 處理和分析, 機器學習
- 查詢語言
- 密碼, Cypher 查詢語言
- 資料日誌, Datalog:遞迴關係查詢-Datalog:遞迴關係查詢
- GraphQL, GraphQL
- 格倫林, 圖資料模型
- recursive SQL queries, SQL 中的圖查詢
- SPARQL, SPARQL 查詢語言-SPARQL 查詢語言
- 轉彎, 屬性圖
- 作為資料模型, 圖資料模型-GraphQL
- 灰色失敗, 系統模型與現實
- 無領導複製, 單主與無主複製的效能
- 格勒姆林(圖形查詢語言), 圖資料模型
- grep (Unix 工具) (英語)., 簡單日誌分析
- gRPC (service calls), 微服務與無伺服器, Web 服務
- 前向和後向相容性, RPC 的資料編碼與演化
- GUIDs(見 UUIDs)
H
- Hadoop(資料基礎設施)
- HANA(見 SAP HANA(資料庫))
- 發生關係前, “先發生"關係與併發
- 硬碟
- 硬體故障, 硬體與軟體故障
- 雜湊函式
- 在 Bloom 過濾器中, 布隆過濾器
- 加入雜湊
- 在溪流處理中, 流表連線(流擴充)
- 雜湊變硬, 按鍵的雜湊分片-一致性雜湊, 總結
- 雜湊表格, 日誌結構儲存
- Hazelcast(模擬資料網)
- FencedLock, 隔離殭屍程序和延遲請求
- Flake ID Generator, ID 生成器和邏輯時鐘
- HBase(資料庫)
- HDFS (Hadoop Distributed File System), 批處理, 分散式檔案系統
- HdrHistogram (numerical library), 響應時間指標的應用
- 頭 (Unix 工具), 簡單日誌分析, 分散式作業編排
- 頭頂(財產圖), 屬性圖
- 頭部阻塞, 延遲與響應時間
- 堆積檔案(資料庫), 在索引中儲存值
- 多轉換併發控制, 多版本併發控制(MVCC)
- 熱量管理, 偏斜的工作負載與緩解熱點
- 被套期請求, 單主與無主複製的效能
- 分散事務, 跨不同系統的分散式事務, XA 事務的問題
- 啟發式決策, 從協調器故障中恢復
- 十六進位制(註解本), 機器學習
- 六邊形
- 地理空間索引, 多維索引與全文索引
- Hibernate(物件關係對映器), 物件關係對映(ORM)
- 層次模型, 關係模型與文件模型
- 可導航的小世界(媒介指數), 向量嵌入
- hierarchical queries(見 recursive common table expressions)
- high availability(見 fault tolerance)
- 高頻事務, 時鐘同步和準確性
- high-performance computing (HPC), 雲端計算與超級計算
- 提示移交, 追趕錯過的寫入
- 直方圖, 響應時間指標的應用
- 蜂窩(資料倉), 雲資料倉儲
- 查詢最佳化器, 查詢語言
- HNSW (vector index), 向量嵌入
- 購物視窗(流程處理), 視窗的型別
- (另見 windows)
- Hoptimator(查詢引擎), 一切的後設資料庫
- 地平線醜聞, 人類與可靠性
- 缺乏事務, 事務
- horizontal scaling(見 scaling out)
- 透過磨損, 分片的利與弊
- HornetQ(訊息系統), 訊息代理, 訊息代理與資料庫的對比
- 分散式事務支援, XA 事務
- 熱鍵, 鍵值資料的分片
- 熱點, 鍵值資料的分片
- 由於名人, 偏斜的工作負載與緩解熱點
- 時間序列資料, 按鍵的範圍分片
- 解除武裝, 偏斜的工作負載與緩解熱點
- hot standbys(見 基於領導者的複製)
- HTAP(見 hybrid transactional/analytic processing)
- HTTP, use in APIs(見 services)
- 人類錯誤, 人類與可靠性, 實踐中的網路故障, 批處理
- 混合邏輯時鐘, 混合邏輯時鐘
- 混合事務/分析處理, 資料倉儲, 分析型資料儲存
- hydrating IDs (join), 社交網路案例研究中的反正規化
- 高頻圖, 屬性圖
- HyperLogLog (algorithm), 流分析
I
- I/O operations, waiting for, 程序暫停
- IaaS(見 infrastructure as a service (IaaS))
- IBM
- Db2(資料庫)
- 分散式事務支援, XA 事務
- 可序列隔離, 快照隔離、可重複讀和命名混淆, 兩階段鎖定的實現
- MQ(訊息系統), 訊息代理與資料庫的對比
- 分散式事務支援, XA 事務
- System R(資料庫), 事務到底是什麼?
- WebSphere(訊息系統), 訊息代理
- Db2(資料庫)
- Iceberg(表格式), 雲資料倉儲
- 冪等性, 遠端過程呼叫(RPC)的問題, 冪等性, 術語表
- by giving operations unique IDs, 多分割槽請求處理
- by giving requests unique IDs, 操作識別符號
- 對於完全的語義, 再談恰好一次訊息處理
- 一元業務, 恰好執行一次操作
- 工作流程引擎中, 持久化執行
- 不可改變性
- 好處, 不可變事件的優點, 為可審計性而設計
- 和清除的權利, 資料系統、法律與社會, 磁碟空間使用
- 刪除加密, 事件溯源與 CQRS, 不變性的侷限性
- 從事件日誌中獲取狀態, 狀態、流和不變性-不變性的侷限性
- 事故恢復, 構建和合並 SSTable
- 在B樹上, B 樹變體, 索引與快照隔離
- 如果來源, 事件溯源與 CQRS, 資料變更捕獲與事件溯源
- 限制, 併發控制
- 阻抗不匹配, 物件關係不匹配
- 存疑, 協調器故障
- 模擬資料庫, 全記憶體儲存
- 事件
- 導致錯誤定罪的會計軟體錯誤, 人類與可靠性
- 無咎死後, 人類與可靠性
- 跳躍秒墜機, 時鐘同步和準確性
- 資料腐敗和貨幣錯誤造成的經濟損失, 弱隔離級別
- 硬碟上的資料腐敗, 永續性
- 資料損失,因最後寫成, 用於事件排序的時間戳
- 磁碟上無法讀取的資料, 將系統模型對映到現實世界
- 由於重用主鑰匙而披露敏感資料, 領導者故障:故障轉移
- 事務序列性中的錯誤, 維護完整性,儘管軟體有Bug
- gigabit network interface with 1 Kb/s throughput, 系統模型與現實
- 跳躍第二次崩潰, 軟體故障
- 網路斷層, 實踐中的網路故障
- 網路介面只放下入境包, 實踐中的網路故障
- 網路分割槽和全資料中心故障, 故障與部分失效
- 網路故障處理不當, 實踐中的網路故障
- 向前合夥人傳送訊息, 排序事件以捕獲因果關係
- 咬海底電纜的鯊魚, 實踐中的網路故障
- split brain due to 1-minute packet delay, 領導者故障:故障轉移, 實踐中的網路故障
- SSD failure after 32,768 hours, 軟體故障
- 執行緒爭吵導致服務下降, 程序暫停
- 伺服器架中的振動, 延遲與響應時間
- 違反獨特性限制, 維護完整性,儘管軟體有Bug
- incremental view maintenance (IVM), 維護物化檢視
- 資料整合, 分拆系統與整合系統
- 索引, OLTP 系統的儲存與索引, 術語表
- 並快照隔離, 索引與快照隔離
- 作為衍生資料, 記錄系統與派生資料, 組合使用資料儲存技術-分拆系統與整合系統
- B樹, B 樹-B 樹變體
- 分組, 在索引中儲存值
- comparison of B-trees and LSM-trees, 比較 B 樹與 LSM 樹-磁碟空間使用
- 覆蓋(包括各欄), 在索引中儲存值
- 建立, 建立索引
- 全文檢索, 全文檢索
- 地理空間, 多維索引與全文索引
- 索引範圍鎖定, 索引範圍鎖
- 多列(壓縮), 多維索引與全文索引
- 中學, 多列索引與二級索引
- 硬化指數和二級指數, 分片與二級索引-全域性二級索引, 總結
- 人煙稀少, SSTable 檔案格式
- SSTable 與 LSM 樹, SSTable 檔案格式-壓實策略
- 資料變化時更新, 保持系統同步, 維護物化檢視
- Industrial Revolution, 回顧工業革命
- InfiniBand (networks), 我們不能簡單地使網路延遲可預測嗎?
- InfluxDB IOx (storage engine), 列式儲存
- information retrieval(見 全文檢索)
- infrastructure as a service (IaaS), 雲服務與自託管, 雲服務的分層
- InnoDB (storage engine)
- 例項(雲端計算), 雲服務的分層
- integrating different data systems(見 資料整合)
- 誠信, 及時性與完整性
- Interface Definition Language (IDL), Protocol Buffers, Avro, Web 服務
- 不變式, 一致性
- (另見 constraints)
- 反向檔案索引(向量索引), 向量嵌入
- 倒轉索引, 全文檢索
- 不可逆轉,儘量減少, 可演化性:讓變化更容易, 事件溯源與 CQRS, 批處理
- ISDN (Integrated Services Digital Network), 同步與非同步網路
- 隔離性
- cgroups(見 cgroups)
- 隔離性, 隔離性, 單物件與多物件操作, 術語表
- 正確性和, 追求正確性
- 用於單物件寫入, 單物件寫入
- 可序列化, 可序列化-可序列化快照隔離的效能
- 實際執行, 實際序列執行-序列執行總結
- 可序列化快照隔離, 可序列化快照隔離(SSI)-可序列化快照隔離的效能
- 兩階段鎖定, 兩階段鎖定(2PL)-索引範圍鎖
- 違反, 單物件與多物件操作
- 薄弱的隔離水平, 弱隔離級別-物化衝突
- IVF (vector index), 向量嵌入
J
- 資料庫連線
- Java Enterprise Edition (EE), 遠端過程呼叫(RPC)的問題, 兩階段提交(2PC), XA 事務
- Java Message Service (JMS), 訊息代理與資料庫的對比
- (另見 messaging systems)
- 比較基於日誌的郵件, 日誌與傳統的訊息傳遞相比, 重播舊訊息
- 分散式事務支援, XA 事務
- 訊息順序, 確認與重新傳遞
- Java Transaction API (JTA), 兩階段提交(2PC), XA 事務
- Java Virtual Machine (JVM)
- 垃圾收集, 程序暫停, 限制垃圾回收的影響
- JIT compilation, 查詢執行:編譯與向量化
- 批次處理器中的工藝再利用, 資料流引擎
- Jena (RDF framework), RDF 資料模型
- SPARQL 查詢語言, SPARQL 查詢語言
- Jepsen(過失容忍度測試), 故障注入, 追求正確性
- jitter (網路延遲), 平均值、中位數與百分位點, 網路擁塞和排隊
- JMESPath(查詢語言), 查詢語言
- 合併表格, 多對一與多對多關係, 屬性圖
- 加入, 術語表
- 作為關係運算子表示, 查詢語言
- handling GraphQL query, GraphQL
- 應用程式程式碼, 正規化、反正規化與連線, 社交網路案例研究中的反正規化
- in DataFrames, 資料框、矩陣與陣列
- 關聯式資料庫和文件資料庫, 正規化、反正規化與連線
- 二級指數和, 多列索引與二級索引
- 排序合併, JOIN 與 GROUP BY
- 串流連線, 流連線-連線的時間依賴性
- 串流流連線, 流流連線(視窗連線)
- 序列表連線, 流表連線(流擴充)
- 表格連線, 表表連線(維護物化檢視)
- 時間的依賴性, 連線的時間依賴性
- 文件資料庫中的支援, 文件和關聯式資料庫的融合
- JOTM (transaction coordinator), 兩階段提交(2PC)
- 日記(檔案系統), 使 B 樹可靠
- JSON
- 管道彙總(用克里語), 文件的查詢語言
- Avro 方案說明, Avro
- 二進位制變體, 二進位制編碼
- 資料位置, 讀寫的資料區域性
- 文件資料模型, 關係模型與文件模型
- 應用資料的問題, JSON、XML 及其二進位制變體
- GraphQL response, GraphQL
- 關聯式資料庫, 文件模型中的模式靈活性
- 代表簡歷(例), 用於一對多關係的文件資料模型
- 模式, JSON 模式
- JSON-LD, 三元組儲存與 SPARQL
- JsonPath(查詢語言), 查詢語言
- JuiceFS(分散式檔案系統), 分散式檔案系統, 物件儲存
- 朱皮特(註解本), 機器學習
- just-in-time (JIT) compilation, 查詢執行:編譯與向量化
K
- Kafka(訊息系統), 訊息代理, 使用日誌進行訊息儲存
- 消費者群體, 多個消費者
- 資料整合, 分拆系統與整合系統
- 用於事件原始碼, 事件溯源與 CQRS
- Kafka 連線(資料庫整合), 資料變更捕獲的實現, 變更流的 API 支援, 從同一事件日誌中派生多個檢視
- 卡夫卡流(流處理器), 流分析, 維護物化檢視
- 恰好一次語義, 再談恰好一次訊息處理
- 過失容忍, 失敗後重建狀態
- ksqlDB (stream database), 維護物化檢視
- 基於領導者的複製, 單主複製
- 日誌壓縮, 日誌壓縮, 維護物化檢視
- 頁:1, 使用日誌進行訊息儲存, 冪等性
- 分割槽, 分片
- 請求路由, 請求路由
- 計劃登記, 但什麼是寫入者模式?
- 服務衍生資料, 對外提供派生資料
- 分層儲存, 磁碟空間使用
- 事務, 資料庫內部的分散式事務, 原子提交再現
- 不潔領袖選舉, 共識的微妙之處
- 使用模型檢查, 模型檢查與規範語言
- kappa 架構, 統一批處理和流處理
- 關鍵價值儲存, OLTP 系統的儲存與索引
- Kinesis(訊息系統), 訊息代理, 使用日誌進行訊息儲存
- 資料倉整合, 雲資料倉儲
- Kryo (Java), 特定語言的格式
- ksqlDB (stream database), 維護物化檢視
- Kubernetes(叢集經理), 雲服務與自託管, 微服務與無伺服器, 分散式作業編排, 應用程式碼和狀態的分離
- KùzuDB (database), 分散式系統的問題, 圖資料模型
- 作為嵌入式儲存引擎, 壓實策略
- Cypher 查詢語言, Cypher 查詢語言
L
- labeled property graphs(見 property graphs)
- 羊肉達建築, 統一批處理和流處理
- Lamport 時間戳, Lamport 時間戳
- Lance(資料格式), 雲資料倉儲, 列式儲存
- (另見 column-oriented storage)
- large language models (LLMs)
- 預處理培訓資料, 機器學習
- 最後寫入勝利, 最後寫入勝利(丟棄併發寫入), 檢測併發寫入, 實現線性一致性系統
- 問題, 用於事件排序的時間戳
- 容易丟失更新, 衝突解決與複製
- 延遲, 延遲與響應時間
- (另見 響應時間)
- 跨區域, 分散式與單節點系統
- 在兩階段鎖定下的不穩定, 兩階段鎖定的效能
- 網路延遲和資源利用, 我們不能簡單地使網路延遲可預測嗎?
- 根據請求減少套期保值, 單主與無主複製的效能
- 響應時間對比, 延遲與響應時間
- 尾延遲, 平均值、中位數與百分位點, 響應時間指標的應用, 本地二級索引
- law(見 legal matters)
- (雲服務), 雲服務的分層
- 基於領導者的複製, 單主複製-邏輯(基於行)日誌複製
- (另見 複製)
- 故障切換, 領導者故障:故障轉移, 分散式鎖和租約
- 處理節點斷電, 處理節點故障
- 實施複製日誌
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- (另見 changelogs)
- 基於語句的, 基於語句的複製
- 預寫日誌(WAL)傳輸, 預寫日誌(WAL)傳輸
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- 操作的可線性, 實現線性一致性系統
- 鎖定和領導者選舉, 鎖定與領導者選舉
- 日誌序列號, 設定新的副本, 消費者偏移量
- 讀縮放架構, 複製延遲的問題, 單主與無主複製的效能
- 與協商一致的關係, 共識, 從單主複製到共識, 共識的利弊
- 設立新的追隨者, 設定新的副本
- 同步對同步, 同步複製與非同步複製-同步複製與非同步複製
- 無領導複製, 無主複製-版本向量
- 跳躍秒, 軟體故障, 時鐘同步和準確性
- 時鐘, 日曆時鐘
- 租賃, 程序暫停
- 分類賬(會計), 總結
- 不可改變性, 不可變事件的優點
- 遺留系統,維護, 可維護性
- 法律事項, 資料系統、法律與社會-資料系統、法律與社會
- 資料刪除, 資料系統、法律與社會, 磁碟空間使用
- 資料儲存, 分散式與單節點系統, 面向多租戶的分片
- 隱私監管, 資料系統、法律與社會, 立法與自律
- legitimate interest (GDPR), 同意與選擇自由
- 平面壓縮, 壓實策略, 磁碟空間使用
- Levenshtein 自動地圖, 全文檢索
- 跛腳(部分失敗), 系統模型與現實
- 線性(專案管理軟體), 實時協作、離線優先和本地優先應用
- 線性代數, 資料框、矩陣與陣列
- 線性可縮放性, 描述負載
- 線性一致性, 複製延遲的解決方案, 線性一致性-線性一致性與網路延遲, 術語表
- 和共識, 共識
- 費用, 線性一致性的代價-線性一致性與網路延遲
- CAP定理, CAP 定理
- memory on multi-core CPUs, 線性一致性與網路延遲
- 定義, 什麼使系統具有線性一致性?-什麼使系統具有線性一致性?
- ID generation, 線性一致的 ID 生成器
- 協調事務, 協調服務
- 資料系統
- 避免協調, 無協調資料系統
- 不同複製方法, 實現線性一致性系統-線性一致性與仲裁
- 使用法定人數, 線性一致性與仲裁
- 在協商一致的制度中讀取, 共識的微妙之處
- 依賴, 依賴線性一致性-跨通道時序依賴
- 可序列性, 什麼使系統具有線性一致性?
- 連結資料, 三元組儲存與 SPARQL
- LinkedIn
- Espresso(資料庫), 但什麼是寫入者模式?
- LIquid(資料庫), Datalog:遞迴關係查詢
- 配置檔案(例), 用於一對多關係的文件資料模型
- Linux 跳過第二個錯誤, 軟體故障, 時鐘同步和準確性
- Litestream (備份工具), 設定新的副本
- 生活屬性, 安全性與活性
- LLVM (compiler), 查詢執行:編譯與向量化
- LMDB (storage engine), 壓實策略, B 樹變體, 索引與快照隔離
- 負載
- 負載平衡, 描述效能, 負載均衡器、服務發現和服務網格
- 硬體, 負載均衡器、服務發現和服務網格
- 軟體, 負載均衡器、服務發現和服務網格
- 使用信件經紀人, 多個消費者
- 裝彈, 描述效能
- 本地二級指數, 本地二級索引, 總結
- 本地第一軟體, 實時協作、離線優先和本地優先應用
- 區域性, 用於一對多關係的文件資料模型, 讀寫的資料區域性, 術語表
- 分批處理, 資料流引擎
- 在狀態客戶端, 同步引擎與本地優先軟體, 有狀態、可離線的客戶端
- 在溪流處理中, 流表連線(流擴充), 失敗後重建狀態, 流處理器和服務, 基於日誌訊息傳遞中的唯一性
- 地點透明度, 遠端過程呼叫(RPC)的問題
- 在演員模式中, 分散式 actor 框架
- 鎖定, 雲服務的利弊
- 鎖, 術語表
- 死鎖, 顯式鎖定, 兩階段鎖定的實現
- 分散式鎖定, 分散式鎖和租約-多副本隔離, 鎖定與領導者選舉
- 柵欄標誌, 隔離殭屍程序和延遲請求
- 與協調處合作執行, 協調服務
- 與協商一致的關係, 單值共識
- 用於事務隔離
- 在快照隔離中, 多版本併發控制(MVCC)
- in two-phase locking (2PL), 兩階段鎖定(2PL)-索引範圍鎖
- 使操作原子化, 原子寫操作
- 效能, 兩階段鎖定的效能
- 防止骯髒的寫作, 實現讀已提交
- 防止帶有索引範圍鎖的幽靈, 索引範圍鎖, 檢測影響先前讀取的寫入
- 讀取鎖(共享模式), 實現讀已提交, 兩階段鎖定的實現
- 共享模式和專屬模式, 兩階段鎖定的實現
- 分散式事務
- 實現衝突, 物化衝突
- 透過明確鎖定防止丟失更新, 顯式鎖定
- 日誌序列號, 設定新的副本, 消費者偏移量
- 邏輯時鐘, 用於事件排序的時間戳, ID 生成器和邏輯時鐘-使用邏輯時鐘強制約束, 排序事件以捕獲因果關係
- 最後寫成的, 最後寫入勝利(丟棄併發寫入)
- 讀後寫入一致性, 讀己之寫
- 混合邏輯時鐘, 混合邏輯時鐘
- 執行制約因素不足, 使用邏輯時鐘強制約束
- Lamport 時間戳, Lamport 時間戳
- 邏輯複製, 邏輯(基於行)日誌複製
- 用於獲取變化資料, 資料變更捕獲的實現
- LogicBlox(資料庫), Datalog:遞迴關係查詢
- 日誌(資料結構), OLTP 系統的儲存與索引, 共享日誌作為共識, 術語表
- (另見 shared logs)
- 不可改變性的好處, 不可變事件的優點
- 和清除的權利, 資料系統、法律與社會, 磁碟空間使用
- 壓實(Compaction), 構建和合並 SSTable, 壓實策略, 日誌壓縮, 狀態、流和不變性
- 流運算子狀態, 失敗後重建狀態
- 執行獨特性限制, 基於日誌訊息傳遞中的唯一性
- 基於日誌的資訊, 基於日誌的訊息代理-重播舊訊息
- 比較傳統訊息, 日誌與傳統的訊息傳遞相比, 重播舊訊息
- 減 減, 消費者偏移量
- 磁碟空間使用情況, 磁碟空間使用
- 重播舊信件, 重播舊訊息, 應用演化後重新處理資料, 統一批處理和流處理
- 緩慢的消費者, 當消費者跟不上生產者時
- 使用日誌儲存信件, 使用日誌進行訊息儲存
- 日誌結構儲存, OLTP 系統的儲存與索引-壓實策略
- log-structured merge tree(見 LSM-trees)
- 與協商一致的關係, 共享日誌作為共識
- 複製, 單主複製, 複製日誌的實現-邏輯(基於行)日誌複製
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- (另見 changelogs)
- 與快照協調, 設定新的副本
- 邏輯(基於row) 複製, 邏輯(基於行)日誌複製
- 基於語句的複製, 基於語句的複製
- 預寫日誌(WAL)傳輸, 預寫日誌(WAL)傳輸
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- 伸縮性限制, 全序的限制
- 瀏覽器(商業情報軟體), 事務處理與分析的特徵, 分析(Analytics)
- 松耦合, 開展分拆工作
- lost updates(見 updates)
- 蓮花筆記(同步引擎), 同步引擎的利弊
- LSM-trees (indexes), SSTable 檔案格式-壓實策略
- 與B樹的比較, 比較 B 樹與 LSM 樹-磁碟空間使用
- Lucene(儲存引擎), 全文檢索
- 相似性搜尋, 全文檢索
- 最後寫入勝利(見 最後寫入勝利)
M
- 機器學習
- 瘋狂(決定性模擬測試), 確定性模擬測試
- 萬金油, 可伸縮性原則
- 可維護性, 可維護性-可演化性:讓變化更容易, 流式系統的哲學
- 可演化性(見 可演化性)
- 可操作性, 可運維性:讓運維更輕鬆
- 簡化和管理複雜性, 簡單性:管理複雜度
- 多種關係, 多對一與多對多關係
- 模擬為圖表, 圖資料模型
- 多對一關係, 多對一與多對多關係
- 在恆星計時, 星型與雪花型:分析模式
- MapReduce (batch processing), 批處理, MapReduce-MapReduce
- 使用者活動活動分析(例項), JOIN 與 GROUP BY
- 與流處理的比較, 流處理
- 不利條件和限制, MapReduce
- 過失容忍, 故障處理
- 高階工具, 查詢語言
- 對映和縮小函式, MapReduce
- 移動資料, 混洗資料
- 排序合併, JOIN 與 GROUP BY
- 工作流程, 工作流排程
- (另見 workflow engines)
- 編組(見 編碼)
- MartenDB(資料庫), 事件溯源與 CQRS
- 主奴隸複製(過時術語), 單主複製
- 物化, 術語表
- 總價值, 物化檢視與資料立方體
- 衝突, 物化衝突
- 實際意見, 物化檢視與資料立方體
- 作為衍生資料, 記錄系統與派生資料, 組合使用資料儲存技術-分拆系統與整合系統
- 如果來源, 事件溯源與 CQRS
- 增量檢視維護, 維護物化檢視
- (另見 incremental view maintenance (IVM))
- 維護,使用流處理, 維護物化檢視, 表表連線(維護物化檢視)
- 社會網路時間表例項, 時間線的物化與更新
- 物化, 物化檢視與資料立方體
- 增量檢視維護, 維護物化檢視
- 矩陣, 資料框、矩陣與陣列
- 人煙稀少, 資料框、矩陣與陣列
- Maxwell(變化資料捕獲), 資料變更捕獲的實現
- 說, 平均值、中位數與百分位點
- 媒體監測, 在流上搜尋
- 中位數, 平均值、中位數與百分位點
- 會議室預訂(例), 寫偏差的更多例子, 謂詞鎖, 強制約束
- 除錯(除錯伺服器), 全記憶體儲存
- Memgraph(資料庫), 圖資料模型
- Cypher 查詢語言, Cypher 查詢語言
- 記憶體
- 壁障, 線性一致性與網路延遲
- 腐敗, 硬體與軟體故障
- 模擬資料庫, 全記憶體儲存
- 資料模擬表示, 編碼資料的格式
- 記憶體表, 構建和合並 SSTable
- 隨機位元- flips in, 信任但驗證
- 索引的使用, 日誌結構儲存
- 記憶體表, 構建和合並 SSTable
- 商品(版本控制系統), 併發控制
- 合併, 資料框、矩陣與陣列
- 合併排序的檔案, 構建和合並 SSTable, 混洗資料
- 默克爾樹, 用於可審計資料系統的工具
- Mesos(分組管理器), 應用程式碼和狀態的分離
- message brokers(見 messaging systems)
- message-passing(見 event-driven architecture)
- MessagePack (encoding format), 二進位制編碼
- 通訊系統, 流處理-重播舊訊息
- (另見 streams)
- 後壓、緩衝或丟棄信件, 訊息傳遞系統
- 無中介訊息, 直接從生產者傳遞給消費者
- 事件日誌, 基於日誌的訊息代理-重播舊訊息
- 作為資料模型, 事件溯源與 CQRS
- 比較傳統訊息, 日誌與傳統的訊息傳遞相比, 重播舊訊息
- 減 減, 消費者偏移量
- 重播舊信件, 重播舊訊息, 應用演化後重新處理資料, 統一批處理和流處理
- 緩慢的消費者, 當消費者跟不上生產者時
- 恰好一次語義, 恰好一次訊息處理, 再談恰好一次訊息處理, 容錯
- 信件經紀人, 訊息代理-確認與重新傳遞
- 承認和重新交付, 確認與重新傳遞
- 比較事件日誌, 日誌與傳統的訊息傳遞相比, 重播舊訊息
- 同一主題的多個消費者, 多個消費者
- versus RPC, 事件驅動的架構
- 訊息丟失, 訊息傳遞系統
- 可靠性, 訊息傳遞系統
- 以日誌為基礎的信件中的獨特性, 基於日誌訊息傳遞中的唯一性
- 可調味的失敗, 描述效能
- 計票
- 微批次, 微批次與存檔點
- 微服務, 微服務與無伺服器
- 微軟
- Azure Blob Storage(見 Azure Blob Storage)
- Azure managed disks, 儲存與計算的分離
- Azure Service Bus(訊息系統), 訊息代理, 訊息代理與資料庫的對比
- Azure SQL DB(資料庫), 雲原生系統架構
- Azure Storage, 物件儲存
- Azure Stream Analytics, 流分析
- Azure Synapse Analytics(資料庫), 雲原生系統架構
- 分散式元件物件模型, 遠端過程呼叫(RPC)的問題
- MSDTC (transaction coordinator), 兩階段提交(2PC)
- SQL Server(見 SQL Server)
- Microsoft Power BI(見 Power BI (business intelligence software))
- 遷移(重寫)資料, 文件模型中的模式靈活性, 不同時間寫入的不同值, 從同一事件日誌中派生多個檢視, 應用演化後重新處理資料
- MinIO(物件儲存), 分散式檔案系統
- 移動應用程式, 資料系統架構中的權衡
- 嵌入式資料庫, 壓實策略
- 模式檢查, 模型檢查與規範語言
- 模組操作員(%), 雜湊取模節點數
- Mojo(程式語言)
- 記憶體管理, 限制垃圾回收的影響
- MongoDB(資料庫)
- 管道合計, 文件的查詢語言
- 原子操作, 原子寫操作
- BSON, 讀寫的資料區域性
- 文件資料模型, 關係模型與文件模型
- 雜湊變硬, 按鍵的雜湊分片, 按雜湊範圍分片
- 在雲層中, 雲原生系統架構
- 加入支援, 文件和關聯式資料庫的融合
- 加入($$ookup 運算子), 正規化、反正規化與連線
- JSON Schema validation, JSON 模式
- 基於領導者的複製, 單主複製
- ObjectIds, ID 生成器和邏輯時鐘
- 基於範圍的硬化, 按鍵的範圍分片
- 請求路由, 請求路由
- 二級指數, 本地二級索引
- 硬分裂, 再平衡鍵範圍分片資料
- 儲存程式, 儲存過程的利弊
- 監測, 雲時代的運維, 人類與可靠性, 可運維性:讓運維更輕鬆
- 單音鍾, 單調時鐘
- 單調讀, 單調讀
- Morel(查詢語言), 查詢語言
- MSMQ(訊息系統), XA 事務
- 多列索引, 多維索引與全文索引
- 多領導複製, 多主複製-處理寫入衝突
- (另見 複製)
- 協作編輯, 實時協作、離線優先和本地優先應用
- 衝突檢測, 處理寫入衝突
- 解決衝突, 處理寫入衝突
- 供多區域複製, 跨地域執行, 線性一致性的代價
- 線性,缺少, 實現線性一致性系統
- 可離線客戶端, 同步引擎與本地優先軟體
- 複製地形, 多主複製拓撲-不同拓撲的問題
- 多物件事務, 單物件與多物件操作
- 需求, 多物件事務的需求
- Multi-Paxos (consensus algorithm), 共識的實踐
- 多讀單寫鎖定, 兩階段鎖定的實現
- 多表索引叢集表, 讀寫的資料區域性
- 多版本併發控制, 多版本併發控制(MVCC), 總結
- detecting stale MVCC reads, 檢測陳舊的 MVCC 讀取
- 索引和快照隔離, 索引與快照隔離
- 使用同步時鐘, 用於全域性快照的同步時鐘
- 多層面陣列, 資料框、矩陣與陣列
- 多重租賃, 儲存與計算的分離, 網路擁塞和排隊
- 相互排斥, 悲觀併發控制與樂觀併發控制
- (另見 locks)
- MySQL(資料庫)
- archiving WAL to object stores, 設定新的副本
- 二進位制日誌座標, 設定新的副本
- 資料變更捕獲, 資料變更捕獲的實現, 變更流的 API 支援
- 迴圈複製地形, 多主複製拓撲
- 一致的快照, 設定新的副本
- 分散式事務支援, XA 事務
- global transaction identifiers (GTIDs), 設定新的副本
- 在雲層中, 雲原生系統架構
- InnoDB storage engine(見 InnoDB)
- 基於領導者的複製, 單主複製
- 多領導複製, 跨地域執行
- 基於行的複製, 邏輯(基於行)日誌複製
- 分片(見 Vitess(資料庫))
- 快速隔離支援, 快照隔離、可重複讀和命名混淆
- (另見 InnoDB)
- 基於語句的複製, 基於語句的複製
N
- N+1 query problem, 物件關係對映(ORM)
- 奈米msg(資訊庫), 直接從生產者傳遞給消費者
- Narayana(事務協調員), 兩階段提交(2PC)
- NATS(訊息系統), 訊息代理
- 自然語言處理, 從資料倉儲到資料湖
- Neo4j(資料庫)
- Cypher 查詢語言, Cypher 查詢語言
- 圖表資料模型, 圖資料模型
- Neon(資料庫), 設定新的副本
- 侄子(資料流引擎), 資料流引擎
- Neptune(圖資料庫), 圖資料模型
- Cypher 查詢語言, Cypher 查詢語言
- SPARQL 查詢語言, SPARQL 查詢語言
- 網碼(遊戲開發), 同步引擎的利弊
- Network Attached Storage (NAS), 共享記憶體、共享磁碟與無共享架構, 分散式檔案系統
- 網路模型(資料表示), 關係模型與文件模型
- Network Time Protocol(見 網路時間協議)
- 網路
- NewSQL, 關係模型與文件模型, 複製延遲的解決方案
- 事務和, 事務到底是什麼?, 資料庫內部的分散式事務
- 下鍵鎖定, 索引範圍鎖
- NFS (network file system), 分散式檔案系統
- 在物件儲存中, 物件儲存
- Nimble(資料格式), 雲資料倉儲, 列式儲存
- (另見 column-oriented storage)
- node (in graphs)(見 vertices)
- 節點(程序), 分散式與單節點系統, 術語表
- 吵鬧的鄰居, 網路擁塞和排隊
- 原子承諾, 三階段提交
- 非決定性操作, 基於語句的複製
- 不起作用的要求, 定義非功能性需求, 總結
- 不可重複讀作, 快照隔離與可重複讀
- (另見 讀取偏差)
- 正規化, 正規化、反正規化與連線-多對一與多對多關係, 術語表
- 外國關鍵參考文獻, 多物件事務的需求
- 社會網路案例研究, 社交網路案例研究中的反正規化
- 在記錄系統中, 記錄系統與派生資料
- 相對於非正常化, 從同一事件日誌中派生多個檢視
- NoSQL, 關係模型與文件模型, 複製延遲的解決方案, 分拆資料庫
- 事務和, 事務到底是什麼?
- Notation3 (N3), 三元組儲存與 SPARQL
- 網路時間協議, 不可靠的時鐘
- 準確性, 時鐘同步和準確性, 用於事件排序的時間戳
- 對單音鐘的調整, 單調時鐘
- 多個伺服器地址, 弱形式的謊言
- XML 與 JSON 編碼中的數字, JSON、XML 及其二進位制變體
- NumPy (Python library), 資料框、矩陣與陣列, 列式儲存
- NVMe (Non-Volatile Memory Express)(見 solid state drives (SSDs))
O
- 物件資料庫, 關係模型與文件模型
- 物件儲存, 雲服務的分層, 物件儲存-物件儲存
- 物件關係對映(ORM)框架, 物件關係對映(ORM)
- 物件關係不匹配, 物件關係不匹配
- 可觀察性, 分散式系統的問題, 人類與可靠性, 可運維性:讓運維更輕鬆
- 觀察員模式, 應用程式碼和狀態的分離
- OBT (one big table), 星型與雪花型:分析模式, 星型與雪花型:分析模式
- 離線系統, 批處理
- (另見 batch processing)
- 離線第一應用程式, 實時協作、離線優先和本地優先應用, 有狀態、可離線的客戶端
- 頁:1
- 加工過的原木中的消費者抵消額, 消費者偏移量
- 已磨損日誌中的訊息, 使用日誌進行訊息儲存
- OLAP, 事務處理與分析的特徵, 術語表
- 資料方塊, 物化檢視與資料立方體
- OLTP, 事務處理與分析的特徵, 術語表
- 分析查詢與, 分析(Analytics)
- 資料正常化, 正規化的權衡
- 工作量特點, 實際序列執行
- 現場部署, 雲服務與自託管
- 資料倉儲, 雲資料倉儲
- 一個大表格(資料倉計劃), 星型與雪花型:分析模式, 星型與雪花型:分析模式
- 單熱編碼, 資料框、矩陣與陣列
- 一對夫婦關係, 用於一對多關係的文件資料模型
- 一對多種關係, 用於一對多關係的文件資料模型
- JSON representation, 用於一對多關係的文件資料模型
- 線上系統, 批處理
- (另見 services)
- 相對於科學計算, 雲端計算與超級計算
- 腫瘤, 三元組儲存與 SPARQL
- Oozie(工作流排程器), 批處理
- OpenAPI (service definition format), 微服務與無伺服器, Web 服務, Web 服務
- use of JSON Schema, JSON 模式
- openCypher(見 Cypher(查詢語言))
- OpenLink Virtuoso(見 Virtuoso(資料庫))
- OpenStack
- Swift(物件儲存), 物件儲存
- 可操作性, 可運維性:讓運維更輕鬆
- 作業系統與資料庫, 分拆資料庫
- 業務系統, 分析型與事務型系統
- 操作轉換, CRDT 與操作變換
- 行動組, 雲時代的運維
- 運算元, 查詢執行:編譯與向量化
- 在溪流處理中, 流處理
- 樂觀併發控制, 悲觀併發控制與樂觀併發控制
- 樂觀鎖定, 條件寫入(比較並設定)
- Oracle(資料庫)
- 分散式事務支援, XA 事務
- GoldenGate (change data capture), 資料變更捕獲的實現
- 等級查詢, SQL 中的圖查詢, SQL 中的圖查詢
- 缺乏序列性, 隔離性
- 基於領導者的複製, 單主複製
- 多領導複製, 跨地域執行
- 多表索引叢集表, 讀寫的資料區域性
- 無法阻止寫入 skew, 寫偏差的特徵
- PL/SQL language, 儲存過程的利弊
- 防止丟失更新, 自動檢測丟失的更新
- 讀作承諾隔離, 實現讀已提交
- Real Application Clusters (RAC), 鎖定與領導者選舉
- 快速隔離支援, 快照隔離與可重複讀, 快照隔離、可重複讀和命名混淆
- TimesTen (in-memory database), 全記憶體儲存
- WAL-based replication, 預寫日誌(WAL)傳輸
- ORC(資料格式), 雲資料倉儲, 列式儲存
- (另見 column-oriented storage)
- 協調(服務部署), 雲服務與自託管, 微服務與無伺服器
- 順序
- 事件日誌, 事件溯源與 CQRS
- 總訂單的限制, 全序的限制
- 邏輯時間戳, 邏輯時鐘
- of auto-incrementing IDs, ID 生成器和邏輯時鐘
- 共享日誌, 共識的實踐-共識的利弊
- Orkes(工作流程引擎), 持久化執行與工作流
- 孤兒頁面(B- 樹), 使 B 樹可靠
- 發件箱圖案, 資料變更捕獲與事件溯源
- 異常值(響應時間), 平均值、中位數與百分位點
- 外包, 雲服務與自託管
- 超載, 描述效能, 處理錯誤和中止
P
- PACELC principle, CAP 定理
- 軟體包管理器, 應用程式碼和狀態的分離
- 包切換, 我們不能簡單地使網路延遲可預測嗎?
- 資料包
- 腐敗, 弱形式的謊言
- sending via UDP, 直接從生產者傳遞給消費者
- PageRank (algorithm), 圖資料模型, 查詢語言, 機器學習
- paging(見 virtual memory)
- 大熊貓(蟒蛇圖書館), 從資料倉儲到資料湖, 資料框、矩陣與陣列, 列式儲存, DataFrames
- Parquet(資料格式), 雲資料倉儲, 列式儲存, 歸檔儲存, 查詢語言
- 部分失敗, 故障與部分失效, 總結
- 跛腳, 系統模型與現實
- 部分同步(系統模型), 系統模型與現實
- 分割槽鍵, 分片的利與弊, 鍵值資料的分片
- 分割槽(見 分片)
- Paxos(協商一致演算法), 共識, 共識的實踐
- payment card industry (PCI), 資料系統、法律與社會
- PCI (payment card industry) compliance, 資料系統、法律與社會
- 百分位點, 平均值、中位數與百分位點, 術語表
- Percolator (Google), 實現線性一致的 ID 生成器
- Percona XtraBackup (MySQL tool), 設定新的副本
- 效能
- 作為過失的降解, 系統模型與現實
- 描述, 描述效能
- 分散式事務, 跨不同系統的分散式事務
- 記憶體資料庫, 全記憶體儲存
- 線性, 線性一致性與網路延遲
- 多領導者複製, 跨地域執行
- 許可權隔離, 面向多租戶的分片
- 永久不一致, 及時性與完整性
- 悲觀併發控制, 悲觀併發控制與樂觀併發控制
- pglogical (PostgreSQL extension), 跨地域執行
- pgvector (向量指數), 向量嵌入
- 幻讀, 導致寫偏差的幻讀
- physical clocks(見 clocks)
- pick菜(蟒魚), 特定語言的格式
- Pinot(資料庫), 事務處理與分析的特徵, 列式儲存
- 處理寫入, 寫入列式儲存
- 預彙總, 分析(Analytics)
- 服務衍生資料, 對外提供派生資料, 對外提供派生資料
- 編審中的執行
- 資料倉查詢, 查詢執行:編譯與向量化
- 樞軸表, 資料框、矩陣與陣列
- 時間點, 不可靠的時鐘
- 點查詢, 事務處理與分析的特徵
- 極地(資料目錄), 雲資料倉儲
- 投票, 表示使用者、帖子與關注關係
- 多邊儲存器, 一切的後設資料庫
- POSIX (portable operating system interface)
- 郵政局地平線醜聞, 人類與可靠性
- 缺乏事務, 事務
- PostgreSQL(資料庫)
- archiving WAL to object stores, 設定新的副本
- 資料變更捕獲, 資料變更捕獲的實現, 變更流的 API 支援
- 分散式事務支援, XA 事務
- 外國資料包, 一切的後設資料庫
- 全文搜尋支援, 組合使用派生資料的工具
- 在雲層中, 雲原生系統架構
- JSON Schema validation, JSON 模式
- 基於領導者的複製, 單主複製
- 日誌序列號, 設定新的副本
- 邏輯解碼, 邏輯(基於行)日誌複製
- 實現檢視維護, 維護物化檢視
- 多領導複製, 跨地域執行
- MVCC implementation, 多版本併發控制(MVCC), 索引與快照隔離
- 分割對硬化, 分片
- pgvector (向量指數), 向量嵌入
- PL/pgSQL language, 儲存過程的利弊
- PostGIS geospatial indexes, 多維索引與全文索引
- 防止丟失更新, 自動檢測丟失的更新
- 防止寫入skew, 寫偏差的特徵, 可序列化快照隔離(SSI)
- 讀作承諾隔離, 實現讀已提交
- 表示圖表, 屬性圖
- 可序列化快照隔離, 可序列化快照隔離(SSI)
- 分片(見 Citus(資料庫))
- 快速隔離支援, 快照隔離與可重複讀, 快照隔離、可重複讀和命名混淆
- WAL-based replication, 預寫日誌(WAL)傳輸
- 倒排列表, 全文檢索
- 在硬化指數中, 本地二級索引
- 死後無咎, 人類與可靠性
- PouchDB(資料庫), 同步引擎的利弊
- Power BI (business intelligence software), 事務處理與分析的特徵, 分析(Analytics)
- 預彙總, 分析(Analytics)
- 服務衍生資料, 對外提供派生資料
- 分享前, 再平衡鍵範圍分片資料
- Precision Time Protocol (PTP), 時鐘同步和準確性
- 上游鎖定, 謂詞鎖
- 預測分析, 分析型與事務型系統, 預測分析-反饋迴路
- 預設, 資源分配
- Prefect(工作流排程器), 持久化執行與工作流, 批處理, 工作流排程
- 雲資料倉整合, 查詢語言
- Presto(查詢引擎), 雲資料倉儲
- 主金鑰, 多列索引與二級索引, 術語表
- 自動遞增, ID 生成器和邏輯時鐘
- 對分割槽鍵, 按雜湊範圍分片
- primary-backup replication(見 基於領導者的複製)
- 隱私, 隱私與追蹤-立法與自律
- 機率演算法, 響應時間指標的應用, 流分析
- 程序暫停, 程序暫停-限制垃圾回收的影響
- 處理時間(事件), 時間推理
- 生產者(資訊流), 傳遞事件流
- 產品分析, 事務處理與分析的特徵
- 面向列的儲存, 列式儲存
- 程式語言
- 用於儲存程式, 儲存過程的利弊
- 預測(活動來源), 事件溯源與 CQRS
- Prolog(語言), Datalog:遞迴關係查詢
- (另見 Datalog)
- 屬性圖, 屬性圖
- Cypher 查詢語言, Cypher 查詢語言
- Property Graph Query Language (PGQL), SQL 中的圖查詢
- 基於屬性的測試, 人類與可靠性, 形式化方法和隨機測試
- Protocol Buffers(資料格式), Protocol Buffers-欄位標籤與模式演化, Protocol Buffers
- 欄位標記和計劃演變, 欄位標籤與模式演化
- 資料來源, 為可審計性而設計
- 釋出/訂閱模式, 訊息傳遞系統
- 出版社(資訊流), 傳遞事件流
- Pulsar (流線平臺), 確認與重新傳遞
- PyTorch (machine learning library), 機器學習
Q
- Qpid(訊息系統), 訊息代理與資料庫的對比
- quality of service (QoS), 我們不能簡單地使網路延遲可預測嗎?
- Quantcast File System(分散式檔案系統), 物件儲存
- 查詢引擎
- 彙編和向量化, 查詢執行:編譯與向量化
- 在雲資料倉儲中, 雲資料倉儲
- 運算元, 查詢執行:編譯與向量化
- 最佳化申報查詢, 資料模型與查詢語言
- 查詢語言
- 密碼, Cypher 查詢語言
- 資料日誌, Datalog:遞迴關係查詢
- GraphQL, GraphQL
- MongoDB aggregation pipeline, 正規化、反正規化與連線, 文件的查詢語言
- recursive SQL queries, SQL 中的圖查詢
- SPARQL, SPARQL 查詢語言
- SQL, 正規化、反正規化與連線
- 查詢最佳化器, 查詢語言
- 查詢計劃, 查詢執行:編譯與向量化
- 排隊延遲, 網路擁塞和排隊
- 佇列(訊息), 訊息代理
- QUIC (protocol), TCP 的侷限性
- 法定人數, 讀寫仲裁-多地區操作, 術語表
- 配額, 雲時代的運維
R
- R(語言), 從資料倉儲到資料湖, 資料框、矩陣與陣列, DataFrames
- R樹(指數), 多維索引與全文索引
- R2(物件儲存), 雲服務的分層, 分散式檔案系統
- RabbitMQ(訊息系統), 訊息代理, 訊息代理與資料庫的對比
- 法定人數佇列(複製), 單主複製
- 種族條件, 隔離性
- Raft(協商一致演算法), 共識, 共識的實踐
- RAID (Redundant Array of Independent Disks), 儲存與計算的分離, 透過冗餘容忍硬體故障, 分散式檔案系統
- 鐵路,計劃遷移, 應用演化後重新處理資料
- RAM(見 memory)
- RAMCloud (in-memory storage), 全記憶體儲存
- 隨機寫入(訪問模式), 順序與隨機寫入
- 區域查詢
- 排名演算法, 機器學習
- Ray(工作流排程器), 機器學習
- RDF (Resource Description Framework), RDF 資料模型
- querying with SPARQL, SPARQL 查詢語言
- 遠端直接記憶體訪問, 雲服務的分層, 雲端計算與超級計算
- 反應(使用者介面庫), 端到端的事件流
- 被動方案擬訂, 同步引擎的利弊
- 讀取承諾隔離級別, 讀已提交-實現讀已提交
- 執行, 實現讀已提交
- 多版本併發控制, 多版本併發控制(MVCC)
- 沒有髒讀, 沒有髒讀
- 沒有汙穢的文字, 沒有髒寫
- 讀取模型(活動來源), 事件溯源與 CQRS
- 讀路徑, 觀察派生資料狀態
- (無鉛複製), 追趕錯過的寫入
- 線性, 線性一致性與仲裁
- 只讀副本(見 基於領導者的複製)
- 讀取偏差, 快照隔離與可重複讀, 總結
- 讀取未承諾的隔離級別, 實現讀已提交
- 寫後讀一致性, 讀己之寫, 及時性與完整性
- 交叉裝置, 讀己之寫
- 在衍生資料系統中, 派生資料與分散式事務
- 讀 - 修改 - 寫入週期, 防止丟失更新
- 讀縮放架構, 複製延遲的問題, 單主與無主複製的效能
- 與磨損, 分片的利與弊
- 讀作事件, 讀也是事件
- 實時
- analytics(見 product analytics)
- 協作編輯, 實時協作、離線優先和本地優先應用
- 釋出/訂閱資料流, 端到端的事件流
- 響應時間保障, 響應時間保證
- 每日時鐘, 日曆時鐘
- Realm(資料庫), 同步引擎的利弊
- 再平衡困難, 再平衡鍵範圍分片資料-運維:自動/手動再平衡, 術語表
- (另見 分片)
- 自動或人工再平衡, 運維:自動/手動再平衡
- 固定塊數, 固定數量的分片
- 每個節點的固定硬度數, 按雜湊範圍分片
- Hash mod N的問題, 雜湊取模節點數
- 新鮮度保證, 線性一致性
- 建議引擎, 分析型與事務型系統
- 重組(協商一致), 共識的微妙之處
- 記錄, MapReduce
- 流處理中的事件, 傳遞事件流
- 遞迴查詢
- 在金鑰中, Cypher 查詢語言
- 在資料日誌中, Datalog:遞迴關係查詢
- in SPARQL, SPARQL 查詢語言
- lack of, in GraphQL, GraphQL
- SQL common table expressions, SQL 中的圖查詢
- Red Hat
- Apicurio Registry, JSON 模式
- 紅黑樹, 構建和合並 SSTable
- 重新交付(通訊), 確認與重新傳遞
- Redis(資料庫)
- redo log(見 write-ahead log)
- Redpanda(訊息系統), 訊息代理, 設定新的副本
- 分層儲存, 磁碟空間使用
- Redshift(資料庫), 雲資料倉儲
- 冗餘
- 硬體元件, 透過冗餘容忍硬體故障
- 生成資料, 記錄系統與派生資料
- (另見 衍生資料)
- Reed–Solomon codes (error correction), 分散式檔案系統
- 重構, 可演化性:讓變化更容易
- (另見 可演化性)
- (地理分佈), 讀己之寫
- 區域(硬化), 分片
- 暫存器, 什麼使系統具有線性一致性?
- regulation(見 legal matters)
- 關係資料模型, 從資料倉儲到資料湖, 關係模型與文件模型-文件和關聯式資料庫的融合
- 與檔案模型的比較, 何時使用哪種模型-文件和關聯式資料庫的融合
- graph queries in SQL, SQL 中的圖查詢
- 模擬資料庫, 全記憶體儲存
- 多對多對多的關係, 多對一與多對多關係
- 多物件事務, 需要, 多物件事務的需求
- 物件關係不匹配, 物件關係不匹配
- 代表可重排列表, 何時使用哪種模型
- 對文件模式
- 模式的趨同, 文件和關聯式資料庫的融合
- 資料位置, 讀寫的資料區域性
- 關聯式資料庫
- 最終一致性, 複製延遲的問題
- 歷史, 關係模型與文件模型
- 基於領導者的複製, 單主複製
- 邏輯日誌, 邏輯(基於行)日誌複製
- 哲學比Unix, 分拆資料庫, 一切的後設資料庫
- 方案變化, 文件模型中的模式靈活性, 編碼與演化, 不同時間寫入的不同值
- 硬化二級指數, 分片與二級索引
- 基於語句的複製, 基於語句的複製
- B樹指數的使用, B 樹
- relationships(見 edges)
- 可靠性, 可靠性與容錯-人類與可靠性, 流式系統的哲學
- Remote Method Invocation (Java RMI), 遠端過程呼叫(RPC)的問題
- remote procedure calls (RPCs), 遠端過程呼叫(RPC)的問題-RPC 的資料編碼與演化
- (另見 services)
- 資料編碼和演化, RPC 的資料編碼與演化
- 問題, 遠端過程呼叫(RPC)的問題
- 使用 Avro, 但什麼是寫入者模式?
- 對信件經紀人, 事件驅動的架構
- 可再生能源, 分散式與單節點系統
- 可重複讀(切換隔離), 快照隔離、可重複讀和命名混淆
- 複製品, 單主複製
- 複製, 複製-總結, 術語表
- 永續性, 永續性
- 解決衝突, 衝突解決與複製
- 一致性屬性, 複製延遲的問題-複製延遲的解決方案
- 在分散式檔案系統中, 分散式檔案系統
- 無主(無領導者), 無主複製-版本向量
- 監測停滯情況, 監控陳舊性
- 多領導者, 多主複製-處理寫入衝突
- 使用原因, 分散式與單節點系統, 複製
- 硬化和, 分片
- 單人領導, 單主複製-邏輯(基於行)日誌複製
- 故障切換, 領導者故障:故障轉移
- 實施複製日誌, 複製日誌的實現-邏輯(基於行)日誌複製
- 與協商一致的關係, 從單主複製到共識, 共識的利弊
- 設立新的追隨者, 設定新的副本
- 同步對同步, 同步複製與非同步複製-同步複製與非同步複製
- 狀態機複製, 基於語句的複製, 儲存過程的利弊, 使用共享日誌, 資料庫與流
- 事件溯源, 事件溯源與 CQRS
- 依賴決定性因素, 確定性模擬測試
- 利用協商一致, 共識的利弊
- 使用擦除編碼, 分散式檔案系統
- 使用物件儲存, 設定新的副本
- 相對備份, 複製
- 具有多樣化資料系統, 保持系統同步
- replication logs(見 logs)
- representations of data(見 data models)
- 後處理資料, 應用演化後重新處理資料, 統一批處理和流處理
- (另見 可演化性)
- 從基於日誌的信件, 重播舊訊息
- 請求套期, 單主與無主複製的效能
- 請求識別符號, 操作識別符號, 多分割槽請求處理
- 請求路由, 請求路由-請求路由
- 方法, 請求路由
- 資料居住法, 分散式與單節點系統, 面向多租戶的分片
- 彈性系統, 可靠性與容錯
- (另見 fault tolerance)
- 資源隔離, 雲端計算與超級計算, 面向多租戶的分片
- 資源限制, 雲時代的運維
- 響應時間
- 作為業績計量, 描述效能, 批處理
- 保證, 響應時間保證
- 對使用者的影響, 平均值、中位數與百分位點
- 在複製系統中, 單主與無主複製的效能
- 暫時性與, 延遲與響應時間
- 平均值和百分位數, 平均值、中位數與百分位點
- 使用者體驗, 平均值、中位數與百分位點
- 責任和問責制, 責任與問責
- 表述性狀態傳遞, Web 服務
- (另見 services)
- 重報(工作流程引擎), 持久化執行與工作流
- RethinkDB(資料庫)
- 加入支援, 文件和關聯式資料庫的融合
- 鍵程硬化, 按鍵的範圍分片
- 重試風暴, 描述效能, 軟體故障
- reverse ETL, 超越資料湖
- Riak(資料庫)
- CRDT support, CRDT 與操作變換, 檢測併發寫入
- 點版向量, 版本向量
- 流言協議, 請求路由
- 雜湊變硬, 固定數量的分片
- 無領導複製, 無主複製
- 線性,缺少, 線性一致性與仲裁
- 多區域支助, 多地區操作
- 再平衡, 運維:自動/手動再平衡
- 二級指數, 本地二級索引
- 草率法定人數, 單主與無主複製的效能
- 節點(硬化), 分片
- 環緩衝器, 磁碟空間使用
- RisingWave(資料庫)
- 增量檢視維護, 維護物化檢視
- 火箭彈, 拜占庭故障
- RocksDB (storage engine), 構建和合並 SSTable
- 退縮(事務), 事務
- 滾動升級, 透過冗餘容忍硬體故障, 編碼與演化, 故障與部分失效
- 在多種租戶系統中, 面向多租戶的分片
- routing(見 request routing)
- 基於行的複製, 邏輯(基於行)日誌複製
- 面向行儲存, 列式儲存
- 搶劫犯(貪汙), 硬體與軟體故障
- RPCs(見 remote procedure calls)
- 規則(資料), Datalog:遞迴關係查詢
- Rust(程式語言)
- 記憶體管理, 限制垃圾回收的影響
S
- S3(物件儲存), 雲服務的分層, 設定新的副本, 批處理, 分散式檔案系統, 物件儲存
- SaaS(見 軟體即服務(SaaS))
- 安全和生活特性, 安全性與活性
- sagas(見 compensating transactions)
- Samza (流處理器), 流分析
- SAP HANA(資料庫), 分析型資料儲存
- 可伸縮性, 可伸縮性-可伸縮性原則, 流式系統的哲學
- 自動縮放, 運維:自動/手動再平衡
- 透過磨損, 分片的利與弊
- 描述負載, 描述負載
- 描述效能, 描述效能
- 線性, 描述負載
- 原則, 可伸縮性原則
- 複製和, 複製延遲的問題
- 擴大規模與擴大規模, 共享記憶體、共享磁碟與無共享架構
- 縮放, 共享記憶體、共享磁碟與無共享架構
- (另見 shared-nothing architecture)
- 透過磨損, 分片的利與弊
- 擴大規模, 共享記憶體、共享磁碟與無共享架構
- 緩慢變化的維度, 連線的時間依賴性
- 排程
- 閱讀時的圖謀, 文件模型中的模式靈活性
- 與可變方案比較, 模式的優點
- 拼寫圖, 文件模型中的模式靈活性
- schemaless databases(見 schema-on-read)
- 計劃, 術語表
- Avro, Avro-動態生成的模式
- 讀者決定作家的計劃, 但什麼是寫入者模式?
- 計劃演變, 寫入者模式與讀取者模式
- 動態生成, 動態生成的模式
- 變化, 應用演化後重新處理資料
- 影響應用程式程式碼, 編碼與演化
- 相容性檢查, 但什麼是寫入者模式?
- 資料庫中, 流經資料庫的資料流-歸檔儲存
- 服務電話, RPC 的資料編碼與演化
- 檔案模式的靈活性, 文件模型中的模式靈活性
- 用於分析, 星型與雪花型:分析模式-星型與雪花型:分析模式
- for JSON and XML, JSON、XML 及其二進位制變體, JSON 模式
- generation and migration using ORMs, 物件關係對映(ORM)
- 案情, 模式的優點
- 遷移, 文件模型中的模式靈活性
- Protocol Buffers, Protocol Buffers-欄位標籤與模式演化
- 計劃演變, 欄位標籤與模式演化
- 鐵路移民計劃, 應用演化後重新處理資料
- 傳統的設計方法,謬誤, 從同一事件日誌中派生多個檢視
- Avro, Avro-動態生成的模式
- 科學計算, 雲端計算與超級計算
- scikit-learn (Python 圖書館), 從資料倉儲到資料湖
- ScyllaDB(資料庫)
- 叢集後設資料, 請求路由
- consistency level ANY, 單主與無主複製的效能
- 雜湊變硬, 按鍵的雜湊分片, 按雜湊範圍分片
- 最後寫成的解決衝突, 檢測併發寫入
- 無領導複製, 無主複製
- 輕量事務, 單物件寫入
- 線性,缺少, 實現線性一致性系統
- 日誌結構儲存, 構建和合並 SSTable
- 多區域支助, 多地區操作
- 使用時鐘, 仲裁一致性的侷限, 用於事件排序的時間戳
- 節點(硬化), 分片
- search engines(見 全文檢索)
- 搜尋流, 在流上搜尋
- 備庫(見 基於領導者的複製)
- 二級指數, 多列索引與二級索引, 術語表
- 二次排序, JOIN 與 GROUP BY
- sed (Unix 工具) (英語)., 簡單日誌分析
- 自我託管, 雲服務與自託管
- 資料倉儲, 雲資料倉儲
- 自我歡樂, 本章小結
- 自動驗證系統, 不要盲目信任承諾
- 語義搜尋, 向量嵌入
- 語義相似性, 向量嵌入
- 語義網, 三元組儲存與 SPARQL
- 半同步複製, 同步複製與非同步複製
- 順序寫(訪問模式), 順序與隨機寫入
- 可序列化, 隔離性, 弱隔離級別, 可序列化-可序列化快照隔離的效能, 術語表
- 線性比對, 什麼使系統具有線性一致性?
- 悲觀與樂觀的併發控制, 悲觀併發控制與樂觀併發控制
- 序列執行, 實際序列執行-序列執行總結
- 分片, 分片
- 使用儲存程式, 將事務封裝在儲存過程中, 使用共享日誌
- 可序列化快照隔離, 可序列化快照隔離(SSI)-可序列化快照隔離的效能
- detecting stale MVCC reads, 檢測陳舊的 MVCC 讀取
- 檢測影響先前讀取的寫入, 檢測影響先前讀取的寫入
- 分散式執行, 可序列化快照隔離的效能, 資料庫內部的分散式事務
- performance of SSI, 可序列化快照隔離的效能
- 防止寫入skew, 基於過時前提的決策-檢測影響先前讀取的寫入
- 嚴格的序列性, 什麼使系統具有線性一致性?
- 及時性與完整性, 及時性與完整性
- 兩階段鎖定, 兩階段鎖定(2PL)-索引範圍鎖
- 可序列化, 特定語言的格式
- 序列化, 編碼資料的格式
- (另見 編碼)
- 無伺服器, 微服務與無伺服器
- 服務發現, 負載均衡器、服務發現和服務網格, 請求路由, 服務發現
- 登記, 負載均衡器、服務發現和服務網格
- using DNS, 負載均衡器、服務發現和服務網格, 請求路由, 服務發現
- 服務級別協議(SLA), 響應時間指標的應用, 描述負載
- 服務網格, 負載均衡器、服務發現和服務網格
- Service Organization Control (SOC), 資料系統、法律與社會
- 服務時間, 延遲與響應時間
- 面向服務的體系結構, 微服務與無伺服器
- (另見 services)
- 服務, 流經服務的資料流:REST 與 RPC-RPC 的資料編碼與演化
- 微服務, 微服務與無伺服器
- 與批次/流程處理器的關係, 批處理, 流處理器和服務
- remote procedure calls (RPCs), 遠端過程呼叫(RPC)的問題-RPC 的資料編碼與演化
- 問題, 遠端過程呼叫(RPC)的問題
- 與資料庫相似, 流經服務的資料流:REST 與 RPC
- 網路服務, Web 服務
- 會話視窗(流處理), 視窗的型別
- (另見 windows)
- 分片, 分片-總結, 術語表
- 和共識, 使用共享日誌
- 複製, 分片
- 分散事務, 分散式事務
- 熱的軟糖, 鍵值資料的分片
- 分批處理, 批處理
- 鍵程分割, 再平衡鍵範圍分片資料
- 多硬性操作, 多分割槽資料處理
- 關鍵值資料, 鍵值資料的分片-偏斜的工作負載與緩解熱點
- 按金鑰範圍, 按鍵的範圍分片
- 搖擺和熱點, 偏斜的工作負載與緩解熱點
- 詞源, 分片
- 分割槽鍵, 分片的利與弊, 鍵值資料的分片
- 再平衡
- 金鑰範圍壓縮資料, 再平衡鍵範圍分片資料
- 再平衡困難, 再平衡鍵範圍分片資料-運維:自動/手動再平衡
- 自動或人工再平衡, 運維:自動/手動再平衡
- Hash mod N的問題, 雜湊取模節點數
- 使用固定的碎片數, 固定數量的分片
- 使用 N 個節點, 按雜湊範圍分片
- 請求路由, 請求路由-請求路由
- 二級指數, 分片與二級索引-全域性二級索引
- 連續執行事務和, 分片
- 正在排序硬化資料, 混洗資料
- 共享日誌, 共識的實踐-共識的利弊, 全序的限制, 基於日誌訊息傳遞中的唯一性
- 共享模式, 兩階段鎖定的實現
- 共享磁碟架構, 共享記憶體、共享磁碟與無共享架構, 分散式檔案系統
- 共享記憶體架構, 共享記憶體、共享磁碟與無共享架構
- 共享- 無結構, 共享記憶體、共享磁碟與無共享架構, 術語表
- 鯊魚
- shredding (deletion)(見 crypto-shredding)
- 粉碎(專欄編碼), 列式儲存
- 粉碎(相關模型), 何時使用哪種模型
- 混洗, 混洗資料-混洗資料
- 兄弟值, 手動衝突解決, 捕獲先發生關係, 衝突解決與複製
- (另見 conflicts)
- 倉, 資料倉儲
- 相似性搜尋
- 簡單, 簡單性:管理複雜度
- 歌手, 資料倉儲
- single-instruction-multi-data (SIMD) instructions, 查詢執行:編譯與向量化
- single-leader replication(見 基於領導者的複製)
- 單條執行, 原子寫操作, 實際序列執行
- 在溪流處理中, 日誌與傳統的訊息傳遞相比, 併發控制, 基於日誌訊息傳遞中的唯一性
- SingleStore(資料庫)
- 記憶體儲, 全記憶體儲存
- 工地可靠性工程師, 雲時代的運維
- 大小級緊湊, 壓實策略, 磁碟空間使用
- 偏斜, 術語表
- 時鐘搖擺, 對同步時鐘的依賴-帶置信區間的時鐘讀數, 實現線性一致性系統
- 事務隔離
- 含義, 快照隔離與可重複讀
- 不平衡的工作量, 鍵值資料的分片
- 補償, 偏斜的工作負載與緩解熱點
- 由於名人, 偏斜的工作負載與緩解熱點
- 時間序列資料, 按鍵的範圍分片
- 跳過列表, 構建和合並 SSTable
- 服務級別協議(見 服務級別協議)
- Slack(分組聊天)
- GraphQL example, GraphQL
- SlateDB(資料庫), 構建和合並 SSTable, 設定新的副本
- 滑動視窗(流處理), 視窗的型別
- (另見 windows)
- 草率法定人數, 單主與無主複製的效能
- 緩慢變化的維度, 連線的時間依賴性
- 塗抹(傾斜秒調整), 時鐘同步和準確性
- 快照(資料庫)
- 作為備份, 複製
- 計算衍生資料, 建立索引
- 變化資料捕獲中, 初始快照
- 可序列化快照隔離, 可序列化快照隔離(SSI)-可序列化快照隔離的效能
- 新建複製品, 設定新的副本
- 快速隔離和可重複讀取, 快照隔離與可重複讀-快照隔離、可重複讀和命名混淆
- implementing with MVCC, 多版本併發控制(MVCC)
- indexes and MVCC, 索引與快照隔離
- 可見度規則, 觀察一致快照的可見性規則
- 全球快照同步時鐘, 用於全域性快照的同步時鐘
- Snowflake(資料庫), 雲原生系統架構, 雲服務的分層, 雲資料倉儲, 批處理
- Snowflake (ID generator), ID 生成器和邏輯時鐘
- 雪花計劃, 星型與雪花型:分析模式
- SOAP (web services), 遠端過程呼叫(RPC)的問題
- SOC2(見 Service Organization Control (SOC))
- 社會圖表, 圖資料模型
- 社會
- 的責任, 資料系統、法律與社會, 立法與自律
- 社會技術系統, 人類與可靠性
- 軟體即服務(SaaS), 資料系統架構中的權衡, 雲服務與自託管
- 軟體錯誤, 軟體故障
- 維護誠信, 維護完整性,儘管軟體有Bug
- 太陽風暴, 硬體與軟體故障
- solid state drives (SSDs)
- Solr (搜尋伺服器)
- 排序(Unix 工具), 簡單日誌分析, 簡單日誌分析, 排序與記憶體聚合, 分散式作業編排
- 排序歸併連線(MapReduce), JOIN 與 GROUP BY
- Sorted String Tables(見 SSTables)
- 排序
- 列儲存中的排序順序, 列儲存中的排序順序
- 真相來源(權威資料來源)(見 systems of record)
- Spanner(資料庫)
- 一致性模式, 什麼使系統具有線性一致性?
- 資料位置, 讀寫的資料區域性
- 在雲層中, 雲原生系統架構
- 使用時鐘快照隔離, 用於全域性快照的同步時鐘
- 事務, 事務到底是什麼?, 資料庫內部的分散式事務
- TrueTime API, 帶置信區間的時鐘讀數
- Spark(處理框架), 從資料倉儲到資料湖, 雲原生系統架構, 批處理, 資料流引擎
- SPARQL(查詢語言), SPARQL 查詢語言
- 零星指數, SSTable 檔案格式
- 稀疏矩陣, 資料框、矩陣與陣列
- 腦裂, 領導者故障:故障轉移, 請求路由, 術語表
- 執行限制, 唯一性約束需要達成共識
- 在共識演算法中, 共識, 從單主複製到共識
- 預防, 實現線性一致性系統
- 使用柵欄標誌來避免, 隔離殭屍程序和延遲請求-多副本隔離
- 現場例項, 故障處理
- 電子表格, 資料系統架構中的權衡, 資料框、矩陣與陣列
- SQL (Structured Query Language), 簡單性:管理複雜度, 關係模型與文件模型, 雲資料倉儲
- 用於分析, 資料倉儲, 列式儲存
- 圖表查詢, SQL 中的圖查詢
- 隔離級別標準,問題, 快照隔離、可重複讀和命名混淆
- 加入, 正規化、反正規化與連線
- 簡歷(例), 用於一對多關係的文件資料模型
- 社會網路家庭時間表(例), 表示使用者、帖子與關注關係
- SQL injection vulnerability, 拜占庭故障
- 基於語句的複製, 基於語句的複製
- 儲存程式, 儲存過程的利弊
- 批次處理框架中的支援, 批處理
- 檢視, Datalog:遞迴關係查詢
- SQL Server(資料庫)
- SQLite(資料庫), 分散式系統的問題, 壓實策略
- archiving WAL to object stores, 設定新的副本
- SRE (site reliability engineer), 雲時代的運維
- SSDs(見 solid state drives)
- SSTables (storage format), SSTable 檔案格式-壓實策略
- 建造和維護, 構建和合並 SSTable
- making LSM-Tree from, 構建和合並 SSTable
- 階段釋出(見 rolling upgrades)
- 停滯(舊資料), 讀己之寫
- 跨渠道時間依賴性, 跨通道時序依賴
- 無頭資料庫中, 當節點故障時寫入資料庫
- 多轉換併發控制, 檢測陳舊的 MVCC 讀取
- 監測, 監控陳舊性
- 客戶端狀態, 將狀態變更推送給客戶端
- 相對線性, 線性一致性
- 相對於及時性, 及時性與完整性
- standbys(見 基於領導者的複製)
- 恆星複製地形, 多主複製拓撲
- 恆星計劃, 星型與雪花型:分析模式-星型與雪花型:分析模式
- 星球大戰類比(事件時間與處理時間), 事件時間與處理時間
- 飢餓(時間安排), 資源分配
- 國家
- 從不可改變事件日誌中得出, 狀態、流和不變性
- 狀態變化與應用程式程式碼之間的相互作用, 資料流:應用程式碼與狀態變化的互動
- 保持衍生狀態, 維護派生狀態
- 由流處理器在流-流連線中維護, 流流連線(視窗連線)
- 觀察匯出狀態, 觀察派生資料狀態-多分割槽資料處理
- 流處理器失敗後重建, 失敗後重建狀態
- 應用程式碼和, 應用程式碼和狀態的分離
- 狀態機複製, 基於語句的複製, 儲存過程的利弊, 使用共享日誌, 資料庫與流
- 事件溯源, 事件溯源與 CQRS
- 依賴決定性因素, 確定性模擬測試
- 無國籍人制度, 資料系統架構中的權衡
- 基於語句的複製, 基於語句的複製
- 依賴決定性因素, 確定性模擬測試
- 靜態輸入語言
- 類比於圖案, 文件模型中的模式靈活性
- 統計和數字演算法, 資料框、矩陣與陣列
- StatsD (metrics aggregator), 直接從生產者傳遞給消費者
- 股票市場飼料, 直接從生產者傳遞給消費者
- 爆彼之頭, 領導者故障:故障轉移
- 問題, 隔離殭屍程序和延遲請求
- 停止所有處理(見 garbage collection)
- 儲存
- 構建資料儲存技術, 組合使用資料儲存技術-分拆系統與整合系統
- 儲存區網路, 共享記憶體、共享磁碟與無共享架構, 分散式檔案系統
- 儲存引擎, 儲存與檢索-總結
- 儲存程式, 將事務封裝在儲存過程中-儲存過程的利弊, 術語表
- 和共享日誌, 使用共享日誌
- 利弊因素, 儲存過程的利弊
- 類似於流處理器, 應用程式碼作為派生函式
- 風暴(流處理器), 流分析
- distributed RPC, 事件驅動架構與 RPC, 多分割槽資料處理
- 三叉戟狀態處理, 冪等性
- 斜拉機事件, 處理滯留事件
- Stream Control Transmission Protocol (SCTP), TCP 的侷限性
- 流處理, 流處理-本章小結, 術語表
- 在工作範圍內獲得外部服務, 流表連線(流擴充), 微批次與存檔點, 冪等性, 恰好執行一次操作
- 與批次處理相結合, 統一批處理和流處理
- 與批次處理的比較, 流處理
- 複合事件處理, 複合事件處理
- 過失容忍, 容錯-失敗後重建狀態
- 資料整合, 批處理與流處理-統一批處理和流處理
- 用於事件原始碼, 事件溯源與 CQRS
- 保持衍生狀態, 維護派生狀態
- 維持實際意見, 維護物化檢視
- messaging systems(見 messaging systems)
- 關於時間的推理, 時間推理-視窗的型別
- relation to databases(見 streams)
- 與服務的關係, 流處理器和服務
- 與批次處理的關係, 批處理
- 在流中搜尋, 在流上搜尋
- 單條執行, 日誌與傳統的訊息傳遞相比, 併發控制
- 流式分析, 流分析
- 串流連線, 流連線-連線的時間依賴性
- 串流流連線, 流流連線(視窗連線)
- 序列表連線, 流表連線(流擴充)
- 表格連線, 表表連線(維護物化檢視)
- 時間的依賴性, 連線的時間依賴性
- 流程, 流處理-重播舊訊息
- 端對端,向客戶推進事件, 端到端的事件流
- messaging systems(見 messaging systems)
- processing(見 流處理)
- 與資料庫的關係, 資料庫與流-不變性的侷限性
- (另見 changelogs)
- 變更流的 API 支援, 變更流的 API 支援
- 資料變更捕獲, 資料變更捕獲-變更流的 API 支援
- 按時間分列的狀態衍生物, 狀態、流和不變性
- 事件溯源, 資料變更捕獲與事件溯源
- 保持系統同步, 保持系統同步-保持系統同步
- 不可改變事件哲學, 狀態、流和不變性-不變性的侷限性
- 專題, 傳遞事件流
- 嚴格的序列性, 什麼使系統具有線性一致性?
- 及時性與完整性, 及時性與完整性
- 條紋(列編碼), 列式儲存
- 強一致性(見 線性一致性)
- 最終的一致性, 自動衝突解決
- 強烈的單份序列性, 什麼使系統具有線性一致性?
- 主題、上游和物體(三層), 三元組儲存與 SPARQL
- 訂閱者, 傳遞事件流
- (另見 consumers)
- 超級計算機, 雲端計算與超級計算
- Superset(資料視覺化軟體), 分析(Analytics)
- 監視, 監視
- (另見 隱私)
- 壽司原則, 從資料倉儲到資料湖
- 可持續性, 分散式與單節點系統
- Swagger(服務定義格式), Web 服務
- swapping to disk(見 virtual memory)
- Swift(程式語言)
- 記憶體管理, 限制垃圾回收的影響
- 同步引擎, 同步引擎與本地優先軟體-同步引擎的利弊
- 例項, 同步引擎的利弊
- 用於本地第一軟體, 實時協作、離線優先和本地優先應用
- 同步網路, 同步與非同步網路, 術語表
- 同步複製, 同步複製與非同步複製, 術語表
- 有多個領導, 多主複製
- 系統管理員, 雲時代的運維
- 系統模型, 知識、真相和謊言, 系統模型與現實-確定性模擬測試
- 假設, 信任但驗證
- 演算法的正確性, 定義演算法的正確性
- 繪製真實世界的地圖, 將系統模型對映到現實世界
- 安全和生活, 安全性與活性
- 記錄系統, 記錄系統與派生資料, 術語表
- 資料變更捕獲, 資料變更捕獲的實現, 理解資料流
- 事件日誌, 事件溯源與 CQRS
- 事件日誌處理為, 狀態、流和不變性
- 系統思維, 反饋迴路
T
- t- digest(演算法), 響應時間指標的應用
- 表格連線, 表表連線(維護物化檢視)
- Tableau(資料視覺化軟體), 事務處理與分析的特徵, 分析(Analytics)
- 尾巴 (Unix 工具), 使用日誌進行訊息儲存
- tail latency(見 延遲)
- 尾頂(財產圖), 屬性圖
- task (workflows)(見 workflow engines)
- TCP (Transmission Control Protocol), TCP 的侷限性
- 時間(工作流程引擎), 持久化執行與工作流
- Tensorflow (機器學習圖書館), 機器學習
- Teradata(資料庫), 雲原生系統架構, 雲資料倉儲
- term-partitioned indexes(見 global secondary indexes)
- 終止(協商一致), 單值共識, 原子提交作為共識
- 測試, 人類與可靠性
- 擊打(記憶體斷), 程序暫停
- 執行緒(併發)
- Actor 模型, 分散式 actor 框架, 事件驅動架構與 RPC
- (另見 event-driven architecture)
- 原子操作, 原子性
- 背景執行緒, 構建和合並 SSTable
- 執行暫停, 我們不能簡單地使網路延遲可預測嗎?, 程序暫停-程序暫停
- 記憶體障礙, 線性一致性與網路延遲
- 預設, 程序暫停
- single(見 single-threaded execution)
- Actor 模型, 分散式 actor 框架, 事件驅動架構與 RPC
- 三階段承諾, 三階段提交
- 三方關係, 屬性圖
- Thrift(資料格式), Protocol Buffers
- 吞吐量, 描述效能, 描述負載, 批處理
- TIBCO, 訊息代理
- Enterprise Message Service, 訊息代理與資料庫的對比
- StreamBase (stream analytics), 複合事件處理
- TiDB(資料庫)
- 基於共識的複製, 單主複製
- 區域(硬化), 分片
- 請求路由, 請求路由
- 服務衍生資料, 對外提供派生資料
- 硬化二級指數, 全域性二級索引
- 快速隔離支援, 快照隔離與可重複讀
- 時間戳, 實現線性一致的 ID 生成器
- 事務, 事務到底是什麼?, 資料庫內部的分散式事務
- 使用模型檢查, 模型檢查與規範語言
- 分層儲存, 設定新的副本, 磁碟空間使用
- TigerBeetle(資料庫), 總結
- 確定性模擬測試, 確定性模擬測試
- TigerGraph(資料庫)
- GSQL language, SQL 中的圖查詢
- Tigris(物件儲存), 分散式檔案系統
- TileDB(資料庫), 資料框、矩陣與陣列
- 時間
- 時間序列資料
- 每日時鐘, 日曆時鐘
- 混合邏輯時鐘, 混合邏輯時鐘
- 及時性, 及時性與完整性
- 超時, 不可靠的網路, 術語表
- 動態配置, 網路擁塞和排隊
- 失敗, 領導者故障:故障轉移
- 長度, 超時和無界延遲
- TimescaleDB(資料庫), 列式儲存
- 時間戳, 邏輯時鐘
- 指定流處理中的事件, 你用的是誰的時鐘?
- 讀後寫入一致性, 讀己之寫
- 用於事務命令, 用於全域性快照的同步時鐘
- 執行制約因素不足, 使用邏輯時鐘強制約束
- 金鑰範圍, 按鍵的範圍分片
- 蘭波特, Lamport 時間戳
- 邏輯, 排序事件以捕獲因果關係
- 命令事件, 用於事件排序的時間戳
- 時間戳, 實現線性一致的 ID 生成器
- TLA+ (specification language), 模型檢查與規範語言
- 符號桶(限制重試), 描述效能
- 墓碑, 構建和合並 SSTable, 磁碟空間使用, 日誌壓縮
- 專題(資訊), 訊息代理, 傳遞事件流
- 撕裂的頁面(B- 樹), 使 B 樹可靠
- 全序, 術語表
- 追蹤, 分散式系統的問題
- 跟蹤行為資料, 隱私與追蹤
- (另見 隱私)
- 權衡, 資料系統架構中的權衡-資料系統、法律與社會
- transaction coordinator(見 協調者)
- transaction manager(見 協調者)
- 事務處理, 事務處理與分析的特徵-事務處理與分析的特徵
- 與分析的比較, 事務處理與分析的特徵
- 與資料儲存的比較, 分析型資料儲存
- 事務, 事務-總結, 術語表
- ACID properties of, ACID 的含義
- 資料完整性, 及時性與完整性
- 複製, 複製延遲的解決方案
- compensating(見 compensating transactions)
- 概念, 事務到底是什麼?
- 分散式事務, 分散式事務-再談恰好一次訊息處理
- 避開, 派生資料與分散式事務, 開展分拆工作, 強制約束-無協調資料系統
- 失敗放大, 維護派生狀態
- 已磨損的系統, 分片的利與弊
- 可疑/不確定狀況, 協調器故障, 存疑時持有鎖
- 兩階段提交, 兩階段提交(2PC)-三階段提交
- 使用, 跨不同系統的分散式事務-恰好一次訊息處理
- XA 事務, XA 事務-XA 事務的問題
- OLTP versus analytics queries, 分析(Analytics)
- 目標, 事務
- 可序列化, 可序列化-可序列化快照隔離的效能
- 實際執行, 實際序列執行-序列執行總結
- 悲觀與樂觀的併發控制, 悲觀併發控制與樂觀併發控制
- 可序列化快照隔離, 可序列化快照隔離(SSI)-可序列化快照隔離的效能
- 兩階段鎖定, 兩階段鎖定(2PL)-索引範圍鎖
- 單物件和多物件, 單物件與多物件操作-處理錯誤和中止
- 快照隔離(見 snapshots)
- 嚴格的序列性, 什麼使系統具有線性一致性?
- 薄弱的隔離水平, 弱隔離級別-物化衝突
- 曲線(圖), 屬性圖
- 三(資料結構), 構建和合並 SSTable, 全文檢索
- as SSTable index, SSTable 檔案格式
- 觸發器(資料庫), 傳遞事件流
- Trino(資料倉儲), 雲資料倉儲
- 聯邦資料庫, 一切的後設資料庫
- 查詢最佳化器, 查詢語言
- 用於 ETL, 提取-轉換-載入(ETL)
- 工作流程示例, 工作流排程
- 三層, 三元組儲存與 SPARQL-SPARQL 查詢語言
- SPARQL 查詢語言, SPARQL 查詢語言
- 翻轉視窗(流處理), 視窗的型別
- (另見 windows)
- 在微戰鬥中, 微批次與存檔點
- Turbopuffer(種子搜尋) Name, 設定新的副本
- Turtle (RDF data format), 三元組儲存與 SPARQL
- Twitter(見 X (social network))
- 兩階段提交, 兩階段提交(2PC)-協調器故障, 術語表
- 與雙相鎖定混淆, 兩階段鎖定(2PL)
- 協調員失敗, 協調器故障
- 協調員恢復, 從協調器故障中恢復
- 如何運作, 系統性的承諾
- 績效成本, 跨不同系統的分散式事務
- problems with XA transactions, XA 事務的問題
- 持有鎖定的事務, 存疑時持有鎖
- 兩階段鎖定, 兩階段鎖定(2PL)-索引範圍鎖, 什麼使系統具有線性一致性?, 術語表
- 與兩階段提交混淆, 兩階段鎖定(2PL)
- 增長和縮小階段, 兩階段鎖定的實現
- 索引範圍鎖定, 索引範圍鎖
- 業績, 兩階段鎖定的效能
- 型別檢查,動態對靜態, 文件模型中的模式靈活性
U
- UDP (User Datagram Protocol)
- comparison to TCP, 網路擁塞和排隊
- 多廣播, 直接從生產者傳遞給消費者
- 終極線上(遊戲), 分片
- 未繫結的資料集, 流處理, 術語表
- (另見 streams)
- 無限制的延誤, 術語表
- 解析資料庫, 分拆資料庫-多分割槽資料處理
- 構建資料儲存技術, 組合使用資料儲存技術-分拆系統與整合系統
- 聯邦制與拆分制, 一切的後設資料庫
- 圍繞資料流設計應用程式, 圍繞資料流設計應用-流處理器和服務
- 觀察匯出狀態, 觀察派生資料狀態-多分割槽資料處理
- 實現檢視和快取, 物化檢視和快取
- 多硬資料處理, 多分割槽資料處理
- 推動客戶端更改狀態, 將狀態變更推送給客戶端
- 構建資料儲存技術, 組合使用資料儲存技術-分拆系統與整合系統
- uncertain (transaction status)(見 存疑)
- 聯盟型別(在 Avro), 模式演化規則
- uniq(Unix 工具), 簡單日誌分析, 簡單日誌分析, 分散式作業編排
- 獨特性限制
- 同步檢查, 寬鬆地解釋約束
- 需要協商一致, 唯一性約束需要達成共識
- 需要線性, 約束與唯一性保證
- 以日誌為基礎的信件中的獨特性, 基於日誌訊息傳遞中的唯一性
- 團結(資料目錄), 雲資料倉儲
- universally unique identifiers(見 UUIDs)
- unix 哲學
- unix 管道, 簡單日誌分析
- 與分散式批次處理相比, 工作流排程
- UPDATE statement (SQL), 文件模型中的模式靈活性
- 更新
- 使用量
- 批次過程排程, 資源分配
- 透過預設增加, 故障處理
- 與暫時取捨, 我們不能簡單地使網路延遲可預測嗎?
- uTP protocol (BitTorrent), TCP 的侷限性
- UUIDs, ID 生成器和邏輯時鐘
V
- 有效性(協商一致), 單值共識, 原子提交作為共識
- vBuckets(硬化), 分片
- 向量時鐘, 版本向量
- (另見 版本向量)
- 和 Lamport/hybrid 邏輯鍾, Lamport/混合邏輯時鐘 vs. 向量時鐘
- 和版本向量, 版本向量
- 向量嵌入, 向量嵌入
- 向量處理, 查詢執行:編譯與向量化
- 供應商鎖定, 雲服務的利弊
- Venice(資料庫), 對外提供派生資料
- 核查, 信任但驗證-用於可審計資料系統的工具
- 避免盲目信任, 不要盲目信任承諾
- 設計可審計性, 為可審計性而設計
- 端對端完整性檢查, 端到端原則重現
- 可審計資料系統工具, 用於可審計資料系統的工具
- 版本控制系統
- 版本向量, 不同拓撲的問題, 版本向量
- Vertica(資料庫), 雲資料倉儲
- 處理寫入, 寫入列式儲存
- vertical scaling(見 scaling up)
- 頂點(圖), 圖資料模型
- 屬性圖模型, 屬性圖
- 電子遊戲, 同步引擎的利弊
- 影片轉碼(例如), 跨通道時序依賴
- views (SQL queries), Datalog:遞迴關係查詢
- materialized views(見 物化)
- 檢視戳複製, 共識, 共識的實踐
- 虛擬塊裝置, 儲存與計算的分離
- 虛擬檔案系統, 分散式檔案系統
- 比較分散式檔案系統, 分散式檔案系統
- 虛擬機器, 雲服務的分層
- 虛擬記憶體
- Virtuoso(資料庫), SPARQL 查詢語言
- VisiCalc (spreadsheets), 圍繞資料流設計應用
- Vitess(資料庫)
- 鍵程硬化, 按鍵的範圍分片
- 節點(硬化), 分片
- 詞彙, 三元組儲存與 SPARQL
- Voice over IP (VoIP), 網路擁塞和排隊
- VoltDB(資料庫)
W
- 預寫式日誌, 使 B 樹可靠
- WAL-G (backup tool), 設定新的副本
- WarpStream(訊息系統), 磁碟空間使用
- web services(見 services)
- 網路使用者, 直接從生產者傳遞給消費者
- 網路方法(通訊), 訊息代理
- WebSocket (protocol), 將狀態變更推送給客戶端
- 寬柱資料模型, 讀寫的資料區域性
- 相對於面向列的儲存, 列壓縮
- 視窗(流程處理), 流分析, 時間推理-視窗的型別
- 更改日誌的無限視窗, 維護物化檢視, 流表連線(流擴充)
- 知道所有事件何時到來, 處理滯留事件
- 串流在視窗內連線, 流流連線(視窗連線)
- 視窗型別, 視窗的型別
- WITH RECURSIVE syntax (SQL), SQL 中的圖查詢
- Word2Vec (language model), 向量嵌入
- 工作流程引擎, 持久化執行與工作流
- Airflow(見 Airflow(工作流排程器))
- 批處理, 工作流排程
- Camunda(見 Camunda (workflow engine))
- Dagster(見 Dagster(工作流排程器))
- 持久執行, 持久化執行與工作流
- 提取-轉換-載入(ETL)(見 ETL)
- 執行器, 持久化執行與工作流
- 樂團, 持久化執行與工作流, 批處理
- Orkes(見 Orkes (workflow engine))
- Prefect(見 Prefect(工作流排程器))
- 依賴決定性因素, 確定性模擬測試
- Restate(見 Restate (workflow engine))
- Temporal(見 Temporal (workflow engine))
- 工作設定, 排序與記憶體聚合
- 寫入放大, 寫放大
- 寫路徑, 觀察派生資料狀態
- 寫偏差, 寫偏差與幻讀-物化衝突
- 預寫式日誌, 使 B 樹可靠, 預寫日誌(WAL)傳輸
- 持久執行, 持久化執行
- 寫入(資料庫)
- 原子寫入操作, 原子寫操作
- 檢測影響前讀的寫入, 檢測影響先前讀取的寫入
- 防止汙穢的寫作,, 沒有髒寫
- WS-* framework, 遠端過程呼叫(RPC)的問題
- WS-AtomicTransaction (2PC), 兩階段提交(2PC)
X
- X (社會網路)
- 建造住房時間表(例如), 案例研究:社交網路首頁時間線, 從同一事件日誌中派生多個檢視, 表表連線(維護物化檢視), 物化檢視和快取
- 加入費用, 社交網路案例研究中的反正規化
- 描述負載, 描述負載
- 過失容忍, 容錯
- 業績計量, 描述效能
- DistributedLog (event log), 使用日誌進行訊息儲存
- Snowflake (ID generator), ID 生成器和邏輯時鐘
- 建造住房時間表(例如), 案例研究:社交網路首頁時間線, 從同一事件日誌中派生多個檢視, 表表連線(維護物化檢視), 物化檢視和快取
- XA 事務, 兩階段提交(2PC), XA 事務-XA 事務的問題
- xargs (Unix 工具) (英語)., 簡單日誌分析
- XFS (file system), 分散式檔案系統
- XGBoost (machine learning library), 機器學習
- XML
- 二進位制變體, 二進位制編碼
- 資料位置, 讀寫的資料區域性
- encoding RDF data, RDF 資料模型
- 應用資料的問題, JSON、XML 及其二進位制變體
- 關聯式資料庫, 文件模型中的模式靈活性
- XML databases, 關係模型與文件模型, 文件的查詢語言
- Xorq(查詢引擎), 一切的後設資料庫
- XPath, 文件的查詢語言
- XQuery, 文件的查詢語言
Y
- 亞虎
- 響應時間研究, 平均值、中位數與百分位點
- YARN (job scheduler), 分散式作業編排, 應用程式碼和狀態的分離
- ApplicationMaster, 分散式作業編排
- Yjs (CRDT library), 同步引擎的利弊
- YugabyteDB(資料庫)
- 雜湊變硬, 按雜湊範圍分片
- 鍵程硬化, 按鍵的範圍分片
- 多領導複製, 跨地域執行
- 請求路由, 請求路由
- 硬化二級指數, 全域性二級索引
- 平板(硬化), 分片
- 事務, 事務到底是什麼?, 資料庫內部的分散式事務
- 使用時鐘同步, 用於全域性快照的同步時鐘
Z
- Zab(協商一致演算法), 共識, 共識的實踐
- use in ZooKeeper, 實現線性一致性系統
- 零複製, 編碼資料的格式
- zero-disk architecture (ZDA), 設定新的副本
- ZeroMQ (messaging library), 直接從生產者傳遞給消費者
- 殭屍(分裂的大腦), 隔離殭屍程序和延遲請求
- zones (cloud computing)(見 availability zones)
- ZooKeeper (coordination service), 協調服務-服務發現
- 生成柵欄標誌, 隔離殭屍程序和延遲請求, 使用共享日誌, 協調服務
- 線性操作, 實現線性一致性系統
- 鎖和領袖選舉, 鎖定與領導者選舉
- 觀察員, 服務發現
- 用於服務發現, 負載均衡器、服務發現和服務網格, 服務發現
- 用於硬性轉讓, 請求路由
- 使用 Zab 演算法, 共識
後記
關於作者
Martin Kleppmann 是英國劍橋大學副教授,教授分散式系統與密碼學協議。2017 年出版的《設計資料密集型應用》第一版確立了他在資料系統領域的權威地位;他在分散式系統方面的研究也推動了 local-first 軟體運動。此前他曾在 LinkedIn、Rapportive 等網際網路公司擔任軟體工程師和創業者,負責大規模資料基礎設施。
Chris Riccomini 是軟體工程師、創業投資人和作者,擁有 15 年以上在 PayPal、LinkedIn、WePay 的工作經驗。他運營 Materialized View Capital,專注於基礎設施初創企業投資;同時也是 Apache Samza 與 SlateDB 的共同創造者,併合著了 The Missing README: A Guide for the New Software Engineer。

關於譯者
馮若航,网名 @Vonng。 PostgreSQL 專家,資料庫老司機,雲端計算泥石流。 PostgreSQL 發行版 Pigsty 作者與創始人。 架構師,DBA,全棧工程師 @ TanTan,Alibaba,Apple。 獨立開源貢獻者,GitStar Ranking 585,国区活跃 Top20。 DDIA / PG Internal 中文版譯者,資料庫/雲端計算 KOL。
後記
《設計資料密集型應用》封面上的動物是印度野豬(Sus scrofa cristatus),它是在印度、緬甸、尼泊爾、斯里蘭卡和泰國發現的一種野豬的亞種。與歐洲野豬不同,它們有更高的背部鬃毛,沒有體表絨毛,以及更大更直的頭骨。
印度野豬有一頭灰色或黑色的頭髮,脊背上有短而硬的毛。雄性有突出的犬齒(稱為 T),用來與對手戰鬥或抵禦掠食者。雄性比雌性大,這些物種平均肩高 33-35 英寸,體重 200-300 磅。他們的天敵包括熊、老虎和各種大型貓科動物。
這些動物夜行且雜食 —— 它們吃各種各樣的東西,包括根、昆蟲、腐肉、堅果、漿果和小動物。野豬經常因為破壞農作物的根被人們所熟知,他們造成大量的破壞,並被農民所敵視。他們每天需要攝入 4,000 ~ 4,500 卡路里的能量。野豬有發達的嗅覺,這有助於尋找地下植物和挖掘動物。然而,它們的視力很差。
野豬在人類文化中一直具有重要意義。在印度教傳說中,野豬是毗溼奴神的化身。在古希臘的喪葬紀念碑中,它是一個勇敢失敗者的象徵(與勝利的獅子相反)。由於它的侵略,它被描繪在斯堪的納維亞、日耳曼和盎格魯撒克遜戰士的盔甲和武器上。在中國十二生肖中,它象徵著決心和急躁。
O’Reilly 封面上的許多動物都受到威脅,這些動物對世界都很重要。要了解有關如何提供幫助的更多資訊,請訪問 animals.oreilly.com。
封面圖片來自 Shaw’s Zoology。封面字型是 URW Typewriter 和 Guardian Sans。文字字型是 Adobe Minion Pro;圖中的字型是 Adobe Myriad Pro;標題字型是 Adobe Myriad Condensed;程式碼字型是 Dalton Maag 的 Ubuntu Mono。
貢獻者
譯者
馮若航,网名 @Vonng。 PostgreSQL 專家,資料庫老司機,雲端計算泥石流。 Pigsty 作者與創始人。 架構師,DBA,全棧工程師 @ TanTan,Alibaba,Apple。 獨立開源貢獻者,GitStar Ranking 585,国区活跃 Top20。 DDIA / PG Internal 中文版譯者,公眾號:《老馮雲數》,資料庫 KOL。
校訂與維護
YinGang @yingang 對本書進行了全文校訂,並持續維護。
繁體中文版本
貢獻列表
頭像牆收錄提交過 Issue 或 PR 的朋友;無論是否採納、合併或關閉,反饋本身即是貢獻。
- 全文校訂 by @yingang
- 序言初翻修正 by @seagullbird
- 第一章語法標點校正 by @nevertiree
- 第六章部分校正 與第十章的初翻 by @MuAlex
- 第一部分前言,ch2校正 by @jiajiadebug
- 詞彙表、後記關於野豬的部分 by @cg-zhou
- 繁體中文版本与转换脚本 by @afunTW
- 多處翻譯修正 by @songzhibin97 @MamaShip @FangYuan33
完整記錄:Issues · Pull Requests。
截至 2026-09-19(UTC),共收錄 212 位貢獻者。以下記錄保留實際狀態,未合併的提議同樣計入貢獻。
| Issue / PR | 貢獻者 | 標題 | 狀態 |
|---|---|---|---|
| Issue #420 | @Ice-pumpkin | 圖 10-2 的描述錯誤 | 已關閉 |
| PR #419 | @JYu1999 | fix(tw): scalability 譯名由「可伸縮」改為「可擴充套件」 | 未合併 |
| PR #418 | @JYu1999 | fix(tw): 修正雲端相關詞彙中誤留簡體「雲」的問題 | 已合併 |
| PR #417 | @JYu1999 | chore: replace early release cover with final 2nd edition cover | 已合併 |
| PR #415 | @Waynting | Fix \[…\] bracket escaping parsed as math passthrough (en source + zh/tw footnote) | 已合併 |
| Issue #414 | @Valerie-space | 字型選項建議 | 已關閉 |
| PR #413 | @luchao2424631502 | fix(ch1): 修改錯別字 | 已合併 |
| Issue #412 | @chesha1 | 近期網站重構後的兩點使用體驗反饋 | 已關閉 |
| PR #411 | @Waynting | Fix bracket escaping in GDPR quote rendered as LaTeX math | 未合併 |
| PR #410 | @aha-jansen | fix(epub): 修復 EPUB 匯出的封面、目錄與導航連結 | 未合併 |
| PR #409 | @Klu5ure | 修復 README 中翻譯進度描述的筆誤 | 未合併 |
| PR #408 | @hezhengdong | fix(ch8): 刪除多餘空行,修復有序列表顯示問題 | 已合併 |
| PR #407 | @AldenWangExis | fix(ch): normalize zh/tw emphasis formatting | 已合併 |
| Issue #406 | @AldenWangExis | Inconsistent bold/italic emphasis in zh translation | 已關閉 |
| PR #405 | @AldenWangExis | fix(ch1): clarify abbreviation expansions in zh and tw | 未合併 |
| Issue #404 | @AldenWangExis | Some zh-only wording fixes may have drifted from tw | 已關閉 |
| Issue #403 | @AldenWangExis | Some zh-only wording fixes may have drifted from tw | 已關閉 |
| PR #402 | @AldenWangExis | fix(ch1): improve wording in zh and tw | 未合併 |
| PR #401 | @AldenWangExis | fix(epub): 修復生成 EPUB 後目錄跳轉錯誤 | 未合併 |
| PR #400 | @SXP-Simon | fix(zh): fix untranslated headings in ch13.md | 未合併 |
| Issue #399 | @1stcoderXiaoLin | 線上網站是不是掛了:http://ddia.vonng.com/ | 已關閉 |
| PR #398 | @c2j | 支援自動生成一整本PDF 電子書 | 未合併 |
| PR #397 | @daihaowxg | fix(ch2): 修正文案中的“觀察記錄果”筆誤 | 已合併 |
| Issue #395 | @ethan-tan-stori | 第六章節複製篇翻譯問題 | 已關閉 |
| Issue #394 | @wayne87140 | 專有名詞在括號後面加入原文 | 已關閉 |
| PR #393 | @cg-zhou | 更新貢獻者 GitHub 連結:按 PR 作者對映修復失效連結,並同步 README/_index/contrib | 已合併 |
| Issue #391 | @demonkit | 機翻閱讀不通順 | 已關閉 |
| PR #390 | @bearomorphism | Fix typographical errors in chapter 1 | 已合併 |
| PR #389 | @demo-zexuan | 恢復 EPUB 匯出功能並修復圖片顯示問題 | 已合併 |
| Issue #388 | @MintBlue | main 分支現在還支援 匯出 epub 這個特性嗎 | 已關閉 |
| PR #387 | @ButcherV | Update part-i.md | 已合併 |
| PR #386 | @uncle-lv | improve ch2 translation | 已合併 |
| PR #384 | @PanggNOTlovebean | docs: 最佳化中文文件的措辭和表達 | 已合併 |
| PR #383 | @PanggNOTlovebean | docs: 修正ch4中的術語和表達錯誤 | 已合併 |
| PR #382 | @uncle-lv | Improve ch1 translation | 已合併 |
| PR #381 | @Max-Tortoise | fix incomplete term in chapter 4 | 已合併 |
| PR #377 | @huang06 | Refine translation terms | 已合併 |
| Issue #375 | @z-soulx | 對於是否100%全中文翻譯的必要性討論?個人-沒必要100%,特別是“名詞”,有原單詞更加適合it人員 | 已關閉 |
| PR #371 | @lewiszlw | CPU core -> CPU 核心 | 已合併 |
| PR #369 | @bbwang-gl | Update ch7.md 可序列化快照隔離檢測一個事務何時修改另一個事務的讀取 | 已合併 |
| PR #368 | @yhao3 | update zh-tw.py and zh-tw content | 已合併 |
| PR #367 | @yhao3 | Fix typo, formatting and punctuation | 已合併 |
| PR #366 | @yangshangde | Update ch8.md,電源失敗改為電源失效 | 已合併 |
| PR #365 | @xyohn | #364 ch1 - optimize translation about Separation of storage and compute | 已合併 |
| Issue #364 | @xyohn | ch1 - optimize translation about Separation of storage and compute | 已關閉 |
| PR #363 | @xyohn | #362 optimize translation | 已合併 |
| Issue #362 | @xyohn | ch1 - optimize translation | 已關閉 |
| PR #359 | @c25423 | fix: Correct "Usage-Agent" to "User-Agent" in Chapter 10 | 已合併 |
| PR #358 | @lewiszlw | Fix a typo in chapter4 | 已合併 |
| PR #357 | @Gezi-lzq | feat: add Generate GitBook eBooks action | 未合併 |
| PR #356 | @lewiszlw | Fix chapter2 comma typo | 已合併 |
| PR #355 | @duroey | fix: 缺少一個括號 | 已合併 |
| PR #354 | @justlorain | fix: ch7 ref51 broken link | 已合併 |
| PR #353 | @fantasyczl | fix: 修復錯誤的引用 | 已合併 |
| PR #352 | @fantasyczl | Feature: Convert Markdown to EPUB | 已合併 |
| PR #351 | @fantasyczl | fix: correct list numbering | 未合併 |
| Issue #350 | @674019130 | ch1 - Transaction Processing versus Analytics | 已關閉 |
| PR #349 | @xiyihan0 | Fix typo on ch1.md | 已合併 |
| PR #348 | @omegaatt36 | fix: wrong img src of fig3-12.png | 已合併 |
| PR #347 | @hli988 | Updated ch1.md: Refined translation of 'interfaces' | 已合併 |
| Issue #346 | @Vermouth1995 | 第二版第一章有一處翻譯不太妥當 | 已關閉 |
| Issue #345 | @Vonng | DDIA 第二版翻譯 | 已關閉 |
| PR #343 | @kehao-chen | 修正不精確的表達方式 | 已合併 |
| PR #342 | @guaidaokakaxi | Update ch9.md | 未合併 |
| PR #341 | @YKIsTheBest | Refine sentences | 已合併 |
| PR #340 | @YKIsTheBest | Refine the chapter 2 sentences | 已合併 |
| Issue #339 | @tigerinus | 關於圖9-4的解釋 | 已關閉 |
| PR #338 | @YKIsTheBest | Refine sentences for more comprehensible in Chinese | 已合併 |
| PR #337 | @YKIsTheBest | Refine setences in ch1.md | 未合併 |
| Issue #336 | @msxfXF | 目錄好像有點問題 | 已關閉 |
| PR #335 | @kimi0230 | fix: zh-tw 日誌 to 日誌 | 已合併 |
| PR #334 | @soulrrrrr | fix zh-tw/ch2 typo | 未合併 |
| Issue #333 | @TronYY | 這個翻譯和[中國電力出版社]趙軍平 呂三平翻譯的有什麼不同 | 已關閉 |
| PR #332 | @justlorain | fix: ch5 inconsistent translation | 已合併 |
| PR #331 | @Lyianu | fix typo in ch9 | 已合併 |
| PR #330 | @Lyianu | fix ch7 translation | 已合併 |
| Issue #329 | @Lyianu | 第六章似乎存在一處翻譯錯誤 | 已關閉 |
| PR #328 | @justlorain | fix: ch4 missing translation | 已合併 |
| Issue #327 | @avenarius-argus | 關於英語版本文字缺失 | 已關閉 |
| PR #326 | @liangGTY | Resilient: 韌性 -> 回彈性 | 已合併 |
| PR #325 | @SunVdong | 修改 第四章 Field tags and schema evolution 移除欄位翻譯的問題 | 未合併 |
| Issue #324 | @starsea | 為什麼英文原版丟失了很多章節 | 已關閉 |
| PR #323 | @marvin263 | 明確表達:需要如何處理的是“老主庫尚未複製的寫入” | 已合併 |
| PR #322 | @marvin263 | 經驗–>從業經歷 | 已合併 |
| Issue #321 | @chlch | 第三章儲存與檢索,雜湊索引這章最後一段翻譯是不是有問題 | 已關閉 |
| PR #320 | @FangYuan33 | 修正錯別字 | 未合併 |
| PR #319 | @FangYuan33 | 修正標點符號 | 已合併 |
| PR #318 | @FangYuan33 | 去掉多餘的”在“ | 已合併 |
| PR #317 | @FangYuan33 | 去掉多餘的空格 | 已合併 |
| PR #316 | @FangYuan33 | 更正右引號 | 已合併 |
| PR #315 | @FangYuan33 | 成為 -> 稱為 | 未合併 |
| PR #314 | @FangYuan33 | 更正右引號 | 已合併 |
| PR #313 | @FangYuan33 | 更正標點符號 | 已合併 |
| PR #312 | @FangYuan33 | 統一上下文名詞 | 已合併 |
| PR #311 | @FangYuan33 | 補充謂語 | 已合併 |
| PR #310 | @FangYuan33 | 確定地 -> 確切地 | 已合併 |
| PR #309 | @FangYuan33 | 最佳化中文表述 | 已合併 |
| PR #308 | @FangYuan33 | 去掉多餘的“最” | 已合併 |
| PR #307 | @FangYuan33 | 最佳化表述 | 已合併 |
| PR #306 | @FangYuan33 | 更正右引號 | 已合併 |
| PR #305 | @FangYuan33 | 更改時間r的位置以增強可讀性 | 已合併 |
| PR #304 | @spike014 | refactor(ch11): update #變更資料捕獲的實現 | 已合併 |
| Issue #303 | @rabomen | 生成epub無法正確顯示公式 | 已關閉 |
| PR #302 | @FangYuan33 | 頁面展示加粗失效 | 已合併 |
| PR #301 | @FangYuan33 | 補充省略的賓語以更容易理解 | 已合併 |
| PR #300 | @FangYuan33 | 補充謂語 | 已合併 |
| PR #299 | @FangYuan33 | 去掉多餘的句號 | 已合併 |
| PR #298 | @Makonike | docs: fix typo | 已合併 |
| PR #297 | @FangYuan33 | 更正右引號 | 已合併 |
| PR #296 | @FangYuan33 | 最佳化翻譯 | 已合併 |
| PR #295 | @FangYuan33 | 最佳化表述 | 已合併 |
| PR #294 | @FangYuan33 | 客戶 -> 客戶端 | 已合併 |
| PR #293 | @FangYuan33 | 並行 -> 併發 | 已合併 |
| Issue #292 | @117503445 | 日期被錯誤解析為 emoji | 已關閉 |
| PR #291 | @FangYuan33 | 頁面內容加粗失效 | 未合併 |
| PR #290 | @FangYuan33 | 頁面內容加粗失效 | 未合併 |
| PR #289 | @FangYuan33 | 修改語序 | 已合併 |
| PR #288 | @FangYuan33 | 去掉不必要的量詞修飾 | 已合併 |
| PR #287 | @FangYuan33 | 補充謂語 | 已合併 |
| PR #286 | @FangYuan33 | 補充丟失的右括號 | 已合併 |
| PR #284 | @WAangzE | fix a wrong bullet in ch4 | 已合併 |
| PR #283 | @WAangzE | fix a typo in ch3 | 已合併 |
| PR #282 | @WAangzE | Fix a mathematical environment issue. | 已合併 |
| PR #281 | @lyuxi99 | fix some broken anchor links | 已合併 |
| PR #280 | @lyuxi99 | fix a broken anchor link in ch9.md | 已合併 |
| Issue #279 | @codexvn | 第9章 什麼使得系統線性一致,列舉CAS情況出現兩個x=V old | 已關閉 |
| PR #278 | @truenorth-lj | Update ch2.md | 未合併 |
| Issue #277 | @cuprumz | 分支因子為 500 的 4KB 頁面的四層樹可以儲存多達 256TB 的資料 | 已關閉 |
| Issue #276 | @sunzeren | 資料庫 | 已關閉 |
| PR #275 | @117503445 | fix: license 404 | 已合併 |
| PR #274 | @uncle-lv | docs: ch7.md 錯字修訂 最著名 -> 最著名 | 已合併 |
| PR #273 | @quwang123 | [docs] ch7 術語統一,寫入偏斜->寫入偏差,寫偏差->寫入偏差 | 已合併 |
| PR #272 | @quwang123 | [docs] 統一ch7中的術語 寫入偏斜->寫入偏差,寫偏差->寫入偏差 | 未合併 |
| PR #271 | @Makonike | docs: update ch6.md | 已合併 |
| PR #270 | @Ynjxsjmh | Fix “更新丟失” to “丟失更新” in ch7.md | 已合併 |
| Issue #269 | @auula | GitBook WeChat Group QRcode Expired | 已關閉 |
| PR #268 | @MamaShip | 最佳化第三章的部分語句 | 已合併 |
| PR #267 | @MamaShip | 調整腳註段落的位置 | 未合併 |
| PR #266 | @MamaShip | 最佳化第二章後半部分 | 已合併 |
| PR #265 | @MamaShip | 最佳化第二章的部分文字 | 已合併 |
| PR #264 | @MamaShip | minor fix: 正確區分「可擴展性」與「可伸縮性」 | 已合併 |
| PR #263 | @zydmayday | Update ch5.md | 已合併 |
| Issue #262 | @ccxhwmy | 第二章的 [例 2-1] 連結缺失 | 已關閉 |
| Issue #261 | @ccxhwmy | 十二張思維導圖分析 | 已關閉 |
| PR #260 | @haifeiWu | 修改部分翻譯不準確的問題 | 已合併 |
| Issue #259 | @SpikeWong | 關於無主複製的疑問 | 已關閉 |
| PR #258 | @bestgrc | Update ch3.md | 已合併 |
| PR #257 | @samwu4166 | Fix: typo nonderterministic | 已合併 |
| PR #256 | @AlphaWang | fix ch7-transaction: serializability | 已合併 |
| PR #255 | @AlphaWang | fix transaction: repeatable read | 已合併 |
| Issue #254 | @2w1nd | 網站404 not found | 已關閉 |
| PR #253 | @AlphaWang | fix ch7-transaction: Read Committed | 已合併 |
| PR #252 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #251 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #250 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #249 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #248 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #247 | @songzhibin97 | Update ch9.md | 已合併 |
| PR #246 | @derekwu0101 | 修正錯字 | 已合併 |
| PR #245 | @skyran1278 | ch12: 修正繁體中文翻譯 討論了 => 討論了 | 已合併 |
| PR #244 | @Axlgrep | Update ch9.md | 已合併 |
| PR #243 | @lynkeib | adjust wording for locking and leader election | 未合併 |
| PR #242 | @lynkeib | adjust wording for linearizability vs serializability | 已合併 |
| PR #241 | @lynkeib | Update ch8.md | 已合併 |
| PR #240 | @leo-987 | Update ch9.md | 已合併 |
| PR #239 | @BeBraveBeCurious | Update ch7.md | 已合併 |
| Issue #238 | @bluebear4 | 線上預覽網站打不開 | 已關閉 |
| PR #237 | @zhangnew | fix img url in ch3.md | 已合併 |
| PR #236 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #235 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #234 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #233 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #232 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #231 | @songzhibin97 | Update ch8.md | 已合併 |
| PR #230 | @songzhibin97 | Update ch8.md | 未合併 |
| PR #229 | @lis186 | 更正錯字 | 未合併 |
| PR #228 | @songzhibin97 | Update ch7.md | 已合併 |
| PR #227 | @songzhibin97 | Update ch7.md | 已合併 |
| PR #226 | @chroming | Update ch1.md | 已合併 |
| PR #225 | @songzhibin97 | Update ch7.md | 已合併 |
| PR #224 | @songzhibin97 | Update ch7.md | 已合併 |
| PR #223 | @songzhibin97 | Update ch7.md | 已合併 |
| PR #222 | @songzhibin97 | Update ch7.md | 已合併 |
| Issue #221 | @Juude | 建議能增加下英文原文的連結。 | 已關閉 |
| PR #220 | @skyran1278 | fix a zh-tw translation issue | 已合併 |
| Issue #219 | @Style980102 | 很多圖都掛了,作者能再配一下嗎 | 已關閉 |
| PR #218 | @songzhibin97 | Update ch5.md | 未合併 |
| PR #217 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #216 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #215 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #214 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #213 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #212 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #211 | @songzhibin97 | Update ch5.md | 未合併 |
| PR #210 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #209 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #208 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #207 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #206 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #205 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #204 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #203 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #202 | @songzhibin97 | Update ch5.md | 已合併 |
| PR #201 | @songzhibin97 | Update ch4.md | 已合併 |
| PR #200 | @songzhibin97 | typo:好外->好處 | 已合併 |
| PR #199 | @songzhibin97 | 索引覆蓋了查詢->索引覆蓋了的查詢 | 未合併 |
| PR #198 | @songzhibin97 | type:更具->更具有 | 已合併 |
| PR #197 | @songzhibin97 | 地區、地區-> 地區 | 已合併 |
| PR #196 | @songzhibin97 | 夠能->能夠 | 已合併 |
| Issue #195 | @BeBraveBeCurious | REST 是否是一個協議呢? | 已關閉 |
| PR #194 | @BeBraveBeCurious | Update ch4.md | 已合併 |
| PR #193 | @BeBraveBeCurious | Update ch4.md | 已合併 |
| PR #192 | @BeBraveBeCurious | Update ch4.md | 已合併 |
| PR #191 | @songzhibin97 | typo:時以->是以 | 已合併 |
| PR #190 | @Pcrab | Update ch1.md | 已合併 |
| Issue #189 | @rovernerd | 第一章中的部分圖片掛掉了 | 已關閉 |
| Issue #188 | @BeBraveBeCurious | 第四章的 typo 更正後,網頁閱讀版未更新 | 已關閉 |
| PR #187 | @narojay | fix 感覺原來翻譯有點生硬 | 已合併 |
| PR #186 | @narojay | 存在語病 | 已合併 |
| Issue #185 | @leo-987 | 小標題無法跳轉 | 已關閉 |
| PR #184 | @DavidZhiXing | Update ch10.md | 已合併 |
| PR #183 | @OneSizeFitsQuorum | Fix typo in ch8 | 已合併 |
| Issue #182 | @lroolle | Feature Request: support change to simple theme | 已關閉 |
| PR #181 | @YunfengGao | fix typo of ch2.md | 已合併 |
| PR #180 | @skyran1278 | fix typo of ch3.md | 未合併 |
| Issue #179 | @goace | 請問能編譯成epub或者mobi嗎? | 已關閉 |
| PR #178 | @BeBraveBeCurious | 嘗試顯示 gitbook 多級目錄 | 未合併 |
| PR #177 | @exzhawk | use docsify-katex to support latex syntax | 已合併 |
| PR #176 | @haifeiWu | fix some translate confusing place | 已合併 |
| PR #175 | @cwr31 | 不變數->不變式 | 已合併 |
| PR #174 | @BeBraveBeCurious | Update README.md | 已合併 |
| PR #173 | @ZvanYang | Update ch12.md | 已合併 |
| PR #172 | @ZvanYang | Update ch12.md | 未合併 |
| PR #171 | @ZvanYang | Update ch12.md | 已合併 |
| PR #170 | @ZvanYang | Update ch12.md | 未合併 |
| PR #169 | @ZvanYang | Update ch12.md | 已合併 |
| PR #168 | @ZvanYang | Update ch12.md | 未合併 |
| Issue #167 | @yhm138 | gitbook pdf這個命令好像行 | 已關閉 |
| PR #166 | @bp4m4h94 | fix: ch1 request | 已合併 |
| Issue #165 | @yhm138 | where can I download pdf version of https://vonng.gitbook.io/vonng/ | 已關閉 |
| PR #164 | @longjiquan | Fix quatation marks of preface.md | 已合併 |
| PR #163 | @llmmddCoder | ch1 evolability -> evolvability | 已合併 |
| Issue #162 | @yingang | GitBook 的翻譯可以整體更新下嘛? | 已關閉 |
| PR #161 | @ZvanYang | Update ch10.md | 未合併 |
| Issue #160 | @Zhayhp | 網路模型(network model)翻譯意見 | 已關閉 |
| PR #159 | @1ess | Update ch4.md | 已合併 |
| PR #158 | @ZvanYang | Update ch7.md | 未合併 |
| PR #157 | @ZvanYang | Update ch7.md | 已合併 |
| PR #156 | @ZvanYang | Update ch7.md | 未合併 |
| PR #155 | @ZvanYang | Update ch7.md | 已合併 |
| PR #154 | @ZvanYang | Update ch7.md | 未合併 |
| PR #153 | @DavidZhiXing | typos | 已合併 |
| PR #152 | @ZvanYang | Update ch7.md | 已合併 |
| PR #151 | @ZvanYang | Update ch5.md | 已合併 |
| PR #150 | @ZvanYang | Update ch5.md | 未合併 |
| PR #149 | @ZvanYang | Update ch5.md | 未合併 |
| PR #148 | @ZvanYang | Update ch5.md | 未合併 |
| PR #147 | @ZvanYang | Update ch5.md | 已合併 |
| PR #146 | @ZvanYang | Update ch5.md | 未合併 |
| PR #145 | @ui-HookeyChiang | Fix zh-tw/ch1 typoes | 未合併 |
| Issue #144 | @secret4233 | 間隙鎖與next-key locking | 已關閉 |
| Issue #143 | @imcheney | 第三章有很多中文翻譯的不通順 | 已關閉 |
| Issue #142 | @XIJINIAN | 文章每一段開頭,為什麼有一個空格呢? | 已關閉 |
| Issue #141 | @Flyraty | ch5.md 中有個小小的 markdown 語法錯誤 | 已關閉 |
| PR #140 | @Bowser1704 | 修復第五章的一些翻譯問題 | 已合併 |
| PR #139 | @Bowser1704 | 修復第二章的錯誤翻譯和第三章的一些描述問題 | 已合併 |
| Issue #138 | @wafer-li | Online Preview should enforce HTTPS | 已關閉 |
| PR #137 | @fuxuemingzhu | 修復第 5 章和第 6 章中有歧義的描述 | 已合併 |
| PR #136 | @wangqiim | fix sidebar view order | 未合併 |
| Issue #135 | @shukebeta | 序言中的本書 gitbook 連結失效了。 | 已關閉 |
| PR #134 | @fuxuemingzhu | 修復第 4 章有歧義的內容 | 已合併 |
| PR #133 | @fuxuemingzhu | 修復第三章的錯別字以及有歧義描述 | 已合併 |
| PR #132 | @fuxuemingzhu | 把「新值不大於舊值」改為「新值位元組數不大於舊值」 | 已合併 |
| PR #131 | @KAKXA | ch6中 金鑰->鍵,zh-tw的ch6中 金鑰->鍵 | 已合併 |
| PR #130 | @fuxuemingzhu | 把「關鍵」修改為「關鍵值索引」,更清晰 | 未合併 |
| PR #129 | @anaer | Update ch4.md | 已合併 |
| PR #128 | @meilin96 | 修改連結錯誤 | 已合併 |
| PR #127 | @guaidaokakaxi | 修改負載偏斜與熱點消除章節的翻譯 | 未合併 |
| PR #126 | @cwr31 | 功能—>函式 | 已合併 |
| PR #125 | @dch1228 | 最好地 -> 以最佳方式 | 已合併 |
| PR #124 | @yingang | translation updates (chapter 10) | 已合併 |
| PR #123 | @yingang | translation updates (chapter 9, TOC in readme, glossary, etc.) | 已合併 |
| Issue #122 | @yingang | 想問下bookstack/書棧網對此譯本的搬運是得到授權的嗎? | 已關閉 |
| PR #121 | @yingang | translation updates (chapter 5 to chapter 8) | 已合併 |
| PR #120 | @jiong-han | Typo fix: 呲之以鼻 -> 嗤之以鼻 | 已合併 |
| PR #119 | @cclauss | Streamline file operations in convert() | 已合併 |
| PR #118 | @yingang | translation updates (chapter 2 and 3) | 已合併 |
| PR #117 | @feeeei | 統一每章的標題格式 | 已合併 |
| Issue #116 | 已刪除賬戶 | 有 epub 版本嗎 | 已關閉 |
| PR #115 | @NageNalock | 第七章病句修改: 重複詞語 | 已合併 |
| PR #114 | @Sunt-ing | Update README.md: correct the book name | 已合併 |
| PR #113 | @lpxxn | 修改語句 | 已合併 |
| PR #112 | @ibyte2011 | Update ch9.md | 已合併 |
| Issue #111 | @mxdljwxx | Ddia | 已關閉 |
| PR #110 | @lpxxn | 讀已寫入資料 | 未合併 |
| Issue #109 | @sunyiwei24601 | 第八章的開頭引用 | 已關閉 |
| Issue #108 | @taco-wang | 來一個pdf版本吧 | 已關閉 |
| PR #107 | @abbychau | 單調鐘和好死還是賴活著 | 已合併 |
| PR #106 | @enochii | typo in ch2: fix braces typo | 已合併 |
| PR #105 | @LiminCode | Chronicle translation error | 已合併 |
| PR #104 | @Sunt-ing | several advice for better translation | 已合併 |
| PR #103 | @Sunt-ing | typo in ch4: should be 完成 rather than 完全 | 已合併 |
| PR #102 | @Sunt-ing | ch4: better-translation: 扼殺 → 破壞 | 已合併 |
| PR #101 | @Sunt-ing | typo in Ch4: should be "改變" rathr than "蓋面" | 已合併 |
| PR #100 | @LiminCode | fix missing translation | 已合併 |
| PR #99 | @mrdrivingduck | ch6: fix the word rebalancing | 已合併 |
| PR #98 | @jacklightChen | fix ch7.md: fix wrong references | 已合併 |
| PR #97 | @jenac | 96 | 未合併 |
| PR #96 | @PragmaTwice | ch2: fix typo about 'may or may not be' | 已合併 |
| PR #95 | @EvanMu96 | fix translation of "the battle cry" in ch5 | 未合併 |
| PR #94 | @kemingy | ch6: fix markdown and punctuations | 已合併 |
| PR #93 | @kemingy | ch5: fix markdown and some typos | 已合併 |
| PR #92 | @Gilbert1024 | Merge pull request #1 from Vonng/master | 未合併 |
| Issue #91 | @xiekeyi98 | 事務處理還是分析,語句不通順問題。 | 已關閉 |
| Issue #90 | @q00218426 | ch4.md 一處翻譯錯誤 | 已關閉 |
| Issue #89 | @brucevoin | 建議將第一章的可擴展性修改為可伸縮性 | 已關閉 |
| PR #88 | @kemingy | fix typo for ch1, ch2, ch3, ch4 | 已合併 |
| PR #87 | @wynn5a | Update ch3.md | 未合併 |
| PR #86 | @northmorn | Update ch1.md | 已合併 |
| PR #85 | @sunbuhui | fix ch2.md: fix ch2 ambiguous translation | 已合併 |
| PR #84 | @ganler | Fix translation: use up | 已合併 |
| PR #83 | @afunTW | Using OpenCC to convert from zh-cn to zh-tw | 已合併 |
| PR #82 | @kangni | fix gitbook url | 已合併 |
| Issue #81 | @atlas927 | gitbook無法開啟了 | 已關閉 |
| Issue #80 | @l1t1 | suggest to reduce the picture size | 已關閉 |
| Issue #79 | @TrafalgarRicardoLu | GitHub不支援公式,能否將數學符號轉為圖片顯示 | 已關閉 |
| PR #78 | @hanyu2 | Fix unappropriated translation | 已合併 |
| PR #77 | @Ozarklake | fix typo | 已合併 |
| Issue #76 | @Stephan14 | 圖片看不到 | 已關閉 |
| PR #75 | @2997ms | Fix typo | 未合併 |
| PR #74 | @2997ms | Update ch9.md | 未合併 |
| Issue #73 | @vult137 | 第四章的錯誤翻譯 | 已關閉 |
| Issue #72 | @tooloudwind | 疑問:原作者或出版社是否反對這裡的翻譯? | 已關閉 |
| Issue #71 | @huiscool | 建議把第四章 message broker 從 '訊息掮客' 譯為 '訊息代理' | 已關閉 |
| PR #70 | @2997ms | Update ch7.md | 已合併 |
| Issue #69 | @NIL-zhuang | 錯誤的引用格式 | 已關閉 |
| Issue #68 | @walshzhang | 將 REST 的翻譯改為 表述性狀態傳遞 更為確切 | 已關閉 |
| PR #67 | @jiajiadebug | fix issues in ch2 - ch9 and glossary | 已合併 |
| PR #66 | @blindpirate | Fix typo | 已合併 |
| Issue #65 | @jasonlei-chn | MarkDown 粗字型未轉換 | 已關閉 |
| Issue #64 | @woodpenker | 第十章似乎存在翻譯錯誤–重複語句 | 已關閉 |
| PR #63 | @haifeiWu | Update ch10.md | 已合併 |
| PR #62 | @ych | fix ch1.md typesetting problem | 已合併 |
| PR #61 | @xianlaioy | docs:鍾–>種,去掉ou | 已合併 |
| PR #60 | @Zombo1296 | 否則 -> 或者 | 已合併 |
| PR #59 | @brynne8 | 呼叫->呼叫,顯著->顯著 | 已合併 |
| PR #58 | @ibyte2011 | Update ch8.md | 已合併 |
| Issue #57 | @meijies | [第二部分]分散式系統 – 參考文獻小節中的第一個參考文獻What Every Programmer Should Know About Memory指向的連結錯誤 | 已關閉 |
| Issue #56 | @amber-moe | 生成pdf | 已關閉 |
| PR #55 | @saintube | ch8: 修改連結錯誤 | 已合併 |
| PR #54 | @Panmax | Update ch2.md | 已合併 |
| PR #53 | @ibyte2011 | Update ch9.md | 未合併 |
| PR #52 | @hecenjie | Update ch1.md | 已合併 |
| PR #51 | @qig243 | fix 修正ch3 ch4幾處翻譯 | 已合併 |
| PR #50 | @AlexZFX | 幾個疏漏和格式錯誤 | 已合併 |
| PR #49 | @haifeiWu | Update ch1.md | 已合併 |
| PR #48 | @scaugrated | fix typo | 已合併 |
| PR #47 | @lzwill | Fixed typos in ch2 | 已合併 |
| Issue #46 | @afredlyj | 書上的圖怎麼搞下來的? | 已關閉 |
| PR #45 | @zenuo | 刪除一個多餘的右括號 | 已合併 |
| PR #44 | @akxxsb | 修正第7章底部連結錯誤 | 未合併 |
| PR #43 | @baijinping | "更假簡單"->"更加簡單" | 已合併 |
| PR #42 | @tisonkun | 修復 ch1 中的無序列表格式 | 未合併 |
| Issue #41 | @shiyiwan | 第10章到第11章的導航連結錯誤 | 已關閉 |
| Issue #40 | @b7woreo | 第十一章 傳遞事件流 部分有重複內容 | 已關閉 |
| Issue #39 | @lllliuliu | 第七章到第八章的導航連結錯了 | 已關閉 |
| PR #38 | @b7woreo | 糾正多處的翻譯小錯誤 | 已合併 |
| PR #37 | @tankilo | fix translation mistakes in ch4.md | 未合併 |
| PR #36 | @wwek | 1.修復多個連結錯誤 2.名詞最佳化修訂 3.錯誤修訂 | 已合併 |
| PR #35 | @wwek | fix ch7.md to ch8.md link error | 未合併 |
| PR #34 | @wwek | Merge pull request #1 from Vonng/master | 未合併 |
| PR #33 | @wwek | fix part-ii.md link error | 已合併 |
| PR #32 | @JCYoky | Update ch2.md | 已合併 |
| PR #31 | @elsonLee | Update ch7.md | 已合併 |
| Issue #30 | @undeflife | 第七章可商榷的地方 | 已關閉 |
| Issue #29 | @nevertiree | 希望能推出Release版本 | 已關閉 |
| Issue #28 | @krisjin | 剛剛出版的不是該翻譯的版本嗎 | 已關閉 |
| Issue #27 | @lqbilbo | 每章最後的導航連結都錯了 | 已關閉 |
| PR #26 | @yjhmelody | 修復一些明顯錯誤 | 已合併 |
| PR #25 | @lqbilbo | 修復連結錯誤 | 已合併 |
| PR #24 | @artiship | 修改詞語順序 | 已合併 |
| PR #23 | @artiship | 修正錯別字 | 已合併 |
| PR #22 | @artiship | 糾正翻譯錯誤 | 已合併 |
| PR #21 | @zhtisi | 修正目錄和本章標題不符的情況 | 已合併 |
| PR #20 | @rentiansheng | Update ch7.md | 已合併 |
| PR #19 | @LHRchina | 修復語句小bug | 已合併 |
| Issue #18 | @patricksuo | 非常感謝翻譯,但是會不會有版權問題? | 已關閉 |
| Issue #17 | @KevinZhangt | [建議] GitBook 增加下載功能 | 已關閉 |
| PR #16 | @MuAlex | Master | 已合併 |
| PR #15 | @cg-zhou | Update translation progress | 未合併 |
| PR #14 | @cg-zhou | Translate glossary | 已合併 |
| PR #13 | @cg-zhou | 詳細修改了後記中和印度野豬相關的描述 | 已合併 |
| PR #12 | @ibyte2011 | 修改了部分翻譯 | 未合併 |
| PR #11 | @jiajiadebug | ch2 100% | 已合併 |
| PR #10 | @jiajiadebug | ch2 20% | 已合併 |
| PR #9 | @jiajiadebug | Preface, ch1, part-i translation minor fixes | 已合併 |
| Issue #8 | @cch123 | QRCode expired | 已關閉 |
| PR #7 | @MuAlex | Ch6 translation pull request | 已合併 |
| PR #6 | @MuAlex | Ch6 change version1 | 已合併 |
| PR #5 | @nevertiree | Chapter 01語法微調 | 已合併 |
| Issue #4 | @nevertiree | GitBook | 已關閉 |
| Issue #3 | @mawenqi | 表3-1標題行的OLTP和OLAP位置反了 | 已關閉 |
| PR #2 | @seagullbird | 序言初翻 | 已合併 |
| Issue #1 | @smallyard | 加油,期待你的完成 | 已關閉 |