2
0
Fork 0
mirror of https://github.com/Vonng/ddia.git synced 2026-06-21 00:47:05 +08:00

fix missing img and anchor

This commit is contained in:
Feng Ruohang 2026-02-15 15:53:37 +08:00
parent d11a2936ae
commit 661692fc4f
25 changed files with 7825 additions and 711 deletions

View file

@ -42,10 +42,10 @@ breadcrumbs: false
為了幫助你瞭解可以做出哪些選擇,本章比較了幾個對比概念,並探討了它們的權衡:
* 事務型系統和分析型系統之間的區別(["分析型與事務型系統"](/tw/ch1#sec_introduction_analytics)
* 雲服務和自託管系統的利弊(["雲服務與自託管"](/tw/ch1#sec_introduction_cloud)
* 何時從單節點系統轉向分散式系統(["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed));以及
* 平衡業務需求和使用者權利(["資料系統、法律與社會"](/tw/ch1#sec_introduction_compliance))。
* 事務型系統和分析型系統之間的區別(["分析型與事務型系統"](#sec_introduction_analytics)
* 雲服務和自託管系統的利弊(["雲服務與自託管"](#sec_introduction_cloud)
* 何時從單節點系統轉向分散式系統(["分散式與單節點系統"](#sec_introduction_distributed));以及
* 平衡業務需求和使用者權利(["資料系統、法律與社會"](#sec_introduction_compliance))。
此外,本章還會引入貫穿全書的關鍵術語。
@ -58,7 +58,7 @@ breadcrumbs: false
## 分析型與事務型系統 {#sec_introduction_analytics}
如果你在企業中從事資料系統工作,往往會遇到幾類不同的資料使用者。第一類是 **後端工程師**,他們構建服務來處理讀取與更新資料的請求;這些服務通常直接面向外部使用者,或透過其他服務間接提供能力(參見["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices))。有時服務也只供組織內部使用。
如果你在企業中從事資料系統工作,往往會遇到幾類不同的資料使用者。第一類是 **後端工程師**,他們構建服務來處理讀取與更新資料的請求;這些服務通常直接面向外部使用者,或透過其他服務間接提供能力(參見["微服務與無伺服器"](#sec_introduction_microservices))。有時服務也只供組織內部使用。
除了管理後端服務的團隊外,通常還有兩類人需要訪問組織的資料:**業務分析師**,他們生成關於組織活動的報告,以幫助管理層做出更好的決策(**商業智慧** 或 **BI**);以及 **資料科學家**他們在資料中尋找新的見解或建立由資料分析和機器學習AI支援的面向使用者的產品功能例如電子商務網站上的“購買了 X 的人也購買了 Y”推薦、風險評分或垃圾郵件過濾等預測分析以及搜尋結果排名
@ -86,7 +86,7 @@ breadcrumbs: false
* 在我們最近的促銷期間,我們比平時多賣出了多少香蕉?
* 哪個品牌的嬰兒食品最常與 X 品牌尿布一起購買?
這些型別的查詢產生的報告對商業智慧很重要,可以幫助管理層決定下一步做什麼。為了將這種使用資料庫的模式與事務處理區分開來,它被稱為 **聯機分析處理**OLAP[^5]。OLTP 和分析之間的區別並不總是很明確,但[表 1-1](/tw/ch1#tab_oltp_vs_olap) 列出了一些典型特徵。
這些型別的查詢產生的報告對商業智慧很重要,可以幫助管理層決定下一步做什麼。為了將這種使用資料庫的模式與事務處理區分開來,它被稱為 **聯機分析處理**OLAP[^5]。OLTP 和分析之間的區別並不總是很明確,但[表 1-1](#tab_oltp_vs_olap) 列出了一些典型特徵。
{{< figure id="tab_oltp_vs_olap" title="表 1-1. 事務型系統和分析型系統特徵比較" class="w-full my-4" >}}
@ -122,7 +122,7 @@ breadcrumbs: false
相比之下,**資料倉庫** 是一個單獨的資料庫,分析師可以隨心所欲地查詢,而不會影響 OLTP 操作 [^7]。正如我們將在[第 4 章](/tw/ch4#ch_storage)中看到的,資料倉庫通常以與 OLTP 資料庫非常不同的方式儲存資料,以最佳化分析中常見的查詢型別。
資料倉庫包含公司中所有各種 OLTP 系統中資料的只讀副本。資料從 OLTP 資料庫中提取(使用定期資料轉儲或連續更新流),轉換為分析友好的模式,進行清理,然後載入到資料倉庫中。這種將資料匯入資料倉庫的過程稱為 **提取-轉換-載入**ETL如[圖 1-1](/tw/ch1#fig_dwh_etl) 所示。有時 **轉換****載入** 步驟的順序會互換(即,先載入,再在資料倉庫中進行轉換),從而產生 **ELT**
資料倉庫包含公司中所有各種 OLTP 系統中資料的只讀副本。資料從 OLTP 資料庫中提取(使用定期資料轉儲或連續更新流),轉換為分析友好的模式,進行清理,然後載入到資料倉庫中。這種將資料匯入資料倉庫的過程稱為 **提取-轉換-載入**ETL如[圖 1-1](#fig_dwh_etl) 所示。有時 **轉換****載入** 步驟的順序會互換(即,先載入,再在資料倉庫中進行轉換),從而產生 **ELT**
{{< figure src="/fig/ddia_0101.png" id="fig_dwh_etl" caption="圖 1-1. ETL 到資料倉庫的簡化概述。" class="w-full my-4" >}}
@ -130,7 +130,7 @@ breadcrumbs: false
一些資料庫系統提供 **混合事務/分析處理**HTAP目標是在單個系統中同時支援 OLTP 和分析,而無需從一個系統 ETL 到另一個系統 [^8] [^9]。然而,許多 HTAP 系統內部由一個 OLTP 系統與一個單獨的分析系統耦合組成,隱藏在公共介面後面——因此兩者之間的區別對於理解這些系統如何工作仍然很重要。
此外,儘管 HTAP 已出現,但由於目標和約束不同,事務型系統與分析型系統分離仍很常見。尤其是,讓每個事務型系統擁有自己的資料庫通常被視為良好實踐(參見["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices)),這會形成數百個相互獨立的事務型資料庫;與之對應,企業往往只有一個統一的資料倉庫,以便分析師能在單個查詢裡組合多個事務型系統的資料。
此外,儘管 HTAP 已出現,但由於目標和約束不同,事務型系統與分析型系統分離仍很常見。尤其是,讓每個事務型系統擁有自己的資料庫通常被視為良好實踐(參見["微服務與無伺服器"](#sec_introduction_microservices)),這會形成數百個相互獨立的事務型資料庫;與之對應,企業往往只有一個統一的資料倉庫,以便分析師能在單個查詢裡組合多個事務型系統的資料。
因此HTAP 不會取代資料倉庫。相反,它在同一應用程式既需要執行掃描大量行的分析查詢,又需要以低延遲讀取和更新單個記錄的場景中很有用。例如,欺詐檢測可能涉及此類工作負載 [^10]。
@ -145,7 +145,7 @@ breadcrumbs: false
儘管已經有人在努力將機器學習運算元新增到 SQL 資料模型 [^12] 並在關係基礎上構建高效的機器學習系統 [^13],但許多資料科學家不喜歡在資料倉庫等關係資料庫中工作。相反,許多人更喜歡使用 Python 資料分析庫(如 pandas 和 scikit-learn、統計分析語言如 R和分散式分析框架如 Spark[^14]。我們將在["資料框、矩陣和陣列"](/tw/ch3#sec_datamodels_dataframes)中進一步討論這些。
因此,組織面臨著以適合資料科學家使用的形式提供資料的需求。答案是 **資料湖**:一個集中的資料儲存庫,儲存任何可能對分析有用的資料副本,透過 ETL 過程從事務型系統獲得。與資料倉庫的區別在於,資料湖只是包含檔案,而不強制任何特定的檔案格式或資料模型。資料湖中的檔案可能是資料庫記錄的集合,使用 Avro 或 Parquet 等檔案格式編碼(參見[第 5 章](/tw/ch5#ch_encoding)),但它們同樣可以包含文字、影像、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列或任何其他型別的資料 [^15]。除了更靈活之外,這通常也比關係資料儲存更便宜,因為資料湖可以使用商品化的檔案儲存,如物件儲存(參見["雲原生系統架構"](/tw/ch1#sec_introduction_cloud_native))。
因此,組織面臨著以適合資料科學家使用的形式提供資料的需求。答案是 **資料湖**:一個集中的資料儲存庫,儲存任何可能對分析有用的資料副本,透過 ETL 過程從事務型系統獲得。與資料倉庫的區別在於,資料湖只是包含檔案,而不強制任何特定的檔案格式或資料模型。資料湖中的檔案可能是資料庫記錄的集合,使用 Avro 或 Parquet 等檔案格式編碼(參見[第 5 章](/tw/ch5#ch_encoding)),但它們同樣可以包含文字、影像、影片、感測器讀數、稀疏矩陣、特徵向量、基因組序列或任何其他型別的資料 [^15]。除了更靈活之外,這通常也比關係資料儲存更便宜,因為資料湖可以使用商品化的檔案儲存,如物件儲存(參見["雲原生系統架構"](#sec_introduction_cloud_native))。
ETL 過程已經泛化為 **資料管道**,在某些情況下,資料湖已成為從事務型系統到資料倉庫路徑上的中間站。資料湖包含事務型系統產生的“原始”形式的資料,沒有轉換為關係資料倉庫模式。這種方法的優勢在於,每個資料消費者都可以將原始資料轉換為最適合其需求的形式。它被稱為 **壽司原則**:“原始資料更好”[^16]。
@ -155,7 +155,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
#### 超越資料湖 {#beyond-the-data-lake}
隨著分析實踐的成熟,組織越來越重視分析系統與資料管道的管理和運維,這一點在 DataOps 宣言中已有體現 [^18]。其中一部分是治理、隱私以及對 GDPR、CCPA 等法規的遵從;我們會在["資料系統、法律與社會"](/tw/ch1#sec_introduction_compliance)和["立法與行業自律"](/ch14#sec_future_legislation)中討論。
隨著分析實踐的成熟,組織越來越重視分析系統與資料管道的管理和運維,這一點在 DataOps 宣言中已有體現 [^18]。其中一部分是治理、隱私以及對 GDPR、CCPA 等法規的遵從;我們會在["資料系統、法律與社會"](#sec_introduction_compliance)和["立法與行業自律"](/ch14#sec_future_legislation)中討論。
此外,分析資料的提供形式也越來越多樣:不僅有檔案和關係表,也有事件流(見[第 12 章](/tw/ch12#ch_stream))。基於檔案的分析通常透過週期性重跑(例如每天一次)來響應資料變化,而流處理能夠讓分析系統在秒級響應事件。對於時效性要求高的場景,這種方式很有價值,例如識別並阻斷潛在的欺詐或濫用行為。
@ -189,7 +189,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
歸根結底,這是一個關於業務優先順序的問題。公認的管理智慧是,作為組織核心競爭力或競爭優勢的事物應該在內部完成,而非核心、例行或常見的事物應該留給供應商 [^21]。
舉一個極端的例子,大多數公司不會自己發電(除非他們是能源公司,而且不考慮緊急備用電源),因為從電網購買電力更便宜。
對於軟體,需要做出的兩個重要決定是誰構建軟體和誰部署它。有一系列可能性,每個決定都在不同程度上外包,如[圖 1-2](/tw/ch1#fig_cloud_spectrum) 所示。
對於軟體,需要做出的兩個重要決定是誰構建軟體和誰部署它。有一系列可能性,每個決定都在不同程度上外包,如[圖 1-2](#fig_cloud_spectrum) 所示。
一個極端是你自己編寫並在內部執行的定製軟體另一個極端是廣泛使用的雲服務或軟體即服務SaaS產品由外部供應商實施和運營你只能透過 Web 介面或 API 訪問。
{{< figure src="/fig/ddia_0102.png" id="fig_cloud_spectrum" caption="圖 1-2. 軟體型別及其運維的範圍。" class="w-full my-4" >}}
@ -212,7 +212,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
另一方面,如果你需要一個你還不知道如何部署和運維的系統,那麼採用雲服務通常比學習自己管理系統更容易、更快。
如果你必須專門僱用和培訓員工來維護和運營系統,那可能會變得非常昂貴。
使用雲時你仍然需要一個運維團隊(參見["雲時代的運維"](/tw/ch1#sec_introduction_operations)),但外包基本的系統管理可以讓你的團隊專注於更高層次的問題。
使用雲時你仍然需要一個運維團隊(參見["雲時代的運維"](#sec_introduction_operations)),但外包基本的系統管理可以讓你的團隊專注於更高層次的問題。
當你將系統的運維外包給專門運維該服務的公司時,可能會帶來更好的服務,因為供應商在向許多客戶提供服務中獲得了專業運維知識。
另一方面,如果你自己運維服務,你可以配置和調整它,以專門針對你特定的工作負載進行最佳化,而云服務不太可能願意替你進行此類定製。
@ -241,7 +241,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
術語 **雲原生** 用於描述旨在利用雲服務的架構。
原則上,幾乎任何可自託管的軟體都可以做成雲服務;事實上,許多主流資料系統都已有託管版本。
不過,從零設計為雲原生的系統已經展示出若干優勢:同等硬體下效能更好、故障恢復更快、能更快按負載擴縮計算資源,並支援更大資料集 [^25] [^26] [^27]。[表 1-2](/tw/ch1#tab_cloud_native_dbs) 給出兩類系統的一些示例。
不過,從零設計為雲原生的系統已經展示出若干優勢:同等硬體下效能更好、故障恢復更快、能更快按負載擴縮計算資源,並支援更大資料集 [^25] [^26] [^27]。[表 1-2](#tab_cloud_native_dbs) 給出兩類系統的一些示例。
{{< figure id="tab_cloud_native_dbs" title="表 1-2. 自託管與雲原生資料庫系統示例" class="w-full my-4" >}}
@ -275,7 +275,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
為了解決這個問題,雲原生服務通常避免使用虛擬磁碟,而是建立在針對特定工作負載最佳化的專用儲存服務之上。物件儲存服務(如 S3設計用於長期儲存相當大的檔案大小從數百 KB 到幾 GB 不等。資料庫中儲存的單個行或值通常比這小得多;因此,雲資料庫通常在單獨的服務中管理較小的值,並將較大的資料塊(包含許多單個值)儲存在物件儲存中 [^26] [^29]。我們將在[第 4 章](/tw/ch4#ch_storage)中看到這樣做的方法。
在傳統的系統架構中同一臺計算機負責儲存磁碟和計算CPU 和 RAM但在雲原生系統中這兩個職責已經在某種程度上分離或 **解耦** [^9] [^27] [^30] [^31]例如S3 只儲存檔案,如果你想分析該資料,你必須在 S3 之外的某個地方執行分析程式碼。這意味著透過網路傳輸資料,我們將在["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed)中進一步討論。
在傳統的系統架構中同一臺計算機負責儲存磁碟和計算CPU 和 RAM但在雲原生系統中這兩個職責已經在某種程度上分離或 **解耦** [^9] [^27] [^30] [^31]例如S3 只儲存檔案,如果你想分析該資料,你必須在 S3 之外的某個地方執行分析程式碼。這意味著透過網路傳輸資料,我們將在["分散式與單節點系統"](#sec_introduction_distributed)中進一步討論。
此外,雲原生系統通常是 **多租戶** 的,這意味著不是每個客戶都有一臺單獨的機器,而是來自幾個不同客戶的資料和計算由同一服務在同一共享硬體上處理 [^32]。
@ -305,7 +305,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
採用雲服務可能比執行自己的基礎設施更容易、更快,儘管學習如何使用它也有成本,也許還要解決其限制。隨著越來越多的供應商提供針對不同用例的更廣泛的雲服務,不同服務之間的整合成為一個特別的挑戰 [^39] [^40]。
ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一部分;面向事務處理的雲服務之間也需要相互整合。目前,缺乏能促進這類整合的標準,因此往往仍要投入大量手工工作。
ETL參見["資料倉庫"](#sec_introduction_dwh))只是故事的一部分;面向事務處理的雲服務之間也需要相互整合。目前,缺乏能促進這類整合的標準,因此往往仍要投入大量手工工作。
無法完全外包給雲服務的其他運維方面包括維護應用程式及其使用的庫的安全性、管理你自己的服務之間的互動、監控服務的負載,以及追蹤問題的原因,例如效能下降或中斷。雖然雲正在改變運維的角色,但對運維的需求比以往任何時候都大。
@ -399,7 +399,7 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
每個從事此類系統工作的人都有責任考慮道德影響並確保他們遵守相關法律。沒有必要讓每個人都成為法律和道德專家,但對法律和道德原則的基本認識與分散式系統中的一些基礎知識同樣重要。
法律考慮正在影響資料系統設計的基礎 [^61]。例如GDPR 授予個人在請求時刪除其資料的權利(有時稱為 **被遺忘權**)。然而,正如我們將在本書中看到的,許多資料系統依賴不可變構造(如僅追加日誌)作為其設計的一部分;我們如何確保刪除應該不可變的檔案中間的某些資料?我們如何處理已被納入派生資料集(參見["記錄系統與派生資料"](/tw/ch1#sec_introduction_derived))的資料刪除,例如機器學習模型的訓練資料?回答這些問題會帶來新的工程挑戰。
法律考慮正在影響資料系統設計的基礎 [^61]。例如GDPR 授予個人在請求時刪除其資料的權利(有時稱為 **被遺忘權**)。然而,正如我們將在本書中看到的,許多資料系統依賴不可變構造(如僅追加日誌)作為其設計的一部分;我們如何確保刪除應該不可變的檔案中間的某些資料?我們如何處理已被納入派生資料集(參見["記錄系統與派生資料"](#sec_introduction_derived))的資料刪除,例如機器學習模型的訓練資料?回答這些問題會帶來新的工程挑戰。
目前,我們對於哪些特定技術或系統架構應被視為“符合 GDPR”沒有明確的指導方針。法規故意不強制要求特定技術因為隨著技術的進步這些技術可能會迅速變化。相反法律文字規定了需要解釋的高層級原則。這意味著如何遵守隱私法規的問題沒有簡單的答案但我們將透過這個視角來看待本書中的一些技術。

View file

@ -48,25 +48,25 @@ breadcrumbs: false
{{< figure src="/fig/ddia_1001.png" id="fig_consistency_linearizability_0" caption="圖 10-1. 如果這個資料庫是線性一致的,那麼 Alice 的讀取要麼返回 1 而不是 0要麼 Bob 的讀取返回 0 而不是 1。" class="w-full my-4" >}}
[圖 10-1](/tw/ch10#fig_consistency_linearizability_0) 顯示了一個非線性一致的體育網站示例 [^4]。Aaliyah 和 Bryce 坐在同一個房間裡都在檢視手機想要了解他們最喜歡的球隊比賽的結果。就在最終比分宣佈後Aaliyah 重新整理了頁面,看到了獲勝者的公告,並興奮地告訴了 Bryce。Bryce 懷疑地在自己的手機上點選了 *重新整理*,但他的請求傳送到了一個滯後的資料庫副本,因此他的手機顯示比賽仍在進行中。
[圖 10-1](#fig_consistency_linearizability_0) 顯示了一個非線性一致的體育網站示例 [^4]。Aaliyah 和 Bryce 坐在同一個房間裡都在檢視手機想要了解他們最喜歡的球隊比賽的結果。就在最終比分宣佈後Aaliyah 重新整理了頁面,看到了獲勝者的公告,並興奮地告訴了 Bryce。Bryce 懷疑地在自己的手機上點選了 *重新整理*,但他的請求傳送到了一個滯後的資料庫副本,因此他的手機顯示比賽仍在進行中。
如果 Aaliyah 和 Bryce 同時點選重新整理他們得到兩個不同的查詢結果就不會那麼令人驚訝了因為他們不知道他們各自的請求在伺服器上被處理的確切時間。然而Bryce 知道他是在聽到 Aaliyah 宣佈最終比分 *之後* 點選重新整理按鈕(發起查詢)的,因此他期望他的查詢結果至少與 Aaliyah 的一樣新。他的查詢返回過時結果這一事實違反了線性一致性。
### 什麼使系統具有線性一致性? {#sec_consistency_lin_definition}
為了更好地理解線性一致性,讓我們看一些更多的例子。[圖 10-2](/tw/ch10#fig_consistency_linearizability_1) 顯示了三個客戶端在線性一致資料庫中併發讀取和寫入同一個物件 *x*。在分散式系統理論中,*x* 被稱為 *暫存器*——在實踐中,它可能是鍵值儲存中的一個鍵,關係資料庫中的一行,或者文件資料庫中的一個文件,例如。
為了更好地理解線性一致性,讓我們看一些更多的例子。[圖 10-2](#fig_consistency_linearizability_1) 顯示了三個客戶端在線性一致資料庫中併發讀取和寫入同一個物件 *x*。在分散式系統理論中,*x* 被稱為 *暫存器*——在實踐中,它可能是鍵值儲存中的一個鍵,關係資料庫中的一行,或者文件資料庫中的一個文件,例如。
{{< figure src="/fig/ddia_1002.png" id="fig_consistency_linearizability_1" caption="圖 10-2. Alice 觀察到 x = 0 且 y = 1而 Bob 觀察到 x = 1 且 y = 0。就好像 Alice 和 Bob 的計算機對寫入發生的順序意見不一。" class="w-full my-4" >}}
為簡單起見,[圖 10-2](/tw/ch10#fig_consistency_linearizability_1) 僅顯示了從客戶端角度看的請求,而不是資料庫的內部。每個條形代表客戶端發出的請求,條形的開始是傳送請求的時間,條形的結束是客戶端收到響應的時間。由於網路延遲可變,客戶端不知道資料庫確切何時處理了它的請求——它只知道必須在客戶端傳送請求和接收響應之間的某個時間發生。
為簡單起見,[圖 10-2](#fig_consistency_linearizability_1) 僅顯示了從客戶端角度看的請求,而不是資料庫的內部。每個條形代表客戶端發出的請求,條形的開始是傳送請求的時間,條形的結束是客戶端收到響應的時間。由於網路延遲可變,客戶端不知道資料庫確切何時處理了它的請求——它只知道必須在客戶端傳送請求和接收響應之間的某個時間發生。
在這個例子中,暫存器有兩種型別的操作:
* *read*(*x*) ⇒ *v* 表示客戶端請求讀取暫存器 *x* 的值,資料庫返回值 *v*
* *write*(*x*, *v*) ⇒ *r* 表示客戶端請求將暫存器 *x* 設定為值 *v*,資料庫返回響應 *r*(可能是 *ok**error*)。
在 [圖 10-2](/tw/ch10#fig_consistency_linearizability_1) 中,*x* 的值最初為 0客戶端 C 執行寫入請求將其設定為 1。在此期間客戶端 A 和 B 反覆輪詢資料庫以讀取最新值。A 和 B 的讀取請求可能得到什麼響應?
在 [圖 10-2](#fig_consistency_linearizability_1) 中,*x* 的值最初為 0客戶端 C 執行寫入請求將其設定為 1。在此期間客戶端 A 和 B 反覆輪詢資料庫以讀取最新值。A 和 B 的讀取請求可能得到什麼響應?
* 客戶端 A 的第一個讀取操作在寫入開始之前完成,因此它必須明確返回舊值 0。
* 客戶端 A 的最後一次讀取在寫入完成後開始,因此如果資料庫是線性一致的,它必須明確返回新值 1因為讀取必須在寫入之後被處理。
@ -74,32 +74,32 @@ breadcrumbs: false
然而,這還不足以完全描述線性一致性:如果與寫入併發的讀取可以返回舊值或新值,那麼讀者可能會在寫入進行時多次看到值在舊值和新值之間來回翻轉。這不是我們對模擬"單一資料副本"的系統所期望的。
為了使系統線性一致,我們需要新增另一個約束,如 [圖 10-3](/tw/ch10#fig_consistency_linearizability_2) 所示。
為了使系統線性一致,我們需要新增另一個約束,如 [圖 10-3](#fig_consistency_linearizability_2) 所示。
{{< figure src="/fig/ddia_1003.png" id="fig_consistency_linearizability_2" caption="圖 10-3. 如果 Alice 和 Bob 有完美的時鐘,線性一致性將要求返回 x = 1因為 x 的讀取在寫入 x = 1 完成後開始。" class="w-full my-4" >}}
在線性一致系統中,我們想象必須有某個時間點(在寫入操作的開始和結束之間),*x* 的值從 0 原子地翻轉到 1。因此如果一個客戶端的讀取返回新值 1所有後續讀取也必須返回新值即使寫入操作尚未完成。
這種時序依賴關係在 [圖 10-3](/tw/ch10#fig_consistency_linearizability_2) 中用箭頭表示。客戶端 A 是第一個讀取新值 1 的。就在 A 的讀取返回後B 開始新的讀取。由於 B 的讀取嚴格發生在 A 的讀取之後,它也必須返回 1即使 C 的寫入仍在進行中。(這與 [圖 10-1](/tw/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同:在 Aaliyah 讀取新值後Bryce 也期望讀取新值。)
這種時序依賴關係在 [圖 10-3](#fig_consistency_linearizability_2) 中用箭頭表示。客戶端 A 是第一個讀取新值 1 的。就在 A 的讀取返回後B 開始新的讀取。由於 B 的讀取嚴格發生在 A 的讀取之後,它也必須返回 1即使 C 的寫入仍在進行中。(這與 [圖 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同:在 Aaliyah 讀取新值後Bryce 也期望讀取新值。)
我們可以進一步細化這個時序圖,以視覺化每個操作在某個時間點原子地生效 [^5],就像 [圖 10-4](/tw/ch10#fig_consistency_linearizability_3) 中顯示的更複雜的例子。在這個例子中,除了 *read**write* 之外,我們添加了第三種操作型別:
我們可以進一步細化這個時序圖,以視覺化每個操作在某個時間點原子地生效 [^5],就像 [圖 10-4](#fig_consistency_linearizability_3) 中顯示的更複雜的例子。在這個例子中,除了 *read**write* 之外,我們添加了第三種操作型別:
* *cas*(*x*, *v*old, *v*new) ⇒ *r* 表示客戶端請求一個原子 *比較並設定* 操作(見 ["條件寫入(比較並設定)"](/tw/ch8#sec_transactions_compare_and_set))。如果暫存器 *x* 的當前值等於 *v*old它應該原子地設定為 *v*new。如果 *x* 的值與 *v*old 不同,則操作應該保持暫存器不變並返回錯誤。*r* 是資料庫的響應(*ok* 或 *error*)。
[圖 10-4](/tw/ch10#fig_consistency_linearizability_3) 中的每個操作都用一條垂直線(在每個操作的條形內)標記,表示我們認為操作執行的時間。這些標記按順序連線起來,結果必須是暫存器的有效讀寫序列(每次讀取必須返回最近寫入設定的值)。
[圖 10-4](#fig_consistency_linearizability_3) 中的每個操作都用一條垂直線(在每個操作的條形內)標記,表示我們認為操作執行的時間。這些標記按順序連線起來,結果必須是暫存器的有效讀寫序列(每次讀取必須返回最近寫入設定的值)。
線性一致性的要求是連線操作標記的線始終向前移動(從左到右),永不後退。這個要求確保了我們之前討論的新鮮度保證:一旦寫入或讀取了新值,所有後續讀取都會看到寫入的值,直到它再次被覆蓋。
{{< figure src="/fig/ddia_1004.png" id="fig_consistency_linearizability_3" caption="圖 10-4. x 的讀取與寫入 x = 1 併發。由於我們不知道操作的確切時序,讀取可以返回 0 或 1。" class="w-full my-4" >}}
[圖 10-4](/tw/ch10#fig_consistency_linearizability_3) 中有一些有趣的細節需要指出:
[圖 10-4](#fig_consistency_linearizability_3) 中有一些有趣的細節需要指出:
* 首先客戶端 B 傳送了讀取 *x* 的請求,然後客戶端 D 傳送了將 *x* 設定為 0 的請求,然後客戶端 A 傳送了將 *x* 設定為 1 的請求。然而,返回給 B 的讀取值是 1A 寫入的值)。這是可以的:這意味著資料庫首先處理了 D 的寫入,然後是 A 的寫入,最後是 B 的讀取。雖然這不是傳送請求的順序,但這是一個可接受的順序,因為這三個請求是併發的。也許 B 的讀取請求在網路中稍有延遲,因此它在兩次寫入之後才到達資料庫。
* 客戶端 B 的讀取在客戶端 A 收到資料庫的響應之前返回了 1表示值 1 的寫入成功。這也是可以的:這只是意味著從資料庫到客戶端 A 的 *ok* 響應在網路中稍有延遲。
* 這個模型不假設任何事務隔離另一個客戶端可以隨時更改值。例如C 首先讀取 1然後讀取 2因為該值在兩次讀取之間被 B 更改了。原子比較並設定(*cas*操作可用於檢查值是否未被另一個客戶端併發更改B 和 C 的 *cas* 請求成功,但 D 的 *cas* 請求失敗(到資料庫處理它時,*x* 的值不再是 0
* 客戶端 B 的最後一次讀取(在陰影條中)不是線性一致的。該操作與 C 的 *cas* 寫入併發,後者將 *x* 從 2 更新到 4。在沒有其他請求的情況下B 的讀取返回 2 是可以的。然而,客戶端 A 在 B 的讀取開始之前已經讀取了新值 4因此 B 不允許讀取比 A 更舊的值。同樣,這與 [圖 10-1](/tw/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同。
* 客戶端 B 的最後一次讀取(在陰影條中)不是線性一致的。該操作與 C 的 *cas* 寫入併發,後者將 *x* 從 2 更新到 4。在沒有其他請求的情況下B 的讀取返回 2 是可以的。然而,客戶端 A 在 B 的讀取開始之前已經讀取了新值 4因此 B 不允許讀取比 A 更舊的值。同樣,這與 [圖 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同。
這就是線性一致性背後的直覺;形式化定義 [^1] 更精確地描述了它。可以(儘管計算成本高昂)透過記錄所有請求和響應的時序,並檢查它們是否可以排列成有效的順序序列來測試系統的行為是否線性一致 [^6] [^7]。
@ -160,18 +160,18 @@ breadcrumbs: false
#### 跨通道時序依賴 {#cross-channel-timing-dependencies}
注意 [圖 10-1](/tw/ch10#fig_consistency_linearizability_0) 中的一個細節:如果 Aaliyah 沒有大聲說出比分Bryce 就不會知道他的查詢結果是過時的。他只會在幾秒鐘後再次重新整理頁面最終看到最終比分。線性一致性違規之所以被注意到只是因為系統中有一個額外的通訊通道Aaliyah 的聲音到 Bryce 的耳朵)。
注意 [圖 10-1](#fig_consistency_linearizability_0) 中的一個細節:如果 Aaliyah 沒有大聲說出比分Bryce 就不會知道他的查詢結果是過時的。他只會在幾秒鐘後再次重新整理頁面最終看到最終比分。線性一致性違規之所以被注意到只是因為系統中有一個額外的通訊通道Aaliyah 的聲音到 Bryce 的耳朵)。
類似的情況可能出現在計算機系統中。例如,假設你有一個網站,使用者可以上傳影片,後臺程序將影片轉碼為較低質量,以便在慢速網際網路連線上流式傳輸。該系統的架構和資料流如 [圖 10-5](/tw/ch10#fig_consistency_transcoder) 所示。
類似的情況可能出現在計算機系統中。例如,假設你有一個網站,使用者可以上傳影片,後臺程序將影片轉碼為較低質量,以便在慢速網際網路連線上流式傳輸。該系統的架構和資料流如 [圖 10-5](#fig_consistency_transcoder) 所示。
影片轉碼器需要明確指示執行轉碼作業,此指令透過訊息佇列從 Web 伺服器傳送到轉碼器(見 ["訊息傳遞系統"](/tw/ch12#sec_stream_messaging)。Web 伺服器不會將整個影片放在佇列中,因為大多數訊息代理都是為小訊息設計的,而影片可能有許多兆位元組大小。相反,影片首先寫入檔案儲存服務,寫入完成後,轉碼指令被放入佇列。
{{< figure src="/fig/ddia_1005.png" id="fig_consistency_transcoder" caption="圖 10-5. 一個非線性一致的系統Alice 和 Bob 在不同時間看到上傳的影像,因此 Bob 的請求基於過時的資料。" class="w-full my-4" >}}
如果檔案儲存服務是線性一致的,那麼這個系統應該工作正常。如果它不是線性一致的,就存在競態條件的風險:訊息佇列([圖 10-5](/tw/ch10#fig_consistency_transcoder) 中的步驟 3 和 4可能比儲存服務內部的複製更快。在這種情況下當轉碼器獲取原始影片步驟 5它可能會看到檔案的舊版本或者根本看不到任何內容。如果它處理影片的舊版本檔案儲存中的原始影片和轉碼影片將永久不一致。
如果檔案儲存服務是線性一致的,那麼這個系統應該工作正常。如果它不是線性一致的,就存在競態條件的風險:訊息佇列([圖 10-5](#fig_consistency_transcoder) 中的步驟 3 和 4可能比儲存服務內部的複製更快。在這種情況下當轉碼器獲取原始影片步驟 5它可能會看到檔案的舊版本或者根本看不到任何內容。如果它處理影片的舊版本檔案儲存中的原始影片和轉碼影片將永久不一致。
這個問題的出現是因為 Web 伺服器和轉碼器之間有兩個不同的通訊通道:檔案儲存和訊息佇列。如果沒有線性一致性的新鮮度保證,這兩個通道之間可能存在競態條件。這種情況類似於 [圖 10-1](/tw/ch10#fig_consistency_linearizability_0),其中也存在兩個通訊通道之間的競態條件:資料庫複製和 Aaliyah 嘴巴到 Bryce 耳朵之間的現實音訊通道。
這個問題的出現是因為 Web 伺服器和轉碼器之間有兩個不同的通訊通道:檔案儲存和訊息佇列。如果沒有線性一致性的新鮮度保證,這兩個通道之間可能存在競態條件。這種情況類似於 [圖 10-1](#fig_consistency_linearizability_0),其中也存在兩個通訊通道之間的競態條件:資料庫複製和 Aaliyah 嘴巴到 Bryce 耳朵之間的現實音訊通道。
如果你有一個可以接收推送通知的移動應用程式,並且應用程式在收到推送通知時從伺服器獲取一些資料,就會發生類似的競態條件。如果資料獲取可能傳送到滯後的副本,可能會發生推送通知快速透過,但後續獲取沒有看到推送通知所涉及的資料。
@ -204,14 +204,14 @@ breadcrumbs: false
#### 線性一致性與仲裁 {#sec_consistency_quorum_linearizable}
直觀地說,在 Dynamo 風格的模型中,仲裁讀寫似乎應該是線性一致的。然而,當我們有可變的網路延遲時,可能會出現競態條件,如 [圖 10-6](/tw/ch10#fig_consistency_leaderless) 所示。
直觀地說,在 Dynamo 風格的模型中,仲裁讀寫似乎應該是線性一致的。然而,當我們有可變的網路延遲時,可能會出現競態條件,如 [圖 10-6](#fig_consistency_leaderless) 所示。
{{< figure src="/fig/ddia_1006.png" id="fig_consistency_leaderless" caption="圖 10-6. 如果網路延遲是可變的,仲裁不足以確保線性一致性。" class="w-full my-4" >}}
在 [圖 10-6](/tw/ch10#fig_consistency_leaderless) 中,*x* 的初始值為 0寫入客戶端透過向所有三個副本傳送寫入*n* = 3*w* = 3*x* 更新為 1。同時客戶端 A 從兩個節點的仲裁(*r* = 2讀取並在其中一個節點上看到新值 1。同時與寫入併發客戶端 B 從不同的兩個節點仲裁讀取,並從兩者獲得舊值 0。
在 [圖 10-6](#fig_consistency_leaderless) 中,*x* 的初始值為 0寫入客戶端透過向所有三個副本傳送寫入*n* = 3*w* = 3*x* 更新為 1。同時客戶端 A 從兩個節點的仲裁(*r* = 2讀取並在其中一個節點上看到新值 1。同時與寫入併發客戶端 B 從不同的兩個節點仲裁讀取,並從兩者獲得舊值 0。
仲裁條件得到滿足(*w* + *r* > *n*但這種執行仍然不是線性一致的B 的請求在 A 的請求完成後開始,但 B 返回舊值而 A 返回新值。(這又是 [圖 10-1](/tw/ch10#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況。)
仲裁條件得到滿足(*w* + *r* > *n*但這種執行仍然不是線性一致的B 的請求在 A 的請求完成後開始,但 B 返回舊值而 A 返回新值。(這又是 [圖 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況。)
可以使 Dynamo 風格的仲裁線性一致,但代價是降低效能:讀者必須同步執行讀修復(見 ["追趕錯過的寫入"](/tw/ch6#sec_replication_read_repair)),然後才能將結果返回給應用程式 [^24]。此外,在寫入之前,寫入者必須讀取節點仲裁的最新狀態以獲取任何先前寫入的最新時間戳,並確保新寫入具有更大的時間戳 [^25] [^26]。然而Riak 由於效能損失而不執行同步讀修復。Cassandra 確實等待仲裁讀取時的讀修復完成 [^27],但由於它使用日曆時鐘作為時間戳而失去了線性一致性。
@ -223,7 +223,7 @@ breadcrumbs: false
由於某些複製方法可以提供線性一致性而其他方法不能,因此更深入地探討線性一致性的利弊是很有趣的。
我們已經在 [第六章](/tw/ch6) 中討論了不同複製方法的一些用例;例如,我們看到多主複製通常是多區域複製的良好選擇(見 ["地理分散式操作"](/tw/ch6#sec_replication_multi_dc))。[圖 10-7](/tw/ch10#fig_consistency_cap_availability) 展示了這種部署的示例。
我們已經在 [第六章](/tw/ch6) 中討論了不同複製方法的一些用例;例如,我們看到多主複製通常是多區域複製的良好選擇(見 ["地理分散式操作"](/tw/ch6#sec_replication_multi_dc))。[圖 10-7](#fig_consistency_cap_availability) 展示了這種部署的示例。
{{< figure src="/fig/ddia_1007.png" id="fig_consistency_cap_availability" caption="圖 10-7. 如果客戶端由於網路分割槽而無法聯絡足夠的副本,它們就無法處理寫入。" class="w-full my-4" >}}
@ -282,12 +282,12 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化
在許多應用程式中,你需要在建立資料庫記錄時為它們分配某種唯一的 ID這給了你一個可以引用這些記錄的主鍵。在單節點資料庫中通常使用自增整數它的優點是隻需要 64 位(如果你確定永遠不會有超過 40 億條記錄,甚至可以使用 32 位,但這是有風險的)來儲存。
這種自增 ID 的另一個優點是ID 的順序告訴你記錄建立的順序。例如,[圖 10-8](/tw/ch10#fig_consistency_id_generator) 顯示了一個聊天應用程式,它在釋出聊天訊息時為其分配自增 ID。然後你可以按 ID 遞增的順序顯示訊息生成的聊天執行緒將有意義Aaliyah 釋出了一個被分配 ID 1 的問題,而 Bryce 對該問題的回答被分配了一個更大的 ID即 3。
這種自增 ID 的另一個優點是ID 的順序告訴你記錄建立的順序。例如,[圖 10-8](#fig_consistency_id_generator) 顯示了一個聊天應用程式,它在釋出聊天訊息時為其分配自增 ID。然後你可以按 ID 遞增的順序顯示訊息生成的聊天執行緒將有意義Aaliyah 釋出了一個被分配 ID 1 的問題,而 Bryce 對該問題的回答被分配了一個更大的 ID即 3。
{{< figure src="/fig/ddia_1008.png" id="fig_consistency_id_generator" caption="圖 10-8. 兩個不同的節點可能生成衝突的 ID。" class="w-full my-4" >}}
這個單節點 ID 生成器是線性一致系統的另一個例子。每個獲取 ID 的請求都是一個原子地遞增計數器並返回舊計數器值的操作(*獲取並增加* 操作);線性一致性確保如果 Aaliyah 的訊息釋出在 Bryce 的釋出開始之前完成,那麼 Bryce 的 ID 必須大於 Aaliyah 的。[圖 10-8](/tw/ch10#fig_consistency_id_generator) 中 Aaliyah 和 Caleb 的訊息是併發的,因此線性一致性不指定它們的 ID 必須如何排序,只要它們是唯一的。
這個單節點 ID 生成器是線性一致系統的另一個例子。每個獲取 ID 的請求都是一個原子地遞增計數器並返回舊計數器值的操作(*獲取並增加* 操作);線性一致性確保如果 Aaliyah 的訊息釋出在 Bryce 的釋出開始之前完成,那麼 Bryce 的 ID 必須大於 Aaliyah 的。[圖 10-8](#fig_consistency_id_generator) 中 Aaliyah 和 Caleb 的訊息是併發的,因此線性一致性不指定它們的 ID 必須如何排序,只要它們是唯一的。
記憶體中的單節點 ID 生成器很容易實現:你可以使用 CPU 提供的原子遞增指令,它允許多個執行緒安全地遞增同一個計數器。使計數器持久化需要更多的努力,這樣節點就可以崩潰並重新啟動而不重置計數器值,這將導致重複的 ID。但真正的問題是
@ -333,14 +333,14 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化
幸運的是,有一種生成邏輯時間戳的簡單方法,它與因果關係 *一致*,你可以將其用作分散式 ID 生成器。它被稱為 *Lamport 時鐘*,由 Leslie Lamport 在 1978 年提出 [^54],現在是分散式系統領域被引用最多的論文之一。
[圖 10-9](/tw/ch10#fig_consistency_lamport_ts) 顯示了 Lamport 時鐘如何在 [圖 10-8](/tw/ch10#fig_consistency_id_generator) 的聊天示例中工作。每個節點都有一個唯一識別符號,在 [圖 10-9](/tw/ch10#fig_consistency_lamport_ts) 中是名稱"Aaliyah"、"Bryce"或"Caleb",但在實踐中可能是隨機 UUID 或類似的東西。此外每個節點都保留它已處理的運算元的計數器。Lamport 時間戳就是一對(*計數器**節點 ID*)。兩個節點有時可能具有相同的計數器值,但透過在時間戳中包含節點 ID每個時間戳都是唯一的。
[圖 10-9](#fig_consistency_lamport_ts) 顯示了 Lamport 時鐘如何在 [圖 10-8](#fig_consistency_id_generator) 的聊天示例中工作。每個節點都有一個唯一識別符號,在 [圖 10-9](#fig_consistency_lamport_ts) 中是名稱"Aaliyah"、"Bryce"或"Caleb",但在實踐中可能是隨機 UUID 或類似的東西。此外每個節點都保留它已處理的運算元的計數器。Lamport 時間戳就是一對(*計數器**節點 ID*)。兩個節點有時可能具有相同的計數器值,但透過在時間戳中包含節點 ID每個時間戳都是唯一的。
{{< figure src="/fig/ddia_1009.png" id="fig_consistency_lamport_ts" caption="圖 10-9. Lamport 時間戳提供與因果關係一致的全序。" class="w-full my-4" >}}
每次節點生成時間戳時,它都會遞增其計數器值並使用新值。此外,每次節點看到來自另一個節點的時間戳時,如果該時間戳中的計數器值大於其本地計數器值,它會將其本地計數器增加到與時間戳中的值匹配。
在 [圖 10-9](/tw/ch10#fig_consistency_lamport_ts) 中Aaliyah 在釋出自己的訊息時還沒有看到 Caleb 的訊息,反之亦然。假設兩個使用者都以初始計數器值 0 開始,因此都遞增其本地計數器並將新計數器值 1 附加到其訊息。當 Bryce 收到這些訊息時,他將本地計數器值增加到 1。最後Bryce 向 Aaliyah 的訊息傳送回覆,為此他遞增本地計數器並將新值 2 附加到訊息。
在 [圖 10-9](#fig_consistency_lamport_ts) 中Aaliyah 在釋出自己的訊息時還沒有看到 Caleb 的訊息,反之亦然。假設兩個使用者都以初始計數器值 0 開始,因此都遞增其本地計數器並將新計數器值 1 附加到其訊息。當 Bryce 收到這些訊息時,他將本地計數器值增加到 1。最後Bryce 向 Aaliyah 的訊息傳送回覆,為此他遞增本地計數器並將新值 2 附加到訊息。
要比較兩個 Lamport 時間戳,我們首先比較它們的計數器值:例如,(2, "Bryce") 大於 (1, "Aaliyah"),也大於 (1, "Caleb")。如果兩個時間戳具有相同的計數器,我們改為比較它們的節點 ID使用通常的字典序字串比較。因此此示例中的時間戳順序是 (1, "Aaliyah") < (1, "Caleb") < (2, "Bryce")。
@ -361,7 +361,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
在 ["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl) 中,我們討論了快照隔離通常是如何實現的:本質上,透過給每個事務一個事務 ID並允許每個事務看到由 ID 較低的事務進行的寫入,但使 ID 較高的事務的寫入不可見。Lamport 時鐘和混合邏輯時鐘是生成這些事務 ID 的好方法,因為它們確保快照與因果關係一致 [^56]。
當併發生成多個時間戳時,這些演算法會任意排序它們。這意味著當你檢視兩個時間戳時,你通常無法判斷它們是併發生成的還是一個發生在另一個之前。(在 [圖 10-9](/tw/ch10#fig_consistency_lamport_ts) 的示例中,你實際上可以判斷 Aaliyah 和 Caleb 的訊息必須是併發的,因為它們具有相同的計數器值,但當計數器值不同時,你無法判斷它們是否併發。)
當併發生成多個時間戳時,這些演算法會任意排序它們。這意味著當你檢視兩個時間戳時,你通常無法判斷它們是併發生成的還是一個發生在另一個之前。(在 [圖 10-9](#fig_consistency_lamport_ts) 的示例中,你實際上可以判斷 Aaliyah 和 Caleb 的訊息必須是併發的,因為它們具有相同的計數器值,但當計數器值不同時,你無法判斷它們是否併發。)
如果你想能夠確定記錄何時併發建立,你需要不同的演算法,例如 *向量時鐘*。缺點是向量時鐘的時間戳要大得多——可能是系統中每個節點一個整數。有關檢測併發的更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。
@ -369,7 +369,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
儘管 Lamport 時鐘和混合邏輯時鐘提供了有用的排序保證,但該排序仍然弱於我們之前討論的線性一致單節點 ID 生成器。回想一下,線性一致性要求如果請求 A 在請求 B 開始之前完成,那麼 B 必須具有更高的 ID即使 A 和 B 從未相互通訊。另一方面Lamport 時鐘只能確保節點生成的時間戳大於該節點看到的任何其他時間戳,但它不能對它沒有看到的時間戳說任何話。
[圖 10-10](/tw/ch10#fig_consistency_permissions) 顯示了非線性一致 ID 生成器如何導致問題。想象一個社交媒體網站,使用者 A 想要與朋友私下分享一張尷尬的照片。A 的賬戶最初是公開的但使用他們的筆記型電腦A 首先將他們的賬戶設定更改為私密。然後 A 使用他們的手機上傳照片。由於 A 按順序執行了這些更新,他們可能合理地期望照片上傳受到新的、受限的賬戶許可權的約束。
[圖 10-10](#fig_consistency_permissions) 顯示了非線性一致 ID 生成器如何導致問題。想象一個社交媒體網站,使用者 A 想要與朋友私下分享一張尷尬的照片。A 的賬戶最初是公開的但使用他們的筆記型電腦A 首先將他們的賬戶設定更改為私密。然後 A 使用他們的手機上傳照片。由於 A 按順序執行了這些更新,他們可能合理地期望照片上傳受到新的、受限的賬戶許可權的約束。
{{< figure src="/fig/ddia_1010.png" id="fig_consistency_permissions" caption="圖 10-10. 使用 Lamport 時間戳的許可權系統示例。" class="w-full my-4" >}}
@ -396,7 +396,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
#### 使用邏輯時鐘強制約束 {#enforcing-constraints-using-logical-clocks}
在 ["約束與唯一性保證"](/tw/ch10#sec_consistency_uniqueness) 中,我們看到線性一致的比較並設定操作可用於在分散式系統中實現鎖、唯一性約束和類似構造。這提出了一個問題:邏輯時鐘或線性一致的 ID 生成器是否也足以實現這些東西?
在 ["約束與唯一性保證"](#sec_consistency_uniqueness) 中,我們看到線性一致的比較並設定操作可用於在分散式系統中實現鎖、唯一性約束和類似構造。這提出了一個問題:邏輯時鐘或線性一致的 ID 生成器是否也足以實現這些東西?
答案是:不完全。當你有幾個節點都試圖獲取同一個鎖或註冊同一個使用者名稱時,你可以使用邏輯時鐘為這些請求分配時間戳,並選擇具有最低時間戳的請求作為獲勝者。如果時鐘是線性一致的,你知道任何未來的請求都將始終生成更大的時間戳,因此你可以確定沒有未來的請求會收到比獲勝者更低的時間戳。
@ -487,7 +487,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
這表明 CAS 和共識彼此等價 [^28] [^73]。同樣,兩者在單個節點上都很簡單,但要使其容錯則具有挑戰性。作為分散式環境中 CAS 的示例,我們在 ["由物件儲存支援的資料庫"](/tw/ch6#sec_replication_object_storage) 中看到了物件儲存的條件寫入操作,它允許寫入僅在自當前客戶端上次讀取以來具有相同名稱的物件未被另一個客戶端建立或修改時發生。
然而線性一致的讀寫暫存器不足以解決共識。FLP 結果告訴我們,共識不能由非同步崩潰停止模型中的確定性演算法解決 [^72],但我們在 ["線性一致性與仲裁"](/tw/ch10#sec_consistency_quorum_linearizable) 中看到,線性一致的暫存器可以使用此模型中的仲裁讀/寫來實現 [^24] [^25] [^26]。由此可見,線性一致的暫存器無法解決共識。
然而線性一致的讀寫暫存器不足以解決共識。FLP 結果告訴我們,共識不能由非同步崩潰停止模型中的確定性演算法解決 [^72],但我們在 ["線性一致性與仲裁"](#sec_consistency_quorum_linearizable) 中看到,線性一致的暫存器可以使用此模型中的仲裁讀/寫來實現 [^24] [^25] [^26]。由此可見,線性一致的暫存器無法解決共識。
#### 共享日誌作為共識 {#sec_consistency_shared_logs}
@ -530,7 +530,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
#### 獲取並增加作為共識 {#fetch-and-add-as-consensus}
我們在 ["線性一致的 ID 生成器"](/tw/ch10#sec_consistency_linearizable_id) 中看到的線性一致 ID 生成器接近解決共識,但略有不足。我們可以使用獲取並增加操作實現這樣的 ID 生成器,該操作原子地遞增計數器並返回舊的計數器值。
我們在 ["線性一致的 ID 生成器"](#sec_consistency_linearizable_id) 中看到的線性一致 ID 生成器接近解決共識,但略有不足。我們可以使用獲取並增加操作實現這樣的 ID 生成器,該操作原子地遞增計數器並返回舊的計數器值。
如果你有 CAS 操作,很容易實現獲取並增加:首先讀取計數器值,然後執行 CAS其中期望值是你讀取的值新值是該值加一。如果 CAS 失敗,你將重試整個過程,直到 CAS 成功。當存在爭用時,這比本機獲取並增加操作效率低,但在功能上是等效的。由於你可以使用共識實現 CAS你也可以使用共識實現獲取並增加。
@ -625,7 +625,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
> [!TIP] 主節點選舉中的一致性與可用性
如果你希望共識演算法嚴格保證 ["共享日誌作為共識"](/tw/ch10#sec_consistency_shared_logs) 中列出的屬性,那麼新主節點在處理任何寫入或線性一致讀取之前必須瞭解任何已確認的日誌條目,這一點至關重要。如果具有過時資料的節點成為新主節點,它可能會將新值寫入已經由舊主節點寫入的日誌條目,從而違反共享日誌的僅追加屬性。
如果你希望共識演算法嚴格保證 ["共享日誌作為共識"](#sec_consistency_shared_logs) 中列出的屬性,那麼新主節點在處理任何寫入或線性一致讀取之前必須瞭解任何已確認的日誌條目,這一點至關重要。如果具有過時資料的節點成為新主節點,它可能會將新值寫入已經由舊主節點寫入的日誌條目,從而違反共享日誌的僅追加屬性。
在某些情況下你可能選擇削弱共識屬性以便更快地從主節點故障中恢復。例如Kafka 提供了啟用 *不乾淨的主節點選舉* 的選項,它允許任何副本成為主節點,即使它不是最新的。此外,在採用非同步複製的資料庫中,當主節點失敗時,你無法保證任何備庫是最新的。

View file

@ -29,7 +29,7 @@ breadcrumbs: false
- 批處理框架能更高效地利用計算資源。雖然也可以用 OLTP 資料庫和應用伺服器等線上系統做批處理,但資源成本通常顯著更高。
批處理也有挑戰。多數框架中,作業只有在整體完成後,其輸出才能被下游進一步處理。批處理也可能低效:輸入哪怕只變動一個位元組,也可能需要重算整個輸入資料集。儘管如此,批處理在大量場景中依然非常有用,我們會在[“批處理用例”](/tw/ch11#sec_batch_output)中回到這個話題。
批處理也有挑戰。多數框架中,作業只有在整體完成後,其輸出才能被下游進一步處理。批處理也可能低效:輸入哪怕只變動一個位元組,也可能需要重算整個輸入資料集。儘管如此,批處理在大量場景中依然非常有用,我們會在[“批處理用例”](#sec_batch_output)中回到這個話題。
批處理作業可能執行很久:幾分鐘、幾小時甚至幾天。很多作業是週期排程的(例如每天一次)。它的核心效能指標通常是吞吐量:單位時間能處理多少資料。有些批處理系統透過“中止並整體重啟”應對故障,也有些具備更細粒度容錯能力,可以在部分節點崩潰時仍讓作業完成。
@ -278,24 +278,24 @@ YARN ResourceManager 或 Spark 內建排程器主要做“作業內排程”,
當一個任務輸出成為另一任務輸入即在工作流內傳遞容錯更複雜。MapReduce 的做法是:中間資料總是寫回 DFS且只有寫入任務成功後才允許下游讀取。這個方案在頻繁搶佔環境中也能工作但會帶來大量 DFS 寫入,效率不高。
Spark 更傾向把中間資料放記憶體或溢寫本地磁碟,只把最終結果寫 DFS它還記錄中間資料的計算血緣丟失時可重算 [^18]。Flink 則採用定期檢查點快照機制 [^19]。我們會在[“資料流引擎”](/tw/ch11#sec_batch_dataflow)繼續討論。
Spark 更傾向把中間資料放記憶體或溢寫本地磁碟,只把最終結果寫 DFS它還記錄中間資料的計算血緣丟失時可重算 [^18]。Flink 則採用定期檢查點快照機制 [^19]。我們會在[“資料流引擎”](#sec_batch_dataflow)繼續討論。
## 批處理模型 {#id431}
前面我們討論了分散式環境中批作業如何排程。現在轉向“批處理框架如何處理資料”。最常見的兩類模型是 MapReduce 與資料流引擎。儘管實踐中資料流引擎已大面積替代 MapReduce但理解 MapReduce 仍然重要,因為它深刻影響了現代批處理框架。
MapReduce 與資料流引擎都發展出多種程式設計介面:低層 API、關係查詢語言、DataFrame API。它們讓應用工程師、資料分析工程師、業務分析師乃至非技術人員都能參與資料處理。我們將在[“批處理用例”](/tw/ch11#sec_batch_output)中討論這些用途。
MapReduce 與資料流引擎都發展出多種程式設計介面:低層 API、關係查詢語言、DataFrame API。它們讓應用工程師、資料分析工程師、業務分析師乃至非技術人員都能參與資料處理。我們將在[“批處理用例”](#sec_batch_output)中討論這些用途。
### MapReduce {#sec_batch_mapreduce}
MapReduce 的處理模式與[“簡單日誌分析”](/tw/ch11#sec_batch_log_analysis)幾乎同構:
MapReduce 的處理模式與[“簡單日誌分析”](#sec_batch_log_analysis)幾乎同構:
1. 讀取輸入檔案並切分為 *記錄records*。在日誌例子裡,每條記錄就是一行(`\n` 為記錄分隔符)。在 Hadoop MapReduce 中,輸入通常存放在 HDFS 或 S3 等物件儲存,檔案格式可能是 Parquet列式見[“面向列儲存”](/tw/ch4#sec_storage_column))或 Avro行式見[“Avro”](/tw/ch5#sec_encoding_avro))。
2. 呼叫 mapper從每條輸入記錄中提取鍵和值。Unix 示例中 mapper 相當於 `awk '{print $7}'`URL`$7`)是鍵,值可留空。
3. 按鍵排序所有鍵值對。日誌示例中這一步對應第一次 `sort`
4. 呼叫 reducer 遍歷排序後的鍵值對。同鍵記錄會相鄰因此可以在很小記憶體狀態下合併。Unix 示例中 reducer 等價於 `uniq -c`,統計相鄰同鍵記錄數。
這四步就是一個 MapReduce 作業。第 2 步map與第 4 步reduce是你寫業務邏輯的地方第 1 步(檔案切記錄)由輸入格式解析器完成;第 3 步排序在 MapReduce 中是隱式內建的,你無需手寫。這一步是批處理的基礎演算法,我們會在[“混洗資料”](/tw/ch11#sec_shuffle)再討論。
這四步就是一個 MapReduce 作業。第 2 步map與第 4 步reduce是你寫業務邏輯的地方第 1 步(檔案切記錄)由輸入格式解析器完成;第 3 步排序在 MapReduce 中是隱式內建的,你無需手寫。這一步是批處理的基礎演算法,我們會在[“混洗資料”](#sec_shuffle)再討論。
要建立 MapReduce 作業你需實現兩個回撥mapper 與 reducer其行為如下。
@ -342,19 +342,19 @@ Reducer
混洗是批處理系統的基礎演算法連線與聚合都依賴它。MapReduce、Spark、Flink、Daft、Dataflow、BigQuery [^24] 都實現了高可伸縮且高效能的混洗機制以處理大資料集。這裡用 Hadoop MapReduce 的混洗實現做說明 [^25],但核心思想在其他系統同樣適用。
[圖 11-1](/tw/ch11#fig_batch_mapreduce) 展示了一個 MapReduce 作業的資料流。假設輸入已分片,標記為 *m1*、*m2*、*m3*。例如每個分片可以是 HDFS 中一個檔案,或物件儲存中的一個物件;同一資料集的所有分片可以放在同一 HDFS 目錄,或使用同一物件字首。
[圖 11-1](#fig_batch_mapreduce) 展示了一個 MapReduce 作業的資料流。假設輸入已分片,標記為 *m1*、*m2*、*m3*。例如每個分片可以是 HDFS 中一個檔案,或物件儲存中的一個物件;同一資料集的所有分片可以放在同一 HDFS 目錄,或使用同一物件字首。
{{< figure src="/fig/ddia_1101.png" id="fig_batch_mapreduce" caption="圖 11-1. 一個包含三個 mapper 和三個 reducer 的 MapReduce 作業。" class="w-full my-4" >}}
框架會為每個輸入分片啟動一個 map 任務。任務讀取分配到的檔案,並逐條記錄呼叫 mapper 回撥。reduce 側也會分片。map 任務數由輸入分片數決定reduce 任務數由作業作者配置(可與 map 數不同)。
mapper 輸出是鍵值對。框架需要保證:若不同 mapper 輸出了同一個鍵,這些鍵值對最終必須由同一個 reducer 處理。為此,每個 mapper 會在本地磁碟為每個 reducer 維護一個輸出檔案(例如[圖 11-1](/tw/ch11#fig_batch_mapreduce)中的 *m1,r2*:由 mapper1 生成,目標是 reducer2。mapper 每輸出一條鍵值對,通常會按鍵的雜湊決定寫入哪個 reducer 檔案(類似[“按鍵雜湊分片”](/tw/ch7#sec_sharding_hash))。
mapper 輸出是鍵值對。框架需要保證:若不同 mapper 輸出了同一個鍵,這些鍵值對最終必須由同一個 reducer 處理。為此,每個 mapper 會在本地磁碟為每個 reducer 維護一個輸出檔案(例如[圖 11-1](#fig_batch_mapreduce)中的 *m1,r2*:由 mapper1 生成,目標是 reducer2。mapper 每輸出一條鍵值對,通常會按鍵的雜湊決定寫入哪個 reducer 檔案(類似[“按鍵雜湊分片”](/tw/ch7#sec_sharding_hash))。
mapper 寫這些檔案的同時,也會在每個檔案內部按鍵排序。可用的正是[“日誌結構儲存”](/tw/ch4#sec_storage_log_structured)中的技術:先在記憶體有序結構裡積累一批鍵值對,寫成有序段檔案,再把小段逐步合併成大段。
每個 mapper 完成後reducer 會連線到 mapper把屬於自己的有序檔案複製到本地磁碟。reducer 拿到所有 mapper 的對應分片後,再用歸併排序方式合併它們並保持有序。同鍵記錄即便來自不同 mapper也會在合併後相鄰。隨後 reducer 以“每個鍵一次呼叫”的方式執行,每次拿到一個可迭代器,遍歷該鍵所有值。
reducer 輸出記錄會順序寫入檔案,每個 reduce 任務一個檔案。[圖 11-1](/tw/ch11#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是輸出資料集的分片,最終寫回 DFS 或物件儲存。
reducer 輸出記錄會順序寫入檔案,每個 reduce 任務一個檔案。[圖 11-1](#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是輸出資料集的分片,最終寫回 DFS 或物件儲存。
MapReduce 在 map 與 reduce 之間執行混洗現代資料流引擎和雲資料倉庫則更複雜。BigQuery 等系統已最佳化混洗,使資料儘量留在記憶體,並寫入外部排序服務 [^24],以提升速度並透過複製增強韌性。
@ -362,13 +362,13 @@ MapReduce 在 map 與 reduce 之間執行混洗;現代資料流引擎和雲資
下面看“有序資料”如何簡化分散式連線與聚合。為便於說明仍以 MapReduce 為例,但概念適用於大多數批處理系統。
批處理裡常見連線場景見[圖 11-2](/tw/ch11#fig_batch_join_example)。左邊是使用者活動日誌(*activity events* 或 *clickstream data*),右邊是使用者資料庫。它可以看作星型模型的一部分(見[“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics)):活動日誌是事實表,使用者庫是維度表之一。
批處理裡常見連線場景見[圖 11-2](#fig_batch_join_example)。左邊是使用者活動日誌(*activity events* 或 *clickstream data*),右邊是使用者資料庫。它可以看作星型模型的一部分(見[“星型與雪花型:分析模式”](/tw/ch3#sec_datamodels_analytics)):活動日誌是事實表,使用者庫是維度表之一。
{{< figure src="/fig/ddia_1102.png" id="fig_batch_join_example" caption="圖 11-2. 使用者活動日誌與使用者畫像資料庫的連線。" class="w-full my-4" >}}
如果你要做“結合使用者庫資訊的活動分析”(例如利用使用者出生日期欄位,判斷哪些頁面更受年輕或年長使用者歡迎),就需要連線這兩張表。若兩邊都大到必須分片,怎麼做?
可利用 MapReduce 的關鍵特性:混洗會把同鍵鍵值對匯聚到同一個 reducer無論它們最初在哪個分片。這裡使用者 ID 就可以作為鍵。因此可寫一個 mapper 掃活動日誌,輸出“按使用者 ID 鍵控的頁面訪問 URL”見[圖 11-3](/tw/ch11#fig_batch_join_reduce));再寫一個 mapper 按行掃描使用者表,提取“使用者 ID 作為鍵、出生日期作為值”。
可利用 MapReduce 的關鍵特性:混洗會把同鍵鍵值對匯聚到同一個 reducer無論它們最初在哪個分片。這裡使用者 ID 就可以作為鍵。因此可寫一個 mapper 掃活動日誌,輸出“按使用者 ID 鍵控的頁面訪問 URL”見[圖 11-3](#fig_batch_join_reduce));再寫一個 mapper 按行掃描使用者表,提取“使用者 ID 作為鍵、出生日期作為值”。
{{< figure src="/fig/ddia_1103.png" id="fig_batch_join_reduce" caption="圖 11-3. 基於使用者 ID 的排序合併連線。若輸入資料集由多個檔案分片組成,可並行啟動多個 mapper 處理。" class="w-full my-4" >}}
@ -390,7 +390,7 @@ MapReduce、資料流引擎、雲資料倉庫都把 SQL 作為批處理“通用
SQL 是最流行的通用批處理語言但在一些細分場景中仍有其他語言。Apache Pig 提供了基於關係運算元的逐步式資料流水線描述方式,而非“一個超大 SQL 查詢”。DataFrame下一節有相似特徵Morel 則是受 Pig 影響的更現代語言。還有使用者採用 jq、JMESPath、JsonPath 等 JSON 查詢語言。
在[“圖狀資料模型”](/tw/ch3#sec_datamodels_graph)中,我們討論了圖建模與圖查詢語言如何遍歷邊和頂點。許多圖處理框架也支援透過查詢語言做批計算,例如 Apache TinkerPop 的 Gremlin。我們會在[“批處理用例”](/tw/ch11#sec_batch_output)繼續看圖處理場景。
在[“圖狀資料模型”](/tw/ch3#sec_datamodels_graph)中,我們討論了圖建模與圖查詢語言如何遍歷邊和頂點。許多圖處理框架也支援透過查詢語言做批計算,例如 Apache TinkerPop 的 Gremlin。我們會在[“批處理用例”](#sec_batch_output)繼續看圖處理場景。
> [!TIP] 批處理與雲資料倉庫正在收斂
> 歷史上,資料倉庫執行在專用硬體裝置上,主要提供關係資料的 SQL 分析查詢;而 MapReduce 等批處理框架強調更高可伸縮性與更高靈活性,允許使用通用程式語言寫處理邏輯,並讀寫任意資料格式。
@ -446,7 +446,7 @@ Daft 等框架甚至同時支援客戶端與服務端計算:小規模記憶體
與 ETL 類似SQL 介面改進讓很多組織用 Spark 等批框架直接承載分析。常見模式有兩類:
- 預聚合查詢:先把資料滾動聚合為 OLAP 立方體或資料集市,以提升查詢速度(見[“物化檢視與資料立方”](/tw/ch4#sec_storage_materialized_views))。預聚合結果可在倉庫查詢,或推送到 Apache Druid、Apache Pinot 這類即時 OLAP 系統。預聚合通常按固定週期執行,通常由[“工作流排程”](/tw/ch11#sec_batch_workflows)中提到的排程器管理。
- 預聚合查詢:先把資料滾動聚合為 OLAP 立方體或資料集市,以提升查詢速度(見[“物化檢視與資料立方”](/tw/ch4#sec_storage_materialized_views))。預聚合結果可在倉庫查詢,或推送到 Apache Druid、Apache Pinot 這類即時 OLAP 系統。預聚合通常按固定週期執行,通常由[“工作流排程”](#sec_batch_workflows)中提到的排程器管理。
- 臨時查詢ad hoc使用者為回答具體業務問題、分析使用者行為、排查執行問題等隨時發起。該場景非常看重響應時間分析師通常會根據每次結果繼續迭代提問。執行快的批處理查詢引擎可顯著縮短等待。
SQL 支援還讓批處理系統更易接入電子表格與視覺化工具,如 Tableau、Power BI、Looker、Apache Superset。比如 Tableau 有 SparkSQL、Presto 聯結器Superset 支援 Trino、Hive、Spark SQL、Presto 等大量最終會觸發批任務的資料系統。

View file

@ -24,7 +24,7 @@ breadcrumbs: false
一般來說,“流” 是指隨著時間的推移逐漸可用的資料。這個概念出現在很多地方Unix 的 stdin 和 stdout、程式語言惰性列表[^2]、檔案系統 API如 Java 的 `FileInputStream`、TCP 連線、透過網際網路傳送音訊和影片等等。
在本章中,我們將把 **事件流event stream** 視為一種資料管理機制:無界限,增量處理,與上一章中的批次資料相對應。我們將首先討論怎樣表示、儲存、透過網路傳輸流。在 “[資料庫與流](/tw/ch12#sec_stream_databases)” 中,我們將研究流和資料庫之間的關係。最後在 “[流處理](/tw/ch12#sec_stream_processing)” 中,我們將研究連續處理這些流的方法和工具,以及它們用於應用構建的方式。
在本章中,我們將把 **事件流event stream** 視為一種資料管理機制:無界限,增量處理,與上一章中的批次資料相對應。我們將首先討論怎樣表示、儲存、透過網路傳輸流。在 “[資料庫與流](#sec_stream_databases)” 中,我們將研究流和資料庫之間的關係。最後在 “[流處理](#sec_stream_processing)” 中,我們將研究連續處理這些流的方法和工具,以及它們用於應用構建的方式。
## 傳遞事件流 {#sec_stream_transmit}
@ -98,7 +98,7 @@ breadcrumbs: false
#### 多個消費者 {#id298}
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 12-1](/fig/ddia_1201.png) 所示:
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 12-1](#fig_stream_broker_patterns) 所示:
負載均衡load balancing
: 每條訊息都被傳遞給消費者 **之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在 AMQP 中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在 JMS 中則稱之為 **共享訂閱**,即 shared subscription
@ -106,9 +106,7 @@ breadcrumbs: false
扇出fan-out
: 每條訊息都被傳遞給 **所有** 消費者。扇出允許幾個獨立的消費者各自 “收聽” 相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 JMS 中的主題訂閱與 AMQP 中的交叉繫結提供了這一功能)。
![](/fig/ddia_1201.png)
**圖 12-1 a負載平衡在消費者間共享消費主題b扇出將每條訊息傳遞給多個消費者。**
{{< figure src="/fig/ddia_1201.png" id="fig_stream_broker_patterns" caption="圖 12-1. a負載均衡在消費者間共享消費主題b扇出將每條訊息傳遞給多個消費者。" class="w-full my-4" >}}
兩種模式可以組合使用:例如,兩個獨立的消費者組可以每組各訂閱同一個主題,每一組都共同收到所有訊息,但在每一組內部,每條訊息僅由單個節點處理。
@ -118,17 +116,15 @@ breadcrumbs: false
如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。(請注意可能發生這樣的情況,訊息 **實際上是** 處理完畢的,但 **確認** 在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在 “[實踐中的分散式事務](/tw/ch8#sec_transactions_xa)” 中所討論的那樣)
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖 12-2](/fig/ddia_1202.png) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1結果消費者 1 按照 m4m3m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在 [圖 12-2](#fig_stream_redelivery_reordering) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1結果消費者 1 按照 m4m3m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
![](/fig/ddia_1202.png)
**圖 12-2 在處理 m3 時消費者 2 崩潰,因此稍後重傳至消費者 1**
{{< figure src="/fig/ddia_1202.png" id="fig_stream_redelivery_reordering" caption="圖 12-2. 在處理 m3 時消費者 2 崩潰,因此稍後重傳至消費者 1。" class="w-full my-4" >}}
即使訊息代理試圖保留訊息的順序(如 JMS 和 AMQP 標準所要求的),負載均衡與重傳的組合也不可避免地導致訊息被重新排序。為避免此問題,你可以讓每個消費者使用單獨的佇列(即不使用負載均衡功能)。如果訊息是完全獨立的,則訊息順序重排並不是一個問題。但正如我們將在本章後續部分所述,如果訊息之間存在因果依賴關係,這就是一個很重要的問題。
重傳還可能導致資源浪費、資源飢餓,甚至使流永久阻塞。一個常見場景是生產者錯誤地序列化訊息,例如 JSON 物件缺少必填鍵。任何讀取到該訊息的消費者都會因為缺鍵而失敗,無法傳送確認,於是代理會不斷重傳,導致其他消費者也不斷失敗。如果代理強順序保證,後續訊息可能被徹底卡住;即便允許重排,也會持續浪費資源在永遠無法確認的壞訊息上。
這類問題通常透過 **死信佇列dead letter queue, DLQ** 處理:不再無限重試,而是把問題訊息移到另一條佇列中,從而解堵主消費鏈路[^17][^18]。運維通常會對死信佇列設定告警 —— 只要有訊息進入,就代表出現了錯誤。收到告警後,操作員可以決定永久丟棄該訊息、人工修復後重新投遞,或修復消費者程式碼以正確處理該訊息。除了傳統佇列系統,基於日誌的訊息系統和流處理系統也開始支援 DLQ[^19]。
這類問題通常透過 **死信佇列dead letter queue, DLQ** 處理:不再無限重試,而是把問題訊息移到另一條佇列中,從而解堵主消費鏈路[^17] [^18]。運維通常會對死信佇列設定告警 —— 只要有訊息進入,就代表出現了錯誤。收到告警後,操作員可以決定永久丟棄該訊息、人工修復後重新投遞,或修復消費者程式碼以正確處理該訊息。除了傳統佇列系統,基於日誌的訊息系統和流處理系統也開始支援 DLQ[^19]。
### 基於日誌的訊息代理 {#sec_stream_log}
@ -148,15 +144,13 @@ breadcrumbs: false
同樣的結構可以用於實現訊息代理生產者透過將訊息追加到日誌末尾來發送訊息而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾則會等待新訊息追加的通知。Unix 工具 `tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第七章](/tw/ch7) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 12-3](/fig/ddia_1203.png) 所示。
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第七章](/tw/ch7) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 12-3](#fig_stream_log_partitions) 所示。
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**offset在 [圖 12-3](/fig/ddia_1203.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**offset在 [圖 12-3](#fig_stream_log_partitions) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
![](/fig/ddia_1203.png)
{{< figure src="/fig/ddia_1203.png" id="fig_stream_log_partitions" caption="圖 12-3. 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案。" class="w-full my-4" >}}
**圖 12-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案**
Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基於日誌的訊息代理。Google Cloud Pub/Sub 在架構上類似,但對外暴露的是 JMS 風格的 API而不是日誌抽象[^15]。儘管這些訊息代理將所有訊息寫入磁碟,但透過跨多臺機器分割槽,依然能夠達到每秒數百萬條訊息的吞吐量,並透過複製訊息實現容錯[^21][^22]。
Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基於日誌的訊息代理。Google Cloud Pub/Sub 在架構上類似,但對外暴露的是 JMS 風格的 API而不是日誌抽象[^15]。儘管這些訊息代理將所有訊息寫入磁碟,但透過跨多臺機器分割槽,依然能夠達到每秒數百萬條訊息的吞吐量,並透過複製訊息實現容錯[^21] [^22]。
#### 日誌與傳統的訊息傳遞相比 {#sec_stream_logs_vs_messaging}
@ -167,7 +161,7 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基
* 共享消費主題工作的節點數,最多為該主題中的日誌分割槽數,因為同一個分割槽內的所有訊息被遞送到同一個節點。
* 如果某條訊息處理緩慢,則它會阻塞該分割槽中後續訊息的處理(一種頭部阻塞的形式;請參閱 “[描述效能](/tw/ch2#sec_introduction_percentiles)”)。
因此在訊息處理代價高昂希望逐條並行處理以及訊息的順序並沒有那麼重要的情況下JMS/AMQP 風格的訊息代理是可取的。另一方面,在訊息吞吐量很高,處理迅速,順序很重要的情況下,基於日誌的方法表現得非常好[^23][^24]。不過,基於日誌與傳統訊息系統的邊界並不絕對:例如,一個主題分割槽通常一次只分配給一個消費者[^25][^26]。
因此在訊息處理代價高昂希望逐條並行處理以及訊息的順序並沒有那麼重要的情況下JMS/AMQP 風格的訊息代理是可取的。另一方面,在訊息吞吐量很高,處理迅速,順序很重要的情況下,基於日誌的方法表現得非常好[^23] [^24]。不過,基於日誌與傳統訊息系統的邊界並不絕對:例如,一個主題分割槽通常一次只分配給一個消費者[^25] [^26]。
#### 消費者偏移量 {#sec_stream_log_offsets}
@ -226,17 +220,15 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基
如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是 **雙寫dual write**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 12-4](/fig/ddia_1204.png) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X客戶端 1 想要將值設定為 A客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫然後將其寫入到搜尋索引。因為運氣不好這些請求的時序是交錯的資料庫首先看到來自客戶端 1 的寫入將值設定為 A然後來自客戶端 2 的寫入將值設定為 B因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤這兩個系統現在也永久地不一致了。
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 12-4](#fig_stream_dual_write_race) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X客戶端 1 想要將值設定為 A客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫然後將其寫入到搜尋索引。因為運氣不好這些請求的時序是交錯的資料庫首先看到來自客戶端 1 的寫入將值設定為 A然後來自客戶端 2 的寫入將值設定為 B因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤這兩個系統現在也永久地不一致了。
![](/fig/ddia_1204.png)
**圖 12-4 在資料庫中 X 首先被設定為 A然後被設定為 B而在搜尋索引處寫入以相反的順序到達**
{{< figure src="/fig/ddia_1204.png" id="fig_stream_dual_write_race" caption="圖 12-4. 在資料庫中 X 首先被設定為 A然後被設定為 B而在搜尋索引處寫入以相反的順序到達。" class="w-full my-4" >}}
除非有一些額外的併發檢測機制,例如我們在 “[檢測併發寫入](/tw/ch6#sec_replication_concurrent)” 中討論的版本向量,否則你甚至不會意識到發生了併發寫入 —— 一個值將簡單地以無提示方式覆蓋另一個值。
雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(請參閱 “[原子提交與兩階段提交](/tw/ch8#sec_transactions_2pc)”)。
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 12-4](/fig/ddia_1204.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch6#sec_replication_multi_leader)”)。
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 12-4](#fig_stream_dual_write_race) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch6#sec_replication_multi_leader)”)。
如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎?
@ -248,17 +240,15 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基
最近,人們對 **資料變更捕獲change data capture, CDC** 越來越感興趣這是一種觀察寫入資料庫的所有資料變更並將其提取並轉換為可以複製到其他系統中的形式的過程。CDC 是非常有意思的,尤其是當變更能在被寫入後立刻用於流時[^28]。
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他派生資料系統只是變更流的消費者,如 [圖 12-5](/fig/ddia_1205.png) 所示。
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他派生資料系統只是變更流的消費者,如 [圖 12-5](#fig_stream_cdc_flow) 所示。
![](/fig/ddia_1205.png)
**圖 12-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統**
{{< figure src="/fig/ddia_1205.png" id="fig_stream_cdc_flow" caption="圖 12-5. 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統。" class="w-full my-4" >}}
#### 資料變更捕獲的實現 {#id307}
我們可以將日誌消費者叫做 **派生資料系統**,正如在 [第一章](/tw/ch1#sec_introduction_derived) 討論“記錄系統與派生資料”時所述:儲存在搜尋索引和資料倉庫中的資料,只是 **記錄系統** 資料的額外檢視。資料變更捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在派生資料系統中,以便派生系統具有資料的準確副本。
從本質上說,資料變更捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 12-2](/fig/ddia_1202.png) 的重新排序問題)。
從本質上說,資料變更捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 12-2](#fig_stream_redelivery_reordering) 的重新排序問題)。
資料庫觸發器可用來實現資料變更捕獲(請參閱 “[基於觸發器的複製](/tw/ch6#sec_replication_logical)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如如何應對模式變更。
@ -272,13 +262,15 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按這種方式工作的基
例如,構建新的全文索引需要整個資料庫的完整副本 —— 僅僅應用最近變更的日誌是不夠的,因為這樣會丟失最近未曾更新的專案。因此,如果你沒有完整的歷史日誌,則需要從一個一致的快照開始,如先前的 “[設定新從庫](/tw/ch6#sec_replication_new_replica)” 中所述。
資料庫的快照必須與變更日誌中的已知位置或偏移量相對應,以便在處理完快照後知道從哪裡開始應用變更。一些 CDC 工具集成了這種快照功能而其他工具則把它留給你手動執行。Debezium 使用 Netflix 的 DBLog 水位線演算法提供增量快照能力[^30][^31]。
資料庫的快照必須與變更日誌中的已知位置或偏移量相對應,以便在處理完快照後知道從哪裡開始應用變更。一些 CDC 工具集成了這種快照功能而其他工具則把它留給你手動執行。Debezium 使用 Netflix 的 DBLog 水位線演算法提供增量快照能力[^30] [^31]。
#### 日誌壓縮 {#sec_stream_log_compaction}
如果你只能保留有限的歷史日誌,則每次要新增新的派生資料系統時,都需要做一次快照。但 **日誌壓縮log compaction** 提供了一個很好的備選方案。
我們之前在 “[日誌結構儲存](/tw/ch4#sec_storage_log_structured)” 的上下文中討論過日誌壓縮(可參閱 [圖 4-3](/fig/ddia_0403.png) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
我們之前在 “[日誌結構儲存](/tw/ch4#sec_storage_log_structured)” 的上下文中討論過日誌壓縮(可參閱 [圖 4-3](/tw/ch4#fig_storage_sstable_merging) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行,如 [圖 12-6](#fig_stream_compaction) 所示。
{{< figure src="/fig/ddia_1206.png" id="fig_stream_compaction" caption="圖 12-6. 一個鍵值對日誌,其中鍵是貓影片的 IDmew、purr、scratch、yawn值是播放次數。日誌壓縮只保留每個鍵的最新值。" class="w-full my-4" >}}
在日誌結構儲存引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示該鍵被刪除並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入則先前的值將最終將被垃圾回收只有最新的值會保留下來。
@ -300,7 +292,7 @@ Kafka Connect[^33]提供了大量資料庫系統與 Kafka 的 CDC 整合能力
資料變更捕獲與事件溯源都把狀態變化表示成事件日誌,但二者抽象層級不同:
* 在資料變更捕獲中,應用仍以可變方式使用資料庫,任意更新/刪除記錄;變更日誌從資料庫底層抽取(如複製日誌),因此能保證抽取順序與真實寫入順序一致,避免 [圖 12-4](/fig/ddia_1204.png) 這類競態問題。
* 在資料變更捕獲中,應用仍以可變方式使用資料庫,任意更新/刪除記錄;變更日誌從資料庫底層抽取(如複製日誌),因此能保證抽取順序與真實寫入順序一致,避免 [圖 12-4](#fig_stream_dual_write_race) 這類競態問題。
* 在事件溯源中,應用邏輯從一開始就構建在不可變事件之上,事件儲存通常是僅追加寫入,更新和刪除被限制或禁止。事件語義是應用層行為,而非底層狀態差異。
二者孰優取決於場景。對未採用事件溯源的系統而言,引入它通常是一次較大架構變更;而資料變更捕獲通常可在現有資料庫上以較小改動接入,應用層甚至可以感知不到 CDC 的存在。
@ -312,7 +304,7 @@ Kafka Connect[^33]提供了大量資料庫系統與 Kafka 的 CDC 整合能力
>
> 但 CDC 往往直接複用上游資料庫模式做複製,這會把原本“內部模式”變成“外部契約”。刪除某個列可能會直接破壞下游消費者[^34]。
>
> 一種常見解法是 **Outbox 模式**:專門維護對外發布的 outbox 表,讓 CDC 讀取 outbox而不是直接讀取內部領域模型表。這樣可以在儘量不影響外部消費者的前提下演化內部模式[^35][^36]。它看起來像雙寫,實際上也是雙寫;但它把兩次寫入留在同一個資料庫系統內,因此可放進同一事務,規避跨系統雙寫的一致性問題。
> 一種常見解法是 **Outbox 模式**:專門維護對外發布的 outbox 表,讓 CDC 讀取 outbox而不是直接讀取內部領域模型表。這樣可以在儘量不影響外部消費者的前提下演化內部模式[^35] [^36]。它看起來像雙寫,實際上也是雙寫;但它把兩次寫入留在同一個資料庫系統內,因此可放進同一事務,規避跨系統雙寫的一致性問題。
和資料變更捕獲一樣,重放事件日誌也能重建當前狀態,但日誌壓縮策略不同:
@ -331,7 +323,7 @@ Kafka Connect[^33]提供了大量資料庫系統與 Kafka 的 CDC 整合能力
無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌 —— **變化日誌changelog**,表示了隨時間演變的狀態。
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 12-7](/fig/ddia_1207.png) 所示[^37][^38]。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 12-7](#fig_stream_state_derivative) 所示[^37] [^38]。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
$$
\begin{aligned}
@ -340,9 +332,7 @@ stream(t) &= \frac{d\,state(t)}{dt}
\end{aligned}
$$
![](/fig/ddia_1207.png)
**圖 12-7 應用當前狀態與事件流之間的關係**
{{< figure src="/fig/ddia_1207.png" id="fig_stream_state_derivative" caption="圖 12-7. 應用當前狀態與事件流之間的關係。" class="w-full my-4" >}}
如果你持久儲存了變更日誌,那麼重現狀態就非常簡單。如果你將事件日誌視為記錄系統,而把可變狀態視為其派生結果,那麼系統中的資料流就更容易推理。正如 Jim Gray 和 Andreas Reuter 在 1992 年所說[^39]
@ -362,9 +352,9 @@ $$
#### 從同一事件日誌中派生多個檢視 {#sec_stream_deriving_views}
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中派生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 12-5](/fig/ddia_1205.png)例如Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引[^33]。這對於許多其他儲存和索引系統(如搜尋伺服器)來說也是有意義的,當系統要從分散式日誌中獲取輸入時尤其如此(請參閱 “[保持系統同步](#sec_stream_sync)”)。
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中派生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 12-5](#fig_stream_cdc_flow)例如Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引[^33]。這對於許多其他儲存和索引系統(如搜尋伺服器)來說也是有意義的,當系統要從分散式日誌中獲取輸入時尤其如此(請參閱 “[保持系統同步](#sec_stream_sync)”)。
新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行複雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源[^42][^43]。
新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行複雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源[^42] [^43]。
如果你不需要擔心如何查詢與訪問資料,那麼儲存資料通常是非常簡單的。模式設計、索引和儲存引擎的許多複雜性,都是希望支援某些特定查詢和訪問模式的結果(請參閱 [第三章](/tw/ch3))。出於這個原因,透過將資料寫入的形式與讀取形式相分離,並允許幾個不同的讀取檢視,你能獲得很大的靈活性。這個想法有時被稱為 **命令查詢責任分離command query responsibility segregation, CQRS**[^44]。
@ -386,7 +376,7 @@ $$
許多不使用事件溯源模型的系統也還是依賴不可變性:各種資料庫在內部使用不可變的資料結構或多版本資料來支援時間點快照(請參閱 “[索引和快照隔離](/tw/ch8#sec_transactions_snapshot_indexes)” 。Git、Mercurial 和 Fossil 等版本控制系統也依靠不可變的資料來儲存檔案的版本歷史記錄。
永遠保持所有變更的不變歷史,在多大程度上是可行的?答案取決於資料集的流失率。一些工作負載主要是新增資料,很少更新或刪除;它們很容易保持不變。其他工作負載在相對較小的資料集上有較高的更新 / 刪除率;在這些情況下,不可變的歷史可能增至難以接受的巨大,碎片化可能成為一個問題,壓縮與垃圾收集的表現對於運維的穩健性變得至關重要[^45][^46]。
永遠保持所有變更的不變歷史,在多大程度上是可行的?答案取決於資料集的流失率。一些工作負載主要是新增資料,很少更新或刪除;它們很容易保持不變。其他工作負載在相對較小的資料集上有較高的更新 / 刪除率;在這些情況下,不可變的歷史可能增至難以接受的巨大,碎片化可能成為一個問題,壓縮與垃圾收集的表現對於運維的穩健性變得至關重要[^45] [^46]。
除了效能方面的原因外,也可能有出於管理方面的原因需要刪除資料的情況,儘管這些資料都是不可變的。例如,隱私條例可能要求在使用者關閉帳戶後刪除他們的個人資訊,資料保護立法可能要求刪除錯誤的資訊,或者可能需要阻止敏感資訊的意外洩露。
@ -407,7 +397,7 @@ $$
剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項:
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 12-5](/fig/ddia_1205.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#sec_batch_output)” 中所討論的,它是寫入儲存系統的流等價物。
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 12-5](#fig_stream_cdc_flow) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#sec_batch_output)” 中所討論的,它是寫入儲存系統的流等價物。
2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可即時顯示的儀表板上。在這種情況下,人是流的最終消費者。
3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項 1 或 2
@ -465,7 +455,7 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
>
> 但很多資料庫重新整理物化檢視仍依賴批處理或按需觸發(例如 PostgreSQL 的 `REFRESH MATERIALIZED VIEW`),而不是在源資料變化時做增量維護。這會帶來兩個問題:
>
> 1. 效率低:每次重新整理都重算全量資料,而不是隻處理變化部分[^38][^59][^60]。
> 1. 效率低:每次重新整理都重算全量資料,而不是隻處理變化部分[^38] [^59] [^60]。
> 2. 不夠即時:重新整理間隔內的變化不會立刻反映在視圖裡。
>
> Materialize、RisingWave、ClickHouse、Feldera 等系統都在探索更即時的增量維護路徑[^61]。
@ -486,7 +476,7 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
* Actor 之間的交流往往是短暫的、一對一的;而事件日誌則是持久的、多訂閱者的。
* Actor 可以以任意方式進行通訊(包括迴圈的請求 / 響應模式),但流處理通常配置在無環流水線中,其中每個流都是一個特定作業的輸出,由良好定義的輸入流中派生而來。
也就是說RPC 類系統與流處理之間有一些交叉領域。例如Apache Storm 有一個稱為 **分散式 RPC** 的功能,它允許將使用者查詢分散到一系列也處理事件流的節點上;然後這些查詢與來自輸入流的事件交織,而結果可以被彙總併發回給使用者(另請參閱 “[多分割槽資料處理](/tw/ch13#多分割槽資料處理)”)。
也就是說RPC 類系統與流處理之間有一些交叉領域。例如Apache Storm 有一個稱為 **分散式 RPC** 的功能,它允許將使用者查詢分散到一系列也處理事件流的節點上;然後這些查詢與來自輸入流的事件交織,而結果可以被彙總併發回給使用者(另請參閱 “[多分割槽資料處理](/tw/ch13#sec_future_unbundled_multi_shard)”)。
也可以使用 Actor 框架來處理流。但是,很多這樣的框架在崩潰時不能保證訊息的傳遞,除非你實現了額外的重試邏輯,否則這種處理不是容錯的。
@ -508,11 +498,9 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
有一個類比也許能幫助理解,“星球大戰” 電影:第四集於 1977 年發行,第五集於 1980 年,第六集於 1983 年,緊隨其後的是 1999 年的第一集、2002 年的第二集、2005 年的第三集,以及 2015 年、2017 年和 2019 年的第七至第九集[^65]。如果你按照它們上映的順序觀看電影,你處理電影的順序與它們敘事的順序就是不一致的。(集數編號就像事件時間戳,而你觀看電影的日期就是處理時間)作為人類,我們能夠應對這種不連續性,但是流處理演算法需要專門編寫,以適應這種時序與順序的問題。
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 12-8](/fig/ddia_1208.png))。
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 12-8](#fig_stream_processing_time_skew))。
![](/fig/ddia_1208.png)
**圖 12-8 按處理時間分窗,會因為處理速率的變動引入人為因素**
{{< figure src="/fig/ddia_1208.png" id="fig_stream_processing_time_skew" caption="圖 12-8. 按處理時間分窗,會因為處理速率的變動引入人為因素。" class="w-full my-4" >}}
#### 處理滯留事件 {#id323}
@ -545,7 +533,7 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
#### 視窗的型別 {#id324}
當你知道如何確定一個事件的時間戳後,下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合,例如事件計數,或計算視窗內值的平均值。有幾種視窗很常用[^64][^68]
當你知道如何確定一個事件的時間戳後,下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合,例如事件計數,或計算視窗內值的平均值。有幾種視窗很常用[^64] [^68]
滾動視窗Tumbling Window
: 滾動視窗有著固定的長度,每個事件都僅能屬於一個視窗。例如,假設你有一個 1 分鐘的滾動視窗,則所有時間戳在 `10:03:00``10:03:59` 之間的事件會被分組到一個視窗中,`10:04:00` 和 `10:04:59` 之間的事件被分組到下一個視窗,依此類推。透過將每個事件時間戳四捨五入至最近的分鐘來確定它所屬的視窗,可以實現 1 分鐘的滾動視窗。
@ -577,7 +565,7 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str
#### 流表連線(流擴充) {#sec_stream_table_joins}
在 “[示例:使用者活動事件分析](/tw/ch11#sec_batch_join)”([圖 11-2](/fig/ddia_1102.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充enriching** 活動事件。
在 “[示例:使用者活動事件分析](/tw/ch11#sec_batch_join)”([圖 11-2](/tw/ch11#fig_batch_join_example))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充enriching** 活動事件。
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者 ID並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在 “[示例:使用者活動事件分析](/tw/ch11#sec_batch_join)” 一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載[^58]。
@ -613,7 +601,7 @@ GROUP BY follows.follower_id
流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當底層的表發生變化時都會更新。
> [!NOTE]
> 如果你將流視作表的導數(如 [圖 12-7](/fig/ddia_1207.png) 所示),並把連線看作兩個表 *u·v* 的乘積,那麼會出現一個有趣現象:物化連線的變化流遵循乘積法則 \( (u \cdot v)' = u'v + uv' \)。換句話說,任何推文變化都要和當前關注關係連線,任何關注關係變化都要和當前推文連線[^37]。
> 如果你將流視作表的導數(如 [圖 12-7](#fig_stream_state_derivative) 所示),並把連線看作兩個表 *u·v* 的乘積,那麼會出現一個有趣現象:物化連線的變化流遵循乘積法則 \( (u \cdot v)' = u'v + uv' \)。換句話說,任何推文變化都要和當前關注關係連線,任何關注關係變化都要和當前推文連線[^37]。
#### 連線的時間依賴性 {#sec_stream_join_time}
@ -627,7 +615,7 @@ GROUP BY follows.follower_id
如果跨越流的事件順序是未定的,則連線會變為不確定性的[^70],這意味著你在同樣輸入上重跑相同的作業未必會得到相同的結果:當你重跑任務時,輸入流上的事件可能會以不同的方式交織。
在資料倉庫中,這個問題被稱為 **緩慢變化的維度slowly changing dimension, SCD**,通常透過對特定版本的記錄使用唯一的識別符號來解決:例如,每當稅率改變時都會獲得一個新的識別符號,而發票在銷售時會帶有稅率的識別符號[^71][^72]。這種變化使連線變為確定性的,但也會導致日誌壓縮無法進行:表中所有的記錄版本都需要保留。
在資料倉庫中,這個問題被稱為 **緩慢變化的維度slowly changing dimension, SCD**,通常透過對特定版本的記錄使用唯一的識別符號來解決:例如,每當稅率改變時都會獲得一個新的識別符號,而發票在銷售時會帶有稅率的識別符號[^71] [^72]。這種變化使連線變為確定性的,但也會導致日誌壓縮無法進行:表中所有的記錄版本都需要保留。
### 容錯 {#sec_stream_fault_tolerance}
@ -643,7 +631,7 @@ GROUP BY follows.follower_id
微批次也隱式提供了一個與批次大小相等的滾動視窗(按處理時間而不是事件時間戳分窗)。任何需要更大視窗的作業都需要顯式地將狀態從一個微批次轉移到下一個微批次。
Apache Flink 則使用不同的方法,它會定期生成狀態的滾動存檔點並將其寫入持久儲存[^75][^76]。如果流運算元崩潰,它可以從最近的存檔點重啟,並丟棄從最近檢查點到崩潰之間的所有輸出。存檔點會由訊息流中的 **壁障barrier** 觸發,類似於微批次之間的邊界,但不會強制一個特定的視窗大小。
Apache Flink 則使用不同的方法,它會定期生成狀態的滾動存檔點並將其寫入持久儲存[^75] [^76]。如果流運算元崩潰,它可以從最近的存檔點重啟,並丟棄從最近檢查點到崩潰之間的所有輸出。存檔點會由訊息流中的 **壁障barrier** 觸發,類似於微批次之間的邊界,但不會強制一個特定的視窗大小。
在流處理框架的範圍內,微批次與存檔點方法提供了與批處理一樣的 **恰好一次語義**。但是,只要輸出離開流處理器(例如,寫入資料庫,向外部訊息代理傳送訊息,或傳送電子郵件),框架就無法拋棄失敗批次的輸出了。在這種情況下,重啟失敗任務會導致外部副作用發生兩次,只有微批次或存檔點不足以阻止這一問題。
@ -653,7 +641,7 @@ Apache Flink 則使用不同的方法,它會定期生成狀態的滾動存檔
這些事情要麼都原子地發生,要麼都不發生,但是它們不應當失去同步。如果這種方法聽起來很熟悉,那是因為我們在分散式事務和兩階段提交的上下文中討論過它(請參閱 “[恰好一次的訊息處理](/tw/ch8#sec_transactions_exactly_once)”)。
在 [第十章](/tw/ch10) 中,我們討論了分散式事務傳統實現中的問題(如 XA。然而在限制更為嚴苛的環境中也是有可能高效實現這種原子提交機制的。Google Cloud Dataflow[^66][^75]、VoltDB[^77] 和 Apache Kafka[^78][^79] 中都使用了這種方法。與 XA 不同,這些實現不會嘗試跨異構技術提供事務,而是透過在流處理框架中同時管理狀態變更與訊息傳遞來內化事務。事務協議的開銷可以透過在單個事務中處理多個輸入訊息來分攤。
在 [第十章](/tw/ch10) 中,我們討論了分散式事務傳統實現中的問題(如 XA。然而在限制更為嚴苛的環境中也是有可能高效實現這種原子提交機制的。Google Cloud Dataflow[^66] [^75]、VoltDB[^77] 和 Apache Kafka[^78] [^79] 中都使用了這種方法。與 XA 不同,這些實現不會嘗試跨異構技術提供事務,而是透過在流處理框架中同時管理狀態變更與訊息傳遞來內化事務。事務協議的開銷可以透過在單個事務中處理多個輸入訊息來分攤。
#### 冪等性 {#sec_stream_idempotence}
@ -663,7 +651,7 @@ Apache Flink 則使用不同的方法,它會定期生成狀態的滾動存檔
即使一個操作不是天生冪等的,往往可以透過一些額外的元資料做成冪等的。例如,在使用來自 Kafka 的訊息時,每條訊息都有一個持久的、單調遞增的偏移量。將值寫入外部資料庫時可以將這個偏移量帶上,這樣你就可以判斷一條更新是不是已經執行過了,因而避免重複執行。
Storm 的 Trident 基於類似的想法來處理狀態。依賴冪等性意味著隱含了一些假設:重啟一個失敗的任務必須以相同的順序重播相同的訊息(基於日誌的訊息代理能做這些事),處理必須是確定性的,沒有其他節點能同時更新相同的值[^81][^82]。
Storm 的 Trident 基於類似的想法來處理狀態。依賴冪等性意味著隱含了一些假設:重啟一個失敗的任務必須以相同的順序重播相同的訊息(基於日誌的訊息代理能做這些事),處理必須是確定性的,沒有其他節點能同時更新相同的值[^81] [^82]。
當從一個處理節點故障切換到另一個節點時,可能需要進行 **防護**fencing請參閱 “[領導者和鎖](/tw/ch9#sec_distributed_lock_fencing)”),以防止被假死節點干擾。儘管有這麼多注意事項,冪等操作是一種實現 **恰好一次語義** 的有效方式,僅需很小的額外開銷。
@ -673,7 +661,7 @@ Storm 的 Trident 基於類似的想法來處理狀態。依賴冪等性意味
一種選擇是將狀態儲存在遠端資料儲存中,並進行復制,然而正如在 “[流表連線(流擴充)](#sec_stream_table_joins)” 中所述,每個訊息都要查詢遠端資料庫可能會很慢。另一種方法是在流處理器本地儲存狀態,並定期複製。然後當流處理器從故障中恢復時,新任務可以讀取狀態副本,恢復處理而不丟失資料。
例如Flink 定期捕獲運算元狀態的快照,並將它們寫入 HDFS 等持久儲存中[^75][^76]。Kafka Streams 透過將狀態變更傳送到具有日誌壓縮功能的專用 Kafka 主題來複制狀態變更,這與資料變更捕獲類似[^83]。VoltDB 透過在多個節點上對每個輸入訊息進行冗餘處理來複制狀態(請參閱 “[真的序列執行](/tw/ch8#sec_transactions_serial)”)。
例如Flink 定期捕獲運算元狀態的快照,並將它們寫入 HDFS 等持久儲存中[^75] [^76]。Kafka Streams 透過將狀態變更傳送到具有日誌壓縮功能的專用 Kafka 主題來複制狀態變更,這與資料變更捕獲類似[^83]。VoltDB 透過在多個節點上對每個輸入訊息進行冗餘處理來複制狀態(請參閱 “[真的序列執行](/tw/ch8#sec_transactions_serial)”)。
在某些情況下,甚至可能都不需要複製狀態,因為它可以從輸入流重建。例如,如果狀態是從相當短的視窗中聚合而成,則簡單地重播該視窗中的輸入事件可能是足夠快的。如果狀態是透過資料變更捕獲來維護的資料庫的本地副本,那麼也可以從日誌壓縮的變更流中重建資料庫(請參閱 “[日誌壓縮](#sec_stream_log_compaction)”)。

View file

@ -42,7 +42,7 @@ breadcrumbs: false
例如,你可能會首先將資料寫入 **記錄系統** 資料庫,捕獲對該資料庫所做的變更(請參閱 “[變更資料捕獲](/tw/ch12#sec_stream_cdc)”然後將變更以相同的順序應用於搜尋索引。如果變更資料捕獲CDC是更新索引的唯一方式則可以確定該索引完全派生自記錄系統因此與其保持一致除軟體錯誤外。寫入資料庫是向該系統提供新輸入的唯一方式。
允許應用程式直接寫入搜尋索引和資料庫引入了如 [圖 12-4](/fig/ddia_1204.png) 所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。
允許應用程式直接寫入搜尋索引和資料庫引入了如 [圖 12-4](/tw/ch12#fig_stream_dual_write_race) 所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。
如果你可以透過單個系統來提供所有使用者輸入,從而決定所有寫入的排序,則透過按相同順序處理寫入,可以更容易地派生出其他資料表示。這是狀態機複製方法的一個應用,我們在 “[全序廣播](/tw/ch10#sec_consistency_total_order)” 中看到。無論你使用變更資料捕獲還是事件溯源日誌,都不如簡單的基於全序的決策原則更重要。
@ -81,10 +81,10 @@ breadcrumbs: false
但是如果好友關係狀態與訊息儲存在不同的地方,在這樣一個系統中,可能會出現 **解除好友** 事件與 **傳送訊息** 事件之間的因果依賴丟失的情況。如果因果依賴關係沒有被捕捉到,則傳送有關新訊息的通知的服務可能會在 **解除好友** 事件之前處理 **傳送訊息** 事件,從而錯誤地向前任傳送通知。
在本例中,通知實際上是訊息和好友列表之間的連線,使得它與我們先前討論的連線的時序問題有關(請參閱 “[連線的時間依賴性](/tw/ch12#sec_stream_join_time)”)。不幸的是,這個問題似乎並沒有一個簡單的答案[^2][^3]。起點包括:
在本例中,通知實際上是訊息和好友列表之間的連線,使得它與我們先前討論的連線的時序問題有關(請參閱 “[連線的時間依賴性](/tw/ch12#sec_stream_join_time)”)。不幸的是,這個問題似乎並沒有一個簡單的答案[^2] [^3]。起點包括:
* 邏輯時間戳可以提供無需協調的全域性順序(請參閱 “[序列號順序](/tw/ch10#sec_consistency_logical)”),因此它們可能有助於全序廣播不可行的情況。但是,他們仍然要求收件人處理不按順序傳送的事件,並且需要傳遞其他元資料。
* 如果你可以記錄一個事件來記錄使用者在做出決定之前所看到的系統狀態,並給該事件一個唯一的識別符號,那麼後面的任何事件都可以引用該事件識別符號來記錄因果關係[^4]。我們將在 “[讀也是事件](#讀也是事件)” 中回到這個想法。
* 如果你可以記錄一個事件來記錄使用者在做出決定之前所看到的系統狀態,並給該事件一個唯一的識別符號,那麼後面的任何事件都可以引用該事件識別符號來記錄因果關係[^4]。我們將在 “[讀也是事件](#sec_future_read_events)” 中回到這個想法。
* 衝突解決演算法(請參閱 “[自動衝突解決](/tw/ch6#automatic-conflict-resolution)”)有助於處理以意外順序傳遞的事件。它們對於維護狀態很有用,但如果行為有外部副作用(例如,給使用者傳送通知),就沒什麼幫助了。
也許,隨著時間的推移,應用開發模式將出現,使得能夠有效地捕獲因果依賴關係,並且保持正確的派生狀態,而不會迫使所有事件經歷全序廣播的瓶頸)。
@ -105,7 +105,7 @@ breadcrumbs: false
原則上,派生資料系統可以同步地維護,就像關係資料庫在與索引表寫入操作相同的事務中同步更新次級索引一樣。然而,非同步是使基於事件日誌的系統穩健的原因:它允許系統的一部分故障被抑制在本地。而如果任何一個參與者失敗,分散式事務將中止,因此它們傾向於透過將故障傳播到系統的其餘部分來放大故障(請參閱 “[分散式事務的限制](/tw/ch8#sec_transactions_xa)”)。
我們在 “[分割槽與次級索引](/tw/ch7#sec_sharding_secondary_indexes)” 中看到,次級索引經常跨越分割槽邊界。具有次級索引的分割槽系統需要將寫入傳送到多個分割槽(如果索引按關鍵詞分割槽的話)或將讀取傳送到所有分割槽(如果索引是按文件分割槽的話)。如果索引是非同步維護的,這種跨分割槽通訊也是最可靠和最可伸縮的[^8](另請參閱 “[多分割槽資料處理](#多分割槽資料處理)”)。
我們在 “[分割槽與次級索引](/tw/ch7#sec_sharding_secondary_indexes)” 中看到,次級索引經常跨越分割槽邊界。具有次級索引的分割槽系統需要將寫入傳送到多個分割槽(如果索引按關鍵詞分割槽的話)或將讀取傳送到所有分割槽(如果索引是按文件分割槽的話)。如果索引是非同步維護的,這種跨分割槽通訊也是最可靠和最可伸縮的[^8](另請參閱 “[多分割槽資料處理](#sec_future_unbundled_multi_shard)”)。
#### 應用演化後重新處理資料 {#sec_future_reprocessing}
@ -169,7 +169,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
此過程非常類似於設定新的從庫副本(請參閱 “[設定新從庫](/tw/ch6#sec_replication_new_replica)”),也非常類似於流處理系統中的 **引導bootstrap** 變更資料捕獲(請參閱 “[初始快照](/tw/ch12#sec_stream_cdc_snapshot)”)。
無論何時執行 `CREATE INDEX`,資料庫都會重新處理現有資料集(如 “[應用演化後重新處理資料](#應用演化後重新處理資料)” 中所述),並將該索引作為新檢視匯出到現有資料上。現有資料可能是狀態的快照,而不是所有發生變化的日誌,但兩者密切相關(請參閱 “[狀態、流和不變性](/tw/ch12#sec_stream_immutability)”)。
無論何時執行 `CREATE INDEX`,資料庫都會重新處理現有資料集(如 “[應用演化後重新處理資料](#sec_future_reprocessing)” 中所述),並將該索引作為新檢視匯出到現有資料上。現有資料可能是狀態的快照,而不是所有發生變化的日誌,但兩者密切相關(請參閱 “[狀態、流和不變性](/tw/ch12#sec_stream_immutability)”)。
#### 一切的元資料庫 {#id341}
@ -181,13 +181,13 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
**聯合資料庫:統一讀取**
可以為各種各樣的底層儲存引擎和處理方法提供一個統一的查詢介面 —— 一種稱為 **聯合資料庫federated database****多型儲存polystore** 的方法[^18][^19]。例如PostgreSQL 的 **外部資料包裝器foreign data wrapper** 功能符合這種模式[^20]。需要專用資料模型或查詢介面的應用程式仍然可以直接訪問底層儲存引擎,而想要組合來自不同位置的資料的使用者可以透過聯合介面輕鬆完成操作。
可以為各種各樣的底層儲存引擎和處理方法提供一個統一的查詢介面 —— 一種稱為 **聯合資料庫federated database****多型儲存polystore** 的方法[^18] [^19]。例如PostgreSQL 的 **外部資料包裝器foreign data wrapper** 功能符合這種模式[^20]。需要專用資料模型或查詢介面的應用程式仍然可以直接訪問底層儲存引擎,而想要組合來自不同位置的資料的使用者可以透過聯合介面輕鬆完成操作。
聯合查詢介面遵循著單一整合系統的關係型傳統,帶有高階查詢語言和優雅的語義,但實現起來非常複雜。
**分拆資料庫:統一寫入**
雖然聯合能解決跨多個不同系統的只讀查詢問題,但它並沒有很好的解決跨系統 **同步** 寫入的問題。我們說過,在單個數據庫中,建立一致的索引是一項內建功能。當我們構建多個儲存系統時,我們同樣需要確保所有資料變更都會在所有正確的位置結束,即使在出現故障時也是如此。想要更容易地將儲存系統可靠地插接在一起(例如,透過變更資料捕獲和事件日誌),就像將資料庫的索引維護功能以可以跨不同技術同步寫入的方式分開[^7][^21]。
雖然聯合能解決跨多個不同系統的只讀查詢問題,但它並沒有很好的解決跨系統 **同步** 寫入的問題。我們說過,在單個數據庫中,建立一致的索引是一項內建功能。當我們構建多個儲存系統時,我們同樣需要確保所有資料變更都會在所有正確的位置結束,即使在出現故障時也是如此。想要更容易地將儲存系統可靠地插接在一起(例如,透過變更資料捕獲和事件日誌),就像將資料庫的索引維護功能以可以跨不同技術同步寫入的方式分開[^7] [^21]。
分拆方法遵循 Unix 傳統的小型工具,它可以很好地完成一件事[^22],透過統一的低層級 API管道進行通訊並且可以使用更高層級的語言進行組合shell[^16] 。
@ -195,7 +195,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
聯合和分拆是一個硬幣的兩面:用不同的元件構成可靠、 可伸縮和可維護的系統。聯合只讀查詢需要將一個數據模型對映到另一個數據模型,這需要一些思考,但最終還是一個可解決的問題。而我認為同步寫入到幾個儲存系統是更困難的工程問題,所以我將重點關注它。
傳統的同步寫入方法需要跨異構儲存系統的分散式事務[^18],我認為這是錯誤的解決方案(請參閱 “[派生資料與分散式事務](#派生資料與分散式事務)”)。單個儲存或流處理系統內的事務是可行的,但是當資料跨越不同技術之間的邊界時,我認為具有冪等寫入的非同步事件日誌是一種更加健壯和實用的方法。
傳統的同步寫入方法需要跨異構儲存系統的分散式事務[^18],我認為這是錯誤的解決方案(請參閱 “[派生資料與分散式事務](#sec_future_derived_vs_transactions)”)。單個儲存或流處理系統內的事務是可行的,但是當資料跨越不同技術之間的邊界時,我認為具有冪等寫入的非同步事件日誌是一種更加健壯和實用的方法。
例如,分散式事務在某些流處理元件內部使用,以匹配 **恰好一次exactly-once** 語義(請參閱 “[原子提交再現](/tw/ch12#sec_stream_atomic_commit)”),這可以很好地工作。然而,當事務需要涉及由不同人群編寫的系統時(例如,當資料從流處理元件寫入分散式鍵值儲存或搜尋索引時),缺乏標準化的事務協議會使整合更難。有冪等消費者的有序事件日誌(請參閱 “[冪等性](/tw/ch12#sec_stream_idempotence)”)是一種更簡單的抽象,因此在異構系統中實現更加可行[^7]。
@ -253,7 +253,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
從資料流的角度思考應用程式,意味著重新協調應用程式碼和狀態管理之間的關係。我們不再將資料庫視作被應用操縱的被動變數,取而代之的是更多地考慮狀態,狀態變更和處理它們的程式碼之間的相互作用與協同關係。應用程式碼透過在另一個地方觸發狀態變更來響應狀態變更。
我們在 “[資料庫與流](/tw/ch12#sec_stream_databases)” 中看到了這一思路,我們討論了將資料庫的變更日誌視為一種我們可以訂閱的事件流。諸如 Actor 的訊息傳遞系統(請參閱 “[訊息傳遞中的資料流](/tw/ch5#sec_encoding_dataflow_msg)”)也具有響應事件的概念。早在 20 世紀 80 年代,**元組空間tuple space** 模型就已經探索了表達分散式計算的方式:觀察狀態變更並作出反應的過程[^38][^39]。
我們在 “[資料庫與流](/tw/ch12#sec_stream_databases)” 中看到了這一思路,我們討論了將資料庫的變更日誌視為一種我們可以訂閱的事件流。諸如 Actor 的訊息傳遞系統(請參閱 “[訊息傳遞中的資料流](/tw/ch5#sec_encoding_dataflow_msg)”)也具有響應事件的概念。早在 20 世紀 80 年代,**元組空間tuple space** 模型就已經探索了表達分散式計算的方式:觀察狀態變更並作出反應的過程[^38] [^39]。
如前所述,當觸發器由於資料變更而被觸發時,或次級索引更新以反映索引表中的變更時,資料庫內部也發生著類似的情況。分拆資料庫意味著將這個想法應用於在主資料庫之外,用於建立派生資料集:快取、全文檢索索引、機器學習或分析系統。我們可以為此使用流處理和訊息傳遞系統。
@ -272,7 +272,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
在資料流中組裝流運算元與微服務方法有很多相似之處[^40]。但底層通訊機制是有很大區別:資料流採用單向非同步訊息流,而不是同步的請求 / 響應式互動。
除了在 “[訊息傳遞中的資料流](/tw/ch5#sec_encoding_dataflow_msg)” 中列出的優點(如更好的容錯性),資料流系統還能實現更好的效能。例如,假設客戶正在購買以一種貨幣定價,但以另一種貨幣支付的商品。為了執行貨幣換算,你需要知道當前的匯率。這個操作可以透過兩種方式實現[^40][^41]
除了在 “[訊息傳遞中的資料流](/tw/ch5#sec_encoding_dataflow_msg)” 中列出的優點(如更好的容錯性),資料流系統還能實現更好的效能。例如,假設客戶正在購買以一種貨幣定價,但以另一種貨幣支付的商品。為了執行貨幣換算,你需要知道當前的匯率。這個操作可以透過兩種方式實現[^40] [^41]
1. 在微服務方法中,處理購買的程式碼可能會查詢匯率服務或資料庫,以獲取特定貨幣的當前匯率。
2. 在資料流方法中,處理訂單的程式碼會提前訂閱匯率變更流,並在匯率發生變動時將當前匯率儲存在本地資料庫中。處理訂單時只需查詢本地資料庫即可。
@ -285,7 +285,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
### 觀察派生資料狀態 {#sec_future_observing}
在抽象層面,上一節討論的資料流系統給出了建立並維護派生資料集(如搜尋索引、物化檢視、預測模型)的過程。我們把這稱為 **寫路徑write path**:當資訊寫入系統後,它可能經過多個批處理與流處理階段,最終所有相關派生資料集都會被更新。[圖 13-1](/tw/ch13#fig_future_write_read_paths) 展示了搜尋索引更新的例子。
在抽象層面,上一節討論的資料流系統給出了建立並維護派生資料集(如搜尋索引、物化檢視、預測模型)的過程。我們把這稱為 **寫路徑write path**:當資訊寫入系統後,它可能經過多個批處理與流處理階段,最終所有相關派生資料集都會被更新。[圖 13-1](#fig_future_write_read_paths) 展示了搜尋索引更新的例子。
{{< figure src="/fig/ddia_1301.png" id="fig_future_write_read_paths" caption="圖 13-1 在搜尋索引中,寫入(文件更新)與讀取(查詢)相遇。" class="w-full my-4" >}}
@ -293,7 +293,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
總而言之,寫路徑和讀路徑涵蓋了資料的整個旅程,從收集資料開始,到使用資料結束(可能是由另一個人)。寫路徑是預計算過程的一部分 —— 即,一旦資料進入,即刻完成,無論是否有人需要看它。讀路徑是這個過程中只有當有人請求時才會發生的部分。如果你熟悉函數語言程式設計語言,則可能會注意到寫路徑類似於立即求值,讀路徑類似於惰性求值。
如 [圖 13-1](/tw/ch13#fig_future_write_read_paths) 所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了寫入時工作量與讀取時工作量之間的權衡。
如 [圖 13-1](#fig_future_write_read_paths) 所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了寫入時工作量與讀取時工作量之間的權衡。
#### 物化檢視和快取 {#id451}
@ -357,7 +357,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
記錄讀取事件的日誌可能對於追蹤整個系統中的因果關係與資料來源也有好處:它可以讓你重現出當用戶做出特定決策之前看見了什麼。例如在網商中,向客戶顯示的預測送達日期與庫存狀態,可能會影響他們是否選擇購買一件商品[^4]。要分析這種聯絡,則需要記錄使用者查詢運輸與庫存狀態的結果。
將讀取事件寫入持久儲存可以更好地跟蹤因果關係(請參閱 “[排序事件以捕獲因果關係](#排序事件以捕獲因果關係)”),但會產生額外的儲存與 I/O 成本。最佳化這些系統以減少開銷仍然是一個開放的研究問題[^2]。但如果你已經出於運維目的留下了讀取請求日誌,將其作為請求處理的副作用,那麼將這份日誌作為請求事件源並不是什麼特別大的變更。
將讀取事件寫入持久儲存可以更好地跟蹤因果關係(請參閱 “[排序事件以捕獲因果關係](#sec_future_capture_causality)”),但會產生額外的儲存與 I/O 成本。最佳化這些系統以減少開銷仍然是一個開放的研究問題[^2]。但如果你已經出於運維目的留下了讀取請求日誌,將其作為請求處理的副作用,那麼將這份日誌作為請求事件源並不是什麼特別大的變更。
#### 多分割槽資料處理 {#sec_future_unbundled_multi_shard}
@ -378,7 +378,7 @@ MPP 資料庫的內部查詢執行圖有著類似的特徵(請參閱 “[Hadoo
事務在某些領域被完全拋棄,並被提供更好效能與可伸縮性的模型取代,但後者有更複雜的語義(例如,請參閱 “[無主複製](/tw/ch6#sec_replication_leaderless)”)。**一致性Consistency** 經常被談起,但其定義並不明確(請參閱 “[一致性](/tw/ch8#sec_transactions_acid_consistency)” 和 [第十章](/tw/ch10))。有些人斷言我們應當為了高可用而 “擁抱弱一致性”,但卻對這些概念實際上意味著什麼缺乏清晰的認識。
對於如此重要的話題,我們的理解,以及我們的工程方法卻是驚人地薄弱。例如,確定在特定事務隔離等級或複製配置下執行特定應用是否安全是非常困難的[^51][^52]。通常簡單的解決方案似乎在低併發性的情況下工作正常,並且沒有錯誤,但在要求更高的情況下卻會出現許多微妙的錯誤。
對於如此重要的話題,我們的理解,以及我們的工程方法卻是驚人地薄弱。例如,確定在特定事務隔離等級或複製配置下執行特定應用是否安全是非常困難的[^51] [^52]。通常簡單的解決方案似乎在低併發性的情況下工作正常,並且沒有錯誤,但在要求更高的情況下卻會出現許多微妙的錯誤。
例如Kyle Kingsbury 的 Jepsen 實驗[^53]標出了一些產品聲稱的安全保證與其在網路問題與崩潰時的實際行為之間的明顯差異。即使像資料庫這樣的基礎設施產品沒有問題,應用程式碼仍然需要正確使用它們提供的功能才行,如果配置很難理解,這是很容易出錯的(在這種情況下指的是弱隔離級別,法定人數配置等)。
@ -481,7 +481,7 @@ COMMIT;
### 強制約束 {#sec_future_constraints}
讓我們思考一下在 [分拆資料庫](#分拆資料庫) 上下文中的 **正確性correctness**。我們看到端到端的除重可以透過從客戶端一路透傳到資料庫的請求 ID 實現。那麼其他型別的約束呢?
讓我們思考一下在 [分拆資料庫](#sec_future_unbundling) 上下文中的 **正確性correctness**。我們看到端到端的除重可以透過從客戶端一路透傳到資料庫的請求 ID 實現。那麼其他型別的約束呢?
我們先來特別關注一下 **唯一性約束** —— 例如我們在 [例 13-2](#fig_future_request_id) 中所依賴的約束。在 “[約束和唯一性保證](/tw/ch10#sec_consistency_uniqueness)” 中,我們看到了幾個其他需要強制實施唯一性的應用功能例子:使用者名稱或電子郵件地址必須唯一標識使用者,檔案儲存服務不能包含多個重名檔案,兩個人不能在航班或劇院預訂同一個座位。
@ -537,7 +537,7 @@ COMMIT;
事務的一個便利屬性是,它們通常是線性一致的(請參閱 “[線性一致性](/tw/ch10#sec_consistency_linearizability)”),也就是說,寫入者會等到事務提交,而之後其寫入立刻對所有讀取者可見。
當我們把一個操作拆分為跨越多個階段的流處理器時,卻並非如此:日誌的消費者在設計上就是非同步的,因此傳送者不會等其訊息被消費者處理完。但是,客戶端等待輸出流中的特定訊息是可能的。這正是我們在 “[基於日誌訊息傳遞中的唯一性](#基於日誌訊息傳遞中的唯一性)” 一節中檢查唯一性約束時所做的事情。
當我們把一個操作拆分為跨越多個階段的流處理器時,卻並非如此:日誌的消費者在設計上就是非同步的,因此傳送者不會等其訊息被消費者處理完。但是,客戶端等待輸出流中的特定訊息是可能的。這正是我們在 “[基於日誌訊息傳遞中的唯一性](#sec_future_uniqueness_log)” 一節中檢查唯一性約束時所做的事情。
在這個例子中,唯一性檢查的正確性不取決於訊息傳送者是否等待結果。等待的目的僅僅是同步通知傳送者唯一性檢查是否成功。但該通知可以與訊息處理的結果相解耦。
@ -573,7 +573,7 @@ ACID 事務通常既提供及時性(例如線性一致性)也提供完整性
正如我們在上一節看到的那樣,可靠的流處理系統可以在無需分散式事務與原子提交協議的情況下保持完整性,這意味著它們有潛力達到與後者相當的正確性,同時還具備好得多的效能與運維穩健性。為了達成這種正確性,我們組合使用了多種機制:
* 將寫入操作的內容表示為單條訊息,從而可以輕鬆地被原子寫入 —— 與事件溯源搭配效果拔群(請參閱 “[事件溯源](/tw/ch12#sec_stream_event_sourcing)”)。
* 使用與儲存過程類似的確定性派生函式,從這一訊息中派生出所有其他的狀態變更(請參閱 “[真的序列執行](/tw/ch8#sec_transactions_serial)” 和 “[應用程式碼作為派生函式](/tw/ch13#sec_future_dataflow_derivation)”)
* 使用與儲存過程類似的確定性派生函式,從這一訊息中派生出所有其他的狀態變更(請參閱 “[真的序列執行](/tw/ch8#sec_transactions_serial)” 和 “[應用程式碼作為派生函式](#sec_future_dataflow_derivation)”)
* 將客戶端生成的請求 ID 傳遞透過所有的處理層次,從而允許端到端的除重,帶來冪等性。
* 使訊息不可變,並允許派生資料能隨時被重新處理,這使從錯誤中恢復更加容易(請參閱 “[不可變事件的優點](/tw/ch12#sec_stream_immutability_pros)”)
@ -585,7 +585,7 @@ ACID 事務通常既提供及時性(例如線性一致性)也提供完整性
然而另一個需要了解的事實是,許多真實世界的應用實際上可以擺脫這種形式,接受弱得多的唯一性:
* 如果兩個人同時註冊了相同的使用者名稱或預訂了相同的座位,你可以給其中一個人發訊息道歉,並要求他們換一個不同的使用者名稱或座位。這種糾正錯誤的變化被稱為 **補償性事務compensating transaction**[^59][^60]。
* 如果兩個人同時註冊了相同的使用者名稱或預訂了相同的座位,你可以給其中一個人發訊息道歉,並要求他們換一個不同的使用者名稱或座位。這種糾正錯誤的變化被稱為 **補償性事務compensating transaction**[^59] [^60]。
* 如果客戶訂購的物品多於倉庫中的物品,你可以下單補倉,併為延誤向客戶道歉,向他們提供折扣。實際上,這麼說吧,如果叉車在倉庫中軋過了你的貨物,剩下的貨物比你想象的要少,那麼你也是得這麼做[^61]。因此,既然道歉工作流無論如何已經成為你商業過程中的一部分了,那麼對庫存物品數目新增線性一致的約束可能就沒必要了。
* 與之類似,許多航空公司都會超賣機票,打著一些旅客可能會錯過航班的算盤;許多旅館也會超賣客房,抱著部分客人可能會取消預訂的期望。在這些情況下,出於商業原因而故意違反了 “一人一座” 的約束;當需求超過供給的情況出現時,就會進入補償流程(退款、升級艙位 / 房型、提供隔壁酒店的免費的房間)。即使沒有超賣,為了應對由惡劣天氣或員工罷工導致的航班取消,你還是需要道歉與補償流程 —— 從這些問題中恢復僅僅是商業活動的正常組成部分。
* 如果有人從賬戶超額取款,銀行可以向他們收取透支費用,並要求他們償還欠款。透過限制每天的提款總額,銀行的風險是有限的。
@ -647,13 +647,13 @@ ACID 意義下的一致性(請參閱 “[一致性](/tw/ch8#sec_transactions_a
顯式處理資料流(請參閱 “[批處理輸出的哲學](/tw/ch11#sec_batch_output)”)可以使資料的 **來龍去脈provenance** 更加清晰,從而使完整性檢查更具可行性。對於事件日誌,我們可以使用雜湊來檢查事件儲存沒有被破壞。對於任何派生狀態,我們可以重新執行從事件日誌中派生它的批處理器與流處理器,以檢查是否獲得相同的結果,或者,甚至並行執行冗餘的派生流程。
具有確定性且定義良好的資料流,也使除錯與跟蹤系統的執行變得容易,以便確定它 **為什麼** 做了某些事情[^4][^69]。如果出現意想之外的事情,那麼重現導致意外事件的確切事故現場的診斷能力 —— 一種時間旅行除錯功能是非常有價值的。
具有確定性且定義良好的資料流,也使除錯與跟蹤系統的執行變得容易,以便確定它 **為什麼** 做了某些事情[^4] [^69]。如果出現意想之外的事情,那麼重現導致意外事件的確切事故現場的診斷能力 —— 一種時間旅行除錯功能是非常有價值的。
#### 端到端原則重現 {#id456}
如果我們不能完全相信系統的每個元件都不會損壞 —— 每一個硬體都沒缺陷,每一個軟體都沒有 Bug —— 那我們至少必須定期檢查資料的完整性。如果我們不檢查,我們就不能發現損壞,直到無可挽回地導致對下游的破壞時,那時候再去追蹤問題就要難得多,且代價也要高的多。
檢查資料系統的完整性,最好是以端到端的方式進行(請參閱 “[資料庫的端到端原則](#資料庫的端到端原則)”):我們能在完整性檢查中涵蓋的系統越多,某些處理階中出現不被察覺損壞的機率就越小。如果我們能檢查整個派生資料管道端到端的正確性,那麼沿著這一路徑的任何磁碟、網路、服務以及演算法的正確性檢查都隱含在其中了。
檢查資料系統的完整性,最好是以端到端的方式進行(請參閱 “[資料庫的端到端原則](#sec_future_end_to_end)”):我們能在完整性檢查中涵蓋的系統越多,某些處理階中出現不被察覺損壞的機率就越小。如果我們能檢查整個派生資料管道端到端的正確性,那麼沿著這一路徑的任何磁碟、網路、服務以及演算法的正確性檢查都隱含在其中了。
持續的端到端完整性檢查可以不斷提高你對系統正確性的信心,從而使你能更快地進步[^70]。與自動化測試一樣,審計提高了快速發現錯誤的可能性,從而降低了系統變更或新儲存技術可能導致損失的風險。如果你不害怕進行變更,就可以更好地充分演化一個應用,使其滿足不斷變化的需求。
@ -661,9 +661,9 @@ ACID 意義下的一致性(請參閱 “[一致性](/tw/ch8#sec_transactions_a
目前,把可審計性作為一級目標的資料系統還不多。一些應用會實現自己的審計機制(例如把變更寫入獨立審計表),但要同時保證審計日誌與主資料庫狀態都不可篡改仍然很難。
像 Bitcoin、Ethereum 這樣的區塊鏈,本質上是帶密碼學一致性校驗的共享僅追加日誌;交易可視作事件,智慧合約可視作流處理器。它們透過共識協議讓所有節點同意同一事件序列。與本書 [第十章](/tw/ch10) 的共識協議相比,區塊鏈的一個差異是強調拜占庭容錯:參與節點會持續相互校驗完整性[^71][^72][^73]。
像 Bitcoin、Ethereum 這樣的區塊鏈,本質上是帶密碼學一致性校驗的共享僅追加日誌;交易可視作事件,智慧合約可視作流處理器。它們透過共識協議讓所有節點同意同一事件序列。與本書 [第十章](/tw/ch10) 的共識協議相比,區塊鏈的一個差異是強調拜占庭容錯:參與節點會持續相互校驗完整性[^71] [^72] [^73]。
對多數應用而言,區塊鏈整體開銷仍偏高;但其中一些密碼學工具可在更輕量的場景複用。比如 **默克爾樹Merkle tree**[^74]可高效證明某條記錄屬於某資料集。**證書透明性certificate transparency** 使用可驗證的僅追加日誌與默克爾樹來校驗 TLS/SSL 證書有效性[^75][^76]。
對多數應用而言,區塊鏈整體開銷仍偏高;但其中一些密碼學工具可在更輕量的場景複用。比如 **默克爾樹Merkle tree**[^74]可高效證明某條記錄屬於某資料集。**證書透明性certificate transparency** 使用可驗證的僅追加日誌與默克爾樹來校驗 TLS/SSL 證書有效性[^75] [^76]。
未來,這類完整性校驗與審計算法可能會在通用資料系統中更廣泛應用。要把它們做到與無密碼學審計系統同等級別的可伸縮性,同時把效能開銷壓到足夠低,仍需要工程改進,但方向值得重視。

View file

@ -6,7 +6,7 @@ breadcrumbs: false
<a id="ch_right_thing"></a>
![](/map/ch13.png)
![](/map/ch12.png)
> *將世界的美好、醜陋與殘酷一起餵給 AI卻期待它只反映美好的一面這是一種幻想。*
>

View file

@ -19,10 +19,10 @@ breadcrumbs: false
許多非功能性需求(比如安全)超出了本書範圍。但本章會討論其中幾項核心要求,並幫助你用更清晰的方式描述自己的系統:
* 如何定義並衡量系統的 **效能**(參見 ["描述效能"](/tw/ch2#sec_introduction_percentiles)
* 服務 **可靠** 到底意味著什麼:也就是在出錯時仍能持續正確工作(參見 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability)
* 如何透過高效增加計算資源,讓系統在負載增長時保持 **可伸縮性**(參見 ["可伸縮性"](/tw/ch2#sec_introduction_scalability));以及
* 如何讓系統在長期演進中保持 **可維護性**(參見 ["可維護性"](/tw/ch2#sec_introduction_maintainability))。
* 如何定義並衡量系統的 **效能**(參見 ["描述效能"](#sec_introduction_percentiles)
* 服務 **可靠** 到底意味著什麼:也就是在出錯時仍能持續正確工作(參見 ["可靠性與容錯"](#sec_introduction_reliability)
* 如何透過高效增加計算資源,讓系統在負載增長時保持 **可伸縮性**(參見 ["可伸縮性"](#sec_introduction_scalability));以及
* 如何讓系統在長期演進中保持 **可維護性**(參見 ["可維護性"](#sec_introduction_maintainability))。
本章引入的術語,在後續章節深入實現細節時也會反覆用到。不過純定義往往比較抽象。為了把概念落到實處,本章先從一個案例研究開始:看看社交網路服務可能如何實現,並藉此討論效能與可伸縮性問題。
@ -35,7 +35,7 @@ breadcrumbs: false
### 表示使用者、帖子與關注關係 {#id20}
假設我們將所有資料儲存在關係資料庫中,如 [圖 2-1](/tw/ch2#fig_twitter_relational) 所示。我們有一個使用者表、一個帖子表和一個關注關係表。
假設我們將所有資料儲存在關係資料庫中,如 [圖 2-1](#fig_twitter_relational) 所示。我們有一個使用者表、一個帖子表和一個關注關係表。
{{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="圖 2-1. 社交網路的簡單關係模式,使用者可以相互關注。" class="w-full my-4" >}}
@ -62,7 +62,7 @@ SELECT posts.*, users.* FROM posts
設想我們為每個使用者維護一個數據結構,儲存其首頁時間線,也就是其所追隨者的近期帖子。每當使用者發帖,我們就找出其所有追隨者,把這條帖子插入每個追隨者的首頁時間線中,就像往郵箱裡投遞信件。這樣使用者登入時,可以直接讀取預先算好的時間線。若要接收新帖提醒,客戶端只需訂閱“寫入該時間線”的帖子流即可。
這種方法的缺點是:每次發帖時都要做更多工作,因為首頁時間線屬於需要持續更新的派生資料。這個過程見 [圖 2-2](/tw/ch2#fig_twitter_timelines)。當一個初始請求觸發多個下游請求時,我們用 *扇出* 描述請求數量被放大的倍數。
這種方法的缺點是:每次發帖時都要做更多工作,因為首頁時間線屬於需要持續更新的派生資料。這個過程見 [圖 2-2](#fig_twitter_timelines)。當一個初始請求觸發多個下游請求時,我們用 *扇出* 描述請求數量被放大的倍數。
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="圖 2-2. 扇出:將新帖子傳遞給釋出帖子的使用者的每個追隨者。" class="w-full my-4" >}}
@ -87,12 +87,14 @@ SELECT posts.*, users.* FROM posts
在社交網路案例中,“每秒帖子數”和“每秒時間線寫入數”屬於吞吐量指標;“載入首頁時間線所需時間”或“帖子送達追隨者所需時間”屬於響應時間指標。
吞吐量和響應時間之間通常相關。線上服務的典型關係如 [圖 2-3](/tw/ch2#fig_throughput):低吞吐量時響應時間較低,負載升高後響應時間上升。原因是 *排隊*。請求到達高負載系統時CPU 往往已在處理前一個請求,新請求只能等待;當吞吐量逼近硬體上限,排隊延遲會急劇上升。
吞吐量和響應時間之間通常相關。線上服務的典型關係如 [圖 2-3](#fig_throughput):低吞吐量時響應時間較低,負載升高後響應時間上升。原因是 *排隊*。請求到達高負載系統時CPU 往往已在處理前一個請求,新請求只能等待;當吞吐量逼近硬體上限,排隊延遲會急劇上升。
{{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="圖 2-3. 隨著服務的吞吐量接近其容量,由於排隊,響應時間急劇增加。" class="w-full my-4" >}}
--------
<a id="sidebar_metastable"></a>
> [!TIP] 當過載系統無法恢復時
如果系統已接近過載、吞吐量逼近極限,有時會進入惡性迴圈:效率下降,進而更加過載。例如,請求佇列很長時,響應時間可能高到讓客戶端超時並重發請求,導致請求速率進一步上升,問題持續惡化,形成 *重試風暴*。即使負載後來回落,系統也可能仍卡在過載狀態,直到重啟或重置。這種現象叫 *亞穩態故障*Metastable Failure可能引發嚴重生產故障 [^7] [^8]。
@ -103,11 +105,11 @@ SELECT posts.*, users.* FROM posts
從效能指標角度看,使用者通常最關心響應時間;而吞吐量決定了所需計算資源(例如伺服器數量),從而決定承載特定工作負載的成本。如果吞吐量增長可能超過當前硬體上限,就必須擴容;若系統可透過增加計算資源顯著提升最大吞吐量,就稱其 *可伸縮*
本節主要討論響應時間;吞吐量與可伸縮性會在 ["可伸縮性"](/tw/ch2#sec_introduction_scalability) 一節再展開。
本節主要討論響應時間;吞吐量與可伸縮性會在 ["可伸縮性"](#sec_introduction_scalability) 一節再展開。
### 延遲與響應時間 {#id23}
“延遲”和“響應時間”有時會混用,但本書對它們有明確區分(見 [圖 2-4](/tw/ch2#fig_response_time)
“延遲”和“響應時間”有時會混用,但本書對它們有明確區分(見 [圖 2-4](#fig_response_time)
* *響應時間* 是客戶端看到的總時間,包含鏈路上各處產生的全部延遲。
* *服務時間* 是服務主動處理該請求的時間。
@ -116,7 +118,7 @@ SELECT posts.*, users.* FROM posts
{{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="圖 2-4. 響應時間、服務時間、網路延遲和排隊延遲。" class="w-full my-4" >}}
在 [圖 2-4](/tw/ch2#fig_response_time) 中,時間從左向右流動。每個通訊節點畫成一條水平線,請求/響應訊息畫成節點間的粗斜箭頭。本書後文會頻繁使用這種圖示風格。
在 [圖 2-4](#fig_response_time) 中,時間從左向右流動。每個通訊節點畫成一條水平線,請求/響應訊息畫成節點間的粗斜箭頭。本書後文會頻繁使用這種圖示風格。
即便反覆傳送同一個請求,響應時間也可能顯著波動。許多因素都會引入隨機延遲:例如切換到後臺程序、網路丟包與 TCP 重傳、垃圾回收暫停、缺頁導致的磁碟讀取、伺服器機架機械振動 [^17] 等。我們會在 ["超時與無界延遲"](/tw/ch9#sec_distributed_queueing) 進一步討論這個問題。
@ -124,7 +126,7 @@ SELECT posts.*, users.* FROM posts
### 平均值、中位數與百分位點 {#id24}
由於響應時間會隨請求變化,我們應將其看作一個可測量的 *分佈*,而非單一數字。在 [圖 2-5](/tw/ch2#fig_lognormal) 中,每個灰色柱表示一次請求,柱高是該請求耗時。大多數請求較快,但會有少量更慢的 *異常值*。網路時延波動也常稱為 *抖動*
由於響應時間會隨請求變化,我們應將其看作一個可測量的 *分佈*,而非單一數字。在 [圖 2-5](#fig_lognormal) 中,每個灰色柱表示一次請求,柱高是該請求耗時。大多數請求較快,但會有少量更慢的 *異常值*。網路時延波動也常稱為 *抖動*
{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="圖 2-5. 說明平均值和百分位點100 個服務請求的響應時間樣本。" class="w-full my-4" >}}
@ -132,7 +134,7 @@ SELECT posts.*, users.* FROM posts
通常,*百分位點* 更有意義。把響應時間從快到慢排序,*中位數* 位於中間。例如中位響應時間為 200 毫秒,表示一半請求在 200 毫秒內返回,另一半更慢。因此中位數適合衡量使用者“通常要等多久”。中位數也稱 *第 50 百分位*,常記為 *p50*
為了看清異常值有多糟,需要觀察更高百分位點:常見的是 *p95*、*p99*、*p999*。它們表示 95%、99%、99.9% 的請求都快於該閾值。例如 p95 為 1.5 秒,表示 100 個請求裡有 95 個小於 1.5 秒,另外 5 個不小於 1.5 秒。[圖 2-5](/tw/ch2#fig_lognormal) 展示了這一點。
為了看清異常值有多糟,需要觀察更高百分位點:常見的是 *p95*、*p99*、*p999*。它們表示 95%、99%、99.9% 的請求都快於該閾值。例如 p95 為 1.5 秒,表示 100 個請求裡有 95 個小於 1.5 秒,另外 5 個不小於 1.5 秒。[圖 2-5](#fig_lognormal) 展示了這一點。
響應時間的高百分位點(也叫 *尾部延遲*)非常重要,因為它直接影響使用者體驗。例如亞馬遜內部服務常以第 99.9 百分位設定響應要求,儘管它隻影響 1/1000 的請求。原因是最慢請求往往來自“賬戶資料最多”的客戶,他們通常也是最有價值客戶 [^19]。讓這批使用者也能獲得快速響應,對業務很關鍵。
@ -154,7 +156,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載
### 響應時間指標的應用 {#sec_introduction_slo_sla}
對於“一個終端請求會觸發多次後端呼叫”的服務,高百分位點尤其關鍵。即使並行呼叫,終端請求仍要等待最慢的那個返回。正如 [圖 2-6](/tw/ch2#fig_tail_amplification) 所示,只要一個呼叫慢,就能拖慢整個終端請求。即便慢呼叫比例很小,只要後端呼叫次數變多,撞上慢呼叫的機率就會上升,於是更大比例的終端請求會變慢(稱為 *尾部延遲放大* [^26])。
對於“一個終端請求會觸發多次後端呼叫”的服務,高百分位點尤其關鍵。即使並行呼叫,終端請求仍要等待最慢的那個返回。正如 [圖 2-6](#fig_tail_amplification) 所示,只要一個呼叫慢,就能拖慢整個終端請求。即便慢呼叫比例很小,只要後端呼叫次數變多,撞上慢呼叫的機率就會上升,於是更大比例的終端請求會變慢(稱為 *尾部延遲放大* [^26])。
{{< figure src="/fig/ddia_0206.png" id="fig_tail_amplification" caption="圖 2-6. 當需要幾個後端呼叫來服務請求時,只需要一個慢的後端請求就可以減慢整個終端使用者請求。" class="w-full my-4" >}}
@ -240,7 +242,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載
導致這類軟體故障的 bug 往往潛伏很久,直到一組不尋常條件把它觸發出來。這時才暴露出:軟體其實對執行環境做了某些假設,平時大多成立,但終有一天會因某種原因失效 [^68] [^69]。
軟體系統性故障沒有“速效藥”。但許多小措施都有效:認真審視系統假設與互動、充分測試、程序隔離、允許程序崩潰並重啟、避免反饋環路(如重試風暴,參見 ["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable)),以及在生產環境持續度量、監控和分析系統行為。
軟體系統性故障沒有“速效藥”。但許多小措施都有效:認真審視系統假設與互動、充分測試、程序隔離、允許程序崩潰並重啟、避免反饋環路(如重試風暴,參見 ["當過載系統無法恢復時"](#sidebar_metastable)),以及在生產環境持續度量、監控和分析系統行為。
### 人類與可靠性 {#id31}
@ -258,6 +260,8 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載
--------
<a id="sidebar_reliability_importance"></a>
> [!TIP] 可靠性有多重要?
可靠性不只適用於核電站或空管系統,普通應用同樣需要可靠。企業軟體中的 bug 會造成生產力損失(若報表錯誤還會帶來法律風險);電商網站故障則會帶來直接收入損失和品牌傷害。
@ -288,7 +292,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載
### 描述負載 {#id33}
首先要簡明描述系統當前負載之後才能討論“增長會怎樣”例如負載翻倍會發生什麼。最常見的是吞吐量指標每秒請求數、每天新增資料量GB、每小時購物車結賬次數等。有時你關心的是峰值變數比如 ["案例研究:社交網路首頁時間線"](/tw/ch2#sec_introduction_twitter) 裡的“同時線上使用者數”。
首先要簡明描述系統當前負載之後才能討論“增長會怎樣”例如負載翻倍會發生什麼。最常見的是吞吐量指標每秒請求數、每天新增資料量GB、每小時購物車結賬次數等。有時你關心的是峰值變數比如 ["案例研究:社交網路首頁時間線"](#sec_introduction_twitter) 裡的“同時線上使用者數”。
此外還可能有其他統計特徵會影響訪問模式,進而影響可伸縮性要求。例如資料庫讀寫比、快取命中率、每使用者資料項數量(如社交網路裡的追隨者數)。有時平均情況最關鍵,有時瓶頸由少數極端情況主導,具體取決於你的應用細節。
@ -297,7 +301,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載
* 以某種方式增大負載、但保持資源CPU、記憶體、網路頻寬等不變時效能如何變化
* 若負載按某種方式增長、但你希望效能不變,需要增加多少資源?
通常目標是:在儘量降低執行成本的同時,讓效能維持在 SLA 要求內(參見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。所需計算資源越多,成本越高。不同硬體的價效比不同,而且會隨著新硬體出現而變化。
通常目標是:在儘量降低執行成本的同時,讓效能維持在 SLA 要求內(參見 ["響應時間指標的應用"](#sec_introduction_slo_sla))。所需計算資源越多,成本越高。不同硬體的價效比不同,而且會隨著新硬體出現而變化。
如果資源翻倍後能承載兩倍負載且效能不變,這稱為 *線性可伸縮性*,通常是理想狀態。偶爾,藉助規模效應或峰值負載更均勻分佈,甚至可用不足兩倍資源處理兩倍負載 [^79] [^80]。但更常見的是成本增長快於線性,低效原因也很多。比如資料量增大後,即使請求大小相同,處理一次寫請求也可能比資料量小時更耗資源。

View file

@ -4,6 +4,8 @@ weight: 103
breadcrumbs: false
---
<a id="ch_datamodels"></a>
![](/map/ch02.png)
> *語言的邊界就是世界的邊界。*
@ -47,7 +49,7 @@ breadcrumbs: false
關係模型最初是一個理論提議,當時許多人懷疑它是否能夠高效實現。
然而,到 20 世紀 80 年代中期關係資料庫管理系統RDBMS和 SQL 已成為大多數需要儲存和查詢具有某種規則結構的資料的人的首選工具。
許多資料管理用例在幾十年後仍然由關係資料主導 —— 例如,商業分析(參見 ["星型與雪花型:分析模式"](/tw/ch3#sec_datamodels_analytics))。
許多資料管理用例在幾十年後仍然由關係資料主導 —— 例如,商業分析(參見 ["星型與雪花型:分析模式"](#sec_datamodels_analytics))。
多年來,出現了許多與資料儲存和查詢相關的競爭方法。在 20 世紀 70 年代和 80 年代初,**網狀模型** 和 **層次模型** 是主要的替代方案,但關係模型最終戰勝了它們。
物件資料庫在 20 世紀 80 年代末和 90 年代初出現又消失。XML 資料庫在 21 世紀初出現,但只獲得了小眾的採用。
@ -94,13 +96,13 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將
#### 用於一對多關係的文件資料模型 {#the-document-data-model-for-one-to-many-relationships}
並非所有資料都很適合關係表示;讓我們透過一個例子來探討關係模型的侷限性。[圖 3-1](/tw/ch3#fig_obama_relational) 說明了如何在關係模式中表達簡歷LinkedIn 個人資料)。整個個人資料可以透過唯一識別符號 `user_id` 來識別。像 `first_name``last_name` 這樣的欄位每個使用者只出現一次,因此它們可以建模為 `users` 表上的列。
並非所有資料都很適合關係表示;讓我們透過一個例子來探討關係模型的侷限性。[圖 3-1](#fig_obama_relational) 說明了如何在關係模式中表達簡歷LinkedIn 個人資料)。整個個人資料可以透過唯一識別符號 `user_id` 來識別。像 `first_name``last_name` 這樣的欄位每個使用者只出現一次,因此它們可以建模為 `users` 表上的列。
大多數人在職業生涯中有多份工作(職位),人們可能有不同數量的教育經歷和任意數量的聯絡資訊。表示這種 *一對多關係* 的一種方法是將職位、教育和聯絡資訊放在單獨的表中,並使用外部索引鍵引用 `users` 表,如 [圖 3-1](/tw/ch3#fig_obama_relational) 所示。
大多數人在職業生涯中有多份工作(職位),人們可能有不同數量的教育經歷和任意數量的聯絡資訊。表示這種 *一對多關係* 的一種方法是將職位、教育和聯絡資訊放在單獨的表中,並使用外部索引鍵引用 `users` 表,如 [圖 3-1](#fig_obama_relational) 所示。
{{< figure src="/fig/ddia_0301.png" id="fig_obama_relational" caption="圖 3-1. 使用關係模式表示 LinkedIn 個人資料。" class="w-full my-4" >}}
另一種表示相同資訊的方式,可能更自然並且更接近應用程式程式碼中的物件結構,是作為 JSON 文件,如 [示例 3-1](/tw/ch3#fig_obama_json) 所示。
另一種表示相同資訊的方式,可能更自然並且更接近應用程式程式碼中的物件結構,是作為 JSON 文件,如 [示例 3-1](#fig_obama_json) 所示。
{{< figure id="fig_obama_json" title="示例 3-1. 將 LinkedIn 個人資料表示為 JSON 文件" class="w-full my-4" >}}
@ -127,24 +129,24 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將
}
```
一些開發人員認為 JSON 模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。然而,正如我們將在 [第 5 章](/tw/ch5#ch_encoding) 中看到的JSON 作為資料編碼格式也存在問題。缺乏模式通常被認為是一個優勢;我們將在 ["文件模型中的模式靈活性"](/tw/ch3#sec_datamodels_schema_flexibility) 中討論這個問題。
一些開發人員認為 JSON 模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。然而,正如我們將在 [第 5 章](/tw/ch5#ch_encoding) 中看到的JSON 作為資料編碼格式也存在問題。缺乏模式通常被認為是一個優勢;我們將在 ["文件模型中的模式靈活性"](#sec_datamodels_schema_flexibility) 中討論這個問題。
與 [圖 3-1](/tw/ch3#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 *區域性*(參見 ["讀寫的資料區域性"](/tw/ch3#sec_datamodels_document_locality))。如果你想在關係示例中獲取個人資料,你需要執行多個查詢(透過 `user_id` 查詢每個表)或在 `users` 表與其從屬表之間執行複雜的多表連線 [^8]。在 JSON 表示中,所有相關資訊都在一個地方,使查詢既更快又更簡單。
與 [圖 3-1](#fig_obama_relational) 中的多表模式相比JSON 表示具有更好的 *區域性*(參見 ["讀寫的資料區域性"](#sec_datamodels_document_locality))。如果你想在關係示例中獲取個人資料,你需要執行多個查詢(透過 `user_id` 查詢每個表)或在 `users` 表與其從屬表之間執行複雜的多表連線 [^8]。在 JSON 表示中,所有相關資訊都在一個地方,使查詢既更快又更簡單。
從使用者個人資料到使用者職位、教育歷史和聯絡資訊的一對多關係暗示了資料中的樹形結構,而 JSON 表示使這種樹形結構變得明確(見 [圖 3-2](/tw/ch3#fig_json_tree))。
從使用者個人資料到使用者職位、教育歷史和聯絡資訊的一對多關係暗示了資料中的樹形結構,而 JSON 表示使這種樹形結構變得明確(見 [圖 3-2](#fig_json_tree))。
{{< figure src="/fig/ddia_0302.png" id="fig_json_tree" caption="圖 3-2. 一對多關係形成樹狀結構。" class="w-full my-4" >}}
--------
> [!NOTE]
> 這種型別的關係有時被稱為 *一對少* 而不是 *一對多*,因為簡歷通常有少量的職位 [^9] [^10]。在可能存在真正大量相關專案的情況下 —— 比如名人社交媒體帖子上的評論,可能有成千上萬條 —— 將它們全部嵌入同一個文件中可能太笨拙了,因此 [圖 3-1](/tw/ch3#fig_obama_relational) 中的關係方法更可取。
> 這種型別的關係有時被稱為 *一對少* 而不是 *一對多*,因為簡歷通常有少量的職位 [^9] [^10]。在可能存在真正大量相關專案的情況下 —— 比如名人社交媒體帖子上的評論,可能有成千上萬條 —— 將它們全部嵌入同一個文件中可能太笨拙了,因此 [圖 3-1](#fig_obama_relational) 中的關係方法更可取。
--------
### 正規化、反正規化與連線 {#sec_datamodels_normalization}
在前一節的 [示例 3-1](/tw/ch3#fig_obama_json) 中,`region_id` 被給出為 ID而不是純文字字串 `"Washington, DC, United States"`。為什麼?
在前一節的 [示例 3-1](#fig_obama_json) 中,`region_id` 被給出為 ID而不是純文字字串 `"Washington, DC, United States"`。為什麼?
如果使用者介面有一個用於輸入地區的自由文字欄位,將其儲存為純文字字串是有意義的。但是,擁有標準化的地理區域列表並讓使用者從下拉列表或自動補全中選擇也有其優勢:
@ -221,13 +223,13 @@ SELECT posts.id, posts.sender_id
### 多對一與多對多關係 {#sec_datamodels_many_to_many}
雖然 [圖 3-1](/tw/ch3#fig_obama_relational) 中的 `positions``education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),但 `region_id` 欄位是 *多對一* 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。
雖然 [圖 3-1](#fig_obama_relational) 中的 `positions``education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),但 `region_id` 欄位是 *多對一* 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。
如果我們為組織和學校引入實體,並透過 ID 從簡歷中引用它們,那麼我們也有 *多對多* 關係(一個人曾為多個組織工作,一個組織有多個過去或現在的員工)。在關係模型中,這種關係通常表示為 *關聯表**連線表*,如 [圖 3-3](/tw/ch3#fig_datamodels_m2m_rel) 所示:每個職位將一個使用者 ID 與一個組織 ID 關聯起來。
如果我們為組織和學校引入實體,並透過 ID 從簡歷中引用它們,那麼我們也有 *多對多* 關係(一個人曾為多個組織工作,一個組織有多個過去或現在的員工)。在關係模型中,這種關係通常表示為 *關聯表**連線表*,如 [圖 3-3](#fig_datamodels_m2m_rel) 所示:每個職位將一個使用者 ID 與一個組織 ID 關聯起來。
{{< figure src="/fig/ddia_0303.png" id="fig_datamodels_m2m_rel" caption="圖 3-3. 關係模型中的多對多關係。" class="w-full my-4" >}}
多對一和多對多關係不容易適應一個自包含的 JSON 文件;它們更適合正規化表示。在文件模型中,一種可能的表示如 [示例 3-2](/tw/ch3#fig_datamodels_m2m_json) 所示,並在 [圖 3-4](/tw/ch3#fig_datamodels_many_to_many) 中說明:每個虛線矩形內的資料可以分組到一個文件中,但到組織和學校的連結最好表示為對其他文件的引用。
多對一和多對多關係不容易適應一個自包含的 JSON 文件;它們更適合正規化表示。在文件模型中,一種可能的表示如 [示例 3-2](#fig_datamodels_m2m_json) 所示,並在 [圖 3-4](#fig_datamodels_many_to_many) 中說明:每個虛線矩形內的資料可以分組到一個文件中,但到組織和學校的連結最好表示為對其他文件的引用。
{{< figure id="fig_datamodels_m2m_json" title="示例 3-2. 透過 ID 引用組織的簡歷。" class="w-full my-4" >}}
@ -248,15 +250,15 @@ SELECT posts.id, posts.sender_id
多對多關係通常需要"雙向"查詢:例如,找到特定人員工作過的所有組織,以及找到在特定組織工作過的所有人員。啟用此類查詢的一種方法是在兩邊都儲存 ID 引用,即簡歷包含該人工作過的每個組織的 ID組織文件包含提到該組織的簡歷的 ID。這種表示是反正規化的因為關係儲存在兩個地方可能會相互不一致。
正規化表示僅在一個地方儲存關係,並依賴 *二級索引*(我們將在 [第 4 章](/tw/ch4#ch_storage) 中討論)來允許有效地雙向查詢關係。在 [圖 3-3](/tw/ch3#fig_datamodels_m2m_rel) 的關係模式中,我們會告訴資料庫在 `positions` 表的 `user_id``org_id` 列上建立索引。
正規化表示僅在一個地方儲存關係,並依賴 *二級索引*(我們將在 [第 4 章](/tw/ch4#ch_storage) 中討論)來允許有效地雙向查詢關係。在 [圖 3-3](#fig_datamodels_m2m_rel) 的關係模式中,我們會告訴資料庫在 `positions` 表的 `user_id``org_id` 列上建立索引。
在 [示例 3-2](/tw/ch3#fig_datamodels_m2m_json) 的文件模型中,資料庫需要索引 `positions` 陣列內物件的 `org_id` 欄位。許多文件資料庫和具有 JSON 支援的關係資料庫能夠在文件內的值上建立此類索引。
在 [示例 3-2](#fig_datamodels_m2m_json) 的文件模型中,資料庫需要索引 `positions` 陣列內物件的 `org_id` 欄位。許多文件資料庫和具有 JSON 支援的關係資料庫能夠在文件內的值上建立此類索引。
### 星型與雪花型:分析模式 {#sec_datamodels_analytics}
資料倉庫(參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))通常是關係型的,並且資料倉庫中表結構有一些廣泛使用的約定:*星型模式*、*雪花模式*、*維度建模* [^12],以及 *一張大表*OBT。這些結構針對業務分析師的需求進行了最佳化。ETL 過程將來自運營系統的資料轉換為此模式。
[圖 3-5](/tw/ch3#fig_dwh_schema) 顯示了一個可能在雜貨零售商的資料倉庫中找到的星型模式示例。模式的中心是所謂的 *事實表*(在此示例中,它稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買產品)。如果我們分析的是網站流量而不是零售銷售,每一行可能代表使用者的頁面檢視或點選。
[圖 3-5](#fig_dwh_schema) 顯示了一個可能在雜貨零售商的資料倉庫中找到的星型模式示例。模式的中心是所謂的 *事實表*(在此示例中,它稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買產品)。如果我們分析的是網站流量而不是零售銷售,每一行可能代表使用者的頁面檢視或點選。
{{< figure src="/fig/ddia_0305.png" id="fig_dwh_schema" caption="圖 3-5. 用於資料倉庫的星型模式示例。" class="w-full my-4" >}}
@ -264,11 +266,11 @@ SELECT posts.id, posts.sender_id
事實表中的一些列是屬性,例如產品售出的價格和從供應商那裡購買它的成本(允許計算利潤率)。事實表中的其他列是對其他表的外部索引鍵引用,稱為 *維度表*。由於事實表中的每一行代表一個事件,維度代表事件的 *誰*、*什麼*、*哪裡*、*何時*、*如何* 和 *為什麼*
例如,在 [圖 3-5](/tw/ch3#fig_dwh_schema) 中,其中一個維度是售出的產品。`dim_product` 表中的每一行代表一種待售產品型別包括其庫存單位SKU、描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表中的每一行使用外部索引鍵來指示在該特定交易中售出了哪種產品。查詢通常涉及對多個維度表的多個連線。
例如,在 [圖 3-5](#fig_dwh_schema) 中,其中一個維度是售出的產品。`dim_product` 表中的每一行代表一種待售產品型別包括其庫存單位SKU、描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表中的每一行使用外部索引鍵來指示在該特定交易中售出了哪種產品。查詢通常涉及對多個維度表的多個連線。
即使日期和時間也經常使用維度表表示,因為這允許編碼有關日期的附加資訊(例如公共假期),允許查詢區分假期和非假期的銷售。
[圖 3-5](/tw/ch3#fig_dwh_schema) 是星型模式的一個例子。該名稱來自這樣一個事實:當表關係被視覺化時,事實表位於中間,被其維度表包圍;到這些表的連線就像星星的光芒。
[圖 3-5](#fig_dwh_schema) 是星型模式的一個例子。該名稱來自這樣一個事實:當表關係被視覺化時,事實表位於中間,被其維度表包圍;到這些表的連線就像星星的光芒。
這個模板的一個變體被稱為 *雪花模式*,其中維度被進一步分解為子維度。例如,品牌和產品類別可能有單獨的表,`dim_product` 表中的每一行都可以將品牌和類別作為外部索引鍵引用,而不是將它們作為字串儲存在 `dim_product` 表中。雪花模式比星型模式更正規化,但星型模式通常更受歡迎,因為它們對分析師來說更簡單 [^12]。
@ -284,7 +286,7 @@ SELECT posts.id, posts.sender_id
文件資料模型的主要論點是模式靈活性、由於區域性而獲得更好的效能,以及對於某些應用程式來說,它更接近應用程式使用的物件模型。關係模型透過為連線、多對一和多對多關係提供更好的支援來反擊。讓我們更詳細地研究這些論點。
如果你的應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 *切碎*shredding為多個表的關係技術如 [圖 3-1](/tw/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要複雜的應用程式程式碼。
如果你的應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 *切碎*shredding為多個表的關係技術如 [圖 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要複雜的應用程式程式碼。
文件模型有侷限性:例如,你不能直接引用文件中的巢狀項,而是需要說類似"使用者 251 的職位列表中的第二項"之類的話。如果你確實需要引用巢狀項,關係方法效果更好,因為你可以透過其 ID 直接引用任何項。
@ -328,7 +330,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
#### 讀寫的資料區域性 {#sec_datamodels_document_locality}
文件通常儲存為單個連續字串,編碼為 JSON、XML 或二進位制變體(如 MongoDB 的 BSON。如果你的應用程式經常需要訪問整個文件例如在網頁上渲染它則這種 *儲存區域性* 具有效能優勢。如果資料分佈在多個表中,如 [圖 3-1](/tw/ch3#fig_obama_relational) 所示,則需要多次索引查詢才能檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。
文件通常儲存為單個連續字串,編碼為 JSON、XML 或二進位制變體(如 MongoDB 的 BSON。如果你的應用程式經常需要訪問整個文件例如在網頁上渲染它則這種 *儲存區域性* 具有效能優勢。如果資料分佈在多個表中,如 [圖 3-1](#fig_obama_relational) 所示,則需要多次索引查詢才能檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。
區域性優勢僅在你同時需要文件的大部分時才適用。資料庫通常需要載入整個文件,如果你只需要訪問大文件的一小部分,這可能會浪費。在文件更新時,通常需要重寫整個文件。由於這些原因,通常建議你保持文件相當小,並避免頻繁對文件進行小的更新。
@ -340,7 +342,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
XML 資料庫通常使用 XQuery 和 XPath 查詢,它們旨在允許複雜的查詢,包括跨多個文件的連線,並將其結果格式化為 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 為 JSON 提供了等效於 XPath 的功能。
MongoDB 的聚合管道,我們在 ["正規化、反正規化與連線"](/tw/ch3#sec_datamodels_normalization) 中看到了其用於連線的 `$lookup` 運算元,是 JSON 文件集合查詢語言的一個例子。
MongoDB 的聚合管道,我們在 ["正規化、反正規化與連線"](#sec_datamodels_normalization) 中看到了其用於連線的 `$lookup` 運算元,是 JSON 文件集合查詢語言的一個例子。
讓我們看另一個例子來感受這種語言 —— 這次是聚合,這對分析特別需要。想象你是一名海洋生物學家,每次你在海洋中看到動物時,你都會向資料庫新增一條觀察記錄。現在你想生成一份報告,說明你每個月看到了多少條鯊魚。在 PostgreSQL 中,你可能會這樣表達該查詢:
@ -404,7 +406,7 @@ db.observations.aggregate([
眾所周知的演算法可以在這些圖上執行例如地圖導航應用程式搜尋道路網路中兩點之間的最短路徑PageRank 可用於網頁圖以確定網頁的受歡迎程度,從而確定其在搜尋結果中的排名 [^32]。
圖可以用幾種不同的方式表示。在 *鄰接表* 模型中,每個頂點儲存其相距一條邊的鄰居頂點的 ID。或者你可以使用 *鄰接矩陣*,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接表適合圖遍歷,矩陣適合機器學習(參見 ["資料框、矩陣與陣列"](/tw/ch3#sec_datamodels_dataframes))。
圖可以用幾種不同的方式表示。在 *鄰接表* 模型中,每個頂點儲存其相距一條邊的鄰居頂點的 ID。或者你可以使用 *鄰接矩陣*,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接表適合圖遍歷,矩陣適合機器學習(參見 ["資料框、矩陣與陣列"](#sec_datamodels_dataframes))。
在剛才給出的示例中,圖中的所有頂點都表示相同型別的事物(分別是人、網頁或道路交叉點)。然而,圖不限於這種 *同質* 資料:圖的一個同樣強大的用途是提供一種一致的方式在單個數據庫中儲存完全不同型別的物件。例如:
@ -415,7 +417,7 @@ db.observations.aggregate([
我們還將檢視圖的四種查詢語言Cypher、SPARQL、Datalog 和 GraphQL以及用於查詢圖的 SQL 支援。還存在其他圖查詢語言,如 Gremlin [^37],但這些將為我們提供代表性的概述。
為了說明這些不同的語言和模型,本節使用 [圖 3-6](/tw/ch3#fig_datamodels_graph) 中顯示的圖作為執行示例。它可能取自社交網路或家譜資料庫:它顯示了兩個人,來自愛達荷州的 Lucy 和來自法國聖洛的 Alain。他們已婚並住在倫敦。每個人和每個位置都表示為頂點它們之間的關係表示為邊。此示例將幫助演示一些在圖資料庫中很容易但在其他模型中很困難的查詢。
為了說明這些不同的語言和模型,本節使用 [圖 3-6](#fig_datamodels_graph) 中顯示的圖作為執行示例。它可能取自社交網路或家譜資料庫:它顯示了兩個人,來自愛達荷州的 Lucy 和來自法國聖洛的 Alain。他們已婚並住在倫敦。每個人和每個位置都表示為頂點它們之間的關係表示為邊。此示例將幫助演示一些在圖資料庫中很容易但在其他模型中很困難的查詢。
{{< figure src="/fig/ddia_0306.png" id="fig_datamodels_graph" caption="圖 3-6. 圖結構資料示例(框表示頂點,箭頭表示邊)。" class="w-full my-4" >}}
@ -437,7 +439,7 @@ db.observations.aggregate([
* 描述兩個頂點之間關係型別的標籤
* 屬性集合(鍵值對)
你可以將圖儲存視為由兩個關係表組成,一個用於頂點,一個用於邊,如 [示例 3-3](/tw/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 資料型別來儲存每個頂點或邊的屬性)。每條邊都儲存頭頂點和尾頂點;如果你想要頂點的入邊或出邊集,可以分別透過 `head_vertex``tail_vertex` 查詢 `edges` 表。
你可以將圖儲存視為由兩個關係表組成,一個用於頂點,一個用於邊,如 [示例 3-3](#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 資料型別來儲存每個頂點或邊的屬性)。每條邊都儲存頭頂點和尾頂點;如果你想要頂點的入邊或出邊集,可以分別透過 `head_vertex``tail_vertex` 查詢 `edges` 表。
{{< figure id="fig_graph_sql_schema" title="示例 3-3. 使用關係模式表示屬性圖" class="w-full my-4" >}}
@ -463,10 +465,10 @@ CREATE INDEX edges_heads ON edges (head_vertex);
此模型的一些重要方面是:
1. 任何頂點都可以有一條邊將其與任何其他頂點連線。沒有限制哪些型別的事物可以或不能關聯的模式。
2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 *遍歷* 圖 —— 即透過頂點鏈跟隨路徑 —— 向前和向後。(這就是為什麼 [示例 3-3](/tw/ch3#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 *遍歷* 圖 —— 即透過頂點鏈跟隨路徑 —— 向前和向後。(這就是為什麼 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex``head_vertex` 列上都有索引。)
3. 透過對不同型別的頂點和關係使用不同的標籤,你可以在單個圖中儲存幾種不同型別的資訊,同時仍保持簡潔的資料模型。
邊表就像我們在 ["多對一與多對多關係"](/tw/ch3#sec_datamodels_many_to_many) 中看到的多對多關聯表/連線表,泛化為允許在同一表中儲存許多不同型別的關係。標籤和屬性上也可能有索引,允許有效地找到具有某些屬性的頂點或邊。
邊表就像我們在 ["多對一與多對多關係"](#sec_datamodels_many_to_many) 中看到的多對多關聯表/連線表,泛化為允許在同一表中儲存許多不同型別的關係。標籤和屬性上也可能有索引,允許有效地找到具有某些屬性的頂點或邊。
--------
@ -475,7 +477,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
--------
這些功能為資料建模提供了極大的靈活性,如 [圖 3-6](/tw/ch3#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的內容,例如不同國家的不同區域結構(法國有 *省**大區*,而美國有 *縣**州*、歷史的怪癖如國中之國暫時忽略主權國家和民族的複雜性以及不同粒度的資料Lucy 的當前居住地指定為城市,而她的出生地僅在州級別指定)。
這些功能為資料建模提供了極大的靈活性,如 [圖 3-6](#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的內容,例如不同國家的不同區域結構(法國有 *省**大區*,而美國有 *縣**州*、歷史的怪癖如國中之國暫時忽略主權國家和民族的複雜性以及不同粒度的資料Lucy 的當前居住地指定為城市,而她的出生地僅在州級別指定)。
你可以想象擴充套件圖以包括有關 Lucy 和 Alain 或其他人的許多其他事實。例如,你可以使用它來指示他們有哪些食物過敏(透過為每個過敏原引入一個頂點,並在人和過敏原之間設定邊以指示過敏),並將過敏原與顯示哪些食物含有哪些物質的一組頂點連結。然後你可以編寫查詢來找出每個人可以安全食用的食物。圖適合可演化性:隨著你嚮應用程式新增功能,圖可以輕鬆擴充套件以適應應用程式資料結構的變化。
@ -483,7 +485,7 @@ CREATE INDEX edges_heads ON edges (head_vertex);
*Cypher* 是用於屬性圖的查詢語言,最初為 Neo4j 圖資料庫建立,後來作為 *openCypher* 發展為開放標準 [^38]。除了 Neo4jCypher 還得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE在 PostgreSQL 中儲存)等的支援。它以電影《駭客帝國》中的角色命名,與密碼學中的密碼無關 [^39]。
[示例 3-4](/tw/ch3#fig_cypher_create) 顯示了將 [圖 3-6](/tw/ch3#fig_datamodels_graph) 的左側部分插入圖資料庫的 Cypher 查詢。圖的其餘部分可以類似地新增。每個頂點都被賦予一個符號名稱,如 `usa``idaho`。該名稱不儲存在資料庫中,僅在查詢內部使用以在頂點之間建立邊,使用箭頭符號:`(idaho) -[:WITHIN]-> (usa)` 建立一條標記為 `WITHIN` 的邊,其中 `idaho` 作為尾節點,`usa` 作為頭節點。
[示例 3-4](#fig_cypher_create) 顯示了將 [圖 3-6](#fig_datamodels_graph) 的左側部分插入圖資料庫的 Cypher 查詢。圖的其餘部分可以類似地新增。每個頂點都被賦予一個符號名稱,如 `usa``idaho`。該名稱不儲存在資料庫中,僅在查詢內部使用以在頂點之間建立邊,使用箭頭符號:`(idaho) -[:WITHIN]-> (usa)` 建立一條標記為 `WITHIN` 的邊,其中 `idaho` 作為尾節點,`usa` 作為頭節點。
{{< figure link="#fig_datamodels_graph" id="fig_cypher_create" title="示例 3-4. 圖 3-6 中資料的子集,表示為 Cypher 查詢" class="w-full my-4" >}}
@ -497,9 +499,9 @@ CREATE
(lucy) -[:BORN_IN]-> (idaho)
```
當 [圖 3-6](/tw/ch3#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,*查詢所有從美國移民到歐洲的人的姓名*。也就是說,找到所有具有指向美國境內位置的 `BORN_IN` 邊,以及指向歐洲境內位置的 `LIVING_IN` 邊的頂點,並返回每個頂點的 `name` 屬性。
當 [圖 3-6](#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,*查詢所有從美國移民到歐洲的人的姓名*。也就是說,找到所有具有指向美國境內位置的 `BORN_IN` 邊,以及指向歐洲境內位置的 `LIVING_IN` 邊的頂點,並返回每個頂點的 `name` 屬性。
[示例 3-5](/tw/ch3#fig_cypher_query) 顯示了如何在 Cypher 中表達該查詢。相同的箭頭符號用於 `MATCH` 子句中以在圖中查詢模式:`(person) -[:BORN_IN]-> ()` 匹配由標記為 `BORN_IN` 的邊相關的任意兩個頂點。該邊的尾頂點繫結到變數 `person`,頭頂點未命名。
[示例 3-5](#fig_cypher_query) 顯示了如何在 Cypher 中表達該查詢。相同的箭頭符號用於 `MATCH` 子句中以在圖中查詢模式:`(person) -[:BORN_IN]-> ()` 匹配由標記為 `BORN_IN` 的邊相關的任意兩個頂點。該邊的尾頂點繫結到變數 `person`,頭頂點未命名。
{{< figure id="fig_cypher_query" title="示例 3-5. Cypher 查詢查詢從美國移民到歐洲的人" class="w-full my-4" >}}
@ -525,7 +527,7 @@ RETURN person.name
### SQL 中的圖查詢 {#id58}
[示例 3-3](/tw/ch3#fig_graph_sql_schema) 建議圖資料可以在關係資料庫中表示。但如果我們將圖資料放入關係結構中,我們還能使用 SQL 查詢它嗎?
[示例 3-3](#fig_graph_sql_schema) 建議圖資料可以在關係資料庫中表示。但如果我們將圖資料放入關係結構中,我們還能使用 SQL 查詢它嗎?
答案是肯定的,但有一些困難。你在圖查詢中遍歷的每條邊實際上都是與 `edges` 表的連線。在關係資料庫中,你通常事先知道查詢中需要哪些連線。另一方面,在圖查詢中,你可能需要遍歷可變數量的邊才能找到你要查詢的頂點 —— 也就是說,連線的數量不是預先固定的。
@ -533,7 +535,7 @@ RETURN person.name
在 Cypher 中,`:WITHIN*0..` 非常簡潔地表達了這個事實:它意味著"跟隨 `WITHIN` 邊,零次或多次"。它就像正則表示式中的 `*` 運算元。
自 SQL:1999 以來,查詢中可變長度遍歷路徑的想法可以使用稱為 *遞迴公用表表達式*`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](/tw/ch3#fig_graph_sql_query) 顯示了相同的查詢 —— 查詢從美國移民到歐洲的人的姓名 —— 使用此技術在 SQL 中表達。然而,與 Cypher 相比,語法非常笨拙。
自 SQL:1999 以來,查詢中可變長度遍歷路徑的想法可以使用稱為 *遞迴公用表表達式*`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](#fig_graph_sql_query) 顯示了相同的查詢 —— 查詢從美國移民到歐洲的人的姓名 —— 使用此技術在 SQL 中表達。然而,與 Cypher 相比,語法非常笨拙。
{{< figure link="#fig_cypher_query" id="fig_graph_sql_query" title="示例 3-6. 與 示例 3-5 相同的查詢,使用遞迴公用表表達式在 SQL 中編寫" class="w-full my-4" >}}
@ -607,13 +609,13 @@ Oracle 對遞迴查詢有不同的 SQL 擴充套件,它稱之為 *層次* [^41
三元組的主語等同於圖中的頂點。賓語是兩種東西之一:
1. 原始資料型別的值,如字串或數字。在這種情況下,三元組的謂語和賓語等同於主語頂點上屬性的鍵和值。使用 [圖 3-6](/tw/ch3#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一個頂點 `lucy`,其屬性為 `{"birthYear": 1989}`
1. 原始資料型別的值,如字串或數字。在這種情況下,三元組的謂語和賓語等同於主語頂點上屬性的鍵和值。使用 [圖 3-6](#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一個頂點 `lucy`,其屬性為 `{"birthYear": 1989}`
2. 圖中的另一個頂點。在這種情況下,謂語是圖中的邊,主語是尾頂點,賓語是頭頂點。例如,在(*lucy*、*marriedTo*、*alain*)中,主語和賓語 *lucy**alain* 都是頂點,謂語 *marriedTo* 是連線它們的邊的標籤。
> [!NOTE]
> 準確地說提供類似三元組資料模型的資料庫通常需要在每個元組上儲存一些額外的元資料。例如AWS Neptune 使用四元組4-tuples透過向每個三元組新增圖 ID [^46]Datomic 使用 5 元組,用事務 ID 和一個表示刪除的布林值擴充套件每個三元組 [^47]。由於這些資料庫保留了上面解釋的基本 *主語-謂語-賓語* 結構,本書仍然稱它們為三元組儲存。
[示例 3-7](/tw/ch3#fig_graph_n3_triples) 顯示了與 [示例 3-4](/tw/ch3#fig_cypher_create) 中相同的資料,以稱為 *Turtle* 的格式編寫為三元組,它是 *Notation3**N3*)的子集 [^48]。
[示例 3-7](#fig_graph_n3_triples) 顯示了與 [示例 3-4](#fig_cypher_create) 中相同的資料,以稱為 *Turtle* 的格式編寫為三元組,它是 *Notation3**N3*)的子集 [^48]。
{{< figure link="#fig_datamodels_graph" id="fig_graph_n3_triples" title="示例 3-7. 圖 3-6 中資料的子集,表示為 Turtle 三元組" class="w-full my-4" >}}
@ -637,7 +639,7 @@ _:namerica :type "continent".
在此示例中,圖的頂點寫為 `_:someName`。該名稱在此檔案之外沒有任何意義;它的存在只是因為否則我們不知道哪些三元組引用同一個頂點。當謂語表示邊時,賓語是頂點,如 `_:idaho :within _:usa`。當謂語是屬性時,賓語是字串字面量,如 `_:usa :name "United States"`
一遍又一遍地重複相同的主語相當重複,但幸運的是,你可以使用分號來表達關於同一主語的多個內容。這使得 Turtle 格式非常易讀:見 [示例 3-8](/tw/ch3#fig_graph_n3_shorthand)。
一遍又一遍地重複相同的主語相當重複,但幸運的是,你可以使用分號來表達關於同一主語的多個內容。這使得 Turtle 格式非常易讀:見 [示例 3-8](#fig_graph_n3_shorthand)。
{{< figure link="#fig_graph_n3_triples" id="fig_graph_n3_shorthand" title="示例 3-8. 編寫 示例 3-7 中資料的更簡潔方式" class="w-full my-4" >}}
@ -661,7 +663,7 @@ _:namerica a :Location; :name "North America"; :type "continent".
#### RDF 資料模型 {#the-rdf-data-model}
我們在 [示例 3-8](/tw/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 語言實際上是在 *資源描述框架*RDF[^55] 中編碼資料的一種方式這是為語義網設計的資料模型。RDF 資料也可以用其他方式編碼,例如(更冗長地)用 XML如 [示例 3-9](/tw/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 這樣的工具可以在不同的 RDF 編碼之間自動轉換。
我們在 [示例 3-8](#fig_graph_n3_shorthand) 中使用的 Turtle 語言實際上是在 *資源描述框架*RDF[^55] 中編碼資料的一種方式這是為語義網設計的資料模型。RDF 資料也可以用其他方式編碼,例如(更冗長地)用 XML如 [示例 3-9](#fig_graph_rdf_xml) 所示。像 Apache Jena 這樣的工具可以在不同的 RDF 編碼之間自動轉換。
{{< figure link="#fig_graph_n3_shorthand" id="fig_graph_rdf_xml" title="示例 3-9. 示例 3-8 的資料,使用 RDF/XML 語法表示" class="w-full my-4" >}}
@ -701,9 +703,9 @@ URL `<http://my-company.com/namespace>` 不一定需要解析為任何內容 —
*SPARQL* 是使用 RDF 資料模型的三元組儲存的查詢語言 [^56]。(它是 *SPARQL Protocol and RDF Query Language* 的首字母縮略詞,發音為 "sparkle"。)它早於 Cypher由於 Cypher 的模式匹配是從 SPARQL 借用的,它們看起來非常相似。
與之前相同的查詢 —— 查詢從美國搬到歐洲的人 —— 在 SPARQL 中與在 Cypher 中一樣簡潔(見 [示例 3-10](/tw/ch3#fig_sparql_query))。
與之前相同的查詢 —— 查詢從美國搬到歐洲的人 —— 在 SPARQL 中與在 Cypher 中一樣簡潔(見 [示例 3-10](#fig_sparql_query))。
{{< figure id="fig_sparql_query" title="示例 3-10. 與 [示例 3-5](/tw/ch3#fig_cypher_query) 相同的查詢,用 SPARQL 表示" class="w-full my-4" >}}
{{< figure id="fig_sparql_query" title="示例 3-10. 與 [示例 3-5](#fig_cypher_query) 相同的查詢,用 SPARQL 表示" class="w-full my-4" >}}
```
PREFIX : <urn:example:>
@ -741,9 +743,9 @@ Datalog 實際上基於關係資料模型,而不是圖,但它出現在本書
Datalog 資料庫的內容由 *事實* 組成,每個事實對應於關係表中的一行。例如,假設我們有一個包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美國是一個國家的事實可以寫成 `location(2, "United States", "country")`,其中 `2` 是美國的 ID。一般來說語句 `table(val1, val2, …​)` 意味著 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此類推。
[示例 3-11](/tw/ch3#fig_datalog_triples) 顯示了如何在 Datalog 中編寫 [圖 3-6](/tw/ch3#fig_datamodels_graph) 左側的資料。圖的邊(`within`、`born_in` 和 `lives_in`表示為兩列連線表。例如Lucy 的 ID 是 100愛達荷州的 ID 是 3所以關係"Lucy 出生在愛達荷州"表示為 `born_in(100, 3)`
[示例 3-11](#fig_datalog_triples) 顯示了如何在 Datalog 中編寫 [圖 3-6](#fig_datamodels_graph) 左側的資料。圖的邊(`within`、`born_in` 和 `lives_in`表示為兩列連線表。例如Lucy 的 ID 是 100愛達荷州的 ID 是 3所以關係"Lucy 出生在愛達荷州"表示為 `born_in(100, 3)`
{{< figure id="fig_datalog_triples" title="示例 3-11. [圖 3-6](/tw/ch3#fig_datamodels_graph) 中資料的子集,表示為 Datalog 事實" class="w-full my-4" >}}
{{< figure id="fig_datalog_triples" title="示例 3-11. [圖 3-6](#fig_datamodels_graph) 中資料的子集,表示為 Datalog 事實" class="w-full my-4" >}}
```
location(1, "North America", "continent").
@ -757,9 +759,9 @@ person(100, "Lucy").
born_in(100, 3). /* Lucy 出生在愛達荷州 */
```
現在我們已經定義了資料,我們可以編寫與之前相同的查詢,如 [示例 3-12](/tw/ch3#fig_datalog_query) 所示。它看起來與 Cypher 或 SPARQL 中的等效查詢有點不同但不要讓這嚇倒你。Datalog 是 Prolog 的子集,這是一種程式語言,如果你學過計算機科學,你可能見過它。
現在我們已經定義了資料,我們可以編寫與之前相同的查詢,如 [示例 3-12](#fig_datalog_query) 所示。它看起來與 Cypher 或 SPARQL 中的等效查詢有點不同但不要讓這嚇倒你。Datalog 是 Prolog 的子集,這是一種程式語言,如果你學過計算機科學,你可能見過它。
{{< figure id="fig_datalog_query" title="示例 3-12. 與 [示例 3-5](/tw/ch3#fig_cypher_query) 相同的查詢,用 Datalog 表示" class="w-full my-4" >}}
{{< figure id="fig_datalog_query" title="示例 3-12. 與 [示例 3-5](#fig_cypher_query) 相同的查詢,用 Datalog 表示" class="w-full my-4" >}}
```sql
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 規則 1 */
@ -779,11 +781,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 規則 4
Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小步。我們定義 *規則* 從底層事實派生新的虛擬表。這些派生表就像虛擬SQL 檢視:它們不儲存在資料庫中,但你可以像查詢包含儲存事實的表一樣查詢它們。
在 [示例 3-12](/tw/ch3#fig_datalog_query) 中,我們定義了三個派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虛擬表的名稱和列由每個規則的 `:-` 符號之前出現的內容定義。例如,`migrated(PName, BornIn, LivingIn)` 是一個具有三列的虛擬表:一個人的姓名、他們出生地的名稱和他們居住地的名稱。
在 [示例 3-12](#fig_datalog_query) 中,我們定義了三個派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虛擬表的名稱和列由每個規則的 `:-` 符號之前出現的內容定義。例如,`migrated(PName, BornIn, LivingIn)` 是一個具有三列的虛擬表:一個人的姓名、他們出生地的名稱和他們居住地的名稱。
虛擬表的內容由規則的 `:-` 符號之後的部分定義,我們在其中嘗試查詢表中匹配某種模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,變數 `PersonID` 繫結到值 `100`,變數 `PName` 繫結到值 `"Lucy"`。如果系統可以為 `:-` 運算元右側的 *所有* 模式找到匹配項,則規則適用。當規則適用時,就好像 `:-` 的左側被新增到資料庫中(變數被它們匹配的值替換)。
因此,應用規則的一種可能方式是(如 [圖 3-7](/tw/ch3#fig_datalog_naive) 所示):
因此,應用規則的一種可能方式是(如 [圖 3-7](#fig_datalog_naive) 所示):
1. `location(1, "North America", "continent")` 存在於資料庫中,因此規則 1 適用。它生成 `within_recursive(1, "North America")`
2. `within(2, 1)` 存在於資料庫中,前一步生成了 `within_recursive(1, "North America")`,因此規則 2 適用。它生成 `within_recursive(2, "North America")`
@ -793,11 +795,11 @@ Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="圖 3-7. 使用示例 3-12 中的 Datalog 規則確定愛達荷州在北美。" class="w-full my-4" >}}
> 圖 3-7. 使用 [示例 3-12](/tw/ch3#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。
> 圖 3-7. 使用 [示例 3-12](#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。
現在規則 3 可以找到出生在某個位置 `BornIn` 並居住在某個位置 `LivingIn` 的人。規則 4 使用 `BornIn = 'United States'``LivingIn = 'Europe'` 呼叫規則 3並僅返回匹配搜尋的人的姓名。透過查詢虛擬 `us_to_europe` 表的內容Datalog 系統最終得到與早期 Cypher 和 SPARQL 查詢相同的答案。
與本章討論的其他查詢語言相比Datalog 方法需要不同型別的思維。它允許逐條規則地構建複雜查詢一個規則引用其他規則類似於你將程式碼分解為相互呼叫的函式的方式。就像函式可以遞迴一樣Datalog 規則也可以呼叫自己,如 [示例 3-12](/tw/ch3#fig_datalog_query) 中的規則 2這使得 Datalog 查詢中的圖遍歷成為可能。
與本章討論的其他查詢語言相比Datalog 方法需要不同型別的思維。它允許逐條規則地構建複雜查詢一個規則引用其他規則類似於你將程式碼分解為相互呼叫的函式的方式。就像函式可以遞迴一樣Datalog 規則也可以呼叫自己,如 [示例 3-12](#fig_datalog_query) 中的規則 2這使得 Datalog 查詢中的圖遍歷成為可能。
### GraphQL {#id63}
@ -805,7 +807,7 @@ GraphQL 是一種查詢語言,從設計上講,它比我們在本章中看到
GraphQL 的靈活性是有代價的。採用 GraphQL 的組織通常需要工具將 GraphQL 查詢轉換為對內部服務的請求,這些服務通常使用 REST 或 gRPC參見 [第 5 章](/tw/ch5#ch_encoding))。授權、速率限制和效能挑戰是額外的關注點 [^61]。GraphQL 的查詢語言也受到限制,因為 GraphQL 查詢來自不受信任的來源。該語言不允許任何可能執行成本高昂的操作否則使用者可能透過執行大量昂貴的查詢對伺服器執行拒絕服務攻擊。特別是GraphQL 不允許遞迴查詢(與 Cypher、SPARQL、SQL 或 Datalog 不同),並且不允許任意搜尋條件,如"查詢在美國出生並現在居住在歐洲的人"(除非服務所有者特別選擇提供此類搜尋功能)。
儘管如此GraphQL 還是很有用的。[示例 3-13](/tw/ch3#fig_graphql_query) 顯示了如何使用 GraphQL 實現 Discord 或 Slack 等群聊應用程式。查詢請求使用者有權訪問的所有頻道,包括頻道名稱和每個頻道中的 50 條最新訊息。對於每條訊息,它請求時間戳、訊息內容以及訊息傳送者的姓名和個人資料圖片 URL。此外如果訊息是對另一條訊息的回覆查詢還會請求傳送者姓名和它所回覆的訊息內容可能以較小的字型呈現在回覆上方以提供一些上下文
儘管如此GraphQL 還是很有用的。[示例 3-13](#fig_graphql_query) 顯示了如何使用 GraphQL 實現 Discord 或 Slack 等群聊應用程式。查詢請求使用者有權訪問的所有頻道,包括頻道名稱和每個頻道中的 50 條最新訊息。對於每條訊息,它請求時間戳、訊息內容以及訊息傳送者的姓名和個人資料圖片 URL。此外如果訊息是對另一條訊息的回覆查詢還會請求傳送者姓名和它所回覆的訊息內容可能以較小的字型呈現在回覆上方以提供一些上下文
{{< figure id="fig_graphql_query" title="示例 3-13. 群聊應用程式的示例 GraphQL 查詢" class="w-full my-4" >}}
@ -831,7 +833,7 @@ query ChatApp {
}
```
[示例 3-14](/tw/ch3#fig_graphql_response) 顯示了對 [示例 3-13](/tw/ch3#fig_graphql_query) 中查詢的響應可能是什麼樣子。響應是一個反映查詢結構的 JSON 文件:它正好包含請求的那些屬性,不多也不少。這種方法的優點是伺服器不需要知道客戶端需要哪些屬性來渲染使用者介面;相反,客戶端可以簡單地請求它需要的內容。例如,此查詢不會為 `replyTo` 訊息的傳送者請求個人資料圖片 URL但如果使用者介面更改為新增該個人資料圖片客戶端可以很容易地將所需的 `imageUrl` 屬性新增到查詢中,而無需更改伺服器。
[示例 3-14](#fig_graphql_response) 顯示了對 [示例 3-13](#fig_graphql_query) 中查詢的響應可能是什麼樣子。響應是一個反映查詢結構的 JSON 文件:它正好包含請求的那些屬性,不多也不少。這種方法的優點是伺服器不需要知道客戶端需要哪些屬性來渲染使用者介面;相反,客戶端可以簡單地請求它需要的內容。例如,此查詢不會為 `replyTo` 訊息的傳送者請求個人資料圖片 URL但如果使用者介面更改為新增該個人資料圖片客戶端可以很容易地將所需的 `imageUrl` 屬性新增到查詢中,而無需更改伺服器。
{{< figure link="#fig_graphql_query" id="fig_graphql_response" title="示例 3-14. 對 示例 3-13 中查詢的可能響應" class="w-full my-4" >}}
@ -860,9 +862,9 @@ query ChatApp {
...
```
在 [示例 3-14](/tw/ch3#fig_graphql_response) 中,訊息傳送者的姓名和影像 URL 直接嵌入在訊息物件中。如果同一使用者傳送多條訊息,此資訊會在每條訊息上重複。原則上,可以減少這種重複,但 GraphQL 做出了接受更大響應大小的設計選擇,以便更簡單地基於資料渲染使用者介面。
在 [示例 3-14](#fig_graphql_response) 中,訊息傳送者的姓名和影像 URL 直接嵌入在訊息物件中。如果同一使用者傳送多條訊息,此資訊會在每條訊息上重複。原則上,可以減少這種重複,但 GraphQL 做出了接受更大響應大小的設計選擇,以便更簡單地基於資料渲染使用者介面。
`replyTo` 欄位類似:在 [示例 3-14](/tw/ch3#fig_graphql_response) 中,第二條訊息是對第一條訊息的回覆,內容("Hey!…")和傳送者 Aaliyah 在 `replyTo` 下重複。可以改為返回被回覆訊息的 ID但如果該 ID 不在返回的 50 條最新訊息中,客戶端就必須向伺服器發出額外的請求。重複內容使得處理資料變得更加簡單。
`replyTo` 欄位類似:在 [示例 3-14](#fig_graphql_response) 中,第二條訊息是對第一條訊息的回覆,內容("Hey!…")和傳送者 Aaliyah 在 `replyTo` 下重複。可以改為返回被回覆訊息的 ID但如果該 ID 不在返回的 50 條最新訊息中,客戶端就必須向伺服器發出額外的請求。重複內容使得處理資料變得更加簡單。
伺服器的資料庫可以以更正規化的形式儲存資料,並執行必要的連線來處理查詢。例如,伺服器可能儲存訊息以及傳送者的使用者 ID 和它所回覆的訊息的 ID當它收到如上所示的查詢時伺服器將解析這些 ID 以查詢它們引用的記錄。但是,客戶端只能要求伺服器執行 GraphQL 模式中明確提供的連線。
@ -877,11 +879,11 @@ query ChatApp {
也許寫入資料的最簡單、最快速和最具表現力的方式是 *事件日誌*:每次你想寫入一些資料時,你將其編碼為自包含的字串(可能是 JSON包括時間戳然後將其追加到事件序列中。此日誌中的事件是 *不可變的*:你永遠不會更改或刪除它們,你只會向日志追加更多事件(這可能會取代早期事件)。事件可以包含任意屬性。
[圖 3-8](/tw/ch3#fig_event_sourcing) 顯示了一個可能來自會議管理系統的示例。會議可能是一個複雜的業務領域:不僅個人參與者可以註冊並用信用卡付款,公司也可以批次訂購座位,透過發票付款,然後再將座位分配給個人。一些座位可能為演講者、贊助商、志願者助手等保留。預訂也可能被取消,與此同時,會議組織者可能透過將其移至不同的房間來更改活動的容量。在所有這些情況發生時,簡單地計算可用座位數量就成為一個具有挑戰性的查詢。
[圖 3-8](#fig_event_sourcing) 顯示了一個可能來自會議管理系統的示例。會議可能是一個複雜的業務領域:不僅個人參與者可以註冊並用信用卡付款,公司也可以批次訂購座位,透過發票付款,然後再將座位分配給個人。一些座位可能為演講者、贊助商、志願者助手等保留。預訂也可能被取消,與此同時,會議組織者可能透過將其移至不同的房間來更改活動的容量。在所有這些情況發生時,簡單地計算可用座位數量就成為一個具有挑戰性的查詢。
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="圖 3-8. 使用不可變事件日誌作為真相來源(權威資料來源),並從中派生物化檢視。" class="w-full my-4" >}}
在 [圖 3-8](/tw/ch3#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參與者進行和取消註冊)首先被儲存為事件。每當事件追加到日誌時,幾個 *物化檢視*(也稱為 *投影**讀模型*)也會更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為列印參與者徽章的印表機生成檔案。
在 [圖 3-8](#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參與者進行和取消註冊)首先被儲存為事件。每當事件追加到日誌時,幾個 *物化檢視*(也稱為 *投影**讀模型*)也會更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為列印參與者徽章的印表機生成檔案。
使用事件作為真相來源(權威資料來源),並將每個狀態變化表達為事件的想法被稱為 *事件溯源* [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則稱為 *命令查詢責任分離CQRS* [^64]。這些術語起源於領域驅動設計DDD社群儘管類似的想法已經存在很長時間了例如 *狀態機複製*(參見 ["使用共享日誌"](/tw/ch10#sec_consistency_smr))。
@ -889,7 +891,7 @@ query ChatApp {
在以事件溯源風格建模資料時,建議你使用過去時態命名事件(例如,"座位已預訂"),因為事件是記錄過去發生的事情的記錄。即使使用者後來決定更改或取消,他們以前持有預訂的事實仍然是真實的,更改或取消是稍後新增的單獨事件。
事件溯源與星型模式事實表之間的相似之處(如 ["星型與雪花型:分析模式"](/tw/ch3#sec_datamodels_analytics) 中所討論的)是兩者都是過去發生的事件的集合。然而,事實表中的行都具有相同的列集,而在事件溯源中可能有許多不同的事件型別,每種都有不同的屬性。此外,事實表是無序集合,而在事件溯源中事件的順序很重要:如果先進行預訂然後取消,以錯誤的順序處理這些事件將沒有意義。
事件溯源與星型模式事實表之間的相似之處(如 ["星型與雪花型:分析模式"](#sec_datamodels_analytics) 中所討論的)是兩者都是過去發生的事件的集合。然而,事實表中的行都具有相同的列集,而在事件溯源中可能有許多不同的事件型別,每種都有不同的屬性。此外,事實表是無序集合,而在事件溯源中事件的順序很重要:如果先進行預訂然後取消,以錯誤的順序處理這些事件將沒有意義。
事件溯源和 CQRS 有幾個優點:
@ -923,16 +925,16 @@ query ChatApp {
資料框 API 還提供了遠遠超出關係資料庫提供的各種操作,資料模型的使用方式通常與典型的關係資料建模非常不同 [^65]。例如,資料框的常見用途是將資料從類似關係的表示轉換為矩陣或多維陣列表示,這是許多機器學習演算法期望的輸入形式。
[圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 顯示了這種轉換的簡單示例。左側是不同使用者如何評價各種電影的關係表(評分為 1 到 5右側資料已轉換為矩陣其中每列是一部電影每行是一個使用者類似於電子表格中的 *資料透視表*)。矩陣是 *稀疏* 的,這意味著許多使用者-電影組合沒有資料,但這沒關係。這個矩陣可能有數千列,因此不太適合關係資料庫,但資料框和提供稀疏陣列的庫(如 Python 的 NumPy可以輕鬆處理此類資料。
[圖 3-9](#fig_dataframe_to_matrix) 顯示了這種轉換的簡單示例。左側是不同使用者如何評價各種電影的關係表(評分為 1 到 5右側資料已轉換為矩陣其中每列是一部電影每行是一個使用者類似於電子表格中的 *資料透視表*)。矩陣是 *稀疏* 的,這意味著許多使用者-電影組合沒有資料,但這沒關係。這個矩陣可能有數千列,因此不太適合關係資料庫,但資料框和提供稀疏陣列的庫(如 Python 的 NumPy可以輕鬆處理此類資料。
{{< figure src="/fig/ddia_0309.png" id="fig_dataframe_to_matrix" title="圖 3-9. 將電影評分的關係資料庫轉換為矩陣表示。" class="w-full my-4" >}}
矩陣只能包含數字,各種技術用於將非數字資料轉換為矩陣中的數字。例如:
* 日期(在 [圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 的示例矩陣中省略了)可以縮放為某個合適範圍內的浮點數。
* 日期(在 [圖 3-9](#fig_dataframe_to_matrix) 的示例矩陣中省略了)可以縮放為某個合適範圍內的浮點數。
* 對於只能取一小組固定值之一的列(例如,電影資料庫中電影的型別),通常使用 *獨熱編碼*:我們為每個可能的值建立一列(一個用於"喜劇",一個用於"劇情",一個用於"恐怖"等),對於代表電影的每一行,我們在對應於該電影型別的列中放置 1在所有其他列中放置 0。這種表示也很容易推廣到適合多種型別的電影。
一旦資料以數字矩陣的形式存在,它就適合線性代數運算,這構成了許多機器學習演算法的基礎。例如,[圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 中的資料可能是推薦使用者可能喜歡的電影系統的一部分。資料框足夠靈活,允許資料從關係形式逐漸演變為矩陣表示,同時讓資料科學家控制最適合實現資料分析或模型訓練過程目標的表示。
一旦資料以數字矩陣的形式存在,它就適合線性代數運算,這構成了許多機器學習演算法的基礎。例如,[圖 3-9](#fig_dataframe_to_matrix) 中的資料可能是推薦使用者可能喜歡的電影系統的一部分。資料框足夠靈活,允許資料從關係形式逐漸演變為矩陣表示,同時讓資料科學家控制最適合實現資料分析或模型訓練過程目標的表示。
還有像 TileDB [^66] 這樣專門儲存大型多維數字陣列的資料庫;它們被稱為 *陣列資料庫*,最常用於科學資料集,如地理空間測量(規則間隔網格上的柵格資料)、醫學成像或天文望遠鏡的觀測 [^67]。資料框在金融行業也用於表示 *時間序列資料*,如資產價格和隨時間變化的交易 [^68]。

View file

@ -21,7 +21,7 @@ breadcrumbs: false
特別是針對事務型工作負載OLTP最佳化的儲存引擎和針對分析型工作負載最佳化的儲存引擎之間存在巨大差異我們在 ["分析型與事務型系統"](/tw/ch1#sec_introduction_analytics) 中介紹了這種區別)。本章首先研究兩種用於 OLTP 的儲存引擎家族:寫入不可變資料檔案的 *日誌結構* 儲存引擎,以及像 *B 樹* 這樣就地更新資料的儲存引擎。這些結構既用於鍵值儲存,也用於二級索引。
隨後在 ["分析型資料儲存"](/tw/ch4#sec_storage_analytics) 中,我們將討論一系列針對分析最佳化的儲存引擎;在 ["多維索引與全文索引"](/tw/ch4#sec_storage_multidimensional) 中,我們將簡要介紹用於更高階查詢(如文字檢索)的索引。
隨後在 ["分析型資料儲存"](#sec_storage_analytics) 中,我們將討論一系列針對分析最佳化的儲存引擎;在 ["多維索引與全文索引"](#sec_storage_multidimensional) 中,我們將簡要介紹用於更高階查詢(如文字檢索)的索引。
## OLTP 系統的儲存與索引 {#sec_storage_oltp}
@ -87,7 +87,7 @@ $ cat database
### 日誌結構儲存 {#sec_storage_log_structured}
首先,讓我們假設你想繼續將資料儲存在 `db_set` 寫入的僅追加檔案中,你只是想加快讀取速度。一種方法是在記憶體中保留一個雜湊對映,其中每個鍵都對映到檔案中可以找到該鍵最新值的位元組偏移量,如 [圖 4-1](/tw/ch4#fig_storage_csv_hash_index) 所示。
首先,讓我們假設你想繼續將資料儲存在 `db_set` 寫入的僅追加檔案中,你只是想加快讀取速度。一種方法是在記憶體中保留一個雜湊對映,其中每個鍵都對映到檔案中可以找到該鍵最新值的位元組偏移量,如 [圖 4-1](#fig_storage_csv_hash_index) 所示。
{{< figure src="/fig/ddia_0401.png" id="fig_storage_csv_hash_index" caption="圖 4-1. 以類似 CSV 格式儲存鍵值對日誌,使用記憶體雜湊對映建立索引。" class="w-full my-4" >}}
@ -102,15 +102,15 @@ $ cat database
#### SSTable 檔案格式 {#the-sstable-file-format}
實際上,雜湊表很少用於資料庫索引,相反,保持資料 *按鍵排序* 的結構更為常見 [^3]。這種結構的一個例子是 *排序字串表**Sorted String Table*),簡稱 *SSTable*,如 [圖 4-2](/tw/ch4#fig_storage_sstable_index) 所示。這種檔案格式也儲存鍵值對,但它確保它們按鍵排序,每個鍵在檔案中只出現一次。
實際上,雜湊表很少用於資料庫索引,相反,保持資料 *按鍵排序* 的結構更為常見 [^3]。這種結構的一個例子是 *排序字串表**Sorted String Table*),簡稱 *SSTable*,如 [圖 4-2](#fig_storage_sstable_index) 所示。這種檔案格式也儲存鍵值對,但它確保它們按鍵排序,每個鍵在檔案中只出現一次。
{{< figure src="/fig/ddia_0402.png" id="fig_storage_sstable_index" caption="圖 4-2. 帶有稀疏索引的 SSTable允許查詢跳轉到正確的塊。" class="w-full my-4" >}}
現在你不需要在記憶體中保留所有鍵:你可以將 SSTable 中的鍵值對分組為幾千位元組的 *塊*,然後在索引中儲存每個塊的第一個鍵。這種只儲存部分鍵的索引稱為 *稀疏* 索引。這個索引儲存在 SSTable 的單獨部分,例如使用不可變 B 樹、字典樹或其他允許查詢快速查詢特定鍵的資料結構 [^4]。
例如,在 [圖 4-2](/tw/ch4#fig_storage_sstable_index) 中,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。現在假設你要查詢鍵 `handiwork`,它沒有出現在稀疏索引中。由於排序,你知道 `handiwork` 必須出現在 `handbag``handsome` 之間。這意味著你可以尋找到 `handbag` 的偏移量,然後從那裡掃描檔案,直到找到 `handiwork`(或沒有,如果該鍵不在檔案中)。幾千位元組的塊可以非常快速地掃描。
例如,在 [圖 4-2](#fig_storage_sstable_index) 中,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。現在假設你要查詢鍵 `handiwork`,它沒有出現在稀疏索引中。由於排序,你知道 `handiwork` 必須出現在 `handbag``handsome` 之間。這意味著你可以尋找到 `handbag` 的偏移量,然後從那裡掃描檔案,直到找到 `handiwork`(或沒有,如果該鍵不在檔案中)。幾千位元組的塊可以非常快速地掃描。
此外,每個記錄塊都可以壓縮(在 [圖 4-2](/tw/ch4#fig_storage_sstable_index) 中用陰影區域表示)。除了節省磁碟空間外,壓縮還減少了 I/O 頻寬使用,代價是使用更多一點的 CPU 時間。
此外,每個記錄塊都可以壓縮(在 [圖 4-2](#fig_storage_sstable_index) 中用陰影區域表示)。除了節省磁碟空間外,壓縮還減少了 I/O 頻寬使用,代價是使用更多一點的 CPU 時間。
#### 構建和合並 SSTable {#constructing-and-merging-sstables}
@ -123,7 +123,7 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
3. 為了讀取某個鍵的值,首先嘗試在記憶體表和最新的磁碟段中找到該鍵。如果沒有找到,就在下一個較舊的段中查詢,依此類推,直到找到鍵或到達最舊的段。如果鍵沒有出現在任何段中,則它不存在於資料庫中。
4. 不時地在後臺執行合併和壓實過程,以合併段檔案並丟棄被覆蓋或刪除的值。
合併段的工作方式類似於 *歸併排序* 演算法 [^5]。該過程如 [圖 4-3](/tw/ch4#fig_storage_sstable_merging) 所示:並排開始讀取輸入檔案,檢視每個檔案中的第一個鍵,將最低的鍵(根據排序順序)複製到輸出檔案,然後重複。如果同一個鍵出現在多個輸入檔案中,只保留較新的值。這會產生一個新的合併段檔案,也按鍵排序,每個鍵只有一個值,並且它使用最少的記憶體,因為我們可以一次遍歷一個鍵的 SSTable。
合併段的工作方式類似於 *歸併排序* 演算法 [^5]。該過程如 [圖 4-3](#fig_storage_sstable_merging) 所示:並排開始讀取輸入檔案,檢視每個檔案中的第一個鍵,將最低的鍵(根據排序順序)複製到輸出檔案,然後重複。如果同一個鍵出現在多個輸入檔案中,只保留較新的值。這會產生一個新的合併段檔案,也按鍵排序,每個鍵只有一個值,並且它使用最少的記憶體,因為我們可以一次遍歷一個鍵的 SSTable。
{{< figure src="/fig/ddia_0403.png" id="fig_storage_sstable_merging" caption="圖 4-3. 合併多個 SSTable 段,僅保留每個鍵的最新值。" class="w-full my-4" >}}
@ -147,11 +147,11 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
使用 LSM 儲存讀取很久以前更新的鍵或不存在的鍵可能會很慢因為儲存引擎需要檢查多個段檔案。為了加快此類讀取LSM 儲存引擎通常在每個段中包含一個 *布隆過濾器**Bloom filter*[^13],它提供了一種快速但近似的方法來檢查特定鍵是否出現在特定 SSTable 中。
[圖 4-4](/tw/ch4#fig_storage_bloom) 顯示了一個包含兩個鍵和 16 位的布隆過濾器示例(實際上,它會包含更多的鍵和更多的位)。對於 SSTable 中的每個鍵,我們計算一個雜湊函式,產生一組數字,然後將其解釋為位陣列的索引 [^14]。我們將對應於這些索引的位設定為 1其餘保持為 0。例如`handbag` 雜湊為數字 (2, 9, 4),所以我們將第 2、9 和 4 位設定為 1。然後將點陣圖與鍵的稀疏索引一起儲存為 SSTable 的一部分。這需要一點額外的空間,但與 SSTable 的其餘部分相比,布隆過濾器通常很小。
[圖 4-4](#fig_storage_bloom) 顯示了一個包含兩個鍵和 16 位的布隆過濾器示例(實際上,它會包含更多的鍵和更多的位)。對於 SSTable 中的每個鍵,我們計算一個雜湊函式,產生一組數字,然後將其解釋為位陣列的索引 [^14]。我們將對應於這些索引的位設定為 1其餘保持為 0。例如`handbag` 雜湊為數字 (2, 9, 4),所以我們將第 2、9 和 4 位設定為 1。然後將點陣圖與鍵的稀疏索引一起儲存為 SSTable 的一部分。這需要一點額外的空間,但與 SSTable 的其餘部分相比,布隆過濾器通常很小。
{{< figure src="/fig/ddia_0404.png" id="fig_storage_bloom" caption="圖 4-4. 布隆過濾器提供了一種快速的機率檢查,用於判斷特定鍵是否存在於特定 SSTable 中。" class="w-full my-4" >}}
當我們想知道一個鍵是否出現在 SSTable 中時,我們像以前一樣計算該鍵的相同雜湊,並檢查這些索引處的位。例如,在 [圖 4-4](/tw/ch4#fig_storage_bloom) 中,我們查詢鍵 `handheld`,它雜湊為 (6, 11, 2)。其中一個位是 1即第 2 位),而另外兩個是 0。這些檢查可以使用所有 CPU 都支援的位運算非常快速地進行。
當我們想知道一個鍵是否出現在 SSTable 中時,我們像以前一樣計算該鍵的相同雜湊,並檢查這些索引處的位。例如,在 [圖 4-4](#fig_storage_bloom) 中,我們查詢鍵 `handheld`,它雜湊為 (6, 11, 2)。其中一個位是 1即第 2 位),而另外兩個是 0。這些檢查可以使用所有 CPU 都支援的位運算非常快速地進行。
如果至少有一個位是 0我們知道該鍵肯定不在 SSTable 中。如果查詢中的位都是 1那麼該鍵很可能在 SSTable 中,但也有可能是巧合,所有這些位都被其他鍵設定為 1。這種看起來鍵存在但實際上不存在的情況稱為 *假陽性**false positive*)。
@ -174,7 +174,7 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
作為經驗法則,如果你主要有寫入而讀取很少,分層壓實表現更好,而如果你的工作負載以讀取為主,分級壓實表現更好。如果你頻繁寫入少量鍵,而很少寫入大量鍵,那麼分級壓實也可能有優勢 [^18]。
儘管有許多細微之處,但 LSM 樹的基本思想 —— 保持在後臺合併的 SSTable 級聯 —— 簡單而有效。我們將在 ["比較 B 樹與 LSM 樹"](/tw/ch4#sec_storage_btree_lsm_comparison) 中更詳細地討論它們的效能特徵。
儘管有許多細微之處,但 LSM 樹的基本思想 —— 保持在後臺合併的 SSTable 級聯 —— 簡單而有效。我們將在 ["比較 B 樹與 LSM 樹"](#sec_storage_btree_lsm_comparison) 中更詳細地討論它們的效能特徵。
--------
@ -200,21 +200,21 @@ B 樹於 1970 年引入 [^21],不到 10 年後就被稱為"無處不在"[^22]
我們之前看到的日誌結構索引將資料庫分解為可變大小的 *段*通常為幾兆位元組或更大寫入一次後就不可變。相比之下B 樹將資料庫分解為固定大小的 *塊**頁*,並可能就地覆蓋頁。頁傳統上大小為 4 KiB但 PostgreSQL 現在預設使用 8 KiBMySQL 預設使用 16 KiB。
每個頁都可以使用頁號來標識,這允許一個頁引用另一個頁 —— 類似於指標,但在磁碟上而不是在記憶體中。如果所有頁都儲存在同一個檔案中,將頁號乘以頁大小就給我們檔案中頁所在位置的位元組偏移量。我們可以使用這些頁引用來構建頁樹,如 [圖 4-5](/tw/ch4#fig_storage_b_tree) 所示。
每個頁都可以使用頁號來標識,這允許一個頁引用另一個頁 —— 類似於指標,但在磁碟上而不是在記憶體中。如果所有頁都儲存在同一個檔案中,將頁號乘以頁大小就給我們檔案中頁所在位置的位元組偏移量。我們可以使用這些頁引用來構建頁樹,如 [圖 4-5](#fig_storage_b_tree) 所示。
{{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="圖 4-5. 使用 B 樹索引查詢鍵 251。從根頁開始我們首先跟隨引用到鍵 200300 的頁,然後是鍵 250270 的頁。" class="w-full my-4" >}}
一個頁被指定為 B 樹的 *根*;每當你想在索引中查詢一個鍵時,你就從這裡開始。該頁包含幾個鍵和對子頁的引用。每個子頁負責一個連續的鍵範圍,引用之間的鍵指示這些範圍之間的邊界在哪裡。(這種結構有時稱為 B+ 樹,但我們不需要將其與其他 B 樹變體區分開來。)
在 [圖 4-5](/tw/ch4#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(*葉頁*),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。
在 [圖 4-5](#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(*葉頁*),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。
B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [圖 4-5](/tw/ch4#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。
B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [圖 4-5](#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。
如果你想更新 B 樹中現有鍵的值,你搜索包含該鍵的葉頁,並用包含新值的版本覆蓋磁碟上的該頁。如果你想新增一個新鍵,你需要找到其範圍包含新鍵的頁並將其新增到該頁。如果頁中沒有足夠的空閒空間來容納新鍵,則頁被分成兩個半滿的頁,並更新父頁以說明鍵範圍的新細分。
{{< figure src="/fig/ddia_0406.png" id="fig_storage_b_tree_split" caption="圖 4-6. 透過在邊界鍵 337 上分割頁來增長 B 樹。父頁被更新以引用兩個子頁。" class="w-full my-4" >}}
在 [圖 4-6](/tw/ch4#fig_storage_b_tree_split) 的例子中,我們想插入鍵 334但範圍 333345 的頁已經滿了。因此,我們將其分成範圍 333337包括新鍵的頁和 337344 的頁。我們還必須更新父頁以引用兩個子頁,它們之間的邊界值為 337。如果父頁沒有足夠的空間容納新引用它也可能需要被分割分割可以一直持續到樹的根。當根被分割時我們在它上面建立一個新根。刪除鍵可能需要合併節點更複雜 [^5]。
在 [圖 4-6](#fig_storage_b_tree_split) 的例子中,我們想插入鍵 334但範圍 333345 的頁已經滿了。因此,我們將其分成範圍 333337包括新鍵的頁和 337344 的頁。我們還必須更新父頁以引用兩個子頁,它們之間的邊界值為 337。如果父頁沒有足夠的空間容納新引用它也可能需要被分割分割可以一直持續到樹的根。當根被分割時我們在它上面建立一個新根。刪除鍵可能需要合併節點更複雜 [^5]。
這個演算法確保樹保持 *平衡*:具有 *n* 個鍵的 B 樹始終具有 *O*(log *n*) 的深度。大多數資料庫可以適合三或四層深的 B 樹,所以你不需要跟隨許多頁引用來找到你要查詢的頁。(具有 500 分支因子的 4 KiB 頁的四層樹可以儲存多達 250 TB。
@ -255,7 +255,7 @@ B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋
使用 B 樹時,如果應用程式寫入的鍵分散在整個鍵空間中,生成的磁碟操作也會隨機分散,因為儲存引擎需要覆蓋的頁可能位於磁碟的任何位置。另一方面,日誌結構儲存引擎一次寫入整個段檔案(無論是寫出記憶體表還是壓實現有段),這比 B 樹中的頁大得多。
許多小的、分散的寫入模式(如 B 樹中的)稱為 *隨機寫入*,而較少的大寫入模式(如 LSM 樹中的)稱為 *順序寫入*。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟HDD上特別大在今天大多數資料庫使用的固態硬碟SSD差異較小但仍然明顯參見 ["SSD 上的順序與隨機寫入"](/tw/ch4#sidebar_sequential))。
許多小的、分散的寫入模式(如 B 樹中的)稱為 *隨機寫入*,而較少的大寫入模式(如 LSM 樹中的)稱為 *順序寫入*。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟HDD上特別大在今天大多數資料庫使用的固態硬碟SSD差異較小但仍然明顯參見 ["SSD 上的順序與隨機寫入"](#sidebar_sequential))。
--------
@ -289,7 +289,7 @@ B 樹索引必須至少寫入每條資料兩次:一次寫入預寫日誌,一
B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了大量鍵,資料庫檔案可能包含許多 B 樹不再使用的頁。對 B 樹的後續新增可以使用這些空閒頁,但它們不能輕易地返回給作業系統,因為它們在檔案的中間,所以它們仍然佔用檔案系統上的空間。因此,資料庫需要一個後臺過程來移動頁以更好地放置它們,例如 PostgreSQL 中的真空過程 [^25]。
碎片化在 LSM 樹中不太成問題,因為壓實過程無論如何都會定期重寫資料檔案,而且 SSTable 沒有未使用空間的頁。此外SSTable 中的鍵值對塊可以更好地壓縮,因此通常比 B 樹在磁碟上產生更小的檔案。被覆蓋的鍵和值繼續消耗空間,直到它們被壓實刪除,但使用分級壓即時,這種開銷相當低 [^40] [^41]。分層壓實(參見 ["壓實策略"](/tw/ch4#sec_storage_lsm_compaction))使用更多的磁碟空間,特別是在壓實期間臨時使用。
碎片化在 LSM 樹中不太成問題,因為壓實過程無論如何都會定期重寫資料檔案,而且 SSTable 沒有未使用空間的頁。此外SSTable 中的鍵值對塊可以更好地壓縮,因此通常比 B 樹在磁碟上產生更小的檔案。被覆蓋的鍵和值繼續消耗空間,直到它們被壓實刪除,但使用分級壓即時,這種開銷相當低 [^40] [^41]。分層壓實(參見 ["壓實策略"](#sec_storage_lsm_compaction))使用更多的磁碟空間,特別是在壓實期間臨時使用。
在磁碟上有一些資料的多個副本也可能是一個問題,當你需要刪除一些資料,並確信它真的已被刪除(也許是為了遵守資料保護法規)。例如,在大多數 LSM 儲存引擎中,已刪除的記錄可能仍然存在於較高級別中,直到代表刪除的墓碑透過所有壓實級別傳播,這可能需要很長時間。專門的儲存引擎設計可以更快地傳播刪除 [^42]。
@ -312,7 +312,7 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了
* 或者值可以是對實際資料的引用要麼是相關行的主鍵InnoDB 對二級索引這樣做),要麼是對磁碟上位置的直接引用。在後一種情況下,儲存行的地方稱為 *堆檔案*它以無特定順序儲存資料它可能是僅追加的或者它可能跟蹤已刪除的行以便稍後用新資料覆蓋它們。例如Postgres 使用堆檔案方法 [^44]。
* 兩者之間的折中是 *覆蓋索引**包含列的索引*,它在索引中儲存表的 *某些* 列,除了在堆上或主鍵聚簇索引中儲存完整行 [^45]。這允許僅使用索引來回答某些查詢,而無需解析主鍵或檢視堆檔案(在這種情況下,索引被稱為 *覆蓋* 查詢)。這可以使某些查詢更快,但資料的重複意味著索引使用更多的磁碟空間並減慢寫入速度。
到目前為止討論的索引只將單個鍵對映到值。如果你需要同時查詢表的多個列(或文件中的多個欄位),請參見 ["多維索引與全文索引"](/tw/ch4#sec_storage_multidimensional)。
到目前為止討論的索引只將單個鍵對映到值。如果你需要同時查詢表的多個列(或文件中的多個欄位),請參見 ["多維索引與全文索引"](#sec_storage_multidimensional)。
當更新值而不更改鍵時,堆檔案方法可以允許記錄就地覆蓋,前提是新值不大於舊值。如果新值更大,情況會更複雜,因為它可能需要移動到堆中有足夠空間的新位置。在這種情況下,要麼所有索引都需要更新以指向記錄的新堆位置,要麼在舊堆位置留下轉發指標 [^2]。
@ -367,7 +367,7 @@ Apache Hive、Trino 和 Apache Spark 等開源資料倉庫也隨著雲的發展
如 ["星型和雪花型:分析模式"](/tw/ch3#sec_datamodels_analytics) 中所討論的,資料倉庫按照慣例通常使用帶有大型事實表的關係模式,該表包含對維度表的外部索引鍵引用。如果你的事實表中有數萬億行和數 PB 的資料,有效地儲存和查詢它們就成為一個具有挑戰性的問題。維度表通常要小得多(數百萬行),因此在本節中我們將重點關注事實的儲存。
儘管事實表通常有超過 100 列,但典型的資料倉庫查詢一次只訪問其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查詢)[^52]。以 [示例 4-1](/tw/ch4#fig_storage_analytics_query) 中的查詢為例它訪問大量行2024 日曆年期間每次有人購買水果或糖果的情況),但它只需要訪問 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查詢忽略所有其他列。
儘管事實表通常有超過 100 列,但典型的資料倉庫查詢一次只訪問其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查詢)[^52]。以 [示例 4-1](#fig_storage_analytics_query) 中的查詢為例它訪問大量行2024 日曆年期間每次有人購買水果或糖果的情況),但它只需要訪問 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查詢忽略所有其他列。
{{< figure id="fig_storage_analytics_query" title="示例 4-1. 分析人們是否更傾向於購買新鮮水果或糖果,取決於星期幾" class="w-full my-4" >}}
@ -387,11 +387,11 @@ GROUP BY
我們如何高效地執行這個查詢?
在大多數 OLTP 資料庫中,儲存是以 *面向行* 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](/tw/ch4#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。
在大多數 OLTP 資料庫中,儲存是以 *面向行* 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。
為了處理像 [示例 4-1](/tw/ch4#fig_storage_analytics_query) 這樣的查詢,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告訴儲存引擎在哪裡找到特定日期或特定產品的所有銷售。但是,面向行的儲存引擎仍然需要將所有這些行(每行包含超過 100 個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉不符合所需條件的行。這可能需要很長時間。
為了處理像 [示例 4-1](#fig_storage_analytics_query) 這樣的查詢,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告訴儲存引擎在哪裡找到特定日期或特定產品的所有銷售。但是,面向行的儲存引擎仍然需要將所有這些行(每行包含超過 100 個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉不符合所需條件的行。這可能需要很長時間。
*面向列*(或 *列式*)儲存背後的想法很簡單:不要將一行中的所有值儲存在一起,而是將每 *列* 中的所有值儲存在一起 [^56]。如果每列單獨儲存,查詢只需要讀取和解析該查詢中使用的那些列,這可以節省大量工作。[圖 4-7](/tw/ch4#fig_column_store) 使用 [圖 3-5](/tw/ch3#fig_dwh_schema) 中事實表的擴充套件版本展示了這一原理。
*面向列*(或 *列式*)儲存背後的想法很簡單:不要將一行中的所有值儲存在一起,而是將每 *列* 中的所有值儲存在一起 [^56]。如果每列單獨儲存,查詢只需要讀取和解析該查詢中使用的那些列,這可以節省大量工作。[圖 4-7](#fig_column_store) 使用 [圖 3-5](/tw/ch3#fig_dwh_schema) 中事實表的擴充套件版本展示了這一原理。
--------
@ -412,13 +412,13 @@ GROUP BY
除了只從磁碟載入查詢所需的那些列之外,我們還可以透過壓縮資料進一步減少對磁碟吞吐量和網路頻寬的需求。幸運的是,面向列的儲存通常非常適合壓縮。
看看 [圖 4-7](/tw/ch4#fig_column_store) 中每列的值序列:它們看起來經常重複,這是壓縮的良好跡象。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是 *點陣圖編碼*,如 [圖 4-8](/tw/ch4#fig_bitmap_index) 所示。
看看 [圖 4-7](#fig_column_store) 中每列的值序列:它們看起來經常重複,這是壓縮的良好跡象。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是 *點陣圖編碼*,如 [圖 4-8](#fig_bitmap_index) 所示。
{{< figure src="/fig/ddia_0408.png" id="fig_bitmap_index" caption="圖 4-8. 單列的壓縮、點陣圖索引儲存。" class="w-full my-4" >}}
通常,列中不同值的數量與行數相比很小(例如,零售商可能有數十億條銷售交易,但只有 100,000 種不同的產品)。我們現在可以將具有 *n* 個不同值的列轉換為 *n* 個單獨的點陣圖:每個不同值一個位圖,每行一位。如果該行具有該值,則該位為 1否則為 0。
一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 *稀疏* 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](/tw/ch4#fig_bitmap_index) 底部所示。諸如 *咆哮點陣圖**roaring bitmaps*)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。
一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 *稀疏* 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](#fig_bitmap_index) 底部所示。諸如 *咆哮點陣圖**roaring bitmaps*)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。
像這樣的點陣圖索引非常適合資料倉庫中常見的查詢型別。例如:
@ -445,9 +445,9 @@ GROUP BY
相反,資料需要一次排序整行,即使它是按列儲存的。資料庫管理員可以使用他們對常見查詢的瞭解來選擇表應按哪些列排序。例如,如果查詢經常針對日期範圍(例如上個月),則將 `date_key` 作為第一個排序鍵可能是有意義的。然後查詢可以只掃描上個月的行,這將比掃描所有行快得多。
第二列可以確定在第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是 [圖 4-7](/tw/ch4#fig_column_store) 中的第一個排序鍵,那麼 `product_sk` 作為第二個排序鍵可能是有意義的,這樣同一天同一產品的所有銷售都在儲存中分組在一起。這將有助於需要在某個日期範圍內按產品分組或過濾銷售的查詢。
第二列可以確定在第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是 [圖 4-7](#fig_column_store) 中的第一個排序鍵,那麼 `product_sk` 作為第二個排序鍵可能是有意義的,這樣同一天同一產品的所有銷售都在儲存中分組在一起。這將有助於需要在某個日期範圍內按產品分組或過濾銷售的查詢。
排序順序的另一個優點是它可以幫助壓縮列。如果主排序列沒有許多不同的值,那麼排序後,它將有很長的序列,其中相同的值在一行中重複多次。簡單的遊程編碼,就像我們在 [圖 4-8](/tw/ch4#fig_bitmap_index) 中用於點陣圖的那樣,可以將該列壓縮到幾千位元組 —— 即使表有數十億行。
排序順序的另一個優點是它可以幫助壓縮列。如果主排序列沒有許多不同的值,那麼排序後,它將有很長的序列,其中相同的值在一行中重複多次。簡單的遊程編碼,就像我們在 [圖 4-8](#fig_bitmap_index) 中用於點陣圖的那樣,可以將該列壓縮到幾千位元組 —— 即使表有數十億行。
該壓縮效果在第一個排序鍵上最強。第二和第三個排序鍵將更加混亂,因此不會有如此長的重複值執行。排序優先順序較低的列基本上以隨機順序出現,因此它們可能不會壓縮得那麼好。但是,讓前幾列排序仍然是整體上的勝利。
@ -476,7 +476,7 @@ GROUP BY
向量化處理
: 查詢被解釋,而不是編譯,但透過批次處理列中的許多值而不是逐行迭代來提高速度。一組固定的預定義運算元內建在資料庫中;我們可以向它們傳遞引數並獲得一批結果 [^50] [^75]。
例如,我們可以將 `product_sk` 列和"香蕉"的 ID 傳遞給相等運算元,並獲得一個位圖(輸入列中每個值一位,如果是香蕉則為 1然後我們可以將 `store_sk` 列和感興趣商店的 ID 傳遞給相同的相等運算元,並獲得另一個位圖;然後我們可以將兩個點陣圖傳遞給"按位 AND"運算元,如 [圖 4-9](/tw/ch4#fig_bitmap_and) 所示。結果將是一個位圖,包含特定商店中所有香蕉銷售的 1。
例如,我們可以將 `product_sk` 列和"香蕉"的 ID 傳遞給相等運算元,並獲得一個位圖(輸入列中每個值一位,如果是香蕉則為 1然後我們可以將 `store_sk` 列和感興趣商店的 ID 傳遞給相同的相等運算元,並獲得另一個位圖;然後我們可以將兩個點陣圖傳遞給"按位 AND"運算元,如 [圖 4-9](#fig_bitmap_and) 所示。結果將是一個位圖,包含特定商店中所有香蕉銷售的 1。
{{< figure src="/fig/ddia_0409.png" id="fig_bitmap_and" caption="圖 4-9. 兩個點陣圖之間的按位 AND 適合向量化。" class="w-full my-4" >}}
@ -493,11 +493,11 @@ GROUP BY
當基礎資料更改時,物化檢視需要相應更新。一些資料庫可以自動執行此操作,還有像 Materialize 這樣專門從事物化檢視維護的系統 [^81]。執行此類更新意味著寫入時需要更多工作,但物化檢視可以改善在重複需要執行相同查詢的工作負載中的讀取效能。
*物化聚合* 是一種可以在資料倉庫中有用的物化檢視型別。如前所述,資料倉庫查詢通常涉及聚合函式,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果許多不同的查詢使用相同的聚合,每次都處理原始資料可能會很浪費。為什麼不快取查詢最常使用的一些計數或總和?*資料立方體**OLAP 立方體*)透過建立按不同維度分組的聚合網格來做到這一點 [^82]。[圖 4-10](/tw/ch4#fig_data_cube) 顯示了一個示例。
*物化聚合* 是一種可以在資料倉庫中有用的物化檢視型別。如前所述,資料倉庫查詢通常涉及聚合函式,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果許多不同的查詢使用相同的聚合,每次都處理原始資料可能會很浪費。為什麼不快取查詢最常使用的一些計數或總和?*資料立方體**OLAP 立方體*)透過建立按不同維度分組的聚合網格來做到這一點 [^82]。[圖 4-10](#fig_data_cube) 顯示了一個示例。
{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="圖 4-10. 資料立方體的兩個維度,透過求和聚合資料。" class="w-full my-4" >}}
現在假設每個事實只有兩個維度表的外部索引鍵 —— 在 [圖 4-10](/tw/ch4#fig_data_cube) 中,這些是 `date_key``product_sk`。你現在可以繪製一個二維表,日期沿著一個軸,產品沿著另一個軸。每個單元格包含具有該日期-產品組合的所有事實的屬性(例如 `net_price`)的聚合(例如 `SUM`)。然後,你可以沿著每行或列應用相同的聚合,並獲得已減少一個維度的摘要(不管日期的產品銷售,或不管產品的日期銷售)。
現在假設每個事實只有兩個維度表的外部索引鍵 —— 在 [圖 4-10](#fig_data_cube) 中,這些是 `date_key``product_sk`。你現在可以繪製一個二維表,日期沿著一個軸,產品沿著另一個軸。每個單元格包含具有該日期-產品組合的所有事實的屬性(例如 `net_price`)的聚合(例如 `SUM`)。然後,你可以沿著每行或列應用相同的聚合,並獲得已減少一個維度的摘要(不管日期的產品銷售,或不管產品的日期銷售)。
一般來說,事實通常有兩個以上的維度。在 [圖 3-5](/tw/ch3#fig_dwh_schema) 中有五個維度:日期、產品、商店、促銷和客戶。很難想象五維超立方體會是什麼樣子,但原理保持不變:每個單元格包含特定日期-產品-商店-促銷-客戶組合的銷售。然後可以沿著每個維度重複彙總這些值。
@ -531,9 +531,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
然而,在其核心,你可以將全文檢索視為另一種多維查詢:在這種情況下,可能出現在文字中的每個單詞(*詞項*)是一個維度。包含詞項 *x* 的文件在維度 *x* 中的值為 1不包含 *x* 的文件的值為 0。搜尋提到“紅蘋果”的文件意味著查詢在 *紅* 維度中查詢 1同時在 *蘋果* 維度中查詢 1。維度數量可能因此非常大。
許多搜尋引擎用來回答此類查詢的資料結構稱為 *倒排索引*。這是一個鍵值結構,其中鍵是詞項,值是包含該詞項的所有文件的 ID 列表(*倒排列表*)。如果文件 ID 是順序數字,倒排列表也可以表示為稀疏點陣圖,如 [圖 4-8](/tw/ch4#fig_bitmap_index):詞項 *x* 的點陣圖中的第 *n* 位是 1如果 ID 為 *n* 的文件包含詞項 *x* [^89]。
許多搜尋引擎用來回答此類查詢的資料結構稱為 *倒排索引*。這是一個鍵值結構,其中鍵是詞項,值是包含該詞項的所有文件的 ID 列表(*倒排列表*)。如果文件 ID 是順序數字,倒排列表也可以表示為稀疏點陣圖,如 [圖 4-8](#fig_bitmap_index):詞項 *x* 的點陣圖中的第 *n* 位是 1如果 ID 為 *n* 的文件包含詞項 *x* [^89]。
查詢包含詞項 *x**y* 的所有文件現在類似於搜尋匹配兩個條件的行的向量化資料倉庫查詢([圖 4-9](/tw/ch4#fig_bitmap_and)):載入詞項 *x**y* 的兩個點陣圖並計算它們的按位 AND。即使點陣圖是遊程編碼的這也可以非常高效地完成。
查詢包含詞項 *x**y* 的所有文件現在類似於搜尋匹配兩個條件的行的向量化資料倉庫查詢([圖 4-9](#fig_bitmap_and)):載入詞項 *x**y* 的兩個點陣圖並計算它們的按位 AND。即使點陣圖是遊程編碼的這也可以非常高效地完成。
例如Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就是這樣工作的 [^90]。它將詞項到倒排列表的對映儲存在類似 SSTable 的排序檔案中,這些檔案使用我們在本章前面看到的相同日誌結構方法在後臺合併 [^91]。PostgreSQL 的 GIN 索引型別也使用倒排列表來支援全文檢索和 JSON 文件內的索引 [^92] [^93]。
@ -551,7 +551,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
--------
> [!NOTE]
> 我們在 ["查詢執行:編譯與向量化"](/tw/ch4#sec_storage_vectorized) 中看到了術語 *向量化處理*。語義搜尋中的向量有不同的含義。在向量化處理中,向量指的是可以用特別最佳化的程式碼處理的一批位。在嵌入模型中,向量是表示多維空間中位置的浮點數列表。
> 我們在 ["查詢執行:編譯與向量化"](#sec_storage_vectorized) 中看到了術語 *向量化處理*。語義搜尋中的向量有不同的含義。在向量化處理中,向量指的是可以用特別最佳化的程式碼處理的一批位。在嵌入模型中,向量是表示多維空間中位置的浮點數列表。
--------
@ -572,7 +572,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
: 向量空間被聚類為向量的分割槽(稱為 *質心*以減少必須比較的向量數量。IVF 索引比平面索引更快,但只能給出近似結果:即使查詢和文件彼此接近,它們也可能落入不同的分割槽。對 IVF 索引的查詢首先定義 *探針*,這只是要檢查的分割槽數。使用更多探針的查詢將更準確,但會更慢,因為必須比較更多向量。
分層可導航小世界HNSW
: HNSW 索引維護向量空間的多個層,如 [圖 4-11](/tw/ch4#fig_vector_hnsw) 所示。每一層都表示為一個圖,其中節點表示向量,邊表示與附近向量的接近度。查詢首先在最頂層定位最近的向量,該層具有少量節點。然後查詢移動到下面一層的同一節點,並跟隨該層中的邊,該層連線更密集,尋找更接近查詢向量的向量。該過程繼續直到到達最後一層。與 IVF 索引一樣HNSW 索引是近似的。
: HNSW 索引維護向量空間的多個層,如 [圖 4-11](#fig_vector_hnsw) 所示。每一層都表示為一個圖,其中節點表示向量,邊表示與附近向量的接近度。查詢首先在最頂層定位最近的向量,該層具有少量節點。然後查詢移動到下面一層的同一節點,並跟隨該層中的邊,該層連線更密集,尋找更接近查詢向量的向量。該過程繼續直到到達最後一層。與 IVF 索引一樣HNSW 索引是近似的。
{{< figure src="/fig/ddia_0411.png" id="fig_vector_hnsw" caption="圖 4-11. 在 HNSW 索引中搜索最接近給定查詢向量的資料庫條目。" class="w-full my-4" >}}

View file

@ -34,7 +34,7 @@ breadcrumbs: false
向後相容性通常不難實現:作為新程式碼的作者,你知道舊程式碼寫入的資料格式,因此可以顯式地處理它(如有必要,只需保留舊程式碼來讀取舊資料)。向前相容性可能更棘手,因為它需要舊程式碼忽略新版本程式碼新增的部分。
向前相容性的另一個挑戰如 [圖 5-1](/tw/ch5#fig_encoding_preserve_field) 所示。假設你向記錄模式添加了一個欄位,新程式碼建立了包含該新欄位的記錄並將其儲存在資料庫中。隨後,舊版本的程式碼(尚不知道新欄位)讀取記錄,更新它,然後寫回。在這種情況下,理想的行為通常是舊程式碼保持新欄位不變,即使它無法解釋。但是,如果記錄被解碼為不顯式保留未知欄位的模型物件,資料可能會丟失,如 [圖 5-1](/tw/ch5#fig_encoding_preserve_field) 所示。
向前相容性的另一個挑戰如 [圖 5-1](#fig_encoding_preserve_field) 所示。假設你向記錄模式添加了一個欄位,新程式碼建立了包含該新欄位的記錄並將其儲存在資料庫中。隨後,舊版本的程式碼(尚不知道新欄位)讀取記錄,更新它,然後寫回。在這種情況下,理想的行為通常是舊程式碼保持新欄位不變,即使它無法解釋。但是,如果記錄被解碼為不顯式保留未知欄位的模型物件,資料可能會丟失,如 [圖 5-1](#fig_encoding_preserve_field) 所示。
{{< figure src="/fig/ddia_0501.png" id="fig_encoding_preserve_field" caption="圖 5-1. 當舊版本的應用程式更新之前由新版本應用程式寫入的資料時,如果不小心,資料可能會丟失。" class="w-full my-4" >}}
@ -91,13 +91,13 @@ JSON、XML 和 CSV 是文字格式,因此在某種程度上是人類可讀的
#### JSON 模式 {#json-schema}
JSON 模式已被廣泛採用,作為系統間交換或寫入儲存時對資料建模的一種方式。你會在 Web 服務中找到 JSON 模式(參見 ["Web 服務"](/tw/ch5#sec_web_services))作為 OpenAPI Web 服務規範的一部分,在模式登錄檔中如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry以及在資料庫中如 PostgreSQL 的 pg_jsonschema 驗證器擴充套件和 MongoDB 的 `$jsonSchema` 驗證器語法。
JSON 模式已被廣泛採用,作為系統間交換或寫入儲存時對資料建模的一種方式。你會在 Web 服務中找到 JSON 模式(參見 ["Web 服務"](#sec_web_services))作為 OpenAPI Web 服務規範的一部分,在模式登錄檔中如 Confluent 的 Schema Registry 和 Red Hat 的 Apicurio Registry以及在資料庫中如 PostgreSQL 的 pg_jsonschema 驗證器擴充套件和 MongoDB 的 `$jsonSchema` 驗證器語法。
JSON 模式規範提供了許多功能。模式包括標準原始型別,包括字串、數字、整數、物件、陣列、布林值或空值。但 JSON 模式還提供了一個單獨的驗證規範,允許開發人員在欄位上疊加約束。例如,`port` 欄位可能具有最小值 1 和最大值 65535。
JSON 模式可以具有開放或封閉的內容模型。開放內容模型允許模式中未定義的任何欄位以任何資料型別存在而封閉內容模型只允許顯式定義的欄位。JSON 模式中的開放內容模型在 `additionalProperties` 設定為 `true` 時啟用這是預設值。因此JSON 模式通常是對 *不允許* 內容的定義(即,任何已定義欄位上的無效值),而不是對模式中 *允許* 內容的定義。
開放內容模型功能強大,但可能很複雜。例如,假設你想定義一個從整數(如 ID到字串的對映。JSON 沒有對映或字典型別,只有一個可以包含字串鍵和任何型別值的"物件"型別。然後,你可以使用 JSON 模式約束此型別,使鍵只能包含數字,值只能是字串,使用 `patternProperties``additionalProperties`,如 [示例 5-1](/tw/ch5#fig_encoding_json_schema) 所示。
開放內容模型功能強大,但可能很複雜。例如,假設你想定義一個從整數(如 ID到字串的對映。JSON 沒有對映或字典型別,只有一個可以包含字串鍵和任何型別值的"物件"型別。然後,你可以使用 JSON 模式約束此型別,使鍵只能包含數字,值只能是字串,使用 `patternProperties``additionalProperties`,如 [示例 5-1](#fig_encoding_json_schema) 所示。
{{< figure id="fig_encoding_json_schema" title="示例 5-1. 具有整數鍵和字串值的示例 JSON 模式。整數鍵表示為僅包含整數的字串,因為 JSON 模式要求所有鍵都是字串。" class="w-full my-4" >}}
@ -121,7 +121,7 @@ JSON 模式可以具有開放或封閉的內容模型。開放內容模型允許
JSON 比 XML 更簡潔,但與二進位制格式相比,兩者仍然使用大量空間。這一觀察導致了大量 JSON 二進位制編碼MessagePack、CBOR、BSON、BJSON、UBJSON、BISON、Hessian 和 Smile 等等)和 XML 二進位制編碼(例如 WBXML 和 Fast Infoset的發展。這些格式已在各種利基市場中被採用因為它們更緊湊有時解析速度更快但它們都沒有像 JSON 和 XML 的文字版本那樣被廣泛採用 [^12]。
其中一些格式擴充套件了資料型別集(例如,區分整數和浮點數,或新增對二進位制字串的支援),但除此之外,它們保持 JSON/XML 資料模型不變。特別是,由於它們不規定模式,因此需要在編碼資料中包含所有物件欄位名稱。也就是說,在 [示例 5-2](/tw/ch5#fig_encoding_json) 中的 JSON 文件的二進位制編碼中,它們需要在某處包含字串 `userName`、`favoriteNumber` 和 `interests`
其中一些格式擴充套件了資料型別集(例如,區分整數和浮點數,或新增對二進位制字串的支援),但除此之外,它們保持 JSON/XML 資料模型不變。特別是,由於它們不規定模式,因此需要在編碼資料中包含所有物件欄位名稱。也就是說,在 [示例 5-2](#fig_encoding_json) 中的 JSON 文件的二進位制編碼中,它們需要在某處包含字串 `userName`、`favoriteNumber` 和 `interests`
{{< figure id="fig_encoding_json" title="示例 5-2. 本章中我們將以幾種二進位制格式編碼的示例記錄" class="w-full my-4" >}}
@ -133,7 +133,7 @@ JSON 比 XML 更簡潔,但與二進位制格式相比,兩者仍然使用大
}
```
讓我們看一個 MessagePack 的例子,它是 JSON 的二進位制編碼。[圖 5-2](/tw/ch5#fig_encoding_messagepack) 顯示了如果你使用 MessagePack 編碼 [示例 5-2](/tw/ch5#fig_encoding_json) 中的 JSON 文件所得到的位元組序列。前幾個位元組如下:
讓我們看一個 MessagePack 的例子,它是 JSON 的二進位制編碼。[圖 5-2](#fig_encoding_messagepack) 顯示了如果你使用 MessagePack 編碼 [示例 5-2](#fig_encoding_json) 中的 JSON 文件所得到的位元組序列。前幾個位元組如下:
1. 第一個位元組 `0x83` 表示接下來是一個物件(前四位 = `0x80`),有三個欄位(後四位 = `0x03`)。(如果你想知道如果物件有超過 15 個欄位會發生什麼,以至於欄位數無法裝入四位,那麼它會獲得不同的型別指示符,欄位數會以兩個或四個位元組編碼。)
2. 第二個位元組 `0xa8` 表示接下來是一個字串(前四位 = `0xa0`),長度為八個位元組(後四位 = `0x08`)。
@ -151,7 +151,7 @@ JSON 比 XML 更簡潔,但與二進位制格式相比,兩者仍然使用大
Protocol Buffers (protobuf) 是 Google 開發的二進位制編碼庫。它類似於 Apache Thrift後者最初由 Facebook 開發 [^13];本節關於 Protocol Buffers 的大部分內容也適用於 Thrift。
Protocol Buffers 需要為任何編碼的資料提供模式。要在 Protocol Buffers 中編碼 [示例 5-2](/tw/ch5#fig_encoding_json) 中的資料,你需要像這樣在 Protocol Buffers 介面定義語言IDL中描述模式
Protocol Buffers 需要為任何編碼的資料提供模式。要在 Protocol Buffers 中編碼 [示例 5-2](#fig_encoding_json) 中的資料,你需要像這樣在 Protocol Buffers 介面定義語言IDL中描述模式
```protobuf
syntax = "proto3";
@ -163,14 +163,14 @@ message Person {
}
```
Protocol Buffers 附帶了一個程式碼生成工具,它接受像這裡顯示的模式定義,並生成以各種程式語言實現該模式的類。你的應用程式程式碼可以呼叫此生成的程式碼來編碼或解碼模式的記錄。使用 Protocol Buffers 編碼器編碼 [示例 5-2](/tw/ch5#fig_encoding_json) 需要 33 位元組,如 [圖 5-3](/tw/ch5#fig_encoding_protobuf) 所示 [^14]。
Protocol Buffers 附帶了一個程式碼生成工具,它接受像這裡顯示的模式定義,並生成以各種程式語言實現該模式的類。你的應用程式程式碼可以呼叫此生成的程式碼來編碼或解碼模式的記錄。使用 Protocol Buffers 編碼器編碼 [示例 5-2](#fig_encoding_json) 需要 33 位元組,如 [圖 5-3](#fig_encoding_protobuf) 所示 [^14]。
{{< figure src="/fig/ddia_0503.png" id="fig_encoding_protobuf" caption="圖 5-3. 使用 Protocol Buffers 編碼的示例記錄。" class="w-full my-4" >}}
與 [圖 5-2](/tw/ch5#fig_encoding_messagepack) 類似,每個欄位都有一個型別註釋(指示它是字串、整數等)以及必要時的長度指示(例如字串的長度)。資料中出現的字串("Martin"、"daydreaming"、"hacking")也編碼為 ASCII準確地說是 UTF-8與之前類似。
與 [圖 5-2](#fig_encoding_messagepack) 類似,每個欄位都有一個型別註釋(指示它是字串、整數等)以及必要時的長度指示(例如字串的長度)。資料中出現的字串("Martin"、"daydreaming"、"hacking")也編碼為 ASCII準確地說是 UTF-8與之前類似。
與 [圖 5-2](/tw/ch5#fig_encoding_messagepack) 相比的最大區別是沒有欄位名(`userName`、`favoriteNumber`、`interests`)。相反,編碼資料包含 *欄位標籤*,即數字(`1`、`2` 和 `3`)。這些是模式定義中出現的數字。欄位標籤就像欄位的別名——它們是說明我們正在談論哪個欄位的緊湊方式,而無需拼寫欄位名。
與 [圖 5-2](#fig_encoding_messagepack) 相比的最大區別是沒有欄位名(`userName`、`favoriteNumber`、`interests`)。相反,編碼資料包含 *欄位標籤*,即數字(`1`、`2` 和 `3`)。這些是模式定義中出現的數字。欄位標籤就像欄位的別名——它們是說明我們正在談論哪個欄位的緊湊方式,而無需拼寫欄位名。
如你所見Protocol Buffers 透過將欄位型別和標籤號打包到單個位元組中來節省更多空間。它使用可變長度整數:數字 1337 編碼為兩個位元組,每個位元組的最高位用於指示是否還有更多位元組要來。這意味著 -64 到 63 之間的數字以一個位元組編碼,-8192 到 8191 之間的數字以兩個位元組編碼,等等。更大的數字使用更多位元組。
@ -182,7 +182,7 @@ Protocol Buffers 沒有顯式的列表或陣列資料型別。相反,`interest
從示例中可以看出,編碼記錄只是其編碼欄位的串聯。每個欄位由其標籤號(示例模式中的數字 `1`、`2`、`3`)標識,並帶有資料型別註釋(例如字串或整數)。如果未設定欄位值,則它會從編碼記錄中省略。由此可以看出,欄位標籤對編碼資料的含義至關重要。你可以更改模式中欄位的名稱,因為編碼資料從不引用欄位名,但你不能更改欄位的標籤,因為這會使所有現有的編碼資料無效。
你可以向模式新增新欄位,前提是你為每個欄位提供新的標籤號。如果舊程式碼(不知道你新增的新標籤號)嘗試讀取由新程式碼寫入的資料(包括具有它不識別的標籤號的新欄位),它可以簡單地忽略該欄位。資料型別註釋允許解析器確定需要跳過多少位元組,並保留未知欄位以避免 [圖 5-1](/tw/ch5#fig_encoding_preserve_field) 中的問題。這保持了向前相容性:舊程式碼可以讀取由新程式碼編寫的記錄。
你可以向模式新增新欄位,前提是你為每個欄位提供新的標籤號。如果舊程式碼(不知道你新增的新標籤號)嘗試讀取由新程式碼寫入的資料(包括具有它不識別的標籤號的新欄位),它可以簡單地忽略該欄位。資料型別註釋允許解析器確定需要跳過多少位元組,並保留未知欄位以避免 [圖 5-1](#fig_encoding_preserve_field) 中的問題。這保持了向前相容性:舊程式碼可以讀取由新程式碼編寫的記錄。
向後相容性呢?只要每個欄位都有唯一的標籤號,新程式碼總是可以讀取舊資料,因為標籤號仍然具有相同的含義。如果在新模式中添加了欄位,而你讀取尚未包含該欄位的舊資料,則它將填充預設值(例如,如果欄位型別為字串,則為空字串;如果是數字,則為零)。
@ -220,7 +220,7 @@ record Person {
}
```
首先,請注意模式中沒有標籤號。如果我們使用此模式編碼示例記錄([示例 5-2](/tw/ch5#fig_encoding_json)Avro 二進位制編碼只有 32 位元組長——是我們看到的所有編碼中最緊湊的。編碼位元組序列的分解如 [圖 5-4](/tw/ch5#fig_encoding_avro) 所示。
首先,請注意模式中沒有標籤號。如果我們使用此模式編碼示例記錄([示例 5-2](#fig_encoding_json)Avro 二進位制編碼只有 32 位元組長——是我們看到的所有編碼中最緊湊的。編碼位元組序列的分解如 [圖 5-4](#fig_encoding_avro) 所示。
如果你檢查位元組序列,你會發現沒有任何東西來標識欄位或其資料型別。編碼只是由串聯在一起的值組成。字串只是一個長度字首,後跟 UTF-8 位元組,但編碼資料中沒有任何內容告訴你它是字串。它也可能是整數,或完全是其他東西。整數使用可變長度編碼進行編碼。
@ -235,11 +235,11 @@ record Person {
當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式對資料進行編碼——例如,該模式可能被編譯到應用程式中。這被稱為 *寫入者模式*
當應用程式想要解碼一些資料(從檔案或資料庫讀取,從網路接收等)時,它使用兩個模式:與用於編碼相同的寫入者模式,以及 *讀取者模式*,後者可能不同。這在 [圖 5-5](/tw/ch5#fig_encoding_avro_schemas) 中說明。讀取者模式定義了應用程式程式碼期望的每條記錄的欄位及其型別。
當應用程式想要解碼一些資料(從檔案或資料庫讀取,從網路接收等)時,它使用兩個模式:與用於編碼相同的寫入者模式,以及 *讀取者模式*,後者可能不同。這在 [圖 5-5](#fig_encoding_avro_schemas) 中說明。讀取者模式定義了應用程式程式碼期望的每條記錄的欄位及其型別。
{{< figure src="/fig/ddia_0505.png" id="fig_encoding_avro_schemas" caption="圖 5-5. 在 Protocol Buffers 中,編碼和解碼可以使用不同版本的模式。在 Avro 中,解碼使用兩個模式:寫入者模式必須與用於編碼的模式相同,但讀取者模式可以是較舊或較新的版本。" class="w-full my-4" >}}
如果讀取者模式和寫入者模式相同解碼很容易。如果它們不同Avro 透過並排檢視寫入者模式和讀取者模式並將資料從寫入者模式轉換為讀取者模式來解決差異。Avro 規範 [^16] [^17] 準確定義了此解析的工作方式,並在 [圖 5-6](/tw/ch5#fig_encoding_avro_resolution) 中進行了說明。
如果讀取者模式和寫入者模式相同解碼很容易。如果它們不同Avro 透過並排檢視寫入者模式和讀取者模式並將資料從寫入者模式轉換為讀取者模式來解決差異。Avro 規範 [^16] [^17] 準確定義了此解析的工作方式,並在 [圖 5-6](#fig_encoding_avro_resolution) 中進行了說明。
例如,如果寫入者模式和讀取者模式的欄位順序不同,這沒有問題,因為模式解析透過欄位名匹配欄位。如果讀取資料的程式碼遇到出現在寫入者模式中但不在讀取者模式中的欄位,它將被忽略。如果讀取資料的程式碼期望某個欄位,但寫入者模式不包含該名稱的欄位,則使用讀取者模式中宣告的預設值填充它。
@ -272,7 +272,7 @@ record Person {
例如Apache Kafka 的 Confluent 模式登錄檔 [^19] 和 LinkedIn 的 Espresso [^20] 就是這樣工作的。
透過網路連線傳送記錄
: 當兩個程序透過雙向網路連線進行通訊時它們可以在連線設定時協商模式版本然後在連線的生命週期內使用該模式。Avro RPC 協議(參見 ["流經服務的資料流REST 與 RPC"](/tw/ch5#sec_encoding_dataflow_rpc))就是這樣工作的。
: 當兩個程序透過雙向網路連線進行通訊時它們可以在連線設定時協商模式版本然後在連線的生命週期內使用該模式。Avro RPC 協議(參見 ["流經服務的資料流REST 與 RPC"](#sec_encoding_dataflow_rpc))就是這樣工作的。
無論如何,模式版本資料庫都是有用的,因為它充當文件並讓你有機會檢查模式相容性 [^21]。作為版本號,你可以使用簡單的遞增整數,或者可以使用模式的雜湊值。
@ -311,10 +311,10 @@ record Person {
這是一個相當抽象的想法——資料可以透過許多方式從一個程序流向另一個程序。誰編碼資料,誰解碼資料?在本章的其餘部分,我們將探討資料在程序之間流動的一些最常見方式:
* 透過資料庫(參見 ["流經資料庫的資料流"](/tw/ch5#sec_encoding_dataflow_db)
* 透過服務呼叫(參見 ["流經服務的資料流REST 與 RPC"](/tw/ch5#sec_encoding_dataflow_rpc)
* 透過工作流引擎(參見 ["持久化執行與工作流"](/tw/ch5#sec_encoding_dataflow_workflows)
* 透過非同步訊息(參見 ["事件驅動的架構"](/tw/ch5#sec_encoding_dataflow_msg)
* 透過資料庫(參見 ["流經資料庫的資料流"](#sec_encoding_dataflow_db)
* 透過服務呼叫(參見 ["流經服務的資料流REST 與 RPC"](#sec_encoding_dataflow_rpc)
* 透過工作流引擎(參見 ["持久化執行與工作流"](#sec_encoding_dataflow_workflows)
* 透過非同步訊息(參見 ["事件驅動的架構"](#sec_encoding_dataflow_msg)
### 流經資料庫的資料流 {#sec_encoding_dataflow_db}
@ -368,7 +368,7 @@ Web 瀏覽器不是唯一型別的客戶端。例如,在移動裝置和桌面
需要呼叫 Web 服務 API 的程式碼必須知道要查詢哪個 HTTP 端點,以及傳送什麼資料格式以及預期的響應。即使服務採用 RESTful 設計原則客戶端也需要以某種方式找出這些詳細資訊。服務開發人員通常使用介面定義語言IDL來定義和記錄其服務的 API 端點和資料模型,並隨著時間的推移演化它們。然後,其他開發人員可以使用服務定義來確定如何查詢服務。兩種最流行的服務 IDL 是 OpenAPI也稱為 Swagger [^32])和 gRPC。OpenAPI 用於傳送和接收 JSON 資料的 Web 服務,而 gRPC 服務傳送和接收 Protocol Buffers。
開發人員通常用 JSON 或 YAML 編寫 OpenAPI 服務定義;參見 [示例 5-3](/tw/ch5#fig_open_api_def)。服務定義允許開發人員定義服務端點、文件、版本、資料模型等。gRPC 定義看起來類似,但使用 Protocol Buffers 服務定義進行定義。
開發人員通常用 JSON 或 YAML 編寫 OpenAPI 服務定義;參見 [示例 5-3](#fig_open_api_def)。服務定義允許開發人員定義服務端點、文件、版本、資料模型等。gRPC 定義看起來類似,但使用 Protocol Buffers 服務定義進行定義。
{{< figure id="fig_open_api_def" title="示例 5-3. YAML 中的示例 OpenAPI 服務定義" class="w-full my-4" >}}
@ -396,9 +396,9 @@ paths:
example: Pong!
```
即使採用了設計理念和 IDL開發人員仍必須編寫實現其服務 API 呼叫的程式碼。通常採用服務框架來簡化這項工作。Spring Boot、FastAPI 和 gRPC 等服務框架允許開發人員為每個 API 端點編寫業務邏輯,而框架程式碼處理路由、指標、快取、身份驗證等。[示例 5-4](/tw/ch5#fig_fastapi_def) 顯示了 [示例 5-3](/tw/ch5#fig_open_api_def) 中定義的服務的示例 Python 實現。
即使採用了設計理念和 IDL開發人員仍必須編寫實現其服務 API 呼叫的程式碼。通常採用服務框架來簡化這項工作。Spring Boot、FastAPI 和 gRPC 等服務框架允許開發人員為每個 API 端點編寫業務邏輯,而框架程式碼處理路由、指標、快取、身份驗證等。[示例 5-4](#fig_fastapi_def) 顯示了 [示例 5-3](#fig_open_api_def) 中定義的服務的示例 Python 實現。
{{< figure id="fig_fastapi_def" title="示例 5-4. 實現 [示例 5-3](/tw/ch5#fig_open_api_def) 中定義的示例 FastAPI 服務" class="w-full my-4" >}}
{{< figure id="fig_fastapi_def" title="示例 5-4. 實現 [示例 5-3](#fig_open_api_def) 中定義的示例 FastAPI 服務" class="w-full my-4" >}}
```python
from fastapi import FastAPI
@ -428,7 +428,7 @@ Web 服務只是透過網路進行 API 請求的一長串技術的最新化身
* 如果你重試失敗的網路請求,可能會發生前一個請求實際上已經成功,只是響應丟失了。在這種情況下,重試將導致操作執行多次,除非你在協議中構建去重機制(*冪等性*[^40]。本地函式呼叫沒有這個問題。(我們在 [“冪等性”](/tw/ch12#sec_stream_idempotence) 中更詳細地討論冪等性。)
* 每次呼叫本地函式時,通常需要大約相同的時間來執行。網路請求比函式呼叫慢得多,其延遲也變化很大:在良好的時候,它可能在不到一毫秒內完成,但當網路擁塞或遠端服務過載時,執行完全相同的操作可能需要許多秒。
* 當你呼叫本地函式時,你可以有效地將引用(指標)傳遞給本地記憶體中的物件。當你發出網路請求時,所有這些引數都需要編碼為可以透過網路傳送的位元組序列。如果引數是不可變的原語,如數字或短字串,那沒問題,但對於更大量的資料和可變物件,它很快就會出現問題。
* 客戶端和服務可能以不同的程式語言實現,因此 RPC 框架必須將資料型別從一種語言轉換為另一種語言。這可能會變得很醜陋,因為並非所有語言都具有相同的型別——例如,回想一下 JavaScript 處理大於 2⁵³ 的數字的問題(參見 ["JSON、XML 及其二進位制變體"](/tw/ch5#sec_encoding_json))。單一語言編寫的單個程序中不存在此問題。
* 客戶端和服務可能以不同的程式語言實現,因此 RPC 框架必須將資料型別從一種語言轉換為另一種語言。這可能會變得很醜陋,因為並非所有語言都具有相同的型別——例如,回想一下 JavaScript 處理大於 2⁵³ 的數字的問題(參見 ["JSON、XML 及其二進位制變體"](#sec_encoding_json))。單一語言編寫的單個程序中不存在此問題。
所有這些因素意味著試圖讓遠端服務看起來太像程式語言中的本地物件是沒有意義的因為它是根本不同的東西。REST 的部分吸引力在於它將網路上的狀態傳輸視為與函式呼叫不同的過程。
@ -467,7 +467,7 @@ RPC 方案的向後和向前相容性屬性繼承自它使用的任何編碼:
根據定義,基於服務的架構具有多個服務,這些服務都負責應用程式的不同部分。考慮一個處理信用卡並將資金存入銀行賬戶的支付處理應用程式。該系統可能有不同的服務負責欺詐檢測、信用卡整合、銀行整合等。
在我們的示例中,處理單個付款需要許多服務呼叫。支付處理器服務可能會呼叫欺詐檢測服務以檢查欺詐,呼叫信用卡服務以扣除信用卡費用,並呼叫銀行服務以存入扣除的資金,如 [圖 5-7](/tw/ch5#fig_encoding_workflow) 所示。我們將這一系列步驟稱為 *工作流*,每個步驟稱為 *任務*。工作流通常定義為任務圖。工作流定義可以用通用程式語言、領域特定語言 (DSL) 或標記語言(如業務流程執行語言 (BPEL)[^44] 編寫。
在我們的示例中,處理單個付款需要許多服務呼叫。支付處理器服務可能會呼叫欺詐檢測服務以檢查欺詐,呼叫信用卡服務以扣除信用卡費用,並呼叫銀行服務以存入扣除的資金,如 [圖 5-7](#fig_encoding_workflow) 所示。我們將這一系列步驟稱為 *工作流*,每個步驟稱為 *任務*。工作流通常定義為任務圖。工作流定義可以用通用程式語言、領域特定語言 (DSL) 或標記語言(如業務流程執行語言 (BPEL)[^44] 編寫。
--------
@ -484,15 +484,15 @@ RPC 方案的向後和向前相容性屬性繼承自它使用的任何編碼:
工作流引擎通常由編排器和執行器組成。編排器負責排程要執行的任務,執行器負責執行任務。當工作流被觸發時,執行開始。如果使用者定義了基於時間的排程(例如每小時執行),則編排器會自行觸發工作流。外部源(如 Web 服務)甚至人類也可以觸發工作流執行。一旦觸發,就會呼叫執行器來執行任務。
有許多型別的工作流引擎可以滿足各種各樣的用例。有些,如 Airflow、Dagster 和 Prefect與資料系統整合並編排 ETL 任務。其他的,如 Camunda 和 Orkes為工作流提供圖形標記法如 [圖 5-7](/tw/ch5#fig_encoding_workflow) 中使用的 BPMN以便非工程師可以更輕鬆地定義和執行工作流。還有一些如 Temporal 和 Restate提供 *持久化執行*
有許多型別的工作流引擎可以滿足各種各樣的用例。有些,如 Airflow、Dagster 和 Prefect與資料系統整合並編排 ETL 任務。其他的,如 Camunda 和 Orkes為工作流提供圖形標記法如 [圖 5-7](#fig_encoding_workflow) 中使用的 BPMN以便非工程師可以更輕鬆地定義和執行工作流。還有一些如 Temporal 和 Restate提供 *持久化執行*
#### 持久化執行 {#durable-execution}
持久化執行框架已成為構建需要事務性的基於服務的架構的流行方式。在我們的支付示例中,我們希望每筆付款都恰好處理一次。工作流執行期間的故障可能導致信用卡扣費,但沒有相應的銀行賬戶存款。在基於服務的架構中,我們不能簡單地將兩個任務包裝在資料庫事務中。此外,我們可能正在與我們控制有限的第三方支付閘道器進行互動。
持久化執行框架是為工作流提供 *恰好一次語義* 的一種方式。如果任務失敗,框架將重新執行該任務,但會跳過任務在失敗之前成功完成的任何 RPC 呼叫或狀態更改。相反,框架將假裝進行呼叫,但實際上將返回先前呼叫的結果。這是可能的,因為持久化執行框架將所有 RPC 和狀態更改記錄到持久儲存(如預寫日誌)[^45] [^46]。[示例 5-5](/tw/ch5#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。
持久化執行框架是為工作流提供 *恰好一次語義* 的一種方式。如果任務失敗,框架將重新執行該任務,但會跳過任務在失敗之前成功完成的任何 RPC 呼叫或狀態更改。相反,框架將假裝進行呼叫,但實際上將返回先前呼叫的結果。這是可能的,因為持久化執行框架將所有 RPC 和狀態更改記錄到持久儲存(如預寫日誌)[^45] [^46]。[示例 5-5](#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。
{{< figure id="fig_temporal_workflow" title="示例 5-5. [圖 5-7](/tw/ch5#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定義片段。" class="w-full my-4" >}}
{{< figure id="fig_temporal_workflow" title="示例 5-5. [圖 5-7](#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定義片段。" class="w-full my-4" >}}
```python
@workflow.defn
@ -552,7 +552,7 @@ class PaymentWorkflow:
訊息代理在訊息的永續性方面有所不同。許多將訊息寫入磁碟,以便在訊息代理崩潰或需要重新啟動時不會丟失。與資料庫不同,許多訊息代理在訊息被消費後會自動再次刪除訊息。某些代理可以配置為無限期地儲存訊息,如果你想使用事件溯源,這是必需的(參見 ["事件溯源與 CQRS"](/tw/ch3#sec_datamodels_events))。
如果消費者將訊息重新發布到另一個主題,你可能需要小心保留未知欄位,以防止前面在資料庫上下文中描述的問題([圖 5-1](/tw/ch5#fig_encoding_preserve_field))。
如果消費者將訊息重新發布到另一個主題,你可能需要小心保留未知欄位,以防止前面在資料庫上下文中描述的問題([圖 5-1](#fig_encoding_preserve_field))。
#### 分散式 actor 框架 {#distributed-actor-frameworks}

View file

@ -4,6 +4,8 @@ weight: 206
breadcrumbs: false
---
<a id="ch_replication"></a>
![](/map/ch05.png)
> *可能出錯的東西和“不可能”出錯的東西之間,最大的區別在於:後者一旦出錯,往往幾乎無從下手,也難以修復。*
@ -22,7 +24,7 @@ breadcrumbs: false
複製需要考慮許多權衡:例如,是使用同步還是非同步複製,以及如何處理失敗的副本。這些通常是資料庫中的配置選項,儘管不同資料庫的細節有所不同,但許多不同實現的通用原則是相似的。我們將在本章中討論這些選擇的後果。
資料庫複製是一個古老的話題——自 20 世紀 70 年代研究以來,原理並沒有太大變化 [^1],因為網路的基本約束保持不變。儘管如此古老,像 **最終一致性** 這樣的概念仍然會引起困惑。在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中,我們將更準確地瞭解最終一致性,並討論諸如 **讀己之寫****單調讀** 等保證。
資料庫複製是一個古老的話題——自 20 世紀 70 年代研究以來,原理並沒有太大變化 [^1],因為網路的基本約束保持不變。儘管如此古老,像 **最終一致性** 這樣的概念仍然會引起困惑。在 ["複製延遲的問題"](#sec_replication_lag) 中,我們將更準確地瞭解最終一致性,並討論諸如 **讀己之寫****單調讀** 等保證。
--------
@ -30,7 +32,7 @@ breadcrumbs: false
>
> 你可能會想,如果有了複製,是否還需要備份。答案是肯定的,因為它們有不同的目的:副本會快速將一個節點的寫入反映到其他節點上,但備份儲存資料的舊快照,以便你可以回到過去的時間點。如果你不小心刪除了一些資料,複製並不能幫助你,因為刪除操作也會傳播到副本,所以如果你想恢復被刪除的資料,就需要備份。
>
> 事實上,複製和備份通常是相互補充的。備份有時是設定複製過程的一部分,正如我們將在 ["設定新的副本"](/tw/ch6#sec_replication_new_replica) 中看到的。反過來,歸檔複製日誌可以成為備份過程的一部分。
> 事實上,複製和備份通常是相互補充的。備份有時是設定複製過程的一部分,正如我們將在 ["設定新的副本"](#sec_replication_new_replica) 中看到的。反過來,歸檔複製日誌可以成為備份過程的一部分。
>
> 一些資料庫在內部維護過去狀態的不可變快照,作為一種內部備份。然而,這意味著在與當前狀態相同的儲存介質上保留資料的舊版本。如果你有大量資料,將舊資料的備份儲存在針對不常訪問資料最佳化的物件儲存中可能會更便宜,而只在主儲存中儲存資料庫的當前狀態。
@ -40,7 +42,7 @@ breadcrumbs: false
儲存資料庫副本的每個節點稱為 **副本**。有了多個副本,不可避免地會出現一個問題:我們如何確保所有資料最終都出現在所有副本上?
每次寫入資料庫都需要由每個副本處理;否則,副本將不再包含相同的資料。最常見的解決方案稱為 **基於領導者的複製**、**主備複製** 或 **主動/被動複製**。它的工作原理如下(見 [圖 6-1](/tw/ch6#fig_replication_leader_follower)
每次寫入資料庫都需要由每個副本處理;否則,副本將不再包含相同的資料。最常見的解決方案稱為 **基於領導者的複製**、**主備複製** 或 **主動/被動複製**。它的工作原理如下(見 [圖 6-1](#fig_replication_leader_follower)
1. 其中一個副本被指定為 **領導者**(也稱為 **主庫****源** [^2])。當客戶端想要寫入資料庫時,他們必須將請求傳送給領導者,領導者首先將新資料寫入其本地儲存。
2. 其他副本稱為 **追隨者****只讀副本**、**從庫** 或 **熱備**)。每當領導者將新資料寫入其本地儲存時,它也會將資料變更作為 **複製日誌****變更流** 的一部分發送給所有追隨者。每個追隨者從領導者獲取日誌,並透過按照與領導者處理相同的順序應用所有寫入來相應地更新其本地資料庫副本。
@ -48,7 +50,7 @@ breadcrumbs: false
{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="圖 6-1. 單主複製將所有寫入定向到指定的領導者,該領導者向追隨者傳送變更流。" class="w-full my-4" >}}
如果資料庫是分片的(見 [第 7 章](/tw/ch7#ch_sharding)),每個分片都有一個領導者。不同的分片可能在不同的節點上有其領導者,但每個分片仍必須有一個領導者。在 ["多主複製"](/tw/ch6#sec_replication_multi_leader) 中,我們將討論一種替代模型,其中系統可能同時為同一分片擁有多個領導者。
如果資料庫是分片的(見 [第 7 章](/tw/ch7#ch_sharding)),每個分片都有一個領導者。不同的分片可能在不同的節點上有其領導者,但每個分片仍必須有一個領導者。在 ["多主複製"](#sec_replication_multi_leader) 中,我們將討論一種替代模型,其中系統可能同時為同一分片擁有多個領導者。
單主複製被廣泛使用。它是許多關係資料庫的內建功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性組 [^4]。它也用於一些文件資料庫,如 MongoDB 和 DynamoDB [^5],訊息代理如 Kafka複製塊裝置如 DRBD以及一些網路檔案系統。許多共識演算法如 Raft也基於單個領導者用於 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁佇列(以及其他)中的複製,並在舊領導者失敗時自動選舉新領導者(我們將在 [第 10 章](/tw/ch10#ch_consistency) 中更詳細地討論共識)。
@ -63,11 +65,11 @@ breadcrumbs: false
複製系統的一個重要細節是複製是 **同步** 發生還是 **非同步** 發生。(在關係資料庫中,這通常是一個可配置選項;其他系統通常硬編碼為其中之一。)
想想 [圖 6-1](/tw/ch6#fig_replication_leader_follower) 中發生的情況,一個網站使用者更新他們的個人資料圖片。在某個時間點,客戶端向領導者傳送更新請求;不久之後,領導者收到了它。在某個時間點,領導者將資料變更轉發給追隨者。最終,領導者通知客戶端更新成功。[圖 6-2](/tw/ch6#fig_replication_sync_replication) 顯示了時序可能的工作方式。
想想 [圖 6-1](#fig_replication_leader_follower) 中發生的情況,一個網站使用者更新他們的個人資料圖片。在某個時間點,客戶端向領導者傳送更新請求;不久之後,領導者收到了它。在某個時間點,領導者將資料變更轉發給追隨者。最終,領導者通知客戶端更新成功。[圖 6-2](#fig_replication_sync_replication) 顯示了時序可能的工作方式。
{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="圖 6-2. 基於領導者的複製,帶有一個同步和一個非同步追隨者。" class="w-full my-4" >}}
在 [圖 6-2](/tw/ch6#fig_replication_sync_replication) 的示例中,對追隨者 1 的複製是 **同步的**:領導者等待追隨者 1 確認它已收到寫入,然後才向用戶報告成功,並使寫入對其他客戶端可見。對追隨者 2 的複製是 **非同步的**:領導者傳送訊息,但不等待追隨者的響應。
在 [圖 6-2](#fig_replication_sync_replication) 的示例中,對追隨者 1 的複製是 **同步的**:領導者等待追隨者 1 確認它已收到寫入,然後才向用戶報告成功,並使寫入對其他客戶端可見。對追隨者 2 的複製是 **非同步的**:領導者傳送訊息,但不等待追隨者的響應。
圖中顯示,追隨者 2 處理訊息之前有相當大的延遲。通常,複製相當快:大多數資料庫系統在不到一秒的時間內將變更應用到追隨者。然而,不能保證需要多長時間。在某些情況下,追隨者可能落後領導者幾分鐘或更長時間;例如,如果追隨者正在從故障中恢復,如果系統正在接近最大容量執行,或者如果節點之間存在網路問題。
@ -75,11 +77,11 @@ breadcrumbs: false
因此,將所有追隨者都設為同步是不切實際的:任何一個節點的中斷都會導致整個系統停止。實際上,如果資料庫提供同步複製,通常意味著 **一個** 追隨者是同步的,其他的是非同步的。如果同步追隨者變得不可用或緩慢,非同步追隨者之一將變為同步。這保證了你至少在兩個節點上擁有最新的資料副本:領導者和一個同步追隨者。這種配置有時也稱為 **半同步**
在某些系統中,**多數**(例如,包括領導者在內的 5 個副本中的 3 個)副本被同步更新,其餘少數是非同步的。這是 **仲裁** 的一個例子,我們將在 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition) 中進一步討論。多數仲裁通常用於使用共識協議進行自動領導者選舉的系統中,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中回到這個話題。
在某些系統中,**多數**(例如,包括領導者在內的 5 個副本中的 3 個)副本被同步更新,其餘少數是非同步的。這是 **仲裁** 的一個例子,我們將在 ["讀寫仲裁"](#sec_replication_quorum_condition) 中進一步討論。多數仲裁通常用於使用共識協議進行自動領導者選舉的系統中,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中回到這個話題。
有時,基於領導者的複製被配置為完全非同步。在這種情況下,如果領導者失敗且無法恢復,任何尚未複製到追隨者的寫入都會丟失。這意味著即使已向客戶端確認,寫入也不能保證持久。然而,完全非同步配置的優點是領導者可以繼續處理寫入,即使所有追隨者都已落後。
弱化永續性可能聽起來像是一個糟糕的權衡,但非同步複製仍然被廣泛使用,特別是如果有許多追隨者或者它們在地理上分佈廣泛 [^9]。我們將在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中回到這個問題。
弱化永續性可能聽起來像是一個糟糕的權衡,但非同步複製仍然被廣泛使用,特別是如果有許多追隨者或者它們在地理上分佈廣泛 [^9]。我們將在 ["複製延遲的問題"](#sec_replication_lag) 中回到這個問題。
### 設定新的副本 {#sec_replication_new_replica}
@ -100,6 +102,8 @@ breadcrumbs: false
--------
<a id="sec_replication_object_storage"></a>
> [!TIP] 由物件儲存支援的資料庫
>
> 物件儲存可用於存檔資料之外的更多用途。許多資料庫開始使用物件儲存(如 Amazon Web Services S3、Google Cloud Storage 和 Azure Blob Storage來為即時查詢提供資料。在物件儲存中儲存資料庫資料有許多好處
@ -145,7 +149,7 @@ breadcrumbs: false
* 如果使用非同步複製,新的領導者可能在失敗之前沒有收到來自舊領導者的所有寫入。如果前領導者在選擇了新領導者後重新加入叢集,那些寫入應該怎麼辦?新的領導者可能同時收到了衝突的寫入。最常見的解決方案是簡單地丟棄舊領導者未複製的寫入,這意味著你認為已提交的寫入實際上並不持久。
* 如果資料庫之外的其他儲存系統需要與資料庫內容協調,丟棄寫入尤其危險。例如,在 GitHub 的一次事故中 [^14],一個過時的 MySQL 追隨者被提升為領導者。資料庫使用自增計數器為新行分配主鍵,但由於新領導者的計數器落後於舊領導者,它重用了舊領導者先前分配的一些主鍵。這些主鍵也在 Redis 儲存中使用,因此主鍵的重用導致 MySQL 和 Redis 之間的不一致,這導致一些私人資料被錯誤地披露給錯誤的使用者。
* 在某些故障場景中(見 [第 9 章](/tw/ch9#ch_distributed)),可能會發生兩個節點都認為自己是領導者的情況。這種情況稱為 **腦裂**,這是危險的:如果兩個領導者都接受寫入,並且沒有解決衝突的過程(見 ["多主複製"](/tw/ch6#sec_replication_multi_leader)),資料很可能會丟失或損壞。作為安全措施,一些系統在檢測到兩個領導者時有一種機制來關閉一個節點。然而,如果這種機制設計不當,你最終可能會關閉兩個節點 [^15]。此外,當檢測到腦裂並關閉舊節點時,可能為時已晚,資料已經損壞。
* 在某些故障場景中(見 [第 9 章](/tw/ch9#ch_distributed)),可能會發生兩個節點都認為自己是領導者的情況。這種情況稱為 **腦裂**,這是危險的:如果兩個領導者都接受寫入,並且沒有解決衝突的過程(見 ["多主複製"](#sec_replication_multi_leader)),資料很可能會丟失或損壞。作為安全措施,一些系統在檢測到兩個領導者時有一種機制來關閉一個節點。然而,如果這種機制設計不當,你最終可能會關閉兩個節點 [^15]。此外,當檢測到腦裂並關閉舊節點時,可能為時已晚,資料已經損壞。
* 在宣佈領導者死亡之前,正確的超時是什麼?更長的超時意味著在領導者失敗的情況下恢復時間更長。然而,如果超時太短,可能會有不必要的故障轉移。例如,臨時負載峰值可能導致節點的響應時間增加到超時以上,或者網路故障可能導致資料包延遲。如果系統已經在高負載或網路問題上掙扎,不必要的故障轉移可能會使情況變得更糟,而不是更好。
--------
@ -187,6 +191,8 @@ breadcrumbs: false
這可能看起來像是一個小的實現細節,但它可能會產生很大的操作影響。如果複製協議允許追隨者使用比領導者更新的軟體版本,你可以透過首先升級追隨者然後執行故障轉移以使其中一個升級的節點成為新的領導者來執行資料庫軟體的零停機升級。如果複製協議不允許此版本不匹配(如 WAL 傳輸的情況),此類升級需要停機。
<a id="sec_replication_logical"></a>
#### 邏輯(基於行)日誌複製 {#logical-row-based-log-replication}
另一種選擇是為複製和儲存引擎使用不同的日誌格式,這允許複製日誌與儲存引擎內部解耦。這種複製日誌稱為 **邏輯日誌**,以區別於儲存引擎的(**物理**)資料表示。
@ -229,7 +235,7 @@ breadcrumbs: false
許多應用程式讓使用者提交一些資料,然後檢視他們提交的內容。這可能是客戶資料庫中的記錄,或討論執行緒上的評論,或其他類似的東西。提交新資料時,必須將其傳送到領導者,但當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視但只是偶爾被寫入,這尤其合適。
使用非同步複製,存在一個問題,如 [圖 6-3](/tw/ch6#fig_replication_read_your_writes) 所示:如果使用者在寫入後不久檢視資料,新資料可能尚未到達副本。對使用者來說,看起來他們提交的資料丟失了,所以他們自然會不高興。
使用非同步複製,存在一個問題,如 [圖 6-3](#fig_replication_read_your_writes) 所示:如果使用者在寫入後不久檢視資料,新資料可能尚未到達副本。對使用者來說,看起來他們提交的資料丟失了,所以他們自然會不高興。
{{< figure src="/fig/ddia_0603.png" id="fig_replication_read_your_writes" caption="圖 6-3. 使用者進行寫入,然後從陳舊副本讀取。為了防止這種異常,我們需要寫後讀一致性。" class="w-full my-4" >}}
@ -255,7 +261,7 @@ breadcrumbs: false
>
> 我們用 **地區**region來指代一個地理位置中的一組資料中心。雲服務提供商通常會在同一地區部署多個數據中心每個資料中心稱為 **可用區**availability zone簡稱 AZ。因此一個地區由多個可用區組成每個可用區都是獨立的物理設施具有自己的供電、製冷等基礎設施。
>
> 同一地區內各可用區通常透過高速網路互聯,延遲足夠低,因此大多數分散式系統可以把同一地區內的多個可用區近似看作一個機房。多可用區部署可以抵禦單個可用區故障,但無法抵禦整個地區不可用。要應對地區級中斷,系統必須跨多個地區部署,這通常會帶來更高延遲、更低吞吐和更高的雲網絡費用。我們將在 ["多主複製拓撲"](/tw/ch6#sec_replication_topologies) 中進一步討論這些權衡。這裡你只需記住:本書所說的“地區”,是同一地理位置內多個可用區(資料中心)的集合。
> 同一地區內各可用區通常透過高速網路互聯,延遲足夠低,因此大多數分散式系統可以把同一地區內的多個可用區近似看作一個機房。多可用區部署可以抵禦單個可用區故障,但無法抵禦整個地區不可用。要應對地區級中斷,系統必須跨多個地區部署,這通常會帶來更高延遲、更低吞吐和更高的雲網絡費用。我們將在 ["多主複製拓撲"](#sec_replication_topologies) 中進一步討論這些權衡。這裡你只需記住:本書所說的“地區”,是同一地理位置內多個可用區(資料中心)的集合。
--------
@ -263,7 +269,7 @@ breadcrumbs: false
從非同步追隨者讀取時可能發生的第二個異常示例是,使用者可能會看到事物 **在時間上倒退**
如果使用者從不同的副本進行多次讀取,就可能發生這種情況。例如,[圖 6-4](/tw/ch6#fig_replication_monotonic_reads) 顯示使用者 2345 進行相同的查詢兩次,首先到延遲很小的追隨者,然後到延遲更大的追隨者。(如果使用者重新整理網頁,並且每個請求都路由到隨機伺服器,這種情況很可能發生。)第一個查詢返回使用者 1234 最近新增的評論,但第二個查詢沒有返回任何內容,因為滯後的追隨者尚未獲取該寫入。實際上,第二個查詢觀察到的系統狀態比第一個查詢更早的時間點。如果第一個查詢沒有返回任何內容,這不會那麼糟糕,因為使用者 2345 可能不知道使用者 1234 最近添加了評論。然而,如果使用者 2345 首先看到使用者 1234 的評論出現,然後又看到它消失,這對使用者 2345 來說非常令人困惑。
如果使用者從不同的副本進行多次讀取,就可能發生這種情況。例如,[圖 6-4](#fig_replication_monotonic_reads) 顯示使用者 2345 進行相同的查詢兩次,首先到延遲很小的追隨者,然後到延遲更大的追隨者。(如果使用者重新整理網頁,並且每個請求都路由到隨機伺服器,這種情況很可能發生。)第一個查詢返回使用者 1234 最近新增的評論,但第二個查詢沒有返回任何內容,因為滯後的追隨者尚未獲取該寫入。實際上,第二個查詢觀察到的系統狀態比第一個查詢更早的時間點。如果第一個查詢沒有返回任何內容,這不會那麼糟糕,因為使用者 2345 可能不知道使用者 1234 最近添加了評論。然而,如果使用者 2345 首先看到使用者 1234 的評論出現,然後又看到它消失,這對使用者 2345 來說非常令人困惑。
{{< figure src="/fig/ddia_0604.png" id="fig_replication_monotonic_reads" caption="圖 6-4. 使用者首先從新鮮副本讀取,然後從陳舊副本讀取。時間似乎倒退了。為了防止這種異常,我們需要單調讀。" class="w-full my-4" >}}
@ -283,7 +289,7 @@ Cake 夫人
這兩個句子之間存在因果依賴關係Cake 夫人聽到了 Poons 先生的問題並回答了它。
現在想象第三個人透過追隨者聽這個對話。Cake 夫人說的話透過延遲很小的追隨者,但 Poons 先生說的話有更長的複製延遲(見 [圖 6-5](/tw/ch6#fig_replication_consistent_prefix))。這個觀察者會聽到以下內容:
現在想象第三個人透過追隨者聽這個對話。Cake 夫人說的話透過延遲很小的追隨者,但 Poons 先生說的話有更長的複製延遲(見 [圖 6-5](#fig_replication_consistent_prefix))。這個觀察者會聽到以下內容:
Cake 夫人
: 通常大約十秒鐘Poons 先生。
@ -299,7 +305,7 @@ Poons 先生
這是分片(分割槽)資料庫中的一個特殊問題,我們將在 [第 7 章](/tw/ch7#ch_sharding) 中討論。如果資料庫始終以相同的順序應用寫入,讀取始終會看到一致的字首,因此這種異常不會發生。然而,在許多分散式資料庫中,不同的分片獨立執行,因此沒有全域性的寫入順序:當用戶從資料庫讀取時,他們可能會看到資料庫的某些部分處於較舊狀態,而某些部分處於較新狀態。
一種解決方案是確保任何因果相關的寫入都寫入同一分片——但在某些應用程式中,這無法有效完成。還有一些演算法明確跟蹤因果依賴關係,這是我們將在 ["先發生關係與併發"](/tw/ch6#sec_replication_happens_before) 中回到的主題。
一種解決方案是確保任何因果相關的寫入都寫入同一分片——但在某些應用程式中,這無法有效完成。還有一些演算法明確跟蹤因果依賴關係,這是我們將在 ["先發生關係與併發"](#sec_replication_happens_before) 中回到的主題。
### 複製延遲的解決方案 {#id131}
@ -333,7 +339,7 @@ Poons 先生
想象你有一個數據庫,在幾個不同的地區有副本(也許是為了能夠容忍整個地區的故障,或者是為了更接近你的使用者)。這被稱為 **地理分散式**、**地域分散式** 或 **地域複製** 設定。使用單主複製,領導者必須在 **一個** 地區,所有寫入都必須透過該地區。
在多主配置中,你可以在 **每個** 地區都部署一個領導者。[圖 6-6](/tw/ch6#fig_replication_multi_dc) 展示了這種架構:在每個地區內使用常規單主複製(追隨者可能位於與領導者不同的可用區);在地區之間,每個地區的領導者把變更復制給其他地區的領導者。
在多主配置中,你可以在 **每個** 地區都部署一個領導者。[圖 6-6](#fig_replication_multi_dc) 展示了這種架構:在每個地區內使用常規單主複製(追隨者可能位於與領導者不同的可用區);在地區之間,每個地區的領導者把變更復制給其他地區的領導者。
{{< figure src="/fig/ddia_0606.png" id="fig_replication_multi_dc" caption="圖 6-6. 跨多個地區的多主複製。" class="w-full my-4" >}}
@ -353,7 +359,7 @@ Poons 先生
一致性
: 單主系統可以提供強一致性保證,例如可序列化事務,我們將在 [第 8 章](/tw/ch8#ch_transactions) 中討論。多主系統的最大缺點是它們能夠實現的一致性要弱得多。例如,你不能保證銀行賬戶不會變成負數或使用者名稱是唯一的:不同的領導者總是可能處理單獨沒問題的寫入(從賬戶中支付一些錢,註冊特定使用者名稱),但當與另一個領導者上的另一個寫入結合時違反了約束。
這只是分散式系統的基本限制 [^28]。如果你必須強制執行這類約束,通常應選擇單主系統。不過,正如我們將在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中看到的,多主系統在不需要這類約束的廣泛應用裡,仍然可以提供有用的一致性屬性。
這只是分散式系統的基本限制 [^28]。如果你必須強制執行這類約束,通常應選擇單主系統。不過,正如我們將在 ["處理寫入衝突"](#sec_replication_write_conflicts) 中看到的,多主系統在不需要這類約束的廣泛應用裡,仍然可以提供有用的一致性屬性。
多主複製不如單主複製常見,但許多資料庫仍然支援它,包括 MySQL、Oracle、SQL Server 和 YugabyteDB。在某些情況下它是一個外部附加功能例如在 Redis Enterprise、EDB Postgres Distributed 和 pglogical 中 [^29]。
@ -361,11 +367,11 @@ Poons 先生
#### 多主複製拓撲 {#sec_replication_topologies}
**複製拓撲** 描述了寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,只有一種合理的拓撲:領導者 1 必須將其所有寫入傳送到領導者 2反之亦然。有了兩個以上的領導者各種不同的拓撲是可能的。[圖 6-7](/tw/ch6#fig_replication_topologies) 中說明了一些示例。
**複製拓撲** 描述了寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如 [圖 6-9](#fig_replication_write_conflict) 中,只有一種合理的拓撲:領導者 1 必須將其所有寫入傳送到領導者 2反之亦然。有了兩個以上的領導者各種不同的拓撲是可能的。[圖 6-7](#fig_replication_topologies) 中說明了一些示例。
{{< figure src="/fig/ddia_0607.png" id="fig_replication_topologies" caption="圖 6-7. 可以設定多主複製的三個示例拓撲。" class="w-full my-4" >}}
最通用的拓撲是 **全對全**,如 [圖 6-7](/tw/ch6#fig_replication_topologies)(c) 所示,其中每個領導者將其寫入傳送到每個其他領導者。然而,也使用更受限制的拓撲:例如 **環形拓撲**,其中每個節點從一個節點接收寫入並將這些寫入(加上其自己的任何寫入)轉發到另一個節點。另一種流行的拓撲具有 **星形** 形狀:一個指定的根節點將寫入轉發到所有其他節點。星形拓撲可以推廣到樹形。
最通用的拓撲是 **全對全**,如 [圖 6-7](#fig_replication_topologies)(c) 所示,其中每個領導者將其寫入傳送到每個其他領導者。然而,也使用更受限制的拓撲:例如 **環形拓撲**,其中每個節點從一個節點接收寫入並將這些寫入(加上其自己的任何寫入)轉發到另一個節點。另一種流行的拓撲具有 **星形** 形狀:一個指定的根節點將寫入轉發到所有其他節點。星形拓撲可以推廣到樹形。
--------
@ -380,15 +386,15 @@ Poons 先生
環形和星形拓撲的一個問題是,如果只有一個節點發生故障,它可能會中斷其他節點之間的複製訊息流,使它們無法通訊,直到節點被修復。可以重新配置拓撲以繞過故障節點,但在大多數部署中,這種重新配置必須手動完成。更密集連線的拓撲(如全對全)的容錯性更好,因為它允許訊息沿著不同的路徑傳播,避免單點故障。
另一方面,全對全拓撲也可能有問題。特別是,一些網路鏈路可能比其他鏈路更快(例如,由於網路擁塞),結果是一些複製訊息可能會"超越"其他訊息,如 [圖 6-8](/tw/ch6#fig_replication_causality) 所示。
另一方面,全對全拓撲也可能有問題。特別是,一些網路鏈路可能比其他鏈路更快(例如,由於網路擁塞),結果是一些複製訊息可能會"超越"其他訊息,如 [圖 6-8](#fig_replication_causality) 所示。
{{< figure src="/fig/ddia_0608.png" id="fig_replication_causality" caption="圖 6-8. 使用多主複製,寫入可能以錯誤的順序到達某些副本。" class="w-full my-4" >}}
在 [圖 6-8](/tw/ch6#fig_replication_causality) 中,客戶端 A 在領導者 1 上向表中插入一行,客戶端 B 在領導者 3 上更新該行。然而,領導者 2 可能以不同的順序接收寫入:它可能首先接收更新(從其角度來看,這是對資料庫中不存在的行的更新),然後才接收相應的插入(應該在更新之前)。
在 [圖 6-8](#fig_replication_causality) 中,客戶端 A 在領導者 1 上向表中插入一行,客戶端 B 在領導者 3 上更新該行。然而,領導者 2 可能以不同的順序接收寫入:它可能首先接收更新(從其角度來看,這是對資料庫中不存在的行的更新),然後才接收相應的插入(應該在更新之前)。
這是一個因果關係問題,類似於我們在 ["一致字首讀"](/tw/ch6#sec_replication_consistent_prefix) 中看到的問題:更新依賴於先前的插入,因此我們需要確保所有節點首先處理插入,然後處理更新。簡單地為每個寫入附加時間戳是不夠的,因為時鐘不能被信任足夠同步以在領導者 2 上正確排序這些事件(見 [第 9 章](/tw/ch9#ch_distributed))。
這是一個因果關係問題,類似於我們在 ["一致字首讀"](#sec_replication_consistent_prefix) 中看到的問題:更新依賴於先前的插入,因此我們需要確保所有節點首先處理插入,然後處理更新。簡單地為每個寫入附加時間戳是不夠的,因為時鐘不能被信任足夠同步以在領導者 2 上正確排序這些事件(見 [第 9 章](/tw/ch9#ch_distributed))。
為了正確排序這些事件,可以使用一種稱為 **版本向量** 的技術,我們將在本章後面討論(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。然而,許多多主複製系統不使用良好的技術來排序更新,使它們容易受到像 [圖 6-8](/tw/ch6#fig_replication_causality) 中的問題的影響。如果你使用多主複製,值得了解這些問題,仔細閱讀文件,並徹底測試你的資料庫,以確保它真正提供你認為它具有的保證。
為了正確排序這些事件,可以使用一種稱為 **版本向量** 的技術,我們將在本章後面討論(見 ["檢測併發寫入"](#sec_replication_concurrent))。然而,許多多主複製系統不使用良好的技術來排序更新,使它們容易受到像 [圖 6-8](#fig_replication_causality) 中的問題的影響。如果你使用多主複製,值得了解這些問題,仔細閱讀文件,並徹底測試你的資料庫,以確保它真正提供你認為它具有的保證。
### 同步引擎與本地優先軟體 {#sec_replication_offline_clients}
@ -430,14 +436,14 @@ Poons 先生
多主複製的最大問題——無論是在地域分散式伺服器端資料庫中還是在終端使用者裝置上的本地優先同步引擎中——是不同領導者上的併發寫入可能導致需要解決的衝突。
例如,考慮一個維基頁面同時被兩個使用者編輯,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 所示。使用者 1 將頁面標題從 A 更改為 B使用者 2 獨立地將標題從 A 更改為 C。每個使用者的更改成功應用於其本地領導者。然而當更改非同步複製時檢測到衝突。這個問題在單主資料庫中不會發生。
例如,考慮一個維基頁面同時被兩個使用者編輯,如 [圖 6-9](#fig_replication_write_conflict) 所示。使用者 1 將頁面標題從 A 更改為 B使用者 2 獨立地將標題從 A 更改為 C。每個使用者的更改成功應用於其本地領導者。然而當更改非同步複製時檢測到衝突。這個問題在單主資料庫中不會發生。
{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="圖 6-9. 兩個領導者併發更新同一記錄導致的寫入衝突。" class="w-full my-4" >}}
> [!NOTE]
> 我們說 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的兩個寫入是 **併發的**,因為在最初進行寫入時,兩者都不“知道”對方。寫入是否真的在同一時刻發生並不重要;實際上,如果寫入發生在離線狀態,它們在物理時間上可能相隔很久。關鍵在於:一個寫入是否發生在另一個寫入已經生效的狀態之上。
> 我們說 [圖 6-9](#fig_replication_write_conflict) 中的兩個寫入是 **併發的**,因為在最初進行寫入時,兩者都不“知道”對方。寫入是否真的在同一時刻發生並不重要;實際上,如果寫入發生在離線狀態,它們在物理時間上可能相隔很久。關鍵在於:一個寫入是否發生在另一個寫入已經生效的狀態之上。
在 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent) 中,我們將解決資料庫如何確定兩個寫入是否併發的問題。現在我們假設我們可以檢測衝突,並且我們想找出解決它們的最佳方法。
在 ["檢測併發寫入"](#sec_replication_concurrent) 中,我們將解決資料庫如何確定兩個寫入是否併發的問題。現在我們假設我們可以檢測衝突,並且我們想找出解決它們的最佳方法。
#### 衝突避免 {#conflict-avoidance}
@ -452,9 +458,9 @@ Poons 先生
#### 最後寫入勝利(丟棄併發寫入) {#sec_replication_lww}
如果無法避免衝突,解決它們的最簡單方法是為每個寫入附加時間戳,並始終使用具有最大時間戳的值。例如,在 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,假設使用者 1 的寫入時間戳大於使用者 2 的寫入時間戳。在這種情況下,兩個領導者都將確定頁面的新標題應該是 B並丟棄將其設定為 C 的寫入。如果寫入巧合地具有相同的時間戳,可以透過比較值來選擇獲勝者(例如,在字串的情況下,取字母表中較早的那個)。
如果無法避免衝突,解決它們的最簡單方法是為每個寫入附加時間戳,並始終使用具有最大時間戳的值。例如,在 [圖 6-9](#fig_replication_write_conflict) 中,假設使用者 1 的寫入時間戳大於使用者 2 的寫入時間戳。在這種情況下,兩個領導者都將確定頁面的新標題應該是 B並丟棄將其設定為 C 的寫入。如果寫入巧合地具有相同的時間戳,可以透過比較值來選擇獲勝者(例如,在字串的情況下,取字母表中較早的那個)。
這種方法稱為 **最後寫入勝利**LWW因為具有最大時間戳的寫入可以被認為是"最後"的。然而,這個術語是誤導性的,因為當兩個寫入像 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中那樣併發時,哪個更舊,哪個更新是未定義的,因此併發寫入的時間戳順序本質上是隨機的。
這種方法稱為 **最後寫入勝利**LWW因為具有最大時間戳的寫入可以被認為是"最後"的。然而,這個術語是誤導性的,因為當兩個寫入像 [圖 6-9](#fig_replication_write_conflict) 中那樣併發時,哪個更舊,哪個更新是未定義的,因此併發寫入的時間戳順序本質上是隨機的。
因此LWW 的真正含義是:當同一記錄在不同的領導者上併發寫入時,其中一個寫入被隨機選擇為獲勝者,其他寫入被靜默丟棄,即使它們在各自的領導者上成功處理。這實現了最終所有副本都處於一致狀態的目標,但代價是資料丟失。
@ -466,13 +472,13 @@ LWW 的另一個問題是,如果使用即時時鐘(例如 Unix 時間戳)
如果隨機丟棄你的一些寫入是不可取的,下一個選擇是手動解決衝突。你可能熟悉 Git 和其他版本控制系統中的手動衝突解決:如果兩個不同分支上的提交編輯同一檔案的相同行,並且你嘗試合併這些分支,你將得到一個需要在合併完成之前解決的合併衝突。
在資料庫裡,讓衝突阻塞整個複製流程、直到人工處理,通常並不現實。更常見的是,資料庫會保留某條記錄的所有併發寫入值——例如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的 B 和 C。這些值有時稱為 **兄弟**。下次查詢該記錄時,資料庫會返回 **所有** 這些值,而不只是最新值。隨後你可以按需要解決這些值:要麼在應用程式碼裡自動處理(例如把 B 和 C 合併成 "B/C"),要麼讓使用者參與處理;最後再把新值寫回資料庫以消解衝突。
在資料庫裡,讓衝突阻塞整個複製流程、直到人工處理,通常並不現實。更常見的是,資料庫會保留某條記錄的所有併發寫入值——例如 [圖 6-9](#fig_replication_write_conflict) 中的 B 和 C。這些值有時稱為 **兄弟**。下次查詢該記錄時,資料庫會返回 **所有** 這些值,而不只是最新值。隨後你可以按需要解決這些值:要麼在應用程式碼裡自動處理(例如把 B 和 C 合併成 "B/C"),要麼讓使用者參與處理;最後再把新值寫回資料庫以消解衝突。
這種衝突解決方法在某些系統中使用,例如 CouchDB。然而它也存在許多問題
* 資料庫的 API 發生變化:例如,以前維基頁面的標題只是一個字串,現在它變成了一組字串,通常包含一個元素,但如果有衝突,有時可能包含多個元素。這可能使應用程式程式碼中的資料難以處理。
* 要求使用者手動合併兄弟,會帶來很大負擔:開發者需要構建衝突解決介面,使用者也可能不明白自己為何要做這件事。在很多場景下,自動合併比打擾使用者更合適。
* 如果不夠謹慎,自動合併兄弟也可能產生反直覺行為。例如,亞馬遜購物車曾允許併發更新,並用“並集”策略合併(保留出現在任一兄弟中的所有商品)。這意味著:若使用者在一個兄弟裡刪除了某商品,但另一個兄弟仍保留它,該商品會“復活”回購物車 [^45]。[圖 6-10](/tw/ch6#fig_replication_amazon_anomaly) 就是一個例子:裝置 1 刪除 Book裝置 2 併發刪除 DVD衝突合併後兩個商品都回來了。
* 如果不夠謹慎,自動合併兄弟也可能產生反直覺行為。例如,亞馬遜購物車曾允許併發更新,並用“並集”策略合併(保留出現在任一兄弟中的所有商品)。這意味著:若使用者在一個兄弟裡刪除了某商品,但另一個兄弟仍保留它,該商品會“復活”回購物車 [^45]。[圖 6-10](#fig_replication_amazon_anomaly) 就是一個例子:裝置 1 刪除 Book裝置 2 併發刪除 DVD衝突合併後兩個商品都回來了。
* 如果多個節點觀察到衝突並併發解決它,衝突解決過程本身可能會引入新的衝突。這些解決方案甚至可能不一致:例如,如果你不小心一致地排序它們,一個節點可能將 B 和 C 合併為"B/C",另一個可能將它們合併為"C/B"。當"B/C"和"C/B"之間的衝突被合併時,它可能導致"B/C/C/B"或類似令人驚訝的東西。
{{< figure src="/fig/ddia_0610.png" id="fig_replication_amazon_anomaly" caption="圖 6-10. 亞馬遜購物車異常的示例:如果購物車上的衝突透過取並集合並,刪除的專案可能會重新出現。" class="w-full my-4" >}}
@ -485,7 +491,7 @@ LWW 的另一個問題是,如果使用即時時鐘(例如 Unix 時間戳)
LWW 是衝突解決演算法的一個簡單示例。已經為不同型別的資料開發了更複雜的合併演算法,目標是儘可能保留所有更新的預期效果,從而避免資料丟失:
* 如果資料是文字(例如維基頁面標題或正文),我們可以檢測每次版本演進中的字元插入和刪除。合併結果會保留任一兄弟中的所有插入和刪除。如果多個使用者併發在同一位置插入文字,還可以用確定性順序來排序,以確保所有節點得到同樣的合併結果。
* 如果資料是專案集合(像待辦事項列表那樣有序,或像購物車那樣無序),我們可以透過跟蹤插入和刪除類似於文字來合併它。為了避免 [圖 6-10](/tw/ch6#fig_replication_amazon_anomaly) 中的購物車問題,演算法跟蹤 Book 和 DVD 被刪除的事實,因此合併的結果是 Cart = {Soap}。
* 如果資料是專案集合(像待辦事項列表那樣有序,或像購物車那樣無序),我們可以透過跟蹤插入和刪除類似於文字來合併它。為了避免 [圖 6-10](#fig_replication_amazon_anomaly) 中的購物車問題,演算法跟蹤 Book 和 DVD 被刪除的事實,因此合併的結果是 Cart = {Soap}。
* 如果資料是可增可減的整數計數器(例如社交媒體帖子的點贊數),合併演算法可以統計每個兄弟上的遞增和遞減次數,並正確求和,既不重複計數,也不丟更新。
* 如果資料是鍵值對映,我們可以透過將其他衝突解決演算法之一應用於該鍵下的值來合併對同一鍵的更新。對不同鍵的更新可以相互獨立處理。
@ -495,7 +501,7 @@ LWW 是衝突解決演算法的一個簡單示例。已經為不同型別的資
兩個演算法族通常用於實現自動衝突解決:**無衝突複製資料型別**CRDT[^46] 和 **操作變換**OT[^47]。它們具有不同的設計理念和效能特徵,但都能夠為前面提到的所有型別的資料執行自動合併。
[圖 6-11](/tw/ch6#fig_replication_ot_crdt) 顯示了 OT 和 CRDT 如何合併對文字的併發更新的示例。假設你有兩個副本,都從文字"ice"開始。一個副本在前面新增字母"n"以製作"nice",而另一個副本併發地附加感嘆號以製作"ice!"。
[圖 6-11](#fig_replication_ot_crdt) 顯示了 OT 和 CRDT 如何合併對文字的併發更新的示例。假設你有兩個副本,都從文字"ice"開始。一個副本在前面新增字母"n"以製作"nice",而另一個副本併發地附加感嘆號以製作"ice!"。
{{< figure src="/fig/ddia_0611.png" id="fig_replication_ot_crdt" caption="圖 6-11. OT 和 CRDT 如何分別合併對字串的兩個併發插入。" class="w-full my-4" >}}
@ -505,7 +511,7 @@ OT
: 我們記錄插入或刪除字元的索引:"n"插入在索引 0"!"插入在索引 3。接下來副本交換它們的操作。在 0 處插入"n"可以按原樣應用,但如果在 3 處插入"!"應用於狀態"nice",我們將得到"nic!e",這是不正確的。因此,我們需要轉換每個操作的索引以考慮已經應用的併發操作;在這種情況下,"!"的插入被轉換為索引 4 以考慮在較早索引處插入"n"。
CRDT
: 大多數 CRDT 為每個字元提供唯一的、不可變的 ID並使用這些 ID 來確定插入/刪除的位置,而不是索引。例如,在 [圖 6-11](/tw/ch6#fig_replication_ot_crdt) 中,我們將 ID 1A 分配給"i"ID 2A 分配給"c"等。插入感嘆號時,我們生成一個包含新字元的 ID4B和我們想要在其後插入的現有字元的 ID3A的操作。要在字串的開頭插入我們將"nil"作為前面的字元 ID。在同一位置的併發插入按字元的 ID 排序。這確保副本收斂而不執行任何轉換。
: 大多數 CRDT 為每個字元提供唯一的、不可變的 ID並使用這些 ID 來確定插入/刪除的位置,而不是索引。例如,在 [圖 6-11](#fig_replication_ot_crdt) 中,我們將 ID 1A 分配給"i"ID 2A 分配給"c"等。插入感嘆號時,我們生成一個包含新字元的 ID4B和我們想要在其後插入的現有字元的 ID3A的操作。要在字串的開頭插入我們將"nil"作為前面的字元 ID。在同一位置的併發插入按字元的 ID 排序。這確保副本收斂而不執行任何轉換。
有許多基於這些想法變體的演算法。列表/陣列可以類似地支援使用列表元素而不是字元其他資料型別如鍵值對映可以很容易地新增。OT 和 CRDT 之間存在一些效能和功能權衡,但可以在一個演算法中結合 CRDT 和 OT 的優點 [^48]。
@ -513,7 +519,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
#### 什麼是衝突? {#what-is-a-conflict}
某些型別的衝突是顯而易見的。在 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 的示例中,兩個寫入併發修改了同一記錄中的同一欄位,將其設定為兩個不同的值。毫無疑問,這是一個衝突。
某些型別的衝突是顯而易見的。在 [圖 6-9](#fig_replication_write_conflict) 的示例中,兩個寫入併發修改了同一記錄中的同一欄位,將其設定為兩個不同的值。毫無疑問,這是一個衝突。
其他型別的衝突可能更難以檢測。例如,考慮一個會議室預訂系統:它跟蹤哪個房間由哪組人在什麼時間預訂。此應用程式需要確保每個房間在任何時間只由一組人預訂(即,同一房間不得有任何重疊的預訂)。在這種情況下,如果為同一房間同時建立兩個不同的預訂,可能會出現衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩個預訂是在兩個不同的領導者上進行的,也可能會發生衝突。
@ -537,9 +543,9 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
### 當節點故障時寫入資料庫 {#id287}
想象你有一個具有三個副本的資料庫,其中一個副本當前不可用——也許它正在重新啟動以安裝系統更新。在單主配置中,如果你想繼續處理寫入,你可能需要執行故障轉移(見 ["處理節點故障"](/tw/ch6#sec_replication_failover))。
想象你有一個具有三個副本的資料庫,其中一個副本當前不可用——也許它正在重新啟動以安裝系統更新。在單主配置中,如果你想繼續處理寫入,你可能需要執行故障轉移(見 ["處理節點故障"](#sec_replication_failover))。
另一方面,在無主配置中,故障轉移不存在。[圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 顯示了發生的情況:客戶端(使用者 1234將寫入並行傳送到所有三個副本兩個可用副本接受寫入但不可用副本錯過了它。假設三個副本中有兩個確認寫入就足夠了在使用者 1234 收到兩個 **ok** 響應後,我們認為寫入成功。客戶端只是忽略了其中一個副本錯過寫入的事實。
另一方面,在無主配置中,故障轉移不存在。[圖 6-12](#fig_replication_quorum_node_outage) 顯示了發生的情況:客戶端(使用者 1234將寫入並行傳送到所有三個副本兩個可用副本接受寫入但不可用副本錯過了它。假設三個副本中有兩個確認寫入就足夠了在使用者 1234 收到兩個 **ok** 響應後,我們認為寫入成功。客戶端只是忽略了其中一個副本錯過寫入的事實。
{{< figure src="/fig/ddia_0612.png" id="fig_replication_quorum_node_outage" caption="圖 6-12. 節點中斷後的仲裁寫入、仲裁讀取和讀修復。" class="w-full my-4" >}}
@ -548,14 +554,14 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
為了解決這個問題,當客戶端從資料庫讀取時,它不只是將其請求傳送到一個副本:**讀取請求也並行傳送到多個節點**。客戶端可能會從不同的節點獲得不同的響應;例如,從一個節點獲得最新值,從另一個節點獲得陳舊值。
為了區分哪些響應是最新的,哪些是過時的,寫入的每個值都需要用版本號或時間戳標記,類似於我們在 ["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的。當客戶端收到對讀取的多個值響應時,它使用具有最大時間戳的值(即使該值僅由一個副本返回,而其他幾個副本返回較舊的值)。有關更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。
為了區分哪些響應是最新的,哪些是過時的,寫入的每個值都需要用版本號或時間戳標記,類似於我們在 ["最後寫入勝利(丟棄併發寫入)"](#sec_replication_lww) 中看到的。當客戶端收到對讀取的多個值響應時,它使用具有最大時間戳的值(即使該值僅由一個副本返回,而其他幾個副本返回較舊的值)。有關更多詳細資訊,請參見 ["檢測併發寫入"](#sec_replication_concurrent)。
#### 追趕錯過的寫入 {#sec_replication_read_repair}
複製系統應確保最終所有資料都複製到每個副本。在不可用節點恢復上線後,它如何趕上它錯過的寫入?在 Dynamo 風格的資料儲存中使用了幾種機制:
讀修復
: 當客戶端並行從多個節點讀取時,它可以檢測任何陳舊響應。例如,在 [圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 中,使用者 2345 從副本 3 獲得版本 6 的值,從副本 1 和 2 獲得版本 7 的值。客戶端發現副本 3 陳舊後,會把較新的值寫回該副本。這種方法適用於經常被讀取的值。
: 當客戶端並行從多個節點讀取時,它可以檢測任何陳舊響應。例如,在 [圖 6-12](#fig_replication_quorum_node_outage) 中,使用者 2345 從副本 3 獲得版本 6 的值,從副本 1 和 2 獲得版本 7 的值。客戶端發現副本 3 陳舊後,會把較新的值寫回該副本。這種方法適用於經常被讀取的值。
提示移交
: 如果一個副本不可用,另一個副本可能會以 **提示** 的形式代表其儲存寫入。當應該接收這些寫入的副本恢復時,儲存提示的副本將它們傳送到恢復的副本,然後刪除提示。這個 **移交** 過程有助於使副本保持最新,即使對於從未讀取的值也是如此,因此不由讀修復處理。
@ -565,7 +571,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
#### 讀寫仲裁 {#sec_replication_quorum_condition}
在 [圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 的例子中,即使寫入僅在三個副本中的兩個上處理,我們也認為寫入成功。如果三個副本中只有一個接受了寫入呢?我們能推多遠?
在 [圖 6-12](#fig_replication_quorum_node_outage) 的例子中,即使寫入僅在三個副本中的兩個上處理,我們也認為寫入成功。如果三個副本中只有一個接受了寫入呢?我們能推多遠?
如果我們知道每次成功的寫入都保證至少存在於三個副本中的兩個上,這意味著最多一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確信兩個中至少有一個是最新的。如果第三個副本宕機或響應緩慢,讀取仍然可以繼續返回最新值。
@ -584,8 +590,8 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
* 如果 *w* < *n*,如果節點不可用,我們仍然可以處理寫入。
* 如果 *r* < *n*,如果節點不可用,我們仍然可以處理讀取。
* 使用 *n* = 3*w* = 2*r* = 2我們可以容忍一個不可用節點如 [圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 中所示。
* 使用 *n* = 5*w* = 3*r* = 3我們可以容忍兩個不可用節點。這種情況在 [圖 6-13](/tw/ch6#fig_replication_quorum_overlap) 中說明。
* 使用 *n* = 3*w* = 2*r* = 2我們可以容忍一個不可用節點如 [圖 6-12](#fig_replication_quorum_node_outage) 中所示。
* 使用 *n* = 5*w* = 3*r* = 3我們可以容忍兩個不可用節點。這種情況在 [圖 6-13](#fig_replication_quorum_overlap) 中說明。
通常,讀取和寫入總是並行傳送到所有 *n* 個副本。引數 *w**r* 確定我們等待多少個節點——即,在我們認為讀取或寫入成功之前,*n* 個節點中有多少個需要報告成功。
@ -596,7 +602,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
### 仲裁一致性的侷限 {#sec_replication_quorum_limitations}
如果你有 *n* 個副本,並且你選擇 *w**r* 使得 *w* + *r* > *n*,你通常可以期望每次讀取都返回為鍵寫入的最新值。這是因為你寫入的節點集和你讀取的節點集必須重疊。也就是說,在你讀取的節點中,必須至少有一個具有最新值的節點(如 [圖 6-13](/tw/ch6#fig_replication_quorum_overlap) 所示)。
如果你有 *n* 個副本,並且你選擇 *w**r* 使得 *w* + *r* > *n*,你通常可以期望每次讀取都返回為鍵寫入的最新值。這是因為你寫入的節點集和你讀取的節點集必須重疊。也就是說,在你讀取的節點中,必須至少有一個具有最新值的節點(如 [圖 6-13](#fig_replication_quorum_overlap) 所示)。
通常,*r* 和 *w* 被選擇為多數(超過 *n*/2節點因為這確保了 *w* + *r* > *n*,同時仍然容忍最多 *n*/2向下舍入個節點故障。但仲裁不一定是多數——重要的是讀取和寫入操作使用的節點集至少在一個節點中重疊。其他仲裁分配是可能的這允許分散式演算法設計中的一些靈活性 [^51]。
@ -610,8 +616,8 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
* 在重新平衡正在進行時,其中一些資料從一個節點移動到另一個節點(見 [第 7 章](/tw/ch7#ch_sharding)),節點可能對哪些節點應該持有特定值的 *n* 個副本有不一致的檢視。這可能導致讀取和寫入仲裁不再重疊。
* 如果讀取與寫入操作併發,讀取可能會或可能不會看到併發寫入的值。特別是,一次讀取可能看到新值,而後續讀取看到舊值,正如我們將在 ["線性一致性與仲裁"](/tw/ch10#sec_consistency_quorum_linearizable) 中看到的。
* 如果寫入在某些副本上成功但在其他副本上失敗(例如,因為某些節點上的磁碟已滿),並且總體上在少於 *w* 個副本上成功,它不會在成功的副本上回滾。這意味著如果寫入被報告為失敗,後續讀取可能會或可能不會返回該寫入的值 [^52]。
* 如果資料庫使用即時時鐘的時間戳來確定哪個寫入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一個具有更快時鐘的節點已寫入同一鍵,寫入可能會被靜默丟棄——我們之前在 ["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的問題。我們將在 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying) 中更詳細地討論這一點。
* 如果兩個寫入併發發生,其中一個可能首先在一個副本上處理,另一個可能首先在另一個副本上處理。這導致衝突,類似於我們在多主複製中看到的(見 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts))。我們將在 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent) 中回到這個主題。
* 如果資料庫使用即時時鐘的時間戳來確定哪個寫入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一個具有更快時鐘的節點已寫入同一鍵,寫入可能會被靜默丟棄——我們之前在 ["最後寫入勝利(丟棄併發寫入)"](#sec_replication_lww) 中看到的問題。我們將在 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying) 中更詳細地討論這一點。
* 如果兩個寫入併發發生,其中一個可能首先在一個副本上處理,另一個可能首先在另一個副本上處理。這導致衝突,類似於我們在多主複製中看到的(見 ["處理寫入衝突"](#sec_replication_write_conflicts))。我們將在 ["檢測併發寫入"](#sec_replication_concurrent) 中回到這個主題。
因此儘管仲裁似乎保證讀取返回最新寫入的值但實際上並不那麼簡單。Dynamo 風格的資料庫通常針對可以容忍最終一致性的用例進行了最佳化。引數 *w**r* 允許你調整讀取陳舊值的機率 [^53],但明智的做法是不要將它們視為絕對保證。
@ -626,7 +632,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
### 單主與無主複製的效能 {#sec_replication_leaderless_perf}
基於單個領導者的複製系統可以提供在無主系統中難以或不可能實現的強一致性保證。然而,正如我們在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中看到的,如果你在非同步更新的追隨者上進行讀取,基於領導者的複製系統中的讀取也可能返回陳舊值。
基於單個領導者的複製系統可以提供在無主系統中難以或不可能實現的強一致性保證。然而,正如我們在 ["複製延遲的問題"](#sec_replication_lag) 中看到的,如果你在非同步更新的追隨者上進行讀取,基於領導者的複製系統中的讀取也可能返回陳舊值。
從領導者讀取確保最新響應,但它存在效能問題:
@ -648,7 +654,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
#### 多地區操作 {#multi-region-operation}
我們之前討論了跨地區複製作為多主複製的用例(見 ["多主複製"](/tw/ch6#sec_replication_multi_leader))。無主複製也適合多地區操作,因為它被設計為容忍衝突的併發寫入、網路中斷和延遲峰值。
我們之前討論了跨地區複製作為多主複製的用例(見 ["多主複製"](#sec_replication_multi_leader))。無主複製也適合多地區操作,因為它被設計為容忍衝突的併發寫入、網路中斷和延遲峰值。
Cassandra 和 ScyllaDB 在正常的無主模型中實現了它們的多地區支援:客戶端直接將其寫入傳送到所有地區的副本,你可以從各種一致性級別中進行選擇,這些級別確定請求成功所需的響應數。例如,你可以請求所有地區中副本的仲裁、每個地區中的單獨仲裁,或僅客戶端本地地區的仲裁。本地仲裁避免了必須等待到其他地區的緩慢請求,但它也更可能返回陳舊結果。
@ -659,7 +665,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
與多主複製一樣,無主資料庫允許對同一鍵進行併發寫入,導致需要解決的衝突。此類衝突可能在寫入發生時發生,但並非總是如此:它們也可能在讀修復、提示移交或反熵期間稍後檢測到。
問題在於,由於可變的網路延遲和部分故障,事件可能以不同的順序到達不同的節點。例如,[圖 6-14](/tw/ch6#fig_replication_concurrency) 顯示了兩個客戶端 A 和 B 同時寫入三節點資料儲存中的鍵 *X*
問題在於,由於可變的網路延遲和部分故障,事件可能以不同的順序到達不同的節點。例如,[圖 6-14](#fig_replication_concurrency) 顯示了兩個客戶端 A 和 B 同時寫入三節點資料儲存中的鍵 *X*
* 節點 1 接收來自 A 的寫入,但由於瞬時中斷從未接收來自 B 的寫入。
* 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。
@ -667,9 +673,9 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
{{< figure src="/fig/ddia_0614.png" id="fig_replication_concurrency" caption="圖 6-14. Dynamo 風格資料儲存中的併發寫入:沒有明確定義的順序。" class="w-full my-4" >}}
如果每個節點在接收到來自客戶端的寫入請求時只是覆蓋鍵的值,節點將變得永久不一致,如 [圖 6-14](/tw/ch6#fig_replication_concurrency) 中的最終 *get* 請求所示:節點 2 認為 *X* 的最終值是 B而其他節點認為值是 A。
如果每個節點在接收到來自客戶端的寫入請求時只是覆蓋鍵的值,節點將變得永久不一致,如 [圖 6-14](#fig_replication_concurrency) 中的最終 *get* 請求所示:節點 2 認為 *X* 的最終值是 B而其他節點認為值是 A。
為了最終保持一致,副本應該收斂到相同的值。為此,我們可以使用我們之前在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中討論的任何衝突解決機制,例如最後寫入勝利(由 Cassandra 和 ScyllaDB 使用)、手動解決或 CRDT在 ["CRDT 與操作變換"](/tw/ch6#sec_replication_crdts) 中描述,並由 Riak 使用)。
為了最終保持一致,副本應該收斂到相同的值。為此,我們可以使用我們之前在 ["處理寫入衝突"](#sec_replication_write_conflicts) 中討論的任何衝突解決機制,例如最後寫入勝利(由 Cassandra 和 ScyllaDB 使用)、手動解決或 CRDT在 ["CRDT 與操作變換"](#sec_replication_crdts) 中描述,並由 Riak 使用)。
最後寫入勝利很容易實現:每個寫入都標有時間戳,具有更高時間戳的值總是覆蓋具有較低時間戳的值。然而,時間戳不會告訴你兩個值是否實際上衝突(即,它們是併發寫入的)或不衝突(它們是一個接一個寫入的)。如果你想顯式解決衝突,系統需要更加小心地檢測併發寫入。
@ -677,8 +683,8 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
我們如何決定兩個操作是否併發?為了培養直覺,讓我們看一些例子:
* 在 [圖 6-8](/tw/ch6#fig_replication_causality) 中兩個寫入不是併發的A 的插入 **先發生於** B 的遞增,因為 B 遞增的值是 A 插入的值。換句話說B 的操作建立在 A 的操作之上,所以 B 的操作必須稍後發生。我們也說 B **因果依賴** 於 A。
* 另一方面,[圖 6-14](/tw/ch6#fig_replication_concurrency) 中的兩個寫入是併發的:當每個客戶端開始操作時,它不知道另一個客戶端也在對同一鍵執行操作。因此,操作之間沒有因果依賴關係。
* 在 [圖 6-8](#fig_replication_causality) 中兩個寫入不是併發的A 的插入 **先發生於** B 的遞增,因為 B 遞增的值是 A 插入的值。換句話說B 的操作建立在 A 的操作之上,所以 B 的操作必須稍後發生。我們也說 B **因果依賴** 於 A。
* 另一方面,[圖 6-14](#fig_replication_concurrency) 中的兩個寫入是併發的:當每個客戶端開始操作時,它不知道另一個客戶端也在對同一鍵執行操作。因此,操作之間沒有因果依賴關係。
如果操作 B 知道 A或依賴於 A或以某種方式建立在 A 之上,則操作 A **先發生於** 另一個操作 B。一個操作是否先發生於另一個操作是定義併發含義的關鍵。事實上我們可以簡單地說如果兩個操作都不先發生於另一個兩者都不知道另一個則它們是 **併發的** [^57]。
@ -700,7 +706,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
讓我們看一個確定兩個操作是否併發或一個先發生於另一個的演算法。為了簡單起見,讓我們從只有一個副本的資料庫開始。一旦我們弄清楚如何在單個副本上執行此操作,我們就可以將該方法推廣到具有多個副本的無主資料庫。
[圖 6-15](/tw/ch6#fig_replication_causality_single) 顯示了兩個客戶端併發地向同一購物車新增專案。(如果這個例子讓你覺得太無聊,想象一下兩個空中交通管制員併發地向他們正在跟蹤的扇區新增飛機。)最初,購物車是空的。兩個客戶端總共向資料庫發起了五次寫入:
[圖 6-15](#fig_replication_causality_single) 顯示了兩個客戶端併發地向同一購物車新增專案。(如果這個例子讓你覺得太無聊,想象一下兩個空中交通管制員併發地向他們正在跟蹤的扇區新增飛機。)最初,購物車是空的。兩個客戶端總共向資料庫發起了五次寫入:
1. 客戶端 1 將 `milk` 新增到購物車。這是對該鍵的第一次寫入,因此伺服器成功儲存它併為其分配版本 1。伺服器還將值連同版本號一起回顯給客戶端。
2. 客戶端 2 將 `eggs` 新增到購物車,不知道客戶端 1 併發地添加了 `milk`(客戶端 2 認為它的 `eggs` 是購物車中的唯一專案)。伺服器為此寫入分配版本 2並將 `eggs``milk` 儲存為兩個單獨的值(兄弟)。然後,它將 **兩個** 值連同版本號 2 一起返回給客戶端。
@ -711,7 +717,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
{{< figure src="/fig/ddia_0615.png" id="fig_replication_causality_single" caption="圖 6-15. 捕獲兩個客戶端併發編輯購物車之間的因果依賴關係。" class="w-full my-4" >}}
[圖 6-15](/tw/ch6#fig_replication_causality_single) 中操作之間的資料流在 [圖 6-16](/tw/ch6#fig_replication_causal_dependencies) 中以圖形方式說明。箭頭指示哪個操作 **先發生於** 哪個其他操作,即後面的操作 **知道****依賴於** 前面的操作。在這個例子中,客戶端從未完全瞭解伺服器上的資料,因為總是有另一個併發進行的操作。但是值的舊版本最終會被覆蓋,並且不會丟失任何寫入。
[圖 6-15](#fig_replication_causality_single) 中操作之間的資料流在 [圖 6-16](#fig_replication_causal_dependencies) 中以圖形方式說明。箭頭指示哪個操作 **先發生於** 哪個其他操作,即後面的操作 **知道****依賴於** 前面的操作。在這個例子中,客戶端從未完全瞭解伺服器上的資料,因為總是有另一個併發進行的操作。但是值的舊版本最終會被覆蓋,並且不會丟失任何寫入。
{{< figure link="#fig_replication_causality_single" src="/fig/ddia_0616.png" id="fig_replication_causal_dependencies" caption="圖 6-16. 圖 6-15 中因果依賴關係的圖。" class="w-full my-4" >}}
@ -727,13 +733,13 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
#### 版本向量 {#version-vectors}
[圖 6-15](/tw/ch6#fig_replication_causality_single) 中的示例只使用了單個副本。當存在多個副本、且沒有領導者時,演算法如何變化?
[圖 6-15](#fig_replication_causality_single) 中的示例只使用了單個副本。當存在多個副本、且沒有領導者時,演算法如何變化?
[圖 6-15](/tw/ch6#fig_replication_causality_single) 使用單個版本號來捕獲操作間依賴關係,但當多個副本併發接受寫入時,這還不夠。我們需要為 **每個副本**、每個鍵分別維護版本號。每個副本在處理寫入時遞增自己的版本號,並追蹤從其他副本看到的版本號。這些資訊決定了哪些值該被覆蓋,哪些值要作為兄弟保留。
[圖 6-15](#fig_replication_causality_single) 使用單個版本號來捕獲操作間依賴關係,但當多個副本併發接受寫入時,這還不夠。我們需要為 **每個副本**、每個鍵分別維護版本號。每個副本在處理寫入時遞增自己的版本號,並追蹤從其他副本看到的版本號。這些資訊決定了哪些值該被覆蓋,哪些值要作為兄弟保留。
來自所有副本的版本號集合稱為 **版本向量** [^58]。這一思想有若干變體,其中較有代表性的是 **點版本向量** [^59] [^60]Riak 2.0 使用了它 [^61] [^62]。這裡不展開細節,它的工作方式與前面的購物車示例非常相似。
和 [圖 6-15](/tw/ch6#fig_replication_causality_single) 裡的版本號一樣版本向量會在讀取時由資料庫副本返回給客戶端並在後續寫入時再由客戶端帶回資料庫。Riak 把版本向量編碼成一個字串,稱為 **因果上下文**。)版本向量讓資料庫能夠區分“覆蓋寫入”和“併發寫入”。
和 [圖 6-15](#fig_replication_causality_single) 裡的版本號一樣版本向量會在讀取時由資料庫副本返回給客戶端並在後續寫入時再由客戶端帶回資料庫。Riak 把版本向量編碼成一個字串,稱為 **因果上下文**。)版本向量讓資料庫能夠區分“覆蓋寫入”和“併發寫入”。
版本向量還保證了“從一個副本讀取,再寫回另一個副本”是安全的。這樣做可能會產生兄弟,但只要正確合併兄弟,就不會丟失資料。

View file

@ -21,7 +21,7 @@ breadcrumbs: false
分片通常與複製結合使用,以便每個分片的副本儲存在多個節點上。這意味著,即使每條記錄屬於恰好一個分片,它仍然可以儲存在多個不同的節點上以提供容錯能力。
一個節點可能儲存多個分片。例如,如果使用單領導者複製模型,分片與複製的組合可能如 [圖 7-1](/tw/ch7#fig_sharding_replicas) 所示。每個分片的領導者被分配到一個節點,追隨者被分配到其他節點。每個節點可能是某些分片的領導者,同時又是其他分片的追隨者,但每個分片仍然只有一個領導者。
一個節點可能儲存多個分片。例如,如果使用單領導者複製模型,分片與複製的組合可能如 [圖 7-1](#fig_sharding_replicas) 所示。每個分片的領導者被分配到一個節點,追隨者被分配到其他節點。每個節點可能是某些分片的領導者,同時又是其他分片的追隨者,但每個分片仍然只有一個領導者。
{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="圖 7-1. 複製與分片結合使用:每個節點對某些分片充當領導者,對另一些分片充當追隨者。" class="w-full my-4" >}}
@ -51,7 +51,7 @@ breadcrumbs: false
推薦這樣做的原因是分片通常會增加複雜性:你通常必須透過選擇 *分割槽鍵* 來決定將哪些記錄放在哪個分片中;具有相同分割槽鍵的所有記錄都放在同一個分片中 [^4]。這個選擇很重要,因為如果你知道記錄在哪個分片中,訪問記錄會很快,但如果你不知道分片,你必須在所有分片中進行低效的搜尋,而且分片方案很難更改。
因此,分片通常適用於鍵值資料,你可以輕鬆地按鍵進行分片,但對於關係資料則較難,因為你可能想要透過二級索引搜尋,或連線可能分佈在不同分片中的記錄。我們將在 ["分片與二級索引"](/tw/ch7#sec_sharding_secondary_indexes) 中進一步討論這個問題。
因此,分片通常適用於鍵值資料,你可以輕鬆地按鍵進行分片,但對於關係資料則較難,因為你可能想要透過二級索引搜尋,或連線可能分佈在不同分片中的記錄。我們將在 ["分片與二級索引"](#sec_sharding_secondary_indexes) 中進一步討論這個問題。
分片的另一個問題是寫入可能需要更新多個不同分片中的相關記錄。雖然單節點上的事務相當常見(見 [第 8 章](/tw/ch8#ch_transactions)),但確保跨多個分片的一致性需要 *分散式事務*。正如我們將在 [第 8 章](/tw/ch8#ch_transactions) 中看到的,分散式事務在某些資料庫中可用,但它們通常比單節點事務慢得多,可能成為整個系統的瓶頸,有些系統根本不支援它們。
@ -105,11 +105,11 @@ breadcrumbs: false
### 按鍵的範圍分片 {#sec_sharding_key_range}
一種分片方法是為每個分片分配一個連續的分割槽鍵範圍(從某個最小值到某個最大值),就像紙質百科全書的卷一樣,如 [圖 7-2](/tw/ch7#fig_sharding_encyclopedia) 所示。在這個例子中,條目的分割槽鍵是其標題。如果你想查詢特定標題的條目,你可以透過找到鍵範圍包含你要查詢標題的捲來輕鬆確定哪個分片包含該條目,從而從書架上挑選正確的書。
一種分片方法是為每個分片分配一個連續的分割槽鍵範圍(從某個最小值到某個最大值),就像紙質百科全書的卷一樣,如 [圖 7-2](#fig_sharding_encyclopedia) 所示。在這個例子中,條目的分割槽鍵是其標題。如果你想查詢特定標題的條目,你可以透過找到鍵範圍包含你要查詢標題的捲來輕鬆確定哪個分片包含該條目,從而從書架上挑選正確的書。
{{< figure src="/fig/ddia_0702.png" id="fig_sharding_encyclopedia" caption="圖 7-2. 印刷版百科全書按鍵範圍分片。" class="w-full my-4" >}}
鍵的範圍不一定是均勻分佈的,因為你的資料可能不是均勻分佈的。例如,在 [圖 7-2](/tw/ch7#fig_sharding_encyclopedia) 中,第 1 捲包含以 A 和 B 開頭的單詞,但第 12 捲包含以 T、U、V、W、X、Y 和 Z 開頭的單詞。簡單地為字母表的每兩個字母分配一卷會導致某些卷比其他卷大得多。為了均勻分佈資料,分片邊界需要適應資料。
鍵的範圍不一定是均勻分佈的,因為你的資料可能不是均勻分佈的。例如,在 [圖 7-2](#fig_sharding_encyclopedia) 中,第 1 捲包含以 A 和 B 開頭的單詞,但第 12 捲包含以 T、U、V、W、X、Y 和 Z 開頭的單詞。簡單地為字母表的每兩個字母分配一卷會導致某些卷比其他卷大得多。為了均勻分佈資料,分片邊界需要適應資料。
分片邊界可能由管理員手動選擇,或者資料庫可以自動選擇它們。手動鍵範圍分片例如被 VitessMySQL 的分片層)使用;自動變體被 Bigtable、其開源等價物 HBase、MongoDB 中基於範圍的分片選項、CockroachDB、RethinkDB 和 FoundationDB 使用 [^6]。YugabyteDB 提供手動和自動錶塊分割兩種選項。
@ -146,7 +146,7 @@ breadcrumbs: false
一旦你對鍵進行了雜湊,如何選擇將其儲存在哪個分片中?也許你的第一個想法是取雜湊值 *模* 系統中的節點數(在許多程式語言中使用 `%` 運算子)。例如,*hash*(*key*) % 10 將返回 0 到 9 之間的數字如果我們將雜湊寫為十進位制數hash % 10 將是最後一位數字)。如果我們有 10 個節點,編號從 0 到 9這似乎是將每個鍵分配給節點的簡單方法。
*mod N* 方法的問題是,如果節點數 *N* 發生變化,大多數鍵必須從一個節點移動到另一個節點。[圖 7-3](/tw/ch7#fig_sharding_hash_mod_n) 顯示了當你有三個節點並新增第四個節點時會發生什麼。在再平衡之前,節點 0 儲存雜湊值為 0、3、6、9 等的鍵。新增第四個節點後,雜湊值為 3 的鍵已移動到節點 3雜湊值為 6 的鍵已移動到節點 2雜湊值為 9 的鍵已移動到節點 1依此類推。
*mod N* 方法的問題是,如果節點數 *N* 發生變化,大多數鍵必須從一個節點移動到另一個節點。[圖 7-3](#fig_sharding_hash_mod_n) 顯示了當你有三個節點並新增第四個節點時會發生什麼。在再平衡之前,節點 0 儲存雜湊值為 0、3、6、9 等的鍵。新增第四個節點後,雜湊值為 3 的鍵已移動到節點 3雜湊值為 6 的鍵已移動到節點 2雜湊值為 9 的鍵已移動到節點 1依此類推。
{{< figure src="/fig/ddia_0703.png" id="fig_sharding_hash_mod_n" caption="圖 7-3. 透過對鍵進行雜湊並取模節點數來將鍵分配給節點。更改節點數會導致許多鍵從一個節點移動到另一個節點。" class="w-full my-4" >}}
@ -156,7 +156,7 @@ breadcrumbs: false
一個簡單但廣泛使用的解決方案是建立比節點多得多的分片,併為每個節點分配多個分片。例如,在 10 個節點的叢集上執行的資料庫可能從一開始就被分成 1,000 個分片,以便每個節點分配 100 個分片。然後將鍵儲存在分片號 *hash*(*key*) % 1,000 中,系統單獨跟蹤哪個分片儲存在哪個節點上。
現在,如果向叢集新增一個節點,系統可以從現有節點重新分配一些分片到新節點,直到它們再次公平分佈。這個過程在 [圖 7-4](/tw/ch7#fig_sharding_rebalance_fixed) 中說明。如果從叢集中刪除節點,則反向發生相同的事情。
現在,如果向叢集新增一個節點,系統可以從現有節點重新分配一些分片到新節點,直到它們再次公平分佈。這個過程在 [圖 7-4](#fig_sharding_rebalance_fixed) 中說明。如果從叢集中刪除節點,則反向發生相同的事情。
{{< figure src="/fig/ddia_0704.png" id="fig_sharding_rebalance_fixed" caption="圖 7-4. 向每個節點有多個分片的資料庫叢集新增新節點。" class="w-full my-4" >}}
@ -174,7 +174,7 @@ breadcrumbs: false
如果無法提前預測所需的分片數量,最好使用一種方案,其中分片數量可以輕鬆適應工作負載。前面提到的鍵範圍分片方案具有這個屬性,但當有大量對相鄰鍵的寫入時,它有熱點的風險。一種解決方案是將鍵範圍分片與雜湊函式結合,使每個分片包含 *雜湊值* 的範圍而不是 *鍵* 的範圍。
[圖 7-5](/tw/ch7#fig_sharding_hash_range) 顯示了使用 16 位雜湊函式的示例,該函式返回 0 到 65,535 = 2¹⁶ 1 之間的數字(實際上,雜湊通常是 32 位或更多)。即使輸入鍵非常相似(例如,連續的時間戳),它們的雜湊值也會在該範圍內均勻分佈。然後我們可以為每個分片分配一個雜湊值範圍:例如,值 0 到 16,383 分配給分片 0值 16,384 到 32,767 分配給分片 1依此類推。
[圖 7-5](#fig_sharding_hash_range) 顯示了使用 16 位雜湊函式的示例,該函式返回 0 到 65,535 = 2¹⁶ 1 之間的數字(實際上,雜湊通常是 32 位或更多)。即使輸入鍵非常相似(例如,連續的時間戳),它們的雜湊值也會在該範圍內均勻分佈。然後我們可以為每個分片分配一個雜湊值範圍:例如,值 0 到 16,383 分配給分片 0值 16,384 到 32,767 分配給分片 1依此類推。
{{< figure src="/fig/ddia_0705.png" id="fig_sharding_hash_range" caption="圖 7-5. 為每個分片分配連續的雜湊值範圍。" class="w-full my-4" >}}
@ -190,11 +190,11 @@ breadcrumbs: false
--------
雜湊範圍分片被 YugabyteDB 和 DynamoDB 使用 [^17],並且是 MongoDB 中的一個選項。Cassandra 和 ScyllaDB 使用這種方法的一個變體,如 [圖 7-6](/tw/ch7#fig_sharding_cassandra) 所示:雜湊值空間被分割成與節點數成比例的範圍數([圖 7-6](/tw/ch7#fig_sharding_cassandra) 中每個節點 3 個範圍,但實際數字在 Cassandra 中預設為每個節點 8 個,在 ScyllaDB 中為每個節點 256 個),這些範圍之間有隨機邊界。這意味著某些範圍比其他範圍大,但透過每個節點有多個範圍,這些不平衡傾向於平均化 [^15] [^18]。
雜湊範圍分片被 YugabyteDB 和 DynamoDB 使用 [^17],並且是 MongoDB 中的一個選項。Cassandra 和 ScyllaDB 使用這種方法的一個變體,如 [圖 7-6](#fig_sharding_cassandra) 所示:雜湊值空間被分割成與節點數成比例的範圍數([圖 7-6](#fig_sharding_cassandra) 中每個節點 3 個範圍,但實際數字在 Cassandra 中預設為每個節點 8 個,在 ScyllaDB 中為每個節點 256 個),這些範圍之間有隨機邊界。這意味著某些範圍比其他範圍大,但透過每個節點有多個範圍,這些不平衡傾向於平均化 [^15] [^18]。
{{< figure src="/fig/ddia_0706.png" id="fig_sharding_cassandra" caption="圖 7-6. Cassandra 和 ScyllaDB 將可能的雜湊值範圍(這裡是 0-1023分割成具有隨機邊界的連續範圍併為每個節點分配多個範圍。" class="w-full my-4" >}}
當新增或刪除節點時,會新增和刪除範圍邊界,並相應地分割或合併分片 [^19]。在 [圖 7-6](/tw/ch7#fig_sharding_cassandra) 的示例中,當新增節點 3 時,節點 1 將其兩個範圍的部分轉移到節點 3節點 2 將其一個範圍的部分轉移到節點 3。這樣做的效果是給新節點一個大致公平的資料集份額而不會在節點之間傳輸超過必要的資料。
當新增或刪除節點時,會新增和刪除範圍邊界,並相應地分割或合併分片 [^19]。在 [圖 7-6](#fig_sharding_cassandra) 的示例中,當新增節點 3 時,節點 1 將其兩個範圍的部分轉移到節點 3節點 2 將其一個範圍的部分轉移到節點 3。這樣做的效果是給新節點一個大致公平的資料集份額而不會在節點之間傳輸超過必要的資料。
#### 一致性雜湊 {#sec_sharding_consistent_hashing}
@ -245,7 +245,7 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始
我們稱這個問題為 *請求路由*,它與 *服務發現* 非常相似,我們之前在 ["負載均衡器、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery) 中討論過。兩者之間最大的區別是,對於執行應用程式程式碼的服務,每個例項通常是無狀態的,負載均衡器可以將請求傳送到任何例項。對於分片資料庫,對鍵的請求只能由包含該鍵的分片的副本節點處理。
這意味著請求路由必須知道鍵到分片的分配,以及分片到節點的分配。在高層次上,這個問題有幾種不同的方法(在 [圖 7-7](/tw/ch7#fig_sharding_routing) 中說明):
這意味著請求路由必須知道鍵到分片的分配,以及分片到節點的分配。在高層次上,這個問題有幾種不同的方法(在 [圖 7-7](#fig_sharding_routing) 中說明):
1. 允許客戶端連線任何節點(例如,透過迴圈負載均衡器)。如果該節點恰好擁有請求適用的分片,它可以直接處理請求;否則,它將請求轉發到適當的節點,接收回復,並將回覆傳遞給客戶端。
2. 首先將客戶端的所有請求傳送到路由層,該層確定應該處理每個請求的節點並相應地轉發它。這個路由層本身不處理任何請求;它只充當分片感知的負載均衡器。
@ -259,7 +259,7 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始
* 執行路由的元件(可能是節點之一、路由層或客戶端)如何瞭解分片到節點分配的變化?
* 當分片從一個節點移動到另一個節點時,有一個切換期,在此期間新節點已接管,但對舊節點的請求可能仍在傳輸中。如何處理這些?
許多分散式資料系統依賴於單獨的協調服務(如 ZooKeeper 或 etcd來跟蹤分片分配如 [圖 7-8](/tw/ch7#fig_sharding_zookeeper) 所示。它們使用共識演算法(見 [第 10 章](/tw/ch10#ch_consistency))來提供容錯和防止腦裂。每個節點在 ZooKeeper 中註冊自己ZooKeeper 維護分片到節點的權威對映。其他參與者,如路由層或分片感知客戶端,可以在 ZooKeeper 中訂閱此資訊。每當分片所有權發生變化或者新增或刪除節點時ZooKeeper 都會通知路由層,以便它可以保持其路由資訊最新。
許多分散式資料系統依賴於單獨的協調服務(如 ZooKeeper 或 etcd來跟蹤分片分配如 [圖 7-8](#fig_sharding_zookeeper) 所示。它們使用共識演算法(見 [第 10 章](/tw/ch10#ch_consistency))來提供容錯和防止腦裂。每個節點在 ZooKeeper 中註冊自己ZooKeeper 維護分片到節點的權威對映。其他參與者,如路由層或分片感知客戶端,可以在 ZooKeeper 中訂閱此資訊。每當分片所有權發生變化或者新增或刪除節點時ZooKeeper 都會通知路由層,以便它可以保持其路由資訊最新。
{{< figure src="/fig/ddia_0708.png" id="fig_sharding_zookeeper" caption="圖 7-8. 使用 ZooKeeper 跟蹤分片到節點的分配。" class="w-full my-4" >}}
@ -281,7 +281,7 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
### 本地二級索引 {#id166}
例如,假設你正在運營一個出售二手車的網站(如 [圖 7-9](/tw/ch7#fig_sharding_local_secondary) 所示)。每個列表都有一個唯一的 ID——稱之為文件 ID——你使用該 ID 作為分割槽鍵對資料庫進行分片例如ID 0 到 499 在分片 0 中ID 500 到 999 在分片 1 中,等等)。
例如,假設你正在運營一個出售二手車的網站(如 [圖 7-9](#fig_sharding_local_secondary) 所示)。每個列表都有一個唯一的 ID——稱之為文件 ID——你使用該 ID 作為分割槽鍵對資料庫進行分片例如ID 0 到 499 在分片 0 中ID 500 到 999 在分片 1 中,等等)。
如果你想讓使用者搜尋汽車,允許他們按顏色和製造商過濾,你需要在 `color``make` 上建立二級索引(在文件資料庫中這些是欄位;在關係資料庫中這些是列)。如果你已宣告索引,資料庫就可以自動維護索引。例如,每當一輛紅色汽車被寫入資料庫,所在分片會自動將其 ID 加入索引條目 `color:red` 對應的文件 ID 列表。正如 [第 4 章](/tw/ch4#ch_storage) 所述,這個 ID 列表也稱為 *倒排列表*
@ -297,7 +297,7 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
當從本地二級索引讀取時,如果你已經知道你正在查詢的記錄的分割槽鍵,你可以只在適當的分片上執行搜尋。此外,如果你只想要 *一些* 結果,而不需要全部,你可以將請求傳送到任何分片。
但是,如果你想要所有結果並且事先不知道它們的分割槽鍵,你需要將查詢傳送到所有分片,並組合你收到的結果,因為匹配的記錄可能分散在所有分片中。在 [圖 7-9](/tw/ch7#fig_sharding_local_secondary) 中,紅色汽車出現在分片 0 和分片 1 中。
但是,如果你想要所有結果並且事先不知道它們的分割槽鍵,你需要將查詢傳送到所有分片,並組合你收到的結果,因為匹配的記錄可能分散在所有分片中。在 [圖 7-9](#fig_sharding_local_secondary) 中,紅色汽車出現在分片 0 和分片 1 中。
這種查詢分片資料庫的方法有時稱為 *分散/收集*scatter/gather它可能使二級索引讀取變得相當昂貴。即使並行查詢各分片分散/收集也容易導致尾部延遲放大(見 ["響應時間指標的使用"](/tw/ch2#sec_introduction_slo_sla))。它還會限制應用的可伸縮性:增加分片可以提升可儲存資料量,但若每個查詢仍需所有分片參與,查詢吞吐量並不會隨分片數增加而提升。
@ -307,13 +307,13 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
我們可以構建一個覆蓋所有分片資料的 *全域性索引*,而不是每個分片有自己的本地二級索引。但是,我們不能只將該索引儲存在一個節點上,因為它可能會成為瓶頸並違背分片的目的。全域性索引也必須進行分片,但它可以以不同於主鍵索引的方式進行分片。
[圖 7-10](/tw/ch7#fig_sharding_global_secondary) 說明了這可能是什麼樣子:來自所有分片的紅色汽車的 ID 出現在索引的 `color:red` 下,但索引是分片的,以便以字母 *a**r* 開頭的顏色出現在分片 0 中,以 *s**z* 開頭的顏色出現在分片 1 中。汽車製造商的索引也類似地分割槽(分片邊界在 *f**h* 之間)。
[圖 7-10](#fig_sharding_global_secondary) 說明了這可能是什麼樣子:來自所有分片的紅色汽車的 ID 出現在索引的 `color:red` 下,但索引是分片的,以便以字母 *a**r* 開頭的顏色出現在分片 0 中,以 *s**z* 開頭的顏色出現在分片 1 中。汽車製造商的索引也類似地分割槽(分片邊界在 *f**h* 之間)。
{{< figure src="/fig/ddia_0710.png" id="fig_sharding_global_secondary" caption="圖 7-10. 全域性二級索引反映來自所有分片的資料,並且本身按索引值進行分片。" class="w-full my-4" >}}
這種索引也稱為 *基於詞項分割槽* [^30]:回憶一下 ["全文檢索"](/tw/ch4#sec_storage_full_text),在全文檢索中,*詞項* 是你可以搜尋的文字中的關鍵字。這裡我們將其推廣為指二級索引中你可以搜尋的任何值。
全域性索引使用詞項作為分割槽鍵,因此當你查詢特定詞項或值時,你可以找出需要查詢哪個分片。和以前一樣,分片可以包含連續的詞項範圍(如 [圖 7-10](/tw/ch7#fig_sharding_global_secondary)),或者你可以基於詞項的雜湊將詞項分配給分片。
全域性索引使用詞項作為分割槽鍵,因此當你查詢特定詞項或值時,你可以找出需要查詢哪個分片。和以前一樣,分片可以包含連續的詞項範圍(如 [圖 7-10](#fig_sharding_global_secondary)),或者你可以基於詞項的雜湊將詞項分配給分片。
全域性索引的優點是,只有一個查詢條件時(如 *color = red*),只需從一個分片讀取即可獲得倒排列表。但如果你不僅要 ID還要取回完整記錄仍然必須去負責這些 ID 的各個分片讀取。

View file

@ -34,7 +34,7 @@ breadcrumbs: false
在本章中,我們將研究許多可能出錯的案例,並探索資料庫用於防範這些問題的演算法。我們將特別深入併發控制領域,討論可能發生的各種競態條件,以及資料庫如何實現*讀已提交*、*快照隔離*和*可序列化*等隔離級別。
併發控制對單節點和分散式資料庫都很重要。在本章後面的["分散式事務"](/tw/ch8#sec_transactions_distributed)部分,我們將研究*兩階段提交*協議和在分散式事務中實現原子性的挑戰。
併發控制對單節點和分散式資料庫都很重要。在本章後面的["分散式事務"](#sec_transactions_distributed)部分,我們將研究*兩階段提交*協議和在分散式事務中實現原子性的挑戰。
## 事務到底是什麼? {#sec_transactions_overview}
@ -60,7 +60,7 @@ breadcrumbs: false
一般來說,*原子*是指不能分解成更小部分的東西。這個詞在計算機的不同分支中意味著相似但又微妙不同的東西。例如,在多執行緒程式設計中,如果一個執行緒執行原子操作,這意味著另一個執行緒無法看到該操作的半完成結果。系統只能處於操作之前或操作之後的狀態,而不是介於兩者之間。
相比之下,在 ACID 的上下文中,原子性*不是*關於併發的。它不描述如果幾個程序試圖同時訪問相同的資料會發生什麼,因為這包含在字母 *I**隔離性*)中(參見["隔離性"](/tw/ch8#sec_transactions_acid_isolation))。
相比之下,在 ACID 的上下文中,原子性*不是*關於併發的。它不描述如果幾個程序試圖同時訪問相同的資料會發生什麼,因為這包含在字母 *I**隔離性*)中(參見["隔離性"](#sec_transactions_acid_isolation))。
相反ACID 原子性描述了當客戶端想要進行多次寫入,但在某些寫入被處理後發生故障時會發生什麼——例如,程序崩潰、網路連線中斷、磁碟變滿或違反了某些完整性約束。如果這些寫入被分組到一個原子事務中,並且由於故障無法完成(*提交*)事務,則事務被*中止*,資料庫必須丟棄或撤消該事務中迄今為止所做的任何寫入。
@ -90,14 +90,14 @@ ACID 一致性的思想是,你對資料有某些陳述(*不變式*)必須
大多數資料庫都會同時被多個客戶端訪問。如果它們讀寫資料庫的不同部分,這沒有問題,但如果它們訪問相同的資料庫記錄,你可能會遇到併發問題(競態條件)。
[圖 8-1](/tw/ch8#fig_transactions_increment) 是這種問題的一個簡單例子。假設你有兩個客戶端同時遞增儲存在資料庫中的計數器。每個客戶端需要讀取當前值,加 1然後寫回新值假設資料庫中沒有內建的遞增操作。在[圖 8-1](/tw/ch8#fig_transactions_increment) 中,計數器應該從 42 增加到 44因為發生了兩次遞增但實際上由於競態條件只增加到 43。
[圖 8-1](#fig_transactions_increment) 是這種問題的一個簡單例子。假設你有兩個客戶端同時遞增儲存在資料庫中的計數器。每個客戶端需要讀取當前值,加 1然後寫回新值假設資料庫中沒有內建的遞增操作。在[圖 8-1](#fig_transactions_increment) 中,計數器應該從 42 增加到 44因為發生了兩次遞增但實際上由於競態條件只增加到 43。
{{< figure src="/fig/ddia_0801.png" id="fig_transactions_increment" caption="圖 8-1. 兩個客戶端併發遞增計數器之間的競態條件。" class="w-full my-4" >}}
ACID 意義上的*隔離性*意味著同時執行的事務彼此隔離:它們不能相互干擾。經典的資料庫教科書將隔離性形式化為*可序列化*,這意味著每個事務可以假裝它是唯一在整個資料庫上執行的事務。資料庫確保當事務已經提交時,結果與它們*序列*執行(一個接一個)相同,即使實際上它們可能是併發執行的[^13]。
然而,可序列化有效能成本。在實踐中,許多資料庫使用比可序列化更弱的隔離形式:也就是說,它們允許併發事務以有限的方式相互干擾。一些流行的資料庫,如 Oracle甚至沒有實現它Oracle 有一個稱為"可序列化"的隔離級別,但它實際上實現了*快照隔離*,這是比可序列化更弱的保證[^10] [^14])。這意味著某些型別的競態條件仍然可能發生。我們將在["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)中探討快照隔離和其他形式的隔離。
然而,可序列化有效能成本。在實踐中,許多資料庫使用比可序列化更弱的隔離形式:也就是說,它們允許併發事務以有限的方式相互干擾。一些流行的資料庫,如 Oracle甚至沒有實現它Oracle 有一個稱為"可序列化"的隔離級別,但它實際上實現了*快照隔離*,這是比可序列化更弱的保證[^10] [^14])。這意味著某些型別的競態條件仍然可能發生。我們將在["弱隔離級別"](#sec_transactions_isolation_levels)中探討快照隔離和其他形式的隔離。
#### 永續性 {#durability}
@ -140,7 +140,7 @@ ACID 意義上的*隔離性*意味著同時執行的事務彼此隔離:它們
隔離性
: 併發執行的事務不應該相互干擾。例如,如果一個事務進行多次寫入,那麼另一個事務應該看到所有或不看到這些寫入,但不是某些子集。
這些定義假設你想要同時修改多個物件(行、文件、記錄)。這種*多物件事務*通常需要保持多塊資料同步。[圖 8-2](/tw/ch8#fig_transactions_read_uncommitted) 顯示了一個來自電子郵件應用程式的示例。要顯示使用者的未讀訊息數,你可以查詢類似這樣的內容:
這些定義假設你想要同時修改多個物件(行、文件、記錄)。這種*多物件事務*通常需要保持多塊資料同步。[圖 8-2](#fig_transactions_read_uncommitted) 顯示了一個來自電子郵件應用程式的示例。要顯示使用者的未讀訊息數,你可以查詢類似這樣的內容:
```
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
@ -151,9 +151,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
然而,如果有很多電子郵件,你可能會發現這個查詢太慢,並決定將未讀訊息的數量儲存在一個單獨的欄位中(一種反正規化,我們在["正規化、反正規化和連線"](/tw/ch3#sec_datamodels_normalization)中討論)。現在,每當有新訊息進來時,你必須增加未讀計數器,每當訊息被標記為已讀時,你也必須減少未讀計數器。
在[圖 8-2](/tw/ch8#fig_transactions_read_uncommitted) 中,使用者 2 遇到了異常:郵箱列表顯示有未讀訊息,但計數器顯示零未讀訊息,因為計數器增量尚未發生。(如果電子郵件應用程式中的錯誤計數器看起來太微不足道,請考慮客戶賬戶餘額而不是未讀計數器,以及支付事務而不是電子郵件。)隔離本可以透過確保使用者 2 看到插入的電子郵件和更新的計數器,或者兩者都不看到,但不是不一致的中間點,來防止這個問題。
在[圖 8-2](#fig_transactions_read_uncommitted) 中,使用者 2 遇到了異常:郵箱列表顯示有未讀訊息,但計數器顯示零未讀訊息,因為計數器增量尚未發生。(如果電子郵件應用程式中的錯誤計數器看起來太微不足道,請考慮客戶賬戶餘額而不是未讀計數器,以及支付事務而不是電子郵件。)隔離本可以透過確保使用者 2 看到插入的電子郵件和更新的計數器,或者兩者都不看到,但不是不一致的中間點,來防止這個問題。
[圖 8-3](/tw/ch8#fig_transactions_atomicity) 說明了對原子性的需求:如果在事務過程中某處發生錯誤,郵箱的內容和未讀計數器可能會失去同步。在原子事務中,如果對計數器的更新失敗,事務將被中止,插入的電子郵件將被回滾。
[圖 8-3](#fig_transactions_atomicity) 說明了對原子性的需求:如果在事務過程中某處發生錯誤,郵箱的內容和未讀計數器可能會失去同步。在原子事務中,如果對計數器的更新失敗,事務將被中止,插入的電子郵件將被回滾。
{{< figure src="/fig/ddia_0803.png" id="fig_transactions_atomicity" caption="圖 8-3. 原子性確保如果發生錯誤,該事務的任何先前寫入都會被撤消,以避免不一致的狀態。" class="w-full my-4" >}}
@ -172,7 +172,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
這些問題會令人非常困惑,因此儲存引擎幾乎普遍的目標是在一個節點上的單個物件(如鍵值對)上提供原子性和隔離性。原子性可以使用日誌實現崩潰恢復(參見["使 B 樹可靠"](/tw/ch4#sec_storage_btree_wal)),隔離性可以使用每個物件上的鎖來實現(一次只允許一個執行緒訪問物件)。
某些資料庫還提供更複雜的原子操作,例如遞增操作,它消除了像[圖 8-1](/tw/ch8#fig_transactions_increment) 中那樣的讀-修改-寫迴圈的需求。類似流行的是*條件寫入*操作,它允許僅在值未被其他人併發更改時才進行寫入(參見["條件寫入(比較並設定)"](/tw/ch8#sec_transactions_compare_and_set)類似於共享記憶體併發中的比較並設定或比較並交換CAS操作。
某些資料庫還提供更複雜的原子操作,例如遞增操作,它消除了像[圖 8-1](#fig_transactions_increment) 中那樣的讀-修改-寫迴圈的需求。類似流行的是*條件寫入*操作,它允許僅在值未被其他人併發更改時才進行寫入(參見["條件寫入(比較並設定)"](#sec_transactions_compare_and_set)類似於共享記憶體併發中的比較並設定或比較並交換CAS操作。
--------
@ -181,7 +181,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
--------
這些單物件操作很有用,因為它們可以防止多個客戶端嘗試同時寫入同一物件時的丟失更新(參見["防止丟失更新"](/tw/ch8#sec_transactions_lost_update)。然而它們不是通常意義上的事務。例如Cassandra 和 ScyllaDB 的"輕量級事務"功能以及 Aerospike 的"強一致性"模式在單個物件上提供線性一致(參見["線性一致性"](/tw/ch10#sec_consistency_linearizability))讀取和條件寫入,但不保證跨多個物件。
這些單物件操作很有用,因為它們可以防止多個客戶端嘗試同時寫入同一物件時的丟失更新(參見["防止丟失更新"](#sec_transactions_lost_update)。然而它們不是通常意義上的事務。例如Cassandra 和 ScyllaDB 的"輕量級事務"功能以及 Aerospike 的"強一致性"模式在單個物件上提供線性一致(參見["線性一致性"](/tw/ch10#sec_consistency_linearizability))讀取和條件寫入,但不保證跨多個物件。
#### 多物件事務的需求 {#sec_transactions_need}
@ -190,10 +190,10 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
在某些用例中,單物件插入、更新和刪除就足夠了。然而,在許多其他情況下,需要協調對多個不同物件的寫入:
* 在關係資料模型中,一個表中的行通常具有對另一個表中行的外部索引鍵引用。類似地,在類似圖的資料模型中,頂點具有指向其他頂點的邊。多物件事務允許你確保這些引用保持有效:插入引用彼此的多個記錄時,外部索引鍵必須正確且最新,否則資料變得毫無意義。
* 在文件資料模型中,需要一起更新的欄位通常在同一文件內,它被視為單個物件——更新單個文件時不需要多物件事務。然而,缺乏連線功能的文件資料庫也鼓勵反正規化(參見["何時使用哪種模型"](/tw/ch3#sec_datamodels_document_summary))。當需要更新反正規化資訊時,如[圖 8-2](/tw/ch8#fig_transactions_read_uncommitted) 的示例,你需要一次更新多個文件。事務在這種情況下非常有用,可以防止反正規化資料失去同步。
* 在文件資料模型中,需要一起更新的欄位通常在同一文件內,它被視為單個物件——更新單個文件時不需要多物件事務。然而,缺乏連線功能的文件資料庫也鼓勵反正規化(參見["何時使用哪種模型"](/tw/ch3#sec_datamodels_document_summary))。當需要更新反正規化資訊時,如[圖 8-2](#fig_transactions_read_uncommitted) 的示例,你需要一次更新多個文件。事務在這種情況下非常有用,可以防止反正規化資料失去同步。
* 在具有二級索引的資料庫中(幾乎除了純鍵值儲存之外的所有資料庫),每次更改值時都需要更新索引。從事務的角度來看,這些索引是不同的資料庫物件:例如,如果沒有事務隔離,記錄可能出現在一個索引中但不在另一個索引中,因為對第二個索引的更新尚未發生(參見["分片和二級索引"](/tw/ch7#sec_sharding_secondary_indexes))。
這些應用程式仍然可以在沒有事務的情況下實現。然而,沒有原子性的錯誤處理變得更加複雜,缺乏隔離性可能導致併發問題。我們將在["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)中討論這些問題,並在["派生資料與分散式事務"](/tw/ch13#sec_future_derived_vs_transactions)中探索替代方法。
這些應用程式仍然可以在沒有事務的情況下實現。然而,沒有原子性的錯誤處理變得更加複雜,缺乏隔離性可能導致併發問題。我們將在["弱隔離級別"](#sec_transactions_isolation_levels)中討論這些問題,並在["派生資料與分散式事務"](/tw/ch13#sec_future_derived_vs_transactions)中探索替代方法。
#### 處理錯誤和中止 {#handling-errors-and-aborts}
@ -208,7 +208,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
* 如果事務實際上成功了,但在伺服器嘗試向客戶端確認成功提交時網路中斷(因此從客戶端的角度來看超時),那麼重試事務會導致它被執行兩次——除非你有額外的應用程式級去重機制。
* 如果錯誤是由於過載或併發事務之間的高爭用,重試事務會使問題變得更糟,而不是更好。為了避免這種反饋迴圈,你可以限制重試次數,使用指數退避,並以不同的方式處理與過載相關的錯誤與其他錯誤(參見["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable))。
* 僅在瞬態錯誤後重試才值得(例如,由於死鎖、隔離違規、臨時網路中斷和故障轉移);在永久錯誤後(例如,約束違規)重試將毫無意義。
* 如果事務在資料庫之外也有副作用,即使事務被中止,這些副作用也可能發生。例如,如果你正在傳送電子郵件,你不會希望每次重試事務時都再次傳送電子郵件。如果你想確保幾個不同的系統一起提交或中止,兩階段提交可以提供幫助(我們將在["兩階段提交2PC"](/tw/ch8#sec_transactions_2pc)中討論這個問題)。
* 如果事務在資料庫之外也有副作用,即使事務被中止,這些副作用也可能發生。例如,如果你正在傳送電子郵件,你不會希望每次重試事務時都再次傳送電子郵件。如果你想確保幾個不同的系統一起提交或中止,兩階段提交可以提供幫助(我們將在["兩階段提交2PC"](#sec_transactions_2pc)中討論這個問題)。
* 如果客戶端程序在重試時崩潰,它試圖寫入資料庫的任何資料都會丟失。
@ -234,7 +234,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
這些例子還強調了一個重要觀點:即使併發問題在正常操作中很少見,你也必須考慮攻擊者故意向你的 API 傳送大量高度併發請求以故意利用併發錯誤的可能性[^30]。因此,為了構建可靠和安全的應用程式,你必須確保系統地防止此類錯誤。
在本節中,我們將研究實踐中使用的幾種弱(非可序列化)隔離級別,並詳細討論哪些競態條件可以發生和不能發生,以便你可以決定哪個級別適合你的應用程式。完成後,我們將詳細討論可序列化(參見["可序列化"](/tw/ch8#sec_transactions_serializability))。我們對隔離級別的討論將是非正式的,使用示例。如果你想要嚴格的定義和對其屬性的分析,你可以在學術文獻中找到它們[^36] [^37] [^38] [^39]。
在本節中,我們將研究實踐中使用的幾種弱(非可序列化)隔離級別,並詳細討論哪些競態條件可以發生和不能發生,以便你可以決定哪個級別適合你的應用程式。完成後,我們將詳細討論可序列化(參見["可序列化"](#sec_transactions_serializability))。我們對隔離級別的討論將是非正式的,使用示例。如果你想要嚴格的定義和對其屬性的分析,你可以在學術文獻中找到它們[^36] [^37] [^38] [^39]。
### 讀已提交 {#sec_transactions_read_committed}
@ -249,14 +249,14 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
想象一個事務已經向資料庫寫入了一些資料,但事務尚未提交或中止。另一個事務能看到那個未提交的資料嗎?如果能,這稱為*髒讀*[^3]。
在讀已提交隔離級別下執行的事務必須防止髒讀。這意味著事務的任何寫入只有在該事務提交時才對其他人可見(然後它的所有寫入立即變得可見)。這在[圖 8-4](/tw/ch8#fig_transactions_read_committed) 中說明,其中使用者 1 已設定 *x* = 3但使用者 2 的 *get x* 仍返回舊值 2因為使用者 1 尚未提交。
在讀已提交隔離級別下執行的事務必須防止髒讀。這意味著事務的任何寫入只有在該事務提交時才對其他人可見(然後它的所有寫入立即變得可見)。這在[圖 8-4](#fig_transactions_read_committed) 中說明,其中使用者 1 已設定 *x* = 3但使用者 2 的 *get x* 仍返回舊值 2因為使用者 1 尚未提交。
{{< figure src="/fig/ddia_0804.png" id="fig_transactions_read_committed" caption="圖 8-4. 沒有髒讀:使用者 2 只有在使用者 1 的事務提交後才能看到 x 的新值。" class="w-full my-4" >}}
有幾個原因說明為什麼防止髒讀是有用的:
* 如果事務需要更新多行,髒讀意味著另一個事務可能看到某些更新但不是其他更新。例如,在[圖 8-2](/tw/ch8#fig_transactions_read_uncommitted) 中,使用者看到新的未讀電子郵件但沒有看到更新的計數器。這是電子郵件的髒讀。看到資料庫處於部分更新狀態會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。
* 如果事務中止,它所做的任何寫入都需要回滾(如[圖 8-3](/tw/ch8#fig_transactions_atomicity))。如果資料庫允許髒讀,這意味著事務可能看到後來被回滾的資料——即從未實際提交到資料庫的資料。任何讀取未提交資料的事務也需要被中止,導致稱為*級聯中止*的問題。
* 如果事務需要更新多行,髒讀意味著另一個事務可能看到某些更新但不是其他更新。例如,在[圖 8-2](#fig_transactions_read_uncommitted) 中,使用者看到新的未讀電子郵件但沒有看到更新的計數器。這是電子郵件的髒讀。看到資料庫處於部分更新狀態會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。
* 如果事務中止,它所做的任何寫入都需要回滾(如[圖 8-3](#fig_transactions_atomicity))。如果資料庫允許髒讀,這意味著事務可能看到後來被回滾的資料——即從未實際提交到資料庫的資料。任何讀取未提交資料的事務也需要被中止,導致稱為*級聯中止*的問題。
#### 沒有髒寫 {#sec_transactions_dirty_write}
@ -266,8 +266,8 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
透過防止髒寫,這個隔離級別避免了某些型別的併發問題:
* 如果事務更新多行,髒寫可能導致糟糕的結果。例如,考慮[圖 8-5](/tw/ch8#fig_transactions_dirty_writes),它說明了一個二手車銷售網站,兩個人 Aaliyah 和 Bryce 同時嘗試購買同一輛車。購買汽車需要兩次資料庫寫入:網站上的列表需要更新以反映買家,銷售發票需要傳送給買家。在[圖 8-5](/tw/ch8#fig_transactions_dirty_writes) 的情況下,銷售被授予 Bryce因為他對 `listings` 表執行了獲勝的更新),但發票被傳送給 Aaliyah因為她對 `invoices` 表執行了獲勝的更新)。讀已提交防止了這種事故。
* 然而,讀已提交*不*防止[圖 8-1](/tw/ch8#fig_transactions_increment) 中兩個計數器遞增之間的競態條件。在這種情況下,第二個寫入發生在第一個事務提交之後,所以它不是髒寫。它仍然是不正確的,但原因不同——在["防止丟失更新"](/tw/ch8#sec_transactions_lost_update)中,我們將討論如何使此類計數器遞增安全。
* 如果事務更新多行,髒寫可能導致糟糕的結果。例如,考慮[圖 8-5](#fig_transactions_dirty_writes),它說明了一個二手車銷售網站,兩個人 Aaliyah 和 Bryce 同時嘗試購買同一輛車。購買汽車需要兩次資料庫寫入:網站上的列表需要更新以反映買家,銷售發票需要傳送給買家。在[圖 8-5](#fig_transactions_dirty_writes) 的情況下,銷售被授予 Bryce因為他對 `listings` 表執行了獲勝的更新),但發票被傳送給 Aaliyah因為她對 `invoices` 表執行了獲勝的更新)。讀已提交防止了這種事故。
* 然而,讀已提交*不*防止[圖 8-1](#fig_transactions_increment) 中兩個計數器遞增之間的競態條件。在這種情況下,第二個寫入發生在第一個事務提交之後,所以它不是髒寫。它仍然是不正確的,但原因不同——在["防止丟失更新"](#sec_transactions_lost_update)中,我們將討論如何使此類計數器遞增安全。
{{< figure src="/fig/ddia_0805.png" id="fig_transactions_dirty_writes" caption="圖 8-5. 有了髒寫,來自不同事務的衝突寫入可能會混在一起。" class="w-full my-4" >}}
@ -284,13 +284,13 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
儘管如此,在某些資料庫中使用鎖來防止髒讀,例如 IBM Db2 和 Microsoft SQL Server 在 `read_committed_snapshot=off` 設定中[^29]。
防止髒讀的更常用方法是[圖 8-4](/tw/ch8#fig_transactions_read_committed) 中說明的方法:對於每個被寫入的行,資料庫記住舊的已提交值和當前持有寫鎖的事務設定的新值。當事務正在進行時,任何其他讀取該行的事務都只是被給予舊值。只有當新值被提交時,事務才會切換到讀取新值(有關更多詳細資訊,請參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl))。
防止髒讀的更常用方法是[圖 8-4](#fig_transactions_read_committed) 中說明的方法:對於每個被寫入的行,資料庫記住舊的已提交值和當前持有寫鎖的事務設定的新值。當事務正在進行時,任何其他讀取該行的事務都只是被給予舊值。只有當新值被提交時,事務才會切換到讀取新值(有關更多詳細資訊,請參見["多版本併發控制MVCC"](#sec_transactions_snapshot_impl))。
### 快照隔離與可重複讀 {#sec_transactions_snapshot_isolation}
如果你膚淺地看待讀已提交隔離,你可能會被原諒認為它做了事務需要做的一切:它允許中止(原子性所需),它防止讀取事務的不完整結果,並且它防止併發寫入混淆。確實,這些是有用的功能,比沒有事務的系統能獲得的保證要強得多。
然而,使用這個隔離級別時,仍然有很多方式可能出現併發錯誤。例如,[圖 8-6](/tw/ch8#fig_transactions_item_many_preceders) 說明了讀已提交可能發生的問題。
然而,使用這個隔離級別時,仍然有很多方式可能出現併發錯誤。例如,[圖 8-6](#fig_transactions_item_many_preceders) 說明了讀已提交可能發生的問題。
{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="圖 8-6. 讀取偏差Aaliyah 觀察到資料庫處於不一致狀態。" class="w-full my-4" >}}
@ -322,18 +322,18 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
#### 多版本併發控制MVCC {#sec_transactions_snapshot_impl}
與讀已提交隔離一樣,快照隔離的實現通常使用寫鎖來防止髒寫(參見["實現讀已提交"](/tw/ch8#sec_transactions_read_committed_impl)),這意味著進行寫入的事務可以阻止寫入同一行的另一個事務的進度。但是,讀取不需要任何鎖。從效能的角度來看,快照隔離的一個關鍵原則是*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*。這允許資料庫在一致快照上處理長時間執行的讀查詢,同時正常處理寫入,兩者之間沒有任何鎖爭用。
與讀已提交隔離一樣,快照隔離的實現通常使用寫鎖來防止髒寫(參見["實現讀已提交"](#sec_transactions_read_committed_impl)),這意味著進行寫入的事務可以阻止寫入同一行的另一個事務的進度。但是,讀取不需要任何鎖。從效能的角度來看,快照隔離的一個關鍵原則是*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*。這允許資料庫在一致快照上處理長時間執行的讀查詢,同時正常處理寫入,兩者之間沒有任何鎖爭用。
為了實現快照隔離,資料庫使用了我們在[圖 8-4](/tw/ch8#fig_transactions_read_committed) 中看到的防止髒讀機制的泛化。資料庫必須潛在地保留每行的幾個不同的已提交版本,而不是每行的兩個版本(已提交版本和被覆蓋但尚未提交的版本),因為各種正在進行的事務可能需要在不同時間點看到資料庫的狀態。因為它並排維護一行的多個版本,所以這種技術被稱為*多版本併發控制*MVCC
為了實現快照隔離,資料庫使用了我們在[圖 8-4](#fig_transactions_read_committed) 中看到的防止髒讀機制的泛化。資料庫必須潛在地保留每行的幾個不同的已提交版本,而不是每行的兩個版本(已提交版本和被覆蓋但尚未提交的版本),因為各種正在進行的事務可能需要在不同時間點看到資料庫的狀態。因為它並排維護一行的多個版本,所以這種技術被稱為*多版本併發控制*MVCC
[圖 8-7](/tw/ch8#fig_transactions_mvcc) 說明了 PostgreSQL 中如何實現基於 MVCC 的快照隔離[^40] [^42] [^43](其他實現類似)。當事務啟動時,它被賦予一個唯一的、始終遞增的事務 ID`txid`)。每當事務向資料庫寫入任何內容時,它寫入的資料都用寫入者的事務 ID 標記。準確地說PostgreSQL 中的事務 ID 是 32 位整數,因此它們在大約 40 億個事務後溢位。清理過程執行清理以確保溢位不會影響資料。)
[圖 8-7](#fig_transactions_mvcc) 說明了 PostgreSQL 中如何實現基於 MVCC 的快照隔離[^40] [^42] [^43](其他實現類似)。當事務啟動時,它被賦予一個唯一的、始終遞增的事務 ID`txid`)。每當事務向資料庫寫入任何內容時,它寫入的資料都用寫入者的事務 ID 標記。準確地說PostgreSQL 中的事務 ID 是 32 位整數,因此它們在大約 40 億個事務後溢位。清理過程執行清理以確保溢位不會影響資料。)
{{< figure src="/fig/ddia_0807.png" id="fig_transactions_mvcc" caption="圖 8-7. 使用多版本併發控制實現快照隔離。" class="w-full my-4" >}}
表中的每一行都有一個 `inserted_by` 欄位,包含將此行插入表中的事務的 ID。此外每行都有一個 `deleted_by` 欄位,最初為空。如果事務刪除一行,該行實際上不會從資料庫中刪除,而是透過將 `deleted_by` 欄位設定為請求刪除的事務的 ID 來標記為刪除。在稍後的某個時間,當確定沒有事務可以再訪問已刪除的資料時,資料庫中的垃圾收集過程會刪除任何標記為刪除的行並釋放它們的空間。
更新在內部被轉換為刪除和插入[^44]。例如,在[圖 8-7](/tw/ch8#fig_transactions_mvcc) 中,事務 13 從賬戶 2 中扣除 100 美元,將餘額從 500 美元更改為 400 美元。`accounts` 表現在實際上包含賬戶 2 的兩行:餘額為 500 美元的行被事務 13 標記為已刪除,餘額為 400 美元的行由事務 13 插入。
更新在內部被轉換為刪除和插入[^44]。例如,在[圖 8-7](#fig_transactions_mvcc) 中,事務 13 從賬戶 2 中扣除 100 美元,將餘額從 500 美元更改為 400 美元。`accounts` 表現在實際上包含賬戶 2 的兩行:餘額為 500 美元的行被事務 13 標記為已刪除,餘額為 400 美元的行由事務 13 插入。
行的所有版本都儲存在同一個資料庫堆中(參見["在索引中儲存值"](/tw/ch4#sec_storage_index_heap)),無論寫入它們的事務是否已提交。同一行的版本形成一個連結串列,從最新版本到最舊版本或相反,以便查詢可以在內部迭代行的所有版本[^45] [^46]。
@ -346,7 +346,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
3. 中止事務所做的任何寫入都被忽略,無論該中止何時發生。這樣做的好處是,當事務中止時,我們不需要立即從儲存中刪除它寫入的行,因為可見性規則會將它們過濾掉。垃圾收集過程可以稍後刪除它們。
4. 所有其他寫入對應用程式的查詢可見。
這些規則適用於行的插入和刪除。在[圖 8-7](/tw/ch8#fig_transactions_mvcc) 中,當事務 12 從賬戶 2 讀取時,它看到 500 美元的餘額,因為 500 美元餘額的刪除是由事務 13 進行的(根據規則 2事務 12 無法看到事務 13 進行的刪除),而 400 美元餘額的插入尚不可見(根據相同的規則)。
這些規則適用於行的插入和刪除。在[圖 8-7](#fig_transactions_mvcc) 中,當事務 12 從賬戶 2 讀取時,它看到 500 美元的餘額,因為 500 美元餘額的刪除是由事務 13 進行的(根據規則 2事務 12 無法看到事務 13 進行的刪除),而 400 美元餘額的插入尚不可見(根據相同的規則)。
換句話說,如果以下兩個條件都為真,則行是可見的:
@ -379,9 +379,9 @@ MVCC 是資料庫常用的實現技術,通常用於實現快照隔離。然而
### 防止丟失更新 {#sec_transactions_lost_update}
到目前為止,我們討論的讀已提交和快照隔離級別主要是關於只讀事務在併發寫入存在的情況下可以看到什麼的保證。我們大多忽略了兩個事務併發寫入的問題——我們只討論了髒寫(參見["沒有髒寫"](/tw/ch8#sec_transactions_dirty_write)),這是可能發生的一種特定型別的寫-寫衝突。
到目前為止,我們討論的讀已提交和快照隔離級別主要是關於只讀事務在併發寫入存在的情況下可以看到什麼的保證。我們大多忽略了兩個事務併發寫入的問題——我們只討論了髒寫(參見["沒有髒寫"](#sec_transactions_dirty_write)),這是可能發生的一種特定型別的寫-寫衝突。
併發寫入事務之間還可能發生其他幾種有趣的衝突。其中最著名的是*丟失更新*問題,在[圖 8-1](/tw/ch8#fig_transactions_increment) 中以兩個併發計數器遞增的例子說明。
併發寫入事務之間還可能發生其他幾種有趣的衝突。其中最著名的是*丟失更新*問題,在[圖 8-1](#fig_transactions_increment) 中以兩個併發計數器遞增的例子說明。
如果應用程式從資料庫讀取某個值,修改它,然後寫回修改後的值(*讀-修改-寫迴圈*),就會出現丟失更新問題。如果兩個事務併發執行此操作,其中一個修改可能會丟失,因為第二個寫入不包括第一個修改。(我們有時說後面的寫入*覆蓋*了前面的寫入。)這種模式出現在各種不同的場景中:
@ -409,7 +409,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
如果資料庫的內建原子操作不提供必要的功能,另一個防止丟失更新的選項是應用程式顯式鎖定要更新的物件。然後應用程式可以執行讀-修改-寫迴圈,如果任何其他事務嘗試併發更新或鎖定同一物件,它將被迫等到第一個讀-修改-寫迴圈完成。
例如,考慮一個多人遊戲,其中幾個玩家可以同時移動同一個棋子。在這種情況下,原子操作可能不夠,因為應用程式還需要確保玩家的移動遵守遊戲規則,這涉及一些你無法合理地作為資料庫查詢實現的邏輯。相反,你可以使用鎖來防止兩個玩家同時移動同一個棋子,如[例 8-1](/tw/ch8#fig_transactions_select_for_update) 所示。
例如,考慮一個多人遊戲,其中幾個玩家可以同時移動同一個棋子。在這種情況下,原子操作可能不夠,因為應用程式還需要確保玩家的移動遵守遊戲規則,這涉及一些你無法合理地作為資料庫查詢實現的邏輯。相反,你可以使用鎖來防止兩個玩家同時移動同一個棋子,如[例 8-1](#fig_transactions_select_for_update) 所示。
{{< figure id="fig_transactions_select_for_update" title="例 8-1. 顯式鎖定行以防止丟失更新" class="w-full my-4" >}}
@ -443,7 +443,7 @@ COMMIT;
#### 條件寫入(比較並設定) {#sec_transactions_compare_and_set}
在不提供事務的資料庫中,你有時會發現一個*條件寫入*操作,它可以透過僅在值自你上次讀取以來未更改時才允許更新來防止丟失的更新(之前在["單物件寫入"](/tw/ch8#sec_transactions_single_object)中提到)。如果當前值與你之前讀取的不匹配,則更新無效,必須重試讀-修改-寫迴圈。它是許多 CPU 支援的原子*比較並設定*或*比較並交換*CAS指令的資料庫等價物。
在不提供事務的資料庫中,你有時會發現一個*條件寫入*操作,它可以透過僅在值自你上次讀取以來未更改時才允許更新來防止丟失的更新(之前在["單物件寫入"](#sec_transactions_single_object)中提到)。如果當前值與你之前讀取的不匹配,則更新無效,必須重試讀-修改-寫迴圈。它是許多 CPU 支援的原子*比較並設定*或*比較並交換*CAS指令的資料庫等價物。
例如,為了防止兩個使用者同時更新同一個 wiki 頁面,你可以嘗試類似這樣的操作,期望僅當頁面內容自使用者開始編輯以來沒有更改時才進行更新:
@ -455,7 +455,7 @@ UPDATE wiki_pages SET content = 'new content'
如果內容已更改並且不再匹配 `'old content'`,則此更新將無效,因此你需要檢查更新是否生效並在必要時重試。你也可以使用在每次更新時遞增的版本號列,並且僅在當前版本號未更改時才應用更新,而不是比較完整內容。這種方法有時稱為*樂觀鎖定*[^52]。
請注意,如果另一個事務併發修改了 `content`,則根據 MVCC 可見性規則,新內容可能不可見(參見["觀察一致快照的可見性規則"](/tw/ch8#sec_transactions_mvcc_visibility)。MVCC 的許多實現對此場景有可見性規則的例外,其中其他事務寫入的值對 `UPDATE``DELETE` 查詢的 `WHERE` 子句的評估可見,即使這些寫入在快照中不可見。
請注意,如果另一個事務併發修改了 `content`,則根據 MVCC 可見性規則,新內容可能不可見(參見["觀察一致快照的可見性規則"](#sec_transactions_mvcc_visibility)。MVCC 的許多實現對此場景有可見性規則的例外,其中其他事務寫入的值對 `UPDATE``DELETE` 查詢的 `WHERE` 子句的評估可見,即使這些寫入在快照中不可見。
#### 衝突解決與複製 {#conflict-resolution-and-replication}
@ -477,7 +477,7 @@ UPDATE wiki_pages SET content = 'new content'
首先,想象這個例子:你正在為醫生編寫一個應用程式來管理他們在醫院的值班班次。醫院通常試圖在任何時候都有幾位醫生值班,但絕對必須至少有一位醫生值班。醫生可以放棄他們的班次(例如,如果他們自己生病了),前提是該班次中至少有一位同事留在值班[^53] [^54]。
現在想象 Aaliyah 和 Bryce 是特定班次的兩位值班醫生。兩人都感覺不舒服,所以他們都決定請假。不幸的是,他們碰巧大約在同一時間點選了下班的按鈕。接下來發生的事情如[圖 8-8](/tw/ch8#fig_transactions_write_skew) 所示。
現在想象 Aaliyah 和 Bryce 是特定班次的兩位值班醫生。兩人都感覺不舒服,所以他們都決定請假。不幸的是,他們碰巧大約在同一時間點選了下班的按鈕。接下來發生的事情如[圖 8-8](#fig_transactions_write_skew) 所示。
{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="圖 8-8. 寫偏差導致應用程式錯誤的示例。" class="w-full my-4" >}}
@ -493,24 +493,24 @@ UPDATE wiki_pages SET content = 'new content'
我們看到有各種不同的方法可以防止丟失的更新。對於寫偏差,我們的選擇更受限制:
* 原子單物件操作沒有幫助,因為涉及多個物件。
* 不幸的是,你在某些快照隔離實現中發現的丟失更新的自動檢測也沒有幫助:寫偏差在 PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化或 SQL Server 的快照隔離級別中不會自動檢測到[^29]。自動防止寫偏差需要真正的可序列化隔離(參見["可序列化"](/tw/ch8#sec_transactions_serializability))。
* 某些資料庫允許你配置約束,然後由資料庫強制執行(例如,唯一性、外部索引鍵約束或對特定值的限制)。但是,為了指定至少有一個醫生必須值班,你需要一個涉及多個物件的約束。大多數資料庫沒有對此類約束的內建支援,但你可能能夠使用觸發器或物化檢視實現它們,如["一致性"](/tw/ch8#sec_transactions_acid_consistency)中所討論的[^12]。
* 不幸的是,你在某些快照隔離實現中發現的丟失更新的自動檢測也沒有幫助:寫偏差在 PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化或 SQL Server 的快照隔離級別中不會自動檢測到[^29]。自動防止寫偏差需要真正的可序列化隔離(參見["可序列化"](#sec_transactions_serializability))。
* 某些資料庫允許你配置約束,然後由資料庫強制執行(例如,唯一性、外部索引鍵約束或對特定值的限制)。但是,為了指定至少有一個醫生必須值班,你需要一個涉及多個物件的約束。大多數資料庫沒有對此類約束的內建支援,但你可能能夠使用觸發器或物化檢視實現它們,如["一致性"](#sec_transactions_acid_consistency)中所討論的[^12]。
* 如果你不能使用可序列化隔離級別,在這種情況下,第二好的選擇可能是顯式鎖定事務所依賴的行。在醫生示例中,你可以編寫如下內容:
```sql
BEGIN TRANSACTION;
```sql
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE; ❶
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE; ❶
UPDATE doctors
SET on_call = false
WHERE name = 'Aaliyah'
AND shift_id = 1234;
UPDATE doctors
SET on_call = false
WHERE name = 'Aaliyah'
AND shift_id = 1234;
COMMIT;
```
COMMIT;
```
❶:和以前一樣,`FOR UPDATE` 告訴資料庫鎖定此查詢返回的所有行。
@ -519,7 +519,7 @@ UPDATE wiki_pages SET content = 'new content'
寫偏差起初可能看起來是一個深奧的問題,但一旦你意識到它,你可能會注意到更多可能發生的情況。以下是更多示例:
會議室預訂系統
: 假設你想強制同一會議室在同一時間不能有兩個預訂[^55]。當有人想要預訂時,你首先檢查是否有任何衝突的預訂(即,具有重疊時間範圍的同一房間的預訂),如果沒有找到,你就建立會議(參見[例 8-2](/tw/ch8#fig_transactions_meeting_rooms))。
: 假設你想強制同一會議室在同一時間不能有兩個預訂[^55]。當有人想要預訂時,你首先檢查是否有任何衝突的預訂(即,具有重疊時間範圍的同一房間的預訂),如果沒有找到,你就建立會議(參見[例 8-2](#fig_transactions_meeting_rooms))。
{{< figure id="fig_transactions_meeting_rooms" title="例 8-2. 會議室預訂系統試圖避免重複預訂(在快照隔離下不安全)" class="w-full my-4" >}}
@ -541,7 +541,7 @@ UPDATE wiki_pages SET content = 'new content'
不幸的是,快照隔離不會阻止另一個使用者併發插入衝突的會議。為了保證你不會出現排程衝突,你再次需要可序列化隔離。
多人遊戲
: 在[例 8-1](/tw/ch8#fig_transactions_select_for_update) 中,我們使用鎖來防止丟失的更新(即,確保兩個玩家不能同時移動同一個棋子)。但是,鎖不會阻止玩家將兩個不同的棋子移動到棋盤上的同一位置,或者可能做出違反遊戲規則的其他移動。根據你要執行的規則型別,你可能能夠使用唯一約束,但否則你很容易受到寫偏差的影響。
: 在[例 8-1](#fig_transactions_select_for_update) 中,我們使用鎖來防止丟失的更新(即,確保兩個玩家不能同時移動同一個棋子)。但是,鎖不會阻止玩家將兩個不同的棋子移動到棋盤上的同一位置,或者可能做出違反遊戲規則的其他移動。根據你要執行的規則型別,你可能能夠使用唯一約束,但否則你很容易受到寫偏差的影響。
宣告使用者名稱
: 在每個使用者都有唯一使用者名稱的網站上,兩個使用者可能同時嘗試使用相同的使用者名稱建立賬戶。你可以使用事務來檢查名稱是否被佔用,如果沒有,使用該名稱建立賬戶。但是,就像前面的例子一樣,這在快照隔離下是不安全的。幸運的是,唯一約束在這裡是一個簡單的解決方案(嘗試註冊使用者名稱的第二個事務將由於違反約束而被中止)。
@ -591,9 +591,9 @@ UPDATE wiki_pages SET content = 'new content'
但如果可序列化隔離比弱隔離級別的混亂要好得多,那為什麼不是每個人都在使用它?要回答這個問題,我們需要檢視實現可序列化的選項,以及它們的效能如何。今天提供可序列化的大多數資料庫使用以下三種技術之一,我們將在本章的其餘部分探討:
* 字面上序列執行事務(參見["實際序列執行"](/tw/ch8#sec_transactions_serial)
* 兩階段鎖定(參見["兩階段鎖定2PL"](/tw/ch8#sec_transactions_2pl)),幾十年來這是唯一可行的選擇
* 樂觀併發控制技術,如可序列化快照隔離(參見["可序列化快照隔離SSI"](/tw/ch8#sec_transactions_ssi)
* 字面上序列執行事務(參見["實際序列執行"](#sec_transactions_serial)
* 兩階段鎖定(參見["兩階段鎖定2PL"](#sec_transactions_2pl)),幾十年來這是唯一可行的選擇
* 樂觀併發控制技術,如可序列化快照隔離(參見["可序列化快照隔離SSI"](#sec_transactions_ssi)
### 實際序列執行 {#sec_transactions_serial}
@ -620,9 +620,9 @@ UPDATE wiki_pages SET content = 'new content'
因此,具有單執行緒序列事務處理的系統不允許互動式多語句事務。相反,應用程式必須將自己限制為包含單個語句的事務,或者提前將整個事務程式碼作為*儲存過程*提交給資料庫[^61]。
互動式事務和儲存過程之間的差異如[圖 8-9](/tw/ch8#fig_transactions_stored_proc) 所示。前提是事務所需的所有資料都在記憶體中,儲存過程可以非常快速地執行,而無需等待任何網路或磁碟 I/O。
互動式事務和儲存過程之間的差異如[圖 8-9](#fig_transactions_stored_proc) 所示。前提是事務所需的所有資料都在記憶體中,儲存過程可以非常快速地執行,而無需等待任何網路或磁碟 I/O。
{{< figure src="/fig/ddia_0809.png" id="fig_transactions_stored_proc" caption="圖 8-9. 互動式事務和儲存過程之間的差異(使用[圖 8-8](/tw/ch8#fig_transactions_write_skew)的示例事務)。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0809.png" id="fig_transactions_stored_proc" caption="圖 8-9. 互動式事務和儲存過程之間的差異(使用[圖 8-8](#fig_transactions_write_skew)的示例事務)。" class="w-full my-4" >}}
#### 儲存過程的利弊 {#sec_transactions_stored_proc_tradeoffs}
@ -671,18 +671,18 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個
> [!TIP] 2PL 不是 2PC
兩階段*鎖定*2PL和兩階段*提交*2PC是兩個非常不同的東西。2PL 提供可序列化隔離,而 2PC 在分散式資料庫中提供原子提交(參見["兩階段提交2PC"](/tw/ch8#sec_transactions_2pc))。為避免混淆,最好將它們視為完全獨立的概念,並忽略名稱中不幸的相似性。
兩階段*鎖定*2PL和兩階段*提交*2PC是兩個非常不同的東西。2PL 提供可序列化隔離,而 2PC 在分散式資料庫中提供原子提交(參見["兩階段提交2PC"](#sec_transactions_2pc))。為避免混淆,最好將它們視為完全獨立的概念,並忽略名稱中不幸的相似性。
--------
我們之前看到鎖通常用於防止髒寫(參見["沒有髒寫"](/tw/ch8#sec_transactions_dirty_write)):如果兩個事務併發嘗試寫入同一物件,鎖確保第二個寫入者必須等到第一個完成其事務(中止或提交)後才能繼續。
我們之前看到鎖通常用於防止髒寫(參見["沒有髒寫"](#sec_transactions_dirty_write)):如果兩個事務併發嘗試寫入同一物件,鎖確保第二個寫入者必須等到第一個完成其事務(中止或提交)後才能繼續。
兩階段鎖定類似,但使鎖要求更強。只要沒有人寫入,多個事務就可以併發讀取同一物件。但是一旦有人想要寫入(修改或刪除)物件,就需要獨佔訪問:
* 如果事務 A 已讀取物件而事務 B 想要寫入該物件B 必須等到 A 提交或中止後才能繼續。(這確保 B 不能在 A 背後意外地更改物件。)
* 如果事務 A 已寫入物件而事務 B 想要讀取該物件B 必須等到 A 提交或中止後才能繼續。(像[圖 8-4](/tw/ch8#fig_transactions_read_committed) 中那樣讀取物件的舊版本在 2PL 下是不可接受的。)
* 如果事務 A 已寫入物件而事務 B 想要讀取該物件B 必須等到 A 提交或中止後才能繼續。(像[圖 8-4](#fig_transactions_read_committed) 中那樣讀取物件的舊版本在 2PL 下是不可接受的。)
在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*(參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏差。
在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*(參見["多版本併發控制MVCC"](#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏差。
#### 兩階段鎖定的實現 {#implementation-of-two-phase-locking}
@ -703,7 +703,7 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個
這部分是由於獲取和釋放所有這些鎖的開銷,但更重要的是由於併發性降低。按設計,如果兩個併發事務嘗試執行任何可能以任何方式導致競態條件的操作,其中一個必須等待另一個完成。
例如,如果你有一個需要讀取整個表的事務(例如,備份、分析查詢或完整性檢查,如["快照隔離與可重複讀"](/tw/ch8#sec_transactions_snapshot_isolation)中所討論的),該事務必須對整個表進行共享鎖。因此,讀取事務首先必須等到所有正在寫入該表的進行中事務完成;然後,在讀取整個表時(對於大表可能需要很長時間),所有想要寫入該表的其他事務都被阻塞,直到大型只讀事務提交。實際上,資料庫在很長一段時間內無法進行寫入。
例如,如果你有一個需要讀取整個表的事務(例如,備份、分析查詢或完整性檢查,如["快照隔離與可重複讀"](#sec_transactions_snapshot_isolation)中所討論的),該事務必須對整個表進行共享鎖。因此,讀取事務首先必須等到所有正在寫入該表的進行中事務完成;然後,在讀取整個表時(對於大表可能需要很長時間),所有想要寫入該表的其他事務都被阻塞,直到大型只讀事務提交。實際上,資料庫在很長一段時間內無法進行寫入。
因此,執行 2PL 的資料庫可能具有相當不穩定的延遲,如果工作負載中存在爭用,它們在高百分位數可能非常慢(參見["描述效能"](/tw/ch2#sec_introduction_percentiles))。可能只需要一個緩慢的事務,或者一個訪問大量資料並獲取許多鎖的事務,就會導致系統的其餘部分停滯不前。
@ -711,9 +711,9 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個
#### 謂詞鎖 {#predicate-locks}
在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏差的幻讀"](/tw/ch8#sec_transactions_phantom)中,我們討論了*幻讀*的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。
在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏差的幻讀"](#sec_transactions_phantom)中,我們討論了*幻讀*的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。
在會議室預訂示例中,這意味著如果一個事務已經搜尋了某個時間視窗內某個房間的現有預訂(參見[例 8-2](/tw/ch8#fig_transactions_meeting_rooms)),另一個事務不允許併發插入或更新同一房間和時間範圍的另一個預訂。(併發插入其他房間的預訂,或同一房間不影響擬議預訂的不同時間的預訂是可以的。)
在會議室預訂示例中,這意味著如果一個事務已經搜尋了某個時間視窗內某個房間的現有預訂(參見[例 8-2](#fig_transactions_meeting_rooms)),另一個事務不允許併發插入或更新同一房間和時間範圍的另一個預訂。(併發插入其他房間的預訂,或同一房間不影響擬議預訂的不同時間的預訂是可以的。)
我們如何實現這一點?從概念上講,我們需要一個*謂詞鎖*[^4]。它的工作方式類似於前面描述的共享/獨佔鎖,但它不屬於特定物件(例如,表中的一行),而是屬於匹配某些搜尋條件的所有物件,例如:
@ -768,11 +768,11 @@ SELECT * FROM bookings
但是,如果有足夠的備用容量,並且事務之間的爭用不太高,樂觀併發控制技術往往比悲觀技術性能更好。可交換原子操作可以減少爭用:例如,如果幾個事務併發想要遞增計數器,應用遞增的順序無關緊要(只要計數器在同一事務中沒有被讀取),因此併發遞增都可以應用而不會發生衝突。
顧名思義SSI 基於快照隔離——也就是說,事務中的所有讀取都從資料庫的一致快照進行(參見["快照隔離與可重複讀"](/tw/ch8#sec_transactions_snapshot_isolation)。在快照隔離的基礎上SSI 添加了一種演算法來檢測讀寫之間的序列化衝突,並確定要中止哪些事務。
顧名思義SSI 基於快照隔離——也就是說,事務中的所有讀取都從資料庫的一致快照進行(參見["快照隔離與可重複讀"](#sec_transactions_snapshot_isolation)。在快照隔離的基礎上SSI 添加了一種演算法來檢測讀寫之間的序列化衝突,並確定要中止哪些事務。
#### 基於過時前提的決策 {#decisions-based-on-an-outdated-premise}
當我們之前討論快照隔離中的寫偏差時(參見["寫偏差與幻讀"](/tw/ch8#sec_transactions_write_skew)),我們觀察到一個反覆出現的模式:事務從資料庫讀取一些資料,檢查查詢結果,並根據它看到的結果決定採取某些行動(寫入資料庫)。但是,在快照隔離下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在此期間被修改。
當我們之前討論快照隔離中的寫偏差時(參見["寫偏差與幻讀"](#sec_transactions_write_skew)),我們觀察到一個反覆出現的模式:事務從資料庫讀取一些資料,檢查查詢結果,並根據它看到的結果決定採取某些行動(寫入資料庫)。但是,在快照隔離下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在此期間被修改。
換句話說,事務基於*前提*(事務開始時為真的事實,例如,"當前有兩名醫生值班")採取行動。後來,當事務想要提交時,原始資料可能已更改——前提可能不再為真。
@ -785,9 +785,9 @@ SELECT * FROM bookings
#### 檢測陳舊的 MVCC 讀取 {#detecting-stale-mvcc-reads}
回想一下快照隔離通常由多版本併發控制MVCC參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl))實現。當事務從 MVCC 資料庫中的一致快照讀取時,它會忽略在拍攝快照時尚未提交的任何其他事務所做的寫入。
回想一下快照隔離通常由多版本併發控制MVCC參見["多版本併發控制MVCC"](#sec_transactions_snapshot_impl))實現。當事務從 MVCC 資料庫中的一致快照讀取時,它會忽略在拍攝快照時尚未提交的任何其他事務所做的寫入。
在[圖 8-10](/tw/ch8#fig_transactions_detect_mvcc) 中,事務 43 看到 Aaliyah 的 `on_call = true`,因為事務 42修改了 Aaliyah 的值班狀態)未提交。但是,當事務 43 想要提交時,事務 42 已經提交。這意味著從一致快照讀取時被忽略的寫入現在已生效,事務 43 的前提不再為真。當寫入者插入以前不存在的資料時,事情變得更加複雜(參見["導致寫偏差的幻讀"](/tw/ch8#sec_transactions_phantom))。我們將在["檢測影響先前讀取的寫入"](/tw/ch8#sec_detecting_writes_affect_reads)中討論為 SSI 檢測幻寫。
在[圖 8-10](#fig_transactions_detect_mvcc) 中,事務 43 看到 Aaliyah 的 `on_call = true`,因為事務 42修改了 Aaliyah 的值班狀態)未提交。但是,當事務 43 想要提交時,事務 42 已經提交。這意味著從一致快照讀取時被忽略的寫入現在已生效,事務 43 的前提不再為真。當寫入者插入以前不存在的資料時,事情變得更加複雜(參見["導致寫偏差的幻讀"](#sec_transactions_phantom))。我們將在["檢測影響先前讀取的寫入"](#sec_detecting_writes_affect_reads)中討論為 SSI 檢測幻寫。
{{< figure src="/fig/ddia_0810.png" id="fig_transactions_detect_mvcc" caption="圖 8-10. 檢測事務何時從 MVCC 快照讀取過時值。" class="w-full my-4" >}}
@ -798,18 +798,18 @@ SELECT * FROM bookings
#### 檢測影響先前讀取的寫入 {#sec_detecting_writes_affect_reads}
要考慮的第二種情況是另一個事務在資料被讀取後修改資料。這種情況如[圖 8-11](/tw/ch8#fig_transactions_detect_index_range) 所示。
要考慮的第二種情況是另一個事務在資料被讀取後修改資料。這種情況如[圖 8-11](#fig_transactions_detect_index_range) 所示。
{{< figure src="/fig/ddia_0811.png" id="fig_transactions_detect_index_range" caption="圖 8-11. 在可序列化快照隔離中,檢測一個事務何時修改另一個事務的讀取。" class="w-full my-4" >}}
在兩階段鎖定的上下文中,我們討論了索引範圍鎖(參見["索引範圍鎖"](/tw/ch8#sec_transactions_2pl_range)),它允許資料庫鎖定對匹配某些搜尋查詢的所有行的訪問,例如 `WHERE shift_id = 1234`。我們可以在這裡使用類似的技術,除了 SSI 鎖不會阻塞其他事務。
在兩階段鎖定的上下文中,我們討論了索引範圍鎖(參見["索引範圍鎖"](#sec_transactions_2pl_range)),它允許資料庫鎖定對匹配某些搜尋查詢的所有行的訪問,例如 `WHERE shift_id = 1234`。我們可以在這裡使用類似的技術,除了 SSI 鎖不會阻塞其他事務。
在[圖 8-11](/tw/ch8#fig_transactions_detect_index_range) 中,事務 42 和 43 都在班次 `1234` 期間搜尋值班醫生。如果 `shift_id` 上有索引,資料庫可以使用索引條目 1234 來記錄事務 42 和 43 讀取此資料的事實。(如果沒有索引,可以在表級別跟蹤此資訊。)此資訊只需要保留一段時間:在事務完成(提交或中止)並且所有併發事務完成後,資料庫可以忘記它讀取的資料。
在[圖 8-11](#fig_transactions_detect_index_range) 中,事務 42 和 43 都在班次 `1234` 期間搜尋值班醫生。如果 `shift_id` 上有索引,資料庫可以使用索引條目 1234 來記錄事務 42 和 43 讀取此資料的事實。(如果沒有索引,可以在表級別跟蹤此資訊。)此資訊只需要保留一段時間:在事務完成(提交或中止)並且所有併發事務完成後,資料庫可以忘記它讀取的資料。
當事務寫入資料庫時,它必須在索引中查詢最近讀取受影響資料的任何其他事務。此過程類似於獲取受影響鍵範圍的寫鎖,但它不是阻塞直到讀者提交,而是充當絆線:它只是通知事務它們讀取的資料可能不再是最新的。
在[圖 8-11](/tw/ch8#fig_transactions_detect_index_range) 中,事務 43 通知事務 42 其先前的讀取已過時,反之亦然。事務 42 首先提交,並且成功:儘管事務 43 的寫入影響了 42但 43 尚未提交,因此寫入尚未生效。但是,當事務 43 想要提交時,來自 42 的衝突寫入已經提交,因此 43 必須中止。
在[圖 8-11](#fig_transactions_detect_index_range) 中,事務 43 通知事務 42 其先前的讀取已過時,反之亦然。事務 42 首先提交,並且成功:儘管事務 43 的寫入影響了 42但 43 尚未提交,因此寫入尚未生效。但是,當事務 43 想要提交時,來自 42 的衝突寫入已經提交,因此 43 必須中止。
#### 可序列化快照隔離的效能 {#performance-of-serializable-snapshot-isolation}
@ -837,7 +837,7 @@ SELECT * FROM bookings
但是,如果多個節點參與事務會怎樣?例如,也許你在分片資料庫中有多物件事務,或者有全域性二級索引(其中索引條目可能與主資料在不同的節點上;參見["分片和二級索引"](/tw/ch7#sec_sharding_secondary_indexes))。大多數"NoSQL"分散式資料儲存不支援此類分散式事務,但各種分散式關係資料庫支援。
在這些情況下,僅向所有節點發送提交請求並在每個節點上獨立提交事務是不夠的。如[圖 8-12](/tw/ch8#fig_transactions_non_atomic) 所示,提交可能在某些節點上成功,在其他節點上失敗:
在這些情況下,僅向所有節點發送提交請求並在每個節點上獨立提交事務是不夠的。如[圖 8-12](#fig_transactions_non_atomic) 所示,提交可能在某些節點上成功,在其他節點上失敗:
* 某些節點可能檢測到約束違規或衝突,需要中止,而其他節點能夠成功提交。
* 某些提交請求可能在網路中丟失,最終由於超時而中止,而其他提交請求透過。
@ -846,7 +846,7 @@ SELECT * FROM bookings
{{< figure src="/fig/ddia_0812.png" id="fig_transactions_non_atomic" caption="圖 8-12. 當事務涉及多個數據庫節點時,它可能在某些節點上提交,在其他節點上失敗。" class="w-full my-4" >}}
如果某些節點提交事務而其他節點中止它,節點之間就會變得不一致。一旦事務在一個節點上提交,如果後來發現它在另一個節點上被中止,就不能撤回了。這是因為一旦資料被提交,它在*讀已提交*或更強的隔離下對其他事務可見。例如,在[圖 8-12](/tw/ch8#fig_transactions_non_atomic) 中,當用戶 1 注意到其在資料庫 1 上的提交失敗時,使用者 2 已經從資料庫 2 上的同一事務讀取了資料。如果使用者 1 的事務後來被中止,使用者 2 的事務也必須被還原,因為它基於被追溯宣告不存在的資料。
如果某些節點提交事務而其他節點中止它,節點之間就會變得不一致。一旦事務在一個節點上提交,如果後來發現它在另一個節點上被中止,就不能撤回了。這是因為一旦資料被提交,它在*讀已提交*或更強的隔離下對其他事務可見。例如,在[圖 8-12](#fig_transactions_non_atomic) 中,當用戶 1 注意到其在資料庫 1 上的提交失敗時,使用者 2 已經從資料庫 2 上的同一事務讀取了資料。如果使用者 1 的事務後來被中止,使用者 2 的事務也必須被還原,因為它基於被追溯宣告不存在的資料。
更好的方法是確保參與事務的節點要麼全部提交,要麼全部中止,並防止兩者的混合。確保這一點被稱為*原子提交*問題。
@ -854,7 +854,7 @@ SELECT * FROM bookings
兩階段提交是一種跨多個節點實現原子事務提交的演算法。它是分散式資料庫中的經典演算法[^13] [^71] [^72]。2PC 在某些資料庫內部使用,也以 *XA 事務*[^73] 的形式提供給應用程式例如Java 事務 API 支援),或透過 WS-AtomicTransaction 用於 SOAP Web 服務[^74] [^75]。
2PC 的基本流程如[圖 8-13](/tw/ch8#fig_transactions_two_phase_commit) 所示。與單節點事務的單個提交請求不同2PC 中的提交/中止過程分為兩個階段(因此得名)。
2PC 的基本流程如[圖 8-13](#fig_transactions_two_phase_commit) 所示。與單節點事務的單個提交請求不同2PC 中的提交/中止過程分為兩個階段(因此得名)。
{{< figure src="/fig/ddia_0813.png" id="fig_transactions_two_phase_commit" title="圖 8-13. 兩階段提交2PC的成功執行。" class="w-full my-4" >}}
@ -893,7 +893,7 @@ SELECT * FROM bookings
如果協調器在傳送準備請求之前失敗,參與者可以安全地中止事務。但是一旦參與者收到準備請求並投票"是",它就不能再單方面中止——它必須等待協調器回覆事務是提交還是中止。如果協調器此時崩潰或網路失敗,參與者除了等待別無他法。參與者在此狀態下的事務稱為*存疑*或*不確定*。
這種情況如[圖 8-14](/tw/ch8#fig_transactions_2pc_crash) 所示。在這個特定的例子中,協調器實際上決定提交,資料庫 2 收到了提交請求。但是,協調器在向資料庫 1 傳送提交請求之前崩潰了,因此資料庫 1 不知道是提交還是中止。即使超時在這裡也沒有幫助:如果資料庫 1 在超時後單方面中止,它將與已提交的資料庫 2 不一致。同樣,單方面提交也不安全,因為另一個參與者可能已中止。
這種情況如[圖 8-14](#fig_transactions_2pc_crash) 所示。在這個特定的例子中,協調器實際上決定提交,資料庫 2 收到了提交請求。但是,協調器在向資料庫 1 傳送提交請求之前崩潰了,因此資料庫 1 不知道是提交還是中止。即使超時在這裡也沒有幫助:如果資料庫 1 在超時後單方面中止,它將與已提交的資料庫 2 不一致。同樣,單方面提交也不安全,因為另一個參與者可能已中止。
{{< figure src="/fig/ddia_0814.png" id="fig_transactions_2pc_crash" title="圖 8-14. 協調器在參與者投票“是”後崩潰。資料庫 1 不知道是提交還是中止。" class="w-full my-4" >}}
@ -952,9 +952,9 @@ XA 假設你的應用程式使用網路驅動程式或客戶端庫與參與者
為什麼我們如此關心事務陷入存疑?系統的其餘部分不能繼續工作,忽略最終會被清理的存疑事務嗎?
問題在於*鎖定*。如["讀已提交"](/tw/ch8#sec_transactions_read_committed)中所討論的,資料庫事務通常對它們修改的任何行進行行級獨佔鎖,以防止髒寫。此外,如果你想要可序列化隔離,使用兩階段鎖定的資料庫還必須對事務*讀取*的任何行進行共享鎖。
問題在於*鎖定*。如["讀已提交"](#sec_transactions_read_committed)中所討論的,資料庫事務通常對它們修改的任何行進行行級獨佔鎖,以防止髒寫。此外,如果你想要可序列化隔離,使用兩階段鎖定的資料庫還必須對事務*讀取*的任何行進行共享鎖。
資料庫在事務提交或中止之前不能釋放這些鎖(如[圖 8-13](/tw/ch8#fig_transactions_two_phase_commit) 中的陰影區域所示)。因此,使用兩階段提交時,事務必須在存疑期間保持鎖。如果協調器崩潰並需要 20 分鐘才能重新啟動,這些鎖將保持 20 分鐘。如果協調器的日誌由於某種原因完全丟失,這些鎖將永遠保持——或者至少直到管理員手動解決情況。
資料庫在事務提交或中止之前不能釋放這些鎖(如[圖 8-13](#fig_transactions_two_phase_commit) 中的陰影區域所示)。因此,使用兩階段提交時,事務必須在存疑期間保持鎖。如果協調器崩潰並需要 20 分鐘才能重新啟動,這些鎖將保持 20 分鐘。如果協調器的日誌由於某種原因完全丟失,這些鎖將永遠保持——或者至少直到管理員手動解決情況。
當這些鎖被持有時,沒有其他事務可以修改這些行。根據隔離級別,其他事務甚至可能被阻止讀取這些行。因此,其他事務不能簡單地繼續他們的業務——如果他們想要訪問相同的資料,他們將被阻塞。這可能導致你的應用程式的大部分變得不可用,直到存疑事務得到解決。
@ -976,7 +976,7 @@ XA 假設你的應用程式使用網路驅動程式或客戶端庫與參與者
即使協調器被複制,應用程式程式碼也將是單點故障。解決這個問題需要完全重新設計應用程式程式碼的執行方式,使其複製或可重啟,這可能看起來類似於持久執行(參見["持久執行和工作流"](/tw/ch5#sec_encoding_dataflow_workflows))。但是,實踐中似乎沒有任何工具實際採用這種方法。
另一個問題是,由於 XA 需要與各種資料系統相容,它必然是最低公分母。例如,它無法檢測跨不同系統的死鎖(因為這需要系統交換有關每個事務正在等待的鎖的資訊的標準化協議),並且它不適用於 SSI參見["可序列化快照隔離SSI"](/tw/ch8#sec_transactions_ssi)),因為這需要跨不同系統識別衝突的協議。
另一個問題是,由於 XA 需要與各種資料系統相容,它必然是最低公分母。例如,它無法檢測跨不同系統的死鎖(因為這需要系統交換有關每個事務正在等待的鎖的資訊的標準化協議),並且它不適用於 SSI參見["可序列化快照隔離SSI"](#sec_transactions_ssi)),因為這需要跨不同系統識別衝突的協議。
這些問題在某種程度上是跨異構技術執行事務所固有的。但是,保持幾個異構資料系統彼此一致仍然是一個真實而重要的問題,因此我們需要為其找到不同的解決方案。這可以做到,我們將在下一節和["派生資料與分散式事務"](/tw/ch13#sec_future_derived_vs_transactions)中看到。
@ -999,7 +999,7 @@ XA 的最大問題可以透過以下方式解決:
#### 再談恰好一次訊息處理 {#exactly-once-message-processing-revisited}
我們在["恰好一次訊息處理"](/tw/ch8#sec_transactions_exactly_once)中看到,分散式事務的一個重要用例是確保某些操作恰好生效一次,即使在處理過程中發生崩潰並且需要重試處理。如果你可以跨訊息代理和資料庫原子地提交事務,則當且僅當成功處理訊息並且從處理過程產生的資料庫寫入被提交時,你可以向代理確認訊息。
我們在["恰好一次訊息處理"](#sec_transactions_exactly_once)中看到,分散式事務的一個重要用例是確保某些操作恰好生效一次,即使在處理過程中發生崩潰並且需要重試處理。如果你可以跨訊息代理和資料庫原子地提交事務,則當且僅當成功處理訊息並且從處理過程產生的資料庫寫入被提交時,你可以向代理確認訊息。
但是,你實際上不需要這樣的分散式事務來實現恰好一次語義。另一種方法如下,它只需要資料庫中的事務:
@ -1024,9 +1024,9 @@ XA 的最大問題可以透過以下方式解決:
沒有事務,各種錯誤場景(程序崩潰、網路中斷、停電、磁碟已滿、意外併發等)意味著資料可能以各種方式變得不一致。例如,反正規化資料很容易與源資料失去同步。沒有事務,很難推理複雜的互動訪問對資料庫可能產生的影響。
在本章中,我們特別深入地探討了併發控制的主題。我們討論了幾種廣泛使用的隔離級別,特別是*讀已提交*、*快照隔離*(有時稱為*可重複讀*)和*可序列化*。我們透過討論各種競態條件的示例來描述這些隔離級別,總結在[表 8-1](/tw/ch8#ch_transactions_isolation_levels) 中:
在本章中,我們特別深入地探討了併發控制的主題。我們討論了幾種廣泛使用的隔離級別,特別是*讀已提交*、*快照隔離*(有時稱為*可重複讀*)和*可序列化*。我們透過討論各種競態條件的示例來描述這些隔離級別,總結在 [表 8-1](#tab_transactions_isolation_levels) 中:
表 8-1. 各種隔離級別可能發生的異常總結
{{< figure id="tab_transactions_isolation_levels" title="表 8-1. 各種隔離級別可能發生的異常總結" class="w-full my-4" >}}
| 隔離級別 | 髒讀 | 讀取偏差 | 幻讀 | 丟失更新 | 寫偏差 |
|------|------|------|------|-------|------|
@ -1066,7 +1066,7 @@ XA 的最大問題可以透過以下方式解決:
最後,我們研究了當事務分佈在多個節點上時如何實現原子性,使用兩階段提交。如果這些節點都執行相同的資料庫軟體,分散式事務可以很好地工作,但跨不同儲存技術(使用 XA 事務2PC 是有問題的:它對協調器和驅動事務的應用程式程式碼中的故障非常敏感,並且與併發控制機制的互動很差。幸運的是,冪等性可以確保恰好一次語義,而無需跨不同儲存技術的原子提交,我們將在後面的章節中看到更多相關內容。
本章中的示例使用了關係資料模型。但是,如["多物件事務的需求"](/tw/ch8#sec_transactions_need)中所討論的,無論使用哪種資料模型,事務都是有價值的資料庫功能。
本章中的示例使用了關係資料模型。但是,如["多物件事務的需求"](#sec_transactions_need)中所討論的,無論使用哪種資料模型,事務都是有價值的資料庫功能。

View file

@ -18,7 +18,7 @@ breadcrumbs: false
此外,使用分散式系統與在單臺計算機上編寫軟體有著根本的不同 —— 主要區別在於有許多新的、令人興奮的出錯方式 [^1] [^2]。在本章中,你將體驗實踐中出現的問題,並理解你可以依賴和不能依賴的事物。
為了理解我們面臨的挑戰,我們現在將把悲觀情緒發揮到極致,探索分散式系統中可能出錯的事情。我們將研究網路問題(["不可靠的網路"](/tw/ch9#sec_distributed_networks))以及時鐘和時序問題(["不可靠的時鐘"](/tw/ch9#sec_distributed_clocks))。所有這些問題的後果令人迷惑,因此我們將探索如何思考分散式系統的狀態以及如何推理已經發生的事情(["知識、真相與謊言"](/tw/ch9#sec_distributed_truth))。稍後,在 [第 10 章](/tw/ch10#ch_consistency) 中,我們將看一些面對這些故障時如何實現容錯的例子。
為了理解我們面臨的挑戰,我們現在將把悲觀情緒發揮到極致,探索分散式系統中可能出錯的事情。我們將研究網路問題(["不可靠的網路"](#sec_distributed_networks))以及時鐘和時序問題(["不可靠的時鐘"](#sec_distributed_clocks))。所有這些問題的後果令人迷惑,因此我們將探索如何思考分散式系統的狀態以及如何推理已經發生的事情(["知識、真相與謊言"](#sec_distributed_truth))。稍後,在 [第 10 章](/tw/ch10#ch_consistency) 中,我們將看一些面對這些故障時如何實現容錯的例子。
## 故障與部分失效 {#sec_distributed_partial_failure}
@ -44,12 +44,12 @@ breadcrumbs: false
正如 ["共享記憶體、共享磁碟和無共享架構"](/tw/ch2#sec_introduction_shared_nothing) 中所討論的,我們在本書中關注的分散式系統主要是 *無共享系統*:即透過網路連線的一組機器。網路是這些機器進行通訊的唯一方式 —— 我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除非透過網路向服務發出請求)。即使儲存是共享的,例如亞馬遜的 S3機器也是透過網路與共享儲存服務通訊。
網際網路和資料中心中的大多數內部網路(通常是乙太網)都是 *非同步分組網路*。在這種網路中,一個節點可以向另一個節點發送訊息(資料包),但網路不保證它何時到達,或者是否會到達。如果你傳送請求並期望響應,許多事情可能會出錯(其中一些如 [圖 9-1](/tw/ch9#fig_distributed_network) 所示):
網際網路和資料中心中的大多數內部網路(通常是乙太網)都是 *非同步分組網路*。在這種網路中,一個節點可以向另一個節點發送訊息(資料包),但網路不保證它何時到達,或者是否會到達。如果你傳送請求並期望響應,許多事情可能會出錯(其中一些如 [圖 9-1](#fig_distributed_network) 所示):
1. 你的請求可能已經丟失(也許有人拔掉了網線)。
2. 你的請求可能在佇列中等待,稍後將被交付(也許網路或接收方過載)。
3. 遠端節點可能已經失效(也許它崩潰了或被關閉了)。
4. 遠端節點可能暫時停止響應(也許它正在經歷長時間的垃圾回收暫停;見 ["程序暫停"](/tw/ch9#sec_distributed_clocks_pauses)),但稍後會再次開始響應。
4. 遠端節點可能暫時停止響應(也許它正在經歷長時間的垃圾回收暫停;見 ["程序暫停"](#sec_distributed_clocks_pauses)),但稍後會再次開始響應。
5. 遠端節點可能已經處理了你的請求,但響應在網路上丟失了(也許網路交換機配置錯誤)。
6. 遠端節點可能已經處理了你的請求,但響應被延遲了,稍後將被交付(也許網路或你自己的機器過載)。
@ -67,7 +67,7 @@ breadcrumbs: false
--------
> [!NOTE]
> 我們關於 TCP 的大部分內容也適用於其更新的替代方案 QUIC以及 WebRTC 中使用的流控制傳輸協議SCTP、BitTorrent uTP 協議和其他傳輸協議。有關與 UDP 的比較,請參見 ["TCP 與 UDP"](/tw/ch9#sidebar_distributed_tcp_udp)。
> 我們關於 TCP 的大部分內容也適用於其更新的替代方案 QUIC以及 WebRTC 中使用的流控制傳輸協議SCTP、BitTorrent uTP 協議和其他傳輸協議。有關與 UDP 的比較,請參見 ["TCP 與 UDP"](#sidebar_distributed_tcp_udp)。
--------
@ -104,7 +104,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
如果網路故障的錯誤處理沒有定義和測試,可能會發生任意糟糕的事情:例如,叢集可能會陷入死鎖並永久無法提供請求,即使網路恢復 [^24],或者它甚至可能刪除你的所有資料 [^25]。如果軟體處於意料之外的情況,它可能會做任意意外的事情。
處理網路故障不一定意味著 *容忍* 它們:如果你的網路通常相當可靠,一個有效的方法可能是在網路出現問題時簡單地向用戶顯示錯誤訊息。但是,你確實需要知道你的軟體如何對網路問題做出反應,並確保系統可以從中恢復。故意觸發網路問題並測試系統的響應可能是有意義的(這被稱為 *故障注入*;見 ["故障注入"](/tw/ch9#sec_fault_injection))。
處理網路故障不一定意味著 *容忍* 它們:如果你的網路通常相當可靠,一個有效的方法可能是在網路出現問題時簡單地向用戶顯示錯誤訊息。但是,你確實需要知道你的軟體如何對網路問題做出反應,並確保系統可以從中恢復。故意觸發網路問題並測試系統的響應可能是有意義的(這被稱為 *故障注入*;見 ["故障注入"](#sec_fault_injection))。
### 檢測故障 {#id307}
@ -128,13 +128,13 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
長超時意味著在節點被宣佈死亡之前需要長時間等待(在此期間,使用者可能不得不等待或看到錯誤訊息)。短超時可以更快地檢測故障,但當節點實際上只是遭受暫時的減速(例如,由於節點或網路上的負載峰值)時,錯誤地宣佈節點死亡的風險更高。
過早地宣佈節點死亡是有問題的:如果節點實際上是活著的並且正在執行某些操作(例如,傳送電子郵件),而另一個節點接管,該操作可能最終被執行兩次。我們將在 ["知識、真相與謊言"](/tw/ch9#sec_distributed_truth) 以及第 10 章和後續章節中更詳細地討論這個問題。
過早地宣佈節點死亡是有問題的:如果節點實際上是活著的並且正在執行某些操作(例如,傳送電子郵件),而另一個節點接管,該操作可能最終被執行兩次。我們將在 ["知識、真相與謊言"](#sec_distributed_truth) 以及第 10 章和後續章節中更詳細地討論這個問題。
當節點被宣佈死亡時,其職責需要轉移到其他節點,這會給其他節點和網路帶來額外的負載。如果系統已經在高負載下掙扎,過早地宣佈節點死亡可能會使問題變得更糟。特別是,可能發生的情況是,節點實際上並沒有死亡,只是由於過載而響應緩慢;將其負載轉移到其他節點可能會導致級聯故障(在極端情況下,所有節點互相宣佈對方死亡,一切都停止工作 —— 見 ["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable))。
想象一個虛構的系統,其網路保證資料包的最大延遲 —— 每個資料包要麼在某個時間 *d* 內交付,要麼丟失,但交付從不會超過 *d*。此外,假設你可以保證未失效的節點總是在某個時間 *r* 內處理請求。在這種情況下,你可以保證每個成功的請求在時間 2*d* + *r* 內收到響應 —— 如果你在該時間內沒有收到響應你就知道網路或遠端節點不工作。如果這是真的2*d* + *r* 將是一個合理的超時時間。
不幸的是,我們使用的大多數系統都沒有這些保證:非同步網路具有 *無界延遲*(即,它們嘗試儘快交付資料包,但資料包到達所需的時間沒有上限),大多數伺服器實現無法保證它們可以在某個最大時間內處理請求(見 ["響應時間保證"](/tw/ch9#sec_distributed_clocks_realtime))。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時很低,往返時間的瞬時峰值就足以使系統失去平衡。
不幸的是,我們使用的大多數系統都沒有這些保證:非同步網路具有 *無界延遲*(即,它們嘗試儘快交付資料包,但資料包到達所需的時間沒有上限),大多數伺服器實現無法保證它們可以在某個最大時間內處理請求(見 ["響應時間保證"](#sec_distributed_clocks_realtime))。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時很低,往返時間的瞬時峰值就足以使系統失去平衡。
<a id="sec_distributed_congestion"></a>
@ -142,7 +142,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
開車時,道路網路上的行駛時間通常因交通擁堵而變化最大。同樣,計算機網路上資料包延遲的可變性最常是由於排隊 [^27]
* 如果幾個不同的節點同時嘗試向同一目的地傳送資料包,網路交換機必須將它們排隊並逐個送入目標網路鏈路(如 [圖 9-2](/tw/ch9#fig_distributed_switch_queueing) 所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為 *網路擁塞*)。如果有太多的傳入資料以至於交換機佇列滿了,資料包將被丟棄,因此需要重新發送 —— 即使網路執行正常。
* 如果幾個不同的節點同時嘗試向同一目的地傳送資料包,網路交換機必須將它們排隊並逐個送入目標網路鏈路(如 [圖 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為 *網路擁塞*)。如果有太多的傳入資料以至於交換機佇列滿了,資料包將被丟棄,因此需要重新發送 —— 即使網路執行正常。
* 當資料包到達目標機器時,如果所有 CPU 核心當前都很忙,來自網路的傳入請求會被作業系統排隊,直到應用程式準備處理它。根據機器上的負載,這可能需要任意長的時間 [^28]。
* 在虛擬化環境中,正在執行的作業系統經常會暫停幾十毫秒,而另一個虛擬機器使用 CPU 核心。在此期間VM 無法消耗來自網路的任何資料,因此傳入資料由虛擬機器監視器排隊(緩衝)[^29],進一步增加了網路延遲的可變性。
* 如前所述為了避免網路過載TCP 限制傳送資料的速率。這意味著在資料甚至進入網路之前,傳送方就有額外的排隊。
@ -205,7 +205,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
>
> 相比之下,網際網路 *動態* 共享網路頻寬。傳送者互相推擠,儘可能快地透過線路傳送資料包,網路交換機決定在每個時刻傳送哪個資料包(即頻寬分配)。這種方法的缺點是排隊,但優點是它最大化了線路的利用率。線路有固定成本,所以如果你更好地利用它,你透過線路傳送的每個位元組都更便宜。
>
> CPU 也會出現類似的情況:如果你在幾個執行緒之間動態共享每個 CPU 核心,一個執行緒有時必須在作業系統的執行佇列中等待,而另一個執行緒正在執行,因此執行緒可能會暫停不同的時間長度 [^38]。然而,這比為每個執行緒分配靜態數量的 CPU 週期更好地利用硬體(見 ["響應時間保證"](/tw/ch9#sec_distributed_clocks_realtime))。更好的硬體利用率也是雲平臺在同一物理機器上執行來自不同客戶的多個虛擬機器的原因。
> CPU 也會出現類似的情況:如果你在幾個執行緒之間動態共享每個 CPU 核心,一個執行緒有時必須在作業系統的執行佇列中等待,而另一個執行緒正在執行,因此執行緒可能會暫停不同的時間長度 [^38]。然而,這比為每個執行緒分配靜態數量的 CPU 週期更好地利用硬體(見 ["響應時間保證"](#sec_distributed_clocks_realtime))。更好的硬體利用率也是雲平臺在同一物理機器上執行來自不同客戶的多個虛擬機器的原因。
>
> 如果資源是靜態分割槽的(例如,專用硬體和獨佔頻寬分配),則在某些環境中可以實現延遲保證。然而,這是以降低利用率為代價的 —— 換句話說,它更昂貴。另一方面,具有動態資源分割槽的多租戶提供了更好的利用率,因此更便宜,但它有可變延遲的缺點。
>
@ -244,7 +244,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
#### 日曆時鐘 {#time-of-day-clocks}
日曆時鐘做你直觀期望時鐘做的事情:它根據某個日曆返回當前日期和時間(也稱為 *牆上時鐘時間*。例如Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *紀元* 以來的秒數或毫秒數根據格里高利曆1970 年 1 月 1 日午夜 UTC不計算閏秒。一些系統使用其他日期作為參考點。儘管 Linux 時鐘被稱為 *即時*,但它與即時作業系統無關,如 ["響應時間保證"](/tw/ch9#sec_distributed_clocks_realtime) 中所討論的。)
日曆時鐘做你直觀期望時鐘做的事情:它根據某個日曆返回當前日期和時間(也稱為 *牆上時鐘時間*。例如Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *紀元* 以來的秒數或毫秒數根據格里高利曆1970 年 1 月 1 日午夜 UTC不計算閏秒。一些系統使用其他日期作為參考點。儘管 Linux 時鐘被稱為 *即時*,但它與即時作業系統無關,如 ["響應時間保證"](#sec_distributed_clocks_realtime) 中所討論的。)
日曆時鐘通常與 NTP 同步,這意味著來自一臺機器的時間戳(理想情況下)與另一臺機器上的時間戳意思相同。然而,日曆時鐘也有各種奇怪之處,如下一節所述。特別是,如果本地時鐘遠遠超前於 NTP 伺服器,它可能會被強制重置並顯示跳回到以前的時間點。這些跳躍,以及閏秒引起的類似跳躍,使日曆時鐘不適合測量經過的時間 [^40]。
@ -293,21 +293,21 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
讓我們考慮一個特定的情況,其中依賴時鐘是誘人但危險的:跨多個節點的事件排序 [^64]。例如,如果兩個客戶端寫入分散式資料庫,誰先到達?哪個寫入是更新的?
[圖 9-3](/tw/ch9#fig_distributed_timestamps) 說明了在具有多主複製的資料庫中日曆時鐘的危險使用(該示例類似於 [圖 6-8](/tw/ch6#fig_replication_causality))。客戶端 A 在節點 1 上寫入 *x* = 1寫入被複制到節點 3客戶端 B 在節點 3 上遞增 *x*(我們現在有 *x* = 2最後兩個寫入都被複制到節點 2。
[圖 9-3](#fig_distributed_timestamps) 說明了在具有多主複製的資料庫中日曆時鐘的危險使用(該示例類似於 [圖 6-8](/tw/ch6#fig_replication_causality))。客戶端 A 在節點 1 上寫入 *x* = 1寫入被複制到節點 3客戶端 B 在節點 3 上遞增 *x*(我們現在有 *x* = 2最後兩個寫入都被複制到節點 2。
{{< figure src="/fig/ddia_0903.png" id="fig_distributed_timestamps" caption="圖 9-3. 客戶端 B 的寫入在因果關係上晚於客戶端 A 的寫入,但 B 的寫入具有更早的時間戳。" class="w-full my-4" >}}
在 [圖 9-3](/tw/ch9#fig_distributed_timestamps) 中,當寫入被複制到其他節點時,它會根據寫入起源節點上的日曆時鐘標記時間戳。此示例中的時鐘同步非常好:節點 1 和節點 3 之間的偏差小於 3 毫秒,這可能比你在實踐中可以期望的要好。
在 [圖 9-3](#fig_distributed_timestamps) 中,當寫入被複制到其他節點時,它會根據寫入起源節點上的日曆時鐘標記時間戳。此示例中的時鐘同步非常好:節點 1 和節點 3 之間的偏差小於 3 毫秒,這可能比你在實踐中可以期望的要好。
由於遞增建立在 *x* = 1 的早期寫入之上,我們可能期望 *x* = 2 的寫入應該具有兩者中更大的時間戳。不幸的是,[圖 9-3](/tw/ch9#fig_distributed_timestamps) 中發生的並非如此:寫入 *x* = 1 的時間戳為 42.004 秒,但寫入 *x* = 2 的時間戳為 42.003 秒。
由於遞增建立在 *x* = 1 的早期寫入之上,我們可能期望 *x* = 2 的寫入應該具有兩者中更大的時間戳。不幸的是,[圖 9-3](#fig_distributed_timestamps) 中發生的並非如此:寫入 *x* = 1 的時間戳為 42.004 秒,但寫入 *x* = 2 的時間戳為 42.003 秒。
如 ["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中所討論的,解決不同節點上併發寫入值之間衝突的一種方法是 *最後寫入勝利*LWW這意味著保留給定鍵的具有最大時間戳的寫入並丟棄所有具有較舊時間戳的寫入。在 [圖 9-3](/tw/ch9#fig_distributed_timestamps) 的示例中,當節點 2 接收這兩個事件時,它將錯誤地得出結論,認為 *x* = 1 是更新的值並丟棄寫入 *x* = 2因此遞增丟失了。
如 ["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中所討論的,解決不同節點上併發寫入值之間衝突的一種方法是 *最後寫入勝利*LWW這意味著保留給定鍵的具有最大時間戳的寫入並丟棄所有具有較舊時間戳的寫入。在 [圖 9-3](#fig_distributed_timestamps) 的示例中,當節點 2 接收這兩個事件時,它將錯誤地得出結論,認為 *x* = 1 是更新的值並丟棄寫入 *x* = 2因此遞增丟失了。
可以透過確保當值被覆蓋時,新值總是具有比被覆蓋值更高的時間戳來防止這個問題,即使該時間戳超前於寫入者的本地時鐘。然而,這會產生額外的讀取成本來查詢最大的現有時間戳。一些系統,包括 Cassandra 和 ScyllaDB希望在單次往返中寫入所有副本因此它們只是使用客戶端時鐘的時間戳以及最後寫入勝利策略 [^62]。這種方法有一些嚴重的問題:
* 資料庫寫入可能會神秘地消失:具有滯後時鐘的節點無法覆蓋先前由具有快速時鐘的節點寫入的值,直到節點之間的時鐘偏差時間過去 [^63] [^65]。這種情況可能導致任意數量的資料被靜默丟棄,而不會嚮應用程式報告任何錯誤。
* LWW 無法區分快速連續發生的順序寫入(在 [圖 9-3](/tw/ch9#fig_distributed_timestamps) 中,客戶端 B 的遞增肯定發生在客戶端 A 的寫入 *之後*)和真正併發的寫入(兩個寫入者都不知道對方)。需要額外的因果關係跟蹤機制,如版本向量,以防止違反因果關係(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。
* LWW 無法區分快速連續發生的順序寫入(在 [圖 9-3](#fig_distributed_timestamps) 中,客戶端 B 的遞增肯定發生在客戶端 A 的寫入 *之後*)和真正併發的寫入(兩個寫入者都不知道對方)。需要額外的因果關係跟蹤機制,如版本向量,以防止違反因果關係(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。
* 兩個節點可能獨立生成具有相同時間戳的寫入,特別是當時鍾只有毫秒解析度時。需要額外的決勝值(可以簡單地是一個大的隨機數)來解決此類衝突,但這種方法也可能導致違反因果關係 [^62]。
因此,即使透過保留最 "新" 的值並丟棄其他值來解決衝突很誘人,但重要的是要意識到 "新" 的定義取決於本地日曆時鐘,它很可能是不正確的。即使使用緊密 NTP 同步的時鐘,你也可能在時間戳 100 毫秒(根據傳送者的時鐘)傳送資料包,並讓它在時間戳 99 毫秒(根據接收者的時鐘)到達 —— 因此看起來資料包在傳送之前就到達了,這是不可能的。
@ -376,7 +376,7 @@ while (true) {
假設執行緒可能暫停這麼長時間是合理的嗎?不幸的是,是的。有各種原因可能導致這種情況發生:
* 執行緒訪問共享資源(如鎖或佇列)時的爭用可能導致執行緒花費大量時間等待。轉移到具有更多 CPU 核心的機器可能會使此類問題變得更糟,並且爭用問題可能難以診斷 [^74]。
* 許多程式語言執行時(如 Java 虛擬機器)有 *垃圾回收器*GC偶爾需要停止所有正在執行的執行緒。過去這種 *"全域性暫停" GC 暫停* 有時會持續幾分鐘 [^75]!使用現代 GC 演算法,這不再是一個大問題,但 GC 暫停仍然可能很明顯(見 ["限制垃圾回收的影響"](/tw/ch9#sec_distributed_gc_impact))。
* 許多程式語言執行時(如 Java 虛擬機器)有 *垃圾回收器*GC偶爾需要停止所有正在執行的執行緒。過去這種 *"全域性暫停" GC 暫停* 有時會持續幾分鐘 [^75]!使用現代 GC 演算法,這不再是一個大問題,但 GC 暫停仍然可能很明顯(見 ["限制垃圾回收的影響"](#sec_distributed_gc_impact))。
* 在虛擬化環境中,虛擬機器可以被 *掛起*(暫停所有程序的執行並將記憶體內容儲存到磁碟)和 *恢復*(恢復記憶體內容並繼續執行)。這種暫停可能發生在程序執行的任何時間,並且可能持續任意長的時間。這個功能有時用於虛擬機器從一臺主機到另一臺主機的 *即時遷移*,無需重啟,在這種情況下,暫停的長度取決於程序寫入記憶體的速率 [^76]。
* 在筆記型電腦和手機等終端使用者裝置上,執行也可能被任意掛起和恢復,例如,當用戶合上筆記型電腦蓋時。
* 當作業系統上下文切換到另一個執行緒時,或者當虛擬機器管理程式切換到不同的虛擬機器時(在虛擬機器中執行時),當前執行的執行緒可能在程式碼的任何任意點暫停。在虛擬機器的情況下,在其他虛擬機器中花費的 CPU 時間稱為 *竊取時間*。如果機器負載很重 —— 即,如果有長佇列的執行緒等待執行 —— 暫停的執行緒可能需要一些時間才能再次執行。
@ -407,7 +407,7 @@ while (true) {
在系統中提供即時保證需要軟體棧所有級別的支援:需要 *即時作業系統*RTOS它允許程序在指定的時間間隔內以有保證的 CPU 時間分配進行排程;庫函式必須記錄其最壞情況執行時間;動態記憶體分配可能受到限制或完全禁止(即時垃圾回收器存在,但應用程式仍必須確保它不會給 GC 太多工作);必須進行大量的測試和測量以確保滿足保證。
所有這些都需要大量的額外工作,並嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供即時保證)。由於這些原因,開發即時系統非常昂貴,它們最常用於安全關鍵的嵌入式裝置。此外,"即時" 不同於 "高效能" —— 事實上,即時系統可能具有較低的吞吐量,因為它們必須優先考慮及時響應高於一切(另見 ["延遲和資源利用率"](/tw/ch9#sidebar_distributed_latency_utilization))。
所有這些都需要大量的額外工作,並嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供即時保證)。由於這些原因,開發即時系統非常昂貴,它們最常用於安全關鍵的嵌入式裝置。此外,"即時" 不同於 "高效能" —— 事實上,即時系統可能具有較低的吞吐量,因為它們必須優先考慮及時響應高於一切(另見 ["延遲和資源利用率"](#sidebar_distributed_latency_utilization))。
對於大多數伺服器端資料處理系統,即時保證根本不經濟或不合適。因此,這些系統必須承受在非即時環境中執行帶來的暫停和時鐘不穩定性。
@ -455,7 +455,7 @@ while (true) {
分散式應用程式中的鎖和租約容易被誤用,並且是錯誤的常見來源 [^84]。讓我們看看它們如何出錯的一個特定案例。
在 ["程序暫停"](/tw/ch9#sec_distributed_clocks_pauses) 中,我們看到租約是一種超時的鎖,如果舊所有者停止響應(可能是因為它崩潰了、暫停太久或與網路斷開連線),可以分配給新所有者。你可以在系統需要只有一個某種東西的情況下使用租約。例如:
在 ["程序暫停"](#sec_distributed_clocks_pauses) 中,我們看到租約是一種超時的鎖,如果舊所有者停止響應(可能是因為它崩潰了、暫停太久或與網路斷開連線),可以分配給新所有者。你可以在系統需要只有一個某種東西的情況下使用租約。例如:
* 只允許一個節點成為資料庫分片的主節點,以避免腦裂(見 ["處理節點中斷"](/tw/ch6#sec_replication_failover))。
* 只允許一個事務或客戶端更新特定資源或物件,以防止併發寫入損壞它。
@ -463,14 +463,14 @@ while (true) {
值得仔細思考如果幾個節點同時認為它們持有租約會發生什麼,可能是由於程序暫停。在第三個例子中,後果只是一些浪費的計算資源,這不是什麼大問題。但在前兩種情況下,後果可能是資料丟失或損壞,這要嚴重得多。
例如,[圖 9-4](/tw/ch9#fig_distributed_lease_pause) 顯示了由於鎖的錯誤實現導致的資料損壞錯誤。該錯誤不是理論上的HBase 曾經有這個問題 [^85] [^86]。)假設你想確保儲存服務中的檔案一次只能由一個客戶端訪問,因為如果多個客戶端試圖寫入它,檔案將被損壞。你嘗試透過要求客戶端在訪問檔案之前從鎖服務獲取租約來實現這一點。這種鎖服務通常使用共識演算法實現;我們將在 [第 10 章](/tw/ch10#ch_consistency) 中進一步討論這一點。
例如,[圖 9-4](#fig_distributed_lease_pause) 顯示了由於鎖的錯誤實現導致的資料損壞錯誤。該錯誤不是理論上的HBase 曾經有這個問題 [^85] [^86]。)假設你想確保儲存服務中的檔案一次只能由一個客戶端訪問,因為如果多個客戶端試圖寫入它,檔案將被損壞。你嘗試透過要求客戶端在訪問檔案之前從鎖服務獲取租約來實現這一點。這種鎖服務通常使用共識演算法實現;我們將在 [第 10 章](/tw/ch10#ch_consistency) 中進一步討論這一點。
{{< figure src="/fig/ddia_0904.png" id="fig_distributed_lease_pause" caption="圖 9-4. 分散式鎖的錯誤實現:客戶端 1 認為它仍然有有效的租約,即使它已經過期,因此損壞了儲存中的檔案。" class="w-full my-4" >}}
問題是我們在 ["程序暫停"](/tw/ch9#sec_distributed_clocks_pauses) 中討論的一個例子:如果持有租約的客戶端暫停太久,其租約就會過期。另一個客戶端可以獲得同一檔案的租約,並開始寫入檔案。當暫停的客戶端回來時,它(錯誤地)認為它仍然有有效的租約,並繼續寫入檔案。我們現在有了腦裂情況:客戶端的寫入衝突並損壞了檔案。
問題是我們在 ["程序暫停"](#sec_distributed_clocks_pauses) 中討論的一個例子:如果持有租約的客戶端暫停太久,其租約就會過期。另一個客戶端可以獲得同一檔案的租約,並開始寫入檔案。當暫停的客戶端回來時,它(錯誤地)認為它仍然有有效的租約,並繼續寫入檔案。我們現在有了腦裂情況:客戶端的寫入衝突並損壞了檔案。
[圖 9-5](/tw/ch9#fig_distributed_lease_delay) 顯示了具有類似後果的另一個問題。在這個例子中沒有程序暫停,只有客戶端 1 的崩潰。就在客戶端 1 崩潰之前,它向儲存服務傳送了一個寫請求,但這個請求在網路中被延遲了很長時間。(請記住 ["實踐中的網路故障"](/tw/ch9#sec_distributed_network_faults),資料包有時可能會延遲一分鐘或更長時間。)當寫請求到達儲存服務時,租約已經超時,允許客戶端 2 獲取它併發出自己的寫入。結果是類似於 [圖 9-4](/tw/ch9#fig_distributed_lease_pause) 的損壞。
[圖 9-5](#fig_distributed_lease_delay) 顯示了具有類似後果的另一個問題。在這個例子中沒有程序暫停,只有客戶端 1 的崩潰。就在客戶端 1 崩潰之前,它向儲存服務傳送了一個寫請求,但這個請求在網路中被延遲了很長時間。(請記住 ["實踐中的網路故障"](#sec_distributed_network_faults),資料包有時可能會延遲一分鐘或更長時間。)當寫請求到達儲存服務時,租約已經超時,允許客戶端 2 獲取它併發出自己的寫入。結果是類似於 [圖 9-4](#fig_distributed_lease_pause) 的損壞。
{{< figure src="/fig/ddia_0905.png" id="fig_distributed_lease_delay" caption="圖 9-5. 來自前租約持有者的訊息可能會延遲很長時間,並在另一個節點接管租約後到達。" class="w-full my-4" >}}
@ -479,9 +479,9 @@ while (true) {
術語 *殭屍* 有時用於描述尚未發現失去租約的前租約持有者,並且仍在充當當前租約持有者。由於我們不能完全排除殭屍,我們必須確保它們不能以腦裂的形式造成任何損害。這被稱為 *隔離* 殭屍。
一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM甚至物理關閉機器 [^87]。這種方法被稱為 *對端節點爆頭*STONITH。不幸的是它存在一些問題它不能防範像 [圖 9-5](/tw/ch9#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。
一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM甚至物理關閉機器 [^87]。這種方法被稱為 *對端節點爆頭*STONITH。不幸的是它存在一些問題它不能防範像 [圖 9-5](#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。
一個更強大的隔離解決方案,可以防範殭屍和延遲請求,如 [圖 9-6](/tw/ch9#fig_distributed_fencing) 所示。
一個更強大的隔離解決方案,可以防範殭屍和延遲請求,如 [圖 9-6](#fig_distributed_fencing) 所示。
{{< figure src="/fig/ddia_0906.png" id="fig_distributed_fencing" caption="圖 9-6. 透過只允許按遞增隔離令牌順序寫入來使儲存訪問安全。" class="w-full my-4" >}}
@ -495,7 +495,7 @@ while (true) {
--------
在 [圖 9-6](/tw/ch9#fig_distributed_fencing) 中,客戶端 1 獲得帶有令牌 33 的租約,但隨後進入長時間暫停,租約過期。客戶端 2 獲得帶有令牌 34 的租約(數字總是增加),然後將其寫請求傳送到儲存服務,包括令牌 34。稍後客戶端 1 恢復執行並將其寫入傳送到儲存服務,包括其令牌值 33。然而儲存服務記得它已經處理了具有更高令牌編號34的寫入因此它拒絕帶有令牌 33 的請求。剛剛獲得租約的客戶端必須立即向儲存服務進行寫入,一旦該寫入完成,任何殭屍都被隔離了。
在 [圖 9-6](#fig_distributed_fencing) 中,客戶端 1 獲得帶有令牌 33 的租約,但隨後進入長時間暫停,租約過期。客戶端 2 獲得帶有令牌 34 的租約(數字總是增加),然後將其寫請求傳送到儲存服務,包括令牌 34。稍後客戶端 1 恢復執行並將其寫入傳送到儲存服務,包括其令牌值 33。然而儲存服務記得它已經處理了具有更高令牌編號34的寫入因此它拒絕帶有令牌 33 的請求。剛剛獲得租約的客戶端必須立即向儲存服務進行寫入,一旦該寫入完成,任何殭屍都被隔離了。
如果 ZooKeeper 是你的鎖服務,你可以使用事務 ID `zxid` 或節點版本 `cversion` 作為隔離令牌 [^85]。使用 etcd修訂號與租約 ID 一起起著類似的作用 [^89]。Hazelcast 中的 FencedLock API 明確生成隔離令牌 [^90]。
@ -507,12 +507,12 @@ while (true) {
例如,想象儲存服務是一個具有最後寫入勝利衝突解決的無主複製鍵值儲存(見 ["無主複製"](/tw/ch6#sec_replication_leaderless))。在這樣的系統中,客戶端直接向每個副本傳送寫入,每個副本根據客戶端分配的時間戳獨立決定是否接受寫入。
如 [圖 9-7](/tw/ch9#fig_distributed_fencing_leaderless) 所示,你可以將寫入者的隔離令牌放在時間戳的最高有效位或數字中。然後你可以確保新租約持有者生成的任何時間戳都將大於舊租約持有者的任何時間戳,即使舊租約持有者的寫入發生得更晚。
如 [圖 9-7](#fig_distributed_fencing_leaderless) 所示,你可以將寫入者的隔離令牌放在時間戳的最高有效位或數字中。然後你可以確保新租約持有者生成的任何時間戳都將大於舊租約持有者的任何時間戳,即使舊租約持有者的寫入發生得更晚。
{{< figure src="/fig/ddia_0907.png" id="fig_distributed_fencing_leaderless" caption="圖 9-7. 使用隔離令牌保護對無主複製資料庫的寫入。" class="w-full my-4" >}}
在 [圖 9-7](/tw/ch9#fig_distributed_fencing_leaderless) 中,客戶端 2 有隔離令牌 34因此它所有以 34… 開頭的時間戳都大於客戶端 1 生成的任何以 33… 開頭的時間戳。客戶端 2 寫入副本的仲裁,但它無法到達副本 3。這意味著當殭屍客戶端 1 稍後嘗試寫入時,它的寫入可能在副本 3 上成功,即使它被副本 1 和 2 忽略。這不是問題,因為後續的仲裁讀取將更喜歡具有更大時間戳的客戶端 2 的寫入,讀修復或反熵最終將覆蓋客戶端 1 寫入的值。
在 [圖 9-7](#fig_distributed_fencing_leaderless) 中,客戶端 2 有隔離令牌 34因此它所有以 34… 開頭的時間戳都大於客戶端 1 生成的任何以 33… 開頭的時間戳。客戶端 2 寫入副本的仲裁,但它無法到達副本 3。這意味著當殭屍客戶端 1 稍後嘗試寫入時,它的寫入可能在副本 3 上成功,即使它被副本 1 和 2 忽略。這不是問題,因為後續的仲裁讀取將更喜歡具有更大時間戳的客戶端 2 的寫入,讀修復或反熵最終將覆蓋客戶端 1 寫入的值。
從這些例子可以看出,假設任何時候只有一個節點持有租約是不安全的。幸運的是,透過一點小心,你可以使用隔離令牌來防止殭屍和延遲請求造成任何損害。
@ -594,7 +594,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意
為了定義演算法 *正確* 的含義,我們可以描述它的 *屬性*。例如,排序演算法的輸出具有這樣的屬性:對於輸出列表的任何兩個不同元素,左邊的元素小於右邊的元素。這只是定義列表排序含義的正式方式。
同樣,我們可以寫下我們希望分散式演算法具有的屬性,以定義正確的含義。例如,如果我們為鎖生成隔離令牌(見 ["隔離殭屍程序和延遲請求"](/tw/ch9#sec_distributed_fencing_tokens)),我們可能要求演算法具有以下屬性:
同樣,我們可以寫下我們希望分散式演算法具有的屬性,以定義正確的含義。例如,如果我們為鎖生成隔離令牌(見 ["隔離殭屍程序和延遲請求"](#sec_distributed_fencing_tokens)),我們可能要求演算法具有以下屬性:
唯一性
: 沒有兩個隔離令牌請求返回相同的值。

View file

@ -10,13 +10,12 @@ breadcrumbs: false
## 關於作者
**Martin Kleppmann** 是英國劍橋大學分散式系統的研究員。此前他曾在網際網路公司擔任過軟體工程師和企業家,其中包括 LinkedIn 和 Rapportive負責大規模資料基礎架構。在這個過程中他以艱難的方式學習了一些東西他希望這本書能夠讓你避免重蹈覆轍
**Martin Kleppmann** 是英國劍橋大學副教授教授分散式系統與密碼學協議。2017 年出版的《設計資料密集型應用》第一版確立了他在資料系統領域的權威地位;他在分散式系統方面的研究也推動了 local-first 軟體運動。此前他曾在 LinkedIn、Rapportive 等網際網路公司擔任軟體工程師和創業者,負責大規模資料基礎設施
Martin 是一位常規會議演講者,博主和開源貢獻者。他認為,每個人都應該有深刻的技術理念,深層次的理解能幫助我們開發出更好的軟體
**Chris Riccomini** 是軟體工程師、創業投資人和作者,擁有 15 年以上在 PayPal、LinkedIn、WePay 的工作經驗。他運營 Materialized View Capital專注於基礎設施初創企業投資同時也是 Apache Samza 與 SlateDB 的共同創造者,併合著了 *The Missing README: A Guide for the New Software Engineer*
![](http://martin.kleppmann.com/2017/03/ddia-poster.jpg)
## 關於譯者
[**馮若航**](https://vonng.com),網名 [@Vonng](https://github.com/Vonng)。

View file

@ -4,257 +4,250 @@ weight: 500
breadcrumbs: false
---
{{< callout type="warning" >}}
當前頁面來自本書第一版,第二版尚不可用
{{< /callout >}}
> 請注意:本術語表的定義刻意保持簡短,旨在傳達核心概念,而非覆蓋術語的全部細節。更多內容請參閱正文對應章節。
> 請注意,本術語表中的定義簡短而簡單,旨在傳達核心思想,而非死扣完整細節。有關更多詳細資訊,請參閱正文中的參考資料。
### 非同步asynchronous
不等待某件事完成(例如透過網路把資料傳送到另一個節點),且不假設它會在多長時間內完成。參見“[同步與非同步複製](/tw/ch6#sec_replication_sync_async)”、“[同步網路與非同步網路](/tw/ch9#sec_distributed_sync_networks)”和“[系統模型與現實](/tw/ch9#sec_distributed_system_model)”。
## **非同步asynchronous**
### 原子atomic
不等待某些事情完成(例如,將資料傳送到網路中的另一個節點),並且不會假設要花多長時間。請參閱“[同步複製與非同步複製](/tw/ch5#同步複製與非同步複製)”、“[同步網路與非同步網路](/tw/ch8#同步網路與非同步網路)”以及“[系統模型與現實](/tw/ch8#系統模型與現實)”。
1. 在併發語境下:指一個操作看起來在某個單一時刻生效,其他併發程序不會看到它處於“半完成”狀態。另見 *isolation*
2. 在事務語境下:指一組寫入要麼全部提交、要麼全部回滾,即使發生故障也不例外。參見“[原子性](/tw/ch8#sec_transactions_acid_atomicity)”和“[兩階段提交2PC](/tw/ch8#sec_transactions_2pc)”。
## **原子atomic**
### 背壓backpressure
在併發操作的上下文中:描述一個在單個時間點看起來生效的操作,所以另一個併發程序永遠不會遇到處於“半完成”狀態的操作。另見隔離
當接收方跟不上時,強制傳送方降速。也稱為 *flow control*。參見“[系統過載後無法恢復時會發生什麼](/tw/ch2#sidebar_metastable)”
在事務的上下文中:將一些寫入操作分為一組,這組寫入要麼全部提交成功,要麼遇到錯誤時全部回滾。請參閱“[原子性](/tw/ch7#原子性)”和“[原子提交與兩階段提交](/tw/ch9#原子提交與兩階段提交)”。
### 批處理batch process
## **背壓backpressure**
以一個固定(通常較大)資料集為輸入、產出另一份資料且不修改輸入的計算。參見[第 11 章](/tw/ch11#ch_batch)。
接收方接收資料速度較慢時,強制降低傳送方的資料傳送速度。也稱為流量控制。請參閱“[訊息傳遞系統](/tw/ch11#訊息傳遞系統)”。
### 有界bounded
## **批處理batch process**
具有已知上限或大小。例如可用於描述網路延遲(參見“[超時與無界延遲](/tw/ch9#sec_distributed_queueing)”)和資料集(參見[第 12 章](/tw/ch12#ch_stream)導言)。
一種計算,它將一些固定的(通常是大的)資料集作為輸入,並將其他一些資料作為輸出,而不修改輸入。見[第十章](/tw/ch10)。
### 拜占庭故障Byzantine fault
## **邊界bounded**
節點以任意錯誤方式行為,例如向不同節點發送相互矛盾或惡意訊息。參見“[拜占庭故障](/tw/ch9#sec_distributed_byzantine)”。
有一些已知的上限或大小。例如,網路延遲情況(請參閱“[超時與無窮的延遲](/tw/ch8#超時與無窮的延遲)”)和資料集(請參閱[第十一章](/tw/ch11)的介紹)。
### 快取cache
## **拜占庭故障Byzantine fault**
透過記住近期訪問資料來加速後續讀取的元件。快取通常不完整:若未命中,需要回源到更慢但完整的底層資料儲存。
表現異常的節點,這種異常可能以任意方式出現,例如向其他節點發送矛盾或惡意訊息。請參閱“[拜占庭故障](/tw/ch8#拜占庭故障)”。
### CAP 定理CAP theorem
## **快取cache**
一個在實踐中經常被誤解、且不太有直接指導價值的理論結果。參見“[CAP 定理](/tw/ch10#the-cap-theorem)”。
一種元件,透過儲存最近使用過的資料,加快未來對相同資料的讀取速度。快取中通常存放部分資料:因此,如果快取中缺少某些資料,則必須從某些底層較慢的資料儲存系統中,獲取完整的資料副本。
### 因果關係causality
## **CAP定理CAP theorem**
當一件事“先於”另一件事發生時產生的事件依賴關係。例如後續事件對先前事件的響應、建立在先前事件之上,或必須結合先前事件理解。參見“[happens-before 關係與併發](/tw/ch6#sec_replication_happens_before)”。
一個被廣泛誤解的理論結果,在實踐中是沒有用的。請參閱“[CAP定理](/tw/ch9#CAP定理)”。
### 共識consensus
## **因果關係causality**
分散式計算中的基本問題:讓多個節點就某件事達成一致(例如誰是主節點)。這比直覺上要困難得多。參見“[共識](/tw/ch10#sec_consistency_consensus)”。
事件之間的依賴關係,當一件事發生在另一件事情之前。例如,後面的事件是對早期事件的回應,或者依賴於更早的事件,或者應該根據先前的事件來理解。請參閱“[“此前發生”的關係和併發](/tw/ch5#“此前發生”的關係和併發)”和“[順序與因果關係](/tw/ch9#順序與因果關係)”。
### 資料倉庫data warehouse
## **共識consensus**
將多個 OLTP 系統的資料彙總並整理後,用於分析場景的資料庫。參見“[資料倉庫](/tw/ch1#sec_introduction_dwh)”。
分散式計算的一個基本問題,就是讓幾個節點同意某些事情(例如,哪個節點應該是資料庫叢集的領導者)。問題比乍看起來要困難得多。請參閱“[容錯共識](/tw/ch9#容錯共識)”。
### 宣告式declarative
## **資料倉庫data warehouse**
描述“想要什麼性質”,而非“如何一步步實現”。在資料庫查詢中,最佳化器接收宣告式查詢並決定最佳執行方式。參見“[術語:宣告式查詢語言](/tw/ch3)”。
一個數據庫其中來自幾個不同的OLTP系統的資料已經被合併和準備用於分析目的。請參閱“[資料倉庫](/tw/ch3#資料倉庫)”。
### 反正規化denormalize
## **宣告式declarative**
在已正規化資料集中引入一定冗餘(常見形式為快取或索引)以換取更快讀取。反正規化值可看作預計算結果,類似物化檢視。參見“[正規化、反正規化與連線](/tw/ch3#sec_datamodels_normalization)”。
描述某些東西應有的屬性,但不知道如何實現它的確切步驟。在查詢的上下文中,查詢最佳化器採用宣告性查詢並決定如何最好地執行它。請參閱“[資料查詢語言](/tw/ch2#資料查詢語言)”。
### 派生資料derived data
## **反正規化denormalize**
透過可重複流程由其他資料生成的資料集,必要時可重新計算。通常用於加速某類讀取。索引、快取、物化檢視都屬於派生資料。參見“[記錄系統與派生資料](/tw/ch1#sec_introduction_derived)”。
為了加速讀取,在標準資料集中引入一些冗餘或重複資料,通常採用快取或索引的形式。反正規化的值是一種預先計算的查詢結果,像物化檢視。請參閱“[單物件和多物件操作](/tw/ch7#單物件和多物件操作)”和“[從同一事件日誌中派生多個檢視](/tw/ch11#從同一事件日誌中派生多個檢視)”。
### 確定性deterministic
## **派生資料derived data**
一個函式在相同輸入下總產生相同輸出,不依賴隨機數、當前時間、網路互動等不可預測因素。參見“[確定性的力量](/tw/ch9#sidebar_distributed_determinism)”。
一種資料集,根據其他資料透過可重複執行的流程建立。必要時,你可以執行該流程再次建立派生資料。派生資料通常用於提高特定資料的讀取速度。常見的派生資料有索引、快取和物化檢視。請參閱[第三部分](/tw/part-iii)的介紹。
### 分散式distributed
## **確定性deterministic**
系統在多個透過網路連線的節點上執行。其典型特徵是 *部分失效*:一部分壞了,另一部分仍在工作,而軟體往往難以精確知道哪裡壞了。參見“[故障與部分失效](/tw/ch9#sec_distributed_partial_failure)”。
描述一個函式,如果給它相同的輸入,則總是產生相同的輸出。這意味著它不能依賴於隨機數字、時間、網路通訊或其他不可預測的事情。
### 永續性durable
## **分散式distributed**
以你相信不會丟失的方式儲存資料,即使發生各種故障。參見“[永續性](/tw/ch8#durability)”。
在由網路連線的多個節點上執行。對於部分節點故障,具有容錯性:系統的一部分發生故障時,其他部分仍可以正常工作,通常情況下,軟體無需瞭解故障相關的確切情況。請參閱“[故障與部分失效](/tw/ch8#故障與部分失效)”。
### ETL
## **持久durable**
Extract-Transform-Load提取-轉換-載入):從源資料庫抽取資料,轉成更適合分析查詢的形式,再載入到資料倉庫或批處理系統。參見“[資料倉庫](/tw/ch1#sec_introduction_dwh)”。
以某種方式儲存資料,即使發生各種故障,也不會丟失資料。請參閱“[永續性](/tw/ch7#永續性)”。
### 故障切換failover
## **ETLExtract-Transform-Load**
在單主系統中,將主角色從一個節點切到另一個節點的過程。參見“[處理節點故障](/tw/ch6#sec_replication_failover)”。
提取-轉換-載入Extract-Transform-Load。從源資料庫中提取資料將其轉換為更適合分析查詢的形式並將其載入到資料倉庫或批處理系統中的過程。請參閱“[資料倉庫](/tw/ch3#資料倉庫)”。
### 容錯fault-tolerant
## **故障切換failover**
出現故障(如機器崩潰、鏈路故障)後仍可自動恢復。參見“[可靠性與容錯](/tw/ch2#sec_introduction_reliability)”。
在具有單一領導者的系統中,故障切換是將領導角色從一個節點轉移到另一個節點的過程。請參閱“[處理節點宕機](/tw/ch5#處理節點宕機)”。
### 流量控制flow control
## **容錯fault-tolerant**
*backpressure*
如果出現問題(例如,機器崩潰或網路連線失敗),可以自動恢復。請參閱“[可靠性](/tw/ch1#可靠性)”。
### 追隨者follower
## **流量控制flow control**
不直接接收客戶端寫入、僅應用來自主節點變更的副本。也稱 *secondary*、*read replica* 或 *hot standby*。參見“[單主複製](/tw/ch6#sec_replication_leader)”。
見背壓backpressure
### 全文檢索full-text search
## **追隨者follower**
按任意關鍵詞搜尋文字,通常支援近似拼寫、同義詞等能力。全文索引是支援此類查詢的一種 *secondary index*。參見“[全文檢索](/tw/ch4#sec_storage_full_text)”。
一種資料副本,僅處理領導者或主庫發出的資料變更,不直接接受來自客戶端的任何寫入。也稱為備庫、從庫、只讀副本或熱備份。請參閱“[領導者與追隨者](/tw/ch5#領導者與追隨者)”。
### 圖graph
## **全文檢索full-text search**
*vertices*(可引用物件,也稱 *nodes**entities*)和 *edges*(頂點間連線,也稱 *relationships**arcs*)組成的資料結構。參見“[圖狀資料模型](/tw/ch3#sec_datamodels_graph)”。
透過任意關鍵字來搜尋文字,通常具有附加特徵,例如匹配類似的拼寫詞或同義詞。全文索引是一種支援這種查詢的次級索引。請參閱“[全文檢索與模糊索引](/tw/ch3#全文檢索與模糊索引)”。
### 雜湊hash
## **圖graph**
把輸入對映成看似隨機數字的函式。相同輸入總得相同輸出;不同輸入通常輸出不同,但也可能碰撞(*collision*)。參見“[按鍵的雜湊分片](/tw/ch7#sec_sharding_hash)”。
一種資料結構,由頂點(可以指向的東西,也稱為節點或實體)和邊(從一個頂點到另一個頂點的連線,也稱為關係或弧)組成。請參閱“[圖資料模型](/tw/ch2#圖資料模型)”。
### 冪等idempotent
## **雜湊hash**
可安全重試的操作:執行多次與執行一次效果相同。參見“[冪等性](/tw/ch12#sec_stream_idempotence)”。
將輸入轉換為看起來像隨機數值的函式。相同的輸入會轉換為相同的數值,不同的輸入一般會轉換為不同的數值,也可能轉換為相同數值(也被稱為衝突)。請參閱“[根據鍵的雜湊分割槽](/tw/ch6#根據鍵的雜湊分割槽)”。
### 索引index
## **冪等idempotent**
一種可高效檢索“某欄位取某值”的記錄的資料結構。參見“[OLTP 的儲存與索引](/tw/ch4#sec_storage_oltp)”。
用於描述一種操作可以安全地重試執行,即執行多次的效果和執行一次的效果相同。請參閱“[冪等性](/tw/ch11#冪等性)”。
### 隔離性isolation
## **索引index**
在事務語境下,併發事務相互干擾的程度。*Serializable* 最強,也常用更弱隔離級別。參見“[隔離性](/tw/ch8#sec_transactions_acid_isolation)”。
一種資料結構。透過索引,你可以根據特定欄位的值,在所有資料記錄中進行高效檢索。請參閱“[驅動資料庫的資料結構](/tw/ch3#驅動資料庫的資料結構)”。
### 連線join
## **隔離性isolation**
把具有關聯關係的記錄拼在一起。常見於一個記錄引用另一個記錄(外部索引鍵、文件引用、圖邊)時,查詢需要取到被引用物件。參見“[正規化、反正規化與連線](/tw/ch3#sec_datamodels_normalization)”和“[JOIN 與 GROUP BY](/tw/ch11#sec_batch_join)”。
在事務上下文中,用於描述併發執行事務的互相干擾程度。序列執行具有最強的隔離性,不過其它程度的隔離也通常被使用。請參閱“[隔離性](/tw/ch7#隔離性)”。
### 領導者leader
## **連線join**
當資料或服務跨多個節點複製時,被指定為可接受寫入的副本。可透過協議選舉或管理員指定。也稱 *primary**source*。參見“[單主複製](/tw/ch6#sec_replication_leader)”。
彙集有共同點的記錄。在一個記錄與另一個記錄有關(外部索引鍵,文件參考,圖中的邊)的情況下最常用,查詢需要獲取參考所指向的記錄。請參閱“[多對一和多對多的關係](/tw/ch2#多對一和多對多的關係)”和“[Reduce側連線與分組](/tw/ch10#Reduce側連線與分組)”。
### 線性一致linearizable
## **領導者leader**
表現得像系統裡只有一份資料副本,且由原子操作更新。參見“[線性一致性](/tw/ch10#sec_consistency_linearizability)”。
當資料或服務被複制到多個節點時,領導者是被指定為可以接受資料變更的副本。領導者可以透過某些協議選舉產生,也可以由管理員手動選擇。領導者也被稱為主庫。請參閱“[領導者與追隨者](/tw/ch5#領導者與追隨者)”。
### 區域性locality
## **線性化linearizable**
一種效能最佳化:把經常被一起訪問的資料放在一起。參見“[讀寫的資料區域性](/tw/ch3#sec_datamodels_document_locality)”。
表現為系統中只有一份透過原子操作更新的資料副本。請參閱“[線性一致性](/tw/ch9#線性一致性)”。
### 鎖lock
## **區域性locality**
保證同一時刻只有一個執行緒/節點/事務訪問某資源的機制;其他訪問者需等待鎖釋放。參見“[兩階段鎖2PL](/tw/ch8#sec_transactions_2pl)”和“[分散式鎖與租約](/tw/ch9#sec_distributed_lock_fencing)”。
一種效能最佳化方式,如果經常在相同的時間請求一些離散資料,把這些資料放到一個位置。請參閱“[查詢的資料區域性](/tw/ch2#查詢的資料區域性)”。
### 日誌log
## **鎖lock**
只追加寫入的資料檔案。*WAL* 用於崩潰恢復(參見“[讓 B 樹可靠](/tw/ch4#sec_storage_btree_wal)”);*log-structured* 儲存把日誌作為主儲存格式(參見“[日誌結構儲存](/tw/ch4#sec_storage_log_structured)”);*replication log* 用於主從複製(參見“[單主複製](/tw/ch6#sec_replication_leader)”);*event log* 可表示資料流(參見“[基於日誌的訊息代理](/tw/ch12#sec_stream_log) ”)。
一種保證只有一個執行緒、節點或事務可以訪問的機制,如果其它執行緒、節點或事務想訪問相同元素,則必須等待鎖被釋放。請參閱“[兩階段鎖定](/tw/ch7#兩階段鎖定)”和“[領導者和鎖](/tw/ch8#領導者和鎖)”。
### 物化materialize
## **日誌log**
把計算結果提前算出並寫下來,而不是按需即時計算。參見“[事件溯源與 CQRS](/tw/ch3#sec_datamodels_events)”。
日誌是一個只能以追加方式寫入的檔案,用於存放資料。預寫式日誌用於在儲存引擎崩潰時恢復資料(請參閱“[讓B樹更可靠](/tw/ch3#讓B樹更可靠)”);結構化日誌儲存引擎使用日誌作為它的主要儲存格式(請參閱“[SSTables和LSM樹](/tw/ch3#SSTables和LSM樹)”);複製型日誌用於把寫入從領導者複製到追隨者(請參閱“[領導者與追隨者](/tw/ch5#領導者與追隨者)”);事件性日誌可以表現為資料流(請參閱“[分割槽日誌](/tw/ch11#分割槽日誌)”)。
### 節點node
## **物化materialize**
執行在某臺計算機上的軟體例項,透過網路與其他節點協作完成任務。
急切地計算並寫出結果,而不是在請求時計算。請參閱“[聚合:資料立方體和物化檢視](/tw/ch3#聚合:資料立方體和物化檢視)”和“[物化中間狀態](/tw/ch10#物化中間狀態)”。
### 正規化normalized
## **節點node**
資料結構中儘量避免冗餘與重複。正規化資料庫裡某資料變化時通常只改一處,不需多處同步。參見“[正規化、反正規化與連線](/tw/ch3#sec_datamodels_normalization)”。
計算機上執行的一些軟體的例項,透過網路與其他節點通訊以完成某項任務。
### OLAP
## **正規化normalized**
Online Analytic Processing線上分析處理典型訪問模式是對大量記錄做聚合如 count/sum/avg。參見“[事務系統與分析系統](/tw/ch1#sec_introduction_analytics)”。
以沒有冗餘或重複的方式進行結構化。在正規化資料庫中,當某些資料發生變化時,你只需要在一個地方進行更改,而不是在許多不同的地方複製很多次。請參閱“[多對一和多對多的關係](/tw/ch2#多對一和多對多的關係)”。
### OLTP
## **OLAPOnline Analytic Processing**
Online Transaction Processing線上事務處理典型訪問模式是快速讀寫少量記錄通常按鍵索引。參見“[事務系統與分析系統](/tw/ch1#sec_introduction_analytics)”。
線上分析處理。透過對大量記錄進行聚合(例如,計數,總和,平均)來表徵的訪問模式。請參閱“[事務處理還是分析?](/tw/ch3#事務處理還是分析?)”。
### 分片sharding
## **OLTPOnline Transaction Processing**
把單機裝不下的大資料集或計算拆成更小部分並分散到多臺機器上。也稱 *partitioning*。參見[第 7 章](/tw/ch7#ch_sharding)。
線上事務處理。訪問模式的特點是快速查詢,讀取或寫入少量記錄,這些記錄通常透過鍵索引。請參閱“[事務處理還是分析?](/tw/ch3#事務處理還是分析?)”。
### 百分位percentile
## **分割槽partitioning**
透過統計多少值高於/低於某閾值來描述分佈。例如某時段 95 分位響應時間為 *t*,表示 95% 請求耗時小於 *t*5% 更長。參見“[描述效能](/tw/ch2#sec_introduction_percentiles)”。
將單機上的大型資料集或計算結果拆分為較小部分,並將其分佈到多臺機器上。也稱為分片。見[第六章](/tw/ch6)。
### 主鍵primary key
## **百分位點percentile**
唯一標識一條記錄的值(通常為數字或字串)。在很多應用中由系統在建立時生成(順序或隨機),而非使用者手工指定。另見 *secondary index*
透過計算有多少值高於或低於某個閾值來衡量值分佈的方法。例如某個時間段的第95個百分位響應時間是時間t則該時間段中95%的請求完成時間小於t5%的請求完成時間要比t長。請參閱“[描述效能](/tw/ch1#描述效能)”。
### 法定票數quorum
## **主鍵primary key**
一個操作被判定成功前所需的最少投票節點數。參見“[讀寫法定票數](/tw/ch6#sec_replication_quorum_condition)”。
唯一標識記錄的值(通常是數字或字串)。在許多應用程式中,主鍵由系統在建立記錄時生成(例如,按順序或隨機); 它們通常不由使用者設定。另請參閱次級索引。
### 再平衡rebalance
## **法定人數quorum**
為均衡負載,把資料或服務從一個節點遷移到另一個節點。參見“[鍵值資料的分片](/tw/ch7#sec_sharding_key_value)”。
在操作完成之前,需要對操作進行投票的最少節點數量。請參閱“[讀寫的法定人數](/tw/ch5#讀寫的法定人數)”。
### 複製replication
## **再平衡rebalance**
在多個節點(*replicas*)上儲存同一份資料,以便部分節點不可達時仍可訪問。參見[第 6 章](/tw/ch6#ch_replication)。
將資料或服務從一個節點移動到另一個節點以實現負載均衡。請參閱“[分割槽再平衡](/tw/ch6#分割槽再平衡)”。
### 模式schema
## **複製replication**
對資料結構(欄位、型別等)的描述。資料是否符合模式可在生命週期不同階段檢查(參見“[文件模型中的模式靈活性](/tw/ch3#sec_datamodels_schema_flexibility)”),模式也可隨時間演進(參見[第 5 章](/tw/ch5#ch_encoding))。
在幾個節點(副本)上保留相同資料的副本,以便在某些節點無法訪問時,資料仍可訪問。請參閱[第五章](/tw/ch5)。
### 二級索引secondary index
## **模式schema**
與主儲存並行維護的附加結構,用於高效檢索滿足某類條件的記錄。參見“[多列索引與二級索引](/tw/ch4#sec_storage_index_multicolumn)”和“[分片與二級索引](/tw/ch7#sec_sharding_secondary_indexes)”。
一些資料結構的描述,包括其欄位和資料型別。可以在資料生命週期的不同點檢查某些資料是否符合模式(請參閱“[文件模型中的模式靈活性](/tw/ch2#文件模型中的模式靈活性)”),模式可以隨時間變化(請參閱[第四章](/tw/ch4))。
### 可序列化serializable
## **次級索引secondary index**
一種 *isolation* 保證:多個事務併發執行時,行為等價於某個序列順序逐個執行。參見“[可序列化](/tw/ch8#sec_transactions_serializability)”。
與主要資料儲存器一起維護的附加資料結構,使你可以高效地搜尋與某種條件相匹配的記錄。請參閱“[其他索引結構](/tw/ch3#其他索引結構)”和“[分割槽與次級索引](/tw/ch6#分割槽與次級索引)”。
### 無共享shared-nothing
## **可序列化serializable**
一種架構:獨立節點(各自 CPU、記憶體、磁碟透過普通網路連線相對的是共享記憶體或共享磁碟架構。參見“[共享記憶體、共享磁碟與無共享架構](/tw/ch2#sec_introduction_shared_nothing)”。
保證多個併發事務同時執行時,它們的行為與按順序逐個執行事務相同。請參閱第七章的“[可序列化](/tw/ch7#可序列化)”。
### 偏斜skew
## **無共享shared-nothing**
1. 分片負載不均:某些分片請求/資料很多,另一些很少。也稱 *hot spots*。參見“[負載偏斜與熱點消除](/tw/ch7#sec_sharding_skew)”。
2. 一種時序異常,導致事件呈現為非預期的非順序。參見“[快照隔離與可重複讀](/tw/ch8#sec_transactions_snapshot_isolation)”中的讀偏斜、“[寫偏斜與幻讀](/tw/ch8#sec_transactions_write_skew)”中的寫偏斜、以及“[用於事件排序的時間戳](/tw/ch9#sec_distributed_lww)”中的時鐘偏斜。
與共享記憶體或共享磁碟架構相比獨立節點每個節點都有自己的CPU記憶體和磁碟透過傳統網路連線。見[第二部分](/tw/part-ii)的介紹。
### 腦裂split brain
## **偏斜skew**
兩個節點同時認為自己是領導者,可能破壞系統保證。參見“[處理節點故障](/tw/ch6#sec_replication_failover)”和“[少數服從多數](/tw/ch9#sec_distributed_majority)”。
各分割槽負載不平衡,例如某些分割槽有大量請求或資料,而其他分割槽則少得多。也被稱為熱點。請參閱“[負載偏斜與熱點消除](/tw/ch6#負載偏斜與熱點消除)”和“[處理偏斜](/tw/ch10#處理偏斜)”。
### 儲存過程stored procedure
時間線異常導致事件以不期望的順序出現。請參閱“[快照隔離和可重複讀](/tw/ch7#快照隔離和可重複讀)”中的關於讀取偏差的討論,“[寫入偏差與幻讀](/tw/ch7#寫入偏差與幻讀)”中的寫入偏差以及“[有序事件的時間戳](/tw/ch8#有序事件的時間戳)”中的時鐘偏斜
把事務邏輯編碼到資料庫伺服器端執行,使事務過程中無需與客戶端來回通訊。參見“[實際序列執行](/tw/ch8#sec_transactions_serial)”
## **腦裂split brain**
### 流處理stream process
兩個節點同時認為自己是領導者的情況,這種情況可能違反系統擔保。請參閱“[處理節點宕機](/tw/ch5#處理節點宕機)”和“[真相由多數所定義](/tw/ch8#真相由多數所定義)”
持續執行的計算:消費無窮事件流併產出結果。參見[第 12 章](/tw/ch12#ch_stream)
## **儲存過程stored procedure**
### 同步synchronous
一種對事務邏輯進行編碼的方式,它可以完全在資料庫伺服器上執行,事務執行期間無需與客戶端通訊。請參閱“[真的序列執行](/tw/ch7#真的序列執行)”
*asynchronous* 的反義詞
## **流處理stream process**
### 記錄系統system of record
持續執行的計算。可以持續接收事件流作為輸入,並得出一些輸出。見[第十一章](/tw/ch11)
持有某類資料主權威版本的系統,也稱 *source of truth*。資料變更首先寫入這裡,其他資料集可由其派生。參見“[記錄系統與派生資料](/tw/ch1#sec_introduction_derived)”
## **同步synchronous**
### 超時timeout
非同步的反義詞
最簡單的故障檢測方式之一:在一定時間內未收到響應即判定超時。但無法確定是遠端節點故障還是網路問題導致。參見“[超時與無界延遲](/tw/ch9#sec_distributed_queueing)”
## **記錄系統system of record**
### 全序total order
一個儲存主要權威版本資料的系統,也被稱為真相的來源。首先在這裡寫入資料變更,其他資料集可以從記錄系統派生。請參閱[第三部分](/tw/part-iii)的介紹
一種可比較關係(如時間戳),任意兩者都能判定大小。若存在不可比較元素,則稱 *partial order*(偏序)
## **超時timeout**
### 事務transaction
檢測故障的最簡單方法之一,即在一段時間內觀察是否缺乏響應。但是,不可能知道超時是由於遠端節點的問題還是網路中的問題造成的。請參閱“[超時與無窮的延遲](/tw/ch8#超時與無窮的延遲)”
把多次讀寫封裝為一個邏輯單元,以簡化錯誤處理與併發問題。參見[第 8 章](/tw/ch8#ch_transactions)
## **全序total order**
### 兩階段提交two-phase commit, 2PC
一種比較事物的方法(例如時間戳),可以讓你總是說出兩件事中哪一件更大,哪件更小。總的來說,有些東西是無法比擬的(不能說哪個更大或更小)的順序稱為偏序。請參閱“[因果順序不是全序的](/tw/ch9#因果順序不是全序的)”。
保證多個數據庫節點對同一事務要麼都 *atomically* 提交、要麼都中止的演算法。參見“[兩階段提交2PC](/tw/ch8#sec_transactions_2pc)”。
## **事務transaction**
### 兩階段鎖two-phase locking, 2PL
為了簡化錯誤處理和併發問題,將幾個讀寫操作分組到一個邏輯單元中。見[第七章](/tw/ch7)
實現 *serializable isolation* 的演算法:事務對讀寫資料加鎖並持有到事務結束。參見“[兩階段鎖2PL](/tw/ch8#sec_transactions_2pl)”
## **兩階段提交2PC, two-phase commit**
### 無界unbounded
一種確保多個數據庫節點全部提交或全部中止事務的演算法。請參閱“[原子提交與兩階段提交](/tw/ch9#原子提交與兩階段提交)”。
## **兩階段鎖定2PL, two-phase locking**
一種用於實現可序列化隔離的演算法,該演算法透過事務獲取對其讀取或寫入的所有資料的鎖,直到事務結束。請參閱“[兩階段鎖定](/tw/ch7#兩階段鎖定)”。
## **無邊界unbounded**
沒有任何已知的上限或大小。反義詞是邊界bounded
沒有已知上限或大小。與 *bounded* 相反。

3542
content/tw/indexes.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ breadcrumbs: false
當前頁面來自本書第一版,第二版尚不可用
{{< /callout >}}
本書前章介紹了資料系統底層的基礎概念,無論是在單臺機器上執行的單點資料系統,還是分佈在多臺機器上的分散式資料系統都適用。
本書前章介紹了資料系統底層的基礎概念,無論是在單臺機器上執行的單點資料系統,還是分佈在多臺機器上的分散式資料系統都適用。
1. [第一章](/tw/ch1) 將介紹 **資料系統架構中的利弊權衡**。我們將討論不同型別的資料系統(例如,分析型與事務型),以及它們在雲環境中的執行方式。
2. [第二章](/tw/ch2) 將介紹非功能性需求的定義。。**可靠性,可伸縮性和可維護性** ,這些詞彙到底意味著什麼?如何實現這些目標?

View file

@ -72,7 +72,7 @@ breadcrumbs: false
2. 在 [第二部分](/tw/part-ii) 中,我們從討論儲存在一臺機器上的資料轉向討論分佈在多臺機器上的資料。這對於可伸縮性通常是必需的,但帶來了各種獨特的挑戰。我們首先討論複製([第五章](/tw/ch5))、分割槽 / 分片([第六章](/tw/ch6))和事務([第七章](/tw/ch7))。然後我們將探索關於分散式系統問題的更多細節([第八章](/tw/ch8)),以及在分散式系統中實現一致性與共識意味著什麼([第九章](/tw/ch9))。
3. 在 [第三部分](/tw/part-iii) 中,我們討論那些從其他資料集派生出一些資料集的系統。派生資料經常出現在異構系統中:當沒有單個數據庫可以把所有事情都做的很好時,應用需要整合幾種不同的資料庫、快取、索引等。在 [第十一章](/tw/ch11) 中我們將從一種派生資料的批處理方法開始,然後在此基礎上建立在 [第十二章](/tw/ch12) 中討論的流處理。最後,在 [第十三章](/tw/ch13) 中,我們將所有內容彙總,討論在將來構建可靠、可伸縮和可維護的應用程式的方法。
3. 在 [第三部分](/tw/part-iii) 中,我們討論那些從其他資料集派生出一些資料集的系統。派生資料經常出現在異構系統中:當沒有單個數據庫可以把所有事情都做的很好時,應用需要整合幾種不同的資料庫、快取、索引等。在 [第十章](/tw/ch10) 中我們將從一種派生資料的批處理方法開始,然後在此基礎上建立在 [第十一章](/tw/ch11) 中討論的流處理。最後,在 [第十二章](/tw/ch12) 中,我們將所有內容彙總,討論在將來構建可靠、可伸縮和可維護的應用程式的方法。
## 參考文獻與延伸閱讀
@ -89,6 +89,28 @@ Members have access to thousands of books, training videos, Learning Paths, inte
For more information, please visit http://oreilly.com/safari.
## 聯絡我們
有關本書的評論和問題,請聯絡出版社:
OReilly Media, Inc.
1005 Gravenstein Highway North
Sebastopol, CA 95472
800-998-9938美國或加拿大
707-829-0515國際或本地
707-829-0104傳真
我們為本書提供了網頁,會在上面列出勘誤、示例以及任何補充資訊。你可以訪問:*http://bit.ly/designing-data-intensive-apps*。
如需發表評論或提出技術問題,請傳送郵件至:*bookquestions@oreilly.com*。
有關 OReilly 圖書、課程、會議和新聞的更多資訊,請訪問:*http://www.oreilly.com*。
* Facebook: [http://facebook.com/oreilly](http://facebook.com/oreilly)
* Twitter: [http://twitter.com/oreillymedia](http://twitter.com/oreillymedia)
* YouTube: [http://www.youtube.com/oreillymedia](http://www.youtube.com/oreillymedia)
## 致謝
本書融合了學術研究和工業實踐的經驗,融合並系統化了大量其他人的想法與知識。在計算領域,我們往往會被各種新鮮花樣所吸引,但我認為前人完成的工作中,有太多值得我們學習的地方了。本書有 800 多處引用:文章、部落格、講座、文件等,對我來說這些都是寶貴的學習資源。我非常感謝這些材料的作者分享他們的知識。

View file

@ -268,7 +268,9 @@ Apache Kafka [^20] 和 Amazon Kinesis Streams 都是按这种方式工作的基
如果你只能保留有限的历史日志,则每次要添加新的派生数据系统时,都需要做一次快照。但 **日志压缩log compaction** 提供了一个很好的备选方案。
我们之前在 “[日志结构存储](/ch4#sec_storage_log_structured)” 的上下文中讨论过日志压缩(可参阅 [图 4-3](/ch4#fig_storage_sstable_merging) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
我们之前在 “[日志结构存储](/ch4#sec_storage_log_structured)” 的上下文中讨论过日志压缩(可参阅 [图 4-3](/ch4#fig_storage_sstable_merging) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行,如 [图 12-6](#fig_stream_compaction) 所示。
{{< figure src="/fig/ddia_1206.png" id="fig_stream_compaction" caption="图 12-6. 一个键值对日志,其中键是猫视频的 IDmew、purr、scratch、yawn值是播放次数。日志压缩只保留每个键的最新值。" class="w-full my-4" >}}
在日志结构存储引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示该键被删除并会在日志压缩过程中被移除。但只要键不被覆盖或删除它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入则先前的值将最终将被垃圾回收只有最新的值会保留下来。

View file

@ -10,13 +10,12 @@ breadcrumbs: false
## 关于作者
**Martin Kleppmann** 是英国剑桥大学分布式系统的研究员。此前他曾在互联网公司担任过软件工程师和企业家,其中包括 LinkedIn 和 Rapportive负责大规模数据基础架构。在这个过程中他以艰难的方式学习了一些东西他希望这本书能够让你避免重蹈覆辙
**Martin Kleppmann** 是英国剑桥大学副教授教授分布式系统与密码学协议。2017 年出版的《设计数据密集型应用》第一版确立了他在数据系统领域的权威地位;他在分布式系统方面的研究也推动了 local-first 软件运动。此前他曾在 LinkedIn、Rapportive 等互联网公司担任软件工程师和创业者,负责大规模数据基础设施
Martin 是一位常规会议演讲者,博主和开源贡献者。他认为,每个人都应该有深刻的技术理念,深层次的理解能帮助我们开发出更好的软件
**Chris Riccomini** 是软件工程师、创业投资人和作者,拥有 15 年以上在 PayPal、LinkedIn、WePay 的工作经验。他运营 Materialized View Capital专注于基础设施初创企业投资同时也是 Apache Samza 与 SlateDB 的共同创造者,并合著了 *The Missing README: A Guide for the New Software Engineer*
![](http://martin.kleppmann.com/2017/03/ddia-poster.jpg)
## 关于译者
[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
@ -40,4 +39,4 @@ PostgreSQL 发行版 [**Pigsty**](https://pgsty.com) 作者与创始人。
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。
封面图片来自 Shaw's Zoology。封面字体是 URW Typewriter 和 Guardian Sans。文字字体是 Adobe Minion Pro图中的字体是 Adobe Myriad Pro标题字体是 Adobe Myriad Condensed代码字体是 Dalton Maag 的 Ubuntu Mono。

View file

@ -4,257 +4,250 @@ weight: 500
breadcrumbs: false
---
{{< callout type="warning" >}}
当前页面来自本书第一版,第二版尚不可用
{{< /callout >}}
> 请注意:本术语表的定义刻意保持简短,旨在传达核心概念,而非覆盖术语的全部细节。更多内容请参阅正文对应章节。
> 请注意,本术语表中的定义简短而简单,旨在传达核心思想,而非死扣完整细节。有关更多详细信息,请参阅正文中的参考资料。
### 异步asynchronous
不等待某件事完成(例如通过网络把数据发送到另一个节点),且不假设它会在多长时间内完成。参见“[同步与异步复制](/ch6#sec_replication_sync_async)”、“[同步网络与异步网络](/ch9#sec_distributed_sync_networks)”和“[系统模型与现实](/ch9#sec_distributed_system_model)”。
## **异步asynchronous**
### 原子atomic
不等待某些事情完成(例如,将数据发送到网络中的另一个节点),并且不会假设要花多长时间。请参阅“[同步复制与异步复制](/ch5#同步复制与异步复制)”、“[同步网络与异步网络](/ch8#同步网络与异步网络)”以及“[系统模型与现实](/ch8#系统模型与现实)”。
1. 在并发语境下:指一个操作看起来在某个单一时刻生效,其他并发进程不会看到它处于“半完成”状态。另见 *isolation*
2. 在事务语境下:指一组写入要么全部提交、要么全部回滚,即使发生故障也不例外。参见“[原子性](/ch8#sec_transactions_acid_atomicity)”和“[两阶段提交2PC](/ch8#sec_transactions_2pc)”。
## **原子atomic**
### 背压backpressure
在并发操作的上下文中:描述一个在单个时间点看起来生效的操作,所以另一个并发进程永远不会遇到处于“半完成”状态的操作。另见隔离
当接收方跟不上时,强制发送方降速。也称为 *flow control*。参见“[系统过载后无法恢复时会发生什么](/ch2#sidebar_metastable)”
在事务的上下文中:将一些写入操作分为一组,这组写入要么全部提交成功,要么遇到错误时全部回滚。请参阅“[原子性](/ch7#原子性)”和“[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”。
### 批处理batch process
## **背压backpressure**
以一个固定(通常较大)数据集为输入、产出另一份数据且不修改输入的计算。参见[第 11 章](/ch11#ch_batch)。
接收方接收数据速度较慢时,强制降低发送方的数据发送速度。也称为流量控制。请参阅“[消息传递系统](/ch11#消息传递系统)”。
### 有界bounded
## **批处理batch process**
具有已知上限或大小。例如可用于描述网络延迟(参见“[超时与无界延迟](/ch9#sec_distributed_queueing)”)和数据集(参见[第 12 章](/ch12#ch_stream)导言)。
一种计算,它将一些固定的(通常是大的)数据集作为输入,并将其他一些数据作为输出,而不修改输入。见[第十章](/ch10)。
### 拜占庭故障Byzantine fault
## **边界bounded**
节点以任意错误方式行为,例如向不同节点发送相互矛盾或恶意消息。参见“[拜占庭故障](/ch9#sec_distributed_byzantine)”。
有一些已知的上限或大小。例如,网络延迟情况(请参阅“[超时与无穷的延迟](/ch8#超时与无穷的延迟)”)和数据集(请参阅[第十一章](/ch11)的介绍)。
### 缓存cache
## **拜占庭故障Byzantine fault**
通过记住近期访问数据来加速后续读取的组件。缓存通常不完整:若未命中,需要回源到更慢但完整的底层数据存储。
表现异常的节点,这种异常可能以任意方式出现,例如向其他节点发送矛盾或恶意消息。请参阅“[拜占庭故障](/ch8#拜占庭故障)”。
### CAP 定理CAP theorem
## **缓存cache**
一个在实践中经常被误解、且不太有直接指导价值的理论结果。参见“[CAP 定理](/ch10#the-cap-theorem)”。
一种组件,通过存储最近使用过的数据,加快未来对相同数据的读取速度。缓存中通常存放部分数据:因此,如果缓存中缺少某些数据,则必须从某些底层较慢的数据存储系统中,获取完整的数据副本。
### 因果关系causality
## **CAP定理CAP theorem**
当一件事“先于”另一件事发生时产生的事件依赖关系。例如后续事件对先前事件的响应、建立在先前事件之上,或必须结合先前事件理解。参见“[happens-before 关系与并发](/ch6#sec_replication_happens_before)”。
一个被广泛误解的理论结果,在实践中是没有用的。请参阅“[CAP定理](/ch9#CAP定理)”。
### 共识consensus
## **因果关系causality**
分布式计算中的基本问题:让多个节点就某件事达成一致(例如谁是主节点)。这比直觉上要困难得多。参见“[共识](/ch10#sec_consistency_consensus)”。
事件之间的依赖关系,当一件事发生在另一件事情之前。例如,后面的事件是对早期事件的回应,或者依赖于更早的事件,或者应该根据先前的事件来理解。请参阅“[“此前发生”的关系和并发](/ch5#“此前发生”的关系和并发)”和“[顺序与因果关系](/ch9#顺序与因果关系)”。
### 数据仓库data warehouse
## **共识consensus**
将多个 OLTP 系统的数据汇总并整理后,用于分析场景的数据库。参见“[数据仓库](/ch1#sec_introduction_dwh)”。
分布式计算的一个基本问题,就是让几个节点同意某些事情(例如,哪个节点应该是数据库集群的领导者)。问题比乍看起来要困难得多。请参阅“[容错共识](/ch9#容错共识)”。
### 声明式declarative
## **数据仓库data warehouse**
描述“想要什么性质”,而非“如何一步步实现”。在数据库查询中,优化器接收声明式查询并决定最佳执行方式。参见“[术语:声明式查询语言](/ch3)”。
一个数据库其中来自几个不同的OLTP系统的数据已经被合并和准备用于分析目的。请参阅“[数据仓库](/ch3#数据仓库)”。
### 反规范化denormalize
## **声明式declarative**
在已规范化数据集中引入一定冗余(常见形式为缓存或索引)以换取更快读取。反规范化值可看作预计算结果,类似物化视图。参见“[规范化、反规范化与连接](/ch3#sec_datamodels_normalization)”。
描述某些东西应有的属性,但不知道如何实现它的确切步骤。在查询的上下文中,查询优化器采用声明性查询并决定如何最好地执行它。请参阅“[数据查询语言](/ch2#数据查询语言)”。
### 派生数据derived data
## **非规范化denormalize**
通过可重复流程由其他数据生成的数据集,必要时可重新计算。通常用于加速某类读取。索引、缓存、物化视图都属于派生数据。参见“[记录系统与派生数据](/ch1#sec_introduction_derived)”。
为了加速读取,在标准数据集中引入一些冗余或重复数据,通常采用缓存或索引的形式。非规范化的值是一种预先计算的查询结果,像物化视图。请参阅“[单对象和多对象操作](/ch7#单对象和多对象操作)”和“[从同一事件日志中派生多个视图](/ch11#从同一事件日志中派生多个视图)”。
### 确定性deterministic
## **派生数据derived data**
一个函数在相同输入下总产生相同输出,不依赖随机数、当前时间、网络交互等不可预测因素。参见“[确定性的力量](/ch9#sidebar_distributed_determinism)”。
一种数据集,根据其他数据通过可重复运行的流程创建。必要时,你可以运行该流程再次创建派生数据。派生数据通常用于提高特定数据的读取速度。常见的派生数据有索引、缓存和物化视图。请参阅[第三部分](/part-iii)的介绍。
### 分布式distributed
## **确定性deterministic**
系统在多个通过网络连接的节点上运行。其典型特征是 *部分失效*:一部分坏了,另一部分仍在工作,而软件往往难以精确知道哪里坏了。参见“[故障与部分失效](/ch9#sec_distributed_partial_failure)”。
描述一个函数,如果给它相同的输入,则总是产生相同的输出。这意味着它不能依赖于随机数字、时间、网络通信或其他不可预测的事情。
### 持久性durable
## **分布式distributed**
以你相信不会丢失的方式存储数据,即使发生各种故障。参见“[持久性](/ch8#durability)”。
在由网络连接的多个节点上运行。对于部分节点故障,具有容错性:系统的一部分发生故障时,其他部分仍可以正常工作,通常情况下,软件无需了解故障相关的确切情况。请参阅“[故障与部分失效](/ch8#故障与部分失效)”。
### ETL
## **持久durable**
Extract-Transform-Load提取-转换-加载):从源数据库抽取数据,转成更适合分析查询的形式,再加载到数据仓库或批处理系统。参见“[数据仓库](/ch1#sec_introduction_dwh)”。
以某种方式存储数据,即使发生各种故障,也不会丢失数据。请参阅“[持久性](/ch7#持久性)”。
### 故障切换failover
## **ETLExtract-Transform-Load**
在单主系统中,将主角色从一个节点切到另一个节点的过程。参见“[处理节点故障](/ch6#sec_replication_failover)”。
提取-转换-加载Extract-Transform-Load。从源数据库中提取数据将其转换为更适合分析查询的形式并将其加载到数据仓库或批处理系统中的过程。请参阅“[数据仓库](/ch3#数据仓库)”。
### 容错fault-tolerant
## **故障切换failover**
出现故障(如机器崩溃、链路故障)后仍可自动恢复。参见“[可靠性与容错](/ch2#sec_introduction_reliability)”。
在具有单一领导者的系统中,故障切换是将领导角色从一个节点转移到另一个节点的过程。请参阅“[处理节点宕机](/ch5#处理节点宕机)”。
### 流量控制flow control
## **容错fault-tolerant**
*backpressure*
如果出现问题(例如,机器崩溃或网络连接失败),可以自动恢复。请参阅“[可靠性](/ch1#可靠性)”。
### 追随者follower
## **流量控制flow control**
不直接接收客户端写入、仅应用来自主节点变更的副本。也称 *secondary*、*read replica* 或 *hot standby*。参见“[单主复制](/ch6#sec_replication_leader)”。
见背压backpressure
### 全文检索full-text search
## **追随者follower**
按任意关键词搜索文本,通常支持近似拼写、同义词等能力。全文索引是支持此类查询的一种 *secondary index*。参见“[全文检索](/ch4#sec_storage_full_text)”。
一种数据副本,仅处理领导者或主库发出的数据变更,不直接接受来自客户端的任何写入。也称为备库、从库、只读副本或热备份。请参阅“[领导者与追随者](/ch5#领导者与追随者)”。
### 图graph
## **全文检索full-text search**
*vertices*(可引用对象,也称 *nodes**entities*)和 *edges*(顶点间连接,也称 *relationships**arcs*)组成的数据结构。参见“[图状数据模型](/ch3#sec_datamodels_graph)”。
通过任意关键字来搜索文本,通常具有附加特征,例如匹配类似的拼写词或同义词。全文索引是一种支持这种查询的次级索引。请参阅“[全文检索与模糊索引](/ch3#全文检索与模糊索引)”。
### 哈希hash
## **图graph**
把输入映射成看似随机数字的函数。相同输入总得相同输出;不同输入通常输出不同,但也可能碰撞(*collision*)。参见“[按键的哈希分片](/ch7#sec_sharding_hash)”。
一种数据结构,由顶点(可以指向的东西,也称为节点或实体)和边(从一个顶点到另一个顶点的连接,也称为关系或弧)组成。请参阅“[图数据模型](/ch2#图数据模型)”。
### 幂等idempotent
## **散列hash**
可安全重试的操作:执行多次与执行一次效果相同。参见“[幂等性](/ch12#sec_stream_idempotence)”。
将输入转换为看起来像随机数值的函数。相同的输入会转换为相同的数值,不同的输入一般会转换为不同的数值,也可能转换为相同数值(也被称为冲突)。请参阅“[根据键的散列分区](/ch6#根据键的散列分区)”。
### 索引index
## **幂等idempotent**
一种可高效检索“某字段取某值”的记录的数据结构。参见“[OLTP 的存储与索引](/ch4#sec_storage_oltp)”。
用于描述一种操作可以安全地重试执行,即执行多次的效果和执行一次的效果相同。请参阅“[幂等性](/ch11#幂等性)”。
### 隔离性isolation
## **索引index**
在事务语境下,并发事务相互干扰的程度。*Serializable* 最强,也常用更弱隔离级别。参见“[隔离性](/ch8#sec_transactions_acid_isolation)”。
一种数据结构。通过索引,你可以根据特定字段的值,在所有数据记录中进行高效检索。请参阅“[驱动数据库的数据结构](/ch3#驱动数据库的数据结构)”。
### 连接join
## **隔离性isolation**
把具有关联关系的记录拼在一起。常见于一个记录引用另一个记录(外键、文档引用、图边)时,查询需要取到被引用对象。参见“[规范化、反规范化与连接](/ch3#sec_datamodels_normalization)”和“[JOIN 与 GROUP BY](/ch11#sec_batch_join)”。
在事务上下文中,用于描述并发执行事务的互相干扰程度。串行运行具有最强的隔离性,不过其它程度的隔离也通常被使用。请参阅“[隔离性](/ch7#隔离性)”。
### 领导者leader
## **连接join**
当数据或服务跨多个节点复制时,被指定为可接受写入的副本。可通过协议选举或管理员指定。也称 *primary**source*。参见“[单主复制](/ch6#sec_replication_leader)”。
汇集有共同点的记录。在一个记录与另一个记录有关(外键,文档参考,图中的边)的情况下最常用,查询需要获取参考所指向的记录。请参阅“[多对一和多对多的关系](/ch2#多对一和多对多的关系)”和“[Reduce侧连接与分组](/ch10#Reduce侧连接与分组)”。
### 线性一致linearizable
## **领导者leader**
表现得像系统里只有一份数据副本,且由原子操作更新。参见“[线性一致性](/ch10#sec_consistency_linearizability)”。
当数据或服务被复制到多个节点时,领导者是被指定为可以接受数据变更的副本。领导者可以通过某些协议选举产生,也可以由管理员手动选择。领导者也被称为主库。请参阅“[领导者与追随者](/ch5#领导者与追随者)”。
### 局部性locality
## **线性化linearizable**
一种性能优化:把经常被一起访问的数据放在一起。参见“[读写的数据局部性](/ch3#sec_datamodels_document_locality)”。
表现为系统中只有一份通过原子操作更新的数据副本。请参阅“[线性一致性](/ch9#线性一致性)”。
### 锁lock
## **局部性locality**
保证同一时刻只有一个线程/节点/事务访问某资源的机制;其他访问者需等待锁释放。参见“[两阶段锁2PL](/ch8#sec_transactions_2pl)”和“[分布式锁与租约](/ch9#sec_distributed_lock_fencing)”。
一种性能优化方式,如果经常在相同的时间请求一些离散数据,把这些数据放到一个位置。请参阅“[查询的数据局部性](/ch2#查询的数据局部性)”。
### 日志log
## **锁lock**
只追加写入的数据文件。*WAL* 用于崩溃恢复(参见“[让 B 树可靠](/ch4#sec_storage_btree_wal)”);*log-structured* 存储把日志作为主存储格式(参见“[日志结构存储](/ch4#sec_storage_log_structured)”);*replication log* 用于主从复制(参见“[单主复制](/ch6#sec_replication_leader)”);*event log* 可表示数据流(参见“[基于日志的消息代理](/ch12#sec_stream_log) ”)。
一种保证只有一个线程、节点或事务可以访问的机制,如果其它线程、节点或事务想访问相同元素,则必须等待锁被释放。请参阅“[两阶段锁定](/ch7#两阶段锁定)”和“[领导者和锁](/ch8#领导者和锁)”。
### 物化materialize
## **日志log**
把计算结果提前算出并写下来,而不是按需即时计算。参见“[事件溯源与 CQRS](/ch3#sec_datamodels_events)”。
日志是一个只能以追加方式写入的文件,用于存放数据。预写式日志用于在存储引擎崩溃时恢复数据(请参阅“[让B树更可靠](/ch3#让B树更可靠)”);结构化日志存储引擎使用日志作为它的主要存储格式(请参阅“[SSTables和LSM树](/ch3#SSTables和LSM树)”);复制型日志用于把写入从领导者复制到追随者(请参阅“[领导者与追随者](/ch5#领导者与追随者)”);事件性日志可以表现为数据流(请参阅“[分区日志](/ch11#分区日志)”)。
### 节点node
## **物化materialize**
运行在某台计算机上的软件实例,通过网络与其他节点协作完成任务。
急切地计算并写出结果,而不是在请求时计算。请参阅“[聚合:数据立方体和物化视图](/ch3#聚合:数据立方体和物化视图)”和“[物化中间状态](/ch10#物化中间状态)”。
### 规范化normalized
## **节点node**
数据结构中尽量避免冗余与重复。规范化数据库里某数据变化时通常只改一处,不需多处同步。参见“[规范化、反规范化与连接](/ch3#sec_datamodels_normalization)”。
计算机上运行的一些软件的实例,通过网络与其他节点通信以完成某项任务。
### OLAP
## **规范化normalized**
Online Analytic Processing在线分析处理典型访问模式是对大量记录做聚合如 count/sum/avg。参见“[事务系统与分析系统](/ch1#sec_introduction_analytics)”。
以没有冗余或重复的方式进行结构化。在规范化数据库中,当某些数据发生变化时,你只需要在一个地方进行更改,而不是在许多不同的地方复制很多次。请参阅“[多对一和多对多的关系](/ch2#多对一和多对多的关系)”。
### OLTP
## **OLAPOnline Analytic Processing**
Online Transaction Processing在线事务处理典型访问模式是快速读写少量记录通常按键索引。参见“[事务系统与分析系统](/ch1#sec_introduction_analytics)”。
在线分析处理。通过对大量记录进行聚合(例如,计数,总和,平均)来表征的访问模式。请参阅“[事务处理还是分析?](/ch3#事务处理还是分析?)”。
### 分片sharding
## **OLTPOnline Transaction Processing**
把单机装不下的大数据集或计算拆成更小部分并分散到多台机器上。也称 *partitioning*。参见[第 7 章](/ch7#ch_sharding)。
在线事务处理。访问模式的特点是快速查询,读取或写入少量记录,这些记录通常通过键索引。请参阅“[事务处理还是分析?](/ch3#事务处理还是分析?)”。
### 百分位percentile
## **分区partitioning**
通过统计多少值高于/低于某阈值来描述分布。例如某时段 95 分位响应时间为 *t*,表示 95% 请求耗时小于 *t*5% 更长。参见“[描述性能](/ch2#sec_introduction_percentiles)”。
将单机上的大型数据集或计算结果拆分为较小部分,并将其分布到多台机器上。也称为分片。见[第六章](/ch6)。
### 主键primary key
## **百分位点percentile**
唯一标识一条记录的值(通常为数字或字符串)。在很多应用中由系统在创建时生成(顺序或随机),而非用户手工指定。另见 *secondary index*
通过计算有多少值高于或低于某个阈值来衡量值分布的方法。例如某个时间段的第95个百分位响应时间是时间t则该时间段中95%的请求完成时间小于t5%的请求完成时间要比t长。请参阅“[描述性能](/ch1#描述性能)”。
### 法定票数quorum
## **主键primary key**
一个操作被判定成功前所需的最少投票节点数。参见“[读写法定票数](/ch6#sec_replication_quorum_condition)”。
唯一标识记录的值(通常是数字或字符串)。在许多应用程序中,主键由系统在创建记录时生成(例如,按顺序或随机); 它们通常不由用户设置。另请参阅次级索引。
### 再平衡rebalance
## **法定人数quorum**
为均衡负载,把数据或服务从一个节点迁移到另一个节点。参见“[键值数据的分片](/ch7#sec_sharding_key_value)”。
在操作完成之前,需要对操作进行投票的最少节点数量。请参阅“[读写的法定人数](/ch5#读写的法定人数)”。
### 复制replication
## **再平衡rebalance**
在多个节点(*replicas*)上保存同一份数据,以便部分节点不可达时仍可访问。参见[第 6 章](/ch6#ch_replication)。
将数据或服务从一个节点移动到另一个节点以实现负载均衡。请参阅“[分区再平衡](/ch6#分区再平衡)”。
### 模式schema
## **复制replication**
对数据结构(字段、类型等)的描述。数据是否符合模式可在生命周期不同阶段检查(参见“[文档模型中的模式灵活性](/ch3#sec_datamodels_schema_flexibility)”),模式也可随时间演进(参见[第 5 章](/ch5#ch_encoding))。
在几个节点(副本)上保留相同数据的副本,以便在某些节点无法访问时,数据仍可访问。请参阅[第五章](/ch5)。
### 二级索引secondary index
## **模式schema**
与主存储并行维护的附加结构,用于高效检索满足某类条件的记录。参见“[多列索引与二级索引](/ch4#sec_storage_index_multicolumn)”和“[分片与二级索引](/ch7#sec_sharding_secondary_indexes)”。
一些数据结构的描述,包括其字段和数据类型。可以在数据生命周期的不同点检查某些数据是否符合模式(请参阅“[文档模型中的模式灵活性](/ch2#文档模型中的模式灵活性)”),模式可以随时间变化(请参阅[第四章](/ch4))。
### 可串行化serializable
## **次级索引secondary index**
一种 *isolation* 保证:多个事务并发执行时,行为等价于某个串行顺序逐个执行。参见“[可串行化](/ch8#sec_transactions_serializability)”。
与主要数据存储器一起维护的附加数据结构,使你可以高效地搜索与某种条件相匹配的记录。请参阅“[其他索引结构](/ch3#其他索引结构)”和“[分区与次级索引](/ch6#分区与次级索引)”。
### 无共享shared-nothing
## **可串行化serializable**
一种架构:独立节点(各自 CPU、内存、磁盘通过普通网络连接相对的是共享内存或共享磁盘架构。参见“[共享内存、共享磁盘与无共享架构](/ch2#sec_introduction_shared_nothing)”。
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。请参阅第七章的“[可串行化](/ch7#可串行化)”。
### 偏斜skew
## **无共享shared-nothing**
1. 分片负载不均:某些分片请求/数据很多,另一些很少。也称 *hot spots*。参见“[负载偏斜与热点消除](/ch7#sec_sharding_skew)”。
2. 一种时序异常,导致事件呈现为非预期的非顺序。参见“[快照隔离与可重复读](/ch8#sec_transactions_snapshot_isolation)”中的读偏斜、“[写偏斜与幻读](/ch8#sec_transactions_write_skew)”中的写偏斜、以及“[用于事件排序的时间戳](/ch9#sec_distributed_lww)”中的时钟偏斜。
与共享内存或共享磁盘架构相比独立节点每个节点都有自己的CPU内存和磁盘通过传统网络连接。见[第二部分](/part-ii)的介绍。
### 脑裂split brain
## **偏斜skew**
两个节点同时认为自己是领导者,可能破坏系统保证。参见“[处理节点故障](/ch6#sec_replication_failover)”和“[少数服从多数](/ch9#sec_distributed_majority)”。
各分区负载不平衡,例如某些分区有大量请求或数据,而其他分区则少得多。也被称为热点。请参阅“[负载偏斜与热点消除](/ch6#负载偏斜与热点消除)”和“[处理偏斜](/ch10#处理偏斜)”。
### 存储过程stored procedure
时间线异常导致事件以不期望的顺序出现。请参阅“[快照隔离和可重复读](/ch7#快照隔离和可重复读)”中的关于读取偏差的讨论,“[写入偏差与幻读](/ch7#写入偏差与幻读)”中的写入偏差以及“[有序事件的时间戳](/ch8#有序事件的时间戳)”中的时钟偏斜
把事务逻辑编码到数据库服务器端执行,使事务过程中无需与客户端来回通信。参见“[实际串行执行](/ch8#sec_transactions_serial)”
## **脑裂split brain**
### 流处理stream process
两个节点同时认为自己是领导者的情况,这种情况可能违反系统担保。请参阅“[处理节点宕机](/ch5#处理节点宕机)”和“[真相由多数所定义](/ch8#真相由多数所定义)”
持续运行的计算:消费无穷事件流并产出结果。参见[第 12 章](/ch12#ch_stream)
## **存储过程stored procedure**
### 同步synchronous
一种对事务逻辑进行编码的方式,它可以完全在数据库服务器上执行,事务执行期间无需与客户端通信。请参阅“[真的串行执行](/ch7#真的串行执行)”
*asynchronous* 的反义词
## **流处理stream process**
### 记录系统system of record
持续运行的计算。可以持续接收事件流作为输入,并得出一些输出。见[第十一章](/ch11)
持有某类数据主权威版本的系统,也称 *source of truth*。数据变更首先写入这里,其他数据集可由其派生。参见“[记录系统与派生数据](/ch1#sec_introduction_derived)”
## **同步synchronous**
### 超时timeout
异步的反义词
最简单的故障检测方式之一:在一定时间内未收到响应即判定超时。但无法确定是远端节点故障还是网络问题导致。参见“[超时与无界延迟](/ch9#sec_distributed_queueing)”
## **记录系统system of record**
### 全序total order
一个保存主要权威版本数据的系统,也被称为真相的来源。首先在这里写入数据变更,其他数据集可以从记录系统派生。请参阅[第三部分](/part-iii)的介绍
一种可比较关系(如时间戳),任意两者都能判定大小。若存在不可比较元素,则称 *partial order*(偏序)
## **超时timeout**
### 事务transaction
检测故障的最简单方法之一,即在一段时间内观察是否缺乏响应。但是,不可能知道超时是由于远程节点的问题还是网络中的问题造成的。请参阅“[超时与无穷的延迟](/ch8#超时与无穷的延迟)”
把多次读写封装为一个逻辑单元,以简化错误处理与并发问题。参见[第 8 章](/ch8#ch_transactions)
## **全序total order**
### 两阶段提交two-phase commit, 2PC
一种比较事物的方法(例如时间戳),可以让你总是说出两件事中哪一件更大,哪件更小。总的来说,有些东西是无法比拟的(不能说哪个更大或更小)的顺序称为偏序。请参阅“[因果顺序不是全序的](/ch9#因果顺序不是全序的)”。
保证多个数据库节点对同一事务要么都 *atomically* 提交、要么都中止的算法。参见“[两阶段提交2PC](/ch8#sec_transactions_2pc)”。
## **事务transaction**
### 两阶段锁two-phase locking, 2PL
为了简化错误处理和并发问题,将几个读写操作分组到一个逻辑单元中。见[第七章](/ch7)
实现 *serializable isolation* 的算法:事务对读写数据加锁并持有到事务结束。参见“[两阶段锁2PL](/ch8#sec_transactions_2pl)”
## **两阶段提交2PC, two-phase commit**
### 无界unbounded
一种确保多个数据库节点全部提交或全部中止事务的算法。请参阅“[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”。
## **两阶段锁定2PL, two-phase locking**
一种用于实现可串行化隔离的算法,该算法通过事务获取对其读取或写入的所有数据的锁,直到事务结束。请参阅“[两阶段锁定](/ch7#两阶段锁定)”。
## **无边界unbounded**
没有任何已知的上限或大小。反义词是边界bounded
没有已知上限或大小。与 *bounded* 相反。

3542
content/zh/indexes.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ breadcrumbs: false
当前页面来自本书第一版,第二版尚不可用
{{< /callout >}}
本书前章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
本书前章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
1. [第一章](/ch1) 将介绍 **数据系统架构中的利弊权衡**。我们将讨论不同类型的数据系统(例如,分析型与事务型),以及它们在云环境中的运行方式。
2. [第二章](/ch2) 将介绍非功能性需求的定义。。**可靠性,可伸缩性和可维护性** ,这些词汇到底意味着什么?如何实现这些目标?

View file

@ -72,7 +72,7 @@ breadcrumbs: false
2. 在 [第二部分](/part-ii) 中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可伸缩性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第五章](/ch5))、分区 / 分片([第六章](/ch6))和事务([第七章](/ch7))。然后我们将探索关于分布式系统问题的更多细节([第八章](/ch8)),以及在分布式系统中实现一致性与共识意味着什么([第九章](/ch9))。
3. 在 [第三部分](/part-iii) 中,我们讨论那些从其他数据集派生出一些数据集的系统。派生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 [第十一章](/ch11) 中我们将从一种派生数据的批处理方法开始,然后在此基础上建立在 [第十二章](/ch12) 中讨论的流处理。最后,在 [第十三章](/ch13) 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。
3. 在 [第三部分](/part-iii) 中,我们讨论那些从其他数据集派生出一些数据集的系统。派生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 [第十章](/ch10) 中我们将从一种派生数据的批处理方法开始,然后在此基础上建立在 [第十一章](/ch11) 中讨论的流处理。最后,在 [第十二章](/ch12) 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。
## 参考文献与延伸阅读
@ -89,6 +89,28 @@ Members have access to thousands of books, training videos, Learning Paths, inte
For more information, please visit http://oreilly.com/safari.
## 联系我们
有关本书的评论和问题,请联系出版社:
OReilly Media, Inc.
1005 Gravenstein Highway North
Sebastopol, CA 95472
800-998-9938美国或加拿大
707-829-0515国际或本地
707-829-0104传真
我们为本书提供了网页,会在上面列出勘误、示例以及任何补充信息。你可以访问:*http://bit.ly/designing-data-intensive-apps*。
如需发表评论或提出技术问题,请发送邮件至:*bookquestions@oreilly.com*。
有关 OReilly 图书、课程、会议和新闻的更多信息,请访问:*http://www.oreilly.com*。
* Facebook: [http://facebook.com/oreilly](http://facebook.com/oreilly)
* Twitter: [http://twitter.com/oreillymedia](http://twitter.com/oreillymedia)
* YouTube: [http://www.youtube.com/oreillymedia](http://www.youtube.com/oreillymedia)
## 致谢
本书融合了学术研究和工业实践的经验,融合并系统化了大量其他人的想法与知识。在计算领域,我们往往会被各种新鲜花样所吸引,但我认为前人完成的工作中,有太多值得我们学习的地方了。本书有 800 多处引用:文章、博客、讲座、文档等,对我来说这些都是宝贵的学习资源。我非常感谢这些材料的作者分享他们的知识。