From 131cee08cb2b2398312005d69d0e75b28f85607d Mon Sep 17 00:00:00 2001 From: Alden Date: Fri, 19 Jun 2026 14:37:38 +0800 Subject: [PATCH 1/2] Normalize zh and tw chapter emphasis --- content/tw/ch10.md | 148 ++++++++++++++++++------------------- content/tw/ch11.md | 74 +++++++++---------- content/tw/ch12.md | 4 +- content/tw/ch14.md | 14 ++-- content/tw/ch2.md | 90 +++++++++++------------ content/tw/ch3.md | 124 +++++++++++++++---------------- content/tw/ch4.md | 126 +++++++++++++++---------------- content/tw/ch5.md | 82 ++++++++++----------- content/tw/ch6.md | 46 ++++++------ content/tw/ch7.md | 72 +++++++++--------- content/tw/ch8.md | 180 ++++++++++++++++++++++----------------------- content/tw/ch9.md | 148 ++++++++++++++++++------------------- content/zh/ch10.md | 148 ++++++++++++++++++------------------- content/zh/ch11.md | 74 +++++++++---------- content/zh/ch12.md | 4 +- content/zh/ch14.md | 14 ++-- content/zh/ch2.md | 90 +++++++++++------------ content/zh/ch3.md | 124 +++++++++++++++---------------- content/zh/ch4.md | 126 +++++++++++++++---------------- content/zh/ch5.md | 82 ++++++++++----------- content/zh/ch6.md | 46 ++++++------ content/zh/ch7.md | 72 +++++++++--------- content/zh/ch8.md | 180 ++++++++++++++++++++++----------------------- content/zh/ch9.md | 148 ++++++++++++++++++------------------- 24 files changed, 1108 insertions(+), 1108 deletions(-) diff --git a/content/tw/ch10.md b/content/tw/ch10.md index cc772d3..b64199e 100644 --- a/content/tw/ch10.md +++ b/content/tw/ch10.md @@ -14,7 +14,7 @@ breadcrumbs: false 正如在 [第九章](/tw/ch9) 中討論的,分散式系統中會出現許多問題。如果我們希望服務在出現這些問題時仍能正確工作,就需要找到容錯的方法。 -我們擁有的最佳容錯工具之一是 *複製*。然而,正如我們在 [第六章](/tw/ch6) 中看到的,在多個副本上擁有多份資料副本會帶來不一致的風險。讀取可能由一個非最新的副本處理,從而產生過時的結果。如果多個副本可以接受寫入,我們必須處理在不同副本上併發寫入的值之間的衝突。從高層次來看,處理這些問題有兩種相互競爭的理念: +我們擁有的最佳容錯工具之一是 **複製**。然而,正如我們在 [第六章](/tw/ch6) 中看到的,在多個副本上擁有多份資料副本會帶來不一致的風險。讀取可能由一個非最新的副本處理,從而產生過時的結果。如果多個副本可以接受寫入,我們必須處理在不同副本上併發寫入的值之間的衝突。從高層次來看,處理這些問題有兩種相互競爭的理念: 最終一致性 : 在這種理念中,系統被複制這一事實對應用程式是可見的,作為應用程式開發者,你需要處理可能出現的不一致和衝突。這種方法通常用於多主複製(見 ["多主複製"](/tw/ch6#sec_replication_multi_leader))和無主複製(見 ["無主複製"](/tw/ch6#sec_replication_leaderless))的系統中。 @@ -26,9 +26,9 @@ breadcrumbs: false 在本章中,我們將深入探討強一致性方法,關注三個領域: -1. 一個挑戰是"強一致性"相當模糊,因此我們將制定一個更精確的定義,明確我們想要實現什麼:*線性一致性*。 +1. 一個挑戰是"強一致性"相當模糊,因此我們將制定一個更精確的定義,明確我們想要實現什麼:**線性一致性**。 2. 我們將研究生成 ID 和時間戳的問題。這可能聽起來與一致性無關,但實際上密切相關。 -3. 我們將探討分散式系統如何在保持容錯的同時實現線性一致性;答案是 *共識* 演算法。 +3. 我們將探討分散式系統如何在保持容錯的同時實現線性一致性;答案是 **共識** 演算法。 在此過程中,我們將看到分散式系統中什麼是可能的,什麼是不可能的,存在一些基本限制。 @@ -42,19 +42,19 @@ breadcrumbs: false 如果你希望複製的資料庫儘可能簡單易用,你應該讓它表現得就像根本沒有複製一樣。然後使用者就不必擔心複製延遲、衝突和其他不一致性。這將給我們帶來容錯的優勢,但不會因為必須考慮多個副本而帶來複雜性。 -這就是 *線性一致性* [^1] 背後的想法(也稱為 *原子一致性* [^2]、*強一致性*、*即時一致性* 或 *外部一致性* [^3])。線性一致性的確切定義相當微妙,我們將在本節的其餘部分探討它。但基本思想是讓系統看起來好像只有一份資料副本,並且對它的所有操作都是原子的。有了這個保證,即使實際上可能有多個副本,應用程式也不需要擔心它們。 +這就是 **線性一致性** [^1] 背後的想法(也稱為 **原子一致性** [^2]、**強一致性**、**即時一致性** 或 **外部一致性** [^3])。線性一致性的確切定義相當微妙,我們將在本節的其餘部分探討它。但基本思想是讓系統看起來好像只有一份資料副本,並且對它的所有操作都是原子的。有了這個保證,即使實際上可能有多個副本,應用程式也不需要擔心它們。 -在線性一致系統中,一旦一個客戶端成功完成寫入,所有從資料庫讀取的客戶端都必須能夠看到剛剛寫入的值。維護單一資料副本的假象,意味著要保證讀取到的是最新值,而不是來自過時的快取或副本。換句話說,線性一致性是一種 *新鮮度保證*。為了闡明這個想法,讓我們看一個非線性一致系統的例子。 +在線性一致系統中,一旦一個客戶端成功完成寫入,所有從資料庫讀取的客戶端都必須能夠看到剛剛寫入的值。維護單一資料副本的假象,意味著要保證讀取到的是最新值,而不是來自過時的快取或副本。換句話說,線性一致性是一種 **新鮮度保證**。為了闡明這個想法,讓我們看一個非線性一致系統的例子。 {{< 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](#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 的一樣新。他的查詢返回過時結果這一事實違反了線性一致性。 +如果 Aaliyah 和 Bryce 同時點選重新整理,他們得到兩個不同的查詢結果就不會那麼令人驚訝了,因為他們不知道他們各自的請求在伺服器上被處理的確切時間。然而,Bryce 知道他是在聽到 Aaliyah 宣佈最終比分 **之後** 點選重新整理按鈕(發起查詢)的,因此他期望他的查詢結果至少與 Aaliyah 的一樣新。他的查詢返回過時結果這一事實違反了線性一致性。 ### 什麼使系統具有線性一致性? {#sec_consistency_lin_definition} -為了更好地理解線性一致性,讓我們看一些更多的例子。[圖 10-2](#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" >}} @@ -63,14 +63,14 @@ breadcrumbs: false 在這個例子中,暫存器有兩種型別的操作: -* *read*(*x*) ⇒ *v* 表示客戶端請求讀取暫存器 *x* 的值,資料庫返回值 *v*。 -* *write*(*x*, *v*) ⇒ *r* 表示客戶端請求將暫存器 *x* 設定為值 *v*,資料庫返回響應 *r*(可能是 *ok* 或 *error*)。 +* **read**(**x**) ⇒ **v** 表示客戶端請求讀取暫存器 **x** 的值,資料庫返回值 **v**。 +* **write**(**x**, **v**) ⇒ **r** 表示客戶端請求將暫存器 **x** 設定為值 **v**,資料庫返回響應 **r**(可能是 **ok** 或 **error**)。 -在 [圖 10-2](#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,因為讀取必須在寫入之後被處理。 -* 與寫入操作在時間上重疊的任何讀取操作可能返回 0 或 1,因為我們不知道在讀取操作被處理時寫入是否已經生效。這些操作與寫入是 *併發* 的。 +* 與寫入操作在時間上重疊的任何讀取操作可能返回 0 或 1,因為我們不知道在讀取操作被處理時寫入是否已經生效。這些操作與寫入是 **併發** 的。 然而,這還不足以完全描述線性一致性:如果與寫入併發的讀取可以返回舊值或新值,那麼讀者可能會在寫入進行時多次看到值在舊值和新值之間來回翻轉。這不是我們對模擬"單一資料副本"的系統所期望的。 @@ -79,13 +79,13 @@ breadcrumbs: false {{< 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,所有後續讀取也必須返回新值,即使寫入操作尚未完成。 +在線性一致系統中,我們想象必須有某個時間點(在寫入操作的開始和結束之間),**x** 的值從 0 原子地翻轉到 1。因此,如果一個客戶端的讀取返回新值 1,所有後續讀取也必須返回新值,即使寫入操作尚未完成。 這種時序依賴關係在 [圖 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](#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*)。 +* **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](#fig_consistency_linearizability_3) 中的每個操作都用一條垂直線(在每個操作的條形內)標記,表示我們認為操作執行的時間。這些標記按順序連線起來,結果必須是暫存器的有效讀寫序列(每次讀取必須返回最近寫入設定的值)。 @@ -96,14 +96,14 @@ breadcrumbs: false [圖 10-4](#fig_consistency_linearizability_3) 中有一些有趣的細節需要指出: -* 首先客戶端 B 傳送了讀取 *x* 的請求,然後客戶端 D 傳送了將 *x* 設定為 0 的請求,然後客戶端 A 傳送了將 *x* 設定為 1 的請求。然而,返回給 B 的讀取值是 1(A 寫入的值)。這是可以的:這意味著資料庫首先處理了 D 的寫入,然後是 A 的寫入,最後是 B 的讀取。雖然這不是傳送請求的順序,但這是一個可接受的順序,因為這三個請求是併發的。也許 B 的讀取請求在網路中稍有延遲,因此它在兩次寫入之後才到達資料庫。 -* 客戶端 B 的讀取在客戶端 A 收到資料庫的響應之前返回了 1,表示值 1 的寫入成功。這也是可以的:這只是意味著從資料庫到客戶端 A 的 *ok* 響應在網路中稍有延遲。 -* 這個模型不假設任何事務隔離:另一個客戶端可以隨時更改值。例如,C 首先讀取 1,然後讀取 2,因為該值在兩次讀取之間被 B 更改了。原子比較並設定(*cas*)操作可用於檢查值是否未被另一個客戶端併發更改:B 和 C 的 *cas* 請求成功,但 D 的 *cas* 請求失敗(到資料庫處理它時,*x* 的值不再是 0)。 -* 客戶端 B 的最後一次讀取(在陰影條中)不是線性一致的。該操作與 C 的 *cas* 寫入併發,後者將 *x* 從 2 更新到 4。在沒有其他請求的情況下,B 的讀取返回 2 是可以的。然而,客戶端 A 在 B 的讀取開始之前已經讀取了新值 4,因此 B 不允許讀取比 A 更舊的值。同樣,這與 [圖 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同。 +* 首先客戶端 B 傳送了讀取 **x** 的請求,然後客戶端 D 傳送了將 **x** 設定為 0 的請求,然後客戶端 A 傳送了將 **x** 設定為 1 的請求。然而,返回給 B 的讀取值是 1(A 寫入的值)。這是可以的:這意味著資料庫首先處理了 D 的寫入,然後是 A 的寫入,最後是 B 的讀取。雖然這不是傳送請求的順序,但這是一個可接受的順序,因為這三個請求是併發的。也許 B 的讀取請求在網路中稍有延遲,因此它在兩次寫入之後才到達資料庫。 +* 客戶端 B 的讀取在客戶端 A 收到資料庫的響應之前返回了 1,表示值 1 的寫入成功。這也是可以的:這只是意味著從資料庫到客戶端 A 的 **ok** 響應在網路中稍有延遲。 +* 這個模型不假設任何事務隔離:另一個客戶端可以隨時更改值。例如,C 首先讀取 1,然後讀取 2,因為該值在兩次讀取之間被 B 更改了。原子比較並設定(**cas**)操作可用於檢查值是否未被另一個客戶端併發更改:B 和 C 的 **cas** 請求成功,但 D 的 **cas** 請求失敗(到資料庫處理它時,**x** 的值不再是 0)。 +* 客戶端 B 的最後一次讀取(在陰影條中)不是線性一致的。該操作與 C 的 **cas** 寫入併發,後者將 **x** 從 2 更新到 4。在沒有其他請求的情況下,B 的讀取返回 2 是可以的。然而,客戶端 A 在 B 的讀取開始之前已經讀取了新值 4,因此 B 不允許讀取比 A 更舊的值。同樣,這與 [圖 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情況相同。 這就是線性一致性背後的直覺;形式化定義 [^1] 更精確地描述了它。可以(儘管計算成本高昂)透過記錄所有請求和響應的時序,並檢查它們是否可以排列成有效的順序序列來測試系統的行為是否線性一致 [^6] [^7]。 -就像除了可序列化之外還有各種弱隔離級別用於事務(見 ["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)),除了線性一致性之外,複製系統也有各種較弱的一致性模型 [^8]。實際上,我們在 ["複製延遲問題"](/tw/ch6#sec_replication_lag) 中看到的 *寫後讀*、*單調讀* 和 *一致性字首讀* 屬性就是這種較弱一致性模型的例子。線性一致性保證所有這些較弱的屬性,以及更多。在本章中,我們將重點關注線性一致性,它是最常用的最強一致性模型。 +就像除了可序列化之外還有各種弱隔離級別用於事務(見 ["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)),除了線性一致性之外,複製系統也有各種較弱的一致性模型 [^8]。實際上,我們在 ["複製延遲問題"](/tw/ch6#sec_replication_lag) 中看到的 **寫後讀**、**單調讀** 和 **一致性字首讀** 屬性就是這種較弱一致性模型的例子。線性一致性保證所有這些較弱的屬性,以及更多。在本章中,我們將重點關注線性一致性,它是最常用的最強一致性模型。 -------- @@ -113,14 +113,14 @@ breadcrumbs: false 線性一致性很容易與可序列化混淆(見 ["可序列化"](/tw/ch8#sec_transactions_serializability)),因為這兩個詞似乎都意味著類似"可以按順序排列"的東西。然而,它們是完全不同的保證,區分它們很重要: 可序列化 -: 可序列化是事務的隔離屬性,其中每個事務可能讀取和寫入 *多個物件*(行、文件、記錄)。它保證事務的行為與它們按 *某種* 序列順序執行時相同:也就是說,就好像你首先執行一個事務的所有操作,然後執行另一個事務的所有操作,依此類推,而不交錯它們。該序列順序可以與事務實際執行的順序不同 [^9]。 +: 可序列化是事務的隔離屬性,其中每個事務可能讀取和寫入 **多個物件**(行、文件、記錄)。它保證事務的行為與它們按 **某種** 序列順序執行時相同:也就是說,就好像你首先執行一個事務的所有操作,然後執行另一個事務的所有操作,依此類推,而不交錯它們。該序列順序可以與事務實際執行的順序不同 [^9]。 線性一致性 -: 線性一致性是對暫存器(*單個物件*)的讀寫保證。它不將操作分組到事務中,因此它不能防止涉及多個物件的問題,如寫偏差(見 ["寫偏差和幻讀"](/tw/ch8#sec_transactions_write_skew))。然而,線性一致性是一個 *新鮮度* 保證:它要求如果一個操作在另一個操作開始之前完成,那麼後一個操作必須觀察到至少與前一個操作一樣新的狀態。可序列化沒有這個要求:例如,可序列化允許過時讀取 [^10]。 +: 線性一致性是對暫存器(**單個物件**)的讀寫保證。它不將操作分組到事務中,因此它不能防止涉及多個物件的問題,如寫偏差(見 ["寫偏差和幻讀"](/tw/ch8#sec_transactions_write_skew))。然而,線性一致性是一個 **新鮮度** 保證:它要求如果一個操作在另一個操作開始之前完成,那麼後一個操作必須觀察到至少與前一個操作一樣新的狀態。可序列化沒有這個要求:例如,可序列化允許過時讀取 [^10]。 -(*順序一致性* 又是另外一回事 [^8],但我們不會在這裡討論它。) +(**順序一致性** 又是另外一回事 [^8],但我們不會在這裡討論它。) -資料庫可能同時提供可序列化和線性一致性,這種組合稱為 *嚴格可序列化* 或 *強單副本可序列化*(*strong-1SR*)[^11] [^12]。單節點資料庫通常是線性一致的。對於使用樂觀方法(如可序列化快照隔離)的分散式資料庫(見 ["可序列化快照隔離(SSI)"](/tw/ch8#sec_transactions_ssi)),情況更加複雜:例如,CockroachDB 提供可序列化和對讀取的一些新鮮度保證,但不是嚴格可序列化 [^13],因為這需要事務之間進行昂貴的協調 [^14]。 +資料庫可能同時提供可序列化和線性一致性,這種組合稱為 **嚴格可序列化** 或 **強單副本可序列化**(**strong-1SR**)[^11] [^12]。單節點資料庫通常是線性一致的。對於使用樂觀方法(如可序列化快照隔離)的分散式資料庫(見 ["可序列化快照隔離(SSI)"](/tw/ch8#sec_transactions_ssi)),情況更加複雜:例如,CockroachDB 提供可序列化和對讀取的一些新鮮度保證,但不是嚴格可序列化 [^13],因為這需要事務之間進行昂貴的協調 [^14]。 也可以將較弱的隔離級別與線性一致性結合,或將較弱的一致性模型與可序列化結合;實際上,一致性模型和隔離級別可以在很大程度上相互獨立地選擇 [^15] [^16]。 @@ -198,7 +198,7 @@ breadcrumbs: false : 具有多主複製的系統通常不是線性一致的,因為它們在多個節點上併發處理寫入,並將它們非同步複製到其他節點。因此,它們可能產生需要解決的衝突寫入(見 ["處理衝突寫入"](/tw/ch6#sec_replication_write_conflicts))。 無主複製(可能非線性一致) -: 對於具有無主複製的系統(Dynamo 風格;見 ["無主複製"](/tw/ch6#sec_replication_leaderless)),人們有時聲稱可以透過要求仲裁讀寫(*w* + *r* > *n*)來獲得"強一致性"。根據確切的演算法,以及你如何定義強一致性,這並不完全正確。 +: 對於具有無主複製的系統(Dynamo 風格;見 ["無主複製"](/tw/ch6#sec_replication_leaderless)),人們有時聲稱可以透過要求仲裁讀寫(**w** + **r** > **n**)來獲得"強一致性"。根據確切的演算法,以及你如何定義強一致性,這並不完全正確。 基於日曆時鐘的"最後寫入獲勝"衝突解決方法(例如,在 Cassandra 和 ScyllaDB 中)幾乎肯定是非線性一致的,因為時鐘時間戳由於時鐘偏差而無法保證與實際事件順序一致(見 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying))。即使使用仲裁,也可能出現非線性一致的行為,如下一節所示。 @@ -209,9 +209,9 @@ breadcrumbs: false {{< figure src="/fig/ddia_1006.png" id="fig_consistency_leaderless" caption="圖 10-6. 如果網路延遲是可變的,仲裁不足以確保線性一致性。" class="w-full my-4" >}} -在 [圖 10-6](#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](#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],但由於它使用日曆時鐘作為時間戳而失去了線性一致性。 @@ -228,7 +228,7 @@ breadcrumbs: false {{< figure src="/fig/ddia_1007.png" id="fig_consistency_cap_availability" caption="圖 10-7. 如果客戶端由於網路分割槽而無法聯絡足夠的副本,它們就無法處理寫入。" class="w-full my-4" >}} -考慮如果兩個區域之間出現網路中斷會發生什麼。讓我們假設每個區域內的網路正常工作,客戶端可以到達其本地區域,但這些區域之間無法相互連線。這被稱為 *網路分割槽*。 +考慮如果兩個區域之間出現網路中斷會發生什麼。讓我們假設每個區域內的網路正常工作,客戶端可以到達其本地區域,但這些區域之間無法相互連線。這被稱為 **網路分割槽**。 使用多主資料庫,每個區域可以繼續正常執行:由於來自一個區域的寫入被非同步複製到另一個區域,寫入只是排隊並在網路連線恢復時交換。 @@ -242,36 +242,36 @@ breadcrumbs: false 這個問題不僅僅是單主和多主複製的結果:任何線性一致的資料庫都有這個問題,無論它如何實現。這個問題也不特定於多區域部署,而是可以發生在任何不可靠的網路上,即使在一個區域內。權衡如下: -* 如果你的應用程式 *需要* 線性一致性,並且某些副本由於網路問題與其他副本斷開連線,那麼某些副本在斷開連線時無法處理請求:它們必須等待網路問題修復,或者返回錯誤(無論哪種方式,它們都變得 *不可用*)。這種選擇有時被稱為 *CP*(在網路分割槽下一致)。 -* 如果你的應用程式 *不需要* 線性一致性,那麼它可以以一種方式編寫,使每個副本可以獨立處理請求,即使它與其他副本斷開連線(例如,多主)。在這種情況下,應用程式可以在面對網路問題時保持 *可用*,但其行為不是線性一致的。這種選擇被稱為 *AP*(在網路分割槽下可用)。 +* 如果你的應用程式 **需要** 線性一致性,並且某些副本由於網路問題與其他副本斷開連線,那麼某些副本在斷開連線時無法處理請求:它們必須等待網路問題修復,或者返回錯誤(無論哪種方式,它們都變得 **不可用**)。這種選擇有時被稱為 **CP**(在網路分割槽下一致)。 +* 如果你的應用程式 **不需要** 線性一致性,那麼它可以以一種方式編寫,使每個副本可以獨立處理請求,即使它與其他副本斷開連線(例如,多主)。在這種情況下,應用程式可以在面對網路問題時保持 **可用**,但其行為不是線性一致的。這種選擇被稱為 **AP**(在網路分割槽下可用)。 -因此,不需要線性一致性的應用程式可以更好地容忍網路問題。這種見解通常被稱為 *CAP 定理* [^29] [^30] [^31] [^32],由 Eric Brewer 在 2000 年命名,儘管這種權衡自 1970 年代以來就為分散式資料庫設計者所知 [^33] [^34] [^35]。 +因此,不需要線性一致性的應用程式可以更好地容忍網路問題。這種見解通常被稱為 **CAP 定理** [^29] [^30] [^31] [^32],由 Eric Brewer 在 2000 年命名,儘管這種權衡自 1970 年代以來就為分散式資料庫設計者所知 [^33] [^34] [^35]。 CAP 最初是作為經驗法則提出的,沒有精確的定義,目的是開始關於資料庫中權衡的討論。當時,許多分散式資料庫專注於在具有共享儲存的機器叢集上提供線性一致語義 [^19],CAP 鼓勵資料庫工程師探索更廣泛的分散式無共享系統設計空間,這些系統更適合實現大規模 Web 服務 [^36]。CAP 在這種文化轉變方面值得稱讚——它幫助觸發了 NoSQL 運動,這是 2000 年代中期左右的一系列新資料庫技術。 > [!TIP] 無用的 CAP 定理 -CAP 有時被表述為 *一致性、可用性、分割槽容錯性:從 3 箇中選擇 2 個*。不幸的是,這樣表述是誤導性的 [^32],因為網路分割槽是一種故障,所以它們不是你可以選擇的:無論你喜歡與否,它們都會發生。 +CAP 有時被表述為 **一致性、可用性、分割槽容錯性:從 3 箇中選擇 2 個**。不幸的是,這樣表述是誤導性的 [^32],因為網路分割槽是一種故障,所以它們不是你可以選擇的:無論你喜歡與否,它們都會發生。 -當網路正常工作時,系統可以同時提供一致性(線性一致性)和完全可用性。當發生網路故障時,你必須在線性一致性或完全可用性之間進行選擇。因此,CAP 的更好表述方式是 *分割槽時要麼一致要麼可用* [^37]。更可靠的網路需要更少地做出這種選擇,但在某個時候這種選擇是不可避免的。 +當網路正常工作時,系統可以同時提供一致性(線性一致性)和完全可用性。當發生網路故障時,你必須在線性一致性或完全可用性之間進行選擇。因此,CAP 的更好表述方式是 **分割槽時要麼一致要麼可用** [^37]。更可靠的網路需要更少地做出這種選擇,但在某個時候這種選擇是不可避免的。 -CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化為線性一致性(定理沒有說任何關於較弱一致性模型的內容),*可用性* 的形式化 [^30] 與該術語的通常含義不匹配 [^38]。許多高可用(容錯)系統實際上不符合 CAP 對可用性的特殊定義。此外,一些系統設計者選擇(有充分理由)既不提供線性一致性也不提供 CAP 定理假設的可用性形式,因此這些系統既不是 CP 也不是 AP [^39] [^40]。 +CP/AP 分類方案還有幾個進一步的缺陷 [^4]。**一致性** 被形式化為線性一致性(定理沒有說任何關於較弱一致性模型的內容),**可用性** 的形式化 [^30] 與該術語的通常含義不匹配 [^38]。許多高可用(容錯)系統實際上不符合 CAP 對可用性的特殊定義。此外,一些系統設計者選擇(有充分理由)既不提供線性一致性也不提供 CAP 定理假設的可用性形式,因此這些系統既不是 CP 也不是 AP [^39] [^40]。 總的來說,關於 CAP 有很多誤解和混淆,它並不能幫助我們更好地理解系統,因此最好避免使用 CAP。 正式定義的 CAP 定理 [^30] 範圍非常狹窄:它只考慮一種一致性模型(即線性一致性)和一種故障(網路分割槽,根據 Google 的資料,這是不到 8% 事件的原因 [^41])。它沒有說任何關於網路延遲、死節點或其他權衡的內容。因此,儘管 CAP 在歷史上具有影響力,但對於設計系統幾乎沒有實際價值 [^4] [^38]。 -已經有努力推廣 CAP。例如,*PACELC 原則* 觀察到系統設計者也可能選擇在網路正常工作時削弱一致性以減少延遲 [^39] [^40] [^42]。因此,在網路分割槽(P)期間,我們需要在可用性(A)和一致性(C)之間進行選擇;否則(E),當沒有分割槽時,我們可能在低延遲(L)和一致性(C)之間進行選擇。然而,這個定義繼承了 CAP 的幾個問題,例如一致性和可用性的反直覺定義。 +已經有努力推廣 CAP。例如,**PACELC 原則** 觀察到系統設計者也可能選擇在網路正常工作時削弱一致性以減少延遲 [^39] [^40] [^42]。因此,在網路分割槽(P)期間,我們需要在可用性(A)和一致性(C)之間進行選擇;否則(E),當沒有分割槽時,我們可能在低延遲(L)和一致性(C)之間進行選擇。然而,這個定義繼承了 CAP 的幾個問題,例如一致性和可用性的反直覺定義。 分散式系統中有許多更有趣的不可能性結果 [^43],CAP 現在已被更精確的結果所取代 [^44] [^45],因此它今天主要具有歷史意義。 #### 線性一致性與網路延遲 {#linearizability-and-network-delays} -儘管線性一致性是一個有用的保證,但令人驚訝的是,實際上很少有系統是線性一致的。例如,即使現代多核 CPU 上的 RAM 也不是線性一致的 [^46]:如果在一個 CPU 核心上執行的執行緒寫入記憶體地址,而另一個 CPU 核心上的執行緒隨後讀取相同的地址,不能保證讀取第一個執行緒寫入的值(除非使用 *記憶體屏障* 或 *柵欄* [^47])。 +儘管線性一致性是一個有用的保證,但令人驚訝的是,實際上很少有系統是線性一致的。例如,即使現代多核 CPU 上的 RAM 也不是線性一致的 [^46]:如果在一個 CPU 核心上執行的執行緒寫入記憶體地址,而另一個 CPU 核心上的執行緒隨後讀取相同的地址,不能保證讀取第一個執行緒寫入的值(除非使用 **記憶體屏障** 或 **柵欄** [^47])。 這種行為的原因是每個 CPU 核心都有自己的記憶體快取和儲存緩衝區。預設情況下,記憶體訪問首先進入快取,任何更改都非同步寫出到主記憶體。由於訪問快取中的資料比訪問主記憶體快得多 [^48],這個特性對於現代 CPU 的良好效能至關重要。然而,現在有多份資料副本(一份在主記憶體中,可能還有幾份在各種快取中),這些副本是非同步更新的,因此線性一致性丟失了。 -為什麼要做出這種權衡?使用 CAP 定理來證明多核記憶體一致性模型是沒有意義的:在一臺計算機內,我們通常假設可靠的通訊,我們不期望一個 CPU 核心在與計算機其餘部分斷開連線的情況下能夠繼續正常執行。放棄線性一致性的原因是 *效能*,而不是容錯 [^39]。 +為什麼要做出這種權衡?使用 CAP 定理來證明多核記憶體一致性模型是沒有意義的:在一臺計算機內,我們通常假設可靠的通訊,我們不期望一個 CPU 核心在與計算機其餘部分斷開連線的情況下能夠繼續正常執行。放棄線性一致性的原因是 **效能**,而不是容錯 [^39]。 許多選擇不提供線性一致保證的分散式資料庫也是如此:它們這樣做主要是為了提高效能,而不是為了容錯 [^42]。線性一致性很慢——這在任何時候都是真的,不僅在網路故障期間。 @@ -287,7 +287,7 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化 {{< 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](#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。但真正的問題是: @@ -304,7 +304,7 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化 : 不是從單節點 ID 生成器請求單個 ID,它可以分發 ID 塊。例如,節點 A 可能宣告從 1 到 1,000 的 ID 塊,節點 B 可能宣告從 1,001 到 2,000 的塊。然後每個節點可以獨立地從其塊中分發 ID,並在其序列號供應開始不足時從單節點 ID 生成器請求新塊。但是,這種方案也不能確保正確的排序:可能會發生這樣的情況,一條訊息被分配了 1,001 到 2,000 範圍內的 ID,而後來的訊息被分配了 1 到 1,000 範圍內的 ID,如果 ID 是由不同的節點分配的。 隨機 UUID -: 你可以使用 *通用唯一識別符號*(UUID),也稱為 *全域性唯一識別符號*(GUID)。它們的一大優點是可以在任何節點上本地生成,無需通訊,但它們需要更多空間(128 位)。有幾種不同版本的 UUID;最簡單的是版本 4,它本質上是一個如此長的隨機數,以至於兩個節點選擇相同的可能性非常小。不幸的是,這些 ID 的順序也是隨機的,因此比較兩個 ID 不會告訴你哪個更新。 +: 你可以使用 **通用唯一識別符號**(UUID),也稱為 **全域性唯一識別符號**(GUID)。它們的一大優點是可以在任何節點上本地生成,無需通訊,但它們需要更多空間(128 位)。有幾種不同版本的 UUID;最簡單的是版本 4,它本質上是一個如此長的隨機數,以至於兩個節點選擇相同的可能性非常小。不幸的是,這些 ID 的順序也是隨機的,因此比較兩個 ID 不會告訴你哪個更新。 時鐘時間戳使其唯一 : 如果你的節點的日曆時鐘使用 NTP 保持大致正確,你可以透過將該時鐘的時間戳放在最高有效位中,並用確保 ID 唯一的額外資訊填充剩餘位來生成 ID,即使時間戳不是——例如,分片編號和每分片遞增序列號,或長隨機值。這種方法用於版本 7 UUID [^50]、Twitter 的 Snowflake [^51]、ULID [^52]、Hazelcast 的 Flake ID 生成器、MongoDB ObjectID 和許多類似方案 [^50]。你可以在應用程式程式碼或資料庫中實現這些 ID 生成器 [^53]。 @@ -313,27 +313,27 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化 如 ["為事件排序的時間戳"](/tw/ch9#sec_distributed_lww) 中所討論的,時鐘時間戳最多隻能提供近似排序:如果較早的寫入從稍快的時鐘獲得時間戳,而較晚寫入的時間戳來自稍慢的時鐘,則時間戳順序可能與事件實際發生的順序不一致。由於使用非單調時鐘而導致的時鐘跳躍,即使單個節點生成的時間戳也可能排序錯誤。因此,基於時鐘時間的 ID 生成器不太可能是線性一致的。 -你可以透過依賴高精度時鐘同步,使用原子鐘或 GPS 接收器來減少這種排序不一致。但如果能夠在不依賴特殊硬體的情況下生成唯一且正確排序的 ID 也會很好。這就是 *邏輯時鐘* 的用途。 +你可以透過依賴高精度時鐘同步,使用原子鐘或 GPS 接收器來減少這種排序不一致。但如果能夠在不依賴特殊硬體的情況下生成唯一且正確排序的 ID 也會很好。這就是 **邏輯時鐘** 的用途。 ### 邏輯時鐘 {#sec_consistency_timestamps} -在 ["不可靠的時鐘"](/tw/ch9#sec_distributed_clocks) 中,我們討論了日曆時鐘和單調時鐘。這兩種都是 *物理時鐘*:它們測量經過的秒數(或毫秒、微秒等)。 +在 ["不可靠的時鐘"](/tw/ch9#sec_distributed_clocks) 中,我們討論了日曆時鐘和單調時鐘。這兩種都是 **物理時鐘**:它們測量經過的秒數(或毫秒、微秒等)。 -在分散式系統中,通常還使用另一種時鐘,稱為 *邏輯時鐘*。物理時鐘是計算已經過的秒數的硬體裝置,而邏輯時鐘是計算已發生事件的演算法。來自邏輯時鐘的時間戳因此不會告訴你現在幾點,但你 *可以* 比較來自邏輯時鐘的兩個時間戳,以判斷哪個更早,哪個更晚。 +在分散式系統中,通常還使用另一種時鐘,稱為 **邏輯時鐘**。物理時鐘是計算已經過的秒數的硬體裝置,而邏輯時鐘是計算已發生事件的演算法。來自邏輯時鐘的時間戳因此不會告訴你現在幾點,但你 **可以** 比較來自邏輯時鐘的兩個時間戳,以判斷哪個更早,哪個更晚。 邏輯時鐘的要求通常是: * 其時間戳緊湊(大小為幾個位元組)且唯一; -* 你可以比較任意兩個時間戳(即它們是 *全序* 的);並且 -* 時間戳的順序與因果關係 *一致*:如果操作 A 發生在 B 之前,那麼 A 的時間戳小於 B 的時間戳。(我們之前在 ["“先發生”關係與併發"](/tw/ch6#sec_replication_happens_before) 中討論了因果關係。) +* 你可以比較任意兩個時間戳(即它們是 **全序** 的);並且 +* 時間戳的順序與因果關係 **一致**:如果操作 A 發生在 B 之前,那麼 A 的時間戳小於 B 的時間戳。(我們之前在 ["“先發生”關係與併發"](/tw/ch6#sec_replication_happens_before) 中討論了因果關係。) 單節點 ID 生成器滿足這些要求,但我們剛剛討論的分散式 ID 生成器不滿足因果排序要求。 #### Lamport 時間戳 {#lamport-timestamps} -幸運的是,有一種生成邏輯時間戳的簡單方法,它與因果關係 *一致*,你可以將其用作分散式 ID 生成器。它被稱為 *Lamport 時鐘*,由 Leslie Lamport 在 1978 年提出 [^54],現在是分散式系統領域被引用最多的論文之一。 +幸運的是,有一種生成邏輯時間戳的簡單方法,它與因果關係 **一致**,你可以將其用作分散式 ID 生成器。它被稱為 **Lamport 時鐘**,由 Leslie Lamport 在 1978 年提出 [^54],現在是分散式系統領域被引用最多的論文之一。 -[圖 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,每個時間戳都是唯一的。 +[圖 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" >}} @@ -351,7 +351,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: * 由於它們與物理時間沒有直接關係,你不能使用它們來查詢,比如說,在特定日期釋出的所有訊息——你需要單獨儲存物理時間。 * 如果兩個節點從不通訊,一個節點的計數器遞增將永遠不會反映在另一個節點的計數器中。因此,可能會發生這樣的情況,即在不同節點上大約同一時間生成的事件具有極不相同的計數器值。 -*混合邏輯時鐘* 結合了物理日曆時鐘的優勢和 Lamport 時鐘的排序保證 [^55]。像物理時鐘一樣,它計算秒或微秒。像 Lamport 時鐘一樣,當一個節點看到來自另一個節點的時間戳大於其本地時鐘值時,它將自己的本地值向前移動以匹配另一個節點的時間戳。因此,如果一個節點的時鐘執行得很快,其他節點在通訊時也會類似地向前移動它們的時鐘。 +**混合邏輯時鐘** 結合了物理日曆時鐘的優勢和 Lamport 時鐘的排序保證 [^55]。像物理時鐘一樣,它計算秒或微秒。像 Lamport 時鐘一樣,當一個節點看到來自另一個節點的時間戳大於其本地時鐘值時,它將自己的本地值向前移動以匹配另一個節點的時間戳。因此,如果一個節點的時鐘執行得很快,其他節點在通訊時也會類似地向前移動它們的時鐘。 每次生成混合邏輯時鐘的時間戳時,它也會遞增,這確保時鐘單調向前移動,即使底層物理時鐘由於 NTP 調整而向後跳躍。因此,混合邏輯時鐘可能略微領先於底層物理時鐘。演算法的細節確保這種差異儘可能小。 @@ -363,7 +363,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 當併發生成多個時間戳時,這些演算法會任意排序它們。這意味著當你檢視兩個時間戳時,你通常無法判斷它們是併發生成的還是一個發生在另一個之前。(在 [圖 10-9](#fig_consistency_lamport_ts) 的示例中,你實際上可以判斷 Aaliyah 和 Caleb 的訊息必須是併發的,因為它們具有相同的計數器值,但當計數器值不同時,你無法判斷它們是否併發。) -如果你想能夠確定記錄何時併發建立,你需要不同的演算法,例如 *向量時鐘*。缺點是向量時鐘的時間戳要大得多——可能是系統中每個節點一個整數。有關檢測併發的更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。 +如果你想能夠確定記錄何時併發建立,你需要不同的演算法,例如 **向量時鐘**。缺點是向量時鐘的時間戳要大得多——可能是系統中每個節點一個整數。有關檢測併發的更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。 ### 線性一致的 ID 生成器 {#sec_consistency_linearizable_id} @@ -384,7 +384,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: #### 實現線性一致的 ID 生成器 {#implementing-a-linearizable-id-generator} -確保 ID 分配線性一致的最簡單方法實際上是為此目的使用單個節點。該節點只需要原子地遞增計數器並在請求時返回其值,持久化計數器值(以便在節點崩潰並重新啟動時不會生成重複的 ID),並使用單主複製進行容錯複製。這種方法在實踐中使用:例如,TiDB/TiKV 稱之為 *時間戳預言機*,受 Google 的 Percolator [^57] 啟發。 +確保 ID 分配線性一致的最簡單方法實際上是為此目的使用單個節點。該節點只需要原子地遞增計數器並在請求時返回其值,持久化計數器值(以便在節點崩潰並重新啟動時不會生成重複的 ID),並使用單主複製進行容錯複製。這種方法在實踐中使用:例如,TiDB/TiKV 稱之為 **時間戳預言機**,受 Google 的 Percolator [^57] 啟發。 作為最佳化,你可以避免在每個請求上執行磁碟寫入和複製。相反,ID 生成器可以寫入描述一批 ID 的記錄;一旦該記錄被持久化並完成複製,節點就可以開始按順序向客戶端分發這些 ID。在它用完該批次中的 ID 之前,它可以為下一批持久化並複製記錄。這樣,如果節點崩潰並重啟,或故障切換到備庫,某些 ID 會被跳過,但不會發出任何重複或亂序的 ID。 @@ -400,7 +400,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 答案是:不完全。當你有幾個節點都試圖獲取同一個鎖或註冊同一個使用者名稱時,你可以使用邏輯時鐘為這些請求分配時間戳,並選擇具有最低時間戳的請求作為獲勝者。如果時鐘是線性一致的,你知道任何未來的請求都將始終生成更大的時間戳,因此你可以確定沒有未來的請求會收到比獲勝者更低的時間戳。 -不幸的是,問題的一部分仍未解決:節點如何知道自己的時間戳是否最低?要確定,它需要聽到可能生成時間戳的 *每個* 其他節點 [^54]。如果其他節點之一在此期間失敗,或者由於網路問題無法訪問,該系統將停止執行,因為我們無法確定該節點是否可能具有最低的時間戳。這不是我們需要的那種容錯系統。 +不幸的是,問題的一部分仍未解決:節點如何知道自己的時間戳是否最低?要確定,它需要聽到可能生成時間戳的 **每個** 其他節點 [^54]。如果其他節點之一在此期間失敗,或者由於網路問題無法訪問,該系統將停止執行,因為我們無法確定該節點是否可能具有最低的時間戳。這不是我們需要的那種容錯系統。 要以容錯方式實現鎖、租約和類似構造,我們需要比邏輯時鐘或 ID 生成器更強大的東西:我們需要共識。 @@ -414,11 +414,11 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: * 單節點上的線性一致 ID 生成器只是一個帶有原子獲取並增加指令的計數器,但如果它崩潰了怎麼辦? * 原子比較並設定(CAS)操作對許多事情都很有用,例如當多個程序競相獲取它時決定誰獲得鎖或租約,或確保具有給定名稱的檔案或使用者的唯一性。在單個節點上,CAS 可能就像一條 CPU 指令一樣簡單,但如何使其容錯? -事實證明,所有這些都是同一個基本分散式系統問題的例項:*共識*。共識是分散式計算中最重要和最基本的問題之一;它也是出了名的難以正確實現 [^58] [^59],許多系統在過去都出錯了。現在我們已經討論了複製([第六章](/tw/ch6))、事務([第八章](/tw/ch8))、系統模型([第九章](/tw/ch9))和線性一致性(本章),我們終於準備好解決共識問題了。 +事實證明,所有這些都是同一個基本分散式系統問題的例項:**共識**。共識是分散式計算中最重要和最基本的問題之一;它也是出了名的難以正確實現 [^58] [^59],許多系統在過去都出錯了。現在我們已經討論了複製([第六章](/tw/ch6))、事務([第八章](/tw/ch8))、系統模型([第九章](/tw/ch9))和線性一致性(本章),我們終於準備好解決共識問題了。 最著名的共識演算法是 Viewstamped Replication [^60] [^61]、Paxos [^58] [^62] [^63] [^64]、Raft [^23] [^65] [^66] 和 Zab [^18] [^22] [^67]。這些演算法之間有相當多的相似之處,但它們並不相同 [^68] [^69]。這些演算法在非拜占庭系統模型中工作:也就是說,網路通訊可能會被任意延遲或丟棄,節點可能會崩潰、重啟和斷開連線,但演算法假設節點在其他方面正確遵循協議,不會惡意行為。 -也有可以容忍某些拜占庭節點的共識演算法,即不正確遵循協議的節點(例如,向其他節點發送矛盾訊息)。一個常見的假設是少於三分之一的節點是拜占庭故障的 [^26] [^70]。這種 *拜占庭容錯*(BFT)共識演算法用於區塊鏈 [^71]。然而,如 ["拜占庭故障"](/tw/ch9#sec_distributed_byzantine) 中所解釋的,BFT 演算法超出了本書的範圍。 +也有可以容忍某些拜占庭節點的共識演算法,即不正確遵循協議的節點(例如,向其他節點發送矛盾訊息)。一個常見的假設是少於三分之一的節點是拜占庭故障的 [^26] [^70]。這種 **拜占庭容錯**(BFT)共識演算法用於區塊鏈 [^71]。然而,如 ["拜占庭故障"](/tw/ch9#sec_distributed_byzantine) 中所解釋的,BFT 演算法超出了本書的範圍。 -------- @@ -426,7 +426,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 你可能聽說過 FLP 結果 [^72]——以作者 Fischer、Lynch 和 Paterson 的名字命名——它證明如果存在節點可能崩潰的風險,就沒有演算法總是能夠達成共識。在分散式系統中,我們必須假設節點可能會崩潰,因此可靠的共識是不可能的。然而,在這裡我們正在討論實現共識的演算法。這是怎麼回事? -首先,FLP 並不是說我們永遠無法達成共識——它只是說我們不能保證共識演算法 *總是* 終止。此外,FLP 結果是在非同步系統模型中假設確定性演算法的情況下證明的(見 ["系統模型與現實"](/tw/ch9#sec_distributed_system_model)),這意味著演算法不能使用任何時鐘或超時。如果它可以使用超時來懷疑另一個節點可能已經崩潰(即使懷疑有時是錯誤的),那麼共識就變得可解 [^73]。即使只是允許演算法使用隨機數也足以繞過不可能性結果 [^74]。 +首先,FLP 並不是說我們永遠無法達成共識——它只是說我們不能保證共識演算法 **總是** 終止。此外,FLP 結果是在非同步系統模型中假設確定性演算法的情況下證明的(見 ["系統模型與現實"](/tw/ch9#sec_distributed_system_model)),這意味著演算法不能使用任何時鐘或超時。如果它可以使用超時來懷疑另一個節點可能已經崩潰(即使懷疑有時是錯誤的),那麼共識就變得可解 [^73]。即使只是允許演算法使用隨機數也足以繞過不可能性結果 [^74]。 因此,儘管 FLP 關於共識不可能性的結果具有重要的理論意義,但分散式系統通常可以在實踐中實現共識。 @@ -436,9 +436,9 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 共識可以用幾種不同的方式表達: -* *單值共識* 非常類似於原子 *比較並設定* 操作,它可用於實現鎖、租約和唯一性約束。 -* 構建 *僅追加日誌* 也需要共識;它通常形式化為 *全序廣播*。有了日誌,你可以構建 *狀態機複製*、基於主節點的複製、事件溯源和其他有用的東西。 -* 多資料庫或多分片事務的 *原子提交* 要求所有參與者就是否提交或中止事務達成一致。 +* **單值共識** 非常類似於原子 **比較並設定** 操作,它可用於實現鎖、租約和唯一性約束。 +* 構建 **僅追加日誌** 也需要共識;它通常形式化為 **全序廣播**。有了日誌,你可以構建 **狀態機複製**、基於主節點的複製、事件溯源和其他有用的東西。 +* 多資料庫或多分片事務的 **原子提交** 要求所有參與者就是否提交或中止事務達成一致。 我們很快就會探討所有這些。事實上,這些問題都是相互等價的:如果你有解決其中一個問題的演算法,你可以將其轉換為任何其他問題的解決方案。這是一個相當深刻且也許令人驚訝的見解!這就是為什麼我們可以將所有這些東西歸入"共識"之下,即使它們表面上看起來完全不同。 @@ -449,7 +449,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: * 當具有單主複製的資料庫首次啟動時,或者當現有主節點失敗時,多個節點可能會同時嘗試成為主節點。同樣,多個節點可能競相獲取鎖或租約。共識允許它們決定哪一個獲勝。 * 如果幾個人同時嘗試預訂飛機上的最後一個座位,或劇院中的同一個座位,或嘗試使用相同的使用者名稱註冊賬戶,那麼共識演算法可以確定哪一個應該成功。 -更一般地說,一個或多個節點可能 *提議* 值,共識演算法 *決定* 其中一個值。在上述示例中,每個節點可以提議自己的 ID,演算法決定哪個節點 ID 應該成為新的主節點、租約的持有者或飛機/劇院座位的購買者。在這種形式主義中,共識演算法必須滿足以下屬性 [^26]: +更一般地說,一個或多個節點可能 **提議** 值,共識演算法 **決定** 其中一個值。在上述示例中,每個節點可以提議自己的 ID,演算法決定哪個節點 ID 應該成為新的主節點、租約的持有者或飛機/劇院座位的購買者。在這種形式主義中,共識演算法必須滿足以下屬性 [^26]: 一致同意 : 沒有兩個節點決定不同。 @@ -458,7 +458,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: : 一旦節點決定了一個值,它就不能透過決定另一個值來改變主意。 有效性 -: 如果節點決定值 *v*,那麼 *v* 是由某個節點提議的。 +: 如果節點決定值 **v**,那麼 **v** 是由某個節點提議的。 終止 : 每個未崩潰的節點最終都會決定某個值。 @@ -473,7 +473,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 如果崩潰的節點可能恢復,你可以等待它回來。然而,共識必須確保即使崩潰的節點突然消失並且永遠不會回來,它也會做出決定。(不要想象軟體崩潰,而是想象有地震,包含你的節點的資料中心被山體滑坡摧毀。你必須假設你的節點被埋在 30 英尺的泥土下,永遠不會重新上線。) -當然,如果 *所有* 節點都崩潰了,並且沒有一個在執行,那麼任何演算法都不可能決定任何事情。演算法可以容忍的故障數量是有限的:事實上,可以證明任何共識演算法都需要至少大多數節點正常執行才能確保終止 [^73]。該多數可以安全地形成仲裁(見 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition))。 +當然,如果 **所有** 節點都崩潰了,並且沒有一個在執行,那麼任何演算法都不可能決定任何事情。演算法可以容忍的故障數量是有限的:事實上,可以證明任何共識演算法都需要至少大多數節點正常執行才能確保終止 [^73]。該多數可以安全地形成仲裁(見 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition))。 因此,終止屬性受到少於一半節點崩潰或不可達的假設的約束。然而,大多數共識演算法確保安全屬性——同意、完整性和有效性——始終得到滿足,即使大多數節點失敗或存在嚴重的網路問題 [^75]。因此,大規模中斷可能會阻止系統處理請求,但它不能透過導致做出不一致的決定來破壞共識系統。 @@ -491,7 +491,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: #### 共享日誌作為共識 {#sec_consistency_shared_logs} -我們已經看到了幾個日誌的例子,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 *日誌條目*,任何讀取它的人都會看到相同順序的相同條目。有時日誌有一個允許追加新條目的單個寫入者,但 *共享日誌* 是多個節點可以請求追加條目的日誌。單主複製就是一個例子:任何客戶端都可以要求主節點進行寫入,主節點將其追加到複製日誌,然後所有備庫按照與主節點相同的順序應用寫入。 +我們已經看到了幾個日誌的例子,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 **日誌條目**,任何讀取它的人都會看到相同順序的相同條目。有時日誌有一個允許追加新條目的單個寫入者,但 **共享日誌** 是多個節點可以請求追加條目的日誌。單主複製就是一個例子:任何客戶端都可以要求主節點進行寫入,主節點將其追加到複製日誌,然後所有備庫按照與主節點相同的順序應用寫入。 更正式地說,共享日誌支援兩種操作:你可以請求將值新增到日誌中,並且可以讀取日誌中的條目。它必須滿足以下屬性: @@ -505,7 +505,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: : 一旦節點讀取了某個日誌條目,它就是不可變的,新的日誌條目只能在它之後新增,而不能在之前。節點可能會重新讀取日誌,在這種情況下,它會以與最初讀取它們時相同的順序看到相同的日誌條目(即使節點崩潰並重新啟動)。 一致性 -: 如果兩個節點都讀取某個日誌條目 *e*,那麼在 *e* 之前,它們必須以相同的順序讀取完全相同的日誌條目序列。 +: 如果兩個節點都讀取某個日誌條目 **e**,那麼在 **e** 之前,它們必須以相同的順序讀取完全相同的日誌條目序列。 有效性 : 如果節點讀取包含某個值的日誌條目,那麼某個節點先前請求將該值新增到日誌中。 @@ -538,13 +538,13 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 現在假設讀取零的節點是獲勝者,它的值被決定。這對於讀取零的節點有效,但其他節點有問題:它們知道自己不是獲勝者,但它們不知道其他節點中哪一個獲勝了。獲勝者可以向其他節點發送訊息,讓它們知道它已經獲勝,但如果獲勝者在有機會發送此訊息之前崩潰了怎麼辦?在這種情況下,其他節點將被掛起,無法決定任何值,因此共識不會終止。其他節點不能回退到另一個節點,因為讀取零的節點可能會回來並正確地決定它提議的值。 -一個例外是,如果我們確定不超過兩個節點將提議值。在這種情況下,節點可以相互發送它們想要提議的值,然後每個都執行獲取並增加操作。讀取零的節點決定自己的值,讀取一的節點決定另一個節點的值。這解決了兩個節點之間的共識問題,這就是為什麼我們可以說獲取並增加的 *共識數* 為二 [^28]。相比之下,CAS 和共享日誌解決了任意數量節點可能提議值的共識,因此它們的共識數為 ∞(無窮大)。 +一個例外是,如果我們確定不超過兩個節點將提議值。在這種情況下,節點可以相互發送它們想要提議的值,然後每個都執行獲取並增加操作。讀取零的節點決定自己的值,讀取一的節點決定另一個節點的值。這解決了兩個節點之間的共識問題,這就是為什麼我們可以說獲取並增加的 **共識數** 為二 [^28]。相比之下,CAS 和共享日誌解決了任意數量節點可能提議值的共識,因此它們的共識數為 ∞(無窮大)。 #### 原子提交作為共識 {#atomic-commitment-as-consensus} -在 ["分散式事務"](/tw/ch8#sec_transactions_distributed) 中,我們看到了 *原子提交* 問題,即確保參與分散式事務的資料庫或分片都提交或中止事務。我們還看到了 *兩階段提交* 演算法,它依賴於作為單點故障的協調器。 +在 ["分散式事務"](/tw/ch8#sec_transactions_distributed) 中,我們看到了 **原子提交** 問題,即確保參與分散式事務的資料庫或分片都提交或中止事務。我們還看到了 **兩階段提交** 演算法,它依賴於作為單點故障的協調器。 -共識和原子提交之間有什麼關係?乍一看,它們似乎非常相似——兩者都需要節點達成某種形式的一致。然而,有一個重要的區別:對於共識,可以決定提議的任何值,而對於原子提交,如果 *任何* 參與者投票中止,演算法 *必須* 中止。更準確地說,原子提交需要以下屬性 [^78]: +共識和原子提交之間有什麼關係?乍一看,它們似乎非常相似——兩者都需要節點達成某種形式的一致。然而,有一個重要的區別:對於共識,可以決定提議的任何值,而對於原子提交,如果 **任何** 參與者投票中止,演算法 **必須** 中止。更準確地說,原子提交需要以下屬性 [^78]: 一致同意 : 沒有兩個節點決定不同的結果。 @@ -579,7 +579,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: #### 使用共享日誌 {#sec_consistency_smr} -共享日誌非常適合資料庫複製:如果每個日誌條目代表對資料庫的寫入,並且每個副本使用確定性邏輯以相同的順序處理相同的寫入,那麼副本將全部處於一致狀態。這個想法被稱為 *狀態機複製* [^80],它是事件溯源背後的原則,我們在 ["事件溯源和 CQRS"](/tw/ch3#sec_datamodels_events) 中看到了。共享日誌對於流處理也很有用,我們將在 [第十二章](/tw/ch12#ch_stream) 中看到。 +共享日誌非常適合資料庫複製:如果每個日誌條目代表對資料庫的寫入,並且每個副本使用確定性邏輯以相同的順序處理相同的寫入,那麼副本將全部處於一致狀態。這個想法被稱為 **狀態機複製** [^80],它是事件溯源背後的原則,我們在 ["事件溯源和 CQRS"](/tw/ch3#sec_datamodels_events) 中看到了。共享日誌對於流處理也很有用,我們將在 [第十二章](/tw/ch12#ch_stream) 中看到。 同樣,共享日誌可用於實現可序列化事務:如 ["實際序列執行"](/tw/ch8#sec_transactions_serial) 中所討論的,如果每個日誌條目代表要作為儲存過程執行的確定性事務,並且如果每個節點以相同的順序執行這些事務,那麼事務將是可序列化的 [^81] [^82]。 @@ -604,7 +604,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 然而,有一個問題。我們之前討論過腦裂的問題,並說所有節點都需要就誰是主節點達成一致——否則兩個不同的節點可能各自認為自己是主節點,從而做出不一致的決定。因此,似乎我們需要共識來選舉主節點,而我們需要主節點來解決共識。我們如何擺脫這個難題? -事實上,共識演算法不要求在任何時候只有一個主節點。相反,它們做出了較弱的保證:它們定義了一個 *紀元編號*(在 Paxos 中稱為 *投票編號*,在 Viewstamped Replication 中稱為 *檢視編號*,在 Raft 中稱為 *任期編號*)並保證在每個紀元內,主節點是唯一的。 +事實上,共識演算法不要求在任何時候只有一個主節點。相反,它們做出了較弱的保證:它們定義了一個 **紀元編號**(在 Paxos 中稱為 **投票編號**,在 Viewstamped Replication 中稱為 **檢視編號**,在 Raft 中稱為 **任期編號**)並保證在每個紀元內,主節點是唯一的。 當節點因為在某個超時時間內沒有收到主節點的訊息而認為當前主節點已死時,它可能會開始投票選舉新的主節點。這次選舉被賦予一個大於任何先前紀元的新紀元編號。如果兩個不同紀元中的兩個不同主節點之間存在衝突(也許是因為先前的主節點實際上並沒有死),那麼具有更高紀元編號的主節點獲勝。 @@ -612,7 +612,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 因此,我們有兩輪投票:一次選擇主節點,第二次對主節點提議的下一個要追加到日誌的條目進行投票。這兩次投票的仲裁必須重疊:如果對提議的投票成功,投票支援它的節點中至少有一個也必須參與了最近成功的主節點選舉 [^85]。因此,如果對提議的投票透過而沒有透露任何更高編號的紀元,當前主節點可以得出結論,沒有選出具有更高紀元編號的主節點,因此它可以安全地將提議的條目追加到日誌中 [^26] [^86]。 -這兩輪投票表面上看起來類似於兩階段提交,但它們是非常不同的協議。在共識演算法中,任何節點都可以開始選舉,它只需要節點仲裁的響應;在 2PC 中,只有協調器可以請求投票,它需要 *每個* 參與者的"是"投票才能提交。 +這兩輪投票表面上看起來類似於兩階段提交,但它們是非常不同的協議。在共識演算法中,任何節點都可以開始選舉,它只需要節點仲裁的響應;在 2PC 中,只有協調器可以請求投票,它需要 **每個** 參與者的"是"投票才能提交。 #### 共識的微妙之處 {#subtleties-of-consensus} @@ -627,7 +627,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 如果你希望共識演算法嚴格保證 ["共享日誌作為共識"](#sec_consistency_shared_logs) 中列出的屬性,那麼新主節點在處理任何寫入或線性一致讀取之前必須瞭解任何已確認的日誌條目,這一點至關重要。如果具有過時資料的節點成為新主節點,它可能會將新值寫入已經由舊主節點寫入的日誌條目,從而違反共享日誌的僅追加屬性。 -在某些情況下,你可能選擇削弱共識屬性,以便更快地從主節點故障中恢復。例如,Kafka 提供了啟用 *不乾淨的主節點選舉* 的選項,它允許任何副本成為主節點,即使它不是最新的。此外,在採用非同步複製的資料庫中,當主節點失敗時,你無法保證任何備庫是最新的。 +在某些情況下,你可能選擇削弱共識屬性,以便更快地從主節點故障中恢復。例如,Kafka 提供了啟用 **不乾淨的主節點選舉** 的選項,它允許任何副本成為主節點,即使它不是最新的。此外,在採用非同步複製的資料庫中,當主節點失敗時,你無法保證任何備庫是最新的。 如果你放棄新主節點必須是最新的要求,你可能會提高效能和可用性,但你是在薄冰上,因為共識理論不再適用。雖然只要沒有故障,事情就會正常工作,但 [第九章](/tw/ch9) 中討論的問題很容易導致大量資料丟失或損壞。 @@ -637,7 +637,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: 對於使用共識演算法進行復制的資料庫,不僅寫入需要轉換為日誌條目並複製到仲裁。如果你想保證線性一致的讀取,它們也必須像寫入一樣透過仲裁投票,以確認認為自己是主節點的節點確實仍然是最新的。例如,etcd 中的線性一致讀取就是這樣工作的。 -在其標準形式中,大多數共識演算法假設一組固定的節點——也就是說,節點可能會宕機並重新啟動,但允許投票的節點集在建立叢集時是固定的。在實踐中,通常需要在系統配置中新增新節點或刪除舊節點。共識演算法已經擴充套件了 *重新配置* 功能,使這成為可能。這在向系統新增新區域或從一個位置遷移到另一個位置(透過首先新增新節點,然後刪除舊節點)時特別有用。 +在其標準形式中,大多數共識演算法假設一組固定的節點——也就是說,節點可能會宕機並重新啟動,但允許投票的節點集在建立叢集時是固定的。在實踐中,通常需要在系統配置中新增新節點或刪除舊節點。共識演算法已經擴充套件了 **重新配置** 功能,使這成為可能。這在向系統新增新區域或從一個位置遷移到另一個位置(透過首先新增新節點,然後刪除舊節點)時特別有用。 #### 共識的利弊 {#pros-and-cons-of-consensus} @@ -656,7 +656,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: ### 協調服務 {#sec_consistency_coordination} -共識演算法對於任何希望提供線性一致操作的分散式資料庫都很有價值,許多現代分散式資料庫也都用共識來做複製。但有一類系統是共識演算法的重度使用者:*協調服務*,例如 ZooKeeper、etcd 和 Consul。雖然它們表面上看起來像普通鍵值儲存,但它們並不是為通用資料儲存而設計的。 +共識演算法對於任何希望提供線性一致操作的分散式資料庫都很有價值,許多現代分散式資料庫也都用共識來做複製。但有一類系統是共識演算法的重度使用者:**協調服務**,例如 ZooKeeper、etcd 和 Consul。雖然它們表面上看起來像普通鍵值儲存,但它們並不是為通用資料儲存而設計的。 相反,它們的目標是協調另一個分散式系統中的多個節點。例如,Kubernetes 依賴 etcd;Spark 和 Flink 在高可用模式下會在後臺依賴 ZooKeeper。協調服務通常只儲存小規模資料,這些資料可以完全放入記憶體(同時仍會寫盤以保證永續性),並透過容錯共識演算法在多個節點間複製。 @@ -666,10 +666,10 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: : 我們前面看到,共識系統可以實現具備容錯能力的原子比較並設定(CAS)操作。協調服務正是基於這一點來實現鎖和租約:若多個節點併發嘗試獲取同一個租約,最終只會有一個成功。 支援柵欄 -: 如 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 所述,當某個資源受租約保護時,需要 *柵欄* 機制來防止程序暫停或網路大延遲時的相互干擾。共識系統可透過為每個日誌條目分配單調遞增 ID 來生成柵欄令牌(ZooKeeper 中的 `zxid` 和 `cversion`,etcd 中的 revision)。 +: 如 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 所述,當某個資源受租約保護時,需要 **柵欄** 機制來防止程序暫停或網路大延遲時的相互干擾。共識系統可透過為每個日誌條目分配單調遞增 ID 來生成柵欄令牌(ZooKeeper 中的 `zxid` 和 `cversion`,etcd 中的 revision)。 故障檢測 -: 客戶端會在協調服務上維持長連線會話,並透過週期性心跳檢查對端是否存活。即使連線臨時中斷或某臺服務端故障,客戶端持有的租約仍可保持有效;但如果超過租約超時時間仍未收到心跳,協調服務就會認為客戶端已失效並釋放租約(ZooKeeper 將其稱為 *臨時節點*)。 +: 客戶端會在協調服務上維持長連線會話,並透過週期性心跳檢查對端是否存活。即使連線臨時中斷或某臺服務端故障,客戶端持有的租約仍可保持有效;但如果超過租約超時時間仍未收到心跳,協調服務就會認為客戶端已失效並釋放租約(ZooKeeper 將其稱為 **臨時節點**)。 變更通知 : 客戶端可以請求:當某些鍵發生變化時由協調服務主動通知。這樣客戶端就能知道另一個節點何時加入叢集(基於其寫入的值),或者何時失效(會話超時、臨時節點消失)。這類通知避免了客戶端頻繁輪詢。 @@ -700,13 +700,13 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制: #### 服務發現 {#service-discovery} -ZooKeeper、etcd 和 Consul 也常用於 *服務發現*:即確定連線某個服務所需的 IP 地址(見 ["負載均衡、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery))。在雲環境下,虛擬機器常常頻繁上下線,因此你通常無法預先知道服務地址。常見做法是讓服務啟動時把自身網路端點註冊到服務登錄檔,再供其他服務查詢。 +ZooKeeper、etcd 和 Consul 也常用於 **服務發現**:即確定連線某個服務所需的 IP 地址(見 ["負載均衡、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery))。在雲環境下,虛擬機器常常頻繁上下線,因此你通常無法預先知道服務地址。常見做法是讓服務啟動時把自身網路端點註冊到服務登錄檔,再供其他服務查詢。 用協調服務做服務發現很方便,因為它的故障檢測和變更通知能讓客戶端及時跟蹤服務例項的增減。而且如果你本來就用協調服務做租約、鎖或主節點選舉,那麼繼續複用它做服務發現通常也很自然,因為它已經知道哪個節點應該接收請求。 不過,對服務發現使用共識往往有些“殺雞用牛刀”:這個場景通常不要求線性一致性,更重要的是高可用和低延遲,因為沒有服務發現,整個系統都會停滯。因此通常更傾向於快取服務發現結果,並接受其可能略有陳舊。比如基於 DNS 的服務發現,就是透過多層快取來獲得良好的效能與可用性。 -為支援這類需求,ZooKeeper 提供了 *observer*(觀察者)節點:它接收日誌並維護一份 ZooKeeper 資料副本,但不參與共識投票。來自 observer 的讀取不具備線性一致性(可能陳舊),但即使網路中斷仍然可用,並且能透過快取提高系統可支援的讀吞吐量。 +為支援這類需求,ZooKeeper 提供了 **observer**(觀察者)節點:它接收日誌並維護一份 ZooKeeper 資料副本,但不參與共識投票。來自 observer 的讀取不具備線性一致性(可能陳舊),但即使網路中斷仍然可用,並且能透過快取提高系統可支援的讀吞吐量。 ## 總結 {#summary} diff --git a/content/tw/ch11.md b/content/tw/ch11.md index 21b08f3..bbf2c02 100644 --- a/content/tw/ch11.md +++ b/content/tw/ch11.md @@ -13,17 +13,17 @@ breadcrumbs: false > > 高德納 -到目前為止,本書大部分內容都圍繞著 *請求(request)* 與 *查詢(query)* 以及對應的 *響應(response)* 或 *結果(result)* 展開。現代很多資料系統都預設採用這種處理方式:你發出請求或指令,系統儘快給出答案。 +到目前為止,本書大部分內容都圍繞著 **請求(request)** 與 **查詢(query)** 以及對應的 **響應(response)** 或 **結果(result)** 展開。現代很多資料系統都預設採用這種處理方式:你發出請求或指令,系統儘快給出答案。 -網頁瀏覽器請求頁面、服務呼叫遠端 API、資料庫、快取、搜尋索引,以及很多其他系統都如此運作。我們稱這類系統為 *線上系統(online systems)*。它們通常以響應時間作為主要效能指標,並且往往需要良好的容錯能力來保證高可用。 +網頁瀏覽器請求頁面、服務呼叫遠端 API、資料庫、快取、搜尋索引,以及很多其他系統都如此運作。我們稱這類系統為 **線上系統(online systems)**。它們通常以響應時間作為主要效能指標,並且往往需要良好的容錯能力來保證高可用。 -但有時候,你需要執行的計算比一次互動式請求大得多,或者要處理的資料量遠超單次請求能承載的範圍。例如訓練 AI 模型、把海量資料從一種形式轉換成另一種形式、或者在超大資料集上做分析計算。我們把這類任務稱為 *批處理(batch processing)* 作業,有時也稱為 *離線系統(offline systems)*。 +但有時候,你需要執行的計算比一次互動式請求大得多,或者要處理的資料量遠超單次請求能承載的範圍。例如訓練 AI 模型、把海量資料從一種形式轉換成另一種形式、或者在超大資料集上做分析計算。我們把這類任務稱為 **批處理(batch processing)** 作業,有時也稱為 **離線系統(offline systems)**。 -批處理作業讀取一批輸入資料(只讀),並生成一批輸出資料(每次執行都從頭生成)。它通常不會像讀寫事務那樣原地修改資料。因此,輸出是由輸入推匯出的 *派生資料(derived data)*(見[“記錄系統與派生資料”](/tw/ch1#sec_introduction_derived)):如果不滿意輸出,你可以直接刪除它,修改作業邏輯,再跑一遍即可。把輸入視為不可變並儘量避免副作用(例如直接寫外部資料庫),不僅有助於效能,也帶來其他好處: +批處理作業讀取一批輸入資料(只讀),並生成一批輸出資料(每次執行都從頭生成)。它通常不會像讀寫事務那樣原地修改資料。因此,輸出是由輸入推匯出的 **派生資料(derived data)**(見[“記錄系統與派生資料”](/tw/ch1#sec_introduction_derived)):如果不滿意輸出,你可以直接刪除它,修改作業邏輯,再跑一遍即可。把輸入視為不可變並儘量避免副作用(例如直接寫外部資料庫),不僅有助於效能,也帶來其他好處: -- 如果你在程式碼中引入了 bug 導致輸出錯誤或損壞,可以直接回滾程式碼並重跑作業,輸出就會恢復正確。更簡單的做法是把舊輸出保留在另一個目錄,直接切回舊版本。多數物件儲存與開放表格式(見[“雲資料倉庫”](/tw/ch4#sec_cloud_data_warehouses))都支援這種能力,通常稱為 *時間旅行(time travel)*。大多數支援讀寫事務的資料庫不具備這種特性:如果錯誤程式碼把壞資料寫進資料庫,僅回滾程式碼並不能修復已寫入的資料。能夠從錯誤程式碼中恢復的能力被稱為 *容忍人為失誤* [^1]。 +- 如果你在程式碼中引入了 bug 導致輸出錯誤或損壞,可以直接回滾程式碼並重跑作業,輸出就會恢復正確。更簡單的做法是把舊輸出保留在另一個目錄,直接切回舊版本。多數物件儲存與開放表格式(見[“雲資料倉庫”](/tw/ch4#sec_cloud_data_warehouses))都支援這種能力,通常稱為 **時間旅行(time travel)**。大多數支援讀寫事務的資料庫不具備這種特性:如果錯誤程式碼把壞資料寫進資料庫,僅回滾程式碼並不能修復已寫入的資料。能夠從錯誤程式碼中恢復的能力被稱為 **容忍人為失誤** [^1]。 -- 因為回滾容易,功能開發能比“犯錯會造成不可逆損害”的環境更快推進。這個 *最小化不可逆性* 的原則對敏捷開發非常有益 [^2]。 +- 因為回滾容易,功能開發能比“犯錯會造成不可逆損害”的環境更快推進。這個 **最小化不可逆性** 的原則對敏捷開發非常有益 [^2]。 - 同一組檔案可以作為多種作業的輸入,包括監控類作業:例如計算指標、驗證輸出是否符合預期(如與上一次結果比較並度量偏差)。 @@ -36,7 +36,7 @@ breadcrumbs: false > [!NOTE] > 批處理的另一種替代形態是 *流處理(stream processing)*:作業不會在“處理完輸入後結束”,而是持續監聽輸入,並在變化發生後很快處理。我們將在[第十二章](/tw/ch12#ch_stream)討論流處理。 -線上處理與批處理的邊界並不總是清晰:一個執行很久的資料庫查詢,看起來也很像批處理過程。但批處理有一些獨特特性,使其成為構建可靠、可伸縮、可維護應用的重要積木。例如,它常在 *資料整合(data integration)* 中發揮作用,即把多個數據系統組合起來完成單一系統做不到的事。ETL(見[“資料倉庫”](/tw/ch1#sec_introduction_dwh))就是典型例子。 +線上處理與批處理的邊界並不總是清晰:一個執行很久的資料庫查詢,看起來也很像批處理過程。但批處理有一些獨特特性,使其成為構建可靠、可伸縮、可維護應用的重要積木。例如,它常在 **資料整合(data integration)** 中發揮作用,即把多個數據系統組合起來完成單一系統做不到的事。ETL(見[“資料倉庫”](/tw/ch1#sec_introduction_dwh))就是典型例子。 現代批處理深受 MapReduce 影響。Google 在 2004 年發表了這一批處理演算法 [^3],隨後 Hadoop、CouchDB、MongoDB 等開源系統都實現了它。MapReduce 是相對底層的程式設計模型,其能力不如資料倉庫中的並行查詢執行引擎成熟 [^4] [^5]。它在誕生時確實讓商用硬體上的處理規模躍升一大步,但今天已大體過時,Google 內部也不再使用 [^6] [^7]。 @@ -59,7 +59,7 @@ breadcrumbs: false $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" -這表示:UTC 時間 2025 年 6 月 27 日 17:55:11,伺服器收到來自客戶端 IP `216.58.210.78` 對 `/css/typography.css` 的請求。使用者未認證,因此 `$remote_user` 是連字元(`-`)。響應狀態碼是 200(成功),響應體大小 3,377 位元組。瀏覽器是 Chrome 137,該檔案是從頁面 *[*https://martin.kleppmann.com/*](https://martin.kleppmann.com/)* 引用而來。 +這表示:UTC 時間 2025 年 6 月 27 日 17:55:11,伺服器收到來自客戶端 IP `216.58.210.78` 對 `/css/typography.css` 的請求。使用者未認證,因此 `$remote_user` 是連字元(`-`)。響應狀態碼是 200(成功),響應體大小 3,377 位元組。瀏覽器是 Chrome 137,該檔案是從頁面 **[*https://martin.kleppmann.com/*](https://martin.kleppmann.com/)** 引用而來。 看起來“解析日誌”有點樸素,但它在現代科技公司裡是核心能力之一,從廣告流水線到支付處理都大量依賴。事實上,這也是 MapReduce 與“大資料”浪潮快速興起的重要推動力。 @@ -78,7 +78,7 @@ cat /var/log/nginx/access.log | #1 1. 讀取日誌檔案。(嚴格說這裡不需要 `cat`,可直接把檔案作為 `awk` 引數;但這樣寫更直觀看出線性管道。) 2. 以空白字元切分每行,只輸出第 7 個欄位,也就是請求 URL。上面的樣例中是 `/css/typography.css`。 -3. 按字典序對 URL 排序。某個 URL 若出現 *n* 次,排序後會連續出現 *n* 行。 +3. 按字典序對 URL 排序。某個 URL 若出現 **n** 次,排序後會連續出現 **n** 行。 4. `uniq` 透過比較相鄰兩行是否相同來去重。`-c` 讓它輸出計數:每個不同 URL 出現了多少次。 5. 第二次 `sort` 按每行開頭的數字(`-n`)排序,並用 `-r` 逆序,出現次數最多的排在最前。 6. `head` 只保留前 5 行(`-n 5`),丟棄其餘。 @@ -129,7 +129,7 @@ for count, url in top5: #5 Python 指令碼在記憶體裡維護了一個“URL -> 出現次數”的散列表。Unix 管道示例沒有這種散列表,而是透過排序把同一 URL 的多次出現排到一起。 -哪種方法更好?取決於不同 URL 的數量。對多數中小網站而言,通常可以把所有不同 URL 及其計數器放進(比如)1GB 記憶體。這個作業的 *工作集(working set)*(需要隨機訪問的記憶體規模)只取決於不同 URL 的個數:即便一百萬條日誌都指向同一 URL,散列表也只存一個 URL 和一個計數器。工作集足夠小時,記憶體散列表很好用,筆記本都能跑。 +哪種方法更好?取決於不同 URL 的數量。對多數中小網站而言,通常可以把所有不同 URL 及其計數器放進(比如)1GB 記憶體。這個作業的 **工作集(working set)**(需要隨機訪問的記憶體規模)只取決於不同 URL 的個數:即便一百萬條日誌都指向同一 URL,散列表也只存一個 URL 和一個計數器。工作集足夠小時,記憶體散列表很好用,筆記本都能跑。 但如果工作集大於可用記憶體,排序法就有優勢:它能高效使用磁碟。這與[“日誌結構儲存”](/tw/ch4#sec_storage_log_structured)中的原理一樣:先在記憶體對資料塊排序並寫成段檔案,再把多個有序段合併成更大的有序檔案。歸併排序的順序訪問模式對磁碟很友好(見[“SSD 上的順序寫與隨機寫”](/tw/ch4#sidebar_sequential))。 @@ -160,7 +160,7 @@ Unix 工具的一個侷限是它們只在單機執行。當資料大到單機記 大多數物理儲存裝置不能做“部分塊寫入”,即使資料不足一個塊也得寫滿塊。DFS 的塊更大且通常構建在作業系統檔案系統之上,因此一般沒有這個約束。比如一個 900MB 檔案在 128MB 分塊下,會有 7 個 128MB 塊和 1 個 4MB 塊。 -讀取 DFS 塊需要透過網路請求到持有該塊的叢集節點。每臺機器都執行守護程序,對外提供 API,使遠端程序能把本地檔案系統中的塊當作檔案讀寫。HDFS 把這些守護程序叫 DataNode,GlusterFS 叫 glusterfsd。後文統稱 *資料節點(data node)*。 +讀取 DFS 塊需要透過網路請求到持有該塊的叢集節點。每臺機器都執行守護程序,對外提供 API,使遠端程序能把本地檔案系統中的塊當作檔案讀寫。HDFS 把這些守護程序叫 DataNode,GlusterFS 叫 glusterfsd。後文統稱 **資料節點(data node)**。 DFS 也實現了“分散式版本”的頁快取。因為 DFS 塊作為檔案存放在資料節點本地,讀寫會經過資料節點作業系統,自帶記憶體頁快取,熱門塊會被快取在記憶體中。某些 DFS 還提供更多快取層,例如 JuiceFS 的客戶端快取和本地磁碟快取。 @@ -173,13 +173,13 @@ DFS 也實現了“分散式版本”的頁快取。因為 DFS 塊作為檔案 > [!TIP] 分散式檔案系統與網路儲存 > 分散式檔案系統基於 *無共享(shared-nothing)* 原則(見[“共享記憶體、共享磁碟與無共享架構”](/tw/ch2#sec_introduction_shared_nothing)),與 NAS(網路附加儲存)和 SAN(儲存區域網路)等 *共享磁碟* 方案形成對照。共享磁碟通常依賴集中式儲存裝置、定製硬體和專用網路(如光纖通道);無共享方案不要求專用硬體,只需普通資料中心網路互聯的機器。 -很多 DFS 構建在商用硬體上,成本更低但故障率高於企業級專用硬體。為容忍機器和磁碟故障,檔案塊通常複製到多臺機器。這也讓排程器更容易均衡負載:任務可在任一持有輸入副本的節點執行。複製可以是多副本(見[第六章](/tw/ch6#ch_replication)),也可以是 Reed-Solomon 等 *糾刪碼* 方案,以更低儲存開銷恢復丟失資料 [^10] [^11] [^12]。這與 RAID 思想類似,只是 RAID 面向同一機器上的多塊磁碟,而 DFS 是透過普通資料中心網路跨機器做訪問和複製。 +很多 DFS 構建在商用硬體上,成本更低但故障率高於企業級專用硬體。為容忍機器和磁碟故障,檔案塊通常複製到多臺機器。這也讓排程器更容易均衡負載:任務可在任一持有輸入副本的節點執行。複製可以是多副本(見[第六章](/tw/ch6#ch_replication)),也可以是 Reed-Solomon 等 **糾刪碼** 方案,以更低儲存開銷恢復丟失資料 [^10] [^11] [^12]。這與 RAID 思想類似,只是 RAID 面向同一機器上的多塊磁碟,而 DFS 是透過普通資料中心網路跨機器做訪問和複製。 ### 物件儲存 {#id277} Amazon S3、Google Cloud Storage、Azure Blob Storage、OpenStack Swift 等物件儲存,已成為批處理場景中對 DFS 的主流替代。實際上兩者邊界越來越模糊:正如前一節和[“由物件儲存支撐的資料庫”](/tw/ch6#sec_replication_object_storage)所述,FUSE 可以把 S3 這類物件儲存“掛載成檔案系統”;JuiceFS、Ceph 等系統也同時提供物件 API 與檔案系統 API。但這些介面、效能、以及一致性保證差異很大,即便 API 看似相容,也需要仔細驗證行為是否符合預期。 -物件儲存中的每個物件有一個 URL,例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。其中主機部分(`my-photo-bucket`)是 bucket 名,後半部分是物件 *鍵(key)*(示例裡是 `/2025/04/01/birthday.png`)。bucket 名全域性唯一;物件鍵在 bucket 內必須唯一。 +物件儲存中的每個物件有一個 URL,例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。其中主機部分(`my-photo-bucket`)是 bucket 名,後半部分是物件 **鍵(key)**(示例裡是 `/2025/04/01/birthday.png`)。bucket 名全域性唯一;物件鍵在 bucket 內必須唯一。 物件讀取用 `get`,寫入用 `put`。與檔案系統檔案不同,物件寫入後通常不可變;更新物件需要透過 `put` 全量重寫,類似鍵值儲存。Azure Blob Storage 和 S3 Express One Zone 支援追加,但多數物件儲存不支援。它也沒有 `fopen`、`fseek` 這類檔案控制代碼 API。 @@ -212,9 +212,9 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等編排器 任務執行器(Task executors) -: 每個節點上執行執行器守護程序,例如 YARN 的 *NodeManager* 或 Kubernetes 的 *kubelet*。執行器負責拉起任務、透過心跳上報存活狀態、跟蹤節點上的任務狀態與資源佔用。收到“啟動任務”請求後,執行器會獲取作業程式碼並執行啟動命令;隨後持續監控程序直至結束或失敗,並更新對應狀態元資料。 +: 每個節點上執行執行器守護程序,例如 YARN 的 **NodeManager** 或 Kubernetes 的 **kubelet**。執行器負責拉起任務、透過心跳上報存活狀態、跟蹤節點上的任務狀態與資源佔用。收到“啟動任務”請求後,執行器會獲取作業程式碼並執行啟動命令;隨後持續監控程序直至結束或失敗,並更新對應狀態元資料。 - 很多執行器還配合作業系統實現安全與效能隔離,例如 YARN 和 Kubernetes 都會使用 Linux *cgroups*。這樣可防止任務越權訪問資料,或因資源濫用影響同機其他任務。 + 很多執行器還配合作業系統實現安全與效能隔離,例如 YARN 和 Kubernetes 都會使用 Linux **cgroups**。這樣可防止任務越權訪問資料,或因資源濫用影響同機其他任務。 資源管理器(Resource Manager) @@ -236,20 +236,20 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等編排器 假設一個小叢集有 5 個節點,共 160 個 CPU 核。排程器收到兩個作業請求,每個都想要 100 核。怎麼排最好? - 可以給每個作業先分 80 個任務,剩餘 20 個等前面的任務結束後再啟動。 -- 也可以先跑完其中一個作業,再等 100 核都空出來後跑另一個。這叫 *gang scheduling*(成組排程)。 +- 也可以先跑完其中一個作業,再等 100 核都空出來後跑另一個。這叫 **gang scheduling**(成組排程)。 - 如果一個請求先到,排程器還要決定是立即把 100 核都給它,還是為未來請求預留一部分資源。 這是很簡化的例子,但已經能看到艱難權衡。以成組排程為例,如果排程器為了湊齊 100 核而長期預留資源,節點會閒置,資源利用率下降,若其他作業也在搶佔式預留,還可能死鎖。 -反過來,如果只是被動等 100 核“自然可用”,中間可能被別的作業拿走,導致長時間湊不齊,從而產生 *飢餓(starvation)*。排程器也可以 *搶佔(preempt)* 一部分先到作業任務,把它們殺掉給後到作業騰資源;但被殺任務之後還要重跑,整體效率同樣下降。 +反過來,如果只是被動等 100 核“自然可用”,中間可能被別的作業拿走,導致長時間湊不齊,從而產生 **飢餓(starvation)**。排程器也可以 **搶佔(preempt)** 一部分先到作業任務,把它們殺掉給後到作業騰資源;但被殺任務之後還要重跑,整體效率同樣下降。 -把這個問題放大到數百甚至數百萬個請求,想求全域性最優幾乎不可行。事實上這是 *NP-hard* 問題:除了很小規模,很難在可接受時間內算出最優解 [^14] [^15]。 +把這個問題放大到數百甚至數百萬個請求,想求全域性最優幾乎不可行。事實上這是 **NP-hard** 問題:除了很小規模,很難在可接受時間內算出最優解 [^14] [^15]。 因此工程上排程器通常採用啟發式方法,在非最優前提下做“足夠好”的決策。常見演算法包括 FIFO、主導資源公平(DRF)、優先順序佇列、容量/配額排程、各種裝箱演算法等。細節超出本書範圍,但這是非常有趣的研究領域。 #### 工作流排程 {#sec_batch_workflows} -本章開頭的 Unix 示例是多個命令串聯。分散式批處理中同樣常見:一個作業輸出要成為一個或多個後續作業輸入,而每個作業又可能依賴多個上游輸入。這個依賴結構稱為 *工作流(workflow)* 或 *有向無環圖(DAG)*。 +本章開頭的 Unix 示例是多個命令串聯。分散式批處理中同樣常見:一個作業輸出要成為一個或多個後續作業輸入,而每個作業又可能依賴多個上游輸入。這個依賴結構稱為 **工作流(workflow)** 或 **有向無環圖(DAG)**。 > [!NOTE] > 我們在[“持久化執行與工作流”](/tw/ch5#sec_encoding_dataflow_workflows)中討論過“按步驟執行 RPC”的工作流引擎;在批處理語境裡,“工作流”指的是一串批處理過程:每一步讀輸入、產輸出,通常不直接對外做 RPC。持久化執行引擎通常單次請求處理的資料量小於批處理系統,但兩者邊界並非絕對。 @@ -260,7 +260,7 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等編排器 - 你可能要在多個處理工具間傳遞資料。比如 Spark 作業寫 HDFS,再由 Python 觸發 Trino SQL 查詢(見[“雲資料倉庫”](/tw/ch4#sec_cloud_data_warehouses))繼續處理並寫入 S3。 - 有些流水線內部天然需要多階段。例如第一階段按某鍵分片,下一階段按另一鍵分片,那麼第一階段需要先產出符合第二階段要求的資料佈局。 -在 Unix 裡,管道用很小的記憶體緩衝連線前後命令,不落盤。若緩衝區滿,上游必須等待下游消費,這是一種 *背壓(backpressure)*。Spark、Flink 等批處理執行引擎也支援類似模式:一個任務輸出直接傳給下一任務(跨機時經網路傳輸)。 +在 Unix 裡,管道用很小的記憶體緩衝連線前後命令,不落盤。若緩衝區滿,上游必須等待下游消費,這是一種 **背壓(backpressure)**。Spark、Flink 等批處理執行引擎也支援類似模式:一個任務輸出直接傳給下一任務(跨機時經網路傳輸)。 但在工作流中,更常見仍是“上游作業寫 DFS/物件儲存,下游再讀”,這樣可讓作業在時間上解耦。若一個作業有多個輸入,工作流排程器通常會等待所有上游輸入生產成功後再啟動它。 @@ -270,7 +270,7 @@ YARN ResourceManager 或 Spark 內建排程器主要做“作業內排程”, 批處理作業往往執行時間長。長時間執行且並行任務多的作業,在執行過程中遇到至少一次任務失敗幾乎是常態。正如[“硬體與軟體故障”](/tw/ch2#sec_introduction_hardware_faults)和[“不可靠網路”](/tw/ch9#sec_distributed_networks)所述,原因可能是硬體故障(商用硬體尤甚)、網路中斷等。 -任務無法完成的另一原因是被排程器主動搶佔(kill)。當系統有多優先順序佇列時,這很常見:低優先順序任務便宜、高優先順序任務昂貴。低優先順序任務可用空閒算力跑,但高優先順序任務一到就可能把它們搶佔掉。雲廠商的對應產品名分別是:AWS 的 *spot instances*、Azure 的 *spot virtual machines*、GCP 的 *preemptible instances* [^16]。 +任務無法完成的另一原因是被排程器主動搶佔(kill)。當系統有多優先順序佇列時,這很常見:低優先順序任務便宜、高優先順序任務昂貴。低優先順序任務可用空閒算力跑,但高優先順序任務一到就可能把它們搶佔掉。雲廠商的對應產品名分別是:AWS 的 **spot instances**、Azure 的 **spot virtual machines**、GCP 的 **preemptible instances** [^16]。 批處理很多時候對即時性要求不高,因此很適合利用低優先順序資源/搶佔式例項降成本:本質上它在“吃”否則會閒置的算力,提高叢集利用率。但代價是更高的被殺機率:實際裡搶佔往往比硬體故障更常見 [^17]。 @@ -290,7 +290,7 @@ MapReduce 與資料流引擎都發展出多種程式設計介面:低層 API、 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))。 +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`,統計相鄰同鍵記錄數。 @@ -318,7 +318,7 @@ Reducer 為解決 MapReduce 的侷限,出現了多種分散式批處理執行引擎,最著名的是 Spark [^18] [^21] 和 Flink [^19]。它們設計細節各異,但有一個共同點:把整條工作流當成一個作業處理,而不是拆成互相獨立的小作業。 -因為它們顯式建模了跨多個處理階段的資料流動,所以稱為 *資料流引擎(dataflow engines)*。與 MapReduce 一樣,它們提供低層 API(反覆呼叫使用者函式逐條處理記錄),也提供更高層運算元(如 *join*、*group by*)。它們透過分片並行輸入,並透過網路把一個任務輸出傳給另一個任務輸入。與 MapReduce 不同,運算元不必嚴格在 map/reduce 兩類角色間交替,而可以更靈活組合。 +因為它們顯式建模了跨多個處理階段的資料流動,所以稱為 **資料流引擎(dataflow engines)**。與 MapReduce 一樣,它們提供低層 API(反覆呼叫使用者函式逐條處理記錄),也提供更高層運算元(如 **join**、**group by**)。它們透過分片並行輸入,並透過網路把一個任務輸出傳給另一個任務輸入。與 MapReduce 不同,運算元不必嚴格在 map/reduce 兩類角色間交替,而可以更靈活組合。 這些 API 通常以關係風格構件表達計算:按欄位值連線資料集、按鍵分組、按條件過濾、按計數或求和等函式聚合。內部實現依賴的正是下一節要講的混洗演算法。 @@ -335,26 +335,26 @@ Reducer ### 混洗資料 {#sec_shuffle} -本章開頭的 Unix 工具示例和 MapReduce 都建立在排序之上。批處理系統要能排序 PB 級資料,單機放不下,因此必須使用“輸入與輸出都分片”的分散式排序演算法,這就是 *混洗(shuffle)*。 +本章開頭的 Unix 工具示例和 MapReduce 都建立在排序之上。批處理系統要能排序 PB 級資料,單機放不下,因此必須使用“輸入與輸出都分片”的分散式排序演算法,這就是 **混洗(shuffle)**。 > [!NOTE] 混洗不是隨機 > “shuffle” 容易引發誤解。洗牌會得到隨機順序;而這裡的 shuffle 產出的是排序後的確定順序,不含隨機性。 混洗是批處理系統的基礎演算法,連線與聚合都依賴它。MapReduce、Spark、Flink、Daft、Dataflow、BigQuery [^24] 都實現了高可伸縮且高效能的混洗機制以處理大資料集。這裡用 Hadoop MapReduce 的混洗實現做說明 [^25],但核心思想在其他系統同樣適用。 -[圖 11-1](#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](#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](#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是輸出資料集的分片,最終寫回 DFS 或物件儲存。 +reducer 輸出記錄會順序寫入檔案,每個 reduce 任務一個檔案。[圖 11-1](#fig_batch_mapreduce)中的 **r1**、**r2**、**r3** 就是輸出資料集的分片,最終寫回 DFS 或物件儲存。 MapReduce 在 map 與 reduce 之間執行混洗;現代資料流引擎和雲資料倉庫則更複雜。BigQuery 等系統已最佳化混洗,使資料儘量留在記憶體,並寫入外部排序服務 [^24],以提升速度並透過複製增強韌性。 @@ -362,7 +362,7 @@ MapReduce 在 map 與 reduce 之間執行混洗;現代資料流引擎和雲資 下面看“有序資料”如何簡化分散式連線與聚合。為便於說明仍以 MapReduce 為例,但概念適用於大多數批處理系統。 -批處理裡常見連線場景見[圖 11-2](#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" >}} @@ -372,11 +372,11 @@ MapReduce 在 map 與 reduce 之間執行混洗;現代資料流引擎和雲資 {{< figure src="/fig/ddia_1103.png" id="fig_batch_join_reduce" caption="圖 11-3. 基於使用者 ID 的排序合併連線。若輸入資料集由多個檔案分片組成,可並行啟動多個 mapper 處理。" class="w-full my-4" >}} -混洗保證 reducer 能同時拿到某使用者的出生日期和該使用者全部頁面訪問事件。MapReduce 甚至可以把記錄進一步排成 reducer 先看到使用者記錄、再按時間戳看到活動事件,這稱為 *二次排序(secondary sort)* [^25]。 +混洗保證 reducer 能同時拿到某使用者的出生日期和該使用者全部頁面訪問事件。MapReduce 甚至可以把記錄進一步排成 reducer 先看到使用者記錄、再按時間戳看到活動事件,這稱為 **二次排序(secondary sort)** [^25]。 -於是 reducer 很容易實現連線邏輯:先拿到出生日期並存入區域性變數,再遍歷同一使用者 ID 的活動事件,輸出“被訪問 URL + 訪問者出生日期”。因為 reducer 一次處理一個使用者的全部記錄,所以記憶體裡只要保留一條使用者記錄,也無需發任何網路請求。這個演算法稱為 *排序合併連線(sort-merge join)*:mapper 輸出先按鍵排序,reducer 再把連線兩側有序記錄合併。 +於是 reducer 很容易實現連線邏輯:先拿到出生日期並存入區域性變數,再遍歷同一使用者 ID 的活動事件,輸出“被訪問 URL + 訪問者出生日期”。因為 reducer 一次處理一個使用者的全部記錄,所以記憶體裡只要保留一條使用者記錄,也無需發任何網路請求。這個演算法稱為 **排序合併連線(sort-merge join)**:mapper 輸出先按鍵排序,reducer 再把連線兩側有序記錄合併。 -工作流中的下一個 MapReduce 作業就可以繼續計算“每個 URL 的訪問者年齡分佈”:先按 URL 做一次混洗,再在 reducer 中遍歷同 URL 的所有訪問記錄(含出生日期),按年齡段維護計數並逐條累加,從而實現 *group by* 與聚合。 +工作流中的下一個 MapReduce 作業就可以繼續計算“每個 URL 的訪問者年齡分佈”:先按 URL 做一次混洗,再在 reducer 中遍歷同 URL 的所有訪問記錄(含出生日期),按年齡段維護計數並逐條累加,從而實現 **group by** 與聚合。 ### 查詢語言 {#sec_batch_query_lanauges} @@ -436,13 +436,13 @@ Daft 等框架甚至同時支援客戶端與服務端計算:小規模記憶體 我們也看到,批處理在“出錯後排障與修復”方面很友好,這對除錯資料流水線極其關鍵。失敗檔案可直接檢查,ETL 作業可修復後重跑。比如輸入檔案不再包含某個轉換邏輯依賴欄位,資料工程師就能據此更新轉換邏輯或修復上游生產作業。 -過去資料流水線往往由單一資料工程團隊集中維護,因為讓產品團隊自行編寫和維護複雜批流水線不太現實。近年隨著處理模型和元資料管理改進,組織內更多團隊都能參與並維護自己的流水線。*data mesh* [^35] [^36]、*data contract* [^37]、*data fabric* [^38] 等實踐,正透過規範和工具幫助團隊安全釋出可被全組織消費的資料。 +過去資料流水線往往由單一資料工程團隊集中維護,因為讓產品團隊自行編寫和維護複雜批流水線不太現實。近年隨著處理模型和元資料管理改進,組織內更多團隊都能參與並維護自己的流水線。**data mesh** [^35] [^36]、**data contract** [^37]、**data fabric** [^38] 等實踐,正透過規範和工具幫助團隊安全釋出可被全組織消費的資料。 如今資料流水線與分析查詢不僅共享處理模型,也常共享執行引擎。很多 ETL 作業與消費其輸出的分析查詢都執行在同一系統裡:例如同樣以 SparkSQL、Trino 或 DuckDB 查詢執行。這樣的架構進一步模糊了應用工程、資料工程、分析工程與業務分析之間的界限。 ### 分析(Analytics) {#sec_batch_olap} -在[“操作型系統與分析型系統”](/tw/ch1#sec_introduction_analytics)中我們看到,分析查詢(OLAP)通常要掃描大量記錄並做分組聚合。這類負載可以與其他批任務一起執行在批處理系統中。分析人員寫 SQL,經查詢引擎執行,讀寫底層 DFS 或物件儲存。表到檔案對映、名稱、型別等表元資料通常由 Apache Iceberg 等表格式與 Unity 等 catalog 管理(見[“雲資料倉庫”](/tw/ch4#sec_cloud_data_warehouses))。這種架構稱為 *資料湖倉(data lakehouse)* [^39]。 +在[“操作型系統與分析型系統”](/tw/ch1#sec_introduction_analytics)中我們看到,分析查詢(OLAP)通常要掃描大量記錄並做分組聚合。這類負載可以與其他批任務一起執行在批處理系統中。分析人員寫 SQL,經查詢引擎執行,讀寫底層 DFS 或物件儲存。表到檔案對映、名稱、型別等表元資料通常由 Apache Iceberg 等表格式與 Unity 等 catalog 管理(見[“雲資料倉庫”](/tw/ch4#sec_cloud_data_warehouses))。這種架構稱為 **資料湖倉(data lakehouse)** [^39]。 與 ETL 類似,SQL 介面改進讓很多組織用 Spark 等批框架直接承載分析。常見模式有兩類: @@ -463,7 +463,7 @@ SQL 支援還讓批處理系統更易接入電子表格與視覺化工具,如 推薦系統和排序系統等 ML 應用也大量使用圖處理(見[“圖狀資料模型”](/tw/ch3#sec_datamodels_graph))。許多圖演算法表達為“沿邊逐步傳播資訊並反覆迭代”:把一個頂點與相鄰頂點連線,傳遞某些資訊,重複直到滿足停止條件,例如無邊可繼續,或某個指標收斂。 -*批同步並行(bulk synchronous parallel, BSP)* 計算模型 [^40] 已成為批圖計算常用模型。Apache Giraph [^20]、Spark GraphX、Flink Gelly [^41] 等都實現了它。它也常被稱為 *Pregel* 模型,因為 Google 的 Pregel 論文讓這一方法廣為人知 [^42]。 +**批同步並行(bulk synchronous parallel, BSP)** 計算模型 [^40] 已成為批圖計算常用模型。Apache Giraph [^20]、Spark GraphX、Flink Gelly [^41] 等都實現了它。它也常被稱為 **Pregel** 模型,因為 Google 的 Pregel 論文讓這一方法廣為人知 [^42]。 批處理同樣是大語言模型(LLM)資料準備與訓練的重要組成部分。網頁等原始文字通常存放在 DFS 或物件儲存中,必須先預處理才能用於訓練。適合批處理框架的預處理步驟包括: @@ -473,7 +473,7 @@ SQL 支援還讓批處理系統更易接入電子表格與視覺化工具,如 Kubeflow、Flyte、Ray 等框架就專為這類負載構建。以 OpenAI 為例,ChatGPT 訓練流程中就使用了 Ray [^43]。這些框架通常內建與 PyTorch、TensorFlow、XGBoost 等 LLM/AI 庫的整合,並支援特徵工程、模型訓練、批次推理、微調等能力。 -最後,資料科學家常在 Jupyter、Hex 等互動式 Notebook 中實驗資料。Notebook 由多個 *cell* 組成,每個 cell 是一小段 Markdown、Python 或 SQL;按順序執行可得到表格、圖表或資料結果。很多 Notebook 背後透過 DataFrame API 或 SQL 呼叫批處理系統。 +最後,資料科學家常在 Jupyter、Hex 等互動式 Notebook 中實驗資料。Notebook 由多個 **cell** 組成,每個 cell 是一小段 Markdown、Python 或 SQL;按順序執行可得到表格、圖表或資料結果。很多 Notebook 背後透過 DataFrame API 或 SQL 呼叫批處理系統。 ### 對外提供派生資料 {#sec_batch_serving_derived} @@ -492,7 +492,7 @@ Kubeflow、Flyte、Ray 等框架就專為這類負載構建。以 OpenAI 為例 - 一個批作業輸出可被多個下游系統同時消費; - 流系統還可作為批處理網路與生產網路之間的安全邊界(可部署在 DMZ)。 -但“經由流”並不會自動解決“全有或全無”語義。要實現這一點,批作業需要在完成後向下遊發出“作業完成,可對外可見”的通知。流消費者需要像 *讀已提交(read committed)* 事務那樣,在收到完成通知前讓新資料對查詢不可見(見[“讀已提交”](/tw/ch8#sec_transactions_read_committed))。 +但“經由流”並不會自動解決“全有或全無”語義。要實現這一點,批作業需要在完成後向下遊發出“作業完成,可對外可見”的通知。流消費者需要像 **讀已提交(read committed)** 事務那樣,在收到完成通知前讓新資料對查詢不可見(見[“讀已提交”](/tw/ch8#sec_transactions_read_committed))。 另一種在資料庫冷啟動(bootstrap)時更常見的模式,是在批作業內直接構建一個全新資料庫,再把檔案從 DFS、物件儲存或本地檔案系統批次匯入目標資料庫。很多系統都提供這類批次匯入工具,如 TiDB Lightning、Apache Pinot/Apache Druid 的 Hadoop 匯入作業,RocksDB 也提供從批作業批次匯入 SST 的 API。 @@ -517,7 +517,7 @@ Kubeflow、Flyte、Ray 等框架就專為這類負載構建。以 OpenAI 為例 - 機器學習:用於準備與處理大規模訓練資料; - 把批處理輸出灌入面向生產流量的系統:常透過流系統或批次匯入工具,把派生資料提供給使用者。 -下一章我們將轉向流處理。與批處理不同,流處理輸入是 *無界(unbounded)* 的:作業仍在,但輸入是持續不斷的資料流,因此作業不會“完成”。我們會看到,流處理與批處理在一些方面很相似,但“輸入無界”這一前提也會顯著改變系統設計。 +下一章我們將轉向流處理。與批處理不同,流處理輸入是 **無界(unbounded)** 的:作業仍在,但輸入是持續不斷的資料流,因此作業不會“完成”。我們會看到,流處理與批處理在一些方面很相似,但“輸入無界”這一前提也會顯著改變系統設計。 ### 參考文獻 {#references} diff --git a/content/tw/ch12.md b/content/tw/ch12.md index 122fce3..93ec732 100644 --- a/content/tw/ch12.md +++ b/content/tw/ch12.md @@ -583,8 +583,8 @@ CEP 的實現包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str * 當用戶 u 傳送新的推文時,它將被新增到每個關注使用者 u 的時間線上。 * 使用者刪除推文時,推文將從所有使用者的時間線中刪除。 -* 當用戶 *u*~1~ 開始關注使用者 *u*~2~ 時,*u*~2~ 最近的推文將被新增到 *u*~1~ 的時間線上。 -* 當用戶 *u*~1~ 取消關注使用者 *u*~2~ 時,*u*~2~ 的推文將從 *u*~1~ 的時間線中移除。 +* 當用戶 **u**~1~ 開始關注使用者 **u**~2~ 時,**u**~2~ 最近的推文將被新增到 **u**~1~ 的時間線上。 +* 當用戶 **u**~1~ 取消關注使用者 **u**~2~ 時,**u**~2~ 的推文將從 **u**~1~ 的時間線中移除。 要在流處理器中實現這種快取維護,你需要推文事件流(傳送與刪除)和關注關係事件流(關注與取消關注)。流處理需要維護一個數據庫,包含每個使用者的粉絲集合,以便知道當一條新推文到達時,需要更新哪些時間線。 diff --git a/content/tw/ch14.md b/content/tw/ch14.md index f801a41..b589961 100644 --- a/content/tw/ch14.md +++ b/content/tw/ch14.md @@ -80,9 +80,9 @@ breadcrumbs: false ### 監視 {#id374} -做個思想實驗:把 *data* 一詞替換為 *surveillance*(監視),看看常見說法是否還那麼“好聽” [^23]。例如:“在我們這個監視驅動的組織中,我們收集即時監視流並存入監視倉庫。我們的監視科學家使用先進的分析與監視處理來產出新洞見。” +做個思想實驗:把 **data** 一詞替換為 **surveillance**(監視),看看常見說法是否還那麼“好聽” [^23]。例如:“在我們這個監視驅動的組織中,我們收集即時監視流並存入監視倉庫。我們的監視科學家使用先進的分析與監視處理來產出新洞見。” -這個思想實驗對本書來說少見地帶有一點論戰色彩,彷彿書名成了《設計監視密集型應用》(*Designing Surveillance-Intensive Applications*)。但為了強調這一點,我們需要更尖銳的詞。在我們試圖讓軟體“吞噬世界” [^24] 的過程中,我們構建了人類有史以來規模最大的群體監視基礎設施。我們正快速接近這樣一個世界:幾乎每個有人居住的空間都至少有一個聯網麥克風,存在於智慧手機、智慧電視、語音助手裝置、嬰兒監視器,甚至使用雲語音識別的兒童玩具中。許多這類裝置的安全記錄都非常糟糕 [^25]。 +這個思想實驗對本書來說少見地帶有一點論戰色彩,彷彿書名成了《設計監視密集型應用》(**Designing Surveillance-Intensive Applications**)。但為了強調這一點,我們需要更尖銳的詞。在我們試圖讓軟體“吞噬世界” [^24] 的過程中,我們構建了人類有史以來規模最大的群體監視基礎設施。我們正快速接近這樣一個世界:幾乎每個有人居住的空間都至少有一個聯網麥克風,存在於智慧手機、智慧電視、語音助手裝置、嬰兒監視器,甚至使用雲語音識別的兒童玩具中。許多這類裝置的安全記錄都非常糟糕 [^25]。 與過去相比,新變化在於:數字化讓大規模收集人的資料變得很容易。對我們位置與行動軌跡、社交關係與通訊、購買與支付、健康資訊的監視,幾乎已不可避免。一個監視型組織最終掌握的個人資訊,甚至可能比當事人自己知道的還多——例如,在當事人意識到之前就識別出其疾病或經濟困境。 @@ -102,15 +102,15 @@ breadcrumbs: false 此外,資料從使用者身上被抽取是單向過程,不是具有真實互惠的關係,也不是公平的價值交換。這裡沒有對話,沒有讓使用者協商“提供多少資料、換取什麼服務”的空間:服務與使用者之間的關係高度不對稱、單向度。規則由服務制定,而非使用者 [^30], [^31]。 -在歐盟,《通用資料保護條例》(GDPR)要求同意必須是 “freely given, specific, informed, and unambiguous”,並且使用者必須能夠 “refuse or withdraw consent without detriment”——否則不被視為 “freely given”。任何徵求同意的請求都必須以 “an intelligible and easily accessible form, using clear and plain language” 撰寫。此外,“silence, pre-ticked boxes or inactivity \[do not\] constitute consent” [^32]。除同意外,個人資料處理還可基於其他合法基礎,例如 *legitimate interest*,它允許某些資料用途,如防欺詐 [^33]。 +在歐盟,《通用資料保護條例》(GDPR)要求同意必須是 “freely given, specific, informed, and unambiguous”,並且使用者必須能夠 “refuse or withdraw consent without detriment”——否則不被視為 “freely given”。任何徵求同意的請求都必須以 “an intelligible and easily accessible form, using clear and plain language” 撰寫。此外,“silence, pre-ticked boxes or inactivity \[do not\] constitute consent” [^32]。除同意外,個人資料處理還可基於其他合法基礎,例如 **legitimate interest**,它允許某些資料用途,如防欺詐 [^33]。 -你可能會說,不同意被監視的使用者可以選擇不用這項服務。但這種選擇同樣不自由:如果某項服務流行到“被大多數人視為基本社會參與所必需” [^30],那就不能合理期待人們退出——使用它在事實上成了強制(*de facto* mandatory)。例如,在多數西方社群中,攜帶智慧手機、透過社交網路社交、使用 Google 獲取資訊,已經成為常態。尤其當服務具有網路效應時,選擇 *不* 使用它會付出社會成本。 +你可能會說,不同意被監視的使用者可以選擇不用這項服務。但這種選擇同樣不自由:如果某項服務流行到“被大多數人視為基本社會參與所必需” [^30],那就不能合理期待人們退出——使用它在事實上成了強制(**de facto** mandatory)。例如,在多數西方社群中,攜帶智慧手機、透過社交網路社交、使用 Google 獲取資訊,已經成為常態。尤其當服務具有網路效應時,選擇 **不** 使用它會付出社會成本。 因為追蹤政策而拒絕使用某服務,說起來容易做起來難。這些平臺本來就是為吸引使用者而設計的。許多平臺使用遊戲機制和賭博常見策略來讓使用者反覆回來 [^34]。即便使用者能克服這一點,拒絕參與也往往只是少數特權人群的選項:他們有時間和知識去理解隱私政策,也有能力承擔潛在代價——比如錯過本可透過該服務獲得的社會參與或職業機會。對於處境更不利的人來說,並不存在真正意義上的選擇自由:監視變得無可逃避。 ### 隱私與資料使用 {#id457} -有時有人聲稱“隱私已死”,理由是某些使用者願意在社交媒體上釋出各種生活內容,有些瑣碎,有些極度私密。但這個說法是錯誤的,它建立在對 *privacy* 一詞的誤解之上。 +有時有人聲稱“隱私已死”,理由是某些使用者願意在社交媒體上釋出各種生活內容,有些瑣碎,有些極度私密。但這個說法是錯誤的,它建立在對 **privacy** 一詞的誤解之上。 擁有隱私並不意味著把一切都保密;它意味著擁有選擇自由:哪些內容向誰披露、哪些公開、哪些保密。隱私權是一種決策權:它讓每個人在每種情境中,決定自己在“保密”與“透明”光譜上的位置 [^30]。這是個體自由與自主性的重要組成部分。 @@ -122,7 +122,7 @@ breadcrumbs: false 即便特定使用者無法從某條廣告所面向的人群桶中被個人重識別,他們仍失去了對某些私密資訊披露的主導權。決定“向誰披露什麼”不再基於使用者自己的偏好,而是公司在行使這種隱私權,目標是利潤最大化。 -許多公司追求的目標是“不被 *感知* 為令人不適”,迴避“資料收集到底有多侵入”這一問題,轉而專注於管理使用者感知。而且就連這種感知管理也常常做得不好:例如,某些內容也許在事實層面是正確的,但若會觸發痛苦記憶,使用者可能並不想被提醒 [^35]。面對任何資料,我們都應預期它可能出錯、不可取或在某些情況下不合適,並且需要構建機制來處理這些失效。至於什麼算“不可取”或“不合適”,當然屬於人的判斷;演算法除非被我們顯式程式設計去尊重人的需要,否則對這些概念是無感的。作為這些系統的工程師,我們必須保持謙遜,接受並預先規劃這些失效。 +許多公司追求的目標是“不被 **感知** 為令人不適”,迴避“資料收集到底有多侵入”這一問題,轉而專注於管理使用者感知。而且就連這種感知管理也常常做得不好:例如,某些內容也許在事實層面是正確的,但若會觸發痛苦記憶,使用者可能並不想被提醒 [^35]。面對任何資料,我們都應預期它可能出錯、不可取或在某些情況下不合適,並且需要構建機制來處理這些失效。至於什麼算“不可取”或“不合適”,當然屬於人的判斷;演算法除非被我們顯式程式設計去尊重人的需要,否則對這些概念是無感的。作為這些系統的工程師,我們必須保持謙遜,接受並預先規劃這些失效。 線上服務裡的隱私設定,允許使用者控制其資料的哪些方面可被其他使用者看到,這是把部分控制權還給使用者的起點。然而,不管設定如何,服務本身仍可不受限制地訪問這些資料,並可在隱私政策允許範圍內任意使用。即使服務承諾不把資料出售給第三方,通常也會賦予自己在內部處理和分析資料的廣泛權利,而這種處理常常遠遠超出使用者可見範圍。 @@ -138,7 +138,7 @@ breadcrumbs: false 因為資料有價值,很多人都想要它。公司當然想要——這本就是它們收集資料的原因。政府也想拿到:透過秘密交易、脅迫、法律強制,或者直接竊取 [^37]。當公司破產時,其收集的個人資料會作為資產被出售。並且,資料很難徹底保護,洩露事件頻發得令人不安。 -這些觀察促使批評者說,資料不只是資產,還是“有毒資產”(*toxic asset*) [^37],或者至少是“危險材料”(*hazardous material*) [^38]。也許資料不是“新黃金”、不是“新石油”,而是“新鈾” [^39]。即使我們認為自己有能力防止資料濫用,每次收集資料時也必須權衡收益與其落入錯誤之手的風險:計算機系統可能被犯罪分子或敵對外國情報機構攻破,資料可能被內部人員洩露,公司可能落入與我們價值觀不一致的管理層手中,或國家可能被一個毫無顧忌、會強迫我們交出資料的政權接管。 +這些觀察促使批評者說,資料不只是資產,還是“有毒資產”(**toxic asset**) [^37],或者至少是“危險材料”(**hazardous material**) [^38]。也許資料不是“新黃金”、不是“新石油”,而是“新鈾” [^39]。即使我們認為自己有能力防止資料濫用,每次收集資料時也必須權衡收益與其落入錯誤之手的風險:計算機系統可能被犯罪分子或敵對外國情報機構攻破,資料可能被內部人員洩露,公司可能落入與我們價值觀不一致的管理層手中,或國家可能被一個毫無顧忌、會強迫我們交出資料的政權接管。 收集資料時,我們不僅要考慮今天的政治環境,還要考慮未來所有可能的政府。無法保證未來每一屆政府都會尊重人權與公民自由,因此,“安裝那些未來可能助長警察國家的技術,是糟糕的公民衛生習慣” [^40]。 diff --git a/content/tw/ch2.md b/content/tw/ch2.md index 1f6f5b6..c387b72 100644 --- a/content/tw/ch2.md +++ b/content/tw/ch2.md @@ -39,7 +39,7 @@ breadcrumbs: false {{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="圖 2-1. 社交網路的簡單關係模式,使用者可以相互關注。" class="w-full my-4" >}} -假設該社交網路最重要的讀操作是 *首頁時間線*:展示你所追隨的人最近釋出的帖子(為簡化起見,我們忽略廣告、未追隨使用者的推薦帖,以及其他擴充套件功能)。獲取某個使用者首頁時間線的 SQL 可能如下: +假設該社交網路最重要的讀操作是 **首頁時間線**:展示你所追隨的人最近釋出的帖子(為簡化起見,我們忽略廣告、未追隨使用者的推薦帖,以及其他擴充套件功能)。獲取某個使用者首頁時間線的 SQL 可能如下: ```sql SELECT posts.*, users.* FROM posts @@ -52,7 +52,7 @@ SELECT posts.*, users.* FROM posts 要執行此查詢,資料庫將使用 `follows` 表找到 `current_user` 關注的所有人,查詢這些使用者最近的帖子,並按時間戳排序以獲取被關注使用者的最新 1,000 條帖子。 -帖子具有時效性。我們假設:某人發帖後,追隨者應在 5 秒內看到。一個做法是客戶端每 5 秒重複執行一次上述查詢(即 *輪詢*)。如果同時線上登入使用者有 1000 萬,就意味著每秒要執行 200 萬次查詢。即使把輪詢間隔調大,這個量也很可觀。 +帖子具有時效性。我們假設:某人發帖後,追隨者應在 5 秒內看到。一個做法是客戶端每 5 秒重複執行一次上述查詢(即 **輪詢**)。如果同時線上登入使用者有 1000 萬,就意味著每秒要執行 200 萬次查詢。即使把輪詢間隔調大,這個量也很可觀。 此外,這個查詢本身也很昂貴。若你追隨 200 人,系統就要分別抓取這 200 人的近期帖子列表,再把它們歸併。每秒 200 萬次時間線查詢,等價於資料庫每秒要執行約 4 億次“按傳送者查最近帖子”。這還只是平均情況。少數使用者會追隨數萬賬戶,這個查詢對他們尤其昂貴,也更難做快。 @@ -62,7 +62,7 @@ SELECT posts.*, users.* FROM posts 設想我們為每個使用者維護一個數據結構,儲存其首頁時間線,也就是其所追隨者的近期帖子。每當使用者發帖,我們就找出其所有追隨者,把這條帖子插入每個追隨者的首頁時間線中,就像往郵箱裡投遞信件。這樣使用者登入時,可以直接讀取預先算好的時間線。若要接收新帖提醒,客戶端只需訂閱“寫入該時間線”的帖子流即可。 -這種方法的缺點是:每次發帖時都要做更多工作,因為首頁時間線屬於需要持續更新的派生資料。這個過程見 [圖 2-2](#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" >}} @@ -70,7 +70,7 @@ SELECT posts.*, users.* FROM posts 如果遇到特殊事件導致發帖速率激增,我們不必立刻完成時間線投遞。可以先入隊,接受“帖子出現在追隨者時間線中”會暫時變慢。即便在這種峰值期,時間線載入仍然很快,因為讀取仍來自快取。 -這種預先計算並持續更新查詢結果的過程稱為 *物化*。時間線快取就是一種 *物化檢視*(這個概念見 [“維護物化檢視”](/tw/ch12#sec_stream_mat_view))。物化檢視能加速讀取,但代價是寫入側工作量增加。對大多數使用者而言,這個寫入成本仍可接受,但社交網路還要處理一些極端情況: +這種預先計算並持續更新查詢結果的過程稱為 **物化**。時間線快取就是一種 **物化檢視**(這個概念見 [“維護物化檢視”](/tw/ch12#sec_stream_mat_view))。物化檢視能加速讀取,但代價是寫入側工作量增加。對大多數使用者而言,這個寫入成本仍可接受,但社交網路還要處理一些極端情況: * 如果某使用者追隨了大量賬戶,且這些賬戶發帖頻繁,那麼該使用者的物化時間線寫入率會很高。但在這種場景下,使用者通常也看不完全部帖子,因此可以丟棄部分時間線寫入,只展示其追隨賬戶帖子的一部分樣本 [^5]。 * 如果一個擁有海量追隨者的名人賬號發帖,我們需要把這條帖子寫入其數百萬追隨者的首頁時間線,工作量極大。此時不能隨意丟寫。常見做法是把名人帖子與普通帖子分開處理:名人帖單獨儲存,讀取時間線時再與物化時間線合併,從而省去寫入數百萬條時間線的成本。即便如此,服務名人賬號仍需大量基礎設施 [^6]。 @@ -83,11 +83,11 @@ SELECT posts.*, users.* FROM posts : 從使用者發出請求到收到響應所經歷的時間。單位是秒(或毫秒、微秒)。 吞吐量 -: 系統每秒可處理的請求數或資料量。對於給定硬體資源,系統存在一個可處理的 *最大吞吐量*。單位是“每秒某種工作量”。 +: 系統每秒可處理的請求數或資料量。對於給定硬體資源,系統存在一個可處理的 **最大吞吐量**。單位是“每秒某種工作量”。 在社交網路案例中,“每秒帖子數”和“每秒時間線寫入數”屬於吞吐量指標;“載入首頁時間線所需時間”或“帖子送達追隨者所需時間”屬於響應時間指標。 -吞吐量和響應時間之間通常相關。線上服務的典型關係如 [圖 2-3](#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" >}} @@ -97,13 +97,13 @@ SELECT posts.*, users.* FROM posts > [!TIP] 當過載系統無法恢復時 -如果系統已接近過載、吞吐量逼近極限,有時會進入惡性迴圈:效率下降,進而更加過載。例如,請求佇列很長時,響應時間可能高到讓客戶端超時並重發請求,導致請求速率進一步上升,問題持續惡化,形成 *重試風暴*。即使負載後來回落,系統也可能仍卡在過載狀態,直到重啟或重置。這種現象叫 *亞穩態故障*(Metastable Failure),可能引發嚴重生產故障 [^7] [^8]。 +如果系統已接近過載、吞吐量逼近極限,有時會進入惡性迴圈:效率下降,進而更加過載。例如,請求佇列很長時,響應時間可能高到讓客戶端超時並重發請求,導致請求速率進一步上升,問題持續惡化,形成 **重試風暴**。即使負載後來回落,系統也可能仍卡在過載狀態,直到重啟或重置。這種現象叫 **亞穩態故障**(Metastable Failure),可能引發嚴重生產故障 [^7] [^8]。 -為了避免重試把服務拖垮,可以在客戶端拉大並隨機化重試間隔(*指數退避* [^9] [^10]),並臨時停止向近期報錯或超時的服務發請求(例如 *熔斷器* [^11] [^12] 或 *令牌桶* [^13])。服務端也可在接近過載時主動拒絕請求(*負載卸除* [^14]),並透過響應要求客戶端降速(*背壓* [^1] [^15])。此外,排隊與負載均衡演算法的選擇也會影響結果 [^16]。 +為了避免重試把服務拖垮,可以在客戶端拉大並隨機化重試間隔(**指數退避** [^9] [^10]),並臨時停止向近期報錯或超時的服務發請求(例如 **熔斷器** [^11] [^12] 或 **令牌桶** [^13])。服務端也可在接近過載時主動拒絕請求(**負載卸除** [^14]),並透過響應要求客戶端降速(**背壓** [^1] [^15])。此外,排隊與負載均衡演算法的選擇也會影響結果 [^16]。 -------- -從效能指標角度看,使用者通常最關心響應時間;而吞吐量決定了所需計算資源(例如伺服器數量),從而決定承載特定工作負載的成本。如果吞吐量增長可能超過當前硬體上限,就必須擴容;若系統可透過增加計算資源顯著提升最大吞吐量,就稱其 *可伸縮*。 +從效能指標角度看,使用者通常最關心響應時間;而吞吐量決定了所需計算資源(例如伺服器數量),從而決定承載特定工作負載的成本。如果吞吐量增長可能超過當前硬體上限,就必須擴容;若系統可透過增加計算資源顯著提升最大吞吐量,就稱其 **可伸縮**。 本節主要討論響應時間;吞吐量與可伸縮性會在 ["可伸縮性"](#sec_introduction_scalability) 一節再展開。 @@ -111,10 +111,10 @@ SELECT posts.*, users.* FROM posts “延遲”和“響應時間”有時會混用,但本書對它們有明確區分(見 [圖 2-4](#fig_response_time)): -* *響應時間* 是客戶端看到的總時間,包含鏈路上各處產生的全部延遲。 -* *服務時間* 是服務主動處理該請求的時間。 -* *排隊延遲* 可發生在流程中的多個位置。例如請求到達後,可能要等 CPU 空出來才能處理;同機其他任務若佔滿出站網絡卡,響應包也可能先在緩衝區等待發送。 -* *延遲* 是對“請求未被主動處理這段時間”的統稱,也就是請求處於 *潛伏(latent)* 狀態的時間。尤其是 *網路延遲*(或網路時延)指請求與響應在網路中傳播所花的時間。 +* **響應時間** 是客戶端看到的總時間,包含鏈路上各處產生的全部延遲。 +* **服務時間** 是服務主動處理該請求的時間。 +* **排隊延遲** 可發生在流程中的多個位置。例如請求到達後,可能要等 CPU 空出來才能處理;同機其他任務若佔滿出站網絡卡,響應包也可能先在緩衝區等待發送。 +* **延遲** 是對“請求未被主動處理這段時間”的統稱,也就是請求處於 **潛伏(latent)** 狀態的時間。尤其是 **網路延遲**(或網路時延)指請求與響應在網路中傳播所花的時間。 {{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="圖 2-4. 響應時間、服務時間、網路延遲和排隊延遲。" class="w-full my-4" >}} @@ -122,21 +122,21 @@ SELECT posts.*, users.* FROM posts 即便反覆傳送同一個請求,響應時間也可能顯著波動。許多因素都會引入隨機延遲:例如切換到後臺程序、網路丟包與 TCP 重傳、垃圾回收暫停、缺頁導致的磁碟讀取、伺服器機架機械振動 [^17] 等。我們會在 ["超時與無界延遲"](/tw/ch9#sec_distributed_queueing) 進一步討論這個問題。 -排隊延遲常常是響應時間波動的主要來源。伺服器並行處理能力有限(例如受 CPU 核數約束),少量慢請求就可能堵住後續請求,這就是 *頭部阻塞*。即便後續請求本身服務時間很短,客戶端仍會因為等待前序請求而看到較慢的總體響應。排隊延遲不屬於服務時間,因此必須在客戶端側測量響應時間。 +排隊延遲常常是響應時間波動的主要來源。伺服器並行處理能力有限(例如受 CPU 核數約束),少量慢請求就可能堵住後續請求,這就是 **頭部阻塞**。即便後續請求本身服務時間很短,客戶端仍會因為等待前序請求而看到較慢的總體響應。排隊延遲不屬於服務時間,因此必須在客戶端側測量響應時間。 ### 平均值、中位數與百分位點 {#id24} -由於響應時間會隨請求變化,我們應將其看作一個可測量的 *分佈*,而非單一數字。在 [圖 2-5](#fig_lognormal) 中,每個灰色柱表示一次請求,柱高是該請求耗時。大多數請求較快,但會有少量更慢的 *異常值*。網路時延波動也常稱為 *抖動*。 +由於響應時間會隨請求變化,我們應將其看作一個可測量的 **分佈**,而非單一數字。在 [圖 2-5](#fig_lognormal) 中,每個灰色柱表示一次請求,柱高是該請求耗時。大多數請求較快,但會有少量更慢的 **異常值**。網路時延波動也常稱為 **抖動**。 {{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="圖 2-5. 說明平均值和百分位點:100 個服務請求的響應時間樣本。" class="w-full my-4" >}} -報告服務 *平均* 響應時間很常見(嚴格說是 *算術平均值*:總響應時間除以請求數)。平均值對估算吞吐量上限有幫助 [^18]。但若你想知道“典型”響應時間,平均值並不理想,因為它不能反映到底有多少使用者經歷了這種延遲。 +報告服務 **平均** 響應時間很常見(嚴格說是 **算術平均值**:總響應時間除以請求數)。平均值對估算吞吐量上限有幫助 [^18]。但若你想知道“典型”響應時間,平均值並不理想,因為它不能反映到底有多少使用者經歷了這種延遲。 -通常,*百分位點* 更有意義。把響應時間從快到慢排序,*中位數* 位於中間。例如中位響應時間為 200 毫秒,表示一半請求在 200 毫秒內返回,另一半更慢。因此中位數適合衡量使用者“通常要等多久”。中位數也稱 *第 50 百分位*,常記為 *p50*。 +通常,**百分位點** 更有意義。把響應時間從快到慢排序,**中位數** 位於中間。例如中位響應時間為 200 毫秒,表示一半請求在 200 毫秒內返回,另一半更慢。因此中位數適合衡量使用者“通常要等多久”。中位數也稱 **第 50 百分位**,常記為 **p50**。 -為了看清異常值有多糟,需要觀察更高百分位點:常見的是 *p95*、*p99*、*p999*。它們表示 95%、99%、99.9% 的請求都快於該閾值。例如 p95 為 1.5 秒,表示 100 個請求裡有 95 個小於 1.5 秒,另外 5 個不小於 1.5 秒。[圖 2-5](#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]。讓這批使用者也能獲得快速響應,對業務很關鍵。 +響應時間的高百分位點(也叫 **尾部延遲**)非常重要,因為它直接影響使用者體驗。例如亞馬遜內部服務常以第 99.9 百分位設定響應要求,儘管它只影響 1/1000 的請求。原因是最慢請求往往來自“賬戶資料最多”的客戶,他們通常也是最有價值客戶 [^19]。讓這批使用者也能獲得快速響應,對業務很關鍵。 另一方面,繼續最佳化到第 99.99 百分位(最慢的萬分之一請求)通常成本過高、收益有限。越到高百分位,越容易受不可控隨機因素影響,也更符合邊際收益遞減規律。 @@ -156,11 +156,11 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 ### 響應時間指標的應用 {#sec_introduction_slo_sla} -對於“一個終端請求會觸發多次後端呼叫”的服務,高百分位點尤其關鍵。即使並行呼叫,終端請求仍要等待最慢的那個返回。正如 [圖 2-6](#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" >}} -百分位點也常用於定義 *服務級別目標*(SLO)和 *服務級別協議*(SLA)[^27]。例如,一個 SLO 可能要求:中位響應時間低於 200 毫秒、p99 低於 1 秒,並且至少 99.9% 的有效請求返回非錯誤響應。SLA 則是“未達成 SLO 時如何處理”的合同條款(例如客戶可獲賠償)。這是基本思路;但在實踐中,為 SLO/SLA 設計合理可用性指標並不容易 [^28] [^29]。 +百分位點也常用於定義 **服務級別目標**(SLO)和 **服務級別協議**(SLA)[^27]。例如,一個 SLO 可能要求:中位響應時間低於 200 毫秒、p99 低於 1 秒,並且至少 99.9% 的有效請求返回非錯誤響應。SLA 則是“未達成 SLO 時如何處理”的合同條款(例如客戶可獲賠償)。這是基本思路;但在實踐中,為 SLO/SLA 設計合理可用性指標並不容易 [^28] [^29]。 -------- @@ -183,25 +183,25 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 * 在預期負載與資料規模下,效能足以支撐目標用例。 * 能防止未授權訪問與濫用。 -如果把這些合起來稱為“正確工作”,那麼 *可靠性* 可以粗略理解為:即使出現問題,系統仍能持續正確工作。為了更精確地描述“出問題”,我們區分 *故障* 與 *失效* [^35] [^36] [^37]: +如果把這些合起來稱為“正確工作”,那麼 **可靠性** 可以粗略理解為:即使出現問題,系統仍能持續正確工作。為了更精確地描述“出問題”,我們區分 **故障** 與 **失效** [^35] [^36] [^37]: 故障 -: 指系統某個 *區域性元件* 停止正常工作:例如單個硬碟損壞、單臺機器宕機,或系統依賴的外部服務中斷。 +: 指系統某個 **區域性元件** 停止正常工作:例如單個硬碟損壞、單臺機器宕機,或系統依賴的外部服務中斷。 失效 -: 指 *整個系統* 無法繼續向用戶提供所需服務;換言之,系統未滿足服務級別目標(SLO)。 +: 指 **整個系統** 無法繼續向用戶提供所需服務;換言之,系統未滿足服務級別目標(SLO)。 “故障”與“失效”的區別容易混淆,因為它們本質上是同一件事在不同層級上的表述。比如一個硬碟壞了,對“硬碟這個系統”來說是失效;但對“由許多硬碟組成的更大系統”來說,它只是一個故障。更大系統若在其他硬碟上有副本,就可能容忍該故障。 ### 容錯 {#id27} -如果系統在發生某些故障時仍繼續向用戶提供所需的服務,我們稱系統為 *容錯的*。如果系統不能容忍某個部分變得有故障,我們稱該部分為 *單點故障*(SPOF),因為該部分的故障會升級導致整個系統的失效。 +如果系統在發生某些故障時仍繼續向用戶提供所需的服務,我們稱系統為 **容錯的**。如果系統不能容忍某個部分變得有故障,我們稱該部分為 **單點故障**(SPOF),因為該部分的故障會升級導致整個系統的失效。 -例如在社交網路案例中,扇出流程裡可能有機器崩潰或不可用,導致物化時間線更新中斷。若要讓該流程具備容錯性,就必須保證有其他機器可接管任務,同時既不漏投帖子,也不重複投遞。(這個思想稱為 *恰好一次語義*,我們會在 [“資料庫的端到端論證”](/tw/ch13#sec_future_end_to_end) 中詳細討論。) +例如在社交網路案例中,扇出流程裡可能有機器崩潰或不可用,導致物化時間線更新中斷。若要讓該流程具備容錯性,就必須保證有其他機器可接管任務,同時既不漏投帖子,也不重複投遞。(這個思想稱為 **恰好一次語義**,我們會在 [“資料庫的端到端論證”](/tw/ch13#sec_future_end_to_end) 中詳細討論。) 容錯能力總是“有邊界”的:它只針對某些型別、某個數量以內的故障。例如系統可能最多容忍 2 塊硬碟同時故障,或 3 個節點裡壞 1 個。若全部節點都崩潰,就無計可施,因此“容忍任意數量故障”並無意義。要是地球和上面的伺服器都被黑洞吞噬,那就只能去太空託管了,預算審批祝你好運。 -反直覺的是,在這類系統裡,故意 *提高* 故障發生率反而有意義,例如無預警隨機殺死某個程序。這叫 *故障注入*。許多關鍵故障本質上是錯誤處理做得不夠好 [^38]。透過主動注入故障,可以持續演練並驗證容錯機制,提升對“真實故障發生時系統仍能正確處理”的信心。*混沌工程* 就是圍繞這類實驗建立起來的方法論 [^39]。 +反直覺的是,在這類系統裡,故意 **提高** 故障發生率反而有意義,例如無預警隨機殺死某個程序。這叫 **故障注入**。許多關鍵故障本質上是錯誤處理做得不夠好 [^38]。透過主動注入故障,可以持續演練並驗證容錯機制,提升對“真實故障發生時系統仍能正確處理”的信心。**混沌工程** 就是圍繞這類實驗建立起來的方法論 [^39]。 儘管我們通常更傾向於“容忍故障”,而非“阻止故障”,但也有“預防優於補救”的場景(例如根本無法補救)。安全問題就是如此:若攻擊者已攻破系統並獲取敏感資料,事件本身無法撤銷。不過,本書主要討論的是可恢復的故障型別。 @@ -224,11 +224,11 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 當元件故障獨立時,冗餘最有效,即一個故障的發生不會改變另一個故障發生的可能性。然而,經驗表明,元件故障之間通常存在顯著的相關性 [^41] [^57] [^58];整個伺服器機架或整個資料中心的不可用仍然比我們預期的更頻繁地發生。 -硬體冗餘確實能提升單機可用時間;但正如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 所述,分散式系統還具備額外優勢,例如可容忍整個資料中心中斷。因此雲系統通常不再過分追求“單機極致可靠”,而是透過軟體層容忍節點故障來實現高可用。雲廠商使用 *可用區* 標識資源是否物理共址;同一可用區內資源比跨地域資源更容易同時失效。 +硬體冗餘確實能提升單機可用時間;但正如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 所述,分散式系統還具備額外優勢,例如可容忍整個資料中心中斷。因此雲系統通常不再過分追求“單機極致可靠”,而是透過軟體層容忍節點故障來實現高可用。雲廠商使用 **可用區** 標識資源是否物理共址;同一可用區內資源比跨地域資源更容易同時失效。 我們在本書中討論的容錯技術旨在容忍整個機器、機架或可用區的丟失。它們通常透過允許一個數據中心的機器在另一個數據中心的機器發生故障或變得不可達時接管來工作。我們將在 [第 6 章](/tw/ch6)、[第 10 章](/tw/ch10) 以及本書的其他各個地方討論這種容錯技術。 -能夠容忍整個機器丟失的系統也具有運營優勢:如果你需要重新啟動機器(例如,應用作業系統安全補丁),單伺服器系統需要計劃停機時間,而多節點容錯系統可以一次修補一個節點,而不影響使用者的服務。這稱為 *滾動升級*,我們將在 [第 5 章](/tw/ch5) 中進一步討論它。 +能夠容忍整個機器丟失的系統也具有運營優勢:如果你需要重新啟動機器(例如,應用作業系統安全補丁),單伺服器系統需要計劃停機時間,而多節點容錯系統可以一次修補一個節點,而不影響使用者的服務。這稱為 **滾動升級**,我們將在 [第 5 章](/tw/ch5) 中進一步討論它。 #### 軟體故障 {#software-faults} @@ -250,11 +250,11 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 遇到這類問題,人們很容易歸咎於“人為錯誤”,並試圖透過更嚴格流程和更強規則約束來控制人。但“責怪個人”通常適得其反。所謂“人為錯誤”往往不是事故根因,而是社會技術系統本身存在問題的徵兆 [^71]。複雜系統裡,元件意外互動產生的湧現行為也常導致故障 [^72]。 -有多種技術手段可降低人為失誤的影響:充分測試(含手寫測試與大量隨機輸入的 *屬性測試*)[^38]、可快速回滾配置變更的機制、新程式碼漸進發布、清晰細緻的監控、用於排查生產問題的可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems)),以及鼓勵“正確操作”並抑制“錯誤操作”的良好介面設計。 +有多種技術手段可降低人為失誤的影響:充分測試(含手寫測試與大量隨機輸入的 **屬性測試**)[^38]、可快速回滾配置變更的機制、新程式碼漸進發布、清晰細緻的監控、用於排查生產問題的可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems)),以及鼓勵“正確操作”並抑制“錯誤操作”的良好介面設計。 但這些措施都需要時間和預算。在日常業務壓力下,組織往往優先投入“直接創收”活動,而非提升抗錯韌性的建設。若在“更多功能”和“更多測試”之間二選一,很多組織會自然選擇前者。既然如此,當可預防錯誤最終發生時,責怪個人並無意義,問題本質在於組織的優先順序選擇。 -越來越多組織在實踐 *無責備事後分析*:事故發生後,鼓勵參與者在不擔心懲罰的前提下完整覆盤細節,讓組織其他人也能學習如何避免類似問題 [^73]。這個過程常會揭示出:業務優先順序需要調整、某些長期被忽視的領域需要補投入、相關激勵機制需要改,或其他應由管理層關注的系統性問題。 +越來越多組織在實踐 **無責備事後分析**:事故發生後,鼓勵參與者在不擔心懲罰的前提下完整覆盤細節,讓組織其他人也能學習如何避免類似問題 [^73]。這個過程常會揭示出:業務優先順序需要調整、某些長期被忽視的領域需要補投入、相關激勵機制需要改,或其他應由管理層關注的系統性問題。 一般來說,調查事故時應警惕“過於簡單”的答案。“鮑勃部署時應更小心”沒有建設性,“我們必須用 Haskell 重寫後端”同樣不是。更可行的做法是:管理層藉機從一線人員視角理解社會技術系統的真實執行方式,並據此推動改進 [^71]。 @@ -278,7 +278,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 即便系統今天執行可靠,也不代表將來一定如此。效能退化的常見原因之一是負載增長:比如併發使用者從 1 萬漲到 10 萬,或從 100 萬漲到 1000 萬;也可能是處理的資料規模遠大於從前。 -*可伸縮性* 用來描述系統應對負載增長的能力。討論這個話題時,常有人說:“你又不是 Google/Amazon,別擔心規模,直接上關係資料庫。”這句話是否成立,取決於你在做什麼型別的應用。 +**可伸縮性** 用來描述系統應對負載增長的能力。討論這個話題時,常有人說:“你又不是 Google/Amazon,別擔心規模,直接上關係資料庫。”這句話是否成立,取決於你在做什麼型別的應用。 如果你在做一個目前使用者很少的新產品(例如創業早期),首要工程目標通常是“儘可能簡單、儘可能靈活”,以便隨著對使用者需求理解加深而快速調整產品功能 [^78]。在這種環境下,過早擔心“未來也許會有”的規模往往適得其反:最好情況是白費功夫、過早最佳化;最壞情況是把自己鎖進僵化設計,反而阻礙演進。 @@ -303,17 +303,17 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 通常目標是:在儘量降低執行成本的同時,讓效能維持在 SLA 要求內(參見 ["響應時間指標的應用"](#sec_introduction_slo_sla))。所需計算資源越多,成本越高。不同硬體的價效比不同,而且會隨著新硬體出現而變化。 -如果資源翻倍後能承載兩倍負載且效能不變,這稱為 *線性可伸縮性*,通常是理想狀態。偶爾,藉助規模效應或峰值負載更均勻分佈,甚至可用不足兩倍資源處理兩倍負載 [^79] [^80]。但更常見的是成本增長快於線性,低效原因也很多。比如資料量增大後,即使請求大小相同,處理一次寫請求也可能比資料量小時更耗資源。 +如果資源翻倍後能承載兩倍負載且效能不變,這稱為 **線性可伸縮性**,通常是理想狀態。偶爾,藉助規模效應或峰值負載更均勻分佈,甚至可用不足兩倍資源處理兩倍負載 [^79] [^80]。但更常見的是成本增長快於線性,低效原因也很多。比如資料量增大後,即使請求大小相同,處理一次寫請求也可能比資料量小時更耗資源。 ### 共享記憶體、共享磁碟與無共享架構 {#sec_introduction_shared_nothing} -增加服務硬體資源的最簡單方式,是遷移到更強的機器。雖然單核 CPU 不再明顯提速,但你仍可購買(或租用)擁有更多 CPU 核心、更多 RAM、更多磁碟的例項。這叫 *縱向伸縮*(scaling up)。 +增加服務硬體資源的最簡單方式,是遷移到更強的機器。雖然單核 CPU 不再明顯提速,但你仍可購買(或租用)擁有更多 CPU 核心、更多 RAM、更多磁碟的例項。這叫 **縱向伸縮**(scaling up)。 -在單機上,你可以透過多程序/多執行緒獲得並行性。同一程序內執行緒共享同一塊 RAM,因此這也叫 *共享記憶體架構*。問題是它的成本常常“超線性增長”:硬體資源翻倍的高階機器,價格往往遠超兩倍;且受限於瓶頸,效能提升通常又達不到兩倍。 +在單機上,你可以透過多程序/多執行緒獲得並行性。同一程序內執行緒共享同一塊 RAM,因此這也叫 **共享記憶體架構**。問題是它的成本常常“超線性增長”:硬體資源翻倍的高階機器,價格往往遠超兩倍;且受限於瓶頸,效能提升通常又達不到兩倍。 -另一種方案是 *共享磁碟架構*:多臺機器各有獨立 CPU 和 RAM,但共享同一組磁碟陣列,透過高速網路連線(NAS 或 SAN)。該架構傳統上用於本地資料倉庫場景,但爭用與鎖開銷限制了其可伸縮性 [^81]。 +另一種方案是 **共享磁碟架構**:多臺機器各有獨立 CPU 和 RAM,但共享同一組磁碟陣列,透過高速網路連線(NAS 或 SAN)。該架構傳統上用於本地資料倉庫場景,但爭用與鎖開銷限制了其可伸縮性 [^81]。 -相比之下,*無共享架構* [^82](即 *橫向伸縮*、scaling out)已廣泛流行。這種方案使用多節點分散式系統,每個節點擁有自己的 CPU、RAM 和磁碟;節點間協作透過常規網路在軟體層完成。 +相比之下,**無共享架構** [^82](即 **橫向伸縮**、scaling out)已廣泛流行。這種方案使用多節點分散式系統,每個節點擁有自己的 CPU、RAM 和磁碟;節點間協作透過常規網路在軟體層完成。 無共享的優勢在於:具備線性伸縮潛力、可靈活選用高性價比硬體(尤其在雲上)、更容易隨負載增減調整資源,並可透過跨多個數據中心/地域部署提升容錯。代價是:需要顯式分片(見 [第 7 章](/tw/ch7)),並承擔分散式系統的全部複雜性(見 [第 9 章](/tw/ch9))。 @@ -321,7 +321,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 ### 可伸縮性原則 {#id35} -能夠大規模執行的系統架構,通常高度依賴具體應用,不存在通用“一招鮮”的可伸縮架構(俗稱 *萬金油*)。例如:面向“每秒 10 萬次請求、每次 1 kB”的系統,與面向“每分鐘 3 次請求、每次 2 GB”的系統,形態會完全不同,儘管二者資料吞吐量都約為 100 MB/s。 +能夠大規模執行的系統架構,通常高度依賴具體應用,不存在通用“一招鮮”的可伸縮架構(俗稱 **萬金油**)。例如:面向“每秒 10 萬次請求、每次 1 kB”的系統,與面向“每分鐘 3 次請求、每次 2 GB”的系統,形態會完全不同,儘管二者資料吞吐量都約為 100 MB/s。 此外,適合某一級負載的架構,通常難以直接承受 10 倍負載。若你在做高速增長服務,幾乎每跨一個數量級都要重新審視架構。考慮到業務需求本身也會變化,提前規劃超過一個數量級的未來伸縮需求,往往不划算。 @@ -335,7 +335,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 業界普遍認同:軟體成本的大頭不在初始開發,而在後續維護,包括修 bug、保障系統穩定執行、排查故障、適配新平臺、支援新場景、償還技術債,以及持續交付新功能 [^85] [^86]。 -然而維護並不容易。一個長期執行成功的系統,可能仍依賴今天少有人熟悉的舊技術(如大型機和 COBOL);隨著人員流動,系統為何如此設計的組織記憶也可能丟失;維護者往往還要修復前人留下的問題。更重要的是,計算機系統通常與其支撐的組織流程深度耦合,這使得 *遺留* 系統維護既是技術問題,也是人員與組織問題 [^87]。 +然而維護並不容易。一個長期執行成功的系統,可能仍依賴今天少有人熟悉的舊技術(如大型機和 COBOL);隨著人員流動,系統為何如此設計的組織記憶也可能丟失;維護者往往還要修復前人留下的問題。更重要的是,計算機系統通常與其支撐的組織流程深度耦合,這使得 **遺留** 系統維護既是技術問題,也是人員與組織問題 [^87]。 如果今天構建的系統足夠有價值並長期存活,它終有一天會變成遺留系統。為減少後繼維護者的痛苦,我們應在設計階段就考慮維護性。雖然難以準確預判哪些決策會在未來埋雷,但本書會強調幾條廣泛適用的原則: @@ -367,7 +367,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 ### 簡單性:管理複雜度 {#id38} -小型專案往往能保持簡潔、優雅、富有表達力;但專案變大後,程式碼常會迅速變複雜且難理解。這種複雜性會拖慢所有參與者效率,進一步抬高維護成本。陷入這種狀態的軟體專案常被稱為 *大泥團* [^91]。 +小型專案往往能保持簡潔、優雅、富有表達力;但專案變大後,程式碼常會迅速變複雜且難理解。這種複雜性會拖慢所有參與者效率,進一步抬高維護成本。陷入這種狀態的軟體專案常被稱為 **大泥團** [^91]。 當複雜性讓維護變難時,預算和進度常常失控。在複雜軟體裡,變更時引入缺陷的風險也更高:系統越難理解和推理,隱藏假設、非預期後果和意外互動就越容易被忽略 [^69]。反過來,降低複雜性能顯著提升可維護性,因此“追求簡單”應是系統設計核心目標之一。 @@ -377,17 +377,17 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 管理複雜度最重要的工具之一是 **抽象**。好的抽象能在清晰外觀後隱藏大量實現細節,也能被多種場景複用。這種複用不僅比反覆重寫更高效,也能提升質量,因為抽象元件一旦改進,所有依賴它的應用都會受益。 -例如,高階語言是對機器碼、CPU 暫存器和系統呼叫的抽象。SQL 則抽象了磁碟/記憶體中的複雜資料結構、來自其他客戶端的併發請求,以及崩潰後的不一致狀態。用高階語言程式設計時,我們仍然在“使用機器碼”,但不再 *直接* 面對它,因為語言抽象替我們遮蔽了細節。 +例如,高階語言是對機器碼、CPU 暫存器和系統呼叫的抽象。SQL 則抽象了磁碟/記憶體中的複雜資料結構、來自其他客戶端的併發請求,以及崩潰後的不一致狀態。用高階語言程式設計時,我們仍然在“使用機器碼”,但不再 **直接** 面對它,因為語言抽象替我們遮蔽了細節。 -應用程式碼層面的抽象,常藉助 *設計模式* [^95]、*領域驅動設計*(DDD)[^96] 等方法來構建。本書重點不在這類應用專用抽象,而在你可以拿來構建應用的通用抽象,例如資料庫事務、索引、事件日誌等。若你想採用 DDD 等方法,也可以建立在本書介紹的基礎能力之上。 +應用程式碼層面的抽象,常藉助 **設計模式** [^95]、**領域驅動設計**(DDD)[^96] 等方法來構建。本書重點不在這類應用專用抽象,而在你可以拿來構建應用的通用抽象,例如資料庫事務、索引、事件日誌等。若你想採用 DDD 等方法,也可以建立在本書介紹的基礎能力之上。 ### 可演化性:讓變化更容易 {#sec_introduction_evolvability} 系統需求永遠不變的機率極低。更常見的是持續變化:你會發現新事實,出現此前未預期用例,業務優先順序會調整,使用者會提出新功能,新平臺會替換舊平臺,法律與監管會變化,系統增長也會倒逼架構調整。 -在組織層面,*敏捷* 方法為適應變化提供了框架;敏捷社群也發展出多種適用於高變化環境的技術與流程,如測試驅動開發(TDD)和重構。本書關注的是:如何在“由多個不同應用/服務組成的系統層級”提升這種敏捷能力。 +在組織層面,**敏捷** 方法為適應變化提供了框架;敏捷社群也發展出多種適用於高變化環境的技術與流程,如測試驅動開發(TDD)和重構。本書關注的是:如何在“由多個不同應用/服務組成的系統層級”提升這種敏捷能力。 -資料系統對變化的適應難易度,與其簡單性和抽象質量高度相關:松耦合、簡單系統通常比緊耦合、複雜系統更容易修改。由於這一點極其重要,我們把“資料系統層面的敏捷性”單獨稱為 *可演化性* [^97]。 +資料系統對變化的適應難易度,與其簡單性和抽象質量高度相關:松耦合、簡單系統通常比緊耦合、複雜系統更容易修改。由於這一點極其重要,我們把“資料系統層面的敏捷性”單獨稱為 **可演化性** [^97]。 大型系統中讓變更困難的一個關鍵因素,是某些操作不可逆,因此執行時必須極其謹慎 [^98]。例如從一個數據庫遷移到另一個:若新庫出問題後無法回切,風險就遠高於可隨時回退。儘量減少不可逆操作,能顯著提升系統靈活性。 @@ -397,7 +397,7 @@ Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載 我們討論了如何衡量效能(例如響應時間百分位點)、如何描述系統負載(例如吞吐量指標),以及這些指標如何進入 SLA。與之緊密相關的是可伸縮性:當負載增長時,如何保持效能不退化。我們也給出了若干通用原則,例如將任務拆解為可獨立執行的小元件。後續章節會深入展開相關技術細節。 -為實現可靠性,可以使用容錯機制,使系統在部分元件(如磁碟、機器或外部服務)故障時仍能持續提供服務。我們區分了硬體故障與軟體故障,並指出軟體故障常更難處理,因為它們往往高度相關。可靠性的另一面是“對人為失誤的韌性”,其中 *無責備事後分析* 是重要學習機制。 +為實現可靠性,可以使用容錯機制,使系統在部分元件(如磁碟、機器或外部服務)故障時仍能持續提供服務。我們區分了硬體故障與軟體故障,並指出軟體故障常更難處理,因為它們往往高度相關。可靠性的另一面是“對人為失誤的韌性”,其中 **無責備事後分析** 是重要學習機制。 最後,我們討論了可維護性的多個維度:支援運維工作、管理複雜度、提升系統可演化性。實現這些目標沒有銀彈,但一個普遍有效的做法是:用清晰、可理解、具備良好抽象的構件來搭建系統。接下來全書會介紹一系列在實踐中證明有效的構件。 diff --git a/content/tw/ch3.md b/content/tw/ch3.md index ed96cbc..daa36fb 100644 --- a/content/tw/ch3.md +++ b/content/tw/ch3.md @@ -58,7 +58,7 @@ breadcrumbs: false 在 2010 年代,**NoSQL** 是試圖推翻關係資料庫主導地位的最新流行詞。 NoSQL 指的不是單一技術,而是圍繞新資料模型、模式靈活性、可伸縮性以及向開源許可模式轉變的一系列鬆散的想法。 -一些資料庫將自己標榜為 *NewSQL*,因為它們旨在提供 NoSQL 系統的可伸縮性以及傳統關係資料庫的資料模型和事務保證。 +一些資料庫將自己標榜為 **NewSQL**,因為它們旨在提供 NoSQL 系統的可伸縮性以及傳統關係資料庫的資料模型和事務保證。 NoSQL 和 NewSQL 的想法在資料系統設計中產生了很大的影響,但隨著這些原則被廣泛採用,這些術語的使用已經減少。 NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將資料表示為 JSON。 @@ -69,7 +69,7 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將 ### 物件關係不匹配 {#sec_datamodels_document} -如今,大部分應用程式開發都是使用物件導向的程式語言完成的,這導致了對 SQL 資料模型的常見批評:如果資料儲存在關係表中,則需要在應用程式程式碼中的物件和資料庫的表、行、列模型之間建立一個笨拙的轉換層。這種模型之間的脫節有時被稱為 *阻抗不匹配*。 +如今,大部分應用程式開發都是使用物件導向的程式語言完成的,這導致了對 SQL 資料模型的常見批評:如果資料儲存在關係表中,則需要在應用程式程式碼中的物件和資料庫的表、行、列模型之間建立一個笨拙的轉換層。這種模型之間的脫節有時被稱為 **阻抗不匹配**。 -------- @@ -86,7 +86,7 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將 * ORM 通常僅用於 OLTP 應用程式開發(參見 ["表徵事務處理和分析"](/tw/ch1#sec_introduction_oltp));為分析目的提供資料的資料工程師仍然需要使用底層的關係表示,因此在使用 ORM 時,關係模式的設計仍然很重要。 * 許多 ORM 僅適用於關係型 OLTP 資料庫。擁有多樣化資料系統(如搜尋引擎、圖資料庫和 NoSQL 系統)的組織可能會發現 ORM 支援不足。 * 一些 ORM 會自動生成關係模式,但這些模式對於直接訪問關係資料的使用者來說可能很尷尬,並且在底層資料庫上可能效率低下。自定義 ORM 的模式和查詢生成可能很複雜,並否定了首先使用 ORM 的好處。 -* ORM 使得意外編寫低效查詢變得容易,例如 *N+1 查詢問題* [^7]。例如,假設你想在頁面上顯示使用者評論列表,因此你執行一個返回 *N* 條評論的查詢,每條評論都包含其作者的 ID。要顯示評論作者的姓名,你需要在使用者表中查詢 ID。在手寫 SQL 中,你可能會在查詢中執行此連線並返回每個評論的作者姓名,但使用 ORM 時,你可能最終會為 *N* 條評論中的每一條在使用者表上進行單獨的查詢以查詢其作者,總共產生 *N*+1 個數據庫查詢,這比在資料庫中執行連線要慢。為了避免這個問題,你可能需要告訴 ORM 在獲取評論的同時獲取作者資訊。 +* ORM 使得意外編寫低效查詢變得容易,例如 **N+1 查詢問題** [^7]。例如,假設你想在頁面上顯示使用者評論列表,因此你執行一個返回 **N** 條評論的查詢,每條評論都包含其作者的 ID。要顯示評論作者的姓名,你需要在使用者表中查詢 ID。在手寫 SQL 中,你可能會在查詢中執行此連線並返回每個評論的作者姓名,但使用 ORM 時,你可能最終會為 **N** 條評論中的每一條在使用者表上進行單獨的查詢以查詢其作者,總共產生 **N**+1 個數據庫查詢,這比在資料庫中執行連線要慢。為了避免這個問題,你可能需要告訴 ORM 在獲取評論的同時獲取作者資訊。 然而,ORM 也有優勢: @@ -98,7 +98,7 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將 並非所有資料都很適合關係表示;讓我們透過一個例子來探討關係模型的侷限性。[圖 3-1](#fig_obama_relational) 說明了如何在關係模式中表達簡歷(LinkedIn 個人資料)。整個個人資料可以透過唯一識別符號 `user_id` 來識別。像 `first_name` 和 `last_name` 這樣的欄位每個使用者只出現一次,因此它們可以建模為 `users` 表上的列。 -大多數人在職業生涯中有多份工作(職位),人們可能有不同數量的教育經歷和任意數量的聯絡資訊。表示這種 *一對多關係* 的一種方法是將職位、教育和聯絡資訊放在單獨的表中,並使用外部索引鍵引用 `users` 表,如 [圖 3-1](#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" >}} @@ -131,7 +131,7 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將 一些開發人員認為 JSON 模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。然而,正如我們將在 [第 5 章](/tw/ch5#ch_encoding) 中看到的,JSON 作為資料編碼格式也存在問題。缺乏模式通常被認為是一個優勢;我們將在 ["文件模型中的模式靈活性"](#sec_datamodels_schema_flexibility) 中討論這個問題。 -與 [圖 3-1](#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 *區域性*(參見 ["讀寫的資料區域性"](#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](#fig_json_tree))。 @@ -156,11 +156,11 @@ NoSQL 運動的一個持久影響是 **文件模型** 的流行,它通常將 * 本地化支援 —— 當網站被翻譯成其他語言時,標準化列表可以被本地化,因此區域可以用檢視者的語言顯示 * 更好的搜尋 —— 例如,搜尋美國東海岸的人可以匹配此個人資料,因為區域列表可以編碼華盛頓位於東海岸的事實(這從字串 `"Washington, DC"` 中並不明顯) -無論你儲存 ID 還是文字字串,這都是 *正規化* 的問題。當你使用 ID 時,你的資料更加正規化:對人類有意義的資訊(如文字 *Washington, DC*)只儲存在一個地方,所有引用它的地方都使用 ID(它只在資料庫中有意義)。當你直接儲存文字時,你在使用它的每條記錄中都複製了對人類有意義的資訊;這種表示是 *反正規化* 的。 +無論你儲存 ID 還是文字字串,這都是 **正規化** 的問題。當你使用 ID 時,你的資料更加正規化:對人類有意義的資訊(如文字 **Washington, DC**)只儲存在一個地方,所有引用它的地方都使用 ID(它只在資料庫中有意義)。當你直接儲存文字時,你在使用它的每條記錄中都複製了對人類有意義的資訊;這種表示是 **反正規化** 的。 使用 ID 的優勢在於,因為它對人類沒有意義,所以永遠不需要更改:即使它標識的資訊發生變化,ID 也可以保持不變。任何對人類有意義的東西將來某個時候可能需要更改 —— 如果該資訊被複制,所有冗餘副本都需要更新。這需要更多的程式碼、更多的寫操作、更多的磁碟空間,並且存在不一致的風險(其中一些資訊副本被更新但其他的沒有)。 -正規化表示的缺點是,每次要顯示包含 ID 的記錄時,都必須進行額外的查詢以將 ID 解析為人類可讀的內容。在關係資料模型中,這是使用 *連線* 完成的,例如: +正規化表示的缺點是,每次要顯示包含 ID 的記錄時,都必須進行額外的查詢以將 ID 解析為人類可讀的內容。在關係資料模型中,這是使用 **連線** 完成的,例如: ```sql SELECT users.*, regions.region_name @@ -213,7 +213,7 @@ SELECT posts.id, posts.sender_id LIMIT 1000 ``` -這意味著每當讀取時間線時,服務仍然需要執行兩個連線:透過 ID 查詢帖子以獲取實際的帖子內容(以及點贊數和回覆數等統計資訊),並透過 ID 查詢傳送者的個人資料(以獲取他們的使用者名稱、個人資料圖片和其他詳細資訊)。這個將 ID 補全為人類可讀資訊的過程稱為 *hydrating* ID,本質上是在應用程式程式碼中執行的連線 [^11]。 +這意味著每當讀取時間線時,服務仍然需要執行兩個連線:透過 ID 查詢帖子以獲取實際的帖子內容(以及點贊數和回覆數等統計資訊),並透過 ID 查詢傳送者的個人資料(以獲取他們的使用者名稱、個人資料圖片和其他詳細資訊)。這個將 ID 補全為人類可讀資訊的過程稱為 **hydrating** ID,本質上是在應用程式程式碼中執行的連線 [^11]。 在預計算時間線中僅儲存 ID 的原因是它們引用的資料變化很快:熱門帖子的點贊數和回覆數可能每秒變化多次,一些使用者定期更改他們的使用者名稱或個人資料照片。由於時間線在檢視時應該顯示最新的點贊數和個人資料圖片,因此將此資訊反正規化到物化時間線中是沒有意義的。此外,這種反正規化會顯著增加儲存成本。 @@ -223,9 +223,9 @@ SELECT posts.id, posts.sender_id ### 多對一與多對多關係 {#sec_datamodels_many_to_many} -雖然 [圖 3-1](#fig_obama_relational) 中的 `positions` 和 `education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),但 `region_id` 欄位是 *多對一* 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。 +雖然 [圖 3-1](#fig_obama_relational) 中的 `positions` 和 `education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),但 `region_id` 欄位是 **多對一** 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。 -如果我們為組織和學校引入實體,並透過 ID 從簡歷中引用它們,那麼我們也有 *多對多* 關係(一個人曾為多個組織工作,一個組織有多個過去或現在的員工)。在關係模型中,這種關係通常表示為 *關聯表* 或 *連線表*,如 [圖 3-3](#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" >}} @@ -250,21 +250,21 @@ SELECT posts.id, posts.sender_id 多對多關係通常需要"雙向"查詢:例如,找到特定人員工作過的所有組織,以及找到在特定組織工作過的所有人員。啟用此類查詢的一種方法是在兩邊都儲存 ID 引用,即簡歷包含該人工作過的每個組織的 ID,組織文件包含提到該組織的簡歷的 ID。這種表示是反正規化的,因為關係儲存在兩個地方,可能會相互不一致。 -正規化表示僅在一個地方儲存關係,並依賴 *二級索引*(我們將在 [第 4 章](/tw/ch4#ch_storage) 中討論)來允許有效地雙向查詢關係。在 [圖 3-3](#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](#fig_datamodels_m2m_json) 的文件模型中,資料庫需要索引 `positions` 陣列內物件的 `org_id` 欄位。許多文件資料庫和具有 JSON 支援的關係資料庫能夠在文件內的值上建立此類索引。 ### 星型與雪花型:分析模式 {#sec_datamodels_analytics} -資料倉庫(參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))通常是關係型的,並且資料倉庫中表結構有一些廣泛使用的約定:*星型模式*、*雪花模式*、*維度建模* [^12],以及 *一張大表*(OBT)。這些結構針對業務分析師的需求進行了最佳化。ETL 過程將來自運營系統的資料轉換為此模式。 +資料倉庫(參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))通常是關係型的,並且資料倉庫中表結構有一些廣泛使用的約定:**星型模式**、**雪花模式**、**維度建模** [^12],以及 **一張大表**(OBT)。這些結構針對業務分析師的需求進行了最佳化。ETL 過程將來自運營系統的資料轉換為此模式。 -[圖 3-5](#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" >}} 通常,事實被捕獲為單個事件,因為這允許以後最大的分析靈活性。然而,這意味著事實表可能變得非常大。一個大型企業可能在其資料倉庫中有許多 PB 的交易歷史,主要表示為事實表。 -事實表中的一些列是屬性,例如產品售出的價格和從供應商那裡購買它的成本(允許計算利潤率)。事實表中的其他列是對其他表的外部索引鍵引用,稱為 *維度表*。由於事實表中的每一行代表一個事件,維度代表事件的 *誰*、*什麼*、*哪裡*、*何時*、*如何* 和 *為什麼*。 +事實表中的一些列是屬性,例如產品售出的價格和從供應商那裡購買它的成本(允許計算利潤率)。事實表中的其他列是對其他表的外部索引鍵引用,稱為 **維度表**。由於事實表中的每一行代表一個事件,維度代表事件的 **誰**、**什麼**、**哪裡**、**何時**、**如何** 和 **為什麼**。 例如,在 [圖 3-5](#fig_dwh_schema) 中,其中一個維度是售出的產品。`dim_product` 表中的每一行代表一種待售產品型別,包括其庫存單位(SKU)、描述、品牌名稱、類別、脂肪含量、包裝尺寸等。`fact_sales` 表中的每一行使用外部索引鍵來指示在該特定交易中售出了哪種產品。查詢通常涉及對多個維度表的多個連線。 @@ -272,13 +272,13 @@ SELECT posts.id, posts.sender_id [圖 3-5](#fig_dwh_schema) 是星型模式的一個例子。該名稱來自這樣一個事實:當表關係被視覺化時,事實表位於中間,被其維度表包圍;到這些表的連線就像星星的光芒。 -這個模板的一個變體被稱為 *雪花模式*,其中維度被進一步分解為子維度。例如,品牌和產品類別可能有單獨的表,`dim_product` 表中的每一行都可以將品牌和類別作為外部索引鍵引用,而不是將它們作為字串儲存在 `dim_product` 表中。雪花模式比星型模式更正規化,但星型模式通常更受歡迎,因為它們對分析師來說更簡單 [^12]。 +這個模板的一個變體被稱為 **雪花模式**,其中維度被進一步分解為子維度。例如,品牌和產品類別可能有單獨的表,`dim_product` 表中的每一行都可以將品牌和類別作為外部索引鍵引用,而不是將它們作為字串儲存在 `dim_product` 表中。雪花模式比星型模式更正規化,但星型模式通常更受歡迎,因為它們對分析師來說更簡單 [^12]。 在典型的資料倉庫中,表通常非常寬:事實表通常有超過 100 列,有時有幾百列。維度表也可能很寬,因為它們包括所有可能與分析相關的元資料 —— 例如,`dim_store` 表可能包括每個商店提供哪些服務的詳細資訊、是否有店內麵包房、平方英尺、商店首次開業的日期、最後一次改造的時間、距離最近的高速公路有多遠等。 星型或雪花模式主要由多對一關係組成(例如,許多銷售發生在一個特定產品,在一個特定商店),表示為事實表對維度表的外部索引鍵,或維度對子維度的外部索引鍵。原則上,其他型別的關係可能存在,但它們通常被反正規化以簡化查詢。例如,如果客戶一次購買多種不同的產品,則該多項交易不會被明確表示;相反,事實表中為每個購買的產品都有一個單獨的行,這些事實都恰好具有相同的客戶 ID、商店 ID 和時間戳。 -一些資料倉庫模式進一步進行反正規化,完全省略維度表,將維度中的資訊摺疊到事實表上的反正規化列中(本質上是預計算事實表和維度表之間的連線)。這種方法被稱為 *一張大表*(OBT),雖然它需要更多的儲存空間,但有時可以實現更快的查詢 [^13]。 +一些資料倉庫模式進一步進行反正規化,完全省略維度表,將維度中的資訊摺疊到事實表上的反正規化列中(本質上是預計算事實表和維度表之間的連線)。這種方法被稱為 **一張大表**(OBT),雖然它需要更多的儲存空間,但有時可以實現更快的查詢 [^13]。 在分析的背景下,這種反正規化是沒有問題的,因為資料通常代表不會改變的歷史資料日誌(除了偶爾糾正錯誤)。OLTP 系統中反正規化出現的資料一致性和寫入開銷問題在分析中並不那麼緊迫。 @@ -286,7 +286,7 @@ SELECT posts.id, posts.sender_id 文件資料模型的主要論點是模式靈活性、由於區域性而獲得更好的效能,以及對於某些應用程式來說,它更接近應用程式使用的物件模型。關係模型透過為連線、多對一和多對多關係提供更好的支援來反擊。讓我們更詳細地研究這些論點。 -如果你的應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 *切碎*(shredding)為多個表的關係技術(如 [圖 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要複雜的應用程式程式碼。 +如果你的應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 **切碎**(shredding)為多個表的關係技術(如 [圖 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要複雜的應用程式程式碼。 文件模型有侷限性:例如,你不能直接引用文件中的巢狀項,而是需要說類似"使用者 251 的職位列表中的第二項"之類的話。如果你確實需要引用巢狀項,關係方法效果更好,因為你可以透過其 ID 直接引用任何項。 @@ -296,7 +296,7 @@ SELECT posts.id, posts.sender_id 大多數文件資料庫以及關係資料庫中的 JSON 支援不會對文件中的資料強制執行任何模式。關係資料庫中的 XML 支援通常帶有可選的模式驗證。沒有模式意味著可以將任意鍵和值新增到文件中,並且在讀取時,客戶端不能保證文件可能包含哪些欄位。 -文件資料庫有時被稱為 *無模式*,但這是誤導性的,因為讀取資料的程式碼通常假設某種結構 —— 即存在隱式模式,但資料庫不強制執行 [^17]。更準確的術語是 *讀時模式*(資料的結構是隱式的,只有在讀取資料時才解釋),與 *寫時模式*(關係資料庫的傳統方法,其中模式是顯式的,資料庫確保所有資料在寫入時都符合它)形成對比 [^18]。 +文件資料庫有時被稱為 **無模式**,但這是誤導性的,因為讀取資料的程式碼通常假設某種結構 —— 即存在隱式模式,但資料庫不強制執行 [^17]。更準確的術語是 **讀時模式**(資料的結構是隱式的,只有在讀取資料時才解釋),與 **寫時模式**(關係資料庫的傳統方法,其中模式是顯式的,資料庫確保所有資料在寫入時都符合它)形成對比 [^18]。 讀時模式類似於程式語言中的動態(執行時)型別檢查,而寫時模式類似於靜態(編譯時)型別檢查。正如靜態和動態型別檢查的倡導者對它們的相對優點有很大的爭論 [^19],資料庫中模式的強制執行是一個有爭議的話題,通常沒有正確或錯誤的答案。 @@ -309,7 +309,7 @@ if (user && user.name && !user.first_name) { } ``` -這種方法的缺點是,從資料庫讀取的應用程式的每個部分現在都需要處理可能很久以前寫入的舊格式的文件。另一方面,在寫時模式資料庫中,你通常會執行 *遷移*,如下所示: +這種方法的缺點是,從資料庫讀取的應用程式的每個部分現在都需要處理可能很久以前寫入的舊格式的文件。另一方面,在寫時模式資料庫中,你通常會執行 **遷移**,如下所示: ```sql ALTER TABLE users ADD COLUMN first_name text DEFAULT NULL; @@ -330,11 +330,11 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 讀寫的資料區域性 {#sec_datamodels_document_locality} -文件通常儲存為單個連續字串,編碼為 JSON、XML 或二進位制變體(如 MongoDB 的 BSON)。如果你的應用程式經常需要訪問整個文件(例如,在網頁上渲染它),則這種 *儲存區域性* 具有效能優勢。如果資料分佈在多個表中,如 [圖 3-1](#fig_obama_relational) 所示,則需要多次索引查詢才能檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。 +文件通常儲存為單個連續字串,編碼為 JSON、XML 或二進位制變體(如 MongoDB 的 BSON)。如果你的應用程式經常需要訪問整個文件(例如,在網頁上渲染它),則這種 **儲存區域性** 具有效能優勢。如果資料分佈在多個表中,如 [圖 3-1](#fig_obama_relational) 所示,則需要多次索引查詢才能檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。 區域性優勢僅在你同時需要文件的大部分時才適用。資料庫通常需要載入整個文件,如果你只需要訪問大文件的一小部分,這可能會浪費。在文件更新時,通常需要重寫整個文件。由於這些原因,通常建議你保持文件相當小,並避免頻繁對文件進行小的更新。 -然而,將相關資料儲存在一起以獲得區域性的想法並不限於文件模型。例如,Google 的 Spanner 資料庫在關係資料模型中提供相同的區域性屬性,允許模式宣告表的行應該交錯(巢狀)在父表中 [^25]。Oracle 允許相同的功能,使用稱為 *多表索引叢集表* 的功能 [^26]。由 Google 的 Bigtable 推廣並在 HBase 和 Accumulo 等中使用的 *寬列* 資料模型具有 *列族* 的概念,其目的類似於管理區域性 [^27]。 +然而,將相關資料儲存在一起以獲得區域性的想法並不限於文件模型。例如,Google 的 Spanner 資料庫在關係資料模型中提供相同的區域性屬性,允許模式宣告表的行應該交錯(巢狀)在父表中 [^25]。Oracle 允許相同的功能,使用稱為 **多表索引叢集表** 的功能 [^26]。由 Google 的 Bigtable 推廣並在 HBase 和 Accumulo 等中使用的 **寬列** 資料模型具有 **列族** 的概念,其目的類似於管理區域性 [^27]。 #### 文件的查詢語言 {#query-languages-for-documents} @@ -393,7 +393,7 @@ db.observations.aggregate([ 但是,如果你的資料中多對多關係非常常見呢?關係模型可以處理多對多關係的簡單情況,但隨著資料內部連線變得更加複雜,開始將資料建模為圖變得更加自然。 -圖由兩種物件組成:*頂點*(也稱為 *節點* 或 *實體*)和 *邊*(也稱為 *關係* 或 *弧*)。許多型別的資料可以建模為圖。典型的例子包括: +圖由兩種物件組成:**頂點**(也稱為 **節點** 或 **實體**)和 **邊**(也稱為 **關係** 或 **弧**)。許多型別的資料可以建模為圖。典型的例子包括: 社交圖 : 頂點是人,邊表示哪些人相互認識。 @@ -406,14 +406,14 @@ db.observations.aggregate([ 眾所周知的演算法可以在這些圖上執行:例如,地圖導航應用程式搜尋道路網路中兩點之間的最短路徑,PageRank 可用於網頁圖以確定網頁的受歡迎程度,從而確定其在搜尋結果中的排名 [^32]。 -圖可以用幾種不同的方式表示。在 *鄰接表* 模型中,每個頂點儲存其相距一條邊的鄰居頂點的 ID。或者,你可以使用 *鄰接矩陣*,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接表適合圖遍歷,矩陣適合機器學習(參見 ["資料框、矩陣與陣列"](#sec_datamodels_dataframes))。 +圖可以用幾種不同的方式表示。在 **鄰接表** 模型中,每個頂點儲存其相距一條邊的鄰居頂點的 ID。或者,你可以使用 **鄰接矩陣**,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接表適合圖遍歷,矩陣適合機器學習(參見 ["資料框、矩陣與陣列"](#sec_datamodels_dataframes))。 -在剛才給出的示例中,圖中的所有頂點都表示相同型別的事物(分別是人、網頁或道路交叉點)。然而,圖不限於這種 *同質* 資料:圖的一個同樣強大的用途是提供一種一致的方式在單個數據庫中儲存完全不同型別的物件。例如: +在剛才給出的示例中,圖中的所有頂點都表示相同型別的事物(分別是人、網頁或道路交叉點)。然而,圖不限於這種 **同質** 資料:圖的一個同樣強大的用途是提供一種一致的方式在單個數據庫中儲存完全不同型別的物件。例如: * Facebook 維護一個包含許多不同型別頂點和邊的單一圖:頂點表示人員、位置、事件、簽到和使用者發表的評論;邊表示哪些人彼此是朋友、哪個簽到發生在哪個位置、誰評論了哪個帖子、誰參加了哪個事件等等 [^33]。 * 知識圖被搜尋引擎用來記錄搜尋查詢中經常出現的實體(如組織、人員和地點)的事實 [^34]。這些資訊透過爬取和分析網站上的文字獲得;一些網站(如 Wikidata)也以結構化形式釋出圖資料。 -在圖中構建和查詢資料有幾種不同但相關的方式。在本節中,我們將討論 *屬性圖* 模型(由 Neo4j、Memgraph、KùzuDB [^35] 和其他 [^36] 實現)和 *三元組儲存* 模型(由 Datomic、AllegroGraph、Blazegraph 和其他實現)。這些模型在它們可以表達的內容方面相當相似,一些圖資料庫(如 Amazon Neptune)支援兩種模型。 +在圖中構建和查詢資料有幾種不同但相關的方式。在本節中,我們將討論 **屬性圖** 模型(由 Neo4j、Memgraph、KùzuDB [^35] 和其他 [^36] 實現)和 **三元組儲存** 模型(由 Datomic、AllegroGraph、Blazegraph 和其他實現)。這些模型在它們可以表達的內容方面相當相似,一些圖資料庫(如 Amazon Neptune)支援兩種模型。 我們還將檢視圖的四種查詢語言(Cypher、SPARQL、Datalog 和 GraphQL),以及用於查詢圖的 SQL 支援。還存在其他圖查詢語言,如 Gremlin [^37],但這些將為我們提供代表性的概述。 @@ -423,7 +423,7 @@ db.observations.aggregate([ ### 屬性圖 {#id56} -在 *屬性圖*(也稱為 *標記屬性圖*)模型中,每個頂點包含: +在 **屬性圖**(也稱為 **標記屬性圖**)模型中,每個頂點包含: * 唯一識別符號 * 標籤(字串),描述此頂點表示的物件型別 @@ -434,8 +434,8 @@ db.observations.aggregate([ 每條邊包含: * 唯一識別符號 -* 邊開始的頂點(*尾頂點*) -* 邊結束的頂點(*頭頂點*) +* 邊開始的頂點(**尾頂點**) +* 邊結束的頂點(**頭頂點**) * 描述兩個頂點之間關係型別的標籤 * 屬性集合(鍵值對) @@ -465,7 +465,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 此模型的一些重要方面是: 1. 任何頂點都可以有一條邊將其與任何其他頂點連線。沒有限制哪些型別的事物可以或不能關聯的模式。 -2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 *遍歷* 圖 —— 即透過頂點鏈跟隨路徑 —— 向前和向後。(這就是為什麼 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。) +2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 **遍歷** 圖 —— 即透過頂點鏈跟隨路徑 —— 向前和向後。(這就是為什麼 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。) 3. 透過對不同型別的頂點和關係使用不同的標籤,你可以在單個圖中儲存幾種不同型別的資訊,同時仍保持簡潔的資料模型。 邊表就像我們在 ["多對一與多對多關係"](#sec_datamodels_many_to_many) 中看到的多對多關聯表/連線表,泛化為允許在同一表中儲存許多不同型別的關係。標籤和屬性上也可能有索引,允許有效地找到具有某些屬性的頂點或邊。 @@ -477,13 +477,13 @@ CREATE INDEX edges_heads ON edges (head_vertex); -------- -這些功能為資料建模提供了極大的靈活性,如 [圖 3-6](#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的內容,例如不同國家的不同區域結構(法國有 *省* 和 *大區*,而美國有 *縣* 和 *州*)、歷史的怪癖(如國中之國)(暫時忽略主權國家和民族的複雜性),以及不同粒度的資料(Lucy 的當前居住地指定為城市,而她的出生地僅在州級別指定)。 +這些功能為資料建模提供了極大的靈活性,如 [圖 3-6](#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的內容,例如不同國家的不同區域結構(法國有 **省** 和 **大區**,而美國有 **縣** 和 **州**)、歷史的怪癖(如國中之國)(暫時忽略主權國家和民族的複雜性),以及不同粒度的資料(Lucy 的當前居住地指定為城市,而她的出生地僅在州級別指定)。 你可以想象擴充套件圖以包括有關 Lucy 和 Alain 或其他人的許多其他事實。例如,你可以使用它來指示他們有哪些食物過敏(透過為每個過敏原引入一個頂點,並在人和過敏原之間設定邊以指示過敏),並將過敏原與顯示哪些食物含有哪些物質的一組頂點連結。然後你可以編寫查詢來找出每個人可以安全食用的食物。圖適合可演化性:隨著你嚮應用程式新增功能,圖可以輕鬆擴充套件以適應應用程式資料結構的變化。 ### Cypher 查詢語言 {#id57} -*Cypher* 是用於屬性圖的查詢語言,最初為 Neo4j 圖資料庫建立,後來作為 *openCypher* 發展為開放標準 [^38]。除了 Neo4j,Cypher 還得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中儲存)等的支援。它以電影《駭客帝國》中的角色命名,與密碼學中的密碼無關 [^39]。 +**Cypher** 是用於屬性圖的查詢語言,最初為 Neo4j 圖資料庫建立,後來作為 **openCypher** 發展為開放標準 [^38]。除了 Neo4j,Cypher 還得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中儲存)等的支援。它以電影《駭客帝國》中的角色命名,與密碼學中的密碼無關 [^39]。 [示例 3-4](#fig_cypher_create) 顯示了將 [圖 3-6](#fig_datamodels_graph) 的左側部分插入圖資料庫的 Cypher 查詢。圖的其餘部分可以類似地新增。每個頂點都被賦予一個符號名稱,如 `usa` 或 `idaho`。該名稱不儲存在資料庫中,僅在查詢內部使用以在頂點之間建立邊,使用箭頭符號:`(idaho) -[:WITHIN]-> (usa)` 建立一條標記為 `WITHIN` 的邊,其中 `idaho` 作為尾節點,`usa` 作為頭節點。 @@ -499,7 +499,7 @@ CREATE (lucy) -[:BORN_IN]-> (idaho) ``` -當 [圖 3-6](#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,*查詢所有從美國移民到歐洲的人的姓名*。也就是說,找到所有具有指向美國境內位置的 `BORN_IN` 邊,以及指向歐洲境內位置的 `LIVING_IN` 邊的頂點,並返回每個頂點的 `name` 屬性。 +當 [圖 3-6](#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,**查詢所有從美國移民到歐洲的人的姓名**。也就是說,找到所有具有指向美國境內位置的 `BORN_IN` 邊,以及指向歐洲境內位置的 `LIVING_IN` 邊的頂點,並返回每個頂點的 `name` 屬性。 [示例 3-5](#fig_cypher_query) 顯示了如何在 Cypher 中表達該查詢。相同的箭頭符號用於 `MATCH` 子句中以在圖中查詢模式:`(person) -[:BORN_IN]-> ()` 匹配由標記為 `BORN_IN` 的邊相關的任意兩個頂點。該邊的尾頂點繫結到變數 `person`,頭頂點未命名。 @@ -535,7 +535,7 @@ RETURN person.name 在 Cypher 中,`:WITHIN*0..` 非常簡潔地表達了這個事實:它意味著"跟隨 `WITHIN` 邊,零次或多次"。它就像正則表示式中的 `*` 運算元。 -自 SQL:1999 以來,查詢中可變長度遍歷路徑的想法可以使用稱為 *遞迴公用表表達式*(`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](#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" >}} @@ -597,7 +597,7 @@ WITH RECURSIVE 4 行 Cypher 查詢需要 31 行 SQL 的事實表明,正確選擇資料模型和查詢語言可以產生多大的差異。這只是開始;還有更多細節需要考慮,例如,處理迴圈,以及在廣度優先或深度優先遍歷之間進行選擇 [^40]。 -Oracle 對遞迴查詢有不同的 SQL 擴充套件,它稱之為 *層次* [^41]。 +Oracle 對遞迴查詢有不同的 SQL 擴充套件,它稱之為 **層次** [^41]。 然而,情況可能正在改善:在撰寫本文時,有計劃向 SQL 標準新增一種名為 GQL 的圖查詢語言 [^42] [^43],它將提供受 Cypher、GSQL [^44] 和 PGQL [^45] 啟發的語法。 @@ -605,17 +605,17 @@ Oracle 對遞迴查詢有不同的 SQL 擴充套件,它稱之為 *層次* [^41 三元組儲存模型大多等同於屬性圖模型,使用不同的詞來描述相同的想法。儘管如此,它仍值得討論,因為有各種三元組儲存的工具和語言,它們可以成為構建應用程式工具箱的寶貴補充。 -在三元組儲存中,所有資訊都以非常簡單的三部分語句的形式儲存:(*主語*、*謂語*、*賓語*)。例如,在三元組(*Jim*、*likes*、*bananas*)中,*Jim* 是主語,*likes* 是謂語(動詞),*bananas* 是賓語。 +在三元組儲存中,所有資訊都以非常簡單的三部分語句的形式儲存:(**主語**、**謂語**、**賓語**)。例如,在三元組(**Jim**、**likes**、**bananas**)中,**Jim** 是主語,**likes** 是謂語(動詞),**bananas** 是賓語。 三元組的主語等同於圖中的頂點。賓語是兩種東西之一: -1. 原始資料型別的值,如字串或數字。在這種情況下,三元組的謂語和賓語等同於主語頂點上屬性的鍵和值。使用 [圖 3-6](#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一個頂點 `lucy`,其屬性為 `{"birthYear": 1989}`。 -2. 圖中的另一個頂點。在這種情況下,謂語是圖中的邊,主語是尾頂點,賓語是頭頂點。例如,在(*lucy*、*marriedTo*、*alain*)中,主語和賓語 *lucy* 和 *alain* 都是頂點,謂語 *marriedTo* 是連線它們的邊的標籤。 +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](#fig_graph_n3_triples) 顯示了與 [示例 3-4](#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" >}} @@ -655,7 +655,7 @@ _:namerica a :Location; :name "North America"; :type "continent". > [!TIP] 語義網 -一些三元組儲存的研究和開發工作是由 *語義網* 推動的,這是 2000 年代初的一項努力,旨在透過不僅以人類可讀的網頁形式釋出資料,還以標準化的機器可讀格式釋出資料來促進網際網路範圍的資料交換。儘管最初設想的語義網沒有成功 [^49] [^50],但語義網專案的遺產在幾項特定技術中繼續存在:*連結資料* 標準(如 JSON-LD [^51])、生物醫學科學中使用的 *本體* [^52]、Facebook 的開放圖協議 [^53](用於連結展開 [^54])、知識圖(如 Wikidata)以及由 [`schema.org`](https://schema.org/) 維護的結構化資料的標準化詞彙表。 +一些三元組儲存的研究和開發工作是由 **語義網** 推動的,這是 2000 年代初的一項努力,旨在透過不僅以人類可讀的網頁形式釋出資料,還以標準化的機器可讀格式釋出資料來促進網際網路範圍的資料交換。儘管最初設想的語義網沒有成功 [^49] [^50],但語義網專案的遺產在幾項特定技術中繼續存在:**連結資料** 標準(如 JSON-LD [^51])、生物醫學科學中使用的 **本體** [^52]、Facebook 的開放圖協議 [^53](用於連結展開 [^54])、知識圖(如 Wikidata)以及由 [`schema.org`](https://schema.org/) 維護的結構化資料的標準化詞彙表。 三元組儲存是另一種在其原始用例之外找到用途的語義網技術:即使你對語義網沒有興趣,三元組也可以成為應用程式的良好內部資料模型。 @@ -663,7 +663,7 @@ _:namerica a :Location; :name "North America"; :type "continent". #### RDF 資料模型 {#the-rdf-data-model} -我們在 [示例 3-8](#fig_graph_n3_shorthand) 中使用的 Turtle 語言實際上是在 *資源描述框架*(RDF)[^55] 中編碼資料的一種方式,這是為語義網設計的資料模型。RDF 資料也可以用其他方式編碼,例如(更冗長地)用 XML,如 [示例 3-9](#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,7 +701,7 @@ URL `` 不一定需要解析為任何內容 — #### SPARQL 查詢語言 {#the-sparql-query-language} -*SPARQL* 是使用 RDF 資料模型的三元組儲存的查詢語言 [^56]。(它是 *SPARQL Protocol and RDF Query Language* 的首字母縮略詞,發音為 "sparkle"。)它早於 Cypher,由於 Cypher 的模式匹配是從 SPARQL 借用的,它們看起來非常相似。 +**SPARQL** 是使用 RDF 資料模型的三元組儲存的查詢語言 [^56]。(它是 **SPARQL Protocol and RDF Query Language** 的首字母縮略詞,發音為 "sparkle"。)它早於 Cypher,由於 Cypher 的模式匹配是從 SPARQL 借用的,它們看起來非常相似。 與之前相同的查詢 —— 查詢從美國搬到歐洲的人 —— 在 SPARQL 中與在 Cypher 中一樣簡潔(見 [示例 3-10](#fig_sparql_query))。 @@ -741,7 +741,7 @@ Datalog 是一種比 SPARQL 或 Cypher 更古老的語言:它源於 20 世紀 Datalog 實際上基於關係資料模型,而不是圖,但它出現在本書的圖資料庫部分,因為圖上的遞迴查詢是 Datalog 的特殊優勢。 -Datalog 資料庫的內容由 *事實* 組成,每個事實對應於關係表中的一行。例如,假設我們有一個包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美國是一個國家的事實可以寫成 `location(2, "United States", "country")`,其中 `2` 是美國的 ID。一般來說,語句 `table(val1, val2, …​)` 意味著 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此類推。 +Datalog 資料庫的內容由 **事實** 組成,每個事實對應於關係表中的一行。例如,假設我們有一個包含位置的表 **location**,它有三列:**ID**、**name** 和 **type**。美國是一個國家的事實可以寫成 `location(2, "United States", "country")`,其中 `2` 是美國的 ID。一般來說,語句 `table(val1, val2, …​)` 意味著 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此類推。 [示例 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)`。 @@ -779,11 +779,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 規則 4 /* us_to_europe 包含行 "Lucy"。 */ ``` -Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小步。我們定義 *規則* 從底層事實派生新的虛擬表。這些派生表就像(虛擬)SQL 檢視:它們不儲存在資料庫中,但你可以像查詢包含儲存事實的表一樣查詢它們。 +Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小步。我們定義 **規則** 從底層事實派生新的虛擬表。這些派生表就像(虛擬)SQL 檢視:它們不儲存在資料庫中,但你可以像查詢包含儲存事實的表一樣查詢它們。 在 [示例 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"`。如果系統可以為 `:-` 運算元右側的 *所有* 模式找到匹配項,則規則適用。當規則適用時,就好像 `:-` 的左側被新增到資料庫中(變數被它們匹配的值替換)。 +虛擬表的內容由規則的 `:-` 符號之後的部分定義,我們在其中嘗試查詢表中匹配某種模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,變數 `PersonID` 繫結到值 `100`,變數 `PName` 繫結到值 `"Lucy"`。如果系統可以為 `:-` 運算元右側的 **所有** 模式找到匹配項,則規則適用。當規則適用時,就好像 `:-` 的左側被新增到資料庫中(變數被它們匹配的值替換)。 因此,應用規則的一種可能方式是(如 [圖 3-7](#fig_datalog_naive) 所示): @@ -877,17 +877,17 @@ query ChatApp { 我們之前在 ["記錄系統與派生資料"](/tw/ch1#sec_introduction_derived) 中看到了這個想法,ETL(參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))就是這種派生過程的一個例子。現在我們將進一步深入這個想法。如果我們無論如何都要從一種資料表示派生出另一種,我們可以選擇分別針對寫入和讀取最佳化的不同表示。如果你只想為寫入最佳化資料建模,而不關心高效查詢,你會如何建模? -也許寫入資料的最簡單、最快速和最具表現力的方式是 *事件日誌*:每次你想寫入一些資料時,你將其編碼為自包含的字串(可能是 JSON),包括時間戳,然後將其追加到事件序列中。此日誌中的事件是 *不可變的*:你永遠不會更改或刪除它們,你只會向日志追加更多事件(這可能會取代早期事件)。事件可以包含任意屬性。 +也許寫入資料的最簡單、最快速和最具表現力的方式是 **事件日誌**:每次你想寫入一些資料時,你將其編碼為自包含的字串(可能是 JSON),包括時間戳,然後將其追加到事件序列中。此日誌中的事件是 **不可變的**:你永遠不會更改或刪除它們,你只會向日志追加更多事件(這可能會取代早期事件)。事件可以包含任意屬性。 [圖 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](#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參與者進行和取消註冊)首先被儲存為事件。每當事件追加到日誌時,幾個 *物化檢視*(也稱為 *投影* 或 *讀模型*)也會更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為列印參與者徽章的印表機生成檔案。 +在 [圖 3-8](#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參與者進行和取消註冊)首先被儲存為事件。每當事件追加到日誌時,幾個 **物化檢視**(也稱為 **投影** 或 **讀模型**)也會更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為列印參與者徽章的印表機生成檔案。 -使用事件作為真相來源(權威資料來源),並將每個狀態變化表達為事件的想法被稱為 *事件溯源* [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則稱為 *命令查詢責任分離(CQRS)* [^64]。這些術語起源於領域驅動設計(DDD)社群,儘管類似的想法已經存在很長時間了,例如 *狀態機複製*(參見 ["使用共享日誌"](/tw/ch10#sec_consistency_smr))。 +使用事件作為真相來源(權威資料來源),並將每個狀態變化表達為事件的想法被稱為 **事件溯源** [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則稱為 **命令查詢責任分離(CQRS)** [^64]。這些術語起源於領域驅動設計(DDD)社群,儘管類似的想法已經存在很長時間了,例如 **狀態機複製**(參見 ["使用共享日誌"](/tw/ch10#sec_consistency_smr))。 -當用戶的請求進來時,它被稱為 *命令*,首先需要驗證。只有在命令已執行並確定有效(例如,請求的預訂有足夠的可用座位)後,它才成為事實,相應的事件被新增到日誌中。因此,事件日誌應該只包含有效事件,構建物化檢視的事件日誌消費者不允許拒絕事件。 +當用戶的請求進來時,它被稱為 **命令**,首先需要驗證。只有在命令已執行並確定有效(例如,請求的預訂有足夠的可用座位)後,它才成為事實,相應的事件被新增到日誌中。因此,事件日誌應該只包含有效事件,構建物化檢視的事件日誌消費者不允許拒絕事件。 在以事件溯源風格建模資料時,建議你使用過去時態命名事件(例如,"座位已預訂"),因為事件是記錄過去發生的事情的記錄。即使使用者後來決定更改或取消,他們以前持有預訂的事實仍然是真實的,更改或取消是稍後新增的單獨事件。 @@ -895,7 +895,7 @@ query ChatApp { 事件溯源和 CQRS 有幾個優點: -* 對於開發系統的人來說,事件更好地傳達了 *為什麼* 發生某事的意圖。例如,理解事件"預訂已取消"比理解"`bookings` 表第 4001 行的 `active` 列被設定為 `false`,與該預訂相關的三行從 `seat_assignments` 表中刪除,並且在 `payments` 表中插入了一行代表退款"更容易。當物化檢視處理取消事件時,這些行修改仍可能發生,但當它們由事件驅動時,更新的原因變得更加清晰。 +* 對於開發系統的人來說,事件更好地傳達了 **為什麼** 發生某事的意圖。例如,理解事件"預訂已取消"比理解"`bookings` 表第 4001 行的 `active` 列被設定為 `false`,與該預訂相關的三行從 `seat_assignments` 表中刪除,並且在 `payments` 表中插入了一行代表退款"更容易。當物化檢視處理取消事件時,這些行修改仍可能發生,但當它們由事件驅動時,更新的原因變得更加清晰。 * 事件溯源的關鍵原則是物化檢視以可重現的方式從事件日誌派生:你應該始終能夠刪除物化檢視並透過以相同順序處理相同事件,使用相同程式碼來重新計算它們。如果檢視維護程式碼中有錯誤,你可以刪除檢視並使用新程式碼重新計算它。查詢錯誤也更容易,因為你可以隨意重新執行檢視維護程式碼並檢查其行為。 * 你可以有多個物化檢視,針對應用程式所需的特定查詢進行最佳化。它們可以儲存在與事件相同的資料庫中,也可以儲存在不同的資料庫中,具體取決於你的需求。它們可以使用任何資料模型,並且可以為快速讀取而反正規化。你甚至可以只在記憶體中保留檢視並避免持久化它,只要可以在服務重新啟動時從事件日誌重新計算檢視即可。 * 如果你決定以新方式呈現現有資訊,很容易從現有事件日誌構建新的物化檢視。你還可以透過新增新型別的事件或向現有事件型別新增新屬性(任何舊事件保持未修改)來發展系統以支援新功能。你還可以將新行為連結到現有事件(例如,當會議參與者取消時,他們的座位可以提供給等候名單上的下一個人)。 @@ -919,50 +919,50 @@ query ChatApp { 資料框是 R 語言、Python 的 Pandas 庫、Apache Spark、ArcticDB、Dask 和其他系統支援的資料模型。它們是資料科學家為訓練機器學習模型準備資料的流行工具,但它們也廣泛用於資料探索、統計資料分析、資料視覺化和類似目的。 -乍一看,資料框類似於關係資料庫中的表或電子表格。它支援對資料框內容執行批次操作的類關係運算元:例如,將函式應用於所有行、基於某些條件過濾行、按某些列對行進行分組並聚合其他列,以及基於某個鍵將一個數據框中的行與另一個數據框連線(關係資料庫稱為 *連線* 的操作在資料框上通常稱為 *合併*)。 +乍一看,資料框類似於關係資料庫中的表或電子表格。它支援對資料框內容執行批次操作的類關係運算元:例如,將函式應用於所有行、基於某些條件過濾行、按某些列對行進行分組並聚合其他列,以及基於某個鍵將一個數據框中的行與另一個數據框連線(關係資料庫稱為 **連線** 的操作在資料框上通常稱為 **合併**)。 資料框通常不是透過宣告式查詢(如 SQL)而是透過一系列修改其結構和內容的命令來操作的。這符合資料科學家的典型工作流程,他們逐步"整理"資料,使其成為能夠找到他們所提問題答案的形式。這些操作通常在資料科學家的資料集私有副本上進行,通常在他們的本地機器上,儘管最終結果可能與其他使用者共享。 資料框 API 還提供了遠遠超出關係資料庫提供的各種操作,資料模型的使用方式通常與典型的關係資料建模非常不同 [^65]。例如,資料框的常見用途是將資料從類似關係的表示轉換為矩陣或多維陣列表示,這是許多機器學習演算法期望的輸入形式。 -[圖 3-9](#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](#fig_dataframe_to_matrix) 的示例矩陣中省略了)可以縮放為某個合適範圍內的浮點數。 -* 對於只能取一小組固定值之一的列(例如,電影資料庫中電影的型別),通常使用 *獨熱編碼*:我們為每個可能的值建立一列(一個用於"喜劇",一個用於"劇情",一個用於"恐怖"等),對於代表電影的每一行,我們在對應於該電影型別的列中放置 1,在所有其他列中放置 0。這種表示也很容易推廣到適合多種型別的電影。 +* 對於只能取一小組固定值之一的列(例如,電影資料庫中電影的型別),通常使用 **獨熱編碼**:我們為每個可能的值建立一列(一個用於"喜劇",一個用於"劇情",一個用於"恐怖"等),對於代表電影的每一行,我們在對應於該電影型別的列中放置 1,在所有其他列中放置 0。這種表示也很容易推廣到適合多種型別的電影。 一旦資料以數字矩陣的形式存在,它就適合線性代數運算,這構成了許多機器學習演算法的基礎。例如,[圖 3-9](#fig_dataframe_to_matrix) 中的資料可能是推薦使用者可能喜歡的電影系統的一部分。資料框足夠靈活,允許資料從關係形式逐漸演變為矩陣表示,同時讓資料科學家控制最適合實現資料分析或模型訓練過程目標的表示。 -還有像 TileDB [^66] 這樣專門儲存大型多維數字陣列的資料庫;它們被稱為 *陣列資料庫*,最常用於科學資料集,如地理空間測量(規則間隔網格上的柵格資料)、醫學成像或天文望遠鏡的觀測 [^67]。資料框在金融行業也用於表示 *時間序列資料*,如資產價格和隨時間變化的交易 [^68]。 +還有像 TileDB [^66] 這樣專門儲存大型多維數字陣列的資料庫;它們被稱為 **陣列資料庫**,最常用於科學資料集,如地理空間測量(規則間隔網格上的柵格資料)、醫學成像或天文望遠鏡的觀測 [^67]。資料框在金融行業也用於表示 **時間序列資料**,如資產價格和隨時間變化的交易 [^68]。 ## 總結 {#summary} 資料模型是一個巨大的主題,在本章中,我們快速瀏覽了各種不同的模型。我們沒有空間深入每個模型的所有細節,但希望這個概述足以激發你的興趣,找出最適合你的應用需求的模型。 -*關係模型* 儘管已有半個多世紀的歷史,但對許多應用來說仍然是一個重要的資料模型——特別是在資料倉庫和商業分析中,關係星型或雪花模式和 SQL 查詢無處不在。然而,關係資料的幾種替代方案也在其他領域變得流行: +**關係模型** 儘管已有半個多世紀的歷史,但對許多應用來說仍然是一個重要的資料模型——特別是在資料倉庫和商業分析中,關係星型或雪花模式和 SQL 查詢無處不在。然而,關係資料的幾種替代方案也在其他領域變得流行: -* *文件模型* 針對資料以獨立的 JSON 文件形式出現的用例,以及一個文件與另一個文件之間的關係很少的情況。 -* *圖資料模型* 走向相反的方向,針對任何東西都可能與一切相關的用例,以及查詢可能需要遍歷多個跳躍才能找到感興趣的資料(可以使用 Cypher、SPARQL 或 Datalog 中的遞迴查詢來表達)。 -* *資料框* 將關係資料推廣到大量列,從而在資料庫和構成大量機器學習、統計資料分析和科學計算基礎的多維陣列之間提供橋樑。 +* **文件模型** 針對資料以獨立的 JSON 文件形式出現的用例,以及一個文件與另一個文件之間的關係很少的情況。 +* **圖資料模型** 走向相反的方向,針對任何東西都可能與一切相關的用例,以及查詢可能需要遍歷多個跳躍才能找到感興趣的資料(可以使用 Cypher、SPARQL 或 Datalog 中的遞迴查詢來表達)。 +* **資料框** 將關係資料推廣到大量列,從而在資料庫和構成大量機器學習、統計資料分析和科學計算基礎的多維陣列之間提供橋樑。 在某種程度上,一個模型可以用另一個模型來模擬——例如,圖資料可以在關係資料庫中表示——但結果可能很彆扭,正如我們在 SQL 中對遞迴查詢的支援中看到的那樣。 因此,為每個資料模型開發了各種專業資料庫,提供針對特定模型最佳化的查詢語言和儲存引擎。然而,資料庫也有透過新增對其他資料模型的支援來擴充套件到相鄰領域的趨勢:例如,關係資料庫以 JSON 列的形式添加了對文件資料的支援,文件資料庫添加了類似關係的連線,SQL 中對圖資料的支援也在逐步改進。 -我們討論的另一個模型是 *事件溯源*,它將資料表示為不可變事件的僅追加日誌,這對於建模複雜業務領域中的活動可能是有利的。僅追加日誌有利於寫入資料(正如我們將在 [第 4 章](/tw/ch4#ch_storage) 中看到的);為了支援高效查詢,事件日誌透過 CQRS 轉換為讀最佳化的物化檢視。 +我們討論的另一個模型是 **事件溯源**,它將資料表示為不可變事件的僅追加日誌,這對於建模複雜業務領域中的活動可能是有利的。僅追加日誌有利於寫入資料(正如我們將在 [第 4 章](/tw/ch4#ch_storage) 中看到的);為了支援高效查詢,事件日誌透過 CQRS 轉換為讀最佳化的物化檢視。 非關係資料模型的一個共同點是,它們通常不會對儲存的資料強制執行模式,這可以使應用更容易適應不斷變化的需求。然而,你的應用很可能仍然假設資料具有某種結構;這只是模式是顯式的(在寫入時強制執行)還是隱式的(在讀取時假設)的問題。 儘管我們涵蓋了很多內容,但仍有資料模型未被提及。僅舉幾個簡短的例子: -* 研究基因組資料的研究人員通常需要執行 *序列相似性搜尋*,這意味著獲取一個非常長的字串(代表 DNA 分子)並將其與相似但不相同的大量字串資料庫進行匹配。這裡描述的資料庫都無法處理這種用法,這就是研究人員編寫了像 GenBank [^69] 這樣的專門基因組資料庫軟體的原因。 -* 許多金融系統使用具有複式記賬的 *賬本* 作為其資料模型。這種型別的資料可以在關係資料庫中表示,但也有像 TigerBeetle 這樣專門研究這種資料模型的資料庫。加密貨幣和區塊鏈通常基於分散式賬本,它們的資料模型中也內建了價值轉移。 -* *全文檢索* 可以說是一種經常與資料庫一起使用的資料模型。資訊檢索是一個大型的專業主題,我們不會在本書中詳細介紹,但我們將在 ["全文檢索"](/tw/ch4#sec_storage_full_text) 中涉及搜尋索引和向量搜尋。 +* 研究基因組資料的研究人員通常需要執行 **序列相似性搜尋**,這意味著獲取一個非常長的字串(代表 DNA 分子)並將其與相似但不相同的大量字串資料庫進行匹配。這裡描述的資料庫都無法處理這種用法,這就是研究人員編寫了像 GenBank [^69] 這樣的專門基因組資料庫軟體的原因。 +* 許多金融系統使用具有複式記賬的 **賬本** 作為其資料模型。這種型別的資料可以在關係資料庫中表示,但也有像 TigerBeetle 這樣專門研究這種資料模型的資料庫。加密貨幣和區塊鏈通常基於分散式賬本,它們的資料模型中也內建了價值轉移。 +* **全文檢索** 可以說是一種經常與資料庫一起使用的資料模型。資訊檢索是一個大型的專業主題,我們不會在本書中詳細介紹,但我們將在 ["全文檢索"](/tw/ch4#sec_storage_full_text) 中涉及搜尋索引和向量搜尋。 -我們現在必須到此為止了。在下一章中,我們將討論在 *實現* 本章中描述的資料模型時出現的一些權衡。 +我們現在必須到此為止了。在下一章中,我們將討論在 **實現** 本章中描述的資料模型時出現的一些權衡。 diff --git a/content/tw/ch4.md b/content/tw/ch4.md index 06f3147..797e6ab 100644 --- a/content/tw/ch4.md +++ b/content/tw/ch4.md @@ -17,9 +17,9 @@ breadcrumbs: false 在 [第 3 章](/tw/ch3#ch_datamodels) 中,我們討論了資料模型和查詢語言 —— 即你向資料庫提供資料的格式,以及之後再次請求資料的介面。在本章中,我們從資料庫的角度討論同樣的問題:資料庫如何儲存你提供的資料,以及當你請求時如何再次找到這些資料。 -作為應用開發者,你為什麼要關心資料庫內部如何處理儲存和檢索?你可能不會從頭開始實現自己的儲存引擎,但你 *確實* 需要從眾多可用的儲存引擎中選擇一個適合你應用的。為了讓儲存引擎在你的工作負載型別上表現良好,你需要對儲存引擎在底層做了什麼有個大致的瞭解。 +作為應用開發者,你為什麼要關心資料庫內部如何處理儲存和檢索?你可能不會從頭開始實現自己的儲存引擎,但你 **確實** 需要從眾多可用的儲存引擎中選擇一個適合你應用的。為了讓儲存引擎在你的工作負載型別上表現良好,你需要對儲存引擎在底層做了什麼有個大致的瞭解。 -特別是,針對事務型工作負載(OLTP)最佳化的儲存引擎和針對分析型工作負載最佳化的儲存引擎之間存在巨大差異(我們在 ["分析型與事務型系統"](/tw/ch1#sec_introduction_analytics) 中介紹了這種區別)。本章首先研究兩種用於 OLTP 的儲存引擎家族:寫入不可變資料檔案的 *日誌結構* 儲存引擎,以及像 *B 樹* 這樣就地更新資料的儲存引擎。這些結構既用於鍵值儲存,也用於二級索引。 +特別是,針對事務型工作負載(OLTP)最佳化的儲存引擎和針對分析型工作負載最佳化的儲存引擎之間存在巨大差異(我們在 ["分析型與事務型系統"](/tw/ch1#sec_introduction_analytics) 中介紹了這種區別)。本章首先研究兩種用於 OLTP 的儲存引擎家族:寫入不可變資料檔案的 **日誌結構** 儲存引擎,以及像 **B 樹** 這樣就地更新資料的儲存引擎。這些結構既用於鍵值儲存,也用於二級索引。 隨後在 ["分析型資料儲存"](#sec_storage_analytics) 中,我們將討論一系列針對分析最佳化的儲存引擎;在 ["多維索引與全文索引"](#sec_storage_multidimensional) 中,我們將簡要介紹用於更高階查詢(如文字檢索)的索引。 @@ -67,7 +67,7 @@ $ cat database ``` -對於如此簡單的實現,`db_set` 函式實際上有相當好的效能,因為追加到檔案通常非常高效。與 `db_set` 所做的類似,許多資料庫內部使用 *日誌*,這是一個僅追加的資料檔案。真正的資料庫有更多問題要處理(如處理併發寫入、回收磁碟空間以防日誌無限增長,以及從崩潰中恢復時處理部分寫入的記錄),但基本原理是相同的。日誌非常有用,我們將在本書中多次遇到它們。 +對於如此簡單的實現,`db_set` 函式實際上有相當好的效能,因為追加到檔案通常非常高效。與 `db_set` 所做的類似,許多資料庫內部使用 **日誌**,這是一個僅追加的資料檔案。真正的資料庫有更多問題要處理(如處理併發寫入、回收磁碟空間以防日誌無限增長,以及從崩潰中恢復時處理部分寫入的記錄),但基本原理是相同的。日誌非常有用,我們將在本書中多次遇到它們。 --------- @@ -77,11 +77,11 @@ $ cat database -------- -另一方面,如果你的資料庫中有大量記錄,`db_get` 函式的效能會很糟糕。每次你想查詢一個鍵時,`db_get` 必須從頭到尾掃描整個資料庫檔案,尋找該鍵的出現。用算法術語來說,查詢的成本是 *O*(*n*):如果你的資料庫中的記錄數 *n* 翻倍,查詢時間也會翻倍。這並不好。 +另一方面,如果你的資料庫中有大量記錄,`db_get` 函式的效能會很糟糕。每次你想查詢一個鍵時,`db_get` 必須從頭到尾掃描整個資料庫檔案,尋找該鍵的出現。用算法術語來說,查詢的成本是 **O**(**n**):如果你的資料庫中的記錄數 **n** 翻倍,查詢時間也會翻倍。這並不好。 -為了高效地找到資料庫中特定鍵的值,我們需要一個不同的資料結構:*索引*。在本章中,我們將研究一系列索引結構並瞭解它們的比較;一般思想是以特定方式(例如,按某個鍵排序)構建資料,使定位所需資料更快。如果你想以幾種不同的方式搜尋相同的資料,你可能需要在資料的不同部分上建立幾個不同的索引。 +為了高效地找到資料庫中特定鍵的值,我們需要一個不同的資料結構:**索引**。在本章中,我們將研究一系列索引結構並瞭解它們的比較;一般思想是以特定方式(例如,按某個鍵排序)構建資料,使定位所需資料更快。如果你想以幾種不同的方式搜尋相同的資料,你可能需要在資料的不同部分上建立幾個不同的索引。 -索引是從主資料派生出的 *額外* 結構。許多資料庫允許你新增和刪除索引,這不會影響資料庫的內容;它只影響查詢的效能。維護額外的結構會產生開銷,特別是在寫入時。對於寫入,很難超越簡單地追加到檔案的效能,因為這是最簡單的寫入操作。任何型別的索引通常都會減慢寫入速度,因為每次寫入資料時也需要更新索引。 +索引是從主資料派生出的 **額外** 結構。許多資料庫允許你新增和刪除索引,這不會影響資料庫的內容;它只影響查詢的效能。維護額外的結構會產生開銷,特別是在寫入時。對於寫入,很難超越簡單地追加到檔案的效能,因為這是最簡單的寫入操作。任何型別的索引通常都會減慢寫入速度,因為每次寫入資料時也需要更新索引。 這是儲存系統中的一個重要權衡:精心選擇的索引加快了讀查詢速度,但每個索引都會消耗額外的磁碟空間並減慢寫入速度,有時會大幅減慢 [^1]。因此,資料庫通常不會預設為所有內容建立索引,而是要求你 —— 編寫應用程式或管理資料庫的人 —— 使用你對應用程式典型查詢模式的瞭解來手動選擇索引。然後你可以選擇為你的應用程式帶來最大收益的索引,而不會引入超過必要的寫入開銷。 @@ -102,11 +102,11 @@ $ cat database #### SSTable 檔案格式 {#the-sstable-file-format} -實際上,雜湊表很少用於資料庫索引,相反,保持資料 *按鍵排序* 的結構更為常見 [^3]。這種結構的一個例子是 *排序字串表*(*Sorted String Table*),簡稱 *SSTable*,如 [圖 4-2](#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]。 +現在你不需要在記憶體中保留所有鍵:你可以將 SSTable 中的鍵值對分組為幾千位元組的 **塊**,然後在索引中儲存每個塊的第一個鍵。這種只儲存部分鍵的索引稱為 **稀疏** 索引。這個索引儲存在 SSTable 的單獨部分,例如使用不可變 B 樹、字典樹或其他允許查詢快速查詢特定鍵的資料結構 [^4]。 例如,在 [圖 4-2](#fig_storage_sstable_index) 中,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。現在假設你要查詢鍵 `handiwork`,它沒有出現在稀疏索引中。由於排序,你知道 `handiwork` 必須出現在 `handbag` 和 `handsome` 之間。這意味著你可以尋找到 `handbag` 的偏移量,然後從那裡掃描檔案,直到找到 `handiwork`(或沒有,如果該鍵不在檔案中)。幾千位元組的塊可以非常快速地掃描。 @@ -116,24 +116,24 @@ $ cat database SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更加困難。我們不能簡單地追加到末尾,因為那樣檔案就不再有序了(除非鍵恰好按升序寫入)。如果我們每次在中間某處插入鍵時都必須重寫整個 SSTable,寫入將變得太昂貴。 -我們可以用 *日誌結構* 方法解決這個問題,這是僅追加日誌和排序檔案之間的混合: +我們可以用 **日誌結構** 方法解決這個問題,這是僅追加日誌和排序檔案之間的混合: -1. 當寫入操作到來時,將其新增到記憶體中的有序對映資料結構中,例如紅黑樹、跳錶 [^5] 或字典樹 [^6]。使用這些資料結構,你可以按任意順序插入鍵,高效地查詢它們,並按排序順序讀回它們。這個記憶體資料結構稱為 *記憶體表*(*memtable*)。 -2. 當記憶體表變得大於某個閾值(通常是幾兆位元組)時,將其按排序順序作為 SSTable 檔案寫入磁碟。我們將這個新的 SSTable 檔案稱為資料庫的最新 *段*,它與舊段一起作為單獨的檔案儲存。每個段都有自己內容的單獨索引。當新段被寫入磁碟時,資料庫可以繼續寫入新的記憶體表例項,當 SSTable 寫入完成時,舊記憶體表的記憶體被釋放。 +1. 當寫入操作到來時,將其新增到記憶體中的有序對映資料結構中,例如紅黑樹、跳錶 [^5] 或字典樹 [^6]。使用這些資料結構,你可以按任意順序插入鍵,高效地查詢它們,並按排序順序讀回它們。這個記憶體資料結構稱為 **記憶體表**(**memtable**)。 +2. 當記憶體表變得大於某個閾值(通常是幾兆位元組)時,將其按排序順序作為 SSTable 檔案寫入磁碟。我們將這個新的 SSTable 檔案稱為資料庫的最新 **段**,它與舊段一起作為單獨的檔案儲存。每個段都有自己內容的單獨索引。當新段被寫入磁碟時,資料庫可以繼續寫入新的記憶體表例項,當 SSTable 寫入完成時,舊記憶體表的記憶體被釋放。 3. 為了讀取某個鍵的值,首先嘗試在記憶體表和最新的磁碟段中找到該鍵。如果沒有找到,就在下一個較舊的段中查詢,依此類推,直到找到鍵或到達最舊的段。如果鍵沒有出現在任何段中,則它不存在於資料庫中。 4. 不時地在後臺執行合併和壓實過程,以合併段檔案並丟棄被覆蓋或刪除的值。 -合併段的工作方式類似於 *歸併排序* 演算法 [^5]。該過程如 [圖 4-3](#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" >}} 為了確保資料庫崩潰時記憶體表中的資料不會丟失,儲存引擎在磁碟上保留一個單獨的日誌,每次寫入都會立即追加到該日誌中。此日誌不按鍵排序,但這無關緊要,因為它的唯一目的是在崩潰後恢復記憶體表。每次記憶體表被寫出到 SSTable 後,日誌的相應部分就可以丟棄。 -如果你想刪除一個鍵及其關聯的值,你必須向資料檔案追加一個稱為 *墓碑*(*tombstone*)的特殊刪除記錄。當日誌段合併時,墓碑告訴合併過程丟棄已刪除鍵的任何先前值。一旦墓碑合併到最舊的段中,它就可以被丟棄。 +如果你想刪除一個鍵及其關聯的值,你必須向資料檔案追加一個稱為 **墓碑**(**tombstone**)的特殊刪除記錄。當日誌段合併時,墓碑告訴合併過程丟棄已刪除鍵的任何先前值。一旦墓碑合併到最舊的段中,它就可以被丟棄。 -這裡描述的演算法本質上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的演算法,它們都受到 Google 的 Bigtable 論文 [^9] 的啟發(該論文引入了 *SSTable* 和 *memtable* 這兩個術語)。 +這裡描述的演算法本質上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的演算法,它們都受到 Google 的 Bigtable 論文 [^9] 的啟發(該論文引入了 **SSTable** 和 **memtable** 這兩個術語)。 -該演算法最初於 1996 年以 *日誌結構合併樹*(*Log-Structured Merge-Tree*)或 *LSM 樹*(*LSM-Tree*)[^10] 的名稱釋出,建立在早期日誌結構檔案系統工作的基礎上 [^11]。因此,基於合併和壓實排序檔案原理的儲存引擎通常被稱為 *LSM 儲存引擎*。 +該演算法最初於 1996 年以 **日誌結構合併樹**(**Log-Structured Merge-Tree**)或 **LSM 樹**(**LSM-Tree**)[^10] 的名稱釋出,建立在早期日誌結構檔案系統工作的基礎上 [^11]。因此,基於合併和壓實排序檔案原理的儲存引擎通常被稱為 **LSM 儲存引擎**。 在 LSM 儲存引擎中,段檔案是一次性寫入的(透過寫出記憶體表或合併一些現有段),此後它是不可變的。段的合併和壓實可以在後臺執行緒中完成,當它進行時,我們仍然可以使用舊的段檔案繼續提供讀取服務。當合並過程完成時,我們將讀取請求切換到使用新的合併段而不是舊段,然後可以刪除舊的段檔案。 @@ -145,7 +145,7 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更 #### 布隆過濾器 {#bloom-filters} -使用 LSM 儲存,讀取很久以前更新的鍵或不存在的鍵可能會很慢,因為儲存引擎需要檢查多個段檔案。為了加快此類讀取,LSM 儲存引擎通常在每個段中包含一個 *布隆過濾器*(*Bloom filter*)[^13],它提供了一種快速但近似的方法來檢查特定鍵是否出現在特定 SSTable 中。 +使用 LSM 儲存,讀取很久以前更新的鍵或不存在的鍵可能會很慢,因為儲存引擎需要檢查多個段檔案。為了加快此類讀取,LSM 儲存引擎通常在每個段中包含一個 **布隆過濾器**(**Bloom filter**)[^13],它提供了一種快速但近似的方法來檢查特定鍵是否出現在特定 SSTable 中。 [圖 4-4](#fig_storage_bloom) 顯示了一個包含兩個鍵和 16 位的布隆過濾器示例(實際上,它會包含更多的鍵和更多的位)。對於 SSTable 中的每個鍵,我們計算一個雜湊函式,產生一組數字,然後將其解釋為位陣列的索引 [^14]。我們將對應於這些索引的位設定為 1,其餘保持為 0。例如,鍵 `handbag` 雜湊為數字 (2, 9, 4),所以我們將第 2、9 和 4 位設定為 1。然後將點陣圖與鍵的稀疏索引一起儲存為 SSTable 的一部分。這需要一點額外的空間,但與 SSTable 的其餘部分相比,布隆過濾器通常很小。 @@ -153,14 +153,14 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更 當我們想知道一個鍵是否出現在 SSTable 中時,我們像以前一樣計算該鍵的相同雜湊,並檢查這些索引處的位。例如,在 [圖 4-4](#fig_storage_bloom) 中,我們查詢鍵 `handheld`,它雜湊為 (6, 11, 2)。其中一個位是 1(即第 2 位),而另外兩個是 0。這些檢查可以使用所有 CPU 都支援的位運算非常快速地進行。 -如果至少有一個位是 0,我們知道該鍵肯定不在 SSTable 中。如果查詢中的位都是 1,那麼該鍵很可能在 SSTable 中,但也有可能是巧合,所有這些位都被其他鍵設定為 1。這種看起來鍵存在但實際上不存在的情況稱為 *假陽性*(*false positive*)。 +如果至少有一個位是 0,我們知道該鍵肯定不在 SSTable 中。如果查詢中的位都是 1,那麼該鍵很可能在 SSTable 中,但也有可能是巧合,所有這些位都被其他鍵設定為 1。這種看起來鍵存在但實際上不存在的情況稱為 **假陽性**(**false positive**)。 假陽性的機率取決於鍵的數量、每個鍵設定的位數和布隆過濾器中的總位數。你可以使用線上計算器工具為你的應用計算出正確的引數 [^15]。作為經驗法則,你需要為 SSTable 中的每個鍵分配 10 位布隆過濾器空間以獲得 1% 的假陽性機率,每為每個鍵分配額外的 5 位,機率就會降低十倍。 在 LSM 儲存引擎的上下文中,假陽性沒有問題: -* 如果布隆過濾器說鍵 *不* 存在,我們可以安全地跳過該 SSTable,因為我們可以確定它不包含該鍵。 -* 如果布隆過濾器說鍵 *存在*,我們必須查詢稀疏索引並解碼鍵值對塊以檢查鍵是否真的在那裡。如果是假陽性,我們做了一些不必要的工作,但除此之外沒有害處 —— 我們只是繼續使用下一個最舊的段進行搜尋。 +* 如果布隆過濾器說鍵 **不** 存在,我們可以安全地跳過該 SSTable,因為我們可以確定它不包含該鍵。 +* 如果布隆過濾器說鍵 **存在**,我們必須查詢稀疏索引並解碼鍵值對塊以檢查鍵是否真的在那裡。如果是假陽性,我們做了一些不必要的工作,但除此之外沒有害處 —— 我們只是繼續使用下一個最舊的段進行搜尋。 #### 壓實策略 {#sec_storage_lsm_compaction} @@ -182,7 +182,7 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更 > [!TIP] 嵌入式儲存引擎 -許多資料庫作為接受網路查詢的服務執行,但也有 *嵌入式* 資料庫不公開網路 API。相反,它們是在與應用程式程式碼相同的程序中執行的庫,通常讀取和寫入本地磁碟上的檔案,你透過正常的函式呼叫與它們互動。嵌入式儲存引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。 +許多資料庫作為接受網路查詢的服務執行,但也有 **嵌入式** 資料庫不公開網路 API。相反,它們是在與應用程式程式碼相同的程序中執行的庫,通常讀取和寫入本地磁碟上的檔案,你透過正常的函式呼叫與它們互動。嵌入式儲存引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。 嵌入式資料庫在移動應用中非常常用,用於儲存本地使用者的資料。在後端,如果資料足夠小以適合單臺機器,並且沒有太多併發事務,它們可能是一個合適的選擇。例如,在多租戶系統中,如果每個租戶足夠小且完全與其他租戶分離(即,你不需要執行合併多個租戶資料的查詢),你可能可以為每個租戶使用單獨的嵌入式資料庫例項 [^20]。 @@ -192,23 +192,23 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更 ### B 樹 {#sec_storage_b_trees} -日誌結構方法很流行,但它不是鍵值儲存的唯一形式。按鍵讀取和寫入資料庫記錄最廣泛使用的結構是 *B 樹*。 +日誌結構方法很流行,但它不是鍵值儲存的唯一形式。按鍵讀取和寫入資料庫記錄最廣泛使用的結構是 **B 樹**。 B 樹於 1970 年引入 [^21],不到 10 年後就被稱為"無處不在"[^22],它們經受住了時間的考驗。它們仍然是幾乎所有關係資料庫中的標準索引實現,許多非關係資料庫也使用它們。 像 SSTable 一樣,B 樹按鍵保持鍵值對排序,這允許高效的鍵值查詢和範圍查詢。但相似之處到此為止:B 樹有著非常不同的設計理念。 -我們之前看到的日誌結構索引將資料庫分解為可變大小的 *段*,通常為幾兆位元組或更大,寫入一次後就不可變。相比之下,B 樹將資料庫分解為固定大小的 *塊* 或 *頁*,並可能就地覆蓋頁。頁傳統上大小為 4 KiB,但 PostgreSQL 現在預設使用 8 KiB,MySQL 預設使用 16 KiB。 +我們之前看到的日誌結構索引將資料庫分解為可變大小的 **段**,通常為幾兆位元組或更大,寫入一次後就不可變。相比之下,B 樹將資料庫分解為固定大小的 **塊** 或 **頁**,並可能就地覆蓋頁。頁傳統上大小為 4 KiB,但 PostgreSQL 現在預設使用 8 KiB,MySQL 預設使用 16 KiB。 每個頁都可以使用頁號來標識,這允許一個頁引用另一個頁 —— 類似於指標,但在磁碟上而不是在記憶體中。如果所有頁都儲存在同一個檔案中,將頁號乘以頁大小就給我們檔案中頁所在位置的位元組偏移量。我們可以使用這些頁引用來構建頁樹,如 [圖 4-5](#fig_storage_b_tree) 所示。 {{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="圖 4-5. 使用 B 樹索引查詢鍵 251。從根頁開始,我們首先跟隨引用到鍵 200–300 的頁,然後是鍵 250–270 的頁。" class="w-full my-4" >}} -一個頁被指定為 B 樹的 *根*;每當你想在索引中查詢一個鍵時,你就從這裡開始。該頁包含幾個鍵和對子頁的引用。每個子頁負責一個連續的鍵範圍,引用之間的鍵指示這些範圍之間的邊界在哪裡。(這種結構有時稱為 B+ 樹,但我們不需要將其與其他 B 樹變體區分開來。) +一個頁被指定為 B 樹的 **根**;每當你想在索引中查詢一個鍵時,你就從這裡開始。該頁包含幾個鍵和對子頁的引用。每個子頁負責一個連續的鍵範圍,引用之間的鍵指示這些範圍之間的邊界在哪裡。(這種結構有時稱為 B+ 樹,但我們不需要將其與其他 B 樹變體區分開來。) -在 [圖 4-5](#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251,所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200–300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(*葉頁*),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。 +在 [圖 4-5](#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251,所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200–300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(**葉頁**),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。 -B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [圖 4-5](#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。 +B 樹的一個頁中對子頁的引用數稱為 **分支因子**。例如,在 [圖 4-5](#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。 如果你想更新 B 樹中現有鍵的值,你搜索包含該鍵的葉頁,並用包含新值的版本覆蓋磁碟上的該頁。如果你想新增一個新鍵,你需要找到其範圍包含新鍵的頁並將其新增到該頁。如果頁中沒有足夠的空閒空間來容納新鍵,則頁被分成兩個半滿的頁,並更新父頁以說明鍵範圍的新細分。 @@ -216,15 +216,15 @@ B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [ 在 [圖 4-6](#fig_storage_b_tree_split) 的例子中,我們想插入鍵 334,但範圍 333–345 的頁已經滿了。因此,我們將其分成範圍 333–337(包括新鍵)的頁和 337–344 的頁。我們還必須更新父頁以引用兩個子頁,它們之間的邊界值為 337。如果父頁沒有足夠的空間容納新引用,它也可能需要被分割,分割可以一直持續到樹的根。當根被分割時,我們在它上面建立一個新根。刪除鍵(可能需要合併節點)更複雜 [^5]。 -這個演算法確保樹保持 *平衡*:具有 *n* 個鍵的 B 樹始終具有 *O*(log *n*) 的深度。大多數資料庫可以適合三或四層深的 B 樹,所以你不需要跟隨許多頁引用來找到你要查詢的頁。(具有 500 分支因子的 4 KiB 頁的四層樹可以儲存多達 250 TB。) +這個演算法確保樹保持 **平衡**:具有 **n** 個鍵的 B 樹始終具有 **O**(log **n**) 的深度。大多數資料庫可以適合三或四層深的 B 樹,所以你不需要跟隨許多頁引用來找到你要查詢的頁。(具有 500 分支因子的 4 KiB 頁的四層樹可以儲存多達 250 TB。) #### 使 B 樹可靠 {#sec_storage_btree_wal} B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋不會改變頁的位置;即,當頁被覆蓋時,對該頁的所有引用保持不變。這與日誌結構索引(如 LSM 樹)形成鮮明對比,後者只追加到檔案(並最終刪除過時的檔案),但從不就地修改檔案。 -一次覆蓋多個頁,如在頁分割中,是一個危險的操作:如果資料庫在只寫入了部分頁後崩潰,你最終會得到一個損壞的樹(例如,可能有一個 *孤立* 頁,它不是任何父頁的子頁)。如果硬體不能原子地寫入整個頁,你也可能最終得到部分寫入的頁(這稱為 *撕裂頁*(*torn page*)[^23])。 +一次覆蓋多個頁,如在頁分割中,是一個危險的操作:如果資料庫在只寫入了部分頁後崩潰,你最終會得到一個損壞的樹(例如,可能有一個 **孤立** 頁,它不是任何父頁的子頁)。如果硬體不能原子地寫入整個頁,你也可能最終得到部分寫入的頁(這稱為 **撕裂頁**(**torn page**)[^23])。 -為了使資料庫對崩潰具有彈性,B 樹實現通常包括磁碟上的額外資料結構:*預寫日誌*(*write-ahead log*,WAL)。這是一個僅追加檔案,每個 B 樹修改必須在應用於樹本身的頁之前寫入其中。當資料庫在崩潰後恢復時,此日誌用於將 B 樹恢復到一致狀態 [^2] [^24]。在檔案系統中,等效機制稱為 *日誌記錄*(*journaling*)。 +為了使資料庫對崩潰具有彈性,B 樹實現通常包括磁碟上的額外資料結構:**預寫日誌**(**write-ahead log**,WAL)。這是一個僅追加檔案,每個 B 樹修改必須在應用於樹本身的頁之前寫入其中。當資料庫在崩潰後恢復時,此日誌用於將 B 樹恢復到一致狀態 [^2] [^24]。在檔案系統中,等效機制稱為 **日誌記錄**(**journaling**)。 為了提高效能,B 樹實現通常不會立即將每個修改的頁寫入磁碟,而是首先將 B 樹頁緩衝在記憶體中一段時間。預寫日誌還確保在崩潰的情況下資料不會丟失:只要資料已寫入 WAL,並使用 `fsync()` 系統呼叫重新整理到磁碟,資料就是持久的,因為資料庫將能夠在崩潰後恢復它 [^25]。 @@ -247,7 +247,7 @@ B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋 範圍查詢在 B 樹上簡單而快速,因為它們可以使用樹的排序結構。在 LSM 儲存上,範圍查詢也可以利用 SSTable 排序,但它們需要並行掃描所有段並組合結果。布隆過濾器對範圍查詢沒有幫助(因為你需要計算範圍內每個可能鍵的雜湊,這是不切實際的),使得範圍查詢在 LSM 方法中比點查詢更昂貴 [^29]。 -如果記憶體表填滿,高寫入吞吐量可能會導致日誌結構儲存引擎中的延遲峰值。如果資料無法足夠快地寫入磁碟,可能是因為壓實過程無法跟上傳入的寫入,就會發生這種情況。許多儲存引擎,包括 RocksDB,在這種情況下執行 *背壓*:它們暫停所有讀取和寫入,直到記憶體表被寫入磁碟 [^30] [^31]。 +如果記憶體表填滿,高寫入吞吐量可能會導致日誌結構儲存引擎中的延遲峰值。如果資料無法足夠快地寫入磁碟,可能是因為壓實過程無法跟上傳入的寫入,就會發生這種情況。許多儲存引擎,包括 RocksDB,在這種情況下執行 **背壓**:它們暫停所有讀取和寫入,直到記憶體表被寫入磁碟 [^30] [^31]。 關於讀取吞吐量,現代 SSD(特別是 NVMe)可以並行執行許多獨立的讀請求。LSM 樹和 B 樹都能夠提供高讀取吞吐量,但儲存引擎需要仔細設計以利用這種並行性 [^32]。 @@ -255,7 +255,7 @@ B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋 使用 B 樹時,如果應用程式寫入的鍵分散在整個鍵空間中,生成的磁碟操作也會隨機分散,因為儲存引擎需要覆蓋的頁可能位於磁碟的任何位置。另一方面,日誌結構儲存引擎一次寫入整個段檔案(無論是寫出記憶體表還是壓實現有段),這比 B 樹中的頁大得多。 -許多小的、分散的寫入模式(如 B 樹中的)稱為 *隨機寫入*,而較少的大寫入模式(如 LSM 樹中的)稱為 *順序寫入*。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟(HDD)上特別大;在今天大多數資料庫使用的固態硬碟(SSD)上,差異較小,但仍然明顯(參見 ["SSD 上的順序與隨機寫入"](#sidebar_sequential))。 +許多小的、分散的寫入模式(如 B 樹中的)稱為 **隨機寫入**,而較少的大寫入模式(如 LSM 樹中的)稱為 **順序寫入**。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟(HDD)上特別大;在今天大多數資料庫使用的固態硬碟(SSD)上,差異較小,但仍然明顯(參見 ["SSD 上的順序與隨機寫入"](#sidebar_sequential))。 -------- @@ -263,7 +263,7 @@ B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋 在旋轉磁碟硬碟(HDD)上,順序寫入比隨機寫入快得多:隨機寫入必須機械地將磁頭移動到新位置,並等待碟片的正確部分經過磁頭下方,這需要幾毫秒 —— 在計算時間尺度上是永恆的。然而,SSD(固態硬碟)包括 NVMe(Non-Volatile Memory Express,即連線到 PCI Express 匯流排的快閃記憶體)現在已經在許多場景中超越了 HDD,它們不受這種機械限制。 -儘管如此,SSD 對順序寫入的吞吐量也高於隨機寫入。原因是快閃記憶體可以一次讀取或寫入一頁(通常為 4 KiB),但只能一次擦除一個塊(通常為 512 KiB)。塊中的某些頁可能包含有效資料,而其他頁可能包含不再需要的資料。在擦除塊之前,控制器必須首先將包含有效資料的頁移動到其他塊中;這個過程稱為 *垃圾回收*(GC)[^33]。 +儘管如此,SSD 對順序寫入的吞吐量也高於隨機寫入。原因是快閃記憶體可以一次讀取或寫入一頁(通常為 4 KiB),但只能一次擦除一個塊(通常為 512 KiB)。塊中的某些頁可能包含有效資料,而其他頁可能包含不再需要的資料。在擦除塊之前,控制器必須首先將包含有效資料的頁移動到其他塊中;這個過程稱為 **垃圾回收**(GC)[^33]。 順序寫入工作負載一次寫入更大的資料塊,因此整個 512 KiB 塊很可能屬於單個檔案;當該檔案稍後再次被刪除時,整個塊可以被擦除而無需執行任何 GC。另一方面,對於隨機寫入工作負載,塊更可能包含有效和無效資料頁的混合,因此 GC 必須在塊可以擦除之前執行更多工作 [^34] [^35] [^36]。 @@ -277,7 +277,7 @@ GC 消耗的寫入頻寬就不能用於應用程式。此外,GC 執行的額 B 樹索引必須至少寫入每條資料兩次:一次寫入預寫日誌,一次寫入樹頁本身。此外,它們有時需要寫出整個頁,即使該頁中只有幾個位元組發生了變化,以確保 B 樹在崩潰或斷電後可以正確恢復 [^38] [^39]。 -如果你獲取在某個工作負載中寫入磁碟的總位元組數,然後除以如果你只是寫入沒有索引的僅追加日誌需要寫入的位元組數,你就得到了 *寫放大*。(有時寫放大是根據 I/O 操作而不是位元組來定義的。)在寫入密集型應用程式中,瓶頸可能是資料庫可以寫入磁碟的速率。在這種情況下,寫放大越高,它在可用磁碟頻寬內可以處理的每秒寫入次數就越少。 +如果你獲取在某個工作負載中寫入磁碟的總位元組數,然後除以如果你只是寫入沒有索引的僅追加日誌需要寫入的位元組數,你就得到了 **寫放大**。(有時寫放大是根據 I/O 操作而不是位元組來定義的。)在寫入密集型應用程式中,瓶頸可能是資料庫可以寫入磁碟的速率。在這種情況下,寫放大越高,它在可用磁碟頻寬內可以處理的每秒寫入次數就越少。 寫放大是 LSM 樹和 B 樹中的問題。哪個更好取決於各種因素,例如鍵和值的長度,以及你覆蓋現有鍵與插入新鍵的頻率。對於典型的工作負載,LSM 樹往往具有較低的寫放大,因為它們不必寫入整個頁,並且可以壓縮 SSTable 的塊 [^40]。這是使 LSM 儲存引擎非常適合寫入密集型工作負載的另一個因素。 @@ -287,7 +287,7 @@ B 樹索引必須至少寫入每條資料兩次:一次寫入預寫日誌,一 #### 磁碟空間使用 {#disk-space-usage} -B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了大量鍵,資料庫檔案可能包含許多 B 樹不再使用的頁。對 B 樹的後續新增可以使用這些空閒頁,但它們不能輕易地返回給作業系統,因為它們在檔案的中間,所以它們仍然佔用檔案系統上的空間。因此,資料庫需要一個後臺過程來移動頁以更好地放置它們,例如 PostgreSQL 中的真空過程 [^25]。 +B 樹可能會隨著時間的推移變得 **碎片化**:例如,如果刪除了大量鍵,資料庫檔案可能包含許多 B 樹不再使用的頁。對 B 樹的後續新增可以使用這些空閒頁,但它們不能輕易地返回給作業系統,因為它們在檔案的中間,所以它們仍然佔用檔案系統上的空間。因此,資料庫需要一個後臺過程來移動頁以更好地放置它們,例如 PostgreSQL 中的真空過程 [^25]。 碎片化在 LSM 樹中不太成問題,因為壓實過程無論如何都會定期重寫資料檔案,而且 SSTable 沒有未使用空間的頁。此外,SSTable 中的鍵值對塊可以更好地壓縮,因此通常比 B 樹在磁碟上產生更小的檔案。被覆蓋的鍵和值繼續消耗空間,直到它們被壓實刪除,但使用分級壓即時,這種開銷相當低 [^40] [^41]。分層壓實(參見 ["壓實策略"](#sec_storage_lsm_compaction))使用更多的磁碟空間,特別是在壓實期間臨時使用。 @@ -298,9 +298,9 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了 ### 多列索引與二級索引 {#sec_storage_index_multicolumn} -到目前為止,我們只討論了鍵值索引,它們就像關係模型中的 *主鍵* 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件,或圖資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或 ID)引用該行/文件/頂點,索引用於解析此類引用。 +到目前為止,我們只討論了鍵值索引,它們就像關係模型中的 **主鍵** 索引。主鍵唯一標識關係表中的一行,或文件資料庫中的一個文件,或圖資料庫中的一個頂點。資料庫中的其他記錄可以透過其主鍵(或 ID)引用該行/文件/頂點,索引用於解析此類引用。 -擁有 *二級索引* 也非常常見。在關係資料庫中,你可以使用 `CREATE INDEX` 命令在同一個表上建立多個二級索引,允許你按主鍵以外的列進行搜尋。例如,在 [第 3 章](/tw/ch3#ch_datamodels) 的 [圖 3-1](/tw/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一個二級索引,以便你可以在每個表中找到屬於同一使用者的所有行。 +擁有 **二級索引** 也非常常見。在關係資料庫中,你可以使用 `CREATE INDEX` 命令在同一個表上建立多個二級索引,允許你按主鍵以外的列進行搜尋。例如,在 [第 3 章](/tw/ch3#ch_datamodels) 的 [圖 3-1](/tw/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一個二級索引,以便你可以在每個表中找到屬於同一使用者的所有行。 二級索引可以很容易地從鍵值索引構建。主要區別在於,在二級索引中,索引值不一定是唯一的;也就是說,同一索引條目下可能有許多行(文件、頂點)。這可以透過兩種方式解決:要麼使索引中的每個值成為匹配行識別符號的列表(如全文索引中的倒排列表),要麼透過向其追加行識別符號使每個條目唯一。具有就地更新的儲存引擎(如 B 樹)和日誌結構儲存都可用於實現索引。 @@ -308,9 +308,9 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了 索引中的鍵是查詢搜尋的內容,但值可以是幾種東西之一: -* 如果實際資料(行、文件、頂點)直接儲存在索引結構中,則稱為 *聚簇索引*。例如,在 MySQL 的 InnoDB 儲存引擎中,表的主鍵始終是聚簇索引,在 SQL Server 中,你可以為每個表指定一個聚簇索引 [^43]。 -* 或者,值可以是對實際資料的引用:要麼是相關行的主鍵(InnoDB 對二級索引這樣做),要麼是對磁碟上位置的直接引用。在後一種情況下,儲存行的地方稱為 *堆檔案*,它以無特定順序儲存資料(它可能是僅追加的,或者它可能跟蹤已刪除的行以便稍後用新資料覆蓋它們)。例如,Postgres 使用堆檔案方法 [^44]。 -* 兩者之間的折中是 *覆蓋索引* 或 *包含列的索引*,它在索引中儲存表的 *某些* 列,除了在堆上或主鍵聚簇索引中儲存完整行 [^45]。這允許僅使用索引來回答某些查詢,而無需解析主鍵或檢視堆檔案(在這種情況下,索引被稱為 *覆蓋* 查詢)。這可以使某些查詢更快,但資料的重複意味著索引使用更多的磁碟空間並減慢寫入速度。 +* 如果實際資料(行、文件、頂點)直接儲存在索引結構中,則稱為 **聚簇索引**。例如,在 MySQL 的 InnoDB 儲存引擎中,表的主鍵始終是聚簇索引,在 SQL Server 中,你可以為每個表指定一個聚簇索引 [^43]。 +* 或者,值可以是對實際資料的引用:要麼是相關行的主鍵(InnoDB 對二級索引這樣做),要麼是對磁碟上位置的直接引用。在後一種情況下,儲存行的地方稱為 **堆檔案**,它以無特定順序儲存資料(它可能是僅追加的,或者它可能跟蹤已刪除的行以便稍後用新資料覆蓋它們)。例如,Postgres 使用堆檔案方法 [^44]。 +* 兩者之間的折中是 **覆蓋索引** 或 **包含列的索引**,它在索引中儲存表的 **某些** 列,除了在堆上或主鍵聚簇索引中儲存完整行 [^45]。這允許僅使用索引來回答某些查詢,而無需解析主鍵或檢視堆檔案(在這種情況下,索引被稱為 **覆蓋** 查詢)。這可以使某些查詢更快,但資料的重複意味著索引使用更多的磁碟空間並減慢寫入速度。 到目前為止討論的索引只將單個鍵對映到值。如果你需要同時查詢表的多個列(或文件中的多個欄位),請參見 ["多維索引與全文索引"](#sec_storage_multidimensional)。 @@ -320,7 +320,7 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了 本章到目前為止討論的資料結構都是對磁碟限制的回應。與主記憶體相比,磁碟很難處理。對於磁碟和 SSD,如果你想在讀取和寫入上獲得良好的效能,磁碟上的資料需要仔細布局。然而,我們容忍這種尷尬,因為磁碟有兩個顯著的優勢:它們是持久的(如果斷電,其內容不會丟失),並且它們每千兆位元組的成本比 RAM 低。 -隨著 RAM 變得更便宜,按每 GB 計價的成本優勢正在減弱。許多資料集根本沒有那麼大,因此將它們完全保留在記憶體中是完全可行的,甚至可以分佈在幾臺機器上。這導致了 *記憶體資料庫* 的發展。 +隨著 RAM 變得更便宜,按每 GB 計價的成本優勢正在減弱。許多資料集根本沒有那麼大,因此將它們完全保留在記憶體中是完全可行的,甚至可以分佈在幾臺機器上。這導致了 **記憶體資料庫** 的發展。 一些記憶體鍵值儲存,例如 Memcached,僅用於快取,如果機器重新啟動,資料丟失是可以接受的。但其他記憶體資料庫旨在實現永續性,這可以透過特殊硬體(例如電池供電的 RAM)、將更改日誌寫入磁碟、將定期快照寫入磁碟或將記憶體狀態複製到其他機器來實現。 @@ -337,7 +337,7 @@ Redis 和 Couchbase 透過非同步寫入磁碟提供弱永續性。 ## 分析型資料儲存 {#sec_storage_analytics} -資料倉庫的資料模型最常見的是關係型,因為 SQL 通常非常適合分析查詢。有許多圖形化資料分析工具可以生成 SQL 查詢、視覺化結果,並允許分析師探索資料(透過 *下鑽* 和 *切片切塊* 等操作)。 +資料倉庫的資料模型最常見的是關係型,因為 SQL 通常非常適合分析查詢。有許多圖形化資料分析工具可以生成 SQL 查詢、視覺化結果,並允許分析師探索資料(透過 **下鑽** 和 **切片切塊** 等操作)。 表面上,資料倉庫和關係型 OLTP 資料庫看起來很相似,因為它們都有 SQL 查詢介面。然而,系統的內部可能看起來完全不同,因為它們針對非常不同的查詢模式進行了最佳化。許多資料庫供應商現在專注於支援事務處理或分析工作負載,但不是兩者兼而有之。 @@ -387,11 +387,11 @@ GROUP BY 我們如何高效地執行這個查詢? -在大多數 OLTP 資料庫中,儲存是以 *面向行* 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。 +在大多數 OLTP 資料庫中,儲存是以 **面向行** 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。 為了處理像 [示例 4-1](#fig_storage_analytics_query) 這樣的查詢,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告訴儲存引擎在哪裡找到特定日期或特定產品的所有銷售。但是,面向行的儲存引擎仍然需要將所有這些行(每行包含超過 100 個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉不符合所需條件的行。這可能需要很長時間。 -*面向列*(或 *列式*)儲存背後的想法很簡單:不要將一行中的所有值儲存在一起,而是將每 *列* 中的所有值儲存在一起 [^56]。如果每列單獨儲存,查詢只需要讀取和解析該查詢中使用的那些列,這可以節省大量工作。[圖 4-7](#fig_column_store) 使用 [圖 3-5](/tw/ch3#fig_dwh_schema) 中事實表的擴充套件版本展示了這一原理。 +**面向列**(或 **列式**)儲存背後的想法很簡單:不要將一行中的所有值儲存在一起,而是將每 **列** 中的所有值儲存在一起 [^56]。如果每列單獨儲存,查詢只需要讀取和解析該查詢中使用的那些列,這可以節省大量工作。[圖 4-7](#fig_column_store) 使用 [圖 3-5](/tw/ch3#fig_dwh_schema) 中事實表的擴充套件版本展示了這一原理。 -------- @@ -412,23 +412,23 @@ GROUP BY 除了只從磁碟載入查詢所需的那些列之外,我們還可以透過壓縮資料進一步減少對磁碟吞吐量和網路頻寬的需求。幸運的是,面向列的儲存通常非常適合壓縮。 -看看 [圖 4-7](#fig_column_store) 中每列的值序列:它們看起來經常重複,這是壓縮的良好跡象。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是 *點陣圖編碼*,如 [圖 4-8](#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。 +通常,列中不同值的數量與行數相比很小(例如,零售商可能有數十億條銷售交易,但只有 100,000 種不同的產品)。我們現在可以將具有 **n** 個不同值的列轉換為 **n** 個單獨的點陣圖:每個不同值一個位圖,每行一位。如果該行具有該值,則該位為 1,否則為 0。 -一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 *稀疏* 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](#fig_bitmap_index) 底部所示。諸如 *咆哮點陣圖*(*roaring bitmaps*)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。 +一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 **稀疏** 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](#fig_bitmap_index) 底部所示。諸如 **咆哮點陣圖**(**roaring bitmaps**)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。 像這樣的點陣圖索引非常適合資料倉庫中常見的查詢型別。例如: `WHERE product_sk IN (31, 68, 69):` -: 載入 `product_sk = 31`、`product_sk = 68` 和 `product_sk = 69` 的三個點陣圖,並計算三個點陣圖的按位 *OR*,這可以非常高效地完成。 +: 載入 `product_sk = 31`、`product_sk = 68` 和 `product_sk = 69` 的三個點陣圖,並計算三個點陣圖的按位 **OR**,這可以非常高效地完成。 `WHERE product_sk = 30 AND store_sk = 3:` -: 載入 `product_sk = 30` 和 `store_sk = 3` 的點陣圖,並計算按位 *AND*。這有效是因為列以相同的順序包含行,所以一列點陣圖中的第 *k* 位對應於另一列點陣圖中第 *k* 位的同一行。 +: 載入 `product_sk = 30` 和 `store_sk = 3` 的點陣圖,並計算按位 **AND**。這有效是因為列以相同的順序包含行,所以一列點陣圖中的第 **k** 位對應於另一列點陣圖中第 **k** 位的同一行。 -點陣圖也可用於回答圖查詢,例如查詢社交網路中被使用者 *X* 關注並且也關注使用者 *Y* 的所有使用者 [^74]。列式資料庫還有各種其他壓縮方案,你可以在參考文獻中找到 [^75]。 +點陣圖也可用於回答圖查詢,例如查詢社交網路中被使用者 **X** 關注並且也關注使用者 **Y** 的所有使用者 [^74]。列式資料庫還有各種其他壓縮方案,你可以在參考文獻中找到 [^75]。 -------- @@ -441,7 +441,7 @@ GROUP BY 在列儲存中,行的儲存順序並不一定重要。最簡單的是按插入順序儲存它們,因為這樣插入新行只需追加到每列。但是,我們可以選擇強制執行順序,就像我們之前對 SSTable 所做的那樣,並將其用作索引機制。 -請注意,獨立排序每列是沒有意義的,因為那樣我們就不再知道列中的哪些項屬於同一行。我們只能重建一行,因為我們知道一列中的第 *k* 個項與另一列中的第 *k* 個項屬於同一行。 +請注意,獨立排序每列是沒有意義的,因為那樣我們就不再知道列中的哪些項屬於同一行。我們只能重建一行,因為我們知道一列中的第 **k** 個項與另一列中的第 **k** 個項屬於同一行。 相反,資料需要一次排序整行,即使它是按列儲存的。資料庫管理員可以使用他們對常見查詢的瞭解來選擇表應按哪些列排序。例如,如果查詢經常針對日期範圍(例如上個月),則將 `date_key` 作為第一個排序鍵可能是有意義的。然後查詢可以只掃描上個月的行,這將比掃描所有行快得多。 @@ -464,7 +464,7 @@ GROUP BY ### 查詢執行:編譯與向量化 {#sec_storage_vectorized} -用於分析的複雜 SQL 查詢被分解為由多個階段組成的 *查詢計劃*,稱為 *運算元*,這些運算元可能分佈在多臺機器上以並行執行。查詢規劃器可以透過選擇使用哪些運算元、以何種順序執行它們以及在哪裡執行每個運算元來執行大量最佳化。 +用於分析的複雜 SQL 查詢被分解為由多個階段組成的 **查詢計劃**,稱為 **運算元**,這些運算元可能分佈在多臺機器上以並行執行。查詢規劃器可以透過選擇使用哪些運算元、以何種順序執行它們以及在哪裡執行每個運算元來執行大量最佳化。 在每個運算元內,查詢引擎需要對列中的值執行各種操作,例如查詢值在特定值集中的所有行(可能作為連線的一部分),或檢查值是否大於 15。它還需要檢視同一行的幾列,例如查詢產品是香蕉且門店是某個特定目標門店的所有銷售交易。 @@ -489,11 +489,11 @@ GROUP BY ### 物化檢視與資料立方體 {#sec_storage_materialized_views} -我們之前在 ["物化和更新時間線"](/tw/ch2#sec_introduction_materializing) 中遇到了 *物化檢視*:在關係資料模型中,它們是表狀物件,其內容是某些查詢的結果。區別在於物化檢視是查詢結果的實際副本,寫入磁碟,而虛擬檢視只是編寫查詢的快捷方式。當你從虛擬檢視讀取時,SQL 引擎會即時將其擴充套件為檢視的基礎查詢,然後處理擴充套件的查詢。 +我們之前在 ["物化和更新時間線"](/tw/ch2#sec_introduction_materializing) 中遇到了 **物化檢視**:在關係資料模型中,它們是表狀物件,其內容是某些查詢的結果。區別在於物化檢視是查詢結果的實際副本,寫入磁碟,而虛擬檢視只是編寫查詢的快捷方式。當你從虛擬檢視讀取時,SQL 引擎會即時將其擴充套件為檢視的基礎查詢,然後處理擴充套件的查詢。 當基礎資料更改時,物化檢視需要相應更新。一些資料庫可以自動執行此操作,還有像 Materialize 這樣專門從事物化檢視維護的系統 [^81]。執行此類更新意味著寫入時需要更多工作,但物化檢視可以改善在重複需要執行相同查詢的工作負載中的讀取效能。 -*物化聚合* 是一種可以在資料倉庫中有用的物化檢視型別。如前所述,資料倉庫查詢通常涉及聚合函式,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果許多不同的查詢使用相同的聚合,每次都處理原始資料可能會很浪費。為什麼不快取查詢最常使用的一些計數或總和?*資料立方體*(*OLAP 立方體*)透過建立按不同維度分組的聚合網格來做到這一點 [^82]。[圖 4-10](#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" >}} @@ -510,9 +510,9 @@ GROUP BY 我們在本章前半部分看到的 B 樹和 LSM 樹允許對單個屬性進行範圍查詢:例如,如果鍵是使用者名稱,你可以使用它們作為索引來高效查詢所有以 L 開頭的名稱。但有時,按單個屬性搜尋是不夠的。 -最常見的多列索引型別稱為 *聯合索引*,它透過將一列追加到另一列來將幾個欄位組合成一個鍵(索引定義指定欄位以何種順序連線)。這就像老式的紙質電話簿,它提供從(*姓氏*、*名字*)到電話號碼的索引。由於排序順序,索引可用於查詢具有特定姓氏的所有人,或具有特定 *姓氏-名字* 組合的所有人。但是,如果你想查詢具有特定名字的所有人,索引是無用的。 +最常見的多列索引型別稱為 **聯合索引**,它透過將一列追加到另一列來將幾個欄位組合成一個鍵(索引定義指定欄位以何種順序連線)。這就像老式的紙質電話簿,它提供從(**姓氏**、**名字**)到電話號碼的索引。由於排序順序,索引可用於查詢具有特定姓氏的所有人,或具有特定 **姓氏-名字** 組合的所有人。但是,如果你想查詢具有特定名字的所有人,索引是無用的。 -另一方面,*多維索引* 允許你一次查詢多個列。在地理空間資料中這尤其重要。例如,餐廳搜尋網站可能有一個包含每個餐廳的緯度和經度的資料庫。當用戶在地圖上檢視餐廳時,網站需要搜尋使用者當前檢視的矩形地圖區域內的所有餐廳。這需要像以下這樣的二維範圍查詢: +另一方面,**多維索引** 允許你一次查詢多個列。在地理空間資料中這尤其重要。例如,餐廳搜尋網站可能有一個包含每個餐廳的緯度和經度的資料庫。當用戶在地圖上檢視餐廳時,網站需要搜尋使用者當前檢視的矩形地圖區域內的所有餐廳。這需要像以下這樣的二維範圍查詢: ```sql SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 @@ -523,30 +523,30 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 一種選擇是使用空間填充曲線將二維位置轉換為單個數字,然後使用常規 B 樹索引 [^83]。更常見的是,使用專門的空間索引,如 R 樹或 Bkd 樹 [^84];它們劃分空間,使附近的資料點傾向於分組在同一子樹中。例如,PostGIS 使用 PostgreSQL 的通用搜索樹索引設施將地理空間索引實現為 R 樹 [^85]。也可以使用規則間隔的三角形、正方形或六邊形網格 [^86]。 -多維索引不僅用於地理位置。例如,在電子商務網站上,你可以在維度(*紅色*、*綠色*、*藍色*)上使用三維索引來搜尋某個顏色範圍內的產品,或者在天氣觀測資料庫中,你可以在(*日期*、*溫度*)上有一個二維索引,以便有效地搜尋 2013 年期間溫度在 25 到 30°C 之間的所有觀測。使用一維索引,你必須掃描 2013 年的所有記錄(不管溫度),然後按溫度過濾它們,反之亦然。二維索引可以同時按時間戳和溫度縮小範圍 [^87]。 +多維索引不僅用於地理位置。例如,在電子商務網站上,你可以在維度(**紅色**、**綠色**、**藍色**)上使用三維索引來搜尋某個顏色範圍內的產品,或者在天氣觀測資料庫中,你可以在(**日期**、**溫度**)上有一個二維索引,以便有效地搜尋 2013 年期間溫度在 25 到 30°C 之間的所有觀測。使用一維索引,你必須掃描 2013 年的所有記錄(不管溫度),然後按溫度過濾它們,反之亦然。二維索引可以同時按時間戳和溫度縮小範圍 [^87]。 ### 全文檢索 {#sec_storage_full_text} 全文檢索允許你透過可能出現在文字中任何位置的關鍵字搜尋文字文件集合(網頁、產品描述等)[^88]。資訊檢索是一個大的專業主題,通常涉及特定於語言的處理:例如,幾種亞洲語言在單詞之間沒有空格或標點符號,因此將文字分割成單詞需要一個指示哪些字元序列構成單詞的模型。全文檢索還經常涉及匹配相似但不相同的單詞(例如拼寫錯誤或單詞的不同語法形式)和同義詞。這些問題超出了本書的範圍。 -然而,在其核心,你可以將全文檢索視為另一種多維查詢:在這種情況下,可能出現在文字中的每個單詞(*詞項*)是一個維度。包含詞項 *x* 的文件在維度 *x* 中的值為 1,不包含 *x* 的文件的值為 0。搜尋提到“紅蘋果”的文件意味著查詢在 *紅* 維度中查詢 1,同時在 *蘋果* 維度中查詢 1。維度數量可能因此非常大。 +然而,在其核心,你可以將全文檢索視為另一種多維查詢:在這種情況下,可能出現在文字中的每個單詞(**詞項**)是一個維度。包含詞項 **x** 的文件在維度 **x** 中的值為 1,不包含 **x** 的文件的值為 0。搜尋提到“紅蘋果”的文件意味著查詢在 **紅** 維度中查詢 1,同時在 **蘋果** 維度中查詢 1。維度數量可能因此非常大。 -許多搜尋引擎用來回答此類查詢的資料結構稱為 *倒排索引*。這是一個鍵值結構,其中鍵是詞項,值是包含該詞項的所有文件的 ID 列表(*倒排列表*)。如果文件 ID 是順序數字,倒排列表也可以表示為稀疏點陣圖,如 [圖 4-8](#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](#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]。 -除了將文字分解為單詞,另一種選擇是查詢長度為 *n* 的所有子字串,稱為 *n-gram*(*n 元語法*)。例如,字串 `"hello"` 的三元語法(*n* = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我們為所有三元語法構建倒排索引,我們就可以搜尋任意至少三個字元長的子字串。三元語法索引甚至允許在搜尋查詢中使用正則表示式;缺點是它們相當大 [^94]。 +除了將文字分解為單詞,另一種選擇是查詢長度為 **n** 的所有子字串,稱為 **n-gram**(**n 元語法**)。例如,字串 `"hello"` 的三元語法(**n** = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我們為所有三元語法構建倒排索引,我們就可以搜尋任意至少三個字元長的子字串。三元語法索引甚至允許在搜尋查詢中使用正則表示式;缺點是它們相當大 [^94]。 -為了處理文件或查詢中的拼寫錯誤,Lucene 能夠在一定編輯距離內搜尋文字中的單詞(編輯距離為 1 意味著已新增、刪除或替換了一個字母)[^95]。它透過將詞項集儲存為字元上的有限狀態自動機(類似於 *字典樹* [^96])並將其轉換為 *萊文斯坦自動機* 來實現,該自動機支援在給定編輯距離內高效搜尋單詞 [^97]。 +為了處理文件或查詢中的拼寫錯誤,Lucene 能夠在一定編輯距離內搜尋文字中的單詞(編輯距離為 1 意味著已新增、刪除或替換了一個字母)[^95]。它透過將詞項集儲存為字元上的有限狀態自動機(類似於 **字典樹** [^96])並將其轉換為 **萊文斯坦自動機** 來實現,該自動機支援在給定編輯距離內高效搜尋單詞 [^97]。 ### 向量嵌入 {#id92} 語義搜尋超越了同義詞和拼寫錯誤,試圖理解文件概念和使用者意圖。例如,如果你的幫助頁面中有一個標題為“取消訂閱”的頁面,使用者在搜尋“如何關閉我的賬戶”或“終止合同”時,仍應能找到這個頁面,即使查詢詞完全不同,但語義非常接近。 -為了理解文件的語義 —— 它的含義 —— 語義搜尋索引使用嵌入模型將文件轉換為浮點值向量,稱為 *向量嵌入*。向量表示多維空間中的一個點,每個浮點值表示文件沿著一個維度軸的位置。嵌入模型生成的向量嵌入在(這個多維空間中)彼此接近,當嵌入的輸入文件在語義上相似時。 +為了理解文件的語義 —— 它的含義 —— 語義搜尋索引使用嵌入模型將文件轉換為浮點值向量,稱為 **向量嵌入**。向量表示多維空間中的一個點,每個浮點值表示文件沿著一個維度軸的位置。嵌入模型生成的向量嵌入在(這個多維空間中)彼此接近,當嵌入的輸入文件在語義上相似時。 -------- @@ -559,7 +559,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 嵌入模型使用更大的向量(通常超過 1,000 個數字),但原理是相同的。我們不試圖理解各個數字的含義;它們只是嵌入模型指向抽象多維空間中位置的一種方式。搜尋引擎使用距離函式(如餘弦相似度或歐幾里得距離)來測量向量之間的距離。餘弦相似度測量兩個向量角度的餘弦以確定它們的接近程度,而歐幾里得距離測量空間中兩點之間的直線距離。 -許多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100] 都處理文字資料。這些模型通常實現為神經網路。研究人員繼續為影片、音訊和影像建立嵌入模型。最近,模型架構已經變成 *多模態* 的:單個模型可以為多種模態(如文字和影像)生成向量嵌入。 +許多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100] 都處理文字資料。這些模型通常實現為神經網路。研究人員繼續為影片、音訊和影像建立嵌入模型。最近,模型架構已經變成 **多模態** 的:單個模型可以為多種模態(如文字和影像)生成向量嵌入。 語義搜尋引擎在使用者輸入查詢時使用嵌入模型生成向量嵌入。使用者的查詢和相關上下文(例如使用者的位置)被輸入到嵌入模型中。嵌入模型生成查詢的向量嵌入後,搜尋引擎必須使用向量索引找到具有相似向量嵌入的文件。 @@ -569,7 +569,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 : 向量按原樣儲存在索引中。查詢必須讀取每個向量並測量其與查詢向量的距離。平面索引是準確的,但測量查詢與每個向量之間的距離很慢。 倒排檔案(IVF)索引 -: 向量空間被聚類為向量的分割槽(稱為 *質心*),以減少必須比較的向量數量。IVF 索引比平面索引更快,但只能給出近似結果:即使查詢和文件彼此接近,它們也可能落入不同的分割槽。對 IVF 索引的查詢首先定義 *探針*,這只是要檢查的分割槽數。使用更多探針的查詢將更準確,但會更慢,因為必須比較更多向量。 +: 向量空間被聚類為向量的分割槽(稱為 **質心**),以減少必須比較的向量數量。IVF 索引比平面索引更快,但只能給出近似結果:即使查詢和文件彼此接近,它們也可能落入不同的分割槽。對 IVF 索引的查詢首先定義 **探針**,這只是要檢查的分割槽數。使用更多探針的查詢將更準確,但會更慢,因為必須比較更多向量。 分層可導航小世界(HNSW) : HNSW 索引維護向量空間的多個層,如 [圖 4-11](#fig_vector_hnsw) 所示。每一層都表示為一個圖,其中節點表示向量,邊表示與附近向量的接近度。查詢首先在最頂層定位最近的向量,該層具有少量節點。然後查詢移動到下面一層的同一節點,並跟隨該層中的邊,該層連線更密集,尋找更接近查詢向量的向量。該過程繼續直到到達最後一層。與 IVF 索引一樣,HNSW 索引是近似的。 diff --git a/content/tw/ch5.md b/content/tw/ch5.md index 4a541ff..e696889 100644 --- a/content/tw/ch5.md +++ b/content/tw/ch5.md @@ -13,7 +13,7 @@ breadcrumbs: false > > 赫拉克利特,引自柏拉圖《克拉提魯斯》(公元前 360 年) -應用程式不可避免地會隨時間而變化。隨著新產品的推出、使用者需求被更深入地理解,或者業務環境發生變化,功能會被新增或修改。在 [第 2 章](/tw/ch2#ch_nonfunctional) 中,我們介紹了 *可演化性* 的概念:我們應該致力於構建易於適應變化的系統(參見 ["可演化性:讓變更更容易"](/tw/ch2#sec_introduction_evolvability))。 +應用程式不可避免地會隨時間而變化。隨著新產品的推出、使用者需求被更深入地理解,或者業務環境發生變化,功能會被新增或修改。在 [第 2 章](/tw/ch2#ch_nonfunctional) 中,我們介紹了 **可演化性** 的概念:我們應該致力於構建易於適應變化的系統(參見 ["可演化性:讓變更更容易"](/tw/ch2#sec_introduction_evolvability))。 在大多數情況下,應用程式功能的變更也需要其儲存資料的變更:可能需要捕獲新的欄位或記錄型別,或者現有資料需要以新的方式呈現。 @@ -21,7 +21,7 @@ breadcrumbs: false 當資料格式或模式發生變化時,通常需要對應用程式程式碼進行相應的更改(例如,你向記錄添加了一個新欄位,應用程式程式碼開始讀寫該欄位)。然而,在大型應用程式中,程式碼更改通常無法立即完成: -* 對於服務端應用程式,你可能希望執行 *滾動升級*(也稱為 *階段釋出*),每次將新版本部署到幾個節點,檢查新版本是否執行順利,然後逐步在所有節點上部署。這允許在不中斷服務的情況下部署新版本,從而鼓勵更頻繁的釋出和更好的可演化性。 +* 對於服務端應用程式,你可能希望執行 **滾動升級**(也稱為 **階段釋出**),每次將新版本部署到幾個節點,檢查新版本是否執行順利,然後逐步在所有節點上部署。這允許在不中斷服務的情況下部署新版本,從而鼓勵更頻繁的釋出和更好的可演化性。 * 對於客戶端應用程式,你要看使用者的意願,他們可能很長時間都不安裝更新。 這意味著新舊版本的程式碼,以及新舊資料格式,可能會同時在系統中共存。為了使系統繼續平穩執行,我們需要在兩個方向上保持相容性: @@ -47,7 +47,7 @@ breadcrumbs: false 1. 在記憶體中,資料儲存在物件、結構體、列表、陣列、雜湊表、樹等中。這些資料結構針對 CPU 的高效訪問和操作進行了最佳化(通常使用指標)。 2. 當你想要將資料寫入檔案或透過網路傳送時,必須將其編碼為某種自包含的位元組序列(例如,JSON 文件)。由於指標對任何其他程序都沒有意義,因此這種位元組序列表示通常與記憶體中常用的資料結構看起來截然不同。 -因此,我們需要在兩種表示之間進行某種轉換。從記憶體表示到位元組序列的轉換稱為 *編碼*(也稱為 *序列化* 或 *編組*),反向過程稱為 *解碼*(*解析*、*反序列化*、*反編組*)。 +因此,我們需要在兩種表示之間進行某種轉換。從記憶體表示到位元組序列的轉換稱為 **編碼**(也稱為 **序列化** 或 **編組**),反向過程稱為 **解碼**(**解析**、**反序列化**、**反編組**)。 -------- @@ -57,7 +57,7 @@ breadcrumbs: false -------- -也有例外情況不需要編碼/解碼——例如,當資料庫直接對從磁碟載入的壓縮資料進行操作時,如 ["查詢執行:編譯與向量化"](/tw/ch4#sec_storage_vectorized) 中所討論的。還有一些 *零複製* 資料格式,旨在在執行時和磁碟/網路上都可以使用,無需顯式轉換步驟,例如 Cap'n Proto 和 FlatBuffers。 +也有例外情況不需要編碼/解碼——例如,當資料庫直接對從磁碟載入的壓縮資料進行操作時,如 ["查詢執行:編譯與向量化"](/tw/ch4#sec_storage_vectorized) 中所討論的。還有一些 **零複製** 資料格式,旨在在執行時和磁碟/網路上都可以使用,無需顯式轉換步驟,例如 Cap'n Proto 和 FlatBuffers。 然而,大多數系統需要在記憶體物件和平面位元組序列之間進行轉換。由於這是一個如此常見的問題,有無數不同的庫和編碼格式可供選擇。讓我們簡要概述一下。 @@ -87,7 +87,7 @@ JSON、XML 和 CSV 是文字格式,因此在某種程度上是人類可讀的 * XML 模式和 JSON 模式功能強大,因此學習和實現起來相當複雜。由於資料的正確解釋(如數字和二進位制字串)取決於模式中的資訊,不使用 XML/JSON 模式的應用程式需要潛在地硬編碼適當的編碼/解碼邏輯。 * CSV 沒有任何模式,因此應用程式需要定義每行和每列的含義。如果應用程式更改添加了新行或列,你必須手動處理該更改。CSV 也是一種相當模糊的格式(如果值包含逗號或換行符會發生什麼?)。儘管其轉義規則已被正式指定 [^9],但並非所有解析器都正確實現它們。 -儘管存在這些缺陷,JSON、XML 和 CSV 對許多目的來說已經足夠好了。它們可能會繼續流行,特別是作為資料交換格式(即從一個組織向另一個組織傳送資料)。在這些情況下,只要人們就格式達成一致,格式有多漂亮或高效通常並不重要。讓不同組織就 *任何事情* 達成一致的困難超過了大多數其他問題。 +儘管存在這些缺陷,JSON、XML 和 CSV 對許多目的來說已經足夠好了。它們可能會繼續流行,特別是作為資料交換格式(即從一個組織向另一個組織傳送資料)。在這些情況下,只要人們就格式達成一致,格式有多漂亮或高效通常並不重要。讓不同組織就 **任何事情** 達成一致的困難超過了大多數其他問題。 #### JSON 模式 {#json-schema} @@ -95,7 +95,7 @@ JSON 模式已被廣泛採用,作為系統間交換或寫入儲存時對資料 JSON 模式規範提供了許多功能。模式包括標準原始型別,包括字串、數字、整數、物件、陣列、布林值或空值。但 JSON 模式還提供了一個單獨的驗證規範,允許開發人員在欄位上疊加約束。例如,`port` 欄位可能具有最小值 1 和最大值 65535。 -JSON 模式可以具有開放或封閉的內容模型。開放內容模型允許模式中未定義的任何欄位以任何資料型別存在,而封閉內容模型只允許顯式定義的欄位。JSON 模式中的開放內容模型在 `additionalProperties` 設定為 `true` 時啟用,這是預設值。因此,JSON 模式通常是對 *不允許* 內容的定義(即,任何已定義欄位上的無效值),而不是對模式中 *允許* 內容的定義。 +JSON 模式可以具有開放或封閉的內容模型。開放內容模型允許模式中未定義的任何欄位以任何資料型別存在,而封閉內容模型只允許顯式定義的欄位。JSON 模式中的開放內容模型在 `additionalProperties` 設定為 `true` 時啟用,這是預設值。因此,JSON 模式通常是對 **不允許** 內容的定義(即,任何已定義欄位上的無效值),而不是對模式中 **允許** 內容的定義。 開放內容模型功能強大,但可能很複雜。例如,假設你想定義一個從整數(如 ID)到字串的對映。JSON 沒有對映或字典型別,只有一個可以包含字串鍵和任何型別值的"物件"型別。然後,你可以使用 JSON 模式約束此型別,使鍵只能包含數字,值只能是字串,使用 `patternProperties` 和 `additionalProperties`,如 [示例 5-1](#fig_encoding_json_schema) 所示。 @@ -170,7 +170,7 @@ Protocol Buffers 附帶了一個程式碼生成工具,它接受像這裡顯示 與 [圖 5-2](#fig_encoding_messagepack) 類似,每個欄位都有一個型別註釋(指示它是字串、整數等)以及必要時的長度指示(例如字串的長度)。資料中出現的字串("Martin"、"daydreaming"、"hacking")也編碼為 ASCII(準確地說是 UTF-8),與之前類似。 -與 [圖 5-2](#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 之間的數字以兩個位元組編碼,等等。更大的數字使用更多位元組。 @@ -178,7 +178,7 @@ Protocol Buffers 沒有顯式的列表或陣列資料型別。相反,`interest #### 欄位標籤與模式演化 {#field-tags-and-schema-evolution} -我們之前說過,模式不可避免地需要隨時間而變化。我們稱之為 *模式演化*。Protocol Buffers 如何在保持向後和向前相容性的同時處理模式更改? +我們之前說過,模式不可避免地需要隨時間而變化。我們稱之為 **模式演化**。Protocol Buffers 如何在保持向後和向前相容性的同時處理模式更改? 從示例中可以看出,編碼記錄只是其編碼欄位的串聯。每個欄位由其標籤號(示例模式中的數字 `1`、`2`、`3`)標識,並帶有資料型別註釋(例如字串或整數)。如果未設定欄位值,則它會從編碼記錄中省略。由此可以看出,欄位標籤對編碼資料的含義至關重要。你可以更改模式中欄位的名稱,因為編碼資料從不引用欄位名,但你不能更改欄位的標籤,因為這會使所有現有的編碼資料無效。 @@ -227,15 +227,15 @@ record Person { {{< figure src="/fig/ddia_0504.png" id="fig_encoding_avro" caption="圖 5-4. 使用 Avro 編碼的示例記錄。" class="w-full my-4" >}} -要解析二進位制資料,你需要按照模式中出現的欄位順序進行遍歷,並使用模式告訴你每個欄位的資料型別。這意味著只有當讀取資料的程式碼使用與寫入資料的程式碼 *完全相同的模式* 時,二進位制資料才能被正確解碼。讀取器和寫入器之間的任何模式不匹配都意味著資料被錯誤解碼。 +要解析二進位制資料,你需要按照模式中出現的欄位順序進行遍歷,並使用模式告訴你每個欄位的資料型別。這意味著只有當讀取資料的程式碼使用與寫入資料的程式碼 **完全相同的模式** 時,二進位制資料才能被正確解碼。讀取器和寫入器之間的任何模式不匹配都意味著資料被錯誤解碼。 那麼,Avro 如何支援模式演化? #### 寫入者模式與讀取者模式 {#the-writers-schema-and-the-readers-schema} -當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式對資料進行編碼——例如,該模式可能被編譯到應用程式中。這被稱為 *寫入者模式*。 +當應用程式想要編碼一些資料(將其寫入檔案或資料庫,透過網路傳送等)時,它使用它知道的任何版本的模式對資料進行編碼——例如,該模式可能被編譯到應用程式中。這被稱為 **寫入者模式**。 -當應用程式想要解碼一些資料(從檔案或資料庫讀取,從網路接收等)時,它使用兩個模式:與用於編碼相同的寫入者模式,以及 *讀取者模式*,後者可能不同。這在 [圖 5-5](#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" >}} @@ -253,7 +253,7 @@ record Person { 如果你要新增一個沒有預設值的欄位,新讀取者將無法讀取舊寫入者寫入的資料,因此你會破壞向後相容性。如果你要刪除一個沒有預設值的欄位,舊讀取者將無法讀取新寫入者寫入的資料,因此你會破壞向前相容性。 -在某些程式語言中,`null` 是任何變數的可接受預設值,但在 Avro 中不是這樣:如果你想允許欄位為 null,你必須使用 *聯合型別*。例如,`union { null, long, string } field;` 表示 `field` 可以是數字、字串或 null。只有當 `null` 是聯合的第一個分支時,你才能將其用作預設值。這比預設情況下一切都可為空更冗長一些,但它透過明確什麼可以和不能為 null 來幫助防止錯誤 [^18]。 +在某些程式語言中,`null` 是任何變數的可接受預設值,但在 Avro 中不是這樣:如果你想允許欄位為 null,你必須使用 **聯合型別**。例如,`union { null, long, string } field;` 表示 `field` 可以是數字、字串或 null。只有當 `null` 是聯合的第一個分支時,你才能將其用作預設值。這比預設情況下一切都可為空更冗長一些,但它透過明確什麼可以和不能為 null 來幫助防止錯誤 [^18]。 更改欄位的資料型別是可能的,前提是 Avro 可以轉換該型別。更改欄位的名稱是可能的,但有點棘手:讀取者模式可以包含欄位名的別名,因此它可以將舊寫入者的模式欄位名與別名匹配。這意味著更改欄位名是向後相容的,但不是向前相容的。同樣,向聯合型別新增分支是向後相容的,但不是向前相容的。 @@ -280,7 +280,7 @@ record Person { 與 Protocol Buffers 相比,Avro 方法的一個優點是模式不包含任何標籤號。但為什麼這很重要?在模式中保留幾個數字有什麼問題? -區別在於 Avro 對 *動態生成* 的模式更友好。例如,假設你有一個關係資料庫,其內容你想要轉儲到檔案中,並且你想要使用二進位制格式來避免前面提到的文字格式(JSON、CSV、XML)的問題。如果你使用 Avro,你可以相當容易地從關係模式生成 Avro 模式(我們之前看到的 JSON 表示),並使用該模式對資料庫內容進行編碼,將其全部轉儲到 Avro 物件容器檔案中 [^22]。你可以為每個資料庫表生成記錄模式,每列成為該記錄中的一個欄位。資料庫中的列名對映到 Avro 中的欄位名。 +區別在於 Avro 對 **動態生成** 的模式更友好。例如,假設你有一個關係資料庫,其內容你想要轉儲到檔案中,並且你想要使用二進位制格式來避免前面提到的文字格式(JSON、CSV、XML)的問題。如果你使用 Avro,你可以相當容易地從關係模式生成 Avro 模式(我們之前看到的 JSON 表示),並使用該模式對資料庫內容進行編碼,將其全部轉儲到 Avro 物件容器檔案中 [^22]。你可以為每個資料庫表生成記錄模式,每列成為該記錄中的一個欄位。資料庫中的列名對映到 Avro 中的欄位名。 現在,如果資料庫模式發生變化(例如,表添加了一列並刪除了一列),你可以從更新的資料庫模式生成新的 Avro 模式,並以新的 Avro 模式匯出資料。資料匯出過程不需要關注模式更改——它可以在每次執行時簡單地進行模式轉換。讀取新資料檔案的任何人都會看到記錄的欄位已更改,但由於欄位是按名稱標識的,因此更新的寫入者模式仍然可以與舊的讀取者模式匹配。 @@ -318,21 +318,21 @@ record Person { ### 流經資料庫的資料流 {#sec_encoding_dataflow_db} -在資料庫中,寫入資料庫的程序對資料進行編碼,從資料庫讀取的程序對其進行解碼。可能只有一個程序訪問資料庫,在這種情況下,讀取者只是同一程序的後續版本——在這種情況下,你可以將在資料庫中儲存某些內容視為 *向未來的自己傳送訊息*。 +在資料庫中,寫入資料庫的程序對資料進行編碼,從資料庫讀取的程序對其進行解碼。可能只有一個程序訪問資料庫,在這種情況下,讀取者只是同一程序的後續版本——在這種情況下,你可以將在資料庫中儲存某些內容視為 **向未來的自己傳送訊息**。 向後相容性在這裡顯然是必要的;否則你未來的自己將無法解碼你之前寫的內容。 通常,幾個不同的程序同時訪問資料庫是很常見的。這些程序可能是幾個不同的應用程式或服務,或者它們可能只是同一服務的幾個例項(為了可伸縮性或容錯而並行執行)。無論哪種方式,在應用程式正在更改的環境中,某些訪問資料庫的程序可能正在執行較新的程式碼,而某些程序正在執行較舊的程式碼——例如,因為新版本當前正在滾動升級中部署,因此某些例項已更新,而其他例項尚未更新。 -這意味著資料庫中的值可能由 *較新* 版本的程式碼寫入,隨後由仍在執行的 *較舊* 版本的程式碼讀取。因此,資料庫通常也需要向前相容性。 +這意味著資料庫中的值可能由 **較新** 版本的程式碼寫入,隨後由仍在執行的 **較舊** 版本的程式碼讀取。因此,資料庫通常也需要向前相容性。 #### 不同時間寫入的不同值 {#different-values-written-at-different-times} 資料庫通常允許在任何時間更新任何值。這意味著在單個數據庫中,你可能有一些五毫秒前寫入的值,以及一些五年前寫入的值。 -當你部署應用程式的新版本時(至少是服務端應用程式),你可能會在幾分鐘內用新版本完全替換舊版本。資料庫內容並非如此:五年前的資料仍然存在,採用原始編碼,除非你自那時以來明確重寫了它。這種觀察有時被總結為 *資料比程式碼更長壽*。 +當你部署應用程式的新版本時(至少是服務端應用程式),你可能會在幾分鐘內用新版本完全替換舊版本。資料庫內容並非如此:五年前的資料仍然存在,採用原始編碼,除非你自那時以來明確重寫了它。這種觀察有時被總結為 **資料比程式碼更長壽**。 -將資料重寫(*遷移*)為新模式當然是可能的,但在大型資料集上這是一件昂貴的事情,因此大多數資料庫儘可能避免它。大多數關係資料庫允許簡單的模式更改,例如新增具有 `null` 預設值的新列,而無需重寫現有資料。從磁碟上的編碼資料中缺少的任何列讀取舊行時,資料庫會為其填充 `null`。因此,模式演化允許整個資料庫看起來好像是用單個模式編碼的,即使底層儲存可能包含用各種歷史版本的模式編碼的記錄。 +將資料重寫(**遷移**)為新模式當然是可能的,但在大型資料集上這是一件昂貴的事情,因此大多數資料庫儘可能避免它。大多數關係資料庫允許簡單的模式更改,例如新增具有 `null` 預設值的新列,而無需重寫現有資料。從磁碟上的編碼資料中缺少的任何列讀取舊行時,資料庫會為其填充 `null`。因此,模式演化允許整個資料庫看起來好像是用單個模式編碼的,即使底層儲存可能包含用各種歷史版本的模式編碼的記錄。 更複雜的模式更改——例如,將單值屬性更改為多值,或將某些資料移動到單獨的表中——仍然需要重寫資料,通常在應用程式級別 [^27]。在此類遷移中保持向前和向後相容性仍然是一個研究問題 [^28]。 @@ -346,7 +346,7 @@ record Person { ### 流經服務的資料流:REST 與 RPC {#sec_encoding_dataflow_rpc} -當你有需要透過網路進行通訊的程序時,有幾種不同的方式來安排這種通訊。最常見的安排是有兩個角色:*客戶端* 和 *伺服器*。伺服器透過網路公開 API,客戶端可以連線到伺服器以向該 API 發出請求。伺服器公開的 API 稱為 *服務*。 +當你有需要透過網路進行通訊的程序時,有幾種不同的方式來安排這種通訊。最常見的安排是有兩個角色:**客戶端** 和 **伺服器**。伺服器透過網路公開 API,客戶端可以連線到伺服器以向該 API 發出請求。伺服器公開的 API 稱為 **服務**。 Web 就是這樣工作的:客戶端(Web 瀏覽器)向 Web 伺服器發出請求,發出 `GET` 請求以下載 HTML、CSS、JavaScript、影像等,併發出 `POST` 請求以向伺服器提交資料。API 由一組標準化的協議和資料格式(HTTP、URL、SSL/TLS、HTML 等)組成。由於 Web 瀏覽器、Web 伺服器和網站作者大多同意這些標準,因此你可以使用任何 Web 瀏覽器訪問任何網站(至少在理論上!)。 @@ -358,13 +358,13 @@ Web 瀏覽器不是唯一型別的客戶端。例如,在移動裝置和桌面 #### Web 服務 {#sec_web_services} -當 HTTP 用作與服務通訊的底層協議時,它被稱為 *Web 服務*。Web 服務通常用於構建面向服務或微服務架構(在 ["微服務與 Serverless"](/tw/ch1#sec_introduction_microservices) 中討論過)。術語"Web 服務"可能有點用詞不當,因為 Web 服務不僅用於 Web,還用於幾種不同的上下文。例如: +當 HTTP 用作與服務通訊的底層協議時,它被稱為 **Web 服務**。Web 服務通常用於構建面向服務或微服務架構(在 ["微服務與 Serverless"](/tw/ch1#sec_introduction_microservices) 中討論過)。術語"Web 服務"可能有點用詞不當,因為 Web 服務不僅用於 Web,還用於幾種不同的上下文。例如: 1. 在使用者裝置上執行的客戶端應用程式(例如,移動裝置上的原生應用程式,或瀏覽器中的 JavaScript Web 應用程式)向服務發出 HTTP 請求。這些請求通常透過公共網際網路進行。 2. 一個服務向同一組織擁有的另一個服務發出請求,通常位於同一資料中心內,作為面向服務/微服務架構的一部分。 3. 一個服務向不同組織擁有的服務發出請求,通常透過網際網路。這用於不同組織後端系統之間的資料交換。此類別包括線上服務提供的公共 API,例如信用卡處理系統或用於共享訪問使用者資料的 OAuth。 -最流行的服務設計理念是 REST,它建立在 HTTP 的原則之上 [^30] [^31]。它強調簡單的資料格式,使用 URL 來標識資源,並使用 HTTP 功能進行快取控制、身份驗證和內容型別協商。根據 REST 原則設計的 API 稱為 *RESTful*。 +最流行的服務設計理念是 REST,它建立在 HTTP 的原則之上 [^30] [^31]。它強調簡單的資料格式,使用 URL 來標識資源,並使用 HTTP 功能進行快取控制、身份驗證和內容型別協商。根據 REST 原則設計的 API 稱為 **RESTful**。 需要呼叫 Web 服務 API 的程式碼必須知道要查詢哪個 HTTP 端點,以及傳送什麼資料格式以及預期的響應。即使服務採用 RESTful 設計原則,客戶端也需要以某種方式找出這些詳細資訊。服務開發人員通常使用介面定義語言(IDL)來定義和記錄其服務的 API 端點和資料模型,並隨著時間的推移演化它們。然後,其他開發人員可以使用服務定義來確定如何查詢服務。兩種最流行的服務 IDL 是 OpenAPI(也稱為 Swagger [^32])和 gRPC。OpenAPI 用於傳送和接收 JSON 資料的 Web 服務,而 gRPC 服務傳送和接收 Protocol Buffers。 @@ -421,11 +421,11 @@ async def ping(): Web 服務只是透過網路進行 API 請求的一長串技術的最新化身,其中許多技術獲得了大量炒作但存在嚴重問題。Enterprise JavaBeans (EJB) 和 Java 的遠端方法呼叫 (RMI) 僅限於 Java。分散式元件物件模型 (DCOM) 僅限於 Microsoft 平臺。公共物件請求代理架構 (CORBA) 過於複雜,並且不提供向後或向前相容性 [^33]。SOAP 和 WS-\* Web 服務框架旨在提供跨供應商的互操作性,但也受到複雜性和相容性問題的困擾 [^34] [^35] [^36]。 -所有這些都基於 *遠端過程呼叫* (RPC) 的想法,這個想法自 1970 年代以來就存在了 [^37]。RPC 模型試圖使向遠端網路服務的請求看起來與在程式語言中呼叫函式或方法相同,在同一程序內(這種抽象稱為 *位置透明性*)。儘管 RPC 起初似乎很方便,但這種方法從根本上是有缺陷的 [^38] [^39]。網路請求與本地函式呼叫非常不同: +所有這些都基於 **遠端過程呼叫** (RPC) 的想法,這個想法自 1970 年代以來就存在了 [^37]。RPC 模型試圖使向遠端網路服務的請求看起來與在程式語言中呼叫函式或方法相同,在同一程序內(這種抽象稱為 **位置透明性**)。儘管 RPC 起初似乎很方便,但這種方法從根本上是有缺陷的 [^38] [^39]。網路請求與本地函式呼叫非常不同: * 本地函式呼叫是可預測的,要麼成功要麼失敗,僅取決於你控制的引數。網路請求是不可預測的:由於網路問題,請求或響應可能會丟失,或者遠端機器可能速度慢或不可用,而這些問題完全超出了你的控制。網路問題很常見,因此你必須預料到它們,例如透過重試失敗的請求。 -* 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永不返回(因為它進入無限迴圈或程序崩潰)。網路請求有另一種可能的結果:它可能由於 *超時* 而沒有返回結果。在這種情況下,你根本不知道發生了什麼:如果你沒有從遠端服務獲得響應,你無法知道請求是否透過。(我們在 [第 9 章](/tw/ch9#ch_distributed) 中更詳細地討論了這個問題。) -* 如果你重試失敗的網路請求,可能會發生前一個請求實際上已經成功,只是響應丟失了。在這種情況下,重試將導致操作執行多次,除非你在協議中構建去重機制(*冪等性*)[^40]。本地函式呼叫沒有這個問題。(我們在 [“冪等性”](/tw/ch12#sec_stream_idempotence) 中更詳細地討論冪等性。) +* 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永不返回(因為它進入無限迴圈或程序崩潰)。網路請求有另一種可能的結果:它可能由於 **超時** 而沒有返回結果。在這種情況下,你根本不知道發生了什麼:如果你沒有從遠端服務獲得響應,你無法知道請求是否透過。(我們在 [第 9 章](/tw/ch9#ch_distributed) 中更詳細地討論了這個問題。) +* 如果你重試失敗的網路請求,可能會發生前一個請求實際上已經成功,只是響應丟失了。在這種情況下,重試將導致操作執行多次,除非你在協議中構建去重機制(**冪等性**)[^40]。本地函式呼叫沒有這個問題。(我們在 [“冪等性”](/tw/ch12#sec_stream_idempotence) 中更詳細地討論冪等性。) * 每次呼叫本地函式時,通常需要大約相同的時間來執行。網路請求比函式呼叫慢得多,其延遲也變化很大:在良好的時候,它可能在不到一毫秒內完成,但當網路擁塞或遠端服務過載時,執行完全相同的操作可能需要許多秒。 * 當你呼叫本地函式時,你可以有效地將引用(指標)傳遞給本地記憶體中的物件。當你發出網路請求時,所有這些引數都需要編碼為可以透過網路傳送的位元組序列。如果引數是不可變的原語,如數字或短字串,那沒問題,但對於更大量的資料和可變物件,它很快就會出現問題。 * 客戶端和服務可能以不同的程式語言實現,因此 RPC 框架必須將資料型別從一種語言轉換為另一種語言。這可能會變得很醜陋,因為並非所有語言都具有相同的型別——例如,回想一下 JavaScript 處理大於 2⁵³ 的數字的問題(參見 ["JSON、XML 及其二進位制變體"](#sec_encoding_json))。單一語言編寫的單個程序中不存在此問題。 @@ -434,17 +434,17 @@ Web 服務只是透過網路進行 API 請求的一長串技術的最新化身 #### 負載均衡器、服務發現和服務網格 {#sec_encoding_service_discovery} -所有服務都透過網路進行通訊。因此,客戶端必須知道它正在連線的服務的地址——這個問題稱為 *服務發現*。最簡單的方法是配置客戶端連線到執行服務的 IP 地址和埠。此配置可以工作,但如果伺服器離線、轉移到新機器或變得過載,則必須手動重新配置客戶端。 +所有服務都透過網路進行通訊。因此,客戶端必須知道它正在連線的服務的地址——這個問題稱為 **服務發現**。最簡單的方法是配置客戶端連線到執行服務的 IP 地址和埠。此配置可以工作,但如果伺服器離線、轉移到新機器或變得過載,則必須手動重新配置客戶端。 -為了提供更高的可用性和可伸縮性,通常在不同的機器上執行服務的多個例項,其中任何一個都可以處理傳入的請求。將請求分散到這些例項上稱為 *負載均衡* [^41]。有許多負載均衡和服務發現解決方案可用: +為了提供更高的可用性和可伸縮性,通常在不同的機器上執行服務的多個例項,其中任何一個都可以處理傳入的請求。將請求分散到這些例項上稱為 **負載均衡** [^41]。有許多負載均衡和服務發現解決方案可用: -* *硬體負載均衡器* 是安裝在資料中心的專用裝置。它們允許客戶端連線到單個主機和埠,傳入連線被路由到執行服務的伺服器之一。此類負載均衡器在連線到下游伺服器時檢測網路故障,並將流量轉移到其他伺服器。 -* *軟體負載均衡器* 的行為方式與硬體負載均衡器大致相同。但是,軟體負載均衡器(如 Nginx 和 HAProxy)不需要特殊裝置,而是可以安裝在標準機器上的應用程式。 -* *域名服務 (DNS)* 是當你開啟網頁時在網際網路上解析域名的方式。它透過允許多個 IP 地址與單個域名關聯來支援負載均衡。然後,客戶端可以配置為使用域名而不是 IP 地址連線到服務,並且客戶端的網路層在建立連線時選擇要使用的 IP 地址。這種方法的一個缺點是 DNS 旨在在較長時間內傳播更改並快取 DNS 條目。如果伺服器頻繁啟動、停止或移動,客戶端可能會看到不再有伺服器執行的陳舊 IP 地址。 -* *服務發現系統* 使用集中式登錄檔而不是 DNS 來跟蹤哪些服務端點可用。當新服務例項啟動時,它透過宣告它正在偵聽的主機和埠以及相關元資料(如分片所有權資訊(參見 [第 7 章](/tw/ch7#ch_sharding))、資料中心位置等)向服務發現系統註冊自己。然後,服務定期向發現系統傳送心跳訊號,以表明服務仍然可用。 +* **硬體負載均衡器** 是安裝在資料中心的專用裝置。它們允許客戶端連線到單個主機和埠,傳入連線被路由到執行服務的伺服器之一。此類負載均衡器在連線到下游伺服器時檢測網路故障,並將流量轉移到其他伺服器。 +* **軟體負載均衡器** 的行為方式與硬體負載均衡器大致相同。但是,軟體負載均衡器(如 Nginx 和 HAProxy)不需要特殊裝置,而是可以安裝在標準機器上的應用程式。 +* **域名服務 (DNS)** 是當你開啟網頁時在網際網路上解析域名的方式。它透過允許多個 IP 地址與單個域名關聯來支援負載均衡。然後,客戶端可以配置為使用域名而不是 IP 地址連線到服務,並且客戶端的網路層在建立連線時選擇要使用的 IP 地址。這種方法的一個缺點是 DNS 旨在在較長時間內傳播更改並快取 DNS 條目。如果伺服器頻繁啟動、停止或移動,客戶端可能會看到不再有伺服器執行的陳舊 IP 地址。 +* **服務發現系統** 使用集中式登錄檔而不是 DNS 來跟蹤哪些服務端點可用。當新服務例項啟動時,它透過宣告它正在偵聽的主機和埠以及相關元資料(如分片所有權資訊(參見 [第 7 章](/tw/ch7#ch_sharding))、資料中心位置等)向服務發現系統註冊自己。然後,服務定期向發現系統傳送心跳訊號,以表明服務仍然可用。 當客戶端希望連線到服務時,它首先查詢發現系統以獲取可用端點列表,然後直接連線到端點。與 DNS 相比,服務發現支援服務例項頻繁更改的更動態環境。發現系統還為客戶端提供有關它們正在連線的服務的更多元資料,這使客戶端能夠做出更智慧的負載均衡決策。 -* *服務網格* 是一種複雜的負載均衡形式,它結合了軟體負載均衡器和服務發現。與在單獨機器上執行的傳統軟體負載均衡器不同,服務網格負載均衡器通常作為程序內客戶端庫或作為客戶端和伺服器上的程序或"邊車"容器部署。客戶端應用程式連線到它們自己的本地服務負載均衡器,該負載均衡器連線到伺服器的負載均衡器。從那裡,連線被路由到本地伺服器程序。 +* **服務網格** 是一種複雜的負載均衡形式,它結合了軟體負載均衡器和服務發現。與在單獨機器上執行的傳統軟體負載均衡器不同,服務網格負載均衡器通常作為程序內客戶端庫或作為客戶端和伺服器上的程序或"邊車"容器部署。客戶端應用程式連線到它們自己的本地服務負載均衡器,該負載均衡器連線到伺服器的負載均衡器。從那裡,連線被路由到本地伺服器程序。 雖然複雜,但這種拓撲提供了許多優勢。由於客戶端和伺服器完全透過本地連線路由,因此連線加密可以完全在負載均衡器級別處理。這使客戶端和伺服器免於處理 SSL 證書和 TLS 的複雜性。網格系統還提供複雜的可觀測性。它們可以即時跟蹤哪些服務正在相互呼叫,檢測故障,跟蹤流量負載等。 @@ -467,7 +467,7 @@ RPC 方案的向後和向前相容性屬性繼承自它使用的任何編碼: 根據定義,基於服務的架構具有多個服務,這些服務都負責應用程式的不同部分。考慮一個處理信用卡並將資金存入銀行賬戶的支付處理應用程式。該系統可能有不同的服務負責欺詐檢測、信用卡整合、銀行整合等。 -在我們的示例中,處理單個付款需要許多服務呼叫。支付處理器服務可能會呼叫欺詐檢測服務以檢查欺詐,呼叫信用卡服務以扣除信用卡費用,並呼叫銀行服務以存入扣除的資金,如 [圖 5-7](#fig_encoding_workflow) 所示。我們將這一系列步驟稱為 *工作流*,每個步驟稱為 *任務*。工作流通常定義為任務圖。工作流定義可以用通用程式語言、領域特定語言 (DSL) 或標記語言(如業務流程執行語言 (BPEL))[^44] 編寫。 +在我們的示例中,處理單個付款需要許多服務呼叫。支付處理器服務可能會呼叫欺詐檢測服務以檢查欺詐,呼叫信用卡服務以扣除信用卡費用,並呼叫銀行服務以存入扣除的資金,如 [圖 5-7](#fig_encoding_workflow) 所示。我們將這一系列步驟稱為 **工作流**,每個步驟稱為 **任務**。工作流通常定義為任務圖。工作流定義可以用通用程式語言、領域特定語言 (DSL) 或標記語言(如業務流程執行語言 (BPEL))[^44] 編寫。 -------- @@ -480,17 +480,17 @@ RPC 方案的向後和向前相容性屬性繼承自它使用的任何編碼: {{< figure src="/fig/ddia_0507.png" id="fig_encoding_workflow" title="圖 5-7. 使用業務流程模型和標記法 (BPMN) 表示的工作流示例,這是一種圖形標記法。" class="w-full my-4" >}} -工作流由 *工作流引擎* 執行或執行。工作流引擎確定何時執行每個任務、任務必須在哪臺機器上執行、如果任務失敗該怎麼辦(例如,如果機器在任務執行時崩潰)、允許並行執行多少任務等。 +工作流由 **工作流引擎** 執行或執行。工作流引擎確定何時執行每個任務、任務必須在哪臺機器上執行、如果任務失敗該怎麼辦(例如,如果機器在任務執行時崩潰)、允許並行執行多少任務等。 工作流引擎通常由編排器和執行器組成。編排器負責排程要執行的任務,執行器負責執行任務。當工作流被觸發時,執行開始。如果使用者定義了基於時間的排程(例如每小時執行),則編排器會自行觸發工作流。外部源(如 Web 服務)甚至人類也可以觸發工作流執行。一旦觸發,就會呼叫執行器來執行任務。 -有許多型別的工作流引擎可以滿足各種各樣的用例。有些,如 Airflow、Dagster 和 Prefect,與資料系統整合並編排 ETL 任務。其他的,如 Camunda 和 Orkes,為工作流提供圖形標記法(如 [圖 5-7](#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](#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。 +持久化執行框架是為工作流提供 **恰好一次語義** 的一種方式。如果任務失敗,框架將重新執行該任務,但會跳過任務在失敗之前成功完成的任何 RPC 呼叫或狀態更改。相反,框架將假裝進行呼叫,但實際上將返回先前呼叫的結果。這是可能的,因為持久化執行框架將所有 RPC 和狀態更改記錄到持久儲存(如預寫日誌)[^45] [^46]。[示例 5-5](#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。 {{< figure id="fig_temporal_workflow" title="示例 5-5. [圖 5-7](#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定義片段。" class="w-full my-4" >}} @@ -527,7 +527,7 @@ class PaymentWorkflow: ### 事件驅動的架構 {#sec_encoding_dataflow_msg} -在這最後一節中,我們將簡要介紹 *事件驅動架構*,這是編碼資料從一個程序流向另一個程序的另一種方式。請求稱為 *事件* 或 *訊息*;與 RPC 不同,傳送者通常不會等待接收者處理事件。此外,事件通常不是透過直接網路連線傳送給接收者,而是透過稱為 *訊息代理*(也稱為 *事件代理*、*訊息佇列* 或 *面向訊息的中介軟體*)的中介,它臨時儲存訊息 [^50]。 +在這最後一節中,我們將簡要介紹 **事件驅動架構**,這是編碼資料從一個程序流向另一個程序的另一種方式。請求稱為 **事件** 或 **訊息**;與 RPC 不同,傳送者通常不會等待接收者處理事件。此外,事件通常不是透過直接網路連線傳送給接收者,而是透過稱為 **訊息代理**(也稱為 **事件代理**、**訊息佇列** 或 **面向訊息的中介軟體**)的中介,它臨時儲存訊息 [^50]。 使用訊息代理與直接 RPC 相比有幾個優點: @@ -537,7 +537,7 @@ class PaymentWorkflow: * 它允許將相同的訊息傳送給多個接收者。 * 它在邏輯上將傳送者與接收者解耦(傳送者只是釋出訊息,不關心誰使用它們)。 -透過訊息代理的通訊是 *非同步的*:傳送者不會等待訊息被傳遞,而是簡單地傳送它然後忘記它。可以透過讓傳送者在單獨的通道上等待響應來實現類似同步 RPC 的模型。 +透過訊息代理的通訊是 **非同步的**:傳送者不會等待訊息被傳遞,而是簡單地傳送它然後忘記它。可以透過讓傳送者在單獨的通道上等待響應來實現類似同步 RPC 的模型。 #### 訊息代理 {#message-brokers} @@ -545,8 +545,8 @@ class PaymentWorkflow: 詳細的傳遞語義因實現和配置而異,但通常,最常使用兩種訊息分發模式: -* 一個程序將訊息新增到命名 *佇列*,代理將該訊息傳遞給該佇列的 *消費者*。如果有多個消費者,其中一個會收到訊息。 -* 一個程序將訊息釋出到命名 *主題*,代理將該訊息傳遞給該主題的所有 *訂閱者*。如果有多個訂閱者,他們都會收到訊息。 +* 一個程序將訊息新增到命名 **佇列**,代理將該訊息傳遞給該佇列的 **消費者**。如果有多個消費者,其中一個會收到訊息。 +* 一個程序將訊息釋出到命名 **主題**,代理將該訊息傳遞給該主題的所有 **訂閱者**。如果有多個訂閱者,他們都會收到訊息。 訊息代理通常不強制執行任何特定的資料模型——訊息只是帶有一些元資料的位元組序列,因此你可以使用任何編碼格式。常見的方法是使用 Protocol Buffers、Avro 或 JSON,並在訊息代理旁邊部署模式登錄檔來儲存所有有效的模式版本並檢查其相容性 [^19] [^21]。AsyncAPI(OpenAPI 的基於訊息傳遞的等效物)也可用於指定訊息的模式。 @@ -556,9 +556,9 @@ class PaymentWorkflow: #### 分散式 actor 框架 {#distributed-actor-frameworks} -*Actor 模型* 是單個程序中併發的程式設計模型。與其直接處理執行緒(以及相關的競態條件、鎖定和死鎖問題),邏輯被封裝在 *actor* 中。每個 actor 通常代表一個客戶端或實體,它可能有一些本地狀態(不與任何其他 actor 共享),並透過傳送和接收非同步訊息與其他 actor 通訊。訊息傳遞不能保證:在某些錯誤場景中,訊息將丟失。由於每個 actor 一次只處理一條訊息,因此它不需要擔心執行緒,並且每個 actor 可以由框架獨立排程。 +**Actor 模型** 是單個程序中併發的程式設計模型。與其直接處理執行緒(以及相關的競態條件、鎖定和死鎖問題),邏輯被封裝在 **actor** 中。每個 actor 通常代表一個客戶端或實體,它可能有一些本地狀態(不與任何其他 actor 共享),並透過傳送和接收非同步訊息與其他 actor 通訊。訊息傳遞不能保證:在某些錯誤場景中,訊息將丟失。由於每個 actor 一次只處理一條訊息,因此它不需要擔心執行緒,並且每個 actor 可以由框架獨立排程。 -在 *分散式 actor 框架* 中,如 Akka、Orleans [^51] 和 Erlang/OTP,此程式設計模型用於跨多個節點擴充套件應用程式。無論傳送者和接收者是在同一節點還是不同節點上,都使用相同的訊息傳遞機制。如果它們在不同的節點上,訊息將透明地編碼為位元組序列,透過網路傳送,並在另一端解碼。 +在 **分散式 actor 框架** 中,如 Akka、Orleans [^51] 和 Erlang/OTP,此程式設計模型用於跨多個節點擴充套件應用程式。無論傳送者和接收者是在同一節點還是不同節點上,都使用相同的訊息傳遞機制。如果它們在不同的節點上,訊息將透明地編碼為位元組序列,透過網路傳送,並在另一端解碼。 位置透明性在 actor 模型中比在 RPC 中效果更好,因為 actor 模型已經假定訊息可能會丟失,即使在單個程序內也是如此。儘管網路上的延遲可能比同一程序內的延遲更高,但在使用 actor 模型時,本地和遠端通訊之間的根本不匹配較少。 @@ -569,7 +569,7 @@ class PaymentWorkflow: 在本章中,我們研究了將資料結構轉換為網路上的位元組或磁碟上的位元組的幾種方法。我們看到了這些編碼的細節不僅影響其效率,更重要的是還影響應用程式的架構和演化選項。 -特別是,許多服務需要支援滾動升級,其中服務的新版本逐步部署到少數節點,而不是同時部署到所有節點。滾動升級允許在不停機的情況下發布服務的新版本(從而鼓勵頻繁的小版本釋出而不是罕見的大版本釋出),並使部署風險更低(允許在影響大量使用者之前檢測和回滾有故障的版本)。這些屬性對 *可演化性* 非常有益,即輕鬆進行應用程式更改。 +特別是,許多服務需要支援滾動升級,其中服務的新版本逐步部署到少數節點,而不是同時部署到所有節點。滾動升級允許在不停機的情況下發布服務的新版本(從而鼓勵頻繁的小版本釋出而不是罕見的大版本釋出),並使部署風險更低(允許在影響大量使用者之前檢測和回滾有故障的版本)。這些屬性對 **可演化性** 非常有益,即輕鬆進行應用程式更改。 在滾動升級期間,或出於其他各種原因,我們必須假設不同的節點正在執行我們應用程式程式碼的不同版本。因此,重要的是系統中流動的所有資料都以提供向後相容性(新程式碼可以讀取舊資料)和向前相容性(舊程式碼可以讀取新資料)的方式進行編碼。 diff --git a/content/tw/ch6.md b/content/tw/ch6.md index 59c85c2..9f84ba3 100644 --- a/content/tw/ch6.md +++ b/content/tw/ch6.md @@ -329,7 +329,7 @@ Poons 先生 單主複製模型的自然擴充套件是允許多個節點接受寫入。複製仍然以相同的方式進行:每個處理寫入的節點必須將該資料變更轉發給所有其他節點。我們稱之為 **多主** 配置(也稱為 **主動/主動** 或 **雙向** 複製)。在這種設定中,每個領導者同時充當其他領導者的追隨者。 -與單主複製一樣,可以選擇使其同步或非同步。假設你有兩個領導者,*A* 和 *B*,你正在嘗試寫入 *A*。如果寫入從 *A* 同步複製到 *B*,並且兩個節點之間的網路中斷,你就無法寫入 *A* 直到網路恢復。同步多主複製因此給你一個非常類似於單主複製的模型,即如果你讓 *B* 成為領導者,*A* 只是將任何寫入請求轉發給 *B* 執行。 +與單主複製一樣,可以選擇使其同步或非同步。假設你有兩個領導者,**A** 和 **B**,你正在嘗試寫入 **A**。如果寫入從 **A** 同步複製到 **B**,並且兩個節點之間的網路中斷,你就無法寫入 **A** 直到網路恢復。同步多主複製因此給你一個非常類似於單主複製的模型,即如果你讓 **B** 成為領導者,**A** 只是將任何寫入請求轉發給 **B** 執行。 因此,我們不會進一步討論同步多主複製,而只是將其視為等同於單主複製。本節的其餘部分專注於非同步多主複製,其中任何領導者都可以處理寫入,即使其與其他領導者的連線中斷。 @@ -575,9 +575,9 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32], 如果我們知道每次成功的寫入都保證至少存在於三個副本中的兩個上,這意味著最多一個副本可能是陳舊的。因此,如果我們從至少兩個副本讀取,我們可以確信兩個中至少有一個是最新的。如果第三個副本宕機或響應緩慢,讀取仍然可以繼續返回最新值。 -更一般地說,如果有 *n* 個副本,每次寫入必須由 *w* 個節點確認才能被認為成功,並且我們必須為每次讀取查詢至少 *r* 個節點。(在我們的例子中,*n* = 3,*w* = 2,*r* = 2。)只要 *w* + *r* > *n*,我們在讀取時期望獲得最新值,因為我們讀取的 *r* 個節點中至少有一個必須是最新的。遵守這些 *r* 和 *w* 值的讀取和寫入稱為 **仲裁** 讀取和寫入 [^50]。你可以將 *r* 和 *w* 視為讀取或寫入有效所需的最小投票數。 +更一般地說,如果有 **n** 個副本,每次寫入必須由 **w** 個節點確認才能被認為成功,並且我們必須為每次讀取查詢至少 **r** 個節點。(在我們的例子中,**n** = 3,**w** = 2,**r** = 2。)只要 **w** + **r** > **n**,我們在讀取時期望獲得最新值,因為我們讀取的 **r** 個節點中至少有一個必須是最新的。遵守這些 **r** 和 **w** 值的讀取和寫入稱為 **仲裁** 讀取和寫入 [^50]。你可以將 **r** 和 **w** 視為讀取或寫入有效所需的最小投票數。 -在 Dynamo 風格的資料庫中,引數 *n*、*w* 和 *r* 通常是可配置的。常見的選擇是使 *n* 為奇數(通常為 3 或 5),並設定 *w* = *r* = (*n* + 1) / 2(向上舍入)。然而,你可以根據需要更改數字。例如,寫入很少而讀取很多的工作負載可能受益於設定 *w* = *n* 和 *r* = 1。這使讀取更快,但缺點是僅一個失敗的節點就會導致所有資料庫寫入失敗。 +在 Dynamo 風格的資料庫中,引數 **n**、**w** 和 **r** 通常是可配置的。常見的選擇是使 **n** 為奇數(通常為 3 或 5),並設定 **w** = **r** = (**n** + 1) / 2(向上舍入)。然而,你可以根據需要更改數字。例如,寫入很少而讀取很多的工作負載可能受益於設定 **w** = **n** 和 **r** = 1。這使讀取更快,但缺點是僅一個失敗的節點就會導致所有資料庫寫入失敗。 -------- @@ -586,40 +586,40 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32], -------- -仲裁條件 *w* + *r* > *n* 允許系統容忍不可用節點,如下所示: +仲裁條件 **w** + **r** > **n** 允許系統容忍不可用節點,如下所示: -* 如果 *w* < *n*,如果節點不可用,我們仍然可以處理寫入。 -* 如果 *r* < *n*,如果節點不可用,我們仍然可以處理讀取。 -* 使用 *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) 中說明。 +* 如果 **w** < **n**,如果節點不可用,我們仍然可以處理寫入。 +* 如果 **r** < **n**,如果節點不可用,我們仍然可以處理讀取。 +* 使用 **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* 個節點中有多少個需要報告成功。 +通常,讀取和寫入總是並行傳送到所有 **n** 個副本。引數 **w** 和 **r** 確定我們等待多少個節點——即,在我們認為讀取或寫入成功之前,**n** 個節點中有多少個需要報告成功。 {{< figure src="/fig/ddia_0613.png" id="fig_replication_quorum_overlap" caption="圖 6-13. 如果 *w* + *r* > *n*,你讀取的 *r* 個副本中至少有一個必須看到最近的成功寫入。" class="w-full my-4" >}} -如果少於所需的 *w* 或 *r* 個節點可用,寫入或讀取將返回錯誤。節點可能因許多原因不可用:因為節點宕機(崩潰、斷電)、由於執行操作時出錯(無法寫入因為磁碟已滿)、由於客戶端和節點之間的網路中斷,或任何其他原因。我們只關心節點是否返回了成功響應,不需要區分不同型別的故障。 +如果少於所需的 **w** 或 **r** 個節點可用,寫入或讀取將返回錯誤。節點可能因許多原因不可用:因為節點宕機(崩潰、斷電)、由於執行操作時出錯(無法寫入因為磁碟已滿)、由於客戶端和節點之間的網路中斷,或任何其他原因。我們只關心節點是否返回了成功響應,不需要區分不同型別的故障。 ### 仲裁一致性的侷限 {#sec_replication_quorum_limitations} -如果你有 *n* 個副本,並且你選擇 *w* 和 *r* 使得 *w* + *r* > *n*,你通常可以期望每次讀取都返回為鍵寫入的最新值。這是因為你寫入的節點集和你讀取的節點集必須重疊。也就是說,在你讀取的節點中,必須至少有一個具有最新值的節點(如 [圖 6-13](#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]。 +通常,**r** 和 **w** 被選擇為多數(超過 **n**/2)節點,因為這確保了 **w** + **r** > **n**,同時仍然容忍最多 **n**/2(向下舍入)個節點故障。但仲裁不一定是多數——重要的是讀取和寫入操作使用的節點集至少在一個節點中重疊。其他仲裁分配是可能的,這允許分散式演算法設計中的一些靈活性 [^51]。 -你也可以將 *w* 和 *r* 設定為較小的數字,使得 *w* + *r* ≤ *n*(即,不滿足仲裁條件)。在這種情況下,讀取和寫入仍將傳送到 *n* 個節點,但需要較少的成功響應數才能使操作成功。 +你也可以將 **w** 和 **r** 設定為較小的數字,使得 **w** + **r** ≤ **n**(即,不滿足仲裁條件)。在這種情況下,讀取和寫入仍將傳送到 **n** 個節點,但需要較少的成功響應數才能使操作成功。 -使用較小的 *w* 和 *r*,你更有可能讀取陳舊值,因為你的讀取更可能沒有包含具有最新值的節點。從好的方面來說,這種配置允許更低的延遲和更高的可用性:如果存在網路中斷並且許多副本變得無法訪問,你繼續處理讀取和寫入的機會更高。只有在可訪問副本的數量低於 *w* 或 *r* 之後,資料庫才分別變得無法寫入或讀取。 +使用較小的 **w** 和 **r**,你更有可能讀取陳舊值,因為你的讀取更可能沒有包含具有最新值的節點。從好的方面來說,這種配置允許更低的延遲和更高的可用性:如果存在網路中斷並且許多副本變得無法訪問,你繼續處理讀取和寫入的機會更高。只有在可訪問副本的數量低於 **w** 或 **r** 之後,資料庫才分別變得無法寫入或讀取。 -然而,即使使用 *w* + *r* > *n*,在某些邊緣情況下,一致性屬性可能會令人困惑。一些場景包括: +然而,即使使用 **w** + **r** > **n**,在某些邊緣情況下,一致性屬性可能會令人困惑。一些場景包括: -* 如果攜帶新值的節點失敗,並且其資料從攜帶舊值的副本恢復,儲存新值的副本數量可能低於 *w*,破壞仲裁條件。 -* 在重新平衡正在進行時,其中一些資料從一個節點移動到另一個節點(見 [第 7 章](/tw/ch7#ch_sharding)),節點可能對哪些節點應該持有特定值的 *n* 個副本有不一致的檢視。這可能導致讀取和寫入仲裁不再重疊。 +* 如果攜帶新值的節點失敗,並且其資料從攜帶舊值的副本恢復,儲存新值的副本數量可能低於 **w**,破壞仲裁條件。 +* 在重新平衡正在進行時,其中一些資料從一個節點移動到另一個節點(見 [第 7 章](/tw/ch7#ch_sharding)),節點可能對哪些節點應該持有特定值的 **n** 個副本有不一致的檢視。這可能導致讀取和寫入仲裁不再重疊。 * 如果讀取與寫入操作併發,讀取可能會或可能不會看到併發寫入的值。特別是,一次讀取可能看到新值,而後續讀取看到舊值,正如我們將在 ["線性一致性與仲裁"](/tw/ch10#sec_consistency_quorum_linearizable) 中看到的。 -* 如果寫入在某些副本上成功但在其他副本上失敗(例如,因為某些節點上的磁碟已滿),並且總體上在少於 *w* 個副本上成功,它不會在成功的副本上回滾。這意味著如果寫入被報告為失敗,後續讀取可能會或可能不會返回該寫入的值 [^52]。 +* 如果寫入在某些副本上成功但在其他副本上失敗(例如,因為某些節點上的磁碟已滿),並且總體上在少於 **w** 個副本上成功,它不會在成功的副本上回滾。這意味著如果寫入被報告為失敗,後續讀取可能會或可能不會返回該寫入的值 [^52]。 * 如果資料庫使用即時時鐘的時間戳來確定哪個寫入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一個具有更快時鐘的節點已寫入同一鍵,寫入可能會被靜默丟棄——我們之前在 ["最後寫入勝利(丟棄併發寫入)"](#sec_replication_lww) 中看到的問題。我們將在 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying) 中更詳細地討論這一點。 * 如果兩個寫入併發發生,其中一個可能首先在一個副本上處理,另一個可能首先在另一個副本上處理。這導致衝突,類似於我們在多主複製中看到的(見 ["處理寫入衝突"](#sec_replication_write_conflicts))。我們將在 ["檢測併發寫入"](#sec_replication_concurrent) 中回到這個主題。 -因此,儘管仲裁似乎保證讀取返回最新寫入的值,但實際上並不那麼簡單。Dynamo 風格的資料庫通常針對可以容忍最終一致性的用例進行了最佳化。引數 *w* 和 *r* 允許你調整讀取陳舊值的機率 [^53],但明智的做法是不要將它們視為絕對保證。 +因此,儘管仲裁似乎保證讀取返回最新寫入的值,但實際上並不那麼簡單。Dynamo 風格的資料庫通常針對可以容忍最終一致性的用例進行了最佳化。引數 **w** 和 **r** 允許你調整讀取陳舊值的機率 [^53],但明智的做法是不要將它們視為絕對保證。 #### 監控陳舊性 {#monitoring-staleness} @@ -647,7 +647,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32], 也就是說,無主系統也可能有效能問題: * 即使系統不需要執行故障轉移,一個副本確實需要檢測另一個副本何時不可用,以便它可以儲存有關不可用副本錯過的寫入的提示。當不可用副本恢復時,移交過程需要向其傳送這些提示。這在系統已經處於壓力下時給副本帶來了額外的負載 [^54]。 -* 你擁有的副本越多,你的仲裁就越大,在請求完成之前你必須等待的響應就越多。即使你只等待最快的 *r* 或 *w* 個副本響應,即使你並行發出請求,更大的 *r* 或 *w* 增加了你遇到慢副本的機會,增加了總體響應時間(見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。 +* 你擁有的副本越多,你的仲裁就越大,在請求完成之前你必須等待的響應就越多。即使你只等待最快的 **r** 或 **w** 個副本響應,即使你並行發出請求,更大的 **r** 或 **w** 增加了你遇到慢副本的機會,增加了總體響應時間(見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。 * 大規模網路中斷使客戶端與大量副本斷開連線,可能使形成仲裁變得不可能。一些無主資料庫提供了一個配置選項,允許任何可訪問的副本接受寫入,即使它不是該鍵的通常副本之一(Riak 和 Dynamo 稱之為 **寬鬆仲裁** [^45];Cassandra 和 ScyllaDB 稱之為 **一致性級別 ANY**)。不能保證後續讀取會看到寫入的值,但根據應用程式,它可能仍然比寫入失敗更好。 多主複製可以提供比無主複製更大的網路中斷彈性,因為讀取和寫入只需要與一個領導者通訊,該領導者可以與客戶端位於同一位置。然而,由於一個領導者上的寫入非同步傳播到其他領導者,讀取可能任意過時。仲裁讀取和寫入提供了一種折衷:良好的容錯性,同時也有很高的可能性讀取最新資料。 @@ -658,14 +658,14 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32], Cassandra 和 ScyllaDB 在正常的無主模型中實現了它們的多地區支援:客戶端直接將其寫入傳送到所有地區的副本,你可以從各種一致性級別中進行選擇,這些級別確定請求成功所需的響應數。例如,你可以請求所有地區中副本的仲裁、每個地區中的單獨仲裁,或僅客戶端本地地區的仲裁。本地仲裁避免了必須等待到其他地區的緩慢請求,但它也更可能返回陳舊結果。 -Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本地,因此 *n* 描述了一個地區內的副本數。資料庫叢集之間的跨地區複製在後臺非同步發生,其風格類似於多主複製。 +Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本地,因此 **n** 描述了一個地區內的副本數。資料庫叢集之間的跨地區複製在後臺非同步發生,其風格類似於多主複製。 ### 檢測併發寫入 {#sec_replication_concurrent} 與多主複製一樣,無主資料庫允許對同一鍵進行併發寫入,導致需要解決的衝突。此類衝突可能在寫入發生時發生,但並非總是如此:它們也可能在讀修復、提示移交或反熵期間稍後檢測到。 -問題在於,由於可變的網路延遲和部分故障,事件可能以不同的順序到達不同的節點。例如,[圖 6-14](#fig_replication_concurrency) 顯示了兩個客戶端 A 和 B 同時寫入三節點資料儲存中的鍵 *X*: +問題在於,由於可變的網路延遲和部分故障,事件可能以不同的順序到達不同的節點。例如,[圖 6-14](#fig_replication_concurrency) 顯示了兩個客戶端 A 和 B 同時寫入三節點資料儲存中的鍵 **X**: * 節點 1 接收來自 A 的寫入,但由於瞬時中斷從未接收來自 B 的寫入。 * 節點 2 首先接收來自 A 的寫入,然後接收來自 B 的寫入。 @@ -673,7 +673,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本 {{< figure src="/fig/ddia_0614.png" id="fig_replication_concurrency" caption="圖 6-14. Dynamo 風格資料儲存中的併發寫入:沒有明確定義的順序。" class="w-full my-4" >}} -如果每個節點在接收到來自客戶端的寫入請求時只是覆蓋鍵的值,節點將變得永久不一致,如 [圖 6-14](#fig_replication_concurrency) 中的最終 *get* 請求所示:節點 2 認為 *X* 的最終值是 B,而其他節點認為值是 A。 +如果每個節點在接收到來自客戶端的寫入請求時只是覆蓋鍵的值,節點將變得永久不一致,如 [圖 6-14](#fig_replication_concurrency) 中的最終 **get** 請求所示:節點 2 認為 **X** 的最終值是 B,而其他節點認為值是 A。 為了最終保持一致,副本應該收斂到相同的值。為此,我們可以使用我們之前在 ["處理寫入衝突"](#sec_replication_write_conflicts) 中討論的任何衝突解決機制,例如最後寫入勝利(由 Cassandra 和 ScyllaDB 使用)、手動解決或 CRDT(在 ["CRDT 與操作變換"](#sec_replication_crdts) 中描述,並由 Riak 使用)。 diff --git a/content/tw/ch7.md b/content/tw/ch7.md index 5483131..cd535d6 100644 --- a/content/tw/ch7.md +++ b/content/tw/ch7.md @@ -14,8 +14,8 @@ breadcrumbs: false 分散式資料庫通常透過兩種方式在節點間分佈資料: -1. 在多個節點上儲存相同資料的副本:這是 *複製*,我們在 [第 6 章](/tw/ch6#ch_replication) 中討論過。 -2. 如果我們不想讓每個節點都儲存所有資料,我們可以將大量資料分割成更小的 *分片(shards)* 或 *分割槽(partitions)*,並將不同的分片儲存在不同的節點上。我們將在本章討論分片。 +1. 在多個節點上儲存相同資料的副本:這是 **複製**,我們在 [第 6 章](/tw/ch6#ch_replication) 中討論過。 +2. 如果我們不想讓每個節點都儲存所有資料,我們可以將大量資料分割成更小的 **分片(shards)** 或 **分割槽(partitions)**,並將不同的分片儲存在不同的節點上。我們將在本章討論分片。 通常,分片的定義方式使得每條資料(每條記錄、行或文件)恰好屬於一個分片。有多種方法可以實現這一點,我們將在本章深入討論。實際上,每個分片本身就是一個小型資料庫,儘管某些資料庫系統支援同時涉及多個分片的操作。 @@ -31,35 +31,35 @@ breadcrumbs: false > [!TIP] 分片和分割槽 -在本章中我們稱之為 *分片* 的東西,根據你使用的軟體不同有許多不同的名稱:在 Kafka 中稱為 *分割槽(partition)*,在 CockroachDB 中稱為 *範圍(range)*,在 HBase 和 TiDB 中稱為 *區域(region)*,在 Bigtable 和 YugabyteDB 中稱為 *表塊(tablet)*,在 Cassandra、ScyllaDB 和 Riak 中稱為 *虛節點(vnode)*,在 Couchbase 中稱為 *虛桶(vBucket)*,僅舉幾例。 +在本章中我們稱之為 **分片** 的東西,根據你使用的軟體不同有許多不同的名稱:在 Kafka 中稱為 **分割槽(partition)**,在 CockroachDB 中稱為 **範圍(range)**,在 HBase 和 TiDB 中稱為 **區域(region)**,在 Bigtable 和 YugabyteDB 中稱為 **表塊(tablet)**,在 Cassandra、ScyllaDB 和 Riak 中稱為 **虛節點(vnode)**,在 Couchbase 中稱為 **虛桶(vBucket)**,僅舉幾例。 一些資料庫將分割槽和分片視為兩個不同的概念。例如,在 PostgreSQL 中,分割槽是將大表拆分為儲存在同一臺機器上的多個檔案的方法(這有幾個優點,例如可以非常快速地刪除整個分割槽),而分片則是將資料集拆分到多臺機器上 [^1] [^2]。在許多其他系統中,分割槽只是分片的另一個詞。 -雖然 *分割槽* 相當具有描述性,但 *分片* 這個術語可能令人驚訝。根據一種理論,該術語源於線上角色扮演遊戲《網路創世紀》(Ultima Online),其中一塊魔法水晶被打碎成碎片,每個碎片都折射出遊戲世界的副本 [^3]。*分片* 一詞因此用來指一組並行遊戲伺服器中的一個,後來被引入資料庫。另一種理論是 *分片* 最初是 *高可用複製資料系統*(System for Highly Available Replicated Data)的縮寫——據說是 1980 年代的一個數據庫,其細節已經失傳。 +雖然 **分割槽** 相當具有描述性,但 **分片** 這個術語可能令人驚訝。根據一種理論,該術語源於線上角色扮演遊戲《網路創世紀》(Ultima Online),其中一塊魔法水晶被打碎成碎片,每個碎片都折射出遊戲世界的副本 [^3]。**分片** 一詞因此用來指一組並行遊戲伺服器中的一個,後來被引入資料庫。另一種理論是 **分片** 最初是 **高可用複製資料系統**(System for Highly Available Replicated Data)的縮寫——據說是 1980 年代的一個數據庫,其細節已經失傳。 -順便說一下,分割槽與 *網路分割槽*(netsplits)無關,後者是節點之間網路中的一種故障。我們將在 [第 9 章](/tw/ch9#ch_distributed) 中討論此類故障。 +順便說一下,分割槽與 **網路分割槽**(netsplits)無關,後者是節點之間網路中的一種故障。我們將在 [第 9 章](/tw/ch9#ch_distributed) 中討論此類故障。 -------- ## 分片的利與弊 {#sec_sharding_reasons} -對資料庫進行分片的主要原因是 *可伸縮性*:如果資料量或寫吞吐量已經超出單個節點的處理能力,這是一個解決方案,它允許你將資料和寫入分散到多個節點上。(如果讀吞吐量是問題,你不一定需要分片——你可以使用 [第 6 章](/tw/ch6#ch_replication) 中討論的 *讀擴充套件*。) +對資料庫進行分片的主要原因是 **可伸縮性**:如果資料量或寫吞吐量已經超出單個節點的處理能力,這是一個解決方案,它允許你將資料和寫入分散到多個節點上。(如果讀吞吐量是問題,你不一定需要分片——你可以使用 [第 6 章](/tw/ch6#ch_replication) 中討論的 **讀擴充套件**。) -事實上,分片是我們實現 *水平擴充套件*(*橫向擴充套件* 架構)的主要工具之一,如 ["共享記憶體、共享磁碟和無共享架構"](/tw/ch2#sec_introduction_shared_nothing) 中所討論的:即,允許系統透過新增更多(較小的)機器而不是轉移到更大的機器來增長其容量。如果你可以劃分工作負載,使每個分片處理大致相等的份額,那麼你可以將這些分片分配給不同的機器,以便並行處理它們的資料和查詢。 +事實上,分片是我們實現 **水平擴充套件**(**橫向擴充套件** 架構)的主要工具之一,如 ["共享記憶體、共享磁碟和無共享架構"](/tw/ch2#sec_introduction_shared_nothing) 中所討論的:即,允許系統透過新增更多(較小的)機器而不是轉移到更大的機器來增長其容量。如果你可以劃分工作負載,使每個分片處理大致相等的份額,那麼你可以將這些分片分配給不同的機器,以便並行處理它們的資料和查詢。 雖然複製在小規模和大規模上都很有用,因為它支援容錯和離線操作,但分片是一個重量級解決方案,主要在大規模場景下才有意義。如果你的資料量和寫吞吐量可以在單臺機器上處理(而單臺機器現在可以做很多事情!),通常最好避免分片並堅持使用單分片資料庫。 -推薦這樣做的原因是分片通常會增加複雜性:你通常必須透過選擇 *分割槽鍵* 來決定將哪些記錄放在哪個分片中;具有相同分割槽鍵的所有記錄都放在同一個分片中 [^4]。這個選擇很重要,因為如果你知道記錄在哪個分片中,訪問記錄會很快,但如果你不知道分片,你必須在所有分片中進行低效的搜尋,而且分片方案很難更改。 +推薦這樣做的原因是分片通常會增加複雜性:你通常必須透過選擇 **分割槽鍵** 來決定將哪些記錄放在哪個分片中;具有相同分割槽鍵的所有記錄都放在同一個分片中 [^4]。這個選擇很重要,因為如果你知道記錄在哪個分片中,訪問記錄會很快,但如果你不知道分片,你必須在所有分片中進行低效的搜尋,而且分片方案很難更改。 因此,分片通常適用於鍵值資料,你可以輕鬆地按鍵進行分片,但對於關係資料則較難,因為你可能想要透過二級索引搜尋,或連線可能分佈在不同分片中的記錄。我們將在 ["分片與二級索引"](#sec_sharding_secondary_indexes) 中進一步討論這個問題。 -分片的另一個問題是寫入可能需要更新多個不同分片中的相關記錄。雖然單節點上的事務相當常見(見 [第 8 章](/tw/ch8#ch_transactions)),但確保跨多個分片的一致性需要 *分散式事務*。正如我們將在 [第 8 章](/tw/ch8#ch_transactions) 中看到的,分散式事務在某些資料庫中可用,但它們通常比單節點事務慢得多,可能成為整個系統的瓶頸,有些系統根本不支援它們。 +分片的另一個問題是寫入可能需要更新多個不同分片中的相關記錄。雖然單節點上的事務相當常見(見 [第 8 章](/tw/ch8#ch_transactions)),但確保跨多個分片的一致性需要 **分散式事務**。正如我們將在 [第 8 章](/tw/ch8#ch_transactions) 中看到的,分散式事務在某些資料庫中可用,但它們通常比單節點事務慢得多,可能成為整個系統的瓶頸,有些系統根本不支援它們。 -一些系統即使在單臺機器上也使用分片,通常每個 CPU 核心執行一個單執行緒程序,以利用 CPU 的並行性,或者利用 *非統一記憶體訪問*(NUMA)架構:某些記憶體分割槽比其他分割槽更靠近某個 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每個核心使用一個程序,並依靠分片在同一臺機器的 CPU 核心之間分散負載 [^6]。 +一些系統即使在單臺機器上也使用分片,通常每個 CPU 核心執行一個單執行緒程序,以利用 CPU 的並行性,或者利用 **非統一記憶體訪問**(NUMA)架構:某些記憶體分割槽比其他分割槽更靠近某個 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每個核心使用一個程序,並依靠分片在同一臺機器的 CPU 核心之間分散負載 [^6]。 ### 面向多租戶的分片 {#sec_sharding_multitenancy} -軟體即服務(SaaS)產品和雲服務通常是 *多租戶* 的,其中每個租戶是一個客戶。多個使用者可能在同一租戶上擁有登入帳戶,但每個租戶都有一個獨立的資料集,與其他租戶分開。例如,在電子郵件營銷服務中,每個註冊的企業通常是一個單獨的租戶,因為一個企業的通訊訂閱、投遞資料等與其他企業的資料是分開的。 +軟體即服務(SaaS)產品和雲服務通常是 **多租戶** 的,其中每個租戶是一個客戶。多個使用者可能在同一租戶上擁有登入帳戶,但每個租戶都有一個獨立的資料集,與其他租戶分開。例如,在電子郵件營銷服務中,每個註冊的企業通常是一個單獨的租戶,因為一個企業的通訊訂閱、投遞資料等與其他企業的資料是分開的。 有時分片用於實現多租戶系統:要麼每個租戶被分配一個單獨的分片,要麼多個小租戶可能被分組到一個更大的分片中。這些分片可能是物理上分離的資料庫(我們之前在 ["嵌入式儲存引擎"](/tw/ch4#sidebar_embedded) 中提到過),或者是更大邏輯資料庫的可單獨管理部分 [^7]。使用分片實現多租戶有幾個優點: @@ -70,7 +70,7 @@ breadcrumbs: false : 如果訪問控制邏輯有漏洞,而租戶資料集又是彼此物理隔離儲存的,那麼誤將一個租戶的資料暴露給另一個租戶的機率會更低。 基於單元的架構 -: 你不僅可以在資料儲存級別應用分片,還可以為執行應用程式程式碼的服務應用分片。在 *基於單元的架構* 中,特定租戶集的服務和儲存被分組到一個自包含的 *單元* 中,不同的單元被設定為可以在很大程度上彼此獨立執行。這種方法提供了 *故障隔離*:即,一個單元中的故障僅限於該單元,其他單元中的租戶不受影響 [^8]。 +: 你不僅可以在資料儲存級別應用分片,還可以為執行應用程式程式碼的服務應用分片。在 **基於單元的架構** 中,特定租戶集的服務和儲存被分組到一個自包含的 **單元** 中,不同的單元被設定為可以在很大程度上彼此獨立執行。這種方法提供了 **故障隔離**:即,一個單元中的故障僅限於該單元,其他單元中的租戶不受影響 [^8]。 按租戶備份和恢復 : 單獨備份每個租戶的分片使得可以從備份中恢復租戶的狀態而不影響其他租戶,這在租戶意外刪除或覆蓋重要資料的情況下很有用 [^9]。 @@ -96,9 +96,9 @@ breadcrumbs: false 假設你有大量資料,並且想要對其進行分片。如何決定將哪些記錄儲存在哪些節點上? -我們進行分片的目標是將資料和查詢負載均勻地分佈在各節點上。如果每個節點承擔公平的份額,那麼理論上——10 個節點應該能夠處理 10 倍的資料量和 10 倍單個節點的讀寫吞吐量(忽略複製)。此外,如果我們新增或刪除節點,我們希望能夠 *再平衡* 負載,使其在新增時均勻分佈在 11 個節點上(或刪除時在剩餘的 9 個節點上)。 +我們進行分片的目標是將資料和查詢負載均勻地分佈在各節點上。如果每個節點承擔公平的份額,那麼理論上——10 個節點應該能夠處理 10 倍的資料量和 10 倍單個節點的讀寫吞吐量(忽略複製)。此外,如果我們新增或刪除節點,我們希望能夠 **再平衡** 負載,使其在新增時均勻分佈在 11 個節點上(或刪除時在剩餘的 9 個節點上)。 -如果分片不公平,使得某些分片比其他分片承載更多資料或查詢,我們稱之為 *偏斜*。偏斜會顯著削弱分片效果。在極端情況下,所有負載都可能集中在一個分片上,導致 10 個節點中有 9 個處於空閒狀態,而瓶頸落在那一個繁忙節點上。負載明顯高於其他分片的分片稱為 *熱分片* 或 *熱點*。如果某個鍵的負載特別高(例如社交網路中的名人),我們稱之為 *熱鍵*。 +如果分片不公平,使得某些分片比其他分片承載更多資料或查詢,我們稱之為 **偏斜**。偏斜會顯著削弱分片效果。在極端情況下,所有負載都可能集中在一個分片上,導致 10 個節點中有 9 個處於空閒狀態,而瓶頸落在那一個繁忙節點上。負載明顯高於其他分片的分片稱為 **熱分片** 或 **熱點**。如果某個鍵的負載特別高(例如社交網路中的名人),我們稱之為 **熱鍵**。 因此,我們需要一種演算法,它以記錄的分割槽鍵作為輸入,並告訴我們該記錄在哪個分片中。在鍵值儲存中,分割槽鍵通常是鍵,或鍵的第一部分。在關係模型中,分割槽鍵可能是表的某一列(不一定是其主鍵)。該演算法需要能夠進行再平衡以緩解熱點。 @@ -121,7 +121,7 @@ breadcrumbs: false #### 重新平衡鍵範圍分片資料 {#rebalancing-key-range-sharded-data} -當你首次設定資料庫時,沒有鍵範圍可以分割成分片。一些資料庫,如 HBase 和 MongoDB,允許你在空資料庫上配置一組初始分片,這稱為 *預分割*。這要求你已經對鍵分佈將會是什麼樣子有所瞭解,以便你可以選擇適當的鍵範圍邊界 [^14]。 +當你首次設定資料庫時,沒有鍵範圍可以分割成分片。一些資料庫,如 HBase 和 MongoDB,允許你在空資料庫上配置一組初始分片,這稱為 **預分割**。這要求你已經對鍵分佈將會是什麼樣子有所瞭解,以便你可以選擇適當的鍵範圍邊界 [^14]。 後來,隨著你的資料量和寫吞吐量增長,具有鍵範圍分片的系統透過將現有分片分割成兩個或更多較小的分片來增長,每個分片都儲存原始分片鍵範圍的連續子範圍。然後可以將生成的較小分片分佈在多個節點上。如果刪除了大量資料,你可能還需要將幾個相鄰的已變小的分片合併為一個更大的分片。這個過程類似於 B 樹頂層發生的事情(參見 ["B 樹"](/tw/ch4#sec_storage_b_trees))。 @@ -144,17 +144,17 @@ breadcrumbs: false #### 雜湊取模節點數 {#hash-modulo-number-of-nodes} -一旦你對鍵進行了雜湊,如何選擇將其儲存在哪個分片中?也許你的第一個想法是取雜湊值 *模* 系統中的節點數(在許多程式語言中使用 `%` 運算子)。例如,*hash*(*key*) % 10 將返回 0 到 9 之間的數字(如果我們將雜湊寫為十進位制數,hash % 10 將是最後一位數字)。如果我們有 10 個節點,編號從 0 到 9,這似乎是將每個鍵分配給節點的簡單方法。 +一旦你對鍵進行了雜湊,如何選擇將其儲存在哪個分片中?也許你的第一個想法是取雜湊值 **模** 系統中的節點數(在許多程式語言中使用 `%` 運算子)。例如,**hash**(**key**) % 10 將返回 0 到 9 之間的數字(如果我們將雜湊寫為十進位制數,hash % 10 將是最後一位數字)。如果我們有 10 個節點,編號從 0 到 9,這似乎是將每個鍵分配給節點的簡單方法。 -*mod N* 方法的問題是,如果節點數 *N* 發生變化,大多數鍵必須從一個節點移動到另一個節點。[圖 7-3](#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" >}} -*mod N* 函式易於計算,但它導致非常低效的再平衡,因為存在大量不必要的記錄從一個節點移動到另一個節點。我們需要一種不會移動超過必要資料的方法。 +**mod N** 函式易於計算,但它導致非常低效的再平衡,因為存在大量不必要的記錄從一個節點移動到另一個節點。我們需要一種不會移動超過必要資料的方法。 #### 固定數量的分片 {#fixed-number-of-shards} -一個簡單但廣泛使用的解決方案是建立比節點多得多的分片,併為每個節點分配多個分片。例如,在 10 個節點的叢集上執行的資料庫可能從一開始就被分成 1,000 個分片,以便每個節點分配 100 個分片。然後將鍵儲存在分片號 *hash*(*key*) % 1,000 中,系統單獨跟蹤哪個分片儲存在哪個節點上。 +一個簡單但廣泛使用的解決方案是建立比節點多得多的分片,併為每個節點分配多個分片。例如,在 10 個節點的叢集上執行的資料庫可能從一開始就被分成 1,000 個分片,以便每個節點分配 100 個分片。然後將鍵儲存在分片號 **hash**(**key**) % 1,000 中,系統單獨跟蹤哪個分片儲存在哪個節點上。 現在,如果向叢集新增一個節點,系統可以從現有節點重新分配一些分片到新節點,直到它們再次公平分佈。這個過程在 [圖 7-4](#fig_sharding_rebalance_fixed) 中說明。如果從叢集中刪除節點,則反向發生相同的事情。 @@ -172,7 +172,7 @@ breadcrumbs: false #### 按雜湊範圍分片 {#sharding-by-hash-range} -如果無法提前預測所需的分片數量,最好使用一種方案,其中分片數量可以輕鬆適應工作負載。前面提到的鍵範圍分片方案具有這個屬性,但當有大量對相鄰鍵的寫入時,它有熱點的風險。一種解決方案是將鍵範圍分片與雜湊函式結合,使每個分片包含 *雜湊值* 的範圍而不是 *鍵* 的範圍。 +如果無法提前預測所需的分片數量,最好使用一種方案,其中分片數量可以輕鬆適應工作負載。前面提到的鍵範圍分片方案具有這個屬性,但當有大量對相鄰鍵的寫入時,它有熱點的風險。一種解決方案是將鍵範圍分片與雜湊函式結合,使每個分片包含 **雜湊值** 的範圍而不是 **鍵** 的範圍。 [圖 7-5](#fig_sharding_hash_range) 顯示了使用 16 位雜湊函式的示例,該函式返回 0 到 65,535 = 2¹⁶ − 1 之間的數字(實際上,雜湊通常是 32 位或更多)。即使輸入鍵非常相似(例如,連續的時間戳),它們的雜湊值也會在該範圍內均勻分佈。然後我們可以為每個分片分配一個雜湊值範圍:例如,值 0 到 16,383 分配給分片 0,值 16,384 到 32,767 分配給分片 1,依此類推。 @@ -198,14 +198,14 @@ breadcrumbs: false #### 一致性雜湊 {#sec_sharding_consistent_hashing} -*一致性雜湊* 演算法是一種雜湊函式,它以滿足兩個屬性的方式將鍵對映到指定數量的分片: +**一致性雜湊** 演算法是一種雜湊函式,它以滿足兩個屬性的方式將鍵對映到指定數量的分片: 1. 對映到每個分片的鍵數大致相等,並且 2. 當分片數量變化時,儘可能少的鍵從一個分片移動到另一個分片。 -注意這裡的 *一致性* 與副本一致性(見 [第 6 章](/tw/ch6#ch_replication))或 ACID 一致性(見 [第 8 章](/tw/ch8#ch_transactions))無關,而是描述了鍵儘可能保持在同一個分片中的傾向。 +注意這裡的 **一致性** 與副本一致性(見 [第 6 章](/tw/ch6#ch_replication))或 ACID 一致性(見 [第 8 章](/tw/ch8#ch_transactions))無關,而是描述了鍵儘可能保持在同一個分片中的傾向。 -Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始定義 [^20],但也提出了其他幾種一致性雜湊演算法 [^21],如 *最高隨機權重*,也稱為 *會合雜湊* [^22],以及 *跳躍一致性雜湊* [^23]。使用 Cassandra 的演算法,如果新增一個節點,少量現有分片會被分割成子範圍;另一方面,使用會合和跳躍一致性雜湊,新節點被分配之前分散在所有其他節點中的單個鍵。哪種更可取取決於應用程式。 +Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始定義 [^20],但也提出了其他幾種一致性雜湊演算法 [^21],如 **最高隨機權重**,也稱為 **會合雜湊** [^22],以及 **跳躍一致性雜湊** [^23]。使用 Cassandra 的演算法,如果新增一個節點,少量現有分片會被分割成子範圍;另一方面,使用會合和跳躍一致性雜湊,新節點被分配之前分散在所有其他節點中的單個鍵。哪種更可取取決於應用程式。 ### 偏斜的工作負載與緩解熱點 {#sec_sharding_skew} @@ -221,7 +221,7 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始 問題因負載隨時間變化而進一步複雜化:例如,一個已經病毒式傳播的特定社交媒體帖子可能會在幾天內經歷高負載,但之後可能會再次平靜下來。此外,某些鍵可能對寫入很熱,而其他鍵對讀取很熱,需要不同的策略來處理它們。 -一些系統(特別是為大規模設計的雲服務)有自動處理熱分片的方法;例如,Amazon 稱之為 *熱管理* [^28] 或 *自適應容量* [^17]。這些系統如何工作的細節超出了本書的範圍。 +一些系統(特別是為大規模設計的雲服務)有自動處理熱分片的方法;例如,Amazon 稱之為 **熱管理** [^28] 或 **自適應容量** [^17]。這些系統如何工作的細節超出了本書的範圍。 ### 運維:自動/手動再平衡 {#sec_sharding_operations} @@ -243,7 +243,7 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始 我們已經討論了如何將資料集分片到多個節點上,以及如何在新增或刪除節點時重新平衡這些分片。現在讓我們繼續討論這個問題:如果你想讀取或寫入特定的鍵,你如何知道需要連線到哪個節點——即哪個 IP 地址和埠號? -我們稱這個問題為 *請求路由*,它與 *服務發現* 非常相似,我們之前在 ["負載均衡器、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery) 中討論過。兩者之間最大的區別是,對於執行應用程式程式碼的服務,每個例項通常是無狀態的,負載均衡器可以將請求傳送到任何例項。對於分片資料庫,對鍵的請求只能由包含該鍵的分片的副本節點處理。 +我們稱這個問題為 **請求路由**,它與 **服務發現** 非常相似,我們之前在 ["負載均衡器、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery) 中討論過。兩者之間最大的區別是,對於執行應用程式程式碼的服務,每個例項通常是無狀態的,負載均衡器可以將請求傳送到任何例項。對於分片資料庫,對鍵的請求只能由包含該鍵的分片的副本節點處理。 這意味著請求路由必須知道鍵到分片的分配,以及分片到節點的分配。在高層次上,這個問題有幾種不同的方法(在 [圖 7-7](#fig_sharding_routing) 中說明): @@ -263,9 +263,9 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始 {{< figure src="/fig/ddia_0708.png" id="fig_sharding_zookeeper" caption="圖 7-8. 使用 ZooKeeper 跟蹤分片到節點的分配。" class="w-full my-4" >}} -例如,HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配,Kubernetes 使用 etcd 跟蹤哪個服務例項在哪裡執行。MongoDB 有類似的架構,但它依賴於自己的 *配置伺服器* 實現和 *mongos* 守護程序作為路由層。Kafka、YugabyteDB 和 TiDB 使用內建的 Raft 共識協議實現來執行此協調功能。 +例如,HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配,Kubernetes 使用 etcd 跟蹤哪個服務例項在哪裡執行。MongoDB 有類似的架構,但它依賴於自己的 **配置伺服器** 實現和 **mongos** 守護程序作為路由層。Kafka、YugabyteDB 和 TiDB 使用內建的 Raft 共識協議實現來執行此協調功能。 -Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使用 *流言協議* 來傳播叢集狀態的任何變化。這提供了比共識協議弱得多的一致性;可能會出現腦裂,其中叢集的不同部分對同一分片有不同的節點分配。無主資料庫可以容忍這一點,因為它們通常提供弱一致性保證(見 ["仲裁一致性的限制"](/tw/ch6#sec_replication_quorum_limitations))。 +Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使用 **流言協議** 來傳播叢集狀態的任何變化。這提供了比共識協議弱得多的一致性;可能會出現腦裂,其中叢集的不同部分對同一分片有不同的節點分配。無主資料庫可以容忍這一點,因為它們通常提供弱一致性保證(見 ["仲裁一致性的限制"](/tw/ch6#sec_replication_quorum_limitations))。 當使用路由層或向隨機節點發送請求時,客戶端仍然需要找到要連線的 IP 地址。這些不像分片到節點的分配那樣快速變化,因此通常使用 DNS 就足夠了。 @@ -277,13 +277,13 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使 如果涉及二級索引,情況會變得更加複雜(另見 ["多列和二級索引"](/tw/ch4#sec_storage_index_multicolumn))。二級索引通常不唯一地標識記錄,而是一種搜尋特定值出現的方法:查詢使用者 `123` 的所有操作、查詢包含單詞 `hogwash` 的所有文章、查詢顏色為 `red` 的所有汽車等。 -鍵值儲存通常沒有二級索引;但在關係資料庫中,二級索引是基礎能力,在文件資料庫中也很常見,而且它們正是 Solr、Elasticsearch 等全文檢索引擎的 *立身之本*。二級索引的難點在於,它們不能整齊地對映到分片。帶二級索引的分片資料庫主要有兩種做法:本地索引與全域性索引。 +鍵值儲存通常沒有二級索引;但在關係資料庫中,二級索引是基礎能力,在文件資料庫中也很常見,而且它們正是 Solr、Elasticsearch 等全文檢索引擎的 **立身之本**。二級索引的難點在於,它們不能整齊地對映到分片。帶二級索引的分片資料庫主要有兩種做法:本地索引與全域性索引。 ### 本地二級索引 {#id166} 例如,假設你正在運營一個出售二手車的網站(如 [圖 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 列表也稱為 *倒排列表*。 +如果你想讓使用者搜尋汽車,允許他們按顏色和製造商過濾,你需要在 `color` 和 `make` 上建立二級索引(在文件資料庫中這些是欄位;在關係資料庫中這些是列)。如果你已宣告索引,資料庫就可以自動維護索引。例如,每當一輛紅色汽車被寫入資料庫,所在分片會自動將其 ID 加入索引條目 `color:red` 對應的文件 ID 列表。正如 [第 4 章](/tw/ch4#ch_storage) 所述,這個 ID 列表也稱為 **倒排列表**。 {{< figure src="/fig/ddia_0709.png" id="fig_sharding_local_secondary" caption="圖 7-9. 本地二級索引:每個分片只索引其自己分片內的記錄。" class="w-full my-4" >}} @@ -293,29 +293,29 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使 -------- -在這種索引方法中,每個分片是完全獨立的:每個分片維護自己的二級索引,僅覆蓋該分片中的文件。它不關心儲存在其他分片中的資料。每當你需要寫入資料庫——新增、刪除或更新記錄——你只需要處理包含你正在寫入的文件 ID 的分片。出於這個原因,這種型別的二級索引被稱為 *本地索引*。在資訊檢索上下文中,它也被稱為 *文件分割槽索引* [^30]。 +在這種索引方法中,每個分片是完全獨立的:每個分片維護自己的二級索引,僅覆蓋該分片中的文件。它不關心儲存在其他分片中的資料。每當你需要寫入資料庫——新增、刪除或更新記錄——你只需要處理包含你正在寫入的文件 ID 的分片。出於這個原因,這種型別的二級索引被稱為 **本地索引**。在資訊檢索上下文中,它也被稱為 **文件分割槽索引** [^30]。 -當從本地二級索引讀取時,如果你已經知道你正在查詢的記錄的分割槽鍵,你可以只在適當的分片上執行搜尋。此外,如果你只想要 *一些* 結果,而不需要全部,你可以將請求傳送到任何分片。 +當從本地二級索引讀取時,如果你已經知道你正在查詢的記錄的分割槽鍵,你可以只在適當的分片上執行搜尋。此外,如果你只想要 **一些** 結果,而不需要全部,你可以將請求傳送到任何分片。 但是,如果你想要所有結果並且事先不知道它們的分割槽鍵,你需要將查詢傳送到所有分片,並組合你收到的結果,因為匹配的記錄可能分散在所有分片中。在 [圖 7-9](#fig_sharding_local_secondary) 中,紅色汽車出現在分片 0 和分片 1 中。 -這種查詢分片資料庫的方法有時稱為 *分散/收集*(scatter/gather),它可能使二級索引讀取變得相當昂貴。即使並行查詢各分片,分散/收集也容易導致尾部延遲放大(見 ["響應時間指標的使用"](/tw/ch2#sec_introduction_slo_sla))。它還會限制應用的可伸縮性:增加分片可以提升可儲存資料量,但若每個查詢仍需所有分片參與,查詢吞吐量並不會隨分片數增加而提升。 +這種查詢分片資料庫的方法有時稱為 **分散/收集**(scatter/gather),它可能使二級索引讀取變得相當昂貴。即使並行查詢各分片,分散/收集也容易導致尾部延遲放大(見 ["響應時間指標的使用"](/tw/ch2#sec_introduction_slo_sla))。它還會限制應用的可伸縮性:增加分片可以提升可儲存資料量,但若每個查詢仍需所有分片參與,查詢吞吐量並不會隨分片數增加而提升。 儘管如此,本地二級索引被廣泛使用 [^31]:例如,MongoDB、Riak、Cassandra [^32]、Elasticsearch [^33]、SolrCloud 和 VoltDB [^34] 都使用本地二級索引。 ### 全域性二級索引 {#id167} -我們可以構建一個覆蓋所有分片資料的 *全域性索引*,而不是每個分片有自己的本地二級索引。但是,我們不能只將該索引儲存在一個節點上,因為它可能會成為瓶頸並違背分片的目的。全域性索引也必須進行分片,但它可以以不同於主鍵索引的方式進行分片。 +我們可以構建一個覆蓋所有分片資料的 **全域性索引**,而不是每個分片有自己的本地二級索引。但是,我們不能只將該索引儲存在一個節點上,因為它可能會成為瓶頸並違背分片的目的。全域性索引也必須進行分片,但它可以以不同於主鍵索引的方式進行分片。 -[圖 7-10](#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),在全文檢索中,*詞項* 是你可以搜尋的文字中的關鍵字。這裡我們將其推廣為指二級索引中你可以搜尋的任何值。 +這種索引也稱為 **基於詞項分割槽** [^30]:回憶一下 ["全文檢索"](/tw/ch4#sec_storage_full_text),在全文檢索中,**詞項** 是你可以搜尋的文字中的關鍵字。這裡我們將其推廣為指二級索引中你可以搜尋的任何值。 全域性索引使用詞項作為分割槽鍵,因此當你查詢特定詞項或值時,你可以找出需要查詢哪個分片。和以前一樣,分片可以包含連續的詞項範圍(如 [圖 7-10](#fig_sharding_global_secondary)),或者你可以基於詞項的雜湊將詞項分配給分片。 -全域性索引的優點是,只有一個查詢條件時(如 *color = red*),只需從一個分片讀取即可獲得倒排列表。但如果你不僅要 ID,還要取回完整記錄,仍然必須去負責這些 ID 的各個分片讀取。 +全域性索引的優點是,只有一個查詢條件時(如 **color = red**),只需從一個分片讀取即可獲得倒排列表。但如果你不僅要 ID,還要取回完整記錄,仍然必須去負責這些 ID 的各個分片讀取。 如果你有多個搜尋條件或詞項(例如搜尋某種顏色且某個製造商的汽車,或搜尋同一文字中出現的多個單詞),這些詞項很可能會落在不同分片。要計算兩個條件的邏輯 AND,系統需要找出同時出現在兩個倒排列表中的 ID。若倒排列表較短,這沒問題;但若很長,把它們透過網路傳送後再算交集就可能很慢 [^30]。 diff --git a/content/tw/ch8.md b/content/tw/ch8.md index f93f55b..29bfe91 100644 --- a/content/tw/ch8.md +++ b/content/tw/ch8.md @@ -24,17 +24,17 @@ breadcrumbs: false 為了實現可靠性,系統必須處理這些故障,確保它們不會導致整個系統的災難性故障。然而,實現容錯機制需要大量工作。它需要仔細考慮所有可能出錯的事情,並進行大量測試,以確保解決方案真正有效。 -數十年來,*事務*一直是簡化這些問題的首選機制。事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視作單個操作來執行:整個事務要麼成功(*提交*),要麼失敗(*中止*、*回滾*)。如果失敗,應用程式可以安全地重試。對於事務來說,應用程式的錯誤處理變得簡單多了,因為它不用再擔心部分失敗——即某些操作成功,某些失敗(無論出於何種原因)。 +數十年來,**事務**一直是簡化這些問題的首選機制。事務是應用程式將多個讀寫操作組合成一個邏輯單元的一種方式。從概念上講,事務中的所有讀寫操作被視作單個操作來執行:整個事務要麼成功(**提交**),要麼失敗(**中止**、**回滾**)。如果失敗,應用程式可以安全地重試。對於事務來說,應用程式的錯誤處理變得簡單多了,因為它不用再擔心部分失敗——即某些操作成功,某些失敗(無論出於何種原因)。 -如果你與事務打交道多年,它們可能看起來顯而易見,但我們不應該將其視為理所當然。事務不是自然法則;它們是有目的地建立的,即為了*簡化應用程式的程式設計模型*。透過使用事務,應用程式可以自由地忽略某些潛在的錯誤場景和併發問題,因為資料庫會替應用處理好這些(我們稱之為*安全保證*)。 +如果你與事務打交道多年,它們可能看起來顯而易見,但我們不應該將其視為理所當然。事務不是自然法則;它們是有目的地建立的,即為了**簡化應用程式的程式設計模型**。透過使用事務,應用程式可以自由地忽略某些潛在的錯誤場景和併發問題,因為資料庫會替應用處理好這些(我們稱之為**安全保證**)。 並非所有應用程式都需要事務,有時弱化事務保證或完全放棄事務也有好處(例如,為了獲得更高的效能或更高的可用性)。某些安全屬性可以在沒有事務的情況下實現。另一方面,事務可以防止很多麻煩:例如,郵局 Horizon 醜聞(參見["可靠性有多重要?"](/tw/ch2#sidebar_reliability_importance))背後的技術原因可能是底層會計系統缺乏 ACID 事務[^1]。 你如何確定是否需要事務?為了回答這個問題,我們首先需要準確理解事務可以提供哪些安全保證,以及相關的成本。儘管事務乍看起來很簡單,但實際上有許多細微但重要的細節在起作用。 -在本章中,我們將研究許多可能出錯的案例,並探索資料庫用於防範這些問題的演算法。我們將特別深入併發控制領域,討論可能發生的各種競態條件,以及資料庫如何實現*讀已提交*、*快照隔離*和*可序列化*等隔離級別。 +在本章中,我們將研究許多可能出錯的案例,並探索資料庫用於防範這些問題的演算法。我們將特別深入併發控制領域,討論可能發生的各種競態條件,以及資料庫如何實現**讀已提交**、**快照隔離**和**可序列化**等隔離級別。 -併發控制對單節點和分散式資料庫都很重要。在本章後面的["分散式事務"](#sec_transactions_distributed)部分,我們將研究*兩階段提交*協議和在分散式事務中實現原子性的挑戰。 +併發控制對單節點和分散式資料庫都很重要。在本章後面的["分散式事務"](#sec_transactions_distributed)部分,我們將研究**兩階段提交**協議和在分散式事務中實現原子性的挑戰。 ## 事務到底是什麼? {#sec_transactions_overview} @@ -48,41 +48,41 @@ breadcrumbs: false ### ACID 的含義 {#sec_transactions_acid} -事務提供的安全保證通常由眾所周知的首字母縮略詞 *ACID* 來描述,它代表*原子性*(Atomicity)、*一致性*(Consistency)、*隔離性*(Isolation)和*永續性*(Durability)。它由 Theo Härder 和 Andreas Reuter 於 1983 年提出[^9],旨在為資料庫中的容錯機制建立精確的術語。 +事務提供的安全保證通常由眾所周知的首字母縮略詞 **ACID** 來描述,它代表**原子性**(Atomicity)、**一致性**(Consistency)、**隔離性**(Isolation)和**永續性**(Durability)。它由 Theo Härder 和 Andreas Reuter 於 1983 年提出[^9],旨在為資料庫中的容錯機制建立精確的術語。 -然而,在實踐中,一個數據庫的 ACID 實現並不等同於另一個數據庫的實現。例如,正如我們將看到的,*隔離性*的含義有很多歧義[^10]。高層次的想法是合理的,但魔鬼在細節中。今天,當一個系統聲稱自己"符合 ACID"時,實際上你能期待什麼保證並不清楚。不幸的是,ACID 基本上已經成為了一個營銷術語。 +然而,在實踐中,一個數據庫的 ACID 實現並不等同於另一個數據庫的實現。例如,正如我們將看到的,**隔離性**的含義有很多歧義[^10]。高層次的想法是合理的,但魔鬼在細節中。今天,當一個系統聲稱自己"符合 ACID"時,實際上你能期待什麼保證並不清楚。不幸的是,ACID 基本上已經成為了一個營銷術語。 -(不符合 ACID 標準的系統有時被稱為 *BASE*,它代表*基本可用*(Basically Available)、*軟狀態*(Soft state)和*最終一致性*(Eventual consistency)[^11]。這比 ACID 的定義更加模糊。似乎 BASE 唯一合理的定義是"非 ACID";即,它幾乎可以代表任何你想要的東西。) +(不符合 ACID 標準的系統有時被稱為 **BASE**,它代表**基本可用**(Basically Available)、**軟狀態**(Soft state)和**最終一致性**(Eventual consistency)[^11]。這比 ACID 的定義更加模糊。似乎 BASE 唯一合理的定義是"非 ACID";即,它幾乎可以代表任何你想要的東西。) 讓我們深入瞭解原子性、一致性、隔離性和永續性的定義,這將讓我們提煉出事務的思想。 #### 原子性 {#sec_transactions_acid_atomicity} -一般來說,*原子*是指不能分解成更小部分的東西。這個詞在計算機的不同分支中意味著相似但又微妙不同的東西。例如,在多執行緒程式設計中,如果一個執行緒執行原子操作,這意味著另一個執行緒無法看到該操作的半完成結果。系統只能處於操作之前或操作之後的狀態,而不是介於兩者之間。 +一般來說,**原子**是指不能分解成更小部分的東西。這個詞在計算機的不同分支中意味著相似但又微妙不同的東西。例如,在多執行緒程式設計中,如果一個執行緒執行原子操作,這意味著另一個執行緒無法看到該操作的半完成結果。系統只能處於操作之前或操作之後的狀態,而不是介於兩者之間。 -相比之下,在 ACID 的上下文中,原子性*不是*關於併發的。它不描述如果幾個程序試圖同時訪問相同的資料會發生什麼,因為這包含在字母 *I*(*隔離性*)中(參見["隔離性"](#sec_transactions_acid_isolation))。 +相比之下,在 ACID 的上下文中,原子性**不是**關於併發的。它不描述如果幾個程序試圖同時訪問相同的資料會發生什麼,因為這包含在字母 **I**(**隔離性**)中(參見["隔離性"](#sec_transactions_acid_isolation))。 -相反,ACID 原子性描述了當客戶端想要進行多次寫入,但在某些寫入被處理後發生故障時會發生什麼——例如,程序崩潰、網路連線中斷、磁碟變滿或違反了某些完整性約束。如果這些寫入被分組到一個原子事務中,並且由於故障無法完成(*提交*)事務,則事務被*中止*,資料庫必須丟棄或撤消該事務中迄今為止所做的任何寫入。 +相反,ACID 原子性描述了當客戶端想要進行多次寫入,但在某些寫入被處理後發生故障時會發生什麼——例如,程序崩潰、網路連線中斷、磁碟變滿或違反了某些完整性約束。如果這些寫入被分組到一個原子事務中,並且由於故障無法完成(**提交**)事務,則事務被**中止**,資料庫必須丟棄或撤消該事務中迄今為止所做的任何寫入。 如果沒有原子性,如果在進行多處更改的中途發生錯誤,很難知道哪些更改已經生效,哪些沒有。應用程式可以重試,但這有進行兩次相同更改的風險,導致資料重複或錯誤。原子性簡化了這個問題:如果事務被中止,應用程式可以確定它沒有改變任何東西,因此可以安全地重試。 -在錯誤時中止事務並丟棄該事務的所有寫入的能力是 ACID 原子性的定義特徵。也許*可中止性*比*原子性*更好,但我們將堅持使用*原子性*,因為這是常用詞。 +在錯誤時中止事務並丟棄該事務的所有寫入的能力是 ACID 原子性的定義特徵。也許**可中止性**比**原子性**更好,但我們將堅持使用**原子性**,因為這是常用詞。 #### 一致性 {#sec_transactions_acid_consistency} -*一致性*這個詞被嚴重濫用: +**一致性**這個詞被嚴重濫用: -* 在[第 6 章](/tw/ch6#ch_replication)中,我們討論了*副本一致性*和非同步複製系統中出現的*最終一致性*問題(參見["複製延遲的問題"](/tw/ch6#sec_replication_lag))。 -* 資料庫的*一致快照*(例如,用於備份)是整個資料庫在某一時刻存在的快照。更準確地說,它與先發生關係(happens-before relation)一致(參見["“先發生”關係和併發"](/tw/ch6#sec_replication_happens_before)):也就是說,如果快照包含在特定時間寫入的值,那麼它也反映了在該值寫入之前發生的所有寫入。 -* *一致性雜湊*是某些系統用於再平衡的分片方法(參見["一致性雜湊"](/tw/ch7#sec_sharding_consistent_hashing))。 -* 在 CAP定理中(參見[第 10 章](/tw/ch10#ch_consistency)),*一致性*一詞用於表示*線性一致性*(參見["線性一致性"](/tw/ch10#sec_consistency_linearizability))。 -* 在 ACID 的上下文中,*一致性*是指應用程式特定的資料庫處於"良好狀態"的概念。 +* 在[第 6 章](/tw/ch6#ch_replication)中,我們討論了**副本一致性**和非同步複製系統中出現的**最終一致性**問題(參見["複製延遲的問題"](/tw/ch6#sec_replication_lag))。 +* 資料庫的**一致快照**(例如,用於備份)是整個資料庫在某一時刻存在的快照。更準確地說,它與先發生關係(happens-before relation)一致(參見["“先發生”關係和併發"](/tw/ch6#sec_replication_happens_before)):也就是說,如果快照包含在特定時間寫入的值,那麼它也反映了在該值寫入之前發生的所有寫入。 +* **一致性雜湊**是某些系統用於再平衡的分片方法(參見["一致性雜湊"](/tw/ch7#sec_sharding_consistent_hashing))。 +* 在 CAP定理中(參見[第 10 章](/tw/ch10#ch_consistency)),**一致性**一詞用於表示**線性一致性**(參見["線性一致性"](/tw/ch10#sec_consistency_linearizability))。 +* 在 ACID 的上下文中,**一致性**是指應用程式特定的資料庫處於"良好狀態"的概念。 不幸的是,同一個詞至少有五種不同的含義。 -ACID 一致性的思想是,你對資料有某些陳述(*不變式*)必須始終為真——例如,在會計系統中,所有賬戶的貸方和借方必須始終平衡。如果事務從滿足這些不變式的有效資料庫開始,並且事務期間的任何寫入都保持有效性,那麼你可以確定不變式始終得到滿足。(不變式可能在事務執行期間暫時違反,但在事務提交時應該再次滿足。) +ACID 一致性的思想是,你對資料有某些陳述(**不變式**)必須始終為真——例如,在會計系統中,所有賬戶的貸方和借方必須始終平衡。如果事務從滿足這些不變式的有效資料庫開始,並且事務期間的任何寫入都保持有效性,那麼你可以確定不變式始終得到滿足。(不變式可能在事務執行期間暫時違反,但在事務提交時應該再次滿足。) -如果你希望資料庫強制執行你的不變式,你需要將它們宣告為模式的一部分的*約束*。例如,外部索引鍵約束、唯一性約束或檢查約束(限制單個行中可以出現的值)通常用於對特定型別的不變式建模。更複雜的一致性要求有時可以使用觸發器或物化檢視建模[^12]。 +如果你希望資料庫強制執行你的不變式,你需要將它們宣告為模式的一部分的**約束**。例如,外部索引鍵約束、唯一性約束或檢查約束(限制單個行中可以出現的值)通常用於對特定型別的不變式建模。更複雜的一致性要求有時可以使用觸發器或物化檢視建模[^12]。 然而,複雜的不變式可能很難或不可能使用資料庫通常提供的約束來建模。在這種情況下,應用程式有責任正確定義其事務,以便它們保持一致性。如果你寫入違反不變式的錯誤資料,但你沒有宣告這些不變式,資料庫無法阻止你。因此,ACID 中的 C 通常取決於應用程式如何使用資料庫,而不僅僅是資料庫的屬性。 @@ -95,13 +95,13 @@ ACID 一致性的思想是,你對資料有某些陳述(*不變式*)必須 {{< figure src="/fig/ddia_0801.png" id="fig_transactions_increment" caption="圖 8-1. 兩個客戶端併發遞增計數器之間的競態條件。" class="w-full my-4" >}} -ACID 意義上的*隔離性*意味著同時執行的事務彼此隔離:它們不能相互干擾。經典的資料庫教科書將隔離性形式化為*可序列化*,這意味著每個事務可以假裝它是唯一在整個資料庫上執行的事務。資料庫確保當事務已經提交時,結果與它們*序列*執行(一個接一個)相同,即使實際上它們可能是併發執行的[^13]。 +ACID 意義上的**隔離性**意味著同時執行的事務彼此隔離:它們不能相互干擾。經典的資料庫教科書將隔離性形式化為**可序列化**,這意味著每個事務可以假裝它是唯一在整個資料庫上執行的事務。資料庫確保當事務已經提交時,結果與它們**序列**執行(一個接一個)相同,即使實際上它們可能是併發執行的[^13]。 -然而,可序列化有效能成本。在實踐中,許多資料庫使用比可序列化更弱的隔離形式:也就是說,它們允許併發事務以有限的方式相互干擾。一些流行的資料庫,如 Oracle,甚至沒有實現它(Oracle 有一個稱為"可序列化"的隔離級別,但它實際上實現了*快照隔離*,這是比可序列化更弱的保證[^10] [^14])。這意味著某些型別的競態條件仍然可能發生。我們將在["弱隔離級別"](#sec_transactions_isolation_levels)中探討快照隔離和其他形式的隔離。 +然而,可序列化有效能成本。在實踐中,許多資料庫使用比可序列化更弱的隔離形式:也就是說,它們允許併發事務以有限的方式相互干擾。一些流行的資料庫,如 Oracle,甚至沒有實現它(Oracle 有一個稱為"可序列化"的隔離級別,但它實際上實現了**快照隔離**,這是比可序列化更弱的保證[^10] [^14])。這意味著某些型別的競態條件仍然可能發生。我們將在["弱隔離級別"](#sec_transactions_isolation_levels)中探討快照隔離和其他形式的隔離。 #### 永續性 {#durability} -資料庫系統的目的是提供一個安全的地方來儲存資料,而不用擔心丟失它。*永續性*是一個承諾,即一旦事務成功提交,它寫入的任何資料都不會被遺忘,即使發生硬體故障或資料庫崩潰。 +資料庫系統的目的是提供一個安全的地方來儲存資料,而不用擔心丟失它。**永續性**是一個承諾,即一旦事務成功提交,它寫入的任何資料都不會被遺忘,即使發生硬體故障或資料庫崩潰。 在單節點資料庫中,永續性通常意味著資料已經寫入非易失性儲存,如硬碟或 SSD。定期檔案寫入通常在傳送到磁碟之前在記憶體中緩衝,這意味著如果突然斷電它們將丟失;因此,許多資料庫使用 `fsync()` 系統呼叫來確保資料真正寫入磁碟。資料庫通常還有預寫日誌或類似的(參見["使 B 樹可靠"](/tw/ch4#sec_storage_btree_wal)),這允許它們在寫入過程中發生崩潰時恢復。 @@ -140,7 +140,7 @@ ACID 意義上的*隔離性*意味著同時執行的事務彼此隔離:它們 隔離性 : 併發執行的事務不應該相互干擾。例如,如果一個事務進行多次寫入,那麼另一個事務應該看到所有或不看到這些寫入,但不是某些子集。 -這些定義假設你想要同時修改多個物件(行、文件、記錄)。這種*多物件事務*通常需要保持多塊資料同步。[圖 8-2](#fig_transactions_read_uncommitted) 顯示了一個來自電子郵件應用程式的示例。要顯示使用者的未讀訊息數,你可以查詢類似這樣的內容: +這些定義假設你想要同時修改多個物件(行、文件、記錄)。這種**多物件事務**通常需要保持多塊資料同步。[圖 8-2](#fig_transactions_read_uncommitted) 顯示了一個來自電子郵件應用程式的示例。要顯示使用者的未讀訊息數,你可以查詢類似這樣的內容: ``` SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -160,7 +160,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 多物件事務需要某種方式來確定哪些讀寫操作屬於同一事務。在關係資料庫中,這通常基於客戶端與資料庫伺服器的 TCP 連線:在任何特定連線上,`BEGIN TRANSACTION` 和 `COMMIT` 語句之間的所有內容都被認為是同一事務的一部分。如果 TCP 連線中斷,事務必須被中止。 -另一方面,許多非關係資料庫沒有這樣的方式來將操作組合在一起。即使有多物件 API(例如,鍵值儲存可能有一個*多重放置*操作,在一個操作中更新多個鍵),這並不一定意味著它具有事務語義:該命令可能在某些鍵上成功而在其他鍵上失敗,使資料庫處於部分更新狀態。 +另一方面,許多非關係資料庫沒有這樣的方式來將操作組合在一起。即使有多物件 API(例如,鍵值儲存可能有一個**多重放置**操作,在一個操作中更新多個鍵),這並不一定意味著它具有事務語義:該命令可能在某些鍵上成功而在其他鍵上失敗,使資料庫處於部分更新狀態。 #### 單物件寫入 {#sec_transactions_single_object} @@ -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](#fig_transactions_increment) 中那樣的讀-修改-寫迴圈的需求。類似流行的是*條件寫入*操作,它允許僅在值未被其他人併發更改時才進行寫入(參見["條件寫入(比較並設定)"](#sec_transactions_compare_and_set)),類似於共享記憶體併發中的比較並設定或比較並交換(CAS)操作。 +某些資料庫還提供更複雜的原子操作,例如遞增操作,它消除了像[圖 8-1](#fig_transactions_increment) 中那樣的讀-修改-寫迴圈的需求。類似流行的是**條件寫入**操作,它允許僅在值未被其他人併發更改時才進行寫入(參見["條件寫入(比較並設定)"](#sec_transactions_compare_and_set)),類似於共享記憶體併發中的比較並設定或比較並交換(CAS)操作。 -------- @@ -219,9 +219,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 併發錯誤很難透過測試發現,因為這些錯誤只有在時機不巧時才會觸發。這種時機問題可能非常罕見,通常難以重現。併發也很難推理,特別是在大型應用程式中,你不一定知道程式碼的其他部分正在訪問資料庫。如果只有一個使用者,應用程式開發就已經夠困難了;有許多併發使用者會讓情況變得更加困難,因為任何資料都可能在任何時候意外地發生變化。 -出於這個原因,資料庫長期以來一直試圖透過提供*事務隔離*來嚮應用程式開發人員隱藏併發問題。理論上,隔離應該讓你的生活更輕鬆,讓你假裝沒有併發發生:*可序列化*隔離意味著資料庫保證事務具有與*序列*執行(即一次一個,沒有任何併發)相同的效果。 +出於這個原因,資料庫長期以來一直試圖透過提供**事務隔離**來嚮應用程式開發人員隱藏併發問題。理論上,隔離應該讓你的生活更輕鬆,讓你假裝沒有併發發生:**可序列化**隔離意味著資料庫保證事務具有與**序列**執行(即一次一個,沒有任何併發)相同的效果。 -在實踐中,隔離不幸並不那麼簡單。可序列化隔離有效能成本,許多資料庫不願意支付這個代價[^10]。因此,系統通常使用較弱的隔離級別,這些級別可以防止*某些*併發問題,但不是全部。這些隔離級別更難理解,它們可能導致微妙的錯誤,但它們在實踐中仍然被使用[^29]。 +在實踐中,隔離不幸並不那麼簡單。可序列化隔離有效能成本,許多資料庫不願意支付這個代價[^10]。因此,系統通常使用較弱的隔離級別,這些級別可以防止**某些**併發問題,但不是全部。這些隔離級別更難理解,它們可能導致微妙的錯誤,但它們在實踐中仍然被使用[^29]。 由弱事務隔離引起的併發錯誤不僅僅是理論問題。它們已經導致了鉅額資金損失[^30] [^31] [^32],引發了金融審計師的調查[^33],並導致客戶資料損壞[^34]。對此類問題披露的一個流行評論是"如果你正在處理金融資料,請使用 ACID 資料庫!"——但這沒有抓住重點。即使許多流行的關係資料庫系統(通常被認為是"ACID")使用弱隔離,因此它們不一定能防止這些錯誤發生。 @@ -238,36 +238,36 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true ### 讀已提交 {#sec_transactions_read_committed} -最基本的事務隔離級別是*讀已提交*。它提供兩個保證: +最基本的事務隔離級別是**讀已提交**。它提供兩個保證: -1. 從資料庫讀取時,你只會看到已經提交的資料(沒有*髒讀*)。 -2. 寫入資料庫時,你只會覆蓋已經提交的資料(沒有*髒寫*)。 +1. 從資料庫讀取時,你只會看到已經提交的資料(沒有**髒讀**)。 +2. 寫入資料庫時,你只會覆蓋已經提交的資料(沒有**髒寫**)。 -某些資料庫支援更弱的隔離級別,稱為*讀未提交*。它防止髒寫,但不防止髒讀。讓我們更詳細地討論這兩個保證。 +某些資料庫支援更弱的隔離級別,稱為**讀未提交**。它防止髒寫,但不防止髒讀。讓我們更詳細地討論這兩個保證。 #### 沒有髒讀 {#no-dirty-reads} -想象一個事務已經向資料庫寫入了一些資料,但事務尚未提交或中止。另一個事務能看到那個未提交的資料嗎?如果能,這稱為*髒讀*[^3]。 +想象一個事務已經向資料庫寫入了一些資料,但事務尚未提交或中止。另一個事務能看到那個未提交的資料嗎?如果能,這稱為**髒讀**[^3]。 -在讀已提交隔離級別下執行的事務必須防止髒讀。這意味著事務的任何寫入只有在該事務提交時才對其他人可見(然後它的所有寫入立即變得可見)。這在[圖 8-4](#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](#fig_transactions_read_uncommitted) 中,使用者看到新的未讀電子郵件但沒有看到更新的計數器。這是電子郵件的髒讀。看到資料庫處於部分更新狀態會讓使用者感到困惑,並可能導致其他事務做出錯誤的決定。 -* 如果事務中止,它所做的任何寫入都需要回滾(如[圖 8-3](#fig_transactions_atomicity))。如果資料庫允許髒讀,這意味著事務可能看到後來被回滾的資料——即從未實際提交到資料庫的資料。任何讀取未提交資料的事務也需要被中止,導致稱為*級聯中止*的問題。 +* 如果事務中止,它所做的任何寫入都需要回滾(如[圖 8-3](#fig_transactions_atomicity))。如果資料庫允許髒讀,這意味著事務可能看到後來被回滾的資料——即從未實際提交到資料庫的資料。任何讀取未提交資料的事務也需要被中止,導致稱為**級聯中止**的問題。 #### 沒有髒寫 {#sec_transactions_dirty_write} 如果兩個事務併發嘗試更新資料庫中的同一行會發生什麼?我們不知道寫入將以什麼順序發生,但我們通常假設後面的寫入會覆蓋前面的寫入。 -然而,如果前面的寫入是尚未提交的事務的一部分,因此後面的寫入覆蓋了一個未提交的值,會發生什麼?這稱為*髒寫*[^36]。在讀已提交隔離級別下執行的事務必須防止髒寫,通常透過延遲第二個寫入直到第一個寫入的事務已提交或中止。 +然而,如果前面的寫入是尚未提交的事務的一部分,因此後面的寫入覆蓋了一個未提交的值,會發生什麼?這稱為**髒寫**[^36]。在讀已提交隔離級別下執行的事務必須防止髒寫,通常透過延遲第二個寫入直到第一個寫入的事務已提交或中止。 透過防止髒寫,這個隔離級別避免了某些型別的併發問題: * 如果事務更新多行,髒寫可能導致糟糕的結果。例如,考慮[圖 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)中,我們將討論如何使此類計數器遞增安全。 +* 然而,讀已提交**不**防止[圖 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" >}} @@ -297,7 +297,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 假設 Aaliyah 在銀行有 1,000 美元的儲蓄,分成兩個賬戶,每個 500 美元。現在一筆事務從她的一個賬戶轉賬 100 美元到另一個賬戶。如果她不幸在該事務處理的同時檢視她的賬戶餘額列表,她可能會看到一個賬戶餘額在收款到達之前(餘額為 500 美元),另一個賬戶在轉出之後(新余額為 400 美元)。對 Aaliyah 來說,現在她的賬戶總共只有 900 美元——似乎 100 美元憑空消失了。 -這種異常稱為*讀取偏差*,它是*不可重複讀*的一個例子:如果 Aaliyah 在事務結束時再次讀取賬戶 1 的餘額,她會看到與之前查詢中看到的不同的值(600 美元)。讀取偏差在讀已提交隔離下被認為是可接受的:Aaliyah 看到的賬戶餘額確實是在她讀取它們時已提交的。 +這種異常稱為**讀取偏差**,它是**不可重複讀**的一個例子:如果 Aaliyah 在事務結束時再次讀取賬戶 1 的餘額,她會看到與之前查詢中看到的不同的值(600 美元)。讀取偏差在讀已提交隔離下被認為是可接受的:Aaliyah 看到的賬戶餘額確實是在她讀取它們時已提交的。 -------- @@ -314,7 +314,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 分析查詢和完整性檢查 : 有時,你可能想要執行掃描資料庫大部分的查詢。此類查詢在分析中很常見(參見["分析與運營系統"](/tw/ch1#sec_introduction_analytics)),或者可能是定期完整性檢查的一部分,以確保一切正常(監控資料損壞)。如果這些查詢在不同時間點觀察資料庫的不同部分,它們很可能返回無意義的結果。 -*快照隔離*[^36] 是解決這個問題的最常見方法。其思想是每個事務從資料庫的*一致快照*讀取——也就是說,事務看到事務開始時資料庫中已提交的所有資料。即使資料隨後被另一個事務更改,每個事務也只能看到該特定時間點的舊資料。 +**快照隔離**[^36] 是解決這個問題的最常見方法。其思想是每個事務從資料庫的**一致快照**讀取——也就是說,事務看到事務開始時資料庫中已提交的所有資料。即使資料隨後被另一個事務更改,每個事務也只能看到該特定時間點的舊資料。 快照隔離對於長時間執行的只讀查詢(如備份和分析)來說是一個福音。如果查詢操作的資料在查詢執行的同時發生變化,很難推理查詢的含義。當事務可以看到資料庫的一致快照(凍結在特定時間點)時,理解起來就容易得多。 @@ -322,9 +322,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true #### 多版本併發控制(MVCC) {#sec_transactions_snapshot_impl} -與讀已提交隔離一樣,快照隔離的實現通常使用寫鎖來防止髒寫(參見["實現讀已提交"](#sec_transactions_read_committed_impl)),這意味著進行寫入的事務可以阻止寫入同一行的另一個事務的進度。但是,讀取不需要任何鎖。從效能的角度來看,快照隔離的一個關鍵原則是*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*。這允許資料庫在一致快照上處理長時間執行的讀查詢,同時正常處理寫入,兩者之間沒有任何鎖爭用。 +與讀已提交隔離一樣,快照隔離的實現通常使用寫鎖來防止髒寫(參見["實現讀已提交"](#sec_transactions_read_committed_impl)),這意味著進行寫入的事務可以阻止寫入同一行的另一個事務的進度。但是,讀取不需要任何鎖。從效能的角度來看,快照隔離的一個關鍵原則是**讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者**。這允許資料庫在一致快照上處理長時間執行的讀查詢,同時正常處理寫入,兩者之間沒有任何鎖爭用。 -為了實現快照隔離,資料庫使用了我們在[圖 8-4](#fig_transactions_read_committed) 中看到的防止髒讀機制的泛化。資料庫必須潛在地保留每行的幾個不同的已提交版本,而不是每行的兩個版本(已提交版本和被覆蓋但尚未提交的版本),因為各種正在進行的事務可能需要在不同時間點看到資料庫的狀態。因為它並排維護一行的多個版本,所以這種技術被稱為*多版本併發控制*(MVCC)。 +為了實現快照隔離,資料庫使用了我們在[圖 8-4](#fig_transactions_read_committed) 中看到的防止髒讀機制的泛化。資料庫必須潛在地保留每行的幾個不同的已提交版本,而不是每行的兩個版本(已提交版本和被覆蓋但尚未提交的版本),因為各種正在進行的事務可能需要在不同時間點看到資料庫的狀態。因為它並排維護一行的多個版本,所以這種技術被稱為**多版本併發控制**(MVCC)。 [圖 8-7](#fig_transactions_mvcc) 說明了 PostgreSQL 中如何實現基於 MVCC 的快照隔離[^40] [^42] [^43](其他實現類似)。當事務啟動時,它被賦予一個唯一的、始終遞增的事務 ID(`txid`)。每當事務向資料庫寫入任何內容時,它寫入的資料都用寫入者的事務 ID 標記。(準確地說,PostgreSQL 中的事務 ID 是 32 位整數,因此它們在大約 40 億個事務後溢位。清理過程執行清理以確保溢位不會影響資料。) @@ -363,7 +363,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 許多實現細節影響多版本併發控制的效能[^45] [^46]。例如,如果同一行的不同版本可以適合同一頁面,PostgreSQL 有避免索引更新的最佳化[^40]。其他一些資料庫避免儲存修改行的完整副本,而只儲存版本之間的差異以節省空間。 -CouchDB、Datomic 和 LMDB 使用另一種方法。儘管它們也使用 B 樹(參見["B 樹"](/tw/ch4#sec_storage_b_trees)),但它們使用*不可變*(寫時複製)變體,在更新時不會覆蓋樹的頁面,而是建立每個修改頁面的新副本。父頁面,直到樹的根,被複制並更新以指向其子頁面的新版本。任何不受寫入影響的頁面都不需要複製,並且可以與新樹共享[^47]。 +CouchDB、Datomic 和 LMDB 使用另一種方法。儘管它們也使用 B 樹(參見["B 樹"](/tw/ch4#sec_storage_b_trees)),但它們使用**不可變**(寫時複製)變體,在更新時不會覆蓋樹的頁面,而是建立每個修改頁面的新副本。父頁面,直到樹的根,被複制並更新以指向其子頁面的新版本。任何不受寫入影響的頁面都不需要複製,並且可以與新樹共享[^47]。 使用不可變 B 樹,每個寫事務(或事務批次)都會建立一個新的 B 樹根,特定的根是建立時資料庫的一致快照。不需要基於事務 ID 過濾行,因為後續寫入無法修改現有的 B 樹;它們只能建立新的樹根。這種方法還需要後臺程序進行壓縮和垃圾收集。 @@ -381,9 +381,9 @@ MVCC 是資料庫常用的實現技術,通常用於實現快照隔離。然而 到目前為止,我們討論的讀已提交和快照隔離級別主要是關於只讀事務在併發寫入存在的情況下可以看到什麼的保證。我們大多忽略了兩個事務併發寫入的問題——我們只討論了髒寫(參見["沒有髒寫"](#sec_transactions_dirty_write)),這是可能發生的一種特定型別的寫-寫衝突。 -併發寫入事務之間還可能發生其他幾種有趣的衝突。其中最著名的是*丟失更新*問題,在[圖 8-1](#fig_transactions_increment) 中以兩個併發計數器遞增的例子說明。 +併發寫入事務之間還可能發生其他幾種有趣的衝突。其中最著名的是**丟失更新**問題,在[圖 8-1](#fig_transactions_increment) 中以兩個併發計數器遞增的例子說明。 -如果應用程式從資料庫讀取某個值,修改它,然後寫回修改後的值(*讀-修改-寫迴圈*),就會出現丟失更新問題。如果兩個事務併發執行此操作,其中一個修改可能會丟失,因為第二個寫入不包括第一個修改。(我們有時說後面的寫入*覆蓋*了前面的寫入。)這種模式出現在各種不同的場景中: +如果應用程式從資料庫讀取某個值,修改它,然後寫回修改後的值(**讀-修改-寫迴圈**),就會出現丟失更新問題。如果兩個事務併發執行此操作,其中一個修改可能會丟失,因為第二個寫入不包括第一個修改。(我們有時說後面的寫入**覆蓋**了前面的寫入。)這種模式出現在各種不同的場景中: * 遞增計數器或更新賬戶餘額(需要讀取當前值,計算新值,並寫回更新的值) * 對複雜值進行本地更改,例如,向 JSON 文件中的列表新增元素(需要解析文件,進行更改,並寫回修改後的文件) @@ -443,7 +443,7 @@ COMMIT; #### 條件寫入(比較並設定) {#sec_transactions_compare_and_set} -在不提供事務的資料庫中,你有時會發現一個*條件寫入*操作,它可以透過僅在值自你上次讀取以來未更改時才允許更新來防止丟失的更新(之前在["單物件寫入"](#sec_transactions_single_object)中提到)。如果當前值與你之前讀取的不匹配,則更新無效,必須重試讀-修改-寫迴圈。它是許多 CPU 支援的原子*比較並設定*或*比較並交換*(CAS)指令的資料庫等價物。 +在不提供事務的資料庫中,你有時會發現一個**條件寫入**操作,它可以透過僅在值自你上次讀取以來未更改時才允許更新來防止丟失的更新(之前在["單物件寫入"](#sec_transactions_single_object)中提到)。如果當前值與你之前讀取的不匹配,則更新無效,必須重試讀-修改-寫迴圈。它是許多 CPU 支援的原子**比較並設定**或**比較並交換**(CAS)指令的資料庫等價物。 例如,為了防止兩個使用者同時更新同一個 wiki 頁面,你可以嘗試類似這樣的操作,期望僅當頁面內容自使用者開始編輯以來沒有更改時才進行更新: @@ -453,7 +453,7 @@ UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old content'; ``` -如果內容已更改並且不再匹配 `'old content'`,則此更新將無效,因此你需要檢查更新是否生效並在必要時重試。你也可以使用在每次更新時遞增的版本號列,並且僅在當前版本號未更改時才應用更新,而不是比較完整內容。這種方法有時稱為*樂觀鎖定*[^52]。 +如果內容已更改並且不再匹配 `'old content'`,則此更新將無效,因此你需要檢查更新是否生效並在必要時重試。你也可以使用在每次更新時遞增的版本號列,並且僅在當前版本號未更改時才應用更新,而不是比較完整內容。這種方法有時稱為**樂觀鎖定**[^52]。 請注意,如果另一個事務併發修改了 `content`,則根據 MVCC 可見性規則,新內容可能不可見(參見["觀察一致快照的可見性規則"](#sec_transactions_mvcc_visibility))。MVCC 的許多實現對此場景有可見性規則的例外,其中其他事務寫入的值對 `UPDATE` 和 `DELETE` 查詢的 `WHERE` 子句的評估可見,即使這些寫入在快照中不可見。 @@ -463,15 +463,15 @@ UPDATE wiki_pages SET content = 'new content' 鎖和條件寫入操作假設有一個最新的資料副本。然而,具有多領導者或無主(無領導者)複製的資料庫通常允許多個寫入併發發生並非同步複製它們,因此它們不能保證有一個最新的資料副本。因此,基於鎖或條件寫入的技術在此上下文中不適用。(我們將在["線性一致性"](/tw/ch10#sec_consistency_linearizability)中更詳細地重新討論這個問題。) -相反,如["處理衝突寫入"](/tw/ch6#sec_replication_write_conflicts)中所討論的,此類複製資料庫中的常見方法是允許併發寫入建立值的多個衝突版本(也稱為*兄弟節點*),並使用應用程式程式碼或特殊資料結構在事後解決和合並這些版本。 +相反,如["處理衝突寫入"](/tw/ch6#sec_replication_write_conflicts)中所討論的,此類複製資料庫中的常見方法是允許併發寫入建立值的多個衝突版本(也稱為**兄弟節點**),並使用應用程式程式碼或特殊資料結構在事後解決和合並這些版本。 如果更新是可交換的(即,你可以在不同副本上以不同順序應用它們,仍然得到相同的結果),合併衝突值可以防止丟失的更新。例如,遞增計數器或向集合新增元素是可交換操作。這就是 CRDT 背後的想法,我們在["CRDT 和操作轉換"](/tw/ch6#sec_replication_crdts)中遇到過。然而,某些操作(如條件寫入)不能成為可交換的。 -另一方面,*最後寫入勝利*(LWW)衝突解決方法容易丟失更新,如["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww)中所討論的。不幸的是,LWW 是許多複製資料庫中的預設值。 +另一方面,**最後寫入勝利**(LWW)衝突解決方法容易丟失更新,如["最後寫入勝利(丟棄併發寫入)"](/tw/ch6#sec_replication_lww)中所討論的。不幸的是,LWW 是許多複製資料庫中的預設值。 ### 寫偏差與幻讀 {#sec_transactions_write_skew} -在前面的部分中,我們看到了*髒寫*和*丟失更新*,這是當不同事務併發嘗試寫入相同物件時可能發生的兩種競態條件。為了避免資料損壞,需要防止這些競態條件——要麼由資料庫自動防止,要麼透過使用鎖或原子寫操作等手動保護措施。 +在前面的部分中,我們看到了**髒寫**和**丟失更新**,這是當不同事務併發嘗試寫入相同物件時可能發生的兩種競態條件。為了避免資料損壞,需要防止這些競態條件——要麼由資料庫自動防止,要麼透過使用鎖或原子寫操作等手動保護措施。 然而,這並不是併發寫入之間可能發生的潛在競態條件列表的結尾。在本節中,我們將看到一些更微妙的衝突示例。 @@ -486,7 +486,7 @@ UPDATE wiki_pages SET content = 'new content' #### 寫偏差的特徵 {#characterizing-write-skew} -這種異常稱為*寫偏差*[^36]。它既不是髒寫也不是丟失的更新,因為兩個事務正在更新兩個不同的物件(分別是 Aaliyah 和 Bryce 的值班記錄)。這裡發生衝突不太明顯,但這絕對是一個競態條件:如果兩個事務一個接一個地執行,第二個醫生將被阻止下班。異常行為只有在事務併發執行時才可能。 +這種異常稱為**寫偏差**[^36]。它既不是髒寫也不是丟失的更新,因為兩個事務正在更新兩個不同的物件(分別是 Aaliyah 和 Bryce 的值班記錄)。這裡發生衝突不太明顯,但這絕對是一個競態條件:如果兩個事務一個接一個地執行,第二個醫生將被阻止下班。異常行為只有在事務併發執行時才可能。 你可以將寫偏差視為丟失更新問題的概括。如果兩個事務讀取相同的物件,然後更新其中一些物件(不同的事務可能更新不同的物件),就會發生寫偏差。在不同事務更新同一物件的特殊情況下,你會得到髒寫或丟失更新異常(取決於時機)。 @@ -561,9 +561,9 @@ UPDATE wiki_pages SET content = 'new content' 步驟可能以不同的順序發生。例如,你可以先進行寫入,然後進行 `SELECT` 查詢,最後根據查詢結果決定是中止還是提交。 -在醫生值班示例的情況下,步驟 3 中被修改的行是步驟 1 中返回的行之一,因此我們可以透過鎖定步驟 1 中的行(`SELECT FOR UPDATE`)來使事務安全並避免寫偏差。但是,其他四個示例是不同的:它們檢查*不存在*匹配某些搜尋條件的行,而寫入*新增*了匹配相同條件的行。如果步驟 1 中的查詢不返回任何行,`SELECT FOR UPDATE` 就無法附加鎖[^56]。 +在醫生值班示例的情況下,步驟 3 中被修改的行是步驟 1 中返回的行之一,因此我們可以透過鎖定步驟 1 中的行(`SELECT FOR UPDATE`)來使事務安全並避免寫偏差。但是,其他四個示例是不同的:它們檢查**不存在**匹配某些搜尋條件的行,而寫入**新增**了匹配相同條件的行。如果步驟 1 中的查詢不返回任何行,`SELECT FOR UPDATE` 就無法附加鎖[^56]。 -這種效果,其中一個事務中的寫入改變另一個事務中搜索查詢的結果,稱為*幻讀*[^4]。快照隔離避免了只讀查詢中的幻讀,但在我們討論的讀寫事務中,幻讀可能導致特別棘手的寫偏差情況。ORM 生成的 SQL 也容易出現寫偏差[^50] [^51]。 +這種效果,其中一個事務中的寫入改變另一個事務中搜索查詢的結果,稱為**幻讀**[^4]。快照隔離避免了只讀查詢中的幻讀,但在我們討論的讀寫事務中,幻讀可能導致特別棘手的寫偏差情況。ORM 生成的 SQL 也容易出現寫偏差[^50] [^51]。 #### 物化衝突 {#materializing-conflicts} @@ -573,7 +573,7 @@ UPDATE wiki_pages SET content = 'new content' 現在,想要建立預訂的事務可以鎖定(`SELECT FOR UPDATE`)表中對應於所需房間和時間段的行。獲取鎖後,它可以像以前一樣檢查重疊的預訂並插入新的預訂。請注意,附加表不用於儲存有關預訂的資訊——它純粹是一組鎖,用於防止同一房間和時間範圍的預訂被併發修改。 -這種方法稱為*物化衝突*,因為它採用了幻讀並將其轉化為存在於資料庫中的具體行集上的鎖衝突[^14]。不幸的是,很難且容易出錯地弄清楚如何物化衝突,並且讓併發控制機制洩漏到應用程式資料模型中是醜陋的。出於這些原因,如果沒有其他選擇,物化衝突應被視為最後的手段。在大多數情況下,可序列化隔離級別要好得多。 +這種方法稱為**物化衝突**,因為它採用了幻讀並將其轉化為存在於資料庫中的具體行集上的鎖衝突[^14]。不幸的是,很難且容易出錯地弄清楚如何物化衝突,並且讓併發控制機制洩漏到應用程式資料模型中是醜陋的。出於這些原因,如果沒有其他選擇,物化衝突應被視為最後的手段。在大多數情況下,可序列化隔離級別要好得多。 @@ -585,9 +585,9 @@ UPDATE wiki_pages SET content = 'new content' * 如果你檢視你的應用程式程式碼,很難判斷在特定隔離級別下執行是否安全——特別是在大型應用程式中,你可能不知道所有可能併發發生的事情。 * 沒有好的工具來幫助我們檢測競態條件。原則上,靜態分析可能有所幫助[^33],但研究技術尚未進入實際使用。測試併發問題很困難,因為它們通常是非確定性的——只有在時機不巧時才會出現問題。 -這不是一個新問題——自 1970 年代引入弱隔離級別以來一直如此[^3]。一直以來,研究人員的答案都很簡單:使用*可序列化*隔離! +這不是一個新問題——自 1970 年代引入弱隔離級別以來一直如此[^3]。一直以來,研究人員的答案都很簡單:使用**可序列化**隔離! -可序列化隔離是最強的隔離級別。它保證即使事務可能並行執行,最終結果與它們*序列*執行(一次一個,沒有任何併發)相同。因此,資料庫保證如果事務在單獨執行時行為正確,那麼在併發執行時它們繼續保持正確——換句話說,資料庫防止了*所有*可能的競態條件。 +可序列化隔離是最強的隔離級別。它保證即使事務可能並行執行,最終結果與它們**序列**執行(一次一個,沒有任何併發)相同。因此,資料庫保證如果事務在單獨執行時行為正確,那麼在併發執行時它們繼續保持正確——換句話說,資料庫防止了**所有**可能的競態條件。 但如果可序列化隔離比弱隔離級別的混亂要好得多,那為什麼不是每個人都在使用它?要回答這個問題,我們需要檢視實現可序列化的選項,以及它們的效能如何。今天提供可序列化的大多數資料庫使用以下三種技術之一,我們將在本章的其餘部分探討: @@ -618,7 +618,7 @@ UPDATE wiki_pages SET content = 'new content' 在這種互動式事務風格中,大量時間花在應用程式和資料庫之間的網路通訊上。如果你要在資料庫中禁止併發並一次只處理一個事務,吞吐量將是可怕的,因為資料庫將大部分時間都在等待應用程式為當前事務發出下一個查詢。在這種資料庫中,為了獲得合理的效能,必須併發處理多個事務。 -因此,具有單執行緒序列事務處理的系統不允許互動式多語句事務。相反,應用程式必須將自己限制為包含單個語句的事務,或者提前將整個事務程式碼作為*儲存過程*提交給資料庫[^61]。 +因此,具有單執行緒序列事務處理的系統不允許互動式多語句事務。相反,應用程式必須將自己限制為包含單個語句的事務,或者提前將整個事務程式碼作為**儲存過程**提交給資料庫[^61]。 互動式事務和儲存過程之間的差異如[圖 8-9](#fig_transactions_stored_proc) 所示。前提是事務所需的所有資料都在記憶體中,儲存過程可以非常快速地執行,而無需等待任何網路或磁碟 I/O。 @@ -639,7 +639,7 @@ UPDATE wiki_pages SET content = 'new content' 使用儲存過程和記憶體資料,在單個執行緒上執行所有事務變得可行。當儲存過程不需要等待 I/O 並避免其他併發控制機制的開銷時,它們可以在單個執行緒上實現相當好的吞吐量。 -VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個節點複製到另一個節點,而是在每個副本上執行相同的儲存過程。因此,VoltDB 要求儲存過程是*確定性的*(在不同節點上執行時,它們必須產生相同的結果)。例如,如果事務需要使用當前日期和時間,它必須透過特殊的確定性 API 來實現(有關確定性操作的更多詳細資訊,請參見["持久執行和工作流"](/tw/ch5#sec_encoding_dataflow_workflows))。這種方法稱為*狀態機複製*,我們將在[第 10 章](/tw/ch10#ch_consistency)中回到它。 +VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個節點複製到另一個節點,而是在每個副本上執行相同的儲存過程。因此,VoltDB 要求儲存過程是**確定性的**(在不同節點上執行時,它們必須產生相同的結果)。例如,如果事務需要使用當前日期和時間,它必須透過特殊的確定性 API 來實現(有關確定性操作的更多詳細資訊,請參見["持久執行和工作流"](/tw/ch5#sec_encoding_dataflow_workflows))。這種方法稱為**狀態機複製**,我們將在[第 10 章](/tw/ch10#ch_consistency)中回到它。 #### 分片 {#sharding} @@ -664,14 +664,14 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個 ### 兩階段鎖定(2PL) {#sec_transactions_2pl} -大約 30 年來,資料庫中只有一種廣泛使用的可序列化演算法:*兩階段鎖定*(2PL),有時稱為*強嚴格兩階段鎖定*(SS2PL),以區別於 2PL 的其他變體。 +大約 30 年來,資料庫中只有一種廣泛使用的可序列化演算法:**兩階段鎖定**(2PL),有時稱為**強嚴格兩階段鎖定**(SS2PL),以區別於 2PL 的其他變體。 -------- > [!TIP] 2PL 不是 2PC -兩階段*鎖定*(2PL)和兩階段*提交*(2PC)是兩個非常不同的東西。2PL 提供可序列化隔離,而 2PC 在分散式資料庫中提供原子提交(參見["兩階段提交(2PC)"](#sec_transactions_2pc))。為避免混淆,最好將它們視為完全獨立的概念,並忽略名稱中不幸的相似性。 +兩階段**鎖定**(2PL)和兩階段**提交**(2PC)是兩個非常不同的東西。2PL 提供可序列化隔離,而 2PC 在分散式資料庫中提供原子提交(參見["兩階段提交(2PC)"](#sec_transactions_2pc))。為避免混淆,最好將它們視為完全獨立的概念,並忽略名稱中不幸的相似性。 -------- @@ -682,20 +682,20 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個 * 如果事務 A 已讀取物件而事務 B 想要寫入該物件,B 必須等到 A 提交或中止後才能繼續。(這確保 B 不能在 A 背後意外地更改物件。) * 如果事務 A 已寫入物件而事務 B 想要讀取該物件,B 必須等到 A 提交或中止後才能繼續。(像[圖 8-4](#fig_transactions_read_committed) 中那樣讀取物件的舊版本在 2PL 下是不可接受的。) -在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*(參見["多版本併發控制(MVCC)"](#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏差。 +在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:**讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者**(參見["多版本併發控制(MVCC)"](#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏差。 #### 兩階段鎖定的實現 {#implementation-of-two-phase-locking} 2PL 由 MySQL(InnoDB)和 SQL Server 中的可序列化隔離級別以及 Db2 中的可重複讀隔離級別使用[^29]。 -讀者和寫者的阻塞是透過在資料庫中的每個物件上有一個鎖來實現的。鎖可以處於*共享模式*或*獨佔模式*(也稱為*多讀者單寫者*鎖)。鎖的使用如下: +讀者和寫者的阻塞是透過在資料庫中的每個物件上有一個鎖來實現的。鎖可以處於**共享模式**或**獨佔模式**(也稱為**多讀者單寫者**鎖)。鎖的使用如下: * 如果事務想要讀取物件,它必須首先以共享模式獲取鎖。多個事務可以同時以共享模式持有鎖,但如果另一個事務已經對該物件具有獨佔鎖,則這些事務必須等待。 * 如果事務想要寫入物件,它必須首先以獨佔模式獲取鎖。沒有其他事務可以同時持有鎖(無論是共享模式還是獨佔模式),因此如果物件上有任何現有鎖,事務必須等待。 * 如果事務首先讀取然後寫入物件,它可以將其共享鎖升級為獨佔鎖。升級的工作方式與直接獲取獨佔鎖相同。 * 獲取鎖後,事務必須繼續持有鎖直到事務結束(提交或中止)。這就是"兩階段"名稱的來源:第一階段(事務執行時)是獲取鎖,第二階段(事務結束時)是釋放所有鎖。 -由於使用了如此多的鎖,很容易發生事務 A 等待事務 B 釋放其鎖,反之亦然的情況。這種情況稱為*死鎖*。資料庫自動檢測事務之間的死鎖並中止其中一個,以便其他事務可以取得進展。中止的事務需要由應用程式重試。 +由於使用了如此多的鎖,很容易發生事務 A 等待事務 B 釋放其鎖,反之亦然的情況。這種情況稱為**死鎖**。資料庫自動檢測事務之間的死鎖並中止其中一個,以便其他事務可以取得進展。中止的事務需要由應用程式重試。 #### 兩階段鎖定的效能 {#performance-of-two-phase-locking} @@ -711,11 +711,11 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個 #### 謂詞鎖 {#predicate-locks} -在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏差的幻讀"](#sec_transactions_phantom)中,我們討論了*幻讀*的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。 +在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏差的幻讀"](#sec_transactions_phantom)中,我們討論了**幻讀**的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。 在會議室預訂示例中,這意味著如果一個事務已經搜尋了某個時間視窗內某個房間的現有預訂(參見[例 8-2](#fig_transactions_meeting_rooms)),另一個事務不允許併發插入或更新同一房間和時間範圍的另一個預訂。(併發插入其他房間的預訂,或同一房間不影響擬議預訂的不同時間的預訂是可以的。) -我們如何實現這一點?從概念上講,我們需要一個*謂詞鎖*[^4]。它的工作方式類似於前面描述的共享/獨佔鎖,但它不屬於特定物件(例如,表中的一行),而是屬於匹配某些搜尋條件的所有物件,例如: +我們如何實現這一點?從概念上講,我們需要一個**謂詞鎖**[^4]。它的工作方式類似於前面描述的共享/獨佔鎖,但它不屬於特定物件(例如,表中的一行),而是屬於匹配某些搜尋條件的所有物件,例如: ``` SELECT * FROM bookings @@ -733,7 +733,7 @@ SELECT * FROM bookings #### 索引範圍鎖 {#sec_transactions_2pl_range} -不幸的是,謂詞鎖的效能不佳:如果活動事務有許多鎖,檢查匹配鎖變得耗時。因此,大多數具有 2PL 的資料庫實際上實現了*索引範圍鎖定*(也稱為*間隙鎖*),這是謂詞鎖定的簡化近似[^54] [^64]。 +不幸的是,謂詞鎖的效能不佳:如果活動事務有許多鎖,檢查匹配鎖變得耗時。因此,大多數具有 2PL 的資料庫實際上實現了**索引範圍鎖定**(也稱為**間隙鎖**),這是謂詞鎖定的簡化近似[^54] [^64]。 透過使謂詞匹配更大的物件集來簡化謂詞是安全的。例如,如果你對中午到下午 1 點之間房間 123 的預訂有謂詞鎖,你可以透過鎖定房間 123 在任何時間的預訂來近似它,或者你可以透過鎖定中午到下午 1 點之間的所有房間(不僅僅是房間 123)來近似它。這是安全的,因為匹配原始謂詞的任何寫入肯定也會匹配近似。 @@ -752,17 +752,17 @@ SELECT * FROM bookings 本章描繪了資料庫併發控制的黯淡畫面。一方面,我們有效能不佳(兩階段鎖定)或可伸縮性不佳(序列執行)的可序列化實現。另一方面,我們有效能良好但容易出現各種競態條件(丟失的更新、寫偏差、幻讀等)的弱隔離級別。可序列化隔離和良好效能從根本上是對立的嗎? -似乎不是:一種稱為*可序列化快照隔離*(SSI)的演算法提供完全可序列化,與快照隔離相比只有很小的效能損失。SSI 相對較新:它於 2008 年首次描述[^53] [^65]。 +似乎不是:一種稱為**可序列化快照隔離**(SSI)的演算法提供完全可序列化,與快照隔離相比只有很小的效能損失。SSI 相對較新:它於 2008 年首次描述[^53] [^65]。 今天,SSI 和類似演算法用於單節點資料庫(PostgreSQL 中的可序列化隔離級別[^54]、SQL Server 的記憶體 OLTP/Hekaton[^66] 和 HyPer[^67])、分散式資料庫(CockroachDB[^5] 和 FoundationDB[^8])以及嵌入式儲存引擎(如 BadgerDB)。 #### 悲觀併發控制與樂觀併發控制 {#pessimistic-versus-optimistic-concurrency-control} -兩階段鎖定是所謂的*悲觀*併發控制機制:它基於這樣的原則,即如果任何事情可能出錯(如另一個事務持有的鎖所示),最好等到情況再次安全後再做任何事情。它就像*互斥*,用於保護多執行緒程式設計中的資料結構。 +兩階段鎖定是所謂的**悲觀**併發控制機制:它基於這樣的原則,即如果任何事情可能出錯(如另一個事務持有的鎖所示),最好等到情況再次安全後再做任何事情。它就像**互斥**,用於保護多執行緒程式設計中的資料結構。 序列執行在某種意義上是悲觀到極端:它本質上相當於每個事務在事務期間對整個資料庫(或資料庫的一個分片)具有獨佔鎖。我們透過使每個事務執行得非常快來補償悲觀主義,因此它只需要短時間持有"鎖"。 -相比之下,可序列化快照隔離是一種*樂觀*併發控制技術。在這種情況下,樂觀意味著,如果發生潛在危險的事情,事務不會阻塞,而是繼續進行,希望一切都會好起來。當事務想要提交時,資料庫會檢查是否發生了任何不好的事情(即,是否違反了隔離);如果是,事務將被中止並必須重試。只允許可序列執行的事務提交。 +相比之下,可序列化快照隔離是一種**樂觀**併發控制技術。在這種情況下,樂觀意味著,如果發生潛在危險的事情,事務不會阻塞,而是繼續進行,希望一切都會好起來。當事務想要提交時,資料庫會檢查是否發生了任何不好的事情(即,是否違反了隔離);如果是,事務將被中止並必須重試。只允許可序列執行的事務提交。 樂觀併發控制是一個老想法[^68],其優缺點已經爭論了很長時間[^69]。如果存在高爭用(許多事務嘗試訪問相同的物件),它的效能很差,因為這會導致大部分事務需要中止。如果系統已經接近其最大吞吐量,重試事務的額外事務負載可能會使效能變差。 @@ -774,7 +774,7 @@ SELECT * FROM bookings 當我們之前討論快照隔離中的寫偏差時(參見["寫偏差與幻讀"](#sec_transactions_write_skew)),我們觀察到一個反覆出現的模式:事務從資料庫讀取一些資料,檢查查詢結果,並根據它看到的結果決定採取某些行動(寫入資料庫)。但是,在快照隔離下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在此期間被修改。 -換句話說,事務基於*前提*(事務開始時為真的事實,例如,"當前有兩名醫生值班")採取行動。後來,當事務想要提交時,原始資料可能已更改——前提可能不再為真。 +換句話說,事務基於**前提**(事務開始時為真的事實,例如,"當前有兩名醫生值班")採取行動。後來,當事務想要提交時,原始資料可能已更改——前提可能不再為真。 當應用程式進行查詢(例如,"當前有多少醫生值班?")時,資料庫不知道應用程式邏輯如何使用該查詢的結果。為了安全起見,資料庫需要假設查詢結果(前提)中的任何更改都意味著該事務中的寫入可能無效。換句話說,事務中的查詢和寫入之間可能存在因果依賴關係。為了提供可序列化隔離,資料庫必須檢測事務可能基於過時前提採取行動的情況,並在這種情況下中止事務。 @@ -833,7 +833,7 @@ SELECT * FROM bookings 對於在單個數據庫節點執行的事務,原子性通常由儲存引擎實現。當客戶端要求資料庫節點提交事務時,資料庫使事務的寫入持久化(通常在預寫日誌中;參見["使 B 樹可靠"](/tw/ch4#sec_storage_btree_wal)),然後將提交記錄附加到磁碟上的日誌。如果資料庫在此過程中崩潰,事務將在節點重新啟動時從日誌中恢復:如果提交記錄在崩潰前成功寫入磁碟,則事務被認為已提交;如果沒有,該事務的任何寫入都將回滾。 -因此,在單個節點上,事務提交關鍵取決於資料持久寫入磁碟的*順序*:首先是資料,然後是提交記錄[^22]。事務提交或中止的關鍵決定時刻是磁碟完成寫入提交記錄的時刻:在那一刻之前,仍然可能中止(由於崩潰),但在那一刻之後,事務已提交(即使資料庫崩潰)。因此,是單個裝置(連線到特定節點的特定磁碟驅動器的控制器)使提交成為原子的。 +因此,在單個節點上,事務提交關鍵取決於資料持久寫入磁碟的**順序**:首先是資料,然後是提交記錄[^22]。事務提交或中止的關鍵決定時刻是磁碟完成寫入提交記錄的時刻:在那一刻之前,仍然可能中止(由於崩潰),但在那一刻之後,事務已提交(即使資料庫崩潰)。因此,是單個裝置(連線到特定節點的特定磁碟驅動器的控制器)使提交成為原子的。 但是,如果多個節點參與事務會怎樣?例如,也許你在分片資料庫中有多物件事務,或者有全域性二級索引(其中索引條目可能與主資料在不同的節點上;參見["分片和二級索引"](/tw/ch7#sec_sharding_secondary_indexes))。大多數"NoSQL"分散式資料儲存不支援此類分散式事務,但各種分散式關係資料庫支援。 @@ -846,25 +846,25 @@ SELECT * FROM bookings {{< figure src="/fig/ddia_0812.png" id="fig_transactions_non_atomic" caption="圖 8-12. 當事務涉及多個數據庫節點時,它可能在某些節點上提交,在其他節點上失敗。" class="w-full my-4" >}} -如果某些節點提交事務而其他節點中止它,節點之間就會變得不一致。一旦事務在一個節點上提交,如果後來發現它在另一個節點上被中止,就不能撤回了。這是因為一旦資料被提交,它在*讀已提交*或更強的隔離下對其他事務可見。例如,在[圖 8-12](#fig_transactions_non_atomic) 中,當用戶 1 注意到其在資料庫 1 上的提交失敗時,使用者 2 已經從資料庫 2 上的同一事務讀取了資料。如果使用者 1 的事務後來被中止,使用者 2 的事務也必須被還原,因為它基於被追溯宣告不存在的資料。 +如果某些節點提交事務而其他節點中止它,節點之間就會變得不一致。一旦事務在一個節點上提交,如果後來發現它在另一個節點上被中止,就不能撤回了。這是因為一旦資料被提交,它在**讀已提交**或更強的隔離下對其他事務可見。例如,在[圖 8-12](#fig_transactions_non_atomic) 中,當用戶 1 注意到其在資料庫 1 上的提交失敗時,使用者 2 已經從資料庫 2 上的同一事務讀取了資料。如果使用者 1 的事務後來被中止,使用者 2 的事務也必須被還原,因為它基於被追溯宣告不存在的資料。 -更好的方法是確保參與事務的節點要麼全部提交,要麼全部中止,並防止兩者的混合。確保這一點被稱為*原子提交*問題。 +更好的方法是確保參與事務的節點要麼全部提交,要麼全部中止,並防止兩者的混合。確保這一點被稱為**原子提交**問題。 ### 兩階段提交(2PC) {#sec_transactions_2pc} -兩階段提交是一種跨多個節點實現原子事務提交的演算法。它是分散式資料庫中的經典演算法[^13] [^71] [^72]。2PC 在某些資料庫內部使用,也以 *XA 事務*[^73] 的形式提供給應用程式(例如,Java 事務 API 支援),或透過 WS-AtomicTransaction 用於 SOAP Web 服務[^74] [^75]。 +兩階段提交是一種跨多個節點實現原子事務提交的演算法。它是分散式資料庫中的經典演算法[^13] [^71] [^72]。2PC 在某些資料庫內部使用,也以 **XA 事務**[^73] 的形式提供給應用程式(例如,Java 事務 API 支援),或透過 WS-AtomicTransaction 用於 SOAP Web 服務[^74] [^75]。 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" >}} -2PC 使用一個通常不會出現在單節點事務中的新元件:*協調器*(也稱為*事務管理器*)。協調器通常作為請求事務的同一應用程式程序中的庫實現(例如,嵌入在 Java EE 容器中),但它也可以是單獨的程序或服務。此類協調器的示例包括 Narayana、JOTM、BTM 或 MSDTC。 +2PC 使用一個通常不會出現在單節點事務中的新元件:**協調器**(也稱為**事務管理器**)。協調器通常作為請求事務的同一應用程式程序中的庫實現(例如,嵌入在 Java EE 容器中),但它也可以是單獨的程序或服務。此類協調器的示例包括 Narayana、JOTM、BTM 或 MSDTC。 -使用 2PC 時,分散式事務從應用程式在多個數據庫節點上正常讀寫資料開始。我們稱這些資料庫節點為事務中的*參與者*。當應用程式準備提交時,協調器開始第 1 階段:它向每個節點發送*準備*請求,詢問它們是否能夠提交。然後協調器跟蹤參與者的響應: +使用 2PC 時,分散式事務從應用程式在多個數據庫節點上正常讀寫資料開始。我們稱這些資料庫節點為事務中的**參與者**。當應用程式準備提交時,協調器開始第 1 階段:它向每個節點發送**準備**請求,詢問它們是否能夠提交。然後協調器跟蹤參與者的響應: -* 如果所有參與者回覆"是",表示他們準備提交,那麼協調器在第 2 階段發出*提交*請求,提交實際發生。 -* 如果任何參與者回覆"否",協調器在第 2 階段向所有節點發送*中止*請求。 +* 如果所有參與者回覆"是",表示他們準備提交,那麼協調器在第 2 階段發出**提交**請求,提交實際發生。 +* 如果任何參與者回覆"否",協調器在第 2 階段向所有節點發送**中止**請求。 這個過程有點像西方文化中的傳統婚禮儀式:牧師分別詢問新娘和新郎是否願意嫁給對方,通常從兩人那裡得到"我願意"的答案。在收到兩個確認後,牧師宣佈這對夫婦為夫妻:事務已提交,這個快樂的事實向所有參加者廣播。如果新娘或新郎沒有說"是",儀式就被中止了[^76]。 @@ -880,7 +880,7 @@ SELECT * FROM bookings 4. 當參與者收到準備請求時,它確保它可以在任何情況下明確提交事務。 這包括將所有事務資料寫入磁碟(崩潰、電源故障或磁碟空間不足不是稍後拒絕提交的可接受藉口),並檢查任何衝突或約束違規。透過向協調器回覆"是",節點承諾在請求時無錯誤地提交事務。換句話說,參與者放棄了中止事務的權利,但沒有實際提交它。 -5. 當協調器收到所有準備請求的響應時,它對是否提交或中止事務做出明確決定(僅當所有參與者投票"是"時才提交)。協調器必須將該決定寫入其磁碟上的事務日誌,以便在隨後崩潰時知道它是如何決定的。這稱為*提交點*。 +5. 當協調器收到所有準備請求的響應時,它對是否提交或中止事務做出明確決定(僅當所有參與者投票"是"時才提交)。協調器必須將該決定寫入其磁碟上的事務日誌,以便在隨後崩潰時知道它是如何決定的。這稱為**提交點**。 6. 一旦協調器的決定被寫入磁碟,提交或中止請求就會發送給所有參與者。如果此請求失敗或超時,協調器必須永遠重試,直到成功。沒有回頭路:如果決定是提交,那麼必須執行該決定,無論需要多少次重試。如果參與者在此期間崩潰,事務將在恢復時提交——因為參與者投票"是",它在恢復時不能拒絕提交。 因此,該協議包含兩個關鍵的"不歸路":當參與者投票"是"時,它承諾它肯定能夠稍後提交(儘管協調器仍可能選擇中止);一旦協調器決定,該決定是不可撤銷的。這些承諾確保了 2PC 的原子性。(單節點原子提交將這兩個事件合併為一個:將提交記錄寫入事務日誌。) @@ -891,7 +891,7 @@ SELECT * FROM bookings 我們已經討論了如果參與者之一或網路在 2PC 期間失敗會發生什麼:如果任何準備請求失敗或超時,協調器將中止事務;如果任何提交或中止請求失敗,協調器將無限期地重試它們。但是,如果協調器崩潰會發生什麼就不太清楚了。 -如果協調器在傳送準備請求之前失敗,參與者可以安全地中止事務。但是一旦參與者收到準備請求並投票"是",它就不能再單方面中止——它必須等待協調器回覆事務是提交還是中止。如果協調器此時崩潰或網路失敗,參與者除了等待別無他法。參與者在此狀態下的事務稱為*存疑*或*不確定*。 +如果協調器在傳送準備請求之前失敗,參與者可以安全地中止事務。但是一旦參與者收到準備請求並投票"是",它就不能再單方面中止——它必須等待協調器回覆事務是提交還是中止。如果協調器此時崩潰或網路失敗,參與者除了等待別無他法。參與者在此狀態下的事務稱為**存疑**或**不確定**。 這種情況如[圖 8-14](#fig_transactions_2pc_crash) 所示。在這個特定的例子中,協調器實際上決定提交,資料庫 2 收到了提交請求。但是,協調器在向資料庫 1 傳送提交請求之前崩潰了,因此資料庫 1 不知道是提交還是中止。即使超時在這裡也沒有幫助:如果資料庫 1 在超時後單方面中止,它將與已提交的資料庫 2 不一致。同樣,單方面提交也不安全,因為另一個參與者可能已中止。 @@ -904,9 +904,9 @@ SELECT * FROM bookings #### 三階段提交 {#three-phase-commit} -由於 2PC 可能會卡住等待協調器恢復,因此兩階段提交被稱為*阻塞*原子提交協議。可以使原子提交協議*非阻塞*,以便在節點失敗時不會卡住。但是,在實踐中使其工作並不那麼簡單。 +由於 2PC 可能會卡住等待協調器恢復,因此兩階段提交被稱為**阻塞**原子提交協議。可以使原子提交協議**非阻塞**,以便在節點失敗時不會卡住。但是,在實踐中使其工作並不那麼簡單。 -作為 2PC 的替代方案,已經提出了一種稱為*三階段提交*(3PC)的演算法[^13] [^77]。但是,3PC 假設具有有界延遲的網路和具有有界響應時間的節點;在大多數具有無界網路延遲和程序暫停的實際系統中(參見[第 9 章](/tw/ch9#ch_distributed)),它無法保證原子性。 +作為 2PC 的替代方案,已經提出了一種稱為**三階段提交**(3PC)的演算法[^13] [^77]。但是,3PC 假設具有有界延遲的網路和具有有界響應時間的節點;在大多數具有無界網路延遲和程序暫停的實際系統中(參見[第 9 章](/tw/ch9#ch_distributed)),它無法保證原子性。 實踐中更好的解決方案是用容錯共識協議替換單節點協調器。我們將在[第 10 章](/tw/ch10#ch_consistency)中看到如何做到這一點。 @@ -922,7 +922,7 @@ SELECT * FROM bookings : 某些分散式資料庫(即,在其標準配置中使用複製和分片的資料庫)支援該資料庫節點之間的內部事務。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB 和 MySQL Cluster 的 NDB 儲存引擎都有這樣的內部事務支援。在這種情況下,參與事務的所有節點都執行相同的資料庫軟體。 異構分散式事務 -: 在*異構*事務中,參與者是兩個或多個不同的技術:例如,來自不同供應商的兩個資料庫,甚至是非資料庫系統(如訊息代理)。跨這些系統的分散式事務必須確保原子提交,即使系統在底層可能完全不同。 +: 在**異構**事務中,參與者是兩個或多個不同的技術:例如,來自不同供應商的兩個資料庫,甚至是非資料庫系統(如訊息代理)。跨這些系統的分散式事務必須確保原子提交,即使系統在底層可能完全不同。 資料庫內部事務不必與任何其他系統相容,因此它們可以使用任何協議並應用特定於該特定技術的最佳化。因此,資料庫內部分散式事務通常可以很好地工作。另一方面,跨異構技術的事務更具挑戰性。 @@ -930,7 +930,7 @@ SELECT * FROM bookings 異構分散式事務允許以強大的方式整合各種系統。例如,當且僅當處理訊息的資料庫事務成功提交時,來自訊息佇列的訊息才能被確認為已處理。這是透過在單個事務中原子地提交訊息確認和資料庫寫入來實現的。有了分散式事務支援,即使訊息代理和資料庫是在不同機器上執行的兩種不相關的技術,這也是可能的。 -如果訊息傳遞或資料庫事務失敗,兩者都會中止,因此訊息代理可以稍後安全地重新傳遞訊息。因此,透過原子地提交訊息及其處理的副作用,我們可以確保訊息在效果上*恰好*處理一次,即使在成功之前需要幾次重試。中止會丟棄部分完成事務的任何副作用。這被稱為*恰好一次語義*。 +如果訊息傳遞或資料庫事務失敗,兩者都會中止,因此訊息代理可以稍後安全地重新傳遞訊息。因此,透過原子地提交訊息及其處理的副作用,我們可以確保訊息在效果上**恰好**處理一次,即使在成功之前需要幾次重試。中止會丟棄部分完成事務的任何副作用。這被稱為**恰好一次語義**。 但是,只有當受事務影響的所有系統都能夠使用相同的原子提交協議時,這種分散式事務才有可能。例如,假設處理訊息的副作用是傳送電子郵件,而電子郵件伺服器不支援兩階段提交:如果訊息處理失敗並重試,可能會發生電子郵件被傳送兩次或更多次。但是,如果處理訊息的所有副作用在事務中止時都會回滾,那麼處理步驟可以安全地重試,就好像什麼都沒有發生一樣。 @@ -938,7 +938,7 @@ SELECT * FROM bookings #### XA 事務 {#xa-transactions} -*X/Open XA*(*eXtended Architecture* 的縮寫)是跨異構技術實現兩階段提交的標準[^73]。它於 1991 年推出並得到廣泛實現:XA 受到許多傳統關係資料庫(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和訊息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)的支援。 +**X/Open XA**(**eXtended Architecture** 的縮寫)是跨異構技術實現兩階段提交的標準[^73]。它於 1991 年推出並得到廣泛實現:XA 受到許多傳統關係資料庫(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和訊息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)的支援。 XA 不是網路協議——它只是用於與事務協調器介面的 C API。此 API 的繫結存在於其他語言中;例如,在 Java EE 應用程式的世界中,XA 事務使用 Java 事務 API(JTA)實現,而 JTA 又由許多使用 Java 資料庫連線(JDBC)的資料庫驅動程式和使用 Java 訊息服務(JMS)API 的訊息代理驅動程式支援。 @@ -952,7 +952,7 @@ XA 假設你的應用程式使用網路驅動程式或客戶端庫與參與者 為什麼我們如此關心事務陷入存疑?系統的其餘部分不能繼續工作,忽略最終會被清理的存疑事務嗎? -問題在於*鎖定*。如["讀已提交"](#sec_transactions_read_committed)中所討論的,資料庫事務通常對它們修改的任何行進行行級獨佔鎖,以防止髒寫。此外,如果你想要可序列化隔離,使用兩階段鎖定的資料庫還必須對事務*讀取*的任何行進行共享鎖。 +問題在於**鎖定**。如["讀已提交"](#sec_transactions_read_committed)中所討論的,資料庫事務通常對它們修改的任何行進行行級獨佔鎖,以防止髒寫。此外,如果你想要可序列化隔離,使用兩階段鎖定的資料庫還必須對事務**讀取**的任何行進行共享鎖。 資料庫在事務提交或中止之前不能釋放這些鎖(如[圖 8-13](#fig_transactions_two_phase_commit) 中的陰影區域所示)。因此,使用兩階段提交時,事務必須在存疑期間保持鎖。如果協調器崩潰並需要 20 分鐘才能重新啟動,這些鎖將保持 20 分鐘。如果協調器的日誌由於某種原因完全丟失,這些鎖將永遠保持——或者至少直到管理員手動解決情況。 @@ -960,13 +960,13 @@ XA 假設你的應用程式使用網路驅動程式或客戶端庫與參與者 #### 從協調器故障中恢復 {#recovering-from-coordinator-failure} -理論上,如果協調器崩潰並重新啟動,它應該從日誌中乾淨地恢復其狀態並解決任何存疑事務。但是,在實踐中,*孤立的*存疑事務確實會發生[^83] [^84]——也就是說,協調器由於某種原因(例如,由於軟體錯誤導致事務日誌丟失或損壞)無法決定結果的事務。這些事務無法自動解決,因此它們永遠留在資料庫中,持有鎖並阻塞其他事務。 +理論上,如果協調器崩潰並重新啟動,它應該從日誌中乾淨地恢復其狀態並解決任何存疑事務。但是,在實踐中,**孤立的**存疑事務確實會發生[^83] [^84]——也就是說,協調器由於某種原因(例如,由於軟體錯誤導致事務日誌丟失或損壞)無法決定結果的事務。這些事務無法自動解決,因此它們永遠留在資料庫中,持有鎖並阻塞其他事務。 即使重新啟動資料庫伺服器也無法解決此問題,因為 2PC 的正確實現必須即使在重新啟動時也保留存疑事務的鎖(否則它將冒著違反原子性保證的風險)。這是一個棘手的情況。 唯一的出路是管理員手動決定是提交還是回滾事務。管理員必須檢查每個存疑事務的參與者,確定是否有任何參與者已經提交或中止,然後將相同的結果應用於其他參與者。解決問題可能需要大量的手動工作,並且很可能需要在嚴重的生產中斷期間在高壓力和時間壓力下完成(否則,為什麼協調器會處於如此糟糕的狀態?)。 -許多 XA 實現都有一個名為*啟發式決策*的緊急逃生艙口:允許參與者在沒有協調器明確決定的情況下單方面決定中止或提交存疑事務[^73]。明確地說,這裡的*啟發式*是*可能破壞原子性*的委婉說法,因為啟發式決策違反了兩階段提交中的承諾系統。因此,啟發式決策僅用於擺脫災難性情況,而不用於常規使用。 +許多 XA 實現都有一個名為**啟發式決策**的緊急逃生艙口:允許參與者在沒有協調器明確決定的情況下單方面決定中止或提交存疑事務[^73]。明確地說,這裡的**啟發式**是**可能破壞原子性**的委婉說法,因為啟發式決策違反了兩階段提交中的承諾系統。因此,啟發式決策僅用於擺脫災難性情況,而不用於常規使用。 #### XA 事務的問題 {#problems-with-xa-transactions} @@ -1010,7 +1010,7 @@ XA 的最大問題可以透過以下方式解決: 如果訊息處理器在提交資料庫事務之前崩潰,事務將被中止,訊息代理將重試處理。如果它在提交後但在向代理確認訊息之前崩潰,它也將重試處理,但重試將在資料庫中看到訊息 ID 並丟棄它。如果它在確認訊息後但在從資料庫中刪除訊息 ID 之前崩潰,你將有一個舊的訊息 ID 留下,除了佔用一點儲存空間外不會造成任何傷害。如果在資料庫事務中止之前發生重試(如果訊息處理器和資料庫之間的通訊中斷,這可能會發生),訊息 ID 表上的唯一性約束應該防止兩個併發事務插入相同的訊息 ID。 -因此,實現恰好一次處理只需要資料庫中的事務——跨資料庫和訊息代理的原子性對於此用例不是必需的。在資料庫中記錄訊息 ID 使訊息處理具備*冪等性*,因此可以安全地重試訊息處理而不會重複其副作用。流處理框架(如 Kafka Streams)中使用類似的方法來實現恰好一次語義,我們將在["容錯"](/tw/ch12#sec_stream_fault_tolerance)中看到。 +因此,實現恰好一次處理只需要資料庫中的事務——跨資料庫和訊息代理的原子性對於此用例不是必需的。在資料庫中記錄訊息 ID 使訊息處理具備**冪等性**,因此可以安全地重試訊息處理而不會重複其副作用。流處理框架(如 Kafka Streams)中使用類似的方法來實現恰好一次語義,我們將在["容錯"](/tw/ch12#sec_stream_fault_tolerance)中看到。 但是,資料庫內的內部分散式事務對於此類模式的可伸縮性仍然有用:例如,它們將允許訊息 ID 儲存在一個分片上,而訊息處理更新的主資料儲存在其他分片上,並確保跨這些分片的事務提交的原子性。 @@ -1018,13 +1018,13 @@ XA 的最大問題可以透過以下方式解決: ## 總結 {#summary} -事務是一個抽象層,允許應用程式假裝某些併發問題和某些型別的硬體和軟體故障不存在。大量錯誤被簡化為簡單的*事務中止*,應用程式只需要重試。 +事務是一個抽象層,允許應用程式假裝某些併發問題和某些型別的硬體和軟體故障不存在。大量錯誤被簡化為簡單的**事務中止**,應用程式只需要重試。 在本章中,我們看到了許多事務有助於防止的問題示例。並非所有應用程式都容易受到所有這些問題的影響:具有非常簡單的訪問模式的應用程式(例如,僅讀取和寫入單個記錄)可能可以在沒有事務的情況下管理。但是,對於更複雜的訪問模式,事務可以大大減少你需要考慮的潛在錯誤情況的數量。 沒有事務,各種錯誤場景(程序崩潰、網路中斷、停電、磁碟已滿、意外併發等)意味著資料可能以各種方式變得不一致。例如,反正規化資料很容易與源資料失去同步。沒有事務,很難推理複雜的互動訪問對資料庫可能產生的影響。 -在本章中,我們特別深入地探討了併發控制的主題。我們討論了幾種廣泛使用的隔離級別,特別是*讀已提交*、*快照隔離*(有時稱為*可重複讀*)和*可序列化*。我們透過討論各種競態條件的示例來描述這些隔離級別,總結在 [表 8-1](#tab_transactions_isolation_levels) 中: +在本章中,我們特別深入地探討了併發控制的主題。我們討論了幾種廣泛使用的隔離級別,特別是**讀已提交**、**快照隔離**(有時稱為**可重複讀**)和**可序列化**。我們透過討論各種競態條件的示例來描述這些隔離級別,總結在 [表 8-1](#tab_transactions_isolation_levels) 中: {{< figure id="tab_transactions_isolation_levels" title="表 8-1. 各種隔離級別可能發生的異常總結" class="w-full my-4" >}} @@ -1042,7 +1042,7 @@ XA 的最大問題可以透過以下方式解決: : 一個客戶端覆蓋另一個客戶端已寫入但尚未提交的資料。幾乎所有事務實現都防止髒寫。 讀取偏差 -: 客戶端在不同時間點看到資料庫的不同部分。某些讀取偏差的情況也稱為*不可重複讀*。這個問題最常透過快照隔離來防止,它允許事務從對應於特定時間點的一致快照讀取。它通常使用*多版本併發控制*(MVCC)實現。 +: 客戶端在不同時間點看到資料庫的不同部分。某些讀取偏差的情況也稱為**不可重複讀**。這個問題最常透過快照隔離來防止,它允許事務從對應於特定時間點的一致快照讀取。它通常使用**多版本併發控制**(MVCC)實現。 丟失更新 : 兩個客戶端併發執行讀-修改-寫迴圈。一個覆蓋另一個的寫入而不合並其更改,因此資料丟失。某些快照隔離的實現會自動防止此異常,而其他實現需要手動鎖(`SELECT FOR UPDATE`)。 diff --git a/content/tw/ch9.md b/content/tw/ch9.md index 72464f6..54fe0c2 100644 --- a/content/tw/ch9.md +++ b/content/tw/ch9.md @@ -14,7 +14,7 @@ breadcrumbs: false 正如 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability) 中所討論的,讓系統可靠意味著確保系統作為一個整體繼續工作,即使出了問題(即出現故障)。然而,預料所有可能的故障並處理它們並不是那麼容易。作為開發者,我們很容易主要關注正常路徑(畢竟,大多數時候事情都執行良好!)而忽略故障,因為故障會引入大量邊界情況。 -如果你希望系統在故障存在的情況下仍然可靠,你必須從根本上改變你的思維方式,並專注於可能出錯的事情,即使它們可能性很低。一件事情出錯的機率是否只有百萬分之一並不重要:在一個足夠大的系統中,百萬分之一的事件每天都在發生。經驗豐富的系統操作員會告訴你,任何 *可能* 出錯的事情 *都會* 出錯。 +如果你希望系統在故障存在的情況下仍然可靠,你必須從根本上改變你的思維方式,並專注於可能出錯的事情,即使它們可能性很低。一件事情出錯的機率是否只有百萬分之一並不重要:在一個足夠大的系統中,百萬分之一的事件每天都在發生。經驗豐富的系統操作員會告訴你,任何 **可能** 出錯的事情 **都會** 出錯。 此外,使用分散式系統與在單臺計算機上編寫軟體有著根本的不同 —— 主要區別在於有許多新的、令人興奮的出錯方式 [^1] [^2]。在本章中,你將體驗實踐中出現的問題,並理解你可以依賴和不能依賴的事物。 @@ -24,7 +24,7 @@ breadcrumbs: false 當你在單臺計算機上編寫程式時,它通常以相當可預測的方式執行:要麼工作,要麼不工作。有缺陷的軟體可能會給人一種計算機有時 "狀態不佳" 的印象(這個問題通常透過重啟來解決),但這主要只是編寫不良的軟體的後果。 -軟體在單臺計算機上不應該是不穩定的,這沒有根本原因:當硬體正常工作時,相同的操作總是產生相同的結果(它是 *確定性的*)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),後果通常是整個系統故障(例如,核心恐慌、"藍色畫面宕機"、無法啟動)。一臺執行良好軟體的單獨計算機通常要麼完全正常執行,要麼完全故障,而不是介於兩者之間。 +軟體在單臺計算機上不應該是不穩定的,這沒有根本原因:當硬體正常工作時,相同的操作總是產生相同的結果(它是 **確定性的**)。如果存在硬體問題(例如,記憶體損壞或聯結器鬆動),後果通常是整個系統故障(例如,核心恐慌、"藍色畫面宕機"、無法啟動)。一臺執行良好軟體的單獨計算機通常要麼完全正常執行,要麼完全故障,而不是介於兩者之間。 這是計算機設計中的一個刻意選擇:如果發生內部故障,我們寧願計算機完全崩潰而不是返回錯誤的結果,因為錯誤的結果很難處理且令人困惑。因此,計算機隱藏了它們所實現的模糊物理現實,並呈現一個以數學完美執行的理想化系統模型。CPU 指令總是做同樣的事情;如果你將一些資料寫入記憶體或磁碟,該資料保持完整,不會被隨機損壞。正如 ["硬體與軟體故障"](/tw/ch2#sec_introduction_hardware_faults) 中所討論的,這實際上並不是真的 —— 實際上,資料確實會被靜默損壞,CPU 有時會靜默返回錯誤的結果 —— 但這種情況發生得足夠少,以至於我們可以忽略它。 @@ -34,7 +34,7 @@ breadcrumbs: false > > —— Coda Hale -在分散式系統中,系統的某些部分可能以某種不可預測的方式出現故障,即使系統的其他部分工作正常。這被稱為 *部分失效*。困難在於部分失效是 *非確定性的*:如果你嘗試做任何涉及多個節點和網路的事情,它有時可能工作,有時可能不可預測地失敗。正如我們將看到的,你甚至可能不 *知道* 某事是否成功! +在分散式系統中,系統的某些部分可能以某種不可預測的方式出現故障,即使系統的其他部分工作正常。這被稱為 **部分失效**。困難在於部分失效是 **非確定性的**:如果你嘗試做任何涉及多個節點和網路的事情,它有時可能工作,有時可能不可預測地失敗。正如我們將看到的,你甚至可能不 **知道** 某事是否成功! 這種非確定性和部分失效的可能性使分散式系統難以使用 [^4]。另一方面,如果分散式系統可以容忍部分失效,這將開啟強大的可能性:例如,它允許你執行滾動升級,一次重啟一個節點以安裝軟體更新,而系統作為一個整體繼續不間斷地工作。因此,容錯使我們能夠從不可靠的元件構建比單節點系統更可靠的分散式系統。 @@ -42,9 +42,9 @@ breadcrumbs: false ## 不可靠的網路 {#sec_distributed_networks} -正如 ["共享記憶體、共享磁碟和無共享架構"](/tw/ch2#sec_introduction_shared_nothing) 中所討論的,我們在本書中關注的分散式系統主要是 *無共享系統*:即透過網路連線的一組機器。網路是這些機器進行通訊的唯一方式 —— 我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除非透過網路向服務發出請求)。即使儲存是共享的,例如亞馬遜的 S3,機器也是透過網路與共享儲存服務通訊。 +正如 ["共享記憶體、共享磁碟和無共享架構"](/tw/ch2#sec_introduction_shared_nothing) 中所討論的,我們在本書中關注的分散式系統主要是 **無共享系統**:即透過網路連線的一組機器。網路是這些機器進行通訊的唯一方式 —— 我們假設每臺機器都有自己的記憶體和磁碟,一臺機器不能訪問另一臺機器的記憶體或磁碟(除非透過網路向服務發出請求)。即使儲存是共享的,例如亞馬遜的 S3,機器也是透過網路與共享儲存服務通訊。 -網際網路和資料中心中的大多數內部網路(通常是乙太網)都是 *非同步分組網路*。在這種網路中,一個節點可以向另一個節點發送訊息(資料包),但網路不保證它何時到達,或者是否會到達。如果你傳送請求並期望響應,許多事情可能會出錯(其中一些如 [圖 9-1](#fig_distributed_network) 所示): +網際網路和資料中心中的大多數內部網路(通常是乙太網)都是 **非同步分組網路**。在這種網路中,一個節點可以向另一個節點發送訊息(資料包),但網路不保證它何時到達,或者是否會到達。如果你傳送請求並期望響應,許多事情可能會出錯(其中一些如 [圖 9-1](#fig_distributed_network) 所示): 1. 你的請求可能已經丟失(也許有人拔掉了網線)。 2. 你的請求可能在佇列中等待,稍後將被交付(也許網路或接收方過載)。 @@ -56,13 +56,13 @@ breadcrumbs: false {{< figure src="/fig/ddia_0901.png" id="fig_distributed_network" caption="圖 9-1. 如果你傳送請求但沒有收到響應,無法區分是 (a) 請求丟失了,(b) 遠端節點宕機了,還是 (c) 響應丟失了。" class="w-full my-4" >}} -傳送方甚至無法判斷資料包是否已交付:唯一的選擇是讓接收方傳送響應訊息,而響應訊息本身也可能丟失或延遲。在非同步網路中,這些問題是無法區分的:你擁有的唯一資訊是你還沒有收到響應。如果你向另一個節點發送請求但沒有收到響應,*不可能* 判斷原因。 +傳送方甚至無法判斷資料包是否已交付:唯一的選擇是讓接收方傳送響應訊息,而響應訊息本身也可能丟失或延遲。在非同步網路中,這些問題是無法區分的:你擁有的唯一資訊是你還沒有收到響應。如果你向另一個節點發送請求但沒有收到響應,**不可能** 判斷原因。 -處理這個問題的常用方法是 *超時*:在一段時間後,你放棄等待並假設響應不會到達。然而,當超時發生時,你仍然不知道遠端節點是否收到了你的請求(如果請求仍在某處排隊,即使傳送方已經放棄了它,它仍可能被交付給接收方)。 +處理這個問題的常用方法是 **超時**:在一段時間後,你放棄等待並假設響應不會到達。然而,當超時發生時,你仍然不知道遠端節點是否收到了你的請求(如果請求仍在某處排隊,即使傳送方已經放棄了它,它仍可能被交付給接收方)。 ### TCP 的侷限性 {#sec_distributed_tcp} -網路資料包有最大大小(通常為幾千位元組),但許多應用程式需要傳送太大而無法裝入一個數據包的訊息(請求、響應)。這些應用程式最常使用 TCP(傳輸控制協議)來建立一個 *連線*,將大型資料流分解為單個數據包,並在接收端將它們重新組合起來。 +網路資料包有最大大小(通常為幾千位元組),但許多應用程式需要傳送太大而無法裝入一個數據包的訊息(請求、響應)。這些應用程式最常使用 TCP(傳輸控制協議)來建立一個 **連線**,將大型資料流分解為單個數據包,並在接收端將它們重新組合起來。 -------- @@ -71,7 +71,7 @@ breadcrumbs: false -------- -TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢測並重傳丟棄的資料包,檢測重新排序的資料包並將它們恢復到正確的順序,並使用簡單的校驗和檢測資料包損壞。它還計算出可以傳送資料的速度,以便儘快傳輸資料,但不會使網路或接收節點過載;這被稱為 *擁塞控制*、*流量控制* 或 *背壓* [^5]。 +TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢測並重傳丟棄的資料包,檢測重新排序的資料包並將它們恢復到正確的順序,並使用簡單的校驗和檢測資料包損壞。它還計算出可以傳送資料的速度,以便儘快傳輸資料,但不會使網路或接收節點過載;這被稱為 **擁塞控制**、**流量控制** 或 **背壓** [^5]。 當你透過將資料寫入套接字來 "傳送" 一些資料時,它實際上不會立即傳送,而只是放置在由作業系統管理的緩衝區中。當擁塞控制演算法決定它有能力傳送資料包時,它會從該緩衝區中獲取下一個資料包的資料並將其傳遞給網路介面。資料包通過幾個交換機和路由器,最終接收節點的作業系統將資料包的資料放置在接收緩衝區中並向傳送方傳送確認資料包。只有這樣,接收作業系統才會通知應用程式有更多資料到達 [^6]。 @@ -88,7 +88,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 * 一項在中型資料中心的研究發現,每月約有 12 次網路故障,其中一半斷開了單臺機器,一半斷開了整個機架 [^9]。 * 另一項研究測量了元件(如機架頂部交換機、匯聚交換機和負載均衡器)的故障率 [^10]。它發現,新增冗餘網路裝置並不能像你希望的那樣減少故障,因為它不能防範人為錯誤(例如,配置錯誤的交換機),這是停機的主要原因。 * 廣域光纖鏈路的中斷被歸咎於奶牛 [^11]、海狸 [^12] 和鯊魚 [^13](儘管由於海底電纜遮蔽更好,鯊魚咬傷已經變得更加罕見 [^14])。人類也有過錯,無論是由於意外配置錯誤 [^15]、拾荒 [^16] 還是破壞 [^17]。 -* 在不同的雲區域之間,已經觀察到高百分位數下長達幾 *分鐘* 的往返時間 [^18]。即使在單個數據中心內,在網路拓撲重新配置期間(由交換機軟體升級期間的問題觸發),也可能發生超過一分鐘的資料包延遲 [^19]。因此,我們必須假設訊息可能被任意延遲。 +* 在不同的雲區域之間,已經觀察到高百分位數下長達幾 **分鐘** 的往返時間 [^18]。即使在單個數據中心內,在網路拓撲重新配置期間(由交換機軟體升級期間的問題觸發),也可能發生超過一分鐘的資料包延遲 [^19]。因此,我們必須假設訊息可能被任意延遲。 * 有時通訊部分中斷,這取決於你在和誰交談:例如,A 和 B 可以通訊,B 和 C 可以通訊,但 A 和 C 不能 [^20] [^21]。其他令人驚訝的故障包括網路介面有時會丟棄所有入站資料包但成功傳送出站資料包 [^22]:僅僅因為網路鏈路在一個方向上工作並不能保證它在相反方向上也工作。 * 即使是短暫的網路中斷也可能產生比原始問題持續時間更長的影響 [^8] [^20] [^23]。 @@ -100,17 +100,17 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 -------- -即使網路故障在你的環境中很少見,故障 *可能* 發生的事實意味著你的軟體需要能夠處理它們。每當透過網路進行任何通訊時,它都可能失敗 —— 這是無法避免的。 +即使網路故障在你的環境中很少見,故障 **可能** 發生的事實意味著你的軟體需要能夠處理它們。每當透過網路進行任何通訊時,它都可能失敗 —— 這是無法避免的。 如果網路故障的錯誤處理沒有定義和測試,可能會發生任意糟糕的事情:例如,叢集可能會陷入死鎖並永久無法提供請求,即使網路恢復 [^24],或者它甚至可能刪除你的所有資料 [^25]。如果軟體處於意料之外的情況,它可能會做任意意外的事情。 -處理網路故障不一定意味著 *容忍* 它們:如果你的網路通常相當可靠,一個有效的方法可能是在網路出現問題時簡單地向用戶顯示錯誤訊息。但是,你確實需要知道你的軟體如何對網路問題做出反應,並確保系統可以從中恢復。故意觸發網路問題並測試系統的響應可能是有意義的(這被稱為 *故障注入*;見 ["故障注入"](#sec_fault_injection))。 +處理網路故障不一定意味著 **容忍** 它們:如果你的網路通常相當可靠,一個有效的方法可能是在網路出現問題時簡單地向用戶顯示錯誤訊息。但是,你確實需要知道你的軟體如何對網路問題做出反應,並確保系統可以從中恢復。故意觸發網路問題並測試系統的響應可能是有意義的(這被稱為 **故障注入**;見 ["故障注入"](#sec_fault_injection))。 ### 檢測故障 {#id307} 許多系統需要自動檢測故障節點。例如: -* 負載均衡器需要停止向已死亡的節點發送請求(即,將其 *從輪詢池中摘除*)。 +* 負載均衡器需要停止向已死亡的節點發送請求(即,將其 **從輪詢池中摘除**)。 * 在具有單主複製的分散式資料庫中,如果主節點失效,其中一個從節點需要被提升為新的主節點(見 ["處理節點中斷"](/tw/ch6#sec_replication_failover))。 不幸的是,網路的不確定性使得很難判斷節點是否正常工作。在某些特定情況下,你可能會得到一些明確告訴你某事不工作的反饋: @@ -132,9 +132,9 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 當節點被宣佈死亡時,其職責需要轉移到其他節點,這會給其他節點和網路帶來額外的負載。如果系統已經在高負載下掙扎,過早地宣佈節點死亡可能會使問題變得更糟。特別是,可能發生的情況是,節點實際上並沒有死亡,只是由於過載而響應緩慢;將其負載轉移到其他節點可能會導致級聯故障(在極端情況下,所有節點互相宣佈對方死亡,一切都停止工作 —— 見 ["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable))。 -想象一個虛構的系統,其網路保證資料包的最大延遲 —— 每個資料包要麼在某個時間 *d* 內交付,要麼丟失,但交付從不會超過 *d*。此外,假設你可以保證未失效的節點總是在某個時間 *r* 內處理請求。在這種情況下,你可以保證每個成功的請求在時間 2*d* + *r* 內收到響應 —— 如果你在該時間內沒有收到響應,你就知道網路或遠端節點不工作。如果這是真的,2*d* + *r* 將是一個合理的超時時間。 +想象一個虛構的系統,其網路保證資料包的最大延遲 —— 每個資料包要麼在某個時間 **d** 內交付,要麼丟失,但交付從不會超過 **d**。此外,假設你可以保證未失效的節點總是在某個時間 **r** 內處理請求。在這種情況下,你可以保證每個成功的請求在時間 2**d** + **r** 內收到響應 —— 如果你在該時間內沒有收到響應,你就知道網路或遠端節點不工作。如果這是真的,2**d** + **r** 將是一個合理的超時時間。 -不幸的是,我們使用的大多數系統都沒有這些保證:非同步網路具有 *無界延遲*(即,它們嘗試儘快交付資料包,但資料包到達所需的時間沒有上限),大多數伺服器實現無法保證它們可以在某個最大時間內處理請求(見 ["響應時間保證"](#sec_distributed_clocks_realtime))。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時很低,往返時間的瞬時峰值就足以使系統失去平衡。 +不幸的是,我們使用的大多數系統都沒有這些保證:非同步網路具有 **無界延遲**(即,它們嘗試儘快交付資料包,但資料包到達所需的時間沒有上限),大多數伺服器實現無法保證它們可以在某個最大時間內處理請求(見 ["響應時間保證"](#sec_distributed_clocks_realtime))。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時很低,往返時間的瞬時峰值就足以使系統失去平衡。 @@ -142,7 +142,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 開車時,道路網路上的行駛時間通常因交通擁堵而變化最大。同樣,計算機網路上資料包延遲的可變性最常是由於排隊 [^27]: -* 如果幾個不同的節點同時嘗試向同一目的地傳送資料包,網路交換機必須將它們排隊並逐個送入目標網路鏈路(如 [圖 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為 *網路擁塞*)。如果有太多的傳入資料以至於交換機佇列滿了,資料包將被丟棄,因此需要重新發送 —— 即使網路執行正常。 +* 如果幾個不同的節點同時嘗試向同一目的地傳送資料包,網路交換機必須將它們排隊並逐個送入目標網路鏈路(如 [圖 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的網路鏈路上,資料包可能需要等待一段時間才能獲得一個插槽(這稱為 **網路擁塞**)。如果有太多的傳入資料以至於交換機佇列滿了,資料包將被丟棄,因此需要重新發送 —— 即使網路執行正常。 * 當資料包到達目標機器時,如果所有 CPU 核心當前都很忙,來自網路的傳入請求會被作業系統排隊,直到應用程式準備處理它。根據機器上的負載,這可能需要任意長的時間 [^28]。 * 在虛擬化環境中,正在執行的作業系統經常會暫停幾十毫秒,而另一個虛擬機器使用 CPU 核心。在此期間,VM 無法消耗來自網路的任何資料,因此傳入資料由虛擬機器監視器排隊(緩衝)[^29],進一步增加了網路延遲的可變性。 * 如前所述,為了避免網路過載,TCP 限制傳送資料的速率。這意味著在資料甚至進入網路之前,傳送方就有額外的排隊。 @@ -165,11 +165,11 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 所有這些因素都導致了網路延遲的可變性。當系統接近其最大容量時,排隊延遲的範圍特別大:具有充足備用容量的系統可以輕鬆排空佇列,而在高度利用的系統中,長佇列可以很快建立起來。 -在公共雲和多租戶資料中心中,資源在許多客戶之間共享:網路鏈路和交換機,甚至每臺機器的網路介面和 CPU(在虛擬機器上執行時)都是共享的。處理大量資料可以使用網路鏈路的全部容量(*飽和* 它們)。由於你無法控制或瞭解其他客戶對共享資源的使用情況,如果你附近的某人(*吵鬧的鄰居*)正在使用大量資源,網路延遲可能會高度可變 [^30] [^31]。 +在公共雲和多租戶資料中心中,資源在許多客戶之間共享:網路鏈路和交換機,甚至每臺機器的網路介面和 CPU(在虛擬機器上執行時)都是共享的。處理大量資料可以使用網路鏈路的全部容量(**飽和** 它們)。由於你無法控制或瞭解其他客戶對共享資源的使用情況,如果你附近的某人(**吵鬧的鄰居**)正在使用大量資源,網路延遲可能會高度可變 [^30] [^31]。 在這種環境中,你只能透過實驗選擇超時:在較長時間內和許多機器上測量網路往返時間的分佈,以確定延遲的預期可變性。然後,考慮到你的應用程式的特徵,你可以在故障檢測延遲和過早超時風險之間確定適當的權衡。 -更好的是,系統可以持續測量響應時間及其可變性(*抖動*),並根據觀察到的響應時間分佈自動調整超時,而不是使用配置的常量超時。Phi 累積故障檢測器 [^32](例如在 Akka 和 Cassandra 中使用 [^33])就是這樣做的一種方法。TCP 重傳超時也以類似的方式工作 [^5]。 +更好的是,系統可以持續測量響應時間及其可變性(**抖動**),並根據觀察到的響應時間分佈自動調整超時,而不是使用配置的常量超時。Phi 累積故障檢測器 [^32](例如在 Akka 和 Cassandra 中使用 [^33])就是這樣做的一種方法。TCP 重傳超時也以類似的方式工作 [^5]。 ### 同步與非同步網路 {#sec_distributed_sync_networks} @@ -177,9 +177,9 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 要回答這個問題,比較資料中心網路與傳統的固定電話網路(非蜂窩、非 VoIP)很有趣,後者極其可靠:延遲的音訊幀和掉線非常罕見。電話通話需要持續的低端到端延遲和足夠的頻寬來傳輸你聲音的音訊樣本。在計算機網路中擁有類似的可靠性和可預測性不是很好嗎? -當你透過電話網路撥打電話時,它會建立一個 *電路*:在兩個呼叫者之間的整個路線上分配固定、有保證的頻寬量。該電路一直保持到通話結束 [^34]。例如,ISDN 網路以每秒 4,000 幀的固定速率執行。建立呼叫時,它在每幀內(在每個方向上)分配 16 位空間。因此,在通話期間,每一方都保證能夠每 250 微秒準確傳送 16 位音訊資料 [^35]。 +當你透過電話網路撥打電話時,它會建立一個 **電路**:在兩個呼叫者之間的整個路線上分配固定、有保證的頻寬量。該電路一直保持到通話結束 [^34]。例如,ISDN 網路以每秒 4,000 幀的固定速率執行。建立呼叫時,它在每幀內(在每個方向上)分配 16 位空間。因此,在通話期間,每一方都保證能夠每 250 微秒準確傳送 16 位音訊資料 [^35]。 -這種網路是 *同步的*:即使資料通過幾個路由器,它也不會遭受排隊,因為呼叫的 16 位空間已經在網路的下一跳中預留了。由於沒有排隊,網路的最大端到端延遲是固定的。我們稱之為 *有界延遲*。 +這種網路是 **同步的**:即使資料通過幾個路由器,它也不會遭受排隊,因為呼叫的 16 位空間已經在網路的下一跳中預留了。由於沒有排隊,網路的最大端到端延遲是固定的。我們稱之為 **有界延遲**。 #### 我們不能簡單地使網路延遲可預測嗎? {#can-we-not-simply-make-network-delays-predictable} @@ -187,11 +187,11 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 如果資料中心網路和網際網路是電路交換網路,那麼在建立電路時就可以建立有保證的最大往返時間。然而,它們不是:乙太網和 IP 是分組交換協議,會遭受排隊,因此在網路中有無界延遲。這些協議沒有電路的概念。 -為什麼資料中心網路和網際網路使用分組交換?答案是它們針對 *突發流量* 進行了最佳化。電路適合音訊或視訊通話,需要在通話期間傳輸相當恆定的每秒位數。另一方面,請求網頁、傳送電子郵件或傳輸檔案沒有任何特定的頻寬要求 —— 我們只希望它儘快完成。 +為什麼資料中心網路和網際網路使用分組交換?答案是它們針對 **突發流量** 進行了最佳化。電路適合音訊或視訊通話,需要在通話期間傳輸相當恆定的每秒位數。另一方面,請求網頁、傳送電子郵件或傳輸檔案沒有任何特定的頻寬要求 —— 我們只希望它儘快完成。 如果你想透過電路傳輸檔案,你必須猜測頻寬分配。如果你猜得太低,傳輸會不必要地慢,使網路容量未被使用。如果你猜得太高,電路無法建立(因為如果無法保證其頻寬分配,網路無法允許建立電路)。因此,使用電路進行突發資料傳輸會浪費網路容量並使傳輸不必要地緩慢。相比之下,TCP 動態調整資料傳輸速率以適應可用的網路容量。 -曾經有一些嘗試構建既支援電路交換又支援分組交換的混合網路。*非同步傳輸模式*(ATM)在 1980 年代是乙太網的競爭對手,但除了電話網路核心交換機外,它沒有獲得太多采用。InfiniBand 有一些相似之處 [^36]:它在鏈路層實現端到端流量控制,減少了網路中排隊的需要,儘管它仍然可能因鏈路擁塞而遭受延遲 [^37]。透過仔細使用 *服務質量*(QoS,資料包的優先順序和排程)和 *准入控制*(對傳送者的速率限制),可以在分組網路上類比電路交換,或提供統計上有界的延遲 [^27] [^34]。新的網路演算法,如低延遲、低損耗和可擴充套件吞吐量(L4S)試圖在客戶端和路由器級別緩解一些排隊和擁塞控制問題。Linux 的流量控制器(TC)也允許應用程式為 QoS 目的重新優先排序資料包。 +曾經有一些嘗試構建既支援電路交換又支援分組交換的混合網路。**非同步傳輸模式**(ATM)在 1980 年代是乙太網的競爭對手,但除了電話網路核心交換機外,它沒有獲得太多采用。InfiniBand 有一些相似之處 [^36]:它在鏈路層實現端到端流量控制,減少了網路中排隊的需要,儘管它仍然可能因鏈路擁塞而遭受延遲 [^37]。透過仔細使用 **服務質量**(QoS,資料包的優先順序和排程)和 **准入控制**(對傳送者的速率限制),可以在分組網路上類比電路交換,或提供統計上有界的延遲 [^27] [^34]。新的網路演算法,如低延遲、低損耗和可擴充套件吞吐量(L4S)試圖在客戶端和路由器級別緩解一些排隊和擁塞控制問題。Linux 的流量控制器(TC)也允許應用程式為 QoS 目的重新優先排序資料包。 -------- @@ -232,7 +232,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 7. 這個快取條目何時過期? 8. 日誌檔案中此錯誤訊息的時間戳是什麼? -示例 1-4 測量 *持續時間*(例如,傳送請求和接收響應之間的時間間隔),而示例 5-8 描述 *時間點*(在特定日期、特定時間發生的事件)。 +示例 1-4 測量 **持續時間**(例如,傳送請求和接收響應之間的時間間隔),而示例 5-8 描述 **時間點**(在特定日期、特定時間發生的事件)。 在分散式系統中,時間是一件棘手的事情,因為通訊不是瞬時的:訊息從一臺機器透過網路傳輸到另一臺機器需要時間。接收訊息的時間總是晚於傳送訊息的時間,但由於網路中的可變延遲,我們不知道晚了多少。當涉及多臺機器時,這個事實有時會使確定事情發生的順序變得困難。 @@ -240,11 +240,11 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 ### 單調時鐘與日曆時鐘 {#sec_distributed_monotonic_timeofday} -現代計算機至少有兩種不同型別的時鐘:*日曆時鐘* 和 *單調時鐘*。儘管它們都測量時間,但區分兩者很重要,因為它們服務於不同的目的。 +現代計算機至少有兩種不同型別的時鐘:**日曆時鐘** 和 **單調時鐘**。儘管它們都測量時間,但區分兩者很重要,因為它們服務於不同的目的。 #### 日曆時鐘 {#time-of-day-clocks} -日曆時鐘做你直觀期望時鐘做的事情:它根據某個日曆返回當前日期和時間(也稱為 *牆上時鐘時間*)。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *紀元* 以來的秒數(或毫秒數):根據格里高利曆,1970 年 1 月 1 日午夜 UTC,不計算閏秒。一些系統使用其他日期作為參考點。(儘管 Linux 時鐘被稱為 *即時*,但它與即時作業系統無關,如 ["響應時間保證"](#sec_distributed_clocks_realtime) 中所討論的。) +日曆時鐘做你直觀期望時鐘做的事情:它根據某個日曆返回當前日期和時間(也稱為 **牆上時鐘時間**)。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 **紀元** 以來的秒數(或毫秒數):根據格里高利曆,1970 年 1 月 1 日午夜 UTC,不計算閏秒。一些系統使用其他日期作為參考點。(儘管 Linux 時鐘被稱為 **即時**,但它與即時作業系統無關,如 ["響應時間保證"](#sec_distributed_clocks_realtime) 中所討論的。) 日曆時鐘通常與 NTP 同步,這意味著來自一臺機器的時間戳(理想情況下)與另一臺機器上的時間戳意思相同。然而,日曆時鐘也有各種奇怪之處,如下一節所述。特別是,如果本地時鐘遠遠超前於 NTP 伺服器,它可能會被強制重置並顯示跳回到以前的時間點。這些跳躍,以及閏秒引起的類似跳躍,使日曆時鐘不適合測量經過的時間 [^40]。 @@ -254,11 +254,11 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 單調時鐘適用於測量持續時間(時間間隔),例如超時或服務的響應時間:例如,Linux 上的 `clock_gettime(CLOCK_MONOTONIC)` 或 `clock_gettime(CLOCK_BOOTTIME)` [^42] 和 Java 中的 `System.nanoTime()` 是單調時鐘。這個名字來源於它們保證始終向前移動的事實(而日曆時鐘可能會在時間上向後跳躍)。 -你可以在某個時間點檢查單調時鐘的值,做一些事情,然後在稍後的時間再次檢查時鐘。兩個值之間的 *差值* 告訴你兩次檢查之間經過了多少時間 —— 更像秒錶而不是掛鐘。然而,時鐘的 *絕對* 值是沒有意義的:它可能是自計算機啟動以來的納秒數,或類似的任意值。特別是,比較來自兩臺不同計算機的單調時鐘值是沒有意義的,因為它們不代表同樣的東西。 +你可以在某個時間點檢查單調時鐘的值,做一些事情,然後在稍後的時間再次檢查時鐘。兩個值之間的 **差值** 告訴你兩次檢查之間經過了多少時間 —— 更像秒錶而不是掛鐘。然而,時鐘的 **絕對** 值是沒有意義的:它可能是自計算機啟動以來的納秒數,或類似的任意值。特別是,比較來自兩臺不同計算機的單調時鐘值是沒有意義的,因為它們不代表同樣的東西。 在具有多個 CPU 插槽的伺服器上,每個 CPU 可能有一個單獨的計時器,它不一定與其他 CPU 同步 [^43]。作業系統會補償任何差異,並嘗試嚮應用程式執行緒呈現時鐘的單調檢視,即使它們被排程到不同的 CPU 上。然而,明智的做法是對這種單調性保證持保留態度 [^44]。 -如果 NTP 檢測到計算機的本地石英晶體比 NTP 伺服器執行得更快或更慢,它可能會調整單調時鐘前進的頻率(這被稱為 *調整* 時鐘)。預設情況下,NTP 允許時鐘速率加速或減速高達 0.05%,但 NTP 不能導致單調時鐘向前或向後跳躍。單調時鐘的解析度通常相當好:在大多數系統上,它們可以測量微秒或更短的時間間隔。 +如果 NTP 檢測到計算機的本地石英晶體比 NTP 伺服器執行得更快或更慢,它可能會調整單調時鐘前進的頻率(這被稱為 **調整** 時鐘)。預設情況下,NTP 允許時鐘速率加速或減速高達 0.05%,但 NTP 不能導致單調時鐘向前或向後跳躍。單調時鐘的解析度通常相當好:在大多數系統上,它們可以測量微秒或更短的時間間隔。 在分散式系統中,使用單調時鐘測量經過的時間(例如,超時)通常是可以的,因為它不假設不同節點的時鐘之間有任何同步,並且對測量的輕微不準確不敏感。 @@ -266,12 +266,12 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 單調時鐘不需要同步,但日曆時鐘需要根據 NTP 伺服器或其他外部時間源設定才能有用。不幸的是,我們讓時鐘顯示正確時間的方法遠不如你希望的那樣可靠或準確 —— 硬體時鐘和 NTP 可能是反覆無常的野獸。僅舉幾個例子: -* 計算機中的石英時鐘不是很準確:它會 *漂移*(比應該的執行得更快或更慢)。時鐘漂移因機器的溫度而異。Google 假設其伺服器的時鐘漂移高達 200 ppm(百萬分之一)[^45],這相當於每 30 秒與伺服器重新同步的時鐘有 6 毫秒漂移,或每天重新同步一次的時鐘有 17 秒漂移。即使一切正常工作,這種漂移也限制了你可以達到的最佳精度。 +* 計算機中的石英時鐘不是很準確:它會 **漂移**(比應該的執行得更快或更慢)。時鐘漂移因機器的溫度而異。Google 假設其伺服器的時鐘漂移高達 200 ppm(百萬分之一)[^45],這相當於每 30 秒與伺服器重新同步的時鐘有 6 毫秒漂移,或每天重新同步一次的時鐘有 17 秒漂移。即使一切正常工作,這種漂移也限制了你可以達到的最佳精度。 * 如果計算機的時鐘與 NTP 伺服器相差太多,它可能會拒絕同步,或者本地時鐘將被強制重置 [^39]。任何在重置前後觀察時間的應用程式都可能看到時間倒退或突然向前跳躍。 * 如果節點意外地被防火牆與 NTP 伺服器隔離,配置錯誤可能會在一段時間內未被注意到,在此期間漂移可能會累積成不同節點時鐘之間的巨大差異。軼事證據表明,這在實踐中確實會發生。 * NTP 同步只能與網路延遲一樣好,因此當你在具有可變資料包延遲的擁塞網路上時,其準確性有限。一項實驗表明,透過網際網路同步時可以達到 35 毫秒的最小誤差 [^46],儘管網路延遲的偶爾峰值會導致大約一秒的誤差。根據配置,大的網路延遲可能導致 NTP 客戶端完全放棄。 * 一些 NTP 伺服器是錯誤的或配置錯誤的,報告的時間相差數小時 [^47] [^48]。NTP 客戶端透過查詢多個伺服器並忽略異常值來減輕此類錯誤。儘管如此,將系統的正確性押注在網際網路上陌生人告訴你的時間上還是有些令人擔憂的。 -* 閏秒導致一分鐘有 59 秒或 61 秒長,這會搞亂在設計時沒有考慮閏秒的系統中的時序假設 [^49]。閏秒已經導致許多大型系統崩潰的事實 [^40] [^50] 表明,關於時鐘的錯誤假設是多麼容易潛入系統。處理閏秒的最佳方法可能是讓 NTP 伺服器 "撒謊",透過在一天的過程中逐漸執行閏秒調整(這被稱為 *平滑*)[^51] [^52],儘管實際的 NTP 伺服器行為在實踐中有所不同 [^53]。從 2035 年起將不再使用閏秒,所以這個問題幸運地將會消失。 +* 閏秒導致一分鐘有 59 秒或 61 秒長,這會搞亂在設計時沒有考慮閏秒的系統中的時序假設 [^49]。閏秒已經導致許多大型系統崩潰的事實 [^40] [^50] 表明,關於時鐘的錯誤假設是多麼容易潛入系統。處理閏秒的最佳方法可能是讓 NTP 伺服器 "撒謊",透過在一天的過程中逐漸執行閏秒調整(這被稱為 **平滑**)[^51] [^52],儘管實際的 NTP 伺服器行為在實踐中有所不同 [^53]。從 2035 年起將不再使用閏秒,所以這個問題幸運地將會消失。 * 在虛擬機器中,硬體時鐘是虛擬化的,這為需要準確計時的應用程式帶來了額外的挑戰 [^54]。當 CPU 核心在虛擬機器之間共享時,每個 VM 在另一個 VM 執行時會暫停數十毫秒。從應用程式的角度來看,這種暫停表現為時鐘突然向前跳躍 [^29]。如果 VM 暫停幾秒鐘,時鐘可能會比實際時間落後幾秒鐘,但 NTP 可能會繼續報告時鐘幾乎完全同步 [^55]。 * 如果你在不完全控制的裝置上執行軟體(例如,移動或嵌入式裝置),你可能根本無法信任裝置的硬體時鐘。一些使用者故意將他們的硬體時鐘設定為不正確的日期和時間,例如在遊戲中作弊 [^56]。因此,時鐘可能被設定為遙遠的過去或未來的時間。 @@ -293,28 +293,28 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢 讓我們考慮一個特定的情況,其中依賴時鐘是誘人但危險的:跨多個節點的事件排序 [^64]。例如,如果兩個客戶端寫入分散式資料庫,誰先到達?哪個寫入是更新的? -[圖 9-3](#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](#fig_distributed_timestamps) 中,當寫入被複制到其他節點時,它會根據寫入起源節點上的日曆時鐘標記時間戳。此示例中的時鐘同步非常好:節點 1 和節點 3 之間的偏差小於 3 毫秒,這可能比你在實踐中可以期望的要好。 -由於遞增建立在 *x* = 1 的早期寫入之上,我們可能期望 *x* = 2 的寫入應該具有兩者中更大的時間戳。不幸的是,[圖 9-3](#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](#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](#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 毫秒(根據接收者的時鐘)到達 —— 因此看起來資料包在傳送之前就到達了,這是不可能的。 NTP 同步能否足夠準確以至於不會發生此類錯誤排序?可能不行,因為除了石英漂移等其他誤差源之外,NTP 的同步精度本身受到網路往返時間的限制。要保證正確的排序,你需要時鐘誤差顯著低於網路延遲,這是不可能的。 -所謂的 *邏輯時鐘* [^66],基於遞增計數器而不是振盪石英晶體,是排序事件的更安全替代方案(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。邏輯時鐘不測量一天中的時間或經過的秒數,只測量事件的相對順序(一個事件是在另一個事件之前還是之後發生)。相比之下,日曆時鐘和單調時鐘測量實際經過的時間,也稱為 *物理時鐘*。我們將在 ["ID 生成器和邏輯時鐘"](/tw/ch10#sec_consistency_logical) 中更詳細地研究邏輯時鐘。 +所謂的 **邏輯時鐘** [^66],基於遞增計數器而不是振盪石英晶體,是排序事件的更安全替代方案(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。邏輯時鐘不測量一天中的時間或經過的秒數,只測量事件的相對順序(一個事件是在另一個事件之前還是之後發生)。相比之下,日曆時鐘和單調時鐘測量實際經過的時間,也稱為 **物理時鐘**。我們將在 ["ID 生成器和邏輯時鐘"](/tw/ch10#sec_consistency_logical) 中更詳細地研究邏輯時鐘。 #### 帶置信區間的時鐘讀數 {#clock-readings-with-a-confidence-interval} @@ -326,11 +326,11 @@ NTP 同步能否足夠準確以至於不會發生此類錯誤排序?可能不 不幸的是,大多數系統不暴露這種不確定性:例如,當你呼叫 `clock_gettime()` 時,返回值不會告訴你時間戳的預期誤差,所以你不知道它的置信區間是五毫秒還是五年。 -有例外:Google Spanner 中的 *TrueTime* API [^45] 和亞馬遜的 ClockBound 明確報告本地時鐘的置信區間。當你詢問當前時間時,你會得到兩個值:`[earliest, latest]`,它們是 *最早可能* 和 *最晚可能* 的時間戳。基於其不確定性計算,時鐘知道實際當前時間在該區間內的某處。區間的寬度取決於多種因素,包括本地石英時鐘上次與更準確的時鐘源同步以來已經過去了多長時間。 +有例外:Google Spanner 中的 **TrueTime** API [^45] 和亞馬遜的 ClockBound 明確報告本地時鐘的置信區間。當你詢問當前時間時,你會得到兩個值:`[earliest, latest]`,它們是 **最早可能** 和 **最晚可能** 的時間戳。基於其不確定性計算,時鐘知道實際當前時間在該區間內的某處。區間的寬度取決於多種因素,包括本地石英時鐘上次與更準確的時鐘源同步以來已經過去了多長時間。 #### 用於全域性快照的同步時鐘 {#sec_distributed_spanner} -在 ["快照隔離和可重複讀"](/tw/ch8#sec_transactions_snapshot_isolation) 中,我們討論了 *多版本併發控制*(MVCC),這是資料庫中非常有用的功能,需要支援小型、快速的讀寫事務和大型、長時間執行的只讀事務(例如,用於備份或分析)。它允許只讀事務看到資料庫的 *快照*,即特定時間點的一致狀態,而不會鎖定和干擾讀寫事務。 +在 ["快照隔離和可重複讀"](/tw/ch8#sec_transactions_snapshot_isolation) 中,我們討論了 **多版本併發控制**(MVCC),這是資料庫中非常有用的功能,需要支援小型、快速的讀寫事務和大型、長時間執行的只讀事務(例如,用於備份或分析)。它允許只讀事務看到資料庫的 **快照**,即特定時間點的一致狀態,而不會鎖定和干擾讀寫事務。 通常,MVCC 需要單調遞增的事務 ID。如果寫入發生在快照之後(即,寫入的事務 ID 大於快照),則該寫入對快照事務不可見。在單節點資料庫上,簡單的計數器就足以生成事務 ID。 @@ -338,7 +338,7 @@ NTP 同步能否足夠準確以至於不會發生此類錯誤排序?可能不 我們能否使用同步日曆時鐘的時間戳作為事務 ID?如果我們能夠獲得足夠好的同步,它們將具有正確的屬性:較晚的事務具有更高的時間戳。當然,問題是時鐘精度的不確定性。 -Spanner 以這種方式跨資料中心實現快照隔離 [^68] [^69]。它使用 TrueTime API 報告的時鐘置信區間,並基於以下觀察:如果你有兩個置信區間,每個都由最早和最晚可能的時間戳組成(*A* = [*A最早*, *A最晚*] 和 *B* = [*B最早*, *B最晚*]),並且這兩個區間不重疊(即,*A最早* < *A最晚* < *B最早* < *B最晚*),那麼 B 肯定發生在 A 之後 —— 毫無疑問。只有當區間重疊時,我們才不確定 A 和 B 發生的順序。 +Spanner 以這種方式跨資料中心實現快照隔離 [^68] [^69]。它使用 TrueTime API 報告的時鐘置信區間,並基於以下觀察:如果你有兩個置信區間,每個都由最早和最晚可能的時間戳組成(**A** = [**A最早**, **A最晚**] 和 **B** = [**B最早**, **B最晚**]),並且這兩個區間不重疊(即,**A最早** < **A最晚** < **B最早** < **B最晚**),那麼 B 肯定發生在 A 之後 —— 毫無疑問。只有當區間重疊時,我們才不確定 A 和 B 發生的順序。 為了確保事務時間戳反映因果關係,Spanner 在提交讀寫事務之前故意等待置信區間的長度。透過這樣做,它確保任何可能讀取資料的事務都在足夠晚的時間,因此它們的置信區間不會重疊。為了使等待時間儘可能短,Spanner 需要使時鐘不確定性儘可能小;為此,Google 在每個資料中心部署 GPS 接收器或原子鐘,使時鐘能夠同步到大約 7 毫秒以內 [^45]。 @@ -348,7 +348,7 @@ Spanner 以這種方式跨資料中心實現快照隔離 [^68] [^69]。它使用 讓我們考慮分散式系統中危險使用時鐘的另一個例子。假設你有一個每個分片都有單個主節點的資料庫。只有主節點被允許接受寫入。節點如何知道它仍然是主節點(它沒有被其他節點宣佈死亡),並且它可以安全地接受寫入? -一種選擇是讓主節點從其他節點獲取 *租約*,這類似於帶有超時的鎖 [^73]。任何時候只有一個節點可以持有租約 —— 因此,當節點獲得租約時,它知道在租約到期之前的一段時間內它是主節點。為了保持主節點身份,節點必須在租約到期之前定期續訂租約。如果節點失效,它會停止續訂租約,因此另一個節點可以在租約到期時接管。 +一種選擇是讓主節點從其他節點獲取 **租約**,這類似於帶有超時的鎖 [^73]。任何時候只有一個節點可以持有租約 —— 因此,當節點獲得租約時,它知道在租約到期之前的一段時間內它是主節點。為了保持主節點身份,節點必須在租約到期之前定期續訂租約。如果節點失效,它會停止續訂租約,因此另一個節點可以在租約到期時接管。 你可以想象請求處理迴圈看起來像這樣: @@ -376,15 +376,15 @@ while (true) { 假設執行緒可能暫停這麼長時間是合理的嗎?不幸的是,是的。有各種原因可能導致這種情況發生: * 執行緒訪問共享資源(如鎖或佇列)時的爭用可能導致執行緒花費大量時間等待。轉移到具有更多 CPU 核心的機器可能會使此類問題變得更糟,並且爭用問題可能難以診斷 [^74]。 -* 許多程式語言執行時(如 Java 虛擬機器)有 *垃圾回收器*(GC),偶爾需要停止所有正在執行的執行緒。過去,這種 *"全域性暫停" GC 暫停* 有時會持續幾分鐘 [^75]!使用現代 GC 演算法,這不再是一個大問題,但 GC 暫停仍然可能很明顯(見 ["限制垃圾回收的影響"](#sec_distributed_gc_impact))。 -* 在虛擬化環境中,虛擬機器可以被 *掛起*(暫停所有程序的執行並將記憶體內容儲存到磁碟)和 *恢復*(恢復記憶體內容並繼續執行)。這種暫停可能發生在程序執行的任何時間,並且可能持續任意長的時間。這個功能有時用於虛擬機器從一臺主機到另一臺主機的 *即時遷移*,無需重啟,在這種情況下,暫停的長度取決於程序寫入記憶體的速率 [^76]。 +* 許多程式語言執行時(如 Java 虛擬機器)有 **垃圾回收器**(GC),偶爾需要停止所有正在執行的執行緒。過去,這種 **"全域性暫停" GC 暫停** 有時會持續幾分鐘 [^75]!使用現代 GC 演算法,這不再是一個大問題,但 GC 暫停仍然可能很明顯(見 ["限制垃圾回收的影響"](#sec_distributed_gc_impact))。 +* 在虛擬化環境中,虛擬機器可以被 **掛起**(暫停所有程序的執行並將記憶體內容儲存到磁碟)和 **恢復**(恢復記憶體內容並繼續執行)。這種暫停可能發生在程序執行的任何時間,並且可能持續任意長的時間。這個功能有時用於虛擬機器從一臺主機到另一臺主機的 **即時遷移**,無需重啟,在這種情況下,暫停的長度取決於程序寫入記憶體的速率 [^76]。 * 在筆記型電腦和手機等終端使用者裝置上,執行也可能被任意掛起和恢復,例如,當用戶合上筆記型電腦蓋時。 -* 當作業系統上下文切換到另一個執行緒時,或者當虛擬機器管理程式切換到不同的虛擬機器時(在虛擬機器中執行時),當前執行的執行緒可能在程式碼的任何任意點暫停。在虛擬機器的情況下,在其他虛擬機器中花費的 CPU 時間稱為 *竊取時間*。如果機器負載很重 —— 即,如果有長佇列的執行緒等待執行 —— 暫停的執行緒可能需要一些時間才能再次執行。 +* 當作業系統上下文切換到另一個執行緒時,或者當虛擬機器管理程式切換到不同的虛擬機器時(在虛擬機器中執行時),當前執行的執行緒可能在程式碼的任何任意點暫停。在虛擬機器的情況下,在其他虛擬機器中花費的 CPU 時間稱為 **竊取時間**。如果機器負載很重 —— 即,如果有長佇列的執行緒等待執行 —— 暫停的執行緒可能需要一些時間才能再次執行。 * 如果應用程式執行同步磁碟訪問,執行緒可能會暫停等待緩慢的磁碟 I/O 操作完成 [^77]。在許多語言中,磁碟訪問可能會令人驚訝地發生,即使程式碼沒有明確提到檔案訪問 —— 例如,Java 類載入器在首次使用時會延遲載入類檔案,這可能發生在程式執行的任何時間。I/O 暫停和 GC 暫停甚至可能共謀結合它們的延遲 [^78]。如果磁碟實際上是網路檔案系統或網路塊裝置(如亞馬遜的 EBS),I/O 延遲還會受到網路延遲可變性的影響 [^31]。 -* 如果作業系統配置為允許 *交換到磁碟*(*分頁*),簡單的記憶體訪問可能會導致頁面錯誤,需要從磁碟載入頁面到記憶體。執行緒在此緩慢的 I/O 操作進行時暫停。如果記憶體壓力很高,這可能反過來需要將不同的頁面交換到磁碟。在極端情況下,作業系統可能會花費大部分時間在記憶體中交換頁面進出,而實際完成的工作很少(這被稱為 *抖動*)。為了避免這個問題,伺服器機器上通常停用分頁(如果你寧願殺死程序以釋放記憶體而不是冒抖動的風險)。 +* 如果作業系統配置為允許 **交換到磁碟**(**分頁**),簡單的記憶體訪問可能會導致頁面錯誤,需要從磁碟載入頁面到記憶體。執行緒在此緩慢的 I/O 操作進行時暫停。如果記憶體壓力很高,這可能反過來需要將不同的頁面交換到磁碟。在極端情況下,作業系統可能會花費大部分時間在記憶體中交換頁面進出,而實際完成的工作很少(這被稱為 **抖動**)。為了避免這個問題,伺服器機器上通常停用分頁(如果你寧願殺死程序以釋放記憶體而不是冒抖動的風險)。 * Unix 程序可以透過向其傳送 `SIGSTOP` 訊號來暫停,例如透過在 shell 中按 Ctrl-Z。此訊號立即停止程序獲取更多 CPU 週期,直到使用 `SIGCONT` 恢復它,此時它從停止的地方繼續執行。即使你的環境通常不使用 `SIGSTOP`,它也可能被運維工程師意外發送。 -所有這些情況都可以在任何時候 *搶佔* 正在執行的執行緒,並在稍後的某個時間恢復它,而執行緒甚至沒有注意到。這個問題類似於在單臺機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為可能會發生任意的上下文切換和並行性。 +所有這些情況都可以在任何時候 **搶佔** 正在執行的執行緒,並在稍後的某個時間恢復它,而執行緒甚至沒有注意到。這個問題類似於在單臺機器上使多執行緒程式碼執行緒安全:你不能對時序做任何假設,因為可能會發生任意的上下文切換和並行性。 在單臺機器上編寫多執行緒程式碼時,我們有相當好的工具來使其執行緒安全:互斥鎖、訊號量、原子計數器、無鎖資料結構、阻塞佇列等。不幸的是,這些工具不能直接轉換到分散式系統,因為分散式系統沒有共享記憶體 —— 只有透過不可靠網路傳送的訊息。 @@ -392,9 +392,9 @@ while (true) { #### 響應時間保證 {#sec_distributed_clocks_realtime} -在許多程式語言和作業系統中,如所討論的,執行緒和程序可能會暫停無限長的時間。如果你足夠努力,這些暫停的原因 *可以* 被消除。 +在許多程式語言和作業系統中,如所討論的,執行緒和程序可能會暫停無限長的時間。如果你足夠努力,這些暫停的原因 **可以** 被消除。 -某些軟體在環境中執行,如果未能在指定時間內響應可能會造成嚴重損害:控制飛機、火箭、機器人、汽車和其他物理物件的計算機必須快速且可預測地響應其感測器輸入。在這些系統中,有一個指定的 *截止時間*,軟體必須在此之前響應;如果它沒有達到截止時間,可能會導致整個系統的故障。這些被稱為 *硬即時* 系統。 +某些軟體在環境中執行,如果未能在指定時間內響應可能會造成嚴重損害:控制飛機、火箭、機器人、汽車和其他物理物件的計算機必須快速且可預測地響應其感測器輸入。在這些系統中,有一個指定的 **截止時間**,軟體必須在此之前響應;如果它沒有達到截止時間,可能會導致整個系統的故障。這些被稱為 **硬即時** 系統。 -------- @@ -405,7 +405,7 @@ while (true) { 例如,如果你的汽車的車載感測器檢測到你當前正在經歷碰撞,你不希望安全氣囊的釋放因為安全氣囊釋放系統中不合時宜的 GC 暫停而延遲。 -在系統中提供即時保證需要軟體棧所有級別的支援:需要 *即時作業系統*(RTOS),它允許程序在指定的時間間隔內以有保證的 CPU 時間分配進行排程;庫函式必須記錄其最壞情況執行時間;動態記憶體分配可能受到限制或完全禁止(即時垃圾回收器存在,但應用程式仍必須確保它不會給 GC 太多工作);必須進行大量的測試和測量以確保滿足保證。 +在系統中提供即時保證需要軟體棧所有級別的支援:需要 **即時作業系統**(RTOS),它允許程序在指定的時間間隔內以有保證的 CPU 時間分配進行排程;庫函式必須記錄其最壞情況執行時間;動態記憶體分配可能受到限制或完全禁止(即時垃圾回收器存在,但應用程式仍必須確保它不會給 GC 太多工作);必須進行大量的測試和測量以確保滿足保證。 所有這些都需要大量的額外工作,並嚴重限制了可以使用的程式語言、庫和工具的範圍(因為大多數語言和工具不提供即時保證)。由於這些原因,開發即時系統非常昂貴,它們最常用於安全關鍵的嵌入式裝置。此外,"即時" 不同於 "高效能" —— 事實上,即時系統可能具有較低的吞吐量,因為它們必須優先考慮及時響應高於一切(另見 ["延遲和資源利用率"](#sidebar_distributed_latency_utilization))。 @@ -429,11 +429,11 @@ while (true) { 到目前為止,在本章中,我們已經探討了分散式系統與在單臺計算機上執行的程式的不同之處:沒有共享記憶體,只有透過不可靠的網路進行訊息傳遞,具有可變延遲,系統可能會遭受部分失效、不可靠的時鐘和處理暫停。 -如果你不習慣分散式系統,這些問題的後果會令人深感迷惑。網路中的節點不能 *確切地知道* 關於其他節點的任何事情 —— 它只能根據它接收(或未接收)的訊息進行猜測。節點只能透過與另一個節點交換訊息來了解它處於什麼狀態(它儲存了什麼資料,它是否正常執行等)。如果遠端節點沒有響應,就無法知道它處於什麼狀態,因為網路中的問題無法與節點的問題可靠地區分開來。 +如果你不習慣分散式系統,這些問題的後果會令人深感迷惑。網路中的節點不能 **確切地知道** 關於其他節點的任何事情 —— 它只能根據它接收(或未接收)的訊息進行猜測。節點只能透過與另一個節點交換訊息來了解它處於什麼狀態(它儲存了什麼資料,它是否正常執行等)。如果遠端節點沒有響應,就無法知道它處於什麼狀態,因為網路中的問題無法與節點的問題可靠地區分開來。 這些系統的討論接近哲學:在我們的系統中,我們知道什麼是真或假?如果感知和測量的機制不可靠,我們對這些知識有多確定 [^83]?軟體系統是否應該遵守我們對物理世界的期望法則,如因果關係? -幸運的是,我們不需要走到弄清生命意義的程度。在分散式系統中,我們可以陳述我們對行為(*系統模型*)的假設,並以這樣的方式設計實際系統,使其滿足這些假設。演算法可以被證明在某個系統模型內正確執行。這意味著即使底層系統模型提供的保證很少,也可以實現可靠的行為。 +幸運的是,我們不需要走到弄清生命意義的程度。在分散式系統中,我們可以陳述我們對行為(**系統模型**)的假設,並以這樣的方式設計實際系統,使其滿足這些假設。演算法可以被證明在某個系統模型內正確執行。這意味著即使底層系統模型提供的保證很少,也可以實現可靠的行為。 然而,儘管可以在不可靠的系統模型中使軟體表現良好,但這樣做並不簡單。在本章的其餘部分,我們將進一步探討分散式系統中知識和真相的概念,這將幫助我們思考我們可以做出的假設型別和我們可能希望提供的保證。在 [第 10 章](/tw/ch10#ch_consistency) 中,我們將繼續檢視在特定假設下提供特定保證的分散式演算法的一些示例。 @@ -445,11 +445,11 @@ while (true) { 作為第三種情況,想象一個節點暫停執行一分鐘。在此期間,沒有請求被處理,也沒有響應被傳送。其他節點等待、重試、變得不耐煩,最終宣佈該節點死亡並將其裝上靈車。最後,暫停結束,節點的執行緒繼續執行,就好像什麼都沒發生過。其他節點驚訝地看到據稱已死的節點突然從棺材裡抬起頭來,健康狀況良好,開始愉快地與旁觀者聊天。起初,暫停的節點甚至沒有意識到整整一分鐘已經過去,它被宣佈死亡 —— 從它的角度來看,自從它上次與其他節點交談以來,幾乎沒有時間過去。 -這些故事的寓意是,節點不一定能信任自己對情況的判斷。分散式系統不能完全依賴單個節點,因為節點可能隨時失效,可能使系統陷入困境並無法恢復。相反,許多分散式演算法依賴於 *仲裁*,即節點之間的投票(見 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition)):決策需要來自幾個節點的最少票數,以減少對任何一個特定節點的依賴。 +這些故事的寓意是,節點不一定能信任自己對情況的判斷。分散式系統不能完全依賴單個節點,因為節點可能隨時失效,可能使系統陷入困境並無法恢復。相反,許多分散式演算法依賴於 **仲裁**,即節點之間的投票(見 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition)):決策需要來自幾個節點的最少票數,以減少對任何一個特定節點的依賴。 這包括關於宣佈節點死亡的決定。如果節點的仲裁宣佈另一個節點死亡,那麼它必須被認為是死亡的,即使該節點仍然感覺自己非常活著。個別節點必須遵守仲裁決定並退出。 -最常見的是,仲裁是超過半數節點的絕對多數(儘管其他型別的仲裁也是可能的)。多數仲裁允許系統在少數節點故障時繼續工作(三個節點可以容忍一個故障節點;五個節點可以容忍兩個故障節點)。然而,它仍然是安全的,因為系統中只能有一個多數 —— 不能同時有兩個具有衝突決策的多數。當我們在 [第 10 章](/tw/ch10#ch_consistency) 討論 *共識演算法* 時,我們將更詳細地討論仲裁的使用。 +最常見的是,仲裁是超過半數節點的絕對多數(儘管其他型別的仲裁也是可能的)。多數仲裁允許系統在少數節點故障時繼續工作(三個節點可以容忍一個故障節點;五個節點可以容忍兩個故障節點)。然而,它仍然是安全的,因為系統中只能有一個多數 —— 不能同時有兩個具有衝突決策的多數。當我們在 [第 10 章](/tw/ch10#ch_consistency) 討論 **共識演算法** 時,我們將更詳細地討論仲裁的使用。 ### 分散式鎖和租約 {#sec_distributed_lock_fencing} @@ -477,16 +477,16 @@ while (true) { #### 隔離殭屍程序和延遲請求 {#sec_distributed_fencing_tokens} -術語 *殭屍* 有時用於描述尚未發現失去租約的前租約持有者,並且仍在充當當前租約持有者。由於我們不能完全排除殭屍,我們必須確保它們不能以腦裂的形式造成任何損害。這被稱為 *隔離* 殭屍。 +術語 **殭屍** 有時用於描述尚未發現失去租約的前租約持有者,並且仍在充當當前租約持有者。由於我們不能完全排除殭屍,我們必須確保它們不能以腦裂的形式造成任何損害。這被稱為 **隔離** 殭屍。 -一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM,甚至物理關閉機器 [^87]。這種方法被稱為 *對端節點爆頭*(STONITH)。不幸的是,它存在一些問題:它不能防範像 [圖 9-5](#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。 +一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM,甚至物理關閉機器 [^87]。這種方法被稱為 **對端節點爆頭**(STONITH)。不幸的是,它存在一些問題:它不能防範像 [圖 9-5](#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。 一個更強大的隔離解決方案,可以防範殭屍和延遲請求,如 [圖 9-6](#fig_distributed_fencing) 所示。 {{< figure src="/fig/ddia_0906.png" id="fig_distributed_fencing" caption="圖 9-6. 透過只允許按遞增隔離令牌順序寫入來使儲存訪問安全。" class="w-full my-4" >}} -假設每次鎖服務授予鎖或租約時,它還返回一個 *隔離令牌*,這是一個每次授予鎖時都會增加的數字(例如,由鎖服務遞增)。然後我們可以要求客戶端每次向儲存服務傳送寫請求時,都必須包含其當前的隔離令牌。 +假設每次鎖服務授予鎖或租約時,它還返回一個 **隔離令牌**,這是一個每次授予鎖時都會增加的數字(例如,由鎖服務遞增)。然後我們可以要求客戶端每次向儲存服務傳送寫請求時,都必須包含其當前的隔離令牌。 -------- @@ -499,7 +499,7 @@ while (true) { 如果 ZooKeeper 是你的鎖服務,你可以使用事務 ID `zxid` 或節點版本 `cversion` 作為隔離令牌 [^85]。使用 etcd,修訂號與租約 ID 一起起著類似的作用 [^89]。Hazelcast 中的 FencedLock API 明確生成隔離令牌 [^90]。 -這種機制要求儲存服務有某種方法來檢查寫入是否基於過時的令牌。或者,服務支援僅在物件自當前客戶端上次讀取以來未被另一個客戶端寫入時才成功的寫入就足夠了,類似於原子比較並設定(CAS)操作。例如,物件儲存服務支援這種檢查:Amazon S3 稱之為 *條件寫入*,Azure Blob Storage 稱之為 *條件標頭*,Google Cloud Storage 稱之為 *請求前提條件*。 +這種機制要求儲存服務有某種方法來檢查寫入是否基於過時的令牌。或者,服務支援僅在物件自當前客戶端上次讀取以來未被另一個客戶端寫入時才成功的寫入就足夠了,類似於原子比較並設定(CAS)操作。例如,物件儲存服務支援這種檢查:Amazon S3 稱之為 **條件寫入**,Azure Blob Storage 稱之為 **條件標頭**,Google Cloud Storage 稱之為 **請求前提條件**。 #### 多副本隔離 {#fencing-with-multiple-replicas} @@ -518,11 +518,11 @@ while (true) { ### 拜占庭故障 {#sec_distributed_byzantine} -隔離令牌可以檢測並阻止 *無意中* 出錯的節點(例如,因為它尚未發現其租約已過期)。然而,如果節點故意想要破壞系統的保證,它可以透過傳送帶有虛假隔離令牌的訊息輕鬆做到。 +隔離令牌可以檢測並阻止 **無意中** 出錯的節點(例如,因為它尚未發現其租約已過期)。然而,如果節點故意想要破壞系統的保證,它可以透過傳送帶有虛假隔離令牌的訊息輕鬆做到。 -在本書中,我們假設節點是不可靠但誠實的:它們可能很慢或從不響應(由於故障),它們的狀態可能已過時(由於 GC 暫停或網路延遲),但我們假設如果節點 *確實* 響應,它就是在說 "真話":據它所知,它正在按協議規則行事。 +在本書中,我們假設節點是不可靠但誠實的:它們可能很慢或從不響應(由於故障),它們的狀態可能已過時(由於 GC 暫停或網路延遲),但我們假設如果節點 **確實** 響應,它就是在說 "真話":據它所知,它正在按協議規則行事。 -如果節點可能 "撒謊"(傳送任意錯誤或損壞的響應)的風險存在,分散式系統問題會變得更加困難 —— 例如,它可能在同一次選舉中投出多個相互矛盾的票。這種行為被稱為 *拜占庭故障*,在這種不信任環境中達成共識的問題被稱為 *拜占庭將軍問題* [^94]。 +如果節點可能 "撒謊"(傳送任意錯誤或損壞的響應)的風險存在,分散式系統問題會變得更加困難 —— 例如,它可能在同一次選舉中投出多個相互矛盾的票。這種行為被稱為 **拜占庭故障**,在這種不信任環境中達成共識的問題被稱為 **拜占庭將軍問題** [^94]。 > [!TIP] 拜占庭將軍問題 > @@ -534,7 +534,7 @@ while (true) { -------- -如果即使某些節點發生故障並且不遵守協議,或者惡意攻擊者干擾網路,系統仍能繼續正確執行,則該系統是 *拜占庭容錯* 的。這種擔憂在某些特定情況下是相關的。例如: +如果即使某些節點發生故障並且不遵守協議,或者惡意攻擊者干擾網路,系統仍能繼續正確執行,則該系統是 **拜占庭容錯** 的。這種擔憂在某些特定情況下是相關的。例如: * 在航空航天環境中,計算機記憶體或 CPU 暫存器中的資料可能因輻射而損壞,導致它以任意不可預測的方式響應其他節點。由於系統故障的成本非常高昂(例如,飛機墜毀並殺宕機上所有人,或火箭與國際空間站相撞),飛行控制系統必須容忍拜占庭故障 [^98] [^99]。 * 在有多個參與方的系統中,一些參與者可能試圖欺騙或欺詐其他人。在這種情況下,節點簡單地信任另一個節點的訊息是不安全的,因為它們可能是惡意傳送的。例如,比特幣等加密貨幣和其他區塊鏈可以被認為是讓相互不信任的各方就交易是否發生達成一致的一種方式,而無需依賴中央權威 [^100]。 @@ -561,7 +561,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 許多演算法被設計來解決分散式系統問題 —— 例如,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中研究共識問題的解決方案。為了有用,這些演算法需要容忍我們在本章中討論的分散式系統的各種故障。 -演算法需要以不過度依賴於它們執行的硬體和軟體配置細節的方式編寫。這反過來又要求我們以某種方式形式化我們期望在系統中發生的故障型別。我們透過定義 *系統模型* 來做到這一點,這是一個描述演算法可能假設什麼事情的抽象。 +演算法需要以不過度依賴於它們執行的硬體和軟體配置細節的方式編寫。這反過來又要求我們以某種方式形式化我們期望在系統中發生的故障型別。我們透過定義 **系統模型** 來做到這一點,這是一個描述演算法可能假設什麼事情的抽象。 關於時序假設,三種系統模型常用: @@ -569,7 +569,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 : 同步模型假設有界的網路延遲、有界的程序暫停和有界的時鐘誤差。這並不意味著精確同步的時鐘或零網路延遲;它只是意味著你知道網路延遲、暫停和時鐘漂移永遠不會超過某個固定的上限 [^108]。同步模型不是大多數實際系統的現實模型,因為(如本章所討論的)無界延遲和暫停確實會發生。 部分同步模型 -: 部分同步意味著系統 *大部分時間* 表現得像同步系統,但有時會超過網路延遲、程序暫停和時鐘漂移的界限 [^108]。這是許多系統的現實模型:大部分時間,網路和程序表現相當良好 —— 否則我們永遠無法完成任何事情 —— 但我們必須考慮到任何時序假設偶爾可能會被打破的事實。發生這種情況時,網路延遲、暫停和時鐘誤差可能會變得任意大。 +: 部分同步意味著系統 **大部分時間** 表現得像同步系統,但有時會超過網路延遲、程序暫停和時鐘漂移的界限 [^108]。這是許多系統的現實模型:大部分時間,網路和程序表現相當良好 —— 否則我們永遠無法完成任何事情 —— 但我們必須考慮到任何時序假設偶爾可能會被打破的事實。發生這種情況時,網路延遲、暫停和時鐘誤差可能會變得任意大。 非同步模型 : 在這個模型中,演算法不允許做出任何時序假設 —— 事實上,它甚至沒有時鐘(因此它不能使用超時)。一些演算法可以為非同步模型設計,但它非常有限。 @@ -577,13 +577,13 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 此外,除了時序問題,我們還必須考慮節點故障。節點的一些常見系統模型是: 崩潰停止故障 -: 在 *崩潰停止*(或 *故障停止*)模型中,演算法可以假設節點只能以一種方式失效,即崩潰 [^109]。這意味著節點可能在任何時刻突然停止響應,此後該節點永遠消失 —— 它永遠不會回來。 +: 在 **崩潰停止**(或 **故障停止**)模型中,演算法可以假設節點只能以一種方式失效,即崩潰 [^109]。這意味著節點可能在任何時刻突然停止響應,此後該節點永遠消失 —— 它永遠不會回來。 崩潰恢復故障 : 我們假設節點可能在任何時刻崩潰,並且可能在某個未知時間後再次開始響應。在崩潰恢復模型中,假設節點具有跨崩潰保留的穩定儲存(即非易失性磁碟儲存),而記憶體中的狀態假設丟失。 效能下降和部分功能 -: 除了崩潰和重啟之外,節點可能變慢:它們可能仍然能夠響應健康檢查請求,但速度太慢而無法完成任何實際工作。例如,千兆網路介面可能由於驅動程式錯誤突然降至 1 Kb/s 吞吐量 [^110];處於記憶體壓力下的程序可能會花費大部分時間執行垃圾回收 [^111];磨損的 SSD 可能具有不穩定的效能;硬體可能受到高溫、鬆動的聯結器、機械振動、電源問題、韌體錯誤等的影響 [^112]。這種情況被稱為 *跛行節點*、*灰色故障* 或 *慢速故障* [^113],它可能比干淨失效的節點更難處理。一個相關的問題是當程序停止執行它應該做的某些事情,而其他方面繼續工作時,例如因為後臺執行緒崩潰或死鎖 [^114]。 +: 除了崩潰和重啟之外,節點可能變慢:它們可能仍然能夠響應健康檢查請求,但速度太慢而無法完成任何實際工作。例如,千兆網路介面可能由於驅動程式錯誤突然降至 1 Kb/s 吞吐量 [^110];處於記憶體壓力下的程序可能會花費大部分時間執行垃圾回收 [^111];磨損的 SSD 可能具有不穩定的效能;硬體可能受到高溫、鬆動的聯結器、機械振動、電源問題、韌體錯誤等的影響 [^112]。這種情況被稱為 **跛行節點**、**灰色故障** 或 **慢速故障** [^113],它可能比干淨失效的節點更難處理。一個相關的問題是當程序停止執行它應該做的某些事情,而其他方面繼續工作時,例如因為後臺執行緒崩潰或死鎖 [^114]。 拜占庭(任意)故障 : 節點可能做任何事情,包括試圖欺騙和欺騙其他節點,如上一節所述。 @@ -592,7 +592,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 #### 定義演算法的正確性 {#defining-the-correctness-of-an-algorithm} -為了定義演算法 *正確* 的含義,我們可以描述它的 *屬性*。例如,排序演算法的輸出具有這樣的屬性:對於輸出列表的任何兩個不同元素,左邊的元素小於右邊的元素。這只是定義列表排序含義的正式方式。 +為了定義演算法 **正確** 的含義,我們可以描述它的 **屬性**。例如,排序演算法的輸出具有這樣的屬性:對於輸出列表的任何兩個不同元素,左邊的元素小於右邊的元素。這只是定義列表排序含義的正式方式。 同樣,我們可以寫下我們希望分散式演算法具有的屬性,以定義正確的含義。例如,如果我們為鎖生成隔離令牌(見 ["隔離殭屍程序和延遲請求"](#sec_distributed_fencing_tokens)),我們可能要求演算法具有以下屬性: @@ -600,7 +600,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 : 沒有兩個隔離令牌請求返回相同的值。 單調序列 -: 如果請求 *x* 返回令牌 *t**x*,請求 *y* 返回令牌 *t**y*,並且 *x* 在 *y* 開始之前完成,則 *t**x* < *t**y*。 +: 如果請求 **x** 返回令牌 **t**x**,請求 **y** 返回令牌 **t**y**,並且 **x** 在 **y** 開始之前完成,則 **t**x** < **t**y**。 可用性 : 請求隔離令牌且不崩潰的節點最終會收到響應。 @@ -609,16 +609,16 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 #### 安全性與活性 {#sec_distributed_safety_liveness} -為了澄清情況,值得區分兩種不同型別的屬性:*安全性* 和 *活性* 屬性。在剛才給出的例子中,*唯一性* 和 *單調序列* 是安全屬性,但 *可用性* 是活性屬性。 +為了澄清情況,值得區分兩種不同型別的屬性:**安全性** 和 **活性** 屬性。在剛才給出的例子中,**唯一性** 和 **單調序列** 是安全屬性,但 **可用性** 是活性屬性。 -什麼區分這兩種屬性?一個跡象是活性屬性通常在其定義中包含 "最終" 一詞。(是的,你猜對了 —— *最終一致性* 是一個活性屬性 [^115]。) +什麼區分這兩種屬性?一個跡象是活性屬性通常在其定義中包含 "最終" 一詞。(是的,你猜對了 —— **最終一致性** 是一個活性屬性 [^115]。) -安全性通常被非正式地定義為 *沒有壞事發生*,活性被定義為 *好事最終會發生*。然而,最好不要過多地解讀這些非正式定義,因為 "好" 和 "壞" 是價值判斷,不能很好地應用於演算法。安全性和活性的實際定義更精確 [^116]: +安全性通常被非正式地定義為 **沒有壞事發生**,活性被定義為 **好事最終會發生**。然而,最好不要過多地解讀這些非正式定義,因為 "好" 和 "壞" 是價值判斷,不能很好地應用於演算法。安全性和活性的實際定義更精確 [^116]: * 如果違反了安全屬性,我們可以指出它被破壞的特定時間點(例如,如果違反了唯一性屬性,我們可以識別返回重複隔離令牌的特定操作)。在違反安全屬性之後,違規無法撤消 —— 損害已經造成。 * 活性屬性以相反的方式工作:它可能在某個時間點不成立(例如,節點可能已傳送請求但尚未收到響應),但總有希望它將來可能得到滿足(即透過接收響應)。 -區分安全性和活性屬性的一個優點是它有助於我們處理困難的系統模型。對於分散式演算法,通常要求安全屬性在系統模型的所有可能情況下 *始終* 成立 [^108]。也就是說,即使所有節點崩潰,或整個網路失效,演算法也必須確保它不會返回錯誤的結果(即,安全屬性保持滿足)。 +區分安全性和活性屬性的一個優點是它有助於我們處理困難的系統模型。對於分散式演算法,通常要求安全屬性在系統模型的所有可能情況下 **始終** 成立 [^108]。也就是說,即使所有節點崩潰,或整個網路失效,演算法也必須確保它不會返回錯誤的結果(即,安全屬性保持滿足)。 然而,對於活性屬性,我們可以做出警告:例如,我們可以說請求只有在大多數節點沒有崩潰時才需要收到響應,並且只有在網路最終從中斷中恢復時才需要響應。部分同步模型的定義要求系統最終返回到同步狀態 —— 也就是說,任何網路中斷期只持續有限的時間,然後被修復。 @@ -638,13 +638,13 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 我們如何知道演算法滿足所需的屬性?由於併發性、部分失效和網路延遲,存在大量潛在狀態。我們需要保證屬性在每個可能的狀態下都成立,並確保我們沒有忘記任何邊界情況。 -一種方法是透過數學描述演算法來形式驗證它,並使用證明技術來表明它在系統模型允許的所有情況下都滿足所需的屬性。證明演算法正確並不意味著它在真實系統上的 *實現* 必然總是正確執行。但這是一個非常好的第一步,因為理論分析可以發現演算法中的問題,這些問題可能在真實系統中長時間隱藏,並且只有當你的假設(例如,關於時序)由於不尋常的情況而失敗時才會咬你一口。 +一種方法是透過數學描述演算法來形式驗證它,並使用證明技術來表明它在系統模型允許的所有情況下都滿足所需的屬性。證明演算法正確並不意味著它在真實系統上的 **實現** 必然總是正確執行。但這是一個非常好的第一步,因為理論分析可以發現演算法中的問題,這些問題可能在真實系統中長時間隱藏,並且只有當你的假設(例如,關於時序)由於不尋常的情況而失敗時才會咬你一口。 將理論分析與經驗測試相結合以驗證實現按預期執行是明智的。基於屬性的測試、模糊測試和確定性模擬測試(DST)等技術使用隨機化來在各種情況下測試系統。亞馬遜網路服務等公司已成功地在其許多產品上使用了這些技術的組合 [^120] [^121]。 #### 模型檢查與規範語言 {#model-checking-and-specification-languages} -*模型檢查器* 是幫助驗證演算法或系統按預期執行的工具。演算法規範是用專門構建的語言編寫的,如 TLA+、Gallina 或 FizzBee。這些語言使得更容易專注於演算法的行為,而不必擔心程式碼實現細節。然後,模型檢查器使用這些模型透過系統地嘗試所有可能發生的事情來驗證不變數在演算法的所有狀態中都成立。 +**模型檢查器** 是幫助驗證演算法或系統按預期執行的工具。演算法規範是用專門構建的語言編寫的,如 TLA+、Gallina 或 FizzBee。這些語言使得更容易專注於演算法的行為,而不必擔心程式碼實現細節。然後,模型檢查器使用這些模型透過系統地嘗試所有可能發生的事情來驗證不變數在演算法的所有狀態中都成立。 模型檢查實際上不能證明演算法的不變數對每個可能的狀態都成立,因為大多數現實世界的演算法都有無限的狀態空間。對所有狀態的真正驗證需要形式證明,這是可以做到的,但通常比執行模型檢查器更困難。相反,模型檢查器鼓勵你將演算法的模型減少到可以完全驗證的近似值,或者將執行限制到某個上限(例如,透過設定可以傳送的最大訊息數)。任何只在更長執行時發生的錯誤將不會被發現。 @@ -656,7 +656,7 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意 許多錯誤是在機器和網路故障發生時觸發的。故障注入是一種有效(有時令人恐懼)的技術,用於驗證系統的實現在出錯時是否按預期工作。這個想法很簡單:將故障注入到正在執行的系統環境中,看看它如何表現。故障可以是網路故障、機器崩潰、磁碟損壞、暫停的程序 —— 你能想象到的計算機出錯的任何事情。 -故障注入測試通常在與系統將執行的生產環境非常相似的環境中執行。有些甚至直接將故障注入到他們的生產環境中。Netflix 透過他們的 Chaos Monkey 工具推廣了這種方法 [^128]。生產故障注入通常被稱為 *混沌工程*,我們在 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability) 中討論過。 +故障注入測試通常在與系統將執行的生產環境非常相似的環境中執行。有些甚至直接將故障注入到他們的生產環境中。Netflix 透過他們的 Chaos Monkey 工具推廣了這種方法 [^128]。生產故障注入通常被稱為 **混沌工程**,我們在 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability) 中討論過。 要執行故障注入測試,首先部署被測系統以及故障注入協調器和指令碼。協調器負責決定執行什麼故障以及何時執行它們。本地或遠端指令碼負責將故障注入到單個節點或程序中。注入指令碼使用許多不同的工具來觸發故障。可以使用 Linux 的 `kill` 命令暫停或殺死 Linux 程序,可以使用 `umount` 解除安裝磁碟,可以透過防火牆設定中斷網路連線。你可以在注入故障期間和之後檢查系統行為,以確保事情按預期工作。 @@ -689,7 +689,7 @@ DST 提供了超越可重放性的幾個優勢。Antithesis 等工具試圖透 * 事件溯源的一個關鍵優勢(見 ["事件溯源和 CQRS"](/tw/ch3#sec_datamodels_events))是你可以確定性地重放事件日誌以重建派生的物化檢視。 * 工作流引擎(見 ["持久執行和工作流"](/tw/ch5#sec_encoding_dataflow_workflows))依賴於工作流定義是確定性的,以提供持久執行語義。 -* *狀態機複製*,我們將在 ["使用共享日誌"](/tw/ch10#sec_consistency_smr) 中討論,透過在每個副本上獨立執行相同的確定性事務序列來複制資料。我們已經看到了這個想法的兩個變體:基於語句的複製(見 ["複製日誌的實現"](/tw/ch6#sec_replication_implementation))和使用儲存過程的序列事務執行(見 ["儲存過程的利弊"](/tw/ch8#sec_transactions_stored_proc_tradeoffs))。 +* **狀態機複製**,我們將在 ["使用共享日誌"](/tw/ch10#sec_consistency_smr) 中討論,透過在每個副本上獨立執行相同的確定性事務序列來複制資料。我們已經看到了這個想法的兩個變體:基於語句的複製(見 ["複製日誌的實現"](/tw/ch6#sec_replication_implementation))和使用儲存過程的序列事務執行(見 ["儲存過程的利弊"](/tw/ch8#sec_transactions_stored_proc_tradeoffs))。 然而,使程式碼完全確定性需要小心。即使你已經刪除了所有併發性並用確定性模擬替換了 I/O、網路通訊、時鐘和隨機數生成器,非確定性元素可能仍然存在。例如,在某些程式語言中,迭代雜湊表元素的順序可能是非確定性的。是否遇到資源限制(記憶體分配失敗、堆疊溢位)也是非確定性的。 @@ -701,9 +701,9 @@ DST 提供了超越可重放性的幾個優勢。Antithesis 等工具試圖透 * 節點的時鐘可能與其他節點嚴重不同步(儘管你盡最大努力設定了 NTP),它可能會突然向前或向後跳躍,而依賴它是危險的,因為你很可能沒有一個好的時鐘置信區間度量。 * 程序可能在其執行的任何時刻暫停相當長的時間,被其他節點宣告死亡,然後再次恢復活動而沒有意識到它曾暫停。 -這種 *部分失效* 可能發生的事實是分散式系統的決定性特徵。每當軟體嘗試做任何涉及其他節點的事情時,都有可能偶爾失敗、隨機變慢或根本沒有響應(並最終超時)。在分散式系統中,我們嘗試將對部分失效的容忍構建到軟體中,這樣即使某些組成部分出現故障,整個系統也可以繼續執行。 +這種 **部分失效** 可能發生的事實是分散式系統的決定性特徵。每當軟體嘗試做任何涉及其他節點的事情時,都有可能偶爾失敗、隨機變慢或根本沒有響應(並最終超時)。在分散式系統中,我們嘗試將對部分失效的容忍構建到軟體中,這樣即使某些組成部分出現故障,整個系統也可以繼續執行。 -要容忍故障,第一步是 *檢測* 它們,但即使這樣也很困難。大多數系統沒有準確的機制來檢測節點是否已失敗,因此大多數分散式演算法依賴超時來確定遠端節點是否仍然可用。然而,超時無法區分網路和節點故障,可變的網路延遲有時會導致節點被錯誤地懷疑崩潰。處理跛行節點(limping nodes)更加困難,這些節點正在響應但速度太慢而無法做任何有用的事情。 +要容忍故障,第一步是 **檢測** 它們,但即使這樣也很困難。大多數系統沒有準確的機制來檢測節點是否已失敗,因此大多數分散式演算法依賴超時來確定遠端節點是否仍然可用。然而,超時無法區分網路和節點故障,可變的網路延遲有時會導致節點被錯誤地懷疑崩潰。處理跛行節點(limping nodes)更加困難,這些節點正在響應但速度太慢而無法做任何有用的事情。 一旦檢測到故障,讓系統容忍它也不容易:沒有全域性變數、沒有共享記憶體、沒有公共知識或機器之間任何其他型別的共享狀態 [^83]。節點甚至無法就現在是什麼時間達成一致,更不用說任何更深刻的事情了。資訊從一個節點流向另一個節點的唯一方式是透過不可靠的網路傳送。單個節點無法安全地做出重大決策,因此我們需要協議來徵求其他節點的幫助並嘗試獲得法定人數的同意。 diff --git a/content/zh/ch10.md b/content/zh/ch10.md index 690fd1c..1ff32df 100644 --- a/content/zh/ch10.md +++ b/content/zh/ch10.md @@ -14,7 +14,7 @@ breadcrumbs: false 正如在 [第九章](/ch9) 中讨论的,分布式系统中会出现许多问题。如果我们希望服务在出现这些问题时仍能正确工作,就需要找到容错的方法。 -我们拥有的最佳容错工具之一是 *复制*。然而,正如我们在 [第六章](/ch6) 中看到的,在多个副本上拥有多份数据副本会带来不一致的风险。读取可能由一个非最新的副本处理,从而产生过时的结果。如果多个副本可以接受写入,我们必须处理在不同副本上并发写入的值之间的冲突。从高层次来看,处理这些问题有两种相互竞争的理念: +我们拥有的最佳容错工具之一是 **复制**。然而,正如我们在 [第六章](/ch6) 中看到的,在多个副本上拥有多份数据副本会带来不一致的风险。读取可能由一个非最新的副本处理,从而产生过时的结果。如果多个副本可以接受写入,我们必须处理在不同副本上并发写入的值之间的冲突。从高层次来看,处理这些问题有两种相互竞争的理念: 最终一致性 : 在这种理念中,系统被复制这一事实对应用程序是可见的,作为应用程序开发者,你需要处理可能出现的不一致和冲突。这种方法通常用于多主复制(见 ["多主复制"](/ch6#sec_replication_multi_leader))和无主复制(见 ["无主复制"](/ch6#sec_replication_leaderless))的系统中。 @@ -26,9 +26,9 @@ breadcrumbs: false 在本章中,我们将深入探讨强一致性方法,关注三个领域: -1. 一个挑战是"强一致性"相当模糊,因此我们将制定一个更精确的定义,明确我们想要实现什么:*线性一致性*。 +1. 一个挑战是"强一致性"相当模糊,因此我们将制定一个更精确的定义,明确我们想要实现什么:**线性一致性**。 2. 我们将研究生成 ID 和时间戳的问题。这可能听起来与一致性无关,但实际上密切相关。 -3. 我们将探讨分布式系统如何在保持容错的同时实现线性一致性;答案是 *共识* 算法。 +3. 我们将探讨分布式系统如何在保持容错的同时实现线性一致性;答案是 **共识** 算法。 在此过程中,我们将看到分布式系统中什么是可能的,什么是不可能的,存在一些基本限制。 @@ -42,19 +42,19 @@ breadcrumbs: false 如果你希望复制的数据库尽可能简单易用,你应该让它表现得就像根本没有复制一样。然后用户就不必担心复制延迟、冲突和其他不一致性。这将给我们带来容错的优势,但不会因为必须考虑多个副本而带来复杂性。 -这就是 *线性一致性* [^1] 背后的想法(也称为 *原子一致性* [^2]、*强一致性*、*即时一致性* 或 *外部一致性* [^3])。线性一致性的确切定义相当微妙,我们将在本节的其余部分探讨它。但基本思想是让系统看起来好像只有一份数据副本,并且对它的所有操作都是原子的。有了这个保证,即使实际上可能有多个副本,应用程序也不需要担心它们。 +这就是 **线性一致性** [^1] 背后的想法(也称为 **原子一致性** [^2]、**强一致性**、**即时一致性** 或 **外部一致性** [^3])。线性一致性的确切定义相当微妙,我们将在本节的其余部分探讨它。但基本思想是让系统看起来好像只有一份数据副本,并且对它的所有操作都是原子的。有了这个保证,即使实际上可能有多个副本,应用程序也不需要担心它们。 -在线性一致系统中,一旦一个客户端成功完成写入,所有从数据库读取的客户端都必须能够看到刚刚写入的值。维护单一数据副本的假象,意味着要保证读取到的是最新值,而不是来自过时的缓存或副本。换句话说,线性一致性是一种 *新鲜度保证*。为了阐明这个想法,让我们看一个非线性一致系统的例子。 +在线性一致系统中,一旦一个客户端成功完成写入,所有从数据库读取的客户端都必须能够看到刚刚写入的值。维护单一数据副本的假象,意味着要保证读取到的是最新值,而不是来自过时的缓存或副本。换句话说,线性一致性是一种 **新鲜度保证**。为了阐明这个想法,让我们看一个非线性一致系统的例子。 {{< 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](#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 的一样新。他的查询返回过时结果这一事实违反了线性一致性。 +如果 Aaliyah 和 Bryce 同时点击刷新,他们得到两个不同的查询结果就不会那么令人惊讶了,因为他们不知道他们各自的请求在服务器上被处理的确切时间。然而,Bryce 知道他是在听到 Aaliyah 宣布最终比分 **之后** 点击刷新按钮(发起查询)的,因此他期望他的查询结果至少与 Aaliyah 的一样新。他的查询返回过时结果这一事实违反了线性一致性。 ### 什么使系统具有线性一致性? {#sec_consistency_lin_definition} -为了更好地理解线性一致性,让我们看一些更多的例子。[图 10-2](#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" >}} @@ -63,14 +63,14 @@ breadcrumbs: false 在这个例子中,寄存器有两种类型的操作: -* *read*(*x*) ⇒ *v* 表示客户端请求读取寄存器 *x* 的值,数据库返回值 *v*。 -* *write*(*x*, *v*) ⇒ *r* 表示客户端请求将寄存器 *x* 设置为值 *v*,数据库返回响应 *r*(可能是 *ok* 或 *error*)。 +* **read**(**x**) ⇒ **v** 表示客户端请求读取寄存器 **x** 的值,数据库返回值 **v**。 +* **write**(**x**, **v**) ⇒ **r** 表示客户端请求将寄存器 **x** 设置为值 **v**,数据库返回响应 **r**(可能是 **ok** 或 **error**)。 -在 [图 10-2](#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,因为读取必须在写入之后被处理。 -* 与写入操作在时间上重叠的任何读取操作可能返回 0 或 1,因为我们不知道在读取操作被处理时写入是否已经生效。这些操作与写入是 *并发* 的。 +* 与写入操作在时间上重叠的任何读取操作可能返回 0 或 1,因为我们不知道在读取操作被处理时写入是否已经生效。这些操作与写入是 **并发** 的。 然而,这还不足以完全描述线性一致性:如果与写入并发的读取可以返回旧值或新值,那么读者可能会在写入进行时多次看到值在旧值和新值之间来回翻转。这不是我们对模拟"单一数据副本"的系统所期望的。 @@ -79,13 +79,13 @@ breadcrumbs: false {{< 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,所有后续读取也必须返回新值,即使写入操作尚未完成。 +在线性一致系统中,我们想象必须有某个时间点(在写入操作的开始和结束之间),**x** 的值从 0 原子地翻转到 1。因此,如果一个客户端的读取返回新值 1,所有后续读取也必须返回新值,即使写入操作尚未完成。 这种时序依赖关系在 [图 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](#fig_consistency_linearizability_3) 中显示的更复杂的例子。在这个例子中,除了 *read* 和 *write* 之外,我们添加了第三种操作类型: +我们可以进一步细化这个时序图,以可视化每个操作在某个时间点原子地生效 [^5],就像 [图 10-4](#fig_consistency_linearizability_3) 中显示的更复杂的例子。在这个例子中,除了 **read** 和 **write** 之外,我们添加了第三种操作类型: -* *cas*(*x*, *v*old, *v*new) ⇒ *r* 表示客户端请求一个原子 *比较并设置* 操作(见 ["条件写入(比较并设置)"](/ch8#sec_transactions_compare_and_set))。如果寄存器 *x* 的当前值等于 *v*old,它应该原子地设置为 *v*new。如果 *x* 的值与 *v*old 不同,则操作应该保持寄存器不变并返回错误。*r* 是数据库的响应(*ok* 或 *error*)。 +* **cas**(**x**, **v**old, **v**new) ⇒ **r** 表示客户端请求一个原子 **比较并设置** 操作(见 ["条件写入(比较并设置)"](/ch8#sec_transactions_compare_and_set))。如果寄存器 **x** 的当前值等于 **v**old,它应该原子地设置为 **v**new。如果 **x** 的值与 **v**old 不同,则操作应该保持寄存器不变并返回错误。**r** 是数据库的响应(**ok** 或 **error**)。 [图 10-4](#fig_consistency_linearizability_3) 中的每个操作都用一条垂直线(在每个操作的条形内)标记,表示我们认为操作执行的时间。这些标记按顺序连接起来,结果必须是寄存器的有效读写序列(每次读取必须返回最近写入设置的值)。 @@ -96,14 +96,14 @@ breadcrumbs: false [图 10-4](#fig_consistency_linearizability_3) 中有一些有趣的细节需要指出: -* 首先客户端 B 发送了读取 *x* 的请求,然后客户端 D 发送了将 *x* 设置为 0 的请求,然后客户端 A 发送了将 *x* 设置为 1 的请求。然而,返回给 B 的读取值是 1(A 写入的值)。这是可以的:这意味着数据库首先处理了 D 的写入,然后是 A 的写入,最后是 B 的读取。虽然这不是发送请求的顺序,但这是一个可接受的顺序,因为这三个请求是并发的。也许 B 的读取请求在网络中稍有延迟,因此它在两次写入之后才到达数据库。 -* 客户端 B 的读取在客户端 A 收到数据库的响应之前返回了 1,表示值 1 的写入成功。这也是可以的:这只是意味着从数据库到客户端 A 的 *ok* 响应在网络中稍有延迟。 -* 这个模型不假设任何事务隔离:另一个客户端可以随时更改值。例如,C 首先读取 1,然后读取 2,因为该值在两次读取之间被 B 更改了。原子比较并设置(*cas*)操作可用于检查值是否未被另一个客户端并发更改:B 和 C 的 *cas* 请求成功,但 D 的 *cas* 请求失败(到数据库处理它时,*x* 的值不再是 0)。 -* 客户端 B 的最后一次读取(在阴影条中)不是线性一致的。该操作与 C 的 *cas* 写入并发,后者将 *x* 从 2 更新到 4。在没有其他请求的情况下,B 的读取返回 2 是可以的。然而,客户端 A 在 B 的读取开始之前已经读取了新值 4,因此 B 不允许读取比 A 更旧的值。同样,这与 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同。 +* 首先客户端 B 发送了读取 **x** 的请求,然后客户端 D 发送了将 **x** 设置为 0 的请求,然后客户端 A 发送了将 **x** 设置为 1 的请求。然而,返回给 B 的读取值是 1(A 写入的值)。这是可以的:这意味着数据库首先处理了 D 的写入,然后是 A 的写入,最后是 B 的读取。虽然这不是发送请求的顺序,但这是一个可接受的顺序,因为这三个请求是并发的。也许 B 的读取请求在网络中稍有延迟,因此它在两次写入之后才到达数据库。 +* 客户端 B 的读取在客户端 A 收到数据库的响应之前返回了 1,表示值 1 的写入成功。这也是可以的:这只是意味着从数据库到客户端 A 的 **ok** 响应在网络中稍有延迟。 +* 这个模型不假设任何事务隔离:另一个客户端可以随时更改值。例如,C 首先读取 1,然后读取 2,因为该值在两次读取之间被 B 更改了。原子比较并设置(**cas**)操作可用于检查值是否未被另一个客户端并发更改:B 和 C 的 **cas** 请求成功,但 D 的 **cas** 请求失败(到数据库处理它时,**x** 的值不再是 0)。 +* 客户端 B 的最后一次读取(在阴影条中)不是线性一致的。该操作与 C 的 **cas** 写入并发,后者将 **x** 从 2 更新到 4。在没有其他请求的情况下,B 的读取返回 2 是可以的。然而,客户端 A 在 B 的读取开始之前已经读取了新值 4,因此 B 不允许读取比 A 更旧的值。同样,这与 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况相同。 这就是线性一致性背后的直觉;形式化定义 [^1] 更精确地描述了它。可以(尽管计算成本高昂)通过记录所有请求和响应的时序,并检查它们是否可以排列成有效的顺序序列来测试系统的行为是否线性一致 [^6] [^7]。 -就像除了可串行化之外还有各种弱隔离级别用于事务(见 ["弱隔离级别"](/ch8#sec_transactions_isolation_levels)),除了线性一致性之外,复制系统也有各种较弱的一致性模型 [^8]。实际上,我们在 ["复制延迟问题"](/ch6#sec_replication_lag) 中看到的 *写后读*、*单调读* 和 *一致性前缀读* 属性就是这种较弱一致性模型的例子。线性一致性保证所有这些较弱的属性,以及更多。在本章中,我们将重点关注线性一致性,它是最常用的最强一致性模型。 +就像除了可串行化之外还有各种弱隔离级别用于事务(见 ["弱隔离级别"](/ch8#sec_transactions_isolation_levels)),除了线性一致性之外,复制系统也有各种较弱的一致性模型 [^8]。实际上,我们在 ["复制延迟问题"](/ch6#sec_replication_lag) 中看到的 **写后读**、**单调读** 和 **一致性前缀读** 属性就是这种较弱一致性模型的例子。线性一致性保证所有这些较弱的属性,以及更多。在本章中,我们将重点关注线性一致性,它是最常用的最强一致性模型。 -------- @@ -113,14 +113,14 @@ breadcrumbs: false 线性一致性很容易与可串行化混淆(见 ["可串行化"](/ch8#sec_transactions_serializability)),因为这两个词似乎都意味着类似"可以按顺序排列"的东西。然而,它们是完全不同的保证,区分它们很重要: 可串行化 -: 可串行化是事务的隔离属性,其中每个事务可能读取和写入 *多个对象*(行、文档、记录)。它保证事务的行为与它们按 *某种* 串行顺序执行时相同:也就是说,就好像你首先执行一个事务的所有操作,然后执行另一个事务的所有操作,依此类推,而不交错它们。该串行顺序可以与事务实际运行的顺序不同 [^9]。 +: 可串行化是事务的隔离属性,其中每个事务可能读取和写入 **多个对象**(行、文档、记录)。它保证事务的行为与它们按 **某种** 串行顺序执行时相同:也就是说,就好像你首先执行一个事务的所有操作,然后执行另一个事务的所有操作,依此类推,而不交错它们。该串行顺序可以与事务实际运行的顺序不同 [^9]。 线性一致性 -: 线性一致性是对寄存器(*单个对象*)的读写保证。它不将操作分组到事务中,因此它不能防止涉及多个对象的问题,如写偏差(见 ["写偏差和幻读"](/ch8#sec_transactions_write_skew))。然而,线性一致性是一个 *新鲜度* 保证:它要求如果一个操作在另一个操作开始之前完成,那么后一个操作必须观察到至少与前一个操作一样新的状态。可串行化没有这个要求:例如,可串行化允许过时读取 [^10]。 +: 线性一致性是对寄存器(**单个对象**)的读写保证。它不将操作分组到事务中,因此它不能防止涉及多个对象的问题,如写偏差(见 ["写偏差和幻读"](/ch8#sec_transactions_write_skew))。然而,线性一致性是一个 **新鲜度** 保证:它要求如果一个操作在另一个操作开始之前完成,那么后一个操作必须观察到至少与前一个操作一样新的状态。可串行化没有这个要求:例如,可串行化允许过时读取 [^10]。 -(*顺序一致性* 又是另外一回事 [^8],但我们不会在这里讨论它。) +(**顺序一致性** 又是另外一回事 [^8],但我们不会在这里讨论它。) -数据库可能同时提供可串行化和线性一致性,这种组合称为 *严格可串行化* 或 *强单副本可串行化*(*strong-1SR*)[^11] [^12]。单节点数据库通常是线性一致的。对于使用乐观方法(如可串行化快照隔离)的分布式数据库(见 ["可串行化快照隔离(SSI)"](/ch8#sec_transactions_ssi)),情况更加复杂:例如,CockroachDB 提供可串行化和对读取的一些新鲜度保证,但不是严格可串行化 [^13],因为这需要事务之间进行昂贵的协调 [^14]。 +数据库可能同时提供可串行化和线性一致性,这种组合称为 **严格可串行化** 或 **强单副本可串行化**(**strong-1SR**)[^11] [^12]。单节点数据库通常是线性一致的。对于使用乐观方法(如可串行化快照隔离)的分布式数据库(见 ["可串行化快照隔离(SSI)"](/ch8#sec_transactions_ssi)),情况更加复杂:例如,CockroachDB 提供可串行化和对读取的一些新鲜度保证,但不是严格可串行化 [^13],因为这需要事务之间进行昂贵的协调 [^14]。 也可以将较弱的隔离级别与线性一致性结合,或将较弱的一致性模型与可串行化结合;实际上,一致性模型和隔离级别可以在很大程度上相互独立地选择 [^15] [^16]。 @@ -198,7 +198,7 @@ breadcrumbs: false : 具有多主复制的系统通常不是线性一致的,因为它们在多个节点上并发处理写入,并将它们异步复制到其他节点。因此,它们可能产生需要解决的冲突写入(见 ["处理冲突写入"](/ch6#sec_replication_write_conflicts))。 无主复制(可能非线性一致) -: 对于具有无主复制的系统(Dynamo 风格;见 ["无主复制"](/ch6#sec_replication_leaderless)),人们有时声称可以通过要求仲裁读写(*w* + *r* > *n*)来获得"强一致性"。根据确切的算法,以及你如何定义强一致性,这并不完全正确。 +: 对于具有无主复制的系统(Dynamo 风格;见 ["无主复制"](/ch6#sec_replication_leaderless)),人们有时声称可以通过要求仲裁读写(**w** + **r** > **n**)来获得"强一致性"。根据确切的算法,以及你如何定义强一致性,这并不完全正确。 基于日历时钟的"最后写入获胜"冲突解决方法(例如,在 Cassandra 和 ScyllaDB 中)几乎肯定是非线性一致的,因为时钟时间戳由于时钟偏差而无法保证与实际事件顺序一致(见 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying))。即使使用仲裁,也可能出现非线性一致的行为,如下一节所示。 @@ -209,9 +209,9 @@ breadcrumbs: false {{< figure src="/fig/ddia_1006.png" id="fig_consistency_leaderless" caption="图 10-6. 如果网络延迟是可变的,仲裁不足以确保线性一致性。" class="w-full my-4" >}} -在 [图 10-6](#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](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况。) +仲裁条件得到满足(**w** + **r** > **n**),但这种执行仍然不是线性一致的:B 的请求在 A 的请求完成后开始,但 B 返回旧值而 A 返回新值。(这又是 [图 10-1](#fig_consistency_linearizability_0) 中 Aaliyah 和 Bryce 的情况。) 可以使 Dynamo 风格的仲裁线性一致,但代价是降低性能:读者必须同步执行读修复(见 ["追赶错过的写入"](/ch6#sec_replication_read_repair)),然后才能将结果返回给应用程序 [^24]。此外,在写入之前,写入者必须读取节点仲裁的最新状态以获取任何先前写入的最新时间戳,并确保新写入具有更大的时间戳 [^25] [^26]。然而,Riak 由于性能损失而不执行同步读修复。Cassandra 确实等待仲裁读取时的读修复完成 [^27],但由于它使用日历时钟作为时间戳而失去了线性一致性。 @@ -228,7 +228,7 @@ breadcrumbs: false {{< figure src="/fig/ddia_1007.png" id="fig_consistency_cap_availability" caption="图 10-7. 如果客户端由于网络分区而无法联系足够的副本,它们就无法处理写入。" class="w-full my-4" >}} -考虑如果两个区域之间出现网络中断会发生什么。让我们假设每个区域内的网络正常工作,客户端可以到达其本地区域,但这些区域之间无法相互连接。这被称为 *网络分区*。 +考虑如果两个区域之间出现网络中断会发生什么。让我们假设每个区域内的网络正常工作,客户端可以到达其本地区域,但这些区域之间无法相互连接。这被称为 **网络分区**。 使用多主数据库,每个区域可以继续正常运行:由于来自一个区域的写入被异步复制到另一个区域,写入只是排队并在网络连接恢复时交换。 @@ -242,36 +242,36 @@ breadcrumbs: false 这个问题不仅仅是单主和多主复制的结果:任何线性一致的数据库都有这个问题,无论它如何实现。这个问题也不特定于多区域部署,而是可以发生在任何不可靠的网络上,即使在一个区域内。权衡如下: -* 如果你的应用程序 *需要* 线性一致性,并且某些副本由于网络问题与其他副本断开连接,那么某些副本在断开连接时无法处理请求:它们必须等待网络问题修复,或者返回错误(无论哪种方式,它们都变得 *不可用*)。这种选择有时被称为 *CP*(在网络分区下一致)。 -* 如果你的应用程序 *不需要* 线性一致性,那么它可以以一种方式编写,使每个副本可以独立处理请求,即使它与其他副本断开连接(例如,多主)。在这种情况下,应用程序可以在面对网络问题时保持 *可用*,但其行为不是线性一致的。这种选择被称为 *AP*(在网络分区下可用)。 +* 如果你的应用程序 **需要** 线性一致性,并且某些副本由于网络问题与其他副本断开连接,那么某些副本在断开连接时无法处理请求:它们必须等待网络问题修复,或者返回错误(无论哪种方式,它们都变得 **不可用**)。这种选择有时被称为 **CP**(在网络分区下一致)。 +* 如果你的应用程序 **不需要** 线性一致性,那么它可以以一种方式编写,使每个副本可以独立处理请求,即使它与其他副本断开连接(例如,多主)。在这种情况下,应用程序可以在面对网络问题时保持 **可用**,但其行为不是线性一致的。这种选择被称为 **AP**(在网络分区下可用)。 -因此,不需要线性一致性的应用程序可以更好地容忍网络问题。这种见解通常被称为 *CAP 定理* [^29] [^30] [^31] [^32],由 Eric Brewer 在 2000 年命名,尽管这种权衡自 1970 年代以来就为分布式数据库设计者所知 [^33] [^34] [^35]。 +因此,不需要线性一致性的应用程序可以更好地容忍网络问题。这种见解通常被称为 **CAP 定理** [^29] [^30] [^31] [^32],由 Eric Brewer 在 2000 年命名,尽管这种权衡自 1970 年代以来就为分布式数据库设计者所知 [^33] [^34] [^35]。 CAP 最初是作为经验法则提出的,没有精确的定义,目的是开始关于数据库中权衡的讨论。当时,许多分布式数据库专注于在具有共享存储的机器集群上提供线性一致语义 [^19],CAP 鼓励数据库工程师探索更广泛的分布式无共享系统设计空间,这些系统更适合实现大规模 Web 服务 [^36]。CAP 在这种文化转变方面值得称赞——它帮助触发了 NoSQL 运动,这是 2000 年代中期左右的一系列新数据库技术。 > [!TIP] 无用的 CAP 定理 -CAP 有时被表述为 *一致性、可用性、分区容错性:从 3 个中选择 2 个*。不幸的是,这样表述是误导性的 [^32],因为网络分区是一种故障,所以它们不是你可以选择的:无论你喜欢与否,它们都会发生。 +CAP 有时被表述为 **一致性、可用性、分区容错性:从 3 个中选择 2 个**。不幸的是,这样表述是误导性的 [^32],因为网络分区是一种故障,所以它们不是你可以选择的:无论你喜欢与否,它们都会发生。 -当网络正常工作时,系统可以同时提供一致性(线性一致性)和完全可用性。当发生网络故障时,你必须在线性一致性或完全可用性之间进行选择。因此,CAP 的更好表述方式是 *分区时要么一致要么可用* [^37]。更可靠的网络需要更少地做出这种选择,但在某个时候这种选择是不可避免的。 +当网络正常工作时,系统可以同时提供一致性(线性一致性)和完全可用性。当发生网络故障时,你必须在线性一致性或完全可用性之间进行选择。因此,CAP 的更好表述方式是 **分区时要么一致要么可用** [^37]。更可靠的网络需要更少地做出这种选择,但在某个时候这种选择是不可避免的。 -CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化为线性一致性(定理没有说任何关于较弱一致性模型的内容),*可用性* 的形式化 [^30] 与该术语的通常含义不匹配 [^38]。许多高可用(容错)系统实际上不符合 CAP 对可用性的特殊定义。此外,一些系统设计者选择(有充分理由)既不提供线性一致性也不提供 CAP 定理假设的可用性形式,因此这些系统既不是 CP 也不是 AP [^39] [^40]。 +CP/AP 分类方案还有几个进一步的缺陷 [^4]。**一致性** 被形式化为线性一致性(定理没有说任何关于较弱一致性模型的内容),**可用性** 的形式化 [^30] 与该术语的通常含义不匹配 [^38]。许多高可用(容错)系统实际上不符合 CAP 对可用性的特殊定义。此外,一些系统设计者选择(有充分理由)既不提供线性一致性也不提供 CAP 定理假设的可用性形式,因此这些系统既不是 CP 也不是 AP [^39] [^40]。 总的来说,关于 CAP 有很多误解和混淆,它并不能帮助我们更好地理解系统,因此最好避免使用 CAP。 正式定义的 CAP 定理 [^30] 范围非常狭窄:它只考虑一种一致性模型(即线性一致性)和一种故障(网络分区,根据 Google 的数据,这是不到 8% 事件的原因 [^41])。它没有说任何关于网络延迟、死节点或其他权衡的内容。因此,尽管 CAP 在历史上具有影响力,但对于设计系统几乎没有实际价值 [^4] [^38]。 -已经有努力推广 CAP。例如,*PACELC 原则* 观察到系统设计者也可能选择在网络正常工作时削弱一致性以减少延迟 [^39] [^40] [^42]。因此,在网络分区(P)期间,我们需要在可用性(A)和一致性(C)之间进行选择;否则(E),当没有分区时,我们可能在低延迟(L)和一致性(C)之间进行选择。然而,这个定义继承了 CAP 的几个问题,例如一致性和可用性的反直觉定义。 +已经有努力推广 CAP。例如,**PACELC 原则** 观察到系统设计者也可能选择在网络正常工作时削弱一致性以减少延迟 [^39] [^40] [^42]。因此,在网络分区(P)期间,我们需要在可用性(A)和一致性(C)之间进行选择;否则(E),当没有分区时,我们可能在低延迟(L)和一致性(C)之间进行选择。然而,这个定义继承了 CAP 的几个问题,例如一致性和可用性的反直觉定义。 分布式系统中有许多更有趣的不可能性结果 [^43],CAP 现在已被更精确的结果所取代 [^44] [^45],因此它今天主要具有历史意义。 #### 线性一致性与网络延迟 {#linearizability-and-network-delays} -尽管线性一致性是一个有用的保证,但令人惊讶的是,实际上很少有系统是线性一致的。例如,即使现代多核 CPU 上的 RAM 也不是线性一致的 [^46]:如果在一个 CPU 核心上运行的线程写入内存地址,而另一个 CPU 核心上的线程随后读取相同的地址,不能保证读取第一个线程写入的值(除非使用 *内存屏障* 或 *栅栏* [^47])。 +尽管线性一致性是一个有用的保证,但令人惊讶的是,实际上很少有系统是线性一致的。例如,即使现代多核 CPU 上的 RAM 也不是线性一致的 [^46]:如果在一个 CPU 核心上运行的线程写入内存地址,而另一个 CPU 核心上的线程随后读取相同的地址,不能保证读取第一个线程写入的值(除非使用 **内存屏障** 或 **栅栏** [^47])。 这种行为的原因是每个 CPU 核心都有自己的内存缓存和存储缓冲区。默认情况下,内存访问首先进入缓存,任何更改都异步写出到主内存。由于访问缓存中的数据比访问主内存快得多 [^48],这个特性对于现代 CPU 的良好性能至关重要。然而,现在有多份数据副本(一份在主内存中,可能还有几份在各种缓存中),这些副本是异步更新的,因此线性一致性丢失了。 -为什么要做出这种权衡?使用 CAP 定理来证明多核内存一致性模型是没有意义的:在一台计算机内,我们通常假设可靠的通信,我们不期望一个 CPU 核心在与计算机其余部分断开连接的情况下能够继续正常运行。放弃线性一致性的原因是 *性能*,而不是容错 [^39]。 +为什么要做出这种权衡?使用 CAP 定理来证明多核内存一致性模型是没有意义的:在一台计算机内,我们通常假设可靠的通信,我们不期望一个 CPU 核心在与计算机其余部分断开连接的情况下能够继续正常运行。放弃线性一致性的原因是 **性能**,而不是容错 [^39]。 许多选择不提供线性一致保证的分布式数据库也是如此:它们这样做主要是为了提高性能,而不是为了容错 [^42]。线性一致性很慢——这在任何时候都是真的,不仅在网络故障期间。 @@ -287,7 +287,7 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化 {{< 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](#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。但真正的问题是: @@ -304,7 +304,7 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化 : 不是从单节点 ID 生成器请求单个 ID,它可以分发 ID 块。例如,节点 A 可能声明从 1 到 1,000 的 ID 块,节点 B 可能声明从 1,001 到 2,000 的块。然后每个节点可以独立地从其块中分发 ID,并在其序列号供应开始不足时从单节点 ID 生成器请求新块。但是,这种方案也不能确保正确的排序:可能会发生这样的情况,一条消息被分配了 1,001 到 2,000 范围内的 ID,而后来的消息被分配了 1 到 1,000 范围内的 ID,如果 ID 是由不同的节点分配的。 随机 UUID -: 你可以使用 *通用唯一标识符*(UUID),也称为 *全局唯一标识符*(GUID)。它们的一大优点是可以在任何节点上本地生成,无需通信,但它们需要更多空间(128 位)。有几种不同版本的 UUID;最简单的是版本 4,它本质上是一个如此长的随机数,以至于两个节点选择相同的可能性非常小。不幸的是,这些 ID 的顺序也是随机的,因此比较两个 ID 不会告诉你哪个更新。 +: 你可以使用 **通用唯一标识符**(UUID),也称为 **全局唯一标识符**(GUID)。它们的一大优点是可以在任何节点上本地生成,无需通信,但它们需要更多空间(128 位)。有几种不同版本的 UUID;最简单的是版本 4,它本质上是一个如此长的随机数,以至于两个节点选择相同的可能性非常小。不幸的是,这些 ID 的顺序也是随机的,因此比较两个 ID 不会告诉你哪个更新。 时钟时间戳使其唯一 : 如果你的节点的日历时钟使用 NTP 保持大致正确,你可以通过将该时钟的时间戳放在最高有效位中,并用确保 ID 唯一的额外信息填充剩余位来生成 ID,即使时间戳不是——例如,分片编号和每分片递增序列号,或长随机值。这种方法用于版本 7 UUID [^50]、Twitter 的 Snowflake [^51]、ULID [^52]、Hazelcast 的 Flake ID 生成器、MongoDB ObjectID 和许多类似方案 [^50]。你可以在应用程序代码或数据库中实现这些 ID 生成器 [^53]。 @@ -313,27 +313,27 @@ CP/AP 分类方案还有几个进一步的缺陷 [^4]。*一致性* 被形式化 如 ["为事件排序的时间戳"](/ch9#sec_distributed_lww) 中所讨论的,时钟时间戳最多只能提供近似排序:如果较早的写入从稍快的时钟获得时间戳,而较晚写入的时间戳来自稍慢的时钟,则时间戳顺序可能与事件实际发生的顺序不一致。由于使用非单调时钟而导致的时钟跳跃,即使单个节点生成的时间戳也可能排序错误。因此,基于时钟时间的 ID 生成器不太可能是线性一致的。 -你可以通过依赖高精度时钟同步,使用原子钟或 GPS 接收器来减少这种排序不一致。但如果能够在不依赖特殊硬件的情况下生成唯一且正确排序的 ID 也会很好。这就是 *逻辑时钟* 的用途。 +你可以通过依赖高精度时钟同步,使用原子钟或 GPS 接收器来减少这种排序不一致。但如果能够在不依赖特殊硬件的情况下生成唯一且正确排序的 ID 也会很好。这就是 **逻辑时钟** 的用途。 ### 逻辑时钟 {#sec_consistency_timestamps} -在 ["不可靠的时钟"](/ch9#sec_distributed_clocks) 中,我们讨论了日历时钟和单调时钟。这两种都是 *物理时钟*:它们测量经过的秒数(或毫秒、微秒等)。 +在 ["不可靠的时钟"](/ch9#sec_distributed_clocks) 中,我们讨论了日历时钟和单调时钟。这两种都是 **物理时钟**:它们测量经过的秒数(或毫秒、微秒等)。 -在分布式系统中,通常还使用另一种时钟,称为 *逻辑时钟*。物理时钟是计算已经过的秒数的硬件设备,而逻辑时钟是计算已发生事件的算法。来自逻辑时钟的时间戳因此不会告诉你现在几点,但你 *可以* 比较来自逻辑时钟的两个时间戳,以判断哪个更早,哪个更晚。 +在分布式系统中,通常还使用另一种时钟,称为 **逻辑时钟**。物理时钟是计算已经过的秒数的硬件设备,而逻辑时钟是计算已发生事件的算法。来自逻辑时钟的时间戳因此不会告诉你现在几点,但你 **可以** 比较来自逻辑时钟的两个时间戳,以判断哪个更早,哪个更晚。 逻辑时钟的要求通常是: * 其时间戳紧凑(大小为几个字节)且唯一; -* 你可以比较任意两个时间戳(即它们是 *全序* 的);并且 -* 时间戳的顺序与因果关系 *一致*:如果操作 A 发生在 B 之前,那么 A 的时间戳小于 B 的时间戳。(我们之前在 ["“先发生”关系与并发"](/ch6#sec_replication_happens_before) 中讨论了因果关系。) +* 你可以比较任意两个时间戳(即它们是 **全序** 的);并且 +* 时间戳的顺序与因果关系 **一致**:如果操作 A 发生在 B 之前,那么 A 的时间戳小于 B 的时间戳。(我们之前在 ["“先发生”关系与并发"](/ch6#sec_replication_happens_before) 中讨论了因果关系。) 单节点 ID 生成器满足这些要求,但我们刚刚讨论的分布式 ID 生成器不满足因果排序要求。 #### Lamport 时间戳 {#lamport-timestamps} -幸运的是,有一种生成逻辑时间戳的简单方法,它与因果关系 *一致*,你可以将其用作分布式 ID 生成器。它被称为 *Lamport 时钟*,由 Leslie Lamport 在 1978 年提出 [^54],现在是分布式系统领域被引用最多的论文之一。 +幸运的是,有一种生成逻辑时间戳的简单方法,它与因果关系 **一致**,你可以将其用作分布式 ID 生成器。它被称为 **Lamport 时钟**,由 Leslie Lamport 在 1978 年提出 [^54],现在是分布式系统领域被引用最多的论文之一。 -[图 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,每个时间戳都是唯一的。 +[图 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" >}} @@ -351,7 +351,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: * 由于它们与物理时间没有直接关系,你不能使用它们来查找,比如说,在特定日期发布的所有消息——你需要单独存储物理时间。 * 如果两个节点从不通信,一个节点的计数器递增将永远不会反映在另一个节点的计数器中。因此,可能会发生这样的情况,即在不同节点上大约同一时间生成的事件具有极不相同的计数器值。 -*混合逻辑时钟* 结合了物理日历时钟的优势和 Lamport 时钟的排序保证 [^55]。像物理时钟一样,它计算秒或微秒。像 Lamport 时钟一样,当一个节点看到来自另一个节点的时间戳大于其本地时钟值时,它将自己的本地值向前移动以匹配另一个节点的时间戳。因此,如果一个节点的时钟运行得很快,其他节点在通信时也会类似地向前移动它们的时钟。 +**混合逻辑时钟** 结合了物理日历时钟的优势和 Lamport 时钟的排序保证 [^55]。像物理时钟一样,它计算秒或微秒。像 Lamport 时钟一样,当一个节点看到来自另一个节点的时间戳大于其本地时钟值时,它将自己的本地值向前移动以匹配另一个节点的时间戳。因此,如果一个节点的时钟运行得很快,其他节点在通信时也会类似地向前移动它们的时钟。 每次生成混合逻辑时钟的时间戳时,它也会递增,这确保时钟单调向前移动,即使底层物理时钟由于 NTP 调整而向后跳跃。因此,混合逻辑时钟可能略微领先于底层物理时钟。算法的细节确保这种差异尽可能小。 @@ -363,7 +363,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 当并发生成多个时间戳时,这些算法会任意排序它们。这意味着当你查看两个时间戳时,你通常无法判断它们是并发生成的还是一个发生在另一个之前。(在 [图 10-9](#fig_consistency_lamport_ts) 的示例中,你实际上可以判断 Aaliyah 和 Caleb 的消息必须是并发的,因为它们具有相同的计数器值,但当计数器值不同时,你无法判断它们是否并发。) -如果你想能够确定记录何时并发创建,你需要不同的算法,例如 *向量时钟*。缺点是向量时钟的时间戳要大得多——可能是系统中每个节点一个整数。有关检测并发的更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。 +如果你想能够确定记录何时并发创建,你需要不同的算法,例如 **向量时钟**。缺点是向量时钟的时间戳要大得多——可能是系统中每个节点一个整数。有关检测并发的更多详细信息,请参见 ["检测并发写入"](/ch6#sec_replication_concurrent)。 ### 线性一致的 ID 生成器 {#sec_consistency_linearizable_id} @@ -384,7 +384,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 实现线性一致的 ID 生成器 {#implementing-a-linearizable-id-generator} -确保 ID 分配线性一致的最简单方法实际上是为此目的使用单个节点。该节点只需要原子地递增计数器并在请求时返回其值,持久化计数器值(以便在节点崩溃并重新启动时不会生成重复的 ID),并使用单主复制进行容错复制。这种方法在实践中使用:例如,TiDB/TiKV 称之为 *时间戳预言机*,受 Google 的 Percolator [^57] 启发。 +确保 ID 分配线性一致的最简单方法实际上是为此目的使用单个节点。该节点只需要原子地递增计数器并在请求时返回其值,持久化计数器值(以便在节点崩溃并重新启动时不会生成重复的 ID),并使用单主复制进行容错复制。这种方法在实践中使用:例如,TiDB/TiKV 称之为 **时间戳预言机**,受 Google 的 Percolator [^57] 启发。 作为优化,你可以避免在每个请求上执行磁盘写入和复制。相反,ID 生成器可以写入描述一批 ID 的记录;一旦该记录被持久化并完成复制,节点就可以开始按顺序向客户端分发这些 ID。在它用完该批次中的 ID 之前,它可以为下一批持久化并复制记录。这样,如果节点崩溃并重启,或故障切换到备库,某些 ID 会被跳过,但不会发出任何重复或乱序的 ID。 @@ -400,7 +400,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 答案是:不完全。当你有几个节点都试图获取同一个锁或注册同一个用户名时,你可以使用逻辑时钟为这些请求分配时间戳,并选择具有最低时间戳的请求作为获胜者。如果时钟是线性一致的,你知道任何未来的请求都将始终生成更大的时间戳,因此你可以确定没有未来的请求会收到比获胜者更低的时间戳。 -不幸的是,问题的一部分仍未解决:节点如何知道自己的时间戳是否最低?要确定,它需要听到可能生成时间戳的 *每个* 其他节点 [^54]。如果其他节点之一在此期间失败,或者由于网络问题无法访问,该系统将停止运行,因为我们无法确定该节点是否可能具有最低的时间戳。这不是我们需要的那种容错系统。 +不幸的是,问题的一部分仍未解决:节点如何知道自己的时间戳是否最低?要确定,它需要听到可能生成时间戳的 **每个** 其他节点 [^54]。如果其他节点之一在此期间失败,或者由于网络问题无法访问,该系统将停止运行,因为我们无法确定该节点是否可能具有最低的时间戳。这不是我们需要的那种容错系统。 要以容错方式实现锁、租约和类似构造,我们需要比逻辑时钟或 ID 生成器更强大的东西:我们需要共识。 @@ -414,11 +414,11 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: * 单节点上的线性一致 ID 生成器只是一个带有原子获取并增加指令的计数器,但如果它崩溃了怎么办? * 原子比较并设置(CAS)操作对许多事情都很有用,例如当多个进程竞相获取它时决定谁获得锁或租约,或确保具有给定名称的文件或用户的唯一性。在单个节点上,CAS 可能就像一条 CPU 指令一样简单,但如何使其容错? -事实证明,所有这些都是同一个基本分布式系统问题的实例:*共识*。共识是分布式计算中最重要和最基本的问题之一;它也是出了名的难以正确实现 [^58] [^59],许多系统在过去都出错了。现在我们已经讨论了复制([第六章](/ch6))、事务([第八章](/ch8))、系统模型([第九章](/ch9))和线性一致性(本章),我们终于准备好解决共识问题了。 +事实证明,所有这些都是同一个基本分布式系统问题的实例:**共识**。共识是分布式计算中最重要和最基本的问题之一;它也是出了名的难以正确实现 [^58] [^59],许多系统在过去都出错了。现在我们已经讨论了复制([第六章](/ch6))、事务([第八章](/ch8))、系统模型([第九章](/ch9))和线性一致性(本章),我们终于准备好解决共识问题了。 最著名的共识算法是 Viewstamped Replication [^60] [^61]、Paxos [^58] [^62] [^63] [^64]、Raft [^23] [^65] [^66] 和 Zab [^18] [^22] [^67]。这些算法之间有相当多的相似之处,但它们并不相同 [^68] [^69]。这些算法在非拜占庭系统模型中工作:也就是说,网络通信可能会被任意延迟或丢弃,节点可能会崩溃、重启和断开连接,但算法假设节点在其他方面正确遵循协议,不会恶意行为。 -也有可以容忍某些拜占庭节点的共识算法,即不正确遵循协议的节点(例如,向其他节点发送矛盾消息)。一个常见的假设是少于三分之一的节点是拜占庭故障的 [^26] [^70]。这种 *拜占庭容错*(BFT)共识算法用于区块链 [^71]。然而,如 ["拜占庭故障"](/ch9#sec_distributed_byzantine) 中所解释的,BFT 算法超出了本书的范围。 +也有可以容忍某些拜占庭节点的共识算法,即不正确遵循协议的节点(例如,向其他节点发送矛盾消息)。一个常见的假设是少于三分之一的节点是拜占庭故障的 [^26] [^70]。这种 **拜占庭容错**(BFT)共识算法用于区块链 [^71]。然而,如 ["拜占庭故障"](/ch9#sec_distributed_byzantine) 中所解释的,BFT 算法超出了本书的范围。 -------- @@ -426,7 +426,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 你可能听说过 FLP 结果 [^72]——以作者 Fischer、Lynch 和 Paterson 的名字命名——它证明如果存在节点可能崩溃的风险,就没有算法总是能够达成共识。在分布式系统中,我们必须假设节点可能会崩溃,因此可靠的共识是不可能的。然而,在这里我们正在讨论实现共识的算法。这是怎么回事? -首先,FLP 并不是说我们永远无法达成共识——它只是说我们不能保证共识算法 *总是* 终止。此外,FLP 结果是在异步系统模型中假设确定性算法的情况下证明的(见 ["系统模型与现实"](/ch9#sec_distributed_system_model)),这意味着算法不能使用任何时钟或超时。如果它可以使用超时来怀疑另一个节点可能已经崩溃(即使怀疑有时是错误的),那么共识就变得可解 [^73]。即使只是允许算法使用随机数也足以绕过不可能性结果 [^74]。 +首先,FLP 并不是说我们永远无法达成共识——它只是说我们不能保证共识算法 **总是** 终止。此外,FLP 结果是在异步系统模型中假设确定性算法的情况下证明的(见 ["系统模型与现实"](/ch9#sec_distributed_system_model)),这意味着算法不能使用任何时钟或超时。如果它可以使用超时来怀疑另一个节点可能已经崩溃(即使怀疑有时是错误的),那么共识就变得可解 [^73]。即使只是允许算法使用随机数也足以绕过不可能性结果 [^74]。 因此,尽管 FLP 关于共识不可能性的结果具有重要的理论意义,但分布式系统通常可以在实践中实现共识。 @@ -436,9 +436,9 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 共识可以用几种不同的方式表达: -* *单值共识* 非常类似于原子 *比较并设置* 操作,它可用于实现锁、租约和唯一性约束。 -* 构建 *仅追加日志* 也需要共识;它通常形式化为 *全序广播*。有了日志,你可以构建 *状态机复制*、基于主节点的复制、事件溯源和其他有用的东西。 -* 多数据库或多分片事务的 *原子提交* 要求所有参与者就是否提交或中止事务达成一致。 +* **单值共识** 非常类似于原子 **比较并设置** 操作,它可用于实现锁、租约和唯一性约束。 +* 构建 **仅追加日志** 也需要共识;它通常形式化为 **全序广播**。有了日志,你可以构建 **状态机复制**、基于主节点的复制、事件溯源和其他有用的东西。 +* 多数据库或多分片事务的 **原子提交** 要求所有参与者就是否提交或中止事务达成一致。 我们很快就会探讨所有这些。事实上,这些问题都是相互等价的:如果你有解决其中一个问题的算法,你可以将其转换为任何其他问题的解决方案。这是一个相当深刻且也许令人惊讶的见解!这就是为什么我们可以将所有这些东西归入"共识"之下,即使它们表面上看起来完全不同。 @@ -449,7 +449,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: * 当具有单主复制的数据库首次启动时,或者当现有主节点失败时,多个节点可能会同时尝试成为主节点。同样,多个节点可能竞相获取锁或租约。共识允许它们决定哪一个获胜。 * 如果几个人同时尝试预订飞机上的最后一个座位,或剧院中的同一个座位,或尝试使用相同的用户名注册账户,那么共识算法可以确定哪一个应该成功。 -更一般地说,一个或多个节点可能 *提议* 值,共识算法 *决定* 其中一个值。在上述示例中,每个节点可以提议自己的 ID,算法决定哪个节点 ID 应该成为新的主节点、租约的持有者或飞机/剧院座位的购买者。在这种形式主义中,共识算法必须满足以下属性 [^26]: +更一般地说,一个或多个节点可能 **提议** 值,共识算法 **决定** 其中一个值。在上述示例中,每个节点可以提议自己的 ID,算法决定哪个节点 ID 应该成为新的主节点、租约的持有者或飞机/剧院座位的购买者。在这种形式主义中,共识算法必须满足以下属性 [^26]: 一致同意 : 没有两个节点决定不同。 @@ -458,7 +458,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: : 一旦节点决定了一个值,它就不能通过决定另一个值来改变主意。 有效性 -: 如果节点决定值 *v*,那么 *v* 是由某个节点提议的。 +: 如果节点决定值 **v**,那么 **v** 是由某个节点提议的。 终止 : 每个未崩溃的节点最终都会决定某个值。 @@ -473,7 +473,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 如果崩溃的节点可能恢复,你可以等待它回来。然而,共识必须确保即使崩溃的节点突然消失并且永远不会回来,它也会做出决定。(不要想象软件崩溃,而是想象有地震,包含你的节点的数据中心被山体滑坡摧毁。你必须假设你的节点被埋在 30 英尺的泥土下,永远不会重新上线。) -当然,如果 *所有* 节点都崩溃了,并且没有一个在运行,那么任何算法都不可能决定任何事情。算法可以容忍的故障数量是有限的:事实上,可以证明任何共识算法都需要至少大多数节点正常运行才能确保终止 [^73]。该多数可以安全地形成仲裁(见 ["读写仲裁"](/ch6#sec_replication_quorum_condition))。 +当然,如果 **所有** 节点都崩溃了,并且没有一个在运行,那么任何算法都不可能决定任何事情。算法可以容忍的故障数量是有限的:事实上,可以证明任何共识算法都需要至少大多数节点正常运行才能确保终止 [^73]。该多数可以安全地形成仲裁(见 ["读写仲裁"](/ch6#sec_replication_quorum_condition))。 因此,终止属性受到少于一半节点崩溃或不可达的假设的约束。然而,大多数共识算法确保安全属性——同意、完整性和有效性——始终得到满足,即使大多数节点失败或存在严重的网络问题 [^75]。因此,大规模中断可能会阻止系统处理请求,但它不能通过导致做出不一致的决定来破坏共识系统。 @@ -491,7 +491,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 共享日志作为共识 {#sec_consistency_shared_logs} -我们已经看到了几个日志的例子,例如复制日志、事务日志和预写日志。日志存储一系列 *日志条目*,任何读取它的人都会看到相同顺序的相同条目。有时日志有一个允许追加新条目的单个写入者,但 *共享日志* 是多个节点可以请求追加条目的日志。单主复制就是一个例子:任何客户端都可以要求主节点进行写入,主节点将其追加到复制日志,然后所有备库按照与主节点相同的顺序应用写入。 +我们已经看到了几个日志的例子,例如复制日志、事务日志和预写日志。日志存储一系列 **日志条目**,任何读取它的人都会看到相同顺序的相同条目。有时日志有一个允许追加新条目的单个写入者,但 **共享日志** 是多个节点可以请求追加条目的日志。单主复制就是一个例子:任何客户端都可以要求主节点进行写入,主节点将其追加到复制日志,然后所有备库按照与主节点相同的顺序应用写入。 更正式地说,共享日志支持两种操作:你可以请求将值添加到日志中,并且可以读取日志中的条目。它必须满足以下属性: @@ -505,7 +505,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: : 一旦节点读取了某个日志条目,它就是不可变的,新的日志条目只能在它之后添加,而不能在之前。节点可能会重新读取日志,在这种情况下,它会以与最初读取它们时相同的顺序看到相同的日志条目(即使节点崩溃并重新启动)。 一致性 -: 如果两个节点都读取某个日志条目 *e*,那么在 *e* 之前,它们必须以相同的顺序读取完全相同的日志条目序列。 +: 如果两个节点都读取某个日志条目 **e**,那么在 **e** 之前,它们必须以相同的顺序读取完全相同的日志条目序列。 有效性 : 如果节点读取包含某个值的日志条目,那么某个节点先前请求将该值添加到日志中。 @@ -538,13 +538,13 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 现在假设读取零的节点是获胜者,它的值被决定。这对于读取零的节点有效,但其他节点有问题:它们知道自己不是获胜者,但它们不知道其他节点中哪一个获胜了。获胜者可以向其他节点发送消息,让它们知道它已经获胜,但如果获胜者在有机会发送此消息之前崩溃了怎么办?在这种情况下,其他节点将被挂起,无法决定任何值,因此共识不会终止。其他节点不能回退到另一个节点,因为读取零的节点可能会回来并正确地决定它提议的值。 -一个例外是,如果我们确定不超过两个节点将提议值。在这种情况下,节点可以相互发送它们想要提议的值,然后每个都执行获取并增加操作。读取零的节点决定自己的值,读取一的节点决定另一个节点的值。这解决了两个节点之间的共识问题,这就是为什么我们可以说获取并增加的 *共识数* 为二 [^28]。相比之下,CAS 和共享日志解决了任意数量节点可能提议值的共识,因此它们的共识数为 ∞(无穷大)。 +一个例外是,如果我们确定不超过两个节点将提议值。在这种情况下,节点可以相互发送它们想要提议的值,然后每个都执行获取并增加操作。读取零的节点决定自己的值,读取一的节点决定另一个节点的值。这解决了两个节点之间的共识问题,这就是为什么我们可以说获取并增加的 **共识数** 为二 [^28]。相比之下,CAS 和共享日志解决了任意数量节点可能提议值的共识,因此它们的共识数为 ∞(无穷大)。 #### 原子提交作为共识 {#atomic-commitment-as-consensus} -在 ["分布式事务"](/ch8#sec_transactions_distributed) 中,我们看到了 *原子提交* 问题,即确保参与分布式事务的数据库或分片都提交或中止事务。我们还看到了 *两阶段提交* 算法,它依赖于作为单点故障的协调器。 +在 ["分布式事务"](/ch8#sec_transactions_distributed) 中,我们看到了 **原子提交** 问题,即确保参与分布式事务的数据库或分片都提交或中止事务。我们还看到了 **两阶段提交** 算法,它依赖于作为单点故障的协调器。 -共识和原子提交之间有什么关系?乍一看,它们似乎非常相似——两者都需要节点达成某种形式的一致。然而,有一个重要的区别:对于共识,可以决定提议的任何值,而对于原子提交,如果 *任何* 参与者投票中止,算法 *必须* 中止。更准确地说,原子提交需要以下属性 [^78]: +共识和原子提交之间有什么关系?乍一看,它们似乎非常相似——两者都需要节点达成某种形式的一致。然而,有一个重要的区别:对于共识,可以决定提议的任何值,而对于原子提交,如果 **任何** 参与者投票中止,算法 **必须** 中止。更准确地说,原子提交需要以下属性 [^78]: 一致同意 : 没有两个节点决定不同的结果。 @@ -579,7 +579,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 使用共享日志 {#sec_consistency_smr} -共享日志非常适合数据库复制:如果每个日志条目代表对数据库的写入,并且每个副本使用确定性逻辑以相同的顺序处理相同的写入,那么副本将全部处于一致状态。这个想法被称为 *状态机复制* [^80],它是事件溯源背后的原则,我们在 ["事件溯源和 CQRS"](/ch3#sec_datamodels_events) 中看到了。共享日志对于流处理也很有用,我们将在 [第十二章](/ch12#ch_stream) 中看到。 +共享日志非常适合数据库复制:如果每个日志条目代表对数据库的写入,并且每个副本使用确定性逻辑以相同的顺序处理相同的写入,那么副本将全部处于一致状态。这个想法被称为 **状态机复制** [^80],它是事件溯源背后的原则,我们在 ["事件溯源和 CQRS"](/ch3#sec_datamodels_events) 中看到了。共享日志对于流处理也很有用,我们将在 [第十二章](/ch12#ch_stream) 中看到。 同样,共享日志可用于实现可串行化事务:如 ["实际串行执行"](/ch8#sec_transactions_serial) 中所讨论的,如果每个日志条目代表要作为存储过程执行的确定性事务,并且如果每个节点以相同的顺序执行这些事务,那么事务将是可串行化的 [^81] [^82]。 @@ -604,7 +604,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 然而,有一个问题。我们之前讨论过脑裂的问题,并说所有节点都需要就谁是主节点达成一致——否则两个不同的节点可能各自认为自己是主节点,从而做出不一致的决定。因此,似乎我们需要共识来选举主节点,而我们需要主节点来解决共识。我们如何摆脱这个难题? -事实上,共识算法不要求在任何时候只有一个主节点。相反,它们做出了较弱的保证:它们定义了一个 *纪元编号*(在 Paxos 中称为 *投票编号*,在 Viewstamped Replication 中称为 *视图编号*,在 Raft 中称为 *任期编号*)并保证在每个纪元内,主节点是唯一的。 +事实上,共识算法不要求在任何时候只有一个主节点。相反,它们做出了较弱的保证:它们定义了一个 **纪元编号**(在 Paxos 中称为 **投票编号**,在 Viewstamped Replication 中称为 **视图编号**,在 Raft 中称为 **任期编号**)并保证在每个纪元内,主节点是唯一的。 当节点因为在某个超时时间内没有收到主节点的消息而认为当前主节点已死时,它可能会开始投票选举新的主节点。这次选举被赋予一个大于任何先前纪元的新纪元编号。如果两个不同纪元中的两个不同主节点之间存在冲突(也许是因为先前的主节点实际上并没有死),那么具有更高纪元编号的主节点获胜。 @@ -612,7 +612,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 因此,我们有两轮投票:一次选择主节点,第二次对主节点提议的下一个要追加到日志的条目进行投票。这两次投票的仲裁必须重叠:如果对提议的投票成功,投票支持它的节点中至少有一个也必须参与了最近成功的主节点选举 [^85]。因此,如果对提议的投票通过而没有透露任何更高编号的纪元,当前主节点可以得出结论,没有选出具有更高纪元编号的主节点,因此它可以安全地将提议的条目追加到日志中 [^26] [^86]。 -这两轮投票表面上看起来类似于两阶段提交,但它们是非常不同的协议。在共识算法中,任何节点都可以开始选举,它只需要节点仲裁的响应;在 2PC 中,只有协调器可以请求投票,它需要 *每个* 参与者的"是"投票才能提交。 +这两轮投票表面上看起来类似于两阶段提交,但它们是非常不同的协议。在共识算法中,任何节点都可以开始选举,它只需要节点仲裁的响应;在 2PC 中,只有协调器可以请求投票,它需要 **每个** 参与者的"是"投票才能提交。 #### 共识的微妙之处 {#subtleties-of-consensus} @@ -627,7 +627,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 如果你希望共识算法严格保证 ["共享日志作为共识"](#sec_consistency_shared_logs) 中列出的属性,那么新主节点在处理任何写入或线性一致读取之前必须了解任何已确认的日志条目,这一点至关重要。如果具有过时数据的节点成为新主节点,它可能会将新值写入已经由旧主节点写入的日志条目,从而违反共享日志的仅追加属性。 -在某些情况下,你可能选择削弱共识属性,以便更快地从主节点故障中恢复。例如,Kafka 提供了启用 *不干净的主节点选举* 的选项,它允许任何副本成为主节点,即使它不是最新的。此外,在采用异步复制的数据库中,当主节点失败时,你无法保证任何备库是最新的。 +在某些情况下,你可能选择削弱共识属性,以便更快地从主节点故障中恢复。例如,Kafka 提供了启用 **不干净的主节点选举** 的选项,它允许任何副本成为主节点,即使它不是最新的。此外,在采用异步复制的数据库中,当主节点失败时,你无法保证任何备库是最新的。 如果你放弃新主节点必须是最新的要求,你可能会提高性能和可用性,但你是在薄冰上,因为共识理论不再适用。虽然只要没有故障,事情就会正常工作,但 [第九章](/ch9) 中讨论的问题很容易导致大量数据丢失或损坏。 @@ -637,7 +637,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: 对于使用共识算法进行复制的数据库,不仅写入需要转换为日志条目并复制到仲裁。如果你想保证线性一致的读取,它们也必须像写入一样通过仲裁投票,以确认认为自己是主节点的节点确实仍然是最新的。例如,etcd 中的线性一致读取就是这样工作的。 -在其标准形式中,大多数共识算法假设一组固定的节点——也就是说,节点可能会宕机并重新启动,但允许投票的节点集在创建集群时是固定的。在实践中,通常需要在系统配置中添加新节点或删除旧节点。共识算法已经扩展了 *重新配置* 功能,使这成为可能。这在向系统添加新区域或从一个位置迁移到另一个位置(通过首先添加新节点,然后删除旧节点)时特别有用。 +在其标准形式中,大多数共识算法假设一组固定的节点——也就是说,节点可能会宕机并重新启动,但允许投票的节点集在创建集群时是固定的。在实践中,通常需要在系统配置中添加新节点或删除旧节点。共识算法已经扩展了 **重新配置** 功能,使这成为可能。这在向系统添加新区域或从一个位置迁移到另一个位置(通过首先添加新节点,然后删除旧节点)时特别有用。 #### 共识的利弊 {#pros-and-cons-of-consensus} @@ -656,7 +656,7 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: ### 协调服务 {#sec_consistency_coordination} -共识算法对于任何希望提供线性一致操作的分布式数据库都很有价值,许多现代分布式数据库也都用共识来做复制。但有一类系统是共识算法的重度用户:*协调服务*,例如 ZooKeeper、etcd 和 Consul。虽然它们表面上看起来像普通键值存储,但它们并不是为通用数据存储而设计的。 +共识算法对于任何希望提供线性一致操作的分布式数据库都很有价值,许多现代分布式数据库也都用共识来做复制。但有一类系统是共识算法的重度用户:**协调服务**,例如 ZooKeeper、etcd 和 Consul。虽然它们表面上看起来像普通键值存储,但它们并不是为通用数据存储而设计的。 相反,它们的目标是协调另一个分布式系统中的多个节点。例如,Kubernetes 依赖 etcd;Spark 和 Flink 在高可用模式下会在后台依赖 ZooKeeper。协调服务通常只存储小规模数据,这些数据可以完全放入内存(同时仍会写盘以保证持久性),并通过容错共识算法在多个节点间复制。 @@ -666,10 +666,10 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: : 我们前面看到,共识系统可以实现具备容错能力的原子比较并设置(CAS)操作。协调服务正是基于这一点来实现锁和租约:若多个节点并发尝试获取同一个租约,最终只会有一个成功。 支持栅栏 -: 如 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 所述,当某个资源受租约保护时,需要 *栅栏* 机制来防止进程暂停或网络大延迟时的相互干扰。共识系统可通过为每个日志条目分配单调递增 ID 来生成栅栏令牌(ZooKeeper 中的 `zxid` 和 `cversion`,etcd 中的 revision)。 +: 如 ["分布式锁和租约"](/ch9#sec_distributed_lock_fencing) 所述,当某个资源受租约保护时,需要 **栅栏** 机制来防止进程暂停或网络大延迟时的相互干扰。共识系统可通过为每个日志条目分配单调递增 ID 来生成栅栏令牌(ZooKeeper 中的 `zxid` 和 `cversion`,etcd 中的 revision)。 故障检测 -: 客户端会在协调服务上维持长连接会话,并通过周期性心跳检查对端是否存活。即使连接临时中断或某台服务端故障,客户端持有的租约仍可保持有效;但如果超过租约超时时间仍未收到心跳,协调服务就会认为客户端已失效并释放租约(ZooKeeper 将其称为 *临时节点*)。 +: 客户端会在协调服务上维持长连接会话,并通过周期性心跳检查对端是否存活。即使连接临时中断或某台服务端故障,客户端持有的租约仍可保持有效;但如果超过租约超时时间仍未收到心跳,协调服务就会认为客户端已失效并释放租约(ZooKeeper 将其称为 **临时节点**)。 变更通知 : 客户端可以请求:当某些键发生变化时由协调服务主动通知。这样客户端就能知道另一个节点何时加入集群(基于其写入的值),或者何时失效(会话超时、临时节点消失)。这类通知避免了客户端频繁轮询。 @@ -700,13 +700,13 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制: #### 服务发现 {#service-discovery} -ZooKeeper、etcd 和 Consul 也常用于 *服务发现*:即确定连接某个服务所需的 IP 地址(见 ["负载均衡、服务发现和服务网格"](/ch5#sec_encoding_service_discovery))。在云环境下,虚拟机常常频繁上下线,因此你通常无法预先知道服务地址。常见做法是让服务启动时把自身网络端点注册到服务注册表,再供其他服务查询。 +ZooKeeper、etcd 和 Consul 也常用于 **服务发现**:即确定连接某个服务所需的 IP 地址(见 ["负载均衡、服务发现和服务网格"](/ch5#sec_encoding_service_discovery))。在云环境下,虚拟机常常频繁上下线,因此你通常无法预先知道服务地址。常见做法是让服务启动时把自身网络端点注册到服务注册表,再供其他服务查询。 用协调服务做服务发现很方便,因为它的故障检测和变更通知能让客户端及时跟踪服务实例的增减。而且如果你本来就用协调服务做租约、锁或主节点选举,那么继续复用它做服务发现通常也很自然,因为它已经知道哪个节点应该接收请求。 不过,对服务发现使用共识往往有些“杀鸡用牛刀”:这个场景通常不要求线性一致性,更重要的是高可用和低延迟,因为没有服务发现,整个系统都会停滞。因此通常更倾向于缓存服务发现结果,并接受其可能略有陈旧。比如基于 DNS 的服务发现,就是通过多层缓存来获得良好的性能与可用性。 -为支持这类需求,ZooKeeper 提供了 *observer*(观察者)节点:它接收日志并维护一份 ZooKeeper 数据副本,但不参与共识投票。来自 observer 的读取不具备线性一致性(可能陈旧),但即使网络中断仍然可用,并且能通过缓存提高系统可支持的读吞吐量。 +为支持这类需求,ZooKeeper 提供了 **observer**(观察者)节点:它接收日志并维护一份 ZooKeeper 数据副本,但不参与共识投票。来自 observer 的读取不具备线性一致性(可能陈旧),但即使网络中断仍然可用,并且能通过缓存提高系统可支持的读吞吐量。 ## 总结 {#summary} diff --git a/content/zh/ch11.md b/content/zh/ch11.md index 88bbe63..188ebee 100644 --- a/content/zh/ch11.md +++ b/content/zh/ch11.md @@ -13,17 +13,17 @@ breadcrumbs: false > > 高德纳 -到目前为止,本书大部分内容都围绕着 *请求(request)* 与 *查询(query)* 以及对应的 *响应(response)* 或 *结果(result)* 展开。现代很多数据系统都默认采用这种处理方式:你发出请求或指令,系统尽快给出答案。 +到目前为止,本书大部分内容都围绕着 **请求(request)** 与 **查询(query)** 以及对应的 **响应(response)** 或 **结果(result)** 展开。现代很多数据系统都默认采用这种处理方式:你发出请求或指令,系统尽快给出答案。 -网页浏览器请求页面、服务调用远程 API、数据库、缓存、搜索索引,以及很多其他系统都如此运作。我们称这类系统为 *在线系统(online systems)*。它们通常以响应时间作为主要性能指标,并且往往需要良好的容错能力来保证高可用。 +网页浏览器请求页面、服务调用远程 API、数据库、缓存、搜索索引,以及很多其他系统都如此运作。我们称这类系统为 **在线系统(online systems)**。它们通常以响应时间作为主要性能指标,并且往往需要良好的容错能力来保证高可用。 -但有时候,你需要执行的计算比一次交互式请求大得多,或者要处理的数据量远超单次请求能承载的范围。例如训练 AI 模型、把海量数据从一种形式转换成另一种形式、或者在超大数据集上做分析计算。我们把这类任务称为 *批处理(batch processing)* 作业,有时也称为 *离线系统(offline systems)*。 +但有时候,你需要执行的计算比一次交互式请求大得多,或者要处理的数据量远超单次请求能承载的范围。例如训练 AI 模型、把海量数据从一种形式转换成另一种形式、或者在超大数据集上做分析计算。我们把这类任务称为 **批处理(batch processing)** 作业,有时也称为 **离线系统(offline systems)**。 -批处理作业读取一批输入数据(只读),并生成一批输出数据(每次运行都从头生成)。它通常不会像读写事务那样原地修改数据。因此,输出是由输入推导出的 *派生数据(derived data)*(见[“记录系统与派生数据”](/ch1#sec_introduction_derived)):如果不满意输出,你可以直接删除它,修改作业逻辑,再跑一遍即可。把输入视为不可变并尽量避免副作用(例如直接写外部数据库),不仅有助于性能,也带来其他好处: +批处理作业读取一批输入数据(只读),并生成一批输出数据(每次运行都从头生成)。它通常不会像读写事务那样原地修改数据。因此,输出是由输入推导出的 **派生数据(derived data)**(见[“记录系统与派生数据”](/ch1#sec_introduction_derived)):如果不满意输出,你可以直接删除它,修改作业逻辑,再跑一遍即可。把输入视为不可变并尽量避免副作用(例如直接写外部数据库),不仅有助于性能,也带来其他好处: -- 如果你在代码中引入了 bug 导致输出错误或损坏,可以直接回滚代码并重跑作业,输出就会恢复正确。更简单的做法是把旧输出保留在另一个目录,直接切回旧版本。多数对象存储与开放表格式(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))都支持这种能力,通常称为 *时间旅行(time travel)*。大多数支持读写事务的数据库不具备这种特性:如果错误代码把坏数据写进数据库,仅回滚代码并不能修复已写入的数据。能够从错误代码中恢复的能力被称为 *容忍人为失误* [^1]。 +- 如果你在代码中引入了 bug 导致输出错误或损坏,可以直接回滚代码并重跑作业,输出就会恢复正确。更简单的做法是把旧输出保留在另一个目录,直接切回旧版本。多数对象存储与开放表格式(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))都支持这种能力,通常称为 **时间旅行(time travel)**。大多数支持读写事务的数据库不具备这种特性:如果错误代码把坏数据写进数据库,仅回滚代码并不能修复已写入的数据。能够从错误代码中恢复的能力被称为 **容忍人为失误** [^1]。 -- 因为回滚容易,功能开发能比“犯错会造成不可逆损害”的环境更快推进。这个 *最小化不可逆性* 的原则对敏捷开发非常有益 [^2]。 +- 因为回滚容易,功能开发能比“犯错会造成不可逆损害”的环境更快推进。这个 **最小化不可逆性** 的原则对敏捷开发非常有益 [^2]。 - 同一组文件可以作为多种作业的输入,包括监控类作业:例如计算指标、验证输出是否符合预期(如与上一次结果比较并度量偏差)。 @@ -36,7 +36,7 @@ breadcrumbs: false > [!NOTE] > 批处理的另一种替代形态是 *流处理(stream processing)*:作业不会在“处理完输入后结束”,而是持续监听输入,并在变化发生后很快处理。我们将在[第十二章](/ch12#ch_stream)讨论流处理。 -在线处理与批处理的边界并不总是清晰:一个运行很久的数据库查询,看起来也很像批处理过程。但批处理有一些独特特性,使其成为构建可靠、可伸缩、可维护应用的重要积木。例如,它常在 *数据集成(data integration)* 中发挥作用,即把多个数据系统组合起来完成单一系统做不到的事。ETL(见[“数据仓库”](/ch1#sec_introduction_dwh))就是典型例子。 +在线处理与批处理的边界并不总是清晰:一个运行很久的数据库查询,看起来也很像批处理过程。但批处理有一些独特特性,使其成为构建可靠、可伸缩、可维护应用的重要积木。例如,它常在 **数据集成(data integration)** 中发挥作用,即把多个数据系统组合起来完成单一系统做不到的事。ETL(见[“数据仓库”](/ch1#sec_introduction_dwh))就是典型例子。 现代批处理深受 MapReduce 影响。Google 在 2004 年发表了这一批处理算法 [^3],随后 Hadoop、CouchDB、MongoDB 等开源系统都实现了它。MapReduce 是相对底层的编程模型,其能力不如数据仓库中的并行查询执行引擎成熟 [^4] [^5]。它在诞生时确实让商用硬件上的处理规模跃升一大步,但今天已大体过时,Google 内部也不再使用 [^6] [^7]。 @@ -59,7 +59,7 @@ breadcrumbs: false $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" -这表示:UTC 时间 2025 年 6 月 27 日 17:55:11,服务器收到来自客户端 IP `216.58.210.78` 对 `/css/typography.css` 的请求。用户未认证,因此 `$remote_user` 是连字符(`-`)。响应状态码是 200(成功),响应体大小 3,377 字节。浏览器是 Chrome 137,该文件是从页面 *[*https://martin.kleppmann.com/*](https://martin.kleppmann.com/)* 引用而来。 +这表示:UTC 时间 2025 年 6 月 27 日 17:55:11,服务器收到来自客户端 IP `216.58.210.78` 对 `/css/typography.css` 的请求。用户未认证,因此 `$remote_user` 是连字符(`-`)。响应状态码是 200(成功),响应体大小 3,377 字节。浏览器是 Chrome 137,该文件是从页面 **[*https://martin.kleppmann.com/*](https://martin.kleppmann.com/)** 引用而来。 看起来“解析日志”有点朴素,但它在现代科技公司里是核心能力之一,从广告流水线到支付处理都大量依赖。事实上,这也是 MapReduce 与“大数据”浪潮快速兴起的重要推动力。 @@ -78,7 +78,7 @@ cat /var/log/nginx/access.log | #1 1. 读取日志文件。(严格说这里不需要 `cat`,可直接把文件作为 `awk` 参数;但这样写更直观看出线性管道。) 2. 以空白字符切分每行,只输出第 7 个字段,也就是请求 URL。上面的样例中是 `/css/typography.css`。 -3. 按字典序对 URL 排序。某个 URL 若出现 *n* 次,排序后会连续出现 *n* 行。 +3. 按字典序对 URL 排序。某个 URL 若出现 **n** 次,排序后会连续出现 **n** 行。 4. `uniq` 通过比较相邻两行是否相同来去重。`-c` 让它输出计数:每个不同 URL 出现了多少次。 5. 第二次 `sort` 按每行开头的数字(`-n`)排序,并用 `-r` 逆序,出现次数最多的排在最前。 6. `head` 只保留前 5 行(`-n 5`),丢弃其余。 @@ -129,7 +129,7 @@ for count, url in top5: #5 Python 脚本在内存里维护了一个“URL -> 出现次数”的散列表。Unix 管道示例没有这种散列表,而是通过排序把同一 URL 的多次出现排到一起。 -哪种方法更好?取决于不同 URL 的数量。对多数中小网站而言,通常可以把所有不同 URL 及其计数器放进(比如)1GB 内存。这个作业的 *工作集(working set)*(需要随机访问的内存规模)只取决于不同 URL 的个数:即便一百万条日志都指向同一 URL,散列表也只存一个 URL 和一个计数器。工作集足够小时,内存散列表很好用,笔记本都能跑。 +哪种方法更好?取决于不同 URL 的数量。对多数中小网站而言,通常可以把所有不同 URL 及其计数器放进(比如)1GB 内存。这个作业的 **工作集(working set)**(需要随机访问的内存规模)只取决于不同 URL 的个数:即便一百万条日志都指向同一 URL,散列表也只存一个 URL 和一个计数器。工作集足够小时,内存散列表很好用,笔记本都能跑。 但如果工作集大于可用内存,排序法就有优势:它能高效使用磁盘。这与[“日志结构存储”](/ch4#sec_storage_log_structured)中的原理一样:先在内存对数据块排序并写成段文件,再把多个有序段合并成更大的有序文件。归并排序的顺序访问模式对磁盘很友好(见[“SSD 上的顺序写与随机写”](/ch4#sidebar_sequential))。 @@ -160,7 +160,7 @@ Unix 工具的一个局限是它们只在单机运行。当数据大到单机内 大多数物理存储设备不能做“部分块写入”,即使数据不足一个块也得写满块。DFS 的块更大且通常构建在操作系统文件系统之上,因此一般没有这个约束。比如一个 900MB 文件在 128MB 分块下,会有 7 个 128MB 块和 1 个 4MB 块。 -读取 DFS 块需要通过网络请求到持有该块的集群节点。每台机器都运行守护进程,对外提供 API,使远程进程能把本地文件系统中的块当作文件读写。HDFS 把这些守护进程叫 DataNode,GlusterFS 叫 glusterfsd。后文统称 *数据节点(data node)*。 +读取 DFS 块需要通过网络请求到持有该块的集群节点。每台机器都运行守护进程,对外提供 API,使远程进程能把本地文件系统中的块当作文件读写。HDFS 把这些守护进程叫 DataNode,GlusterFS 叫 glusterfsd。后文统称 **数据节点(data node)**。 DFS 也实现了“分布式版本”的页缓存。因为 DFS 块作为文件存放在数据节点本地,读写会经过数据节点操作系统,自带内存页缓存,热门块会被缓存在内存中。某些 DFS 还提供更多缓存层,例如 JuiceFS 的客户端缓存和本地磁盘缓存。 @@ -173,13 +173,13 @@ DFS 也实现了“分布式版本”的页缓存。因为 DFS 块作为文件 > [!TIP] 分布式文件系统与网络存储 > 分布式文件系统基于 *无共享(shared-nothing)* 原则(见[“共享内存、共享磁盘与无共享架构”](/ch2#sec_introduction_shared_nothing)),与 NAS(网络附加存储)和 SAN(存储区域网络)等 *共享磁盘* 方案形成对照。共享磁盘通常依赖集中式存储设备、定制硬件和专用网络(如光纤通道);无共享方案不要求专用硬件,只需普通数据中心网络互联的机器。 -很多 DFS 构建在商用硬件上,成本更低但故障率高于企业级专用硬件。为容忍机器和磁盘故障,文件块通常复制到多台机器。这也让调度器更容易均衡负载:任务可在任一持有输入副本的节点运行。复制可以是多副本(见[第六章](/ch6#ch_replication)),也可以是 Reed-Solomon 等 *纠删码* 方案,以更低存储开销恢复丢失数据 [^10] [^11] [^12]。这与 RAID 思想类似,只是 RAID 面向同一机器上的多块磁盘,而 DFS 是通过普通数据中心网络跨机器做访问和复制。 +很多 DFS 构建在商用硬件上,成本更低但故障率高于企业级专用硬件。为容忍机器和磁盘故障,文件块通常复制到多台机器。这也让调度器更容易均衡负载:任务可在任一持有输入副本的节点运行。复制可以是多副本(见[第六章](/ch6#ch_replication)),也可以是 Reed-Solomon 等 **纠删码** 方案,以更低存储开销恢复丢失数据 [^10] [^11] [^12]。这与 RAID 思想类似,只是 RAID 面向同一机器上的多块磁盘,而 DFS 是通过普通数据中心网络跨机器做访问和复制。 ### 对象存储 {#id277} Amazon S3、Google Cloud Storage、Azure Blob Storage、OpenStack Swift 等对象存储,已成为批处理场景中对 DFS 的主流替代。实际上两者边界越来越模糊:正如前一节和[“由对象存储支撑的数据库”](/ch6#sec_replication_object_storage)所述,FUSE 可以把 S3 这类对象存储“挂载成文件系统”;JuiceFS、Ceph 等系统也同时提供对象 API 与文件系统 API。但这些接口、性能、以及一致性保证差异很大,即便 API 看似兼容,也需要仔细验证行为是否符合预期。 -对象存储中的每个对象有一个 URL,例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。其中主机部分(`my-photo-bucket`)是 bucket 名,后半部分是对象 *键(key)*(示例里是 `/2025/04/01/birthday.png`)。bucket 名全局唯一;对象键在 bucket 内必须唯一。 +对象存储中的每个对象有一个 URL,例如 `s3://my-photo-bucket/2025/04/01/birthday.png`。其中主机部分(`my-photo-bucket`)是 bucket 名,后半部分是对象 **键(key)**(示例里是 `/2025/04/01/birthday.png`)。bucket 名全局唯一;对象键在 bucket 内必须唯一。 对象读取用 `get`,写入用 `put`。与文件系统文件不同,对象写入后通常不可变;更新对象需要通过 `put` 全量重写,类似键值存储。Azure Blob Storage 和 S3 Express One Zone 支持追加,但多数对象存储不支持。它也没有 `fopen`、`fseek` 这类文件句柄 API。 @@ -212,9 +212,9 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等编排器 任务执行器(Task executors) -: 每个节点上运行执行器守护进程,例如 YARN 的 *NodeManager* 或 Kubernetes 的 *kubelet*。执行器负责拉起任务、通过心跳上报存活状态、跟踪节点上的任务状态与资源占用。收到“启动任务”请求后,执行器会获取作业代码并执行启动命令;随后持续监控进程直至结束或失败,并更新对应状态元数据。 +: 每个节点上运行执行器守护进程,例如 YARN 的 **NodeManager** 或 Kubernetes 的 **kubelet**。执行器负责拉起任务、通过心跳上报存活状态、跟踪节点上的任务状态与资源占用。收到“启动任务”请求后,执行器会获取作业代码并执行启动命令;随后持续监控进程直至结束或失败,并更新对应状态元数据。 - 很多执行器还配合操作系统实现安全与性能隔离,例如 YARN 和 Kubernetes 都会使用 Linux *cgroups*。这样可防止任务越权访问数据,或因资源滥用影响同机其他任务。 + 很多执行器还配合操作系统实现安全与性能隔离,例如 YARN 和 Kubernetes 都会使用 Linux **cgroups**。这样可防止任务越权访问数据,或因资源滥用影响同机其他任务。 资源管理器(Resource Manager) @@ -236,20 +236,20 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等编排器 假设一个小集群有 5 个节点,共 160 个 CPU 核。调度器收到两个作业请求,每个都想要 100 核。怎么排最好? - 可以给每个作业先分 80 个任务,剩余 20 个等前面的任务结束后再启动。 -- 也可以先跑完其中一个作业,再等 100 核都空出来后跑另一个。这叫 *gang scheduling*(成组调度)。 +- 也可以先跑完其中一个作业,再等 100 核都空出来后跑另一个。这叫 **gang scheduling**(成组调度)。 - 如果一个请求先到,调度器还要决定是立即把 100 核都给它,还是为未来请求预留一部分资源。 这是很简化的例子,但已经能看到艰难权衡。以成组调度为例,如果调度器为了凑齐 100 核而长期预留资源,节点会闲置,资源利用率下降,若其他作业也在抢占式预留,还可能死锁。 -反过来,如果只是被动等 100 核“自然可用”,中间可能被别的作业拿走,导致长时间凑不齐,从而产生 *饥饿(starvation)*。调度器也可以 *抢占(preempt)* 一部分先到作业任务,把它们杀掉给后到作业腾资源;但被杀任务之后还要重跑,整体效率同样下降。 +反过来,如果只是被动等 100 核“自然可用”,中间可能被别的作业拿走,导致长时间凑不齐,从而产生 **饥饿(starvation)**。调度器也可以 **抢占(preempt)** 一部分先到作业任务,把它们杀掉给后到作业腾资源;但被杀任务之后还要重跑,整体效率同样下降。 -把这个问题放大到数百甚至数百万个请求,想求全局最优几乎不可行。事实上这是 *NP-hard* 问题:除了很小规模,很难在可接受时间内算出最优解 [^14] [^15]。 +把这个问题放大到数百甚至数百万个请求,想求全局最优几乎不可行。事实上这是 **NP-hard** 问题:除了很小规模,很难在可接受时间内算出最优解 [^14] [^15]。 因此工程上调度器通常采用启发式方法,在非最优前提下做“足够好”的决策。常见算法包括 FIFO、主导资源公平(DRF)、优先级队列、容量/配额调度、各种装箱算法等。细节超出本书范围,但这是非常有趣的研究领域。 #### 工作流调度 {#sec_batch_workflows} -本章开头的 Unix 示例是多个命令串联。分布式批处理中同样常见:一个作业输出要成为一个或多个后续作业输入,而每个作业又可能依赖多个上游输入。这个依赖结构称为 *工作流(workflow)* 或 *有向无环图(DAG)*。 +本章开头的 Unix 示例是多个命令串联。分布式批处理中同样常见:一个作业输出要成为一个或多个后续作业输入,而每个作业又可能依赖多个上游输入。这个依赖结构称为 **工作流(workflow)** 或 **有向无环图(DAG)**。 > [!NOTE] > 我们在[“持久化执行与工作流”](/ch5#sec_encoding_dataflow_workflows)中讨论过“按步骤执行 RPC”的工作流引擎;在批处理语境里,“工作流”指的是一串批处理过程:每一步读输入、产输出,通常不直接对外做 RPC。持久化执行引擎通常单次请求处理的数据量小于批处理系统,但两者边界并非绝对。 @@ -260,7 +260,7 @@ Kubernetes、Hadoop YARN(Yet Another Resource Negotiator)[^13] 等编排器 - 你可能要在多个处理工具间传递数据。比如 Spark 作业写 HDFS,再由 Python 触发 Trino SQL 查询(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))继续处理并写入 S3。 - 有些流水线内部天然需要多阶段。例如第一阶段按某键分片,下一阶段按另一键分片,那么第一阶段需要先产出符合第二阶段要求的数据布局。 -在 Unix 里,管道用很小的内存缓冲连接前后命令,不落盘。若缓冲区满,上游必须等待下游消费,这是一种 *背压(backpressure)*。Spark、Flink 等批处理执行引擎也支持类似模式:一个任务输出直接传给下一任务(跨机时经网络传输)。 +在 Unix 里,管道用很小的内存缓冲连接前后命令,不落盘。若缓冲区满,上游必须等待下游消费,这是一种 **背压(backpressure)**。Spark、Flink 等批处理执行引擎也支持类似模式:一个任务输出直接传给下一任务(跨机时经网络传输)。 但在工作流中,更常见仍是“上游作业写 DFS/对象存储,下游再读”,这样可让作业在时间上解耦。若一个作业有多个输入,工作流调度器通常会等待所有上游输入生产成功后再启动它。 @@ -270,7 +270,7 @@ YARN ResourceManager 或 Spark 内置调度器主要做“作业内调度”, 批处理作业往往运行时间长。长时间运行且并行任务多的作业,在执行过程中遇到至少一次任务失败几乎是常态。正如[“硬件与软件故障”](/ch2#sec_introduction_hardware_faults)和[“不可靠网络”](/ch9#sec_distributed_networks)所述,原因可能是硬件故障(商用硬件尤甚)、网络中断等。 -任务无法完成的另一原因是被调度器主动抢占(kill)。当系统有多优先级队列时,这很常见:低优先级任务便宜、高优先级任务昂贵。低优先级任务可用空闲算力跑,但高优先级任务一到就可能把它们抢占掉。云厂商的对应产品名分别是:AWS 的 *spot instances*、Azure 的 *spot virtual machines*、GCP 的 *preemptible instances* [^16]。 +任务无法完成的另一原因是被调度器主动抢占(kill)。当系统有多优先级队列时,这很常见:低优先级任务便宜、高优先级任务昂贵。低优先级任务可用空闲算力跑,但高优先级任务一到就可能把它们抢占掉。云厂商的对应产品名分别是:AWS 的 **spot instances**、Azure 的 **spot virtual machines**、GCP 的 **preemptible instances** [^16]。 批处理很多时候对实时性要求不高,因此很适合利用低优先级资源/抢占式实例降成本:本质上它在“吃”否则会闲置的算力,提高集群利用率。但代价是更高的被杀概率:实际里抢占往往比硬件故障更常见 [^17]。 @@ -290,7 +290,7 @@ MapReduce 与数据流引擎都发展出多种编程接口:低层 API、关系 MapReduce 的处理模式与[“简单日志分析”](#sec_batch_log_analysis)几乎同构: -1. 读取输入文件并切分为 *记录(records)*。在日志例子里,每条记录就是一行(`\n` 为记录分隔符)。在 Hadoop MapReduce 中,输入通常存放在 HDFS 或 S3 等对象存储,文件格式可能是 Parquet(列式,见[“面向列存储”](/ch4#sec_storage_column))或 Avro(行式,见[“Avro”](/ch5#sec_encoding_avro))。 +1. 读取输入文件并切分为 **记录(records)**。在日志例子里,每条记录就是一行(`\n` 为记录分隔符)。在 Hadoop MapReduce 中,输入通常存放在 HDFS 或 S3 等对象存储,文件格式可能是 Parquet(列式,见[“面向列存储”](/ch4#sec_storage_column))或 Avro(行式,见[“Avro”](/ch5#sec_encoding_avro))。 2. 调用 mapper,从每条输入记录中提取键和值。Unix 示例中 mapper 相当于 `awk '{print $7}'`:URL(`$7`)是键,值可留空。 3. 按键排序所有键值对。日志示例中这一步对应第一次 `sort`。 4. 调用 reducer 遍历排序后的键值对。同键记录会相邻,因此可以在很小内存状态下合并。Unix 示例中 reducer 等价于 `uniq -c`,统计相邻同键记录数。 @@ -318,7 +318,7 @@ Reducer 为解决 MapReduce 的局限,出现了多种分布式批处理执行引擎,最著名的是 Spark [^18] [^21] 和 Flink [^19]。它们设计细节各异,但有一个共同点:把整条工作流当成一个作业处理,而不是拆成互相独立的小作业。 -因为它们显式建模了跨多个处理阶段的数据流动,所以称为 *数据流引擎(dataflow engines)*。与 MapReduce 一样,它们提供低层 API(反复调用用户函数逐条处理记录),也提供更高层算子(如 *join*、*group by*)。它们通过分片并行输入,并通过网络把一个任务输出传给另一个任务输入。与 MapReduce 不同,算子不必严格在 map/reduce 两类角色间交替,而可以更灵活组合。 +因为它们显式建模了跨多个处理阶段的数据流动,所以称为 **数据流引擎(dataflow engines)**。与 MapReduce 一样,它们提供低层 API(反复调用用户函数逐条处理记录),也提供更高层算子(如 **join**、**group by**)。它们通过分片并行输入,并通过网络把一个任务输出传给另一个任务输入。与 MapReduce 不同,算子不必严格在 map/reduce 两类角色间交替,而可以更灵活组合。 这些 API 通常以关系风格构件表达计算:按字段值连接数据集、按键分组、按条件过滤、按计数或求和等函数聚合。内部实现依赖的正是下一节要讲的混洗算法。 @@ -335,26 +335,26 @@ Reducer ### 混洗数据 {#sec_shuffle} -本章开头的 Unix 工具示例和 MapReduce 都建立在排序之上。批处理系统要能排序 PB 级数据,单机放不下,因此必须使用“输入与输出都分片”的分布式排序算法,这就是 *混洗(shuffle)*。 +本章开头的 Unix 工具示例和 MapReduce 都建立在排序之上。批处理系统要能排序 PB 级数据,单机放不下,因此必须使用“输入与输出都分片”的分布式排序算法,这就是 **混洗(shuffle)**。 > [!NOTE] 混洗不是随机 > “shuffle” 容易引发误解。洗牌会得到随机顺序;而这里的 shuffle 产出的是排序后的确定顺序,不含随机性。 混洗是批处理系统的基础算法,连接与聚合都依赖它。MapReduce、Spark、Flink、Daft、Dataflow、BigQuery [^24] 都实现了高可伸缩且高性能的混洗机制以处理大数据集。这里用 Hadoop MapReduce 的混洗实现做说明 [^25],但核心思想在其他系统同样适用。 -[图 11-1](#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](#fig_batch_mapreduce)中的 *m1,r2*:由 mapper1 生成,目标是 reducer2)。mapper 每输出一条键值对,通常会按键的哈希决定写入哪个 reducer 文件(类似[“按键哈希分片”](/ch7#sec_sharding_hash))。 +mapper 输出是键值对。框架需要保证:若不同 mapper 输出了同一个键,这些键值对最终必须由同一个 reducer 处理。为此,每个 mapper 会在本地磁盘为每个 reducer 维护一个输出文件(例如[图 11-1](#fig_batch_mapreduce)中的 **m1,r2**:由 mapper1 生成,目标是 reducer2)。mapper 每输出一条键值对,通常会按键的哈希决定写入哪个 reducer 文件(类似[“按键哈希分片”](/ch7#sec_sharding_hash))。 mapper 写这些文件的同时,也会在每个文件内部按键排序。可用的正是[“日志结构存储”](/ch4#sec_storage_log_structured)中的技术:先在内存有序结构里积累一批键值对,写成有序段文件,再把小段逐步合并成大段。 每个 mapper 完成后,reducer 会连接到 mapper,把属于自己的有序文件拷贝到本地磁盘。reducer 拿到所有 mapper 的对应分片后,再用归并排序方式合并它们并保持有序。同键记录即便来自不同 mapper,也会在合并后相邻。随后 reducer 以“每个键一次调用”的方式执行,每次拿到一个可迭代器,遍历该键所有值。 -reducer 输出记录会顺序写入文件,每个 reduce 任务一个文件。[图 11-1](#fig_batch_mapreduce)中的 *r1*、*r2*、*r3* 就是输出数据集的分片,最终写回 DFS 或对象存储。 +reducer 输出记录会顺序写入文件,每个 reduce 任务一个文件。[图 11-1](#fig_batch_mapreduce)中的 **r1**、**r2**、**r3** 就是输出数据集的分片,最终写回 DFS 或对象存储。 MapReduce 在 map 与 reduce 之间执行混洗;现代数据流引擎和云数据仓库则更复杂。BigQuery 等系统已优化混洗,使数据尽量留在内存,并写入外部排序服务 [^24],以提升速度并通过复制增强韧性。 @@ -362,7 +362,7 @@ MapReduce 在 map 与 reduce 之间执行混洗;现代数据流引擎和云数 下面看“有序数据”如何简化分布式连接与聚合。为便于说明仍以 MapReduce 为例,但概念适用于大多数批处理系统。 -批处理里常见连接场景见[图 11-2](#fig_batch_join_example)。左边是用户活动日志(*activity events* 或 *clickstream data*),右边是用户数据库。它可以看作星型模型的一部分(见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):活动日志是事实表,用户库是维度表之一。 +批处理里常见连接场景见[图 11-2](#fig_batch_join_example)。左边是用户活动日志(**activity events** 或 **clickstream data**),右边是用户数据库。它可以看作星型模型的一部分(见[“星型与雪花型:分析模式”](/ch3#sec_datamodels_analytics)):活动日志是事实表,用户库是维度表之一。 {{< figure src="/fig/ddia_1102.png" id="fig_batch_join_example" caption="图 11-2. 用户活动日志与用户画像数据库的连接。" class="w-full my-4" >}} @@ -372,11 +372,11 @@ MapReduce 在 map 与 reduce 之间执行混洗;现代数据流引擎和云数 {{< figure src="/fig/ddia_1103.png" id="fig_batch_join_reduce" caption="图 11-3. 基于用户 ID 的排序合并连接。若输入数据集由多个文件分片组成,可并行启动多个 mapper 处理。" class="w-full my-4" >}} -混洗保证 reducer 能同时拿到某用户的出生日期和该用户全部页面访问事件。MapReduce 甚至可以把记录进一步排成 reducer 先看到用户记录、再按时间戳看到活动事件,这称为 *二次排序(secondary sort)* [^25]。 +混洗保证 reducer 能同时拿到某用户的出生日期和该用户全部页面访问事件。MapReduce 甚至可以把记录进一步排成 reducer 先看到用户记录、再按时间戳看到活动事件,这称为 **二次排序(secondary sort)** [^25]。 -于是 reducer 很容易实现连接逻辑:先拿到出生日期并存入局部变量,再遍历同一用户 ID 的活动事件,输出“被访问 URL + 访问者出生日期”。因为 reducer 一次处理一个用户的全部记录,所以内存里只要保留一条用户记录,也无需发任何网络请求。这个算法称为 *排序合并连接(sort-merge join)*:mapper 输出先按键排序,reducer 再把连接两侧有序记录合并。 +于是 reducer 很容易实现连接逻辑:先拿到出生日期并存入局部变量,再遍历同一用户 ID 的活动事件,输出“被访问 URL + 访问者出生日期”。因为 reducer 一次处理一个用户的全部记录,所以内存里只要保留一条用户记录,也无需发任何网络请求。这个算法称为 **排序合并连接(sort-merge join)**:mapper 输出先按键排序,reducer 再把连接两侧有序记录合并。 -工作流中的下一个 MapReduce 作业就可以继续计算“每个 URL 的访问者年龄分布”:先按 URL 做一次混洗,再在 reducer 中遍历同 URL 的所有访问记录(含出生日期),按年龄段维护计数并逐条累加,从而实现 *group by* 与聚合。 +工作流中的下一个 MapReduce 作业就可以继续计算“每个 URL 的访问者年龄分布”:先按 URL 做一次混洗,再在 reducer 中遍历同 URL 的所有访问记录(含出生日期),按年龄段维护计数并逐条累加,从而实现 **group by** 与聚合。 ### 查询语言 {#sec_batch_query_lanauges} @@ -436,13 +436,13 @@ Daft 等框架甚至同时支持客户端与服务端计算:小规模内存操 我们也看到,批处理在“出错后排障与修复”方面很友好,这对调试数据流水线极其关键。失败文件可直接检查,ETL 作业可修复后重跑。比如输入文件不再包含某个转换逻辑依赖字段,数据工程师就能据此更新转换逻辑或修复上游生产作业。 -过去数据流水线往往由单一数据工程团队集中维护,因为让产品团队自行编写和维护复杂批流水线不太现实。近年随着处理模型和元数据管理改进,组织内更多团队都能参与并维护自己的流水线。*data mesh* [^35] [^36]、*data contract* [^37]、*data fabric* [^38] 等实践,正通过规范和工具帮助团队安全发布可被全组织消费的数据。 +过去数据流水线往往由单一数据工程团队集中维护,因为让产品团队自行编写和维护复杂批流水线不太现实。近年随着处理模型和元数据管理改进,组织内更多团队都能参与并维护自己的流水线。**data mesh** [^35] [^36]、**data contract** [^37]、**data fabric** [^38] 等实践,正通过规范和工具帮助团队安全发布可被全组织消费的数据。 如今数据流水线与分析查询不仅共享处理模型,也常共享执行引擎。很多 ETL 作业与消费其输出的分析查询都运行在同一系统里:例如同样以 SparkSQL、Trino 或 DuckDB 查询执行。这样的架构进一步模糊了应用工程、数据工程、分析工程与业务分析之间的界限。 ### 分析(Analytics) {#sec_batch_olap} -在[“操作型系统与分析型系统”](/ch1#sec_introduction_analytics)中我们看到,分析查询(OLAP)通常要扫描大量记录并做分组聚合。这类负载可以与其他批任务一起运行在批处理系统中。分析人员写 SQL,经查询引擎执行,读写底层 DFS 或对象存储。表到文件映射、名称、类型等表元数据通常由 Apache Iceberg 等表格式与 Unity 等 catalog 管理(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))。这种架构称为 *数据湖仓(data lakehouse)* [^39]。 +在[“操作型系统与分析型系统”](/ch1#sec_introduction_analytics)中我们看到,分析查询(OLAP)通常要扫描大量记录并做分组聚合。这类负载可以与其他批任务一起运行在批处理系统中。分析人员写 SQL,经查询引擎执行,读写底层 DFS 或对象存储。表到文件映射、名称、类型等表元数据通常由 Apache Iceberg 等表格式与 Unity 等 catalog 管理(见[“云数据仓库”](/ch4#sec_cloud_data_warehouses))。这种架构称为 **数据湖仓(data lakehouse)** [^39]。 与 ETL 类似,SQL 接口改进让很多组织用 Spark 等批框架直接承载分析。常见模式有两类: @@ -463,7 +463,7 @@ SQL 支持还让批处理系统更易接入电子表格与可视化工具,如 推荐系统和排序系统等 ML 应用也大量使用图处理(见[“图状数据模型”](/ch3#sec_datamodels_graph))。许多图算法表达为“沿边逐步传播信息并反复迭代”:把一个顶点与相邻顶点连接,传递某些信息,重复直到满足停止条件,例如无边可继续,或某个指标收敛。 -*批同步并行(bulk synchronous parallel, BSP)* 计算模型 [^40] 已成为批图计算常用模型。Apache Giraph [^20]、Spark GraphX、Flink Gelly [^41] 等都实现了它。它也常被称为 *Pregel* 模型,因为 Google 的 Pregel 论文让这一方法广为人知 [^42]。 +**批同步并行(bulk synchronous parallel, BSP)** 计算模型 [^40] 已成为批图计算常用模型。Apache Giraph [^20]、Spark GraphX、Flink Gelly [^41] 等都实现了它。它也常被称为 **Pregel** 模型,因为 Google 的 Pregel 论文让这一方法广为人知 [^42]。 批处理同样是大语言模型(LLM)数据准备与训练的重要组成部分。网页等原始文本通常存放在 DFS 或对象存储中,必须先预处理才能用于训练。适合批处理框架的预处理步骤包括: @@ -473,7 +473,7 @@ SQL 支持还让批处理系统更易接入电子表格与可视化工具,如 Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例,ChatGPT 训练流程中就使用了 Ray [^43]。这些框架通常内置与 PyTorch、TensorFlow、XGBoost 等 LLM/AI 库的集成,并支持特征工程、模型训练、批量推理、微调等能力。 -最后,数据科学家常在 Jupyter、Hex 等交互式 Notebook 中实验数据。Notebook 由多个 *cell* 组成,每个 cell 是一小段 Markdown、Python 或 SQL;按顺序执行可得到表格、图表或数据结果。很多 Notebook 背后通过 DataFrame API 或 SQL 调用批处理系统。 +最后,数据科学家常在 Jupyter、Hex 等交互式 Notebook 中实验数据。Notebook 由多个 **cell** 组成,每个 cell 是一小段 Markdown、Python 或 SQL;按顺序执行可得到表格、图表或数据结果。很多 Notebook 背后通过 DataFrame API 或 SQL 调用批处理系统。 ### 对外提供派生数据 {#sec_batch_serving_derived} @@ -492,7 +492,7 @@ Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例 - 一个批作业输出可被多个下游系统同时消费; - 流系统还可作为批处理网络与生产网络之间的安全边界(可部署在 DMZ)。 -但“经由流”并不会自动解决“全有或全无”语义。要实现这一点,批作业需要在完成后向下游发出“作业完成,可对外可见”的通知。流消费者需要像 *读已提交(read committed)* 事务那样,在收到完成通知前让新数据对查询不可见(见[“读已提交”](/ch8#sec_transactions_read_committed))。 +但“经由流”并不会自动解决“全有或全无”语义。要实现这一点,批作业需要在完成后向下游发出“作业完成,可对外可见”的通知。流消费者需要像 **读已提交(read committed)** 事务那样,在收到完成通知前让新数据对查询不可见(见[“读已提交”](/ch8#sec_transactions_read_committed))。 另一种在数据库冷启动(bootstrap)时更常见的模式,是在批作业内直接构建一个全新数据库,再把文件从 DFS、对象存储或本地文件系统批量导入目标数据库。很多系统都提供这类批量导入工具,如 TiDB Lightning、Apache Pinot/Apache Druid 的 Hadoop 导入作业,RocksDB 也提供从批作业批量导入 SST 的 API。 @@ -517,7 +517,7 @@ Kubeflow、Flyte、Ray 等框架就专为这类负载构建。以 OpenAI 为例 - 机器学习:用于准备与处理大规模训练数据; - 把批处理输出灌入面向生产流量的系统:常通过流系统或批量导入工具,把派生数据提供给用户。 -下一章我们将转向流处理。与批处理不同,流处理输入是 *无界(unbounded)* 的:作业仍在,但输入是持续不断的数据流,因此作业不会“完成”。我们会看到,流处理与批处理在一些方面很相似,但“输入无界”这一前提也会显著改变系统设计。 +下一章我们将转向流处理。与批处理不同,流处理输入是 **无界(unbounded)** 的:作业仍在,但输入是持续不断的数据流,因此作业不会“完成”。我们会看到,流处理与批处理在一些方面很相似,但“输入无界”这一前提也会显著改变系统设计。 ### 参考文献 {#references} diff --git a/content/zh/ch12.md b/content/zh/ch12.md index 03eba6e..7bf2a65 100644 --- a/content/zh/ch12.md +++ b/content/zh/ch12.md @@ -583,8 +583,8 @@ CEP 的实现包括 Esper、Apama 和 TIBCO StreamBase。像 Flink 和 Spark Str * 当用户 u 发送新的推文时,它将被添加到每个关注用户 u 的时间线上。 * 用户删除推文时,推文将从所有用户的时间线中删除。 -* 当用户 *u*~1~ 开始关注用户 *u*~2~ 时,*u*~2~ 最近的推文将被添加到 *u*~1~ 的时间线上。 -* 当用户 *u*~1~ 取消关注用户 *u*~2~ 时,*u*~2~ 的推文将从 *u*~1~ 的时间线中移除。 +* 当用户 **u**~1~ 开始关注用户 **u**~2~ 时,**u**~2~ 最近的推文将被添加到 **u**~1~ 的时间线上。 +* 当用户 **u**~1~ 取消关注用户 **u**~2~ 时,**u**~2~ 的推文将从 **u**~1~ 的时间线中移除。 要在流处理器中实现这种缓存维护,你需要推文事件流(发送与删除)和关注关系事件流(关注与取消关注)。流处理需要维护一个数据库,包含每个用户的粉丝集合,以便知道当一条新推文到达时,需要更新哪些时间线。 diff --git a/content/zh/ch14.md b/content/zh/ch14.md index 20ef809..1b9f0fb 100644 --- a/content/zh/ch14.md +++ b/content/zh/ch14.md @@ -80,9 +80,9 @@ breadcrumbs: false ### 监视 {#id374} -做个思想实验:把 *data* 一词替换为 *surveillance*(监视),看看常见说法是否还那么“好听” [^23]。例如:“在我们这个监视驱动的组织中,我们收集实时监视流并存入监视仓库。我们的监视科学家使用先进的分析与监视处理来产出新洞见。” +做个思想实验:把 **data** 一词替换为 **surveillance**(监视),看看常见说法是否还那么“好听” [^23]。例如:“在我们这个监视驱动的组织中,我们收集实时监视流并存入监视仓库。我们的监视科学家使用先进的分析与监视处理来产出新洞见。” -这个思想实验对本书来说少见地带有一点论战色彩,仿佛书名成了《设计监视密集型应用》(*Designing Surveillance-Intensive Applications*)。但为了强调这一点,我们需要更尖锐的词。在我们试图让软件“吞噬世界” [^24] 的过程中,我们构建了人类有史以来规模最大的群体监视基础设施。我们正快速接近这样一个世界:几乎每个有人居住的空间都至少有一个联网麦克风,存在于智能手机、智能电视、语音助手设备、婴儿监视器,甚至使用云语音识别的儿童玩具中。许多这类设备的安全记录都非常糟糕 [^25]。 +这个思想实验对本书来说少见地带有一点论战色彩,仿佛书名成了《设计监视密集型应用》(**Designing Surveillance-Intensive Applications**)。但为了强调这一点,我们需要更尖锐的词。在我们试图让软件“吞噬世界” [^24] 的过程中,我们构建了人类有史以来规模最大的群体监视基础设施。我们正快速接近这样一个世界:几乎每个有人居住的空间都至少有一个联网麦克风,存在于智能手机、智能电视、语音助手设备、婴儿监视器,甚至使用云语音识别的儿童玩具中。许多这类设备的安全记录都非常糟糕 [^25]。 与过去相比,新变化在于:数字化让大规模收集人的数据变得很容易。对我们位置与行动轨迹、社交关系与通信、购买与支付、健康信息的监视,几乎已不可避免。一个监视型组织最终掌握的个人信息,甚至可能比当事人自己知道的还多——例如,在当事人意识到之前就识别出其疾病或经济困境。 @@ -102,15 +102,15 @@ breadcrumbs: false 此外,数据从用户身上被抽取是单向过程,不是具有真实互惠的关系,也不是公平的价值交换。这里没有对话,没有让用户协商“提供多少数据、换取什么服务”的空间:服务与用户之间的关系高度不对称、单向度。规则由服务制定,而非用户 [^30], [^31]。 -在欧盟,《通用数据保护条例》(GDPR)要求同意必须是 “freely given, specific, informed, and unambiguous”,并且用户必须能够 “refuse or withdraw consent without detriment”——否则不被视为 “freely given”。任何征求同意的请求都必须以 “an intelligible and easily accessible form, using clear and plain language” 撰写。此外,“silence, pre-ticked boxes or inactivity \[do not\] constitute consent” [^32]。除同意外,个人数据处理还可基于其他合法基础,例如 *legitimate interest*,它允许某些数据用途,如防欺诈 [^33]。 +在欧盟,《通用数据保护条例》(GDPR)要求同意必须是 “freely given, specific, informed, and unambiguous”,并且用户必须能够 “refuse or withdraw consent without detriment”——否则不被视为 “freely given”。任何征求同意的请求都必须以 “an intelligible and easily accessible form, using clear and plain language” 撰写。此外,“silence, pre-ticked boxes or inactivity \[do not\] constitute consent” [^32]。除同意外,个人数据处理还可基于其他合法基础,例如 **legitimate interest**,它允许某些数据用途,如防欺诈 [^33]。 -你可能会说,不同意被监视的用户可以选择不用这项服务。但这种选择同样不自由:如果某项服务流行到“被大多数人视为基本社会参与所必需” [^30],那就不能合理期待人们退出——使用它在事实上成了强制(*de facto* mandatory)。例如,在多数西方社群中,携带智能手机、通过社交网络社交、使用 Google 获取信息,已经成为常态。尤其当服务具有网络效应时,选择 *不* 使用它会付出社会成本。 +你可能会说,不同意被监视的用户可以选择不用这项服务。但这种选择同样不自由:如果某项服务流行到“被大多数人视为基本社会参与所必需” [^30],那就不能合理期待人们退出——使用它在事实上成了强制(**de facto** mandatory)。例如,在多数西方社群中,携带智能手机、通过社交网络社交、使用 Google 获取信息,已经成为常态。尤其当服务具有网络效应时,选择 **不** 使用它会付出社会成本。 因为追踪政策而拒绝使用某服务,说起来容易做起来难。这些平台本来就是为吸引用户而设计的。许多平台使用游戏机制和赌博常见策略来让用户反复回来 [^34]。即便用户能克服这一点,拒绝参与也往往只是少数特权人群的选项:他们有时间和知识去理解隐私政策,也有能力承担潜在代价——比如错过本可通过该服务获得的社会参与或职业机会。对于处境更不利的人来说,并不存在真正意义上的选择自由:监视变得无可逃避。 ### 隐私与数据使用 {#id457} -有时有人声称“隐私已死”,理由是某些用户愿意在社交媒体上发布各种生活内容,有些琐碎,有些极度私密。但这个说法是错误的,它建立在对 *privacy* 一词的误解之上。 +有时有人声称“隐私已死”,理由是某些用户愿意在社交媒体上发布各种生活内容,有些琐碎,有些极度私密。但这个说法是错误的,它建立在对 **privacy** 一词的误解之上。 拥有隐私并不意味着把一切都保密;它意味着拥有选择自由:哪些内容向谁披露、哪些公开、哪些保密。隐私权是一种决策权:它让每个人在每种情境中,决定自己在“保密”与“透明”光谱上的位置 [^30]。这是个体自由与自主性的重要组成部分。 @@ -122,7 +122,7 @@ breadcrumbs: false 即便特定用户无法从某条广告所面向的人群桶中被个人重识别,他们仍失去了对某些私密信息披露的主导权。决定“向谁披露什么”不再基于用户自己的偏好,而是公司在行使这种隐私权,目标是利润最大化。 -许多公司追求的目标是“不被 *感知* 为令人不适”,回避“数据收集到底有多侵入”这一问题,转而专注于管理用户感知。而且就连这种感知管理也常常做得不好:例如,某些内容也许在事实层面是正确的,但若会触发痛苦记忆,用户可能并不想被提醒 [^35]。面对任何数据,我们都应预期它可能出错、不可取或在某些情况下不合适,并且需要构建机制来处理这些失效。至于什么算“不可取”或“不合适”,当然属于人的判断;算法除非被我们显式编程去尊重人的需要,否则对这些概念是无感的。作为这些系统的工程师,我们必须保持谦逊,接受并预先规划这些失效。 +许多公司追求的目标是“不被 **感知** 为令人不适”,回避“数据收集到底有多侵入”这一问题,转而专注于管理用户感知。而且就连这种感知管理也常常做得不好:例如,某些内容也许在事实层面是正确的,但若会触发痛苦记忆,用户可能并不想被提醒 [^35]。面对任何数据,我们都应预期它可能出错、不可取或在某些情况下不合适,并且需要构建机制来处理这些失效。至于什么算“不可取”或“不合适”,当然属于人的判断;算法除非被我们显式编程去尊重人的需要,否则对这些概念是无感的。作为这些系统的工程师,我们必须保持谦逊,接受并预先规划这些失效。 在线服务里的隐私设置,允许用户控制其数据的哪些方面可被其他用户看到,这是把部分控制权还给用户的起点。然而,不管设置如何,服务本身仍可不受限制地访问这些数据,并可在隐私政策允许范围内任意使用。即使服务承诺不把数据出售给第三方,通常也会赋予自己在内部处理和分析数据的广泛权利,而这种处理常常远远超出用户可见范围。 @@ -138,7 +138,7 @@ breadcrumbs: false 因为数据有价值,很多人都想要它。公司当然想要——这本就是它们收集数据的原因。政府也想拿到:通过秘密交易、胁迫、法律强制,或者直接窃取 [^37]。当公司破产时,其收集的个人数据会作为资产被出售。并且,数据很难彻底保护,泄露事件频发得令人不安。 -这些观察促使批评者说,数据不只是资产,还是“有毒资产”(*toxic asset*) [^37],或者至少是“危险材料”(*hazardous material*) [^38]。也许数据不是“新黄金”、不是“新石油”,而是“新铀” [^39]。即使我们认为自己有能力防止数据滥用,每次收集数据时也必须权衡收益与其落入错误之手的风险:计算机系统可能被犯罪分子或敌对外国情报机构攻破,数据可能被内部人员泄露,公司可能落入与我们价值观不一致的管理层手中,或国家可能被一个毫无顾忌、会强迫我们交出数据的政权接管。 +这些观察促使批评者说,数据不只是资产,还是“有毒资产”(**toxic asset**) [^37],或者至少是“危险材料”(**hazardous material**) [^38]。也许数据不是“新黄金”、不是“新石油”,而是“新铀” [^39]。即使我们认为自己有能力防止数据滥用,每次收集数据时也必须权衡收益与其落入错误之手的风险:计算机系统可能被犯罪分子或敌对外国情报机构攻破,数据可能被内部人员泄露,公司可能落入与我们价值观不一致的管理层手中,或国家可能被一个毫无顾忌、会强迫我们交出数据的政权接管。 收集数据时,我们不仅要考虑今天的政治环境,还要考虑未来所有可能的政府。无法保证未来每一届政府都会尊重人权与公民自由,因此,“安装那些未来可能助长警察国家的技术,是糟糕的公民卫生习惯” [^40]。 diff --git a/content/zh/ch2.md b/content/zh/ch2.md index 6ce4ea3..801f03b 100644 --- a/content/zh/ch2.md +++ b/content/zh/ch2.md @@ -39,7 +39,7 @@ breadcrumbs: false {{< figure src="/fig/ddia_0201.png" id="fig_twitter_relational" caption="图 2-1. 社交网络的简单关系模式,用户可以相互关注。" class="w-full my-4" >}} -假设该社交网络最重要的读操作是 *首页时间线*:展示你所追随的人最近发布的帖子(为简化起见,我们忽略广告、未追随用户的推荐帖,以及其他扩展功能)。获取某个用户首页时间线的 SQL 可能如下: +假设该社交网络最重要的读操作是 **首页时间线**:展示你所追随的人最近发布的帖子(为简化起见,我们忽略广告、未追随用户的推荐帖,以及其他扩展功能)。获取某个用户首页时间线的 SQL 可能如下: ```sql SELECT posts.*, users.* FROM posts @@ -52,7 +52,7 @@ SELECT posts.*, users.* FROM posts 要执行此查询,数据库将使用 `follows` 表找到 `current_user` 关注的所有人,查找这些用户最近的帖子,并按时间戳排序以获取被关注用户的最新 1,000 条帖子。 -帖子具有时效性。我们假设:某人发帖后,追随者应在 5 秒内看到。一个做法是客户端每 5 秒重复执行一次上述查询(即 *轮询*)。如果同时在线登录用户有 1000 万,就意味着每秒要执行 200 万次查询。即使把轮询间隔调大,这个量也很可观。 +帖子具有时效性。我们假设:某人发帖后,追随者应在 5 秒内看到。一个做法是客户端每 5 秒重复执行一次上述查询(即 **轮询**)。如果同时在线登录用户有 1000 万,就意味着每秒要执行 200 万次查询。即使把轮询间隔调大,这个量也很可观。 此外,这个查询本身也很昂贵。若你追随 200 人,系统就要分别抓取这 200 人的近期帖子列表,再把它们归并。每秒 200 万次时间线查询,等价于数据库每秒要执行约 4 亿次“按发送者查最近帖子”。这还只是平均情况。少数用户会追随数万账户,这个查询对他们尤其昂贵,也更难做快。 @@ -62,7 +62,7 @@ SELECT posts.*, users.* FROM posts 设想我们为每个用户维护一个数据结构,保存其首页时间线,也就是其所追随者的近期帖子。每当用户发帖,我们就找出其所有追随者,把这条帖子插入每个追随者的首页时间线中,就像往邮箱里投递信件。这样用户登录时,可以直接读取预先算好的时间线。若要接收新帖提醒,客户端只需订阅“写入该时间线”的帖子流即可。 -这种方法的缺点是:每次发帖时都要做更多工作,因为首页时间线属于需要持续更新的派生数据。这个过程见 [图 2-2](#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" >}} @@ -70,7 +70,7 @@ SELECT posts.*, users.* FROM posts 如果遇到特殊事件导致发帖速率激增,我们不必立刻完成时间线投递。可以先入队,接受“帖子出现在追随者时间线中”会暂时变慢。即便在这种峰值期,时间线加载仍然很快,因为读取仍来自缓存。 -这种预先计算并持续更新查询结果的过程称为 *物化*。时间线缓存就是一种 *物化视图*(这个概念见 [“维护物化视图”](/ch12#sec_stream_mat_view))。物化视图能加速读取,但代价是写入侧工作量增加。对大多数用户而言,这个写入成本仍可接受,但社交网络还要处理一些极端情况: +这种预先计算并持续更新查询结果的过程称为 **物化**。时间线缓存就是一种 **物化视图**(这个概念见 [“维护物化视图”](/ch12#sec_stream_mat_view))。物化视图能加速读取,但代价是写入侧工作量增加。对大多数用户而言,这个写入成本仍可接受,但社交网络还要处理一些极端情况: * 如果某用户追随了大量账户,且这些账户发帖频繁,那么该用户的物化时间线写入率会很高。但在这种场景下,用户通常也看不完全部帖子,因此可以丢弃部分时间线写入,只展示其追随账户帖子的一部分样本 [^5]。 * 如果一个拥有海量追随者的名人账号发帖,我们需要把这条帖子写入其数百万追随者的首页时间线,工作量极大。此时不能随意丢写。常见做法是把名人帖子与普通帖子分开处理:名人帖单独存储,读取时间线时再与物化时间线合并,从而省去写入数百万条时间线的成本。即便如此,服务名人账号仍需大量基础设施 [^6]。 @@ -83,11 +83,11 @@ SELECT posts.*, users.* FROM posts : 从用户发出请求到收到响应所经历的时间。单位是秒(或毫秒、微秒)。 吞吐量 -: 系统每秒可处理的请求数或数据量。对于给定硬件资源,系统存在一个可处理的 *最大吞吐量*。单位是“每秒某种工作量”。 +: 系统每秒可处理的请求数或数据量。对于给定硬件资源,系统存在一个可处理的 **最大吞吐量**。单位是“每秒某种工作量”。 在社交网络案例中,“每秒帖子数”和“每秒时间线写入数”属于吞吐量指标;“加载首页时间线所需时间”或“帖子送达追随者所需时间”属于响应时间指标。 -吞吐量和响应时间之间通常相关。在线服务的典型关系如 [图 2-3](#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" >}} @@ -97,13 +97,13 @@ SELECT posts.*, users.* FROM posts > [!TIP] 当过载系统无法恢复时 -如果系统已接近过载、吞吐量逼近极限,有时会进入恶性循环:效率下降,进而更加过载。例如,请求队列很长时,响应时间可能高到让客户端超时并重发请求,导致请求速率进一步上升,问题持续恶化,形成 *重试风暴*。即使负载后来回落,系统也可能仍卡在过载状态,直到重启或重置。这种现象叫 *亚稳态故障*(Metastable Failure),可能引发严重生产故障 [^7] [^8]。 +如果系统已接近过载、吞吐量逼近极限,有时会进入恶性循环:效率下降,进而更加过载。例如,请求队列很长时,响应时间可能高到让客户端超时并重发请求,导致请求速率进一步上升,问题持续恶化,形成 **重试风暴**。即使负载后来回落,系统也可能仍卡在过载状态,直到重启或重置。这种现象叫 **亚稳态故障**(Metastable Failure),可能引发严重生产故障 [^7] [^8]。 -为了避免重试把服务拖垮,可以在客户端拉大并随机化重试间隔(*指数退避* [^9] [^10]),并临时停止向近期报错或超时的服务发请求(例如 *熔断器* [^11] [^12] 或 *令牌桶* [^13])。服务端也可在接近过载时主动拒绝请求(*负载卸除* [^14]),并通过响应要求客户端降速(*背压* [^1] [^15])。此外,排队与负载均衡算法的选择也会影响结果 [^16]。 +为了避免重试把服务拖垮,可以在客户端拉大并随机化重试间隔(**指数退避** [^9] [^10]),并临时停止向近期报错或超时的服务发请求(例如 **熔断器** [^11] [^12] 或 **令牌桶** [^13])。服务端也可在接近过载时主动拒绝请求(**负载卸除** [^14]),并通过响应要求客户端降速(**背压** [^1] [^15])。此外,排队与负载均衡算法的选择也会影响结果 [^16]。 -------- -从性能指标角度看,用户通常最关心响应时间;而吞吐量决定了所需计算资源(例如服务器数量),从而决定承载特定工作负载的成本。如果吞吐量增长可能超过当前硬件上限,就必须扩容;若系统可通过增加计算资源显著提升最大吞吐量,就称其 *可伸缩*。 +从性能指标角度看,用户通常最关心响应时间;而吞吐量决定了所需计算资源(例如服务器数量),从而决定承载特定工作负载的成本。如果吞吐量增长可能超过当前硬件上限,就必须扩容;若系统可通过增加计算资源显著提升最大吞吐量,就称其 **可伸缩**。 本节主要讨论响应时间;吞吐量与可伸缩性会在 ["可伸缩性"](#sec_introduction_scalability) 一节再展开。 @@ -111,10 +111,10 @@ SELECT posts.*, users.* FROM posts “延迟”和“响应时间”有时会混用,但本书对它们有明确区分(见 [图 2-4](#fig_response_time)): -* *响应时间* 是客户端看到的总时间,包含链路上各处产生的全部延迟。 -* *服务时间* 是服务主动处理该请求的时间。 -* *排队延迟* 可发生在流程中的多个位置。例如请求到达后,可能要等 CPU 空出来才能处理;同机其他任务若占满出站网卡,响应包也可能先在缓冲区等待发送。 -* *延迟* 是对“请求未被主动处理这段时间”的统称,也就是请求处于 *潜伏(latent)* 状态的时间。尤其是 *网络延迟*(或网络时延)指请求与响应在网络中传播所花的时间。 +* **响应时间** 是客户端看到的总时间,包含链路上各处产生的全部延迟。 +* **服务时间** 是服务主动处理该请求的时间。 +* **排队延迟** 可发生在流程中的多个位置。例如请求到达后,可能要等 CPU 空出来才能处理;同机其他任务若占满出站网卡,响应包也可能先在缓冲区等待发送。 +* **延迟** 是对“请求未被主动处理这段时间”的统称,也就是请求处于 **潜伏(latent)** 状态的时间。尤其是 **网络延迟**(或网络时延)指请求与响应在网络中传播所花的时间。 {{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="图 2-4. 响应时间、服务时间、网络延迟和排队延迟。" class="w-full my-4" >}} @@ -122,21 +122,21 @@ SELECT posts.*, users.* FROM posts 即便反复发送同一个请求,响应时间也可能显著波动。许多因素都会引入随机延迟:例如切换到后台进程、网络丢包与 TCP 重传、垃圾回收暂停、缺页导致的磁盘读取、服务器机架机械振动 [^17] 等。我们会在 ["超时与无界延迟"](/ch9#sec_distributed_queueing) 进一步讨论这个问题。 -排队延迟常常是响应时间波动的主要来源。服务器并行处理能力有限(例如受 CPU 核数约束),少量慢请求就可能堵住后续请求,这就是 *头部阻塞*。即便后续请求本身服务时间很短,客户端仍会因为等待前序请求而看到较慢的总体响应。排队延迟不属于服务时间,因此必须在客户端侧测量响应时间。 +排队延迟常常是响应时间波动的主要来源。服务器并行处理能力有限(例如受 CPU 核数约束),少量慢请求就可能堵住后续请求,这就是 **头部阻塞**。即便后续请求本身服务时间很短,客户端仍会因为等待前序请求而看到较慢的总体响应。排队延迟不属于服务时间,因此必须在客户端侧测量响应时间。 ### 平均值、中位数与百分位点 {#id24} -由于响应时间会随请求变化,我们应将其看作一个可测量的 *分布*,而非单一数字。在 [图 2-5](#fig_lognormal) 中,每个灰色柱表示一次请求,柱高是该请求耗时。大多数请求较快,但会有少量更慢的 *异常值*。网络时延波动也常称为 *抖动*。 +由于响应时间会随请求变化,我们应将其看作一个可测量的 **分布**,而非单一数字。在 [图 2-5](#fig_lognormal) 中,每个灰色柱表示一次请求,柱高是该请求耗时。大多数请求较快,但会有少量更慢的 **异常值**。网络时延波动也常称为 **抖动**。 {{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="图 2-5. 说明平均值和百分位点:100 个服务请求的响应时间样本。" class="w-full my-4" >}} -报告服务 *平均* 响应时间很常见(严格说是 *算术平均值*:总响应时间除以请求数)。平均值对估算吞吐量上限有帮助 [^18]。但若你想知道“典型”响应时间,平均值并不理想,因为它不能反映到底有多少用户经历了这种延迟。 +报告服务 **平均** 响应时间很常见(严格说是 **算术平均值**:总响应时间除以请求数)。平均值对估算吞吐量上限有帮助 [^18]。但若你想知道“典型”响应时间,平均值并不理想,因为它不能反映到底有多少用户经历了这种延迟。 -通常,*百分位点* 更有意义。把响应时间从快到慢排序,*中位数* 位于中间。例如中位响应时间为 200 毫秒,表示一半请求在 200 毫秒内返回,另一半更慢。因此中位数适合衡量用户“通常要等多久”。中位数也称 *第 50 百分位*,常记为 *p50*。 +通常,**百分位点** 更有意义。把响应时间从快到慢排序,**中位数** 位于中间。例如中位响应时间为 200 毫秒,表示一半请求在 200 毫秒内返回,另一半更慢。因此中位数适合衡量用户“通常要等多久”。中位数也称 **第 50 百分位**,常记为 **p50**。 -为了看清异常值有多糟,需要观察更高百分位点:常见的是 *p95*、*p99*、*p999*。它们表示 95%、99%、99.9% 的请求都快于该阈值。例如 p95 为 1.5 秒,表示 100 个请求里有 95 个小于 1.5 秒,另外 5 个不小于 1.5 秒。[图 2-5](#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]。让这批用户也能获得快速响应,对业务很关键。 +响应时间的高百分位点(也叫 **尾部延迟**)非常重要,因为它直接影响用户体验。例如亚马逊内部服务常以第 99.9 百分位设定响应要求,尽管它只影响 1/1000 的请求。原因是最慢请求往往来自“账户数据最多”的客户,他们通常也是最有价值客户 [^19]。让这批用户也能获得快速响应,对业务很关键。 另一方面,继续优化到第 99.99 百分位(最慢的万分之一请求)通常成本过高、收益有限。越到高百分位,越容易受不可控随机因素影响,也更符合边际收益递减规律。 @@ -156,11 +156,11 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 ### 响应时间指标的应用 {#sec_introduction_slo_sla} -对于“一个终端请求会触发多次后端调用”的服务,高百分位点尤其关键。即使并行调用,终端请求仍要等待最慢的那个返回。正如 [图 2-6](#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" >}} -百分位点也常用于定义 *服务级别目标*(SLO)和 *服务级别协议*(SLA)[^27]。例如,一个 SLO 可能要求:中位响应时间低于 200 毫秒、p99 低于 1 秒,并且至少 99.9% 的有效请求返回非错误响应。SLA 则是“未达成 SLO 时如何处理”的合同条款(例如客户可获赔偿)。这是基本思路;但在实践中,为 SLO/SLA 设计合理可用性指标并不容易 [^28] [^29]。 +百分位点也常用于定义 **服务级别目标**(SLO)和 **服务级别协议**(SLA)[^27]。例如,一个 SLO 可能要求:中位响应时间低于 200 毫秒、p99 低于 1 秒,并且至少 99.9% 的有效请求返回非错误响应。SLA 则是“未达成 SLO 时如何处理”的合同条款(例如客户可获赔偿)。这是基本思路;但在实践中,为 SLO/SLA 设计合理可用性指标并不容易 [^28] [^29]。 -------- @@ -183,25 +183,25 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 * 在预期负载与数据规模下,性能足以支撑目标用例。 * 能防止未授权访问与滥用。 -如果把这些合起来称为“正确工作”,那么 *可靠性* 可以粗略理解为:即使出现问题,系统仍能持续正确工作。为了更精确地描述“出问题”,我们区分 *故障* 与 *失效* [^35] [^36] [^37]: +如果把这些合起来称为“正确工作”,那么 **可靠性** 可以粗略理解为:即使出现问题,系统仍能持续正确工作。为了更精确地描述“出问题”,我们区分 **故障** 与 **失效** [^35] [^36] [^37]: 故障 -: 指系统某个 *局部组件* 停止正常工作:例如单个硬盘损坏、单台机器宕机,或系统依赖的外部服务中断。 +: 指系统某个 **局部组件** 停止正常工作:例如单个硬盘损坏、单台机器宕机,或系统依赖的外部服务中断。 失效 -: 指 *整个系统* 无法继续向用户提供所需服务;换言之,系统未满足服务级别目标(SLO)。 +: 指 **整个系统** 无法继续向用户提供所需服务;换言之,系统未满足服务级别目标(SLO)。 “故障”与“失效”的区别容易混淆,因为它们本质上是同一件事在不同层级上的表述。比如一个硬盘坏了,对“硬盘这个系统”来说是失效;但对“由许多硬盘组成的更大系统”来说,它只是一个故障。更大系统若在其他硬盘上有副本,就可能容忍该故障。 ### 容错 {#id27} -如果系统在发生某些故障时仍继续向用户提供所需的服务,我们称系统为 *容错的*。如果系统不能容忍某个部分变得有故障,我们称该部分为 *单点故障*(SPOF),因为该部分的故障会升级导致整个系统的失效。 +如果系统在发生某些故障时仍继续向用户提供所需的服务,我们称系统为 **容错的**。如果系统不能容忍某个部分变得有故障,我们称该部分为 **单点故障**(SPOF),因为该部分的故障会升级导致整个系统的失效。 -例如在社交网络案例中,扇出流程里可能有机器崩溃或不可用,导致物化时间线更新中断。若要让该流程具备容错性,就必须保证有其他机器可接管任务,同时既不漏投帖子,也不重复投递。(这个思想称为 *恰好一次语义*,我们会在 [“数据库的端到端论证”](/ch13#sec_future_end_to_end) 中详细讨论。) +例如在社交网络案例中,扇出流程里可能有机器崩溃或不可用,导致物化时间线更新中断。若要让该流程具备容错性,就必须保证有其他机器可接管任务,同时既不漏投帖子,也不重复投递。(这个思想称为 **恰好一次语义**,我们会在 [“数据库的端到端论证”](/ch13#sec_future_end_to_end) 中详细讨论。) 容错能力总是“有边界”的:它只针对某些类型、某个数量以内的故障。例如系统可能最多容忍 2 块硬盘同时故障,或 3 个节点里坏 1 个。若全部节点都崩溃,就无计可施,因此“容忍任意数量故障”并无意义。要是地球和上面的服务器都被黑洞吞噬,那就只能去太空托管了,预算审批祝你好运。 -反直觉的是,在这类系统里,故意 *提高* 故障发生率反而有意义,例如无预警随机杀死某个进程。这叫 *故障注入*。许多关键故障本质上是错误处理做得不够好 [^38]。通过主动注入故障,可以持续演练并验证容错机制,提升对“真实故障发生时系统仍能正确处理”的信心。*混沌工程* 就是围绕这类实验建立起来的方法论 [^39]。 +反直觉的是,在这类系统里,故意 **提高** 故障发生率反而有意义,例如无预警随机杀死某个进程。这叫 **故障注入**。许多关键故障本质上是错误处理做得不够好 [^38]。通过主动注入故障,可以持续演练并验证容错机制,提升对“真实故障发生时系统仍能正确处理”的信心。**混沌工程** 就是围绕这类实验建立起来的方法论 [^39]。 尽管我们通常更倾向于“容忍故障”,而非“阻止故障”,但也有“预防优于补救”的场景(例如根本无法补救)。安全问题就是如此:若攻击者已攻破系统并获取敏感数据,事件本身无法撤销。不过,本书主要讨论的是可恢复的故障类型。 @@ -224,11 +224,11 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 当组件故障独立时,冗余最有效,即一个故障的发生不会改变另一个故障发生的可能性。然而,经验表明,组件故障之间通常存在显著的相关性 [^41] [^57] [^58];整个服务器机架或整个数据中心的不可用仍然比我们预期的更频繁地发生。 -硬件冗余确实能提升单机可用时间;但正如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 所述,分布式系统还具备额外优势,例如可容忍整个数据中心中断。因此云系统通常不再过分追求“单机极致可靠”,而是通过软件层容忍节点故障来实现高可用。云厂商使用 *可用区* 标识资源是否物理共址;同一可用区内资源比跨地域资源更容易同时失效。 +硬件冗余确实能提升单机可用时间;但正如 ["分布式与单节点系统"](/ch1#sec_introduction_distributed) 所述,分布式系统还具备额外优势,例如可容忍整个数据中心中断。因此云系统通常不再过分追求“单机极致可靠”,而是通过软件层容忍节点故障来实现高可用。云厂商使用 **可用区** 标识资源是否物理共址;同一可用区内资源比跨地域资源更容易同时失效。 我们在本书中讨论的容错技术旨在容忍整个机器、机架或可用区的丢失。它们通常通过允许一个数据中心的机器在另一个数据中心的机器发生故障或变得不可达时接管来工作。我们将在 [第 6 章](/ch6)、[第 10 章](/ch10) 以及本书的其他各个地方讨论这种容错技术。 -能够容忍整个机器丢失的系统也具有运营优势:如果你需要重新启动机器(例如,应用操作系统安全补丁),单服务器系统需要计划停机时间,而多节点容错系统可以一次修补一个节点,而不影响用户的服务。这称为 *滚动升级*,我们将在 [第 5 章](/ch5) 中进一步讨论它。 +能够容忍整个机器丢失的系统也具有运营优势:如果你需要重新启动机器(例如,应用操作系统安全补丁),单服务器系统需要计划停机时间,而多节点容错系统可以一次修补一个节点,而不影响用户的服务。这称为 **滚动升级**,我们将在 [第 5 章](/ch5) 中进一步讨论它。 #### 软件故障 {#software-faults} @@ -250,11 +250,11 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 遇到这类问题,人们很容易归咎于“人为错误”,并试图通过更严格流程和更强规则约束来控制人。但“责怪个人”通常适得其反。所谓“人为错误”往往不是事故根因,而是社会技术系统本身存在问题的征兆 [^71]。复杂系统里,组件意外交互产生的涌现行为也常导致故障 [^72]。 -有多种技术手段可降低人为失误的影响:充分测试(含手写测试与大量随机输入的 *属性测试*)[^38]、可快速回滚配置变更的机制、新代码渐进发布、清晰细致的监控、用于排查生产问题的可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及鼓励“正确操作”并抑制“错误操作”的良好界面设计。 +有多种技术手段可降低人为失误的影响:充分测试(含手写测试与大量随机输入的 **属性测试**)[^38]、可快速回滚配置变更的机制、新代码渐进发布、清晰细致的监控、用于排查生产问题的可观测性工具(参见 ["分布式系统的问题"](/ch1#sec_introduction_dist_sys_problems)),以及鼓励“正确操作”并抑制“错误操作”的良好界面设计。 但这些措施都需要时间和预算。在日常业务压力下,组织往往优先投入“直接创收”活动,而非提升抗错韧性的建设。若在“更多功能”和“更多测试”之间二选一,很多组织会自然选择前者。既然如此,当可预防错误最终发生时,责怪个人并无意义,问题本质在于组织的优先级选择。 -越来越多组织在实践 *无责备事后分析*:事故发生后,鼓励参与者在不担心惩罚的前提下完整复盘细节,让组织其他人也能学习如何避免类似问题 [^73]。这个过程常会揭示出:业务优先级需要调整、某些长期被忽视的领域需要补投入、相关激励机制需要改,或其他应由管理层关注的系统性问题。 +越来越多组织在实践 **无责备事后分析**:事故发生后,鼓励参与者在不担心惩罚的前提下完整复盘细节,让组织其他人也能学习如何避免类似问题 [^73]。这个过程常会揭示出:业务优先级需要调整、某些长期被忽视的领域需要补投入、相关激励机制需要改,或其他应由管理层关注的系统性问题。 一般来说,调查事故时应警惕“过于简单”的答案。“鲍勃部署时应更小心”没有建设性,“我们必须用 Haskell 重写后端”同样不是。更可行的做法是:管理层借机从一线人员视角理解社会技术系统的真实运行方式,并据此推动改进 [^71]。 @@ -278,7 +278,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 即便系统今天运行可靠,也不代表将来一定如此。性能退化的常见原因之一是负载增长:比如并发用户从 1 万涨到 10 万,或从 100 万涨到 1000 万;也可能是处理的数据规模远大于从前。 -*可伸缩性* 用来描述系统应对负载增长的能力。讨论这个话题时,常有人说:“你又不是 Google/Amazon,别担心规模,直接上关系数据库。”这句话是否成立,取决于你在做什么类型的应用。 +**可伸缩性** 用来描述系统应对负载增长的能力。讨论这个话题时,常有人说:“你又不是 Google/Amazon,别担心规模,直接上关系数据库。”这句话是否成立,取决于你在做什么类型的应用。 如果你在做一个目前用户很少的新产品(例如创业早期),首要工程目标通常是“尽可能简单、尽可能灵活”,以便随着对用户需求理解加深而快速调整产品功能 [^78]。在这种环境下,过早担心“未来也许会有”的规模往往适得其反:最好情况是白费功夫、过早优化;最坏情况是把自己锁进僵化设计,反而阻碍演进。 @@ -303,17 +303,17 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 通常目标是:在尽量降低运行成本的同时,让性能维持在 SLA 要求内(参见 ["响应时间指标的应用"](#sec_introduction_slo_sla))。所需计算资源越多,成本越高。不同硬件的性价比不同,而且会随着新硬件出现而变化。 -如果资源翻倍后能承载两倍负载且性能不变,这称为 *线性可伸缩性*,通常是理想状态。偶尔,借助规模效应或峰值负载更均匀分布,甚至可用不足两倍资源处理两倍负载 [^79] [^80]。但更常见的是成本增长快于线性,低效原因也很多。比如数据量增大后,即使请求大小相同,处理一次写请求也可能比数据量小时更耗资源。 +如果资源翻倍后能承载两倍负载且性能不变,这称为 **线性可伸缩性**,通常是理想状态。偶尔,借助规模效应或峰值负载更均匀分布,甚至可用不足两倍资源处理两倍负载 [^79] [^80]。但更常见的是成本增长快于线性,低效原因也很多。比如数据量增大后,即使请求大小相同,处理一次写请求也可能比数据量小时更耗资源。 ### 共享内存、共享磁盘与无共享架构 {#sec_introduction_shared_nothing} -增加服务硬件资源的最简单方式,是迁移到更强的机器。虽然单核 CPU 不再明显提速,但你仍可购买(或租用)拥有更多 CPU 核心、更多 RAM、更多磁盘的实例。这叫 *纵向伸缩*(scaling up)。 +增加服务硬件资源的最简单方式,是迁移到更强的机器。虽然单核 CPU 不再明显提速,但你仍可购买(或租用)拥有更多 CPU 核心、更多 RAM、更多磁盘的实例。这叫 **纵向伸缩**(scaling up)。 -在单机上,你可以通过多进程/多线程获得并行性。同一进程内线程共享同一块 RAM,因此这也叫 *共享内存架构*。问题是它的成本常常“超线性增长”:硬件资源翻倍的高端机器,价格往往远超两倍;且受限于瓶颈,性能提升通常又达不到两倍。 +在单机上,你可以通过多进程/多线程获得并行性。同一进程内线程共享同一块 RAM,因此这也叫 **共享内存架构**。问题是它的成本常常“超线性增长”:硬件资源翻倍的高端机器,价格往往远超两倍;且受限于瓶颈,性能提升通常又达不到两倍。 -另一种方案是 *共享磁盘架构*:多台机器各有独立 CPU 和 RAM,但共享同一组磁盘阵列,通过高速网络连接(NAS 或 SAN)。该架构传统上用于本地数据仓库场景,但争用与锁开销限制了其可伸缩性 [^81]。 +另一种方案是 **共享磁盘架构**:多台机器各有独立 CPU 和 RAM,但共享同一组磁盘阵列,通过高速网络连接(NAS 或 SAN)。该架构传统上用于本地数据仓库场景,但争用与锁开销限制了其可伸缩性 [^81]。 -相比之下,*无共享架构* [^82](即 *横向伸缩*、scaling out)已广泛流行。这种方案使用多节点分布式系统,每个节点拥有自己的 CPU、RAM 和磁盘;节点间协作通过常规网络在软件层完成。 +相比之下,**无共享架构** [^82](即 **横向伸缩**、scaling out)已广泛流行。这种方案使用多节点分布式系统,每个节点拥有自己的 CPU、RAM 和磁盘;节点间协作通过常规网络在软件层完成。 无共享的优势在于:具备线性伸缩潜力、可灵活选用高性价比硬件(尤其在云上)、更容易随负载增减调整资源,并可通过跨多个数据中心/地域部署提升容错。代价是:需要显式分片(见 [第 7 章](/ch7)),并承担分布式系统的全部复杂性(见 [第 9 章](/ch9))。 @@ -321,7 +321,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 ### 可伸缩性原则 {#id35} -能够大规模运行的系统架构,通常高度依赖具体应用,不存在通用“一招鲜”的可伸缩架构(俗称 *万金油*)。例如:面向“每秒 10 万次请求、每次 1 kB”的系统,与面向“每分钟 3 次请求、每次 2 GB”的系统,形态会完全不同,尽管二者数据吞吐量都约为 100 MB/s。 +能够大规模运行的系统架构,通常高度依赖具体应用,不存在通用“一招鲜”的可伸缩架构(俗称 **万金油**)。例如:面向“每秒 10 万次请求、每次 1 kB”的系统,与面向“每分钟 3 次请求、每次 2 GB”的系统,形态会完全不同,尽管二者数据吞吐量都约为 100 MB/s。 此外,适合某一级负载的架构,通常难以直接承受 10 倍负载。若你在做高速增长服务,几乎每跨一个数量级都要重新审视架构。考虑到业务需求本身也会变化,提前规划超过一个数量级的未来伸缩需求,往往不划算。 @@ -335,7 +335,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 业界普遍认同:软件成本的大头不在初始开发,而在后续维护,包括修 bug、保障系统稳定运行、排查故障、适配新平台、支持新场景、偿还技术债,以及持续交付新功能 [^85] [^86]。 -然而维护并不容易。一个长期运行成功的系统,可能仍依赖今天少有人熟悉的旧技术(如大型机和 COBOL);随着人员流动,系统为何如此设计的组织记忆也可能丢失;维护者往往还要修复前人留下的问题。更重要的是,计算机系统通常与其支撑的组织流程深度耦合,这使得 *遗留* 系统维护既是技术问题,也是人员与组织问题 [^87]。 +然而维护并不容易。一个长期运行成功的系统,可能仍依赖今天少有人熟悉的旧技术(如大型机和 COBOL);随着人员流动,系统为何如此设计的组织记忆也可能丢失;维护者往往还要修复前人留下的问题。更重要的是,计算机系统通常与其支撑的组织流程深度耦合,这使得 **遗留** 系统维护既是技术问题,也是人员与组织问题 [^87]。 如果今天构建的系统足够有价值并长期存活,它终有一天会变成遗留系统。为减少后继维护者的痛苦,我们应在设计阶段就考虑维护性。虽然难以准确预判哪些决策会在未来埋雷,但本书会强调几条广泛适用的原则: @@ -367,7 +367,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 ### 简单性:管理复杂度 {#id38} -小型项目往往能保持简洁、优雅、富有表达力;但项目变大后,代码常会迅速变复杂且难理解。这种复杂性会拖慢所有参与者效率,进一步抬高维护成本。陷入这种状态的软件项目常被称为 *大泥团* [^91]。 +小型项目往往能保持简洁、优雅、富有表达力;但项目变大后,代码常会迅速变复杂且难理解。这种复杂性会拖慢所有参与者效率,进一步抬高维护成本。陷入这种状态的软件项目常被称为 **大泥团** [^91]。 当复杂性让维护变难时,预算和进度常常失控。在复杂软件里,变更时引入缺陷的风险也更高:系统越难理解和推理,隐藏假设、非预期后果和意外交互就越容易被忽略 [^69]。反过来,降低复杂性能显著提升可维护性,因此“追求简单”应是系统设计核心目标之一。 @@ -377,17 +377,17 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 管理复杂度最重要的工具之一是 **抽象**。好的抽象能在清晰外观后隐藏大量实现细节,也能被多种场景复用。这种复用不仅比反复重写更高效,也能提升质量,因为抽象组件一旦改进,所有依赖它的应用都会受益。 -例如,高级语言是对机器码、CPU 寄存器和系统调用的抽象。SQL 则抽象了磁盘/内存中的复杂数据结构、来自其他客户端的并发请求,以及崩溃后的不一致状态。用高级语言编程时,我们仍然在“使用机器码”,但不再 *直接* 面对它,因为语言抽象替我们屏蔽了细节。 +例如,高级语言是对机器码、CPU 寄存器和系统调用的抽象。SQL 则抽象了磁盘/内存中的复杂数据结构、来自其他客户端的并发请求,以及崩溃后的不一致状态。用高级语言编程时,我们仍然在“使用机器码”,但不再 **直接** 面对它,因为语言抽象替我们屏蔽了细节。 -应用代码层面的抽象,常借助 *设计模式* [^95]、*领域驱动设计*(DDD)[^96] 等方法来构建。本书重点不在这类应用专用抽象,而在你可以拿来构建应用的通用抽象,例如数据库事务、索引、事件日志等。若你想采用 DDD 等方法,也可以建立在本书介绍的基础能力之上。 +应用代码层面的抽象,常借助 **设计模式** [^95]、**领域驱动设计**(DDD)[^96] 等方法来构建。本书重点不在这类应用专用抽象,而在你可以拿来构建应用的通用抽象,例如数据库事务、索引、事件日志等。若你想采用 DDD 等方法,也可以建立在本书介绍的基础能力之上。 ### 可演化性:让变化更容易 {#sec_introduction_evolvability} 系统需求永远不变的概率极低。更常见的是持续变化:你会发现新事实,出现此前未预期用例,业务优先级会调整,用户会提出新功能,新平台会替换旧平台,法律与监管会变化,系统增长也会倒逼架构调整。 -在组织层面,*敏捷* 方法为适应变化提供了框架;敏捷社区也发展出多种适用于高变化环境的技术与流程,如测试驱动开发(TDD)和重构。本书关注的是:如何在“由多个不同应用/服务组成的系统层级”提升这种敏捷能力。 +在组织层面,**敏捷** 方法为适应变化提供了框架;敏捷社区也发展出多种适用于高变化环境的技术与流程,如测试驱动开发(TDD)和重构。本书关注的是:如何在“由多个不同应用/服务组成的系统层级”提升这种敏捷能力。 -数据系统对变化的适应难易度,与其简单性和抽象质量高度相关:松耦合、简单系统通常比紧耦合、复杂系统更容易修改。由于这一点极其重要,我们把“数据系统层面的敏捷性”单独称为 *可演化性* [^97]。 +数据系统对变化的适应难易度,与其简单性和抽象质量高度相关:松耦合、简单系统通常比紧耦合、复杂系统更容易修改。由于这一点极其重要,我们把“数据系统层面的敏捷性”单独称为 **可演化性** [^97]。 大型系统中让变更困难的一个关键因素,是某些操作不可逆,因此执行时必须极其谨慎 [^98]。例如从一个数据库迁移到另一个:若新库出问题后无法回切,风险就远高于可随时回退。尽量减少不可逆操作,能显著提升系统灵活性。 @@ -397,7 +397,7 @@ Yahoo 的一项研究 [^25] 在控制搜索结果质量后,比对了快慢加 我们讨论了如何衡量性能(例如响应时间百分位点)、如何描述系统负载(例如吞吐量指标),以及这些指标如何进入 SLA。与之紧密相关的是可伸缩性:当负载增长时,如何保持性能不退化。我们也给出了若干通用原则,例如将任务拆解为可独立运行的小组件。后续章节会深入展开相关技术细节。 -为实现可靠性,可以使用容错机制,使系统在部分组件(如磁盘、机器或外部服务)故障时仍能持续提供服务。我们区分了硬件故障与软件故障,并指出软件故障常更难处理,因为它们往往高度相关。可靠性的另一面是“对人为失误的韧性”,其中 *无责备事后分析* 是重要学习机制。 +为实现可靠性,可以使用容错机制,使系统在部分组件(如磁盘、机器或外部服务)故障时仍能持续提供服务。我们区分了硬件故障与软件故障,并指出软件故障常更难处理,因为它们往往高度相关。可靠性的另一面是“对人为失误的韧性”,其中 **无责备事后分析** 是重要学习机制。 最后,我们讨论了可维护性的多个维度:支持运维工作、管理复杂度、提升系统可演化性。实现这些目标没有银弹,但一个普遍有效的做法是:用清晰、可理解、具备良好抽象的构件来搭建系统。接下来全书会介绍一系列在实践中证明有效的构件。 diff --git a/content/zh/ch3.md b/content/zh/ch3.md index 26b458e..83cb081 100644 --- a/content/zh/ch3.md +++ b/content/zh/ch3.md @@ -58,7 +58,7 @@ breadcrumbs: false 在 2010 年代,**NoSQL** 是试图推翻关系数据库主导地位的最新流行词。 NoSQL 指的不是单一技术,而是围绕新数据模型、模式灵活性、可伸缩性以及向开源许可模式转变的一系列松散的想法。 -一些数据库将自己标榜为 *NewSQL*,因为它们旨在提供 NoSQL 系统的可伸缩性以及传统关系数据库的数据模型和事务保证。 +一些数据库将自己标榜为 **NewSQL**,因为它们旨在提供 NoSQL 系统的可伸缩性以及传统关系数据库的数据模型和事务保证。 NoSQL 和 NewSQL 的想法在数据系统设计中产生了很大的影响,但随着这些原则被广泛采用,这些术语的使用已经减少。 NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将数据表示为 JSON。 @@ -69,7 +69,7 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将 ### 对象关系不匹配 {#sec_datamodels_document} -如今,大部分应用程序开发都是使用面向对象的编程语言完成的,这导致了对 SQL 数据模型的常见批评:如果数据存储在关系表中,则需要在应用程序代码中的对象和数据库的表、行、列模型之间建立一个笨拙的转换层。这种模型之间的脱节有时被称为 *阻抗不匹配*。 +如今,大部分应用程序开发都是使用面向对象的编程语言完成的,这导致了对 SQL 数据模型的常见批评:如果数据存储在关系表中,则需要在应用程序代码中的对象和数据库的表、行、列模型之间建立一个笨拙的转换层。这种模型之间的脱节有时被称为 **阻抗不匹配**。 -------- @@ -86,7 +86,7 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将 * ORM 通常仅用于 OLTP 应用程序开发(参见 ["表征事务处理和分析"](/ch1#sec_introduction_oltp));为分析目的提供数据的数据工程师仍然需要使用底层的关系表示,因此在使用 ORM 时,关系模式的设计仍然很重要。 * 许多 ORM 仅适用于关系型 OLTP 数据库。拥有多样化数据系统(如搜索引擎、图数据库和 NoSQL 系统)的组织可能会发现 ORM 支持不足。 * 一些 ORM 会自动生成关系模式,但这些模式对于直接访问关系数据的用户来说可能很尴尬,并且在底层数据库上可能效率低下。自定义 ORM 的模式和查询生成可能很复杂,并否定了首先使用 ORM 的好处。 -* ORM 使得意外编写低效查询变得容易,例如 *N+1 查询问题* [^7]。例如,假设你想在页面上显示用户评论列表,因此你执行一个返回 *N* 条评论的查询,每条评论都包含其作者的 ID。要显示评论作者的姓名,你需要在用户表中查找 ID。在手写 SQL 中,你可能会在查询中执行此连接并返回每个评论的作者姓名,但使用 ORM 时,你可能最终会为 *N* 条评论中的每一条在用户表上进行单独的查询以查找其作者,总共产生 *N*+1 个数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。 +* ORM 使得意外编写低效查询变得容易,例如 **N+1 查询问题** [^7]。例如,假设你想在页面上显示用户评论列表,因此你执行一个返回 **N** 条评论的查询,每条评论都包含其作者的 ID。要显示评论作者的姓名,你需要在用户表中查找 ID。在手写 SQL 中,你可能会在查询中执行此连接并返回每个评论的作者姓名,但使用 ORM 时,你可能最终会为 **N** 条评论中的每一条在用户表上进行单独的查询以查找其作者,总共产生 **N**+1 个数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。 然而,ORM 也有优势: @@ -98,7 +98,7 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将 并非所有数据都很适合关系表示;让我们通过一个例子来探讨关系模型的局限性。[图 3-1](#fig_obama_relational) 说明了如何在关系模式中表达简历(LinkedIn 个人资料)。整个个人资料可以通过唯一标识符 `user_id` 来识别。像 `first_name` 和 `last_name` 这样的字段每个用户只出现一次,因此它们可以建模为 `users` 表上的列。 -大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 *一对多关系* 的一种方法是将职位、教育和联系信息放在单独的表中,并使用外键引用 `users` 表,如 [图 3-1](#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" >}} @@ -131,7 +131,7 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将 一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在 [第 5 章](/ch5#ch_encoding) 中看到的,JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在 ["文档模型中的模式灵活性"](#sec_datamodels_schema_flexibility) 中讨论这个问题。 -与 [图 3-1](#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 *局部性*(参见 ["读写的数据局部性"](#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](#fig_json_tree))。 @@ -156,11 +156,11 @@ NoSQL 运动的一个持久影响是 **文档模型** 的流行,它通常将 * 本地化支持 —— 当网站被翻译成其他语言时,标准化列表可以被本地化,因此区域可以用查看者的语言显示 * 更好的搜索 —— 例如,搜索美国东海岸的人可以匹配此个人资料,因为区域列表可以编码华盛顿位于东海岸的事实(这从字符串 `"Washington, DC"` 中并不明显) -无论你存储 ID 还是文本字符串,这都是 *规范化* 的问题。当你使用 ID 时,你的数据更加规范化:对人类有意义的信息(如文本 *Washington, DC*)只存储在一个地方,所有引用它的地方都使用 ID(它只在数据库中有意义)。当你直接存储文本时,你在使用它的每条记录中都复制了对人类有意义的信息;这种表示是 *反规范化* 的。 +无论你存储 ID 还是文本字符串,这都是 **规范化** 的问题。当你使用 ID 时,你的数据更加规范化:对人类有意义的信息(如文本 **Washington, DC**)只存储在一个地方,所有引用它的地方都使用 ID(它只在数据库中有意义)。当你直接存储文本时,你在使用它的每条记录中都复制了对人类有意义的信息;这种表示是 **反规范化** 的。 使用 ID 的优势在于,因为它对人类没有意义,所以永远不需要更改:即使它标识的信息发生变化,ID 也可以保持不变。任何对人类有意义的东西将来某个时候可能需要更改 —— 如果该信息被复制,所有冗余副本都需要更新。这需要更多的代码、更多的写操作、更多的磁盘空间,并且存在不一致的风险(其中一些信息副本被更新但其他的没有)。 -规范化表示的缺点是,每次要显示包含 ID 的记录时,都必须进行额外的查找以将 ID 解析为人类可读的内容。在关系数据模型中,这是使用 *连接* 完成的,例如: +规范化表示的缺点是,每次要显示包含 ID 的记录时,都必须进行额外的查找以将 ID 解析为人类可读的内容。在关系数据模型中,这是使用 **连接** 完成的,例如: ```sql SELECT users.*, regions.region_name @@ -213,7 +213,7 @@ SELECT posts.id, posts.sender_id LIMIT 1000 ``` -这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及点赞数和回复数等统计信息),并通过 ID 查找发送者的个人资料(以获取他们的用户名、个人资料图片和其他详细信息)。这个将 ID 补全为人类可读信息的过程称为 *hydrating* ID,本质上是在应用程序代码中执行的连接 [^11]。 +这意味着每当读取时间线时,服务仍然需要执行两个连接:通过 ID 查找帖子以获取实际的帖子内容(以及点赞数和回复数等统计信息),并通过 ID 查找发送者的个人资料(以获取他们的用户名、个人资料图片和其他详细信息)。这个将 ID 补全为人类可读信息的过程称为 **hydrating** ID,本质上是在应用程序代码中执行的连接 [^11]。 在预计算时间线中仅存储 ID 的原因是它们引用的数据变化很快:热门帖子的点赞数和回复数可能每秒变化多次,一些用户定期更改他们的用户名或个人资料照片。由于时间线在查看时应该显示最新的点赞数和个人资料图片,因此将此信息反规范化到物化时间线中是没有意义的。此外,这种反规范化会显著增加存储成本。 @@ -223,9 +223,9 @@ SELECT posts.id, posts.sender_id ### 多对一与多对多关系 {#sec_datamodels_many_to_many} -虽然 [图 3-1](#fig_obama_relational) 中的 `positions` 和 `education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),但 `region_id` 字段是 *多对一* 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。 +虽然 [图 3-1](#fig_obama_relational) 中的 `positions` 和 `education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),但 `region_id` 字段是 **多对一** 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。 -如果我们为组织和学校引入实体,并通过 ID 从简历中引用它们,那么我们也有 *多对多* 关系(一个人曾为多个组织工作,一个组织有多个过去或现在的员工)。在关系模型中,这种关系通常表示为 *关联表* 或 *连接表*,如 [图 3-3](#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" >}} @@ -250,21 +250,21 @@ SELECT posts.id, posts.sender_id 多对多关系通常需要"双向"查询:例如,找到特定人员工作过的所有组织,以及找到在特定组织工作过的所有人员。启用此类查询的一种方法是在两边都存储 ID 引用,即简历包含该人工作过的每个组织的 ID,组织文档包含提到该组织的简历的 ID。这种表示是反规范化的,因为关系存储在两个地方,可能会相互不一致。 -规范化表示仅在一个地方存储关系,并依赖 *二级索引*(我们将在 [第 4 章](/ch4#ch_storage) 中讨论)来允许有效地双向查询关系。在 [图 3-3](#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id` 和 `org_id` 列上创建索引。 +规范化表示仅在一个地方存储关系,并依赖 **二级索引**(我们将在 [第 4 章](/ch4#ch_storage) 中讨论)来允许有效地双向查询关系。在 [图 3-3](#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id` 和 `org_id` 列上创建索引。 在 [示例 3-2](#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和具有 JSON 支持的关系数据库能够在文档内的值上创建此类索引。 ### 星型与雪花型:分析模式 {#sec_datamodels_analytics} -数据仓库(参见 ["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,并且数据仓库中表结构有一些广泛使用的约定:*星型模式*、*雪花模式*、*维度建模* [^12],以及 *一张大表*(OBT)。这些结构针对业务分析师的需求进行了优化。ETL 过程将来自运营系统的数据转换为此模式。 +数据仓库(参见 ["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,并且数据仓库中表结构有一些广泛使用的约定:**星型模式**、**雪花模式**、**维度建模** [^12],以及 **一张大表**(OBT)。这些结构针对业务分析师的需求进行了优化。ETL 过程将来自运营系统的数据转换为此模式。 -[图 3-5](#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" >}} 通常,事实被捕获为单个事件,因为这允许以后最大的分析灵活性。然而,这意味着事实表可能变得非常大。一个大型企业可能在其数据仓库中有许多 PB 的交易历史,主要表示为事实表。 -事实表中的一些列是属性,例如产品售出的价格和从供应商那里购买它的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 *维度表*。由于事实表中的每一行代表一个事件,维度代表事件的 *谁*、*什么*、*哪里*、*何时*、*如何* 和 *为什么*。 +事实表中的一些列是属性,例如产品售出的价格和从供应商那里购买它的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 **维度表**。由于事实表中的每一行代表一个事件,维度代表事件的 **谁**、**什么**、**哪里**、**何时**、**如何** 和 **为什么**。 例如,在 [图 3-5](#fig_dwh_schema) 中,其中一个维度是售出的产品。`dim_product` 表中的每一行代表一种待售产品类型,包括其库存单位(SKU)、描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中售出了哪种产品。查询通常涉及对多个维度表的多个连接。 @@ -272,13 +272,13 @@ SELECT posts.id, posts.sender_id [图 3-5](#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;到这些表的连接就像星星的光芒。 -这个模板的一个变体被称为 *雪花模式*,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。 +这个模板的一个变体被称为 **雪花模式**,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。 在典型的数据仓库中,表通常非常宽:事实表通常有超过 100 列,有时有几百列。维度表也可能很宽,因为它们包括所有可能与分析相关的元数据 —— 例如,`dim_store` 表可能包括每个商店提供哪些服务的详细信息、是否有店内面包房、平方英尺、商店首次开业的日期、最后一次改造的时间、距离最近的高速公路有多远等。 星型或雪花模式主要由多对一关系组成(例如,许多销售发生在一个特定产品,在一个特定商店),表示为事实表对维度表的外键,或维度对子维度的外键。原则上,其他类型的关系可能存在,但它们通常被反规范化以简化查询。例如,如果客户一次购买多种不同的产品,则该多项交易不会被明确表示;相反,事实表中为每个购买的产品都有一个单独的行,这些事实都恰好具有相同的客户 ID、商店 ID 和时间戳。 -一些数据仓库模式进一步进行反规范化,完全省略维度表,将维度中的信息折叠到事实表上的反规范化列中(本质上是预计算事实表和维度表之间的连接)。这种方法被称为 *一张大表*(OBT),虽然它需要更多的存储空间,但有时可以实现更快的查询 [^13]。 +一些数据仓库模式进一步进行反规范化,完全省略维度表,将维度中的信息折叠到事实表上的反规范化列中(本质上是预计算事实表和维度表之间的连接)。这种方法被称为 **一张大表**(OBT),虽然它需要更多的存储空间,但有时可以实现更快的查询 [^13]。 在分析的背景下,这种反规范化是没有问题的,因为数据通常代表不会改变的历史数据日志(除了偶尔纠正错误)。OLTP 系统中反规范化出现的数据一致性和写入开销问题在分析中并不那么紧迫。 @@ -286,7 +286,7 @@ SELECT posts.id, posts.sender_id 文档数据模型的主要论点是模式灵活性、由于局部性而获得更好的性能,以及对于某些应用程序来说,它更接近应用程序使用的对象模型。关系模型通过为连接、多对一和多对多关系提供更好的支持来反击。让我们更详细地研究这些论点。 -如果你的应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 *切碎*(shredding)为多个表的关系技术(如 [图 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂的应用程序代码。 +如果你的应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 **切碎**(shredding)为多个表的关系技术(如 [图 3-1](#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要复杂的应用程序代码。 文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而是需要说类似"用户 251 的职位列表中的第二项"之类的话。如果你确实需要引用嵌套项,关系方法效果更好,因为你可以通过其 ID 直接引用任何项。 @@ -296,7 +296,7 @@ SELECT posts.id, posts.sender_id 大多数文档数据库以及关系数据库中的 JSON 支持不会对文档中的数据强制执行任何模式。关系数据库中的 XML 支持通常带有可选的模式验证。没有模式意味着可以将任意键和值添加到文档中,并且在读取时,客户端不能保证文档可能包含哪些字段。 -文档数据库有时被称为 *无模式*,但这是误导性的,因为读取数据的代码通常假设某种结构 —— 即存在隐式模式,但数据库不强制执行 [^17]。更准确的术语是 *读时模式*(数据的结构是隐式的,只有在读取数据时才解释),与 *写时模式*(关系数据库的传统方法,其中模式是显式的,数据库确保所有数据在写入时都符合它)形成对比 [^18]。 +文档数据库有时被称为 **无模式**,但这是误导性的,因为读取数据的代码通常假设某种结构 —— 即存在隐式模式,但数据库不强制执行 [^17]。更准确的术语是 **读时模式**(数据的结构是隐式的,只有在读取数据时才解释),与 **写时模式**(关系数据库的传统方法,其中模式是显式的,数据库确保所有数据在写入时都符合它)形成对比 [^18]。 读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。正如静态和动态类型检查的倡导者对它们的相对优点有很大的争论 [^19],数据库中模式的强制执行是一个有争议的话题,通常没有正确或错误的答案。 @@ -309,7 +309,7 @@ if (user && user.name && !user.first_name) { } ``` -这种方法的缺点是,从数据库读取的应用程序的每个部分现在都需要处理可能很久以前写入的旧格式的文档。另一方面,在写时模式数据库中,你通常会执行 *迁移*,如下所示: +这种方法的缺点是,从数据库读取的应用程序的每个部分现在都需要处理可能很久以前写入的旧格式的文档。另一方面,在写时模式数据库中,你通常会执行 **迁移**,如下所示: ```sql ALTER TABLE users ADD COLUMN first_name text DEFAULT NULL; @@ -330,11 +330,11 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL #### 读写的数据局部性 {#sec_datamodels_document_locality} -文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON)。如果你的应用程序经常需要访问整个文档(例如,在网页上渲染它),则这种 *存储局部性* 具有性能优势。如果数据分布在多个表中,如 [图 3-1](#fig_obama_relational) 所示,则需要多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。 +文档通常存储为单个连续字符串,编码为 JSON、XML 或二进制变体(如 MongoDB 的 BSON)。如果你的应用程序经常需要访问整个文档(例如,在网页上渲染它),则这种 **存储局部性** 具有性能优势。如果数据分布在多个表中,如 [图 3-1](#fig_obama_relational) 所示,则需要多次索引查找才能检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。 局部性优势仅在你同时需要文档的大部分时才适用。数据库通常需要加载整个文档,如果你只需要访问大文档的一小部分,这可能会浪费。在文档更新时,通常需要重写整个文档。由于这些原因,通常建议你保持文档相当小,并避免频繁对文档进行小的更新。 -然而,将相关数据存储在一起以获得局部性的想法并不限于文档模型。例如,Google 的 Spanner 数据库在关系数据模型中提供相同的局部性属性,允许模式声明表的行应该交错(嵌套)在父表中 [^25]。Oracle 允许相同的功能,使用称为 *多表索引集群表* 的功能 [^26]。由 Google 的 Bigtable 推广并在 HBase 和 Accumulo 等中使用的 *宽列* 数据模型具有 *列族* 的概念,其目的类似于管理局部性 [^27]。 +然而,将相关数据存储在一起以获得局部性的想法并不限于文档模型。例如,Google 的 Spanner 数据库在关系数据模型中提供相同的局部性属性,允许模式声明表的行应该交错(嵌套)在父表中 [^25]。Oracle 允许相同的功能,使用称为 **多表索引集群表** 的功能 [^26]。由 Google 的 Bigtable 推广并在 HBase 和 Accumulo 等中使用的 **宽列** 数据模型具有 **列族** 的概念,其目的类似于管理局部性 [^27]。 #### 文档的查询语言 {#query-languages-for-documents} @@ -393,7 +393,7 @@ db.observations.aggregate([ 但是,如果你的数据中多对多关系非常常见呢?关系模型可以处理多对多关系的简单情况,但随着数据内部连接变得更加复杂,开始将数据建模为图变得更加自然。 -图由两种对象组成:*顶点*(也称为 *节点* 或 *实体*)和 *边*(也称为 *关系* 或 *弧*)。许多类型的数据可以建模为图。典型的例子包括: +图由两种对象组成:**顶点**(也称为 **节点** 或 **实体**)和 **边**(也称为 **关系** 或 **弧**)。许多类型的数据可以建模为图。典型的例子包括: 社交图 : 顶点是人,边表示哪些人相互认识。 @@ -406,14 +406,14 @@ db.observations.aggregate([ 众所周知的算法可以在这些图上运行:例如,地图导航应用程序搜索道路网络中两点之间的最短路径,PageRank 可用于网页图以确定网页的受欢迎程度,从而确定其在搜索结果中的排名 [^32]。 -图可以用几种不同的方式表示。在 *邻接表* 模型中,每个顶点存储其相距一条边的邻居顶点的 ID。或者,你可以使用 *邻接矩阵*,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见 ["数据框、矩阵与数组"](#sec_datamodels_dataframes))。 +图可以用几种不同的方式表示。在 **邻接表** 模型中,每个顶点存储其相距一条边的邻居顶点的 ID。或者,你可以使用 **邻接矩阵**,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接表适合图遍历,矩阵适合机器学习(参见 ["数据框、矩阵与数组"](#sec_datamodels_dataframes))。 -在刚才给出的示例中,图中的所有顶点都表示相同类型的事物(分别是人、网页或道路交叉点)。然而,图不限于这种 *同质* 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如: +在刚才给出的示例中,图中的所有顶点都表示相同类型的事物(分别是人、网页或道路交叉点)。然而,图不限于这种 **同质** 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如: * Facebook 维护一个包含许多不同类型顶点和边的单一图:顶点表示人员、位置、事件、签到和用户发表的评论;边表示哪些人彼此是朋友、哪个签到发生在哪个位置、谁评论了哪个帖子、谁参加了哪个事件等等 [^33]。 * 知识图被搜索引擎用来记录搜索查询中经常出现的实体(如组织、人员和地点)的事实 [^34]。这些信息通过爬取和分析网站上的文本获得;一些网站(如 Wikidata)也以结构化形式发布图数据。 -在图中构建和查询数据有几种不同但相关的方式。在本节中,我们将讨论 *属性图* 模型(由 Neo4j、Memgraph、KùzuDB [^35] 和其他 [^36] 实现)和 *三元组存储* 模型(由 Datomic、AllegroGraph、Blazegraph 和其他实现)。这些模型在它们可以表达的内容方面相当相似,一些图数据库(如 Amazon Neptune)支持两种模型。 +在图中构建和查询数据有几种不同但相关的方式。在本节中,我们将讨论 **属性图** 模型(由 Neo4j、Memgraph、KùzuDB [^35] 和其他 [^36] 实现)和 **三元组存储** 模型(由 Datomic、AllegroGraph、Blazegraph 和其他实现)。这些模型在它们可以表达的内容方面相当相似,一些图数据库(如 Amazon Neptune)支持两种模型。 我们还将查看图的四种查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及用于查询图的 SQL 支持。还存在其他图查询语言,如 Gremlin [^37],但这些将为我们提供代表性的概述。 @@ -423,7 +423,7 @@ db.observations.aggregate([ ### 属性图 {#id56} -在 *属性图*(也称为 *标记属性图*)模型中,每个顶点包含: +在 **属性图**(也称为 **标记属性图**)模型中,每个顶点包含: * 唯一标识符 * 标签(字符串),描述此顶点表示的对象类型 @@ -434,8 +434,8 @@ db.observations.aggregate([ 每条边包含: * 唯一标识符 -* 边开始的顶点(*尾顶点*) -* 边结束的顶点(*头顶点*) +* 边开始的顶点(**尾顶点**) +* 边结束的顶点(**头顶点**) * 描述两个顶点之间关系类型的标签 * 属性集合(键值对) @@ -465,7 +465,7 @@ CREATE INDEX edges_heads ON edges (head_vertex); 此模型的一些重要方面是: 1. 任何顶点都可以有一条边将其与任何其他顶点连接。没有限制哪些类型的事物可以或不能关联的模式。 -2. 给定任何顶点,你可以有效地找到其入边和出边,从而 *遍历* 图 —— 即通过顶点链跟随路径 —— 向前和向后。(这就是为什么 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。) +2. 给定任何顶点,你可以有效地找到其入边和出边,从而 **遍历** 图 —— 即通过顶点链跟随路径 —— 向前和向后。(这就是为什么 [示例 3-3](#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。) 3. 通过对不同类型的顶点和关系使用不同的标签,你可以在单个图中存储几种不同类型的信息,同时仍保持简洁的数据模型。 边表就像我们在 ["多对一与多对多关系"](#sec_datamodels_many_to_many) 中看到的多对多关联表/连接表,泛化为允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。 @@ -477,13 +477,13 @@ CREATE INDEX edges_heads ON edges (head_vertex); -------- -这些功能为数据建模提供了极大的灵活性,如 [图 3-6](#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有 *省* 和 *大区*,而美国有 *县* 和 *州*)、历史的怪癖(如国中之国)(暂时忽略主权国家和民族的复杂性),以及不同粒度的数据(Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。 +这些功能为数据建模提供了极大的灵活性,如 [图 3-6](#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的内容,例如不同国家的不同区域结构(法国有 **省** 和 **大区**,而美国有 **县** 和 **州**)、历史的怪癖(如国中之国)(暂时忽略主权国家和民族的复杂性),以及不同粒度的数据(Lucy 的当前居住地指定为城市,而她的出生地仅在州级别指定)。 你可以想象扩展图以包括有关 Lucy 和 Alain 或其他人的许多其他事实。例如,你可以使用它来指示他们有哪些食物过敏(通过为每个过敏原引入一个顶点,并在人和过敏原之间设置边以指示过敏),并将过敏原与显示哪些食物含有哪些物质的一组顶点链接。然后你可以编写查询来找出每个人可以安全食用的食物。图适合可演化性:随着你向应用程序添加功能,图可以轻松扩展以适应应用程序数据结构的变化。 ### Cypher 查询语言 {#id57} -*Cypher* 是用于属性图的查询语言,最初为 Neo4j 图数据库创建,后来作为 *openCypher* 发展为开放标准 [^38]。除了 Neo4j,Cypher 还得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中存储)等的支持。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关 [^39]。 +**Cypher** 是用于属性图的查询语言,最初为 Neo4j 图数据库创建,后来作为 **openCypher** 发展为开放标准 [^38]。除了 Neo4j,Cypher 还得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中存储)等的支持。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关 [^39]。 [示例 3-4](#fig_cypher_create) 显示了将 [图 3-6](#fig_datamodels_graph) 的左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个符号名称,如 `usa` 或 `idaho`。该名称不存储在数据库中,仅在查询内部使用以在顶点之间创建边,使用箭头符号:`(idaho) -[:WITHIN]-> (usa)` 创建一条标记为 `WITHIN` 的边,其中 `idaho` 作为尾节点,`usa` 作为头节点。 @@ -499,7 +499,7 @@ CREATE (lucy) -[:BORN_IN]-> (idaho) ``` -当 [图 3-6](#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,*查找所有从美国移民到欧洲的人的姓名*。也就是说,找到所有具有指向美国境内位置的 `BORN_IN` 边,以及指向欧洲境内位置的 `LIVING_IN` 边的顶点,并返回每个顶点的 `name` 属性。 +当 [图 3-6](#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,**查找所有从美国移民到欧洲的人的姓名**。也就是说,找到所有具有指向美国境内位置的 `BORN_IN` 边,以及指向欧洲境内位置的 `LIVING_IN` 边的顶点,并返回每个顶点的 `name` 属性。 [示例 3-5](#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头符号用于 `MATCH` 子句中以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。 @@ -535,7 +535,7 @@ RETURN person.name 在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这个事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 算子。 -自 SQL:1999 以来,查询中可变长度遍历路径的想法可以使用称为 *递归公用表表达式*(`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](#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" >}} @@ -597,7 +597,7 @@ WITH RECURSIVE 4 行 Cypher 查询需要 31 行 SQL 的事实表明,正确选择数据模型和查询语言可以产生多大的差异。这只是开始;还有更多细节需要考虑,例如,处理循环,以及在广度优先或深度优先遍历之间进行选择 [^40]。 -Oracle 对递归查询有不同的 SQL 扩展,它称之为 *层次* [^41]。 +Oracle 对递归查询有不同的 SQL 扩展,它称之为 **层次** [^41]。 然而,情况可能正在改善:在撰写本文时,有计划向 SQL 标准添加一种名为 GQL 的图查询语言 [^42] [^43],它将提供受 Cypher、GSQL [^44] 和 PGQL [^45] 启发的语法。 @@ -605,17 +605,17 @@ Oracle 对递归查询有不同的 SQL 扩展,它称之为 *层次* [^41]。 三元组存储模型大多等同于属性图模型,使用不同的词来描述相同的想法。尽管如此,它仍值得讨论,因为有各种三元组存储的工具和语言,它们可以成为构建应用程序工具箱的宝贵补充。 -在三元组存储中,所有信息都以非常简单的三部分语句的形式存储:(*主语*、*谓语*、*宾语*)。例如,在三元组(*Jim*、*likes*、*bananas*)中,*Jim* 是主语,*likes* 是谓语(动词),*bananas* 是宾语。 +在三元组存储中,所有信息都以非常简单的三部分语句的形式存储:(**主语**、**谓语**、**宾语**)。例如,在三元组(**Jim**、**likes**、**bananas**)中,**Jim** 是主语,**likes** 是谓语(动词),**bananas** 是宾语。 三元组的主语等同于图中的顶点。宾语是两种东西之一: -1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语等同于主语顶点上属性的键和值。使用 [图 3-6](#fig_datamodels_graph) 中的示例,(*lucy*、*birthYear*、*1989*)就像一个顶点 `lucy`,其属性为 `{"birthYear": 1989}`。 -2. 图中的另一个顶点。在这种情况下,谓语是图中的边,主语是尾顶点,宾语是头顶点。例如,在(*lucy*、*marriedTo*、*alain*)中,主语和宾语 *lucy* 和 *alain* 都是顶点,谓语 *marriedTo* 是连接它们的边的标签。 +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](#fig_graph_n3_triples) 显示了与 [示例 3-4](#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" >}} @@ -655,7 +655,7 @@ _:namerica a :Location; :name "North America"; :type "continent". > [!TIP] 语义网 -一些三元组存储的研究和开发工作是由 *语义网* 推动的,这是 2000 年代初的一项努力,旨在通过不仅以人类可读的网页形式发布数据,还以标准化的机器可读格式发布数据来促进互联网范围的数据交换。尽管最初设想的语义网没有成功 [^49] [^50],但语义网项目的遗产在几项特定技术中继续存在:*链接数据* 标准(如 JSON-LD [^51])、生物医学科学中使用的 *本体* [^52]、Facebook 的开放图协议 [^53](用于链接展开 [^54])、知识图(如 Wikidata)以及由 [`schema.org`](https://schema.org/) 维护的结构化数据的标准化词汇表。 +一些三元组存储的研究和开发工作是由 **语义网** 推动的,这是 2000 年代初的一项努力,旨在通过不仅以人类可读的网页形式发布数据,还以标准化的机器可读格式发布数据来促进互联网范围的数据交换。尽管最初设想的语义网没有成功 [^49] [^50],但语义网项目的遗产在几项特定技术中继续存在:**链接数据** 标准(如 JSON-LD [^51])、生物医学科学中使用的 **本体** [^52]、Facebook 的开放图协议 [^53](用于链接展开 [^54])、知识图(如 Wikidata)以及由 [`schema.org`](https://schema.org/) 维护的结构化数据的标准化词汇表。 三元组存储是另一种在其原始用例之外找到用途的语义网技术:即使你对语义网没有兴趣,三元组也可以成为应用程序的良好内部数据模型。 @@ -663,7 +663,7 @@ _:namerica a :Location; :name "North America"; :type "continent". #### RDF 数据模型 {#the-rdf-data-model} -我们在 [示例 3-8](#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 *资源描述框架*(RDF)[^55] 中编码数据的一种方式,这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)用 XML,如 [示例 3-9](#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,7 +701,7 @@ URL `` 不一定需要解析为任何内容 — #### SPARQL 查询语言 {#the-sparql-query-language} -*SPARQL* 是使用 RDF 数据模型的三元组存储的查询语言 [^56]。(它是 *SPARQL Protocol and RDF Query Language* 的首字母缩略词,发音为 "sparkle"。)它早于 Cypher,由于 Cypher 的模式匹配是从 SPARQL 借用的,它们看起来非常相似。 +**SPARQL** 是使用 RDF 数据模型的三元组存储的查询语言 [^56]。(它是 **SPARQL Protocol and RDF Query Language** 的首字母缩略词,发音为 "sparkle"。)它早于 Cypher,由于 Cypher 的模式匹配是从 SPARQL 借用的,它们看起来非常相似。 与之前相同的查询 —— 查找从美国搬到欧洲的人 —— 在 SPARQL 中与在 Cypher 中一样简洁(见 [示例 3-10](#fig_sparql_query))。 @@ -741,7 +741,7 @@ Datalog 是一种比 SPARQL 或 Cypher 更古老的语言:它源于 20 世纪 Datalog 实际上基于关系数据模型,而不是图,但它出现在本书的图数据库部分,因为图上的递归查询是 Datalog 的特殊优势。 -Datalog 数据库的内容由 *事实* 组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美国是一个国家的事实可以写成 `location(2, "United States", "country")`,其中 `2` 是美国的 ID。一般来说,语句 `table(val1, val2, …​)` 意味着 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此类推。 +Datalog 数据库的内容由 **事实** 组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 **location**,它有三列:**ID**、**name** 和 **type**。美国是一个国家的事实可以写成 `location(2, "United States", "country")`,其中 `2` 是美国的 ID。一般来说,语句 `table(val1, val2, …​)` 意味着 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此类推。 [示例 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)`。 @@ -779,11 +779,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 规则 4 /* us_to_europe 包含行 "Lucy"。 */ ``` -Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义 *规则* 从底层事实派生新的虚拟表。这些派生表就像(虚拟)SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。 +Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义 **规则** 从底层事实派生新的虚拟表。这些派生表就像(虚拟)SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。 在 [示例 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"`。如果系统可以为 `:-` 算子右侧的 *所有* 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。 +虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试查找表中匹配某种模式的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 算子右侧的 **所有** 模式找到匹配项,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。 因此,应用规则的一种可能方式是(如 [图 3-7](#fig_datalog_naive) 所示): @@ -877,17 +877,17 @@ query ChatApp { 我们之前在 ["记录系统与派生数据"](/ch1#sec_introduction_derived) 中看到了这个想法,ETL(参见 ["数据仓库"](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想为写入优化数据建模,而不关心高效查询,你会如何建模? -也许写入数据的最简单、最快速和最具表现力的方式是 *事件日志*:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON),包括时间戳,然后将其追加到事件序列中。此日志中的事件是 *不可变的*:你永远不会更改或删除它们,你只会向日志追加更多事件(这可能会取代早期事件)。事件可以包含任意属性。 +也许写入数据的最简单、最快速和最具表现力的方式是 **事件日志**:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON),包括时间戳,然后将其追加到事件序列中。此日志中的事件是 **不可变的**:你永远不会更改或删除它们,你只会向日志追加更多事件(这可能会取代早期事件)。事件可以包含任意属性。 [图 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](#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参与者进行和取消注册)首先被存储为事件。每当事件追加到日志时,几个 *物化视图*(也称为 *投影* 或 *读模型*)也会更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印参与者徽章的打印机生成文件。 +在 [图 3-8](#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参与者进行和取消注册)首先被存储为事件。每当事件追加到日志时,几个 **物化视图**(也称为 **投影** 或 **读模型**)也会更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为打印参与者徽章的打印机生成文件。 -使用事件作为真相来源(权威数据源),并将每个状态变化表达为事件的想法被称为 *事件溯源* [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 *命令查询责任分离(CQRS)* [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如 *状态机复制*(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。 +使用事件作为真相来源(权威数据源),并将每个状态变化表达为事件的想法被称为 **事件溯源** [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则称为 **命令查询责任分离(CQRS)** [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如 **状态机复制**(参见 ["使用共享日志"](/ch10#sec_consistency_smr))。 -当用户的请求进来时,它被称为 *命令*,首先需要验证。只有在命令已执行并确定有效(例如,请求的预订有足够的可用座位)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。 +当用户的请求进来时,它被称为 **命令**,首先需要验证。只有在命令已执行并确定有效(例如,请求的预订有足够的可用座位)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。 在以事件溯源风格建模数据时,建议你使用过去时态命名事件(例如,"座位已预订"),因为事件是记录过去发生的事情的记录。即使用户后来决定更改或取消,他们以前持有预订的事实仍然是真实的,更改或取消是稍后添加的单独事件。 @@ -895,7 +895,7 @@ query ChatApp { 事件溯源和 CQRS 有几个优点: -* 对于开发系统的人来说,事件更好地传达了 *为什么* 发生某事的意图。例如,理解事件"预订已取消"比理解"`bookings` 表第 4001 行的 `active` 列被设置为 `false`,与该预订相关的三行从 `seat_assignments` 表中删除,并且在 `payments` 表中插入了一行代表退款"更容易。当物化视图处理取消事件时,这些行修改仍可能发生,但当它们由事件驱动时,更新的原因变得更加清晰。 +* 对于开发系统的人来说,事件更好地传达了 **为什么** 发生某事的意图。例如,理解事件"预订已取消"比理解"`bookings` 表第 4001 行的 `active` 列被设置为 `false`,与该预订相关的三行从 `seat_assignments` 表中删除,并且在 `payments` 表中插入了一行代表退款"更容易。当物化视图处理取消事件时,这些行修改仍可能发生,但当它们由事件驱动时,更新的原因变得更加清晰。 * 事件溯源的关键原则是物化视图以可重现的方式从事件日志派生:你应该始终能够删除物化视图并通过以相同顺序处理相同事件,使用相同代码来重新计算它们。如果视图维护代码中有错误,你可以删除视图并使用新代码重新计算它。查找错误也更容易,因为你可以随意重新运行视图维护代码并检查其行为。 * 你可以有多个物化视图,针对应用程序所需的特定查询进行优化。它们可以存储在与事件相同的数据库中,也可以存储在不同的数据库中,具体取决于你的需求。它们可以使用任何数据模型,并且可以为快速读取而反规范化。你甚至可以只在内存中保留视图并避免持久化它,只要可以在服务重新启动时从事件日志重新计算视图即可。 * 如果你决定以新方式呈现现有信息,很容易从现有事件日志构建新的物化视图。你还可以通过添加新类型的事件或向现有事件类型添加新属性(任何旧事件保持未修改)来发展系统以支持新功能。你还可以将新行为链接到现有事件(例如,当会议参与者取消时,他们的座位可以提供给等候名单上的下一个人)。 @@ -919,50 +919,50 @@ query ChatApp { 数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家为训练机器学习模型准备数据的流行工具,但它们也广泛用于数据探索、统计数据分析、数据可视化和类似目的。 -乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系算子:例如,将函数应用于所有行、基于某些条件过滤行、按某些列对行进行分组并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为 *连接* 的操作在数据框上通常称为 *合并*)。 +乍一看,数据框类似于关系数据库中的表或电子表格。它支持对数据框内容执行批量操作的类关系算子:例如,将函数应用于所有行、基于某些条件过滤行、按某些列对行进行分组并聚合其他列,以及基于某个键将一个数据框中的行与另一个数据框连接(关系数据库称为 **连接** 的操作在数据框上通常称为 **合并**)。 数据框通常不是通过声明式查询(如 SQL)而是通过一系列修改其结构和内容的命令来操作的。这符合数据科学家的典型工作流程,他们逐步"整理"数据,使其成为能够找到他们所提问题答案的形式。这些操作通常在数据科学家的数据集私有副本上进行,通常在他们的本地机器上,尽管最终结果可能与其他用户共享。 数据框 API 还提供了远远超出关系数据库提供的各种操作,数据模型的使用方式通常与典型的关系数据建模非常不同 [^65]。例如,数据框的常见用途是将数据从类似关系的表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。 -[图 3-9](#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](#fig_dataframe_to_matrix) 的示例矩阵中省略了)可以缩放为某个合适范围内的浮点数。 -* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用 *独热编码*:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"剧情",一个用于"恐怖"等),对于代表电影的每一行,我们在对应于该电影类型的列中放置 1,在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。 +* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用 **独热编码**:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"剧情",一个用于"恐怖"等),对于代表电影的每一行,我们在对应于该电影类型的列中放置 1,在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。 一旦数据以数字矩阵的形式存在,它就适合线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。 -还有像 TileDB [^66] 这样专门存储大型多维数字数组的数据库;它们被称为 *数组数据库*,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框在金融行业也用于表示 *时间序列数据*,如资产价格和随时间变化的交易 [^68]。 +还有像 TileDB [^66] 这样专门存储大型多维数字数组的数据库;它们被称为 **数组数据库**,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框在金融行业也用于表示 **时间序列数据**,如资产价格和随时间变化的交易 [^68]。 ## 总结 {#summary} 数据模型是一个巨大的主题,在本章中,我们快速浏览了各种不同的模型。我们没有空间深入每个模型的所有细节,但希望这个概述足以激发你的兴趣,找出最适合你的应用需求的模型。 -*关系模型* 尽管已有半个多世纪的历史,但对许多应用来说仍然是一个重要的数据模型——特别是在数据仓库和商业分析中,关系星型或雪花模式和 SQL 查询无处不在。然而,关系数据的几种替代方案也在其他领域变得流行: +**关系模型** 尽管已有半个多世纪的历史,但对许多应用来说仍然是一个重要的数据模型——特别是在数据仓库和商业分析中,关系星型或雪花模式和 SQL 查询无处不在。然而,关系数据的几种替代方案也在其他领域变得流行: -* *文档模型* 针对数据以独立的 JSON 文档形式出现的用例,以及一个文档与另一个文档之间的关系很少的情况。 -* *图数据模型* 走向相反的方向,针对任何东西都可能与一切相关的用例,以及查询可能需要遍历多个跳跃才能找到感兴趣的数据(可以使用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。 -* *数据框* 将关系数据推广到大量列,从而在数据库和构成大量机器学习、统计数据分析和科学计算基础的多维数组之间提供桥梁。 +* **文档模型** 针对数据以独立的 JSON 文档形式出现的用例,以及一个文档与另一个文档之间的关系很少的情况。 +* **图数据模型** 走向相反的方向,针对任何东西都可能与一切相关的用例,以及查询可能需要遍历多个跳跃才能找到感兴趣的数据(可以使用 Cypher、SPARQL 或 Datalog 中的递归查询来表达)。 +* **数据框** 将关系数据推广到大量列,从而在数据库和构成大量机器学习、统计数据分析和科学计算基础的多维数组之间提供桥梁。 在某种程度上,一个模型可以用另一个模型来模拟——例如,图数据可以在关系数据库中表示——但结果可能很别扭,正如我们在 SQL 中对递归查询的支持中看到的那样。 因此,为每个数据模型开发了各种专业数据库,提供针对特定模型优化的查询语言和存储引擎。然而,数据库也有通过添加对其他数据模型的支持来扩展到相邻领域的趋势:例如,关系数据库以 JSON 列的形式添加了对文档数据的支持,文档数据库添加了类似关系的连接,SQL 中对图数据的支持也在逐步改进。 -我们讨论的另一个模型是 *事件溯源*,它将数据表示为不可变事件的仅追加日志,这对于建模复杂业务领域中的活动可能是有利的。仅追加日志有利于写入数据(正如我们将在 [第 4 章](/ch4#ch_storage) 中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。 +我们讨论的另一个模型是 **事件溯源**,它将数据表示为不可变事件的仅追加日志,这对于建模复杂业务领域中的活动可能是有利的。仅追加日志有利于写入数据(正如我们将在 [第 4 章](/ch4#ch_storage) 中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。 非关系数据模型的一个共同点是,它们通常不会对存储的数据强制执行模式,这可以使应用更容易适应不断变化的需求。然而,你的应用很可能仍然假设数据具有某种结构;这只是模式是显式的(在写入时强制执行)还是隐式的(在读取时假设)的问题。 尽管我们涵盖了很多内容,但仍有数据模型未被提及。仅举几个简短的例子: -* 研究基因组数据的研究人员通常需要执行 *序列相似性搜索*,这意味着获取一个非常长的字符串(代表 DNA 分子)并将其与相似但不相同的大量字符串数据库进行匹配。这里描述的数据库都无法处理这种用法,这就是研究人员编写了像 GenBank [^69] 这样的专门基因组数据库软件的原因。 -* 许多金融系统使用具有复式记账的 *账本* 作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样专门研究这种数据模型的数据库。加密货币和区块链通常基于分布式账本,它们的数据模型中也内置了价值转移。 -* *全文检索* 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专业主题,我们不会在本书中详细介绍,但我们将在 ["全文检索"](/ch4#sec_storage_full_text) 中涉及搜索索引和向量搜索。 +* 研究基因组数据的研究人员通常需要执行 **序列相似性搜索**,这意味着获取一个非常长的字符串(代表 DNA 分子)并将其与相似但不相同的大量字符串数据库进行匹配。这里描述的数据库都无法处理这种用法,这就是研究人员编写了像 GenBank [^69] 这样的专门基因组数据库软件的原因。 +* 许多金融系统使用具有复式记账的 **账本** 作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样专门研究这种数据模型的数据库。加密货币和区块链通常基于分布式账本,它们的数据模型中也内置了价值转移。 +* **全文检索** 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专业主题,我们不会在本书中详细介绍,但我们将在 ["全文检索"](/ch4#sec_storage_full_text) 中涉及搜索索引和向量搜索。 -我们现在必须到此为止了。在下一章中,我们将讨论在 *实现* 本章中描述的数据模型时出现的一些权衡。 +我们现在必须到此为止了。在下一章中,我们将讨论在 **实现** 本章中描述的数据模型时出现的一些权衡。 diff --git a/content/zh/ch4.md b/content/zh/ch4.md index 608313c..f66da5f 100644 --- a/content/zh/ch4.md +++ b/content/zh/ch4.md @@ -17,9 +17,9 @@ breadcrumbs: false 在 [第 3 章](/ch3#ch_datamodels) 中,我们讨论了数据模型和查询语言 —— 即你向数据库提供数据的格式,以及之后再次请求数据的接口。在本章中,我们从数据库的角度讨论同样的问题:数据库如何存储你提供的数据,以及当你请求时如何再次找到这些数据。 -作为应用开发者,你为什么要关心数据库内部如何处理存储和检索?你可能不会从头开始实现自己的存储引擎,但你 *确实* 需要从众多可用的存储引擎中选择一个适合你应用的。为了让存储引擎在你的工作负载类型上表现良好,你需要对存储引擎在底层做了什么有个大致的了解。 +作为应用开发者,你为什么要关心数据库内部如何处理存储和检索?你可能不会从头开始实现自己的存储引擎,但你 **确实** 需要从众多可用的存储引擎中选择一个适合你应用的。为了让存储引擎在你的工作负载类型上表现良好,你需要对存储引擎在底层做了什么有个大致的了解。 -特别是,针对事务型工作负载(OLTP)优化的存储引擎和针对分析型工作负载优化的存储引擎之间存在巨大差异(我们在 ["分析型与事务型系统"](/ch1#sec_introduction_analytics) 中介绍了这种区别)。本章首先研究两种用于 OLTP 的存储引擎家族:写入不可变数据文件的 *日志结构* 存储引擎,以及像 *B 树* 这样就地更新数据的存储引擎。这些结构既用于键值存储,也用于二级索引。 +特别是,针对事务型工作负载(OLTP)优化的存储引擎和针对分析型工作负载优化的存储引擎之间存在巨大差异(我们在 ["分析型与事务型系统"](/ch1#sec_introduction_analytics) 中介绍了这种区别)。本章首先研究两种用于 OLTP 的存储引擎家族:写入不可变数据文件的 **日志结构** 存储引擎,以及像 **B 树** 这样就地更新数据的存储引擎。这些结构既用于键值存储,也用于二级索引。 随后在 ["分析型数据存储"](#sec_storage_analytics) 中,我们将讨论一系列针对分析优化的存储引擎;在 ["多维索引与全文索引"](#sec_storage_multidimensional) 中,我们将简要介绍用于更高级查询(如文本检索)的索引。 @@ -67,7 +67,7 @@ $ cat database ``` -对于如此简单的实现,`db_set` 函数实际上有相当好的性能,因为追加到文件通常非常高效。与 `db_set` 所做的类似,许多数据库内部使用 *日志*,这是一个仅追加的数据文件。真正的数据库有更多问题要处理(如处理并发写入、回收磁盘空间以防日志无限增长,以及从崩溃中恢复时处理部分写入的记录),但基本原理是相同的。日志非常有用,我们将在本书中多次遇到它们。 +对于如此简单的实现,`db_set` 函数实际上有相当好的性能,因为追加到文件通常非常高效。与 `db_set` 所做的类似,许多数据库内部使用 **日志**,这是一个仅追加的数据文件。真正的数据库有更多问题要处理(如处理并发写入、回收磁盘空间以防日志无限增长,以及从崩溃中恢复时处理部分写入的记录),但基本原理是相同的。日志非常有用,我们将在本书中多次遇到它们。 --------- @@ -77,11 +77,11 @@ $ cat database -------- -另一方面,如果你的数据库中有大量记录,`db_get` 函数的性能会很糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件,寻找该键的出现。用算法术语来说,查找的成本是 *O*(*n*):如果你的数据库中的记录数 *n* 翻倍,查找时间也会翻倍。这并不好。 +另一方面,如果你的数据库中有大量记录,`db_get` 函数的性能会很糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件,寻找该键的出现。用算法术语来说,查找的成本是 **O**(**n**):如果你的数据库中的记录数 **n** 翻倍,查找时间也会翻倍。这并不好。 -为了高效地找到数据库中特定键的值,我们需要一个不同的数据结构:*索引*。在本章中,我们将研究一系列索引结构并了解它们的比较;一般思想是以特定方式(例如,按某个键排序)构建数据,使定位所需数据更快。如果你想以几种不同的方式搜索相同的数据,你可能需要在数据的不同部分上建立几个不同的索引。 +为了高效地找到数据库中特定键的值,我们需要一个不同的数据结构:**索引**。在本章中,我们将研究一系列索引结构并了解它们的比较;一般思想是以特定方式(例如,按某个键排序)构建数据,使定位所需数据更快。如果你想以几种不同的方式搜索相同的数据,你可能需要在数据的不同部分上建立几个不同的索引。 -索引是从主数据派生出的 *额外* 结构。许多数据库允许你添加和删除索引,这不会影响数据库的内容;它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。对于写入,很难超越简单地追加到文件的性能,因为这是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时也需要更新索引。 +索引是从主数据派生出的 **额外** 结构。许多数据库允许你添加和删除索引,这不会影响数据库的内容;它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。对于写入,很难超越简单地追加到文件的性能,因为这是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时也需要更新索引。 这是存储系统中的一个重要权衡:精心选择的索引加快了读查询速度,但每个索引都会消耗额外的磁盘空间并减慢写入速度,有时会大幅减慢 [^1]。因此,数据库通常不会默认为所有内容建立索引,而是要求你 —— 编写应用程序或管理数据库的人 —— 使用你对应用程序典型查询模式的了解来手动选择索引。然后你可以选择为你的应用程序带来最大收益的索引,而不会引入超过必要的写入开销。 @@ -102,11 +102,11 @@ $ cat database #### SSTable 文件格式 {#the-sstable-file-format} -实际上,哈希表很少用于数据库索引,相反,保持数据 *按键排序* 的结构更为常见 [^3]。这种结构的一个例子是 *排序字符串表*(*Sorted String Table*),简称 *SSTable*,如 [图 4-2](#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]。 +现在你不需要在内存中保留所有键:你可以将 SSTable 中的键值对分组为几千字节的 **块**,然后在索引中存储每个块的第一个键。这种只存储部分键的索引称为 **稀疏** 索引。这个索引存储在 SSTable 的单独部分,例如使用不可变 B 树、字典树或其他允许查询快速查找特定键的数据结构 [^4]。 例如,在 [图 4-2](#fig_storage_sstable_index) 中,一个块的第一个键是 `handbag`,下一个块的第一个键是 `handsome`。现在假设你要查找键 `handiwork`,它没有出现在稀疏索引中。由于排序,你知道 `handiwork` 必须出现在 `handbag` 和 `handsome` 之间。这意味着你可以寻找到 `handbag` 的偏移量,然后从那里扫描文件,直到找到 `handiwork`(或没有,如果该键不在文件中)。几千字节的块可以非常快速地扫描。 @@ -116,24 +116,24 @@ $ cat database SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更加困难。我们不能简单地追加到末尾,因为那样文件就不再有序了(除非键恰好按升序写入)。如果我们每次在中间某处插入键时都必须重写整个 SSTable,写入将变得太昂贵。 -我们可以用 *日志结构* 方法解决这个问题,这是仅追加日志和排序文件之间的混合: +我们可以用 **日志结构** 方法解决这个问题,这是仅追加日志和排序文件之间的混合: -1. 当写入操作到来时,将其添加到内存中的有序映射数据结构中,例如红黑树、跳表 [^5] 或字典树 [^6]。使用这些数据结构,你可以按任意顺序插入键,高效地查找它们,并按排序顺序读回它们。这个内存数据结构称为 *内存表*(*memtable*)。 -2. 当内存表变得大于某个阈值(通常是几兆字节)时,将其按排序顺序作为 SSTable 文件写入磁盘。我们将这个新的 SSTable 文件称为数据库的最新 *段*,它与旧段一起作为单独的文件存储。每个段都有自己内容的单独索引。当新段被写入磁盘时,数据库可以继续写入新的内存表实例,当 SSTable 写入完成时,旧内存表的内存被释放。 +1. 当写入操作到来时,将其添加到内存中的有序映射数据结构中,例如红黑树、跳表 [^5] 或字典树 [^6]。使用这些数据结构,你可以按任意顺序插入键,高效地查找它们,并按排序顺序读回它们。这个内存数据结构称为 **内存表**(**memtable**)。 +2. 当内存表变得大于某个阈值(通常是几兆字节)时,将其按排序顺序作为 SSTable 文件写入磁盘。我们将这个新的 SSTable 文件称为数据库的最新 **段**,它与旧段一起作为单独的文件存储。每个段都有自己内容的单独索引。当新段被写入磁盘时,数据库可以继续写入新的内存表实例,当 SSTable 写入完成时,旧内存表的内存被释放。 3. 为了读取某个键的值,首先尝试在内存表和最新的磁盘段中找到该键。如果没有找到,就在下一个较旧的段中查找,依此类推,直到找到键或到达最旧的段。如果键没有出现在任何段中,则它不存在于数据库中。 4. 不时地在后台运行合并和压实过程,以合并段文件并丢弃被覆盖或删除的值。 -合并段的工作方式类似于 *归并排序* 算法 [^5]。该过程如 [图 4-3](#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" >}} 为了确保数据库崩溃时内存表中的数据不会丢失,存储引擎在磁盘上保留一个单独的日志,每次写入都会立即追加到该日志中。此日志不按键排序,但这无关紧要,因为它的唯一目的是在崩溃后恢复内存表。每次内存表被写出到 SSTable 后,日志的相应部分就可以丢弃。 -如果你想删除一个键及其关联的值,你必须向数据文件追加一个称为 *墓碑*(*tombstone*)的特殊删除记录。当日志段合并时,墓碑告诉合并过程丢弃已删除键的任何先前值。一旦墓碑合并到最旧的段中,它就可以被丢弃。 +如果你想删除一个键及其关联的值,你必须向数据文件追加一个称为 **墓碑**(**tombstone**)的特殊删除记录。当日志段合并时,墓碑告诉合并过程丢弃已删除键的任何先前值。一旦墓碑合并到最旧的段中,它就可以被丢弃。 -这里描述的算法本质上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的算法,它们都受到 Google 的 Bigtable 论文 [^9] 的启发(该论文引入了 *SSTable* 和 *memtable* 这两个术语)。 +这里描述的算法本质上就是 RocksDB [^7]、Cassandra、Scylla 和 HBase [^8] 中使用的算法,它们都受到 Google 的 Bigtable 论文 [^9] 的启发(该论文引入了 **SSTable** 和 **memtable** 这两个术语)。 -该算法最初于 1996 年以 *日志结构合并树*(*Log-Structured Merge-Tree*)或 *LSM 树*(*LSM-Tree*)[^10] 的名称发布,建立在早期日志结构文件系统工作的基础上 [^11]。因此,基于合并和压实排序文件原理的存储引擎通常被称为 *LSM 存储引擎*。 +该算法最初于 1996 年以 **日志结构合并树**(**Log-Structured Merge-Tree**)或 **LSM 树**(**LSM-Tree**)[^10] 的名称发布,建立在早期日志结构文件系统工作的基础上 [^11]。因此,基于合并和压实排序文件原理的存储引擎通常被称为 **LSM 存储引擎**。 在 LSM 存储引擎中,段文件是一次性写入的(通过写出内存表或合并一些现有段),此后它是不可变的。段的合并和压实可以在后台线程中完成,当它进行时,我们仍然可以使用旧的段文件继续提供读取服务。当合并过程完成时,我们将读取请求切换到使用新的合并段而不是旧段,然后可以删除旧的段文件。 @@ -145,7 +145,7 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 #### 布隆过滤器 {#bloom-filters} -使用 LSM 存储,读取很久以前更新的键或不存在的键可能会很慢,因为存储引擎需要检查多个段文件。为了加快此类读取,LSM 存储引擎通常在每个段中包含一个 *布隆过滤器*(*Bloom filter*)[^13],它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。 +使用 LSM 存储,读取很久以前更新的键或不存在的键可能会很慢,因为存储引擎需要检查多个段文件。为了加快此类读取,LSM 存储引擎通常在每个段中包含一个 **布隆过滤器**(**Bloom filter**)[^13],它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。 [图 4-4](#fig_storage_bloom) 显示了一个包含两个键和 16 位的布隆过滤器示例(实际上,它会包含更多的键和更多的位)。对于 SSTable 中的每个键,我们计算一个哈希函数,产生一组数字,然后将其解释为位数组的索引 [^14]。我们将对应于这些索引的位设置为 1,其余保持为 0。例如,键 `handbag` 哈希为数字 (2, 9, 4),所以我们将第 2、9 和 4 位设置为 1。然后将位图与键的稀疏索引一起存储为 SSTable 的一部分。这需要一点额外的空间,但与 SSTable 的其余部分相比,布隆过滤器通常很小。 @@ -153,14 +153,14 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 当我们想知道一个键是否出现在 SSTable 中时,我们像以前一样计算该键的相同哈希,并检查这些索引处的位。例如,在 [图 4-4](#fig_storage_bloom) 中,我们查询键 `handheld`,它哈希为 (6, 11, 2)。其中一个位是 1(即第 2 位),而另外两个是 0。这些检查可以使用所有 CPU 都支持的位运算非常快速地进行。 -如果至少有一个位是 0,我们知道该键肯定不在 SSTable 中。如果查询中的位都是 1,那么该键很可能在 SSTable 中,但也有可能是巧合,所有这些位都被其他键设置为 1。这种看起来键存在但实际上不存在的情况称为 *假阳性*(*false positive*)。 +如果至少有一个位是 0,我们知道该键肯定不在 SSTable 中。如果查询中的位都是 1,那么该键很可能在 SSTable 中,但也有可能是巧合,所有这些位都被其他键设置为 1。这种看起来键存在但实际上不存在的情况称为 **假阳性**(**false positive**)。 假阳性的概率取决于键的数量、每个键设置的位数和布隆过滤器中的总位数。你可以使用在线计算器工具为你的应用计算出正确的参数 [^15]。作为经验法则,你需要为 SSTable 中的每个键分配 10 位布隆过滤器空间以获得 1% 的假阳性概率,每为每个键分配额外的 5 位,概率就会降低十倍。 在 LSM 存储引擎的上下文中,假阳性没有问题: -* 如果布隆过滤器说键 *不* 存在,我们可以安全地跳过该 SSTable,因为我们可以确定它不包含该键。 -* 如果布隆过滤器说键 *存在*,我们必须查询稀疏索引并解码键值对块以检查键是否真的在那里。如果是假阳性,我们做了一些不必要的工作,但除此之外没有害处 —— 我们只是继续使用下一个最旧的段进行搜索。 +* 如果布隆过滤器说键 **不** 存在,我们可以安全地跳过该 SSTable,因为我们可以确定它不包含该键。 +* 如果布隆过滤器说键 **存在**,我们必须查询稀疏索引并解码键值对块以检查键是否真的在那里。如果是假阳性,我们做了一些不必要的工作,但除此之外没有害处 —— 我们只是继续使用下一个最旧的段进行搜索。 #### 压实策略 {#sec_storage_lsm_compaction} @@ -182,7 +182,7 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 > [!TIP] 嵌入式存储引擎 -许多数据库作为接受网络查询的服务运行,但也有 *嵌入式* 数据库不公开网络 API。相反,它们是在与应用程序代码相同的进程中运行的库,通常读取和写入本地磁盘上的文件,你通过正常的函数调用与它们交互。嵌入式存储引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。 +许多数据库作为接受网络查询的服务运行,但也有 **嵌入式** 数据库不公开网络 API。相反,它们是在与应用程序代码相同的进程中运行的库,通常读取和写入本地磁盘上的文件,你通过正常的函数调用与它们交互。嵌入式存储引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。 嵌入式数据库在移动应用中非常常用,用于存储本地用户的数据。在后端,如果数据足够小以适合单台机器,并且没有太多并发事务,它们可能是一个合适的选择。例如,在多租户系统中,如果每个租户足够小且完全与其他租户分离(即,你不需要运行合并多个租户数据的查询),你可能可以为每个租户使用单独的嵌入式数据库实例 [^20]。 @@ -192,23 +192,23 @@ SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更 ### B 树 {#sec_storage_b_trees} -日志结构方法很流行,但它不是键值存储的唯一形式。按键读取和写入数据库记录最广泛使用的结构是 *B 树*。 +日志结构方法很流行,但它不是键值存储的唯一形式。按键读取和写入数据库记录最广泛使用的结构是 **B 树**。 B 树于 1970 年引入 [^21],不到 10 年后就被称为"无处不在"[^22],它们经受住了时间的考验。它们仍然是几乎所有关系数据库中的标准索引实现,许多非关系数据库也使用它们。 像 SSTable 一样,B 树按键保持键值对排序,这允许高效的键值查找和范围查询。但相似之处到此为止:B 树有着非常不同的设计理念。 -我们之前看到的日志结构索引将数据库分解为可变大小的 *段*,通常为几兆字节或更大,写入一次后就不可变。相比之下,B 树将数据库分解为固定大小的 *块* 或 *页*,并可能就地覆盖页。页传统上大小为 4 KiB,但 PostgreSQL 现在默认使用 8 KiB,MySQL 默认使用 16 KiB。 +我们之前看到的日志结构索引将数据库分解为可变大小的 **段**,通常为几兆字节或更大,写入一次后就不可变。相比之下,B 树将数据库分解为固定大小的 **块** 或 **页**,并可能就地覆盖页。页传统上大小为 4 KiB,但 PostgreSQL 现在默认使用 8 KiB,MySQL 默认使用 16 KiB。 每个页都可以使用页号来标识,这允许一个页引用另一个页 —— 类似于指针,但在磁盘上而不是在内存中。如果所有页都存储在同一个文件中,将页号乘以页大小就给我们文件中页所在位置的字节偏移量。我们可以使用这些页引用来构建页树,如 [图 4-5](#fig_storage_b_tree) 所示。 {{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="图 4-5. 使用 B 树索引查找键 251。从根页开始,我们首先跟随引用到键 200–300 的页,然后是键 250–270 的页。" class="w-full my-4" >}} -一个页被指定为 B 树的 *根*;每当你想在索引中查找一个键时,你就从这里开始。该页包含几个键和对子页的引用。每个子页负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。) +一个页被指定为 B 树的 **根**;每当你想在索引中查找一个键时,你就从这里开始。该页包含几个键和对子页的引用。每个子页负责一个连续的键范围,引用之间的键指示这些范围之间的边界在哪里。(这种结构有时称为 B+ 树,但我们不需要将其与其他 B 树变体区分开来。) -在 [图 4-5](#fig_storage_b_tree) 的例子中,我们正在查找键 251,所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200–300 范围分解为子范围。最终我们到达包含单个键的页(*叶页*),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。 +在 [图 4-5](#fig_storage_b_tree) 的例子中,我们正在查找键 251,所以我们知道我们需要跟随边界 200 和 300 之间的页引用。这将我们带到一个看起来相似的页,该页进一步将 200–300 范围分解为子范围。最终我们到达包含单个键的页(**叶页**),该页要么内联包含每个键的值,要么包含对可以找到值的页的引用。 -B 树的一个页中对子页的引用数称为 *分支因子*。例如,在 [图 4-5](#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常为几百。 +B 树的一个页中对子页的引用数称为 **分支因子**。例如,在 [图 4-5](#fig_storage_b_tree) 中,分支因子为六。实际上,分支因子取决于存储页引用和范围边界所需的空间量,但通常为几百。 如果你想更新 B 树中现有键的值,你搜索包含该键的叶页,并用包含新值的版本覆盖磁盘上的该页。如果你想添加一个新键,你需要找到其范围包含新键的页并将其添加到该页。如果页中没有足够的空闲空间来容纳新键,则页被分成两个半满的页,并更新父页以说明键范围的新细分。 @@ -216,15 +216,15 @@ B 树的一个页中对子页的引用数称为 *分支因子*。例如,在 [ 在 [图 4-6](#fig_storage_b_tree_split) 的例子中,我们想插入键 334,但范围 333–345 的页已经满了。因此,我们将其分成范围 333–337(包括新键)的页和 337–344 的页。我们还必须更新父页以引用两个子页,它们之间的边界值为 337。如果父页没有足够的空间容纳新引用,它也可能需要被分割,分割可以一直持续到树的根。当根被分割时,我们在它上面创建一个新根。删除键(可能需要合并节点)更复杂 [^5]。 -这个算法确保树保持 *平衡*:具有 *n* 个键的 B 树始终具有 *O*(log *n*) 的深度。大多数数据库可以适合三或四层深的 B 树,所以你不需要跟随许多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。) +这个算法确保树保持 **平衡**:具有 **n** 个键的 B 树始终具有 **O**(log **n**) 的深度。大多数数据库可以适合三或四层深的 B 树,所以你不需要跟随许多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。) #### 使 B 树可靠 {#sec_storage_btree_wal} B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖不会改变页的位置;即,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件),但从不就地修改文件。 -一次覆盖多个页,如在页分割中,是一个危险的操作:如果数据库在只写入了部分页后崩溃,你最终会得到一个损坏的树(例如,可能有一个 *孤立* 页,它不是任何父页的子页)。如果硬件不能原子地写入整个页,你也可能最终得到部分写入的页(这称为 *撕裂页*(*torn page*)[^23])。 +一次覆盖多个页,如在页分割中,是一个危险的操作:如果数据库在只写入了部分页后崩溃,你最终会得到一个损坏的树(例如,可能有一个 **孤立** 页,它不是任何父页的子页)。如果硬件不能原子地写入整个页,你也可能最终得到部分写入的页(这称为 **撕裂页**(**torn page**)[^23])。 -为了使数据库对崩溃具有弹性,B 树实现通常包括磁盘上的额外数据结构:*预写日志*(*write-ahead log*,WAL)。这是一个仅追加文件,每个 B 树修改必须在应用于树本身的页之前写入其中。当数据库在崩溃后恢复时,此日志用于将 B 树恢复到一致状态 [^2] [^24]。在文件系统中,等效机制称为 *日志记录*(*journaling*)。 +为了使数据库对崩溃具有弹性,B 树实现通常包括磁盘上的额外数据结构:**预写日志**(**write-ahead log**,WAL)。这是一个仅追加文件,每个 B 树修改必须在应用于树本身的页之前写入其中。当数据库在崩溃后恢复时,此日志用于将 B 树恢复到一致状态 [^2] [^24]。在文件系统中,等效机制称为 **日志记录**(**journaling**)。 为了提高性能,B 树实现通常不会立即将每个修改的页写入磁盘,而是首先将 B 树页缓冲在内存中一段时间。预写日志还确保在崩溃的情况下数据不会丢失:只要数据已写入 WAL,并使用 `fsync()` 系统调用刷新到磁盘,数据就是持久的,因为数据库将能够在崩溃后恢复它 [^25]。 @@ -247,7 +247,7 @@ B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖 范围查询在 B 树上简单而快速,因为它们可以使用树的排序结构。在 LSM 存储上,范围查询也可以利用 SSTable 排序,但它们需要并行扫描所有段并组合结果。布隆过滤器对范围查询没有帮助(因为你需要计算范围内每个可能键的哈希,这是不切实际的),使得范围查询在 LSM 方法中比点查询更昂贵 [^29]。 -如果内存表填满,高写入吞吐量可能会导致日志结构存储引擎中的延迟峰值。如果数据无法足够快地写入磁盘,可能是因为压实过程无法跟上传入的写入,就会发生这种情况。许多存储引擎,包括 RocksDB,在这种情况下执行 *背压*:它们暂停所有读取和写入,直到内存表被写入磁盘 [^30] [^31]。 +如果内存表填满,高写入吞吐量可能会导致日志结构存储引擎中的延迟峰值。如果数据无法足够快地写入磁盘,可能是因为压实过程无法跟上传入的写入,就会发生这种情况。许多存储引擎,包括 RocksDB,在这种情况下执行 **背压**:它们暂停所有读取和写入,直到内存表被写入磁盘 [^30] [^31]。 关于读取吞吐量,现代 SSD(特别是 NVMe)可以并行执行许多独立的读请求。LSM 树和 B 树都能够提供高读取吞吐量,但存储引擎需要仔细设计以利用这种并行性 [^32]。 @@ -255,7 +255,7 @@ B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖 使用 B 树时,如果应用程序写入的键分散在整个键空间中,生成的磁盘操作也会随机分散,因为存储引擎需要覆盖的页可能位于磁盘的任何位置。另一方面,日志结构存储引擎一次写入整个段文件(无论是写出内存表还是压实现有段),这比 B 树中的页大得多。 -许多小的、分散的写入模式(如 B 树中的)称为 *随机写入*,而较少的大写入模式(如 LSM 树中的)称为 *顺序写入*。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘(HDD)上特别大;在今天大多数数据库使用的固态硬盘(SSD)上,差异较小,但仍然明显(参见 ["SSD 上的顺序与随机写入"](#sidebar_sequential))。 +许多小的、分散的写入模式(如 B 树中的)称为 **随机写入**,而较少的大写入模式(如 LSM 树中的)称为 **顺序写入**。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘(HDD)上特别大;在今天大多数数据库使用的固态硬盘(SSD)上,差异较小,但仍然明显(参见 ["SSD 上的顺序与随机写入"](#sidebar_sequential))。 -------- @@ -263,7 +263,7 @@ B 树的基本底层写操作是用新数据覆盖磁盘上的页。假设覆盖 在旋转磁盘硬盘(HDD)上,顺序写入比随机写入快得多:随机写入必须机械地将磁头移动到新位置,并等待盘片的正确部分经过磁头下方,这需要几毫秒 —— 在计算时间尺度上是永恒的。然而,SSD(固态硬盘)包括 NVMe(Non-Volatile Memory Express,即连接到 PCI Express 总线的闪存)现在已经在许多场景中超越了 HDD,它们不受这种机械限制。 -尽管如此,SSD 对顺序写入的吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB),但只能一次擦除一个块(通常为 512 KiB)。块中的某些页可能包含有效数据,而其他页可能包含不再需要的数据。在擦除块之前,控制器必须首先将包含有效数据的页移动到其他块中;这个过程称为 *垃圾回收*(GC)[^33]。 +尽管如此,SSD 对顺序写入的吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB),但只能一次擦除一个块(通常为 512 KiB)。块中的某些页可能包含有效数据,而其他页可能包含不再需要的数据。在擦除块之前,控制器必须首先将包含有效数据的页移动到其他块中;这个过程称为 **垃圾回收**(GC)[^33]。 顺序写入工作负载一次写入更大的数据块,因此整个 512 KiB 块很可能属于单个文件;当该文件稍后再次被删除时,整个块可以被擦除而无需执行任何 GC。另一方面,对于随机写入工作负载,块更可能包含有效和无效数据页的混合,因此 GC 必须在块可以擦除之前执行更多工作 [^34] [^35] [^36]。 @@ -277,7 +277,7 @@ GC 消耗的写入带宽就不能用于应用程序。此外,GC 执行的额 B 树索引必须至少写入每条数据两次:一次写入预写日志,一次写入树页本身。此外,它们有时需要写出整个页,即使该页中只有几个字节发生了变化,以确保 B 树在崩溃或断电后可以正确恢复 [^38] [^39]。 -如果你获取在某个工作负载中写入磁盘的总字节数,然后除以如果你只是写入没有索引的仅追加日志需要写入的字节数,你就得到了 *写放大*。(有时写放大是根据 I/O 操作而不是字节来定义的。)在写入密集型应用程序中,瓶颈可能是数据库可以写入磁盘的速率。在这种情况下,写放大越高,它在可用磁盘带宽内可以处理的每秒写入次数就越少。 +如果你获取在某个工作负载中写入磁盘的总字节数,然后除以如果你只是写入没有索引的仅追加日志需要写入的字节数,你就得到了 **写放大**。(有时写放大是根据 I/O 操作而不是字节来定义的。)在写入密集型应用程序中,瓶颈可能是数据库可以写入磁盘的速率。在这种情况下,写放大越高,它在可用磁盘带宽内可以处理的每秒写入次数就越少。 写放大是 LSM 树和 B 树中的问题。哪个更好取决于各种因素,例如键和值的长度,以及你覆盖现有键与插入新键的频率。对于典型的工作负载,LSM 树往往具有较低的写放大,因为它们不必写入整个页,并且可以压缩 SSTable 的块 [^40]。这是使 LSM 存储引擎非常适合写入密集型工作负载的另一个因素。 @@ -287,7 +287,7 @@ B 树索引必须至少写入每条数据两次:一次写入预写日志,一 #### 磁盘空间使用 {#disk-space-usage} -B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了大量键,数据库文件可能包含许多 B 树不再使用的页。对 B 树的后续添加可以使用这些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件的中间,所以它们仍然占用文件系统上的空间。因此,数据库需要一个后台过程来移动页以更好地放置它们,例如 PostgreSQL 中的真空过程 [^25]。 +B 树可能会随着时间的推移变得 **碎片化**:例如,如果删除了大量键,数据库文件可能包含许多 B 树不再使用的页。对 B 树的后续添加可以使用这些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件的中间,所以它们仍然占用文件系统上的空间。因此,数据库需要一个后台过程来移动页以更好地放置它们,例如 PostgreSQL 中的真空过程 [^25]。 碎片化在 LSM 树中不太成问题,因为压实过程无论如何都会定期重写数据文件,而且 SSTable 没有未使用空间的页。此外,SSTable 中的键值对块可以更好地压缩,因此通常比 B 树在磁盘上产生更小的文件。被覆盖的键和值继续消耗空间,直到它们被压实删除,但使用分级压实时,这种开销相当低 [^40] [^41]。分层压实(参见 ["压实策略"](#sec_storage_lsm_compaction))使用更多的磁盘空间,特别是在压实期间临时使用。 @@ -298,9 +298,9 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了 ### 多列索引与二级索引 {#sec_storage_index_multicolumn} -到目前为止,我们只讨论了键值索引,它们就像关系模型中的 *主键* 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档,或图数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID)引用该行/文档/顶点,索引用于解析此类引用。 +到目前为止,我们只讨论了键值索引,它们就像关系模型中的 **主键** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档,或图数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID)引用该行/文档/顶点,索引用于解析此类引用。 -拥有 *二级索引* 也非常常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,允许你按主键以外的列进行搜索。例如,在 [第 3 章](/ch3#ch_datamodels) 的 [图 3-1](/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一个二级索引,以便你可以在每个表中找到属于同一用户的所有行。 +拥有 **二级索引** 也非常常见。在关系数据库中,你可以使用 `CREATE INDEX` 命令在同一个表上创建多个二级索引,允许你按主键以外的列进行搜索。例如,在 [第 3 章](/ch3#ch_datamodels) 的 [图 3-1](/ch3#fig_obama_relational) 中,你很可能在 `user_id` 列上有一个二级索引,以便你可以在每个表中找到属于同一用户的所有行。 二级索引可以很容易地从键值索引构建。主要区别在于,在二级索引中,索引值不一定是唯一的;也就是说,同一索引条目下可能有许多行(文档、顶点)。这可以通过两种方式解决:要么使索引中的每个值成为匹配行标识符的列表(如全文索引中的倒排列表),要么通过向其追加行标识符使每个条目唯一。具有就地更新的存储引擎(如 B 树)和日志结构存储都可用于实现索引。 @@ -308,9 +308,9 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了 索引中的键是查询搜索的内容,但值可以是几种东西之一: -* 如果实际数据(行、文档、顶点)直接存储在索引结构中,则称为 *聚簇索引*。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键始终是聚簇索引,在 SQL Server 中,你可以为每个表指定一个聚簇索引 [^43]。 -* 或者,值可以是对实际数据的引用:要么是相关行的主键(InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 *堆文件*,它以无特定顺序存储数据(它可能是仅追加的,或者它可能跟踪已删除的行以便稍后用新数据覆盖它们)。例如,Postgres 使用堆文件方法 [^44]。 -* 两者之间的折中是 *覆盖索引* 或 *包含列的索引*,它在索引中存储表的 *某些* 列,除了在堆上或主键聚簇索引中存储完整行 [^45]。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引被称为 *覆盖* 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多的磁盘空间并减慢写入速度。 +* 如果实际数据(行、文档、顶点)直接存储在索引结构中,则称为 **聚簇索引**。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键始终是聚簇索引,在 SQL Server 中,你可以为每个表指定一个聚簇索引 [^43]。 +* 或者,值可以是对实际数据的引用:要么是相关行的主键(InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 **堆文件**,它以无特定顺序存储数据(它可能是仅追加的,或者它可能跟踪已删除的行以便稍后用新数据覆盖它们)。例如,Postgres 使用堆文件方法 [^44]。 +* 两者之间的折中是 **覆盖索引** 或 **包含列的索引**,它在索引中存储表的 **某些** 列,除了在堆上或主键聚簇索引中存储完整行 [^45]。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引被称为 **覆盖** 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多的磁盘空间并减慢写入速度。 到目前为止讨论的索引只将单个键映射到值。如果你需要同时查询表的多个列(或文档中的多个字段),请参见 ["多维索引与全文索引"](#sec_storage_multidimensional)。 @@ -320,7 +320,7 @@ B 树可能会随着时间的推移变得 *碎片化*:例如,如果删除了 本章到目前为止讨论的数据结构都是对磁盘限制的回应。与主内存相比,磁盘很难处理。对于磁盘和 SSD,如果你想在读取和写入上获得良好的性能,磁盘上的数据需要仔细布局。然而,我们容忍这种尴尬,因为磁盘有两个显著的优势:它们是持久的(如果断电,其内容不会丢失),并且它们每千兆字节的成本比 RAM 低。 -随着 RAM 变得更便宜,按每 GB 计价的成本优势正在减弱。许多数据集根本没有那么大,因此将它们完全保留在内存中是完全可行的,甚至可以分布在几台机器上。这导致了 *内存数据库* 的发展。 +随着 RAM 变得更便宜,按每 GB 计价的成本优势正在减弱。许多数据集根本没有那么大,因此将它们完全保留在内存中是完全可行的,甚至可以分布在几台机器上。这导致了 **内存数据库** 的发展。 一些内存键值存储,例如 Memcached,仅用于缓存,如果机器重新启动,数据丢失是可以接受的。但其他内存数据库旨在实现持久性,这可以通过特殊硬件(例如电池供电的 RAM)、将更改日志写入磁盘、将定期快照写入磁盘或将内存状态复制到其他机器来实现。 @@ -337,7 +337,7 @@ Redis 和 Couchbase 通过异步写入磁盘提供弱持久性。 ## 分析型数据存储 {#sec_storage_analytics} -数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形化数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 *下钻* 和 *切片切块* 等操作)。 +数据仓库的数据模型最常见的是关系型,因为 SQL 通常非常适合分析查询。有许多图形化数据分析工具可以生成 SQL 查询、可视化结果,并允许分析师探索数据(通过 **下钻** 和 **切片切块** 等操作)。 表面上,数据仓库和关系型 OLTP 数据库看起来很相似,因为它们都有 SQL 查询接口。然而,系统的内部可能看起来完全不同,因为它们针对非常不同的查询模式进行了优化。许多数据库供应商现在专注于支持事务处理或分析工作负载,但不是两者兼而有之。 @@ -387,11 +387,11 @@ GROUP BY 我们如何高效地执行这个查询? -在大多数 OLTP 数据库中,存储是以 *面向行* 的方式布局的:表中一行的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。 +在大多数 OLTP 数据库中,存储是以 **面向行** 的方式布局的:表中一行的所有值彼此相邻存储。文档数据库类似:整个文档通常作为一个连续的字节序列存储。你可以在 [图 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到这一点。 为了处理像 [示例 4-1](#fig_storage_analytics_query) 这样的查询,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告诉存储引擎在哪里找到特定日期或特定产品的所有销售。但是,面向行的存储引擎仍然需要将所有这些行(每行包含超过 100 个属性)从磁盘加载到内存中,解析它们,并过滤掉不符合所需条件的行。这可能需要很长时间。 -*面向列*(或 *列式*)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 *列* 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本展示了这一原理。 +**面向列**(或 **列式**)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 **列** 中的所有值存储在一起 [^56]。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。[图 4-7](#fig_column_store) 使用 [图 3-5](/ch3#fig_dwh_schema) 中事实表的扩展版本展示了这一原理。 -------- @@ -412,23 +412,23 @@ GROUP BY 除了只从磁盘加载查询所需的那些列之外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,面向列的存储通常非常适合压缩。 -看看 [图 4-7](#fig_column_store) 中每列的值序列:它们看起来经常重复,这是压缩的良好迹象。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 *位图编码*,如 [图 4-8](#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。 +通常,列中不同值的数量与行数相比很小(例如,零售商可能有数十亿条销售交易,但只有 100,000 种不同的产品)。我们现在可以将具有 **n** 个不同值的列转换为 **n** 个单独的位图:每个不同值一个位图,每行一位。如果该行具有该值,则该位为 1,否则为 0。 -一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 *稀疏* 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](#fig_bitmap_index) 底部所示。诸如 *咆哮位图*(*roaring bitmaps*)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。 +一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 **稀疏** 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如 [图 4-8](#fig_bitmap_index) 底部所示。诸如 **咆哮位图**(**roaring bitmaps**)之类的技术在两种位图表示之间切换,使用最紧凑的表示 [^73]。这可以使列的编码非常高效。 像这样的位图索引非常适合数据仓库中常见的查询类型。例如: `WHERE product_sk IN (31, 68, 69):` -: 加载 `product_sk = 31`、`product_sk = 68` 和 `product_sk = 69` 的三个位图,并计算三个位图的按位 *OR*,这可以非常高效地完成。 +: 加载 `product_sk = 31`、`product_sk = 68` 和 `product_sk = 69` 的三个位图,并计算三个位图的按位 **OR**,这可以非常高效地完成。 `WHERE product_sk = 30 AND store_sk = 3:` -: 加载 `product_sk = 30` 和 `store_sk = 3` 的位图,并计算按位 *AND*。这有效是因为列以相同的顺序包含行,所以一列位图中的第 *k* 位对应于另一列位图中第 *k* 位的同一行。 +: 加载 `product_sk = 30` 和 `store_sk = 3` 的位图,并计算按位 **AND**。这有效是因为列以相同的顺序包含行,所以一列位图中的第 **k** 位对应于另一列位图中第 **k** 位的同一行。 -位图也可用于回答图查询,例如查找社交网络中被用户 *X* 关注并且也关注用户 *Y* 的所有用户 [^74]。列式数据库还有各种其他压缩方案,你可以在参考文献中找到 [^75]。 +位图也可用于回答图查询,例如查找社交网络中被用户 **X** 关注并且也关注用户 **Y** 的所有用户 [^74]。列式数据库还有各种其他压缩方案,你可以在参考文献中找到 [^75]。 -------- @@ -441,7 +441,7 @@ GROUP BY 在列存储中,行的存储顺序并不一定重要。最简单的是按插入顺序存储它们,因为这样插入新行只需追加到每列。但是,我们可以选择强制执行顺序,就像我们之前对 SSTable 所做的那样,并将其用作索引机制。 -请注意,独立排序每列是没有意义的,因为那样我们就不再知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第 *k* 个项与另一列中的第 *k* 个项属于同一行。 +请注意,独立排序每列是没有意义的,因为那样我们就不再知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第 **k** 个项与另一列中的第 **k** 个项属于同一行。 相反,数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(例如上个月),则将 `date_key` 作为第一个排序键可能是有意义的。然后查询可以只扫描上个月的行,这将比扫描所有行快得多。 @@ -464,7 +464,7 @@ GROUP BY ### 查询执行:编译与向量化 {#sec_storage_vectorized} -用于分析的复杂 SQL 查询被分解为由多个阶段组成的 *查询计划*,称为 *算子*,这些算子可能分布在多台机器上以并行执行。查询规划器可以通过选择使用哪些算子、以何种顺序执行它们以及在哪里运行每个算子来执行大量优化。 +用于分析的复杂 SQL 查询被分解为由多个阶段组成的 **查询计划**,称为 **算子**,这些算子可能分布在多台机器上以并行执行。查询规划器可以通过选择使用哪些算子、以何种顺序执行它们以及在哪里运行每个算子来执行大量优化。 在每个算子内,查询引擎需要对列中的值执行各种操作,例如查找值在特定值集中的所有行(可能作为连接的一部分),或检查值是否大于 15。它还需要查看同一行的几列,例如查找产品是香蕉且门店是某个特定目标门店的所有销售交易。 @@ -489,11 +489,11 @@ GROUP BY ### 物化视图与数据立方体 {#sec_storage_materialized_views} -我们之前在 ["物化和更新时间线"](/ch2#sec_introduction_materializing) 中遇到了 *物化视图*:在关系数据模型中,它们是表状对象,其内容是某些查询的结果。区别在于物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时,SQL 引擎会即时将其扩展为视图的基础查询,然后处理扩展的查询。 +我们之前在 ["物化和更新时间线"](/ch2#sec_introduction_materializing) 中遇到了 **物化视图**:在关系数据模型中,它们是表状对象,其内容是某些查询的结果。区别在于物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时,SQL 引擎会即时将其扩展为视图的基础查询,然后处理扩展的查询。 当基础数据更改时,物化视图需要相应更新。一些数据库可以自动执行此操作,还有像 Materialize 这样专门从事物化视图维护的系统 [^81]。执行此类更新意味着写入时需要更多工作,但物化视图可以改善在重复需要执行相同查询的工作负载中的读取性能。 -*物化聚合* 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,例如 SQL 中的 `COUNT`、`SUM`、`AVG`、`MIN` 或 `MAX`。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能会很浪费。为什么不缓存查询最常使用的一些计数或总和?*数据立方体*(*OLAP 立方体*)通过创建按不同维度分组的聚合网格来做到这一点 [^82]。[图 4-10](#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" >}} @@ -510,9 +510,9 @@ GROUP BY 我们在本章前半部分看到的 B 树和 LSM 树允许对单个属性进行范围查询:例如,如果键是用户名,你可以使用它们作为索引来高效查找所有以 L 开头的名称。但有时,按单个属性搜索是不够的。 -最常见的多列索引类型称为 *联合索引*,它通过将一列追加到另一列来将几个字段组合成一个键(索引定义指定字段以何种顺序连接)。这就像老式的纸质电话簿,它提供从(*姓氏*、*名字*)到电话号码的索引。由于排序顺序,索引可用于查找具有特定姓氏的所有人,或具有特定 *姓氏-名字* 组合的所有人。但是,如果你想查找具有特定名字的所有人,索引是无用的。 +最常见的多列索引类型称为 **联合索引**,它通过将一列追加到另一列来将几个字段组合成一个键(索引定义指定字段以何种顺序连接)。这就像老式的纸质电话簿,它提供从(**姓氏**、**名字**)到电话号码的索引。由于排序顺序,索引可用于查找具有特定姓氏的所有人,或具有特定 **姓氏-名字** 组合的所有人。但是,如果你想查找具有特定名字的所有人,索引是无用的。 -另一方面,*多维索引* 允许你一次查询多个列。在地理空间数据中这尤其重要。例如,餐厅搜索网站可能有一个包含每个餐厅的纬度和经度的数据库。当用户在地图上查看餐厅时,网站需要搜索用户当前查看的矩形地图区域内的所有餐厅。这需要像以下这样的二维范围查询: +另一方面,**多维索引** 允许你一次查询多个列。在地理空间数据中这尤其重要。例如,餐厅搜索网站可能有一个包含每个餐厅的纬度和经度的数据库。当用户在地图上查看餐厅时,网站需要搜索用户当前查看的矩形地图区域内的所有餐厅。这需要像以下这样的二维范围查询: ```sql SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 @@ -523,30 +523,30 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引 [^83]。更常见的是,使用专门的空间索引,如 R 树或 Bkd 树 [^84];它们划分空间,使附近的数据点倾向于分组在同一子树中。例如,PostGIS 使用 PostgreSQL 的通用搜索树索引设施将地理空间索引实现为 R 树 [^85]。也可以使用规则间隔的三角形、正方形或六边形网格 [^86]。 -多维索引不仅用于地理位置。例如,在电子商务网站上,你可以在维度(*红色*、*绿色*、*蓝色*)上使用三维索引来搜索某个颜色范围内的产品,或者在天气观测数据库中,你可以在(*日期*、*温度*)上有一个二维索引,以便有效地搜索 2013 年期间温度在 25 到 30°C 之间的所有观测。使用一维索引,你必须扫描 2013 年的所有记录(不管温度),然后按温度过滤它们,反之亦然。二维索引可以同时按时间戳和温度缩小范围 [^87]。 +多维索引不仅用于地理位置。例如,在电子商务网站上,你可以在维度(**红色**、**绿色**、**蓝色**)上使用三维索引来搜索某个颜色范围内的产品,或者在天气观测数据库中,你可以在(**日期**、**温度**)上有一个二维索引,以便有效地搜索 2013 年期间温度在 25 到 30°C 之间的所有观测。使用一维索引,你必须扫描 2013 年的所有记录(不管温度),然后按温度过滤它们,反之亦然。二维索引可以同时按时间戳和温度缩小范围 [^87]。 ### 全文检索 {#sec_storage_full_text} 全文检索允许你通过可能出现在文本中任何位置的关键字搜索文本文档集合(网页、产品描述等)[^88]。信息检索是一个大的专业主题,通常涉及特定于语言的处理:例如,几种亚洲语言在单词之间没有空格或标点符号,因此将文本分割成单词需要一个指示哪些字符序列构成单词的模型。全文检索还经常涉及匹配相似但不相同的单词(例如拼写错误或单词的不同语法形式)和同义词。这些问题超出了本书的范围。 -然而,在其核心,你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(*词项*)是一个维度。包含词项 *x* 的文档在维度 *x* 中的值为 1,不包含 *x* 的文档的值为 0。搜索提到“红苹果”的文档意味着查询在 *红* 维度中查找 1,同时在 *苹果* 维度中查找 1。维度数量可能因此非常大。 +然而,在其核心,你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(**词项**)是一个维度。包含词项 **x** 的文档在维度 **x** 中的值为 1,不包含 **x** 的文档的值为 0。搜索提到“红苹果”的文档意味着查询在 **红** 维度中查找 1,同时在 **苹果** 维度中查找 1。维度数量可能因此非常大。 -许多搜索引擎用来回答此类查询的数据结构称为 *倒排索引*。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(*倒排列表*)。如果文档 ID 是顺序数字,倒排列表也可以表示为稀疏位图,如 [图 4-8](#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](#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]。 -除了将文本分解为单词,另一种选择是查找长度为 *n* 的所有子字符串,称为 *n-gram*(*n 元语法*)。例如,字符串 `"hello"` 的三元语法(*n* = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元语法构建倒排索引,我们就可以搜索任意至少三个字符长的子字符串。三元语法索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。 +除了将文本分解为单词,另一种选择是查找长度为 **n** 的所有子字符串,称为 **n-gram**(**n 元语法**)。例如,字符串 `"hello"` 的三元语法(**n** = 3)是 `"hel"`、`"ell"` 和 `"llo"`。如果我们为所有三元语法构建倒排索引,我们就可以搜索任意至少三个字符长的子字符串。三元语法索引甚至允许在搜索查询中使用正则表达式;缺点是它们相当大 [^94]。 -为了处理文档或查询中的拼写错误,Lucene 能够在一定编辑距离内搜索文本中的单词(编辑距离为 1 意味着已添加、删除或替换了一个字母)[^95]。它通过将词项集存储为字符上的有限状态自动机(类似于 *字典树* [^96])并将其转换为 *莱文斯坦自动机* 来实现,该自动机支持在给定编辑距离内高效搜索单词 [^97]。 +为了处理文档或查询中的拼写错误,Lucene 能够在一定编辑距离内搜索文本中的单词(编辑距离为 1 意味着已添加、删除或替换了一个字母)[^95]。它通过将词项集存储为字符上的有限状态自动机(类似于 **字典树** [^96])并将其转换为 **莱文斯坦自动机** 来实现,该自动机支持在给定编辑距离内高效搜索单词 [^97]。 ### 向量嵌入 {#id92} 语义搜索超越了同义词和拼写错误,试图理解文档概念和用户意图。例如,如果你的帮助页面中有一个标题为“取消订阅”的页面,用户在搜索“如何关闭我的账户”或“终止合同”时,仍应能找到这个页面,即使查询词完全不同,但语义非常接近。 -为了理解文档的语义 —— 它的含义 —— 语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 *向量嵌入*。向量表示多维空间中的一个点,每个浮点值表示文档沿着一个维度轴的位置。嵌入模型生成的向量嵌入在(这个多维空间中)彼此接近,当嵌入的输入文档在语义上相似时。 +为了理解文档的语义 —— 它的含义 —— 语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 **向量嵌入**。向量表示多维空间中的一个点,每个浮点值表示文档沿着一个维度轴的位置。嵌入模型生成的向量嵌入在(这个多维空间中)彼此接近,当嵌入的输入文档在语义上相似时。 -------- @@ -559,7 +559,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 嵌入模型使用更大的向量(通常超过 1,000 个数字),但原理是相同的。我们不试图理解各个数字的含义;它们只是嵌入模型指向抽象多维空间中位置的一种方式。搜索引擎使用距离函数(如余弦相似度或欧几里得距离)来测量向量之间的距离。余弦相似度测量两个向量角度的余弦以确定它们的接近程度,而欧几里得距离测量空间中两点之间的直线距离。 -许多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100] 都处理文本数据。这些模型通常实现为神经网络。研究人员继续为视频、音频和图像创建嵌入模型。最近,模型架构已经变成 *多模态* 的:单个模型可以为多种模态(如文本和图像)生成向量嵌入。 +许多早期的嵌入模型,如 Word2Vec [^98]、BERT [^99] 和 GPT [^100] 都处理文本数据。这些模型通常实现为神经网络。研究人员继续为视频、音频和图像创建嵌入模型。最近,模型架构已经变成 **多模态** 的:单个模型可以为多种模态(如文本和图像)生成向量嵌入。 语义搜索引擎在用户输入查询时使用嵌入模型生成向量嵌入。用户的查询和相关上下文(例如用户的位置)被输入到嵌入模型中。嵌入模型生成查询的向量嵌入后,搜索引擎必须使用向量索引找到具有相似向量嵌入的文档。 @@ -569,7 +569,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 : 向量按原样存储在索引中。查询必须读取每个向量并测量其与查询向量的距离。平面索引是准确的,但测量查询与每个向量之间的距离很慢。 倒排文件(IVF)索引 -: 向量空间被聚类为向量的分区(称为 *质心*),以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。对 IVF 索引的查询首先定义 *探针*,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。 +: 向量空间被聚类为向量的分区(称为 **质心**),以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。对 IVF 索引的查询首先定义 **探针**,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。 分层可导航小世界(HNSW) : HNSW 索引维护向量空间的多个层,如 [图 4-11](#fig_vector_hnsw) 所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面一层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样,HNSW 索引是近似的。 diff --git a/content/zh/ch5.md b/content/zh/ch5.md index 7a05fb8..020ee88 100644 --- a/content/zh/ch5.md +++ b/content/zh/ch5.md @@ -13,7 +13,7 @@ breadcrumbs: false > > 赫拉克利特,引自柏拉图《克拉提鲁斯》(公元前 360 年) -应用程序不可避免地会随时间而变化。随着新产品的推出、用户需求被更深入地理解,或者业务环境发生变化,功能会被添加或修改。在 [第 2 章](/ch2#ch_nonfunctional) 中,我们介绍了 *可演化性* 的概念:我们应该致力于构建易于适应变化的系统(参见 ["可演化性:让变更更容易"](/ch2#sec_introduction_evolvability))。 +应用程序不可避免地会随时间而变化。随着新产品的推出、用户需求被更深入地理解,或者业务环境发生变化,功能会被添加或修改。在 [第 2 章](/ch2#ch_nonfunctional) 中,我们介绍了 **可演化性** 的概念:我们应该致力于构建易于适应变化的系统(参见 ["可演化性:让变更更容易"](/ch2#sec_introduction_evolvability))。 在大多数情况下,应用程序功能的变更也需要其存储数据的变更:可能需要捕获新的字段或记录类型,或者现有数据需要以新的方式呈现。 @@ -21,7 +21,7 @@ breadcrumbs: false 当数据格式或模式发生变化时,通常需要对应用程序代码进行相应的更改(例如,你向记录添加了一个新字段,应用程序代码开始读写该字段)。然而,在大型应用程序中,代码更改通常无法立即完成: -* 对于服务端应用程序,你可能希望执行 *滚动升级*(也称为 *阶段发布*),每次将新版本部署到几个节点,检查新版本是否运行顺利,然后逐步在所有节点上部署。这允许在不中断服务的情况下部署新版本,从而鼓励更频繁的发布和更好的可演化性。 +* 对于服务端应用程序,你可能希望执行 **滚动升级**(也称为 **阶段发布**),每次将新版本部署到几个节点,检查新版本是否运行顺利,然后逐步在所有节点上部署。这允许在不中断服务的情况下部署新版本,从而鼓励更频繁的发布和更好的可演化性。 * 对于客户端应用程序,你要看用户的意愿,他们可能很长时间都不安装更新。 这意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统中共存。为了使系统继续平稳运行,我们需要在两个方向上保持兼容性: @@ -47,7 +47,7 @@ breadcrumbs: false 1. 在内存中,数据保存在对象、结构体、列表、数组、哈希表、树等中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。 2. 当你想要将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(例如,JSON 文档)。由于指针对任何其他进程都没有意义,因此这种字节序列表示通常与内存中常用的数据结构看起来截然不同。 -因此,我们需要在两种表示之间进行某种转换。从内存表示到字节序列的转换称为 *编码*(也称为 *序列化* 或 *编组*),反向过程称为 *解码*(*解析*、*反序列化*、*反编组*)。 +因此,我们需要在两种表示之间进行某种转换。从内存表示到字节序列的转换称为 **编码**(也称为 **序列化** 或 **编组**),反向过程称为 **解码**(**解析**、**反序列化**、**反编组**)。 -------- @@ -57,7 +57,7 @@ breadcrumbs: false -------- -也有例外情况不需要编码/解码——例如,当数据库直接对从磁盘加载的压缩数据进行操作时,如 ["查询执行:编译与向量化"](/ch4#sec_storage_vectorized) 中所讨论的。还有一些 *零拷贝* 数据格式,旨在在运行时和磁盘/网络上都可以使用,无需显式转换步骤,例如 Cap'n Proto 和 FlatBuffers。 +也有例外情况不需要编码/解码——例如,当数据库直接对从磁盘加载的压缩数据进行操作时,如 ["查询执行:编译与向量化"](/ch4#sec_storage_vectorized) 中所讨论的。还有一些 **零拷贝** 数据格式,旨在在运行时和磁盘/网络上都可以使用,无需显式转换步骤,例如 Cap'n Proto 和 FlatBuffers。 然而,大多数系统需要在内存对象和平面字节序列之间进行转换。由于这是一个如此常见的问题,有无数不同的库和编码格式可供选择。让我们简要概述一下。 @@ -87,7 +87,7 @@ JSON、XML 和 CSV 是文本格式,因此在某种程度上是人类可读的 * XML 模式和 JSON 模式功能强大,因此学习和实现起来相当复杂。由于数据的正确解释(如数字和二进制字符串)取决于模式中的信息,不使用 XML/JSON 模式的应用程序需要潜在地硬编码适当的编码/解码逻辑。 * CSV 没有任何模式,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加了新行或列,你必须手动处理该更改。CSV 也是一种相当模糊的格式(如果值包含逗号或换行符会发生什么?)。尽管其转义规则已被正式指定 [^9],但并非所有解析器都正确实现它们。 -尽管存在这些缺陷,JSON、XML 和 CSV 对许多目的来说已经足够好了。它们可能会继续流行,特别是作为数据交换格式(即从一个组织向另一个组织发送数据)。在这些情况下,只要人们就格式达成一致,格式有多漂亮或高效通常并不重要。让不同组织就 *任何事情* 达成一致的困难超过了大多数其他问题。 +尽管存在这些缺陷,JSON、XML 和 CSV 对许多目的来说已经足够好了。它们可能会继续流行,特别是作为数据交换格式(即从一个组织向另一个组织发送数据)。在这些情况下,只要人们就格式达成一致,格式有多漂亮或高效通常并不重要。让不同组织就 **任何事情** 达成一致的困难超过了大多数其他问题。 #### JSON 模式 {#json-schema} @@ -95,7 +95,7 @@ JSON 模式已被广泛采用,作为系统间交换或写入存储时对数据 JSON 模式规范提供了许多功能。模式包括标准原始类型,包括字符串、数字、整数、对象、数组、布尔值或空值。但 JSON 模式还提供了一个单独的验证规范,允许开发人员在字段上叠加约束。例如,`port` 字段可能具有最小值 1 和最大值 65535。 -JSON 模式可以具有开放或封闭的内容模型。开放内容模型允许模式中未定义的任何字段以任何数据类型存在,而封闭内容模型只允许显式定义的字段。JSON 模式中的开放内容模型在 `additionalProperties` 设置为 `true` 时启用,这是默认值。因此,JSON 模式通常是对 *不允许* 内容的定义(即,任何已定义字段上的无效值),而不是对模式中 *允许* 内容的定义。 +JSON 模式可以具有开放或封闭的内容模型。开放内容模型允许模式中未定义的任何字段以任何数据类型存在,而封闭内容模型只允许显式定义的字段。JSON 模式中的开放内容模型在 `additionalProperties` 设置为 `true` 时启用,这是默认值。因此,JSON 模式通常是对 **不允许** 内容的定义(即,任何已定义字段上的无效值),而不是对模式中 **允许** 内容的定义。 开放内容模型功能强大,但可能很复杂。例如,假设你想定义一个从整数(如 ID)到字符串的映射。JSON 没有映射或字典类型,只有一个可以包含字符串键和任何类型值的"对象"类型。然后,你可以使用 JSON 模式约束此类型,使键只能包含数字,值只能是字符串,使用 `patternProperties` 和 `additionalProperties`,如 [示例 5-1](#fig_encoding_json_schema) 所示。 @@ -170,7 +170,7 @@ Protocol Buffers 附带了一个代码生成工具,它接受像这里显示的 与 [图 5-2](#fig_encoding_messagepack) 类似,每个字段都有一个类型注释(指示它是字符串、整数等)以及必要时的长度指示(例如字符串的长度)。数据中出现的字符串("Martin"、"daydreaming"、"hacking")也编码为 ASCII(准确地说是 UTF-8),与之前类似。 -与 [图 5-2](#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 之间的数字以两个字节编码,等等。更大的数字使用更多字节。 @@ -178,7 +178,7 @@ Protocol Buffers 没有显式的列表或数组数据类型。相反,`interest #### 字段标签与模式演化 {#field-tags-and-schema-evolution} -我们之前说过,模式不可避免地需要随时间而变化。我们称之为 *模式演化*。Protocol Buffers 如何在保持向后和向前兼容性的同时处理模式更改? +我们之前说过,模式不可避免地需要随时间而变化。我们称之为 **模式演化**。Protocol Buffers 如何在保持向后和向前兼容性的同时处理模式更改? 从示例中可以看出,编码记录只是其编码字段的串联。每个字段由其标签号(示例模式中的数字 `1`、`2`、`3`)标识,并带有数据类型注释(例如字符串或整数)。如果未设置字段值,则它会从编码记录中省略。由此可以看出,字段标签对编码数据的含义至关重要。你可以更改模式中字段的名称,因为编码数据从不引用字段名,但你不能更改字段的标签,因为这会使所有现有的编码数据无效。 @@ -227,15 +227,15 @@ record Person { {{< figure src="/fig/ddia_0504.png" id="fig_encoding_avro" caption="图 5-4. 使用 Avro 编码的示例记录。" class="w-full my-4" >}} -要解析二进制数据,你需要按照模式中出现的字段顺序进行遍历,并使用模式告诉你每个字段的数据类型。这意味着只有当读取数据的代码使用与写入数据的代码 *完全相同的模式* 时,二进制数据才能被正确解码。读取器和写入器之间的任何模式不匹配都意味着数据被错误解码。 +要解析二进制数据,你需要按照模式中出现的字段顺序进行遍历,并使用模式告诉你每个字段的数据类型。这意味着只有当读取数据的代码使用与写入数据的代码 **完全相同的模式** 时,二进制数据才能被正确解码。读取器和写入器之间的任何模式不匹配都意味着数据被错误解码。 那么,Avro 如何支持模式演化? #### 写入者模式与读取者模式 {#the-writers-schema-and-the-readers-schema} -当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式对数据进行编码——例如,该模式可能被编译到应用程序中。这被称为 *写入者模式*。 +当应用程序想要编码一些数据(将其写入文件或数据库,通过网络发送等)时,它使用它知道的任何版本的模式对数据进行编码——例如,该模式可能被编译到应用程序中。这被称为 **写入者模式**。 -当应用程序想要解码一些数据(从文件或数据库读取,从网络接收等)时,它使用两个模式:与用于编码相同的写入者模式,以及 *读取者模式*,后者可能不同。这在 [图 5-5](#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" >}} @@ -253,7 +253,7 @@ record Person { 如果你要添加一个没有默认值的字段,新读取者将无法读取旧写入者写入的数据,因此你会破坏向后兼容性。如果你要删除一个没有默认值的字段,旧读取者将无法读取新写入者写入的数据,因此你会破坏向前兼容性。 -在某些编程语言中,`null` 是任何变量的可接受默认值,但在 Avro 中不是这样:如果你想允许字段为 null,你必须使用 *联合类型*。例如,`union { null, long, string } field;` 表示 `field` 可以是数字、字符串或 null。只有当 `null` 是联合的第一个分支时,你才能将其用作默认值。这比默认情况下一切都可为空更冗长一些,但它通过明确什么可以和不能为 null 来帮助防止错误 [^18]。 +在某些编程语言中,`null` 是任何变量的可接受默认值,但在 Avro 中不是这样:如果你想允许字段为 null,你必须使用 **联合类型**。例如,`union { null, long, string } field;` 表示 `field` 可以是数字、字符串或 null。只有当 `null` 是联合的第一个分支时,你才能将其用作默认值。这比默认情况下一切都可为空更冗长一些,但它通过明确什么可以和不能为 null 来帮助防止错误 [^18]。 更改字段的数据类型是可能的,前提是 Avro 可以转换该类型。更改字段的名称是可能的,但有点棘手:读取者模式可以包含字段名的别名,因此它可以将旧写入者的模式字段名与别名匹配。这意味着更改字段名是向后兼容的,但不是向前兼容的。同样,向联合类型添加分支是向后兼容的,但不是向前兼容的。 @@ -280,7 +280,7 @@ record Person { 与 Protocol Buffers 相比,Avro 方法的一个优点是模式不包含任何标签号。但为什么这很重要?在模式中保留几个数字有什么问题? -区别在于 Avro 对 *动态生成* 的模式更友好。例如,假设你有一个关系数据库,其内容你想要转储到文件中,并且你想要使用二进制格式来避免前面提到的文本格式(JSON、CSV、XML)的问题。如果你使用 Avro,你可以相当容易地从关系模式生成 Avro 模式(我们之前看到的 JSON 表示),并使用该模式对数据库内容进行编码,将其全部转储到 Avro 对象容器文件中 [^22]。你可以为每个数据库表生成记录模式,每列成为该记录中的一个字段。数据库中的列名映射到 Avro 中的字段名。 +区别在于 Avro 对 **动态生成** 的模式更友好。例如,假设你有一个关系数据库,其内容你想要转储到文件中,并且你想要使用二进制格式来避免前面提到的文本格式(JSON、CSV、XML)的问题。如果你使用 Avro,你可以相当容易地从关系模式生成 Avro 模式(我们之前看到的 JSON 表示),并使用该模式对数据库内容进行编码,将其全部转储到 Avro 对象容器文件中 [^22]。你可以为每个数据库表生成记录模式,每列成为该记录中的一个字段。数据库中的列名映射到 Avro 中的字段名。 现在,如果数据库模式发生变化(例如,表添加了一列并删除了一列),你可以从更新的数据库模式生成新的 Avro 模式,并以新的 Avro 模式导出数据。数据导出过程不需要关注模式更改——它可以在每次运行时简单地进行模式转换。读取新数据文件的任何人都会看到记录的字段已更改,但由于字段是按名称标识的,因此更新的写入者模式仍然可以与旧的读取者模式匹配。 @@ -318,21 +318,21 @@ record Person { ### 流经数据库的数据流 {#sec_encoding_dataflow_db} -在数据库中,写入数据库的进程对数据进行编码,从数据库读取的进程对其进行解码。可能只有一个进程访问数据库,在这种情况下,读取者只是同一进程的后续版本——在这种情况下,你可以将在数据库中存储某些内容视为 *向未来的自己发送消息*。 +在数据库中,写入数据库的进程对数据进行编码,从数据库读取的进程对其进行解码。可能只有一个进程访问数据库,在这种情况下,读取者只是同一进程的后续版本——在这种情况下,你可以将在数据库中存储某些内容视为 **向未来的自己发送消息**。 向后兼容性在这里显然是必要的;否则你未来的自己将无法解码你之前写的内容。 通常,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是同一服务的几个实例(为了可伸缩性或容错而并行运行)。无论哪种方式,在应用程序正在更改的环境中,某些访问数据库的进程可能正在运行较新的代码,而某些进程正在运行较旧的代码——例如,因为新版本当前正在滚动升级中部署,因此某些实例已更新,而其他实例尚未更新。 -这意味着数据库中的值可能由 *较新* 版本的代码写入,随后由仍在运行的 *较旧* 版本的代码读取。因此,数据库通常也需要向前兼容性。 +这意味着数据库中的值可能由 **较新** 版本的代码写入,随后由仍在运行的 **较旧** 版本的代码读取。因此,数据库通常也需要向前兼容性。 #### 不同时间写入的不同值 {#different-values-written-at-different-times} 数据库通常允许在任何时间更新任何值。这意味着在单个数据库中,你可能有一些五毫秒前写入的值,以及一些五年前写入的值。 -当你部署应用程序的新版本时(至少是服务端应用程序),你可能会在几分钟内用新版本完全替换旧版本。数据库内容并非如此:五年前的数据仍然存在,采用原始编码,除非你自那时以来明确重写了它。这种观察有时被总结为 *数据比代码更长寿*。 +当你部署应用程序的新版本时(至少是服务端应用程序),你可能会在几分钟内用新版本完全替换旧版本。数据库内容并非如此:五年前的数据仍然存在,采用原始编码,除非你自那时以来明确重写了它。这种观察有时被总结为 **数据比代码更长寿**。 -将数据重写(*迁移*)为新模式当然是可能的,但在大型数据集上这是一件昂贵的事情,因此大多数数据库尽可能避免它。大多数关系数据库允许简单的模式更改,例如添加具有 `null` 默认值的新列,而无需重写现有数据。从磁盘上的编码数据中缺少的任何列读取旧行时,数据库会为其填充 `null`。因此,模式演化允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。 +将数据重写(**迁移**)为新模式当然是可能的,但在大型数据集上这是一件昂贵的事情,因此大多数数据库尽可能避免它。大多数关系数据库允许简单的模式更改,例如添加具有 `null` 默认值的新列,而无需重写现有数据。从磁盘上的编码数据中缺少的任何列读取旧行时,数据库会为其填充 `null`。因此,模式演化允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。 更复杂的模式更改——例如,将单值属性更改为多值,或将某些数据移动到单独的表中——仍然需要重写数据,通常在应用程序级别 [^27]。在此类迁移中保持向前和向后兼容性仍然是一个研究问题 [^28]。 @@ -346,7 +346,7 @@ record Person { ### 流经服务的数据流:REST 与 RPC {#sec_encoding_dataflow_rpc} -当你有需要通过网络进行通信的进程时,有几种不同的方式来安排这种通信。最常见的安排是有两个角色:*客户端* 和 *服务器*。服务器通过网络公开 API,客户端可以连接到服务器以向该 API 发出请求。服务器公开的 API 称为 *服务*。 +当你有需要通过网络进行通信的进程时,有几种不同的方式来安排这种通信。最常见的安排是有两个角色:**客户端** 和 **服务器**。服务器通过网络公开 API,客户端可以连接到服务器以向该 API 发出请求。服务器公开的 API 称为 **服务**。 Web 就是这样工作的:客户端(Web 浏览器)向 Web 服务器发出请求,发出 `GET` 请求以下载 HTML、CSS、JavaScript、图像等,并发出 `POST` 请求以向服务器提交数据。API 由一组标准化的协议和数据格式(HTTP、URL、SSL/TLS、HTML 等)组成。由于 Web 浏览器、Web 服务器和网站作者大多同意这些标准,因此你可以使用任何 Web 浏览器访问任何网站(至少在理论上!)。 @@ -358,13 +358,13 @@ Web 浏览器不是唯一类型的客户端。例如,在移动设备和桌面 #### Web 服务 {#sec_web_services} -当 HTTP 用作与服务通信的底层协议时,它被称为 *Web 服务*。Web 服务通常用于构建面向服务或微服务架构(在 ["微服务与 Serverless"](/ch1#sec_introduction_microservices) 中讨论过)。术语"Web 服务"可能有点用词不当,因为 Web 服务不仅用于 Web,还用于几种不同的上下文。例如: +当 HTTP 用作与服务通信的底层协议时,它被称为 **Web 服务**。Web 服务通常用于构建面向服务或微服务架构(在 ["微服务与 Serverless"](/ch1#sec_introduction_microservices) 中讨论过)。术语"Web 服务"可能有点用词不当,因为 Web 服务不仅用于 Web,还用于几种不同的上下文。例如: 1. 在用户设备上运行的客户端应用程序(例如,移动设备上的原生应用程序,或浏览器中的 JavaScript Web 应用程序)向服务发出 HTTP 请求。这些请求通常通过公共互联网进行。 2. 一个服务向同一组织拥有的另一个服务发出请求,通常位于同一数据中心内,作为面向服务/微服务架构的一部分。 3. 一个服务向不同组织拥有的服务发出请求,通常通过互联网。这用于不同组织后端系统之间的数据交换。此类别包括在线服务提供的公共 API,例如信用卡处理系统或用于共享访问用户数据的 OAuth。 -最流行的服务设计理念是 REST,它建立在 HTTP 的原则之上 [^30] [^31]。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制、身份验证和内容类型协商。根据 REST 原则设计的 API 称为 *RESTful*。 +最流行的服务设计理念是 REST,它建立在 HTTP 的原则之上 [^30] [^31]。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制、身份验证和内容类型协商。根据 REST 原则设计的 API 称为 **RESTful**。 需要调用 Web 服务 API 的代码必须知道要查询哪个 HTTP 端点,以及发送什么数据格式以及预期的响应。即使服务采用 RESTful 设计原则,客户端也需要以某种方式找出这些详细信息。服务开发人员通常使用接口定义语言(IDL)来定义和记录其服务的 API 端点和数据模型,并随着时间的推移演化它们。然后,其他开发人员可以使用服务定义来确定如何查询服务。两种最流行的服务 IDL 是 OpenAPI(也称为 Swagger [^32])和 gRPC。OpenAPI 用于发送和接收 JSON 数据的 Web 服务,而 gRPC 服务发送和接收 Protocol Buffers。 @@ -421,11 +421,11 @@ async def ping(): Web 服务只是通过网络进行 API 请求的一长串技术的最新化身,其中许多技术获得了大量炒作但存在严重问题。Enterprise JavaBeans (EJB) 和 Java 的远程方法调用 (RMI) 仅限于 Java。分布式组件对象模型 (DCOM) 仅限于 Microsoft 平台。公共对象请求代理架构 (CORBA) 过于复杂,并且不提供向后或向前兼容性 [^33]。SOAP 和 WS-\* Web 服务框架旨在提供跨供应商的互操作性,但也受到复杂性和兼容性问题的困扰 [^34] [^35] [^36]。 -所有这些都基于 *远程过程调用* (RPC) 的想法,这个想法自 1970 年代以来就存在了 [^37]。RPC 模型试图使向远程网络服务的请求看起来与在编程语言中调用函数或方法相同,在同一进程内(这种抽象称为 *位置透明性*)。尽管 RPC 起初似乎很方便,但这种方法从根本上是有缺陷的 [^38] [^39]。网络请求与本地函数调用非常不同: +所有这些都基于 **远程过程调用** (RPC) 的想法,这个想法自 1970 年代以来就存在了 [^37]。RPC 模型试图使向远程网络服务的请求看起来与在编程语言中调用函数或方法相同,在同一进程内(这种抽象称为 **位置透明性**)。尽管 RPC 起初似乎很方便,但这种方法从根本上是有缺陷的 [^38] [^39]。网络请求与本地函数调用非常不同: * 本地函数调用是可预测的,要么成功要么失败,仅取决于你控制的参数。网络请求是不可预测的:由于网络问题,请求或响应可能会丢失,或者远程机器可能速度慢或不可用,而这些问题完全超出了你的控制。网络问题很常见,因此你必须预料到它们,例如通过重试失败的请求。 -* 本地函数调用要么返回结果,要么抛出异常,要么永不返回(因为它进入无限循环或进程崩溃)。网络请求有另一种可能的结果:它可能由于 *超时* 而没有返回结果。在这种情况下,你根本不知道发生了什么:如果你没有从远程服务获得响应,你无法知道请求是否通过。(我们在 [第 9 章](/ch9#ch_distributed) 中更详细地讨论了这个问题。) -* 如果你重试失败的网络请求,可能会发生前一个请求实际上已经成功,只是响应丢失了。在这种情况下,重试将导致操作执行多次,除非你在协议中构建去重机制(*幂等性*)[^40]。本地函数调用没有这个问题。(我们在 [“幂等性”](/ch12#sec_stream_idempotence) 中更详细地讨论幂等性。) +* 本地函数调用要么返回结果,要么抛出异常,要么永不返回(因为它进入无限循环或进程崩溃)。网络请求有另一种可能的结果:它可能由于 **超时** 而没有返回结果。在这种情况下,你根本不知道发生了什么:如果你没有从远程服务获得响应,你无法知道请求是否通过。(我们在 [第 9 章](/ch9#ch_distributed) 中更详细地讨论了这个问题。) +* 如果你重试失败的网络请求,可能会发生前一个请求实际上已经成功,只是响应丢失了。在这种情况下,重试将导致操作执行多次,除非你在协议中构建去重机制(**幂等性**)[^40]。本地函数调用没有这个问题。(我们在 [“幂等性”](/ch12#sec_stream_idempotence) 中更详细地讨论幂等性。) * 每次调用本地函数时,通常需要大约相同的时间来执行。网络请求比函数调用慢得多,其延迟也变化很大:在良好的时候,它可能在不到一毫秒内完成,但当网络拥塞或远程服务过载时,执行完全相同的操作可能需要许多秒。 * 当你调用本地函数时,你可以有效地将引用(指针)传递给本地内存中的对象。当你发出网络请求时,所有这些参数都需要编码为可以通过网络发送的字节序列。如果参数是不可变的原语,如数字或短字符串,那没问题,但对于更大量的数据和可变对象,它很快就会出现问题。 * 客户端和服务可能以不同的编程语言实现,因此 RPC 框架必须将数据类型从一种语言转换为另一种语言。这可能会变得很丑陋,因为并非所有语言都具有相同的类型——例如,回想一下 JavaScript 处理大于 2⁵³ 的数字的问题(参见 ["JSON、XML 及其二进制变体"](#sec_encoding_json))。单一语言编写的单个进程中不存在此问题。 @@ -434,17 +434,17 @@ Web 服务只是通过网络进行 API 请求的一长串技术的最新化身 #### 负载均衡器、服务发现和服务网格 {#sec_encoding_service_discovery} -所有服务都通过网络进行通信。因此,客户端必须知道它正在连接的服务的地址——这个问题称为 *服务发现*。最简单的方法是配置客户端连接到运行服务的 IP 地址和端口。此配置可以工作,但如果服务器离线、转移到新机器或变得过载,则必须手动重新配置客户端。 +所有服务都通过网络进行通信。因此,客户端必须知道它正在连接的服务的地址——这个问题称为 **服务发现**。最简单的方法是配置客户端连接到运行服务的 IP 地址和端口。此配置可以工作,但如果服务器离线、转移到新机器或变得过载,则必须手动重新配置客户端。 -为了提供更高的可用性和可伸缩性,通常在不同的机器上运行服务的多个实例,其中任何一个都可以处理传入的请求。将请求分散到这些实例上称为 *负载均衡* [^41]。有许多负载均衡和服务发现解决方案可用: +为了提供更高的可用性和可伸缩性,通常在不同的机器上运行服务的多个实例,其中任何一个都可以处理传入的请求。将请求分散到这些实例上称为 **负载均衡** [^41]。有许多负载均衡和服务发现解决方案可用: -* *硬件负载均衡器* 是安装在数据中心的专用设备。它们允许客户端连接到单个主机和端口,传入连接被路由到运行服务的服务器之一。此类负载均衡器在连接到下游服务器时检测网络故障,并将流量转移到其他服务器。 -* *软件负载均衡器* 的行为方式与硬件负载均衡器大致相同。但是,软件负载均衡器(如 Nginx 和 HAProxy)不需要特殊设备,而是可以安装在标准机器上的应用程序。 -* *域名服务 (DNS)* 是当你打开网页时在互联网上解析域名的方式。它通过允许多个 IP 地址与单个域名关联来支持负载均衡。然后,客户端可以配置为使用域名而不是 IP 地址连接到服务,并且客户端的网络层在建立连接时选择要使用的 IP 地址。这种方法的一个缺点是 DNS 旨在在较长时间内传播更改并缓存 DNS 条目。如果服务器频繁启动、停止或移动,客户端可能会看到不再有服务器运行的陈旧 IP 地址。 -* *服务发现系统* 使用集中式注册表而不是 DNS 来跟踪哪些服务端点可用。当新服务实例启动时,它通过声明它正在侦听的主机和端口以及相关元数据(如分片所有权信息(参见 [第 7 章](/ch7#ch_sharding))、数据中心位置等)向服务发现系统注册自己。然后,服务定期向发现系统发送心跳信号,以表明服务仍然可用。 +* **硬件负载均衡器** 是安装在数据中心的专用设备。它们允许客户端连接到单个主机和端口,传入连接被路由到运行服务的服务器之一。此类负载均衡器在连接到下游服务器时检测网络故障,并将流量转移到其他服务器。 +* **软件负载均衡器** 的行为方式与硬件负载均衡器大致相同。但是,软件负载均衡器(如 Nginx 和 HAProxy)不需要特殊设备,而是可以安装在标准机器上的应用程序。 +* **域名服务 (DNS)** 是当你打开网页时在互联网上解析域名的方式。它通过允许多个 IP 地址与单个域名关联来支持负载均衡。然后,客户端可以配置为使用域名而不是 IP 地址连接到服务,并且客户端的网络层在建立连接时选择要使用的 IP 地址。这种方法的一个缺点是 DNS 旨在在较长时间内传播更改并缓存 DNS 条目。如果服务器频繁启动、停止或移动,客户端可能会看到不再有服务器运行的陈旧 IP 地址。 +* **服务发现系统** 使用集中式注册表而不是 DNS 来跟踪哪些服务端点可用。当新服务实例启动时,它通过声明它正在侦听的主机和端口以及相关元数据(如分片所有权信息(参见 [第 7 章](/ch7#ch_sharding))、数据中心位置等)向服务发现系统注册自己。然后,服务定期向发现系统发送心跳信号,以表明服务仍然可用。 当客户端希望连接到服务时,它首先查询发现系统以获取可用端点列表,然后直接连接到端点。与 DNS 相比,服务发现支持服务实例频繁更改的更动态环境。发现系统还为客户端提供有关它们正在连接的服务的更多元数据,这使客户端能够做出更智能的负载均衡决策。 -* *服务网格* 是一种复杂的负载均衡形式,它结合了软件负载均衡器和服务发现。与在单独机器上运行的传统软件负载均衡器不同,服务网格负载均衡器通常作为进程内客户端库或作为客户端和服务器上的进程或"边车"容器部署。客户端应用程序连接到它们自己的本地服务负载均衡器,该负载均衡器连接到服务器的负载均衡器。从那里,连接被路由到本地服务器进程。 +* **服务网格** 是一种复杂的负载均衡形式,它结合了软件负载均衡器和服务发现。与在单独机器上运行的传统软件负载均衡器不同,服务网格负载均衡器通常作为进程内客户端库或作为客户端和服务器上的进程或"边车"容器部署。客户端应用程序连接到它们自己的本地服务负载均衡器,该负载均衡器连接到服务器的负载均衡器。从那里,连接被路由到本地服务器进程。 虽然复杂,但这种拓扑提供了许多优势。由于客户端和服务器完全通过本地连接路由,因此连接加密可以完全在负载均衡器级别处理。这使客户端和服务器免于处理 SSL 证书和 TLS 的复杂性。网格系统还提供复杂的可观测性。它们可以实时跟踪哪些服务正在相互调用,检测故障,跟踪流量负载等。 @@ -467,7 +467,7 @@ RPC 方案的向后和向前兼容性属性继承自它使用的任何编码: 根据定义,基于服务的架构具有多个服务,这些服务都负责应用程序的不同部分。考虑一个处理信用卡并将资金存入银行账户的支付处理应用程序。该系统可能有不同的服务负责欺诈检测、信用卡集成、银行集成等。 -在我们的示例中,处理单个付款需要许多服务调用。支付处理器服务可能会调用欺诈检测服务以检查欺诈,调用信用卡服务以扣除信用卡费用,并调用银行服务以存入扣除的资金,如 [图 5-7](#fig_encoding_workflow) 所示。我们将这一系列步骤称为 *工作流*,每个步骤称为 *任务*。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言 (DSL) 或标记语言(如业务流程执行语言 (BPEL))[^44] 编写。 +在我们的示例中,处理单个付款需要许多服务调用。支付处理器服务可能会调用欺诈检测服务以检查欺诈,调用信用卡服务以扣除信用卡费用,并调用银行服务以存入扣除的资金,如 [图 5-7](#fig_encoding_workflow) 所示。我们将这一系列步骤称为 **工作流**,每个步骤称为 **任务**。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言 (DSL) 或标记语言(如业务流程执行语言 (BPEL))[^44] 编写。 -------- @@ -480,17 +480,17 @@ RPC 方案的向后和向前兼容性属性继承自它使用的任何编码: {{< figure src="/fig/ddia_0507.png" id="fig_encoding_workflow" title="图 5-7. 使用业务流程模型和标记法 (BPMN) 表示的工作流示例,这是一种图形标记法。" class="w-full my-4" >}} -工作流由 *工作流引擎* 运行或执行。工作流引擎确定何时运行每个任务、任务必须在哪台机器上运行、如果任务失败该怎么办(例如,如果机器在任务运行时崩溃)、允许并行执行多少任务等。 +工作流由 **工作流引擎** 运行或执行。工作流引擎确定何时运行每个任务、任务必须在哪台机器上运行、如果任务失败该怎么办(例如,如果机器在任务运行时崩溃)、允许并行执行多少任务等。 工作流引擎通常由编排器和执行器组成。编排器负责调度要执行的任务,执行器负责执行任务。当工作流被触发时,执行开始。如果用户定义了基于时间的调度(例如每小时执行),则编排器会自行触发工作流。外部源(如 Web 服务)甚至人类也可以触发工作流执行。一旦触发,就会调用执行器来运行任务。 -有许多类型的工作流引擎可以满足各种各样的用例。有些,如 Airflow、Dagster 和 Prefect,与数据系统集成并编排 ETL 任务。其他的,如 Camunda 和 Orkes,为工作流提供图形标记法(如 [图 5-7](#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](#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。 +持久化执行框架是为工作流提供 **恰好一次语义** 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。这是可能的,因为持久化执行框架将所有 RPC 和状态更改记录到持久存储(如预写日志)[^45] [^46]。[示例 5-5](#fig_temporal_workflow) 显示了使用 Temporal 支持持久化执行的工作流定义示例。 {{< figure id="fig_temporal_workflow" title="示例 5-5. [图 5-7](#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定义片段。" class="w-full my-4" >}} @@ -527,7 +527,7 @@ class PaymentWorkflow: ### 事件驱动的架构 {#sec_encoding_dataflow_msg} -在这最后一节中,我们将简要介绍 *事件驱动架构*,这是编码数据从一个进程流向另一个进程的另一种方式。请求称为 *事件* 或 *消息*;与 RPC 不同,发送者通常不会等待接收者处理事件。此外,事件通常不是通过直接网络连接发送给接收者,而是通过称为 *消息代理*(也称为 *事件代理*、*消息队列* 或 *面向消息的中间件*)的中介,它临时存储消息 [^50]。 +在这最后一节中,我们将简要介绍 **事件驱动架构**,这是编码数据从一个进程流向另一个进程的另一种方式。请求称为 **事件** 或 **消息**;与 RPC 不同,发送者通常不会等待接收者处理事件。此外,事件通常不是通过直接网络连接发送给接收者,而是通过称为 **消息代理**(也称为 **事件代理**、**消息队列** 或 **面向消息的中间件**)的中介,它临时存储消息 [^50]。 使用消息代理与直接 RPC 相比有几个优点: @@ -537,7 +537,7 @@ class PaymentWorkflow: * 它允许将相同的消息发送给多个接收者。 * 它在逻辑上将发送者与接收者解耦(发送者只是发布消息,不关心谁使用它们)。 -通过消息代理的通信是 *异步的*:发送者不会等待消息被传递,而是简单地发送它然后忘记它。可以通过让发送者在单独的通道上等待响应来实现类似同步 RPC 的模型。 +通过消息代理的通信是 **异步的**:发送者不会等待消息被传递,而是简单地发送它然后忘记它。可以通过让发送者在单独的通道上等待响应来实现类似同步 RPC 的模型。 #### 消息代理 {#message-brokers} @@ -545,8 +545,8 @@ class PaymentWorkflow: 详细的传递语义因实现和配置而异,但通常,最常使用两种消息分发模式: -* 一个进程将消息添加到命名 *队列*,代理将该消息传递给该队列的 *消费者*。如果有多个消费者,其中一个会收到消息。 -* 一个进程将消息发布到命名 *主题*,代理将该消息传递给该主题的所有 *订阅者*。如果有多个订阅者,他们都会收到消息。 +* 一个进程将消息添加到命名 **队列**,代理将该消息传递给该队列的 **消费者**。如果有多个消费者,其中一个会收到消息。 +* 一个进程将消息发布到命名 **主题**,代理将该消息传递给该主题的所有 **订阅者**。如果有多个订阅者,他们都会收到消息。 消息代理通常不强制执行任何特定的数据模型——消息只是带有一些元数据的字节序列,因此你可以使用任何编码格式。常见的方法是使用 Protocol Buffers、Avro 或 JSON,并在消息代理旁边部署模式注册表来存储所有有效的模式版本并检查其兼容性 [^19] [^21]。AsyncAPI(OpenAPI 的基于消息传递的等效物)也可用于指定消息的模式。 @@ -556,9 +556,9 @@ class PaymentWorkflow: #### 分布式 actor 框架 {#distributed-actor-frameworks} -*Actor 模型* 是单个进程中并发的编程模型。与其直接处理线程(以及相关的竞态条件、锁定和死锁问题),逻辑被封装在 *actor* 中。每个 actor 通常代表一个客户端或实体,它可能有一些本地状态(不与任何其他 actor 共享),并通过发送和接收异步消息与其他 actor 通信。消息传递不能保证:在某些错误场景中,消息将丢失。由于每个 actor 一次只处理一条消息,因此它不需要担心线程,并且每个 actor 可以由框架独立调度。 +**Actor 模型** 是单个进程中并发的编程模型。与其直接处理线程(以及相关的竞态条件、锁定和死锁问题),逻辑被封装在 **actor** 中。每个 actor 通常代表一个客户端或实体,它可能有一些本地状态(不与任何其他 actor 共享),并通过发送和接收异步消息与其他 actor 通信。消息传递不能保证:在某些错误场景中,消息将丢失。由于每个 actor 一次只处理一条消息,因此它不需要担心线程,并且每个 actor 可以由框架独立调度。 -在 *分布式 actor 框架* 中,如 Akka、Orleans [^51] 和 Erlang/OTP,此编程模型用于跨多个节点扩展应用程序。无论发送者和接收者是在同一节点还是不同节点上,都使用相同的消息传递机制。如果它们在不同的节点上,消息将透明地编码为字节序列,通过网络发送,并在另一端解码。 +在 **分布式 actor 框架** 中,如 Akka、Orleans [^51] 和 Erlang/OTP,此编程模型用于跨多个节点扩展应用程序。无论发送者和接收者是在同一节点还是不同节点上,都使用相同的消息传递机制。如果它们在不同的节点上,消息将透明地编码为字节序列,通过网络发送,并在另一端解码。 位置透明性在 actor 模型中比在 RPC 中效果更好,因为 actor 模型已经假定消息可能会丢失,即使在单个进程内也是如此。尽管网络上的延迟可能比同一进程内的延迟更高,但在使用 actor 模型时,本地和远程通信之间的根本不匹配较少。 @@ -569,7 +569,7 @@ class PaymentWorkflow: 在本章中,我们研究了将数据结构转换为网络上的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是还影响应用程序的架构和演化选项。 -特别是,许多服务需要支持滚动升级,其中服务的新版本逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布服务的新版本(从而鼓励频繁的小版本发布而不是罕见的大版本发布),并使部署风险更低(允许在影响大量用户之前检测和回滚有故障的版本)。这些属性对 *可演化性* 非常有益,即轻松进行应用程序更改。 +特别是,许多服务需要支持滚动升级,其中服务的新版本逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布服务的新版本(从而鼓励频繁的小版本发布而不是罕见的大版本发布),并使部署风险更低(允许在影响大量用户之前检测和回滚有故障的版本)。这些属性对 **可演化性** 非常有益,即轻松进行应用程序更改。 在滚动升级期间,或出于其他各种原因,我们必须假设不同的节点正在运行我们应用程序代码的不同版本。因此,重要的是系统中流动的所有数据都以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码。 diff --git a/content/zh/ch6.md b/content/zh/ch6.md index a294fdb..eae867c 100644 --- a/content/zh/ch6.md +++ b/content/zh/ch6.md @@ -329,7 +329,7 @@ Poons 先生 单主复制模型的自然扩展是允许多个节点接受写入。复制仍然以相同的方式进行:每个处理写入的节点必须将该数据变更转发给所有其他节点。我们称之为 **多主** 配置(也称为 **主动/主动** 或 **双向** 复制)。在这种设置中,每个领导者同时充当其他领导者的追随者。 -与单主复制一样,可以选择使其同步或异步。假设你有两个领导者,*A* 和 *B*,你正在尝试写入 *A*。如果写入从 *A* 同步复制到 *B*,并且两个节点之间的网络中断,你就无法写入 *A* 直到网络恢复。同步多主复制因此给你一个非常类似于单主复制的模型,即如果你让 *B* 成为领导者,*A* 只是将任何写入请求转发给 *B* 执行。 +与单主复制一样,可以选择使其同步或异步。假设你有两个领导者,**A** 和 **B**,你正在尝试写入 **A**。如果写入从 **A** 同步复制到 **B**,并且两个节点之间的网络中断,你就无法写入 **A** 直到网络恢复。同步多主复制因此给你一个非常类似于单主复制的模型,即如果你让 **B** 成为领导者,**A** 只是将任何写入请求转发给 **B** 执行。 因此,我们不会进一步讨论同步多主复制,而只是将其视为等同于单主复制。本节的其余部分专注于异步多主复制,其中任何领导者都可以处理写入,即使其与其他领导者的连接中断。 @@ -575,9 +575,9 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], 如果我们知道每次成功的写入都保证至少存在于三个副本中的两个上,这意味着最多一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确信两个中至少有一个是最新的。如果第三个副本宕机或响应缓慢,读取仍然可以继续返回最新值。 -更一般地说,如果有 *n* 个副本,每次写入必须由 *w* 个节点确认才能被认为成功,并且我们必须为每次读取查询至少 *r* 个节点。(在我们的例子中,*n* = 3,*w* = 2,*r* = 2。)只要 *w* + *r* > *n*,我们在读取时期望获得最新值,因为我们读取的 *r* 个节点中至少有一个必须是最新的。遵守这些 *r* 和 *w* 值的读取和写入称为 **仲裁** 读取和写入 [^50]。你可以将 *r* 和 *w* 视为读取或写入有效所需的最小投票数。 +更一般地说,如果有 **n** 个副本,每次写入必须由 **w** 个节点确认才能被认为成功,并且我们必须为每次读取查询至少 **r** 个节点。(在我们的例子中,**n** = 3,**w** = 2,**r** = 2。)只要 **w** + **r** > **n**,我们在读取时期望获得最新值,因为我们读取的 **r** 个节点中至少有一个必须是最新的。遵守这些 **r** 和 **w** 值的读取和写入称为 **仲裁** 读取和写入 [^50]。你可以将 **r** 和 **w** 视为读取或写入有效所需的最小投票数。 -在 Dynamo 风格的数据库中,参数 *n*、*w* 和 *r* 通常是可配置的。常见的选择是使 *n* 为奇数(通常为 3 或 5),并设置 *w* = *r* = (*n* + 1) / 2(向上舍入)。然而,你可以根据需要更改数字。例如,写入很少而读取很多的工作负载可能受益于设置 *w* = *n* 和 *r* = 1。这使读取更快,但缺点是仅一个失败的节点就会导致所有数据库写入失败。 +在 Dynamo 风格的数据库中,参数 **n**、**w** 和 **r** 通常是可配置的。常见的选择是使 **n** 为奇数(通常为 3 或 5),并设置 **w** = **r** = (**n** + 1) / 2(向上舍入)。然而,你可以根据需要更改数字。例如,写入很少而读取很多的工作负载可能受益于设置 **w** = **n** 和 **r** = 1。这使读取更快,但缺点是仅一个失败的节点就会导致所有数据库写入失败。 -------- @@ -586,40 +586,40 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], -------- -仲裁条件 *w* + *r* > *n* 允许系统容忍不可用节点,如下所示: +仲裁条件 **w** + **r** > **n** 允许系统容忍不可用节点,如下所示: -* 如果 *w* < *n*,如果节点不可用,我们仍然可以处理写入。 -* 如果 *r* < *n*,如果节点不可用,我们仍然可以处理读取。 -* 使用 *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) 中说明。 +* 如果 **w** < **n**,如果节点不可用,我们仍然可以处理写入。 +* 如果 **r** < **n**,如果节点不可用,我们仍然可以处理读取。 +* 使用 **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* 个节点中有多少个需要报告成功。 +通常,读取和写入总是并行发送到所有 **n** 个副本。参数 **w** 和 **r** 确定我们等待多少个节点——即,在我们认为读取或写入成功之前,**n** 个节点中有多少个需要报告成功。 {{< figure src="/fig/ddia_0613.png" id="fig_replication_quorum_overlap" caption="图 6-13. 如果 *w* + *r* > *n*,你读取的 *r* 个副本中至少有一个必须看到最近的成功写入。" class="w-full my-4" >}} -如果少于所需的 *w* 或 *r* 个节点可用,写入或读取将返回错误。节点可能因许多原因不可用:因为节点宕机(崩溃、断电)、由于执行操作时出错(无法写入因为磁盘已满)、由于客户端和节点之间的网络中断,或任何其他原因。我们只关心节点是否返回了成功响应,不需要区分不同类型的故障。 +如果少于所需的 **w** 或 **r** 个节点可用,写入或读取将返回错误。节点可能因许多原因不可用:因为节点宕机(崩溃、断电)、由于执行操作时出错(无法写入因为磁盘已满)、由于客户端和节点之间的网络中断,或任何其他原因。我们只关心节点是否返回了成功响应,不需要区分不同类型的故障。 ### 仲裁一致性的局限 {#sec_replication_quorum_limitations} -如果你有 *n* 个副本,并且你选择 *w* 和 *r* 使得 *w* + *r* > *n*,你通常可以期望每次读取都返回为键写入的最新值。这是因为你写入的节点集和你读取的节点集必须重叠。也就是说,在你读取的节点中,必须至少有一个具有最新值的节点(如 [图 6-13](#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]。 +通常,**r** 和 **w** 被选择为多数(超过 **n**/2)节点,因为这确保了 **w** + **r** > **n**,同时仍然容忍最多 **n**/2(向下舍入)个节点故障。但仲裁不一定是多数——重要的是读取和写入操作使用的节点集至少在一个节点中重叠。其他仲裁分配是可能的,这允许分布式算法设计中的一些灵活性 [^51]。 -你也可以将 *w* 和 *r* 设置为较小的数字,使得 *w* + *r* ≤ *n*(即,不满足仲裁条件)。在这种情况下,读取和写入仍将发送到 *n* 个节点,但需要较少的成功响应数才能使操作成功。 +你也可以将 **w** 和 **r** 设置为较小的数字,使得 **w** + **r** ≤ **n**(即,不满足仲裁条件)。在这种情况下,读取和写入仍将发送到 **n** 个节点,但需要较少的成功响应数才能使操作成功。 -使用较小的 *w* 和 *r*,你更有可能读取陈旧值,因为你的读取更可能没有包含具有最新值的节点。从好的方面来说,这种配置允许更低的延迟和更高的可用性:如果存在网络中断并且许多副本变得无法访问,你继续处理读取和写入的机会更高。只有在可访问副本的数量低于 *w* 或 *r* 之后,数据库才分别变得无法写入或读取。 +使用较小的 **w** 和 **r**,你更有可能读取陈旧值,因为你的读取更可能没有包含具有最新值的节点。从好的方面来说,这种配置允许更低的延迟和更高的可用性:如果存在网络中断并且许多副本变得无法访问,你继续处理读取和写入的机会更高。只有在可访问副本的数量低于 **w** 或 **r** 之后,数据库才分别变得无法写入或读取。 -然而,即使使用 *w* + *r* > *n*,在某些边缘情况下,一致性属性可能会令人困惑。一些场景包括: +然而,即使使用 **w** + **r** > **n**,在某些边缘情况下,一致性属性可能会令人困惑。一些场景包括: -* 如果携带新值的节点失败,并且其数据从携带旧值的副本恢复,存储新值的副本数量可能低于 *w*,破坏仲裁条件。 -* 在重新平衡正在进行时,其中一些数据从一个节点移动到另一个节点(见 [第 7 章](/ch7#ch_sharding)),节点可能对哪些节点应该持有特定值的 *n* 个副本有不一致的视图。这可能导致读取和写入仲裁不再重叠。 +* 如果携带新值的节点失败,并且其数据从携带旧值的副本恢复,存储新值的副本数量可能低于 **w**,破坏仲裁条件。 +* 在重新平衡正在进行时,其中一些数据从一个节点移动到另一个节点(见 [第 7 章](/ch7#ch_sharding)),节点可能对哪些节点应该持有特定值的 **n** 个副本有不一致的视图。这可能导致读取和写入仲裁不再重叠。 * 如果读取与写入操作并发,读取可能会或可能不会看到并发写入的值。特别是,一次读取可能看到新值,而后续读取看到旧值,正如我们将在 ["线性一致性与仲裁"](/ch10#sec_consistency_quorum_linearizable) 中看到的。 -* 如果写入在某些副本上成功但在其他副本上失败(例如,因为某些节点上的磁盘已满),并且总体上在少于 *w* 个副本上成功,它不会在成功的副本上回滚。这意味着如果写入被报告为失败,后续读取可能会或可能不会返回该写入的值 [^52]。 +* 如果写入在某些副本上成功但在其他副本上失败(例如,因为某些节点上的磁盘已满),并且总体上在少于 **w** 个副本上成功,它不会在成功的副本上回滚。这意味着如果写入被报告为失败,后续读取可能会或可能不会返回该写入的值 [^52]。 * 如果数据库使用实时时钟的时间戳来确定哪个写入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一个具有更快时钟的节点已写入同一键,写入可能会被静默丢弃——我们之前在 ["最后写入胜利(丢弃并发写入)"](#sec_replication_lww) 中看到的问题。我们将在 ["依赖同步时钟"](/ch9#sec_distributed_clocks_relying) 中更详细地讨论这一点。 * 如果两个写入并发发生,其中一个可能首先在一个副本上处理,另一个可能首先在另一个副本上处理。这导致冲突,类似于我们在多主复制中看到的(见 ["处理写入冲突"](#sec_replication_write_conflicts))。我们将在 ["检测并发写入"](#sec_replication_concurrent) 中回到这个主题。 -因此,尽管仲裁似乎保证读取返回最新写入的值,但实际上并不那么简单。Dynamo 风格的数据库通常针对可以容忍最终一致性的用例进行了优化。参数 *w* 和 *r* 允许你调整读取陈旧值的概率 [^53],但明智的做法是不要将它们视为绝对保证。 +因此,尽管仲裁似乎保证读取返回最新写入的值,但实际上并不那么简单。Dynamo 风格的数据库通常针对可以容忍最终一致性的用例进行了优化。参数 **w** 和 **r** 允许你调整读取陈旧值的概率 [^53],但明智的做法是不要将它们视为绝对保证。 #### 监控陈旧性 {#monitoring-staleness} @@ -647,7 +647,7 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], 也就是说,无主系统也可能有性能问题: * 即使系统不需要执行故障转移,一个副本确实需要检测另一个副本何时不可用,以便它可以存储有关不可用副本错过的写入的提示。当不可用副本恢复时,移交过程需要向其发送这些提示。这在系统已经处于压力下时给副本带来了额外的负载 [^54]。 -* 你拥有的副本越多,你的仲裁就越大,在请求完成之前你必须等待的响应就越多。即使你只等待最快的 *r* 或 *w* 个副本响应,即使你并行发出请求,更大的 *r* 或 *w* 增加了你遇到慢副本的机会,增加了总体响应时间(见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。 +* 你拥有的副本越多,你的仲裁就越大,在请求完成之前你必须等待的响应就越多。即使你只等待最快的 **r** 或 **w** 个副本响应,即使你并行发出请求,更大的 **r** 或 **w** 增加了你遇到慢副本的机会,增加了总体响应时间(见 ["响应时间指标的应用"](/ch2#sec_introduction_slo_sla))。 * 大规模网络中断使客户端与大量副本断开连接,可能使形成仲裁变得不可能。一些无主数据库提供了一个配置选项,允许任何可访问的副本接受写入,即使它不是该键的通常副本之一(Riak 和 Dynamo 称之为 **宽松仲裁** [^45];Cassandra 和 ScyllaDB 称之为 **一致性级别 ANY**)。不能保证后续读取会看到写入的值,但根据应用程序,它可能仍然比写入失败更好。 多主复制可以提供比无主复制更大的网络中断弹性,因为读取和写入只需要与一个领导者通信,该领导者可以与客户端位于同一位置。然而,由于一个领导者上的写入异步传播到其他领导者,读取可能任意过时。仲裁读取和写入提供了一种折衷:良好的容错性,同时也有很高的可能性读取最新数据。 @@ -658,14 +658,14 @@ OT 最常用于文本的实时协作编辑,例如在 Google Docs 中 [^32], Cassandra 和 ScyllaDB 在正常的无主模型中实现了它们的多地区支持:客户端直接将其写入发送到所有地区的副本,你可以从各种一致性级别中进行选择,这些级别确定请求成功所需的响应数。例如,你可以请求所有地区中副本的仲裁、每个地区中的单独仲裁,或仅客户端本地地区的仲裁。本地仲裁避免了必须等待到其他地区的缓慢请求,但它也更可能返回陈旧结果。 -Riak 将客户端和数据库节点之间的所有通信保持在一个地区本地,因此 *n* 描述了一个地区内的副本数。数据库集群之间的跨地区复制在后台异步发生,其风格类似于多主复制。 +Riak 将客户端和数据库节点之间的所有通信保持在一个地区本地,因此 **n** 描述了一个地区内的副本数。数据库集群之间的跨地区复制在后台异步发生,其风格类似于多主复制。 ### 检测并发写入 {#sec_replication_concurrent} 与多主复制一样,无主数据库允许对同一键进行并发写入,导致需要解决的冲突。此类冲突可能在写入发生时发生,但并非总是如此:它们也可能在读修复、提示移交或反熵期间稍后检测到。 -问题在于,由于可变的网络延迟和部分故障,事件可能以不同的顺序到达不同的节点。例如,[图 6-14](#fig_replication_concurrency) 显示了两个客户端 A 和 B 同时写入三节点数据存储中的键 *X*: +问题在于,由于可变的网络延迟和部分故障,事件可能以不同的顺序到达不同的节点。例如,[图 6-14](#fig_replication_concurrency) 显示了两个客户端 A 和 B 同时写入三节点数据存储中的键 **X**: * 节点 1 接收来自 A 的写入,但由于瞬时中断从未接收来自 B 的写入。 * 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。 @@ -673,7 +673,7 @@ Riak 将客户端和数据库节点之间的所有通信保持在一个地区本 {{< figure src="/fig/ddia_0614.png" id="fig_replication_concurrency" caption="图 6-14. Dynamo 风格数据存储中的并发写入:没有明确定义的顺序。" class="w-full my-4" >}} -如果每个节点在接收到来自客户端的写入请求时只是覆盖键的值,节点将变得永久不一致,如 [图 6-14](#fig_replication_concurrency) 中的最终 *get* 请求所示:节点 2 认为 *X* 的最终值是 B,而其他节点认为值是 A。 +如果每个节点在接收到来自客户端的写入请求时只是覆盖键的值,节点将变得永久不一致,如 [图 6-14](#fig_replication_concurrency) 中的最终 **get** 请求所示:节点 2 认为 **X** 的最终值是 B,而其他节点认为值是 A。 为了最终保持一致,副本应该收敛到相同的值。为此,我们可以使用我们之前在 ["处理写入冲突"](#sec_replication_write_conflicts) 中讨论的任何冲突解决机制,例如最后写入胜利(由 Cassandra 和 ScyllaDB 使用)、手动解决或 CRDT(在 ["CRDT 与操作变换"](#sec_replication_crdts) 中描述,并由 Riak 使用)。 diff --git a/content/zh/ch7.md b/content/zh/ch7.md index 2eb2cdc..b238fb5 100644 --- a/content/zh/ch7.md +++ b/content/zh/ch7.md @@ -14,8 +14,8 @@ breadcrumbs: false 分布式数据库通常通过两种方式在节点间分布数据: -1. 在多个节点上保存相同数据的副本:这是 *复制*,我们在 [第 6 章](/ch6#ch_replication) 中讨论过。 -2. 如果我们不想让每个节点都存储所有数据,我们可以将大量数据分割成更小的 *分片(shards)* 或 *分区(partitions)*,并将不同的分片存储在不同的节点上。我们将在本章讨论分片。 +1. 在多个节点上保存相同数据的副本:这是 **复制**,我们在 [第 6 章](/ch6#ch_replication) 中讨论过。 +2. 如果我们不想让每个节点都存储所有数据,我们可以将大量数据分割成更小的 **分片(shards)** 或 **分区(partitions)**,并将不同的分片存储在不同的节点上。我们将在本章讨论分片。 通常,分片的定义方式使得每条数据(每条记录、行或文档)恰好属于一个分片。有多种方法可以实现这一点,我们将在本章深入讨论。实际上,每个分片本身就是一个小型数据库,尽管某些数据库系统支持同时涉及多个分片的操作。 @@ -31,35 +31,35 @@ breadcrumbs: false > [!TIP] 分片和分区 -在本章中我们称之为 *分片* 的东西,根据你使用的软件不同有许多不同的名称:在 Kafka 中称为 *分区(partition)*,在 CockroachDB 中称为 *范围(range)*,在 HBase 和 TiDB 中称为 *区域(region)*,在 Bigtable 和 YugabyteDB 中称为 *表块(tablet)*,在 Cassandra、ScyllaDB 和 Riak 中称为 *虚节点(vnode)*,在 Couchbase 中称为 *虚桶(vBucket)*,仅举几例。 +在本章中我们称之为 **分片** 的东西,根据你使用的软件不同有许多不同的名称:在 Kafka 中称为 **分区(partition)**,在 CockroachDB 中称为 **范围(range)**,在 HBase 和 TiDB 中称为 **区域(region)**,在 Bigtable 和 YugabyteDB 中称为 **表块(tablet)**,在 Cassandra、ScyllaDB 和 Riak 中称为 **虚节点(vnode)**,在 Couchbase 中称为 **虚桶(vBucket)**,仅举几例。 一些数据库将分区和分片视为两个不同的概念。例如,在 PostgreSQL 中,分区是将大表拆分为存储在同一台机器上的多个文件的方法(这有几个优点,例如可以非常快速地删除整个分区),而分片则是将数据集拆分到多台机器上 [^1] [^2]。在许多其他系统中,分区只是分片的另一个词。 -虽然 *分区* 相当具有描述性,但 *分片* 这个术语可能令人惊讶。根据一种理论,该术语源于在线角色扮演游戏《网络创世纪》(Ultima Online),其中一块魔法水晶被打碎成碎片,每个碎片都折射出游戏世界的副本 [^3]。*分片* 一词因此用来指一组并行游戏服务器中的一个,后来被引入数据库。另一种理论是 *分片* 最初是 *高可用复制数据系统*(System for Highly Available Replicated Data)的缩写——据说是 1980 年代的一个数据库,其细节已经失传。 +虽然 **分区** 相当具有描述性,但 **分片** 这个术语可能令人惊讶。根据一种理论,该术语源于在线角色扮演游戏《网络创世纪》(Ultima Online),其中一块魔法水晶被打碎成碎片,每个碎片都折射出游戏世界的副本 [^3]。**分片** 一词因此用来指一组并行游戏服务器中的一个,后来被引入数据库。另一种理论是 **分片** 最初是 **高可用复制数据系统**(System for Highly Available Replicated Data)的缩写——据说是 1980 年代的一个数据库,其细节已经失传。 -顺便说一下,分区与 *网络分区*(netsplits)无关,后者是节点之间网络中的一种故障。我们将在 [第 9 章](/ch9#ch_distributed) 中讨论此类故障。 +顺便说一下,分区与 **网络分区**(netsplits)无关,后者是节点之间网络中的一种故障。我们将在 [第 9 章](/ch9#ch_distributed) 中讨论此类故障。 -------- ## 分片的利与弊 {#sec_sharding_reasons} -对数据库进行分片的主要原因是 *可伸缩性*:如果数据量或写吞吐量已经超出单个节点的处理能力,这是一个解决方案,它允许你将数据和写入分散到多个节点上。(如果读吞吐量是问题,你不一定需要分片——你可以使用 [第 6 章](/ch6#ch_replication) 中讨论的 *读扩展*。) +对数据库进行分片的主要原因是 **可伸缩性**:如果数据量或写吞吐量已经超出单个节点的处理能力,这是一个解决方案,它允许你将数据和写入分散到多个节点上。(如果读吞吐量是问题,你不一定需要分片——你可以使用 [第 6 章](/ch6#ch_replication) 中讨论的 **读扩展**。) -事实上,分片是我们实现 *水平扩展*(*横向扩展* 架构)的主要工具之一,如 ["共享内存、共享磁盘和无共享架构"](/ch2#sec_introduction_shared_nothing) 中所讨论的:即,允许系统通过添加更多(较小的)机器而不是转移到更大的机器来增长其容量。如果你可以划分工作负载,使每个分片处理大致相等的份额,那么你可以将这些分片分配给不同的机器,以便并行处理它们的数据和查询。 +事实上,分片是我们实现 **水平扩展**(**横向扩展** 架构)的主要工具之一,如 ["共享内存、共享磁盘和无共享架构"](/ch2#sec_introduction_shared_nothing) 中所讨论的:即,允许系统通过添加更多(较小的)机器而不是转移到更大的机器来增长其容量。如果你可以划分工作负载,使每个分片处理大致相等的份额,那么你可以将这些分片分配给不同的机器,以便并行处理它们的数据和查询。 虽然复制在小规模和大规模上都很有用,因为它支持容错和离线操作,但分片是一个重量级解决方案,主要在大规模场景下才有意义。如果你的数据量和写吞吐量可以在单台机器上处理(而单台机器现在可以做很多事情!),通常最好避免分片并坚持使用单分片数据库。 -推荐这样做的原因是分片通常会增加复杂性:你通常必须通过选择 *分区键* 来决定将哪些记录放在哪个分片中;具有相同分区键的所有记录都放在同一个分片中 [^4]。这个选择很重要,因为如果你知道记录在哪个分片中,访问记录会很快,但如果你不知道分片,你必须在所有分片中进行低效的搜索,而且分片方案很难更改。 +推荐这样做的原因是分片通常会增加复杂性:你通常必须通过选择 **分区键** 来决定将哪些记录放在哪个分片中;具有相同分区键的所有记录都放在同一个分片中 [^4]。这个选择很重要,因为如果你知道记录在哪个分片中,访问记录会很快,但如果你不知道分片,你必须在所有分片中进行低效的搜索,而且分片方案很难更改。 因此,分片通常适用于键值数据,你可以轻松地按键进行分片,但对于关系数据则较难,因为你可能想要通过二级索引搜索,或连接可能分布在不同分片中的记录。我们将在 ["分片与二级索引"](#sec_sharding_secondary_indexes) 中进一步讨论这个问题。 -分片的另一个问题是写入可能需要更新多个不同分片中的相关记录。虽然单节点上的事务相当常见(见 [第 8 章](/ch8#ch_transactions)),但确保跨多个分片的一致性需要 *分布式事务*。正如我们将在 [第 8 章](/ch8#ch_transactions) 中看到的,分布式事务在某些数据库中可用,但它们通常比单节点事务慢得多,可能成为整个系统的瓶颈,有些系统根本不支持它们。 +分片的另一个问题是写入可能需要更新多个不同分片中的相关记录。虽然单节点上的事务相当常见(见 [第 8 章](/ch8#ch_transactions)),但确保跨多个分片的一致性需要 **分布式事务**。正如我们将在 [第 8 章](/ch8#ch_transactions) 中看到的,分布式事务在某些数据库中可用,但它们通常比单节点事务慢得多,可能成为整个系统的瓶颈,有些系统根本不支持它们。 -一些系统即使在单台机器上也使用分片,通常每个 CPU 核心运行一个单线程进程,以利用 CPU 的并行性,或者利用 *非统一内存访问*(NUMA)架构:某些内存分区比其他分区更靠近某个 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每个核心使用一个进程,并依靠分片在同一台机器的 CPU 核心之间分散负载 [^6]。 +一些系统即使在单台机器上也使用分片,通常每个 CPU 核心运行一个单线程进程,以利用 CPU 的并行性,或者利用 **非统一内存访问**(NUMA)架构:某些内存分区比其他分区更靠近某个 CPU [^5]。例如,Redis、VoltDB 和 FoundationDB 每个核心使用一个进程,并依靠分片在同一台机器的 CPU 核心之间分散负载 [^6]。 ### 面向多租户的分片 {#sec_sharding_multitenancy} -软件即服务(SaaS)产品和云服务通常是 *多租户* 的,其中每个租户是一个客户。多个用户可能在同一租户上拥有登录帐户,但每个租户都有一个独立的数据集,与其他租户分开。例如,在电子邮件营销服务中,每个注册的企业通常是一个单独的租户,因为一个企业的通讯订阅、投递数据等与其他企业的数据是分开的。 +软件即服务(SaaS)产品和云服务通常是 **多租户** 的,其中每个租户是一个客户。多个用户可能在同一租户上拥有登录帐户,但每个租户都有一个独立的数据集,与其他租户分开。例如,在电子邮件营销服务中,每个注册的企业通常是一个单独的租户,因为一个企业的通讯订阅、投递数据等与其他企业的数据是分开的。 有时分片用于实现多租户系统:要么每个租户被分配一个单独的分片,要么多个小租户可能被分组到一个更大的分片中。这些分片可能是物理上分离的数据库(我们之前在 ["嵌入式存储引擎"](/ch4#sidebar_embedded) 中提到过),或者是更大逻辑数据库的可单独管理部分 [^7]。使用分片实现多租户有几个优点: @@ -70,7 +70,7 @@ breadcrumbs: false : 如果访问控制逻辑有漏洞,而租户数据集又是彼此物理隔离存储的,那么误将一个租户的数据暴露给另一个租户的概率会更低。 基于单元的架构 -: 你不仅可以在数据存储级别应用分片,还可以为运行应用程序代码的服务应用分片。在 *基于单元的架构* 中,特定租户集的服务和存储被分组到一个自包含的 *单元* 中,不同的单元被设置为可以在很大程度上彼此独立运行。这种方法提供了 *故障隔离*:即,一个单元中的故障仅限于该单元,其他单元中的租户不受影响 [^8]。 +: 你不仅可以在数据存储级别应用分片,还可以为运行应用程序代码的服务应用分片。在 **基于单元的架构** 中,特定租户集的服务和存储被分组到一个自包含的 **单元** 中,不同的单元被设置为可以在很大程度上彼此独立运行。这种方法提供了 **故障隔离**:即,一个单元中的故障仅限于该单元,其他单元中的租户不受影响 [^8]。 按租户备份和恢复 : 单独备份每个租户的分片使得可以从备份中恢复租户的状态而不影响其他租户,这在租户意外删除或覆盖重要数据的情况下很有用 [^9]。 @@ -96,9 +96,9 @@ breadcrumbs: false 假设你有大量数据,并且想要对其进行分片。如何决定将哪些记录存储在哪些节点上? -我们进行分片的目标是将数据和查询负载均匀地分布在各节点上。如果每个节点承担公平的份额,那么理论上——10 个节点应该能够处理 10 倍的数据量和 10 倍单个节点的读写吞吐量(忽略复制)。此外,如果我们添加或删除节点,我们希望能够 *再平衡* 负载,使其在添加时均匀分布在 11 个节点上(或删除时在剩余的 9 个节点上)。 +我们进行分片的目标是将数据和查询负载均匀地分布在各节点上。如果每个节点承担公平的份额,那么理论上——10 个节点应该能够处理 10 倍的数据量和 10 倍单个节点的读写吞吐量(忽略复制)。此外,如果我们添加或删除节点,我们希望能够 **再平衡** 负载,使其在添加时均匀分布在 11 个节点上(或删除时在剩余的 9 个节点上)。 -如果分片不公平,使得某些分片比其他分片承载更多数据或查询,我们称之为 *偏斜*。偏斜会显著削弱分片效果。在极端情况下,所有负载都可能集中在一个分片上,导致 10 个节点中有 9 个处于空闲状态,而瓶颈落在那一个繁忙节点上。负载明显高于其他分片的分片称为 *热分片* 或 *热点*。如果某个键的负载特别高(例如社交网络中的名人),我们称之为 *热键*。 +如果分片不公平,使得某些分片比其他分片承载更多数据或查询,我们称之为 **偏斜**。偏斜会显著削弱分片效果。在极端情况下,所有负载都可能集中在一个分片上,导致 10 个节点中有 9 个处于空闲状态,而瓶颈落在那一个繁忙节点上。负载明显高于其他分片的分片称为 **热分片** 或 **热点**。如果某个键的负载特别高(例如社交网络中的名人),我们称之为 **热键**。 因此,我们需要一种算法,它以记录的分区键作为输入,并告诉我们该记录在哪个分片中。在键值存储中,分区键通常是键,或键的第一部分。在关系模型中,分区键可能是表的某一列(不一定是其主键)。该算法需要能够进行再平衡以缓解热点。 @@ -121,7 +121,7 @@ breadcrumbs: false #### 重新平衡键范围分片数据 {#rebalancing-key-range-sharded-data} -当你首次设置数据库时,没有键范围可以分割成分片。一些数据库,如 HBase 和 MongoDB,允许你在空数据库上配置一组初始分片,这称为 *预分割*。这要求你已经对键分布将会是什么样子有所了解,以便你可以选择适当的键范围边界 [^14]。 +当你首次设置数据库时,没有键范围可以分割成分片。一些数据库,如 HBase 和 MongoDB,允许你在空数据库上配置一组初始分片,这称为 **预分割**。这要求你已经对键分布将会是什么样子有所了解,以便你可以选择适当的键范围边界 [^14]。 后来,随着你的数据量和写吞吐量增长,具有键范围分片的系统通过将现有分片分割成两个或更多较小的分片来增长,每个分片都保存原始分片键范围的连续子范围。然后可以将生成的较小分片分布在多个节点上。如果删除了大量数据,你可能还需要将几个相邻的已变小的分片合并为一个更大的分片。这个过程类似于 B 树顶层发生的事情(参见 ["B 树"](/ch4#sec_storage_b_trees))。 @@ -144,17 +144,17 @@ breadcrumbs: false #### 哈希取模节点数 {#hash-modulo-number-of-nodes} -一旦你对键进行了哈希,如何选择将其存储在哪个分片中?也许你的第一个想法是取哈希值 *模* 系统中的节点数(在许多编程语言中使用 `%` 运算符)。例如,*hash*(*key*) % 10 将返回 0 到 9 之间的数字(如果我们将哈希写为十进制数,hash % 10 将是最后一位数字)。如果我们有 10 个节点,编号从 0 到 9,这似乎是将每个键分配给节点的简单方法。 +一旦你对键进行了哈希,如何选择将其存储在哪个分片中?也许你的第一个想法是取哈希值 **模** 系统中的节点数(在许多编程语言中使用 `%` 运算符)。例如,**hash**(**key**) % 10 将返回 0 到 9 之间的数字(如果我们将哈希写为十进制数,hash % 10 将是最后一位数字)。如果我们有 10 个节点,编号从 0 到 9,这似乎是将每个键分配给节点的简单方法。 -*mod N* 方法的问题是,如果节点数 *N* 发生变化,大多数键必须从一个节点移动到另一个节点。[图 7-3](#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" >}} -*mod N* 函数易于计算,但它导致非常低效的再平衡,因为存在大量不必要的记录从一个节点移动到另一个节点。我们需要一种不会移动超过必要数据的方法。 +**mod N** 函数易于计算,但它导致非常低效的再平衡,因为存在大量不必要的记录从一个节点移动到另一个节点。我们需要一种不会移动超过必要数据的方法。 #### 固定数量的分片 {#fixed-number-of-shards} -一个简单但广泛使用的解决方案是创建比节点多得多的分片,并为每个节点分配多个分片。例如,在 10 个节点的集群上运行的数据库可能从一开始就被分成 1,000 个分片,以便每个节点分配 100 个分片。然后将键存储在分片号 *hash*(*key*) % 1,000 中,系统单独跟踪哪个分片存储在哪个节点上。 +一个简单但广泛使用的解决方案是创建比节点多得多的分片,并为每个节点分配多个分片。例如,在 10 个节点的集群上运行的数据库可能从一开始就被分成 1,000 个分片,以便每个节点分配 100 个分片。然后将键存储在分片号 **hash**(**key**) % 1,000 中,系统单独跟踪哪个分片存储在哪个节点上。 现在,如果向集群添加一个节点,系统可以从现有节点重新分配一些分片到新节点,直到它们再次公平分布。这个过程在 [图 7-4](#fig_sharding_rebalance_fixed) 中说明。如果从集群中删除节点,则反向发生相同的事情。 @@ -172,7 +172,7 @@ breadcrumbs: false #### 按哈希范围分片 {#sharding-by-hash-range} -如果无法提前预测所需的分片数量,最好使用一种方案,其中分片数量可以轻松适应工作负载。前面提到的键范围分片方案具有这个属性,但当有大量对相邻键的写入时,它有热点的风险。一种解决方案是将键范围分片与哈希函数结合,使每个分片包含 *哈希值* 的范围而不是 *键* 的范围。 +如果无法提前预测所需的分片数量,最好使用一种方案,其中分片数量可以轻松适应工作负载。前面提到的键范围分片方案具有这个属性,但当有大量对相邻键的写入时,它有热点的风险。一种解决方案是将键范围分片与哈希函数结合,使每个分片包含 **哈希值** 的范围而不是 **键** 的范围。 [图 7-5](#fig_sharding_hash_range) 显示了使用 16 位哈希函数的示例,该函数返回 0 到 65,535 = 2¹⁶ − 1 之间的数字(实际上,哈希通常是 32 位或更多)。即使输入键非常相似(例如,连续的时间戳),它们的哈希值也会在该范围内均匀分布。然后我们可以为每个分片分配一个哈希值范围:例如,值 0 到 16,383 分配给分片 0,值 16,384 到 32,767 分配给分片 1,依此类推。 @@ -198,14 +198,14 @@ breadcrumbs: false #### 一致性哈希 {#sec_sharding_consistent_hashing} -*一致性哈希* 算法是一种哈希函数,它以满足两个属性的方式将键映射到指定数量的分片: +**一致性哈希** 算法是一种哈希函数,它以满足两个属性的方式将键映射到指定数量的分片: 1. 映射到每个分片的键数大致相等,并且 2. 当分片数量变化时,尽可能少的键从一个分片移动到另一个分片。 -注意这里的 *一致性* 与副本一致性(见 [第 6 章](/ch6#ch_replication))或 ACID 一致性(见 [第 8 章](/ch8#ch_transactions))无关,而是描述了键尽可能保持在同一个分片中的倾向。 +注意这里的 **一致性** 与副本一致性(见 [第 6 章](/ch6#ch_replication))或 ACID 一致性(见 [第 8 章](/ch8#ch_transactions))无关,而是描述了键尽可能保持在同一个分片中的倾向。 -Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定义 [^20],但也提出了其他几种一致性哈希算法 [^21],如 *最高随机权重*,也称为 *会合哈希* [^22],以及 *跳跃一致性哈希* [^23]。使用 Cassandra 的算法,如果添加一个节点,少量现有分片会被分割成子范围;另一方面,使用会合和跳跃一致性哈希,新节点被分配之前分散在所有其他节点中的单个键。哪种更可取取决于应用程序。 +Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定义 [^20],但也提出了其他几种一致性哈希算法 [^21],如 **最高随机权重**,也称为 **会合哈希** [^22],以及 **跳跃一致性哈希** [^23]。使用 Cassandra 的算法,如果添加一个节点,少量现有分片会被分割成子范围;另一方面,使用会合和跳跃一致性哈希,新节点被分配之前分散在所有其他节点中的单个键。哪种更可取取决于应用程序。 ### 偏斜的工作负载与缓解热点 {#sec_sharding_skew} @@ -221,7 +221,7 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定 问题因负载随时间变化而进一步复杂化:例如,一个已经病毒式传播的特定社交媒体帖子可能会在几天内经历高负载,但之后可能会再次平静下来。此外,某些键可能对写入很热,而其他键对读取很热,需要不同的策略来处理它们。 -一些系统(特别是为大规模设计的云服务)有自动处理热分片的方法;例如,Amazon 称之为 *热管理* [^28] 或 *自适应容量* [^17]。这些系统如何工作的细节超出了本书的范围。 +一些系统(特别是为大规模设计的云服务)有自动处理热分片的方法;例如,Amazon 称之为 **热管理** [^28] 或 **自适应容量** [^17]。这些系统如何工作的细节超出了本书的范围。 ### 运维:自动/手动再平衡 {#sec_sharding_operations} @@ -243,7 +243,7 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定 我们已经讨论了如何将数据集分片到多个节点上,以及如何在添加或删除节点时重新平衡这些分片。现在让我们继续讨论这个问题:如果你想读取或写入特定的键,你如何知道需要连接到哪个节点——即哪个 IP 地址和端口号? -我们称这个问题为 *请求路由*,它与 *服务发现* 非常相似,我们之前在 ["负载均衡器、服务发现和服务网格"](/ch5#sec_encoding_service_discovery) 中讨论过。两者之间最大的区别是,对于运行应用程序代码的服务,每个实例通常是无状态的,负载均衡器可以将请求发送到任何实例。对于分片数据库,对键的请求只能由包含该键的分片的副本节点处理。 +我们称这个问题为 **请求路由**,它与 **服务发现** 非常相似,我们之前在 ["负载均衡器、服务发现和服务网格"](/ch5#sec_encoding_service_discovery) 中讨论过。两者之间最大的区别是,对于运行应用程序代码的服务,每个实例通常是无状态的,负载均衡器可以将请求发送到任何实例。对于分片数据库,对键的请求只能由包含该键的分片的副本节点处理。 这意味着请求路由必须知道键到分片的分配,以及分片到节点的分配。在高层次上,这个问题有几种不同的方法(在 [图 7-7](#fig_sharding_routing) 中说明): @@ -263,9 +263,9 @@ Cassandra 和 ScyllaDB 使用的分片算法类似于一致性哈希的原始定 {{< figure src="/fig/ddia_0708.png" id="fig_sharding_zookeeper" caption="图 7-8. 使用 ZooKeeper 跟踪分片到节点的分配。" class="w-full my-4" >}} -例如,HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配,Kubernetes 使用 etcd 跟踪哪个服务实例在哪里运行。MongoDB 有类似的架构,但它依赖于自己的 *配置服务器* 实现和 *mongos* 守护进程作为路由层。Kafka、YugabyteDB 和 TiDB 使用内置的 Raft 共识协议实现来执行此协调功能。 +例如,HBase 和 SolrCloud 使用 ZooKeeper 管理分片分配,Kubernetes 使用 etcd 跟踪哪个服务实例在哪里运行。MongoDB 有类似的架构,但它依赖于自己的 **配置服务器** 实现和 **mongos** 守护进程作为路由层。Kafka、YugabyteDB 和 TiDB 使用内置的 Raft 共识协议实现来执行此协调功能。 -Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使用 *流言协议* 来传播集群状态的任何变化。这提供了比共识协议弱得多的一致性;可能会出现脑裂,其中集群的不同部分对同一分片有不同的节点分配。无主数据库可以容忍这一点,因为它们通常提供弱一致性保证(见 ["仲裁一致性的限制"](/ch6#sec_replication_quorum_limitations))。 +Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使用 **流言协议** 来传播集群状态的任何变化。这提供了比共识协议弱得多的一致性;可能会出现脑裂,其中集群的不同部分对同一分片有不同的节点分配。无主数据库可以容忍这一点,因为它们通常提供弱一致性保证(见 ["仲裁一致性的限制"](/ch6#sec_replication_quorum_limitations))。 当使用路由层或向随机节点发送请求时,客户端仍然需要找到要连接的 IP 地址。这些不像分片到节点的分配那样快速变化,因此通常使用 DNS 就足够了。 @@ -277,13 +277,13 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 如果涉及二级索引,情况会变得更加复杂(另见 ["多列和二级索引"](/ch4#sec_storage_index_multicolumn))。二级索引通常不唯一地标识记录,而是一种搜索特定值出现的方法:查找用户 `123` 的所有操作、查找包含单词 `hogwash` 的所有文章、查找颜色为 `red` 的所有汽车等。 -键值存储通常没有二级索引;但在关系数据库中,二级索引是基础能力,在文档数据库中也很常见,而且它们正是 Solr、Elasticsearch 等全文检索引擎的 *立身之本*。二级索引的难点在于,它们不能整齐地映射到分片。带二级索引的分片数据库主要有两种做法:本地索引与全局索引。 +键值存储通常没有二级索引;但在关系数据库中,二级索引是基础能力,在文档数据库中也很常见,而且它们正是 Solr、Elasticsearch 等全文检索引擎的 **立身之本**。二级索引的难点在于,它们不能整齐地映射到分片。带二级索引的分片数据库主要有两种做法:本地索引与全局索引。 ### 本地二级索引 {#id166} 例如,假设你正在运营一个出售二手车的网站(如 [图 7-9](#fig_sharding_local_secondary) 所示)。每个列表都有一个唯一的 ID——称之为文档 ID——你使用该 ID 作为分区键对数据库进行分片(例如,ID 0 到 499 在分片 0 中,ID 500 到 999 在分片 1 中,等等)。 -如果你想让用户搜索汽车,允许他们按颜色和制造商过滤,你需要在 `color` 和 `make` 上建立二级索引(在文档数据库中这些是字段;在关系数据库中这些是列)。如果你已声明索引,数据库就可以自动维护索引。例如,每当一辆红色汽车被写入数据库,所在分片会自动将其 ID 加入索引条目 `color:red` 对应的文档 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这个 ID 列表也称为 *倒排列表*。 +如果你想让用户搜索汽车,允许他们按颜色和制造商过滤,你需要在 `color` 和 `make` 上建立二级索引(在文档数据库中这些是字段;在关系数据库中这些是列)。如果你已声明索引,数据库就可以自动维护索引。例如,每当一辆红色汽车被写入数据库,所在分片会自动将其 ID 加入索引条目 `color:red` 对应的文档 ID 列表。正如 [第 4 章](/ch4#ch_storage) 所述,这个 ID 列表也称为 **倒排列表**。 {{< figure src="/fig/ddia_0709.png" id="fig_sharding_local_secondary" caption="图 7-9. 本地二级索引:每个分片只索引其自己分片内的记录。" class="w-full my-4" >}} @@ -293,29 +293,29 @@ Cassandra、ScyllaDB 和 Riak 采用不同的方法:它们在节点之间使 -------- -在这种索引方法中,每个分片是完全独立的:每个分片维护自己的二级索引,仅覆盖该分片中的文档。它不关心存储在其他分片中的数据。每当你需要写入数据库——添加、删除或更新记录——你只需要处理包含你正在写入的文档 ID 的分片。出于这个原因,这种类型的二级索引被称为 *本地索引*。在信息检索上下文中,它也被称为 *文档分区索引* [^30]。 +在这种索引方法中,每个分片是完全独立的:每个分片维护自己的二级索引,仅覆盖该分片中的文档。它不关心存储在其他分片中的数据。每当你需要写入数据库——添加、删除或更新记录——你只需要处理包含你正在写入的文档 ID 的分片。出于这个原因,这种类型的二级索引被称为 **本地索引**。在信息检索上下文中,它也被称为 **文档分区索引** [^30]。 -当从本地二级索引读取时,如果你已经知道你正在查找的记录的分区键,你可以只在适当的分片上执行搜索。此外,如果你只想要 *一些* 结果,而不需要全部,你可以将请求发送到任何分片。 +当从本地二级索引读取时,如果你已经知道你正在查找的记录的分区键,你可以只在适当的分片上执行搜索。此外,如果你只想要 **一些** 结果,而不需要全部,你可以将请求发送到任何分片。 但是,如果你想要所有结果并且事先不知道它们的分区键,你需要将查询发送到所有分片,并组合你收到的结果,因为匹配的记录可能分散在所有分片中。在 [图 7-9](#fig_sharding_local_secondary) 中,红色汽车出现在分片 0 和分片 1 中。 -这种查询分片数据库的方法有时称为 *分散/收集*(scatter/gather),它可能使二级索引读取变得相当昂贵。即使并行查询各分片,分散/收集也容易导致尾部延迟放大(见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla))。它还会限制应用的可伸缩性:增加分片可以提升可存储数据量,但若每个查询仍需所有分片参与,查询吞吐量并不会随分片数增加而提升。 +这种查询分片数据库的方法有时称为 **分散/收集**(scatter/gather),它可能使二级索引读取变得相当昂贵。即使并行查询各分片,分散/收集也容易导致尾部延迟放大(见 ["响应时间指标的使用"](/ch2#sec_introduction_slo_sla))。它还会限制应用的可伸缩性:增加分片可以提升可存储数据量,但若每个查询仍需所有分片参与,查询吞吐量并不会随分片数增加而提升。 尽管如此,本地二级索引被广泛使用 [^31]:例如,MongoDB、Riak、Cassandra [^32]、Elasticsearch [^33]、SolrCloud 和 VoltDB [^34] 都使用本地二级索引。 ### 全局二级索引 {#id167} -我们可以构建一个覆盖所有分片数据的 *全局索引*,而不是每个分片有自己的本地二级索引。但是,我们不能只将该索引存储在一个节点上,因为它可能会成为瓶颈并违背分片的目的。全局索引也必须进行分片,但它可以以不同于主键索引的方式进行分片。 +我们可以构建一个覆盖所有分片数据的 **全局索引**,而不是每个分片有自己的本地二级索引。但是,我们不能只将该索引存储在一个节点上,因为它可能会成为瓶颈并违背分片的目的。全局索引也必须进行分片,但它可以以不同于主键索引的方式进行分片。 -[图 7-10](#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]:回忆一下 ["全文检索"](/ch4#sec_storage_full_text),在全文检索中,*词项* 是你可以搜索的文本中的关键字。这里我们将其推广为指二级索引中你可以搜索的任何值。 +这种索引也称为 **基于词项分区** [^30]:回忆一下 ["全文检索"](/ch4#sec_storage_full_text),在全文检索中,**词项** 是你可以搜索的文本中的关键字。这里我们将其推广为指二级索引中你可以搜索的任何值。 全局索引使用词项作为分区键,因此当你查找特定词项或值时,你可以找出需要查询哪个分片。和以前一样,分片可以包含连续的词项范围(如 [图 7-10](#fig_sharding_global_secondary)),或者你可以基于词项的哈希将词项分配给分片。 -全局索引的优点是,只有一个查询条件时(如 *color = red*),只需从一个分片读取即可获得倒排列表。但如果你不仅要 ID,还要取回完整记录,仍然必须去负责这些 ID 的各个分片读取。 +全局索引的优点是,只有一个查询条件时(如 **color = red**),只需从一个分片读取即可获得倒排列表。但如果你不仅要 ID,还要取回完整记录,仍然必须去负责这些 ID 的各个分片读取。 如果你有多个搜索条件或词项(例如搜索某种颜色且某个制造商的汽车,或搜索同一文本中出现的多个单词),这些词项很可能会落在不同分片。要计算两个条件的逻辑 AND,系统需要找出同时出现在两个倒排列表中的 ID。若倒排列表较短,这没问题;但若很长,把它们通过网络发送后再算交集就可能很慢 [^30]。 diff --git a/content/zh/ch8.md b/content/zh/ch8.md index 063552b..f9b558b 100644 --- a/content/zh/ch8.md +++ b/content/zh/ch8.md @@ -24,17 +24,17 @@ breadcrumbs: false 为了实现可靠性,系统必须处理这些故障,确保它们不会导致整个系统的灾难性故障。然而,实现容错机制需要大量工作。它需要仔细考虑所有可能出错的事情,并进行大量测试,以确保解决方案真正有效。 -数十年来,*事务*一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(*提交*),要么失败(*中止*、*回滚*)。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理变得简单多了,因为它不用再担心部分失败——即某些操作成功,某些失败(无论出于何种原因)。 +数十年来,**事务**一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(**提交**),要么失败(**中止**、**回滚**)。如果失败,应用程序可以安全地重试。对于事务来说,应用程序的错误处理变得简单多了,因为它不用再担心部分失败——即某些操作成功,某些失败(无论出于何种原因)。 -如果你与事务打交道多年,它们可能看起来显而易见,但我们不应该将其视为理所当然。事务不是自然法则;它们是有目的地创建的,即为了*简化应用程序的编程模型*。通过使用事务,应用程序可以自由地忽略某些潜在的错误场景和并发问题,因为数据库会替应用处理好这些(我们称之为*安全保证*)。 +如果你与事务打交道多年,它们可能看起来显而易见,但我们不应该将其视为理所当然。事务不是自然法则;它们是有目的地创建的,即为了**简化应用程序的编程模型**。通过使用事务,应用程序可以自由地忽略某些潜在的错误场景和并发问题,因为数据库会替应用处理好这些(我们称之为**安全保证**)。 并非所有应用程序都需要事务,有时弱化事务保证或完全放弃事务也有好处(例如,为了获得更高的性能或更高的可用性)。某些安全属性可以在没有事务的情况下实现。另一方面,事务可以防止很多麻烦:例如,邮局 Horizon 丑闻(参见["可靠性有多重要?"](/ch2#sidebar_reliability_importance))背后的技术原因可能是底层会计系统缺乏 ACID 事务[^1]。 你如何确定是否需要事务?为了回答这个问题,我们首先需要准确理解事务可以提供哪些安全保证,以及相关的成本。尽管事务乍看起来很简单,但实际上有许多细微但重要的细节在起作用。 -在本章中,我们将研究许多可能出错的案例,并探索数据库用于防范这些问题的算法。我们将特别深入并发控制领域,讨论可能发生的各种竞态条件,以及数据库如何实现*读已提交*、*快照隔离*和*可串行化*等隔离级别。 +在本章中,我们将研究许多可能出错的案例,并探索数据库用于防范这些问题的算法。我们将特别深入并发控制领域,讨论可能发生的各种竞态条件,以及数据库如何实现**读已提交**、**快照隔离**和**可串行化**等隔离级别。 -并发控制对单节点和分布式数据库都很重要。在本章后面的["分布式事务"](#sec_transactions_distributed)部分,我们将研究*两阶段提交*协议和在分布式事务中实现原子性的挑战。 +并发控制对单节点和分布式数据库都很重要。在本章后面的["分布式事务"](#sec_transactions_distributed)部分,我们将研究**两阶段提交**协议和在分布式事务中实现原子性的挑战。 ## 事务到底是什么? {#sec_transactions_overview} @@ -48,41 +48,41 @@ breadcrumbs: false ### ACID 的含义 {#sec_transactions_acid} -事务提供的安全保证通常由众所周知的首字母缩略词 *ACID* 来描述,它代表*原子性*(Atomicity)、*一致性*(Consistency)、*隔离性*(Isolation)和*持久性*(Durability)。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出[^9],旨在为数据库中的容错机制建立精确的术语。 +事务提供的安全保证通常由众所周知的首字母缩略词 **ACID** 来描述,它代表**原子性**(Atomicity)、**一致性**(Consistency)、**隔离性**(Isolation)和**持久性**(Durability)。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出[^9],旨在为数据库中的容错机制建立精确的术语。 -然而,在实践中,一个数据库的 ACID 实现并不等同于另一个数据库的实现。例如,正如我们将看到的,*隔离性*的含义有很多歧义[^10]。高层次的想法是合理的,但魔鬼在细节中。今天,当一个系统声称自己"符合 ACID"时,实际上你能期待什么保证并不清楚。不幸的是,ACID 基本上已经成为了一个营销术语。 +然而,在实践中,一个数据库的 ACID 实现并不等同于另一个数据库的实现。例如,正如我们将看到的,**隔离性**的含义有很多歧义[^10]。高层次的想法是合理的,但魔鬼在细节中。今天,当一个系统声称自己"符合 ACID"时,实际上你能期待什么保证并不清楚。不幸的是,ACID 基本上已经成为了一个营销术语。 -(不符合 ACID 标准的系统有时被称为 *BASE*,它代表*基本可用*(Basically Available)、*软状态*(Soft state)和*最终一致性*(Eventual consistency)[^11]。这比 ACID 的定义更加模糊。似乎 BASE 唯一合理的定义是"非 ACID";即,它几乎可以代表任何你想要的东西。) +(不符合 ACID 标准的系统有时被称为 **BASE**,它代表**基本可用**(Basically Available)、**软状态**(Soft state)和**最终一致性**(Eventual consistency)[^11]。这比 ACID 的定义更加模糊。似乎 BASE 唯一合理的定义是"非 ACID";即,它几乎可以代表任何你想要的东西。) 让我们深入了解原子性、一致性、隔离性和持久性的定义,这将让我们提炼出事务的思想。 #### 原子性 {#sec_transactions_acid_atomicity} -一般来说,*原子*是指不能分解成更小部分的东西。这个词在计算机的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行原子操作,这意味着另一个线程无法看到该操作的半完成结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间。 +一般来说,**原子**是指不能分解成更小部分的东西。这个词在计算机的不同分支中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行原子操作,这意味着另一个线程无法看到该操作的半完成结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间。 -相比之下,在 ACID 的上下文中,原子性*不是*关于并发的。它不描述如果几个进程试图同时访问相同的数据会发生什么,因为这包含在字母 *I*(*隔离性*)中(参见["隔离性"](#sec_transactions_acid_isolation))。 +相比之下,在 ACID 的上下文中,原子性**不是**关于并发的。它不描述如果几个进程试图同时访问相同的数据会发生什么,因为这包含在字母 **I**(**隔离性**)中(参见["隔离性"](#sec_transactions_acid_isolation))。 -相反,ACID 原子性描述了当客户端想要进行多次写入,但在某些写入被处理后发生故障时会发生什么——例如,进程崩溃、网络连接中断、磁盘变满或违反了某些完整性约束。如果这些写入被分组到一个原子事务中,并且由于故障无法完成(*提交*)事务,则事务被*中止*,数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。 +相反,ACID 原子性描述了当客户端想要进行多次写入,但在某些写入被处理后发生故障时会发生什么——例如,进程崩溃、网络连接中断、磁盘变满或违反了某些完整性约束。如果这些写入被分组到一个原子事务中,并且由于故障无法完成(**提交**)事务,则事务被**中止**,数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。 如果没有原子性,如果在进行多处更改的中途发生错误,很难知道哪些更改已经生效,哪些没有。应用程序可以重试,但这有进行两次相同更改的风险,导致数据重复或错误。原子性简化了这个问题:如果事务被中止,应用程序可以确定它没有改变任何东西,因此可以安全地重试。 -在错误时中止事务并丢弃该事务的所有写入的能力是 ACID 原子性的定义特征。也许*可中止性*比*原子性*更好,但我们将坚持使用*原子性*,因为这是常用词。 +在错误时中止事务并丢弃该事务的所有写入的能力是 ACID 原子性的定义特征。也许**可中止性**比**原子性**更好,但我们将坚持使用**原子性**,因为这是常用词。 #### 一致性 {#sec_transactions_acid_consistency} -*一致性*这个词被严重滥用: +**一致性**这个词被严重滥用: -* 在[第 6 章](/ch6#ch_replication)中,我们讨论了*副本一致性*和异步复制系统中出现的*最终一致性*问题(参见["复制延迟的问题"](/ch6#sec_replication_lag))。 -* 数据库的*一致快照*(例如,用于备份)是整个数据库在某一时刻存在的快照。更准确地说,它与先发生关系(happens-before relation)一致(参见["“先发生”关系和并发"](/ch6#sec_replication_happens_before)):也就是说,如果快照包含在特定时间写入的值,那么它也反映了在该值写入之前发生的所有写入。 -* *一致性哈希*是某些系统用于再平衡的分片方法(参见["一致性哈希"](/ch7#sec_sharding_consistent_hashing))。 -* 在 CAP定理中(参见[第 10 章](/ch10#ch_consistency)),*一致性*一词用于表示*线性一致性*(参见["线性一致性"](/ch10#sec_consistency_linearizability))。 -* 在 ACID 的上下文中,*一致性*是指应用程序特定的数据库处于"良好状态"的概念。 +* 在[第 6 章](/ch6#ch_replication)中,我们讨论了**副本一致性**和异步复制系统中出现的**最终一致性**问题(参见["复制延迟的问题"](/ch6#sec_replication_lag))。 +* 数据库的**一致快照**(例如,用于备份)是整个数据库在某一时刻存在的快照。更准确地说,它与先发生关系(happens-before relation)一致(参见["“先发生”关系和并发"](/ch6#sec_replication_happens_before)):也就是说,如果快照包含在特定时间写入的值,那么它也反映了在该值写入之前发生的所有写入。 +* **一致性哈希**是某些系统用于再平衡的分片方法(参见["一致性哈希"](/ch7#sec_sharding_consistent_hashing))。 +* 在 CAP定理中(参见[第 10 章](/ch10#ch_consistency)),**一致性**一词用于表示**线性一致性**(参见["线性一致性"](/ch10#sec_consistency_linearizability))。 +* 在 ACID 的上下文中,**一致性**是指应用程序特定的数据库处于"良好状态"的概念。 不幸的是,同一个词至少有五种不同的含义。 -ACID 一致性的思想是,你对数据有某些陈述(*不变式*)必须始终为真——例如,在会计系统中,所有账户的贷方和借方必须始终平衡。如果事务从满足这些不变式的有效数据库开始,并且事务期间的任何写入都保持有效性,那么你可以确定不变式始终得到满足。(不变式可能在事务执行期间暂时违反,但在事务提交时应该再次满足。) +ACID 一致性的思想是,你对数据有某些陈述(**不变式**)必须始终为真——例如,在会计系统中,所有账户的贷方和借方必须始终平衡。如果事务从满足这些不变式的有效数据库开始,并且事务期间的任何写入都保持有效性,那么你可以确定不变式始终得到满足。(不变式可能在事务执行期间暂时违反,但在事务提交时应该再次满足。) -如果你希望数据库强制执行你的不变式,你需要将它们声明为模式的一部分的*约束*。例如,外键约束、唯一性约束或检查约束(限制单个行中可以出现的值)通常用于对特定类型的不变式建模。更复杂的一致性要求有时可以使用触发器或物化视图建模[^12]。 +如果你希望数据库强制执行你的不变式,你需要将它们声明为模式的一部分的**约束**。例如,外键约束、唯一性约束或检查约束(限制单个行中可以出现的值)通常用于对特定类型的不变式建模。更复杂的一致性要求有时可以使用触发器或物化视图建模[^12]。 然而,复杂的不变式可能很难或不可能使用数据库通常提供的约束来建模。在这种情况下,应用程序有责任正确定义其事务,以便它们保持一致性。如果你写入违反不变式的错误数据,但你没有声明这些不变式,数据库无法阻止你。因此,ACID 中的 C 通常取决于应用程序如何使用数据库,而不仅仅是数据库的属性。 @@ -95,13 +95,13 @@ ACID 一致性的思想是,你对数据有某些陈述(*不变式*)必须 {{< figure src="/fig/ddia_0801.png" id="fig_transactions_increment" caption="图 8-1. 两个客户端并发递增计数器之间的竞态条件。" class="w-full my-4" >}} -ACID 意义上的*隔离性*意味着同时执行的事务彼此隔离:它们不能相互干扰。经典的数据库教科书将隔离性形式化为*可串行化*,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们*串行*运行(一个接一个)相同,即使实际上它们可能是并发运行的[^13]。 +ACID 意义上的**隔离性**意味着同时执行的事务彼此隔离:它们不能相互干扰。经典的数据库教科书将隔离性形式化为**可串行化**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们**串行**运行(一个接一个)相同,即使实际上它们可能是并发运行的[^13]。 -然而,可串行化有性能成本。在实践中,许多数据库使用比可串行化更弱的隔离形式:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle,甚至没有实现它(Oracle 有一个称为"可串行化"的隔离级别,但它实际上实现了*快照隔离*,这是比可串行化更弱的保证[^10] [^14])。这意味着某些类型的竞态条件仍然可能发生。我们将在["弱隔离级别"](#sec_transactions_isolation_levels)中探讨快照隔离和其他形式的隔离。 +然而,可串行化有性能成本。在实践中,许多数据库使用比可串行化更弱的隔离形式:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle,甚至没有实现它(Oracle 有一个称为"可串行化"的隔离级别,但它实际上实现了**快照隔离**,这是比可串行化更弱的保证[^10] [^14])。这意味着某些类型的竞态条件仍然可能发生。我们将在["弱隔离级别"](#sec_transactions_isolation_levels)中探讨快照隔离和其他形式的隔离。 #### 持久性 {#durability} -数据库系统的目的是提供一个安全的地方来存储数据,而不用担心丢失它。*持久性*是一个承诺,即一旦事务成功提交,它写入的任何数据都不会被遗忘,即使发生硬件故障或数据库崩溃。 +数据库系统的目的是提供一个安全的地方来存储数据,而不用担心丢失它。**持久性**是一个承诺,即一旦事务成功提交,它写入的任何数据都不会被遗忘,即使发生硬件故障或数据库崩溃。 在单节点数据库中,持久性通常意味着数据已经写入非易失性存储,如硬盘或 SSD。定期文件写入通常在发送到磁盘之前在内存中缓冲,这意味着如果突然断电它们将丢失;因此,许多数据库使用 `fsync()` 系统调用来确保数据真正写入磁盘。数据库通常还有预写日志或类似的(参见["使 B 树可靠"](/ch4#sec_storage_btree_wal)),这允许它们在写入过程中发生崩溃时恢复。 @@ -140,7 +140,7 @@ ACID 意义上的*隔离性*意味着同时执行的事务彼此隔离:它们 隔离性 : 并发运行的事务不应该相互干扰。例如,如果一个事务进行多次写入,那么另一个事务应该看到所有或不看到这些写入,但不是某些子集。 -这些定义假设你想要同时修改多个对象(行、文档、记录)。这种*多对象事务*通常需要保持多块数据同步。[图 8-2](#fig_transactions_read_uncommitted) 显示了一个来自电子邮件应用程序的示例。要显示用户的未读消息数,你可以查询类似这样的内容: +这些定义假设你想要同时修改多个对象(行、文档、记录)。这种**多对象事务**通常需要保持多块数据同步。[图 8-2](#fig_transactions_read_uncommitted) 显示了一个来自电子邮件应用程序的示例。要显示用户的未读消息数,你可以查询类似这样的内容: ``` SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true @@ -160,7 +160,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 多对象事务需要某种方式来确定哪些读写操作属于同一事务。在关系数据库中,这通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,`BEGIN TRANSACTION` 和 `COMMIT` 语句之间的所有内容都被认为是同一事务的一部分。如果 TCP 连接中断,事务必须被中止。 -另一方面,许多非关系数据库没有这样的方式来将操作组合在一起。即使有多对象 API(例如,键值存储可能有一个*多重放置*操作,在一个操作中更新多个键),这并不一定意味着它具有事务语义:该命令可能在某些键上成功而在其他键上失败,使数据库处于部分更新状态。 +另一方面,许多非关系数据库没有这样的方式来将操作组合在一起。即使有多对象 API(例如,键值存储可能有一个**多重放置**操作,在一个操作中更新多个键),这并不一定意味着它具有事务语义:该命令可能在某些键上成功而在其他键上失败,使数据库处于部分更新状态。 #### 单对象写入 {#sec_transactions_single_object} @@ -172,7 +172,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 这些问题会令人非常困惑,因此存储引擎几乎普遍的目标是在一个节点上的单个对象(如键值对)上提供原子性和隔离性。原子性可以使用日志实现崩溃恢复(参见["使 B 树可靠"](/ch4#sec_storage_btree_wal)),隔离性可以使用每个对象上的锁来实现(一次只允许一个线程访问对象)。 -某些数据库还提供更复杂的原子操作,例如递增操作,它消除了像[图 8-1](#fig_transactions_increment) 中那样的读-修改-写循环的需求。类似流行的是*条件写入*操作,它允许仅在值未被其他人并发更改时才进行写入(参见["条件写入(比较并设置)"](#sec_transactions_compare_and_set)),类似于共享内存并发中的比较并设置或比较并交换(CAS)操作。 +某些数据库还提供更复杂的原子操作,例如递增操作,它消除了像[图 8-1](#fig_transactions_increment) 中那样的读-修改-写循环的需求。类似流行的是**条件写入**操作,它允许仅在值未被其他人并发更改时才进行写入(参见["条件写入(比较并设置)"](#sec_transactions_compare_and_set)),类似于共享内存并发中的比较并设置或比较并交换(CAS)操作。 -------- @@ -219,9 +219,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 并发错误很难通过测试发现,因为这些错误只有在时机不巧时才会触发。这种时机问题可能非常罕见,通常难以重现。并发也很难推理,特别是在大型应用程序中,你不一定知道代码的其他部分正在访问数据库。如果只有一个用户,应用程序开发就已经够困难了;有许多并发用户会让情况变得更加困难,因为任何数据都可能在任何时候意外地发生变化。 -出于这个原因,数据库长期以来一直试图通过提供*事务隔离*来向应用程序开发人员隐藏并发问题。理论上,隔离应该让你的生活更轻松,让你假装没有并发发生:*可串行化*隔离意味着数据库保证事务具有与*串行*运行(即一次一个,没有任何并发)相同的效果。 +出于这个原因,数据库长期以来一直试图通过提供**事务隔离**来向应用程序开发人员隐藏并发问题。理论上,隔离应该让你的生活更轻松,让你假装没有并发发生:**可串行化**隔离意味着数据库保证事务具有与**串行**运行(即一次一个,没有任何并发)相同的效果。 -在实践中,隔离不幸并不那么简单。可串行化隔离有性能成本,许多数据库不愿意支付这个代价[^10]。因此,系统通常使用较弱的隔离级别,这些级别可以防止*某些*并发问题,但不是全部。这些隔离级别更难理解,它们可能导致微妙的错误,但它们在实践中仍然被使用[^29]。 +在实践中,隔离不幸并不那么简单。可串行化隔离有性能成本,许多数据库不愿意支付这个代价[^10]。因此,系统通常使用较弱的隔离级别,这些级别可以防止**某些**并发问题,但不是全部。这些隔离级别更难理解,它们可能导致微妙的错误,但它们在实践中仍然被使用[^29]。 由弱事务隔离引起的并发错误不仅仅是理论问题。它们已经导致了巨额资金损失[^30] [^31] [^32],引发了金融审计师的调查[^33],并导致客户数据损坏[^34]。对此类问题披露的一个流行评论是"如果你正在处理金融数据,请使用 ACID 数据库!"——但这没有抓住重点。即使许多流行的关系数据库系统(通常被认为是"ACID")使用弱隔离,因此它们不一定能防止这些错误发生。 @@ -238,36 +238,36 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true ### 读已提交 {#sec_transactions_read_committed} -最基本的事务隔离级别是*读已提交*。它提供两个保证: +最基本的事务隔离级别是**读已提交**。它提供两个保证: -1. 从数据库读取时,你只会看到已经提交的数据(没有*脏读*)。 -2. 写入数据库时,你只会覆盖已经提交的数据(没有*脏写*)。 +1. 从数据库读取时,你只会看到已经提交的数据(没有**脏读**)。 +2. 写入数据库时,你只会覆盖已经提交的数据(没有**脏写**)。 -某些数据库支持更弱的隔离级别,称为*读未提交*。它防止脏写,但不防止脏读。让我们更详细地讨论这两个保证。 +某些数据库支持更弱的隔离级别,称为**读未提交**。它防止脏写,但不防止脏读。让我们更详细地讨论这两个保证。 #### 没有脏读 {#no-dirty-reads} -想象一个事务已经向数据库写入了一些数据,但事务尚未提交或中止。另一个事务能看到那个未提交的数据吗?如果能,这称为*脏读*[^3]。 +想象一个事务已经向数据库写入了一些数据,但事务尚未提交或中止。另一个事务能看到那个未提交的数据吗?如果能,这称为**脏读**[^3]。 -在读已提交隔离级别下运行的事务必须防止脏读。这意味着事务的任何写入只有在该事务提交时才对其他人可见(然后它的所有写入立即变得可见)。这在[图 8-4](#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](#fig_transactions_read_uncommitted) 中,用户看到新的未读电子邮件但没有看到更新的计数器。这是电子邮件的脏读。看到数据库处于部分更新状态会让用户感到困惑,并可能导致其他事务做出错误的决定。 -* 如果事务中止,它所做的任何写入都需要回滚(如[图 8-3](#fig_transactions_atomicity))。如果数据库允许脏读,这意味着事务可能看到后来被回滚的数据——即从未实际提交到数据库的数据。任何读取未提交数据的事务也需要被中止,导致称为*级联中止*的问题。 +* 如果事务中止,它所做的任何写入都需要回滚(如[图 8-3](#fig_transactions_atomicity))。如果数据库允许脏读,这意味着事务可能看到后来被回滚的数据——即从未实际提交到数据库的数据。任何读取未提交数据的事务也需要被中止,导致称为**级联中止**的问题。 #### 没有脏写 {#sec_transactions_dirty_write} 如果两个事务并发尝试更新数据库中的同一行会发生什么?我们不知道写入将以什么顺序发生,但我们通常假设后面的写入会覆盖前面的写入。 -然而,如果前面的写入是尚未提交的事务的一部分,因此后面的写入覆盖了一个未提交的值,会发生什么?这称为*脏写*[^36]。在读已提交隔离级别下运行的事务必须防止脏写,通常通过延迟第二个写入直到第一个写入的事务已提交或中止。 +然而,如果前面的写入是尚未提交的事务的一部分,因此后面的写入覆盖了一个未提交的值,会发生什么?这称为**脏写**[^36]。在读已提交隔离级别下运行的事务必须防止脏写,通常通过延迟第二个写入直到第一个写入的事务已提交或中止。 通过防止脏写,这个隔离级别避免了某些类型的并发问题: * 如果事务更新多行,脏写可能导致糟糕的结果。例如,考虑[图 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)中,我们将讨论如何使此类计数器递增安全。 +* 然而,读已提交**不**防止[图 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" >}} @@ -297,7 +297,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 假设 Aaliyah 在银行有 1,000 美元的储蓄,分成两个账户,每个 500 美元。现在一笔事务从她的一个账户转账 100 美元到另一个账户。如果她不幸在该事务处理的同时查看她的账户余额列表,她可能会看到一个账户余额在收款到达之前(余额为 500 美元),另一个账户在转出之后(新余额为 400 美元)。对 Aaliyah 来说,现在她的账户总共只有 900 美元——似乎 100 美元凭空消失了。 -这种异常称为*读取偏差*,它是*不可重复读*的一个例子:如果 Aaliyah 在事务结束时再次读取账户 1 的余额,她会看到与之前查询中看到的不同的值(600 美元)。读取偏差在读已提交隔离下被认为是可接受的:Aaliyah 看到的账户余额确实是在她读取它们时已提交的。 +这种异常称为**读取偏差**,它是**不可重复读**的一个例子:如果 Aaliyah 在事务结束时再次读取账户 1 的余额,她会看到与之前查询中看到的不同的值(600 美元)。读取偏差在读已提交隔离下被认为是可接受的:Aaliyah 看到的账户余额确实是在她读取它们时已提交的。 -------- @@ -314,7 +314,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 分析查询和完整性检查 : 有时,你可能想要运行扫描数据库大部分的查询。此类查询在分析中很常见(参见["分析与运营系统"](/ch1#sec_introduction_analytics)),或者可能是定期完整性检查的一部分,以确保一切正常(监控数据损坏)。如果这些查询在不同时间点观察数据库的不同部分,它们很可能返回无意义的结果。 -*快照隔离*[^36] 是解决这个问题的最常见方法。其思想是每个事务从数据库的*一致快照*读取——也就是说,事务看到事务开始时数据库中已提交的所有数据。即使数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。 +**快照隔离**[^36] 是解决这个问题的最常见方法。其思想是每个事务从数据库的**一致快照**读取——也就是说,事务看到事务开始时数据库中已提交的所有数据。即使数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。 快照隔离对于长时间运行的只读查询(如备份和分析)来说是一个福音。如果查询操作的数据在查询执行的同时发生变化,很难推理查询的含义。当事务可以看到数据库的一致快照(冻结在特定时间点)时,理解起来就容易得多。 @@ -322,9 +322,9 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true #### 多版本并发控制(MVCC) {#sec_transactions_snapshot_impl} -与读已提交隔离一样,快照隔离的实现通常使用写锁来防止脏写(参见["实现读已提交"](#sec_transactions_read_committed_impl)),这意味着进行写入的事务可以阻止写入同一行的另一个事务的进度。但是,读取不需要任何锁。从性能的角度来看,快照隔离的一个关键原则是*读者永远不会阻塞写者,写者永远不会阻塞读者*。这允许数据库在一致快照上处理长时间运行的读查询,同时正常处理写入,两者之间没有任何锁争用。 +与读已提交隔离一样,快照隔离的实现通常使用写锁来防止脏写(参见["实现读已提交"](#sec_transactions_read_committed_impl)),这意味着进行写入的事务可以阻止写入同一行的另一个事务的进度。但是,读取不需要任何锁。从性能的角度来看,快照隔离的一个关键原则是**读者永远不会阻塞写者,写者永远不会阻塞读者**。这允许数据库在一致快照上处理长时间运行的读查询,同时正常处理写入,两者之间没有任何锁争用。 -为了实现快照隔离,数据库使用了我们在[图 8-4](#fig_transactions_read_committed) 中看到的防止脏读机制的泛化。数据库必须潜在地保留每行的几个不同的已提交版本,而不是每行的两个版本(已提交版本和被覆盖但尚未提交的版本),因为各种正在进行的事务可能需要在不同时间点看到数据库的状态。因为它并排维护一行的多个版本,所以这种技术被称为*多版本并发控制*(MVCC)。 +为了实现快照隔离,数据库使用了我们在[图 8-4](#fig_transactions_read_committed) 中看到的防止脏读机制的泛化。数据库必须潜在地保留每行的几个不同的已提交版本,而不是每行的两个版本(已提交版本和被覆盖但尚未提交的版本),因为各种正在进行的事务可能需要在不同时间点看到数据库的状态。因为它并排维护一行的多个版本,所以这种技术被称为**多版本并发控制**(MVCC)。 [图 8-7](#fig_transactions_mvcc) 说明了 PostgreSQL 中如何实现基于 MVCC 的快照隔离[^40] [^42] [^43](其他实现类似)。当事务启动时,它被赋予一个唯一的、始终递增的事务 ID(`txid`)。每当事务向数据库写入任何内容时,它写入的数据都用写入者的事务 ID 标记。(准确地说,PostgreSQL 中的事务 ID 是 32 位整数,因此它们在大约 40 亿个事务后溢出。清理过程执行清理以确保溢出不会影响数据。) @@ -363,7 +363,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true 许多实现细节影响多版本并发控制的性能[^45] [^46]。例如,如果同一行的不同版本可以适合同一页面,PostgreSQL 有避免索引更新的优化[^40]。其他一些数据库避免存储修改行的完整副本,而只存储版本之间的差异以节省空间。 -CouchDB、Datomic 和 LMDB 使用另一种方法。尽管它们也使用 B 树(参见["B 树"](/ch4#sec_storage_b_trees)),但它们使用*不可变*(写时复制)变体,在更新时不会覆盖树的页面,而是创建每个修改页面的新副本。父页面,直到树的根,被复制并更新以指向其子页面的新版本。任何不受写入影响的页面都不需要复制,并且可以与新树共享[^47]。 +CouchDB、Datomic 和 LMDB 使用另一种方法。尽管它们也使用 B 树(参见["B 树"](/ch4#sec_storage_b_trees)),但它们使用**不可变**(写时复制)变体,在更新时不会覆盖树的页面,而是创建每个修改页面的新副本。父页面,直到树的根,被复制并更新以指向其子页面的新版本。任何不受写入影响的页面都不需要复制,并且可以与新树共享[^47]。 使用不可变 B 树,每个写事务(或事务批次)都会创建一个新的 B 树根,特定的根是创建时数据库的一致快照。不需要基于事务 ID 过滤行,因为后续写入无法修改现有的 B 树;它们只能创建新的树根。这种方法还需要后台进程进行压缩和垃圾收集。 @@ -381,9 +381,9 @@ MVCC 是数据库常用的实现技术,通常用于实现快照隔离。然而 到目前为止,我们讨论的读已提交和快照隔离级别主要是关于只读事务在并发写入存在的情况下可以看到什么的保证。我们大多忽略了两个事务并发写入的问题——我们只讨论了脏写(参见["没有脏写"](#sec_transactions_dirty_write)),这是可能发生的一种特定类型的写-写冲突。 -并发写入事务之间还可能发生其他几种有趣的冲突。其中最著名的是*丢失更新*问题,在[图 8-1](#fig_transactions_increment) 中以两个并发计数器递增的例子说明。 +并发写入事务之间还可能发生其他几种有趣的冲突。其中最著名的是**丢失更新**问题,在[图 8-1](#fig_transactions_increment) 中以两个并发计数器递增的例子说明。 -如果应用程序从数据库读取某个值,修改它,然后写回修改后的值(*读-修改-写循环*),就会出现丢失更新问题。如果两个事务并发执行此操作,其中一个修改可能会丢失,因为第二个写入不包括第一个修改。(我们有时说后面的写入*覆盖*了前面的写入。)这种模式出现在各种不同的场景中: +如果应用程序从数据库读取某个值,修改它,然后写回修改后的值(**读-修改-写循环**),就会出现丢失更新问题。如果两个事务并发执行此操作,其中一个修改可能会丢失,因为第二个写入不包括第一个修改。(我们有时说后面的写入**覆盖**了前面的写入。)这种模式出现在各种不同的场景中: * 递增计数器或更新账户余额(需要读取当前值,计算新值,并写回更新的值) * 对复杂值进行本地更改,例如,向 JSON 文档中的列表添加元素(需要解析文档,进行更改,并写回修改后的文档) @@ -443,7 +443,7 @@ COMMIT; #### 条件写入(比较并设置) {#sec_transactions_compare_and_set} -在不提供事务的数据库中,你有时会发现一个*条件写入*操作,它可以通过仅在值自你上次读取以来未更改时才允许更新来防止丢失的更新(之前在["单对象写入"](#sec_transactions_single_object)中提到)。如果当前值与你之前读取的不匹配,则更新无效,必须重试读-修改-写循环。它是许多 CPU 支持的原子*比较并设置*或*比较并交换*(CAS)指令的数据库等价物。 +在不提供事务的数据库中,你有时会发现一个**条件写入**操作,它可以通过仅在值自你上次读取以来未更改时才允许更新来防止丢失的更新(之前在["单对象写入"](#sec_transactions_single_object)中提到)。如果当前值与你之前读取的不匹配,则更新无效,必须重试读-修改-写循环。它是许多 CPU 支持的原子**比较并设置**或**比较并交换**(CAS)指令的数据库等价物。 例如,为了防止两个用户同时更新同一个 wiki 页面,你可以尝试类似这样的操作,期望仅当页面内容自用户开始编辑以来没有更改时才进行更新: @@ -453,7 +453,7 @@ UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old content'; ``` -如果内容已更改并且不再匹配 `'old content'`,则此更新将无效,因此你需要检查更新是否生效并在必要时重试。你也可以使用在每次更新时递增的版本号列,并且仅在当前版本号未更改时才应用更新,而不是比较完整内容。这种方法有时称为*乐观锁定*[^52]。 +如果内容已更改并且不再匹配 `'old content'`,则此更新将无效,因此你需要检查更新是否生效并在必要时重试。你也可以使用在每次更新时递增的版本号列,并且仅在当前版本号未更改时才应用更新,而不是比较完整内容。这种方法有时称为**乐观锁定**[^52]。 请注意,如果另一个事务并发修改了 `content`,则根据 MVCC 可见性规则,新内容可能不可见(参见["观察一致快照的可见性规则"](#sec_transactions_mvcc_visibility))。MVCC 的许多实现对此场景有可见性规则的例外,其中其他事务写入的值对 `UPDATE` 和 `DELETE` 查询的 `WHERE` 子句的评估可见,即使这些写入在快照中不可见。 @@ -463,15 +463,15 @@ UPDATE wiki_pages SET content = 'new content' 锁和条件写入操作假设有一个最新的数据副本。然而,具有多领导者或无主(无领导者)复制的数据库通常允许多个写入并发发生并异步复制它们,因此它们不能保证有一个最新的数据副本。因此,基于锁或条件写入的技术在此上下文中不适用。(我们将在["线性一致性"](/ch10#sec_consistency_linearizability)中更详细地重新讨论这个问题。) -相反,如["处理冲突写入"](/ch6#sec_replication_write_conflicts)中所讨论的,此类复制数据库中的常见方法是允许并发写入创建值的多个冲突版本(也称为*兄弟节点*),并使用应用程序代码或特殊数据结构在事后解决和合并这些版本。 +相反,如["处理冲突写入"](/ch6#sec_replication_write_conflicts)中所讨论的,此类复制数据库中的常见方法是允许并发写入创建值的多个冲突版本(也称为**兄弟节点**),并使用应用程序代码或特殊数据结构在事后解决和合并这些版本。 如果更新是可交换的(即,你可以在不同副本上以不同顺序应用它们,仍然得到相同的结果),合并冲突值可以防止丢失的更新。例如,递增计数器或向集合添加元素是可交换操作。这就是 CRDT 背后的想法,我们在["CRDT 和操作转换"](/ch6#sec_replication_crdts)中遇到过。然而,某些操作(如条件写入)不能成为可交换的。 -另一方面,*最后写入胜利*(LWW)冲突解决方法容易丢失更新,如["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww)中所讨论的。不幸的是,LWW 是许多复制数据库中的默认值。 +另一方面,**最后写入胜利**(LWW)冲突解决方法容易丢失更新,如["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww)中所讨论的。不幸的是,LWW 是许多复制数据库中的默认值。 ### 写偏差与幻读 {#sec_transactions_write_skew} -在前面的部分中,我们看到了*脏写*和*丢失更新*,这是当不同事务并发尝试写入相同对象时可能发生的两种竞态条件。为了避免数据损坏,需要防止这些竞态条件——要么由数据库自动防止,要么通过使用锁或原子写操作等手动保护措施。 +在前面的部分中,我们看到了**脏写**和**丢失更新**,这是当不同事务并发尝试写入相同对象时可能发生的两种竞态条件。为了避免数据损坏,需要防止这些竞态条件——要么由数据库自动防止,要么通过使用锁或原子写操作等手动保护措施。 然而,这并不是并发写入之间可能发生的潜在竞态条件列表的结尾。在本节中,我们将看到一些更微妙的冲突示例。 @@ -486,7 +486,7 @@ UPDATE wiki_pages SET content = 'new content' #### 写偏差的特征 {#characterizing-write-skew} -这种异常称为*写偏差*[^36]。它既不是脏写也不是丢失的更新,因为两个事务正在更新两个不同的对象(分别是 Aaliyah 和 Bryce 的值班记录)。这里发生冲突不太明显,但这绝对是一个竞态条件:如果两个事务一个接一个地运行,第二个医生将被阻止下班。异常行为只有在事务并发运行时才可能。 +这种异常称为**写偏差**[^36]。它既不是脏写也不是丢失的更新,因为两个事务正在更新两个不同的对象(分别是 Aaliyah 和 Bryce 的值班记录)。这里发生冲突不太明显,但这绝对是一个竞态条件:如果两个事务一个接一个地运行,第二个医生将被阻止下班。异常行为只有在事务并发运行时才可能。 你可以将写偏差视为丢失更新问题的概括。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),就会发生写偏差。在不同事务更新同一对象的特殊情况下,你会得到脏写或丢失更新异常(取决于时机)。 @@ -561,9 +561,9 @@ UPDATE wiki_pages SET content = 'new content' 步骤可能以不同的顺序发生。例如,你可以先进行写入,然后进行 `SELECT` 查询,最后根据查询结果决定是中止还是提交。 -在医生值班示例的情况下,步骤 3 中被修改的行是步骤 1 中返回的行之一,因此我们可以通过锁定步骤 1 中的行(`SELECT FOR UPDATE`)来使事务安全并避免写偏差。但是,其他四个示例是不同的:它们检查*不存在*匹配某些搜索条件的行,而写入*添加*了匹配相同条件的行。如果步骤 1 中的查询不返回任何行,`SELECT FOR UPDATE` 就无法附加锁[^56]。 +在医生值班示例的情况下,步骤 3 中被修改的行是步骤 1 中返回的行之一,因此我们可以通过锁定步骤 1 中的行(`SELECT FOR UPDATE`)来使事务安全并避免写偏差。但是,其他四个示例是不同的:它们检查**不存在**匹配某些搜索条件的行,而写入**添加**了匹配相同条件的行。如果步骤 1 中的查询不返回任何行,`SELECT FOR UPDATE` 就无法附加锁[^56]。 -这种效果,其中一个事务中的写入改变另一个事务中搜索查询的结果,称为*幻读*[^4]。快照隔离避免了只读查询中的幻读,但在我们讨论的读写事务中,幻读可能导致特别棘手的写偏差情况。ORM 生成的 SQL 也容易出现写偏差[^50] [^51]。 +这种效果,其中一个事务中的写入改变另一个事务中搜索查询的结果,称为**幻读**[^4]。快照隔离避免了只读查询中的幻读,但在我们讨论的读写事务中,幻读可能导致特别棘手的写偏差情况。ORM 生成的 SQL 也容易出现写偏差[^50] [^51]。 #### 物化冲突 {#materializing-conflicts} @@ -573,7 +573,7 @@ UPDATE wiki_pages SET content = 'new content' 现在,想要创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中对应于所需房间和时间段的行。获取锁后,它可以像以前一样检查重叠的预订并插入新的预订。请注意,附加表不用于存储有关预订的信息——它纯粹是一组锁,用于防止同一房间和时间范围的预订被并发修改。 -这种方法称为*物化冲突*,因为它采用了幻读并将其转化为存在于数据库中的具体行集上的锁冲突[^14]。不幸的是,很难且容易出错地弄清楚如何物化冲突,并且让并发控制机制泄漏到应用程序数据模型中是丑陋的。出于这些原因,如果没有其他选择,物化冲突应被视为最后的手段。在大多数情况下,可串行化隔离级别要好得多。 +这种方法称为**物化冲突**,因为它采用了幻读并将其转化为存在于数据库中的具体行集上的锁冲突[^14]。不幸的是,很难且容易出错地弄清楚如何物化冲突,并且让并发控制机制泄漏到应用程序数据模型中是丑陋的。出于这些原因,如果没有其他选择,物化冲突应被视为最后的手段。在大多数情况下,可串行化隔离级别要好得多。 @@ -585,9 +585,9 @@ UPDATE wiki_pages SET content = 'new content' * 如果你查看你的应用程序代码,很难判断在特定隔离级别下运行是否安全——特别是在大型应用程序中,你可能不知道所有可能并发发生的事情。 * 没有好的工具来帮助我们检测竞态条件。原则上,静态分析可能有所帮助[^33],但研究技术尚未进入实际使用。测试并发问题很困难,因为它们通常是非确定性的——只有在时机不巧时才会出现问题。 -这不是一个新问题——自 1970 年代引入弱隔离级别以来一直如此[^3]。一直以来,研究人员的答案都很简单:使用*可串行化*隔离! +这不是一个新问题——自 1970 年代引入弱隔离级别以来一直如此[^3]。一直以来,研究人员的答案都很简单:使用**可串行化**隔离! -可串行化隔离是最强的隔离级别。它保证即使事务可能并行执行,最终结果与它们*串行*执行(一次一个,没有任何并发)相同。因此,数据库保证如果事务在单独运行时行为正确,那么在并发运行时它们继续保持正确——换句话说,数据库防止了*所有*可能的竞态条件。 +可串行化隔离是最强的隔离级别。它保证即使事务可能并行执行,最终结果与它们**串行**执行(一次一个,没有任何并发)相同。因此,数据库保证如果事务在单独运行时行为正确,那么在并发运行时它们继续保持正确——换句话说,数据库防止了**所有**可能的竞态条件。 但如果可串行化隔离比弱隔离级别的混乱要好得多,那为什么不是每个人都在使用它?要回答这个问题,我们需要查看实现可串行化的选项,以及它们的性能如何。今天提供可串行化的大多数数据库使用以下三种技术之一,我们将在本章的其余部分探讨: @@ -618,7 +618,7 @@ UPDATE wiki_pages SET content = 'new content' 在这种交互式事务风格中,大量时间花在应用程序和数据库之间的网络通信上。如果你要在数据库中禁止并发并一次只处理一个事务,吞吐量将是可怕的,因为数据库将大部分时间都在等待应用程序为当前事务发出下一个查询。在这种数据库中,为了获得合理的性能,必须并发处理多个事务。 -因此,具有单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须将自己限制为包含单个语句的事务,或者提前将整个事务代码作为*存储过程*提交给数据库[^61]。 +因此,具有单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须将自己限制为包含单个语句的事务,或者提前将整个事务代码作为**存储过程**提交给数据库[^61]。 交互式事务和存储过程之间的差异如[图 8-9](#fig_transactions_stored_proc) 所示。前提是事务所需的所有数据都在内存中,存储过程可以非常快速地执行,而无需等待任何网络或磁盘 I/O。 @@ -639,7 +639,7 @@ UPDATE wiki_pages SET content = 'new content' 使用存储过程和内存数据,在单个线程上执行所有事务变得可行。当存储过程不需要等待 I/O 并避免其他并发控制机制的开销时,它们可以在单个线程上实现相当好的吞吐量。 -VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个节点复制到另一个节点,而是在每个副本上执行相同的存储过程。因此,VoltDB 要求存储过程是*确定性的*(在不同节点上运行时,它们必须产生相同的结果)。例如,如果事务需要使用当前日期和时间,它必须通过特殊的确定性 API 来实现(有关确定性操作的更多详细信息,请参见["持久执行和工作流"](/ch5#sec_encoding_dataflow_workflows))。这种方法称为*状态机复制*,我们将在[第 10 章](/ch10#ch_consistency)中回到它。 +VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个节点复制到另一个节点,而是在每个副本上执行相同的存储过程。因此,VoltDB 要求存储过程是**确定性的**(在不同节点上运行时,它们必须产生相同的结果)。例如,如果事务需要使用当前日期和时间,它必须通过特殊的确定性 API 来实现(有关确定性操作的更多详细信息,请参见["持久执行和工作流"](/ch5#sec_encoding_dataflow_workflows))。这种方法称为**状态机复制**,我们将在[第 10 章](/ch10#ch_consistency)中回到它。 #### 分片 {#sharding} @@ -664,14 +664,14 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个 ### 两阶段锁定(2PL) {#sec_transactions_2pl} -大约 30 年来,数据库中只有一种广泛使用的可串行化算法:*两阶段锁定*(2PL),有时称为*强严格两阶段锁定*(SS2PL),以区别于 2PL 的其他变体。 +大约 30 年来,数据库中只有一种广泛使用的可串行化算法:**两阶段锁定**(2PL),有时称为**强严格两阶段锁定**(SS2PL),以区别于 2PL 的其他变体。 -------- > [!TIP] 2PL 不是 2PC -两阶段*锁定*(2PL)和两阶段*提交*(2PC)是两个非常不同的东西。2PL 提供可串行化隔离,而 2PC 在分布式数据库中提供原子提交(参见["两阶段提交(2PC)"](#sec_transactions_2pc))。为避免混淆,最好将它们视为完全独立的概念,并忽略名称中不幸的相似性。 +两阶段**锁定**(2PL)和两阶段**提交**(2PC)是两个非常不同的东西。2PL 提供可串行化隔离,而 2PC 在分布式数据库中提供原子提交(参见["两阶段提交(2PC)"](#sec_transactions_2pc))。为避免混淆,最好将它们视为完全独立的概念,并忽略名称中不幸的相似性。 -------- @@ -682,20 +682,20 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个 * 如果事务 A 已读取对象而事务 B 想要写入该对象,B 必须等到 A 提交或中止后才能继续。(这确保 B 不能在 A 背后意外地更改对象。) * 如果事务 A 已写入对象而事务 B 想要读取该对象,B 必须等到 A 提交或中止后才能继续。(像[图 8-4](#fig_transactions_read_committed) 中那样读取对象的旧版本在 2PL 下是不可接受的。) -在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:*读者永远不会阻塞写者,写者永远不会阻塞读者*(参见["多版本并发控制(MVCC)"](#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏差。 +在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读者,反之亦然。快照隔离有这样的口号:**读者永远不会阻塞写者,写者永远不会阻塞读者**(参见["多版本并发控制(MVCC)"](#sec_transactions_snapshot_impl)),这捕捉了快照隔离和两阶段锁定之间的关键区别。另一方面,因为 2PL 提供可串行化,它可以防止早期讨论的所有竞态条件,包括丢失的更新和写偏差。 #### 两阶段锁定的实现 {#implementation-of-two-phase-locking} 2PL 由 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别以及 Db2 中的可重复读隔离级别使用[^29]。 -读者和写者的阻塞是通过在数据库中的每个对象上有一个锁来实现的。锁可以处于*共享模式*或*独占模式*(也称为*多读者单写者*锁)。锁的使用如下: +读者和写者的阻塞是通过在数据库中的每个对象上有一个锁来实现的。锁可以处于**共享模式**或**独占模式**(也称为**多读者单写者**锁)。锁的使用如下: * 如果事务想要读取对象,它必须首先以共享模式获取锁。多个事务可以同时以共享模式持有锁,但如果另一个事务已经对该对象具有独占锁,则这些事务必须等待。 * 如果事务想要写入对象,它必须首先以独占模式获取锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),因此如果对象上有任何现有锁,事务必须等待。 * 如果事务首先读取然后写入对象,它可以将其共享锁升级为独占锁。升级的工作方式与直接获取独占锁相同。 * 获取锁后,事务必须继续持有锁直到事务结束(提交或中止)。这就是"两阶段"名称的来源:第一阶段(事务执行时)是获取锁,第二阶段(事务结束时)是释放所有锁。 -由于使用了如此多的锁,很容易发生事务 A 等待事务 B 释放其锁,反之亦然的情况。这种情况称为*死锁*。数据库自动检测事务之间的死锁并中止其中一个,以便其他事务可以取得进展。中止的事务需要由应用程序重试。 +由于使用了如此多的锁,很容易发生事务 A 等待事务 B 释放其锁,反之亦然的情况。这种情况称为**死锁**。数据库自动检测事务之间的死锁并中止其中一个,以便其他事务可以取得进展。中止的事务需要由应用程序重试。 #### 两阶段锁定的性能 {#performance-of-two-phase-locking} @@ -711,11 +711,11 @@ VoltDB 还使用存储过程进行复制:它不是将事务的写入从一个 #### 谓词锁 {#predicate-locks} -在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏差的幻读"](#sec_transactions_phantom)中,我们讨论了*幻读*的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。 +在前面的锁描述中,我们掩盖了一个微妙但重要的细节。在["导致写偏差的幻读"](#sec_transactions_phantom)中,我们讨论了**幻读**的问题——即一个事务改变另一个事务的搜索查询结果。具有可串行化隔离的数据库必须防止幻读。 在会议室预订示例中,这意味着如果一个事务已经搜索了某个时间窗口内某个房间的现有预订(参见[例 8-2](#fig_transactions_meeting_rooms)),另一个事务不允许并发插入或更新同一房间和时间范围的另一个预订。(并发插入其他房间的预订,或同一房间不影响拟议预订的不同时间的预订是可以的。) -我们如何实现这一点?从概念上讲,我们需要一个*谓词锁*[^4]。它的工作方式类似于前面描述的共享/独占锁,但它不属于特定对象(例如,表中的一行),而是属于匹配某些搜索条件的所有对象,例如: +我们如何实现这一点?从概念上讲,我们需要一个**谓词锁**[^4]。它的工作方式类似于前面描述的共享/独占锁,但它不属于特定对象(例如,表中的一行),而是属于匹配某些搜索条件的所有对象,例如: ``` SELECT * FROM bookings @@ -733,7 +733,7 @@ SELECT * FROM bookings #### 索引范围锁 {#sec_transactions_2pl_range} -不幸的是,谓词锁的性能不佳:如果活动事务有许多锁,检查匹配锁变得耗时。因此,大多数具有 2PL 的数据库实际上实现了*索引范围锁定*(也称为*间隙锁*),这是谓词锁定的简化近似[^54] [^64]。 +不幸的是,谓词锁的性能不佳:如果活动事务有许多锁,检查匹配锁变得耗时。因此,大多数具有 2PL 的数据库实际上实现了**索引范围锁定**(也称为**间隙锁**),这是谓词锁定的简化近似[^54] [^64]。 通过使谓词匹配更大的对象集来简化谓词是安全的。例如,如果你对中午到下午 1 点之间房间 123 的预订有谓词锁,你可以通过锁定房间 123 在任何时间的预订来近似它,或者你可以通过锁定中午到下午 1 点之间的所有房间(不仅仅是房间 123)来近似它。这是安全的,因为匹配原始谓词的任何写入肯定也会匹配近似。 @@ -752,17 +752,17 @@ SELECT * FROM bookings 本章描绘了数据库并发控制的黯淡画面。一方面,我们有性能不佳(两阶段锁定)或可伸缩性不佳(串行执行)的可串行化实现。另一方面,我们有性能良好但容易出现各种竞态条件(丢失的更新、写偏差、幻读等)的弱隔离级别。可串行化隔离和良好性能从根本上是对立的吗? -似乎不是:一种称为*可串行化快照隔离*(SSI)的算法提供完全可串行化,与快照隔离相比只有很小的性能损失。SSI 相对较新:它于 2008 年首次描述[^53] [^65]。 +似乎不是:一种称为**可串行化快照隔离**(SSI)的算法提供完全可串行化,与快照隔离相比只有很小的性能损失。SSI 相对较新:它于 2008 年首次描述[^53] [^65]。 今天,SSI 和类似算法用于单节点数据库(PostgreSQL 中的可串行化隔离级别[^54]、SQL Server 的内存 OLTP/Hekaton[^66] 和 HyPer[^67])、分布式数据库(CockroachDB[^5] 和 FoundationDB[^8])以及嵌入式存储引擎(如 BadgerDB)。 #### 悲观并发控制与乐观并发控制 {#pessimistic-versus-optimistic-concurrency-control} -两阶段锁定是所谓的*悲观*并发控制机制:它基于这样的原则,即如果任何事情可能出错(如另一个事务持有的锁所示),最好等到情况再次安全后再做任何事情。它就像*互斥*,用于保护多线程编程中的数据结构。 +两阶段锁定是所谓的**悲观**并发控制机制:它基于这样的原则,即如果任何事情可能出错(如另一个事务持有的锁所示),最好等到情况再次安全后再做任何事情。它就像**互斥**,用于保护多线程编程中的数据结构。 串行执行在某种意义上是悲观到极端:它本质上相当于每个事务在事务期间对整个数据库(或数据库的一个分片)具有独占锁。我们通过使每个事务执行得非常快来补偿悲观主义,因此它只需要短时间持有"锁"。 -相比之下,可串行化快照隔离是一种*乐观*并发控制技术。在这种情况下,乐观意味着,如果发生潜在危险的事情,事务不会阻塞,而是继续进行,希望一切都会好起来。当事务想要提交时,数据库会检查是否发生了任何不好的事情(即,是否违反了隔离);如果是,事务将被中止并必须重试。只允许可串行执行的事务提交。 +相比之下,可串行化快照隔离是一种**乐观**并发控制技术。在这种情况下,乐观意味着,如果发生潜在危险的事情,事务不会阻塞,而是继续进行,希望一切都会好起来。当事务想要提交时,数据库会检查是否发生了任何不好的事情(即,是否违反了隔离);如果是,事务将被中止并必须重试。只允许可串行执行的事务提交。 乐观并发控制是一个老想法[^68],其优缺点已经争论了很长时间[^69]。如果存在高争用(许多事务尝试访问相同的对象),它的性能很差,因为这会导致大部分事务需要中止。如果系统已经接近其最大吞吐量,重试事务的额外事务负载可能会使性能变差。 @@ -774,7 +774,7 @@ SELECT * FROM bookings 当我们之前讨论快照隔离中的写偏差时(参见["写偏差与幻读"](#sec_transactions_write_skew)),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。但是,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。 -换句话说,事务基于*前提*(事务开始时为真的事实,例如,"当前有两名医生值班")采取行动。后来,当事务想要提交时,原始数据可能已更改——前提可能不再为真。 +换句话说,事务基于**前提**(事务开始时为真的事实,例如,"当前有两名医生值班")采取行动。后来,当事务想要提交时,原始数据可能已更改——前提可能不再为真。 当应用程序进行查询(例如,"当前有多少医生值班?")时,数据库不知道应用程序逻辑如何使用该查询的结果。为了安全起见,数据库需要假设查询结果(前提)中的任何更改都意味着该事务中的写入可能无效。换句话说,事务中的查询和写入之间可能存在因果依赖关系。为了提供可串行化隔离,数据库必须检测事务可能基于过时前提采取行动的情况,并在这种情况下中止事务。 @@ -833,7 +833,7 @@ SELECT * FROM bookings 对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端要求数据库节点提交事务时,数据库使事务的写入持久化(通常在预写日志中;参见["使 B 树可靠"](/ch4#sec_storage_btree_wal)),然后将提交记录附加到磁盘上的日志。如果数据库在此过程中崩溃,事务将在节点重新启动时从日志中恢复:如果提交记录在崩溃前成功写入磁盘,则事务被认为已提交;如果没有,该事务的任何写入都将回滚。 -因此,在单个节点上,事务提交关键取决于数据持久写入磁盘的*顺序*:首先是数据,然后是提交记录[^22]。事务提交或中止的关键决定时刻是磁盘完成写入提交记录的时刻:在那一刻之前,仍然可能中止(由于崩溃),但在那一刻之后,事务已提交(即使数据库崩溃)。因此,是单个设备(连接到特定节点的特定磁盘驱动器的控制器)使提交成为原子的。 +因此,在单个节点上,事务提交关键取决于数据持久写入磁盘的**顺序**:首先是数据,然后是提交记录[^22]。事务提交或中止的关键决定时刻是磁盘完成写入提交记录的时刻:在那一刻之前,仍然可能中止(由于崩溃),但在那一刻之后,事务已提交(即使数据库崩溃)。因此,是单个设备(连接到特定节点的特定磁盘驱动器的控制器)使提交成为原子的。 但是,如果多个节点参与事务会怎样?例如,也许你在分片数据库中有多对象事务,或者有全局二级索引(其中索引条目可能与主数据在不同的节点上;参见["分片和二级索引"](/ch7#sec_sharding_secondary_indexes))。大多数"NoSQL"分布式数据存储不支持此类分布式事务,但各种分布式关系数据库支持。 @@ -846,25 +846,25 @@ SELECT * FROM bookings {{< figure src="/fig/ddia_0812.png" id="fig_transactions_non_atomic" caption="图 8-12. 当事务涉及多个数据库节点时,它可能在某些节点上提交,在其他节点上失败。" class="w-full my-4" >}} -如果某些节点提交事务而其他节点中止它,节点之间就会变得不一致。一旦事务在一个节点上提交,如果后来发现它在另一个节点上被中止,就不能撤回了。这是因为一旦数据被提交,它在*读已提交*或更强的隔离下对其他事务可见。例如,在[图 8-12](#fig_transactions_non_atomic) 中,当用户 1 注意到其在数据库 1 上的提交失败时,用户 2 已经从数据库 2 上的同一事务读取了数据。如果用户 1 的事务后来被中止,用户 2 的事务也必须被还原,因为它基于被追溯声明不存在的数据。 +如果某些节点提交事务而其他节点中止它,节点之间就会变得不一致。一旦事务在一个节点上提交,如果后来发现它在另一个节点上被中止,就不能撤回了。这是因为一旦数据被提交,它在**读已提交**或更强的隔离下对其他事务可见。例如,在[图 8-12](#fig_transactions_non_atomic) 中,当用户 1 注意到其在数据库 1 上的提交失败时,用户 2 已经从数据库 2 上的同一事务读取了数据。如果用户 1 的事务后来被中止,用户 2 的事务也必须被还原,因为它基于被追溯声明不存在的数据。 -更好的方法是确保参与事务的节点要么全部提交,要么全部中止,并防止两者的混合。确保这一点被称为*原子提交*问题。 +更好的方法是确保参与事务的节点要么全部提交,要么全部中止,并防止两者的混合。确保这一点被称为**原子提交**问题。 ### 两阶段提交(2PC) {#sec_transactions_2pc} -两阶段提交是一种跨多个节点实现原子事务提交的算法。它是分布式数据库中的经典算法[^13] [^71] [^72]。2PC 在某些数据库内部使用,也以 *XA 事务*[^73] 的形式提供给应用程序(例如,Java 事务 API 支持),或通过 WS-AtomicTransaction 用于 SOAP Web 服务[^74] [^75]。 +两阶段提交是一种跨多个节点实现原子事务提交的算法。它是分布式数据库中的经典算法[^13] [^71] [^72]。2PC 在某些数据库内部使用,也以 **XA 事务**[^73] 的形式提供给应用程序(例如,Java 事务 API 支持),或通过 WS-AtomicTransaction 用于 SOAP Web 服务[^74] [^75]。 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" >}} -2PC 使用一个通常不会出现在单节点事务中的新组件:*协调器*(也称为*事务管理器*)。协调器通常作为请求事务的同一应用程序进程中的库实现(例如,嵌入在 Java EE 容器中),但它也可以是单独的进程或服务。此类协调器的示例包括 Narayana、JOTM、BTM 或 MSDTC。 +2PC 使用一个通常不会出现在单节点事务中的新组件:**协调器**(也称为**事务管理器**)。协调器通常作为请求事务的同一应用程序进程中的库实现(例如,嵌入在 Java EE 容器中),但它也可以是单独的进程或服务。此类协调器的示例包括 Narayana、JOTM、BTM 或 MSDTC。 -使用 2PC 时,分布式事务从应用程序在多个数据库节点上正常读写数据开始。我们称这些数据库节点为事务中的*参与者*。当应用程序准备提交时,协调器开始第 1 阶段:它向每个节点发送*准备*请求,询问它们是否能够提交。然后协调器跟踪参与者的响应: +使用 2PC 时,分布式事务从应用程序在多个数据库节点上正常读写数据开始。我们称这些数据库节点为事务中的**参与者**。当应用程序准备提交时,协调器开始第 1 阶段:它向每个节点发送**准备**请求,询问它们是否能够提交。然后协调器跟踪参与者的响应: -* 如果所有参与者回复"是",表示他们准备提交,那么协调器在第 2 阶段发出*提交*请求,提交实际发生。 -* 如果任何参与者回复"否",协调器在第 2 阶段向所有节点发送*中止*请求。 +* 如果所有参与者回复"是",表示他们准备提交,那么协调器在第 2 阶段发出**提交**请求,提交实际发生。 +* 如果任何参与者回复"否",协调器在第 2 阶段向所有节点发送**中止**请求。 这个过程有点像西方文化中的传统婚礼仪式:牧师分别询问新娘和新郎是否愿意嫁给对方,通常从两人那里得到"我愿意"的答案。在收到两个确认后,牧师宣布这对夫妇为夫妻:事务已提交,这个快乐的事实向所有参加者广播。如果新娘或新郎没有说"是",仪式就被中止了[^76]。 @@ -880,7 +880,7 @@ SELECT * FROM bookings 4. 当参与者收到准备请求时,它确保它可以在任何情况下明确提交事务。 这包括将所有事务数据写入磁盘(崩溃、电源故障或磁盘空间不足不是稍后拒绝提交的可接受借口),并检查任何冲突或约束违规。通过向协调器回复"是",节点承诺在请求时无错误地提交事务。换句话说,参与者放弃了中止事务的权利,但没有实际提交它。 -5. 当协调器收到所有准备请求的响应时,它对是否提交或中止事务做出明确决定(仅当所有参与者投票"是"时才提交)。协调器必须将该决定写入其磁盘上的事务日志,以便在随后崩溃时知道它是如何决定的。这称为*提交点*。 +5. 当协调器收到所有准备请求的响应时,它对是否提交或中止事务做出明确决定(仅当所有参与者投票"是"时才提交)。协调器必须将该决定写入其磁盘上的事务日志,以便在随后崩溃时知道它是如何决定的。这称为**提交点**。 6. 一旦协调器的决定被写入磁盘,提交或中止请求就会发送给所有参与者。如果此请求失败或超时,协调器必须永远重试,直到成功。没有回头路:如果决定是提交,那么必须执行该决定,无论需要多少次重试。如果参与者在此期间崩溃,事务将在恢复时提交——因为参与者投票"是",它在恢复时不能拒绝提交。 因此,该协议包含两个关键的"不归路":当参与者投票"是"时,它承诺它肯定能够稍后提交(尽管协调器仍可能选择中止);一旦协调器决定,该决定是不可撤销的。这些承诺确保了 2PC 的原子性。(单节点原子提交将这两个事件合并为一个:将提交记录写入事务日志。) @@ -891,7 +891,7 @@ SELECT * FROM bookings 我们已经讨论了如果参与者之一或网络在 2PC 期间失败会发生什么:如果任何准备请求失败或超时,协调器将中止事务;如果任何提交或中止请求失败,协调器将无限期地重试它们。但是,如果协调器崩溃会发生什么就不太清楚了。 -如果协调器在发送准备请求之前失败,参与者可以安全地中止事务。但是一旦参与者收到准备请求并投票"是",它就不能再单方面中止——它必须等待协调器回复事务是提交还是中止。如果协调器此时崩溃或网络失败,参与者除了等待别无他法。参与者在此状态下的事务称为*存疑*或*不确定*。 +如果协调器在发送准备请求之前失败,参与者可以安全地中止事务。但是一旦参与者收到准备请求并投票"是",它就不能再单方面中止——它必须等待协调器回复事务是提交还是中止。如果协调器此时崩溃或网络失败,参与者除了等待别无他法。参与者在此状态下的事务称为**存疑**或**不确定**。 这种情况如[图 8-14](#fig_transactions_2pc_crash) 所示。在这个特定的例子中,协调器实际上决定提交,数据库 2 收到了提交请求。但是,协调器在向数据库 1 发送提交请求之前崩溃了,因此数据库 1 不知道是提交还是中止。即使超时在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将与已提交的数据库 2 不一致。同样,单方面提交也不安全,因为另一个参与者可能已中止。 @@ -904,9 +904,9 @@ SELECT * FROM bookings #### 三阶段提交 {#three-phase-commit} -由于 2PC 可能会卡住等待协调器恢复,因此两阶段提交被称为*阻塞*原子提交协议。可以使原子提交协议*非阻塞*,以便在节点失败时不会卡住。但是,在实践中使其工作并不那么简单。 +由于 2PC 可能会卡住等待协调器恢复,因此两阶段提交被称为**阻塞**原子提交协议。可以使原子提交协议**非阻塞**,以便在节点失败时不会卡住。但是,在实践中使其工作并不那么简单。 -作为 2PC 的替代方案,已经提出了一种称为*三阶段提交*(3PC)的算法[^13] [^77]。但是,3PC 假设具有有界延迟的网络和具有有界响应时间的节点;在大多数具有无界网络延迟和进程暂停的实际系统中(参见[第 9 章](/ch9#ch_distributed)),它无法保证原子性。 +作为 2PC 的替代方案,已经提出了一种称为**三阶段提交**(3PC)的算法[^13] [^77]。但是,3PC 假设具有有界延迟的网络和具有有界响应时间的节点;在大多数具有无界网络延迟和进程暂停的实际系统中(参见[第 9 章](/ch9#ch_distributed)),它无法保证原子性。 实践中更好的解决方案是用容错共识协议替换单节点协调器。我们将在[第 10 章](/ch10#ch_consistency)中看到如何做到这一点。 @@ -922,7 +922,7 @@ SELECT * FROM bookings : 某些分布式数据库(即,在其标准配置中使用复制和分片的数据库)支持该数据库节点之间的内部事务。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB 和 MySQL Cluster 的 NDB 存储引擎都有这样的内部事务支持。在这种情况下,参与事务的所有节点都运行相同的数据库软件。 异构分布式事务 -: 在*异构*事务中,参与者是两个或多个不同的技术:例如,来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。跨这些系统的分布式事务必须确保原子提交,即使系统在底层可能完全不同。 +: 在**异构**事务中,参与者是两个或多个不同的技术:例如,来自不同供应商的两个数据库,甚至是非数据库系统(如消息代理)。跨这些系统的分布式事务必须确保原子提交,即使系统在底层可能完全不同。 数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议并应用特定于该特定技术的优化。因此,数据库内部分布式事务通常可以很好地工作。另一方面,跨异构技术的事务更具挑战性。 @@ -930,7 +930,7 @@ SELECT * FROM bookings 异构分布式事务允许以强大的方式集成各种系统。例如,当且仅当处理消息的数据库事务成功提交时,来自消息队列的消息才能被确认为已处理。这是通过在单个事务中原子地提交消息确认和数据库写入来实现的。有了分布式事务支持,即使消息代理和数据库是在不同机器上运行的两种不相关的技术,这也是可能的。 -如果消息传递或数据库事务失败,两者都会中止,因此消息代理可以稍后安全地重新传递消息。因此,通过原子地提交消息及其处理的副作用,我们可以确保消息在效果上*恰好*处理一次,即使在成功之前需要几次重试。中止会丢弃部分完成事务的任何副作用。这被称为*恰好一次语义*。 +如果消息传递或数据库事务失败,两者都会中止,因此消息代理可以稍后安全地重新传递消息。因此,通过原子地提交消息及其处理的副作用,我们可以确保消息在效果上**恰好**处理一次,即使在成功之前需要几次重试。中止会丢弃部分完成事务的任何副作用。这被称为**恰好一次语义**。 但是,只有当受事务影响的所有系统都能够使用相同的原子提交协议时,这种分布式事务才有可能。例如,假设处理消息的副作用是发送电子邮件,而电子邮件服务器不支持两阶段提交:如果消息处理失败并重试,可能会发生电子邮件被发送两次或更多次。但是,如果处理消息的所有副作用在事务中止时都会回滚,那么处理步骤可以安全地重试,就好像什么都没有发生一样。 @@ -938,7 +938,7 @@ SELECT * FROM bookings #### XA 事务 {#xa-transactions} -*X/Open XA*(*eXtended Architecture* 的缩写)是跨异构技术实现两阶段提交的标准[^73]。它于 1991 年推出并得到广泛实现:XA 受到许多传统关系数据库(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和消息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)的支持。 +**X/Open XA**(**eXtended Architecture** 的缩写)是跨异构技术实现两阶段提交的标准[^73]。它于 1991 年推出并得到广泛实现:XA 受到许多传统关系数据库(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和消息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)的支持。 XA 不是网络协议——它只是用于与事务协调器接口的 C API。此 API 的绑定存在于其他语言中;例如,在 Java EE 应用程序的世界中,XA 事务使用 Java 事务 API(JTA)实现,而 JTA 又由许多使用 Java 数据库连接(JDBC)的数据库驱动程序和使用 Java 消息服务(JMS)API 的消息代理驱动程序支持。 @@ -952,7 +952,7 @@ XA 假设你的应用程序使用网络驱动程序或客户端库与参与者 为什么我们如此关心事务陷入存疑?系统的其余部分不能继续工作,忽略最终会被清理的存疑事务吗? -问题在于*锁定*。如["读已提交"](#sec_transactions_read_committed)中所讨论的,数据库事务通常对它们修改的任何行进行行级独占锁,以防止脏写。此外,如果你想要可串行化隔离,使用两阶段锁定的数据库还必须对事务*读取*的任何行进行共享锁。 +问题在于**锁定**。如["读已提交"](#sec_transactions_read_committed)中所讨论的,数据库事务通常对它们修改的任何行进行行级独占锁,以防止脏写。此外,如果你想要可串行化隔离,使用两阶段锁定的数据库还必须对事务**读取**的任何行进行共享锁。 数据库在事务提交或中止之前不能释放这些锁(如[图 8-13](#fig_transactions_two_phase_commit) 中的阴影区域所示)。因此,使用两阶段提交时,事务必须在存疑期间保持锁。如果协调器崩溃并需要 20 分钟才能重新启动,这些锁将保持 20 分钟。如果协调器的日志由于某种原因完全丢失,这些锁将永远保持——或者至少直到管理员手动解决情况。 @@ -960,13 +960,13 @@ XA 假设你的应用程序使用网络驱动程序或客户端库与参与者 #### 从协调器故障中恢复 {#recovering-from-coordinator-failure} -理论上,如果协调器崩溃并重新启动,它应该从日志中干净地恢复其状态并解决任何存疑事务。但是,在实践中,*孤立的*存疑事务确实会发生[^83] [^84]——也就是说,协调器由于某种原因(例如,由于软件错误导致事务日志丢失或损坏)无法决定结果的事务。这些事务无法自动解决,因此它们永远留在数据库中,持有锁并阻塞其他事务。 +理论上,如果协调器崩溃并重新启动,它应该从日志中干净地恢复其状态并解决任何存疑事务。但是,在实践中,**孤立的**存疑事务确实会发生[^83] [^84]——也就是说,协调器由于某种原因(例如,由于软件错误导致事务日志丢失或损坏)无法决定结果的事务。这些事务无法自动解决,因此它们永远留在数据库中,持有锁并阻塞其他事务。 即使重新启动数据库服务器也无法解决此问题,因为 2PC 的正确实现必须即使在重新启动时也保留存疑事务的锁(否则它将冒着违反原子性保证的风险)。这是一个棘手的情况。 唯一的出路是管理员手动决定是提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有任何参与者已经提交或中止,然后将相同的结果应用于其他参与者。解决问题可能需要大量的手动工作,并且很可能需要在严重的生产中断期间在高压力和时间压力下完成(否则,为什么协调器会处于如此糟糕的状态?)。 -许多 XA 实现都有一个名为*启发式决策*的紧急逃生舱口:允许参与者在没有协调器明确决定的情况下单方面决定中止或提交存疑事务[^73]。明确地说,这里的*启发式*是*可能破坏原子性*的委婉说法,因为启发式决策违反了两阶段提交中的承诺系统。因此,启发式决策仅用于摆脱灾难性情况,而不用于常规使用。 +许多 XA 实现都有一个名为**启发式决策**的紧急逃生舱口:允许参与者在没有协调器明确决定的情况下单方面决定中止或提交存疑事务[^73]。明确地说,这里的**启发式**是**可能破坏原子性**的委婉说法,因为启发式决策违反了两阶段提交中的承诺系统。因此,启发式决策仅用于摆脱灾难性情况,而不用于常规使用。 #### XA 事务的问题 {#problems-with-xa-transactions} @@ -1010,7 +1010,7 @@ XA 的最大问题可以通过以下方式解决: 如果消息处理器在提交数据库事务之前崩溃,事务将被中止,消息代理将重试处理。如果它在提交后但在向代理确认消息之前崩溃,它也将重试处理,但重试将在数据库中看到消息 ID 并丢弃它。如果它在确认消息后但在从数据库中删除消息 ID 之前崩溃,你将有一个旧的消息 ID 留下,除了占用一点存储空间外不会造成任何伤害。如果在数据库事务中止之前发生重试(如果消息处理器和数据库之间的通信中断,这可能会发生),消息 ID 表上的唯一性约束应该防止两个并发事务插入相同的消息 ID。 -因此,实现恰好一次处理只需要数据库中的事务——跨数据库和消息代理的原子性对于此用例不是必需的。在数据库中记录消息 ID 使消息处理具备*幂等性*,因此可以安全地重试消息处理而不会重复其副作用。流处理框架(如 Kafka Streams)中使用类似的方法来实现恰好一次语义,我们将在["容错"](/ch12#sec_stream_fault_tolerance)中看到。 +因此,实现恰好一次处理只需要数据库中的事务——跨数据库和消息代理的原子性对于此用例不是必需的。在数据库中记录消息 ID 使消息处理具备**幂等性**,因此可以安全地重试消息处理而不会重复其副作用。流处理框架(如 Kafka Streams)中使用类似的方法来实现恰好一次语义,我们将在["容错"](/ch12#sec_stream_fault_tolerance)中看到。 但是,数据库内的内部分布式事务对于此类模式的可伸缩性仍然有用:例如,它们将允许消息 ID 存储在一个分片上,而消息处理更新的主数据存储在其他分片上,并确保跨这些分片的事务提交的原子性。 @@ -1018,13 +1018,13 @@ XA 的最大问题可以通过以下方式解决: ## 总结 {#summary} -事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。大量错误被简化为简单的*事务中止*,应用程序只需要重试。 +事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。大量错误被简化为简单的**事务中止**,应用程序只需要重试。 在本章中,我们看到了许多事务有助于防止的问题示例。并非所有应用程序都容易受到所有这些问题的影响:具有非常简单的访问模式的应用程序(例如,仅读取和写入单个记录)可能可以在没有事务的情况下管理。但是,对于更复杂的访问模式,事务可以大大减少你需要考虑的潜在错误情况的数量。 没有事务,各种错误场景(进程崩溃、网络中断、停电、磁盘已满、意外并发等)意味着数据可能以各种方式变得不一致。例如,反规范化数据很容易与源数据失去同步。没有事务,很难推理复杂的交互访问对数据库可能产生的影响。 -在本章中,我们特别深入地探讨了并发控制的主题。我们讨论了几种广泛使用的隔离级别,特别是*读已提交*、*快照隔离*(有时称为*可重复读*)和*可串行化*。我们通过讨论各种竞态条件的示例来描述这些隔离级别,总结在 [表 8-1](#tab_transactions_isolation_levels) 中: +在本章中,我们特别深入地探讨了并发控制的主题。我们讨论了几种广泛使用的隔离级别,特别是**读已提交**、**快照隔离**(有时称为**可重复读**)和**可串行化**。我们通过讨论各种竞态条件的示例来描述这些隔离级别,总结在 [表 8-1](#tab_transactions_isolation_levels) 中: {{< figure id="tab_transactions_isolation_levels" title="表 8-1. 各种隔离级别可能发生的异常总结" class="w-full my-4" >}} @@ -1042,7 +1042,7 @@ XA 的最大问题可以通过以下方式解决: : 一个客户端覆盖另一个客户端已写入但尚未提交的数据。几乎所有事务实现都防止脏写。 读取偏差 -: 客户端在不同时间点看到数据库的不同部分。某些读取偏差的情况也称为*不可重复读*。这个问题最常通过快照隔离来防止,它允许事务从对应于特定时间点的一致快照读取。它通常使用*多版本并发控制*(MVCC)实现。 +: 客户端在不同时间点看到数据库的不同部分。某些读取偏差的情况也称为**不可重复读**。这个问题最常通过快照隔离来防止,它允许事务从对应于特定时间点的一致快照读取。它通常使用**多版本并发控制**(MVCC)实现。 丢失更新 : 两个客户端并发执行读-修改-写循环。一个覆盖另一个的写入而不合并其更改,因此数据丢失。某些快照隔离的实现会自动防止此异常,而其他实现需要手动锁(`SELECT FOR UPDATE`)。 diff --git a/content/zh/ch9.md b/content/zh/ch9.md index 59768f6..26ab23c 100644 --- a/content/zh/ch9.md +++ b/content/zh/ch9.md @@ -14,7 +14,7 @@ breadcrumbs: false 正如 ["可靠性与容错"](/ch2#sec_introduction_reliability) 中所讨论的,让系统可靠意味着确保系统作为一个整体继续工作,即使出了问题(即出现故障)。然而,预料所有可能的故障并处理它们并不是那么容易。作为开发者,我们很容易主要关注正常路径(毕竟,大多数时候事情都运行良好!)而忽略故障,因为故障会引入大量边界情况。 -如果你希望系统在故障存在的情况下仍然可靠,你必须从根本上改变你的思维方式,并专注于可能出错的事情,即使它们可能性很低。一件事情出错的概率是否只有百万分之一并不重要:在一个足够大的系统中,百万分之一的事件每天都在发生。经验丰富的系统操作员会告诉你,任何 *可能* 出错的事情 *都会* 出错。 +如果你希望系统在故障存在的情况下仍然可靠,你必须从根本上改变你的思维方式,并专注于可能出错的事情,即使它们可能性很低。一件事情出错的概率是否只有百万分之一并不重要:在一个足够大的系统中,百万分之一的事件每天都在发生。经验丰富的系统操作员会告诉你,任何 **可能** 出错的事情 **都会** 出错。 此外,使用分布式系统与在单台计算机上编写软件有着根本的不同 —— 主要区别在于有许多新的、令人兴奋的出错方式 [^1] [^2]。在本章中,你将体验实践中出现的问题,并理解你可以依赖和不能依赖的事物。 @@ -24,7 +24,7 @@ breadcrumbs: false 当你在单台计算机上编写程序时,它通常以相当可预测的方式运行:要么工作,要么不工作。有缺陷的软件可能会给人一种计算机有时 "状态不佳" 的印象(这个问题通常通过重启来解决),但这主要只是编写不良的软件的后果。 -软件在单台计算机上不应该是不稳定的,这没有根本原因:当硬件正常工作时,相同的操作总是产生相同的结果(它是 *确定性的*)。如果存在硬件问题(例如,内存损坏或连接器松动),后果通常是整个系统故障(例如,内核恐慌、"蓝屏死机"、无法启动)。一台运行良好软件的单独计算机通常要么完全正常运行,要么完全故障,而不是介于两者之间。 +软件在单台计算机上不应该是不稳定的,这没有根本原因:当硬件正常工作时,相同的操作总是产生相同的结果(它是 **确定性的**)。如果存在硬件问题(例如,内存损坏或连接器松动),后果通常是整个系统故障(例如,内核恐慌、"蓝屏死机"、无法启动)。一台运行良好软件的单独计算机通常要么完全正常运行,要么完全故障,而不是介于两者之间。 这是计算机设计中的一个刻意选择:如果发生内部故障,我们宁愿计算机完全崩溃而不是返回错误的结果,因为错误的结果很难处理且令人困惑。因此,计算机隐藏了它们所实现的模糊物理现实,并呈现一个以数学完美运行的理想化系统模型。CPU 指令总是做同样的事情;如果你将一些数据写入内存或磁盘,该数据保持完整,不会被随机损坏。正如 ["硬件与软件故障"](/ch2#sec_introduction_hardware_faults) 中所讨论的,这实际上并不是真的 —— 实际上,数据确实会被静默损坏,CPU 有时会静默返回错误的结果 —— 但这种情况发生得足够少,以至于我们可以忽略它。 @@ -34,7 +34,7 @@ breadcrumbs: false > > —— Coda Hale -在分布式系统中,系统的某些部分可能以某种不可预测的方式出现故障,即使系统的其他部分工作正常。这被称为 *部分失效*。困难在于部分失效是 *非确定性的*:如果你尝试做任何涉及多个节点和网络的事情,它有时可能工作,有时可能不可预测地失败。正如我们将看到的,你甚至可能不 *知道* 某事是否成功! +在分布式系统中,系统的某些部分可能以某种不可预测的方式出现故障,即使系统的其他部分工作正常。这被称为 **部分失效**。困难在于部分失效是 **非确定性的**:如果你尝试做任何涉及多个节点和网络的事情,它有时可能工作,有时可能不可预测地失败。正如我们将看到的,你甚至可能不 **知道** 某事是否成功! 这种非确定性和部分失效的可能性使分布式系统难以使用 [^4]。另一方面,如果分布式系统可以容忍部分失效,这将开启强大的可能性:例如,它允许你执行滚动升级,一次重启一个节点以安装软件更新,而系统作为一个整体继续不间断地工作。因此,容错使我们能够从不可靠的组件构建比单节点系统更可靠的分布式系统。 @@ -42,9 +42,9 @@ breadcrumbs: false ## 不可靠的网络 {#sec_distributed_networks} -正如 ["共享内存、共享磁盘和无共享架构"](/ch2#sec_introduction_shared_nothing) 中所讨论的,我们在本书中关注的分布式系统主要是 *无共享系统*:即通过网络连接的一组机器。网络是这些机器进行通信的唯一方式 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除非通过网络向服务发出请求)。即使存储是共享的,例如亚马逊的 S3,机器也是通过网络与共享存储服务通信。 +正如 ["共享内存、共享磁盘和无共享架构"](/ch2#sec_introduction_shared_nothing) 中所讨论的,我们在本书中关注的分布式系统主要是 **无共享系统**:即通过网络连接的一组机器。网络是这些机器进行通信的唯一方式 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除非通过网络向服务发出请求)。即使存储是共享的,例如亚马逊的 S3,机器也是通过网络与共享存储服务通信。 -互联网和数据中心中的大多数内部网络(通常是以太网)都是 *异步分组网络*。在这种网络中,一个节点可以向另一个节点发送消息(数据包),但网络不保证它何时到达,或者是否会到达。如果你发送请求并期望响应,许多事情可能会出错(其中一些如 [图 9-1](#fig_distributed_network) 所示): +互联网和数据中心中的大多数内部网络(通常是以太网)都是 **异步分组网络**。在这种网络中,一个节点可以向另一个节点发送消息(数据包),但网络不保证它何时到达,或者是否会到达。如果你发送请求并期望响应,许多事情可能会出错(其中一些如 [图 9-1](#fig_distributed_network) 所示): 1. 你的请求可能已经丢失(也许有人拔掉了网线)。 2. 你的请求可能在队列中等待,稍后将被交付(也许网络或接收方过载)。 @@ -56,13 +56,13 @@ breadcrumbs: false {{< figure src="/fig/ddia_0901.png" id="fig_distributed_network" caption="图 9-1. 如果你发送请求但没有收到响应,无法区分是 (a) 请求丢失了,(b) 远程节点宕机了,还是 (c) 响应丢失了。" class="w-full my-4" >}} -发送方甚至无法判断数据包是否已交付:唯一的选择是让接收方发送响应消息,而响应消息本身也可能丢失或延迟。在异步网络中,这些问题是无法区分的:你拥有的唯一信息是你还没有收到响应。如果你向另一个节点发送请求但没有收到响应,*不可能* 判断原因。 +发送方甚至无法判断数据包是否已交付:唯一的选择是让接收方发送响应消息,而响应消息本身也可能丢失或延迟。在异步网络中,这些问题是无法区分的:你拥有的唯一信息是你还没有收到响应。如果你向另一个节点发送请求但没有收到响应,**不可能** 判断原因。 -处理这个问题的常用方法是 *超时*:在一段时间后,你放弃等待并假设响应不会到达。然而,当超时发生时,你仍然不知道远程节点是否收到了你的请求(如果请求仍在某处排队,即使发送方已经放弃了它,它仍可能被交付给接收方)。 +处理这个问题的常用方法是 **超时**:在一段时间后,你放弃等待并假设响应不会到达。然而,当超时发生时,你仍然不知道远程节点是否收到了你的请求(如果请求仍在某处排队,即使发送方已经放弃了它,它仍可能被交付给接收方)。 ### TCP 的局限性 {#sec_distributed_tcp} -网络数据包有最大大小(通常为几千字节),但许多应用程序需要发送太大而无法装入一个数据包的消息(请求、响应)。这些应用程序最常使用 TCP(传输控制协议)来建立一个 *连接*,将大型数据流分解为单个数据包,并在接收端将它们重新组合起来。 +网络数据包有最大大小(通常为几千字节),但许多应用程序需要发送太大而无法装入一个数据包的消息(请求、响应)。这些应用程序最常使用 TCP(传输控制协议)来建立一个 **连接**,将大型数据流分解为单个数据包,并在接收端将它们重新组合起来。 -------- @@ -71,7 +71,7 @@ breadcrumbs: false -------- -TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检测并重传丢弃的数据包,检测重新排序的数据包并将它们恢复到正确的顺序,并使用简单的校验和检测数据包损坏。它还计算出可以发送数据的速度,以便尽快传输数据,但不会使网络或接收节点过载;这被称为 *拥塞控制*、*流量控制* 或 *背压* [^5]。 +TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检测并重传丢弃的数据包,检测重新排序的数据包并将它们恢复到正确的顺序,并使用简单的校验和检测数据包损坏。它还计算出可以发送数据的速度,以便尽快传输数据,但不会使网络或接收节点过载;这被称为 **拥塞控制**、**流量控制** 或 **背压** [^5]。 当你通过将数据写入套接字来 "发送" 一些数据时,它实际上不会立即发送,而只是放置在由操作系统管理的缓冲区中。当拥塞控制算法决定它有能力发送数据包时,它会从该缓冲区中获取下一个数据包的数据并将其传递给网络接口。数据包通过几个交换机和路由器,最终接收节点的操作系统将数据包的数据放置在接收缓冲区中并向发送方发送确认数据包。只有这样,接收操作系统才会通知应用程序有更多数据到达 [^6]。 @@ -88,7 +88,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 * 一项在中型数据中心的研究发现,每月约有 12 次网络故障,其中一半断开了单台机器,一半断开了整个机架 [^9]。 * 另一项研究测量了组件(如机架顶部交换机、汇聚交换机和负载均衡器)的故障率 [^10]。它发现,添加冗余网络设备并不能像你希望的那样减少故障,因为它不能防范人为错误(例如,配置错误的交换机),这是停机的主要原因。 * 广域光纤链路的中断被归咎于奶牛 [^11]、海狸 [^12] 和鲨鱼 [^13](尽管由于海底电缆屏蔽更好,鲨鱼咬伤已经变得更加罕见 [^14])。人类也有过错,无论是由于意外配置错误 [^15]、拾荒 [^16] 还是破坏 [^17]。 -* 在不同的云区域之间,已经观察到高百分位数下长达几 *分钟* 的往返时间 [^18]。即使在单个数据中心内,在网络拓扑重新配置期间(由交换机软件升级期间的问题触发),也可能发生超过一分钟的数据包延迟 [^19]。因此,我们必须假设消息可能被任意延迟。 +* 在不同的云区域之间,已经观察到高百分位数下长达几 **分钟** 的往返时间 [^18]。即使在单个数据中心内,在网络拓扑重新配置期间(由交换机软件升级期间的问题触发),也可能发生超过一分钟的数据包延迟 [^19]。因此,我们必须假设消息可能被任意延迟。 * 有时通信部分中断,这取决于你在和谁交谈:例如,A 和 B 可以通信,B 和 C 可以通信,但 A 和 C 不能 [^20] [^21]。其他令人惊讶的故障包括网络接口有时会丢弃所有入站数据包但成功发送出站数据包 [^22]:仅仅因为网络链路在一个方向上工作并不能保证它在相反方向上也工作。 * 即使是短暂的网络中断也可能产生比原始问题持续时间更长的影响 [^8] [^20] [^23]。 @@ -100,17 +100,17 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 -------- -即使网络故障在你的环境中很少见,故障 *可能* 发生的事实意味着你的软件需要能够处理它们。每当通过网络进行任何通信时,它都可能失败 —— 这是无法避免的。 +即使网络故障在你的环境中很少见,故障 **可能** 发生的事实意味着你的软件需要能够处理它们。每当通过网络进行任何通信时,它都可能失败 —— 这是无法避免的。 如果网络故障的错误处理没有定义和测试,可能会发生任意糟糕的事情:例如,集群可能会陷入死锁并永久无法提供请求,即使网络恢复 [^24],或者它甚至可能删除你的所有数据 [^25]。如果软件处于意料之外的情况,它可能会做任意意外的事情。 -处理网络故障不一定意味着 *容忍* 它们:如果你的网络通常相当可靠,一个有效的方法可能是在网络出现问题时简单地向用户显示错误消息。但是,你确实需要知道你的软件如何对网络问题做出反应,并确保系统可以从中恢复。故意触发网络问题并测试系统的响应可能是有意义的(这被称为 *故障注入*;见 ["故障注入"](#sec_fault_injection))。 +处理网络故障不一定意味着 **容忍** 它们:如果你的网络通常相当可靠,一个有效的方法可能是在网络出现问题时简单地向用户显示错误消息。但是,你确实需要知道你的软件如何对网络问题做出反应,并确保系统可以从中恢复。故意触发网络问题并测试系统的响应可能是有意义的(这被称为 **故障注入**;见 ["故障注入"](#sec_fault_injection))。 ### 检测故障 {#id307} 许多系统需要自动检测故障节点。例如: -* 负载均衡器需要停止向已死亡的节点发送请求(即,将其 *从轮询池中摘除*)。 +* 负载均衡器需要停止向已死亡的节点发送请求(即,将其 **从轮询池中摘除**)。 * 在具有单主复制的分布式数据库中,如果主节点失效,其中一个从节点需要被提升为新的主节点(见 ["处理节点中断"](/ch6#sec_replication_failover))。 不幸的是,网络的不确定性使得很难判断节点是否正常工作。在某些特定情况下,你可能会得到一些明确告诉你某事不工作的反馈: @@ -132,9 +132,9 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 当节点被宣布死亡时,其职责需要转移到其他节点,这会给其他节点和网络带来额外的负载。如果系统已经在高负载下挣扎,过早地宣布节点死亡可能会使问题变得更糟。特别是,可能发生的情况是,节点实际上并没有死亡,只是由于过载而响应缓慢;将其负载转移到其他节点可能会导致级联故障(在极端情况下,所有节点互相宣布对方死亡,一切都停止工作 —— 见 ["当过载系统无法恢复时"](/ch2#sidebar_metastable))。 -想象一个虚构的系统,其网络保证数据包的最大延迟 —— 每个数据包要么在某个时间 *d* 内交付,要么丢失,但交付从不会超过 *d*。此外,假设你可以保证未失效的节点总是在某个时间 *r* 内处理请求。在这种情况下,你可以保证每个成功的请求在时间 2*d* + *r* 内收到响应 —— 如果你在该时间内没有收到响应,你就知道网络或远程节点不工作。如果这是真的,2*d* + *r* 将是一个合理的超时时间。 +想象一个虚构的系统,其网络保证数据包的最大延迟 —— 每个数据包要么在某个时间 **d** 内交付,要么丢失,但交付从不会超过 **d**。此外,假设你可以保证未失效的节点总是在某个时间 **r** 内处理请求。在这种情况下,你可以保证每个成功的请求在时间 2**d** + **r** 内收到响应 —— 如果你在该时间内没有收到响应,你就知道网络或远程节点不工作。如果这是真的,2**d** + **r** 将是一个合理的超时时间。 -不幸的是,我们使用的大多数系统都没有这些保证:异步网络具有 *无界延迟*(即,它们尝试尽快交付数据包,但数据包到达所需的时间没有上限),大多数服务器实现无法保证它们可以在某个最大时间内处理请求(见 ["响应时间保证"](#sec_distributed_clocks_realtime))。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时很低,往返时间的瞬时峰值就足以使系统失去平衡。 +不幸的是,我们使用的大多数系统都没有这些保证:异步网络具有 **无界延迟**(即,它们尝试尽快交付数据包,但数据包到达所需的时间没有上限),大多数服务器实现无法保证它们可以在某个最大时间内处理请求(见 ["响应时间保证"](#sec_distributed_clocks_realtime))。对于故障检测,系统大部分时间快速运行是不够的:如果你的超时很低,往返时间的瞬时峰值就足以使系统失去平衡。 @@ -142,7 +142,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 开车时,道路网络上的行驶时间通常因交通拥堵而变化最大。同样,计算机网络上数据包延迟的可变性最常是由于排队 [^27]: -* 如果几个不同的节点同时尝试向同一目的地发送数据包,网络交换机必须将它们排队并逐个送入目标网络链路(如 [图 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为 *网络拥塞*)。如果有太多的传入数据以至于交换机队列满了,数据包将被丢弃,因此需要重新发送 —— 即使网络运行正常。 +* 如果几个不同的节点同时尝试向同一目的地发送数据包,网络交换机必须将它们排队并逐个送入目标网络链路(如 [图 9-2](#fig_distributed_switch_queueing) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为 **网络拥塞**)。如果有太多的传入数据以至于交换机队列满了,数据包将被丢弃,因此需要重新发送 —— 即使网络运行正常。 * 当数据包到达目标机器时,如果所有 CPU 核心当前都很忙,来自网络的传入请求会被操作系统排队,直到应用程序准备处理它。根据机器上的负载,这可能需要任意长的时间 [^28]。 * 在虚拟化环境中,正在运行的操作系统经常会暂停几十毫秒,而另一个虚拟机使用 CPU 核心。在此期间,VM 无法消耗来自网络的任何数据,因此传入数据由虚拟机监视器排队(缓冲)[^29],进一步增加了网络延迟的可变性。 * 如前所述,为了避免网络过载,TCP 限制发送数据的速率。这意味着在数据甚至进入网络之前,发送方就有额外的排队。 @@ -165,11 +165,11 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 所有这些因素都导致了网络延迟的可变性。当系统接近其最大容量时,排队延迟的范围特别大:具有充足备用容量的系统可以轻松排空队列,而在高度利用的系统中,长队列可以很快建立起来。 -在公共云和多租户数据中心中,资源在许多客户之间共享:网络链路和交换机,甚至每台机器的网络接口和 CPU(在虚拟机上运行时)都是共享的。处理大量数据可以使用网络链路的全部容量(*饱和* 它们)。由于你无法控制或了解其他客户对共享资源的使用情况,如果你附近的某人(*吵闹的邻居*)正在使用大量资源,网络延迟可能会高度可变 [^30] [^31]。 +在公共云和多租户数据中心中,资源在许多客户之间共享:网络链路和交换机,甚至每台机器的网络接口和 CPU(在虚拟机上运行时)都是共享的。处理大量数据可以使用网络链路的全部容量(**饱和** 它们)。由于你无法控制或了解其他客户对共享资源的使用情况,如果你附近的某人(**吵闹的邻居**)正在使用大量资源,网络延迟可能会高度可变 [^30] [^31]。 在这种环境中,你只能通过实验选择超时:在较长时间内和许多机器上测量网络往返时间的分布,以确定延迟的预期可变性。然后,考虑到你的应用程序的特征,你可以在故障检测延迟和过早超时风险之间确定适当的权衡。 -更好的是,系统可以持续测量响应时间及其可变性(*抖动*),并根据观察到的响应时间分布自动调整超时,而不是使用配置的常量超时。Phi 累积故障检测器 [^32](例如在 Akka 和 Cassandra 中使用 [^33])就是这样做的一种方法。TCP 重传超时也以类似的方式工作 [^5]。 +更好的是,系统可以持续测量响应时间及其可变性(**抖动**),并根据观察到的响应时间分布自动调整超时,而不是使用配置的常量超时。Phi 累积故障检测器 [^32](例如在 Akka 和 Cassandra 中使用 [^33])就是这样做的一种方法。TCP 重传超时也以类似的方式工作 [^5]。 ### 同步与异步网络 {#sec_distributed_sync_networks} @@ -177,9 +177,9 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 要回答这个问题,比较数据中心网络与传统的固定电话网络(非蜂窝、非 VoIP)很有趣,后者极其可靠:延迟的音频帧和掉线非常罕见。电话通话需要持续的低端到端延迟和足够的带宽来传输你声音的音频样本。在计算机网络中拥有类似的可靠性和可预测性不是很好吗? -当你通过电话网络拨打电话时,它会建立一个 *电路*:在两个呼叫者之间的整个路线上分配固定、有保证的带宽量。该电路一直保持到通话结束 [^34]。例如,ISDN 网络以每秒 4,000 帧的固定速率运行。建立呼叫时,它在每帧内(在每个方向上)分配 16 位空间。因此,在通话期间,每一方都保证能够每 250 微秒准确发送 16 位音频数据 [^35]。 +当你通过电话网络拨打电话时,它会建立一个 **电路**:在两个呼叫者之间的整个路线上分配固定、有保证的带宽量。该电路一直保持到通话结束 [^34]。例如,ISDN 网络以每秒 4,000 帧的固定速率运行。建立呼叫时,它在每帧内(在每个方向上)分配 16 位空间。因此,在通话期间,每一方都保证能够每 250 微秒准确发送 16 位音频数据 [^35]。 -这种网络是 *同步的*:即使数据通过几个路由器,它也不会遭受排队,因为呼叫的 16 位空间已经在网络的下一跳中预留了。由于没有排队,网络的最大端到端延迟是固定的。我们称之为 *有界延迟*。 +这种网络是 **同步的**:即使数据通过几个路由器,它也不会遭受排队,因为呼叫的 16 位空间已经在网络的下一跳中预留了。由于没有排队,网络的最大端到端延迟是固定的。我们称之为 **有界延迟**。 #### 我们不能简单地使网络延迟可预测吗? {#can-we-not-simply-make-network-delays-predictable} @@ -187,11 +187,11 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立有保证的最大往返时间。然而,它们不是:以太网和 IP 是分组交换协议,会遭受排队,因此在网络中有无界延迟。这些协议没有电路的概念。 -为什么数据中心网络和互联网使用分组交换?答案是它们针对 *突发流量* 进行了优化。电路适合音频或视频通话,需要在通话期间传输相当恒定的每秒位数。另一方面,请求网页、发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只希望它尽快完成。 +为什么数据中心网络和互联网使用分组交换?答案是它们针对 **突发流量** 进行了优化。电路适合音频或视频通话,需要在通话期间传输相当恒定的每秒位数。另一方面,请求网页、发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只希望它尽快完成。 如果你想通过电路传输文件,你必须猜测带宽分配。如果你猜得太低,传输会不必要地慢,使网络容量未被使用。如果你猜得太高,电路无法建立(因为如果无法保证其带宽分配,网络无法允许创建电路)。因此,使用电路进行突发数据传输会浪费网络容量并使传输不必要地缓慢。相比之下,TCP 动态调整数据传输速率以适应可用的网络容量。 -曾经有一些尝试构建既支持电路交换又支持分组交换的混合网络。*异步传输模式*(ATM)在 1980 年代是以太网的竞争对手,但除了电话网络核心交换机外,它没有获得太多采用。InfiniBand 有一些相似之处 [^36]:它在链路层实现端到端流量控制,减少了网络中排队的需要,尽管它仍然可能因链路拥塞而遭受延迟 [^37]。通过仔细使用 *服务质量*(QoS,数据包的优先级和调度)和 *准入控制*(对发送者的速率限制),可以在分组网络上模拟电路交换,或提供统计上有界的延迟 [^27] [^34]。新的网络算法,如低延迟、低损耗和可扩展吞吐量(L4S)试图在客户端和路由器级别缓解一些排队和拥塞控制问题。Linux 的流量控制器(TC)也允许应用程序为 QoS 目的重新优先排序数据包。 +曾经有一些尝试构建既支持电路交换又支持分组交换的混合网络。**异步传输模式**(ATM)在 1980 年代是以太网的竞争对手,但除了电话网络核心交换机外,它没有获得太多采用。InfiniBand 有一些相似之处 [^36]:它在链路层实现端到端流量控制,减少了网络中排队的需要,尽管它仍然可能因链路拥塞而遭受延迟 [^37]。通过仔细使用 **服务质量**(QoS,数据包的优先级和调度)和 **准入控制**(对发送者的速率限制),可以在分组网络上模拟电路交换,或提供统计上有界的延迟 [^27] [^34]。新的网络算法,如低延迟、低损耗和可扩展吞吐量(L4S)试图在客户端和路由器级别缓解一些排队和拥塞控制问题。Linux 的流量控制器(TC)也允许应用程序为 QoS 目的重新优先排序数据包。 -------- @@ -232,7 +232,7 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 7. 这个缓存条目何时过期? 8. 日志文件中此错误消息的时间戳是什么? -示例 1-4 测量 *持续时间*(例如,发送请求和接收响应之间的时间间隔),而示例 5-8 描述 *时间点*(在特定日期、特定时间发生的事件)。 +示例 1-4 测量 **持续时间**(例如,发送请求和接收响应之间的时间间隔),而示例 5-8 描述 **时间点**(在特定日期、特定时间发生的事件)。 在分布式系统中,时间是一件棘手的事情,因为通信不是瞬时的:消息从一台机器通过网络传输到另一台机器需要时间。接收消息的时间总是晚于发送消息的时间,但由于网络中的可变延迟,我们不知道晚了多少。当涉及多台机器时,这个事实有时会使确定事情发生的顺序变得困难。 @@ -240,11 +240,11 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 ### 单调时钟与日历时钟 {#sec_distributed_monotonic_timeofday} -现代计算机至少有两种不同类型的时钟:*日历时钟* 和 *单调时钟*。尽管它们都测量时间,但区分两者很重要,因为它们服务于不同的目的。 +现代计算机至少有两种不同类型的时钟:**日历时钟** 和 **单调时钟**。尽管它们都测量时间,但区分两者很重要,因为它们服务于不同的目的。 #### 日历时钟 {#time-of-day-clocks} -日历时钟做你直观期望时钟做的事情:它根据某个日历返回当前日期和时间(也称为 *墙上时钟时间*)。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 *纪元* 以来的秒数(或毫秒数):根据格里高利历,1970 年 1 月 1 日午夜 UTC,不计算闰秒。一些系统使用其他日期作为参考点。(尽管 Linux 时钟被称为 *实时*,但它与实时操作系统无关,如 ["响应时间保证"](#sec_distributed_clocks_realtime) 中所讨论的。) +日历时钟做你直观期望时钟做的事情:它根据某个日历返回当前日期和时间(也称为 **墙上时钟时间**)。例如,Linux 上的 `clock_gettime(CLOCK_REALTIME)` 和 Java 中的 `System.currentTimeMillis()` 返回自 **纪元** 以来的秒数(或毫秒数):根据格里高利历,1970 年 1 月 1 日午夜 UTC,不计算闰秒。一些系统使用其他日期作为参考点。(尽管 Linux 时钟被称为 **实时**,但它与实时操作系统无关,如 ["响应时间保证"](#sec_distributed_clocks_realtime) 中所讨论的。) 日历时钟通常与 NTP 同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳意思相同。然而,日历时钟也有各种奇怪之处,如下一节所述。特别是,如果本地时钟远远超前于 NTP 服务器,它可能会被强制重置并显示跳回到以前的时间点。这些跳跃,以及闰秒引起的类似跳跃,使日历时钟不适合测量经过的时间 [^40]。 @@ -254,11 +254,11 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 单调时钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:例如,Linux 上的 `clock_gettime(CLOCK_MONOTONIC)` 或 `clock_gettime(CLOCK_BOOTTIME)` [^42] 和 Java 中的 `System.nanoTime()` 是单调时钟。这个名字来源于它们保证始终向前移动的事实(而日历时钟可能会在时间上向后跳跃)。 -你可以在某个时间点检查单调时钟的值,做一些事情,然后在稍后的时间再次检查时钟。两个值之间的 *差值* 告诉你两次检查之间经过了多少时间 —— 更像秒表而不是挂钟。然而,时钟的 *绝对* 值是没有意义的:它可能是自计算机启动以来的纳秒数,或类似的任意值。特别是,比较来自两台不同计算机的单调时钟值是没有意义的,因为它们不代表同样的东西。 +你可以在某个时间点检查单调时钟的值,做一些事情,然后在稍后的时间再次检查时钟。两个值之间的 **差值** 告诉你两次检查之间经过了多少时间 —— 更像秒表而不是挂钟。然而,时钟的 **绝对** 值是没有意义的:它可能是自计算机启动以来的纳秒数,或类似的任意值。特别是,比较来自两台不同计算机的单调时钟值是没有意义的,因为它们不代表同样的东西。 在具有多个 CPU 插槽的服务器上,每个 CPU 可能有一个单独的计时器,它不一定与其他 CPU 同步 [^43]。操作系统会补偿任何差异,并尝试向应用程序线程呈现时钟的单调视图,即使它们被调度到不同的 CPU 上。然而,明智的做法是对这种单调性保证持保留态度 [^44]。 -如果 NTP 检测到计算机的本地石英晶体比 NTP 服务器运行得更快或更慢,它可能会调整单调时钟前进的频率(这被称为 *调整* 时钟)。默认情况下,NTP 允许时钟速率加速或减速高达 0.05%,但 NTP 不能导致单调时钟向前或向后跳跃。单调时钟的分辨率通常相当好:在大多数系统上,它们可以测量微秒或更短的时间间隔。 +如果 NTP 检测到计算机的本地石英晶体比 NTP 服务器运行得更快或更慢,它可能会调整单调时钟前进的频率(这被称为 **调整** 时钟)。默认情况下,NTP 允许时钟速率加速或减速高达 0.05%,但 NTP 不能导致单调时钟向前或向后跳跃。单调时钟的分辨率通常相当好:在大多数系统上,它们可以测量微秒或更短的时间间隔。 在分布式系统中,使用单调时钟测量经过的时间(例如,超时)通常是可以的,因为它不假设不同节点的时钟之间有任何同步,并且对测量的轻微不准确不敏感。 @@ -266,12 +266,12 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 单调时钟不需要同步,但日历时钟需要根据 NTP 服务器或其他外部时间源设置才能有用。不幸的是,我们让时钟显示正确时间的方法远不如你希望的那样可靠或准确 —— 硬件时钟和 NTP 可能是反复无常的野兽。仅举几个例子: -* 计算机中的石英时钟不是很准确:它会 *漂移*(比应该的运行得更快或更慢)。时钟漂移因机器的温度而异。Google 假设其服务器的时钟漂移高达 200 ppm(百万分之一)[^45],这相当于每 30 秒与服务器重新同步的时钟有 6 毫秒漂移,或每天重新同步一次的时钟有 17 秒漂移。即使一切正常工作,这种漂移也限制了你可以达到的最佳精度。 +* 计算机中的石英时钟不是很准确:它会 **漂移**(比应该的运行得更快或更慢)。时钟漂移因机器的温度而异。Google 假设其服务器的时钟漂移高达 200 ppm(百万分之一)[^45],这相当于每 30 秒与服务器重新同步的时钟有 6 毫秒漂移,或每天重新同步一次的时钟有 17 秒漂移。即使一切正常工作,这种漂移也限制了你可以达到的最佳精度。 * 如果计算机的时钟与 NTP 服务器相差太多,它可能会拒绝同步,或者本地时钟将被强制重置 [^39]。任何在重置前后观察时间的应用程序都可能看到时间倒退或突然向前跳跃。 * 如果节点意外地被防火墙与 NTP 服务器隔离,配置错误可能会在一段时间内未被注意到,在此期间漂移可能会累积成不同节点时钟之间的巨大差异。轶事证据表明,这在实践中确实会发生。 * NTP 同步只能与网络延迟一样好,因此当你在具有可变数据包延迟的拥塞网络上时,其准确性有限。一项实验表明,通过互联网同步时可以达到 35 毫秒的最小误差 [^46],尽管网络延迟的偶尔峰值会导致大约一秒的误差。根据配置,大的网络延迟可能导致 NTP 客户端完全放弃。 * 一些 NTP 服务器是错误的或配置错误的,报告的时间相差数小时 [^47] [^48]。NTP 客户端通过查询多个服务器并忽略异常值来减轻此类错误。尽管如此,将系统的正确性押注在互联网上陌生人告诉你的时间上还是有些令人担忧的。 -* 闰秒导致一分钟有 59 秒或 61 秒长,这会搞乱在设计时没有考虑闰秒的系统中的时序假设 [^49]。闰秒已经导致许多大型系统崩溃的事实 [^40] [^50] 表明,关于时钟的错误假设是多么容易潜入系统。处理闰秒的最佳方法可能是让 NTP 服务器 "撒谎",通过在一天的过程中逐渐执行闰秒调整(这被称为 *平滑*)[^51] [^52],尽管实际的 NTP 服务器行为在实践中有所不同 [^53]。从 2035 年起将不再使用闰秒,所以这个问题幸运地将会消失。 +* 闰秒导致一分钟有 59 秒或 61 秒长,这会搞乱在设计时没有考虑闰秒的系统中的时序假设 [^49]。闰秒已经导致许多大型系统崩溃的事实 [^40] [^50] 表明,关于时钟的错误假设是多么容易潜入系统。处理闰秒的最佳方法可能是让 NTP 服务器 "撒谎",通过在一天的过程中逐渐执行闰秒调整(这被称为 **平滑**)[^51] [^52],尽管实际的 NTP 服务器行为在实践中有所不同 [^53]。从 2035 年起将不再使用闰秒,所以这个问题幸运地将会消失。 * 在虚拟机中,硬件时钟是虚拟化的,这为需要准确计时的应用程序带来了额外的挑战 [^54]。当 CPU 核心在虚拟机之间共享时,每个 VM 在另一个 VM 运行时会暂停数十毫秒。从应用程序的角度来看,这种暂停表现为时钟突然向前跳跃 [^29]。如果 VM 暂停几秒钟,时钟可能会比实际时间落后几秒钟,但 NTP 可能会继续报告时钟几乎完全同步 [^55]。 * 如果你在不完全控制的设备上运行软件(例如,移动或嵌入式设备),你可能根本无法信任设备的硬件时钟。一些用户故意将他们的硬件时钟设置为不正确的日期和时间,例如在游戏中作弊 [^56]。因此,时钟可能被设置为遥远的过去或未来的时间。 @@ -293,28 +293,28 @@ TCP 通常被描述为提供 "可靠" 的交付,从某种意义上说,它检 让我们考虑一个特定的情况,其中依赖时钟是诱人但危险的:跨多个节点的事件排序 [^64]。例如,如果两个客户端写入分布式数据库,谁先到达?哪个写入是更新的? -[图 9-3](#fig_distributed_timestamps) 说明了在具有多主复制的数据库中日历时钟的危险使用(该示例类似于 [图 6-8](/ch6#fig_replication_causality))。客户端 A 在节点 1 上写入 *x* = 1;写入被复制到节点 3;客户端 B 在节点 3 上递增 *x*(我们现在有 *x* = 2);最后,两个写入都被复制到节点 2。 +[图 9-3](#fig_distributed_timestamps) 说明了在具有多主复制的数据库中日历时钟的危险使用(该示例类似于 [图 6-8](/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](#fig_distributed_timestamps) 中,当写入被复制到其他节点时,它会根据写入起源节点上的日历时钟标记时间戳。此示例中的时钟同步非常好:节点 1 和节点 3 之间的偏差小于 3 毫秒,这可能比你在实践中可以期望的要好。 -由于递增建立在 *x* = 1 的早期写入之上,我们可能期望 *x* = 2 的写入应该具有两者中更大的时间戳。不幸的是,[图 9-3](#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 秒。 -如 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中所讨论的,解决不同节点上并发写入值之间冲突的一种方法是 *最后写入胜利*(LWW),这意味着保留给定键的具有最大时间戳的写入,并丢弃所有具有较旧时间戳的写入。在 [图 9-3](#fig_distributed_timestamps) 的示例中,当节点 2 接收这两个事件时,它将错误地得出结论,认为 *x* = 1 是更新的值并丢弃写入 *x* = 2,因此递增丢失了。 +如 ["最后写入胜利(丢弃并发写入)"](/ch6#sec_replication_lww) 中所讨论的,解决不同节点上并发写入值之间冲突的一种方法是 **最后写入胜利**(LWW),这意味着保留给定键的具有最大时间戳的写入,并丢弃所有具有较旧时间戳的写入。在 [图 9-3](#fig_distributed_timestamps) 的示例中,当节点 2 接收这两个事件时,它将错误地得出结论,认为 **x** = 1 是更新的值并丢弃写入 **x** = 2,因此递增丢失了。 可以通过确保当值被覆盖时,新值总是具有比被覆盖值更高的时间戳来防止这个问题,即使该时间戳超前于写入者的本地时钟。然而,这会产生额外的读取成本来查找最大的现有时间戳。一些系统,包括 Cassandra 和 ScyllaDB,希望在单次往返中写入所有副本,因此它们只是使用客户端时钟的时间戳以及最后写入胜利策略 [^62]。这种方法有一些严重的问题: * 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖先前由具有快速时钟的节点写入的值,直到节点之间的时钟偏差时间过去 [^63] [^65]。这种情况可能导致任意数量的数据被静默丢弃,而不会向应用程序报告任何错误。 -* LWW 无法区分快速连续发生的顺序写入(在 [图 9-3](#fig_distributed_timestamps) 中,客户端 B 的递增肯定发生在客户端 A 的写入 *之后*)和真正并发的写入(两个写入者都不知道对方)。需要额外的因果关系跟踪机制,如版本向量,以防止违反因果关系(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。 +* LWW 无法区分快速连续发生的顺序写入(在 [图 9-3](#fig_distributed_timestamps) 中,客户端 B 的递增肯定发生在客户端 A 的写入 **之后**)和真正并发的写入(两个写入者都不知道对方)。需要额外的因果关系跟踪机制,如版本向量,以防止违反因果关系(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。 * 两个节点可能独立生成具有相同时间戳的写入,特别是当时钟只有毫秒分辨率时。需要额外的决胜值(可以简单地是一个大的随机数)来解决此类冲突,但这种方法也可能导致违反因果关系 [^62]。 因此,即使通过保留最 "新" 的值并丢弃其他值来解决冲突很诱人,但重要的是要意识到 "新" 的定义取决于本地日历时钟,它很可能是不正确的。即使使用紧密 NTP 同步的时钟,你也可能在时间戳 100 毫秒(根据发送者的时钟)发送数据包,并让它在时间戳 99 毫秒(根据接收者的时钟)到达 —— 因此看起来数据包在发送之前就到达了,这是不可能的。 NTP 同步能否足够准确以至于不会发生此类错误排序?可能不行,因为除了石英漂移等其他误差源之外,NTP 的同步精度本身受到网络往返时间的限制。要保证正确的排序,你需要时钟误差显著低于网络延迟,这是不可能的。 -所谓的 *逻辑时钟* [^66],基于递增计数器而不是振荡石英晶体,是排序事件的更安全替代方案(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。逻辑时钟不测量一天中的时间或经过的秒数,只测量事件的相对顺序(一个事件是在另一个事件之前还是之后发生)。相比之下,日历时钟和单调时钟测量实际经过的时间,也称为 *物理时钟*。我们将在 ["ID 生成器和逻辑时钟"](/ch10#sec_consistency_logical) 中更详细地研究逻辑时钟。 +所谓的 **逻辑时钟** [^66],基于递增计数器而不是振荡石英晶体,是排序事件的更安全替代方案(见 ["检测并发写入"](/ch6#sec_replication_concurrent))。逻辑时钟不测量一天中的时间或经过的秒数,只测量事件的相对顺序(一个事件是在另一个事件之前还是之后发生)。相比之下,日历时钟和单调时钟测量实际经过的时间,也称为 **物理时钟**。我们将在 ["ID 生成器和逻辑时钟"](/ch10#sec_consistency_logical) 中更详细地研究逻辑时钟。 #### 带置信区间的时钟读数 {#clock-readings-with-a-confidence-interval} @@ -326,11 +326,11 @@ NTP 同步能否足够准确以至于不会发生此类错误排序?可能不 不幸的是,大多数系统不暴露这种不确定性:例如,当你调用 `clock_gettime()` 时,返回值不会告诉你时间戳的预期误差,所以你不知道它的置信区间是五毫秒还是五年。 -有例外:Google Spanner 中的 *TrueTime* API [^45] 和亚马逊的 ClockBound 明确报告本地时钟的置信区间。当你询问当前时间时,你会得到两个值:`[earliest, latest]`,它们是 *最早可能* 和 *最晚可能* 的时间戳。基于其不确定性计算,时钟知道实际当前时间在该区间内的某处。区间的宽度取决于多种因素,包括本地石英时钟上次与更准确的时钟源同步以来已经过去了多长时间。 +有例外:Google Spanner 中的 **TrueTime** API [^45] 和亚马逊的 ClockBound 明确报告本地时钟的置信区间。当你询问当前时间时,你会得到两个值:`[earliest, latest]`,它们是 **最早可能** 和 **最晚可能** 的时间戳。基于其不确定性计算,时钟知道实际当前时间在该区间内的某处。区间的宽度取决于多种因素,包括本地石英时钟上次与更准确的时钟源同步以来已经过去了多长时间。 #### 用于全局快照的同步时钟 {#sec_distributed_spanner} -在 ["快照隔离和可重复读"](/ch8#sec_transactions_snapshot_isolation) 中,我们讨论了 *多版本并发控制*(MVCC),这是数据库中非常有用的功能,需要支持小型、快速的读写事务和大型、长时间运行的只读事务(例如,用于备份或分析)。它允许只读事务看到数据库的 *快照*,即特定时间点的一致状态,而不会锁定和干扰读写事务。 +在 ["快照隔离和可重复读"](/ch8#sec_transactions_snapshot_isolation) 中,我们讨论了 **多版本并发控制**(MVCC),这是数据库中非常有用的功能,需要支持小型、快速的读写事务和大型、长时间运行的只读事务(例如,用于备份或分析)。它允许只读事务看到数据库的 **快照**,即特定时间点的一致状态,而不会锁定和干扰读写事务。 通常,MVCC 需要单调递增的事务 ID。如果写入发生在快照之后(即,写入的事务 ID 大于快照),则该写入对快照事务不可见。在单节点数据库上,简单的计数器就足以生成事务 ID。 @@ -338,7 +338,7 @@ NTP 同步能否足够准确以至于不会发生此类错误排序?可能不 我们能否使用同步日历时钟的时间戳作为事务 ID?如果我们能够获得足够好的同步,它们将具有正确的属性:较晚的事务具有更高的时间戳。当然,问题是时钟精度的不确定性。 -Spanner 以这种方式跨数据中心实现快照隔离 [^68] [^69]。它使用 TrueTime API 报告的时钟置信区间,并基于以下观察:如果你有两个置信区间,每个都由最早和最晚可能的时间戳组成(*A* = [*A最早*, *A最晚*] 和 *B* = [*B最早*, *B最晚*]),并且这两个区间不重叠(即,*A最早* < *A最晚* < *B最早* < *B最晚*),那么 B 肯定发生在 A 之后 —— 毫无疑问。只有当区间重叠时,我们才不确定 A 和 B 发生的顺序。 +Spanner 以这种方式跨数据中心实现快照隔离 [^68] [^69]。它使用 TrueTime API 报告的时钟置信区间,并基于以下观察:如果你有两个置信区间,每个都由最早和最晚可能的时间戳组成(**A** = [**A最早**, **A最晚**] 和 **B** = [**B最早**, **B最晚**]),并且这两个区间不重叠(即,**A最早** < **A最晚** < **B最早** < **B最晚**),那么 B 肯定发生在 A 之后 —— 毫无疑问。只有当区间重叠时,我们才不确定 A 和 B 发生的顺序。 为了确保事务时间戳反映因果关系,Spanner 在提交读写事务之前故意等待置信区间的长度。通过这样做,它确保任何可能读取数据的事务都在足够晚的时间,因此它们的置信区间不会重叠。为了使等待时间尽可能短,Spanner 需要使时钟不确定性尽可能小;为此,Google 在每个数据中心部署 GPS 接收器或原子钟,使时钟能够同步到大约 7 毫秒以内 [^45]。 @@ -348,7 +348,7 @@ Spanner 以这种方式跨数据中心实现快照隔离 [^68] [^69]。它使用 让我们考虑分布式系统中危险使用时钟的另一个例子。假设你有一个每个分片都有单个主节点的数据库。只有主节点被允许接受写入。节点如何知道它仍然是主节点(它没有被其他节点宣布死亡),并且它可以安全地接受写入? -一种选择是让主节点从其他节点获取 *租约*,这类似于带有超时的锁 [^73]。任何时候只有一个节点可以持有租约 —— 因此,当节点获得租约时,它知道在租约到期之前的一段时间内它是主节点。为了保持主节点身份,节点必须在租约到期之前定期续订租约。如果节点失效,它会停止续订租约,因此另一个节点可以在租约到期时接管。 +一种选择是让主节点从其他节点获取 **租约**,这类似于带有超时的锁 [^73]。任何时候只有一个节点可以持有租约 —— 因此,当节点获得租约时,它知道在租约到期之前的一段时间内它是主节点。为了保持主节点身份,节点必须在租约到期之前定期续订租约。如果节点失效,它会停止续订租约,因此另一个节点可以在租约到期时接管。 你可以想象请求处理循环看起来像这样: @@ -376,15 +376,15 @@ while (true) { 假设线程可能暂停这么长时间是合理的吗?不幸的是,是的。有各种原因可能导致这种情况发生: * 线程访问共享资源(如锁或队列)时的争用可能导致线程花费大量时间等待。转移到具有更多 CPU 核心的机器可能会使此类问题变得更糟,并且争用问题可能难以诊断 [^74]。 -* 许多编程语言运行时(如 Java 虚拟机)有 *垃圾回收器*(GC),偶尔需要停止所有正在运行的线程。过去,这种 *"全局暂停" GC 暂停* 有时会持续几分钟 [^75]!使用现代 GC 算法,这不再是一个大问题,但 GC 暂停仍然可能很明显(见 ["限制垃圾回收的影响"](#sec_distributed_gc_impact))。 -* 在虚拟化环境中,虚拟机可以被 *挂起*(暂停所有进程的执行并将内存内容保存到磁盘)和 *恢复*(恢复内存内容并继续执行)。这种暂停可能发生在进程执行的任何时间,并且可能持续任意长的时间。这个功能有时用于虚拟机从一台主机到另一台主机的 *实时迁移*,无需重启,在这种情况下,暂停的长度取决于进程写入内存的速率 [^76]。 +* 许多编程语言运行时(如 Java 虚拟机)有 **垃圾回收器**(GC),偶尔需要停止所有正在运行的线程。过去,这种 **"全局暂停" GC 暂停** 有时会持续几分钟 [^75]!使用现代 GC 算法,这不再是一个大问题,但 GC 暂停仍然可能很明显(见 ["限制垃圾回收的影响"](#sec_distributed_gc_impact))。 +* 在虚拟化环境中,虚拟机可以被 **挂起**(暂停所有进程的执行并将内存内容保存到磁盘)和 **恢复**(恢复内存内容并继续执行)。这种暂停可能发生在进程执行的任何时间,并且可能持续任意长的时间。这个功能有时用于虚拟机从一台主机到另一台主机的 **实时迁移**,无需重启,在这种情况下,暂停的长度取决于进程写入内存的速率 [^76]。 * 在笔记本电脑和手机等终端用户设备上,执行也可能被任意挂起和恢复,例如,当用户合上笔记本电脑盖时。 -* 当操作系统上下文切换到另一个线程时,或者当虚拟机管理程序切换到不同的虚拟机时(在虚拟机中运行时),当前运行的线程可能在代码的任何任意点暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间称为 *窃取时间*。如果机器负载很重 —— 即,如果有长队列的线程等待运行 —— 暂停的线程可能需要一些时间才能再次运行。 +* 当操作系统上下文切换到另一个线程时,或者当虚拟机管理程序切换到不同的虚拟机时(在虚拟机中运行时),当前运行的线程可能在代码的任何任意点暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间称为 **窃取时间**。如果机器负载很重 —— 即,如果有长队列的线程等待运行 —— 暂停的线程可能需要一些时间才能再次运行。 * 如果应用程序执行同步磁盘访问,线程可能会暂停等待缓慢的磁盘 I/O 操作完成 [^77]。在许多语言中,磁盘访问可能会令人惊讶地发生,即使代码没有明确提到文件访问 —— 例如,Java 类加载器在首次使用时会延迟加载类文件,这可能发生在程序执行的任何时间。I/O 暂停和 GC 暂停甚至可能共谋结合它们的延迟 [^78]。如果磁盘实际上是网络文件系统或网络块设备(如亚马逊的 EBS),I/O 延迟还会受到网络延迟可变性的影响 [^31]。 -* 如果操作系统配置为允许 *交换到磁盘*(*分页*),简单的内存访问可能会导致页面错误,需要从磁盘加载页面到内存。线程在此缓慢的 I/O 操作进行时暂停。如果内存压力很高,这可能反过来需要将不同的页面交换到磁盘。在极端情况下,操作系统可能会花费大部分时间在内存中交换页面进出,而实际完成的工作很少(这被称为 *抖动*)。为了避免这个问题,服务器机器上通常禁用分页(如果你宁愿杀死进程以释放内存而不是冒抖动的风险)。 +* 如果操作系统配置为允许 **交换到磁盘**(**分页**),简单的内存访问可能会导致页面错误,需要从磁盘加载页面到内存。线程在此缓慢的 I/O 操作进行时暂停。如果内存压力很高,这可能反过来需要将不同的页面交换到磁盘。在极端情况下,操作系统可能会花费大部分时间在内存中交换页面进出,而实际完成的工作很少(这被称为 **抖动**)。为了避免这个问题,服务器机器上通常禁用分页(如果你宁愿杀死进程以释放内存而不是冒抖动的风险)。 * Unix 进程可以通过向其发送 `SIGSTOP` 信号来暂停,例如通过在 shell 中按 Ctrl-Z。此信号立即停止进程获取更多 CPU 周期,直到使用 `SIGCONT` 恢复它,此时它从停止的地方继续运行。即使你的环境通常不使用 `SIGSTOP`,它也可能被运维工程师意外发送。 -所有这些情况都可以在任何时候 *抢占* 正在运行的线程,并在稍后的某个时间恢复它,而线程甚至没有注意到。这个问题类似于在单台机器上使多线程代码线程安全:你不能对时序做任何假设,因为可能会发生任意的上下文切换和并行性。 +所有这些情况都可以在任何时候 **抢占** 正在运行的线程,并在稍后的某个时间恢复它,而线程甚至没有注意到。这个问题类似于在单台机器上使多线程代码线程安全:你不能对时序做任何假设,因为可能会发生任意的上下文切换和并行性。 在单台机器上编写多线程代码时,我们有相当好的工具来使其线程安全:互斥锁、信号量、原子计数器、无锁数据结构、阻塞队列等。不幸的是,这些工具不能直接转换到分布式系统,因为分布式系统没有共享内存 —— 只有通过不可靠网络发送的消息。 @@ -392,9 +392,9 @@ while (true) { #### 响应时间保证 {#sec_distributed_clocks_realtime} -在许多编程语言和操作系统中,如所讨论的,线程和进程可能会暂停无限长的时间。如果你足够努力,这些暂停的原因 *可以* 被消除。 +在许多编程语言和操作系统中,如所讨论的,线程和进程可能会暂停无限长的时间。如果你足够努力,这些暂停的原因 **可以** 被消除。 -某些软件在环境中运行,如果未能在指定时间内响应可能会造成严重损害:控制飞机、火箭、机器人、汽车和其他物理对象的计算机必须快速且可预测地响应其传感器输入。在这些系统中,有一个指定的 *截止时间*,软件必须在此之前响应;如果它没有达到截止时间,可能会导致整个系统的故障。这些被称为 *硬实时* 系统。 +某些软件在环境中运行,如果未能在指定时间内响应可能会造成严重损害:控制飞机、火箭、机器人、汽车和其他物理对象的计算机必须快速且可预测地响应其传感器输入。在这些系统中,有一个指定的 **截止时间**,软件必须在此之前响应;如果它没有达到截止时间,可能会导致整个系统的故障。这些被称为 **硬实时** 系统。 -------- @@ -405,7 +405,7 @@ while (true) { 例如,如果你的汽车的车载传感器检测到你当前正在经历碰撞,你不希望安全气囊的释放因为安全气囊释放系统中不合时宜的 GC 暂停而延迟。 -在系统中提供实时保证需要软件栈所有级别的支持:需要 *实时操作系统*(RTOS),它允许进程在指定的时间间隔内以有保证的 CPU 时间分配进行调度;库函数必须记录其最坏情况执行时间;动态内存分配可能受到限制或完全禁止(实时垃圾回收器存在,但应用程序仍必须确保它不会给 GC 太多工作);必须进行大量的测试和测量以确保满足保证。 +在系统中提供实时保证需要软件栈所有级别的支持:需要 **实时操作系统**(RTOS),它允许进程在指定的时间间隔内以有保证的 CPU 时间分配进行调度;库函数必须记录其最坏情况执行时间;动态内存分配可能受到限制或完全禁止(实时垃圾回收器存在,但应用程序仍必须确保它不会给 GC 太多工作);必须进行大量的测试和测量以确保满足保证。 所有这些都需要大量的额外工作,并严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,它们最常用于安全关键的嵌入式设备。此外,"实时" 不同于 "高性能" —— 事实上,实时系统可能具有较低的吞吐量,因为它们必须优先考虑及时响应高于一切(另见 ["延迟和资源利用率"](#sidebar_distributed_latency_utilization))。 @@ -429,11 +429,11 @@ while (true) { 到目前为止,在本章中,我们已经探讨了分布式系统与在单台计算机上运行的程序的不同之处:没有共享内存,只有通过不可靠的网络进行消息传递,具有可变延迟,系统可能会遭受部分失效、不可靠的时钟和处理暂停。 -如果你不习惯分布式系统,这些问题的后果会令人深感迷惑。网络中的节点不能 *确切地知道* 关于其他节点的任何事情 —— 它只能根据它接收(或未接收)的消息进行猜测。节点只能通过与另一个节点交换消息来了解它处于什么状态(它存储了什么数据,它是否正常运行等)。如果远程节点没有响应,就无法知道它处于什么状态,因为网络中的问题无法与节点的问题可靠地区分开来。 +如果你不习惯分布式系统,这些问题的后果会令人深感迷惑。网络中的节点不能 **确切地知道** 关于其他节点的任何事情 —— 它只能根据它接收(或未接收)的消息进行猜测。节点只能通过与另一个节点交换消息来了解它处于什么状态(它存储了什么数据,它是否正常运行等)。如果远程节点没有响应,就无法知道它处于什么状态,因为网络中的问题无法与节点的问题可靠地区分开来。 这些系统的讨论接近哲学:在我们的系统中,我们知道什么是真或假?如果感知和测量的机制不可靠,我们对这些知识有多确定 [^83]?软件系统是否应该遵守我们对物理世界的期望法则,如因果关系? -幸运的是,我们不需要走到弄清生命意义的程度。在分布式系统中,我们可以陈述我们对行为(*系统模型*)的假设,并以这样的方式设计实际系统,使其满足这些假设。算法可以被证明在某个系统模型内正确运行。这意味着即使底层系统模型提供的保证很少,也可以实现可靠的行为。 +幸运的是,我们不需要走到弄清生命意义的程度。在分布式系统中,我们可以陈述我们对行为(**系统模型**)的假设,并以这样的方式设计实际系统,使其满足这些假设。算法可以被证明在某个系统模型内正确运行。这意味着即使底层系统模型提供的保证很少,也可以实现可靠的行为。 然而,尽管可以在不可靠的系统模型中使软件表现良好,但这样做并不简单。在本章的其余部分,我们将进一步探讨分布式系统中知识和真相的概念,这将帮助我们思考我们可以做出的假设类型和我们可能希望提供的保证。在 [第 10 章](/ch10#ch_consistency) 中,我们将继续查看在特定假设下提供特定保证的分布式算法的一些示例。 @@ -445,11 +445,11 @@ while (true) { 作为第三种情况,想象一个节点暂停执行一分钟。在此期间,没有请求被处理,也没有响应被发送。其他节点等待、重试、变得不耐烦,最终宣布该节点死亡并将其装上灵车。最后,暂停结束,节点的线程继续运行,就好像什么都没发生过。其他节点惊讶地看到据称已死的节点突然从棺材里抬起头来,健康状况良好,开始愉快地与旁观者聊天。起初,暂停的节点甚至没有意识到整整一分钟已经过去,它被宣布死亡 —— 从它的角度来看,自从它上次与其他节点交谈以来,几乎没有时间过去。 -这些故事的寓意是,节点不一定能信任自己对情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能使系统陷入困境并无法恢复。相反,许多分布式算法依赖于 *仲裁*,即节点之间的投票(见 ["读写仲裁"](/ch6#sec_replication_quorum_condition)):决策需要来自几个节点的最少票数,以减少对任何一个特定节点的依赖。 +这些故事的寓意是,节点不一定能信任自己对情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能使系统陷入困境并无法恢复。相反,许多分布式算法依赖于 **仲裁**,即节点之间的投票(见 ["读写仲裁"](/ch6#sec_replication_quorum_condition)):决策需要来自几个节点的最少票数,以减少对任何一个特定节点的依赖。 这包括关于宣布节点死亡的决定。如果节点的仲裁宣布另一个节点死亡,那么它必须被认为是死亡的,即使该节点仍然感觉自己非常活着。个别节点必须遵守仲裁决定并退出。 -最常见的是,仲裁是超过半数节点的绝对多数(尽管其他类型的仲裁也是可能的)。多数仲裁允许系统在少数节点故障时继续工作(三个节点可以容忍一个故障节点;五个节点可以容忍两个故障节点)。然而,它仍然是安全的,因为系统中只能有一个多数 —— 不能同时有两个具有冲突决策的多数。当我们在 [第 10 章](/ch10#ch_consistency) 讨论 *共识算法* 时,我们将更详细地讨论仲裁的使用。 +最常见的是,仲裁是超过半数节点的绝对多数(尽管其他类型的仲裁也是可能的)。多数仲裁允许系统在少数节点故障时继续工作(三个节点可以容忍一个故障节点;五个节点可以容忍两个故障节点)。然而,它仍然是安全的,因为系统中只能有一个多数 —— 不能同时有两个具有冲突决策的多数。当我们在 [第 10 章](/ch10#ch_consistency) 讨论 **共识算法** 时,我们将更详细地讨论仲裁的使用。 ### 分布式锁和租约 {#sec_distributed_lock_fencing} @@ -477,16 +477,16 @@ while (true) { #### 隔离僵尸进程和延迟请求 {#sec_distributed_fencing_tokens} -术语 *僵尸* 有时用于描述尚未发现失去租约的前租约持有者,并且仍在充当当前租约持有者。由于我们不能完全排除僵尸,我们必须确保它们不能以脑裂的形式造成任何损害。这被称为 *隔离* 僵尸。 +术语 **僵尸** 有时用于描述尚未发现失去租约的前租约持有者,并且仍在充当当前租约持有者。由于我们不能完全排除僵尸,我们必须确保它们不能以脑裂的形式造成任何损害。这被称为 **隔离** 僵尸。 -一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM,甚至物理关闭机器 [^87]。这种方法被称为 *对端节点爆头*(STONITH)。不幸的是,它存在一些问题:它不能防范像 [图 9-5](#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。 +一些系统试图通过关闭僵尸来隔离它们,例如通过断开它们与网络的连接 [^9]、通过云提供商的管理界面关闭 VM,甚至物理关闭机器 [^87]。这种方法被称为 **对端节点爆头**(STONITH)。不幸的是,它存在一些问题:它不能防范像 [图 9-5](#fig_distributed_lease_delay) 中那样的大网络延迟;可能会发生所有节点相互关闭的情况 [^19];到检测到僵尸并关闭它时,可能已经太晚了,数据可能已经被损坏。 一个更强大的隔离解决方案,可以防范僵尸和延迟请求,如 [图 9-6](#fig_distributed_fencing) 所示。 {{< figure src="/fig/ddia_0906.png" id="fig_distributed_fencing" caption="图 9-6. 通过只允许按递增隔离令牌顺序写入来使存储访问安全。" class="w-full my-4" >}} -假设每次锁服务授予锁或租约时,它还返回一个 *隔离令牌*,这是一个每次授予锁时都会增加的数字(例如,由锁服务递增)。然后我们可以要求客户端每次向存储服务发送写请求时,都必须包含其当前的隔离令牌。 +假设每次锁服务授予锁或租约时,它还返回一个 **隔离令牌**,这是一个每次授予锁时都会增加的数字(例如,由锁服务递增)。然后我们可以要求客户端每次向存储服务发送写请求时,都必须包含其当前的隔离令牌。 -------- @@ -499,7 +499,7 @@ while (true) { 如果 ZooKeeper 是你的锁服务,你可以使用事务 ID `zxid` 或节点版本 `cversion` 作为隔离令牌 [^85]。使用 etcd,修订号与租约 ID 一起起着类似的作用 [^89]。Hazelcast 中的 FencedLock API 明确生成隔离令牌 [^90]。 -这种机制要求存储服务有某种方法来检查写入是否基于过时的令牌。或者,服务支持仅在对象自当前客户端上次读取以来未被另一个客户端写入时才成功的写入就足够了,类似于原子比较并设置(CAS)操作。例如,对象存储服务支持这种检查:Amazon S3 称之为 *条件写入*,Azure Blob Storage 称之为 *条件标头*,Google Cloud Storage 称之为 *请求前提条件*。 +这种机制要求存储服务有某种方法来检查写入是否基于过时的令牌。或者,服务支持仅在对象自当前客户端上次读取以来未被另一个客户端写入时才成功的写入就足够了,类似于原子比较并设置(CAS)操作。例如,对象存储服务支持这种检查:Amazon S3 称之为 **条件写入**,Azure Blob Storage 称之为 **条件标头**,Google Cloud Storage 称之为 **请求前提条件**。 #### 多副本隔离 {#fencing-with-multiple-replicas} @@ -518,11 +518,11 @@ while (true) { ### 拜占庭故障 {#sec_distributed_byzantine} -隔离令牌可以检测并阻止 *无意中* 出错的节点(例如,因为它尚未发现其租约已过期)。然而,如果节点故意想要破坏系统的保证,它可以通过发送带有虚假隔离令牌的消息轻松做到。 +隔离令牌可以检测并阻止 **无意中** 出错的节点(例如,因为它尚未发现其租约已过期)。然而,如果节点故意想要破坏系统的保证,它可以通过发送带有虚假隔离令牌的消息轻松做到。 -在本书中,我们假设节点是不可靠但诚实的:它们可能很慢或从不响应(由于故障),它们的状态可能已过时(由于 GC 暂停或网络延迟),但我们假设如果节点 *确实* 响应,它就是在说 "真话":据它所知,它正在按协议规则行事。 +在本书中,我们假设节点是不可靠但诚实的:它们可能很慢或从不响应(由于故障),它们的状态可能已过时(由于 GC 暂停或网络延迟),但我们假设如果节点 **确实** 响应,它就是在说 "真话":据它所知,它正在按协议规则行事。 -如果节点可能 "撒谎"(发送任意错误或损坏的响应)的风险存在,分布式系统问题会变得更加困难 —— 例如,它可能在同一次选举中投出多个相互矛盾的票。这种行为被称为 *拜占庭故障*,在这种不信任环境中达成共识的问题被称为 *拜占庭将军问题* [^94]。 +如果节点可能 "撒谎"(发送任意错误或损坏的响应)的风险存在,分布式系统问题会变得更加困难 —— 例如,它可能在同一次选举中投出多个相互矛盾的票。这种行为被称为 **拜占庭故障**,在这种不信任环境中达成共识的问题被称为 **拜占庭将军问题** [^94]。 > [!TIP] 拜占庭将军问题 > @@ -534,7 +534,7 @@ while (true) { -------- -如果即使某些节点发生故障并且不遵守协议,或者恶意攻击者干扰网络,系统仍能继续正确运行,则该系统是 *拜占庭容错* 的。这种担忧在某些特定情况下是相关的。例如: +如果即使某些节点发生故障并且不遵守协议,或者恶意攻击者干扰网络,系统仍能继续正确运行,则该系统是 **拜占庭容错** 的。这种担忧在某些特定情况下是相关的。例如: * 在航空航天环境中,计算机内存或 CPU 寄存器中的数据可能因辐射而损坏,导致它以任意不可预测的方式响应其他节点。由于系统故障的成本非常高昂(例如,飞机坠毁并杀死机上所有人,或火箭与国际空间站相撞),飞行控制系统必须容忍拜占庭故障 [^98] [^99]。 * 在有多个参与方的系统中,一些参与者可能试图欺骗或欺诈其他人。在这种情况下,节点简单地信任另一个节点的消息是不安全的,因为它们可能是恶意发送的。例如,比特币等加密货币和其他区块链可以被认为是让相互不信任的各方就交易是否发生达成一致的一种方式,而无需依赖中央权威 [^100]。 @@ -561,7 +561,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 许多算法被设计来解决分布式系统问题 —— 例如,我们将在 [第 10 章](/ch10#ch_consistency) 中研究共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。 -算法需要以不过度依赖于它们运行的硬件和软件配置细节的方式编写。这反过来又要求我们以某种方式形式化我们期望在系统中发生的故障类型。我们通过定义 *系统模型* 来做到这一点,这是一个描述算法可能假设什么事情的抽象。 +算法需要以不过度依赖于它们运行的硬件和软件配置细节的方式编写。这反过来又要求我们以某种方式形式化我们期望在系统中发生的故障类型。我们通过定义 **系统模型** 来做到这一点,这是一个描述算法可能假设什么事情的抽象。 关于时序假设,三种系统模型常用: @@ -569,7 +569,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 : 同步模型假设有界的网络延迟、有界的进程暂停和有界的时钟误差。这并不意味着精确同步的时钟或零网络延迟;它只是意味着你知道网络延迟、暂停和时钟漂移永远不会超过某个固定的上限 [^108]。同步模型不是大多数实际系统的现实模型,因为(如本章所讨论的)无界延迟和暂停确实会发生。 部分同步模型 -: 部分同步意味着系统 *大部分时间* 表现得像同步系统,但有时会超过网络延迟、进程暂停和时钟漂移的界限 [^108]。这是许多系统的现实模型:大部分时间,网络和进程表现相当良好 —— 否则我们永远无法完成任何事情 —— 但我们必须考虑到任何时序假设偶尔可能会被打破的事实。发生这种情况时,网络延迟、暂停和时钟误差可能会变得任意大。 +: 部分同步意味着系统 **大部分时间** 表现得像同步系统,但有时会超过网络延迟、进程暂停和时钟漂移的界限 [^108]。这是许多系统的现实模型:大部分时间,网络和进程表现相当良好 —— 否则我们永远无法完成任何事情 —— 但我们必须考虑到任何时序假设偶尔可能会被打破的事实。发生这种情况时,网络延迟、暂停和时钟误差可能会变得任意大。 异步模型 : 在这个模型中,算法不允许做出任何时序假设 —— 事实上,它甚至没有时钟(因此它不能使用超时)。一些算法可以为异步模型设计,但它非常有限。 @@ -577,13 +577,13 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 此外,除了时序问题,我们还必须考虑节点故障。节点的一些常见系统模型是: 崩溃停止故障 -: 在 *崩溃停止*(或 *故障停止*)模型中,算法可以假设节点只能以一种方式失效,即崩溃 [^109]。这意味着节点可能在任何时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。 +: 在 **崩溃停止**(或 **故障停止**)模型中,算法可以假设节点只能以一种方式失效,即崩溃 [^109]。这意味着节点可能在任何时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。 崩溃恢复故障 : 我们假设节点可能在任何时刻崩溃,并且可能在某个未知时间后再次开始响应。在崩溃恢复模型中,假设节点具有跨崩溃保留的稳定存储(即非易失性磁盘存储),而内存中的状态假设丢失。 性能下降和部分功能 -: 除了崩溃和重启之外,节点可能变慢:它们可能仍然能够响应健康检查请求,但速度太慢而无法完成任何实际工作。例如,千兆网络接口可能由于驱动程序错误突然降至 1 Kb/s 吞吐量 [^110];处于内存压力下的进程可能会花费大部分时间执行垃圾回收 [^111];磨损的 SSD 可能具有不稳定的性能;硬件可能受到高温、松动的连接器、机械振动、电源问题、固件错误等的影响 [^112]。这种情况被称为 *跛行节点*、*灰色故障* 或 *慢速故障* [^113],它可能比干净失效的节点更难处理。一个相关的问题是当进程停止执行它应该做的某些事情,而其他方面继续工作时,例如因为后台线程崩溃或死锁 [^114]。 +: 除了崩溃和重启之外,节点可能变慢:它们可能仍然能够响应健康检查请求,但速度太慢而无法完成任何实际工作。例如,千兆网络接口可能由于驱动程序错误突然降至 1 Kb/s 吞吐量 [^110];处于内存压力下的进程可能会花费大部分时间执行垃圾回收 [^111];磨损的 SSD 可能具有不稳定的性能;硬件可能受到高温、松动的连接器、机械振动、电源问题、固件错误等的影响 [^112]。这种情况被称为 **跛行节点**、**灰色故障** 或 **慢速故障** [^113],它可能比干净失效的节点更难处理。一个相关的问题是当进程停止执行它应该做的某些事情,而其他方面继续工作时,例如因为后台线程崩溃或死锁 [^114]。 拜占庭(任意)故障 : 节点可能做任何事情,包括试图欺骗和欺骗其他节点,如上一节所述。 @@ -592,7 +592,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 #### 定义算法的正确性 {#defining-the-correctness-of-an-algorithm} -为了定义算法 *正确* 的含义,我们可以描述它的 *属性*。例如,排序算法的输出具有这样的属性:对于输出列表的任何两个不同元素,左边的元素小于右边的元素。这只是定义列表排序含义的正式方式。 +为了定义算法 **正确** 的含义,我们可以描述它的 **属性**。例如,排序算法的输出具有这样的属性:对于输出列表的任何两个不同元素,左边的元素小于右边的元素。这只是定义列表排序含义的正式方式。 同样,我们可以写下我们希望分布式算法具有的属性,以定义正确的含义。例如,如果我们为锁生成隔离令牌(见 ["隔离僵尸进程和延迟请求"](#sec_distributed_fencing_tokens)),我们可能要求算法具有以下属性: @@ -600,7 +600,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 : 没有两个隔离令牌请求返回相同的值。 单调序列 -: 如果请求 *x* 返回令牌 *t**x*,请求 *y* 返回令牌 *t**y*,并且 *x* 在 *y* 开始之前完成,则 *t**x* < *t**y*。 +: 如果请求 **x** 返回令牌 **t**x**,请求 **y** 返回令牌 **t**y**,并且 **x** 在 **y** 开始之前完成,则 **t**x** < **t**y**。 可用性 : 请求隔离令牌且不崩溃的节点最终会收到响应。 @@ -609,16 +609,16 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 #### 安全性与活性 {#sec_distributed_safety_liveness} -为了澄清情况,值得区分两种不同类型的属性:*安全性* 和 *活性* 属性。在刚才给出的例子中,*唯一性* 和 *单调序列* 是安全属性,但 *可用性* 是活性属性。 +为了澄清情况,值得区分两种不同类型的属性:**安全性** 和 **活性** 属性。在刚才给出的例子中,**唯一性** 和 **单调序列** 是安全属性,但 **可用性** 是活性属性。 -什么区分这两种属性?一个迹象是活性属性通常在其定义中包含 "最终" 一词。(是的,你猜对了 —— *最终一致性* 是一个活性属性 [^115]。) +什么区分这两种属性?一个迹象是活性属性通常在其定义中包含 "最终" 一词。(是的,你猜对了 —— **最终一致性** 是一个活性属性 [^115]。) -安全性通常被非正式地定义为 *没有坏事发生*,活性被定义为 *好事最终会发生*。然而,最好不要过多地解读这些非正式定义,因为 "好" 和 "坏" 是价值判断,不能很好地应用于算法。安全性和活性的实际定义更精确 [^116]: +安全性通常被非正式地定义为 **没有坏事发生**,活性被定义为 **好事最终会发生**。然而,最好不要过多地解读这些非正式定义,因为 "好" 和 "坏" 是价值判断,不能很好地应用于算法。安全性和活性的实际定义更精确 [^116]: * 如果违反了安全属性,我们可以指出它被破坏的特定时间点(例如,如果违反了唯一性属性,我们可以识别返回重复隔离令牌的特定操作)。在违反安全属性之后,违规无法撤消 —— 损害已经造成。 * 活性属性以相反的方式工作:它可能在某个时间点不成立(例如,节点可能已发送请求但尚未收到响应),但总有希望它将来可能得到满足(即通过接收响应)。 -区分安全性和活性属性的一个优点是它有助于我们处理困难的系统模型。对于分布式算法,通常要求安全属性在系统模型的所有可能情况下 *始终* 成立 [^108]。也就是说,即使所有节点崩溃,或整个网络失效,算法也必须确保它不会返回错误的结果(即,安全属性保持满足)。 +区分安全性和活性属性的一个优点是它有助于我们处理困难的系统模型。对于分布式算法,通常要求安全属性在系统模型的所有可能情况下 **始终** 成立 [^108]。也就是说,即使所有节点崩溃,或整个网络失效,算法也必须确保它不会返回错误的结果(即,安全属性保持满足)。 然而,对于活性属性,我们可以做出警告:例如,我们可以说请求只有在大多数节点没有崩溃时才需要收到响应,并且只有在网络最终从中断中恢复时才需要响应。部分同步模型的定义要求系统最终返回到同步状态 —— 也就是说,任何网络中断期只持续有限的时间,然后被修复。 @@ -638,13 +638,13 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 我们如何知道算法满足所需的属性?由于并发性、部分失效和网络延迟,存在大量潜在状态。我们需要保证属性在每个可能的状态下都成立,并确保我们没有忘记任何边界情况。 -一种方法是通过数学描述算法来形式验证它,并使用证明技术来表明它在系统模型允许的所有情况下都满足所需的属性。证明算法正确并不意味着它在真实系统上的 *实现* 必然总是正确运行。但这是一个非常好的第一步,因为理论分析可以发现算法中的问题,这些问题可能在真实系统中长时间隐藏,并且只有当你的假设(例如,关于时序)由于不寻常的情况而失败时才会咬你一口。 +一种方法是通过数学描述算法来形式验证它,并使用证明技术来表明它在系统模型允许的所有情况下都满足所需的属性。证明算法正确并不意味着它在真实系统上的 **实现** 必然总是正确运行。但这是一个非常好的第一步,因为理论分析可以发现算法中的问题,这些问题可能在真实系统中长时间隐藏,并且只有当你的假设(例如,关于时序)由于不寻常的情况而失败时才会咬你一口。 将理论分析与经验测试相结合以验证实现按预期运行是明智的。基于属性的测试、模糊测试和确定性模拟测试(DST)等技术使用随机化来在各种情况下测试系统。亚马逊网络服务等公司已成功地在其许多产品上使用了这些技术的组合 [^120] [^121]。 #### 模型检查与规范语言 {#model-checking-and-specification-languages} -*模型检查器* 是帮助验证算法或系统按预期运行的工具。算法规范是用专门构建的语言编写的,如 TLA+、Gallina 或 FizzBee。这些语言使得更容易专注于算法的行为,而不必担心代码实现细节。然后,模型检查器使用这些模型通过系统地尝试所有可能发生的事情来验证不变量在算法的所有状态中都成立。 +**模型检查器** 是帮助验证算法或系统按预期运行的工具。算法规范是用专门构建的语言编写的,如 TLA+、Gallina 或 FizzBee。这些语言使得更容易专注于算法的行为,而不必担心代码实现细节。然后,模型检查器使用这些模型通过系统地尝试所有可能发生的事情来验证不变量在算法的所有状态中都成立。 模型检查实际上不能证明算法的不变量对每个可能的状态都成立,因为大多数现实世界的算法都有无限的状态空间。对所有状态的真正验证需要形式证明,这是可以做到的,但通常比运行模型检查器更困难。相反,模型检查器鼓励你将算法的模型减少到可以完全验证的近似值,或者将执行限制到某个上限(例如,通过设置可以发送的最大消息数)。任何只在更长执行时发生的错误将不会被发现。 @@ -656,7 +656,7 @@ Web 应用程序确实需要预期客户端在最终用户控制下的任意和 许多错误是在机器和网络故障发生时触发的。故障注入是一种有效(有时令人恐惧)的技术,用于验证系统的实现在出错时是否按预期工作。这个想法很简单:将故障注入到正在运行的系统环境中,看看它如何表现。故障可以是网络故障、机器崩溃、磁盘损坏、暂停的进程 —— 你能想象到的计算机出错的任何事情。 -故障注入测试通常在与系统将运行的生产环境非常相似的环境中运行。有些甚至直接将故障注入到他们的生产环境中。Netflix 通过他们的 Chaos Monkey 工具推广了这种方法 [^128]。生产故障注入通常被称为 *混沌工程*,我们在 ["可靠性与容错"](/ch2#sec_introduction_reliability) 中讨论过。 +故障注入测试通常在与系统将运行的生产环境非常相似的环境中运行。有些甚至直接将故障注入到他们的生产环境中。Netflix 通过他们的 Chaos Monkey 工具推广了这种方法 [^128]。生产故障注入通常被称为 **混沌工程**,我们在 ["可靠性与容错"](/ch2#sec_introduction_reliability) 中讨论过。 要运行故障注入测试,首先部署被测系统以及故障注入协调器和脚本。协调器负责决定执行什么故障以及何时执行它们。本地或远程脚本负责将故障注入到单个节点或进程中。注入脚本使用许多不同的工具来触发故障。可以使用 Linux 的 `kill` 命令暂停或杀死 Linux 进程,可以使用 `umount` 卸载磁盘,可以通过防火墙设置中断网络连接。你可以在注入故障期间和之后检查系统行为,以确保事情按预期工作。 @@ -689,7 +689,7 @@ DST 提供了超越可重放性的几个优势。Antithesis 等工具试图通 * 事件溯源的一个关键优势(见 ["事件溯源和 CQRS"](/ch3#sec_datamodels_events))是你可以确定性地重放事件日志以重建派生的物化视图。 * 工作流引擎(见 ["持久执行和工作流"](/ch5#sec_encoding_dataflow_workflows))依赖于工作流定义是确定性的,以提供持久执行语义。 -* *状态机复制*,我们将在 ["使用共享日志"](/ch10#sec_consistency_smr) 中讨论,通过在每个副本上独立执行相同的确定性事务序列来复制数据。我们已经看到了这个想法的两个变体:基于语句的复制(见 ["复制日志的实现"](/ch6#sec_replication_implementation))和使用存储过程的串行事务执行(见 ["存储过程的利弊"](/ch8#sec_transactions_stored_proc_tradeoffs))。 +* **状态机复制**,我们将在 ["使用共享日志"](/ch10#sec_consistency_smr) 中讨论,通过在每个副本上独立执行相同的确定性事务序列来复制数据。我们已经看到了这个想法的两个变体:基于语句的复制(见 ["复制日志的实现"](/ch6#sec_replication_implementation))和使用存储过程的串行事务执行(见 ["存储过程的利弊"](/ch8#sec_transactions_stored_proc_tradeoffs))。 然而,使代码完全确定性需要小心。即使你已经删除了所有并发性并用确定性模拟替换了 I/O、网络通信、时钟和随机数生成器,非确定性元素可能仍然存在。例如,在某些编程语言中,迭代哈希表元素的顺序可能是非确定性的。是否遇到资源限制(内存分配失败、堆栈溢出)也是非确定性的。 @@ -701,9 +701,9 @@ DST 提供了超越可重放性的几个优势。Antithesis 等工具试图通 * 节点的时钟可能与其他节点严重不同步(尽管你尽最大努力设置了 NTP),它可能会突然向前或向后跳跃,而依赖它是危险的,因为你很可能没有一个好的时钟置信区间度量。 * 进程可能在其执行的任何时刻暂停相当长的时间,被其他节点宣告死亡,然后再次恢复活动而没有意识到它曾暂停。 -这种 *部分失效* 可能发生的事实是分布式系统的决定性特征。每当软件尝试做任何涉及其他节点的事情时,都有可能偶尔失败、随机变慢或根本没有响应(并最终超时)。在分布式系统中,我们尝试将对部分失效的容忍构建到软件中,这样即使某些组成部分出现故障,整个系统也可以继续运行。 +这种 **部分失效** 可能发生的事实是分布式系统的决定性特征。每当软件尝试做任何涉及其他节点的事情时,都有可能偶尔失败、随机变慢或根本没有响应(并最终超时)。在分布式系统中,我们尝试将对部分失效的容忍构建到软件中,这样即使某些组成部分出现故障,整个系统也可以继续运行。 -要容忍故障,第一步是 *检测* 它们,但即使这样也很困难。大多数系统没有准确的机制来检测节点是否已失败,因此大多数分布式算法依赖超时来确定远程节点是否仍然可用。然而,超时无法区分网络和节点故障,可变的网络延迟有时会导致节点被错误地怀疑崩溃。处理跛行节点(limping nodes)更加困难,这些节点正在响应但速度太慢而无法做任何有用的事情。 +要容忍故障,第一步是 **检测** 它们,但即使这样也很困难。大多数系统没有准确的机制来检测节点是否已失败,因此大多数分布式算法依赖超时来确定远程节点是否仍然可用。然而,超时无法区分网络和节点故障,可变的网络延迟有时会导致节点被错误地怀疑崩溃。处理跛行节点(limping nodes)更加困难,这些节点正在响应但速度太慢而无法做任何有用的事情。 一旦检测到故障,让系统容忍它也不容易:没有全局变量、没有共享内存、没有公共知识或机器之间任何其他类型的共享状态 [^83]。节点甚至无法就现在是什么时间达成一致,更不用说任何更深刻的事情了。信息从一个节点流向另一个节点的唯一方式是通过不可靠的网络发送。单个节点无法安全地做出重大决策,因此我们需要协议来征求其他节点的帮助并尝试获得法定人数的同意。 From 0ba6234d3a5362d59580a24d49bb79dc828ba4e1 Mon Sep 17 00:00:00 2001 From: Alden Date: Fri, 19 Jun 2026 14:37:49 +0800 Subject: [PATCH 2/2] Normalize zh and tw front matter emphasis --- content/tw/colophon.md | 2 +- content/tw/glossary.md | 46 +++++++++++++++++++++--------------------- content/tw/preface.md | 6 +++--- content/zh/colophon.md | 2 +- content/zh/glossary.md | 46 +++++++++++++++++++++--------------------- content/zh/preface.md | 6 +++--- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/content/tw/colophon.md b/content/tw/colophon.md index 0aaf842..1b1dd81 100644 --- a/content/tw/colophon.md +++ b/content/tw/colophon.md @@ -12,7 +12,7 @@ breadcrumbs: false **Martin Kleppmann** 是英國劍橋大學副教授,教授分散式系統與密碼學協議。2017 年出版的《設計資料密集型應用》第一版確立了他在資料系統領域的權威地位;他在分散式系統方面的研究也推動了 local-first 軟體運動。此前他曾在 LinkedIn、Rapportive 等網際網路公司擔任軟體工程師和創業者,負責大規模資料基礎設施。 -**Chris Riccomini** 是軟體工程師、創業投資人和作者,擁有 15 年以上在 PayPal、LinkedIn、WePay 的工作經驗。他運營 Materialized View Capital,專注於基礎設施初創企業投資;同時也是 Apache Samza 與 SlateDB 的共同創造者,併合著了 *The Missing README: A Guide for the New Software Engineer*。 +**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) diff --git a/content/tw/glossary.md b/content/tw/glossary.md index 0e13de3..f979262 100644 --- a/content/tw/glossary.md +++ b/content/tw/glossary.md @@ -12,12 +12,12 @@ breadcrumbs: false ### 原子(atomic) -1. 在併發語境下:指一個操作看起來在某個單一時刻生效,其他併發程序不會看到它處於“半完成”狀態。另見 *isolation*。 +1. 在併發語境下:指一個操作看起來在某個單一時刻生效,其他併發程序不會看到它處於“半完成”狀態。另見 **isolation**。 2. 在事務語境下:指一組寫入要麼全部提交、要麼全部回滾,即使發生故障也不例外。參見“[原子性](/tw/ch8#sec_transactions_acid_atomicity)”和“[兩階段提交(2PC)](/tw/ch8#sec_transactions_2pc)”。 ### 背壓(backpressure) -當接收方跟不上時,強制傳送方降速。也稱為 *flow control*。參見“[系統過載後無法恢復時會發生什麼](/tw/ch2#sidebar_metastable)”。 +當接收方跟不上時,強制傳送方降速。也稱為 **flow control**。參見“[系統過載後無法恢復時會發生什麼](/tw/ch2#sidebar_metastable)”。 ### 批處理(batch process) @@ -69,7 +69,7 @@ breadcrumbs: false ### 分散式(distributed) -系統在多個透過網路連線的節點上執行。其典型特徵是 *部分失效*:一部分壞了,另一部分仍在工作,而軟體往往難以精確知道哪裡壞了。參見“[故障與部分失效](/tw/ch9#sec_distributed_partial_failure)”。 +系統在多個透過網路連線的節點上執行。其典型特徵是 **部分失效**:一部分壞了,另一部分仍在工作,而軟體往往難以精確知道哪裡壞了。參見“[故障與部分失效](/tw/ch9#sec_distributed_partial_failure)”。 ### 永續性(durable) @@ -89,23 +89,23 @@ Extract-Transform-Load(提取-轉換-載入):從源資料庫抽取資料 ### 流量控制(flow control) -見 *backpressure*。 +見 **backpressure**。 ### 追隨者(follower) -不直接接收客戶端寫入、僅應用來自主節點變更的副本。也稱 *secondary*、*read replica* 或 *hot standby*。參見“[單主複製](/tw/ch6#sec_replication_leader)”。 +不直接接收客戶端寫入、僅應用來自主節點變更的副本。也稱 **secondary**、**read replica** 或 **hot standby**。參見“[單主複製](/tw/ch6#sec_replication_leader)”。 ### 全文檢索(full-text search) -按任意關鍵詞搜尋文字,通常支援近似拼寫、同義詞等能力。全文索引是支援此類查詢的一種 *secondary index*。參見“[全文檢索](/tw/ch4#sec_storage_full_text)”。 +按任意關鍵詞搜尋文字,通常支援近似拼寫、同義詞等能力。全文索引是支援此類查詢的一種 **secondary index**。參見“[全文檢索](/tw/ch4#sec_storage_full_text)”。 ### 圖(graph) -由 *vertices*(可引用物件,也稱 *nodes* 或 *entities*)和 *edges*(頂點間連線,也稱 *relationships* 或 *arcs*)組成的資料結構。參見“[圖狀資料模型](/tw/ch3#sec_datamodels_graph)”。 +由 **vertices**(可引用物件,也稱 **nodes** 或 **entities**)和 **edges**(頂點間連線,也稱 **relationships** 或 **arcs**)組成的資料結構。參見“[圖狀資料模型](/tw/ch3#sec_datamodels_graph)”。 ### 雜湊(hash) -把輸入對映成看似隨機數字的函式。相同輸入總得相同輸出;不同輸入通常輸出不同,但也可能碰撞(*collision*)。參見“[按鍵的雜湊分片](/tw/ch7#sec_sharding_hash)”。 +把輸入對映成看似隨機數字的函式。相同輸入總得相同輸出;不同輸入通常輸出不同,但也可能碰撞(**collision**)。參見“[按鍵的雜湊分片](/tw/ch7#sec_sharding_hash)”。 ### 冪等(idempotent) @@ -117,7 +117,7 @@ Extract-Transform-Load(提取-轉換-載入):從源資料庫抽取資料 ### 隔離性(isolation) -在事務語境下,併發事務相互干擾的程度。*Serializable* 最強,也常用更弱隔離級別。參見“[隔離性](/tw/ch8#sec_transactions_acid_isolation)”。 +在事務語境下,併發事務相互干擾的程度。**Serializable** 最強,也常用更弱隔離級別。參見“[隔離性](/tw/ch8#sec_transactions_acid_isolation)”。 ### 連線(join) @@ -125,7 +125,7 @@ Extract-Transform-Load(提取-轉換-載入):從源資料庫抽取資料 ### 領導者(leader) -當資料或服務跨多個節點複製時,被指定為可接受寫入的副本。可透過協議選舉或管理員指定。也稱 *primary* 或 *source*。參見“[單主複製](/tw/ch6#sec_replication_leader)”。 +當資料或服務跨多個節點複製時,被指定為可接受寫入的副本。可透過協議選舉或管理員指定。也稱 **primary** 或 **source**。參見“[單主複製](/tw/ch6#sec_replication_leader)”。 ### 線性一致(linearizable) @@ -141,7 +141,7 @@ Extract-Transform-Load(提取-轉換-載入):從源資料庫抽取資料 ### 日誌(log) -只追加寫入的資料檔案。*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) ”)。 +只追加寫入的資料檔案。**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) ”)。 ### 物化(materialize) @@ -165,15 +165,15 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 分片(sharding) -把單機裝不下的大資料集或計算拆成更小部分並分散到多臺機器上。也稱 *partitioning*。參見[第 7 章](/tw/ch7#ch_sharding)。 +把單機裝不下的大資料集或計算拆成更小部分並分散到多臺機器上。也稱 **partitioning**。參見[第 7 章](/tw/ch7#ch_sharding)。 ### 百分位(percentile) -透過統計多少值高於/低於某閾值來描述分佈。例如某時段 95 分位響應時間為 *t*,表示 95% 請求耗時小於 *t*,5% 更長。參見“[描述效能](/tw/ch2#sec_introduction_percentiles)”。 +透過統計多少值高於/低於某閾值來描述分佈。例如某時段 95 分位響應時間為 **t**,表示 95% 請求耗時小於 **t**,5% 更長。參見“[描述效能](/tw/ch2#sec_introduction_percentiles)”。 ### 主鍵(primary key) -唯一標識一條記錄的值(通常為數字或字串)。在很多應用中由系統在建立時生成(順序或隨機),而非使用者手工指定。另見 *secondary index*。 +唯一標識一條記錄的值(通常為數字或字串)。在很多應用中由系統在建立時生成(順序或隨機),而非使用者手工指定。另見 **secondary index**。 ### 法定票數(quorum) @@ -185,7 +185,7 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 複製(replication) -在多個節點(*replicas*)上儲存同一份資料,以便部分節點不可達時仍可訪問。參見[第 6 章](/tw/ch6#ch_replication)。 +在多個節點(**replicas**)上儲存同一份資料,以便部分節點不可達時仍可訪問。參見[第 6 章](/tw/ch6#ch_replication)。 ### 模式(schema) @@ -197,7 +197,7 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 可序列化(serializable) -一種 *isolation* 保證:多個事務併發執行時,行為等價於某個序列順序逐個執行。參見“[可序列化](/tw/ch8#sec_transactions_serializability)”。 +一種 **isolation** 保證:多個事務併發執行時,行為等價於某個序列順序逐個執行。參見“[可序列化](/tw/ch8#sec_transactions_serializability)”。 ### 無共享(shared-nothing) @@ -205,7 +205,7 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 偏斜(skew) -1. 分片負載不均:某些分片請求/資料很多,另一些很少。也稱 *hot spots*。參見“[負載偏斜與熱點消除](/tw/ch7#sec_sharding_skew)”。 +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)”中的時鐘偏斜。 ### 腦裂(split brain) @@ -222,11 +222,11 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 同步(synchronous) -*asynchronous* 的反義詞。 +**asynchronous** 的反義詞。 ### 記錄系統(system of record) -持有某類資料主權威版本的系統,也稱 *source of truth*。資料變更首先寫入這裡,其他資料集可由其派生。參見“[記錄系統與派生資料](/tw/ch1#sec_introduction_derived)”。 +持有某類資料主權威版本的系統,也稱 **source of truth**。資料變更首先寫入這裡,其他資料集可由其派生。參見“[記錄系統與派生資料](/tw/ch1#sec_introduction_derived)”。 ### 超時(timeout) @@ -234,7 +234,7 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 全序(total order) -一種可比較關係(如時間戳),任意兩者都能判定大小。若存在不可比較元素,則稱 *partial order*(偏序)。 +一種可比較關係(如時間戳),任意兩者都能判定大小。若存在不可比較元素,則稱 **partial order**(偏序)。 ### 事務(transaction) @@ -242,12 +242,12 @@ Online Transaction Processing(線上事務處理):典型訪問模式是快 ### 兩階段提交(two-phase commit, 2PC) -保證多個數據庫節點對同一事務要麼都 *atomically* 提交、要麼都中止的演算法。參見“[兩階段提交(2PC)](/tw/ch8#sec_transactions_2pc)”。 +保證多個數據庫節點對同一事務要麼都 **atomically** 提交、要麼都中止的演算法。參見“[兩階段提交(2PC)](/tw/ch8#sec_transactions_2pc)”。 ### 兩階段鎖(two-phase locking, 2PL) -實現 *serializable isolation* 的演算法:事務對讀寫資料加鎖並持有到事務結束。參見“[兩階段鎖(2PL)](/tw/ch8#sec_transactions_2pl)”。 +實現 **serializable isolation** 的演算法:事務對讀寫資料加鎖並持有到事務結束。參見“[兩階段鎖(2PL)](/tw/ch8#sec_transactions_2pl)”。 ### 無界(unbounded) -沒有已知上限或大小。與 *bounded* 相反。 \ No newline at end of file +沒有已知上限或大小。與 **bounded** 相反。 \ No newline at end of file diff --git a/content/tw/preface.md b/content/tw/preface.md index 9c78737..6baed6b 100644 --- a/content/tw/preface.md +++ b/content/tw/preface.md @@ -100,11 +100,11 @@ Sebastopol, CA 95472 707-829-0515(國際或本地) 707-829-0104(傳真) -我們為本書提供了網頁,會在上面列出勘誤、示例以及任何補充資訊。你可以訪問:*http://bit.ly/designing-data-intensive-apps*。 +我們為本書提供了網頁,會在上面列出勘誤、示例以及任何補充資訊。你可以訪問:**http://bit.ly/designing-data-intensive-apps**。 -如需發表評論或提出技術問題,請傳送郵件至:*bookquestions@oreilly.com*。 +如需發表評論或提出技術問題,請傳送郵件至:**bookquestions@oreilly.com**。 -有關 O’Reilly 圖書、課程、會議和新聞的更多資訊,請訪問:*http://www.oreilly.com*。 +有關 O’Reilly 圖書、課程、會議和新聞的更多資訊,請訪問:**http://www.oreilly.com**。 * Facebook: [http://facebook.com/oreilly](http://facebook.com/oreilly) * Twitter: [http://twitter.com/oreillymedia](http://twitter.com/oreillymedia) diff --git a/content/zh/colophon.md b/content/zh/colophon.md index a6ee4c7..cf5b737 100644 --- a/content/zh/colophon.md +++ b/content/zh/colophon.md @@ -12,7 +12,7 @@ breadcrumbs: false **Martin Kleppmann** 是英国剑桥大学副教授,教授分布式系统与密码学协议。2017 年出版的《设计数据密集型应用》第一版确立了他在数据系统领域的权威地位;他在分布式系统方面的研究也推动了 local-first 软件运动。此前他曾在 LinkedIn、Rapportive 等互联网公司担任软件工程师和创业者,负责大规模数据基础设施。 -**Chris Riccomini** 是软件工程师、创业投资人和作者,拥有 15 年以上在 PayPal、LinkedIn、WePay 的工作经验。他运营 Materialized View Capital,专注于基础设施初创企业投资;同时也是 Apache Samza 与 SlateDB 的共同创造者,并合著了 *The Missing README: A Guide for the New Software Engineer*。 +**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) diff --git a/content/zh/glossary.md b/content/zh/glossary.md index 6da2d0c..a3a0afb 100644 --- a/content/zh/glossary.md +++ b/content/zh/glossary.md @@ -12,12 +12,12 @@ breadcrumbs: false ### 原子(atomic) -1. 在并发语境下:指一个操作看起来在某个单一时刻生效,其他并发进程不会看到它处于“半完成”状态。另见 *isolation*。 +1. 在并发语境下:指一个操作看起来在某个单一时刻生效,其他并发进程不会看到它处于“半完成”状态。另见 **isolation**。 2. 在事务语境下:指一组写入要么全部提交、要么全部回滚,即使发生故障也不例外。参见“[原子性](/ch8#sec_transactions_acid_atomicity)”和“[两阶段提交(2PC)](/ch8#sec_transactions_2pc)”。 ### 背压(backpressure) -当接收方跟不上时,强制发送方降速。也称为 *flow control*。参见“[系统过载后无法恢复时会发生什么](/ch2#sidebar_metastable)”。 +当接收方跟不上时,强制发送方降速。也称为 **flow control**。参见“[系统过载后无法恢复时会发生什么](/ch2#sidebar_metastable)”。 ### 批处理(batch process) @@ -69,7 +69,7 @@ breadcrumbs: false ### 分布式(distributed) -系统在多个通过网络连接的节点上运行。其典型特征是 *部分失效*:一部分坏了,另一部分仍在工作,而软件往往难以精确知道哪里坏了。参见“[故障与部分失效](/ch9#sec_distributed_partial_failure)”。 +系统在多个通过网络连接的节点上运行。其典型特征是 **部分失效**:一部分坏了,另一部分仍在工作,而软件往往难以精确知道哪里坏了。参见“[故障与部分失效](/ch9#sec_distributed_partial_failure)”。 ### 持久性(durable) @@ -89,23 +89,23 @@ Extract-Transform-Load(提取-转换-加载):从源数据库抽取数据 ### 流量控制(flow control) -见 *backpressure*。 +见 **backpressure**。 ### 追随者(follower) -不直接接收客户端写入、仅应用来自主节点变更的副本。也称 *secondary*、*read replica* 或 *hot standby*。参见“[单主复制](/ch6#sec_replication_leader)”。 +不直接接收客户端写入、仅应用来自主节点变更的副本。也称 **secondary**、**read replica** 或 **hot standby**。参见“[单主复制](/ch6#sec_replication_leader)”。 ### 全文检索(full-text search) -按任意关键词搜索文本,通常支持近似拼写、同义词等能力。全文索引是支持此类查询的一种 *secondary index*。参见“[全文检索](/ch4#sec_storage_full_text)”。 +按任意关键词搜索文本,通常支持近似拼写、同义词等能力。全文索引是支持此类查询的一种 **secondary index**。参见“[全文检索](/ch4#sec_storage_full_text)”。 ### 图(graph) -由 *vertices*(可引用对象,也称 *nodes* 或 *entities*)和 *edges*(顶点间连接,也称 *relationships* 或 *arcs*)组成的数据结构。参见“[图状数据模型](/ch3#sec_datamodels_graph)”。 +由 **vertices**(可引用对象,也称 **nodes** 或 **entities**)和 **edges**(顶点间连接,也称 **relationships** 或 **arcs**)组成的数据结构。参见“[图状数据模型](/ch3#sec_datamodels_graph)”。 ### 哈希(hash) -把输入映射成看似随机数字的函数。相同输入总得相同输出;不同输入通常输出不同,但也可能碰撞(*collision*)。参见“[按键的哈希分片](/ch7#sec_sharding_hash)”。 +把输入映射成看似随机数字的函数。相同输入总得相同输出;不同输入通常输出不同,但也可能碰撞(**collision**)。参见“[按键的哈希分片](/ch7#sec_sharding_hash)”。 ### 幂等(idempotent) @@ -117,7 +117,7 @@ Extract-Transform-Load(提取-转换-加载):从源数据库抽取数据 ### 隔离性(isolation) -在事务语境下,并发事务相互干扰的程度。*Serializable* 最强,也常用更弱隔离级别。参见“[隔离性](/ch8#sec_transactions_acid_isolation)”。 +在事务语境下,并发事务相互干扰的程度。**Serializable** 最强,也常用更弱隔离级别。参见“[隔离性](/ch8#sec_transactions_acid_isolation)”。 ### 连接(join) @@ -125,7 +125,7 @@ Extract-Transform-Load(提取-转换-加载):从源数据库抽取数据 ### 领导者(leader) -当数据或服务跨多个节点复制时,被指定为可接受写入的副本。可通过协议选举或管理员指定。也称 *primary* 或 *source*。参见“[单主复制](/ch6#sec_replication_leader)”。 +当数据或服务跨多个节点复制时,被指定为可接受写入的副本。可通过协议选举或管理员指定。也称 **primary** 或 **source**。参见“[单主复制](/ch6#sec_replication_leader)”。 ### 线性一致(linearizable) @@ -141,7 +141,7 @@ Extract-Transform-Load(提取-转换-加载):从源数据库抽取数据 ### 日志(log) -只追加写入的数据文件。*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) ”)。 +只追加写入的数据文件。**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) ”)。 ### 物化(materialize) @@ -165,15 +165,15 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 分片(sharding) -把单机装不下的大数据集或计算拆成更小部分并分散到多台机器上。也称 *partitioning*。参见[第 7 章](/ch7#ch_sharding)。 +把单机装不下的大数据集或计算拆成更小部分并分散到多台机器上。也称 **partitioning**。参见[第 7 章](/ch7#ch_sharding)。 ### 百分位(percentile) -通过统计多少值高于/低于某阈值来描述分布。例如某时段 95 分位响应时间为 *t*,表示 95% 请求耗时小于 *t*,5% 更长。参见“[描述性能](/ch2#sec_introduction_percentiles)”。 +通过统计多少值高于/低于某阈值来描述分布。例如某时段 95 分位响应时间为 **t**,表示 95% 请求耗时小于 **t**,5% 更长。参见“[描述性能](/ch2#sec_introduction_percentiles)”。 ### 主键(primary key) -唯一标识一条记录的值(通常为数字或字符串)。在很多应用中由系统在创建时生成(顺序或随机),而非用户手工指定。另见 *secondary index*。 +唯一标识一条记录的值(通常为数字或字符串)。在很多应用中由系统在创建时生成(顺序或随机),而非用户手工指定。另见 **secondary index**。 ### 法定票数(quorum) @@ -185,7 +185,7 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 复制(replication) -在多个节点(*replicas*)上保存同一份数据,以便部分节点不可达时仍可访问。参见[第 6 章](/ch6#ch_replication)。 +在多个节点(**replicas**)上保存同一份数据,以便部分节点不可达时仍可访问。参见[第 6 章](/ch6#ch_replication)。 ### 模式(schema) @@ -197,7 +197,7 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 可串行化(serializable) -一种 *isolation* 保证:多个事务并发执行时,行为等价于某个串行顺序逐个执行。参见“[可串行化](/ch8#sec_transactions_serializability)”。 +一种 **isolation** 保证:多个事务并发执行时,行为等价于某个串行顺序逐个执行。参见“[可串行化](/ch8#sec_transactions_serializability)”。 ### 无共享(shared-nothing) @@ -205,7 +205,7 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 偏斜(skew) -1. 分片负载不均:某些分片请求/数据很多,另一些很少。也称 *hot spots*。参见“[负载偏斜与热点消除](/ch7#sec_sharding_skew)”。 +1. 分片负载不均:某些分片请求/数据很多,另一些很少。也称 **hot spots**。参见“[负载偏斜与热点消除](/ch7#sec_sharding_skew)”。 2. 一种时序异常,导致事件呈现为非预期的非顺序。参见“[快照隔离与可重复读](/ch8#sec_transactions_snapshot_isolation)”中的读偏斜、“[写偏斜与幻读](/ch8#sec_transactions_write_skew)”中的写偏斜、以及“[用于事件排序的时间戳](/ch9#sec_distributed_lww)”中的时钟偏斜。 ### 脑裂(split brain) @@ -222,11 +222,11 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 同步(synchronous) -*asynchronous* 的反义词。 +**asynchronous** 的反义词。 ### 记录系统(system of record) -持有某类数据主权威版本的系统,也称 *source of truth*。数据变更首先写入这里,其他数据集可由其派生。参见“[记录系统与派生数据](/ch1#sec_introduction_derived)”。 +持有某类数据主权威版本的系统,也称 **source of truth**。数据变更首先写入这里,其他数据集可由其派生。参见“[记录系统与派生数据](/ch1#sec_introduction_derived)”。 ### 超时(timeout) @@ -234,7 +234,7 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 全序(total order) -一种可比较关系(如时间戳),任意两者都能判定大小。若存在不可比较元素,则称 *partial order*(偏序)。 +一种可比较关系(如时间戳),任意两者都能判定大小。若存在不可比较元素,则称 **partial order**(偏序)。 ### 事务(transaction) @@ -242,12 +242,12 @@ Online Transaction Processing(在线事务处理):典型访问模式是快 ### 两阶段提交(two-phase commit, 2PC) -保证多个数据库节点对同一事务要么都 *atomically* 提交、要么都中止的算法。参见“[两阶段提交(2PC)](/ch8#sec_transactions_2pc)”。 +保证多个数据库节点对同一事务要么都 **atomically** 提交、要么都中止的算法。参见“[两阶段提交(2PC)](/ch8#sec_transactions_2pc)”。 ### 两阶段锁(two-phase locking, 2PL) -实现 *serializable isolation* 的算法:事务对读写数据加锁并持有到事务结束。参见“[两阶段锁(2PL)](/ch8#sec_transactions_2pl)”。 +实现 **serializable isolation** 的算法:事务对读写数据加锁并持有到事务结束。参见“[两阶段锁(2PL)](/ch8#sec_transactions_2pl)”。 ### 无界(unbounded) -没有已知上限或大小。与 *bounded* 相反。 +没有已知上限或大小。与 **bounded** 相反。 diff --git a/content/zh/preface.md b/content/zh/preface.md index 5c98fe8..fadd5e4 100644 --- a/content/zh/preface.md +++ b/content/zh/preface.md @@ -100,11 +100,11 @@ Sebastopol, CA 95472 707-829-0515(国际或本地) 707-829-0104(传真) -我们为本书提供了网页,会在上面列出勘误、示例以及任何补充信息。你可以访问:*http://bit.ly/designing-data-intensive-apps*。 +我们为本书提供了网页,会在上面列出勘误、示例以及任何补充信息。你可以访问:**http://bit.ly/designing-data-intensive-apps**。 -如需发表评论或提出技术问题,请发送邮件至:*bookquestions@oreilly.com*。 +如需发表评论或提出技术问题,请发送邮件至:**bookquestions@oreilly.com**。 -有关 O’Reilly 图书、课程、会议和新闻的更多信息,请访问:*http://www.oreilly.com*。 +有关 O’Reilly 图书、课程、会议和新闻的更多信息,请访问:**http://www.oreilly.com**。 * Facebook: [http://facebook.com/oreilly](http://facebook.com/oreilly) * Twitter: [http://twitter.com/oreillymedia](http://twitter.com/oreillymedia)