From 96a6ef3ced4073dc4edab1610c2fe822584edfb3 Mon Sep 17 00:00:00 2001 From: afunTW Date: Mon, 5 Oct 2020 10:06:21 +0800 Subject: [PATCH 01/12] update: orig to one language one folder --- README.md => zh-cn/README.md | 0 SUMMARY.md => zh-cn/SUMMARY.md | 0 ch1.md => zh-cn/ch1.md | 0 ch10.md => zh-cn/ch10.md | 0 ch11.md => zh-cn/ch11.md | 0 ch12.md => zh-cn/ch12.md | 0 ch2.md => zh-cn/ch2.md | 0 ch3.md => zh-cn/ch3.md | 0 ch4.md => zh-cn/ch4.md | 0 ch5.md => zh-cn/ch5.md | 0 ch6.md => zh-cn/ch6.md | 0 ch7.md => zh-cn/ch7.md | 0 ch8.md => zh-cn/ch8.md | 0 ch9.md => zh-cn/ch9.md | 0 colophon.md => zh-cn/colophon.md | 0 glossary.md => zh-cn/glossary.md | 0 part-i.md => zh-cn/part-i.md | 0 part-ii.md => zh-cn/part-ii.md | 0 part-iii.md => zh-cn/part-iii.md | 0 preface.md => zh-cn/preface.md | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename README.md => zh-cn/README.md (100%) rename SUMMARY.md => zh-cn/SUMMARY.md (100%) rename ch1.md => zh-cn/ch1.md (100%) rename ch10.md => zh-cn/ch10.md (100%) rename ch11.md => zh-cn/ch11.md (100%) rename ch12.md => zh-cn/ch12.md (100%) rename ch2.md => zh-cn/ch2.md (100%) rename ch3.md => zh-cn/ch3.md (100%) rename ch4.md => zh-cn/ch4.md (100%) rename ch5.md => zh-cn/ch5.md (100%) rename ch6.md => zh-cn/ch6.md (100%) rename ch7.md => zh-cn/ch7.md (100%) rename ch8.md => zh-cn/ch8.md (100%) rename ch9.md => zh-cn/ch9.md (100%) rename colophon.md => zh-cn/colophon.md (100%) rename glossary.md => zh-cn/glossary.md (100%) rename part-i.md => zh-cn/part-i.md (100%) rename part-ii.md => zh-cn/part-ii.md (100%) rename part-iii.md => zh-cn/part-iii.md (100%) rename preface.md => zh-cn/preface.md (100%) diff --git a/README.md b/zh-cn/README.md similarity index 100% rename from README.md rename to zh-cn/README.md diff --git a/SUMMARY.md b/zh-cn/SUMMARY.md similarity index 100% rename from SUMMARY.md rename to zh-cn/SUMMARY.md diff --git a/ch1.md b/zh-cn/ch1.md similarity index 100% rename from ch1.md rename to zh-cn/ch1.md diff --git a/ch10.md b/zh-cn/ch10.md similarity index 100% rename from ch10.md rename to zh-cn/ch10.md diff --git a/ch11.md b/zh-cn/ch11.md similarity index 100% rename from ch11.md rename to zh-cn/ch11.md diff --git a/ch12.md b/zh-cn/ch12.md similarity index 100% rename from ch12.md rename to zh-cn/ch12.md diff --git a/ch2.md b/zh-cn/ch2.md similarity index 100% rename from ch2.md rename to zh-cn/ch2.md diff --git a/ch3.md b/zh-cn/ch3.md similarity index 100% rename from ch3.md rename to zh-cn/ch3.md diff --git a/ch4.md b/zh-cn/ch4.md similarity index 100% rename from ch4.md rename to zh-cn/ch4.md diff --git a/ch5.md b/zh-cn/ch5.md similarity index 100% rename from ch5.md rename to zh-cn/ch5.md diff --git a/ch6.md b/zh-cn/ch6.md similarity index 100% rename from ch6.md rename to zh-cn/ch6.md diff --git a/ch7.md b/zh-cn/ch7.md similarity index 100% rename from ch7.md rename to zh-cn/ch7.md diff --git a/ch8.md b/zh-cn/ch8.md similarity index 100% rename from ch8.md rename to zh-cn/ch8.md diff --git a/ch9.md b/zh-cn/ch9.md similarity index 100% rename from ch9.md rename to zh-cn/ch9.md diff --git a/colophon.md b/zh-cn/colophon.md similarity index 100% rename from colophon.md rename to zh-cn/colophon.md diff --git a/glossary.md b/zh-cn/glossary.md similarity index 100% rename from glossary.md rename to zh-cn/glossary.md diff --git a/part-i.md b/zh-cn/part-i.md similarity index 100% rename from part-i.md rename to zh-cn/part-i.md diff --git a/part-ii.md b/zh-cn/part-ii.md similarity index 100% rename from part-ii.md rename to zh-cn/part-ii.md diff --git a/part-iii.md b/zh-cn/part-iii.md similarity index 100% rename from part-iii.md rename to zh-cn/part-iii.md diff --git a/preface.md b/zh-cn/preface.md similarity index 100% rename from preface.md rename to zh-cn/preface.md From 334425a78344f1248738d3641511bbd017cfc794 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 00:57:18 +0800 Subject: [PATCH 02/12] add: using opencc project to transform chinese --- Pipfile | 13 +++++++++++++ Pipfile.lock | 36 ++++++++++++++++++++++++++++++++++++ transform.py | 21 +++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 transform.py diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..fa016567 --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +opencc = "*" +click = "*" + +[dev-packages] + +[requires] +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 00000000..f5a95c23 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,36 @@ +{ + "_meta": { + "hash": { + "sha256": "e11e067f853c70dd2660c4e3d606219472ef597161bcf00ffcd63872df1183bf" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "index": "pypi", + "version": "==7.1.2" + }, + "opencc": { + "hashes": [ + "sha256:1e0d40581dd5130ac3160f97e752caff202aa22aa004d468496fa8cba81035e7" + ], + "index": "pypi", + "version": "==1.1.1.post1" + } + }, + "develop": {} +} diff --git a/transform.py b/transform.py new file mode 100644 index 00000000..6465e845 --- /dev/null +++ b/transform.py @@ -0,0 +1,21 @@ +"""Convert zh-cn to zh-tw +Refer to https://github.com/BYVoid/OpenCC +""" +import click +import opencc + + +@click.command() +@click.option("-i", "--input", "infile", required=True) +@click.option("-o", "--output", "outfile", required=True) +@click.option("-c", "--config", "cfg", required=True, default="s2twp.json") +def main(infile, outfile, cfg): + converter = opencc.OpenCC(cfg) + with open(infile, "r") as inf, open(outfile, "w+") as outf: + data = inf.readlines() + data = list(map(converter.convert, data)) + outf.writelines(data) + + +if __name__ == "__main__": + main() From ddf0a8e7ff9be3f5b4d51299bba25d6130af9e31 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 00:57:33 +0800 Subject: [PATCH 03/12] translate --- zh-tw/ch1.md | 462 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 zh-tw/ch1.md diff --git a/zh-tw/ch1.md b/zh-tw/ch1.md new file mode 100644 index 00000000..2cb19809 --- /dev/null +++ b/zh-tw/ch1.md @@ -0,0 +1,462 @@ +# 第一章:可靠性,可擴充套件性,可維護性 + +![](img/ch1.png) + +> 網際網路做得太棒了,以至於大多數人將它看作像太平洋這樣的自然資源,而不是什麼人工產物。上一次出現這種大規模且無差錯的技術, 你還記得是什麼時候嗎? +> +> ——阿蘭·凱在接受Dobb博士雜誌採訪時說(2012年) + +----------------------- + +[TOC] + +現今很多應用程式都是 **資料密集型(data-intensive)** 的,而非 **計算密集型(compute-intensive)** 的。因此CPU很少成為這類應用的瓶頸,更大的問題通常來自資料量、資料複雜性、以及資料的變更速度。 + +資料密集型應用通常由標準組件構建而成,標準組件提供了很多通用的功能;例如,許多應用程式都需要: + + - 儲存資料,以便自己或其他應用程式之後能再次找到 (***資料庫(database)***) + - 記住開銷昂貴操作的結果,加快讀取速度(***快取(cache)***) + - 允許使用者按關鍵字搜尋資料,或以各種方式對資料進行過濾(***搜尋索引(search indexes)***) + - 向其他程序傳送訊息,進行非同步處理(***流處理(stream processing)***) + - 定期處理累積的大批次資料(***批處理(batch processing)***) + +如果這些功能聽上去平淡無奇,那是因為這些 **資料系統(data system)** 是非常成功的抽象:我們一直不假思索地使用它們並習以為常。絕大多數工程師不會幻想從零開始編寫儲存引擎,因為在開發應用時,資料庫已經是足夠完美的工具了。 + +​ 但現實沒有這麼簡單。不同的應用有著不同的需求,因而資料庫系統也是百花齊放,有著各式各樣的特性。實現快取有很多種手段,建立搜尋索引也有好幾種方法,諸如此類。因此在開發應用前,我們依然有必要先弄清楚最適合手頭工作的工具和方法。而且當單個工具解決不了你的問題時,組合使用這些工具可能還是有些難度的。 + +​ 本書將是一趟關於資料系統原理、實踐與應用的旅程,並講述了設計資料密集型應用的方法。我們將探索不同工具之間的共性與特性,以及各自的實現原理。 + +​ 本章將從我們所要實現的基礎目標開始:可靠、可擴充套件、可維護的資料系統。我們將澄清這些詞語的含義,概述考量這些目標的方法。並回顧一些後續章節所需的基礎知識。在接下來的章節中我們將抽絲剝繭,研究設計資料密集型應用時可能遇到的設計決策。 + + + +## 關於資料系統的思考 + +​ 我們通常認為,資料庫、訊息佇列、快取等工具分屬於幾個差異顯著的類別。雖然資料庫和訊息隊列表面上有一些相似性——它們都會儲存一段時間的資料——但它們有迥然不同的訪問模式,這意味著迥異的效能特徵和實現手段。 + +​ 那我們為什麼要把這些東西放在 **資料系統(data system)** 的總稱之下混為一談呢? + +​ 近些年來,出現了許多新的資料儲存工具與資料處理工具。它們針對不同應用場景進行最佳化,因此不再適合生硬地歸入傳統類別【1】。類別之間的界限變得越來越模糊,例如:資料儲存可以被當成訊息佇列用(Redis),訊息佇列則帶有類似資料庫的持久保證(Apache Kafka)。 + +​ 其次,越來越多的應用程式有著各種嚴格而廣泛的要求,單個工具不足以滿足所有的資料處理和儲存需求。取而代之的是,總體工作被拆分成一系列能被單個工具高效完成的任務,並透過應用程式碼將它們縫合起來。 + +​ 例如,如果將快取(應用管理的快取層,Memcached或同類產品)和全文搜尋(全文搜尋伺服器,例如Elasticsearch或Solr)功能從主資料庫剝離出來,那麼使快取/索引與主資料庫保持同步通常是應用程式碼的責任。[圖1-1](img/fig1-1.png) 給出了這種架構可能的樣子(細節將在後面的章節中詳細介紹)。 + +![](img/fig1-1.png) + +**圖1-1 一個可能的組合使用多個元件的資料系統架構** + +​ 當你將多個工具組合在一起提供服務時,服務的介面或**應用程式程式設計介面(API, Application Programming Interface)**通常向客戶端隱藏這些實現細節。現在,你基本上已經使用較小的通用元件建立了一個全新的、專用的資料系統。這個新的複合資料系統可能會提供特定的保證,例如:快取在寫入時會作廢或更新,以便外部客戶端獲取一致的結果。現在你不僅是應用程式開發人員,還是資料系統設計人員了。 + +​ 設計資料系統或服務時可能會遇到很多棘手的問題,例如:當系統出問題時,如何確保資料的正確性和完整性?當部分系統退化降級時,如何為客戶提供始終如一的良好效能?當負載增加時,如何擴容應對?什麼樣的API才是好的API? + +​ 影響資料系統設計的因素很多,包括參與人員的技能和經驗、歷史遺留問題、系統路徑依賴、交付時限、公司的風險容忍度、監管約束等,這些因素都需要具體問題具體分析。 + +​ 本書著重討論三個在大多數軟體系統中都很重要的問題: + +***可靠性(Reliability)*** + +​ 系統在**困境(adversity)**(硬體故障、軟體故障、人為錯誤)中仍可正常工作(正確完成功能,並能達到期望的效能水準)。 + +***可擴充套件性(Scalability)*** + +​ 有合理的辦法應對系統的增長(資料量、流量、複雜性)(參閱“[可擴充套件性](#可擴充套件性)”) + +***可維護性(Maintainability)*** + +​ 許多不同的人(工程師、運維)在不同的生命週期,都能高效地在系統上工作(使系統保持現有行為,並適應新的應用場景)。(參閱”[可維護性](#可維護性)“) + + + +​ 人們經常追求這些詞彙,卻沒有清楚理解它們到底意味著什麼。為了工程的嚴謹性,本章的剩餘部分將探討可靠性、可擴充套件性、可維護性的含義。為實現這些目標而使用的各種技術,架構和演算法將在後續的章節中研究。 + + + + +## 可靠性 + +人們對於一個東西是否可靠,都有一個直觀的想法。人們對可靠軟體的典型期望包括: + +* 應用程式表現出使用者所期望的功能。 +* 允許使用者犯錯,允許使用者以出乎意料的方式使用軟體。 +* 在預期的負載和資料量下,效能滿足要求。 +* 系統能防止未經授權的訪問和濫用。 + +如果所有這些在一起意味著“正確工作”,那麼可以把可靠性粗略理解為“即使出現問題,也能繼續正確工作”。 + +​ 造成錯誤的原因叫做**故障(fault)**,能預料並應對故障的系統特性可稱為**容錯(fault-tolerant)**或**韌性(resilient)**。“**容錯**”一詞可能會產生誤導,因為它暗示著系統可以容忍所有可能的錯誤,但在實際中這是不可能的。比方說,如果整個地球(及其上的所有伺服器)都被黑洞吞噬了,想要容忍這種錯誤,需要把網路託管到太空中——這種預算能不能批准就祝你好運了。所以在討論容錯時,只有談論特定型別的錯誤才有意義。 + +​ 注意**故障(fault)**不同於**失效(failure)**【2】。**故障**通常定義為系統的一部分狀態偏離其標準,而**失效**則是系統作為一個整體停止向用戶提供服務。故障的概率不可能降到零,因此最好設計容錯機制以防因**故障**而導致**失效**。本書中我們將介紹幾種用不可靠的部件構建可靠系統的技術。 + +​ 反直覺的是,在這類容錯系統中,透過故意觸發來**提高**故障率是有意義的,例如:在沒有警告的情況下隨機地殺死單個程序。許多高危漏洞實際上是由糟糕的錯誤處理導致的【3】,因此我們可以透過故意引發故障來確保容錯機制不斷執行並接受考驗,從而提高故障自然發生時系統能正確處理的信心。Netflix公司的*Chaos Monkey*【4】就是這種方法的一個例子。 + +​ 儘管比起**阻止錯誤(prevent error)**,我們通常更傾向於**容忍錯誤**。但也有**預防勝於治療**的情況(比如不存在治療方法時)。安全問題就屬於這種情況。例如,如果攻擊者破壞了系統,並獲取了敏感資料,這種事是撤銷不了的。但本書主要討論的是可以恢復的故障種類,正如下面幾節所述。 + +### 硬體故障 + +​ 當想到系統失效的原因時,**硬體故障(hardware faults)**總會第一個進入腦海。硬碟崩潰、記憶體出錯、機房斷電、有人拔錯網線……任何與大型資料中心打過交道的人都會告訴你:一旦你擁有很多機器,這些事情**總**會發生! + +​ 據報道稱,硬碟的 **平均無故障時間(MTTF mean time to failure)** 約為10到50年【5】【6】。因此從數學期望上講,在擁有10000個磁碟的儲存叢集上,平均每天會有1個磁碟出故障。 + +​ 為了減少系統的故障率,第一反應通常都是增加單個硬體的冗餘度,例如:磁碟可以組建RAID,伺服器可能有雙路電源和熱插拔CPU,資料中心可能有電池和柴油發電機作為後備電源,某個元件掛掉時冗餘元件可以立刻接管。這種方法雖然不能完全防止由硬體問題導致的系統失效,但它簡單易懂,通常也足以讓機器不間斷執行很多年。 + +​ 直到最近,硬體冗餘對於大多數應用來說已經足夠了,它使單臺機器完全失效變得相當罕見。只要你能快速地把備份恢復到新機器上,故障停機時間對大多數應用而言都算不上災難性的。只有少量高可用性至關重要的應用才會要求有多套硬體冗餘。 + +​ 但是隨著資料量和應用計算需求的增加,越來越多的應用開始大量使用機器,這會相應地增加硬體故障率。此外在一些雲平臺(**如亞馬遜網路服務(AWS, Amazon Web Services)**)中,虛擬機器例項不可用卻沒有任何警告也是很常見的【7】,因為雲平臺的設計就是優先考慮**靈活性(flexibility)**和**彈性(elasticity)**[^i],而不是單機可靠性。 + +​ 如果在硬體冗餘的基礎上進一步引入軟體容錯機制,那麼系統在容忍整個(單臺)機器故障的道路上就更進一步了。這樣的系統也有運維上的便利,例如:如果需要重啟機器(例如應用作業系統安全補丁),單伺服器系統就需要計劃停機。而允許機器失效的系統則可以一次修復一個節點,無需整個系統停機。 + +[^i]: 在[應對負載的方法](#應對負載的方法)一節定義 + +### 軟體錯誤 + +​ 我們通常認為硬體故障是隨機的、相互獨立的:一臺機器的磁碟失效並不意味著另一臺機器的磁碟也會失效。大量硬體元件不可能同時發生故障,除非它們存在比較弱的相關性(同樣的原因導致關聯性錯誤,例如伺服器機架的溫度)。 + +​ 另一類錯誤是內部的**系統性錯誤(systematic error)**【7】。這類錯誤難以預料,而且因為是跨節點相關的,所以比起不相關的硬體故障往往可能造成更多的**系統失效**【5】。例子包括: + +* 接受特定的錯誤輸入,便導致所有應用伺服器例項崩潰的BUG。例如2012年6月30日的閏秒,由於Linux核心中的一個錯誤,許多應用同時掛掉了。 +* 失控程序會佔用一些共享資源,包括CPU時間、記憶體、磁碟空間或網路頻寬。 +* 系統依賴的服務變慢,沒有響應,或者開始返回錯誤的響應。 +* 級聯故障,一個元件中的小故障觸發另一個元件中的故障,進而觸發更多的故障【10】。 + +導致這類軟體故障的BUG通常會潛伏很長時間,直到被異常情況觸發為止。這種情況意味著軟體對其環境做出了某種假設——雖然這種假設通常來說是正確的,但由於某種原因最後不再成立了【11】。 + +​ 雖然軟體中的系統性故障沒有速效藥,但我們還是有很多小辦法,例如:仔細考慮系統中的假設和互動;徹底的測試;程序隔離;允許程序崩潰並重啟;測量、監控並分析生產環境中的系統行為。如果系統能夠提供一些保證(例如在一個訊息佇列中,進入與發出的訊息數量相等),那麼系統就可以在執行時不斷自檢,並在出現**差異(discrepancy)**時報警【12】。 + +### 人為錯誤 + +​ 設計並構建了軟體系統的工程師是人類,維持系統執行的運維也是人類。即使他們懷有最大的善意,人類也是不可靠的。舉個例子,一項關於大型網際網路服務的研究發現,運維配置錯誤是導致服務中斷的首要原因,而硬體故障(伺服器或網路)僅導致了10-25%的服務中斷【13】。 + +​ 儘管人類不可靠,但怎麼做才能讓系統變得可靠?最好的系統會組合使用以下幾種辦法: + +* 以最小化犯錯機會的方式設計系統。例如,精心設計的抽象、API和管理後臺使做對事情更容易,搞砸事情更困難。但如果介面限制太多,人們就會忽略它們的好處而想辦法繞開。很難正確把握這種微妙的平衡。 +* 將人們最容易犯錯的地方與可能導致失效的地方**解耦(decouple)**。特別是提供一個功能齊全的非生產環境**沙箱(sandbox)**,使人們可以在不影響真實使用者的情況下,使用真實資料安全地探索和實驗。 +* 在各個層次進行徹底的測試【3】,從單元測試、全系統整合測試到手動測試。自動化測試易於理解,已經被廣泛使用,特別適合用來覆蓋正常情況中少見的**邊緣場景(corner case)**。 +* 允許從人為錯誤中簡單快速地恢復,以最大限度地減少失效情況帶來的影響。 例如,快速回滾配置變更,分批發布新程式碼(以便任何意外錯誤隻影響一小部分使用者),並提供資料重算工具(以備舊的計算出錯)。 +* 配置詳細和明確的監控,比如效能指標和錯誤率。 在其他工程學科中這指的是**遙測(telemetry)**。 (一旦火箭離開了地面,遙測技術對於跟蹤發生的事情和理解失敗是至關重要的。)監控可以向我們發出預警訊號,並允許我們檢查是否有任何地方違反了假設和約束。當出現問題時,指標資料對於問題診斷是非常寶貴的。 +* 良好的管理實踐與充分的培訓——一個複雜而重要的方面,但超出了本書的範圍。 + +### 可靠性有多重要? + +​ 可靠性不僅僅是針對核電站和空中交通管制軟體而言,我們也期望更多平凡的應用能可靠地執行。商務應用中的錯誤會導致生產力損失(也許資料報告不完整還會有法律風險),而電商網站的中斷則可能會導致收入和聲譽的巨大損失。 + +​ 即使在“非關鍵”應用中,我們也對使用者負有責任。試想一位家長把所有的照片和孩子的影片儲存在你的照片應用裡【15】。如果資料庫突然損壞,他們會感覺如何?他們可能會知道如何從備份恢復嗎? + +​ 在某些情況下,我們可能會選擇犧牲可靠性來降低開發成本(例如為未經證實的市場開發產品原型)或運營成本(例如利潤率極低的服務),但我們偷工減料時,應該清楚意識到自己在做什麼。 + + + +## 可擴充套件性 + +​ 系統今天能可靠執行,並不意味未來也能可靠執行。服務 **降級(degradation)** 的一個常見原因是負載增加,例如:系統負載已經從一萬個併發使用者增長到十萬個併發使用者,或者從一百萬增長到一千萬。也許現在處理的資料量級要比過去大得多。 + +​ **可擴充套件性(Scalability)** 是用來描述系統應對負載增長能力的術語。但是請注意,這不是貼在系統上的一維標籤:說“X可擴充套件”或“Y不可擴充套件”是沒有任何意義的。相反,討論可擴充套件性意味著考慮諸如“如果系統以特定方式增長,有什麼選項可以應對增長?”和“如何增加計算資源來處理額外的負載?”等問題。 + +### 描述負載 + +​ 在討論增長問題(如果負載加倍會發生什麼?)前,首先要能簡要描述系統的當前負載。負載可以用一些稱為 **負載引數(load parameters)** 的數字來描述。引數的最佳選擇取決於系統架構,它可能是每秒向Web伺服器發出的請求、資料庫中的讀寫比率、聊天室中同時活躍的使用者數量、快取命中率或其他東西。除此之外,也許平均情況對你很重要,也許你的瓶頸是少數極端場景。 + +​ 為了使這個概念更加具體,我們以推特在2012年11月釋出的資料【16】為例。推特的兩個主要業務是: + +***釋出推文*** + +​ 使用者可以向其粉絲髮布新訊息(平均 4.6k請求/秒,峰值超過 12k請求/秒)。 + +***主頁時間線*** + +​ 使用者可以查閱他們關注的人釋出的推文(300k請求/秒)。 + + + +​ 處理每秒12,000次寫入(發推文的速率峰值)還是很簡單的。然而推特的擴充套件性挑戰並不是主要來自推特量,而是來自**扇出(fan-out)**——每個使用者關注了很多人,也被很多人關注。 + +[^ii]: 扇出:從電子工程學中借用的術語,它描述了輸入連線到另一個門輸出的邏輯閘數量。 輸出需要提供足夠的電流來驅動所有連線的輸入。 在事務處理系統中,我們使用它來描述為了服務一個傳入請求而需要執行其他服務的請求數量。 + +大體上講,這一對操作有兩種實現方式。 + +1. 釋出推文時,只需將新推文插入全域性推文集合即可。當一個使用者請求自己的主頁時間線時,首先查詢他關注的所有人,查詢這些被關注使用者釋出的推文並按時間順序合併。在如[圖1-2](img/fig1-2.png)所示的關係型資料庫中,可以編寫這樣的查詢: + + ```sql + SELECT tweets.*, users.* + FROM tweets + JOIN users ON tweets.sender_id = users.id + JOIN follows ON follows.followee_id = users.id + WHERE follows.follower_id = current_user + ``` + ![](img/fig1-2.png) + + **圖1-2 推特主頁時間線的關係型模式簡單實現** + +2. 為每個使用者的主頁時間線維護一個快取,就像每個使用者的推文收件箱([圖1-3](img/fig1-3.png))。 當一個使用者釋出推文時,查詢所有關注該使用者的人,並將新的推文插入到每個主頁時間線快取中。 因此讀取主頁時間線的請求開銷很小,因為結果已經提前計算好了。 + + ![](img/fig1-3.png) + + **圖1-3 用於分發推特至關注者的資料流水線,2012年11月的負載引數【16】** + +推特的第一個版本使用了方法1,但系統很難跟上主頁時間線查詢的負載。所以公司轉向了方法2,方法2的效果更好,因為發推頻率比查詢主頁時間線的頻率幾乎低了兩個數量級,所以在這種情況下,最好在寫入時做更多的工作,而在讀取時做更少的工作。 + +​ 然而方法2的缺點是,發推現在需要大量的額外工作。平均來說,一條推文會發往約75個關注者,所以每秒4.6k的發推寫入,變成了對主頁時間線快取每秒345k的寫入。但這個平均值隱藏了使用者粉絲數差異巨大這一現實,一些使用者有超過3000萬的粉絲,這意味著一條推文就可能會導致主頁時間線快取的3000萬次寫入!及時完成這種操作是一個巨大的挑戰 —— 推特嘗試在5秒內向粉絲髮送推文。 + +​ 在推特的例子中,每個使用者粉絲數的分佈(可能按這些使用者的發推頻率來加權)是探討可擴充套件性的一個關鍵負載引數,因為它決定了扇出負載。你的應用程式可能具有非常不同的特徵,但可以採用相似的原則來考慮它的負載。 + +​ 推特軼事的最終轉折:現在已經穩健地實現了方法2,推特逐步轉向了兩種方法的混合。大多數使用者發的推文會被扇出寫入其粉絲主頁時間線快取中。但是少數擁有海量粉絲的使用者(即名流)會被排除在外。當用戶讀取主頁時間線時,分別地獲取出該使用者所關注的每位名流的推文,再與使用者的主頁時間線快取合併,如方法1所示。這種混合方法能始終如一地提供良好效能。在[第12章](ch12.md)中我們將重新討論這個例子,這在覆蓋更多技術層面之後。 + +### 描述效能 + +一旦系統的負載被描述好,就可以研究當負載增加會發生什麼。我們可以從兩種角度來看: + +* 增加負載引數並保持系統資源(CPU、記憶體、網路頻寬等)不變時,系統性能將受到什麼影響? +* 增加負載引數並希望保持效能不變時,需要增加多少系統資源? + +這兩個問題都需要效能資料,所以讓我們簡單地看一下如何描述系統性能。 + +​ 對於Hadoop這樣的批處理系統,通常關心的是**吞吐量(throughput)**,即每秒可以處理的記錄數量,或者在特定規模資料集上執行作業的總時間[^iii]。對於線上系統,通常更重要的是服務的**響應時間(response time)**,即客戶端傳送請求到接收響應之間的時間。 + +[^iii]: 理想情況下,批次作業的執行時間是資料集的大小除以吞吐量。 在實踐中由於資料傾斜(資料不是均勻分佈在每個工作程序中),需要等待最慢的任務完成,所以執行時間往往更長。 + +> #### 延遲和響應時間 +> +> **延遲(latency)** 和 **響應時間(response time)** 經常用作同義詞,但實際上它們並不一樣。響應時間是客戶所看到的,除了實際處理請求的時間( **服務時間(service time)** )之外,還包括網路延遲和排隊延遲。延遲是某個請求等待處理的**持續時長**,在此期間它處於 **休眠(latent)** 狀態,並等待服務【17】。 + +​ 即使不斷重複傳送同樣的請求,每次得到的響應時間也都會略有不同。現實世界的系統會處理各式各樣的請求,響應時間可能會有很大差異。因此我們需要將響應時間視為一個可以測量的數值**分佈(distribution)**,而不是單個數值。 + +​ 在[圖1-4](img/fig1-4.png)中,每個灰條表代表一次對服務的請求,其高度表示請求花費了多長時間。大多數請求是相當快的,但偶爾會出現需要更長的時間的異常值。這也許是因為緩慢的請求實質上開銷更大,例如它們可能會處理更多的資料。但即使(你認為)所有請求都花費相同時間的情況下,隨機的附加延遲也會導致結果變化,例如:上下文切換到後臺程序,網路資料包丟失與TCP重傳,垃圾收集暫停,強制從磁碟讀取的頁面錯誤,伺服器機架中的震動【18】,還有很多其他原因。 + +![](img/fig1-4.png) + +**圖1-4 展示了一個服務100次請求響應時間的均值與百分位數** + +​ 通常報表都會展示服務的平均響應時間。 (嚴格來講“平均”一詞並不指代任何特定公式,但實際上它通常被理解為**算術平均值(arithmetic mean)**:給定 n 個值,加起來除以 n )。然而如果你想知道“**典型(typical)**”響應時間,那麼平均值並不是一個非常好的指標,因為它不能告訴你有多少使用者實際上經歷了這個延遲。 + +​ 通常使用**百分位點(percentiles)**會更好。如果將響應時間列表按最快到最慢排序,那麼**中位數(median)**就在正中間:舉個例子,如果你的響應時間中位數是200毫秒,這意味著一半請求的返回時間少於200毫秒,另一半比這個要長。 + +​ 如果想知道典型場景下使用者需要等待多長時間,那麼中位數是一個好的度量標準:一半使用者請求的響應時間少於響應時間的中位數,另一半服務時間比中位數長。中位數也被稱為第50百分位點,有時縮寫為p50。注意中位數是關於單個請求的;如果使用者同時發出幾個請求(在一個會話過程中,或者由於一個頁面中包含了多個資源),則至少一個請求比中位數慢的概率遠大於50%。 + +​ 為了弄清異常值有多糟糕,可以看看更高的百分位點,例如第95、99和99.9百分位點(縮寫為p95,p99和p999)。它們意味著95%,99%或99.9%的請求響應時間要比該閾值快,例如:如果第95百分位點響應時間是1.5秒,則意味著100個請求中的95個響應時間快於1.5秒,而100個請求中的5個響應時間超過1.5秒。如[圖1-4](img/fig1-4.png)所示。 + +​ 響應時間的高百分位點(也稱為**尾部延遲(tail latencies)**)非常重要,因為它們直接影響使用者的服務體驗。例如亞馬遜在描述內部服務的響應時間要求時以99.9百分位點為準,即使它隻影響一千個請求中的一個。這是因為請求響應最慢的客戶往往也是資料最多的客戶,也可以說是最有價值的客戶 —— 因為他們掏錢了【19】。保證網站響應迅速對於保持客戶的滿意度非常重要,亞馬遜觀察到:響應時間增加100毫秒,銷售量就減少1%【20】;而另一些報告說:慢 1 秒鐘會讓客戶滿意度指標減少16%【21,22】。 + +​ 另一方面,最佳化第99.99百分位點(一萬個請求中最慢的一個)被認為太昂貴了,不能為亞馬遜的目標帶來足夠好處。減小高百分位點處的響應時間相當困難,因為它很容易受到隨機事件的影響,這超出了控制範圍,而且效益也很小。 + +​ 百分位點通常用於**服務級別目標(SLO, service level objectives)**和**服務級別協議(SLA, service level agreements)**,即定義服務預期效能和可用性的合同。 SLA可能會宣告,如果服務響應時間的中位數小於200毫秒,且99.9百分位點低於1秒,則認為服務工作正常(如果響應時間更長,就認為服務不達標)。這些指標為客戶設定了期望值,並允許客戶在SLA未達標的情況下要求退款。 + +​ **排隊延遲(queueing delay)** 通常佔了高百分位點處響應時間的很大一部分。由於伺服器只能並行處理少量的事務(如受其CPU核數的限制),所以只要有少量緩慢的請求就能阻礙後續請求的處理,這種效應有時被稱為 **頭部阻塞(head-of-line blocking)** 。即使後續請求在伺服器上處理的非常迅速,由於需要等待先前請求完成,客戶端最終看到的是緩慢的總體響應時間。因為存在這種效應,測量客戶端的響應時間非常重要。 + +​ 為測試系統的可擴充套件性而人為產生負載時,產生負載的客戶端要獨立於響應時間不斷髮送請求。如果客戶端在傳送下一個請求之前等待先前的請求完成,這種行為會產生人為排隊的效果,使得測試時的佇列比現實情況更短,使測量結果產生偏差【23】。 + +> #### 實踐中的百分位點 +> +> ​ 在多重呼叫的後端服務裡,高百分位數變得特別重要。即使並行呼叫,終端使用者請求仍然需要等待最慢的並行呼叫完成。如[圖1-5](img/fig1-5.png)所示,只需要一個緩慢的呼叫就可以使整個終端使用者請求變慢。即使只有一小部分後端呼叫速度較慢,如果終端使用者請求需要多個後端呼叫,則獲得較慢呼叫的機會也會增加,因此較高比例的終端使用者請求速度會變慢(效果稱為尾部延遲放大【24】)。 +> +> ​ 如果您想將響應時間百分點新增到您的服務的監視儀表板,則需要持續有效地計算它們。例如,您可能希望在最近10分鐘內保持請求響應時間的滾動視窗。每一分鐘,您都會計算出該視窗中的中值和各種百分數,並將這些度量值繪製在圖上。 +> +> ​ 簡單的實現是在時間視窗內儲存所有請求的響應時間列表,並且每分鐘對列表進行排序。如果對你來說效率太低,那麼有一些演算法能夠以最小的CPU和記憶體成本(如前向衰減【25】,t-digest【26】或HdrHistogram 【27】)來計算百分位數的近似值。請注意,平均百分比(例如,減少時間解析度或合併來自多臺機器的資料)在數學上沒有意義 - 聚合響應時間資料的正確方法是新增直方圖【28】。 + +![](img/fig1-5.png) + +**圖1-5 當一個請求需要多個後端請求時,單個後端慢請求就會拖慢整個終端使用者的請求** + +### 應對負載的方法 + +​ 現在我們已經討論了用於描述負載的引數和用於衡量效能的指標。可以開始認真討論可擴充套件性了:當負載引數增加時,如何保持良好的效能? + +​ 適應某個級別負載的架構不太可能應付10倍於此的負載。如果你正在開發一個快速增長的服務,那麼每次負載發生數量級的增長時,你可能都需要重新考慮架構——或者更頻繁。 + +​ 人們經常討論**縱向擴充套件(scaling up)**(**垂直擴充套件(vertical scaling)**,轉向更強大的機器)和**橫向擴充套件(scaling out)** (**水平擴充套件(horizontal scaling)**,將負載分佈到多臺小機器上)之間的對立。跨多臺機器分配負載也稱為“**無共享(shared-nothing)**”架構。可以在單臺機器上執行的系統通常更簡單,但高階機器可能非常貴,所以非常密集的負載通常無法避免地需要橫向擴充套件。現實世界中的優秀架構需要將這兩種方法務實地結合,因為使用幾臺足夠強大的機器可能比使用大量的小型虛擬機器更簡單也更便宜。 + +​ 有些系統是 **彈性(elastic)** 的,這意味著可以在檢測到負載增加時自動增加計算資源,而其他系統則是手動擴充套件(人工分析容量並決定向系統新增更多的機器)。如果負載**極難預測(highly unpredictable)**,則彈性系統可能很有用,但手動擴充套件系統更簡單,並且意外操作可能會更少(參閱“[重新平衡分割槽](ch6.md#分割槽再平衡)”)。 + +​ 跨多臺機器部署**無狀態服務(stateless services)**非常簡單,但將帶狀態的資料系統從單節點變為分散式配置則可能引入許多額外複雜度。出於這個原因,常識告訴我們應該將資料庫放在單個節點上(縱向擴充套件),直到擴充套件成本或可用性需求迫使其改為分散式。 + +​ 隨著分散式系統的工具和抽象越來越好,至少對於某些型別的應用而言,這種常識可能會改變。可以預見分散式資料系統將成為未來的預設設定,即使對不處理大量資料或流量的場景也如此。本書的其餘部分將介紹多種分散式資料系統,不僅討論它們在可擴充套件性方面的表現,還包括易用性和可維護性。 + +​ 大規模的系統架構通常是應用特定的—— 沒有一招鮮吃遍天的通用可擴充套件架構(不正式的叫法:**萬金油(magic scaling sauce)** )。應用的問題可能是讀取量、寫入量、要儲存的資料量、資料的複雜度、響應時間要求、訪問模式或者所有問題的大雜燴。 + +​ 舉個例子,用於處理每秒十萬個請求(每個大小為1 kB)的系統與用於處理每分鐘3個請求(每個大小為2GB)的系統看上去會非常不一樣,儘管兩個系統有同樣的資料吞吐量。 + +​ 一個良好適配應用的可擴充套件架構,是圍繞著**假設(assumption)**建立的:哪些操作是常見的?哪些操作是罕見的?這就是所謂負載引數。如果假設最終是錯誤的,那麼為擴充套件所做的工程投入就白費了,最糟糕的是適得其反。在早期創業公司或非正式產品中,通常支援產品快速迭代的能力,要比可擴充套件至未來的假想負載要重要的多。 + +​ 儘管這些架構是應用程式特定的,但可擴充套件的架構通常也是從通用的積木塊搭建而成的,並以常見的模式排列。在本書中,我們將討論這些構件和模式。 + + + +## 可維護性 + +​ 眾所周知,軟體的大部分開銷並不在最初的開發階段,而是在持續的維護階段,包括修復漏洞、保持系統正常執行、調查失效、適配新的平臺、為新的場景進行修改、償還技術債、新增新的功能等等。 + +​ 不幸的是,許多從事軟體系統行業的人不喜歡維護所謂的**遺留(legacy)**系統,——也許因為涉及修復其他人的錯誤、和過時的平臺打交道,或者系統被迫使用於一些份外工作。每一個遺留系統都以自己的方式讓人不爽,所以很難給出一個通用的建議來和它們打交道。 + +​ 但是我們可以,也應該以這樣一種方式來設計軟體:在設計之初就儘量考慮儘可能減少維護期間的痛苦,從而避免自己的軟體系統變成遺留系統。為此,我們將特別關注軟體系統的三個設計原則: + +***可操作性(Operability)*** + +​ 便於運維團隊保持系統平穩執行。 + +***簡單性(Simplicity)*** + +​ 從系統中消除儘可能多的**複雜度(complexity)**,使新工程師也能輕鬆理解系統。(注意這和使用者介面的簡單性不一樣。) + +***可演化性(evolability)*** + +​ 使工程師在未來能輕鬆地對系統進行更改,當需求變化時為新應用場景做適配。也稱為**可擴充套件性(extensibility)**,**可修改性(modifiability)**或**可塑性(plasticity)**。 + +​ 和之前提到的可靠性、可擴充套件性一樣,實現這些目標也沒有簡單的解決方案。不過我們會試著想象具有可操作性,簡單性和可演化性的系統會是什麼樣子。 + +### 可操作性:人生苦短,關愛運維 + +​ 有人認為,“良好的運維經常可以繞開垃圾(或不完整)軟體的侷限性,而再好的軟體攤上垃圾運維也沒法可靠執行”。儘管運維的某些方面可以,而且應該是自動化的,但在最初建立正確運作的自動化機制仍然取決於人。 + +運維團隊對於保持軟體系統順利執行至關重要。一個優秀運維團隊的典型職責如下(或者更多)【29】: + +* 監控系統的執行狀況,並在服務狀態不佳時快速恢復服務 +* 跟蹤問題的原因,例如系統故障或效能下降 +* 及時更新軟體和平臺,比如安全補丁 +* 瞭解系統間的相互作用,以便在異常變更造成損失前進行規避。 +* 預測未來的問題,並在問題出現之前加以解決(例如,容量規劃) +* 建立部署,配置、管理方面的良好實踐,編寫相應工具 +* 執行復雜的維護任務,例如將應用程式從一個平臺遷移到另一個平臺 +* 當配置變更時,維持系統的安全性 +* 定義工作流程,使運維操作可預測,並保持生產環境穩定。 +* 鐵打的營盤流水的兵,維持組織對系統的瞭解。 + +良好的可操作性意味著更輕鬆的日常工作,進而運維團隊能專注於高價值的事情。資料系統可以透過各種方式使日常任務更輕鬆: + +* 透過良好的監控,提供對系統內部狀態和執行時行為的**可見性(visibility)** +* 為自動化提供良好支援,將系統與標準化工具相整合 +* 避免依賴單臺機器(在整個系統繼續不間斷執行的情況下允許機器停機維護) +* 提供良好的文件和易於理解的操作模型(“如果做X,會發生Y”) +* 提供良好的預設行為,但需要時也允許管理員自由覆蓋預設值 +* 有條件時進行自我修復,但需要時也允許管理員手動控制系統狀態 +* 行為可預測,最大限度減少意外 + + + +### 簡單性:管理複雜度 + +​ 小型軟體專案可以使用簡單討喜的、富表現力的程式碼,但隨著專案越來越大,程式碼往往變得非常複雜,難以理解。這種複雜度拖慢了所有系統相關人員,進一步增加了維護成本。一個陷入複雜泥潭的軟體專案有時被描述為 **爛泥潭(a big ball of mud)** 【30】。 + +​ **複雜度(complexity)** 有各種可能的症狀,例如:狀態空間激增、模組間緊密耦合、糾結的依賴關係、不一致的命名和術語、解決效能問題的Hack、需要繞開的特例等等,現在已經有很多關於這個話題的討論【31,32,33】。 + +​ 因為複雜度導致維護困難時,預算和時間安排通常會超支。在複雜的軟體中進行變更,引入錯誤的風險也更大:當開發人員難以理解系統時,隱藏的假設、無意的後果和意外的互動就更容易被忽略。相反,降低複雜度能極大地提高軟體的可維護性,因此簡單性應該是構建系統的一個關鍵目標。 + +​ 簡化系統並不一定意味著減少功能;它也可以意味著消除**額外的(accidental)**的複雜度。 Moseley和Marks【32】把 **額外複雜度** 定義為:由具體實現中湧現,而非(從使用者視角看,系統所解決的)問題本身固有的複雜度。 + +​ 用於消除**額外複雜度**的最好工具之一是**抽象(abstraction)**。一個好的抽象可以將大量實現細節隱藏在一個乾淨,簡單易懂的外觀下面。一個好的抽象也可以廣泛用於各類不同應用。比起重複造很多輪子,重用抽象不僅更有效率,而且有助於開發高質量的軟體。抽象元件的質量改進將使所有使用它的應用受益。 + +​ 例如,高階程式語言是一種抽象,隱藏了機器碼、CPU暫存器和系統呼叫。 SQL也是一種抽象,隱藏了複雜的磁碟/記憶體資料結構、來自其他客戶端的併發請求、崩潰後的不一致性。當然在用高階語言程式設計時,我們仍然用到了機器碼;只不過沒有**直接(directly)**使用罷了,正是因為程式語言的抽象,我們才不必去考慮這些實現細節。 + +​ 抽象可以幫助我們將系統的複雜度控制在可管理的水平,不過,找到好的抽象是非常困難的。在分散式系統領域雖然有許多好的演算法,但我們並不清楚它們應該打包成什麼樣抽象。 + +​ 本書將緊盯那些允許我們將大型系統的部分提取為定義明確的、可重用的元件的優秀抽象。 + +### 可演化性:擁抱變化 + +​ 系統的需求永遠不變,基本是不可能的。更可能的情況是,它們處於常態的變化中,例如:你瞭解了新的事實、出現意想不到的應用場景、業務優先順序發生變化、使用者要求新功能、新平臺取代舊平臺、法律或監管要求發生變化、系統增長迫使架構變化等。 + +​ 在組織流程方面, **敏捷(agile)** 工作模式為適應變化提供了一個框架。敏捷社群還開發了對在頻繁變化的環境中開發軟體很有幫助的技術工具和模式,如 **測試驅動開發(TDD, test-driven development)** 和 **重構(refactoring)** 。 + +​ 這些敏捷技術的大部分討論都集中在相當小的規模(同一個應用中的幾個程式碼檔案)。本書將探索在更大資料系統層面上提高敏捷性的方法,可能由幾個不同的應用或服務組成。例如,為了將裝配主頁時間線的方法從方法1變為方法2,你會如何“重構”推特的架構 ? + +​ 修改資料系統並使其適應不斷變化需求的容易程度,是與**簡單性**和**抽象性**密切相關的:簡單易懂的系統通常比複雜系統更容易修改。但由於這是一個非常重要的概念,我們將用一個不同的詞來指代資料系統層面的敏捷性: **可演化性(evolvability)** 【34】。 + + + +## 本章小結 + +​ 本章探討了一些關於資料密集型應用的基本思考方式。這些原則將指導我們閱讀本書的其餘部分,那裡將會深入技術細節。 + +​ 一個應用必須滿足各種需求才稱得上有用。有一些**功能需求(functional requirements)**(它應該做什麼,比如允許以各種方式儲存,檢索,搜尋和處理資料)以及一些**非功能性需求(nonfunctional )**(通用屬性,例如安全性,可靠性,合規性,可擴充套件性,相容性和可維護性)。在本章詳細討論了可靠性,可擴充套件性和可維護性。 + + +​ **可靠性(Reliability)** 意味著即使發生故障,系統也能正常工作。故障可能發生在硬體(通常是隨機的和不相關的),軟體(通常是系統性的Bug,很難處理),和人類(不可避免地時不時出錯)。 **容錯技術** 可以對終端使用者隱藏某些型別的故障。 + +​ **可擴充套件性(Scalability)** 意味著即使在負載增加的情況下也有保持效能的策略。為了討論可擴充套件性,我們首先需要定量描述負載和效能的方法。我們簡要了解了推特主頁時間線的例子,介紹描述負載的方法,並將響應時間百分位點作為衡量效能的一種方式。在可擴充套件的系統中可以新增 **處理容量(processing capacity)** 以在高負載下保持可靠。 + +​ **可維護性(Maintainability)** 有許多方面,但實質上是關於工程師和運維團隊的生活質量的。良好的抽象可以幫助降低複雜度,並使系統易於修改和適應新的應用場景。良好的可操作性意味著對系統的健康狀態具有良好的可見性,並擁有有效的管理手段。 + +​ 不幸的是,使應用可靠、可擴充套件或可維護並不容易。但是某些模式和技術會不斷重新出現在不同的應用中。在接下來的幾章中,我們將看到一些資料系統的例子,並分析它們如何實現這些目標。 + +​ 在本書後面的[第三部分](part-iii.md)中,我們將看到一種模式:幾個元件協同工作以構成一個完整的系統(如[圖1-1](img/fig1-1.png)中的例子) + + + +## 參考文獻 + + +1. Michael Stonebraker and Uğur Çetintemel: “['One Size Fits All': An Idea Whose Time Has Come and Gone](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.68.9136&rep=rep1&type=pdf),” at *21st International Conference on Data Engineering* (ICDE), April 2005. + +1. Walter L. Heimerdinger and Charles B. Weinstock: “[A Conceptual Framework for System Fault Tolerance](http://www.sei.cmu.edu/reports/92tr033.pdf),” Technical Report CMU/SEI-92-TR-033, Software Engineering Institute, Carnegie Mellon University, October 1992. + +2. Ding Yuan, Yu Luo, Xin Zhuang, et al.: “[Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf),” at *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2014. + +3. Yury Izrailevsky and Ariel Tseitlin: “[The Netflix Simian Army](http://techblog.netflix.com/2011/07/netflix-simian-army.html),” *techblog.netflix.com*, July 19, 2011. + +4. Daniel Ford, François Labelle, Florentina I. Popovici, et al.: “[Availability in Globally Distributed Storage Systems](http://research.google.com/pubs/archive/36737.pdf),” at *9th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), + October 2010. + +5. Brian Beach: “[Hard Drive Reliability Update – Sep 2014](https://www.backblaze.com/blog/hard-drive-reliability-update-september-2014/),” *backblaze.com*, September 23, 2014. + +6. Laurie Voss: “[AWS: The Good, the Bad and the Ugly](https://web.archive.org/web/20160429075023/http://blog.awe.sm/2012/12/18/aws-the-good-the-bad-and-the-ugly/),” *blog.awe.sm*, December 18, 2012. + +7. Haryadi S. Gunawi, Mingzhe Hao, Tanakorn Leesatapornwongsa, et al.: “[What Bugs Live in the Cloud?](http://ucare.cs.uchicago.edu/pdf/socc14-cbs.pdf),” at *5th ACM Symposium on Cloud Computing* (SoCC), November 2014. [doi:10.1145/2670979.2670986](http://dx.doi.org/10.1145/2670979.2670986) + +8. Nelson Minar: “[Leap Second Crashes Half the Internet](http://www.somebits.com/weblog/tech/bad/leap-second-2012.html),” *somebits.com*, July 3, 2012. + +9. Amazon Web Services: “[Summary of the Amazon EC2 and Amazon RDS Service Disruption in the US East Region](http://aws.amazon.com/message/65648/),” *aws.amazon.com*, April 29, 2011. + +10. Richard I. Cook: “[How Complex Systems Fail](http://web.mit.edu/2.75/resources/random/How%20Complex%20Systems%20Fail.pdf),” Cognitive Technologies Laboratory, April 2000. + +11. Jay Kreps: “[Getting Real About Distributed System Reliability](http://blog.empathybox.com/post/19574936361/getting-real-about-distributed-system-reliability),” *blog.empathybox.com*, March 19, 2012. + +12. David Oppenheimer, Archana Ganapathi, and David A. Patterson: “[Why Do Internet Services Fail, and What Can Be Done About It?](http://static.usenix.org/legacy/events/usits03/tech/full_papers/oppenheimer/oppenheimer.pdf),” at *4th USENIX Symposium on Internet Technologies and Systems* (USITS), March 2003. + +13. Nathan Marz: “[Principles of Software Engineering, Part 1](http://nathanmarz.com/blog/principles-of-software-engineering-part-1.html),” *nathanmarz.com*, April 2, 2013. + +14. Michael Jurewitz:“[The Human Impact of Bugs](http://jury.me/blog/2013/3/14/the-human-impact-of-bugs),” *jury.me*, March 15, 2013. + +15. Raffi Krikorian: “[Timelines at Scale](http://www.infoq.com/presentations/Twitter-Timeline-Scalability),” at *QCon San Francisco*, November 2012. + +16. Martin Fowler: *Patterns of Enterprise Application Architecture*. Addison Wesley, 2002. ISBN: 978-0-321-12742-6 + +17. Kelly Sommers: “[After all that run around, what caused 500ms disk latency even when we replaced physical server?](https://twitter.com/kellabyte/status/532930540777635840)” *twitter.com*, November 13, 2014. + +18. Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, et al.: “[Dynamo: Amazon's Highly Available Key-Value Store](http://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf),” at *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007. + +19. Greg Linden: “[Make Data Useful](http://glinden.blogspot.co.uk/2006/12/slides-from-my-talk-at-stanford.html),” slides from presentation at Stanford University Data Mining class (CS345), December 2006. + +20. Tammy Everts: “[The Real Cost of Slow Time vs Downtime](http://www.webperformancetoday.com/2014/11/12/real-cost-slow-time-vs-downtime-slides/),” *webperformancetoday.com*, November 12, 2014. + +21. Jake Brutlag:“[Speed Matters for Google Web Search](http://googleresearch.blogspot.co.uk/2009/06/speed-matters.html),” *googleresearch.blogspot.co.uk*, June 22, 2009. + +22. Tyler Treat: “[Everything You Know About Latency Is Wrong](http://bravenewgeek.com/everything-you-know-about-latency-is-wrong/),” *bravenewgeek.com*, December 12, 2015. + +23. Jeffrey Dean and Luiz André Barroso: “[The Tail at Scale](http://cacm.acm.org/magazines/2013/2/160173-the-tail-at-scale/fulltext),” *Communications of the ACM*, volume 56, number 2, pages 74–80, February 2013. [doi:10.1145/2408776.2408794](http://dx.doi.org/10.1145/2408776.2408794) + +24. Graham Cormode, Vladislav Shkapenyuk, Divesh Srivastava, and Bojian Xu: “[Forward Decay: A Practical Time Decay Model for Streaming Systems](http://dimacs.rutgers.edu/~graham/pubs/papers/fwddecay.pdf),” at *25th IEEE International Conference on Data Engineering* (ICDE), March 2009. + +25. Ted Dunning and Otmar Ertl: “[Computing Extremely Accurate Quantiles Using t-Digests](https://github.com/tdunning/t-digest),” *github.com*, March 2014. + +26. Gil Tene: “[HdrHistogram](http://www.hdrhistogram.org/),” *hdrhistogram.org*. + +27. Baron Schwartz: “[Why Percentiles Don’t Work the Way You Think](https://www.vividcortex.com/blog/why-percentiles-dont-work-the-way-you-think),” *vividcortex.com*, December 7, 2015. + +28. James Hamilton: “[On Designing and Deploying Internet-Scale Services](https://www.usenix.org/legacy/events/lisa07/tech/full_papers/hamilton/hamilton.pdf),” at *21st Large Installation + System Administration Conference* (LISA), November 2007. + +29. Brian Foote and Joseph Yoder: “[Big Ball of Mud](http://www.laputan.org/pub/foote/mud.pdf),” at *4th Conference on Pattern Languages of Programs* (PLoP), September 1997. + +30. Frederick P Brooks: “No Silver Bullet – Essence and Accident in Software Engineering,” in *The Mythical Man-Month*, Anniversary edition, Addison-Wesley, 1995. ISBN: 978-0-201-83595-3 + +31. Ben Moseley and Peter Marks: “[Out of the Tar Pit](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.93.8928),” at *BCS Software Practice Advancement* (SPA), 2006. + +32. Rich Hickey: “[Simple Made Easy](http://www.infoq.com/presentations/Simple-Made-Easy),” at *Strange Loop*, September 2011. + +33. Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson: “[Analyzing Software Evolvability](http://www.mrtc.mdh.se/publications/1478.pdf),” at *32nd Annual IEEE International Computer Software and Applications Conference* (COMPSAC), July 2008. [doi:10.1109/COMPSAC.2008.50](http://dx.doi.org/10.1109/COMPSAC.2008.50) + + + +------ + +| 上一章 | 目錄 | 下一章 | +| ----------------------------------- | ------------------------------- | ------------------------------------ | +| [第一部分:資料系統基礎](part-i.md) | [設計資料密集型應用](README.md) | [第二章:資料模型與查詢語言](ch2.md) | From df568afb6239ac200cd391e49f425def39af25f8 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 01:21:28 +0800 Subject: [PATCH 04/12] translate --- zh-tw/README.md | 101 ++++ zh-tw/SUMMARY.md | 23 + zh-tw/ch10.md | 912 ++++++++++++++++++++++++++++++++++ zh-tw/ch11.md | 934 +++++++++++++++++++++++++++++++++++ zh-tw/ch12.md | 1152 +++++++++++++++++++++++++++++++++++++++++++ zh-tw/ch2.md | 1045 +++++++++++++++++++++++++++++++++++++++ zh-tw/ch3.md | 765 ++++++++++++++++++++++++++++ zh-tw/ch4.md | 640 ++++++++++++++++++++++++ zh-tw/ch5.md | 925 ++++++++++++++++++++++++++++++++++ zh-tw/ch6.md | 410 +++++++++++++++ zh-tw/ch7.md | 958 +++++++++++++++++++++++++++++++++++ zh-tw/ch8.md | 781 +++++++++++++++++++++++++++++ zh-tw/ch9.md | 1205 +++++++++++++++++++++++++++++++++++++++++++++ zh-tw/colophon.md | 35 ++ zh-tw/glossary.md | 377 ++++++++++++++ zh-tw/part-i.md | 29 ++ zh-tw/part-ii.md | 100 ++++ zh-tw/part-iii.md | 44 ++ zh-tw/preface.md | 102 ++++ 19 files changed, 10538 insertions(+) create mode 100644 zh-tw/README.md create mode 100644 zh-tw/SUMMARY.md create mode 100644 zh-tw/ch10.md create mode 100644 zh-tw/ch11.md create mode 100644 zh-tw/ch12.md create mode 100644 zh-tw/ch2.md create mode 100644 zh-tw/ch3.md create mode 100644 zh-tw/ch4.md create mode 100644 zh-tw/ch5.md create mode 100644 zh-tw/ch6.md create mode 100644 zh-tw/ch7.md create mode 100644 zh-tw/ch8.md create mode 100644 zh-tw/ch9.md create mode 100644 zh-tw/colophon.md create mode 100644 zh-tw/glossary.md create mode 100644 zh-tw/part-i.md create mode 100644 zh-tw/part-ii.md create mode 100644 zh-tw/part-iii.md create mode 100644 zh-tw/preface.md diff --git a/zh-tw/README.md b/zh-tw/README.md new file mode 100644 index 00000000..f73d7688 --- /dev/null +++ b/zh-tw/README.md @@ -0,0 +1,101 @@ +# 設計資料密集型應用 - 中文翻譯 + +- 作者: [Martin Kleppmann](https://martin.kleppmann.com) +- 原書名稱:[《Designing Data-Intensive Application》](http://shop.oreilly.com/product/0636920032175.do) +- 譯者:[馮若航]( http://vonng.com/about) (fengruohang@outlook.com ) +- Gitbook地址:[ddia-cn](https://www.gitbook.com/book/vonng/ddia-cn) +- 使用[Typora](https://www.typora.io)或Gitbook以獲取最佳閱讀體驗。 + + + + +## 譯序 + +> 不懂資料庫的全棧工程師不是好架構師 +> +> —— Vonng + +​ 現今,尤其是在網際網路領域,大多數應用都屬於資料密集型應用。本書從底層資料結構到頂層架構設計,將資料系統設計中的精髓娓娓道來。其中的寶貴經驗無論是對架構師,DBA、還是後端工程師、甚至產品經理都會有幫助。 + +​ 這是一本理論結合實踐的書,書中很多問題,譯者在實際場景中都曾遇到過,讀來讓人擊節扼腕。如果能早點讀到這本書,該少走多少彎路啊! + +​ 這也是一本深入淺出的書,講述概念的來龍去脈而不是賣弄定義,介紹事物發展演化歷程而不是事實堆砌,將複雜的概念講述的淺顯易懂,但又直擊本質不失深度。每章最後的引用質量非常好,是深入學習各個主題的絕佳索引。 + +​ 本書為資料系統的設計、實現、與評價提供了很好的概念框架。讀完並理解本書內容後,讀者可以輕鬆看破大多數的技術忽悠,與技術磚家撕起來虎虎生風🤣。 + +​ 這是2017年譯者讀過最好的一本技術類書籍,這麼好的書沒有中文翻譯,實在是遺憾。某不才,願為先進技術文化的傳播貢獻一分力量。既可以深入學習有趣的技術主題,又可以鍛鍊中英文語言文字功底,何樂而不為? + + + +## 前言 + +> 在我們的社會中,技術是一種強大的力量。資料、軟體、通訊可以用於壞的方面:不公平的階級固化,損害公民權利,保護既得利益集團。但也可以用於好的方面:讓底層人民發出自己的聲音,讓每個人都擁有機會,避免災難。本書獻給所有將技術用於善途的人們。 + +--------- + +> 計算是一種流行文化,流行文化鄙視歷史。 流行文化關乎個體身份和參與感,但與合作無關。流行文化活在當下,也與過去和未來無關。 我認為大部分(為了錢)編寫程式碼的人就是這樣的, 他們不知道自己的文化來自哪裡。 +> +> ——阿蘭·凱接受Dobb博士的雜誌採訪時(2012年) + + + +## 目錄 + +### [序言](preface.md) + +### [第一部分:資料系統的基石](part-i.md) + +* [第一章:可靠性、可擴充套件性、可維護性](ch1.md) +* [第二章:資料模型與查詢語言](ch2.md) +* [第三章:儲存與檢索](ch3.md) +* [第四章:編碼與演化](ch4.md) + +### [第二部分:分散式資料](part-ii.md) + +* [第五章:複製](ch5.md) +* [第六章:分割槽](ch6.md) +* [第七章:事務](ch7.md) +* [第八章:分散式系統的麻煩](ch8.md) +* [第九章:一致性與共識](ch9.md) + +### [第三部分:衍生資料](part-iii.md) + +* [第十章:批處理](ch10.md) +* [第十一章:流處理](ch11.md) +* [第十二章:資料系統的未來](ch12.md) + +### [術語表](glossary.md) + +### [後記](colophon.md) + + + +## 法律宣告 + +從原作者處得知,已經有簡體中文的翻譯計劃,將於2018年末完成。 + +譯者純粹出於**學習目的**與**個人興趣**翻譯本書,不追求任何經濟利益。 + +譯者保留對此版本譯文的署名權,其他權利以原作者和出版社的主張為準。 + +本譯文只供學習研究參考之用,不得公開傳播發行或用於商業用途。有能力閱讀英文書籍者請購買正版支援。 + + + +## CONTRIBUTION + +1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird) +2. [第一章語法標點校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree) +3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 與[第10章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by @[MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex) +4. 第一部分前言,ch2校正 by @jiajiadebug +5. 詞彙表、後記關於野豬的部分 by @[Chowss](https://github.com/Vonng/ddia/commits?author=Chowss) + +https://github.com/Vonng/ddia/pulls) + +感謝所有作出貢獻,提出意見的朋友們:[Issues](https://github.com/Vonng/ddia/issues),[Pull Requests](https://github.com/Vonng/ddia/pulls) + + + +## LICENSE + +CC-BY 4.0 \ No newline at end of file diff --git a/zh-tw/SUMMARY.md b/zh-tw/SUMMARY.md new file mode 100644 index 00000000..e8c9036f --- /dev/null +++ b/zh-tw/SUMMARY.md @@ -0,0 +1,23 @@ +# Summary + +* [簡介](README.md) +* [序言](preface.md) +* [第一部分:資料系統的基石](part-i.md) + * [第一章:可靠性、可擴充套件性、可維護性](ch1.md) + * [第二章:資料模型與查詢語言](ch2.md) + * [第三章:儲存與檢索](ch3.md) + * [第四章:編碼與演化](ch4.md) +* [第二部分:分散式資料](part-ii.md) + * [第五章:複製](ch5.md) + * [第六章:分割槽](ch6.md) + * [第七章:事務](ch7.md) + * [第八章:分散式系統的麻煩](ch8.md) + * [第九章:一致性與共識](ch9.md) +* [第三部分:衍生資料](part-iii.md) + * [第十章:批處理](ch10.md) + * [第十一章:流處理](ch11.md) + * [第十二章:資料系統的未來](ch12.md) +* [術語表](glossary.md) +* [後記](colophon.md) + + diff --git a/zh-tw/ch10.md b/zh-tw/ch10.md new file mode 100644 index 00000000..9760f582 --- /dev/null +++ b/zh-tw/ch10.md @@ -0,0 +1,912 @@ +# 10. 批處理 + +![](img/ch10.png) + +> 帶有太強個人色彩的系統無法成功。當最初的設計完成並且相對穩定時,不同的人們以自己的方式進行測試,真正的考驗才開始。 +> +> ——高德納 + +--------------- + +[TOC] + +​ 在本書的前兩部分中,我們討論了很多關於**請求**和**查詢**以及相應的**響應**或**結果**。許多現有資料系統中都採用這種資料處理方式:你傳送請求指令,一段時間後(我們期望)系統會給出一個結果。資料庫,快取,搜尋索引,Web伺服器以及其他一些系統都以這種方式工作。 + +​ 像這樣的**線上(online)**系統,無論是瀏覽器請求頁面還是呼叫遠端API的服務,我們通常認為請求是由人類使用者觸發的,並且正在等待響應。他們不應該等太久,所以我們非常關注系統的響應時間(參閱“[描述效能](ch1.md)”)。 + +​ Web和越來越多的基於HTTP/REST的API使互動的請求/響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統: + + ***服務(線上系統)*** + +​ 服務等待客戶的請求或指令到達。每收到一個,服務會試圖儘快處理它,併發回一個響應。響應時間通常是服務效能的主要衡量指標,可用性通常非常重要(如果客戶端無法訪問服務,使用者可能會收到錯誤訊息)。 + +***批處理系統(離線系統)*** + +​ 一個批處理系統有大量的輸入資料,跑一個**作業(job)**來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。 + +***流處理系統(準實時系統)*** + +​ 流處理介於線上和離線(批處理)之間,所以有時候被稱為**準實時(near-real-time)**或**準線上(nearline)**處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在[第11章](ch11.md)討論它。 + +​ 正如我們將在本章中看到的那樣,批處理是構建可靠,可擴充套件和可維護應用程式的重要組成部分。例如,2004年釋出的批處理演算法Map-Reduce(可能被過分熱情地)被稱為“造就Google大規模可擴充套件性的演算法”【2】。隨後在各種開源資料系統中得到應用,包括Hadoop,CouchDB和MongoDB。 + +​ 與多年前為資料倉庫開發的並行處理系統【3,4】相比,MapReduce是一個相當低級別的程式設計模型,但它使得在商用硬體上能進行的處理規模邁上一個新的臺階。雖然MapReduce的重要性正在下降【5】,但它仍然值得去理解,因為它描繪了一幅關於批處理為什麼有用,以及如何實用的清晰圖景。 + +​ 實際上,批處理是一種非常古老的計算方式。早在可程式設計數字計算機誕生之前,打孔卡製表機(例如1890年美國人口普查【6】中使用的霍爾里斯機)實現了半機械化的批處理形式,從大量輸入中彙總計算。 Map-Reduce與1940年代和1950年代廣泛用於商業資料處理的機電IBM卡片分類機器有著驚人的相似之處【7】。正如我們所說,歷史總是在不斷重複自己。 + +​ 在本章中,我們將瞭解MapReduce和其他一些批處理演算法和框架,並探索它們在現代資料系統中的作用。但首先我們將看看使用標準Unix工具的資料處理。即使你已經熟悉了它們,Unix的哲學也值得一讀,Unix的思想和經驗教訓可以遷移到大規模,異構的分散式資料系統中。 + + + +## 使用Unix工具的批處理 + +​ 我們從一個簡單的例子開始。假設您有一臺Web伺服器,每次處理請求時都會在日誌檔案中附加一行。例如,使用nginx預設訪問日誌格式,日誌的一行可能如下所示: + +```bash +216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1" +200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5) +AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36" +``` + +(實際上這只是一行,分成多行只是為了便於閱讀。)這一行中有很多資訊。為了解釋它,你需要了解日誌格式的定義,如下所示: + +``` + $remote_addr - $remote_user [$time_local] "$request" + $status $body_bytes_sent "$http_referer" "$http_user_agent" +``` + +​ 日誌的這一行表明在2015年2月27日17:55:11 UTC,伺服器從客戶端IP地址`216.58.210.78`接收到對檔案`/css/typography.css`的請求。使用者沒有被認證,所以`$remote_user`被設定為連字元(`-` )。響應狀態是200(即請求成功),響應的大小是3377位元組。網頁瀏覽器是Chrome 40,URL `http://martin.kleppmann.com/` 的頁面中的引用導致該檔案被載入。 + + + +### 分析簡單日誌 + +​ 很多工具可以從這些日誌檔案生成關於網站流量的漂亮的報告,但為了練手,讓我們使用基本的Unix功能建立自己的工具。 例如,假設你想在你的網站上找到五個最受歡迎的網頁。 則可以在Unix shell中這樣做:[^i] + +[^i]: 有些人認為`cat`這裡並沒有必要,因為輸入檔案可以直接作為awk的引數。 但這種寫法讓線性管道更為顯眼。 + +```bash +cat /var/log/nginx/access.log | #1 + awk '{print $7}' | #2 + sort | #3 + uniq -c | #4 + sort -r -n | #5 + head -n 5 #6 +``` + +1. 讀取日誌檔案 +2. 將每一行按空格分割成不同的欄位,每行只輸出第七個欄位,恰好是請求的URL。在我們的例子中是`/css/typography.css`。 +3. 按字母順序排列請求的URL列表。如果某個URL被請求過n次,那麼排序後,檔案將包含連續重複出現n次的該URL。 +4. `uniq`命令透過檢查兩個相鄰的行是否相同來過濾掉輸入中的重複行。 `-c`則表示還要輸出一個計數器:對於每個不同的URL,它會報告輸入中出現該URL的次數。 +5. 第二種排序按每行起始處的數字(`-n`)排序,這是URL的請求次數。然後逆序(`-r`)返回結果,大的數字在前。 +6. 最後,只輸出前五行(`-n 5`),並丟棄其餘的。該系列命令的輸出如下所示: + +``` + 4189 /favicon.ico + 3631 /2013/05/24/improving-security-of-ssh-private-keys.html + 2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html + 1369 / + 915 /css/typography.css +``` + +​ 如果你不熟悉Unix工具,上面的命令列可能看起來有點吃力,但是它非常強大。它能在幾秒鐘內處理幾GB的日誌檔案,並且您可以根據需要輕鬆修改命令。例如,如果要從報告中省略CSS檔案,可以將awk引數更改為`'$7 !~ /\.css$/ {print $7}'`,如果想統計最多的客戶端IP地址,可以把awk引數改為`'{print $1}'`等等。 + +​ 我們不會在這裡詳細探索Unix工具,但是它非常值得學習。令人驚訝的是,使用awk,sed,grep,sort,uniq和xargs的組合,可以在幾分鐘內完成許多資料分析,並且它們的效能相當的好【8】。 + +#### 命令鏈與自定義程式 + +除了Unix命令鏈,你還可以寫一個簡單的程式來做同樣的事情。例如在Ruby中,它可能看起來像這樣: + +```ruby +counts = Hash.new(0) # 1 +File.open('/var/log/nginx/access.log') do |file| + file.each do |line| + url = line.split[6] # 2 + counts[url] += 1 # 3 + end +end + +top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4 +top5.each{|count, url| puts "#{count} #{url}" } # 5 +``` + +1. `counts`是一個儲存計數器的雜湊表,儲存了每個URL被瀏覽的次數,預設為0。 +2. 逐行讀取日誌,抽取每行第七個被空格分隔的欄位為URL(這裡的陣列索引是6,因為Ruby的陣列索引從0開始計數) +3. 將日誌當前行中URL對應的計數器值加一。 +4. 按計數器值(降序)對雜湊表內容進行排序,並取前五位。 +5. 打印出前五個條目。 + +這個程式並不像Unix管道那樣簡潔,但是它的可讀性很強,喜歡哪一種屬於口味的問題。但兩者除了表面上的差異之外,執行流程也有很大差異,如果你在大檔案上執行此分析,則會變得明顯。 + +#### 排序 VS 記憶體中的聚合 + +​ Ruby指令碼在記憶體中儲存了一個URL的雜湊表,將每個URL對映到它出現的次數。 Unix管道沒有這樣的雜湊表,而是依賴於對URL列表的排序,在這個URL列表中,同一個URL的只是簡單地重複出現。 + +​ 哪種方法更好?這取決於你有多少個不同的URL。對於大多數中小型網站,你可能可以為所有不同網址提供一個計數器(假設我們使用1GB記憶體)。在此例中,作業的**工作集(working set)**(作業需要隨機訪問的記憶體大小)僅取決於不同URL的數量:如果日誌中只有單個URL,重複出現一百萬次,則散列表所需的空間表就只有一個URL加上一個計數器的大小。當工作集足夠小時,記憶體散列表表現良好,甚至在效能較差的膝上型電腦上也可以正常工作。 + +​ 另一方面,如果作業的工作集大於可用記憶體,則排序方法的優點是可以高效地使用磁碟。這與我們在“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”中討論過的原理是一樣的:資料塊可以在記憶體中排序並作為段檔案寫入磁碟,然後多個排序好的段可以合併為一個更大的排序檔案。 歸併排序具有在磁碟上執行良好的順序訪問模式。 (請記住,針對順序I/O進行最佳化是[第3章](ch3.md)中反覆出現的主題,相同的模式在此重現) + +​ GNU Coreutils(Linux)中的`sort `程式透過溢位至磁碟的方式來自動應對大於記憶體的資料集,並能同時使用多個CPU核進行並行排序【9】。這意味著我們之前看到的簡單的Unix命令鏈很容易擴充套件到大資料集,且不會耗盡記憶體。瓶頸可能是從磁碟讀取輸入檔案的速度。 + + + +### Unix哲學 + +​ 我們可以非常容易地使用前一個例子中的一系列命令來分析日誌檔案,這並非巧合:事實上,這實際上是Unix的關鍵設計思想之一,且它今天仍然令人訝異地關聯。讓我們更深入地研究一下,以便從Unix中借鑑一些想法【10】。 + +​ Unix管道的發明者道格·麥克羅伊(Doug McIlroy)在1964年首先描述了這種情況【11】:“當我們需要將訊息從一個程式傳遞另一個程式時,我們需要一種類似水管法蘭的拼接程式的方式【a】 ,I/O應該也按照這種方式進行“。水管的類比仍然在生效,透過管道連線程式的想法成為了現在被稱為**Unix哲學**的一部分 —— 這一組設計原則在Unix使用者與開發者之間流行起來,該哲學在1978年表述如下【12,13】: + +1. 讓每個程式都做好一件事。要做一件新的工作,寫一個新程式,而不是透過新增“功能”讓老程式複雜化。 +2. 期待每個程式的輸出成為另一個程式的輸入。不要將無關資訊混入輸出。避免使用嚴格的列資料或二進位制輸入格式。不要堅持互動式輸入。 +3. 設計和構建軟體,甚至是作業系統,要儘早嘗試,最好在幾周內完成。不要猶豫,扔掉笨拙的部分,重建它們。 +4. 優先使用工具來減輕程式設計任務,即使必須曲線救國編寫工具,且在用完後很可能要扔掉大部分。 + +這種方法 —— 自動化,快速原型設計,增量式迭代,對實驗友好,將大型專案分解成可管理的塊 —— 聽起來非常像今天的敏捷開發和DevOps運動。奇怪的是,四十年來變化不大。 + +​ `sort`工具是一個很好的例子。可以說它比大多數程式語言標準庫中的實現(它們不會利用磁碟或使用多執行緒,即使這樣做有很大好處)要更好。然而,單獨使用`sort` 幾乎沒什麼用。它只能與其他Unix工具(如`uniq`)結合使用。 + +​ 像 `bash`這樣的Unix shell可以讓我們輕鬆地將這些小程式組合成令人訝異的強大資料處理任務。儘管這些程式中有很多是由不同人群編寫的,但它們可以靈活地結合在一起。 Unix如何實現這種可組合性? + +#### 統一的介面 + +​ 如果你希望一個程式的輸出成為另一個程式的輸入,那意味著這些程式必須使用相同的資料格式 —— 換句話說,一個相容的介面。如果你希望能夠將任何程式的輸出連線到任何程式的輸入,那意味著所有程式必須使用相同的I/O介面。 + +​ 在Unix中,這種介面是一個**檔案(file)**(更準確地說,是一個檔案描述符)。一個檔案只是一串有序的位元組序列。因為這是一個非常簡單的介面,所以可以使用相同的介面來表示許多不同的東西:檔案系統上的真實檔案,到另一個程序(Unix套接字,stdin,stdout)的通訊通道,裝置驅動程式(比如`/dev/audio`或`/dev/lp0`),表示TCP連線的套接字等等。很容易將這些設計視為理所當然的,但實際上能讓這些差異巨大的東西共享一個統一的介面是非常厲害的,這使得它們可以很容易地連線在一起[^ii]。 + +[^ii]: 統一介面的另一個例子是URL和HTTP,這是Web的基石。 一個URL標識一個網站上的一個特定的東西(資源),你可以連結到任何其他網站的任何網址。 具有網路瀏覽器的使用者因此可以透過跟隨連結在網站之間無縫跳轉,即使伺服器可能由完全不相關的組織維護。 這個原則現在似乎非常明顯,但它卻是網路取能取得今天成就的關鍵。 之前的系統並不是那麼統一:例如,在公告板系統(BBS)時代,每個系統都有自己的電話號碼和波特率配置。 從一個BBS到另一個BBS的引用必須以電話號碼和調變解調器設定的形式;使用者將不得不掛斷,撥打其他BBS,然後手動找到他們正在尋找的資訊。 這是不可能的直接連結到另一個BBS內的一些內容。 + +​ 按照慣例,許多(但不是全部)Unix程式將這個位元組序列視為ASCII文字。我們的日誌分析示例使用了這個事實:`awk`,`sort`,`uniq`和`head`都將它們的輸入檔案視為由`\n`(換行符,ASCII `0x0A`)字元分隔的記錄列表。 `\n`的選擇是任意的 —— 可以說,ASCII記錄分隔符`0x1E`本來就是一個更好的選擇,因為它是為了這個目的而設計的【14】,但是無論如何,所有這些程式都使用相同的記錄分隔符允許它們互操作。 + +​ 每條記錄(即一行輸入)的解析則更加模糊。 Unix工具通常透過空白或製表符將行分割成欄位,但也使用CSV(逗號分隔),管道分隔和其他編碼。即使像`xargs`這樣一個相當簡單的工具也有六個命令列選項,用於指定如何解析輸入。 + +​ ASCII文字的統一介面大多數時候都能工作,但它不是很優雅:我們的日誌分析示例使用`{print $7}`來提取網址,這樣可讀性不是很好。在理想的世界中可能是`{print $request_url}`或類似的東西。我們稍後會回顧這個想法。 + +​ 儘管幾十年後還不夠完美,但統一的Unix介面仍然是非常出色的設計。沒有多少軟體能像Unix工具一樣互動組合的這麼好:你不能透過自定義分析工具輕鬆地將電子郵件帳戶的內容和線上購物歷史記錄以管道傳送至電子表格中,並將結果釋出到社交網路或維基。今天,像Unix工具一樣流暢地執行程式是一種例外,而不是規範。 + +​ 即使是具有**相同資料模型**的資料庫,將資料從一種匯出再匯入另一種也並不容易。缺乏整合導致了資料的**巴爾幹化**[^譯註i]。 + +[^譯註i]: **巴爾幹化(Balkanization)**是一個常帶有貶義的地緣政治學術語,其定義為:一個國家或政區分裂成多個互相敵對的國家或政區的過程。 + + + +#### 邏輯與佈線相分離 + +​ Unix工具的另一個特點是使用標準輸入(`stdin`)和標準輸出(`stdout`)。如果你執行一個程式,而不指定任何其他的東西,標準輸入來自鍵盤,標準輸出指向螢幕。但是,你也可以從檔案輸入和/或將輸出重定向到檔案。管道允許你將一個程序的標準輸出附加到另一個程序的標準輸入(有個小記憶體緩衝區,而不需要將整個中間資料流寫入磁碟)。 + +​ 程式仍然可以直接讀取和寫入檔案,但如果程式不擔心特定的檔案路徑,只使用標準輸入和標準輸出,則Unix方法效果最好。這允許shell使用者以任何他們想要的方式連線輸入和輸出;該程式不知道或不關心輸入來自哪裡以及輸出到哪裡。 (人們可以說這是一種**松耦合(loose coupling)**,**晚期繫結(late binding)**【15】或**控制反轉(inversion of control)**【16】)。將輸入/輸出佈線與程式邏輯分開,可以將小工具組合成更大的系統。 + +​ 你甚至可以編寫自己的程式,並將它們與作業系統提供的工具組合在一起。你的程式只需要從標準輸入讀取輸入,並將輸出寫入標準輸出,它就可以加入資料處理的管道中。在日誌分析示例中,你可以編寫一個將Usage-Agent字串轉換為更靈敏的瀏覽器識別符號,或者將IP地址轉換為國家程式碼的工具,並將其插入管道。`sort`程式並不關心它是否與作業系統的另一部分或者你寫的程式通訊。 + +​ 但是,使用`stdin`和`stdout`能做的事情是有限的。需要多個輸入或輸出的程式是可能的,但非常棘手。你沒法將程式的輸出管道連線至網路連線中【17,18】[^iii] 。如果程式直接開啟檔案進行讀取和寫入,或者將另一個程式作為子程序啟動,或者開啟網路連線,那麼I/O的佈線就取決於程式本身了。它仍然可以被配置(例如透過命令列選項),但在Shell中對輸入和輸出進行佈線的靈活性就少了。 + +[^iii]: 除了使用一個單獨的工具,如`netcat`或`curl`。 Unix開始試圖將所有東西都表示為檔案,但是BSD套接字API偏離了這個慣例【17】。研究用作業系統Plan 9和Inferno在使用檔案方面更加一致:它們將TCP連線表示為`/net/tcp`中的檔案【18】。 + + + +#### 透明度和實驗 + +使Unix工具如此成功的部分原因是,它們使檢視正在發生的事情變得非常容易: + +- Unix命令的輸入檔案通常被視為不可變的。這意味著你可以隨意執行命令,嘗試各種命令列選項,而不會損壞輸入檔案。 + + +- 你可以在任何時候結束管道,將管道輸出到`less`,然後檢視它是否具有預期的形式。這種檢查能力對除錯非常有用。 +- 你可以將一個流水線階段的輸出寫入檔案,並將該檔案用作下一階段的輸入。這使你可以重新啟動後面的階段,而無需重新執行整個管道。 + +因此,與關係資料庫的查詢最佳化器相比,即使Unix工具非常簡單,但仍然非常有用,特別是對於實驗而言。 + +然而,Unix工具的最大侷限在於它們只能在一臺機器上執行 —— 而Hadoop這樣的工具即應運而生。 + + + +## MapReduce和分散式檔案系統 + +​ MapReduce有點像Unix工具,但分佈在數千臺機器上。像Unix工具一樣,它相當簡單粗暴,但令人驚異地管用。一個MapReduce作業可以和一個Unix程序相類比:它接受一個或多個輸入,併產生一個或多個輸出。 + +​ 和大多數Unix工具一樣,執行MapReduce作業通常不會修改輸入,除了生成輸出外沒有任何副作用。輸出檔案以連續的方式一次性寫入(一旦寫入檔案,不會修改任何現有的檔案部分)。 + +​ 雖然Unix工具使用`stdin`和`stdout`作為輸入和輸出,但MapReduce作業在分散式檔案系統上讀寫檔案。在Hadoop的Map-Reduce實現中,該檔案系統被稱為**HDFS(Hadoop分散式檔案系統)**,一個Google檔案系統(GFS)的開源實現【19】。 + +​ 除HDFS外,還有各種其他分散式檔案系統,如GlusterFS和Quantcast File System(QFS)【20】。諸如Amazon S3,Azure Blob儲存和OpenStack Swift 【21】等物件儲存服務在很多方面都是相似的[^iv]。在本章中,我們將主要使用HDFS作為示例,但是這些原則適用於任何分散式檔案系統。 + +[^iv]: 一個不同之處在於,對於HDFS,可以將計算任務安排在儲存特定檔案副本的計算機上執行,而物件儲存通常將儲存和計算分開。如果網路頻寬是一個瓶頸,從本地磁碟讀取有效能優勢。但是請注意,如果使用糾刪碼,則會丟失區域性性,因為來自多臺機器的資料必須進行合併以重建原始檔案【20】。 + +​ 與網路連線儲存(NAS)和儲存區域網路(SAN)架構的共享磁碟方法相比,HDFS基於**無共享**原則(參見[第二部分前言](part-ii.md))。共享磁碟儲存由集中式儲存裝置實現,通常使用定製硬體和專用網路基礎設施(如光纖通道)。而另一方面,無共享方法不需要特殊的硬體,只需要透過傳統資料中心網路連線的計算機。 + +​ HDFS包含在每臺機器上執行的守護程序,對外暴露網路服務,允許其他節點訪問儲存在該機器上的檔案(假設資料中心中的每臺通用計算機都掛載著一些磁碟)。名為**NameNode**的中央伺服器會跟蹤哪個檔案塊儲存在哪臺機器上。因此,HDFS在概念上建立了一個大型檔案系統,可以使用所有執行有守護程序的機器的磁碟。 + +​ 為了容忍機器和磁碟故障,檔案塊被複制到多臺機器上。複製可能意味著多個機器上的相同資料的多個副本,如[第5章](ch5.md)中所述,或者諸如Reed-Solomon碼這樣的糾刪碼方案,它允許以比完全複製更低的儲存開銷以恢復丟失的資料【20,22】。這些技術與RAID相似,可以在連線到同一臺機器的多個磁碟上提供冗餘;區別在於在分散式檔案系統中,檔案訪問和複製是在傳統的資料中心網路上完成的,沒有特殊的硬體。 + +​ HDFS已經擴充套件的很不錯了:在撰寫本書時,最大的HDFS部署執行在上萬臺機器上,總儲存容量達數百PB【23】。如此大的規模已經變得可行,因為使用商品硬體和開源軟體的HDFS上的資料儲存和訪問成本遠低於專用儲存裝置上的同等容量【24】。 + +### MapReduce作業執行 + +​ MapReduce是一個程式設計框架,你可以使用它編寫程式碼來處理HDFS等分散式檔案系統中的大型資料集。理解它的最簡單方法是參考“[簡單日誌分析](#簡單日誌分析)”中的Web伺服器日誌分析示例。MapReduce中的資料處理模式與此示例非常相似: + +1. 讀取一組輸入檔案,並將其分解成**記錄(records)**。在Web伺服器日誌示例中,每條記錄都是日誌中的一行(即`\n`是記錄分隔符)。 +2. 呼叫Mapper函式,從每條輸入記錄中提取一對鍵值。在前面的例子中,Mapper函式是`awk '{print $7}'`:它提取URL(`$7`)作為關鍵字,並將值留空。 +3. 按鍵排序所有的鍵值對。在日誌的例子中,這由第一個`sort`命令完成。 +4. 呼叫Reducer函式遍歷排序後的鍵值對。如果同一個鍵出現多次,排序使它們在列表中相鄰,所以很容易組合這些值而不必在記憶體中保留很多狀態。在前面的例子中,Reducer是由`uniq -c`命令實現的,該命令使用相同的鍵來統計相鄰記錄的數量。 + +這四個步驟可以作為一個MapReduce作業執行。步驟2(Map)和4(Reduce)是你編寫自定義資料處理程式碼的地方。步驟1(將檔案分解成記錄)由輸入格式解析器處理。步驟3中的排序步驟隱含在MapReduce中 —— 你不必編寫它,因為Mapper的輸出始終在送往Reducer之前進行排序。 + +要建立MapReduce作業,你需要實現兩個回撥函式,Mapper和Reducer,其行為如下(參閱“[MapReduce查詢](ch2.md#MapReduce查詢)”): + +***Mapper*** + +​ Mapper會在每條輸入記錄上呼叫一次,其工作是從輸入記錄中提取鍵值。對於每個輸入,它可以生成任意數量的鍵值對(包括None)。它不會保留從一個輸入記錄到下一個記錄的任何狀態,因此每個記錄都是獨立處理的。 + +***Reducer*** + MapReduce框架拉取由Mapper生成的鍵值對,收集屬於同一個鍵的所有值,並使用在這組值列表上迭代呼叫Reducer。 Reducer可以產生輸出記錄(例如相同URL的出現次數)。 + +​ 在Web伺服器日誌的例子中,我們在第5步中有第二個`sort`命令,它按請求數對URL進行排序。在MapReduce中,如果你需要第二個排序階段,則可以透過編寫第二個MapReduce作業並將第一個作業的輸出用作第二個作業的輸入來實現它。這樣看來,Mapper的作用是將資料放入一個適合排序的表單中,並且Reducer的作用是處理已排序的資料。 + +#### 分散式執行MapReduce + +​ MapReduce與Unix命令管道的主要區別在於,MapReduce可以在多臺機器上並行執行計算,而無需編寫程式碼來顯式處理並行問題。Mapper和Reducer一次只能處理一條記錄;它們不需要知道它們的輸入來自哪裡,或者輸出去往什麼地方,所以框架可以處理在機器之間移動資料的複雜性。 + +​ 在分散式計算中可以使用標準的Unix工具作為Mapper和Reducer【25】,但更常見的是,它們被實現為傳統程式語言的函式。在Hadoop MapReduce中,Mapper和Reducer都是實現特定介面的Java類。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函式(參閱“[MapReduce查詢](ch2.md#MapReduce查詢)”)。 + +​ [圖10-1]()顯示了Hadoop MapReduce作業中的資料流。其並行化基於分割槽(參見[第6章](ch6.md)):作業的輸入通常是HDFS中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理map任務([圖10-1](img/fig10-1.png)中的m1,m2和m3標記)。 + +​ 每個輸入檔案的大小通常是數百兆位元組。 MapReduce排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個Mapper,只要該機器有足夠的備用RAM和CPU資源來執行Mapper任務【26】。這個原則被稱為**將計算放在資料附近**【27】:它節省了透過網路複製輸入檔案的開銷,減少網路負載並增加區域性性。 + +![](img/fig10-1.png) + +**圖10-1 具有三個Mapper和三個Reducer的MapReduce任務** + +​ 在大多數情況下,應該在Mapper任務中執行的應用程式碼在將要執行它的機器上還不存在,所以MapReduce框架首先將程式碼(例如Java程式中的JAR檔案)複製到適當的機器。然後啟動Map任務並開始讀取輸入檔案,一次將一條記錄傳入Mapper回撥函式。Mapper的輸出由鍵值對組成。 + +​ 計算的Reduce端也被分割槽。雖然Map任務的數量由輸入檔案塊的數量決定,但Reducer的任務的數量是由作業作者配置的(它可以不同於Map任務的數量)。為了確保具有相同鍵的所有鍵值對最終落在相同的Reducer處,框架使用鍵的雜湊值來確定哪個Reduce任務應該接收到特定的鍵值對(參見“[按鍵雜湊分割槽](ch6.md#按鍵雜湊分割槽)”))。 + +​ 鍵值對必須進行排序,但資料集可能太大,無法在單臺機器上使用常規排序演算法進行排序。相反,分類是分階段進行的。首先每個Map任務都按照Reducer對輸出進行分割槽。每個分割槽都被寫入Mapper程式的本地磁碟,使用的技術與我們在“[SSTables與LSM樹](ch3.md#SSTables與LSM樹)”中討論的類似。 + +​ 只要當Mapper讀取完輸入檔案,並寫完排序後的輸出檔案,MapReduce排程器就會通知Reducer可以從該Mapper開始獲取輸出檔案。Reducer連線到每個Mapper,並下載自己相應分割槽的有序鍵值對檔案。按Reducer分割槽,排序,從Mapper向Reducer複製分割槽資料,這一整個過程被稱為**混洗(shuffle)**【26】(一個容易混淆的術語 —— 不像洗牌,在MapReduce中的混洗沒有隨機性)。 + +​ Reduce任務從Mapper獲取檔案,並將它們合併在一起,並保留有序特性。因此,如果不同的Mapper生成了鍵相同的記錄,則在Reducer的輸入中,這些記錄將會相鄰。 + +​ Reducer呼叫時會收到一個鍵,和一個迭代器作為引數,迭代器會順序地掃過所有具有該鍵的記錄(因為在某些情況可能無法完全放入記憶體中)。Reducer可以使用任意邏輯來處理這些記錄,並且可以生成任意數量的輸出記錄。這些輸出記錄會寫入分散式檔案系統上的檔案中(通常是在跑Reducer的機器本地磁碟上留一份,並在其他機器上留幾份副本)。 + +#### MapReduce工作流 + +​ 單個MapReduce作業可以解決的問題範圍很有限。以日誌分析為例,單個MapReduce作業可以確定每個URL的頁面瀏覽次數,但無法確定最常見的URL,因為這需要第二輪排序。 + +​ 因此將MapReduce作業連結成為**工作流(workflow)**中是極為常見的,例如,一個作業的輸出成為下一個作業的輸入。 Hadoop Map-Reduce框架對工作流沒有特殊支援,所以這個鏈是透過目錄名隱式實現的:第一個作業必須將其輸出配置為HDFS中的指定目錄,第二個作業必須將其輸入配置為從同一個目錄。從MapReduce框架的角度來看,這是是兩個獨立的作業。 + +​ 因此,被連結的MapReduce作業並沒有那麼像Unix命令管道(它直接將一個程序的輸出作為另一個程序的輸入,僅用一個很小的記憶體緩衝區)。它更像是一系列命令,其中每個命令的輸出寫入臨時檔案,下一個命令從臨時檔案中讀取。這種設計有利也有弊,我們將在“[物化中間狀態](#物化中間狀態)”中討論。 + +​ 只有當作業成功完成後,批處理作業的輸出才會被視為有效的(MapReduce會丟棄失敗作業的部分輸出)。因此,工作流中的一項作業只有在先前的作業 —— 即生產其輸入的作業 —— 成功完成後才能開始。為了處理這些作業之間的依賴,有很多針對Hadoop的工作流排程器被開發出來,包括Oozie,Azkaban,Luigi,Airflow和Pinball 【28】。 + +​ 這些排程程式還具有管理功能,在維護大量批處理作業時非常有用。在構建推薦系統時,由50到100個MapReduce作業組成的工作流是常見的【29】。而在大型組織中,許多不同的團隊可能執行不同的作業來讀取彼此的輸出。工具支援對於管理這樣複雜的資料流而言非常重要。 + +​ Hadoop的各種高階工具(如Pig 【30】,Hive 【31】,Cascading 【32】,Crunch 【33】和FlumeJava 【34】)也能自動佈線組裝多個MapReduce階段,生成合適的工作流。 + +### Reduce端連線與分組 + +​ 我們在[第2章](ch2.md)中討論了資料模型和查詢語言的聯接,但是我們還沒有深入探討連線是如何實現的。現在是我們再次撿起這條線索的時候了。 + +​ 在許多資料集中,一條記錄與另一條記錄存在關聯是很常見的:關係模型中的**外來鍵**,文件模型中的**文件引用**或圖模型中的**邊**。當你需要同時訪問這一關聯的兩側(持有引用的記錄與被引用的記錄)時,連線就是必須的。(包含引用的記錄和被引用的記錄),連線就是必需的。正如[第2章](ch2.md)所討論的,非規範化可以減少對連線的需求,但通常無法將其完全移除[^v]。 + +[^v]: 我們在本書中討論的連線通常是等值連線,即最常見的連線型別,其中記錄與其他記錄在特定欄位(例如ID)中具有**相同值**相關聯。有些資料庫支援更通用的連線型別,例如使用小於運算子而不是等號運算子,但是我們沒有地方來講這些東西。 + +​ 在資料庫中,如果執行只涉及少量記錄的查詢,資料庫通常會使用**索引**來快速定位感興趣的記錄(參閱[第3章](ch3.md))。如果查詢涉及到連線,則可能涉及到查詢多個索引。然而MapReduce沒有索引的概念 —— 至少在通常意義上沒有。 + +​ 當MapReduce作業被賦予一組檔案作為輸入時,它讀取所有這些檔案的全部內容;資料庫會將這種操作稱為**全表掃描**。如果你只想讀取少量的記錄,則全表掃描與索引查詢相比,代價非常高昂。但是在分析查詢中(參閱“[事務處理或分析?](ch3.md#事務處理還是分析?)”),通常需要計算大量記錄的聚合。在這種情況下,特別是如果能在多臺機器上並行處理時,掃描整個輸入可能是相當合理的事情。 + +​ 當我們在批處理的語境中討論連線時,我們指的是在資料集中解析某種關聯的全量存在。 例如我們假設一個作業是同時處理所有使用者的資料,而非僅僅是為某個特定使用者查詢資料(而這能透過索引更高效地完成)。 + +#### 示例:分析使用者活動事件 + +​ [圖10-2](img/fig10-2.png)給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為**活動事件(activity events)**或**點選流資料(clickstream data)**),右側是使用者資料庫。 你可以將此示例看作是星型模式的一部分(參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。 + +![](img/fig10-2.png) + +**圖10-2 使用者行為日誌與使用者檔案的連線** + +​ 分析任務可能需要將使用者活動與使用者簡檔相關聯:例如,如果檔案包含使用者的年齡或出生日期,系統就可以確定哪些頁面更受哪些年齡段的使用者歡迎。然而活動事件僅包含使用者ID,而沒有包含完整的使用者檔案資訊。在每個活動事件中嵌入這些檔案資訊很可能會非常浪費。因此,活動事件需要與使用者檔案資料庫相連線。 + +​ 實現這一連線的最簡單方法是,逐個遍歷活動事件,併為每個遇到的使用者ID查詢使用者資料庫(在遠端伺服器上)。這是可能的,但是它的效能可能會非常差:處理吞吐量將受限於受資料庫伺服器的往返時間,本地快取的有效性很大程度上取決於資料的分佈,並行執行大量查詢可能會輕易壓垮資料庫【35】。 + +​ 為了在批處理過程中實現良好的吞吐量,計算必須(儘可能)限於單臺機器上進行。為待處理的每條記錄發起隨機訪問的網路請求實在是太慢了。而且,查詢遠端資料庫意味著批處理作業變為**非確定的(nondeterministic)**,因為遠端資料庫中的資料可能會改變。 + +​ 因此,更好的方法是獲取使用者資料庫的副本(例如,使用ETL程序從資料庫備份中提取資料,參閱“[資料倉庫](ch3.md#資料倉庫)”),並將它和使用者行為日誌放入同一個分散式檔案系統中。然後你可以將使用者資料庫儲存在HDFS中的一組檔案中,而使用者活動記錄儲存在另一組檔案中,並能用MapReduce將所有相關記錄集中到同一個地方進行高效處理。 + +#### 排序合併連線 + +​ 回想一下,Mapper的目的是從每個輸入記錄中提取一對鍵值。在[圖10-2](img/fig10-2.png)的情況下,這個鍵就是使用者ID:一組Mapper會掃過活動事件(提取使用者ID作為鍵,活動事件作為值),而另一組Mapper將會掃過使用者資料庫(提取使用者ID作為鍵,使用者的出生日期作為值)。這個過程如[圖10-3](img/fig10-3.png)所示。 + +![](img/fig10-3.png) + +**圖10-3 在使用者ID上進行的Reduce端連線。如果輸入資料集分割槽為多個檔案,則每個分割槽都會被多個Mapper並行處理** + +​ 當MapReduce框架透過鍵對Mapper輸出進行分割槽,然後對鍵值對進行排序時,效果是具有相同ID的所有活動事件和使用者記錄在Reducer輸入中彼此相鄰。 Map-Reduce作業甚至可以也讓這些記錄排序,使Reducer總能先看到來自使用者資料庫的記錄,緊接著是按時間戳順序排序的活動事件 —— 這種技術被稱為**二次排序(secondary sort)**【26】。 + +​ 然後Reducer可以容易地執行實際的連線邏輯:每個使用者ID都會被呼叫一次Reducer函式,且因為二次排序,第一個值應該是來自使用者資料庫的出生日期記錄。 Reducer將出生日期儲存在區域性變數中,然後使用相同的使用者ID遍歷活動事件,輸出**已觀看網址**和**觀看者年齡**的結果對。隨後的Map-Reduce作業可以計算每個URL的檢視者年齡分佈,並按年齡段進行聚集。 + +​ 由於Reducer一次處理一個特定使用者ID的所有記錄,因此一次只需要將一條使用者記錄儲存在記憶體中,而不需要透過網路發出任何請求。這個演算法被稱為**排序合併連線(sort-merge join)**,因為Mapper的輸出是按鍵排序的,然後Reducer將來自連線兩側的有序記錄列表合併在一起。 + +#### 把相關資料放在一起 + +​ 在排序合併連線中,Mapper和排序過程確保了所有對特定使用者ID執行連線操作的必須資料都被放在同一個地方:單次呼叫Reducer的地方。預先排好了所有需要的資料,Reducer可以是相當簡單的單執行緒程式碼,能夠以高吞吐量和與低記憶體開銷掃過這些記錄。 + +​ 這種架構可以看做,Mapper將“訊息”傳送給Reducer。當一個Mapper發出一個鍵值對時,這個鍵的作用就像值應該傳遞到的目標地址。即使鍵只是一個任意的字串(不是像IP地址和埠號那樣的實際的網路地址),它表現的就像一個地址:所有具有相同鍵的鍵值對將被傳遞到相同的目標(一次Reduce的呼叫)。 + +​ 使用MapReduce程式設計模型,能將計算的物理網路通訊層面(從正確的機器獲取資料)從應用邏輯中剝離出來(獲取資料後執行處理)。這種分離與資料庫的典型用法形成了鮮明對比,從資料庫中獲取資料的請求經常出現在應用程式碼內部【36】。由於MapReduce能夠處理所有的網路通訊,因此它也避免了應用程式碼去擔心部分故障,例如另一個節點的崩潰:MapReduce在不影響應用邏輯的情況下能透明地重試失敗的任務。 + +### GROUP BY + +​ 除了連線之外,“把相關資料放在一起”的另一種常見模式是,按某個鍵對記錄分組(如SQL中的GROUP BY子句)。所有帶有相同鍵的記錄構成一個組,而下一步往往是在每個組內進行某種聚合操作,例如: + +- 統計每個組中記錄的數量(例如在統計PV的例子中,在SQL中表示為`COUNT(*)`聚合) +- 對某個特定欄位求和(SQL中的`SUM(fieldname)`) +- 按某種分級函式取出排名前k條記錄。 + +使用MapReduce實現這種分組操作的最簡單方法是設定Mapper,以便它們生成的鍵值對使用所需的分組鍵。然後分割槽和排序過程將所有具有相同分割槽鍵的記錄導向同一個Reducer。因此在MapReduce之上實現分組和連線看上去非常相似。 + +​ 分組的另一個常見用途是整理特定使用者會話的所有活動事件,以找出使用者進行的一系列操作(稱為**會話化(sessionization)**【37】)。例如,可以使用這種分析來確定顯示新版網站的使用者是否比那些顯示舊版本(A/B測試)的使用者更有購買慾,或者計算某個營銷活動是否值得。 + +​ 如果你有多個Web伺服器處理使用者請求,則特定使用者的活動事件很可能分散在各個不同的伺服器的日誌檔案中。你可以透過使用會話cookie,使用者ID或類似的識別符號作為分組鍵,以將特定使用者的所有活動事件放在一起來實現會話化,與此同時,不同使用者的事件仍然散步在不同的分割槽中。 + +#### 處理傾斜 + +​ 如果存在與單個鍵關聯的大量資料,則“將具有相同鍵的所有記錄放到相同的位置”這種模式就被破壞了。例如在社交網路中,大多數使用者可能會與幾百人有連線,但少數名人可能有數百萬的追隨者。這種不成比例的活動資料庫記錄被稱為**關鍵物件(linchpin object)**【38】或**熱鍵(hot key)**。 + +​ 在單個Reducer中收集與某個名流相關的所有活動(例如他們釋出內容的回覆)可能導致嚴重的傾斜(也稱為**熱點(hot spot)**)—— 也就是說,一個Reducer必須比其他Reducer處理更多的記錄(參見“[負載傾斜與消除熱點](ch6.md#負載傾斜與消除熱點)“)。由於MapReduce作業只有在所有Mapper和Reducer都完成時才完成,所有後續作業必須等待最慢的Reducer才能啟動。 + +​ 如果連線的輸入存在熱點鍵,可以使用一些演算法進行補償。例如,Pig中的**傾斜連線(skewed join)**方法首先執行一個抽樣作業來確定哪些鍵是熱鍵【39】。連線實際執行時,Mapper會將熱鍵的關聯記錄**隨機**(相對於傳統MapReduce基於鍵雜湊的確定性方法)傳送到幾個Reducer之一。對於另外一側的連線輸入,與熱鍵相關的記錄需要被複制到所有處理該鍵的Reducer上【40】。 + +​ 這種技術將處理熱鍵的工作分散到多個Reducer上,這樣可以使其更好地並行化,代價是需要將連線另一側的輸入記錄複製到多個Reducer上。 Crunch中的**分片連線(sharded join)**方法與之類似,但需要顯式指定熱鍵而不是使用取樣作業。這種技術也非常類似於我們在“[負載傾斜與消除熱點](ch6.md#負載傾斜與消除熱點)”中討論的技術,使用隨機化來緩解分割槽資料庫中的熱點。 + +​ Hive的偏斜連線最佳化採取了另一種方法。它需要在表格元資料中顯式指定熱鍵,並將與這些鍵相關的記錄單獨存放,與其它檔案分開。當在該表上執行連線時,對於熱鍵,它會使用Map端連線(參閱[下一節](#Map端連線))。 + +​ 當按照熱鍵進行分組並聚合時,可以將分組分兩個階段進行。第一個MapReduce階段將記錄傳送到隨機Reducer,以便每個Reducer只對熱鍵的子集執行分組,為每個鍵輸出一個更緊湊的中間聚合結果。然後第二個MapReduce作業將所有來自第一階段Reducer的中間聚合結果合併為每個鍵一個值。 + + + +### Map端連線 + +​ 上一節描述的連線演算法在Reducer中執行實際的連線邏輯,因此被稱為Reduce端連線。Mapper扮演著預處理輸入資料的角色:從每個輸入記錄中提取鍵值,將鍵值對分配給Reducer分割槽,並按鍵排序。 + +​ Reduce端方法的優點是不需要對輸入資料做任何假設:無論其屬性和結構如何,Mapper都可以對其預處理以備連線。然而不利的一面是,排序,複製至Reducer,以及合併Reducer輸入,所有這些操作可能開銷巨大。當資料透過MapReduce 階段時,資料可能需要落盤好幾次,取決於可用的記憶體緩衝區【37】。 + +​ 另一方面,如果你**能**對輸入資料作出某些假設,則透過使用所謂的Map端連線來加快連線速度是可行的。這種方法使用了一個閹掉Reduce與排序的MapReduce作業,每個Mapper只是簡單地從分散式檔案系統中讀取一個輸入檔案塊,然後將輸出檔案寫入檔案系統,僅此而已。 + +#### 廣播雜湊連線 + +​ 適用於執行Map端連線的最簡單場景是大資料集與小資料集連線的情況。要點在於小資料集需要足夠小,以便可以將其全部載入到每個Mapper的記憶體中。 + +​ 例如,假設在[圖10-2](img/fig10-2.png)的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當Mapper啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的雜湊中。完成此操作後,Map程式可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者ID[^vi]。 + +[^vi]: 這個例子假定散列表中的每個鍵只有一個條目,這對使用者資料庫(使用者ID唯一標識一個使用者)可能是正確的。通常,雜湊表可能需要包含具有相同鍵的多個條目,而連線運算子將對每個鍵輸出所有的匹配。 + +​ 參與連線的較大輸入的每個檔案塊各有一個Mapper(在[圖10-2](img/fig10-2.png)的例子中活動事件是較大的輸入)。每個Mapper都會將較小輸入整個載入到記憶體中。 + +​ 這種簡單有效的演算法被稱為**廣播雜湊連線(broadcast hash join)**:**廣播**一詞反映了這樣一個事實,每個連線較大輸入端分割槽的Mapper都會將較小輸入端資料集整個讀入記憶體中(所以較小輸入實際上“廣播”到較大資料的所有分割槽上),**雜湊**一詞反映了它使用一個散列表。 Pig(名為“**複製連結(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支援這種連線。它也被諸如Impala的資料倉庫查詢引擎使用【41】。 + +​ 除了將連線較小輸入載入到記憶體散列表中,另一種方法是將較小輸入儲存在本地磁碟上的只讀索引中【42】。索引中經常使用的部分將保留在作業系統的頁面快取中,因而這種方法可以提供與記憶體散列表幾乎一樣快的隨機查詢效能,但實際上並不需要資料集能放入記憶體中。 + +#### 分割槽雜湊連線 + +​ 如果Map端連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在[圖10-2](img/fig10-2.png)的情況中,你可以根據使用者ID的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有10個分割槽)。例如,Mapper3首先將所有具有以3結尾的ID的使用者載入到散列表中,然後掃描ID為3的每個使用者的所有活動事件。 + +​ 如果分割槽正確無誤,可以確定的是,所有你可能需要連線的記錄都落在同一個編號的分割槽中。因此每個Mapper只需要從輸入兩端各讀取一個分割槽就足夠了。好處是每個Mapper都可以在記憶體散列表中少放點資料。 + +​ 這種方法只有當連線兩端輸入有相同的分割槽數,且兩側的記錄都是使用相同的鍵與相同的雜湊函式做分割槽時才適用。如果輸入是由之前執行過這種分組的MapReduce作業生成的,那麼這可能是一個合理的假設。 + +​ 分割槽雜湊連線在Hive中稱為**Map端桶連線(bucketed map joins)【37】**。 + +#### Map端合併連線 + +​ 如果輸入資料集不僅以相同的方式進行分割槽,而且還基於相同的鍵進行**排序**,則可適用另一種Map端聯接的變體。在這種情況下,輸入是否小到能放入記憶體並不重要,因為這時候Mapper同樣可以執行歸併操作(通常由Reducer執行)的歸併操作:按鍵遞增的順序依次讀取兩個輸入檔案,將具有相同鍵的記錄配對。 + +​ 如果能進行Map端合併連線,這通常意味著前一個MapReduce作業可能一開始就已經把輸入資料做了分割槽並進行了排序。原則上這個連線就可以在前一個作業的Reduce階段進行。但使用獨立的僅Map作業有時也是合適的,例如,分好區且排好序的中間資料集可能還會用於其他目的。 + +#### MapReduce工作流與Map端連線 + +​ 當下遊作業使用MapReduce連線的輸出時,選擇Map端連線或Reduce端連線會影響輸出的結構。Reduce端連線的輸出是按照**連線鍵**進行分割槽和排序的,而Map端連線的輸出則按照與較大輸入相同的方式進行分割槽和排序(因為無論是使用分割槽連線還是廣播連線,連線較大輸入端的每個檔案塊都會啟動一個Map任務)。 + +​ 如前所述,Map端連線也對輸入資料集的大小,有序性和分割槽方式做出了更多假設。在最佳化連線策略時,瞭解分散式檔案系統中資料集的物理佈局變得非常重要:僅僅知道編碼格式和資料儲存目錄的名稱是不夠的;你還必須知道資料是按哪些鍵做的分割槽和排序,以及分割槽的數量。 + +​ 在Hadoop生態系統中,這種關於資料集分割槽的元資料通常在HCatalog和Hive Metastore中維護【37】。 + + + +### 批處理工作流的輸出 + +​ 我們已經說了很多用於實現MapReduce工作流的演算法,但卻忽略了一個重要的問題:這些處理完成之後的最終結果是什麼?我們最開始為什麼要跑這些作業? + +​ 在資料庫查詢的場景中,我們將事務處理(OLTP)與分析兩種目的區分開來(參閱“[事務處理或分析?](ch3.md#事務處理或分析?)”)。我們看到,OLTP查詢通常根據鍵查詢少量記錄,使用索引,並將其呈現給使用者(比如在網頁上)。另一方面,分析查詢通常會掃描大量記錄,執行分組與聚合,輸出通常有著報告的形式:顯示某個指標隨時間變化的圖表,或按照某種排位取前10項,或一些數字細化為子類。這種報告的消費者通常是需要做出商業決策的分析師或經理。 + +​ 批處理放哪裡合適?它不屬於事務處理,也不是分析。它和分析比較接近,因為批處理通常會掃過輸入資料集的絕大部分。然而MapReduce作業工作流與用於分析目的的SQL查詢是不同的(參閱“[Hadoop與分散式資料庫的對比](#Hadoop與分散式資料庫的對比)”)。批處理過程的輸出通常不是報表,而是一些其他型別的結構。 + +#### 建立搜尋索引 + +​ Google最初使用MapReduce是為其搜尋引擎建立索引,用了由5到10個MapReduce作業組成的工作流實現【1】。雖然Google後來也不僅僅是為這個目的而使用MapReduce 【43】,但如果從構建搜尋索引的角度來看,更能幫助理解MapReduce。 (直至今日,Hadoop MapReduce仍然是為Lucene/Solr構建索引的好方法【44】) + +​ 我們在“[全文搜尋和模糊索引](ch3.md#全文搜尋和模糊索引)”中簡要地瞭解了Lucene這樣的全文搜尋索引是如何工作的:它是一個檔案(關鍵詞字典),你可以在其中高效地查詢特定關鍵字,並找到包含該關鍵字的所有文件ID列表(文章列表)。這是一種非常簡化的看法 —— 實際上,搜尋索引需要各種額外資料,以便根據相關性對搜尋結果進行排名,糾正拼寫錯誤,解析同義詞等等 —— 但這個原則是成立的。 + +​ 如果需要對一組固定文件執行全文搜尋,則批處理是一種構建索引的高效方法:Mapper根據需要對文件集合進行分割槽,每個Reducer構建該分割槽的索引,並將索引檔案寫入分散式檔案系統。構建這樣的文件分割槽索引(參閱“[分割槽和二級索引](ch6.md#分割槽和二級索引)”)並行處理效果拔群。 + +​ 由於按關鍵字查詢搜尋索引是隻讀操作,因而這些索引檔案一旦建立就是不可變的。 + +​ 如果索引的文件集合發生更改,一種選擇是定期重跑整個索引工作流,並在完成後用新的索引檔案批次替換以前的索引檔案。如果只有少量的文件發生了變化,這種方法的計算成本可能會很高。但它的優點是索引過程很容易理解:文件進,索引出。 + +​ 另一個選擇是,可以增量建立索引。如[第3章](ch3.md)中討論的,如果要在索引中新增,刪除或更新文件,Lucene會寫新的段檔案,並在後臺非同步合併壓縮段檔案。我們將在[第11章](ch11.md)中看到更多這種增量處理。 + +#### 鍵值儲存作為批處理輸出 + +​ 搜尋索引只是批處理工作流可能輸出的一個例子。批處理的另一個常見用途是構建機器學習系統,例如分類器(比如垃圾郵件過濾器,異常檢測,影象識別)與推薦系統(例如,你可能認識的人,你可能感興趣的產品或相關的搜尋【29】)。 + +​ 這些批處理作業的輸出通常是某種資料庫:例如,可以透過給定使用者ID查詢該使用者推薦好友的資料庫,或者可以透過產品ID查詢相關產品的資料庫【45】。 + +​ 這些資料庫需要被處理使用者請求的Web應用所查詢,而它們通常是獨立於Hadoop基礎設施的。那麼批處理過程的輸出如何回到Web應用可以查詢的資料庫中呢? + +​ 最直接的選擇可能是,直接在Mapper或Reducer中使用你最愛資料庫的客戶端庫,並從批處理作業直接寫入資料庫伺服器,一次寫入一條記錄。它能工作(假設你的防火牆規則允許從你的Hadoop環境直接訪問你的生產資料庫),但這並不是一個好主意,出於以下幾個原因: + +- 正如前面在連線的上下文中討論的那樣,為每條記錄發起一個網路請求,要比批處理任務的正常吞吐量慢幾個數量級。即使客戶端庫支援批處理,效能也可能很差。 +- MapReduce作業經常並行執行許多工。如果所有Mapper或Reducer都同時寫入相同的輸出資料庫,並以批處理的預期速率工作,那麼該資料庫很可能被輕易壓垮,其查詢效能可能變差。這可能會導致系統其他部分的執行問題【35】。 +- 通常情況下,MapReduce為作業輸出提供了一個乾淨利落的“全有或全無”保證:如果作業成功,則結果就是每個任務恰好執行一次所產生的輸出,即使某些任務失敗且必須一路重試。如果整個作業失敗,則不會生成輸出。然而從作業內部寫入外部系統,會產生外部可見的副作用,這種副作用是不能以這種方式被隱藏的。因此,你不得不去操心部分完成的作業對其他系統可見的結果,並需要理解Hadoop任務嘗試與預測執行的複雜性。 + +更好的解決方案是在批處理作業**內**建立一個全新的資料庫,並將其作為檔案寫入分散式檔案系統中作業的輸出目錄,就像上節中的搜尋索引一樣。這些資料檔案一旦寫入就是不可變的,可以批次載入到處理只讀查詢的伺服器中。不少鍵值儲存都支援在MapReduce作業中構建資料庫檔案,包括Voldemort 【46】,Terrapin 【47】,ElephantDB 【48】和HBase批次載入【49】。 + +​ 構建這些資料庫檔案是MapReduce的一種很好用法的使用方法:使用Mapper提取出鍵並按該鍵排序,現在已經是構建索引所必需的大量工作。由於這些鍵值儲存大多都是隻讀的(檔案只能由批處理作業一次性寫入,然後就不可變),所以資料結構非常簡單。比如它們就不需要WAL(參閱“[使B樹可靠](ch3.md#使B樹可靠)”)。 + +​ 將資料載入到Voldemort時,伺服器將繼續用舊資料檔案服務請求,同時將新資料檔案從分散式檔案系統複製到伺服器的本地磁碟。一旦複製完成,伺服器會自動將查詢切換到新檔案。如果在這個過程中出現任何問題,它可以輕易回滾至舊檔案,因為它們仍然存在而且不可變【46】。 + +#### 批處理輸出的哲學 + +​ 本章前面討論過的Unix哲學(“[Unix哲學](#Unix哲學)”)鼓勵以顯式指明資料流的方式進行實驗:程式讀取輸入並寫入輸出。在這一過程中,輸入保持不變,任何先前的輸出都被新輸出完全替換,且沒有其他副作用。這意味著你可以隨心所欲地重新執行一個命令,略做改動或進行除錯,而不會攪亂系統的狀態。 + +​ MapReduce作業的輸出處理遵循同樣的原理。透過將輸入視為不可變且避免副作用(如寫入外部資料庫),批處理作業不僅實現了良好的效能,而且更容易維護: + +- 如果在程式碼中引入了一個錯誤,而輸出錯誤或損壞了,則可以簡單地回滾到程式碼的先前版本,然後重新執行該作業,輸出將重新被糾正。或者,甚至更簡單,你可以將舊的輸出儲存在不同的目錄中,然後切換回原來的目錄。具有讀寫事務的資料庫沒有這個屬性:如果你部署了錯誤的程式碼,將錯誤的資料寫入資料庫,那麼回滾程式碼將無法修復資料庫中的資料。 (能夠從錯誤程式碼中恢復的概念被稱為**人類容錯(human fault tolerance)**【50】) +- 由於回滾很容易,比起在錯誤意味著不可挽回的傷害的環境,功能開發進展能快很多。這種**最小化不可逆性(minimizing irreversibility)**的原則有利於敏捷軟體開發【51】。 +- 如果Map或Reduce任務失敗,MapReduce框架將自動重新排程,並在同樣的輸入上再次執行它。如果失敗是由程式碼中的錯誤造成的,那麼它會不斷崩潰,並最終導致作業在幾次嘗試之後失敗。但是如果故障是由於臨時問題導致的,那麼故障就會被容忍。因為輸入不可變,這種自動重試是安全的,而失敗任務的輸出會被MapReduce框架丟棄。 +- 同一組檔案可用作各種不同作業的輸入,包括計算指標的監控作業可以評估作業的輸出是否具有預期的性質(例如,將其與前一次執行的輸出進行比較並測量差異) 。 +- 與Unix工具類似,MapReduce作業將邏輯與佈線(配置輸入和輸出目錄)分離,這使得關注點分離,可以重用程式碼:一個團隊可以實現一個專注做好一件事的作業;而其他團隊可以決定何時何地執行這項作業。 + +在這些領域,在Unix上表現良好的設計原則似乎也適用於Hadoop,但Unix和Hadoop在某些方面也有所不同。例如,因為大多數Unix工具都假設輸入輸出是無型別文字檔案,所以它們必須做大量的輸入解析工作(本章開頭的日誌分析示例使用`{print $7}`來提取URL)。在Hadoop上可以透過使用更結構化的檔案格式消除一些低價值的語法轉換:比如Avro(參閱“[Avro](ch4.md#Avro)”)和Parquet(參閱“[列儲存](ch3.md#列儲存)”)經常使用,因為它們提供了基於模式的高效編碼,並允許模式隨時間推移而演進(見第4章)。 + +### Hadoop與分散式資料庫的對比 + +​ 正如我們所看到的,Hadoop有點像Unix的分散式版本,其中HDFS是檔案系統,而MapReduce是Unix程序的怪異實現(總是在Map階段和Reduce階段執行`sort`工具)。我們瞭解瞭如何在這些原語的基礎上實現各種連線和分組操作。 + +​ 當MapReduce論文發表時【1】,它從某種意義上來說 —— 並不新鮮。我們在前幾節中討論的所有處理和並行連線演算法已經在十多年前所謂的**大規模並行處理(MPP, massively parallel processing)**資料庫中實現了【3,40】。比如Gamma database machine,Teradata和Tandem NonStop SQL就是這方面的先驅【52】。 + +​ 最大的區別是,MPP資料庫專注於在一組機器上並行執行分析SQL查詢,而MapReduce和分散式檔案系統【19】的組合則更像是一個可以執行任意程式的通用作業系統。 + +#### 儲存多樣性 + +​ 資料庫要求你根據特定的模型(例如關係或文件)來構造資料,而分散式檔案系統中的檔案只是位元組序列,可以使用任何資料模型和編碼來編寫。它們可能是資料庫記錄的集合,但同樣可以是文字,影象,影片,感測器讀數,稀疏矩陣,特徵向量,基因組序列或任何其他型別的資料。 + +​ 說白了,Hadoop開放了將資料不加區分地轉儲到HDFS的可能性,允許後續再研究如何進一步處理【53】。相比之下,在將資料匯入資料庫專有儲存格式之前,MPP資料庫通常需要對資料和查詢模式進行仔細的前期建模。 + +​ 在純粹主義者看來,這種仔細的建模和匯入似乎是可取的,因為這意味著資料庫的使用者有更高質量的資料來處理。然而實踐經驗表明,簡單地使資料快速可用 —— 即使它很古怪,難以使用,使用原始格式 —— 也通常要比事先決定理想資料模型要更有價值【54】。 + +​ 這個想法與資料倉庫類似(參閱“[資料倉庫](ch3.md#資料倉庫)”):將大型組織的各個部分的資料集中在一起是很有價值的,因為它可以跨越以前相分離的資料集進行連線。 MPP資料庫所要求的謹慎模式設計拖慢了集中式資料收集速度;以原始形式收集資料,稍後再操心模式的設計,能使資料收集速度加快(有時被稱為“**資料湖(data lake)**”或“**企業資料中心(enterprise data hub)**”【55】)。 + +​ 不加區分的資料轉儲轉移瞭解釋資料的負擔:資料集的生產者不再需要強制將其轉化為標準格式,資料的解釋成為消費者的問題(**讀時模式**方法【56】;參閱“[文件模型中的架構靈活性](ch2.md#文件模型中的架構靈活性)”)。如果生產者和消費者是不同優先順序的不同團隊,這可能是一種優勢。甚至可能不存在一個理想的資料模型,對於不同目的有不同的合適視角。以原始形式簡單地轉儲資料,可以允許多種這樣的轉換。這種方法被稱為**壽司原則(sushi principle)**:“原始資料更好”【57】。 + +​ 因此,Hadoop經常被用於實現ETL過程(參閱“[資料倉庫](ch3.md#資料倉庫)”):事務處理系統中的資料以某種原始形式轉儲到分散式檔案系統中,然後編寫MapReduce作業來清理資料,將其轉換為關係形式,並將其匯入MPP資料倉庫以進行分析。資料建模仍然在進行,但它在一個單獨的步驟中進行,與資料收集相解耦。這種解耦是可行的,因為分散式檔案系統支援以任何格式編碼的資料。 + +#### 處理模型多樣性 + +​ MPP資料庫是單體的,緊密整合的軟體,負責磁碟上的儲存佈局,查詢計劃,排程和執行。由於這些元件都可以針對資料庫的特定需求進行調整和最佳化,因此整個系統可以在其設計針對的查詢型別上取得非常好的效能。而且,SQL查詢語言允許以優雅的語法表達查詢,而無需編寫程式碼,使業務分析師用來做商業分析的視覺化工具(例如Tableau)能夠訪問。 + +​ 另一方面,並非所有型別的處理都可以合理地表達為SQL查詢。例如,如果要構建機器學習和推薦系統,或者使用相關性排名模型的全文搜尋索引,或者執行影象分析,則很可能需要更一般的資料處理模型。這些型別的處理通常是特別針對特定應用的(例如機器學習的特徵工程,機器翻譯的自然語言模型,欺詐預測的風險評估函式),因此它們不可避免地需要編寫程式碼,而不僅僅是查詢。 + +​ MapReduce使工程師能夠輕鬆地在大型資料集上執行自己的程式碼。如果你有HDFS和MapReduce,那麼你**可以**在它之上建立一個SQL查詢執行引擎,事實上這正是Hive專案所做的【31】。但是,你也可以編寫許多其他形式的批處理,這些批處理不必非要用SQL查詢表示。 + +​ 隨後,人們發現MapReduce對於某些型別的處理而言侷限性很大,表現很差,因此在Hadoop之上其他各種處理模型也被開發出來(我們將在“[MapReduce之後](#後MapReduce時代)”中看到其中一些)。有兩種處理模型,SQL和MapReduce,還不夠,需要更多不同的模型!而且由於Hadoop平臺的開放性,實施一整套方法是可行的,而這在單體MPP資料庫的範疇內是不可能的【58】。 + +​ 至關重要的是,這些不同的處理模型都可以在共享的單個機器叢集上執行,所有這些機器都可以訪問分散式檔案系統上的相同檔案。在Hadoop方法中,不需要將資料匯入到幾個不同的專用系統中進行不同型別的處理:系統足夠靈活,可以支援同一個群集內不同的工作負載。不需要移動資料,使得從資料中挖掘價值變得容易得多,也使採用新的處理模型容易的多。 + +​ Hadoop生態系統包括隨機訪問的OLTP資料庫,如HBase(參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)和MPP風格的分析型資料庫,如Impala 【41】。 HBase與Impala都不使用MapReduce,但都使用HDFS進行儲存。它們是迥異的資料訪問與處理方法,但是它們可以共存,並被整合到同一個系統中。 + +#### 針對頻繁故障設計 + +​ 當比較MapReduce和MPP資料庫時,兩種不同的設計思路出現了:處理故障和使用記憶體與磁碟的方式。與線上系統相比,批處理對故障不太敏感,因為就算失敗也不會立即影響到使用者,而且它們總是能再次執行。 + +​ 如果一個節點在執行查詢時崩潰,大多數MPP資料庫會中止整個查詢,並讓使用者重新提交查詢或自動重新執行它【3】。由於查詢通常最多執行幾秒鐘或幾分鐘,所以這種錯誤處理的方法是可以接受的,因為重試的代價不是太大。 MPP資料庫還傾向於在記憶體中保留儘可能多的資料(例如,使用雜湊連線)以避免從磁碟讀取的開銷。 + +​ 另一方面,MapReduce可以容忍單個Map或Reduce任務的失敗,而不會影響作業的整體,透過以單個任務的粒度重試工作。它也會非常急切地將資料寫入磁碟,一方面是為了容錯,另一部分是因為假設資料集太大而不能適應記憶體。 + +​ MapReduce方式更適用於較大的作業:要處理如此之多的資料並執行很長時間的作業,以至於在此過程中很可能至少遇到一個任務故障。在這種情況下,由於單個任務失敗而重新執行整個作業將是非常浪費的。即使以單個任務的粒度進行恢復引入了使得無故障處理更慢的開銷,但如果任務失敗率足夠高,這仍然是一種合理的權衡。 + +​ 但是這些假設有多麼現實呢?在大多數叢集中,機器故障確實會發生,但是它們不是很頻繁 —— 可能少到絕大多數作業都不會經歷機器故障。為了容錯,真的值得帶來這麼大的額外開銷嗎? + +​ 要了解MapReduce節約使用記憶體和在任務的層次進行恢復的原因,瞭解最初設計MapReduce的環境是很有幫助的。 Google有著混用的資料中心,線上生產服務和離線批處理作業在同樣機器上執行。每個任務都有一個透過容器強制執行的資源配給(CPU核心,RAM,磁碟空間等)。每個任務也具有優先順序,如果優先順序較高的任務需要更多的資源,則可以終止(搶佔)同一臺機器上較低優先順序的任務以釋放資源。優先順序還決定了計算資源的定價:團隊必須為他們使用的資源付費,而優先順序更高的程序花費更多【59】。 + +​ 這種架構允許非生產(低優先順序)計算資源被**過量使用(overcommitted)**,因為系統知道必要時它可以回收資源。與分離生產和非生產任務的系統相比,過量使用資源可以更好地利用機器並提高效率。但由於MapReduce作業以低優先順序執行,它們隨時都有被搶佔的風險,因為優先順序較高的程序可能需要其資源。在高優先順序程序拿走所需資源後,批次作業能有效地“撿麵包屑”,利用剩下的任何計算資源。 + +​ 在谷歌,執行一個小時的MapReduce任務有大約有5%的風險被終止,為了給更高優先順序的程序挪地方。這一概率比硬體問題,機器重啟或其他原因的概率高了一個數量級【59】。按照這種搶佔率,如果一個作業有100個任務,每個任務執行10分鐘,那麼至少有一個任務在完成之前被終止的風險大於50%。 + +​ 這就是MapReduce被設計為容忍頻繁意外任務終止的原因:不是因為硬體很不可靠,而是因為任意終止程序的自由有利於提高計算叢集中的資源利用率。 + +​ 在開源的叢集排程器中,搶佔的使用較少。 YARN的CapacityScheduler支援搶佔,以平衡不同佇列的資源分配【58】,但在編寫本文時,YARN,Mesos或Kubernetes不支援通用優先順序搶佔【60】。在任務不經常被終止的環境中,MapReduce的這一設計決策就沒有多少意義了。在下一節中,我們將研究一些與MapReduce設計決策相異的替代方案。 + + + +## MapReduce之後 + +​ 雖然MapReduce在二十世紀二十年代後期變得非常流行,並受到大量的炒作,但它只是分散式系統的許多可能的程式設計模型之一。對於不同的資料量,資料結構和處理型別,其他工具可能更適合表示計算。 + +​ 不管如何,我們在這一章花了大把時間來討論MapReduce,因為它是一種有用的學習工具,它是分散式檔案系統的一種相當簡單明晰的抽象。在這裡,**簡單**意味著我們能理解它在做什麼,而不是意味著使用它很簡單。恰恰相反:使用原始的MapReduce API來實現複雜的處理工作實際上是非常困難和費力的 —— 例如,任意一種連線演算法都需要你從頭開始實現【37】。 + +​ 針對直接使用MapReduce的困難,在MapReduce上有很多高階程式設計模型(Pig,Hive,Cascading,Crunch)被創造出來,作為建立在MapReduce之上的抽象。如果你瞭解MapReduce的原理,那麼它們學起來相當簡單。而且它們的高階結構能顯著簡化許多常見批處理任務的實現。 + +​ 但是,MapReduce執行模型本身也存在一些問題,這些問題並沒有透過增加另一個抽象層次而解決,而對於某些型別的處理,它表現得非常差勁。一方面,MapReduce非常穩健:你可以使用它在任務會頻繁終止的多租戶系統上處理幾乎任意大量級的資料,並且仍然可以完成工作(雖然速度很慢)。另一方面,對於某些型別的處理而言,其他工具有時會快上幾個數量級。 + +​ 在本章的其餘部分中,我們將介紹一些批處理方法。在[第11章](ch11.md)我們將轉向流處理,它可以看作是加速批處理的另一種方法。 + +### 物化中間狀態 + +​ 如前所述,每個MapReduce作業都獨立於其他任何作業。作業與世界其他地方的主要連線點是分散式檔案系統上的輸入和輸出目錄。如果希望一個作業的輸出成為第二個作業的輸入,則需要將第二個作業的輸入目錄配置為第一個作業輸出目錄,且外部工作流排程程式必須在第一個作業完成後再啟動第二個。 + +​ 如果第一個作業的輸出是要在組織內廣泛釋出的資料集,則這種配置是合理的。在這種情況下,你需要透過名稱引用它,並將其重用為多個不同作業的輸入(包括由其他團隊開發的作業)。將資料釋出到分散式檔案系統中眾所周知的位置能夠帶來**松耦合**,這樣作業就不需要知道是誰在提供輸入或誰在消費輸出(參閱“[邏輯與佈線相分離](#邏輯與佈線相分離)”)。 + +​ 但在很多情況下,你知道一個作業的輸出只能用作另一個作業的輸入,這些作業由同一個團隊維護。在這種情況下,分散式檔案系統上的檔案只是簡單的**中間狀態(intermediate state)**:一種將資料從一個作業傳遞到下一個作業的方式。在一個用於構建推薦系統的,由50或100個MapReduce作業組成的複雜工作流中,存在著很多這樣的中間狀態【29】。 + +​ 將這個中間狀態寫入檔案的過程稱為**物化(materialization)**。 (在“[聚合:資料立方體和物化檢視](ch2.md#聚合:資料立方體和物化檢視)”中已經在物化檢視的背景中遇到過這個術語。它意味著對某個操作的結果立即求值並寫出來,而不是在請求時按需計算) + +​ 作為對照,本章開頭的日誌分析示例使用Unix管道將一個命令的輸出與另一個命令的輸入連線起來。管道並沒有完全物化中間狀態,而是隻使用一個小的記憶體緩衝區,將輸出增量地**流(stream)**向輸入。 + +​ 與Unix管道相比,MapReduce完全物化中間狀態的方法存在不足之處: + +- MapReduce作業只有在前驅作業(生成其輸入)中的所有任務都完成時才能啟動,而由Unix管道連線的程序會同時啟動,輸出一旦生成就會被消費。不同機器上的資料傾斜或負載不均意味著一個作業往往會有一些掉隊的任務,比其他任務要慢得多才能完成。必須等待至前驅作業的所有任務完成,拖慢了整個工作流程的執行。 +- Mapper通常是多餘的:它們僅僅是讀取剛剛由Reducer寫入的同樣檔案,為下一個階段的分割槽和排序做準備。在許多情況下,Mapper程式碼可能是前驅Reducer的一部分:如果Reducer和Mapper的輸出有著相同的分割槽與排序方式,那麼Reducer就可以直接串在一起,而不用與Mapper相互交織。 +- 將中間狀態儲存在分散式檔案系統中意味著這些檔案被複制到多個節點,這些臨時資料這麼搞就比較過分了。 + +#### 資料流引擎 + +​ 瞭解決MapReduce的這些問題,幾種用於分散式批處理的新執行引擎被開發出來,其中最著名的是Spark 【61,62】,Tez 【63,64】和Flink 【65,66】。它們的設計方式有很多區別,但有一個共同點:把整個工作流作為單個作業來處理,而不是把它分解為獨立的子作業。 + +​ 由於它們將工作流顯式建模為 資料從幾個處理階段穿過,所以這些系統被稱為**資料流引擎(dataflow engines)**。像MapReduce一樣,它們在一條線上透過反覆呼叫使用者定義的函式來一次處理一條記錄,它們透過輸入分割槽來並行化載荷,它們透過網路將一個函式的輸出複製到另一個函式的輸入。 + +​ 與MapReduce不同,這些功能不需要嚴格扮演交織的Map與Reduce的角色,而是可以以更靈活的方式進行組合。我們稱這些函式為**運算元(operators)**,資料流引擎提供了幾種不同的選項來將一個運算元的輸出連線到另一個運算元的輸入: + +- 一種選項是對記錄按鍵重新分割槽並排序,就像在MapReduce的混洗階段一樣(參閱“[分散式執行MapReduce](#分散式執行MapReduce)”)。這種功能可以用於實現排序合併連線和分組,就像在MapReduce中一樣。 +- 另一種可能是接受多個輸入,並以相同的方式進行分割槽,但跳過排序。當記錄的分割槽重要但順序無關緊要時,這省去了分割槽雜湊連線的工作,因為構建散列表還是會把順序隨機打亂。 +- 對於廣播雜湊連線,可以將一個運算元的輸出,傳送到連線運算元的所有分割槽。 + +這種型別的處理引擎是基於像Dryad 【67】和Nephele 【68】這樣的研究系統,與MapReduce模型相比,它有幾個優點: + +- 排序等昂貴的工作只需要在實際需要的地方執行,而不是預設地在每個Map和Reduce階段之間出現。 +- 沒有不必要的Map任務,因為Mapper所做的工作通常可以合併到前面的Reduce運算元中(因為Mapper不會更改資料集的分割槽)。 +- 由於工作流中的所有連線和資料依賴都是顯式宣告的,因此排程程式能夠總覽全域性,知道哪裡需要哪些資料,因而能夠利用區域性性進行最佳化。例如,它可以嘗試將消費某些資料的任務放在與生成這些資料的任務相同的機器上,從而資料可以透過共享記憶體緩衝區傳輸,而不必透過網路複製。 +- 通常,運算元間的中間狀態足以儲存在記憶體中或寫入本地磁碟,這比寫入HDFS需要更少的I/O(必須將其複製到多臺機器,並將每個副本寫入磁碟)。 MapReduce已經對Mapper的輸出做了這種最佳化,但資料流引擎將這種思想推廣至所有的中間狀態。 +- 運算元可以在輸入就緒後立即開始執行;後續階段無需等待前驅階段整個完成後再開始。 +- 與MapReduce(為每個任務啟動一個新的JVM)相比,現有Java虛擬機器(JVM)程序可以重用來執行新運算元,從而減少啟動開銷。 + +你可以使用資料流引擎執行與MapReduce工作流同樣的計算,而且由於此處所述的最佳化,通常執行速度要明顯快得多。既然運算元是Map和Reduce的泛化,那麼相同的處理程式碼就可以在任一執行引擎上執行:Pig,Hive或Cascading中實現的工作流可以無需修改程式碼,可以透過修改配置,簡單地從MapReduce切換到Tez或Spark【64】。 + +​ Tez是一個相當薄的庫,它依賴於YARN shuffle服務來實現節點間資料的實際複製【58】,而Spark和Flink則是包含了獨立網路通訊層,排程器,及使用者向API的大型框架。我們將簡要討論這些高階API。 + +#### 容錯 + +​ 完全物化中間狀態至分散式檔案系統的一個優點是,它具有永續性,這使得MapReduce中的容錯相當容易:如果一個任務失敗,它可以在另一臺機器上重新啟動,並從檔案系統重新讀取相同的輸入。 + +​ Spark,Flink和Tez避免將中間狀態寫入HDFS,因此它們採取了不同的方法來容錯:如果一臺機器發生故障,並且該機器上的中間狀態丟失,則它會從其他仍然可用的資料重新計算(在可行的情況下是先前的中間狀態,要麼就只能是原始輸入資料,通常在HDFS上)。 + +​ 為了實現這種重新計算,框架必須跟蹤一個給定的資料是如何計算的 —— 使用了哪些輸入分割槽?應用了哪些運算元? Spark使用**彈性分散式資料集(RDD)**的抽象來跟蹤資料的譜系【61】,而Flink對運算元狀態存檔,允許恢復執行在執行過程中遇到錯誤的運算元【66】。 + +​ 在重新計算資料時,重要的是要知道計算是否是**確定性的**:也就是說,給定相同的輸入資料,運算元是否始終產生相同的輸出?如果一些丟失的資料已經發送給下游運算元,這個問題就很重要。如果運算元重新啟動,重新計算的資料與原有的丟失資料不一致,下游運算元很難解決新舊資料之間的矛盾。對於不確定性運算元來說,解決方案通常是殺死下游運算元,然後再重跑新資料。 + +​ 為了避免這種級聯故障,最好讓運算元具有確定性。但需要注意的是,非確定性行為很容易悄悄溜進來:例如,許多程式語言在迭代雜湊表的元素時不能對順序作出保證,許多概率和統計演算法顯式依賴於使用隨機數,以及用到系統時鐘或外部資料來源,這些都是都不確定性的行為。為了能可靠地從故障中恢復,需要消除這種不確定性因素,例如使用固定的種子生成偽隨機數。 + +​ 透過重算資料來從故障中恢復並不總是正確的答案:如果中間狀態資料要比源資料小得多,或者如果計算量非常大,那麼將中間資料物化為檔案可能要比重新計算廉價的多。 + +#### 關於物化的討論 + +​ 回到Unix的類比,我們看到,MapReduce就像是將每個命令的輸出寫入臨時檔案,而資料流引擎看起來更像是Unix管道。尤其是Flink是基於管道執行的思想而建立的:也就是說,將運算元的輸出增量地傳遞給其他運算元,不待輸入完成便開始處理。 + +​ 排序運算元不可避免地需要消費全部的輸入後才能生成任何輸出,因為輸入中最後一條輸入記錄可能具有最小的鍵,因此需要作為第一條記錄輸出。因此,任何需要排序的運算元都需要至少暫時地累積狀態。但是工作流的許多其他部分可以以流水線方式執行。 + +​ 當作業完成時,它的輸出需要持續到某個地方,以便使用者可以找到並使用它—— 很可能它會再次寫入分散式檔案系統。因此,在使用資料流引擎時,HDFS上的物化資料集通常仍是作業的輸入和最終輸出。和MapReduce一樣,輸入是不可變的,輸出被完全替換。比起MapReduce的改進是,你不用再自己去將中間狀態寫入檔案系統了。 + +### 圖與迭代處理 + +​ 在“[圖資料模型](ch2.md#圖資料模型)”中,我們討論了使用圖來建模資料,並使用圖查詢語言來遍歷圖中的邊與點。[第2章](ch2.md)的討論集中在OLTP風格的應用場景:快速執行查詢來查詢少量符合特定條件的頂點。 + +​ 批處理上下文中的圖也很有趣,其目標是在整個圖上執行某種離線處理或分析。這種需求經常出現在機器學習應用(如推薦引擎)或排序系統中。例如,最著名的圖形分析演算法之一是PageRank 【69】,它試圖根據連結到某個網頁的其他網頁來估計該網頁的流行度。它作為配方的一部分,用於確定網路搜尋引擎呈現結果的順序 + +> 像Spark,Flink和Tez這樣的資料流引擎(參見“[中間狀態的物化](#中間狀態的物化)”)通常將運算元作為**有向無環圖(DAG)**的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流**被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂! + +​ 許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**閉包傳遞(transitive closure)**)。 + +​ 可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種“重複至完成”的想法不能用普通的MapReduce來表示,因為它只掃過一趟資料。這種演算法因此經常以**迭代**的風格實現: + +1. 外部排程程式執行批處理來計算演算法的一個步驟。 +2. 當批處理過程完成時,排程器檢查它是否完成(基於完成條件 —— 例如,沒有更多的邊要跟進,或者與上次迭代相比的變化低於某個閾值)。 +3. 如果尚未完成,則排程程式返回到步驟1並執行另一輪批處理。 + +這種方法是有效的,但是用MapReduce實現它往往非常低效,因為MapReduce沒有考慮演算法的迭代性質:它總是讀取整個輸入資料集併產生一個全新的輸出資料集,即使與上次迭代相比,改變的僅僅是圖中的一小部分。 + +#### Pregel處理模型 + +​ 針對圖批處理的最佳化 —— **批次同步並行(BSP)**計算模型【70】已經開始流行起來。其中,Apache Giraph 【37】,Spark的GraphX API和Flink的Gelly API 【71】實現了它。它也被稱為**Pregel**模型,因為Google的Pregel論文推廣了這種處理圖的方法【72】。 + +​ 回想一下在MapReduce中,Mapper在概念上向Reducer的特定呼叫“傳送訊息”,因為框架將所有具有相同鍵的Mapper輸出集中在一起。 Pregel背後有一個類似的想法:一個頂點可以向另一個頂點“傳送訊息”,通常這些訊息是沿著圖的邊傳送的。 + +​ 在每次迭代中,為每個頂點呼叫一個函式,將所有傳送給它的訊息傳遞給它 —— 就像呼叫Reducer一樣。與MapReduce的不同之處在於,在Pregel模型中,頂點在一次迭代到下一次迭代的過程中會記住它的狀態,所以這個函式只需要處理新的傳入訊息。如果圖的某個部分沒有被髮送訊息,那裡就不需要做任何工作。 + +​ 這與Actor模型有些相似(參閱“[分散式的Actor框架](ch4.md#分散式的Actor框架)”),除了頂點狀態和頂點之間的訊息具有容錯性和耐久性,且通訊以固定的方式進行:在每次迭代中,框架遞送上次迭代中傳送的所有訊息。Actor通常沒有這樣的時間保證。 + +#### 容錯 + +​ 頂點只能透過訊息傳遞進行通訊(而不是直接相互查詢)的事實有助於提高Pregel作業的效能,因為訊息可以成批處理,且等待通訊的次數也減少了。唯一的等待是在迭代之間:由於Pregel模型保證所有在一輪迭代中傳送的訊息都在下輪迭代中送達,所以在下一輪迭代開始前,先前的迭代必須完全完成,而所有的訊息必須在網路上完成複製。 + +​ 即使底層網路可能丟失,重複或任意延遲訊息(參閱“[不可靠的網路](ch8.md#不可靠的網路)”),Pregel的實現能保證在後續迭代中訊息在其目標頂點恰好處理一次。像MapReduce一樣,框架能從故障中透明地恢復,以簡化在Pregel上實現演算法的程式設計模型。 + +​ 這種容錯是透過在迭代結束時,定期存檔所有頂點的狀態來實現的,即將其全部狀態寫入持久化儲存。如果某個節點發生故障並且其記憶體中的狀態丟失,則最簡單的解決方法是將整個圖計算回滾到上一個存檔點,然後重啟計算。如果演算法是確定性的,且訊息記錄在日誌中,那麼也可以選擇性地只恢復丟失的分割槽(就像之前討論過的資料流引擎)【72】。 + +#### 並行執行 + +​ 頂點不需要知道它在哪臺物理機器上執行;當它向其他頂點發送訊息時,它只是簡單地將訊息發往某個頂點ID。圖的分割槽取決於框架 —— 即,確定哪個頂點執行在哪臺機器上,以及如何透過網路路由訊息,以便它們到達正確的地方。 + +​ 由於程式設計模型一次僅處理一個頂點(有時稱為“像頂點一樣思考”),所以框架可以以任意方式對圖分割槽。理想情況下如果頂點需要進行大量的通訊,那麼它們最好能被分割槽到同一臺機器上。然而找到這樣一種最佳化的分割槽方法是很困難的 —— 在實踐中,圖經常按照任意分配的頂點ID分割槽,而不會嘗試將相關的頂點分組在一起。 + +​ 因此,圖演算法通常會有很多跨機器通訊的額外開銷,而中間狀態(節點之間傳送的訊息)往往比原始圖大。透過網路傳送訊息的開銷會顯著拖慢分散式圖演算法的速度。 + +​ 出於這個原因,如果你的圖可以放入一臺計算機的記憶體中,那麼單機(甚至可能是單執行緒)演算法很可能會超越分散式批處理【73,74】。圖比記憶體大也沒關係,只要能放入單臺計算機的磁碟,使用GraphChi等框架進行單機處理是就一個可行的選擇【75】。如果圖太大,不適合單機處理,那麼像Pregel這樣的分散式方法是不可避免的。高效的並行圖演算法是一個進行中的研究領域【76】。 + + + +### 高階API和語言 + +​ 自MapReduce開始流行的這幾年以來,分散式批處理的執行引擎已經很成熟了。到目前為止,基礎設施已經足夠強大,能夠儲存和處理超過10,000臺機器群集上的數PB的資料。由於在這種規模下物理執行批處理的問題已經被認為或多或少解決了,所以關注點已經轉向其他領域:改進程式設計模型,提高處理效率,擴大這些技術可以解決的問題集。 + +​ 如前所述,Hive,Pig,Cascading和Crunch等高階語言和API變得越來越流行,因為手寫MapReduce作業實在是個苦力活。隨著Tez的出現,這些高階語言還有一個額外好處,可以遷移到新的資料流執行引擎,而無需重寫作業程式碼。 Spark和Flink也有它們自己的高階資料流API,通常是從FlumeJava中獲取的靈感【34】。 + +​ 這些資料流API通常使用關係型構建塊來表達一個計算:按某個欄位連線資料集;按鍵對元組做分組;按某些條件過濾;並透過計數求和或其他函式來聚合元組。在內部,這些操作是使用本章前面討論過的各種連線和分組演算法來實現的。 + +​ 除了少寫程式碼的明顯優勢之外,這些高階介面還支援互動式用法,在這種互動式使用中,你可以在Shell中增量式編寫分析程式碼,頻繁執行來觀察它做了什麼。這種開發風格在探索資料集和試驗處理方法時非常有用。這也讓人聯想到Unix哲學,我們在“[Unix哲學](#Unix哲學)”中討論過這個問題。 + +​ 此外,這些高階介面不僅提高了人類的工作效率,也提高了機器層面的作業執行效率。 + +#### 向宣告式查詢語言的轉變 + +​ 與硬寫執行連線的程式碼相比,指定連線關係運算元的優點是,框架可以分析連線輸入的屬性,並自動決定哪種上述連線演算法最適合當前任務。 Hive,Spark和Flink都有基於代價的查詢最佳化器可以做到這一點,甚至可以改變連線順序,最小化中間狀態的數量【66,77,78,79】。 + +​ 連線演算法的選擇可以對批處理作業的效能產生巨大影響,而無需理解和記住本章中討論的各種連線演算法。如果連線是以**宣告式(declarative)**的方式指定的,那這就這是可行的:應用只是簡單地說明哪些連線是必需的,查詢最佳化器決定如何最好地執行連線。我們以前在“[資料查詢語言](ch2.md#資料查詢語言)”中見過這個想法。 + +​ 但MapReduce及其後繼者資料流在其他方面,與SQL的完全宣告式查詢模型有很大區別。 MapReduce是圍繞著回撥函式的概念建立的:對於每條記錄或者一組記錄,呼叫一個使用者定義的函式(Mapper或Reducer),並且該函式可以自由地呼叫任意程式碼來決定輸出什麼。這種方法的優點是可以基於大量已有庫的生態系統創作:解析,自然語言分析,影象分析以及執行數值演算法或統計演算法等。 + +​ 自由執行任意程式碼,長期以來都是傳統MapReduce批處理系統與MPP資料庫的區別所在(參見“[比較Hadoop和分散式資料庫](#比較Hadoop和分散式資料庫)”一節)。雖然資料庫具有編寫使用者定義函式的功能,但是它們通常使用起來很麻煩,而且與大多數程式語言中廣泛使用的程式包管理器和依賴管理系統相容不佳(例如Java的Maven, Javascript的npm,以及Ruby的gems)。 + +​ 然而資料流引擎已經發現,支援除連線之外的更多**宣告式特性**還有其他的優勢。例如,如果一個回撥函式只包含一個簡單的過濾條件,或者只是從一條記錄中選擇了一些欄位,那麼在為每條記錄呼叫函式時會有相當大的額外CPU開銷。如果以宣告方式表示這些簡單的過濾和對映操作,那麼查詢最佳化器可以利用面向列的儲存佈局(參閱“[列儲存](ch3.md#列儲存)”),只從磁碟讀取所需的列。 Hive,Spark DataFrames和Impala還使用了向量化執行(參閱“[記憶體頻寬和向量處理](ch3.md#記憶體頻寬和向量處理)”):在對CPU快取友好的內部迴圈中迭代資料,避免函式呼叫。Spark生成JVM位元組碼【79】,Impala使用LLVM為這些內部迴圈生成本機程式碼【41】。 + +​ 透過在高階API中引入宣告式的部分,並使查詢最佳化器可以在執行期間利用這些來做最佳化,批處理框架看起來越來越像MPP資料庫了(並且能實現可與之媲美的效能)。同時,透過擁有執行任意程式碼和以任意格式讀取資料的可擴充套件性,它們保持了靈活性的優勢。 + +#### 專業化的不同領域 + +​ 儘管能夠執行任意程式碼的可擴充套件性是很有用的,但是也有很多常見的例子,不斷重複著標準的處理模式。因而這些模式值得擁有自己的可重用通用構建模組實現,傳統上,MPP資料庫滿足了商業智慧分析和業務報表的需求,但這只是許多使用批處理的領域之一。 + +​ 另一個越來越重要的領域是統計和數值演算法,它們是機器學習應用所需要的(例如分類器和推薦系統)。可重用的實現正在出現:例如,Mahout在MapReduce,Spark和Flink之上實現了用於機器學習的各種演算法,而MADlib在關係型MPP資料庫(Apache HAWQ)中實現了類似的功能【54】。 + +​ 空間演算法也是有用的,例如**最近鄰搜尋(k-nearest neghbors, kNN)**【80】,它在一些多維空間中搜索與給定項最近的專案 —— 這是一種相似性搜尋。近似搜尋對於基因組分析演算法也很重要,它們需要找到相似但不相同的字串【81】。 + +​ 批處理引擎正被用於分散式執行日益廣泛的各領域演算法。隨著批處理系統獲得各種內建功能以及高階宣告式運算元,且隨著MPP資料庫變得更加靈活和易於程式設計,兩者開始看起來相似了:最終,它們都只是儲存和處理資料的系統。 + + + +## 本章小結 + +​ 在本章中,我們探索了批處理的主題。我們首先看到了諸如awk,grep和sort之類的Unix工具,然後我們看到了這些工具的設計理念是如何應用到MapReduce和更近的資料流引擎中的。一些設計原則包括:輸入是不可變的,輸出是為了作為另一個(仍未知的)程式的輸入,而複雜的問題是透過編寫“做好一件事”的小工具來解決的。 + +​ 在Unix世界中,允許程式與程式組合的統一介面是檔案與管道;在MapReduce中,該介面是一個分散式檔案系統。我們看到資料流引擎添加了自己的管道式資料傳輸機制,以避免將中間狀態物化至分散式檔案系統,但作業的初始輸入和最終輸出通常仍是HDFS。 + +​ 分散式批處理框架需要解決的兩個主要問題是: + +***分割槽*** + +​ 在MapReduce中,Mapper根據輸入檔案塊進行分割槽。Mapper的輸出被重新分割槽,排序,併合併到可配置數量的Reducer分割槽中。這一過程的目的是把所有的**相關**資料(例如帶有相同鍵的所有記錄)都放在同一個地方。 + +​ 後MapReduce時代的資料流引擎若非必要會盡量避免排序,但它們也採取了大致類似的分割槽方法。 + +***容錯*** + +​ MapReduce經常寫入磁碟,這使得從單個失敗的任務恢復很輕鬆,無需重新啟動整個作業,但在無故障的情況下減慢了執行速度。資料流引擎更多地將中間狀態儲存在記憶體中,更少地物化中間狀態,這意味著如果節點發生故障,則需要重算更多的資料。確定性運算元減少了需要重算的資料量。 + + + +​ 我們討論了幾種MapReduce的連線演算法,其中大多數也在MPP資料庫和資料流引擎內部使用。它們也很好地演示了分割槽演算法是如何工作的: + +***排序合併連線*** + +​ 每個參與連線的輸入都透過一個提取連線鍵的Mapper。透過分割槽,排序和合並,具有相同鍵的所有記錄最終都會進入相同的Reducer呼叫。這個函式能輸出連線好的記錄。 + +***廣播雜湊連線*** + +​ 兩個連線輸入之一很小,所以它並沒有分割槽,而且能被完全載入進一個雜湊表中。因此,你可以為連線輸入大端的每個分割槽啟動一個Mapper,將輸入小端的散列表載入到每個Mapper中,然後掃描大端,一次一條記錄,併為每條記錄查詢散列表。 + +***分割槽雜湊連線*** + +​ 如果兩個連線輸入以相同的方式分割槽(使用相同的鍵,相同的雜湊函式和相同數量的分割槽),則可以獨立地對每個分割槽應用散列表方法。 + +​ 分散式批處理引擎有一個刻意限制的程式設計模型:回撥函式(比如Mapper和Reducer)被假定是無狀態的,而且除了指定的輸出外,必須沒有任何外部可見的副作用。這一限制允許框架在其抽象下隱藏一些困難的分散式系統問題:當遇到崩潰和網路問題時,任務可以安全地重試,任何失敗任務的輸出都被丟棄。如果某個分割槽的多個任務成功,則其中只有一個能使其輸出實際可見。 + +​ 得益於這個框架,你在批處理作業中的程式碼無需操心實現容錯機制:框架可以保證作業的最終輸出與沒有發生錯誤的情況相同,也許不得不重試各種任務。線上服務處理使用者請求,並將寫入資料庫作為處理請求的副作用,比起線上服務,批處理提供的這種可靠性語義要強得多。 + +​ 批處理作業的顯著特點是,它讀取一些輸入資料併產生一些輸出資料,但不修改輸入—— 換句話說,輸出是從輸入衍生出的。最關鍵的是,輸入資料是**有界的(bounded)**:它有一個已知的,固定的大小(例如,它包含一些時間點的日誌檔案或資料庫內容的快照)。因為它是有界的,一個作業知道自己什麼時候完成了整個輸入的讀取,所以一個工作在做完後,最終總是會完成的。 + +​ 在下一章中,我們將轉向流處理,其中的輸入是**無界的(unbounded)** —— 也就是說,你還有活兒要幹,然而它的輸入是永無止境的資料流。在這種情況下,作業永無完成之日。因為在任何時候都可能有更多的工作湧入。我們將看到,在某些方面上,流處理和批處理是相似的。但是關於無盡資料流的假設也對我們構建系統的方式產生了很多改變。 + + + + + +## 參考文獻 + +1. Jeffrey Dean and Sanjay Ghemawat: “[MapReduce: Simplified Data Processing on Large Clusters](http://research.google.com/archive/mapreduce.html),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004. + +1. Joel Spolsky: “[The Perils of JavaSchools](http://www.joelonsoftware.com/articles/ThePerilsofJavaSchools.html),” *joelonsoftware.com*, December 25, 2005. + +1. Shivnath Babu and Herodotos Herodotou: “[Massively Parallel Databases and MapReduce Systems](http://research.microsoft.com/pubs/206464/db-mr-survey-final.pdf),” *Foundations and Trends in Databases*, volume 5, number 1, pages 1–104, November 2013. [doi:10.1561/1900000036](http://dx.doi.org/10.1561/1900000036) + +1. David J. DeWitt and Michael Stonebraker: “[MapReduce: A Major Step Backwards](https://homes.cs.washington.edu/~billhowe/mapreduce_a_major_step_backwards.html),” originally published at *databasecolumn.vertica.com*, January 17, 2008. + +1. Henry Robinson: “[The Elephant Was a Trojan Horse: On the Death of Map-Reduce at Google](http://the-paper-trail.org/blog/the-elephant-was-a-trojan-horse-on-the-death-of-map-reduce-at-google/),” + *the-paper-trail.org*, June 25, 2014. + +1. “[The Hollerith Machine](https://www.census.gov/history/www/innovations/technology/the_hollerith_tabulator.html),” United States Census Bureau, *census.gov*. + +1. “[IBM 82, 83, and 84 Sorters Reference Manual](http://www.textfiles.com/bitsavers/pdf/ibm/punchedCard/Sorter/A24-1034-1_82-83-84_sorters.pdf),” Edition A24-1034-1, International Business + Machines Corporation, July 1962. + +1. Adam Drake: “[Command-Line Tools Can Be 235x Faster than Your Hadoop Cluster](http://aadrake.com/command-line-tools-can-be-235x-faster-than-your-hadoop-cluster.html),” *aadrake.com*, January 25, 2014. + +1. “[GNU Coreutils 8.23 Documentation](http://www.gnu.org/software/coreutils/manual/html_node/index.html),” Free Software Foundation, Inc., 2014. + +1. Martin Kleppmann: “[Kafka, Samza, and the Unix Philosophy of Distributed Data](http://martin.kleppmann.com/2015/08/05/kafka-samza-unix-philosophy-distributed-data.html),” *martin.kleppmann.com*, August 5, 2015. + +1. Doug McIlroy:[Internal Bell Labs memo](http://cm.bell-labs.com/cm/cs/who/dmr/mdmpipe.pdf), October 1964. Cited in: Dennis M. Richie: “[Advice from Doug McIlroy](https://www.bell-labs.com/usr/dmr/www/mdmpipe.html),” *cm.bell-labs.com*. + +1. M. D. McIlroy, E. N. Pinson, and B. A. Tague: “[UNIX Time-Sharing System: Foreword](https://archive.org/details/bstj57-6-1899),” *The Bell System Technical Journal*, volume 57, number 6, pages 1899–1904, July 1978. + +1. Eric S. Raymond: *The Art of UNIX Programming*. Addison-Wesley, 2003. ISBN: 978-0-13-142901-7 + +1. Ronald Duncan: “[Text File Formats – ASCII Delimited Text – Not CSV or TAB Delimited Text](https://ronaldduncan.wordpress.com/2009/10/31/text-file-formats-ascii-delimited-text-not-csv-or-tab-delimited-text/),” + *ronaldduncan.wordpress.com*, October 31, 2009. + +1. Alan Kay: “[Is 'Software Engineering' an Oxymoron?](http://tinlizzie.org/~takashi/IsSoftwareEngineeringAnOxymoron.pdf),” *tinlizzie.org*. + +1. Martin Fowler: “[InversionOfControl](http://martinfowler.com/bliki/InversionOfControl.html),” *martinfowler.com*, June 26, 2005. + +1. Daniel J. Bernstein: “[Two File Descriptors for Sockets](http://cr.yp.to/tcpip/twofd.html),” *cr.yp.to*. + +1. Rob Pike and Dennis M. Ritchie: “[The Styx Architecture for Distributed Systems](http://doc.cat-v.org/inferno/4th_edition/styx),” *Bell Labs Technical Journal*, volume 4, number 2, pages 146–152, April 1999. + +1. Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung: “[The Google File System](http://research.google.com/archive/gfs-sosp2003.pdf),” at *19th ACM Symposium on Operating Systems Principles* (SOSP), October 2003. [doi:10.1145/945445.945450](http://dx.doi.org/10.1145/945445.945450) + +1. Michael Ovsiannikov, Silvius Rus, Damian Reeves, et al.: “[The Quantcast File System](http://db.disi.unitn.eu/pages/VLDBProgram/pdf/industry/p808-ovsiannikov.pdf),” *Proceedings of the VLDB Endowment*, volume 6, number 11, pages 1092–1101, August 2013. [doi:10.14778/2536222.2536234](http://dx.doi.org/10.14778/2536222.2536234) + +1. “[OpenStack Swift 2.6.1 Developer Documentation](http://docs.openstack.org/developer/swift/),” OpenStack Foundation, *docs.openstack.org*, March 2016. + +1. Zhe Zhang, Andrew Wang, Kai Zheng, et al.: “[Introduction to HDFS Erasure Coding in Apache Hadoop](http://blog.cloudera.com/blog/2015/09/introduction-to-hdfs-erasure-coding-in-apache-hadoop/),” *blog.cloudera.com*, September 23, 2015. + +1. Peter Cnudde: “[Hadoop Turns 10](http://yahoohadoop.tumblr.com/post/138739227316/hadoop-turns-10),” *yahoohadoop.tumblr.com*, February 5, 2016. + +1. Eric Baldeschwieler: “[Thinking About the HDFS vs. Other Storage Technologies](http://hortonworks.com/blog/thinking-about-the-hdfs-vs-other-storage-technologies/),” *hortonworks.com*, July 25, 2012. + +1. Brendan Gregg: “[Manta: Unix Meets Map Reduce](http://dtrace.org/blogs/brendan/2013/06/25/manta-unix-meets-map-reduce/),” *dtrace.org*, June 25, 2013. + +1. Tom White: *Hadoop: The Definitive Guide*, 4th edition. O'Reilly Media, 2015. ISBN: 978-1-491-90163-2 + +1. Jim N. Gray: “[Distributed Computing Economics](http://arxiv.org/pdf/cs/0403019.pdf),” Microsoft Research Tech Report MSR-TR-2003-24, March 2003. + +1. Márton Trencséni: “[Luigi vs Airflow vs Pinball](http://bytepawn.com/luigi-airflow-pinball.html),” *bytepawn.com*, February 6, 2016. + +1. Roshan Sumbaly, Jay Kreps, and Sam Shah: “[The 'Big Data' Ecosystem at LinkedIn](http://www.slideshare.net/s_shah/the-big-data-ecosystem-at-linkedin-23512853),” at *ACM International Conference on Management of Data (SIGMOD)*, July 2013. [doi:10.1145/2463676.2463707](http://dx.doi.org/10.1145/2463676.2463707) + +1. Alan F. Gates, Olga Natkovich, Shubham Chopra, et al.: “[Building a High-Level Dataflow System on Top of Map-Reduce: The Pig Experience](http://www.vldb.org/pvldb/2/vldb09-1074.pdf),” at *35th International Conference on Very Large Data Bases* (VLDB), August 2009. + +1. Ashish Thusoo, Joydeep Sen Sarma, Namit Jain, et al.: “[Hive – A Petabyte Scale Data Warehouse Using Hadoop](http://i.stanford.edu/~ragho/hive-icde2010.pdf),” at *26th IEEE International Conference on Data Engineering* (ICDE), March 2010. [doi:10.1109/ICDE.2010.5447738](http://dx.doi.org/10.1109/ICDE.2010.5447738) + +1. “[Cascading 3.0 User Guide](http://docs.cascading.org/cascading/3.0/userguide/),” Concurrent, Inc., *docs.cascading.org*, January 2016. + +1. “[Apache Crunch User Guide](https://crunch.apache.org/user-guide.html),” Apache Software Foundation, *crunch.apache.org*. + +1. Craig Chambers, Ashish Raniwala, Frances Perry, et al.: “[FlumeJava: Easy, Efficient Data-Parallel Pipelines](https://research.google.com/pubs/archive/35650.pdf),” at *31st ACM SIGPLAN Conference on Programming Language Design and Implementation* (PLDI), June 2010. [doi:10.1145/1806596.1806638](http://dx.doi.org/10.1145/1806596.1806638) + +1. Jay Kreps: “[Why Local State is a Fundamental Primitive in Stream Processing](https://www.oreilly.com/ideas/why-local-state-is-a-fundamental-primitive-in-stream-processing),” *oreilly.com*, July 31, 2014. + +1. Martin Kleppmann: “[Rethinking Caching in Web Apps](http://martin.kleppmann.com/2012/10/01/rethinking-caching-in-web-apps.html),” *martin.kleppmann.com*, October 1, 2012. + +1. Mark Grover, Ted Malaska, Jonathan Seidman, and Gwen Shapira: *[Hadoop Application Architectures](http://shop.oreilly.com/product/0636920033196.do)*. O'Reilly Media, 2015. ISBN: 978-1-491-90004-8 + +1. Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, et al.: “[Challenges to Adopting Stronger Consistency at Scale](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-ajoux.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015. + +1. Sriranjan Manjunath: “[Skewed Join](https://wiki.apache.org/pig/PigSkewedJoinSpec),” *wiki.apache.org*, 2009. + +1. David J. DeWitt, Jeffrey F. Naughton, Donovan A.Schneider, and S. Seshadri: “[Practical Skew Handling in Parallel Joins](http://www.vldb.org/conf/1992/P027.PDF),” at *18th International Conference on Very Large Data Bases* (VLDB), August 1992. + +1. Marcel Kornacker, Alexander Behm, Victor Bittorf, et al.: “[Impala: A Modern, Open-Source SQL Engine for Hadoop](http://pandis.net/resources/cidr15impala.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015. + +1. Matthieu Monsch: “[Open-Sourcing PalDB, a Lightweight Companion for Storing Side Data](https://engineering.linkedin.com/blog/2015/10/open-sourcing-paldb--a-lightweight-companion-for-storing-side-da),” *engineering.linkedin.com*, October 26, 2015. + +1. Daniel Peng and Frank Dabek: “[Large-Scale Incremental Processing Using Distributed Transactions and Notifications](https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Peng.pdf),” at *9th USENIX conference on Operating Systems Design and Implementation* (OSDI), October 2010. + +1. “["Cloudera Search User Guide,"](http://www.cloudera.com/documentation/cdh/5-1-x/Search/Cloudera-Search-User-Guide/Cloudera-Search-User-Guide.html) Cloudera, Inc., September 2015. + +1. Lili Wu, Sam Shah, Sean Choi, et al.: + “[The Browsemaps: Collaborative Filtering at LinkedIn](http://ls13-www.cs.uni-dortmund.de/homepage/rsweb2014/papers/rsweb2014_submission_3.pdf),” at *6th Workshop on Recommender Systems and the Social Web* (RSWeb), October 2014. + +1. Roshan Sumbaly, Jay Kreps, Lei Gao, et al.: “[Serving Large-Scale Batch Computed Data with Project Voldemort](http://static.usenix.org/events/fast12/tech/full_papers/Sumbaly.pdf),” at *10th USENIX Conference on File and Storage Technologies* (FAST), February 2012. + +1. Varun Sharma: “[Open-Sourcing Terrapin: A Serving System for Batch Generated Data](https://engineering.pinterest.com/blog/open-sourcing-terrapin-serving-system-batch-generated-data-0),” *engineering.pinterest.com*, September 14, 2015. + +1. Nathan Marz: “[ElephantDB](http://www.slideshare.net/nathanmarz/elephantdb),” *slideshare.net*, May 30, 2011. + +1. Jean-Daniel (JD) Cryans: “[How-to: Use HBase Bulk Loading, and Why](http://blog.cloudera.com/blog/2013/09/how-to-use-hbase-bulk-loading-and-why/),” *blog.cloudera.com*, September 27, 2013. + +1. Nathan Marz: “[How to Beat the CAP Theorem](http://nathanmarz.com/blog/how-to-beat-the-cap-theorem.html),” *nathanmarz.com*, October 13, 2011. + +1. Molly Bartlett Dishman and Martin Fowler: “[Agile Architecture](http://conferences.oreilly.com/software-architecture/sa2015/public/schedule/detail/40388),” at *O'Reilly Software Architecture Conference*, March 2015. + +1. David J. DeWitt and Jim N. Gray: “[Parallel Database Systems: The Future of High Performance Database Systems](http://www.cs.cmu.edu/~pavlo/courses/fall2013/static/papers/dewittgray92.pdf),” *Communications of the ACM*, volume 35, number 6, pages 85–98, June 1992. [doi:10.1145/129888.129894](http://dx.doi.org/10.1145/129888.129894) + +1. Jay Kreps: “[But the multi-tenancy thing is actually really really hard](https://twitter.com/jaykreps/status/528235702480142336),” tweetstorm, *twitter.com*, October 31, 2014. + +1. Jeffrey Cohen, Brian Dolan, Mark Dunlap, et al.: “[MAD Skills: New Analysis Practices for Big Data](http://www.vldb.org/pvldb/2/vldb09-219.pdf),” *Proceedings of the VLDB Endowment*, volume 2, number 2, pages 1481–1492, August 2009. [doi:10.14778/1687553.1687576](http://dx.doi.org/10.14778/1687553.1687576) + +1. Ignacio Terrizzano, Peter Schwarz, Mary Roth, and John E. Colino: “[Data Wrangling: The Challenging Journey from the Wild to the Lake](http://cidrdb.org/cidr2015/Papers/CIDR15_Paper2.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015. + +1. Paige Roberts: “[To Schema on Read or to Schema on Write, That Is the Hadoop Data Lake Question](http://adaptivesystemsinc.com/blog/to-schema-on-read-or-to-schema-on-write-that-is-the-hadoop-data-lake-question/),” *adaptivesystemsinc.com*, July 2, 2015. + +1. Bobby Johnson and Joseph Adler: “[The Sushi Principle: Raw Data Is Better](https://vimeo.com/123985284),” at *Strata+Hadoop World*, February 2015. + +1. Vinod Kumar Vavilapalli, Arun C. Murthy, Chris Douglas, et al.: “[Apache Hadoop YARN: Yet Another Resource Negotiator](http://www.socc2013.org/home/program/a5-vavilapalli.pdf),” at *4th ACM Symposium on Cloud Computing* (SoCC), October 2013. [doi:10.1145/2523616.2523633](http://dx.doi.org/10.1145/2523616.2523633) + +1. Abhishek Verma, Luis Pedrosa, Madhukar Korupolu, et al.: “[Large-Scale Cluster Management at Google with Borg](http://research.google.com/pubs/pub43438.html),” at *10th European Conference on Computer Systems* (EuroSys), April 2015. [doi:10.1145/2741948.2741964](http://dx.doi.org/10.1145/2741948.2741964) + +1. Malte Schwarzkopf: “[The Evolution of Cluster Scheduler Architectures](http://www.firmament.io/blog/scheduler-architectures.html),” *firmament.io*, March 9, 2016. + +1. Matei Zaharia, Mosharaf Chowdhury, Tathagata Das, et al.: “[Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing](https://www.usenix.org/system/files/conference/nsdi12/nsdi12-final138.pdf),” at *9th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), April 2012. + +1. Holden Karau, Andy Konwinski, Patrick Wendell, and Matei Zaharia: *Learning Spark*. O'Reilly Media, 2015. ISBN: 978-1-449-35904-1 + +1. Bikas Saha and Hitesh Shah: “[Apache Tez: Accelerating Hadoop Query Processing](http://www.slideshare.net/Hadoop_Summit/w-1205phall1saha),” at *Hadoop Summit*, June 2014. + +1. Bikas Saha, Hitesh Shah, Siddharth Seth, et al.: “[Apache Tez: A Unifying Framework for Modeling and Building Data Processing Applications](http://home.cse.ust.hk/~weiwa/teaching/Fall15-COMP6611B/reading_list/Tez.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2742790](http://dx.doi.org/10.1145/2723372.2742790) + +1. Kostas Tzoumas: “[Apache Flink: API, Runtime, and Project Roadmap](http://www.slideshare.net/KostasTzoumas/apache-flink-api-runtime-and-project-roadmap),” *slideshare.net*, January 14, 2015. + +1. Alexander Alexandrov, Rico Bergmann, Stephan Ewen, et al.: “[The Stratosphere Platform for Big Data Analytics](https://ssc.io/pdf/2014-VLDBJ_Stratosphere_Overview.pdf),” *The VLDB Journal*, volume 23, number 6, pages 939–964, May 2014. [doi:10.1007/s00778-014-0357-y](http://dx.doi.org/10.1007/s00778-014-0357-y) + +1. Michael Isard, Mihai Budiu, Yuan Yu, et al.: “[Dryad: Distributed Data-Parallel Programs from Sequential Building Blocks](http://research.microsoft.com/en-us/projects/dryad/eurosys07.pdf),” at *European Conference on Computer Systems* (EuroSys), March 2007. [doi:10.1145/1272996.1273005](http://dx.doi.org/10.1145/1272996.1273005) + +1. Daniel Warneke and Odej Kao: “[Nephele: Efficient Parallel Data Processing in the Cloud](https://stratosphere2.dima.tu-berlin.de/assets/papers/Nephele_09.pdf),” at *2nd Workshop on Many-Task Computing on Grids and Supercomputers* (MTAGS), November 2009. [doi:10.1145/1646468.1646476](http://dx.doi.org/10.1145/1646468.1646476) + +1. Lawrence Page, Sergey Brin, Rajeev Motwani, and Terry Winograd: ["The PageRank"](http://ilpubs.stanford.edu:8090/422/) + +1. Leslie G. Valiant: “[A Bridging Model for Parallel Computation](http://dl.acm.org/citation.cfm?id=79181),” *Communications of the ACM*, volume 33, number 8, pages 103–111, August 1990. [doi:10.1145/79173.79181](http://dx.doi.org/10.1145/79173.79181) + +1. Stephan Ewen, Kostas Tzoumas, Moritz Kaufmann, and Volker Markl: “[Spinning Fast Iterative Data Flows](http://vldb.org/pvldb/vol5/p1268_stephanewen_vldb2012.pdf),” *Proceedings of the VLDB Endowment*, volume 5, number 11, pages 1268-1279, July 2012. [doi:10.14778/2350229.2350245](http://dx.doi.org/10.14778/2350229.2350245) + +1. Grzegorz Malewicz, Matthew H.Austern, Aart J. C. Bik, et al.: “[Pregel: A System for Large-Scale Graph Processing](https://kowshik.github.io/JPregel/pregel_paper.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2010. [doi:10.1145/1807167.1807184](http://dx.doi.org/10.1145/1807167.1807184) + +1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015. + +1. Ionel Gog, Malte Schwarzkopf, Natacha Crooks, et al.: “[Musketeer: All for One, One for All in Data Processing Systems](http://www.cl.cam.ac.uk/research/srg/netos/camsas/pubs/eurosys15-musketeer.pdf),” at *10th European Conference on Computer Systems* (EuroSys), April 2015. [doi:10.1145/2741948.2741968](http://dx.doi.org/10.1145/2741948.2741968) + +1. Aapo Kyrola, Guy Blelloch, and Carlos Guestrin: “[GraphChi: Large-Scale Graph Computation on Just a PC](https://www.usenix.org/system/files/conference/osdi12/osdi12-final-126.pdf),” at *10th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2012. + +1. Andrew Lenharth, Donald Nguyen, and Keshav Pingali: “[Parallel Graph Analytics](http://cacm.acm.org/magazines/2016/5/201591-parallel-graph-analytics/fulltext),” *Communications of the ACM*, volume 59, number 5, pages 78–87, May [doi:10.1145/2901919](http://dx.doi.org/10.1145/2901919) + +1. Fabian Hüske: “[Peeking into Apache Flink's Engine Room](http://flink.apache.org/news/2015/03/13/peeking-into-Apache-Flinks-Engine-Room.html),” *flink.apache.org*, March 13, 2015. + +1. Mostafa Mokhtar: “[Hive 0.14 Cost Based Optimizer (CBO) Technical Overview](http://hortonworks.com/blog/hive-0-14-cost-based-optimizer-cbo-technical-overview/),” *hortonworks.com*, March 2, 2015. + +1. Michael Armbrust, Reynold S Xin, Cheng Lian, et al.: “[Spark SQL: Relational Data Processing in Spark](http://people.csail.mit.edu/matei/papers/2015/sigmod_spark_sql.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2742797](http://dx.doi.org/10.1145/2723372.2742797) + +1. Daniel Blazevski: “[Planting Quadtrees for Apache Flink](http://insightdataengineering.com/blog/flink-knn/),” *insightdataengineering.com*, March 25, 2016. + +1. Tom White: “[Genome Analysis Toolkit: Now Using Apache Spark for Data Processing](http://blog.cloudera.com/blog/2016/04/genome-analysis-toolkit-now-using-apache-spark-for-data-processing/),” *blog.cloudera.com*, April 6, 2016. + + +------ + +| 上一章 | 目錄 | 下一章 | +| --------------------------------- | ------------------------------- | ------------------------ | +| [第三部分:派生資料](part-iii.md) | [設計資料密集型應用](README.md) | [第十一章:流處理](ch11.md) | \ No newline at end of file diff --git a/zh-tw/ch11.md b/zh-tw/ch11.md new file mode 100644 index 00000000..5d87e038 --- /dev/null +++ b/zh-tw/ch11.md @@ -0,0 +1,934 @@ +# 11. 流處理 + +![](img/ch11.png) + +> 有效的複雜系統總是從簡單的系統演化而來。 反之亦然:從零設計的複雜系統沒一個能有效工作的。 +> +> ——約翰·加爾,Systemantics(1975) + +--------------- + +[TOC] + +​ 在[第10章](ch10.md)中,我們討論了批處理技術,它讀取一組檔案作為輸入,並生成一組新的檔案作為輸出。輸出是**衍生資料(derived data)**的一種形式;也就是說,如果需要,可以透過再次執行批處理過程來重新建立資料集。我們看到了如何使用這個簡單而強大的想法來建立搜尋索引,推薦系統,做分析等等。 + +​ 然而,在[第10章](ch10.md)中仍然有一個很大的假設:即輸入是有界的,即已知和有限的大小,所以批處理知道它何時完成輸入的讀取。例如,MapReduce核心的排序操作必須讀取其全部輸入,然後才能開始生成輸出:可能發生這種情況:最後一條輸入記錄具有最小的鍵,因此需要第一個被輸出,所以提早開始輸出是不可行的。 + +​ 實際上,很多資料是**無界限**的,因為它隨著時間的推移而逐漸到達:你的使用者在昨天和今天產生了資料,明天他們將繼續產生更多的資料。除非你停業,否則這個過程永遠都不會結束,所以資料集從來就不會以任何有意義的方式“完成”【1】。因此,批處理程式必須將資料人為地分成固定時間段的資料塊,例如,在每天結束時處理一天的資料,或者在每小時結束時處理一小時的資料。 + +​ 日常批處理中的問題是,輸入的變更只會在一天之後的輸出中反映出來,這對於許多急躁的使用者來說太慢了。為了減少延遲,我們可以更頻繁地執行處理 —— 比如說,在每秒鐘的末尾 —— 或者甚至更連續一些,完全拋開固定的時間切片,當事件發生時就立即進行處理,這就是**流處理(stream processing)**背後的想法。 + +​ 一般來說,“流”是指隨著時間的推移逐漸可用的資料。這個概念出現在很多地方:Unix的stdin和stdout,程式語言(惰性列表)【2】,檔案系統API(如Java的`FileInputStream`),TCP連線,透過網際網路傳送音訊和影片等等。 + 在本章中,我們將把**事件流(event stream)**視為一種資料管理機制:無界限,增量處理,與[上一章](ch10.md)中批次資料相對應。我們將首先討論怎樣表示、儲存、透過網路傳輸流。在“[資料庫和流](#資料庫和流)”中,我們將研究流和資料庫之間的關係。最後在“[流處理](#流處理)”中,我們將研究連續處理這些流的方法和工具,以及它們用於應用構建的方式。 + + + +## 傳遞事件流 + +​ 在批處理領域,作業的輸入和輸出是檔案(也許在分散式檔案系統上)。流處理領域中的等價物看上去是什麼樣子的? + +​ 當輸入是一個檔案(一個位元組序列),第一個處理步驟通常是將其解析為一系列記錄。在流處理的上下文中,記錄通常被叫做 **事件(event)** ,但它本質上是一樣的:一個小的,自包含的,不可變的物件,包含某個時間點發生的某件事情的細節。一個事件通常包含一個來自時鐘的時間戳,以指明事件發生的時間(參見“[單調鍾與時鐘](ch8.md#單調鍾與時鐘)”)。 + +​ 例如,發生的事件可能是使用者採取的行動,例如檢視頁面或進行購買。它也可能來源於機器,例如對溫度感測器或CPU利用率的週期性測量。在“[使用Unix工具進行批處理](ch10.md#使用Unix工具進行批處理)”的示例中,Web伺服器日誌的每一行都是一個事件。 + +​ 事件可能被編碼為文字字串或JSON,或者某種二進位制編碼,如[第4章](ch4.md)所述。這種編碼允許你儲存一個事件,例如將其附加到一個檔案,將其插入關係表,或將其寫入文件資料庫。它還允許你透過網路將事件傳送到另一個節點以進行處理。 + +​ 在批處理中,檔案被寫入一次,然後可能被多個作業讀取。類似地,在流處理術語中,一個事件由 **生產者(producer)** (也稱為 **釋出者(publisher)** 或 **傳送者(sender)** )生成一次,然後可能由多個 **消費者(consumer)** ( **訂閱者(subscribers)** 或 **接收者(recipients)** )進行處理【3】。在檔案系統中,檔名標識一組相關記錄;在流式系統中,相關的事件通常被聚合為一個 **主題(topic)** 或 **流(stream)** 。 + +​ 原則上講,檔案或資料庫就足以連線生產者和消費者:生產者將其生成的每個事件寫入資料儲存,且每個消費者定期輪詢資料儲存,檢查自上次執行以來新出現的事件。這實際上正是批處理在每天結束時處理當天資料時所做的事情。 + +​ 但當我們想要進行低延遲的連續處理時,如果資料儲存不是為這種用途專門設計的,那麼輪詢開銷就會很大。輪詢的越頻繁,能返回新事件的請求比例就越低,而額外開銷也就越高。相比之下,最好能在新事件出現時直接通知消費者。 + +​ 資料庫在傳統上對這種通知機制支援的並不好,關係型資料庫通常有 **觸發器(trigger)** ,它們可以對變化作出反應(如,插入表中的一行),但是它們的功能非常有限,並且在資料庫設計中有些後顧之憂【4,5】。相應的是,已經開發了專門的工具來提供事件通知。 + + +### 訊息系統 + +​ 向消費者通知新事件的常用方式是使用**訊息傳遞系統(messaging system)**:生產者傳送包含事件的訊息,然後將訊息推送給消費者。我們之前在“[訊息傳遞中的資料流](ch4.md#訊息傳遞中的資料流)”中介紹了這些系統,但現在我們將詳細介紹這些系統。 + +​ 像生產者和消費者之間的Unix管道或TCP連線這樣的直接通道,是實現訊息傳遞系統的簡單方法。但是,大多數訊息傳遞系統都在這一基本模型上進行擴充套件。特別的是,Unix管道和TCP將恰好一個傳送者與恰好一個接收者連線,而一個訊息傳遞系統允許多個生產者節點將訊息傳送到同一個主題,並允許多個消費者節點接收主題中的訊息。 + +​ 在這個**釋出/訂閱**模式中,不同的系統採取各種各樣的方法,並沒有針對所有目的的通用答案。為了區分這些系統,問一下這兩個問題會特別有幫助: + +1. **如果生產者傳送訊息的速度比消費者能夠處理的速度快會發生什麼?**一般來說,有三種選擇:系統可以丟掉訊息,將訊息放入緩衝佇列,或使用**背壓(backpressure)**(也稱為**流量控制(flow control);**即阻塞生產者,以免其傳送更多的訊息)。例如Unix管道和TCP使用背壓:它們有一個固定大小的小緩衝區,如果填滿,傳送者會被阻塞,直到接收者從緩衝區中取出資料(參見“[網路擁塞和排隊](ch8.md#網路擁塞和排隊)”)。 + + 如果訊息被快取在佇列中,那麼理解佇列增長會發生什麼是很重要的。當佇列裝不進記憶體時系統會崩潰嗎?還是將訊息寫入磁碟?如果是這樣,磁碟訪問又會如何影響訊息傳遞系統的效能【6】? + +2. **如果節點崩潰或暫時離線,會發生什麼情況? —— 是否會有訊息丟失?**與資料庫一樣,永續性可能需要寫入磁碟**和/或**複製的某種組合(參閱“[複製和永續性](ch7.md#複製和永續性)”),這是有代價的。如果你能接受有時訊息會丟失,則可能在同一硬體上獲得更高的吞吐量和更低的延遲。 + +是否可以接受訊息丟失取決於應用。例如,對於週期傳輸的感測器讀數和指標,偶爾丟失的資料點可能並不重要,因為更新的值會在短時間內發出。但要注意,如果大量的訊息被丟棄,可能無法立刻意識到指標已經不正確了【7】。如果你正在對事件計數,那麼更重要的是它們能夠可靠送達,因為每個丟失的訊息都意味著使計數器的錯誤擴大。 + +​ 我們在[第10章](ch10.md)中探討的批處理系統的一個很好的特性是,它們提供了強大的可靠性保證:失敗的任務會自動重試,失敗任務的部分輸出會自動丟棄。這意味著輸出與沒有發生故障一樣,這有助於簡化程式設計模型。在本章的後面,我們將研究如何在流處理的上下文中提供類似的保證。 + +#### 直接從生產者傳遞給消費者 + +許多訊息傳遞系統使用生產者和消費者之間的直接網路通訊,而不透過中間節點: + +* UDP組播廣泛應用於金融行業,例如股票市場,其中低時延非常重要【8】。雖然UDP本身是不可靠的,但應用層的協議可以恢復丟失的資料包(生產者必須記住它傳送的資料包,以便能按需重新發送資料包)。 +* 無代理的訊息庫,如ZeroMQ 【9】和nanomsg採取類似的方法,透過TCP或IP多播實現釋出/訂閱訊息傳遞。 +* StatsD 【10】和Brubeck 【7】使用不可靠的UDP訊息傳遞來收集網路中所有機器的指標並對其進行監控。 (在StatsD協議中,只有接收到所有訊息,才認為計數器指標是正確的;使用UDP將使得指標處在一種最佳近似狀態【11】。另請參閱“[TCP與UDP](ch8.md#TCP與UDP)” +* 如果消費者在網路上公開了服務,生產者可以直接傳送HTTP或RPC請求(參閱“[透過服務進行資料流:REST和RPC](ch4.md#透過服務進行資料流:REST和RPC)”)將訊息推送給使用者。這就是webhooks背後的想法【12】,一種服務的回撥URL被註冊到另一個服務中,並且每當事件發生時都會向該URL發出請求。 + +儘管這些直接訊息傳遞系統在設計它們的環境中執行良好,但是它們通常要求應用程式碼意識到訊息丟失的可能性。它們的容錯程度極為有限:即使協議檢測到並重傳在網路中丟失的資料包,它們通常也只是假設生產者和消費者始終線上。 + +​ 如果消費者處於離線狀態,則可能會丟失其不可達時傳送的訊息。一些協議允許生產者重試失敗的訊息傳遞,但當生產者崩潰時,它可能會丟失訊息緩衝區及其本應傳送的訊息,這種方法可能就沒用了。 + +#### 訊息代理 + +​ 一種廣泛使用的替代方法是透過**訊息代理(message broker)**(也稱為**訊息佇列(message queue)**)傳送訊息,訊息代理實質上是一種針對處理訊息流而最佳化的資料庫。它作為伺服器執行,生產者和消費者作為客戶端連線到伺服器。生產者將訊息寫入代理,消費者透過從代理那裡讀取來接收訊息。 + +​ 透過將資料集中在代理上,這些系統可以更容易地容忍來來去去的客戶端(連線,斷開連線和崩潰),而永續性問題則轉移到代理的身上。一些訊息代理只將訊息儲存在記憶體中,而另一些訊息代理(取決於配置)將其寫入磁碟,以便在代理崩潰的情況下不會丟失。針對緩慢的消費者,它們通常會允許無上限的排隊(而不是丟棄訊息或背壓),儘管這種選擇也可能取決於配置。 + +​ 排隊的結果是,消費者通常是**非同步(asynchronous)**的:當生產者傳送訊息時,通常只會等待代理確認訊息已經被快取,而不等待訊息被消費者處理。向消費者遞送訊息將發生在未來某個未定的時間點 —— 通常在幾分之一秒之內,但有時當訊息堆積時會顯著延遲。 + +#### 訊息代理與資料庫對比 + +​ 有些訊息代理甚至可以使用XA或JTA參與兩階段提交協議(參閱“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”)。這個功能與資料庫在本質上非常相似,儘管訊息代理和資料庫之間仍存在實踐上很重要的差異: + +* 資料庫通常保留資料直至顯式刪除,而大多數訊息代理在訊息成功遞送給消費者時會自動刪除訊息。這樣的訊息代理不適合長期的資料儲存。 +* 由於它們很快就能刪除訊息,大多數訊息代理都認為它們的工作集相當小—— 即佇列很短。如果代理需要緩衝很多訊息,比如因為消費者速度較慢(如果記憶體裝不下訊息,可能會溢位到磁碟),每個訊息需要更長的處理時間,整體吞吐量可能會惡化【6】。 +* 資料庫通常支援二級索引和各種搜尋資料的方式,而訊息代理通常支援按照某種模式匹配主題,訂閱其子集。機制並不一樣,對於客戶端選擇想要了解的資料的一部分,這是兩種基本的方式。 +* 查詢資料庫時,結果通常基於某個時間點的資料快照;如果另一個客戶端隨後向資料庫寫入一些改變了查詢結果的內容,則第一個客戶端不會發現其先前結果現已過期(除非它重複查詢或輪詢變更)。相比之下,訊息代理不支援任意查詢,但是當資料發生變化時(即新訊息可用時),它們會通知客戶端。 + +這是關於訊息代理的傳統觀點,它被封裝在諸如JMS 【14】和AMQP 【15】的標準中,並且被諸如RabbitMQ,ActiveMQ,HornetQ,Qpid,TIBCO企業訊息服務,IBM MQ,Azure Service Bus和Google Cloud Pub/Sub實現 【16】。 + +#### 多個消費者 + +當多個消費者從同一主題中讀取訊息時,有使用兩種主要的訊息傳遞模式,如[圖11-1](img/fig11-1.png)所示: + +***負載均衡(load balance)*** + +​ 每條訊息都被傳遞給消費者**之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在AMQP中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在JMS中則稱之為**共享訂閱(shared subscription)**)。 + +***扇出(fan-out)*** + +​ 每條訊息都被傳遞給**所有**消費者。扇出允許幾個獨立的消費者各自“收聽”相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 (JMS中的主題訂閱與AMQP中的交叉繫結提供了這一功能)。 + +![](img/fig11-1.png) + +**圖11-1 (a)負載平衡:在消費者間共享消費主題;(b)扇出:將每條訊息傳遞給多個消費者。** + +​ 兩種模式可以組合使用:例如,兩個獨立的消費者組可以每組各訂閱一個主題,每一組都共同收到所有訊息,但在每一組內部,每條訊息僅由單個節點處理。 + +#### 確認與重新交付 + +​ 消費隨時可能會崩潰,所以有一種可能的情況是:代理向消費者遞送訊息,但消費者沒有處理,或者在消費者崩潰之前只進行了部分處理。為了確保訊息不會丟失,訊息代理使用**確認(acknowledgments)**:客戶端必須顯式告知代理訊息處理完畢的時間,以便代理能將訊息從佇列中移除。 + +​ 如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。 (請注意可能發生這樣的情況,訊息**實際上是**處理完畢的,但**確認**在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”中所討論的那樣) + +​ 當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖11-2](img/fig11-2.png)中,消費者通常按照生產者傳送的順序處理訊息。然而消費者2在處理訊息m3時崩潰,與此同時消費者1正在處理訊息m4。未確認的訊息m3隨後被重新發送給消費者1,結果消費者1按照m4,m3,m5的順序處理訊息。因此m3和m4的交付順序與以生產者1的傳送順序不同。 + +![](img/fig11-2.png) + +**圖11-2 在處理m3時消費者2崩潰,因此稍後重傳至消費者1** + +​ 即使訊息代理試圖保留訊息的順序(如JMS和AMQP標準所要求的),負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題,你可以讓每個消費者使用單獨的佇列(即不使用負載均衡功能)。如果訊息是完全獨立的,則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述,如果訊息之間存在因果依賴關係,這就是一個很重要的問題。 + +### 分割槽日誌 + +​ 透過網路傳送資料包或向網路服務傳送請求通常是短暫的操作,不會留下永久的痕跡。儘管可以永久記錄(透過抓包與日誌),但我們通常不這麼做。即使是將訊息持久地寫入磁碟的訊息代理,在送達給消費者之後也會很快刪除訊息,因為它們建立在短暫訊息傳遞的思維方式上。 + +​ 資料庫和檔案系統採用截然相反的方法論:至少在某人顯式刪除前,通常寫入資料庫或檔案的所有內容都要被永久記錄下來。 + +​ 這種思維方式上的差異對建立衍生資料的方式有巨大影響。如[第10章](ch10.md)所述,批處理過程的一個關鍵特性是,你可以反覆執行它們,試驗處理步驟,不用擔心損壞輸入(因為輸入是隻讀的)。而 AMQP/JMS風格的訊息傳遞並非如此:收到訊息是具有破壞性的,因為確認可能導致訊息從代理中被刪除,因此你不能期望再次運行同一個消費者能得到相同的結果。 + +​ 如果你將新的消費者新增到訊息系統,通常只能接收到消費者註冊之後開始傳送的訊息。先前的任何訊息都隨風而逝,一去不復返。作為對比,你可以隨時為檔案和資料庫新增新的客戶端,且能讀取任意久遠的資料(只要應用沒有顯式覆蓋或刪除這些資料)。 + +​ 為什麼我們不能把它倆雜交一下,既有資料庫的持久儲存方式,又有訊息傳遞的低延遲通知?這就是**基於日誌的訊息代理(log-based message brokers)** 背後的想法。 + +#### 使用日誌進行訊息儲存 + +​ 日誌只是磁碟上簡單的僅追加記錄序列。我們先前在[第3章](ch3.md)中日誌結構儲存引擎和預寫式日誌的上下文中討論了日誌,在[第5章](ch5.md)複製的上下文裡也討論了它。 + +​ 同樣的結構可以用於實現訊息代理:生產者透過將訊息追加到日誌末尾來發送訊息,而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾,則會等待新訊息追加的通知。 Unix工具`tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。 + +​ 為了擴充套件到比單個磁碟所能提供的更高吞吐量,可以對日誌進行**分割槽**(在[第6章](ch6.md)的意義上)。不同的分割槽可以託管在不同的機器上,且每個分割槽都拆分出一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如[圖11-3](img/fig11-3.png)所示。 + +​ 在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或**偏移量(offset)**(在[圖11-3](img/fig11-3.png)中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。 + +![](img/fig11-3.png) + +**圖11-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案** + +​ Apache Kafka 【17,18】,Amazon Kinesis Streams 【19】和Twitter的DistributedLog 【20,21】都是基於日誌的訊息代理。 Google Cloud Pub/Sub在架構上類似,但對外暴露的是JMS風格的API,而不是日誌抽象【16】。儘管這些訊息代理將所有訊息寫入磁碟,但透過跨多臺機器分割槽,每秒能夠實現數百萬條訊息的吞吐量,並透過複製訊息來實現容錯性【22,23】。 + +#### 日誌與傳統訊息相比 + +​ 基於日誌的方法天然支援扇出式訊息傳遞,因為多個消費者可以獨立讀取日誌,而不會相互影響 —— 讀取訊息不會將其從日誌中刪除。為了在一組消費者之間實現負載平衡,代理可以將整個分割槽分配給消費者組中的節點,而不是將單條訊息分配給消費者客戶端。 + +​ 每個客戶端消費指派分割槽中的**所有**訊息。然後使用分配的分割槽中的所有訊息。通常情況下,當一個使用者被指派了一個日誌分割槽時,它會以簡單的單執行緒方式順序地讀取分割槽中的訊息。這種粗粒度的負載均衡方法有一些缺點: + +* 共享消費主題工作的節點數,最多為該主題中的日誌分割槽數,因為同一個分割槽內的所有訊息被遞送到同一個節點[^i]。 +* 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種行首阻塞的形式;請參閱“[描述效能](ch1.md#描述效能)”)。 + +因此在訊息處理代價高昂,希望逐條並行處理,以及訊息的順序並沒有那麼重要的情況下,JMS/AMQP風格的訊息代理是可取的。另一方面,在訊息吞吐量很高,處理迅速,順序很重要的情況下,基於日誌的方法表現得非常好。 + +[^i]: 設計一種負載均衡方案是可行的,在這種方案中,兩個消費者透過讀取全部訊息來共享處理分割槽的工作,但是其中一個只考慮具有偶數偏移量的訊息,而另一個消費者只處理奇數編號的偏移量。或者你可以將訊息攤到一個執行緒池中來處理,但這種方法會使消費者偏移量管理變得複雜。一般來說,單執行緒處理單分割槽是合適的,可以透過增加更多分割槽來提高並行度。 + +#### 消費者偏移量 + +​ 順序消費一個分割槽使得判斷訊息是否已經被處理變得相當容易:所有偏移量小於消費者的當前偏移量的訊息已經被處理,而具有更大偏移量的訊息還沒有被看到。因此,代理不需要跟蹤確認每條訊息,只需要定期記錄消費者的偏移即可。在這種方法減少了額外簿記開銷,而且在批處理和流處理中採用這種方法有助於提高基於日誌的系統的吞吐量。 + +​ 實際上,這種偏移量與單領導者資料庫複製中常見的日誌序列號非常相似,我們在“[設定新從庫](ch5.md#設定新從庫)”中討論了這種情況。在資料庫複製中,日誌序列號允許跟隨者斷開連線後,重新連線到領導者,並在不跳過任何寫入的情況下恢復複製。這裡原理完全相同:訊息代理的表現得像一個主庫,而消費者就像一個從庫。 + +​ 如果消費者節點失效,則失效消費者的分割槽將指派給其他節點,並從最後記錄的偏移量開始消費訊息。如果消費者已經處理了後續的訊息,但還沒有記錄它們的偏移量,那麼重啟後這些訊息將被處理兩次。我們將在本章後面討論這個問題的處理方法。 + +#### 磁碟空間使用 + +​ 如果只追加寫入日誌,則磁碟空間終究會耗盡。為了回收磁碟空間,日誌實際上被分割成段,並不時地將舊段刪除或移動到歸檔儲存。 (我們將在後面討論一種更為複雜的磁碟空間釋放方式) + +​ 這就意味著如果一個慢消費者跟不上訊息產生的速率而落後的太多,它的消費偏移量指向了刪除的段,那麼它就會錯過一些訊息。實際上,日誌實現了一個有限大小的緩衝區,當緩衝區填滿時會丟棄舊訊息,它也被稱為**迴圈緩衝區(circular buffer)**或**環形緩衝區(ring buffer)**。不過由於緩衝區在磁碟上,因此可能相當的大。 + +​ 讓我們做個簡單計算。在撰寫本文時,典型的大型硬碟容量為6TB,順序寫入吞吐量為150MB/s。如果以最快的速度寫訊息,則需要大約11個小時才能填滿磁碟。因而磁碟可以緩衝11個小時的訊息,之後它將開始覆蓋舊的訊息。即使使用多個磁碟和機器,這個比率也是一樣的。實踐中的部署很少能用滿磁碟的寫入頻寬,所以通常可以儲存一個幾天甚至幾周的日誌緩衝區。 + +​ 不管保留多長時間的訊息,日誌的吞吐量或多或少保持不變,因為無論如何,每個訊息都會被寫入磁碟【18】。這種行為與預設將訊息儲存在記憶體中,僅當佇列太長時才寫入磁碟的訊息傳遞系統形成鮮明對比。當佇列很短時,這些系統非常快;而當這些系統開始寫入磁碟時,就要慢的多,所以吞吐量取決於保留的歷史數量。 + +#### 當消費者跟不上生產者時 + +​ 在“[訊息傳遞系統](#訊息傳遞系統)”中,如果消費者無法跟上生產者傳送資訊的速度時,我們討論了三種選擇:丟棄資訊,進行緩衝或施加背壓。在這種分類法裡,基於日誌的方法是緩衝的一種形式,具有很大,但大小固定的緩衝區(受可用磁碟空間的限制)。 + +​ 如果消費者遠遠落後,而所要求的資訊比保留在磁碟上的資訊還要舊,那麼它將不能讀取這些資訊,所以代理實際上丟棄了比緩衝區容量更大的舊資訊。你可以監控消費者落後日誌頭部的距離,如果落後太多就發出報警。由於緩衝區很大,因而有足夠的時間讓人類運維來修復慢消費者,並在訊息開始丟失之前讓其趕上。 + +​ 即使消費者真的落後太多開始丟失訊息,也只有那個消費者受到影響;它不會中斷其他消費者的服務。這是一個巨大的運維優勢:你可以實驗性地消費生產日誌,以進行開發,測試或除錯,而不必擔心會中斷生產服務。當消費者關閉或崩潰時,會停止消耗資源,唯一剩下的只有消費者偏移量。 + +​ 這種行為也與傳統的資訊代理形成了鮮明對比,在那種情況下,你需要小心地刪除那些消費者已經關閉的佇列—— 否則那些佇列就會累積不必要的訊息,從其他仍活躍的消費者那裡佔走記憶體。 + +#### 重播舊資訊 + +​ 我們之前提到,使用AMQP和JMS風格的訊息代理,處理和確認訊息是一個破壞性的操作,因為它會導致訊息在代理上被刪除。另一方面,在基於日誌的訊息代理中,使用訊息更像是從檔案中讀取資料:這是隻讀操作,不會更改日誌。 + +​ 除了消費者的任何輸出之外,處理的唯一副作用是消費者偏移量的前進。但偏移量是在消費者的控制之下的,所以如果需要的話可以很容易地操縱:例如你可以用昨天的偏移量跑一個消費者副本,並將輸出寫到不同的位置,以便重新處理最近一天的訊息。你可以使用各種不同的處理程式碼重複任意次。 + +​ 這一方面使得基於日誌的訊息傳遞更像上一章的批處理,其中衍生資料透過可重複的轉換過程與輸入資料顯式分離。它允許進行更多的實驗,更容易從錯誤和漏洞中恢復,使其成為在組織內整合資料流的良好工具【24】。 + + + +## 流與資料庫 + +​ 我們已經在訊息代理和資料庫之間進行了一些比較。儘管傳統上它們被視為單獨的工具類別,但是我們看到基於日誌的訊息代理已經成功地從資料庫中獲取靈感並將其應用於訊息傳遞。我們也可以反過來:從訊息傳遞和流中獲取靈感,並將它們應用於資料庫。 + +​ 我們之前曾經說過,事件是某個時刻發生的事情的記錄。發生的事情可能是使用者操作(例如鍵入搜尋查詢)或讀取感測器,但也可能是**寫入資料庫**。某些東西被寫入資料庫的事實是可以被捕獲,儲存和處理的事件。這一觀察結果表明,資料庫和資料流之間的聯絡不僅僅是磁碟日誌的物理儲存 —— 而是更深層的聯絡。 + +​ 事實上,複製日誌(參閱“[複製日誌的實現](ch5.md#複製日誌的實現)”)是資料庫寫入事件的流,由主庫在處理事務時生成。從庫將寫入流應用到它們自己的資料庫副本,從而最終得到相同資料的精確副本。複製日誌中的事件描述發生的資料更改。 + +​ 我們還在“[全序廣播](ch9.md#全序廣播)”中遇到了狀態機複製原理,其中指出:如果每個事件代表對資料庫的寫入,並且每個副本按相同的順序處理相同的事件,則副本將達到相同的最終狀態 (假設處理一個事件是一個確定性的操作)。這是事件流的又一種場景! + +​ 在本節中,我們將首先看看異構資料系統中出現的一個問題,然後探討如何透過將事件流的想法帶入資料庫來解決這個問題。 + +### 保持系統同步 + +​ 正如我們在本書中所看到的,沒有一個系統能夠滿足所有的資料儲存,查詢和處理需求。在實踐中,大多數重要應用都需要組合使用幾種不同的技術來滿足所有的需求:例如,使用OLTP資料庫來為使用者請求提供服務,使用快取來加速常見請求,使用全文索引搜尋處理搜尋查詢,使用資料倉庫用於分析。每一個元件都有自己的資料副本,以自己的表示儲存,並根據自己的目的進行最佳化。 + +​ 由於相同或相關的資料出現在了不同的地方,因此相互間需要保持同步:如果某個專案在資料庫中被更新,它也應當在快取,搜尋索引和資料倉庫中被更新。對於資料倉庫,這種同步通常由ETL程序執行(參見“[資料倉庫](ch3.md#資料倉庫)”),通常是先取得資料庫的完整副本,然後執行轉換,並批次載入到資料倉庫中 —— 換句話說,批處理。我們在“[批次工作流的輸出](ch10.md#批次工作流的輸出)”中同樣看到了如何使用批處理建立搜尋索引,推薦系統和其他衍生資料系統。 + +​ 如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是**雙寫(dual write)**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。 + +​ 但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如[圖11-4](img/fig11-4.png)所示。在這個例子中,兩個客戶端同時想要更新一個專案X:客戶端1想要將值設定為A,客戶端2想要將其設定為B。兩個客戶端首先將新值寫入資料庫,然後將其寫入到搜尋索引。因為運氣不好,這些請求的時序是交錯的:資料庫首先看到來自客戶端1的寫入將值設定為A,然後來自客戶端2的寫入將值設定為B,因此資料庫中的最終值為B。搜尋索引首先看到來自客戶端2的寫入,然後是客戶端1的寫入,所以搜尋索引中的最終值是A。即使沒發生錯誤,這兩個系統現在也永久地不一致了。 + +![](img/fig11-4.png) + +**圖11-4 在資料庫中X首先被設定為A,然後被設定為B,而在搜尋索引處,寫入以相反的順序到達** + +​ 除非有一些額外的併發檢測機制,例如我們在“[檢測併發寫入](ch5.md#檢測併發寫入)”中討論的版本向量,否則你甚至不會意識到發生了併發寫入 —— 一個值將簡單地以無提示方式覆蓋另一個值。 + +​ 雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(參閱“[原子提交和兩階段提交(2PC)](ch7.md#原子提交和兩階段提交(2PC))”)。 + +​ 如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在[圖11-4](img/fig11-4.png)中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(參見“[多領導者複製](ch5.md#多領導者複製)“)。 + +​ 如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎? + +### 變更資料捕獲 + +​ 大多數資料庫的複製日誌的問題在於,它們一直被當做資料庫的內部實現細節,而不是公開的API。客戶端應該透過其資料模型和查詢語言來查詢資料庫,而不是解析複製日誌並嘗試從中提取資料。 + +​ 數十年來,許多資料庫根本沒有記錄在檔的,獲取變更日誌的方式。由於這個原因,捕獲資料庫中所有的變更,然後將其複製到其他儲存技術(搜尋索引,快取,資料倉庫)中是相當困難的。 + +​ 最近,人們對**變更資料捕獲(change data capture, CDC)** 越來越感興趣,這是一種觀察寫入資料庫的所有資料變更,並將其提取並轉換為可以複製到其他系統中的形式的過程。 CDC是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。 + +​ 例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如[圖11-5](img/fig11-5.png)所示。 + +![](img/fig11-5.png) + +**圖11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統** + +#### 變更資料捕獲的實現 + +​ 我們可以將日誌消費者叫做**衍生資料系統**,正如在第三部分的[介紹](part-iii.md)中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是**記錄系統**資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在衍生資料系統中,以便衍生系統具有資料的準確副本。 + +​ 從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了[圖11-2](img/fig11-2.png)的重新排序問題)。 + +​ 資料庫觸發器可用來實現變更資料捕獲(參閱“[基於觸發器的複製](ch5.md#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如應對模式變更。 + +​ LinkedIn的Databus 【25】,Facebook的Wormhole 【26】和Yahoo!的Sherpa【27】大規模地應用這個思路。 Bottled Water使用解碼WAL的API實現了PostgreSQL的CDC 【28】,Maxwell和Debezium透過解析binlog對MySQL做了類似的事情【29,30,31】,Mongoriver讀取MongoDB oplog 【32,33】 ,而GoldenGate為Oracle提供類似的功能【34,35】。 + +​ 像訊息代理一樣,變更資料捕獲通常是非同步的:記錄資料庫系統不會等待消費者應用變更再進行提交。這種設計具有的運維優勢是,新增緩慢的消費者不會過度影響記錄系統。不過,所有複製延遲可能有的問題在這裡都可能出現(參見“[複製延遲問題](ch5.md#複製延遲問題)”)。 + +#### 初始快照 + +​ 如果你擁有**所有**對資料庫進行變更的日誌,則可以透過重放該日誌,來重建資料庫的完整狀態。但是在許多情況下,永遠保留所有更改會耗費太多磁碟空間,且重放過於費時,因此日誌需要被截斷。 + +​ 例如,構建新的全文索引需要整個資料庫的完整副本 —— 僅僅應用最近變更的日誌是不夠的,因為這樣會丟失最近未曾更新的專案。因此,如果你沒有完整的歷史日誌,則需要從一個一致的快照開始,如先前上的“[設定新的從庫](ch5.md#設定新的從庫)”中所述。 + +​ 資料庫的快照必須與變更日誌中的已知位置或偏移量相對應,以便在處理完快照後知道從哪裡開始應用變更。一些CDC工具集成了這種快照功能,而其他工具則把它留給你手動執行。 + +#### 日誌壓縮 + +​ 如果你只能保留有限的歷史日誌,則每次要新增新的衍生資料系統時,都需要做一次快照。但**日誌壓縮(log compaction)** 提供了一個很好的備選方案。 + +​ 我們之前在日誌結構儲存引擎的上下文中討論了“[Hash索引](ch3.md#Hash索引)”中的日誌壓縮(參見[圖3-2](img/fig3-2.png)的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。 + +​ 在日誌結構儲存引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示該鍵被刪除,並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除,它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容,而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入,則先前的值將最終將被垃圾回收,只有最新的值會保留下來。 + +​ 在基於日誌的訊息代理與變更資料捕獲的上下文中也適用相同的想法。如果CDC系統被配置為,每個變更都包含一個主鍵,且每個鍵的更新都替換了該鍵以前的值,那麼只需要保留對鍵的最新寫入就足夠了。 + +​ 現在,無論何時需要重建衍生資料系統(如搜尋索引),你可以從壓縮日誌主題0偏移量處啟動新的消費者,然後依次掃描日誌中的所有訊息。日誌能保證包含資料庫中每個鍵的最新值(也可能是一些較舊的值)—— 換句話說,你可以使用它來獲取資料庫內容的完整副本,而無需從CDC源資料庫取一個快照。 + +​ Apache Kafka支援這種日誌壓縮功能。正如我們將在本章後面看到的,它允許訊息代理被當成永續性儲存使用,而不僅僅是用於臨時訊息。 + +#### 變更流的API支援 + +​ 越來越多的資料庫開始將變更流作為第一類的介面,而不像傳統上要去做加裝改造,費工夫逆向工程一個CDC。例如,RethinkDB允許查詢訂閱通知,當查詢結果變更時獲得通知【36】,Firebase 【37】和CouchDB 【38】基於變更流進行同步,該變更流同樣可用於應用。而Meteor使用MongoDB oplog訂閱資料變更,並改變了使用者介面【39】。 + +​ VoltDB允許事務以流的形式連續地從資料庫中匯出資料【40】。資料庫將關係資料模型中的輸出流表示為一個表,事務可以向其中插入元組,但不能查詢。已提交事務按照提交順序寫入這個特殊表,而流則由該表中的元組日誌構成。外部消費者可以非同步消費該日誌,並使用它來更新衍生資料系統。 + +​ Kafka Connect 【41】致力於將廣泛的資料庫系統的變更資料捕獲工具與Kafka整合。一旦變更事件進入Kafka中,它就可以用於更新衍生資料系統,比如搜尋索引,也可以用於本章稍後討論的流處理系統。 + +### 事件溯源 + +​ 我們在這裡討論的想法和**事件溯源( Event Sourcing)** 之間有一些相似之處,這是一個在 **領域驅動設計(domain-driven design, DDD)** 社群中折騰出來的技術。我們將簡要討論事件溯源,因為它包含了一些關於流處理系統的有用想法。 + +​ 與變更資料捕獲類似,事件溯源涉及到**將所有對應用狀態的變更** 儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了幾個不同的抽象層次上: + +* 在變更資料捕獲中,應用以**可變方式(mutable way)** 使用資料庫,任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免[圖11-4](img/fig11-4.png)中的競態條件。寫入資料庫的應用不需要知道CDC的存在。 +* 在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。在這種情況下,事件儲存是僅追加寫入的,更新與刪除是不鼓勵的或禁止的。事件被設計為旨在反映應用層面發生的事情,而不是底層的狀態變更。 + +事件源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件代理使得應用隨時間演化更為容易,透過事實更容易理解事情發生的原因,使得除錯更為容易,並有利於防止應用Bug(請參閱“[不可變事件的優點](#不可變事件的優點)”)。 + +​ 例如,儲存“學生取消選課”事件以中性的方式清楚地表達了單個行為的意圖,而副作用“從登錄檔中刪除了一個條目,而一條取消原因被新增到學生反饋表“則嵌入了很多有關稍後資料使用方式的假設。如果引入一個新的應用功能,例如“將位置留給等待列表中的下一個人” —— 事件溯源方法允許將新的副作用輕鬆地連結至現有事件之後。 + +​ 事件溯源類似於**編年史(chronicle)** 資料模型【45】,事件日誌與星型模式中的事實表之間也存在相似之處(參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”) 。 + +​ 諸如Event Store【46】這樣的專業資料庫已經被開發出來,供使用事件溯源的應用使用,但總的來說,這種方法獨立於任何特定的工具。傳統的資料庫或基於日誌的訊息代理也可以用來構建這種風格的應用。 + +#### 從事件日誌中派生出當前狀態 + +​ 事件日誌本身並不是很有用,因為使用者通常期望看到的是系統的當前狀態,而不是變更歷史。例如,在購物網站上,使用者期望能看到他們購物車裡的當前內容,而不是他們購物車所有變更的一個僅追加列表。 + +​ 因此,使用事件溯源的應用需要拉取事件日誌(表示**寫入**系統的資料),並將其轉換為適合向用戶顯示的應用狀態(從系統**讀取**資料的方式【47】)。這種轉換可以使用任意邏輯,但它應當是確定性的,以便能再次執行,並從事件日誌中衍生出相同的應用狀態。 + +​ 與變更資料捕獲一樣,重放事件日誌允許讓你重新構建系統的當前狀態。不過,日誌壓縮需要採用不同的方式處理: + +* 用於記錄更新的CDC事件通常包含記錄的**完整新版本**,因此主鍵的當前值完全由該主鍵的最近事件確定,而日誌壓縮可以丟棄相同主鍵的先前事件。 +* 另一方面,事件溯源在更高層次進行建模:事件通常表示使用者操作的意圖,而不是因為操作而發生的狀態更新機制。在這種情況下,後面的事件通常不會覆蓋先前的事件,所以你需要完整的歷史事件來重新構建最終狀態。這裡進行同樣的日誌壓縮是不可能的。 + +使用事件溯源的應用通常有一些機制,用於儲存從事件日誌中匯出的當前狀態快照,因此它們不需要重複處理完整的日誌。然而這只是一種效能最佳化,用來加速讀取,提高從崩潰中恢復的速度;真正的目的是系統能夠永久儲存所有原始事件,並在需要時重新處理完整的事件日誌。我們將在“[不變性的限制](#不變性的限制)”中討論這個假設。 + +#### 命令和事件 + +​ 事件溯源的哲學是仔細區分**事件(event)**和**命令(command)**【48】。當來自使用者的請求剛到達時,它一開始是一個命令:在這個時間點上它仍然可能可能失敗,比如,因為違反了一些完整性條件。應用必須首先驗證它是否可以執行該命令。如果驗證成功並且命令被接受,則它變為一個持久化且不可變的事件。 + +​ 例如,如果使用者試圖註冊特定使用者名稱,或預定飛機或劇院的座位,則應用需要檢查使用者名稱或座位是否已被佔用。 (先前在“[容錯概念](ch8.md#容錯概念)”中討論過這個例子)當檢查成功時,應用可以生成一個事件,指示特定的使用者名稱是由特定的使用者ID註冊的,座位已經預留給特定的顧客。 + +​ 在事件生成的時刻,它就成為了**事實(fact)**。即使客戶稍後決定更改或取消預訂,他們之前曾預定了某個特定座位的事實仍然成立,而更改或取消是之後新增的單獨的事件。 + +​ 事件流的消費者不允許拒絕事件:當消費者看到事件時,它已經成為日誌中不可變的一部分,並且可能已經被其他消費者看到了。因此任何對命令的驗證,都需要在它成為事件之前同步完成。例如,透過使用一個可自動驗證命令的可序列化事務來發布事件。 + +​ 或者,預訂座位的使用者請求可以拆分為兩個事件:第一個是暫時預約,第二個是驗證預約後的獨立的確認事件(如“[使用全序廣播實現線性一致儲存](ch9.md#使用全序廣播實現線性一致儲存)”中所述) 。這種分割方式允許驗證發生在一個非同步的過程中。 + +#### 狀態,流和不變性 + +​ 我們在[第10章](ch10.md)中看到,批處理因其輸入檔案不變性而受益良多,你可以在現有輸入檔案上執行實驗性處理作業,而不用擔心損壞它們。這種不變性原則也是使得事件溯源與變更資料捕獲如此強大的原因。 + +​ 我們通常將資料庫視為應用程式當前狀態的儲存 —— 這種表示針對讀取進行了最佳化,而且通常對於服務查詢而言是最為方便的表示。狀態的本質是,它會變化,所以資料庫才會支援資料的增刪改。這又是如何符合不變性的呢? + +​ 只要你的狀態發生了變化,那麼這個狀態就是這段時間中事件修改的結果。例如,當前可用的座位列表是已處理預訂產生的結果,當前帳戶餘額是帳戶中的借與貸的結果,而Web伺服器的響應時間圖,是所有已發生Web請求的獨立響應時間的聚合結果。 + +​ 無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌—— **變化日誌(change log)**,表示了隨時間演變的狀態。 + +​ 如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如[圖11-6](img/fig11-6.png)所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。 +$$ +state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ +stream(t) = \frac{d\ state(t)}{dt} +$$ +![](img/fig11-6.png) + +**圖11-6 應用當前狀態與事件流之間的關係** + +​ 如果你持久儲存了變更日誌,那麼重現狀態就非常簡單。如果你認為事件日誌是你的記錄系統,而所有的衍生狀態都從它派生而來,那麼系統中的資料流動就容易理解的多。正如帕特·赫蘭(Pat Helland)所說的【52】: + +> 事務日誌記錄了資料庫的所有變更。高速追加是更改日誌的唯一方法。從這個角度來看,資料庫的內容其實是日誌中記錄最新值的快取。日誌才是真相,資料庫是日誌子集的快取,這一快取子集恰好來自日誌中每條記錄與索引值的最新值。 + +​ 日誌壓縮(如“[日誌壓縮](#日誌壓縮)”中所述)是連線日誌與資料庫狀態之間的橋樑:它只保留每條記錄的最新版本,並丟棄被覆蓋的版本。 + +#### 不可變事件的優點 + +​ 資料庫中的不變性是一個古老的概念。例如,會計在幾個世紀以來一直在財務記賬中應用不變性。一筆交易發生時,它被記錄在一個僅追加寫入的分類帳中,實質上是描述貨幣,商品或服務轉手的事件日誌。賬目,比如利潤、虧損、資產負債表,是從分類賬中的交易求和衍生而來【53】。 + +​ 如果發生錯誤,會計師不會刪除或更改分類帳中的錯誤交易 —— 而是新增另一筆交易以補償錯誤,例如退還一比不正確的費用。不正確的交易將永遠保留在分類帳中,對於審計而言可能非常重要。如果從不正確的分類賬衍生出的錯誤數字已經公佈,那麼下一個會計週期的數字就會包括一個更正。這個過程在會計事務中是很常見的【54】。 + +​ 儘管這種可審計性在金融系統中尤其重要,但對於不受這種嚴格監管的許多其他系統,也是很有幫助的。如“[批處理輸出的哲學](ch10.md#批處理輸出的哲學)”中所討論的,如果你意外地部署了將錯誤資料寫入資料庫的錯誤程式碼,當代碼會破壞性地覆寫資料時,恢復要困難得多。使用不可變事件的僅追加日誌,診斷問題與故障恢復就要容易的多。 + +​ 不可變的事件也包含了比當前狀態更多的資訊。例如在購物網站上,顧客可以將物品新增到他們的購物車,然後再將其移除。雖然從履行訂單的角度,第二個事件取消了第一個事件,但對分析目的而言,知道客戶考慮過某個特定項而之後又反悔,可能是很有用的。也許他們會選擇在未來購買,或者他們已經找到了替代品。這個資訊被記錄在事件日誌中,但對於移出購物車就刪除記錄的資料庫而言,這個資訊在移出購物車時可能就丟失【42】。 + +#### 從同一事件日誌中派生多個檢視 + +​ 此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖11-5](img/fig11-5.png)):例如,分析型資料庫Druid使用這種方式直接從Kafka攝取資料【55】,Pistachio是一個分散式的鍵值儲存,使用Kafka作為提交日誌【56】,Kafka Connect能將來自Kafka的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(參閱“[保持系統同步](#保持系統同步)”)。 + +​ 新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的,針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行復雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源【47,57】。 + +​ 如果你不需要擔心如何查詢與訪問資料,那麼儲存資料通常是非常簡單的。模式設計,索引和儲存引擎的許多複雜性,都是希望支援某些特定查詢和訪問模式的結果(參見[第3章](ch3.md))。出於這個原因,透過將資料寫入的形式與讀取形式相分離,並允許幾個不同的讀取檢視,你能獲得很大的靈活性。這個想法有時被稱為**命令查詢責任分離(command query responsibility segregation, CQRS)**【42,58,59】。 + +​ 資料庫和模式設計的傳統方法是基於這樣一種謬論,資料必須以與查詢相同的形式寫入。如果可以將資料從針對寫入最佳化的事件日誌轉換為針對讀取最佳化的應用狀態,那麼有關規範化和非規範化的爭論就變得無關緊要了(參閱“[多對一和多對多的關係](ch2.md#多對一和多對多的關係)”):在針對讀取最佳化的檢視中對資料進行非規範化是完全合理的,因為翻譯過程提供了使其與事件日誌保持一致的機制。 + +​ 在“[描述負載](ch1.md#描述負載)”中,我們討論了推特主頁時間線,它是特定使用者關注人群所發推特的快取(類似郵箱)。這是**針對讀取最佳化的狀態**的又一個例子:主頁時間線是高度非規範化的,因為你的推文與所有粉絲的時間線都構成了重複。然而,扇出服務保持了這種重複狀態與新推特以及新關注關係的同步,從而保證了重複的可管理性。 + +#### 併發控制 + +​ 事件溯源和變更資料捕獲的最大缺點是,事件日誌的消費者通常是非同步的,所以可能會出現這樣的情況:使用者會寫入日誌,然後從日誌衍生檢視中讀取,結果發現他的寫入還沒有反映在讀取檢視中。我們之前在在“[讀己之寫](ch5.md#讀己之寫)”中討論了這個問題以及可能的解決方案。 + +​ 一種解決方案是將事件附加到日誌時同步執行讀取檢視的更新。而將這些寫入操作合併為一個原子單元需要**事務**,所以要麼將事件日誌和讀取檢視儲存在同一個儲存系統中,要麼就需要跨不同系統進行分散式事務。或者,你也可以使用在“[使用全序廣播實現線性化儲存](ch9.md#使用全序廣播實現線性化儲存)”中討論的方法。 + +​ 另一方面,從事件日誌匯出當前狀態也簡化了併發控制的某些部分。許多對於多物件事務的需求(參閱“[單物件和多物件操作](ch7.md#單物件和多物件操作)”)源於單個使用者操作需要在多個不同的位置更改資料。透過事件溯源,你可以設計一個自包含的事件以表示一個使用者操作。然後使用者操作就只需要在一個地方進行單次寫入操作 —— 即將事件附加到日誌中 —— 這個還是很容易使原子化的。 + +​ 如果事件日誌與應用狀態以相同的方式分割槽(例如,處理分割槽3中的客戶事件只需要更新分割槽3中的應用狀態),那麼直接使用單執行緒日誌消費者就不需要寫入併發控制了。它從設計上一次只處理一個事件(參閱“[真的的序列執行](ch7.md#真的的序列執行)”)。日誌透過在分割槽中定義事件的序列順序,消除了併發性的不確定性【24】。如果一個事件觸及多個狀態分割槽,那麼需要做更多的工作,我們將在[第12章](ch12.md)討論。 + +#### 不變性的限制 + +​ 許多不使用事件溯源模型的系統也還是依賴不可變性:各種資料庫在內部使用不可變的資料結構或多版本資料來支援時間點快照(參見“[索引和快照隔離](ch7.md#索引和快照隔離)” )。 Git,Mercurial和Fossil等版本控制系統也依靠不可變的資料來儲存檔案的版本歷史記錄。 + +​ 永遠保持所有變更的不變歷史,在多大程度上是可行的?答案取決於資料集的流失率。一些工作負載主要是新增資料,很少更新或刪除;它們很容易保持不變。其他工作負載在相對較小的資料集上有較高的更新/刪除率;在這些情況下,不可變的歷史可能增至難以接受的巨大,碎片化可能成為一個問題,壓縮與垃圾收集的表現對於運維的穩健性變得至關重要【60,61】。 + +​ 除了效能方面的原因外,也可能有出於管理方面的原因需要刪除資料的情況,儘管這些資料都是不可變的。例如,隱私條例可能要求在使用者關閉帳戶後刪除他們的個人資訊,資料保護立法可能要求刪除錯誤的資訊,或者可能需要阻止敏感資訊的意外洩露。 + +​ 在這種情況下,僅僅在日誌中新增另一個事件來指明先前的資料應該被視為刪除是不夠的 —— 你實際上是想改寫歷史,並假裝資料從一開始就沒有寫入。例如,Datomic管這個特性叫**切除(excision)** 【62】,而Fossil版本控制系統有一個類似的概念叫**避免(shunning)** 【63】。 + +​ 真正刪除資料是非常非常困難的【64】,因為副本可能存在於很多地方:例如,儲存引擎,檔案系統和SSD通常會向一個新位置寫入,而不是原地覆蓋舊資料【52】,而備份通常是特意做成不可變的,防止意外刪除或損壞。刪除更多的是“使取回資料更困難”,而不是“使取回資料不可能”。無論如何,有時你必須得嘗試,正如我們在“[立法與自律](ch12.md#立法與自律)”中所看到的。 + + + +## 流處理 + +到目前為止,本章中我們已經討論了流的來源(使用者活動事件,感測器和寫入資料庫),我們討論了流如何傳輸(直接透過訊息傳送,透過訊息代理,透過事件日誌)。 + +剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項: + +1. 你可以將事件中的資料寫入資料庫,快取,搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如[圖11-5](img/fig11-5.png)所示,這是資料庫與系統其他部分發生變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”中所討論的,它是寫入儲存系統的流等價物。 +2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可實時顯示的儀表板上。在這種情況下,人是流的最終消費者。 +3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項1或2)。 + +在本章的剩餘部分中,我們將討論選項3:處理流以產生其他衍生流。處理這樣的流的程式碼片段,被稱為**運算元(operator)**或**作業(job)**。它與我們在[第10章](ch10.md)中討論過的Unix程序和MapReduce作業密切相關,資料流的模式是相似的:一個流處理器以只讀的方式使用輸入流,並將其輸出以僅追加的方式寫入一個不同的位置。 + +​ 流處理中的分割槽和並行化模式也非常類似於[第10章](ch10.md)中介紹的MapReduce和資料流引擎,因此我們不再重複這些主題。基本的Map操作(如轉換和過濾記錄)也是一樣的。 + +​ 與批次作業相比的一個關鍵區別是,流不會結束。這種差異會帶來很多隱含的結果。正如本章開始部分所討論的,排序對無界資料集沒有意義,因此無法使用**排序合併聯接**(請參閱“[Reduce端連線與分組](ch10.md#減少連線和分組)”)。容錯機制也必須改變:對於已經運行了幾分鐘的批處理作業,可以簡單地從頭開始重啟失敗任務,但是對於已經執行數年的流作業,重啟後從頭開始跑可能並不是一個可行的選項。 + +### 流處理的應用 + +長期以來,流處理一直用於監控目的,如果某個事件發生,單位希望能得到警報。例如: + +* 欺詐檢測系統需要確定信用卡的使用模式是否有意外地變化,如果信用卡可能已被盜刷,則鎖卡。 +* 交易系統需要檢查金融市場的價格變化,並根據指定的規則進行交易。 +* 製造系統需要監控工廠中機器的狀態,如果出現故障,可以快速定位問題。 +* 軍事和情報系統需要跟蹤潛在侵略者的活動,並在出現襲擊徵兆時發出警報。 + +這些型別的應用需要非常精密複雜的模式匹配與相關檢測。然而隨著時代的進步,流處理的其他用途也開始出現。在本節中,我們將簡要比較一下這些應用。 + +#### 複合事件處理 + +​ **複合事件處理(complex, event processing, CEP)** 是20世紀90年代為分析事件流而開發出的一種方法,尤其適用於需要搜尋某些事件模式的應用【65,66】。與正則表示式允許你在字串中搜索特定字元模式的方式類似,CEP允許你指定規則以在流中搜索某些事件模式。 + +​ CEP系統通常使用高層次的宣告式查詢語言,比如SQL,或者圖形使用者介面,來描述應該檢測到的事件模式。這些查詢被提交給處理引擎,該引擎消費輸入流,並在內部維護一個執行所需匹配的狀態機。當發現匹配時,引擎發出一個**複合事件(complex event)**(因此得名),並附有檢測到的事件模式詳情【67】。 + +​ 在這些系統中,查詢和資料之間的關係與普通資料庫相比是顛倒的。通常情況下,資料庫會持久儲存資料,並將查詢視為臨時的:當查詢進入時,資料庫搜尋與查詢匹配的資料,然後在查詢完成時丟掉查詢。 CEP引擎反轉了角色:查詢是長期儲存的,來自輸入流的事件不斷流過它們,搜尋匹配事件模式的查詢【68】。 + +​ CEP的實現包括Esper 【69】,IBM InfoSphere Streams 【70】,Apama,TIBCO StreamBase和SQLstream。像Samza這樣的分散式流處理元件,支援使用SQL在流上進行宣告式查詢【71】。 + +#### 流分析 + +​ 使用流處理的另一個領域是對流進行分析。 CEP與流分析之間的邊界是模糊的,但一般來說,分析往往對找出特定事件序列並不關心,而更關注大量事件上的聚合與統計指標 —— 例如: + +* 測量某種型別事件的速率(每個時間間隔內發生的頻率) +* 滾動計算一段時間視窗內某個值的平均值 +* 將當前的統計值與先前的時間區間的值對比(例如,檢測趨勢,當指標與上週同比異常偏高或偏低時報警) + +這些統計值通常是在固定時間區間內進行計算的,例如,你可能想知道在過去5分鐘內服務每秒查詢次數的均值,以及此時間段內響應時間的第99百分位點。在幾分鐘內取平均,能抹平秒和秒之間的無關波動,且仍然能向你展示流量模式的時間圖景。聚合的時間間隔稱為**視窗(window)**,我們將在“[理解時間](#理解時間)”中更詳細地討論視窗。 + +​ 流分析系統有時會使用概率演算法,例如Bloom filter(我們在“[效能最佳化](ch3.md#效能最佳化)”中遇到過)來管理成員資格,HyperLogLog 【72】用於基數估計以及各種百分比估計演算法(請參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)“)。概率演算法產出近似的結果,但比起精確演算法的優點是記憶體使用要少得多。使用近似演算法有時讓人們覺得流處理系統總是有損的和不精確的,但這是錯誤看法:流處理並沒有任何內在的近似性,而概率演算法只是一種最佳化【73】。 + +​ 許多開源分散式流處理框架的設計都是針對分析設計的:例如Apache Storm,Spark Streaming,Flink,Concord,Samza和Kafka Streams 【74】。託管服務包括Google Cloud Dataflow和Azure Stream Analytics。 + +#### 維護物化檢視 + +​ 我們在“[資料庫和資料流](#資料庫和資料流)”中看到,資料庫的變更流可以用於維護衍生資料系統(如快取,搜尋索引和資料倉庫),使其與源資料庫保持最新。我們可以將這些示例視作維護**物化檢視(materialized view)** 的一種具體場景(參閱“[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)”):在某個資料集上衍生出一個替代檢視以便高效查詢,並在底層資料變更時更新檢視【50】。 + +​ 同樣,在事件溯源中,應用程式的狀態是透過**應用(apply)**事件日誌來維護的;這裡的應用狀態也是一種物化檢視。與流分析場景不同的是,僅考慮某個時間視窗內的事件通常是不夠的:構建物化檢視可能需要任意時間段內的**所有**事件,除了那些可能由日誌壓縮丟棄的過時事件(請參閱“[日誌壓縮](#日誌壓縮)“)。實際上,你需要一個可以一直延伸到時間開端的視窗。 + +​ 原則上講,任何流處理元件都可以用於維護物化檢視,儘管“永遠執行”與一些面向分析的框架假設的“主要在有限時間段視窗上執行”背道而馳, Samza和Kafka Streams支援這種用法,建立在Kafka對日誌壓縮comp的支援上【75】。 + +#### 在流上搜索 + +​ 除了允許搜尋由多個事件構成模式的CEP外,有時也存在基於複雜標準(例如全文搜尋查詢)來搜尋單個事件的需求。 + +​ 例如,媒體監測服務可以訂閱新聞文章Feed與來自媒體的播客,搜尋任何關於公司,產品或感興趣的話題的新聞。這是透過預先構建一個搜尋查詢來完成的,然後不斷地將新聞項的流與該查詢進行匹配。在一些網站上也有類似的功能:例如,當市場上出現符合其搜尋條件的新房產時,房地產網站的使用者可以要求網站通知他們。 Elasticsearch的這種過濾器功能,是實現這種流搜尋的一種選擇【76】。 + +​ 傳統的搜尋引擎首先索引檔案,然後在索引上跑查詢。相比之下,搜尋一個數據流則反了過來:查詢被儲存下來,文件從查詢中流過,就像在CEP中一樣。在簡單的情況就是,你可以為每個文件測試每個查詢。但是如果你有大量查詢,這可能會變慢。為了最佳化這個過程,可以像對文件一樣,為查詢建立索引。因而收窄可能匹配的查詢集合【77】。 + +#### 訊息傳遞和RPC + +​ 在“[訊息傳遞資料流](ch4.md#訊息傳遞資料流)”中我們討論過,訊息傳遞系統可以作為RPC的替代方案,即作為一種服務間通訊的機制,比如在Actor模型中所使用的那樣。儘管這些系統也是基於訊息和事件,但我們通常不會將其視作流處理元件: + +* Actor框架主要是管理模組通訊的併發和分散式執行的一種機制,而流處理主要是一種資料管理技術。 + + +* Actor之間的交流往往是短暫的,一對一的;而事件日誌則是持久的,多訂閱者的。 +* Actor可以以任意方式進行通訊(允許包括迴圈的請求/響應),但流處理通常配置在無環流水線中,其中每個流都是一個特定作業的輸出,由良好定義的輸入流中派生而來。 + +也就是說,RPC類系統與流處理之間有一些交叉領域。例如,Apache Storm有一個稱為**分散式RPC**的功能,它允許將使用者查詢分散到一系列也處理事件流的節點上;然後這些查詢與來自輸入流的事件交織,而結果可以被彙總併發回給使用者【78】(另參閱“[多分割槽資料處理](ch12.md#多分割槽資料處理)”)。 + +​ 也可以使用Actor框架來處理流。但是,很多這樣的框架在崩潰時不能保證訊息的傳遞,除非你實現了額外的重試邏輯,否則這種處理不是容錯的。 + +### 時間推理 + +​ 流處理通常需要與時間打交道,尤其是用於分析目的時候,會頻繁使用時間視窗,例如“過去五分鐘的平均值”。“最後五分鐘”的含義看上去似乎是清晰而無歧義的,但不幸的是,這個概念非常棘手。 + +​ 在批處理中過程中,大量的歷史事件迅速收縮。如果需要按時間來分析,批處理器需要檢查每個事件中嵌入的時間戳。讀取執行批處理機器的系統時鐘沒有任何意義,因為處理執行的時間與事件實際發生的時間無關。 + +​ 批處理可以在幾分鐘內讀取一年的歷史事件;在大多數情況下,感興趣的時間線是歷史中的一年,而不是處理中的幾分鐘。而且使用事件中的時間戳,使得處理是**確定性**的:在相同的輸入上再次執行相同的處理過程會得到相同的結果(參閱“[故障容錯](ch10.md#故障容錯)”)。 + +​ 另一方面,許多流處理框架使用處理機器上的本地系統時鐘(**處理時間(processing time)**)來確定**視窗**【79】。這種方法的優點是簡單,事件建立與事件處理之間的延遲可以忽略不計。然而,如果存在任何顯著的處理延遲 —— 即,事件處理顯著地晚於事件實際發生的時間,處理就失效了。 + +#### 事件時間與處理時間 + +​ 很多原因都可能導致處理延遲:排隊,網路故障(參閱“[不可靠的網路](ch8.md#不可靠的網路)”),效能問題導致訊息代理/訊息處理器出現爭用,流消費者重啟,重新處理過去的事件(參閱“[重放舊訊息](#重放舊訊息)”),或者在修復程式碼BUG之後從故障中恢復。 + +​ 而且,訊息延遲還可能導致無法預測訊息順序。例如,假設使用者首先發出一個Web請求(由Web伺服器A處理),然後發出第二個請求(由伺服器B處理)。 A和B發出描述它們所處理請求的事件,但是B的事件在A的事件發生之前到達訊息代理。現在,流處理器將首先看到B事件,然後看到A事件,即使它們實際上是以相反的順序發生的。 + +​ 有一個類比也許能幫助理解,“星球大戰”電影:第四集於1977年發行,第五集於1980年,第六集於1983年,緊隨其後的是1999年的第一集,2002年的第二集,和2005年的三集,以及2015年的第七集【80】[^ii]。如果你按照按照它們上映的順序觀看電影,你處理電影的順序與它們敘事的順序就是不一致的。 (集數編號就像事件時間戳,而你觀看電影的日期就是處理時間)作為人類,我們能夠應對這種不連續性,但是流處理演算法需要專門編寫,以適應這種時機與順序的問題。 + +[^ii]: 感謝Flink社群的Kostas Kloudas提出這個比喻。 + +​ 將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖11-7](img/fig11-7.png))。 + +![](img/fig11-7.png) + +**圖11-7 按處理時間分窗,會因為處理速率的變動引入人為因素** + +#### 知道什麼時候準備好了 + +​ 用事件時間來定義視窗的一個棘手的問題是,你永遠也無法確定是不是已經收到了特定視窗的所有事件,還是說還有一些事件正在來的路上。 + +​ 例如,假設你將事件分組為一分鐘的視窗,以便統計每分鐘的請求數。你已經計數了一些帶有本小時內第37分鐘時間戳的事件,時間流逝,現在進入的主要都是本小時內第38和第39分鐘的事件。什麼時候才能宣佈你已經完成了第37分鐘的視窗計數,並輸出其計數器值? + +​ 在一段時間沒有看到任何新的事件之後,你可以超時並宣佈一個視窗已經就緒,但仍然可能發生這種情況:某些事件被緩衝在另一臺機器上,由於網路中斷而延遲。你需要能夠處理這種在視窗宣告完成之後到達的 **滯留(straggler)** 事件。大體上,你有兩種選擇【1】: + +1. 忽略這些滯留事件,因為在正常情況下它們可能只是事件中的一小部分。你可以將丟棄事件的數量作為一個監控指標,並在出現大量丟訊息的情況時報警。 +2. 釋出一個**更正(correction)**,一個包括滯留事件的更新視窗值。更新的視窗與包含散兵隊員的價值。你可能還需要收回以前的輸出。 + +在某些情況下,可以使用特殊的訊息來指示“從現在開始,不會有比t更早時間戳的訊息了”,消費者可以使用它來觸發視窗【81】。但是,如果不同機器上的多個生產者都在生成事件,每個生產者都有自己的最小時間戳閾值,則消費者需要分別跟蹤每個生產者。在這種情況下,新增和刪除生產者都是比較棘手的。 + +#### 你用的是誰的時鐘? + +​ 當事件可能在系統內多個地方進行緩衝時,為事件分配時間戳更加困難了。例如,考慮一個移動應用向伺服器上報關於用量的事件。該應用可能會在裝置處於離線狀態時被使用,在這種情況下,它將在裝置本地緩衝事件,並在下一次網際網路連線可用時向伺服器上報這些事件(可能是幾小時甚至幾天)。對於這個流的任意消費者而言,它們就如延遲極大的滯留事件一樣。 + +​ 在這種情況下,事件上的事件戳實際上應當是使用者交互發生的時間,取決於移動裝置的本地時鐘。然而使用者控制的裝置上的時鐘通常是不可信的,因為它可能會被無意或故意設定成錯誤的時間(參見“[時鐘同步與準確性](ch8.md#時鐘同步與準確性)”)。伺服器收到事件的時間(取決於伺服器的時鐘)可能是更準確的,因為伺服器在你的控制之下,但在描述使用者互動方面意義不大。 + +要校正不正確的裝置時鐘,一種方法是記錄三個時間戳【82】: + +* 事件發生的時間,取決於裝置時鐘 +* 事件傳送往伺服器的時間,取決於裝置時鐘 +* 事件被伺服器接收的時間,取決於伺服器時鐘 + +透過從第三個時間戳中減去第二個時間戳,可以估算裝置時鐘和伺服器時鐘之間的偏移(假設網路延遲與所需的時間戳精度相比可忽略不計)。然後可以將該偏移應用於事件時間戳,從而估計事件實際發生的真實時間(假設裝置時鐘偏移在事件發生時與送往伺服器之間沒有變化)。 + +​ 這並不是流處理獨有的問題,批處理有著完全一樣的時間推理問題。只是在流處理的上下文中,我們更容易意識到時間的流逝。 + +#### 視窗的型別 + +​ 當你知道如何確定一個事件的時間戳後,下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合,例如事件計數,或計算視窗內值的平均值。有幾種視窗很常用【79,83】: + +***滾動視窗(Tumbling Window)*** + +​ 滾動視窗有著固定的長度,每個事件都僅能屬於一個視窗。例如,假設你有一個1分鐘的滾動視窗,則所有時間戳在`10:03:00`和`10:03:59`之間的事件會被分組到一個視窗中,`10:04:00`和`10:04:59`之間的事件被分組到下一個視窗,依此類推。透過將每個事件時間戳四捨五入至最近的分鐘來確定它所屬的視窗,可以實現1分鐘的滾動視窗。 + +***跳動視窗(Hopping Window)*** + +​ 跳動視窗也有著固定的長度,但允許視窗重疊以提供一些平滑。例如,一個帶有1分鐘跳躍步長的5分鐘視窗將包含`10:03:00`至`10:07:59`之間的事件,而下一個視窗將覆蓋`10:04:00`至`10:08:59`之間的事件,等等。透過首先計算1分鐘的滾動視窗,然後在幾個相鄰視窗上進行聚合,可以實現這種跳動視窗。 + +***滑動視窗(Sliding Window)*** + +​ 滑動視窗包含了彼此間距在特定時長內的所有事件。例如,一個5分鐘的滑動視窗應當覆蓋`10:03:39`和`10:08:12`的事件,因為它們相距不超過5分鐘(注意滾動視窗與步長5分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中,因為它們使用固定的邊界)。透過維護一個按時間排序的事件緩衝區,並不斷從視窗中移除過期的舊事件,可以實現滑動視窗。 + +***會話視窗(Session window)*** + +​ 與其他視窗型別不同,會話視窗沒有固定的持續時間,而定義為:將同一使用者出現時間相近的所有事件分組在一起,而當用戶一段時間沒有活動時(例如,如果30分鐘內沒有事件)視窗結束。會話切分是網站分析的常見需求(參閱“[GROUP BY](ch10.md#GROUP\ BY)”)。 + +### 流式連線 + +​ 在[第10章](ch10.md)中,我們討論了批處理作業如何透過鍵來連線資料集,以及這種連線是如何成為資料管道的重要組成部分的。由於流處理將資料管道泛化為對無限資料集進行增量處理,因此對流進行連線的需求也是完全相同的。 + +​ 然而,新事件隨時可能出現在一個流中,這使得流連線要比批處理連線更具挑戰性。為了更好地理解情況,讓我們先來區分三種不同型別的連線:**流-流**連線,**流-表**連線,與**表-表**連線【84】。我們將在下面的章節中透過例子來說明。 + +#### 流流連線(視窗連線) + +​ 假設你的網站上有搜尋功能,而你想要找出搜尋URL的近期趨勢。每當有人鍵入搜尋查詢時,都會記錄下一個包含查詢與其返回結果的事件。每當有人點選其中一個搜尋結果時,就會記錄另一個記錄點選事件。為了計算搜尋結果中每個URL的點選率,你需要將搜尋動作與點選動作的事件連在一起,這些事件透過相同的會話ID進行連線。廣告系統中需要類似的分析【85】。 + +​ 如果使用者丟棄了搜尋結果,點選可能永遠不會發生,即使它出現了,搜尋與點選之間的時間可能是高度可變的:在很多情況下,它可能是幾秒鐘,但也可能長達幾天或幾周(如果使用者執行搜尋,忘掉了這個瀏覽器頁面,過了一段時間後重新回到這個瀏覽器頁面上,並點選了一個結果)。由於可變的網路延遲,點選事件甚至可能先於搜尋事件到達。你可以選擇合適的連線視窗 —— 例如,如果點選與搜尋之間的時間間隔在一小時內,你可能會選擇連線兩者。 + +​ 請注意,在點選事件中嵌入搜尋詳情與事件連線並不一樣:這樣做的話,只有當用戶點選了一個搜尋結果時你才能知道,而那些沒有點選的搜尋就無能為力了。為了衡量搜尋質量,你需要準確的點選率,為此搜尋事件和點選事件兩者都是必要的。 + +​ 為了實現這種型別的連線,流處理器需要維護**狀態**:例如,按會話ID索引最近一小時內發生的所有事件。無論何時發生搜尋事件或點選事件,都會被新增到合適的索引中,而流處理器也會檢查另一個索引是否有具有相同會話ID的事件到達。如果有匹配事件就會發出一個表示搜尋結果被點選的事件;如果搜尋事件直到過期都沒看見有匹配的點選事件,就會發出一個表示搜尋結果未被點選的事件。 + +#### 流表連線(流擴充套件) + +​ 在“[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”([圖10-2](img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者ID的活動事件流,而輸出還是活動事件流,但其中使用者ID已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為 使用資料庫的資訊來**擴充(enriching)** 活動事件。 + +​ 要執行此聯接,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者ID,並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在“[示例:分析使用者活動事件](ch10.md#示例:分析使用者活動事件)”一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載【75】。 + +​ 另一種方法是將資料庫副本載入到流處理器中,以便在本地進行查詢而無需網路往返。這種技術與我們在“[Map端連線](ch10.md#Map端連線)”中討論的雜湊連線非常相似:如果資料庫的本地副本足夠小,則可以是記憶體中的散列表,比較大的話也可以是本地磁碟上的索引。 + +​ 與批處理作業的區別在於,批處理作業使用資料庫的時間點快照作為輸入,而流處理器是長時間執行的,且資料庫的內容可能隨時間而改變,所以流處理器資料庫的本地副本需要保持更新。這個問題可以透過變更資料捕獲來解決:流處理器可以訂閱使用者檔案資料庫的更新日誌,如同活躍事件流一樣。當增添或修改檔案時,流處理器會更新其本地副本。因此,我們有了兩個流之間的連線:活動事件和檔案更新。 + +​ 流表連線實際上非常類似於流流連線;最大的區別在於對於表的變更日誌流,連線使用了一個可以回溯到“時間起點”的視窗(概念上是無限的視窗),新版本的記錄會覆蓋更早的版本。對於輸入的流,連線可能壓根兒就沒有維護視窗。 + +#### 表表連線(維護物化檢視) + +​ 我們在“[描述負載](ch1.md#描述負載)”中討論的推特時間線例子時說過,當用戶想要檢視他們的主頁時間線時,迭代使用者所關注人群的推文併合並它們是一個開銷巨大的操作。 + +​ 相反,我們需要一個時間線快取:一種每個使用者的“收件箱”,在傳送推文的時候寫入這些資訊,因而讀取時間線時只需要簡單地查詢即可。物化與維護這個快取需要處理以下事件: + +* 當用戶u傳送新的推文時,它將被新增到每個關注使用者u的時間線上。 +* 使用者刪除推文時,推文將從所有使用者的時間表中刪除。 +* 當用戶$u_1$開始關注使用者$u_2$時,$u_2$最近的推文將被新增到$u_1$的時間線上。 +* 當用戶$u_1$取消關注使用者$u_2$時,$u_2$的推文將從$u_1$的時間線中移除。 + +要在流處理器中實現這種快取維護,你需要推文事件流(傳送與刪除)和關注關係事件流(關注與取消關注)。流處理需要為維護一個數據庫,包含每個使用者的粉絲集合。以便知道當一條新推文到達時,需要更新哪些時間線【86】。 + +觀察這個流處理過程的另一種視角是:它維護了一個連線了兩個表(推文與關注)的物化檢視,如下所示: + +```sql +SELECT follows.follower_id AS timeline_id, + array_agg(tweets.* ORDER BY tweets.timestamp DESC) +FROM tweets +JOIN follows ON follows.followee_id = tweets.sender_id +GROUP BY follows.follower_id +``` + +​ 流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當基礎表發生變化時都會更新[^iii]。 + +[^iii]: 如果你將流視作表的衍生物,如[圖11-6](img/fig11-6.png)所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。 + +#### 連線的時間依賴性 + +​ 這裡描述的三種連線(流流,流表,表表)有很多共通之處:它們都需要流處理器維護連線一側的一些狀態(搜尋與點選事件,使用者檔案,關注列表),然後當連線另一側的訊息到達時查詢該狀態。 + +​ 用於維護狀態的事件順序是很重要的(先關注然後取消關注,或者其他類似操作)。在分割槽日誌中,單個分割槽內的事件順序是保留下來的。但典型情況下是沒有跨流或跨分割槽的順序保證的。 + +​ 這就產生了一個問題:如果不同流中的事件發生在近似的時間範圍內,則應該按照什麼樣的順序進行處理?在流表連線的例子中,如果使用者更新了它們的檔案,哪些活動事件與舊檔案連線(在檔案更新前處理),哪些又與新檔案連線(在檔案更新之後處理)?換句話說:你需要對一些狀態做連線,如果狀態會隨著時間推移而變化,那應當使用什麼時間點來連線呢【45】? + +​ 這種時序依賴可能出現在很多地方。例如銷售東西需要對發票應用適當的稅率,這取決於所處的國家/州,產品型別,銷售日期(因為稅率會隨時變化)。當連線銷售額與稅率表時,你可能期望的是使用銷售時的稅率參與連線。如果你正在重新處理歷史資料,銷售時的稅率可能和現在的稅率有所不同。 + +​ 如果跨越流的事件順序是未定的,則連線會變為不確定性的【87】,這意味著你在同樣輸入上重跑相同的作業未必會得到相同的結果:當你重跑任務時,輸入流上的事件可能會以不同的方式交織。 + +​ 在資料倉庫中,這個問題被稱為**緩慢變化的維度(slowly changing dimension, SCD)**,通常透過對特定版本的記錄使用唯一的識別符號來解決:例如,每當稅率改變時都會獲得一個新的識別符號,而發票在銷售時會帶有稅率的識別符號【88,89】。這種變化使連線變為確定性的,但也會導致日誌壓縮無法進行:表中所有的記錄版本都需要保留。 + +### 容錯 + +​ 在本章的最後一節中,讓我們看一看流處理是如何容錯的。我們在[第10章](ch10.md)中看到,批處理框架可以很容易地容錯:如果MapReduce作業中的任務失敗,可以簡單地在另一臺機器上再次啟動,並且丟棄失敗任務的輸出。這種透明的重試是可能的,因為輸入檔案是不可變的,每個任務都將其輸出寫入到HDFS上的獨立檔案中,而輸出僅當任務成功完成後可見。 + +​ 特別是,批處理容錯方法可確保批處理作業的輸出與沒有出錯的情況相同,即使實際上某些任務失敗了。看起來好像每條輸入記錄都被處理了恰好一次 —— 沒有記錄被跳過,而且沒有記錄被處理兩次。儘管重啟任務意味著實際上可能會多次處理記錄,但輸出中的可見效果看上去就像只處理過一次。這個原則被稱為**恰好一次語義(exactly-once semantics)**,儘管**有效一次(effectively-once)** 可能會是一個更寫實的術語【90】。 + +​ 在流處理中也出現了同樣的容錯問題,但是處理起來沒有那麼直觀:等待某個任務完成之後再使其輸出可見並不是一個可行選項,因為你永遠無法處理完一個無限的流。 + +#### 微批次與存檔點 + +​ 一個解決方案是將流分解成小塊,並像微型批處理一樣處理每個塊。這種方法被稱為**微批次(microbatching)**,它被用於Spark Streaming 【91】。批次的大小通常約為1秒,這是對效能妥協的結果:較小的批次會導致更大的排程與協調開銷,而較大的批次意味著流處理器結果可見之前的延遲要更長。 + +​ 微批次也隱式提供了一個與批次大小相等的滾動視窗(按處理時間而不是事件時間戳分窗)。任何需要更大視窗的作業都需要顯式地將狀態從一個微批次轉移到下一個微批次。 + +​ Apache Flink則使用不同的方法,它會定期生成狀態的滾動存檔點並將其寫入持久儲存【92,93】。如果流運算元崩潰,它可以從最近的存檔點重啟,並丟棄從最近檢查點到崩潰之間的所有輸出。存檔點會由訊息流中的 **壁障(barrier)** 觸發,類似於微批次之間的邊界,但不會強制一個特定的視窗大小。 + +​ 在流處理框架的範圍內,微批次與存檔點方法提供了與批處理一樣的**恰好一次語義**。但是,只要輸出離開流處理器(例如,寫入資料庫,向外部訊息代理髮送訊息,或傳送電子郵件),框架就無法拋棄失敗批次的輸出了。在這種情況下,重啟失敗任務會導致外部副作用發生兩次,只有微批次或存檔點不足以阻止這一問題。 + +#### 原子提交再現 + +​ 為了在出現故障時表現出恰好處理一次的樣子,我們需要確保事件處理的所有輸出和副作用**當且僅當**處理成功時才會生效。這些影響包括髮送給下游運算元或外部訊息傳遞系統(包括電子郵件或推送通知)的任何訊息,任何資料庫寫入,對運算元狀態的任何變更,以及對輸入訊息的任何確認(包括在基於日誌的訊息代理中將消費者偏移量前移)。 + +​ 這些事情要麼都原子地發生,要麼都不發生,但是它們不應當失去同步。如果這種方法聽起來很熟悉,那是因為我們在分散式事務和兩階段提交的上下文中討論過它(參閱“[恰好一次的訊息處理](ch9.md#恰好一次的訊息處理)”)。 + +​ 在[第9章](ch9.md)中,我們討論了分散式事務傳統實現中的問題(如XA)。然而在限制更為嚴苛的環境中,也是有可能高效實現這種原子提交機制的。 Google Cloud Dataflow【81,92】和VoltDB 【94】中使用了這種方法,Apache Kafka有計劃加入類似的功能【95,96】。與XA不同,這些實現不會嘗試跨異構技術提供事務,而是透過在流處理框架中同時管理狀態變更與訊息傳遞來內化事務。事務協議的開銷可以透過在單個事務中處理多個輸入訊息來分攤。 + +#### 冪等性 + +​ 我們的目標是丟棄任何失敗任務的部分輸出,以便能安全地重試,而不會生效兩次。分散式事務是實現這個目標的一種方式,而另一種方式是依賴**冪等性(idempotence)**【97】。 + +​ 冪等操作是多次重複執行與單次執行效果相同的操作。例如,將鍵值儲存中的某個鍵設定為某個特定值是冪等的(再次寫入該值,只是用同樣的值替代),而遞增一個計數器不是冪等的(再次執行遞增意味著該值遞增兩次)。 + +​ 即使一個操作不是天生冪等的,往往可以透過一些額外的元資料做成冪等的。例如,在使用來自Kafka的訊息時,每條訊息都有一個持久的,單調遞增的偏移量。將值寫入外部資料庫時可以將這個偏移量帶上,這樣你就可以判斷一條更新是不是已經執行過了,因而避免重複執行。 + +​ Storm的Trident基於類似的想法來處理狀態【78】。依賴冪等性意味著隱含了一些假設:重啟一個失敗的任務必須以相同的順序重放相同的訊息(基於日誌的訊息代理能做這些事),處理必須是確定性的,沒有其他節點能同時更新相同的值【98,99】。 + +​ 當從一個處理節點故障切換到另一個節點時,可能需要進行**防護(fencing)**(參閱“[領導和鎖](ch8.md#領導和鎖)”),以防止被假死節點干擾。儘管有這麼多注意事項,冪等操作是一種實現**恰好一次語義**的有效方式,僅需很小的額外開銷。 + +#### 失敗後重建狀態 + +​ 任何需要狀態的流處理 —— 例如,任何視窗聚合(例如計數器,平均值和直方圖)以及任何用於連線的表和索引,都必須確保在失敗之後能恢復其狀態。 + +​ 一種選擇是將狀態儲存在遠端資料儲存中,並進行復制,然而正如在“[流表連線](#流表連線)”中所述,每個訊息都要查詢遠端資料庫可能會很慢。另一種方法是在流處理器本地儲存狀態,並定期複製。然後當流處理器從故障中恢復時,新任務可以讀取狀態副本,恢復處理而不丟失資料。 + +​ 例如,Flink定期捕獲運算元狀態的快照,並將它們寫入HDFS等持久儲存中【92,93】。 Samza和Kafka Streams透過將狀態變更傳送到具有日誌壓縮功能的專用Kafka主題來複制狀態變更,這與變更資料捕獲類似【84,100】。 VoltDB透過在多個節點上對每個輸入訊息進行冗餘處理來複制狀態(參閱“[真的序列執行](ch7.md#真的序列執行)”)。 + +​ 在某些情況下,甚至可能都不需要複製狀態,因為它可以從輸入流重建。例如,如果狀態是從相當短的視窗中聚合而成,則簡單地重放該視窗中的輸入事件可能是足夠快的。如果狀態是透過變更資料捕獲來維護的資料庫的本地副本,那麼也可以從日誌壓縮的變更流中重建資料庫(參閱“[日誌壓縮](#日誌壓縮)”)。 + +​ 然而,所有這些權衡取決於底層基礎架構的效能特徵:在某些系統中,網路延遲可能低於磁碟訪問延遲,網路頻寬可能與磁碟頻寬相當。沒有針對所有情況的普世理想權衡,隨著儲存和網路技術的發展,本地狀態與遠端狀態的優點也可能會互換。 + + + +## 本章小結 + +​ 在本章中,我們討論了事件流,它們所服務的目的,以及如何處理它們。在某些方面,流處理非常類似於在[第10章](ch10.md) 中討論的批處理,不過是在無限的(永無止境的)流而不是固定大小的輸入上持續進行。從這個角度來看,訊息代理和事件日誌可以視作檔案系統的流式等價物。 + +我們花了一些時間比較兩種訊息代理: + +***AMQP/JMS風格的訊息代理*** + +​ 代理將單條訊息分配給消費者,消費者在成功處理單條訊息後確認訊息。訊息被確認後從代理中刪除。這種方法適合作為一種非同步形式的RPC(另請參閱“[訊息傳遞資料流](ch4.md#訊息傳遞資料流)”),例如在任務佇列中,訊息處理的確切順序並不重要,而且訊息在處理完之後,不需要回頭重新讀取舊訊息。 + +***基於日誌的訊息代理*** + +​ 代理將一個分割槽中的所有訊息分配給同一個消費者節點,並始終以相同的順序傳遞訊息。並行是透過分割槽實現的,消費者透過存檔最近處理訊息的偏移量來跟蹤工作進度。訊息代理將訊息保留在磁碟上,因此如有必要的話,可以回跳並重新讀取舊訊息。 + +​ 基於日誌的方法與資料庫中的複製日誌(參見[第5章](ch5.md))和日誌結構儲存引擎(請參閱[第3章](ch3.md))有相似之處。我們看到,這種方法對於消費輸入流,產生衍生狀態與衍生輸出資料流的系統而言特別適用。 + +​ 就流的來源而言,我們討論了幾種可能性:使用者活動事件,定期讀數的感測器,和Feed資料(例如,金融中的市場資料)能夠自然地表示為流。我們發現將資料庫寫入視作流也是很有用的:我們可以捕獲變更日誌 —— 即對資料庫所做的所有變更的歷史記錄 —— 隱式地透過變更資料捕獲,或顯式地透過事件溯源。日誌壓縮允許流也能保有資料庫內容的完整副本。 + +​ 將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至衍生系統,你能使諸如搜尋索引,快取,以及分析系統這類衍生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。 + +​ 像流一樣維護狀態,以及訊息重放的基礎設施,是在各種流處理框架中實現流連線和容錯的基礎。我們討論了流處理的幾種目的,包括搜尋事件模式(複雜事件處理),計算分窗聚合(流分析),以及保證衍生資料系統處於最新狀態(物化檢視)。 + +​ 然後我們討論了在流處理中對時間進行推理的困難,包括處理時間與事件時間戳之間的區別,以及當你認為視窗已經完事之後,如何處理到達的掉隊事件的問題。 + +​ 我們區分了流處理中可能出現的三種連線型別: + +***流流連線*** + +​ 兩個輸入流都由活動事件組成,而連線運算元在某個時間視窗內搜尋相關的事件。例如,它可能會將同一個使用者30分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件,連線的兩側輸入可能實際上都是同一個流(**自連線(self-join)**)。 + +***流表連線*** + +​ 一個輸入流由活動事件組成,另一個輸入流是資料庫變更日誌。變更日誌保證了資料庫的本地副本是最新的。對於每個活動事件,連線運算元將查詢資料庫,並輸出一個擴充套件的活動事件。 + +***表表連線*** + +​ 兩個輸入流都是資料庫變更日誌。在這種情況下,一側的每一個變化都與另一側的最新狀態相連線。結果是兩表連線所得物化檢視的變更流。 + +最後,我們討論了在流處理中實現容錯和恰好一次語義的技術。與批處理一樣,我們需要放棄任何部分失敗任務的輸出。然而由於流處理長時間執行並持續產生輸出,所以不能簡單地丟棄所有的輸出。相反,可以使用更細粒度的恢復機制,基於微批次,存檔點,事務,或冪等寫入。 + +## 參考文獻 + +1. Tyler Akidau, Robert Bradshaw, Craig Chambers, et al.: “[The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing](http://www.vldb.org/pvldb/vol8/p1792-Akidau.pdf),” + *Proceedings of the VLDB Endowment*, volume 8, number 12, pages 1792–1803, August 2015. [doi:10.14778/2824032.2824076](http://dx.doi.org/10.14778/2824032.2824076) + +1. 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, available online at *mitpress.mit.edu* + +1. Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec: “[The Many Faces of Publish/Subscribe](http://www.cs.ru.nl/~pieter/oss/manyfaces.pdf),” *ACM Computing Surveys*, volume 35, number 2, pages 114–131, June 2003. + [doi:10.1145/857076.857078](http://dx.doi.org/10.1145/857076.857078) + +1. Joseph M. Hellerstein and Michael Stonebraker: *Readings in Database Systems*, 4th edition. MIT Press, 2005. ISBN: 978-0-262-69314-1, available online at *redbook.cs.berkeley.edu* + +1. Don Carney, Uğur Çetintemel, Mitch Cherniack, et al.: “[Monitoring Streams – A New Class of Data Management Applications](http://www.vldb.org/conf/2002/S07P02.pdf),” at *28th International Conference on Very Large Data Bases* (VLDB), August 2002. + +1. Matthew Sackman: “[Pushing Back](http://www.lshift.net/blog/2016/05/05/pushing-back/),” *lshift.net*, May 5, 2016. Vicent Martí: “[Brubeck, a statsd-Compatible Metrics Aggregator](http://githubengineering.com/brubeck/),” *githubengineering.com*, June 15, 2015. Seth Lowenberger: “[MoldUDP64 Protocol Specification V 1.00](http://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/moldudp64.pdf),” *nasdaqtrader.com*, July 2009. + +1. Pieter Hintjens: *ZeroMQ – The Guide*. O'Reilly Media, 2013. ISBN: 978-1-449-33404-8 + +1. Ian Malpass: “[Measure Anything, Measure Everything](https://codeascraft.com/2011/02/15/measure-anything-measure-everything/),” *codeascraft.com*, February 15, 2011. + +1. Dieter Plaetinck: “[25 Graphite, Grafana and statsd Gotchas](https://blog.raintank.io/25-graphite-grafana-and-statsd-gotchas/),” *blog.raintank.io*, March 3, 2016. + +1. Jeff Lindsay: “[Web Hooks to Revolutionize the Web](http://progrium.com/blog/2007/05/03/web-hooks-to-revolutionize-the-web/),” *progrium.com*, May 3, 2007. + +1. Jim N. Gray: “[Queues Are Databases](http://research.microsoft.com/pubs/69641/tr-95-56.pdf),” Microsoft Research Technical Report MSR-TR-95-56, December 1995. + +1. Mark Hapner, Rich Burridge, Rahul Sharma, et al.: “[JSR-343 Java Message Service (JMS) 2.0 Specification](https://jcp.org/en/jsr/detail?id=343),” *jms-spec.java.net*, March 2013. + +1. Sanjay Aiyagari, Matthew Arrott, Mark Atwell, et al.: “[AMQP: Advanced Message Queuing Protocol Specification](http://www.rabbitmq.com/resources/specs/amqp0-9-1.pdf),” Version 0-9-1, November 2008. + +1. “[Google Cloud Pub/Sub: A Google-Scale Messaging Service](https://cloud.google.com/pubsub/architecture),” *cloud.google.com*, 2016. + +1. “[Apache Kafka 0.9 Documentation](http://kafka.apache.org/documentation.html),” *kafka.apache.org*, November 2015. + +1. Jay Kreps, Neha Narkhede, and Jun Rao: “[Kafka: A Distributed Messaging System for Log Processing](http://www.longyu23.com/doc/Kafka.pdf),” at *6th International Workshop on Networking Meets Databases* (NetDB), June 2011. + +1. “[Amazon Kinesis Streams Developer Guide](http://docs.aws.amazon.com/streams/latest/dev/introduction.html),” *docs.aws.amazon.com*, April 2016. + +1. Leigh Stewart and Sijie Guo: “[Building DistributedLog: Twitter’s High-Performance Replicated Log Service](https://blog.twitter.com/2015/building-distributedlog-twitter-s-high-performance-replicated-log-service),” *blog.twitter.com*, September 16, 2015. + +1. “[DistributedLog Documentation](http://distributedlog.incubator.apache.org/docs/latest/),” Twitter, Inc., *distributedlog.io*, May 2016. Jay Kreps: + + “[Benchmarking Apache Kafka: 2 Million Writes Per Second (On Three Cheap Machines)](https://engineering.linkedin.com/kafka/benchmarking-apache-kafka-2-million-writes-second-three-cheap-machines),” *engineering.linkedin.com*, April 27, 2014. + +1. Kartik Paramasivam: “[How We’re Improving and Advancing Kafka at LinkedIn](https://engineering.linkedin.com/apache-kafka/how-we_re-improving-and-advancing-kafka-linkedin),” *engineering.linkedin.com*, September 2, 2015. + +1. Jay Kreps: “[The Log: What Every Software Engineer Should Know About Real-Time Data's Unifying Abstraction](http://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying),” *engineering.linkedin.com*, December 16, 2013. + +1. Shirshanka Das, Chavdar Botev, Kapil Surlaker, et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at *3rd ACM Symposium on Cloud Computing* (SoCC), October 2012. + +1. Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, et al.: “[Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-sharma.pdf),” at *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015. + +1. P. P. S. Narayan: “[Sherpa Update](http://web.archive.org/web/20160801221400/https://developer.yahoo.com/blogs/ydn/sherpa-7992.html),” *developer.yahoo.com*, June 8, . + +1. Martin Kleppmann: “[Bottled Water: Real-Time Integration of PostgreSQL and Kafka](http://martin.kleppmann.com/2015/04/23/bottled-water-real-time-postgresql-kafka.html),” *martin.kleppmann.com*, April 23, 2015. + +1. Ben Osheroff: “[Introducing Maxwell, a mysql-to-kafka Binlog Processor](https://developer.zendesk.com/blog/introducing-maxwell-a-mysql-to-kafka-binlog-processor),” *developer.zendesk.com*, August 20, 2015. + +1. Randall Hauch: “[Debezium 0.2.1 Released](http://debezium.io/blog/2016/06/10/Debezium-0/),” *debezium.io*, June 10, 2016. + +1. Prem Santosh Udaya Shankar: “[Streaming MySQL Tables in Real-Time to Kafka](https://engineeringblog.yelp.com/2016/08/streaming-mysql-tables-in-real-time-to-kafka.html),” *engineeringblog.yelp.com*, August 1, 2016. + +1. “[Mongoriver](https://github.com/stripe/mongoriver),” Stripe, Inc., *github.com*, September 2014. + +1. Dan Harvey: “[Change Data Capture with Mongo + Kafka](http://www.slideshare.net/danharvey/change-data-capture-with-mongodb-and-kafka),” at *Hadoop Users Group UK*, August 2015. + +1. “[Oracle GoldenGate 12c: Real-Time Access to Real-Time Information](http://www.oracle.com/us/products/middleware/data-integration/oracle-goldengate-realtime-access-2031152.pdf),” Oracle White Paper, March 2015. + +1. “[Oracle GoldenGate Fundamentals: How Oracle GoldenGate Works](https://www.youtube.com/watch?v=6H9NibIiPQE),” Oracle Corporation, *youtube.com*, November 2012. + +1. Slava Akhmechet: “[Advancing the Realtime Web](http://rethinkdb.com/blog/realtime-web/),” *rethinkdb.com*, January 27, 2015. + +1. “[Firebase Realtime Database Documentation](https://firebase.google.com/docs/database/),” Google, Inc., *firebase.google.com*, May 2016. + +1. “[Apache CouchDB 1.6 Documentation](http://docs.couchdb.org/en/latest/),” *docs.couchdb.org*, 2014. + +1. Matt DeBergalis: “[Meteor 0.7.0: Scalable Database Queries Using MongoDB Oplog Instead of Poll-and-Diff](http://info.meteor.com/blog/meteor-070-scalable-database-queries-using-mongodb-oplog-instead-of-poll-and-diff),” *info.meteor.com*, December 17, 2013. + +1. “[Chapter 15. Importing and Exporting Live Data](https://docs.voltdb.com/UsingVoltDB/ChapExport.php),” VoltDB 6.4 User Manual, *docs.voltdb.com*, June 2016. + +1. Neha Narkhede: “[Announcing Kafka Connect: Building Large-Scale Low-Latency Data Pipelines](http://www.confluent.io/blog/announcing-kafka-connect-building-large-scale-low-latency-data-pipelines),” *confluent.io*, February 18, 2016. + +1. Greg Young: “[CQRS and Event Sourcing](https://www.youtube.com/watch?v=JHGkaShoyNs),” at *Code on the Beach*, August 2014. + +1. Martin Fowler: “[Event Sourcing](http://martinfowler.com/eaaDev/EventSourcing.html),” *martinfowler.com*, December 12, 2005. + +1. Vaughn Vernon: *Implementing Domain-Driven Design*. Addison-Wesley Professional, 2013. ISBN: 978-0-321-83457-7 + +1. H. V. Jagadish, Inderpal Singh Mumick, and Abraham Silberschatz: “[View Maintenance Issues for the Chronicle Data Model](http://www.mathcs.emory.edu/~cheung/papers/StreamDB/Histogram/1995-Jagadish-Histo.pdf),” at *14th ACM SIGACT-SIGMOD-SIGART Symposium on Principles of Database Systems* (PODS), May 1995. [doi:10.1145/212433.220201](http://dx.doi.org/10.1145/212433.220201) + +1. “[Event Store 3.5.0 Documentation](http://docs.geteventstore.com/),” Event Store LLP, *docs.geteventstore.com*, February 2016. + +1. Martin Kleppmann: *Making Sense of Stream Processing*. Report, O'Reilly Media, May 2016. + +1. Sander Mak: “[Event-Sourced Architectures with Akka](http://www.slideshare.net/SanderMak/eventsourced-architectures-with-akka),” at *JavaOne*, September 2014. + +1. Julian Hyde: [personal communication](https://twitter.com/julianhyde/status/743374145006641153), June 2016. + +1. Ashish Gupta and Inderpal Singh Mumick: *Materialized Views: Techniques, Implementations, and Applications*. MIT Press, 1999. ISBN: 978-0-262-57122-7 + +1. Timothy Griffin and Leonid Libkin: “[Incremental Maintenance of Views with Duplicates](http://homepages.inf.ed.ac.uk/libkin/papers/sigmod95.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1995. [doi:10.1145/223784.223849](http://dx.doi.org/10.1145/223784.223849) + +1. Pat Helland: “[Immutability Changes Everything](http://www.cidrdb.org/cidr2015/Papers/CIDR15_Paper16.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015. + +1. Martin Kleppmann: “[Accounting for Computer Scientists](http://martin.kleppmann.com/2011/03/07/accounting-for-computer-scientists.html),” *martin.kleppmann.com*, March 7, 2011. + +1. Pat Helland: “[Accountants Don't Use Erasers](https://blogs.msdn.microsoft.com/pathelland/2007/06/14/accountants-dont-use-erasers/),” *blogs.msdn.com*, June 14, 2007. + +1. Fangjin Yang: “[Dogfooding with Druid, Samza, and Kafka: Metametrics at Metamarkets](https://metamarkets.com/2015/dogfooding-with-druid-samza-and-kafka-metametrics-at-metamarkets/),” *metamarkets.com*, June 3, 2015. + +1. Gavin Li, Jianqiu Lv, and Hang Qi: “[Pistachio: Co-Locate the Data and Compute for Fastest Cloud Compute](http://yahoohadoop.tumblr.com/post/116365275781/pistachio-co-locate-the-data-and-compute-for),” *yahoohadoop.tumblr.com*, April 13, 2015. + +1. Kartik Paramasivam: “[Stream Processing Hard Problems – Part 1: Killing Lambda](https://engineering.linkedin.com/blog/2016/06/stream-processing-hard-problems-part-1-killing-lambda),” *engineering.linkedin.com*, June 27, 2016. + +1. Martin Fowler: “[CQRS](http://martinfowler.com/bliki/CQRS.html),” *martinfowler.com*, July 14, 2011. + +1. Greg Young: “[CQRS Documents](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf),” *cqrs.files.wordpress.com*, November 2010. + +1. Baron Schwartz: “[Immutability, MVCC, and Garbage Collection](http://www.xaprb.com/blog/2013/12/28/immutability-mvcc-and-garbage-collection/),” *xaprb.com*, December 28, 2013. + +1. Daniel Eloff, Slava Akhmechet, Jay Kreps, et al.: ["Re: Turning the Database Inside-out with Apache Samza](https://news.ycombinator.com/item?id=9145197)," *Hacker News discussion, news.ycombinator.com*, March 4, 2015. + +1. “[Datomic Development Resources: Excision](http://docs.datomic.com/excision.html),” Cognitect, Inc., *docs.datomic.com*. + +1. “[Fossil Documentation: Deleting Content from Fossil](http://fossil-scm.org/index.html/doc/trunk/www/shunning.wiki),” *fossil-scm.org*, 2016. + +1. Jay Kreps: “[The irony of distributed systems is that data loss is really easy but deleting data is surprisingly hard,](https://twitter.com/jaykreps/status/582580836425330688)” *twitter.com*, March 30, 2015. + +1. David C. Luckham: “[What’s the Difference Between ESP and CEP?](http://www.complexevents.com/2006/08/01/what%E2%80%99s-the-difference-between-esp-and-cep/),” *complexevents.com*, August 1, 2006. + +1. Srinath Perera: “[How Is Stream Processing and Complex Event Processing (CEP) Different?](https://www.quora.com/How-is-stream-processing-and-complex-event-processing-CEP-different),” *quora.com*, December 3, 2015. + +1. Arvind Arasu, Shivnath Babu, and Jennifer Widom: “[The CQL Continuous Query Language: Semantic Foundations and Query Execution](http://research.microsoft.com/pubs/77607/cql.pdf),” *The VLDB Journal*, volume 15, number 2, pages 121–142, June 2006. [doi:10.1007/s00778-004-0147-z](http://dx.doi.org/10.1007/s00778-004-0147-z) + +1. Julian Hyde: “[Data in Flight: How Streaming SQL Technology Can Help Solve the Web 2.0 Data Crunch](http://queue.acm.org/detail.cfm?id=1667562),” *ACM Queue*, volume 7, number 11, December 2009. [doi:10.1145/1661785.1667562](http://dx.doi.org/10.1145/1661785.1667562) + +1. “[Esper Reference, Version 5.4.0](http://www.espertech.com/esper/release-5.4.0/esper-reference/html_single/index.html),” EsperTech, Inc., *espertech.com*, April 2016. + +1. Zubair Nabi, Eric Bouillet, Andrew Bainbridge, and Chris Thomas: + “[Of Streams and Storms](https://developer.ibm.com/streamsdev/wp-content/uploads/sites/15/2014/04/Streams-and-Storm-April-2014-Final.pdf),” IBM technical report, *developer.ibm.com*, April 2014. + +1. Milinda Pathirage, Julian Hyde, Yi Pan, and Beth Plale: “[SamzaSQL: Scalable Fast Data Management with Streaming SQL](https://github.com/milinda/samzasql-hpbdc2016/blob/master/samzasql-hpbdc2016.pdf),” at *IEEE International Workshop on High-Performance Big Data Computing* (HPBDC), May 2016. [doi:10.1109/IPDPSW.2016.141](http://dx.doi.org/10.1109/IPDPSW.2016.141) + +1. Philippe Flajolet, Éric Fusy, Olivier Gandouet, and Frédéric Meunier: “[HyperLo⁠g​Log: The Analysis of a Near-Optimal Cardinality Estimation Algorithm](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf),” at *Conference on Analysis of Algorithms* (AofA), June 2007. + +1. Jay Kreps: “[Questioning the Lambda Architecture](https://www.oreilly.com/ideas/questioning-the-lambda-architecture),” *oreilly.com*, July 2, 2014. + +1. Ian Hellström: “[An Overview of Apache Streaming Technologies](https://databaseline.wordpress.com/2016/03/12/an-overview-of-apache-streaming-technologies/),” *databaseline.wordpress.com*, March 12, 2016. + +1. Jay Kreps: “[Why Local State Is a Fundamental Primitive in Stream Processing](https://www.oreilly.com/ideas/why-local-state-is-a-fundamental-primitive-in-stream-processing),” *oreilly.com*, July 31, 2014. + +1. Shay Banon: “[Percolator](https://www.elastic.co/blog/percolator),” *elastic.co*, February 8, 2011. + +1. Alan Woodward and Martin Kleppmann: “[Real-Time Full-Text Search with Luwak and Samza](http://martin.kleppmann.com/2015/04/13/real-time-full-text-search-luwak-samza.html),” *martin.kleppmann.com*, April 13, 2015. + +1. “[Apache Storm 1.0.1 Documentation](https://storm.apache.org/releases/1.0.1/index.html),” *storm.apache.org*, May 2016. + +1. Tyler Akidau: “[The World Beyond Batch: Streaming 102](https://www.oreilly.com/ideas/the-world-beyond-batch-streaming-102),” *oreilly.com*, January 20, 2016. + +1. Stephan Ewen: “[Streaming Analytics with Apache Flink](http://www.confluent.io/kafka-summit-2016-systems-advanced-streaming-analytics-with-apache-flink-and-apache-kafka),” at *Kafka Summit*, April 2016. + +1. Tyler Akidau, Alex Balikov, Kaya Bekiroğlu, et al.: “[MillWheel: Fault-Tolerant Stream Processing at Internet Scale](http://research.google.com/pubs/pub41378.html),” at *39th International Conference on Very Large Data Bases* (VLDB), August 2013. + +1. Alex Dean: “[Improving Snowplow's Understanding of Time](http://snowplowanalytics.com/blog/2015/09/15/improving-snowplows-understanding-of-time/),” *snowplowanalytics.com*, September 15, 2015. + +1. “[Windowing (Azure Stream Analytics)](https://msdn.microsoft.com/en-us/library/azure/dn835019.aspx),” Microsoft Azure Reference, *msdn.microsoft.com*, April 2016. + +1. “[State Management](http://samza.apache.org/learn/documentation/0.10/container/state-management.html),” Apache Samza 0.10 Documentation, *samza.apache.org*, December 2015. + +1. Rajagopal Ananthanarayanan, Venkatesh Basker, Sumit Das, et al.: “[Photon: Fault-Tolerant and Scalable Joining of Continuous Data Streams](http://research.google.com/pubs/pub41318.html),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013. [doi:10.1145/2463676.2465272](http://dx.doi.org/10.1145/2463676.2465272) + +1. Martin Kleppmann: “[Samza Newsfeed Demo](https://github.com/ept/newsfeed),” *github.com*, September 2014. + +1. Ben Kirwin: “[Doing the Impossible: Exactly-Once Messaging Patterns in Kafka](http://ben.kirw.in/2014/11/28/kafka-patterns/),” *ben.kirw.in*, November 28, 2014. + +1. Pat Helland: “[Data on the Outside Versus Data on the Inside](http://cidrdb.org/cidr2005/papers/P12.pdf),” at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005. + +1. 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 + +1. Viktor Klang: “[I'm coining the phrase 'effectively-once' for message processing with at-least-once + idempotent operations](https://twitter.com/viktorklang/status/789036133434978304),” *twitter.com*, October 20, 2016. + +1. Matei Zaharia, Tathagata Das, Haoyuan Li, et al.: “[Discretized Streams: An Efficient and Fault-Tolerant Model for Stream Processing on Large Clusters](https://www.usenix.org/system/files/conference/hotcloud12/hotcloud12-final28.pdf),” at *4th USENIX Conference in Hot Topics in Cloud Computing* (HotCloud), June 2012. + +1. Kostas Tzoumas, Stephan Ewen, and Robert Metzger: “[High-Throughput, Low-Latency, and Exactly-Once Stream Processing with Apache Flink](http://data-artisans.com/high-throughput-low-latency-and-exactly-once-stream-processing-with-apache-flink/),” *data-artisans.com*, August 5, 2015. + +1. Paris Carbone, Gyula Fóra, Stephan Ewen, et al.: “[Lightweight Asynchronous Snapshots for Distributed Dataflows](http://arxiv.org/abs/1506.08603),” arXiv:1506.08603 [cs.DC], June 29, 2015. + +1. Ryan Betts and John Hugg: *Fast Data: Smart and at Scale*. Report, O'Reilly Media, October 2015. + +1. Flavio Junqueira: “[Making Sense of Exactly-Once Semantics](http://conferences.oreilly.com/strata/hadoop-big-data-eu/public/schedule/detail/49690),” at *Strata+Hadoop World London*, June 2016. + +1. Jason Gustafson, Flavio Junqueira, Apurva Mehta, Sriram Subramanian, and Guozhang Wang: “[KIP-98 – Exactly Once Delivery and Transactional Messaging](https://cwiki.apache.org/confluence/display/KAFKA/KIP-98+-+Exactly+Once+Delivery+and+Transactional+Messaging),” *cwiki.apache.org*, November 2016. + +1. Pat Helland: “[Idempotence Is Not a Medical Condition](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.401.1539&rep=rep1&type=pdf),” *Communications of the ACM*, volume 55, number 5, page 56, May 2012. [doi:10.1145/2160718.2160734](http://dx.doi.org/10.1145/2160718.2160734) + +1. Jay Kreps: “[Re: Trying to Achieve Deterministic Behavior on Recovery/Rewind](http://mail-archives.apache.org/mod_mbox/samza-dev/201409.mbox/%3CCAOeJiJg%2Bc7Ei%3DgzCuOz30DD3G5Hm9yFY%3DUJ6SafdNUFbvRgorg%40mail.gmail.com%3E),” email to *samza-dev* mailing list, September 9, 2014. + +1. E. N. (Mootaz) Elnozahy, Lorenzo Alvisi, Yi-Min Wang, and David B. Johnson: “[A Survey of Rollback-Recovery Protocols in Message-Passing Systems](http://www.cs.utexas.edu/~lorenzo/papers/SurveyFinal.pdf),” *ACM Computing Surveys*, volume 34, number 3, pages 375–408, September 2002. [doi:10.1145/568522.568525](http://dx.doi.org/10.1145/568522.568525) + +1. Adam Warski: “[Kafka Streams – How Does It Fit the Stream Processing Landscape?](https://softwaremill.com/kafka-streams-how-does-it-fit-stream-landscape/),” *softwaremill.com*, June 1, 2016. + + + + +------ + +| 上一章 | 目錄 | 下一章 | +| ------------------------- | ------------------------------- | ---------------------------------- | +| [第十章:批處理](ch10.md) | [設計資料密集型應用](README.md) | [第十二章:資料系統的未來](ch12.md) | \ No newline at end of file diff --git a/zh-tw/ch12.md b/zh-tw/ch12.md new file mode 100644 index 00000000..bed9b25e --- /dev/null +++ b/zh-tw/ch12.md @@ -0,0 +1,1152 @@ +# 12. 資料系統的未來 + +![](img/ch12.png) + +> 如果船長的終極目標是保護船隻,他應該永遠待在港口。 +> +> ——聖托馬斯·阿奎那《神學大全》(1265-1274) + +--------------- + +[TOC] + +​ 到目前為止,本書主要描述的是**現狀**。在這最後一章中,我們將放眼**未來**,討論應該是怎麼樣的:我將提出一些想法與方法,我相信它們能從根本上改進我們設計與構建應用的方式。 + +​ 對未來的看法與推測當然具有很大的主觀性。所以在撰寫本章時,當提及我個人的觀點時會使用第一人稱。您完全可以不同意這些觀點並提出自己的看法,但我希望本章中的概念,至少能成為富有成效的討論出發點,並澄清一些經常被混淆的概念。 + +​ [第1章](ch1.md)概述了本書的目標:探索如何建立**可靠**,**可擴充套件**和**可維護**的應用與系統。這一主題貫穿了所有的章節:例如,我們討論了許多有助於提高可靠性的容錯演算法,有助於提高可擴充套件性的分割槽,以及有助於提高可維護性的演化與抽象機制。在本章中,我們將把所有這些想法結合在一起,並在它們的基礎上展望未來。我們的目標是,發現如何設計出比現有應用更好的應用 —— 健壯,正確,可演化,且最終對人類有益。 + +## 資料整合 + +​ 本書中反覆出現的主題是,對於任何給定的問題都會有好幾種解決方案,所有這些解決方案都有不同的優缺點與利弊權衡。例如在[第3章](ch3.md)討論儲存引擎時,我們看到了日誌結構儲存,B樹,以及列儲存。在[第5章](ch5.md)討論複製時,我們看到了單領導者,多領導者,和無領導者的方法。 + +​ 如果你有一個類似於“我想儲存一些資料並稍後再查詢”的問題,那麼並沒有一種正確的解決方案。但對於不同的具體環境,總會有不同的合適方法。軟體實現通常必須選擇一種特定的方法。使單條程式碼路徑能做到穩定健壯且表現良好已經是一件非常困難的事情了 —— 嘗試在單個軟體中完成所有事情,幾乎可以保證,實現效果會很差。 + +​ 因此軟體工具的最佳選擇也取決於情況。每一種軟體,甚至所謂的“通用”資料庫,都是針對特定的使用模式設計的。 + +​ 面對讓人眼花繚亂的諸多替代品,第一個挑戰就是弄清軟體與其適用環境的對映關係。供應商不願告訴你他們軟體不適用的工作負載,這是可以理解的。但是希望先前的章節能給你提供一些問題,讓你讀出字裡行間的言外之意,並更好地理解這些權衡。 + +​ 但是,即使你已經完全理解各種工具與其適用環境間的關係,還有一個挑戰:在複雜的應用中,資料的用法通常花樣百出。不太可能存在適用於**所有**不同資料應用場景的軟體,因此您不可避免地需要拼湊幾個不同的軟體來以提供應用所需的功能。 + +### 組合使用衍生資料的工具 + +​ 例如,為了處理任意關鍵詞的搜尋查詢,將OLTP資料庫與全文搜尋索引整合在一起是很常見的的需求。儘管一些資料庫(例如PostgreSQL)包含了全文索引功能,對於簡單的應用完全夠了【1】,但更復雜的搜尋能力就需要專業的資訊檢索工具了。相反的是,搜尋索引通常不適合作為持久的記錄系統,因此許多應用需要組合這兩種不同的工具以滿足所有需求。 + +​ 我們在“[使系統保持同步](ch11.md#使系統保持同步)”中接觸過整合資料系統的問題。隨著資料不同表示形式的增加,整合問題變得越來越困難。除了資料庫和搜尋索引之外,也許你需要在分析系統(資料倉庫,或批處理和流處理系統)中維護資料副本;維護從原始資料中衍生的快取,或反規範化的資料版本;將資料灌入機器學習,分類,排名,或推薦系統中;或者基於資料變更傳送通知。 + +​ 令人驚訝的是,我經常看到軟體工程師做出這樣的陳述:“根據我的經驗,99%的人只需要X”或者 “......不需要X”(對於各種各樣的X)。我認為這種陳述更像是發言人自己的經驗,而不是技術實際上的實用性。可能對資料執行的操作,其範圍極其寬廣。某人認為雞肋而毫無意義的功能可能是別人的核心需求。當你拉高視角,並考慮跨越整個組織範圍的資料流時,資料整合的需求往往就會變得明顯起來。 + +#### 理解資料流 + +​ 當需要在多個儲存系統中維護相同資料的副本以滿足不同的訪問模式時,你要對輸入和輸出瞭如指掌:哪些資料先寫入,哪些資料表示衍生自哪些來源?如何以正確的格式,將所有資料匯入正確的地方? + +​ 例如,你可能會首先將資料寫入**記錄資料庫**系統,捕獲對該資料庫所做的變更(參閱“[捕獲資料變更](ch11.md#捕獲資料變更)”),然後將變更應用於資料庫中的搜尋索引相同的順序。如果變更資料捕獲(CDC)是更新索引的唯一方式,則可以確定該索引完全派生自記錄系統,因此與其保持一致(除軟體錯誤外)。寫入資料庫是向該系統提供新輸入的唯一方式。 + +​ 允許應用程式直接寫入搜尋索引和資料庫引入瞭如[圖11-4](img/fig11-4.png)所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。 + +​ 如果您可以透過單個系統來提供所有使用者輸入,從而決定所有寫入的排序,則透過按相同順序處理寫入,可以更容易地衍生出其他資料表示。 這是狀態機複製方法的一個應用,我們在“[全序廣播](ch9.md#全序廣播)”中看到。無論您使用變更資料捕獲還是事件源日誌,都不如僅對全域性順序達成共識更重要。 + +​ 基於事件日誌來更新衍生資料的系統,通常可以做到**確定性**與**冪等性**(參見第478頁的“[冪等性]()”),使得從故障中恢復相當容易。 + +#### 衍生資料與分散式事務 + +​ 保持不同資料系統彼此一致的經典方法涉及分散式事務,如“[原子提交和兩階段提交(2PC)](ch9.md#原子提交和兩階段提交(2PC))”中所述。與分散式事務相比,使用衍生資料系統的方法如何? + +​ 在抽象層面,它們透過不同的方式達到類似的目標。分散式事務透過**鎖**進行互斥來決定寫入的順序(參閱“[兩階段鎖定(2PL)](ch7.md#兩階段鎖定(2PL))”),而CDC和事件溯源使用日誌進行排序。分散式事務使用原子提交來確保變更只生效一次,而基於日誌的系統通常基於**確定性重試**和**冪等性**。 + +​ 最大的不同之處在於事務系統通常提供 **[線性一致性](ch9.md#線性一致性)**,這包含著有用的保證,例如[讀己之寫](ch5.md#讀己之寫)。另一方面,衍生資料系統通常是非同步更新的,因此它們預設不會提供相同的時序保證。 + +​ 在願意為分散式事務付出代價的有限場景中,它們已被成功應用。但是,我認為XA的容錯能力和效能很差勁(參閱“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”),這嚴重限制了它的實用性。我相信為分散式事務設計一種更好的協議是可行的。但使這樣一種協議被現有工具廣泛接受是很有挑戰的,且不是立竿見影的事。 + +​ 在沒有廣泛支援的良好分散式事務協議的情況下,我認為基於日誌的衍生資料是整合不同資料系統的最有前途的方法。然而,諸如讀己之寫的保證是有用的,我認為告訴所有人“最終一致性是不可避免的 —— 忍一忍並學會和它打交道”是沒有什麼建設性的(至少在缺乏**如何**應對的良好指導時)。 + +​ 在“[將事情做正確](#將事情做正確)”中,我們將討論一些在非同步衍生系統之上實現更強保障的方法,並邁向分散式事務和基於日誌的非同步系統之間的中間地帶。 + +#### 全域性有序的限制 + +​ 對於足夠小的系統,構建一個完全有序的事件日誌是完全可行的(正如單主複製資料庫的流行所證明的那樣,這正好建立了這樣一種日誌)。但是,隨著系統向更大更復雜的工作負載擴充套件,限制開始出現: + +* 在大多數情況下,構建完全有序的日誌,需要所有事件彙集於決定順序的單個領導節點。如果事件吞吐量大於單臺計算機的處理能力,則需要將其分割到多臺計算機上(參見“[分割槽日誌](ch11.md#分割槽日誌)”)。然後兩個不同分割槽中的事件順序關係就不明確了。 + + +* 如果伺服器分佈在多個**地理位置分散**的資料中心上,例如為了容忍整個資料中心掉線,您通常在每個資料中心都有單獨的主庫,因為網路延遲會導致同步的跨資料中心協調效率低下(請參閱“[多主複製](ch5.md#多主複製)“)。這意味著源自兩個不同資料中心的事件順序未定義。 + + +* 將應用程式部署為微服務時(請參閱第125頁上的“[服務中的資料流:REST與RPC](ch4.md#服務中的資料流:REST與RPC)”),常見的設計選擇是將每個服務及其持久狀態作為獨立單元進行部署,服務之間不共享持久狀態。當兩個事件來自不同的服務時,這些事件間的順序未定義。 + + +* 某些應用程式在客戶端儲存狀態,該狀態在使用者輸入時立即更新(無需等待伺服器確認),甚至可以繼續離線工作(參閱“[離線操作的客戶端](ch5.md#離線操作的客戶端)”)。有了這樣的應用程式,客戶端和伺服器很可能以不同的順序看到事件。 + +在形式上,決定事件的全域性順序稱為**全序廣播**,相當於**共識**(參閱“[共識演算法和全序廣播](ch9.md#共識演算法和全序廣播)”)。大多數共識演算法都是針對單個節點的吞吐量足以處理整個事件流的情況而設計的,並且這些演算法不提供多個節點共享事件排序工作的機制。設計可以擴充套件到單個節點的吞吐量之上,且在地理散佈環境中仍然工作良好的的共識演算法仍然是一個開放的研究問題。 + +#### 排序事件以捕捉因果關係 + +​ 在事件之間不存在因果關係的情況下,缺乏全域性順序並不是一個大問題,因為併發事件可以任意排序。其他一些情況很容易處理:例如,當同一物件有多個更新時,它們可以透過將特定物件ID的所有更新路由到相同的日誌分割槽來完全排序。然而,因果關係有時會以更微妙的方式出現(參閱“[順序和因果關係](ch8.md#順序和因果關係)”)。 + +​ 例如,考慮一個社交網路服務,以及一對曾處於戀愛關係但剛分手的使用者。其中一個使用者將另一個使用者從好友中移除,然後向剩餘的好友傳送訊息,抱怨他們的前任。使用者的心思是他們的前任不應該看到這些粗魯的訊息,因為訊息是在好友狀態解除後傳送的。 + +​ 但是如果好友關係狀態與訊息儲存在不同的地方,在這樣一個系統中,可能會出現**解除好友**事件與**傳送訊息**事件之間的因果依賴丟失的情況。如果因果依賴關係沒有被捕捉到,則傳送有關新訊息的通知的服務可能會在**解除好友**事件之前處理**傳送訊息**事件,從而錯誤地向前任傳送通知。 + +​ 在本例中,通知實際上是訊息和好友列表之間的連線,使得它與我們先前討論的連線的時間問題有關(請參閱第475頁的“[連線的時間依賴性](ch11.md#連線的時間依賴性)”)。不幸的是,這個問題似乎並沒有一個簡單的答案【2,3】。起點包括: + +* 邏輯時間戳可以提供無需協調的全域性順序(參見“[序列號排序](ch8.md#序列號排序)”),因此它們可能有助於全序廣播不可行的情況。但是,他們仍然要求收件人處理不按順序傳送的事件,並且需要傳遞其他元資料。 +* 如果你可以記錄一個事件來記錄使用者在做出決定之前所看到的系統狀態,並給該事件一個唯一的識別符號,那麼後面的任何事件都可以引用該事件識別符號來記錄因果關係【4】 。我們將在“[讀也是事件](#讀也是事件)”中回到這個想法。 +* 衝突解決演算法(請參閱“[自動衝突解決](ch5.md#自動衝突解決)”)有助於處理以意外順序傳遞的事件。它們對於維護狀態很有用,但如果行為有外部副作用(例如,也許,隨著時間的推移,應用開發模式將出現,使得能夠有效地捕獲因果依賴關係,並且保持正確的衍生狀態,而不會迫使所有事件經歷全序廣播的瓶頸)。 + +### 批處理與流處理 + +​ 我會說資料整合的目標是,確保資料最終能在所有正確的地方表現出正確的形式。這樣做需要消費輸入,轉換,連線,過濾,聚合,訓練模型,評估,以及最終寫出適當的輸出。批處理和流處理是實現這一目標的工具。 + +​ 批處理和流處理的輸出是衍生資料集,例如搜尋索引,物化檢視,向用戶顯示的建議,聚合指標等(請參閱“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”和“[流處理的用法](ch11.md#流處理的用法)”)。 + +​ 正如我們在[第10章](ch10.md)和[第11章](ch11.md)中看到的,批處理和流處理有許多共同的原則,主要的根本區別在於流處理器在無限資料集上執行,而批處理輸入是已知的有限大小。處理引擎的實現方式也有很多細節上的差異,但是這些區別已經開始模糊。 + +​ Spark在批處理引擎上執行流處理,將流分解為**微批次(microbatches)**,而Apache Flink則在流處理引擎上執行批處理【5】。原則上,一種型別的處理可以用另一種型別來模擬,但是效能特徵會有所不同:例如,在跳躍或滑動視窗上,微批次可能表現不佳【6】。 + +#### 維護衍生狀態 + +​ 批處理有著很強的函式式風格(即使其程式碼不是用函式式語言編寫的):它鼓勵確定性的純函式,其輸出僅依賴於輸入,除了顯式輸出外沒有副作用,將輸入視作不可變的,且輸出是僅追加的。流處理與之類似,但它擴充套件了運算元以允許受管理的,容錯的狀態(參閱“[失敗後重建狀態”](ch11.md#失敗後重建狀態))。 + +​ 具有良好定義的輸入和輸出的確定性函式的原理不僅有利於容錯(參見“[冪等性](ch11.md#冪等性)”),也簡化了有關組織中資料流的推理【7】。無論衍生資料是搜尋索引,統計模型還是快取,採用這種觀點思考都是很有幫助的:將其視為從一個東西衍生出另一個的資料管道,將一個系統的狀態變更推送至函式式應用程式碼中,並將其效果應用至衍生系統中。 + +​ 原則上,衍生資料系統可以同步地維護,就像關係資料庫在與被索引表寫入操作相同的事務中同步更新輔助索引一樣。然而,非同步是基於事件日誌的系統穩健的原因:它允許系統的一部分故障被抑制在本地,而如果任何一個參與者失敗,分散式事務將中止,因此它們傾向於透過將故障傳播到系統的其餘部分來放大故障(請參閱“[分散式事務的限制](ch9.md#分散式事務的限制)”)。 + +​ 我們在“[分割槽與次級索引](ch6.md#分割槽與次級索引)”中看到,二級索引經常跨越分割槽邊界。具有二級索引的分割槽系統需要將寫入傳送到多個分割槽(如果索引按關鍵詞分割槽的話)或將讀取傳送到所有分割槽(如果索引是按文件分割槽的話)。如果索引是非同步維護的,這種交叉分割槽通訊也是最可靠和最可擴充套件的【8】(另請參閱“[多分割槽資料處理](ch11.md#多分割槽資料處理)”)。 + +#### 應用演化後重新處理資料 + +​ 在維護衍生資料時,批處理和流處理都是有用的。流處理允許將輸入中的變化以低延遲反映在衍生檢視中,而批處理允許重新處理大量累積的歷史資料以便將新檢視匯出到現有資料集上。 + +​ 特別是,重新處理現有資料為維護系統提供了一個良好的機制,演化並支援新功能和需求變更(參見[第4章](ch4.md))。不需要重新進行處理,模式演化僅限於簡單的變化,例如向記錄中新增新的可選欄位或新增新型別的記錄。無論是在寫模式還是在讀模式中都是如此(參閱“[文件模型中的模式靈活性](ch2.md#文件模型中的模式靈活性)”)。另一方面,透過重新處理,可以將資料集重組為一個完全不同的模型,以便更好地滿足新的要求。 + +> ### 鐵路上的模式遷移 +> +> ​ 大規模的“模式遷移”也發生在非計算機系統中。例如,在19世紀英國鐵路建設初期,軌距(兩軌之間的距離)就有了各種各樣的競爭標準。為一種軌距而建的列車不能在另一種軌距的軌道上執行,這限制了火車網路中可能的相互連線【9】。 +> +> ​ 在1846年最終確定了一個標準軌距之後,其他軌距的軌道必須轉換 —— 但是如何在不停運火車線路的情況下進行數月甚至數年的遷移?解決的辦法是首先透過新增第三條軌道將軌道轉換為**雙軌距(dual guage)**或**混合軌距**。這種轉換可以逐漸完成,當完成時,兩種軌距的列車都可以線上路上跑,使用三條軌道中的兩條。事實上,一旦所有的列車都轉換成標準軌距,那麼可以移除提供非標準軌距的軌道。 +> +> ​ 以這種方式“再加工”現有的軌道,讓新舊版本並存,可以在幾年的時間內逐漸改變軌距。然而,這是一項昂貴的事業,這就是今天非標準軌距仍然存在的原因。例如,舊金山灣區的BART系統使用與美國大部分地區不同的軌距。 + +​ 衍生檢視允許**漸進演化(gradual evolution)**。如果你想重新構建資料集,不需要執行遷移,例如**突然切換**。取而代之的是,你可以將舊架構和新架構並排維護為相同基礎資料上的兩個獨立衍生檢視。然後可以開始將少量使用者轉移到新檢視,以測試其效能並發現任何錯誤,而大多數使用者仍然會被路由到舊檢視。你可以逐漸地增加訪問新檢視的使用者比例,最終可以刪除舊檢視【10】。 + +​ 這種逐漸遷移的美妙之處在於,如果出現問題,每個階段的過程都很容易逆轉:你始終有一個可以回滾的可用系統。透過降低不可逆損害的風險,你能對繼續前進更有信心,從而更快地改善系統【11】。 + +#### Lambda架構 + +​ 如果批處理用於重新處理歷史資料,並且流處理用於處理最近的更新,那麼如何將這兩者結合起來?Lambda架構【12】是這方面的一個建議,引起了很多關注。 + +​ Lambda架構的核心思想是透過將不可變事件附加到不斷增長的資料集來記錄傳入資料,這類似於事件溯源(參閱“[事件溯源](ch11.md#事件溯源)”)。為了從這些事件中衍生出讀取最佳化的檢視, Lambda架構建議並行執行兩個不同的系統:批處理系統(如Hadoop MapReduce)和獨立的流處理系統(如Storm)。 + +​ 在Lambda方法中,流處理器消耗事件並快速生成對檢視的近似更新;批處理器稍後將使用同一組事件並生成衍生檢視的更正版本。這個設計背後的原因是批處理更簡單,因此不易出錯,而流處理器被認為是不太可靠和難以容錯的(請參閱“[故障容錯]()”)。而且,流處理可以使用快速近似演算法,而批處理使用較慢的精確演算法。 + +​ Lambda架構是一種有影響力的想法,它將資料系統的設計變得更好,尤其是透過推廣這樣的原則:在不可變事件流上建立衍生檢視,並在需要時重新處理事件。但是我也認為它有一些實際問題: + +* 在批處理和流處理框架中維護相同的邏輯是很顯著的額外工作。雖然像Summingbird 【13】這樣的庫提供了一種可以在批處理和流處理的上下文中執行的計算抽象。除錯,調整和維護兩個不同系統的操作複雜性依然存在【14】。 +* 由於流管道和批處理管道產生獨立的輸出,因此需要合併它們以響應使用者請求。如果計算是基於滾動視窗的簡單聚合,則合併相當容易,但如果檢視基於更復雜的操作(例如連線和會話化)而匯出,或者輸出不是時間序列,則會變得非常困難。 +* 儘管有能力重新處理整個歷史資料集是很好的,但在大型資料集上這樣做經常會開銷巨大。因此,批處理流水線通常需要設定為處理增量批處理(例如,在每小時結束時處理一小時的資料),而不是重新處理所有內容。這引發了“[關於時間的推理](ch11.md#關於時間的推理)”中討論的問題,例如處理分段器和處理跨批次邊界的視窗。增加批次計算會增加複雜性,使其更類似於流式傳輸層,這與保持批處理層儘可能簡單的目標背道而馳。 + +#### 統一批處理和流處理 + +​ 最近的工作使得Lambda架構的優點在沒有其缺點的情況下得以實現,允許批處理計算(重新處理歷史資料)和流計算(處理事件到達時)在同一個系統中實現【15】。 + +在一個系統中統一批處理和流處理需要以下功能,這些功能越來越廣泛: + +* 透過處理最近事件流的相同處理引擎來重放歷史事件的能力。例如,基於日誌的訊息代理可以重放訊息(參閱“[重放舊訊息](ch11.md#重放舊訊息)”),某些流處理器可以從HDFS等分散式檔案系統讀取輸入。 +* 對於流處理器來說,恰好一次語義 —— 即確保輸出與未發生故障的輸出相同,即使事實上發生故障(參閱“[故障容錯](ch11.md#故障容錯)”)。與批處理一樣,這需要丟棄任何失敗任務的部分輸出。 +* 按事件時間進行視窗化的工具,而不是按處理時間進行視窗化,因為處理歷史事件時,處理時間毫無意義(參閱“[時間推理](ch11.md#時間推理)”)。例如,Apache Beam提供了用於表達這種計算的API,然後可以使用Apache Flink或Google Cloud Dataflow執行。 + + + + + +## 分拆資料庫 + +​ 在最抽象的層面上,資料庫,Hadoop和作業系統都發揮相同的功能:它們儲存一些資料,並允許你處理和查詢這些資料【16】。資料庫將資料儲存為特定資料模型的記錄(表中的行、文件、圖中的頂點等),而作業系統的檔案系統則將資料儲存在檔案中 —— 但其核心都是“資訊管理”系統【17】。正如我們在[第10章](ch10.md)中看到的,Hadoop生態系統有點像Unix的分散式版本。 + +​ 當然,有很多實際的差異。例如,許多檔案系統都不能很好地處理包含1000萬個小檔案的目錄,而包含1000萬個小記錄的資料庫完全是尋常而不起眼的。無論如何,作業系統和資料庫之間的相似之處和差異值得探討。 + +​ Unix和關係資料庫以非常不同的哲學來處理資訊管理問題。 Unix認為它的目的是為程式設計師提供一種相當低層次的硬體的邏輯抽象,而關係資料庫則希望為應用程式設計師提供一種高層次的抽象,以隱藏磁碟上資料結構的複雜性,併發性,崩潰恢復以及等等。 Unix發展出的管道和檔案只是位元組序列,而資料庫則發展出了SQL和事務。 + +​ 哪種方法更好?當然這取決於你想要的是什麼。 Unix是“簡單的”,因為它是硬體資源相當薄的包裝;關係資料庫是“更簡單”的,因為一個簡短的宣告性查詢可以利用很多強大的基礎設施(查詢最佳化,索引,連線方法,併發控制,複製等),而不需要查詢的作者理解其實現細節。 + +​ 這些哲學之間的矛盾已經持續了幾十年(Unix和關係模型都出現在70年代初),仍然沒有解決。例如,我將NoSQL運動解釋為,希望將類Unix的低級別抽象方法應用於分散式OLTP資料儲存的領域。 + +​ 在這一部分我將試圖調和這兩個哲學,希望我們能各取其美。 + +### 組合使用資料儲存技術 + +在本書的過程中,我們討論了資料庫提供的各種功能及其工作原理,其中包括: + +* 次級索引,使您可以根據欄位的值有效地搜尋記錄(參閱“[其他索引結構](ch3.md#其他索引結構)”) +* 物化檢視,這是一種預計算的查詢結果快取(參閱“[聚合:資料立方體和物化檢視](ch3.md#聚合:資料立方體和物化檢視)”) +* 複製日誌,保持其他節點上資料的副本最新(參閱“[複製日誌的實現](ch5.md#複製日誌的實現)”) +* 全文搜尋索引,允許在文字中進行關鍵字搜尋(參見“[全文搜尋和模糊索引](ch3.md#全文搜尋和模糊索引)”)內置於某些關係資料庫【1】 + +在[第10章](ch10.md)和[第11章](ch11.md)中,出現了類似的主題。我們討論瞭如何構建全文搜尋索引(請參閱第357頁上的“[批處理工作流的輸出]()”),瞭解有關例項化檢視維護(請參閱“[維護例項化檢視]()”一節第437頁)以及有關將變更從資料庫複製到衍生資料系統(請參閱第454頁的“[變更資料捕獲]()”)。 + +資料庫中內建的功能與人們用批處理和流處理器構建的衍生資料系統似乎有相似之處。 + +#### 建立索引 + +​ 想想當你執行`CREATE INDEX`在關係資料庫中建立一個新的索引時會發生什麼。資料庫必須掃描表的一致性快照,挑選出所有被索引的欄位值,對它們進行排序,然後寫出索引。然後它必須處理自一致快照以來所做的寫入操作(假設表在建立索引時未被鎖定,所以寫操作可能會繼續)。一旦完成,只要事務寫入表中,資料庫就必須繼續保持索引最新。 + +​ 此過程非常類似於設定新的從庫副本(參閱“[設定新的追隨者]()”),也非常類似於流處理系統中的**引導(bootstrap)** 變更資料捕獲(請參閱第455頁的“[初始快照]()”)。 + +​ 無論何時執行`CREATE INDEX`,資料庫都會重新處理現有資料集(如第494頁的“[重新處理應用程式資料的演變資料]()”中所述),並將該索引作為新檢視匯出到現有資料上。現有資料可能是狀態的快照,而不是所有發生變化的日誌,但兩者密切相關(請參閱“[狀態,資料流和不變性]()”第459頁)。 + +#### 一切的元資料庫 + +​ 有鑑於此,我認為整個組織的資料流開始像一個巨大的資料庫【7】。每當批處理,流或ETL過程將資料從一個地方傳輸到另一個地方並組裝時,它表現地就像資料庫子系統一樣,使索引或物化檢視保持最新。 + +​ 從這種角度來看,批處理和流處理器就像觸發器,儲存過程和物化檢視維護例程的精細實現。它們維護的衍生資料系統就像不同的索引型別。例如,關係資料庫可能支援B樹索引,雜湊索引,空間索引(請參閱第79頁的“[多列索引]()”)以及其他型別的索引。在新興的衍生資料系統架構中,不是將這些設施作為單個整合資料庫產品的功能實現,而是由各種不同的軟體提供,執行在不同的機器上,由不同的團隊管理。 + +​ 這些發展在未來將會把我們帶到哪裡?如果我們從沒有適合所有訪問模式的單一資料模型或儲存格式的前提出發,我推測有兩種途徑可以將不同的儲存和處理工具組合成一個有凝聚力的系統: + +**聯合資料庫:統一讀取** + +​ 可以為各種各樣的底層儲存引擎和處理方法提供一個統一的查詢介面 —— 一種稱為**聯合資料庫(federated database)** 或**多型儲存(polystore)** 的方法【18,19】。例如,PostgreSQL的外部資料包裝器功能符合這種模式【20】。需要專用資料模型或查詢介面的應用程式仍然可以直接訪問底層儲存引擎,而想要組合來自不同位置的資料的使用者可以透過聯合介面輕鬆完成操作。 + +​ 聯合查詢介面遵循著單一整合系統與關係型模型的傳統,帶有高階查詢語言和優雅的語義,但實現起來非常複雜。 + +**分拆資料庫:統一寫入** + +​ 雖然聯合能解決跨多個不同系統的只讀查詢問題,但它並沒有很好的解決跨系統**同步**寫入的問題。我們說過,在單個數據庫中,建立一致的索引是一項內建功能。當我們構建多個儲存系統時,我們同樣需要確保所有資料變更都會在所有正確的位置結束,即使在出現故障時也是如此。將儲存系統可靠地插接在一起(例如,透過變更資料捕獲和事件日誌)更容易,就像將資料庫的索引維護功能以可以跨不同技術同步寫入的方式分開【7,21】。 + +​ 分拆方法遵循Unix傳統的小型工具,它可以很好地完成一件事【22】,透過統一的低階API(管道)進行通訊,並且可以使用更高階的語言進行組合(shell)【16】 。 + +#### 開展分拆工作 + +​ 聯合和分拆是一個硬幣的兩面:用不同的元件構成可靠,可擴充套件和可維護的系統。聯合只讀查詢需要將一個數據模型對映到另一個數據模型,這需要一些思考,但最終還是一個可解決的問題。我認為同步寫入到幾個儲存系統是更困難的工程問題,所以我將重點關注它。 + +​ 傳統的同步寫入方法需要跨異構儲存系統的分散式事務【18】,我認為這是錯誤的解決方案(請參閱“[匯出的資料與分散式事務]()”第495頁)。單個儲存或流處理系統內的事務是可行的,但是當資料跨越不同技術之間的邊界時,我認為具有冪等寫入的非同步事件日誌是一種更加健壯和實用的方法。 + +​ 例如,分散式事務在某些流處理元件內部使用,以匹配**恰好一次(exactly-once)** 語義(請參閱第477頁的“[重新訪問原子提交]()”),這可以很好地工作。然而,當事務需要涉及由不同人群編寫的系統時(例如,當資料從流處理元件寫入分散式鍵值儲存或搜尋索引時),缺乏標準化的事務協議會使整合更難。有冪等消費者的事件的有序事件日誌(參見第478頁的“[冪等性]()”)是一種更簡單的抽象,因此在異構系統中實現更加可行【7】。 + +基於日誌的整合的一大優勢是各個元件之間的**鬆散耦合(loose coupling)**,這體現在兩個方面: + +1. 在系統級別,非同步事件流使整個系統對各個元件的中斷或效能下降更加穩健。如果使用者執行緩慢或失敗,那麼事件日誌可以緩衝訊息(請參閱“[磁碟空間使用情況]()”第369頁),以便生產者和任何其他使用者可以繼續不受影響地執行。有問題的消費者可以在固定時趕上,因此不會錯過任何資料,並且包含故障。相比之下,分散式事務的同步互動往往會將本地故障升級為大規模故障(請參見第363頁的“[分散式事務的限制]()”)。 +2. 在人力方面,分拆資料系統允許不同的團隊獨立開發,改進和維護不同的軟體元件和服務。專業化使得每個團隊都可以專注於做好一件事,並與其他團隊的系統以明確的介面互動。事件日誌提供了一個足夠強大的介面,以捕獲相當強的一致性屬性(由於永續性和事件的順序),但也足夠普適於幾乎任何型別的資料。 + +#### 分拆系統vs整合系統 + +​ 如果分拆確實成為未來的方式,它也不會取代目前形式的資料庫 —— 它們仍然會像以往一樣被需要。為了維護流處理元件中的狀態,資料庫仍然是需要的,並且為批處理和流處理器的輸出提供查詢服務(參閱“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”與“[流處理](ch11.md#流處理)”)。專用查詢引擎對於特定的工作負載仍然非常重要:例如,MPP資料倉庫中的查詢引擎針對探索性分析查詢進行了最佳化,並且能夠很好地處理這種型別的工作負載(參閱“[對比Hadoop與分散式資料庫](ch10.md#對比Hadoop與分散式資料庫)” 。 + +​ 執行幾種不同基礎設施的複雜性可能是一個問題:每種軟體都有一個學習曲線,配置問題和操作怪癖,因此部署儘可能少的移動部件是很有必要的。比起使用應用程式碼拼接多個工具而成的系統,單一整合軟體產品也可以在其設計應對的工作負載型別上實現更好,更可預測的效能【23】。正如在前言中所說的那樣,為了不需要的規模而構建系統是白費精力,而且可能會將你鎖死在一個不靈活的設計中。實際上,這是一種過早最佳化的形式。 + +​ 分拆的目標不是要針對個別資料庫與特定工作負載的效能進行競爭;我們的目標是允許您結合多個不同的資料庫,以便在比單個軟體可能實現的更廣泛的工作負載範圍內實現更好的效能。這是關於廣度,而不是深度 —— 與我們在“[對比Hadoop與分散式資料庫](ch10.md#對比Hadoop與分散式資料庫)”中討論的儲存和處理模型的多樣性一樣。 + +​ 因此,如果有一項技術可以滿足您的所有需求,那麼最好使用該產品,而不是試圖用低階元件重新實現它。只有當沒有單一軟體滿足您的所有需求時,才會出現拆分和聯合的優勢。 + +#### 少了什麼? + +​ 用於組成資料系統的工具正在變得越來越好,但我認為還缺少一個主要的東西:我們還沒有與Unix shell類似的分拆資料庫(即,一種宣告式的,簡單的,用於組裝儲存和處理系統的高階語言)。 + +​ 例如,如果我們可以簡單地宣告`mysql |elasticsearch`,類似於Unix管道【22】,成為`CREATE INDEX`的分拆等價物:它將MySQL資料庫中的所有文件並將其索引到Elasticsearch叢集中。然後它會不斷捕獲對資料庫所做的所有變更,並自動將它們應用於搜尋索引,而無需編寫自定義應用程式碼。這種整合應當支援幾乎任何型別的儲存或索引系統。 + +​ 同樣,能夠更容易地預先計算和更新快取將是一件好事。回想一下,物化檢視本質上是一個預先計算的快取,所以您可以透過為複雜查詢宣告指定物化檢視來建立快取,包括圖上的遞迴查詢(參閱“[圖資料模型](ch2.md#圖資料模型)”)和應用邏輯。在這方面有一些有趣的早期研究,如**差分資料流(differential dataflow)**【24,25】,我希望這些想法能夠在生產系統中找到自己的方法。 + +### 圍繞資料流設計應用 + +​ 使用應用程式碼組合專用儲存與處理系統來分拆資料庫的方法,也被稱為“**資料庫由內而外**”方法【26】,在我在2014年的一次會議演講標題之後【27】。然而稱它為“新架構”過於巨集大。我將其看作是一種設計模式,一個討論的起點,我們只是簡單地給它起一個名字,以便我們能更好地討論它。 + +​ 這些想法不是我的;它們是很多人的思想的融合,這些思想非常值得我們學習。尤其是,以Oz 【28】和Juttle 【29】為代表的資料流語言,以Elm【30,31】為代表的**函式式響應式程式設計(functional reactive programming, FRP)**,以Bloom【32】為代表的邏輯程式語言。在這一語境中的術語**分拆(unbundling)** 是由Jay Kreps 提出的【7】。 + +​ 即使是**電子表格**也在資料流程式設計能力上甩開大多數主流程式語言幾條街【33】。在電子表格中,可以將公式放入一個單元格中(例如,另一列中的單元格求和值),並且只要公式的任何輸入發生變更,公式的結果都會自動重新計算。這正是我們在資料系統層次所需要的:當資料庫中的記錄發生變更時,我們希望自動更新該記錄的任何索引,並且自動重新整理依賴於記錄的任何快取檢視或聚合。您不必擔心這種重新整理如何發生的技術細節,但能夠簡單地相信它可以正常工作。 + +​ 因此,我認為絕大多數資料系統仍然可以從VisiCalc在1979年已經具備的功能中學習【34】。與電子表格的不同之處在於,今天的資料系統需要具有容錯性,可擴充套件性以及持久儲存資料。它們還需要能夠整合不同人群編寫的不同技術,並重用現有的庫和服務:期望使用某種特定語言,框架或工具開發所有軟體是不切實際的。 + +​ 在本節中,我將詳細介紹這些想法,並探討一些圍繞分拆資料庫和資料流的想法構建應用的方法。 + +#### 應用程式碼作為衍生函式 + +​ 當一個數據集衍生自另一個數據集時,它會經歷某種轉換函式。例如: + +* 次級索引是由一種直白的轉換函式生成的衍生資料集:對於基礎表中的每行或每個文件,它挑選被索引的列或欄位中的值,並按這些值排序(假設使用B樹或SSTable索引,按鍵排序,如[第3章](ch3.md)所述)。 +* 全文搜尋索引是透過應用各種自然語言處理函式而建立的,諸如語言檢測,分詞,詞幹或詞彙化,拼寫糾正和同義詞識別)建立全文搜尋索引,然後構建用於高效查詢的資料結構(例如倒排索引)。 +* 在機器學習系統中,我們可以將模型視作從訓練資料透過應用各種特徵提取,統計分析函式衍生的資料,當模型應用於新的輸入資料時,模型的輸出是從輸入和模型(因此間接地從訓練資料)中衍生的。 +* 快取通常包含將以使用者介面(UI)顯示的形式的資料聚合。因此填充快取需要知道UI中引用的欄位;UI中的變更可能需要更新快取填充方式的定義,並重建快取。 + +用於次級索引的衍生函式是如此常用的需求,以致於它作為核心功能被內建至許多資料庫中,你可以簡單地透過`CREATE INDEX`來呼叫它。對於全文索引,常見語言的基本語言特徵可能內建到資料庫中,但更復雜的特徵通常需要領域特定的調整。在機器學習中,特徵工程是眾所周知的特定於應用的特徵,通常需要包含很多關於使用者互動與應用部署的詳細知識【35】。 + 當建立衍生資料集的函式不是像建立二級索引那樣的標準搬磚函式時,需要自定義程式碼來處理特定於應用的東西。而這個自定義程式碼是讓許多資料庫掙扎的地方,雖然關係資料庫通常支援觸發器,儲存過程和使用者定義的函式,它們可以用來在資料庫中執行應用程式碼,但它們有點像資料庫設計裡的事後反思。(參閱“[傳輸事件流](ch11.md#傳輸事件流)”)。 + +#### 應用程式碼和狀態的分離 + +​ 理論上,資料庫可以是任意應用程式碼的部署環境,就如同作業系統一樣。然而實踐中它們對這一目標適配的很差。它們不滿足現代應用開發的要求,例如依賴性和軟體包管理,版本控制,滾動升級,可演化性,監控,指標,對網路服務的呼叫以及與外部系統的整合。 + +​ 另一方面,Mesos,YARN,Docker,Kubernetes等部署和叢集管理工具專為執行應用程式碼而設計。透過專注於做好一件事情,他們能夠做得比將資料庫作為其眾多功能之一執行使用者定義的功能要好得多。我認為讓系統的某些部分專門用於持久資料儲存以及專門執行應用程式程式碼的其他部分是有意義的。這兩者可以在保持獨立的同時互動。 + +​ 現在大多數Web應用程式都是作為無狀態服務部署的,其中任何使用者請求都可以路由到任何應用程式伺服器,並且伺服器在傳送響應後會忘記所有請求。這種部署方式很方便,因為可以隨意新增或刪除伺服器,但狀態必須到某個地方:通常是資料庫。趨勢是將無狀態應用程式邏輯與狀態管理(資料庫)分開:不將應用程式邏輯放入資料庫中,也不將持久狀態置於應用程式中【36】。正如職能規劃界人士喜歡開玩笑說的那樣,“我們相信**教會(Church)** 與**國家(state)** 的分離”【37】 [^i] + +[^i]: 解釋笑話很少會讓人感覺更好,但我不想讓任何人感到被遺漏。 在這裡,Church指代的是數學家的阿隆佐·邱奇,他創立了lambda演算,這是計算的早期形式,是大多數函數語言程式設計語言的基礎。 lambda演算不具有可變狀態(即沒有變數可以被覆蓋),所以可以說可變狀態與Church的工作是分離的。 + +​ 在這個典型的Web應用模型中,資料庫充當一種可以透過網路同步訪問的可變共享變數。應用程式可以讀取和更新變數,而資料庫負責維持它的永續性,提供一些諸如併發控制和容錯的功能。 + +​ 但是,在大多數程式語言中,你無法訂閱可變變數中的變更 —— 你只能定期讀取它。與電子表格不同,如果變數的值發生變化,變數的讀者不會收到通知。 (你可以在自己的程式碼中實現這樣的通知 —— 這被稱為**觀察者模式** —— 但大多數語言沒有將這種模式作為內建功能。) + +​ 資料庫繼承了這種可變資料的被動方法:如果你想知道資料庫的內容是否發生了變化,通常你唯一的選擇就是輪詢(即定期重複你的查詢)。 訂閱變更只是剛剛開始出現的功能(參閱“[變更流的API支援](ch11.md#變更流的API支援)”)。 + +#### 資料流:應用程式碼與狀態變化的互動 + +​ 從資料流的角度思考應用,意味著重新協調應用程式碼和狀態管理之間的關係。將資料庫視作被應用操縱的被動變數,取而代之的是更多地考慮狀態,狀態變更和處理它們的程式碼之間的相互作用與協同關係。應用程式碼透過在另一個地方觸發狀態變更來響應狀態變更。 + +​ 我們在“[流與資料庫](ch11.md#流與資料庫)”中看到了這一思路,我們討論了將資料庫的變更日誌視為一種我們可以訂閱的事件流。諸如Actor的訊息傳遞系統(參閱“[訊息傳遞資料流](ch4.md#訊息傳遞資料流)”)也具有響應事件的概念。早在20世紀80年代,**元組空間(tuple space)** 模型就已經探索了表達分散式計算的方式:觀察狀態變更並作出反應【38,39】。 + +​ 如前所述,當觸發器由於資料變更而被觸發時,或次級索引更新以反映索引表中的變更時,資料庫內部也發生著類似的情況。分拆資料庫意味著將這個想法應用於在主資料庫之外,用於建立衍生資料集:快取,全文搜尋索引,機器學習或分析系統。我們可以為此使用流處理和訊息傳遞系統。 + +​ 需要記住的重要一點是,維護衍生資料不同於執行非同步任務。傳統訊息系統通常是為執行非同步任務設計的(參閱“[與傳統訊息傳遞相比的日誌](ch11.md#與傳統訊息傳遞相比的日誌)”): + +* 在維護衍生資料時,狀態變更的順序通常很重要(如果多個檢視是從事件日誌衍生的,則需要按照相同的順序處理事件,以便它們之間保持一致)。如“[確認與重傳](ch11.md#確認與重傳)”中所述,許多訊息代理在重傳未確認訊息時沒有此屬性,雙寫也被排除在外(參閱“[保持系統同步](ch11.md#保持系統同步)”)。 + + +* 容錯是衍生資料的關鍵:僅僅丟失單個訊息就會導致衍生資料集永遠與其資料來源失去同步。訊息傳遞和衍生狀態更新都必須可靠。例如,許多Actor系統預設在記憶體中維護Actor的狀態和訊息,所以如果執行Actor的機器崩潰,狀態和訊息就會丟失。 + + +穩定的訊息排序和容錯訊息處理是相當嚴格的要求,但與分散式事務相比,它們開銷更小,執行更穩定。現代流處理元件可以提供這些排序和可靠性保證,並允許應用程式碼以流運算元的形式執行。 + +​ 這些應用程式碼可以執行任意處理,包括資料庫內建衍生函式通常不提供的功能。就像透過管道連結的Unix工具一樣,流運算元可以圍繞著資料流構建大型系統。每個運算元接受狀態變更的流作為輸入,併產生其他狀態變化的流作為輸出。 + +#### 流處理器和服務 + +​ 當今流行的應用開發風格涉及將功能分解為一組透過同步網路請求(如REST API)進行通訊的**服務(service)**(參閱“[透過服務實現資料流:REST和RPC](ch4.md#透過服務實現資料流:REST和RPC)”)。這種面向服務的架構優於單一龐大應用的優勢主要在於:通過鬆散耦合來提供組織上的可擴充套件性:不同的團隊可以專職於不同的服務上,從而減少團隊之間的協調工作(因為服務可以獨立部署和更新)。 + +​ 在資料流中組裝流運算元與微服務方法有很多相似之處【40】。但底層通訊機制是有很大區別:資料流採用單向非同步訊息流,而不是同步的請求/響應式互動。 + +​ 除了在“[訊息傳遞資料流](ch4.md#訊息傳遞資料流)”中列出的優點(如更好的容錯性),資料流系統還能實現更好的效能。例如,假設客戶正在購買以一種貨幣定價,但以另一種貨幣支付的商品。為了執行貨幣換算,你需要知道當前的匯率。這個操作可以透過兩種方式實現【40,41】: + +1. 在微服務方法中,處理購買的程式碼可能會查詢匯率服務或資料庫,以獲取特定貨幣的當前匯率。 +2. 在資料流方法中,處理訂單的程式碼會提前訂閱匯率變更流,並在匯率發生變動時將當前匯率儲存在本地資料庫中。處理訂單時只需查詢本地資料庫即可。 + + + +​ 第二種方法能將對另一服務的同步網路請求替換為對本地資料庫的查詢(可能在同一臺機器甚至同一個程序中)[^ii]。資料流方法不僅更快,而且當其他服務失效時也更穩健。最快且最可靠的網路請求就是壓根沒有網路請求!我們現在不再使用RPC,而是在購買事件和匯率更新事件之間建立流聯接(參閱“[流表聯接](ch11.md#流表聯接)”)。 + +[^ii]: 在微服務方法中,你也可以透過在處理購買的服務中本地快取匯率來避免同步網路請求。 但是為了保證快取的新鮮度,你需要定期輪詢匯率以獲取其更新,或訂閱變更流 —— 這恰好是資料流方法中發生的事情。 + +​ 連線是時間相關的:如果購買事件在稍後的時間點被重新處理,匯率可能已經改變。如果要重建原始輸出,則需要獲取原始購買時的歷史匯率。無論是查詢服務還是訂閱匯率更新流,你都需要處理這種時間相關性(參閱“[連線的時間相關性](ch11.md#連線的時間相關性)”)。 + +​ 訂閱變更流,而不是在需要時查詢當前狀態,使我們更接近類似電子表格的計算模型:當某些資料發生變更時,依賴於此的所有衍生資料都可以快速更新。還有很多未解決的問題,例如關於時間相關連線等問題,但我認為圍繞資料流構建應用的想法是一個非常有希望的方向。 + +### 觀察衍生資料狀態 + +​ 在抽象層面,上一節討論的資料流系統提供了建立衍生資料集(例如搜尋索引,物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為**寫路徑(write path)**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個衍生資料集都會被更新,以適配寫入的資料。[圖12-1](img/fig12-1.png)顯示了一個更新搜尋索引的例子。 + +![](img/fig12-1.png) + +**圖12-1 在搜尋索引中,寫(文件更新)遇上讀(查詢)** + +​ 但你為什麼一開始就要建立衍生資料集?很可能是因為你想在以後再次查詢它。這就是**讀路徑(read path)**:當服務使用者請求時,你需要從衍生資料集中讀取,也許還要對結果進行一些額外處理,然後構建給使用者的響應。 + +​ 總而言之,寫路徑和讀路徑涵蓋了資料的整個旅程,從收集資料開始,到使用資料結束(可能是由另一個人)。寫路徑是預計算過程的一部分 —— 即,一旦資料進入,即刻完成,無論是否有人需要看它。讀路徑是這個過程中只有當有人請求時才會發生的部分。如果你熟悉函數語言程式設計語言,則可能會注意到寫路徑類似於立即求值,讀路徑類似於惰性求值。 + +​ 如[圖12-1](img/fig12-1.png)所示,衍生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。 + +#### 物化檢視和快取 + +​ 全文搜尋索引就是一個很好的例子:寫路徑更新索引,讀路徑在索引中搜索關鍵字。讀寫都需要做一些工作。寫入需要更新文件中出現的所有關鍵詞的索引條目。讀取需要搜尋查詢中的每個單詞,並應用布林邏輯來查詢包含查詢中所有單詞(AND運算子)的文件,或者每個單詞(OR運算子)的任何同義詞。 + +​ 如果沒有索引,搜尋查詢將不得不掃描所有文件(如grep),如果有著大量文件,這樣做的開銷巨大。沒有索引意味著寫入路徑上的工作量較少(沒有要更新的索引),但是在讀取路徑上需要更多工作。 + +​ 另一方面,可以想象為所有可能的查詢預先計算搜尋結果。在這種情況下,讀路徑上的工作量會減少:不需要布林邏輯,只需查詢查詢結果並返回即可。但寫路徑會更加昂貴:可能的搜尋查詢集合是無限大的,因此預先計算所有可能的搜尋結果將需要無限的時間和儲存空間。那肯定沒戲[^iii]。 + +[^iii]: 假設一個有限的語料庫,那麼返回非空搜尋結果的搜尋查詢集合是有限的。然而,它是與語料庫中的術語數量呈指數關係,這仍是一個壞訊息。 + +​ 另一個選擇是隻為一組固定的最常見的查詢預先計算搜尋結果,以便它們可以快速地服務而不必去走索引。不常見的查詢仍然可以從走索引。這通常被稱為常見查詢的**快取(cache)**,儘管我們也可以稱之為**物化檢視(materialized view)**,因為當新文件出現,且需要被包含在這些常見查詢的搜尋結果之中時,這些索引就需要更新。 + +​ 從這個例子中我們可以看到,索引不是寫路徑和讀路徑之間唯一可能的邊界;快取常見搜尋結果也是可行的;而在少量文件上使用沒有索引的類grep掃描也是可行的。由此來看,快取,索引和物化檢視的作用很簡單:它們改變了讀路徑與寫路徑之間的邊界。透過預先計算結果,從而允許我們在寫路徑上做更多的工作,以節省讀取路徑上的工作量。 + +​ 在寫路徑上完成的工作和讀路徑之間的界限,實際上是本書開始處在“[描述負載](ch1.md#描述負載)”中推特例子裡談到的主題。在該例中,我們還看到了與普通使用者相比,名流的寫路徑和讀路徑可能有所不同。在500頁之後,我們已經走完一個大迴圈! + +#### 有狀態,可離線的客戶端 + +​ 我發現寫和讀路徑之間的邊界很有趣,因為我們可以試著改變這個邊界,並探討這種改變的實際意義。我們來看看不同上下文中的這一想法。 + +​ 過去二十年來,Web應用的火熱讓我們對應用開發作出了一些很容易視作理所當然的假設。具體來說就是,客戶端/伺服器模型 —— 客戶端大多是無狀態的,而伺服器擁有資料的權威 —— 已經普遍到我們幾乎忘掉了還有其他任何模型的存在。但是技術在不斷地發展,我認為不時地質疑現狀非常重要。 + +​ 傳統上,網路瀏覽器是無狀態的客戶端,只有當連線到網際網路時才能做一些有用的事情(能離線執行的唯一事情基本上就是上下滾動之前線上時載入好的頁面)。然而,最近的“單頁面”JavaScript Web應用已經獲得了很多有狀態的功能,包括客戶端使用者介面互動,以及Web瀏覽器中的持久化本地儲存。移動應用可以類似地在裝置上儲存大量狀態,而且大多數使用者互動都不需要與伺服器往返互動。 + +​ 這些不斷變化的功能重新引發了對**離線優先(offline-first)** 應用的興趣,這些應用盡可能地在同一裝置上使用本地資料庫,無需連線網際網路,並在後臺網路連線可用時與遠端伺服器同步【42】。由於移動裝置通常具有緩慢且不可靠的蜂窩網路連線,因此,如果使用者的使用者介面不必等待同步網路請求,且應用主要是離線工作的,則這是一個巨大優勢(參閱“[具有離線操作的客戶端](ch5.md#具有離線操作的客戶端)”)。 + +​ 當我們擺脫無狀態客戶端與中央資料庫互動的假設,並轉向在終端使用者裝置上維護狀態時,這就開啟了新世界的大門。特別是,我們可以將裝置上的狀態視為**伺服器狀態的快取**。螢幕上的畫素是客戶端應用中模型物件的物化檢視;模型物件是遠端資料中心的本地狀態副本【27】。 + +#### 將狀態變更推送給客戶端 + +​ 在典型的網頁中,如果你在Web瀏覽器中載入頁面,並且隨後伺服器上的資料發生變更,則瀏覽器在重新載入頁面之前對此一無所知。瀏覽器只能在一個時間點讀取資料,假設它是靜態的 —— 它不會訂閱來自伺服器的更新。因此裝置上的狀態是陳舊的快取,除非你顯式輪詢變更否則不會更新。(像RSS這樣基於HTTP的Feed訂閱協議實際上只是一種基本的輪詢形式) + +​ 最近的協議已經超越了HTTP的基本請求/響應模式:服務端傳送的事件(EventSource API)和WebSockets提供了通訊通道,透過這些通道,Web瀏覽器可以與伺服器保持開啟的TCP連線,只要瀏覽器仍然連線著,伺服器就能主動向瀏覽器推送資訊。這為伺服器提供了主動通知終端使用者客戶端的機會,伺服器能告知客戶端其本地儲存狀態的任何變化,從而減少客戶端狀態的陳舊程度。 + +​ 用我們的寫路徑與讀路徑模型來講,主動將狀態變更推至到客戶端裝置,意味著將寫路徑一直延伸到終端使用者。當客戶端首次初始化時,它仍然需要使用讀路徑來獲取其初始狀態,但此後它就可能依賴於伺服器傳送的狀態變更流了。我們在流處理和訊息傳遞部分討論的想法並不侷限於資料中心中:我們可以進一步採納這些想法,並將它們一直延伸到終端使用者裝置【43】。 + +​ 這些裝置有時會離線,並在此期間無法收到伺服器狀態變更的任何通知。但是我們已經解決了這個問題:在“[消費者偏移量](ch11.md#消費者偏移量)”中,我們討論了基於日誌的訊息代理的消費者能在失敗或斷開連線後重連,並確保它不會錯過掉線期間任何到達的訊息。同樣的技術適用於單個使用者,每個裝置都是一個小事件流的小小訂閱者。 + +#### 端到端的事件流 + +​ 最近用於開發帶狀態客戶端與使用者介面的工具,例如如Elm語言【30】和Facebook的React,Flux和Redux工具鏈,已經透過訂閱表示使用者輸入和伺服器響應的事件流,來管理客戶端的內部狀態,其結構與事件溯源相似(請參閱第457頁的“[事件溯源](ch11.md#事件溯源)”)。 + +​ 將這種程式設計模型擴充套件為:允許伺服器將狀態變更事件,推送到客戶端的事件管道中,是非常自然的。因此,狀態變化可以透過**端到端(end-to-end)** 的寫路徑流動:從一個裝置上的互動觸發狀態變更開始,經由事件日誌,並穿過幾個衍生資料系統與流處理器,一直到另一臺裝置上的使用者介面,而有人正在觀察使用者介面上的狀態變化。這些狀態變化能以相當低的延遲傳播 —— 比如說,在一秒內從一端到另一端。 + +​ 一些應用(如即時訊息傳遞與線上遊戲)已經具有這種“實時”架構(在低延遲互動的意義上,不是在“[響應時間保證](ch8.md#響應時間保證)”中的意義上)。但我們為什麼不用這種方式構建所有的應用? + +​ 挑戰在於,關於無狀態客戶端和請求/響應互動的假設已經根深蒂固地植入在在我們的資料庫,庫,框架,以及協議之中。許多資料儲存支援讀取與寫入操作,為請求返回一個響應,但只有極少數提供訂閱變更的能力 —— 為請求返回一個隨時間推移返回響應的流(請參閱“[變更流的API支援](ch11.md#變更流的API支援)” )。 + +​ 為了將寫路徑延伸至終端使用者,我們需要從根本上重新思考我們構建這些系統的方式:從請求/響應互動轉向釋出/訂閱資料流【27】。更具響應性的使用者介面與更好的離線支援,我認為這些優勢值得我們付出努力。如果你正在設計資料系統,我希望您對訂閱變更的選項留有印象,而不只是查詢當前狀態。 + +#### 讀也是事件 + +​ 我們討論過,當流處理器將衍生資料寫入儲存(資料庫,快取或索引)時,以及當用戶請求查詢該儲存時,儲存將充當寫路徑和讀路徑之間的邊界。該儲存應當允許對資料進行隨機訪問的讀取查詢,否則這些查詢將需要掃描整個事件日誌。 + +​ 在很多情況下,資料儲存與流處理系統是分開的。但回想一下,流處理器還是需要維護狀態以執行聚合和連線的(參閱“[流連線](ch11.md#流連線)”)。這種狀態通常隱藏在流處理器內部,但一些框架也允許這些狀態被外部客戶端查詢【45】,將流處理器本身變成一種簡單的資料庫。 + +​ 我願意進一步思考這個想法。正如到目前為止所討論的那樣,對儲存的寫入是透過事件日誌進行的,而讀取是臨時的網路請求,直接流向儲存著待查資料的節點。這是一個合理的設計,但不是唯一可行的設計。也可以將讀取請求表示為事件流,並同時將讀事件與寫事件送往流處理器;流處理器透過將讀取結果傳送到輸出流來響應讀取事件【46】。 + +​ 當寫入和讀取都被表示為事件,並且被路由到同一個流運算元以便處理時,我們實際上是在讀取查詢流和資料庫之間執行流表連線。讀取事件需要被送往儲存資料的資料庫分割槽(參閱“[請求路由](ch6.md#請求路由)”),就像批處理和流處理器在連線時需要在同一個鍵上對輸入分割槽一樣(請參閱“[Reduce端連線與分組](ch10.md#Reduce端連線與分組)“)。 + +​ 服務請求與執行連線之間的這種相似之處是非常關鍵的【47】。一次性讀取請求只是將請求傳過連線運算元,然後請求馬上就被忘掉了;而一個訂閱請求,則是與連線另一側過去與未來事件的持久化連線。 + +​ 記錄讀取事件的日誌可能對於追蹤整個系統中的因果關係與資料來源也有好處:它可以讓你重現出當用戶做出特定決策之前看見了什麼。例如在網商中,向客戶顯示的預測送達日期與庫存狀態,可能會影響他們是否選擇購買一件商品【4】。要分析這種聯絡,則需要記錄使用者查詢運輸與庫存狀態的結果。 + +​ 將讀取事件寫入持久儲存可以更好地跟蹤因果關係(參閱“[排序事件以捕獲因果關係](ch9.md#排序事件以捕獲因果關係)”),但會產生額外的儲存與I/O成本。最佳化這些系統以減少開銷仍然是一個開放的研究問題【2】。但如果你已經出於運維目的留下了讀取請求日誌,將其作為請求處理的副作用,那麼將這份日誌作為請求事件源並不是什麼特別大的變更。 + +#### 多分割槽資料處理 + +​ 對於只涉及單個分割槽的查詢,透過流來發送查詢與收集響應可能是殺雞用牛刀了。然而,這個想法開啟了分散式執行復雜查詢的可能性,這需要合併來自多個分割槽的資料,利用流處理器已經提供的訊息路由,分割槽和連線的基礎設施。 + +​ Storm的分散式RPC功能支援這種使用模式(參閱“[訊息傳遞和RPC](ch11.md#訊息傳遞和RPC)”)。例如,它已經被用來計算瀏覽過某個推特URL的人數 —— 即,轉推該URL的粉絲集合的並集【48】。由於推特的使用者是分割槽的,因此這種計算需要合併來自多個分割槽的結果。 + +​ 這種模式的另一個例子是欺詐預防:為了評估特定購買事件是否具有欺詐風險,你可以檢查該使用者IP地址,電子郵件地址,帳單地址,送貨地址的信用分。這些信用資料庫中的每一個自己都是一個分割槽,因此為特定購買事件採集分數需要連線一系列不同的分割槽資料集【49】。 + +​ MPP資料庫的內部查詢執行圖有著類似的特徵(參閱“[比較Hadoop與分散式資料庫](ch10.md#比較Hadoop與分散式資料庫)”)。如果需要執行這種多分割槽連線,則直接使用提供此功能的資料庫,可能要比使用流處理器實現它要更簡單。然而將查詢視為流提供了一種選項,可以用於實現超出傳統現成解決方案的大規模應用。 + + + +## 將事情做正確 + +​ 對於只讀取資料的無狀態服務,出問題也沒什麼大不了的:你可以修復該錯誤並重啟服務,而一切都恢復正常。像資料庫這樣的有狀態系統就沒那麼簡單了:它們被設計為永遠記住事物(或多或少),所以如果出現問題,這種(錯誤的)效果也將潛在地永遠持續下去,這意味著它們需要更仔細的思考【50】。 + +​ 我們希望構建可靠且**正確**的應用(即使面對各種故障,程式的語義也能被很好地定義與理解)。約四十年來,原子性,隔離性和永續性([第7章](ch7.md))等事務特性一直是構建正確應用的首選工具。然而這些地基沒有看上去那麼牢固:例如弱隔離級別帶來的困惑可以佐證(請參見“[弱隔離級別](ch7.md#弱隔離級別)”)。 + +​ 事務在某些領域被完全拋棄,並被提供更好效能與可擴充套件性的模型取代,但更復雜的語義(例如,參閱“[無領導者複製](ch5.md#無領導者複製)”)。**一致性(Consistency)** 經常被談起,但其定義並不明確(“[一致性](ch5.md#一致性)”和[第9章](ch9.md))。有些人斷言我們應當為了高可用而“擁抱弱一致性”,但卻對這些概念實際上意味著什麼缺乏清晰的認識。 + +​ 對於如此重要的話題,我們的理解,以及我們的工程方法卻是驚人地薄弱。例如,確定在特定事務隔離等級或複製配置下執行特定應用是否安全是非常困難的【51,52】。通常簡單的解決方案似乎在低併發性的情況下工作正常,並且沒有錯誤,但在要求更高的情況下卻會出現許多微妙的錯誤。 + +​ 例如,凱爾金斯伯裡(Kyle Kingsbury)的傑普森(Jepsen)實驗【53】標出了一些產品聲稱的安全保證與其在網路問題與崩潰時的實際行為之間的明顯差異。即使像資料庫這樣的基礎設施產品沒有問題,應用程式碼仍然需要正確使用它們提供的功能才行,如果配置很難理解,這是很容易出錯的(在這種情況下指的是弱隔離級別,法定人數配置等)。 + +​ 如果你的應用可以容忍偶爾的崩潰,以及以不可預料的方式損壞或丟失資料,那生活就要簡單得多,而你可能只要雙手合十念阿彌陀佛,期望佛祖能保佑最好的結果。另一方面,如果你需要更強的正確性保證,那麼可序列化與原子提交就是久經考驗的方法,但它們是有代價的:它們通常只在單個數據中心中工作(排除地理散佈式架構),並限制了系統能夠實現的規模與容錯特性。 + +​ 雖然傳統的事務方法並沒有走遠,但我也相信在使應用正確而靈活地處理錯誤方面上,事務並不是最後的遺言。在本節中,我將提出一些在資料流架構中考量正確性的方式。 + +### 為資料庫使用端到端的引數 + +​ 應用僅僅是使用具有相對較強安全屬性的資料系統(例如可序列化的事務),並不意味著就可以保證沒有資料丟失或損壞。例如,如果某個應用有個Bug,導致它寫入不正確的資料,或者從資料庫中刪除資料,那麼可序列化的事務也救不了你。 + +​ 這個例子可能看起來很無聊,但值得認真對待:應用會出Bug,而人也會犯錯誤。我在“[狀態,流與不可變性](ch11.md#狀態,流與不可變性)”中使用了這個例子來支援不可變和僅追加的資料,閹割掉錯誤程式碼摧毀良好資料的能力,能讓從錯誤中恢復更為容易。 + +​ 雖然不變性很有用,但它本身並非萬靈藥。讓我們來看一個可能發生的,非常微妙的資料損壞案例。 + +#### 正好執行一次操作 + +​ 在“[容錯](ch11.md#容錯)”中,我們見到了**恰好一次**(或**等效一次**)語義的概念。如果在處理訊息時出現問題,你可以選擇放棄(丟棄訊息 —— 導致資料丟失)或重試。如果重試,就會有這種風險:第一次實際上成功了,只不過你沒有發現。結果這個訊息就被處理了兩次。 + +​ 處理兩次是資料損壞的一種形式:為同樣的服務向客戶收費兩次(收費太多)或增長計數器兩次(誇大指標)都不是我們想要的。在這種情況下,恰好一次意味著安排計算,使得最終效果與沒有發生錯誤的情況一樣,即使操作實際上因為某種錯誤而重試。我們先前討論過實現這一目標的幾種方法。 + +​ 最有效的方法之一是使操作**冪等(idempotent)**(參閱“[冪等性](ch11.md#冪等性)”);即確保它無論是執行一次還是執行多次都具有相同的效果。但是,將不是天生冪等的操作變為冪等的操作需要一些額外的努力與關注:你可能需要維護一些額外的元資料(例如更新了值的操作ID集合),並在從一個節點故障切換至另一個節點時做好防護(參閱的“[領導與鎖定](ch9.md#領導與鎖定)”)。 + +#### 抑制重複 + +​ 除了流處理之外,其他許多地方也需要抑制重複的模式。例如,TCP使用資料包上的序列號,在接收方將它們正確排序。並確定網路上是否有資料包丟失或重複。任何丟失的資料包都會被重新傳輸,而在將資料交付應用前,TCP協議棧會移除任何重複資料包。 + +​ 但是,這種重複抑制僅適用於單條TCP連線的場景中。假設TCP連線是一個客戶端與資料庫的連線,並且它正在執行[例12-1]()中的事務。在許多資料庫中,事務是繫結在客戶端連線上的(如果客戶端傳送了多個查詢,資料庫就知道它們屬於同一個事務,因為它們是在同一個TCP連線上傳送的)。如果客戶端在傳送`COMMIT`之後但在從資料庫伺服器收到響應之前遇到網路中斷與連線超時,客戶端是不知道事務是否已經被提交的([圖8-1](img/fig8-1.png))。 + +**例12-1 資金從一個賬戶到另一個賬戶的非冪等轉移** + +```sql +BEGIN TRANSACTION; + UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234; + UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321; +COMMIT; +``` + +​ 客戶端可以重連到資料庫並重試事務,但現在現在處於TCP重複抑制的範圍之外了。因為[例12-1]()中的事務不是冪等的,可能會發生轉了\$22而不是期望的\$11。因此,儘管[例12-1]()是一個事務原子性的標準樣例,但它實際上並不正確,而真正的銀行並不會這樣辦事【3】。 + +​ 兩階段提交(參閱“[原子提交與兩階段提交(2PC)](ch9.md#原子提交與兩階段提交(2PC))”)協議會破壞TCP連線與事務之間的1:1對映,因為它們必須在故障後允許事務協調器重連到資料庫,告訴資料庫將存疑事務提交還是中止。這足以確保事務只被恰好執行一次嗎?不幸的是,並不能。 + +​ 即使我們可以抑制資料庫客戶端與伺服器之間的重複事務,我們仍然需要擔心終端使用者裝置與應用伺服器之間的網路。例如,如果終端使用者的客戶端是Web瀏覽器,則它可能會使用HTTP POST請求向伺服器提交指令。也許使用者正處於一個訊號微弱的蜂窩資料網路連線中,它們成功地傳送了POST,但卻在能夠從伺服器接收響應之前沒了訊號。 + +​ 在這種情況下,可能會向用戶顯示錯誤訊息,而他們可能會手動重試。 Web瀏覽器警告說,“你確定要再次提交這個表單嗎?” —— 使用者選“是”,因為他們希望操作發生。 (Post/Redirect/Get模式【54】可以避免在正常操作中出現此警告訊息,但POST請求超時就沒辦法了。)從Web伺服器的角度來看,重試是一個獨立的請求,而從資料庫的角度來看,這是一個獨立的事務。通常的除重機制無濟於事。 + +#### 操作識別符號 + +​ 要在通過幾跳的網路通訊上使操作具有冪等性,僅僅依賴資料庫提供的事務機制是不夠的 —— 你需要考慮**端到端(end-to-end)** 的請求流。 + 例如,你可以為操作生成一個唯一的識別符號(例如UUID),並將其作為隱藏表單欄位包含在客戶端應用中,或透過計算所有表單相關欄位的雜湊來生成操作ID 【3】。如果Web瀏覽器提交了兩次POST請求,這兩個請求將具有相同的操作ID。然後,你可以將該操作ID一路傳遞到資料庫,並檢查你是否曾經使用給定的ID執行過一個操作,如[例12-2]()中所示。 + +**例12-2 使用唯一ID來抑制重複請求** + +```sql +ALTER TABLE requests ADD UNIQUE (request_id); + +BEGIN TRANSACTION; + INSERT INTO requests(request_id, from_account, to_account, amount) + VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00); + UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234; + UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321; +COMMIT; +``` + +​ [例12-2]()依賴於`request_id`列上的唯一約束。如果一個事務嘗試插入一個已經存在的ID,那麼`INSERT`失敗,事務被中止,使其無法生效兩次。即使在較弱的隔離級別下,關係資料庫也能正確地維護唯一性約束(而在“[寫入偏差與幻讀](ch7.md#寫入偏差與幻讀)”中討論過,應用級別的**檢查-然後-插入**可能會在不可序列化的隔離下失敗)。 + +​ 除了抑制重複的請求之外,[例12-2]()中的請求表表現得就像一種事件日誌,提示向著事件溯源的方向(參閱“[事件溯源](ch11.md#事件溯源)”)。更新賬戶餘額事實上不必與插入事件發生在同一個事務中,因為它們是冗餘的,而能由下游消費者從請求事件中衍生出來 —— 只要該事件被恰好處理一次,這又一次可以使用請求ID來強制執行。 + +**端到端的原則** + +​ 抑制重複事務的這種情況只是一個更普遍的原則的一個例子,這個原則被稱為**端到端的原則(end-to-end argument)**,它在1984年由Saltzer,Reed和Clark闡述【55】: + +> ​ 只有在通訊系統兩端應用的知識與幫助下,所討論的功能才能完全地正確地實現。因而將這種被質疑的功能作為通訊系統本身的功能是不可能的。 (有時,通訊系統可以提供這種功能的不完備版本,可能有助於提高效能) +> + +​ 在我們的例子中**所討論的功能**是重複抑制。我們看到TCP在TCP連線層次抑制了重複的資料包,一些流處理器在訊息處理層次提供了所謂的恰好一次語義,但這些都無法阻止當一個請求超時時,使用者親自提交重複的請求。TCP,資料庫事務,以及流處理器本身並不能完全排除這些重複。解決這個問題需要一個端到端的解決方案:從終端使用者的客戶端一路傳遞到資料庫的事務識別符號。 + +​ 端到端引數也適用於檢查資料的完整性:乙太網,TCP和TLS中內建的校驗和可以檢測網路中資料包的損壞情況,但是它們無法檢測到由連線兩端傳送/接收軟體中Bug導致的損壞。或資料儲存所在磁碟上的損壞。如果你想捕獲資料所有可能的損壞來源,你也需要端到端的校驗和。 + +​ 類似的原則也適用於加密【55】:家庭WiFi網路上的密碼可以防止人們竊聽你的WiFi流量,但無法阻止網際網路上其他地方攻擊者的窺探;客戶端與伺服器之間的TLS/SSL可以阻擋網路攻擊者,但無法阻止惡意伺服器。只有端到端的加密和認證可以防止所有這些事情。 + +​ 儘管低層級的功能(TCP複製抑制,乙太網校驗和,WiFi加密)無法單獨提供所需的端到端功能,但它們仍然很有用,因為它們能降低較高層級出現問題的可能性。例如,如果我們沒有TCP來將資料包排成正確的順序,那麼HTTP請求通常就會被攪爛。我們只需要記住,低級別的可靠性功能本身並不足以確保端到端的正確性。 + +#### 在資料系統中應用端到端思考 + +​ 這將我帶回最初的論點·:僅僅因為應用使用了提供相對較強安全屬性的資料系統,例如可序列化的事務,並不意味著應用的資料就不會丟失或損壞了。應用本身也需要採取端到端的措施,例如除重。 + +​ 這實在是一個遺憾,因為容錯機制很難弄好。低層級的可靠機制(比如TCP中的那些)執行的相當好,因而剩下的高層級錯誤基本很少出現。如果能將這些剩下的高層級容錯機制打包成抽象,而應用不需要再去操心,那該多好呀 —— 但恐怕我們還沒有找到這一正確的抽象。 + +​ 長期以來,事務被認為是一個很好的抽象,我相信它們確實是很有用的。正如[第7章](ch7.md)導言中所討論的,它們將各種可能的問題(併發寫入,違背約束,崩潰,網路中斷,磁碟故障)合併為兩種可能結果:提交或中止。這是對程式設計模型而言是一種巨大的簡化,但恐怕這還不夠。 + +​ 事務是代價高昂的,當涉及異構儲存技術時尤為甚(參閱的“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”)。我們拒絕使用分散式事務是因為它開銷太大,結果我們最後不得不在應用程式碼中重新實現容錯機制。正如本書中大量的例子所示,對併發性與部分失敗的推理是困難且違反直覺的,所以我懷疑大多數應用級別的機制都不能正確工作,最終結果是資料丟失或損壞。 + +​ 出於這些原因,我認為探索對容錯的抽象是很有價值的。它使提供應用特定的端到端的正確性屬性變得更簡單,而且還能在大規模分散式環境中提供良好的效能與運維特性。 + +### 強制約束 + +​ 讓我們思考一下在[分拆資料庫](#分拆資料庫)上下文中的**正確性(correctness)**。我們看到端到端的除重可以透過從客戶端一路透傳到資料庫的請求ID實現。那麼其他型別的約束呢? + +​ 我們先來特別關注一下**唯一性約束** —— 例如我們在[例12-2]()中所依賴的約束。在“[約束和唯一性保證](ch9.md#約束和唯一性保證)”中,我們看到了幾個其他需要強制實施唯一性的應用功能例子:使用者名稱或電子郵件地址必須唯一標識使用者,檔案儲存服務不能包含多個重名檔案,兩個人不能在航班或劇院預訂同一個座位。 + +​ 其他型別的約束也非常類似:例如,確保帳戶餘額永遠不會變為負數,你就不會超賣庫存;或者會議室沒有重複的預訂。執行唯一性約束的技術通常也可以用於這些約束。 + +#### 唯一性約束需要達成共識 + +​ 在[第9章](ch9.md)中我們看到,在分散式環境中,強制執行唯一性約束需要共識:如果存在多個具有相同值的併發請求,則系統需要決定衝突操作中的哪一個被接受,並拒絕其他違背約束的操作。 + +​ 達成這一共識的最常見方式是使單個節點作為領導,並使其負責所有決策。只要你不介意所有請求都擠過單個節點(即使客戶端位於世界的另一端),只要該節點沒有失效,系統就能正常工作。如果你需要容忍領導者失效,那麼就又回到了共識問題(參閱“[單領導者複製與共識](ch9.md#單領導者複製與共識)”)。 + +​ 唯一性檢查可以透過對唯一性欄位分割槽做橫向擴充套件。例如,如果需要透過請求ID確保唯一性(如[例12-2]()所示),你可以確保所有具有相同請求ID的請求都被路由到同一分割槽(參閱[第6章](ch6.md))。如果你需要讓使用者名稱是唯一的,則可以按使用者名稱的雜湊值做分割槽。 + +​ 但非同步多主複製排除在外,因為可能會發生不同主庫同時接受衝突寫操作的情況,因而這些值不再是唯一的(參閱“[實現可線性化系統](ch9.md#實現可線性化系統)”)。如果你想立刻拒絕任何違背約束的寫入,同步協調是無法避免的【56】。 + +#### 基於日誌訊息傳遞中的唯一性 + +​ 日誌確保所有消費者以相同的順序看見訊息 —— 這種保證在形式上被稱為**全序廣播(total order boardcast)** 並且等價於共識(參見“[全序廣播](ch9.md#全序廣播)”)。在使用基於日誌的訊息傳遞的分拆資料庫方法中,我們可以使用非常類似的方法來執行唯一性約束。 + +​ 流處理器在單個執行緒上依次消費單個日誌分割槽中的所有訊息(參閱“[與傳統訊息傳遞相比的日誌](ch11.md#與傳統訊息傳遞相比的日誌)”)。因此,如果日誌是按有待確保唯一的值做的分割槽,則流處理器可以無歧義地,確定性地決定幾個衝突操作中的哪一個先到達。例如,在多個使用者嘗試宣告相同使用者名稱的情況下【57】: + +1. 每個對使用者名稱的請求都被編碼為一條訊息,並追加到按使用者名稱雜湊值確定的分割槽。 +2. 流處理器依序讀取日誌中的請求,並使用本地資料庫來追蹤哪些使用者名稱已經被佔用了。對於所有申請可用使用者名稱的請求,它都會記錄該使用者名稱,並向輸出流傳送一條成功訊息。對於所有申請已佔用使用者名稱的請求,它都會向輸出流傳送一條拒絕訊息。 +3. 請求使用者名稱的客戶端監視輸出流,等待與其請求相對應的成功或拒絕訊息。 + + +該演算法基本上與“[使用全序廣播實現線性一致的儲存](ch9.md#使用全序廣播實現線性一致的儲存)”中的演算法相同。它可以簡單地透過增加分割槽數擴充套件至較大的請求吞吐量,因為每個分割槽可以被獨立處理。 + +​ 該方法不僅適用於唯一性約束,而且適用於許多其他型別的約束。其基本原理是,任何可能衝突的寫入都會路由到相同的分割槽並按順序處理。正如“[什麼是衝突?](ch5.md#什麼是衝突?)”與“[寫入偏差與幻讀](ch7.md#寫入偏差與幻讀)”中所述,衝突的定義可能取決於應用,但流處理器可以使用任意邏輯來驗證請求。這個想法與Bayou在90年代開創的方法類似【58】。 + +#### 多分割槽請求處理 + +​ 當涉及多個分割槽時,確保操作以原子方式執行且同時滿足約束就變得很有趣了。在[例12-2]()中,可能有三個分割槽:一個包含請求ID,一個包含收款人賬戶,另一個包含付款人賬戶。沒有理由把這三種東西放入同一個分割槽,因為它們都是相互獨立的。 + +​ 在資料庫的傳統方法中,執行此事務需要跨全部三個分割槽進行原子提交,這實質上是將該事務嵌入一個全序,就這些分割槽上的所有其他事務而言。而這樣就要求跨分割槽協調,不同的分割槽無法再獨立地進行處理,因此吞吐量可能會受到影響。 + +但事實證明,使用分割槽日誌可以達到等價的正確性而無需原子提交: + +1. 從賬戶A向賬戶B轉賬的請求由客戶端提供一個唯一的請求ID,並按請求ID追加寫入相應日誌分割槽。 +2. 流處理器讀取請求日誌。對於每個請求訊息,它向輸出流發出兩條訊息:付款人賬戶A的借記指令(按A分割槽),收款人B的貸記指令(按B分割槽)。被髮出的訊息中會帶有原始的請求ID。 +3. 後續處理器消費借記/貸記指令流,按照請求ID除重,並將變更應用至賬戶餘額。 + +步驟1和步驟2是必要的,因為如果客戶直接傳送貸記與借記指令,則需要在這兩個分割槽之間進行原子提交,以確保兩者要麼都發生或都不發生。為了避免對分散式事務的需要,我們首先將請求持久化記錄為單條訊息,然後從這第一條訊息中衍生出貸記指令與借記指令。幾乎在所有資料系統中,單物件寫入都是原子性的(參閱“[單物件寫入](ch7.md#單物件寫入)),因此請求要麼出現在日誌中,要麼就不出現,無需多分割槽原子提交。 + +​ 如果流處理器在步驟2中崩潰,則它會從上一個存檔點恢復處理。這樣做時,它不會跳過任何請求訊息,但可能會多次處理請求併產生重複的貸記與借記指令。但由於它是確定性的,因此它只是再次生成相同的指令,而步驟3中的處理器可以使用端到端請求ID輕鬆地對其除重。 + +​ 如果你想確保付款人的帳戶不會因此次轉賬而透支,則可以使用一個額外的流處理器來維護賬戶餘額並校驗事務(按付款人賬戶分割槽),只有有效的事務會被記錄在步驟1中的請求日誌中。 + +​ 透過將多分割槽事務分解為兩個不同分割槽方式的階段,並使用端到端的請求ID,我們實現了同樣的正確性屬性(每個請求對付款人與收款人都恰好生效一次),即使在出現故障,且沒有使用原子提交協議的情況下依然如此。使用多個不同分割槽方式的階段與我們在“[多分割槽資料處理](#多分割槽資料處理)”中討論的想法類似(參閱“[併發控制](ch11.md#併發控制)”)。 + +### 及時性與完整性 + +​ 事務的一個便利屬性是,它們通常是線性一致的(參閱“[線性一致性](ch9.md#線性一致性)”),也就是說,寫入者會等到事務提交,而之後其寫入立刻對所有讀取者可見。 + +​ 當我們把一個操作拆分為跨越多個階段的流處理器時,卻並非如此:日誌的消費者在設計上就是非同步的,因此傳送者不會等其訊息被消費者處理完。但是,客戶端等待輸出流中的特定訊息是可能的。這正是我們在“[基於日誌訊息傳遞中的唯一性](#基於日誌訊息傳遞中的唯一性)”一節中檢查唯一性約束時所做的事情。 + +​ 在這個例子中,唯一性檢查的正確性不取決於訊息傳送者是否等待結果。等待的目的僅僅是同步通知傳送者唯一性檢查是否成功。但該通知可以與訊息處理的結果相解耦。 + +​ 更一般地來講,我認為術語**一致性(consistency)** 這個術語混淆了兩個值得分別考慮的需求: + +***及時性(Timeliness)*** + +​ 及時性意味著確保使用者觀察到系統的最新狀態。我們之前看到,如果使用者從陳舊的資料副本中讀取資料,它們可能會觀察到系統處於不一致的狀態(參閱“[複製延遲問題](ch5.md#複製延遲問題)”)。但這種不一致是暫時的,而最終會透過等待與重試簡單地得到解決。 + +​ CAP定理(參閱“[線性一致性的代價](ch9.md#線性一致性的代價)”)使用**線性一致性(linearizability)** 意義上的一致性,這是實現及時性的強有力方法。像**寫後讀**這樣及時性更弱的一致性也很有用(參閱“[讀己之寫](ch5.md#讀己之寫)”)也很有用。 + +***完整性(Integrity)*** + +​ 完整性意味著沒有損壞;即沒有資料丟失,並且沒有矛盾或錯誤的資料。尤其是如果某些衍生資料集是作為底層資料之上的檢視而維護的(參閱“[從事件日誌匯出當前狀態](ch11.md#從事件日誌匯出當前狀態)”),這種衍生必須是正確的。例如,資料庫索引必須正確地反映資料庫的內容 —— 缺失某些記錄的索引並不是很有用。 + +​ 如果完整性被違背,這種不一致是永久的:在大多數情況下,等待與重試並不能修復資料庫損壞。相反的是,需要顯式地檢查與修復。在ACID事務的上下文中(參閱“[ACID的涵義](ch7.md#ACID的涵義)”),一致性通常被理解為某種特定於應用的完整性概念。原子性和永續性是保持完整性的重要工具。 + + + +​ 口號形式:違反及時性,“最終一致性”;違反完整性,“永無一致性”。 + +​ 我斷言在大多數應用中,完整性比及時性重要得多。違反及時性可能令人困惑與討厭,但違反完整性的結果可能是災難性的。 + +​ 例如在你的信用卡對賬單上,如果某一筆過去24小時內完成的交易尚未出現並不令人奇怪 —— 這些系統有一定的滯後是正常的。我們知道銀行是非同步核算與敲定交易的,而這裡的及時性也並不是非常重要【3】。但果當期對賬單餘額與上期對賬單餘額加交易總額對不上(求和錯誤),或者出現一比向你收費但未向商家付款的交易(消失的錢),那實在是太糟糕了。這樣的問題就違背了系統的完整性。 + +#### 資料流系統的正確性 + +​ ACID事務通常既提供及時性(例如線性一致性)也提供完整性保證(例如原子提交)。因此如果你從ACID事務的角度來看待應用的正確性,那麼及時性與完整性的區別是無關緊要的。 + +​ 另一方面,對於在本章中討論的基於事件的資料流系統而言,它們的一個有趣特性就是將及時性與完整性分開。在非同步處理事件流時不能保證及時性,除非你顯式構建一個在返回之前明確等待特定訊息到達的消費者。但完整性實際上才是流處理系統的核心。 + +​ **恰好一次**或**等效一次**語義(參閱“[容錯](ch11.md#容錯)”)是一種保持完整性的機制。如果事件丟失或者生效兩次,就有可能違背資料系統的完整性。因此在面對故障時,容錯訊息傳遞與重複抑制(例如,冪等操作)對於維護資料系統的完整性是很重要的。 + +​ 正如我們在上一節看到的那樣,可靠的流處理系統可以在無需分散式事務與原子提交協議的情況下保持完整性,這意味著它們能潛在地實現好得多的效能與運維穩健性,在達到類似正確性的前提下。為了達成這種正確性,我們組合使用了多種機制: + +* 將寫入操作的內容表示為單條訊息,從而可以輕鬆地被原子寫入 —— 與事件溯源搭配效果拔群(參閱“[事件溯源](ch11.md#事件溯源)”)。 +* 使用與儲存過程類似的確定性衍生函式,從這一訊息中衍生出所有其他的狀態變更(參見“[真的序列執行](ch7.md#真的序列執行)”和“[作為衍生函式的應用程式碼](ch11.md#作為衍生函式的應用程式碼)”) +* 將客戶端生成的請求ID傳遞透過所有的處理層次,從而啟用端到端除重,帶來冪等性。 +* 使訊息不可變,並允許衍生資料能隨時被重新處理,這使從錯誤中恢復更加容易(參閱“[不可變事件的優點](ch11.md#不可變事件的優點)”) + +這種機制組合在我看來,是未來構建容錯應用的一個非常有前景的方向。 + +#### 寬鬆地解釋約束 + +​ 如前所述,執行唯一性約束需要共識,通常透過在單個節點中彙集特定分割槽中的所有事件來實現。如果我們想要傳統的唯一性約束形式,這種限制是不可避免的,流處理也不例外。 + +然而另一個需要了解的事實是,許多真實世界的應用實際上可以擺脫這種形式,接受弱得多的唯一性: + +* 如果兩個人同時註冊了相同的使用者名稱或預訂了相同的座位,你可以傳送其中一個發訊息道歉,並要求他們選擇一個不同的使用者名稱。這種糾正錯誤的變化被稱為**補償性事務(compensating transaction)**【59,60】。 +* 如果客戶訂購的物品多於倉庫中的物品,你可以下單補倉,併為延誤向客戶道歉,向他們提供折扣。實際上,這麼說吧,如果在叉車在倉庫中軋過了你的貨物,剩下的貨物比你想象的要少,那麼你也是得這麼做【61】。因此,既然道歉工作流無論如何已經成為你商業過程中的一部分了,那麼對庫存物品數目新增線性一致的約束可能就沒必要了。 +* 與之類似,許多航空公司都會超賣機票,打著一些旅客可能會錯過航班的算盤;許多旅館也會超賣客房,抱著部分客人可能會取消預訂的期望。在這些情況下,出於商業原因而故意違反了“一人一座”的約束;當需求超過供給的情況出現時,就會進入補償流程(退款、升級艙位/房型、提供隔壁酒店的免費的房間)。即使沒有超賣,為了應對由惡劣天氣或員工罷工導致的航班取消,你還是需要道歉與補償流程 —— 從這些問題中恢復僅僅是商業活動的正常組成部分。 +* 如果有人從賬戶超額取款,銀行可以向他們收取透支費用,並要求他們償還欠款。透過限制每天的提款總額,銀行的風險是有限的。 + + + +​ 在許多商業場景中,臨時違背約束並稍後透過道歉來修復,實際上是可以接受的。道歉的成本各不相同,但通常很低(以金錢或名聲來算):你無法撤回已傳送的電子郵件,但可以傳送一封后續電子郵件進行更正。如果你不小心向信用卡收取了兩次費用,則可以將其中一項收費退款,而代價僅僅是手續費,也許還有客戶的投訴……。儘管一旦ATM吐了錢,你無法直接取回,但原則上如果賬戶透支而客戶拒不支付,你可以派催收員收回欠款…。 + +​ 道歉的成本是否能接受是一個商業決策。如果可以接受的話,在寫入資料之前檢查所有約束的傳統模型反而會帶來不必要的限制,而線性一致性的約束也不是必須的。樂觀寫入,事後檢查可能是一種合理的選擇。你仍然可以在做一些挽回成本高昂的事情前確保驗證發生,但這並不意味著寫入資料之前必須先進行驗證。 + +​ 這些應用**確實**需要完整性:你不會希望丟失預訂資訊,或者由於借方貸方不匹配導致資金消失。但是它們在執行約束時**並不需要**及時性:如果你銷售的貨物多於倉庫中的庫存,可以在事後道歉後並彌補問題。這種做法與我們在“[處理寫入衝突](ch5.md#處理寫入衝突)”中討論的衝突解決方法類似。 + +#### 無協調資料系統 + +我們現在做了兩個有趣的觀察: + +1. 資料流系統可以維持衍生資料的完整性保證,而無需原子提交,線性一致性,或者同步跨分割槽協調。 +2. 雖然嚴格的唯一性約束要求及時性和協調,但許多應用實際上可以接受寬鬆的約束:只要整個過程保持完整性,這些約束可能會被臨時違反並在稍後被修復。 + +總之這些觀察意味著,資料流系統可以為許多應用提供無需協調的資料管理服務,且仍能給出很強的完整性保證。這種**無協調(coordination-avoiding)** 的資料系統有著很大的吸引力:比起需要執行同步協調的系統,它們能達到更好的效能與更強的容錯能力【56】。 + +​ 例如,這種系統可以使用多領導者配置運維,跨越多個數據中心,在區域間非同步複製。任何一個數據中心都可以持續獨立執行,因為不需要同步的跨區域協調。這樣的系統時效性保證會很弱 —— 如果不引入協調它是不可能是線性一致的 —— 但它仍然可以提供有力的完整性保證。 + +​ 在這種情況下,可序列化事務作為維護衍生狀態的一部分仍然是有用的,但它們可以在小範圍內執行,在那裡它們工作得很好【8】。異構分散式事務(如XA事務)(請參閱“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”)不是必需的。同步協調仍然可以在需要的地方引入(例如在無法恢復的操作之前強制執行嚴格的約束),但是如果只是應用的一小部分地方需要它,沒必要讓所有操作都付出協調的代價。【43】。 + +​ 另一種審視協調與約束的角度是:它們減少了由於不一致而必須做出的道歉數量,但也可能會降低系統的效能和可用性,從而可能增加由於宕機中斷而需要做出的道歉數量。你不可能將道歉數量減少到零,但可以根據自己的需求尋找最佳平衡點 —— 既不存在太多不一致性,又不存在太多可用性問題的最佳選擇。 + +### 信任但驗證 + +​ 我們所有關於正確性,完整性和容錯的討論都基於一些假設,假設某些事情可能會出錯,但其他事情不會。我們將這些假設稱為我們的**系統模型(system model)**(參閱“[將系統模型對映到現實世界](ch8.md#將系統模型對映到現實世界)”):例如,我們應該假設程序可能會崩潰,機器可能突然斷電,網路可能會任意延遲或丟棄訊息。但是我們也可能假設寫入磁碟的資料在執行`fsync`後不會丟失,記憶體中的資料沒有損壞,而CPU的乘法指令總是能返回正確的結果。 + +​ 這些假設是相當合理的,因為大多數時候它們都是成立的,如果我們不得不經常擔心計算機出錯,那麼基本上寸步難行。在傳統上,系統模型採用二元方法處理故障:我們假設有些事情可能會發生,而其他事情**永遠**不會發生。實際上,這更像是一個概率問題:有些事情更有可能,其他事情不太可能。問題在於違反我們假設的情況是否經常發生,以至於我們可能在實踐中遇到它們。 + +​ 我們已經看到,資料可能會在尚未落盤時損壞(參閱“[複製與永續性](ch5.md#複製與永續性)”),而網路上的資料損壞有時可能規避了TCP校驗和(參閱“[弱謊言形式](ch8.md#弱謊言形式)” )。也許我們應當更關注這些事情? + +​ 我過去所從事的一個應用收集了來自客戶端的崩潰報告,我們收到的一些報告,只有在這些裝置記憶體中出現了隨機位翻轉才解釋的通。這看起來不太可能,但是如果有足夠多的裝置執行你的軟體,那麼即使再不可能發生的事也確實會發生。除了由於硬體故障或輻射導致的隨機儲存器損壞之外,一些病態的儲存器訪問模式甚至可以在沒有故障的儲存器中翻轉位【62】 —— 一種可用於破壞作業系統安全機制的效應【63】(這種技術被稱為**Rowhammer**)。一旦你仔細觀察,硬體並不是看上去那樣完美的抽象。 + +​ 要澄清的是,隨機位翻轉在現代硬體上仍是非常罕見的【64】。我只想指出,它們並沒有超越可能性的範疇,所以值得一些關注。 + +#### 維護完整性,儘管軟體有Bug + +​ 除了這些硬體問題之外,總是存在軟體Bug的風險,這些錯誤不會被較低層次的網路,記憶體或檔案系統校驗和所捕獲。即使廣泛使用的資料庫軟體也有Bug:即使像MySQL與PostgreSQL這樣穩健、考慮充分、久經實戰考驗,多年以來被許多人充分測試過的軟體,就我個人所見也有Bug:比如MySQL未能正確維護唯一約束【65】,以及PostgreSQL的可序列化隔離等級存在特定的寫偏差異常【66】。對於更不成熟的軟體來說,情況可能要糟糕的多。 + +​ 儘管在仔細設計,測試,以及審查上做出很多努力,但Bug仍然會在不知不覺中產生。儘管它們很少,而且最終會被發現並被修復,但總會有那麼一段時間,這些Bug可能會損壞資料。 + +​ 而對於應用程式碼,我們不得不假設會有更多的錯誤,因為絕大多數應用的程式碼經受的評審與測試遠遠無法與資料庫的程式碼相比。許多應用甚至沒有正確使用資料庫提供的用於維持完整性的功能,例如外來鍵或唯一性約束【36】。 + +​ ACID意義下的一致性(參閱“[一致性](ch7.md#一致性)”)基於這樣一種想法:資料庫以一致的狀態啟動,而事務將其從一個一致狀態轉換至另一個一致的狀態。因此,我們期望資料庫始終處於一致狀態。然而,只有當你假設事務沒有Bug時,這種想法才有意義。如果應用以某種錯誤的方式使用資料庫,例如,不安全地使用弱隔離等級,資料庫的完整性就無法得到保證。 + +#### 不要盲目信任承諾 + +​ 由於硬體和軟體並不總是符合我們的理想,所以資料損壞似乎早晚不可避免。因此,我們至少應該有辦法查明資料是否已經損壞,以便我們能夠修復它,並嘗試追查錯誤的來源。檢查資料完整性稱為**審計(auditing)**。 + +​ 如“[不可變事件的優點](ch11.md#不可變事件的優點)”一節中所述,審計不僅僅適用於財務應用程式。不過,可審計性在財務中是非常非常重要的。這種錯誤發生之後,需要能被檢測與解決,我們都知道所有人都會認為這是合理需求。 + +​ 成熟的系統同樣傾向於考慮不太可能的事情出錯的可能性,並管理這種風險。例如,HDFS和Amazon S3等大規模儲存系統並不完全信任磁碟:它們執行後臺程序持續回讀檔案,並將其與其他副本進行比較,並將檔案從一個磁碟移動到另一個,以便降低靜默損壞的風險【67】。 + +​ 如果你想確保你的資料仍然存在,你必須真正讀取它並進行檢查。大多數時候它們仍然會在那裡,但如果不是這樣,你一定想盡早知道答案,而不是更晚。按照同樣的原則,不時地嘗試從備份中恢復是非常重要的 —— 否則當你發現備份損壞時,你可能已經遇到了資料丟失,那時候就真的太晚了。不要盲目地相信它們全都管用。 + +#### 驗證的文化 + +​ 像HDFS和S3這樣的系統仍然需要假設磁碟大部分時間都能正常工作 —— 這是一個合理的假設,但與它們**始終**能正常工作的假設並不相同。然而目前還沒有多少系統採用這種“信任但是驗證”的方式來持續審計自己。許多人認為正確性保證是絕對的,並且沒有為罕見的資料損壞的可能性做過準備。我希望未來能看到更多的**自我驗證(self-validating)** 或**自我審計(self-auditing)** 系統,不斷檢查自己的完整性,而不是依賴盲目的信任【68】。 + +​ 我擔心ACID資料庫的文化導致我們在盲目信任技術(如事務機制)的基礎上開發應用,而忽視了這種過程中的任何可審計性。由於我們所信任的技術在大多數情況下工作得很好,通常會認為審計機制並不值得投資。 + +​ 但隨之而來的是,資料庫的格局發生了變化:在NoSQL的旗幟下,更弱的一致性保證成為常態,更不成熟的儲存技術越來越被廣泛使用。但是由於審計機制還沒有被開發出來,儘管這種方式越來越危險,我們仍不斷在盲目信任的基礎上構建應用。讓我們想一想如何針對可審計性而設計吧。 + +#### 為可審計性而設計 + +​ 如果一個事務在一個數據庫中改變了多個物件,在這一事實發生後,很難說清這個事務到底意味著什麼。即使你捕獲了事務日誌(參閱“[變更資料捕獲](ch11.md#變更資料捕獲)”),各種表中的插入,更新和刪除操作並不一定能清楚地表明**為什麼**要執行這些變更。決定這些變更的是應用邏輯中的呼叫,而這一應用邏輯稍縱即逝,無法重現。 + +​ 相比之下,基於事件的系統可以提供更好的可審計性。在事件溯源方法中,系統的使用者輸入被表示為一個單一不可變事件,而任何其導致的狀態變更都衍生自該事件。衍生可以實現為具有確定性與可重複性,因而相同的事件日誌透過相同版本的衍生程式碼時,會導致相同的狀態變更。 + +​ 顯式處理資料流(參閱“[批處理輸出的哲學](ch10.md#批處理輸出的哲學)”)可以使資料的**來龍去脈(provenance)** 更加清晰,從而使完整性檢查更具可行性。對於事件日誌,我們可以使用雜湊來檢查事件儲存沒有被破壞。對於任何衍生狀態,我們可以重新執行從事件日誌中衍生它的批處理器與流處理器,以檢查是否獲得相同的結果,或者,甚至並行執行冗餘的衍生流程。 + +​ 具有確定性且定義良好的資料流,也使除錯與跟蹤系統的執行變得容易,以便確定它**為什麼**做了某些事情【4,69】。如果出現意想之外的事情,那麼重現導致意外事件的確切事故現場的診斷能力—— 一種時間旅行除錯功能是非常有價值的。 + +#### 端到端原則重現 + +​ 如果我們不能完全相信系統的每個元件都不會損壞 —— 每一個硬體都沒缺陷,每一個軟體都沒有Bug —— 那我們至少必須定期檢查資料的完整性。如果我們不檢查,我們就不能發現損壞,直到無可挽回地導致對下游的破壞時,那時候再去追蹤問題就要難得多,且代價也要高的多。 + +​ 檢查資料系統的完整性,最好是以端到端的方式進行(參閱“[資料庫的端到端爭論](#資料庫的端到端爭論)”):我們能在完整性檢查中涵蓋的系統越多,某些處理階中出現不被察覺損壞的機率就越小。如果我們能檢查整個衍生資料管道端到端的正確性,那麼沿著這一路徑的任何磁碟,網路,服務,以及演算法的正確性檢查都隱含在其中了。 + +​ 持續的端到端完整性檢查可以不斷提高你對系統正確性的信心,從而使你能更快地進步【70】。與自動化測試一樣,審計提高了快速發現錯誤的可能性,從而降低了系統變更或新儲存技術可能導致損失的風險。如果你不害怕進行變更,就可以更好地充分演化一個應用,使其滿足不斷變化的需求。 + +#### 用於可審計資料系統的工具 + +​ 目前,將可審計性作為頂層關注點的資料系統並不多。一些應用實現了自己的審計機制,例如將所有變更記錄到單獨的審計表中,但是確保審計日誌與資料庫狀態的完整性仍然是很困難的。可以透過定期使用硬體安全模組對事務日誌進行簽名來防止篡改,但這無法保證正確的事務一開始就能進入到日誌中。 + +​ 使用密碼學工具來證明系統的完整性是十分有趣的,這種方式對於寬泛的硬體與軟體問題,甚至是潛在的惡意行為都很穩健有效。加密貨幣,區塊鏈,以及諸如比特幣,以太坊,Ripple,Stellar的分散式賬本技術已經迅速出現在這一領域【71,72,73】。 + +​ 我沒有資格評論這些技術用於貨幣,或者合同商定機制的價值。但從資料系統的角度來看,它們包含了一些有趣的想法。實質上,它們是分散式資料庫,具有資料模型與事務機制,而不同副本可以由互不信任的組織託管。副本不斷檢查其他副本的完整性,並使用共識協議對應當執行的事務達成一致。 + +​ 我對這些技術的拜占庭容錯方面有些懷疑(參閱“[拜占庭故障](ch8.md#拜占庭故障)”),而且我發現**工作證明(proof of work)** 技術非常浪費(比如,比特幣挖礦)。比特幣的交易吞吐量相當低,儘管是出於政治與經濟原因而非技術上的原因。不過,完整性檢查的方面是很有趣的。 + +​ 密碼學審計與完整性檢查通常依賴**默克爾樹(Merkle tree)**【74】,這是一顆雜湊值的樹,能夠用於高效地證明一條記錄出現在一個數據集中(以及其他一些特性)。除了炒作的沸沸揚揚的加密貨幣之外,**證書透明性(certificate transparency)** 也是一種依賴Merkle樹的安全技術,用來檢查TLS/SSL證書的有效性【75,76】。 + +​ 我可以想象,那些在證書透明度與分散式賬本中使用的完整性檢查和審計演算法,將會在通用資料系統中得到越來越廣泛的應用。要使得這些演算法對於沒有密碼學審計的系統同樣可伸縮,並儘可能降低效能損失還需要一些工作。 但我認為這是一個值得關注的有趣領域。 + + + +## 做正確的事情 + +​ 在本書的最後部分,我想退後一步。在本書中,我們考察了各種不同的資料系統架構,評價了它們的優點與缺點,並探討了構建可靠,可擴充套件,可維護應用的技術。但是,我們忽略了討論中一個重要而基礎的部分,現在我想補充一下。 + +​ 每個系統都服務於一個目的;我們採取的每個舉措都會同時產生期望的後果與意外的後果。這個目的可能只是簡單地賺錢,但其對世界的影響,可能會遠遠超出最初的目的。我們,建立這些系統的工程師,有責任去仔細考慮這些後果,並有意識地決定,我們希望生活在怎樣的世界中。 + +​ 我們將資料當成一種抽象的東西來討論,但請記住,許多資料集都是關於人的:他們的行為,他們的興趣,他們的身份。對待這些資料,我們必須懷著人性與尊重。使用者也是人類,人類的尊嚴是至關重要的。 + +​ 軟體開發越來越多地涉及重要的道德抉擇。有一些指導原則可以幫助軟體工程師解決這些問題,例如ACM的軟體工程道德規範與專業實踐【77】,但實踐中很少會討論這些,更不用說應用與強制執行了。因此,工程師和產品經理有時會對隱私與產品潛在的負面後果抱有非常傲慢的態度【78,79,80】。 + +​ 技術本身並無好壞之分 —— 關鍵在於它被如何使用,以及它如何影響人們。這對槍械這樣的武器,這是成立的,而搜尋引擎這樣的軟體系統與之類似。我認為,軟體工程師僅僅專注於技術而忽視其後果是不夠的:道德責任也是我們的責任。對道德推理很困難,但它太重要了,我們無法忽視。 + +### 預測性分析 + +​ 舉個例子,預測性分析是“大資料”炒作的主要內容之一。使用資料分析預測天氣或疾病傳播是一碼事【81】;而預測一個罪犯是否可能再犯,一個貸款申請人是否有可能違約,或者一個保險客戶是否可能進行昂貴的索賠,則是另外一碼事。後者會直接影響到個人的生活。 + +​ 當然,支付網路希望防止欺詐交易,銀行希望避免不良貸款,航空公司希望避免劫機,公司希望避免僱傭效率低下或不值得信任的人。從它們的角度來看,失去商機的成本很低,而不良貸款或問題員工的成本則要高得多,因而組織希望保持謹慎也是自然而然的事情。所以如果存疑,它們通常會Say No。 + +​ 然而,隨著演算法決策變得越來越普遍,被某種演算法(準確地或錯誤地)標記為有風險的某人可能會遭受大量這種“No”的決定。系統性地被排除在工作,航旅,保險,租賃,金融服務,以及其他社會關鍵領域之外。這是一種對個體自由的極大約束,因此被稱為“演算法監獄”【82】。在尊重人權的國家,刑事司法系統會做無罪推定(預設清白,直到被證明有罪)。另一方面,自動化系統可以系統地,任意地將一個人排除在社會參與之外,不需要任何有罪的證明,而且幾乎沒有申訴的機會。 + +#### 偏見與歧視 + +​ 演算法做出的決定不一定比人類更好或更差。每個人都可能有偏見,即使他們主動抗拒這一點;而歧視性做法也可能已經在文化上被制度化了。人們希望根據資料做出決定,而不是透過人的主觀評價與直覺,希望這樣能更加公平,並給予傳統體制中經常被忽視的人更好的機會。【83】。 + +​ 當我們開發預測性分析系統時,不是僅僅用軟體透過一系列IF ELSE規則將人類的決策過程自動化,那些規則本身甚至都是從資料中推斷出來的。但這些系統學到的模式是個黑盒:即使資料中存在一些相關性,我們可能也壓根不知道為什麼。如果演算法的輸入中存在系統性的偏見,則系統很有可能會在輸出中學習並放大這種偏見【84】。 + +​ 在許多國家,反歧視法律禁止按種族,年齡,性別,性取向,殘疾,或信仰等受保護的特徵區分對待不同的人。其他的個人特徵可能是允許用於分析的,但是如果這些特徵與受保護的特徵存在關聯,又會發生什麼?例如在種族隔離地區中,一個人的郵政編碼,甚至是他們的IP地址,都是很強的種族指示物。這樣的話,相信一種演算法可以以某種方式將有偏資料作為輸入,併產生公平和公正的輸出【85】似乎是很荒謬的。然而這種觀點似乎常常潛伏在資料驅動型決策的支持者中,這種態度被諷刺為“在處理偏差上,機器學習與洗錢類似”(machine learning is like money laundering for bias)【86】。 + +​ 預測性分析系統只是基於過去進行推斷;如果過去是歧視性的,它們就會將這種歧視歸納為規律。如果我們希望未來比過去更好,那麼就需要道德想象力,而這是隻有人類才能提供的東西【87】。資料與模型應該是我們的工具,而不是我們的主人。 + +#### 責任與問責 + +​ 自動決策引發了關於責任與問責的問題【87】。如果一個人犯了錯誤,他可以被追責,受決定影響的人可以申訴。演算法也會犯錯誤,但是如果它們出錯,誰來負責【88】?當一輛自動駕駛汽車引發事故時,誰來負責?如果自動信用評分算法系統性地歧視特定種族或宗教的人,這些人是否有任何追索權?如果機器學習系統的決定要受到司法審查,你能向法官解釋演算法是如何做出決定的嗎? + +​ 收集關於人的資料並進行決策,信用評級機構是一個很經典的例子。不良的信用評分會使生活變得更艱難,但至少信用分通常是基於個人**實際的**借款歷史記錄,而記錄中的任何錯誤都能被糾正(儘管機構通常會設定門檻)。然而,基於機器學習的評分演算法通常會使用更寬泛的輸入,並且更不透明;因而很難理解特定決策是怎樣作出的,以及是否有人被不公正地,歧視性地對待【89】。 + +​ 信用分總結了“你過去的表現如何?”,而預測性分析通常是基於“誰與你類似,以及與你類似的人過去表現的如何?”。與他人的行為畫上等號意味著刻板印象,例如,根據他們居住的地方(與種族和階級關係密切的特徵)。那麼那些放錯位置的人怎麼辦?而且,如果是因為錯誤資料導致的錯誤決定,追索幾乎是不可能的【87】。 + +​ 很多資料本質上是統計性的,這意味著即使概率分佈在總體上是正確的,對於個例也可能是錯誤的。例如,如果貴國的平均壽命是80歲,這並不意味著你在80歲生日時就會死掉。很難從平均值與概率分佈中對某個特定個體的壽命作出什麼判斷,同樣,預測系統的輸出是概率性的,對於個例可能是錯誤的。 + +​ 盲目相信資料決策至高無上,這不僅僅是一種妄想,而是有切實危險的。隨著資料驅動的決策變得越來越普遍,我們需要弄清楚,如何使演算法更負責任且更加透明,如何避免加強現有的偏見,以及如何在它們不可避免地出錯時加以修復。 + +​ 我們還需要想清楚,如何避免資料被用於害人,如何認識資料的積極潛力。例如,分析可以揭示人們生活的財務特點與社會特點。一方面,這種權力可以用來將援助與支援集中在幫助那些最需要援助的人身上。另一方面,它有時會被掠奪性企業用於識別弱勢群體,並向其兜售高風險產品,比如高利貸,智商稅與莆田醫院【87,90】[^譯註i]。 + +#### 反饋迴圈 + +​ 即使是那些對人直接影響比較小的預測性應用,比如推薦系統,也有一些必須正視的難題。當服務變得善於預測使用者想要看到什麼內容時,它最終可能只會向人們展示他們已經同意的觀點,將人們帶入滋生刻板印象,誤導資訊,與極端思想的**迴音室**。我們已經看到過社交媒體迴音室對競選的影響了【91】。 + +​ 當預測性分析影響人們的生活時,自我強化的反饋迴圈會導致非常有害的問題。例如,考慮僱主使用信用分來評估候選人的例子。你可能是一個信用分不錯的好員工,但因不可抗力的意外而陷入財務困境。由於不能按期付賬單,你的信用分會受到影響,進而導致找到工作更為困難。失業使你陷入貧困,這進一步惡化了你的分數,使你更難找到工作【87】。在資料與數學嚴謹性的偽裝背後,隱藏的是由惡毒假設導致的惡性迴圈。 + +​ 我們無法預測這種反饋迴圈何時發生。然而透過對整個系統(不僅僅是計算機化的部分,而且還有與之互動的人)進行整體思考,許多後果是可以夠預測的 —— 一種稱為**系統思維(systems thinkin)** 的方法【92】。我們可以嘗試理解資料分析系統如何響應不同的行為,結構或特性。該系統是否加強和增大了人們之間現有的差異(例如,損不足以奉有餘,富者愈富,貧者愈貧),還是試圖與不公作鬥爭?而且即使有著最好的動機,我們也必須當心意想不到的後果。 + +### 隱私和追蹤 + +​ 除了預測性分析 —— 即使用資料來做出關於人的自動決策 —— 資料收集本身也存在道德問題。收集資料的組織,與被收集資料的人之間,到底屬於什麼關係? + +​ 當系統只儲存使用者明確輸入的資料時,是因為使用者希望系統以特定方式儲存和處理這些資料,**系統是在為使用者提供服務**:使用者就是客戶。但是,當用戶的活動被跟蹤並記錄,作為他們正在做的其他事情的副作用時,這種關係就沒有那麼清晰了。該服務不再僅僅完成使用者想要它要做的事情,而是服務於它自己的利益,而這可能與使用者的利益相沖突。 + +​ 追蹤使用者行為資料對於許多面向用戶的線上服務而言,變得越來越重要:追蹤使用者點選了哪些搜尋結果有助於提高搜尋結果的排名;推薦“喜歡X的人也喜歡Y”,可以幫助使用者發現實用有趣的東西; A/B測試和使用者流量分析有助於改善使用者介面。這些功能需要一定量的使用者行為跟蹤,而使用者也可以從中受益。 + +​ 但不同公司有著不同的商業模式,追蹤並未止步於此。如果服務是透過廣告盈利的,那麼廣告主才是真正的客戶,而使用者的利益則屈居其次。跟蹤的資料會變得更詳細,分析變得更深入,資料會保留很長時間,以便為每個人建立詳細畫像,用於營銷。 + +​ 現在,公司與被收集資料的使用者之間的關係,看上去就不太一樣了。公司會免費服務使用者,並引誘使用者儘可能多地使用服務。對使用者的追蹤,主要不是服務於該使用者個體,而是服務於掏錢資助該服務的廣告商。我認為這種關係可以用一個更具罪犯內涵的詞來恰當地描述:**監視(surveilance)**。 + +#### 監視 + +​ 讓我們做一個思想實驗,嘗試用**監視(surveillance)** 一詞替換**資料(data)**,再看看常見的短語是不是聽起來還那麼漂亮【93】。比如:“在我們的監視驅動的組織中,我們收集實時監視流並將它們儲存在我們的監視倉庫中。我們的監視科學家使用高階分析和監視處理來獲得新的見解。“ + +​ 對於本書《設計監控密集型應用》而言,這個思想實驗是罕見的爭議性內容,但我認為需要激烈的言辭來強調這一點。在我們嘗試製造軟體“吞噬世界”的過程中【94】,我們已經建立了世界上迄今為止所見過的最偉大的大規模監視基礎設施。我們正朝著萬物互聯邁進,我們正在迅速走近這樣一個世界:每個有人居住的空間至少包含一個帶網際網路連線的麥克風,以智慧手機,智慧電視,語音控制助理裝置,嬰兒監視器甚至兒童玩具的形式存在,並使用基於雲的語音識別。這些裝置中的很多都有著可怕的安全記錄【95】。 + +​ 即使是最為極權與專制的政權,可能也只會想著在每個房間裝一個麥克風,並強迫每個人始終攜帶能夠追蹤其位置與動向的裝置。然而,我們顯然是自願地,甚至熱情地投身於這個全域監視的世界。不同之處在於,資料是由公司,而不是由政府機構收集的【96】。 + +​ 並不是所有的資料收集都稱得上監視,但檢視這一點有助於理解我們與資料收集者之間的關係。為什麼我們似乎很樂意接受企業的監視呢?也許你覺得自己沒有什麼好隱瞞的 —— 換句話說,你與當權階級穿一條褲子,你不是被邊緣化的少數派,也不必害怕受到迫害【97】。不是每個人都如此幸運。或者,也許這是因為目的似乎是溫和的 —— 這不是公然脅迫,也不是強制性的,而只是更好的推薦與更個性化的營銷。但是,結合上一節中對預測性分析的討論,這種區別似乎並不是很清晰。 + +​ 我們已經看到與汽車追蹤裝置掛鉤的汽車保險費,以及取決於需要人佩戴健身追蹤裝置來確定的健康保險範圍。當監視被用於決定生活的重要方面時,例如保險或就業,它就開始變得不那麼溫和了。此外,資料分析可以揭示出令人驚訝的私密事物:例如,智慧手錶或健身追蹤器中的運動感測器能以相當好的精度計算出你正在輸入的內容(比如密碼)【98】。而分析演算法只會變得越來越精確。 + +#### 同意與選擇的自由 + +​ 我們可能會斷言使用者是自願選擇使用服務的,儘管服務會跟蹤其活動,而且他們已經同意了服務條款與隱私政策,因此他們同意資料收集。我們甚至可以聲稱,使用者在用所提供的資料來**換取**有價值的服務,並且為了提供服務,追蹤是必要的。毫無疑問,社交網路,搜尋引擎,以及各種其他免費的線上服務對於使用者來說都是有價值的,但是這個說法卻存在問題。 + +​ 使用者幾乎不知道他們提供給我們的是什麼資料,哪些資料被放進了資料庫,資料又是怎樣被保留與處理的 —— 大多數隱私政策都是模稜兩可的,忽悠使用者而不敢開啟天窗說亮話。如果使用者不瞭解他們的資料會發生什麼,就無法給出任何有意義的同意。有時來自一個使用者的資料還會提到一些關於其他人的事,而其他那些人既不是該服務的使用者,也沒有同意任何條款。我們在本書這一部分中討論的衍生資料集 —— 來自整個使用者群的資料,加上行為追蹤與外部資料來源 —— 就恰好是使用者無法(在真正意義上)理解的資料型別。 + +​ 而且從使用者身上挖掘資料是一個單向過程,而不是真正的互惠關係,也不是公平的價值交換。使用者對能用多少資料換來什麼樣的服務,既沒有沒有發言權也沒有選擇權:服務與使用者之間的關係是非常不對稱與單邊的。這些條款是由服務提出的,而不是由使用者提出的【99】。 + +​ 對於不同意監視的使用者,唯一真正管用的備選項,就是簡單地不使用服務。但這個選擇也不是真正自由的:如果一項服務如此受歡迎,以至於“被大多數人認為是基本社會參與的必要條件”【99】,那麼指望人們選擇退出這項服務是不合理的 —— 使用它**事實上(de facto)** 是強制性的。例如,在大多數西方社會群體中,攜帶智慧手機,使用Facebook進行社交,以及使用Google查詢資訊已成為常態。特別是當一項服務具有網路效應時,人們選擇**不**使用會產生社會成本。 + +​ 因為跟蹤使用者而拒絕使用服務,這只是少數人才擁有的權力,他們有足夠的時間與知識來了解隱私政策,並承受的起代價:錯過社會參與,以及使用服務可能帶來的專業機會。對於那些處境不太好的人而言,並沒有真正意義上的選擇:監控是不可避免的。 + +#### 隱私與資料使用 + +​ 有時候,人們聲稱“隱私已死”,理由是有些使用者願意把各種關於他們生活的事情釋出到社交媒體上,有時是平凡俗套,但有時是高度私密的。但這種說法是錯誤的,而且是對**隱私(privacy)** 一詞的誤解。 + +​ 擁有隱私並不意味著保密一切東西;它意味著擁有選擇向誰展示哪些東西的自由,要公開什麼,以及要保密什麼。**隱私權是一項決定權**:在從保密到透明的光譜上,隱私使得每個人都能決定自己想要在什麼地方位於光譜上的哪個位置【99】。這是一個人自由與自主的重要方面。 + +​ 當透過監控基礎設施從人身上提取資料時,隱私權不一定受到損害,而是轉移到了資料收集者手中。獲取資料的公司實際上是說“相信我們會用你的資料做正確的事情”,這意味著,決定要透露什麼和保密什麼的權利從個體手中轉移到了公司手中。 + +​ 這些公司反過來選擇保密這些監視結果,因為揭露這些會令人毛骨悚然,並損害它們的商業模式(比其他公司更瞭解人)。使用者的私密資訊只會間接地披露,例如針對特定人群定向投放廣告的工具(比如那些患有特定疾病的人群)。 + +​ 即使特定使用者無法從特定廣告定向的人群中以個體的形式區分出來,但他們已經失去了披露一些私密資訊的能動性,例如他們是否患有某種疾病。決定向誰透露什麼並不是由個體按照自己的喜好決定的,是由**公司**,以利潤最大化為目標來行使隱私權的。 + +​ 許多公司都有一個目標,不要讓人**感覺到**毛骨悚然 —— 先不說它們收集資料實際上是多麼具有侵犯性,讓我們先關注使用者感知的管理。這些使用者感受經常被管理的很糟糕:例如,在事實上可能正確的一些東西,但如果會觸發痛苦的回憶,使用者可能並不希望被提醒【100】。對於任何型別的資料,我們都應當考慮它出錯、不可取、不合時宜的可能性,並且需要建立處理這些失效的機制。無論是“不可取”還是“不合時宜”,當然都是由人的判斷決定的;除非我們明確地將演算法編碼設計為尊重人類的需求,否則演算法會無視這些概念。作為這些系統的工程師,我們必須保持謙卑,充分規劃,接受這些失效。 + +​ 允許線上服務的使用者控制其隱私設定,例如控制其他使用者可以看到哪些東西,是將一些控制交還給使用者的第一步。但無論怎麼設定,服務本身仍然可以不受限制地訪問資料,並能以隱私策略允許的任何方式自由使用它。即使服務承諾不會將資料出售給第三方,它通常會授予自己不受限制的權利,以便在內部處理與分析資料,而且往往比使用者公開可見的部分要深入的多。 + +​ 這種從個體到公司的大規模隱私權轉移在歷史上是史無前例的【99】。監控一直存在,但它過去是昂貴的,手動的,不是可擴充套件的,自動化的。信任關係始終存在,例如患者與其醫生之間,或被告與其律師之間 —— 但在這些情況下,資料的使用嚴格受到道德,法律和監管限制的約束。網際網路服務使得在未經有意義的同意下收集大量敏感資訊變得容易得多,而且無需使用者理解他們的私人資料到底發生了什麼。 + +#### 資料資產與權力 + +​ 由於行為資料是使用者與服務互動的副產品,因此有時被稱為“資料廢氣” —— 暗示資料是毫無價值的廢料。從這個角度來看,行為和預測性分析可以被看作是一種從資料中提取價值的回收形式,否則這些資料就會被浪費。 + +​ 更準確的看法恰恰相反:從經濟的角度來看,如果定向廣告是服務的金主,那麼關於人的行為資料就是服務的核心資產。在這種情況下,使用者與之互動的應用僅僅是一種誘騙使用者將更多的個人資訊提供給監控基礎設施的手段【99】。線上服務中經常表現出的令人愉悅的人類創造力與社會關係,十分諷刺地被資料提取機器所濫用。 + +​ 個人資料是珍貴資產的說法因為資料中介的存在得到支援,這是陰影中的祕密行業,購買,聚合,分析,推斷,以及轉售私密個人資料,主要用於市場營銷【90】。初創公司按照它們的使用者數量,“眼球數”,—— 即它們的監視能力來估值。 + +​ 因為資料很有價值,所以很多人都想要它。當然,公司也想要它 —— 這就是為什麼它們一開始就收集資料的原因。但政府也想獲得它:透過祕密交易,脅迫,法律強制,或者只是竊取【101】。當公司破產時,收集到的個人資料就是被出售的資產之一。而且資料安全很難保護,因此經常發生令人難堪的洩漏事件【102】。 + +​ 這些觀察已經導致批評者聲稱,資料不僅僅是一種資產,而且是一種“有毒資產”【101】,或者至少是“有害物質”【103】。即使我們認為自己有能力阻止資料濫用,但每當我們收集資料時,我們都需要平衡收益以及這些資料落入惡人手中的風險:計算機系統可能會被犯罪分子或敵國特務滲透,資料可能會被內鬼洩露,公司可能會落入不擇手段的管理層手中,而這些管理者有著迥然不同的價值觀,或者國家可能被能毫無愧色迫使我們交出資料的政權所接管。 + +​ 俗話說,“知識就是力量”。更進一步,“在避免自己被審視的同時審視他人,是權力最重要的形式之一”【105】。這就是極權政府想要監控的原因:這讓它們有能力控制全體居民。儘管今天的科技公司並沒有公開地尋求政治權力,但是它們積累的資料與知識卻給它們帶來了很多權力,其中大部分是在公共監督之外偷偷進行的【106】。 + +#### 回顧工業革命 + +​ 資料是資訊時代的決定性特徵。網際網路,資料儲存,處理和軟體驅動的自動化正在對全球經濟和人類社會產生重大影響。我們的日常生活與社會組織在過去十年中發生了變化,而且在未來的十年中可能會繼續發生根本性的變化,所以我們會想到與工業革命對比【87,96】。 + +​ 工業革命是透過重大的技術與農業進步實現的,它帶來了持續的經濟增長,長期的生活水平顯著提高。然而它也帶來了一些嚴重的問題:空氣汙染(由於煙霧和化學過程)和水汙染(工業垃圾和人類垃圾)是可怖的。工廠老闆生活在紛奢之中,而城市工人經常居住在非常糟糕的住房中,並且在惡劣的條件下長時間工作。童工很常見,甚至包括礦井中危險而低薪的工作。 + +​ 制定了保護措施花費了很長的時間,例如環境保護條例,工作場所安全條例,宣佈使用童工非法,以及食品衛生檢查。毫無疑問,生產成本增加了,因為工廠再也不能把廢物倒入河流,銷售汙染的食物,或者剝削工人。但是整個社會都從中受益良多,我們中很少會有人想回到這些管制條例之前的日子【87】。 + +​ 就像工業革命有著黑暗面需要應對一樣,我們轉向資訊時代的過程中,也有需要應對與解決的重大問題。我相信資料的收集與使用就是其中一個問題。用布魯斯·施奈爾的話來說【96】: + +> ​ 資料是資訊時代的汙染問題,保護隱私是環境挑戰。幾乎所有的電腦都能生產資訊。它堆積在周圍,開始潰爛。我們如何處理它 —— 我們如何控制它,以及如何擺脫它 —— 是資訊經濟健康發展的核心議題。正如我們今天回顧工業時代的早期年代,並想知道我們的祖先在忙於建設工業世界的過程時怎麼能忽略汙染問題;我們的孫輩在回望資訊時代的早期年代時,將會就我們如何應對資料收集和濫用的挑戰來評斷我們。 +> +> ​ 我們應該設法讓他們感到驕傲。 + +#### 立法和自律 + +​ 資料保護法可能有助於維護個人的權利。例如,1995年的“歐洲資料保護指示”規定,個人資料必須“為特定的,明確的和合法的目的收集,而不是以與這些目的不相符的方式進一步處理”,並且資料必須“就收集的目的而言適當,相關,不過分。“【107】。 + +​ 但是,這個立法在今天的網際網路環境下是否有效還是有疑問的【108】。這些規則直接否定了大資料的哲學,即最大限度地收集資料,將其與其他資料集結合起來進行試驗和探索,以便產生新的洞察。探索意味著將資料用於未曾預期的目的,這與使用者同意的“特定和明確”目的相反(如果我們可以有意義地表示同意的話)【109】。更新的規章正在制定中【89】。 + +​ 那些收集了大量有關人的資料的公司反對監管,認為這是創新的負擔與阻礙。在某種程度上,這種反對是有道理的。例如,分享醫療資料時,存在明顯的隱私風險,但也有潛在的機遇:如果資料分析能夠幫助我們實現更好的診斷或找到更好的治療方法,能夠阻止多少人的死亡【110】?過度監管可能會阻止這種突破。在這種潛在機會與風險之間找出平衡是很困難的【105】。 + +​ 從根本上說,我認為我們需要科技行業在個人資料方面的文化轉變。我們應該停止將使用者視作待最佳化的指標資料,並記住他們是值得尊重,有尊嚴和能動性的人。我們應當在資料收集和實際處理中自我約束,以建立和維持依賴我們軟體的人們的信任【111】。我們應當將教育終端使用者視為己任,告訴他們我們是如何使用他們的資料的,而不是將他們矇在鼓裡。 + +​ 我們應該允許每個人保留自己的隱私 —— 即,對自己資料的控制 —— 而不是透過監視來竊取這種控制權。我們控制自己資料的個體權利就像是國家公園的自然環境:如果我們不去明確地保護它,關心它,它就會被破壞。這將是公地的悲劇,我們都會因此而變得更糟。無所不在的監視並非不可避免的 —— 我們現在仍然能阻止它。 + +​ 我們究竟能做到哪一步,是一個開放的問題。首先,我們不應該永久保留資料,而是一旦不再需要就立即清除資料【111,112】。清除資料與不變性的想法背道而馳(參閱“[不變性的侷限性](ch11.md#不變性的侷限性)”),但這是可以解決該問題。我所看到的一種很有前景的方法是透過加密協議來實施訪問控制,而不僅僅是透過策略【113,114】。總的來說,文化與態度的改變是必要的。 + + + +## 本章小結 + +​ 在本章中,我們討論了設計資料系統的新方式,而且也包括了我的個人觀點,以及對未來的猜測。我們從這樣一種觀察開始:沒有單種工具能高效服務所有可能的用例,因此應用必須組合使用幾種不同的軟體才能實現其目標。我們討論瞭如何使用批處理與事件流來解決這一**資料整合(data integration)** 問題,以便讓資料變更在不同系統之間流動。 + +​ 在這種方法中,某些系統被指定為記錄系統,而其他資料則透過轉換衍生自記錄系統。透過這種方式,我們可以維護索引,物化檢視,機器學習模型,統計摘要等等。透過使這些衍生和轉換操作非同步且鬆散耦合,能夠防止一個區域中的問題擴散到系統中不相關部分,從而增加整個系統的穩健性與容錯性。 + +​ 將資料流表示為從一個數據集到另一個數據集的轉換也有助於演化應用程式:如果你想變更其中一個處理步驟,例如變更索引或快取的結構,則可以在整個輸入資料集上重新執行新的轉換程式碼,以便重新衍生輸出。同樣,出現問題時,你也可以修復程式碼並重新處理資料以便恢復。 + +​ 這些過程與資料庫內部已經完成的過程非常類似,因此我們將資料流應用的概念重新改寫為,**分拆(unbundling)** 資料庫元件,並透過組合這些鬆散耦合的元件來構建應用程式。 + +​ 衍生狀態可以透過觀察底層資料的變更來更新。此外,衍生狀態本身可以進一步被下游消費者觀察。我們甚至可以將這種資料流一路傳送至顯示資料的終端使用者裝置,從而構建可動態更新以反映資料變更,並在離線時能繼續工作的使用者介面。 + +​ 接下來,我們討論瞭如何確保所有這些處理在出現故障時保持正確。我們看到可擴充套件的強完整性保證可以透過非同步事件處理來實現,透過使用端到端操作識別符號使操作冪等,以及透過非同步檢查約束。客戶端可以等到檢查透過,或者不等待繼續前進,但是可能會冒有違反約束需要道歉的風險。這種方法比使用分散式事務的傳統方法更具可擴充套件性與可靠性,並且在實踐中適用於很多業務流程。 + +​ 透過圍繞資料流構建應用,並非同步檢查約束,我們可以避免絕大多數的協調工作,建立保證完整性且效能仍然表現良好的系統,即使在地理散佈的情況下與出現故障時亦然。然後,我們對使用審計來驗證資料完整性,以及損壞檢測進行了一些討論。 + +​ 最後,我們退後一步,審視了構建資料密集型應用的一些道德問題。我們看到,雖然資料可以用來做好事,但它也可能造成很大傷害:作出嚴重影響人們生活的決定卻難以申訴,導致歧視與剝削,監視常態化,曝光私密資訊。我們也冒著資料被洩露的風險,並且可能會發現,即使是善意地使用資料也可能會導致意想不到的後果。 + +​ 由於軟體和資料對世界產生了如此巨大的影響,我們工程師們必須牢記,我們有責任為我們想要的那種世界而努力:一個尊重人們,尊重人性的世界。我希望我們能夠一起為實現這一目標而努力。 + + + +## 參考文獻 + + +1. Rachid Belaid: “[Postgres Full-Text Search is Good Enough!](http://rachbelaid.com/postgres-full-text-search-is-good-enough/),” *rachbelaid.com*, July 13, 2015. + +1. Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, et al.: “[Challenges to Adopting Stronger Consistency at Scale](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-ajoux.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015. + +1. Pat Helland and Dave Campbell: “[Building on Quicksand](https://database.cs.wisc.edu/cidr/cidr2009/Paper_133.pdf),” at *4th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2009. + +1. Jessica Kerr: “[Provenance and Causality in Distributed Systems](http://blog.jessitron.com/2016/09/provenance-and-causality-in-distributed.html),” *blog.jessitron.com*, September 25, 2016. + +1. Kostas Tzoumas: “[Batch Is a Special Case of Streaming](http://data-artisans.com/batch-is-a-special-case-of-streaming/),” *data-artisans.com*, September 15, 2015. + +1. Shinji Kim and Robert Blafford: “[Stream Windowing Performance Analysis: Concord and Spark Streaming](http://concord.io/posts/windowing_performance_analysis_w_spark_streaming),” *concord.io*, July 6, 2016. + +1. Jay Kreps: “[The Log: What Every Software Engineer Should Know About Real-Time Data's Unifying Abstraction](http://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying),” *engineering.linkedin.com*, December 16, 2013. + +1. Pat Helland: “[Life Beyond Distributed Transactions: An Apostate’s Opinion](http://www-db.cs.wisc.edu/cidr/cidr2007/papers/cidr07p15.pdf),” at *3rd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2007. + +1. “[Great Western Railway (1835–1948)](https://www.networkrail.co.uk/VirtualArchive/great-western/),” Network Rail Virtual Archive, *networkrail.co.uk*. + +1. Jacqueline Xu: “[Online Migrations at Scale](https://stripe.com/blog/online-migrations),” *stripe.com*, February 2, 2017. + +1. Molly Bartlett Dishman and Martin Fowler: “[Agile Architecture](http://conferences.oreilly.com/software-architecture/sa2015/public/schedule/detail/40388),” at *O'Reilly Software Architecture Conference*, March 2015. + +1. 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 + +1. Oscar Boykin, Sam Ritchie, Ian O'Connell, and Jimmy Lin: “[Summingbird: A Framework for Integrating Batch and Online MapReduce Computations](http://www.vldb.org/pvldb/vol7/p1441-boykin.pdf),” at *40th International Conference on Very Large Data Bases* (VLDB), September 2014. + +1. Jay Kreps: “[Questioning the Lambda Architecture](https://www.oreilly.com/ideas/questioning-the-lambda-architecture),” *oreilly.com*, July 2, 2014. + +1. Raul Castro Fernandez, Peter Pietzuch, Jay Kreps, et al.: “[Liquid: Unifying Nearline and Offline Big Data Integration](http://www.cidrdb.org/cidr2015/Papers/CIDR15_Paper25u.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015. + +1. Dennis M. Ritchie and Ken Thompson: “[The UNIX Time-Sharing System](http://www.cs.virginia.edu/~zaher/classes/CS656/p365-ritchie.pdf),” *Communications of the ACM*, volume 17, number 7, pages 365–375, July 1974. [doi:10.1145/361011.361061](http://dx.doi.org/10.1145/361011.361061) + +1. Eric A. Brewer and Joseph M. Hellerstein: “[CS262a: Advanced Topics in Computer Systems](http://people.eecs.berkeley.edu/~brewer/cs262/systemr.html),” lecture notes, University of California, Berkeley, *cs.berkeley.edu*, August 2011. + +1. Michael Stonebraker: “[The Case for Polystores](http://wp.sigmod.org/?p=1629),” *wp.sigmod.org*, July 13, 2015. + +1. Jennie Duggan, Aaron J. Elmore, Michael Stonebraker, et al.: “[The BigDAWG Polystore System](http://dspace.mit.edu/openaccess-disseminate/1721.1/100936),” *ACM SIGMOD Record*, volume 44, number 2, pages 11–16, June 2015. [doi:10.1145/2814710.2814713](http://dx.doi.org/10.1145/2814710.2814713) + +1. Patrycja Dybka: “[Foreign Data Wrappers for PostgreSQL](http://www.vertabelo.com/blog/technical-articles/foreign-data-wrappers-for-postgresql),” *vertabelo.com*, March 24, 2015. + +1. David B. Lomet, Alan Fekete, Gerhard Weikum, and Mike Zwilling: “[Unbundling Transaction Services in the Cloud](https://www.microsoft.com/en-us/research/publication/unbundling-transaction-services-in-the-cloud/),” at *4th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2009. + +1. Martin Kleppmann and Jay Kreps: “[Kafka, Samza and the Unix Philosophy of Distributed Data](http://martin.kleppmann.com/papers/kafka-debull15.pdf),” *IEEE Data Engineering Bulletin*, volume 38, number 4, pages 4–14, December 2015. + +1. John Hugg: “[Winning Now and in the Future: Where VoltDB Shines](https://voltdb.com/blog/winning-now-and-future-where-voltdb-shines),” *voltdb.com*, March 23, 2016. + +1. Frank McSherry, Derek G. Murray, Rebecca Isaacs, and Michael Isard: “[Differential Dataflow](http://cidrdb.org/cidr2013/Papers/CIDR13_Paper111.pdf),” + at *6th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2013. + +1. Derek G Murray, Frank McSherry, Rebecca Isaacs, et al.: “[Naiad: A Timely Dataflow System](http://research.microsoft.com/pubs/201100/naiad_sosp2013.pdf),” + at *24th ACM Symposium on Operating Systems Principles* (SOSP), pages 439–455, November 2013. + [doi:10.1145/2517349.2522738](http://dx.doi.org/10.1145/2517349.2522738) + +1. Gwen Shapira: “[We have a bunch of customers who are implementing ‘database inside-out’ concept and they all ask ‘is anyone else doing it? are we crazy?’](https://twitter.com/gwenshap/status/758800071110430720)” *twitter.com*, July 28, 2016. + +1. Martin Kleppmann: “[Turning the Database Inside-out with Apache Samza,](http://martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html)” at *Strange Loop*, September 2014. + +1. Peter Van Roy and Seif Haridi: *Concepts, Techniques, and Models of Computer Programming*. MIT Press, 2004. ISBN: 978-0-262-22069-9 + +1. “[Juttle Documentation](http://juttle.github.io/juttle/),” *juttle.github.io*, 2016. + +1. Evan Czaplicki and Stephen Chong: “[Asynchronous Functional Reactive Programming for GUIs](http://people.seas.harvard.edu/~chong/pubs/pldi13-elm.pdf),” at *34th ACM SIGPLAN Conference on Programming Language Design and Implementation* (PLDI), June 2013. [doi:10.1145/2491956.2462161](http://dx.doi.org/10.1145/2491956.2462161) + +1. Engineer Bainomugisha, Andoni Lombide Carreton, Tom van Cutsem, Stijn Mostinckx, and Wolfgang de Meuter: “[A Survey on Reactive Programming](http://soft.vub.ac.be/Publications/2012/vub-soft-tr-12-13.pdf),” *ACM Computing Surveys*, volume 45, number 4, pages 1–34, August 2013. [doi:10.1145/2501654.2501666](http://dx.doi.org/10.1145/2501654.2501666) + +1. Peter Alvaro, Neil Conway, Joseph M. Hellerstein, and William R. Marczak: “[Consistency Analysis in Bloom: A CALM and Collected Approach](http://www.eecs.berkeley.edu/~palvaro/cidr11.pdf),” at *5th Biennial Conference on Innovative Data Systems Research* + (CIDR), January 2011. + +1. Felienne Hermans: “[Spreadsheets Are Code](https://vimeo.com/145492419),” at *Code Mesh*, November 2015. + +1. Dan Bricklin and Bob Frankston: “[VisiCalc: Information from Its Creators](http://danbricklin.com/visicalc.htm),” *danbricklin.com*. + +1. D. Sculley, Gary Holt, Daniel Golovin, et al.: “[Machine Learning: The High-Interest Credit Card of Technical Debt](http://research.google.com/pubs/pub43146.html),” at *NIPS Workshop on Software Engineering for Machine Learning* (SE4ML), December 2014. + +1. Peter Bailis, Alan Fekete, Michael J Franklin, et al.: “[Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity](http://www.bailis.org/papers/feral-sigmod2015.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2737784](http://dx.doi.org/10.1145/2723372.2737784) + +1. Guy Steele: “[Re: Need for Macros (Was Re: Icon)](https://people.csail.mit.edu/gregs/ll1-discuss-archive-html/msg01134.html),” email to *ll1-discuss* mailing list, *people.csail.mit.edu*, December 24, 2001. + +1. David Gelernter: “[Generative Communication in Linda](http://cseweb.ucsd.edu/groups/csag/html/teaching/cse291s03/Readings/p80-gelernter.pdf),” *ACM Transactions on Programming Languages and Systems* (TOPLAS), volume 7, number 1, pages 80–112, January 1985. [doi:10.1145/2363.2433](http://dx.doi.org/10.1145/2363.2433) + +1. Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec: “[The Many Faces of Publish/Subscribe](http://www.cs.ru.nl/~pieter/oss/manyfaces.pdf),” *ACM Computing Surveys*, volume 35, number 2, pages 114–131, June 2003. [doi:10.1145/857076.857078](http://dx.doi.org/10.1145/857076.857078) + +1. Ben Stopford: “[Microservices in a Streaming World](https://www.infoq.com/presentations/microservices-streaming),” at *QCon London*, March 2016. + +1. Christian Posta: “[Why Microservices Should Be Event Driven: Autonomy vs Authority](http://blog.christianposta.com/microservices/why-microservices-should-be-event-driven-autonomy-vs-authority/),” *blog.christianposta.com*, May 27, 2016. + +1. Alex Feyerke: “[Say Hello to Offline First](http://hood.ie/blog/say-hello-to-offline-first.html),” *hood.ie*, November 5, 2013. + +1. Sebastian Burckhardt, Daan Leijen, Jonathan Protzenko, and Manuel Fähndrich: “[Global Sequence Protocol: A Robust Abstraction for Replicated Shared State](http://drops.dagstuhl.de/opus/volltexte/2015/5238/),” at *29th European Conference on Object-Oriented Programming* (ECOOP), July 2015. [doi:10.4230/LIPIcs.ECOOP.2015.568](http://dx.doi.org/10.4230/LIPIcs.ECOOP.2015.568) + +1. Mark Soper: “[Clearing Up React Data Management Confusion with Flux, Redux, and Relay](https://medium.com/@marksoper/clearing-up-react-data-management-confusion-with-flux-redux-and-relay-aad504e63cae),” *medium.com*, December 3, 2015. + +1. Eno Thereska, Damian Guy, Michael Noll, and Neha Narkhede: “[Unifying Stream Processing and Interactive Queries in Apache Kafka](http://www.confluent.io/blog/unifying-stream-processing-and-interactive-queries-in-apache-kafka/),” *confluent.io*, October 26, 2016. + +1. Frank McSherry: “[Dataflow as Database](https://github.com/frankmcsherry/blog/blob/master/posts/2016-07-17.md),” *github.com*, July 17, 2016. + +1. Peter Alvaro: “[I See What You Mean](https://www.youtube.com/watch?v=R2Aa4PivG0g),” at *Strange Loop*, September 2015. + +1. Nathan Marz: “[Trident: A High-Level Abstraction for Realtime Computation](https://blog.twitter.com/2012/trident-a-high-level-abstraction-for-realtime-computation),” *blog.twitter.com*, August 2, 2012. + +1. Edi Bice: “[Low Latency Web Scale Fraud Prevention with Apache Samza, Kafka and Friends](http://www.slideshare.net/edibice/extremely-low-latency-web-scale-fraud-prevention-with-apache-samza-kafka-and-friends),” at *Merchant Risk Council MRC Vegas Conference*, March 2016. + +1. Charity Majors: “[The Accidental DBA](https://charity.wtf/2016/10/02/the-accidental-dba/),” *charity.wtf*, October 2, 2016. + +1. Arthur J. Bernstein, Philip M. Lewis, and Shiyong Lu: “[Semantic Conditions for Correctness at Different Isolation Levels](http://db.cs.berkeley.edu/cs286/papers/isolation-icde2000.pdf),” at *16th International Conference on Data Engineering* (ICDE), February 2000. [doi:10.1109/ICDE.2000.839387](http://dx.doi.org/10.1109/ICDE.2000.839387) + +1. Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan: “[Automating the Detection of Snapshot Isolation Anomalies](http://www.vldb.org/conf/2007/papers/industrial/p1263-jorwekar.pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007. + +1. Kyle Kingsbury: [Jepsen blog post series](https://aphyr.com/tags/jepsen), *aphyr.com*, 2013–2016. + +1. Michael Jouravlev: “[Redirect After Post](http://www.theserverside.com/news/1365146/Redirect-After-Post),” *theserverside.com*, August 1, 2004. + +1. Jerome H. Saltzer, David P. Reed, and David D. Clark: “[End-to-End Arguments in System Design](http://www.ece.drexel.edu/courses/ECE-C631-501/SalRee1984.pdf),” *ACM Transactions on Computer Systems*, volume 2, number 4, pages 277–288, November 1984. [doi:10.1145/357401.357402](http://dx.doi.org/10.1145/357401.357402) + +1. Peter Bailis, Alan Fekete, Michael J. Franklin, et al.: “[Coordination-Avoiding Database Systems](http://arxiv.org/pdf/1402.2237.pdf),” + *Proceedings of the VLDB Endowment*, volume 8, number 3, pages 185–196, November 2014. + +1. Alex Yarmula: “[Strong Consistency in Manhattan](https://blog.twitter.com/2016/strong-consistency-in-manhattan),” *blog.twitter.com*, March 17, 2016. + +1. Douglas B Terry, Marvin M Theimer, Karin Petersen, et al.: “[Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System](http://css.csail.mit.edu/6.824/2014/papers/bayou-conflicts.pdf),” at *15th ACM Symposium on Operating Systems Principles* (SOSP), pages 172–182, December 1995. [doi:10.1145/224056.224070](http://dx.doi.org/10.1145/224056.224070) + +1. Jim Gray: “[The Transaction Concept: Virtues and Limitations](http://research.microsoft.com/en-us/um/people/gray/papers/theTransactionConcept.pdf),” at *7th International Conference on Very Large Data Bases* (VLDB), September 1981. + +1. Hector Garcia-Molina and Kenneth Salem: “[Sagas](http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1987. [doi:10.1145/38713.38742](http://dx.doi.org/10.1145/38713.38742) + +1. Pat Helland: “[Memories, Guesses, and Apologies](http://blogs.msdn.com/b/pathelland/archive/2007/05/15/memories-guesses-and-apologies.aspx),” *blogs.msdn.com*, May 15, 2007. + +1. Yoongu Kim, Ross Daly, Jeremie Kim, et al.: “[Flipping Bits in Memory Without Accessing Them: An Experimental Study of DRAM Disturbance Errors](https://users.ece.cmu.edu/~yoonguk/papers/kim-isca14.pdf),” at *41st Annual International Symposium on Computer Architecture* (ISCA), June 2014. [doi:10.1145/2678373.2665726](http://dx.doi.org/10.1145/2678373.2665726) + +1. Mark Seaborn and Thomas Dullien: “[Exploiting the DRAM Rowhammer Bug to Gain Kernel Privileges](https://googleprojectzero.blogspot.co.uk/2015/03/exploiting-dram-rowhammer-bug-to-gain.html),” *googleprojectzero.blogspot.co.uk*, March 9, 2015. + +1. Jim N. Gray and Catharine van Ingen: “[Empirical Measurements of Disk Failure Rates and Error Rates](https://www.microsoft.com/en-us/research/publication/empirical-measurements-of-disk-failure-rates-and-error-rates/),” Microsoft Research, MSR-TR-2005-166, December 2005. + +1. Annamalai Gurusami and Daniel Price: “[Bug #73170: Duplicates in Unique Secondary Index Because of Fix of Bug#68021](http://bugs.mysql.com/bug.php?id=73170),” *bugs.mysql.com*, July 2014. + +1. Gary Fredericks: “[Postgres Serializability Bug](https://github.com/gfredericks/pg-serializability-bug),” *github.com*, September 2015. + +1. Xiao Chen: “[HDFS DataNode Scanners and Disk Checker Explained](http://blog.cloudera.com/blog/2016/12/hdfs-datanode-scanners-and-disk-checker-explained/),” *blog.cloudera.com*, December 20, + 2016. + +1. Jay Kreps: “[Getting Real About Distributed System Reliability](http://blog.empathybox.com/post/19574936361/getting-real-about-distributed-system-reliability),” *blog.empathybox.com*, March 19, 2012. + +1. Martin Fowler: “[The LMAX Architecture](http://martinfowler.com/articles/lmax.html),” *martinfowler.com*, July 12, 2011. + +1. Sam Stokes: “[Move Fast with Confidence](http://blog.samstokes.co.uk/blog/2016/07/11/move-fast-with-confidence/),” *blog.samstokes.co.uk*, July 11, 2016. + +1. “[Sawtooth Lake Documentation](http://intelledger.github.io/introduction.html),” Intel Corporation, *intelledger.github.io*, 2016. + +1. Richard Gendal Brown: “[Introducing R3 Corda™: A Distributed Ledger Designed for Financial Services](https://gendal.me/2016/04/05/introducing-r3-corda-a-distributed-ledger-designed-for-financial-services/),” *gendal.me*, April 5, 2016. + +1. Trent McConaghy, Rodolphe Marques, Andreas Müller, et al.: “[BigchainDB: A Scalable Blockchain Database](https://www.bigchaindb.com/whitepaper/bigchaindb-whitepaper.pdf),” *bigchaindb.com*, June 8, 2016. + +1. Ralph C. Merkle: “[A Digital Signature Based on a Conventional Encryption Function](https://people.eecs.berkeley.edu/~raluca/cs261-f15/readings/merkle.pdf),” at *CRYPTO '87*, August 1987. [doi:10.1007/3-540-48184-2_32](http://dx.doi.org/10.1007/3-540-48184-2_32) + +1. Ben Laurie: “[Certificate Transparency](http://queue.acm.org/detail.cfm?id=2668154),” *ACM Queue*, volume 12, number 8, pages 10-19, August 2014. [doi:10.1145/2668152.2668154](http://dx.doi.org/10.1145/2668152.2668154) + +1. Mark D. Ryan: “[Enhanced Certificate Transparency and End-to-End Encrypted Mail](http://www.internetsociety.org/doc/enhanced-certificate-transparency-and-end-end-encrypted-mail),” at *Network and Distributed System Security Symposium* (NDSS), February 2014. [doi:10.14722/ndss.2014.23379](http://dx.doi.org/10.14722/ndss.2014.23379) + +1. “Software Engineering Code of Ethics and Professional + Practice,” Association for Computing Machinery, *acm.org*, 1999. + +1. François Chollet: “[Software development is starting to involve important ethical choices](https://twitter.com/fchollet/status/792958695722201088),” *twitter.com*, October 30, 2016. + +1. Igor Perisic: “[Making Hard Choices: The Quest for Ethics in Machine Learning](https://engineering.linkedin.com/blog/2016/11/making-hard-choices--the-quest-for-ethics-in-machine-learning),” *engineering.linkedin.com*, November 2016. + +1. John Naughton: “[Algorithm Writers Need a Code of Conduct](https://www.theguardian.com/commentisfree/2015/dec/06/algorithm-writers-should-have-code-of-conduct),” *theguardian.com*, December 6, 2015. + +1. Logan Kugler: “[What Happens When Big Data Blunders?](http://cacm.acm.org/magazines/2016/6/202655-what-happens-when-big-data-blunders/fulltext),” *Communications of the ACM*, volume 59, number 6, pages 15–16, June 2016. [doi:10.1145/2911975](http://dx.doi.org/10.1145/2911975) + +1. Bill Davidow: “[Welcome to Algorithmic Prison](http://www.theatlantic.com/technology/archive/2014/02/welcome-to-algorithmic-prison/283985/),” *theatlantic.com*, February 20, 2014. + +1. Don Peck: “[They're Watching You at Work](http://www.theatlantic.com/magazine/archive/2013/12/theyre-watching-you-at-work/354681/),” *theatlantic.com*, December 2013. + +1. Leigh Alexander: “[Is an Algorithm Any Less Racist Than a Human?](https://www.theguardian.com/technology/2016/aug/03/algorithm-racist-human-employers-work)” *theguardian.com*, August 3, 2016. + +1. Jesse Emspak: “[How a Machine Learns Prejudice](https://www.scientificamerican.com/article/how-a-machine-learns-prejudice/),” *scientificamerican.com*, December 29, 2016. + +1. Maciej Cegłowski: “[The Moral Economy of Tech](http://idlewords.com/talks/sase_panel.htm),” *idlewords.com*, June 2016. + +1. Cathy O'Neil: *Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy*. Crown Publishing, 2016. ISBN: 978-0-553-41881-1 + +1. Julia Angwin: “[Make Algorithms Accountable](http://www.nytimes.com/2016/08/01/opinion/make-algorithms-accountable.html),” *nytimes.com*, August 1, 2016. + +1. Bryce Goodman and Seth Flaxman: “[European Union Regulations on Algorithmic Decision-Making and a ‘Right to Explanation’](https://arxiv.org/abs/1606.08813),” *arXiv:1606.08813*, August 31, 2016. + +1. “[A Review of the Data Broker Industry: Collection, Use, and Sale of Consumer Data for Marketing Purposes](https://www.commerce.senate.gov/public/index.cfm/reports?ID=57C428EC-8F20-44EE-BFB8-A570E9BE0CCC),” Staff Report, *United States Senate Committee on Commerce, Science, and Transportation*, *commerce.senate.gov*, December 2013. + +1. Olivia Solon: “[Facebook’s Failure: Did Fake News and Polarized Politics Get Trump Elected?](https://www.theguardian.com/technology/2016/nov/10/facebook-fake-news-election-conspiracy-theories)” *theguardian.com*, November 10, 2016. + +1. Donella H. Meadows and Diana Wright: *Thinking in Systems: A Primer*. Chelsea Green Publishing, 2008. ISBN: 978-1-603-58055-7 + +1. Daniel J. Bernstein: “[Listening to a ‘big data’/‘data science’ talk](https://twitter.com/hashbreaker/status/598076230437568512),” *twitter.com*, May 12, 2015. + +1. Marc Andreessen: “[Why Software Is Eating the World](http://genius.com/Marc-andreessen-why-software-is-eating-the-world-annotated),” *The Wall Street Journal*, 20 August 2011. + +1. J. M. Porup: “[‘Internet of Things’ Security Is Hilariously Broken and Getting Worse](http://arstechnica.com/security/2016/01/how-to-search-the-internet-of-things-for-photos-of-sleeping-babies/),” *arstechnica.com*, January 23, 2016. + +1. 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 + +1. The Grugq: “[Nothing to Hide](https://grugq.tumblr.com/post/142799983558/nothing-to-hide),” *grugq.tumblr.com*, April 15, 2016. + +1. Tony Beltramelli: “[Deep-Spying: Spying Using Smartwatch and Deep Learning](https://arxiv.org/abs/1512.05616),” Masters Thesis, IT University of Copenhagen, December 2015. Available at *arxiv.org/abs/1512.05616* + +1. Shoshana Zuboff: “[Big Other: Surveillance Capitalism and the Prospects of an Information Civilization](http://papers.ssrn.com/sol3/papers.cfm?abstract_id=2594754),” *Journal of Information Technology*, volume 30, number 1, pages 75–89, April 2015.[doi:10.1057/jit.2015.5](http://dx.doi.org/10.1057/jit.2015.5) + +1. Carina C. Zona: “[Consequences of an Insightful Algorithm](https://www.youtube.com/watch?v=YRI40A4tyWU),” at *GOTO Berlin*, November 2016. + +1. Bruce Schneier: “[Data Is a Toxic Asset, So Why Not Throw It Out?](https://www.schneier.com/essays/archives/2016/03/data_is_a_toxic_asse.html),” *schneier.com*, March 1, 2016. + +1. John E. Dunn: “[The UK’s 15 Most Infamous Data Breaches](http://www.techworld.com/security/uks-most-infamous-data-breaches-2016-3604586/),” *techworld.com*, November 18, 2016. + +1. Cory Scott: “[Data is not toxic - which implies no benefit - but rather hazardous material, where we must balance need vs. want](https://twitter.com/cory_scott/status/706586399483437056),” *twitter.com*, March 6, 2016. + +1. Bruce Schneier: “[Mission Creep: When Everything Is Terrorism](https://www.schneier.com/essays/archives/2013/07/mission_creep_when_e.html),” *schneier.com*, July 16, 2013. + +1. Lena Ulbricht and Maximilian von Grafenstein: “[Big Data: Big Power Shifts?](http://policyreview.info/articles/analysis/big-data-big-power-shifts),” *Internet Policy Review*, volume 5, number 1, March 2016. [doi:10.14763/2016.1.406](http://dx.doi.org/10.14763/2016.1.406) + +1. Ellen P. Goodman and Julia Powles: “[Facebook and Google: Most Powerful and Secretive Empires We've Ever Known](https://www.theguardian.com/technology/2016/sep/28/google-facebook-powerful-secretive-empire-transparency),” *theguardian.com*, September 28, 2016. + +1. [Directive 95/46/EC on the protection of individuals with regard to the processing of personal data and on the free movement of such data](http://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:31995L0046), Official Journal of the European Communities No. L 281/31, + *eur-lex.europa.eu*, November 1995. + +1. Brendan Van Alsenoy: “[Regulating Data Protection: The Allocation of Responsibility and Risk Among Actors Involved in Personal Data Processing](https://lirias.kuleuven.be/handle/123456789/545027),” Thesis, KU Leuven Centre for IT and IP Law, August 2016. + +1. Michiel Rhoen: “[Beyond Consent: Improving Data Protection Through Consumer Protection Law](http://policyreview.info/articles/analysis/beyond-consent-improving-data-protection-through-consumer-protection-law),” *Internet Policy Review*, volume 5, number 1, March 2016. [doi:10.14763/2016.1.404](http://dx.doi.org/10.14763/2016.1.404) + +1. Jessica Leber: “[Your Data Footprint Is Affecting Your Life in Ways You Can’t Even Imagine](https://www.fastcoexist.com/3057514/your-data-footprint-is-affecting-your-life-in-ways-you-cant-even-imagine),” *fastcoexist.com*, March 15, 2016. + +1. Maciej Cegłowski: “[Haunted by Data](http://idlewords.com/talks/haunted_by_data.htm),” *idlewords.com*, October 2015. + +1. Sam Thielman: “[You Are Not What You Read: Librarians Purge User Data to Protect Privacy](https://www.theguardian.com/us-news/2016/jan/13/us-library-records-purged-data-privacy),” *theguardian.com*, January 13, 2016. + +1. Conor Friedersdorf: “[Edward Snowden’s Other Motive for Leaking](http://www.theatlantic.com/politics/archive/2014/05/edward-snowdens-other-motive-for-leaking/370068/),” *theatlantic.com*, May 13, 2014. + +1. Phillip Rogaway: “[The Moral Character of Cryptographic Work](http://web.cs.ucdavis.edu/~rogaway/papers/moral-fn.pdf),” Cryptology ePrint 2015/1162, December 2015. + + + + +------ + +| 上一章 | 目錄 | 下一章 | +| --------------------------- | ------------------------------- | ------------------- | +| [第十一章:流處理](ch11.md) | [設計資料密集型應用](README.md) | [後記](colophon.md) | + diff --git a/zh-tw/ch2.md b/zh-tw/ch2.md new file mode 100644 index 00000000..2b2e52e7 --- /dev/null +++ b/zh-tw/ch2.md @@ -0,0 +1,1045 @@ +# 2. 資料模型與查詢語言 + +![](img/ch2.png) + +> 語言的邊界就是思想的邊界。 +> +> —— 路德維奇·維特根斯坦,《邏輯哲學》(1922) +> + +------------------- + +[TOC] + +資料模型可能是軟體開發中最重要的部分了,因為它們的影響如此深遠:不僅僅影響著軟體的編寫方式,而且影響著我們的**解題思路**。 + +多數應用使用層層疊加的資料模型構建。對於每層資料模型的關鍵問題是:它是如何用低一層資料模型來**表示**的?例如: + +1. 作為一名應用開發人員,你觀察現實世界(裡面有人員,組織,貨物,行為,資金流向,感測器等),並採用物件或資料結構,以及操控那些資料結構的API來進行建模。那些結構通常是特定於應用程式的。 +2. 當要儲存那些資料結構時,你可以利用通用資料模型來表示它們,如JSON或XML文件,關係資料庫中的表、或圖模型。 +3. 資料庫軟體的工程師選定如何以記憶體、磁碟或網路上的位元組來表示JSON/XML/關係/圖資料。這類表示形式使資料有可能以各種方式來查詢,搜尋,操縱和處理。 +4. 在更低的層次上,硬體工程師已經想出了使用電流,光脈衝,磁場或者其他東西來表示位元組的方法。 + +一個複雜的應用程式可能會有更多的中間層次,比如基於API的API,不過基本思想仍然是一樣的:每個層都透過提供一個明確的資料模型來隱藏更低層次中的複雜性。這些抽象允許不同的人群有效地協作(例如資料庫廠商的工程師和使用資料庫的應用程式開發人員)。 + +資料模型種類繁多,每個資料模型都帶有如何使用的設想。有些用法很容易,有些則不支援如此;有些操作執行很快,有些則表現很差;有些資料轉換非常自然,有些則很麻煩。 + +掌握一個數據模型需要花費很多精力(想想關係資料建模有多少本書)。即便只使用一個數據模型,不用操心其內部工作機制,構建軟體也是非常困難的。然而,因為資料模型對上層軟體的功能(能做什麼,不能做什麼)有著至深的影響,所以選擇一個適合的資料模型是非常重要的。 + +在本章中,我們將研究一系列用於資料儲存和查詢的通用資料模型(前面列表中的第2點)。特別地,我們將比較關係模型,文件模型和少量基於圖形的資料模型。我們還將檢視各種查詢語言並比較它們的用例。在第3章中,我們將討論儲存引擎是如何工作的。也就是說,這些資料模型實際上是如何實現的(列表中的第3點)。 + + + +## 關係模型與文件模型 + +現在最著名的資料模型可能是SQL。它基於Edgar Codd在1970年提出的關係模型【1】:資料被組織成**關係**(SQL中稱作**表**),其中每個關係是**元組**(SQL中稱作**行**)的無序集合。 + +關係模型曾是一個理論性的提議,當時很多人都懷疑是否能夠有效實現它。然而到了20世紀80年代中期,關係資料庫管理系統(RDBMSes)和SQL已成為大多數人們儲存和查詢某些常規結構的資料的首選工具。關係資料庫已經持續稱霸了大約25~30年——這對計算機史來說是極其漫長的時間。 + +關係資料庫起源於商業資料處理,在20世紀60年代和70年代用大型計算機來執行。從今天的角度來看,那些用例顯得很平常:典型的**事務處理**(將銷售或銀行交易,航空公司預訂,庫存管理資訊記錄在庫)和**批處理**(客戶發票,工資單,報告)。 + +當時的其他資料庫迫使應用程式開發人員必須考慮資料庫內部的資料表示形式。關係模型致力於將上述實現細節隱藏在更簡潔的介面之後。 + +多年來,在資料儲存和查詢方面存在著許多相互競爭的方法。在20世紀70年代和80年代初,網路模型和分層模型曾是主要的選擇,但關係模型隨後佔據了主導地位。物件資料庫在20世紀80年代末和90年代初來了又去。XML資料庫在二十一世紀初出現,但只有小眾採用過。關係模型的每個競爭者都在其時代產生了大量的炒作,但從來沒有持續【2】。 + +隨著電腦越來越強大和互聯,它們開始用於日益多樣化的目的。關係資料庫非常成功地被推廣到業務資料處理的原始範圍之外更為廣泛的用例上。你今天在網上看到的大部分內容依舊是由關係資料庫來提供支援,無論是線上釋出,討論,社交網路,電子商務,遊戲,軟體即服務生產力應用程式等等內容。 + +### NoSQL的誕生 + +現在 - 2010年代,NoSQL開始了最新一輪嘗試,試圖推翻關係模型的統治地位。“NoSQL”這個名字讓人遺憾,因為實際上它並沒有涉及到任何特定的技術。最初它只是作為一個醒目的Twitter標籤,用在2009年一個關於分散式,非關係資料庫上的開源聚會上。無論如何,這個術語觸動了某些神經,並迅速在網路創業社群內外傳播開來。好些有趣的資料庫系統現在都與*#NoSQL#*標籤相關聯,並且NoSQL被追溯性地重新解釋為**不僅是SQL(Not Only SQL)** 【4】。 + +採用NoSQL資料庫的背後有幾個驅動因素,其中包括: + +* 需要比關係資料庫更好的可擴充套件性,包括非常大的資料集或非常高的寫入吞吐量 +* 相比商業資料庫產品,免費和開源軟體更受偏愛。 +* 關係模型不能很好地支援一些特殊的查詢操作 +* 受挫於關係模型的限制性,渴望一種更具多動態性與表現力的資料模型【5】 + +不同的應用程式有不同的需求,一個用例的最佳技術選擇可能不同於另一個用例的最佳技術選擇。因此,在可預見的未來,關係資料庫似乎可能會繼續與各種非關係資料庫一起使用 - 這種想法有時也被稱為**混合持久化(polyglot persistence)**。 + +### 物件關係不匹配 + +目前大多數應用程式開發都使用面向物件的程式語言來開發,這導致了對SQL資料模型的普遍批評:如果資料儲存在關係表中,那麼需要一個笨拙的轉換層,處於應用程式程式碼中的物件和表,行,列的資料庫模型之間。模型之間的不連貫有時被稱為**阻抗不匹配(impedance mismatch)**[^i]。 + +[^i]: 一個從電子學借用的術語。每個電路的輸入和輸出都有一定的阻抗(交流電阻)。當你將一個電路的輸出連線到另一個電路的輸入時,如果兩個電路的輸出和輸入阻抗匹配,則連線上的功率傳輸將被最大化。阻抗不匹配會導致訊號反射及其他問題。 + +像ActiveRecord和Hibernate這樣的 **物件關係對映(ORM object-relational mapping)** 框架可以減少這個轉換層所需的樣板程式碼的數量,但是它們不能完全隱藏這兩個模型之間的差異。 + +![](img/fig2-1.png) + +**圖2-1 使用關係型模式來表示領英簡介** + +例如,[圖2-1](img/fig2-1.png)展示瞭如何在關係模式中表示簡歷(一個LinkedIn簡介)。整個簡介可以透過一個唯一的識別符號`user_id`來標識。像`first_name`和`last_name`這樣的欄位每個使用者只出現一次,所以可以在User表上將其建模為列。但是,大多數人在職業生涯中擁有多於一份的工作,人們可能有不同樣的教育階段和任意數量的聯絡資訊。從使用者到這些專案之間存在一對多的關係,可以用多種方式來表示: + +* 傳統SQL模型(SQL:1999之前)中,最常見的規範化表示形式是將職位,教育和聯絡資訊放在單獨的表中,對User表提供外來鍵引用,如[圖2-1](img/fig2-1.png)所示。 +* 後續的SQL標準增加了對結構化資料型別和XML資料的支援;這允許將多值資料儲存在單行內,並支援在這些文件內查詢和索引。這些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支援【6,7】。JSON資料型別也得到多個數據庫的支援,包括IBM DB2,MySQL和PostgreSQL 【8】。 +* 第三種選擇是將職業,教育和聯絡資訊編碼為JSON或XML文件,將其儲存在資料庫的文字列中,並讓應用程式解析其結構和內容。這種配置下,通常不能使用資料庫來查詢該編碼列中的值。 + +對於一個像簡歷這樣自包含文件的資料結構而言,JSON表示是非常合適的:參見[例2-1]()。JSON比XML更簡單。面向文件的資料庫(如MongoDB 【9】,RethinkDB 【10】,CouchDB 【11】和Espresso【12】)支援這種資料模型。 + +**例2-1. 用JSON文件表示一個LinkedIn簡介** + +```json +{ + "user_id": 251, + "first_name": "Bill", + "last_name": "Gates", + "summary": "Co-chair of the Bill & Melinda Gates... Active blogger.", + "region_id": "us:91", + "industry_id": 131, + "photo_url": "/p/7/000/253/05b/308dd6e.jpg", + "positions": [ + { + "job_title": "Co-chair", + "organization": "Bill & Melinda Gates Foundation" + }, + { + "job_title": "Co-founder, Chairman", + "organization": "Microsoft" + } + ], + "education": [ + { + "school_name": "Harvard University", + "start": 1973, + "end": 1975 + }, + { + "school_name": "Lakeside School, Seattle", + "start": null, + "end": null + } + ], + "contact_info": { + "blog": "http://thegatesnotes.com", + "twitter": "http://twitter.com/BillGates" + } +} +``` + +有一些開發人員認為JSON模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。不過,正如我們將在[第4章](ch4.md)中看到的那樣,JSON作為資料編碼格式也存在問題。缺乏一個模式往往被認為是一個優勢;我們將在“[文件模型中的模式靈活性](#文件模型中的模式靈活性)”中討論這個問題。 + +JSON表示比[圖2-1](img/fig2-1.png)中的多表模式具有更好的**區域性性(locality)**。如果在前面的關係型示例中獲取簡介,那需要執行多個查詢(透過`user_id`查詢每個表),或者在User表與其下屬表之間混亂地執行多路連線。而在JSON表示中,所有相關資訊都在同一個地方,一個查詢就足夠了。 + +從使用者簡介檔案到使用者職位,教育歷史和聯絡資訊,這種一對多關係隱含了資料中的一個樹狀結構,而JSON表示使得這個樹狀結構變得明確(見[圖2-2](img/fig2-2.png))。 + +![](img/fig2-2.png) + +**圖2-2 一對多關係構建了一個樹結構** + +### 多對一和多對多的關係 + +在上一節的[例2-1]()中,`region_id`和`industry_id`是以ID,而不是純字串“Greater Seattle Area”和“Philanthropy”的形式給出的。為什麼? + +如果使用者介面用一個自由文字欄位來輸入區域和行業,那麼將他們儲存為純文字字串是合理的。另一方式是給出地理區域和行業的標準化的列表,並讓使用者從下拉列表或自動填充器中進行選擇,其優勢如下: + +* 各個簡介之間樣式和拼寫統一 +* 避免歧義(例如,如果有幾個同名的城市) +* 易於更新——名稱只儲存在一個地方,如果需要更改(例如,由於政治事件而改變城市名稱),很容易進行全面更新。 +* 本地化支援——當網站翻譯成其他語言時,標準化的列表可以被本地化,使得地區和行業可以使用使用者的語言來顯示 +* 更好的搜尋——例如,搜尋華盛頓州的慈善家就會匹配這份簡介,因為地區列表可以編碼記錄西雅圖在華盛頓這一事實(從“Greater Seattle Area”這個字串中看不出來) + +儲存ID還是文字字串,這是個 **副本(duplication)** 問題。當使用ID時,對人類有意義的資訊(比如單詞:Philanthropy)只儲存在一處,所有引用它的地方使用ID(ID只在資料庫中有意義)。當直接儲存文字時,對人類有意義的資訊會複製在每處使用記錄中。 + +使用ID的好處是,ID對人類沒有任何意義,因而永遠不需要改變:ID可以保持不變,即使它標識的資訊發生變化。任何對人類有意義的東西都可能需要在將來某個時候改變——如果這些資訊被複制,所有的冗餘副本都需要更新。這會導致寫入開銷,也存在不一致的風險(一些副本被更新了,還有些副本沒有被更新)。去除此類重複是資料庫 **規範化(normalization)** 的關鍵思想。[^ii] + +[^ii]: 關於關係模型的文獻區分了幾種不同的規範形式,但這些區別幾乎沒有實際意義。一個經驗法則是,如果重複儲存了可以儲存在一個地方的值,則模式就不是**規範化(normalized)**的。 + +> 資料庫管理員和開發人員喜歡爭論規範化和非規範化,讓我們暫時保留判斷吧。在本書的[第三部分](part-iii.md),我們將回到這個話題,探討系統的方法用以處理快取,非規範化和衍生資料。 + +不幸的是,對這些資料進行規範化需要多對一的關係(許多人生活在一個特定的地區,許多人在一個特定的行業工作),這與文件模型不太吻合。在關係資料庫中,透過ID來引用其他表中的行是正常的,因為連線很容易。在文件資料庫中,一對多樹結構沒有必要用連線,對連線的支援通常很弱[^iii]。 + +[^iii]: 在撰寫本文時,RethinkDB支援連線,MongoDB不支援連線,而CouchDB只支援預先宣告的檢視。 + +如果資料庫本身不支援連線,則必須在應用程式程式碼中透過對資料庫進行多個查詢來模擬連線。(在這種情況中,地區和行業的列表可能很小,改動很少,應用程式可以簡單地將其儲存在記憶體中。不過,執行連線的工作從資料庫被轉移到應用程式程式碼上。 + +此外,即便應用程式的最初版本適合無連線的文件模型,隨著功能新增到應用程式中,資料會變得更加互聯。例如,考慮一下對簡歷例子進行的一些修改: + +***組織和學校作為實體*** + +在前面的描述中,`organization`(使用者工作的公司)和`school_name`(他們學習的地方)只是字串。也許他們應該是對實體的引用呢?然後,每個組織,學校或大學都可以擁有自己的網頁(標識,新聞提要等)。每個簡歷可以連結到它所提到的組織和學校,並且包括他們的圖示和其他資訊(參見[圖2-3](img/fig2-3.png),來自LinkedIn的一個例子)。 + +***推薦*** + +假設你想新增一個新的功能:一個使用者可以為另一個使用者寫一個推薦。在使用者的簡歷上顯示推薦,並附上推薦使用者的姓名和照片。如果推薦人更新他們的照片,那他們寫的任何建議都需要顯示新的照片。因此,推薦應該擁有作者個人簡介的引用。 +![](img/fig2-3.png) + +**圖2-3 公司名不僅是字串,還是一個指向公司實體的連結(LinkedIn截圖)** + +[圖2-4](img/fig2-4.png)闡明瞭這些新功能需要如何使用多對多關係。每個虛線矩形內的資料可以分組成一個文件,但是對單位,學校和其他使用者的引用需要表示成引用,並且在查詢時需要連線。 + +![](img/fig2-4.png) + +**圖2-4 使用多對多關係擴充套件簡歷** + +### 文件資料庫是否在重蹈覆轍? + +在多對多的關係和連線已常規用在關係資料庫時,文件資料庫和NoSQL重啟了辯論:如何最好地在資料庫中表示多對多關係。那場辯論可比NoSQL古老得多,事實上,最早可以追溯到計算機化資料庫系統。 + +20世紀70年代最受歡迎的業務資料處理資料庫是IBM的資訊管理系統(IMS),最初是為了阿波羅太空計劃的庫存管理而開發的,並於1968年有了首次商業釋出【13】。目前它仍在使用和維護,執行在IBM大型機的OS/390上【14】。 + +IMS的設計中使用了一個相當簡單的資料模型,稱為**層次模型(hierarchical model)**,它與文件資料庫使用的JSON模型有一些驚人的相似之處【2】。它將所有資料表示為巢狀在記錄中的記錄樹,這很像[圖2-2](img/fig2-2.png)的JSON結構。 + +同文檔資料庫一樣,IMS能良好處理一對多的關係,但是很難應對多對多的關係,並且不支援連線。開發人員必須決定是否複製(非規範化)資料或手動解決從一個記錄到另一個記錄的引用。這些二十世紀六七十年代的問題與現在開發人員遇到的文件資料庫問題非常相似【15】。 + +那時人們提出了各種不同的解決方案來解決層次模型的侷限性。其中最突出的兩個是**關係模型(relational model)**(它變成了SQL,統治了世界)和**網路模型(network model)**(最初很受關注,但最終變得冷門)。這兩個陣營之間的“大辯論”在70年代持續了很久時間【2】。 + +那兩個模式解決的問題與當前的問題相關,因此值得簡要回顧一下那場辯論。 + +#### 網路模型 + +網路模型由一個稱為資料系統語言會議(CODASYL)的委員會進行了標準化,並被數個不同的資料庫商實現;它也被稱為CODASYL模型【16】。 + +CODASYL模型是層次模型的推廣。在層次模型的樹結構中,每條記錄只有一個父節點;在網路模式中,每條記錄可能有多個父節點。例如,“Greater Seattle Area”地區可能是一條記錄,每個居住在該地區的使用者都可以與之相關聯。這允許對多對一和多對多的關係進行建模。 + +網路模型中記錄之間的連結不是外來鍵,而更像程式語言中的指標(同時仍然儲存在磁碟上)。訪問記錄的唯一方法是跟隨從根記錄起沿這些鏈路所形成的路徑。這被稱為**訪問路徑(access path)**。 + +最簡單的情況下,訪問路徑類似遍歷連結串列:從列表頭開始,每次檢視一條記錄,直到找到所需的記錄。但在多對多關係的情況中,數條不同的路徑可以到達相同的記錄,網路模型的程式設計師必須跟蹤這些不同的訪問路徑。 + +CODASYL中的查詢是透過利用遍歷記錄列和跟隨訪問路徑表在資料庫中移動遊標來執行的。如果記錄有多個父結點(即多個來自其他記錄的傳入指標),則應用程式程式碼必須跟蹤所有的各種關係。甚至CODASYL委員會成員也承認,這就像在n維資料空間中進行導航【17】。 + +儘管手動選擇訪問路徑夠能最有效地利用20世紀70年代非常有限的硬體功能(如磁帶驅動器,其搜尋速度非常慢),但這使得查詢和更新資料庫的程式碼變得複雜不靈活。無論是分層還是網路模型,如果你沒有所需資料的路徑,就會陷入困境。你可以改變訪問路徑,但是必須瀏覽大量手寫資料庫查詢程式碼,並重寫來處理新的訪問路徑。更改應用程式的資料模型是很難的。 + +#### 關係模型 + +相比之下,關係模型做的就是將所有的資料放在光天化日之下:一個 **關係(表)** 只是一個 **元組(行)** 的集合,僅此而已。如果你想讀取資料,它沒有迷宮似的巢狀結構,也沒有複雜的訪問路徑。你可以選中符合任意條件的行,讀取表中的任何或所有行。你可以透過指定某些列作為匹配關鍵字來讀取特定行。你可以在任何表中插入一個新的行,而不必擔心與其他表的外來鍵關係[^iv]。 + +[^iv]: 外來鍵約束允許對修改約束,但對於關係模型這並不是必選項。即使有約束,外來鍵連線在查詢時執行,而在CODASYL中,連線在插入時高效完成。 + +在關係資料庫中,查詢最佳化器自動決定查詢的哪些部分以哪個順序執行,以及使用哪些索引。這些選擇實際上是“訪問路徑”,但最大的區別在於它們是由查詢最佳化器自動生成的,而不是由程式設計師生成,所以我們很少需要考慮它們。 + +如果想按新的方式查詢資料,你可以宣告一個新的索引,查詢會自動使用最合適的那些索引。無需更改查詢來利用新的索引。(請參閱“[用於資料的查詢語言](#用於資料的查詢語言)”。)關係模型因此使新增應用程式新功能變得更加容易。 + +關係資料庫的查詢最佳化器是複雜的,已耗費了多年的研究和開發精力【18】。關係模型的一個關鍵洞察是:只需構建一次查詢最佳化器,隨後使用該資料庫的所有應用程式都可以從中受益。如果你沒有查詢最佳化器的話,那麼為特定查詢手動編寫訪問路徑比編寫通用最佳化器更容易——不過從長期看通用解決方案更好。 + +#### 與文件資料庫相比 + +在一個方面,文件資料庫還原為層次模型:在其父記錄中儲存巢狀記錄([圖2-1]()中的一對多關係,如`positions`,`education`和`contact_info`),而不是在單獨的表中。 + +但是,在表示多對一和多對多的關係時,關係資料庫和文件資料庫並沒有根本的不同:在這兩種情況下,相關專案都被一個唯一的識別符號引用,這個識別符號在關係模型中被稱為**外來鍵**,在文件模型中稱為**文件引用**【9】。該識別符號在讀取時透過連線或後續查詢來解析。迄今為止,文件資料庫沒有走CODASYL的老路。 + +### 關係型資料庫與文件資料庫在今日的對比 + +將關係資料庫與文件資料庫進行比較時,可以考慮許多方面的差異,包括它們的容錯屬性(參閱[第5章](ch5.md))和處理併發性(參閱[第7章](ch7.md))。本章將只關注資料模型中的差異。 + +支援文件資料模型的主要論據是架構靈活性,因區域性性而擁有更好的效能,以及對於某些應用程式而言更接近於應用程式使用的資料結構。關係模型透過為連線提供更好的支援以及支援多對一和多對多的關係來反擊。 + +#### 哪個資料模型更方便寫程式碼? + +如果應用程式中的資料具有類似文件的結構(即,一對多關係樹,通常一次性載入整個樹),那麼使用文件模型可能是一個好主意。將類似文件的結構分解成多個表(如[圖2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的關係技術可能導致繁瑣的模式和不必要的複雜的應用程式程式碼。 + +文件模型有一定的侷限性:例如,不能直接引用文件中的巢狀的專案,而是需要說“使用者251的位置列表中的第二項”(很像分層模型中的訪問路徑)。但是,只要檔案巢狀不太深,這通常不是問題。 + +文件資料庫對連線的糟糕支援也許或也許不是一個問題,這取決於應用程式。例如,分析應用程可能永遠不需要多對多的關係,如果它使用文件資料庫來記錄何事發生於何時【19】。 + +但是,如果你的應用程式確實使用多對多關係,那麼文件模型就沒有那麼吸引人了。透過反規範化可以減少對連線的需求,但是應用程式程式碼需要做額外的工作來保持資料的一致性。透過向資料庫發出多個請求,可以在應用程式程式碼中模擬連線,但是這也將複雜性轉移到應用程式中,並且通常比由資料庫內的專用程式碼執行的連線慢。在這種情況下,使用文件模型會導致更復雜的應用程式程式碼和更差的效能【15】。 + +很難說在一般情況下哪個資料模型讓應用程式程式碼更簡單;它取決於資料項之間存在的關係種類。對於高度相聯的資料,選用文件模型是糟糕的,選用關係模型是可接受的,而選用圖形模型(參見“[圖資料模型](#圖資料模型)”)是最自然的。 + +#### 文件模型中的架構靈活性 + +大多數文件資料庫以及關係資料庫中的JSON支援都不會強制文件中的資料採用何種模式。關係資料庫的XML支援通常帶有可選的模式驗證。沒有模式意味著可以將任意的鍵和值新增到文件中,並且當讀取時,客戶端對無法保證文件可能包含的欄位。 + +文件資料庫有時稱為**無模式(schemaless)**,但這具有誤導性,因為讀取資料的程式碼通常假定某種結構——即存在隱式模式,但不由資料庫強制執行【20】。一個更精確的術語是**讀時模式(schema-on-read)**(資料的結構是隱含的,只有在資料被讀取時才被解釋),相應的是**寫時模式(schema-on-write)**(傳統的關係資料庫方法中,模式明確,且資料庫確保所有的資料都符合其模式)【21】。 + +讀時模式類似於程式語言中的動態(執行時)型別檢查,而寫時模式類似於靜態(編譯時)型別檢查。就像靜態和動態型別檢查的相對優點具有很大的爭議性一樣【22】,資料庫中模式的強制性是一個具有爭議的話題,一般來說沒有正確或錯誤的答案。 + +在應用程式想要改變其資料格式的情況下,這些方法之間的區別尤其明顯。例如,假設你把每個使用者的全名儲存在一個欄位中,而現在想分別儲存名字和姓氏【23】。在文件資料庫中,只需開始寫入具有新欄位的新文件,並在應用程式中使用程式碼來處理讀取舊文件的情況。例如: + +```go +if (user && user.name && !user.first_name) { + // Documents written before Dec 8, 2013 don't have first_name + user.first_name = user.name.split(" ")[0]; +} +``` + +另一方面,在“靜態型別”資料庫模式中,通常會執行以下 **遷移(migration)** 操作: + +```sql +ALTER TABLE users ADD COLUMN first_name text; +UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL +UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL +``` + +模式變更的速度很慢,而且要求停運。它的這種壞名譽並不是完全應得的:大多數關係資料庫系統可在幾毫秒內執行`ALTER TABLE`語句。MySQL是一個值得注意的例外,它執行`ALTER TABLE`時會複製整個表,這可能意味著在更改一個大型表時會花費幾分鐘甚至幾個小時的停機時間,儘管存在各種工具來解決這個限制【24,25,26】。 + +大型表上執行`UPDATE`語句在任何資料庫上都可能會很慢,因為每一行都需要重寫。要是不可接受的話,應用程式可以將`first_name`設定為預設值`NULL`,並在讀取時再填充,就像使用文件資料庫一樣。 + +當由於某種原因(例如,資料是異構的)集合中的專案並不都具有相同的結構時,讀時模式更具優勢。例如,如果: + +* 存在許多不同型別的物件,將每種型別的物件放在自己的表中是不現實的。 +* 資料的結構由外部系統決定。你無法控制外部系統且它隨時可能變化。 + +在上述情況下,模式的壞處遠大於它的幫助,無模式文件可能是一個更加自然的資料模型。但是,要是所有記錄都具有相同的結構,那麼模式是記錄並強制這種結構的有效機制。第四章將更詳細地討論模式和模式演化。 + +#### 查詢的資料區域性性 + +文件通常以單個連續字串形式進行儲存,編碼為JSON,XML或其二進位制變體(如MongoDB的BSON)。如果應用程式經常需要訪問整個文件(例如,將其渲染至網頁),那麼儲存區域性性會帶來效能優勢。如果將資料分割到多個表中(如[圖2-1](img/fig2-1.png)所示),則需要進行多次索引查詢才能將其全部檢索出來,這可能需要更多的磁碟查詢並花費更多的時間。 + +區域性性僅僅適用於同時需要文件絕大部分內容的情況。資料庫通常需要載入整個文件,即使只訪問其中的一小部分,這對於大型文件來說是很浪費的。更新文件時,通常需要整個重寫。只有不改變文件大小的修改才可以容易地原地執行。因此,通常建議保持相對小的文件,並避免增加文件大小的寫入【9】。這些效能限制大大減少了文件資料庫的實用場景。 + +值得指出的是,為了區域性性而分組集合相關資料的想法並不侷限於文件模型。例如,Google的Spanner資料庫在關係資料模型中提供了同樣的區域性性屬性,允許模式宣告一個表的行應該交錯(巢狀)在父表內【27】。Oracle類似地允許使用一個稱為 **多表索引叢集表(multi-table index cluster tables)** 的類似特性【28】。Bigtable資料模型(用於Cassandra和HBase)中的 **列族(column-family)** 概念與管理區域性性的目的類似【29】。 + +在[第3章](ch3.md)將還會看到更多關於區域性性的內容。 + +#### 文件和關係資料庫的融合 + +自2000年代中期以來,大多數關係資料庫系統(MySQL除外)都已支援XML。這包括對XML文件進行本地修改的功能,以及在XML文件中進行索引和查詢的功能。這允許應用程式使用那種與文件資料庫應當使用的非常類似的資料模型。 + +從9.3版本開始的PostgreSQL 【8】,從5.7版本開始的MySQL以及從版本10.5開始的IBM DB2 [30]也對JSON文件提供了類似的支援級別。鑑於用在Web APIs的JSON流行趨勢,其他關係資料庫很可能會跟隨他們的腳步並新增JSON支援。 + +在文件資料庫中,RethinkDB在其查詢語言中支援類似關係的連線,一些MongoDB驅動程式可以自動解析資料庫引用(有效地執行客戶端連線,儘管這可能比在資料庫中執行的連線慢,需要額外的網路往返,並且最佳化更少)。 + +隨著時間的推移,關係資料庫和文件資料庫似乎變得越來越相似,這是一件好事:資料模型相互補充[^v],如果一個數據庫能夠處理類似文件的資料,並能夠對其執行關係查詢,那麼應用程式就可以使用最符合其需求的功能組合。 + +關係模型和文件模型的混合是未來資料庫一條很好的路線。 + +[^v]: Codd對關係模型【1】的原始描述實際上允許在關係模式中與JSON文件非常相似。他稱之為**非簡單域(nonsimple domains)**。這個想法是,一行中的值不一定是一個像數字或字串一樣的原始資料型別,也可以是一個巢狀的關係(表),因此可以把一個任意巢狀的樹結構作為一個值,這很像30年後新增到SQL中的JSON或XML支援。 + + + +## 資料查詢語言 + +當引入關係模型時,關係模型包含了一種查詢資料的新方法:SQL是一種 **宣告式** 查詢語言,而IMS和CODASYL使用 **命令式** 程式碼來查詢資料庫。那是什麼意思? + +許多常用的程式語言是命令式的。例如,給定一個動物物種的列表,返回列表中的鯊魚可以這樣寫: + +```js +function getSharks() { + var sharks = []; + for (var i = 0; i < animals.length; i++) { + if (animals[i].family === "Sharks") { + sharks.push(animals[i]); + } + } + return sharks; +} +``` + +在關係代數中: +$$ +sharks = σ_{family = "sharks"}(animals) +$$ +σ(希臘字母西格瑪)是選擇運算子,只返回符合條件的動物,`family="shark"`。 + +定義SQL時,它緊密地遵循關係代數的結構: + +```sql +SELECT * FROM animals WHERE family ='Sharks'; +``` + +命令式語言告訴計算機以特定順序執行某些操作。可以想象一下,逐行地遍歷程式碼,評估條件,更新變數,並決定是否再迴圈一遍。 + +在宣告式查詢語言(如SQL或關係代數)中,你只需指定所需資料的模式 - 結果必須符合哪些條件,以及如何將資料轉換(例如,排序,分組和集合) - 但不是如何實現這一目標。資料庫系統的查詢最佳化器決定使用哪些索引和哪些連線方法,以及以何種順序執行查詢的各個部分。 + +宣告式查詢語言是迷人的,因為它通常比命令式API更加簡潔和容易。但更重要的是,它還隱藏了資料庫引擎的實現細節,這使得資料庫系統可以在無需對查詢做任何更改的情況下進行效能提升。 + +例如,在本節開頭所示的命令程式碼中,動物列表以特定順序出現。如果資料庫想要在後臺回收未使用的磁碟空間,則可能需要移動記錄,這會改變動物出現的順序。資料庫能否安全地執行,而不會中斷查詢? + +SQL示例不確保任何特定的順序,因此不在意順序是否改變。但是如果查詢用命令式的程式碼來寫的話,那麼資料庫就永遠不可能確定程式碼是否依賴於排序。SQL相當有限的功能性為資料庫提供了更多自動最佳化的空間。 + +最後,宣告式語言往往適合並行執行。現在,CPU的速度透過核心的增加變得更快,而不是以比以前更高的時鐘速度執行【31】。命令程式碼很難在多個核心和多個機器之間並行化,因為它指定了指令必須以特定順序執行。宣告式語言更具有並行執行的潛力,因為它們僅指定結果的模式,而不指定用於確定結果的演算法。在適當情況下,資料庫可以自由使用查詢語言的並行實現【32】。 + +### Web上的宣告式查詢 + +宣告式查詢語言的優勢不僅限於資料庫。為了說明這一點,讓我們在一個完全不同的環境中比較宣告式和命令式方法:一個Web瀏覽器。 + +假設你有一個關於海洋動物的網站。使用者當前正在檢視鯊魚頁面,因此你將當前所選的導航專案“鯊魚”標記為當前選中專案。 + +```html +
    +
  • +

    Sharks

    +
      +
    • Great White Shark
    • +
    • Tiger Shark
    • +
    • Hammerhead Shark
    • +
    +
  • +
  • Whales

    +
      +
    • Blue Whale
    • +
    • Humpback Whale
    • +
    • Fin Whale
    • +
    +
  • +
+``` + +現在想讓當前所選頁面的標題具有一個藍色的背景,以便在視覺上突出顯示。使用CSS實現起來非常簡單: + +```css +li.selected > p { + background-color: blue; +} +``` + +這裡的CSS選擇器`li.selected> p`聲明瞭我們想要應用藍色樣式的元素的模式:即其直接父元素是具有`selected`CSS類的`
  • `元素的所有`

    `元素。示例中的元素`

    Sharks

    `匹配此模式,但`

    Whales

    `不匹配,因為其`
  • `父元素缺少`class =“selected”`。 + +如果使用XSL而不是CSS,你可以做類似的事情: + +```xml + + + + + +``` + +這裡的XPath表示式`li[@class='selected']/p`相當於上例中的CSS選擇器`li.selected> p`。CSS和XSL的共同之處在於,它們都是用於指定文件樣式的宣告式語言。 + +想象一下,必須使用命令式方法的情況會是如何。在Javascript中,使用 **文件物件模型(DOM)** API,其結果可能如下所示: + +```js +var liElements = document.getElementsByTagName("li"); +for (var i = 0; i < liElements.length; i++) { + if (liElements[i].className === "selected") { + var children = liElements[i].childNodes; + for (var j = 0; j < children.length; j++) { + var child = children[j]; + if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") { + child.setAttribute("style", "background-color: blue"); + } + } + } +} +``` + +這段JavaScript程式碼命令式地將元素設定為藍色背景,但是程式碼看起來很糟糕。不僅比CSS和XSL等價物更長,更難理解,而且還有一些嚴重的問題: + +* 如果選定的類被移除(例如,因為使用者點選了不同的頁面),即使程式碼重新執行,藍色背景也不會被移除 - 因此該專案將保持突出顯示,直到整個頁面被重新載入。使用CSS,瀏覽器會自動檢測`li.selected> p`規則何時不再適用,並在選定的類被移除後立即移除藍色背景。 + +* 如果你想要利用新的API(例如`document.getElementsBy ClassName(“selected”`)甚至`document.evaluate()`)來提高效能,則必須重寫程式碼。另一方面,瀏覽器供應商可以在不破壞相容性的情況下提高CSS和XPath的效能。 + +在Web瀏覽器中,使用宣告式CSS樣式比使用JavaScript命令式地操作樣式要好得多。類似地,在資料庫中,使用像SQL這樣的宣告式查詢語言比使用命令式查詢API要好得多[^vi]。 + +[^vi]: vi IMS和CODASYL都使用命令式API。應用程式通常使用COBOL程式碼遍歷資料庫中的記錄,一次一條記錄【2,16】。 + +### MapReduce查詢 + +MapReduce是一個由Google推廣的程式設計模型,用於在多臺機器上批次處理大規模的資料【33】。一些NoSQL資料儲存(包括MongoDB和CouchDB)支援有限形式的MapReduce,作為在多個文件中執行只讀查詢的機制。 + +MapReduce將[第10章](ch10.md)中有更詳細的描述。現在我們將簡要討論一下MongoDB使用的模型。 + +MapReduce既不是一個宣告式的查詢語言,也不是一個完全命令式的查詢API,而是處於兩者之間:查詢的邏輯用程式碼片斷來表示,這些程式碼片段會被處理框架重複性呼叫。它基於`map`(也稱為`collect`)和`reduce`(也稱為`fold`或`inject`)函式,兩個函式存在於許多函數語言程式設計語言中。 + +最好舉例來解釋MapReduce模型。假設你是一名海洋生物學家,每當你看到海洋中的動物時,你都會在資料庫中新增一條觀察記錄。現在你想生成一個報告,說明你每月看到多少鯊魚。 + +在PostgreSQL中,你可以像這樣表述這個查詢: + +```sql +SELECT + date_trunc('month', observation_timestamp) AS observation_month, + sum(num_animals) AS total_animals +FROM observations +WHERE family = 'Sharks' +GROUP BY observation_month; +``` + +`date_trunc('month',timestamp)`函式用於確定包含`timestamp`的日曆月份,並返回代表該月份開始的另一個時間戳。換句話說,它將時間戳舍入成最近的月份。 + +這個查詢首先過濾觀察記錄,以只顯示鯊魚家族的物種,然後根據它們發生的日曆月份對觀察記錄果進行分組,最後將在該月的所有觀察記錄中看到的動物數目加起來。 + +同樣的查詢用MongoDB的MapReduce功能可以按如下來表述: + +```js +db.observations.mapReduce(function map() { + var year = this.observationTimestamp.getFullYear(); + var month = this.observationTimestamp.getMonth() + 1; + emit(year + "-" + month, this.numAnimals); + }, + function reduce(key, values) { + return Array.sum(values); + }, + { + query: { + family: "Sharks" + }, + out: "monthlySharkReport" + }); +``` + +* 可以宣告式地指定只考慮鯊魚種類的過濾器(這是一個針對MapReduce的特定於MongoDB的擴充套件)。 +* 每個匹配查詢的文件都會呼叫一次JavaScript函式`map`,將`this`設定為文件物件。 +* `map`函式發出一個鍵(包括年份和月份的字串,如`"2013-12"`或`"2014-1"`)和一個值(該觀察記錄中的動物數量)。 +* `map`發出的鍵值對按鍵來分組。對於具有相同鍵(即,相同的月份和年份)的所有鍵值對,呼叫一次`reduce`函式。 +* `reduce`函式將特定月份內所有觀測記錄中的動物數量相加。 +* 將最終的輸出寫入到`monthlySharkReport`集合中。 + +例如,假設`observations`集合包含這兩個文件: + +```json +{ + observationTimestamp: Date.parse( "Mon, 25 Dec 1995 12:34:56 GMT"), + family: "Sharks", + species: "Carcharodon carcharias", + numAnimals: 3 +{ +} + observationTimestamp: Date.parse("Tue, 12 Dec 1995 16:17:18 GMT"), + family: "Sharks", + species: "Carcharias taurus", + numAnimals: 4 +} +``` + +對每個文件都會呼叫一次`map`函式,結果將是`emit("1995-12",3)`和`emit("1995-12",4)`。隨後,以`reduce("1995-12",[3,4])`呼叫`reduce`函式,將返回`7`。 + +map和reduce函式在功能上有所限制:它們必須是**純**函式,這意味著它們只使用傳遞給它們的資料作為輸入,它們不能執行額外的資料庫查詢,也不能有任何副作用。這些限制允許資料庫以任何順序執行任何功能,並在失敗時重新執行它們。然而,map和reduce函式仍然是強大的:它們可以解析字串,呼叫庫函式,執行計算等等。 + +MapReduce是一個相當底層的程式設計模型,用於計算機叢集上的分散式執行。像SQL這樣的更高階的查詢語言可以用一系列的MapReduce操作來實現(見[第10章](ch10.md)),但是也有很多不使用MapReduce的分散式SQL實現。請注意,SQL中沒有任何內容限制它在單個機器上執行,而MapReduce在分散式查詢執行上沒有壟斷權。 + +能夠在查詢中使用JavaScript程式碼是高階查詢的一個重要特性,但這不限於MapReduce,一些SQL資料庫也可以用JavaScript函式進行擴充套件【34】。 + +MapReduce的一個可用性問題是,必須編寫兩個密切合作的JavaScript函式,這通常比編寫單個查詢更困難。此外,宣告式查詢語言為查詢最佳化器提供了更多機會來提高查詢的效能。基於這些原因,MongoDB 2.2添加了一種叫做**聚合管道**的宣告式查詢語言的支援【9】。用這種語言表述鯊魚計數查詢如下所示: + +```js +db.observations.aggregate([ + { $match: { family: "Sharks" } }, + { $group: { + _id: { + year: { $year: "$observationTimestamp" }, + month: { $month: "$observationTimestamp" } + }, + totalAnimals: { $sum: "$numAnimals" } }} +]); +``` + +聚合管道語言與SQL的子集具有類似表現力,但是它使用基於JSON的語法而不是SQL的英語句子式語法; 這種差異也許是口味問題。這個故事的寓意是NoSQL系統可能會發現自己意外地重新發明了SQL,儘管帶著偽裝。 + + + +## 圖資料模型 + +如我們之前所見,多對多關係是不同資料模型之間具有區別性的重要特徵。如果你的應用程式大多數的關係是一對多關係(樹狀結構化資料),或者大多數記錄之間不存在關係,那麼使用文件模型是合適的。 + +但是,要是多對多關係在你的資料中很常見呢?關係模型可以處理多對多關係的簡單情況,但是隨著資料之間的連線變得更加複雜,將資料建模為圖形顯得更加自然。 + +一個圖由兩種物件組成:**頂點(vertices)**(也稱為**節點(nodes)** 或**實體(entities)**),和**邊(edges)**( 也稱為**關係(relationships)**或**弧 (arcs)** )。多種資料可以被建模為一個圖形。典型的例子包括: + +***社交圖譜*** + +頂點是人,邊指示哪些人彼此認識。 + +***網路圖譜*** + +頂點是網頁,邊緣表示指向其他頁面的HTML連結。 + +***公路或鐵路網路*** + +頂點是交叉路口,邊線代表它們之間的道路或鐵路線。 + +可以將那些眾所周知的演算法運用到這些圖上:例如,汽車導航系統搜尋道路網路中兩點之間的最短路徑,PageRank可以用在網路圖上來確定網頁的流行程度,從而確定該網頁在搜尋結果中的排名。 + +在剛剛給出的例子中,圖中的所有頂點代表了相同型別的事物(人,網頁或交叉路口)。不過,圖並不侷限於這樣的同類資料:同樣強大地是,圖提供了一種一致的方式,用來在單個數據儲存中儲存完全不同型別的物件。例如,Facebook維護一個包含許多不同型別的頂點和邊的單個圖:頂點表示人,地點,事件,簽到和使用者的評論;邊緣表示哪些人是彼此的朋友,哪個簽到發生在何處,誰評論了哪條訊息,誰參與了哪個事件,等等【35】。 + +在本節中,我們將使用[圖2-5](img/fig2-5.png)所示的示例。它可以從社交網路或系譜資料庫中獲得:它顯示了兩個人,來自愛達荷州的Lucy和來自法國Beaune的Alain。他們已婚,住在倫敦。 + +![](img/fig2-5.png) + +**圖2-5 圖資料結構示例(框代表頂點,箭頭代表邊)** + +有幾種不同但相關的方法用來構建和查詢圖表中的資料。在本節中,我們將討論屬性圖模型(由Neo4j,Titan和InfiniteGraph實現)和三元組儲存(triple-store)模型(由Datomic,AllegroGraph等實現)。我們將檢視圖的三種宣告式查詢語言:Cypher,SPARQL和Datalog。除此之外,還有像Gremlin 【36】這樣的圖形查詢語言和像Pregel這樣的圖形處理框架(見[第10章](ch10.md))。 + +### 屬性圖 + +在屬性圖模型中,每個**頂點(vertex)**包括: + +* 唯一的識別符號 +* 一組 **出邊(outgoing edges)** +* 一組 **入邊(ingoing edges)** +* 一組屬性(鍵值對) + +每條 **邊(edge)** 包括: + +* 唯一識別符號 +* **邊的起點/尾部頂點(tail vertex)** +* **邊的終點/頭部頂點(head vertex)** +* 描述兩個頂點之間關係型別的標籤 +* 一組屬性(鍵值對) + +可以將圖儲存看作由兩個關係表組成:一個儲存頂點,另一個儲存邊,如[例2-2]()所示(該模式使用PostgreSQL json資料型別來儲存每個頂點或每條邊的屬性)。頭部和尾部頂點用來儲存每條邊;如果你想要一組頂點的輸入或輸出邊,你可以分別透過`head_vertex`或`tail_vertex`來查詢`edges`表。 + +**例2-2 使用關係模式來表示屬性圖** + +```sql +CREATE TABLE vertices ( + vertex_id INTEGER PRIMARY KEY, + properties JSON +); + +CREATE TABLE edges ( + edge_id INTEGER PRIMARY KEY, + tail_vertex INTEGER REFERENCES vertices (vertex_id), + head_vertex INTEGER REFERENCES vertices (vertex_id), + label TEXT, + properties JSON +); + +CREATE INDEX edges_tails ON edges (tail_vertex); +CREATE INDEX edges_heads ON edges (head_vertex); +``` + +關於這個模型的一些重要方面是: + +1. 任何頂點都可以有一條邊連線到任何其他頂點。沒有模式限制哪種事物可不可以關聯。 +2. 給定任何頂點,可以高效地找到它的入邊和出邊,從而遍歷圖,即沿著一系列頂點的路徑前後移動。(這就是為什麼[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) +3. 透過對不同型別的關係使用不同的標籤,可以在一個圖中儲存幾種不同的資訊,同時仍然保持一個清晰的資料模型。 + +這些特性為資料建模提供了很大的靈活性,如[圖2-5](img/fig2-5.png)所示。圖中顯示了一些傳統關係模式難以表達的事情,例如不同國家的不同地區結構(法國有省和州,美國有不同的州和州),國中國的怪事(先忽略主權國家和國家錯綜複雜的爛攤子),不同的資料粒度(Lucy現在的住所被指定為一個城市,而她的出生地點只是在一個州的級別)。 + +你可以想象延伸圖還能包括許多關於Lucy和Alain,或其他人的其他更多的事實。例如,你可以用它來表示食物過敏(為每個過敏源增加一個頂點,並增加人與過敏源之間的一條邊來指示一種過敏情況),並連結到過敏源,每個過敏源具有一組頂點用來顯示哪些食物含有哪些物質。然後,你可以寫一個查詢,找出每個人吃什麼是安全的。圖表在可演化性是富有優勢的:當嚮應用程式新增功能時,可以輕鬆擴充套件圖以適應應用程式資料結構的變化。 + +### Cypher查詢語言 + +Cypher是屬性圖的宣告式查詢語言,為Neo4j圖形資料庫而發明【37】。(它是以電影“駭客帝國”中的一個角色來命名的,而與密碼術中的密碼無關【38】。) + +[例2-3]()顯示了將[圖2-5](img/fig2-5.png)的左邊部分插入圖形資料庫的Cypher查詢。可以類似地新增圖的其餘部分,為了便於閱讀而省略。每個頂點都有一個像`USA`或`Idaho`這樣的符號名稱,查詢的其他部分可以使用這些名稱在頂點之間建立邊,使用箭頭符號:`(Idaho) - [:WITHIN] ->(USA)`建立一條標記為`WITHIN`的邊,`Idaho`為尾節點,`USA`為頭節點。 + +**例2-3 將圖2-5中的資料子集表示為Cypher查詢** + +```cypher +CREATE + (NAmerica:Location {name:'North America', type:'continent'}), + (USA:Location {name:'United States', type:'country' }), + (Idaho:Location {name:'Idaho', type:'state' }), + (Lucy:Person {name:'Lucy' }), + (Idaho) -[:WITHIN]-> (USA) -[:WITHIN]-> (NAmerica), + (Lucy) -[:BORN_IN]-> (Idaho) +``` + +當[圖2-5](img/fig2-5.png)的所有頂點和邊被新增到資料庫後,讓我們提些有趣的問題:例如,找到所有從美國移民到歐洲的人的名字。更確切地說,這裡我們想要找到符合下麵條件的所有頂點,並且返回這些頂點的`name`屬性:該頂點擁有一條連到美國任一位置的`BORN_IN`邊,和一條連到歐洲的任一位置的`LIVING_IN`邊。 + +[例2-4]()展示瞭如何在Cypher中表達這個查詢。在MATCH子句中使用相同的箭頭符號來查詢圖中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`邊的任意兩個頂點。該邊的尾節點被綁定了變數`person`,頭節點則未被繫結。 + +**例2-4 查詢所有從美國移民到歐洲的人的Cypher查詢:** + +```cypher +MATCH + (person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}), + (person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'}) +RETURN person.name +``` + +查詢按如下來解讀: + +> 找到滿足以下兩個條件的所有頂點(稱之為person頂點): +> 1. `person`頂點擁有一條到某個頂點的`BORN_IN`出邊。從那個頂點開始,沿著一系列`WITHIN`出邊最終到達一個型別為`Location`,`name`屬性為`United States`的頂點。 +> +> 2. `person`頂點還擁有一條`LIVES_IN`出邊。沿著這條邊,可以透過一系列`WITHIN`出邊最終到達一個型別為`Location`,`name`屬性為`Europe`的頂點。 +> +> 對於這樣的`Person`頂點,返回其`name`屬性。 + +執行這條查詢可能會有幾種可行的查詢路徑。這裡給出的描述建議首先掃描資料庫中的所有人,檢查每個人的出生地和居住地,然後只返回符合條件的那些人。 + +等價地,也可以從兩個`Location`頂點開始反向地查詢。假如`name`屬性上有索引,則可以高效地找到代表美國和歐洲的兩個頂點。然後,沿著所有`WITHIN`入邊,可以繼續查找出所有在美國和歐洲的位置(州,地區,城市等)。最後,查找出那些可以由`BORN_IN`或`LIVES_IN`入邊到那些位置頂點的人。 + +通常對於宣告式查詢語言來說,在編寫查詢語句時,不需要指定執行細節:查詢最佳化程式會自動選擇預測效率最高的策略,因此你可以繼續編寫應用程式的其他部分。 + +### SQL中的圖查詢 + +[例2-2]()建議在關係資料庫中表示圖資料。但是,如果把圖資料放入關係結構中,我們是否也可以使用SQL查詢它? + +答案是肯定的,但有些困難。在關係資料庫中,你通常會事先知道在查詢中需要哪些連線。在圖查詢中,你可能需要在找到待查詢的頂點之前,遍歷可變數量的邊。也就是說,連線的數量事先並不確定。 + +在我們的例子中,這發生在Cypher查詢中的`() -[:WITHIN*0..]-> ()`規則中。一個人的`LIVES_IN`邊可以指向任何型別的位置:街道,城市,地區,地區,國家等。城市可以在一個地區,在一個州內的一個地區,在一個國家內的一個州等等。`LIVES_IN`邊可以直接指向正在查詢的位置,或者一個在位置層次結構中隔了數層的位置。 + +在Cypher中,用`WITHIN * 0`非常簡潔地表述了上述事實:“沿著`WITHIN`邊,零次或多次”。它很像正則表示式中的`*`運算子。 + +自SQL:1999,查詢可變長度遍歷路徑的思想可以使用稱為**遞迴公用表表達式**(`WITH RECURSIVE`語法)的東西來表示。[例2-5]()顯示了同樣的查詢 - 查詢從美國移民到歐洲的人的姓名 - 在SQL使用這種技術(PostgreSQL,IBM DB2,Oracle和SQL Server均支援)來表述。但是,與Cypher相比,其語法非常笨拙。 + +**例2-5 與示例2-4同樣的查詢,在SQL中使用遞迴公用表表達式表示** + +```sql +WITH RECURSIVE + -- in_usa 包含所有的美國境內的位置ID + in_usa(vertex_id) AS ( + SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'United States' + UNION + SELECT edges.tail_vertex FROM edges + JOIN in_usa ON edges.head_vertex = in_usa.vertex_id + WHERE edges.label = 'within' + ), + -- in_europe 包含所有的歐洲境內的位置ID + in_europe(vertex_id) AS ( + SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'Europe' + UNION + SELECT edges.tail_vertex FROM edges + JOIN in_europe ON edges.head_vertex = in_europe.vertex_id + WHERE edges.label = 'within' ), + + -- born_in_usa 包含了所有型別為Person,且出生在美國的頂點 + born_in_usa(vertex_id) AS ( + SELECT edges.tail_vertex FROM edges + JOIN in_usa ON edges.head_vertex = in_usa.vertex_id + WHERE edges.label = 'born_in' ), + + -- lives_in_europe 包含了所有型別為Person,且居住在歐洲的頂點。 + lives_in_europe(vertex_id) AS ( + SELECT edges.tail_vertex FROM edges + JOIN in_europe ON edges.head_vertex = in_europe.vertex_id + WHERE edges.label = 'lives_in') + + SELECT vertices.properties ->> 'name' + FROM vertices + JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id + JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id; +``` + +* 首先,查詢`name`屬性為`United States`的頂點,將其作為`in_usa`頂點的集合的第一個元素。 +* 從`in_usa`集合的頂點出發,沿著所有的`with_in`入邊,將其尾頂點加入同一集合,不斷遞迴直到所有`with_in`入邊都被訪問完畢。 +* 同理,從`name`屬性為`Europe`的頂點出發,建立`in_europe`頂點的集合。 +* 對於`in_usa`集合中的每個頂點,根據`born_in`入邊來查找出生在美國某個地方的人。 +* 同樣,對於`in_europe`集合中的每個頂點,根據`lives_in`入邊來查詢居住在歐洲的人。 +* 最後,把在美國出生的人的集合與在歐洲居住的人的集合相交。 + +同一個查詢,用某一個查詢語言可以寫成4行,而用另一個查詢語言需要29行,這恰恰說明了不同的資料模型是為不同的應用場景而設計的。選擇適合應用程式的資料模型非常重要。 + +### 三元組儲存和SPARQL + +三元組儲存模式大體上與屬性圖模型相同,用不同的詞來描述相同的想法。不過仍然值得討論,因為三元組儲存有很多現成的工具和語言,這些工具和語言對於構建應用程式的工具箱可能是寶貴的補充。 + +在三元組儲存中,所有資訊都以非常簡單的三部分表示形式儲存(**主語**,**謂語**,**賓語**)。例如,三元組 **(吉姆, 喜歡 ,香蕉)** 中,**吉姆** 是主語,**喜歡** 是謂語(動詞),**香蕉** 是物件。 + +三元組的主語相當於圖中的一個頂點。而賓語是下面兩者之一: + +1. 原始資料型別中的值,例如字串或數字。在這種情況下,三元組的謂語和賓語相當於主語頂點上的屬性的鍵和值。例如,`(lucy, age, 33)`就像屬性`{“age”:33}`的頂點lucy。 +2. 圖中的另一個頂點。在這種情況下,謂語是圖中的一條邊,主語是其尾部頂點,而賓語是其頭部頂點。例如,在`(lucy, marriedTo, alain)`中主語和賓語`lucy`和`alain`都是頂點,並且謂語`marriedTo`是連線他們的邊的標籤。 + +[例2-6]()顯示了與[例2-3]()相同的資料,以稱為Turtle的格式(Notation3(N3)【39】)的一個子集形式寫成三元組。 + +**例2-6 圖2-5中的資料子集,表示為Turtle三元組** + +```reStructuredText +@prefix : . +_:lucy a :Person. +_:lucy :name "Lucy". +_:lucy :bornIn _:idaho. +_:idaho a :Location. +_:idaho :name "Idaho". +_:idaho :type "state". +_:idaho :within _:usa. +_:usa a :Location +_:usa :name "United States" +_:usa :type "country". +_:usa :within _:namerica. +_:namerica a :Location +_:namerica :name "North America" +_:namerica :type :"continent" +``` + +在這個例子中,圖的頂點被寫為:`_:someName`。這個名字並不意味著這個檔案以外的任何東西。它的存在只是幫助我們明確哪些三元組引用了同一頂點。當謂語表示邊時,該賓語是一個頂點,如`_:idaho :within _:usa.`。當謂語是一個屬性時,該賓語是一個字串,如`_:usa :name "United States"` + +一遍又一遍地重複相同的主語看起來相當重複,但幸運的是,可以使用分號來說明關於同一主語的多個事情。這使得Turtle格式相當不錯,可讀性強:參見[例2-7]()。 + +**例2-7 一種相對例2-6寫入資料的更為簡潔的方法。** + +``` +@prefix : . +_:lucy a :Person; :name "Lucy"; :bornIn _:idaho. +_:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa +_:usa a :Loaction; :name "United States"; :type "country"; :within _:namerica. +_:namerica a :Location; :name "North America"; :type "continent". +``` + +#### 語義網路 + +如果你閱讀更多關於三元組儲存的資訊,你可能會被捲入關於語義網路的文章漩渦中。三元組儲存資料模型完全獨立於語義網路,例如,Datomic【40】是三元組儲存[^vii],並沒有聲稱與它有任何關係。但是,由於在很多人眼中這兩者緊密相連,我們應該簡要地討論一下。 + +[^vii]: 從技術上講,Datomic使用的是五元組而不是三元組,兩個額外的欄位是用於版本控制的元資料 + +從本質上講語義網是一個簡單且合理的想法:網站已經將資訊釋出為文字和圖片供人類閱讀,為什麼不將資訊作為機器可讀的資料也釋出給計算機呢?**資源描述框架**(RDF)【41】的目的是作為不同網站以一致的格式釋出資料的一種機制,允許來自不同網站的資料自動合併成**一個數據網路** - 一種網際網路範圍內的“關於一切的資料庫“。 + +不幸的是,這個語義網在二十一世紀初被過度使用,但到目前為止沒有任何跡象表明已在實踐中實現,這使得許多人呲之以鼻。它還遭受了過多的令人眼花繚亂的縮略詞,過於複雜的標準提議和狂妄自大的苦果。 + +然而,如果仔細觀察這些失敗,語義Web專案還是擁有很多優秀的工作成果。即使你沒有興趣在語義網上釋出RDF資料,三元組也可以成為應用程式的良好內部資料模型。 + +#### RDF資料模型 + +[例2-7]()中使用的Turtle語言是一種用於RDF資料的人可讀格式。有時候,RDF也可以以XML格式編寫,不過完成同樣的事情會相對囉嗦,參見[例2-8]()。Turtle/N3是更可取的,因為它更容易閱讀,像Apache Jena 【42】這樣的工具可以根據需要在不同的RDF格式之間進行自動轉換。 + +**例2-8 用RDF/XML語法表示例2-7的資料** + +```xml + + + Idaho + state + + + United States + country + + + North America + continent + + + + + + + Lucy + + + +``` + +RDF有一些奇怪之處,因為它是為了在網際網路上交換資料而設計的。三元組的主語,謂語和賓語通常是URI。例如,謂語可能是一個URI,如 ``或``,而不僅僅是`WITHIN`或`LIVES_IN`。這個設計背後的原因為了讓你能夠把你的資料和其他人的資料結合起來,如果他們賦予單詞`within`或者`lives_in`不同的含義,兩者也不會衝突,因為它們的謂語實際上是``和``。 + +從RDF的角度來看,URL `` 不一定需要能解析成什麼東西,它只是一個名稱空間。為避免與`http://URL`混淆,本節中的示例使用不可解析的URI,如`urn:example:within`。幸運的是,你只需在檔案頂部指定一個字首,然後就不用再管了。 + +### SPARQL查詢語言 + +**SPARQL**是一種用於三元組儲存的面向RDF資料模型的查詢語言,【43】。(它是SPARQL協議和RDF查詢語言的縮寫,發音為“sparkle”。)SPARQL早於Cypher,並且由於Cypher的模式匹配借鑑於SPARQL,這使得它們看起來非常相似【37】。 + +與之前相同的查詢 - 查詢從美國轉移到歐洲的人 - 使用SPARQL比使用Cypher甚至更為簡潔(參見[例2-9]())。 + +**例2-9 與示例2-4相同的查詢,用SPARQL表示** + +```sparql +PREFIX : +SELECT ?personName WHERE { + ?person :name ?personName. + ?person :bornIn / :within* / :name "United States". + ?person :livesIn / :within* / :name "Europe". +} +``` + +結構非常相似。以下兩個表示式是等價的(SPARQL中的變數以問號開頭): + +``` +(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher +?person :bornIn / :within* ?location. # SPARQL +``` + +因為RDF不區分屬性和邊,而只是將它們作為謂語,所以可以使用相同的語法來匹配屬性。在下面的表示式中,變數`usa`被繫結到任意具有值為字串`"United States"`的`name`屬性的頂點: + +``` +(usa {name:'United States'}) # Cypher +?usa :name "United States". # SPARQL +``` + +SPARQL是一種很好的查詢語言——哪怕語義網從未實現,它仍然可以成為一種應用程式內部使用的強大工具。 + +> #### 圖形資料庫與網路模型相比較 +> +> 在“[文件資料庫是否在重蹈覆轍?](#文件資料庫是否在重蹈覆轍?)”中,我們討論了CODASYL和關係模型如何競相解決IMS中的多對多關係問題。乍一看,CODASYL的網路模型看起來與圖模型相似。CODASYL是否是圖形資料庫的第二個變種? +> +> 不,他們在幾個重要方面有所不同: +> +> * 在CODASYL中,資料庫有一個模式,用於指定哪種記錄型別可以巢狀在其他記錄型別中。在圖形資料庫中,不存在這樣的限制:任何頂點都可以具有到其他任何頂點的邊。這為應用程式適應不斷變化的需求提供了更大的靈活性。 +> * 在CODASYL中,達到特定記錄的唯一方法是遍歷其中的一個訪問路徑。在圖形資料庫中,可以透過其唯一ID直接引用任何頂點,也可以使用索引來查詢具有特定值的頂點。 +> * 在CODASYL,記錄的後續是一個有序集合,所以資料庫的人不得不維持排序(這會影響儲存佈局),並且插入新記錄到資料庫的應用程式不得不擔心的新記錄在這些集合中的位置。在圖形資料庫中,頂點和邊不是有序的(只能在查詢時對結果進行排序)。 +> * 在CODASYL中,所有查詢都是命令式的,難以編寫,並且很容易因架構中的變化而受到破壞。在圖形資料庫中,如果需要,可以在命令式程式碼中編寫遍歷,但大多數圖形資料庫也支援高階宣告式查詢語言,如Cypher或SPARQL。 +> +> + +### 基礎:Datalog + +**Datalog**是比SPARQL或Cypher更古老的語言,在20世紀80年代被學者廣泛研究【44,45,46】。它在軟體工程師中不太知名,但是它是重要的,因為它為以後的查詢語言提供了基礎。 + +在實踐中,Datalog被用於少數的資料系統中:例如,它是Datomic 【40】的查詢語言,Cascalog 【47】是一種用於查詢Hadoop大資料集的Datalog實現[^viii]。 + +[^viii]: Datomic和Cascalog使用Datalog的Clojure S表示式語法。在下面的例子中使用了一個更容易閱讀的Prolog語法,但兩者沒有任何功能差異。 + +Datalog的資料模型類似於三元組模式,但進行了一點泛化。把三元組寫成**謂語**(**主語,賓語**),而不是寫三元語(**主語,謂語,賓語**)。[例2-10]()顯示瞭如何用Datalog寫入我們的例子中的資料。 + +**例2-10 用Datalog來表示圖2-5中的資料子集** + +```prolog +name(namerica, 'North America'). +type(namerica, continent). + +name(usa, 'United States'). +type(usa, country). +within(usa, namerica). + +name(idaho, 'Idaho'). +type(idaho, state). +within(idaho, usa). + +name(lucy, 'Lucy'). +born_in(lucy, idaho). +``` + +既然已經定義了資料,我們可以像之前一樣編寫相同的查詢,如[例2-11]()所示。它看起來有點不同於Cypher或SPARQL的等價物,但是請不要放棄它。Datalog是Prolog的一個子集,如果你學過電腦科學,你可能已經見過。 + +**例2-11 與示例2-4相同的查詢,用Datalog表示** + +``` +within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */ + +within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */ + within_recursive(Via, Name). + +migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */ + born_in(Person, BornLoc), + within_recursive(BornLoc, BornIn), + lives_in(Person, LivingLoc), + within_recursive(LivingLoc, LivingIn). + +?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */ + +``` + +Cypher和SPARQL使用SELECT立即跳轉,但是Datalog一次只進行一小步。我們定義**規則**,以將新謂語告訴資料庫:在這裡,我們定義了兩個新的謂語,`within_recursive`和`migrated`。這些謂語不是儲存在資料庫中的三元組中,而是它們是從資料或其他規則派生而來的。規則可以引用其他規則,就像函式可以呼叫其他函式或者遞迴地呼叫自己一樣。像這樣,複雜的查詢可以一次構建其中的一小塊。 + +在規則中,以大寫字母開頭的單詞是變數,謂語則用Cypher和SPARQL的方式一樣來匹配。例如,`name(Location, Name)`透過變數繫結`Location = namerica`和`Name ='North America'`可以匹配三元組`name(namerica, 'North America')`。 + +要是系統可以在`:-` 運算子的右側找到與所有謂語的一個匹配,就運用該規則。當規則運用時,就好像透過`:-`的左側將其新增到資料庫(將變數替換成它們匹配的值)。 + +因此,一種可能的應用規則的方式是: + +1. 資料庫存在`name(namerica, 'North America')`,故運用規則1。它生成`within_recursive(namerica, 'North America')`。 +2. 資料庫存在`within(usa, namerica)`,在上一步驟中生成`within_recursive(namerica, 'North America')`,故運用規則2。它會產生`within_recursive(usa, 'North America')`。 +3. 資料庫存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故運用規則2。它產生`within_recursive(idaho, 'North America')`。 + +透過重複應用規則1和2,`within_recursive`謂語可以告訴我們在資料庫中包含北美(或任何其他位置名稱)的所有位置。這個過程如[圖2-6](img/fig2-6.png)所示。 + +![](img/fig2-6.png) + +**圖2-6 使用示例2-11中的Datalog規則來確定愛達荷州在北美。** + +現在規則3可以找到出生在某個地方`BornIn`的人,並住在某個地方`LivingIn`。透過查詢`BornIn ='United States'`和`LivingIn ='Europe'`,並將此人作為變數`Who`,讓Datalog系統找出變數`Who`會出現哪些值。因此,最後得到了與早先的Cypher和SPARQL查詢相同的答案。 + +相對於本章討論的其他查詢語言,我們需要採取不同的思維方式來思考Datalog方法,但這是一種非常強大的方法,因為規則可以在不同的查詢中進行組合和重用。雖然對於簡單的一次性查詢,顯得不太方便,但是它可以更好地處理資料很複雜的情況。 + + + +## 本章小結 + +資料模型是一個巨大的課題,在本章中,我們快速瀏覽了各種不同的模型。我們沒有足夠的空間來詳細介紹每個模型的細節,但是希望這個概述足以激起你的興趣,以更多地瞭解最適合你的應用需求的模型。 + +在歷史上,資料最開始被表示為一棵大樹(層次資料模型),但是這不利於表示多對多的關係,所以發明了關係模型來解決這個問題。最近,開發人員發現一些應用程式也不適合採用關係模型。新的非關係型“NoSQL”資料儲存在兩個主要方向上存在分歧: + +1. **文件資料庫**的應用場景是:資料通常是自我包含的,而且文件之間的關係非常稀少。 +2. **圖形資料庫**用於相反的場景:任意事物都可能與任何事物相關聯。 + +這三種模型(文件,關係和圖形)在今天都被廣泛使用,並且在各自的領域都發揮很好。一個模型可以用另一個模型來模擬 — 例如,圖資料可以在關係資料庫中表示 — 但結果往往是糟糕的。這就是為什麼我們有著針對不同目的的不同系統,而不是一個單一的萬能解決方案。 + +文件資料庫和圖資料庫有一個共同點,那就是它們通常不會為儲存的資料強制一個模式,這可以使應用程式更容易適應不斷變化的需求。但是應用程式很可能仍會假定資料具有一定的結構;這只是模式是明確的(寫入時強制)還是隱含的(讀取時處理)的問題。 + +每個資料模型都具有各自的查詢語言或框架,我們討論了幾個例子:SQL,MapReduce,MongoDB的聚合管道,Cypher,SPARQL和Datalog。我們也談到了CSS和XSL/XPath,它們不是資料庫查詢語言,而包含有趣的相似之處。 + +雖然我們已經覆蓋了很多層面,但仍然有許多資料模型沒有提到。舉幾個簡單的例子: + +* 使用基因組資料的研究人員通常需要執行**序列相似性搜尋**,這意味著需要一個很長的字串(代表一個DNA分子),並在一個擁有類似但不完全相同的字串的大型資料庫中尋找匹配。這裡所描述的資料庫都不能處理這種用法,這就是為什麼研究人員編寫了像GenBank這樣的專門的基因組資料庫軟體的原因【48】。 +* 粒子物理學家數十年來一直在進行大資料型別的大規模資料分析,像大型強子對撞機(LHC)這樣的專案現在可以工作在數百億兆位元組的範圍內!在這樣的規模下,需要定製解決方案來阻住硬體成本的失控【49】。 +* **全文搜尋**可以說是一種經常與資料庫一起使用的資料模型。資訊檢索是一個很大的專業課題,我們不會在本書中詳細介紹,但是我們將在第三章和第三章中介紹搜尋索引。 + +讓我們暫時將其放在一邊。在[下一章](ch3.md)中,我們將討論在**實現**本章描述的資料模型時會遇到的一些權衡。 + + + + + +## 參考文獻 + + +1. Edgar F. Codd: “[A Relational Model of Data for Large Shared Data Banks](https://www.seas.upenn.edu/~zives/03f/cis550/codd.pdf),” *Communications of the ACM*, volume 13, number 6, pages 377–387, June 1970. [doi:10.1145/362384.362685](http://dx.doi.org/10.1145/362384.362685) + +1. Michael Stonebraker and Joseph M. Hellerstein: “[What Goes Around Comes Around](http://mitpress2.mit.edu/books/chapters/0262693143chapm1.pdf),” + in *Readings in Database Systems*, 4th edition, MIT Press, pages 2–41, 2005. ISBN: 978-0-262-69314-1 + +1. Pramod J. Sadalage and Martin Fowler: *NoSQL Distilled*. Addison-Wesley, August 2012. ISBN: + 978-0-321-82662-6 + +1. Eric Evans: “[NoSQL: What's in a Name?](http://blog.sym-link.com/2009/10/30/nosql_whats_in_a_name.html),” *blog.sym-link.com*, October 30, 2009. + +1. James Phillips: “[Surprises in Our NoSQL Adoption Survey](http://blog.couchbase.com/nosql-adoption-survey-surprises),” *blog.couchbase.com*, February 8, 2012. + +1. Michael Wagner: *SQL/XML:2006 – Evaluierung der Standardkonformität ausgewählter Datenbanksysteme*. Diplomica Verlag, Hamburg, 2010. ISBN: 978-3-836-64609-3 + +1. “[XML Data in SQL Server](http://technet.microsoft.com/en-us/library/bb522446.aspx),” SQL Server 2012 documentation, *technet.microsoft.com*, 2013. + +1. “[PostgreSQL 9.3.1 Documentation](http://www.postgresql.org/docs/9.3/static/index.html),” The PostgreSQL Global Development Group, 2013. + +1. “[The MongoDB 2.4 Manual](http://docs.mongodb.org/manual/),” MongoDB, Inc., 2013. + +1. “[RethinkDB 1.11 Documentation](http://www.rethinkdb.com/docs/),” *rethinkdb.com*, 2013. + +1. “[Apache CouchDB 1.6 Documentation](http://docs.couchdb.org/en/latest/),” *docs.couchdb.org*, 2014. + +1. Lin Qiao, Kapil Surlaker, Shirshanka Das, et al.: “[On Brewing Fresh Espresso: LinkedIn’s Distributed Data Serving Platform](http://www.slideshare.net/amywtang/espresso-20952131),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013. + +1. Rick Long, Mark Harrington, Robert Hain, and Geoff Nicholls: *IMS Primer*. IBM Redbook SG24-5352-00, IBM International Technical Support Organization, January 2000. + +1. Stephen D. Bartlett: “[IBM’s IMS—Myths, Realities, and Opportunities](ftp://public.dhe.ibm.com/software/data/ims/pdf/TCG2013015LI.pdf),” The Clipper Group Navigator, TCG2013015LI, July 2013. + +1. Sarah Mei: “[Why You Should Never Use MongoDB](http://www.sarahmei.com/blog/2013/11/11/why-you-should-never-use-mongodb/),” *sarahmei.com*, November 11, 2013. + +1. J. S. Knowles and D. M. R. Bell: “The CODASYL Model,” in *Databases—Role and Structure: An Advanced Course*, edited by P. M. Stocker, P. M. D. Gray, and M. P. Atkinson, pages 19–56, Cambridge University Press, 1984. ISBN: 978-0-521-25430-4 + +1. Charles W. Bachman: “[The Programmer as Navigator](http://dl.acm.org/citation.cfm?id=362534),” *Communications of the ACM*, volume 16, number 11, pages 653–658, November 1973. [doi:10.1145/355611.362534](http://dx.doi.org/10.1145/355611.362534) + +1. Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton: “[Architecture of a Database System](http://db.cs.berkeley.edu/papers/fntdb07-architecture.pdf),” + *Foundations and Trends in Databases*, volume 1, number 2, pages 141–259, November 2007. [doi:10.1561/1900000002](http://dx.doi.org/10.1561/1900000002) + +1. Sandeep Parikh and Kelly Stirman: “[Schema Design for Time Series Data in MongoDB](http://blog.mongodb.org/post/65517193370/schema-design-for-time-series-data-in-mongodb),” *blog.mongodb.org*, October 30, 2013. + +1. Martin Fowler: “[Schemaless Data Structures](http://martinfowler.com/articles/schemaless/),” *martinfowler.com*, January 7, 2013. + +1. Amr Awadallah: “[Schema-on-Read vs. Schema-on-Write](http://www.slideshare.net/awadallah/schemaonread-vs-schemaonwrite),” at *Berkeley EECS RAD Lab Retreat*, Santa Cruz, CA, May 2009. + +1. Martin Odersky: “[The Trouble with Types](http://www.infoq.com/presentations/data-types-issues),” at *Strange Loop*, September 2013. + +1. Conrad Irwin: “[MongoDB—Confessions of a PostgreSQL Lover](https://speakerdeck.com/conradirwin/mongodb-confessions-of-a-postgresql-lover),” at *HTML5DevConf*, October 2013. + +1. “[Percona Toolkit Documentation: pt-online-schema-change](http://www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html),” Percona Ireland Ltd., 2013. + +1. Rany Keddo, Tobias Bielohlawek, and Tobias Schmidt: “[Large Hadron Migrator](https://github.com/soundcloud/lhm),” SoundCloud, 2013. Shlomi Noach: + + “[gh-ost: GitHub's Online Schema Migration Tool for MySQL](http://githubengineering.com/gh-ost-github-s-online-migration-tool-for-mysql/),” *githubengineering.com*, August 1, 2016. + +1. James C. Corbett, Jeffrey Dean, Michael Epstein, et al.: “[Spanner: Google’s Globally-Distributed Database](http://research.google.com/archive/spanner.html),” at *10th USENIX Symposium on Operating System Design and Implementation* (OSDI), + October 2012. + +1. Donald K. Burleson: “[Reduce I/O with Oracle Cluster Tables](http://www.dba-oracle.com/oracle_tip_hash_index_cluster_table.htm),” *dba-oracle.com*. + +1. Fay Chang, Jeffrey Dean, Sanjay Ghemawat, et al.: “[Bigtable: A Distributed Storage System for Structured Data](http://research.google.com/archive/bigtable.html),” at *7th USENIX Symposium on Operating System Design and Implementation* (OSDI), November 2006. + +1. Bobbie J. Cochrane and Kathy A. McKnight: “[DB2 JSON Capabilities, Part 1: Introduction to DB2 JSON](http://www.ibm.com/developerworks/data/library/techarticle/dm-1306nosqlforjson1/),” IBM developerWorks, June 20, 2013. + +1. Herb Sutter: “[The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software](http://www.gotw.ca/publications/concurrency-ddj.htm),” *Dr. Dobb's Journal*, volume 30, number 3, pages 202-210, March 2005. + +1. Joseph M. Hellerstein: “[The Declarative Imperative: Experiences and Conjectures in Distributed Logic](http://www.eecs.berkeley.edu/Pubs/TechRpts/2010/EECS-2010-90.pdf),” Electrical Engineering and Computer Sciences, University of California at Berkeley, Tech report UCB/EECS-2010-90, June 2010. + +1. Jeffrey Dean and Sanjay Ghemawat: “[MapReduce: Simplified Data Processing on Large Clusters](http://research.google.com/archive/mapreduce.html),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004. + +1. Craig Kerstiens: “[JavaScript in Your Postgres](https://blog.heroku.com/javascript_in_your_postgres),” *blog.heroku.com*, June 5, 2013. + +1. Nathan Bronson, Zach Amsden, George Cabrera, et al.: “[TAO: Facebook’s Distributed Data Store for the Social Graph](https://www.usenix.org/conference/atc13/technical-sessions/presentation/bronson),” at *USENIX Annual Technical Conference* (USENIX ATC), June 2013. + +1. “[Apache TinkerPop3.2.3 Documentation](http://tinkerpop.apache.org/docs/3.2.3/reference/),” *tinkerpop.apache.org*, October 2016. + +1. “[The Neo4j Manual v2.0.0](http://docs.neo4j.org/chunked/2.0.0/index.html),” Neo Technology, 2013. Emil Eifrem: [Twitter correspondence](https://twitter.com/emileifrem/status/419107961512804352), January 3, 2014. + +1. David Beckett and Tim Berners-Lee: “[Turtle – Terse RDF Triple Language](http://www.w3.org/TeamSubmission/turtle/),” W3C Team Submission, March 28, 2011. + +1. “[Datomic Development Resources](http://docs.datomic.com/),” Metadata Partners, LLC, 2013. W3C RDF Working Group: “[Resource Description Framework (RDF)](http://www.w3.org/RDF/),” *w3.org*, 10 February 2004. + +1. “[Apache Jena](http://jena.apache.org/),” Apache Software Foundation. + +1. Steve Harris, Andy Seaborne, and Eric Prud'hommeaux: “[SPARQL 1.1 Query Language](http://www.w3.org/TR/sparql11-query/),” + W3C Recommendation, March 2013. + +1. Todd J. Green, Shan Shan Huang, Boon Thau Loo, and Wenchao Zhou: “[Datalog and Recursive Query Processing](http://blogs.evergreen.edu/sosw/files/2014/04/Green-Vol5-DBS-017.pdf),” *Foundations and Trends in Databases*, volume 5, number 2, pages 105–195, November 2013. [doi:10.1561/1900000017](http://dx.doi.org/10.1561/1900000017) + +1. Stefano Ceri, Georg Gottlob, and Letizia Tanca: “[What You Always Wanted to Know About Datalog (And Never Dared to Ask)](https://www.researchgate.net/profile/Letizia_Tanca/publication/3296132_What_you_always_wanted_to_know_about_Datalog_and_never_dared_to_ask/links/0fcfd50ca2d20473ca000000.pdf),” *IEEE Transactions on Knowledge and Data Engineering*, volume 1, number 1, pages 146–166, March 1989. [doi:10.1109/69.43410](http://dx.doi.org/10.1109/69.43410) + +1. Serge Abiteboul, Richard Hull, and Victor Vianu: *Foundations of Databases*. Addison-Wesley, 1995. ISBN: 978-0-201-53771-0, available online at *webdam.inria.fr/Alice* + +1. Nathan Marz: “[Cascalog](http://cascalog.org/)," *cascalog.org*. Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, et al.: + + “[GenBank](http://nar.oxfordjournals.org/content/36/suppl_1/D25.full-text-lowres.pdf),” *Nucleic Acids Research*, volume 36, Database issue, pages D25–D30, December 2007. [doi:10.1093/nar/gkm929](http://dx.doi.org/10.1093/nar/gkm929) + +1. Fons Rademakers: “[ROOT for Big Data Analysis](http://indico.cern.ch/getFile.py/access?contribId=13&resId=0&materialId=slides&confId=246453),” at *Workshop on the Future of Big Data Management*, + London, UK, June 2013. + +------ + +| 上一章 | 目錄 | 下一章 | +| -------------------------------------- | ------------------------------- | ---------------------------- | +| [第一章:可靠、可擴充套件、可維護](ch1.md) | [設計資料密集型應用](README.md) | [第三章:儲存與檢索](ch3.md) | diff --git a/zh-tw/ch3.md b/zh-tw/ch3.md new file mode 100644 index 00000000..9bee1f6a --- /dev/null +++ b/zh-tw/ch3.md @@ -0,0 +1,765 @@ +# 3. 儲存與檢索 + +![](img/ch3.png) + +> 建立秩序,省卻搜尋 +> +> ——德國諺語 +> + +------------------- + +[TOC] + +一個數據庫在最基礎的層次上需要完成兩件事情:當你把資料交給資料庫時,它應當把資料儲存起來;而後當你向資料庫要資料時,它應當把資料返回給你。 + +在[第2章](ch2.md)中,我們討論了資料模型和查詢語言,即程式設計師將資料錄入資料庫的格式,以及再次要回資料的機制。在本章中我們會從資料庫的視角來討論同樣的問題:資料庫如何儲存我們提供的資料,以及如何在我們需要時重新找到資料。 + +作為程式設計師,為什麼要關心資料庫內部儲存與檢索的機理?你可能不會去從頭開始實現自己的儲存引擎,但是你**確實**需要從許多可用的儲存引擎中選擇一個合適的。而且為了協調儲存引擎以適配應用工作負載,你也需要大致瞭解儲存引擎在底層究竟做什麼。 + +特別需要注意,針對**事務**性負載和**分析性**負載最佳化的儲存引擎之間存在巨大差異。稍後我們將在 “[事務處理還是分析?](#事務處理還是分析?)” 一節中探討這一區別,並在 “[列儲存](#列儲存)”中討論一系列針對分析最佳化儲存引擎。 + +但是,我們將從您最可能熟悉的兩大類資料庫:傳統關係型資料庫與很多所謂的“NoSQL”資料庫開始,透過介紹它們的**儲存引擎**來開始本章的內容。我們會研究兩大類儲存引擎:**日誌結構(log-structured)** 的儲存引擎,以及**面向頁面(page-oriented)** 的儲存引擎(例如B樹)。 + +## 驅動資料庫的資料結構 + +世界上最簡單的資料庫可以用兩個Bash函式實現: + +```bash +#!/bin/bash +db_set () { + echo "$1,$2" >> database +} + +db_get () { + grep "^$1," database | sed -e "s/^$1,//" | tail -n 1 +} +``` + +這兩個函式實現了鍵值儲存的功能。執行 `db_set key value` ,會將 **鍵(key)**和**值(value)** 儲存在資料庫中。鍵和值(幾乎)可以是你喜歡的任何東西,例如,值可以是JSON文件。然後呼叫 `db_get key` ,查詢與該鍵關聯的最新值並將其返回。 + +麻雀雖小,五臟俱全: + +```bash +$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}' $ + +$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}' + +$ db_get 42 +{"name":"San Francisco","attractions":["Golden Gate Bridge"]} +``` + +底層的儲存格式非常簡單:一個文字檔案,每行包含一條逗號分隔的鍵值對(忽略轉義問題的話,大致與CSV檔案類似)。每次對 `db_set` 的呼叫都會向檔案末尾追加記錄,所以更新鍵的時候舊版本的值不會被覆蓋 —— 因而查詢最新值的時候,需要找到檔案中鍵最後一次出現的位置(因此 `db_get` 中使用了 `tail -n 1 ` 。) + +```bash +$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}' + +$ db_get 42 +{"name":"San Francisco","attractions":["Exploratorium"]} + +$ cat database +123456,{"name":"London","attractions":["Big Ben","London Eye"]} +42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]} +42,{"name":"San Francisco","attractions":["Exploratorium"]} +``` + +`db_set` 函式對於極其簡單的場景其實有非常好的效能,因為在檔案尾部追加寫入通常是非常高效的。與`db_set`做的事情類似,許多資料庫在內部使用了**日誌(log)**,也就是一個 **僅追加(append-only)** 的資料檔案。真正的資料庫有更多的問題需要處理(如併發控制,回收磁碟空間以避免日誌無限增長,處理錯誤與部分寫入的記錄),但基本原理是一樣的。日誌極其有用,我們還將在本書的其它部分重複見到它好幾次。 + +> **日誌(log)** 這個詞通常指應用日誌:即應用程式輸出的描述發生事情的文字。本書在更普遍的意義下使用**日誌**這一詞:一個僅追加的記錄序列。它可能壓根就不是給人類看的,使用二進位制格式,並僅能由其他程式讀取。 + +另一方面,如果這個資料庫中有著大量記錄,則這個`db_get` 函式的效能會非常糟糕。每次你想查詢一個鍵時,`db_get` 必須從頭到尾掃描整個資料庫檔案來查詢鍵的出現。用演算法的語言來說,查詢的開銷是 `O(n)` :如果資料庫記錄數量 n 翻了一倍,查詢時間也要翻一倍。這就不好了。 + +為了高效查詢資料庫中特定鍵的值,我們需要一個數據結構:**索引(index)**。本章將介紹一系列的索引結構,並它們進行對比。索引背後的大致思想是,儲存一些額外的元資料作為路標,幫助你找到想要的資料。如果您想在同一份資料中以幾種不同的方式進行搜尋,那麼你也許需要不同的索引,建在資料的不同部分上。 + +索引是從主資料衍生的**附加(additional)**結構。許多資料庫允許新增與刪除索引,這不會影響資料的內容,它隻影響查詢的效能。維護額外的結構會產生開銷,特別是在寫入時。寫入效能很難超過簡單地追加寫入檔案,因為追加寫入是最簡單的寫入操作。任何型別的索引通常都會減慢寫入速度,因為每次寫入資料時都需要更新索引。 + +這是儲存系統中一個重要的權衡:精心選擇的索引加快了讀查詢的速度,但是每個索引都會拖慢寫入速度。因為這個原因,資料庫預設並不會索引所有的內容,而需要你(程式設計師或DBA)透過對應用查詢模式的瞭解來手動選擇索引。你可以選擇能為應用帶來最大收益,同時又不會引入超出必要開銷的索引。 + + + +### 雜湊索引 + +讓我們從 **鍵值資料(key-value Data)** 的索引開始。這不是您可以索引的唯一資料型別,但鍵值資料是很常見的。對於更復雜的索引來說,這是一個有用的構建模組。 + +鍵值儲存與在大多數程式語言中可以找到的**字典(dictionary)**型別非常相似,通常字典都是用**雜湊對映(hash map)**(或**雜湊表(hash table)**)實現的。雜湊對映在許多演算法教科書中都有描述【1,2】,所以這裡我們不會討論它的工作細節。既然我們已經有**記憶體中**資料結構 —— 雜湊對映,為什麼不使用它來索引在**磁碟上**的資料呢? + +假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣。那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到一個數據檔案中的位元組偏移量,指明瞭可以找到對應值的位置,如[圖3-1](img/fig3-1.png)所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找(seek)** 該位置並讀取該值。 + +![](img/fig3-1.png) + +**圖3-1 以類CSV格式儲存鍵值對的日誌,並使用記憶體雜湊對映進行索引。** + +聽上去簡單,但這是一個可行的方法。現實中,Bitcask實際上就是這麼做的(Riak中預設的儲存引擎)【3】。 Bitcask提供高效能的讀取和寫入操作,但所有鍵必須能放入可用記憶體中,因為雜湊對映完全保留在記憶體中。這些值可以使用比可用記憶體更多的空間,因為可以從磁碟上透過一次`seek`載入所需部分,如果資料檔案的那部分已經在檔案系統快取中,則讀取根本不需要任何磁碟I/O。 + +像Bitcask這樣的儲存引擎非常適合每個鍵的值經常更新的情況。例如,鍵可能是影片的URL,值可能是它播放的次數(每次有人點選播放按鈕時遞增)。在這種型別的工作負載中,有很多寫操作,但是沒有太多不同的鍵——每個鍵有很多的寫操作,但是將所有鍵儲存在記憶體中是可行的。 + +直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完磁碟空間?一種好的解決方案是,將日誌分為特定大小的段,當日志增長到特定尺寸時關閉當前段檔案,並開始寫入一個新的段檔案。然後,我們就可以對這些段進行**壓縮(compaction)**,如[圖3-2](img/fig3-2.png)所示。壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。 + +![](img/fig3-2.png) + +**圖3-2 壓縮鍵值更新日誌(統計貓影片的播放次數),只保留每個鍵的最近值** + +而且,由於壓縮經常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾次),我們也可以在執行壓縮的同時將多個段合併在一起,如[圖3-3](img/fig3-3.png)所示。段被寫入後永遠不會被修改,所以合併的段被寫入一個新的檔案。凍結段的合併和壓縮可以在後臺執行緒中完成,在進行時,我們仍然可以繼續使用舊的段檔案來正常提供讀寫請求。合併過程完成後,我們將讀取請求轉換為使用新的合併段而不是舊段 —— 然後可以簡單地刪除舊的段檔案。 + +![](img/fig3-3.png) + +**圖3-3 同時執行壓縮和分段合併** + +每個段現在都有自己的記憶體散列表,將鍵對映到檔案偏移量。為了找到一個鍵的值,我們首先檢查最近段的雜湊對映;如果鍵不存在,我們檢查第二個最近的段,依此類推。合併過程保持細分的數量,所以查詢不需要檢查許多雜湊對映。 +大量的細節進入實踐這個簡單的想法工作。簡而言之,一些真正實施中重要的問題是: + +***檔案格式*** + +​ CSV不是日誌的最佳格式。使用二進位制格式更快,更簡單,首先以位元組為單位對字串的長度進行編碼,然後使用原始字串(不需要轉義)。 + +***刪除記錄*** + +如果要刪除一個鍵及其關聯的值,則必須在資料檔案(有時稱為邏輯刪除)中附加一個特殊的刪除記錄。當日志段被合併時,邏輯刪除告訴合併過程放棄刪除鍵的任何以前的值。 + +***崩潰恢復*** + +如果資料庫重新啟動,則記憶體雜湊對映將丟失。原則上,您可以透過從頭到尾讀取整個段檔案並在每次按鍵時注意每個鍵的最近值的偏移量來恢復每個段的雜湊對映。但是,如果段檔案很大,這可能需要很長時間,這將使伺服器重新啟動痛苦。 Bitcask透過儲存加速恢復磁碟上每個段的雜湊對映的快照,可以更快地載入到記憶體中。 + +***部分寫入記錄*** + +資料庫可能隨時崩潰,包括將記錄附加到日誌中途。 Bitcask檔案包含校驗和,允許檢測和忽略日誌的這些損壞部分。 + +***併發控制*** + +由於寫操作是以嚴格順序的順序附加到日誌中的,所以常見的實現選擇是隻有一個寫入器執行緒。資料檔案段是附加的,或者是不可變的,所以它們可以被多個執行緒同時讀取。 + +乍一看,只有追加日誌看起來很浪費:為什麼不更新檔案,用新值覆蓋舊值?但是隻能追加設計的原因有幾個: + +* 追加和分段合併是順序寫入操作,通常比隨機寫入快得多,尤其是在磁碟旋轉硬碟上。在某種程度上,順序寫入在基於快閃記憶體的 **固態硬碟(SSD)** 上也是優選的【4】。我們將在第83頁的“[比較B-樹和LSM-樹](#比較B-樹和LSM-樹)”中進一步討論這個問題。 +* 如果段檔案是附加的或不可變的,併發和崩潰恢復就簡單多了。例如,您不必擔心在覆蓋值時發生崩潰的情況,而將包含舊值和新值的一部分的檔案保留在一起。 +* 合併舊段可以避免資料檔案隨著時間的推移而分散的問題。 + +但是,雜湊表索引也有侷限性: + +* 散列表必須能放進記憶體 + + 如果你有非常多的鍵,那真是倒黴。原則上可以在磁碟上保留一個雜湊對映,不幸的是磁碟雜湊對映很難表現優秀。它需要大量的隨機訪問I/O,當它變滿時增長是很昂貴的,並且雜湊衝突需要很多的邏輯【5】。 + +* 範圍查詢效率不高。例如,您無法輕鬆掃描kitty00000和kitty99999之間的所有鍵——您必須在雜湊對映中單獨查詢每個鍵。 + +在下一節中,我們將看看一個沒有這些限制的索引結構。 + + + +### SSTables和LSM樹 + +在[圖3-3](img/fig3-3.png)中,每個日誌結構儲存段都是一系列鍵值對。這些對按照它們寫入的順序出現,日誌中稍後的值優先於日誌中較早的相同鍵的值。除此之外,檔案中鍵值對的順序並不重要。 + +現在我們可以對段檔案的格式做一個簡單的改變:我們要求鍵值對的序列按鍵排序。乍一看,這個要求似乎打破了我們使用順序寫入的能力,但是我們馬上就會明白這一點。 + +我們把這個格式稱為**排序字串表(Sorted String Table)**,簡稱SSTable。我們還要求每個鍵只在每個合併的段檔案中出現一次(壓縮過程已經保證)。與使用雜湊索引的日誌段相比,SSTable有幾個很大的優勢: + +1. 合併段是簡單而高效的,即使檔案大於可用記憶體。這種方法就像歸併排序演算法中使用的方法一樣,如[圖3-4](img/fig3-4.png)所示:您開始並排讀取輸入檔案,檢視每個檔案中的第一個鍵,複製最低鍵(根據排序順序)到輸出檔案,並重復。這產生一個新的合併段檔案,也按鍵排序。 + + ![](img/fig3-4.png) + + ##### 圖3-4 合併幾個SSTable段,只保留每個鍵的最新值 + + 如果在幾個輸入段中出現相同的鍵,該怎麼辦?請記住,每個段都包含在一段時間內寫入資料庫的所有值。這意味著一個輸入段中的所有值必須比另一個段中的所有值更新(假設我們總是合併相鄰的段)。當多個段包含相同的鍵時,我們可以保留最近段的值,並丟棄舊段中的值。 + +2. 為了在檔案中找到一個特定的鍵,你不再需要儲存記憶體中所有鍵的索引。以[圖3-5](img/fig3-5.png)為例:假設你正在記憶體中尋找鍵 `handiwork`,但是你不知道段檔案中該關鍵字的確切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由於排序特性,你知道 `handiwork` 必須出現在這兩者之間。這意味著您可以跳到 `handbag` 的偏移位置並從那裡掃描,直到您找到 `handiwork`(或沒找到,如果該檔案中沒有該鍵)。 + + ![](img/fig3-5.png) + + **圖3-5 具有記憶體索引的SSTable** + + 您仍然需要一個記憶體中索引來告訴您一些鍵的偏移量,但它可能很稀疏:每幾千位元組的段檔案就有一個鍵就足夠了,因為幾千位元組可以很快被掃描[^i]。 + + +3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對,因此可以將這些記錄分組到塊中,並在將其寫入磁碟之前對其進行壓縮(如[圖3-5](img/fig3-5.png)中的陰影區域所示) 。稀疏記憶體中索引的每個條目都指向壓縮塊的開始處。除了節省磁碟空間之外,壓縮還可以減少IO頻寬的使用。 + + +[^i]: 如果所有的鍵與值都是定長的,你可以使用段檔案上的二分查詢並完全避免使用記憶體索引。然而實踐中鍵值通常都是變長的,因此如果沒有索引,就很難知道記錄的分界點(前一條記錄結束,後一條記錄開始的地方) + +#### 構建和維護SSTables + +到目前為止,但是如何讓你的資料首先被按鍵排序呢?我們的傳入寫入可以以任何順序發生。 + +在磁碟上維護有序結構是可能的(參閱“[B樹](#B樹)”),但在記憶體儲存則要容易得多。有許多可以使用的眾所周知的樹形資料結構,例如紅黑樹或AVL樹【2】。使用這些資料結構,您可以按任何順序插入鍵,並按排序順序讀取它們。 + +現在我們可以使我們的儲存引擎工作如下: + +* 寫入時,將其新增到記憶體中的平衡樹資料結構(例如,紅黑樹)。這個記憶體樹有時被稱為**記憶體表(memtable)**。 +* 當**記憶體表**大於某個閾值(通常為幾兆位元組)時,將其作為SSTable檔案寫入磁碟。這可以高效地完成,因為樹已經維護了按鍵排序的鍵值對。新的SSTable檔案成為資料庫的最新部分。當SSTable被寫入磁碟時,寫入可以繼續到一個新的記憶體表例項。 +* 為了提供讀取請求,首先嚐試在記憶體表中找到關鍵字,然後在最近的磁碟段中,然後在下一個較舊的段中找到該關鍵字。 +* 有時會在後臺執行合併和壓縮過程以組合段檔案並丟棄覆蓋或刪除的值。 + +這個方案效果很好。它只會遇到一個問題:如果資料庫崩潰,則最近的寫入(在記憶體表中,但尚未寫入磁碟)將丟失。為了避免這個問題,我們可以在磁碟上儲存一個單獨的日誌,每個寫入都會立即被附加到磁碟上,就像在前一節中一樣。該日誌不是按排序順序,但這並不重要,因為它的唯一目的是在崩潰後恢復記憶體表。每當記憶體表寫出到SSTable時,相應的日誌都可以被丟棄。 + +#### 用SSTables製作LSM樹 + +這裡描述的演算法本質上是LevelDB 【6】和RocksDB 【7】中使用的關鍵值儲存引擎庫,被設計嵌入到其他應用程式中。除此之外,LevelDB可以在Riak中用作Bitcask的替代品。在Cassandra和HBase中使用了類似的儲存引擎【8】,這兩種引擎都受到了Google的Bigtable文件【9】(引入了SSTable和memtable)的啟發。 + +最初這種索引結構是由Patrick O'Neil等人描述的。在日誌結構合併樹(或LSM樹)【10】的基礎上,建立在以前的工作上日誌結構的檔案系統【11】。基於這種合併和壓縮排序檔案原理的儲存引擎通常被稱為LSM儲存引擎。 + +Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎,它使用類似的方法來儲存它的詞典【12,13】。全文索引比鍵值索引複雜得多,但是基於類似的想法:在搜尋查詢中給出一個單詞,找到提及單詞的所有文件(網頁,產品描述等)。這是透過鍵值結構實現的,其中鍵是單詞(**關鍵詞(term)**),值是包含單詞(文章列表)的所有文件的ID的列表。在Lucene中,從術語到釋出列表的這種對映儲存在SSTable類的有序檔案中,根據需要在後臺合併【14】。 + +#### 效能最佳化 + +與往常一樣,大量的細節使得儲存引擎在實踐中表現良好。例如,當查詢資料庫中不存在的鍵時,LSM樹演算法可能會很慢:您必須檢查記憶體表,然後將這些段一直回到最老的(可能必須從磁碟讀取每一個),然後才能確定鍵不存在。為了最佳化這種訪問,儲存引擎通常使用額外的Bloom過濾器【15】。 (布隆過濾器是用於近似集合內容的記憶體高效資料結構,它可以告訴您資料庫中是否出現鍵,從而為不存在的鍵節省許多不必要的磁碟讀取操作。 + +還有不同的策略來確定SSTables如何被壓縮和合並的順序和時間。最常見的選擇是大小分層壓實。 LevelDB和RocksDB使用平坦壓縮(LevelDB因此得名),HBase使用大小分層,Cassandra同時支援【16】。在規模級別的調整中,更新和更小的SSTables先後被合併到更老的和更大的SSTable中。在水平壓實中,關鍵範圍被拆分成更小的SSTables,而較舊的資料被移動到單獨的“水平”,這使得壓縮能夠更加遞增地進行,並且使用更少的磁碟空間。 + +即使有許多微妙的東西,LSM樹的基本思想 —— 儲存一系列在後臺合併的SSTables —— 簡單而有效。即使資料集比可用記憶體大得多,它仍能繼續正常工作。由於資料按排序順序儲存,因此可以高效地執行範圍查詢(掃描所有高於某些最小值和最高值的所有鍵),並且因為磁碟寫入是連續的,所以LSM樹可以支援非常高的寫入吞吐量。 + + + +### B樹 + +剛才討論的日誌結構索引正處在逐漸被接受的階段,但它們並不是最常見的索引型別。使用最廣泛的索引結構在1970年被引入【17】,不到10年後變得“無處不在”【18】,B樹經受了時間的考驗。在幾乎所有的關係資料庫中,它們仍然是標準的索引實現,許多非關係資料庫也使用它們。 + +像SSTables一樣,B樹保持按鍵排序的鍵值對,這允許高效的鍵值查詢和範圍查詢。但這就是相似之處的結尾:B樹有著非常不同的設計理念。 + +我們前面看到的日誌結構索引將資料庫分解為可變大小的段,通常是幾兆位元組或更大的大小,並且總是按順序編寫段。相比之下,B樹將資料庫分解成固定大小的塊或頁面,傳統上大小為4KB(有時會更大),並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體,因為磁碟也被安排在固定大小的塊中。 + +每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在磁碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](img/fig3-6.png)所示。 + +![](img/fig3-6.png) + +**圖3-6 使用B樹索引查詢一個鍵** + +一個頁面會被指定為B樹的根;在索引中查詢一個鍵時,就從這裡開始。該頁面包含幾個鍵和對子頁面的引用。每個子頁面負責一段連續範圍的鍵,引用之間的鍵,指明瞭引用子頁面的鍵範圍。 + +在[圖3-6](img/fig3-6.png)的例子中,我們正在尋找關鍵字 251 ,所以我們知道我們需要遵循邊界 200 和 300 之間的頁面引用。這將我們帶到一個類似的頁面,進一步打破了200 - 300到子範圍。 + +最後,我們可以看到包含單個鍵(葉頁)的頁面,該頁面包含每個鍵的內聯值,或者包含對可以找到值的頁面的引用。 + +在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](img/fig3-6.png)中,分支因子是 6 。在實踐中,分支因子取決於儲存頁面參考和範圍邊界所需的空間量,但通常是幾百個。 + +如果要更新B樹中現有鍵的值,則搜尋包含該鍵的葉頁,更改該頁中的值,並將該頁寫回到磁碟(對該頁的任何引用保持有效) 。如果你想新增一個新的鍵,你需要找到其範圍包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以解釋鍵範圍的新分割槽,如[圖3-7](img/fig3-7.png)所示[^ii]。 + +[^ii]: 向B樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了。 + +![](img/fig3-7.png) + +**圖3-7 透過分割頁面來生長B樹** + +該演算法確保樹保持平衡:具有 n 個鍵的B樹總是具有 $O(log n)$ 的深度。大多數資料庫可以放入一個三到四層的B樹,所以你不需要遵追蹤多頁面引用來找到你正在查詢的頁面。 (分支因子為 500 的 4KB 頁面的四級樹可以儲存多達 256TB 。) + +#### 讓B樹更可靠 + +B樹的基本底層寫操作是用新資料覆蓋磁碟上的頁面。假定覆蓋不改變頁面的位置;即,當頁面被覆蓋時,對該頁面的所有引用保持完整。這與日誌結構索引(如LSM樹)形成鮮明對比,後者只附加到檔案(並最終刪除過時的檔案),但從不修改檔案。 + +您可以考慮將硬碟上的頁面覆蓋為實際的硬體操作。在磁性硬碟驅動器上,這意味著將磁頭移動到正確的位置,等待旋轉盤上的正確位置出現,然後用新的資料覆蓋適當的扇區。在固態硬碟上,由於SSD必須一次擦除和重寫相當大的儲存晶片塊,所以會發生更復雜的事情【19】。 + +而且,一些操作需要覆蓋幾個不同的頁面。例如,如果因為插入導致頁面過度而拆分頁面,則需要編寫已拆分的兩個頁面,並覆蓋其父頁面以更新對兩個子頁面的引用。這是一個危險的操作,因為如果資料庫在僅有一些頁面被寫入後崩潰,那麼最終將導致一個損壞的索引(例如,可能有一個孤兒頁面不是任何父項的子項) 。 + +為了使資料庫對崩潰具有韌性,B樹實現通常會帶有一個額外的磁碟資料結構:**預寫式日誌(WAL, write-ahead-log)**(也稱為**重做日誌(redo log)**)。這是一個僅追加的檔案,每個B樹修改都可以應用到樹本身的頁面上。當資料庫在崩潰後恢復時,這個日誌被用來使B樹恢復到一致的狀態【5,20】。 + +更新頁面的一個額外的複雜情況是,如果多個執行緒要同時訪問B樹,則需要仔細的併發控制 —— 否則執行緒可能會看到樹處於不一致的狀態。這通常透過使用**鎖存器(latches)**(輕量級鎖)保護樹的資料結構來完成。日誌結構化的方法在這方面更簡單,因為它們在後臺進行所有的合併,而不會干擾傳入的查詢,並且不時地將舊的分段原子交換為新的分段。 + +#### B樹最佳化 + +由於B樹已經存在了這麼久,許多最佳化已經發展了多年,這並不奇怪。僅舉幾例: + +* 一些資料庫(如LMDB)使用寫時複製方案【21】,而不是覆蓋頁面並維護WAL進行崩潰恢復。修改的頁面被寫入到不同的位置,並且樹中的父頁面的新版本被建立,指向新的位置。這種方法對於併發控制也很有用,我們將在“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”中看到。 +* 我們可以透過不儲存整個鍵來節省頁面空間,但可以縮小它的大小。特別是在樹內部的頁面上,鍵只需要提供足夠的資訊來充當鍵範圍之間的邊界。在頁面中包含更多的鍵允許樹具有更高的分支因子,因此更少的層次 +* 通常,頁面可以放置在磁碟上的任何位置;沒有什麼要求附近的鍵範圍頁面附近的磁碟上。如果查詢需要按照排序順序掃描大部分關鍵字範圍,那麼每個頁面的佈局可能會非常不方便,因為每個讀取的頁面都可能需要磁碟查詢。因此,許多B樹實現嘗試佈局樹,使得葉子頁面按順序出現在磁碟上。但是,隨著樹的增長,維持這個順序是很困難的。相比之下,由於LSM樹在合併過程中一次又一次地重寫儲存的大部分,所以它們更容易使順序鍵在磁碟上彼此靠近。 +* 額外的指標已新增到樹中。例如,每個葉子頁面可以在左邊和右邊具有對其兄弟頁面的引用,這允許不跳回父頁面就能順序掃描。 +* B樹的變體如分形樹【22】借用一些日誌結構的思想來減少磁碟尋道(而且它們與分形無關)。 + +### 比較B樹和LSM樹 + +儘管B樹實現通常比LSM樹實現更成熟,但LSM樹由於其效能特點也非常有趣。根據經驗,通常LSM樹的寫入速度更快,而B樹的讀取速度更快【23】。 LSM樹上的讀取通常比較慢,因為它們必須在壓縮的不同階段檢查幾個不同的資料結構和SSTables。 + +然而,基準通常對工作量的細節不確定和敏感。 您需要測試具有特定工作負載的系統,以便進行有效的比較。 在本節中,我們將簡要討論一些在衡量儲存引擎效能時值得考慮的事情。 + +#### LSM樹的優點 + +B樹索引必須至少兩次寫入每一段資料:一次寫入預先寫入日誌,一次寫入樹頁面本身(也許再次分頁)。即使在該頁面中只有幾個位元組發生了變化,也需要一次編寫整個頁面的開銷。有些儲存引擎甚至會覆蓋同一個頁面兩次,以免在電源故障的情況下導致頁面部分更新【24,25】。 + +由於反覆壓縮和合並SSTables,日誌結構索引也會重寫資料。這種影響 —— 在資料庫的生命週期中寫入資料庫導致對磁碟的多次寫入 —— 被稱為**寫放大(write amplification)**。需要特別注意的是固態硬碟,固態硬碟的快閃記憶體壽命在覆寫有限次數後就會耗盡。 + +在寫入繁重的應用程式中,效能瓶頸可能是資料庫可以寫入磁碟的速度。在這種情況下,寫放大會導致直接的效能代價:儲存引擎寫入磁碟的次數越多,可用磁碟頻寬內的每秒寫入次數越少。 + +而且,LSM樹通常能夠比B樹支援更高的寫入吞吐量,部分原因是它們有時具有較低的寫放大(儘管這取決於儲存引擎配置和工作負載),部分是因為它們順序地寫入緊湊的SSTable檔案而不是必須覆蓋樹中的幾個頁面【26】。這種差異在磁性硬碟驅動器上尤其重要,順序寫入比隨機寫入快得多。 + +LSM樹可以被壓縮得更好,因此經常比B樹在磁碟上產生更小的檔案。 B樹儲存引擎會由於分割而留下一些未使用的磁碟空間:當頁面被拆分或某行不能放入現有頁面時,頁面中的某些空間仍未被使用。由於LSM樹不是面向頁面的,並且定期重寫SSTables以去除碎片,所以它們具有較低的儲存開銷,特別是當使用平坦壓縮時【27】。 + +在許多固態硬碟上,韌體內部使用日誌結構化演算法,將隨機寫入轉變為順序寫入底層儲存晶片,因此儲存引擎寫入模式的影響不太明顯【19】。但是,較低的寫入放大率和減少的碎片對SSD仍然有利:更緊湊地表示資料可在可用的I/O頻寬內提供更多的讀取和寫入請求。 + +#### LSM樹的缺點 + +日誌結構儲存的缺點是壓縮過程有時會干擾正在進行的讀寫操作。儘管儲存引擎嘗試逐步執行壓縮而不影響併發訪問,但是磁碟資源有限,所以很容易發生請求需要等待而磁碟完成昂貴的壓縮操作。對吞吐量和平均響應時間的影響通常很小,但是在更高百分比的情況下(參閱“[描述效能](ch1.md#描述效能)”),對日誌結構化儲存引擎的查詢響應時間有時會相當長,而B樹的行為則相對更具可預測性【28】。 + +壓縮的另一個問題出現在高寫入吞吐量:磁碟的有限寫入頻寬需要在初始寫入(記錄和重新整理記憶體表到磁碟)和在後臺執行的壓縮執行緒之間共享。寫入空資料庫時,可以使用全磁碟頻寬進行初始寫入,但資料庫越大,壓縮所需的磁碟頻寬就越多。 + +如果寫入吞吐量很高,並且壓縮沒有仔細配置,壓縮跟不上寫入速率。在這種情況下,磁碟上未合併段的數量不斷增加,直到磁碟空間用完,讀取速度也會減慢,因為它們需要檢查更多段檔案。通常情況下,即使壓縮無法跟上,基於SSTable的儲存引擎也不會限制傳入寫入的速率,所以您需要進行明確的監控來檢測這種情況【29,30】。 + +B樹的一個優點是每個鍵只存在於索引中的一個位置,而日誌結構化的儲存引擎可能在不同的段中有相同鍵的多個副本。這個方面使得B樹在想要提供強大的事務語義的資料庫中很有吸引力:在許多關係資料庫中,事務隔離是透過在鍵範圍上使用鎖來實現的,在B樹索引中,這些鎖可以直接連線到樹【5】。在[第7章](ch7.md)中,我們將更詳細地討論這一點。 + +B樹在資料庫體系結構中是非常根深蒂固的,為許多工作負載提供始終如一的良好效能,所以它們不可能很快就會消失。在新的資料儲存中,日誌結構化索引變得越來越流行。沒有快速和容易的規則來確定哪種型別的儲存引擎對你的場景更好,所以值得進行一些經驗上的測試。 + +### 其他索引結構 + +到目前為止,我們只討論了關鍵值索引,它們就像關係模型中的**主鍵(primary key)** 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件或圖形資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或ID)引用該行/文件/頂點,並且索引用於解析這樣的引用。 + +有二級索引也很常見。在關係資料庫中,您可以使用 `CREATE INDEX` 命令在同一個表上建立多個二級索引,而且這些索引通常對於有效地執行聯接而言至關重要。例如,在[第2章](ch2.md)中的[圖2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一個二級索引,以便您可以在每個表中找到屬於同一使用者的所有行。 + +一個二級索引可以很容易地從一個鍵值索引構建。主要的不同是鍵不是唯一的。即可能有許多行(文件,頂點)具有相同的鍵。這可以透過兩種方式來解決:或者透過使索引中的每個值,成為匹配行識別符號的列表(如全文索引中的釋出列表),或者透過向每個索引新增行識別符號來使每個關鍵字唯一。無論哪種方式,B樹和日誌結構索引都可以用作輔助索引。 + +#### 將值儲存在索引中 + +索引中的關鍵字是查詢搜尋的內容,但是該值可以是以下兩種情況之一:它可以是所討論的實際行(文件,頂點),也可以是對儲存在別處的行的引用。在後一種情況下,行被儲存的地方被稱為**堆檔案(heap file)**,並且儲存的資料沒有特定的順序(它可以是僅附加的,或者可以跟蹤被刪除的行以便用新資料覆蓋它們後來)。堆檔案方法很常見,因為它避免了在存在多個二級索引時複製資料:每個索引只引用堆檔案中的一個位置,實際的資料儲存在一個地方。 +在不更改鍵的情況下更新值時,堆檔案方法可以非常高效:只要新值不大於舊值,就可以覆蓋該記錄。如果新值更大,情況會更復雜,因為它可能需要移到堆中有足夠空間的新位置。在這種情況下,要麼所有的索引都需要更新,以指向記錄的新堆位置,或者在舊堆位置留下一個轉發指標【5】。 + +在某些情況下,從索引到堆檔案的額外跳躍對讀取來說效能損失太大,因此可能希望將索引行直接儲存在索引中。這被稱為聚集索引。例如,在MySQL的InnoDB儲存引擎中,表的主鍵總是一個聚簇索引,二級索引用主鍵(而不是堆檔案中的位置)【31】。在SQL Server中,可以為每個表指定一個聚簇索引【32】。 + +在 **聚集索引(clustered index)** (在索引中儲存所有行資料)和 **非聚集索引(nonclustered index)** (僅在索引中儲存對資料的引用)之間的折衷被稱為 **包含列的索引(index with included columns)**或**覆蓋索引(covering index)**,其儲存表的一部分在索引內【33】。這允許透過單獨使用索引來回答一些查詢(這種情況叫做:索引 **覆蓋(cover)** 了查詢)【32】。 + +與任何型別的資料重複一樣,聚簇和覆蓋索引可以加快讀取速度,但是它們需要額外的儲存空間,並且會增加寫入開銷。資料庫還需要額外的努力來執行事務保證,因為應用程式不應該因為重複而導致不一致。 + +#### 多列索引 + +至今討論的索引只是將一個鍵對映到一個值。如果我們需要同時查詢一個表中的多個列(或文件中的多個欄位),這顯然是不夠的。 + +最常見的多列索引被稱為 **連線索引(concatenated index)** ,它透過將一列的值追加到另一列後面,簡單地將多個欄位組合成一個鍵(索引定義中指定了欄位的連線順序)。這就像一個老式的紙質電話簿,它提供了一個從(姓,名)到電話號碼的索引。由於排序順序,索引可以用來查詢所有具有特定姓氏的人,或所有具有特定姓-名組合的人。**然而,如果你想找到所有具有特定名字的人,這個索引是沒有用的**。 + +**多維索引(multi-dimensional index)** 是一種查詢多個列的更一般的方法,這對於地理空間資料尤為重要。例如,餐廳搜尋網站可能有一個數據庫,其中包含每個餐廳的經度和緯度。當用戶在地圖上檢視餐館時,網站需要搜尋使用者正在檢視的矩形地圖區域內的所有餐館。這需要一個二維範圍查詢,如下所示: + +```sql +SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 + AND longitude > -0.1162 AND longitude < -0.1004; +``` + +一個標準的B樹或者LSM樹索引不能夠高效地響應這種查詢:它可以返回一個緯度範圍內的所有餐館(但經度可能是任意值),或者返回在同一個經度範圍內的所有餐館(但緯度可能是北極和南極之間的任意地方),但不能同時滿足。 + +一種選擇是使用空間填充曲線將二維位置轉換為單個數字,然後使用常規B樹索引【34】。更普遍的是,使用特殊化的空間索引,例如R樹。例如,PostGIS使用PostgreSQL的通用Gist工具【35】將地理空間索引實現為R樹。這裡我們沒有足夠的地方來描述R樹,但是有大量的文獻可供參考。 + +一個有趣的主意是,多維索引不僅可以用於地理位置。例如,在電子商務網站上可以使用維度(紅色,綠色,藍色)上的三維索引來搜尋特定顏色範圍內的產品,也可以在天氣觀測資料庫中搜索二維(日期,溫度)的指數,以便有效地搜尋2013年的溫度在25至30°C之間的所有觀測資料。使用一維索引,你將不得不掃描2013年的所有記錄(不管溫度如何),然後透過溫度進行過濾,反之亦然。 二維索引可以同時透過時間戳和溫度來收窄資料集。這個技術被HyperDex使用【36】。 + +#### 全文搜尋和模糊索引 + +到目前為止所討論的所有索引都假定您有確切的資料,並允許您查詢鍵的確切值或具有排序順序的鍵的值範圍。他們不允許你做的是搜尋類似的鍵,如拼寫錯誤的單詞。這種模糊的查詢需要不同的技術。 + +例如,全文搜尋引擎通常允許搜尋一個單詞以擴充套件為包括該單詞的同義詞,忽略單詞的語法變體,並且搜尋在相同文件中彼此靠近的單詞的出現,並且支援各種其他功能取決於文字的語言分析。為了處理文件或查詢中的拼寫錯誤,Lucene能夠在一定的編輯距離內搜尋文字(編輯距離1意味著新增,刪除或替換了一個字母)【37】。 + +正如“[在SSTables中建立LSM樹](#在SSTables中建立LSM樹)”中所提到的,Lucene為其詞典使用了一個類似於SSTable的結構。這個結構需要一個小的記憶體索引,告訴查詢在排序檔案中哪個偏移量需要查詢關鍵字。在LevelDB中,這個記憶體中的索引是一些鍵的稀疏集合,但在Lucene中,記憶體中的索引是鍵中字元的有限狀態自動機,類似於trie 【38】。這個自動機可以轉換成Levenshtein自動機,它支援在給定的編輯距離內有效地搜尋單詞【39】。 + +其他的模糊搜尋技術正朝著文件分類和機器學習的方向發展。有關更多詳細資訊,請參閱資訊檢索教科書,例如【40】。 + +#### 在記憶體中儲存一切 + +本章到目前為止討論的資料結構都是對磁碟限制的回答。與主記憶體相比,磁碟處理起來很尷尬。對於磁碟和SSD,如果要在讀取和寫入時獲得良好效能,則需要仔細地佈置磁碟上的資料。但是,我們容忍這種尷尬,因為磁碟有兩個顯著的優點:它們是耐用的(它們的內容在電源關閉時不會丟失),並且每GB的成本比RAM低。 + +隨著RAM變得更便宜,每GB的成本價格被侵蝕了。許多資料集不是那麼大,所以將它們全部儲存在記憶體中是非常可行的,可能分佈在多個機器上。這導致了記憶體資料庫的發展。 + +某些記憶體中的鍵值儲存(如Memcached)僅用於快取,在重新啟動計算機時丟失的資料是可以接受的。但其他記憶體資料庫的目標是永續性,可以透過特殊的硬體(例如電池供電的RAM),將更改日誌寫入磁碟,將定時快照寫入磁碟或透過複製記憶體來實現,記憶狀態到其他機器。 + +記憶體資料庫重新啟動時,需要從磁碟或透過網路從副本重新載入其狀態(除非使用特殊的硬體)。儘管寫入磁碟,它仍然是一個記憶體資料庫,因為磁碟僅用作耐久性附加日誌,讀取完全由記憶體提供。寫入磁碟也具有操作優勢:磁碟上的檔案可以很容易地由外部實用程式進行備份,檢查和分析。 + +諸如VoltDB,MemSQL和Oracle TimesTen等產品是具有關係模型的記憶體資料庫,供應商聲稱,透過消除與管理磁碟上的資料結構相關的所有開銷,他們可以提供巨大的效能改進【41,42】。 RAM Cloud是一個開源的記憶體鍵值儲存器,具有永續性(對儲存器中的資料以及磁碟上的資料使用日誌結構化方法)【43】。 Redis和Couchbase透過非同步寫入磁碟提供了較弱的永續性。 + +反直覺的是,記憶體資料庫的效能優勢並不是因為它們不需要從磁碟讀取的事實。即使是基於磁碟的儲存引擎也可能永遠不需要從磁碟讀取,因為作業系統快取最近在記憶體中使用了磁碟塊。相反,它們更快的原因在於省去了將記憶體資料結構編碼為磁碟資料結構的開銷。【44】。 + +除了效能,記憶體資料庫的另一個有趣的領域是提供難以用基於磁碟的索引實現的資料模型。例如,Redis為各種資料結構(如優先順序佇列和集合)提供了類似資料庫的介面。因為它將所有資料儲存在記憶體中,所以它的實現相對簡單。 + +最近的研究表明,記憶體資料庫體系結構可以擴充套件到支援比可用記憶體更大的資料集,而不必重新採用以磁碟為中心的體系結構【45】。所謂的 **反快取(anti-caching)** 方法透過在記憶體不足的情況下將最近最少使用的資料從記憶體轉移到磁碟,並在將來再次訪問時將其重新載入到記憶體中。這與作業系統對虛擬記憶體和交換檔案的操作類似,但資料庫可以比作業系統更有效地管理記憶體,因為它可以按單個記錄的粒度工作,而不是整個記憶體頁面。儘管如此,這種方法仍然需要索引能完全放入記憶體中(就像本章開頭的Bitcask例子)。 + +如果 **非易失性儲存器(NVM)** 技術得到更廣泛的應用,可能還需要進一步改變儲存引擎設計【46】。目前這是一個新的研究領域,值得關注。 + + + +## 事務處理還是分析? + +在業務資料處理的早期,對資料庫的寫入通常對應於正在進行的商業交易:進行銷售,向供應商下訂單,支付員工工資等等。隨著資料庫擴充套件到那些沒有不涉及錢易手,術語交易仍然卡住,指的是形成一個邏輯單元的一組讀寫。 +事務不一定具有ACID(原子性,一致性,隔離性和永續性)屬性。事務處理只是意味著允許客戶端進行低延遲讀取和寫入 —— 而不是批次處理作業,而這些作業只能定期執行(例如每天一次)。我們在[第7章](ch7.md)中討論ACID屬性,在[第10章](ch10.md)中討論批處理。 + +即使資料庫開始被用於許多不同型別的部落格文章,遊戲中的動作,地址簿中的聯絡人等等,基本訪問模式仍然類似於處理業務事務。應用程式通常使用索引透過某個鍵查詢少量記錄。根據使用者的輸入插入或更新記錄。由於這些應用程式是互動式的,因此訪問模式被稱為 **線上事務處理(OLTP, OnLine Transaction Processing)** 。 + +但是,資料庫也開始越來越多地用於資料分析,這些資料分析具有非常不同的訪問模式。通常,分析查詢需要掃描大量記錄,每個記錄只讀取幾列,並計算彙總統計資訊(如計數,總和或平均值),而不是將原始資料返回給使用者。例如,如果您的資料是一個銷售交易表,那麼分析查詢可能是: + +* 一月份每個商店的總收入是多少? +* 在最近的推廣活動中賣了多少香蕉? +* 哪個牌子的嬰兒食品最常與X品牌的尿布同時購買? + +這些查詢通常由業務分析師編寫,並提供給幫助公司管理層做出更好決策(商業智慧)的報告。為了區分這種使用資料庫的事務處理模式,它被稱為**線上分析處理(OLAP, OnLine Analytice Processing)**。【47】。OLTP和OLAP之間的區別並不總是清晰的,但是一些典型的特徵在[表3-1]()中列出。 + +**表3-1 比較交易處理和分析系統的特點** + +| 屬性 | 事務處理 OLTP | 分析系統 OLAP | +| :----------: | :--------------------------: | :----------------------: | +| 主要讀取模式 | 查詢少量記錄,按鍵讀取 | 在大批次記錄上聚合 | +| 主要寫入模式 | 隨機訪問,寫入要求低延時 | 批次匯入(ETL),事件流 | +| 主要使用者 | 終端使用者,透過Web應用 | 內部資料分析師,決策支援 | +| 處理的資料 | 資料的最新狀態(當前時間點) | 隨時間推移的歷史事件 | +| 資料集尺寸 | GB ~ TB | TB ~ PB | + +起初,相同的資料庫用於事務處理和分析查詢。 SQL在這方面證明是非常靈活的:對於OLTP型別的查詢以及OLAP型別的查詢來說效果很好。儘管如此,在二十世紀八十年代末和九十年代初期,公司有停止使用OLTP系統進行分析的趨勢,而是在單獨的資料庫上執行分析。這個單獨的資料庫被稱為**資料倉庫(data warehouse)**。 + +### 資料倉庫 + +一個企業可能有幾十個不同的交易處理系統:面向終端客戶的網站,控制實體商店的收銀系統,跟蹤倉庫庫存,規劃車輛路線,供應鏈管理,員工管理等。這些系統中每一個都很複雜,需要專人維護,所以系統最終都是自動執行的。 + +這些OLTP系統往往對業務運作至關重要,因而通常會要求 **高可用** 與 **低延遲**。所以DBA會密切關注他們的OLTP資料庫,他們通常不願意讓業務分析人員在OLTP資料庫上執行臨時分析查詢,因為這些查詢通常開銷巨大,會掃描大部分資料集,這會損害同時執行的事務的效能。 + +相比之下,資料倉庫是一個獨立的資料庫,分析人員可以查詢他們想要的內容而不影響OLTP操作【48】。資料倉庫包含公司各種OLTP系統中所有的只讀資料副本。從OLTP資料庫中提取資料(使用定期的資料轉儲或連續的更新流),轉換成適合分析的模式,清理並載入到資料倉庫中。將資料存入倉庫的過程稱為“**抽取-轉換-載入(ETL)**”,如[圖3-8](img/fig3-8)所示。 + +![](img/fig3-8.png) + +**圖3-8 ETL至資料倉庫的簡化提綱** + +幾乎所有的大型企業都有資料倉庫,但在小型企業中幾乎聞所未聞。這可能是因為大多數小公司沒有這麼多不同的OLTP系統,大多數小公司只有少量的資料 —— 可以在傳統的SQL資料庫中查詢,甚至可以在電子表格中分析。在一家大公司裡,要做一些在一家小公司很簡單的事情,需要很多繁重的工作。 + +使用單獨的資料倉庫,而不是直接查詢OLTP系統進行分析的一大優勢是資料倉庫可針對分析訪問模式進行最佳化。事實證明,本章前半部分討論的索引演算法對於OLTP來說工作得很好,但對於回答分析查詢並不是很好。在本章的其餘部分中,我們將研究為分析而最佳化的儲存引擎。 + +#### OLTP資料庫和資料倉庫之間的分歧 + +資料倉庫的資料模型通常是關係型的,因為SQL通常很適合分析查詢。有許多圖形資料分析工具可以生成SQL查詢,視覺化結果,並允許分析人員探索資料(透過下鑽,切片和切塊等操作)。 + +表面上,一個數據倉庫和一個關係OLTP資料庫看起來很相似,因為它們都有一個SQL查詢介面。然而,系統的內部看起來可能完全不同,因為它們針對非常不同的查詢模式進行了最佳化。現在許多資料庫供應商都將重點放在支援事務處理或分析工作負載上,而不是兩者都支援。 + +一些資料庫(例如Microsoft SQL Server和SAP HANA)支援在同一產品中進行事務處理和資料倉庫。但是,它們正在日益成為兩個獨立的儲存和查詢引擎,這些引擎正好可以透過一個通用的SQL介面訪問【49,50,51】。 + +Teradata,Vertica,SAP HANA和ParAccel等資料倉庫供應商通常使用昂貴的商業許可證銷售他們的系統。 Amazon RedShift是ParAccel的託管版本。最近,大量的開源SQL-on-Hadoop專案已經出現,它們還很年輕,但是正在與商業資料倉庫系統競爭。這些包括Apache Hive,Spark SQL,Cloudera Impala,Facebook Presto,Apache Tajo和Apache Drill 【52,53】。其中一些是基於谷歌的Dremel [54]的想法。 + +### 星型和雪花型:分析的模式 + +正如[第2章](ch2.md)所探討的,根據應用程式的需要,在事務處理領域中使用了大量不同的資料模型。另一方面,在分析中,資料模型的多樣性則少得多。許多資料倉庫都以相當公式化的方式使用,被稱為星型模式(也稱為維度建模【55】)。 + +圖3-9中的示例模式顯示了可能在食品零售商處找到的資料倉庫。在模式的中心是一個所謂的事實表(在這個例子中,它被稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買的產品)。如果我們分析的是網站流量而不是零售量,則每行可能代表一個使用者的頁面瀏覽量或點選量。 + +![](img/fig3-9.png) + +**圖3-9 用於資料倉庫的星型模式的示例** + +通常情況下,事實被視為單獨的事件,因為這樣可以在以後分析中獲得最大的靈活性。但是,這意味著事實表可以變得非常大。像蘋果,沃爾瑪或eBay這樣的大企業在其資料倉庫中可能有幾十PB的交易歷史,其中大部分實際上是表【56】。 + +事實表中的一些列是屬性,例如產品銷售的價格和從供應商那裡購買的成本(允許計算利潤餘額)。事實表中的其他列是對其他表(稱為維表)的外來鍵引用。由於事實表中的每一行都表示一個事件,因此這些維度代表事件的發生地點,時間,方式和原因。 + +例如,在[圖3-9](img/fig3-9.md)中,其中一個維度是已售出的產品。 `dim_product` 表中的每一行代表一種待售產品,包括**庫存單位(SKU)**,說明,品牌名稱,類別,脂肪含量,包裝尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中銷售了哪些產品。 (為了簡單起見,如果客戶一次購買幾種不同的產品,則它們在事實表中被表示為單獨的行)。 + +即使日期和時間通常使用維度表來表示,因為這允許對日期(諸如公共假期)的附加資訊進行編碼,從而允許查詢區分假期和非假期的銷售。 + +“星型模式”這個名字來源於這樣一個事實,即當表關係視覺化時,事實表在中間,由維表包圍;與這些表的連線就像星星的光芒。 + +這個模板的變體被稱為雪花模式,其中尺寸被進一步分解為子尺寸。例如,品牌和產品類別可能有單獨的表格,並且 `dim_product` 表格中的每一行都可以將品牌和類別作為外來鍵引用,而不是將它們作為字串儲存在 `dim_product` 表格中。雪花模式比星形模式更規範化,但是星形模式通常是首選,因為分析師使用它更簡單【55】。 + +在典型的資料倉庫中,表格通常非常寬泛:事實表格通常有100列以上,有時甚至有數百列【51】。維度表也可以是非常寬的,因為它們包括可能與分析相關的所有元資料——例如,`dim_store` 表可以包括在每個商店提供哪些服務的細節,它是否具有店內麵包房,方形鏡頭,商店第一次開幕的日期,最後一次改造的時間,離最近的高速公路的距離等等。 + + + +## 列儲存 + +如果事實表中有萬億行和數PB的資料,那麼高效地儲存和查詢它們就成為一個具有挑戰性的問題。維度表通常要小得多(數百萬行),所以在本節中我們將主要關注事實的儲存。 + +儘管事實表通常超過100列,但典型的資料倉庫查詢一次只能訪問4個或5個查詢( “ `SELECT *` ” 查詢很少用於分析)【51】。以[例3-1]()中的查詢為例:它訪問了大量的行(在2013日曆年中每次都有人購買水果或糖果),但只需訪問`fact_sales`表的三列:`date_key, product_sk, quantity`。查詢忽略所有其他列。 + +**例3-1 分析人們是否更傾向於購買新鮮水果或糖果,這取決於一週中的哪一天** + +```sql +SELECT + dim_date.weekday, + dim_product.category, + SUM(fact_sales.quantity) AS quantity_sold +FROM fact_sales + JOIN dim_date ON fact_sales.date_key = dim_date.date_key + JOIN dim_product ON fact_sales.product_sk = dim_product.product_sk +WHERE + dim_date.year = 2013 AND + dim_product.category IN ('Fresh fruit', 'Candy') +GROUP BY + dim_date.weekday, dim_product.category; +``` + +我們如何有效地執行這個查詢? + +在大多數OLTP資料庫中,儲存都是以面向行的方式進行佈局的:表格的一行中的所有值都相鄰儲存。文件資料庫是相似的:整個文件通常儲存為一個連續的位元組序列。你可以在[圖3-1](img/fig3-1.png)的CSV例子中看到這個。 + +為了處理像[例3-1]()這樣的查詢,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它們告訴儲存引擎在哪裡查詢特定日期或特定產品的所有銷售情況。但是,面向行的儲存引擎仍然需要將所有這些行(每個包含超過100個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉那些不符合要求的條件。這可能需要很長時間。 + +面向列的儲存背後的想法很簡單:不要將所有來自一行的值儲存在一起,而是將來自每一列的所有值儲存在一起。如果每個列儲存在一個單獨的檔案中,查詢只需要讀取和解析查詢中使用的那些列,這可以節省大量的工作。這個原理如[圖3-10](img/fig3-10.png)所示。 + +![](img/fig3-10.png) + +**圖3-10 使用列儲存關係型資料,而不是行** + +列儲存在關係資料模型中是最容易理解的,但它同樣適用於非關係資料。例如,Parquet 【57】是一種列式儲存格式,支援基於Google的Dremel 【54】的文件資料模型。 + +面向列的儲存佈局依賴於包含相同順序行的每個列檔案。 因此,如果您需要重新組裝整行,您可以從每個單獨的列檔案中獲取第23項,並將它們放在一起形成表的第23行。 + + + +### 列壓縮 + +除了僅從磁碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對磁碟吞吐量的需求。幸運的是,面向列的儲存通常很適合壓縮。 + +看看[圖3-10](img/fig3-10.png)中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如[圖3-11](img/fig3-11.png)所示。 + +![](img/fig3-11.png) + +**圖3-11 壓縮點陣圖索引儲存佈局** + +通常情況下,一列中不同值的數量與行數相比較小(例如,零售商可能有數十億的銷售交易,但只有100,000個不同的產品)。現在我們可以得到一個有 n 個不同值的列,並把它轉換成 n 個獨立的點陣圖:每個不同值的一個位圖,每行一位。如果該行具有該值,則該位為 1 ,否則為 0 。 + +如果 n 非常小(例如,國家/地區列可能有大約200個不同的值),則這些點陣圖可以每行儲存一位。但是,如果n更大,大部分點陣圖中將會有很多的零(我們說它們是稀疏的)。在這種情況下,點陣圖可以另外進行遊程編碼,如[圖3-11](fig3-11.png)底部所示。這可以使列的編碼非常緊湊。 + +這些點陣圖索引非常適合資料倉庫中常見的各種查詢。例如: + +```sql +WHERE product_sk IN(30,68,69) +``` + +載入 `product_sk = 30` , `product_sk = 68` , `product_sk = 69` 的三個點陣圖,並計算三個點陣圖的按位或,這可以非常有效地完成。 + +```sql +WHERE product_sk = 31 AND store_sk = 3 +``` + +載入 `product_sk = 31` 和 `store_sk = 3` 的點陣圖,並逐位計算AND。 這是因為列按照相同的順序包含行,因此一列的點陣圖中的第 k 位對應於與另一列的點陣圖中的第 k 位相同的行。 + +對於不同種類的資料,也有各種不同的壓縮方案,但我們不會詳細討論它們,參見【58】的概述。 + +> #### 面向列的儲存和列族 +> +> Cassandra和HBase有一個列族的概念,他們從Bigtable繼承【9】。然而,把它們稱為面向列是非常具有誤導性的:在每個列族中,它們將一行中的所有列與行鍵一起儲存,並且不使用列壓縮。因此,Bigtable模型仍然主要是面向行的。 +> + +#### 記憶體頻寬和向量處理 + +對於需要掃描數百萬行的資料倉庫查詢來說,一個巨大的瓶頸是從磁盤獲取資料到記憶體的頻寬。但是,這不是唯一的瓶頸。分析資料庫的開發人員也擔心有效利用主儲存器頻寬到CPU快取中的頻寬,避免CPU指令處理流水線中的分支錯誤預測和泡沫,以及在現代中使用單指令多資料(SIMD)指令CPU 【59,60】。 + +除了減少需要從磁碟載入的資料量以外,面向列的儲存佈局也可以有效利用CPU週期。例如,查詢引擎可以將大量壓縮的列資料放在CPU的L1快取中,然後在緊密的迴圈中迴圈(即沒有函式呼叫)。一個CPU可以執行這樣一個迴圈比程式碼要快得多,這個程式碼需要處理每個記錄的大量函式呼叫和條件。列壓縮允許列中的更多行適合相同數量的L1快取。前面描述的按位“與”和“或”運算子可以被設計為直接在這樣的壓縮列資料塊上操作。這種技術被稱為向量化處理【58,49】。 + + + +### 列儲存中的排序順序 + +在列儲存中,儲存行的順序並不一定很重要。按插入順序儲存它們是最簡單的,因為插入一個新行就意味著附加到每個列檔案。但是,我們可以選擇強制執行一個命令,就像我們之前對SSTables所做的那樣,並將其用作索引機制。 + +注意,每列獨自排序是沒有意義的,因為那樣我們就不會知道列中的哪些項屬於同一行。我們只能重建一行,因為我們知道一列中的第k項與另一列中的第k項屬於同一行。 + +相反,即使按列儲存資料,也需要一次對整行進行排序。資料庫的管理員可以使用他們對常見查詢的知識來選擇表格應該被排序的列。例如,如果查詢通常以日期範圍為目標,例如上個月,則可以將 `date_key` 作為第一個排序鍵。然後,查詢最佳化器只能掃描上個月的行,這比掃描所有行要快得多。 + +第二列可以確定第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是[圖3-10](img/fig3-10.png)中的第一個排序關鍵字,那麼 `product_sk` 可能是第二個排序關鍵字,因此同一天的同一產品的所有銷售都將在儲存中組合在一起。這將有助於需要在特定日期範圍內按產品對銷售進行分組或過濾的查詢。 + +排序順序的另一個好處是它可以幫助壓縮列。如果主要排序列沒有多個不同的值,那麼在排序之後,它將具有很長的序列,其中相同的值連續重複多次。一個簡單的執行長度編碼(就像我們用於[圖3-11](img/fig3-11.png)中的點陣圖一樣)可以將該列壓縮到幾千位元組 —— 即使表中有數十億行。 + +第一個排序鍵的壓縮效果最強。第二和第三個排序鍵會更混亂,因此不會有這麼長時間的重複值。排序優先順序下面的列以基本上隨機的順序出現,所以它們可能不會被壓縮。但前幾列排序仍然是一個整體。 + +#### 幾個不同的排序順序 + +這個想法的巧妙擴充套件在C-Store中引入,並在商業資料倉庫Vertica【61,62】中被採用。不同的查詢受益於不同的排序順序,為什麼不以相同的方式儲存相同的資料呢?無論如何,資料需要複製到多臺機器,這樣,如果一臺機器發生故障,您不會丟失資料。您可能還需要儲存以不同方式排序的冗餘資料,以便在處理查詢時,可以使用最適合查詢模式的版本。 + +在一個面向列的儲存中有多個排序順序有點類似於在一個面向行的儲存中有多個二級索引。但最大的區別在於面向行的儲存將每一行儲存在一個地方(在堆檔案或聚簇索引中),二級索引只包含指向匹配行的指標。在列儲存中,通常在其他地方沒有任何指向資料的指標,只有包含值的列。 + +### 寫入列儲存 + +這些最佳化在資料倉庫中是有意義的,因為大多數負載由分析人員執行的大型只讀查詢組成。面向列的儲存,壓縮和排序都有助於更快地讀取這些查詢。然而,他們有寫更加困難的缺點。 + +使用B樹的更新就地方法對於壓縮的列是不可能的。如果你想在排序表的中間插入一行,你很可能不得不重寫所有的列檔案。由於行由列中的位置標識,因此插入必須始終更新所有列。 + +幸運的是,本章前面已經看到了一個很好的解決方案:LSM樹。所有的寫操作首先進入一個記憶體中的儲存,在這裡它們被新增到一個已排序的結構中,並準備寫入磁碟。記憶體中的儲存是面向行還是列的,這並不重要。當已經積累了足夠的寫入資料時,它們將與磁碟上的列檔案合併,並批次寫入新檔案。這基本上是Vertica所做的【62】。 + +查詢需要檢查磁碟上的列資料和最近在記憶體中的寫入,並將兩者結合起來。但是,查詢最佳化器隱藏了使用者的這個區別。從分析師的角度來看,透過插入,更新或刪除操作進行修改的資料會立即反映在後續查詢中。 + +### 聚合:資料立方體和物化檢視 + +並不是每個資料倉庫都必定是一個列儲存:傳統的面向行的資料庫和其他一些架構也被使用。然而,對於專門的分析查詢,列式儲存可以顯著加快,所以它正在迅速普及【51,63】。 + +資料倉庫的另一個值得一提的是物化彙總。如前所述,資料倉庫查詢通常涉及一個聚合函式,如SQL中的COUNT,SUM,AVG,MIN或MAX。如果相同的聚合被許多不同的查詢使用,那麼每次都可以透過原始資料來處理。為什麼不快取一些查詢使用最頻繁的計數或總和? + +建立這種快取的一種方式是物化檢視。在關係資料模型中,它通常被定義為一個標準(虛擬)檢視:一個類似於表的物件,其內容是一些查詢的結果。不同的是,物化檢視是查詢結果的實際副本,寫入磁碟,而虛擬檢視只是寫入查詢的捷徑。從虛擬檢視讀取時,SQL引擎會將其展開到檢視的底層查詢中,然後處理展開的查詢。 + +當底層資料發生變化時,物化檢視需要更新,因為它是資料的非規範化副本。資料庫可以自動完成,但是這樣的更新使得寫入成本更高,這就是在OLTP資料庫中不經常使用物化檢視的原因。在讀取繁重的資料倉庫中,它們可能更有意義(不管它們是否實際上改善了讀取效能取決於個別情況)。 + +物化檢視的常見特例稱為資料立方體或OLAP立方【64】。它是按不同維度分組的聚合網格。[圖3-12](img/fig3-12.png)顯示了一個例子。 + +![](img/fig3-12.png) + +**圖3-12 資料立方的兩個維度,透過求和聚合** + +想象一下,現在每個事實都只有兩個維度表的外來鍵——在[圖3-12](img/fig-3-12.png)中,這些是日期和產品。您現在可以繪製一個二維表格,一個軸線上的日期和另一個軸上的產品。每個單元包含具有該日期 - 產品組合的所有事實的屬性(例如,`net_price`)的聚集(例如,`SUM`)。然後,您可以沿著每行或每列應用相同的彙總,並獲得一個維度減少的彙總(按產品的銷售額,無論日期,還是按日期銷售,無論產品如何)。 + +一般來說,事實往往有兩個以上的維度。在圖3-9中有五個維度:日期,產品,商店,促銷和客戶。要想象一個五維超立方體是什麼樣子是很困難的,但是原理是一樣的:每個單元格都包含特定日期(產品-商店-促銷-客戶)組合的銷售。這些值可以在每個維度上重複概括。 + +物化資料立方體的優點是某些查詢變得非常快,因為它們已經被有效地預先計算了。例如,如果您想知道每個商店的總銷售額,則只需檢視合適維度的總計,無需掃描數百萬行。 + +缺點是資料立方體不具有查詢原始資料的靈活性。例如,沒有辦法計算哪個銷售比例來自成本超過100美元的專案,因為價格不是其中的一個維度。因此,大多數資料倉庫試圖保留儘可能多的原始資料,並將聚合資料(如資料立方體)僅用作某些查詢的效能提升。 + + + +## 本章小結 + +在本章中,我們試圖深入瞭解資料庫如何處理儲存和檢索。將資料儲存在資料庫中會發生什麼,以及稍後再次查詢資料時資料庫會做什麼? + +在高層次上,我們看到儲存引擎分為兩大類:最佳化 **事務處理(OLTP)** 或 **線上分析(OLAP)** 。這些用例的訪問模式之間有很大的區別: + +* OLTP系統通常面向使用者,這意味著系統可能會收到大量的請求。為了處理負載,應用程式通常只訪問每個查詢中的少部分記錄。應用程式使用某種鍵來請求記錄,儲存引擎使用索引來查詢所請求的鍵的資料。磁碟尋道時間往往是這裡的瓶頸。 +* 資料倉庫和類似的分析系統會低調一些,因為它們主要由業務分析人員使用,而不是由終端使用者使用。它們的查詢量要比OLTP系統少得多,但通常每個查詢開銷高昂,需要在短時間內掃描數百萬條記錄。磁碟頻寬(而不是查詢時間)往往是瓶頸,列式儲存是這種工作負載越來越流行的解決方案。 + +在OLTP方面,我們能看到兩派主流的儲存引擎: + +***日誌結構學派*** + +只允許附加到檔案和刪除過時的檔案,但不會更新已經寫入的檔案。 Bitcask,SSTables,LSM樹,LevelDB,Cassandra,HBase,Lucene等都屬於這個類別。 + +***就地更新學派*** + +將磁碟視為一組可以覆寫的固定大小的頁面。 B樹是這種哲學的典範,用在所有主要的關係資料庫中和許多非關係型資料庫。 + +日誌結構的儲存引擎是相對較新的發展。他們的主要想法是,他們系統地將隨機訪問寫入順序寫入磁碟,由於硬碟驅動器和固態硬碟的效能特點,可以實現更高的寫入吞吐量。在完成OLTP方面,我們透過一些更復雜的索引結構和為保留所有資料而最佳化的資料庫做了一個簡短的介紹。 + +然後,我們從儲存引擎的內部繞開,看看典型資料倉庫的高階架構。這一背景說明了為什麼分析工作負載與OLTP差別很大:當您的查詢需要在大量行中順序掃描時,索引的相關性就會降低很多。相反,非常緊湊地編碼資料變得非常重要,以最大限度地減少查詢需要從磁碟讀取的資料量。我們討論了列式儲存如何幫助實現這一目標。 + +作為一名應用程式開發人員,如果您掌握了有關儲存引擎內部的知識,那麼您就能更好地瞭解哪種工具最適合您的特定應用程式。如果您需要調整資料庫的調整引數,這種理解可以讓您設想一個更高或更低的值可能會產生什麼效果。 + +儘管本章不能讓你成為一個特定儲存引擎的調參專家,但它至少有大概率使你有了足夠的概念與詞彙儲備去讀懂資料庫的文件,從而選擇合適的資料庫。 + + + + + +## 參考文獻 + + +1. Alfred V. Aho, John E. Hopcroft, and Jeffrey D. Ullman: *Data Structures and Algorithms*. Addison-Wesley, 1983. ISBN: 978-0-201-00023-8 + +1. 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 + +1. Justin Sheehy and David Smith: “[Bitcask: A Log-Structured Hash Table for Fast Key/Value Data](http://basho.com/wp-content/uploads/2015/05/bitcask-intro.pdf),” Basho Technologies, April 2010. + +1. Yinan Li, Bingsheng He, Robin Jun Yang, et al.: “[Tree Indexing on Solid State Drives](http://www.vldb.org/pvldb/vldb2010/papers/R106.pdf),” *Proceedings of the VLDB Endowment*, volume 3, number 1, pages 1195–1206, September 2010. + +1. Goetz Graefe: “[Modern B-Tree Techniques](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.219.7269&rep=rep1&type=pdf),” *Foundations and Trends in Databases*, volume 3, number 4, pages 203–402, August 2011. [doi:10.1561/1900000028](http://dx.doi.org/10.1561/1900000028) + +1. Jeffrey Dean and Sanjay Ghemawat: “[LevelDB Implementation Notes](https://github.com/google/leveldb/blob/master/doc/impl.html),” *leveldb.googlecode.com*. + +1. Dhruba Borthakur: “[The History of RocksDB](http://rocksdb.blogspot.com/),” *rocksdb.blogspot.com*, November 24, 2013. + +1. Matteo Bertozzi: “[Apache HBase I/O – HFile](http://blog.cloudera.com/blog/2012/06/hbase-io-hfile-input-output/),” *blog.cloudera.com*, June, 29 2012. + +1. Fay Chang, Jeffrey Dean, Sanjay Ghemawat, et al.: “[Bigtable: A Distributed Storage System for Structured Data](http://research.google.com/archive/bigtable.html),” at *7th USENIX Symposium on Operating System Design and Implementation* (OSDI), November 2006. + +1. Patrick O'Neil, Edward Cheng, Dieter Gawlick, and Elizabeth O'Neil: “[The Log-Structured Merge-Tree (LSM-Tree)](http://www.cs.umb.edu/~poneil/lsmtree.pdf),” *Acta Informatica*, volume 33, number 4, pages 351–385, June 1996. [doi:10.1007/s002360050048](http://dx.doi.org/10.1007/s002360050048) + +1. Mendel Rosenblum and John K. Ousterhout: “[The Design and Implementation of a Log-Structured File System](http://research.cs.wisc.edu/areas/os/Qual/papers/lfs.pdf),” *ACM Transactions on Computer Systems*, volume 10, number 1, pages 26–52, February 1992. + [doi:10.1145/146941.146943](http://dx.doi.org/10.1145/146941.146943) + +1. Adrien Grand: “[What Is in a Lucene Index?](http://www.slideshare.net/lucenerevolution/what-is-inaluceneagrandfinal),” at *Lucene/Solr Revolution*, November 14, 2013. + +1. Deepak Kandepet: “[Hacking Lucene—The Index Format]( http://hackerlabs.github.io/blog/2011/10/01/hacking-lucene-the-index-format/index.html),” *hackerlabs.org*, October 1, 2011. + +1. Michael McCandless: “[Visualizing Lucene's Segment Merges](http://blog.mikemccandless.com/2011/02/visualizing-lucenes-segment-merges.html),” *blog.mikemccandless.com*, February 11, 2011. + +1. Burton H. Bloom: “[Space/Time Trade-offs in Hash Coding with Allowable Errors](http://www.cs.upc.edu/~diaz/p422-bloom.pdf),” *Communications of the ACM*, volume 13, number 7, pages 422–426, July 1970. [doi:10.1145/362686.362692](http://dx.doi.org/10.1145/362686.362692) + +1. “[Operating Cassandra: Compaction](https://cassandra.apache.org/doc/latest/operating/compaction.html),” Apache Cassandra Documentation v4.0, 2016. + +1. Rudolf Bayer and Edward M. McCreight: “[Organization and Maintenance of Large Ordered Indices](http://www.dtic.mil/cgi-bin/GetTRDoc?AD=AD0712079),” Boeing Scientific Research Laboratories, Mathematical and Information Sciences Laboratory, report no. 20, July 1970. + +1. Douglas Comer: “[The Ubiquitous B-Tree](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.96.6637&rep=rep1&type=pdf),” *ACM Computing Surveys*, volume 11, number 2, pages 121–137, June 1979. [doi:10.1145/356770.356776](http://dx.doi.org/10.1145/356770.356776) + +1. Emmanuel Goossaert: “[Coding for SSDs](http://codecapsule.com/2014/02/12/coding-for-ssds-part-1-introduction-and-table-of-contents/),” *codecapsule.com*, February 12, 2014. + +1. C. Mohan and Frank Levine: “[ARIES/IM: An Efficient and High Concurrency Index Management Method Using Write-Ahead Logging](http://www.ics.uci.edu/~cs223/papers/p371-mohan.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 1992. [doi:10.1145/130283.130338](http://dx.doi.org/10.1145/130283.130338) + +1. Howard Chu: “[LDAP at Lightning Speed]( https://buildstuff14.sched.com/event/08a1a368e272eb599a52e08b4c3c779d),” at *Build Stuff '14*, November 2014. + +1. Bradley C. Kuszmaul: “[A Comparison of Fractal Trees to Log-Structured Merge (LSM) Trees](http://insideanalysis.com/wp-content/uploads/2014/08/Tokutek_lsm-vs-fractal.pdf),” *tokutek.com*, April 22, 2014. + +1. Manos Athanassoulis, Michael S. Kester, Lukas M. Maas, et al.: “[Designing Access Methods: The RUM Conjecture](http://openproceedings.org/2016/conf/edbt/paper-12.pdf),” at *19th International Conference on Extending Database Technology* (EDBT), March 2016. + [doi:10.5441/002/edbt.2016.42](http://dx.doi.org/10.5441/002/edbt.2016.42) + +1. Peter Zaitsev: “[Innodb Double Write](https://www.percona.com/blog/2006/08/04/innodb-double-write/),” *percona.com*, August 4, 2006. + +1. Tomas Vondra: “[On the Impact of Full-Page Writes](http://blog.2ndquadrant.com/on-the-impact-of-full-page-writes/),” *blog.2ndquadrant.com*, November 23, 2016. + +1. Mark Callaghan: “[The Advantages of an LSM vs a B-Tree](http://smalldatum.blogspot.co.uk/2016/01/summary-of-advantages-of-lsm-vs-b-tree.html),” *smalldatum.blogspot.co.uk*, January 19, 2016. + +1. Mark Callaghan: “[Choosing Between Efficiency and Performance with RocksDB](http://www.codemesh.io/codemesh/mark-callaghan),” at *Code Mesh*, November 4, 2016. + +1. Michi Mutsuzaki: “[MySQL vs. LevelDB](https://github.com/m1ch1/mapkeeper/wiki/MySQL-vs.-LevelDB),” *github.com*, August 2011. + +1. Benjamin Coverston, Jonathan Ellis, et al.: “[CASSANDRA-1608: Redesigned Compaction](https://issues.apache.org/jira/browse/CASSANDRA-1608), *issues.apache.org*, July 2011. + +1. Igor Canadi, Siying Dong, and Mark Callaghan: “[RocksDB Tuning Guide](https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide),” + *github.com*, 2016. + +1. [*MySQL 5.7 Reference Manual*](http://dev.mysql.com/doc/refman/5.7/en/index.html). Oracle, 2014. + +1. [*Books Online for SQL Server 2012*](http://msdn.microsoft.com/en-us/library/ms130214.aspx). Microsoft, 2012. + +1. Joe Webb: “[Using Covering Indexes to Improve Query Performance](https://www.simple-talk.com/sql/learn-sql-server/using-covering-indexes-to-improve-query-performance/),” *simple-talk.com*, 29 September 2008. + +1. Frank Ramsak, Volker Markl, Robert Fenk, et al.: “[Integrating the UB-Tree into a Database System Kernel](http://www.vldb.org/conf/2000/P263.pdf),” at *26th International Conference on Very Large Data Bases* (VLDB), September 2000. + +1. The PostGIS Development Group: “[PostGIS 2.1.2dev Manual](http://postgis.net/docs/manual-2.1/),” *postgis.net*, 2014. + +1. Robert Escriva, Bernard Wong, and Emin Gün Sirer: “[HyperDex: A Distributed, Searchable Key-Value Store](http://www.cs.princeton.edu/courses/archive/fall13/cos518/papers/hyperdex.pdf),” at *ACM SIGCOMM Conference*, August 2012. [doi:10.1145/2377677.2377681](http://dx.doi.org/10.1145/2377677.2377681) + +1. Michael McCandless: “[Lucene's FuzzyQuery Is 100 Times Faster in 4.0](http://blog.mikemccandless.com/2011/03/lucenes-fuzzyquery-is-100-times-faster.html),” *blog.mikemccandless.com*, March 24, 2011. + +1. Steffen Heinz, Justin Zobel, and Hugh E. Williams: “[Burst Tries: A Fast, Efficient Data Structure for String Keys](http://citeseer.ist.psu.edu/viewdoc/summary?doi=10.1.1.18.3499),” *ACM Transactions on Information Systems*, volume 20, number 2, pages 192–223, April 2002. [doi:10.1145/506309.506312](http://dx.doi.org/10.1145/506309.506312) + +1. Klaus U. Schulz and Stoyan Mihov: “[Fast String Correction with Levenshtein Automata](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.16.652),” *International Journal on Document Analysis and Recognition*, volume 5, number 1, pages 67–85, November 2002. [doi:10.1007/s10032-002-0082-8](http://dx.doi.org/10.1007/s10032-002-0082-8) + +1. Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze: [*Introduction to Information Retrieval*](http://nlp.stanford.edu/IR-book/). Cambridge University Press, 2008. ISBN: 978-0-521-86571-5, available online at *nlp.stanford.edu/IR-book* + +1. Michael Stonebraker, Samuel Madden, Daniel J. Abadi, et al.: “[The End of an Architectural Era (It’s Time for a Complete Rewrite)](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.137.3697&rep=rep1&type=pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007. + +1. “[VoltDB Technical Overview White Paper](https://www.voltdb.com/wptechnicaloverview),” VoltDB, 2014. + +1. Stephen M. Rumble, Ankita Kejriwal, and John K. Ousterhout: “[Log-Structured Memory for DRAM-Based Storage](https://www.usenix.org/system/files/conference/fast14/fast14-paper_rumble.pdf),” at *12th USENIX Conference on File and Storage Technologies* (FAST), February 2014. + +1. Stavros Harizopoulos, Daniel J. Abadi, Samuel Madden, and Michael Stonebraker: “[OLTP Through the Looking Glass, and What We Found There](http://hstore.cs.brown.edu/papers/hstore-lookingglass.pdf),” at *ACM International Conference on Management of Data* + (SIGMOD), June 2008. [doi:10.1145/1376616.1376713](http://dx.doi.org/10.1145/1376616.1376713) + +1. Justin DeBrabant, Andrew Pavlo, Stephen Tu, et al.: “[Anti-Caching: A New Approach to Database Management System Architecture](http://www.vldb.org/pvldb/vol6/p1942-debrabant.pdf),” *Proceedings of the VLDB Endowment*, volume 6, number 14, pages 1942–1953, September 2013. + +1. Joy Arulraj, Andrew Pavlo, and Subramanya R. Dulloor: “[Let's Talk About Storage & Recovery Methods for Non-Volatile Memory Database Systems](http://www.pdl.cmu.edu/PDL-FTP/NVM/storage.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2749441](http://dx.doi.org/10.1145/2723372.2749441) + +1. Edgar F. Codd, S. B. Codd, and C. T. Salley: “[Providing OLAP to User-Analysts: An IT Mandate](http://www.minet.uni-jena.de/dbis/lehre/ss2005/sem_dwh/lit/Cod93.pdf),” E. F. Codd Associates, 1993. + +1. Surajit Chaudhuri and Umeshwar Dayal: “[An Overview of Data Warehousing and OLAP Technology](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/sigrecord.pdf),” *ACM SIGMOD Record*, volume 26, number 1, pages 65–74, March 1997. [doi:10.1145/248603.248616](http://dx.doi.org/10.1145/248603.248616) + +1. Per-Åke Larson, Cipri Clinciu, Campbell Fraser, et al.: “[Enhancements to SQL Server Column Stores](http://research.microsoft.com/pubs/193599/Apollo3%20-%20Sigmod%202013%20-%20final.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013. + +1. Franz Färber, Norman May, Wolfgang Lehner, et al.: “[The SAP HANA Database – An Architecture Overview](http://sites.computer.org/debull/A12mar/hana.pdf),” *IEEE Data Engineering Bulletin*, volume 35, number 1, pages 28–33, March 2012. + +1. Michael Stonebraker: “[The Traditional RDBMS Wisdom Is (Almost Certainly) All Wrong](http://slideshot.epfl.ch/talks/166),” presentation at *EPFL*, May 2013. + +1. Daniel J. Abadi: “[Classifying the SQL-on-Hadoop Solutions](https://web.archive.org/web/20150622074951/http://hadapt.com/blog/2013/10/02/classifying-the-sql-on-hadoop-solutions/),” *hadapt.com*, October 2, 2013. + +1. Marcel Kornacker, Alexander Behm, Victor Bittorf, et al.: “[Impala: A Modern, Open-Source SQL Engine for Hadoop](http://pandis.net/resources/cidr15impala.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015. + +1. Sergey Melnik, Andrey Gubarev, Jing Jing Long, et al.: “[Dremel: Interactive Analysis of Web-Scale Datasets](http://research.google.com/pubs/pub36632.html),” at *36th International Conference on Very Large Data Bases* (VLDB), pages + 330–339, September 2010. + +1. Ralph Kimball and Margy Ross: *The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling*, 3rd edition. John Wiley & Sons, July 2013. ISBN: 978-1-118-53080-1 + +1. Derrick Harris: “[Why Apple, eBay, and Walmart Have Some of the Biggest Data Warehouses You’ve Ever Seen](http://gigaom.com/2013/03/27/why-apple-ebay-and-walmart-have-some-of-the-biggest-data-warehouses-youve-ever-seen/),” *gigaom.com*, March 27, 2013. + +1. Julien Le Dem: “[Dremel Made Simple with Parquet](https://blog.twitter.com/2013/dremel-made-simple-with-parquet),” *blog.twitter.com*, September 11, 2013. + +1. Daniel J. Abadi, Peter Boncz, Stavros Harizopoulos, et al.: “[The Design and Implementation of Modern Column-Oriented Database Systems](http://cs-www.cs.yale.edu/homes/dna/papers/abadi-column-stores.pdf),” *Foundations and Trends in Databases*, volume 5, number 3, pages 197–280, December 2013. [doi:10.1561/1900000024](http://dx.doi.org/10.1561/1900000024) + +1. Peter Boncz, Marcin Zukowski, and Niels Nes: “[MonetDB/X100: Hyper-Pipelining Query Execution](http://www.cidrdb.org/cidr2005/papers/P19.pdf),” + at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005. + +1. Jingren Zhou and Kenneth A. Ross: “[Implementing Database Operations Using SIMD Instructions](http://www1.cs.columbia.edu/~kar/pubsk/simd.pdf),” + at *ACM International Conference on Management of Data* (SIGMOD), pages 145–156, June 2002. + [doi:10.1145/564691.564709](http://dx.doi.org/10.1145/564691.564709) + +1. Michael Stonebraker, Daniel J. Abadi, Adam Batkin, et al.: “[C-Store: A Column-oriented DBMS](http://www.vldb2005.org/program/paper/thu/p553-stonebraker.pdf),” + at *31st International Conference on Very Large Data Bases* (VLDB), pages 553–564, September 2005. + +1. Andrew Lamb, Matt Fuller, Ramakrishna Varadarajan, et al.: “[The Vertica Analytic Database: C-Store 7 Years Later](http://vldb.org/pvldb/vol5/p1790_andrewlamb_vldb2012.pdf),” *Proceedings of the VLDB Endowment*, volume 5, number 12, pages 1790–1801, August 2012. + +1. Julien Le Dem and Nong Li: “[Efficient Data Storage for Analytics with Apache Parquet 2.0](http://www.slideshare.net/julienledem/th-210pledem),” at *Hadoop Summit*, San Jose, June 2014. + +1. Jim Gray, Surajit Chaudhuri, Adam Bosworth, et al.: “[Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals](http://arxiv.org/pdf/cs/0701155.pdf),” *Data Mining and Knowledge Discovery*, volume 1, number 1, pages 29–53, March 2007. [doi:10.1023/A:1009726021843](http://dx.doi.org/10.1023/A:1009726021843) + + + +------ + +| 上一章 | 目錄 | 下一章 | +| ------------------------------------ | ------------------------------- | ---------------------------- | +| [第二章:資料模型與查詢語言](ch2.md) | [設計資料密集型應用](README.md) | [第四章:編碼與演化](ch4.md) | + diff --git a/zh-tw/ch4.md b/zh-tw/ch4.md new file mode 100644 index 00000000..ce6e3fdf --- /dev/null +++ b/zh-tw/ch4.md @@ -0,0 +1,640 @@ +# 4. 編碼與演化 + +![](img/ch4.png) + +> 唯變所適 +> +> ——以弗所的赫拉克利特,為柏拉圖所引(公元前360年) +> + +------------------- + +[TOC] + +應用程式不可避免地隨時間而變化。新產品的推出,對需求的深入理解,或者商業環境的變化,總會伴隨著**功能(feature)**的增增改改。[第一章](ch1.md)介紹了[**可演化性(evolvability)**](ch1.md#可演化性:擁抱變化)的概念:應該盡力構建能靈活適應變化的系統(參閱“[可演化性:擁抱變化]()”)。 + +在大多數情況下,修改應用程式的功能也意味著需要更改其儲存的資料:可能需要使用新的欄位或記錄型別,或者以新方式展示現有資料。 + +我們在[第二章](ch2.md)討論的資料模型有不同的方法來應對這種變化。關係資料庫通常假定資料庫中的所有資料都遵循一個模式:儘管可以更改該模式(透過模式遷移,即`ALTER`語句),但是在任何時間點都有且僅有一個正確的模式。相比之下,**讀時模式(schema-on-read)**(或 **無模式(schemaless)**)資料庫不會強制一個模式,因此資料庫可以包含在不同時間寫入的新老資料格式的混合(參閱 “文件模型中的模式靈活性” )。 + +當資料**格式(format)**或**模式(schema)**發生變化時,通常需要對應用程式程式碼進行相應的更改(例如,為記錄新增新欄位,然後修改程式開始讀寫該欄位)。但在大型應用程式中,程式碼變更通常不會立即完成: + +* 對於 **服務端(server-side)** 應用程式,可能需要執行 **滾動升級 (rolling upgrade)** (也稱為 **階段釋出(staged rollout)** ),一次將新版本部署到少數幾個節點,檢查新版本是否執行正常,然後逐漸部完所有的節點。這樣無需中斷服務即可部署新版本,為頻繁釋出提供了可行性,從而帶來更好的可演化性。 +* 對於 **客戶端(client-side)** 應用程式,升不升級就要看使用者的心情了。使用者可能相當長一段時間裡都不會去升級軟體。 + +這意味著,新舊版本的程式碼,以及新舊資料格式可能會在系統中同時共處。系統想要繼續順利執行,就需要保持**雙向相容性**: + +***向後相容 (backward compatibility)*** + +​ 新程式碼可以讀舊資料。 + +***向前相容 (forward compatibility)*** + +​ 舊程式碼可以讀新資料。 + +向後相容性通常並不難實現:新程式碼的作者當然知道由舊程式碼使用的資料格式,因此可以顯示地處理它(最簡單的辦法是,保留舊程式碼即可讀取舊資料)。 + +向前相容性可能會更棘手,因為舊版的程式需要忽略新版資料格式中新增的部分。 + +本章中將介紹幾種編碼資料的格式,包括 JSON,XML,Protocol Buffers,Thrift和Avro。尤其將關注這些格式如何應對模式變化,以及它們如何對新舊程式碼資料需要共存的系統提供支援。然後將討論如何使用這些格式進行資料儲存和通訊:在Web服務中,**具象狀態傳輸(REST)**和**遠端過程呼叫(RPC)**,以及**訊息傳遞系統**(如Actor和訊息佇列)。 + +## 編碼資料的格式 + +程式通常(至少)使用兩種形式的資料: + +1. 在記憶體中,資料儲存在物件,結構體,列表,陣列,雜湊表,樹等中。 這些資料結構針對CPU的高效訪問和操作進行了最佳化(通常使用指標)。 +2. 如果要將資料寫入檔案,或透過網路傳送,則必須將其 **編碼(encode)** 為某種自包含的位元組序列(例如,JSON文件)。 由於每個程序都有自己獨立的地址空間,一個程序中的指標對任何其他程序都沒有意義,所以這個位元組序列表示會與通常在記憶體中使用的資料結構完全不同[^i]。 + +[^i]: 除一些特殊情況外,例如某些記憶體對映檔案或直接在壓縮資料上操作(如“[列壓縮](ch4.md#列壓縮)”中所述)。 + +所以,需要在兩種表示之間進行某種型別的翻譯。 從記憶體中表示到位元組序列的轉換稱為 **編碼(Encoding)** (也稱為**序列化(serialization)**或**編組(marshalling)**),反過來稱為**解碼(Decoding)**[^ii](**解析(Parsing)**,**反序列化(deserialization)**,**反編組( unmarshalling)**)[^譯i]。 + +[^ii]: 請注意,**編碼(encode)** 與 **加密(encryption)** 無關。 本書不討論加密。 +[^譯i]: Marshal與Serialization的區別:Marshal不僅傳輸物件的狀態,而且會一起傳輸物件的方法(相關程式碼)。 + +> #### 術語衝突 +> 不幸的是,在[第七章](ch7.md): **事務(Transaction)** 的上下文裡,**序列化(Serialization)** 這個術語也出現了,而且具有完全不同的含義。儘管序列化可能是更常見的術語,為了避免術語過載,本書中堅持使用 **編碼(Encoding)** 表達此含義。 + +這是一個常見的問題,因而有許多庫和編碼格式可供選擇。 首先讓我們概覽一下。 + +### 語言特定的格式 + +許多程式語言都內建了將記憶體物件編碼為位元組序列的支援。例如,Java有`java.io.Serializable` 【1】,Ruby有`Marshal`【2】,Python有`pickle`【3】等等。許多第三方庫也存在,例如`Kryo for Java` 【4】。 + +這些編碼庫非常方便,可以用很少的額外程式碼實現記憶體物件的儲存與恢復。但是它們也有一些深層次的問題: + +* 這類編碼通常與特定的程式語言深度繫結,其他語言很難讀取這種資料。如果以這類編碼儲存或傳輸資料,那你就和這門語言綁死在一起了。並且很難將系統與其他組織的系統(可能用的是不同的語言)進行整合。 +* 為了恢復相同物件型別的資料,解碼過程需要**例項化任意類**的能力,這通常是安全問題的一個來源【5】:如果攻擊者可以讓應用程式解碼任意的位元組序列,他們就能例項化任意的類,這會允許他們做可怕的事情,如遠端執行任意程式碼【6,7】。 +* 在這些庫中,資料版本控制通常是事後才考慮的。因為它們旨在快速簡便地對資料進行編碼,所以往往忽略了前向後向相容性帶來的麻煩問題。 +* 效率(編碼或解碼所花費的CPU時間,以及編碼結構的大小)往往也是事後才考慮的。 例如,Java的內建序列化由於其糟糕的效能和臃腫的編碼而臭名昭著【8】。 + +因此,除非臨時使用,採用語言內建編碼通常是一個壞主意。 + +### JSON,XML和二進位制變體 + +談到可以被許多程式語言編寫和讀取的標準化編碼,JSON和XML是顯眼的競爭者。它們廣為人知,廣受支援,也“廣受憎惡”。 XML經常被批評為過於冗長和不必要的複雜【9】。 JSON倍受歡迎,主要由於它在Web瀏覽器中的內建支援(透過成為JavaScript的一個子集)以及相對於XML的簡單性。 CSV是另一種流行的與語言無關的格式,儘管功能較弱。 + +JSON,XML和CSV是文字格式,因此具有人類可讀性(儘管語法是一個熱門辯題)。除了表面的語法問題之外,它們也有一些微妙的問題: + +* 數字的編碼多有歧義之處。XML和CSV不能區分數字和字串(除非引用外部模式)。 JSON雖然區分字串和數字,但不區分整數和浮點數,而且不能指定精度。 +* 當處理大量資料時,這個問題更嚴重了。例如,大於$2^{53}$的整數不能在IEEE 754雙精度浮點數中精確表示,因此在使用浮點數(例如JavaScript)的語言進行分析時,這些數字會變得不準確。 Twitter上有一個大於$2^{53}$的數字的例子,它使用一個64位的數字來標識每條推文。 Twitter API返回的JSON包含了兩種推特ID,一個JSON數字,另一個是十進位制字串,以此避免JavaScript程式無法正確解析數字的問題【10】。 +* JSON和XML對Unicode字串(即人類可讀的文字)有很好的支援,但是它們不支援二進位制資料(不帶字元編碼(character encoding)的位元組序列)。二進位制串是很實用的功能,所以人們透過使用Base64將二進位制資料編碼為文字來繞開這個限制。模式然後用於表示該值應該被解釋為Base64編碼。這個工作,但它有點hacky,並增加了33%的資料大小。 XML 【11】和JSON 【12】都有可選的模式支援。這些模式語言相當強大,所以學習和實現起來相當複雜。 XML模式的使用相當普遍,但許多基於JSON的工具嫌麻煩才不會使用模式。由於資料的正確解釋(例如數字和二進位制字串)取決於模式中的資訊,因此不使用XML/JSON模式的應用程式可能需要對相應的編碼/解碼邏輯進行硬編碼。 +* CSV沒有任何模式,因此應用程式需要定義每行和每列的含義。如果應用程式更改新增新的行或列,則必須手動處理該變更。 CSV也是一個相當模糊的格式(如果一個值包含逗號或換行符,會發生什麼?)。儘管其轉義規則已經被正式指定【13】,但並不是所有的解析器都正確的實現了標準。 + +儘管存在這些缺陷,但JSON,XML和CSV已經足夠用於很多目的。特別是作為資料交換格式(即將資料從一個組織傳送到另一個組織),它們很可能仍然很受歡迎。這種情況下,只要人們對格式是什麼意見一致,格式多麼美觀或者高效就沒有關係。**讓不同的組織達成一致的難度超過了其他大多數問題。** + +#### 二進位制編碼 + +對於僅在組織內部使用的資料,使用最小公分母編碼格式的壓力較小。例如,可以選擇更緊湊或更快的解析格式。雖然對小資料集來說,收益可以忽略不計,但一旦達到TB級別,資料格式的選擇就會產生巨大的影響。 + +JSON比XML簡潔,但與二進位制格式一比,還是太佔地方。這一事實導致大量二進位制編碼版本JSON & XML的出現,JSON(MessagePack,BSON,BJSON,UBJSON,BISON和Smile等)(例如WBXML和Fast Infoset)。這些格式已經被各種各樣的領域所採用,但是沒有一個像JSON和XML的文字版本那樣被廣泛採用。 + +這些格式中的一些擴充套件了一組資料型別(例如,區分整數和浮點數,或者增加對二進位制字串的支援),另一方面,它們沒有蓋面JSON / XML的資料模型。特別是由於它們沒有規定模式,所以它們需要在編碼資料中包含所有的物件欄位名稱。也就是說,在[例4-1]()中的JSON文件的二進位制編碼中,需要在某處包含字串`userName`,`favoriteNumber`和`interest`。 + +**例4-1 本章中用於展示二進位制編碼的示例記錄** + +```json +{ + "userName": "Martin", + "favoriteNumber": 1337, + "interests": ["daydreaming", "hacking"] +} +``` + +我們來看一個MessagePack的例子,它是一個JSON的二進位制編碼。圖4-1顯示瞭如果使用MessagePack 【14】對[例4-1]()中的JSON文件進行編碼,則得到的位元組序列。前幾個位元組如下: + +1. 第一個位元組`0x83`表示接下來是**3**個欄位(低四位= `0x03`)的**物件 object**(高四位= `0x80`)。 (如果想知道如果一個物件有15個以上的欄位會發生什麼情況,欄位的數量塞不進4個bit裡,那麼它會用另一個不同的型別識別符號,欄位的數量被編碼兩個或四個位元組)。 +2. 第二個位元組`0xa8`表示接下來是**8**位元組長的字串(最高四位= 0x08)。 +3. 接下來八個位元組是ASCII字串形式的欄位名稱`userName`。由於之前已經指明長度,不需要任何標記來標識字串的結束位置(或者任何轉義)。 +4. 接下來的七個位元組對字首為`0xa6`的六個字母的字串值`Martin`進行編碼,依此類推。 + +二進位制編碼長度為66個位元組,僅略小於文字JSON編碼所取的81個位元組(刪除了空白)。所有的JSON的二進位制編碼在這方面是相似的。空間節省了一丁點(以及解析加速)是否能彌補可讀性的損失,誰也說不準。 + +在下面的章節中,能達到比這好得多的結果,只用32個位元組對相同的記錄進行編碼。 + + +![](img/fig4-1.png) + +**圖4-1 使用MessagePack編碼的記錄(例4-1)** + +### Thrift與Protocol Buffers + +Apache Thrift 【15】和Protocol Buffers(protobuf)【16】是基於相同原理的二進位制編碼庫。 Protocol Buffers最初是在Google開發的,Thrift最初是在Facebook開發的,並且在2007~2008年都是開源的【17】。 +Thrift和Protocol Buffers都需要一個模式來編碼任何資料。要在Thrift的[例4-1]()中對資料進行編碼,可以使用Thrift **介面定義語言(IDL)** 來描述模式,如下所示: + +```c +struct Person { + 1: required string userName, + 2: optional i64 favoriteNumber, + 3: optional list interests +} +``` + +Protocol Buffers的等效模式定義看起來非常相似: + +```protobuf +message Person { + required string user_name = 1; + optional int64 favorite_number = 2; + repeated string interests = 3; +} +``` + +Thrift和Protocol Buffers每一個都帶有一個程式碼生成工具,它採用了類似於這裡所示的模式定義,並且生成了以各種程式語言實現模式的類【18】。您的應用程式程式碼可以呼叫此生成的程式碼來對模式的記錄進行編碼或解碼。 +用這個模式編碼的資料是什麼樣的?令人困惑的是,Thrift有兩種不同的二進位制編碼格式[^iii],分別稱為BinaryProtocol和CompactProtocol。先來看看BinaryProtocol。使用這種格式的編碼來編碼[例4-1]()中的訊息只需要59個位元組,如[圖4-2](img/fig4-2.png)所示【19】。 + +![](img/fig4-2.png) + +**圖4-2 使用Thrift二進位制協議編碼的記錄** + +[^iii]: 實際上,Thrift有三種二進位制協議:CompactProtocol和DenseProtocol,儘管DenseProtocol只支援C ++實現,所以不算作跨語言[18]。 除此之外,它還有兩種不同的基於JSON的編碼格式【19】。 真逗! + +與[圖4-1](Img/fig4-1.png)類似,每個欄位都有一個型別註釋(用於指示它是一個字串,整數,列表等),還可以根據需要指定長度(字串的長度,列表中的專案數) 。出現在資料中的字串`(“Martin”, “daydreaming”, “hacking”)`也被編碼為ASCII(或者說,UTF-8),與之前類似。 + +與[圖4-1](img/fig4-1.png)相比,最大的區別是沒有欄位名`(userName, favoriteNumber, interest)`。相反,編碼資料包含欄位標籤,它們是數字`(1, 2和3)`。這些是模式定義中出現的數字。欄位標記就像欄位的別名 - 它們是說我們正在談論的欄位的一種緊湊的方式,而不必拼出欄位名稱。 + +Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4-3](img/fig4-3.png)所示,它只將相同的資訊打包成只有34個位元組。它透過將欄位型別和標籤號打包到單個位元組中,並使用可變長度整數來實現。數字1337不是使用全部八個位元組,而是用兩個位元組編碼,每個位元組的最高位用來指示是否還有更多的位元組來。這意味著-64到63之間的數字被編碼為一個位元組,-8192和8191之間的數字以兩個位元組編碼,等等。較大的數字使用更多的位元組。 + +![](img/fig4-3.png) + +**圖4-3 使用Thrift壓縮協議編碼的記錄** + +最後,Protocol Buffers(只有一種二進位制編碼格式)對相同的資料進行編碼,如[圖4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但與Thrift的CompactProtocol非常相似。 Protobuf將同樣的記錄塞進了33個位元組中。 + +![](img/fig4-4.png) + +**圖4-4 使用Protobuf編碼的記錄** + +需要注意的一個細節:在前面所示的模式中,每個欄位被標記為必需或可選,但是這對欄位如何編碼沒有任何影響(二進位制資料中沒有任何欄位指示是否需要欄位)。所不同的是,如果未設定該欄位,則所需的執行時檢查將失敗,這對於捕獲錯誤非常有用。 + +#### 欄位標籤和模式演變 + +我們之前說過,模式不可避免地需要隨著時間而改變。我們稱之為模式演變。 Thrift和Protocol Buffers如何處理模式更改,同時保持向後相容性? + +從示例中可以看出,編碼的記錄就是其編碼欄位的拼接。每個欄位由其標籤號碼(樣本模式中的數字1,2,3)標識,並用資料型別(例如字串或整數)註釋。如果沒有設定欄位值,則簡單地從編碼記錄中省略。從中可以看到,欄位標記對編碼資料的含義至關重要。您可以更改架構中欄位的名稱,因為編碼的資料永遠不會引用欄位名稱,但不能更改欄位的標記,因為這會使所有現有的編碼資料無效。 + +您可以新增新的欄位到架構,只要您給每個欄位一個新的標籤號碼。如果舊的程式碼(不知道你新增的新的標籤號碼)試圖讀取新程式碼寫入的資料,包括一個新的欄位,其標籤號碼不能識別,它可以簡單地忽略該欄位。資料型別註釋允許解析器確定需要跳過的位元組數。這保持了前向相容性:舊程式碼可以讀取由新程式碼編寫的記錄。 + +向後相容性呢?只要每個欄位都有一個唯一的標籤號碼,新的程式碼總是可以讀取舊的資料,因為標籤號碼仍然具有相同的含義。唯一的細節是,如果你新增一個新的領域,你不能要求。如果您要新增一個欄位並將其設定為必需,那麼如果新程式碼讀取舊程式碼寫入的資料,則該檢查將失敗,因為舊程式碼不會寫入您新增的新欄位。因此,為了保持向後相容性,在模式的初始部署之後 **新增的每個欄位必須是可選的或具有預設值**。 + +刪除一個欄位就像新增一個欄位,倒退和向前相容性問題相反。這意味著您只能刪除一個可選的欄位(必填欄位永遠不能刪除),而且您不能再次使用相同的標籤號碼(因為您可能仍然有資料寫在包含舊標籤號碼的地方,而該欄位必須被新程式碼忽略)。 + +#### 資料型別和模式演變 + +如何改變欄位的資料型別?這可能是可能的——檢查檔案的細節——但是有一個風險,值將失去精度或被扼殺。例如,假設你將一個32位的整數變成一個64位的整數。新程式碼可以輕鬆讀取舊程式碼寫入的資料,因為解析器可以用零填充任何缺失的位。但是,如果舊程式碼讀取由新程式碼寫入的資料,則舊程式碼仍使用32位變數來儲存該值。如果解碼的64位值不適合32位,則它將被截斷。 + +Protobuf的一個奇怪的細節是,它沒有列表或陣列資料型別,而是有一個欄位的重複標記(這是第三個選項旁邊必要和可選)。如[圖4-4](img/fig4-4.png)所示,重複欄位的編碼正如它所說的那樣:同一個欄位標記只是簡單地出現在記錄中。這具有很好的效果,可以將可選(單值)欄位更改為重複(多值)欄位。讀取舊資料的新程式碼會看到一個包含零個或一個元素的列表(取決於該欄位是否存在)。讀取新資料的舊程式碼只能看到列表的最後一個元素。 + +Thrift有一個專用的列表資料型別,它使用列表元素的資料型別進行引數化。這不允許Protocol Buffers所做的從單值到多值的相同演變,但是它具有支援巢狀列表的優點。 + +### Avro + +Apache Avro 【20】是另一種二進位制編碼格式,與Protocol Buffers和Thrift有趣的不同。 它是作為Hadoop的一個子專案在2009年開始的,因為Thrift不適合Hadoop的用例【21】。 + +Avro也使用模式來指定正在編碼的資料的結構。 它有兩種模式語言:一種(Avro IDL)用於人工編輯,一種(基於JSON),更易於機器讀取。 + +我們用Avro IDL編寫的示例模式可能如下所示: + +```c +record Person { + string userName; + union { null, long } favoriteNumber = null; + array interests; +} +``` + +等價的JSON表示: + +```json +{ + "type": "record", + "name": "Person", + "fields": [ + {"name": "userName", "type": "string"}, + {"name": "favoriteNumber", "type": ["null", "long"], "default": null}, + {"name": "interests", "type": {"type": "array", "items": "string"} + ] +} +``` + +首先,請注意架構中沒有標籤號碼。 如果我們使用這個模式編碼我們的例子記錄([例4-1]()),Avro二進位制編碼只有32個位元組長,這是我們所見過的所有編碼中最緊湊的。 編碼位元組序列的分解如[圖4-5](img/fig4-5.png)所示。 + +如果您檢查位元組序列,您可以看到沒有什麼可以識別字段或其資料型別。 編碼只是由連在一起的值組成。 一個字串只是一個長度字首,後跟UTF-8位元組,但是在被包含的資料中沒有任何內容告訴你它是一個字串。 它可以是一個整數,也可以是其他的整數。 整數使用可變長度編碼(與Thrift的CompactProtocol相同)進行編碼。 + +![](img/fig4-5.png) + +**圖4-5 使用Avro編碼的記錄** + +為了解析二進位制資料,您按照它們出現在架構中的順序遍歷這些欄位,並使用架構來告訴您每個欄位的資料型別。這意味著如果讀取資料的程式碼使用與寫入資料的程式碼完全相同的模式,則只能正確解碼二進位制資料。閱讀器和作者之間的模式不匹配意味著錯誤地解碼資料。 + +那麼,Avro如何支援模式演變呢? + +#### 作者模式與讀者模式 + +有了Avro,當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式編碼資料,例如,架構可能被編譯到應用程式中。這被稱為作者的模式。 + +當一個應用程式想要解碼一些資料(從一個檔案或資料庫讀取資料,從網路接收資料等)時,它希望資料在某個模式中,這就是讀者的模式。這是應用程式程式碼所依賴的模式,在應用程式的構建過程中,程式碼可能是從該模式生成的。 + +Avro的關鍵思想是作者的模式和讀者的模式不必是相同的 - 他們只需要相容。當資料解碼(讀取)時,Avro庫透過並排檢視作者的模式和讀者的模式並將資料從作者的模式轉換到讀者的模式來解決差異。 Avro規範【20】確切地定義了這種解析的工作原理,如[圖4-6](img/fig4-6.png)所示。 + +例如,如果作者的模式和讀者的模式的欄位順序不同,這是沒有問題的,因為模式解析透過欄位名匹配欄位。如果讀取資料的程式碼遇到出現在作者模式中但不在讀者模式中的欄位,則忽略它。如果讀取資料的程式碼需要某個欄位,但是作者的模式不包含該名稱的欄位,則使用在讀者模式中宣告的預設值填充。 + +![](img/fig4-6.png) + +**圖4-6 一個Avro Reader解決讀寫模式的差異** + +#### 模式演變規則 + +使用Avro,向前相容性意味著您可以將新版本的架構作為編寫器,並將舊版本的架構作為讀者。相反,向後相容意味著你可以有一個作為讀者的新版本的模式和作為作者的舊版本。 + +為了保持相容性,您只能新增或刪除具有預設值的欄位。 (我們的Avro模式中的欄位`favourNumber`的預設值為`null`)。例如,假設您新增一個預設值的欄位,所以這個新的欄位存在於新的模式中,而不是舊的。當使用新模式的閱讀器讀取使用舊模式寫入的記錄時,將為缺少的欄位填充預設值。 + +如果你要新增一個沒有預設值的欄位,新的閱讀器將無法讀取舊作者寫的資料,所以你會破壞向後相容性。如果您要刪除沒有預設值的欄位,舊的閱讀器將無法讀取新作者寫入的資料,因此您會打破相容性。在一些程式語言中,null是任何變數可以接受的預設值,但在Avro中並不是這樣:如果要允許一個欄位為`null`,則必須使用聯合型別。例如,`union {null,long,string}`欄位;表示該欄位可以是數字或字串,也可以是`null`。如果它是union的分支之一,那麼只能使用null作為預設值[^iv]。這比預設情況下可以為`null`是更加冗長的,但是透過明確什麼可以和不可以是什麼,有助於防止錯誤的`null` 【22】。 + +[^iv]: 確切地說,預設值必須是聯合的第一個分支的型別,儘管這是Avro的特定限制,而不是聯合型別的一般特徵。 + +因此,Avro沒有像Protocol Buffers和Thrift那樣的`optional`和`required`標記(它有聯合型別和預設值)。 + +只要Avro可以轉換型別,就可以改變欄位的資料型別。更改欄位的名稱是可能的,但有點棘手:讀者的模式可以包含欄位名稱的別名,所以它可以匹配舊作家的模式欄位名稱與別名。這意味著更改欄位名稱是向後相容的,但不能向前相容。同樣,向聯合型別新增分支也是向後相容的,但不能向前相容。 + +##### 但作者模式到底是什麼? + +到目前為止,我們已經討論了一個重要的問題:讀者如何知道作者的模式是哪一部分資料被編碼的?我們不能只將整個模式包括在每個記錄中,因為模式可能比編碼的資料大得多,從而使二進位制編碼節省的所有空間都是徒勞的。 +答案取決於Avro使用的上下文。舉幾個例子: + +* 有很多記錄的大檔案 + + Avro的一個常見用途 - 尤其是在Hadoop環境中 - 用於儲存包含數百萬條記錄的大檔案,所有記錄都使用相同的模式進行編碼。 (我們將在[第10章](ch10.md)討論這種情況。)在這種情況下,該檔案的作者可以在檔案的開頭只包含一次作者的模式。 Avro指定一個檔案格式(物件容器檔案)來做到這一點。 + +* 支援獨立寫入的記錄的資料庫 + + 在一個數據庫中,不同的記錄可能會在不同的時間點使用不同的作者的模式編寫 - 你不能假定所有的記錄都有相同的模式。最簡單的解決方案是在每個編碼記錄的開始處包含一個版本號,並在資料庫中保留一個模式版本列表。讀者可以獲取記錄,提取版本號,然後從資料庫中獲取該版本號的作者模式。使用該作者的模式,它可以解碼記錄的其餘部分。 (例如Espresso 【23】就是這樣工作的。) + +* 透過網路連線傳送記錄 + + 當兩個程序透過雙向網路連線進行通訊時,他們可以在連線設定上協商模式版本,然後在連線的生命週期中使用該模式。 Avro RPC協議(參閱“[透過服務的資料流:REST和RPC](#透過服務的資料流:REST和RPC)”)如此工作。 + +具有模式版本的資料庫在任何情況下都是非常有用的,因為它充當文件併為您提供了檢查模式相容性的機會【24】。作為版本號,你可以使用一個簡單的遞增整數,或者你可以使用模式的雜湊。 + +#### 動態生成的模式 + +與Protocol Buffers和Thrift相比,Avro方法的一個優點是架構不包含任何標籤號碼。但為什麼這很重要?在模式中保留一些數字有什麼問題? + +不同之處在於Avro對動態生成的模式更友善。例如,假如你有一個關係資料庫,你想要把它的內容轉儲到一個檔案中,並且你想使用二進位制格式來避免前面提到的文字格式(JSON,CSV,SQL)的問題。如果你使用Avro,你可以很容易地從關係模式生成一個Avro模式(在我們之前看到的JSON表示中),並使用該模式對資料庫內容進行編碼,並將其全部轉儲到Avro物件容器檔案【25】中。您為每個資料庫表生成一個記錄模式,每個列成為該記錄中的一個欄位。資料庫中的列名稱對映到Avro中的欄位名稱。 + +現在,如果資料庫模式發生變化(例如,一個表中添加了一列,刪除了一列),則可以從更新的資料庫模式生成新的Avro模式,並在新的Avro模式中匯出資料。資料匯出過程不需要注意模式的改變 - 每次執行時都可以簡單地進行模式轉換。任何讀取新資料檔案的人都會看到記錄的欄位已經改變,但是由於欄位是透過名字來標識的,所以更新的作者的模式仍然可以與舊的讀者模式匹配。 + +相比之下,如果您為此使用Thrift或Protocol Buffers,則欄位標記可能必須手動分配:每次資料庫模式更改時,管理員都必須手動更新從資料庫列名到欄位標籤。 (這可能會自動化,但模式生成器必須非常小心,不要分配以前使用的欄位標記。)這種動態生成的模式根本不是Thrift或Protocol Buffers的設計目標,而是為Avro。 + +#### 程式碼生成和動態型別的語言 + +Thrift和Protobuf依賴於程式碼生成:在定義了模式之後,可以使用您選擇的程式語言生成實現此模式的程式碼。這在Java,C ++或C#等靜態型別語言中很有用,因為它允許將高效的記憶體中結構用於解碼的資料,並且在編寫訪問資料結構的程式時允許在IDE中進行型別檢查和自動完成。 + +在動態型別程式語言(如JavaScript,Ruby或Python)中,生成程式碼沒有太多意義,因為沒有編譯時型別檢查器來滿足。程式碼生成在這些語言中經常被忽視,因為它們避免了明確的編譯步驟。而且,對於動態生成的模式(例如從資料庫表生成的Avro模式),程式碼生成對獲取資料是一個不必要的障礙。 + +Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是它也可以在不生成任何程式碼的情況下使用。如果你有一個物件容器檔案(它嵌入了作者的模式),你可以簡單地使用Avro庫開啟它,並以與檢視JSON檔案相同的方式檢視資料。該檔案是自描述的,因為它包含所有必要的元資料。 + +這個屬性特別適用於動態型別的資料處理語言如Apache Pig 【26】。在Pig中,您可以開啟一些Avro檔案,開始分析它們,並編寫派生資料集以Avro格式輸出檔案,而無需考慮模式。 + +### 模式的優點 + +正如我們所看到的,Protocol Buffers,Thrift和Avro都使用模式來描述二進位制編碼格式。他們的模式語言比XML模式或者JSON模式簡單得多,它支援更詳細的驗證規則(例如,“該欄位的字串值必須與該正則表示式匹配”或“該欄位的整數值必須在0和100之間“)。由於Protocol Buffers,Thrift和Avro實現起來更簡單,使用起來也更簡單,所以它們已經發展到支援相當廣泛的程式語言。 + +這些編碼所基於的想法絕不是新的。例如,它們與ASN.1有很多相似之處,它是1984年首次被標準化的模式定義語言【27】。它被用來定義各種網路協議,其二進位制編碼(DER)仍然被用於編碼SSL證書(X.509),例如【28】。 ASN.1支援使用標籤號碼的模式演進,類似於Protocol Buf-fers和Thrift 【29】。然而,這也是非常複雜和嚴重的檔案記錄,所以ASN.1可能不是新應用程式的好選擇。 + +許多資料系統也為其資料實現某種專有的二進位制編碼。例如,大多數關係資料庫都有一個網路協議,您可以透過該協議向資料庫傳送查詢並獲取響應。這些協議通常特定於特定的資料庫,並且資料庫供應商提供將來自資料庫的網路協議的響應解碼為記憶體資料結構的驅動程式(例如使用ODBC或JDBC API)。 + +所以,我們可以看到,儘管JSON,XML和CSV等文字資料格式非常普遍,但基於模式的二進位制編碼也是一個可行的選擇。他們有一些很好的屬性: + +* 它們可以比各種“二進位制JSON”變體更緊湊,因為它們可以省略編碼資料中的欄位名稱。 +* 模式是一種有價值的文件形式,因為模式是解碼所必需的,所以可以確定它是最新的(而手動維護的文件可能很容易偏離現實)。 +* 保留模式資料庫允許您在部署任何內容之前檢查模式更改的向前和向後相容性。 +* 對於靜態型別程式語言的使用者來說,從模式生成程式碼的能力是有用的,因為它可以在編譯時進行型別檢查。 + +總而言之,模式進化允許與JSON資料庫提供的無模式/模式讀取相同的靈活性(請參閱第39頁的“文件模型中的模式靈活性”),同時還可以更好地保證資料和更好的工具。 + + + +## 資料流的型別 + +在本章的開始部分,我們曾經說過,無論何時您想要將某些資料傳送到不共享記憶體的另一個程序,例如,只要您想透過網路傳送資料或將其寫入檔案,就需要將它編碼為一個位元組序列。然後我們討論了做這個的各種不同的編碼。 +我們討論了向前和向後的相容性,這對於可演化性來說非常重要(透過允許您獨立升級系統的不同部分,而不必一次改變所有內容,可以輕鬆地進行更改)。相容性是編碼資料的一個程序和解碼它的另一個程序之間的一種關係。 + +這是一個相當抽象的概念 - 資料可以透過多種方式從一個流程流向另一個流程。誰編碼資料,誰解碼?在本章的其餘部分中,我們將探討資料如何在流程之間流動的一些最常見的方式: + +* 透過資料庫(參閱“[透過資料庫的資料流](#透過資料庫的資料流)”) +* 透過服務呼叫(參閱“[透過服務傳輸資料流:REST和RPC](#透過服務傳輸資料流:REST和RPC)”) +* 透過非同步訊息傳遞(參閱“[訊息傳遞資料流](#訊息傳遞資料流)”) + + + +### 資料庫中的資料流 + +在資料庫中,寫入資料庫的過程對資料進行編碼,從資料庫讀取的過程對資料進行解碼。可能只有一個程序訪問資料庫,在這種情況下,讀者只是相同程序的後續版本 - 在這種情況下,您可以考慮將資料庫中的內容儲存為向未來的自我傳送訊息。 + +向後相容性顯然是必要的。否則你未來的自己將無法解碼你以前寫的東西。 + +一般來說,幾個不同的程序同時訪問資料庫是很常見的。這些程序可能是幾個不同的應用程式或服務,或者它們可能只是幾個相同服務的例項(為了可擴充套件性或容錯性而並行執行)。無論哪種方式,在應用程式發生變化的環境中,訪問資料庫的某些程序可能會執行較新的程式碼,有些程序可能會執行較舊的程式碼,例如,因為新版本當前正在部署在滾動升級,所以有些例項已經更新,而其他例項尚未更新。 + +這意味著資料庫中的一個值可能會被更新版本的程式碼寫入,然後被仍舊執行的舊版本的程式碼讀取。因此,資料庫也經常需要向前相容。 + +但是,還有一個額外的障礙。假設您將一個欄位新增到記錄模式,並且較新的程式碼將該新欄位的值寫入資料庫。隨後,舊版本的程式碼(尚不知道新欄位)將讀取記錄,更新記錄並將其寫回。在這種情況下,理想的行為通常是舊程式碼保持新的領域完整,即使它不能被解釋。 + +前面討論的編碼格式支援未知域的儲存,但是有時候需要在應用程式層面保持謹慎,如圖4-7所示。例如,如果將資料庫值解碼為應用程式中的模型物件,稍後重新編碼這些模型物件,那麼未知欄位可能會在該翻譯過程中丟失。 + +解決這個問題不是一個難題,你只需要意識到它。 + +![](img/fig4-7.png) + +**圖4-7 當較舊版本的應用程式更新以前由較新版本的應用程式編寫的資料時,如果不小心,資料可能會丟失。** + +#### 在不同的時間寫入不同的值 + +資料庫通常允許任何時候更新任何值。這意味著在一個單一的資料庫中,可能有一些值是五毫秒前寫的,而一些值是五年前寫的。 + +在部署應用程式的新版本(至少是伺服器端應用程式)時,您可能會在幾分鐘內完全用新版本替換舊版本。資料庫內容也是如此:五年前的資料仍然存在於原始編碼中,除非您已經明確地重寫了它。這種觀察有時被總結為資料超出程式碼。 + +將資料重寫(遷移)到一個新的模式當然是可能的,但是在一個大資料集上執行是一個昂貴的事情,所以大多數資料庫如果可能的話就避免它。大多數關係資料庫都允許簡單的模式更改,例如新增一個預設值為空的新列,而不重寫現有資料[^v]讀取舊行時,資料庫將填充編碼資料中缺少的任何列的空值在磁碟上。 LinkedIn的文件資料庫Espresso使用Avro儲存,允許它使用Avro的模式演變規則【23】。 + +因此,架構演變允許整個資料庫看起來好像是用單個模式編碼的,即使底層儲存可能包含用模式的各種歷史版本編碼的記錄。 + +[^v]: 除了MySQL,即使並非真的必要,它也經常會重寫整個表,正如“[文件模型中的架構靈活性](ch3.md#文件模型中的靈活性)”中所提到的。 + + + +#### 歸檔儲存 + +也許您不時為資料庫建立一個快照,例如備份或載入到資料倉庫(參閱“[資料倉庫](ch3.md#資料倉庫)”)。在這種情況下,即使源資料庫中的原始編碼包含來自不同時代的模式版本的混合,資料轉儲通常也將使用最新模式進行編碼。既然你正在複製資料,那麼你可能會一直對資料的副本進行編碼。 + +由於資料轉儲是一次寫入的,而且以後是不可變的,所以Avro物件容器檔案等格式非常適合。這也是一個很好的機會,可以將資料編碼為面向分析的列式格式,例如Parquet(請參閱第97頁的“[列壓縮](ch3.md#列壓縮)”)。 + +在[第10章](ch10.md)中,我們將詳細討論在檔案儲存中使用資料。 + + + +### 服務中的資料流:REST與RPC + +當您需要透過網路進行通訊的程序時,安排該通訊的方式有幾種。最常見的安排是有兩個角色:客戶端和伺服器。伺服器透過網路公開API,並且客戶端可以連線到伺服器以向該API發出請求。伺服器公開的API被稱為服務。 + +Web以這種方式工作:客戶(Web瀏覽器)向Web伺服器發出請求,使GET請求下載HTML,CSS,JavaScript,影象等,並向POST請求提交資料到伺服器。 API包含一組標準的協議和資料格式(HTTP,URL,SSL/TLS,HTML等)。由於網路瀏覽器,網路伺服器和網站作者大多同意這些標準,您可以使用任何網路瀏覽器訪問任何網站(至少在理論上!)。 + +Web瀏覽器不是唯一的客戶端型別。例如,在移動裝置或桌面計算機上執行的本地應用程式也可以向伺服器發出網路請求,並且在Web瀏覽器內執行的客戶端JavaScript應用程式可以使用XMLHttpRequest成為HTTP客戶端(該技術被稱為Ajax 【30】)。在這種情況下,伺服器的響應通常不是用於顯示給人的HTML,而是用於便於客戶端應用程式程式碼(如JSON)進一步處理的編碼資料。儘管HTTP可能被用作傳輸協議,但頂層實現的API是特定於應用程式的,客戶端和伺服器需要就該API的細節達成一致。 + +此外,伺服器本身可以是另一個服務的客戶端(例如,典型的Web應用伺服器充當資料庫的客戶端)。這種方法通常用於將大型應用程式按照功能區域分解為較小的服務,這樣當一個服務需要來自另一個服務的某些功能或資料時,就會向另一個服務發出請求。這種構建應用程式的方式傳統上被稱為**面向服務的體系結構(service-oriented architecture,SOA)**,最近被改進和更名為**微服務架構**【31,32】。 + +在某些方面,服務類似於資料庫:它們通常允許客戶端提交和查詢資料。但是,雖然資料庫允許使用我們在第2章 中討論的查詢語言進行任意查詢,但是服務公開了一個特定於應用程式的API,它只允許由服務的業務邏輯(應用程式程式碼)預定的輸入和輸出【33】。這種限制提供了一定程度的封裝:服務可以對客戶可以做什麼和不可以做什麼施加細粒度的限制。 + +面向服務/微服務架構的一個關鍵設計目標是透過使服務獨立部署和演化來使應用程式更易於更改和維護。例如,每個服務應該由一個團隊擁有,並且該團隊應該能夠經常釋出新版本的服務,而不必與其他團隊協調。換句話說,我們應該期望伺服器和客戶端的舊版本和新版本同時執行,因此伺服器和客戶端使用的資料編碼必須在不同版本的服務API之間相容——正是我們所做的本章一直在談論。 + +#### Web服務 + +**當服務使用HTTP作為底層通訊協議時,可稱之為Web服務**。這可能是一個小錯誤,因為Web服務不僅在Web上使用,而且在幾個不同的環境中使用。例如: + +1. 執行在使用者裝置上的客戶端應用程式(例如,移動裝置上的本地應用程式,或使用Ajax的JavaScript web應用程式)透過HTTP向服務發出請求。這些請求通常透過公共網際網路進行。 +2. 一種服務向同一組織擁有的另一項服務提出請求,這些服務通常位於同一資料中心內,作為面向服務/微型架構的一部分。 (支援這種用例的軟體有時被稱為 **中介軟體(middleware)** ) +3. 一種服務透過網際網路向不同組織所擁有的服務提出請求。這用於不同組織後端系統之間的資料交換。此類別包括由線上服務(如信用卡處理系統)提供的公共API,或用於共享訪問使用者資料的OAuth。 + +有兩種流行的Web服務方法:REST和SOAP。他們在哲學方面幾乎是截然相反的,往往是各自支持者之間的激烈辯論(即使在每個陣營內也有很多爭論。 例如,**HATEOAS(超媒體作為應用程式狀態的引擎)**經常引發討論【35】。) + +REST不是一個協議,而是一個基於HTTP原則的設計哲學【34,35】。它強調簡單的資料格式,使用URL來標識資源,並使用HTTP功能進行快取控制,身份驗證和內容型別協商。與SOAP相比,REST已經越來越受歡迎,至少在跨組織服務整合的背景下【36】,並經常與微服務相關[31]。根據REST原則設計的API稱為RESTful。 + +相比之下,SOAP是用於製作網路API請求的基於XML的協議( 儘管首字母縮寫詞相似,SOAP並不是SOA的要求。 SOAP是一種特殊的技術,而SOA是構建系統的一般方法。)。雖然它最常用於HTTP,但其目的是獨立於HTTP,並避免使用大多數HTTP功能。相反,它帶有龐大而複雜的多種相關標準(Web服務框架,稱為`WS-*`),它們增加了各種功能【37】。 + +SOAP Web服務的API使用稱為Web服務描述語言(WSDL)的基於XML的語言來描述。 WSDL支援程式碼生成,客戶端可以使用本地類和方法呼叫(編碼為XML訊息並由框架再次解碼)訪問遠端服務。這在靜態型別程式語言中非常有用,但在動態型別程式語言中很少(參閱“[程式碼生成和動態型別化語言](#程式碼生成和動態型別化語言)”)。 + +由於WSDL的設計不是人類可讀的,而且由於SOAP訊息通常是手動構建的過於複雜,所以SOAP的使用者在很大程度上依賴於工具支援,程式碼生成和IDE【38】。對於SOAP供應商不支援的程式語言的使用者來說,與SOAP服務的整合是困難的。 + +儘管SOAP及其各種擴充套件表面上是標準化的,但是不同廠商的實現之間的互操作性往往會造成問題【39】。由於所有這些原因,儘管許多大型企業仍然使用SOAP,但在大多數小公司中已經不再受到青睞。 + +REST風格的API傾向於更簡單的方法,通常涉及較少的程式碼生成和自動化工具。定義格式(如OpenAPI,也稱為Swagger 【40】)可用於描述RESTful API並生成文件。 + +#### 遠端過程呼叫(RPC)的問題 + +Web服務僅僅是透過網路進行API請求的一系列技術的最新版本,其中許多技術受到了大量的炒作,但是存在嚴重的問題。 Enterprise JavaBeans(EJB)和Java的**遠端方法呼叫(RMI)**僅限於Java。**分散式元件物件模型(DCOM)**僅限於Microsoft平臺。**公共物件請求代理體系結構(CORBA)**過於複雜,不提供前向或後向相容性【41】。 + +所有這些都是基於 **遠端過程呼叫(RPC)** 的思想,該過程呼叫自20世紀70年代以來一直存在【42】。 RPC模型試圖向遠端網路服務發出請求,看起來與在同一程序中呼叫程式語言中的函式或方法相同(這種抽象稱為位置透明)。儘管RPC起初看起來很方便,但這種方法根本上是有缺陷的【43,44】。網路請求與本地函式呼叫非常不同: + +* 本地函式呼叫是可預測的,並且成功或失敗,這僅取決於受您控制的引數。網路請求是不可預知的:由於網路問題,請求或響應可能會丟失,或者遠端計算機可能很慢或不可用,這些問題完全不在您的控制範圍之內。網路問題是常見的,所以你必須預測他們,例如透過重試失敗的請求。 +* 本地函式呼叫要麼返回結果,要麼丟擲異常,或者永遠不返回(因為進入無限迴圈或程序崩潰)。網路請求有另一個可能的結果:由於超時,它可能會返回沒有結果。在這種情況下,你根本不知道發生了什麼:如果你沒有得到來自遠端服務的響應,你無法知道請求是否透過。 (我們將在[第8章](ch8.md)更詳細地討論這個問題。) +* 如果您重試失敗的網路請求,可能會發生請求實際上正在透過,只有響應丟失。在這種情況下,重試將導致該操作被執行多次,除非您在協議中引入除重( **冪等(idempotence)**)機制。本地函式呼叫沒有這個問題。 (在[第十一章](ch11.md)更詳細地討論冪等性) +* 每次呼叫本地功能時,通常需要大致相同的時間來執行。網路請求比函式呼叫要慢得多,而且其延遲也是非常可變的:在不到一毫秒的時間內它可能會完成,但是當網路擁塞或者遠端服務超載時,可能需要幾秒鐘的時間完全一樣的東西。 +* 呼叫本地函式時,可以高效地將引用(指標)傳遞給本地記憶體中的物件。當你發出一個網路請求時,所有這些引數都需要被編碼成可以透過網路傳送的一系列位元組。沒關係,如果引數是像數字或字串這樣的基本型別,但是對於較大的物件很快就會變成問題。 + +客戶端和服務可以用不同的程式語言實現,所以RPC框架必須將資料型別從一種語言翻譯成另一種語言。這可能會捅出大簍子,因為不是所有的語言都具有相同的型別 —— 例如回想一下JavaScript的數字大於$2^{53}$的問題(參閱“[JSON,XML和二進位制變體](#JSON,XML和二進位制變體)”)。用單一語言編寫的單個程序中不存在此問題。 + +所有這些因素意味著嘗試使遠端服務看起來像程式語言中的本地物件一樣毫無意義,因為這是一個根本不同的事情。 REST的部分吸引力在於,它並不試圖隱藏它是一個網路協議的事實(儘管這似乎並沒有阻止人們在REST之上構建RPC庫)。 + +#### RPC的當前方向 + +儘管有這樣那樣的問題,RPC不會消失。在本章提到的所有編碼的基礎上構建了各種RPC框架:例如,Thrift和Avro帶有RPC支援,gRPC是使用Protocol Buffers的RPC實現,Finagle也使用Thrift,Rest.li使用JSON over HTTP。 + +這種新一代的RPC框架更加明確的是,遠端請求與本地函式呼叫不同。例如,Finagle和Rest.li 使用futures(promises)來封裝可能失敗的非同步操作。`Futures`還可以簡化需要並行發出多項服務的情況,並將其結果合併【45】。 gRPC支援流,其中一個呼叫不僅包括一個請求和一個響應,還包括一系列的請求和響應【46】。 + +其中一些框架還提供服務發現,即允許客戶端找出在哪個IP地址和埠號上可以找到特定的服務。我們將在“[請求路由](ch6.md#請求路由)”中回到這個主題。 + +使用二進位制編碼格式的自定義RPC協議可以實現比通用的JSON over REST更好的效能。但是,RESTful API還有其他一些顯著的優點:對於實驗和除錯(只需使用Web瀏覽器或命令列工具curl,無需任何程式碼生成或軟體安裝即可向其請求),它是受支援的所有的主流程式語言和平臺,還有大量可用的工具(伺服器,快取,負載平衡器,代理,防火牆,監控,除錯工具,測試工具等)的生態系統。由於這些原因,REST似乎是公共API的主要風格。 RPC框架的主要重點在於同一組織擁有的服務之間的請求,通常在同一資料中心內。 + +#### 資料編碼與RPC的演化 + +對於可演化性,重要的是可以獨立更改和部署RPC客戶端和伺服器。與透過資料庫流動的資料相比(如上一節所述),我們可以在透過服務進行資料流的情況下做一個簡化的假設:假定所有的伺服器都會先更新,其次是所有的客戶端。因此,您只需要在請求上具有向後相容性,並且對響應具有前向相容性。 + +RPC方案的前後向相容性屬性從它使用的編碼方式中繼承 + +* Thrift,gRPC(Protobuf)和Avro RPC可以根據相應編碼格式的相容性規則進行演變。 +* 在SOAP中,請求和響應是使用XML模式指定的。這些可以演變,但有一些微妙的陷阱【47】。 +* RESTful API通常使用JSON(沒有正式指定的模式)用於響應,以及用於請求的JSON或URI編碼/表單編碼的請求引數。新增可選的請求引數並向響應物件新增新的欄位通常被認為是保持相容性的改變。 + +由於RPC經常被用於跨越組織邊界的通訊,所以服務的相容性變得更加困難,因此服務的提供者經常無法控制其客戶,也不能強迫他們升級。因此,需要長期保持相容性,也許是無限期的。如果需要進行相容性更改,則服務提供商通常會並排維護多個版本的服務API。 + +關於API版本化應該如何工作(即,客戶端如何指示它想要使用哪個版本的API)沒有一致意見【48】)。對於RESTful API,常用的方法是在URL或HTTP Accept頭中使用版本號。對於使用API金鑰來標識特定客戶端的服務,另一種選擇是將客戶端請求的API版本儲存在伺服器上,並允許透過單獨的管理介面更新該版本選項【49】。 + +### 訊息傳遞中的資料流 + +我們一直在研究從一個過程到另一個過程的編碼資料流的不同方式。到目前為止,我們已經討論了REST和RPC(其中一個程序透過網路向另一個程序傳送請求並期望儘可能快的響應)以及資料庫(一個程序寫入編碼資料,另一個程序在將來再次讀取)。 + +在最後一節中,我們將簡要介紹一下RPC和資料庫之間的非同步訊息傳遞系統。它們與RPC類似,因為客戶端的請求(通常稱為訊息)以低延遲傳送到另一個程序。它們與資料庫類似,不是透過直接的網路連線傳送訊息,而是透過稱為訊息代理(也稱為訊息佇列或面向訊息的中介軟體)的中介來臨時儲存訊息。 + +與直接RPC相比,使用訊息代理有幾個優點: + +* 如果收件人不可用或過載,可以充當緩衝區,從而提高系統的可靠性。 +* 它可以自動將訊息重新發送到已經崩潰的程序,從而防止訊息丟失。 +* 避免發件人需要知道收件人的IP地址和埠號(這在虛擬機器經常出入的雲部署中特別有用)。 +* 它允許將一條訊息傳送給多個收件人。 +* 將發件人與收件人邏輯分離(發件人只是釋出郵件,不關心使用者)。 + +然而,與RPC相比,差異在於訊息傳遞通訊通常是單向的:傳送者通常不期望收到其訊息的回覆。一個程序可能傳送一個響應,但這通常是在一個單獨的通道上完成的。這種通訊模式是非同步的:傳送者不會等待訊息被傳遞,而只是傳送它,然後忘記它。 + +#### 訊息掮客 + +過去,資訊掮客主要是TIBCO,IBM WebSphere和webMethods等公司的商業軟體的秀場。最近像RabbitMQ,ActiveMQ,HornetQ,NATS和Apache Kafka這樣的開源實現已經流行起來。我們將在[第11章](ch11.md)中對它們進行更詳細的比較。 + +詳細的交付語義因實現和配置而異,但通常情況下,訊息代理的使用方式如下:一個程序將訊息傳送到指定的佇列或主題,代理確保將訊息傳遞給一個或多個消費者或訂閱者到那個佇列或主題。在同一主題上可以有許多生產者和許多消費者。 + +一個主題只提供單向資料流。但是,消費者本身可能會將訊息釋出到另一個主題上(因此,可以將它們連結在一起,就像我們將在[第11章](ch11.md)中看到的那樣),或者傳送給原始訊息的傳送者使用的回覆佇列(允許請求/響應資料流,類似於RPC)。 + +訊息代理通常不會執行任何特定的資料模型 - 訊息只是包含一些元資料的位元組序列,因此您可以使用任何編碼格式。如果編碼是向後相容的,則您可以靈活地更改發行商和消費者的獨立編碼,並以任意順序進行部署。 + +如果消費者重新發布訊息到另一個主題,則可能需要小心保留未知欄位,以防止前面在資料庫環境中描述的問題([圖4-7](img/fig4-7.png))。 + +#### 分散式的Actor框架 + +Actor模型是單個程序中併發的程式設計模型。邏輯被封裝在角色中,而不是直接處理執行緒(以及競爭條件,鎖定和死鎖的相關問題)。每個角色通常代表一個客戶或實體,它可能有一些本地狀態(不與其他任何角色共享),它透過傳送和接收非同步訊息與其他角色通訊。訊息傳送不保證:在某些錯誤情況下,訊息將丟失。由於每個角色一次只能處理一條訊息,因此不需要擔心執行緒,每個角色可以由框架獨立排程。 + +在分散式的行為者框架中,這個程式設計模型被用來跨越多個節點來擴充套件應用程式。不管傳送方和接收方是在同一個節點上還是在不同的節點上,都使用相同的訊息傳遞機制。如果它們在不同的節點上,則該訊息被透明地編碼成位元組序列,透過網路傳送,並在另一側解碼。 + +位置透明在actor模型中比在RPC中效果更好,因為actor模型已經假定訊息可能會丟失,即使在單個程序中也是如此。儘管網路上的延遲可能比同一個程序中的延遲更高,但是在使用參與者模型時,本地和遠端通訊之間的基本不匹配是較少的。 + +分散式的Actor框架實質上是將訊息代理和角色程式設計模型整合到一個框架中。但是,如果要執行基於角色的應用程式的滾動升級,則仍然需要擔心向前和向後相容性問題,因為訊息可能會從執行新版本的節點發送到執行舊版本的節點,反之亦然。 + +三個流行的分散式actor框架處理訊息編碼如下: + +* 預設情況下,Akka使用Java的內建序列化,不提供前向或後向相容性。 但是,你可以用類似緩衝區的東西替代它,從而獲得滾動升級的能力【50】。 +* Orleans 預設使用不支援滾動升級部署的自定義資料編碼格式; 要部署新版本的應用程式,您需要設定一個新的群集,將流量從舊群集遷移到新群集,然後關閉舊群集【51,52】。 像Akka一樣,可以使用自定義序列化外掛。 +* 在Erlang OTP中,對記錄模式進行更改是非常困難的(儘管系統具有許多為高可用性設計的功能)。 滾動升級是可能的,但需要仔細計劃【53】。 一個新的實驗性的`maps`資料型別(2014年在Erlang R17中引入的類似於JSON的結構)可能使得這個資料型別在未來更容易【54】。 + + + + +## 本章小結 + +在本章中,我們研究了將資料結構轉換為網路中的位元組或磁碟上的位元組的幾種方法。我們看到了這些編碼的細節不僅影響其效率,更重要的是應用程式的體系結構和部署它們的選項。 + +特別是,許多服務需要支援滾動升級,其中新版本的服務逐步部署到少數節點,而不是同時部署到所有節點。滾動升級允許在不停機的情況下發布新版本的服務(從而鼓勵在罕見的大型版本上頻繁釋出小型版本),並使部署風險降低(允許在影響大量使用者之前檢測並回滾有故障的版本)。這些屬性對於可演化性,以及對應用程式進行更改的容易性都是非常有利的。 + +在滾動升級期間,或出於各種其他原因,我們必須假設不同的節點正在執行我們的應用程式程式碼的不同版本。因此,在系統周圍流動的所有資料都是以提供向後相容性(新程式碼可以讀取舊資料)和向前相容性(舊程式碼可以讀取新資料)的方式進行編碼是重要的。 + +我們討論了幾種資料編碼格式及其相容性屬性: + +* 程式語言特定的編碼僅限於單一程式語言,並且往往無法提供前向和後向相容性。 +* JSON,XML和CSV等文字格式非常普遍,其相容性取決於您如何使用它們。他們有可選的模式語言,這有時是有用的,有時是一個障礙。這些格式對於資料型別有些模糊,所以你必須小心數字和二進位制字串。 +* 像Thrift,Protocol Buffers和Avro這樣的二進位制模式驅動格式允許使用清晰定義的前向和後向相容性語義進行緊湊,高效的編碼。這些模式可以用於靜態型別語言的文件和程式碼生成。但是,他們有一個缺點,就是在資料可讀之前需要對資料進行解碼。 + +我們還討論了資料流的幾種模式,說明了資料編碼是重要的不同場景: + +* 資料庫,寫入資料庫的程序對資料進行編碼,並從資料庫讀取程序對其進行解碼 +* RPC和REST API,客戶端對請求進行編碼,伺服器對請求進行解碼並對響應進行編碼,客戶端最終對響應進行解碼 +* 非同步訊息傳遞(使用訊息代理或參與者),其中節點之間透過傳送訊息進行通訊,訊息由傳送者編碼並由接收者解碼 + +我們可以小心地得出這樣的結論:前向相容性和滾動升級在某種程度上是可以實現的。願您的應用程式的演變迅速、敏捷部署。 + + + +## 參考文獻 + + +1. “[Java Object Serialization Specification](http://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.html),” *docs.oracle.com*, 2010. + +1. “[Ruby 2.2.0 API Documentation](http://ruby-doc.org/core-2.2.0/),” *ruby-doc.org*, Dec 2014. + +1. “[The Python 3.4.3 Standard Library Reference Manual](https://docs.python.org/3/library/pickle.html),” *docs.python.org*, February 2015. + +1. “[EsotericSoftware/kryo](https://github.com/EsotericSoftware/kryo),” *github.com*, October 2014. + +1. “[CWE-502: Deserialization of Untrusted Data](http://cwe.mitre.org/data/definitions/502.html),” Common Weakness Enumeration, *cwe.mitre.org*, + July 30, 2014. + +1. Steve Breen: “[What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability](http://foxglovesecurity.com/2015/11/06/what-do-weblogic-websphere-jboss-jenkins-opennms-and-your-application-have-in-common-this-vulnerability/),” *foxglovesecurity.com*, November 6, 2015. + +1. Patrick McKenzie: “[What the Rails Security Issue Means for Your Startup](http://www.kalzumeus.com/2013/01/31/what-the-rails-security-issue-means-for-your-startup/),” *kalzumeus.com*, January 31, 2013. + +1. Eishay Smith: “[jvm-serializers wiki](https://github.com/eishay/jvm-serializers/wiki),” *github.com*, November 2014. + +1. “[XML Is a Poor Copy of S-Expressions](http://c2.com/cgi/wiki?XmlIsaPoorCopyOfEssExpressions),” *c2.com* wiki. + +1. Matt Harris: “[Snowflake: An Update and Some Very Important Information](https://groups.google.com/forum/#!topic/twitter-development-talk/ahbvo3VTIYI),” email to *Twitter Development Talk* mailing list, October 19, 2010. + +1. Shudi (Sandy) Gao, C. M. Sperberg-McQueen, and Henry S. Thompson: “[XML Schema 1.1](http://www.w3.org/XML/Schema),” W3C Recommendation, May 2001. + +1. Francis Galiegue, Kris Zyp, and Gary Court: “[JSON Schema](http://json-schema.org/),” IETF Internet-Draft, February 2013. + +1. Yakov Shafranovich: “[RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://tools.ietf.org/html/rfc4180),” October 2005. + +1. “[MessagePack Specification](http://msgpack.org/),” *msgpack.org*. Mark Slee, Aditya Agarwal, and Marc Kwiatkowski: “[Thrift: Scalable Cross-Language Services Implementation](http://thrift.apache.org/static/files/thrift-20070401.pdf),” Facebook technical report, April 2007. + +1. “[Protocol Buffers Developer Guide](https://developers.google.com/protocol-buffers/docs/overview),” Google, Inc., *developers.google.com*. + +1. Igor Anishchenko: “[Thrift vs Protocol Buffers vs Avro - Biased Comparison](http://www.slideshare.net/IgorAnishchenko/pb-vs-thrift-vs-avro),” *slideshare.net*, September 17, 2012. + +1. “[A Matrix of the Features Each Individual Language Library Supports](http://wiki.apache.org/thrift/LibraryFeatures),” *wiki.apache.org*. + +1. Martin Kleppmann: “[Schema Evolution in Avro, Protocol Buffers and Thrift](http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html),” *martin.kleppmann.com*, December 5, 2012. + +1. “[Apache Avro 1.7.7 Documentation](http://avro.apache.org/docs/1.7.7/),” *avro.apache.org*, July 2014. + +1. Doug Cutting, Chad Walters, Jim Kellerman, et al.: + “[[PROPOSAL] New Subproject: Avro](http://mail-archives.apache.org/mod_mbox/hadoop-general/200904.mbox/%3C49D53694.1050906@apache.org%3E),” email thread on *hadoop-general* mailing list, + *mail-archives.apache.org*, April 2009. + +1. Tony Hoare: “[Null References: The Billion Dollar Mistake](http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare),” at *QCon London*, March 2009. + +1. Aditya Auradkar and Tom Quiggle: “[Introducing Espresso—LinkedIn's Hot New Distributed Document Store](https://engineering.linkedin.com/espresso/introducing-espresso-linkedins-hot-new-distributed-document-store),” *engineering.linkedin.com*, January 21, 2015. + +1. Jay Kreps: “[Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2)](http://blog.confluent.io/2015/02/25/stream-data-platform-2/),” *blog.confluent.io*, February 25, 2015. + +1. Gwen Shapira: “[The Problem of Managing Schemas](http://radar.oreilly.com/2014/11/the-problem-of-managing-schemas.html),” *radar.oreilly.com*, November 4, 2014. + +1. “[Apache Pig 0.14.0 Documentation](http://pig.apache.org/docs/r0.14.0/),” *pig.apache.org*, November 2014. + +1. John Larmouth: [*ASN.1Complete*](http://www.oss.com/asn1/resources/books-whitepapers-pubs/larmouth-asn1-book.pdf). Morgan Kaufmann, 1999. ISBN: 978-0-122-33435-1 + +1. Russell Housley, Warwick Ford, Tim Polk, and David Solo: “[RFC 2459: Internet X.509 Public Key Infrastructure: Certificate and CRL Profile](https://www.ietf.org/rfc/rfc2459.txt),” IETF Network Working Group, Standards Track, + January 1999. + +1. Lev Walkin: “[Question: Extensibility and Dropping Fields](http://lionet.info/asn1c/blog/2010/09/21/question-extensibility-removing-fields/),” *lionet.info*, September 21, 2010. + +1. Jesse James Garrett: “[Ajax: A New Approach to Web Applications](http://www.adaptivepath.com/ideas/ajax-new-approach-web-applications/),” *adaptivepath.com*, February 18, 2005. + +1. Sam Newman: *Building Microservices*. O'Reilly Media, 2015. ISBN: 978-1-491-95035-7 + +1. Chris Richardson: “[Microservices: Decomposing Applications for Deployability and Scalability](http://www.infoq.com/articles/microservices-intro),” *infoq.com*, May 25, 2014. + +1. Pat Helland: “[Data on the Outside Versus Data on the Inside](http://cidrdb.org/cidr2005/papers/P12.pdf),” at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005. + +1. Roy Thomas Fielding: “[Architectural Styles and the Design of Network-Based Software Architectures](https://www.ics.uci.edu/~fielding/pubs/dissertation/fielding_dissertation.pdf),” PhD Thesis, University of California, Irvine, 2000. + +1. Roy Thomas Fielding: “[REST APIs Must Be Hypertext-Driven](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven),” *roy.gbiv.com*, October 20 2008. + +1. “[REST in Peace, SOAP](http://royal.pingdom.com/2010/10/15/rest-in-peace-soap/),” *royal.pingdom.com*, October 15, 2010. + +1. “[Web Services Standards as of Q1 2007](https://www.innoq.com/resources/ws-standards-poster/),” *innoq.com*, February 2007. + +1. Pete Lacey: “[The S Stands for Simple](http://harmful.cat-v.org/software/xml/soap/simple),” *harmful.cat-v.org*, November 15, 2006. + +1. Stefan Tilkov: “[Interview: Pete Lacey Criticizes Web Services](http://www.infoq.com/articles/pete-lacey-ws-criticism),” *infoq.com*, December 12, 2006. + +1. “[OpenAPI Specification (fka Swagger RESTful API Documentation Specification) Version 2.0](http://swagger.io/specification/),” *swagger.io*, September 8, 2014. + +1. Michi Henning: “[The Rise and Fall of CORBA](http://queue.acm.org/detail.cfm?id=1142044),” *ACM Queue*, volume 4, number 5, pages 28–34, June 2006. + [doi:10.1145/1142031.1142044](http://dx.doi.org/10.1145/1142031.1142044) + +1. Andrew D. Birrell and Bruce Jay Nelson: “[Implementing Remote Procedure Calls](http://www.cs.princeton.edu/courses/archive/fall03/cs518/papers/rpc.pdf),” *ACM Transactions on Computer Systems* (TOCS), volume 2, number 1, pages 39–59, February 1984. [doi:10.1145/2080.357392](http://dx.doi.org/10.1145/2080.357392) + +1. Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall: “[A Note on Distributed Computing](http://m.mirror.facebook.net/kde/devel/smli_tr-94-29.pdf),” Sun Microsystems Laboratories, Inc., Technical Report TR-94-29, November 1994. + +1. Steve Vinoski: “[Convenience over Correctness](http://steve.vinoski.net/pdf/IEEE-Convenience_Over_Correctness.pdf),” *IEEE Internet Computing*, volume 12, number 4, pages 89–92, July 2008. [doi:10.1109/MIC.2008.75](http://dx.doi.org/10.1109/MIC.2008.75) + +1. Marius Eriksen: “[Your Server as a Function](http://monkey.org/~marius/funsrv.pdf),” at *7th Workshop on Programming Languages and Operating Systems* (PLOS), November 2013. [doi:10.1145/2525528.2525538](http://dx.doi.org/10.1145/2525528.2525538) + +1. “[grpc-common Documentation](https://github.com/grpc/grpc-common),” Google, Inc., *github.com*, February 2015. + +1. Aditya Narayan and Irina Singh: “[Designing and Versioning Compatible Web Services](http://www.ibm.com/developerworks/websphere/library/techarticles/0705_narayan/0705_narayan.html),” *ibm.com*, March 28, 2007. + +1. Troy Hunt: “[Your API Versioning Is Wrong, Which Is Why I Decided to Do It 3 Different Wrong Ways](http://www.troyhunt.com/2014/02/your-api-versioning-is-wrong-which-is.html),” *troyhunt.com*, February 10, 2014. + +1. “[API Upgrades](https://stripe.com/docs/upgrades),” Stripe, Inc., April 2015. + +1. Jonas Bonér: “[Upgrade in an Akka Cluster](http://grokbase.com/t/gg/akka-user/138wd8j9e3/upgrade-in-an-akka-cluster),” email to *akka-user* mailing list, *grokbase.com*, August 28, 2013. + +1. Philip A. Bernstein, Sergey Bykov, Alan Geller, et al.: “[Orleans: Distributed Virtual Actors for Programmability and Scalability](http://research.microsoft.com/pubs/210931/Orleans-MSR-TR-2014-41.pdf),” Microsoft Research Technical Report MSR-TR-2014-41, March 2014. + +1. “[Microsoft Project Orleans Documentation](http://dotnet.github.io/orleans/),” Microsoft Research, *dotnet.github.io*, 2015. + +1. David Mercer, Sean Hinde, Yinso Chen, and Richard A O'Keefe: “[beginner: Updating Data Structures](http://erlang.org/pipermail/erlang-questions/2007-October/030318.html),” email thread on *erlang-questions* mailing list, *erlang.com*, October 29, 2007. + +1. Fred Hebert: “[Postscript: Maps](http://learnyousomeerlang.com/maps),” *learnyousomeerlang.com*, April 9, 2014. + +------ + +| 上一章 | 目錄 | 下一章 | +| ---------------------------- | ------------------------------- | --------------------------------- | +| [第三章:儲存與檢索](ch3.md) | [設計資料密集型應用](README.md) | [第二部分:分散式資料](part-ii.md) | diff --git a/zh-tw/ch5.md b/zh-tw/ch5.md new file mode 100644 index 00000000..d0d1d529 --- /dev/null +++ b/zh-tw/ch5.md @@ -0,0 +1,925 @@ +# 5. 複製 + +![](img/ch5.png) + +> 與可能出錯的東西比,'不可能'出錯的東西最顯著的特點就是:一旦真的出錯,通常就徹底玩完了。 +> +> ——道格拉斯·亞當斯(1992) + +------ + +[TOC] + +​ 複製意味著在透過網路連線的多臺機器上保留相同資料的副本。正如在[第二部分簡介](part-ii.md)中所討論的那樣,我們希望能複製資料,可能出於各種各樣的原因: + +* 使得資料與使用者在地理上接近(從而減少延遲) +* 即使系統的一部分出現故障,系統也能繼續工作(從而提高可用性) +* 擴充套件可以接受讀請求的機器數量(從而提高讀取吞吐量) + +本章將假設你的資料集非常小,每臺機器都可以儲存整個資料集的副本。在[第6章](ch6.md)中將放寬這個假設,討論對單個機器來說太大的資料集的分割(分片)。在後面的章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理這些故障。 + +​ 如果複製中的資料不會隨時間而改變,那複製就很簡單:將資料複製到每個節點一次就萬事大吉。複製的困難之處在於處理複製資料的**變更(change)**,這就是本章所要講的。我們將討論三種流行的變更復制演算法:**單領導者(single leader)**,**多領導者(multi leader)**和**無領導者(leaderless)**。幾乎所有分散式資料庫都使用這三種方法之一。 + +​ 在複製時需要進行許多權衡:例如,使用同步複製還是非同步複製?如何處理失敗的副本?這些通常是資料庫中的配置選項,細節因資料庫而異,但原理在許多不同的實現中都類似。本章會討論這些決策的後果。 + +​ 資料庫的複製算得上是老生常談了 ——70年代研究得出的基本原則至今沒有太大變化【1】,因為網路的基本約束仍保持不變。然而在研究之外,許多開發人員仍然假設一個數據庫只有一個節點。分散式資料庫變為主流只是最近發生的事。許多程式設計師都是這一領域的新手,因此對於諸如**最終一致性(eventual consistency)**等問題存在許多誤解。在“[複製延遲問題](#複製延遲問題)”一節,我們將更加精確地瞭解最終的一致性,並討論諸如**讀己之寫(read-your-writes)**和**單調讀(monotonic read)**保證等內容。 + +## 領導者與追隨者 + +​ 儲存資料庫副本的每個節點稱為 **副本(replica)** 。當存在多個副本時,會不可避免的出現一個問題:如何確保所有資料都落在了所有的副本上? + +​ 每一次向資料庫的寫入操作都需要傳播到所有副本上,否則副本就會包含不一樣的資料。最常見的解決方案被稱為 **基於領導者的複製(leader-based replication)** (也稱**主動/被動(active/passive)** 或 **主/從(master/slave)**複製),如[圖5-1](#fig5-1.png)所示。它的工作原理如下: + +1. 副本之一被指定為 **領導者(leader)**,也稱為 **主庫(master|primary)** 。當客戶端要向資料庫寫入時,它必須將請求傳送給**領導者**,領導者會將新資料寫入其本地儲存。 +2. 其他副本被稱為**追隨者(followers)**,亦稱為**只讀副本(read replicas)**,**從庫(slaves)**,**備庫( sencondaries)**,**熱備(hot-standby)**[^i]。每當領導者將新資料寫入本地儲存時,它也會將資料變更傳送給所有的追隨者,稱之為**複製日誌(replication log)**記錄或**變更流(change stream)**。每個跟隨者從領導者拉取日誌,並相應更新其本地資料庫副本,方法是按照領導者處理的相同順序應用所有寫入。 +3. 當客戶想要從資料庫中讀取資料時,它可以向領導者或追隨者查詢。 但只有領導者才能接受寫操作(從客戶端的角度來看從庫都是隻讀的)。 + +[^i]: 不同的人對**熱(hot)**,**溫(warm)**,**冷(cold)** 備份伺服器有不同的定義。 例如在PostgreSQL中,**熱備(hot standby)**指的是能接受客戶端讀請求的副本。而**溫備(warm standby)**只是追隨領導者,但不處理客戶端的任何查詢。 就本書而言,這些差異並不重要。 + +![](img/fig5-1.png) +**圖5-1 基於領導者(主-從)的複製** + +​ 這種複製模式是許多關係資料庫的內建功能,如PostgreSQL(從9.0版本開始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性組【3】。 它也被用於一些非關係資料庫,包括MongoDB,RethinkDB和Espresso 【4】。 最後,基於領導者的複製並不僅限於資料庫:像Kafka 【5】和RabbitMQ高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統,例如DRBD這樣的塊複製裝置也與之類似。 + +### 同步複製與非同步複製 + +​ 複製系統的一個重要細節是:複製是**同步(synchronously)**發生還是**非同步(asynchronously)**發生。 (在關係型資料庫中這通常是一個配置項,其他系統通常硬編碼為其中一個)。 + +​ 想象[圖5-1](fig5-1.png)中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。 + +[圖5-2](img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。 + +![](img/fig5-2.png) +**圖5-2 基於領導者的複製:一個同步從庫和一個非同步從庫** + +​ 在[圖5-2]()的示例中,從庫1的複製是同步的:在向用戶報告寫入成功,並使結果對其他使用者可見之前,主庫需要等待從庫1的確認,確保從庫1已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者2的複製是非同步的:主庫傳送訊息,但不等待從庫的響應。 + +​ 在這幅圖中,從庫2處理訊息前存在一個顯著的延遲。通常情況下,複製的速度相當快:大多數資料庫系統能在一秒向從庫應用變更,但它們不能提供複製用時的保證。有些情況下,從庫可能落後主庫幾分鐘或更久;例如:從庫正在從故障中恢復,系統在最大容量附近執行,或者如果節點間存在網路問題。 + +​ 同步複製的優點是,從庫保證有與主庫一致的最新資料副本。如果主庫突然失效,我們可以確信這些資料仍然能在從庫上上找到。缺點是,如果同步從庫沒有響應(比如它已經崩潰,或者出現網路故障,或其它任何原因),主庫就無法處理寫入操作。主庫必須阻止所有寫入,並等待同步副本再次可用。 + +​ 因此,將所有從庫都設定為同步的是不切實際的:任何一個節點的中斷都會導致整個系統停滯不前。實際上,如果在資料庫上啟用同步複製,通常意味著其中**一個**跟隨者是同步的,而其他的則是非同步的。如果同步從庫變得不可用或緩慢,則使一個非同步從庫同步。這保證你至少在兩個節點上擁有最新的資料副本:主庫和同步從庫。 這種配置有時也被稱為 **半同步(semi-synchronous)**【7】。 + +​ 通常情況下,基於領導者的複製都配置為完全非同步。 在這種情況下,如果主庫失效且不可恢復,則任何尚未複製給從庫的寫入都會丟失。 這意味著即使已經向客戶端確認成功,寫入也不能保證 **持久(Durable)** 。 然而,一個完全非同步的配置也有優點:即使所有的從庫都落後了,主庫也可以繼續處理寫入。 + +​ 弱化的永續性可能聽起來像是一個壞的折衷,然而非同步複製已經被廣泛使用了,特別當有很多追隨者,或追隨者異地分佈時。 稍後將在“[複製延遲問題](#複製延遲問題)”中回到這個問題。 + +> ### 關於複製的研究 +> +> 對於非同步複製系統而言,主庫故障時有可能丟失資料。這可能是一個嚴重的問題,因此研究人員仍在研究不丟資料但仍能提供良好效能和可用性的複製方法。 例如,**鏈式複製**【8,9】]是同步複製的一種變體,已經在一些系統(如Microsoft Azure儲存【10,11】)中成功實現。 +> +> 複製的一致性與**共識(consensus)**(使幾個節點就某個值達成一致)之間有著密切的聯絡,[第9章](ch9.md)將詳細地探討這一領域的理論。本章主要討論實踐中資料庫常用的簡單複製形式。 +> + +### 設定新從庫 + +​ 有時候需要設定一個新的從庫:也許是為了增加副本的數量,或替換失敗的節點。如何確保新的從庫擁有主庫資料的精確副本? + +​ 簡單地將資料檔案從一個節點複製到另一個節點通常是不夠的:客戶端不斷向資料庫寫入資料,資料總是在不斷變化,標準的資料副本會在不同的時間點總是不一樣。複製的結果可能沒有任何意義。 + +​ 可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但是這會違背高可用的目標。幸運的是,拉起新的從庫通常並不需要停機。從概念上講,過程如下所示: + +1. 在某個時刻獲取主庫的一致性快照(如果可能),而不必鎖定整個資料庫。大多數資料庫都具有這個功能,因為它是備份必需的。對於某些場景,可能需要第三方工具,例如MySQL的innobackupex 【12】。 +2. 將快照複製到新的從庫節點。 +3. 從庫連線到主庫,並拉取快照之後發生的所有資料變更。這要求快照與主庫複製日誌中的位置精確關聯。該位置有不同的名稱:例如,PostgreSQL將其稱為 **日誌序列號(log sequence number, LSN)**,MySQL將其稱為 **二進位制日誌座標(binlog coordinates)**。 +4. 當從庫處理完快照之後積壓的資料變更,我們說它**趕上(caught up)**了主庫。現在它可以繼續處理主庫產生的資料變化了。 + +建立從庫的實際步驟因資料庫而異。在某些系統中,這個過程是完全自動化的,而在另外一些系統中,它可能是一個需要由管理員手動執行的,有點神祕的多步驟工作流。 + +### 處理節點宕機 + +​ 系統中的任何節點都可能宕機,可能因為意外的故障,也可能由於計劃內的維護(例如,重啟機器以安裝核心安全補丁)。對運維而言,能在系統不中斷服務的情況下重啟單個節點好處多多。我們的目標是,即使個別節點失效,也能保持整個系統執行,並儘可能控制節點停機帶來的影響。 + +​ 如何透過基於主庫的複製實現高可用? + +#### 從庫失效:追趕恢復 + +​ 在其本地磁碟上,每個從庫記錄從主庫收到的資料變更。如果從庫崩潰並重新啟動,或者,如果主庫和從庫之間的網路暫時中斷,則比較容易恢復:從庫可以從日誌中知道,在發生故障之前處理的最後一個事務。因此,從庫可以連線到主庫,並請求在從庫斷開連線時發生的所有資料變更。當應用完所有這些變化後,它就趕上了主庫,並可以像以前一樣繼續接收資料變更流。 + +#### 主庫失效:故障切換 + +​ 主庫失效處理起來相當棘手:其中一個從庫需要被提升為新的主庫,需要重新配置客戶端,以將它們的寫操作傳送給新的主庫,其他從庫需要開始拉取來自新主庫的資料變更。這個過程被稱為**故障切換(failover)**。 + +​ 故障切換可以手動進行(通知管理員主庫掛了,並採取必要的步驟來建立新的主庫)或自動進行。自動故障切換過程通常由以下步驟組成: + +1. 確認主庫失效。有很多事情可能會出錯:崩潰,停電,網路問題等等。沒有萬無一失的方法來檢測出現了什麼問題,所以大多數系統只是簡單使用 **超時(Timeout)** :節點頻繁地相互來回傳遞訊息,並且如果一個節點在一段時間內(例如30秒)沒有響應,就認為它掛了(因為計劃內維護而故意關閉主庫不算)。 +2. 選擇一個新的主庫。這可以透過選舉過程(主庫由剩餘副本以多數選舉產生)來完成,或者可以由之前選定的**控制器節點(controller node)**來指定新的主庫。主庫的最佳人選通常是擁有舊主庫最新資料副本的從庫(最小化資料損失)。讓所有的節點同意一個新的領導者,是一個**共識**問題,將在[第9章](ch9.md)詳細討論。 +3. 重新配置系統以啟用新的主庫。客戶端現在需要將它們的寫請求傳送給新主庫(將在“[請求路由](ch6.md#請求路由)”中討論這個問題)。如果老領導回來,可能仍然認為自己是主庫,沒有意識到其他副本已經讓它下臺了。系統需要確保老領導認可新領導,成為一個從庫。 + +故障切換會出現很多大麻煩: + +* 如果使用非同步複製,則新主庫可能沒有收到老主庫宕機前最後的寫入操作。在選出新主庫後,如果老主庫重新加入叢集,新主庫在此期間可能會收到衝突的寫入,那這些寫入該如何處理?最常見的解決方案是簡單丟棄老主庫未複製的寫入,這很可能打破客戶對於資料永續性的期望。 + +* 如果資料庫需要和其他外部儲存相協調,那麼丟棄寫入內容是極其危險的操作。例如在GitHub 【13】的一場事故中,一個過時的MySQL從庫被提升為主庫。資料庫使用自增ID作為主鍵,因為新主庫的計數器落後於老主庫的計數器,所以新主庫重新分配了一些已經被老主庫分配掉的ID作為主鍵。這些主鍵也在Redis中使用,主鍵重用使得MySQL和Redis中資料產生不一致,最後導致一些私有資料洩漏到錯誤的使用者手中。 + +* 發生某些故障時(見[第8章](ch8.md))可能會出現兩個節點都以為自己是主庫的情況。這種情況稱為 **腦裂(split brain)**,非常危險:如果兩個主庫都可以接受寫操作,卻沒有衝突解決機制(參見“[多領導者複製](#多領導者複製)”),那麼資料就可能丟失或損壞。一些系統採取了安全防範措施:當檢測到兩個主庫節點同時存在時會關閉其中一個節點[^ii],但設計粗糙的機制可能最後會導致兩個節點都被關閉【14】。 + + [^ii]: 這種機制稱為 **遮蔽(fencing)**,充滿感情的術語是:**爆彼之頭(Shoot The Other Node In The Head, STONITH)**。 + +* 主庫被宣告死亡之前的正確超時應該怎麼配置?在主庫失效的情況下,超時時間越長,意味著恢復時間也越長。但是如果超時設定太短,又可能會出現不必要的故障切換。例如,臨時負載峰值可能導致節點的響應時間超時,或網路故障可能導致資料包延遲。如果系統已經處於高負載或網路問題的困擾之中,那麼不必要的故障切換可能會讓情況變得更糟糕。 + +這些問題沒有簡單的解決方案。因此,即使軟體支援自動故障切換,不少運維團隊還是更願意手動執行故障切換。 + +節點故障、不可靠的網路、對副本一致性,永續性,可用性和延遲的權衡 ,這些問題實際上是分散式系統中的基本問題。[第8章](ch8.md)和[第9章](ch9.md)將更深入地討論它們。 + +### 複製日誌的實現 + +基於主庫的複製底層是如何工作的?實踐中有好幾種不同的複製方式,所以先簡要地看一下。 + +#### 基於語句的複製 + +​ 在最簡單的情況下,主庫記錄下它執行的每個寫入請求(**語句(statement)**)並將該語句日誌傳送給其從庫。對於關係資料庫來說,這意味著每個`INSERT`,`UPDATE`或`DELETE`語句都被轉發給每個從庫,每個從庫解析並執行該SQL語句,就像從客戶端收到一樣。 + +雖然聽上去很合理,但有很多問題會搞砸這種複製方式: + +* 任何呼叫**非確定性函式(nondeterministic)**的語句,可能會在每個副本上生成不同的值。例如,使用`NOW()`獲取當前日期時間,或使用`RAND()`獲取一個隨機數。 +* 如果語句使用了**自增列(auto increment)**,或者依賴於資料庫中的現有資料(例如,`UPDATE ... WHERE <某些條件>`),則必須在每個副本上按照完全相同的順序執行它們,否則可能會產生不同的效果。當有多個併發執行的事務時,這可能成為一個限制。 +* 有副作用的語句(例如,觸發器,儲存過程,使用者定義的函式)可能會在每個副本上產生不同的副作用,除非副作用是絕對確定的。 + +的確有辦法繞開這些問題 ——例如,當語句被記錄時,主庫可以用固定的返回值替換任何不確定的函式呼叫,以便從庫獲得相同的值。但是由於邊緣情況實在太多了,現在通常會選擇其他的複製方法。 + +​ 基於語句的複製在5.1版本前的MySQL中使用。因為它相當緊湊,現在有時候也還在用。但現在在預設情況下,如果語句中存在任何不確定性,MySQL會切換到基於行的複製(稍後討論)。 VoltDB使用了基於語句的複製,但要求事務必須是確定性的,以此來保證安全【15】。 + +#### 傳輸預寫式日誌(WAL) + +在[第3章](ch3.md)中,我們討論了儲存引擎如何在磁碟上表示資料,並且我們發現,通常寫操作都是追加到日誌中: + +* 對於日誌結構儲存引擎(請參閱“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”),日誌是主要的儲存位置。日誌段在後臺壓縮,並進行垃圾回收。 +* 對於覆寫單個磁碟塊的[B樹](ch3.md#B樹),每次修改都會先寫入 **預寫式日誌(Write Ahead Log, WAL)**,以便崩潰後索引可以恢復到一個一致的狀態。 + +在任何一種情況下,日誌都是包含所有資料庫寫入的僅追加位元組序列。可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟之外,主庫還可以透過網路將其傳送給其從庫。 + +​ 當從庫應用這個日誌時,它會建立和主庫一模一樣資料結構的副本。 + +​ PostgreSQL和Oracle等使用這種複製方法【16】。主要缺點是日誌記錄的資料非常底層:WAL包含哪些磁碟塊中的哪些位元組發生了更改。這使複製與儲存引擎緊密耦合。如果資料庫將其儲存格式從一個版本更改為另一個版本,通常不可能在主庫和從庫上執行不同版本的資料庫軟體。 + +​ 看上去這可能只是一個微小的實現細節,但卻可能對運維產生巨大的影響。如果複製協議允許從庫使用比主庫更新的軟體版本,則可以先升級從庫,然後執行故障切換,使升級後的節點之一成為新的主庫,從而執行資料庫軟體的零停機升級。如果複製協議不允許版本不匹配(傳輸WAL經常出現這種情況),則此類升級需要停機。 + +#### 邏輯日誌複製(基於行) + +​ 另一種方法是,複製和儲存引擎使用不同的日誌格式,這樣可以使複製日誌從儲存引擎內部分離出來。這種複製日誌被稱為邏輯日誌,以將其與儲存引擎的(物理)資料表示區分開來。 + +關係資料庫的邏輯日誌通常是以行的粒度描述對資料庫表的寫入的記錄序列: + +* 對於插入的行,日誌包含所有列的新值。 +* 對於刪除的行,日誌包含足夠的資訊來唯一標識已刪除的行。通常是主鍵,但是如果表上沒有主鍵,則需要記錄所有列的舊值。 +* 對於更新的行,日誌包含足夠的資訊來唯一標識更新的行,以及所有列的新值(或至少所有已更改的列的新值)。 + +修改多行的事務會生成多個這樣的日誌記錄,後面跟著一條記錄,指出事務已經提交。 MySQL的二進位制日誌(當配置為使用基於行的複製時)使用這種方法【17】。 + +​ 由於邏輯日誌與儲存引擎內部分離,因此可以更容易地保持向後相容,從而使領導者和跟隨者能夠執行不同版本的資料庫軟體甚至不同的儲存引擎。 + +​ 對於外部應用程式來說,邏輯日誌格式也更容易解析。如果要將資料庫的內容傳送到外部系統(如資料),這一點很有用,例如複製到資料倉庫進行離線分析,或建立自定義索引和快取【18】。 這種技術被稱為 **資料變更捕獲(change data capture)**,第11章將重新講到它。 + +#### 基於觸發器的複製 + +​ 到目前為止描述的複製方法是由資料庫系統實現的,不涉及任何應用程式程式碼。在很多情況下,這就是你想要的。但在某些情況下需要更多的靈活性。例如,如果您只想複製資料的一個子集,或者想從一種資料庫複製到另一種資料庫,或者如果您需要衝突解決邏輯(參閱“[處理寫入衝突](#處理寫入衝突)”),則可能需要將複製移動到應用程式層。 + +​ 一些工具,如Oracle Golden Gate 【19】,可以透過讀取資料庫日誌,使得其他應用程式可以使用資料。另一種方法是使用許多關係資料庫自帶的功能:觸發器和儲存過程。 + +​ 觸發器允許您註冊在資料庫系統中發生資料更改(寫入事務)時自動執行的自定義應用程式程式碼。觸發器有機會將更改記錄到一個單獨的表中,使用外部程式讀取這個表,再加上任何業務邏輯處理,會後將資料變更復制到另一個系統去。例如,Databus for Oracle 【20】和Bucardo for Postgres 【21】就是這樣工作的。 + +​ 基於觸發器的複製通常比其他複製方法具有更高的開銷,並且比資料庫的內建複製更容易出錯,也有很多限制。然而由於其靈活性,仍然是很有用的。 + + + +## 複製延遲問題 + +​ 容忍節點故障只是需要複製的一個原因。正如在[第二部分](part-ii.md)的介紹中提到的,另一個原因是可擴充套件性(處理比單個機器更多的請求)和延遲(讓副本在地理位置上更接近使用者)。 + +​ 基於主庫的複製要求所有寫入都由單個節點處理,但只讀查詢可以由任何副本處理。所以對於讀多寫少的場景(Web上的常見模式),一個有吸引力的選擇是建立很多從庫,並將讀請求分散到所有的從庫上去。這樣能減小主庫的負載,並允許向最近的副本傳送讀請求。 + +​ 在這種擴充套件體系結構中,只需新增更多的追隨者,就可以提高只讀請求的服務容量。但是,這種方法實際上只適用於非同步複製——如果嘗試同步複製到所有追隨者,則單個節點故障或網路中斷將使整個系統無法寫入。而且越多的節點越有可能會被關閉,所以完全同步的配置是非常不可靠的。 + +​ 不幸的是,當應用程式從非同步從庫讀取時,如果從庫落後,它可能會看到過時的資訊。這會導致資料庫中出現明顯的不一致:同時對主庫和從庫執行相同的查詢,可能得到不同的結果,因為並非所有的寫入都反映在從庫中。這種不一致只是一個暫時的狀態——如果停止寫入資料庫並等待一段時間,從庫最終會趕上並與主庫保持一致。出於這個原因,這種效應被稱為 **最終一致性(eventually consistency)**[^iii]【22,23】 + +[^iii]: 道格拉斯·特里(Douglas Terry)等人創造了術語最終一致性。 【24】 並經由Werner Vogels 【22】推廣,成為許多NoSQL專案的戰吼。 然而,不只有NoSQL資料庫是最終一致的:關係型資料庫中的非同步複製追隨者也有相同的特性。 + +​ “最終”一詞故意含糊不清:總的來說,副本落後的程度是沒有限制的。在正常的操作中,**複製延遲(replication lag)**,即寫入主庫到反映至從庫之間的延遲,可能僅僅是幾分之一秒,在實踐中並不顯眼。但如果系統在接近極限的情況下執行,或網路中存在問題,延遲可以輕而易舉地超過幾秒,甚至幾分鐘。 + +​ 因為滯後時間太長引入的不一致性,可不僅是一個理論問題,更是應用設計中會遇到的真實問題。本節將重點介紹三個由複製延遲問題的例子,並簡述解決這些問題的一些方法。 + +### 讀己之寫 + +​ 許多應用讓使用者提交一些資料,然後檢視他們提交的內容。可能是使用者資料庫中的記錄,也可能是對討論主題的評論,或其他類似的內容。提交新資料時,必須將其傳送給領導者,但是當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視,但只是偶爾寫入,這是非常合適的。 + +​ 但對於非同步複製,問題就來了。如[圖5-3](fig5-3.png)所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。 + +![](img/fig5-3.png) + +**圖5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀(read-after-write)的一致性來防止這種異常** + +​ 在這種情況下,我們需要 **讀寫一致性(read-after-write consistency)**,也稱為 **讀己之寫一致性(read-your-writes consistency)**【24】。這是一個保證,如果使用者重新載入頁面,他們總會看到他們自己提交的任何更新。它不會對其他使用者的寫入做出承諾:其他使用者的更新可能稍等才會看到。它保證使用者自己的輸入已被正確儲存。 + +如何在基於領導者的複製系統中實現讀後一致性?有各種可能的技術,這裡說一些: + +* 讀使用者**可能已經修改過**的內容時,都從主庫讀;這就要求有一些方法,不用實際查詢就可以知道使用者是否修改了某些東西。舉個例子,社交網路上的使用者個人資料資訊通常只能由使用者本人編輯,而不能由其他人編輯。因此一個簡單的規則是:從主庫讀取使用者自己的檔案,在從庫讀取其他使用者的檔案。 + +* 如果應用中的大部分內容都可能被使用者編輯,那這種方法就沒用了,因為大部分內容都必須從主庫讀取(擴容讀就沒效果了)。在這種情況下可以使用其他標準來決定是否從主庫讀取。例如可以跟蹤上次更新的時間,在上次更新後的一分鐘內,從主庫讀。還可以監控從庫的複製延遲,防止對任意比主庫滯後超過一分鐘的從庫發出查詢。 + +* 客戶端可以記住最近一次寫入的時間戳,系統需要確保從庫為該使用者提供任何查詢時,該時間戳前的變更都已經傳播到了本從庫中。如果當前從庫不夠新,則可以從另一個從庫讀,或者等待從庫追趕上來。 + + 時間戳可以是邏輯時間戳(指示寫入順序的東西,例如日誌序列號)或實際系統時鐘(在這種情況下,時鐘同步變得至關重要;參閱“[不可靠的時鐘](ch8.md#不可靠的時鐘)”)。 + +* 如果您的副本分佈在多個數據中心(出於可用性目的與使用者儘量在地理上接近),則會增加複雜性。任何需要由領導者提供服務的請求都必須路由到包含主庫的資料中心。 + +另一種複雜的情況是:如果同一個使用者從多個裝置請求服務,例如桌面瀏覽器和移動APP。這種情況下可能就需要提供跨裝置的寫後讀一致性:如果使用者在某個裝置上輸入了一些資訊,然後在另一個裝置上檢視,則應該看到他們剛輸入的資訊。 + +在這種情況下,還有一些需要考慮的問題: + +* 記住使用者上次更新時間戳的方法變得更加困難,因為一臺裝置上執行的程式不知道另一臺裝置上發生了什麼。元資料需要一箇中心儲存。 +* 如果副本分佈在不同的資料中心,很難保證來自不同裝置的連線會路由到同一資料中心。 (例如,使用者的臺式計算機使用家庭寬頻連線,而移動裝置使用蜂窩資料網路,則裝置的網路路線可能完全不同)。如果你的方法需要讀主庫,可能首先需要把來自同一使用者的請求路由到同一個資料中心。 + + + +### 單調讀 + +​ 從非同步從庫讀取第二個異常例子是,使用者可能會遇到 **時光倒流(moving backward in time)**。 + +​ 如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢,首先查詢了一個延遲很小的從庫,然後是一個延遲較大的從庫。 (如果使用者重新整理網頁,而每個請求被路由到一個隨機的伺服器,這種情況是很有可能的。)第一個查詢返回最近由使用者1234新增的評論,但是第二個查詢不返回任何東西,因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢,第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容,那問題並不大,因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論,然後又看到它消失,那麼對於使用者2345,就很讓人頭大了。 + +![](img/fig5-4.png) + +**圖5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。** + +​ **單調讀(Monotonic reads)**【23】是這種異常不會發生的保證。這是一個比**強一致性(strong consistency)**更弱,但比**最終一致性(eventually consistency)**更強的保證。當讀取資料時,您可能會看到一箇舊值;單調讀取僅意味著如果一個使用者順序地進行多次讀取,則他們不會看到時間後退,即,如果先前讀取到較新的資料,後續讀取不會得到更舊的資料。 + +​ 實現單調讀取的一種方式是確保每個使用者總是從同一個副本進行讀取(不同的使用者可以從不同的副本讀取)。例如,可以基於使用者ID的雜湊來選擇副本,而不是隨機選擇副本。但是,如果該副本失敗,使用者的查詢將需要重新路由到另一個副本。 + + + +### 一致字首讀 + +第三個複製延遲例子違反了因果律。 想象一下Poons先生和Cake夫人之間的以下簡短對話: + +> *Mr. Poons* +> ​ Mrs. Cake,你能看到多遠的未來? +> +> *Mrs. Cake* +> ​ 通常約十秒鐘,Mr. Poons. + +這兩句話之間有因果關係:Cake夫人聽到了Poons先生的問題並回答了這個問題。 + +​ 現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的,但Poons先生所說的內容,從庫的延遲要大的多(見[圖5-5](img/fig5-5.png))。 於是,這個觀察者會聽到以下內容: + +> *Mrs. Cake* +> ​ 通常約十秒鐘,Mr. Poons. +> +> *Mr. Poons* +> ​ Mrs. Cake,你能看到多遠的未來? + +對於觀察者來說,看起來好像Cake夫人在Poons先生髮問前就回答了這個問題。 + 這種超能力讓人印象深刻,但也會把人搞糊塗。【25】。 + +![](img/fig5-5.png) + +**圖5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。** + +​ 防止這種異常,需要另一種型別的保證:**一致字首讀(consistent prefix reads)**【23】。 這個保證說:如果一系列寫入按某個順序發生,那麼任何人讀取這些寫入時,也會看見它們以同樣的順序出現。 + +​ 這是**分割槽(partitioned)**(**分片(sharded)**)資料庫中的一個特殊問題,將在第6章中討論。如果資料庫總是以相同的順序應用寫入,則讀取總是會看到一致的字首,所以這種異常不會發生。但是在許多分散式資料庫中,不同的分割槽獨立執行,因此不存在**全域性寫入順序**:當用戶從資料庫中讀取資料時,可能會看到資料庫的某些部分處於較舊的狀態,而某些處於較新的狀態。 + +​ 一種解決方案是,確保任何因果相關的寫入都寫入相同的分割槽。對於某些無法高效完成這種操作的應用,還有一些顯式跟蹤因果依賴關係的演算法,本書將在“關係與併發”一節中返回這個主題。 + +### 複製延遲的解決方案 + +​ 在使用最終一致的系統時,如果複製延遲增加到幾分鐘甚至幾小時,則應該考慮應用程式的行為。如果答案是“沒問題”,那很好。但如果結果對於使用者來說是不好體驗,那麼設計系統來提供更強的保證是很重要的,例如**寫後讀**。明明是非同步複製卻假設複製是同步的,這是很多麻煩的根源。 + +​ 如前所述,應用程式可以提供比底層資料庫更強有力的保證,例如透過主庫進行某種讀取。但在應用程式程式碼中處理這些問題是複雜的,容易出錯。 + +​ 如果應用程式開發人員不必擔心微妙的複製問題,並可以信賴他們的資料庫“做了正確的事情”,那該多好呀。這就是**事務(transaction)**存在的原因:**資料庫透過事務提供強大的保證**,所以應用程式可以更加簡單。 + +​ 單節點事務已經存在了很長時間。然而在走向分散式(複製和分割槽)資料庫時,許多系統放棄了事務。聲稱事務在效能和可用性上的代價太高,並斷言在可擴充套件系統中最終一致性是不可避免的。這個敘述有一些道理,但過於簡單了,本書其餘部分將提出更為細緻的觀點。第七章和第九章將回到事務的話題,並討論一些替代機制。 + + + +## 多主複製 + +​ 本章到目前為止,我們只考慮使用單個領導者的複製架構。 雖然這是一種常見的方法,但也有一些有趣的選擇。 + +​ 基於領導者的複製有一個主要的缺點:只有一個主庫,而所有的寫入都必須透過它。如果出於任何原因(例如和主庫之間的網路連線中斷)無法連線到主庫, 就無法向資料庫寫入。 + +[^iv]: 如果資料庫被分割槽(見第6章),每個分割槽都有一個領導。 不同的分割槽可能在不同的節點上有其領導者,但是每個分割槽必須有一個領導者節點。 + +​ 基於領導者的複製模型的自然延伸是允許多個節點接受寫入。 複製仍然以同樣的方式發生:處理寫入的每個節點都必須將該資料更改轉發給所有其他節點。 稱之為**多領導者配置**(也稱多主、多活複製)。 在這種情況下,每個領導者同時扮演其他領導者的追隨者。 + +### 多主複製的應用場景 + +​ 在單個數據中心內部使用多個主庫很少是有意義的,因為好處很少超過複雜性的代價。 但在一些情況下,多活配置是也合理的。 + +#### 運維多個數據中心 + +​ 假如你有一個數據庫,副本分散在好幾個不同的資料中心(也許這樣可以容忍單個數據中心的故障,或地理上更接近使用者)。 使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。 + +​ 多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。 + +![](img/fig5-6.png) + +**圖5-6 跨多個數據中心的多主複製** + +我們來比較一下在運維多個數據中心時,單主和多主的適應情況。 + +***效能*** + +​ 在單活配置中,每個寫入都必須穿過網際網路,進入主庫所在的資料中心。這可能會增加寫入時間,並可能違背了設定多個數據中心的初心。在多活配置中,每個寫操作都可以在本地資料中心進行處理,並與其他資料中心非同步複製。因此,資料中心之間的網路延遲對使用者來說是透明的,這意味著感覺到的效能可能會更好。 + +***容忍資料中心停機*** + +​ 在單主配置中,如果主庫所在的資料中心發生故障,故障切換可以使另一個數據中心裡的追隨者成為領導者。在多活配置中,每個資料中心可以獨立於其他資料中心繼續執行,並且當發生故障的資料中心歸隊時,複製會自動趕上。 + +***容忍網路問題*** + +​ 資料中心之間的通訊通常穿過公共網際網路,這可能不如資料中心內的本地網路可靠。單主配置對這資料中心間的連線問題非常敏感,因為透過這個連線進行的寫操作是同步的。採用非同步複製功能的多活配置通常能更好地承受網路問題:臨時的網路中斷並不會妨礙正在處理的寫入。 + +​ 有些資料庫預設情況下支援多主配置,但使用外部工具實現也很常見,例如用於MySQL的Tungsten Replicator 【26】,用於PostgreSQL的BDR【27】以及用於Oracle的GoldenGate 【19】。 + +​ 儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](img/fig5-6.png)中“[衝突解決](#衝突解決)”)。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。 + +​ 由於多主複製在許多資料庫中都屬於改裝的功能,所以常常存在微妙的配置缺陷,且經常與其他資料庫功能之間出現意外的反應。例如自增主鍵、觸發器、完整性約束等,都可能會有麻煩。因此,多主複製往往被認為是危險的領域,應儘可能避免【28】。 + +#### 需要離線操作的客戶端 + +​ 多主複製的另一種適用場景是:應用程式在斷網之後仍然需要繼續工作。 + +​ 例如,考慮手機,膝上型電腦和其他裝置上的日曆應用。無論裝置目前是否有網際網路連線,你需要能隨時檢視你的會議(發出讀取請求),輸入新的會議(發出寫入請求)。如果在離線狀態下進行任何更改,則裝置下次上線時,需要與伺服器和其他裝置同步。 + +​ 在這種情況下,每個裝置都有一個充當領導者的本地資料庫(它接受寫請求),並且在所有裝置上的日曆副本之間同步時,存在非同步的多主複製過程。複製延遲可能是幾小時甚至幾天,具體取決於何時可以訪問網際網路。 + +​ 從架構的角度來看,這種設定實際上與資料中心之間的多領導者複製類似,每個裝置都是一個“資料中心”,而它們之間的網路連線是極度不可靠的。從歷史上各類日曆同步功能的破爛實現可以看出,想把多活配好是多麼困難的一件事。 + +​ 有一些工具旨在使這種多領導者配置更容易。例如,CouchDB就是為這種操作模式而設計的【29】。 + +#### 協同編輯 + +​ 實時協作編輯應用程式允許多個人同時編輯文件。例如,Etherpad 【30】和Google Docs 【31】允許多人同時編輯文字文件或電子表格(該演算法在“[自動衝突解決](#自動衝突解決)”中簡要討論)。我們通常不會將協作式編輯視為資料庫複製問題,但與前面提到的離線編輯用例有許多相似之處。當一個使用者編輯文件時,所做的更改將立即應用到其本地副本(Web瀏覽器或客戶端應用程式中的文件狀態),並非同步複製到伺服器和編輯同一文件的任何其他使用者。 + +​ 如果要保證不會發生編輯衝突,則應用程式必須先取得文件的鎖定,然後使用者才能對其進行編輯。如果另一個使用者想要編輯同一個文件,他們首先必須等到第一個使用者提交修改並釋放鎖定。這種協作模式相當於在領導者上進行交易的單領導者複製。 + +​ 但是,為了加速協作,您可能希望將更改的單位設定得非常小(例如,一個按鍵),並避免鎖定。這種方法允許多個使用者同時進行編輯,但同時也帶來了多領導者複製的所有挑戰,包括需要解決衝突【32】。 + +### 處理寫入衝突 + +​ 多領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。 + +​ 例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B,並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時,會發現衝突【33】。單主資料庫中不會出現此問題。 + +![](img/fig5-7.png) + +**圖5-7 兩個主庫同時更新同一記錄引起的寫入衝突** + +#### 同步與非同步衝突檢測 + +​ 在單主資料庫中,第二個寫入將被阻塞,並等待第一個寫入完成,或中止第二個寫入事務,強制使用者重試。另一方面,在多活配置中,兩個寫入都是成功的,並且在稍後的時間點僅僅非同步地檢測到衝突。那時要求使用者解決衝突可能為時已晚。 + +​ 原則上,可以使衝突檢測同步 - 即等待寫入被複制到所有副本,然後再告訴使用者寫入成功。但是,透過這樣做,您將失去多主複製的主要優點:允許每個副本獨立接受寫入。如果您想要同步衝突檢測,那麼您可以使用單主程式複製。 + +#### 避免衝突 + +​ 處理衝突的最簡單的策略就是避免它們:如果應用程式可以確保特定記錄的所有寫入都透過同一個領導者,那麼衝突就不會發生。由於多領導者複製處理的許多實現衝突相當不好,避免衝突是一個經常推薦的方法【34】。 + +​ 例如,在使用者可以編輯自己的資料的應用程式中,可以確保來自特定使用者的請求始終路由到同一資料中心,並使用該資料中心的領導者進行讀寫。不同的使用者可能有不同的“家庭”資料中心(可能根據使用者的地理位置選擇),但從任何使用者的角度來看,配置基本上都是單一的領導者。 + +​ 但是,有時您可能需要更改指定的記錄的主庫——可能是因為一個數據中心出現故障,您需要將流量重新路由到另一個數據中心,或者可能是因為使用者已經遷移到另一個位置,現在更接近不同的資料中心。在這種情況下,衝突避免會中斷,你必須處理不同主庫同時寫入的可能性。 + +#### 收斂至一致的狀態 + +​ 單主資料庫按順序進行寫操作:如果同一個欄位有多個更新,則最後一個寫操作將決定該欄位的最終值。 + +​ 在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](img/fig5-7.png)中,在主庫1中標題首先更新為B而後更新為C;在主庫2中,首先更新為C,然後更新為B。兩個順序都不是“更正確”的。 + +​ 如果每個副本只是按照它看到寫入的順序寫入,那麼資料庫最終將處於不一致的狀態:最終值將是在主庫1的C和主庫2的B。這是不可接受的,每個複製方案都必須確保資料在所有副本中最終都是相同的。因此,資料庫必須以一種**收斂(convergent)**的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。 + +實現衝突合併解決有多種途徑: + +* 給每個寫入一個唯一的ID(例如,一個時間戳,一個長的隨機數,一個UUID或者一個鍵和值的雜湊),挑選最高ID的寫入作為勝利者,並丟棄其他寫入。如果使用時間戳,這種技術被稱為**最後寫入勝利(LWW, last write wins)**。雖然這種方法很流行,但是很容易造成資料丟失【35】。我們將在[本章末尾](#檢測併發寫入)更詳細地討論LWW。 +* 為每個副本分配一個唯一的ID,ID編號更高的寫入具有更高的優先順序。這種方法也意味著資料丟失。 +* 以某種方式將這些值合併在一起 - 例如,按字母順序排序,然後連線它們(在[圖5-7](img/fig5-7.png)中,合併的標題可能類似於“B/C”)。 +* 用一種可保留所有資訊的顯式資料結構來記錄衝突,並編寫解決衝突的應用程式程式碼(也許透過提示使用者的方式)。 + + + +#### 自定義衝突解決邏輯 + +​ 作為解決衝突最合適的方法可能取決於應用程式,大多數多主複製工具允許使用應用程式程式碼編寫衝突解決邏輯。該程式碼可以在寫入或讀取時執行: + +***寫時執行*** + +​ 只要資料庫系統檢測到複製更改日誌中存在衝突,就會呼叫衝突處理程式。例如,Bucardo允許您為此編寫一段Perl程式碼。這個處理程式通常不能提示使用者——它在後臺程序中執行,並且必須快速執行。 + +***讀時執行*** + +​ 當檢測到衝突時,所有衝突寫入被儲存。下一次讀取資料時,會將這些多個版本的資料返回給應用程式。應用程式可能會提示使用者或自動解決衝突,並將結果寫回資料庫。例如,CouchDB以這種方式工作。 + +​ 請注意,衝突解決通常適用於單個行或文件層面,而不是整個事務【36】。因此,如果您有一個事務會原子性地進行幾次不同的寫入(請參閱第7章),則對於衝突解決而言,每個寫入仍需分開單獨考慮。 + + + +> #### 題外話:自動衝突解決 +> +> ​ 衝突解決規則可能很快變得複雜,並且自定義程式碼可能容易出錯。亞馬遜是一個經常被引用的例子,由於衝突解決處理程式令人意外的效果:一段時間以來,購物車上的衝突解決邏輯將保留新增到購物車的物品,但不包括從購物車中移除的物品。因此,顧客有時會看到物品重新出現在他們的購物車中,即使他們之前已經被移走【37】。 +> +> 已經有一些有趣的研究來自動解決由於資料修改引起的衝突。有幾行研究值得一提: +> +> * **無衝突複製資料型別(Conflict-free replicated datatypes)**(CRDT)【32,38】是可以由多個使用者同時編輯的集合,對映,有序列表,計數器等的一系列資料結構,它們以合理的方式自動解決衝突。一些CRDT已經在Riak 2.0中實現【39,40】。 +> * **可合併的持久資料結構(Mergeable persistent data structures)**【41】顯式跟蹤歷史記錄,類似於Git版本控制系統,並使用三向合併功能(而CRDT使用雙向合併)。 +> * **可執行的轉換(operational transformation)**[42]是Etherpad 【30】和Google Docs 【31】等合作編輯應用背後的衝突解決演算法。它是專為同時編輯專案的有序列表而設計的,例如構成文字文件的字元列表。 +> +> 這些演算法在資料庫中的實現還很年輕,但很可能將來它們將被整合到更多的複製資料系統中。自動衝突解決方案可以使應用程式處理多領導者資料同步更為簡單。 +> + + + +#### 什麼是衝突? + +​ 有些衝突是顯而易見的。在[圖5-7](img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。 + +​ 其他型別的衝突可能更為微妙,難以發現。例如,考慮一個會議室預訂系統:它記錄誰訂了哪個時間段的哪個房間。應用需要確保每個房間只有一組人同時預定(即不得有相同房間的重疊預訂)。在這種情況下,如果同時為同一個房間建立兩個不同的預訂,則可能會發生衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩次預訂是由兩個不同的領導者進行的,則可能會有衝突。 + +​ 現在還沒有一個現成的答案,但在接下來的章節中,我們將更好地瞭解這個問題。我們將在第7章中看到更多的衝突示例,在[第12章](ch12.md)中我們將討論用於檢測和解決複製系統中衝突的可擴充套件方法。 + + + +### 多主複製拓撲 + +​ **複製拓撲**(replication topology)描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如[圖5-7]()所示,只有一個合理的拓撲結構:領導者1必須把他所有的寫到領導者2,反之亦然。當有兩個以上的領導,各種不同的拓撲是可能的。[圖5-8]()舉例說明了一些例子。 + +![](img/fig5-8.png) + +**圖5-8 三個可以設定多領導者複製的示例拓撲。** + +​ 最普遍的拓撲是全部到全部([圖5-8 [c]]()),其中每個領導者將其寫入每個其他領導。但是,也會使用更多受限制的拓撲:例如,預設情況下,MySQL僅支援**環形拓撲(circular topology)**【34】,其中每個節點接收來自一個節點的寫入,並將這些寫入(加上自己的任何寫入)轉發給另一個節點。另一種流行的拓撲結構具有星形的形狀[^v]。個指定的根節點將寫入轉發給所有其他節點。星型拓撲可以推廣到樹。 + +[^v]: 不要與星型模式混淆(請參閱“[分析模式:星型還是雪花](ch2.md#分析模式:星型還是雪花)”),其中描述了資料模型的結構,而不是節點之間的通訊拓撲。 + +​ 在圓形和星形拓撲中,寫入可能需要在到達所有副本之前透過多個節點。因此,節點需要轉發從其他節點收到的資料更改。為了防止無限複製迴圈,每個節點被賦予一個唯一的識別符號,並且在複製日誌中,每個寫入都被標記了所有已經過的節點的識別符號【43】。當一個節點收到用自己的識別符號標記的資料更改時,該資料更改將被忽略,因為節點知道它已經被處理過。 + +​ 迴圈和星型拓撲的問題是,如果只有一個節點發生故障,則可能會中斷其他節點之間的複製訊息流,導致它們無法通訊,直到節點修復。拓撲結構可以重新配置為在發生故障的節點上工作,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲結構(例如全部到全部)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。 + +​ 另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](img/fig5-9.png)所示。 + +![](img/fig5-9.png) + +**圖5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。** + +​ 在[圖5-9](img/fig5-9.png)中,客戶端A向主庫1的表中插入一行,客戶端B在主庫3上更新該行。然而,主庫2可以以不同的順序接收寫入:它可以首先接收更新(其中,從它的角度來看,是對資料庫中不存在的行的更新),並且僅在稍後接收到相應的插入(其應該在更新之前)。 + +​ 這是一個因果關係的問題,類似於我們在“[一致字首讀](ch8.md#一致字首讀)”中看到的:更新取決於先前的插入,所以我們需要確保所有節點先處理插入,然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的,因為時鐘不可能被充分地同步,以便在主庫2處正確地排序這些事件(見[第8章](ch8.md))。 + +​ 要正確排序這些事件,可以使用一種稱為**版本向量(version vectors)**的技術,本章稍後將討論這種技術(參閱“[檢測併發寫入](#檢測併發寫入)”)。然而,衝突檢測技術在許多多領導者複製系統中執行得不好。例如,在撰寫本文時,PostgreSQL BDR不提供寫入的因果排序【27】,而Tungsten Replicator for MySQL甚至不嘗試檢測衝突【34】。 + +​ 如果您正在使用具有多領導者複製功能的系統,那麼應該瞭解這些問題,仔細閱讀文件,並徹底測試您的資料庫,以確保它確實提供了您認為具有的保證。 + + + +## 無主複製 + +​ 我們在本章到目前為止所討論的複製方法 ——單主複製、多主複製——都是這樣的想法:客戶端向一個主庫傳送寫請求,而資料庫系統負責將寫入複製到其他副本。主庫決定寫入的順序,而從庫按相同順序應用主庫的寫入。 + +​ 一些資料儲存系統採用不同的方法,放棄主庫的概念,並允許任何副本直接接受來自客戶端的寫入。最早的一些的複製資料系統是**無領導的(leaderless)**【1,44】,但是在關係資料庫主導的時代,這個想法幾乎已被忘卻。在亞馬遜將其用於其內部的Dynamo系統[^vi]之後,它再一次成為資料庫的一種時尚架構【37】。 Riak,Cassandra和Voldemort是由Dynamo啟發的無領導複製模型的開源資料儲存,所以這類資料庫也被稱為*Dynamo風格*。 + +[^vi]: Dynamo不適用於Amazon以外的使用者。 令人困惑的是,AWS提供了一個名為DynamoDB的託管資料庫產品,它使用了完全不同的體系結構:它基於單載入程式複製。 + +​ 在一些無領導者的實現中,客戶端直接將寫入傳送到到幾個副本中,而另一些情況下,一個**協調者(coordinator)**節點代表客戶端進行寫入。但與主庫資料庫不同,協調者不執行特定的寫入順序。我們將會看到,這種設計上的差異對資料庫的使用方式有著深遠的影響。 + +### 當節點故障時寫入資料庫 + +​ 假設你有一個帶有三個副本的資料庫,而其中一個副本目前不可用,或許正在重新啟動以安裝系統更新。在基於主機的配置中,如果要繼續處理寫入,則可能需要執行故障切換(參閱「[處理節點宕機](#處理節點宕機)」)。 + +​ 另一方面,在無領導配置中,故障切換不存在。[圖5-10](img/fig5-10.png)顯示了發生了什麼事情:客戶端(使用者1234)並行傳送寫入到所有三個副本,並且兩個可用副本接受寫入,但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的:在使用者1234已經收到兩個確定的響應之後,我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。 + +![](img/fig5-10.png) + +**圖5-10 法定寫入,法定讀取,並在節點中斷後讀修復。** + +​ 現在想象一下,不可用的節點重新聯機,客戶端開始讀取它。節點關閉時發生的任何寫入都從該節點丟失。因此,如果您從該節點讀取資料,則可能會將陳舊(過時)值視為響應。 + +​ 為了解決這個問題,當一個客戶端從資料庫中讀取資料時,它不僅僅傳送它的請求到一個副本:讀請求也被並行地傳送到多個節點。客戶可能會從不同的節點獲得不同的響應。即來自一個節點的最新值和來自另一個節點的陳舊值。版本號用於確定哪個值更新(參閱“[檢測併發寫入](#檢測併發寫入)”)。 + +#### 讀修復和反熵 + +​ 複製方案應確保最終將所有資料複製到每個副本。在一個不可用的節點重新聯機之後,它如何趕上它錯過的寫入? + +​ 在Dynamo風格的資料儲存中經常使用兩種機制: + +***讀修復(Read repair)*** + +​ 當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](img/fig5-10.png)中,使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值,並將新值寫回到該副本。這種方法適用於讀頻繁的值。 + +***反熵過程(Anti-entropy process)*** + +​ 此外,一些資料儲存具有後臺程序,該程序不斷查詢副本之間的資料差異,並將任何缺少的資料從一個副本複製到另一個副本。與基於領導者的複製中的複製日誌不同,此反熵過程不會以任何特定的順序複製寫入,並且在複製資料之前可能會有顯著的延遲。 + +​ 並不是所有的系統都實現了這兩個,例如,Voldemort目前沒有反熵過程。請注意,如果沒有反熵過程,某些副本中很少讀取的值可能會丟失,從而降低了永續性,因為只有在應用程式讀取值時才執行讀修復。 + +#### 讀寫的法定人數 + +​ 在[圖5-10](img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?我們能推多遠呢? + +​ 如果我們知道,每個成功的寫操作意味著在三個副本中至少有兩個出現,這意味著至多有一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確定至少有一個是最新的。如果第三個副本停機或響應速度緩慢,則讀取仍可以繼續返回最新值。 + +​ 更一般地說,如果有n個副本,每個寫入必須由w節點確認才能被認為是成功的,並且我們必須至少為每個讀取查詢r個節點。 (在我們的例子中,$n = 3,w = 2,r = 2$)。只要$w + r> n$,我們期望在讀取時獲得最新的值,因為r個讀取中至少有一個節點是最新的。遵循這些r值,w值的讀寫稱為**法定人數(quorum)**[^vii]的讀和寫【44】。你可以認為,r和w是有效讀寫所需的最低票數。 + +[^vii]: 有時候這種法定人數被稱為嚴格的法定人數,相對“寬鬆的法定人數”而言(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”) + +​ 在Dynamo風格的資料庫中,引數n,w和r通常是可配置的。一個常見的選擇是使n為奇數(通常為3或5)並設定 $w = r =(n + 1)/ 2$(向上取整)。但是可以根據需要更改數字。例如,設定$w = n$和$r = 1$的寫入很少且讀取次數較多的工作負載可能會受益。這使得讀取速度更快,但具有隻有一個失敗節點導致所有資料庫寫入失敗的缺點。 + +> 叢集中可能有多於n的節點。(叢集的機器數可能多於副本數目),但是任何給定的值只能儲存在n個節點上。 這允許對資料集進行分割槽,從而支援可以放在一個節點上的資料集更大的資料集。 將在第6章回到分割槽。 +> + +法定人數條件$w + r> n$允許系統容忍不可用的節點,如下所示: + +* 如果$w n$,讀取r個副本,至少有一個r副本必然包含了最近的成功寫入** + +​ 如果少於所需的w或r節點可用,則寫入或讀取將返回錯誤。 由於許多原因,節點可能不可用:因為由於執行操作的錯誤(由於磁碟已滿而無法寫入)導致節點關閉(崩潰,關閉電源),由於客戶端和伺服器之間的網路中斷 節點,或任何其他原因。 我們只關心節點是否返回了成功的響應,而不需要區分不同型別的錯誤。 + + + +### 法定人數一致性的侷限性 + +​ 如果你有n個副本,並且你選擇w和r,使得$w + r> n$,你通常可以期望每個讀取返回為一個鍵寫的最近的值。情況就是這樣,因為你寫的節點集合和你讀過的節點集合必須重疊。也就是說,您讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](img/fig5-11.png)所示)。 + +​ 通常,r和w被選為多數(超過 $n/2$ )節點,因為這確保了$w + r> n$,同時仍然容忍多達$n/2$個節點故障。但是,法定人數不一定必須是大多數,只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的,這使得分散式演算法的設計有一定的靈活性【45】。 + +​ 您也可以將w和r設定為較小的數字,以使$w + r≤n$(即法定條件不滿足)。在這種情況下,讀取和寫入操作仍將被髮送到n個節點,但操作成功只需要少量的成功響應。 + +​ 較小的w和r更有可能會讀取過時的資料,因為您的讀取更有可能不包含具有最新值的節點。另一方面,這種配置允許更低的延遲和更高的可用性:如果存在網路中斷,並且許多副本變得無法訪問,則可以繼續處理讀取和寫入的機會更大。只有當可達副本的數量低於w或r時,資料庫才分別變得不可用於寫入或讀取。 + +但是,即使在$w + r> n$的情況下,也可能存在返回陳舊值的邊緣情況。這取決於實現,但可能的情況包括: + +* 如果使用寬鬆的法定人數(見“[寬鬆的法定人數與提示移交](#寬鬆的法定人數與提示移交)”),w個寫入和r個讀取落在完全不同的節點上,因此r節點和w之間不再保證有重疊節點【46】。 +* 如果兩個寫入同時發生,不清楚哪一個先發生。在這種情況下,唯一安全的解決方案是合併併發寫入(請參閱第171頁的“處理寫入衝突”)。如果根據時間戳(最後寫入勝利)挑選出一個勝者,則由於時鐘偏差[35],寫入可能會丟失。我們將返回“[檢測併發寫入](#檢測併發寫入)”中的此主題。 +* 如果寫操作與讀操作同時發生,寫操作可能僅反映在某些副本上。在這種情況下,不確定讀取是返回舊值還是新值。 +* 如果寫操作在某些副本上成功,而在其他節點上失敗(例如,因為某些節點上的磁碟已滿),在小於w個副本上寫入成功。所以整體判定寫入失敗,但整體寫入失敗並沒有在寫入成功的副本上回滾。這意味著如果一個寫入雖然報告失敗,後續的讀取仍然可能會讀取這次失敗寫入的值【47】。 +* 如果攜帶新值的節點失敗,需要讀取其他帶有舊值的副本。並且其資料從帶有舊值的副本中恢復,則儲存新值的副本數可能會低於w,從而打破法定人數條件。 +* 即使一切工作正常,有時也會不幸地出現關於**時序(timing)**的邊緣情況,我們將在第334頁上的“[線性化和法定人數](ch9.md#線性化和法定人數)”中看到這點。 + +因此,儘管法定人數似乎保證讀取返回最新的寫入值,但在實踐中並不那麼簡單。 Dynamo風格的資料庫通常針對可以忍受最終一致性的用例進行最佳化。允許透過引數w和r來調整讀取陳舊值的概率,但把它們當成絕對的保證是不明智的。 + +​ 尤其是,通常沒有得到“[與延遲有關的問題](#)”(讀取您的寫入,單調讀取或一致的字首讀取)中討論的保證,因此前面提到的異常可能會發生在應用程式中。更強有力的保證通常需要**事務**或**共識**。我們將在[第七章](ch7.md)和[第九章](ch9.md)回到這些話題。 + +#### 監控陳舊度 + +​ 從運維的角度來看,監視你的資料庫是否返回最新的結果是很重要的。即使應用可以容忍陳舊的讀取,您也需要了解複製的健康狀況。如果顯著落後,應該提醒您,以便您可以調查原因(例如,網路中的問題或超載節點)。 + +​ 對於基於領導者的複製,資料庫通常會公開復制滯後的度量標準,您可以將其提供給監視系統。這是可能的,因為寫入按照相同的順序應用於領導者和追隨者,並且每個節點在複製日誌中具有一個位置(在本地應用的寫入次數)。透過從領導者的當前位置中減去隨從者的當前位置,您可以測量複製滯後量。 + +​ 然而,在無領導者複製的系統中,沒有固定的寫入順序,這使得監控變得更加困難。而且,如果資料庫只使用讀修復(沒有反熵過程),那麼對於一個值可能會有多大的限制是沒有限制的 - 如果一個值很少被讀取,那麼由一個陳舊副本返回的值可能是古老的。 + +​ 已經有一些關於衡量無主複製資料庫中的複製陳舊度的研究,並根據引數n,w和r來預測陳舊讀取的預期百分比【48】。不幸的是,這還不是很常見的做法,但是將過時測量值包含在資料庫的標準度量標準中是一件好事。最終的一致性是故意模糊的保證,但是對於可操作性來說,能夠量化“最終”是很重要的。 + +### 寬鬆的法定人數與提示移交 + +​ 合理配置的法定人數可以使資料庫無需故障切換即可容忍個別節點的故障。也可以容忍個別節點變慢,因為請求不必等待所有n個節點響應——當w或r節點響應時它們可以返回。對於需要高可用、低延時、且能夠容忍偶爾讀到陳舊值的應用場景來說,這些特性使無主複製的資料庫很有吸引力。 + +​ 然而,法定人數(如迄今為止所描述的)並不像它們可能的那樣具有容錯性。網路中斷可以很容易地將客戶端從大量的資料庫節點上切斷。雖然這些節點是活著的,而其他客戶端可能能夠連線到它們,但是從資料庫節點切斷的客戶端,它們也可能已經死亡。在這種情況下,剩餘的可用節點可能會少於可用節點,因此客戶端可能無法達到法定人數。 + +​ 在一個大型的群集中(節點數量明顯多於n個),網路中斷期間客戶端可能連線到某些資料庫節點,而不是為了為特定值組成法定人數的節點們。在這種情況下,資料庫設計人員需要權衡一下: + +* 將錯誤返回給我們無法達到w或r節點的法定數量的所有請求是否更好? +* 或者我們是否應該接受寫入,然後將它們寫入一些可達的節點,但不在n值通常存在的n個節點之間? + +後者被認為是一個**寬鬆的法定人數(sloppy quorum)**【37】:寫和讀仍然需要w和r成功的響應,但是那些可能包括不在指定的n個“主”節點中的值。比方說,如果你把自己鎖在房子外面,你可能會敲開鄰居的門,問你是否可以暫時停留在沙發上。 + +​ 一旦網路中斷得到解決,代表另一個節點臨時接受的一個節點的任何寫入都被髮送到適當的“本地”節點。這就是所謂的**提示移交(hinted handoff)**。 (一旦你再次找到你的房子的鑰匙,你的鄰居禮貌地要求你離開沙發回家。) + +​ 寬鬆的法定人數對寫入可用性的提高特別有用:只要有任何w節點可用,資料庫就可以接受寫入。然而,這意味著即使當$w + r> n$時,也不能確定讀取某個鍵的最新值,因為最新的值可能已經臨時寫入了n之外的某些節點【47】。 + +​ 因此,在傳統意義上,一個寬鬆的法定人數實際上不是一個法定人數。這只是一個保證,即資料儲存在w節點的地方。不能保證r節點的讀取直到提示已經完成。 + +​ 在所有常見的Dynamo實現中,寬鬆的法定人數是可選的。在Riak中,它們預設是啟用的,而在Cassandra和Voldemort中它們預設是禁用的【46,49,50】。 + +#### 運維多個數據中心 + +​ 我們先前討論了跨資料中心複製作為多主複製的用例(參閱“[多主複製](#多主複製)”)。無主複製還適用於多資料中心操作,因為它旨在容忍衝突的併發寫入,網路中斷和延遲尖峰。 + +​ Cassandra和Voldemort在正常的無主模型中實現了他們的多資料中心支援:副本的數量n包括所有資料中心的節點,在配置中,您可以指定每個資料中心中您想擁有的副本的數量。無論資料中心如何,每個來自客戶端的寫入都會發送到所有副本,但客戶端通常只等待來自其本地資料中心內的法定節點的確認,從而不會受到跨資料中心鏈路延遲和中斷的影響。對其他資料中心的高延遲寫入通常被配置為非同步發生,儘管配置有一定的靈活性【50,51】。 + +​ Riak將客戶端和資料庫節點之間的所有通訊保持在一個數據中心本地,因此n描述了一個數據中心內的副本數量。資料庫叢集之間的跨資料中心複製在後臺非同步發生,其風格類似於多領導者複製【52】。 + +### 檢測併發寫入 + +​ Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key,這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似(參閱“[處理寫入衝突](#處理寫入衝突)”),但在Dynamo樣式的資料庫中,在**讀修復**或**提示移交**期間也可能會產生衝突。 + +​ 問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X: + +* 節點 1 接收來自 A 的寫入,但由於暫時中斷,從不接收來自 B 的寫入。 +* 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。 +* 節點 3 首先接收來自 B 的寫入,然後從 A 寫入。 + +![](img/fig5-12.png) + +**圖5-12 併發寫入Dynamo風格的資料儲存:沒有明確定義的順序。** + +​ 如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](img/fig5-12.png)中的最終獲取請求所示:節點2認為 X 的最終值是 B,而其他節點認為值是 A 。 + +​ 為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。 + +​ 在“[處理寫衝突](#處理寫入衝突)”一節中已經簡要介紹了一些解決衝突的技術。在總結本章之前,讓我們來更詳細地探討這個問題。 + +#### 最後寫入勝利(丟棄併發寫入) + +​ 實現最終融合的一種方法是宣告每個副本只需要儲存最**“最近”**的值,並允許**“更舊”**的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是“最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。 + +​ 正如**“最近”**的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是**併發(concurrent)**的,所以它們的順序是不確定的。 + +​ 即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最**“最近”**的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為**最後寫入勝利(LWW, last write wins)**,是Cassandra 【53】唯一支援的衝突解決方法,也是Riak 【35】中的一個可選特徵。 + +​ LWW實現了最終收斂的目標,但以**永續性**為代價:如果同一個Key有多個併發寫入,即使它們都被報告為客戶端成功(因為它們被寫入 w 個副本),但只有一個寫入將存活,而其他寫入將被靜默丟棄。此外,LWW甚至可能會刪除不是併發的寫入,我們將在的“[有序事件的時間戳](ch8.md#有序事件的時間戳)”中討論。 + +​ 有一些情況,如快取,其中丟失的寫入可能是可以接受的。如果丟失資料不可接受,LWW是解決衝突的一個很爛的選擇。 + +​ 與LWW一起使用資料庫的唯一安全方法是確保一個鍵只寫入一次,然後視為不可變,從而避免對同一個金鑰進行併發更新。例如,Cassandra推薦使用的方法是使用UUID作為鍵,從而為每個寫操作提供一個唯一的鍵【53】。 + +#### “此前發生”的關係和併發 + +我們如何判斷兩個操作是否是併發的?為了建立一個直覺,讓我們看看一些例子: + +* 在[圖5-9](fig5-9.png)中,兩個寫入不是併發的:A的插入發生在B的增量之前,因為B遞增的值是A插入的值。換句話說,B的操作建立在A的操作上,所以B的操作必須有後來發生。我們也可以說B是**因果依賴(causally dependent)**於A +* 另一方面,[圖5-12](fig5-12.png)中的兩個寫入是併發的:當每個客戶端啟動操作時,它不知道另一個客戶端也正在執行操作同樣的Key。因此,操作之間不存在因果關係。 + +如果操作B瞭解操作A,或者依賴於A,或者以某種方式構建於操作A之上,則操作A在另一個操作B之前發生。在另一個操作之前是否發生一個操作是定義什麼併發的關鍵。事實上,我們可以簡單地說,如果兩個操作都不在另一個之前發生,那麼兩個操作是併發的(即,兩個操作都不知道另一個)【54】。 + +​ 因此,只要有兩個操作A和B,就有三種可能性:A在B之前發生,或者B在A之前發生,或者A和B併發。我們需要的是一個演算法來告訴我們兩個操作是否是併發的。如果一個操作發生在另一個操作之前,則後面的操作應該覆蓋較早的操作,但是如果這些操作是併發的,則存在需要解決的衝突。 + + + +> #### 併發性,時間和相對性 +> +> ​ 如果兩個操作**“同時”**發生,似乎應該稱為併發——但事實上,它們在字面時間上重疊與否並不重要。由於分散式系統中的時鐘問題,現實中是很難判斷兩個事件是否**同時**發生的,這個問題我們將在[第8章](ch8.md)中詳細討論。 +> +> ​ 為了定義併發性,確切的時間並不重要:如果兩個操作都意識不到對方的存在,就稱這兩個操作**併發**,而不管它們發生的物理時間。人們有時把這個原理和狹義相對論的物理學聯絡起來【54】,它引入了資訊不能比光速更快的思想。因此,如果事件之間的時間短於光透過它們之間的距離,那麼發生一定距離的兩個事件不可能相互影響。 +> +> ​ 在計算機系統中,即使光速原則上允許一個操作影響另一個操作,但兩個操作也可能是**並行的**。例如,如果網路緩慢或中斷,兩個操作間可能會出現一段時間間隔,且仍然是併發的,因為網路問題阻止一個操作意識到另一個操作的存在。 + + + +#### 捕獲"此前發生"關係 + +​ 來看一個演算法,它確定兩個操作是否為併發的,還是一個在另一個之前。為了簡單起見,我們從一個只有一個副本的資料庫開始。一旦我們已經制定了如何在單個副本上完成這項工作,我們可以將該方法概括為具有多個副本的無領導者資料庫。 + +[圖5-13]()顯示了兩個客戶端同時向同一購物車新增專案。 (如果這樣的例子讓你覺得太麻煩了,那麼可以想象,兩個空中交通管制員同時把飛機新增到他們正在跟蹤的區域)最初,購物車是空的。在它們之間,客戶端向資料庫發出五次寫入: + +1. 客戶端 1 將牛奶加入購物車。這是該鍵的第一次寫入,伺服器成功儲存了它併為其分配版本號1,最後將值與版本號一起回送給客戶端。 +2. 客戶端 2 將雞蛋加入購物車,不知道客戶端 1 同時添加了牛奶(客戶端 2 認為它的雞蛋是購物車中的唯一物品)。伺服器為此寫入分配版本號 2,並將雞蛋和牛奶儲存為兩個單獨的值。然後它將這兩個值**都**反回給客戶端 2 ,並附上版本號 2 。 +3. 客戶端 1 不知道客戶端 2 的寫入,想要將麵粉加入購物車,因此認為當前的購物車內容應該是 [牛奶,麵粉]。它將此值與伺服器先前向客戶端 1 提供的版本號 1 一起傳送到伺服器。伺服器可以從版本號中知道[牛奶,麵粉]的寫入取代了[牛奶]的先前值,但與[雞蛋]的值是**併發**的。因此,伺服器將版本 3 分配給[牛奶,麵粉],覆蓋版本1值[牛奶],但保留版本 2 的值[蛋],並將所有的值返回給客戶端 1 。 +4. 同時,客戶端 2 想要加入火腿,不知道客端戶 1 剛剛加了麵粉。客戶端 2 在最後一個響應中從伺服器收到了兩個值[牛奶]和[蛋],所以客戶端 2 現在合併這些值,並新增火腿形成一個新的值,[雞蛋,牛奶,火腿]。它將這個值傳送到伺服器,帶著之前的版本號 2 。伺服器檢測到新值會覆蓋版本 2 [雞蛋],但新值也會與版本 3 [牛奶,麵粉]**併發**,所以剩下的兩個是v3 [牛奶,麵粉],和v4:[雞蛋,牛奶,火腿] +5. 最後,客戶端 1 想要加培根。它以前在v3中從伺服器接收[牛奶,麵粉]和[雞蛋],所以它合併這些,新增培根,並將最終值[牛奶,麵粉,雞蛋,培根]連同版本號v3發往伺服器。這會覆蓋v3[牛奶,麵粉](請注意[雞蛋]已經在最後一步被覆蓋),但與v4[雞蛋,牛奶,火腿]併發,所以伺服器保留這兩個併發值。 + +![](img/fig5-13.png) + +**圖5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。** + +​ [圖5-13](img/fig5-13.png)中的操作之間的資料流如[圖5-14](img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。 + +![](img/fig5-14.png) + +**圖5-14 圖5-13中的因果依賴關係圖。** + +​ 請注意,伺服器可以透過檢視版本號來確定兩個操作是否是併發的——它不需要解釋該值本身(因此該值可以是任何資料結構)。該演算法的工作原理如下: + +* 伺服器為每個鍵保留一個版本號,每次寫入鍵時都增加版本號,並將新版本號與寫入的值一起儲存。 +* 當客戶端讀取鍵時,伺服器將返回所有未覆蓋的值以及最新的版本號。客戶端在寫入前必須讀取。 +* 客戶端寫入鍵時,必須包含之前讀取的版本號,並且必須將之前讀取的所有值合併在一起。 (來自寫入請求的響應可以像讀取一樣,返回所有當前值,這使得我們可以像購物車示例那樣連線多個寫入。) +* 當伺服器接收到具有特定版本號的寫入時,它可以覆蓋該版本號或更低版本的所有值(因為它知道它們已經被合併到新的值中),但是它必須保持所有值更高版本號(因為這些值與傳入的寫入同時發生)。 + +當一個寫入包含前一次讀取的版本號時,它會告訴我們寫入的是哪一種狀態。如果在不包含版本號的情況下進行寫操作,則與所有其他寫操作併發,因此它不會覆蓋任何內容 —— 只會在隨後的讀取中作為其中一個值返回。 + +#### 合併同時寫入的值 + +​ 這種演算法可以確保沒有資料被無聲地丟棄,但不幸的是,客戶端需要做一些額外的工作:如果多個操作併發發生,則客戶端必須透過合併併發寫入的值來擦屁股。 Riak稱這些併發值**兄弟(siblings)**。 + +​ 合併兄弟值,本質上是與多領導者複製中的衝突解決相同的問題,我們先前討論過(參閱“[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中做更聰明的事情。 + +​ 以購物車為例,一種合理的合併兄弟方法就是集合求並。在[圖5-14](img/fig5-14.png)中,最後的兩個兄弟是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋出現在兩個,即使他們每個只寫一次。合併的價值可能是像[牛奶,麵粉,雞蛋,培根,火腿],沒有重複。 + +​ 然而,如果你想讓人們也可以從他們的手推車中**刪除**東西,而不是僅僅新增東西,那麼把兄弟求並可能不會產生正確的結果:如果你合併了兩個兄弟手推車,並且只在其中一個兄弟值裡刪掉了它,那麼被刪除的專案會重新出現在兄弟的並集中【37】。為了防止這個問題,一個專案在刪除時不能簡單地從資料庫中刪除;相反,系統必須留下一個具有合適版本號的標記,以指示合併兄弟時該專案已被刪除。這種刪除標記被稱為**墓碑(tombstone)**。 (我們之前在“[雜湊索引”](ch3.md#雜湊索引)中的日誌壓縮的上下文中看到了墓碑。) + +​ 因為在應用程式程式碼中合併兄弟是複雜且容易出錯的,所以有一些資料結構被設計出來用於自動執行這種合併,如“[自動衝突解決]()”中討論的。例如,Riak的資料型別支援使用稱為CRDT的資料結構家族【38,39,55】可以以合理的方式自動合併兄弟,包括保留刪除。 + +#### 版本向量 + +​ [圖5-13](img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改? + +​ [圖5-13](img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,除了對每個鍵使用版本號之外,還需要在**每個副本**中使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些值,以及保留哪些值作為兄弟。 + +​ 所有副本的版本號集合稱為**版本向量(version vector)**【56】。這個想法的一些變體正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本向量(dotted version vector)**【57】。我們不會深入細節,但是它的工作方式與我們在購物車示例中看到的非常相似。 + +​ 與[圖5-13](img/fig5-13.png)中的版本號一樣,當讀取值時,版本向量會從資料庫副本傳送到客戶端,並且隨後寫入值時需要將其傳送回資料庫。 (Riak將版本向量編碼為一個字串,它稱為**因果上下文(causal context)**)。版本向量允許資料庫區分覆蓋寫入和併發寫入。 + +​ 另外,就像在單個副本的例子中,應用程式可能需要合併兄弟。版本向量結構確保從一個副本讀取並隨後寫回到另一個副本是安全的。這樣做可能會建立兄弟,但只要兄弟姐妹合併正確,就不會丟失資料。 + +> #### 版本向量和向量時鐘 +> +> ​ 版本向量有時也被稱為向量時鐘,即使它們不完全相同。 差別很微妙——請參閱參考資料的細節【57,60,61】。 簡而言之,在比較副本的狀態時,版本向量是正確的資料結構。 +> + +## 本章小結 + +在本章中,我們考察了複製的問題。複製可以用於幾個目的: + +***高可用性*** + +​ 即使在一臺機器(或多臺機器,或整個資料中心)停機的情況下也能保持系統正常執行 + +***斷開連線的操作*** + +​ 允許應用程式在網路中斷時繼續工作 + +***延遲*** + +​ 將資料放置在距離使用者較近的地方,以便使用者能夠更快地與其互動 + +***可擴充套件性*** + +​ 能夠處理比單個機器更高的讀取量可以透過對副本進行讀取來處理 + + + +​ 儘管是一個簡單的目標 - 在幾臺機器上保留相同資料的副本,但複製卻是一個非常棘手的問題。它需要仔細考慮併發和所有可能出錯的事情,並處理這些故障的後果。至少,我們需要處理不可用的節點和網路中斷(甚至不考慮更隱蔽的故障,例如由於軟體錯誤導致的無提示資料損壞)。 + +​ 我們討論了複製的三種主要方法: + +***單主複製*** + +​ 客戶端將所有寫入操作傳送到單個節點(領導者),該節點將資料更改事件流傳送到其他副本(追隨者)。讀取可以在任何副本上執行,但從追隨者讀取可能是陳舊的。 + +***多主複製*** + +​ 客戶端傳送每個寫入到幾個領導節點之一,其中任何一個都可以接受寫入。領導者將資料更改事件流傳送給彼此以及任何跟隨者節點。 + +***無主複製*** + +​ 客戶端傳送每個寫入到幾個節點,並從多個節點並行讀取,以檢測和糾正具有陳舊資料的節點。 +每種方法都有優點和缺點。單主複製是非常流行的,因為它很容易理解,不需要擔心衝突解決。在出現故障節點,網路中斷和延遲峰值的情況下,多領導者和無領導者複製可以更加穩健,但以更難以推理並僅提供非常弱的一致性保證為代價。 + +​ 複製可以是同步的,也可以是非同步的,在發生故障時對系統行為有深遠的影響。儘管在系統執行平穩時非同步複製速度很快,但是在複製滯後增加和伺服器故障時要弄清楚會發生什麼,這一點很重要。如果一個領導者失敗了,並且你推動一個非同步更新的追隨者成為新的領導者,那麼最近承諾的資料可能會丟失。 + +​ 我們研究了一些可能由複製滯後引起的奇怪效應,我們討論了一些有助於決定應用程式在複製滯後時的行為的一致性模型: + +***寫後讀*** + +​ 使用者應該總是看到自己提交的資料。 + +***單調讀*** + + 使用者在一個時間點看到資料後,他們不應該在某個早期時間點看到資料。 + +***一致字首讀*** + +​ 使用者應該將資料視為具有因果意義的狀態:例如,按照正確的順序檢視問題及其答覆。 + + + +​ 最後,我們討論了多領導者和無領導者複製方法所固有的併發問題:因為他們允許多個寫入併發發生衝突。我們研究了一個數據庫可能使用的演算法來確定一個操作是否發生在另一個操作之前,或者它們是否同時發生。我們還談到了透過合併併發更新來解決衝突的方法。 + +​ 在下一章中,我們將繼續研究分佈在多個機器上的資料,透過複製的對應方式:將大資料集分割成分割槽。 + + + + + +## 參考文獻 + +1. Bruce G. Lindsay, Patricia Griffiths Selinger, C. Galtieri, et al.: + “[Notes on Distributed Databases](http://domino.research.ibm.com/library/cyberdig.nsf/papers/A776EC17FC2FCE73852579F100578964/$File/RJ2571.pdf),” IBM Research, Research Report RJ2571(33471), July 1979. + +1. “[Oracle Active Data Guard Real-Time Data Protection and Availability](http://www.oracle.com/technetwork/database/availability/active-data-guard-wp-12c-1896127.pdf),” Oracle White Paper, June 2013. + +1. “[AlwaysOn Availability Groups](http://msdn.microsoft.com/en-us/library/hh510230.aspx),” in *SQL Server Books Online*, Microsoft, 2012. + +1. Lin Qiao, Kapil Surlaker, Shirshanka Das, et al.: “[On Brewing Fresh Espresso: LinkedIn’s Distributed Data Serving Platform](http://www.slideshare.net/amywtang/espresso-20952131),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013. + +1. Jun Rao: “[Intra-Cluster Replication for Apache Kafka](http://www.slideshare.net/junrao/kafka-replication-apachecon2013),” at *ApacheCon North America*, February 2013. + +1. “[Highly Available Queues](https://www.rabbitmq.com/ha.html),” in *RabbitMQ Server Documentation*, Pivotal Software, Inc., 2014. + +1. Yoshinori Matsunobu: “[Semi-Synchronous Replication at Facebook](http://yoshinorimatsunobu.blogspot.co.uk/2014/04/semi-synchronous-replication-at-facebook.html),” *yoshinorimatsunobu.blogspot.co.uk*, April 1, 2014. + +1. Robbert van Renesse and Fred B. Schneider: “[Chain Replication for Supporting High Throughput and Availability](http://static.usenix.org/legacy/events/osdi04/tech/full_papers/renesse/renesse.pdf),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004. + +1. Jeff Terrace and Michael J. Freedman: “[Object Storage on CRAQ: High-Throughput Chain Replication for Read-Mostly Workloads](https://www.usenix.org/legacy/event/usenix09/tech/full_papers/terrace/terrace.pdf),” at *USENIX Annual Technical Conference* (ATC), June 2009. + +1. Brad Calder, Ju Wang, Aaron Ogus, et al.: “[Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency](http://sigops.org/sosp/sosp11/current/2011-Cascais/printable/11-calder.pdf),” at *23rd ACM Symposium on Operating Systems Principles* (SOSP), October 2011. + +1. Andrew Wang: “[Windows Azure Storage](http://umbrant.com/blog/2016/windows_azure_storage.html),” *umbrant.com*, February 4, 2016. + +1. “[Percona Xtrabackup - Documentation](https://www.percona.com/doc/percona-xtrabackup/2.1/index.html),” Percona LLC, 2014. + +1. Jesse Newland: “[GitHub Availability This Week](https://github.com/blog/1261-github-availability-this-week),” *github.com*, September 14, 2012. + +1. Mark Imbriaco: “[Downtime Last Saturday](https://github.com/blog/1364-downtime-last-saturday),” *github.com*, December 26, 2012. + +1. John Hugg: “[‘All in’ with Determinism for Performance and Testing in Distributed Systems](https://www.youtube.com/watch?v=gJRj3vJL4wE),” at *Strange Loop*, September 2015. Amit Kapila: “[WAL Internals of PostgreSQL](http://www.pgcon.org/2012/schedule/attachments/258_212_Internals%20Of%20PostgreSQL%20Wal.pdf),” at *PostgreSQL Conference* (PGCon), May 2012. + +1. [*MySQL Internals Manual*](http://dev.mysql.com/doc/internals/en/index.html). Oracle, 2014. + +1. Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, et al.: “[Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-sharma.pdf),” at *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015. + +1. “[Oracle GoldenGate 12c: Real-Time Access to Real-Time Information](http://www.oracle.com/us/products/middleware/data-integration/oracle-goldengate-realtime-access-2031152.pdf),” Oracle White Paper, October 2013. + +1. Shirshanka Das, Chavdar Botev, Kapil Surlaker, et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at + *ACM Symposium on Cloud Computing* (SoCC), October 2012. + +1. Greg Sabino Mullane: “[Version 5 of Bucardo Database Replication System](http://blog.endpoint.com/2014/06/bucardo-5-multimaster-postgres-released.html),” *blog.endpoint.com*, June 23, 2014. + +1. Werner Vogels: “[Eventually Consistent](http://queue.acm.org/detail.cfm?id=1466448),” *ACM Queue*, volume 6, number 6, pages 14–19, October 2008. + [doi:10.1145/1466443.1466448](http://dx.doi.org/10.1145/1466443.1466448) + +1. Douglas B. Terry: “[Replicated Data Consistency Explained Through Baseball](http://research.microsoft.com/pubs/157411/ConsistencyAndBaseballReport.pdf),” Microsoft Research, Technical Report MSR-TR-2011-137, October 2011. + +1. Douglas B. Terry, Alan J. Demers, Karin Petersen, et al.: “[Session Guarantees for Weakly Consistent Replicated Data](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.71.2269&rep=rep1&type=pdf),” at *3rd International Conference on Parallel and Distributed Information Systems* (PDIS), September 1994. [doi:10.1109/PDIS.1994.331722](http://dx.doi.org/10.1109/PDIS.1994.331722) + +1. Terry Pratchett: *Reaper Man: A Discworld Novel*. Victor Gollancz, 1991. ISBN: 978-0-575-04979-6 + +1. “[Tungsten Replicator](http://tungsten-replicator.org/),” Continuent, Inc., 2014. + +1. “[BDR 0.10.0 Documentation](http://bdr-project.org/docs/next/index.html),” The PostgreSQL Global Development Group, *bdr-project.org*, 2015. + +1. Robert Hodges: + “[If You *Must* Deploy Multi-Master Replication, Read This First](http://scale-out-blog.blogspot.co.uk/2012/04/if-you-must-deploy-multi-master.html),” *scale-out-blog.blogspot.co.uk*, + March 30, 2012. + +1. J. Chris Anderson, Jan Lehnardt, and Noah Slater: *CouchDB: The Definitive Guide*. O'Reilly Media, 2010. + ISBN: 978-0-596-15589-6 + +1. AppJet, Inc.: “[Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad-lite/blob/e2ce9dc/doc/easysync/easysync-full-description.pdf),” *github.com*, March 26, 2011. + +1. John Day-Richter: “[What’s Different About the New Google Docs: Making Collaboration Fast](http://googledrive.blogspot.com/2010/09/whats-different-about-new-google-docs.html),” *googledrive.blogspot.com*, 23 September 2010. + +1. Martin Kleppmann and Alastair R. Beresford: “[A Conflict-Free Replicated JSON Datatype](http://arxiv.org/abs/1608.03960),” + arXiv:1608.03960, August 13, 2016. + +1. Frazer Clement: “[Eventual Consistency – Detecting Conflicts](http://messagepassing.blogspot.co.uk/2011/10/eventual-consistency-detecting.html),” *messagepassing.blogspot.co.uk*, October 20, 2011. + +1. Robert Hodges: “[State of the Art for MySQL Multi-Master Replication](https://www.percona.com/live/mysql-conference-2013/sessions/state-art-mysql-multi-master-replication),” at *Percona Live: MySQL Conference & Expo*, April 2013. + +1. John Daily: “[Clocks Are Bad, or, Welcome to the Wonderful World of Distributed Systems](http://basho.com/clocks-are-bad-or-welcome-to-distributed-systems/),” *basho.com*, November 12, 2013. + +1. Riley Berton: “[Is Bi-Directional Replication (BDR) in Postgres Transactional?](http://sdf.org/~riley/blog/2016/01/04/is-bi-directional-replication-bdr-in-postgres-transactional/),” *sdf.org*, January 4, 2016. + +1. Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, et al.: “[Dynamo: Amazon's Highly Available Key-Value Store](http://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf),” at *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007. + +1. Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski: “[A Comprehensive Study of Convergent and Commutative Replicated Data Types](http://hal.inria.fr/inria-00555588/),” INRIA Research Report no. 7506, + January 2011. + +1. Sam Elliott: “[CRDTs: An UPDATE (or Maybe Just a PUT)](https://speakerdeck.com/lenary/crdts-an-update-or-just-a-put),” at *RICON West*, October 2013. + +1. Russell Brown: “[A Bluffers Guide to CRDTs in Riak](https://gist.github.com/russelldb/f92f44bdfb619e089a4d),” *gist.github.com*, October 28, 2013. + +1. Benjamin Farinier, Thomas Gazagnaire, and Anil Madhavapeddy: “[Mergeable Persistent Data Structures](http://gazagnaire.org/pub/FGM15.pdf),” at *26es Journées Francophones des Langages Applicatifs* (JFLA), January 2015. + +1. Chengzheng Sun and Clarence Ellis: “[Operational Transformation in Real-Time Group Editors: Issues, Algorithms, and Achievements](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.53.933&rep=rep1&type=pdf),” at *ACM Conference on Computer Supported Cooperative Work* (CSCW), November 1998. + +1. Lars Hofhansl: “[HBASE-7709: Infinite Loop Possible in Master/Master Replication](https://issues.apache.org/jira/browse/HBASE-7709),” *issues.apache.org*, January 29, 2013. + +1. David K. Gifford: “[Weighted Voting for Replicated Data](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.84.7698),” at *7th ACM Symposium on Operating Systems Principles* (SOSP), December 1979. [doi:10.1145/800215.806583](http://dx.doi.org/10.1145/800215.806583) + +1. Heidi Howard, Dahlia Malkhi, and Alexander Spiegelman: “[Flexible Paxos: Quorum Intersection Revisited](https://arxiv.org/abs/1608.06696),” *arXiv:1608.06696*, August 24, 2016. + +1. Joseph Blomstedt: “[Re: Absolute Consistency](http://lists.basho.com/pipermail/riak-users_lists.basho.com/2012-January/007157.html),” email to *riak-users* mailing list, *lists.basho.com*, + January 11, 2012. + +1. Joseph Blomstedt: “[Bringing Consistency to Riak](https://vimeo.com/51973001),” at *RICON West*, October 2012. + +1. Peter Bailis, Shivaram Venkataraman, Michael J. Franklin, et al.: “[Quantifying Eventual Consistency with PBS](http://www.bailis.org/papers/pbs-cacm2014.pdf),” *Communications of the ACM*, volume 57, number 8, pages 93–102, August 2014. [doi:10.1145/2632792](http://dx.doi.org/10.1145/2632792) + +1. Jonathan Ellis: “[Modern Hinted Handoff](http://www.datastax.com/dev/blog/modern-hinted-handoff),” *datastax.com*, December 11, 2012. + +1. “[Project Voldemort Wiki](https://github.com/voldemort/voldemort/wiki),” *github.com*, 2013. + +1. “[Apache Cassandra 2.0 Documentation](http://www.datastax.com/documentation/cassandra/2.0/index.html),” DataStax, Inc., 2014. + +1. “[Riak Enterprise: Multi-Datacenter Replication](http://basho.com/assets/MultiDatacenter_Replication.pdf).” Technical whitepaper, Basho Technologies, Inc., + September 2014. + +1. Jonathan Ellis: “[Why Cassandra Doesn't Need Vector Clocks](http://www.datastax.com/dev/blog/why-cassandra-doesnt-need-vector-clocks),” *datastax.com*, September 2, 2013. + +1. Leslie Lamport: “[Time, Clocks, and the Ordering of Events in a Distributed System](http://research.microsoft.com/en-US/um/people/Lamport/pubs/time-clocks.pdf),” *Communications of the ACM*, volume 21, number 7, pages 558–565, July 1978. [doi:10.1145/359545.359563](http://dx.doi.org/10.1145/359545.359563) + +1. Joel Jacobson: “[Riak 2.0: Data Types](http://blog.joeljacobson.com/riak-2-0-data-types/),” *blog.joeljacobson.com*, March 23, 2014. + +1. D. Stott Parker Jr., Gerald J. Popek, Gerard Rudisin, et al.: “[Detection of Mutual Inconsistency in Distributed Systems](http://zoo.cs.yale.edu/classes/cs426/2013/bib/parker83detection.pdf),” *IEEE Transactions on Software Engineering*, volume 9, number 3, pages 240–247, May 1983. [doi:10.1109/TSE.1983.236733](http://dx.doi.org/10.1109/TSE.1983.236733) + +1. Nuno Preguiça, Carlos Baquero, Paulo Sérgio Almeida, et al.: “[Dotted Version Vectors: Logical Clocks for Optimistic Replication](http://arxiv.org/pdf/1011.5808v1.pdf),” arXiv:1011.5808, November 26, 2010. + +1. Sean Cribbs: “[A Brief History of Time in Riak](https://www.youtube.com/watch?v=HHkKPdOi-ZU),” at *RICON*, October 2014. + +1. Russell Brown: “[Vector Clocks Revisited Part 2: Dotted Version Vectors](http://basho.com/posts/technical/vector-clocks-revisited-part-2-dotted-version-vectors/),” *basho.com*, November 10, 2015. + +1. Carlos Baquero: “[Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/),” *haslab.wordpress.com*, July 8, 2011. + +1. Reinhard Schwarz and Friedemann Mattern: “[Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](http://dcg.ethz.ch/lectures/hs08/seminar/papers/mattern4.pdf),” *Distributed Computing*, volume 7, number 3, pages 149–174, March 1994. [doi:10.1007/BF02277859](http://dx.doi.org/10.1007/BF02277859) + +-------- + +| 上一章 | 目錄 | 下一章 | +| :--------------------------------: | :-----------------------------: | :--------------------: | +| [第二部分:分散式資料](part-ii.md) | [設計資料密集型應用](README.md) | [第六章:分割槽](ch6.md) | diff --git a/zh-tw/ch6.md b/zh-tw/ch6.md new file mode 100644 index 00000000..7a98427e --- /dev/null +++ b/zh-tw/ch6.md @@ -0,0 +1,410 @@ +# 6. 分割槽 + +![](img/ch6.png) + +> 我們必須跳出電腦指令序列的窠臼。 敘述定義、描述元資料、梳理關係,而不是編寫過程。 +> +> —— Grace Murray Hopper,未來的計算機及其管理(1962) +> + +------------- + +[TOC] + +​ 在[第5章](ch5.md)中,我們討論了複製——即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行**分割槽(partitions)**,也稱為**分片(sharding)**[^i] + +[^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與**網路分割槽(net splits)**無關,這是節點之間網路中的一種故障型別。我們將在[第8章](ch8.md)討論這些錯誤。 + +> ##### 術語澄清 +> +> ​ 上文中的**分割槽(partition)**,在MongoDB,Elasticsearch和Solr Cloud中被稱為**分片(shard)**,在HBase中稱之為**區域(Region)**,Bigtable中則是 **表塊(tablet)**,Cassandra和Riak中是**虛節點(vnode)**, Couchbase中叫做**虛桶(vBucket)**.但是**分割槽(partition)** 是約定俗成的叫法。 +> + +​ 通常情況下,每條資料(每條記錄,每行或每個文件)屬於且僅屬於一個分割槽。有很多方法可以實現這一點,本章將進行深入討論。實際上,每個分割槽都是自己的小型資料庫,儘管資料庫可能支援同時進行多個分割槽的操作。 + +​ 分割槽主要是為了**可擴充套件性**。不同的分割槽可以放在不共享叢集中的不同節點上(參閱[第二部分](part-ii.md)關於[無共享架構](part-ii.md#無共享架構)的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。 + +​ 對於在單個分割槽上執行的查詢,每個節點可以獨立執行對自己的查詢,因此可以透過新增更多的節點來擴大查詢吞吐量。大型,複雜的查詢可能會跨越多個節點並行處理,儘管這也帶來了新的困難。 + +​ 分割槽資料庫在20世紀80年代由Teradata和NonStop SQL【1】等產品率先推出,最近因為NoSQL資料庫和基於Hadoop的資料倉庫重新被關注。有些系統是為事務性工作設計的,有些系統則用於分析(參閱“[事務處理或分析]”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。 + +​ 在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論[重新平衡分割槽](#重新平衡分割槽),如果想要新增或刪除群集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。 + +## 分割槽與複製 + +​ 分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。 這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。 + +​ 一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1]()所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。 +我們在[第5章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。 + +![](img/fig6-1.png) + +**圖6-1 組合使用複製和分割槽:每個節點充當某些分割槽的領導者,其他分割槽充當追隨者。** + +## 鍵值資料的分割槽 + +​ 假設你有大量資料並且想要分割槽,如何決定在哪些節點上儲存哪些記錄呢? + +​ 分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載,那麼理論上10個節點應該能夠處理10倍的資料量和10倍的單個節點的讀寫吞吐量(暫時忽略複製)。 + +​ 如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為**偏斜(skew)**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下,所有的負載可能壓在一個分割槽上,其餘9個節點空閒的,瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為**熱點(hot spot)**。 + +​ 避免熱點最簡單的方法是將記錄隨機分配給節點。這將在所有節點上平均分配資料,但是它有一個很大的缺點:當你試圖讀取一個特定的值時,你無法知道它在哪個節點上,所以你必須並行地查詢所有的節點。 + +​ 我們可以做得更好。現在假設您有一個簡單的鍵值資料模型,其中您總是透過其主鍵訪問記錄。例如,在一本老式的紙質百科全書中,你可以透過標題來查詢一個條目;由於所有條目按字母順序排序,因此您可以快速找到您要查詢的條目。 + +### 根據鍵的範圍分割槽 + +​ 一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙百科全書的卷([圖6-2]())。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果您還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。 + +![](img/fig6-2.png) + +**圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽** + +​ 鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在[圖6-2]()中,第1捲包含以A和B開頭的單詞,但第12卷則包含以T,U,V,X,Y和Z開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料,分割槽邊界需要依據資料調整。 + +​ 分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在“[重新平衡分割槽]()”中更詳細地討論分割槽邊界的選擇)。 Bigtable使用了這種分割槽策略,以及其開源等價物HBase 【2, 3】,RethinkDB和2.4版本之前的MongoDB 【4】。 + +​ 在每個分割槽中,我們可以按照一定的順序儲存鍵(參見“[SSTables和LSM-樹]()”)。好處是進行範圍掃描非常簡單,您可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(參閱“[多列索引](#ch2.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。 + +​ 然而,Key Range分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是,由於我們在測量發生時將資料從感測器寫入資料庫,因此所有寫入操作都會轉到同一個分割槽(即今天的分割槽),這樣分割槽可能會因寫入而過載,而其他分割槽則處於空閒狀態【5】。 + +​ 為了避免感測器資料庫中的這個問題,需要使用除了時間戳以外的其他東西作為主鍵的第一個部分。 例如,可以在每個時間戳前新增感測器名稱,這樣會首先按感測器名稱,然後按時間進行分割槽。 假設有多個感測器同時執行,寫入負載將最終均勻分佈在不同分割槽上。 現在,當想要在一個時間範圍內獲取多個感測器的值時,您需要為每個感測器名稱執行一個單獨的範圍查詢。 + +### 根據鍵的雜湊分割槽 + +​ 由於偏斜和熱點的風險,許多分散式資料儲存使用雜湊函式來確定給定鍵的分割槽。 + +​ 一個好的雜湊函式可以將將偏斜的資料均勻分佈。假設你有一個32位雜湊函式,無論何時給定一個新的字串輸入,它將返回一個0到$2^{32}$ -1之間的"隨機"數。即使輸入的字串非常相似,它們的雜湊也會均勻分佈在這個數字範圍內。 + +​ 出於分割槽的目的,雜湊函式不需要多麼強壯的加密演算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式(它們用於雜湊表),但是它們可能不適合分割槽:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一個鍵可能在不同的程序中有不同的雜湊值【6】。 + +​ 一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](img/fig6-3.png)所示。 + +![](img/fig6-3.png) + +**圖6-3 按雜湊鍵分割槽** + +​ 這種技術擅長在分割槽之間分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為**一致性雜湊(consistent hashing)**)。 + +> #### 一致性雜湊 +> +> ​ 一致性雜湊由Karger等人定義。【7】 用於跨網際網路級別的快取系統,例如CDN中,是一種能均勻分配負載的方法。它使用隨機選擇的**分割槽邊界(partition boundaries)**來避免中央控制或分散式一致性的需要。 請注意,這裡的一致性與複製一致性(請參閱第5章)或ACID一致性(參閱[第7章](ch7.md))無關,而是描述了重新平衡的特定方法。 +> +> ​ 正如我們將在“[重新平衡分割槽](#重新平衡分割槽)”中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然指的是一致性雜湊,但是它 往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為**雜湊分割槽(hash partitioning)**。 + +​ 不幸的是,透過使用Key雜湊進行分割槽,我們失去了鍵範圍分割槽的一個很好的屬性:高效執行範圍查詢的能力。曾經相鄰的金鑰現在分散在所有分割槽中,所以它們之間的順序就丟失了。在MongoDB中,如果您使用了基於雜湊的分割槽模式,則任何範圍查詢都必須傳送到所有分割槽【4】。Riak 【9】,Couchbase 【10】或Voldemort不支援主鍵上的範圍查詢。 + +​ Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據,而其他列則被用作Casssandra的SSTables中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表,但如果第一列已經指定了固定值,則可以對該鍵的其他列執行有效的範圍掃描。 + +​ 組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為`(user_id, update_timestamp)`,那麼您可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。 + +### 負載傾斜與消除熱點 + +​ 如前所述,雜湊分割槽可以幫助減少熱點。但是,它不能完全避免它們:在極端情況下,所有的讀寫操作都是針對同一個鍵的,所有的請求都會被路由到同一個分割槽。 + +​ 這種場景也許並不常見,但並非聞所未聞:例如,在社交媒體網站上,一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致大量寫入同一個鍵(鍵可能是名人的使用者ID,或者人們正在評論的動作的ID)。雜湊策略不起作用,因為兩個相同ID的雜湊值仍然是相同的。 + +​ 如今,大多數資料系統無法自動補償這種高度偏斜的負載,因此應用程式有責任減少偏斜。例如,如果一個主鍵被認為是非常火爆的,一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為100種不同的主鍵,從而儲存在不同的分割槽中。 + +​ 然而,將主鍵進行分割之後,任何讀取都必須要做額外的工作,因為他們必須從所有100個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄:只需要對少量熱點附加隨機數;對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此,您還需要一些方法來跟蹤哪些鍵需要被分割。 + +​ 也許在將來,資料系統將能夠自動檢測和補償偏斜的工作負載;但現在,您需要自己來權衡。 + + +## 分片與次級索引 + + +​ 到目前為止,我們討論的分割槽方案依賴於鍵值資料模型。如果只通過主鍵訪問記錄,我們可以從該鍵確定分割槽,並使用它來將讀寫請求路由到負責該鍵的分割槽。 + +​ 如果涉及次級索引,情況會變得更加複雜(參考“[其他索引結構]()”)。輔助索引通常並不能唯一地標識記錄,而是一種搜尋記錄中出現特定值的方式:查詢使用者123的所有操作,查詢包含詞語`hogwash`的所有文章,查詢所有顏色為紅色的車輛等等。 + +​ 次級索引是關係型資料庫的基礎,並且在文件資料庫中也很普遍。許多鍵值儲存(如HBase和Volde-mort)為了減少實現的複雜度而放棄了次級索引,但是一些(如Riak)已經開始新增它們,因為它們對於資料模型實在是太有用了。並且次級索引也是Solr和Elasticsearch等搜尋伺服器的基石。 + +​ 次級索引的問題是它們不能整齊地對映到分割槽。有兩種用二級索引對資料庫進行分割槽的方法:**基於文件的分割槽(document-based)**和**基於關鍵詞(term-based)的分割槽**。 + +### 基於文件的二級索引進行分割槽 + +​ 假設你正在經營一個銷售二手車的網站(如[圖6-4](img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽(例如,分割槽0中的ID 0到499,分割槽1中的ID 500到999等)。 + +​ 你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位(field)**,關係資料庫中這些是**列(column)** )。 如果您聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。 + +[^ii]: 如果資料庫僅支援鍵值模型,則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現輔助索引。 如果沿著這條路線走下去,請萬分小心,確保您的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 參見“[多物件事務的需求]()”。 + +![](img/fig6-4.png) + +**圖6-4 基於文件的二級索引進行分割槽** + +​ 在這種索引方法中,每個分割槽是完全獨立的:每個分割槽維護自己的二級索引,僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時您需要寫入資料庫(新增,刪除或更新文件),只需處理包含您正在編寫的文件ID的分割槽即可。出於這個原因,**文件分割槽索引**也被稱為**本地索引(local index)**(而不是將在下一節中描述的**全域性索引(global index)**)。 + +​ 但是,從文件分割槽索引中讀取需要注意:除非您對文件ID做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](img/fig6-4.png)中,紅色汽車出現在分割槽0和分割槽1中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。 + + +​ 這種查詢分割槽資料庫的方法有時被稱為**分散/聚集(scatter/gather)**,並且可能會使二級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”)。然而,它被廣泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文件分割槽二級索引。大多數資料庫供應商建議您構建一個能從單個分割槽提供二級索引查詢的分割槽方案,但這並不總是可行,尤其是當在單個查詢中使用多個二級索引時(例如同時需要按顏色和製造商查詢)。 + + +### 基於關鍵詞(Term)的二級索引進行分割槽 + +​ 我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。 + +​ [圖6-5](img/fig6-5.png)述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中,`s`到`z`的在分割槽1。汽車製造商的索引也與之類似(分割槽邊界在`f`和`h`之間)。 + +![](img/fig6-5.png) + +**圖6-5 基於關鍵詞對二級索引進行分割槽** + +​ 我們將這種索引稱為**關鍵詞分割槽(term-partitioned)**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`顏色:紅色`。**關鍵詞(Term)** 來源於來自全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。 + +​ 和之前一樣,我們可以透過**關鍵詞**本身或者它的雜湊進行索引分割槽。根據它本身分割槽對於範圍掃描非常有用(例如對於數字,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。 + +​ 關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要**分散/收集**所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。 + +​ 理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱[第7章](ch7.md)和[第9章](ch9.md))。 + +​ 在實踐中,對全域性二級索引的更新通常是**非同步**的(也就是說,如果在寫入之後不久讀取索引,剛才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB聲稱在正常情況下,其全域性次級索引會在不到一秒的時間內更新,但在基礎架構出現故障的情況下可能會有延遲【20】。 + +​ 全域性關鍵詞分割槽索引的其他用途包括Riak的搜尋功能【21】和Oracle資料倉庫,它允許您在本地和全域性索引之間進行選擇【22】。我們將在[第12章](ch12.md)中涉及實現關鍵字二級索引的話題。 + +## 分割槽再平衡 + +隨著時間的推移,資料庫會有各種變化。 + +* 查詢吞吐量增加,所以您想要新增更多的CPU來處理負載。 +* 資料集大小增加,所以您想新增更多的磁碟和RAM來儲存它。 +* 機器出現故障,其他機器需要接管故障機器的責任。 + +所有這些更改都需要資料和請求從一個節點移動到另一個節點。 將負載從叢集中的一個節點向另一個節點移動的過程稱為**再平衡(reblancing)**。 + +無論使用哪種分割槽方案,再平衡通常都要滿足一些最低要求: + +* 再平衡之後,負載(資料儲存,讀取和寫入請求)應該在叢集中的節點之間公平地共享。 +* 再平衡發生時,資料庫應該繼續接受讀取和寫入。 +* 節點之間只移動必須的資料,以便快速再平衡,並減少網路和磁碟I/O負載。 + + +### 平衡策略 + +有幾種不同的分割槽分配方法【23】,讓我們依次簡要討論一下。 + +#### 反面教材:hash mod N + +​ 我們在前面說過([圖6-3](img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key) ​ 一些作者聲稱,支援通用的兩階段提交代價太大,會帶來效能與可用性的問題。讓程式設計師來處理過度使用事務導致的效能問題,總比缺少事務程式設計好得多。 +> +> ​ ——James Corbett等人,Spanner:Google的全球分散式資料庫(2012) + +------ + +[TOC] + +在資料系統的殘酷現實中,很多事情都可能出錯: + +- 資料庫軟體、硬體可能在任意時刻發生故障(包括寫操作進行到一半時)。 +- 應用程式可能在任意時刻崩潰(包括一系列操作的中間)。 +- 網路中斷可能會意外切斷資料庫與應用的連線,或資料庫之間的連線。 +- 多個客戶端可能會同時寫入資料庫,覆蓋彼此的更改。 +- 客戶端可能讀取到無意義的資料,因為資料只更新了一部分。 +- 客戶之間的競爭條件可能導致令人驚訝的錯誤。 + +為了實現可靠性,系統必須處理這些故障,確保它們不會導致整個系統的災難性故障。但是實現容錯機制工作量巨大。需要仔細考慮所有可能出錯的事情,並進行大量的測試,以確保解決方案真正管用。 + +​ 數十年來,**事務(transaction)** 一直是簡化這些問題的首選機制。事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視作單個操作來執行:整個事務要麼成功(**提交(commit)**)要麼失敗(**中止(abort)**,**回滾(rollback)**)。如果失敗,應用程式可以安全地重試。對於事務來說,應用程式的錯誤處理變得簡單多了,因為它不用再擔心部分失敗的情況了,即某些操作成功,某些失敗(無論出於何種原因)。 + +​ 和事務打交道時間長了,你可能會覺得它顯而易見。但我們不應將其視為理所當然。事務不是天然存在的;它們是為了**簡化應用程式設計模型**而建立的。透過使用事務,應用程式可以自由地忽略某些潛在的錯誤情況和併發問題,因為資料庫會替應用處理好這些。(我們稱之為**安全保證(safety guarantees)**)。 + +​ 並不是所有的應用都需要事務,有時候弱化事務保證、或完全放棄事務也是有好處的(例如,為了獲得更高效能或更高可用性)。一些安全屬性也可以在沒有事務的情況下實現。 + +​ 怎樣知道你是否需要事務?為了回答這個問題,首先需要確切理解事務可以提供的安全保障,以及它們的代價。儘管乍看事務似乎很簡單,但實際上有許多微妙但重要的細節在起作用。 + +​ 本章將研究許多出錯案例,並探索資料庫用於防範這些問題的演算法。尤其會深入**併發控制**的領域,討論各種可能發生的競爭條件,以及資料庫如何實現**讀已提交**,**快照隔離**和**可序列化**等隔離級別。 + +​ 本章同時適用於單機資料庫與分散式資料庫;在[第8章](ch8.md)中將重點討論僅出現在分散式系統中的特殊挑戰。 + + + +## 事務的棘手概念 + +​ 現今,幾乎所有的關係型資料庫和一些非關係資料庫都支援**事務**。其中大多數遵循IBM System R(第一個SQL資料庫)在1975年引入的風格【1,2,3】。40年裡,儘管一些實現細節發生了變化,但總體思路大同小異:MySQL,PostgreSQL,Oracle,SQL Server等資料庫中的事務支援與System R異乎尋常地相似。 + +2000年以後,非關係(NoSQL)資料庫開始普及。它們的目標是透過提供新的資料模型選擇(參見第2章),並透過預設包含複製(第5章)和分割槽(第6章)來改善關係現狀。事務是這種運動的主要原因:這些新一代資料庫中的許多資料庫完全放棄了事務,或者重新定義了這個詞,描述比以前理解所更弱的一套保證【4】。 + +隨著這種新型分散式資料庫的炒作,人們普遍認為事務是可擴充套件性的對立面,任何大型系統都必須放棄事務以保持良好的效能和高可用性【5,6】。另一方面,資料庫廠商有時將事務保證作為“重要應用”和“有價值資料”的基本要求。這兩種觀點都是**純粹的誇張**。 + +事實並非如此簡單:與其他技術設計選擇一樣,事務有其優勢和侷限性。為了理解這些權衡,讓我們瞭解事務所提供保證的細節——無論是在正常執行中還是在各種極端(但是現實存在)情況下。 + +### ACID的含義 + +事務所提供的安全保證,通常由眾所周知的首字母縮略詞ACID來描述,ACID代表**原子性(Atomicity)**,**一致性(Consistency)**,**隔離性(Isolation)**和**永續性(Durability)**。它由TheoHärder和Andreas Reuter於1983年建立,旨在為資料庫中的容錯機制建立精確的術語。 + +但實際上,不同資料庫的ACID實現並不相同。例如,我們將會看到,關於**隔離性(Isolation)** 的含義就有許多含糊不清【8】。高層次上的想法很美好,但魔鬼隱藏在細節裡。今天,當一個系統聲稱自己“符合ACID”時,實際上能期待的是什麼保證並不清楚。不幸的是,ACID現在幾乎已經變成了一個營銷術語。 + +(不符合ACID標準的系統有時被稱為BASE,它代表**基本可用性(Basically Available)**,**軟狀態(Soft State)**和**最終一致性(Eventual consistency)**【9】,這比ACID的定義更加模糊,似乎BASE的唯一合理的定義是“不是ACID”,即它幾乎可以代表任何你想要的東西。) + +讓我們深入瞭解原子性,一致性,隔離性和永續性的定義,這可以讓我們提煉出事務的思想。 + +#### 原子性(Atomicity) + +一般來說,原子是指不能分解成小部分的東西。這個詞在計算機的不同領域中意味著相似但又微妙不同的東西。例如,在多執行緒程式設計中,如果一個執行緒執行一個原子操作,這意味著另一個執行緒無法看到該操作的一半結果。系統只能處於操作之前或操作之後的狀態,而不是介於兩者之間的狀態。 + +相比之下,ACID的原子性並**不**是關於**併發(concurrent)**的。它並不是在描述如果幾個程序試圖同時訪問相同的資料會發生什麼情況,這種情況包含在縮寫 ***I*** 中,即[**隔離性(Isolation)**](#隔離性(Isolation)) + +ACID的原子性而是描述了當客戶想進行多次寫入,但在一些寫操作處理完之後出現故障的情況。例如程序崩潰,網路連線中斷,磁碟變滿或者某種完整性約束被違反。如果這些寫操作被分組到一個原子事務中,並且該事務由於錯誤而不能完成(提交),則該事務將被中止,並且資料庫必須丟棄或撤消該事務中迄今為止所做的任何寫入。 + +如果沒有原子性,在多處更改進行到一半時發生錯誤,很難知道哪些更改已經生效,哪些沒有生效。該應用程式可以再試一次,但冒著進行兩次相同變更的風險,可能會導致資料重複或錯誤的資料。原子性簡化了這個問題:如果事務被**中止(abort)**,應用程式可以確定它沒有改變任何東西,所以可以安全地重試。 + +ACID原子性的定義特徵是:**能夠在錯誤時中止事務,丟棄該事務進行的所有寫入變更的能力。** 或許 **可中止性(abortability)** 是更好的術語,但本書將繼續使用原子性,因為這是慣用詞。 + +#### 一致性(Consistency) + +一致性這個詞被賦予太多含義: + +* 在[第5章](ch5.md)中,我們討論了副本一致性,以及非同步複製系統中的最終一致性問題(參閱“[複製延遲問題](ch5.md#複製延遲問題)”)。 +* [一致性雜湊(Consistency Hash)](ch6.md#一致性雜湊))是某些系統用於重新分割槽的一種分割槽方法。 +* 在[CAP定理](ch9.md#CAP定理)中,一致性一詞用於表示[可線性化](ch9.md#線性化)。 +* 在ACID的上下文中,**一致性**是指資料庫在應用程式的特定概念中處於“良好狀態”。 + +很不幸,這一個詞就至少有四種不同的含義。 + +ACID一致性的概念是,**對資料的一組特定約束必須始終成立**。即**不變數(invariants)**。例如,在會計系統中,所有賬戶整體上必須借貸相抵。如果一個事務開始於一個滿足這些不變數的有效資料庫,且在事務處理期間的任何寫入操作都保持這種有效性,那麼可以確定,不變數總是滿足的。 + +但是,一致性的這種概念取決於應用程式對不變數的觀念,應用程式負責正確定義它的事務,並保持一致性。這並不是資料庫可以保證的事情:如果你寫入違反不變數的髒資料,資料庫也無法阻止你。 (一些特定型別的不變數可以由資料庫檢查,例如外來鍵約束或唯一約束,但是一般來說,是應用程式來定義什麼樣的資料是有效的,什麼樣是無效的。—— 資料庫只管儲存。) + +原子性,隔離性和永續性是資料庫的屬性,而一致性(在ACID意義上)是應用程式的屬性。應用可能依賴資料庫的原子性和隔離屬性來實現一致性,但這並不僅取決於資料庫。因此,字母C不屬於ACID[^i]。 + +[^i]: 喬·海勒斯坦(Joe Hellerstein)指出,在論Härder與Reuter的論文中,“ACID中的C”是被“扔進去湊縮寫單詞的”【7】,而且那時候大家都不怎麼在乎一致性。 + +#### 隔離性(Isolation) + +大多數資料庫都會同時被多個客戶端訪問。如果它們各自讀寫資料庫的不同部分,這是沒有問題的,但是如果它們訪問相同的資料庫記錄,則可能會遇到**併發**問題(**競爭條件(race conditions)**)。 + +[圖7-1](img/fig7-1.png)是這類問題的一個簡單例子。假設你有兩個客戶端同時在資料庫中增長一個計數器。(假設資料庫中沒有自增操作)每個客戶端需要讀取計數器的當前值,加 1 ,再回寫新值。[圖7-1](img/fig7-1.png) 中,因為發生了兩次增長,計數器應該從42增至44;但由於競態條件,實際上只增至 43 。 + +ACID意義上的隔離性意味著,**同時執行的事務是相互隔離的**:它們不能相互冒犯。傳統的資料庫教科書將隔離性形式化為**可序列化(Serializability)**,這意味著每個事務可以假裝它是唯一在整個資料庫上執行的事務。資料庫確保當事務已經提交時,結果與它們按順序執行(一個接一個)是一樣的,儘管實際上它們可能是併發執行的【10】。 + +![](img/fig7-1.png) + +**圖7-1 兩個客戶之間的競爭狀態同時遞增計數器** + +然而實踐中很少會使用可序列化隔離,因為它有效能損失。一些流行的資料庫如Oracle 11g,甚至沒有實現它。在Oracle中有一個名為“可序列化”的隔離級別,但實際上它實現了一種叫做**快照隔離(snapshot isolation)** 的功能,**這是一種比可序列化更弱的保證**【8,11】。我們將在“[弱隔離等級]()”中研究快照隔離和其他形式的隔離。 + +#### 永續性(Durability) + +資料庫系統的目的是,提供一個安全的地方儲存資料,而不用擔心丟失。**永續性** 是一個承諾,即一旦事務成功完成,即使發生硬體故障或資料庫崩潰,寫入的任何資料也不會丟失。 + +在單節點資料庫中,永續性通常意味著資料已被寫入非易失性儲存裝置,如硬碟或SSD。它通常還包括預寫日誌或類似的檔案(參閱“[讓B樹更可靠](ch3.md#讓B樹更可靠)”),以便在磁碟上的資料結構損壞時進行恢復。在帶複製的資料庫中,永續性可能意味著資料已成功複製到一些節點。為了提供永續性保證,資料庫必須等到這些寫入或複製完成後,才能報告事務成功提交。 + +如“[可靠性](ch1.md#可靠性)”一節所述,**完美的永續性是不存在的** :如果所有硬碟和所有備份同時被銷燬,那顯然沒有任何資料庫能救得了你。 + +> #### 複製和永續性 +> +> 在歷史上,永續性意味著寫入歸檔磁帶。後來它被理解為寫入硬碟或SSD。最近它已經適應了“複製(replication)”的新內涵。哪種實現更好一些? +> +> 真相是,沒有什麼是完美的: +> +> * 如果你寫入磁碟然後機器宕機,即使資料沒有丟失,在修復機器或將磁碟轉移到其他機器之前,也是無法訪問的。這種情況下,複製系統可以保持可用性。 +> * 一個相關性故障(停電,或一個特定輸入導致所有節點崩潰的Bug)可能會一次性摧毀所有副本(參閱「[可靠性](ch1.md#可靠性)」),任何僅儲存在記憶體中的資料都會丟失,故記憶體資料庫仍然要和磁碟寫入打交道。 +> * 在非同步複製系統中,當主庫不可用時,最近的寫入操作可能會丟失(參閱「[處理節點宕機](ch5.md#處理節點宕機)」)。 +> * 當電源突然斷電時,特別是固態硬碟,有證據顯示有時會違反應有的保證:甚至fsync也不能保證正常工作【12】。硬碟韌體可能有錯誤,就像任何其他型別的軟體一樣【13,14】。 +> * 儲存引擎和檔案系統之間的微妙互動可能會導致難以追蹤的錯誤,並可能導致磁碟上的檔案在崩潰後被損壞【15,16】。 +> * 磁碟上的資料可能會在沒有檢測到的情況下逐漸損壞【17】。如果資料已損壞一段時間,副本和最近的備份也可能損壞。這種情況下,需要嘗試從歷史備份中恢復資料。 +> * 一項關於固態硬碟的研究發現,在執行的前四年中,30%到80%的硬碟會產生至少一個壞塊【18】。相比固態硬碟,磁碟的壞道率較低,但完全失效的概率更高。 +> * 如果SSD斷電,可能會在幾周內開始丟失資料,具體取決於溫度【19】。 +> +> 在實踐中,沒有一種技術可以提供絕對保證。只有各種降低風險的技術,包括寫入磁碟,複製到遠端機器和備份——它們可以且應該一起使用。與往常一樣,最好抱著懷疑的態度接受任何理論上的“保證” + +### 單物件和多物件操作 + +回顧一下,在ACID中,原子性和隔離性描述了客戶端在同一事務中執行多次寫入時,資料庫應該做的事情: + +***原子性*** + +如果在一系列寫操作的中途發生錯誤,則應中止事務處理,並丟棄當前事務的所有寫入。換句話說,資料庫免去了使用者對部分失敗的擔憂——透過提供“**寧為玉碎,不為瓦全(all-or-nothing)**”的保證。 + +***隔離性*** + +同時執行的事務不應該互相干擾。例如,如果一個事務進行多次寫入,則另一個事務要麼看到全部寫入結果,要麼什麼都看不到,但不應該是一些子集。 + +這些定義假設你想同時修改多個物件(行,文件,記錄)。通常需要**多物件事務(multi-object transaction)** 來保持多塊資料同步。[圖7-2](img/fig7-2.png)展示了一個來自電郵應用的例子。執行以下查詢來顯示使用者未讀郵件數量: + +```sql +SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true +``` + +但如果郵件太多,你可能會覺得這個查詢太慢,並決定用單獨的欄位儲存未讀郵件的數量(一種反規範化)。現在每當一個新訊息寫入時,必須也增長未讀計數器,每當一個訊息被標記為已讀時,也必須減少未讀計數器。 + +在[圖7-2](img/fig7-2.png)中,使用者2 遇到異常情況:郵件列表裡顯示有未讀訊息,但計數器顯示為零未讀訊息,因為計數器增長還沒有發生[^ii]。隔離性可以避免這個問題:透過確保使用者2 要麼同時看到新郵件和增長後的計數器,要麼都看不到。反正不會看到執行到一半的中間結果。 + +[^ii]: 可以說郵件應用中的錯誤計數器並不是什麼特別重要的問題。但換種方式來看,你可以把未讀計數器換成客戶賬戶餘額,把郵件收發看成支付交易。 + +![](img/fig7-2.png) + +**圖7-2 違反隔離性:一個事務讀取另一個事務的未被執行的寫入(“髒讀”)。** + +[圖7-3](img/fig7-3.png)說明了對原子性的需求:如果在事務過程中發生錯誤,郵箱和未讀計數器的內容可能會失去同步。在原子事務中,如果對計數器的更新失敗,事務將被中止,並且插入的電子郵件將被回滾。 + +![](img/fig7-3.png) + +**圖7-3 原子性確保發生錯誤時,事務先前的任何寫入都會被撤消,以避免狀態不一致** + +多物件事務需要某種方式來確定哪些讀寫操作屬於同一個事務。在關係型資料庫中,通常基於客戶端與資料庫伺服器的TCP連線:在任何特定連線上,`BEGIN TRANSACTION` 和 `COMMIT` 語句之間的所有內容,被認為是同一事務的一部分.[^iii] + +[^iii]: 這並不完美。如果TCP連線中斷,則事務必須中止。如果中斷髮生在客戶端請求提交之後,但在伺服器確認提交發生之前,客戶端並不知道事務是否已提交。為了解決這個問題,事務管理器可以透過一個唯一事務識別符號來對操作進行分組,這個識別符號並未繫結到特定TCP連線。後續再“[資料庫端到端的爭論](ch12.md#資料庫端到端的爭論)”一節將回到這個主題。 + +另一方面,許多非關係資料庫並沒有將這些操作組合在一起的方法。即使存在多物件API(例如,鍵值儲存可能具有在一個操作中更新幾個鍵的數個put操作),但這並不一定意味著它具有事務語義:該命令可能在一些鍵上成功,在其他的鍵上失敗,使資料庫處於部分更新的狀態。 + +#### 單物件寫入 + +當單個物件發生改變時,原子性和隔離也是適用的。例如,假設您正在向資料庫寫入一個 20 KB的 JSON文件: + +- 如果在傳送第一個10 KB之後網路連線中斷,資料庫是否儲存了不可解析的10KB JSON片段? +- 如果在資料庫正在覆蓋磁碟上的前一個值的過程中電源發生故障,是否最終將新舊值拼接在一起? +- 如果另一個客戶端在寫入過程中讀取該文件,是否會看到部分更新的值? + +這些問題非常讓人頭大,故儲存引擎一個幾乎普遍的目標是:對單節點上的單個物件(例如鍵值對)上提供原子性和隔離性。原子性可以透過使用日誌來實現崩潰恢復(參閱“[使B樹可靠]()”),並且可以使用每個物件上的鎖來實現隔離(每次只允許一個執行緒訪問物件) )。 + +一些資料庫也提供更復雜的原子操作,例如自增操作,這樣就不再需要像 [圖7-1](img/fig7-1.png) 那樣的讀取-修改-寫入序列了。同樣流行的是 **[比較和設定(CAS, compare-and-set)](#比較並設定(CAS))** 操作,當值沒有被其他併發修改過時,才允許執行寫操作。 + +這些單物件操作很有用,因為它們可以防止在多個客戶端嘗試同時寫入同一個物件時丟失更新(參閱“[防止丟失更新](#防止丟失更新)”)。但它們不是通常意義上的事務。CAS以及其他單一物件操作被稱為“輕量級事務”,甚至出於營銷目的被稱為“ACID”【20,21,22】,但是這個術語是誤導性的。事務通常被理解為,**將多個物件上的多個操作合併為一個執行單元的機制**。[^iv] + +[^iv]: 嚴格地說,**原子自增(atomic increment)** 這個術語在多執行緒程式設計的意義上使用了原子這個詞。 在ACID的情況下,它實際上應該被稱為 **孤立(isolated)** 的或**可序列化(serializable)** 的增量。 但這就太吹毛求疵了。 + +#### 多物件事務的需求 + +許多分散式資料儲存已經放棄了多物件事務,因為多物件事務很難跨分割槽實現,而且在需要高可用性或高效能的情況下,它們可能會礙事。但說到底,在分散式資料庫中實現事務,並沒有什麼根本性的障礙。[第9章](ch9.md) 將討論分散式事務的實現。 + +但是我們是否需要多物件事務?**是否有可能只用鍵值資料模型和單物件操作來實現任何應用程式?** + +有一些場景中,單物件插入,更新和刪除是足夠的。但是許多其他場景需要協調寫入幾個不同的物件: + +* 在關係資料模型中,一個表中的行通常具有對另一個表中的行的外來鍵引用。(類似的是,在一個圖資料模型中,一個頂點有著到其他頂點的邊)。多物件事務使你確信這些引用始終有效:當插入幾個相互引用的記錄時,外來鍵必須是正確的,最新的,不然資料就沒有意義。 +* 在文件資料模型中,需要一起更新的欄位通常在同一個文件中,這被視為單個物件——更新單個文件時不需要多物件事務。但是,缺乏連線功能的文件資料庫會鼓勵非規範化(參閱“[關係型資料庫與文件資料庫在今日的對比](ch2.md#關係型資料庫與文件資料庫在今日的對比)”)。當需要更新非規範化的資訊時,如 [圖7-2](img/fig7-2.png) 所示,需要一次更新多個文件。事務在這種情況下非常有用,可以防止非規範化的資料不同步。 +* 在具有二級索引的資料庫中(除了純粹的鍵值儲存以外幾乎都有),每次更改值時都需要更新索引。從事務角度來看,這些索引是不同的資料庫物件:例如,如果沒有事務隔離性,記錄可能出現在一個索引中,但沒有出現在另一個索引中,因為第二個索引的更新還沒有發生。 + +這些應用仍然可以在沒有事務的情況下實現。然而,**沒有原子性,錯誤處理就要複雜得多,缺乏隔離性,就會導致併發問題**。我們將在“[弱隔離級別](#弱隔離級別)”中討論這些問題,並在[第12章]()中探討其他方法。 + +#### 處理錯誤和中止 + +事務的一個關鍵特性是,如果發生錯誤,它可以中止並安全地重試。 ACID資料庫基於這樣的哲學:如果資料庫有違反其原子性,隔離性或永續性的危險,則寧願完全放棄事務,而不是留下半成品。 + +然而並不是所有的系統都遵循這個哲學。特別是具有[無主複製](ch6.md#無主複製)的資料儲存,主要是在“盡力而為”的基礎上進行工作。可以概括為“資料庫將做盡可能多的事,執行遇到錯誤時,它不會撤消它已經完成的事情“ ——所以,從錯誤中恢復是應用程式的責任。 + +錯誤發生不可避免,但許多軟體開發人員傾向於只考慮樂觀情況,而不是錯誤處理的複雜性。例如,像Rails的ActiveRecord和Django這樣的**物件關係對映(ORM, object-relation Mapping)** 框架不會重試中斷的事務—— 這個錯誤通常會導致一個從堆疊向上傳播的異常,所以任何使用者輸入都會被丟棄,使用者拿到一個錯誤資訊。這實在是太恥辱了,因為中止的重點就是允許安全的重試。 + +儘管重試一箇中止的事務是一個簡單而有效的錯誤處理機制,但它並不完美: + +- 如果事務實際上成功了,但是在伺服器試圖向客戶端確認提交成功時網路發生故障(所以客戶端認為提交失敗了),那麼重試事務會導致事務被執行兩次——除非你有一個額外的應用級除重機制。 +- 如果錯誤是由於負載過大造成的,則重試事務將使問題變得更糟,而不是更好。為了避免這種正反饋迴圈,可以限制重試次數,使用指數退避演算法,並單獨處理與過載相關的錯誤(如果允許)。 +- 僅在臨時性錯誤(例如,由於死鎖,異常情況,臨時性網路中斷和故障切換)後才值得重試。在發生永久性錯誤(例如,違反約束)之後重試是毫無意義的。 +- 如果事務在資料庫之外也有副作用,即使事務被中止,也可能發生這些副作用。例如,如果你正在傳送電子郵件,那你肯定不希望每次重試事務時都重新發送電子郵件。如果你想確保幾個不同的系統一起提交或放棄,**二階段提交(2PC, two-phase commit)** 可以提供幫助(“[原子提交和兩階段提交(2PC)](ch9.md#原子提交與二階段提交(2PC))”中將討論這個問題)。 +- 如果客戶端程序在重試中失效,任何試圖寫入資料庫的資料都將丟失。 + + + + + +## 弱隔離級別 + +如果兩個事務不觸及相同的資料,它們可以安全地**並行(parallel)** 執行,因為兩者都不依賴於另一個。當一個事務讀取由另一個事務同時修改的資料時,或者當兩個事務試圖同時修改相同的資料時,併發問題(競爭條件)才會出現。 + +併發BUG很難透過測試找到,因為這樣的錯誤只有在特殊時機下才會觸發。這樣的時機可能很少,通常很難重現[^譯註i]。併發性也很難推理,特別是在大型應用中,你不一定知道哪些其他程式碼正在訪問資料庫。在一次只有一個使用者時,應用開發已經很麻煩了,有許多併發使用者使得它更加困難,因為任何一個數據都可能隨時改變。 + +[^譯註i]: 軼事:偶然出現的瞬時錯誤有時稱為***Heisenbug***,而確定性的問題對應地稱為***Bohrbugs*** + +出於這個原因,資料庫一直試圖透過提供**事務隔離(transaction isolation)** 來隱藏應用程式開發者的併發問題。從理論上講,隔離可以透過假裝沒有併發發生,讓你的生活更加輕鬆:**可序列化(serializable)** 的隔離等級意味著資料庫保證事務的效果如同連續執行(即一次一個,沒有任何併發)。 + +實際上不幸的是:隔離並沒有那麼簡單。**可序列化** 會有效能損失,許多資料庫不願意支付這個代價【8】。因此,系統通常使用較弱的隔離級別來防止一部分,而不是全部的併發問題。這些隔離級別難以理解,並且會導致微妙的錯誤,但是它們仍然在實踐中被使用【23】。 + +弱事務隔離級別導致的併發性錯誤不僅僅是一個理論問題。它們造成了很多的資金損失【24,25】,耗費了財務審計人員的調查【26】,並導致客戶資料被破壞【27】。關於這類問題的一個流行的評論是“如果你正在處理財務資料,請使用ACID資料庫!” —— 但是這一點沒有提到。即使是很多流行的關係型資料庫系統(通常被認為是“ACID”)也使用弱隔離級別,所以它們也不一定能防止這些錯誤的發生。 + +比起盲目地依賴工具,我們應該對存在的併發問題的種類,以及如何防止這些問題有深入的理解。然後就可以使用我們所掌握的工具來構建可靠和正確的應用程式。 + +在本節中,我們將看幾個在實踐中使用的弱(**不可序列化(nonserializable)**)隔離級別,並詳細討論哪種競爭條件可能發生也可能不發生,以便您可以決定什麼級別適合您的應用程式。一旦我們完成了這個工作,我們將詳細討論可序列性(請參閱“[可序列化](#可序列化)”)。我們討論的隔離級別將是非正式的,使用示例。如果你需要嚴格的定義和分析它們的屬性,你可以在學術文獻中找到它們[28,29,30]。 + +### 讀已提交 + +最基本的事務隔離級別是**讀已提交(Read Committed)**[^v],它提供了兩個保證: + +1. 從資料庫讀時,只能看到已提交的資料(沒有**髒讀(dirty reads)**)。 +2. 寫入資料庫時,只會覆蓋已經寫入的資料(沒有**髒寫(dirty writes)**)。 + +我們來更詳細地討論這兩個保證。 + +[^v]: 某些資料庫支援甚至更弱的隔離級別,稱為**讀未提交(Read uncommitted)**。它可以防止髒寫,但不防止髒讀。 + +#### 沒有髒讀 + +設想一個事務已經將一些資料寫入資料庫,但事務還沒有提交或中止。另一個事務可以看到未提交的資料嗎?如果是的話,那就叫做**髒讀(dirty reads)**【2】。 + +在**讀已提交**隔離級別執行的事務必須防止髒讀。這意味著事務的任何寫入操作只有在該事務提交時才能被其他人看到(然後所有的寫入操作都會立即變得可見)。如[圖7-4]()所示,使用者1 設定了`x = 3`,但使用者2 的 `get x `仍舊返回舊值2 ,而使用者1 尚未提交。 + +![](img/fig7-4.png) + +**圖7-4 沒有髒讀:使用者2只有在使用者1的事務已經提交後才能看到x的新值。** + +為什麼要防止髒讀,有幾個原因: + +- 如果事務需要更新多個物件,髒讀取意味著另一個事務可能會只看到一部分更新。例如,在[圖7-2](img/fig7-2.png)中,使用者看到新的未讀電子郵件,但看不到更新的計數器。這就是電子郵件的髒讀。看到處於部分更新狀態的資料庫會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。 +- 如果事務中止,則所有寫入操作都需要回滾(如[圖7-3](img/fig7-3.png)所示)。如果資料庫允許髒讀,那就意味著一個事務可能會看到稍後需要回滾的資料,即從未實際提交給資料庫的資料。想想後果就讓人頭大。 + +#### 沒有髒寫 + +如果兩個事務同時嘗試更新資料庫中的相同物件,會發生什麼情況?我們不知道寫入的順序是怎樣的,但是我們通常認為後面的寫入會覆蓋前面的寫入。 + +但是,如果先前的寫入是尚未提交事務的一部分,又會發生什麼情況,後面的寫入會覆蓋一個尚未提交的值?這被稱作**髒寫(dirty write)**【28】。在**讀已提交**的隔離級別上執行的事務必須防止髒寫,通常是延遲第二次寫入,直到第一次寫入事務提交或中止為止。 + +透過防止髒寫,這個隔離級別避免了一些併發問題: + +- 如果事務更新多個物件,髒寫會導致不好的結果。例如,考慮 [圖7-5](img/fig7-5.png),[圖7-5](img/fig7-5.png) 以一個二手車銷售網站為例,Alice和Bob兩個人同時試圖購買同一輛車。購買汽車需要兩次資料庫寫入:網站上的商品列表需要更新,以反映買家的購買,銷售發票需要傳送給買家。在[圖7-5](img/fig7-5.png)的情況下,銷售是屬於Bob的(因為他成功更新了商品列表),但發票卻寄送給了愛麗絲(因為她成功更新了發票表)。讀已提交會阻止這樣這樣的事故。 +- 但是,提交讀取並不能防止[圖7-1]()中兩個計數器增量之間的競爭狀態。在這種情況下,第二次寫入發生在第一個事務提交後,所以它不是一個髒寫。這仍然是不正確的,但是出於不同的原因,在“[防止更新丟失](#防止丟失更新)”中將討論如何使這種計數器增量安全。 + +![](img/fig7-5.png) + +**圖7-5 如果存在髒寫,來自不同事務的衝突寫入可能會混淆在一起** + +#### 實現讀已提交 + +**讀已提交**是一個非常流行的隔離級別。這是Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL和其他許多資料庫的預設設定【8】。 + +最常見的情況是,資料庫透過使用**行鎖(row-level lock)** 來防止髒寫:當事務想要修改特定物件(行或文件)時,它必須首先獲得該物件的鎖。然後必須持有該鎖直到事務被提交或中止。一次只有一個事務可持有任何給定物件的鎖;如果另一個事務要寫入同一個物件,則必須等到第一個事務提交或中止後,才能獲取該鎖並繼續。這種鎖定是讀已提交模式(或更強的隔離級別)的資料庫自動完成的。 + +如何防止髒讀?一種選擇是使用相同的鎖,並要求任何想要讀取物件的事務來簡單地獲取該鎖,然後在讀取之後立即再次釋放該鎖。這能確保不會讀取進行時,物件不會在髒的狀態,有未提交的值(因為在那段時間鎖會被寫入該物件的事務持有)。 + +但是要求讀鎖的辦法在實踐中效果並不好。因為一個長時間執行的寫入事務會迫使許多隻讀事務等到這個慢寫入事務完成。這會損失只讀事務的響應時間,並且不利於可操作性:因為等待鎖,應用某個部分的遲緩可能由於連鎖效應,導致其他部分出現問題。 + +出於這個原因,大多數資料庫[^vi]使用[圖7-4]()的方式防止髒讀:對於寫入的每個物件,資料庫都會記住舊的已提交值,和由當前持有寫入鎖的事務設定的新值。當事務正在進行時,任何其他讀取物件的事務都會拿到舊值。 只有當新值提交後,事務才會切換到讀取新值。 + +[^vi]: 在撰寫本文時,唯一在讀已提交隔離級別使用讀鎖的主流資料庫是使用`read_committed_snapshot = off`配置的IBM DB2和Microsoft SQL Server [23,36]。 + +### 快照隔離和可重複讀 + +如果只從表面上看讀已提交隔離級別你就認為它完成了事務所需的一切,那是可以原諒的。它允許**中止**(原子性的要求);它防止讀取不完整的事務結果,並且防止併發寫入造成的混合。事實上這些功能非常有用,比起沒有事務的系統來,可以提供更多的保證。 + +但是在使用此隔離級別時,仍然有很多地方可能會產生併發錯誤。例如[圖7-6](img/fig7-6.png)說明了讀已提交時可能發生的問題。 + +![](img/fig7-6.png) + +**圖7-6 讀取偏差:Alice觀察資料庫處於不一致的狀態** + +愛麗絲在銀行有1000美元的儲蓄,分為兩個賬戶,每個500美元。現在一筆事務從她的一個賬戶中轉移了100美元到另一個賬戶。如果她在事務處理的同時檢視其賬戶餘額列表,不幸地在轉賬事務完成前看到收款賬戶餘額(餘額為500美元),而在轉賬完成後看到另一個轉出賬戶(已經轉出100美元,餘額400美元)。對愛麗絲來說,現在她的賬戶似乎只有900美元——看起來100美元已經消失了。 + +這種異常被稱為**不可重複讀(nonrepeatable read)**或**讀取偏差(read skew)**:如果Alice在事務結束時再次讀取賬戶1的餘額,她將看到與她之前的查詢中看到的不同的值(600美元)。在讀已提交的隔離條件下,**不可重複讀**被認為是可接受的:Alice看到的帳戶餘額時確實在閱讀時已經提交了。 + +> 不幸的是,術語**偏差(skew)** 這個詞是過載的:以前使用它是因為熱點的不平衡工作量(參閱“[偏斜的負載傾斜與消除熱點](ch6.md#負載傾斜與消除熱點)”),而這裡偏差意味著異常的時機。 + +對於Alice的情況,這不是一個長期持續的問題。因為如果她幾秒鐘後重新整理銀行網站的頁面,她很可能會看到一致的帳戶餘額。但是有些情況下,不能容忍這種暫時的不一致: + +***備份*** + +​ 進行備份需要複製整個資料庫,對大型資料庫而言可能需要花費數小時才能完成。備份程序執行時,資料庫仍然會接受寫入操作。因此備份可能會包含一些舊的部分和一些新的部分。如果從這樣的備份中恢復,那麼不一致(如消失的錢)就會變成永久的。 + +***分析查詢和完整性檢查*** + +​ 有時,您可能需要執行一個查詢,掃描大部分的資料庫。這樣的查詢在分析中很常見(參閱“[事務處理或分析?](ch3.md#事務處理還是分析?)”),也可能是定期完整性檢查(即監視資料損壞)的一部分。如果這些查詢在不同時間點觀察資料庫的不同部分,則可能會返回毫無意義的結果。 + +**快照隔離(snapshot isolation)**【28】是這個問題最常見的解決方案。想法是,每個事務都從資料庫的**一致快照(consistent snapshot)** 中讀取——也就是說,事務可以看到事務開始時在資料庫中提交的所有資料。即使這些資料隨後被另一個事務更改,每個事務也只能看到該特定時間點的舊資料。 + +快照隔離對長時間執行的只讀查詢(如備份和分析)非常有用。如果查詢的資料在查詢執行的同時發生變化,則很難理解查詢的含義。當一個事務可以看到資料庫在某個特定時間點凍結時的一致快照,理解起來就很容易了。 + +快照隔離是一個流行的功能:PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支援【23,31,32】。 + +#### 實現快照隔離 + +與讀取提交的隔離類似,快照隔離的實現通常使用寫鎖來防止髒寫(請參閱“[讀已提交](#讀已提交)”),這意味著進行寫入的事務會阻止另一個事務修改同一個物件。但是讀取不需要任何鎖定。從效能的角度來看,快照隔離的一個關鍵原則是:**讀不阻塞寫,寫不阻塞讀**。這允許資料庫在處理一致性快照上的長時間查詢時,可以正常地同時處理寫入操作。且兩者間沒有任何鎖定爭用。 + +為了實現快照隔離,資料庫使用了我們看到的用於防止[圖7-4]()中的髒讀的機制的一般化。資料庫必須可能保留一個物件的幾個不同的提交版本,因為各種正在進行的事務可能需要看到資料庫在不同的時間點的狀態。因為它並排維護著多個版本的物件,所以這種技術被稱為**多版本併發控制(MVCC, multi-version concurrentcy control)**。 + +如果一個數據庫只需要提供**讀已提交**的隔離級別,而不提供**快照隔離**,那麼保留一個物件的兩個版本就足夠了:提交的版本和被覆蓋但尚未提交的版本。支援快照隔離的儲存引擎通常也使用MVCC來實現**讀已提交**隔離級別。一種典型的方法是**讀已提交**為每個查詢使用單獨的快照,而**快照隔離**對整個事務使用相同的快照。 + +[圖7-7]()說明了如何在PostgreSQL中實現基於MVCC的快照隔離【31】(其他實現類似)。當一個事務開始時,它被賦予一個唯一的,永遠增長[^vii]的事務ID(`txid`)。每當事務向資料庫寫入任何內容時,它所寫入的資料都會被標記上寫入者的事務ID。 + +[^vii]: 事實上,事務ID是32位整數,所以大約會在40億次事務之後溢位。 PostgreSQL的Vacuum過程會清理老舊的事務ID,確保事務ID溢位(回捲)不會影響到資料。 + +![](img/fig7-7.png) + +**圖7-7 使用多版本物件實現快照隔離** + +表中的每一行都有一個 `created_by` 欄位,其中包含將該行插入到表中的的事務ID。此外,每行都有一個 `deleted_by` 欄位,最初是空的。如果某個事務刪除了一行,那麼該行實際上並未從資料庫中刪除,而是透過將 `deleted_by` 欄位設定為請求刪除的事務的ID來標記為刪除。在稍後的時間,當確定沒有事務可以再訪問已刪除的資料時,資料庫中的垃圾收集過程會將所有帶有刪除標記的行移除,並釋放其空間。[^譯註ii] + +[^譯註ii]: 在PostgreSQL中,`created_by` 的實際名稱為`xmin`,`deleted_by` 的實際名稱為`xmax` + +`UPDATE` 操作在內部翻譯為 `DELETE` 和 `INSERT` 。例如,在[圖7-7]()中,事務13 從賬戶2 中扣除100美元,將餘額從500美元改為400美元。實際上包含兩條賬戶2 的記錄:餘額為 \$500 的行被標記為**被事務13刪除**,餘額為 \$400 的行**由事務13建立**。 + +#### 觀察一致性快照的可見性規則 + +當一個事務從資料庫中讀取時,事務ID用於決定它可以看見哪些物件,看不見哪些物件。透過仔細定義可見性規則,資料庫可以嚮應用程式呈現一致的資料庫快照。工作如下: + +1. 在每次事務開始時,資料庫列出當時所有其他(尚未提交或尚未中止)的事務清單,即使之後提交了,這些事務已執行的任何寫入也都會被忽略。 +2. 被中止事務所執行的任何寫入都將被忽略。 +3. 由具有較晚事務ID(即,在當前事務開始之後開始的)的事務所做的任何寫入都被忽略,而不管這些事務是否已經提交。 +4. 所有其他寫入,對應用都是可見的。 + +這些規則適用於建立和刪除物件。在[圖7-7]()中,當事務12 從賬戶2 讀取時,它會看到 \$500 的餘額,因為 \$500 餘額的刪除是由事務13 完成的(根據規則3,事務12 看不到事務13 執行的刪除),且400美元記錄的建立也是不可見的(按照相同的規則)。 + +換句話說,如果以下兩個條件都成立,則可見一個物件: + +- 讀事務開始時,建立該物件的事務已經提交。 +- 物件未被標記為刪除,或如果被標記為刪除,請求刪除的事務在讀事務開始時尚未提交。 + +長時間執行的事務可能會長時間使用快照,並繼續讀取(從其他事務的角度來看)早已被覆蓋或刪除的值。由於從來不原地更新值,而是每次值改變時建立一個新的版本,資料庫可以在提供一致快照的同時只產生很小的額外開銷。 + +#### 索引和快照隔離 + +索引如何在多版本資料庫中工作?一種選擇是使索引簡單地指向物件的所有版本,並且需要索引查詢來過濾掉當前事務不可見的任何物件版本。當垃圾收集刪除任何事務不再可見的舊物件版本時,相應的索引條目也可以被刪除。 + +在實踐中,許多實現細節決定了多版本併發控制的效能。例如,如果同一物件的不同版本可以放入同一個頁面中,PostgreSQL的最佳化可以避免更新索引【31】。 + +在CouchDB,Datomic和LMDB中使用另一種方法。雖然它們也使用[B樹](ch2.md#B樹),但它們使用的是一種**僅追加/寫時複製(append-only/copy-on-write)** 的變體,它們在更新時不覆蓋樹的頁面,而為每個修改頁面建立一份副本。從父頁面直到樹根都會級聯更新,以指向它們子頁面的新版本。任何不受寫入影響的頁面都不需要被複制,並且保持不變【33,34,35】。 + +使用僅追加的B樹,每個寫入事務(或一批事務)都會建立一顆新的B樹,當建立時,從該特定樹根生長的樹就是資料庫的一個一致性快照。沒必要根據事務ID過濾掉物件,因為後續寫入不能修改現有的B樹;它們只能建立新的樹根。但這種方法也需要一個負責壓縮和垃圾收集的後臺程序。 + +#### 可重複讀與命名混淆 + +快照隔離是一個有用的隔離級別,特別對於只讀事務而言。但是,許多資料庫實現了它,卻用不同的名字來稱呼。在Oracle中稱為**可序列化(Serializable)**的,在PostgreSQL和MySQL中稱為**可重複讀(repeatable read)**【23】。 + +這種命名混淆的原因是SQL標準沒有**快照隔離**的概念,因為標準是基於System R 1975年定義的隔離級別【2】,那時候**快照隔離**尚未發明。相反,它定義了**可重複讀**,表面上看起來與快照隔離很相似。 PostgreSQL和MySQL稱其**快照隔離**級別為**可重複讀(repeatable read)**,因為這樣符合標準要求,所以它們可以聲稱自己“標準相容”。 + +不幸的是,SQL標準對隔離級別的定義是有缺陷的——模糊,不精確,並不像標準應有的樣子獨立於實現【28】。有幾個資料庫實現了可重複讀,但它們實際提供的保證存在很大的差異,儘管表面上是標準化的【23】。在研究文獻【29,30】中已經有了可重複讀的正式定義,但大多數的實現並不能滿足這個正式定義。最後,IBM DB2使用“可重複讀”來引用可序列化【8】。 + +結果,沒有人真正知道**可重複讀**的意思。 + +### 防止丟失更新 + +到目前為止已經討論的**讀已提交**和**快照隔離**級別,主要保證了**只讀事務在併發寫入時**可以看到什麼。卻忽略了兩個事務併發寫入的問題——我們只討論了[髒寫](#髒寫),一種特定型別的寫-寫衝突是可能出現的。 + +併發的寫入事務之間還有其他幾種有趣的衝突。其中最著名的是**丟失更新(lost update)** 問題,如[圖7-1]()所示,以兩個併發計數器增量為例。 + +如果應用從資料庫中讀取一些值,修改它並寫回修改的值(讀取-修改-寫入序列),則可能會發生丟失更新的問題。如果兩個事務同時執行,則其中一個的修改可能會丟失,因為第二個寫入的內容並沒有包括第一個事務的修改(有時會說後面寫入**狠揍(clobber)** 了前面的寫入)這種模式發生在各種不同的情況下: + +- 增加計數器或更新賬戶餘額(需要讀取當前值,計算新值並寫回更新後的值) +- 在複雜值中進行本地修改:例如,將元素新增到JSON文件中的一個列表(需要解析文件,進行更改並寫回修改的文件) +- 兩個使用者同時編輯wiki頁面,每個使用者透過將整個頁面內容傳送到伺服器來儲存其更改,覆寫資料庫中當前的任何內容。 + +這是一個普遍的問題,所以已經開發了各種解決方案。 + +#### 原子寫 + +許多資料庫提供了原子更新操作,從而消除了在應用程式程式碼中執行讀取-修改-寫入序列的需要。如果你的程式碼可以用這些操作來表達,那這通常是最好的解決方案。例如,下面的指令在大多數關係資料庫中是併發安全的: + +```sql +UPDATE counters SET value = value + 1 WHERE key = 'foo'; +``` + +類似地,像MongoDB這樣的文件資料庫提供了對JSON文件的一部分進行本地修改的原子操作,Redis提供了修改資料結構(如優先順序佇列)的原子操作。並不是所有的寫操作都可以用原子操作的方式來表達,例如維基頁面的更新涉及到任意文字編輯[^viii],但是在可以使用原子操作的情況下,它們通常是最好的選擇。 + +[^viii]: 將文字文件的編輯表示為原子的變化流是可能的,儘管相當複雜。參閱“[自動衝突解決](ch5.md#題外話:自動衝突解決)”。 + +原子操作通常透過在讀取物件時,獲取其上的排它鎖來實現。以便更新完成之前沒有其他事務可以讀取它。這種技術有時被稱為**遊標穩定性(cursor stability)**【36,37】。另一個選擇是簡單地強制所有的原子操作在單一執行緒上執行。 + +不幸的是,ORM框架很容易意外地執行不安全的讀取-修改-寫入序列,而不是使用資料庫提供的原子操作【38】。如果你知道自己在做什麼那當然不是問題,但它經常產生那種很難測出來的微妙Bug。 + +#### 顯式鎖定 + +如果資料庫的內建原子操作沒有提供必要的功能,防止丟失更新的另一個選擇是讓應用程式顯式地鎖定將要更新的物件。然後應用程式可以執行讀取-修改-寫入序列,如果任何其他事務嘗試同時讀取同一個物件,則強制等待,直到第一個**讀取-修改-寫入序列**完成。 + +例如,考慮一個多人遊戲,其中幾個玩家可以同時移動相同的棋子。在這種情況下,一個原子操作可能是不夠的,因為應用程式還需要確保玩家的移動符合遊戲規則,這可能涉及到一些不能合理地用資料庫查詢實現的邏輯。但你可以使用鎖來防止兩名玩家同時移動相同的棋子,如例7-1所示。 + +**例7-1 顯式鎖定行以防止丟失更新** + +```plsql +BEGIN TRANSACTION; +SELECT * FROM figures + WHERE name = 'robot' AND game_id = 222 +FOR UPDATE; + +-- 檢查玩家的操作是否有效,然後更新先前SELECT返回棋子的位置。 +UPDATE figures SET position = 'c4' WHERE id = 1234; +COMMIT; +``` + +- `FOR UPDATE`子句告訴資料庫應該對該查詢返回的所有行加鎖。 + +這是有效的,但要做對,你需要仔細考慮應用邏輯。忘記在程式碼某處加鎖很容易引入競爭條件。 + +#### 自動檢測丟失的更新 + +原子操作和鎖是透過強制**讀取-修改-寫入序列**按順序發生,來防止丟失更新的方法。另一種方法是允許它們並行執行,如果事務管理器檢測到丟失更新,則中止事務並強制它們重試其**讀取-修改-寫入序列**。 + +這種方法的一個優點是,資料庫可以結合快照隔離高效地執行此檢查。事實上,PostgreSQL的可重複讀,Oracle的可序列化和SQL Server的快照隔離級別,都會自動檢測到丟失更新,並中止惹麻煩的事務。但是,MySQL/InnoDB的可重複讀並不會檢測**丟失更新**【23】。一些作者【28,30】認為,資料庫必須能防止丟失更新才稱得上是提供了**快照隔離**,所以在這個定義下,MySQL下不提供快照隔離。 + +丟失更新檢測是一個很好的功能,因為它不需要應用程式碼使用任何特殊的資料庫功能,你可能會忘記使用鎖或原子操作,從而引入錯誤;但丟失更新的檢測是自動發生的,因此不太容易出錯。 + +#### 比較並設定(CAS) + +在不提供事務的資料庫中,有時會發現一種原子操作:**比較並設定(CAS, Compare And Set)**(先前在“[單物件寫入]()”中提到)。此操作的目的是為了避免丟失更新:只有當前值從上次讀取時一直未改變,才允許更新發生。如果當前值與先前讀取的值不匹配,則更新不起作用,且必須重試讀取-修改-寫入序列。 + +例如,為了防止兩個使用者同時更新同一個wiki頁面,可以嘗試類似這樣的方式,只有當用戶開始編輯頁面內容時,才會發生更新: + +```sql +-- 根據資料庫的實現情況,這可能也可能不安全 +UPDATE wiki_pages SET content = '新內容' + WHERE id = 1234 AND content = '舊內容'; +``` + +如果內容已經更改並且不再與“舊內容”相匹配,則此更新將不起作用,因此您需要檢查更新是否生效,必要時重試。但是,如果資料庫允許`WHERE`子句從舊快照中讀取,則此語句可能無法防止丟失更新,因為即使發生了另一個併發寫入,`WHERE`條件也可能為真。在依賴資料庫的CAS操作前要檢查其是否安全。 + +#### 衝突解決和複製 + +在複製資料庫中(參見[第5章](ch5.md)),防止丟失的更新需要考慮另一個維度:由於在多個節點上存在資料副本,並且在不同節點上的資料可能被併發地修改,因此需要採取一些額外的步驟來防止丟失更新。 + +鎖和CAS操作假定只有一個最新的資料副本。但是多主或無主複製的資料庫通常允許多個寫入併發執行,並非同步複製到副本上,因此無法保證只有一個最新資料的副本。所以基於鎖或CAS操作的技術不適用於這種情況。 (我們將在“[線性化](ch9.md#線性化)”中更詳細地討論這個問題。) + +相反,如“[檢測併發寫入](ch5.md#檢測併發寫入)”一節所述,這種複製資料庫中的一種常見方法是允許併發寫入建立多個衝突版本的值(也稱為兄弟),並使用應用程式碼或特殊資料結構在事實發生之後解決和合並這些版本。 + +原子操作可以在複製的上下文中很好地工作,尤其當它們具有可交換性時(即,可以在不同的副本上以不同的順序應用它們,且仍然可以得到相同的結果)。例如,遞增計數器或向集合新增元素是可交換的操作。這是Riak 2.0資料型別背後的思想,它可以防止複製副本丟失更新。當不同的客戶端同時更新一個值時,Riak自動將更新合併在一起,以免丟失更新【39】。 + +另一方面,最後寫入勝利(LWW)的衝突解決方法很容易丟失更新,如“[最後寫入勝利(丟棄併發寫入)](ch5.md#最後寫入勝利(丟棄併發寫入))”中所述。不幸的是,LWW是許多複製資料庫中的預設方案。 + +#### 寫入偏差與幻讀 + +前面的章節中,我們看到了**髒寫**和**丟失更新**,當不同的事務併發地嘗試寫入相同的物件時,會出現這兩種競爭條件。為了避免資料損壞,這些競爭條件需要被阻止——既可以由資料庫自動執行,也可以透過鎖和原子寫操作這類手動安全措施來防止。 + +但是,併發寫入間可能發生的競爭條件還沒有完。在本節中,我們將看到一些更微妙的衝突例子。 + +首先,想象一下這個例子:你正在為醫院寫一個醫生輪班管理程式。醫院通常會同時要求幾位醫生待命,但底線是至少有一位醫生在待命。醫生可以放棄他們的班次(例如,如果他們自己生病了),只要至少有一個同事在這一班中繼續工作【40,41】。 + +現在想象一下,Alice和Bob是兩位值班醫生。兩人都感到不適,所以他們都決定請假。不幸的是,他們恰好在同一時間點選按鈕下班。[圖7-8](img/fig7-8.png)說明了接下來的事情。 + +![](img/fig7-8.png) + +**圖7-8 寫入偏差導致應用程式錯誤的示例** + +在兩個事務中,應用首先檢查是否有兩個或以上的醫生正在值班;如果是的話,它就假定一名醫生可以安全地休班。由於資料庫使用快照隔離,兩次檢查都返回 2 ,所以兩個事務都進入下一個階段。Alice更新自己的記錄休班了,而Bob也做了一樣的事情。兩個事務都成功提交了,現在沒有醫生值班了。違反了至少有一名醫生在值班的要求。 + +#### 寫偏差的特徵 + +這種異常稱為**寫偏差**【28】。它既不是**髒寫**,也不是**丟失更新**,因為這兩個事務正在更新兩個不同的物件(Alice和Bob各自的待命記錄)。在這裡發生的衝突並不是那麼明顯,但是這顯然是一個競爭條件:如果兩個事務一個接一個地執行,那麼第二個醫生就不能歇班了。異常行為只有在事務併發進行時才有可能。 + +可以將寫入偏差視為丟失更新問題的一般化。如果兩個事務讀取相同的物件,然後更新其中一些物件(不同的事務可能更新不同的物件),則可能發生寫入偏差。在多個事務更新同一個物件的特殊情況下,就會發生髒寫或丟失更新(取決於時機)。 + +我們看到,有各種不同的方法來防止丟失的更新。隨著寫偏差,我們的選擇更受限制: + +* 由於涉及多個物件,單物件的原子操作不起作用。 +* 不幸的是,在一些快照隔離的實現中,自動檢測丟失更新對此並沒有幫助。在PostgreSQL的可重複讀,MySQL/InnoDB的可重複讀,Oracle可序列化或SQL Server的快照隔離級別中,都不會自動檢測寫入偏差【23】。自動防止寫入偏差需要真正的可序列化隔離(請參見“[可序列化](#可序列化)”)。 +* 某些資料庫允許配置約束,然後由資料庫強制執行(例如,唯一性,外來鍵約束或特定值限制)。但是為了指定至少有一名醫生必須線上,需要一個涉及多個物件的約束。大多數資料庫沒有內建對這種約束的支援,但是你可以使用觸發器,或者物化檢視來實現它們,這取決於不同的資料庫【42】。 +* 如果無法使用可序列化的隔離級別,則此情況下的次優選項可能是顯式鎖定事務所依賴的行。在例子中,你可以寫下如下的程式碼: + +```sql +BEGIN TRANSACTION; +SELECT * FROM doctors + WHERE on_call = TRUE + AND shift_id = 1234 FOR UPDATE; + +UPDATE doctors + SET on_call = FALSE + WHERE name = 'Alice' + AND shift_id = 1234; + +COMMIT; +``` + +* 和以前一樣,`FOR UPDATE`告訴資料庫鎖定返回的所有行用於更新。 + +#### 寫偏差的更多例子 + +寫偏差乍看像是一個深奧的問題,但一旦意識到這一點,很容易會注意到更多可能的情況。以下是一些例子: + +***會議室預訂系統*** + +假設你想強制執行,同一時間不能同時在兩個會議室預訂【43】。當有人想要預訂時,首先檢查是否存在相互衝突的預訂(即預訂時間範圍重疊的同一房間),如果沒有找到,則建立會議(請參見示例7-2)[^ix]。 + +[^ix]: 在PostgreSQL中,您可以使用範圍型別優雅地執行此操作,但在其他資料庫中並未得到廣泛支援。 + +**例7-2 會議室預訂系統試圖避免重複預訂(在快照隔離下不安全)** + +```sql +BEGIN TRANSACTION; + +-- 檢查所有現存的與12:00~13:00重疊的預定 +SELECT COUNT(*) FROM bookings +WHERE room_id = 123 AND + end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00'; + +-- 如果之前的查詢返回0 +INSERT INTO bookings(room_id, start_time, end_time, user_id) + VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666); + +COMMIT; +``` + +不幸的是,快照隔離並不能防止另一個使用者同時插入衝突的會議。為了確保不會遇到排程衝突,你又需要可序列化的隔離級別了。 + +***多人遊戲*** + +在[例7-1]()中,我們使用一個鎖來防止丟失更新(也就是確保兩個玩家不能同時移動同一個棋子)。但是鎖定並不妨礙玩家將兩個不同的棋子移動到棋盤上的相同位置,或者採取其他違反遊戲規則的行為。按照您正在執行的規則型別,也許可以使用唯一約束,否則您很容易發生寫入偏差。 + +***搶注使用者名稱*** + +在每個使用者擁有唯一使用者名稱的網站上,兩個使用者可能會嘗試同時建立具有相同使用者名稱的帳戶。可以在事務檢查名稱是否被搶佔,如果沒有則使用該名稱建立賬戶。但是像在前面的例子中那樣,在快照隔離下這是不安全的。幸運的是,唯一約束是一個簡單的解決辦法(第二個事務在提交時會因為違反使用者名稱唯一約束而被中止)。 + +***防止雙重開支*** + +允許使用者花錢或積分的服務,需要檢查使用者的支付數額不超過其餘額。可以透過在使用者的帳戶中插入一個試探性的消費專案來實現這一點,列出帳戶中的所有專案,並檢查總和是否為正值【44】。有了寫入偏差,可能會發生兩個支出專案同時插入,一起導致餘額變為負值,但這兩個事務都不會注意到另一個。 + +#### 導致寫入偏差的幻讀 + +所有這些例子都遵循類似的模式: + +1. 一個`SELECT`查詢找出符合條件的行,並檢查是否符合一些要求。(例如:至少有兩名醫生在值班;不存在對該會議室同一時段的預定;棋盤上的位置沒有被其他棋子佔據;使用者名稱還沒有被搶注;賬戶裡還有足夠餘額) + +2. 按照第一個查詢的結果,應用程式碼決定是否繼續。(可能會繼續操作,也可能中止並報錯) + +3. 如果應用決定繼續操作,就執行寫入(插入、更新或刪除),並提交事務。 + + 這個寫入的效果改變了步驟2 中的先決條件。換句話說,如果在提交寫入後,重複執行一次步驟1 的SELECT查詢,將會得到不同的結果。因為寫入改變符合搜尋條件的行集(現在少了一個醫生值班,那時候的會議室現在已經被預訂了,棋盤上的這個位置已經被佔據了,使用者名稱已經被搶注,賬戶餘額不夠了)。 + +這些步驟可能以不同的順序發生。例如可以首先進行寫入,然後進行SELECT查詢,最後根據查詢結果決定是放棄還是提交。 + +在醫生值班的例子中,在步驟3中修改的行,是步驟1中返回的行之一,所以我們可以透過鎖定步驟1 中的行(`SELECT FOR UPDATE`)來使事務安全並避免寫入偏差。但是其他四個例子是不同的:它們檢查是否**不存在**某些滿足條件的行,寫入會**新增**一個匹配相同條件的行。如果步驟1中的查詢沒有返回任何行,則`SELECT FOR UPDATE`鎖不了任何東西。 + +這種效應:一個事務中的寫入改變另一個事務的搜尋查詢的結果,被稱為**幻讀**【3】。快照隔離避免了只讀查詢中幻讀,但是在像我們討論的例子那樣的讀寫事務中,幻讀會導致特別棘手的寫入偏差情況。 + +#### 物化衝突 + +如果幻讀的問題是沒有物件可以加鎖,也許可以人為地在資料庫中引入一個鎖物件? + +例如,在會議室預訂的場景中,可以想象建立一個關於時間槽和房間的表。此表中的每一行對應於特定時間段(例如15分鐘)的特定房間。可以提前插入房間和時間的所有可能組合行(例如接下來的六個月)。 + +現在,要建立預訂的事務可以鎖定(`SELECT FOR UPDATE`)表中與所需房間和時間段對應的行。在獲得鎖定之後,它可以檢查重疊的預訂並像以前一樣插入新的預訂。請注意,這個表並不是用來儲存預訂相關的資訊——它完全就是一組鎖,用於防止同時修改同一房間和時間範圍內的預訂。 + +這種方法被稱為**物化衝突(materializing conflicts)**,因為它將幻讀變為資料庫中一組具體行上的鎖衝突【11】。不幸的是,弄清楚如何物化衝突可能很難,也很容易出錯,而讓併發控制機制洩漏到應用資料模型是很醜陋的做法。出於這些原因,如果沒有其他辦法可以實現,物化衝突應被視為最後的手段。在大多數情況下。**可序列化(Serializable)** 的隔離級別是更可取的。 + + + +## 可序列化 + +在本章中,已經看到了幾個易於出現競爭條件的事務例子。**讀已提交**和**快照隔離**級別會阻止某些競爭條件,但不會阻止另一些。我們遇到了一些特別棘手的例子,**寫入偏差**和**幻讀**。這是一個可悲的情況: + +- 隔離級別難以理解,並且在不同的資料庫中實現的不一致(例如,“可重複讀”的含義天差地別)。 +- 光檢查應用程式碼很難判斷在特定的隔離級別執行是否安全。 特別是在大型應用程式中,您可能並不知道併發發生的所有事情。 +- 沒有檢測競爭條件的好工具。原則上來說,靜態分析可能會有幫助【26】,但研究中的技術還沒法實際應用。併發問題的測試是很難的,因為它們通常是非確定性的 —— 只有在倒黴的時機下才會出現問題。 + +這不是一個新問題,從20世紀70年代以來就一直是這樣了,當時首先引入了較弱的隔離級別【2】。一直以來,研究人員的答案都很簡單:使用**可序列化(serializable)** 的隔離級別! + +**可序列化(Serializability)**隔離通常被認為是最強的隔離級別。它保證即使事務可以並行執行,最終的結果也是一樣的,就好像它們沒有任何併發性,連續挨個執行一樣。因此資料庫保證,如果事務在單獨執行時正常執行,則它們在併發執行時繼續保持正確 —— 換句話說,資料庫可以防止**所有**可能的競爭條件。 + +但如果可序列化隔離級別比弱隔離級別的爛攤子要好得多,那為什麼沒有人見人愛?為了回答這個問題,我們需要看看實現可序列化的選項,以及它們如何執行。目前大多數提供可序列化的資料庫都使用了三種技術之一,本章的剩餘部分將會介紹這些技術。 + +- 字面意義上地序列順序執行事務(參見“[真的序列執行](#真的序列執行)”) +- **兩相鎖定(2PL, two-phase locking)**,幾十年來唯一可行的選擇。(參見“[兩相鎖定(2PL)](#兩階段鎖定(2PL))”) +- 樂觀併發控制技術,例如**可序列化的快照隔離(serializable snapshot isolation)**(參閱“[可序列化的快照隔離(SSI)](#序列化快照隔離(SSI))” + +現在將主要在單節點資料庫的背景下討論這些技術;在[第9章](ch9.md)中,我們將研究如何將它們推廣到涉及分散式系統中多個節點的事務。 + +#### 真的序列執行 + +避免併發問題的最簡單方法就是完全不要併發:在單個執行緒上按順序一次只執行一個事務。這樣做就完全繞開了檢測/防止事務間衝突的問題,由此產生的隔離,正是可序列化的定義。 + +儘管這似乎是一個明顯的主意,但資料庫設計人員只是在2007年左右才決定,單執行緒迴圈執行事務是可行的【45】。如果多執行緒併發在過去的30年中被認為是獲得良好效能的關鍵所在,那麼究竟是什麼改變致使單執行緒執行變為可能呢? + +兩個進展引發了這個反思: + +- RAM足夠便宜了,許多場景現在都可以將完整的活躍資料集儲存在記憶體中。(參閱“[在記憶體中儲存一切](ch3.md#在記憶體中儲存一切)”)。當事務需要訪問的所有資料都在記憶體中時,事務處理的執行速度要比等待資料從磁碟載入時快得多。 +- 資料庫設計人員意識到OLTP事務通常很短,而且只進行少量的讀寫操作(參閱“[事務處理或分析?](ch3.md#事務處理還是分析?)”)。相比之下,長時間執行的分析查詢通常是隻讀的,因此它們可以在序列執行迴圈之外的一致快照(使用快照隔離)上執行。 + +序列執行事務的方法在VoltDB/H-Store,Redis和Datomic中實現【46,47,48】。設計用於單執行緒執行的系統有時可以比支援併發的系統更好,因為它可以避免鎖的協調開銷。但是其吞吐量僅限於單個CPU核的吞吐量。為了充分利用單一執行緒,需要與傳統形式不同的結構的事務。 + +#### 在儲存過程中封裝事務 + +​ 在資料庫的早期階段,意圖是資料庫事務可以包含整個使用者活動流程。例如,預訂機票是一個多階段的過程(搜尋路線,票價和可用座位,決定行程,在每段行程的航班上訂座,輸入乘客資訊,付款)。資料庫設計者認為,如果整個過程是一個事務,那麼它就可以被原子化地執行。 + +​ 不幸的是,人類做出決定和迴應的速度非常緩慢。如果資料庫事務需要等待來自使用者的輸入,則資料庫需要支援潛在的大量併發事務,其中大部分是空閒的。大多數資料庫不能高效完成這項工作,因此幾乎所有的OLTP應用程式都避免在事務中等待互動式的使用者輸入,以此來保持事務的簡短。在Web上,這意味著事務在同一個HTTP請求中被提交——一個事務不會跨越多個請求。一個新的HTTP請求開始一個新的事務。 + +​ 即使人類已經找到了關鍵路徑,事務仍然以互動式的客戶端/伺服器風格執行,一次一個語句。應用程式進行查詢,讀取結果,可能根據第一個查詢的結果進行另一個查詢,依此類推。查詢和結果在應用程式程式碼(在一臺機器上執行)和資料庫伺服器(在另一臺機器上)之間來回傳送。 + +​ 在這種互動式的事務方式中,應用程式和資料庫之間的網路通訊耗費了大量的時間。如果不允許在資料庫中進行併發處理,且一次只處理一個事務,則吞吐量將會非常糟糕,因為資料庫大部分的時間都花費在等待應用程式發出當前事務的下一個查詢。在這種資料庫中,為了獲得合理的效能,需要同時處理多個事務。 + +​ 出於這個原因,具有單執行緒序列事務處理的系統不允許互動式的多語句事務。取而代之,應用程式必須提前將整個事務程式碼作為儲存過程提交給資料庫。這些方法之間的差異如[圖7-9](img/fig7-9.png) 所示。如果事務所需的所有資料都在記憶體中,則儲存過程可以非常快地執行,而不用等待任何網路或磁碟I/O。 + +![](img/fig7-9.png) + +**圖7-9 互動式事務和儲存過程之間的區別(使用圖7-8的示例事務)** + +#### 儲存過程的優點和缺點 + +儲存過程在關係型資料庫中已經存在了一段時間了,自1999年以來它們一直是SQL標準(SQL/PSM)的一部分。出於各種原因,它們的名聲有點不太好: + +- 每個資料庫廠商都有自己的儲存過程語言(Oracle有PL/SQL,SQL Server有T-SQL,PostgreSQL有PL/pgSQL等)。這些語言並沒有跟上通用程式語言的發展,所以從今天的角度來看,它們看起來相當醜陋和陳舊,而且缺乏大多數程式語言中能找到的庫的生態系統。 +- 與應用伺服器相,比在資料庫中執行的管理困難,除錯困難,版本控制和部署起來也更為尷尬,更難測試,更難和用於監控的指標收集系統相整合。 +- 資料庫通常比應用伺服器對效能敏感的多,因為單個數據庫例項通常由許多應用伺服器共享。資料庫中一個寫得不好的儲存過程(例如,佔用大量記憶體或CPU時間)會比在應用伺服器中相同的程式碼造成更多的麻煩。 + +但是這些問題都是可以克服的。現代的儲存過程實現放棄了PL/SQL,而是使用現有的通用程式語言:VoltDB使用Java或Groovy,Datomic使用Java或Clojure,而Redis使用Lua。 + +**儲存過程與記憶體儲存**,使得在單個執行緒上執行所有事務變得可行。由於不需要等待I/O,且避免了併發控制機制的開銷,它們可以在單個執行緒上實現相當好的吞吐量。 + +VoltDB還使用儲存過程進行復制:但不是將事務的寫入結果從一個節點複製到另一個節點,而是在每個節點上執行相同的儲存過程。因此VoltDB要求儲存過程是**確定性的**(在不同的節點上執行時,它們必須產生相同的結果)。舉個例子,如果事務需要使用當前的日期和時間,則必須透過特殊的確定性API來實現。 + +#### 分割槽 + +順序執行所有事務使併發控制簡單多了,但資料庫的事務吞吐量被限制為單機單核的速度。只讀事務可以使用快照隔離在其它地方執行,但對於寫入吞吐量較高的應用,單執行緒事務處理器可能成為一個嚴重的瓶頸。 + +為了擴充套件到多個CPU核心和多個節點,可以對資料進行分割槽(參見[第6章](ch6.md)),在VoltDB中支援這樣做。如果你可以找到一種對資料集進行分割槽的方法,以便每個事務只需要在單個分割槽中讀寫資料,那麼每個分割槽就可以擁有自己獨立執行的事務處理執行緒。在這種情況下可以為每個分割槽指派一個獨立的CPU核,事務吞吐量就可以與CPU核數保持線性擴充套件【47】。 + +但是,對於需要訪問多個分割槽的任何事務,資料庫必須在觸及的所有分割槽之間協調事務。儲存過程需要跨越所有分割槽鎖定執行,以確保整個系統的可序列性。 + +由於跨分割槽事務具有額外的協調開銷,所以它們比單分割槽事務慢得多。 VoltDB報告的吞吐量大約是每秒1000個跨分割槽寫入,比單分割槽吞吐量低幾個數量級,並且不能透過增加更多的機器來增加【49】。 + +事務是否可以是劃分至單個分割槽很大程度上取決於應用資料的結構。簡單的鍵值資料通常可以非常容易地進行分割槽,但是具有多個二級索引的資料可能需要大量的跨分割槽協調(參閱“[分片與次級索引](ch6.md#分片與次級索引)”)。 + +#### 序列執行小結 + +在特定約束條件下,真的序列執行事務,已經成為一種實現可序列化隔離等級的可行辦法。 + +- 每個事務都必須小而快,只要有一個緩慢的事務,就會拖慢所有事務處理。 +- 僅限於活躍資料集可以放入記憶體的情況。很少訪問的資料可能會被移動到磁碟,但如果需要在單執行緒執行的事務中訪問,系統就會變得非常慢[^x]。 +- 寫入吞吐量必須低到能在單個CPU核上處理,如若不然,事務需要能劃分至單個分割槽,且不需要跨分割槽協調。 +- 跨分割槽事務是可能的,但是它們的使用程度有很大的限制。 + +[^x]: 如果事務需要訪問不在記憶體中的資料,最好的解決方案可能是中止事務,非同步地將資料提取到記憶體中,同時繼續處理其他事務,然後在資料載入完畢時重新啟動事務。這種方法被稱為**反快取(anti-caching)**,正如前面在第88頁“將所有內容儲存在記憶體”中所述。 + +### 兩階段鎖定(2PL) + +大約30年來,在資料庫中只有一種廣泛使用的序列化演算法:**兩階段鎖定(2PL,two-phase locking)** [^xi] + +[^xi]: 有時也稱為**嚴格兩階段鎖定(SS2PL, strict two-phase locking)**,以便和其他2PL變體區分。 + +> #### 2PL不是2PC +> +> 請注意,雖然兩階段鎖定(2PL)聽起來非常類似於兩階段提交(2PC),但它們是完全不同的東西。我們將在第9章討論2PC。 + +之前我們看到鎖通常用於防止髒寫(參閱“[沒有髒寫](沒有髒寫)”一節):如果兩個事務同時嘗試寫入同一個物件,則鎖可確保第二個寫入必須等到第一個寫入完成事務(中止或提交),然後才能繼續。 + +兩階段鎖定定類似,但使鎖的要求更強。只要沒有寫入,就允許多個事務同時讀取同一個物件。但物件只要有寫入(修改或刪除),就需要**獨佔訪問(exclusive access)** 許可權: + +- 如果事務A讀取了一個物件,並且事務B想要寫入該物件,那麼B必須等到A提交或中止才能繼續。 (這確保B不能在A底下意外地改變物件。) +- 如果事務A寫入了一個物件,並且事務B想要讀取該物件,則B必須等到A提交或中止才能繼續。 (像[圖7-1]()那樣讀取舊版本的物件在2PL下是不可接受的。) + +在2PL中,寫入不僅會阻塞其他寫入,也會阻塞讀,反之亦然。快照隔離使得**讀不阻塞寫,寫也不阻塞讀**(參閱“[實現快照隔離](#實現快照隔離)”),這是2PL和快照隔離之間的關鍵區別。另一方面,因為2PL提供了可序列化的性質,它可以防止早先討論的所有競爭條件,包括丟失更新和寫入偏差。 + +#### 實現兩階段鎖 + +2PL用於MySQL(InnoDB)和SQL Server中的可序列化隔離級別,以及DB2中的可重複讀隔離級別【23,36】。 + +讀與寫的阻塞是透過為資料庫中每個物件新增鎖來實現的。鎖可以處於**共享模式(shared mode)**或**獨佔模式(exclusive mode)**。鎖使用如下: + +- 若事務要讀取物件,則須先以共享模式獲取鎖。允許多個事務同時持有共享鎖。但如果另一個事務已經在物件上持有排它鎖,則這些事務必須等待。 +- 若事務要寫入一個物件,它必須首先以獨佔模式獲取該鎖。沒有其他事務可以同時持有鎖(無論是共享模式還是獨佔模式),所以如果物件上存在任何鎖,該事務必須等待。 +- 如果事務先讀取再寫入物件,則它可能會將其共享鎖升級為獨佔鎖。升級鎖的工作與直接獲得排他鎖相同。 +- 事務獲得鎖之後,必須繼續持有鎖直到事務結束(提交或中止)。這就是“兩階段”這個名字的來源:第一階段(當事務正在執行時)獲取鎖,第二階段(在事務結束時)釋放所有的鎖。 + +由於使用了這麼多的鎖,因此很可能會發生:事務A等待事務B釋放它的鎖,反之亦然。這種情況叫做**死鎖(Deadlock)**。資料庫會自動檢測事務之間的死鎖,並中止其中一個,以便另一個繼續執行。被中止的事務需要由應用程式重試。 + +#### 兩階段鎖定的效能 + +兩階段鎖定的巨大缺點,以及70年代以來沒有被所有人使用的原因,是其效能問題。兩階段鎖定下的事務吞吐量與查詢響應時間要比弱隔離級別下要差得多。 + +這一部分是由於獲取和釋放所有這些鎖的開銷,但更重要的是由於併發性的降低。按照設計,如果兩個併發事務試圖做任何可能導致競爭條件的事情,那麼必須等待另一個完成。 + +傳統的關係資料庫不限制事務的持續時間,因為它們是為等待人類輸入的互動式應用而設計的。因此,當一個事務需要等待另一個事務時,等待的時長並沒有限制。即使你保證所有的事務都很短,如果有多個事務想要訪問同一個物件,那麼可能會形成一個佇列,所以事務可能需要等待幾個其他事務才能完成。 + +因此,執行2PL的資料庫可能具有相當不穩定的延遲,如果在工作負載中存在爭用,那麼可能高百分位點處的響應會非常的慢(參閱“[描述效能](ch1.md#描述效能)”)。可能只需要一個緩慢的事務,或者一個訪問大量資料並獲取許多鎖的事務,就能把系統的其他部分拖慢,甚至迫使系統停機。當需要穩健的操作時,這種不穩定性是有問題的。 + +基於鎖實現的讀已提交隔離級別可能發生死鎖,但在基於2PL實現的可序列化隔離級別中,它們會出現的頻繁的多(取決於事務的訪問模式)。這可能是一個額外的效能問題:當事務由於死鎖而被中止並被重試時,它需要從頭重做它的工作。如果死鎖很頻繁,這可能意味著巨大的浪費。 + +#### 謂詞鎖 + +在前面關於鎖的描述中,我們掩蓋了一個微妙而重要的細節。在“[導致寫入偏差的幻讀](#導致寫入偏差的幻讀)”中,我們討論了**幻讀(phantoms)**的問題。即一個事務改變另一個事務的搜尋查詢的結果。具有可序列化隔離級別的資料庫必須防止**幻讀**。 + +在會議室預訂的例子中,這意味著如果一個事務在某個時間視窗內搜尋了一個房間的現有預訂(見[例7-2]()),則另一個事務不能同時插入或更新同一時間視窗與同一房間的另一個預訂 (可以同時插入其他房間的預訂,或在不影響另一個預定的條件下預定同一房間的其他時間段)。 + +如何實現這一點?從概念上講,我們需要一個**謂詞鎖(predicate lock)**【3】。它類似於前面描述的共享/排它鎖,但不屬於特定的物件(例如,表中的一行),它屬於所有符合某些搜尋條件的物件,如: + +```sql +SELECT * FROM bookings +WHERE room_id = 123 AND + end_time > '2018-01-01 12:00' AND + start_time < '2018-01-01 13:00'; +``` + +謂詞鎖限制訪問,如下所示: + +- 如果事務A想要讀取匹配某些條件的物件,就像在這個 `SELECT` 查詢中那樣,它必須獲取查詢條件上的**共享謂詞鎖(shared-mode predicate lock)**。如果另一個事務B持有任何滿足這一查詢條件物件的排它鎖,那麼A必須等到B釋放它的鎖之後才允許進行查詢。 +- 如果事務A想要插入,更新或刪除任何物件,則必須首先檢查舊值或新值是否與任何現有的謂詞鎖匹配。如果事務B持有匹配的謂詞鎖,那麼A必須等到B已經提交或中止後才能繼續。 + +這裡的關鍵思想是,謂詞鎖甚至適用於資料庫中尚不存在,但將來可能會新增的物件(幻象)。如果兩階段鎖定包含謂詞鎖,則資料庫將阻止所有形式的寫入偏差和其他競爭條件,因此其隔離實現了可序列化。 + +#### 索引範圍鎖 + +不幸的是謂詞鎖效能不佳:**如果活躍事務持有很多鎖,檢查匹配的鎖會非常耗時。**因此,大多數使用2PL的資料庫實際上實現了索引範圍鎖(也稱為**間隙鎖(next-key locking)**),這是一個簡化的近似版謂詞鎖【41,50】。 + +透過使謂詞匹配到一個更大的集合來簡化謂詞鎖是安全的。例如,如果你有在中午和下午1點之間預訂123號房間的謂詞鎖,則鎖定123號房間的所有時間段,或者鎖定12:00~13:00時間段的所有房間(不只是123號房間)是一個安全的近似,因為任何滿足原始謂詞的寫入也一定會滿足這種更鬆散的近似。 + +在房間預訂資料庫中,您可能會在`room_id`列上有一個索引,並且/或者在`start_time` 和 `end_time`上有索引(否則前面的查詢在大型資料庫上的速度會非常慢): + +- 假設您的索引位於`room_id`上,並且資料庫使用此索引查詢123號房間的現有預訂。現在資料庫可以簡單地將共享鎖附加到這個索引項上,指示事務已搜尋123號房間用於預訂。 +- 或者,如果資料庫使用基於時間的索引來查詢現有預訂,那麼它可以將共享鎖附加到該索引中的一系列值,指示事務已經將12:00~13:00時間段標記為用於預定。 + +無論哪種方式,搜尋條件的近似值都附加到其中一個索引上。現在,如果另一個事務想要插入,更新或刪除同一個房間和/或重疊時間段的預訂,則它將不得不更新索引的相同部分。在這樣做的過程中,它會遇到共享鎖,它將被迫等到鎖被釋放。 + +這種方法能夠有效防止幻讀和寫入偏差。索引範圍鎖並不像謂詞鎖那樣精確(它們可能會鎖定更大範圍的物件,而不是維持可序列化所必需的範圍),但是由於它們的開銷較低,所以是一個很好的折衷。 + +如果沒有可以掛載間隙鎖的索引,資料庫可以退化到使用整個表上的共享鎖。這對效能不利,因為它會阻止所有其他事務寫入表格,但這是一個安全的回退位置。 + + + +### 序列化快照隔離(SSI) + +本章描繪了資料庫中併發控制的黯淡畫面。一方面,我們實現了效能不好(2PL)或者擴充套件性不好(序列執行)的可序列化隔離級別。另一方面,我們有效能良好的弱隔離級別,但容易出現各種競爭條件(丟失更新,寫入偏差,幻讀等)。序列化的隔離級別和高效能是從根本上相互矛盾的嗎? + +也許不是:一個稱為**可序列化快照隔離(SSI, serializable snapshot isolation)** 的演算法是非常有前途的。它提供了完整的可序列化隔離級別,但與快照隔離相比只有只有很小的效能損失。 SSI是相當新的:它在2008年首次被描述【40】,並且是Michael Cahill的博士論文【51】的主題。 + +今天,SSI既用於單節點資料庫(PostgreSQL9.1 以後的可序列化隔離級別)和分散式資料庫(FoundationDB使用類似的演算法)。由於SSI與其他併發控制機制相比還很年輕,還處於在實踐中證明自己表現的階段。但它有可能因為足夠快而在未來成為新的預設選項。 + +#### 悲觀與樂觀的併發控制 + +兩階段鎖是一種所謂的**悲觀併發控制機制(pessimistic)** :它是基於這樣的原則:如果有事情可能出錯(如另一個事務所持有的鎖所表示的),最好等到情況安全後再做任何事情。這就像互斥,用於保護多執行緒程式設計中的資料結構。 + +從某種意義上說,序列執行可以稱為悲觀到了極致:在事務持續期間,每個事務對整個資料庫(或資料庫的一個分割槽)具有排它鎖,作為對悲觀的補償,我們讓每筆事務執行得非常快,所以只需要短時間持有“鎖”。 + +相比之下,**序列化快照隔離**是一種**樂觀(optimistic)** 的併發控制技術。在這種情況下,樂觀意味著,如果存在潛在的危險也不阻止事務,而是繼續執行事務,希望一切都會好起來。當一個事務想要提交時,資料庫檢查是否有什麼不好的事情發生(即隔離是否被違反);如果是的話,事務將被中止,並且必須重試。只有可序列化的事務才被允許提交。 + +樂觀併發控制是一個古老的想法【52】,其優點和缺點已經爭論了很長時間【53】。如果存在很多**爭用(contention)**(很多事務試圖訪問相同的物件),則表現不佳,因為這會導致很大一部分事務需要中止。如果系統已經接近最大吞吐量,來自重試事務的額外負載可能會使效能變差。 + +但是,如果有足夠的備用容量,並且事務之間的爭用不是太高,樂觀的併發控制技術往往比悲觀的要好。可交換的原子操作可以減少爭用:例如,如果多個事務同時要增加一個計數器,那麼應用增量的順序(只要計數器不在同一個事務中讀取)就無關緊要了,所以併發增量可以全部應用且無需衝突。 + +顧名思義,SSI基於快照隔離——也就是說,事務中的所有讀取都是來自資料庫的一致性快照(參見“[快照隔離和可重複讀取](#快照隔離和可重複讀)”)。與早期的樂觀併發控制技術相比這是主要的區別。在快照隔離的基礎上,SSI添加了一種演算法來檢測寫入之間的序列化衝突,並確定要中止哪些事務。 + +#### 基於過時前提的決策 + +先前討論了快照隔離中的寫入偏差(參閱“[寫入偏差和幻像](#寫入偏差與幻讀)”)時,我們觀察到一個迴圈模式:事務從資料庫讀取一些資料,檢查查詢的結果,並根據它看到的結果決定採取一些操作(寫入資料庫)。但是,在快照隔離的情況下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在同一時間被修改。 + +換句話說,事務基於一個**前提(premise)** 採取行動(事務開始時候的事實,例如:“目前有兩名醫生正在值班”)。之後當事務要提交時,原始資料可能已經改變——前提可能不再成立。 + +當應用程式進行查詢時(例如,“當前有多少醫生正在值班?”),資料庫不知道應用邏輯如何使用該查詢結果。在這種情況下為了安全,資料庫需要假設任何對該結果集的變更都可能會使該事務中的寫入變得無效。 換而言之,事務中的查詢與寫入可能存在因果依賴。為了提供可序列化的隔離級別,如果事務在過時的前提下執行操作,資料庫必須能檢測到這種情況,並中止事務。 + +資料庫如何知道查詢結果是否可能已經改變?有兩種情況需要考慮: + +- 檢測對舊MVCC物件版本的讀取(讀之前存在未提交的寫入) +- 檢測影響先前讀取的寫入(讀之後發生寫入) + +#### 檢測舊MVCC讀取 + +回想一下,快照隔離通常是透過多版本併發控制(MVCC;見[圖7-10]())來實現的。當一個事務從MVCC資料庫中的一致快照讀時,它將忽略取快照時尚未提交的任何其他事務所做的寫入。在[圖7-10]()中,事務43 認為Alice的 `on_call = true` ,因為事務42(修改Alice的待命狀態)未被提交。然而,在事務43想要提交時,事務42 已經提交。這意味著在讀一致性快照時被忽略的寫入已經生效,事務43 的前提不再為真。 + +![](img/fig7-10.png) + +**圖7-10 檢測事務何時從MVCC快照讀取過時的值** + +為了防止這種異常,資料庫需要跟蹤一個事務由於MVCC可見性規則而忽略另一個事務的寫入。當事務想要提交時,資料庫檢查是否有任何被忽略的寫入現在已經被提交。如果是這樣,事務必須中止。 + +為什麼要等到提交?當檢測到陳舊的讀取時,為什麼不立即中止事務43 ?因為如果事務43 是隻讀事務,則不需要中止,因為沒有寫入偏差的風險。當事務43 進行讀取時,資料庫還不知道事務是否要稍後執行寫操作。此外,事務42 可能在事務43 被提交的時候中止或者可能仍然未被提交,因此讀取可能終究不是陳舊的。透過避免不必要的中止,SSI 保留快照隔離對從一致快照中長時間執行的讀取的支援。 + +#### 檢測影響之前讀取的寫入 + +第二種情況要考慮的是另一個事務在讀取資料之後修改資料。這種情況如[圖7-11](img/fig7-11.png)所示。 + +![](img/fig7-11.png) + +**圖7-11 在可序列化快照隔離中,檢測一個事務何時修改另一個事務的讀取。** + +在兩階段鎖定的上下文中,我們討論了[索引範圍鎖]()(請參閱“[索引範圍鎖]()”),它允許資料庫鎖定與某個搜尋查詢匹配的所有行的訪問權,例如 `WHERE shift_id = 1234`。可以在這裡使用類似的技術,除了SSI鎖不會阻塞其他事務。 + +在[圖7-11]()中,事務42 和43 都在班次1234 查詢值班醫生。如果在`shift_id`上有索引,則資料庫可以使用索引項1234 來記錄事務42 和43 讀取這個資料的事實。 (如果沒有索引,這個資訊可以在表級別進行跟蹤)。這個資訊只需要保留一段時間:在一個事務完成(提交或中止),並且所有的併發事務完成之後,資料庫就可以忘記它讀取的資料了。 + +當事務寫入資料庫時,它必須在索引中查詢最近曾讀取受影響資料的其他事務。這個過程類似於在受影響的鍵範圍上獲取寫鎖,但鎖並不會阻塞事務指導其他讀事務完成,而是像警戒線一樣只是簡單通知其他事務:你們讀過的資料可能不是最新的啦。 + +在[圖7-11]()中,事務43 通知事務42 其先前讀已過時,反之亦然。事務42首先提交併成功,儘管事務43 的寫影響了42 ,但因為事務43 尚未提交,所以寫入尚未生效。然而當事務43 想要提交時,來自事務42 的衝突寫入已經被提交,所以事務43 必須中止。 + +#### 可序列化的快照隔離的效能 + +與往常一樣,許多工程細節會影響演算法的實際表現。例如一個權衡是跟蹤事務的讀取和寫入的**粒度(granularity)**。如果資料庫詳細地跟蹤每個事務的活動(細粒度),那麼可以準確地確定哪些事務需要中止,但是簿記開銷可能變得很顯著。簡略的跟蹤速度更快(粗粒度),但可能會導致更多不必要的事務中止。 + +在某些情況下,事務可以讀取被另一個事務覆蓋的資訊:這取決於發生了什麼,有時可以證明執行結果無論如何都是可序列化的。 PostgreSQL使用這個理論來減少不必要的中止次數【11,41】。 + +與兩階段鎖定相比,可序列化快照隔離的最大優點是一個事務不需要阻塞等待另一個事務所持有的鎖。就像在快照隔離下一樣,寫不會阻塞讀,反之亦然。這種設計原則使得查詢延遲更可預測,變數更少。特別是,只讀查詢可以執行在一致的快照上,而不需要任何鎖定,這對於讀取繁重的工作負載非常有吸引力。 + +與序列執行相比,可序列化快照隔離並不侷限於單個CPU核的吞吐量:FoundationDB將檢測到的序列化衝突分佈在多臺機器上,允許擴充套件到很高的吞吐量。即使資料可能跨多臺機器進行分割槽,事務也可以在保證可序列化隔離等級的同時讀寫多個分割槽中的資料【54】。 + +中止率顯著影響SSI的整體表現。例如,長時間讀取和寫入資料的事務很可能會發生衝突並中止,因此SSI要求同時讀寫的事務儘量短(只讀長事務可能沒問題)。對於慢事務,SSI可能比兩階段鎖定或序列執行更不敏感。 + + + +## 本章小結 + +事務是一個抽象層,允許應用程式假裝某些併發問題和某些型別的硬體和軟體故障不存在。各式各樣的錯誤被簡化為一種簡單情況:**事務中止(transaction abort)**,而應用需要的僅僅是重試。 + +在本章中介紹了很多問題,事務有助於防止這些問題發生。並非所有應用都易受此類問題影響:具有非常簡單訪問模式的應用(例如每次讀寫單條記錄)可能無需事務管理。但是對於更復雜的訪問模式,事務可以大大減少需要考慮的潛在錯誤情景數量。 + +如果沒有事務處理,各種錯誤情況(程序崩潰,網路中斷,停電,磁碟已滿,意外併發等)意味著資料可能以各種方式變得不一致。例如,非規範化的資料可能很容易與源資料不同步。如果沒有事務處理,就很難推斷複雜的互動訪問可能對資料庫造成的影響。 + +本章深入討論了**併發控制**的話題。我們討論了幾個廣泛使用的隔離級別,特別是**讀已提交**,**快照隔離**(有時稱為可重複讀)和**可序列化**。並透過研究競爭條件的各種例子,來描述這些隔離等級: + +***髒讀*** + +​ 一個客戶端讀取到另一個客戶端尚未提交的寫入。**讀已提交**或更強的隔離級別可以防止髒讀。 + +***髒寫*** + +​ 一個客戶端覆蓋寫入了另一個客戶端尚未提交的寫入。幾乎所有的事務實現都可以防止髒寫。 + +***讀取偏差(不可重複讀)*** + +​ 在同一個事務中,客戶端在不同的時間點會看見資料庫的不同狀態。**快照隔離**經常用於解決這個問題,它允許事務從一個特定時間點的一致性快照中讀取資料。快照隔離通常使用**多版本併發控制(MVCC)** 來實現。 + +***更新丟失*** + +​ 兩個客戶端同時執行**讀取-修改-寫入序列**。其中一個寫操作,在沒有合併另一個寫入變更情況下,直接覆蓋了另一個寫操作的結果。所以導致資料丟失。快照隔離的一些實現可以自動防止這種異常,而另一些實現則需要手動鎖定(`SELECT FOR UPDATE`)。 + +***寫偏差*** + +​ 一個事務讀取一些東西,根據它所看到的值作出決定,並將該決定寫入資料庫。但是,寫入時,該決定的前提不再是真實的。只有可序列化的隔離才能防止這種異常。 + +***幻讀*** + +​ 事務讀取符合某些搜尋條件的物件。另一個客戶端進行寫入,影響搜尋結果。快照隔離可以防止直接的幻像讀取,但是寫入偏差上下文中的幻讀需要特殊處理,例如索引範圍鎖定。 + +​弱隔離級別可以防止其中一些異常情況,此外讓應用程式開發人員手動處理剩餘那些(例如,使用顯式鎖定)。只有可序列化的隔離才能防範所有這些問題。我們討論了實現可序列化事務的三種不同方法: + +***字面意義上的序列執行*** + +​ 如果每個事務的執行速度非常快,並且事務吞吐量足夠低,足以在單個CPU核上處理,這是一個簡單而有效的選擇。 + +***兩階段鎖定*** + +​ 數十年來,兩階段鎖定一直是實現可序列化的標準方式,但是許多應用出於效能問題的考慮避免使用它。 + +***可序列化快照隔離(SSI)*** + +​ 一個相當新的演算法,避免了先前方法的大部分缺點。它使用樂觀的方法,允許事務執行而無需阻塞。當一個事務想要提交時,它會進行檢查,如果執行不可序列化,事務就會被中止。 + +​ 本章中的示例主要是在關係資料模型的上下文中。使用關係資料模型。但是,正如在討論中,無論使用哪種資料模型,如“**[多物件事務的需求](#多物件事務的需求)**”中所討論的,事務都是重要的資料庫功能。 + +​ 本章主要是在單機資料庫的上下文中,探討了各種概念與想法。分散式資料庫中的事務,則引入了一系列新的困難挑戰,將在接下來的兩章中討論。 + + + +## 參考文獻 + +1. Donald D. Chamberlin, Morton M. Astrahan, Michael W. Blasgen, et al.: “[A History and Evaluation of System R](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.84.348&rep=rep1&type=pdf),” *Communications of the ACM*, volume 24, number 10, pages 632–646, October 1981. + [doi:10.1145/358769.358784](http://dx.doi.org/10.1145/358769.358784) +2. 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](http://citeseer.ist.psu.edu/viewdoc/download?doi=10.1.1.92.8248&rep=rep1&type=pdf),” 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 +3. Kapali P. Eswaran, Jim N. Gray, Raymond A. Lorie, and Irving L. Traiger: “[The Notions of Consistency and Predicate Locks in a Database System](http://research.microsoft.com/en-us/um/people/gray/papers/On%20the%20Notions%20of%20Consistency%20and%20Predicate%20Locks%20in%20a%20Database%20System%20CACM.pdf),” *Communications of the ACM*, volume 19, number 11, pages 624–633, November 1976. +4. “[ACID Transactions Are Incredibly Helpful](http://web.archive.org/web/20150320053809/https://foundationdb.com/acid-claims),” FoundationDB, LLC, 2013. +5. John D. Cook: “[ACID Versus BASE for Database Transactions](http://www.johndcook.com/blog/2009/07/06/brewer-cap-theorem-base/),” *johndcook.com*, July 6, 2009. +6. Gavin Clarke: “[NoSQL's CAP Theorem Busters: We Don't Drop ACID](http://www.theregister.co.uk/2012/11/22/foundationdb_fear_of_cap_theorem/),” *theregister.co.uk*, November 22, 2012. +7. Theo Härder and Andreas Reuter: “[Principles of Transaction-Oriented Database Recovery](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.87.2812&rep=rep1&type=pdf),” *ACM Computing Surveys*, volume 15, number 4, pages 287–317, December 1983. [doi:10.1145/289.291](http://dx.doi.org/10.1145/289.291) +8. Peter Bailis, Alan Fekete, Ali Ghodsi, et al.: “[HAT, not CAP: Towards Highly Available Transactions](http://www.bailis.org/papers/hat-hotos2013.pdf),” + at *14th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2013. +9. Armando Fox, Steven D. Gribble, Yatin Chawathe, et al.: “[Cluster-Based Scalable Network Services](http://www.cs.berkeley.edu/~brewer/cs262b/TACC.pdf),” at + *16th ACM Symposium on Operating Systems Principles* (SOSP), October 1997. +10. Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman: [*Concurrency Control and Recovery in Database Systems*](http://research.microsoft.com/en-us/people/philbe/ccontrol.aspx). Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at *research.microsoft.com*. +11. Alan Fekete, Dimitrios Liarokapis, Elizabeth O'Neil, et al.: “[Making Snapshot Isolation Serializable](https://www.cse.iitb.ac.in/infolab/Data/Courses/CS632/2009/Papers/p492-fekete.pdf),” *ACM Transactions on Database Systems*, volume 30, number 2, pages 492–528, June 2005. + [doi:10.1145/1071610.1071615](http://dx.doi.org/10.1145/1071610.1071615) +12. Mai Zheng, Joseph Tucek, Feng Qin, and Mark Lillibridge: “[Understanding the Robustness of SSDs Under Power Fault](https://www.usenix.org/system/files/conference/fast13/fast13-final80.pdf),” at *11th USENIX Conference on File and Storage Technologies* (FAST), February 2013. +13. Laurie Denness: “[SSDs: A Gift and a Curse](https://laur.ie/blog/2015/06/ssds-a-gift-and-a-curse/),” *laur.ie*, June 2, 2015. +14. Adam Surak: “[When Solid State Drives Are Not That Solid](https://blog.algolia.com/when-solid-state-drives-are-not-that-solid/),” *blog.algolia.com*, June 15, 2015. +15. Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, et al.: “[All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications](http://research.cs.wisc.edu/wind/Publications/alice-osdi14.pdf),” at *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), + October 2014. +16. Chris Siebenmann: “[Unix's File Durability Problem](https://utcc.utoronto.ca/~cks/space/blog/unix/FileSyncProblem),” *utcc.utoronto.ca*, April 14, 2016. +17. Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, et al.: “[An Analysis of Data Corruption in the Storage Stack](http://research.cs.wisc.edu/adsl/Publications/corruption-fast08.pdf),” at *6th USENIX Conference on File and Storage Technologies* (FAST), February 2008. +18. Bianca Schroeder, Raghav Lagisetty, and Arif Merchant: “[Flash Reliability in Production: The Expected and the Unexpected](https://www.usenix.org/conference/fast16/technical-sessions/presentation/schroeder),” at *14th USENIX Conference on File and Storage Technologies* (FAST), February 2016. +19. Don Allison: “[SSD Storage – Ignorance of Technology Is No Excuse](https://blog.korelogic.com/blog/2015/03/24),” *blog.korelogic.com*, March 24, 2015. +20. Dave Scherer: “[Those Are Not Transactions (Cassandra 2.0)](http://web.archive.org/web/20150526065247/http://blog.foundationdb.com/those-are-not-transactions-cassandra-2-0),” *blog.foundationdb.com*, September 6, 2013. +21. Kyle Kingsbury: “[Call Me Maybe: Cassandra](http://aphyr.com/posts/294-call-me-maybe-cassandra/),” *aphyr.com*, September 24, 2013. +22. “[ACID Support in Aerospike](http://www.aerospike.com/docs/architecture/assets/AerospikeACIDSupport.pdf),” Aerospike, Inc., June 2014. +23. Martin Kleppmann: “[Hermitage: Testing the 'I' in ACID](http://martin.kleppmann.com/2014/11/25/hermitage-testing-the-i-in-acid.html),” *martin.kleppmann.com*, November 25, 2014. +24. Tristan D'Agosta: “[BTC Stolen from Poloniex](https://bitcointalk.org/index.php?topic=499580),” *bitcointalk.org*, March 4, 2014. +25. bitcointhief2: “[How I Stole Roughly 100 BTC from an Exchange and How I Could Have Stolen More!](http://www.reddit.com/r/Bitcoin/comments/1wtbiu/how_i_stole_roughly_100_btc_from_an_exchange_and/),” *reddit.com*, February 2, 2014. +26. Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan: “[Automating the Detection of Snapshot Isolation Anomalies](http://www.vldb.org/conf/2007/papers/industrial/p1263-jorwekar.pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007. +27. Michael Melanson: “[Transactions: The Limits of Isolation](http://www.michaelmelanson.net/2014/03/20/transactions/),” *michaelmelanson.net*, March 20, 2014. +28. Hal Berenson, Philip A. Bernstein, Jim N. Gray, et al.: “[A Critique of ANSI SQL Isolation Levels](http://research.microsoft.com/pubs/69541/tr-95-51.pdf),” + at *ACM International Conference on Management of Data* (SIGMOD), May 1995. +29. Atul Adya: “[Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions](http://pmg.csail.mit.edu/papers/adya-phd.pdf),” PhD Thesis, Massachusetts Institute of Technology, March 1999. +30. Peter Bailis, Aaron Davidson, Alan Fekete, et al.: “[Highly Available Transactions: Virtues and Limitations (Extended Version)](http://arxiv.org/pdf/1302.0309.pdf),” at *40th International Conference on Very Large Data Bases* (VLDB), September 2014. +31. Bruce Momjian: “[MVCC Unmasked](http://momjian.us/main/presentations/internals.html#mvcc),” *momjian.us*, July 2014. +32. Annamalai Gurusami: “[Repeatable Read Isolation Level in InnoDB – How Consistent Read View Works](https://blogs.oracle.com/mysqlinnodb/entry/repeatable_read_isolation_level_in),” *blogs.oracle.com*, January 15, 2013. +33. Nikita Prokopov: “[Unofficial Guide to Datomic Internals](http://tonsky.me/blog/unofficial-guide-to-datomic-internals/),” *tonsky.me*, May 6, 2014. +34. Baron Schwartz: “[Immutability, MVCC, and Garbage Collection](http://www.xaprb.com/blog/2013/12/28/immutability-mvcc-and-garbage-collection/),” *xaprb.com*, December 28, 2013. +35. J. Chris Anderson, Jan Lehnardt, and Noah Slater: *CouchDB: The Definitive Guide*. O'Reilly Media, 2010. + ISBN: 978-0-596-15589-6 Rikdeb Mukherjee: “[Isolation in DB2 (Repeatable Read, Read Stability, Cursor Stability, Uncommitted Read) with Examples](http://mframes.blogspot.co.uk/2013/07/isolation-in-cursor.html),” *mframes.blogspot.co.uk*, July 4, 2013. +36. Steve Hilker: “[Cursor Stability (CS) – IBM DB2 Community](http://www.toadworld.com/platforms/ibmdb2/w/wiki/6661.cursor-stability-cs.aspx),” *toadworld.com*, March 14, 2013. +37. Nate Wiger: “[An Atomic Rant](http://www.nateware.com/an-atomic-rant.html),” *nateware.com*, February 18, 2010. +38. Joel Jacobson: “[Riak 2.0: Data Types](http://blog.joeljacobson.com/riak-2-0-data-types/),” *blog.joeljacobson.com*, March 23, 2014. +39. Michael J. Cahill, Uwe Röhm, and Alan Fekete: “[Serializable Isolation for Snapshot Databases](http://www.cs.nyu.edu/courses/fall12/CSCI-GA.2434-001/p729-cahill.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2008. [doi:10.1145/1376616.1376690](http://dx.doi.org/10.1145/1376616.1376690) +40. Dan R. K. Ports and Kevin Grittner: “[Serializable Snapshot Isolation in PostgreSQL](http://drkp.net/papers/ssi-vldb12.pdf),” at *38th International Conference on Very Large Databases* (VLDB), August 2012. +41. Tony Andrews: “[Enforcing Complex Constraints in Oracle](http://tonyandrews.blogspot.co.uk/2004/10/enforcing-complex-constraints-in.html),” *tonyandrews.blogspot.co.uk*, October 15, 2004. +42. Douglas B. Terry, Marvin M. Theimer, Karin Petersen, et al.: “[Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.141.7889&rep=rep1&type=pdf),” at *15th ACM Symposium on Operating Systems Principles* (SOSP), December 1995. [doi:10.1145/224056.224070](http://dx.doi.org/10.1145/224056.224070) +43. Gary Fredericks: “[Postgres Serializability Bug](https://github.com/gfredericks/pg-serializability-bug),” *github.com*, September 2015. +44. Michael Stonebraker, Samuel Madden, Daniel J. Abadi, et al.: “[The End of an Architectural Era (It’s Time for a Complete Rewrite)](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.137.3697&rep=rep1&type=pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007. +45. John Hugg: “[H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures](https://www.youtube.com/watch?v=hD5M4a1UVz8),” at *Data @Scale Boston*, November 2014. +46. Robert Kallman, Hideaki Kimura, Jonathan Natkins, et al.: “[H-Store: A High-Performance, Distributed Main Memory Transaction Processing System](http://www.vldb.org/pvldb/1/1454211.pdf),” *Proceedings of the VLDB Endowment*, volume 1, number 2, pages 1496–1499, August 2008. +47. Rich Hickey: “[The Architecture of Datomic](http://www.infoq.com/articles/Architecture-Datomic),” *infoq.com*, November 2, 2012. +48. John Hugg: “[Debunking Myths About the VoltDB In-Memory Database](http://voltdb.com/blog/debunking-myths-about-voltdb-memory-database),” *voltdb.com*, May 12, 2014. +49. Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton: “[Architecture of a Database System](http://db.cs.berkeley.edu/papers/fntdb07-architecture.pdf),” + *Foundations and Trends in Databases*, volume 1, number 2, pages 141–259, November 2007. + [doi:10.1561/1900000002](http://dx.doi.org/10.1561/1900000002) +50. Michael J. Cahill: “[Serializable Isolation for Snapshot Databases](http://cahill.net.au/wp-content/uploads/2010/02/cahill-thesis.pdf),” PhD Thesis, University of Sydney, July 2009. +51. D. Z. Badal: “[Correctness of Concurrency Control and Implications in Distributed Databases](http://ieeexplore.ieee.org/abstract/document/762563/),” at *3rd International IEEE Computer Software and Applications Conference* (COMPSAC), November 1979. +52. Rakesh Agrawal, Michael J. Carey, and Miron Livny: “[Concurrency Control Performance Modeling: Alternatives and Implications](http://www.eecs.berkeley.edu/~brewer/cs262/ConcControl.pdf),” *ACM Transactions on Database Systems* (TODS), volume 12, number 4, pages 609–654, December 1987. [doi:10.1145/32204.32220](http://dx.doi.org/10.1145/32204.32220) +53. Dave Rosenthal: “[Databases at 14.4MHz](http://web.archive.org/web/20150427041746/http://blog.foundationdb.com/databases-at-14.4mhz),” *blog.foundationdb.com*, December 10, 2014. + + +------ + +| 上一章 | 目錄 | 下一章 | +| ---------------------- | ------------------------------- | ---------------------------------- | +| [第六章:分割槽](ch6.md) | [設計資料密集型應用](README.md) | [第八章:分散式系統的麻煩](ch8.md) | diff --git a/zh-tw/ch8.md b/zh-tw/ch8.md new file mode 100644 index 00000000..b691521f --- /dev/null +++ b/zh-tw/ch8.md @@ -0,0 +1,781 @@ +# 第八章:分散式系統的麻煩 + +![](img/ch8.png) + +> 邂逅相遇 +> +> 網路延遲 +> +> 存之為吾 +> +> 無食我數 +> +> ​ —— Kyle Kingsbury, Carly Rae Jepsen 《網路分割槽的危害》(2013年) + +--------- + +[TOC] + +​ 最近幾章中反覆出現的主題是,系統如何處理錯誤的事情。例如,我們討論了**副本故障切換**(“[處理節點中斷](#ch5.md#處理節點宕機)”),**複製延遲**(“[複製延遲問題](ch6.md#複製延遲問題)”)和事務控制(“[弱隔離級別](ch7.md#弱隔離級別)”)。當我們瞭解可能在實際系統中出現的各種邊緣情況時,我們會更好地處理它們。 + +​ 但是,儘管我們已經談了很多錯誤,但之前幾章仍然過於樂觀。現實更加黑暗。我們現在將悲觀主義最大化,假設任何可能出錯的東西**都會**出錯[^i]。(經驗豐富的系統運維會告訴你,這是一個合理的假設。如果你問得好,他們可能會一邊治療心理創傷一邊告訴你一些可怕的故事) + +[^i]: 除了一個例外:我們將假定故障是非拜占庭式的(參見“[拜占庭故障](#拜占庭故障)”)。 + +​ 使用分散式系統與在一臺計算機上編寫軟體有著根本的區別,主要的區別在於,有許多新穎和刺激的方法可以使事情出錯【1,2】。在這一章中,我們將瞭解實踐中出現的問題,理解我們能夠依賴,和不可以依賴的東西。 + +​ 最後,作為工程師,我們的任務是構建能夠完成工作的系統(即滿足使用者期望的保證),儘管一切都出錯了。 在[第9章](ch9.md)中,我們將看看一些可以在分散式系統中提供這種保證的演算法的例子。 但首先,在本章中,我們必須瞭解我們面臨的挑戰。 + +​ 本章對分散式系統中可能出現的問題進行徹底的悲觀和沮喪的總結。 我們將研究網路的問題(“[無法訪問的網路](#無法訪問的網路)”); 時鐘和時序問題(“[不可靠時鐘](#不可靠時鐘)”); 我們將討論他們可以避免的程度。 所有這些問題的後果都是困惑的,所以我們將探索如何思考一個分散式系統的狀態,以及如何推理髮生的事情(“[知識,真相和謊言](#知識,真相和謊言)”)。 + + + +## 故障與部分失效 + +​ 當你在一臺計算機上編寫一個程式時,它通常會以一種相當可預測的方式執行:無論是工作還是不工作。充滿錯誤的軟體可能會讓人覺得電腦有時候是“糟糕的一天”(這個問題通常是重新啟動的問題),但這主要是軟體寫得不好的結果。 + +​ 單個計算機上的軟體沒有根本性的不可靠原因:當硬體正常工作時,相同的操作總是產生相同的結果(這是確定性的)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),其後果通常是整個系統故障(例如,核心恐慌,“藍色畫面宕機”,啟動失敗)。裝有良好軟體的個人計算機通常要麼功能完好,要麼完全失效,而不是介於兩者之間。 + +​ 這是計算機設計中的一個慎重的選擇:如果發生內部錯誤,我們寧願電腦完全崩潰,而不是返回錯誤的結果,因為錯誤的結果很難處理。因為計算機隱藏了模糊不清的物理實現,並呈現出一個理想化的系統模型,並以數學一樣的完美的方式運作。 CPU指令總是做同樣的事情;如果您將一些資料寫入記憶體或磁碟,那麼這些資料將保持不變,並且不會被隨機破壞。從第一臺數字計算機開始,*始終正確地計算*這個設計目標貫穿始終【3】。 + +​ 當你編寫執行在多臺計算機上的軟體時,情況有本質上的區別。在分散式系統中,我們不再處於理想化的系統模型中,我們別無選擇,只能面對現實世界的混亂現實。而在現實世界中,各種各樣的事情都可能會出現問題【4】,如下面的軼事所述: + +> ​ 在我有限的經驗中,我已經和很多東西打過交道:單個**資料中心(DC)**中長期存在的網路分割槽,配電單元PDU故障,交換機故障,整個機架的意外重啟,整個資料中心主幹網路故障,整個資料中心的電源故障,以及一個低血糖的司機把他的福特皮卡撞在資料中心的HVAC(加熱,通風和空調)系統上。而且我甚至不是一個運維。 +> +> ——柯達黑爾 + +​ 在分散式系統中,儘管系統的其他部分工作正常,但系統的某些部分可能會以某種不可預知的方式被破壞。這被稱為**部分失效(partial failure)**。難點在於部分失效是**不確定性的(nonderterministic)**:如果你試圖做任何涉及多個節點和網路的事情,它有時可能會工作,有時會出現不可預知的失敗。正如我們將要看到的,你甚至不知道是否成功了,因為訊息透過網路傳播的時間也是不確定的! + +​ 這種不確定性和部分失效的可能性,使得分散式系統難以工作【5】。 + +### 雲端計算與超級計算機 + +關於如何構建大型計算系統有一系列的哲學: + +* 規模的一端是高效能運算(HPC)領域。具有數千個CPU的超級計算機通常用於計算密集型科學計算任務,如天氣預報或分子動力學(模擬原子和分子的運動)。 +* 另一個極端是**雲端計算(cloud computing)**,雲端計算並不是一個良好定義的概念【6】,但通常與多租戶資料中心,連線IP網路的商品計算機(通常是乙太網),彈性/按需資源分配以及計量計費等相關聯。 +* 傳統企業資料中心位於這兩個極端之間。 + +不同的哲學會導致不同的故障處理方式。在超級計算機中,作業通常會不時地會將計算的狀態存檔到持久儲存中。如果一個節點出現故障,通常的解決方案是簡單地停止整個叢集的工作負載。故障節點修復後,計算從上一個檢查點重新開始【7,8】。因此,超級計算機更像是一個單節點計算機而不是分散式系統:透過讓部分失敗升級為完全失敗來處理部分失敗——如果系統的任何部分發生故障,只是讓所有的東西都崩潰(就像單臺機器上的核心恐慌一樣)。 + +在本書中,我們將重點放在實現網際網路服務的系統上,這些系統通常與超級計算機看起來有很大不同 + +* 許多與網際網路有關的應用程式都是**線上(online)**的,因為它們需要能夠隨時以低延遲服務使用者。使服務不可用(例如,停止群集以進行修復)是不可接受的。相比之下,像天氣模擬這樣的離線(批處理)工作可以停止並重新啟動,影響相當小。 + +* 超級計算機通常由專用硬體構建而成,每個節點相當可靠,節點透過共享記憶體和**遠端直接記憶體訪問(RDMA)**進行通訊。另一方面,雲服務中的節點是由商品機器構建而成的,由於規模經濟,可以以較低的成本提供相同的效能,而且具有較高的故障率。 + +* 大型資料中心網路通常基於IP和乙太網,以閉合拓撲排列,以提供更高的二等分頻寬【9】。超級計算機通常使用專門的網路拓撲結構,例如多維網格和環面 【10】,這為具有已知通訊模式的HPC工作負載提供了更好的效能。 + +* 系統越大,其元件之一就越有可能壞掉。隨著時間的推移,壞掉的東西得到修復,新的東西又壞掉,但是在一個有成千上萬個節點的系統中,有理由認為總是有一些東西是壞掉的【7】。當錯誤處理策略由簡單的放棄組成時,一個大的系統最終會花費大量時間從錯誤中恢復,而不是做有用的工作【8】。 + +* 如果系統可以容忍發生故障的節點,並繼續保持整體工作狀態,那麼這對於操作和維護非常有用:例如,可以執行滾動升級(參閱[第4章](ch4.md)),一次重新啟動一個節點,而服務繼續服務使用者不中斷。在雲環境中,如果一臺虛擬機器執行不佳,可以殺死它並請求一臺新的虛擬機器(希望新的虛擬機器速度更快)。 + +* 在地理位置分散的部署中(保持資料在地理位置上接近使用者以減少訪問延遲),通訊很可能透過網際網路進行,與本地網路相比,通訊速度緩慢且不可靠。超級計算機通常假設它們的所有節點都靠近在一起。 + +如果要使分散式系統工作,就必須接受部分故障的可能性,並在軟體中建立容錯機制。換句話說,我們需要從不可靠的元件構建一個可靠的系統。 (正如“[可靠性](ch1.md#可靠性)”中所討論的那樣,沒有完美的可靠性,所以我們需要理解我們可以實際承諾的限制。) + +​ 即使在只有少數節點的小型系統中,考慮部分故障也是很重要的。在一個小系統中,很可能大部分元件在大部分時間都正常工作。然而,遲早會有一部分系統出現故障,軟體必須以某種方式處理。故障處理必須是軟體設計的一部分,並且作為軟體的運維,您需要知道在發生故障的情況下,軟體可能會表現出怎樣的行為。 + +​ 簡單地假設缺陷很罕見,只是希望始終保持最好的狀況是不明智的。考慮一系列可能的錯誤(甚至是不太可能的錯誤),並在測試環境中人為地建立這些情況來檢視會發生什麼是非常重要的。在分散式系統中,懷疑,悲觀和偏執狂是值得的。 + +> #### 從不可靠的元件構建可靠的系統 +> +> 您可能想知道這是否有意義——直觀地看來,系統只能像其最不可靠的元件(最薄弱的環節)一樣可靠。事實並非如此:事實上,從不太可靠的潛在基礎構建更可靠的系統是計算機領域的一個古老思想【11】。例如: +> +> * 糾錯碼允許數字資料在通訊通道上準確傳輸,偶爾會出現一些錯誤,例如由於無線網路上的無線電干擾【12】。 +> * **網際網路協議(Internet Protocol, IP)**不可靠:可能丟棄,延遲,複製或重排資料包。 傳輸控制協議(Transmission Control Protocol, TCP)在網際網路協議(IP)之上提供了更可靠的傳輸層:它確保丟失的資料包被重新傳輸,消除重複,並且資料包被重新組裝成它們被髮送的順序。 +> +> 雖然這個系統可以比它的底層部分更可靠,但它的可靠性總是有限的。例如,糾錯碼可以處理少量的單位元錯誤,但是如果你的訊號被幹擾所淹沒,那麼透過通道可以得到多少資料,是有根本性的限制的【13】。 TCP可以隱藏資料包的丟失,重複和重新排序,但是它不能神奇地消除網路中的延遲。 +> +> 雖然更可靠的高階系統並不完美,但它仍然有用,因為它處理了一些棘手的低階錯誤,所以其餘的錯誤通常更容易推理和處理。我們將在“[資料庫端到端的爭論](ch12.md#資料庫端到端的爭論)”中進一步探討這個問題。 + + + +## 不可靠的網路 + +​ 正如在第二部分的介紹中所討論的那樣,我們在本書中關注的分散式系統是無共享的系統,即透過網路連線的一堆機器。網路是這些機器可以通訊的唯一途徑——我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除了透過網路向伺服器發出請求)。 + +​ **無共享**並不是構建系統的唯一方式,但它已經成為構建網際網路服務的主要方式,其原因如下:相對便宜,因為它不需要特殊的硬體,可以利用商品化的雲端計算服務,透過跨多個地理分佈的資料中心進行冗餘可以實現高可靠性。 + +​ 網際網路和資料中心(通常是乙太網)中的大多數內部網路都是**非同步分組網路(asynchronous packet networks)**。在這種網路中,一個節點可以向另一個節點發送一個訊息(一個數據包),但是網路不能保證它什麼時候到達,或者是否到達。如果您傳送請求並期待響應,則很多事情可能會出錯(其中一些如[圖8-1](img/fig8-1.png)所示): + +1. 請求可能已經丟失(可能有人拔掉了網線)。 +2. 請求可能正在排隊,稍後將交付(也許網路或收件人超載)。 +3. 遠端節點可能已經失效(可能是崩潰或關機)。 +4. 遠端節點可能暫時停止了響應(可能會遇到長時間的垃圾回收暫停;參閱“[暫停程序](#暫停程序)”),但稍後會再次響應。 +5. 遠端節點可能已經處理了請求,但是網路上的響應已經丟失(可能是網路交換機配置錯誤)。 +6. 遠端節點可能已經處理了請求,但是響應已經被延遲,並且稍後將被傳遞(可能是網路或者你自己的機器過載)。 + +![](img/fig8-1.png) + +**圖8-1 如果傳送請求並沒有得到響應,則無法區分(a)請求是否丟失,(b)遠端節點是否關閉,或(c)響應是否丟失。** + +​ 傳送者甚至不能分辨資料包是否被髮送:唯一的選擇是讓接收者傳送響應訊息,這可能會丟失或延遲。這些問題在非同步網路中難以區分:您所擁有的唯一資訊是,您尚未收到響應。如果您向另一個節點發送請求並且沒有收到響應,則無法說明原因。 + +​ 處理這個問題的通常方法是**超時(Timeout)**:在一段時間之後放棄等待,並且認為響應不會到達。但是,當發生超時時,你仍然不知道遠端節點是否收到了請求(如果請求仍然在某個地方排隊,那麼即使發件人已經放棄了該請求,仍然可能會將其傳送給收件人)。 + +### 真實世界的網路故障 + +​ 我們幾十年來一直在建設計算機網路——有人可能希望現在我們已經找出了使網路變得可靠的方法。但是現在似乎還沒有成功。 + +​ 有一些系統的研究和大量的軼事證據表明,即使在像一家公司運營的資料中心那樣的受控環境中,網路問題也可能出乎意料地普遍。在一家中型資料中心進行的一項研究發現,每個月大約有12個網路故障,其中一半斷開一臺機器,一半斷開整個機架【15】。另一項研究測量了架頂式交換機,匯聚交換機和負載平衡器等元件的故障率【16】。它發現新增冗餘網路裝置不會像您所希望的那樣減少故障,因為它不能防範人為錯誤(例如,錯誤配置的交換機),這是造成中斷的主要原因。 + +​ 諸如EC2之類的公有云服務因頻繁的暫態網路故障而臭名昭著【14】,管理良好的私有資料中心網路可能是更穩定的環境。儘管如此,沒有人不受網路問題的困擾:例如,交換機軟體升級過程中的一個問題可能會引發網路拓撲重構,在此期間網路資料包可能會延遲超過一分鐘【17】。鯊魚可能咬住海底電纜並損壞它們 【18】。其他令人驚訝的故障包括網路介面有時會丟棄所有入站資料包,但是成功傳送出站資料包 【19】:僅僅因為網路連結在一個方向上工作,並不能保證它也在相反的方向工作。 + +> #### 網路分割槽 +> +> ​ 當網路的一部分由於網路故障而被切斷時,有時稱為**網路分割槽(network partition)**或**網路斷裂(netsplit)**。在本書中,我們通常會堅持使用更一般的術語**網路故障(network fault)**,以避免與[第6章](ch6.md)討論的儲存系統的分割槽(分片)相混淆。 + +​ 即使網路故障在你的環境中非常罕見,故障可能發生的事實,意味著你的軟體需要能夠處理它們。無論何時透過網路進行通訊,都可能會失敗,這是無法避免的。 + +​ 如果網路故障的錯誤處理沒有定義與測試,武斷地講,各種錯誤可能都會發生:例如,即使網路恢復【20】,叢集可能會發生**死鎖**,永久無法為請求提供服務,甚至可能會刪除所有的資料【21】。如果軟體被置於意料之外的情況下,它可能會做出出乎意料的事情。 + +​ 處理網路故障並不意味著容忍它們:如果你的網路通常是相當可靠的,一個有效的方法可能是當你的網路遇到問題時,簡單地向用戶顯示一條錯誤資訊。但是,您確實需要知道您的軟體如何應對網路問題,並確保系統能夠從中恢復。有意識地觸發網路問題並測試系統響應(這是Chaos Monkey背後的想法;參閱“[可靠性](ch1.md#可靠性)”)。 + +### 檢測故障 + +許多系統需要自動檢測故障節點。例如: + +* 負載平衡器需要停止向已死亡的節點轉發請求(即從**移出輪詢列表(out of rotation)**)。 +* 在單主複製功能的分散式資料庫中,如果主庫失效,則需要將從庫之一升級為新主庫(參閱“[處理節點宕機](ch5.md#處理節點宕機)”)。 + +不幸的是,網路的不確定性使得很難判斷一個節點是否工作。在某些特定的情況下,您可能會收到一些反饋資訊,明確告訴您某些事情沒有成功: + +* 如果你可以到達執行節點的機器,但沒有程序正在偵聽目標埠(例如,因為程序崩潰),作業系統將透過傳送FIN或RST來關閉並重用TCP連線。但是,如果節點在處理請求時發生崩潰,則無法知道遠端節點實際處理了多少資料【22】。 +* 如果節點程序崩潰(或被管理員殺死),但節點的作業系統仍在執行,則指令碼可以通知其他節點有關該崩潰的資訊,以便另一個節點可以快速接管,而無需等待超時到期。例如,HBase做這個【23】。 +* 如果您有權訪問資料中心網路交換機的管理介面,則可以查詢它們以檢測硬體級別的鏈路故障(例如,遠端機器是否關閉電源)。如果您透過網際網路連線,或者如果您處於共享資料中心而無法訪問交換機,或者由於網路問題而無法訪問管理介面,則排除此選項。 +* 如果路由器確認您嘗試連線的IP地址不可用,則可能會使用ICMP目標不可達資料包回覆您。但是,路由器不具備神奇的故障檢測能力——它受到與網路其他參與者相同的限制。 + +關於遠端節點關閉的快速反饋很有用,但是你不能指望它。即使TCP確認已經傳送了一個數據包,應用程式在處理之前可能已經崩潰。如果你想確保一個請求是成功的,你需要應用程式本身的積極響應【24】。 + +​ 相反,如果出了什麼問題,你可能會在堆疊的某個層次上得到一個錯誤響應,但總的來說,你必須假設你根本就沒有得到任何迴應。您可以重試幾次(TCP重試是透明的,但是您也可以在應用程式級別重試),等待超時過期,並且如果在超時時間內沒有收到響應,則最終宣告節點已經死亡。 + +### 超時與無窮的延遲 + +​ 如果超時是檢測故障的唯一可靠方法,那麼超時應該等待多久?不幸的是沒有簡單的答案。 + +​ 長時間的超時意味著長時間等待,直到一個節點被宣告死亡(在這段時間內,使用者可能不得不等待,或者看到錯誤資訊)。短暫的超時可以更快地檢測到故障,但是實際上它只是經歷了暫時的減速(例如,由於節點或網路上的負載峰值)而導致錯誤地宣佈節點失效的風險更高。 + +​ 過早地宣告一個節點已經死了是有問題的:如果這個節點實際上是活著的,並且正在執行一些動作(例如,傳送一封電子郵件),而另一個節點接管,那麼這個動作可能會最終執行兩次。我們將在“[知識,真相和謊言](#知識,真相和謊言)”以及[第9章](ch9.md)和[第11章](ch11.md)中更詳細地討論這個問題。 + +​ 當一個節點被宣告死亡時,它的職責需要轉移到其他節點,這會給其他節點和網路帶來額外的負擔。如果系統已經處於高負荷狀態,則過早宣告節點死亡會使問題更嚴重。尤其是可能發生,節點實際上並沒有死亡,而是由於過載導致響應緩慢;將其負載轉移到其他節點可能會導致**級聯失效(cascading failure)**(在極端情況下,所有節點都宣告對方死亡,並且所有節點都停止工作)。 + +​ 設想一個虛構的系統,其網路可以保證資料包的最大延遲——每個資料包要麼在一段時間內傳送,要麼丟失,但是傳遞永遠不會比$d$更長。此外,假設你可以保證一個非故障節點總是在一段時間內處理一個請求$r$。在這種情況下,您可以保證每個成功的請求在$2d + r$時間內都能收到響應,如果您在此時間內沒有收到響應,則知道網路或遠端節點不工作。如果這是成立的,$2d + r$ 會是一個合理的超時設定。 + +​ 不幸的是,我們所使用的大多數系統都沒有這些保證:非同步網路具有無限的延遲(即儘可能快地傳送資料包,但資料包到達可能需要的時間沒有上限),並且大多數伺服器實現並不能保證它們可以在一定的最大時間內處理請求(請參閱“[響應時間保證](#響應時間保證)”)。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時時間很短,往返時間只需要一個瞬時尖峰就可以使系統失衡。 + +#### 網路擁塞和排隊 + +​ 在駕駛汽車時,由於交通擁堵,道路交通網路的通行時間往往不盡相同。同樣,計算機網路上資料包延遲的可變性通常是由於排隊【25】: + +* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路連線)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。 +* 當資料包到達目標機器時,如果所有CPU核心當前都處於繁忙狀態,則來自網路的傳入請求將被作業系統排隊,直到應用程式準備好處理它為止。根據機器上的負載,這可能需要一段任意的時間。 +* 在虛擬化環境中,正在執行的作業系統經常暫停幾十毫秒,而另一個虛擬機器使用CPU核心。在這段時間內,虛擬機器不能從網路中消耗任何資料,所以傳入的資料被虛擬機器監視器 【26】排隊(緩衝),進一步增加了網路延遲的可變性。 +* TCP執行**流量控制(flow control)**(也稱為**擁塞避免(congestion avoidance)**或**背壓(backpressure)**),其中節點限制自己的傳送速率以避免網路鏈路或接收節點過載【27】。這意味著在資料甚至進入網路之前,在傳送者處需要進行額外的排隊。 + +![](img/fig8-2.png) + +**圖8-2 如果有多臺機器將網路流量傳送到同一目的地,則其交換機佇列可能會被填滿。在這裡,埠1,2和4都試圖傳送資料包到埠3** + +​ 而且,如果TCP在某個超時時間內沒有被確認(這是根據觀察的往返時間計算的),則認為資料包丟失,丟失的資料包將自動重新發送。儘管應用程式沒有看到資料包丟失和重新傳輸,但它看到了延遲(等待超時到期,然後等待重新傳輸的資料包得到確認)。 +      + +> ### TCP與UDP +> +> ​ 一些對延遲敏感的應用程式(如影片會議和IP語音(VoIP))使用UDP而不是TCP。這是在可靠性和和延遲可變性之間的折衷:由於UDP不執行流量控制並且不重傳丟失的分組,所以避免了可變網路延遲的一些原因(儘管它仍然易受切換佇列和排程延遲的影響)。 +> +> ​ 在延遲資料毫無價值的情況下,UDP是一個不錯的選擇。例如,在VoIP電話呼叫中,可能沒有足夠的時間重新發送丟失的資料包,並在揚聲器上播放資料。在這種情況下,重發資料包沒有意義——應用程式必須使用靜音填充丟失資料包的時隙(導致聲音短暫中斷),然後在資料流中繼續。重試發生在人類層。 (“你能再說一遍嗎?聲音剛剛斷了一會兒。“) + +​ 所有這些因素都會造成網路延遲的變化。當系統接近其最大容量時,排隊延遲的範圍特別廣泛: + +​ 擁有足夠備用容量的系統可以輕鬆排空佇列,而在高利用率的系統中,很快就能積累很長的佇列。 + +​ 在公共雲和多租戶資料中心中,資源被許多客戶共享:網路連結和交換機,甚至每個機器的網絡卡和CPU(在虛擬機器上執行時)。批處理工作負載(如MapReduce)(參閱[第10章](ch10.md))可能很容易使網路連結飽和。由於無法控制或瞭解其他客戶對共享資源的使用情況,如果附近的某個人(嘈雜的鄰居)正在使用大量資源,則網路延遲可能會發生劇烈抖動【28,29】。 + +​ 在這種環境下,您只能透過實驗方式選擇超時:測量延長的網路往返時間和多臺機器的分佈,以確定延遲的預期可變性。然後,考慮到應用程式的特性,可以確定**故障檢測延遲**與**過早超時風險**之間的適當折衷。 + +​ 更好的一種做法是,系統不是使用配置的常量超時時間,而是連續測量響應時間及其變化(抖動),並根據觀察到的響應時間分佈自動調整超時時間。這可以透過Phi Accrual故障檢測器【30】來完成,該檢測器在例如Akka和Cassandra 【31】中使用。 TCP超時重傳機制也同樣起作用【27】。 + +### 同步網路 vs 非同步網路 + +​ 如果我們可以依靠網路來傳遞一些**最大延遲固定**的資料包,而不是丟棄資料包,那麼分散式系統就會簡單得多。為什麼我們不能在硬體層面上解決這個問題,使網路可靠,使軟體不必擔心呢? + +​ 為了回答這個問題,將資料中心網路與非常可靠的傳統固定電話網路(非蜂窩,非VoIP)進行比較是很有趣的:延遲音訊幀和掉話是非常罕見的。一個電話需要一個很低的端到端延遲,以及足夠的頻寬來傳輸你聲音的音訊取樣資料。在計算機網路中有類似的可靠性和可預測性不是很好嗎? + +​ 當您透過電話網路撥打電話時,它會建立一個電路:在兩個呼叫者之間的整個路線上為呼叫分配一個固定的,有保證的頻寬量。這個電路會保持至通話結束【32】。例如,ISDN網路以每秒4000幀的固定速率執行。呼叫建立時,每個幀內(每個方向)分配16位空間。因此,在通話期間,每一方都保證能夠每250微秒傳送一個精確的16位音訊資料【33,34】。 + +​ 這種網路是同步的:即使資料經過多個路由器,也不會受到排隊的影響,因為呼叫的16位空間已經在網路的下一跳中保留了下來。而且由於沒有排隊,網路的最大端到端延遲是固定的。我們稱之為**有限延遲(bounded delay)**。 + +#### 我們不能簡單地使網路延遲可預測嗎? + +​ 請注意,電話網路中的電路與TCP連線有很大不同:電路是固定數量的預留頻寬,在電路建立時沒有其他人可以使用,而TCP連線的資料包**機會性地**使用任何可用的網路頻寬。您可以給TCP一個可變大小的資料塊(例如,一個電子郵件或一個網頁),它會盡可能在最短的時間內傳輸它。 TCP連線空閒時,不使用任何頻寬[^ii]。 + +[^ii]: 除了偶爾的keepalive資料包,如果TCP keepalive被啟用。 + +​ 如果資料中心網路和網際網路是電路交換網路,那麼在建立電路時就可以建立一個保證的最大往返時間。但是,它們並不是:乙太網和IP是**分組交換協議**,不得不忍受排隊的折磨,及其導致的網路無限延遲。這些協議沒有電路的概念。 + +​ 為什麼資料中心網路和網際網路使用分組交換?答案是,它們針對**突發流量(bursty traffic)**進行了最佳化。一個電路適用於音訊或影片通話,在通話期間需要每秒傳送相當數量的位元。另一方面,請求網頁,傳送電子郵件或傳輸檔案沒有任何特定的頻寬要求——我們只是希望它儘快完成。 + +​ 如果你想透過電路傳輸檔案,你將不得不猜測一個頻寬分配。如果您猜的太低,傳輸速度會不必要的太慢,導致網路容量閒置。如果你猜的太高,電路就無法建立(因為如果無法保證其頻寬分配,網路不能建立電路)。因此,使用用於突發資料傳輸的電路浪費網路容量,並且使傳輸不必要地緩慢。相比之下,TCP動態調整資料傳輸速率以適應可用的網路容量。 + +​ 已經有一些嘗試去建立支援電路交換和分組交換的混合網路,比如ATM[^iii] InfiniBand有一些相似之處【35】:它在鏈路層實現了端到端的流量控制,從而減少了在網路中排隊,儘管它仍然可能因鏈路擁塞而受到延遲【36】。透過仔細使用**服務質量(quality of service,)**(QoS,資料包的優先順序和排程)和**准入控制(admission control)**(限速傳送器),可以模擬分組網路上的電路交換,或提供統計上的**有限延遲**【25,32】。 + +[^iii]: **非同步傳輸模式(Asynchronous TransferMode, ATM)**在20世紀80年代是乙太網的競爭對手【32】,但在電話網核心交換機之外並沒有得到太多的採用。與自動櫃員機(也稱為自動取款機)無關,儘管共用一個縮寫詞。或許,在一些平行的世界裡,網際網路是基於像ATM這樣的東西,因為網際網路影片通話可能比我們的更可靠,因為它們不會遭受丟包和延遲的包裹。 + +​ 但是,目前在多租戶資料中心和公共雲或透過網際網路[^iv]進行通訊時,此類服務質量尚未啟用。當前部署的技術不允許我們對網路的延遲或可靠性作出任何保證:我們必須假設網路擁塞,排隊和無限的延遲總是會發生。因此,超時時間沒有“正確”的值——它需要透過實驗來確定。 + +[^iv]: 網際網路服務提供商之間的對等協議和透過**BGP閘道器協議(BGP)**建立路由之間的對等協議,與電路交換本身相比,與電路交換更接近。在這個級別上,可以購買專用頻寬。但是,網際網路路由在網路級別執行,而不是主機之間的單獨連線,而且執行時間要長得多。 + +> ### 延遲和資源利用 +> +> ​ 更一般地說,可以將**延遲變化**視為**動態資源分割槽**的結果。 +> +> ​ 假設兩臺電話交換機之間有一條線路,可以同時進行10,000個呼叫。透過此線路切換的每個電路都佔用其中一個呼叫插槽。因此,您可以將線路視為可由多達10,000個併發使用者共享的資源。資源以靜態方式分配:即使您現在是電話上唯一的電話,並且所有其他9,999個插槽都未使用,您的電路仍將分配與導線充分利用時相同的固定數量的頻寬。 +> +> ​ 相比之下,網際網路動態分享網路頻寬。傳送者互相推擠並互相推擠以儘可能快地透過網路獲得它們的分組,並且網路交換機決定從一個時刻到另一個時刻傳送哪個分組(即,頻寬分配)。這種方法有排隊的缺點,但其優點是它最大限度地利用了電線。電線固定成本,所以如果你更好地利用它,你透過電線傳送的每個位元組都會更便宜。 +> +> ​ CPU也會出現類似的情況:如果您在多個執行緒間動態共享每個CPU核心,則有一個執行緒有時必須等待作業系統的執行佇列,而另一個執行緒正在執行,這樣執行緒可以暫停不同的時間長度。但是,與為每個執行緒分配靜態數量的CPU週期相比,這會更好地利用硬體(參閱“[響應時間保證](#響應時間保證)”)。更好的硬體利用率也是使用虛擬機器的重要動機。 +> +> ​ 如果資源是靜態分割槽的(例如,專用硬體和專用頻寬分配),則在某些環境中可以實現**延遲保證**。但是,這是以降低利用率為代價的——換句話說,它是更昂貴的。另一方面,動態資源分配的多租戶提供了更好的利用率,所以它更便宜,但它具有可變延遲的缺點。 +> +> ​ 網路中的可變延遲不是一種自然規律,而只是成本/收益權衡的結果。 + + + +## 不可靠的時鐘 + +時鐘和時間很重要。應用程式以各種方式依賴於時鐘來回答以下問題: + +1. 這個請求是否超時了? +2. 這項服務的第99百分位響應時間是多少? +3. 在過去五分鐘內,該服務平均每秒處理多少個查詢? +4. 使用者在我們的網站上花了多長時間? +5. 這篇文章在何時釋出? +6. 在什麼時間傳送提醒郵件? +7. 這個快取條目何時到期? +8. 日誌檔案中此錯誤訊息的時間戳是什麼? + +[例1-4](ch1.md)測量[持續時間]()(例如,傳送請求與正在接收的響應之間的時間間隔),而[例5-8](ch5.md)描述**時間點(point in time)**(在特定日期,特定時間發生的事件)。 + +​ 在分散式系統中,時間是一件棘手的事情,因為通訊不是即時的:訊息透過網路從一臺機器傳送到另一臺機器需要時間。收到訊息的時間總是晚於傳送的時間,但是由於網路中的可變延遲,我們不知道多少時間。這個事實有時很難確定在涉及多臺機器時發生事情的順序。 + +​ 而且,網路上的每臺機器都有自己的時鐘,這是一個實際的硬體裝置:通常是石英晶體振盪器。這些裝置不是完全準確的,所以每臺機器都有自己的時間概念,可能比其他機器稍快或更慢。可以在一定程度上同步時鐘:最常用的機制是**網路時間協議(NTP)**,它允許根據一組伺服器報告的時間來調整計算機時鐘【37】。伺服器則從更精確的時間源(如GPS接收機)獲取時間。 + +### 單調鍾與時鐘 + +​ 現代計算機至少有兩種不同的時鐘:時鐘和單調鍾。儘管它們都衡量時間,但區分這兩者很重要,因為它們有不同的目的。 + +#### 時鐘 + +​ 時鐘是您直觀地瞭解時鐘的依據:它根據某個日曆(也稱為**掛鐘時間(wall-clock time)**)返回當前日期和時間。例如,Linux[^v]上的`clock_gettime(CLOCK_REALTIME)`和Java中的`System.currentTimeMillis()`返回自epoch(1970年1月1日 午夜 UTC,格里高利曆)以來的秒數(或毫秒),根據公曆日曆,不包括閏秒。有些系統使用其他日期作為參考點。 + +[^v]: 雖然時鐘被稱為實時時鐘,但它與實時作業系統無關,如第298頁上的“[響應時間保證](#響應時間保證)”中所述。 + +​ 時鐘通常與NTP同步,這意味著來自一臺機器的時間戳(理想情況下)意味著與另一臺機器上的時間戳相同。但是如下節所述,時鐘也具有各種各樣的奇特之處。特別是,如果本地時鐘在NTP伺服器之前太遠,則它可能會被強制重置,看上去好像跳回了先前的時間點。這些跳躍以及他們經常忽略閏秒的事實,使時鐘不能用於測量經過時間【38】。 + +​ 時鐘還具有相當粗略的解析度,例如,在較早的Windows系統上以10毫秒為單位前進【39】。在最近的系統中這已經不是一個問題了。 + +#### 單調鍾 + +​ 單調鍾適用於測量持續時間(時間間隔),例如超時或服務的響應時間:Linux上的`clock_gettime(CLOCK_MONOTONIC)`,和Java中的`System.nanoTime()`都是單調時鐘。這個名字來源於他們保證總是前進的事實(而時鐘可以及時跳回)。 + +​ 你可以在某個時間點檢查單調鐘的值,做一些事情,且稍後再次檢查它。這兩個值之間的差異告訴你兩次檢查之間經過了多長時間。但單調鐘的絕對值是毫無意義的:它可能是計算機啟動以來的納秒數,或類似的任意值。特別是比較來自兩臺不同計算機的單調鐘的值是沒有意義的,因為它們並不是一回事。 + +​ 在具有多個CPU插槽的伺服器上,每個CPU可能有一個單獨的計時器,但不一定與其他CPU同步。作業系統會補償所有的差異,並嘗試嚮應用執行緒表現出單調鐘的樣子,即使這些執行緒被排程到不同的CPU上。當然,明智的做法是不要太把這種單調性保證當回事【40】。 + +​ 如果NTP協議檢測到計算機的本地石英鐘比NTP伺服器要更快或更慢,則可以調整單調鍾向前走的頻率(這稱為**偏移(skewing)**時鐘)。預設情況下,NTP允許時鐘速率增加或減慢最高至0.05%,但NTP不能使單調時鐘向前或向後跳轉。單調時鐘的解析度通常相當好:在大多數系統中,它們能在幾微秒或更短的時間內測量時間間隔。 + +​ 在分散式系統中,使用單調鍾測量**經過時間(elapsed time)**(比如超時)通常很好,因為它不假定不同節點的時鐘之間存在任何同步,並且對測量的輕微不準確性不敏感。 + +### 時鐘同步與準確性 + +​ 單調鐘不需要同步,但是時鐘需要根據NTP伺服器或其他外部時間源來設定才能有用。不幸的是,我們獲取時鐘的方法並不像你所希望的那樣可靠或準確——硬體時鐘和NTP可能會變幻莫測。舉幾個例子: + +​ 計算機中的石英鐘不夠精確:它會**漂移(drifts)**(執行速度快於或慢於預期)。時鐘漂移取決於機器的溫度。 Google假設其伺服器時鐘漂移為200 ppm(百萬分之一)【41】,相當於每30秒與伺服器重新同步一次的時鐘漂移為6毫秒,或者每天重新同步的時鐘漂移為17秒。即使一切工作正常,此漂移也會限制可以達到的最佳準確度。 + +* 如果計算機的時鐘與NTP伺服器的時鐘差別太大,可能會拒絕同步,或者本地時鐘將被強制重置【37】。任何觀察重置前後時間的應用程式都可能會看到時間倒退或突然跳躍。 +* 如果某個節點被NTP伺服器意外阻塞,可能會在一段時間內忽略錯誤配置。有證據表明,這在實踐中確實發生過。 +* NTP同步只能和網路延遲一樣好,所以當您在擁有可變資料包延遲的擁塞網路上時,NTP同步的準確性會受到限制。一個實驗表明,當透過網際網路同步時,35毫秒的最小誤差是可以實現的,儘管偶爾的網路延遲峰值會導致大約一秒的誤差。根據配置,較大的網路延遲會導致NTP客戶端完全放棄。 +* 一些NTP伺服器錯誤或配置錯誤,報告時間已經過去了幾個小時【43,44】。 NTP客戶端非常強大,因為他們查詢多個伺服器並忽略異常值。儘管如此,在網際網路上陌生人告訴你的時候,你的系統的正確性還是值得擔憂的。 +* 閏秒導致59分鐘或61秒長的分鐘,這混淆了未設計閏秒的系統中的時序假設【45】。閏秒已經使許多大型系統崩潰【38,46】的事實說明了,關於時鐘的假設是多麼容易偷偷溜入系統中。處理閏秒的最佳方法可能是透過在一天中逐漸執行閏秒調整(這被稱為**拖尾(smearing)**)【47,48】,使NTP伺服器“撒謊”,雖然實際的NTP伺服器表現各異【49】。 +* 在虛擬機器中,硬體時鐘被虛擬化,這對於需要精確計時的應用程式提出了額外的挑戰【50】。當一個CPU核心在虛擬機器之間共享時,每個虛擬機器都會暫停幾十毫秒,與此同時另一個虛擬機器正在執行。從應用程式的角度來看,這種停頓表現為時鐘突然向前跳躍【26】。 +* 如果您在未完全控制的裝置上執行軟體(例如,移動裝置或嵌入式裝置),則可能完全不能信任該裝置的硬體時鐘。一些使用者故意將其硬體時鐘設定為不正確的日期和時間,例如,為了規避遊戲中的時間限制,時鐘可能會被設定到很遠的過去或將來。 + +如果你足夠在乎這件事並投入大量資源,就可以達到非常好的時鐘精度。例如,針對金融機構的歐洲法規草案MiFID II要求所有高頻率交易基金在UTC時間100微秒內同步時鐘,以便除錯“閃崩”等市場異常現象,並幫助檢測市場操縱 【51】。 + +​ 透過GPS接收機,精確時間協議(PTP)【52】以及仔細的部署和監測可以實現這種精確度。然而,這需要很多努力和專業知識,而且有很多東西都會導致時鐘同步錯誤。如果你的NTP守護程序配置錯誤,或者防火牆阻止了NTP通訊,由漂移引起的時鐘誤差可能很快就會變大。 + +### 依賴同步時鐘 + +​ 時鐘的問題在於,雖然它們看起來簡單易用,但卻具有令人驚訝的缺陷:一天可能不會有精確的86,400秒,**時鐘**可能會前後跳躍,而一個節點上的時間可能與另一個節點上的時間完全不同。 + +​ 本章早些時候,我們討論了網路丟包和任意延遲包的問題。儘管網路在大多數情況下表現良好,但軟體的設計必須假定網路偶爾會出現故障,而軟體必須正常處理這些故障。時鐘也是如此:儘管大多數時間都工作得很好,但需要準備健壯的軟體來處理不正確的時鐘。 + +​ 有一部分問題是,不正確的時鐘很容易被視而不見。如果一臺機器的CPU出現故障或者網路配置錯誤,很可能根本無法工作,所以很快就會被注意和修復。另一方面,如果它的石英時鐘有缺陷,或者它的NTP客戶端配置錯誤,大部分事情似乎仍然可以正常工作,即使它的時鐘逐漸偏離現實。如果某個軟體依賴於精確同步的時鐘,那麼結果更可能是悄無聲息的,僅有微量的資料丟失,而不是一次驚天動地的崩潰【53,54】。 + +​ 因此,如果你使用需要同步時鐘的軟體,必須仔細監控所有機器之間的時鐘偏移。時鐘偏離其他時鐘太遠的節點應當被宣告死亡,並從叢集中移除。這樣的監控可以確保你在損失發生之前注意到破損的時鐘。 + +#### 有序事件的時間戳 + +​ 讓我們考慮一個特別的情況,一件很有誘惑但也很危險的事情:依賴時鐘,在多個節點上對事件進行排序。 例如,如果兩個客戶端寫入分散式資料庫,誰先到達? 哪一個更近? + +​ [圖8-3](img/fig8-3.png)顯示了在具有多領導者複製的資料庫中對時鐘的危險使用(該例子類似於[圖5-9](img/fig5-9.png))。 客戶端A在節點1上寫入`x = 1`;寫入被複制到節點3;客戶端B在節點3上增加x(我們現在有`x = 2`);最後這兩個寫入都被複制到節點2。 + +![](img/fig8-3.png) + +**圖8-3 客戶端B的寫入比客戶端A的寫入要晚,但是B的寫入具有較早的時間戳。** + +​ 在[圖8-3]()中,當一個寫入被複制到其他節點時,它會根據發生寫入的節點上的時鐘時鐘標記一個時間戳。在這個例子中,時鐘同步是非常好的:節點1和節點3之間的偏差小於3ms,這可能比你在實踐中預期的更好。 + +​ 儘管如此,[圖8-3](img/fig8-3.png)中的時間戳卻無法正確排列事件:寫入`x = 1`的時間戳為42.004秒,但寫入`x = 2`的時間戳為42.003秒,即使`x = 2`在稍後出現。當節點2接收到這兩個事件時,會錯誤地推斷出`x = 1`是最近的值,而丟棄寫入`x = 2`。效果上表現為,客戶端B的增量操作會丟失。 + +​ 這種衝突解決策略被稱為**最後寫入勝利(LWW)**,它在多領導者複製和無領導者資料庫(如Cassandra 【53】和Riak 【54】)中被廣泛使用(參見“[最後寫入勝利(丟棄併發寫入)](#最後寫入勝利(丟棄併發寫入))”一節)。有些實現會在客戶端而不是伺服器上生成時間戳,但這並不能改變LWW的基本問題: + +* 資料庫寫入可能會神祕地消失:具有滯後時鐘的節點無法覆蓋之前具有快速時鐘的節點寫入的值,直到節點之間的時鐘偏差消逝【54,55】。此方案可能導致一定數量的資料被悄悄丟棄,而未嚮應用報告任何錯誤。 +* LWW無法區分**高頻順序寫入**(在[圖8-3](img/fig8-3.png)中,客戶端B的增量操作**一定**發生在客戶端A的寫入之後)和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止因果關係的衝突(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。 +* 兩個節點很可能獨立地生成具有相同時間戳的寫入,特別是在時鐘僅具有毫秒解析度的情況下。為了解決這樣的衝突,還需要一個額外的**決勝值(tiebreaker)**(可以簡單地是一個大隨機數),但這種方法也可能會導致違背因果關係【53】。 + +因此,儘管透過保留最“最近”的值並放棄其他值來解決衝突是很誘惑人的,但是要注意,“最近”的定義取決於本地的**時鐘**,這很可能是不正確的。即使用頻繁同步的NTP時鐘,一個數據包也可能在時間戳100毫秒(根據傳送者的時鐘)時傳送,並在時間戳99毫秒(根據接收者的時鐘)處到達——看起來好像資料包在傳送之前已經到達,這是不可能的。 + +​ NTP同步是否能足夠準確,以至於這種不正確的排序不會發生?也許不能,因為NTP的同步精度本身受到網路往返時間的限制,除了石英鐘漂移這類誤差源之外。為了進行正確的排序,你需要一個比測量物件(即網路延遲)要精確得多的時鐘。 + +​ 所謂的**邏輯時鐘(logic clock)**【56,57】是基於遞增計數器而不是振盪石英晶體,對於排序事件來說是更安全的選擇(請參見“[檢測併發寫入](ch5.md#檢測併發寫入)”)。邏輯時鐘不測量一天中的時間或經過的秒數,而僅測量事件的相對順序(無論一個事件發生在另一個事件之前還是之後)。相反,用來測量實際經過時間的**時鐘**和**單調鍾**也被稱為**物理時鐘(physical clock)**。我們將在“[順序保證](#順序保證)”中來看順序問題。 + +#### 時鐘讀數存在置信區間 + +​ 您可能能夠以微秒或甚至納秒的精度讀取機器的時鐘。但即使可以得到如此細緻的測量結果,這並不意味著這個值對於這樣的精度實際上是準確的。實際上,大概率是不準確的——如前所述,即使您每分鐘與本地網路上的NTP伺服器進行同步,幾毫秒的時間漂移也很容易在不精確的石英時鐘上發生。使用公共網際網路上的NTP伺服器,最好的準確度可能達到幾十毫秒,而且當網路擁塞時,誤差可能會超過100毫秒【57】。 + +​ 因此,將時鐘讀數視為一個時間點是沒有意義的——它更像是一段時間範圍:例如,一個系統可能以95%的置信度認為當前時間處於本分鐘內的第10.3秒和10.5秒之間,它可能沒法比這更精確了【58】。如果我們只知道±100毫秒的時間,那麼時間戳中的微秒數字部分基本上是沒有意義的。 + +​ 不確定性界限可以根據你的時間源來計算。如果您的GPS接收器或原子(銫)時鐘直接連線到您的計算機上,預期的錯誤範圍由製造商告知。如果從伺服器獲得時間,則不確定性取決於自上次與伺服器同步以來的石英鐘漂移的期望值,加上NTP伺服器的不確定性,再加上到伺服器的網路往返時間(只是獲取粗略近似值,並假設伺服器是可信的)。 + +​ 不幸的是,大多數系統不公開這種不確定性:例如,當呼叫`clock_gettime()`時,返回值不會告訴你時間戳的預期錯誤,所以你不知道其置信區間是5毫秒還是5年。 + +​ 一個有趣的例外是Spanner中的Google TrueTime API 【41】,它明確地報告了本地時鐘的置信區間。當你詢問當前時間時,你會得到兩個值:[最早,最晚],這是最早可能的時間戳和最晚可能的時間戳。在不確定性估計的基礎上,時鐘知道當前的實際時間落在該區間內。區間的寬度取決於自從本地石英鐘最後與更精確的時鐘源同步以來已經過了多長時間。 + +#### 全域性快照的同步時鐘 + +​ 在“[快照隔離和可重複讀取](ch7.md#快照隔離和可重複讀取)”中,我們討論了快照隔離,這是資料庫中非常有用的功能,需要支援小型快速讀寫事務和大型長時間執行的只讀事務,用於備份或分析)。它允許只讀事務看到特定時間點的處於一致狀態的資料庫,且不會鎖定和干擾讀寫事務。 + +​ 快照隔離最常見的實現需要單調遞增的事務ID。如果寫入比快照晚(即,寫入具有比快照更大的事務ID),則該寫入對於快照事務是不可見的。在單節點資料庫上,一個簡單的計數器就足以生成事務ID。 + +​ 但是當資料庫分佈在許多機器上,也許可能在多個數據中心中時,由於需要協調,(跨所有分割槽)全域性單調遞增的事務ID會很難生成。事務ID必須反映因果關係:如果事務B讀取由事務A寫入的值,則B必須具有比A更大的事務ID,否則快照就無法保持一致。在有大量的小規模、高頻率的事務情景下,在分散式系統中建立事務ID成為一個難以防守的瓶頸[^vi]。 + +[^vi]: 存在分散式序列號生成器,例如Twitter的雪花(Snowflake),其以可擴充套件的方式(例如,透過將ID空間的塊分配給不同節點)近似單調地增加唯一ID。但是,它們通常無法保證與因果關係一致的排序,因為分配的ID塊的時間範圍比資料庫讀取和寫入的時間範圍要長。另請參閱“[順序保證](#順序保證)”。 + +​ 我們可以使用同步時鐘的時間戳作為事務ID嗎?如果我們能夠獲得足夠好的同步性,那麼這種方法將具有很合適的屬性:更晚的事務會有更大的時間戳。當然,問題在於時鐘精度的不確定性。 + +​ Spanner以這種方式實現跨資料中心的快照隔離【59,60】。它使用TrueTime API報告的時鐘置信區間,並基於以下觀察結果:如果您有兩個置信區間,每個置信區間包含最早和最晚可能的時間戳( $A = [A_{earliest}, A_{latest}]$, $B=[B_{earliest}, B_{latest}]$),這兩個區間不重疊(即:$A_{earliest} < A_{latest} < B_{earliest} < B_{latest}$)的話,那麼B肯定發生在A之後——這是毫無疑問的。只有當區間重疊時,我們才不確定A和B發生的順序。 + +​ 為了確保事務時間戳反映因果關係,在提交讀寫事務之前,Spanner在提交讀寫事務時,會故意等待置信區間長度的時間。透過這樣,它可以確保任何可能讀取資料的事務處於足夠晚的時間,因此它們的置信區間不會重疊。為了保持儘可能短的等待時間,Spanner需要保持儘可能小的時鐘不確定性,為此,Google在每個資料中心都部署了一個GPS接收器或原子鐘,這允許時鐘在大約7毫秒內同步【41】。 + +​ 對分散式事務語義使用時鐘同步是一個活躍的研究領域【57,61,62】。這些想法很有趣,但是它們還沒有在谷歌之外的主流資料庫中實現。 + +### 暫停程序 + +​ 讓我們考慮在分散式系統中使用危險時鐘的另一個例子。假設你有一個數據庫,每個分割槽只有一個領導者。只有領導被允許接受寫入。一個節點如何知道它仍然是領導者(它並沒有被別人宣告為死亡),並且它可以安全地接受寫入? + +​ 一種選擇是領導者從其他節點獲得一個**租約(lease)**,類似一個帶超時的鎖【63】。任一時刻只有一個節點可以持有租約——因此,當一個節點獲得一個租約時,它知道它在某段時間內自己是領導者,直到租約到期。為了保持領導地位,節點必須週期性地在租約過期前續期。 + +​ 如果節點發生故障,就會停止續期,所以當租約過期時,另一個節點可以接管。 + +​ 可以想象,請求處理迴圈看起來像這樣: + +```java +while(true){ + request=getIncomingRequest(); + // 確保租約還剩下至少10秒 + if (lease.expiryTimeMillis-System.currentTimeMillis()< 10000){ + lease = lease.renew(); + } + + if(lease.isValid()){ + process(request); + }} +} +``` + +​  這個程式碼有什麼問題?首先,它依賴於同步時鐘:租約到期時間由另一臺機器設定(例如,當前時間加上30秒,計算到期時間),並將其與本地系統時鐘進行比較。如果時鐘超過幾秒不同步,這段程式碼將開始做奇怪的事情。 + +​ 其次,即使我們將協議更改為僅使用本地單調時鐘,也存在另一個問題:程式碼假定在執行剩餘時間檢查`System.currentTimeMillis()`和實際執行請求`process(request)`中間的時間間隔非常短。通常情況下,這段程式碼執行得非常快,所以10秒的緩衝區已經足夠確保**租約**在請求處理到一半時不會過期。 + +​ 但是,如果程式執行中出現了意外的停頓呢?例如,想象一下,執行緒在`lease.isValid()`行周圍停止15秒,然後才終止。在這種情況下,在請求被處理的時候,租約可能已經過期,而另一個節點已經接管了領導。然而,沒有什麼可以告訴這個執行緒已經暫停了這麼長時間了,所以這段程式碼不會注意到租約已經到期了,直到迴圈的下一個迭代 ——到那個時候它可能已經做了一些不安全的處理請求。 + +​ 假設一個執行緒可能會暫停很長時間,這是瘋了嗎?不幸的是,這種情況發生的原因有很多種: + +* 許多程式語言執行時(如Java虛擬機器)都有一個垃圾收集器(GC),偶爾需要停止所有正在執行的執行緒。這些“**停止所有處理(stop-the-world)**”GC暫停有時會持續幾分鐘【64】!甚至像HotSpot JVM的CMS這樣的所謂的“並行”垃圾收集器也不能完全與應用程式程式碼並行執行,它需要不時地停止所有處理【65】。儘管通常可以透過改變分配模式或調整GC設定來減少暫停【66】,但是如果我們想要提供健壯的保證,就必須假設最壞的情況發生。 +* 在虛擬化環境中,可以**掛起(suspend)**虛擬機器(暫停執行所有程序並將記憶體內容儲存到磁碟)並恢復(恢復記憶體內容並繼續執行)。這個暫停可以在程序執行的任何時候發生,並且可以持續任意長的時間。這個功能有時用於虛擬機器從一個主機到另一個主機的實時遷移,而不需要重新啟動,在這種情況下,暫停的長度取決於程序寫入記憶體的速率【67】。 +* 在終端使用者的裝置(如膝上型電腦)上,執行也可能被暫停並隨意恢復,例如當用戶關閉膝上型電腦的蓋子時。 +* 當作業系統上下文切換到另一個執行緒時,或者當管理程式切換到另一個虛擬機器時(在虛擬機器中執行時),當前正在執行的執行緒可以在程式碼中的任意點處暫停。在虛擬機器的情況下,在其他虛擬機器中花費的CPU時間被稱為**竊取時間(steal time)**。如果機器處於沉重的負載下(即,如果等待執行的執行緒很長),暫停的執行緒再次執行可能需要一些時間。 +* 如果應用程式執行同步磁碟訪問,則執行緒可能暫停,等待緩慢的磁碟I/O操作完成【68】。在許多語言中,即使程式碼沒有包含檔案訪問,磁碟訪問也可能出乎意料地發生——例如,Java類載入器在第一次使用時惰性載入類檔案,這可能在程式執行過程中隨時發生。 I/O暫停和GC暫停甚至可能合謀組合它們的延遲【69】。如果磁碟實際上是一個網路檔案系統或網路塊裝置(如亞馬遜的EBS),I/O延遲進一步受到網路延遲變化的影響【29】。 +* 如果作業系統配置為允許交換到磁碟(分頁),則簡單的記憶體訪問可能導致**頁面錯誤(page fault)**,要求將磁碟中的頁面裝入記憶體。當這個緩慢的I/O操作發生時,執行緒暫停。如果記憶體壓力很高,則可能需要將不同的頁面換出到磁碟。在極端情況下,作業系統可能花費大部分時間將頁面交換到記憶體中,而實際上完成的工作很少(這被稱為**抖動(thrashing)**)。為了避免這個問題,通常在伺服器機器上禁用頁面排程(如果你寧願幹掉一個程序來釋放記憶體,也不願意冒抖動風險)。 +* 可以透過傳送SIGSTOP訊號來暫停Unix程序,例如透過在shell中按下Ctrl-Z。 這個訊號立即阻止程序繼續執行更多的CPU週期,直到SIGCONT恢復為止,此時它將繼續執行。 即使你的環境通常不使用SIGSTOP,也可能由運維工程師意外發送。 + +所有這些事件都可以隨時**搶佔(preempt)**正在執行的執行緒,並在稍後的時間恢復執行,而執行緒甚至不會注意到這一點。這個問題類似於在單個機器上使多執行緒程式碼執行緒安全:你不能對時機做任何假設,因為隨時可能發生上下文切換,或者出現並行執行。 + +​ 當在一臺機器上編寫多執行緒程式碼時,我們有相當好的工具來實現執行緒安全:互斥量,訊號量,原子計數器,無鎖資料結構,阻塞佇列等等。不幸的是,這些工具並不能直接轉化為分散式系統操作,因為分散式系統沒有共享記憶體,只有透過不可靠網路傳送的訊息。 + +​ 分散式系統中的節點,必須假定其執行可能在任意時刻暫停相當長的時間,即使是在一個函式的中間。在暫停期間,世界的其它部分在繼續運轉,甚至可能因為該節點沒有響應,而宣告暫停節點的死亡。最終暫停的節點可能會繼續執行,在再次檢查自己的時鐘之前,甚至可能不會意識到自己進入了睡眠。 + +#### 響應時間保證 + +​ 在許多程式語言和作業系統中,執行緒和程序可能暫停一段無限制的時間,正如討論的那樣。如果你足夠努力,導致暫停的原因是**可以**消除的。 + +​ 某些軟體的執行環境要求很高,不能在特定時間內響應可能會導致嚴重的損失:飛機主控計算機,火箭,機器人,汽車和其他物體的計算機必須對其感測器輸入做出快速而可預測的響應。在這些系統中,軟體必須有一個特定的**截止時間(deadline)**,如果截止時間不滿足,可能會導致整個系統的故障。這就是所謂的**硬實時(hard real-time)**系統。 + +> #### 實時是真的嗎? +> +> 在嵌入式系統中,實時是指系統經過精心設計和測試,以滿足所有情況下的特定時間保證。這個含義與Web上實時術語的模糊使用相反,它描述了伺服器將資料推送到客戶端以及流處理,而沒有嚴格的響應時間限制(見[第11章](ch11.md))。 + +例如,如果車載感測器檢測到當前正在經歷碰撞,你肯定不希望安全氣囊釋放系統因為GC暫停而延遲彈出。 + +​ 在系統中提供**實時保證**需要各級軟體棧的支援:一個實時作業系統(RTOS),允許在指定的時間間隔內保證CPU時間的分配。庫函式必須記錄最壞情況下的執行時間;動態記憶體分配可能受到限制或完全不允許(實時垃圾收集器存在,但是應用程式仍然必須確保它不會給GC太多的負擔);必須進行大量的測試和測量,以確保達到保證。 + +​ 所有這些都需要大量額外的工作,嚴重限制了可以使用的程式語言,庫和工具的範圍(因為大多數語言和工具不提供實時保證)。由於這些原因,開發實時系統非常昂貴,並且它們通常用於安全關鍵的嵌入式裝置。而且,“**實時**”與“**高效能**”不一樣——事實上,實時系統可能具有較低的吞吐量,因為他們必須優先考慮及時響應高於一切(另請參見“[延遲和資源利用](#延遲和資源利用)“)。 + +​ 對於大多數伺服器端資料處理系統來說,實時保證是不經濟或不合適的。因此,這些系統必須承受在非實時環境中執行的暫停和時鐘不穩定性。 + +#### 限制垃圾收集的影響 + +​ 過程暫停的負面影響可以在不訴諸昂貴的實時排程保證的情況下得到緩解。語言執行時在計劃垃圾回收時具有一定的靈活性,因為它們可以跟蹤物件分配的速度和隨著時間的推移剩餘的空閒記憶體。 + +​ 一個新興的想法是將GC暫停視為一個節點的短暫計劃中斷,並讓其他節點處理來自客戶端的請求,同時一個節點正在收集其垃圾。如果執行時可以警告應用程式一個節點很快需要GC暫停,那麼應用程式可以停止向該節點發送新的請求,等待它完成處理未完成的請求,然後在沒有請求正在進行時執行GC。這個技巧隱藏了來自客戶端的GC暫停,並降低了響應時間的高百分比【70,71】。一些對延遲敏感的金融交易系統【72】使用這種方法。 + +​ 這個想法的一個變種是隻用垃圾收集器來處理短命物件(這些物件要快速收集),並定期在積累大量長壽物件(因此需要完整GC)之前重新啟動程序【65,73】。一次可以重新啟動一個節點,在計劃重新啟動之前,流量可以從節點移開,就像[滾動升級](ch4.md#滾動升級)一樣。 + +​ 這些措施不能完全阻止垃圾回收暫停,但可以有效地減少它們對應用的影響。 + + + +## 知識、真相與謊言 + +​ 本章到目前為止,我們已經探索了分散式系統與執行在單臺計算機上的程式的不同之處:沒有共享記憶體,只有透過可變延遲的不可靠網路傳遞的訊息,系統可能遭受部分失效,不可靠的時鐘和處理暫停。 + +​ 如果你不習慣於分散式系統,那麼這些問題的後果就會讓人迷惑不解。網路中的一個節點無法確切地知道任何事情——它只能根據它透過網路接收到(或沒有接收到)的訊息進行猜測。節點只能透過交換訊息來找出另一個節點所處的狀態(儲存了哪些資料,是否正確執行等等)。如果遠端節點沒有響應,則無法知道它處於什麼狀態,因為網路中的問題不能可靠地與節點上的問題區分開來。 + +​ 這些系統的討論與哲學有關:在系統中什麼是真什麼是假?如果感知和測量的機制都是不可靠的,那麼關於這些知識我們又能多麼確定呢?軟體系統應該遵循我們對物理世界所期望的法則,如因果關係嗎? + +​ 幸運的是,我們不需要去搞清楚生命的意義。在分散式系統中,我們可以陳述關於行為(系統模型)的假設,並以滿足這些假設的方式設計實際系統。演算法可以被證明在某個系統模型中正確執行。這意味著即使底層系統模型提供了很少的保證,也可以實現可靠的行為。 + +​ 但是,儘管可以使軟體在不可靠的系統模型中表現良好,但這並不是可以直截了當實現的。在本章的其餘部分中,我們將進一步探討分散式系統中的知識和真理的概念,這將有助於我們思考我們可以做出的各種假設以及我們可能希望提供的保證。在[第9章](ch9.md)中,我們將著眼於分散式系統的一些例子,這些演算法在特定的假設條件下提供了特定的保證。 + +### 真理由多數所定義 + +​ 設想一個具有不對稱故障的網路:一個節點能夠接收發送給它的所有訊息,但是來自該節點的任何傳出訊息被丟棄或延遲【19】。即使該節點執行良好,並且正在接收來自其他節點的請求,其他節點也無法聽到其響應。經過一段時間後,其他節點宣佈它已經死亡,因為他們沒有聽到節點的訊息。這種情況就像夢魘一樣:**半斷開(semi-disconnected)**的節點被拖向墓地,敲打尖叫道“我沒死!” ——但是由於沒有人能聽到它的尖叫,葬禮隊伍繼續以堅忍的決心繼續行進。 + +​ 在一個稍微不那麼夢魘的場景中,半斷開的節點可能會注意到它傳送的訊息沒有被其他節點確認,因此意識到網路中必定存在故障。儘管如此,節點被其他節點錯誤地宣告為死亡,而半連線的節點對此無能為力。 + +​ 第三種情況,想象一個經歷了一個長時間**停止所有處理垃圾收集暫停(stop-the-world GC Pause)**的節點。節點的所有執行緒被GC搶佔並暫停一分鐘,因此沒有請求被處理,也沒有響應被髮送。其他節點等待,重試,不耐煩,並最終宣佈節點死亡,並將其丟到靈車上。最後,GC完成,節點的執行緒繼續,好像什麼也沒有發生。其他節點感到驚訝,因為所謂的死亡節點突然從棺材中抬起頭來,身體健康,開始和旁觀者高興地聊天。GC後的節點最初甚至沒有意識到已經經過了整整一分鐘,而且自己已被宣告死亡。從它自己的角度來看,從最後一次與其他節點交談以來,幾乎沒有經過任何時間。 + +​ 這些故事的寓意是,節點不一定能相信自己對於情況的判斷。分散式系統不能完全依賴單個節點,因為節點可能隨時失效,可能會使系統卡死,無法恢復。相反,許多分散式演算法都依賴於法定人數,即在節點之間進行投票(參閱“[讀寫的法定人數](ch5.md#讀寫的法定人數)“):決策需要來自多個節點的最小投票數,以減少對於某個特定節點的依賴。 + +​ 這也包括關於宣告節點死亡的決定。如果法定數量的節點宣告另一個節點已經死亡,那麼即使該節點仍感覺自己活著,它也必須被認為是死的。個體節點必須遵守法定決定並下臺。 + +​ 最常見的法定人數是超過一半的絕對多數(儘管其他型別的法定人數也是可能的)。多數法定人數允許系統繼續工作,如果單個節點發生故障(三個節點可以容忍單節點故障;五個節點可以容忍雙節點故障)。系統仍然是安全的,因為在這個制度中只能有一個多數——不能同時存在兩個相互衝突的多數決定。當我們在[第9章](ch9.md)中討論**共識演算法(consensus algorithms)**時,我們將更詳細地討論法定人數的應用。 + +#### 領導者和鎖 + +通常情況下,一些東西在一個系統中只能有一個。例如: + +* 資料庫分割槽的領導者只能有一個節點,以避免**腦裂(split brain)**(參閱“[處理節點宕機](ch5.md#處理節點宕機)”)。 +* 特定資源的鎖或物件只允許一個事務/客戶端持有,以防同時寫入和損壞。 +* 一個特定的使用者名稱只能被一個使用者所註冊,因為使用者名稱必須唯一標識一個使用者。 + +在分散式系統中實現這一點需要注意:即使一個節點認為它是“**天選者(the choosen one)**”(分割槽的負責人,鎖的持有者,成功獲取使用者名稱的使用者的請求處理程式),但這並不一定意味著有法定人數的節點同意!一個節點可能以前是領導者,但是如果其他節點在此期間宣佈它死亡(例如,由於網路中斷或GC暫停),則它可能已被降級,且另一個領導者可能已經當選。 + +​ 如果一個節點繼續表現為**天選者**,即使大多數節點已經宣告它已經死了,則在考慮不周的系統中可能會導致問題。這樣的節點能以自己賦予的權能向其他節點發送訊息,如果其他節點相信,整個系統可能會做一些不正確的事情。 + +​ 例如,[圖8-4](img/fig8-4.png)顯示了由於不正確的鎖實現導致的資料損壞錯誤。 (這個錯誤不僅僅是理論上的:HBase曾經有這個問題【74,75】)假設你要確保一個儲存服務中的檔案一次只能被一個客戶訪問,因為如果多個客戶試圖寫對此,該檔案將被損壞。您嘗試透過在訪問檔案之前要求客戶端從鎖定服務獲取租約來實現此目的。 + +![](img/fig8-4.png) + +**圖8-4 分散式鎖的實現不正確:客戶端1認為它仍然具有有效的租約,即使它已經過期,從而破壞了儲存中的檔案** + +​ 這個問題就是我們先前在“[程序暫停](#程序暫停)”中討論過的一個例子:如果持有租約的客戶端暫停太久,它的租約將到期。另一個客戶端可以獲得同一檔案的租約,並開始寫入檔案。當暫停的客戶端回來時,它認為(不正確)它仍然有一個有效的租約,並繼續寫入檔案。結果,客戶的寫入衝突和損壞的檔案。 + +#### 防護令牌 + +​ 當使用鎖或租約來保護對某些資源(如[圖8-4](img/fig8-4.png)中的檔案儲存)的訪問時,需要確保一個被誤認為自己是“天選者”的節點不能擾亂系統的其它部分。實現這一目標的一個相當簡單的技術就是**防護(fencing)**,如[圖8-5]()所示 + +![](img/fig8-5.png) + +**圖8-5 只允許以增加防護令牌的順序進行寫操作,從而保證儲存安全** + +​ 我們假設每次鎖定伺服器授予鎖或租約時,它還會返回一個**防護令牌(fencing token)**,這個數字在每次授予鎖定時都會增加(例如,由鎖定服務增加)。然後,我們可以要求客戶端每次向儲存服務傳送寫入請求時,都必須包含當前的防護令牌。 + +​ 在[圖8-5](img/fig8-5.png)中,客戶端1以33的令牌獲得租約,但隨後進入一個長時間的停頓並且租約到期。客戶端2以34的令牌(該數字總是增加)獲取租約,然後將其寫入請求傳送到儲存服務,包括34的令牌。稍後,客戶端1恢復生機並將其寫入儲存服務,包括其令牌值33.但是,儲存伺服器會記住它已經處理了一個具有更高令牌編號(34)的寫入,因此它會拒絕帶有令牌33的請求。 + +​ 如果將ZooKeeper用作鎖定服務,則可將事務標識`zxid`或節點版本`cversion`用作防護令牌。由於它們保證單調遞增,因此它們具有所需的屬性【74】。 + +​ 請注意,這種機制要求資源本身在檢查令牌方面發揮積極作用,透過拒絕使用舊的令牌,而不是已經被處理的令牌來進行寫操作——僅僅依靠客戶端檢查自己的鎖狀態是不夠的。對於不明確支援防護令牌的資源,可能仍然可以解決此限制(例如,在檔案儲存服務的情況下,可以將防護令牌包含在檔名中)。但是,為了避免在鎖的保護之外處理請求,需要進行某種檢查。 + +​ 在伺服器端檢查一個令牌可能看起來像是一個缺點,但這可以說是一件好事:一個服務假定它的客戶總是守規矩並不明智,因為使用客戶端的人與執行服務的人優先順序非常不一樣【76】。因此,任何服務保護自己免受意外客戶的濫用是一個好主意。 + +### 拜占庭故障 + +​ 防護令牌可以檢測和阻止無意中發生錯誤的節點(例如,因為它尚未發現其租約已過期)。但是,如果節點有意破壞系統的保證,則可以透過使用假防護令牌傳送訊息來輕鬆完成此操作。 + +​ 在本書中,我們假設節點是不可靠但誠實的:它們可能很慢或者從不響應(由於故障),並且它們的狀態可能已經過時(由於GC暫停或網路延遲),但是我們假設如果節點它做出了迴應,它正在說出“真相”:盡其所知,它正在按照協議的規則扮演其角色。 + +​ 如果存在節點可能“撒謊”(傳送任意錯誤或損壞的響應)的風險,則分散式系統的問題變得更困難了——例如,如果節點可能聲稱其實際上沒有收到特定的訊息。這種行為被稱為**拜占庭故障(Byzantine fault)**,**在不信任的環境中達成共識的問題被稱為拜占庭將軍問題**【77】。 + +> ### 拜占庭將軍問題 +> +> ​ 拜占庭將軍問題是所謂“兩將軍問題”的概括【78】,它想象兩個將軍需要就戰鬥計劃達成一致的情況。由於他們在兩個不同的地點建立了營地,他們只能透過信使進行溝通,信使有時會被延遲或丟失(就像網路中的資訊包一樣)。我們將在[第9章](ch9.md)討論這個共識問題。 +> +> ​ 在這個拜占庭式的問題中,有n位將軍需要同意,他們的努力因為有一些叛徒在他們中間而受到阻礙。大多數的將軍都是忠誠的,因而發出了真實的資訊,但是叛徒可能會試圖透過傳送虛假或不真實的資訊來欺騙和混淆他人(在試圖保持未被發現的同時)。事先並不知道叛徒是誰。 +> +> ​ 拜占庭是後來成為君士坦丁堡的古希臘城市,現在在土耳其的伊斯坦布林。沒有任何歷史證據表明拜占庭將軍比其他地方更容易出現陰謀和陰謀。相反,這個名字來源於拜占庭式的過度複雜,官僚,迂迴等意義,早在計算機之前就已經在政治中被使用了【79】。Lamport想要選一個不會冒犯任何讀者的國家,他被告知將其稱為阿爾巴尼亞將軍問題並不是一個好主意【80】。 + +​ 當一個系統在部分節點發生故障、不遵守協議、甚至惡意攻擊、擾亂網路時仍然能繼續正確工作,稱之為**拜占庭容錯(Byzantine fault-tolerant)**的,在特定場景下,這種擔憂在是有意義的: + +* 在航空航天環境中,計算機記憶體或CPU暫存器中的資料可能被輻射破壞,導致其以任意不可預知的方式響應其他節點。由於系統故障非常昂貴(例如,飛機撞毀和炸死船上所有人員,或火箭與國際空間站相撞),飛行控制系統必須容忍拜占庭故障【81,82】。 +* 在多個參與組織的系統中,一些參與者可能會試圖欺騙或欺騙他人。在這種情況下,節點僅僅信任另一個節點的訊息是不安全的,因為它們可能是出於惡意的目的而被髮送的。例如,像比特幣和其他區塊鏈一樣的對等網路可以被認為是讓互不信任的各方同意交易是否發生的一種方式,而不依賴於中央當局【83】。 + +然而,在本書討論的那些系統中,我們通常可以安全地假設沒有拜占庭式的錯誤。在你的資料中心裡,所有的節點都是由你的組織控制的(所以他們可以信任),輻射水平足夠低,記憶體損壞不是一個大問題。製作拜占庭容錯系統的協議相當複雜【84】,而容錯嵌入式系統依賴於硬體層面的支援【81】。在大多數伺服器端資料系統中,部署拜占庭容錯解決方案的成本使其變得不切實際。 + +​ Web應用程式確實需要預期受終端使用者控制的客戶端(如Web瀏覽器)的任意和惡意行為。這就是為什麼輸入驗證,資料清洗和輸出轉義如此重要:例如,防止SQL注入和跨站點指令碼。但是,我們通常不使用拜占庭容錯協議,而只是讓伺服器決定什麼是客戶端行為,而不是允許的。在沒有這種中心授權的對等網路中,拜占庭容錯更為重要。 + +​ 軟體中的一個錯誤可能被認為是拜占庭式的錯誤,但是如果您將相同的軟體部署到所有節點上,那麼拜占庭式的容錯演算法不能為您節省。大多數拜占庭式容錯演算法要求超過三分之二的節點能夠正常工作(即,如果有四個節點,最多隻能有一個故障)。要使用這種方法對付bug,你必須有四個獨立的相同軟體的實現,並希望一個bug只出現在四個實現之一中。 + +​ 同樣,如果一個協議可以保護我們免受漏洞,安全妥協和惡意攻擊,那麼這將是有吸引力的。不幸的是,這也是不現實的:在大多數系統中,如果攻擊者可以滲透一個節點,那他們可能會滲透所有這些節點,因為它們可能執行相同的軟體。因此傳統機制(認證,訪問控制,加密,防火牆等)仍然是抵禦攻擊者的主要保護措施。 + +#### 弱謊言形式 + +​ 儘管我們假設節點通常是誠實的,但值得向軟體中新增防止“撒謊”弱形式的機制——例如,由硬體問題導致的無效訊息,軟體錯誤和錯誤配置。這種保護機制並不是完全的拜占庭容錯,因為它們不能抵擋決心堅定的對手,但它們仍然是簡單而實用的步驟,以提高可靠性。例如: + +* 由於硬體問題或作業系統,驅動程式,路由器等中的錯誤,網路資料包有時會受到損壞。通常,內建於TCP和UDP中的校驗和會俘獲損壞的資料包,但有時它們會逃避檢測【85,86,87】 。簡單的措施通常是採用充分的保護來防止這種破壞,例如應用程式級協議中的校驗和。 +* 可公開訪問的應用程式必須仔細清理來自使用者的任何輸入,例如檢查值是否在合理的範圍內,並限制字串的大小以防止透過大記憶體分配拒絕服務。防火牆後面的內部服務可能能夠在對輸入進行較不嚴格的檢查的情況下逃脫,但是一些基本的理智檢查(例如,在協議解析中)是一個好主意。 +* NTP客戶端可以配置多個伺服器地址。同步時,客戶端聯絡所有的伺服器,估計它們的誤差,並檢查大多數伺服器是否在對某個時間範圍內達成一致。只要大多數的伺服器沒問題,一個配置錯誤的NTP伺服器報告的時間會被當成特異值從同步中排除【37】。使用多個伺服器使NTP更健壯(比起只用單個伺服器來)。 + +### 系統模型與現實 + +​ 已經有很多演算法被設計以解決分散式系統問題——例如,我們將在[第9章](ch9.md)討論共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。 + +​ 演算法的編寫方式並不過分依賴於執行的硬體和軟體配置的細節。這又要求我們以某種方式將我們期望在系統中發生的錯誤形式化。我們透過定義一個系統模型來做到這一點,這個模型是一個抽象,描述一個演算法可能承擔的事情。 +關於定時假設,三種系統模型是常用的: + +***同步模型*** + +​ **同步模型(synchronous model)**假設網路延遲,程序暫停和和時鐘誤差都是有界限的。這並不意味著完全同步的時鐘或零網路延遲;這隻意味著你知道網路延遲,暫停和時鐘漂移將永遠不會超過某個固定的上限【88】。同步模型並不是大多數實際系統的現實模型,因為(如本章所討論的)無限延遲和暫停確實會發生。 + +***部分同步模型*** + +​ **部分同步(partial synchronous)**意味著一個系統在大多數情況下像一個同步系統一樣執行,但有時候會超出網路延遲,程序暫停和時鐘漂移的界限【88】。這是很多系統的現實模型:大多數情況下,網路和程序表現良好,否則我們永遠無法完成任何事情,但是我們必須承認,在任何時刻假設都存在偶然被破壞的事實。發生這種情況時,網路延遲,暫停和時鐘錯誤可能會變得相當大。 + +***非同步模型*** + +​ 在這個模型中,一個演算法不允許對時機做任何假設——事實上它甚至沒有時鐘(所以它不能使用超時)。一些演算法被設計為可用於非同步模型,但非常受限。 + + + +進一步來說,除了時間問題,我們還要考慮**節點失效**。三種最常見的節點系統模型是: + +***崩潰-停止故障*** + +​ 在**崩潰停止(crash-stop)**模型中,演算法可能會假設一個節點只能以一種方式失效,即透過崩潰。這意味著節點可能在任意時刻突然停止響應,此後該節點永遠消失——它永遠不會回來。 + +***崩潰-恢復故障*** + +​ 我們假設節點可能會在任何時候崩潰,但也許會在未知的時間之後再次開始響應。在**崩潰-恢復(crash-recovery)**模型中,假設節點具有穩定的儲存(即,非易失性磁碟儲存)且會在崩潰中保留,而記憶體中的狀態會丟失。 + +***拜占庭(任意)故障*** + +​ 節點可以做(絕對意義上的)任何事情,包括試圖戲弄和欺騙其他節點,如上一節所述。 + +對於真實系統的建模,具有**崩潰-恢復故障(crash-recovery)**的**部分同步模型(partial synchronous)**通常是最有用的模型。分散式演算法如何應對這種模型? + +#### 演算法的正確性 + +​ 為了定義演算法是正確的,我們可以描述它的屬性。例如,排序演算法的輸出具有如下特性:對於輸出列表中的任何兩個不同的元素,左邊的元素比右邊的元素小。這只是定義對列表進行排序含義的一種形式方式。 + +​ 同樣,我們可以寫下我們想要的分散式演算法的屬性來定義它的正確含義。例如,如果我們正在為一個鎖生成防護令牌(參閱“[防護令牌](防護令牌)”),我們可能要求演算法具有以下屬性: + +***唯一性*** + +​ 沒有兩個防護令牌請求返回相同的值。 + +***單調序列*** + +​ 如果請求 $x$ 返回了令牌 $t_x$,並且請求$y$返回了令牌$t_y$,並且 $x$ 在 $y$ 開始之前已經完成,那麼$t_x 好死不如賴活著 +> —— Jay Kreps, 關於Kafka與 Jepsen的若干筆記 (2013) + +--------------- + +[TOC] + +​ 正如[第8章](ch8.md)所討論的,分散式系統中的許多事情可能會出錯。處理這種故障的最簡單方法是簡單地讓整個服務失效,並向用戶顯示錯誤訊息。如果無法接受這個解決方案,我們就需要找到容錯的方法—— 即使某些內部元件出現故障,服務也能正常執行。 + +​ 在本章中,我們將討論構建容錯分散式系統的演算法和協議的一些例子。我們將假設[第8章](ch8.md)的所有問題都可能發生:網路中的資料包可能會丟失,重新排序,重複遞送或任意延遲;時鐘只是盡其所能地近似;且節點可以暫停(例如,由於垃圾收集)或隨時崩潰。 + +​ 構建容錯系統的最好方法,是找到一些帶有實用保證的通用抽象,實現一次,然後讓應用依賴這些保證。這與[第7章](ch7.md)中的事務處理方法相同:透過使用事務,應用可以假裝沒有崩潰(原子性),沒有其他人同時訪問資料庫(隔離),儲存裝置是完全可靠的(永續性)。即使發生崩潰,競態條件和磁碟故障,事務抽象隱藏了這些問題,因此應用不必擔心它們。 + +​ 現在我們將繼續沿著同樣的路線前進,尋求可以讓應用忽略分散式系統部分問題的抽象概念。例如,分散式系統最重要的抽象之一就是**共識(consensus)**:**就是讓所有的節點對某件事達成一致**。正如我們在本章中將會看到的那樣,儘管存在網路故障和流程故障,可靠地達成共識是一個令人驚訝的棘手問題。 + +​ 一旦達成共識,應用可以將其用於各種目的。例如,假設你有一個單主複製的資料庫。如果領導者掛掉,並且需要故障切換到另一個節點,剩餘的資料庫節點可以使用共識來選舉新的領導者。正如在“[處理節點宕機](ch5.md#處理節點宕機)”中所討論的那樣,重要的是隻有一個領導者,且所有的節點都認同其領導。如果兩個節點都認為自己是領導者,這種情況被稱為**腦裂(split brain)**,且經常導致資料丟失。正確實現共識有助於避免這種問題。 + +​ 在本章後面的“[分散式事務和共識](#分散式事務和共識)”中,我們將研究解決共識和相關問題的演算法。但首先,我們首先需要探索可以在分散式系統中提供的保證和抽象的範圍。 + +​ 我們需要了解可以做什麼和不可以做什麼的範圍:在某些情況下,系統可以容忍故障並繼續工作;在其他情況下,這是不可能的。我們將深入研究什麼可能而什麼不可能的限制,既透過理論證明,也透過實際實現。我們將在本章中概述這些基本限制。 + +​ 分散式系統領域的研究人員幾十年來一直在研究這些主題,所以有很多資料—— 我們只能介紹一些皮毛。在本書中,我們沒有空間去詳細介紹形式模型和證明的細節,所以我們將堅持非正式的直覺。如果你有興趣,參考文獻可以提供更多的深度。 + + + +## 一致性保證 + +​ 在“[複製延遲問題](ch5.md#複製延遲問題)”中,我們看到了資料庫複製中發生的一些時序問題。如果你在同一時刻檢視兩個資料庫節點,則可能在兩個節點上看到不同的資料,因為寫請求在不同的時間到達不同的節點。無論資料庫使用何種複製方法(單主複製,多主複製或無主複製),都會出現這些不一致情況。 + +​ 大多數複製的資料庫至少提供了**最終一致性**,這意味著如果你停止向資料庫寫入資料並等待一段不確定的時間,那麼最終所有的讀取請求都會返回相同的值【1】。換句話說,不一致性是暫時的,最終會自行解決(假設網路中的任何故障最終都會被修復)。最終一致性的一個更好的名字可能是**收斂(convergence)**,因為我們預計所有的副本最終會收斂到相同的值【2】。 + +​ 然而,這是一個非常弱的保證 —— 它並沒有說什麼時候副本會收斂。在收斂之前,讀操作可能會返回任何東西或什麼都沒有【1】。例如,如果你寫入了一個值,然後立即再次讀取,這並不能保證你能看到剛跟寫入的值,因為讀請求可能會被路由到另外的副本上。(參閱“[讀己之寫](ch5.md#讀己之寫)” )。 + +​ 對於應用開發人員而言,最終一致性是很困難的,因為它與普通單執行緒程式中變數的行為有很大區別。對於後者,如果將一個值賦給一個變數,然後很快地再次讀取,不可能讀到舊的值,或者讀取失敗。資料庫表面上看起來像一個你可以讀寫的變數,但實際上它有更復雜的語義【3】。 + +​ 在與只提供弱保證的資料庫打交道時,你需要始終意識到它的侷限性,而不是意外地作出太多假設。錯誤往往是微妙的,很難找到,也很難測試,因為應用可能在大多數情況下執行良好。當系統出現故障(例如網路中斷)或高併發時,最終一致性的邊緣情況才會顯現出來。 + +​ 本章將探索資料系統可能選擇提供的更強一致性模型。它不是免費的:具有較強保證的系統可能會比保證較差的系統具有更差的效能或更少的容錯性。儘管如此,更強的保證可以吸引人,因為它們更容易用對。只有見過不同的一致性模型後,才能更好地決定哪一個最適合自己的需求。 + +​ **分散式一致性模型**和我們之前討論的事務隔離級別的層次結構有一些相似之處【4,5】(參見“[弱隔離級別](ch7.md#弱隔離級別)”)。儘管兩者有一部分內容重疊,但它們大多是無關的問題:事務隔離主要是為了,**避免由於同時執行事務而導致的競爭狀態**,而分散式一致性主要關於,**面對延遲和故障時,如何協調副本間的狀態。** + +本章涵蓋了廣泛的話題,但我們將會看到這些領域實際上是緊密聯絡在一起的: + +* 首先看一下常用的**最強一致性模型**之一,**線性一致性(linearizability)**,並考察其優缺點。 +* 然後我們將檢查分散式系統中[**事件順序**](#順序保證)的問題,特別是因果關係和全域性順序的問題。 +* 在第三部分(“[分散式事務和共識](#分散式事務和共識)”)中將探討如何原子地提交分散式事務,這將最終引領我們走向共識問題的解決方案。 + + + + +## 線性一致性 + +​ 在**最終一致**的資料庫,如果你在同一時刻問兩個不同副本相同的問題,可能會得到兩個不同的答案。這很讓人困惑。如果資料庫可以提供只有一個副本的假象(即,只有一個數據副本),那麼事情就簡單太多了。那麼每個客戶端都會有相同的資料檢視,且不必擔心複製滯後了。 + +​ 這就是**線性一致性(linearizability)**背後的想法【6】(也稱為**原子一致性(atomic consistency)**【7】,**強一致性(strong consistency)**,**立即一致性(immediate consistency)**或**外部一致性(external consistency )**【8】)。線性一致性的精確定義相當微妙,我們將在本節的剩餘部分探討它。但是基本的想法是讓一個系統看起來好像只有一個數據副本,而且所有的操作都是原子性的。有了這個保證,即使實際中可能有多個副本,應用也不需要擔心它們。 + +​ 在一個線性一致的系統中,只要一個客戶端成功完成寫操作,所有客戶端從資料庫中讀取資料必須能夠看到剛剛寫入的值。維護資料的單個副本的錯覺是指,系統能保障讀到的值是最近的,最新的,而不是來自陳舊的快取或副本。換句話說,線性一致性是一個**新鮮度保證(recency guarantee)**。為了闡明這個想法,我們來看看一個非線性一致系統的例子。 + +![](img/fig9-1.png) + +**圖9-1 這個系統是非線性一致的,導致了球迷的困惑** + +​ [圖9-1 ](img/fig9-1.png)展示了一個關於體育網站的非線性一致例子【9】。Alice和Bob正坐在同一個房間裡,都盯著各自的手機,關注著2014年FIFA世界盃決賽的結果。在最後得分公佈後,Alice重新整理頁面,看到宣佈了獲勝者,並興奮地告訴Bob。Bob難以置信地重新整理了自己的手機,但他的請求路由到了一個落後的資料庫副本上,手機顯示比賽仍在進行。 + +​ 如果Alice和Bob在同一時間重新整理並獲得了兩個不同的查詢結果,也許就沒有那麼令人驚訝了。因為他們不知道伺服器處理他們請求的精確時刻。然而Bob是在聽到Alice驚呼最後得分**之後**,點選了重新整理按鈕(啟動了他的查詢),因此他希望查詢結果至少與愛麗絲一樣新鮮。但他的查詢返回了陳舊結果,這一事實違背了線性一致性的要求。 + +### 什麼使得系統線性一致? + +​ 線性一致性背後的基本思想很簡單:使系統看起來好像只有一個數據副本。然而確切來講,實際上有更多要操心的地方。為了更好地理解線性一致性,讓我們再看幾個例子。 + +​ [圖9-2](img/fig9-2.png) 顯示了三個客戶端線上性一致資料庫中同時讀寫相同的鍵`x`。在分散式系統文獻中,`x`被稱為**暫存器(register)**,例如,它可以是鍵值儲存中的一個**鍵**,關係資料庫中的一**行**,或文件資料庫中的一個**文件**。 + +![](img/fig9-2.png) + +**圖9-2 如果讀取請求與寫入請求併發,則可能會返回舊值或新值** + +​ 為了簡單起見,[圖9-2](img/fig9-2.png)採用了使用者請求的視角,而不是資料庫內部的視角。每個柱都是由客戶端發出的請求,其中柱頭是請求傳送的時刻,柱尾是客戶端收到響應的時刻。因為網路延遲變化無常,客戶端不知道資料庫處理其請求的精確時間——只知道它發生在傳送請求和接收響應的之間的某個時刻。[^i] + +[^i]: 這個圖的一個微妙的細節是它假定存在一個全域性時鐘,由水平軸表示。即使真實的系統通常沒有準確的時鐘(參閱“[不可靠的時鐘](ch8.md#不可靠的時鐘)”),但這種假設是允許的:為了分析分散式演算法,我們可以假設一個精確的全域性時鐘存在,不過演算法無法訪問它【47】。演算法只能看到由石英振盪器和NTP產生的實時逼近。 + +在這個例子中,暫存器有兩種型別的操作: + +* $ read(x)⇒v$表示客戶端請求讀取暫存器 `x` 的值,資料庫返回值 `v`。 + + +* $write(x,v)⇒r$ 表示客戶端請求將暫存器 `x` 設定為值 `v` ,資料庫返回響應 `r` (可能正確,可能錯誤)。 + +在[圖9-2](img/fig9-2.png) 中,`x` 的值最初為 `0`,客戶端C 執行寫請求將其設定為 `1`。發生這種情況時,客戶端A和B反覆輪詢資料庫以讀取最新值。 A和B的請求可能會收到怎樣的響應? + +* 客戶端A的第一個讀操作,完成於寫操作開始之前,因此必須返回舊值 `0`。 +* 客戶端A的最後一個讀操作,開始於寫操作完成之後。如果資料庫是線性一致性的,它必然返回新值 `1`:因為讀操作和寫操作一定是在其各自的起止區間內的某個時刻被處理。如果在寫入結束後開始讀取,則必須在寫入之後處理讀取,因此它必須看到寫入的新值。 +* 與寫操作在時間上重疊的任何讀操作,可能會返回 `0` 或 `1` ,因為我們不知道讀取時,寫操作是否已經生效。這些操作是**併發(concurrent)**的。 + +但是,這還不足以完全描述線性一致性:如果與寫入同時發生的讀取可以返回舊值或新值,那麼讀者可能會在寫入期間看到數值在舊值和新值之間來回翻轉。這不是我們所期望的模擬“單一資料副本”的系統。[^ii] + +[^ii]: 如果讀取(與寫入同時發生時)可能返回舊值或新值,則稱該暫存器為**常規暫存器(regular register)**【7,25】 + +為了使系統線性一致,我們需要新增另一個約束,如[圖9-3](img/fig9-3.png)所示 + +![](img/fig9-3.png) +**圖9-3 任何一個讀取返回新值後,所有後續讀取(在相同或其他客戶端上)也必須返回新值。** + +​ 在一個線性一致的系統中,我們可以想象,在 `x` 的值從`0` 自動翻轉到 `1` 的時候(在寫操作的開始和結束之間)必定有一個時間點。因此,如果一個客戶端的讀取返回新的值 `1`,即使寫操作尚未完成,所有後續讀取也必須返回新值。 + +​ [圖9-3](img/fig9-3.png)中的箭頭說明了這個時序依賴關係。客戶端A 是第一個讀取新的值 `1` 的位置。在A 的讀取返回之後,B開始新的讀取。由於B的讀取嚴格在發生於A的讀取之後,因此即使C的寫入仍在進行中,也必須返回 `1`。 (與[圖9-1](img/fig9-1.png)中的Alice和Bob的情況相同:在Alice讀取新值之後,Bob也希望讀取新的值。) + +​ 我們可以進一步細化這個時序圖,展示每個操作是如何在特定時刻原子性生效的。[圖9-4](img/fig9-4.png)顯示了一個更復雜的例子【10】。 + +在[圖9-4]()中,除了讀寫之外,還增加了第三種類型的操作: + +* $cas(x, v_{old}, v_{new})⇒r$ 表示客戶端請求進行原子性的[**比較與設定**](ch7.md#比較並設定(CAS))操作。如果暫存器 $x$ 的當前值等於 $v_{old}$ ,則應該原子地設定為 $v_{new}$ 。如果 $x≠v_{old}$ ,則操作應該保持暫存器不變並返回一個錯誤。 $r$ 是資料庫的響應(正確或錯誤)。 + +[圖9-4]()中的每個操作都在我們認為執行操作的時候用豎線標出(在每個操作的條柱之內)。這些標記按順序連在一起,其結果必須是一個有效的暫存器讀寫序列(**每次讀取都必須返回最近一次寫入設定的值**)。 + +​ 線性一致性的要求是,操作標記的連線總是按時間(從左到右)向前移動,而不是向後移動。這個要求確保了我們之前討論的新鮮性保證:一旦新的值被寫入或讀取,所有後續的讀都會看到寫入的值,直到它被再次覆蓋。 + +![](img/fig9-4.png) + +**圖9-4 視覺化讀取和寫入看起來已經生效的時間點。 B的最後讀取不是線性一致性的** + +[圖9-4]()中有一些有趣的細節需要指出: + +* 第一個客戶端B傳送一個讀取 `x` 的請求,然後客戶端D傳送一個請求將 `x` 設定為 `0`,然後客戶端A傳送請求將 `x` 設定為 `1`。儘管如此,返回到B的讀取值為 `1`(由A寫入的值)。這是可以的:這意味著資料庫首先處理D的寫入,然後是A的寫入,最後是B的讀取。雖然這不是請求傳送的順序,但這是一個可以接受的順序,因為這三個請求是併發的。也許B的讀請求在網路上略有延遲,所以它在兩次寫入之後才到達資料庫。 + +* 在客戶端A從資料庫收到響應之前,客戶端B的讀取返回 `1` ,表示寫入值 `1` 已成功。這也是可以的:這並不意味著在寫之前讀到了值,這只是意味著從資料庫到客戶端A的正確響應在網路中略有延遲。 + +* 此模型不假設有任何事務隔離:另一個客戶端可能隨時更改值。例如,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更舊的值。再次,與[圖9-1](img/fig9-1.png)中的Alice和Bob的情況相同。 + + 這就是線性一致性背後的直覺。 正式的定義【6】更準確地描述了它。 透過記錄所有請求和響應的時序,並檢查它們是否可以排列成有效的順序,測試一個系統的行為是否線性一致性是可能的(儘管在計算上是昂貴的)【11】。 + + + +> ### 線性一致性與可序列化 +> +> **線性一致性**容易和[**可序列化**](ch7.md#可序列化)相混淆,因為兩個詞似乎都是類似“可以按順序排列”的東西。但它們是兩種完全不同的保證,區分兩者非常重要: +> +> ***可序列化*** +> +> **可序列化(Serializability)**是事務的隔離屬性,每個事務可以讀寫多個物件(行,文件,記錄)——參閱“[單物件和多物件操作](ch7.md#單物件和多物件操作)”。它確保事務的行為,與它們按照**某種**順序依次執行的結果相同(每個事務在下一個事務開始之前執行完成)。這種執行順序可以與事務實際執行的順序不同。【12】。 +> +> ***線性一致性*** +> +> **線性一致性(Linearizability)**是讀取和寫入暫存器(單個物件)的**新鮮度保證**。它不會將操作組合為事務,因此它也不會阻止寫偏差等問題(參閱“[寫偏差和幻讀](ch7.md#寫偏差和幻讀)”),除非採取其他措施(例如[物化衝突](ch7.md#物化衝突))。 +> +> 一個數據庫可以提供可序列化和線性一致性,這種組合被稱為嚴格的可序列化或強的**強單副本可序列化(strong-1SR)**【4,13】。基於兩階段鎖定的可序列化實現(參見“[兩階段鎖定(2PL)](#兩階段鎖定(2PL))”一節)或**實際序列執行**(參見第“[實際序列執行](ch7.md#實際序列執行)”)通常是線性一致性的。 +> +> 但是,可序列化的快照隔離(參見“[可序列化的快照隔離(SSI)](ch7.md#可序列化的快照隔離(SSI))”)不是線性一致性的:按照設計,它從一致的快照中進行讀取,以避免讀者和寫者之間的鎖競爭。一致性快照的要點就在於**它不會包括該快照之後的寫入**,因此從快照讀取不是線性一致性的。 + + + +### 依賴線性一致性 + +​ 線性一致性在什麼情況下有用?觀看體育比賽的最後得分可能是一個輕率的例子:過了幾秒鐘的結果不可能在這種情況下造成任何真正的傷害。然而對於少數領域,線性一致性是系統正確工作的一個重要條件。 + +#### 鎖定和領導選舉 + +​ 一個使用單主複製的系統,需要確保領導真的只有一個,而不是幾個(腦裂)。一種選擇領導者的方法是使用鎖:每個節點在啟動時嘗試獲取鎖,成功者成為領導者【14】。不管這個鎖是如何實現的,它必須是線性一致的:所有節點必須就哪個節點擁有鎖達成一致,否則就沒用了。 + +​ 諸如Apache ZooKeeper 【15】和etcd 【16】之類的協調服務通常用於實現分散式鎖和領導者選舉。它們使用一致性演算法,以容錯的方式實現線性一致的操作(在本章後面的“[容錯共識](#容錯共識)”中討論此類演算法)[^iii]。還有許多微妙的細節來正確地實現鎖和領導者選舉(例如,參閱“[領導者和鎖](#領導者和鎖)”中的防護問題),而像Apache Curator 【17】這樣的庫則透過在ZooKeeper之上提供更高級別的配方來提供幫助。但是,線性一致性儲存服務是這些協調任務的基礎。 + +[^iii]: 嚴格地說,ZooKeeper和etcd提供線性一致性的寫操作,但讀取可能是陳舊的,因為預設情況下,它們可以由任何一個副本服務。你可以選擇請求線性一致性讀取:etcd呼叫這個法定讀取【16】,而在ZooKeeper中,你需要在讀取【15】之前呼叫`sync()`。參閱“[使用全域性順序廣播實現線性儲存](#使用全域性順序廣播實現線性儲存)”。 + +​ 分散式鎖也在一些分散式資料庫(如Oracle Real Application Clusters(RAC)【18】)中更多的粒度級別上使用。RAC對每個磁碟頁面使用一個鎖,多個節點共享對同一個磁碟儲存系統的訪問許可權。由於這些線性一致的鎖處於事務執行的關鍵路徑上,RAC部署通常具有用於資料庫節點之間通訊的專用叢集互連網路。 + +#### 約束和唯一性保證 + +​ 唯一性約束在資料庫中很常見:例如,使用者名稱或電子郵件地址必須唯一標識一個使用者,而在檔案儲存服務中,不能有兩個具有相同路徑和檔名的檔案。如果要在寫入資料時強制執行此約束(例如,如果兩個人試圖同時建立一個具有相同名稱的使用者或檔案,其中一個將返回一個錯誤),則需要線性一致性。 + +​ 這種情況實際上類似於一個鎖:當一個使用者註冊你的服務時,可以認為他們獲得了所選使用者名稱的“鎖定”。該操作與原子性的比較與設定非常相似:將使用者名稱賦予宣告它的使用者,前提是使用者名稱尚未被使用。 + +​ 如果想要確保銀行賬戶餘額永遠不會為負數,或者不會出售比倉庫裡的庫存更多的物品,或者兩個人不會都預定了航班或劇院裡同一時間的同一個位置。這些約束條件都要求所有節點都同意一個最新的值(賬戶餘額,庫存水平,座位佔用率)。 + +​ 在實際應用中,寬鬆地處理這些限制有時是可以接受的(例如,如果航班超額預訂,你可以將客戶轉移到不同的航班併為其提供補償)。在這種情況下,可能不需要線性一致性,我們將在“[及時性與完整性](ch12.md#及時性與完整性)”中討論這種寬鬆的約束。 + +​ 然而,一個硬性的唯一性約束(關係型資料庫中常見的那種)需要線性一致性。其他型別的約束,如外來鍵或屬性約束,可以不需要線性一致性【19】。 + +#### 跨通道的時序依賴 + +​ 注意[圖9-1](img/fig9-1.png) 中的一個細節:如果Alice沒有驚呼得分,Bob就不會知道他的查詢結果是陳舊的。他會在幾秒鐘之後再次重新整理頁面,並最終看到最後的分數。由於系統中存在額外的通道(Alice的聲音傳到了Bob的耳朵中),線性一致性的違背才被注意到。 + +​ 計算機系統也會出現類似的情況。例如,假設有一個網站,使用者可以上傳照片,一個後臺程序會調整照片大小,降低解析度以加快下載速度(縮圖)。該系統的架構和資料流如[圖9-5](img/fig9-5.png)所示。 + +​ 影象縮放器需要明確的指令來執行尺寸縮放作業,指令是Web伺服器透過訊息佇列傳送的(參閱[第11章](ch11.md))。 Web伺服器不會將整個照片放在佇列中,因為大多數訊息代理都是針對較短的訊息而設計的,而一張照片的空間佔用可能達到幾兆位元組。取而代之的是,首先將照片寫入檔案儲存服務,寫入完成後再將縮放器的指令放入訊息佇列。 +![](img/fig9-5.png) +**圖9-5 Web伺服器和影象調整器透過檔案儲存和訊息佇列進行通訊,開啟競爭條件的可能性。** + +​ 如果檔案儲存服務是線性一致的,那麼這個系統應該可以正常工作。如果它不是線性一致的,則存在競爭條件的風險:訊息佇列([圖9-5](img/fig9-5.png)中的步驟3和4)可能比儲存服務內部的複製更快。在這種情況下,當縮放器讀取影象(步驟5)時,可能會看到影象的舊版本,或者什麼都沒有。如果它處理的是舊版本的影象,則檔案儲存中的全尺寸圖和略縮圖就產生了永久性的不一致。 + +​ 出現這個問題是因為Web伺服器和縮放器之間存在兩個不同的通道:檔案儲存與訊息佇列。沒有線性一致性的新鮮性保證,這兩個通道之間的競爭條件是可能的。這種情況類似於[圖9-1](img/fig9-1.png),資料庫複製與Alice的嘴到Bob耳朵之間的真人音訊通道之間也存在競爭條件。 + +​ 線性一致性並不是避免這種競爭條件的唯一方法,但它是最容易理解的。如果你可以控制額外通道(例如訊息佇列的例子,而不是在Alice和Bob的例子),則可以使用在“[讀己之寫](ch5.md#讀己之寫)”討論過的備選方法,不過會有額外的複雜度代價。 + +### 實現線性一致的系統 + +​ 我們已經見到了幾個線性一致性有用的例子,讓我們思考一下,如何實現一個提供線性一致語義的系統。 + +​ 由於線性一致性本質上意味著“表現得好像只有一個數據副本,而且所有的操作都是原子的”,所以最簡單的答案就是,真的只用一個數據副本。但是這種方法無法容錯:如果持有該副本的節點失效,資料將會丟失,或者至少無法訪問,直到節點重新啟動。 + +​ 使系統容錯最常用的方法是使用複製。我們再來回顧[第5章](ch5.md)中的複製方法,並比較它們是否可以滿足線性一致性: + +***單主複製(可能線性一致)*** + +​ 在具有單主複製功能的系統中(參見“[領導者與追隨者](ch5.md#領導者與追隨者)”),主庫具有用於寫入的資料的主副本,而追隨者在其他節點上保留資料的備份副本。如果從主庫或同步更新的從庫讀取資料,它們**可能(protential)**是線性一致性的[^iv]。然而,並不是每個單主資料庫都是實際線性一致性的,無論是透過設計(例如,因為使用快照隔離)還是併發錯誤【10】。 + +[^iv]: 對單領域資料庫進行分割槽(分片),以便每個分割槽有一個單獨的領導者,不會影響線性一致性,因為線性一致性只是對單一物件的保證。 交叉分割槽事務是一個不同的問題(參閱“[分散式事務和共識](#分散式事務和共識)”)。 + +​ 從主庫讀取依賴一個假設,你確定領導是誰。正如在“[真理在多數人手中](ch8.md#真理被多數人定義)”中所討論的那樣,一個節點很可能會認為它是領導者,而事實上並非如此——如果具有錯覺的領導者繼續為請求提供服務,可能違反線性一致性【20】。使用非同步複製,故障切換時甚至可能會丟失已提交的寫入(參閱“[處理節點宕機](ch5.md#處理節點宕機)”),這同時違反了永續性和線性一致性。 + +***共識演算法(線性一致)*** + +​ 一些在本章後面討論的共識演算法,與單領導者複製類似。然而,共識協議包含防止腦裂和陳舊副本的措施。正是由於這些細節,共識演算法可以安全地實現線性一致性儲存。例如,Zookeeper 【21】和etcd 【22】就是這樣工作的。 + +***多主複製(非線性一致)*** + +​ 具有多主程式複製的系統通常不是線性一致的,因為它們同時在多個節點上處理寫入,並將其非同步複製到其他節點。因此,它們可能會產生衝突的寫入,需要解析(參閱“[處理寫入衝突](ch5.md#處理寫入衝突)”)。這種衝突是因為缺少單一資料副本人為產生的。 + +***無主複製(也許不是線性一致的)*** + +​ 對於無領導者複製的系統(Dynamo風格;參閱“[無主複製](ch5.md#無主複製)”),有時候人們會聲稱透過要求法定人數讀寫( $w + r> n$ )可以獲得“強一致性”。這取決於法定人數的具體配置,以及強一致性如何定義(通常不完全正確)。 + + + +​ 基於時鐘(例如,在Cassandra中;參見“[依賴同步時鐘](ch8.md#依賴同步時鐘)”)的“最後寫入勝利”衝突解決方法幾乎可以確定是非線性的,由於時鐘偏差,不能保證時鐘的時間戳與實際事件順序一致。[寬鬆的法定人數](ch5.md#寬鬆的法定人數與提示移交)也破壞了線性一致的可能性。即使使用嚴格的法定人數,非線性一致的行為也是可能的,如下節所示。 + +#### 線性一致性和法定人數 + +​ 直覺上在Dynamo風格的模型中,嚴格的法定人數讀寫應該是線性一致性的。但是當我們有可變的網路延遲時,就可能存在競爭條件,如[圖9-6](img/fig9-6.png)所示。 + +![](img/fig9-6.png) + +**圖9-6 非線性一致的執行,儘管使用了嚴格的法定人數** + +​ 在[圖9-6](img/fig9-6.png)中,$x$ 的初始值為0,寫入客戶端透過向所有三個副本( $n = 3, w = 3$ )傳送寫入將 $x$ 更新為 `1`。客戶端A併發地從兩個節點組成的法定人群( $r = 2$ )中讀取資料,並在其中一個節點上看到新值 `1` 。客戶端B也併發地從兩個不同的節點組成的法定人數中讀取,並從兩個節點中取回了舊值 `0` 。 + +​ 法定人數條件滿足( $w + r> n$ ),但是這個執行是非線性一致的:B的請求在A的請求完成後開始,但是B返回舊值,而A返回新值。 (又一次,如同Alice和Bob的例子 [圖9-1]()) + +​ 有趣的是,透過犧牲效能,可以使Dynamo風格的法定人數線性化:讀取者必須在將結果返回給應用之前,同步執行讀修復(參閱“[讀時修復與反熵過程](ch5.md#讀時修復與反熵過程)”) ,並且寫入者必須在傳送寫入之前,讀取法定數量節點的最新狀態【24,25】。然而,由於效能損失,Riak不執行同步讀修復【26】。 Cassandra在進行法定人數讀取時,**確實**在等待讀修復完成【27】;但是由於使用了最後寫入勝利的衝突解決方案,當同一個鍵有多個併發寫入時,將不能保證線性一致性。 + +​ 而且,這種方式只能實現線性一致的讀寫;不能實現線性一致的比較和設定操作,因為它需要一個共識演算法【28】。 + +​ 總而言之,最安全的做法是:假設採用Dynamo風格無主複製的系統不能提供線性一致性。 + + + +### 線性一致性的代價 + +​ 一些複製方法可以提供線性一致性,另一些複製方法則不能,因此深入地探討線性一致性的優缺點是很有趣的。 + +​ 我們已經在[第五章](ch5.md)中討論了不同複製方法的一些用例。例如對多資料中心的複製而言,多主複製通常是理想的選擇(參閱“[運維多個數據中心](ch5.md#運維多個數據中心)”)。[圖9-7](img/fig9-7.png)說明了這種部署的一個例子。 + +![](img/fig9-7.png) + +**圖9-7 網路中斷迫使線上性一致性和可用性之間做出選擇。** + +​ 考慮這樣一種情況:如果兩個資料中心之間發生網路中斷會發生什麼?我們假設每個資料中心內的網路正在工作,客戶端可以訪問資料中心,但資料中心之間彼此無法互相連線。 + +​ 使用多主資料庫,每個資料中心都可以繼續正常執行:由於在一個數據中心寫入的資料是非同步複製到另一個數據中心的,所以在恢復網路連線時,寫入操作只是簡單地排隊並交換。 + +​ 另一方面,如果使用單主複製,則主庫必須位於其中一個數據中心。任何寫入和任何線性一致的讀取請求都必須傳送給該主庫,因此對於連線到從庫所在資料中心的客戶端,這些讀取和寫入請求必須透過網路同步傳送到主庫所在的資料中心。 + +​ 在單主配置的條件下,如果資料中心之間的網路被中斷,則連線到從庫資料中心的客戶端無法聯絡到主庫,因此它們無法對資料庫執行任何寫入,也不能執行任何線性一致的讀取。它們仍能從從庫讀取,但結果可能是陳舊的(非線性一致)。如果應用需要線性一致的讀寫,卻又位於與主庫網路中斷的資料中心,則網路中斷將導致這些應用不可用。 + +如果客戶端可以直接連線到主庫所在的資料中心,這就不是問題了,那些應用可以繼續正常工作。但只能訪問從庫資料中心的客戶端會中斷執行,直到網路連結得到修復。 + +#### CAP定理 + +​ 這個問題不僅僅是單主複製和多主複製的後果:任何線性一致的資料庫都有這個問題,不管它是如何實現的。這個問題也不僅僅侷限於多資料中心部署,而可能發生在任何不可靠的網路上,即使在同一個資料中心內也是如此。問題面臨的權衡如下:[^v] + +* 如果應用需要線性一致性,且某些副本因為網路問題與其他副本斷開連線,那麼這些副本掉線時不能處理請求。請求必須等到網路問題解決,或直接返回錯誤。(無論哪種方式,服務都**不可用(unavailable)**)。 +* 如果應用不需要線性一致性,那麼某個副本即使與其他副本斷開連線,也可以獨立處理請求(例如多主複製)。在這種情況下,應用可以在網路問題前保持可用,但其行為不是線性一致的。 + +[^v]: 這兩種選擇有時分別稱為CP(在網路分割槽下一致但不可用)和AP(在網路分割槽下可用但不一致)。 但是,這種分類方案存在一些缺陷【9】,所以最好不要這樣用。 + +因此不需要線性一致性的應用對網路問題有更強的容錯能力。這種見解通常被稱為CAP定理【29,30,31,32】,由Eric Brewer於2000年命名,儘管70年代的分散式資料庫設計者早就知道了這種權衡【33,34,35,36】。 + +​ CAP最初是作為一個經驗法則提出的,沒有準確的定義,目的是開始討論資料庫的權衡。那時候許多分散式資料庫側重於在共享儲存的叢集上提供線性一致性的語義【18】,CAP定理鼓勵資料庫工程師向分散式無共享系統的設計領域深入探索,這類架構更適合實現大規模的網路服務【37】。 對於這種文化上的轉變,CAP值得讚揚 —— 它見證了自00年代中期以來新資料庫的技術爆炸(即NoSQL)。 + +> ### CAP定理沒有幫助 +> +> CAP有時以這種面目出現:一致性,可用性和分割槽容錯性:三者只能擇其二。不幸的是這種說法很有誤導性【32】,因為網路分割槽是一種錯誤,所以它並不是一個選項:不管你喜不喜歡它都會發生【38】。 +> +> 在網路正常工作的時候,系統可以提供一致性(線性一致性)和整體可用性。發生網路故障時,你必須線上性一致性和整體可用性之間做出選擇。因此,CAP更好的表述成:在分割槽時要麼選擇一致,要麼選擇可用【39】。一個更可靠的網路需要減少這個選擇,但是在某些時候選擇是不可避免的。 +> +> 在CAP的討論中,術語可用性有幾個相互矛盾的定義,形式化作為一個定理【30】並不符合其通常的含義【40】。許多所謂的“高可用”(容錯)系統實際上不符合CAP對可用性的特殊定義。總而言之,圍繞著CAP有很多誤解和困惑,並不能幫助我們更好地理解系統,所以最好避免使用CAP。 + +​ CAP定理的正式定義僅限於很狹隘的範圍【30】,它只考慮了一個一致性模型(即線性一致性)和一種故障(網路分割槽[^vi],或活躍但彼此斷開的節點)。它沒有討論任何關於網路延遲,死亡節點或其他權衡的事。 因此,儘管CAP在歷史上有一些影響力,但對於設計系統而言並沒有實際價值【9,40】。 + +​ 在分散式系統中有更多有趣的“不可能”的結果【41】,且CAP定理現在已經被更精確的結果取代【2,42】,所以它現在基本上成了歷史古蹟了。 + +[^vi]: 正如“[真實世界的網路故障](ch8.md#真實世界的網路故障)”中所討論的,本書使用**分割槽(partition)**指代將大資料集細分為小資料集的操作(分片;參見[第6章](ch6.md))。與之對應的是,**網路分割槽(network partition)**是一種特定型別的網路故障,我們通常不會將其與其他型別的故障分開考慮。但是,由於它是CAP的P,所以這種情況下不能將其混為一談。 + +#### 線性一致性和網路延遲 + +​ 雖然線性一致是一個很有用的保證,但實際上,線性一致的系統驚人的少。例如,現代多核CPU上的記憶體甚至都不是線性一致的【43】:如果一個CPU核上執行的執行緒寫入某個記憶體地址,而另一個CPU核上執行的執行緒不久之後讀取相同的地址,並沒有保證一定能一定讀到第一個執行緒寫入的值(除非使用了**記憶體屏障(memory barrier)**或**圍欄(fence)**【44】)。 + +​ 這種行為的原因是每個CPU核都有自己的記憶體快取和儲存緩衝區。預設情況下,記憶體訪問首先走快取,任何變更會非同步寫入主存。因為快取訪問比主存要快得多【45】,所以這個特性對於現代CPU的良好效能表現至關重要。但是現在就有幾個資料副本(一個在主存中,也許還有幾個在不同快取中的其他副本),而且這些副本是非同步更新的,所以就失去了線性一致性。 + +​ 為什麼要做這個權衡?對多核記憶體一致性模型而言,CAP定理是沒有意義的:在同一臺計算機中,我們通常假定通 信都是可靠的。並且我們並不指望一個CPU核能在脫離計算機其他部分的條件下繼續正常工作。犧牲線性一致性的原因是**效能(performance)**,而不是容錯。 + +​ 許多分散式資料庫也是如此:它們是**為了提高效能**而選擇了犧牲線性一致性,而不是為了容錯【46】。線性一致的速度很慢——這始終是事實,而不僅僅是網路故障期間。 + +​ 能找到一個更高效的線性一致儲存實現嗎?看起來答案是否定的:Attiya和Welch 【47】證明,如果你想要線性一致性,讀寫請求的響應時間至少與網路延遲的不確定性成正比。在像大多數計算機網路一樣具有高度可變延遲的網路中(參見“[超時與無窮的延遲](ch8.md#超時與無窮的延遲)”),線性讀寫的響應時間不可避免地會很高。更快地線性一致演算法不存在,但更弱的一致性模型可以快得多,所以對延遲敏感的系統而言,這類權衡非常重要。在[第12章](ch12.md)中將討論一些在不犧牲正確性的前提下,繞開線性一致性的方法。 + + + +## 順序保證 + +​ 之前說過,線性一致暫存器的行為就好像只有單個數據副本一樣,且每個操作似乎都是在某個時間點以原子性的方式生效的。這個定義意味著操作是按照某種良好定義的順序執行的。我們透過操作(似乎)執行完畢的順序來連線操作,以此說明[圖9-4](img/fig9-4.png)中的順序。 + +**順序(ordering)**這一主題在本書中反覆出現,這表明它可能是一個重要的基礎性概念。讓我們簡要回顧一下其它**順序**曾經出現過的上下文: + +* 在[第5章](ch5.md)中我們看到,領導者在單主複製中的主要目的就是,在複製日誌中確定**寫入順序(order of write)**——也就是從庫應用這些寫入的順序。如果不存在一個領導者,則併發操作可能導致衝突(參閱“[處理寫入衝突](ch5.md#處理寫入衝突)”)。 +* 在[第7章](ch7.md)中討論的**可序列化**,是關於事務表現的像按**某種序列順序(some sequential order)**執行的保證。它可以透過字面意義上地**序列順序(serial order)**執行事務來實現,或者透過允許並行執行,同時防止序列化衝突來實現(透過鎖或中止事務)。 +* 在[第8章](ch8.md)討論過的在分散式系統中使用時間戳和時鐘(參閱“[依賴於同步時鐘](ch8.md#依賴於同步時鐘)”)是另一種將順序引入無序世界的嘗試,例如,確定兩個寫入操作哪一個更晚發生。 + +事實證明,順序,線性一致性和共識之間有著深刻的聯絡。儘管這個概念比本書其他部分更加理論化和抽象,但對於明確系統的能力範圍(可以做什麼和不可以做什麼)而言是非常有幫助的。我們將在接下來的幾節中探討這個話題。 + +### 順序與因果 + +**順序**反覆出現有幾個原因,其中一個原因是,它有助於保持**因果關係(causality)**。在本書中我們已經看到了幾個例子,其中因果關係是很重要的: + +* 在“[一致字首讀](ch5.md#一致字首讀)”([圖5-5](img/fig5-5.png))中,我們看到一個例子:一個對話的觀察者首先看到問題的答案,然後才看到被回答的問題。這是令人困惑的,因為它違背了我們對**因(cause)**與**果(effect)**的直覺:如果一個問題被回答,顯然問題本身得先在那裡,因為給出答案的人必須看到這個問題(假如他們並沒有預見未來的超能力)。我們認為在問題和答案之間存在**因果依賴(causal dependency)**。 +* [圖5-9](img/fig5-9.png)中出現了類似的模式,我們看到三位領導者之間的複製,並注意到由於網路延遲,一些寫入可能會“壓倒”其他寫入。從其中一個副本的角度來看,好像有一個對尚不存在的記錄的更新操作。這裡的因果意味著,一條記錄必須先被建立,然後才能被更新。 +* 在“[檢測併發寫入](ch5.md#檢測併發寫入)”中我們觀察到,如果有兩個操作A和B,則存在三種可能性:A發生在B之前,或B發生在A之前,或者A和B**併發**。這種**此前發生(happened before)**關係是因果關係的另一種表述:如果A在B前發生,那麼意味著B可能已經知道了A,或者建立在A的基礎上,或者依賴於A。如果A和B是**併發**的,那麼它們之間並沒有因果聯絡;換句話說,我們確信A和B不知道彼此。 +* 在事務快照隔離的上下文中(“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”),我們說事務是從一致性快照中讀取的。但此語境中“一致”到底又是什麼意思?這意味著**與因果關係保持一致(consistent with causality)**:如果快照包含答案,它也必須包含被回答的問題【48】。在某個時間點觀察整個資料庫,與因果關係保持一致意味著:因果上在該時間點之前發生的所有操作,其影響都是可見的,但因果上在該時間點之後發生的操作,其影響對觀察者不可見。**讀偏差(read skew)**意味著讀取的資料處於違反因果關係的狀態(不可重複讀,如[圖7-6](img/fig7-6)所示)。 +* 事務之間**寫偏差(write skew)**的例子(參見“[寫偏差和幻象](ch7.md#寫偏差和幻象)”)也說明了因果依賴:在[圖7-8](img/fig7-8.png)中,愛麗絲被允許離班,因為事務認為鮑勃仍在值班,反之亦然。在這種情況下,離班的動作因果依賴於對當前值班情況的觀察。[可序列化的快照隔離](ch7.md#可序列化的快照隔離(SSI))透過跟蹤事務之間的因果依賴來檢測寫偏差。 +* 在愛麗絲和鮑勃看球的例子中([圖9-1](img/fig9-1.png)),在聽到愛麗絲驚呼比賽結果後,鮑勃從伺服器得到陳舊結果的事實違背了因果關係:愛麗絲的驚呼因果依賴於得分宣告,所以鮑勃應該也能在聽到愛麗斯驚呼後查詢到比分。相同的模式在“[跨通道的時序依賴](#跨通道的時序依賴)”一節中,以“影象大小調整服務”的偽裝再次出現。 + +因果關係對事件施加了一種**順序**:因在果之前;訊息傳送在訊息收取之前。而且就像現實生活中一樣,一件事會導致另一件事:某個節點讀取了一些資料然後寫入一些結果,另一個節點讀取其寫入的內容,並依次寫入一些其他內容,等等。這些因果依賴的操作鏈定義了系統中的因果順序,即,什麼在什麼之前發生。 + +如果一個系統服從因果關係所規定的順序,我們說它是**因果一致(causally consistent)**的。例如,快照隔離提供了因果一致性:當你從資料庫中讀取到一些資料時,你一定還能夠看到其因果前驅(假設在此期間這些資料還沒有被刪除)。 + + + +#### 因果順序不是全序的 + +​ **全序(total order)**允許任意兩個元素進行比較,所以如果有兩個元素,你總是可以說出哪個更大,哪個更小。例如,自然數集是全序的:給定兩個自然數,比如說5和13,那麼你可以告訴我,13大於5。 + +​ 然而數學集合並不完全是全序的:`{a, b}` 比 `{b, c}` 更大嗎?好吧,你沒法真正比較它們,因為二者都不是對方的子集。我們說它們是**無法比較(incomparable)**的,因此數學集合是**偏序(partially order)**的:在某些情況下,可以說一個集合大於另一個(如果一個集合包含另一個集合的所有元素),但在其他情況下它們是無法比較的[^譯註i]。 + +[^譯註i]: 設R為非空集合A上的關係,如果R是自反的、反對稱的和可傳遞的,則稱R為A上的偏序關係。簡稱偏序,通常記作≦。一個集合A與A上的偏序關係R一起叫作偏序集,記作$(A,R)$或$(A, ≦)$。全序、偏序、關係、集合,這些概念的精確定義可以參考任意一本離散數學教材。 + +​ 全序和偏序之間的差異反映在不同的資料庫一致性模型中: + +***線性一致性*** + +​ 線上性一致的系統中,操作是全序的:如果系統表現的就好像只有一個數據副本,並且所有操作都是原子性的,這意味著對任何兩個操作,我們總是能判定哪個操作先發生。這個全序[圖9-4](img/fig9-4.png)中以時間線表示。 + +***因果性*** + +​ 我們說過,如果兩個操作都沒有在彼此**之前發生**,那麼這兩個操作是併發的(參閱[“此前發生”的關係和併發](ch5.md#“此前發生”的關係和併發))。換句話說,如果兩個事件是因果相關的(一個發生在另一個事件之前),則它們之間是有序的,但如果它們是併發的,則它們之間的順序是無法比較的。這意味著因果關係定義了一個偏序,而不是一個全序:一些操作相互之間是有順序的,但有些則是無法比較的。 + +​ 因此,根據這個定義,線上性一致的資料儲存中是不存在併發操作的:必須有且僅有一條時間線,所有的操作都在這條時間線上,構成一個全序關係。可能有幾個請求在等待處理,但是資料儲存確保了每個請求都是在唯一時間線上的某個時間點自動處理的,不存在任何併發。 + +​ 併發意味著時間線會分岔然後合併 —— 在這種情況下,不同分支上的操作是無法比較的(即併發操作)。在[第五章](ch5.md)中我們看到了這種現象:例如,[圖5-14](img/fig5-14.md) 並不是一條直線的全序關係,而是一堆不同的操作併發進行。圖中的箭頭指明瞭因果依賴 —— 操作的偏序。 + +​ 如果你熟悉像Git這樣的分散式版本控制系統,那麼其版本歷史與因果關係圖極其相似。通常,一個**提交(Commit)**發生在另一個提交之後,在一條直線上。但是有時你會遇到分支(當多個人同時在一個專案上工作時),**合併(Merge)**會在這些併發建立的提交相融合時建立。 + +#### 線性一致性強於因果一致性 + +​ 那麼因果順序和線性一致性之間的關係是什麼?答案是線性一致性**隱含著(implies)**因果關係:任何線性一致的系統都能正確保持因果性【7】。特別是,如果系統中有多個通訊通道(如[圖9-5](img/fig9-5.png) 中的訊息佇列和檔案儲存服務),線性一致性可以自動保證因果性,系統無需任何特殊操作(如在不同元件間傳遞時間戳)。 + +​ 線性一致性確保因果性的事實使線性一致系統變得簡單易懂,更有吸引力。然而,正如“[線性一致性的代價](#線性一致性的代價)”中所討論的,使系統線性一致可能會損害其效能和可用性,尤其是在系統具有嚴重的網路延遲的情況下(例如,如果系統在地理上散佈)。出於這個原因,一些分散式資料系統已經放棄了線性一致性,從而獲得更好的效能,但它們用起來也更為困難。 + +​ 好訊息是存在折衷的可能性。線性一致性並不是保持因果性的唯一途徑 —— 還有其他方法。一個系統可以是因果一致的,而無需承擔線性一致帶來的效能折損(尤其對於CAP定理不適用的情況)。實際上在所有的不會被網路延遲拖慢的一致性模型中,因果一致性是可行的最強的一致性模型。而且在網路故障時仍能保持可用【2,42】。 + +​ 在許多情況下,看上去需要線性一致性的系統,實際上需要的只是因果一致性,因果一致性可以更高效地實現。基於這種觀察結果,研究人員正在探索新型的資料庫,既能保證因果一致性,且效能與可用性與最終一致的系統類似【49,50,51】。 + +​ 這方面的研究相當新鮮,其中很多尚未應用到生產系統,仍然有不少挑戰需要克服【52,53】。但對於未來的系統而言,這是一個有前景的方向。 + +#### 捕獲因果關係 + +​ 我們不會在這裡討論非線性一致的系統如何保證因果性的細節,而只是簡要地探討一些關鍵的思想。 + +​ 為了維持因果性,你需要知道哪個操作發生在哪個其他操作之前(**happened before**)。這是一個偏序:併發操作可以以任意順序進行,但如果一個操作發生在另一個操作之前,那它們必須在所有副本上以那個順序被處理。因此,當一個副本處理一個操作時,它必須確保所有因果前驅的操作(之前發生的所有操作)已經被處理;如果前面的某個操作丟失了,後面的操作必須等待,直到前面的操作被處理完畢。 + +​ 為了確定因果依賴,我們需要一些方法來描述系統中節點的“知識”。如果節點在發出寫入Y 的請求時已經看到了 X的值,則 X 和 Y 可能存在因果關係。這個分析使用了那些在欺詐指控刑事調查中常見的問題:CEO在做出決定 Y 時是否**知道** X ? + +​ 用於確定*哪些操作發生在其他操作之前* 的技術,與我們在“[檢測併發寫入](ch5.md#檢測併發寫入)”中所討論的內容類似。那一節討論了無領導者資料儲存中的因果性:為了防止丟失更新,我們需要檢測到對同一個鍵的併發寫入。因果一致性則更進一步:它需要跟蹤整個資料庫中的因果依賴,而不僅僅是一個鍵。可以推廣版本向量以解決此類問題【54】。 + +​ 為了確定因果順序,資料庫需要知道應用讀取了哪個版本的資料。這就是為什麼在 [圖5-13 ](img/fig5-13.png)中,來自先前操作的版本號在寫入時被傳回到資料庫的原因。在SSI 的衝突檢測中會出現類似的想法,如“[可序列化的快照隔離(SSI)]()”中所述:當事務要提交時,資料庫將檢查它所讀取的資料版本是否仍然是最新的。為此,資料庫跟蹤哪些資料被哪些事務所讀取。 + + + +### 序列號順序 + +​ 雖然因果是一個重要的理論概念,但實際上跟蹤所有的因果關係是不切實際的。在許多應用中,客戶端在寫入內容之前會先讀取大量資料,我們無法弄清寫入因果依賴於先前全部的讀取內容,還是僅包括其中一部分。顯式跟蹤所有已讀資料意味著巨大的額外開銷。 + +​ 但還有一個更好的方法:我們可以使用**序列號(sequence nunber)**或**時間戳(timestamp)**來排序事件。時間戳不一定來自時鐘(或物理時鐘,存在許多問題,如 “[不可靠時鐘](ch8.md#不可靠的時鐘)” 中所述)。它可以來自一個**邏輯時鐘(logical clock)**,這是一個用來生成標識操作的數字序列的演算法,典型實現是使用一個每次操作自增的計數器。 + + +​ 這樣的序列號或時間戳是緊湊的(只有幾個位元組大小),它提供了一個全序關係:也就是說每操作都有一個唯一的序列號,而且總是可以比較兩個序列號,確定哪一個更大(即哪些操作後發生)。 + +​ 特別是,我們可以使用**與因果一致(consistent with causality)**的全序來生成序列號[^vii]:我們保證,如果操作 A 因果後繼於操作 B,那麼在這個全序中 A 在 B 前( A 具有比 B 更小的序列號)。並行操作之間可以任意排序。這樣一個全序關係捕獲了所有關於因果的資訊,但也施加了一個比因果性要求更為嚴格的順序。 + +[^vii]: 與因果關係不一致的全序很容易建立,但沒啥用。例如你可以為每個操作生成隨機的UUID,並按照字典序比較UUID,以定義操作的全序。這是一個有效的全序,但是隨機的UUID並不能告訴你哪個操作先發生,或者操作是否為併發的。 + +​ 在單主複製的資料庫中(參見“[領導者與追隨者](ch5.md#領導者與追隨者)”),複製日誌定義了與因果一致的寫操作。主庫可以簡單地為每個操作自增一個計數器,從而為複製日誌中的每個操作分配一個單調遞增的序列號。如果一個從庫按照它們在複製日誌中出現的順序來應用寫操作,那麼從庫的狀態始終是因果一致的(即使它落後於領導者)。 + +#### 非因果序列號生成器 + +​ 如果主庫不存在(可能因為使用了多主資料庫或無主資料庫,或者因為使用了分割槽的資料庫),如何為操作生成序列號就沒有那麼明顯了。在實踐中有各種各樣的方法: + +* 每個節點都可以生成自己獨立的一組序列號。例如有兩個節點,一個節點只能生成奇數,而另一個節點只能生成偶數。通常,可以在序列號的二進位制表示中預留一些位,用於唯一的節點識別符號,這樣可以確保兩個不同的節點永遠不會生成相同的序列號。 +* 可以將時鐘(物理時鐘)時間戳附加到每個操作上【55】。這種時間戳並不連續,但是如果它具有足夠高的解析度,那也許足以提供一個操作的全序關係。這一事實應用於 *最後寫入勝利* 的衝突解決方法中(參閱“[有序事件的時間戳](ch8.md#有序事件的時間戳)”)。 +* 可以預先分配序列號區塊。例如,節點 A 可能要求從序列號1到1,000區塊的所有權,而節點 B 可能要求序列號1,001到2,000區塊的所有權。然後每個節點可以獨立分配所屬區塊中的序列號,並在序列號告急時請求分配一個新的區塊。 + +這三個選項都比單一主庫的自增計數器表現要好,並且更具可擴充套件性。它們為每個操作生成一個唯一的,近似自增的序列號。然而它們都有同一個問題:生成的序列號與因果不一致。 + +因為這些序列號生成器不能正確地捕獲跨節點的操作順序,所以會出現因果關係的問題: + +* 每個節點每秒可以處理不同數量的操作。因此,如果一個節點產生偶數序列號而另一個產生奇數序列號,則偶數計數器可能落後於奇數計數器,反之亦然。如果你有一個奇數編號的操作和一個偶數編號的操作,你無法準確地說出哪一個操作在因果上先發生。 + +* 來自物理時鐘的時間戳會受到時鐘偏移的影響,這可能會使其與因果不一致。例如[圖8-3](img/fig8-3.png) 展示了一個例子,其中因果上晚發生的操作,卻被分配了一個更早的時間戳。[^vii] + + [^viii]: 可以使物理時鐘時間戳與因果關係保持一致:在“[用於全域性快照的同步時鐘](#用於全域性快照的同步時鐘)”中,我們討論了Google的Spanner,它可以估計預期的時鐘偏差,並在提交寫入之前等待不確定性間隔。 這中方法確保了實際上靠後的事務會有更大的時間戳。 但是大多數時鐘不能提供這種所需的不確定性度量。 + +* 在分配區塊的情況下,某個操作可能會被賦予一個範圍在1,001到2,000內的序列號,然而一個因果上更晚的操作可能被賦予一個範圍在1到1,000之間的數字。這裡序列號與因果關係也是不一致的。 + + + +#### 蘭伯特時間戳 + +​ 儘管剛才描述的三個序列號生成器與因果不一致,但實際上有一個簡單的方法來產生與因果關係一致的序列號。它被稱為蘭伯特時間戳,萊斯利·蘭伯特(Leslie Lamport)於1978年提出【56】,現在是分散式系統領域中被引用最多的論文之一。 + +​ [圖9-8](img/fig9-8.png) 說明了蘭伯特時間戳的應用。每個節點都有一個唯一識別符號,和一個儲存自己執行運算元量的計數器。 蘭伯特時間戳就是兩者的簡單組合:(計數器,節點ID)$(counter, node ID)$。兩個節點有時可能具有相同的計數器值,但透過在時間戳中包含節點ID,每個時間戳都是唯一的。 + +![](img/fig9-8.png) + +**圖9-8 Lamport時間戳提供了與因果關係一致的總排序。** + + +​ 蘭伯特時間戳與物理時間時鐘沒有任何關係,但是它提供了一個全序:如果你有兩個時間戳,則**計數器**值大者是更大的時間戳。如果計數器值相同,則節點ID越大的,時間戳越大。 + +​ 迄今,這個描述與上節所述的奇偶計數器基本類似。使蘭伯特時間戳因果一致的關鍵思想如下所示:每個節點和每個客戶端跟蹤迄今為止所見到的最大**計數器**值,並在每個請求中包含這個最大計數器值。當一個節點收到最大計數器值大於自身計數器值的請求或響應時,它立即將自己的計數器設定為這個最大值。 + +​ 這如 [圖9-8](img/fig9-8.png) 所示,其中客戶端 A 從節點2 接收計數器值 `5` ,然後將最大值 `5` 傳送到節點1 。此時,節點1 的計數器僅為 `1` ,但是它立即前移至 `5` ,所以下一個操作的計數器的值為 `6` 。 + +​ 只要每一個操作都攜帶著最大計數器值,這個方案確保蘭伯特時間戳的排序與因果一致,因為每個因果依賴都會導致時間戳增長。 + +​ 蘭伯特時間戳有時會與我們在 “[檢測併發寫入](ch5.md#檢測併發寫入)” 中看到的版本向量相混淆。雖然兩者有一些相似之處,但它們有著不同的目的:版本向量可以區分兩個操作是併發的,還是一個因果依賴另一個;而蘭伯特時間戳總是施行一個全序。從蘭伯特時間戳的全序中,你無法分辨兩個操作是併發的還是因果依賴的。 蘭伯特時間戳優於版本向量的地方是,它更加緊湊。 + +#### 光有時間戳排序還不夠 + +​ 雖然蘭伯特時間戳定義了一個與因果一致的全序,但它還不足以解決分散式系統中的許多常見問題。 + +​ 例如,考慮一個需要確保使用者名稱能唯一標識使用者帳戶的系統。如果兩個使用者同時嘗試使用相同的使用者名稱建立帳戶,則其中一個應該成功,另一個應該失敗。 (我們之前在“[領導者與鎖定](ch8.md#領導者與鎖定)”中提到過這個問題。) + +​ 乍看之下,似乎操作的全序關係足以解決這一問題(例如使用蘭伯特時間戳):如果建立了兩個具有相同使用者名稱的帳戶,選擇時間戳較小的那個作為勝者(第一個抓到使用者名稱的人),並讓帶有更大時間戳者失敗。由於時間戳上有全序關係,所以這個比較總是可行的。 + +​ 這種方法適用於事後確定勝利者:一旦你收集了系統中的所有使用者名稱建立操作,就可以比較它們的時間戳。然而當某個節點需要實時處理使用者建立使用者名稱的請求時,這樣的方法就無法滿足了。節點需要**馬上(right now)**決定這個請求是成功還是失敗。在那個時刻,節點並不知道是否存其他節點正在併發執行建立同樣使用者名稱的操作,罔論其它節點可能分配給那個操作的時間戳。 + +​ 為了確保沒有其他節點正在使用相同的使用者名稱和較小的時間戳併發建立同名賬戶,你必須檢查其它每個節點,看看它在做什麼【56】。如果其中一個節點由於網路問題出現故障或不可達,則整個系統可能被拖至停機。這不是我們需要的那種容錯系統。 + +​ 這裡的問題是,只有在所有的操作都被收集之後,操作的全序才會出現。如果另一個節點已經產生了一些操作,但你還不知道那些操作是什麼,那就無法構造所有操作最終的全序關係:來自另一個節點的未知操作可能需要被插入到全序中的不同位置。 + +​ 總之:為了實諸如如使用者名稱上的唯一約束這種東西,僅有操作的全序是不夠的,你還需要知道這個全序何時會塵埃落定。如果你有一個建立使用者名稱的操作,並且確定在全序中,沒有任何其他節點可以在你的操作之前插入對同一使用者名稱的聲稱,那麼你就可以安全地宣告操作執行成功。 + +​ 如何確定全序關係已經塵埃落定,這將在[全序廣播](#全序廣播)一節中詳細說明。 + +### 全序廣播 + +​ 如果你的程式只執行在單個CPU核上,那麼定義一個操作全序是很容易的:可以簡單地就是CPU執行這些操作的順序。但是在分散式系統中,讓所有節點對同一個全域性操作順序達成一致可能相當棘手。在上一節中,我們討論了按時間戳或序列號進行排序,但發現它還不如單主複製給力(如果你使用時間戳排序來實現唯一性約束,而且不能容忍任何錯誤)。 + +​ 如前所述,單主複製透過選擇一個節點作為主庫來確定操作的全序,並在主庫的單個CPU核上對所有操作進行排序。接下來的挑戰是,如果吞吐量超出單個主庫的處理能力,這種情況下如何擴充套件系統;以及,如果主庫失效(“[處理節點宕機](#處理節點宕機)”),如何處理故障切換。在分散式系統文獻中,這個問題被稱為**全序廣播(total order broadcast)**或**原子廣播(atomic broadcast)**[^ix]【25,57,58】。 + +[^ix]: “原子廣播”是一個傳統的術語,非常混亂,而且與“原子”一詞的其他用法不一致:它與ACID事務中的原子性沒有任何關係,只是與原子操作(在多執行緒程式設計的意義上 )或原子暫存器(線性一致儲存)有間接的聯絡。全序廣播是另一個同義詞。 + +> #### 順序保證的範圍 +> +> 每個分割槽各有一個主庫的分割槽資料庫,通常只在每個分割槽內維持順序,這意味著它們不能提供跨分割槽的一致性保證(例如,一致性快照,外來鍵引用)。 跨所有分割槽的全序是可能的,但需要額外的協調【59】。 + +全序廣播通常被描述為在節點間交換訊息的協議。 非正式地講,它要滿足兩個安全屬性: + +***可靠交付(reliable delivery)*** + +​ 沒有訊息丟失:如果訊息被傳遞到一個節點,它將被傳遞到所有節點。 + +***全序交付(totally ordered delivery)*** + +​ 訊息以相同的順序傳遞給每個節點。 + +正確的全序廣播演算法必須始終保證可靠性和有序性,即使節點或網路出現故障。當然在網路中斷的時候,訊息是傳不出去的,但是演算法可以不斷重試,以便在網路最終修復時,訊息能及時透過並送達(當然它們必須仍然按照正確的順序傳遞)。 + +#### 使用全序廣播 + +​ 像ZooKeeper和etcd這樣的共識服務實際上實現了全序廣播。這一事實暗示了全序廣播與共識之間有著緊密聯絡,我們將在本章稍後進行探討。 + +​ 全序廣播正是資料庫複製所需的:如果每個訊息都代表一次資料庫的寫入,且每個副本都按相同的順序處理相同的寫入,那麼副本間將相互保持一致(除了臨時的複製延遲)。這個原理被稱為**狀態機複製(state machine replication)**【60】,我們將在[第11章](ch11.md)中重新回到這個概念。 + +​ 與之類似,可以使用全序廣播來實現可序列化的事務:如“[真的序列執行](ch7.md#真的序列執行)”中所述,如果每個訊息都表示一個確定性事務,以儲存過程的形式來執行,且每個節點都以相同的順序處理這些訊息,那麼資料庫的分割槽和副本就可以相互保持一致【61】。 + +​ 全序廣播的一個重要表現是,順序在訊息送達時被固化:如果後續的訊息已經送達,節點就不允許追溯地將(先前)訊息插入順序中的較早位置。這個事實使得全序廣播比時間戳命令更強。 + +​ 考量全序廣播的另一種方式是,這是一種建立日誌的方式(如在複製日誌,事務日誌或預寫式日誌中):傳遞訊息就像附加寫入日誌。由於所有節點必須以相同的順序傳遞相同的訊息,因此所有節點都可以讀取日誌,並看到相同的訊息序列。 + +​ 全序廣播對於實現提供防護令牌的鎖服務也很有用(參見“[防護令牌](ch8.md#防護令牌)”)。每個獲取鎖的請求都作為一條訊息追加到日誌末尾,並且所有的訊息都按它們在日誌中出現的順序依次編號。序列號可以當成防護令牌用,因為它是單調遞增的。在ZooKeeper中,這個序列號被稱為`zxid` 【15】。 + +#### 使用全序廣播實現線性一致的儲存 + +​ 如 [圖9-4](img/fig9-4.png) 所示,線上性一致的系統中,存在操作的全序。這是否意味著線性一致與全序廣播一樣?不盡然,但兩者之間有者密切的聯絡[^x]。 + +[^x]: 從形式上講,線性一致讀寫暫存器是一個“更容易”的問題。 全序廣播等價於共識【67】,而共識問題在非同步的崩潰-停止模型【68】中沒有確定性的解決方案,而線性一致的讀寫暫存器**可以**在這種模型中實現【23,24,25】。 然而,支援諸如**比較並設定(CAS, compare-and-set)**,或**自增並返回(increment-and-get)**的原子操作使它等價於共識問題【28】。 因此,共識問題與線性一致暫存器問題密切相關。 + +​ 全序廣播是非同步的:訊息被保證以固定的順序可靠地傳送,但是不能保證訊息**何時**被送達(所以一個接收者可能落後於其他接收者)。相比之下,線性一致性是新鮮性的保證:讀取一定能看見最新的寫入值。 + +​ 但如果有了全序廣播,你就可以在此基礎上構建線性一致的儲存。例如,你可以確保使用者名稱能唯一標識使用者帳戶。 + +​ 設想對於每一個可能的使用者名稱,你都可以有一個帶有CAS原子操作的線性一致暫存器。每個暫存器最初的值為空值(表示不使用使用者名稱)。當用戶想要建立一個使用者名稱時,對該使用者名稱的暫存器執行CAS操作,在先前暫存器值為空的條件,將其值設定為使用者的賬號ID。如果多個使用者試圖同時獲取相同的使用者名稱,則只有一個CAS操作會成功,因為其他使用者會看到非空的值(由於線性一致性)。 + +你可以透過將全序廣播當成僅追加日誌【62,63】的方式來實現這種線性一致的CAS操作: + +1. 在日誌中追加一條訊息,試探性地指明你要宣告的使用者名稱。 +2. 讀日誌,並等待你所附加的資訊被回送。[^xi] +3. 檢查是否有任何訊息聲稱目標使用者名稱的所有權。如果這些訊息中的第一條就你自己的訊息,那麼你就成功了:你可以提交聲稱的使用者名稱(也許是透過向日志追加另一條訊息)並向客戶端確認。如果所需使用者名稱的第一條訊息來自其他使用者,則中止操作。 + +[^xi]: 如果你不等待,而是在訊息入隊之後立即確認寫入,則會得到類似於多核x86處理器記憶體的一致性模型【43】。 該模型既不是線性一致的也不是順序一致的。 + +​ 由於日誌項是以相同順序送達至所有節點,因此如果有多個併發寫入,則所有節點會對最先到達者達成一致。選擇衝突寫入中的第一個作為勝利者,並中止後來者,以此確定所有節點對某個寫入是提交還是中止達成一致。類似的方法可以在一個日誌的基礎上實現可序列化的多物件事務【62】。 + +​ 儘管這一過程保證寫入是線性一致的,但它並不保證讀取也是線性一致的 —— 如果你從與日誌非同步更新的儲存中讀取資料,結果可能是陳舊的。 (精確地說,這裡描述的過程提供了**順序一致性(sequential consistency)**【47,64】,有時也稱為**時間線一致性(timeline consistency)**【65,66】,比線性一致性稍微弱一些的保證)。為了使讀取也線性一致,有幾個選項: + +* 你可以透過追加一條訊息,當訊息回送時讀取日誌,執行實際的讀取。訊息在日誌中的位置因此定義了讀取發生的時間點。 (etcd的法定人數讀取有些類似這種情況【16】。) +* 如果日誌允許以線性一致的方式獲取最新日誌訊息的位置,則可以查詢該位置,等待直到該位置前的所有訊息都傳達到你,然後執行讀取。 (這是Zookeeper `sync()` 操作背後的思想【15】)。 +* 你可以從同步更新的副本中進行讀取,因此可以確保結果是最新的。 (這種技術用於鏈式複製【63】;參閱“[複製研究](ch5.md#設定新從庫)”。) + +#### 使用線性一致性儲存實現全序廣播 + +​ 上一節介紹瞭如何從全序廣播構建一個線性一致的CAS操作。我們也可以把它反過來,假設我們有線性一致的儲存,接下來會展示如何在此基礎上構建全序廣播。 + +​ 最簡單的方法是假設你有一個線性一致的暫存器來儲存一個整數,並且有一個原子**自增並返回**操作【28】。或者原子CAS操作也可以完成這項工作。 + +​ 該演算法很簡單:每個要透過全序廣播發送的訊息首先對線性一致暫存器執行**自增並返回**操作。然後將從暫存器獲得的值作為序列號附加到訊息中。然後你可以將訊息傳送到所有節點(重新發送任何丟失的訊息),而收件人將按序列號連續傳送訊息。 + +​ 請注意,與蘭伯特時間戳不同,透過自增線性一致性暫存器獲得的數字形式上是一個沒有間隙的序列。因此,如果一個節點已經發送了訊息 4 並且接收到序列號為 6 的傳入訊息,則它知道它在傳遞訊息 6 之前必須等待訊息 5 。蘭伯特時間戳則與之不同 —— 事實上,這是全序廣播和時間戳排序間的關鍵區別。 + +​ 實現一個帶有原子性**自增並返回**操作的線性一致暫存器有多困難?像往常一樣,如果事情從來不出差錯,那很容易:你可以簡單地把它儲存在單個節點內的變數中。問題在於處理當該節點的網路連線中斷時的情況,並在該節點失效時能恢復這個值【59】。一般來說,如果你對線性一致性的序列號生成器進行深入過足夠深入的思考,你不可避免地會得出一個共識演算法。 + +​ 這並非巧合:可以證明,線性一致的CAS(或自增並返回)暫存器與全序廣播都都等價於**共識**問題【28,67】。也就是說,如果你能解決其中的一個問題,你可以把它轉化成為其他問題的解決方案。這是相當深刻和令人驚訝的洞察! + +現在是時候正面處理共識問題了,我們將在本章的其餘部分進行討論。 + + + +## 分散式事務與共識 + +​ **共識**是分散式計算中最重要也是最基本的問題之一。從表面上看似乎很簡單:非正式地講,目標只是**讓幾個節點達成一致(get serveral nodes to agree on something)**。你也許會認為這不會太難。不幸的是,許多出故障的系統都是因為錯誤地輕信這個問題很容易解決。 + +​ 儘管共識非常重要,但關於它的內容出現在本書的後半部分,因為這個主題非常微妙,欣賞細微之處需要一些必要的知識。即使在學術界,對共識的理解也是在幾十年的過程中逐漸沉澱而來,一路上也有著許多誤解。現在我們已經討論了複製([第5章](ch5.md)),事務([第7章](ch7.md)),系統模型([第8章](ch8.md)),線性一致以及全序([本章](ch9.md)),我們終於準備好解決共識問題了。 + +節點能達成一致,在很多場景下都非常重要,例如: + +***領導選舉*** + +​ 在單主複製的資料庫中,所有節點需要就哪個節點是領導者達成一致。如果一些節點由於網路故障而無法與其他節點通訊,則可能會對領導權的歸屬引起爭議。在這種情況下,共識對於避免錯誤的故障切換非常重要。錯誤的故障切換會導致兩個節點都認為自己是領導者(**腦裂**,參閱“[處理節點宕機](ch5.md#處理節點宕機)”)。如果有兩個領導者,它們都會接受寫入,它們的資料會發生分歧,從而導致不一致和資料丟失。 + +***原子提交*** + +​ 在支援跨多節點或跨多分割槽事務的資料庫中,一個事務可能在某些節點上失敗,但在其他節點上成功。如果我們想要維護事務的原子性(就ACID而言,請參“[原子性](ch7.md#原子性)”),我們必須讓所有節點對事務的結果達成一致:要麼全部中止/回滾(如果出現任何錯誤),要麼它們全部提交(如果沒有出錯)。這個共識的例子被稱為**原子提交(atomic commit)**問題[^xii]。 + + +[^xii]: 原子提交的形式化與共識稍有不同:原子事務只有在**所有**參與者投票提交的情況下才能提交,如果有任何參與者需要中止,則必須中止。 共識則允許就**任意一個**被參與者提出的候選值達成一致。 然而,原子提交和共識可以相互簡化為對方【70,71】。 **非阻塞**原子提交則要比共識更為困難 —— 參閱“[三階段提交](#三階段提交)”。 + +> ### 共識的不可能性 +> +> 你可能已經聽說過作者Fischer,Lynch和Paterson之後的FLP結果【68】,它證明,如果存在節點可能崩潰的風險,則不存在**總是**能夠達成共識的演算法。在分散式系統中,我們必須假設節點可能會崩潰,所以可靠的共識是不可能的。然而這裡我們正在討論達成共識的演算法,到底是怎麼回事? +> +> 答案是FLP結果在**非同步系統模型**中得到了證明(參閱“[系統模型與現實](ch8.md#系統模型與現實)”),這是一種限制性很強的模型,它假定確定性演算法不能使用任何時鐘或超時。如果允許演算法使用**超時**或其他方法來識別可疑的崩潰節點(即使懷疑有時是錯誤的),則共識變為一個可解的問題【67】。即使僅僅允許演算法使用隨機數,也足以繞過這個不可能的結果【69】。 +> +> 因此,FLP是關於共識不可能性的重要理論結果,但現實中的分散式系統通常是可以達成共識的。 + +​ 在本節中,我們將首先更詳細地研究**原子提交**問題。具體來說,我們將討論**兩階段提交(2PC, two-phase commit)**演算法,這是解決原子提交問題最常見的辦法,並在各種資料庫、訊息佇列和應用伺服器中實現。事實證明2PC是一種共識演算法,但不是一個非常好的演算法【70,71】。 + +​ 透過對2PC的學習,我們將繼續努力實現更好的一致性演算法,比如ZooKeeper(Zab)和etcd(Raft)中使用的演算法。 + + + +### 原子提交與二階段提交(2PC) + +​ 在[第7章](ch7.md)中我們瞭解到,事務原子性的目的是在多次寫操作中途出錯的情況下,提供一種簡單的語義。事務的結果要麼是成功提交,在這種情況下,事務的所有寫入都是持久化的;要麼是中止,在這種情況下,事務的所有寫入都被回滾(即撤消或丟棄)。 + +​ 原子性可以防止失敗的事務攪亂資料庫,避免資料庫陷入半成品結果和半更新狀態。這對於多物件事務(參閱“[單物件和多物件操作](ch7.md#單物件和多物件操作)”)和維護次級索引的資料庫尤其重要。每個輔助索引都是與主資料相分離的資料結構—— 因此,如果你修改了一些資料,則還需要在輔助索引中進行相應的更改。原子性確保二級索引與主資料保持一致(如果索引與主資料不一致,就沒什麼用了)。 + +#### 從單節點到分散式原子提交 + +​ 對於在單個數據庫節點執行的事務,原子性通常由儲存引擎實現。當客戶端請求資料庫節點提交事務時,資料庫將使事務的寫入持久化(通常在預寫式日誌中:參閱“[使B樹可靠](ch3.md#使B樹可靠)”),然後將提交記錄追加到磁碟中的日誌裡。如果資料庫在這個過程中間崩潰,當節點重啟時,事務會從日誌中恢復:如果提交記錄在崩潰之前成功地寫入磁碟,則認為事務被提交;否則來自該事務的任何寫入都被回滾。 + +​ 因此,在單個節點上,事務的提交主要取決於資料持久化落盤的**順序**:首先是資料,然後是提交記錄【72】。事務提交或終止的關鍵決定時刻是磁碟完成寫入提交記錄的時刻:在此之前,仍有可能中止(由於崩潰),但在此之後,事務已經提交(即使資料庫崩潰)。因此,是單一的裝置(連線到單個磁碟驅動的控制器,且掛載在單臺機器上)使得提交具有原子性。 + +​ 但是,如果一個事務中涉及多個節點呢?例如,你也許在分割槽資料庫中會有一個多物件事務,或者是一個按關鍵詞分割槽的二級索引(其中索引條目可能位於與主資料不同的節點上;參閱“[分割槽和二級索引](ch6.md#分割槽和二級索引)”)。大多數“NoSQL”分散式資料儲存不支援這種分散式事務,但是很多關係型資料庫叢集支援(參見“[實踐中的分散式事務](#實踐中的分散式事務)”)。 + +​ 在這些情況下,僅向所有節點發送提交請求並獨立提交每個節點的事務是不夠的。這樣很容易發生違反原子性的情況:提交在某些節點上成功,而在其他節點上失敗: + +* 某些節點可能會檢測到約束衝突或衝突,因此需要中止,而其他節點則可以成功進行提交。 +* 某些提交請求可能在網路中丟失,最終由於超時而中止,而其他提交請求則透過。 +* 在提交記錄完全寫入之前,某些節點可能會崩潰,並在恢復時回滾,而其他節點則成功提交。 + +如果某些節點提交了事務,但其他節點卻放棄了這些事務,那麼這些節點就會彼此不一致(如 [圖7-3](img/fig7-3.png) 所示)。而且一旦在某個節點上提交了一個事務,如果事後發現它在其它節點上被中止了,它是無法撤回的。出於這個原因,一旦確定事務中的所有其他節點也將提交,節點就必須進行提交。 + +​ 事務提交必須是不可撤銷的 —— 事務提交之後,你不能改變主意,並追溯性地中止事務。這個規則的原因是,一旦資料被提交,其結果就對其他事務可見,因此其他客戶端可能會開始依賴這些資料。這個原則構成了**讀已提交**隔離等級的基礎,在“[讀已提交](ch7.md#讀已提交)”一節中討論了這個問題。如果一個事務在提交後被允許中止,所有那些讀取了**已提交卻又被追溯宣告不存在資料**的事務也必須回滾。 + +​ (提交事務的結果有可能透過事後執行另一個補償事務來取消【73,74】,但從資料庫的角度來看,這是一個單獨的事務,因此任何關於跨事務正確性的保證都是應用自己的問題。) + +#### 兩階段提交簡介 + +​ **兩階段提交(two-phase commit)**是一種用於實現跨多個節點的原子事務提交的演算法,即確保所有節點提交或所有節點中止。 它是分散式資料庫中的經典演算法【13,35,75】。 2PC在某些資料庫內部使用,也以**XA事務**的形式對應用可用【76,77】(例如Java Transaction API支援)或以SOAP Web服務的`WS-AtomicTransaction` 形式提供給應用【78,79】。 + +[ 圖9-9](img/fig9-9)說明了2PC的基本流程。2PC中的提交/中止過程分為兩個階段(因此而得名),而不是單節點事務中的單個提交請求。 + +![](img/fig9-9.png) + +**圖9-9 兩階段提交(2PC)的成功執行** + +> #### 不要把2PC和2PL搞混了 +> +> 兩階段提交(2PC)和兩階段鎖定(參閱“[兩階段鎖定(2PL)](ch7.md#兩階段鎖定(2PL))”)是兩個完全不同的東西。 2PC在分散式資料庫中提供原子提交,而2PL提供可序列化的隔離等級。為了避免混淆,最好把它們看作完全獨立的概念,並忽略名稱中不幸的相似性。 + +​ 2PC使用一個通常不會出現在單節點事務中的新元件:**協調者(coordinator)**(也稱為**事務管理器(transaction manager)**)。協調者通常在請求事務的相同應用程序中以庫的形式實現(例如,嵌入在Java EE容器中),但也可以是單獨的程序或服務。這種協調者的例子包括Narayana,JOTM,BTM或MSDTC。 + +​ 正常情況下,2PC事務以應用在多個數據庫節點上讀寫資料開始。我們稱這些資料庫節點為**參與者(participants)**。當應用準備提交時,協調者開始階段 1 :它傳送一個**準備(prepare)**請求到每個節點,詢問它們是否能夠提交。然後協調者會跟蹤參與者的響應: + +* 如果所有參與者都回答“是”,表示它們已經準備好提交,那麼協調者在階段 2 發出**提交(commit)**請求,然後提交真正發生。 +* 如果任意一個參與者回覆了“否”,則協調者在階段2 中向所有節點發送**中止(abort)**請求。 + +這個過程有點像西方傳統婚姻儀式:司儀分別詢問新娘和新郎是否要結婚,通常是從兩方都收到“我願意”的答覆。收到兩者的回覆後,司儀宣佈這對情侶成為夫妻:事務就提交了,這一幸福事實會廣播至所有的參與者中。如果新娘與新郎之一沒有回覆”我願意“,婚禮就會中止【73】。 + +#### 系統承諾 + +​ 這個簡短的描述可能並沒有說清楚為什麼兩階段提交保證了原子性,而跨多個節點的一階段提交卻沒有。在兩階段提交的情況下,準備請求和提交請求當然也可以輕易丟失。 2PC又有什麼不同呢? + +為了理解它的工作原理,我們必須更詳細地分解這個過程: + +1. 當應用想要啟動一個分散式事務時,它向協調者請求一個事務ID。此事務ID是全域性唯一的。 +2. 應用在每個參與者上啟動單節點事務,並在單節點事務上捎帶上這個全域性事務ID。所有的讀寫都是在這些單節點事務中各自完成的。如果在這個階段出現任何問題(例如,節點崩潰或請求超時),則協調者或任何參與者都可以中止。 +3. 當應用準備提交時,協調者向所有參與者傳送一個**準備**請求,並打上全域性事務ID的標記。如果任意一個請求失敗或超時,則協調者向所有參與者傳送針對該事務ID的中止請求。 +4. 參與者收到準備請求時,需要確保在任意情況下都的確可以提交事務。這包括將所有事務資料寫入磁碟(出現故障,電源故障,或硬碟空間不足都不能是稍後拒絕提交的理由)以及檢查是否存在任何衝突或違反約束。透過向協調者回答“是”,節點承諾,只要請求,這個事務一定可以不出差錯地提交。換句話說,參與者放棄了中止事務的權利,但沒有實際提交。 +5. 當協調者收到所有準備請求的答覆時,會就提交或中止事務作出明確的決定(只有在所有參與者投贊成票的情況下才會提交)。協調者必須把這個決定寫到磁碟上的事務日誌中,如果它隨後就崩潰,恢復後也能知道自己所做的決定。這被稱為**提交點(commit point)**。 +6. 一旦協調者的決定落盤,提交或放棄請求會發送給所有參與者。如果這個請求失敗或超時,協調者必須永遠保持重試,直到成功為止。沒有回頭路:如果已經做出決定,不管需要多少次重試它都必須被執行。如果參與者在此期間崩潰,事務將在其恢復後提交——由於參與者投了贊成,因此恢復後它不能拒絕提交。 + +因此,該協議包含兩個關鍵的“不歸路”點:當參與者投票“是”時,它承諾它稍後肯定能夠提交(儘管協調者可能仍然選擇放棄)。一旦協調者做出決定,這一決定是不可撤銷的。這些承諾保證了2PC的原子性。 (單節點原子提交將這兩個事件混為一談:將提交記錄寫入事務日誌。) + +​ 回到婚姻的比喻,在說“我是”之前,你和你的新娘/新郎有中止這個事務的自由,透過回覆 “沒門!”(或者有類似效果的話)。然而在說了“我願意”之後,你就不能撤回那個聲明瞭。如果你說“我願意”後暈倒了,沒有聽到司儀說“你們現在是夫妻了”,那也並不會改變事務已經提交的現實。當你稍後恢復意識時,可以透過查詢司儀的全域性事務ID狀態來確定你是否已經成婚,或者你可以等待司儀重試下一次提交請求(因為重試將在你無意識期間一直持續)。 + +#### 協調者失效 + +​ 我們已經討論了在2PC期間,如果參與者之一或網路發生故障時會發生什麼情況:如果任何一個**準備**請求失敗或者超時,協調者就會中止事務。如果任何提交或中止請求失敗,協調者將無條件重試。但是如果協調者崩潰,會發生什麼情況就不太清楚了。 + +​ 如果協調者在傳送**準備**請求之前失敗,參與者可以安全地中止事務。但是,一旦參與者收到了準備請求並投了“是”,就不能再單方面放棄 —— 必須等待協調者回答事務是否已經提交或中止。如果此時協調者崩潰或網路出現故障,參與者什麼也做不了只能等待。參與者的這種事務狀態稱為**存疑(in doubt)**的或**不確定(uncertain)**的。 + +​ 情況如[圖9-10](img/fig9-10) 所示。在這個特定的例子中,協調者實際上決定提交,資料庫2 收到提交請求。但是,協調者在將提交請求傳送到資料庫1 之前發生崩潰,因此資料庫1 不知道是否提交或中止。即使**超時**在這裡也沒有幫助:如果資料庫1 在超時後單方面中止,它將最終與執行提交的資料庫2 不一致。同樣,單方面提交也是不安全的,因為另一個參與者可能已經中止了。 + +![](img/fig9-10.png) + **圖9-10 參與者投贊成票後,協調者崩潰。資料庫1不知道是否提交或中止** + +​ 沒有協調者的訊息,參與者無法知道是提交還是放棄。原則上參與者可以相互溝通,找出每個參與者是如何投票的,並達成一致,但這不是2PC協議的一部分。 + +​ 可以完成2PC的唯一方法是等待協調者恢復。這就是為什麼協調者必須在向參與者傳送提交或中止請求之前,將其提交或中止決定寫入磁碟上的事務日誌:協調者恢復後,透過讀取其事務日誌來確定所有存疑事務的狀態。任何在協調者日誌中沒有提交記錄的事務都會中止。因此,2PC的**提交點**歸結為協調者上的常規單節點原子提交。 + +#### 三階段提交 + +​ 兩階段提交被稱為**阻塞(blocking)**原子提交協議,因為存在2PC可能卡住並等待協調者恢復的情況。理論上,可以使一個原子提交協議變為**非阻塞(nonblocking)**的,以便在節點失敗時不會卡住。但是讓這個協議能在實踐中工作並沒有那麼簡單。 + +​ 作為2PC的替代方案,已經提出了一種稱為**三階段提交(3PC)**的演算法【13,80】。然而,3PC假定網路延遲有界,節點響應時間有限;在大多數具有無限網路延遲和程序暫停的實際系統中(見[第8章](ch8.md)),它並不能保證原子性。 + +​ 通常,非阻塞原子提交需要一個**完美的故障檢測器(perfect failure detector)**【67,71】—— 即一個可靠的機制來判斷一個節點是否已經崩潰。在具有無限延遲的網路中,超時並不是一種可靠的故障檢測機制,因為即使沒有節點崩潰,請求也可能由於網路問題而超時。出於這個原因,2PC仍然被使用,儘管大家都清楚可能存在協調者故障的問題。 + + + +### 實踐中的分散式事務 + +​ 分散式事務的名聲譭譽參半,尤其是那些透過兩階段提交實現的。一方面,它被視作提供了一個難以實現的重要的安全性保證;另一方面,它們因為導致運維問題,造成效能下降,做出超過能力範圍的承諾而飽受批評【81,82,83,84】。許多雲服務由於其導致的運維問題,而選擇不實現分散式事務【85,86】。 + +​ 分散式事務的某些實現會帶來嚴重的效能損失 —— 例如據報告稱,MySQL中的分散式事務比單節點事務慢10倍以上【87】,所以當人們建議不要使用它們時就不足為奇了。兩階段提交所固有的效能成本,大部分是由於崩潰恢復所需的額外強制刷盤(`fsync`)【88】以及額外的網路往返。 + +​ 但我們不應該直接忽視分散式事務,而應當更加仔細地審視這些事務,因為從中可以汲取重要的經驗教訓。首先,我們應該精確地說明“**分散式事務**”的含義。兩種截然不同的分散式事務型別經常被混淆: + +***資料庫內部的分散式事務*** + +​ 一些分散式資料庫(即在其標準配置中使用複製和分割槽的資料庫)支援資料庫節點之間的內部事務。例如,VoltDB和MySQL Cluster的NDB儲存引擎就有這樣的內部事務支援。在這種情況下,所有參與事務的節點都執行相同的資料庫軟體。 + +***異構分散式事務*** + +​ 在**異構(heterogeneous)**事務中,參與者是兩種或以上不同技術:例如來自不同供應商的兩個資料庫,甚至是非資料庫系統(如訊息代理)。跨系統的分散式事務必須確保原子提交,儘管系統可能完全不同。 + +​ 資料庫內部事務不必與任何其他系統相容,因此它們可以使用任何協議,並能針對特定技術進行特定的最佳化。因此資料庫內部的分散式事務通常工作地很好。另一方面,跨異構技術的事務則更有挑戰性。 + +#### 恰好一次的訊息處理 + +​ 異構的分散式事務處理能夠以強大的方式整合不同的系統。例如:訊息佇列中的一條訊息可以被確認為已處理,當且僅當用於處理訊息的資料庫事務成功提交。這是透過在同一個事務中原子提交**訊息確認**和**資料庫寫入**兩個操作來實現的。藉由分散式事務的支援,即使訊息代理和資料庫是在不同機器上執行的兩種不相關的技術,這種操作也是可能的。 + +​ 如果訊息傳遞或資料庫事務任意一者失敗,兩者都會中止,因此訊息代理可能會在稍後安全地重傳訊息。因此,透過原子提交**訊息處理及其副作用**,即使在成功之前需要幾次重試,也可以確保訊息被**有效地(effectively)**恰好處理一次。中止會拋棄部分完成事務所導致的任何副作用。 + +​ 然而,只有當所有受事務影響的系統都使用同樣的**原子提交協議(atomic commit protocl)**時,這樣的分散式事務才是可能的。例如,假設處理訊息的副作用是傳送一封郵件,而郵件伺服器並不支援兩階段提交:如果訊息處理失敗並重試,則可能會發送兩次或更多次的郵件。但如果處理訊息的所有副作用都可以在事務中止時回滾,那麼這樣的處理流程就可以安全地重試,就好像什麼都沒有發生過一樣。 + +​ 在[第11章](ch11.md)中將再次回到”恰好一次“訊息處理的主題。讓我們先來看看允許這種異構分散式事務的原子提交協議。 + +#### XA事務 + +​ *X/Open XA*(**擴充套件架構(eXtended Architecture)**的縮寫)是跨異構技術實現兩階段提交的標準【76,77】。它於1991年推出並得到了廣泛的實現:許多傳統關係資料庫(包括PostgreSQL,MySQL,DB2,SQL Server和Oracle)和訊息代理(包括ActiveMQ,HornetQ,MSMQ和IBM MQ) 都支援XA。 + +​ XA不是一個網路協議——它只是一個用來與事務協調者連線的C API。其他語言也有這種API的繫結;例如在Java EE應用的世界中,XA事務是使用**Java事務API(JTA, Java Transaction API)**實現的,而許多使用**Java資料庫連線(JDBC, Java Database Connectivity)**的資料庫驅動,以及許多使用**Java訊息服務(JMS)**API的訊息代理都支援**Java事務API(JTA)**。 + +​ XA假定你的應用使用網路驅動或客戶端庫來與**參與者**進行通訊(資料庫或訊息服務)。如果驅動支援XA,則意味著它會呼叫XA API 以查明操作是否為分散式事務的一部分 —— 如果是,則將必要的資訊發往資料庫伺服器。驅動還會向協調者暴露回撥介面,協調者可以透過回撥來要求參與者準備,提交或中止。 + +​ 事務協調者需要實現XA API。標準沒有指明應該如何實現,但實際上協調者通常只是一個庫,被載入到發起事務的應用的同一個程序中(而不是單獨的服務)。它在事務中個跟蹤所有的參與者,並在要求它們**準備**之後收集參與者的響應(透過驅動回撥),並使用本地磁碟上的日誌記錄每次事務的決定(提交/中止)。 + +​ 如果應用程序崩潰,或者執行應用的機器報銷了,協調者也隨之往生極樂。然後任何帶有**準備了**但未提交事務的參與者都會在疑慮中卡死。由於協調程式的日誌位於應用伺服器的本地磁碟上,因此必須重啟該伺服器,且協調程式庫必須讀取日誌以恢復每個事務的提交/中止結果。只有這樣,協調者才能使用資料庫驅動的XA回撥來要求參與者提交或中止。資料庫伺服器不能直接聯絡協調者,因為所有通訊都必須透過客戶端庫。 + +#### 懷疑時持有鎖 + +​ 為什麼我們這麼關心存疑事務?系統的其他部分就不能繼續正常工作,無視那些終將被清理的存疑事務嗎? + +​ 問題在於**鎖(locking)**。正如在“[讀已提交](ch7.md#讀已提交)”中所討論的那樣,資料庫事務通常獲取待修改的行上的**行級排他鎖**,以防止髒寫。此外,如果要使用可序列化的隔離等級,則使用兩階段鎖定的資料庫也必須為事務所讀取的行加上共享鎖(參見“[兩階段鎖定(2PL)](ch7.md#兩階段鎖定(2PL))”)。 + +​ 在事務提交或中止之前,資料庫不能釋放這些鎖(如[圖9-9](img/fig9-9.png)中的陰影區域所示)。因此,在使用兩階段提交時,事務必須在整個存疑期間持有這些鎖。如果協調者已經崩潰,需要20分鐘才能重啟,那麼這些鎖將會被持有20分鐘。如果協調者的日誌由於某種原因徹底丟失,這些鎖將被永久持有 —— 或至少在管理員手動解決該情況之前。 + +​ 當這些鎖被持有時,其他事務不能修改這些行。根據資料庫的不同,其他事務甚至可能因為讀取這些行而被阻塞。因此,其他事務沒法兒簡單地繼續它們的業務了 —— 如果它們要訪問同樣的資料,就會被阻塞。這可能會導致應用大面積進入不可用狀態,直到存疑事務被解決。 + +#### 從協調者故障中恢復 + +​ 理論上,如果協調者崩潰並重新啟動,它應該乾淨地從日誌中恢復其狀態,並解決任何存疑事務。然而在實踐中,**孤立(orphaned)**的存疑事務確實會出現【89,90】,即無論出於何種理由,協調者無法確定事務的結果(例如事務日誌已經由於軟體錯誤丟失或損壞)。這些事務無法自動解決,所以它們永遠待在資料庫中,持有鎖並阻塞其他事務。 + +​ 即使重啟資料庫伺服器也無法解決這個問題,因為在2PC的正確實現中,即使重啟也必須保留存疑事務的鎖(否則就會冒有違反原子性保證的風險)。這是一種棘手的情況。 + +​ 唯一的出路是讓管理員手動決定提交還是回滾事務。管理員必須檢查每個存疑事務的參與者,確定是否有任何參與者已經提交或中止,然後將相同的結果應用於其他參與者。解決這個問題潛在地需要大量的人力,並且可能發生在嚴重的生產中斷期間(不然為什麼協調者處於這種糟糕的狀態),並很可能要在巨大精神壓力和時間壓力下完成。 + +​ 許多XA的實現都有一個叫做**啟發式決策(heuristic decistions)**的緊急逃生艙口:允許參與者單方面決定放棄或提交一個存疑事務,而無需協調者做出最終決定【76,77,91】。要清楚的是,這裡**啟發式**是**可能破壞原子性(probably breaking atomicity)**的委婉說法,因為它違背了兩階段提交的系統承諾。因此,啟發式決策只是為了逃出災難性的情況而準備的,而不是為了日常使用的。 + +#### 分散式事務的限制 + +​ XA事務解決了保持多個參與者(資料系統)相互一致的現實的重要問題,但正如我們所看到的那樣,它也引入了嚴重的運維問題。特別來講,這裡的核心認識是:事務協調者本身就是一種資料庫(儲存了事務的結果),因此需要像其他重要資料庫一樣小心地打交道: + +* 如果協調者沒有複製,而是隻在單臺機器上執行,那麼它是整個系統的失效單點(因為它的失效會導致其他應用伺服器阻塞在存疑事務持有的鎖上)。令人驚訝的是,許多協調者實現預設情況下並不是高可用的,或者只有基本的複製支援。 +* 許多伺服器端應用都是使用無狀態模式開發的(受HTTP的青睞),所有持久狀態都儲存在資料庫中,因此具有應用伺服器可隨意按需新增刪除的優點。但是,當協調者成為應用伺服器的一部分時,它會改變部署的性質。突然間,協調者的日誌成為持久系統狀態的關鍵部分—— 與資料庫本身一樣重要,因為協調者日誌是為了在崩潰後恢復存疑事務所必需的。這樣的應用伺服器不再是無狀態的了。 +* 由於XA需要相容各種資料系統,因此它必須是所有系統的最小公分母。例如,它不能檢測不同系統間的死鎖(因為這將需要一個標準協議來讓系統交換每個事務正在等待的鎖的資訊),而且它無法與[SSI ](ch7.md#可序列快照隔離(SSI) )協同工作,因為這需要一個跨系統定位衝突的協議。 +* 對於資料庫內部的分散式事務(不是XA),限制沒有這麼大,例如,分散式版本的SSI 是可能的。然而仍然存在問題:2PC成功提交一個事務需要所有參與者的響應。因此,如果系統的**任何**部分損壞,事務也會失敗。因此,分散式事務又有**擴大失效(amplifying failures)**的趨勢,這又與我們構建容錯系統的目標背道而馳。 + +這些事實是否意味著我們應該放棄保持幾個系統相互一致的所有希望?不完全是 —— 還有其他的辦法,可以讓我們在沒有異構分散式事務的痛苦的情況下實現同樣的事情。我們將在[第11章](ch11.md) 和[第12章](ch12.md) 回到這些章節。但首先,我們應該概括一下關於**共識**的話題。 + + + +### 容錯共識 + +​ 非正式地,共識意味著讓幾個節點就某事達成一致。例如,如果有幾個人**同時(concurrently)**嘗試預訂飛機上的最後一個座位,或劇院中的同一個座位,或者嘗試使用相同的使用者名稱註冊一個帳戶。共識演算法可以用來確定這些**互不相容(mutually incompatible)**的操作中,哪一個才是贏家。 + +​ 共識問題通常形式化如下:一個或多個節點可以**提議(propose)**某些值,而共識演算法**決定(decides)**採用其中的某個值。在座位預訂的例子中,當幾個顧客同時試圖訂購最後一個座位時,處理顧客請求的每個節點可以**提議**正在服務的顧客的ID,而**決定**指明瞭哪個顧客獲得了座位。 + +在這種形式下,共識演算法必須滿足以下性質【25】:[^xiii] + +[^xiii]: 這種共識的特殊形式被稱為**統一共識(uniform consensus)**,相當於在具有不可靠故障檢測器的非同步系統中的**常規共識(regular consensus)**【71】。學術文獻通常指的是**程序(process)**而不是節點,但我們在這裡使用**節點(node)**來與本書的其餘部分保持一致。 + +***一致同意(Uniform agreement)*** + +​ 沒有兩個節點的決定不同。 + +***完整性(Integrity)*** + +​ 沒有節點決定兩次。 + +***有效性(Validity)*** + +​ 如果一個節點決定了值 `v` ,則 `v` 由某個節點所提議。 + +***終止(Termination)*** + 由所有未崩潰的節點來最終決定值。 + +**一致同意**和**完整性**屬性定義了共識的核心思想:所有人都決定了相同的結果,一旦決定了,你就不能改變主意。**有效性**屬性主要是為了排除平凡的解決方案:例如,無論提議了什麼值,你都可以有一個始終決定值為`null`的演算法。;該演算法滿足**一致同意**和**完整性**屬性,但不滿足**有效性**屬性。 + +​ 如果你不關心容錯,那麼滿足前三個屬性很容易:你可以將一個節點硬編碼為“獨裁者”,並讓該節點做出所有的決定。但如果該節點失效,那麼系統就無法再做出任何決定。事實上,這就是我們在兩階段提交的情況中所看到的:如果協調者失效,那麼存疑的參與者就無法決定提交還是中止。 + +​ **終止**屬性正式形成了容錯的思想。它實質上說的是,一個共識演算法不能簡單地永遠閒坐著等死 —— 換句話說,它必須取得進展。即使部分節點出現故障,其他節點也必須達成一項決定。 (**終止**是一種**活性屬性**,而另外三種是安全屬性 —— 參見“[安全性和活性](ch8.md#安全性和活性)”。) + +​ 共識的系統模型假設,當一個節點“崩潰”時,它會突然消失而且永遠不會回來。(不像軟體崩潰,想象一下地震,包含你的節點的資料中心被山體滑坡所摧毀,你必須假設節點被埋在30英尺以下的泥土中,並且永遠不會重新上線)在這個系統模型中,任何需要等待節點恢復的演算法都不能滿足**終止**屬性。特別是,2PC不符合終止屬性的要求。 + +​ 當然如果**所有**的節點都崩潰了,沒有一個在執行,那麼所有演算法都不可能決定任何事情。演算法可以容忍的失效數量是有限的:事實上可以證明,任何共識演算法都需要至少佔總體**多數(majority)**的節點正確工作,以確保終止屬性【67】。多數可以安全地組成法定人數(參閱“[讀寫的法定人數](ch5.md#讀寫的法定人數)”)。 + +​ 因此**終止**屬性取決於一個假設,**不超過一半的節點崩潰或不可達**。然而即使多數節點出現故障或存在嚴重的網路問題,絕大多數共識的實現都能始終確保安全屬性得到滿足—— 一致同意,完整性和有效性【92】。因此,大規模的中斷可能會阻止系統處理請求,但是它不能透過使系統做出無效的決定來破壞共識系統。 + +​ 大多數共識演算法假設不存在**拜占庭式錯誤**,正如在“[拜占庭式錯誤](#拜占庭式錯誤)”一節中所討論的那樣。也就是說,如果一個節點沒有正確地遵循協議(例如,如果它向不同節點發送矛盾的訊息),它就可能會破壞協議的安全屬性。克服拜占庭故障,穩健地達成共識是可能的,只要少於三分之一的節點存在拜占庭故障【25,93】。但我們沒有地方在本書中詳細討論這些演算法了。 + +#### 共識演算法和全序廣播 + +​ 最著名的容錯共識演算法是**檢視戳複製(VSR, viewstamped replication)**【94,95】,Paxos 【96,97,98,99】,Raft 【22,100,101】以及 Zab 【15,21,102】 。這些演算法之間有不少相似之處,但它們並不相同【103】。在本書中我們不會介紹各種演算法的詳細細節:瞭解一些它們共通的高階思想通常已經足夠了,除非你準備自己實現一個共識系統。(可能並不明智,相當難【98,104】) + +​ 大多數這些演算法實際上並不直接使用這裡描述的形式化模型(提議與決定單個值,一致同意,完整性,有效性和終止屬性)。取而代之的是,它們決定了值的**順序(sequence)**,這使它們成為全序廣播演算法,正如本章前面所討論的那樣(參閱“[全序廣播](#全序廣播)”)。 + +​ 請記住,全序廣播要求將訊息按照相同的順序,恰好傳遞一次,準確傳送到所有節點。如果仔細思考,這相當於進行了幾輪共識:在每一輪中,節點提議下一條要傳送的訊息,然後決定在全序中下一條要傳送的訊息【67】。 + +所以,全序廣播相當於重複進行多輪共識(每次共識決定與一次訊息傳遞相對應): + +* 由於**一致同意**屬性,所有節點決定以相同的順序傳遞相同的訊息。 + + +* 由於**完整性**屬性,訊息不會重複。 +* 由於**有效性**屬性,訊息不會被損壞,也不能憑空編造。 +* 由於**終止**屬性,訊息不會丟失。 + +檢視戳複製,Raft和Zab直接實現了全序廣播,因為這樣做比重複**一次一值(one value a time)**的共識更高效。在Paxos的情況下,這種最佳化被稱為Multi-Paxos。 + +#### 單領導者複製和共識 + +​ 在[第5章](ch5.md)中,我們討論了單領導者複製(參見“[領導者和追隨者](ch5.md#領導者和追隨者)”),它將所有的寫入操作都交給主庫,並以相同的順序將它們應用到從庫,從而使副本保持在最新狀態。這實際上不就是一個全序廣播嗎?為什麼我們在[第五章](ch5.md)裡一點都沒擔心過共識問題呢? + +​ 答案取決於如何選擇領導者。如果主庫是由運維人員手動選擇和配置的,那麼你實際上擁有一種**獨裁型別**的“共識演算法”:只有一個節點被允許接受寫入(即決定寫入複製日誌的順序),如果該節點發生故障,則系統將無法寫入,直到運維手動配置其他節點作為主庫。這樣的系統在實踐中可以表現良好,但它無法滿足共識的**終止**屬性,因為它需要人為干預才能取得**進展**。 + +​ 一些資料庫會自動執行領導者選舉和故障切換,如果舊主庫失效,會提拔一個從庫為新主庫(參見“[處理節點宕機](ch5.md#處理節點宕機)”)。這使我們向容錯的全序廣播更進一步,從而達成共識。 + +​ 但是還有一個問題。我們之前曾經討論過腦裂的問題,並且說過所有的節點都需要同意是誰領導,否則兩個不同的節點都會認為自己是領導者,從而導致資料庫進入不一致的狀態。因此,選出一位領導者需要共識。但如果這裡描述的共識演算法實際上是全序廣播演算法,並且全序廣播就像單主複製,而單主複製需要一個領導者,那麼... + +​ 這樣看來,要選出一個領導者,我們首先需要一個領導者。要解決共識問題,我們首先需要解決共識問題。我們如何跳出這個先有雞還是先有蛋的問題? + +#### 時代編號和法定人數 + +​ 迄今為止所討論的所有共識協議,在內部都以某種形式使用一個領導者,但它們並不能保證領導者是獨一無二的。相反,它們可以做出更弱的保證:協議定義了一個**時代編號(epoch number)**(在Paxos中稱為**投票編號(ballot number)**,檢視戳複製中的**檢視編號(view number)**,以及Raft中的**任期號碼(term number)**),並確保在每個時代中,領導者都是唯一的。 + +​ 每次當現任領導被認為掛掉的時候,節點間就會開始一場投票,以選出一個新領導。這次選舉被賦予一個遞增的時代編號,因此時代編號是全序且單調遞增的。如果兩個不同的時代的領導者之間出現衝突(也許是因為前任領導者實際上並未死亡),那麼帶有更高時代編號的領導說了算。 + +​ 在任何領導者被允許決定任何事情之前,必須先檢查是否存在其他帶有更高時代編號的領導者,它們可能會做出相互衝突的決定。領導者如何知道自己沒有被另一個節點趕下臺?回想一下在“[真理在多數人手中](ch8.md#真理在多數人手中)”中提到的:一個節點不一定能相信自己的判斷—— 因為只有節點自己認為自己是領導者,並不一定意味著其他節點接受它作為它們的領導者。 + +​ 相反,它必須從**法定人數(quorum)**的節點中獲取選票(參閱“[讀寫的法定人數](ch5.md#讀寫的法定人數)”)。對領導者想要做出的每一個決定,都必須將提議值傳送給其他節點,並等待法定人數的節點響應並贊成提案。法定人數通常(但不總是)由多數節點組成【105】。只有在沒有意識到任何帶有更高時代編號的領導者的情況下,一個節點才會投票贊成提議。 + +​ 因此,我們有兩輪投票:第一次是為了選出一位領導者,第二次是對領導者的提議進行表決。關鍵的洞察在於,這兩次投票的**法定人群**必須相互**重疊(overlap)**:如果一個提案的表決透過,則至少得有一個參與投票的節點也必須參加過最近的領導者選舉【105】。因此,如果在一個提案的表決過程中沒有出現更高的時代編號。那麼現任領導者就可以得出這樣的結論:沒有發生過更高時代的領導選舉,因此可以確定自己仍然在領導。然後它就可以安全地對提議值做出決定。 + +​ 這一投票過程表面上看起來很像兩階段提交。最大的區別在於,2PC中協調者不是由選舉產生的,而且2PC則要求**所有**參與者都投贊成票,而容錯共識演算法只需要多數節點的投票。而且,共識演算法還定義了一個恢復過程,節點可以在選舉出新的領導者之後進入一個一致的狀態,確保始終能滿足安全屬性。這些區別正是共識演算法正確性和容錯性的關鍵。 + +#### 共識的侷限性 + +​ 共識演算法對於分散式系統來說是一個巨大的突破:它為其他充滿不確定性的系統帶來了基礎的安全屬性(一致同意,完整性和有效性),然而它們還能保持容錯(只要多數節點正常工作且可達,就能取得進展)。它們提供了全序廣播,因此它們也可以以一種容錯的方式實現線性一致的原子操作(參見“[使用全序廣播實現線性一致性儲存](#使用全序廣播實現線性一致性儲存)”)。 + +​ 儘管如此,它們並不是在所有地方都用上了,因為好處總是有代價的。 + +​ 節點在做出決定之前對提議進行投票的過程是一種同步複製。如“[同步與非同步複製](ch5.md#同步與非同步複製)”中所述,通常資料庫會配置為非同步複製模式。在這種配置中發生故障切換時,一些已經提交的資料可能會丟失 —— 但是為了獲得更好的效能,許多人選擇接受這種風險。 + +​ 共識系統總是需要嚴格多數來運轉。這意味著你至少需要三個節點才能容忍單節點故障(其餘兩個構成多數),或者至少有五個節點來容忍兩個節點發生故障(其餘三個構成多數)。如果網路故障切斷了某些節點同其他節點的連線,則只有多數節點所在的網路可以繼續工作,其餘部分將被阻塞(參閱“[線性一致性的代價](#線性一致性的代價)”)。 + +​ 大多數共識演算法假定參與投票的節點是固定的集合,這意味著你不能簡單的在叢集中新增或刪除節點。共識演算法的**動態成員擴充套件(dynamic membership extension)**允許叢集中的節點集隨時間推移而變化,但是它們比靜態成員演算法要難理解得多。 + +​ 共識系統通常依靠超時來檢測失效的節點。在網路延遲高度變化的環境中,特別是在地理上散佈的系統中,經常發生一個節點由於暫時的網路問題,錯誤地認為領導者已經失效。雖然這種錯誤不會損害安全屬性,但頻繁的領導者選舉會導致糟糕的效能表現,因系統最後可能花在權力傾紮上的時間要比花在建設性工作的多得多。 + +​ 有時共識演算法對網路問題特別敏感。例如Raft已被證明存在讓人不悅的極端情況【106】:如果整個網路工作正常,但只有一條特定的網路連線一直不可靠,Raft可能會進入領導頻繁二人轉的局面,或者當前領導者不斷被迫辭職以致系統實質上毫無進展。其他一致性演算法也存在類似的問題,而設計能健壯應對不可靠網路的演算法仍然是一個開放的研究問題。 + +### 成員與協調服務 + +​ 像ZooKeeper或etcd這樣的專案通常被描述為“分散式鍵值儲存”或“協調與配置服務”。這種服務的API看起來非常像資料庫:你可以讀寫給定鍵的值,並遍歷鍵。所以如果它們基本上算是資料庫的話,為什麼它們要把工夫全花在實現一個共識演算法上呢?是什麼使它們區別於其他任意型別的資料庫? + +​ 為了理解這一點,簡單瞭解如何使用ZooKeeper這類服務是很有幫助的。作為應用開發人員,你很少需要直接使用ZooKeeper,因為它實際上不適合當成通用資料庫來用。更有可能的是,你會透過其他專案間接依賴它,例如HBase,Hadoop YARN,OpenStack Nova和Kafka都依賴ZooKeeper在後臺執行。這些專案從它那裡得到了什麼? + +​ ZooKeeper和etcd被設計為容納少量完全可以放在記憶體中的資料(雖然它們仍然會寫入磁碟以保證永續性),所以你不會想著把所有應用資料放到這裡。這些少量資料會透過容錯的全序廣播演算法複製到所有節點上。正如前面所討論的那樣,資料庫複製需要的就是全序廣播:如果每條訊息代表對資料庫的寫入,則以相同的順序應用相同的寫入操作可以使副本之間保持一致。 + +​ ZooKeeper模仿了Google的Chubby鎖服務【14,98】,不僅實現了全序廣播(因此也實現了共識),而且還構建了一組有趣的其他特性,這些特性在構建分散式系統時變得特別有用: + +***線性一致性的原子操作*** + +​ 使用原子CAS操作可以實現鎖:如果多個節點同時嘗試執行相同的操作,只有一個節點會成功。共識協議保證了操作的原子性和線性一致性,即使節點發生故障或網路在任意時刻中斷。分散式鎖通常以**租約(lease)**的形式實現,租約有一個到期時間,以便在客戶端失效的情況下最終能被釋放(參閱“[程序暫停](ch8.md#程序暫停)”)。 + +***操作的全序排序*** + +如“[領導者與鎖定](ch8.md#領導者與鎖定)”中所述,當某個資源受到鎖或租約的保護時,你需要一個防護令牌來防止客戶端在程序暫停的情況下彼此衝突。防護令牌是每次鎖被獲取時單調增加的數字。 ZooKeeper透過全域性排序操作來提供這個功能,它為每個操作提供一個單調遞增的事務ID(`zxid`)和版本號(`cversion`)【15】。 + +***失效檢測*** + +​ 客戶端在ZooKeeper伺服器上維護一個長期會話,客戶端和伺服器週期性地交換心跳包來檢查節點是否還活著。即使連線暫時中斷,或者ZooKeeper節點失效,會話仍保持在活躍狀態。但如果心跳停止的持續時間超出會話超時,ZooKeeper會宣告該會話已死亡。當會話超時(ZooKeeper呼叫這些臨時節點)時,會話持有的任何鎖都可以配置為自動釋放(ZooKeeper稱之為**臨時節點(ephemeral nodes)**)。 + +***變更通知*** + +​ 客戶端不僅可以讀取其他客戶端建立的鎖和值,還可以監聽它們的變更。因此,客戶端可以知道另一個客戶端何時加入叢集(基於新客戶端寫入ZooKeeper的值),或發生故障(因其會話超時,而其臨時節點消失)。透過訂閱通知,客戶端不用再透過頻繁輪詢的方式來找出變更。 + +​ 在這些功能中,只有線性一致的原子操作才真的需要共識。但正是這些功能的組合,使得像ZooKeeper這樣的系統在分散式協調中非常有用。 + +#### 將工作分配給節點 + +​ ZooKeeper/Chubby模型執行良好的一個例子是,如果你有幾個程序例項或服務,需要選擇其中一個例項作為主庫或首選服務。如果領導者失敗,其他節點之一應該接管。這對單主資料庫當然非常實用,但對作業排程程式和類似的有狀態系統也很好用。 + +​ 另一個例子是,當你有一些分割槽資源(資料庫,訊息流,檔案儲存,分散式Actor系統等),並需要決定將哪個分割槽分配給哪個節點時。當新節點加入叢集時,需要將某些分割槽從現有節點移動到新節點,以便重新平衡負載(參閱“[重新平衡分割槽](ch6.md#重新平衡分割槽)”)。當節點被移除或失效時,其他節點需要接管失效節點的工作。 + +​ 這類任務可以透過在ZooKeeper中明智地使用原子操作,臨時節點與通知來實現。如果設計得當,這種方法允許應用自動從故障中恢復而無需人工干預。不過這並不容易,儘管已經有不少在ZooKeeper客戶端API基礎之上提供更高層工具的庫,例如Apache Curator 【17】。但它仍然要比嘗試從頭實現必要的共識演算法要好得多,這樣的嘗試鮮有成功記錄【107】。 + +​ 應用最初只能在單個節點上執行,但最終可能會增長到數千個節點。試圖在如此之多的節點上進行多數投票將是非常低效的。相反,ZooKeeper在固定數量的節點(通常是三到五個)上執行,並在這些節點之間執行其多數票,同時支援潛在的大量客戶端。因此,ZooKeeper提供了一種將協調節點(共識,操作排序和故障檢測)的一些工作“外包”到外部服務的方式。 + +​ 通常,由ZooKeeper管理的資料的型別變化十分緩慢:代表“分割槽 7 中的節點執行在 `10.1.1.23` 上”的資訊可能會在幾分鐘或幾小時的時間內發生變化。它不是用來儲存應用的執行時狀態的,每秒可能會改變數千甚至數百萬次。如果應用狀態需要從一個節點複製到另一個節點,則可以使用其他工具(如Apache BookKeeper 【108】)。 + +#### 服務發現 + +​ ZooKeeper,etcd和Consul也經常用於服務發現——也就是找出你需要連線到哪個IP地址才能到達特定的服務。在雲資料中心環境中,虛擬機器連續來去常見,你通常不會事先知道服務的IP地址。相反,你可以配置你的服務,使其在啟動時註冊服務登錄檔中的網路端點,然後可以由其他服務找到它們。 + +​ 但是,服務發現是否需要達成共識還不太清楚。 DNS是查詢服務名稱的IP地址的傳統方式,它使用多層快取來實現良好的效能和可用性。從DNS讀取是絕對不線性一致性的,如果DNS查詢的結果有點陳舊,通常不會有問題【109】。 DNS的可用性和對網路中斷的魯棒性更重要。 + +​ 儘管服務發現並不需要共識,但領導者選舉卻是如此。因此,如果你的共識系統已經知道領導是誰,那麼也可以使用這些資訊來幫助其他服務發現領導是誰。為此,一些共識系統支援只讀快取副本。這些副本非同步接收共識演算法所有決策的日誌,但不主動參與投票。因此,它們能夠提供不需要線性一致性的讀取請求。 + +#### 成員服務 + +​ ZooKeeper和它的小夥伴們可以看作是成員服務研究的悠久歷史的一部分,這個歷史可以追溯到20世紀80年代,並且對建立高度可靠的系統(例如空中交通管制)非常重要【110】。 + +​ 成員資格服務確定哪些節點當前處於活動狀態並且是群集的活動成員。正如我們在[第8章](ch8.md)中看到的那樣,由於無限的網路延遲,無法可靠地檢測到另一個節點是否發生故障。但是,如果你透過一致的方式進行故障檢測,那麼節點可以就哪些節點應該被認為是存在或不存在達成一致。 + +​ 即使它確實存在,仍然可能發生一個節點被共識錯誤地宣告死亡。但是對於一個系統來說,在哪些節點構成當前的成員關係方面是非常有用的。例如,選擇領導者可能意味著簡單地選擇當前成員中編號最小的成員,但如果不同的節點對現有成員的成員有不同意見,則這種方法將不起作用。 + + + +## 本章小結 + +​ 在本章中,我們從幾個不同的角度審視了關於一致性與共識的話題。我們深入研究了線性一致性(一種流行的一致性模型):其目標是使多副本資料看起來好像只有一個副本一樣,並使其上所有操作都原子性地生效。雖然線性一致性因為簡單易懂而很吸引人 —— 它使資料庫表現的好像單執行緒程式中的一個變數一樣,但它有著速度緩慢的缺點,特別是在網路延遲很大的環境中。 + +​ 我們還探討了因果性,因果性對系統中的事件施加了順序(什麼發生在什麼之前,基於因與果)。與線性一致不同,線性一致性將所有操作放在單一的全序時間線中,因果一致性為我們提供了一個較弱的一致性模型:某些事件可以是**併發**的,所以版本歷史就像是一條不斷分叉與合併的時間線。因果一致性沒有線性一致性的協調開銷,而且對網路問題的敏感性要低得多。 + +​ 但即使捕獲到因果順序(例如使用蘭伯特時間戳),我們發現有些事情也不能透過這種方式實現:在“[光有時間戳排序還不夠](#光有時間戳排序還不夠)”一節的例子中,我們需要確保使用者名稱是唯一的,並拒絕同一使用者名稱的其他併發註冊。如果一個節點要透過註冊,則需要知道其他的節點沒有在併發搶注同一使用者名稱的過程中。這個問題引領我們走向**共識**。 + +​ 我們看到,達成共識意味著以這樣一種方式決定某件事:所有節點一致同意所做決定,且這一決定不可撤銷。透過深入挖掘,結果我們發現很廣泛的一系列問題實際上都可以歸結為共識問題,並且彼此等價(從這個意義上來講,如果你有其中之一的解決方案,就可以輕易將它轉換為其他問題的解決方案)。這些等價的問題包括: + +***線性一致性的CAS暫存器*** + +​ 暫存器需要基於當前值是否等於操作給出的引數,原子地**決定**是否設定新值。 + +***原子事務提交*** + +​ 資料庫必須**決定**是否提交或中止分散式事務。 + +***全序廣播*** + +​ 訊息系統必須**決定**傳遞訊息的順序。 + +***鎖和租約*** + +​ 當幾個客戶端爭搶鎖或租約時,由鎖來**決定**哪個客戶端成功獲得鎖。 + +***成員/協調服務*** + +​ 給定某種故障檢測器(例如超時),系統必須**決定**哪些節點活著,哪些節點因為會話超時需要被宣告死亡。 + +***唯一性約束*** + +​ 當多個事務同時嘗試使用相同的鍵建立衝突記錄時,約束必須**決定**哪一個被允許,哪些因為違反約束而失敗。 + +如果你只有一個節點,或者你願意將決策的權能分配給單個節點,所有這些事都很簡單。這就是在單領導者資料庫中發生的事情:所有決策權歸屬於領導者,這就是為什麼這樣的資料庫能夠提供線性一致的操作,唯一性約束,完全有序的複製日誌,以及更多。 + +​ 但如果該領導者失效,或者如果網路中斷導致領導者不可達,這樣的系統就無法取得任何進展。應對這種情況可以有三種方法: + +1. 等待領導者恢復,接受系統將在這段時間阻塞的事實。許多XA/JTA事務協調者選擇這個選項。這種方法並不能完全達成共識,因為它不能滿足**終止**屬性的要求:如果領導者續命失敗,系統可能會永久阻塞。 +2. 人工故障切換,讓人類選擇一個新的領導者節點,並重新配置系統使之生效,許多關係型資料庫都採用這種方方式。這是一種來自“天意”的共識 —— 由計算機系統之外的運維人員做出決定。故障切換的速度受到人類行動速度的限制,通常要比計算機慢(得多)。 +3. 使用演算法自動選擇一個新的領導者。這種方法需要一種共識演算法,使用成熟的演算法來正確處理惡劣的網路條件是明智之舉【107】。 + +儘管單領導者資料庫可以提供線性一致性,且無需對每個寫操作都執行共識演算法,但共識對於保持及變更領導權仍然是必須的。因此從某種意義上說,使用單個領導者不過是“緩兵之計”:共識仍然是需要的,只是在另一個地方,而且沒那麼頻繁。好訊息是,容錯的共識演算法與容錯的共識系統是存在的,我們在本章中簡要地討論了它們。 + +​ 像ZooKeeper這樣的工具為應用提供了“外包”的共識、故障檢測和成員服務。它們扮演了重要的角色,雖說使用不易,但總比自己去開發一個能經受[第8章](ch8.md)中所有問題考驗的演算法要好得多。如果你發現自己想要解決的問題可以歸結為共識,並且希望它能容錯,使用一個類似ZooKeeper的東西是明智之舉。 + +​ 儘管如此,並不是所有系統都需要共識:例如,無領導者複製和多領導者複製系統通常不會使用全域性的共識。這些系統中出現的衝突(參見“[處理衝突](ch5.md#處理衝突)”)正是不同領導者之間沒有達成共識的結果,但也這並沒有關係:也許我們只是需要接受沒有線性一致性的事實,並學會更好地與具有分支與合併版本歷史的資料打交道。 + +​ 本章引用了大量關於分散式系統理論的研究。雖然理論論文和證明並不總是容易理解,有時也會做出不切實際的假設,但它們對於指導這一領域的實踐有著極其重要的價值:它們幫助我們推理什麼可以做,什麼不可以做,幫助我們找到反直覺的分散式系統缺陷。如果你有時間,這些參考資料值得探索。 + +​ 這裡已經到了本書[第二部分](part-ii.md)的末尾,第二部介紹了複製([第5章](ch5.md)),分割槽([第6章](ch6.md)),事務([第7章](ch7.md)),分散式系統的故障模型([第8章](ch8.md))以及最後的一致性與共識([第9章](ch9.md))。現在我們已經奠定了紮實的理論基礎,我們將在[第三部分](part-iii.md)再次轉向更實際的系統,並討論如何使用異構的元件積木塊構建強大的應用。 + + + +## 參考文獻 + + +1. Peter Bailis and Ali Ghodsi: “[Eventual Consistency Today: Limitations, Extensions, and Beyond](http://queue.acm.org/detail.cfm?id=2462076),” *ACM Queue*, volume 11, number 3, pages 55-63, March 2013. [doi:10.1145/2460276.2462076](http://dx.doi.org/10.1145/2460276.2462076) + +1. Prince Mahajan, Lorenzo Alvisi, and Mike Dahlin: “[Consistency, Availability, and Convergence](http://apps.cs.utexas.edu/tech_reports/reports/tr/TR-2036.pdf),” University of Texas at Austin, Department of Computer Science, Tech Report UTCS TR-11-22, May 2011. + +1. Alex Scotti: “[Adventures in Building Your Own Database](http://www.slideshare.net/AlexScotti1/allyourbase-55212398),” at *All Your Base*, November 2015. + +1. Peter Bailis, Aaron Davidson, Alan Fekete, et al.: “[Highly Available Transactions: Virtues and Limitations](http://arxiv.org/pdf/1302.0309.pdf),” at *40th International Conference on Very Large Data Bases* (VLDB), September 2014. Extended version published as pre-print arXiv:1302.0309 [cs.DB]. + +1. Paolo Viotti and Marko Vukolić: “[Consistency in Non-Transactional Distributed Storage Systems](http://arxiv.org/abs/1512.00168),” arXiv:1512.00168, 12 April 2016. + +1. Maurice P. Herlihy and Jeannette M. Wing: “[Linearizability: A Correctness Condition for Concurrent Objects](http://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf),” *ACM Transactions on Programming Languages and Systems* (TOPLAS), volume 12, number 3, pages 463–492, July 1990. [doi:10.1145/78969.78972](http://dx.doi.org/10.1145/78969.78972) + +1. Leslie Lamport: “[On interprocess communication](http://research.microsoft.com/en-us/um/people/lamport/pubs/interprocess.pdf),” *Distributed Computing*, volume 1, number 2, pages 77–101, June 1986. [doi:10.1007/BF01786228](http://dx.doi.org/10.1007/BF01786228) + +1. David K. Gifford: “[Information Storage in a Decentralized Computer System](http://www.mirrorservice.org/sites/www.bitsavers.org/pdf/xerox/parc/techReports/CSL-81-8_Information_Storage_in_a_Decentralized_Computer_System.pdf),” Xerox Palo Alto Research Centers, CSL-81-8, June 1981. + +1. Martin Kleppmann: “[Please Stop Calling Databases CP or AP](http://martin.kleppmann.com/2015/05/11/please-stop-calling-databases-cp-or-ap.html),” *martin.kleppmann.com*, May 11, 2015. + +1. Kyle Kingsbury: “[Call Me Maybe: MongoDB Stale Reads](https://aphyr.com/posts/322-call-me-maybe-mongodb-stale-reads),” *aphyr.com*, April 20, 2015. + +1. Kyle Kingsbury: “[Computational Techniques in Knossos](https://aphyr.com/posts/314-computational-techniques-in-knossos),” *aphyr.com*, May 17, 2014. + +1. Peter Bailis: “[Linearizability Versus Serializability](http://www.bailis.org/blog/linearizability-versus-serializability/),” *bailis.org*, September 24, 2014. + +1. Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman: [*Concurrency Control and Recovery in Database Systems*](http://research.microsoft.com/en-us/people/philbe/ccontrol.aspx). Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at *research.microsoft.com*. + +1. Mike Burrows: “[The Chubby Lock Service for Loosely-Coupled Distributed Systems](http://research.google.com/archive/chubby.html),” at *7th USENIX Symposium on Operating System Design and Implementation* (OSDI), November 2006. + +1. Flavio P. Junqueira and Benjamin Reed: *ZooKeeper: Distributed Process Coordination*. O'Reilly Media, 2013. ISBN: 978-1-449-36130-3 + +1. “[etcd 2.0.12 Documentation](https://coreos.com/etcd/docs/2.0.12/),” CoreOS, Inc., 2015. + +1. “[Apache Curator](http://curator.apache.org/),” Apache Software Foundation, *curator.apache.org*, 2015. + +1. Morali Vallath: *Oracle 10g RAC Grid, Services & Clustering*. Elsevier Digital Press, 2006. ISBN: 978-1-555-58321-7 + +1. Peter Bailis, Alan Fekete, Michael J Franklin, et al.: “[Coordination-Avoiding Database Systems](http://arxiv.org/pdf/1402.2237.pdf),” *Proceedings of the VLDB Endowment*, volume 8, number 3, pages 185–196, November 2014. + +1. Kyle Kingsbury: “[Call Me Maybe: etcd and Consul](https://aphyr.com/posts/316-call-me-maybe-etcd-and-consul),” *aphyr.com*, June 9, 2014. + +1. Flavio P. Junqueira, Benjamin C. Reed, and Marco Serafini: “[Zab: High-Performance Broadcast for Primary-Backup Systems](https://pdfs.semanticscholar.org/b02c/6b00bd5dbdbd951fddb00b906c82fa80f0b3.pdf),” at *41st IEEE International Conference on Dependable Systems and Networks* (DSN), June 2011. [doi:10.1109/DSN.2011.5958223](http://dx.doi.org/10.1109/DSN.2011.5958223) + +1. Diego Ongaro and John K. Ousterhout: “[In Search of an Understandable Consensus Algorithm (Extended Version)](http://ramcloud.stanford.edu/raft.pdf),” at *USENIX Annual Technical Conference* (ATC), June 2014. + +1. Hagit Attiya, Amotz Bar-Noy, and Danny Dolev: “[Sharing Memory Robustly in Message-Passing Systems](http://www.cse.huji.ac.il/course/2004/dist/p124-attiya.pdf),” *Journal of the ACM*, volume 42, number 1, pages 124–142, January 1995. [doi:10.1145/200836.200869](http://dx.doi.org/10.1145/200836.200869) + +1. Nancy Lynch and Alex Shvartsman: “[Robust Emulation of Shared Memory Using Dynamic Quorum-Acknowledged Broadcasts](http://groups.csail.mit.edu/tds/papers/Lynch/FTCS97.pdf),” at *27th Annual International Symposium on Fault-Tolerant Computing* (FTCS), June 1997. [doi:10.1109/FTCS.1997.614100](http://dx.doi.org/10.1109/FTCS.1997.614100) + +1. Christian Cachin, Rachid Guerraoui, and Luís Rodrigues: [*Introduction to Reliable and Secure Distributed Programming*](http://www.distributedprogramming.net/), 2nd edition. Springer, 2011. ISBN: 978-3-642-15259-7, [doi:10.1007/978-3-642-15260-3](http://dx.doi.org/10.1007/978-3-642-15260-3) + +1. Sam Elliott, Mark Allen, and Martin Kleppmann: [personal communication](https://twitter.com/lenary/status/654761711933648896), thread on *twitter.com*, October 15, 2015. + +1. Niklas Ekström, Mikhail Panchenko, and Jonathan Ellis: “[Possible Issue with Read Repair?](http://mail-archives.apache.org/mod_mbox/cassandra-dev/201210.mbox/%3CFA480D1DC3964E2C8B0A14E0880094C9%40Robotech%3E),” email thread on *cassandra-dev* mailing list, October 2012. + +1. Maurice P. Herlihy: “[Wait-Free Synchronization](https://cs.brown.edu/~mph/Herlihy91/p124-herlihy.pdf),” *ACM Transactions on Programming Languages and Systems* (TOPLAS), volume 13, number 1, pages 124–149, January 1991. [doi:10.1145/114005.102808](http://dx.doi.org/10.1145/114005.102808) + +1. Armando Fox and Eric A. Brewer: “[Harvest, Yield, and Scalable Tolerant Systems](http://radlab.cs.berkeley.edu/people/fox/static/pubs/pdf/c18.pdf),” at *7th Workshop on Hot Topics in Operating Systems* (HotOS), March 1999. [doi:10.1109/HOTOS.1999.798396](http://dx.doi.org/10.1109/HOTOS.1999.798396) + +1. Seth Gilbert and Nancy Lynch: “[Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services](http://www.comp.nus.edu.sg/~gilbert/pubs/BrewersConjecture-SigAct.pdf),” *ACM SIGACT News*, volume 33, number 2, pages 51–59, June 2002. [doi:10.1145/564585.564601](http://dx.doi.org/10.1145/564585.564601) + +1. Seth Gilbert and Nancy Lynch: “[Perspectives on the CAP Theorem](http://groups.csail.mit.edu/tds/papers/Gilbert/Brewer2.pdf),” *IEEE Computer Magazine*, volume 45, number 2, pages 30–36, February 2012. [doi:10.1109/MC.2011.389](http://dx.doi.org/10.1109/MC.2011.389) + +1. Eric A. Brewer: “[CAP Twelve Years Later: How the 'Rules' Have Changed](http://cs609.cs.ua.edu/CAP12.pdf),” *IEEE Computer Magazine*, volume 45, number 2, pages 23–29, February 2012. [doi:10.1109/MC.2012.37](http://dx.doi.org/10.1109/MC.2012.37) + +1. Susan B. Davidson, Hector Garcia-Molina, and Dale Skeen: “[Consistency in Partitioned Networks](http://delab.csd.auth.gr/~dimitris/courses/mpc_fall05/papers/invalidation/acm_csur85_partitioned_network_consistency.pdf),” *ACM Computing Surveys*, volume 17, number 3, pages 341–370, September 1985. [doi:10.1145/5505.5508](http://dx.doi.org/10.1145/5505.5508) + +1. Paul R. Johnson and Robert H. Thomas: “[RFC 677: The Maintenance of Duplicate Databases](https://tools.ietf.org/html/rfc677),” Network Working Group, January 27, 1975. + +1. Bruce G. Lindsay, Patricia Griffiths Selinger, C. Galtieri, et al.: “[Notes on Distributed Databases](http://domino.research.ibm.com/library/cyberdig.nsf/papers/A776EC17FC2FCE73852579F100578964/$File/RJ2571.pdf),” IBM Research, Research Report RJ2571(33471), July 1979. + +1. Michael J. Fischer and Alan Michael: “[Sacrificing Serializability to Attain High Availability of Data in an Unreliable Network](http://www.cs.ucsb.edu/~agrawal/spring2011/ugrad/p70-fischer.pdf),” at *1st ACM Symposium on Principles of Database Systems* (PODS), March 1982. + [doi:10.1145/588111.588124](http://dx.doi.org/10.1145/588111.588124) + +1. Eric A. Brewer: “[NoSQL: Past, Present, Future](http://www.infoq.com/presentations/NoSQL-History),” at *QCon San Francisco*, November 2012. + +1. Henry Robinson: “[CAP Confusion: Problems with 'Partition Tolerance,'](http://blog.cloudera.com/blog/2010/04/cap-confusion-problems-with-partition-tolerance/)” *blog.cloudera.com*, April 26, 2010. + +1. Adrian Cockcroft: “[Migrating to Microservices](http://www.infoq.com/presentations/migration-cloud-native),” at *QCon London*, March 2014. + +1. Martin Kleppmann: “[A Critique of the CAP Theorem](http://arxiv.org/abs/1509.05393),” arXiv:1509.05393, September 17, 2015. + +1. Nancy A. Lynch: “[A Hundred Impossibility Proofs for Distributed Computing](http://groups.csail.mit.edu/tds/papers/Lynch/podc89.pdf),” at *8th ACM Symposium on Principles of Distributed Computing* (PODC), August 1989. [doi:10.1145/72981.72982](http://dx.doi.org/10.1145/72981.72982) + +1. Hagit Attiya, Faith Ellen, and Adam Morrison: “[Limitations of Highly-Available Eventually-Consistent Data Stores](http://www.cs.technion.ac.il/people/mad/online-publications/podc2015-replds.pdf),” at *ACM Symposium on Principles of Distributed Computing* (PODC), July 2015. doi:10.1145/2767386.2767419](http://dx.doi.org/10.1145/2767386.2767419) + +1. Peter Sewell, Susmit Sarkar, Scott Owens, et al.: “[x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors](http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf),” *Communications of the ACM*, volume 53, number 7, pages 89–97, July 2010. + [doi:10.1145/1785414.1785443](http://dx.doi.org/10.1145/1785414.1785443) + +1. Martin Thompson: “[Memory Barriers/Fences](http://mechanical-sympathy.blogspot.co.uk/2011/07/memory-barriersfences.html),” *mechanical-sympathy.blogspot.co.uk*, July 24, 2011. + +1. Ulrich Drepper: “[What Every Programmer Should Know About Memory](http://www.akkadia.org/drepper/cpumemory.pdf),” *akkadia.org*, November 21, 2007. + +1. Daniel J. Abadi: “[Consistency Tradeoffs in Modern Distributed Database System Design](http://cs-www.cs.yale.edu/homes/dna/papers/abadi-pacelc.pdf),” *IEEE Computer Magazine*, volume 45, number 2, pages 37–42, February 2012. [doi:10.1109/MC.2012.33](http://dx.doi.org/10.1109/MC.2012.33) + +1. Hagit Attiya and Jennifer L. Welch: “[Sequential Consistency Versus Linearizability](http://courses.csail.mit.edu/6.852/01/papers/p91-attiya.pdf),” *ACM Transactions on Computer Systems* (TOCS), volume 12, number 2, pages 91–122, May 1994. [doi:10.1145/176575.176576](http://dx.doi.org/10.1145/176575.176576) + +1. Mustaque Ahamad, Gil Neiger, James E. Burns, et al.: “[Causal Memory: Definitions, Implementation, and Programming](http://www-i2.informatik.rwth-aachen.de/i2/fileadmin/user_upload/documents/Seminar_MCMM11/Causal_memory_1996.pdf),” *Distributed Computing*, volume 9, number 1, pages 37–49, March 1995. [doi:10.1007/BF01784241](http://dx.doi.org/10.1007/BF01784241) + +1. Wyatt Lloyd, Michael J. Freedman, Michael Kaminsky, and David G. Andersen: “[Stronger Semantics for Low-Latency Geo-Replicated Storage](https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final149.pdf),” at *10th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), April 2013. + +1. Marek Zawirski, Annette Bieniusa, Valter Balegas, et al.: “[SwiftCloud: Fault-Tolerant Geo-Replication Integrated All the Way to the Client Machine](http://arxiv.org/abs/1310.3107),” INRIA Research Report 8347, August 2013. + +1. Peter Bailis, Ali Ghodsi, Joseph M Hellerstein, and Ion Stoica: “[Bolt-on Causal Consistency](http://db.cs.berkeley.edu/papers/sigmod13-bolton.pdf),” at + *ACM International Conference on Management of Data* (SIGMOD), June 2013. + +1. Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, et al.: “[Challenges to Adopting Stronger Consistency at Scale](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-ajoux.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015. + +1. Peter Bailis: “[Causality Is Expensive (and What to Do About It)](http://www.bailis.org/blog/causality-is-expensive-and-what-to-do-about-it/),” *bailis.org*, February 5, 2014. + +1. Ricardo Gonçalves, Paulo Sérgio Almeida, Carlos Baquero, and Victor Fonte: “[Concise Server-Wide Causality Management for Eventually Consistent Data Stores](http://haslab.uminho.pt/tome/files/global_logical_clocks.pdf),” at *15th IFIP International Conference on Distributed Applications and Interoperable Systems* (DAIS), June 2015. [doi:10.1007/978-3-319-19129-4_6](http://dx.doi.org/10.1007/978-3-319-19129-4_6) + +1. Rob Conery: “[A Better ID Generator for PostgreSQL](http://rob.conery.io/2014/05/29/a-better-id-generator-for-postgresql/),” *rob.conery.io*, May 29, 2014. + +1. Leslie Lamport: “[Time, Clocks, and the Ordering of Events in a Distributed System](http://research.microsoft.com/en-US/um/people/Lamport/pubs/time-clocks.pdf),” *Communications of the ACM*, volume 21, number 7, pages 558–565, July 1978. [doi:10.1145/359545.359563](http://dx.doi.org/10.1145/359545.359563) + +1. Xavier Défago, André Schiper, and Péter Urbán: “[Total Order Broadcast and Multicast Algorithms: Taxonomy and Survey](https://dspace.jaist.ac.jp/dspace/bitstream/10119/4883/1/defago_et_al.pdf),” *ACM Computing Surveys*, volume 36, number 4, pages 372–421, December 2004. + [doi:10.1145/1041680.1041682](http://dx.doi.org/10.1145/1041680.1041682) + +1. 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](http://dx.doi.org/10.1002/0471478210) + +1. Mahesh Balakrishnan, Dahlia Malkhi, Vijayan Prabhakaran, et al.: “[CORFU: A Shared Log Design for Flash Clusters](https://www.usenix.org/system/files/conference/nsdi12/nsdi12-final30.pdf),” at *9th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), April 2012. + +1. Fred B. Schneider: “[Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial](http://www.cs.cornell.edu/fbs/publications/smsurvey.pdf),” *ACM Computing Surveys*, volume 22, number 4, pages 299–319, December 1990. + +1. Alexander Thomson, Thaddeus Diamond, Shu-Chun Weng, et al.: “[Calvin: Fast Distributed Transactions for Partitioned Database Systems](http://cs.yale.edu/homes/thomson/publications/calvin-sigmod12.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 2012. + +1. Mahesh Balakrishnan, Dahlia Malkhi, Ted Wobber, et al.: “[Tango: Distributed Data Structures over a Shared Log](http://research.microsoft.com/pubs/199947/Tango.pdf),” at *24th ACM Symposium on Operating Systems Principles* (SOSP), November 2013. + [doi:10.1145/2517349.2522732](http://dx.doi.org/10.1145/2517349.2522732) + +1. Robbert van Renesse and Fred B. Schneider: “[Chain Replication for Supporting High Throughput and Availability](http://static.usenix.org/legacy/events/osdi04/tech/full_papers/renesse/renesse.pdf),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004. + +1. Leslie Lamport: “[How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs](http://research-srv.microsoft.com/en-us/um/people/lamport/pubs/multi.pdf),” *IEEE Transactions on Computers*, volume 28, number 9, pages 690–691, September 1979. + [doi:10.1109/TC.1979.1675439](http://dx.doi.org/10.1109/TC.1979.1675439) + +1. Enis Söztutar, Devaraj Das, and Carter Shanklin: “[Apache HBase High Availability at the Next Level](http://hortonworks.com/blog/apache-hbase-high-availability-next-level/),” *hortonworks.com*, January 22, 2015. + +1. Brian F Cooper, Raghu Ramakrishnan, Utkarsh Srivastava, et al.: “[PNUTS: Yahoo!’s Hosted Data Serving Platform](http://www.mpi-sws.org/~druschel/courses/ds/papers/cooper-pnuts.pdf),” at *34th International Conference on Very Large Data Bases* (VLDB), August 2008. + [doi:10.14778/1454159.1454167](http://dx.doi.org/10.14778/1454159.1454167) + +1. Tushar Deepak Chandra and Sam Toueg: “[Unreliable Failure Detectors for Reliable Distributed Systems](http://courses.csail.mit.edu/6.852/08/papers/CT96-JACM.pdf),” *Journal of the ACM*, volume 43, number 2, pages 225–267, March 1996. [doi:10.1145/226643.226647](http://dx.doi.org/10.1145/226643.226647) + +1. Michael J. Fischer, Nancy Lynch, and Michael S. Paterson: “[Impossibility of Distributed Consensus with One Faulty Process](https://groups.csail.mit.edu/tds/papers/Lynch/jacm85.pdf),” *Journal of the ACM*, volume 32, number 2, pages 374–382, April 1985. [doi:10.1145/3149.214121](http://dx.doi.org/10.1145/3149.214121) + +1. 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](http://dl.acm.org/citation.cfm?id=806707) + +1. Jim N. Gray and Leslie Lamport: “[Consensus on Transaction Commit](http://db.cs.berkeley.edu/cs286/papers/paxoscommit-tods2006.pdf),” *ACM Transactions on Database Systems* (TODS), volume 31, number 1, pages 133–160, March 2006. [doi:10.1145/1132863.1132867](http://dx.doi.org/10.1145/1132863.1132867) + +1. Rachid Guerraoui: “[Revisiting the Relationship Between Non-Blocking Atomic Commitment and Consensus](https://pdfs.semanticscholar.org/5d06/489503b6f791aa56d2d7942359c2592e44b0.pdf),” at *9th International Workshop on Distributed Algorithms* (WDAG), September 1995. [doi:10.1007/BFb0022140](http://dx.doi.org/10.1007/BFb0022140) + +1. Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, et al.: “[All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications](http://research.cs.wisc.edu/wind/Publications/alice-osdi14.pdf),” + at *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), + October 2014. + +1. Jim Gray: “[The Transaction Concept: Virtues and Limitations](http://research.microsoft.com/en-us/um/people/gray/papers/theTransactionConcept.pdf),” at *7th International Conference on Very Large Data Bases* (VLDB), September 1981. + +1. Hector Garcia-Molina and Kenneth Salem: “[Sagas](http://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1987. [doi:10.1145/38713.38742](http://dx.doi.org/10.1145/38713.38742) + +1. C. Mohan, Bruce G. Lindsay, and Ron Obermarck: “[Transaction Management in the R* Distributed Database Management System](https://cs.brown.edu/courses/csci2270/archives/2012/papers/dtxn/p378-mohan.pdf),” *ACM Transactions on Database Systems*, volume 11, number 4, pages 378–396, December 1986. [doi:10.1145/7239.7266](http://dx.doi.org/10.1145/7239.7266) + +1. “[Distributed Transaction Processing: The XA Specification](http://pubs.opengroup.org/onlinepubs/009680699/toc.pdf),” X/Open Company Ltd., Technical Standard + XO/CAE/91/300, December 1991. ISBN: 978-1-872-63024-3 + +1. Mike Spille: “[XA Exposed, Part II](http://www.jroller.com/pyrasun/entry/xa_exposed_part_ii_schwartz),” *jroller.com*, April 3, 2004. + +1. Ivan Silva Neto and Francisco Reverbel: “[Lessons Learned from Implementing WS-Coordination and WS-AtomicTransaction](http://www.ime.usp.br/~reverbel/papers/icis2008.pdf),” at *7th IEEE/ACIS International Conference on Computer and Information Science* (ICIS), May 2008. [doi:10.1109/ICIS.2008.75](http://dx.doi.org/10.1109/ICIS.2008.75) + +1. James E. Johnson, David E. Langworthy, Leslie Lamport, and Friedrich H. Vogt: “[Formal Specification of a Web Services Protocol](http://research.microsoft.com/en-us/um/people/lamport/pubs/wsfm-web.pdf),” at *1st International Workshop on Web Services and Formal Methods* (WS-FM), February 2004. [doi:10.1016/j.entcs.2004.02.022](http://dx.doi.org/10.1016/j.entcs.2004.02.022) + +1. Dale Skeen: “[Nonblocking Commit Protocols](http://www.cs.utexas.edu/~lorenzo/corsi/cs380d/papers/Ske81.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), April 1981. [doi:10.1145/582318.582339](http://dx.doi.org/10.1145/582318.582339) + +1. Gregor Hohpe: “[Your Coffee Shop Doesn’t Use Two-Phase Commit](http://www.martinfowler.com/ieeeSoftware/coffeeShop.pdf),” *IEEE Software*, volume 22, number 2, pages 64–66, March 2005. [doi:10.1109/MS.2005.52](http://dx.doi.org/10.1109/MS.2005.52) + +1. Pat Helland: “[Life Beyond Distributed Transactions: An Apostate’s Opinion](http://www-db.cs.wisc.edu/cidr/cidr2007/papers/cidr07p15.pdf),” at *3rd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2007. + +1. Jonathan Oliver: “[My Beef with MSDTC and Two-Phase Commits](http://blog.jonathanoliver.com/my-beef-with-msdtc-and-two-phase-commits/),” *blog.jonathanoliver.com*, April 4, 2011. + +1. Oren Eini (Ahende Rahien): “[The Fallacy of Distributed Transactions](http://ayende.com/blog/167362/the-fallacy-of-distributed-transactions),” *ayende.com*, July 17, 2014. + +1. Clemens Vasters: “[Transactions in Windows Azure (with Service Bus) – An Email Discussion](https://blogs.msdn.microsoft.com/clemensv/2012/07/30/transactions-in-windows-azure-with-service-bus-an-email-discussion/),” *vasters.com*, July 30, 2012. + +1. “[Understanding Transactionality in Azure](https://docs.particular.net/nservicebus/azure/understanding-transactionality-in-azure),” NServiceBus Documentation, Particular Software, 2015. + +1. Randy Wigginton, Ryan Lowe, Marcos Albe, and Fernando Ipar: “[Distributed Transactions in MySQL](https://www.percona.com/live/mysql-conference-2013/sites/default/files/slides/XA_final.pdf),” at *MySQL Conference and Expo*, April 2013. + +1. Mike Spille: “[XA Exposed, Part I](http://www.jroller.com/pyrasun/entry/xa_exposed),” *jroller.com*, April 3, 2004. + +1. Ajmer Dhariwal: “[Orphaned MSDTC Transactions (-2 spids)](http://www.eraofdata.com/orphaned-msdtc-transactions-2-spids/),” *eraofdata.com*, December 12, 2008. + +1. Paul Randal: “[Real World Story of DBCC PAGE Saving the Day](http://www.sqlskills.com/blogs/paul/real-world-story-of-dbcc-page-saving-the-day/),” *sqlskills.com*, June 19, 2013. + +1. “[in-doubt xact resolution Server Configuration Option](https://msdn.microsoft.com/en-us/library/ms179586.aspx),” SQL Server 2016 documentation, Microsoft, Inc., + 2016. + +1. Cynthia Dwork, Nancy Lynch, and Larry Stockmeyer: “[Consensus in the Presence of Partial Synchrony](http://www.net.t-labs.tu-berlin.de/~petr/ADC-07/papers/DLS88.pdf),” *Journal of the ACM*, volume 35, number 2, pages 288–323, April 1988. [doi:10.1145/42282.42283](http://dx.doi.org/10.1145/42282.42283) + +1. Miguel Castro and Barbara H. Liskov: “[Practical Byzantine Fault Tolerance and Proactive Recovery](http://zoo.cs.yale.edu/classes/cs426/2012/bib/castro02practical.pdf),” *ACM Transactions on Computer Systems*, volume 20, number 4, pages 396–461, November 2002. [doi:10.1145/571637.571640](http://dx.doi.org/10.1145/571637.571640) + +1. Brian M. Oki and Barbara H. Liskov: “[Viewstamped Replication: A New Primary Copy Method to Support Highly-Available Distributed Systems](http://www.cs.princeton.edu/courses/archive/fall11/cos518/papers/viewstamped.pdf),” at *7th ACM Symposium on Principles of Distributed Computing* (PODC), August 1988. [doi:10.1145/62546.62549](http://dx.doi.org/10.1145/62546.62549) + +1. Barbara H. Liskov and James Cowling: “[Viewstamped Replication Revisited](http://pmg.csail.mit.edu/papers/vr-revisited.pdf),” Massachusetts Institute of Technology, Tech Report MIT-CSAIL-TR-2012-021, July 2012. + +1. Leslie Lamport: “[The Part-Time Parliament](http://research.microsoft.com/en-us/um/people/lamport/pubs/lamport-paxos.pdf),” *ACM Transactions on Computer Systems*, volume 16, number 2, pages 133–169, May 1998. [doi:10.1145/279227.279229](http://dx.doi.org/10.1145/279227.279229) + +1. Leslie Lamport: “[Paxos Made Simple](http://research.microsoft.com/en-us/um/people/lamport/pubs/paxos-simple.pdf),” *ACM SIGACT News*, volume 32, number 4, pages 51–58, December 2001. + +1. Tushar Deepak Chandra, Robert Griesemer, and Joshua Redstone: “[Paxos Made Live – An Engineering Perspective](http://www.read.seas.harvard.edu/~kohler/class/08w-dsi/chandra07paxos.pdf),” at *26th ACM Symposium on Principles of Distributed Computing* (PODC), June 2007. + +1. Robbert van Renesse: “[Paxos Made Moderately Complex](http://www.cs.cornell.edu/home/rvr/Paxos/paxos.pdf),” *cs.cornell.edu*, March 2011. + +1. Diego Ongaro: “[Consensus: Bridging Theory and Practice](https://github.com/ongardie/dissertation),” PhD Thesis, Stanford University, August 2014. + +1. Heidi Howard, Malte Schwarzkopf, Anil Madhavapeddy, and Jon Crowcroft: “[Raft Refloated: Do We Have Consensus?](http://www.cl.cam.ac.uk/~ms705/pub/papers/2015-osr-raft.pdf),” *ACM SIGOPS Operating Systems Review*, volume 49, number 1, pages 12–21, January 2015. + [doi:10.1145/2723872.2723876](http://dx.doi.org/10.1145/2723872.2723876) + +1. André Medeiros: “[ZooKeeper’s Atomic Broadcast Protocol: Theory and Practice](http://www.tcs.hut.fi/Studies/T-79.5001/reports/2012-deSouzaMedeiros.pdf),” Aalto University School of Science, March 20, 2012. + +1. Robbert van Renesse, Nicolas Schiper, and Fred B. Schneider: “[Vive La Différence: Paxos vs. Viewstamped Replication vs. Zab](http://arxiv.org/abs/1309.5671),” *IEEE Transactions on Dependable and Secure Computing*, + volume 12, number 4, pages 472–484, September 2014. [doi:10.1109/TDSC.2014.2355848](http://dx.doi.org/10.1109/TDSC.2014.2355848) + +1. Will Portnoy: “[Lessons Learned from Implementing Paxos](http://blog.willportnoy.com/2012/06/lessons-learned-from-paxos.html),” *blog.willportnoy.com*, June 14, 2012. + +1. Heidi Howard, Dahlia Malkhi, and Alexander Spiegelman: “[Flexible Paxos: Quorum Intersection Revisited](https://arxiv.org/abs/1608.06696),” *arXiv:1608.06696*, August 24, 2016. + +1. Heidi Howard and Jon Crowcroft: “[Coracle: Evaluating Consensus at the Internet Edge](http://www.sigcomm.org/sites/default/files/ccr/papers/2015/August/2829988-2790010.pdf),” at *Annual Conference of the ACM Special Interest Group on Data Communication* (SIGCOMM), August 2015. + [doi:10.1145/2829988.2790010](http://dx.doi.org/10.1145/2829988.2790010) + +1. Kyle Kingsbury: “[Call Me Maybe: Elasticsearch 1.5.0](https://aphyr.com/posts/323-call-me-maybe-elasticsearch-1-5-0),” *aphyr.com*, April 27, 2015. + +1. Ivan Kelly: “[BookKeeper Tutorial](https://github.com/ivankelly/bookkeeper-tutorial),” *github.com*, October 2014. + +1. Camille Fournier: “[Consensus Systems for the Skeptical Architect](http://www.ustream.tv/recorded/61483409),” at *Craft Conference*, Budapest, Hungary, April 2015. + +1. Kenneth P. Birman: “[A History of the Virtual Synchrony Replication Model](https://www.truststc.org/pubs/713.html),” in *Replication: Theory and Practice*, Springer LNCS volume 5959, chapter 6, pages 91–120, 2010. ISBN: 978-3-642-11293-5, [doi:10.1007/978-3-642-11294-2_6](http://dx.doi.org/10.1007/978-3-642-11294-2_6) + + + + +------ + +| 上一章 | 目錄 | 下一章 | +| ---------------------------------- | ------------------------------- | --------------------------------- | +| [第八章:分散式系統的麻煩](ch8.md) | [設計資料密集型應用](README.md) | [第三部分:衍生資料](part-iii.md) | + diff --git a/zh-tw/colophon.md b/zh-tw/colophon.md new file mode 100644 index 00000000..6fc3b70a --- /dev/null +++ b/zh-tw/colophon.md @@ -0,0 +1,35 @@ +# 後記 + +## 關於作者 + +**Martin Kleppmann**是英國劍橋大學分散式系統的研究員。此前他曾在網際網路公司擔任過軟體工程師和企業家,其中包括LinkedIn和Rapportive,負責大規模資料基礎架構。在這個過程中,他以艱難的方式學習了一些東西,他希望這本書能夠讓你避免重蹈覆轍。 + +Martin是一位常規會議演講者,博主和開源貢獻者。他認為,每個人都應該有深刻的技術理念,深層次的理解能幫助我們開發出更好的軟體。 + +![](http://martin.kleppmann.com/2017/03/ddia-poster.jpg) + + + +## 關於譯者 + +[馮若航](https://vonng.com/about) + +PostgreSQL DBA @ TanTan + +Alibaba+-Finplus 架構師/全棧工程師 (2015 ~ 2017) + + + +## 後記 + +《設計資料密集型應用》封面上的動物是**印度野豬(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。 diff --git a/zh-tw/glossary.md b/zh-tw/glossary.md new file mode 100644 index 00000000..ae27f848 --- /dev/null +++ b/zh-tw/glossary.md @@ -0,0 +1,377 @@ +# 術語表 【DRAFT】 + +> 請注意,本術語表中的定義簡短而簡單,旨在傳達核心思想,而不是術語的完整細微之處。 有關更多詳細資訊,請參閱正文中的參考資料。 + + + +[TOC] + + + +### 非同步(asynchronous) + +不等待某些事情完成(例如,將資料傳送到網路中的另一個節點),並且不會假設要花多長時間。請參閱第153頁上的“同步與非同步複製”,第284頁上的“同步與非同步網路”,以及第306頁上的“系統模型與現實”。 + + + +### 原子(atomic) + +1.在併發操作的上下文中:描述一個在單個時間點看起來生效的操作,所以另一個併發程序永遠不會遇到處於“半完成”狀態的操作。另見隔離。 + +2.在事務的上下文中:將一些寫入操作分為一組,這組寫入要麼全部提交成功,要麼遇到錯誤時全部回滾。參見第223頁的“原子性”和第354頁的“原子提交和兩階段提交(2PC)”。 + + + +### 背壓(backpressure) + +接收方接收資料速度較慢時,強制降低傳送方的資料傳送速度。也稱為流量控制。請參閱第441頁上的“訊息系統”。 + + + +### 批處理(batch process) + +一種計算,它將一些固定的(通常是大的)資料集作為輸入,並將其他一些資料作為輸出,而不修改輸入。見第十章。 + + + +### 邊界(bounded) + +有一些已知的上限或大小。例如,網路延遲情況(請參閱“超時和未定義的延遲”在本頁281)和資料集(請參閱第11章的介紹)。 + + + +### 拜占庭故障(Byzantine fault) + +表現異常的節點,這種異常可能以任意方式出現,例如向其他節點發送矛盾或惡意訊息。請參閱第304頁上的“拜占庭故障”。 + + + +### 快取(cache) + +一種元件,透過儲存最近使用過的資料,加快未來對相同資料的讀取速度。快取中通常存放部分資料:因此,如果快取中缺少某些資料,則必須從某些底層較慢的資料儲存系統中,獲取完整的資料副本。 + + + +### CAP定理(CAP theorem) + +一個被廣泛誤解的理論結果,在實踐中是沒有用的。參見第336頁的“CAP定理”。 + + + +### 因果關係(causality) + +事件之間的依賴關係,當一件事發生在另一件事情之前。例如,後面的事件是對早期事件的迴應,或者依賴於更早的事件,或者應該根據先前的事件來理解。請參閱第186頁上的“發生之前的關係和併發性”和第339頁上的“排序和因果關係”。 + + + +### 共識(consensus) + +分散式計算的一個基本問題,就是讓幾個節點同意某些事情(例如,哪個節點應該是資料庫叢集的領導者)。問題比乍看起來要困難得多。請參閱第364頁上的“容錯共識”。 + + + +### 資料倉庫(data warehouse) + +一個數據庫,其中來自幾個不同的OLTP系統的資料已經被合併和準備用於分析目的。請參閱第91頁上的“資料倉庫”。 + + + +### 宣告式(declarative) + +描述某些東西應有的屬性,但不知道如何實現它的確切步驟。在查詢的上下文中,查詢最佳化器採用宣告性查詢並決定如何最好地執行它。請參閱第42頁上的“資料的查詢語言”。 + + + +### 非規範化(denormalize) + +為了加速讀取,在標準資料集中引入一些冗餘或重複資料,通常採用快取或索引的形式。非規範化的值是一種預先計算的查詢結果,像物化檢視。請參見“單物件和多物件操作”(第228頁)和“從同一事件日誌中派生多個檢視”(第461頁)。 + + + +### 衍生資料(derived data) + +一種資料集,根據其他資料透過可重複執行的流程建立。必要時,你可以執行該流程再次建立衍生資料。衍生資料通常用於提高特定資料的讀取速度。常見的衍生資料有索引、快取和物化檢視。參見第三部分的介紹。 + + + +### 確定性(deterministic) + +描述一個函式,如果給它相同的輸入,則總是產生相同的輸出。這意味著它不能依賴於隨機數字、時間、網路通訊或其他不可預測的事情。 + + + +### 分散式(distributed) + +在由網路連線的多個節點上執行。對於部分節點故障,具有容錯性:系統的一部分發生故障時,其他部分仍可以正常工作,通常情況下,軟體無需瞭解故障相關的確切情況。請參閱第274頁上的“故障和部分故障”。 + + + +### 持久(durable) + +以某種方式儲存資料,即使發生各種故障,也不會丟失資料。請參閱第226頁上的“永續性”。 + + + +### ETL(Extract-Transform-Load) + +提取-轉換-載入(Extract-Transform-Load)。從源資料庫中提取資料,將其轉換為更適合分析查詢的形式,並將其載入到資料倉庫或批處理系統中的過程。請參閱第91頁上的“資料倉庫”。 + + + +### 故障切換(failover) + +在具有單一領導者的系統中,故障切換是將領導角色從一個節點轉移到另一個節點的過程。請參閱第156頁的“處理節點故障”。 + + + +### 容錯(fault-tolerant) + +如果出現問題(例如,機器崩潰或網路連線失敗),可以自動恢復。請參閱第6頁上的“可靠性”。 + + + +### 流量控制(flow control) + +見背壓(backpressure)。 + + + +### 追隨者(follower) + +一種資料副本,僅處理領導者發出的資料變更,不直接接受來自客戶端的任何寫入。也稱為輔助、僕從、只讀副本或熱備份。請參閱第152頁上的“領導和追隨者”。 + + + +### 全文檢索(full-text search) + +透過任意關鍵字來搜尋文字,通常具有附加特徵,例如匹配類似的拼寫詞或同義詞。全文索引是一種支援這種查詢的次級索引。請參閱第88頁上的“全文搜尋和模糊索引”。 + + + +### 圖(graph) + +一種資料結構,由頂點(可以指向的東西,也稱為節點或實體)和邊(從一個頂點到另一個頂點的連線,也稱為關係或弧)組成。請參閱第49頁上的“和圖相似的資料模型”。 + + + +### 雜湊(hash) + +將輸入轉換為看起來像隨機數值的函式。相同的輸入會轉換為相同的數值,不同的輸入一般會轉換為不同的數值,也可能轉換為相同數值(也被稱為衝突)。請參閱第203頁的“根據鍵的雜湊值分隔”。 + + + +### 冪等(idempotent) + +用於描述一種操作可以安全地重試執行,即執行多次的效果和執行一次的效果相同。請參閱第478頁的“冪等”。 + + + +### 索引(index) + +一種資料結構。透過索引,你可以根據特定欄位的值,在所有資料記錄中進行高效檢索。請參閱第70頁的“讓資料庫更強大的資料結構”。 + + + +### 隔離性(isolation) + +在事務上下文中,用於描述併發執行事務的互相干擾程度。序列執行具有最強的隔離性,不過其它程度的隔離也通常被使用。請參閱第225頁的“隔離”。 + + + +### 連線(join) + +彙集有共同點的記錄。在一個記錄與另一個記錄有關(外來鍵,文件參考,圖中的邊)的情況下最常用,查詢需要獲取參考所指向的記錄。請參閱第33頁上的“多對一和多對多關係”和第393頁上的“減少端連線和分組”。 + + + +### 領導者(leader) + +當資料或服務被複制到多個節點時,由領導者分發已授權變更的資料副本。領導者可以透過某些協議選舉產生,也可以由管理者手動選擇。也被稱為主人。請參閱第152頁的“領導者和追隨者”。 + + + +### 線性化(linearizable) + +表現為系統中只有一份透過原子操作更新的資料副本。請參閱第324頁的“線性化”。 + + + +### 區域性性(locality) + +一種效能最佳化方式,如果經常在相同的時間請求一些離散資料,把這些資料放到一個位置。請參閱第41頁的“請求資料的區域性性”。 + + + +### 鎖(lock) + +一種保證只有一個執行緒、節點或事務可以訪問的機制,如果其它執行緒、節點或事務想訪問相同元素,則必須等待鎖被釋放。請參閱第257頁的“兩段鎖(2PL)”和301頁的“領導者和鎖”。 + + + +### 日誌(log) + +日誌是一個只能以追加方式寫入的檔案,用於存放資料。預寫式日誌用於在儲存引擎崩潰時恢復資料(請參閱第82頁的“使二叉樹更穩定”);結構化日誌儲存引擎使用日誌作為它的主要儲存格式(請參閱第76頁的“有序字串表和日誌結構的合併樹”);複製型日誌用於把寫入從領導者複製到追隨者(請參閱第152頁的“領導者和追隨者”);事件性日誌可以表現為資料流(請參閱第446頁的“分段日誌”)。 + + + +### 物化(materialize) + +急切地計算並寫出結果,而不是在請求時計算。請參閱第101頁的“聚合:資料立方和物化檢視”和419頁的“中間狀態的物化”。 + + + +### 節點(node) + +計算機上執行的一些軟體的例項,透過網路與其他節點通訊以完成某項任務。 + + + +### 規範化(normalized) + +以沒有冗餘或重複的方式進行結構化。 在規範化資料庫中,當某些資料發生變化時,您只需要在一個地方進行更改,而不是在許多不同的地方複製很多次。 請參閱第33頁上的“多對一和多對多關係”。 + + + +### OLAP(Online Analytic Processing) + +線上分析處理。 透過對大量記錄進行聚合(例如,計數,總和,平均)來表徵的訪問模式。 請參閱第90頁上的“交易處理或分析?”。 + + + +### OLTP(Online Transaction Processing) + +線上事務處理。 訪問模式的特點是快速查詢,讀取或寫入少量記錄,這些記錄通常透過鍵索引。 請參閱第90頁上的“交易處理或分析?”。 + + + +### 分割槽(partitioning) + +將單機上的大型資料集或計算結果拆分為較小部分,並將其分佈到多臺機器上。 也稱為分片。 見第6章。 + + + +### 百分位點(percentile) + +透過計算有多少值高於或低於某個閾值來衡量值分佈的方法。 例如,某個時間段的第95個百分位響應時間是時間t,則該時間段中,95%的請求完成時間小於t,5%的請求完成時間要比t長。 請參閱第13頁上的“描述效能”。 + + + +### 主鍵(primary key) + +唯一標識記錄的值(通常是數字或字串)。 在許多應用程式中,主鍵由系統在建立記錄時生成(例如,按順序或隨機); 它們通常不由使用者設定。 另請參閱二級索引。 + + + +### 法定人數(quorum) + +在操作完成之前,需要對操作進行投票的最少節點數量。 請參閱第179頁上的“讀寫的法定人數”。 + + + +### 再平衡(rebalance) + +將資料或服務從一個節點移動到另一個節點以實現負載均衡。 請參閱第209頁上的“再平衡分割槽”。 + + + +### 複製(replication) + +在幾個節點(副本)上保留相同資料的副本,以便在某些節點無法訪問時,資料仍可訪問。請參閱第5章。 + + + +### 模式(schema) + +一些資料結構的描述,包括其欄位和資料型別。 可以在資料生命週期的不同點檢查某些資料是否符合模式(請參閱第39頁上的“文件模型中的模式靈活性”),模式可以隨時間變化(請參閱第4章)。 + + + +### 次級索引(secondary index) + +與主要資料儲存器一起維護的附加資料結構,使您可以高效地搜尋與某種條件相匹配的記錄。 請參閱第85頁上的“其他索引結構”和第206頁上的“分割槽和二級索引”。 + + + +### 可序列化(serializable) + +保證多個併發事務同時執行時,它們的行為與按順序逐個執行事務相同。 請參閱第251頁上的“可序列化”。 + + + +### 無共享(shared-nothing) + +與共享記憶體或共享磁碟架構相比,獨立節點(每個節點都有自己的CPU,記憶體和磁碟)透過傳統網路連線。 見第二部分的介紹。 + + + +### 偏斜(skew) + +1.各分割槽負載不平衡,例如某些分割槽有大量請求或資料,而其他分割槽則少得多。也被稱為熱點。請參閱第205頁上的“工作負載偏斜和減輕熱點”和第407頁上的“處理偏斜”。 + +2.時間線異常導致事件以不期望的順序出現。 請參閱第237頁上的“快照隔離和可重複讀取”中的關於讀取偏斜的討論,第246頁上的“寫入偏斜和模糊”中的寫入偏斜以及第291頁上的“訂購事件的時間戳”中的時鐘偏斜。 + + + +### 腦裂(split brain) + +兩個節點同時認為自己是領導者的情況,這種情況可能違反系統擔保。 請參閱第156頁的“處理節點中斷”和第300頁的“真相由多數定義”。 + + + +### 儲存過程(stored procedure) + +一種對事務邏輯進行編碼的方式,它可以完全在資料庫伺服器上執行,事務執行期間無需與客戶端通訊。 請參閱第252頁的“實際序列執行”。 + + + +### 流處理(stream process) + +持續執行的計算。可以持續接收事件流作為輸入,並得出一些輸出。 見第11章。 + + + +### 同步(synchronous) + +非同步的反義詞。 + + + +### 記錄系統(system of record) + +一個儲存主要權威版本資料的系統,也被稱為真相的來源。首先在這裡寫入資料變更,其他資料集可以從記錄系統衍生。 參見第三部分的介紹。 + + + +### 超時(timeout) + +檢測故障的最簡單方法之一,即在一段時間內觀察是否缺乏響應。 但是,不可能知道超時是由於遠端節點的問題還是網路中的問題造成的。 請參閱第281頁上的“超時和無限延遲”。 + + + +### 全序(total order) + +一種比較事物的方法(例如時間戳),可以讓您總是說出兩件事中哪一件更大,哪件更小。 總的來說,有些東西是無法比擬的(不能說哪個更大或更小)的順序稱為偏序。 請參見第341頁的“因果順序不是全序”。 + + + +### 事務(transaction) + +為了簡化錯誤處理和併發問題,將幾個讀寫操作分組到一個邏輯單元中。 見第7章。 + + + +### 兩階段提交(2PC, two-phase commit) + +一種確保多個數據庫節點全部提交或全部中止事務的演算法。 請參閱第354頁上的“原子提交和兩階段提交(2PC)”。 + + + +### 兩階段鎖定(2PL, two-phase locking) + +一種用於實現可序列化隔離的演算法,該演算法透過事務獲取對其讀取或寫入的所有資料的鎖,直到事務結束。 請參閱第257頁上的“兩階段鎖定(2PL)”。 + + + +### 無邊界(unbounded) + +沒有任何已知的上限或大小。 反義詞是邊界(bounded)。 \ No newline at end of file diff --git a/zh-tw/part-i.md b/zh-tw/part-i.md new file mode 100644 index 00000000..7386f0be --- /dev/null +++ b/zh-tw/part-i.md @@ -0,0 +1,29 @@ +# 第一部分 資料系統的基石 + +本書前四章介紹了資料系統底層的基礎概念,無論是在單臺機器上執行的單點資料系統,還是分佈在多臺機器上的分散式資料系統都適用。 + +1. [第一章](ch1.md)將介紹本書使用的術語和方法。**可靠性,可擴充套件性和可維護性** ,這些詞彙到底意味著什麼?如何實現這些目標? +2. [第二章](ch2.md)將對幾種不同的**資料模型和查詢語言**進行比較。從程式設計師的角度看,這是資料庫之間最明顯的區別。不同的資料模型適用於不同的應用場景。 +3. [第三章](ch3.md)將深入**儲存引擎**內部,研究資料庫如何在磁碟上擺放資料。不同的儲存引擎針對不同的負載進行最佳化,選擇合適的儲存引擎對系統性能有巨大影響。 +4. [第四章](ch4)將對幾種不同的 **資料編碼**進行比較。特別研究了這些格式在應用需求經常變化、模式需要隨時間演變的環境中表現如何。 + +第二部分將專門討論在**分散式資料系統**中特有的問題。 + + + +## 目錄 + + +1. [可靠性、可擴充套件性、可維護性](ch1.md) +2. [資料模型與查詢語言](ch2.md) +3. [儲存與檢索](ch3.md) +4. [編碼與演化](ch4.md) + + + + +------ + +| 上一章 | 目錄 | 下一章 | +| ------------------ | ------------------------------- | -------------------------------------------- | +| [序言](preface.md) | [設計資料密集型應用](README.md) | [第一章:可靠性、可擴充套件性、可維護性](ch1.md) | diff --git a/zh-tw/part-ii.md b/zh-tw/part-ii.md new file mode 100644 index 00000000..29b07ae2 --- /dev/null +++ b/zh-tw/part-ii.md @@ -0,0 +1,100 @@ +# 第二部分: 分散式資料 + +> 一個成功的技術,現實的優先順序必須高於公關,你可以糊弄別人,但糊弄不了自然規律。 +> +> ——羅傑斯委員會報告(1986) +> + +------- + +在本書的[第一部分](part-i.md)中,我們討論了資料系統的各個方面,但僅限於資料儲存在單臺機器上的情況。現在我們到了[第二部分](part-ii.md),進入更高的層次,並提出一個問題:如果**多臺機器**參與資料的儲存和檢索,會發生什麼? + +你可能會出於各種各樣的原因,希望將資料庫分佈到多臺機器上: + +***可擴充套件性*** + +如果你的資料量、讀取負載、寫入負載超出單臺機器的處理能力,可以將負載分散到多臺計算機上。 + +***容錯/高可用性*** + +如果你的應用需要在單臺機器(或多臺機器,網路或整個資料中心)出現故障的情況下仍然能繼續工作,則可使用多臺機器,以提供冗餘。一臺故障時,另一臺可以接管。 + +***延遲*** + +如果在世界各地都有使用者,你也許會考慮在全球範圍部署多個伺服器,從而每個使用者可以從地理上最近的資料中心獲取服務,避免了等待網路資料包穿越半個世界。 + +### 擴充套件至更高的載荷 + +如果你需要的只是擴充套件至更高的**載荷(load)**,最簡單的方法就是購買更強大的機器(有時稱為**垂直擴充套件(vertical scaling)**或**向上擴充套件(scale up)**)。許多處理器,記憶體和磁碟可以在同一個作業系統下相互連線,快速的相互連線允許任意處理器訪問記憶體或磁碟的任意部分。在這種**共享記憶體架構(shared-memory architecture)**中,所有的元件都可以看作一臺單獨的機器。 + +[^i]: 在大型機中,儘管任意處理器都可以訪問記憶體的任意部分,但總有一些記憶體區域與一些處理器更接近(稱為**非均勻記憶體訪問(nonuniform memory access, NUMA)**【1】)。 為了有效利用這種架構特性,需要對處理進行細分,以便每個處理器主要訪問臨近的記憶體,這意味著即使表面上看起來只有一臺機器在執行,**分割槽(partitioning)**仍然是必要的。 + +共享記憶體方法的問題在於,成本增長速度快於線性增長:一臺有著雙倍處理器數量,雙倍記憶體大小,雙倍磁碟容量的機器,通常成本會遠遠超過原來的兩倍。而且可能因為存在瓶頸,並不足以處理雙倍的載荷。 + +共享記憶體架構可以提供有限的容錯能力,高階機器可以使用熱插拔的元件(不關機更換磁碟,記憶體模組,甚至處理器)——但它必然囿於單個地理位置的桎梏。 + +另一種方法是**共享磁碟架構(shared-disk architecture)**,它使用多臺具有獨立處理器和記憶體的機器,但將資料儲存在機器之間共享的磁碟陣列上,這些磁碟透過快速網路連線[^ii]。這種架構用於某些資料倉庫,但競爭和鎖定的開銷限制了共享磁碟方法的可擴充套件性【2】。 + +[^ii]: 網路附屬儲存(Network Attached Storage, NAS),或**儲存區網路(Storage Area Network, SAN)** + +#### 無共享架構 + +相比之下,**無共享架構(shared-nothing architecture)**(有時稱為**水平擴充套件(horizontal scale)** 或**向外擴充套件(scale out)**)已經相當普及。在這種架構中,執行資料庫軟體的每臺機器/虛擬機器都稱為**節點(node)**。每個節點只使用各自的處理器,記憶體和磁碟。節點之間的任何協調,都是在軟體層面使用傳統網路實現的。 + +無共享系統不需要使用特殊的硬體,所以你可以用任意機器——比如價效比最好的機器。你也許可以跨多個地理區域分佈資料從而減少使用者延遲,或者在損失一整個資料中心的情況下倖免於難。隨著雲端虛擬機器部署的出現,即使是小公司,現在無需Google級別的運維,也可以實現異地分散式架構。 + +在這一部分裡,我們將重點放在無共享架構上。它不見得是所有場景的最佳選擇,但它是最需要你謹慎從事的架構。如果你的資料分佈在多個節點上,你需要意識到這樣一個分散式系統中約束和權衡 ——資料庫並不能魔術般地把這些東西隱藏起來。 + +雖然分散式無共享架構有許多優點,但它通常也會給應用帶來額外的複雜度,有時也會限制你可用資料模型的表達力。在某些情況下,一個簡單的單執行緒程式可以比一個擁有超過100個CPU核的叢集表現得更好【4】。另一方面,無共享系統可以非常強大。接下來的幾章,將詳細討論分散式資料會帶來的問題。 + +### 複製 vs 分割槽 + +資料分佈在多個節點上有兩種常見的方式: + +***複製(Replication)*** + +​ 在幾個不同的節點上儲存資料的相同副本,可能放在不同的位置。 複製提供了冗餘:如果一些節點不可用,剩餘的節點仍然可以提供資料服務。 複製也有助於改善效能。 [第五章](ch5.md)將討論複製。 + +***分割槽 (Partitioning)*** + +​ 將一個大型資料庫拆分成較小的子集(稱為**分割槽(partitions)**),從而不同的分割槽可以指派給不同的**節點(node)**(亦稱**分片(shard)**)。 [第六章](ch6.md)將討論分割槽。 + +複製和分割槽是不同的機制,但它們經常同時使用。如[圖II-1](img/figii-1.png)所示。 + +![](img/figii-1.png) + +**圖II-1 一個數據庫切分為兩個分割槽,每個分割槽都有兩個副本** + +理解了這些概念,就可以開始討論在分散式系統中需要做出的困難抉擇。[第七章](ch7.md)將討論**事務(Transaction)**,這對於瞭解資料系統中可能出現的各種問題,以及我們可以做些什麼很有幫助。[第八章](ch8.md)和[第九章](ch9.md)將討論分散式系統的根本侷限性。 + +在本書的[第三部分](part-iii.md)中,將討論如何將多個(可能是分散式的)資料儲存整合為一個更大的系統,以滿足複雜的應用需求。 但首先,我們來聊聊分散式的資料。 + + + +## 索引 + +5. [複製](ch5.md) +6. [分片](ch6.md) +7. [事務](ch7.md) +8. [分散式系統的麻煩](ch8.md) +9. [一致性與共識](ch9.md) + + + + + +## 參考文獻 + +1. Ulrich Drepper: “[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf),” akka‐dia.org, November 21, 2007. + +2. Ben Stopford: “[Shared Nothing vs. Shared Disk Architectures: An Independent View](http://www.benstopford.com/2009/11/24/understanding-the-shared-nothing-architecture/),” benstopford.com, November 24, 2009. + + +3. Michael Stonebraker: “[The Case for Shared Nothing](http://db.cs.berkeley.edu/papers/hpts85-nothing.pdf),” IEEE Database EngineeringBulletin, volume 9, number 1, pages 4–9, March 1986. +4. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS),May 2015. + +------ + +| 上一章 | 目錄 | 下一章 | +| ---------------------------- | ------------------------------- | ---------------------- | +| [第四章:編碼與演化](ch4.md) | [設計資料密集型應用](README.md) | [第五章:複製](ch5.md) | \ No newline at end of file diff --git a/zh-tw/part-iii.md b/zh-tw/part-iii.md new file mode 100644 index 00000000..59348a83 --- /dev/null +++ b/zh-tw/part-iii.md @@ -0,0 +1,44 @@ +# 第三部分:衍生資料 + +在本書的[第一部分](part-i.md)和[第二部分](part-ii.md)中,我們自底向上地把所有關於分散式資料庫的主要考量都過了一遍。從資料在磁碟上的佈局,一直到出現故障時分散式系統一致性的侷限。但所有的討論都假定了應用中只用了一種資料庫。 + +現實世界中的資料系統往往更為複雜。大型應用程式經常需要以多種方式訪問和處理資料,沒有一個數據庫可以同時滿足所有這些不同的需求。因此應用程式通常組合使用多種元件:資料儲存,索引,快取,分析系統,等等,並實現在這些元件中移動資料的機制。 + +本書的最後一部分,會研究將多個不同資料系統(可能有著不同資料模型,並針對不同的訪問模式進行最佳化)整合為一個協調一致的應用架構時,會遇到的問題。軟體供應商經常會忽略這一方面的生態建設,並聲稱他們的產品能夠滿足你的所有需求。在現實世界中,整合不同的系統是實際應用中最重要的事情之一。 + +## 記錄和衍生資料系統 + +從高層次上看,儲存和處理資料的系統可以分為兩大類: + +#### 記錄系統(System of record) + +**記錄系統**,也被稱為**真相源(source of truth)**,持有資料的權威版本。當新的資料進入時(例如,使用者輸入)首先會記錄在這裡。每個事實正正好好表示一次(表示通常是**標準化的(normalized)**)。如果其他系統和**記錄系統**之間存在任何差異,那麼記錄系統中的值是正確的(根據定義)。 + +#### 衍生資料系統(Derived data systems) + +**衍生系統**中的資料,通常是另一個系統中的現有資料以某種方式進行轉換或處理的結果。如果丟失衍生資料,可以從原始來源重新建立。典型的例子是**快取(cache)**:如果資料在快取中,就可以由快取提供服務;如果快取不包含所需資料,則降級由底層資料庫提供。非規範化的值,索引和物化檢視亦屬此類。在推薦系統中,預測彙總資料通常衍生自使用者日誌。 + +從技術上講,衍生資料是**冗餘的(redundant)**,因為它重複了已有的資訊。但是衍生資料對於獲得良好的只讀查詢效能通常是至關重要的。它通常是非規範化的。可以從單個源頭衍生出多個不同的資料集,使你能從不同的“視角”洞察資料。 + +並不是所有的系統都在其架構中明確區分**記錄系統**和**衍生資料系統**,但是這是一種有用的區分方式,因為它明確了系統中的資料流:系統的哪一部分具有哪些輸入和哪些輸出,以及它們如何相互依賴。 + +大多數資料庫,儲存引擎和查詢語言,本質上既不是記錄系統也不是衍生系統。資料庫只是一個工具:如何使用它取決於你自己。**記錄系統和衍生資料系統之間的區別不在於工具,而在於應用程式中的使用方式。** + +透過梳理資料的衍生關係,可以清楚地理解一個令人困惑的系統架構。這將貫穿本書的這一部分。 + +## 章節概述 + +我們將從[第十章](ch10.md)開始,研究例如MapReduce這樣**面向批處理(batch-oriented)**的資料流系統。對於建設大規模資料系統,我們將看到,它們提供了優秀的工具和思想。[第十一章](ch11.md)將把這些思想應用到**流式資料(data streams)**中,使我們能用更低的延遲完成同樣的任務。[第十二章](ch12.md)將對本書進行總結,探討如何使用這些工具來構建可靠,可擴充套件和可維護的應用。 + +## 索引 + +10. [批處理](ch10.md) +11. [流處理](ch11.md) +12. [資料系統的未來](ch12.md) + + +------ + +| 上一章 | 目錄 | 下一章 | +| ------------------------------ | ------------------------------- | ------------------------- | +| [第九章:一致性與共識](ch9.md) | [設計資料密集型應用](README.md) | [第十章:批處理](ch10.md) | \ No newline at end of file diff --git a/zh-tw/preface.md b/zh-tw/preface.md new file mode 100644 index 00000000..49414649 --- /dev/null +++ b/zh-tw/preface.md @@ -0,0 +1,102 @@ +# 序言 + +如果近幾年從業於軟體工程,特別是伺服器端和後端系統開發,那麼您很有可能已經被大量關於資料儲存和處理的時髦詞彙轟炸過了: 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,或一些在文獻中描述過但未公開發行的公司內部軟體)。 + +## 本書綱要 + +本書分為三部分: + +1. 在[第一部分](part-i.md)中,我們會討論設計資料密集型應用所賴的基本思想。我們從[第1章](ch1.md)開始,討論我們實際要達到的目標:可靠性,可擴充套件性和可維護性;我們該如何思考這些概念;以及如何實現它們。在[第2章](ch2.md)中,我們比較了幾種不同的資料模型和查詢語言,看看它們如何適用於不同的場景。在[第3章](ch3.md)中將討論儲存引擎:資料庫如何在磁碟上擺放資料,以便能高效地再次找到它。[第4章](ch4.md)轉向資料編碼(序列化),以及隨時間演化的模式。 + +2. 在[第二部分](part-ii.md)中,我們從討論儲存在一臺機器上的資料轉向討論分佈在多臺機器上的資料。這對於可擴充套件性通常是必需的,但帶來了各種獨特的挑戰。我們首先討論複製([第5章](ch5.md)),分割槽/分片([第6章](ch6.md))和事務([第7章](ch7.md))。然後我們將探索關於分散式系統問題的更多細節([第8章](ch8.md)),以及在分散式系統中實現一致性與共識意味著什麼([第9章](ch9.md))。 + +3. 在[第三部分](part-iii.md)中,我們討論那些從其他資料集衍生出一些資料集的系統。衍生資料經常出現在異構系統中:當沒有單個數據庫可以把所有事情都做的很好時,應用需要整合幾種不同的資料庫,快取,索引等。在[第10章](ch10.md)中我們將從一種衍生資料的批處理方法開始,然後在此基礎上建立在[第11章](ch11.md)中討論的流處理。最後,在[第12章](ch12.md)中,我們將所有內容彙總,討論在將來構建可靠,可伸縮和可維護的應用程式的方法。 + + + + +## 參考文獻與延伸閱讀 + +本書中討論的大部分內容已經在其它地方以某種形式出現過了 —— 會議簡報,研究論文,部落格文章,程式碼,BUG跟蹤器,郵件列表,以及工程習慣中。本書總結了不同來源資料中最重要的想法,並在文字中包含了指向原始文獻的連結。 如果你想更深入地探索一個領域,那麼每章末尾的參考文獻都是很好的資源,其中大部分可以免費線上獲取。 + + + +## O‘Reilly Safari + +[Safari](http://oreilly.com/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. + + + +## 致謝 + +本書融合了學術研究和工業實踐的經驗,融合並系統化了大量其他人的想法與知識。在計算領域,我們往往會被各種新鮮花樣所吸引,但我認為前人完成的工作中,有太多值得我們學習的地方了。本書有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,他們非常用心地為各章配了地圖。他們提出了不落俗套的靈感,創作了這些地圖,美麗而引人入勝,真是太棒了。 + +最後我要表達對家人和朋友們的愛,沒有他們,我將無法走完這個將近四年的寫作歷程。你們是最棒的。 \ No newline at end of file From 36f2daaeca57f7cfe7291abff4268309b798c512 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 01:21:47 +0800 Subject: [PATCH 05/12] add: translate file or repo --- transform.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/transform.py b/transform.py index 6465e845..54d311e9 100644 --- a/transform.py +++ b/transform.py @@ -4,18 +4,60 @@ import click import opencc +from pathlib import Path +from pprint import pprint -@click.command() -@click.option("-i", "--input", "infile", required=True) -@click.option("-o", "--output", "outfile", required=True) -@click.option("-c", "--config", "cfg", required=True, default="s2twp.json") -def main(infile, outfile, cfg): + +@click.group() +def cli(): + pass + + +def convert(infile: str, outfile: str, cfg: str): + """read >> convert >> write file + Args: + infile (str): input file + outfile (str): output file + cfg (str): config + """ converter = opencc.OpenCC(cfg) with open(infile, "r") as inf, open(outfile, "w+") as outf: data = inf.readlines() data = list(map(converter.convert, data)) outf.writelines(data) + print(f"Convert to {outfile}") + + +@cli.command() +@click.option("-i", "--input", "infile", required=True) +@click.option("-o", "--output", "outfile", required=True) +@click.option("-c", "--config", "cfg", required=True, default="s2twp.json") +def file(infile: str, outfile: str, cfg: str): + """read >> convert >> write file + Args: + infile (str): input file + outfile (str): output file + cfg (str): config + """ + convert(infile, outfile, cfg) + + +@cli.command() +@click.option("-i", "--input", "infolder", required=True) +@click.option("-o", "--output", "outfolder", required=True) +@click.option("-c", "--config", "cfg", required=True, default="s2twp.json") +def repo(infolder, outfolder, cfg): + if not Path(outfolder).exists(): + Path(outfolder).mkdir(parents=True) + print(f"Create {outfolder}") + infiles = Path(infolder).resolve().glob("*.md") + pair = [ + {"infile": str(infile), "outfile": str(Path(outfolder).resolve() / infile.name)} + for idx, infile in enumerate(infiles) + ] + for p in pair: + convert(p["infile"], p["outfile"], cfg) if __name__ == "__main__": - main() + cli() From 1a7f8f450fda0dbee3a8f02e1a124c161df081cd Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 01:22:15 +0800 Subject: [PATCH 06/12] rename --- transform.py => translate.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename transform.py => translate.py (100%) diff --git a/transform.py b/translate.py similarity index 100% rename from transform.py rename to translate.py From ac626a5b94fbbf733412b1a4513fa5725ae4b3b4 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 09:23:46 +0800 Subject: [PATCH 07/12] bugfix: file move structure --- zh-cn/ch1.md | 26 +++++++------- zh-cn/ch10.md | 22 ++++++------ zh-cn/ch11.md | 48 +++++++++++++------------- zh-cn/ch12.md | 12 +++---- zh-cn/ch2.md | 42 +++++++++++------------ zh-cn/ch3.md | 68 ++++++++++++++++++------------------- zh-cn/ch4.md | 32 +++++++++--------- zh-cn/ch5.md | 78 +++++++++++++++++++++--------------------- zh-cn/ch6.md | 34 +++++++++---------- zh-cn/ch7.md | 50 +++++++++++++-------------- zh-cn/ch8.md | 28 +++++++-------- zh-cn/ch9.md | 88 ++++++++++++++++++++++++------------------------ zh-cn/part-ii.md | 4 +-- 13 files changed, 266 insertions(+), 266 deletions(-) diff --git a/zh-cn/ch1.md b/zh-cn/ch1.md index 42555e35..463317ba 100644 --- a/zh-cn/ch1.md +++ b/zh-cn/ch1.md @@ -1,6 +1,6 @@ # 第一章:可靠性,可扩展性,可维护性 -![](img/ch1.png) +![](../img/ch1.png) > 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗? > @@ -40,9 +40,9 @@ ​ 其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。 -​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 +​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](../img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 -![](img/fig1-1.png) +![](../img/fig1-1.png) **图1-1 一个可能的组合使用多个组件的数据系统架构** @@ -174,7 +174,7 @@ 大体上讲,这一对操作有两种实现方式。 -1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: +1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](../img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: ```sql SELECT tweets.*, users.* @@ -183,13 +183,13 @@ JOIN follows ON follows.followee_id = users.id WHERE follows.follower_id = current_user ``` - ![](img/fig1-2.png) + ![](../img/fig1-2.png) **图1-2 推特主页时间线的关系型模式简单实现** -2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 +2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](../img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 - ![](img/fig1-3.png) + ![](../img/fig1-3.png) **图1-3 用于分发推特至关注者的数据流水线,2012年11月的负载参数【16】** @@ -220,9 +220,9 @@ ​ 即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。 -​ 在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 +​ 在[图1-4](../img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 -![](img/fig1-4.png) +![](../img/fig1-4.png) **图1-4 展示了一个服务100次请求响应时间的均值与百分位数** @@ -232,7 +232,7 @@ ​ 如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第50百分位点,有时缩写为p50。注意中位数是关于单个请求的;如果用户同时发出几个请求(在一个会话过程中,或者由于一个页面中包含了多个资源),则至少一个请求比中位数慢的概率远大于50%。 -​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。 +​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](../img/fig1-4.png)所示。 ​ 响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。 @@ -246,13 +246,13 @@ > #### 实践中的百分位点 > -> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 +> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](../img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 > > ​ 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。 > > ​ 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。 -![](img/fig1-5.png) +![](../img/fig1-5.png) **图1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求** @@ -376,7 +376,7 @@ ​ 不幸的是,使应用可靠、可扩展或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。 -​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子) +​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](../img/fig1-1.png)中的例子) diff --git a/zh-cn/ch10.md b/zh-cn/ch10.md index d039ab24..58de73f4 100644 --- a/zh-cn/ch10.md +++ b/zh-cn/ch10.md @@ -1,6 +1,6 @@ # 10. 批处理 -![](img/ch10.png) +![](../img/ch10.png) > 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。 > @@ -247,11 +247,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(参阅“[MapReduce查询](ch2.md#MapReduce查询)”)。 -​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。 +​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](../img/fig10-1.png)中的m1,m2和m3标记)。 ​ 每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。 -![](img/fig10-1.png) +![](../img/fig10-1.png) **图10-1 具有三个Mapper和三个Reducer的MapReduce任务** @@ -297,9 +297,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 示例:分析用户活动事件 -​ [图10-2](img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 +​ [图10-2](../img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 -![](img/fig10-2.png) +![](../img/fig10-2.png) **图10-2 用户行为日志与用户档案的连接** @@ -313,9 +313,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 排序合并连接 -​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。 +​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](../img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](../img/fig10-3.png)所示。 -![](img/fig10-3.png) +![](../img/fig10-3.png) **图10-3 在用户ID上进行的Reduce端连接。如果输入数据集分区为多个文件,则每个分区都会被多个Mapper并行处理** @@ -375,11 +375,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。 -​ 例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 +​ 例如,假设在[图10-2](../img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 [^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。 -​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 +​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](../img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 ​ 这种简单有效的算法被称为**广播散列连接(broadcast hash join)**:**广播**一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),**散列**一词反映了它使用一个散列表。 Pig(名为“**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用【41】。 @@ -387,7 +387,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 分区散列连接 -​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 +​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](../img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 ​ 如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。 @@ -612,7 +612,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 > 像Spark,Flink和Tez这样的数据流引擎(参见“[中间状态的物化](#中间状态的物化)”)通常将算子作为**有向无环图(DAG)**的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱! -​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 +​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](../img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 ​ 可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种“重复至完成”的想法不能用普通的MapReduce来表示,因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现: diff --git a/zh-cn/ch11.md b/zh-cn/ch11.md index 97309aad..983e63e0 100644 --- a/zh-cn/ch11.md +++ b/zh-cn/ch11.md @@ -1,6 +1,6 @@ # 11. 流处理 -![](img/ch11.png) +![](../img/ch11.png) > 有效的复杂系统总是从简单的系统演化而来。 反之亦然:从零设计的复杂系统没一个能有效工作的。 > @@ -94,7 +94,7 @@ #### 多个消费者 -当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示: +当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](../img/fig11-1.png)所示: ***负载均衡(load balance)*** @@ -104,7 +104,7 @@ ​ 每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。 -![](img/fig11-1.png) +![](../img/fig11-1.png) **图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。** @@ -116,9 +116,9 @@ ​ 如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样) -​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 +​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](../img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 -![](img/fig11-2.png) +![](../img/fig11-2.png) **图11-2 在处理m3时消费者2崩溃,因此稍后重传至消费者1** @@ -142,11 +142,11 @@ ​ 同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。 -​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。 +​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](../img/fig11-3.png)所示。 -​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 +​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](../img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 -![](img/fig11-3.png) +![](../img/fig11-3.png) **图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件** @@ -223,9 +223,9 @@ ​ 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是**双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。 -​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 +​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](../img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 -![](img/fig11-4.png) +![](../img/fig11-4.png) **图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达** @@ -233,7 +233,7 @@ ​ 双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。 -​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 +​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](../img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 ​ 如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗? @@ -245,9 +245,9 @@ ​ 最近,人们对**变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。 -​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。 +​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](../img/fig11-5.png)所示。 -![](img/fig11-5.png) +![](../img/fig11-5.png) **图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统** @@ -255,7 +255,7 @@ ​ 我们可以将日志消费者叫做**衍生数据系统**,正如在第三部分的[介绍](part-iii.md)中所讨论的:存储在搜索索引和数据仓库中的数据,只是**记录系统**数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。 -​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。 +​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](../img/fig11-2.png)的重新排序问题)。 ​ 数据库触发器可用来实现变更数据捕获(参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如应对模式变更。 @@ -275,7 +275,7 @@ ​ 如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)** 提供了一个很好的备选方案。 -​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 +​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](../img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 ​ 在日志结构存储引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。 @@ -299,7 +299,7 @@ ​ 与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上: -* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 +* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](../img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 * 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。 事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。 @@ -345,12 +345,12 @@ ​ 无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(change log)**,表示了随时间演变的状态。 -​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 +​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](../img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 $$ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ stream(t) = \frac{d\ state(t)}{dt} $$ -![](img/fig11-6.png) +![](../img/fig11-6.png) **图11-6 应用当前状态与事件流之间的关系** @@ -372,7 +372,7 @@ $$ #### 从同一事件日志中派生多个视图 -​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 +​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](../img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 ​ 添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。 @@ -412,7 +412,7 @@ $$ 剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项: -1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 +1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](../img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。 3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项1或2)。 @@ -507,9 +507,9 @@ $$ [^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。 -​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](img/fig11-7.png))。 +​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](../img/fig11-7.png))。 -![](img/fig11-7.png) +![](../img/fig11-7.png) **图11-7 按处理时间分窗,会因为处理速率的变动引入人为因素** @@ -580,7 +580,7 @@ $$ #### 流表连接(流扩展) -​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 +​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](../img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 ​ 要执行此联接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在“[示例:分析用户活动事件](ch10.md#示例:分析用户活动事件)”一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。 @@ -615,7 +615,7 @@ GROUP BY follows.follower_id ​ 流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当基础表发生变化时都会更新[^iii]。 -[^iii]: 如果你将流视作表的衍生物,如[图11-6](img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 +[^iii]: 如果你将流视作表的衍生物,如[图11-6](../img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 #### 连接的时间依赖性 diff --git a/zh-cn/ch12.md b/zh-cn/ch12.md index 63b3fd0d..99496056 100644 --- a/zh-cn/ch12.md +++ b/zh-cn/ch12.md @@ -1,6 +1,6 @@ # 12. 数据系统的未来 -![](img/ch12.png) +![](../img/ch12.png) > 如果船长的终极目标是保护船只,他应该永远待在港口。 > @@ -42,7 +42,7 @@ ​ 例如,你可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 -​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 +​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](../img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 ​ 如果您可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。 这是状态机复制方法的一个应用,我们在“[全序广播](ch9.md#全序广播)”中看到。无论您使用变更数据捕获还是事件源日志,都不如仅对全局顺序达成共识更重要。 @@ -328,9 +328,9 @@ ### 观察衍生数据状态 -​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了一个更新搜索索引的例子。 +​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](../img/fig12-1.png)显示了一个更新搜索索引的例子。 -![](img/fig12-1.png) +![](../img/fig12-1.png) **图12-1 在搜索索引中,写(文档更新)遇上读(查询)** @@ -338,7 +338,7 @@ ​ 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。 -​ 如[图12-1](img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 +​ 如[图12-1](../img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 #### 物化视图和缓存 @@ -454,7 +454,7 @@ ​ 除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP使用数据包上的序列号,在接收方将它们正确排序。并确定网络上是否有数据包丢失或重复。任何丢失的数据包都会被重新传输,而在将数据交付应用前,TCP协议栈会移除任何重复数据包。 -​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](img/fig8-1.png))。 +​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](../img/fig8-1.png))。 **例12-1 资金从一个账户到另一个账户的非幂等转移** diff --git a/zh-cn/ch2.md b/zh-cn/ch2.md index 4fbc1796..ed2dbbfd 100644 --- a/zh-cn/ch2.md +++ b/zh-cn/ch2.md @@ -1,6 +1,6 @@ # 2. 数据模型与查询语言 -![](img/ch2.png) +![](../img/ch2.png) > 语言的边界就是思想的边界。 > @@ -65,13 +65,13 @@ 像ActiveRecord和Hibernate这样的 **对象关系映射(ORM object-relational mapping)** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。 -![](img/fig2-1.png) +![](../img/fig2-1.png) **图2-1 使用关系型模式来表示领英简介** -例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: +例如,[图2-1](../img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: -* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](img/fig2-1.png)所示。 +* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](../img/fig2-1.png)所示。 * 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。JSON数据类型也得到多个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。 * 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。 @@ -119,11 +119,11 @@ 有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。 -JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 +JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 -从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。 +从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](../img/fig2-2.png))。 -![](img/fig2-2.png) +![](../img/fig2-2.png) **图2-2 一对多关系构建了一个树结构** @@ -157,18 +157,18 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 ***组织和学校作为实体*** -在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。 +在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](../img/fig2-3.png),来自LinkedIn的一个例子)。 ***推荐*** 假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何建议都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。 -![](img/fig2-3.png) +![](../img/fig2-3.png) **图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)** -[图2-4](img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 +[图2-4](../img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 -![](img/fig2-4.png) +![](../img/fig2-4.png) **图2-4 使用多对多关系扩展简历** @@ -178,7 +178,7 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用和维护,运行在IBM大型机的OS/390上【14】。 -IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](img/fig2-2.png)的JSON结构。 +IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](../img/fig2-2.png)的JSON结构。 同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。 @@ -226,7 +226,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数 #### 哪个数据模型更方便写代码? -如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 +如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](../img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。 @@ -274,7 +274,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 查询的数据局部性 -文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 +文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](../img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。 @@ -533,9 +533,9 @@ db.observations.aggregate([ 在刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人,网页或交叉路口)。不过,图并不局限于这样的同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到和用户的评论;边缘表示哪些人是彼此的朋友,哪个签到发生在何处,谁评论了哪条消息,谁参与了哪个事件,等等【35】。 -在本节中,我们将使用[图2-5](img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 +在本节中,我们将使用[图2-5](../img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 -![](img/fig2-5.png) +![](../img/fig2-5.png) **图2-5 图数据结构示例(框代表顶点,箭头代表边)** @@ -586,7 +586,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动。(这就是为什么[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) 3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。 -这些特性为数据建模提供了很大的灵活性,如[图2-5](img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 +这些特性为数据建模提供了很大的灵活性,如[图2-5](../img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 你可以想象延伸图还能包括许多关于Lucy和Alain,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图表在可演化性是富有优势的:当向应用程序添加功能时,可以轻松扩展图以适应应用程序数据结构的变化。 @@ -594,7 +594,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); Cypher是属性图的声明式查询语言,为Neo4j图形数据库而发明【37】。(它是以电影“黑客帝国”中的一个角色来命名的,而与密码术中的密码无关【38】。) -[例2-3]()显示了将[图2-5](img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 +[例2-3]()显示了将[图2-5](../img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 **例2-3 将图2-5中的数据子集表示为Cypher查询** @@ -608,7 +608,7 @@ CREATE (Lucy) -[:BORN_IN]-> (Idaho) ``` -当[图2-5](img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 +当[图2-5](../img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 [例2-4]()展示了如何在Cypher中表达这个查询。在MATCH子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`边的任意两个顶点。该边的尾节点被绑定了变量`person`,头节点则未被绑定。 @@ -896,9 +896,9 @@ Cypher和SPARQL使用SELECT立即跳转,但是Datalog一次只进行一小步 2. 数据库存在`within(usa, namerica)`,在上一步骤中生成`within_recursive(namerica, 'North America')`,故运用规则2。它会产生`within_recursive(usa, 'North America')`。 3. 数据库存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故运用规则2。它产生`within_recursive(idaho, 'North America')`。 -通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](img/fig2-6.png)所示。 +通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](../img/fig2-6.png)所示。 -![](img/fig2-6.png) +![](../img/fig2-6.png) **图2-6 使用示例2-11中的Datalog规则来确定爱达荷州在北美。** diff --git a/zh-cn/ch3.md b/zh-cn/ch3.md index 8cbf6fed..881e8281 100644 --- a/zh-cn/ch3.md +++ b/zh-cn/ch3.md @@ -1,6 +1,6 @@ # 3. 存储与检索 -![](img/ch3.png) +![](../img/ch3.png) > 建立秩序,省却搜索 > @@ -83,9 +83,9 @@ $ cat database 键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢? -假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 +假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](../img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 -![](img/fig3-1.png) +![](../img/fig3-1.png) **图3-1 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。** @@ -93,15 +93,15 @@ $ cat database 像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。 -直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 +直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](../img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 -![](img/fig3-2.png) +![](../img/fig3-2.png) **图3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值** -而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 +而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](../img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 -![](img/fig3-3.png) +![](../img/fig3-3.png) **图3-3 同时执行压缩和分段合并** @@ -148,30 +148,30 @@ $ cat database ### SSTables和LSM树 -在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 +在[图3-3](../img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。 我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势: -1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 +1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](../img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 - ![](img/fig3-4.png) + ![](../img/fig3-4.png) ##### 图3-4 合并几个SSTable段,只保留每个键的最新值 如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。 -2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 +2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](../img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 - ![](img/fig3-5.png) + ![](../img/fig3-5.png) **图3-5 具有内存索引的SSTable** 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。 -3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](../img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 [^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) @@ -217,25 +217,25 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使 我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。 -每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。 +每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](../img/fig3-6.png)所示。 -![](img/fig3-6.png) +![](../img/fig3-6.png) **图3-6 使用B树索引查找一个键** 一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。 -在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 +在[图3-6](../img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。 -在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 +在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](../img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 -如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。 +如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](../img/fig3-7.png)所示[^ii]。 [^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 -![](img/fig3-7.png) +![](../img/fig3-7.png) **图3-7 通过分割页面来生长B树** @@ -299,7 +299,7 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载 到目前为止,我们只讨论了关键值索引,它们就像关系模型中的**主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。 -有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 +有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](../img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。 @@ -398,9 +398,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 这些OLTP系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以DBA会密切关注他们的OLTP数据库,他们通常不愿意让业务分析人员在OLTP数据库上运行临时分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时执行的事务的性能。 -相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8)所示。 +相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](../img/fig3-8)所示。 -![](img/fig3-8.png) +![](../img/fig3-8.png) **图3-8 ETL至数据仓库的简化提纲** @@ -424,7 +424,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 -![](img/fig3-9.png) +![](../img/fig3-9.png) **图3-9 用于数据仓库的星型模式的示例** @@ -432,7 +432,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。 -例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 +例如,在[图3-9](../img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 即使日期和时间通常使用维度表来表示,因为这允许对日期(诸如公共假期)的附加信息进行编码,从而允许查询区分假期和非假期的销售。 @@ -469,13 +469,13 @@ GROUP BY 我们如何有效地执行这个查询? -在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。 +在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](../img/fig3-1.png)的CSV例子中看到这个。 为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。 -面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。 +面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](../img/fig3-10.png)所示。 -![](img/fig3-10.png) +![](../img/fig3-10.png) **图3-10 使用列存储关系型数据,而不是行** @@ -489,9 +489,9 @@ GROUP BY 除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。 -看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。 +看看[图3-10](../img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](../img/fig3-11.png)所示。 -![](img/fig3-11.png) +![](../img/fig3-11.png) **图3-11 压缩位图索引存储布局** @@ -536,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3 相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。 -第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 +第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](../img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 -排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 +排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](../img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。 @@ -568,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3 当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。 -物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。 +物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](../img/fig3-12.png)显示了一个例子。 -![](img/fig3-12.png) +![](../img/fig3-12.png) **图3-12 数据立方的两个维度,通过求和聚合** -想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 +想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](../img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。 diff --git a/zh-cn/ch4.md b/zh-cn/ch4.md index d047edd1..f45c9aff 100644 --- a/zh-cn/ch4.md +++ b/zh-cn/ch4.md @@ -1,6 +1,6 @@ # 4. 编码与演化 -![](img/ch4.png) +![](../img/ch4.png) > 唯变所适 > @@ -113,7 +113,7 @@ JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事 在下面的章节中,能达到比这好得多的结果,只用32个字节对相同的记录进行编码。 -![](img/fig4-1.png) +![](../img/fig4-1.png) **图4-1 使用MessagePack编码的记录(例4-1)** @@ -141,9 +141,9 @@ message Person { ``` Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。您的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。 -用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](img/fig4-2.png)所示【19】。 +用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](../img/fig4-2.png)所示【19】。 -![](img/fig4-2.png) +![](../img/fig4-2.png) **图4-2 使用Thrift二进制协议编码的记录** @@ -151,17 +151,17 @@ Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用 与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。 -与[图4-1](img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 +与[图4-1](../img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 -Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 +Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](../img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 -![](img/fig4-3.png) +![](../img/fig4-3.png) **图4-3 使用Thrift压缩协议编码的记录** -最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 +最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 -![](img/fig4-4.png) +![](../img/fig4-4.png) **图4-4 使用Protobuf编码的记录** @@ -183,7 +183,7 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4 如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被扼杀。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。 -Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 +Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](../img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。 @@ -217,11 +217,11 @@ record Person { } ``` -首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。 +首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](../img/fig4-5.png)所示。 如果您检查字节序列,您可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。 -![](img/fig4-5.png) +![](../img/fig4-5.png) **图4-5 使用Avro编码的记录** @@ -235,11 +235,11 @@ record Person { 当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能是从该模式生成的。 -Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。 +Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](../img/fig4-6.png)所示。 例如,如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。 -![](img/fig4-6.png) +![](../img/fig4-6.png) **图4-6 一个Avro Reader解决读写模式的差异** @@ -344,7 +344,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它 解决这个问题不是一个难题,你只需要意识到它。 -![](img/fig4-7.png) +![](../img/fig4-7.png) **图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。** @@ -474,7 +474,7 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承 消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。 -如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。 +如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](../img/fig4-7.png))。 #### 分布式的Actor框架 diff --git a/zh-cn/ch5.md b/zh-cn/ch5.md index e7499fd7..ed4405ba 100644 --- a/zh-cn/ch5.md +++ b/zh-cn/ch5.md @@ -1,6 +1,6 @@ # 5. 复制 -![](img/ch5.png) +![](../img/ch5.png) > 与可能出错的东西比,'不可能'出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。 > @@ -36,7 +36,7 @@ [^i]: 不同的人对**热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)**指的是能接受客户端读请求的副本。而**温备(warm standby)**只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。 -![](img/fig5-1.png) +![](../img/fig5-1.png) **图5-1 基于领导者(主-从)的复制** ​ 这种复制模式是许多关系数据库的内置功能,如PostgreSQL(从9.0版本开始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库,包括MongoDB,RethinkDB和Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如DRBD这样的块复制设备也与之类似。 @@ -47,9 +47,9 @@ ​ 想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。 -[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 +[图5-2](../img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 -![](img/fig5-2.png) +![](../img/fig5-2.png) **图5-2 基于领导者的复制:一个同步从库和一个异步从库** ​ 在[图5-2]()的示例中,从库1的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库1的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的:主库发送消息,但不等待从库的响应。 @@ -205,7 +205,7 @@ ​ 但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。 -![](img/fig5-3.png) +![](../img/fig5-3.png) **图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常** @@ -236,9 +236,9 @@ ​ 从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流(moving backward in time)**。 -​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 +​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](../img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 -![](img/fig5-4.png) +![](../img/fig5-4.png) **图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。** @@ -260,7 +260,7 @@ 这两句话之间有因果关系:Cake夫人听到了Poons先生的问题并回答了这个问题。 -​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容: +​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](../img/fig5-5.png))。 于是,这个观察者会听到以下内容: > *Mrs. Cake* > ​ 通常约十秒钟,Mr. Poons. @@ -271,7 +271,7 @@ 对于观察者来说,看起来好像Cake夫人在Poons先生发问前就回答了这个问题。 这种超能力让人印象深刻,但也会把人搞糊涂。【25】。 -![](img/fig5-5.png) +![](../img/fig5-5.png) **图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。** @@ -311,9 +311,9 @@ ​ 假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 -​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 +​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](../img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 -![](img/fig5-6.png) +![](../img/fig5-6.png) **图5-6 跨多个数据中心的多主复制** @@ -333,7 +333,7 @@ ​ 有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于MySQL的Tungsten Replicator 【26】,用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。 -​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 +​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](../img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 ​ 由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免【28】。 @@ -361,9 +361,9 @@ ​ 多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。 -​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 +​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](../img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 -![](img/fig5-7.png) +![](../img/fig5-7.png) **图5-7 两个主库同时更新同一记录引起的写入冲突** @@ -385,7 +385,7 @@ ​ 单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。 -​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 +​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](../img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 ​ 如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库1的C和主库2的B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种**收敛(convergent)**的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。 @@ -393,7 +393,7 @@ * 给每个写入一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为**最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在[本章末尾](#检测并发写入)更详细地讨论LWW。 * 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。 -* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 +* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](../img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 * 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。 @@ -431,7 +431,7 @@ #### 什么是冲突? -​ 有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 +​ 有些冲突是显而易见的。在[图5-7](../img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 ​ 其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。 @@ -443,7 +443,7 @@ ​ **复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如[图5-7]()所示,只有一个合理的拓扑结构:领导者1必须把他所有的写到领导者2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图5-8]()举例说明了一些例子。 -![](img/fig5-8.png) +![](../img/fig5-8.png) **图5-8 三个可以设置多领导者复制的示例拓扑。** @@ -455,13 +455,13 @@ ​ 循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。 -​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。 +​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](../img/fig5-9.png)所示。 -![](img/fig5-9.png) +![](../img/fig5-9.png) **图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。** -​ 在[图5-9](img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 +​ 在[图5-9](../img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 ​ 这是一个因果关系的问题,类似于我们在“[一致前缀读](ch8.md#一致前缀读)”中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,以便在主库2处正确地排序这些事件(见[第8章](ch8.md))。 @@ -485,9 +485,9 @@ ​ 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(参阅「[处理节点宕机](#处理节点宕机)」)。 -​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 +​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](../img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 -![](img/fig5-10.png) +![](../img/fig5-10.png) **图5-10 法定写入,法定读取,并在节点中断后读修复。** @@ -503,7 +503,7 @@ ***读修复(Read repair)*** -​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 +​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](../img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 ***反熵过程(Anti-entropy process)*** @@ -513,7 +513,7 @@ #### 读写的法定人数 -​ 在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? +​ 在[图5-10](../img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? ​ 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。 @@ -531,10 +531,10 @@ * 如果$w n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入** @@ -544,7 +544,7 @@ ### 法定人数一致性的局限性 -​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。 +​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](../img/fig5-11.png)所示)。 ​ 通常,r和w被选为多数(超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。 @@ -608,17 +608,17 @@ ​ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**提示移交**期间也可能会产生冲突。 -​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: +​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](../img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: * 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 * 节点 3 首先接收来自 B 的写入,然后从 A 写入。 -![](img/fig5-12.png) +![](../img/fig5-12.png) **图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。** -​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 +​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](../img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 ​ 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。 @@ -628,7 +628,7 @@ ​ 实现最终融合的一种方法是声明每个副本只需要存储最**“最近”**的值,并允许**“更旧”**的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。 -​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 +​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](../img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 ​ 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入胜利(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。 @@ -673,13 +673,13 @@ 4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿] 5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。 -![](img/fig5-13.png) +![](../img/fig5-13.png) **图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。** -​ [图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 +​ [图5-13](../img/fig5-13.png)中的操作之间的数据流如[图5-14](../img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 -![](img/fig5-14.png) +![](../img/fig5-14.png) **图5-14 图5-13中的因果依赖关系图。** @@ -698,7 +698,7 @@ ​ 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。 -​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 +​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](../img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 ​ 然而,如果你想让人们也可以从他们的手推车中**删除**东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果:如果你合并了两个兄弟手推车,并且只在其中一个兄弟值里删掉了它,那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有合适版本号的标记,以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑(tombstone)**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。) @@ -706,13 +706,13 @@ #### 版本向量 -​ [图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? +​ [图5-13](../img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? -​ [图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 +​ [图5-13](../img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 ​ 所有副本的版本号集合称为**版本向量(version vector)**【56】。这个想法的一些变体正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。 -​ 与[图5-13](img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 +​ 与[图5-13](../img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 ​ 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。 diff --git a/zh-cn/ch6.md b/zh-cn/ch6.md index 4dfd6531..c2acbc21 100644 --- a/zh-cn/ch6.md +++ b/zh-cn/ch6.md @@ -1,6 +1,6 @@ # 6. 分区 -![](img/ch6.png) +![](../img/ch6.png) > 我们必须跳出电脑指令序列的窠臼。 叙述定义、描述元数据、梳理关系,而不是编写过程。 > @@ -37,7 +37,7 @@ ​ 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 -![](img/fig6-1.png) +![](../img/fig6-1.png) **图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。** @@ -57,7 +57,7 @@ ​ 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 -![](img/fig6-2.png) +![](../img/fig6-2.png) **图6-2 印刷版百科全书按照关键字范围进行分区** @@ -79,9 +79,9 @@ ​ 出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于哈希表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。 -​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 +​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](../img/fig6-3.png)所示。 -![](img/fig6-3.png) +![](../img/fig6-3.png) **图6-3 按哈希键分区** @@ -125,19 +125,19 @@ ### 基于文档的二级索引进行分区 -​ 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 +​ 假设你正在经营一个销售二手车的网站(如[图6-4](../img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 ​ 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 [^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。 -![](img/fig6-4.png) +![](../img/fig6-4.png) **图6-4 基于文档的二级索引进行分区** ​ 在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库(添加,删除或更新文档),只需处理包含您正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引(local index)**(而不是将在下一节中描述的**全局索引(global index)**)。 -​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 +​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](../img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 ​ 这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使二级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区二级索引。大多数数据库供应商建议您构建一个能从单个分区提供二级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个二级索引时(例如同时需要按颜色和制造商查询)。 @@ -147,9 +147,9 @@ ​ 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 -​ [图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 +​ [图6-5](../img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 -![](img/fig6-5.png) +![](../img/fig6-5.png) **图6-5 基于关键词对二级索引进行分区** @@ -188,7 +188,7 @@ #### 反面教材:hash mod N -​ 我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key) ​ 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。 > @@ -90,11 +90,11 @@ ACID一致性的概念是,**对数据的一组特定约束必须始终成立** 大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。 -[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 +[图7-1](../img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](../img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。 -![](img/fig7-1.png) +![](../img/fig7-1.png) **图7-1 两个客户之间的竞争状态同时递增计数器** @@ -137,7 +137,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的** 同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。 -这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: +这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](../img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: ```sql SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -145,17 +145,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。 -在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 +在[图7-2](../img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 [^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。 -![](img/fig7-2.png) +![](../img/fig7-2.png) **图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。** -[图7-3](img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 +[图7-3](../img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 -![](img/fig7-3.png) +![](../img/fig7-3.png) **图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致** @@ -175,7 +175,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。 -一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 +一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](../img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv] @@ -190,7 +190,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象: * 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。 -* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 +* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](../img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 * 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。 这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。 @@ -250,14 +250,14 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。 -![](img/fig7-4.png) +![](../img/fig7-4.png) **图7-4 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。** 为什么要防止脏读,有几个原因: -- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 -- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 +- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](../img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 +- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](../img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 #### 没有脏写 @@ -267,10 +267,10 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 通过防止脏写,这个隔离级别避免了一些并发问题: -- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 +- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](../img/fig7-5.png),[图7-5](../img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](../img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 - 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。 -![](img/fig7-5.png) +![](../img/fig7-5.png) **图7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起** @@ -292,9 +292,9 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许**中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混合。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。 -但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](img/fig7-6.png)说明了读已提交时可能发生的问题。 +但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](../img/fig7-6.png)说明了读已提交时可能发生的问题。 -![](img/fig7-6.png) +![](../img/fig7-6.png) **图7-6 读取偏差:Alice观察数据库处于不一致的状态** @@ -332,7 +332,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true [^vii]: 事实上,事务ID是32位整数,所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。 -![](img/fig7-7.png) +![](../img/fig7-7.png) **图7-7 使用多版本对象实现快照隔离** @@ -475,9 +475,9 @@ UPDATE wiki_pages SET content = '新内容' 首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作【40,41】。 -现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](img/fig7-8.png)说明了接下来的事情。 +现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](../img/fig7-8.png)说明了接下来的事情。 -![](img/fig7-8.png) +![](../img/fig7-8.png) **图7-8 写入偏差导致应用程序错误的示例** @@ -626,9 +626,9 @@ COMMIT; ​ 在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。 -​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 +​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](../img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 -![](img/fig7-9.png) +![](../img/fig7-9.png) **图7-9 交互式事务和存储过程之间的区别(使用图7-8的示例事务)** @@ -793,7 +793,7 @@ WHERE room_id = 123 AND 回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43 认为Alice的 `on_call = true` ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。 -![](img/fig7-10.png) +![](../img/fig7-10.png) **图7-10 检测事务何时从MVCC快照读取过时的值** @@ -803,9 +803,9 @@ WHERE room_id = 123 AND #### 检测影响之前读取的写入 -第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](img/fig7-11.png)所示。 +第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](../img/fig7-11.png)所示。 -![](img/fig7-11.png) +![](../img/fig7-11.png) **图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。** diff --git a/zh-cn/ch8.md b/zh-cn/ch8.md index d047d162..382487de 100644 --- a/zh-cn/ch8.md +++ b/zh-cn/ch8.md @@ -1,6 +1,6 @@ # 第八章:分布式系统的麻烦 -![](img/ch8.png) +![](../img/ch8.png) > 邂逅相遇 > @@ -97,7 +97,7 @@ ​ **无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。 -​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示): +​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](../img/fig8-1.png)所示): 1. 请求可能已经丢失(可能有人拔掉了网线)。 2. 请求可能正在排队,稍后将交付(也许网络或收件人超载)。 @@ -106,7 +106,7 @@ 5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。 6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。 -![](img/fig8-1.png) +![](../img/fig8-1.png) **图8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。** @@ -168,12 +168,12 @@ ​ 在驾驶汽车时,由于交通拥堵,道路交通网络的通行时间往往不尽相同。同样,计算机网络上数据包延迟的可变性通常是由于排队【25】: -* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 +* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](../img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 * 当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。 * 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,而另一个虚拟机使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。 * TCP执行**流量控制(flow control)**(也称为**拥塞避免(congestion avoidance)**或**背压(backpressure)**),其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队。 -![](img/fig8-2.png) +![](../img/fig8-2.png) **图8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口1,2和4都试图发送数据包到端口3** @@ -319,20 +319,20 @@ ​ 让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近? -​ [图8-3](img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 +​ [图8-3](../img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](../img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 -![](img/fig8-3.png) +![](../img/fig8-3.png) **图8-3 客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。** ​ 在[图8-3]()中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的时钟时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点1和节点3之间的偏差小于3ms,这可能比你在实践中预期的更好。 -​ 尽管如此,[图8-3](img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 +​ 尽管如此,[图8-3](../img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 ​ 这种冲突解决策略被称为**最后写入胜利(LWW)**,它在多领导者复制和无领导者数据库(如Cassandra 【53】和Riak 【54】)中被广泛使用(参见“[最后写入胜利(丢弃并发写入)](#最后写入胜利(丢弃并发写入))”一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题: * 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。 -* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 +* LWW无法区分**高频顺序写入**(在[图8-3](../img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 * 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的**决胜值(tiebreaker)**(可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系【53】。 因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**时钟**,这很可能是不正确的。即使用频繁同步的NTP时钟,一个数据包也可能在时间戳100毫秒(根据发送者的时钟)时发送,并在时间戳99毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。 @@ -485,9 +485,9 @@ while(true){ ​ 如果一个节点继续表现为**天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。 -​ 例如,[图8-4](img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 +​ 例如,[图8-4](../img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 -![](img/fig8-4.png) +![](../img/fig8-4.png) **图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件** @@ -495,15 +495,15 @@ while(true){ #### 防护令牌 -​ 当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 +​ 当使用锁或租约来保护对某些资源(如[图8-4](../img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 -![](img/fig8-5.png) +![](../img/fig8-5.png) **图8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全** ​ 我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。 -​ 在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 +​ 在[图8-5](../img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 ​ 如果将ZooKeeper用作锁定服务,则可将事务标识`zxid`或节点版本`cversion`用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。 diff --git a/zh-cn/ch9.md b/zh-cn/ch9.md index 0936689a..85317a33 100644 --- a/zh-cn/ch9.md +++ b/zh-cn/ch9.md @@ -1,6 +1,6 @@ # 9. 一致性与共识 -![](img/ch9.png) +![](../img/ch9.png) > 好死不如赖活着 > —— Jay Kreps, 关于Kafka与 Jepsen的若干笔记 (2013) @@ -60,11 +60,11 @@ ​ 在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。 -![](img/fig9-1.png) +![](../img/fig9-1.png) **图9-1 这个系统是非线性一致的,导致了球迷的困惑** -​ [图9-1 ](img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 +​ [图9-1 ](../img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 ​ 如果Alice和Bob在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而Bob是在听到Alice惊呼最后得分**之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。 @@ -72,13 +72,13 @@ ​ 线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。 -​ [图9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 +​ [图9-2](../img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 -![](img/fig9-2.png) +![](../img/fig9-2.png) **图9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值** -​ 为了简单起见,[图9-2](img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] +​ 为了简单起见,[图9-2](../img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] [^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。即使真实的系统通常没有准确的时钟(参阅“[不可靠的时钟](ch8.md#不可靠的时钟)”),但这种假设是允许的:为了分析分布式算法,我们可以假设一个精确的全局时钟存在,不过算法无法访问它【47】。算法只能看到由石英振荡器和NTP产生的实时逼近。 @@ -89,7 +89,7 @@ * $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。 -在[图9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? +在[图9-2](../img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? * 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`。 * 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。 @@ -99,16 +99,16 @@ [^ii]: 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为**常规寄存器(regular register)**【7,25】 -为了使系统线性一致,我们需要添加另一个约束,如[图9-3](img/fig9-3.png)所示 +为了使系统线性一致,我们需要添加另一个约束,如[图9-3](../img/fig9-3.png)所示 -![](img/fig9-3.png) +![](../img/fig9-3.png) **图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。** ​ 在一个线性一致的系统中,我们可以想象,在 `x` 的值从`0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。 -​ [图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) +​ [图9-3](../img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) -​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](img/fig9-4.png)显示了一个更复杂的例子【10】。 +​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](../img/fig9-4.png)显示了一个更复杂的例子【10】。 在[图9-4]()中,除了读写之外,还增加了第三种类型的操作: @@ -118,7 +118,7 @@ ​ 线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。 -![](img/fig9-4.png) +![](../img/fig9-4.png) **图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的** @@ -130,7 +130,7 @@ * 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,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更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。 +* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同。 这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。 @@ -182,17 +182,17 @@ #### 跨信道的时序依赖 -​ 注意[图9-1](img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 +​ 注意[图9-1](../img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 -​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](img/fig9-5.png)所示。 +​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](../img/fig9-5.png)所示。 ​ 图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的(参阅[第11章](ch11.md))。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。 -![](img/fig9-5.png) +![](../img/fig9-5.png) **图9-5 Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性。** -​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 +​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](../img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 -​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 +​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](../img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 ​ 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在Alice和Bob的例子),则可以使用在“[读己之写](ch5.md#读己之写)”讨论过的备选方法,不过会有额外的复杂度代价。 @@ -230,13 +230,13 @@ #### 线性一致性和法定人数 -​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](img/fig9-6.png)所示。 +​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](../img/fig9-6.png)所示。 -![](img/fig9-6.png) +![](../img/fig9-6.png) **图9-6 非线性一致的执行,尽管使用了严格的法定人数** -​ 在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 +​ 在[图9-6](../img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 ​ 法定人数条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]()) @@ -252,9 +252,9 @@ ​ 一些复制方法可以提供线性一致性,另一些复制方法则不能,因此深入地探讨线性一致性的优缺点是很有趣的。 -​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](img/fig9-7.png)说明了这种部署的一个例子。 +​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](../img/fig9-7.png)说明了这种部署的一个例子。 -![](img/fig9-7.png) +![](../img/fig9-7.png) **图9-7 网络中断迫使在线性一致性和可用性之间做出选择。** @@ -311,7 +311,7 @@ ## 顺序保证 -​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。 +​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](../img/fig9-4.png)中的顺序。 **顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它**顺序**曾经出现过的上下文: @@ -325,12 +325,12 @@ **顺序**反复出现有几个原因,其中一个原因是,它有助于保持**因果关系(causality)**。在本书中我们已经看到了几个例子,其中因果关系是很重要的: -* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 -* [图5-9](img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 +* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](../img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 +* [图5-9](../img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 * 在“[检测并发写入](ch5.md#检测并发写入)”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B**并发**。这种**此前发生(happened before)**关系是因果关系的另一种表述:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是**并发**的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。 -* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](img/fig7-6)所示)。 -* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 -* 在爱丽丝和鲍勃看球的例子中([图9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 +* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](../img/fig7-6)所示)。 +* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](../img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 +* 在爱丽丝和鲍勃看球的例子中([图9-1](../img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 因果关系对事件施加了一种**顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。 @@ -350,7 +350,7 @@ ***线性一致性*** -​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](img/fig9-4.png)中以时间线表示。 +​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](../img/fig9-4.png)中以时间线表示。 ***因果性*** @@ -358,13 +358,13 @@ ​ 因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。 -​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 +​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](../img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 ​ 如果你熟悉像Git这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个**提交(Commit)**发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并(Merge)**会在这些并发创建的提交相融合时创建。 #### 线性一致性强于因果一致性 -​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 +​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](../img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 ​ 线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。 @@ -384,7 +384,7 @@ ​ 用于确定*哪些操作发生在其他操作之前* 的技术,与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。 -​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 +​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](../img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 @@ -417,7 +417,7 @@ * 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。 -* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] +* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](../img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] [^viii]: 可以使物理时钟时间戳与因果关系保持一致:在“[用于全局快照的同步时钟](#用于全局快照的同步时钟)”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这中方法确保了实际上靠后的事务会有更大的时间戳。 但是大多数时钟不能提供这种所需的不确定性度量。 @@ -429,9 +429,9 @@ ​ 尽管刚才描述的三个序列号生成器与因果不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。 -​ [图9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 +​ [图9-8](../img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 -![](img/fig9-8.png) +![](../img/fig9-8.png) **图9-8 Lamport时间戳提供了与因果关系一致的总排序。** @@ -440,7 +440,7 @@ ​ 迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大**计数器**值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。 -​ 这如 [图9-8](img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 +​ 这如 [图9-8](../img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 ​ 只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。 @@ -504,7 +504,7 @@ #### 使用全序广播实现线性一致的存储 -​ 如 [图9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 +​ 如 [图9-4](../img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 [^x]: 从形式上讲,线性一致读写寄存器是一个“更容易”的问题。 全序广播等价于共识【67】,而共识问题在异步的崩溃-停止模型【68】中没有确定性的解决方案,而线性一致的读写寄存器**可以**在这种模型中实现【23,24,25】。 然而,支持诸如**比较并设置(CAS, compare-and-set)**,或**自增并返回(increment-and-get)**的原子操作使它等价于共识问题【28】。 因此,共识问题与线性一致寄存器问题密切相关。 @@ -601,7 +601,7 @@ * 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。 * 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。 -如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 +如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](../img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 ​ 事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了**读已提交**隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了**已提交却又被追溯声明不存在数据**的事务也必须回滚。 @@ -611,9 +611,9 @@ ​ **两阶段提交(two-phase commit)**是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,也以**XA事务**的形式对应用可用【76,77】(例如Java Transaction API支持)或以SOAP Web服务的`WS-AtomicTransaction` 形式提供给应用【78,79】。 -[ 图9-9](img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 +[ 图9-9](../img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 -![](img/fig9-9.png) +![](../img/fig9-9.png) **图9-9 两阶段提交(2PC)的成功执行** @@ -653,9 +653,9 @@ ​ 如果协调者在发送**准备**请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)**的或**不确定(uncertain)**的。 -​ 情况如[图9-10](img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 +​ 情况如[图9-10](../img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 -![](img/fig9-10.png) +![](../img/fig9-10.png)  **图9-10 参与者投赞成票后,协调者崩溃。数据库1不知道是否提交或中止** ​ 没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。 @@ -718,7 +718,7 @@ ​ 问题在于**锁(locking)**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常获取待修改的行上的**行级排他锁**,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。 -​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 +​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](../img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 ​ 当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。 diff --git a/zh-cn/part-ii.md b/zh-cn/part-ii.md index 197bfc05..e81ef7b1 100644 --- a/zh-cn/part-ii.md +++ b/zh-cn/part-ii.md @@ -59,9 +59,9 @@ ​ 将一个大型数据库拆分成较小的子集(称为**分区(partitions)**),从而不同的分区可以指派给不同的**节点(node)**(亦称**分片(shard)**)。 [第六章](ch6.md)将讨论分区。 -复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。 +复制和分区是不同的机制,但它们经常同时使用。如[图II-1](../img/figii-1.png)所示。 -![](img/figii-1.png) +![](../img/figii-1.png) **图II-1 一个数据库切分为两个分区,每个分区都有两个副本** From 9746baf3c054fec44f32dbf36bde421018e28290 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 09:23:46 +0800 Subject: [PATCH 08/12] bugfix: file move structure --- zh-cn/ch1.md | 26 +++++++------- zh-cn/ch10.md | 22 ++++++------ zh-cn/ch11.md | 48 +++++++++++++------------- zh-cn/ch12.md | 12 +++---- zh-cn/ch2.md | 42 +++++++++++------------ zh-cn/ch3.md | 68 ++++++++++++++++++------------------- zh-cn/ch4.md | 32 +++++++++--------- zh-cn/ch5.md | 78 +++++++++++++++++++++--------------------- zh-cn/ch6.md | 34 +++++++++---------- zh-cn/ch7.md | 50 +++++++++++++-------------- zh-cn/ch8.md | 28 +++++++-------- zh-cn/ch9.md | 88 ++++++++++++++++++++++++------------------------ zh-cn/part-ii.md | 4 +-- 13 files changed, 266 insertions(+), 266 deletions(-) diff --git a/zh-cn/ch1.md b/zh-cn/ch1.md index 42555e35..463317ba 100644 --- a/zh-cn/ch1.md +++ b/zh-cn/ch1.md @@ -1,6 +1,6 @@ # 第一章:可靠性,可扩展性,可维护性 -![](img/ch1.png) +![](../img/ch1.png) > 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗? > @@ -40,9 +40,9 @@ ​ 其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。 -​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 +​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](../img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 -![](img/fig1-1.png) +![](../img/fig1-1.png) **图1-1 一个可能的组合使用多个组件的数据系统架构** @@ -174,7 +174,7 @@ 大体上讲,这一对操作有两种实现方式。 -1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: +1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](../img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: ```sql SELECT tweets.*, users.* @@ -183,13 +183,13 @@ JOIN follows ON follows.followee_id = users.id WHERE follows.follower_id = current_user ``` - ![](img/fig1-2.png) + ![](../img/fig1-2.png) **图1-2 推特主页时间线的关系型模式简单实现** -2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 +2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](../img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 - ![](img/fig1-3.png) + ![](../img/fig1-3.png) **图1-3 用于分发推特至关注者的数据流水线,2012年11月的负载参数【16】** @@ -220,9 +220,9 @@ ​ 即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。 -​ 在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 +​ 在[图1-4](../img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 -![](img/fig1-4.png) +![](../img/fig1-4.png) **图1-4 展示了一个服务100次请求响应时间的均值与百分位数** @@ -232,7 +232,7 @@ ​ 如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第50百分位点,有时缩写为p50。注意中位数是关于单个请求的;如果用户同时发出几个请求(在一个会话过程中,或者由于一个页面中包含了多个资源),则至少一个请求比中位数慢的概率远大于50%。 -​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。 +​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](../img/fig1-4.png)所示。 ​ 响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。 @@ -246,13 +246,13 @@ > #### 实践中的百分位点 > -> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 +> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](../img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 > > ​ 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。 > > ​ 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。 -![](img/fig1-5.png) +![](../img/fig1-5.png) **图1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求** @@ -376,7 +376,7 @@ ​ 不幸的是,使应用可靠、可扩展或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。 -​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子) +​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](../img/fig1-1.png)中的例子) diff --git a/zh-cn/ch10.md b/zh-cn/ch10.md index d039ab24..58de73f4 100644 --- a/zh-cn/ch10.md +++ b/zh-cn/ch10.md @@ -1,6 +1,6 @@ # 10. 批处理 -![](img/ch10.png) +![](../img/ch10.png) > 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。 > @@ -247,11 +247,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(参阅“[MapReduce查询](ch2.md#MapReduce查询)”)。 -​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。 +​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](../img/fig10-1.png)中的m1,m2和m3标记)。 ​ 每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。 -![](img/fig10-1.png) +![](../img/fig10-1.png) **图10-1 具有三个Mapper和三个Reducer的MapReduce任务** @@ -297,9 +297,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 示例:分析用户活动事件 -​ [图10-2](img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 +​ [图10-2](../img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 -![](img/fig10-2.png) +![](../img/fig10-2.png) **图10-2 用户行为日志与用户档案的连接** @@ -313,9 +313,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 排序合并连接 -​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。 +​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](../img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](../img/fig10-3.png)所示。 -![](img/fig10-3.png) +![](../img/fig10-3.png) **图10-3 在用户ID上进行的Reduce端连接。如果输入数据集分区为多个文件,则每个分区都会被多个Mapper并行处理** @@ -375,11 +375,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。 -​ 例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 +​ 例如,假设在[图10-2](../img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 [^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。 -​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 +​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](../img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 ​ 这种简单有效的算法被称为**广播散列连接(broadcast hash join)**:**广播**一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),**散列**一词反映了它使用一个散列表。 Pig(名为“**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用【41】。 @@ -387,7 +387,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 分区散列连接 -​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 +​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](../img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 ​ 如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。 @@ -612,7 +612,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 > 像Spark,Flink和Tez这样的数据流引擎(参见“[中间状态的物化](#中间状态的物化)”)通常将算子作为**有向无环图(DAG)**的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱! -​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 +​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](../img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 ​ 可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种“重复至完成”的想法不能用普通的MapReduce来表示,因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现: diff --git a/zh-cn/ch11.md b/zh-cn/ch11.md index 97309aad..983e63e0 100644 --- a/zh-cn/ch11.md +++ b/zh-cn/ch11.md @@ -1,6 +1,6 @@ # 11. 流处理 -![](img/ch11.png) +![](../img/ch11.png) > 有效的复杂系统总是从简单的系统演化而来。 反之亦然:从零设计的复杂系统没一个能有效工作的。 > @@ -94,7 +94,7 @@ #### 多个消费者 -当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示: +当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](../img/fig11-1.png)所示: ***负载均衡(load balance)*** @@ -104,7 +104,7 @@ ​ 每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。 -![](img/fig11-1.png) +![](../img/fig11-1.png) **图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。** @@ -116,9 +116,9 @@ ​ 如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样) -​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 +​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](../img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 -![](img/fig11-2.png) +![](../img/fig11-2.png) **图11-2 在处理m3时消费者2崩溃,因此稍后重传至消费者1** @@ -142,11 +142,11 @@ ​ 同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。 -​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。 +​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](../img/fig11-3.png)所示。 -​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 +​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](../img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 -![](img/fig11-3.png) +![](../img/fig11-3.png) **图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件** @@ -223,9 +223,9 @@ ​ 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是**双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。 -​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 +​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](../img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 -![](img/fig11-4.png) +![](../img/fig11-4.png) **图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达** @@ -233,7 +233,7 @@ ​ 双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。 -​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 +​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](../img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 ​ 如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗? @@ -245,9 +245,9 @@ ​ 最近,人们对**变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。 -​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。 +​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](../img/fig11-5.png)所示。 -![](img/fig11-5.png) +![](../img/fig11-5.png) **图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统** @@ -255,7 +255,7 @@ ​ 我们可以将日志消费者叫做**衍生数据系统**,正如在第三部分的[介绍](part-iii.md)中所讨论的:存储在搜索索引和数据仓库中的数据,只是**记录系统**数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。 -​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。 +​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](../img/fig11-2.png)的重新排序问题)。 ​ 数据库触发器可用来实现变更数据捕获(参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如应对模式变更。 @@ -275,7 +275,7 @@ ​ 如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)** 提供了一个很好的备选方案。 -​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 +​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](../img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 ​ 在日志结构存储引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。 @@ -299,7 +299,7 @@ ​ 与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上: -* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 +* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](../img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 * 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。 事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。 @@ -345,12 +345,12 @@ ​ 无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(change log)**,表示了随时间演变的状态。 -​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 +​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](../img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 $$ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ stream(t) = \frac{d\ state(t)}{dt} $$ -![](img/fig11-6.png) +![](../img/fig11-6.png) **图11-6 应用当前状态与事件流之间的关系** @@ -372,7 +372,7 @@ $$ #### 从同一事件日志中派生多个视图 -​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 +​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](../img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 ​ 添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。 @@ -412,7 +412,7 @@ $$ 剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项: -1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 +1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](../img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。 3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项1或2)。 @@ -507,9 +507,9 @@ $$ [^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。 -​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](img/fig11-7.png))。 +​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](../img/fig11-7.png))。 -![](img/fig11-7.png) +![](../img/fig11-7.png) **图11-7 按处理时间分窗,会因为处理速率的变动引入人为因素** @@ -580,7 +580,7 @@ $$ #### 流表连接(流扩展) -​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 +​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](../img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 ​ 要执行此联接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在“[示例:分析用户活动事件](ch10.md#示例:分析用户活动事件)”一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。 @@ -615,7 +615,7 @@ GROUP BY follows.follower_id ​ 流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当基础表发生变化时都会更新[^iii]。 -[^iii]: 如果你将流视作表的衍生物,如[图11-6](img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 +[^iii]: 如果你将流视作表的衍生物,如[图11-6](../img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 #### 连接的时间依赖性 diff --git a/zh-cn/ch12.md b/zh-cn/ch12.md index 63b3fd0d..99496056 100644 --- a/zh-cn/ch12.md +++ b/zh-cn/ch12.md @@ -1,6 +1,6 @@ # 12. 数据系统的未来 -![](img/ch12.png) +![](../img/ch12.png) > 如果船长的终极目标是保护船只,他应该永远待在港口。 > @@ -42,7 +42,7 @@ ​ 例如,你可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 -​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 +​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](../img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 ​ 如果您可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。 这是状态机复制方法的一个应用,我们在“[全序广播](ch9.md#全序广播)”中看到。无论您使用变更数据捕获还是事件源日志,都不如仅对全局顺序达成共识更重要。 @@ -328,9 +328,9 @@ ### 观察衍生数据状态 -​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了一个更新搜索索引的例子。 +​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](../img/fig12-1.png)显示了一个更新搜索索引的例子。 -![](img/fig12-1.png) +![](../img/fig12-1.png) **图12-1 在搜索索引中,写(文档更新)遇上读(查询)** @@ -338,7 +338,7 @@ ​ 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。 -​ 如[图12-1](img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 +​ 如[图12-1](../img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 #### 物化视图和缓存 @@ -454,7 +454,7 @@ ​ 除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP使用数据包上的序列号,在接收方将它们正确排序。并确定网络上是否有数据包丢失或重复。任何丢失的数据包都会被重新传输,而在将数据交付应用前,TCP协议栈会移除任何重复数据包。 -​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](img/fig8-1.png))。 +​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](../img/fig8-1.png))。 **例12-1 资金从一个账户到另一个账户的非幂等转移** diff --git a/zh-cn/ch2.md b/zh-cn/ch2.md index 4fbc1796..ed2dbbfd 100644 --- a/zh-cn/ch2.md +++ b/zh-cn/ch2.md @@ -1,6 +1,6 @@ # 2. 数据模型与查询语言 -![](img/ch2.png) +![](../img/ch2.png) > 语言的边界就是思想的边界。 > @@ -65,13 +65,13 @@ 像ActiveRecord和Hibernate这样的 **对象关系映射(ORM object-relational mapping)** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。 -![](img/fig2-1.png) +![](../img/fig2-1.png) **图2-1 使用关系型模式来表示领英简介** -例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: +例如,[图2-1](../img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: -* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](img/fig2-1.png)所示。 +* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](../img/fig2-1.png)所示。 * 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。JSON数据类型也得到多个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。 * 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。 @@ -119,11 +119,11 @@ 有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。 -JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 +JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 -从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。 +从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](../img/fig2-2.png))。 -![](img/fig2-2.png) +![](../img/fig2-2.png) **图2-2 一对多关系构建了一个树结构** @@ -157,18 +157,18 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 ***组织和学校作为实体*** -在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。 +在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](../img/fig2-3.png),来自LinkedIn的一个例子)。 ***推荐*** 假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何建议都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。 -![](img/fig2-3.png) +![](../img/fig2-3.png) **图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)** -[图2-4](img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 +[图2-4](../img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 -![](img/fig2-4.png) +![](../img/fig2-4.png) **图2-4 使用多对多关系扩展简历** @@ -178,7 +178,7 @@ JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部 20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用和维护,运行在IBM大型机的OS/390上【14】。 -IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](img/fig2-2.png)的JSON结构。 +IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](../img/fig2-2.png)的JSON结构。 同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。 @@ -226,7 +226,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数 #### 哪个数据模型更方便写代码? -如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 +如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](../img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。 @@ -274,7 +274,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 查询的数据局部性 -文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 +文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](../img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。 @@ -533,9 +533,9 @@ db.observations.aggregate([ 在刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人,网页或交叉路口)。不过,图并不局限于这样的同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到和用户的评论;边缘表示哪些人是彼此的朋友,哪个签到发生在何处,谁评论了哪条消息,谁参与了哪个事件,等等【35】。 -在本节中,我们将使用[图2-5](img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 +在本节中,我们将使用[图2-5](../img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 -![](img/fig2-5.png) +![](../img/fig2-5.png) **图2-5 图数据结构示例(框代表顶点,箭头代表边)** @@ -586,7 +586,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动。(这就是为什么[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) 3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。 -这些特性为数据建模提供了很大的灵活性,如[图2-5](img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 +这些特性为数据建模提供了很大的灵活性,如[图2-5](../img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 你可以想象延伸图还能包括许多关于Lucy和Alain,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图表在可演化性是富有优势的:当向应用程序添加功能时,可以轻松扩展图以适应应用程序数据结构的变化。 @@ -594,7 +594,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); Cypher是属性图的声明式查询语言,为Neo4j图形数据库而发明【37】。(它是以电影“黑客帝国”中的一个角色来命名的,而与密码术中的密码无关【38】。) -[例2-3]()显示了将[图2-5](img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 +[例2-3]()显示了将[图2-5](../img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 **例2-3 将图2-5中的数据子集表示为Cypher查询** @@ -608,7 +608,7 @@ CREATE (Lucy) -[:BORN_IN]-> (Idaho) ``` -当[图2-5](img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 +当[图2-5](../img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 [例2-4]()展示了如何在Cypher中表达这个查询。在MATCH子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`边的任意两个顶点。该边的尾节点被绑定了变量`person`,头节点则未被绑定。 @@ -896,9 +896,9 @@ Cypher和SPARQL使用SELECT立即跳转,但是Datalog一次只进行一小步 2. 数据库存在`within(usa, namerica)`,在上一步骤中生成`within_recursive(namerica, 'North America')`,故运用规则2。它会产生`within_recursive(usa, 'North America')`。 3. 数据库存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故运用规则2。它产生`within_recursive(idaho, 'North America')`。 -通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](img/fig2-6.png)所示。 +通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](../img/fig2-6.png)所示。 -![](img/fig2-6.png) +![](../img/fig2-6.png) **图2-6 使用示例2-11中的Datalog规则来确定爱达荷州在北美。** diff --git a/zh-cn/ch3.md b/zh-cn/ch3.md index 8cbf6fed..881e8281 100644 --- a/zh-cn/ch3.md +++ b/zh-cn/ch3.md @@ -1,6 +1,6 @@ # 3. 存储与检索 -![](img/ch3.png) +![](../img/ch3.png) > 建立秩序,省却搜索 > @@ -83,9 +83,9 @@ $ cat database 键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢? -假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 +假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](../img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 -![](img/fig3-1.png) +![](../img/fig3-1.png) **图3-1 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。** @@ -93,15 +93,15 @@ $ cat database 像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。 -直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 +直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](../img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 -![](img/fig3-2.png) +![](../img/fig3-2.png) **图3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值** -而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 +而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](../img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 -![](img/fig3-3.png) +![](../img/fig3-3.png) **图3-3 同时执行压缩和分段合并** @@ -148,30 +148,30 @@ $ cat database ### SSTables和LSM树 -在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 +在[图3-3](../img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。 我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势: -1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 +1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](../img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 - ![](img/fig3-4.png) + ![](../img/fig3-4.png) ##### 图3-4 合并几个SSTable段,只保留每个键的最新值 如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。 -2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 +2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](../img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 - ![](img/fig3-5.png) + ![](../img/fig3-5.png) **图3-5 具有内存索引的SSTable** 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。 -3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](../img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 [^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) @@ -217,25 +217,25 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使 我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。 -每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。 +每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](../img/fig3-6.png)所示。 -![](img/fig3-6.png) +![](../img/fig3-6.png) **图3-6 使用B树索引查找一个键** 一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。 -在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 +在[图3-6](../img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。 -在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 +在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](../img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 -如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。 +如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](../img/fig3-7.png)所示[^ii]。 [^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 -![](img/fig3-7.png) +![](../img/fig3-7.png) **图3-7 通过分割页面来生长B树** @@ -299,7 +299,7 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载 到目前为止,我们只讨论了关键值索引,它们就像关系模型中的**主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。 -有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 +有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](../img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。 @@ -398,9 +398,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 这些OLTP系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以DBA会密切关注他们的OLTP数据库,他们通常不愿意让业务分析人员在OLTP数据库上运行临时分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时执行的事务的性能。 -相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8)所示。 +相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](../img/fig3-8)所示。 -![](img/fig3-8.png) +![](../img/fig3-8.png) **图3-8 ETL至数据仓库的简化提纲** @@ -424,7 +424,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 -![](img/fig3-9.png) +![](../img/fig3-9.png) **图3-9 用于数据仓库的星型模式的示例** @@ -432,7 +432,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。 -例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 +例如,在[图3-9](../img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 即使日期和时间通常使用维度表来表示,因为这允许对日期(诸如公共假期)的附加信息进行编码,从而允许查询区分假期和非假期的销售。 @@ -469,13 +469,13 @@ GROUP BY 我们如何有效地执行这个查询? -在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。 +在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](../img/fig3-1.png)的CSV例子中看到这个。 为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。 -面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。 +面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](../img/fig3-10.png)所示。 -![](img/fig3-10.png) +![](../img/fig3-10.png) **图3-10 使用列存储关系型数据,而不是行** @@ -489,9 +489,9 @@ GROUP BY 除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。 -看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。 +看看[图3-10](../img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](../img/fig3-11.png)所示。 -![](img/fig3-11.png) +![](../img/fig3-11.png) **图3-11 压缩位图索引存储布局** @@ -536,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3 相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。 -第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 +第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](../img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 -排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 +排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](../img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。 @@ -568,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3 当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。 -物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。 +物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](../img/fig3-12.png)显示了一个例子。 -![](img/fig3-12.png) +![](../img/fig3-12.png) **图3-12 数据立方的两个维度,通过求和聚合** -想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 +想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](../img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。 diff --git a/zh-cn/ch4.md b/zh-cn/ch4.md index d047edd1..f45c9aff 100644 --- a/zh-cn/ch4.md +++ b/zh-cn/ch4.md @@ -1,6 +1,6 @@ # 4. 编码与演化 -![](img/ch4.png) +![](../img/ch4.png) > 唯变所适 > @@ -113,7 +113,7 @@ JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事 在下面的章节中,能达到比这好得多的结果,只用32个字节对相同的记录进行编码。 -![](img/fig4-1.png) +![](../img/fig4-1.png) **图4-1 使用MessagePack编码的记录(例4-1)** @@ -141,9 +141,9 @@ message Person { ``` Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。您的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。 -用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](img/fig4-2.png)所示【19】。 +用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](../img/fig4-2.png)所示【19】。 -![](img/fig4-2.png) +![](../img/fig4-2.png) **图4-2 使用Thrift二进制协议编码的记录** @@ -151,17 +151,17 @@ Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用 与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。 -与[图4-1](img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 +与[图4-1](../img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 -Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 +Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](../img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 -![](img/fig4-3.png) +![](../img/fig4-3.png) **图4-3 使用Thrift压缩协议编码的记录** -最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 +最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 -![](img/fig4-4.png) +![](../img/fig4-4.png) **图4-4 使用Protobuf编码的记录** @@ -183,7 +183,7 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4 如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被扼杀。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。 -Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 +Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](../img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。 @@ -217,11 +217,11 @@ record Person { } ``` -首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。 +首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](../img/fig4-5.png)所示。 如果您检查字节序列,您可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。 -![](img/fig4-5.png) +![](../img/fig4-5.png) **图4-5 使用Avro编码的记录** @@ -235,11 +235,11 @@ record Person { 当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能是从该模式生成的。 -Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。 +Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](../img/fig4-6.png)所示。 例如,如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。 -![](img/fig4-6.png) +![](../img/fig4-6.png) **图4-6 一个Avro Reader解决读写模式的差异** @@ -344,7 +344,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它 解决这个问题不是一个难题,你只需要意识到它。 -![](img/fig4-7.png) +![](../img/fig4-7.png) **图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。** @@ -474,7 +474,7 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承 消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。 -如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。 +如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](../img/fig4-7.png))。 #### 分布式的Actor框架 diff --git a/zh-cn/ch5.md b/zh-cn/ch5.md index e7499fd7..ed4405ba 100644 --- a/zh-cn/ch5.md +++ b/zh-cn/ch5.md @@ -1,6 +1,6 @@ # 5. 复制 -![](img/ch5.png) +![](../img/ch5.png) > 与可能出错的东西比,'不可能'出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。 > @@ -36,7 +36,7 @@ [^i]: 不同的人对**热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)**指的是能接受客户端读请求的副本。而**温备(warm standby)**只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。 -![](img/fig5-1.png) +![](../img/fig5-1.png) **图5-1 基于领导者(主-从)的复制** ​ 这种复制模式是许多关系数据库的内置功能,如PostgreSQL(从9.0版本开始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库,包括MongoDB,RethinkDB和Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如DRBD这样的块复制设备也与之类似。 @@ -47,9 +47,9 @@ ​ 想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。 -[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 +[图5-2](../img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 -![](img/fig5-2.png) +![](../img/fig5-2.png) **图5-2 基于领导者的复制:一个同步从库和一个异步从库** ​ 在[图5-2]()的示例中,从库1的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库1的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的:主库发送消息,但不等待从库的响应。 @@ -205,7 +205,7 @@ ​ 但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。 -![](img/fig5-3.png) +![](../img/fig5-3.png) **图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常** @@ -236,9 +236,9 @@ ​ 从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流(moving backward in time)**。 -​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 +​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](../img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 -![](img/fig5-4.png) +![](../img/fig5-4.png) **图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。** @@ -260,7 +260,7 @@ 这两句话之间有因果关系:Cake夫人听到了Poons先生的问题并回答了这个问题。 -​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容: +​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](../img/fig5-5.png))。 于是,这个观察者会听到以下内容: > *Mrs. Cake* > ​ 通常约十秒钟,Mr. Poons. @@ -271,7 +271,7 @@ 对于观察者来说,看起来好像Cake夫人在Poons先生发问前就回答了这个问题。 这种超能力让人印象深刻,但也会把人搞糊涂。【25】。 -![](img/fig5-5.png) +![](../img/fig5-5.png) **图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。** @@ -311,9 +311,9 @@ ​ 假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 -​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 +​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](../img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 -![](img/fig5-6.png) +![](../img/fig5-6.png) **图5-6 跨多个数据中心的多主复制** @@ -333,7 +333,7 @@ ​ 有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于MySQL的Tungsten Replicator 【26】,用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。 -​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 +​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](../img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 ​ 由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免【28】。 @@ -361,9 +361,9 @@ ​ 多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。 -​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 +​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](../img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 -![](img/fig5-7.png) +![](../img/fig5-7.png) **图5-7 两个主库同时更新同一记录引起的写入冲突** @@ -385,7 +385,7 @@ ​ 单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。 -​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 +​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](../img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 ​ 如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库1的C和主库2的B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种**收敛(convergent)**的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。 @@ -393,7 +393,7 @@ * 给每个写入一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为**最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在[本章末尾](#检测并发写入)更详细地讨论LWW。 * 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。 -* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 +* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](../img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 * 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。 @@ -431,7 +431,7 @@ #### 什么是冲突? -​ 有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 +​ 有些冲突是显而易见的。在[图5-7](../img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 ​ 其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。 @@ -443,7 +443,7 @@ ​ **复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如[图5-7]()所示,只有一个合理的拓扑结构:领导者1必须把他所有的写到领导者2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图5-8]()举例说明了一些例子。 -![](img/fig5-8.png) +![](../img/fig5-8.png) **图5-8 三个可以设置多领导者复制的示例拓扑。** @@ -455,13 +455,13 @@ ​ 循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。 -​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。 +​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](../img/fig5-9.png)所示。 -![](img/fig5-9.png) +![](../img/fig5-9.png) **图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。** -​ 在[图5-9](img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 +​ 在[图5-9](../img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 ​ 这是一个因果关系的问题,类似于我们在“[一致前缀读](ch8.md#一致前缀读)”中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,以便在主库2处正确地排序这些事件(见[第8章](ch8.md))。 @@ -485,9 +485,9 @@ ​ 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(参阅「[处理节点宕机](#处理节点宕机)」)。 -​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 +​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](../img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 -![](img/fig5-10.png) +![](../img/fig5-10.png) **图5-10 法定写入,法定读取,并在节点中断后读修复。** @@ -503,7 +503,7 @@ ***读修复(Read repair)*** -​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 +​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](../img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 ***反熵过程(Anti-entropy process)*** @@ -513,7 +513,7 @@ #### 读写的法定人数 -​ 在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? +​ 在[图5-10](../img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? ​ 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。 @@ -531,10 +531,10 @@ * 如果$w n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入** @@ -544,7 +544,7 @@ ### 法定人数一致性的局限性 -​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。 +​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](../img/fig5-11.png)所示)。 ​ 通常,r和w被选为多数(超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。 @@ -608,17 +608,17 @@ ​ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**提示移交**期间也可能会产生冲突。 -​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: +​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](../img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: * 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 * 节点 3 首先接收来自 B 的写入,然后从 A 写入。 -![](img/fig5-12.png) +![](../img/fig5-12.png) **图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。** -​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 +​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](../img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 ​ 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。 @@ -628,7 +628,7 @@ ​ 实现最终融合的一种方法是声明每个副本只需要存储最**“最近”**的值,并允许**“更旧”**的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。 -​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 +​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](../img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 ​ 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入胜利(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。 @@ -673,13 +673,13 @@ 4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿] 5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。 -![](img/fig5-13.png) +![](../img/fig5-13.png) **图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。** -​ [图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 +​ [图5-13](../img/fig5-13.png)中的操作之间的数据流如[图5-14](../img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 -![](img/fig5-14.png) +![](../img/fig5-14.png) **图5-14 图5-13中的因果依赖关系图。** @@ -698,7 +698,7 @@ ​ 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。 -​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 +​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](../img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 ​ 然而,如果你想让人们也可以从他们的手推车中**删除**东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果:如果你合并了两个兄弟手推车,并且只在其中一个兄弟值里删掉了它,那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有合适版本号的标记,以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑(tombstone)**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。) @@ -706,13 +706,13 @@ #### 版本向量 -​ [图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? +​ [图5-13](../img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? -​ [图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 +​ [图5-13](../img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 ​ 所有副本的版本号集合称为**版本向量(version vector)**【56】。这个想法的一些变体正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。 -​ 与[图5-13](img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 +​ 与[图5-13](../img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 ​ 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。 diff --git a/zh-cn/ch6.md b/zh-cn/ch6.md index 4dfd6531..c2acbc21 100644 --- a/zh-cn/ch6.md +++ b/zh-cn/ch6.md @@ -1,6 +1,6 @@ # 6. 分区 -![](img/ch6.png) +![](../img/ch6.png) > 我们必须跳出电脑指令序列的窠臼。 叙述定义、描述元数据、梳理关系,而不是编写过程。 > @@ -37,7 +37,7 @@ ​ 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 -![](img/fig6-1.png) +![](../img/fig6-1.png) **图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。** @@ -57,7 +57,7 @@ ​ 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 -![](img/fig6-2.png) +![](../img/fig6-2.png) **图6-2 印刷版百科全书按照关键字范围进行分区** @@ -79,9 +79,9 @@ ​ 出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于哈希表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。 -​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 +​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](../img/fig6-3.png)所示。 -![](img/fig6-3.png) +![](../img/fig6-3.png) **图6-3 按哈希键分区** @@ -125,19 +125,19 @@ ### 基于文档的二级索引进行分区 -​ 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 +​ 假设你正在经营一个销售二手车的网站(如[图6-4](../img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 ​ 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 [^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。 -![](img/fig6-4.png) +![](../img/fig6-4.png) **图6-4 基于文档的二级索引进行分区** ​ 在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库(添加,删除或更新文档),只需处理包含您正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引(local index)**(而不是将在下一节中描述的**全局索引(global index)**)。 -​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 +​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](../img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 ​ 这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使二级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区二级索引。大多数数据库供应商建议您构建一个能从单个分区提供二级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个二级索引时(例如同时需要按颜色和制造商查询)。 @@ -147,9 +147,9 @@ ​ 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 -​ [图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 +​ [图6-5](../img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 -![](img/fig6-5.png) +![](../img/fig6-5.png) **图6-5 基于关键词对二级索引进行分区** @@ -188,7 +188,7 @@ #### 反面教材:hash mod N -​ 我们在前面说过([图6-3](img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key) ​ 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。 > @@ -90,11 +90,11 @@ ACID一致性的概念是,**对数据的一组特定约束必须始终成立** 大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。 -[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 +[图7-1](../img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](../img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。 -![](img/fig7-1.png) +![](../img/fig7-1.png) **图7-1 两个客户之间的竞争状态同时递增计数器** @@ -137,7 +137,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的** 同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。 -这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: +这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](../img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: ```sql SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -145,17 +145,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。 -在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 +在[图7-2](../img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 [^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。 -![](img/fig7-2.png) +![](../img/fig7-2.png) **图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。** -[图7-3](img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 +[图7-3](../img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 -![](img/fig7-3.png) +![](../img/fig7-3.png) **图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致** @@ -175,7 +175,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。 -一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 +一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](../img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv] @@ -190,7 +190,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象: * 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。 -* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 +* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](../img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 * 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。 这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。 @@ -250,14 +250,14 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。 -![](img/fig7-4.png) +![](../img/fig7-4.png) **图7-4 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。** 为什么要防止脏读,有几个原因: -- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 -- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 +- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](../img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 +- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](../img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 #### 没有脏写 @@ -267,10 +267,10 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 通过防止脏写,这个隔离级别避免了一些并发问题: -- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 +- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](../img/fig7-5.png),[图7-5](../img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](../img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 - 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。 -![](img/fig7-5.png) +![](../img/fig7-5.png) **图7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起** @@ -292,9 +292,9 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许**中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混合。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。 -但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](img/fig7-6.png)说明了读已提交时可能发生的问题。 +但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](../img/fig7-6.png)说明了读已提交时可能发生的问题。 -![](img/fig7-6.png) +![](../img/fig7-6.png) **图7-6 读取偏差:Alice观察数据库处于不一致的状态** @@ -332,7 +332,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true [^vii]: 事实上,事务ID是32位整数,所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。 -![](img/fig7-7.png) +![](../img/fig7-7.png) **图7-7 使用多版本对象实现快照隔离** @@ -475,9 +475,9 @@ UPDATE wiki_pages SET content = '新内容' 首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作【40,41】。 -现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](img/fig7-8.png)说明了接下来的事情。 +现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](../img/fig7-8.png)说明了接下来的事情。 -![](img/fig7-8.png) +![](../img/fig7-8.png) **图7-8 写入偏差导致应用程序错误的示例** @@ -626,9 +626,9 @@ COMMIT; ​ 在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。 -​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 +​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](../img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 -![](img/fig7-9.png) +![](../img/fig7-9.png) **图7-9 交互式事务和存储过程之间的区别(使用图7-8的示例事务)** @@ -793,7 +793,7 @@ WHERE room_id = 123 AND 回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43 认为Alice的 `on_call = true` ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。 -![](img/fig7-10.png) +![](../img/fig7-10.png) **图7-10 检测事务何时从MVCC快照读取过时的值** @@ -803,9 +803,9 @@ WHERE room_id = 123 AND #### 检测影响之前读取的写入 -第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](img/fig7-11.png)所示。 +第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](../img/fig7-11.png)所示。 -![](img/fig7-11.png) +![](../img/fig7-11.png) **图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。** diff --git a/zh-cn/ch8.md b/zh-cn/ch8.md index d047d162..382487de 100644 --- a/zh-cn/ch8.md +++ b/zh-cn/ch8.md @@ -1,6 +1,6 @@ # 第八章:分布式系统的麻烦 -![](img/ch8.png) +![](../img/ch8.png) > 邂逅相遇 > @@ -97,7 +97,7 @@ ​ **无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。 -​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示): +​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](../img/fig8-1.png)所示): 1. 请求可能已经丢失(可能有人拔掉了网线)。 2. 请求可能正在排队,稍后将交付(也许网络或收件人超载)。 @@ -106,7 +106,7 @@ 5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。 6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。 -![](img/fig8-1.png) +![](../img/fig8-1.png) **图8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。** @@ -168,12 +168,12 @@ ​ 在驾驶汽车时,由于交通拥堵,道路交通网络的通行时间往往不尽相同。同样,计算机网络上数据包延迟的可变性通常是由于排队【25】: -* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 +* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](../img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 * 当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。 * 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,而另一个虚拟机使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。 * TCP执行**流量控制(flow control)**(也称为**拥塞避免(congestion avoidance)**或**背压(backpressure)**),其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队。 -![](img/fig8-2.png) +![](../img/fig8-2.png) **图8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口1,2和4都试图发送数据包到端口3** @@ -319,20 +319,20 @@ ​ 让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近? -​ [图8-3](img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 +​ [图8-3](../img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](../img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 -![](img/fig8-3.png) +![](../img/fig8-3.png) **图8-3 客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。** ​ 在[图8-3]()中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的时钟时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点1和节点3之间的偏差小于3ms,这可能比你在实践中预期的更好。 -​ 尽管如此,[图8-3](img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 +​ 尽管如此,[图8-3](../img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 ​ 这种冲突解决策略被称为**最后写入胜利(LWW)**,它在多领导者复制和无领导者数据库(如Cassandra 【53】和Riak 【54】)中被广泛使用(参见“[最后写入胜利(丢弃并发写入)](#最后写入胜利(丢弃并发写入))”一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题: * 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。 -* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 +* LWW无法区分**高频顺序写入**(在[图8-3](../img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 * 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的**决胜值(tiebreaker)**(可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系【53】。 因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**时钟**,这很可能是不正确的。即使用频繁同步的NTP时钟,一个数据包也可能在时间戳100毫秒(根据发送者的时钟)时发送,并在时间戳99毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。 @@ -485,9 +485,9 @@ while(true){ ​ 如果一个节点继续表现为**天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。 -​ 例如,[图8-4](img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 +​ 例如,[图8-4](../img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 -![](img/fig8-4.png) +![](../img/fig8-4.png) **图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件** @@ -495,15 +495,15 @@ while(true){ #### 防护令牌 -​ 当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 +​ 当使用锁或租约来保护对某些资源(如[图8-4](../img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 -![](img/fig8-5.png) +![](../img/fig8-5.png) **图8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全** ​ 我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。 -​ 在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 +​ 在[图8-5](../img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 ​ 如果将ZooKeeper用作锁定服务,则可将事务标识`zxid`或节点版本`cversion`用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。 diff --git a/zh-cn/ch9.md b/zh-cn/ch9.md index 0936689a..85317a33 100644 --- a/zh-cn/ch9.md +++ b/zh-cn/ch9.md @@ -1,6 +1,6 @@ # 9. 一致性与共识 -![](img/ch9.png) +![](../img/ch9.png) > 好死不如赖活着 > —— Jay Kreps, 关于Kafka与 Jepsen的若干笔记 (2013) @@ -60,11 +60,11 @@ ​ 在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。 -![](img/fig9-1.png) +![](../img/fig9-1.png) **图9-1 这个系统是非线性一致的,导致了球迷的困惑** -​ [图9-1 ](img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 +​ [图9-1 ](../img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 ​ 如果Alice和Bob在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而Bob是在听到Alice惊呼最后得分**之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。 @@ -72,13 +72,13 @@ ​ 线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。 -​ [图9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 +​ [图9-2](../img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 -![](img/fig9-2.png) +![](../img/fig9-2.png) **图9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值** -​ 为了简单起见,[图9-2](img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] +​ 为了简单起见,[图9-2](../img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] [^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。即使真实的系统通常没有准确的时钟(参阅“[不可靠的时钟](ch8.md#不可靠的时钟)”),但这种假设是允许的:为了分析分布式算法,我们可以假设一个精确的全局时钟存在,不过算法无法访问它【47】。算法只能看到由石英振荡器和NTP产生的实时逼近。 @@ -89,7 +89,7 @@ * $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。 -在[图9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? +在[图9-2](../img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? * 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`。 * 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。 @@ -99,16 +99,16 @@ [^ii]: 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为**常规寄存器(regular register)**【7,25】 -为了使系统线性一致,我们需要添加另一个约束,如[图9-3](img/fig9-3.png)所示 +为了使系统线性一致,我们需要添加另一个约束,如[图9-3](../img/fig9-3.png)所示 -![](img/fig9-3.png) +![](../img/fig9-3.png) **图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。** ​ 在一个线性一致的系统中,我们可以想象,在 `x` 的值从`0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。 -​ [图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) +​ [图9-3](../img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) -​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](img/fig9-4.png)显示了一个更复杂的例子【10】。 +​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](../img/fig9-4.png)显示了一个更复杂的例子【10】。 在[图9-4]()中,除了读写之外,还增加了第三种类型的操作: @@ -118,7 +118,7 @@ ​ 线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。 -![](img/fig9-4.png) +![](../img/fig9-4.png) **图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的** @@ -130,7 +130,7 @@ * 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,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更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。 +* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同。 这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。 @@ -182,17 +182,17 @@ #### 跨信道的时序依赖 -​ 注意[图9-1](img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 +​ 注意[图9-1](../img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 -​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](img/fig9-5.png)所示。 +​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](../img/fig9-5.png)所示。 ​ 图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的(参阅[第11章](ch11.md))。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。 -![](img/fig9-5.png) +![](../img/fig9-5.png) **图9-5 Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性。** -​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 +​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](../img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 -​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 +​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](../img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 ​ 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在Alice和Bob的例子),则可以使用在“[读己之写](ch5.md#读己之写)”讨论过的备选方法,不过会有额外的复杂度代价。 @@ -230,13 +230,13 @@ #### 线性一致性和法定人数 -​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](img/fig9-6.png)所示。 +​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](../img/fig9-6.png)所示。 -![](img/fig9-6.png) +![](../img/fig9-6.png) **图9-6 非线性一致的执行,尽管使用了严格的法定人数** -​ 在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 +​ 在[图9-6](../img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 ​ 法定人数条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]()) @@ -252,9 +252,9 @@ ​ 一些复制方法可以提供线性一致性,另一些复制方法则不能,因此深入地探讨线性一致性的优缺点是很有趣的。 -​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](img/fig9-7.png)说明了这种部署的一个例子。 +​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](../img/fig9-7.png)说明了这种部署的一个例子。 -![](img/fig9-7.png) +![](../img/fig9-7.png) **图9-7 网络中断迫使在线性一致性和可用性之间做出选择。** @@ -311,7 +311,7 @@ ## 顺序保证 -​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。 +​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](../img/fig9-4.png)中的顺序。 **顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它**顺序**曾经出现过的上下文: @@ -325,12 +325,12 @@ **顺序**反复出现有几个原因,其中一个原因是,它有助于保持**因果关系(causality)**。在本书中我们已经看到了几个例子,其中因果关系是很重要的: -* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 -* [图5-9](img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 +* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](../img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 +* [图5-9](../img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 * 在“[检测并发写入](ch5.md#检测并发写入)”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B**并发**。这种**此前发生(happened before)**关系是因果关系的另一种表述:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是**并发**的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。 -* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](img/fig7-6)所示)。 -* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 -* 在爱丽丝和鲍勃看球的例子中([图9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 +* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](../img/fig7-6)所示)。 +* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](../img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 +* 在爱丽丝和鲍勃看球的例子中([图9-1](../img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 因果关系对事件施加了一种**顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。 @@ -350,7 +350,7 @@ ***线性一致性*** -​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](img/fig9-4.png)中以时间线表示。 +​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](../img/fig9-4.png)中以时间线表示。 ***因果性*** @@ -358,13 +358,13 @@ ​ 因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。 -​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 +​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](../img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 ​ 如果你熟悉像Git这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个**提交(Commit)**发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并(Merge)**会在这些并发创建的提交相融合时创建。 #### 线性一致性强于因果一致性 -​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 +​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](../img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 ​ 线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。 @@ -384,7 +384,7 @@ ​ 用于确定*哪些操作发生在其他操作之前* 的技术,与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。 -​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 +​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](../img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 @@ -417,7 +417,7 @@ * 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。 -* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] +* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](../img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] [^viii]: 可以使物理时钟时间戳与因果关系保持一致:在“[用于全局快照的同步时钟](#用于全局快照的同步时钟)”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这中方法确保了实际上靠后的事务会有更大的时间戳。 但是大多数时钟不能提供这种所需的不确定性度量。 @@ -429,9 +429,9 @@ ​ 尽管刚才描述的三个序列号生成器与因果不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。 -​ [图9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 +​ [图9-8](../img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 -![](img/fig9-8.png) +![](../img/fig9-8.png) **图9-8 Lamport时间戳提供了与因果关系一致的总排序。** @@ -440,7 +440,7 @@ ​ 迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大**计数器**值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。 -​ 这如 [图9-8](img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 +​ 这如 [图9-8](../img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 ​ 只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。 @@ -504,7 +504,7 @@ #### 使用全序广播实现线性一致的存储 -​ 如 [图9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 +​ 如 [图9-4](../img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 [^x]: 从形式上讲,线性一致读写寄存器是一个“更容易”的问题。 全序广播等价于共识【67】,而共识问题在异步的崩溃-停止模型【68】中没有确定性的解决方案,而线性一致的读写寄存器**可以**在这种模型中实现【23,24,25】。 然而,支持诸如**比较并设置(CAS, compare-and-set)**,或**自增并返回(increment-and-get)**的原子操作使它等价于共识问题【28】。 因此,共识问题与线性一致寄存器问题密切相关。 @@ -601,7 +601,7 @@ * 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。 * 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。 -如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 +如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](../img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 ​ 事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了**读已提交**隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了**已提交却又被追溯声明不存在数据**的事务也必须回滚。 @@ -611,9 +611,9 @@ ​ **两阶段提交(two-phase commit)**是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,也以**XA事务**的形式对应用可用【76,77】(例如Java Transaction API支持)或以SOAP Web服务的`WS-AtomicTransaction` 形式提供给应用【78,79】。 -[ 图9-9](img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 +[ 图9-9](../img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 -![](img/fig9-9.png) +![](../img/fig9-9.png) **图9-9 两阶段提交(2PC)的成功执行** @@ -653,9 +653,9 @@ ​ 如果协调者在发送**准备**请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)**的或**不确定(uncertain)**的。 -​ 情况如[图9-10](img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 +​ 情况如[图9-10](../img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 -![](img/fig9-10.png) +![](../img/fig9-10.png)  **图9-10 参与者投赞成票后,协调者崩溃。数据库1不知道是否提交或中止** ​ 没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。 @@ -718,7 +718,7 @@ ​ 问题在于**锁(locking)**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常获取待修改的行上的**行级排他锁**,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。 -​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 +​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](../img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 ​ 当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。 diff --git a/zh-cn/part-ii.md b/zh-cn/part-ii.md index 197bfc05..e81ef7b1 100644 --- a/zh-cn/part-ii.md +++ b/zh-cn/part-ii.md @@ -59,9 +59,9 @@ ​ 将一个大型数据库拆分成较小的子集(称为**分区(partitions)**),从而不同的分区可以指派给不同的**节点(node)**(亦称**分片(shard)**)。 [第六章](ch6.md)将讨论分区。 -复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。 +复制和分区是不同的机制,但它们经常同时使用。如[图II-1](../img/figii-1.png)所示。 -![](img/figii-1.png) +![](../img/figii-1.png) **图II-1 一个数据库切分为两个分区,每个分区都有两个副本** From ef5a627d731d59eb6816adc8bd720467572e7ed5 Mon Sep 17 00:00:00 2001 From: afunTW Date: Tue, 6 Oct 2020 09:25:46 +0800 Subject: [PATCH 09/12] translate --- zh-tw/ch1.md | 26 +++++++------- zh-tw/ch10.md | 22 ++++++------ zh-tw/ch11.md | 48 +++++++++++++------------- zh-tw/ch12.md | 12 +++---- zh-tw/ch2.md | 42 +++++++++++------------ zh-tw/ch3.md | 68 ++++++++++++++++++------------------- zh-tw/ch4.md | 32 +++++++++--------- zh-tw/ch5.md | 78 +++++++++++++++++++++--------------------- zh-tw/ch6.md | 34 +++++++++---------- zh-tw/ch7.md | 50 +++++++++++++-------------- zh-tw/ch8.md | 28 +++++++-------- zh-tw/ch9.md | 88 ++++++++++++++++++++++++------------------------ zh-tw/part-ii.md | 4 +-- 13 files changed, 266 insertions(+), 266 deletions(-) diff --git a/zh-tw/ch1.md b/zh-tw/ch1.md index 2cb19809..89f01576 100644 --- a/zh-tw/ch1.md +++ b/zh-tw/ch1.md @@ -1,6 +1,6 @@ # 第一章:可靠性,可擴充套件性,可維護性 -![](img/ch1.png) +![](../img/ch1.png) > 網際網路做得太棒了,以至於大多數人將它看作像太平洋這樣的自然資源,而不是什麼人工產物。上一次出現這種大規模且無差錯的技術, 你還記得是什麼時候嗎? > @@ -40,9 +40,9 @@ ​ 其次,越來越多的應用程式有著各種嚴格而廣泛的要求,單個工具不足以滿足所有的資料處理和儲存需求。取而代之的是,總體工作被拆分成一系列能被單個工具高效完成的任務,並透過應用程式碼將它們縫合起來。 -​ 例如,如果將快取(應用管理的快取層,Memcached或同類產品)和全文搜尋(全文搜尋伺服器,例如Elasticsearch或Solr)功能從主資料庫剝離出來,那麼使快取/索引與主資料庫保持同步通常是應用程式碼的責任。[圖1-1](img/fig1-1.png) 給出了這種架構可能的樣子(細節將在後面的章節中詳細介紹)。 +​ 例如,如果將快取(應用管理的快取層,Memcached或同類產品)和全文搜尋(全文搜尋伺服器,例如Elasticsearch或Solr)功能從主資料庫剝離出來,那麼使快取/索引與主資料庫保持同步通常是應用程式碼的責任。[圖1-1](../img/fig1-1.png) 給出了這種架構可能的樣子(細節將在後面的章節中詳細介紹)。 -![](img/fig1-1.png) +![](../img/fig1-1.png) **圖1-1 一個可能的組合使用多個元件的資料系統架構** @@ -174,7 +174,7 @@ 大體上講,這一對操作有兩種實現方式。 -1. 釋出推文時,只需將新推文插入全域性推文集合即可。當一個使用者請求自己的主頁時間線時,首先查詢他關注的所有人,查詢這些被關注使用者釋出的推文並按時間順序合併。在如[圖1-2](img/fig1-2.png)所示的關係型資料庫中,可以編寫這樣的查詢: +1. 釋出推文時,只需將新推文插入全域性推文集合即可。當一個使用者請求自己的主頁時間線時,首先查詢他關注的所有人,查詢這些被關注使用者釋出的推文並按時間順序合併。在如[圖1-2](../img/fig1-2.png)所示的關係型資料庫中,可以編寫這樣的查詢: ```sql SELECT tweets.*, users.* @@ -183,13 +183,13 @@ JOIN follows ON follows.followee_id = users.id WHERE follows.follower_id = current_user ``` - ![](img/fig1-2.png) + ![](../img/fig1-2.png) **圖1-2 推特主頁時間線的關係型模式簡單實現** -2. 為每個使用者的主頁時間線維護一個快取,就像每個使用者的推文收件箱([圖1-3](img/fig1-3.png))。 當一個使用者釋出推文時,查詢所有關注該使用者的人,並將新的推文插入到每個主頁時間線快取中。 因此讀取主頁時間線的請求開銷很小,因為結果已經提前計算好了。 +2. 為每個使用者的主頁時間線維護一個快取,就像每個使用者的推文收件箱([圖1-3](../img/fig1-3.png))。 當一個使用者釋出推文時,查詢所有關注該使用者的人,並將新的推文插入到每個主頁時間線快取中。 因此讀取主頁時間線的請求開銷很小,因為結果已經提前計算好了。 - ![](img/fig1-3.png) + ![](../img/fig1-3.png) **圖1-3 用於分發推特至關注者的資料流水線,2012年11月的負載引數【16】** @@ -220,9 +220,9 @@ ​ 即使不斷重複傳送同樣的請求,每次得到的響應時間也都會略有不同。現實世界的系統會處理各式各樣的請求,響應時間可能會有很大差異。因此我們需要將響應時間視為一個可以測量的數值**分佈(distribution)**,而不是單個數值。 -​ 在[圖1-4](img/fig1-4.png)中,每個灰條表代表一次對服務的請求,其高度表示請求花費了多長時間。大多數請求是相當快的,但偶爾會出現需要更長的時間的異常值。這也許是因為緩慢的請求實質上開銷更大,例如它們可能會處理更多的資料。但即使(你認為)所有請求都花費相同時間的情況下,隨機的附加延遲也會導致結果變化,例如:上下文切換到後臺程序,網路資料包丟失與TCP重傳,垃圾收集暫停,強制從磁碟讀取的頁面錯誤,伺服器機架中的震動【18】,還有很多其他原因。 +​ 在[圖1-4](../img/fig1-4.png)中,每個灰條表代表一次對服務的請求,其高度表示請求花費了多長時間。大多數請求是相當快的,但偶爾會出現需要更長的時間的異常值。這也許是因為緩慢的請求實質上開銷更大,例如它們可能會處理更多的資料。但即使(你認為)所有請求都花費相同時間的情況下,隨機的附加延遲也會導致結果變化,例如:上下文切換到後臺程序,網路資料包丟失與TCP重傳,垃圾收集暫停,強制從磁碟讀取的頁面錯誤,伺服器機架中的震動【18】,還有很多其他原因。 -![](img/fig1-4.png) +![](../img/fig1-4.png) **圖1-4 展示了一個服務100次請求響應時間的均值與百分位數** @@ -232,7 +232,7 @@ ​ 如果想知道典型場景下使用者需要等待多長時間,那麼中位數是一個好的度量標準:一半使用者請求的響應時間少於響應時間的中位數,另一半服務時間比中位數長。中位數也被稱為第50百分位點,有時縮寫為p50。注意中位數是關於單個請求的;如果使用者同時發出幾個請求(在一個會話過程中,或者由於一個頁面中包含了多個資源),則至少一個請求比中位數慢的概率遠大於50%。 -​ 為了弄清異常值有多糟糕,可以看看更高的百分位點,例如第95、99和99.9百分位點(縮寫為p95,p99和p999)。它們意味著95%,99%或99.9%的請求響應時間要比該閾值快,例如:如果第95百分位點響應時間是1.5秒,則意味著100個請求中的95個響應時間快於1.5秒,而100個請求中的5個響應時間超過1.5秒。如[圖1-4](img/fig1-4.png)所示。 +​ 為了弄清異常值有多糟糕,可以看看更高的百分位點,例如第95、99和99.9百分位點(縮寫為p95,p99和p999)。它們意味著95%,99%或99.9%的請求響應時間要比該閾值快,例如:如果第95百分位點響應時間是1.5秒,則意味著100個請求中的95個響應時間快於1.5秒,而100個請求中的5個響應時間超過1.5秒。如[圖1-4](../img/fig1-4.png)所示。 ​ 響應時間的高百分位點(也稱為**尾部延遲(tail latencies)**)非常重要,因為它們直接影響使用者的服務體驗。例如亞馬遜在描述內部服務的響應時間要求時以99.9百分位點為準,即使它隻影響一千個請求中的一個。這是因為請求響應最慢的客戶往往也是資料最多的客戶,也可以說是最有價值的客戶 —— 因為他們掏錢了【19】。保證網站響應迅速對於保持客戶的滿意度非常重要,亞馬遜觀察到:響應時間增加100毫秒,銷售量就減少1%【20】;而另一些報告說:慢 1 秒鐘會讓客戶滿意度指標減少16%【21,22】。 @@ -246,13 +246,13 @@ > #### 實踐中的百分位點 > -> ​ 在多重呼叫的後端服務裡,高百分位數變得特別重要。即使並行呼叫,終端使用者請求仍然需要等待最慢的並行呼叫完成。如[圖1-5](img/fig1-5.png)所示,只需要一個緩慢的呼叫就可以使整個終端使用者請求變慢。即使只有一小部分後端呼叫速度較慢,如果終端使用者請求需要多個後端呼叫,則獲得較慢呼叫的機會也會增加,因此較高比例的終端使用者請求速度會變慢(效果稱為尾部延遲放大【24】)。 +> ​ 在多重呼叫的後端服務裡,高百分位數變得特別重要。即使並行呼叫,終端使用者請求仍然需要等待最慢的並行呼叫完成。如[圖1-5](../img/fig1-5.png)所示,只需要一個緩慢的呼叫就可以使整個終端使用者請求變慢。即使只有一小部分後端呼叫速度較慢,如果終端使用者請求需要多個後端呼叫,則獲得較慢呼叫的機會也會增加,因此較高比例的終端使用者請求速度會變慢(效果稱為尾部延遲放大【24】)。 > > ​ 如果您想將響應時間百分點新增到您的服務的監視儀表板,則需要持續有效地計算它們。例如,您可能希望在最近10分鐘內保持請求響應時間的滾動視窗。每一分鐘,您都會計算出該視窗中的中值和各種百分數,並將這些度量值繪製在圖上。 > > ​ 簡單的實現是在時間視窗內儲存所有請求的響應時間列表,並且每分鐘對列表進行排序。如果對你來說效率太低,那麼有一些演算法能夠以最小的CPU和記憶體成本(如前向衰減【25】,t-digest【26】或HdrHistogram 【27】)來計算百分位數的近似值。請注意,平均百分比(例如,減少時間解析度或合併來自多臺機器的資料)在數學上沒有意義 - 聚合響應時間資料的正確方法是新增直方圖【28】。 -![](img/fig1-5.png) +![](../img/fig1-5.png) **圖1-5 當一個請求需要多個後端請求時,單個後端慢請求就會拖慢整個終端使用者的請求** @@ -376,7 +376,7 @@ ​ 不幸的是,使應用可靠、可擴充套件或可維護並不容易。但是某些模式和技術會不斷重新出現在不同的應用中。在接下來的幾章中,我們將看到一些資料系統的例子,並分析它們如何實現這些目標。 -​ 在本書後面的[第三部分](part-iii.md)中,我們將看到一種模式:幾個元件協同工作以構成一個完整的系統(如[圖1-1](img/fig1-1.png)中的例子) +​ 在本書後面的[第三部分](part-iii.md)中,我們將看到一種模式:幾個元件協同工作以構成一個完整的系統(如[圖1-1](../img/fig1-1.png)中的例子) diff --git a/zh-tw/ch10.md b/zh-tw/ch10.md index 9760f582..27a7cb74 100644 --- a/zh-tw/ch10.md +++ b/zh-tw/ch10.md @@ -1,6 +1,6 @@ # 10. 批處理 -![](img/ch10.png) +![](../img/ch10.png) > 帶有太強個人色彩的系統無法成功。當最初的設計完成並且相對穩定時,不同的人們以自己的方式進行測試,真正的考驗才開始。 > @@ -247,11 +247,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 在分散式計算中可以使用標準的Unix工具作為Mapper和Reducer【25】,但更常見的是,它們被實現為傳統程式語言的函式。在Hadoop MapReduce中,Mapper和Reducer都是實現特定介面的Java類。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函式(參閱“[MapReduce查詢](ch2.md#MapReduce查詢)”)。 -​ [圖10-1]()顯示了Hadoop MapReduce作業中的資料流。其並行化基於分割槽(參見[第6章](ch6.md)):作業的輸入通常是HDFS中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理map任務([圖10-1](img/fig10-1.png)中的m1,m2和m3標記)。 +​ [圖10-1]()顯示了Hadoop MapReduce作業中的資料流。其並行化基於分割槽(參見[第6章](ch6.md)):作業的輸入通常是HDFS中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理map任務([圖10-1](../img/fig10-1.png)中的m1,m2和m3標記)。 ​ 每個輸入檔案的大小通常是數百兆位元組。 MapReduce排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個Mapper,只要該機器有足夠的備用RAM和CPU資源來執行Mapper任務【26】。這個原則被稱為**將計算放在資料附近**【27】:它節省了透過網路複製輸入檔案的開銷,減少網路負載並增加區域性性。 -![](img/fig10-1.png) +![](../img/fig10-1.png) **圖10-1 具有三個Mapper和三個Reducer的MapReduce任務** @@ -297,9 +297,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 示例:分析使用者活動事件 -​ [圖10-2](img/fig10-2.png)給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為**活動事件(activity events)**或**點選流資料(clickstream data)**),右側是使用者資料庫。 你可以將此示例看作是星型模式的一部分(參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。 +​ [圖10-2](../img/fig10-2.png)給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為**活動事件(activity events)**或**點選流資料(clickstream data)**),右側是使用者資料庫。 你可以將此示例看作是星型模式的一部分(參閱“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。 -![](img/fig10-2.png) +![](../img/fig10-2.png) **圖10-2 使用者行為日誌與使用者檔案的連線** @@ -313,9 +313,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 排序合併連線 -​ 回想一下,Mapper的目的是從每個輸入記錄中提取一對鍵值。在[圖10-2](img/fig10-2.png)的情況下,這個鍵就是使用者ID:一組Mapper會掃過活動事件(提取使用者ID作為鍵,活動事件作為值),而另一組Mapper將會掃過使用者資料庫(提取使用者ID作為鍵,使用者的出生日期作為值)。這個過程如[圖10-3](img/fig10-3.png)所示。 +​ 回想一下,Mapper的目的是從每個輸入記錄中提取一對鍵值。在[圖10-2](../img/fig10-2.png)的情況下,這個鍵就是使用者ID:一組Mapper會掃過活動事件(提取使用者ID作為鍵,活動事件作為值),而另一組Mapper將會掃過使用者資料庫(提取使用者ID作為鍵,使用者的出生日期作為值)。這個過程如[圖10-3](../img/fig10-3.png)所示。 -![](img/fig10-3.png) +![](../img/fig10-3.png) **圖10-3 在使用者ID上進行的Reduce端連線。如果輸入資料集分割槽為多個檔案,則每個分割槽都會被多個Mapper並行處理** @@ -375,11 +375,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 適用於執行Map端連線的最簡單場景是大資料集與小資料集連線的情況。要點在於小資料集需要足夠小,以便可以將其全部載入到每個Mapper的記憶體中。 -​ 例如,假設在[圖10-2](img/fig10-2.png)的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當Mapper啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的雜湊中。完成此操作後,Map程式可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者ID[^vi]。 +​ 例如,假設在[圖10-2](../img/fig10-2.png)的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當Mapper啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的雜湊中。完成此操作後,Map程式可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者ID[^vi]。 [^vi]: 這個例子假定散列表中的每個鍵只有一個條目,這對使用者資料庫(使用者ID唯一標識一個使用者)可能是正確的。通常,雜湊表可能需要包含具有相同鍵的多個條目,而連線運算子將對每個鍵輸出所有的匹配。 -​ 參與連線的較大輸入的每個檔案塊各有一個Mapper(在[圖10-2](img/fig10-2.png)的例子中活動事件是較大的輸入)。每個Mapper都會將較小輸入整個載入到記憶體中。 +​ 參與連線的較大輸入的每個檔案塊各有一個Mapper(在[圖10-2](../img/fig10-2.png)的例子中活動事件是較大的輸入)。每個Mapper都會將較小輸入整個載入到記憶體中。 ​ 這種簡單有效的演算法被稱為**廣播雜湊連線(broadcast hash join)**:**廣播**一詞反映了這樣一個事實,每個連線較大輸入端分割槽的Mapper都會將較小輸入端資料集整個讀入記憶體中(所以較小輸入實際上“廣播”到較大資料的所有分割槽上),**雜湊**一詞反映了它使用一個散列表。 Pig(名為“**複製連結(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支援這種連線。它也被諸如Impala的資料倉庫查詢引擎使用【41】。 @@ -387,7 +387,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 分割槽雜湊連線 -​ 如果Map端連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在[圖10-2](img/fig10-2.png)的情況中,你可以根據使用者ID的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有10個分割槽)。例如,Mapper3首先將所有具有以3結尾的ID的使用者載入到散列表中,然後掃描ID為3的每個使用者的所有活動事件。 +​ 如果Map端連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在[圖10-2](../img/fig10-2.png)的情況中,你可以根據使用者ID的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有10個分割槽)。例如,Mapper3首先將所有具有以3結尾的ID的使用者載入到散列表中,然後掃描ID為3的每個使用者的所有活動事件。 ​ 如果分割槽正確無誤,可以確定的是,所有你可能需要連線的記錄都落在同一個編號的分割槽中。因此每個Mapper只需要從輸入兩端各讀取一個分割槽就足夠了。好處是每個Mapper都可以在記憶體散列表中少放點資料。 @@ -612,7 +612,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 > 像Spark,Flink和Tez這樣的資料流引擎(參見“[中間狀態的物化](#中間狀態的物化)”)通常將運算元作為**有向無環圖(DAG)**的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流**被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂! -​ 許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**閉包傳遞(transitive closure)**)。 +​ 許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在[圖2-6](../img/fig2-6.png)中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為**閉包傳遞(transitive closure)**)。 ​ 可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種“重複至完成”的想法不能用普通的MapReduce來表示,因為它只掃過一趟資料。這種演算法因此經常以**迭代**的風格實現: diff --git a/zh-tw/ch11.md b/zh-tw/ch11.md index 5d87e038..c52325b2 100644 --- a/zh-tw/ch11.md +++ b/zh-tw/ch11.md @@ -1,6 +1,6 @@ # 11. 流處理 -![](img/ch11.png) +![](../img/ch11.png) > 有效的複雜系統總是從簡單的系統演化而來。 反之亦然:從零設計的複雜系統沒一個能有效工作的。 > @@ -94,7 +94,7 @@ #### 多個消費者 -當多個消費者從同一主題中讀取訊息時,有使用兩種主要的訊息傳遞模式,如[圖11-1](img/fig11-1.png)所示: +當多個消費者從同一主題中讀取訊息時,有使用兩種主要的訊息傳遞模式,如[圖11-1](../img/fig11-1.png)所示: ***負載均衡(load balance)*** @@ -104,7 +104,7 @@ ​ 每條訊息都被傳遞給**所有**消費者。扇出允許幾個獨立的消費者各自“收聽”相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 (JMS中的主題訂閱與AMQP中的交叉繫結提供了這一功能)。 -![](img/fig11-1.png) +![](../img/fig11-1.png) **圖11-1 (a)負載平衡:在消費者間共享消費主題;(b)扇出:將每條訊息傳遞給多個消費者。** @@ -116,9 +116,9 @@ ​ 如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。 (請注意可能發生這樣的情況,訊息**實際上是**處理完畢的,但**確認**在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在“[實踐中的分散式事務](ch9.md#實踐中的分散式事務)”中所討論的那樣) -​ 當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖11-2](img/fig11-2.png)中,消費者通常按照生產者傳送的順序處理訊息。然而消費者2在處理訊息m3時崩潰,與此同時消費者1正在處理訊息m4。未確認的訊息m3隨後被重新發送給消費者1,結果消費者1按照m4,m3,m5的順序處理訊息。因此m3和m4的交付順序與以生產者1的傳送順序不同。 +​ 當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖11-2](../img/fig11-2.png)中,消費者通常按照生產者傳送的順序處理訊息。然而消費者2在處理訊息m3時崩潰,與此同時消費者1正在處理訊息m4。未確認的訊息m3隨後被重新發送給消費者1,結果消費者1按照m4,m3,m5的順序處理訊息。因此m3和m4的交付順序與以生產者1的傳送順序不同。 -![](img/fig11-2.png) +![](../img/fig11-2.png) **圖11-2 在處理m3時消費者2崩潰,因此稍後重傳至消費者1** @@ -142,11 +142,11 @@ ​ 同樣的結構可以用於實現訊息代理:生產者透過將訊息追加到日誌末尾來發送訊息,而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾,則會等待新訊息追加的通知。 Unix工具`tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。 -​ 為了擴充套件到比單個磁碟所能提供的更高吞吐量,可以對日誌進行**分割槽**(在[第6章](ch6.md)的意義上)。不同的分割槽可以託管在不同的機器上,且每個分割槽都拆分出一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如[圖11-3](img/fig11-3.png)所示。 +​ 為了擴充套件到比單個磁碟所能提供的更高吞吐量,可以對日誌進行**分割槽**(在[第6章](ch6.md)的意義上)。不同的分割槽可以託管在不同的機器上,且每個分割槽都拆分出一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如[圖11-3](../img/fig11-3.png)所示。 -​ 在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或**偏移量(offset)**(在[圖11-3](img/fig11-3.png)中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。 +​ 在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或**偏移量(offset)**(在[圖11-3](../img/fig11-3.png)中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。 -![](img/fig11-3.png) +![](../img/fig11-3.png) **圖11-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案** @@ -223,9 +223,9 @@ ​ 如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是**雙寫(dual write)**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。 -​ 但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如[圖11-4](img/fig11-4.png)所示。在這個例子中,兩個客戶端同時想要更新一個專案X:客戶端1想要將值設定為A,客戶端2想要將其設定為B。兩個客戶端首先將新值寫入資料庫,然後將其寫入到搜尋索引。因為運氣不好,這些請求的時序是交錯的:資料庫首先看到來自客戶端1的寫入將值設定為A,然後來自客戶端2的寫入將值設定為B,因此資料庫中的最終值為B。搜尋索引首先看到來自客戶端2的寫入,然後是客戶端1的寫入,所以搜尋索引中的最終值是A。即使沒發生錯誤,這兩個系統現在也永久地不一致了。 +​ 但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如[圖11-4](../img/fig11-4.png)所示。在這個例子中,兩個客戶端同時想要更新一個專案X:客戶端1想要將值設定為A,客戶端2想要將其設定為B。兩個客戶端首先將新值寫入資料庫,然後將其寫入到搜尋索引。因為運氣不好,這些請求的時序是交錯的:資料庫首先看到來自客戶端1的寫入將值設定為A,然後來自客戶端2的寫入將值設定為B,因此資料庫中的最終值為B。搜尋索引首先看到來自客戶端2的寫入,然後是客戶端1的寫入,所以搜尋索引中的最終值是A。即使沒發生錯誤,這兩個系統現在也永久地不一致了。 -![](img/fig11-4.png) +![](../img/fig11-4.png) **圖11-4 在資料庫中X首先被設定為A,然後被設定為B,而在搜尋索引處,寫入以相反的順序到達** @@ -233,7 +233,7 @@ ​ 雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(參閱“[原子提交和兩階段提交(2PC)](ch7.md#原子提交和兩階段提交(2PC))”)。 -​ 如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在[圖11-4](img/fig11-4.png)中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(參見“[多領導者複製](ch5.md#多領導者複製)“)。 +​ 如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在[圖11-4](../img/fig11-4.png)中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(參見“[多領導者複製](ch5.md#多領導者複製)“)。 ​ 如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎? @@ -245,9 +245,9 @@ ​ 最近,人們對**變更資料捕獲(change data capture, CDC)** 越來越感興趣,這是一種觀察寫入資料庫的所有資料變更,並將其提取並轉換為可以複製到其他系統中的形式的過程。 CDC是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。 -​ 例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如[圖11-5](img/fig11-5.png)所示。 +​ 例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如[圖11-5](../img/fig11-5.png)所示。 -![](img/fig11-5.png) +![](../img/fig11-5.png) **圖11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統** @@ -255,7 +255,7 @@ ​ 我們可以將日誌消費者叫做**衍生資料系統**,正如在第三部分的[介紹](part-iii.md)中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是**記錄系統**資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在衍生資料系統中,以便衍生系統具有資料的準確副本。 -​ 從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了[圖11-2](img/fig11-2.png)的重新排序問題)。 +​ 從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了[圖11-2](../img/fig11-2.png)的重新排序問題)。 ​ 資料庫觸發器可用來實現變更資料捕獲(參閱“[基於觸發器的複製](ch5.md#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如應對模式變更。 @@ -275,7 +275,7 @@ ​ 如果你只能保留有限的歷史日誌,則每次要新增新的衍生資料系統時,都需要做一次快照。但**日誌壓縮(log compaction)** 提供了一個很好的備選方案。 -​ 我們之前在日誌結構儲存引擎的上下文中討論了“[Hash索引](ch3.md#Hash索引)”中的日誌壓縮(參見[圖3-2](img/fig3-2.png)的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。 +​ 我們之前在日誌結構儲存引擎的上下文中討論了“[Hash索引](ch3.md#Hash索引)”中的日誌壓縮(參見[圖3-2](../img/fig3-2.png)的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。 ​ 在日誌結構儲存引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示該鍵被刪除,並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除,它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容,而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入,則先前的值將最終將被垃圾回收,只有最新的值會保留下來。 @@ -299,7 +299,7 @@ ​ 與變更資料捕獲類似,事件溯源涉及到**將所有對應用狀態的變更** 儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了幾個不同的抽象層次上: -* 在變更資料捕獲中,應用以**可變方式(mutable way)** 使用資料庫,任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免[圖11-4](img/fig11-4.png)中的競態條件。寫入資料庫的應用不需要知道CDC的存在。 +* 在變更資料捕獲中,應用以**可變方式(mutable way)** 使用資料庫,任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免[圖11-4](../img/fig11-4.png)中的競態條件。寫入資料庫的應用不需要知道CDC的存在。 * 在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。在這種情況下,事件儲存是僅追加寫入的,更新與刪除是不鼓勵的或禁止的。事件被設計為旨在反映應用層面發生的事情,而不是底層的狀態變更。 事件源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件代理使得應用隨時間演化更為容易,透過事實更容易理解事情發生的原因,使得除錯更為容易,並有利於防止應用Bug(請參閱“[不可變事件的優點](#不可變事件的優點)”)。 @@ -345,12 +345,12 @@ ​ 無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌—— **變化日誌(change log)**,表示了隨時間演變的狀態。 -​ 如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如[圖11-6](img/fig11-6.png)所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。 +​ 如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如[圖11-6](../img/fig11-6.png)所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。 $$ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ stream(t) = \frac{d\ state(t)}{dt} $$ -![](img/fig11-6.png) +![](../img/fig11-6.png) **圖11-6 應用當前狀態與事件流之間的關係** @@ -372,7 +372,7 @@ $$ #### 從同一事件日誌中派生多個檢視 -​ 此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖11-5](img/fig11-5.png)):例如,分析型資料庫Druid使用這種方式直接從Kafka攝取資料【55】,Pistachio是一個分散式的鍵值儲存,使用Kafka作為提交日誌【56】,Kafka Connect能將來自Kafka的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(參閱“[保持系統同步](#保持系統同步)”)。 +​ 此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖11-5](../img/fig11-5.png)):例如,分析型資料庫Druid使用這種方式直接從Kafka攝取資料【55】,Pistachio是一個分散式的鍵值儲存,使用Kafka作為提交日誌【56】,Kafka Connect能將來自Kafka的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(參閱“[保持系統同步](#保持系統同步)”)。 ​ 新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的,針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行復雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源【47,57】。 @@ -412,7 +412,7 @@ $$ 剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項: -1. 你可以將事件中的資料寫入資料庫,快取,搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如[圖11-5](img/fig11-5.png)所示,這是資料庫與系統其他部分發生變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”中所討論的,它是寫入儲存系統的流等價物。 +1. 你可以將事件中的資料寫入資料庫,快取,搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如[圖11-5](../img/fig11-5.png)所示,這是資料庫與系統其他部分發生變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如“[批處理工作流的輸出](ch10.md#批處理工作流的輸出)”中所討論的,它是寫入儲存系統的流等價物。 2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可實時顯示的儀表板上。在這種情況下,人是流的最終消費者。 3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項1或2)。 @@ -507,9 +507,9 @@ $$ [^ii]: 感謝Flink社群的Kostas Kloudas提出這個比喻。 -​ 將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖11-7](img/fig11-7.png))。 +​ 將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖11-7](../img/fig11-7.png))。 -![](img/fig11-7.png) +![](../img/fig11-7.png) **圖11-7 按處理時間分窗,會因為處理速率的變動引入人為因素** @@ -580,7 +580,7 @@ $$ #### 流表連線(流擴充套件) -​ 在“[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”([圖10-2](img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者ID的活動事件流,而輸出還是活動事件流,但其中使用者ID已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為 使用資料庫的資訊來**擴充(enriching)** 活動事件。 +​ 在“[示例:使用者活動事件分析](ch10.md#示例:使用者活動事件分析)”([圖10-2](../img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者ID的活動事件流,而輸出還是活動事件流,但其中使用者ID已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為 使用資料庫的資訊來**擴充(enriching)** 活動事件。 ​ 要執行此聯接,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者ID,並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在“[示例:分析使用者活動事件](ch10.md#示例:分析使用者活動事件)”一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載【75】。 @@ -615,7 +615,7 @@ GROUP BY follows.follower_id ​ 流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當基礎表發生變化時都會更新[^iii]。 -[^iii]: 如果你將流視作表的衍生物,如[圖11-6](img/fig11-6.png)所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。 +[^iii]: 如果你將流視作表的衍生物,如[圖11-6](../img/fig11-6.png)所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。 #### 連線的時間依賴性 diff --git a/zh-tw/ch12.md b/zh-tw/ch12.md index bed9b25e..1503c0dd 100644 --- a/zh-tw/ch12.md +++ b/zh-tw/ch12.md @@ -1,6 +1,6 @@ # 12. 資料系統的未來 -![](img/ch12.png) +![](../img/ch12.png) > 如果船長的終極目標是保護船隻,他應該永遠待在港口。 > @@ -42,7 +42,7 @@ ​ 例如,你可能會首先將資料寫入**記錄資料庫**系統,捕獲對該資料庫所做的變更(參閱“[捕獲資料變更](ch11.md#捕獲資料變更)”),然後將變更應用於資料庫中的搜尋索引相同的順序。如果變更資料捕獲(CDC)是更新索引的唯一方式,則可以確定該索引完全派生自記錄系統,因此與其保持一致(除軟體錯誤外)。寫入資料庫是向該系統提供新輸入的唯一方式。 -​ 允許應用程式直接寫入搜尋索引和資料庫引入瞭如[圖11-4](img/fig11-4.png)所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。 +​ 允許應用程式直接寫入搜尋索引和資料庫引入瞭如[圖11-4](../img/fig11-4.png)所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。 ​ 如果您可以透過單個系統來提供所有使用者輸入,從而決定所有寫入的排序,則透過按相同順序處理寫入,可以更容易地衍生出其他資料表示。 這是狀態機複製方法的一個應用,我們在“[全序廣播](ch9.md#全序廣播)”中看到。無論您使用變更資料捕獲還是事件源日誌,都不如僅對全域性順序達成共識更重要。 @@ -328,9 +328,9 @@ ### 觀察衍生資料狀態 -​ 在抽象層面,上一節討論的資料流系統提供了建立衍生資料集(例如搜尋索引,物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為**寫路徑(write path)**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個衍生資料集都會被更新,以適配寫入的資料。[圖12-1](img/fig12-1.png)顯示了一個更新搜尋索引的例子。 +​ 在抽象層面,上一節討論的資料流系統提供了建立衍生資料集(例如搜尋索引,物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為**寫路徑(write path)**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個衍生資料集都會被更新,以適配寫入的資料。[圖12-1](../img/fig12-1.png)顯示了一個更新搜尋索引的例子。 -![](img/fig12-1.png) +![](../img/fig12-1.png) **圖12-1 在搜尋索引中,寫(文件更新)遇上讀(查詢)** @@ -338,7 +338,7 @@ ​ 總而言之,寫路徑和讀路徑涵蓋了資料的整個旅程,從收集資料開始,到使用資料結束(可能是由另一個人)。寫路徑是預計算過程的一部分 —— 即,一旦資料進入,即刻完成,無論是否有人需要看它。讀路徑是這個過程中只有當有人請求時才會發生的部分。如果你熟悉函數語言程式設計語言,則可能會注意到寫路徑類似於立即求值,讀路徑類似於惰性求值。 -​ 如[圖12-1](img/fig12-1.png)所示,衍生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。 +​ 如[圖12-1](../img/fig12-1.png)所示,衍生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。 #### 物化檢視和快取 @@ -454,7 +454,7 @@ ​ 除了流處理之外,其他許多地方也需要抑制重複的模式。例如,TCP使用資料包上的序列號,在接收方將它們正確排序。並確定網路上是否有資料包丟失或重複。任何丟失的資料包都會被重新傳輸,而在將資料交付應用前,TCP協議棧會移除任何重複資料包。 -​ 但是,這種重複抑制僅適用於單條TCP連線的場景中。假設TCP連線是一個客戶端與資料庫的連線,並且它正在執行[例12-1]()中的事務。在許多資料庫中,事務是繫結在客戶端連線上的(如果客戶端傳送了多個查詢,資料庫就知道它們屬於同一個事務,因為它們是在同一個TCP連線上傳送的)。如果客戶端在傳送`COMMIT`之後但在從資料庫伺服器收到響應之前遇到網路中斷與連線超時,客戶端是不知道事務是否已經被提交的([圖8-1](img/fig8-1.png))。 +​ 但是,這種重複抑制僅適用於單條TCP連線的場景中。假設TCP連線是一個客戶端與資料庫的連線,並且它正在執行[例12-1]()中的事務。在許多資料庫中,事務是繫結在客戶端連線上的(如果客戶端傳送了多個查詢,資料庫就知道它們屬於同一個事務,因為它們是在同一個TCP連線上傳送的)。如果客戶端在傳送`COMMIT`之後但在從資料庫伺服器收到響應之前遇到網路中斷與連線超時,客戶端是不知道事務是否已經被提交的([圖8-1](../img/fig8-1.png))。 **例12-1 資金從一個賬戶到另一個賬戶的非冪等轉移** diff --git a/zh-tw/ch2.md b/zh-tw/ch2.md index 2b2e52e7..668c8914 100644 --- a/zh-tw/ch2.md +++ b/zh-tw/ch2.md @@ -1,6 +1,6 @@ # 2. 資料模型與查詢語言 -![](img/ch2.png) +![](../img/ch2.png) > 語言的邊界就是思想的邊界。 > @@ -65,13 +65,13 @@ 像ActiveRecord和Hibernate這樣的 **物件關係對映(ORM object-relational mapping)** 框架可以減少這個轉換層所需的樣板程式碼的數量,但是它們不能完全隱藏這兩個模型之間的差異。 -![](img/fig2-1.png) +![](../img/fig2-1.png) **圖2-1 使用關係型模式來表示領英簡介** -例如,[圖2-1](img/fig2-1.png)展示瞭如何在關係模式中表示簡歷(一個LinkedIn簡介)。整個簡介可以透過一個唯一的識別符號`user_id`來標識。像`first_name`和`last_name`這樣的欄位每個使用者只出現一次,所以可以在User表上將其建模為列。但是,大多數人在職業生涯中擁有多於一份的工作,人們可能有不同樣的教育階段和任意數量的聯絡資訊。從使用者到這些專案之間存在一對多的關係,可以用多種方式來表示: +例如,[圖2-1](../img/fig2-1.png)展示瞭如何在關係模式中表示簡歷(一個LinkedIn簡介)。整個簡介可以透過一個唯一的識別符號`user_id`來標識。像`first_name`和`last_name`這樣的欄位每個使用者只出現一次,所以可以在User表上將其建模為列。但是,大多數人在職業生涯中擁有多於一份的工作,人們可能有不同樣的教育階段和任意數量的聯絡資訊。從使用者到這些專案之間存在一對多的關係,可以用多種方式來表示: -* 傳統SQL模型(SQL:1999之前)中,最常見的規範化表示形式是將職位,教育和聯絡資訊放在單獨的表中,對User表提供外來鍵引用,如[圖2-1](img/fig2-1.png)所示。 +* 傳統SQL模型(SQL:1999之前)中,最常見的規範化表示形式是將職位,教育和聯絡資訊放在單獨的表中,對User表提供外來鍵引用,如[圖2-1](../img/fig2-1.png)所示。 * 後續的SQL標準增加了對結構化資料型別和XML資料的支援;這允許將多值資料儲存在單行內,並支援在這些文件內查詢和索引。這些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支援【6,7】。JSON資料型別也得到多個數據庫的支援,包括IBM DB2,MySQL和PostgreSQL 【8】。 * 第三種選擇是將職業,教育和聯絡資訊編碼為JSON或XML文件,將其儲存在資料庫的文字列中,並讓應用程式解析其結構和內容。這種配置下,通常不能使用資料庫來查詢該編碼列中的值。 @@ -119,11 +119,11 @@ 有一些開發人員認為JSON模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。不過,正如我們將在[第4章](ch4.md)中看到的那樣,JSON作為資料編碼格式也存在問題。缺乏一個模式往往被認為是一個優勢;我們將在“[文件模型中的模式靈活性](#文件模型中的模式靈活性)”中討論這個問題。 -JSON表示比[圖2-1](img/fig2-1.png)中的多表模式具有更好的**區域性性(locality)**。如果在前面的關係型示例中獲取簡介,那需要執行多個查詢(透過`user_id`查詢每個表),或者在User表與其下屬表之間混亂地執行多路連線。而在JSON表示中,所有相關資訊都在同一個地方,一個查詢就足夠了。 +JSON表示比[圖2-1](../img/fig2-1.png)中的多表模式具有更好的**區域性性(locality)**。如果在前面的關係型示例中獲取簡介,那需要執行多個查詢(透過`user_id`查詢每個表),或者在User表與其下屬表之間混亂地執行多路連線。而在JSON表示中,所有相關資訊都在同一個地方,一個查詢就足夠了。 -從使用者簡介檔案到使用者職位,教育歷史和聯絡資訊,這種一對多關係隱含了資料中的一個樹狀結構,而JSON表示使得這個樹狀結構變得明確(見[圖2-2](img/fig2-2.png))。 +從使用者簡介檔案到使用者職位,教育歷史和聯絡資訊,這種一對多關係隱含了資料中的一個樹狀結構,而JSON表示使得這個樹狀結構變得明確(見[圖2-2](../img/fig2-2.png))。 -![](img/fig2-2.png) +![](../img/fig2-2.png) **圖2-2 一對多關係構建了一個樹結構** @@ -157,18 +157,18 @@ JSON表示比[圖2-1](img/fig2-1.png)中的多表模式具有更好的**區域 ***組織和學校作為實體*** -在前面的描述中,`organization`(使用者工作的公司)和`school_name`(他們學習的地方)只是字串。也許他們應該是對實體的引用呢?然後,每個組織,學校或大學都可以擁有自己的網頁(標識,新聞提要等)。每個簡歷可以連結到它所提到的組織和學校,並且包括他們的圖示和其他資訊(參見[圖2-3](img/fig2-3.png),來自LinkedIn的一個例子)。 +在前面的描述中,`organization`(使用者工作的公司)和`school_name`(他們學習的地方)只是字串。也許他們應該是對實體的引用呢?然後,每個組織,學校或大學都可以擁有自己的網頁(標識,新聞提要等)。每個簡歷可以連結到它所提到的組織和學校,並且包括他們的圖示和其他資訊(參見[圖2-3](../img/fig2-3.png),來自LinkedIn的一個例子)。 ***推薦*** 假設你想新增一個新的功能:一個使用者可以為另一個使用者寫一個推薦。在使用者的簡歷上顯示推薦,並附上推薦使用者的姓名和照片。如果推薦人更新他們的照片,那他們寫的任何建議都需要顯示新的照片。因此,推薦應該擁有作者個人簡介的引用。 -![](img/fig2-3.png) +![](../img/fig2-3.png) **圖2-3 公司名不僅是字串,還是一個指向公司實體的連結(LinkedIn截圖)** -[圖2-4](img/fig2-4.png)闡明瞭這些新功能需要如何使用多對多關係。每個虛線矩形內的資料可以分組成一個文件,但是對單位,學校和其他使用者的引用需要表示成引用,並且在查詢時需要連線。 +[圖2-4](../img/fig2-4.png)闡明瞭這些新功能需要如何使用多對多關係。每個虛線矩形內的資料可以分組成一個文件,但是對單位,學校和其他使用者的引用需要表示成引用,並且在查詢時需要連線。 -![](img/fig2-4.png) +![](../img/fig2-4.png) **圖2-4 使用多對多關係擴充套件簡歷** @@ -178,7 +178,7 @@ JSON表示比[圖2-1](img/fig2-1.png)中的多表模式具有更好的**區域 20世紀70年代最受歡迎的業務資料處理資料庫是IBM的資訊管理系統(IMS),最初是為了阿波羅太空計劃的庫存管理而開發的,並於1968年有了首次商業釋出【13】。目前它仍在使用和維護,執行在IBM大型機的OS/390上【14】。 -IMS的設計中使用了一個相當簡單的資料模型,稱為**層次模型(hierarchical model)**,它與文件資料庫使用的JSON模型有一些驚人的相似之處【2】。它將所有資料表示為巢狀在記錄中的記錄樹,這很像[圖2-2](img/fig2-2.png)的JSON結構。 +IMS的設計中使用了一個相當簡單的資料模型,稱為**層次模型(hierarchical model)**,它與文件資料庫使用的JSON模型有一些驚人的相似之處【2】。它將所有資料表示為巢狀在記錄中的記錄樹,這很像[圖2-2](../img/fig2-2.png)的JSON結構。 同文檔資料庫一樣,IMS能良好處理一對多的關係,但是很難應對多對多的關係,並且不支援連線。開發人員必須決定是否複製(非規範化)資料或手動解決從一個記錄到另一個記錄的引用。這些二十世紀六七十年代的問題與現在開發人員遇到的文件資料庫問題非常相似【15】。 @@ -226,7 +226,7 @@ CODASYL中的查詢是透過利用遍歷記錄列和跟隨訪問路徑表在資 #### 哪個資料模型更方便寫程式碼? -如果應用程式中的資料具有類似文件的結構(即,一對多關係樹,通常一次性載入整個樹),那麼使用文件模型可能是一個好主意。將類似文件的結構分解成多個表(如[圖2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的關係技術可能導致繁瑣的模式和不必要的複雜的應用程式程式碼。 +如果應用程式中的資料具有類似文件的結構(即,一對多關係樹,通常一次性載入整個樹),那麼使用文件模型可能是一個好主意。將類似文件的結構分解成多個表(如[圖2-1](../img/fig2-1.png)中的`positions`,`education`和`contact_info`)的關係技術可能導致繁瑣的模式和不必要的複雜的應用程式程式碼。 文件模型有一定的侷限性:例如,不能直接引用文件中的巢狀的專案,而是需要說“使用者251的位置列表中的第二項”(很像分層模型中的訪問路徑)。但是,只要檔案巢狀不太深,這通常不是問題。 @@ -274,7 +274,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 查詢的資料區域性性 -文件通常以單個連續字串形式進行儲存,編碼為JSON,XML或其二進位制變體(如MongoDB的BSON)。如果應用程式經常需要訪問整個文件(例如,將其渲染至網頁),那麼儲存區域性性會帶來效能優勢。如果將資料分割到多個表中(如[圖2-1](img/fig2-1.png)所示),則需要進行多次索引查詢才能將其全部檢索出來,這可能需要更多的磁碟查詢並花費更多的時間。 +文件通常以單個連續字串形式進行儲存,編碼為JSON,XML或其二進位制變體(如MongoDB的BSON)。如果應用程式經常需要訪問整個文件(例如,將其渲染至網頁),那麼儲存區域性性會帶來效能優勢。如果將資料分割到多個表中(如[圖2-1](../img/fig2-1.png)所示),則需要進行多次索引查詢才能將其全部檢索出來,這可能需要更多的磁碟查詢並花費更多的時間。 區域性性僅僅適用於同時需要文件絕大部分內容的情況。資料庫通常需要載入整個文件,即使只訪問其中的一小部分,這對於大型文件來說是很浪費的。更新文件時,通常需要整個重寫。只有不改變文件大小的修改才可以容易地原地執行。因此,通常建議保持相對小的文件,並避免增加文件大小的寫入【9】。這些效能限制大大減少了文件資料庫的實用場景。 @@ -533,9 +533,9 @@ db.observations.aggregate([ 在剛剛給出的例子中,圖中的所有頂點代表了相同型別的事物(人,網頁或交叉路口)。不過,圖並不侷限於這樣的同類資料:同樣強大地是,圖提供了一種一致的方式,用來在單個數據儲存中儲存完全不同型別的物件。例如,Facebook維護一個包含許多不同型別的頂點和邊的單個圖:頂點表示人,地點,事件,簽到和使用者的評論;邊緣表示哪些人是彼此的朋友,哪個簽到發生在何處,誰評論了哪條訊息,誰參與了哪個事件,等等【35】。 -在本節中,我們將使用[圖2-5](img/fig2-5.png)所示的示例。它可以從社交網路或系譜資料庫中獲得:它顯示了兩個人,來自愛達荷州的Lucy和來自法國Beaune的Alain。他們已婚,住在倫敦。 +在本節中,我們將使用[圖2-5](../img/fig2-5.png)所示的示例。它可以從社交網路或系譜資料庫中獲得:它顯示了兩個人,來自愛達荷州的Lucy和來自法國Beaune的Alain。他們已婚,住在倫敦。 -![](img/fig2-5.png) +![](../img/fig2-5.png) **圖2-5 圖資料結構示例(框代表頂點,箭頭代表邊)** @@ -586,7 +586,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 2. 給定任何頂點,可以高效地找到它的入邊和出邊,從而遍歷圖,即沿著一系列頂點的路徑前後移動。(這就是為什麼[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) 3. 透過對不同型別的關係使用不同的標籤,可以在一個圖中儲存幾種不同的資訊,同時仍然保持一個清晰的資料模型。 -這些特性為資料建模提供了很大的靈活性,如[圖2-5](img/fig2-5.png)所示。圖中顯示了一些傳統關係模式難以表達的事情,例如不同國家的不同地區結構(法國有省和州,美國有不同的州和州),國中國的怪事(先忽略主權國家和國家錯綜複雜的爛攤子),不同的資料粒度(Lucy現在的住所被指定為一個城市,而她的出生地點只是在一個州的級別)。 +這些特性為資料建模提供了很大的靈活性,如[圖2-5](../img/fig2-5.png)所示。圖中顯示了一些傳統關係模式難以表達的事情,例如不同國家的不同地區結構(法國有省和州,美國有不同的州和州),國中國的怪事(先忽略主權國家和國家錯綜複雜的爛攤子),不同的資料粒度(Lucy現在的住所被指定為一個城市,而她的出生地點只是在一個州的級別)。 你可以想象延伸圖還能包括許多關於Lucy和Alain,或其他人的其他更多的事實。例如,你可以用它來表示食物過敏(為每個過敏源增加一個頂點,並增加人與過敏源之間的一條邊來指示一種過敏情況),並連結到過敏源,每個過敏源具有一組頂點用來顯示哪些食物含有哪些物質。然後,你可以寫一個查詢,找出每個人吃什麼是安全的。圖表在可演化性是富有優勢的:當嚮應用程式新增功能時,可以輕鬆擴充套件圖以適應應用程式資料結構的變化。 @@ -594,7 +594,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); Cypher是屬性圖的宣告式查詢語言,為Neo4j圖形資料庫而發明【37】。(它是以電影“駭客帝國”中的一個角色來命名的,而與密碼術中的密碼無關【38】。) -[例2-3]()顯示了將[圖2-5](img/fig2-5.png)的左邊部分插入圖形資料庫的Cypher查詢。可以類似地新增圖的其餘部分,為了便於閱讀而省略。每個頂點都有一個像`USA`或`Idaho`這樣的符號名稱,查詢的其他部分可以使用這些名稱在頂點之間建立邊,使用箭頭符號:`(Idaho) - [:WITHIN] ->(USA)`建立一條標記為`WITHIN`的邊,`Idaho`為尾節點,`USA`為頭節點。 +[例2-3]()顯示了將[圖2-5](../img/fig2-5.png)的左邊部分插入圖形資料庫的Cypher查詢。可以類似地新增圖的其餘部分,為了便於閱讀而省略。每個頂點都有一個像`USA`或`Idaho`這樣的符號名稱,查詢的其他部分可以使用這些名稱在頂點之間建立邊,使用箭頭符號:`(Idaho) - [:WITHIN] ->(USA)`建立一條標記為`WITHIN`的邊,`Idaho`為尾節點,`USA`為頭節點。 **例2-3 將圖2-5中的資料子集表示為Cypher查詢** @@ -608,7 +608,7 @@ CREATE (Lucy) -[:BORN_IN]-> (Idaho) ``` -當[圖2-5](img/fig2-5.png)的所有頂點和邊被新增到資料庫後,讓我們提些有趣的問題:例如,找到所有從美國移民到歐洲的人的名字。更確切地說,這裡我們想要找到符合下麵條件的所有頂點,並且返回這些頂點的`name`屬性:該頂點擁有一條連到美國任一位置的`BORN_IN`邊,和一條連到歐洲的任一位置的`LIVING_IN`邊。 +當[圖2-5](../img/fig2-5.png)的所有頂點和邊被新增到資料庫後,讓我們提些有趣的問題:例如,找到所有從美國移民到歐洲的人的名字。更確切地說,這裡我們想要找到符合下麵條件的所有頂點,並且返回這些頂點的`name`屬性:該頂點擁有一條連到美國任一位置的`BORN_IN`邊,和一條連到歐洲的任一位置的`LIVING_IN`邊。 [例2-4]()展示瞭如何在Cypher中表達這個查詢。在MATCH子句中使用相同的箭頭符號來查詢圖中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`邊的任意兩個頂點。該邊的尾節點被綁定了變數`person`,頭節點則未被繫結。 @@ -896,9 +896,9 @@ Cypher和SPARQL使用SELECT立即跳轉,但是Datalog一次只進行一小步 2. 資料庫存在`within(usa, namerica)`,在上一步驟中生成`within_recursive(namerica, 'North America')`,故運用規則2。它會產生`within_recursive(usa, 'North America')`。 3. 資料庫存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故運用規則2。它產生`within_recursive(idaho, 'North America')`。 -透過重複應用規則1和2,`within_recursive`謂語可以告訴我們在資料庫中包含北美(或任何其他位置名稱)的所有位置。這個過程如[圖2-6](img/fig2-6.png)所示。 +透過重複應用規則1和2,`within_recursive`謂語可以告訴我們在資料庫中包含北美(或任何其他位置名稱)的所有位置。這個過程如[圖2-6](../img/fig2-6.png)所示。 -![](img/fig2-6.png) +![](../img/fig2-6.png) **圖2-6 使用示例2-11中的Datalog規則來確定愛達荷州在北美。** diff --git a/zh-tw/ch3.md b/zh-tw/ch3.md index 9bee1f6a..dba582fe 100644 --- a/zh-tw/ch3.md +++ b/zh-tw/ch3.md @@ -1,6 +1,6 @@ # 3. 儲存與檢索 -![](img/ch3.png) +![](../img/ch3.png) > 建立秩序,省卻搜尋 > @@ -83,9 +83,9 @@ $ cat database 鍵值儲存與在大多數程式語言中可以找到的**字典(dictionary)**型別非常相似,通常字典都是用**雜湊對映(hash map)**(或**雜湊表(hash table)**)實現的。雜湊對映在許多演算法教科書中都有描述【1,2】,所以這裡我們不會討論它的工作細節。既然我們已經有**記憶體中**資料結構 —— 雜湊對映,為什麼不使用它來索引在**磁碟上**的資料呢? -假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣。那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到一個數據檔案中的位元組偏移量,指明瞭可以找到對應值的位置,如[圖3-1](img/fig3-1.png)所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找(seek)** 該位置並讀取該值。 +假設我們的資料儲存只是一個追加寫入的檔案,就像前面的例子一樣。那麼最簡單的索引策略就是:保留一個記憶體中的雜湊對映,其中每個鍵都對映到一個數據檔案中的位元組偏移量,指明瞭可以找到對應值的位置,如[圖3-1](../img/fig3-1.png)所示。當你將新的鍵值對追加寫入檔案中時,還要更新雜湊對映,以反映剛剛寫入的資料的偏移量(這同時適用於插入新鍵與更新現有鍵)。當你想查詢一個值時,使用雜湊對映來查詢資料檔案中的偏移量,**尋找(seek)** 該位置並讀取該值。 -![](img/fig3-1.png) +![](../img/fig3-1.png) **圖3-1 以類CSV格式儲存鍵值對的日誌,並使用記憶體雜湊對映進行索引。** @@ -93,15 +93,15 @@ $ cat database 像Bitcask這樣的儲存引擎非常適合每個鍵的值經常更新的情況。例如,鍵可能是影片的URL,值可能是它播放的次數(每次有人點選播放按鈕時遞增)。在這種型別的工作負載中,有很多寫操作,但是沒有太多不同的鍵——每個鍵有很多的寫操作,但是將所有鍵儲存在記憶體中是可行的。 -直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完磁碟空間?一種好的解決方案是,將日誌分為特定大小的段,當日志增長到特定尺寸時關閉當前段檔案,並開始寫入一個新的段檔案。然後,我們就可以對這些段進行**壓縮(compaction)**,如[圖3-2](img/fig3-2.png)所示。壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。 +直到現在,我們只是追加寫入一個檔案 —— 所以如何避免最終用完磁碟空間?一種好的解決方案是,將日誌分為特定大小的段,當日志增長到特定尺寸時關閉當前段檔案,並開始寫入一個新的段檔案。然後,我們就可以對這些段進行**壓縮(compaction)**,如[圖3-2](../img/fig3-2.png)所示。壓縮意味著在日誌中丟棄重複的鍵,只保留每個鍵的最近更新。 -![](img/fig3-2.png) +![](../img/fig3-2.png) **圖3-2 壓縮鍵值更新日誌(統計貓影片的播放次數),只保留每個鍵的最近值** -而且,由於壓縮經常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾次),我們也可以在執行壓縮的同時將多個段合併在一起,如[圖3-3](img/fig3-3.png)所示。段被寫入後永遠不會被修改,所以合併的段被寫入一個新的檔案。凍結段的合併和壓縮可以在後臺執行緒中完成,在進行時,我們仍然可以繼續使用舊的段檔案來正常提供讀寫請求。合併過程完成後,我們將讀取請求轉換為使用新的合併段而不是舊段 —— 然後可以簡單地刪除舊的段檔案。 +而且,由於壓縮經常會使得段變得很小(假設在一個段內鍵被平均重寫了好幾次),我們也可以在執行壓縮的同時將多個段合併在一起,如[圖3-3](../img/fig3-3.png)所示。段被寫入後永遠不會被修改,所以合併的段被寫入一個新的檔案。凍結段的合併和壓縮可以在後臺執行緒中完成,在進行時,我們仍然可以繼續使用舊的段檔案來正常提供讀寫請求。合併過程完成後,我們將讀取請求轉換為使用新的合併段而不是舊段 —— 然後可以簡單地刪除舊的段檔案。 -![](img/fig3-3.png) +![](../img/fig3-3.png) **圖3-3 同時執行壓縮和分段合併** @@ -148,30 +148,30 @@ $ cat database ### SSTables和LSM樹 -在[圖3-3](img/fig3-3.png)中,每個日誌結構儲存段都是一系列鍵值對。這些對按照它們寫入的順序出現,日誌中稍後的值優先於日誌中較早的相同鍵的值。除此之外,檔案中鍵值對的順序並不重要。 +在[圖3-3](../img/fig3-3.png)中,每個日誌結構儲存段都是一系列鍵值對。這些對按照它們寫入的順序出現,日誌中稍後的值優先於日誌中較早的相同鍵的值。除此之外,檔案中鍵值對的順序並不重要。 現在我們可以對段檔案的格式做一個簡單的改變:我們要求鍵值對的序列按鍵排序。乍一看,這個要求似乎打破了我們使用順序寫入的能力,但是我們馬上就會明白這一點。 我們把這個格式稱為**排序字串表(Sorted String Table)**,簡稱SSTable。我們還要求每個鍵只在每個合併的段檔案中出現一次(壓縮過程已經保證)。與使用雜湊索引的日誌段相比,SSTable有幾個很大的優勢: -1. 合併段是簡單而高效的,即使檔案大於可用記憶體。這種方法就像歸併排序演算法中使用的方法一樣,如[圖3-4](img/fig3-4.png)所示:您開始並排讀取輸入檔案,檢視每個檔案中的第一個鍵,複製最低鍵(根據排序順序)到輸出檔案,並重復。這產生一個新的合併段檔案,也按鍵排序。 +1. 合併段是簡單而高效的,即使檔案大於可用記憶體。這種方法就像歸併排序演算法中使用的方法一樣,如[圖3-4](../img/fig3-4.png)所示:您開始並排讀取輸入檔案,檢視每個檔案中的第一個鍵,複製最低鍵(根據排序順序)到輸出檔案,並重復。這產生一個新的合併段檔案,也按鍵排序。 - ![](img/fig3-4.png) + ![](../img/fig3-4.png) ##### 圖3-4 合併幾個SSTable段,只保留每個鍵的最新值 如果在幾個輸入段中出現相同的鍵,該怎麼辦?請記住,每個段都包含在一段時間內寫入資料庫的所有值。這意味著一個輸入段中的所有值必須比另一個段中的所有值更新(假設我們總是合併相鄰的段)。當多個段包含相同的鍵時,我們可以保留最近段的值,並丟棄舊段中的值。 -2. 為了在檔案中找到一個特定的鍵,你不再需要儲存記憶體中所有鍵的索引。以[圖3-5](img/fig3-5.png)為例:假設你正在記憶體中尋找鍵 `handiwork`,但是你不知道段檔案中該關鍵字的確切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由於排序特性,你知道 `handiwork` 必須出現在這兩者之間。這意味著您可以跳到 `handbag` 的偏移位置並從那裡掃描,直到您找到 `handiwork`(或沒找到,如果該檔案中沒有該鍵)。 +2. 為了在檔案中找到一個特定的鍵,你不再需要儲存記憶體中所有鍵的索引。以[圖3-5](../img/fig3-5.png)為例:假設你正在記憶體中尋找鍵 `handiwork`,但是你不知道段檔案中該關鍵字的確切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由於排序特性,你知道 `handiwork` 必須出現在這兩者之間。這意味著您可以跳到 `handbag` 的偏移位置並從那裡掃描,直到您找到 `handiwork`(或沒找到,如果該檔案中沒有該鍵)。 - ![](img/fig3-5.png) + ![](../img/fig3-5.png) **圖3-5 具有記憶體索引的SSTable** 您仍然需要一個記憶體中索引來告訴您一些鍵的偏移量,但它可能很稀疏:每幾千位元組的段檔案就有一個鍵就足夠了,因為幾千位元組可以很快被掃描[^i]。 -3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對,因此可以將這些記錄分組到塊中,並在將其寫入磁碟之前對其進行壓縮(如[圖3-5](img/fig3-5.png)中的陰影區域所示) 。稀疏記憶體中索引的每個條目都指向壓縮塊的開始處。除了節省磁碟空間之外,壓縮還可以減少IO頻寬的使用。 +3. 由於讀取請求無論如何都需要掃描所請求範圍內的多個鍵值對,因此可以將這些記錄分組到塊中,並在將其寫入磁碟之前對其進行壓縮(如[圖3-5](../img/fig3-5.png)中的陰影區域所示) 。稀疏記憶體中索引的每個條目都指向壓縮塊的開始處。除了節省磁碟空間之外,壓縮還可以減少IO頻寬的使用。 [^i]: 如果所有的鍵與值都是定長的,你可以使用段檔案上的二分查詢並完全避免使用記憶體索引。然而實踐中鍵值通常都是變長的,因此如果沒有索引,就很難知道記錄的分界點(前一條記錄結束,後一條記錄開始的地方) @@ -217,25 +217,25 @@ Lucene是Elasticsearch和Solr使用的一種全文搜尋的索引引擎,它使 我們前面看到的日誌結構索引將資料庫分解為可變大小的段,通常是幾兆位元組或更大的大小,並且總是按順序編寫段。相比之下,B樹將資料庫分解成固定大小的塊或頁面,傳統上大小為4KB(有時會更大),並且一次只能讀取或寫入一個頁面。這種設計更接近於底層硬體,因為磁碟也被安排在固定大小的塊中。 -每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在磁碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](img/fig3-6.png)所示。 +每個頁面都可以使用地址或位置來標識,這允許一個頁面引用另一個頁面 —— 類似於指標,但在磁碟而不是在記憶體中。我們可以使用這些頁面引用來構建一個頁面樹,如[圖3-6](../img/fig3-6.png)所示。 -![](img/fig3-6.png) +![](../img/fig3-6.png) **圖3-6 使用B樹索引查詢一個鍵** 一個頁面會被指定為B樹的根;在索引中查詢一個鍵時,就從這裡開始。該頁面包含幾個鍵和對子頁面的引用。每個子頁面負責一段連續範圍的鍵,引用之間的鍵,指明瞭引用子頁面的鍵範圍。 -在[圖3-6](img/fig3-6.png)的例子中,我們正在尋找關鍵字 251 ,所以我們知道我們需要遵循邊界 200 和 300 之間的頁面引用。這將我們帶到一個類似的頁面,進一步打破了200 - 300到子範圍。 +在[圖3-6](../img/fig3-6.png)的例子中,我們正在尋找關鍵字 251 ,所以我們知道我們需要遵循邊界 200 和 300 之間的頁面引用。這將我們帶到一個類似的頁面,進一步打破了200 - 300到子範圍。 最後,我們可以看到包含單個鍵(葉頁)的頁面,該頁面包含每個鍵的內聯值,或者包含對可以找到值的頁面的引用。 -在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](img/fig3-6.png)中,分支因子是 6 。在實踐中,分支因子取決於儲存頁面參考和範圍邊界所需的空間量,但通常是幾百個。 +在B樹的一個頁面中對子頁面的引用的數量稱為分支因子。例如,在[圖3-6](../img/fig3-6.png)中,分支因子是 6 。在實踐中,分支因子取決於儲存頁面參考和範圍邊界所需的空間量,但通常是幾百個。 -如果要更新B樹中現有鍵的值,則搜尋包含該鍵的葉頁,更改該頁中的值,並將該頁寫回到磁碟(對該頁的任何引用保持有效) 。如果你想新增一個新的鍵,你需要找到其範圍包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以解釋鍵範圍的新分割槽,如[圖3-7](img/fig3-7.png)所示[^ii]。 +如果要更新B樹中現有鍵的值,則搜尋包含該鍵的葉頁,更改該頁中的值,並將該頁寫回到磁碟(對該頁的任何引用保持有效) 。如果你想新增一個新的鍵,你需要找到其範圍包含新鍵的頁面,並將其新增到該頁面。如果頁面中沒有足夠的可用空間容納新鍵,則將其分成兩個半滿頁面,並更新父頁面以解釋鍵範圍的新分割槽,如[圖3-7](../img/fig3-7.png)所示[^ii]。 [^ii]: 向B樹中插入一個新的鍵是相當符合直覺的,但刪除一個鍵(同時保持樹平衡)就會牽扯很多其他東西了。 -![](img/fig3-7.png) +![](../img/fig3-7.png) **圖3-7 透過分割頁面來生長B樹** @@ -299,7 +299,7 @@ B樹在資料庫體系結構中是非常根深蒂固的,為許多工作負載 到目前為止,我們只討論了關鍵值索引,它們就像關係模型中的**主鍵(primary key)** 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件或圖形資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或ID)引用該行/文件/頂點,並且索引用於解析這樣的引用。 -有二級索引也很常見。在關係資料庫中,您可以使用 `CREATE INDEX` 命令在同一個表上建立多個二級索引,而且這些索引通常對於有效地執行聯接而言至關重要。例如,在[第2章](ch2.md)中的[圖2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一個二級索引,以便您可以在每個表中找到屬於同一使用者的所有行。 +有二級索引也很常見。在關係資料庫中,您可以使用 `CREATE INDEX` 命令在同一個表上建立多個二級索引,而且這些索引通常對於有效地執行聯接而言至關重要。例如,在[第2章](ch2.md)中的[圖2-1](../img/fig2-1.png)中,很可能在 `user_id` 列上有一個二級索引,以便您可以在每個表中找到屬於同一使用者的所有行。 一個二級索引可以很容易地從一個鍵值索引構建。主要的不同是鍵不是唯一的。即可能有許多行(文件,頂點)具有相同的鍵。這可以透過兩種方式來解決:或者透過使索引中的每個值,成為匹配行識別符號的列表(如全文索引中的釋出列表),或者透過向每個索引新增行識別符號來使每個關鍵字唯一。無論哪種方式,B樹和日誌結構索引都可以用作輔助索引。 @@ -398,9 +398,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 這些OLTP系統往往對業務運作至關重要,因而通常會要求 **高可用** 與 **低延遲**。所以DBA會密切關注他們的OLTP資料庫,他們通常不願意讓業務分析人員在OLTP資料庫上執行臨時分析查詢,因為這些查詢通常開銷巨大,會掃描大部分資料集,這會損害同時執行的事務的效能。 -相比之下,資料倉庫是一個獨立的資料庫,分析人員可以查詢他們想要的內容而不影響OLTP操作【48】。資料倉庫包含公司各種OLTP系統中所有的只讀資料副本。從OLTP資料庫中提取資料(使用定期的資料轉儲或連續的更新流),轉換成適合分析的模式,清理並載入到資料倉庫中。將資料存入倉庫的過程稱為“**抽取-轉換-載入(ETL)**”,如[圖3-8](img/fig3-8)所示。 +相比之下,資料倉庫是一個獨立的資料庫,分析人員可以查詢他們想要的內容而不影響OLTP操作【48】。資料倉庫包含公司各種OLTP系統中所有的只讀資料副本。從OLTP資料庫中提取資料(使用定期的資料轉儲或連續的更新流),轉換成適合分析的模式,清理並載入到資料倉庫中。將資料存入倉庫的過程稱為“**抽取-轉換-載入(ETL)**”,如[圖3-8](../img/fig3-8)所示。 -![](img/fig3-8.png) +![](../img/fig3-8.png) **圖3-8 ETL至資料倉庫的簡化提綱** @@ -424,7 +424,7 @@ Teradata,Vertica,SAP HANA和ParAccel等資料倉庫供應商通常使用昂 圖3-9中的示例模式顯示了可能在食品零售商處找到的資料倉庫。在模式的中心是一個所謂的事實表(在這個例子中,它被稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買的產品)。如果我們分析的是網站流量而不是零售量,則每行可能代表一個使用者的頁面瀏覽量或點選量。 -![](img/fig3-9.png) +![](../img/fig3-9.png) **圖3-9 用於資料倉庫的星型模式的示例** @@ -432,7 +432,7 @@ Teradata,Vertica,SAP HANA和ParAccel等資料倉庫供應商通常使用昂 事實表中的一些列是屬性,例如產品銷售的價格和從供應商那裡購買的成本(允許計算利潤餘額)。事實表中的其他列是對其他表(稱為維表)的外來鍵引用。由於事實表中的每一行都表示一個事件,因此這些維度代表事件的發生地點,時間,方式和原因。 -例如,在[圖3-9](img/fig3-9.md)中,其中一個維度是已售出的產品。 `dim_product` 表中的每一行代表一種待售產品,包括**庫存單位(SKU)**,說明,品牌名稱,類別,脂肪含量,包裝尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中銷售了哪些產品。 (為了簡單起見,如果客戶一次購買幾種不同的產品,則它們在事實表中被表示為單獨的行)。 +例如,在[圖3-9](../img/fig3-9.md)中,其中一個維度是已售出的產品。 `dim_product` 表中的每一行代表一種待售產品,包括**庫存單位(SKU)**,說明,品牌名稱,類別,脂肪含量,包裝尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中銷售了哪些產品。 (為了簡單起見,如果客戶一次購買幾種不同的產品,則它們在事實表中被表示為單獨的行)。 即使日期和時間通常使用維度表來表示,因為這允許對日期(諸如公共假期)的附加資訊進行編碼,從而允許查詢區分假期和非假期的銷售。 @@ -469,13 +469,13 @@ GROUP BY 我們如何有效地執行這個查詢? -在大多數OLTP資料庫中,儲存都是以面向行的方式進行佈局的:表格的一行中的所有值都相鄰儲存。文件資料庫是相似的:整個文件通常儲存為一個連續的位元組序列。你可以在[圖3-1](img/fig3-1.png)的CSV例子中看到這個。 +在大多數OLTP資料庫中,儲存都是以面向行的方式進行佈局的:表格的一行中的所有值都相鄰儲存。文件資料庫是相似的:整個文件通常儲存為一個連續的位元組序列。你可以在[圖3-1](../img/fig3-1.png)的CSV例子中看到這個。 為了處理像[例3-1]()這樣的查詢,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它們告訴儲存引擎在哪裡查詢特定日期或特定產品的所有銷售情況。但是,面向行的儲存引擎仍然需要將所有這些行(每個包含超過100個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉那些不符合要求的條件。這可能需要很長時間。 -面向列的儲存背後的想法很簡單:不要將所有來自一行的值儲存在一起,而是將來自每一列的所有值儲存在一起。如果每個列儲存在一個單獨的檔案中,查詢只需要讀取和解析查詢中使用的那些列,這可以節省大量的工作。這個原理如[圖3-10](img/fig3-10.png)所示。 +面向列的儲存背後的想法很簡單:不要將所有來自一行的值儲存在一起,而是將來自每一列的所有值儲存在一起。如果每個列儲存在一個單獨的檔案中,查詢只需要讀取和解析查詢中使用的那些列,這可以節省大量的工作。這個原理如[圖3-10](../img/fig3-10.png)所示。 -![](img/fig3-10.png) +![](../img/fig3-10.png) **圖3-10 使用列儲存關係型資料,而不是行** @@ -489,9 +489,9 @@ GROUP BY 除了僅從磁碟載入查詢所需的列以外,我們還可以透過壓縮資料來進一步降低對磁碟吞吐量的需求。幸運的是,面向列的儲存通常很適合壓縮。 -看看[圖3-10](img/fig3-10.png)中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如[圖3-11](img/fig3-11.png)所示。 +看看[圖3-10](../img/fig3-10.png)中每一列的值序列:它們通常看起來是相當重複的,這是壓縮的好兆頭。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是點陣圖編碼,如[圖3-11](../img/fig3-11.png)所示。 -![](img/fig3-11.png) +![](../img/fig3-11.png) **圖3-11 壓縮點陣圖索引儲存佈局** @@ -536,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3 相反,即使按列儲存資料,也需要一次對整行進行排序。資料庫的管理員可以使用他們對常見查詢的知識來選擇表格應該被排序的列。例如,如果查詢通常以日期範圍為目標,例如上個月,則可以將 `date_key` 作為第一個排序鍵。然後,查詢最佳化器只能掃描上個月的行,這比掃描所有行要快得多。 -第二列可以確定第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是[圖3-10](img/fig3-10.png)中的第一個排序關鍵字,那麼 `product_sk` 可能是第二個排序關鍵字,因此同一天的同一產品的所有銷售都將在儲存中組合在一起。這將有助於需要在特定日期範圍內按產品對銷售進行分組或過濾的查詢。 +第二列可以確定第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是[圖3-10](../img/fig3-10.png)中的第一個排序關鍵字,那麼 `product_sk` 可能是第二個排序關鍵字,因此同一天的同一產品的所有銷售都將在儲存中組合在一起。這將有助於需要在特定日期範圍內按產品對銷售進行分組或過濾的查詢。 -排序順序的另一個好處是它可以幫助壓縮列。如果主要排序列沒有多個不同的值,那麼在排序之後,它將具有很長的序列,其中相同的值連續重複多次。一個簡單的執行長度編碼(就像我們用於[圖3-11](img/fig3-11.png)中的點陣圖一樣)可以將該列壓縮到幾千位元組 —— 即使表中有數十億行。 +排序順序的另一個好處是它可以幫助壓縮列。如果主要排序列沒有多個不同的值,那麼在排序之後,它將具有很長的序列,其中相同的值連續重複多次。一個簡單的執行長度編碼(就像我們用於[圖3-11](../img/fig3-11.png)中的點陣圖一樣)可以將該列壓縮到幾千位元組 —— 即使表中有數十億行。 第一個排序鍵的壓縮效果最強。第二和第三個排序鍵會更混亂,因此不會有這麼長時間的重複值。排序優先順序下面的列以基本上隨機的順序出現,所以它們可能不會被壓縮。但前幾列排序仍然是一個整體。 @@ -568,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3 當底層資料發生變化時,物化檢視需要更新,因為它是資料的非規範化副本。資料庫可以自動完成,但是這樣的更新使得寫入成本更高,這就是在OLTP資料庫中不經常使用物化檢視的原因。在讀取繁重的資料倉庫中,它們可能更有意義(不管它們是否實際上改善了讀取效能取決於個別情況)。 -物化檢視的常見特例稱為資料立方體或OLAP立方【64】。它是按不同維度分組的聚合網格。[圖3-12](img/fig3-12.png)顯示了一個例子。 +物化檢視的常見特例稱為資料立方體或OLAP立方【64】。它是按不同維度分組的聚合網格。[圖3-12](../img/fig3-12.png)顯示了一個例子。 -![](img/fig3-12.png) +![](../img/fig3-12.png) **圖3-12 資料立方的兩個維度,透過求和聚合** -想象一下,現在每個事實都只有兩個維度表的外來鍵——在[圖3-12](img/fig-3-12.png)中,這些是日期和產品。您現在可以繪製一個二維表格,一個軸線上的日期和另一個軸上的產品。每個單元包含具有該日期 - 產品組合的所有事實的屬性(例如,`net_price`)的聚集(例如,`SUM`)。然後,您可以沿著每行或每列應用相同的彙總,並獲得一個維度減少的彙總(按產品的銷售額,無論日期,還是按日期銷售,無論產品如何)。 +想象一下,現在每個事實都只有兩個維度表的外來鍵——在[圖3-12](../img/fig-3-12.png)中,這些是日期和產品。您現在可以繪製一個二維表格,一個軸線上的日期和另一個軸上的產品。每個單元包含具有該日期 - 產品組合的所有事實的屬性(例如,`net_price`)的聚集(例如,`SUM`)。然後,您可以沿著每行或每列應用相同的彙總,並獲得一個維度減少的彙總(按產品的銷售額,無論日期,還是按日期銷售,無論產品如何)。 一般來說,事實往往有兩個以上的維度。在圖3-9中有五個維度:日期,產品,商店,促銷和客戶。要想象一個五維超立方體是什麼樣子是很困難的,但是原理是一樣的:每個單元格都包含特定日期(產品-商店-促銷-客戶)組合的銷售。這些值可以在每個維度上重複概括。 diff --git a/zh-tw/ch4.md b/zh-tw/ch4.md index ce6e3fdf..5962bf56 100644 --- a/zh-tw/ch4.md +++ b/zh-tw/ch4.md @@ -1,6 +1,6 @@ # 4. 編碼與演化 -![](img/ch4.png) +![](../img/ch4.png) > 唯變所適 > @@ -113,7 +113,7 @@ JSON比XML簡潔,但與二進位制格式一比,還是太佔地方。這一 在下面的章節中,能達到比這好得多的結果,只用32個位元組對相同的記錄進行編碼。 -![](img/fig4-1.png) +![](../img/fig4-1.png) **圖4-1 使用MessagePack編碼的記錄(例4-1)** @@ -141,9 +141,9 @@ message Person { ``` Thrift和Protocol Buffers每一個都帶有一個程式碼生成工具,它採用了類似於這裡所示的模式定義,並且生成了以各種程式語言實現模式的類【18】。您的應用程式程式碼可以呼叫此生成的程式碼來對模式的記錄進行編碼或解碼。 -用這個模式編碼的資料是什麼樣的?令人困惑的是,Thrift有兩種不同的二進位制編碼格式[^iii],分別稱為BinaryProtocol和CompactProtocol。先來看看BinaryProtocol。使用這種格式的編碼來編碼[例4-1]()中的訊息只需要59個位元組,如[圖4-2](img/fig4-2.png)所示【19】。 +用這個模式編碼的資料是什麼樣的?令人困惑的是,Thrift有兩種不同的二進位制編碼格式[^iii],分別稱為BinaryProtocol和CompactProtocol。先來看看BinaryProtocol。使用這種格式的編碼來編碼[例4-1]()中的訊息只需要59個位元組,如[圖4-2](../img/fig4-2.png)所示【19】。 -![](img/fig4-2.png) +![](../img/fig4-2.png) **圖4-2 使用Thrift二進位制協議編碼的記錄** @@ -151,17 +151,17 @@ Thrift和Protocol Buffers每一個都帶有一個程式碼生成工具,它採 與[圖4-1](Img/fig4-1.png)類似,每個欄位都有一個型別註釋(用於指示它是一個字串,整數,列表等),還可以根據需要指定長度(字串的長度,列表中的專案數) 。出現在資料中的字串`(“Martin”, “daydreaming”, “hacking”)`也被編碼為ASCII(或者說,UTF-8),與之前類似。 -與[圖4-1](img/fig4-1.png)相比,最大的區別是沒有欄位名`(userName, favoriteNumber, interest)`。相反,編碼資料包含欄位標籤,它們是數字`(1, 2和3)`。這些是模式定義中出現的數字。欄位標記就像欄位的別名 - 它們是說我們正在談論的欄位的一種緊湊的方式,而不必拼出欄位名稱。 +與[圖4-1](../img/fig4-1.png)相比,最大的區別是沒有欄位名`(userName, favoriteNumber, interest)`。相反,編碼資料包含欄位標籤,它們是數字`(1, 2和3)`。這些是模式定義中出現的數字。欄位標記就像欄位的別名 - 它們是說我們正在談論的欄位的一種緊湊的方式,而不必拼出欄位名稱。 -Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4-3](img/fig4-3.png)所示,它只將相同的資訊打包成只有34個位元組。它透過將欄位型別和標籤號打包到單個位元組中,並使用可變長度整數來實現。數字1337不是使用全部八個位元組,而是用兩個位元組編碼,每個位元組的最高位用來指示是否還有更多的位元組來。這意味著-64到63之間的數字被編碼為一個位元組,-8192和8191之間的數字以兩個位元組編碼,等等。較大的數字使用更多的位元組。 +Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4-3](../img/fig4-3.png)所示,它只將相同的資訊打包成只有34個位元組。它透過將欄位型別和標籤號打包到單個位元組中,並使用可變長度整數來實現。數字1337不是使用全部八個位元組,而是用兩個位元組編碼,每個位元組的最高位用來指示是否還有更多的位元組來。這意味著-64到63之間的數字被編碼為一個位元組,-8192和8191之間的數字以兩個位元組編碼,等等。較大的數字使用更多的位元組。 -![](img/fig4-3.png) +![](../img/fig4-3.png) **圖4-3 使用Thrift壓縮協議編碼的記錄** -最後,Protocol Buffers(只有一種二進位制編碼格式)對相同的資料進行編碼,如[圖4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但與Thrift的CompactProtocol非常相似。 Protobuf將同樣的記錄塞進了33個位元組中。 +最後,Protocol Buffers(只有一種二進位制編碼格式)對相同的資料進行編碼,如[圖4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但與Thrift的CompactProtocol非常相似。 Protobuf將同樣的記錄塞進了33個位元組中。 -![](img/fig4-4.png) +![](../img/fig4-4.png) **圖4-4 使用Protobuf編碼的記錄** @@ -183,7 +183,7 @@ Thrift CompactProtocol編碼在語義上等同於BinaryProtocol,但是如[圖4 如何改變欄位的資料型別?這可能是可能的——檢查檔案的細節——但是有一個風險,值將失去精度或被扼殺。例如,假設你將一個32位的整數變成一個64位的整數。新程式碼可以輕鬆讀取舊程式碼寫入的資料,因為解析器可以用零填充任何缺失的位。但是,如果舊程式碼讀取由新程式碼寫入的資料,則舊程式碼仍使用32位變數來儲存該值。如果解碼的64位值不適合32位,則它將被截斷。 -Protobuf的一個奇怪的細節是,它沒有列表或陣列資料型別,而是有一個欄位的重複標記(這是第三個選項旁邊必要和可選)。如[圖4-4](img/fig4-4.png)所示,重複欄位的編碼正如它所說的那樣:同一個欄位標記只是簡單地出現在記錄中。這具有很好的效果,可以將可選(單值)欄位更改為重複(多值)欄位。讀取舊資料的新程式碼會看到一個包含零個或一個元素的列表(取決於該欄位是否存在)。讀取新資料的舊程式碼只能看到列表的最後一個元素。 +Protobuf的一個奇怪的細節是,它沒有列表或陣列資料型別,而是有一個欄位的重複標記(這是第三個選項旁邊必要和可選)。如[圖4-4](../img/fig4-4.png)所示,重複欄位的編碼正如它所說的那樣:同一個欄位標記只是簡單地出現在記錄中。這具有很好的效果,可以將可選(單值)欄位更改為重複(多值)欄位。讀取舊資料的新程式碼會看到一個包含零個或一個元素的列表(取決於該欄位是否存在)。讀取新資料的舊程式碼只能看到列表的最後一個元素。 Thrift有一個專用的列表資料型別,它使用列表元素的資料型別進行引數化。這不允許Protocol Buffers所做的從單值到多值的相同演變,但是它具有支援巢狀列表的優點。 @@ -217,11 +217,11 @@ record Person { } ``` -首先,請注意架構中沒有標籤號碼。 如果我們使用這個模式編碼我們的例子記錄([例4-1]()),Avro二進位制編碼只有32個位元組長,這是我們所見過的所有編碼中最緊湊的。 編碼位元組序列的分解如[圖4-5](img/fig4-5.png)所示。 +首先,請注意架構中沒有標籤號碼。 如果我們使用這個模式編碼我們的例子記錄([例4-1]()),Avro二進位制編碼只有32個位元組長,這是我們所見過的所有編碼中最緊湊的。 編碼位元組序列的分解如[圖4-5](../img/fig4-5.png)所示。 如果您檢查位元組序列,您可以看到沒有什麼可以識別字段或其資料型別。 編碼只是由連在一起的值組成。 一個字串只是一個長度字首,後跟UTF-8位元組,但是在被包含的資料中沒有任何內容告訴你它是一個字串。 它可以是一個整數,也可以是其他的整數。 整數使用可變長度編碼(與Thrift的CompactProtocol相同)進行編碼。 -![](img/fig4-5.png) +![](../img/fig4-5.png) **圖4-5 使用Avro編碼的記錄** @@ -235,11 +235,11 @@ record Person { 當一個應用程式想要解碼一些資料(從一個檔案或資料庫讀取資料,從網路接收資料等)時,它希望資料在某個模式中,這就是讀者的模式。這是應用程式程式碼所依賴的模式,在應用程式的構建過程中,程式碼可能是從該模式生成的。 -Avro的關鍵思想是作者的模式和讀者的模式不必是相同的 - 他們只需要相容。當資料解碼(讀取)時,Avro庫透過並排檢視作者的模式和讀者的模式並將資料從作者的模式轉換到讀者的模式來解決差異。 Avro規範【20】確切地定義了這種解析的工作原理,如[圖4-6](img/fig4-6.png)所示。 +Avro的關鍵思想是作者的模式和讀者的模式不必是相同的 - 他們只需要相容。當資料解碼(讀取)時,Avro庫透過並排檢視作者的模式和讀者的模式並將資料從作者的模式轉換到讀者的模式來解決差異。 Avro規範【20】確切地定義了這種解析的工作原理,如[圖4-6](../img/fig4-6.png)所示。 例如,如果作者的模式和讀者的模式的欄位順序不同,這是沒有問題的,因為模式解析透過欄位名匹配欄位。如果讀取資料的程式碼遇到出現在作者模式中但不在讀者模式中的欄位,則忽略它。如果讀取資料的程式碼需要某個欄位,但是作者的模式不包含該名稱的欄位,則使用在讀者模式中宣告的預設值填充。 -![](img/fig4-6.png) +![](../img/fig4-6.png) **圖4-6 一個Avro Reader解決讀寫模式的差異** @@ -344,7 +344,7 @@ Avro為靜態型別程式語言提供了可選的程式碼生成功能,但是 解決這個問題不是一個難題,你只需要意識到它。 -![](img/fig4-7.png) +![](../img/fig4-7.png) **圖4-7 當較舊版本的應用程式更新以前由較新版本的應用程式編寫的資料時,如果不小心,資料可能會丟失。** @@ -474,7 +474,7 @@ RPC方案的前後向相容性屬性從它使用的編碼方式中繼承 訊息代理通常不會執行任何特定的資料模型 - 訊息只是包含一些元資料的位元組序列,因此您可以使用任何編碼格式。如果編碼是向後相容的,則您可以靈活地更改發行商和消費者的獨立編碼,並以任意順序進行部署。 -如果消費者重新發布訊息到另一個主題,則可能需要小心保留未知欄位,以防止前面在資料庫環境中描述的問題([圖4-7](img/fig4-7.png))。 +如果消費者重新發布訊息到另一個主題,則可能需要小心保留未知欄位,以防止前面在資料庫環境中描述的問題([圖4-7](../img/fig4-7.png))。 #### 分散式的Actor框架 diff --git a/zh-tw/ch5.md b/zh-tw/ch5.md index d0d1d529..deddadbd 100644 --- a/zh-tw/ch5.md +++ b/zh-tw/ch5.md @@ -1,6 +1,6 @@ # 5. 複製 -![](img/ch5.png) +![](../img/ch5.png) > 與可能出錯的東西比,'不可能'出錯的東西最顯著的特點就是:一旦真的出錯,通常就徹底玩完了。 > @@ -36,7 +36,7 @@ [^i]: 不同的人對**熱(hot)**,**溫(warm)**,**冷(cold)** 備份伺服器有不同的定義。 例如在PostgreSQL中,**熱備(hot standby)**指的是能接受客戶端讀請求的副本。而**溫備(warm standby)**只是追隨領導者,但不處理客戶端的任何查詢。 就本書而言,這些差異並不重要。 -![](img/fig5-1.png) +![](../img/fig5-1.png) **圖5-1 基於領導者(主-從)的複製** ​ 這種複製模式是許多關係資料庫的內建功能,如PostgreSQL(從9.0版本開始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性組【3】。 它也被用於一些非關係資料庫,包括MongoDB,RethinkDB和Espresso 【4】。 最後,基於領導者的複製並不僅限於資料庫:像Kafka 【5】和RabbitMQ高可用佇列【6】這樣的分散式訊息代理也使用它。 某些網路檔案系統,例如DRBD這樣的塊複製裝置也與之類似。 @@ -47,9 +47,9 @@ ​ 想象[圖5-1](fig5-1.png)中發生的情況,網站的使用者更新他們的個人頭像。在某個時間點,客戶向主庫傳送更新請求;不久之後主庫就收到了請求。在某個時刻,主庫又會將資料變更轉發給自己的從庫。最後,主庫通知客戶更新成功。 -[圖5-2](img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。 +[圖5-2](../img/fig5-2.png)顯示了系統各個元件之間的通訊:使用者客戶端,主庫和兩個從庫。時間從左到右流動。請求或響應訊息用粗箭頭表示。 -![](img/fig5-2.png) +![](../img/fig5-2.png) **圖5-2 基於領導者的複製:一個同步從庫和一個非同步從庫** ​ 在[圖5-2]()的示例中,從庫1的複製是同步的:在向用戶報告寫入成功,並使結果對其他使用者可見之前,主庫需要等待從庫1的確認,確保從庫1已經收到寫入操作。以及在使寫入對其他客戶端可見之前接收到寫入。跟隨者2的複製是非同步的:主庫傳送訊息,但不等待從庫的響應。 @@ -205,7 +205,7 @@ ​ 但對於非同步複製,問題就來了。如[圖5-3](fig5-3.png)所示:如果使用者在寫入後馬上就檢視資料,則新資料可能尚未到達副本。對使用者而言,看起來好像是剛提交的資料丟失了,使用者會不高興,可以理解。 -![](img/fig5-3.png) +![](../img/fig5-3.png) **圖5-3 使用者寫入後從舊副本中讀取資料。需要寫後讀(read-after-write)的一致性來防止這種異常** @@ -236,9 +236,9 @@ ​ 從非同步從庫讀取第二個異常例子是,使用者可能會遇到 **時光倒流(moving backward in time)**。 -​ 如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢,首先查詢了一個延遲很小的從庫,然後是一個延遲較大的從庫。 (如果使用者重新整理網頁,而每個請求被路由到一個隨機的伺服器,這種情況是很有可能的。)第一個查詢返回最近由使用者1234新增的評論,但是第二個查詢不返回任何東西,因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢,第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容,那問題並不大,因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論,然後又看到它消失,那麼對於使用者2345,就很讓人頭大了。 +​ 如果使用者從不同從庫進行多次讀取,就可能發生這種情況。例如,[圖5-4](../img/fig5-4.png)顯示了使用者2345兩次進行相同的查詢,首先查詢了一個延遲很小的從庫,然後是一個延遲較大的從庫。 (如果使用者重新整理網頁,而每個請求被路由到一個隨機的伺服器,這種情況是很有可能的。)第一個查詢返回最近由使用者1234新增的評論,但是第二個查詢不返回任何東西,因為滯後的從庫還沒有拉取寫入內容。在效果上相比第一個查詢,第二個查詢是在更早的時間點來觀察系統。如果第一個查詢沒有返回任何內容,那問題並不大,因為使用者2345可能不知道使用者1234最近添加了評論。但如果使用者2345先看見使用者1234的評論,然後又看到它消失,那麼對於使用者2345,就很讓人頭大了。 -![](img/fig5-4.png) +![](../img/fig5-4.png) **圖5-4 使用者首先從新副本讀取,然後從舊副本讀取。時光倒流。為了防止這種異常,我們需要單調的讀取。** @@ -260,7 +260,7 @@ 這兩句話之間有因果關係:Cake夫人聽到了Poons先生的問題並回答了這個問題。 -​ 現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的,但Poons先生所說的內容,從庫的延遲要大的多(見[圖5-5](img/fig5-5.png))。 於是,這個觀察者會聽到以下內容: +​ 現在,想象第三個人正在透過從庫來聽這個對話。 Cake夫人說的內容是從一個延遲很低的從庫讀取的,但Poons先生所說的內容,從庫的延遲要大的多(見[圖5-5](../img/fig5-5.png))。 於是,這個觀察者會聽到以下內容: > *Mrs. Cake* > ​ 通常約十秒鐘,Mr. Poons. @@ -271,7 +271,7 @@ 對於觀察者來說,看起來好像Cake夫人在Poons先生髮問前就回答了這個問題。 這種超能力讓人印象深刻,但也會把人搞糊塗。【25】。 -![](img/fig5-5.png) +![](../img/fig5-5.png) **圖5-5 如果某些分割槽的複製速度慢於其他分割槽,那麼觀察者在看到問題之前可能會看到答案。** @@ -311,9 +311,9 @@ ​ 假如你有一個數據庫,副本分散在好幾個不同的資料中心(也許這樣可以容忍單個數據中心的故障,或地理上更接近使用者)。 使用常規的基於領導者的複製設定,主庫必須位於其中一個數據中心,且所有寫入都必須經過該資料中心。 -​ 多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。 +​ 多領導者配置中可以在每個資料中心都有主庫。 [圖5-6](../img/fig5-6.png)展示了這個架構的樣子。 在每個資料中心內使用常規的主從複製;在資料中心之間,每個資料中心的主庫都會將其更改複製到其他資料中心的主庫中。 -![](img/fig5-6.png) +![](../img/fig5-6.png) **圖5-6 跨多個數據中心的多主複製** @@ -333,7 +333,7 @@ ​ 有些資料庫預設情況下支援多主配置,但使用外部工具實現也很常見,例如用於MySQL的Tungsten Replicator 【26】,用於PostgreSQL的BDR【27】以及用於Oracle的GoldenGate 【19】。 -​ 儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](img/fig5-6.png)中“[衝突解決](#衝突解決)”)。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。 +​ 儘管多主複製有這些優勢,但也有一個很大的缺點:兩個不同的資料中心可能會同時修改相同的資料,寫衝突是必須解決的(如[圖5-6](../img/fig5-6.png)中“[衝突解決](#衝突解決)”)。本書將在“[處理寫入衝突](#處理寫入衝突)”中詳細討論這個問題。 ​ 由於多主複製在許多資料庫中都屬於改裝的功能,所以常常存在微妙的配置缺陷,且經常與其他資料庫功能之間出現意外的反應。例如自增主鍵、觸發器、完整性約束等,都可能會有麻煩。因此,多主複製往往被認為是危險的領域,應儘可能避免【28】。 @@ -361,9 +361,9 @@ ​ 多領導者複製的最大問題是可能發生寫衝突,這意味著需要解決衝突。 -​ 例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B,並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時,會發現衝突【33】。單主資料庫中不會出現此問題。 +​ 例如,考慮一個由兩個使用者同時編輯的維基頁面,如[圖5-7](../img/fig5-7.png)所示。使用者1將頁面的標題從A更改為B,並且使用者2同時將標題從A更改為C。每個使用者的更改已成功應用到其本地主庫。但當非同步複製時,會發現衝突【33】。單主資料庫中不會出現此問題。 -![](img/fig5-7.png) +![](../img/fig5-7.png) **圖5-7 兩個主庫同時更新同一記錄引起的寫入衝突** @@ -385,7 +385,7 @@ ​ 單主資料庫按順序進行寫操作:如果同一個欄位有多個更新,則最後一個寫操作將決定該欄位的最終值。 -​ 在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](img/fig5-7.png)中,在主庫1中標題首先更新為B而後更新為C;在主庫2中,首先更新為C,然後更新為B。兩個順序都不是“更正確”的。 +​ 在多主配置中,沒有明確的寫入順序,所以最終值應該是什麼並不清楚。在[圖5-7](../img/fig5-7.png)中,在主庫1中標題首先更新為B而後更新為C;在主庫2中,首先更新為C,然後更新為B。兩個順序都不是“更正確”的。 ​ 如果每個副本只是按照它看到寫入的順序寫入,那麼資料庫最終將處於不一致的狀態:最終值將是在主庫1的C和主庫2的B。這是不可接受的,每個複製方案都必須確保資料在所有副本中最終都是相同的。因此,資料庫必須以一種**收斂(convergent)**的方式解決衝突,這意味著所有副本必須在所有變更復制完成時收斂至一個相同的最終值。 @@ -393,7 +393,7 @@ * 給每個寫入一個唯一的ID(例如,一個時間戳,一個長的隨機數,一個UUID或者一個鍵和值的雜湊),挑選最高ID的寫入作為勝利者,並丟棄其他寫入。如果使用時間戳,這種技術被稱為**最後寫入勝利(LWW, last write wins)**。雖然這種方法很流行,但是很容易造成資料丟失【35】。我們將在[本章末尾](#檢測併發寫入)更詳細地討論LWW。 * 為每個副本分配一個唯一的ID,ID編號更高的寫入具有更高的優先順序。這種方法也意味著資料丟失。 -* 以某種方式將這些值合併在一起 - 例如,按字母順序排序,然後連線它們(在[圖5-7](img/fig5-7.png)中,合併的標題可能類似於“B/C”)。 +* 以某種方式將這些值合併在一起 - 例如,按字母順序排序,然後連線它們(在[圖5-7](../img/fig5-7.png)中,合併的標題可能類似於“B/C”)。 * 用一種可保留所有資訊的顯式資料結構來記錄衝突,並編寫解決衝突的應用程式程式碼(也許透過提示使用者的方式)。 @@ -431,7 +431,7 @@ #### 什麼是衝突? -​ 有些衝突是顯而易見的。在[圖5-7](img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。 +​ 有些衝突是顯而易見的。在[圖5-7](../img/fig5-7.png)的例子中,兩個寫操作併發地修改了同一條記錄中的同一個欄位,並將其設定為兩個不同的值。毫無疑問這是一個衝突。 ​ 其他型別的衝突可能更為微妙,難以發現。例如,考慮一個會議室預訂系統:它記錄誰訂了哪個時間段的哪個房間。應用需要確保每個房間只有一組人同時預定(即不得有相同房間的重疊預訂)。在這種情況下,如果同時為同一個房間建立兩個不同的預訂,則可能會發生衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩次預訂是由兩個不同的領導者進行的,則可能會有衝突。 @@ -443,7 +443,7 @@ ​ **複製拓撲**(replication topology)描述寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如[圖5-7]()所示,只有一個合理的拓撲結構:領導者1必須把他所有的寫到領導者2,反之亦然。當有兩個以上的領導,各種不同的拓撲是可能的。[圖5-8]()舉例說明了一些例子。 -![](img/fig5-8.png) +![](../img/fig5-8.png) **圖5-8 三個可以設定多領導者複製的示例拓撲。** @@ -455,13 +455,13 @@ ​ 迴圈和星型拓撲的問題是,如果只有一個節點發生故障,則可能會中斷其他節點之間的複製訊息流,導致它們無法通訊,直到節點修復。拓撲結構可以重新配置為在發生故障的節點上工作,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲結構(例如全部到全部)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。 -​ 另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](img/fig5-9.png)所示。 +​ 另一方面,全部到全部的拓撲也可能有問題。特別是,一些網路連結可能比其他網路連結更快(例如,由於網路擁塞),結果是一些複製訊息可能“超過”其他複製訊息,如[圖5-9](../img/fig5-9.png)所示。 -![](img/fig5-9.png) +![](../img/fig5-9.png) **圖5-9 使用多主程式複製時,可能會在某些副本中寫入錯誤的順序。** -​ 在[圖5-9](img/fig5-9.png)中,客戶端A向主庫1的表中插入一行,客戶端B在主庫3上更新該行。然而,主庫2可以以不同的順序接收寫入:它可以首先接收更新(其中,從它的角度來看,是對資料庫中不存在的行的更新),並且僅在稍後接收到相應的插入(其應該在更新之前)。 +​ 在[圖5-9](../img/fig5-9.png)中,客戶端A向主庫1的表中插入一行,客戶端B在主庫3上更新該行。然而,主庫2可以以不同的順序接收寫入:它可以首先接收更新(其中,從它的角度來看,是對資料庫中不存在的行的更新),並且僅在稍後接收到相應的插入(其應該在更新之前)。 ​ 這是一個因果關係的問題,類似於我們在“[一致字首讀](ch8.md#一致字首讀)”中看到的:更新取決於先前的插入,所以我們需要確保所有節點先處理插入,然後再處理更新。僅僅在每一次寫入時新增一個時間戳是不夠的,因為時鐘不可能被充分地同步,以便在主庫2處正確地排序這些事件(見[第8章](ch8.md))。 @@ -485,9 +485,9 @@ ​ 假設你有一個帶有三個副本的資料庫,而其中一個副本目前不可用,或許正在重新啟動以安裝系統更新。在基於主機的配置中,如果要繼續處理寫入,則可能需要執行故障切換(參閱「[處理節點宕機](#處理節點宕機)」)。 -​ 另一方面,在無領導配置中,故障切換不存在。[圖5-10](img/fig5-10.png)顯示了發生了什麼事情:客戶端(使用者1234)並行傳送寫入到所有三個副本,並且兩個可用副本接受寫入,但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的:在使用者1234已經收到兩個確定的響應之後,我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。 +​ 另一方面,在無領導配置中,故障切換不存在。[圖5-10](../img/fig5-10.png)顯示了發生了什麼事情:客戶端(使用者1234)並行傳送寫入到所有三個副本,並且兩個可用副本接受寫入,但是不可用副本錯過了它。假設三個副本中的兩個承認寫入是足夠的:在使用者1234已經收到兩個確定的響應之後,我們認為寫入成功。客戶簡單地忽略了其中一個副本錯過了寫入的事實。 -![](img/fig5-10.png) +![](../img/fig5-10.png) **圖5-10 法定寫入,法定讀取,並在節點中斷後讀修復。** @@ -503,7 +503,7 @@ ***讀修復(Read repair)*** -​ 當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](img/fig5-10.png)中,使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值,並將新值寫回到該副本。這種方法適用於讀頻繁的值。 +​ 當客戶端並行讀取多個節點時,它可以檢測到任何陳舊的響應。例如,在[圖5-10](../img/fig5-10.png)中,使用者2345獲得了來自副本3的版本6值和來自副本1和2的版本7值。客戶端發現副本3具有陳舊值,並將新值寫回到該副本。這種方法適用於讀頻繁的值。 ***反熵過程(Anti-entropy process)*** @@ -513,7 +513,7 @@ #### 讀寫的法定人數 -​ 在[圖5-10](img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?我們能推多遠呢? +​ 在[圖5-10](../img/fig5-10.png)的示例中,我們認為即使僅在三個副本中的兩個上進行處理,寫入仍然是成功的。如果三個副本中只有一個接受了寫入,會怎樣?我們能推多遠呢? ​ 如果我們知道,每個成功的寫操作意味著在三個副本中至少有兩個出現,這意味著至多有一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確定至少有一個是最新的。如果第三個副本停機或響應速度緩慢,則讀取仍可以繼續返回最新值。 @@ -531,10 +531,10 @@ * 如果$w n$,讀取r個副本,至少有一個r副本必然包含了最近的成功寫入** @@ -544,7 +544,7 @@ ### 法定人數一致性的侷限性 -​ 如果你有n個副本,並且你選擇w和r,使得$w + r> n$,你通常可以期望每個讀取返回為一個鍵寫的最近的值。情況就是這樣,因為你寫的節點集合和你讀過的節點集合必須重疊。也就是說,您讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](img/fig5-11.png)所示)。 +​ 如果你有n個副本,並且你選擇w和r,使得$w + r> n$,你通常可以期望每個讀取返回為一個鍵寫的最近的值。情況就是這樣,因為你寫的節點集合和你讀過的節點集合必須重疊。也就是說,您讀取的節點中必須至少有一個具有最新值的節點(如[圖5-11](../img/fig5-11.png)所示)。 ​ 通常,r和w被選為多數(超過 $n/2$ )節點,因為這確保了$w + r> n$,同時仍然容忍多達$n/2$個節點故障。但是,法定人數不一定必須是大多數,只是讀寫使用的節點交集至少需要包括一個節點。其他法定人數的配置是可能的,這使得分散式演算法的設計有一定的靈活性【45】。 @@ -608,17 +608,17 @@ ​ Dynamo風格的資料庫允許多個客戶端同時寫入相同的Key,這意味著即使使用嚴格的法定人數也會發生衝突。這種情況與多領導者複製相似(參閱“[處理寫入衝突](#處理寫入衝突)”),但在Dynamo樣式的資料庫中,在**讀修復**或**提示移交**期間也可能會產生衝突。 -​ 問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X: +​ 問題在於,由於可變的網路延遲和部分故障,事件可能在不同的節點以不同的順序到達。例如,[圖5-12](../img/fig5-12.png)顯示了兩個客戶機A和B同時寫入三節點資料儲存區中的鍵X: * 節點 1 接收來自 A 的寫入,但由於暫時中斷,從不接收來自 B 的寫入。 * 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。 * 節點 3 首先接收來自 B 的寫入,然後從 A 寫入。 -![](img/fig5-12.png) +![](../img/fig5-12.png) **圖5-12 併發寫入Dynamo風格的資料儲存:沒有明確定義的順序。** -​ 如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](img/fig5-12.png)中的最終獲取請求所示:節點2認為 X 的最終值是 B,而其他節點認為值是 A 。 +​ 如果每個節點只要接收到來自客戶端的寫入請求就簡單地覆蓋了某個鍵的值,那麼節點就會永久地不一致,如[圖5-12](../img/fig5-12.png)中的最終獲取請求所示:節點2認為 X 的最終值是 B,而其他節點認為值是 A 。 ​ 為了最終達成一致,副本應該趨於相同的值。如何做到這一點?有人可能希望複製的資料庫能夠自動處理,但不幸的是,大多數的實現都很糟糕:如果你想避免丟失資料,你(應用程式開發人員)需要知道很多有關資料庫衝突處理的內部資訊。 @@ -628,7 +628,7 @@ ​ 實現最終融合的一種方法是宣告每個副本只需要儲存最**“最近”**的值,並允許**“更舊”**的值被覆蓋和拋棄。然後,只要我們有一種明確的方式來確定哪個寫是“最近的”,並且每個寫入最終都被複制到每個副本,那麼複製最終會收斂到相同的值。 -​ 正如**“最近”**的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是**併發(concurrent)**的,所以它們的順序是不確定的。 +​ 正如**“最近”**的引號所表明的,這個想法其實頗具誤導性。在[圖5-12](../img/fig5-12.png)的例子中,當客戶端向資料庫節點發送寫入請求時,客戶端都不知道另一個客戶端,因此不清楚哪一個先發生了。事實上,說“發生”是沒有意義的:我們說寫入是**併發(concurrent)**的,所以它們的順序是不確定的。 ​ 即使寫入沒有自然的排序,我們也可以強制任意排序。例如,可以為每個寫入附加一個時間戳,挑選最**“最近”**的最大時間戳,並丟棄具有較早時間戳的任何寫入。這種衝突解決演算法被稱為**最後寫入勝利(LWW, last write wins)**,是Cassandra 【53】唯一支援的衝突解決方法,也是Riak 【35】中的一個可選特徵。 @@ -673,13 +673,13 @@ 4. 同時,客戶端 2 想要加入火腿,不知道客端戶 1 剛剛加了麵粉。客戶端 2 在最後一個響應中從伺服器收到了兩個值[牛奶]和[蛋],所以客戶端 2 現在合併這些值,並新增火腿形成一個新的值,[雞蛋,牛奶,火腿]。它將這個值傳送到伺服器,帶著之前的版本號 2 。伺服器檢測到新值會覆蓋版本 2 [雞蛋],但新值也會與版本 3 [牛奶,麵粉]**併發**,所以剩下的兩個是v3 [牛奶,麵粉],和v4:[雞蛋,牛奶,火腿] 5. 最後,客戶端 1 想要加培根。它以前在v3中從伺服器接收[牛奶,麵粉]和[雞蛋],所以它合併這些,新增培根,並將最終值[牛奶,麵粉,雞蛋,培根]連同版本號v3發往伺服器。這會覆蓋v3[牛奶,麵粉](請注意[雞蛋]已經在最後一步被覆蓋),但與v4[雞蛋,牛奶,火腿]併發,所以伺服器保留這兩個併發值。 -![](img/fig5-13.png) +![](../img/fig5-13.png) **圖5-13 捕獲兩個客戶端之間的因果關係,同時編輯購物車。** -​ [圖5-13](img/fig5-13.png)中的操作之間的資料流如[圖5-14](img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。 +​ [圖5-13](../img/fig5-13.png)中的操作之間的資料流如[圖5-14](../img/fig5-14.png)所示。 箭頭表示哪個操作發生在其他操作之前,意味著後面的操作知道或依賴於較早的操作。 在這個例子中,客戶端永遠不會完全掌握伺服器上的資料,因為總是有另一個操作同時進行。 但是,舊版本的值最終會被覆蓋,並且不會丟失任何寫入。 -![](img/fig5-14.png) +![](../img/fig5-14.png) **圖5-14 圖5-13中的因果依賴關係圖。** @@ -698,7 +698,7 @@ ​ 合併兄弟值,本質上是與多領導者複製中的衝突解決相同的問題,我們先前討論過(參閱“[處理寫入衝突](#處理寫入衝突)”)。一個簡單的方法是根據版本號或時間戳(最後寫入勝利)選擇一個值,但這意味著丟失資料。所以,你可能需要在應用程式程式碼中做更聰明的事情。 -​ 以購物車為例,一種合理的合併兄弟方法就是集合求並。在[圖5-14](img/fig5-14.png)中,最後的兩個兄弟是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋出現在兩個,即使他們每個只寫一次。合併的價值可能是像[牛奶,麵粉,雞蛋,培根,火腿],沒有重複。 +​ 以購物車為例,一種合理的合併兄弟方法就是集合求並。在[圖5-14](../img/fig5-14.png)中,最後的兩個兄弟是[牛奶,麵粉,雞蛋,燻肉]和[雞蛋,牛奶,火腿]。注意牛奶和雞蛋出現在兩個,即使他們每個只寫一次。合併的價值可能是像[牛奶,麵粉,雞蛋,培根,火腿],沒有重複。 ​ 然而,如果你想讓人們也可以從他們的手推車中**刪除**東西,而不是僅僅新增東西,那麼把兄弟求並可能不會產生正確的結果:如果你合併了兩個兄弟手推車,並且只在其中一個兄弟值裡刪掉了它,那麼被刪除的專案會重新出現在兄弟的並集中【37】。為了防止這個問題,一個專案在刪除時不能簡單地從資料庫中刪除;相反,系統必須留下一個具有合適版本號的標記,以指示合併兄弟時該專案已被刪除。這種刪除標記被稱為**墓碑(tombstone)**。 (我們之前在“[雜湊索引”](ch3.md#雜湊索引)中的日誌壓縮的上下文中看到了墓碑。) @@ -706,13 +706,13 @@ #### 版本向量 -​ [圖5-13](img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改? +​ [圖5-13](../img/fig5-13.png)中的示例只使用一個副本。當有多個副本但沒有領導者時,演算法如何修改? -​ [圖5-13](img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,除了對每個鍵使用版本號之外,還需要在**每個副本**中使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些值,以及保留哪些值作為兄弟。 +​ [圖5-13](../img/fig5-13.png)使用單個版本號來捕獲操作之間的依賴關係,但是當多個副本併發接受寫入時,這是不夠的。相反,除了對每個鍵使用版本號之外,還需要在**每個副本**中使用版本號。每個副本在處理寫入時增加自己的版本號,並且跟蹤從其他副本中看到的版本號。這個資訊指出了要覆蓋哪些值,以及保留哪些值作為兄弟。 ​ 所有副本的版本號集合稱為**版本向量(version vector)**【56】。這個想法的一些變體正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本向量(dotted version vector)**【57】。我們不會深入細節,但是它的工作方式與我們在購物車示例中看到的非常相似。 -​ 與[圖5-13](img/fig5-13.png)中的版本號一樣,當讀取值時,版本向量會從資料庫副本傳送到客戶端,並且隨後寫入值時需要將其傳送回資料庫。 (Riak將版本向量編碼為一個字串,它稱為**因果上下文(causal context)**)。版本向量允許資料庫區分覆蓋寫入和併發寫入。 +​ 與[圖5-13](../img/fig5-13.png)中的版本號一樣,當讀取值時,版本向量會從資料庫副本傳送到客戶端,並且隨後寫入值時需要將其傳送回資料庫。 (Riak將版本向量編碼為一個字串,它稱為**因果上下文(causal context)**)。版本向量允許資料庫區分覆蓋寫入和併發寫入。 ​ 另外,就像在單個副本的例子中,應用程式可能需要合併兄弟。版本向量結構確保從一個副本讀取並隨後寫回到另一個副本是安全的。這樣做可能會建立兄弟,但只要兄弟姐妹合併正確,就不會丟失資料。 diff --git a/zh-tw/ch6.md b/zh-tw/ch6.md index 7a98427e..c0008548 100644 --- a/zh-tw/ch6.md +++ b/zh-tw/ch6.md @@ -1,6 +1,6 @@ # 6. 分割槽 -![](img/ch6.png) +![](../img/ch6.png) > 我們必須跳出電腦指令序列的窠臼。 敘述定義、描述元資料、梳理關係,而不是編寫過程。 > @@ -37,7 +37,7 @@ ​ 一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1]()所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。 我們在[第5章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。 -![](img/fig6-1.png) +![](../img/fig6-1.png) **圖6-1 組合使用複製和分割槽:每個節點充當某些分割槽的領導者,其他分割槽充當追隨者。** @@ -57,7 +57,7 @@ ​ 一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙百科全書的卷([圖6-2]())。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果您還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。 -![](img/fig6-2.png) +![](../img/fig6-2.png) **圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽** @@ -79,9 +79,9 @@ ​ 出於分割槽的目的,雜湊函式不需要多麼強壯的加密演算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式(它們用於雜湊表),但是它們可能不適合分割槽:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一個鍵可能在不同的程序中有不同的雜湊值【6】。 -​ 一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](img/fig6-3.png)所示。 +​ 一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](../img/fig6-3.png)所示。 -![](img/fig6-3.png) +![](../img/fig6-3.png) **圖6-3 按雜湊鍵分割槽** @@ -125,19 +125,19 @@ ### 基於文件的二級索引進行分割槽 -​ 假設你正在經營一個銷售二手車的網站(如[圖6-4](img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽(例如,分割槽0中的ID 0到499,分割槽1中的ID 500到999等)。 +​ 假設你正在經營一個銷售二手車的網站(如[圖6-4](../img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽(例如,分割槽0中的ID 0到499,分割槽1中的ID 500到999等)。 ​ 你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位(field)**,關係資料庫中這些是**列(column)** )。 如果您聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。 [^ii]: 如果資料庫僅支援鍵值模型,則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現輔助索引。 如果沿著這條路線走下去,請萬分小心,確保您的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 參見“[多物件事務的需求]()”。 -![](img/fig6-4.png) +![](../img/fig6-4.png) **圖6-4 基於文件的二級索引進行分割槽** ​ 在這種索引方法中,每個分割槽是完全獨立的:每個分割槽維護自己的二級索引,僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時您需要寫入資料庫(新增,刪除或更新文件),只需處理包含您正在編寫的文件ID的分割槽即可。出於這個原因,**文件分割槽索引**也被稱為**本地索引(local index)**(而不是將在下一節中描述的**全域性索引(global index)**)。 -​ 但是,從文件分割槽索引中讀取需要注意:除非您對文件ID做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](img/fig6-4.png)中,紅色汽車出現在分割槽0和分割槽1中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。 +​ 但是,從文件分割槽索引中讀取需要注意:除非您對文件ID做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](../img/fig6-4.png)中,紅色汽車出現在分割槽0和分割槽1中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。 ​ 這種查詢分割槽資料庫的方法有時被稱為**分散/聚集(scatter/gather)**,並且可能會使二級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”)。然而,它被廣泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文件分割槽二級索引。大多數資料庫供應商建議您構建一個能從單個分割槽提供二級索引查詢的分割槽方案,但這並不總是可行,尤其是當在單個查詢中使用多個二級索引時(例如同時需要按顏色和製造商查詢)。 @@ -147,9 +147,9 @@ ​ 我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。 -​ [圖6-5](img/fig6-5.png)述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中,`s`到`z`的在分割槽1。汽車製造商的索引也與之類似(分割槽邊界在`f`和`h`之間)。 +​ [圖6-5](../img/fig6-5.png)述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中,`s`到`z`的在分割槽1。汽車製造商的索引也與之類似(分割槽邊界在`f`和`h`之間)。 -![](img/fig6-5.png) +![](../img/fig6-5.png) **圖6-5 基於關鍵詞對二級索引進行分割槽** @@ -188,7 +188,7 @@ #### 反面教材:hash mod N -​ 我們在前面說過([圖6-3](img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key) ​ 一些作者聲稱,支援通用的兩階段提交代價太大,會帶來效能與可用性的問題。讓程式設計師來處理過度使用事務導致的效能問題,總比缺少事務程式設計好得多。 > @@ -90,11 +90,11 @@ ACID一致性的概念是,**對資料的一組特定約束必須始終成立** 大多數資料庫都會同時被多個客戶端訪問。如果它們各自讀寫資料庫的不同部分,這是沒有問題的,但是如果它們訪問相同的資料庫記錄,則可能會遇到**併發**問題(**競爭條件(race conditions)**)。 -[圖7-1](img/fig7-1.png)是這類問題的一個簡單例子。假設你有兩個客戶端同時在資料庫中增長一個計數器。(假設資料庫中沒有自增操作)每個客戶端需要讀取計數器的當前值,加 1 ,再回寫新值。[圖7-1](img/fig7-1.png) 中,因為發生了兩次增長,計數器應該從42增至44;但由於競態條件,實際上只增至 43 。 +[圖7-1](../img/fig7-1.png)是這類問題的一個簡單例子。假設你有兩個客戶端同時在資料庫中增長一個計數器。(假設資料庫中沒有自增操作)每個客戶端需要讀取計數器的當前值,加 1 ,再回寫新值。[圖7-1](../img/fig7-1.png) 中,因為發生了兩次增長,計數器應該從42增至44;但由於競態條件,實際上只增至 43 。 ACID意義上的隔離性意味著,**同時執行的事務是相互隔離的**:它們不能相互冒犯。傳統的資料庫教科書將隔離性形式化為**可序列化(Serializability)**,這意味著每個事務可以假裝它是唯一在整個資料庫上執行的事務。資料庫確保當事務已經提交時,結果與它們按順序執行(一個接一個)是一樣的,儘管實際上它們可能是併發執行的【10】。 -![](img/fig7-1.png) +![](../img/fig7-1.png) **圖7-1 兩個客戶之間的競爭狀態同時遞增計數器** @@ -137,7 +137,7 @@ ACID意義上的隔離性意味著,**同時執行的事務是相互隔離的** 同時執行的事務不應該互相干擾。例如,如果一個事務進行多次寫入,則另一個事務要麼看到全部寫入結果,要麼什麼都看不到,但不應該是一些子集。 -這些定義假設你想同時修改多個物件(行,文件,記錄)。通常需要**多物件事務(multi-object transaction)** 來保持多塊資料同步。[圖7-2](img/fig7-2.png)展示了一個來自電郵應用的例子。執行以下查詢來顯示使用者未讀郵件數量: +這些定義假設你想同時修改多個物件(行,文件,記錄)。通常需要**多物件事務(multi-object transaction)** 來保持多塊資料同步。[圖7-2](../img/fig7-2.png)展示了一個來自電郵應用的例子。執行以下查詢來顯示使用者未讀郵件數量: ```sql SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -145,17 +145,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 但如果郵件太多,你可能會覺得這個查詢太慢,並決定用單獨的欄位儲存未讀郵件的數量(一種反規範化)。現在每當一個新訊息寫入時,必須也增長未讀計數器,每當一個訊息被標記為已讀時,也必須減少未讀計數器。 -在[圖7-2](img/fig7-2.png)中,使用者2 遇到異常情況:郵件列表裡顯示有未讀訊息,但計數器顯示為零未讀訊息,因為計數器增長還沒有發生[^ii]。隔離性可以避免這個問題:透過確保使用者2 要麼同時看到新郵件和增長後的計數器,要麼都看不到。反正不會看到執行到一半的中間結果。 +在[圖7-2](../img/fig7-2.png)中,使用者2 遇到異常情況:郵件列表裡顯示有未讀訊息,但計數器顯示為零未讀訊息,因為計數器增長還沒有發生[^ii]。隔離性可以避免這個問題:透過確保使用者2 要麼同時看到新郵件和增長後的計數器,要麼都看不到。反正不會看到執行到一半的中間結果。 [^ii]: 可以說郵件應用中的錯誤計數器並不是什麼特別重要的問題。但換種方式來看,你可以把未讀計數器換成客戶賬戶餘額,把郵件收發看成支付交易。 -![](img/fig7-2.png) +![](../img/fig7-2.png) **圖7-2 違反隔離性:一個事務讀取另一個事務的未被執行的寫入(“髒讀”)。** -[圖7-3](img/fig7-3.png)說明了對原子性的需求:如果在事務過程中發生錯誤,郵箱和未讀計數器的內容可能會失去同步。在原子事務中,如果對計數器的更新失敗,事務將被中止,並且插入的電子郵件將被回滾。 +[圖7-3](../img/fig7-3.png)說明了對原子性的需求:如果在事務過程中發生錯誤,郵箱和未讀計數器的內容可能會失去同步。在原子事務中,如果對計數器的更新失敗,事務將被中止,並且插入的電子郵件將被回滾。 -![](img/fig7-3.png) +![](../img/fig7-3.png) **圖7-3 原子性確保發生錯誤時,事務先前的任何寫入都會被撤消,以避免狀態不一致** @@ -175,7 +175,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 這些問題非常讓人頭大,故儲存引擎一個幾乎普遍的目標是:對單節點上的單個物件(例如鍵值對)上提供原子性和隔離性。原子性可以透過使用日誌來實現崩潰恢復(參閱“[使B樹可靠]()”),並且可以使用每個物件上的鎖來實現隔離(每次只允許一個執行緒訪問物件) )。 -一些資料庫也提供更復雜的原子操作,例如自增操作,這樣就不再需要像 [圖7-1](img/fig7-1.png) 那樣的讀取-修改-寫入序列了。同樣流行的是 **[比較和設定(CAS, compare-and-set)](#比較並設定(CAS))** 操作,當值沒有被其他併發修改過時,才允許執行寫操作。 +一些資料庫也提供更復雜的原子操作,例如自增操作,這樣就不再需要像 [圖7-1](../img/fig7-1.png) 那樣的讀取-修改-寫入序列了。同樣流行的是 **[比較和設定(CAS, compare-and-set)](#比較並設定(CAS))** 操作,當值沒有被其他併發修改過時,才允許執行寫操作。 這些單物件操作很有用,因為它們可以防止在多個客戶端嘗試同時寫入同一個物件時丟失更新(參閱“[防止丟失更新](#防止丟失更新)”)。但它們不是通常意義上的事務。CAS以及其他單一物件操作被稱為“輕量級事務”,甚至出於營銷目的被稱為“ACID”【20,21,22】,但是這個術語是誤導性的。事務通常被理解為,**將多個物件上的多個操作合併為一個執行單元的機制**。[^iv] @@ -190,7 +190,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 有一些場景中,單物件插入,更新和刪除是足夠的。但是許多其他場景需要協調寫入幾個不同的物件: * 在關係資料模型中,一個表中的行通常具有對另一個表中的行的外來鍵引用。(類似的是,在一個圖資料模型中,一個頂點有著到其他頂點的邊)。多物件事務使你確信這些引用始終有效:當插入幾個相互引用的記錄時,外來鍵必須是正確的,最新的,不然資料就沒有意義。 -* 在文件資料模型中,需要一起更新的欄位通常在同一個文件中,這被視為單個物件——更新單個文件時不需要多物件事務。但是,缺乏連線功能的文件資料庫會鼓勵非規範化(參閱“[關係型資料庫與文件資料庫在今日的對比](ch2.md#關係型資料庫與文件資料庫在今日的對比)”)。當需要更新非規範化的資訊時,如 [圖7-2](img/fig7-2.png) 所示,需要一次更新多個文件。事務在這種情況下非常有用,可以防止非規範化的資料不同步。 +* 在文件資料模型中,需要一起更新的欄位通常在同一個文件中,這被視為單個物件——更新單個文件時不需要多物件事務。但是,缺乏連線功能的文件資料庫會鼓勵非規範化(參閱“[關係型資料庫與文件資料庫在今日的對比](ch2.md#關係型資料庫與文件資料庫在今日的對比)”)。當需要更新非規範化的資訊時,如 [圖7-2](../img/fig7-2.png) 所示,需要一次更新多個文件。事務在這種情況下非常有用,可以防止非規範化的資料不同步。 * 在具有二級索引的資料庫中(除了純粹的鍵值儲存以外幾乎都有),每次更改值時都需要更新索引。從事務角度來看,這些索引是不同的資料庫物件:例如,如果沒有事務隔離性,記錄可能出現在一個索引中,但沒有出現在另一個索引中,因為第二個索引的更新還沒有發生。 這些應用仍然可以在沒有事務的情況下實現。然而,**沒有原子性,錯誤處理就要複雜得多,缺乏隔離性,就會導致併發問題**。我們將在“[弱隔離級別](#弱隔離級別)”中討論這些問題,並在[第12章]()中探討其他方法。 @@ -250,14 +250,14 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 在**讀已提交**隔離級別執行的事務必須防止髒讀。這意味著事務的任何寫入操作只有在該事務提交時才能被其他人看到(然後所有的寫入操作都會立即變得可見)。如[圖7-4]()所示,使用者1 設定了`x = 3`,但使用者2 的 `get x `仍舊返回舊值2 ,而使用者1 尚未提交。 -![](img/fig7-4.png) +![](../img/fig7-4.png) **圖7-4 沒有髒讀:使用者2只有在使用者1的事務已經提交後才能看到x的新值。** 為什麼要防止髒讀,有幾個原因: -- 如果事務需要更新多個物件,髒讀取意味著另一個事務可能會只看到一部分更新。例如,在[圖7-2](img/fig7-2.png)中,使用者看到新的未讀電子郵件,但看不到更新的計數器。這就是電子郵件的髒讀。看到處於部分更新狀態的資料庫會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。 -- 如果事務中止,則所有寫入操作都需要回滾(如[圖7-3](img/fig7-3.png)所示)。如果資料庫允許髒讀,那就意味著一個事務可能會看到稍後需要回滾的資料,即從未實際提交給資料庫的資料。想想後果就讓人頭大。 +- 如果事務需要更新多個物件,髒讀取意味著另一個事務可能會只看到一部分更新。例如,在[圖7-2](../img/fig7-2.png)中,使用者看到新的未讀電子郵件,但看不到更新的計數器。這就是電子郵件的髒讀。看到處於部分更新狀態的資料庫會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。 +- 如果事務中止,則所有寫入操作都需要回滾(如[圖7-3](../img/fig7-3.png)所示)。如果資料庫允許髒讀,那就意味著一個事務可能會看到稍後需要回滾的資料,即從未實際提交給資料庫的資料。想想後果就讓人頭大。 #### 沒有髒寫 @@ -267,10 +267,10 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 透過防止髒寫,這個隔離級別避免了一些併發問題: -- 如果事務更新多個物件,髒寫會導致不好的結果。例如,考慮 [圖7-5](img/fig7-5.png),[圖7-5](img/fig7-5.png) 以一個二手車銷售網站為例,Alice和Bob兩個人同時試圖購買同一輛車。購買汽車需要兩次資料庫寫入:網站上的商品列表需要更新,以反映買家的購買,銷售發票需要傳送給買家。在[圖7-5](img/fig7-5.png)的情況下,銷售是屬於Bob的(因為他成功更新了商品列表),但發票卻寄送給了愛麗絲(因為她成功更新了發票表)。讀已提交會阻止這樣這樣的事故。 +- 如果事務更新多個物件,髒寫會導致不好的結果。例如,考慮 [圖7-5](../img/fig7-5.png),[圖7-5](../img/fig7-5.png) 以一個二手車銷售網站為例,Alice和Bob兩個人同時試圖購買同一輛車。購買汽車需要兩次資料庫寫入:網站上的商品列表需要更新,以反映買家的購買,銷售發票需要傳送給買家。在[圖7-5](../img/fig7-5.png)的情況下,銷售是屬於Bob的(因為他成功更新了商品列表),但發票卻寄送給了愛麗絲(因為她成功更新了發票表)。讀已提交會阻止這樣這樣的事故。 - 但是,提交讀取並不能防止[圖7-1]()中兩個計數器增量之間的競爭狀態。在這種情況下,第二次寫入發生在第一個事務提交後,所以它不是一個髒寫。這仍然是不正確的,但是出於不同的原因,在“[防止更新丟失](#防止丟失更新)”中將討論如何使這種計數器增量安全。 -![](img/fig7-5.png) +![](../img/fig7-5.png) **圖7-5 如果存在髒寫,來自不同事務的衝突寫入可能會混淆在一起** @@ -292,9 +292,9 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 如果只從表面上看讀已提交隔離級別你就認為它完成了事務所需的一切,那是可以原諒的。它允許**中止**(原子性的要求);它防止讀取不完整的事務結果,並且防止併發寫入造成的混合。事實上這些功能非常有用,比起沒有事務的系統來,可以提供更多的保證。 -但是在使用此隔離級別時,仍然有很多地方可能會產生併發錯誤。例如[圖7-6](img/fig7-6.png)說明了讀已提交時可能發生的問題。 +但是在使用此隔離級別時,仍然有很多地方可能會產生併發錯誤。例如[圖7-6](../img/fig7-6.png)說明了讀已提交時可能發生的問題。 -![](img/fig7-6.png) +![](../img/fig7-6.png) **圖7-6 讀取偏差:Alice觀察資料庫處於不一致的狀態** @@ -332,7 +332,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true [^vii]: 事實上,事務ID是32位整數,所以大約會在40億次事務之後溢位。 PostgreSQL的Vacuum過程會清理老舊的事務ID,確保事務ID溢位(回捲)不會影響到資料。 -![](img/fig7-7.png) +![](../img/fig7-7.png) **圖7-7 使用多版本物件實現快照隔離** @@ -475,9 +475,9 @@ UPDATE wiki_pages SET content = '新內容' 首先,想象一下這個例子:你正在為醫院寫一個醫生輪班管理程式。醫院通常會同時要求幾位醫生待命,但底線是至少有一位醫生在待命。醫生可以放棄他們的班次(例如,如果他們自己生病了),只要至少有一個同事在這一班中繼續工作【40,41】。 -現在想象一下,Alice和Bob是兩位值班醫生。兩人都感到不適,所以他們都決定請假。不幸的是,他們恰好在同一時間點選按鈕下班。[圖7-8](img/fig7-8.png)說明了接下來的事情。 +現在想象一下,Alice和Bob是兩位值班醫生。兩人都感到不適,所以他們都決定請假。不幸的是,他們恰好在同一時間點選按鈕下班。[圖7-8](../img/fig7-8.png)說明了接下來的事情。 -![](img/fig7-8.png) +![](../img/fig7-8.png) **圖7-8 寫入偏差導致應用程式錯誤的示例** @@ -626,9 +626,9 @@ COMMIT; ​ 在這種互動式的事務方式中,應用程式和資料庫之間的網路通訊耗費了大量的時間。如果不允許在資料庫中進行併發處理,且一次只處理一個事務,則吞吐量將會非常糟糕,因為資料庫大部分的時間都花費在等待應用程式發出當前事務的下一個查詢。在這種資料庫中,為了獲得合理的效能,需要同時處理多個事務。 -​ 出於這個原因,具有單執行緒序列事務處理的系統不允許互動式的多語句事務。取而代之,應用程式必須提前將整個事務程式碼作為儲存過程提交給資料庫。這些方法之間的差異如[圖7-9](img/fig7-9.png) 所示。如果事務所需的所有資料都在記憶體中,則儲存過程可以非常快地執行,而不用等待任何網路或磁碟I/O。 +​ 出於這個原因,具有單執行緒序列事務處理的系統不允許互動式的多語句事務。取而代之,應用程式必須提前將整個事務程式碼作為儲存過程提交給資料庫。這些方法之間的差異如[圖7-9](../img/fig7-9.png) 所示。如果事務所需的所有資料都在記憶體中,則儲存過程可以非常快地執行,而不用等待任何網路或磁碟I/O。 -![](img/fig7-9.png) +![](../img/fig7-9.png) **圖7-9 互動式事務和儲存過程之間的區別(使用圖7-8的示例事務)** @@ -793,7 +793,7 @@ WHERE room_id = 123 AND 回想一下,快照隔離通常是透過多版本併發控制(MVCC;見[圖7-10]())來實現的。當一個事務從MVCC資料庫中的一致快照讀時,它將忽略取快照時尚未提交的任何其他事務所做的寫入。在[圖7-10]()中,事務43 認為Alice的 `on_call = true` ,因為事務42(修改Alice的待命狀態)未被提交。然而,在事務43想要提交時,事務42 已經提交。這意味著在讀一致性快照時被忽略的寫入已經生效,事務43 的前提不再為真。 -![](img/fig7-10.png) +![](../img/fig7-10.png) **圖7-10 檢測事務何時從MVCC快照讀取過時的值** @@ -803,9 +803,9 @@ WHERE room_id = 123 AND #### 檢測影響之前讀取的寫入 -第二種情況要考慮的是另一個事務在讀取資料之後修改資料。這種情況如[圖7-11](img/fig7-11.png)所示。 +第二種情況要考慮的是另一個事務在讀取資料之後修改資料。這種情況如[圖7-11](../img/fig7-11.png)所示。 -![](img/fig7-11.png) +![](../img/fig7-11.png) **圖7-11 在可序列化快照隔離中,檢測一個事務何時修改另一個事務的讀取。** diff --git a/zh-tw/ch8.md b/zh-tw/ch8.md index b691521f..6bb6324b 100644 --- a/zh-tw/ch8.md +++ b/zh-tw/ch8.md @@ -1,6 +1,6 @@ # 第八章:分散式系統的麻煩 -![](img/ch8.png) +![](../img/ch8.png) > 邂逅相遇 > @@ -97,7 +97,7 @@ ​ **無共享**並不是構建系統的唯一方式,但它已經成為構建網際網路服務的主要方式,其原因如下:相對便宜,因為它不需要特殊的硬體,可以利用商品化的雲端計算服務,透過跨多個地理分佈的資料中心進行冗餘可以實現高可靠性。 -​ 網際網路和資料中心(通常是乙太網)中的大多數內部網路都是**非同步分組網路(asynchronous packet networks)**。在這種網路中,一個節點可以向另一個節點發送一個訊息(一個數據包),但是網路不能保證它什麼時候到達,或者是否到達。如果您傳送請求並期待響應,則很多事情可能會出錯(其中一些如[圖8-1](img/fig8-1.png)所示): +​ 網際網路和資料中心(通常是乙太網)中的大多數內部網路都是**非同步分組網路(asynchronous packet networks)**。在這種網路中,一個節點可以向另一個節點發送一個訊息(一個數據包),但是網路不能保證它什麼時候到達,或者是否到達。如果您傳送請求並期待響應,則很多事情可能會出錯(其中一些如[圖8-1](../img/fig8-1.png)所示): 1. 請求可能已經丟失(可能有人拔掉了網線)。 2. 請求可能正在排隊,稍後將交付(也許網路或收件人超載)。 @@ -106,7 +106,7 @@ 5. 遠端節點可能已經處理了請求,但是網路上的響應已經丟失(可能是網路交換機配置錯誤)。 6. 遠端節點可能已經處理了請求,但是響應已經被延遲,並且稍後將被傳遞(可能是網路或者你自己的機器過載)。 -![](img/fig8-1.png) +![](../img/fig8-1.png) **圖8-1 如果傳送請求並沒有得到響應,則無法區分(a)請求是否丟失,(b)遠端節點是否關閉,或(c)響應是否丟失。** @@ -168,12 +168,12 @@ ​ 在駕駛汽車時,由於交通擁堵,道路交通網路的通行時間往往不盡相同。同樣,計算機網路上資料包延遲的可變性通常是由於排隊【25】: -* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路連線)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。 +* 如果多個不同的節點同時嘗試將資料包傳送到同一目的地,則網路交換機必須將它們排隊並將它們逐個送入目標網路鏈路(如[圖8-2](../img/fig8-2.png)所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為網路連線)。如果傳入的資料太多,交換機佇列填滿,資料包將被丟棄,因此需要重新發送資料包 - 即使網路執行良好。 * 當資料包到達目標機器時,如果所有CPU核心當前都處於繁忙狀態,則來自網路的傳入請求將被作業系統排隊,直到應用程式準備好處理它為止。根據機器上的負載,這可能需要一段任意的時間。 * 在虛擬化環境中,正在執行的作業系統經常暫停幾十毫秒,而另一個虛擬機器使用CPU核心。在這段時間內,虛擬機器不能從網路中消耗任何資料,所以傳入的資料被虛擬機器監視器 【26】排隊(緩衝),進一步增加了網路延遲的可變性。 * TCP執行**流量控制(flow control)**(也稱為**擁塞避免(congestion avoidance)**或**背壓(backpressure)**),其中節點限制自己的傳送速率以避免網路鏈路或接收節點過載【27】。這意味著在資料甚至進入網路之前,在傳送者處需要進行額外的排隊。 -![](img/fig8-2.png) +![](../img/fig8-2.png) **圖8-2 如果有多臺機器將網路流量傳送到同一目的地,則其交換機佇列可能會被填滿。在這裡,埠1,2和4都試圖傳送資料包到埠3** @@ -319,20 +319,20 @@ ​ 讓我們考慮一個特別的情況,一件很有誘惑但也很危險的事情:依賴時鐘,在多個節點上對事件進行排序。 例如,如果兩個客戶端寫入分散式資料庫,誰先到達? 哪一個更近? -​ [圖8-3](img/fig8-3.png)顯示了在具有多領導者複製的資料庫中對時鐘的危險使用(該例子類似於[圖5-9](img/fig5-9.png))。 客戶端A在節點1上寫入`x = 1`;寫入被複制到節點3;客戶端B在節點3上增加x(我們現在有`x = 2`);最後這兩個寫入都被複制到節點2。 +​ [圖8-3](../img/fig8-3.png)顯示了在具有多領導者複製的資料庫中對時鐘的危險使用(該例子類似於[圖5-9](../img/fig5-9.png))。 客戶端A在節點1上寫入`x = 1`;寫入被複制到節點3;客戶端B在節點3上增加x(我們現在有`x = 2`);最後這兩個寫入都被複制到節點2。 -![](img/fig8-3.png) +![](../img/fig8-3.png) **圖8-3 客戶端B的寫入比客戶端A的寫入要晚,但是B的寫入具有較早的時間戳。** ​ 在[圖8-3]()中,當一個寫入被複制到其他節點時,它會根據發生寫入的節點上的時鐘時鐘標記一個時間戳。在這個例子中,時鐘同步是非常好的:節點1和節點3之間的偏差小於3ms,這可能比你在實踐中預期的更好。 -​ 儘管如此,[圖8-3](img/fig8-3.png)中的時間戳卻無法正確排列事件:寫入`x = 1`的時間戳為42.004秒,但寫入`x = 2`的時間戳為42.003秒,即使`x = 2`在稍後出現。當節點2接收到這兩個事件時,會錯誤地推斷出`x = 1`是最近的值,而丟棄寫入`x = 2`。效果上表現為,客戶端B的增量操作會丟失。 +​ 儘管如此,[圖8-3](../img/fig8-3.png)中的時間戳卻無法正確排列事件:寫入`x = 1`的時間戳為42.004秒,但寫入`x = 2`的時間戳為42.003秒,即使`x = 2`在稍後出現。當節點2接收到這兩個事件時,會錯誤地推斷出`x = 1`是最近的值,而丟棄寫入`x = 2`。效果上表現為,客戶端B的增量操作會丟失。 ​ 這種衝突解決策略被稱為**最後寫入勝利(LWW)**,它在多領導者複製和無領導者資料庫(如Cassandra 【53】和Riak 【54】)中被廣泛使用(參見“[最後寫入勝利(丟棄併發寫入)](#最後寫入勝利(丟棄併發寫入))”一節)。有些實現會在客戶端而不是伺服器上生成時間戳,但這並不能改變LWW的基本問題: * 資料庫寫入可能會神祕地消失:具有滯後時鐘的節點無法覆蓋之前具有快速時鐘的節點寫入的值,直到節點之間的時鐘偏差消逝【54,55】。此方案可能導致一定數量的資料被悄悄丟棄,而未嚮應用報告任何錯誤。 -* LWW無法區分**高頻順序寫入**(在[圖8-3](img/fig8-3.png)中,客戶端B的增量操作**一定**發生在客戶端A的寫入之後)和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止因果關係的衝突(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。 +* LWW無法區分**高頻順序寫入**(在[圖8-3](../img/fig8-3.png)中,客戶端B的增量操作**一定**發生在客戶端A的寫入之後)和**真正併發寫入**(寫入者意識不到其他寫入者)。需要額外的因果關係跟蹤機制(例如版本向量),以防止因果關係的衝突(請參閱“[檢測併發寫入](ch5.md#檢測併發寫入)”)。 * 兩個節點很可能獨立地生成具有相同時間戳的寫入,特別是在時鐘僅具有毫秒解析度的情況下。為了解決這樣的衝突,還需要一個額外的**決勝值(tiebreaker)**(可以簡單地是一個大隨機數),但這種方法也可能會導致違背因果關係【53】。 因此,儘管透過保留最“最近”的值並放棄其他值來解決衝突是很誘惑人的,但是要注意,“最近”的定義取決於本地的**時鐘**,這很可能是不正確的。即使用頻繁同步的NTP時鐘,一個數據包也可能在時間戳100毫秒(根據傳送者的時鐘)時傳送,並在時間戳99毫秒(根據接收者的時鐘)處到達——看起來好像資料包在傳送之前已經到達,這是不可能的。 @@ -485,9 +485,9 @@ while(true){ ​ 如果一個節點繼續表現為**天選者**,即使大多數節點已經宣告它已經死了,則在考慮不周的系統中可能會導致問題。這樣的節點能以自己賦予的權能向其他節點發送訊息,如果其他節點相信,整個系統可能會做一些不正確的事情。 -​ 例如,[圖8-4](img/fig8-4.png)顯示了由於不正確的鎖實現導致的資料損壞錯誤。 (這個錯誤不僅僅是理論上的:HBase曾經有這個問題【74,75】)假設你要確保一個儲存服務中的檔案一次只能被一個客戶訪問,因為如果多個客戶試圖寫對此,該檔案將被損壞。您嘗試透過在訪問檔案之前要求客戶端從鎖定服務獲取租約來實現此目的。 +​ 例如,[圖8-4](../img/fig8-4.png)顯示了由於不正確的鎖實現導致的資料損壞錯誤。 (這個錯誤不僅僅是理論上的:HBase曾經有這個問題【74,75】)假設你要確保一個儲存服務中的檔案一次只能被一個客戶訪問,因為如果多個客戶試圖寫對此,該檔案將被損壞。您嘗試透過在訪問檔案之前要求客戶端從鎖定服務獲取租約來實現此目的。 -![](img/fig8-4.png) +![](../img/fig8-4.png) **圖8-4 分散式鎖的實現不正確:客戶端1認為它仍然具有有效的租約,即使它已經過期,從而破壞了儲存中的檔案** @@ -495,15 +495,15 @@ while(true){ #### 防護令牌 -​ 當使用鎖或租約來保護對某些資源(如[圖8-4](img/fig8-4.png)中的檔案儲存)的訪問時,需要確保一個被誤認為自己是“天選者”的節點不能擾亂系統的其它部分。實現這一目標的一個相當簡單的技術就是**防護(fencing)**,如[圖8-5]()所示 +​ 當使用鎖或租約來保護對某些資源(如[圖8-4](../img/fig8-4.png)中的檔案儲存)的訪問時,需要確保一個被誤認為自己是“天選者”的節點不能擾亂系統的其它部分。實現這一目標的一個相當簡單的技術就是**防護(fencing)**,如[圖8-5]()所示 -![](img/fig8-5.png) +![](../img/fig8-5.png) **圖8-5 只允許以增加防護令牌的順序進行寫操作,從而保證儲存安全** ​ 我們假設每次鎖定伺服器授予鎖或租約時,它還會返回一個**防護令牌(fencing token)**,這個數字在每次授予鎖定時都會增加(例如,由鎖定服務增加)。然後,我們可以要求客戶端每次向儲存服務傳送寫入請求時,都必須包含當前的防護令牌。 -​ 在[圖8-5](img/fig8-5.png)中,客戶端1以33的令牌獲得租約,但隨後進入一個長時間的停頓並且租約到期。客戶端2以34的令牌(該數字總是增加)獲取租約,然後將其寫入請求傳送到儲存服務,包括34的令牌。稍後,客戶端1恢復生機並將其寫入儲存服務,包括其令牌值33.但是,儲存伺服器會記住它已經處理了一個具有更高令牌編號(34)的寫入,因此它會拒絕帶有令牌33的請求。 +​ 在[圖8-5](../img/fig8-5.png)中,客戶端1以33的令牌獲得租約,但隨後進入一個長時間的停頓並且租約到期。客戶端2以34的令牌(該數字總是增加)獲取租約,然後將其寫入請求傳送到儲存服務,包括34的令牌。稍後,客戶端1恢復生機並將其寫入儲存服務,包括其令牌值33.但是,儲存伺服器會記住它已經處理了一個具有更高令牌編號(34)的寫入,因此它會拒絕帶有令牌33的請求。 ​ 如果將ZooKeeper用作鎖定服務,則可將事務標識`zxid`或節點版本`cversion`用作防護令牌。由於它們保證單調遞增,因此它們具有所需的屬性【74】。 diff --git a/zh-tw/ch9.md b/zh-tw/ch9.md index 99ebdb25..b11db84f 100644 --- a/zh-tw/ch9.md +++ b/zh-tw/ch9.md @@ -1,6 +1,6 @@ # 9. 一致性與共識 -![](img/ch9.png) +![](../img/ch9.png) > 好死不如賴活著 > —— Jay Kreps, 關於Kafka與 Jepsen的若干筆記 (2013) @@ -60,11 +60,11 @@ ​ 在一個線性一致的系統中,只要一個客戶端成功完成寫操作,所有客戶端從資料庫中讀取資料必須能夠看到剛剛寫入的值。維護資料的單個副本的錯覺是指,系統能保障讀到的值是最近的,最新的,而不是來自陳舊的快取或副本。換句話說,線性一致性是一個**新鮮度保證(recency guarantee)**。為了闡明這個想法,我們來看看一個非線性一致系統的例子。 -![](img/fig9-1.png) +![](../img/fig9-1.png) **圖9-1 這個系統是非線性一致的,導致了球迷的困惑** -​ [圖9-1 ](img/fig9-1.png)展示了一個關於體育網站的非線性一致例子【9】。Alice和Bob正坐在同一個房間裡,都盯著各自的手機,關注著2014年FIFA世界盃決賽的結果。在最後得分公佈後,Alice重新整理頁面,看到宣佈了獲勝者,並興奮地告訴Bob。Bob難以置信地重新整理了自己的手機,但他的請求路由到了一個落後的資料庫副本上,手機顯示比賽仍在進行。 +​ [圖9-1 ](../img/fig9-1.png)展示了一個關於體育網站的非線性一致例子【9】。Alice和Bob正坐在同一個房間裡,都盯著各自的手機,關注著2014年FIFA世界盃決賽的結果。在最後得分公佈後,Alice重新整理頁面,看到宣佈了獲勝者,並興奮地告訴Bob。Bob難以置信地重新整理了自己的手機,但他的請求路由到了一個落後的資料庫副本上,手機顯示比賽仍在進行。 ​ 如果Alice和Bob在同一時間重新整理並獲得了兩個不同的查詢結果,也許就沒有那麼令人驚訝了。因為他們不知道伺服器處理他們請求的精確時刻。然而Bob是在聽到Alice驚呼最後得分**之後**,點選了重新整理按鈕(啟動了他的查詢),因此他希望查詢結果至少與愛麗絲一樣新鮮。但他的查詢返回了陳舊結果,這一事實違背了線性一致性的要求。 @@ -72,13 +72,13 @@ ​ 線性一致性背後的基本思想很簡單:使系統看起來好像只有一個數據副本。然而確切來講,實際上有更多要操心的地方。為了更好地理解線性一致性,讓我們再看幾個例子。 -​ [圖9-2](img/fig9-2.png) 顯示了三個客戶端線上性一致資料庫中同時讀寫相同的鍵`x`。在分散式系統文獻中,`x`被稱為**暫存器(register)**,例如,它可以是鍵值儲存中的一個**鍵**,關係資料庫中的一**行**,或文件資料庫中的一個**文件**。 +​ [圖9-2](../img/fig9-2.png) 顯示了三個客戶端線上性一致資料庫中同時讀寫相同的鍵`x`。在分散式系統文獻中,`x`被稱為**暫存器(register)**,例如,它可以是鍵值儲存中的一個**鍵**,關係資料庫中的一**行**,或文件資料庫中的一個**文件**。 -![](img/fig9-2.png) +![](../img/fig9-2.png) **圖9-2 如果讀取請求與寫入請求併發,則可能會返回舊值或新值** -​ 為了簡單起見,[圖9-2](img/fig9-2.png)採用了使用者請求的視角,而不是資料庫內部的視角。每個柱都是由客戶端發出的請求,其中柱頭是請求傳送的時刻,柱尾是客戶端收到響應的時刻。因為網路延遲變化無常,客戶端不知道資料庫處理其請求的精確時間——只知道它發生在傳送請求和接收響應的之間的某個時刻。[^i] +​ 為了簡單起見,[圖9-2](../img/fig9-2.png)採用了使用者請求的視角,而不是資料庫內部的視角。每個柱都是由客戶端發出的請求,其中柱頭是請求傳送的時刻,柱尾是客戶端收到響應的時刻。因為網路延遲變化無常,客戶端不知道資料庫處理其請求的精確時間——只知道它發生在傳送請求和接收響應的之間的某個時刻。[^i] [^i]: 這個圖的一個微妙的細節是它假定存在一個全域性時鐘,由水平軸表示。即使真實的系統通常沒有準確的時鐘(參閱“[不可靠的時鐘](ch8.md#不可靠的時鐘)”),但這種假設是允許的:為了分析分散式演算法,我們可以假設一個精確的全域性時鐘存在,不過演算法無法訪問它【47】。演算法只能看到由石英振盪器和NTP產生的實時逼近。 @@ -89,7 +89,7 @@ * $write(x,v)⇒r$ 表示客戶端請求將暫存器 `x` 設定為值 `v` ,資料庫返回響應 `r` (可能正確,可能錯誤)。 -在[圖9-2](img/fig9-2.png) 中,`x` 的值最初為 `0`,客戶端C 執行寫請求將其設定為 `1`。發生這種情況時,客戶端A和B反覆輪詢資料庫以讀取最新值。 A和B的請求可能會收到怎樣的響應? +在[圖9-2](../img/fig9-2.png) 中,`x` 的值最初為 `0`,客戶端C 執行寫請求將其設定為 `1`。發生這種情況時,客戶端A和B反覆輪詢資料庫以讀取最新值。 A和B的請求可能會收到怎樣的響應? * 客戶端A的第一個讀操作,完成於寫操作開始之前,因此必須返回舊值 `0`。 * 客戶端A的最後一個讀操作,開始於寫操作完成之後。如果資料庫是線性一致性的,它必然返回新值 `1`:因為讀操作和寫操作一定是在其各自的起止區間內的某個時刻被處理。如果在寫入結束後開始讀取,則必須在寫入之後處理讀取,因此它必須看到寫入的新值。 @@ -99,16 +99,16 @@ [^ii]: 如果讀取(與寫入同時發生時)可能返回舊值或新值,則稱該暫存器為**常規暫存器(regular register)**【7,25】 -為了使系統線性一致,我們需要新增另一個約束,如[圖9-3](img/fig9-3.png)所示 +為了使系統線性一致,我們需要新增另一個約束,如[圖9-3](../img/fig9-3.png)所示 -![](img/fig9-3.png) +![](../img/fig9-3.png) **圖9-3 任何一個讀取返回新值後,所有後續讀取(在相同或其他客戶端上)也必須返回新值。** ​ 在一個線性一致的系統中,我們可以想象,在 `x` 的值從`0` 自動翻轉到 `1` 的時候(在寫操作的開始和結束之間)必定有一個時間點。因此,如果一個客戶端的讀取返回新的值 `1`,即使寫操作尚未完成,所有後續讀取也必須返回新值。 -​ [圖9-3](img/fig9-3.png)中的箭頭說明了這個時序依賴關係。客戶端A 是第一個讀取新的值 `1` 的位置。在A 的讀取返回之後,B開始新的讀取。由於B的讀取嚴格在發生於A的讀取之後,因此即使C的寫入仍在進行中,也必須返回 `1`。 (與[圖9-1](img/fig9-1.png)中的Alice和Bob的情況相同:在Alice讀取新值之後,Bob也希望讀取新的值。) +​ [圖9-3](../img/fig9-3.png)中的箭頭說明了這個時序依賴關係。客戶端A 是第一個讀取新的值 `1` 的位置。在A 的讀取返回之後,B開始新的讀取。由於B的讀取嚴格在發生於A的讀取之後,因此即使C的寫入仍在進行中,也必須返回 `1`。 (與[圖9-1](../img/fig9-1.png)中的Alice和Bob的情況相同:在Alice讀取新值之後,Bob也希望讀取新的值。) -​ 我們可以進一步細化這個時序圖,展示每個操作是如何在特定時刻原子性生效的。[圖9-4](img/fig9-4.png)顯示了一個更復雜的例子【10】。 +​ 我們可以進一步細化這個時序圖,展示每個操作是如何在特定時刻原子性生效的。[圖9-4](../img/fig9-4.png)顯示了一個更復雜的例子【10】。 在[圖9-4]()中,除了讀寫之外,還增加了第三種類型的操作: @@ -118,7 +118,7 @@ ​ 線性一致性的要求是,操作標記的連線總是按時間(從左到右)向前移動,而不是向後移動。這個要求確保了我們之前討論的新鮮性保證:一旦新的值被寫入或讀取,所有後續的讀都會看到寫入的值,直到它被再次覆蓋。 -![](img/fig9-4.png) +![](../img/fig9-4.png) **圖9-4 視覺化讀取和寫入看起來已經生效的時間點。 B的最後讀取不是線性一致性的** @@ -130,7 +130,7 @@ * 此模型不假設有任何事務隔離:另一個客戶端可能隨時更改值。例如,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更舊的值。再次,與[圖9-1](img/fig9-1.png)中的Alice和Bob的情況相同。 +* 客戶B的最後一次讀取(陰影條柱中)不是線性一致性的。 該操作與C的**cas**寫操作併發(它將 `x` 從 `2` 更新為 `4` )。在沒有其他請求的情況下,B的讀取返回 `2` 是可以的。然而,在B的讀取開始之前,客戶端A已經讀取了新的值 `4` ,因此不允許B讀取比A更舊的值。再次,與[圖9-1](../img/fig9-1.png)中的Alice和Bob的情況相同。 這就是線性一致性背後的直覺。 正式的定義【6】更準確地描述了它。 透過記錄所有請求和響應的時序,並檢查它們是否可以排列成有效的順序,測試一個系統的行為是否線性一致性是可能的(儘管在計算上是昂貴的)【11】。 @@ -182,17 +182,17 @@ #### 跨通道的時序依賴 -​ 注意[圖9-1](img/fig9-1.png) 中的一個細節:如果Alice沒有驚呼得分,Bob就不會知道他的查詢結果是陳舊的。他會在幾秒鐘之後再次重新整理頁面,並最終看到最後的分數。由於系統中存在額外的通道(Alice的聲音傳到了Bob的耳朵中),線性一致性的違背才被注意到。 +​ 注意[圖9-1](../img/fig9-1.png) 中的一個細節:如果Alice沒有驚呼得分,Bob就不會知道他的查詢結果是陳舊的。他會在幾秒鐘之後再次重新整理頁面,並最終看到最後的分數。由於系統中存在額外的通道(Alice的聲音傳到了Bob的耳朵中),線性一致性的違背才被注意到。 -​ 計算機系統也會出現類似的情況。例如,假設有一個網站,使用者可以上傳照片,一個後臺程序會調整照片大小,降低解析度以加快下載速度(縮圖)。該系統的架構和資料流如[圖9-5](img/fig9-5.png)所示。 +​ 計算機系統也會出現類似的情況。例如,假設有一個網站,使用者可以上傳照片,一個後臺程序會調整照片大小,降低解析度以加快下載速度(縮圖)。該系統的架構和資料流如[圖9-5](../img/fig9-5.png)所示。 ​ 影象縮放器需要明確的指令來執行尺寸縮放作業,指令是Web伺服器透過訊息佇列傳送的(參閱[第11章](ch11.md))。 Web伺服器不會將整個照片放在佇列中,因為大多數訊息代理都是針對較短的訊息而設計的,而一張照片的空間佔用可能達到幾兆位元組。取而代之的是,首先將照片寫入檔案儲存服務,寫入完成後再將縮放器的指令放入訊息佇列。 -![](img/fig9-5.png) +![](../img/fig9-5.png) **圖9-5 Web伺服器和影象調整器透過檔案儲存和訊息佇列進行通訊,開啟競爭條件的可能性。** -​ 如果檔案儲存服務是線性一致的,那麼這個系統應該可以正常工作。如果它不是線性一致的,則存在競爭條件的風險:訊息佇列([圖9-5](img/fig9-5.png)中的步驟3和4)可能比儲存服務內部的複製更快。在這種情況下,當縮放器讀取影象(步驟5)時,可能會看到影象的舊版本,或者什麼都沒有。如果它處理的是舊版本的影象,則檔案儲存中的全尺寸圖和略縮圖就產生了永久性的不一致。 +​ 如果檔案儲存服務是線性一致的,那麼這個系統應該可以正常工作。如果它不是線性一致的,則存在競爭條件的風險:訊息佇列([圖9-5](../img/fig9-5.png)中的步驟3和4)可能比儲存服務內部的複製更快。在這種情況下,當縮放器讀取影象(步驟5)時,可能會看到影象的舊版本,或者什麼都沒有。如果它處理的是舊版本的影象,則檔案儲存中的全尺寸圖和略縮圖就產生了永久性的不一致。 -​ 出現這個問題是因為Web伺服器和縮放器之間存在兩個不同的通道:檔案儲存與訊息佇列。沒有線性一致性的新鮮性保證,這兩個通道之間的競爭條件是可能的。這種情況類似於[圖9-1](img/fig9-1.png),資料庫複製與Alice的嘴到Bob耳朵之間的真人音訊通道之間也存在競爭條件。 +​ 出現這個問題是因為Web伺服器和縮放器之間存在兩個不同的通道:檔案儲存與訊息佇列。沒有線性一致性的新鮮性保證,這兩個通道之間的競爭條件是可能的。這種情況類似於[圖9-1](../img/fig9-1.png),資料庫複製與Alice的嘴到Bob耳朵之間的真人音訊通道之間也存在競爭條件。 ​ 線性一致性並不是避免這種競爭條件的唯一方法,但它是最容易理解的。如果你可以控制額外通道(例如訊息佇列的例子,而不是在Alice和Bob的例子),則可以使用在“[讀己之寫](ch5.md#讀己之寫)”討論過的備選方法,不過會有額外的複雜度代價。 @@ -230,13 +230,13 @@ #### 線性一致性和法定人數 -​ 直覺上在Dynamo風格的模型中,嚴格的法定人數讀寫應該是線性一致性的。但是當我們有可變的網路延遲時,就可能存在競爭條件,如[圖9-6](img/fig9-6.png)所示。 +​ 直覺上在Dynamo風格的模型中,嚴格的法定人數讀寫應該是線性一致性的。但是當我們有可變的網路延遲時,就可能存在競爭條件,如[圖9-6](../img/fig9-6.png)所示。 -![](img/fig9-6.png) +![](../img/fig9-6.png) **圖9-6 非線性一致的執行,儘管使用了嚴格的法定人數** -​ 在[圖9-6](img/fig9-6.png)中,$x$ 的初始值為0,寫入客戶端透過向所有三個副本( $n = 3, w = 3$ )傳送寫入將 $x$ 更新為 `1`。客戶端A併發地從兩個節點組成的法定人群( $r = 2$ )中讀取資料,並在其中一個節點上看到新值 `1` 。客戶端B也併發地從兩個不同的節點組成的法定人數中讀取,並從兩個節點中取回了舊值 `0` 。 +​ 在[圖9-6](../img/fig9-6.png)中,$x$ 的初始值為0,寫入客戶端透過向所有三個副本( $n = 3, w = 3$ )傳送寫入將 $x$ 更新為 `1`。客戶端A併發地從兩個節點組成的法定人群( $r = 2$ )中讀取資料,並在其中一個節點上看到新值 `1` 。客戶端B也併發地從兩個不同的節點組成的法定人數中讀取,並從兩個節點中取回了舊值 `0` 。 ​ 法定人數條件滿足( $w + r> n$ ),但是這個執行是非線性一致的:B的請求在A的請求完成後開始,但是B返回舊值,而A返回新值。 (又一次,如同Alice和Bob的例子 [圖9-1]()) @@ -252,9 +252,9 @@ ​ 一些複製方法可以提供線性一致性,另一些複製方法則不能,因此深入地探討線性一致性的優缺點是很有趣的。 -​ 我們已經在[第五章](ch5.md)中討論了不同複製方法的一些用例。例如對多資料中心的複製而言,多主複製通常是理想的選擇(參閱“[運維多個數據中心](ch5.md#運維多個數據中心)”)。[圖9-7](img/fig9-7.png)說明了這種部署的一個例子。 +​ 我們已經在[第五章](ch5.md)中討論了不同複製方法的一些用例。例如對多資料中心的複製而言,多主複製通常是理想的選擇(參閱“[運維多個數據中心](ch5.md#運維多個數據中心)”)。[圖9-7](../img/fig9-7.png)說明了這種部署的一個例子。 -![](img/fig9-7.png) +![](../img/fig9-7.png) **圖9-7 網路中斷迫使線上性一致性和可用性之間做出選擇。** @@ -311,7 +311,7 @@ ## 順序保證 -​ 之前說過,線性一致暫存器的行為就好像只有單個數據副本一樣,且每個操作似乎都是在某個時間點以原子性的方式生效的。這個定義意味著操作是按照某種良好定義的順序執行的。我們透過操作(似乎)執行完畢的順序來連線操作,以此說明[圖9-4](img/fig9-4.png)中的順序。 +​ 之前說過,線性一致暫存器的行為就好像只有單個數據副本一樣,且每個操作似乎都是在某個時間點以原子性的方式生效的。這個定義意味著操作是按照某種良好定義的順序執行的。我們透過操作(似乎)執行完畢的順序來連線操作,以此說明[圖9-4](../img/fig9-4.png)中的順序。 **順序(ordering)**這一主題在本書中反覆出現,這表明它可能是一個重要的基礎性概念。讓我們簡要回顧一下其它**順序**曾經出現過的上下文: @@ -325,12 +325,12 @@ **順序**反覆出現有幾個原因,其中一個原因是,它有助於保持**因果關係(causality)**。在本書中我們已經看到了幾個例子,其中因果關係是很重要的: -* 在“[一致字首讀](ch5.md#一致字首讀)”([圖5-5](img/fig5-5.png))中,我們看到一個例子:一個對話的觀察者首先看到問題的答案,然後才看到被回答的問題。這是令人困惑的,因為它違背了我們對**因(cause)**與**果(effect)**的直覺:如果一個問題被回答,顯然問題本身得先在那裡,因為給出答案的人必須看到這個問題(假如他們並沒有預見未來的超能力)。我們認為在問題和答案之間存在**因果依賴(causal dependency)**。 -* [圖5-9](img/fig5-9.png)中出現了類似的模式,我們看到三位領導者之間的複製,並注意到由於網路延遲,一些寫入可能會“壓倒”其他寫入。從其中一個副本的角度來看,好像有一個對尚不存在的記錄的更新操作。這裡的因果意味著,一條記錄必須先被建立,然後才能被更新。 +* 在“[一致字首讀](ch5.md#一致字首讀)”([圖5-5](../img/fig5-5.png))中,我們看到一個例子:一個對話的觀察者首先看到問題的答案,然後才看到被回答的問題。這是令人困惑的,因為它違背了我們對**因(cause)**與**果(effect)**的直覺:如果一個問題被回答,顯然問題本身得先在那裡,因為給出答案的人必須看到這個問題(假如他們並沒有預見未來的超能力)。我們認為在問題和答案之間存在**因果依賴(causal dependency)**。 +* [圖5-9](../img/fig5-9.png)中出現了類似的模式,我們看到三位領導者之間的複製,並注意到由於網路延遲,一些寫入可能會“壓倒”其他寫入。從其中一個副本的角度來看,好像有一個對尚不存在的記錄的更新操作。這裡的因果意味著,一條記錄必須先被建立,然後才能被更新。 * 在“[檢測併發寫入](ch5.md#檢測併發寫入)”中我們觀察到,如果有兩個操作A和B,則存在三種可能性:A發生在B之前,或B發生在A之前,或者A和B**併發**。這種**此前發生(happened before)**關係是因果關係的另一種表述:如果A在B前發生,那麼意味著B可能已經知道了A,或者建立在A的基礎上,或者依賴於A。如果A和B是**併發**的,那麼它們之間並沒有因果聯絡;換句話說,我們確信A和B不知道彼此。 -* 在事務快照隔離的上下文中(“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”),我們說事務是從一致性快照中讀取的。但此語境中“一致”到底又是什麼意思?這意味著**與因果關係保持一致(consistent with causality)**:如果快照包含答案,它也必須包含被回答的問題【48】。在某個時間點觀察整個資料庫,與因果關係保持一致意味著:因果上在該時間點之前發生的所有操作,其影響都是可見的,但因果上在該時間點之後發生的操作,其影響對觀察者不可見。**讀偏差(read skew)**意味著讀取的資料處於違反因果關係的狀態(不可重複讀,如[圖7-6](img/fig7-6)所示)。 -* 事務之間**寫偏差(write skew)**的例子(參見“[寫偏差和幻象](ch7.md#寫偏差和幻象)”)也說明了因果依賴:在[圖7-8](img/fig7-8.png)中,愛麗絲被允許離班,因為事務認為鮑勃仍在值班,反之亦然。在這種情況下,離班的動作因果依賴於對當前值班情況的觀察。[可序列化的快照隔離](ch7.md#可序列化的快照隔離(SSI))透過跟蹤事務之間的因果依賴來檢測寫偏差。 -* 在愛麗絲和鮑勃看球的例子中([圖9-1](img/fig9-1.png)),在聽到愛麗絲驚呼比賽結果後,鮑勃從伺服器得到陳舊結果的事實違背了因果關係:愛麗絲的驚呼因果依賴於得分宣告,所以鮑勃應該也能在聽到愛麗斯驚呼後查詢到比分。相同的模式在“[跨通道的時序依賴](#跨通道的時序依賴)”一節中,以“影象大小調整服務”的偽裝再次出現。 +* 在事務快照隔離的上下文中(“[快照隔離和可重複讀](ch7.md#快照隔離和可重複讀)”),我們說事務是從一致性快照中讀取的。但此語境中“一致”到底又是什麼意思?這意味著**與因果關係保持一致(consistent with causality)**:如果快照包含答案,它也必須包含被回答的問題【48】。在某個時間點觀察整個資料庫,與因果關係保持一致意味著:因果上在該時間點之前發生的所有操作,其影響都是可見的,但因果上在該時間點之後發生的操作,其影響對觀察者不可見。**讀偏差(read skew)**意味著讀取的資料處於違反因果關係的狀態(不可重複讀,如[圖7-6](../img/fig7-6)所示)。 +* 事務之間**寫偏差(write skew)**的例子(參見“[寫偏差和幻象](ch7.md#寫偏差和幻象)”)也說明了因果依賴:在[圖7-8](../img/fig7-8.png)中,愛麗絲被允許離班,因為事務認為鮑勃仍在值班,反之亦然。在這種情況下,離班的動作因果依賴於對當前值班情況的觀察。[可序列化的快照隔離](ch7.md#可序列化的快照隔離(SSI))透過跟蹤事務之間的因果依賴來檢測寫偏差。 +* 在愛麗絲和鮑勃看球的例子中([圖9-1](../img/fig9-1.png)),在聽到愛麗絲驚呼比賽結果後,鮑勃從伺服器得到陳舊結果的事實違背了因果關係:愛麗絲的驚呼因果依賴於得分宣告,所以鮑勃應該也能在聽到愛麗斯驚呼後查詢到比分。相同的模式在“[跨通道的時序依賴](#跨通道的時序依賴)”一節中,以“影象大小調整服務”的偽裝再次出現。 因果關係對事件施加了一種**順序**:因在果之前;訊息傳送在訊息收取之前。而且就像現實生活中一樣,一件事會導致另一件事:某個節點讀取了一些資料然後寫入一些結果,另一個節點讀取其寫入的內容,並依次寫入一些其他內容,等等。這些因果依賴的操作鏈定義了系統中的因果順序,即,什麼在什麼之前發生。 @@ -350,7 +350,7 @@ ***線性一致性*** -​ 線上性一致的系統中,操作是全序的:如果系統表現的就好像只有一個數據副本,並且所有操作都是原子性的,這意味著對任何兩個操作,我們總是能判定哪個操作先發生。這個全序[圖9-4](img/fig9-4.png)中以時間線表示。 +​ 線上性一致的系統中,操作是全序的:如果系統表現的就好像只有一個數據副本,並且所有操作都是原子性的,這意味著對任何兩個操作,我們總是能判定哪個操作先發生。這個全序[圖9-4](../img/fig9-4.png)中以時間線表示。 ***因果性*** @@ -358,13 +358,13 @@ ​ 因此,根據這個定義,線上性一致的資料儲存中是不存在併發操作的:必須有且僅有一條時間線,所有的操作都在這條時間線上,構成一個全序關係。可能有幾個請求在等待處理,但是資料儲存確保了每個請求都是在唯一時間線上的某個時間點自動處理的,不存在任何併發。 -​ 併發意味著時間線會分岔然後合併 —— 在這種情況下,不同分支上的操作是無法比較的(即併發操作)。在[第五章](ch5.md)中我們看到了這種現象:例如,[圖5-14](img/fig5-14.md) 並不是一條直線的全序關係,而是一堆不同的操作併發進行。圖中的箭頭指明瞭因果依賴 —— 操作的偏序。 +​ 併發意味著時間線會分岔然後合併 —— 在這種情況下,不同分支上的操作是無法比較的(即併發操作)。在[第五章](ch5.md)中我們看到了這種現象:例如,[圖5-14](../img/fig5-14.md) 並不是一條直線的全序關係,而是一堆不同的操作併發進行。圖中的箭頭指明瞭因果依賴 —— 操作的偏序。 ​ 如果你熟悉像Git這樣的分散式版本控制系統,那麼其版本歷史與因果關係圖極其相似。通常,一個**提交(Commit)**發生在另一個提交之後,在一條直線上。但是有時你會遇到分支(當多個人同時在一個專案上工作時),**合併(Merge)**會在這些併發建立的提交相融合時建立。 #### 線性一致性強於因果一致性 -​ 那麼因果順序和線性一致性之間的關係是什麼?答案是線性一致性**隱含著(implies)**因果關係:任何線性一致的系統都能正確保持因果性【7】。特別是,如果系統中有多個通訊通道(如[圖9-5](img/fig9-5.png) 中的訊息佇列和檔案儲存服務),線性一致性可以自動保證因果性,系統無需任何特殊操作(如在不同元件間傳遞時間戳)。 +​ 那麼因果順序和線性一致性之間的關係是什麼?答案是線性一致性**隱含著(implies)**因果關係:任何線性一致的系統都能正確保持因果性【7】。特別是,如果系統中有多個通訊通道(如[圖9-5](../img/fig9-5.png) 中的訊息佇列和檔案儲存服務),線性一致性可以自動保證因果性,系統無需任何特殊操作(如在不同元件間傳遞時間戳)。 ​ 線性一致性確保因果性的事實使線性一致系統變得簡單易懂,更有吸引力。然而,正如“[線性一致性的代價](#線性一致性的代價)”中所討論的,使系統線性一致可能會損害其效能和可用性,尤其是在系統具有嚴重的網路延遲的情況下(例如,如果系統在地理上散佈)。出於這個原因,一些分散式資料系統已經放棄了線性一致性,從而獲得更好的效能,但它們用起來也更為困難。 @@ -384,7 +384,7 @@ ​ 用於確定*哪些操作發生在其他操作之前* 的技術,與我們在“[檢測併發寫入](ch5.md#檢測併發寫入)”中所討論的內容類似。那一節討論了無領導者資料儲存中的因果性:為了防止丟失更新,我們需要檢測到對同一個鍵的併發寫入。因果一致性則更進一步:它需要跟蹤整個資料庫中的因果依賴,而不僅僅是一個鍵。可以推廣版本向量以解決此類問題【54】。 -​ 為了確定因果順序,資料庫需要知道應用讀取了哪個版本的資料。這就是為什麼在 [圖5-13 ](img/fig5-13.png)中,來自先前操作的版本號在寫入時被傳回到資料庫的原因。在SSI 的衝突檢測中會出現類似的想法,如“[可序列化的快照隔離(SSI)]()”中所述:當事務要提交時,資料庫將檢查它所讀取的資料版本是否仍然是最新的。為此,資料庫跟蹤哪些資料被哪些事務所讀取。 +​ 為了確定因果順序,資料庫需要知道應用讀取了哪個版本的資料。這就是為什麼在 [圖5-13 ](../img/fig5-13.png)中,來自先前操作的版本號在寫入時被傳回到資料庫的原因。在SSI 的衝突檢測中會出現類似的想法,如“[可序列化的快照隔離(SSI)]()”中所述:當事務要提交時,資料庫將檢查它所讀取的資料版本是否仍然是最新的。為此,資料庫跟蹤哪些資料被哪些事務所讀取。 @@ -417,7 +417,7 @@ * 每個節點每秒可以處理不同數量的操作。因此,如果一個節點產生偶數序列號而另一個產生奇數序列號,則偶數計數器可能落後於奇數計數器,反之亦然。如果你有一個奇數編號的操作和一個偶數編號的操作,你無法準確地說出哪一個操作在因果上先發生。 -* 來自物理時鐘的時間戳會受到時鐘偏移的影響,這可能會使其與因果不一致。例如[圖8-3](img/fig8-3.png) 展示了一個例子,其中因果上晚發生的操作,卻被分配了一個更早的時間戳。[^vii] +* 來自物理時鐘的時間戳會受到時鐘偏移的影響,這可能會使其與因果不一致。例如[圖8-3](../img/fig8-3.png) 展示了一個例子,其中因果上晚發生的操作,卻被分配了一個更早的時間戳。[^vii] [^viii]: 可以使物理時鐘時間戳與因果關係保持一致:在“[用於全域性快照的同步時鐘](#用於全域性快照的同步時鐘)”中,我們討論了Google的Spanner,它可以估計預期的時鐘偏差,並在提交寫入之前等待不確定性間隔。 這中方法確保了實際上靠後的事務會有更大的時間戳。 但是大多數時鐘不能提供這種所需的不確定性度量。 @@ -429,9 +429,9 @@ ​ 儘管剛才描述的三個序列號生成器與因果不一致,但實際上有一個簡單的方法來產生與因果關係一致的序列號。它被稱為蘭伯特時間戳,萊斯利·蘭伯特(Leslie Lamport)於1978年提出【56】,現在是分散式系統領域中被引用最多的論文之一。 -​ [圖9-8](img/fig9-8.png) 說明了蘭伯特時間戳的應用。每個節點都有一個唯一識別符號,和一個儲存自己執行運算元量的計數器。 蘭伯特時間戳就是兩者的簡單組合:(計數器,節點ID)$(counter, node ID)$。兩個節點有時可能具有相同的計數器值,但透過在時間戳中包含節點ID,每個時間戳都是唯一的。 +​ [圖9-8](../img/fig9-8.png) 說明了蘭伯特時間戳的應用。每個節點都有一個唯一識別符號,和一個儲存自己執行運算元量的計數器。 蘭伯特時間戳就是兩者的簡單組合:(計數器,節點ID)$(counter, node ID)$。兩個節點有時可能具有相同的計數器值,但透過在時間戳中包含節點ID,每個時間戳都是唯一的。 -![](img/fig9-8.png) +![](../img/fig9-8.png) **圖9-8 Lamport時間戳提供了與因果關係一致的總排序。** @@ -440,7 +440,7 @@ ​ 迄今,這個描述與上節所述的奇偶計數器基本類似。使蘭伯特時間戳因果一致的關鍵思想如下所示:每個節點和每個客戶端跟蹤迄今為止所見到的最大**計數器**值,並在每個請求中包含這個最大計數器值。當一個節點收到最大計數器值大於自身計數器值的請求或響應時,它立即將自己的計數器設定為這個最大值。 -​ 這如 [圖9-8](img/fig9-8.png) 所示,其中客戶端 A 從節點2 接收計數器值 `5` ,然後將最大值 `5` 傳送到節點1 。此時,節點1 的計數器僅為 `1` ,但是它立即前移至 `5` ,所以下一個操作的計數器的值為 `6` 。 +​ 這如 [圖9-8](../img/fig9-8.png) 所示,其中客戶端 A 從節點2 接收計數器值 `5` ,然後將最大值 `5` 傳送到節點1 。此時,節點1 的計數器僅為 `1` ,但是它立即前移至 `5` ,所以下一個操作的計數器的值為 `6` 。 ​ 只要每一個操作都攜帶著最大計數器值,這個方案確保蘭伯特時間戳的排序與因果一致,因為每個因果依賴都會導致時間戳增長。 @@ -504,7 +504,7 @@ #### 使用全序廣播實現線性一致的儲存 -​ 如 [圖9-4](img/fig9-4.png) 所示,線上性一致的系統中,存在操作的全序。這是否意味著線性一致與全序廣播一樣?不盡然,但兩者之間有者密切的聯絡[^x]。 +​ 如 [圖9-4](../img/fig9-4.png) 所示,線上性一致的系統中,存在操作的全序。這是否意味著線性一致與全序廣播一樣?不盡然,但兩者之間有者密切的聯絡[^x]。 [^x]: 從形式上講,線性一致讀寫暫存器是一個“更容易”的問題。 全序廣播等價於共識【67】,而共識問題在非同步的崩潰-停止模型【68】中沒有確定性的解決方案,而線性一致的讀寫暫存器**可以**在這種模型中實現【23,24,25】。 然而,支援諸如**比較並設定(CAS, compare-and-set)**,或**自增並返回(increment-and-get)**的原子操作使它等價於共識問題【28】。 因此,共識問題與線性一致暫存器問題密切相關。 @@ -601,7 +601,7 @@ * 某些提交請求可能在網路中丟失,最終由於超時而中止,而其他提交請求則透過。 * 在提交記錄完全寫入之前,某些節點可能會崩潰,並在恢復時回滾,而其他節點則成功提交。 -如果某些節點提交了事務,但其他節點卻放棄了這些事務,那麼這些節點就會彼此不一致(如 [圖7-3](img/fig7-3.png) 所示)。而且一旦在某個節點上提交了一個事務,如果事後發現它在其它節點上被中止了,它是無法撤回的。出於這個原因,一旦確定事務中的所有其他節點也將提交,節點就必須進行提交。 +如果某些節點提交了事務,但其他節點卻放棄了這些事務,那麼這些節點就會彼此不一致(如 [圖7-3](../img/fig7-3.png) 所示)。而且一旦在某個節點上提交了一個事務,如果事後發現它在其它節點上被中止了,它是無法撤回的。出於這個原因,一旦確定事務中的所有其他節點也將提交,節點就必須進行提交。 ​ 事務提交必須是不可撤銷的 —— 事務提交之後,你不能改變主意,並追溯性地中止事務。這個規則的原因是,一旦資料被提交,其結果就對其他事務可見,因此其他客戶端可能會開始依賴這些資料。這個原則構成了**讀已提交**隔離等級的基礎,在“[讀已提交](ch7.md#讀已提交)”一節中討論了這個問題。如果一個事務在提交後被允許中止,所有那些讀取了**已提交卻又被追溯宣告不存在資料**的事務也必須回滾。 @@ -611,9 +611,9 @@ ​ **兩階段提交(two-phase commit)**是一種用於實現跨多個節點的原子事務提交的演算法,即確保所有節點提交或所有節點中止。 它是分散式資料庫中的經典演算法【13,35,75】。 2PC在某些資料庫內部使用,也以**XA事務**的形式對應用可用【76,77】(例如Java Transaction API支援)或以SOAP Web服務的`WS-AtomicTransaction` 形式提供給應用【78,79】。 -[ 圖9-9](img/fig9-9)說明了2PC的基本流程。2PC中的提交/中止過程分為兩個階段(因此而得名),而不是單節點事務中的單個提交請求。 +[ 圖9-9](../img/fig9-9)說明了2PC的基本流程。2PC中的提交/中止過程分為兩個階段(因此而得名),而不是單節點事務中的單個提交請求。 -![](img/fig9-9.png) +![](../img/fig9-9.png) **圖9-9 兩階段提交(2PC)的成功執行** @@ -653,9 +653,9 @@ ​ 如果協調者在傳送**準備**請求之前失敗,參與者可以安全地中止事務。但是,一旦參與者收到了準備請求並投了“是”,就不能再單方面放棄 —— 必須等待協調者回答事務是否已經提交或中止。如果此時協調者崩潰或網路出現故障,參與者什麼也做不了只能等待。參與者的這種事務狀態稱為**存疑(in doubt)**的或**不確定(uncertain)**的。 -​ 情況如[圖9-10](img/fig9-10) 所示。在這個特定的例子中,協調者實際上決定提交,資料庫2 收到提交請求。但是,協調者在將提交請求傳送到資料庫1 之前發生崩潰,因此資料庫1 不知道是否提交或中止。即使**超時**在這裡也沒有幫助:如果資料庫1 在超時後單方面中止,它將最終與執行提交的資料庫2 不一致。同樣,單方面提交也是不安全的,因為另一個參與者可能已經中止了。 +​ 情況如[圖9-10](../img/fig9-10) 所示。在這個特定的例子中,協調者實際上決定提交,資料庫2 收到提交請求。但是,協調者在將提交請求傳送到資料庫1 之前發生崩潰,因此資料庫1 不知道是否提交或中止。即使**超時**在這裡也沒有幫助:如果資料庫1 在超時後單方面中止,它將最終與執行提交的資料庫2 不一致。同樣,單方面提交也是不安全的,因為另一個參與者可能已經中止了。 -![](img/fig9-10.png) +![](../img/fig9-10.png)  **圖9-10 參與者投贊成票後,協調者崩潰。資料庫1不知道是否提交或中止** ​ 沒有協調者的訊息,參與者無法知道是提交還是放棄。原則上參與者可以相互溝通,找出每個參與者是如何投票的,並達成一致,但這不是2PC協議的一部分。 @@ -718,7 +718,7 @@ ​ 問題在於**鎖(locking)**。正如在“[讀已提交](ch7.md#讀已提交)”中所討論的那樣,資料庫事務通常獲取待修改的行上的**行級排他鎖**,以防止髒寫。此外,如果要使用可序列化的隔離等級,則使用兩階段鎖定的資料庫也必須為事務所讀取的行加上共享鎖(參見“[兩階段鎖定(2PL)](ch7.md#兩階段鎖定(2PL))”)。 -​ 在事務提交或中止之前,資料庫不能釋放這些鎖(如[圖9-9](img/fig9-9.png)中的陰影區域所示)。因此,在使用兩階段提交時,事務必須在整個存疑期間持有這些鎖。如果協調者已經崩潰,需要20分鐘才能重啟,那麼這些鎖將會被持有20分鐘。如果協調者的日誌由於某種原因徹底丟失,這些鎖將被永久持有 —— 或至少在管理員手動解決該情況之前。 +​ 在事務提交或中止之前,資料庫不能釋放這些鎖(如[圖9-9](../img/fig9-9.png)中的陰影區域所示)。因此,在使用兩階段提交時,事務必須在整個存疑期間持有這些鎖。如果協調者已經崩潰,需要20分鐘才能重啟,那麼這些鎖將會被持有20分鐘。如果協調者的日誌由於某種原因徹底丟失,這些鎖將被永久持有 —— 或至少在管理員手動解決該情況之前。 ​ 當這些鎖被持有時,其他事務不能修改這些行。根據資料庫的不同,其他事務甚至可能因為讀取這些行而被阻塞。因此,其他事務沒法兒簡單地繼續它們的業務了 —— 如果它們要訪問同樣的資料,就會被阻塞。這可能會導致應用大面積進入不可用狀態,直到存疑事務被解決。 diff --git a/zh-tw/part-ii.md b/zh-tw/part-ii.md index 29b07ae2..f6913bf8 100644 --- a/zh-tw/part-ii.md +++ b/zh-tw/part-ii.md @@ -59,9 +59,9 @@ ​ 將一個大型資料庫拆分成較小的子集(稱為**分割槽(partitions)**),從而不同的分割槽可以指派給不同的**節點(node)**(亦稱**分片(shard)**)。 [第六章](ch6.md)將討論分割槽。 -複製和分割槽是不同的機制,但它們經常同時使用。如[圖II-1](img/figii-1.png)所示。 +複製和分割槽是不同的機制,但它們經常同時使用。如[圖II-1](../img/figii-1.png)所示。 -![](img/figii-1.png) +![](../img/figii-1.png) **圖II-1 一個數據庫切分為兩個分割槽,每個分割槽都有兩個副本** From f3124bc80fadf282aa443336e45ac1c71e68bffc Mon Sep 17 00:00:00 2001 From: afunTW Date: Sat, 10 Oct 2020 18:30:47 +0800 Subject: [PATCH 10/12] update: revise file structure --- zh-cn/README.md => README.md | 0 zh-cn/SUMMARY.md => SUMMARY.md | 0 zh-cn/ch1.md => ch1.md | 26 ++++---- zh-cn/ch10.md => ch10.md | 22 +++---- zh-cn/ch11.md => ch11.md | 48 +++++++-------- zh-cn/ch12.md => ch12.md | 12 ++-- zh-cn/ch2.md => ch2.md | 42 ++++++------- zh-cn/ch3.md => ch3.md | 68 ++++++++++----------- zh-cn/ch4.md => ch4.md | 32 +++++----- zh-cn/ch5.md => ch5.md | 78 ++++++++++++------------ zh-cn/ch6.md => ch6.md | 34 +++++------ zh-cn/ch7.md => ch7.md | 50 ++++++++-------- zh-cn/ch8.md => ch8.md | 28 ++++----- zh-cn/ch9.md => ch9.md | 88 ++++++++++++++-------------- zh-cn/colophon.md => colophon.md | 0 zh-cn/glossary.md => glossary.md | 0 zh-cn/part-i.md => part-i.md | 0 zh-cn/part-ii.md => part-ii.md | 4 +- zh-cn/part-iii.md => part-iii.md | 0 zh-cn/preface.md => preface.md | 0 translate.py => scripts/translate.py | 0 21 files changed, 266 insertions(+), 266 deletions(-) rename zh-cn/README.md => README.md (100%) rename zh-cn/SUMMARY.md => SUMMARY.md (100%) rename zh-cn/ch1.md => ch1.md (96%) rename zh-cn/ch10.md => ch10.md (98%) rename zh-cn/ch11.md => ch11.md (96%) rename zh-cn/ch12.md => ch12.md (99%) rename zh-cn/ch2.md => ch2.md (95%) rename zh-cn/ch3.md => ch3.md (93%) rename zh-cn/ch4.md => ch4.md (97%) rename zh-cn/ch5.md => ch5.md (93%) rename zh-cn/ch6.md => ch6.md (95%) rename zh-cn/ch7.md => ch7.md (96%) rename zh-cn/ch8.md => ch8.md (97%) rename zh-cn/ch9.md => ch9.md (94%) rename zh-cn/colophon.md => colophon.md (100%) rename zh-cn/glossary.md => glossary.md (100%) rename zh-cn/part-i.md => part-i.md (100%) rename zh-cn/part-ii.md => part-ii.md (99%) rename zh-cn/part-iii.md => part-iii.md (100%) rename zh-cn/preface.md => preface.md (100%) rename translate.py => scripts/translate.py (100%) diff --git a/zh-cn/README.md b/README.md similarity index 100% rename from zh-cn/README.md rename to README.md diff --git a/zh-cn/SUMMARY.md b/SUMMARY.md similarity index 100% rename from zh-cn/SUMMARY.md rename to SUMMARY.md diff --git a/zh-cn/ch1.md b/ch1.md similarity index 96% rename from zh-cn/ch1.md rename to ch1.md index 463317ba..42555e35 100644 --- a/zh-cn/ch1.md +++ b/ch1.md @@ -1,6 +1,6 @@ # 第一章:可靠性,可扩展性,可维护性 -![](../img/ch1.png) +![](img/ch1.png) > 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术, 你还记得是什么时候吗? > @@ -40,9 +40,9 @@ ​ 其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。 -​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](../img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 +​ 例如,如果将缓存(应用管理的缓存层,Memcached或同类产品)和全文搜索(全文搜索服务器,例如Elasticsearch或Solr)功能从主数据库剥离出来,那么使缓存/索引与主数据库保持同步通常是应用代码的责任。[图1-1](img/fig1-1.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。 -![](../img/fig1-1.png) +![](img/fig1-1.png) **图1-1 一个可能的组合使用多个组件的数据系统架构** @@ -174,7 +174,7 @@ 大体上讲,这一对操作有两种实现方式。 -1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](../img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: +1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如[图1-2](img/fig1-2.png)所示的关系型数据库中,可以编写这样的查询: ```sql SELECT tweets.*, users.* @@ -183,13 +183,13 @@ JOIN follows ON follows.followee_id = users.id WHERE follows.follower_id = current_user ``` - ![](../img/fig1-2.png) + ![](img/fig1-2.png) **图1-2 推特主页时间线的关系型模式简单实现** -2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](../img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 +2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图1-3](img/fig1-3.png))。 当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。 因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。 - ![](../img/fig1-3.png) + ![](img/fig1-3.png) **图1-3 用于分发推特至关注者的数据流水线,2012年11月的负载参数【16】** @@ -220,9 +220,9 @@ ​ 即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值**分布(distribution)**,而不是单个数值。 -​ 在[图1-4](../img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 +​ 在[图1-4](img/fig1-4.png)中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。 -![](../img/fig1-4.png) +![](img/fig1-4.png) **图1-4 展示了一个服务100次请求响应时间的均值与百分位数** @@ -232,7 +232,7 @@ ​ 如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第50百分位点,有时缩写为p50。注意中位数是关于单个请求的;如果用户同时发出几个请求(在一个会话过程中,或者由于一个页面中包含了多个资源),则至少一个请求比中位数慢的概率远大于50%。 -​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](../img/fig1-4.png)所示。 +​ 为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第95、99和99.9百分位点(缩写为p95,p99和p999)。它们意味着95%,99%或99.9%的请求响应时间要比该阈值快,例如:如果第95百分位点响应时间是1.5秒,则意味着100个请求中的95个响应时间快于1.5秒,而100个请求中的5个响应时间超过1.5秒。如[图1-4](img/fig1-4.png)所示。 ​ 响应时间的高百分位点(也称为**尾部延迟(tail latencies)**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时以99.9百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要,亚马逊观察到:响应时间增加100毫秒,销售量就减少1%【20】;而另一些报告说:慢 1 秒钟会让客户满意度指标减少16%【21,22】。 @@ -246,13 +246,13 @@ > #### 实践中的百分位点 > -> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](../img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 +> ​ 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如[图1-5](img/fig1-5.png)所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(效果称为尾部延迟放大【24】)。 > > ​ 如果您想将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。每一分钟,您都会计算出该窗口中的中值和各种百分数,并将这些度量值绘制在图上。 > > ​ 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的CPU和内存成本(如前向衰减【25】,t-digest【26】或HdrHistogram 【27】)来计算百分位数的近似值。请注意,平均百分比(例如,减少时间分辨率或合并来自多台机器的数据)在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。 -![](../img/fig1-5.png) +![](img/fig1-5.png) **图1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求** @@ -376,7 +376,7 @@ ​ 不幸的是,使应用可靠、可扩展或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。 -​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](../img/fig1-1.png)中的例子) +​ 在本书后面的[第三部分](part-iii.md)中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如[图1-1](img/fig1-1.png)中的例子) diff --git a/zh-cn/ch10.md b/ch10.md similarity index 98% rename from zh-cn/ch10.md rename to ch10.md index 58de73f4..d039ab24 100644 --- a/zh-cn/ch10.md +++ b/ch10.md @@ -1,6 +1,6 @@ # 10. 批处理 -![](../img/ch10.png) +![](img/ch10.png) > 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。 > @@ -247,11 +247,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 在分布式计算中可以使用标准的Unix工具作为Mapper和Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在Hadoop MapReduce中,Mapper和Reducer都是实现特定接口的Java类。在MongoDB和CouchDB中,Mapper和Reducer都是JavaScript函数(参阅“[MapReduce查询](ch2.md#MapReduce查询)”)。 -​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](../img/fig10-1.png)中的m1,m2和m3标记)。 +​ [图10-1]()显示了Hadoop MapReduce作业中的数据流。其并行化基于分区(参见[第6章](ch6.md)):作业的输入通常是HDFS中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理map任务([图10-1](img/fig10-1.png)中的m1,m2和m3标记)。 ​ 每个输入文件的大小通常是数百兆字节。 MapReduce调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个Mapper,只要该机器有足够的备用RAM和CPU资源来运行Mapper任务【26】。这个原则被称为**将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。 -![](../img/fig10-1.png) +![](img/fig10-1.png) **图10-1 具有三个Mapper和三个Reducer的MapReduce任务** @@ -297,9 +297,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 示例:分析用户活动事件 -​ [图10-2](../img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 +​ [图10-2](img/fig10-2.png)给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为**活动事件(activity events)**或**点击流数据(clickstream data)**),右侧是用户数据库。 你可以将此示例看作是星型模式的一部分(参阅“[星型和雪花型:分析的模式](ch3.md#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。 -![](../img/fig10-2.png) +![](img/fig10-2.png) **图10-2 用户行为日志与用户档案的连接** @@ -313,9 +313,9 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 排序合并连接 -​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](../img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](../img/fig10-3.png)所示。 +​ 回想一下,Mapper的目的是从每个输入记录中提取一对键值。在[图10-2](img/fig10-2.png)的情况下,这个键就是用户ID:一组Mapper会扫过活动事件(提取用户ID作为键,活动事件作为值),而另一组Mapper将会扫过用户数据库(提取用户ID作为键,用户的出生日期作为值)。这个过程如[图10-3](img/fig10-3.png)所示。 -![](../img/fig10-3.png) +![](img/fig10-3.png) **图10-3 在用户ID上进行的Reduce端连接。如果输入数据集分区为多个文件,则每个分区都会被多个Mapper并行处理** @@ -375,11 +375,11 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 ​ 适用于执行Map端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个Mapper的内存中。 -​ 例如,假设在[图10-2](../img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 +​ 例如,假设在[图10-2](img/fig10-2.png)的情况下,用户数据库小到足以放进内存中。在这种情况下,当Mapper启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列中。完成此操作后,Map程序可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户ID[^vi]。 [^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户ID唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。 -​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](../img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 +​ 参与连接的较大输入的每个文件块各有一个Mapper(在[图10-2](img/fig10-2.png)的例子中活动事件是较大的输入)。每个Mapper都会将较小输入整个加载到内存中。 ​ 这种简单有效的算法被称为**广播散列连接(broadcast hash join)**:**广播**一词反映了这样一个事实,每个连接较大输入端分区的Mapper都会将较小输入端数据集整个读入内存中(所以较小输入实际上“广播”到较大数据的所有分区上),**散列**一词反映了它使用一个散列表。 Pig(名为“**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading和Crunch支持这种连接。它也被诸如Impala的数据仓库查询引擎使用【41】。 @@ -387,7 +387,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 #### 分区散列连接 -​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](../img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 +​ 如果Map端连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在[图10-2](img/fig10-2.png)的情况中,你可以根据用户ID的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有10个分区)。例如,Mapper3首先将所有具有以3结尾的ID的用户加载到散列表中,然后扫描ID为3的每个用户的所有活动事件。 ​ 如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个Mapper只需要从输入两端各读取一个分区就足够了。好处是每个Mapper都可以在内存散列表中少放点数据。 @@ -612,7 +612,7 @@ top5.each{|count, url| puts "#{count} #{url}" } # 5 > 像Spark,Flink和Tez这样的数据流引擎(参见“[中间状态的物化](#中间状态的物化)”)通常将算子作为**有向无环图(DAG)**的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流**被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱! -​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](../img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 +​ 许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在[图2-6](img/fig2-6.png)中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为**闭包传递(transitive closure)**)。 ​ 可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种“重复至完成”的想法不能用普通的MapReduce来表示,因为它只扫过一趟数据。这种算法因此经常以**迭代**的风格实现: diff --git a/zh-cn/ch11.md b/ch11.md similarity index 96% rename from zh-cn/ch11.md rename to ch11.md index 983e63e0..97309aad 100644 --- a/zh-cn/ch11.md +++ b/ch11.md @@ -1,6 +1,6 @@ # 11. 流处理 -![](../img/ch11.png) +![](img/ch11.png) > 有效的复杂系统总是从简单的系统演化而来。 反之亦然:从零设计的复杂系统没一个能有效工作的。 > @@ -94,7 +94,7 @@ #### 多个消费者 -当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](../img/fig11-1.png)所示: +当多个消费者从同一主题中读取消息时,有使用两种主要的消息传递模式,如[图11-1](img/fig11-1.png)所示: ***负载均衡(load balance)*** @@ -104,7 +104,7 @@ ​ 每条消息都被传递给**所有**消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS中的主题订阅与AMQP中的交叉绑定提供了这一功能)。 -![](../img/fig11-1.png) +![](img/fig11-1.png) **图11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。** @@ -116,9 +116,9 @@ ​ 如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。 (请注意可能发生这样的情况,消息**实际上是**处理完毕的,但**确认**在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在“[实践中的分布式事务](ch9.md#实践中的分布式事务)”中所讨论的那样) -​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](../img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 +​ 当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图11-2](img/fig11-2.png)中,消费者通常按照生产者发送的顺序处理消息。然而消费者2在处理消息m3时崩溃,与此同时消费者1正在处理消息m4。未确认的消息m3随后被重新发送给消费者1,结果消费者1按照m4,m3,m5的顺序处理消息。因此m3和m4的交付顺序与以生产者1的发送顺序不同。 -![](../img/fig11-2.png) +![](img/fig11-2.png) **图11-2 在处理m3时消费者2崩溃,因此稍后重传至消费者1** @@ -142,11 +142,11 @@ ​ 同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。 Unix工具`tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。 -​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](../img/fig11-3.png)所示。 +​ 为了扩展到比单个磁盘所能提供的更高吞吐量,可以对日志进行**分区**(在[第6章](ch6.md)的意义上)。不同的分区可以托管在不同的机器上,且每个分区都拆分出一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如[图11-3](img/fig11-3.png)所示。 -​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](../img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 +​ 在每个分区内,代理为每个消息分配一个单调递增的序列号或**偏移量(offset)**(在[图11-3](img/fig11-3.png)中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。 -![](../img/fig11-3.png) +![](img/fig11-3.png) **图11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件** @@ -223,9 +223,9 @@ ​ 如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是**双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。 -​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](../img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 +​ 但是,双写有一些严重的问题,其中一个是竞争条件,如[图11-4](img/fig11-4.png)所示。在这个例子中,两个客户端同时想要更新一个项目X:客户端1想要将值设置为A,客户端2想要将其设置为B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端1的写入将值设置为A,然后来自客户端2的写入将值设置为B,因此数据库中的最终值为B。搜索索引首先看到来自客户端2的写入,然后是客户端1的写入,所以搜索索引中的最终值是A。即使没发生错误,这两个系统现在也永久地不一致了。 -![](../img/fig11-4.png) +![](img/fig11-4.png) **图11-4 在数据库中X首先被设置为A,然后被设置为B,而在搜索索引处,写入以相反的顺序到达** @@ -233,7 +233,7 @@ ​ 双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(参阅“[原子提交和两阶段提交(2PC)](ch7.md#原子提交和两阶段提交(2PC))”)。 -​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](../img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 +​ 如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在[图11-4](img/fig11-4.png)中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(参见“[多领导者复制](ch5.md#多领导者复制)“)。 ​ 如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗? @@ -245,9 +245,9 @@ ​ 最近,人们对**变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC是非常有意思的,尤其是当变更能在被写入后立刻用于流时。 -​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](../img/fig11-5.png)所示。 +​ 例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如[图11-5](img/fig11-5.png)所示。 -![](../img/fig11-5.png) +![](img/fig11-5.png) **图11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统** @@ -255,7 +255,7 @@ ​ 我们可以将日志消费者叫做**衍生数据系统**,正如在第三部分的[介绍](part-iii.md)中所讨论的:存储在搜索索引和数据仓库中的数据,只是**记录系统**数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。 -​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](../img/fig11-2.png)的重新排序问题)。 +​ 从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了[图11-2](img/fig11-2.png)的重新排序问题)。 ​ 数据库触发器可用来实现变更数据捕获(参阅“[基于触发器的复制](ch5.md#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如应对模式变更。 @@ -275,7 +275,7 @@ ​ 如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但**日志压缩(log compaction)** 提供了一个很好的备选方案。 -​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](../img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 +​ 我们之前在日志结构存储引擎的上下文中讨论了“[Hash索引](ch3.md#Hash索引)”中的日志压缩(参见[图3-2](img/fig3-2.png)的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。 ​ 在日志结构存储引擎中,具有特殊值NULL(**墓碑(tombstone)**)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。 @@ -299,7 +299,7 @@ ​ 与变更数据捕获类似,事件溯源涉及到**将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了几个不同的抽象层次上: -* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](../img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 +* 在变更数据捕获中,应用以**可变方式(mutable way)** 使用数据库,任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免[图11-4](img/fig11-4.png)中的竞态条件。写入数据库的应用不需要知道CDC的存在。 * 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。 事件源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件代理使得应用随时间演化更为容易,通过事实更容易理解事情发生的原因,使得调试更为容易,并有利于防止应用Bug(请参阅“[不可变事件的优点](#不可变事件的优点)”)。 @@ -345,12 +345,12 @@ ​ 无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志—— **变化日志(change log)**,表示了随时间演变的状态。 -​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](../img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 +​ 如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如[图11-6](img/fig11-6.png)所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。 $$ state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\ stream(t) = \frac{d\ state(t)}{dt} $$ -![](../img/fig11-6.png) +![](img/fig11-6.png) **图11-6 应用当前状态与事件流之间的关系** @@ -372,7 +372,7 @@ $$ #### 从同一事件日志中派生多个视图 -​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](../img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 +​ 此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图11-5](img/fig11-5.png)):例如,分析型数据库Druid使用这种方式直接从Kafka摄取数据【55】,Pistachio是一个分布式的键值存储,使用Kafka作为提交日志【56】,Kafka Connect能将来自Kafka的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(参阅“[保持系统同步](#保持系统同步)”)。 ​ 添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的,针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。 @@ -412,7 +412,7 @@ $$ 剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项: -1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](../img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 +1. 你可以将事件中的数据写入数据库,缓存,搜索索引或类似的存储系统,然后能被其他客户端查询。如[图11-5](img/fig11-5.png)所示,这是数据库与系统其他部分发生变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如“[批处理工作流的输出](ch10.md#批处理工作流的输出)”中所讨论的,它是写入存储系统的流等价物。 2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。 3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项1或2)。 @@ -507,9 +507,9 @@ $$ [^ii]: 感谢Flink社区的Kostas Kloudas提出这个比喻。 -​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](../img/fig11-7.png))。 +​ 将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图11-7](img/fig11-7.png))。 -![](../img/fig11-7.png) +![](img/fig11-7.png) **图11-7 按处理时间分窗,会因为处理速率的变动引入人为因素** @@ -580,7 +580,7 @@ $$ #### 流表连接(流扩展) -​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](../img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 +​ 在“[示例:用户活动事件分析](ch10.md#示例:用户活动事件分析)”([图10-2](img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户ID的活动事件流,而输出还是活动事件流,但其中用户ID已经被扩展为用户的档案信息。这个过程有时被称为 使用数据库的信息来**扩充(enriching)** 活动事件。 ​ 要执行此联接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在“[示例:分析用户活动事件](ch10.md#示例:分析用户活动事件)”一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。 @@ -615,7 +615,7 @@ GROUP BY follows.follower_id ​ 流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当基础表发生变化时都会更新[^iii]。 -[^iii]: 如果你将流视作表的衍生物,如[图11-6](../img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 +[^iii]: 如果你将流视作表的衍生物,如[图11-6](img/fig11-6.png)所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'(u·v)'= u'v + uv'。 换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。 #### 连接的时间依赖性 diff --git a/zh-cn/ch12.md b/ch12.md similarity index 99% rename from zh-cn/ch12.md rename to ch12.md index 99496056..63b3fd0d 100644 --- a/zh-cn/ch12.md +++ b/ch12.md @@ -1,6 +1,6 @@ # 12. 数据系统的未来 -![](../img/ch12.png) +![](img/ch12.png) > 如果船长的终极目标是保护船只,他应该永远待在港口。 > @@ -42,7 +42,7 @@ ​ 例如,你可能会首先将数据写入**记录数据库**系统,捕获对该数据库所做的变更(参阅“[捕获数据变更](ch11.md#捕获数据变更)”),然后将变更应用于数据库中的搜索索引相同的顺序。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。 -​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](../img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 +​ 允许应用程序直接写入搜索索引和数据库引入了如[图11-4](img/fig11-4.png)所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。 ​ 如果您可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。 这是状态机复制方法的一个应用,我们在“[全序广播](ch9.md#全序广播)”中看到。无论您使用变更数据捕获还是事件源日志,都不如仅对全局顺序达成共识更重要。 @@ -328,9 +328,9 @@ ### 观察衍生数据状态 -​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](../img/fig12-1.png)显示了一个更新搜索索引的例子。 +​ 在抽象层面,上一节讨论的数据流系统提供了创建衍生数据集(例如搜索索引,物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为**写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个衍生数据集都会被更新,以适配写入的数据。[图12-1](img/fig12-1.png)显示了一个更新搜索索引的例子。 -![](../img/fig12-1.png) +![](img/fig12-1.png) **图12-1 在搜索索引中,写(文档更新)遇上读(查询)** @@ -338,7 +338,7 @@ ​ 总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。 -​ 如[图12-1](../img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 +​ 如[图12-1](img/fig12-1.png)所示,衍生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。 #### 物化视图和缓存 @@ -454,7 +454,7 @@ ​ 除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP使用数据包上的序列号,在接收方将它们正确排序。并确定网络上是否有数据包丢失或重复。任何丢失的数据包都会被重新传输,而在将数据交付应用前,TCP协议栈会移除任何重复数据包。 -​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](../img/fig8-1.png))。 +​ 但是,这种重复抑制仅适用于单条TCP连接的场景中。假设TCP连接是一个客户端与数据库的连接,并且它正在执行[例12-1]()中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个TCP连接上发送的)。如果客户端在发送`COMMIT`之后但在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图8-1](img/fig8-1.png))。 **例12-1 资金从一个账户到另一个账户的非幂等转移** diff --git a/zh-cn/ch2.md b/ch2.md similarity index 95% rename from zh-cn/ch2.md rename to ch2.md index ed2dbbfd..4fbc1796 100644 --- a/zh-cn/ch2.md +++ b/ch2.md @@ -1,6 +1,6 @@ # 2. 数据模型与查询语言 -![](../img/ch2.png) +![](img/ch2.png) > 语言的边界就是思想的边界。 > @@ -65,13 +65,13 @@ 像ActiveRecord和Hibernate这样的 **对象关系映射(ORM object-relational mapping)** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。 -![](../img/fig2-1.png) +![](img/fig2-1.png) **图2-1 使用关系型模式来表示领英简介** -例如,[图2-1](../img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: +例如,[图2-1](img/fig2-1.png)展示了如何在关系模式中表示简历(一个LinkedIn简介)。整个简介可以通过一个唯一的标识符`user_id`来标识。像`first_name`和`last_name`这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示: -* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](../img/fig2-1.png)所示。 +* 传统SQL模型(SQL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如[图2-1](img/fig2-1.png)所示。 * 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MS SQL Server和PostgreSQL中都有不同程度的支持【6,7】。JSON数据类型也得到多个数据库的支持,包括IBM DB2,MySQL和PostgreSQL 【8】。 * 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。 @@ -119,11 +119,11 @@ 有一些开发人员认为JSON模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在[第4章](ch4.md)中看到的那样,JSON作为数据编码格式也存在问题。缺乏一个模式往往被认为是一个优势;我们将在“[文档模型中的模式灵活性](#文档模型中的模式灵活性)”中讨论这个问题。 -JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 +JSON表示比[图2-1](img/fig2-1.png)中的多表模式具有更好的**局部性(locality)**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过`user_id`查询每个表),或者在User表与其下属表之间混乱地执行多路连接。而在JSON表示中,所有相关信息都在同一个地方,一个查询就足够了。 -从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](../img/fig2-2.png))。 +从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确(见[图2-2](img/fig2-2.png))。 -![](../img/fig2-2.png) +![](img/fig2-2.png) **图2-2 一对多关系构建了一个树结构** @@ -157,18 +157,18 @@ JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局 ***组织和学校作为实体*** -在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](../img/fig2-3.png),来自LinkedIn的一个例子)。 +在前面的描述中,`organization`(用户工作的公司)和`school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见[图2-3](img/fig2-3.png),来自LinkedIn的一个例子)。 ***推荐*** 假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何建议都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。 -![](../img/fig2-3.png) +![](img/fig2-3.png) **图2-3 公司名不仅是字符串,还是一个指向公司实体的链接(LinkedIn截图)** -[图2-4](../img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 +[图2-4](img/fig2-4.png)阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。 -![](../img/fig2-4.png) +![](img/fig2-4.png) **图2-4 使用多对多关系扩展简历** @@ -178,7 +178,7 @@ JSON表示比[图2-1](../img/fig2-1.png)中的多表模式具有更好的**局 20世纪70年代最受欢迎的业务数据处理数据库是IBM的信息管理系统(IMS),最初是为了阿波罗太空计划的库存管理而开发的,并于1968年有了首次商业发布【13】。目前它仍在使用和维护,运行在IBM大型机的OS/390上【14】。 -IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](../img/fig2-2.png)的JSON结构。 +IMS的设计中使用了一个相当简单的数据模型,称为**层次模型(hierarchical model)**,它与文档数据库使用的JSON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像[图2-2](img/fig2-2.png)的JSON结构。 同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。开发人员必须决定是否复制(非规范化)数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。 @@ -226,7 +226,7 @@ CODASYL中的查询是通过利用遍历记录列和跟随访问路径表在数 #### 哪个数据模型更方便写代码? -如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](../img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 +如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如[图2-1](img/fig2-1.png)中的`positions`,`education`和`contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。 文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。 @@ -274,7 +274,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 查询的数据局部性 -文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](../img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 +文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的BSON)。如果应用程序经常需要访问整个文档(例如,将其渲染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如[图2-1](img/fig2-1.png)所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。 局部性仅仅适用于同时需要文档绝大部分内容的情况。数据库通常需要加载整个文档,即使只访问其中的一小部分,这对于大型文档来说是很浪费的。更新文档时,通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此,通常建议保持相对小的文档,并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。 @@ -533,9 +533,9 @@ db.observations.aggregate([ 在刚刚给出的例子中,图中的所有顶点代表了相同类型的事物(人,网页或交叉路口)。不过,图并不局限于这样的同类数据:同样强大地是,图提供了一种一致的方式,用来在单个数据存储中存储完全不同类型的对象。例如,Facebook维护一个包含许多不同类型的顶点和边的单个图:顶点表示人,地点,事件,签到和用户的评论;边缘表示哪些人是彼此的朋友,哪个签到发生在何处,谁评论了哪条消息,谁参与了哪个事件,等等【35】。 -在本节中,我们将使用[图2-5](../img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 +在本节中,我们将使用[图2-5](img/fig2-5.png)所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的Lucy和来自法国Beaune的Alain。他们已婚,住在伦敦。 -![](../img/fig2-5.png) +![](img/fig2-5.png) **图2-5 图数据结构示例(框代表顶点,箭头代表边)** @@ -586,7 +586,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动。(这就是为什么[例2-2]()在`tail_vertex`和`head_vertex`列上都有索引的原因。) 3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。 -这些特性为数据建模提供了很大的灵活性,如[图2-5](../img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 +这些特性为数据建模提供了很大的灵活性,如[图2-5](img/fig2-5.png)所示。图中显示了一些传统关系模式难以表达的事情,例如不同国家的不同地区结构(法国有省和州,美国有不同的州和州),国中国的怪事(先忽略主权国家和国家错综复杂的烂摊子),不同的数据粒度(Lucy现在的住所被指定为一个城市,而她的出生地点只是在一个州的级别)。 你可以想象延伸图还能包括许多关于Lucy和Alain,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图表在可演化性是富有优势的:当向应用程序添加功能时,可以轻松扩展图以适应应用程序数据结构的变化。 @@ -594,7 +594,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); Cypher是属性图的声明式查询语言,为Neo4j图形数据库而发明【37】。(它是以电影“黑客帝国”中的一个角色来命名的,而与密码术中的密码无关【38】。) -[例2-3]()显示了将[图2-5](../img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 +[例2-3]()显示了将[图2-5](img/fig2-5.png)的左边部分插入图形数据库的Cypher查询。可以类似地添加图的其余部分,为了便于阅读而省略。每个顶点都有一个像`USA`或`Idaho`这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`(Idaho) - [:WITHIN] ->(USA)`创建一条标记为`WITHIN`的边,`Idaho`为尾节点,`USA`为头节点。 **例2-3 将图2-5中的数据子集表示为Cypher查询** @@ -608,7 +608,7 @@ CREATE (Lucy) -[:BORN_IN]-> (Idaho) ``` -当[图2-5](../img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 +当[图2-5](img/fig2-5.png)的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的`name`属性:该顶点拥有一条连到美国任一位置的`BORN_IN`边,和一条连到欧洲的任一位置的`LIVING_IN`边。 [例2-4]()展示了如何在Cypher中表达这个查询。在MATCH子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配`BORN_IN`边的任意两个顶点。该边的尾节点被绑定了变量`person`,头节点则未被绑定。 @@ -896,9 +896,9 @@ Cypher和SPARQL使用SELECT立即跳转,但是Datalog一次只进行一小步 2. 数据库存在`within(usa, namerica)`,在上一步骤中生成`within_recursive(namerica, 'North America')`,故运用规则2。它会产生`within_recursive(usa, 'North America')`。 3. 数据库存在`within(idaho, usa)`,在上一步生成`within_recursive(usa, 'North America')`,故运用规则2。它产生`within_recursive(idaho, 'North America')`。 -通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](../img/fig2-6.png)所示。 +通过重复应用规则1和2,`within_recursive`谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如[图2-6](img/fig2-6.png)所示。 -![](../img/fig2-6.png) +![](img/fig2-6.png) **图2-6 使用示例2-11中的Datalog规则来确定爱达荷州在北美。** diff --git a/zh-cn/ch3.md b/ch3.md similarity index 93% rename from zh-cn/ch3.md rename to ch3.md index 881e8281..8cbf6fed 100644 --- a/zh-cn/ch3.md +++ b/ch3.md @@ -1,6 +1,6 @@ # 3. 存储与检索 -![](../img/ch3.png) +![](img/ch3.png) > 建立秩序,省却搜索 > @@ -83,9 +83,9 @@ $ cat database 键值存储与在大多数编程语言中可以找到的**字典(dictionary)**类型非常相似,通常字典都是用**散列映射(hash map)**(或**哈希表(hash table)**)实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有**内存中**数据结构 —— 哈希映射,为什么不使用它来索引在**磁盘上**的数据呢? -假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](../img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 +假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如[图3-1](img/fig3-1.png)所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量,**寻找(seek)** 该位置并读取该值。 -![](../img/fig3-1.png) +![](img/fig3-1.png) **图3-1 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引。** @@ -93,15 +93,15 @@ $ cat database 像Bitcask这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。 -直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](../img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 +直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行**压缩(compaction)**,如[图3-2](img/fig3-2.png)所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。 -![](../img/fig3-2.png) +![](img/fig3-2.png) **图3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值** -而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](../img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 +而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如[图3-3](img/fig3-3.png)所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。 -![](../img/fig3-3.png) +![](img/fig3-3.png) **图3-3 同时执行压缩和分段合并** @@ -148,30 +148,30 @@ $ cat database ### SSTables和LSM树 -在[图3-3](../img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 +在[图3-3](img/fig3-3.png)中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。 现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。 我们把这个格式称为**排序字符串表(Sorted String Table)**,简称SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable有几个很大的优势: -1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](../img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 +1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如[图3-4](img/fig3-4.png)所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。 - ![](../img/fig3-4.png) + ![](img/fig3-4.png) ##### 图3-4 合并几个SSTable段,只保留每个键的最新值 如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。 -2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](../img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 +2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以[图3-5](img/fig3-5.png)为例:假设你正在内存中寻找键 `handiwork`,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 `handbag` 和 `handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着您可以跳到 `handbag` 的偏移位置并从那里扫描,直到您找到 `handiwork`(或没找到,如果该文件中没有该键)。 - ![](../img/fig3-5.png) + ![](img/fig3-5.png) **图3-5 具有内存索引的SSTable** 您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描[^i]。 -3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](../img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 +3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如[图3-5](img/fig3-5.png)中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。 [^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) @@ -217,25 +217,25 @@ Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使 我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。 -每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](../img/fig3-6.png)所示。 +每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如[图3-6](img/fig3-6.png)所示。 -![](../img/fig3-6.png) +![](img/fig3-6.png) **图3-6 使用B树索引查找一个键** 一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。 -在[图3-6](../img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 +在[图3-6](img/fig3-6.png)的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了200 - 300到子范围。 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。 -在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](../img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 +在B树的一个页面中对子页面的引用的数量称为分支因子。例如,在[图3-6](img/fig3-6.png)中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。 -如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](../img/fig3-7.png)所示[^ii]。 +如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如[图3-7](img/fig3-7.png)所示[^ii]。 [^ii]: 向B树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 -![](../img/fig3-7.png) +![](img/fig3-7.png) **图3-7 通过分割页面来生长B树** @@ -299,7 +299,7 @@ B树在数据库体系结构中是非常根深蒂固的,为许多工作负载 到目前为止,我们只讨论了关键值索引,它们就像关系模型中的**主键(primary key)** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。 -有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](../img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 +有二级索引也很常见。在关系数据库中,您可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在[第2章](ch2.md)中的[图2-1](img/fig2-1.png)中,很可能在 `user_id` 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。 一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B树和日志结构索引都可以用作辅助索引。 @@ -398,9 +398,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 这些OLTP系统往往对业务运作至关重要,因而通常会要求 **高可用** 与 **低延迟**。所以DBA会密切关注他们的OLTP数据库,他们通常不愿意让业务分析人员在OLTP数据库上运行临时分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时执行的事务的性能。 -相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](../img/fig3-8)所示。 +相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响OLTP操作【48】。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“**抽取-转换-加载(ETL)**”,如[图3-8](img/fig3-8)所示。 -![](../img/fig3-8.png) +![](img/fig3-8.png) **图3-8 ETL至数据仓库的简化提纲** @@ -424,7 +424,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 图3-9中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。 -![](../img/fig3-9.png) +![](img/fig3-9.png) **图3-9 用于数据仓库的星型模式的示例** @@ -432,7 +432,7 @@ Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂 事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(允许计算利润余额)。事实表中的其他列是对其他表(称为维表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件的发生地点,时间,方式和原因。 -例如,在[图3-9](../img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 +例如,在[图3-9](img/fig3-9.md)中,其中一个维度是已售出的产品。 `dim_product` 表中的每一行代表一种待售产品,包括**库存单位(SKU)**,说明,品牌名称,类别,脂肪含量,包装尺寸等。`fact_sales` 表中的每一行都使用外部表明在特定交易中销售了哪些产品。 (为了简单起见,如果客户一次购买几种不同的产品,则它们在事实表中被表示为单独的行)。 即使日期和时间通常使用维度表来表示,因为这允许对日期(诸如公共假期)的附加信息进行编码,从而允许查询区分假期和非假期的销售。 @@ -469,13 +469,13 @@ GROUP BY 我们如何有效地执行这个查询? -在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](../img/fig3-1.png)的CSV例子中看到这个。 +在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。你可以在[图3-1](img/fig3-1.png)的CSV例子中看到这个。 为了处理像[例3-1]()这样的查询,您可能在 `fact_sales.date_key`, `fact_sales.product_sk`上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。这可能需要很长时间。 -面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](../img/fig3-10.png)所示。 +面向列的存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如[图3-10](img/fig3-10.png)所示。 -![](../img/fig3-10.png) +![](img/fig3-10.png) **图3-10 使用列存储关系型数据,而不是行** @@ -489,9 +489,9 @@ GROUP BY 除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。幸运的是,面向列的存储通常很适合压缩。 -看看[图3-10](../img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](../img/fig3-11.png)所示。 +看看[图3-10](img/fig3-10.png)中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如[图3-11](img/fig3-11.png)所示。 -![](../img/fig3-11.png) +![](img/fig3-11.png) **图3-11 压缩位图索引存储布局** @@ -536,9 +536,9 @@ WHERE product_sk = 31 AND store_sk = 3 相反,即使按列存储数据,也需要一次对整行进行排序。数据库的管理员可以使用他们对常见查询的知识来选择表格应该被排序的列。例如,如果查询通常以日期范围为目标,例如上个月,则可以将 `date_key` 作为第一个排序键。然后,查询优化器只能扫描上个月的行,这比扫描所有行要快得多。 -第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](../img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 +第二列可以确定第一列中具有相同值的任何行的排序顺序。例如,如果 `date_key` 是[图3-10](img/fig3-10.png)中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,因此同一天的同一产品的所有销售都将在存储中组合在一起。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。 -排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](../img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 +排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。一个简单的运行长度编码(就像我们用于[图3-11](img/fig3-11.png)中的位图一样)可以将该列压缩到几千字节 —— 即使表中有数十亿行。 第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。 @@ -568,13 +568,13 @@ WHERE product_sk = 31 AND store_sk = 3 当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(不管它们是否实际上改善了读取性能取决于个别情况)。 -物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](../img/fig3-12.png)显示了一个例子。 +物化视图的常见特例称为数据立方体或OLAP立方【64】。它是按不同维度分组的聚合网格。[图3-12](img/fig3-12.png)显示了一个例子。 -![](../img/fig3-12.png) +![](img/fig3-12.png) **图3-12 数据立方的两个维度,通过求和聚合** -想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](../img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 +想象一下,现在每个事实都只有两个维度表的外键——在[图3-12](img/fig-3-12.png)中,这些是日期和产品。您现在可以绘制一个二维表格,一个轴线上的日期和另一个轴上的产品。每个单元包含具有该日期 - 产品组合的所有事实的属性(例如,`net_price`)的聚集(例如,`SUM`)。然后,您可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总(按产品的销售额,无论日期,还是按日期销售,无论产品如何)。 一般来说,事实往往有两个以上的维度。在图3-9中有五个维度:日期,产品,商店,促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期(产品-商店-促销-客户)组合的销售。这些值可以在每个维度上重复概括。 diff --git a/zh-cn/ch4.md b/ch4.md similarity index 97% rename from zh-cn/ch4.md rename to ch4.md index f45c9aff..d047edd1 100644 --- a/zh-cn/ch4.md +++ b/ch4.md @@ -1,6 +1,6 @@ # 4. 编码与演化 -![](../img/ch4.png) +![](img/ch4.png) > 唯变所适 > @@ -113,7 +113,7 @@ JSON比XML简洁,但与二进制格式一比,还是太占地方。这一事 在下面的章节中,能达到比这好得多的结果,只用32个字节对相同的记录进行编码。 -![](../img/fig4-1.png) +![](img/fig4-1.png) **图4-1 使用MessagePack编码的记录(例4-1)** @@ -141,9 +141,9 @@ message Person { ``` Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用了类似于这里所示的模式定义,并且生成了以各种编程语言实现模式的类【18】。您的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。 -用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](../img/fig4-2.png)所示【19】。 +用这个模式编码的数据是什么样的?令人困惑的是,Thrift有两种不同的二进制编码格式[^iii],分别称为BinaryProtocol和CompactProtocol。先来看看BinaryProtocol。使用这种格式的编码来编码[例4-1]()中的消息只需要59个字节,如[图4-2](img/fig4-2.png)所示【19】。 -![](../img/fig4-2.png) +![](img/fig4-2.png) **图4-2 使用Thrift二进制协议编码的记录** @@ -151,17 +151,17 @@ Thrift和Protocol Buffers每一个都带有一个代码生成工具,它采用 与[图4-1](Img/fig4-1.png)类似,每个字段都有一个类型注释(用于指示它是一个字符串,整数,列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串`(“Martin”, “daydreaming”, “hacking”)`也被编码为ASCII(或者说,UTF-8),与之前类似。 -与[图4-1](../img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 +与[图4-1](img/fig4-1.png)相比,最大的区别是没有字段名`(userName, favoriteNumber, interest)`。相反,编码数据包含字段标签,它们是数字`(1, 2和3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。 -Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](../img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 +Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4-3](img/fig4-3.png)所示,它只将相同的信息打包成只有34个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字1337不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节来。这意味着-64到63之间的数字被编码为一个字节,-8192和8191之间的数字以两个字节编码,等等。较大的数字使用更多的字节。 -![](../img/fig4-3.png) +![](img/fig4-3.png) **图4-3 使用Thrift压缩协议编码的记录** -最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](../img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 +最后,Protocol Buffers(只有一种二进制编码格式)对相同的数据进行编码,如[图4-4](img/fig4-4.png)所示。 它的打包方式稍有不同,但与Thrift的CompactProtocol非常相似。 Protobuf将同样的记录塞进了33个字节中。 -![](../img/fig4-4.png) +![](img/fig4-4.png) **图4-4 使用Protobuf编码的记录** @@ -183,7 +183,7 @@ Thrift CompactProtocol编码在语义上等同于BinaryProtocol,但是如[图4 如何改变字段的数据类型?这可能是可能的——检查文件的细节——但是有一个风险,值将失去精度或被扼杀。例如,假设你将一个32位的整数变成一个64位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用32位变量来保存该值。如果解码的64位值不适合32位,则它将被截断。 -Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](../img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 +Protobuf的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(这是第三个选项旁边必要和可选)。如[图4-4](img/fig4-4.png)所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。 Thrift有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许Protocol Buffers所做的从单值到多值的相同演变,但是它具有支持嵌套列表的优点。 @@ -217,11 +217,11 @@ record Person { } ``` -首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](../img/fig4-5.png)所示。 +首先,请注意架构中没有标签号码。 如果我们使用这个模式编码我们的例子记录([例4-1]()),Avro二进制编码只有32个字节长,这是我们所见过的所有编码中最紧凑的。 编码字节序列的分解如[图4-5](img/fig4-5.png)所示。 如果您检查字节序列,您可以看到没有什么可以识别字段或其数据类型。 编码只是由连在一起的值组成。 一个字符串只是一个长度前缀,后跟UTF-8字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。 它可以是一个整数,也可以是其他的整数。 整数使用可变长度编码(与Thrift的CompactProtocol相同)进行编码。 -![](../img/fig4-5.png) +![](img/fig4-5.png) **图4-5 使用Avro编码的记录** @@ -235,11 +235,11 @@ record Person { 当一个应用程序想要解码一些数据(从一个文件或数据库读取数据,从网络接收数据等)时,它希望数据在某个模式中,这就是读者的模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能是从该模式生成的。 -Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](../img/fig4-6.png)所示。 +Avro的关键思想是作者的模式和读者的模式不必是相同的 - 他们只需要兼容。当数据解码(读取)时,Avro库通过并排查看作者的模式和读者的模式并将数据从作者的模式转换到读者的模式来解决差异。 Avro规范【20】确切地定义了这种解析的工作原理,如[图4-6](img/fig4-6.png)所示。 例如,如果作者的模式和读者的模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在作者模式中但不在读者模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是作者的模式不包含该名称的字段,则使用在读者模式中声明的默认值填充。 -![](../img/fig4-6.png) +![](img/fig4-6.png) **图4-6 一个Avro Reader解决读写模式的差异** @@ -344,7 +344,7 @@ Avro为静态类型编程语言提供了可选的代码生成功能,但是它 解决这个问题不是一个难题,你只需要意识到它。 -![](../img/fig4-7.png) +![](img/fig4-7.png) **图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。** @@ -474,7 +474,7 @@ RPC方案的前后向兼容性属性从它使用的编码方式中继承 消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。 -如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](../img/fig4-7.png))。 +如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图4-7](img/fig4-7.png))。 #### 分布式的Actor框架 diff --git a/zh-cn/ch5.md b/ch5.md similarity index 93% rename from zh-cn/ch5.md rename to ch5.md index ed4405ba..e7499fd7 100644 --- a/zh-cn/ch5.md +++ b/ch5.md @@ -1,6 +1,6 @@ # 5. 复制 -![](../img/ch5.png) +![](img/ch5.png) > 与可能出错的东西比,'不可能'出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。 > @@ -36,7 +36,7 @@ [^i]: 不同的人对**热(hot)**,**温(warm)**,**冷(cold)** 备份服务器有不同的定义。 例如在PostgreSQL中,**热备(hot standby)**指的是能接受客户端读请求的副本。而**温备(warm standby)**只是追随领导者,但不处理客户端的任何查询。 就本书而言,这些差异并不重要。 -![](../img/fig5-1.png) +![](img/fig5-1.png) **图5-1 基于领导者(主-从)的复制** ​ 这种复制模式是许多关系数据库的内置功能,如PostgreSQL(从9.0版本开始),MySQL,Oracle Data Guard 【2】和SQL Server的AlwaysOn可用性组【3】。 它也被用于一些非关系数据库,包括MongoDB,RethinkDB和Espresso 【4】。 最后,基于领导者的复制并不仅限于数据库:像Kafka 【5】和RabbitMQ高可用队列【6】这样的分布式消息代理也使用它。 某些网络文件系统,例如DRBD这样的块复制设备也与之类似。 @@ -47,9 +47,9 @@ ​ 想象[图5-1](fig5-1.png)中发生的情况,网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时刻,主库又会将数据变更转发给自己的从库。最后,主库通知客户更新成功。 -[图5-2](../img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 +[图5-2](img/fig5-2.png)显示了系统各个组件之间的通信:用户客户端,主库和两个从库。时间从左到右流动。请求或响应消息用粗箭头表示。 -![](../img/fig5-2.png) +![](img/fig5-2.png) **图5-2 基于领导者的复制:一个同步从库和一个异步从库** ​ 在[图5-2]()的示例中,从库1的复制是同步的:在向用户报告写入成功,并使结果对其他用户可见之前,主库需要等待从库1的确认,确保从库1已经收到写入操作。以及在使写入对其他客户端可见之前接收到写入。跟随者2的复制是异步的:主库发送消息,但不等待从库的响应。 @@ -205,7 +205,7 @@ ​ 但对于异步复制,问题就来了。如[图5-3](fig5-3.png)所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,用户会不高兴,可以理解。 -![](../img/fig5-3.png) +![](img/fig5-3.png) **图5-3 用户写入后从旧副本中读取数据。需要写后读(read-after-write)的一致性来防止这种异常** @@ -236,9 +236,9 @@ ​ 从异步从库读取第二个异常例子是,用户可能会遇到 **时光倒流(moving backward in time)**。 -​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](../img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 +​ 如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图5-4](img/fig5-4.png)显示了用户2345两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库。 (如果用户刷新网页,而每个请求被路由到一个随机的服务器,这种情况是很有可能的。)第一个查询返回最近由用户1234添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取写入内容。在效果上相比第一个查询,第二个查询是在更早的时间点来观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户2345可能不知道用户1234最近添加了评论。但如果用户2345先看见用户1234的评论,然后又看到它消失,那么对于用户2345,就很让人头大了。 -![](../img/fig5-4.png) +![](img/fig5-4.png) **图5-4 用户首先从新副本读取,然后从旧副本读取。时光倒流。为了防止这种异常,我们需要单调的读取。** @@ -260,7 +260,7 @@ 这两句话之间有因果关系:Cake夫人听到了Poons先生的问题并回答了这个问题。 -​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](../img/fig5-5.png))。 于是,这个观察者会听到以下内容: +​ 现在,想象第三个人正在通过从库来听这个对话。 Cake夫人说的内容是从一个延迟很低的从库读取的,但Poons先生所说的内容,从库的延迟要大的多(见[图5-5](img/fig5-5.png))。 于是,这个观察者会听到以下内容: > *Mrs. Cake* > ​ 通常约十秒钟,Mr. Poons. @@ -271,7 +271,7 @@ 对于观察者来说,看起来好像Cake夫人在Poons先生发问前就回答了这个问题。 这种超能力让人印象深刻,但也会把人搞糊涂。【25】。 -![](../img/fig5-5.png) +![](img/fig5-5.png) **图5-5 如果某些分区的复制速度慢于其他分区,那么观察者在看到问题之前可能会看到答案。** @@ -311,9 +311,9 @@ ​ 假如你有一个数据库,副本分散在好几个不同的数据中心(也许这样可以容忍单个数据中心的故障,或地理上更接近用户)。 使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。 -​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](../img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 +​ 多领导者配置中可以在每个数据中心都有主库。 [图5-6](img/fig5-6.png)展示了这个架构的样子。 在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。 -![](../img/fig5-6.png) +![](img/fig5-6.png) **图5-6 跨多个数据中心的多主复制** @@ -333,7 +333,7 @@ ​ 有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于MySQL的Tungsten Replicator 【26】,用于PostgreSQL的BDR【27】以及用于Oracle的GoldenGate 【19】。 -​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](../img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 +​ 尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如[图5-6](img/fig5-6.png)中“[冲突解决](#冲突解决)”)。本书将在“[处理写入冲突](#处理写入冲突)”中详细讨论这个问题。 ​ 由于多主复制在许多数据库中都属于改装的功能,所以常常存在微妙的配置缺陷,且经常与其他数据库功能之间出现意外的反应。例如自增主键、触发器、完整性约束等,都可能会有麻烦。因此,多主复制往往被认为是危险的领域,应尽可能避免【28】。 @@ -361,9 +361,9 @@ ​ 多领导者复制的最大问题是可能发生写冲突,这意味着需要解决冲突。 -​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](../img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 +​ 例如,考虑一个由两个用户同时编辑的维基页面,如[图5-7](img/fig5-7.png)所示。用户1将页面的标题从A更改为B,并且用户2同时将标题从A更改为C。每个用户的更改已成功应用到其本地主库。但当异步复制时,会发现冲突【33】。单主数据库中不会出现此问题。 -![](../img/fig5-7.png) +![](img/fig5-7.png) **图5-7 两个主库同时更新同一记录引起的写入冲突** @@ -385,7 +385,7 @@ ​ 单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。 -​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](../img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 +​ 在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在[图5-7](img/fig5-7.png)中,在主库1中标题首先更新为B而后更新为C;在主库2中,首先更新为C,然后更新为B。两个顺序都不是“更正确”的。 ​ 如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库1的C和主库2的B。这是不可接受的,每个复制方案都必须确保数据在所有副本中最终都是相同的。因此,数据库必须以一种**收敛(convergent)**的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。 @@ -393,7 +393,7 @@ * 给每个写入一个唯一的ID(例如,一个时间戳,一个长的随机数,一个UUID或者一个键和值的哈希),挑选最高ID的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为**最后写入胜利(LWW, last write wins)**。虽然这种方法很流行,但是很容易造成数据丢失【35】。我们将在[本章末尾](#检测并发写入)更详细地讨论LWW。 * 为每个副本分配一个唯一的ID,ID编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。 -* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](../img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 +* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在[图5-7](img/fig5-7.png)中,合并的标题可能类似于“B/C”)。 * 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。 @@ -431,7 +431,7 @@ #### 什么是冲突? -​ 有些冲突是显而易见的。在[图5-7](../img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 +​ 有些冲突是显而易见的。在[图5-7](img/fig5-7.png)的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。 ​ 其他类型的冲突可能更为微妙,难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用需要确保每个房间只有一组人同时预定(即不得有相同房间的重叠预订)。在这种情况下,如果同时为同一个房间创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前检查可用性,如果两次预订是由两个不同的领导者进行的,则可能会有冲突。 @@ -443,7 +443,7 @@ ​ **复制拓扑**(replication topology)描述写入从一个节点传播到另一个节点的通信路径。如果你有两个领导者,如[图5-7]()所示,只有一个合理的拓扑结构:领导者1必须把他所有的写到领导者2,反之亦然。当有两个以上的领导,各种不同的拓扑是可能的。[图5-8]()举例说明了一些例子。 -![](../img/fig5-8.png) +![](img/fig5-8.png) **图5-8 三个可以设置多领导者复制的示例拓扑。** @@ -455,13 +455,13 @@ ​ 循环和星型拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,直到节点修复。拓扑结构可以重新配置为在发生故障的节点上工作,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,避免单点故障。 -​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](../img/fig5-9.png)所示。 +​ 另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如,由于网络拥塞),结果是一些复制消息可能“超过”其他复制消息,如[图5-9](img/fig5-9.png)所示。 -![](../img/fig5-9.png) +![](img/fig5-9.png) **图5-9 使用多主程序复制时,可能会在某些副本中写入错误的顺序。** -​ 在[图5-9](../img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 +​ 在[图5-9](img/fig5-9.png)中,客户端A向主库1的表中插入一行,客户端B在主库3上更新该行。然而,主库2可以以不同的顺序接收写入:它可以首先接收更新(其中,从它的角度来看,是对数据库中不存在的行的更新),并且仅在稍后接收到相应的插入(其应该在更新之前)。 ​ 这是一个因果关系的问题,类似于我们在“[一致前缀读](ch8.md#一致前缀读)”中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,以便在主库2处正确地排序这些事件(见[第8章](ch8.md))。 @@ -485,9 +485,9 @@ ​ 假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于主机的配置中,如果要继续处理写入,则可能需要执行故障切换(参阅「[处理节点宕机](#处理节点宕机)」)。 -​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](../img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 +​ 另一方面,在无领导配置中,故障切换不存在。[图5-10](img/fig5-10.png)显示了发生了什么事情:客户端(用户1234)并行发送写入到所有三个副本,并且两个可用副本接受写入,但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的:在用户1234已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。 -![](../img/fig5-10.png) +![](img/fig5-10.png) **图5-10 法定写入,法定读取,并在节点中断后读修复。** @@ -503,7 +503,7 @@ ***读修复(Read repair)*** -​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](../img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 +​ 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在[图5-10](img/fig5-10.png)中,用户2345获得了来自副本3的版本6值和来自副本1和2的版本7值。客户端发现副本3具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。 ***反熵过程(Anti-entropy process)*** @@ -513,7 +513,7 @@ #### 读写的法定人数 -​ 在[图5-10](../img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? +​ 在[图5-10](img/fig5-10.png)的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?我们能推多远呢? ​ 如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。 @@ -531,10 +531,10 @@ * 如果$w n$,读取r个副本,至少有一个r副本必然包含了最近的成功写入** @@ -544,7 +544,7 @@ ### 法定人数一致性的局限性 -​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](../img/fig5-11.png)所示)。 +​ 如果你有n个副本,并且你选择w和r,使得$w + r> n$,你通常可以期望每个读取返回为一个键写的最近的值。情况就是这样,因为你写的节点集合和你读过的节点集合必须重叠。也就是说,您读取的节点中必须至少有一个具有最新值的节点(如[图5-11](img/fig5-11.png)所示)。 ​ 通常,r和w被选为多数(超过 $n/2$ )节点,因为这确保了$w + r> n$,同时仍然容忍多达$n/2$个节点故障。但是,法定人数不一定必须是大多数,只是读写使用的节点交集至少需要包括一个节点。其他法定人数的配置是可能的,这使得分布式算法的设计有一定的灵活性【45】。 @@ -608,17 +608,17 @@ ​ Dynamo风格的数据库允许多个客户端同时写入相同的Key,这意味着即使使用严格的法定人数也会发生冲突。这种情况与多领导者复制相似(参阅“[处理写入冲突](#处理写入冲突)”),但在Dynamo样式的数据库中,在**读修复**或**提示移交**期间也可能会产生冲突。 -​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](../img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: +​ 问题在于,由于可变的网络延迟和部分故障,事件可能在不同的节点以不同的顺序到达。例如,[图5-12](img/fig5-12.png)显示了两个客户机A和B同时写入三节点数据存储区中的键X: * 节点 1 接收来自 A 的写入,但由于暂时中断,从不接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 * 节点 3 首先接收来自 B 的写入,然后从 A 写入。 -![](../img/fig5-12.png) +![](img/fig5-12.png) **图5-12 并发写入Dynamo风格的数据存储:没有明确定义的顺序。** -​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](../img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 +​ 如果每个节点只要接收到来自客户端的写入请求就简单地覆盖了某个键的值,那么节点就会永久地不一致,如[图5-12](img/fig5-12.png)中的最终获取请求所示:节点2认为 X 的最终值是 B,而其他节点认为值是 A 。 ​ 为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。 @@ -628,7 +628,7 @@ ​ 实现最终融合的一种方法是声明每个副本只需要存储最**“最近”**的值,并允许**“更旧”**的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是“最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。 -​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](../img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 +​ 正如**“最近”**的引号所表明的,这个想法其实颇具误导性。在[图5-12](img/fig5-12.png)的例子中,当客户端向数据库节点发送写入请求时,客户端都不知道另一个客户端,因此不清楚哪一个先发生了。事实上,说“发生”是没有意义的:我们说写入是**并发(concurrent)**的,所以它们的顺序是不确定的。 ​ 即使写入没有自然的排序,我们也可以强制任意排序。例如,可以为每个写入附加一个时间戳,挑选最**“最近”**的最大时间戳,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为**最后写入胜利(LWW, last write wins)**,是Cassandra 【53】唯一支持的冲突解决方法,也是Riak 【35】中的一个可选特征。 @@ -673,13 +673,13 @@ 4. 同时,客户端 2 想要加入火腿,不知道客端户 1 刚刚加了面粉。客户端 2 在最后一个响应中从服务器收到了两个值[牛奶]和[蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值,[鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 [鸡蛋],但新值也会与版本 3 [牛奶,面粉]**并发**,所以剩下的两个是v3 [牛奶,面粉],和v4:[鸡蛋,牛奶,火腿] 5. 最后,客户端 1 想要加培根。它以前在v3中从服务器接收[牛奶,面粉]和[鸡蛋],所以它合并这些,添加培根,并将最终值[牛奶,面粉,鸡蛋,培根]连同版本号v3发往服务器。这会覆盖v3[牛奶,面粉](请注意[鸡蛋]已经在最后一步被覆盖),但与v4[鸡蛋,牛奶,火腿]并发,所以服务器保留这两个并发值。 -![](../img/fig5-13.png) +![](img/fig5-13.png) **图5-13 捕获两个客户端之间的因果关系,同时编辑购物车。** -​ [图5-13](../img/fig5-13.png)中的操作之间的数据流如[图5-14](../img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 +​ [图5-13](img/fig5-13.png)中的操作之间的数据流如[图5-14](img/fig5-14.png)所示。 箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。 在这个例子中,客户端永远不会完全掌握服务器上的数据,因为总是有另一个操作同时进行。 但是,旧版本的值最终会被覆盖,并且不会丢失任何写入。 -![](../img/fig5-14.png) +![](img/fig5-14.png) **图5-14 图5-13中的因果依赖关系图。** @@ -698,7 +698,7 @@ ​ 合并兄弟值,本质上是与多领导者复制中的冲突解决相同的问题,我们先前讨论过(参阅“[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中做更聪明的事情。 -​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](../img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 +​ 以购物车为例,一种合理的合并兄弟方法就是集合求并。在[图5-14](img/fig5-14.png)中,最后的两个兄弟是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋出现在两个,即使他们每个只写一次。合并的价值可能是像[牛奶,面粉,鸡蛋,培根,火腿],没有重复。 ​ 然而,如果你想让人们也可以从他们的手推车中**删除**东西,而不是仅仅添加东西,那么把兄弟求并可能不会产生正确的结果:如果你合并了两个兄弟手推车,并且只在其中一个兄弟值里删掉了它,那么被删除的项目会重新出现在兄弟的并集中【37】。为了防止这个问题,一个项目在删除时不能简单地从数据库中删除;相反,系统必须留下一个具有合适版本号的标记,以指示合并兄弟时该项目已被删除。这种删除标记被称为**墓碑(tombstone)**。 (我们之前在“[哈希索引”](ch3.md#哈希索引)中的日志压缩的上下文中看到了墓碑。) @@ -706,13 +706,13 @@ #### 版本向量 -​ [图5-13](../img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? +​ [图5-13](img/fig5-13.png)中的示例只使用一个副本。当有多个副本但没有领导者时,算法如何修改? -​ [图5-13](../img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 +​ [图5-13](img/fig5-13.png)使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键使用版本号之外,还需要在**每个副本**中使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些值,以及保留哪些值作为兄弟。 ​ 所有副本的版本号集合称为**版本向量(version vector)**【56】。这个想法的一些变体正在使用,但最有趣的可能是在Riak 2.0 【58,59】中使用的**分散版本矢量(dotted version vector)**【57】。我们不会深入细节,但是它的工作方式与我们在购物车示例中看到的非常相似。 -​ 与[图5-13](../img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 +​ 与[图5-13](img/fig5-13.png)中的版本号一样,当读取值时,版本向量会从数据库副本发送到客户端,并且随后写入值时需要将其发送回数据库。 (Riak将版本向量编码为一个字符串,它称为**因果上下文(causal context)**)。版本向量允许数据库区分覆盖写入和并发写入。 ​ 另外,就像在单个副本的例子中,应用程序可能需要合并兄弟。版本向量结构确保从一个副本读取并随后写回到另一个副本是安全的。这样做可能会创建兄弟,但只要兄弟姐妹合并正确,就不会丢失数据。 diff --git a/zh-cn/ch6.md b/ch6.md similarity index 95% rename from zh-cn/ch6.md rename to ch6.md index c2acbc21..4dfd6531 100644 --- a/zh-cn/ch6.md +++ b/ch6.md @@ -1,6 +1,6 @@ # 6. 分区 -![](../img/ch6.png) +![](img/ch6.png) > 我们必须跳出电脑指令序列的窠臼。 叙述定义、描述元数据、梳理关系,而不是编写过程。 > @@ -37,7 +37,7 @@ ​ 一个节点可能存储多个分区。 如果使用主从复制模型,则分区和复制的组合如[图6-1]()所示。 每个分区领导者(主)被分配给一个节点,追随者(从)被分配给其他节点。 每个节点可能是某些分区的领导者,同时是其他分区的追随者。 我们在[第5章](ch5.md)讨论的关于数据库复制的所有内容同样适用于分区的复制。 大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。 -![](../img/fig6-1.png) +![](img/fig6-1.png) **图6-1 组合使用复制和分区:每个节点充当某些分区的领导者,其他分区充当追随者。** @@ -57,7 +57,7 @@ ​ 一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸百科全书的卷([图6-2]())。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果您还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。 -![](../img/fig6-2.png) +![](img/fig6-2.png) **图6-2 印刷版百科全书按照关键字范围进行分区** @@ -79,9 +79,9 @@ ​ 出于分区的目的,散列函数不需要多么强壮的加密算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函数。许多编程语言都有内置的简单哈希函数(它们用于哈希表),但是它们可能不适合分区:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一个键可能在不同的进程中有不同的哈希值【6】。 -​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](../img/fig6-3.png)所示。 +​ 一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如[图6-3](img/fig6-3.png)所示。 -![](../img/fig6-3.png) +![](img/fig6-3.png) **图6-3 按哈希键分区** @@ -125,19 +125,19 @@ ### 基于文档的二级索引进行分区 -​ 假设你正在经营一个销售二手车的网站(如[图6-4](../img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 +​ 假设你正在经营一个销售二手车的网站(如[图6-4](img/fig6-4.png)所示)。 每个列表都有一个唯一的ID——称之为文档ID——并且用文档ID对数据库进行分区(例如,分区0中的ID 0到499,分区1中的ID 500到999等)。 ​ 你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是**字段(field)**,关系数据库中这些是**列(column)** )。 如果您声明了索引,则数据库可以自动执行索引[^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目`color:red`的文档ID列表中。 [^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档ID的映射来实现辅助索引。 如果沿着这条路线走下去,请万分小心,确保您的索引与底层数据保持一致。 竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 参见“[多对象事务的需求]()”。 -![](../img/fig6-4.png) +![](img/fig6-4.png) **图6-4 基于文档的二级索引进行分区** ​ 在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时您需要写入数据库(添加,删除或更新文档),只需处理包含您正在编写的文档ID的分区即可。出于这个原因,**文档分区索引**也被称为**本地索引(local index)**(而不是将在下一节中描述的**全局索引(global index)**)。 -​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](../img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 +​ 但是,从文档分区索引中读取需要注意:除非您对文档ID做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在[图6-4](img/fig6-4.png)中,红色汽车出现在分区0和分区1中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。 ​ 这种查询分区数据库的方法有时被称为**分散/聚集(scatter/gather)**,并且可能会使二级索引上的读取查询相当昂贵。即使并行查询分区,分散/聚集也容易导致尾部延迟放大(参阅“[实践中的百分位点](ch1.md#实践中的百分位点)”)。然而,它被广泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文档分区二级索引。大多数数据库供应商建议您构建一个能从单个分区提供二级索引查询的分区方案,但这并不总是可行,尤其是当在单个查询中使用多个二级索引时(例如同时需要按颜色和制造商查询)。 @@ -147,9 +147,9 @@ ​ 我们可以构建一个覆盖所有分区数据的**全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。 -​ [图6-5](../img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 +​ [图6-5](img/fig6-5.png)述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从`a`到`r`的颜色在分区0中,`s`到`z`的在分区1。汽车制造商的索引也与之类似(分区边界在`f`和`h`之间)。 -![](../img/fig6-5.png) +![](img/fig6-5.png) **图6-5 基于关键词对二级索引进行分区** @@ -188,7 +188,7 @@ #### 反面教材:hash mod N -​ 我们在前面说过([图6-3](../img/fig6-3.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果$0≤hash(key) ​ 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。 > @@ -90,11 +90,11 @@ ACID一致性的概念是,**对数据的一组特定约束必须始终成立** 大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。 -[图7-1](../img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](../img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 +[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。 ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。 -![](../img/fig7-1.png) +![](img/fig7-1.png) **图7-1 两个客户之间的竞争状态同时递增计数器** @@ -137,7 +137,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的** 同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。 -这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](../img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: +这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要**多对象事务(multi-object transaction)** 来保持多块数据同步。[图7-2](img/fig7-2.png)展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量: ```sql SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -145,17 +145,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。 -在[图7-2](../img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 +在[图7-2](img/fig7-2.png)中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生[^ii]。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。 [^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。 -![](../img/fig7-2.png) +![](img/fig7-2.png) **图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。** -[图7-3](../img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 +[图7-3](img/fig7-3.png)说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。 -![](../img/fig7-3.png) +![](img/fig7-3.png) **图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致** @@ -175,7 +175,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。 -一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](../img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 +一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。 这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv] @@ -190,7 +190,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象: * 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。 -* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](../img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 +* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。 * 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。 这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。 @@ -250,14 +250,14 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。 -![](../img/fig7-4.png) +![](img/fig7-4.png) **图7-4 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。** 为什么要防止脏读,有几个原因: -- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](../img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 -- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](../img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 +- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在[图7-2](img/fig7-2.png)中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。 +- 如果事务中止,则所有写入操作都需要回滚(如[图7-3](img/fig7-3.png)所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。 #### 没有脏写 @@ -267,10 +267,10 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 通过防止脏写,这个隔离级别避免了一些并发问题: -- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](../img/fig7-5.png),[图7-5](../img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](../img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 +- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。 - 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。 -![](../img/fig7-5.png) +![](img/fig7-5.png) **图7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起** @@ -292,9 +292,9 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true 如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许**中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混合。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。 -但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](../img/fig7-6.png)说明了读已提交时可能发生的问题。 +但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如[图7-6](img/fig7-6.png)说明了读已提交时可能发生的问题。 -![](../img/fig7-6.png) +![](img/fig7-6.png) **图7-6 读取偏差:Alice观察数据库处于不一致的状态** @@ -332,7 +332,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true [^vii]: 事实上,事务ID是32位整数,所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。 -![](../img/fig7-7.png) +![](img/fig7-7.png) **图7-7 使用多版本对象实现快照隔离** @@ -475,9 +475,9 @@ UPDATE wiki_pages SET content = '新内容' 首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作【40,41】。 -现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](../img/fig7-8.png)说明了接下来的事情。 +现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图7-8](img/fig7-8.png)说明了接下来的事情。 -![](../img/fig7-8.png) +![](img/fig7-8.png) **图7-8 写入偏差导致应用程序错误的示例** @@ -626,9 +626,9 @@ COMMIT; ​ 在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。 -​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](../img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 +​ 出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如[图7-9](img/fig7-9.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。 -![](../img/fig7-9.png) +![](img/fig7-9.png) **图7-9 交互式事务和存储过程之间的区别(使用图7-8的示例事务)** @@ -793,7 +793,7 @@ WHERE room_id = 123 AND 回想一下,快照隔离通常是通过多版本并发控制(MVCC;见[图7-10]())来实现的。当一个事务从MVCC数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在[图7-10]()中,事务43 认为Alice的 `on_call = true` ,因为事务42(修改Alice的待命状态)未被提交。然而,在事务43想要提交时,事务42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务43 的前提不再为真。 -![](../img/fig7-10.png) +![](img/fig7-10.png) **图7-10 检测事务何时从MVCC快照读取过时的值** @@ -803,9 +803,9 @@ WHERE room_id = 123 AND #### 检测影响之前读取的写入 -第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](../img/fig7-11.png)所示。 +第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如[图7-11](img/fig7-11.png)所示。 -![](../img/fig7-11.png) +![](img/fig7-11.png) **图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。** diff --git a/zh-cn/ch8.md b/ch8.md similarity index 97% rename from zh-cn/ch8.md rename to ch8.md index 382487de..d047d162 100644 --- a/zh-cn/ch8.md +++ b/ch8.md @@ -1,6 +1,6 @@ # 第八章:分布式系统的麻烦 -![](../img/ch8.png) +![](img/ch8.png) > 邂逅相遇 > @@ -97,7 +97,7 @@ ​ **无共享**并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。 -​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](../img/fig8-1.png)所示): +​ 互联网和数据中心(通常是以太网)中的大多数内部网络都是**异步分组网络(asynchronous packet networks)**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果您发送请求并期待响应,则很多事情可能会出错(其中一些如[图8-1](img/fig8-1.png)所示): 1. 请求可能已经丢失(可能有人拔掉了网线)。 2. 请求可能正在排队,稍后将交付(也许网络或收件人超载)。 @@ -106,7 +106,7 @@ 5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。 6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。 -![](../img/fig8-1.png) +![](img/fig8-1.png) **图8-1 如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失。** @@ -168,12 +168,12 @@ ​ 在驾驶汽车时,由于交通拥堵,道路交通网络的通行时间往往不尽相同。同样,计算机网络上数据包延迟的可变性通常是由于排队【25】: -* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](../img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 +* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如[图8-2](img/fig8-2.png)所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络连接)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。 * 当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。 * 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,而另一个虚拟机使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队(缓冲),进一步增加了网络延迟的可变性。 * TCP执行**流量控制(flow control)**(也称为**拥塞避免(congestion avoidance)**或**背压(backpressure)**),其中节点限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队。 -![](../img/fig8-2.png) +![](img/fig8-2.png) **图8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口1,2和4都试图发送数据包到端口3** @@ -319,20 +319,20 @@ ​ 让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近? -​ [图8-3](../img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](../img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 +​ [图8-3](img/fig8-3.png)显示了在具有多领导者复制的数据库中对时钟的危险使用(该例子类似于[图5-9](img/fig5-9.png))。 客户端A在节点1上写入`x = 1`;写入被复制到节点3;客户端B在节点3上增加x(我们现在有`x = 2`);最后这两个写入都被复制到节点2。 -![](../img/fig8-3.png) +![](img/fig8-3.png) **图8-3 客户端B的写入比客户端A的写入要晚,但是B的写入具有较早的时间戳。** ​ 在[图8-3]()中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的时钟时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点1和节点3之间的偏差小于3ms,这可能比你在实践中预期的更好。 -​ 尽管如此,[图8-3](../img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 +​ 尽管如此,[图8-3](img/fig8-3.png)中的时间戳却无法正确排列事件:写入`x = 1`的时间戳为42.004秒,但写入`x = 2`的时间戳为42.003秒,即使`x = 2`在稍后出现。当节点2接收到这两个事件时,会错误地推断出`x = 1`是最近的值,而丢弃写入`x = 2`。效果上表现为,客户端B的增量操作会丢失。 ​ 这种冲突解决策略被称为**最后写入胜利(LWW)**,它在多领导者复制和无领导者数据库(如Cassandra 【53】和Riak 【54】)中被广泛使用(参见“[最后写入胜利(丢弃并发写入)](#最后写入胜利(丢弃并发写入))”一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题: * 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。 -* LWW无法区分**高频顺序写入**(在[图8-3](../img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 +* LWW无法区分**高频顺序写入**(在[图8-3](img/fig8-3.png)中,客户端B的增量操作**一定**发生在客户端A的写入之后)和**真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止因果关系的冲突(请参阅“[检测并发写入](ch5.md#检测并发写入)”)。 * 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的**决胜值(tiebreaker)**(可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系【53】。 因此,尽管通过保留最“最近”的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近”的定义取决于本地的**时钟**,这很可能是不正确的。即使用频繁同步的NTP时钟,一个数据包也可能在时间戳100毫秒(根据发送者的时钟)时发送,并在时间戳99毫秒(根据接收者的时钟)处到达——看起来好像数据包在发送之前已经到达,这是不可能的。 @@ -485,9 +485,9 @@ while(true){ ​ 如果一个节点继续表现为**天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。 -​ 例如,[图8-4](../img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 +​ 例如,[图8-4](img/fig8-4.png)显示了由于不正确的锁实现导致的数据损坏错误。 (这个错误不仅仅是理论上的:HBase曾经有这个问题【74,75】)假设你要确保一个存储服务中的文件一次只能被一个客户访问,因为如果多个客户试图写对此,该文件将被损坏。您尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。 -![](../img/fig8-4.png) +![](img/fig8-4.png) **图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件** @@ -495,15 +495,15 @@ while(true){ #### 防护令牌 -​ 当使用锁或租约来保护对某些资源(如[图8-4](../img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 +​ 当使用锁或租约来保护对某些资源(如[图8-4](img/fig8-4.png)中的文件存储)的访问时,需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是**防护(fencing)**,如[图8-5]()所示 -![](../img/fig8-5.png) +![](img/fig8-5.png) **图8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全** ​ 我们假设每次锁定服务器授予锁或租约时,它还会返回一个**防护令牌(fencing token)**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。 -​ 在[图8-5](../img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 +​ 在[图8-5](img/fig8-5.png)中,客户端1以33的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端2以34的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括34的令牌。稍后,客户端1恢复生机并将其写入存储服务,包括其令牌值33.但是,存储服务器会记住它已经处理了一个具有更高令牌编号(34)的写入,因此它会拒绝带有令牌33的请求。 ​ 如果将ZooKeeper用作锁定服务,则可将事务标识`zxid`或节点版本`cversion`用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性【74】。 diff --git a/zh-cn/ch9.md b/ch9.md similarity index 94% rename from zh-cn/ch9.md rename to ch9.md index 85317a33..0936689a 100644 --- a/zh-cn/ch9.md +++ b/ch9.md @@ -1,6 +1,6 @@ # 9. 一致性与共识 -![](../img/ch9.png) +![](img/ch9.png) > 好死不如赖活着 > —— Jay Kreps, 关于Kafka与 Jepsen的若干笔记 (2013) @@ -60,11 +60,11 @@ ​ 在一个线性一致的系统中,只要一个客户端成功完成写操作,所有客户端从数据库中读取数据必须能够看到刚刚写入的值。维护数据的单个副本的错觉是指,系统能保障读到的值是最近的,最新的,而不是来自陈旧的缓存或副本。换句话说,线性一致性是一个**新鲜度保证(recency guarantee)**。为了阐明这个想法,我们来看看一个非线性一致系统的例子。 -![](../img/fig9-1.png) +![](img/fig9-1.png) **图9-1 这个系统是非线性一致的,导致了球迷的困惑** -​ [图9-1 ](../img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 +​ [图9-1 ](img/fig9-1.png)展示了一个关于体育网站的非线性一致例子【9】。Alice和Bob正坐在同一个房间里,都盯着各自的手机,关注着2014年FIFA世界杯决赛的结果。在最后得分公布后,Alice刷新页面,看到宣布了获胜者,并兴奋地告诉Bob。Bob难以置信地刷新了自己的手机,但他的请求路由到了一个落后的数据库副本上,手机显示比赛仍在进行。 ​ 如果Alice和Bob在同一时间刷新并获得了两个不同的查询结果,也许就没有那么令人惊讶了。因为他们不知道服务器处理他们请求的精确时刻。然而Bob是在听到Alice惊呼最后得分**之后**,点击了刷新按钮(启动了他的查询),因此他希望查询结果至少与爱丽丝一样新鲜。但他的查询返回了陈旧结果,这一事实违背了线性一致性的要求。 @@ -72,13 +72,13 @@ ​ 线性一致性背后的基本思想很简单:使系统看起来好像只有一个数据副本。然而确切来讲,实际上有更多要操心的地方。为了更好地理解线性一致性,让我们再看几个例子。 -​ [图9-2](../img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 +​ [图9-2](img/fig9-2.png) 显示了三个客户端在线性一致数据库中同时读写相同的键`x`。在分布式系统文献中,`x`被称为**寄存器(register)**,例如,它可以是键值存储中的一个**键**,关系数据库中的一**行**,或文档数据库中的一个**文档**。 -![](../img/fig9-2.png) +![](img/fig9-2.png) **图9-2 如果读取请求与写入请求并发,则可能会返回旧值或新值** -​ 为了简单起见,[图9-2](../img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] +​ 为了简单起见,[图9-2](img/fig9-2.png)采用了用户请求的视角,而不是数据库内部的视角。每个柱都是由客户端发出的请求,其中柱头是请求发送的时刻,柱尾是客户端收到响应的时刻。因为网络延迟变化无常,客户端不知道数据库处理其请求的精确时间——只知道它发生在发送请求和接收响应的之间的某个时刻。[^i] [^i]: 这个图的一个微妙的细节是它假定存在一个全局时钟,由水平轴表示。即使真实的系统通常没有准确的时钟(参阅“[不可靠的时钟](ch8.md#不可靠的时钟)”),但这种假设是允许的:为了分析分布式算法,我们可以假设一个精确的全局时钟存在,不过算法无法访问它【47】。算法只能看到由石英振荡器和NTP产生的实时逼近。 @@ -89,7 +89,7 @@ * $write(x,v)⇒r$ 表示客户端请求将寄存器 `x` 设置为值 `v` ,数据库返回响应 `r` (可能正确,可能错误)。 -在[图9-2](../img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? +在[图9-2](img/fig9-2.png) 中,`x` 的值最初为 `0`,客户端C 执行写请求将其设置为 `1`。发生这种情况时,客户端A和B反复轮询数据库以读取最新值。 A和B的请求可能会收到怎样的响应? * 客户端A的第一个读操作,完成于写操作开始之前,因此必须返回旧值 `0`。 * 客户端A的最后一个读操作,开始于写操作完成之后。如果数据库是线性一致性的,它必然返回新值 `1`:因为读操作和写操作一定是在其各自的起止区间内的某个时刻被处理。如果在写入结束后开始读取,则必须在写入之后处理读取,因此它必须看到写入的新值。 @@ -99,16 +99,16 @@ [^ii]: 如果读取(与写入同时发生时)可能返回旧值或新值,则称该寄存器为**常规寄存器(regular register)**【7,25】 -为了使系统线性一致,我们需要添加另一个约束,如[图9-3](../img/fig9-3.png)所示 +为了使系统线性一致,我们需要添加另一个约束,如[图9-3](img/fig9-3.png)所示 -![](../img/fig9-3.png) +![](img/fig9-3.png) **图9-3 任何一个读取返回新值后,所有后续读取(在相同或其他客户端上)也必须返回新值。** ​ 在一个线性一致的系统中,我们可以想象,在 `x` 的值从`0` 自动翻转到 `1` 的时候(在写操作的开始和结束之间)必定有一个时间点。因此,如果一个客户端的读取返回新的值 `1`,即使写操作尚未完成,所有后续读取也必须返回新值。 -​ [图9-3](../img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) +​ [图9-3](img/fig9-3.png)中的箭头说明了这个时序依赖关系。客户端A 是第一个读取新的值 `1` 的位置。在A 的读取返回之后,B开始新的读取。由于B的读取严格在发生于A的读取之后,因此即使C的写入仍在进行中,也必须返回 `1`。 (与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同:在Alice读取新值之后,Bob也希望读取新的值。) -​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](../img/fig9-4.png)显示了一个更复杂的例子【10】。 +​ 我们可以进一步细化这个时序图,展示每个操作是如何在特定时刻原子性生效的。[图9-4](img/fig9-4.png)显示了一个更复杂的例子【10】。 在[图9-4]()中,除了读写之外,还增加了第三种类型的操作: @@ -118,7 +118,7 @@ ​ 线性一致性的要求是,操作标记的连线总是按时间(从左到右)向前移动,而不是向后移动。这个要求确保了我们之前讨论的新鲜性保证:一旦新的值被写入或读取,所有后续的读都会看到写入的值,直到它被再次覆盖。 -![](../img/fig9-4.png) +![](img/fig9-4.png) **图9-4 可视化读取和写入看起来已经生效的时间点。 B的最后读取不是线性一致性的** @@ -130,7 +130,7 @@ * 此模型不假设有任何事务隔离:另一个客户端可能随时更改值。例如,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更旧的值。再次,与[图9-1](../img/fig9-1.png)中的Alice和Bob的情况相同。 +* 客户B的最后一次读取(阴影条柱中)不是线性一致性的。 该操作与C的**cas**写操作并发(它将 `x` 从 `2` 更新为 `4` )。在没有其他请求的情况下,B的读取返回 `2` 是可以的。然而,在B的读取开始之前,客户端A已经读取了新的值 `4` ,因此不允许B读取比A更旧的值。再次,与[图9-1](img/fig9-1.png)中的Alice和Bob的情况相同。 这就是线性一致性背后的直觉。 正式的定义【6】更准确地描述了它。 通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序,测试一个系统的行为是否线性一致性是可能的(尽管在计算上是昂贵的)【11】。 @@ -182,17 +182,17 @@ #### 跨信道的时序依赖 -​ 注意[图9-1](../img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 +​ 注意[图9-1](img/fig9-1.png) 中的一个细节:如果Alice没有惊呼得分,Bob就不会知道他的查询结果是陈旧的。他会在几秒钟之后再次刷新页面,并最终看到最后的分数。由于系统中存在额外的信道(Alice的声音传到了Bob的耳朵中),线性一致性的违背才被注意到。 -​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](../img/fig9-5.png)所示。 +​ 计算机系统也会出现类似的情况。例如,假设有一个网站,用户可以上传照片,一个后台进程会调整照片大小,降低分辨率以加快下载速度(缩略图)。该系统的架构和数据流如[图9-5](img/fig9-5.png)所示。 ​ 图像缩放器需要明确的指令来执行尺寸缩放作业,指令是Web服务器通过消息队列发送的(参阅[第11章](ch11.md))。 Web服务器不会将整个照片放在队列中,因为大多数消息代理都是针对较短的消息而设计的,而一张照片的空间占用可能达到几兆字节。取而代之的是,首先将照片写入文件存储服务,写入完成后再将缩放器的指令放入消息队列。 -![](../img/fig9-5.png) +![](img/fig9-5.png) **图9-5 Web服务器和图像调整器通过文件存储和消息队列进行通信,打开竞争条件的可能性。** -​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](../img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 +​ 如果文件存储服务是线性一致的,那么这个系统应该可以正常工作。如果它不是线性一致的,则存在竞争条件的风险:消息队列([图9-5](img/fig9-5.png)中的步骤3和4)可能比存储服务内部的复制更快。在这种情况下,当缩放器读取图像(步骤5)时,可能会看到图像的旧版本,或者什么都没有。如果它处理的是旧版本的图像,则文件存储中的全尺寸图和略缩图就产生了永久性的不一致。 -​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](../img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 +​ 出现这个问题是因为Web服务器和缩放器之间存在两个不同的信道:文件存储与消息队列。没有线性一致性的新鲜性保证,这两个信道之间的竞争条件是可能的。这种情况类似于[图9-1](img/fig9-1.png),数据库复制与Alice的嘴到Bob耳朵之间的真人音频信道之间也存在竞争条件。 ​ 线性一致性并不是避免这种竞争条件的唯一方法,但它是最容易理解的。如果你可以控制额外信道(例如消息队列的例子,而不是在Alice和Bob的例子),则可以使用在“[读己之写](ch5.md#读己之写)”讨论过的备选方法,不过会有额外的复杂度代价。 @@ -230,13 +230,13 @@ #### 线性一致性和法定人数 -​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](../img/fig9-6.png)所示。 +​ 直觉上在Dynamo风格的模型中,严格的法定人数读写应该是线性一致性的。但是当我们有可变的网络延迟时,就可能存在竞争条件,如[图9-6](img/fig9-6.png)所示。 -![](../img/fig9-6.png) +![](img/fig9-6.png) **图9-6 非线性一致的执行,尽管使用了严格的法定人数** -​ 在[图9-6](../img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 +​ 在[图9-6](img/fig9-6.png)中,$x$ 的初始值为0,写入客户端通过向所有三个副本( $n = 3, w = 3$ )发送写入将 $x$ 更新为 `1`。客户端A并发地从两个节点组成的法定人群( $r = 2$ )中读取数据,并在其中一个节点上看到新值 `1` 。客户端B也并发地从两个不同的节点组成的法定人数中读取,并从两个节点中取回了旧值 `0` 。 ​ 法定人数条件满足( $w + r> n$ ),但是这个执行是非线性一致的:B的请求在A的请求完成后开始,但是B返回旧值,而A返回新值。 (又一次,如同Alice和Bob的例子 [图9-1]()) @@ -252,9 +252,9 @@ ​ 一些复制方法可以提供线性一致性,另一些复制方法则不能,因此深入地探讨线性一致性的优缺点是很有趣的。 -​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](../img/fig9-7.png)说明了这种部署的一个例子。 +​ 我们已经在[第五章](ch5.md)中讨论了不同复制方法的一些用例。例如对多数据中心的复制而言,多主复制通常是理想的选择(参阅“[运维多个数据中心](ch5.md#运维多个数据中心)”)。[图9-7](img/fig9-7.png)说明了这种部署的一个例子。 -![](../img/fig9-7.png) +![](img/fig9-7.png) **图9-7 网络中断迫使在线性一致性和可用性之间做出选择。** @@ -311,7 +311,7 @@ ## 顺序保证 -​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](../img/fig9-4.png)中的顺序。 +​ 之前说过,线性一致寄存器的行为就好像只有单个数据副本一样,且每个操作似乎都是在某个时间点以原子性的方式生效的。这个定义意味着操作是按照某种良好定义的顺序执行的。我们通过操作(似乎)执行完毕的顺序来连接操作,以此说明[图9-4](img/fig9-4.png)中的顺序。 **顺序(ordering)**这一主题在本书中反复出现,这表明它可能是一个重要的基础性概念。让我们简要回顾一下其它**顺序**曾经出现过的上下文: @@ -325,12 +325,12 @@ **顺序**反复出现有几个原因,其中一个原因是,它有助于保持**因果关系(causality)**。在本书中我们已经看到了几个例子,其中因果关系是很重要的: -* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](../img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 -* [图5-9](../img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 +* 在“[一致前缀读](ch5.md#一致前缀读)”([图5-5](img/fig5-5.png))中,我们看到一个例子:一个对话的观察者首先看到问题的答案,然后才看到被回答的问题。这是令人困惑的,因为它违背了我们对**因(cause)**与**果(effect)**的直觉:如果一个问题被回答,显然问题本身得先在那里,因为给出答案的人必须看到这个问题(假如他们并没有预见未来的超能力)。我们认为在问题和答案之间存在**因果依赖(causal dependency)**。 +* [图5-9](img/fig5-9.png)中出现了类似的模式,我们看到三位领导者之间的复制,并注意到由于网络延迟,一些写入可能会“压倒”其他写入。从其中一个副本的角度来看,好像有一个对尚不存在的记录的更新操作。这里的因果意味着,一条记录必须先被创建,然后才能被更新。 * 在“[检测并发写入](ch5.md#检测并发写入)”中我们观察到,如果有两个操作A和B,则存在三种可能性:A发生在B之前,或B发生在A之前,或者A和B**并发**。这种**此前发生(happened before)**关系是因果关系的另一种表述:如果A在B前发生,那么意味着B可能已经知道了A,或者建立在A的基础上,或者依赖于A。如果A和B是**并发**的,那么它们之间并没有因果联系;换句话说,我们确信A和B不知道彼此。 -* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](../img/fig7-6)所示)。 -* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](../img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 -* 在爱丽丝和鲍勃看球的例子中([图9-1](../img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 +* 在事务快照隔离的上下文中(“[快照隔离和可重复读](ch7.md#快照隔离和可重复读)”),我们说事务是从一致性快照中读取的。但此语境中“一致”到底又是什么意思?这意味着**与因果关系保持一致(consistent with causality)**:如果快照包含答案,它也必须包含被回答的问题【48】。在某个时间点观察整个数据库,与因果关系保持一致意味着:因果上在该时间点之前发生的所有操作,其影响都是可见的,但因果上在该时间点之后发生的操作,其影响对观察者不可见。**读偏差(read skew)**意味着读取的数据处于违反因果关系的状态(不可重复读,如[图7-6](img/fig7-6)所示)。 +* 事务之间**写偏差(write skew)**的例子(参见“[写偏差和幻象](ch7.md#写偏差和幻象)”)也说明了因果依赖:在[图7-8](img/fig7-8.png)中,爱丽丝被允许离班,因为事务认为鲍勃仍在值班,反之亦然。在这种情况下,离班的动作因果依赖于对当前值班情况的观察。[可序列化的快照隔离](ch7.md#可序列化的快照隔离(SSI))通过跟踪事务之间的因果依赖来检测写偏差。 +* 在爱丽丝和鲍勃看球的例子中([图9-1](img/fig9-1.png)),在听到爱丽丝惊呼比赛结果后,鲍勃从服务器得到陈旧结果的事实违背了因果关系:爱丽丝的惊呼因果依赖于得分宣告,所以鲍勃应该也能在听到爱丽斯惊呼后查询到比分。相同的模式在“[跨信道的时序依赖](#跨信道的时序依赖)”一节中,以“图像大小调整服务”的伪装再次出现。 因果关系对事件施加了一种**顺序**:因在果之前;消息发送在消息收取之前。而且就像现实生活中一样,一件事会导致另一件事:某个节点读取了一些数据然后写入一些结果,另一个节点读取其写入的内容,并依次写入一些其他内容,等等。这些因果依赖的操作链定义了系统中的因果顺序,即,什么在什么之前发生。 @@ -350,7 +350,7 @@ ***线性一致性*** -​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](../img/fig9-4.png)中以时间线表示。 +​ 在线性一致的系统中,操作是全序的:如果系统表现的就好像只有一个数据副本,并且所有操作都是原子性的,这意味着对任何两个操作,我们总是能判定哪个操作先发生。这个全序[图9-4](img/fig9-4.png)中以时间线表示。 ***因果性*** @@ -358,13 +358,13 @@ ​ 因此,根据这个定义,在线性一致的数据存储中是不存在并发操作的:必须有且仅有一条时间线,所有的操作都在这条时间线上,构成一个全序关系。可能有几个请求在等待处理,但是数据存储确保了每个请求都是在唯一时间线上的某个时间点自动处理的,不存在任何并发。 -​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](../img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 +​ 并发意味着时间线会分岔然后合并 —— 在这种情况下,不同分支上的操作是无法比较的(即并发操作)。在[第五章](ch5.md)中我们看到了这种现象:例如,[图5-14](img/fig5-14.md) 并不是一条直线的全序关系,而是一堆不同的操作并发进行。图中的箭头指明了因果依赖 —— 操作的偏序。 ​ 如果你熟悉像Git这样的分布式版本控制系统,那么其版本历史与因果关系图极其相似。通常,一个**提交(Commit)**发生在另一个提交之后,在一条直线上。但是有时你会遇到分支(当多个人同时在一个项目上工作时),**合并(Merge)**会在这些并发创建的提交相融合时创建。 #### 线性一致性强于因果一致性 -​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](../img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 +​ 那么因果顺序和线性一致性之间的关系是什么?答案是线性一致性**隐含着(implies)**因果关系:任何线性一致的系统都能正确保持因果性【7】。特别是,如果系统中有多个通信通道(如[图9-5](img/fig9-5.png) 中的消息队列和文件存储服务),线性一致性可以自动保证因果性,系统无需任何特殊操作(如在不同组件间传递时间戳)。 ​ 线性一致性确保因果性的事实使线性一致系统变得简单易懂,更有吸引力。然而,正如“[线性一致性的代价](#线性一致性的代价)”中所讨论的,使系统线性一致可能会损害其性能和可用性,尤其是在系统具有严重的网络延迟的情况下(例如,如果系统在地理上散布)。出于这个原因,一些分布式数据系统已经放弃了线性一致性,从而获得更好的性能,但它们用起来也更为困难。 @@ -384,7 +384,7 @@ ​ 用于确定*哪些操作发生在其他操作之前* 的技术,与我们在“[检测并发写入](ch5.md#检测并发写入)”中所讨论的内容类似。那一节讨论了无领导者数据存储中的因果性:为了防止丢失更新,我们需要检测到对同一个键的并发写入。因果一致性则更进一步:它需要跟踪整个数据库中的因果依赖,而不仅仅是一个键。可以推广版本向量以解决此类问题【54】。 -​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](../img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 +​ 为了确定因果顺序,数据库需要知道应用读取了哪个版本的数据。这就是为什么在 [图5-13 ](img/fig5-13.png)中,来自先前操作的版本号在写入时被传回到数据库的原因。在SSI 的冲突检测中会出现类似的想法,如“[可序列化的快照隔离(SSI)]()”中所述:当事务要提交时,数据库将检查它所读取的数据版本是否仍然是最新的。为此,数据库跟踪哪些数据被哪些事务所读取。 @@ -417,7 +417,7 @@ * 每个节点每秒可以处理不同数量的操作。因此,如果一个节点产生偶数序列号而另一个产生奇数序列号,则偶数计数器可能落后于奇数计数器,反之亦然。如果你有一个奇数编号的操作和一个偶数编号的操作,你无法准确地说出哪一个操作在因果上先发生。 -* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](../img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] +* 来自物理时钟的时间戳会受到时钟偏移的影响,这可能会使其与因果不一致。例如[图8-3](img/fig8-3.png) 展示了一个例子,其中因果上晚发生的操作,却被分配了一个更早的时间戳。[^vii] [^viii]: 可以使物理时钟时间戳与因果关系保持一致:在“[用于全局快照的同步时钟](#用于全局快照的同步时钟)”中,我们讨论了Google的Spanner,它可以估计预期的时钟偏差,并在提交写入之前等待不确定性间隔。 这中方法确保了实际上靠后的事务会有更大的时间戳。 但是大多数时钟不能提供这种所需的不确定性度量。 @@ -429,9 +429,9 @@ ​ 尽管刚才描述的三个序列号生成器与因果不一致,但实际上有一个简单的方法来产生与因果关系一致的序列号。它被称为兰伯特时间戳,莱斯利·兰伯特(Leslie Lamport)于1978年提出【56】,现在是分布式系统领域中被引用最多的论文之一。 -​ [图9-8](../img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 +​ [图9-8](img/fig9-8.png) 说明了兰伯特时间戳的应用。每个节点都有一个唯一标识符,和一个保存自己执行操作数量的计数器。 兰伯特时间戳就是两者的简单组合:(计数器,节点ID)$(counter, node ID)$。两个节点有时可能具有相同的计数器值,但通过在时间戳中包含节点ID,每个时间戳都是唯一的。 -![](../img/fig9-8.png) +![](img/fig9-8.png) **图9-8 Lamport时间戳提供了与因果关系一致的总排序。** @@ -440,7 +440,7 @@ ​ 迄今,这个描述与上节所述的奇偶计数器基本类似。使兰伯特时间戳因果一致的关键思想如下所示:每个节点和每个客户端跟踪迄今为止所见到的最大**计数器**值,并在每个请求中包含这个最大计数器值。当一个节点收到最大计数器值大于自身计数器值的请求或响应时,它立即将自己的计数器设置为这个最大值。 -​ 这如 [图9-8](../img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 +​ 这如 [图9-8](img/fig9-8.png) 所示,其中客户端 A 从节点2 接收计数器值 `5` ,然后将最大值 `5` 发送到节点1 。此时,节点1 的计数器仅为 `1` ,但是它立即前移至 `5` ,所以下一个操作的计数器的值为 `6` 。 ​ 只要每一个操作都携带着最大计数器值,这个方案确保兰伯特时间戳的排序与因果一致,因为每个因果依赖都会导致时间戳增长。 @@ -504,7 +504,7 @@ #### 使用全序广播实现线性一致的存储 -​ 如 [图9-4](../img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 +​ 如 [图9-4](img/fig9-4.png) 所示,在线性一致的系统中,存在操作的全序。这是否意味着线性一致与全序广播一样?不尽然,但两者之间有者密切的联系[^x]。 [^x]: 从形式上讲,线性一致读写寄存器是一个“更容易”的问题。 全序广播等价于共识【67】,而共识问题在异步的崩溃-停止模型【68】中没有确定性的解决方案,而线性一致的读写寄存器**可以**在这种模型中实现【23,24,25】。 然而,支持诸如**比较并设置(CAS, compare-and-set)**,或**自增并返回(increment-and-get)**的原子操作使它等价于共识问题【28】。 因此,共识问题与线性一致寄存器问题密切相关。 @@ -601,7 +601,7 @@ * 某些提交请求可能在网络中丢失,最终由于超时而中止,而其他提交请求则通过。 * 在提交记录完全写入之前,某些节点可能会崩溃,并在恢复时回滚,而其他节点则成功提交。 -如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](../img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 +如果某些节点提交了事务,但其他节点却放弃了这些事务,那么这些节点就会彼此不一致(如 [图7-3](img/fig7-3.png) 所示)。而且一旦在某个节点上提交了一个事务,如果事后发现它在其它节点上被中止了,它是无法撤回的。出于这个原因,一旦确定事务中的所有其他节点也将提交,节点就必须进行提交。 ​ 事务提交必须是不可撤销的 —— 事务提交之后,你不能改变主意,并追溯性地中止事务。这个规则的原因是,一旦数据被提交,其结果就对其他事务可见,因此其他客户端可能会开始依赖这些数据。这个原则构成了**读已提交**隔离等级的基础,在“[读已提交](ch7.md#读已提交)”一节中讨论了这个问题。如果一个事务在提交后被允许中止,所有那些读取了**已提交却又被追溯声明不存在数据**的事务也必须回滚。 @@ -611,9 +611,9 @@ ​ **两阶段提交(two-phase commit)**是一种用于实现跨多个节点的原子事务提交的算法,即确保所有节点提交或所有节点中止。 它是分布式数据库中的经典算法【13,35,75】。 2PC在某些数据库内部使用,也以**XA事务**的形式对应用可用【76,77】(例如Java Transaction API支持)或以SOAP Web服务的`WS-AtomicTransaction` 形式提供给应用【78,79】。 -[ 图9-9](../img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 +[ 图9-9](img/fig9-9)说明了2PC的基本流程。2PC中的提交/中止过程分为两个阶段(因此而得名),而不是单节点事务中的单个提交请求。 -![](../img/fig9-9.png) +![](img/fig9-9.png) **图9-9 两阶段提交(2PC)的成功执行** @@ -653,9 +653,9 @@ ​ 如果协调者在发送**准备**请求之前失败,参与者可以安全地中止事务。但是,一旦参与者收到了准备请求并投了“是”,就不能再单方面放弃 —— 必须等待协调者回答事务是否已经提交或中止。如果此时协调者崩溃或网络出现故障,参与者什么也做不了只能等待。参与者的这种事务状态称为**存疑(in doubt)**的或**不确定(uncertain)**的。 -​ 情况如[图9-10](../img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 +​ 情况如[图9-10](img/fig9-10) 所示。在这个特定的例子中,协调者实际上决定提交,数据库2 收到提交请求。但是,协调者在将提交请求发送到数据库1 之前发生崩溃,因此数据库1 不知道是否提交或中止。即使**超时**在这里也没有帮助:如果数据库1 在超时后单方面中止,它将最终与执行提交的数据库2 不一致。同样,单方面提交也是不安全的,因为另一个参与者可能已经中止了。 -![](../img/fig9-10.png) +![](img/fig9-10.png)  **图9-10 参与者投赞成票后,协调者崩溃。数据库1不知道是否提交或中止** ​ 没有协调者的消息,参与者无法知道是提交还是放弃。原则上参与者可以相互沟通,找出每个参与者是如何投票的,并达成一致,但这不是2PC协议的一部分。 @@ -718,7 +718,7 @@ ​ 问题在于**锁(locking)**。正如在“[读已提交](ch7.md#读已提交)”中所讨论的那样,数据库事务通常获取待修改的行上的**行级排他锁**,以防止脏写。此外,如果要使用可序列化的隔离等级,则使用两阶段锁定的数据库也必须为事务所读取的行加上共享锁(参见“[两阶段锁定(2PL)](ch7.md#两阶段锁定(2PL))”)。 -​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](../img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 +​ 在事务提交或中止之前,数据库不能释放这些锁(如[图9-9](img/fig9-9.png)中的阴影区域所示)。因此,在使用两阶段提交时,事务必须在整个存疑期间持有这些锁。如果协调者已经崩溃,需要20分钟才能重启,那么这些锁将会被持有20分钟。如果协调者的日志由于某种原因彻底丢失,这些锁将被永久持有 —— 或至少在管理员手动解决该情况之前。 ​ 当这些锁被持有时,其他事务不能修改这些行。根据数据库的不同,其他事务甚至可能因为读取这些行而被阻塞。因此,其他事务没法儿简单地继续它们的业务了 —— 如果它们要访问同样的数据,就会被阻塞。这可能会导致应用大面积进入不可用状态,直到存疑事务被解决。 diff --git a/zh-cn/colophon.md b/colophon.md similarity index 100% rename from zh-cn/colophon.md rename to colophon.md diff --git a/zh-cn/glossary.md b/glossary.md similarity index 100% rename from zh-cn/glossary.md rename to glossary.md diff --git a/zh-cn/part-i.md b/part-i.md similarity index 100% rename from zh-cn/part-i.md rename to part-i.md diff --git a/zh-cn/part-ii.md b/part-ii.md similarity index 99% rename from zh-cn/part-ii.md rename to part-ii.md index e81ef7b1..197bfc05 100644 --- a/zh-cn/part-ii.md +++ b/part-ii.md @@ -59,9 +59,9 @@ ​ 将一个大型数据库拆分成较小的子集(称为**分区(partitions)**),从而不同的分区可以指派给不同的**节点(node)**(亦称**分片(shard)**)。 [第六章](ch6.md)将讨论分区。 -复制和分区是不同的机制,但它们经常同时使用。如[图II-1](../img/figii-1.png)所示。 +复制和分区是不同的机制,但它们经常同时使用。如[图II-1](img/figii-1.png)所示。 -![](../img/figii-1.png) +![](img/figii-1.png) **图II-1 一个数据库切分为两个分区,每个分区都有两个副本** diff --git a/zh-cn/part-iii.md b/part-iii.md similarity index 100% rename from zh-cn/part-iii.md rename to part-iii.md diff --git a/zh-cn/preface.md b/preface.md similarity index 100% rename from zh-cn/preface.md rename to preface.md diff --git a/translate.py b/scripts/translate.py similarity index 100% rename from translate.py rename to scripts/translate.py From 586d3d4a0f0d580a36842676bebdcebf45aa895d Mon Sep 17 00:00:00 2001 From: afunTW Date: Sat, 10 Oct 2020 19:46:06 +0800 Subject: [PATCH 11/12] update: README, add opencc contribution --- zh-tw/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/zh-tw/README.md b/zh-tw/README.md index f73d7688..1dabe5b7 100644 --- a/zh-tw/README.md +++ b/zh-tw/README.md @@ -89,8 +89,7 @@ 3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 與[第10章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by @[MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex) 4. 第一部分前言,ch2校正 by @jiajiadebug 5. 詞彙表、後記關於野豬的部分 by @[Chowss](https://github.com/Vonng/ddia/commits?author=Chowss) - -https://github.com/Vonng/ddia/pulls) +6. 簡體與繁體中文轉換工具 by [@afunTW](https://github.com/afunTW) using [OpenCC](https://github.com/BYVoid/OpenCC) 感謝所有作出貢獻,提出意見的朋友們:[Issues](https://github.com/Vonng/ddia/issues),[Pull Requests](https://github.com/Vonng/ddia/pulls) @@ -98,4 +97,4 @@ https://github.com/Vonng/ddia/pulls) ## LICENSE -CC-BY 4.0 \ No newline at end of file +CC-BY 4.0 From df8c1736e7614f89d7f798881b0808e03883e03b Mon Sep 17 00:00:00 2001 From: afunTW Date: Sat, 10 Oct 2020 19:46:40 +0800 Subject: [PATCH 12/12] update: typo --- zh-tw/ch1.md | 4 ++-- zh-tw/ch2.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/zh-tw/ch1.md b/zh-tw/ch1.md index 89f01576..e4dcf766 100644 --- a/zh-tw/ch1.md +++ b/zh-tw/ch1.md @@ -160,7 +160,7 @@ ***釋出推文*** -​ 使用者可以向其粉絲髮布新訊息(平均 4.6k請求/秒,峰值超過 12k請求/秒)。 +​ 使用者可以向其粉絲發佈新訊息(平均 4.6k請求/秒,峰值超過 12k請求/秒)。 ***主頁時間線*** @@ -195,7 +195,7 @@ 推特的第一個版本使用了方法1,但系統很難跟上主頁時間線查詢的負載。所以公司轉向了方法2,方法2的效果更好,因為發推頻率比查詢主頁時間線的頻率幾乎低了兩個數量級,所以在這種情況下,最好在寫入時做更多的工作,而在讀取時做更少的工作。 -​ 然而方法2的缺點是,發推現在需要大量的額外工作。平均來說,一條推文會發往約75個關注者,所以每秒4.6k的發推寫入,變成了對主頁時間線快取每秒345k的寫入。但這個平均值隱藏了使用者粉絲數差異巨大這一現實,一些使用者有超過3000萬的粉絲,這意味著一條推文就可能會導致主頁時間線快取的3000萬次寫入!及時完成這種操作是一個巨大的挑戰 —— 推特嘗試在5秒內向粉絲髮送推文。 +​ 然而方法2的缺點是,發推現在需要大量的額外工作。平均來說,一條推文會發往約75個關注者,所以每秒4.6k的發推寫入,變成了對主頁時間線快取每秒345k的寫入。但這個平均值隱藏了使用者粉絲數差異巨大這一現實,一些使用者有超過3000萬的粉絲,這意味著一條推文就可能會導致主頁時間線快取的3000萬次寫入!及時完成這種操作是一個巨大的挑戰 —— 推特嘗試在5秒內向粉絲發送推文。 ​ 在推特的例子中,每個使用者粉絲數的分佈(可能按這些使用者的發推頻率來加權)是探討可擴充套件性的一個關鍵負載引數,因為它決定了扇出負載。你的應用程式可能具有非常不同的特徵,但可以採用相似的原則來考慮它的負載。 diff --git a/zh-tw/ch2.md b/zh-tw/ch2.md index 668c8914..c2b38690 100644 --- a/zh-tw/ch2.md +++ b/zh-tw/ch2.md @@ -272,13 +272,13 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL 在上述情況下,模式的壞處遠大於它的幫助,無模式文件可能是一個更加自然的資料模型。但是,要是所有記錄都具有相同的結構,那麼模式是記錄並強制這種結構的有效機制。第四章將更詳細地討論模式和模式演化。 -#### 查詢的資料區域性性 +#### 查詢的資料區域性 -文件通常以單個連續字串形式進行儲存,編碼為JSON,XML或其二進位制變體(如MongoDB的BSON)。如果應用程式經常需要訪問整個文件(例如,將其渲染至網頁),那麼儲存區域性性會帶來效能優勢。如果將資料分割到多個表中(如[圖2-1](../img/fig2-1.png)所示),則需要進行多次索引查詢才能將其全部檢索出來,這可能需要更多的磁碟查詢並花費更多的時間。 +文件通常以單個連續字串形式進行儲存,編碼為JSON,XML或其二進位制變體(如MongoDB的BSON)。如果應用程式經常需要訪問整個文件(例如,將其渲染至網頁),那麼儲存區域性會帶來效能優勢。如果將資料分割到多個表中(如[圖2-1](../img/fig2-1.png)所示),則需要進行多次索引查詢才能將其全部檢索出來,這可能需要更多的磁碟查詢並花費更多的時間。 區域性性僅僅適用於同時需要文件絕大部分內容的情況。資料庫通常需要載入整個文件,即使只訪問其中的一小部分,這對於大型文件來說是很浪費的。更新文件時,通常需要整個重寫。只有不改變文件大小的修改才可以容易地原地執行。因此,通常建議保持相對小的文件,並避免增加文件大小的寫入【9】。這些效能限制大大減少了文件資料庫的實用場景。 -值得指出的是,為了區域性性而分組集合相關資料的想法並不侷限於文件模型。例如,Google的Spanner資料庫在關係資料模型中提供了同樣的區域性性屬性,允許模式宣告一個表的行應該交錯(巢狀)在父表內【27】。Oracle類似地允許使用一個稱為 **多表索引叢集表(multi-table index cluster tables)** 的類似特性【28】。Bigtable資料模型(用於Cassandra和HBase)中的 **列族(column-family)** 概念與管理區域性性的目的類似【29】。 +值得指出的是,為了區域性而分組集合相關資料的想法並不侷限於文件模型。例如,Google的Spanner資料庫在關係資料模型中提供了同樣的區域性性屬性,允許模式宣告一個表的行應該交錯(巢狀)在父表內【27】。Oracle類似地允許使用一個稱為 **多表索引叢集表(multi-table index cluster tables)** 的類似特性【28】。Bigtable資料模型(用於Cassandra和HBase)中的 **列族(column-family)** 概念與管理區域性性的目的類似【29】。 在[第3章](ch3.md)將還會看到更多關於區域性性的內容。