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

add zh-tw update

This commit is contained in:
Feng Ruohang 2026-02-15 13:32:02 +08:00
parent b1e36fa95b
commit 52f5581c80
20 changed files with 1978 additions and 2082 deletions

View file

@ -611,7 +611,7 @@ automatically adds its ID to the list of IDs for the index entry `color:red`. As
{{< figure src="/fig/ddia_0709.png" id="fig_sharding_local_secondary" caption="Figure 7-9. Local secondary indexes: each shard indexes only the records within its own shard." class="w-full my-4" >}}
> [!WARN] WARNING
> [!WARNING] WARNING
If your database only supports a key-value model, you might be tempted to implement a secondary
index yourself by creating a mapping from values to IDs in application code. If you go down this

View file

@ -13,7 +13,7 @@ breadcrumbs: false
PostgreSQL 專家,資料庫老司機,雲計算泥石流。
[**Pigsty**](https://pgsty.com) 作者與創始人。
架構師DBA全棧工程師 @ TanTanAlibabaApple。
獨立開源貢獻者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[國區活躍 Top20](https://committers.top/china)。
獨立開源貢獻者,[GitStar Ranking 600](https://gitstar-ranking.com/Vonng)[國區活躍 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版譯者,公眾號:《老馮雲數》,資料庫 KOL。
**校訂** [@yingang](https://github.com/yingang) [繁體中文](/tw) **版本維護** by [@afunTW](https://github.com/afunTW) [完整貢獻者列表](/contrib)
@ -76,10 +76,12 @@ PostgreSQL 專家,資料庫老司機,雲計算泥石流。
### [第三部分:派生資料](/tw/part-iii)
- [11. 批處理](/tw/ch11) (尚未釋出)
- [12. 流處理](/tw/ch12) (尚未釋出)
- [13. 做正確的事](/tw/ch13) (尚未釋出)
- [11. 批處理](/tw/ch11)
- [12. 流處理](/tw/ch12)
- [13. 流式系統的哲學](/tw/ch13)
- [14. 將事情做正確](/ch14)
- [術語表](/tw/glossary)
- [索引](/index)
- [後記](/tw/colophon)
@ -112,6 +114,22 @@ PostgreSQL 專家,資料庫老司機,雲計算泥石流。
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [386](https://github.com/Vonng/ddia/pull/386) | [@uncle-lv](https://github.com/uncle-lv) | ch2: 最佳化一處翻譯 |
| [384](https://github.com/Vonng/ddia/pull/384) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 最佳化中文文件的措辭和表達 |
| [383](https://github.com/Vonng/ddia/pull/383) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 修正 ch4 中的術語和表達錯誤 |
| [382](https://github.com/Vonng/ddia/pull/382) | [@uncle-lv](https://github.com/uncle-lv) | ch1: 最佳化一處翻譯 |
| [381](https://github.com/Vonng/ddia/pull/381) | [@Max-Tortoise](https://github.com/Max-Tortoise) | ch4: 修正一處術語不完整問題 |
| [377](https://github.com/Vonng/ddia/pull/377) | [@huang06](https://github.com/huang06) | 最佳化翻譯術語 |
| [375](https://github.com/Vonng/ddia/issues/375) | [@z-soulx](https://github.com/z-soulx) | 對於是否100%全中文翻譯的必要性討論?個人-沒必要100%特別是“名詞”有原單詞更加適合it人員 |
| [371](https://github.com/Vonng/ddia/pull/371) | [@lewiszlw](https://github.com/lewiszlw) | CPU core -> CPU 核心 |
| [369](https://github.com/Vonng/ddia/pull/369) | [@bbwang-gl](https://github.com/bbwang-gl) | ch7: 可序列化快照隔離檢測一個事務何時修改另一個事務的讀取 |
| [368](https://github.com/Vonng/ddia/pull/368) | [@yhao3](https://github.com/yhao3) | 更新 zh-tw.py 與 zh-tw 內容 |
| [367](https://github.com/Vonng/ddia/pull/367) | [@yhao3](https://github.com/yhao3) | 修正拼寫、格式和標點問題 |
| [366](https://github.com/Vonng/ddia/pull/366) | [@yangshangde](https://github.com/yangshangde) | ch8: 將“電源失敗”改為“電源失效” |
| [365](https://github.com/Vonng/ddia/pull/365) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化“儲存與計算分離”相關翻譯 |
| [364](https://github.com/Vonng/ddia/issues/364) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化“儲存與計算分離”相關翻譯 |
| [363](https://github.com/Vonng/ddia/pull/363) | [@xyohn](https://github.com/xyohn) | #362: 最佳化一處翻譯 |
| [362](https://github.com/Vonng/ddia/issues/362) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化一處翻譯 |
| [359](https://github.com/Vonng/ddia/pull/359) | [@c25423](https://github.com/c25423) | ch10: 修正一處拼寫錯誤 |
| [358](https://github.com/Vonng/ddia/pull/358) | [@lewiszlw](https://github.com/lewiszlw) | ch4: 修正一處拼寫錯誤 |
| [356](https://github.com/Vonng/ddia/pull/356) | [@lewiszlw](https://github.com/lewiszlw) | ch2: 修正一處標點錯誤 |

View file

@ -4,23 +4,25 @@ weight: 101
breadcrumbs: false
---
<a id="ch_tradeoffs"></a>
> *沒有完美的解決方案,只有權衡取捨。[…] 你能做的就是努力獲得最佳的權衡,這就是你所能期望的一切。*
>
> [Thomas Sowell](https://www.youtube.com/watch?v=2YUtKr8-_Fg),接受 Fred Barnes 採訪2005
> [!TIP] 早期讀者注意事項
> 透過早期釋出電子書,您可以在書籍最早期的形式中獲得內容——作者在撰寫時的原始和未經編輯的內容——以便您可以在這些技術正式釋出之前就充分利用它們
> 透過 Early Release 電子書,你可以在最早階段讀到作者寫作中的原始、未編輯內容,從而在正式版釋出前儘早使用這些技術
>
> 這將是最終書籍的第 1 章。本書的 GitHub 倉庫是 https://github.com/ept/ddia2-feedback。
> 如果您想積極參與審閱和評論本草稿,請在 GitHub 上聯絡我們
> 如果你希望積極參與本草稿的審閱與評論,請在 GitHub 上聯絡
資料是當今許多應用程式開發的核心。隨著 Web 和移動應用、軟體即服務SaaS以及雲服務的興起將來自不同使用者的資料儲存在共享的基於伺服器的資料基礎設施中已成為常態。來自使用者活動、業務交易、裝置和感測器的資料需要被儲存並可供分析使用。當用戶與應用程式互動時他們既讀取已儲存的資料也生成更多的資料。
資料是當今應用開發的核心。隨著 Web 與移動應用、軟體即服務SaaS和雲服務普及把許多不同使用者的資料存放在共享的伺服器端資料基礎設施中已經成為常態。來自使用者行為、業務交易、裝置與感測器的資料需要被儲存並可用於分析。使用者每次與應用互動既會讀取已有資料也會產生新資料。
少量的資料可以儲存和處理在單臺機器上,通常相當容易處理。然而,隨著資料量或查詢速率的增長,資料需要分佈在多臺機器上,這帶來了許多挑戰。隨著應用程式需求變得更加複雜,將所有內容儲存在一個系統中已經不夠,可能需要組合多個提供不同能力的儲存或處理系統。
當資料量較小、可在單機儲存和處理時,問題往往並不複雜。但隨著資料規模或查詢速率增長,資料必須分佈到多臺機器上,挑戰隨之而來。隨著需求變得更複雜,僅靠單一系統通常已不足夠,你可能需要組合多個具備不同能力的儲存與處理系統。
如果資料管理是開發應用程式的主要挑戰之一,我們就稱應用程式為 **資料密集型data-intensive** 的 [^1]。雖然在 **計算密集型compute-intensive** 系統中,挑戰是並行化某些非常大規模的計算,但在資料密集型應用中,我們通常更關心諸如儲存和處理大量資料、管理資料變更、在面對故障和併發時確保一致性,以及確保服務高可用等問題
如果“管理資料”是開發過程中的主要挑戰之一,我們稱這樣的應用為 **資料密集型data-intensive** 應用 [^1]。與之對照,在 **計算密集型compute-intensive** 系統中,難點是並行化超大規模計算;而在資料密集型應用中,我們更常關心的是:如何儲存與處理海量資料、如何管理資料變化、如何在故障與併發下保持一致性,以及如何讓服務保持高可用
些應用程式通常由提供常用功能的標準構建塊構建而成。例如,許多應用程式需要:
類應用通常由若干標準構件搭建而成,每個構件負責一種常見能力。例如,很多應用都需要:
* 儲存資料,以便它們或其他應用程式以後能再次找到(**資料庫**
* 記住昂貴操作的結果,以加快讀取速度(**快取**
@ -28,15 +30,15 @@ breadcrumbs: false
* 一旦事件和資料變更發生就立即處理(**流處理**
* 定期處理累積的大量資料(**批處理**
在構建應用程式時,我們通常會採用幾個軟體系統或服務,例如資料庫或 API並用一些應用程式程式碼將它們粘合在一起。如果你正在做資料系統設計的工作那麼這個過程可能會相當容易
在構建應用時,我們通常會選擇若干軟體系統或服務(例如資料庫或 API再用應用程式碼把它們拼接起來。如果你的需求恰好落在這些系統的設計邊界內這並不困難
然而,隨著你的應用程式變得更加雄心勃勃,挑戰就會出現。有許多具有不同特性的資料庫系統,適合不同的目的——你如何選擇使用哪一個?有各種快取方法、構建搜尋索引的幾種方式等等——你如何在它們之間進行權衡?你需要找出哪些工具和哪些方法最適合手頭的任務,當你需要做單個工具無法單獨完成的事情時,組合工具可能會很困難
但當應用目標更有野心時,問題就會出現。資料庫有很多種,各自特性不同、適用場景也不同,如何選型?快取有多種做法,搜尋索引也有多種構建方式,如何權衡?當單個工具無法獨立完成目標時,如何把多個工具可靠地組合起來?這些都並不簡單
本書是一個指南,幫助你決定使用哪些技術以及如何組合它們。正如你將看到的,沒有一種方法從根本上優於其他方法;一切都有利弊。透過本書,你將學會提出正確的問題來評估和比較資料系統,以便你能找出哪種方法最能滿足你特定應用程式的需求
本書正是用來幫助你做這類決策:該用什麼技術、怎樣組合技術。你會看到,沒有哪種方案在根本上永遠優於另一種;每種方案都有得失。透過本書,你將學會提出正確問題來評估和比較資料系統,從而為你的具體應用找到更合適的方案
我們將透過觀察當今組織中資料的一些典型使用方式來開始我們的旅程。這裡的許多想法起源於 **企業軟體**(即大型組織的軟體需求和工程實踐,大型組織包括大公司和政府等),因為歷史上只有大型組織擁有需要複雜技術解決方案的大資料量。如果你的資料量足夠小,你可以簡單地將其儲存在電子表格中!然而,最近小公司和初創公司管理大資料量並構建資料密集型系統也變得很常見。
我們將從今天組織內資料的典型使用方式開始。這些思想很多源自 **企業軟體**(即大型組織的軟體需求與工程實踐,例如大公司和政府機構),因為在歷史上,只有這類組織才有足夠大的資料規模,值得投入複雜技術方案。如果你的資料足夠小,電子表格都可能夠用;但近些年,小公司和初創團隊構建資料密集型系統也越來越常見。
資料系統的關鍵挑戰之一是不同的人需要用資料做非常不同的事情。如果你在一家公司工作,你和你的團隊將有一套優先事項,而另一個團隊可能有完全不同的目標,即使你們可能在處理相同的資料集!此外,這些目標可能沒有被明確闡述,這可能導致對正確方法的誤解和分歧。
資料系統的核心難點之一在於:不同的人需要用同一份資料做完全不同的事。在公司裡,你和你的團隊有自己的優先順序,另一個團隊即使使用同一資料集,目標也可能完全不同。更麻煩的是,這些目標往往並未被明確表達,容易引發誤解和分歧。
為了幫助你瞭解可以做出哪些選擇,本章比較了幾個對比概念,並探討了它們的權衡:
@ -45,18 +47,18 @@ breadcrumbs: false
* 何時從單節點系統轉向分散式系統(["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed));以及
* 平衡業務需求和使用者權利(["資料系統、法律與社會"](/tw/ch1#sec_introduction_compliance))。
此外,本章將為你提供本書其餘部分所需的術語。
此外,本章還會引入貫穿全書的關鍵術語。
> [!TIP] 術語:前端和後端
本書中我們將討論的大部分內容都與 **後端開發** 有關。為了解釋這個術語:對於 Web 應用程式,在 Web 瀏覽器中執行的客戶端程式碼稱為 **前端**,處理使用者請求的伺服器端程式碼稱為 **後端**。移動應用類似於前端,它們提供使用者介面,通常透過網際網路與伺服器端後端通訊。前端有時在使用者裝置上本地管理資料 [^2],但最大的資料基礎設施挑戰通常在於後端:前端只需要處理一個使用者的資料,而後端為 **所有** 使用者管理資料。
本書討論的大部分內容都與 **後端開發** 相關。對 Web 應用而言,執行在瀏覽器中的客戶端程式碼稱為 **前端**,處理使用者請求的伺服器端程式碼稱為 **後端**。移動應用也類似前端:它們提供使用者介面,通常經由網際網路與伺服器端後端通訊。前端有時會在裝置本地管理資料 [^2],但更棘手的資料基礎設施問題通常發生在後端:前端只處理單個使用者的資料,而後端需要代表 **所有** 使用者管理資料。
後端服務通常可透過 HTTP有時是 WebSocket訪問它通常由一些應用程式程式碼組成這些程式碼在一個或多個數據庫中讀取和寫入資料有時還與其他資料系統如快取或訊息佇列互動我們可能將其統稱為 **資料基礎設施**)。應用程式程式碼通常是 **無狀態的**(即,當它完成處理一個 HTTP 請求時,它會忘記關於該請求的所有內容),任何需要從一個請求持續到另一個請求的資訊都需要儲存在客戶端或伺服器端的資料基礎設施中
後端服務通常透過 HTTP有時是 WebSocket提供訪問。其核心是應用程式碼在一個或多個數據庫中讀寫資料並按需接入快取、訊息佇列等其他系統可統稱為 **資料基礎設施**)。應用程式碼往往是 **無狀態** 的:處理完一個 HTTP 請求後,不保留該請求上下文。因此,凡是需要跨請求持久化的資訊,都必須寫在客戶端,或寫入伺服器端資料基礎設施
## 分析型與事務型系統 {#sec_introduction_analytics}
如果你在企業中從事資料系統工作,你可能會遇到幾種不同型別的資料工作者。第一類是 **後端工程師**,他們構建服務來處理讀取和更新資料的請求;這些服務通常直接或間接地透過其他服務為外部使用者提供服務(參見["微服務與 Serverless"](/tw/ch1#sec_introduction_microservices))。有時服務是供組織其他部門內部使用的
如果你在企業中從事資料系統工作,往往會遇到幾類不同的資料使用者。第一類是 **後端工程師**,他們構建服務來處理讀取與更新資料的請求;這些服務通常直接面向外部使用者,或透過其他服務間接提供能力(參見["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices))。有時服務也只供組織內部使用
除了管理後端服務的團隊外,通常還有兩類人需要訪問組織的資料:**業務分析師**,他們生成關於組織活動的報告,以幫助管理層做出更好的決策(**商業智慧** 或 **BI**);以及 **資料科學家**他們在資料中尋找新的見解或建立由資料分析和機器學習AI支援的面向使用者的產品功能例如電子商務網站上的“購買了 X 的人也購買了 Y”推薦、風險評分或垃圾郵件過濾等預測分析以及搜尋結果排名
@ -67,7 +69,7 @@ breadcrumbs: false
正如我們將在下一節中看到的,事務型系統和分析型系統通常出於充分的理由而保持分離。隨著這些系統的成熟,出現了兩個新的專業角色:**資料工程師** 和 **分析工程師**。資料工程師是知道如何整合事務型系統和分析型系統的人,並更廣泛地負責組織的資料基礎設施 [^3]。分析工程師對資料進行建模和轉換,使其對組織中的業務分析師和資料科學家更有用 [^4]。
許多工程師專注於事務型系統和分析型系統中的一個。然而,本書涵蓋了事務型和分析型資料系統,因為兩者在組織內資料的生命週期中都扮演著重要角色。我們將深入探討用於向內部和外部使用者提供服務的資料基礎設施,以便你能更好地與分界線另一邊的同事合作。
許多工程師只專注於事務型或分析型其中一側。然而,本書會同時覆蓋這兩類資料系統,因為它們都在組織內的資料生命週期中扮演關鍵角色。我們將深入討論向內外部使用者提供服務所需的資料基礎設施,幫助你更好地與“另一側”的同事協作。
### 事務處理與分析的特徵 {#sec_introduction_oltp}
@ -128,7 +130,7 @@ breadcrumbs: false
一些資料庫系統提供 **混合事務/分析處理**HTAP目標是在單個系統中同時支援 OLTP 和分析,而無需從一個系統 ETL 到另一個系統 [^8] [^9]。然而,許多 HTAP 系統內部由一個 OLTP 系統與一個單獨的分析系統耦合組成,隱藏在公共介面後面——因此兩者之間的區別對於理解這些系統如何工作仍然很重要。
此外,儘管 HTAP 存在,但由於目標和要求不同,事務型系統和分析型系統之間的分離是常見的。特別是,讓每個事務型系統擁有自己的資料庫被認為是良好的做法(參見["微服務與 Serverless"](/tw/ch1#sec_introduction_microservices)),這將導致數百個單獨的事務型資料庫;另一方面,企業通常有一個單一的資料倉庫,以便業務分析師可以在單個查詢中組合來自多個事務型系統的資料。
此外,儘管 HTAP 已出現,但由於目標和約束不同,事務型系統與分析型系統分離仍很常見。尤其是,讓每個事務型系統擁有自己的資料庫通常被視為良好實踐(參見["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices)),這會形成數百個相互獨立的事務型資料庫;與之對應,企業往往只有一個統一的資料倉庫,以便分析師能在單個查詢裡組合多個事務型系統的資料。
因此HTAP 不會取代資料倉庫。相反,它在同一應用程式既需要執行掃描大量行的分析查詢,又需要以低延遲讀取和更新單個記錄的場景中很有用。例如,欺詐檢測可能涉及此類工作負載 [^10]。
@ -153,31 +155,31 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
#### 超越資料湖 {#beyond-the-data-lake}
隨著分析實踐的成熟,組織越來越關注分析系統和資料管道的管理和運維,如在 DataOps 宣言中所描述的那樣 [^18]。其中一部分是治理、隱私和遵守 GDPR 和 CCPA 等法規的問題,我們將在["資料系統、法律與社會"](/tw/ch1#sec_introduction_compliance)和[待補充連結]中討論。
隨著分析實踐的成熟,組織越來越重視分析系統與資料管道的管理和運維,這一點在 DataOps 宣言中已有體現 [^18]。其中一部分是治理、隱私以及對 GDPR、CCPA 等法規的遵從;我們會在["資料系統、法律與社會"](/tw/ch1#sec_introduction_compliance)和["立法與行業自律"](/ch14#sec_future_legislation)中討論。
此外,分析資料越來越多地不僅作為檔案和關係表提供,還作為事件流(參見[待補充連結])。使用基於檔案的資料分析,你可以定期(例如,每天)重新執行分析以響應資料的變化,但流處理允許分析系統以秒級的速度響應事件。根據應用程式及其時間敏感性,流處理方法可能很有價值,例如識別和阻止潛在的欺詐或濫用活動
此外,分析資料的提供形式也越來越多樣:不僅有檔案和關係表,也有事件流(見[第 12 章](/tw/ch12#ch_stream))。基於檔案的分析通常透過週期性重跑(例如每天一次)來響應資料變化,而流處理能夠讓分析系統在秒級響應事件。對於時效性要求高的場景,這種方式很有價值,例如識別並阻斷潛在的欺詐或濫用行為
在某些情況下,分析系統的輸出被提供給事務型系統(這個過程有時被稱為 **反向 ETL** [^19])。例如,在分析系統中訓練的機器學習模型可能會部署到生產環境中,以便為終端使用者生成推薦,例如“購買了 X 的人也購買了 Y”。這種分析系統的部署輸出也被稱為 **資料產品** [^20]。機器學習模型可以使用 TFX、Kubeflow 或 MLflow 等專門工具部署到事務型系統。
在某些場景中,分析系統的輸出還會迴流到事務型系統(這一過程有時稱為 **反向 ETL** [^19])。例如,在分析系統裡訓練出的機器學習模型會部署到生產環境,為終端使用者生成“買了 X 的人也買了 Y”這類推薦。此類分析系統的投產結果也稱為 **資料產品** [^20]。機器學習模型可藉助 TFX、Kubeflow、MLflow 等專用工具部署到事務型系統。
### 權威資料來源與派生資料 {#sec_introduction_derived}
### 記錄系統與派生資料 {#sec_introduction_derived}
與事務型系統和分析型系統之間的區別相關,本書還區分了 **權威記錄系統****派生資料系統**。這些術語很有用,因為它們可以幫助你澄清資料在系統中的流動
與事務型系統和分析型系統的區分相關,本書還區分 **記錄系統****派生資料系統**。這組術語有助於你理清資料在系統中的流向
權威記錄系統
: 權威記錄系統,也稱為 **權威資料來源**,儲存某些資料的權威或 **規範** 版本。當新資料進入時,例如作為使用者輸入,它首先寫入這裡。每個事實只表示一次(表示通常是 **正規化** 的;參見["正規化、反正規化和連線"](/tw/ch3#sec_datamodels_normalization))。如果另一個系統與權威記錄系統之間存在任何差異,那麼權威記錄系統中的值(根據定義)是正確的
: 記錄系統,也稱 **真相來源(權威資料來源)**儲存某類資料的權威canonical版本。新資料進入系統時例如使用者輸入首先寫入這裡。每個事實只表示一次這種表示通常是 **正規化** 的;見["正規化、反正規化與連線"](/tw/ch3#sec_datamodels_normalization))。如果其他系統與記錄系統不一致,則按定義以記錄系統為準
派生資料系統
: 派生系統中的資料是從另一個系統獲取一些現有資料並以某種方式轉換或處理它的結果。如果你丟失了派生資料,你可以從原始源重新建立它。一個經典的例子是快取:如果存在,可以從快取提供資料,但如果快取不包含你需要的內容,你可以回退到底層資料庫。反正規化值、索引、物化檢視、轉換的資料表示和在資料集上訓練的模型也屬於這一類別
: 派生系統中的資料,是對其他系統中已有資料進行轉換或處理後的結果。如果派生資料丟失,可以從原始資料來源重新構建。經典例子是快取:命中時由快取返回,未命中時回退到底層資料庫。反正規化值、索引、物化檢視、變換後的資料表示,以及在資料集上訓練出的模型,都屬於這一類
從技術上講,派生資料是 **冗餘** 的,因為它複製了現有資訊。然而,它通常對於在讀取查詢上獲得良好效能至關重要。你可以從單個源派生幾個不同的資料集,使你能夠從不同的"視角"檢視資料
從技術上說,派生資料是 **冗餘** 的,因為它複製了已有資訊。但它往往是讀查詢高效能的關鍵。你可以從同一個源資料派生出多個數據集,以不同“視角”觀察同一份事實
分析系統通常是派生資料系統,因為它們是在其他地方建立的資料的消費者。事務型服務可能包含權威記錄系統和派生資料系統的混合。權威記錄系統是資料首先被寫入的主資料庫,而派生資料系統是加速常見讀取操作的索引和快取,特別是對於權威記錄系統無法有效回答的查詢。
分析系統通常屬於派生資料系統,因為它消費的是別處產生的資料。事務型服務往往同時包含記錄系統和派生資料系統:前者是資料首先寫入的主資料庫,後者則是用於加速常見讀取操作的索引與快取,尤其針對記錄系統難以高效回答的查詢。
大多數資料庫、儲存引擎和查詢語言並非從本質上就是權威記錄系統或派生資料系統。資料庫只是一個工具:如何使用它取決於你。權威記錄系統和派生資料系統之間的區別不取決於工具,而取決於你如何在應用程式中使用它。透過明確哪些資料是從哪些其他資料派生的,你可以為讓原本混亂的系統架構變得清晰
大多數資料庫、儲存引擎和查詢語言本身並不天然屬於“記錄系統”或“派生系統”。資料庫只是工具,關鍵在於你如何使用它。兩者的區別不在工具本身,而在應用中的職責劃分。只要明確“哪些資料由哪些資料派生而來”,原本混亂的系統架構就會清晰很多
當一個系統中的資料來源自另一個系統中的資料時,你需要一個過程來在權威記錄系統中的原始資料發生變化時更新派生資料。不幸的是,許多資料庫的設計基於這樣的假設:你的應用程式只需要使用那一個數據庫,它們不易於整合多個系統以傳播此類更新。在[待補充連結]中,我們將討論 **資料整合** 的方法,這允許我們組合多個數據系統來實現單個系統無法做到的事情
當一個系統的資料由另一個系統的資料派生而來時,你需要在記錄系統原始資料變化時同步更新派生資料。不幸的是,很多資料庫預設假設應用只依賴單一資料庫,並不擅長在多系統之間傳播這類更新。在["資料整合"](/tw/ch13#sec_future_integration)中,我們會討論如何組合多個數據系統,實現單一系統難以獨立完成的能力
這就結束了我們對分析和事務處理的比較。在下一節中,我們將研究另一個你可能已經看到多次爭論的權衡。
至此,我們結束了對分析與事務處理的比較。下一節將討論另一組常被反覆爭論的權衡。
## 雲服務與自託管 {#sec_introduction_cloud}
@ -238,11 +240,10 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
除了具有不同的經濟模型(訂閱服務而不是購買硬體和許可軟體在其上執行)之外,雲的興起也對資料系統在技術層面的實現產生了深遠的影響。
術語 **雲原生** 用於描述旨在利用雲服務的架構。
原則上,幾乎任何你可以自託管的軟體也可以作為雲服務提供,實際上,許多流行的資料系統現在都有託管服務。
然而,從頭開始設計為雲原生的系統已被證明具有幾個優勢:在相同硬體上具有更好的效能、從故障中更快恢復、
能夠快速擴充套件計算資源以匹配負載,以及支援更大的資料集 [^25] [^26] [^27]。[表 1-2](/tw/ch1#tab_cloud_native_dbs) 列出了兩種型別系統的一些示例。
原則上,幾乎任何可自託管的軟體都可以做成雲服務;事實上,許多主流資料系統都已有託管版本。
不過,從零設計為雲原生的系統已經展示出若干優勢:同等硬體下效能更好、故障恢復更快、能更快按負載擴縮計算資源,並支援更大資料集 [^25] [^26] [^27]。[表 1-2](/tw/ch1#tab_cloud_native_dbs) 給出兩類系統的一些示例。
{{< figure id="#tab_cloud_native_dbs" title="表 1-2. 自託管雲原生資料庫系統示例" class="w-full my-4" >}}
{{< figure id="tab_cloud_native_dbs" title="表 1-2. 自託管雲原生資料庫系統示例" class="w-full my-4" >}}
| 類別 | 自託管系統 | 雲原生系統 |
|------------------|----------------------------|----------------------------------------------------------------------|
@ -304,7 +305,7 @@ Apache Hive、Spark SQL、Presto 和 Trino 是這種方法的例子。
採用雲服務可能比執行自己的基礎設施更容易、更快,儘管學習如何使用它也有成本,也許還要解決其限制。隨著越來越多的供應商提供針對不同用例的更廣泛的雲服務,不同服務之間的整合成為一個特別的挑戰 [^39] [^40]。
ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一部分;事務型雲服務也需要相互整合。目前,缺乏促進這種整合的標準,因此它通常涉及大量的手動工作。
ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一部分;面向事務處理的雲服務之間也需要相互整合。目前,缺乏能促進這類整合的標準,因此往往仍要投入大量手工工作。
無法完全外包給雲服務的其他運維方面包括維護應用程式及其使用的庫的安全性、管理你自己的服務之間的互動、監控服務的負載,以及追蹤問題的原因,例如效能下降或中斷。雖然雲正在改變運維的角色,但對運維的需求比以往任何時候都大。
@ -358,11 +359,11 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
出於所有這些原因,如果你可以在單臺機器上做某件事情,與搭建分散式系統相比通常要簡單得多,成本也更低 [^23] [^46] [^51]。CPU、記憶體和磁碟已經變得更大、更快、更可靠。當與 DuckDB、SQLite 和 KùzuDB 等單節點資料庫結合使用時,許多工作負載現在可以在單個節點上執行。我們將在[第 4 章](/tw/ch4#ch_storage)中進一步探討這個主題。
### 微服務與 Serverless {#sec_introduction_microservices}
### 微服務與無伺服器 {#sec_introduction_microservices}
在多臺機器上分佈系統的最常見方式是將它們分為客戶端和伺服器,並讓客戶端向伺服器發出請求。最常見的是使用 HTTP 進行此通訊,正如我們將在["流經服務的資料流REST 和 RPC"](/tw/ch5#sec_encoding_dataflow_rpc)中討論的。同一程序可能既是伺服器(處理傳入請求)又是客戶端(向其他服務發出出站請求)。
這種構建應用程式的方式傳統上被稱為 **面向服務構**SOA最近這個想法已經被細化為 **微服務** 架構 [^52] [^53]。在這種架構中,服務有一個明確定義的目的(例如,對於 S3 來說,這個目的是檔案儲存);每個服務公開一個可以由客戶端透過網路呼叫的 API每個服務有一個負責其維護的團隊。因此複雜的應用程式可以分解為多個互動服務每個服務由單獨的團隊管理。
這種構建應用程式的方式傳統上被稱為 **面向服務的體系結構**SOA最近這個想法已經被細化為 **微服務** 架構 [^52] [^53]。在這種架構中,服務有一個明確定義的目的(例如,對於 S3 來說,這個目的是檔案儲存);每個服務公開一個可以由客戶端透過網路呼叫的 API每個服務有一個負責其維護的團隊。因此複雜的應用程式可以分解為多個互動服務每個服務由單獨的團隊管理。
將複雜的軟體分解為多個服務有幾個優點:每個服務可以獨立更新,減少團隊之間的協調工作;每個服務可以分配它需要的硬體資源;透過將實現細節隱藏在 API 後面,服務所有者可以自由地更改實現而不影響客戶端。在資料儲存方面,每個服務通常有自己的資料庫,而不在服務之間共享資料庫:共享資料庫實際上會使整個資料庫結構成為服務 API 的一部分,然後該結構將很難更改。共享資料庫還可能導致一個服務的查詢對其他服務的效能產生負面影響。
@ -372,9 +373,9 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
微服務主要是人員問題的技術解決方案:允許不同的團隊獨立取得進展,而無需相互協調。這在大公司中很有價值,但在沒有很多團隊的小公司中,使用微服務可能是不必要的開銷,最好以最簡單的方式實現應用程式 [^52]。
**Serverless** 或 **函式即服務**FaaS是部署服務的另一種方法其中基礎設施的管理外包給雲供應商 [^33]。使用虛擬機器時,你必須明確選擇何時啟動或關閉例項;相比之下,使用 serverless 模型,雲提供商根據對你服務的傳入請求自動分配和釋放硬體資源 [^54]。Serverless 部署將更多的運營負擔轉移到雲提供商,並透過使用量而不是機器例項實現靈活的計費。為了提供這些好處,許多 serverless 基礎設施提供商對函式執行施加時間限制限制執行時環境並且在首次呼叫函式時可能會遭受緩慢的啟動時間。術語“serverless”也可能具有誤導性每個 serverless 函式執行仍然在伺服器上執行但後續執行可能在不同的伺服器上執行。此外BigQuery 和各種 Kafka 產品等基礎設施已經採用“serverless”術語來表示他們的服務自動擴充套件並且他們按使用量而不是機器例項計費。
**無伺服器Serverless**,或 **函式即服務**FaaS是另一種部署方式基礎設施管理進一步外包給雲廠商 [^33]。使用虛擬機器時,你需要顯式決定何時啟動、何時關閉例項;而在無伺服器模型中,雲廠商會根據進入服務的請求自動分配和回收計算資源 [^54]。這種部署方式把更多運維負擔轉移給雲廠商並支援按使用量計費而不是按例項計費。為實現這些優勢許多無伺服器平臺會限制函式執行時長、限制執行時環境並在函式首次呼叫時出現較慢冷啟動。術語“無伺服器”本身也容易誤導每次函式執行依然執行在某臺伺服器上只是後續執行未必在同一臺機器上。此外BigQuery 及多種 Kafka 產品也採用“Serverless”術語強調其服務可自動擴縮容且按使用量計費。
就像雲端儲存用計量計費模型取代了容量規劃提前決定購買多少磁碟一樣serverless 方法正在為程式碼執行帶來計量計費:你只為應用程式程式碼實際執行的時間付費,而不必提前配置資源。
就像雲端儲存以計量計費取代了傳統容量規劃(預先決定買多少磁碟)一樣,無伺服器模式把同樣的計費邏輯帶到了程式碼執行層:你只為程式碼實際執行的時間付費,而不必預先準備固定資源。
### 雲計算與超級計算 {#id17}
@ -398,7 +399,7 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
每個從事此類系統工作的人都有責任考慮道德影響並確保他們遵守相關法律。沒有必要讓每個人都成為法律和道德專家,但對法律和道德原則的基本認識與分散式系統中的一些基礎知識同樣重要。
法律考慮正在影響資料系統設計的基礎 [^61]。例如GDPR 授予個人在請求時刪除其資料的權利(有時稱為 **被遺忘權**)。然而,正如我們將在本書中看到的,許多資料系統依賴不可變構造(如僅追加日誌)作為其設計的一部分;我們如何確保刪除應該不可變的檔案中間的某些資料?我們如何處理已被納入派生資料集(參見["權威資料來源與派生資料"](/tw/ch1#sec_introduction_derived))的資料刪除,例如機器學習模型的訓練資料?回答這些問題會帶來新的工程挑戰。
法律考慮正在影響資料系統設計的基礎 [^61]。例如GDPR 授予個人在請求時刪除其資料的權利(有時稱為 **被遺忘權**)。然而,正如我們將在本書中看到的,許多資料系統依賴不可變構造(如僅追加日誌)作為其設計的一部分;我們如何確保刪除應該不可變的檔案中間的某些資料?我們如何處理已被納入派生資料集(參見["記錄系統與派生資料"](/tw/ch1#sec_introduction_derived))的資料刪除,例如機器學習模型的訓練資料?回答這些問題會帶來新的工程挑戰。
目前,我們對於哪些特定技術或系統架構應被視為“符合 GDPR”沒有明確的指導方針。法規故意不強制要求特定技術因為隨著技術的進步這些技術可能會迅速變化。相反法律文字規定了需要解釋的高層級原則。這意味著如何遵守隱私法規的問題沒有簡單的答案但我們將透過這個視角來看待本書中的一些技術。
@ -410,22 +411,22 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
企業也注意到了隱私和安全問題。信用卡公司要求處理支付的企業遵守嚴格的支付卡行業PCI標準。處理商需要經常接受獨立審計師的評估以驗證持續的合規性。軟體供應商也受到了更多的審查。現在許多買家要求他們的供應商遵守服務組織控制SOC型別 2 標準。與 PCI 合規性一樣,供應商需要接受第三方審計以驗證遵守情況。
一般來說,重要的是平衡你的業務需求與你收集和處理其資料的人的需求。這個話題還有很多內容;在[待補充連結]中,我們將更深入地探討道德和法律合規性的主題,包括偏見和歧視的問題。
總的來說,關鍵在於平衡業務目標與被收集、被處理資料的人們的權益。這個主題還有很多內容;在[第 14 章](/ch14#ch_right_thing)中,我們會進一步討論倫理與法律合規,以及偏見與歧視等問題。
## 總結 {#summary}
本章的主題是理解權衡:也就是說要認識到對於許多問題並沒有一個正確的答案,而是有幾種不同的方法,每種方法都有各種利弊。我們探討了影響資料系統架構的一些最重要的選擇,並介紹了本書其餘部分所需的術語。
本章的主線是理解“權衡”。對許多問題而言,並不存在唯一正確答案,而是有多種路徑,各有利弊。我們討論了影響資料系統架構的幾個關鍵選擇,並引入了後續章節會反覆使用的術語。
我們首先區分了事務型事務處理OLTP和分析型OLAP系統,並看到了它們的不同特徵:不僅透過不同的訪問模式管理不同型別的資料,而且服務於不同的受眾。我們遇到了資料倉庫和資料湖的概念,它們透過 ETL 從事務型系統接收資料。在[第 4 章](/tw/ch4#ch_storage)中,我們將看到事務型和分析型系統通常使用非常不同的內部資料佈局,因為它們需要服務的查詢型別不同
我們首先區分了事務型事務處理OLTP和分析型OLAP系統。它們不僅面對不同訪問模式與資料型別,也服務於不同人群。我們還看到資料倉庫與資料湖這兩類體系,它們透過 ETL 接收來自事務型系統的資料。在[第 4 章](/tw/ch4#ch_storage)中,我們會看到由於查詢型別不同,事務型與分析型系統常常採用截然不同的內部資料佈局
然後,我們將雲服務(一個相對較新的發展)與傳統的自託管軟體正規化進行了比較,後者以前主導了資料系統架構。這些方法中哪一種更具成本效益在很大程度上取決於你的特定情況,但不可否認的是,雲原生方法正在為資料系統的架構帶來重大變化,例如它們分離儲存和計算的方式
隨後,我們把相對較新的雲服務模式與長期主導資料系統架構的自託管正規化做了比較。哪種方式更具成本效益高度依賴具體情境,但不可否認,雲原生架構正在深刻改變資料系統的構建方式,例如儲存與計算的分離
雲系統本質上是分散式的,我們簡要地研究了分散式系統與使用單臺機器相比的一些權衡。有些情況下你無法避免分散式,但如果可能在單臺機器上執行系統,建議不要急於使系統分散式化。在[第 9 章](/tw/ch9#ch_distributed)中,我們將更詳細地介紹分散式系統的挑戰。
雲系統天然是分散式系統,我們也簡要討論了它與單機方案之間的權衡。有些場景無法避免分散式,但如果單機可行,不必急於把系統分散式化。在[第 9 章](/tw/ch9#ch_distributed)中,我們會更深入地討論分散式系統的挑戰。
最後,我們看到資料系統架構不僅由部署系統的企業的需求決定,還由保護其資料被處理的人的權利的隱私法規決定——這是許多工程師容易忽視的一個方面。我們如何將法律要求轉化為技術實現還沒有非常清晰的答案,但在我們閱讀本書的其餘部分時,記住這個問題很重要。
最後,資料系統架構不僅由企業自身需求決定,也受保護資料主體權利的隱私法規所塑造,而這一點常被工程實踐忽略。如何把法律要求轉化為技術實現,目前仍無標準答案;但在閱讀本書後續內容時,始終帶著這個問題會很重要。
### 參考
### 參考文獻
[^1]: Richard T. Kouzes, Gordon A. Anderson, Stephen T. Elbert, Ian Gorton, and Deborah K. Gracio. [The Changing Paradigm of Data-Intensive Computing](http://www2.ic.uff.br/~boeres/slides_AP/papers/TheChanginParadigmDataIntensiveComputing_2009.pdf). *IEEE Computer*, volume 42, issue 1, January 2009. [doi:10.1109/MC.2009.26](https://doi.org/10.1109/MC.2009.26)
[^2]: Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. [Local-first software: you own your data, in spite of the cloud](https://www.inkandswitch.com/local-first/). At *2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software* (Onward!), October 2019. [doi:10.1145/3359591.3359737](https://doi.org/10.1145/3359591.3359737)
@ -489,4 +490,4 @@ ETL參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))只是故事的一
[^60]: Cathy ONeil: *Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy*. Crown Publishing, 2016. ISBN: 9780553418811
[^61]: Supreeth Shastri, Vinay Banakar, Melissa Wasserman, Arun Kumar, and Vijay Chidambaram. [Understanding and Benchmarking the Impact of GDPR on Database Systems](https://www.vldb.org/pvldb/vol13/p1064-shastri.pdf). *Proceedings of the VLDB Endowment*, volume 13, issue 7, pages 10641077, March 2020. [doi:10.14778/3384345.3384354](https://doi.org/10.14778/3384345.3384354)
[^62]: Martin Fowler. [Datensparsamkeit](https://www.martinfowler.com/bliki/Datensparsamkeit.html). *martinfowler.com*, December 2013. Archived at [perma.cc/R9QX-CME6](https://perma.cc/R9QX-CME6)
[^63]: [Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation)](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679&from=EN). *Official Journal of the European Union* L 119/1, May 2016.
[^63]: [Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 (General Data Protection Regulation)](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679&from=EN). *Official Journal of the European Union* L 119/1, May 2016.

View file

@ -4,6 +4,8 @@ weight: 210
breadcrumbs: false
---
<a id="ch_consistency"></a>
![](/map/ch09.png)
> *一句古老的格言告誡說:"千萬不要帶著兩塊計時器出海;要麼帶一塊,要麼帶三塊。"*
@ -42,7 +44,7 @@ breadcrumbs: false
這就是 *線性一致性* [^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" >}}
@ -152,7 +154,7 @@ breadcrumbs: false
如果你想確保銀行賬戶餘額永遠不會變為負數,或者你不會銷售超過倉庫庫存的物品,或者兩個人不會同時預訂同一航班或劇院的同一座位,也會出現類似的問題。這些約束都要求有一個所有節點都同意的單一最新值(賬戶餘額、庫存水平、座位佔用情況)。
在實際應用中,有時可以接受寬鬆地對待這些約束(例如,如果航班超售,你可以將客戶轉移到其他航班,併為不便提供補償)。在這種情況下,可能不需要線性一致性,我們將在 [Link to Come] 中討論這種寬鬆解釋的約束。
在實際應用中,有時可以接受寬鬆地對待這些約束(例如,如果航班超售,你可以將客戶轉移到其他航班,併為不便提供補償)。在這種情況下,可能不需要線性一致性,我們將在 ["時效性與完整性"](/tw/ch13#sec_future_integrity) 中討論這種寬鬆解釋的約束。
然而,硬唯一性約束,例如你通常在關係資料庫中找到的約束,需要線性一致性。其他型別的約束,例如外部索引鍵或屬性約束,可以在沒有線性一致性的情況下實現 [^20]。
@ -162,7 +164,7 @@ breadcrumbs: false
類似的情況可能出現在計算機系統中。例如,假設你有一個網站,使用者可以上傳影片,後臺程序將影片轉碼為較低質量,以便在慢速網際網路連線上流式傳輸。該系統的架構和資料流如 [圖 10-5](/tw/ch10#fig_consistency_transcoder) 所示。
影片轉碼器需要明確指示執行轉碼作業,此指令透過訊息佇列從 Web 伺服器傳送到轉碼器(見 [Link to Come]。Web 伺服器不會將整個影片放在佇列中,因為大多數訊息代理都是為小訊息設計的,而影片可能有許多兆位元組大小。相反,影片首先寫入檔案儲存服務,寫入完成後,轉碼指令被放入佇列。
影片轉碼器需要明確指示執行轉碼作業,此指令透過訊息佇列從 Web 伺服器傳送到轉碼器(見 ["訊息傳遞系統"](/tw/ch12#sec_stream_messaging)。Web 伺服器不會將整個影片放在佇列中,因為大多數訊息代理都是為小訊息設計的,而影片可能有許多兆位元組大小。相反,影片首先寫入檔案儲存服務,寫入完成後,轉碼指令被放入佇列。
{{< figure src="/fig/ddia_1005.png" id="fig_consistency_transcoder" caption="圖 10-5. 一個非線性一致的系統Alice 和 Bob 在不同時間看到上傳的影像,因此 Bob 的請求基於過時的資料。" class="w-full my-4" >}}
@ -185,7 +187,7 @@ breadcrumbs: false
讓我們重新審視 [第六章](/tw/ch6) 中的複製方法,並比較它們是否可以實現線性一致:
單主複製(可能線性一致)
: 在單主複製系統中,主節點擁有用於寫入的資料主副本,從節點在其他節點上維護資料的備份副本。只要你在主節點上執行所有讀寫操作,它們很可能是線性一致的。然而,這假設你確定知道誰是主節點。如 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 中所討論的,一個節點很可能認為自己是主節點,而實際上並不是——如果這個妄想的主節點繼續服務請求,很可能會違反線性一致性 [^21]。使用非同步複製,故障切換甚至可能丟失已提交的寫入,這違反了永續性和線性一致性。
: 在單主複製系統中,主節點擁有用於寫入的資料主副本,備庫在其他節點上維護資料副本。只要你在主節點上執行所有讀寫操作,它們很可能是線性一致的。然而,這假設你確定知道誰是主節點。如 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 中所討論的,一個節點很可能認為自己是主節點,而實際上並不是。如果這個“妄想中的主節點”繼續處理請求,很可能會違反線性一致性 [^21]。使用非同步複製,故障切換甚至可能丟失已提交的寫入,這違反了永續性和線性一致性。
對單主資料庫進行分片,每個分片有一個單獨的主節點,不會影響線性一致性,因為它只是單物件保證。跨分片事務是另一回事(見 ["分散式事務"](/tw/ch8#sec_transactions_distributed))。
@ -230,11 +232,11 @@ breadcrumbs: false
使用多主資料庫,每個區域可以繼續正常執行:由於來自一個區域的寫入被非同步複製到另一個區域,寫入只是排隊並在網路連線恢復時交換。
另一方面,如果使用單主複製,那麼主節點必須在其中一個區域。任何寫入和任何線性一致的讀取都必須傳送到主節點——因此,對於連線到從節點區域的任何客戶端,這些讀寫請求必須透過網路同步傳送到主節點區域。
另一方面,如果使用單主複製,那麼主節點必須在其中一個區域。任何寫入和任何線性一致的讀取都必須傳送到主節點。因此,對於連線到備庫所在區域的任何客戶端,這些讀寫請求都必須透過網路同步傳送到主節點區域。
如果在單主設定中區域之間的網路中斷,連線到從節點區域的客戶端無法聯絡主節點,因此它們既不能對資料庫進行任何寫入,也不能進行任何線性一致的讀取。它們仍然可以從從節點讀取,但它們可能是過時的(非線性一致)。如果應用程式需要線性一致的讀寫,網路中斷會導致應用程式在無法聯絡主節點的區域中變得不可用。
如果在單主設定中區域之間的網路中斷,連線到備庫區域的客戶端無法聯絡主節點,因此它們既不能對資料庫進行任何寫入,也不能進行任何線性一致的讀取。它們仍然可以從備庫讀取,但這些讀取可能是過時的(非線性一致)。如果應用程式需要線性一致的讀寫,網路中斷會導致應用程式在無法聯絡主節點的區域中變得不可用。
如果客戶端可以直接連線到主節點區域,這不是問題,因為應用程式在那裡繼續正常工作。但只能訪問從節點區域的客戶端將在網路連結修復之前遇到中斷。
如果客戶端可以直接連線到主節點區域,這不是問題,因為應用程式在那裡繼續正常工作。但只能訪問備庫區域的客戶端將在網路鏈路修復之前遇到中斷。
#### CAP 定理 {#the-cap-theorem}
@ -273,7 +275,7 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化
許多選擇不提供線性一致保證的分散式資料庫也是如此:它們這樣做主要是為了提高效能,而不是為了容錯 [^42]。線性一致性很慢——這在任何時候都是真的,不僅在網路故障期間。
我們能否找到更高效的線性一致儲存實現答案似乎是否定的Attiya 和 Welch [^49] 證明,如果你想要線性一致性,讀寫請求的響應時間至少與網路中延遲的不確定性成正比。在具有高度可變延遲的網路中,例如大多數計算機網路(見 ["超時和無界延遲"](/tw/ch9#sec_distributed_queueing)),線性一致讀寫的響應時間不可避免地會很高。更快的線性一致性演算法不存在,但較弱的一致性模型可能會快得多,因此這種權衡對於延遲敏感的系統很重要。在 [Link to Come] 中,我們將討論一些在不犧牲正確性的情況下避免線性一致性的方法。
我們能否找到更高效的線性一致儲存實現答案似乎是否定的Attiya 和 Welch [^49] 證明,如果你想要線性一致性,讀寫請求的響應時間至少與網路中延遲的不確定性成正比。在具有高度可變延遲的網路中,例如大多數計算機網路(見 ["超時和無界延遲"](/tw/ch9#sec_distributed_queueing)),線性一致讀寫的響應時間不可避免地會很高。更快的線性一致性演算法不存在,但較弱的一致性模型可能會快得多,因此這種權衡對於延遲敏感的系統很重要。在 ["時效性與完整性"](/tw/ch13#sec_future_integrity) 中,我們將討論一些在不犧牲正確性的情況下避免線性一致性的方法。
## ID 生成器和邏輯時鐘 {#sec_consistency_logical}
@ -323,7 +325,7 @@ CP/AP 分類方案還有幾個進一步的缺陷 [^4]。*一致性* 被形式化
* 其時間戳緊湊(大小為幾個位元組)且唯一;
* 你可以比較任意兩個時間戳(即它們是 *全序* 的);並且
* 時間戳的順序與因果關係 *一致*:如果操作 A 發生在 B 之前,那麼 A 的時間戳小於 B 的時間戳。(我們之前在 [""先發生"關係和併發"](/tw/ch6#sec_replication_happens_before) 中討論了因果關係。)
* 時間戳的順序與因果關係 *一致*:如果操作 A 發生在 B 之前,那麼 A 的時間戳小於 B 的時間戳。(我們之前在 ["“先發生”關係與併發"](/tw/ch6#sec_replication_happens_before) 中討論了因果關係。)
單節點 ID 生成器滿足這些要求,但我們剛剛討論的分散式 ID 生成器不滿足因果排序要求。
@ -384,7 +386,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
確保 ID 分配線性一致的最簡單方法實際上是為此目的使用單個節點。該節點只需要原子地遞增計數器並在請求時返回其值,持久化計數器值(以便在節點崩潰並重新啟動時不會生成重複的 ID並使用單主複製進行容錯複製。這種方法在實踐中使用例如TiDB/TiKV 稱之為 *時間戳預言機*,受 Google 的 Percolator [^57] 啟發。
作為最佳化你可以避免在每個請求上執行磁碟寫入和複製。相反ID 生成器可以寫入描述一批 ID 的記錄;一旦該記錄被持久化複製,節點就可以開始按順序向客戶端分發這些 ID。在它用完該批次中的 ID 之前,它可以為下一批持久化和複製記錄。這樣,如果節點崩潰並重新啟動或你故障轉移到從節點,某些 ID 將被跳過,但你不會發出任何重複或亂序的 ID。
作為最佳化你可以避免在每個請求上執行磁碟寫入和複製。相反ID 生成器可以寫入描述一批 ID 的記錄;一旦該記錄被持久化並完成複製,節點就可以開始按順序向客戶端分發這些 ID。在它用完該批次中的 ID 之前,它可以為下一批持久化並複製記錄。這樣,如果節點崩潰並重啟,或故障切換到備庫,某些 ID 會被跳過,但不會發出任何重複或亂序的 ID。
你不能輕易地對 ID 生成器進行分片,因為如果你有多個分片獨立分發 ID你就無法再保證它們的順序是線性一致的。你也不能輕易地將 ID 生成器分佈在多個區域;因此,在地理分散式資料庫中,所有 ID 請求都必須轉到單個區域的節點。從好的方面來說ID 生成器的工作非常簡單,因此單個節點可以處理大量請求吞吐量。
@ -489,7 +491,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
#### 共享日誌作為共識 {#sec_consistency_shared_logs}
我們已經看到了幾個日誌的例子,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 *日誌條目*,任何讀取它的人都會看到相同順序的相同條目。有時日誌有一個允許追加新條目的單個寫入者,但 *共享日誌* 是多個節點可以請求追加條目的日誌。單主複製一個例子:任何客戶端都可以要求主節點進行寫入,主節點將其追加到複製日誌,然後所有從節點按照與主節點相同的順序應用寫入。
我們已經看到了幾個日誌的例子,例如複製日誌、事務日誌和預寫日誌。日誌儲存一系列 *日誌條目*,任何讀取它的人都會看到相同順序的相同條目。有時日誌有一個允許追加新條目的單個寫入者,但 *共享日誌* 是多個節點可以請求追加條目的日誌。單主複製就是一個例子:任何客戶端都可以要求主節點進行寫入,主節點將其追加到複製日誌,然後所有備庫按照與主節點相同的順序應用寫入。
更正式地說,共享日誌支援兩種操作:你可以請求將值新增到日誌中,並且可以讀取日誌中的條目。它必須滿足以下屬性:
@ -577,7 +579,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
#### 使用共享日誌 {#sec_consistency_smr}
共享日誌非常適合資料庫複製:如果每個日誌條目代表對資料庫的寫入,並且每個副本使用確定性邏輯以相同的順序處理相同的寫入,那麼副本將全部處於一致狀態。這個想法被稱為 *狀態機複製* [^80],它是事件溯源背後的原則,我們在 ["事件溯源和 CQRS"](/tw/ch3#sec_datamodels_events) 中看到了。共享日誌對於流處理也很有用,我們將在 [Link to Come] 中看到。
共享日誌非常適合資料庫複製:如果每個日誌條目代表對資料庫的寫入,並且每個副本使用確定性邏輯以相同的順序處理相同的寫入,那麼副本將全部處於一致狀態。這個想法被稱為 *狀態機複製* [^80],它是事件溯源背後的原則,我們在 ["事件溯源和 CQRS"](/tw/ch3#sec_datamodels_events) 中看到了。共享日誌對於流處理也很有用,我們將在 [第十二章](/tw/ch12#ch_stream) 中看到。
同樣,共享日誌可用於實現可序列化事務:如 ["實際序列執行"](/tw/ch8#sec_transactions_serial) 中所討論的,如果每個日誌條目代表要作為儲存過程執行的確定性事務,並且如果每個節點以相同的順序執行這些事務,那麼事務將是可序列化的 [^81] [^82]。
@ -606,7 +608,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
當節點因為在某個超時時間內沒有收到主節點的訊息而認為當前主節點已死時,它可能會開始投票選舉新的主節點。這次選舉被賦予一個大於任何先前紀元的新紀元編號。如果兩個不同紀元中的兩個不同主節點之間存在衝突(也許是因為先前的主節點實際上並沒有死),那麼具有更高紀元編號的主節點獲勝。
在主節點被允許將下一個條目追加到共享日誌之前,它必須首先檢查是否有其他具有更高紀元編號的主節點可能追加不同的條目。它可以透過從節點仲裁收集投票來做到這一點——通常但不總是大多數節點 [^85]。只有在節點不知道任何其他具有更高紀元的主節點時,節點才會投贊成票。
在主節點被允許將下一個條目追加到共享日誌之前,它必須首先檢查是否有其他具有更高紀元編號的主節點可能追加不同的條目。它可以透過從一個節點仲裁收集投票來做到這一點,通常(但並非總是)是多數節點 [^85]。只有在節點不知道任何其他具有更高紀元的主節點時,節點才會投贊成票。
因此,我們有兩輪投票:一次選擇主節點,第二次對主節點提議的下一個要追加到日誌的條目進行投票。這兩次投票的仲裁必須重疊:如果對提議的投票成功,投票支援它的節點中至少有一個也必須參與了最近成功的主節點選舉 [^85]。因此,如果對提議的投票透過而沒有透露任何更高編號的紀元,當前主節點可以得出結論,沒有選出具有更高紀元編號的主節點,因此它可以安全地將提議的條目追加到日誌中 [^26] [^86]。
@ -625,7 +627,7 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
如果你希望共識演算法嚴格保證 ["共享日誌作為共識"](/tw/ch10#sec_consistency_shared_logs) 中列出的屬性,那麼新主節點在處理任何寫入或線性一致讀取之前必須瞭解任何已確認的日誌條目,這一點至關重要。如果具有過時資料的節點成為新主節點,它可能會將新值寫入已經由舊主節點寫入的日誌條目,從而違反共享日誌的僅追加屬性。
在某些情況下你可能選擇削弱共識屬性以便更快地從主節點故障中恢復。例如Kafka 提供了啟用 *不乾淨的主節點選舉* 的選項,它允許任何副本成為主節點,即使它不是最新的。此外,在具有非同步複製的資料庫中,當主節點失敗時,你無法保證任何從節點是最新的。
在某些情況下你可能選擇削弱共識屬性以便更快地從主節點故障中恢復。例如Kafka 提供了啟用 *不乾淨的主節點選舉* 的選項,它允許任何副本成為主節點,即使它不是最新的。此外,在採用非同步複製的資料庫中,當主節點失敗時,你無法保證任何備庫是最新的。
如果你放棄新主節點必須是最新的要求,你可能會提高效能和可用性,但你是在薄冰上,因為共識理論不再適用。雖然只要沒有故障,事情就會正常工作,但 [第九章](/tw/ch9) 中討論的問題很容易導致大量資料丟失或損壞。
@ -649,7 +651,62 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
有時共識演算法對網路問題特別敏感。例如Raft 已被證明具有不愉快的邊緣情況 [^88] [^89]如果除了一個始終不可靠的特定網路連結之外整個網路都正常工作Raft 可能會進入主節點身份在兩個節點之間不斷跳躍的情況,或者當前主節點不斷被迫辭職,因此係統實際上從未取得進展。設計對不可靠網路更穩健的演算法仍然是一個開放的研究問題。
對於想要高可用但不想接受共識成本的系統,唯一真正的選擇是使用較弱的一致性模型,例如 [第六章](/tw/ch6) 中討論的無主或多主複製提供的模型。這些方法通常不提供線性一致性,但對於不需要它的應用程式來說這很好。
對於想要高可用但不想接受共識成本的系統,唯一真正的選擇是使用較弱的一致性模型,例如 [第六章](/tw/ch6) 中討論的無主或多主複製提供的模型。這些方法通常不提供線性一致性,但對於不需要它的應用程式來說已經足夠。
### 協調服務 {#sec_consistency_coordination}
共識演算法對於任何希望提供線性一致操作的分散式資料庫都很有價值,許多現代分散式資料庫也都用共識來做複製。但有一類系統是共識演算法的重度使用者:*協調服務*,例如 ZooKeeper、etcd 和 Consul。雖然它們表面上看起來像普通鍵值儲存但它們並不是為通用資料儲存而設計的。
相反它們的目標是協調另一個分散式系統中的多個節點。例如Kubernetes 依賴 etcdSpark 和 Flink 在高可用模式下會在後臺依賴 ZooKeeper。協調服務通常只儲存小規模資料這些資料可以完全放入記憶體同時仍會寫盤以保證永續性並透過容錯共識演算法在多個節點間複製。
協調服務的設計思路來自 Google 的 Chubby 鎖服務 [^17] [^58]。它把共識演算法與一些在分散式系統裡尤其有用的能力結合在一起:
鎖與租約
: 我們前面看到共識系統可以實現具備容錯能力的原子比較並設定CAS操作。協調服務正是基於這一點來實現鎖和租約若多個節點併發嘗試獲取同一個租約最終只會有一個成功。
支援柵欄
: 如 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 所述,當某個資源受租約保護時,需要 *柵欄* 機制來防止程序暫停或網路大延遲時的相互干擾。共識系統可透過為每個日誌條目分配單調遞增 ID 來生成柵欄令牌ZooKeeper 中的 `zxid``cversion`etcd 中的 revision
故障檢測
: 客戶端會在協調服務上維持長連線會話並透過週期性心跳檢查對端是否存活。即使連線臨時中斷或某臺服務端故障客戶端持有的租約仍可保持有效但如果超過租約超時時間仍未收到心跳協調服務就會認為客戶端已失效並釋放租約ZooKeeper 將其稱為 *臨時節點*)。
變更通知
: 客戶端可以請求:當某些鍵發生變化時由協調服務主動通知。這樣客戶端就能知道另一個節點何時加入叢集(基於其寫入的值),或者何時失效(會話超時、臨時節點消失)。這類通知避免了客戶端頻繁輪詢。
故障檢測和變更通知本身不需要共識,但與需要共識的原子操作、柵欄機制結合後,它們對分散式協調非常有用。
--------
> [!TIP] 用協調服務管理配置
應用與基礎設施通常都有配置引數,例如超時時間、執行緒池大小等。有時會把這類配置資料以鍵值對形式存放在協調服務中。程序啟動時載入最新配置,並訂閱後續變更通知。配置更新後,程序可以立即應用新值,或重啟後生效。
配置管理本身不需要協調服務裡的共識能力;但如果系統本來就已經運行了協調服務,那麼直接複用它的通知機制會很方便。另一種做法是程序週期性地從檔案或 URL 拉取配置更新,以避免依賴專門的協調服務。
--------
#### 將工作分配給節點 {#allocating-work-to-nodes}
當你有某個程序或服務的多個例項,且其中一個需要被選為主節點時,協調服務很有用。如果主節點失效,其他節點之一應當接管。這不僅適用於單主資料庫,也適用於作業排程器等有狀態系統。
另一個場景是:你有某種分片資源(資料庫、訊息流、檔案儲存、分散式 Actor 系統等),需要決定每個分片由哪個節點負責。隨著新節點加入叢集,需要把部分分片從舊節點遷移到新節點以實現再平衡;當節點被移除或失效時,其他節點需要接手其工作。
這類任務可以透過協調服務中的原子操作、臨時節點和通知機制配合完成。若實現得當,應用可以在無人值守的情況下自動從故障中恢復。即使有 Apache Curator 這類在 ZooKeeper 客戶端 API 上封裝的高階庫,這件事仍不容易;但它仍遠好於從零實現共識演算法,後者極易引入缺陷。
專用協調服務還有一個優勢:無論被協調系統有多少節點,協調服務本身通常都只需執行在一組固定節點上(常見是 3 個或 5 個)。例如,一個擁有數千分片的儲存系統若在數千節點上直接跑共識會非常低效;把共識“外包”給少量協調服務節點通常更合理。
通常協調服務管理的資料變化頻率不高例如“IP 為 10.1.1.23 的節點當前是分片 7 的主節點”這類資訊,更新週期往往是分鐘級或小時級。協調服務不適合儲存每秒變化數千次的資料。對於高頻變化資料,應該使用常規資料庫;或者使用 Apache BookKeeper [^90] [^91] 這類工具複製服務內部的快速變化狀態。
#### 服務發現 {#service-discovery}
ZooKeeper、etcd 和 Consul 也常用於 *服務發現*:即確定連線某個服務所需的 IP 地址(見 ["負載均衡、服務發現和服務網格"](/tw/ch5#sec_encoding_service_discovery))。在雲環境下,虛擬機器常常頻繁上下線,因此你通常無法預先知道服務地址。常見做法是讓服務啟動時把自身網路端點註冊到服務登錄檔,再供其他服務查詢。
用協調服務做服務發現很方便,因為它的故障檢測和變更通知能讓客戶端及時跟蹤服務例項的增減。而且如果你本來就用協調服務做租約、鎖或主節點選舉,那麼繼續複用它做服務發現通常也很自然,因為它已經知道哪個節點應該接收請求。
不過,對服務發現使用共識往往有些“殺雞用牛刀”:這個場景通常不要求線性一致性,更重要的是高可用和低延遲,因為沒有服務發現,整個系統都會停滯。因此通常更傾向於快取服務發現結果,並接受其可能略有陳舊。比如基於 DNS 的服務發現,就是透過多層快取來獲得良好的效能與可用性。
為支援這類需求ZooKeeper 提供了 *observer*(觀察者)節點:它接收日誌並維護一份 ZooKeeper 資料副本,但不參與共識投票。來自 observer 的讀取不具備線性一致性(可能陳舊),但即使網路中斷仍然可用,並且能透過快取提高系統可支援的讀吞吐量。
## 總結 {#summary}
@ -676,24 +733,23 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
原子事務提交
: 參與分散式事務的資料庫節點必須都以相同的方式 **決定** 是提交還是中止事務。
線性一致的 fetch-and-add 操作
: 這個操作可以用來實現 ID 生成器。多個節點可以併發呼叫該操作,它 **決定** 它們遞增計數器的順序。這種情況實際上只解決了兩個節點之間的共識,而其他適用於任意數量的節點。
線性一致的獲取並增加操作
: 這個操作可以用來實現 ID 生成器。多個節點可以併發呼叫該操作,它 **決定** 它們遞增計數器的順序。這種情況實際上只解決了兩個節點之間的共識,而其他情況適用於任意數量的節點。
如果你只有一個節點,或者如果你願意將決策能力分配給單個節點,所有這些都是簡單的。這就是單領導者資料庫中發生的事情:所有的決策權都授予了領導者,這就是為什麼這樣的資料庫能夠提供線性一致的操作、唯一性約束、複製日誌等等
如果你只有一個節點,或者願意把決策能力交給單個節點,所有這些都很簡單。這就是單主資料庫中發生的事情:所有決策權都授予主節點,這也是這類資料庫能夠提供線性一致操作、唯一性約束和複製日誌等能力的原因
然而,如果那個單一的領導者失敗,或者如果網路中斷使領導者無法訪問,這樣的系統就無法取得任何進展,直到人工執行手動故障轉移。廣泛使用的共識演算法如 Raft 和 Paxos 本質上是帶有內建自動領導者選舉和故障轉移的單領導者複製(如果當前領導者失敗)
然而,如果這個單一主節點失效或者網路中斷使其不可達這樣的系統就無法繼續推進直到人工完成手動故障切換。Raft 和 Paxos 等廣泛使用的共識演算法,本質上就是內建自動主節點選舉與故障切換的“單主複製”
共識演算法經過精心設計,以確保在故障轉移期間不會丟失任何已提交的寫入,並且系統不會進入腦裂狀態(多個節點接受寫入)。這要求每個寫入和每個線性一致的讀取都由節點的仲裁(通常是多數)確認。這可能是昂貴的,特別是跨地理區域,但如果你想要共識提供的強一致性和容錯性,這是不可避免的。
像 ZooKeeper 和 etcd 這樣的協調服務也是建立在共識演算法之上的。它們提供鎖、租約、故障檢測和變更通知功能,這些功能對於管理分散式應用程式的狀態很有用。如果你發現自己想要做那些可以歸約為共識的事情之一,並且你希望它是容錯的,建議使用協調服務。它不會保證你做對,但它可能會有所幫助。
共識演算法是複雜而微妙的,但它們得到了自 1980 年代以來發展起來的豐富理論體系的支援。這個理論使得構建能夠容忍我們在[第 9 章](/tw/ch9#ch_distributed)中討論的所有故障的系統成為可能,同時仍然確保你的資料不會損壞。這是一個了不起的成就,本章末尾的參考文獻展示了這項工作的一些亮點
共識演算法複雜而微妙,但其背後有自 1980 年代以來形成的豐富理論體系支援。正是這些理論,使我們能夠構建出能夠容忍 [第九章](/tw/ch9#ch_distributed) 所述故障、同時仍保證資料不被破壞的系統。這是分散式系統工程中的重要成就,本章末尾參考文獻展示了其中一些關鍵工作
然而,共識並不總是正確的工具:在某些系統中,不需要它提供的強一致性屬性,使用較弱的一致性以獲得更高的可用性和更好的效能會更好。在這些情況下,通常使用無領導者或多領導者複製,這是我們之前在[第 6 章](/tw/ch6#ch_replication)中討論過的。我們在本章中討論的邏輯時鐘在那種情況下是有幫助的
然而,共識並不總是正確的工具:在某些系統中,不需要它提供的強一致性屬性,使用較弱一致性來換取更高可用性和更好效能反而更合適。在這些場景下,通常會使用無主或多主複製,這也是我們之前在 [第六章](/tw/ch6#ch_replication) 討論過的內容。我們在本章討論的邏輯時鐘在那類場景中也很有幫助
### 參考文獻
[^1]: Maurice P. Herlihy and Jeannette M. Wing. [Linearizability: A Correctness Condition for Concurrent Objects](https://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf). *ACM Transactions on Programming Languages and Systems* (TOPLAS), volume 12, issue 3, pages 463492, July 1990. [doi:10.1145/78969.78972](https://doi.org/10.1145/78969.78972)
[^2]: Leslie Lamport. [On interprocess communication](https://www.microsoft.com/en-us/research/publication/interprocess-communication-part-basic-formalism-part-ii-algorithms/). *Distributed Computing*, volume 1, issue 2, pages 77101, June 1986. [doi:10.1007/BF01786228](https://doi.org/10.1007/BF01786228)
[^3]: David K. Gifford. [Information Storage in a Decentralized Computer System](https://bitsavers.org/pdf/xerox/parc/techReports/CSL-81-8_Information_Storage_in_a_Decentralized_Computer_System.pdf). Xerox Palo Alto Research Centers, CSL-81-8, June 1981. Archived at [perma.cc/2XXP-3JPB](https://perma.cc/2XXP-3JPB)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

260
content/tw/ch14.md Normal file
View file

@ -0,0 +1,260 @@
---
title: "14. 將事情做正確"
weight: 314
breadcrumbs: false
---
<a id="ch_right_thing"></a>
![](/map/ch13.png)
> *將世界的美好、醜陋與殘酷一起餵給 AI卻期待它只反映美好的一面這是一種幻想。*
>
> Vinay Uday Prabhu 與 Abeba Birhane《Large Datasets: A Pyrrhic Win for Computer Vision?》2020
在本書最後一章,讓我們退一步看問題。整本書裡,我們考察了各種資料系統架構,評估了它們的利弊,也探討了如何構建可靠、可伸縮、可維護的應用。然而,我們一直略去了討論中一個重要而基礎的部分,現在該補上了。
每個系統都是為了某種目的而建;我們做的每個動作,都有預期後果,也有非預期後果。目的可能只是賺錢,但對世界產生的影響可能遠遠超出這個初始目的。構建這些系統的工程師,有責任認真思考這些後果,並且有意識地決定我們希望生活在怎樣的世界中。
我們常把資料當成抽象事物來談論,但請記住,許多資料集都是關於人的:他們的行為、興趣、身份。我們必須以人性與尊重來對待這樣的資料。使用者也是人,而人的尊嚴至高無上 [^1]。
軟體開發越來越涉及重要的倫理抉擇。確實有一些指南幫助軟體工程師應對這些問題,比如 ACM《倫理與職業行為準則》 [^2],但在實踐中,它們很少被討論、應用與執行。因此,工程師和產品經理有時會對隱私以及產品可能帶來的負面後果抱持一種輕率態度 [^3], [^4]。
技術本身並無善惡,關鍵在於它如何被使用,以及它如何影響人。這一點對搜尋引擎這樣的軟體系統成立,對槍支這樣的武器同樣成立。軟體工程師若只專注技術本身而忽視其後果,是不夠的:倫理責任同樣由我們承擔。倫理推理很難,但它又重要到不能迴避。
不過,什麼算“好”或“壞”並沒有清晰定義,而計算領域的大多數人甚至不討論這個問題 [^5]。與計算領域中的很多概念不同,倫理的核心概念並沒有嚴格且確定的單一含義,它們需要解釋,而解釋可能具有主觀性 [^6]。倫理並不是走一遍檢查清單、確認你“合規”就完事;它是一種參與式、迭代式的反思過程,要與相關人群對話,並對結果負責 [^7]。
## 預測分析 {#id369}
例如,預測分析是人們對大資料和 AI 感到興奮的重要原因之一。用資料分析來預測天氣或疾病傳播是一回事 [^8];預測一個罪犯是否可能再犯、一個貸款申請者是否可能違約,或一個保險客戶是否可能提出高額理賠,又是另一回事 [^9]。後者會直接影響個人的生活。
支付網路當然想防止欺詐交易,銀行想避免壞賬,航空公司想避免劫機,公司想避免僱到低效或不可信的人。從它們的角度看,錯過一筆業務機會的成本較低,而壞賬或問題員工的成本更高,因此機構傾向於謹慎行事完全可以理解。拿不準時,說“不”更穩妥。
然而,隨著演算法決策越來越普遍,一個被某個演算法標記為“高風險”的人(不管標記準確與否),可能會不斷遭遇這種“不”。如果一個人系統性地被排除在工作、航空出行、保險保障、房屋租賃、金融服務以及社會其他關鍵領域之外,這對個體自由構成的約束之大,以至於有人稱之為“演算法監獄” [^10]。在尊重人權的國家,刑事司法講究“未經證明有罪即推定無罪”;但自動化系統卻可能在沒有罪證、幾乎無申訴機會的情況下,系統性且任意地把一個人排除在社會參與之外。
### 偏見與歧視 {#id370}
演算法作出的決策並不必然比人更好,也不必然更差。每個人都可能有偏見,即使他們主動嘗試糾偏也是如此;歧視性做法也可能被文化性地制度化。人們期待基於資料、而非基於人的主觀直覺評估來作決定,可能更公平,也能讓傳統系統中常被忽視的人獲得更好機會 [^11]。
當我們開發預測分析和 AI 系統時,我們並不只是把人的決策“自動化”——即用軟體寫明何時說“是”或“否”的規則;我們甚至把規則本身也交給資料去推斷。然而,這些系統學到的模式往往是不透明的:即使資料中存在某種相關性,我們也未必知道為什麼。如果演算法輸入中存在系統性偏差,系統很可能會在輸出中學習並放大這種偏差 [^12]。
在許多國家,反歧視法禁止依據族裔、年齡、性別、性取向、殘障或信仰等受保護特徵而區別對待他人。一個人的其他資料特徵也許可以分析,但如果這些特徵與受保護特徵相關怎麼辦?例如,在按種族隔離的社群裡,一個人的郵編,甚至其 IP 地址,都可能是種族的強預測因子。這樣一看,認為演算法能把帶偏見的資料作為輸入,卻產出公平中立的結果,幾乎是荒謬的 [^13], [^14]。然而,資料驅動決策的支持者常隱含這種信念,這種態度甚至被諷刺為“機器學習就像給偏見洗錢” [^15]。
預測分析系統只是在外推過去;如果過去有歧視,它們就會把歧視編碼並放大 [^16]。如果我們希望未來比過去更好,就需要道德想象力,而這隻能由人提供 [^17]。資料和模型應當是我們的工具,而不是我們的主宰。
### 責任與問責 {#id371}
自動化決策把責任與問責問題擺到了臺前 [^17]。如果人犯了錯,可以追責,受影響者也可以申訴。演算法同樣會出錯,但如果演算法出了問題,誰來負責 [^18]?自動駕駛汽車造成事故,誰應承擔責任?自動化信用評分演算法如果系統性歧視某一族裔或宗教的人,受害者是否有救濟途徑?如果你的機器學習系統的決策受到司法審查,你能向法官解釋演算法是如何作出該決策的嗎?人不應透過“怪演算法”來逃避自己的責任。
信用評級機構是一個較早的先例:透過收集資料來對人作決策。糟糕的信用評分會讓生活變難,但至少信用分通常基於與借貸歷史直接相關的事實記錄,記錄中的錯誤也可以更正(儘管機構往往不會讓這件事變得容易)。相比之下,基於機器學習的評分演算法通常使用更廣泛的輸入且更不透明,使人更難理解某個具體決策是如何得出的,也更難判斷某人是否受到了不公平或歧視性對待 [^19]。
信用分回答的是“你過去行為如何?”;而預測分析通常基於“誰和你相似,以及像你這樣的人過去行為如何?”。把某人和“相似人群”類比,本質上就是在給人貼群體標籤,比如按居住地(這往往是種族和社會經濟階層的近似代理)來推斷。那被分錯桶的人怎麼辦?此外,如果決策因錯誤資料而出錯,幾乎不可能得到救濟 [^17]。
許多資料本質上是統計性的,這意味著即便總體機率分佈正確,具體個案也可能是錯的。比如,某國平均預期壽命是 80 歲,並不意味著你會在 80 歲生日那天去世。僅憑平均值和機率分佈,我們很難判斷某個具體個體會活到多少歲。同樣,預測系統的輸出是機率性的,在具體個案上完全可能出錯。
盲目相信資料在決策中的至高地位,不僅是錯覺,更是危險。隨著資料驅動決策越來越普遍,我們必須找到辦法讓演算法可問責、可透明,避免強化既有偏見,並在它們不可避免地犯錯時加以糾正。
我們還需要想辦法防止資料被用來傷害人,並實現其積極潛力。比如,分析可以揭示一個人財務和社會生活上的特徵。一方面,這種能力可以用於把援助精準地送到最需要的人手中。另一方面,它有時被掠奪性企業用來識別脆弱人群,並向其兜售高成本貸款、含金量極低的學歷專案等高風險產品 [^17], [^20]。
### 反饋迴路 {#id372}
即便在對人影響沒那麼立竿見影的預測應用中,比如推薦系統,我們也必須直面棘手問題。當服務越來越擅長預測使用者想看什麼內容時,它可能最終只向人們展示他們本就認同的觀點,形成迴音室,讓刻板印象、錯誤資訊和社會極化不斷滋生。我們已經看到社交媒體迴音室對選舉活動的影響。
當預測分析影響人的生活時,自我強化的反饋迴路會帶來尤其惡性的後果。比如,設想僱主用信用分來評估候選人。你原本是一個工作能力不錯、信用也不錯的人,但因某個無法控制的不幸事件突然陷入財務困境。賬單逾期後,你的信用分下降,找到工作的可能性也隨之下降。失業把你推向貧困,反過來讓你的評分更差,進一步降低就業機會 [^17]。這就是一種下行螺旋:有毒假設披著數學嚴謹與資料客觀的偽裝。
反饋迴路還有另一個例子:經濟學家發現,德國加油站引入演算法定價後,競爭反而減弱,消費者價格上升,因為演算法學會了“合謀” [^21]。
我們並不總能預測這些反饋迴路何時出現。不過,很多後果可以透過思考“整個系統”來預見(不僅是計算機化部分,還包括與系統互動的人)——這種方法稱為 **系統思維** [^22]。我們可以嘗試理解資料分析系統對不同行為、結構與特徵的響應。系統是在強化和放大人與人之間既有差異(例如讓富者更富、窮者更窮),還是在努力對抗不公?而且,即便出發點再好,我們也必須警惕非預期後果。
## 隱私與追蹤 {#id373}
除了預測分析的問題——也就是用資料自動化地對人作決策——資料收集本身也有倫理問題。收集資料的組織,與資料被收集的人之間,到底是什麼關係?
當系統只儲存使用者明確輸入的資料,因為使用者希望系統以某種方式儲存和處理它時,系統是在為使用者提供服務:使用者是客戶。但當用戶活動是在做其他事情時被“順帶”追蹤並記錄下來,這種關係就不那麼清晰了。服務不再只是執行使用者指令,而開始擁有自己的利益,而這種利益可能與使用者利益衝突。
行為資料追蹤已成為許多線上服務面向使用者功能的重要組成部分:追蹤搜尋結果點選有助於改進搜尋排序;推薦“喜歡 X 的人也喜歡 Y”可幫助使用者發現有趣且有用的內容A/B 測試與使用者流程分析可幫助改進使用者介面。這些功能都需要一定程度的使用者行為追蹤,使用者也能從中受益。
然而,取決於公司的商業模式,追蹤往往不會止步於此。如果服務靠廣告資助,那麼廣告主才是真正客戶,使用者利益就會退居次位。追蹤資料會變得更細、分析會更深入、資料會被長期保留,以便為營銷目的構建每個人的精細畫像。
這時,公司與被收集資料的使用者之間的關係,就開始顯著改變了。使用者得到“免費”服務,並被引導儘可能多地參與。對使用者的追蹤,主要服務的並不是這個個體,而是資助服務的廣告主需求。這樣的關係,用一個語義更陰暗的詞來描述更貼切:**監視**。
### 監視 {#id374}
做個思想實驗:把 *data* 一詞替換為 *surveillance*(監視),看看常見說法是否還那麼“好聽” [^23]。例如:“在我們這個監視驅動的組織中,我們收集即時監視流並存入監視倉庫。我們的監視科學家使用先進的分析與監視處理來產出新洞見。”
這個思想實驗對本書來說少見地帶有一點論戰色彩,彷彿書名成了《設計監視密集型應用》(*Designing Surveillance-Intensive Applications*)。但為了強調這一點,我們需要更尖銳的詞。在我們試圖讓軟體“吞噬世界” [^24] 的過程中,我們構建了人類有史以來規模最大的群體監視基礎設施。我們正快速接近這樣一個世界:幾乎每個有人居住的空間都至少有一個聯網麥克風,存在於智慧手機、智慧電視、語音助手裝置、嬰兒監視器,甚至使用雲語音識別的兒童玩具中。許多這類裝置的安全記錄都非常糟糕 [^25]。
與過去相比,新變化在於:數字化讓大規模收集人的資料變得很容易。對我們位置與行動軌跡、社交關係與通訊、購買與支付、健康資訊的監視,幾乎已不可避免。一個監視型組織最終掌握的個人資訊,甚至可能比當事人自己知道的還多——例如,在當事人意識到之前就識別出其疾病或經濟困境。
即便是過去最極權、最壓迫的政權,也只能夢想把麥克風裝進每個房間,並迫使每個人隨身攜帶可追蹤其位置與行動的裝置。可是,由於數字技術帶來的好處太大,我們如今卻自願接受這個全面監視的世界。區別只在於:資料由企業收集以向我們提供服務,而不是由政府機構為控制目的而收集 [^26]。
並非所有資料收集都一定構成監視,但把它放在“監視”的框架下審視,有助於我們理解自己與資料收集者的關係。為什麼我們似乎樂於接受企業監視?也許你覺得自己“沒什麼可隱瞞”——換句話說,你與既有權力結構完全一致,不是邊緣少數群體,也無需擔心被迫害 [^27]。但不是每個人都這麼幸運。又或者,你覺得目的似乎是善意的——不是公開的強制和馴化,而只是更好的推薦與更個性化的營銷。然而,結合上一節對預測分析的討論,這種區分就沒那麼清楚了。
我們已經看到,汽車在未經駕駛員同意的情況下追蹤其駕駛行為,並影響保險費率 [^28];也看到了與佩戴健身追蹤裝置繫結的健康保險保障。當監視被用於決定對生活關鍵方面有重大影響的事項(如保險保障或就業)時,它看起來就不再“無害”。而且,資料分析還能揭示極具侵入性的內容:例如,智慧手錶或健身手環裡的運動感測器可以以相當高的準確率推斷你在輸入什麼(包括密碼) [^29]。感測器精度和分析演算法只會越來越強。
### 同意與選擇自由 {#id375}
我們或許會主張,使用者是自願選擇使用會追蹤其活動的服務,並且他們同意了服務條款和隱私政策,因此他們已同意資料收集。我們甚至可能聲稱,使用者正以其提供的資料換取有價值的服務,而追蹤是提供該服務所必需的。毫無疑問,社交網路、搜尋引擎和各種其他免費線上服務確實對使用者有價值——但這個論證有問題。
首先,我們應當問:追蹤在哪種意義上是“必要的”?有些追蹤形式確實直接用於改進使用者功能:例如,追蹤搜尋結果點選率可提升搜尋排序與相關性;追蹤客戶常一起購買哪些商品,可幫助網店推薦關聯商品。然而,當追蹤使用者互動是為了內容推薦,或為了廣告構建使用者畫像時,這是否真正在使用者利益之中就不那麼清楚了——還是說,它“必要”僅僅因為廣告在為服務買單?
其次,使用者對自己向我們的資料庫“喂入”了哪些資料、這些資料如何被保留與處理,幾乎沒有認知——而多數隱私政策更多是在遮蔽而非闡明。使用者若不瞭解其資料會發生什麼,就無法給出有意義的同意。並且,某個使用者的資料往往也會揭示並非該服務使用者、也未同意任何條款的其他人。我們在本書這部分討論過的那些派生資料集——其中可能把全體使用者資料與行為追蹤及外部資料來源結合——正是使用者不可能形成有意義理解的資料型別。
此外,資料從使用者身上被抽取是單向過程,不是具有真實互惠的關係,也不是公平的價值交換。這裡沒有對話,沒有讓使用者協商“提供多少資料、換取什麼服務”的空間:服務與使用者之間的關係高度不對稱、單向度。規則由服務制定,而非使用者 [^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]。
你可能會說,不同意被監視的使用者可以選擇不用這項服務。但這種選擇同樣不自由:如果某項服務流行到“被大多數人視為基本社會參與所必需” [^30],那就不能合理期待人們退出——使用它在事實上成了強制(*de facto* mandatory。例如在多數西方社群中攜帶智慧手機、透過社交網路社交、使用 Google 獲取資訊,已經成為常態。尤其當服務具有網路效應時,選擇 *不* 使用它會付出社會成本。
因為追蹤政策而拒絕使用某服務,說起來容易做起來難。這些平臺本來就是為吸引使用者而設計的。許多平臺使用遊戲機制和賭博常見策略來讓使用者反覆回來 [^34]。即便使用者能克服這一點,拒絕參與也往往只是少數特權人群的選項:他們有時間和知識去理解隱私政策,也有能力承擔潛在代價——比如錯過本可透過該服務獲得的社會參與或職業機會。對於處境更不利的人來說,並不存在真正意義上的選擇自由:監視變得無可逃避。
### 隱私與資料使用 {#id457}
有時有人聲稱“隱私已死”,理由是某些使用者願意在社交媒體上釋出各種生活內容,有些瑣碎,有些極度私密。但這個說法是錯誤的,它建立在對 *privacy* 一詞的誤解之上。
擁有隱私並不意味著把一切都保密;它意味著擁有選擇自由:哪些內容向誰披露、哪些公開、哪些保密。隱私權是一種決策權:它讓每個人在每種情境中,決定自己在“保密”與“透明”光譜上的位置 [^30]。這是個體自由與自主性的重要組成部分。
例如,一個患有罕見疾病的人,可能非常願意把其私密醫療資料提供給研究者,只要這有助於開發治療方法。但關鍵在於,這個人應當有權選擇誰可以訪問這些資料,以及出於什麼目的。如果其病情資訊可能損害其醫療保險、就業或其他重要權益,這個人很可能會更謹慎地共享資料。
當資料透過監視基礎設施從人們身上被抽取時,被侵蝕的未必是隱私權本身,而可能是隱私權的轉移:轉移給資料收集者。獲取資料的公司本質上是在說“相信我們會正確使用你的資料”,這意味著決定“披露什麼、保密什麼”的權利,從個人轉移到了公司。
這些公司反過來會把監視結果中的很大一部分保密,因為一旦公開,會讓人感到毛骨悚然,並傷害其商業模式(該模式依賴於“比其他公司更瞭解你”)。關於使用者的私密資訊通常只以間接方式被暴露,例如透過向特定人群(如患有某種疾病的人)定向投放廣告的工具表現出來。
即便特定使用者無法從某條廣告所面向的人群桶中被個人重識別,他們仍失去了對某些私密資訊披露的主導權。決定“向誰披露什麼”不再基於使用者自己的偏好,而是公司在行使這種隱私權,目標是利潤最大化。
許多公司追求的目標是“不被 *感知* 為令人不適”,迴避“資料收集到底有多侵入”這一問題,轉而專注於管理使用者感知。而且就連這種感知管理也常常做得不好:例如,某些內容也許在事實層面是正確的,但若會觸發痛苦記憶,使用者可能並不想被提醒 [^35]。面對任何資料,我們都應預期它可能出錯、不可取或在某些情況下不合適,並且需要構建機制來處理這些失效。至於什麼算“不可取”或“不合適”,當然屬於人的判斷;演算法除非被我們顯式程式設計去尊重人的需要,否則對這些概念是無感的。作為這些系統的工程師,我們必須保持謙遜,接受並預先規劃這些失效。
線上服務裡的隱私設定,允許使用者控制其資料的哪些方面可被其他使用者看到,這是把部分控制權還給使用者的起點。然而,不管設定如何,服務本身仍可不受限制地訪問這些資料,並可在隱私政策允許範圍內任意使用。即使服務承諾不把資料出售給第三方,通常也會賦予自己在內部處理和分析資料的廣泛權利,而這種處理常常遠遠超出使用者可見範圍。
這種把隱私權從個人大規模轉移到企業的現象,在歷史上前所未有 [^30]。監視並非從未存在,但過去它昂貴且依賴人工,不具備自動化與可伸縮性。信任關係也一直存在,比如病人與醫生、被告與律師之間——但這些關係中的資料使用長期受倫理、法律與監管約束。網際網路服務則讓“在缺乏有意義同意的情況下聚合海量敏感資訊,並在使用者不知情時以大規模方式使用”變得容易得多。
### 資料作為資產與權力 {#id376}
由於行為資料是使用者與服務互動的副產物,它有時被稱為 “data exhaust”資料尾氣暗示這些資料是無價值的廢料。照這個角度看行為分析與預測分析像一種“回收”從原本會被丟棄的資料中提煉價值。
更準確的看法可能正相反:從經濟學角度看,如果定向廣告在為服務買單,那麼生成行為資料的使用者活動就可被視作一種勞動 [^36]。甚至可以更進一步主張:使用者互動的應用本身,只是引誘使用者不斷向監視基礎設施輸入更多個人資訊的手段 [^30]。線上服務中常見的人類創造力與社會關係,被資料抽取機器以冷酷方式利用。
個人資料是有價值資產,這從資料經紀商行業的存在即可見一斑:這是一個在隱秘中運作、頗為灰暗的行業,購買、聚合、分析、推斷並轉售關於個人的侵入性資料,多數用於營銷 [^20]。初創公司的估值常以使用者數、以“眼球”為基礎——也就是以其監視能力為基礎。
因為資料有價值,很多人都想要它。公司當然想要——這本就是它們收集資料的原因。政府也想拿到:透過秘密交易、脅迫、法律強制,或者直接竊取 [^37]。當公司破產時,其收集的個人資料會作為資產被出售。並且,資料很難徹底保護,洩露事件頻發得令人不安。
這些觀察促使批評者說,資料不只是資產,還是“有毒資產”(*toxic asset* [^37],或者至少是“危險材料”(*hazardous material* [^38]。也許資料不是“新黃金”、不是“新石油”,而是“新鈾” [^39]。即使我們認為自己有能力防止資料濫用,每次收集資料時也必須權衡收益與其落入錯誤之手的風險:計算機系統可能被犯罪分子或敵對外國情報機構攻破,資料可能被內部人員洩露,公司可能落入與我們價值觀不一致的管理層手中,或國家可能被一個毫無顧忌、會強迫我們交出資料的政權接管。
收集資料時,我們不僅要考慮今天的政治環境,還要考慮未來所有可能的政府。無法保證未來每一屆政府都會尊重人權與公民自由,因此,“安裝那些未來可能助長警察國家的技術,是糟糕的公民衛生習慣” [^40]。
正如古老格言所說,“知識就是力量”。而且,“審視他人而避免自身被審視,是最重要的權力形式之一” [^41]。這正是極權政府追求監視的原因:它賦予其控制人口的力量。今天的科技公司雖未公開追求政治權力,但它們積累的資料與知識依然賦予其對我們生活的巨大影響力,其中很多是隱蔽的,處在公共監督之外 [^42]。
### 回顧工業革命 {#id377}
資料是資訊時代的決定性特徵。網際網路、資料儲存與處理、軟體驅動自動化,正在深刻影響全球經濟和人類社會。我們的日常生活與社會組織已被資訊科技改變,並且在未來幾十年很可能繼續發生劇烈變化,這很容易讓人聯想到工業革命 [^17], [^26]。
工業革命建立在重大技術與農業進步之上,長期看帶來了持續經濟增長和生活水平顯著改善。但它也伴隨嚴重問題:空氣汙染(煙塵與化工過程)和水汙染(工業與生活廢棄物)都觸目驚心。工廠主生活奢華,城市工人卻常住在惡劣住房裡、長時間在嚴苛條件下勞動。童工普遍存在,包括礦井中危險且低薪的工作。
社會花了很長時間才建立起各種防護措施:環境保護法規、工作場所安全規程、取締童工、食品衛生檢查。毫無疑問,當工廠不再被允許把廢棄物排進河裡、售賣汙染食品、剝削工人時,做生意的成本上升了。但整個社會從這些規制中獲益巨大,今天幾乎沒人願意回到那之前 [^17]。
正如工業革命有其需要被管理的黑暗面一樣,我們向資訊時代的過渡也有重大問題,必須正視並解決 [^43], [^44]。資料的收集與使用就是其中之一。借用 Bruce Schneier 的話 [^26]
> 資料是資訊時代的汙染問題,而保護隱私是環境挑戰。幾乎所有計算機都會產生資訊。它會長期滯留、不斷髮酵。我們如何處理它——如何圍堵它、如何處置它——對資訊經濟的健康至關重要。正如今天我們回望工業時代的早期幾十年,會疑惑我們的祖先為何在建設工業世界的狂熱中忽視了汙染問題;我們的後代也將回望資訊時代的這些早期幾十年,並以我們如何應對資料收集與濫用的挑戰來評判我們。
>
> 我們應努力讓他們感到驕傲。
### 立法與自律 {#sec_future_legislation}
資料保護法也許能夠幫助維護個體權利。例如,歐盟 GDPR 規定,個人資料必須“為特定、明確且合法的目的而收集,不得以與這些目的不相容的方式進一步處理”;並且資料必須“就處理目的而言充分、相關且限於必要範圍” [^32]。
然而,這一 **資料最小化** 原則與大資料哲學正面衝突。大資料強調最大化資料收集,把資料與其他資料集合並,持續實驗與探索,以產生新洞見。探索意味著為預見之外的目的使用資料,這與“特定且明確目的”正相反。儘管 GDPR 對線上廣告行業產生了一些影響 [^45],監管執行總體仍偏弱 [^46],也似乎沒有在更廣泛的科技行業內真正帶來文化與實踐層面的顯著轉變。
那些收集大量個人資料的公司把監管視為負擔和創新阻礙。這種反對在某種程度上也有其合理性。比如共享醫療資料時,隱私風險確實明確存在,但也有潛在機會:如果資料分析能幫助我們實現更好的診斷或找到更好的治療方案,能減少多少死亡 [^47]?過度監管可能會阻礙這類突破。如何平衡機會與風險並不容易 [^41]。
歸根結底,科技行業需要在個人資料問題上完成一次文化轉向。我們應停止把使用者當作可最佳化指標,記住他們是應被尊重、擁有尊嚴與主體性的人。我們應透過自律來約束資料收集與處理實踐,以建立並維繫依賴我們軟體的人們的信任 [^48]。並且,我們應主動教育終端使用者其資料如何被使用,而不是把他們矇在鼓裡。
我們應允許每個個體保有其隱私——也就是對自身資料的控制——而不是透過監視把這種控制偷走。個體對自身資料的控制權,就像國家公園中的自然環境:如果我們不明確保護並照料它,它就會被破壞。這會成為“公地悲劇”,最終所有人都更糟。無處不在的監視並非命中註定——我們仍有機會阻止它。
第一步是不要無限期保留資料,而應在不再需要時儘快清除,並在源頭最小化收集 [^48], [^49]。只要你的資料不存在,它就不會被洩露、被盜,或被政府強制交出。總的來說,這需要文化與態度的改變。作為技術從業者,如果我們不考慮自己工作的社會影響,那就是沒有盡到本職 [^50]。
## 總結 {#id594}
至此,本書接近尾聲。我們已經走過了很長一段路:
- 在 [第 1 章](/tw/ch1#ch_tradeoffs) 中,我們對比了分析型系統與事務型系統,比較了雲與自託管,權衡了分散式與單節點系統,並討論了如何平衡業務需求與使用者需求。
- 在 [第 2 章](/tw/ch2#ch_nonfunctional) 中,我們看到了如何定義非功能性需求,例如效能、可靠性、可伸縮性與可維護性。
- 在 [第 3 章](/tw/ch3#ch_datamodels) 中,我們考察了從關係模型、文件模型到圖模型的一系列資料模型,也討論了事件溯源與 DataFrame。我們還看了多種查詢語言示例包括 SQL、Cypher、SPARQL、Datalog 與 GraphQL。
- 在 [第 4 章](/tw/ch4#ch_storage) 中,我們討論了面向 OLTP 的儲存引擎LSM 樹與 B 樹)、面向分析的儲存(列式儲存),以及面向資訊檢索的索引(全文檢索與向量檢索)。
- 在 [第 5 章](/tw/ch5#ch_encoding) 中,我們考察了將資料物件編碼為位元組的不同方式,以及如何在需求變化時支援演化。我們還比較了程序間資料流動的幾種方式:經由資料庫、服務呼叫、工作流引擎或事件驅動架構。
- 在 [第 6 章](/tw/ch6#ch_replication) 中,我們研究了單領導者、多領導者與無主(無領導者)複製之間的權衡,也討論了寫後讀一致性等一致性模型,以及可讓客戶端離線工作的同步引擎。
- 在 [第 7 章](/tw/ch7#ch_sharding) 中,我們深入討論了分片,包括再平衡策略、請求路由與次級索引。
- 在 [第 8 章](/tw/ch8#ch_transactions) 中,我們覆蓋了事務:永續性、各種隔離級別(讀已提交、快照隔離、可序列化)的實現方式,以及如何在分散式事務中保證原子性。
- 在 [第 9 章](/tw/ch9#ch_distributed) 中,我們梳理了分散式系統中的基礎問題(網路失效與延遲、時鐘誤差、程序暫停、崩潰),並看到這些問題如何讓“實現一個看似簡單的鎖”都變得困難。
- 在 [第 10 章](/tw/ch10#ch_consistency) 中,我們深入分析了各種共識形式,以及它所支援的一致性模型(線性一致性)。
- 在 [第 11 章](/tw/ch11#ch_batch) 中,我們深入批處理,從簡單的 Unix 工具鏈一直講到基於分散式檔案系統或物件儲存的大規模分散式批處理系統。
- 在 [第 12 章](/tw/ch12#ch_stream) 中,我們把批處理推廣到流處理,討論了底層訊息代理、資料變更捕獲、容錯機制,以及流連線等處理模式。
- 在 [第 13 章](/tw/ch13#ch_philosophy) 中,我們探討了流式系統的一種哲學,它使異構資料系統更易於整合、系統更易於演化、應用更易於擴充套件。
最後,在本章中,我們後退一步,審視了構建資料密集型應用的一些倫理面向。我們看到,資料雖可為善,也可能造成嚴重傷害:作出深刻影響個人生活卻難以申訴的決策,導致歧視與剝削,使監視常態化,並暴露私密資訊。我們還面臨資料洩露風險,也可能發現某些出於善意的資料使用產生了非預期後果。
隨著軟體與資料對世界產生如此巨大的影響,我們作為工程師必須記住:我們有責任朝著我們希望生活其中的世界努力——一個以人性與尊重對待人的世界。讓我們共同朝這個目標前進。
### 參考文獻 {#references}
[^1]: David Schmudde. [What If Data Is a Bad Idea?](https://schmud.de/posts/2024-08-18-data-is-a-bad-idea.html). *schmud.de*, August 2024. Archived at [perma.cc/ZXU5-XMCT](https://perma.cc/ZXU5-XMCT)
[^2]: [ACM Code of Ethics and Professional Conduct](https://www.acm.org/code-of-ethics). Association for Computing Machinery, *acm.org*, 2018. Archived at [perma.cc/SEA8-CMB8](https://perma.cc/SEA8-CMB8)
[^3]: Igor Perisic. [Making Hard Choices: The Quest for Ethics in Machine Learning](https://www.linkedin.com/blog/engineering/archive/making-hard-choices-the-quest-for-ethics-in-machine-learning). *linkedin.com*, November 2016. Archived at [perma.cc/DGF8-KNT7](https://perma.cc/DGF8-KNT7)
[^4]: John Naughton. [Algorithm Writers Need a Code of Conduct](https://www.theguardian.com/commentisfree/2015/dec/06/algorithm-writers-should-have-code-of-conduct). *theguardian.com*, December 2015. Archived at [perma.cc/TBG2-3NG6](https://perma.cc/TBG2-3NG6)
[^5]: Ben Green. ["Good" isn't good enough](https://www.benzevgreen.com/wp-content/uploads/2019/11/19-ai4sg.pdf). At *NeurIPS Joint Workshop on AI for Social Good*, December 2019. Archived at [perma.cc/H4LN-7VY3](https://perma.cc/H4LN-7VY3)
[^6]: Deborah G. Johnson and Mario Verdicchio. [Ethical AI is Not about AI](https://cacm.acm.org/opinion/ethical-ai-is-not-about-ai/). *Communications of the ACM*, volume 66, issue 2, pages 32--34, January 2023. [doi:10.1145/3576932](https://doi.org/10.1145/3576932)
[^7]: Marc Steen. [Ethics as a Participatory and Iterative Process](https://cacm.acm.org/opinion/ethics-as-a-participatory-and-iterative-process/). *Communications of the ACM*, volume 66, issue 5, pages 27--29, April 2023. [doi:10.1145/3550069](https://doi.org/10.1145/3550069)
[^8]: Logan Kugler. [What Happens When Big Data Blunders?](https://cacm.acm.org/news/what-happens-when-big-data-blunders/) *Communications of the ACM*, volume 59, issue 6, pages 15--16, June 2016. [doi:10.1145/2911975](https://doi.org/10.1145/2911975)
[^9]: Miri Zilka. [Algorithms and the criminal justice system: promises and challenges in deployment and research](https://www.cl.cam.ac.uk/research/security/seminars/archive/video/2023-03-07-t196231.html). At *University of Cambridge Security Seminar Series*, March 2023.
[^10]: Bill Davidow. [Welcome to Algorithmic Prison](https://www.theatlantic.com/technology/archive/2014/02/welcome-to-algorithmic-prison/283985/). *theatlantic.com*, February 2014. Archived at [archive.org](https://web.archive.org/web/20171019201812/https://www.theatlantic.com/technology/archive/2014/02/welcome-to-algorithmic-prison/283985/)
[^11]: Don Peck. [They're Watching You at Work](https://www.theatlantic.com/magazine/archive/2013/12/theyre-watching-you-at-work/354681/). *theatlantic.com*, December 2013. Archived at [perma.cc/YR9T-6M38](https://perma.cc/YR9T-6M38)
[^12]: Leigh Alexander. [Is an Algorithm Any Less Racist Than a Human?](https://www.theguardian.com/technology/2016/aug/03/algorithm-racist-human-employers-work) *theguardian.com*, August 2016. Archived at [perma.cc/XP93-DSVX](https://perma.cc/XP93-DSVX)
[^13]: Jesse Emspak. [How a Machine Learns Prejudice](https://www.scientificamerican.com/article/how-a-machine-learns-prejudice/). *scientificamerican.com*, December 2016. [perma.cc/R3L5-55E6](https://perma.cc/R3L5-55E6)
[^14]: Rohit Chopra, Kristen Clarke, Charlotte A. Burrows, and Lina M. Khan. [Joint Statement on Enforcement Efforts Against Discrimination and Bias in Automated Systems](https://www.ftc.gov/system/files/ftc_gov/pdf/EEOC-CRT-FTC-CFPB-AI-Joint-Statement%28final%29.pdf). *ftc.gov*, April 2023. Archived at [perma.cc/YY4Y-RCCA](https://perma.cc/YY4Y-RCCA)
[^15]: Maciej Cegłowski. [The Moral Economy of Tech](https://idlewords.com/talks/sase_panel.htm). *idlewords.com*, June 2016. Archived at [perma.cc/L8XV-BKTD](https://perma.cc/L8XV-BKTD)
[^16]: Greg Nichols. [Artificial Intelligence in healthcare is racist](https://www.zdnet.com/article/artificial-intelligence-in-healthcare-is-racist/). *zdnet.com*, November 2020. Archived at [perma.cc/3MKW-YKRS](https://perma.cc/3MKW-YKRS)
[^17]: Cathy O'Neil. *Weapons of Math Destruction: How Big Data Increases Inequality and Threatens Democracy*. Crown Publishing, 2016. ISBN: 978-0-553-41881-1
[^18]: Julia Angwin. [Make Algorithms Accountable](https://www.nytimes.com/2016/08/01/opinion/make-algorithms-accountable.html). *nytimes.com*, August 2016. Archived at [archive.org](https://web.archive.org/web/20230819055242/https://www.nytimes.com/2016/08/01/opinion/make-algorithms-accountable.html)
[^19]: Bryce Goodman and Seth Flaxman. [European Union Regulations on Algorithmic Decision-Making and a 'Right to Explanation'](https://arxiv.org/abs/1606.08813). At *ICML Workshop on Human Interpretability in Machine Learning*, June 2016. Archived at [arxiv.org/abs/1606.08813](https://arxiv.org/abs/1606.08813)
[^20]: [A Review of the Data Broker Industry: Collection, Use, and Sale of Consumer Data for Marketing Purposes](https://www.commerce.senate.gov/services/files/0d2b3642-6221-4888-a631-08f2f255b577). Staff Report, *United States Senate Committee on Commerce, Science, and Transportation*, *commerce.senate.gov*, December 2013. Archived at [perma.cc/32NV-YWLQ](https://perma.cc/32NV-YWLQ)
[^21]: Stephanie Assad, Robert Clark, Daniel Ershov, and Lei Xu. [Algorithmic Pricing and Competition: Empirical Evidence from the German Retail Gasoline Market](https://economics.yale.edu/sites/default/files/clark_acex_jan_2021.pdf). *Journal of Political Economy*, volume 132, issue 3, pages 723-771, March 2024. [doi:10.1086/726906](https://doi.org/10.1086/726906)
[^22]: Donella H. Meadows and Diana Wright. *Thinking in Systems: A Primer*. Chelsea Green Publishing, 2008. ISBN: 978-1-603-58055-7
[^23]: Daniel J. Bernstein. [Listening to a "big data"/"data science" talk. Mentally translating "data" to "surveillance": "\...everything starts with surveillance\..."](https://x.com/hashbreaker/status/598076230437568512) *x.com*, May 2015. Archived at [perma.cc/EY3D-WBBJ](https://perma.cc/EY3D-WBBJ)
[^24]: Marc Andreessen. [Why Software Is Eating the World](https://a16z.com/why-software-is-eating-the-world/). *a16z.com*, August 2011. Archived at [perma.cc/3DCC-W3G6](https://perma.cc/3DCC-W3G6)
[^25]: J. M. Porup. ['Internet of Things' Security Is Hilariously Broken and Getting Worse](https://arstechnica.com/information-technology/2016/01/how-to-search-the-internet-of-things-for-photos-of-sleeping-babies/). *arstechnica.com*, January 2016. Archived at [archive.org](https://web.archive.org/web/20250823001716/https://arstechnica.com/information-technology/2016/01/how-to-search-the-internet-of-things-for-photos-of-sleeping-babies/)
[^26]: Bruce Schneier. [*Data and Goliath: The Hidden Battles to Collect Your Data and Control Your World*](https://www.schneier.com/books/data_and_goliath/). W. W. Norton, 2015. ISBN: 978-0-393-35217-7
[^27]: The Grugq. [Nothing to Hide](https://grugq.tumblr.com/post/142799983558/nothing-to-hide). *grugq.tumblr.com*, April 2016. Archived at [perma.cc/BL95-8W5M](https://perma.cc/BL95-8W5M)
[^28]: Federal Trade Commission. [FTC Takes Action Against General Motors for Sharing Drivers' Precise Location and Driving Behavior Data Without Consent](https://www.ftc.gov/news-events/news/press-releases/2025/01/ftc-takes-action-against-general-motors-sharing-drivers-precise-location-driving-behavior-data). *ftc.gov*, January 2025. Archived at [perma.cc/3XGV-3HRD](https://perma.cc/3XGV-3HRD)
[^29]: Tony Beltramelli. [Deep-Spying: Spying Using Smartwatch and Deep Learning](https://arxiv.org/abs/1512.05616). Masters Thesis, IT University of Copenhagen, December 2015. Archived at *arxiv.org/abs/1512.05616*
[^30]: Shoshana Zuboff. [Big Other: Surveillance Capitalism and the Prospects of an Information Civilization](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2594754). *Journal of Information Technology*, volume 30, issue 1, pages 75--89, April 2015. [doi:10.1057/jit.2015.5](https://doi.org/10.1057/jit.2015.5)
[^31]: Michiel Rhoen. [Beyond Consent: Improving Data Protection Through Consumer Protection Law](https://policyreview.info/articles/analysis/beyond-consent-improving-data-protection-through-consumer-protection-law). *Internet Policy Review*, volume 5, issue 1, March 2016. [doi:10.14763/2016.1.404](https://doi.org/10.14763/2016.1.404)
[^32]: [Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016](https://eur-lex.europa.eu/eli/reg/2016/679/oj/eng). *Official Journal of the European Union*, L 119/1, May 2016.
[^33]: UK Information Commissioner's Office. [What is the 'legitimate interests' basis?](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/lawful-basis/legitimate-interests/what-is-the-legitimate-interests-basis/) *ico.org.uk*. Archived at [perma.cc/W8XR-F7ML](https://perma.cc/W8XR-F7ML)
[^34]: Tristan Harris. [How a handful of tech companies control billions of minds every day](https://www.ted.com/talks/tristan_harris_how_a_handful_of_tech_companies_control_billions_of_minds_every_day). At *TED2017*, April 2017.
[^35]: Carina C. Zona. [Consequences of an Insightful Algorithm](https://www.youtube.com/watch?v=YRI40A4tyWU). At *GOTO Berlin*, November 2016.
[^36]: Imanol Arrieta Ibarra, Leonard Goff, Diego Jiménez Hernández, Jaron Lanier, and E. Glen Weyl. [Should We Treat Data as Labor? Moving Beyond 'Free'](https://www.aeaweb.org/conference/2018/preliminary/paper/2Y7N88na). *American Economic Association Papers Proceedings*, volume 1, issue 1, December 2017.
[^37]: Bruce Schneier. [Data Is a Toxic Asset, So Why Not Throw It Out?](https://www.schneier.com/essays/archives/2016/03/data_is_a_toxic_asse.html) *schneier.com*, March 2016. Archived at [perma.cc/4GZH-WR3D](https://perma.cc/4GZH-WR3D)
[^38]: Cory Scott. [Data is not toxic - which implies no benefit - but rather hazardous material, where we must balance need vs. want](https://x.com/cory_scott/status/706586399483437056). *x.com*, March 2016. Archived at [perma.cc/CLV7-JF2E](https://perma.cc/CLV7-JF2E)
[^39]: Mark Pesce. [Data is the new uranium -- incredibly powerful and amazingly dangerous](https://www.theregister.com/2024/11/20/data_is_the_new_uranium/). *theregister.com*, November 2024. Archived at [perma.cc/NV8B-GYGV](https://perma.cc/NV8B-GYGV)
[^40]: Bruce Schneier. [Mission Creep: When Everything Is Terrorism](https://www.schneier.com/essays/archives/2013/07/mission_creep_when_e.html). *schneier.com*, July 2013. Archived at [perma.cc/QB2C-5RCE](https://perma.cc/QB2C-5RCE)
[^41]: Lena Ulbricht and Maximilian von Grafenstein. [Big Data: Big Power Shifts?](https://policyreview.info/articles/analysis/big-data-big-power-shifts) *Internet Policy Review*, volume 5, issue 1, March 2016. [doi:10.14763/2016.1.406](https://doi.org/10.14763/2016.1.406)
[^42]: Ellen P. Goodman and Julia Powles. [Facebook and Google: Most Powerful and Secretive Empires We've Ever Known](https://www.theguardian.com/technology/2016/sep/28/google-facebook-powerful-secretive-empire-transparency). *theguardian.com*, September 2016. Archived at [perma.cc/8UJA-43G6](https://perma.cc/8UJA-43G6)
[^43]: Judy Estrin and Sam Gill. [The World Is Choking on Digital Pollution](https://washingtonmonthly.com/2019/01/13/the-world-is-choking-on-digital-pollution/). *washingtonmonthly.com*, January 2019. Archived at [perma.cc/3VHF-C6UC](https://perma.cc/3VHF-C6UC)
[^44]: A. Michael Froomkin. [Regulating Mass Surveillance as Privacy Pollution: Learning from Environmental Impact Statements](https://repository.law.miami.edu/cgi/viewcontent.cgi?article=1062&context=fac_articles). *University of Illinois Law Review*, volume 2015, issue 5, August 2015. Archived at [perma.cc/24ZL-VK2T](https://perma.cc/24ZL-VK2T)
[^45]: Pengyuan Wang, Li Jiang, and Jian Yang. [The Early Impact of GDPR Compliance on Display Advertising: The Case of an Ad Publisher](https://openreview.net/pdf?id=TUnLHNo19S). *Journal of Marketing Research*, volume 61, issue 1, April 2023. [doi:10.1177/00222437231171848](https://doi.org/10.1177/00222437231171848)
[^46]: Johnny Ryan. [Don't be fooled by Meta's fine for data breaches](https://www.economist.com/by-invitation/2023/05/24/dont-be-fooled-by-metas-fine-for-data-breaches-says-johnny-ryan). *The Economist*, May 2023. Archived at [perma.cc/VCR6-55HR](https://perma.cc/VCR6-55HR)
[^47]: Jessica Leber. [Your Data Footprint Is Affecting Your Life in Ways You Can't Even Imagine](https://www.fastcompany.com/3057514/your-data-footprint-is-affecting-your-life-in-ways-you-cant-even-imagine). *fastcompany.com*, March 2016. Archived at [archive.org](https://web.archive.org/web/20161128133016/https://www.fastcoexist.com/3057514/your-data-footprint-is-affecting-your-life-in-ways-you-cant-even-imagine)
[^48]: Maciej Cegłowski. [Haunted by Data](https://idlewords.com/talks/haunted_by_data.htm). *idlewords.com*, October 2015. Archived at [archive.org](https://web.archive.org/web/20161130143932/https://idlewords.com/talks/haunted_by_data.htm)
[^49]: Sam Thielman. [You Are Not What You Read: Librarians Purge User Data to Protect Privacy](https://www.theguardian.com/us-news/2016/jan/13/us-library-records-purged-data-privacy). *theguardian.com*, January 2016. Archived at [archive.org](https://web.archive.org/web/20250828224851/https://www.theguardian.com/us-news/2016/jan/13/us-library-records-purged-data-privacy)
[^50]: Jez Humble. [It's a cliché that people get into tech to "change the world". So then, you have to actually consider what the impact of your work is on the world. The idea that you can or should exclude societal and political discussions in tech is idiotic. It means you're not doing your job](https://x.com/jezhumble/status/1386758340894597122). *x.com*, April 2021. Archived at [perma.cc/3NYS-MHLC](https://perma.cc/3NYS-MHLC)

View file

@ -4,32 +4,34 @@ weight: 102
breadcrumbs: false
---
<a id="ch_nonfunctional"></a>
![](/map/ch01.png)
> *網際網路做得如此之好,以至於大多數人都把它想象成像太平洋一樣的自然資源,而不是人造的東西。上一次出現這種規模且無差錯的技術是什麼時候?*
> *網際網路做得太好了,以至於大多數人把它看成像太平洋那樣的自然資源,而不是人造產物。上一次出現這種規模且幾乎無差錯的技術是什麼時候?*
>
> [艾倫・凱](https://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442)
> 在接受 *Dr Dobb's Journal* 採訪時2012 年)
如果你正在構建一個應用程式,你將會被一系列需求所驅動。在你的需求列表中,最重要的可能是應用程式必須提供的功能:需要哪些介面和按鈕,以及每個操作應該做什麼,以實現軟體的目的。這些是你的 ***功能性需求***。
構建一個應用時,你通常會從一張需求清單開始。清單最上面的,往往是應用必須提供的功能:需要哪些頁面和按鈕,每個操作應該完成什麼行為,才能實現軟體的目標。這些就是 ***功能性需求***。
此外,你可能還有一些 ***非功能性需求***:例如,應用程式應該快速、可靠、安全、合規,並且易於維護。這些需求可能沒有明確寫下來,因為它們看起來有些顯而易見,但它們與應用程式的功能同樣重要:一個慢得讓人無法忍受或不可靠的應用程式還不如不存在。
此外,你通常還會有一些 ***非功能性需求***:例如,應用應當足夠快、足夠可靠、足夠安全、符合法規,而且易於維護。這些需求可能並沒有明確寫下來,因為它們看起來像是“常識”,但它們與功能需求同樣重要。一個慢得無法忍受、或頻繁出錯的應用,幾乎等於不存在。
許多非功能性需求,比如安全性,超出了本書的範圍。但我們將考慮一些非功能性需求,本章將幫助你為自己的系統闡明它們
許多非功能性需求(比如安全)超出了本書範圍。但本章會討論其中幾項核心要求,並幫助你用更清晰的方式描述自己的系統
* 如何定義衡量系統的 **效能**(參見 ["描述效能"](/tw/ch2#sec_introduction_percentiles)
* 服務 **可靠** 意味著什麼——即即使出現問題也能繼續正確工作(參見 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability)
* 透過在系統負載增長時新增計算能力的有效方法,使系統具有 **可伸縮性**(參見 ["可伸縮性"](/tw/ch2#sec_introduction_scalability));以及
* 使系統長期更 **易於維護**(參見 ["可維護性"](/tw/ch2#sec_introduction_maintainability))。
* 如何定義衡量系統的 **效能**(參見 ["描述效能"](/tw/ch2#sec_introduction_percentiles)
* 服務 **可靠** 到底意味著什麼:也就是在出錯時仍能持續正確工作(參見 ["可靠性與容錯"](/tw/ch2#sec_introduction_reliability)
* 如何透過高效增加計算資源,讓系統在負載增長時保持 **可伸縮性**(參見 ["可伸縮性"](/tw/ch2#sec_introduction_scalability));以及
* 如何讓系統在長期演進中保持 **可維護性**(參見 ["可維護性"](/tw/ch2#sec_introduction_maintainability))。
本章介紹的術語在後續章節中也很有用,當我們深入研究資料密集型系統的實現細節時。然而,抽象定義可能相當枯燥;為了使這些想法更具體,我們將從一個案例研究開始本章,研究社交網路服務可能如何工作,這將提供效能和可伸縮性的實際案例
本章引入的術語,在後續章節深入實現細節時也會反覆用到。不過純定義往往比較抽象。為了把概念落到實處,本章先從一個案例研究開始:看看社交網路服務可能如何實現,並藉此討論效能與可伸縮性問題
## 案例研究:社交網路首頁時間線 {#sec_introduction_twitter}
想象一下,你被賦予了實現一個類似 X前身為 Twitter風格的社交網路的任務使用者可以釋出訊息並關注其他使用者。這將是對這種服務實際工作方式的巨大簡化 [^1] [^2] [^3],但它將有助於說明大規模系統中出現的一些問題。
假設你要實現一個類似 X原 Twitter的社交網路使用者可以發帖並追隨其他使用者。這會極大簡化真實系統的實現方式 [^1] [^2] [^3],但足以說明大規模系統會遇到的一些關鍵問題。
假設使用者每天釋出 5 億條帖子,或平均每秒 5,700 條帖子。偶爾,速率可能飆升至每秒 150,000 條帖子 [^4]。我們還假設平均每個使用者關注 200 人並有 200 個粉絲(儘管實際的範圍要大得多:大多數人只有少數粉絲,而少數名人如巴拉克・奧巴馬有超過 1 億粉絲)。
我們假設:使用者每天發帖 5 億條,平均每秒約 5,700 條;在特殊事件期間,峰值可能衝到每秒 150,000 條 [^4]。再假設平均每位使用者追隨 200 人,並有 200 名追隨者(實際分佈非常不均勻:大多數人只有少量追隨者,少數名人如巴拉克・奧巴馬則有上億追隨者)。
### 表示使用者、帖子與關注關係 {#id20}
@ -37,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
@ -50,42 +52,42 @@ SELECT posts.*, users.* FROM posts
要執行此查詢,資料庫將使用 `follows` 表找到 `current_user` 關注的所有人,查詢這些使用者最近的帖子,並按時間戳排序以獲取被關注使用者的最新 1,000 條帖子。
帖子應該是及時的,所以假設在某人釋出帖子後,我們希望他們的粉絲能夠在 5 秒內看到它。一種方法是讓使用者的客戶端每 5 秒重複上述查詢(這稱為 *輪詢*)。如果我們假設有 1000 萬用戶同時線上登入,這意味著每秒執行 200 萬次查詢。即使增加輪詢間隔,這也是很大的負載
帖子具有時效性。我們假設:某人發帖後,追隨者應在 5 秒內看到。一個做法是客戶端每 5 秒重複執行一次上述查詢(即 *輪詢*)。如果同時線上登入使用者有 1000 萬,就意味著每秒要執行 200 萬次查詢。即使把輪詢間隔調大,這個量也很可觀
此外,上述查詢相當昂貴:如果你關注 200 人,它需要獲取這 200 人中每個人的最近帖子列表,併合並這些列表。每秒 200 萬次時間線查詢意味著資料庫需要每秒查詢某個傳送者的最近帖子 4 億次——這是一個巨大的數字。這是平均情況。一些使用者關注數萬個賬戶;對他們來說,這個查詢執行起來非常昂貴,而且很難快速完成
此外,這個查詢本身也很昂貴。若你追隨 200 人,系統就要分別抓取這 200 人的近期帖子列表,再把它們歸併。每秒 200 萬次時間線查詢,等價於資料庫每秒要執行約 4 億次“按傳送者查最近帖子”。這還只是平均情況。少數使用者會追隨數萬賬戶,這個查詢對他們尤其昂貴,也更難做快
### 時間線的物化與更新 {#sec_introduction_materializing}
我們如何做得更好?首先,與其輪詢,不如伺服器主動向當前線上的任何粉絲推送新帖子。其次,我們應該預先計算上述查詢的結果,以便可以從快取中提供使用者的首頁時間線請求
要如何最佳化?第一,與其輪詢,不如由伺服器主動向線上追隨者推送新帖。第二,我們應該預先計算上述查詢結果,讓首頁時間線請求可以直接從快取返回
想象一下,我們為每個使用者儲存一個包含其首頁時間線的資料結構,即他們關注的人的最近帖子。每次使用者釋出帖子時,我們查詢他們的所有粉絲,並將該帖子插入到每個粉絲的首頁時間線中——就像向郵箱投遞訊息一樣。現在當用戶登入時,我們可以簡單地給他們這個預先計算的首頁時間線。此外,要接收時間線上任何新帖子的通知,使用者的客戶端只需訂閱新增到其首頁時間線的帖子流
設想我們為每個使用者維護一個數據結構,儲存其首頁時間線,也就是其所追隨者的近期帖子。每當使用者發帖,我們就找出其所有追隨者,把這條帖子插入每個追隨者的首頁時間線中,就像往郵箱裡投遞信件。這樣使用者登入時,可以直接讀取預先算好的時間線。若要接收新帖提醒,客戶端只需訂閱“寫入該時間線”的帖子流即可
這種方法的缺點是,現在每次使用者釋出帖子時我們需要做更多的工作,因為首頁時間線是需要更新的派生資料。該過程如 [圖 2-2](/tw/ch2#fig_twitter_timelines) 所示。當一個初始請求導致幾個下游請求被執行時,我們使用術語 *扇出* 來描述請求數量增加的因子
這種方法的缺點是:每次發帖時都要做更多工作,因為首頁時間線屬於需要持續更新的派生資料。這個過程見 [圖 2-2](/tw/ch2#fig_twitter_timelines)。當一個初始請求觸發多個下游請求時,我們用 *扇出* 描述請求數量被放大的倍數
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="圖 2-2. 扇出:將新帖子傳遞給釋出帖子的使用者的每個粉絲。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0202.png" id="fig_twitter_timelines" caption="圖 2-2. 扇出:將新帖子傳遞給釋出帖子的使用者的每個追隨者。" class="w-full my-4" >}}
以每秒 5,700 條帖子的速率,如果平均帖子到達 200 個粉絲(即扇出因子為 200我們將需要每秒執行超過 100 萬次首頁時間線寫入。這很多,但與我們本來需要的每秒 4 億次每個傳送者的帖子查詢相比,這仍然是一個顯著的節省
按每秒 5,700 條帖子計算,若平均每條帖到達 200 名追隨者(扇出因子 200則每秒需要略高於 100 萬次首頁時間線寫入。這已經很多,但相比原先每秒 4 億次“按傳送者查帖”,仍是顯著最佳化
如果由於某些特殊事件導致帖子速率激增,我們不必立即進行時間線交付——我們可以將它們排隊,並接受帖子在粉絲的時間線中顯示會暫時花費更長時間。即使在這種負載峰值期間,時間線仍然可以快速載入,因為我們只是從快取中提供它們
如果遇到特殊事件導致發帖速率激增,我們不必立刻完成時間線投遞。可以先入隊,接受“帖子出現在追隨者時間線中”會暫時變慢。即便在這種峰值期,時間線載入仍然很快,因為讀取仍來自快取
這種預先計算和更新查詢結果的過程稱為 *物化*,時間線快取是 *物化檢視* 的一個例子(我們將在 [待補充連結] 中進一步討論這個概念)。物化檢視加速了讀取,但作為交換,我們必須在寫入時做更多的工作。對於大多數使用者來說,寫入成本是適中的,但社交網路還必須考慮一些極端情況:
這種預先計算並持續更新查詢結果的過程稱為 *物化*。時間線快取就是一種 *物化檢視*(這個概念見 [“維護物化檢視”](/tw/ch12#sec_stream_mat_view))。物化檢視能加速讀取,但代價是寫入側工作量增加。對大多數使用者而言,這個寫入成本仍可接受,但社交網路還要處理一些極端情況:
* 如果使用者關注非常多的賬戶,並且這些賬戶釋出很多內容,該使用者的物化時間線將有很高的寫入率。然而,在這種情況下,使用者實際上不太可能閱讀其時間線中的所有帖子,因此可以簡單地丟棄其時間線的一些寫入,只向用戶顯示他們關注的賬戶的帖子樣本 [^5]。
* 當擁有大量粉絲的名人賬戶釋出帖子時,我們必須做大量工作將該帖子插入到他們數百萬粉絲的每個首頁時間線中。在這種情況下,丟棄一些寫入是不可接受的。解決這個問題的一種方法是將名人帖子與其他人的帖子分開處理:我們可以透過將名人帖子單獨儲存並在讀取時與物化時間線合併,來節省將它們新增到數百萬時間線的工作。儘管有這些最佳化,處理社交網路上的名人仍然需要大量基礎設施 [^6]。
* 如果某使用者追隨了大量賬戶,且這些賬戶發帖頻繁,那麼該使用者的物化時間線寫入率會很高。但在這種場景下,使用者通常也看不完全部帖子,因此可以丟棄部分時間線寫入,只展示其追隨賬戶帖子的一部分樣本 [^5]。
* 如果一個擁有海量追隨者的名人賬號發帖,我們需要把這條帖子寫入其數百萬追隨者的首頁時間線,工作量極大。此時不能隨意丟寫。常見做法是把名人帖子與普通帖子分開處理:名人帖單獨儲存,讀取時間線時再與物化時間線合併,從而省去寫入數百萬條時間線的成本。即便如此,服務名人賬號仍需大量基礎設施 [^6]。
## 描述效能 {#sec_introduction_percentiles}
大多數關於軟體效能的討論都考慮兩種主要的度量型別
軟體效能通常圍繞兩類指標展開
響應時間
: 從使用者發出請求到收到請求應答所經過的時間。測量單位是秒(或毫秒,或微秒)。
: 從使用者發出請求到收到響應所經歷的時間。單位是秒(或毫秒、微秒)。
吞吐量
: 系統正在處理的每秒請求數,或每秒資料量。對於給定的硬體資源分配,存在可以處理的 *最大吞吐量*。測量單位是"每秒某物"
: 系統每秒可處理的請求數或資料量。對於給定硬體資源,系統存在一個可處理的 *最大吞吐量*。單位是“每秒某種工作量”
在社交網路案例研究中,"每秒帖子數"和"每秒時間線寫入數"是吞吐量指標,而"載入首頁時間線所需的時間"或"帖子傳遞給粉絲的時間"是響應時間指標。
在社交網路案例中,“每秒帖子數”和“每秒時間線寫入數”屬於吞吐量指標;“載入首頁時間線所需時間”或“帖子送達追隨者所需時間”屬於響應時間指標。
吞吐量和響應時間之間通常存在聯絡;線上服務的這種關係示例如 [圖 2-3](/tw/ch2#fig_throughput) 所示。當請求吞吐量較低時,服務具有較低的響應時間,但隨著負載增加,響應時間也會增加。這是因為 *排隊*當請求到達高負載系統時CPU 很可能已經在處理先前的請求,因此傳入請求需要等待先前請求完成。隨著吞吐量接近硬體可以處理的最大值,排隊延遲急劇增加
吞吐量和響應時間之間通常相關。線上服務的典型關係如 [圖 2-3](/tw/ch2#fig_throughput):低吞吐量時響應時間較低,負載升高後響應時間上升。原因是 *排隊*。請求到達高負載系統時CPU 往往已在處理前一個請求,新請求只能等待;當吞吐量逼近硬體上限,排隊延遲會急劇上升
{{< figure src="/fig/ddia_0203.png" id="fig_throughput" caption="圖 2-3. 隨著服務的吞吐量接近其容量,由於排隊,響應時間急劇增加。" class="w-full my-4" >}}
@ -93,126 +95,126 @@ 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]。
--------
就效能指標而言,響應時間通常是使用者最關心的,而吞吐量決定了所需的計算資源(例如,你需要多少伺服器),因此決定了服務特定工作負載的成本。如果吞吐量可能會增長超出當前硬體可以處理的範圍,則需要擴充套件容量;如果系統的最大吞吐量可以透過新增計算資源顯著增加,則稱系統為 *可伸縮的*。
從效能指標角度看,使用者通常最關心響應時間;而吞吐量決定了所需計算資源(例如伺服器數量),從而決定承載特定工作負載的成本。如果吞吐量增長可能超過當前硬體上限,就必須擴容;若系統可透過增加計算資源顯著提升最大吞吐量,就稱其 *可伸縮*。
在本節中,我們將主要關注響應時間,我們將在 ["可伸縮性"](/tw/ch2#sec_introduction_scalability) 中回到吞吐量和可伸縮性
本節主要討論響應時間;吞吐量與可伸縮性會在 ["可伸縮性"](/tw/ch2#sec_introduction_scalability) 一節再展開
### 延遲與響應時間 {#id23}
"延遲"和"響應時間"有時可互換使用,但在本書中我們將以特定方式使用這些術語(如 [圖 2-4](/tw/ch2#fig_response_time) 所示
“延遲”和“響應時間”有時會混用,但本書對它們有明確區分(見 [圖 2-4](/tw/ch2#fig_response_time)
* *響應時間* 是客戶端看到的;它包括系統中任何地方產生的所有延遲。
* *服務時間* 是服務主動處理使用者請求的持續時間。
* *排隊延遲*能發生在流程中的幾個點:例如,在收到請求後,它可能需要等待直到 CPU 可用才能被處理;如果同一臺機器上的其他任務通過出站網路介面傳送大量資料,響應資料包可能需要在傳送之前進行緩衝
* *延遲*一個涵蓋請求未被主動處理時間的總稱,即在此期間它是 *潛在的*。特別是,*網路延遲* 或 *網路延遲* 指的是請求和響應在網路中傳輸所花費的時間。
* *響應時間* 是客戶端看到的總時間,包含鏈路上各處產生的全部延遲。
* *服務時間* 是服務主動處理該請求的時間。
* *排隊延遲*發生在流程中的多個位置。例如請求到達後,可能要等 CPU 空出來才能處理;同機其他任務若佔滿出站網絡卡,響應包也可能先在緩衝區等待發送
* *延遲*對“請求未被主動處理這段時間”的統稱,也就是請求處於 *潛伏latent* 狀態的時間。尤其是 *網路延遲*(或網路時延)指請求與響應在網路中傳播所花的時間。
{{< figure src="/fig/ddia_0204.png" id="fig_response_time" caption="圖 2-4. 響應時間、服務時間、網路延遲和排隊延遲。" class="w-full my-4" >}}
在 [圖 2-4](/tw/ch2#fig_response_time) 中,時間從左到右流動,每個通訊節點顯示為水平線,請求或響應訊息顯示為從一個節點到另一個節點的粗對角箭頭。你將在本書中經常遇到這種風格的圖表
在 [圖 2-4](/tw/ch2#fig_response_time) 中,時間從左向右流動。每個通訊節點畫成一條水平線,請求/響應訊息畫成節點間的粗斜箭頭。本書後文會頻繁使用這種圖示風格
響應時間可能會因請求而異,即使你一遍又一遍地發出相同的請求。許多因素可能會增加隨機延遲:例如,上下文切換到後臺程序、網路資料包丟失和 TCP 重傳、垃圾回收暫停、強制從磁碟讀取的缺頁錯誤、伺服器機架中的機械振動 [^17],或許多其他原因。我們將在 ["超時與無界延遲"](/tw/ch9#sec_distributed_queueing) 中更詳細地討論這個主題。
即便反覆傳送同一個請求,響應時間也可能顯著波動。許多因素都會引入隨機延遲:例如切換到後臺程序、網路丟包與 TCP 重傳、垃圾回收暫停、缺頁導致的磁碟讀取、伺服器機架機械振動 [^17] 等。我們會在 ["超時與無界延遲"](/tw/ch9#sec_distributed_queueing) 進一步討論這個問題。
排隊延遲通常佔響應時間變化的很大一部分。由於伺服器只能並行處理少量事務(例如,受其 CPU 核心數的限制),只需要少量慢請求就可以阻塞後續請求的處理——這種效應稱為 *隊頭阻塞*。即使那些後續請求的服務時間很快,由於等待先前請求完成的時間,客戶端仍會看到緩慢的整體響應時間。排隊延遲不是服務時間的一部分,因此在客戶端測量響應時間很重要
排隊延遲常常是響應時間波動的主要來源。伺服器並行處理能力有限(例如受 CPU 核數約束),少量慢請求就可能堵住後續請求,這就是 *頭部阻塞*。即便後續請求本身服務時間很短,客戶端仍會因為等待前序請求而看到較慢的總體響應。排隊延遲不屬於服務時間,因此必須在客戶端側測量響應時間
### 平均值、中位數與百分位 {#id24}
### 平均值、中位數與百分位 {#id24}
因為響應時間因請求而異,我們需要將其視為值的 *分佈*,而不是單個數字。在 [圖 2-5](/tw/ch2#fig_lognormal) 中,每個灰色條表示對服務的請求,其高度顯示該請求花費的時間。大多數請求相當快,但偶爾會有 *異常值* 需要更長時間。網路延遲的變化也稱為 *抖動*
由於響應時間會隨請求變化,我們應將其看作一個可測量的 *分佈*,而非單一數字。在 [圖 2-5](/tw/ch2#fig_lognormal) 中,每個灰色柱表示一次請求,柱高是該請求耗時。大多數請求較快,但會有少量更慢的 *異常值*。網路時延波動也常稱為 *抖動*
{{< figure src="/fig/ddia_0205.png" id="fig_lognormal" caption="圖 2-5. 說明平均值和百分位100 個服務請求的響應時間樣本。" class="w-full my-4" >}}
{{< 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*
為了弄清異常值有多糟糕,你可以檢視更高的百分位數:*第 95*、*99* 和 *99.9* 百分位數很常見(縮寫為 *p95*、*p99* 和 *p999*)。它們是 95%、99% 或 99.9% 的請求比該特定閾值快的響應時間閾值。例如,如果第 95 百分位響應時間是 1.5 秒,這意味著 100 個請求中的 95 個花費不到 1.5 秒100 個請求中的 5 個花費 1.5 秒或更長時間。這在 [圖 2-5](/tw/ch2#fig_lognormal) 中有所說明
為了看清異常值有多糟,需要觀察更高百分位點:常見的是 *p95*、*p99*、*p999*。它們表示 95%、99%、99.9% 的請求都快於該閾值。例如 p95 為 1.5 秒,表示 100 個請求裡有 95 個小於 1.5 秒,另外 5 個不小於 1.5 秒。[圖 2-5](/tw/ch2#fig_lognormal) 展示了這一點
響應時間的高百分位數,也稱為 *尾部延遲*,很重要,因為它們直接影響使用者的服務體驗。例如,亞馬遜在描述內部服務的響應時間要求時使用第 99.9 百分位,即使它隻影響 1,000 個請求中的 1 個。這是因為請求最慢的客戶通常是那些賬戶上資料最多的客戶,因為他們進行了許多購買——也就是說,他們是最有價值的客戶 [^19]。確保網站對他們來說速度快對於保持這些客戶的滿意度很重要
響應時間的高百分位點(也叫 *尾部延遲*)非常重要,因為它直接影響使用者體驗。例如亞馬遜內部服務常以第 99.9 百分位設定響應要求,儘管它隻影響 1/1000 的請求。原因是最慢請求往往來自“賬戶資料最多”的客戶,他們通常也是最有價值客戶 [^19]。讓這批使用者也能獲得快速響應,對業務很關鍵
另一方面,最佳化第 99.99 百分位10,000 個請求中最慢的 1 個)被認為太昂貴,對亞馬遜的目的沒有足夠的好處。在非常高的百分位數上減少響應時間很困難,因為它們很容易受到你無法控制的隨機事件的影響,而且收益遞減
另一方面,繼續最佳化到第 99.99 百分位(最慢的萬分之一請求)通常成本過高、收益有限。越到高百分位,越容易受不可控隨機因素影響,也更符合邊際收益遞減規律
--------
> [!TIP] 響應時間對使用者的影響
直覺上似乎很明顯,快速服務比慢速服務對使用者更好 [^20]。然而,要獲得可靠的資料來量化延遲對使用者行為的影響是令人驚訝地困難的
直覺上,快服務當然比慢服務更好 [^20]。但真正要拿到“延遲如何影響使用者行為”的可靠量化資料,其實非常困難
一些經常被引用的統計資料是不可靠的。2006 年,谷歌報告說,搜尋結果從 400 毫秒減慢到 900 毫秒與流量和收入下降 20% 相關 [^21]。然而2009 年穀歌的另一項研究報告說,延遲增加 400 毫秒導致每天搜尋減少僅 0.6% [^22],同年必應發現載入時間增加兩秒將廣告收入減少 4.3% [^23]。這些公司的較新資料似乎沒有公開。
一些被頻繁引用的統計並不可靠。2006 年Google 曾報告:搜尋結果從 400 毫秒變慢到 900 毫秒,與流量和收入下降 20% 相關 [^21]。但 2009 年 Google 另一項研究又稱,延遲增加 400 毫秒僅導致日搜尋量下降 0.6% [^22];同年 Bing 發現,載入時間增加 2 秒會讓廣告收入下降 4.3% [^23]。這些公司的更新資料似乎並未公開。
Akamai 最近的一項研究 [^24] 聲稱響應時間增加 100 毫秒將電子商務網站的轉化率降低多達 7%;然而,仔細檢查後,同一研究顯示,非常 *快* 的頁面載入時間也與較低的轉化率相關這個看似矛盾的結果是因為載入最快的頁面通常是那些沒有有用內容的頁面例如404 錯誤頁面)。然而,由於該研究沒有努力將頁面內容的影響與載入時間的影響分開,其結果可能沒有意義
Akamai 的一項較新研究 [^24] 聲稱:響應時間增加 100 毫秒會讓電商網站轉化率最多下降 7%。但細看可知,同一研究也顯示“載入極快”的頁面同樣和較低轉化率相關。這個看似矛盾的結果,很可能是因為載入最快的頁面往往是“無有效內容”的頁面(如 404。而該研究並未把“頁面內容影響”和“載入時間影響”區分開因此結論可能並不可靠
雅虎的一項研究 [^25] 比較了快速載入與慢速載入搜尋結果的點選率,控制了搜尋結果的質量。它發現當快速和慢速響應之間的差異為 1.25 秒或更多時,快速搜尋的點選次數增加 20-30%。
Yahoo 的一項研究 [^25] 在控制搜尋結果質量後,比對了快慢載入對點選率的影響。結果顯示:當快慢響應差異達到 1.25 秒或以上時,快速搜尋的點選量會高出 20%30%。
--------
### 響應時間指標的應用 {#sec_introduction_slo_sla}
高百分位數在被多次呼叫作為服務單個終端使用者請求的一部分的後端服務中尤其重要。即使你並行進行呼叫,終端使用者請求仍然需要等待最慢的並行呼叫完成。只需要一個慢呼叫就可以使整個終端使用者請求變慢,如 [圖 2-6](/tw/ch2#fig_tail_amplification) 所示。即使只有一小部分後端呼叫很慢,如果終端使用者請求需要多個後端呼叫,獲得慢呼叫的機會就會增加,因此更高比例的終端使用者請求最終會變慢(這種效應稱為 *尾部延遲放大* [^26])。
對於“一個終端請求會觸發多次後端呼叫”的服務,高百分位點尤其關鍵。即使並行呼叫,終端請求仍要等待最慢的那個返回。正如 [圖 2-6](/tw/ch2#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 毫秒且第 99 百分位低於 1 秒的目標,以及至少 99.9% 的有效請求產生非錯誤響應的目標。SLA 是一份合同,規定如果不滿足 SLO 會發生什麼(例如,客戶可能有權獲得退款)。這至少是基本想法;實際上,為 SLO 和 SLA 定義良好的可用性指標並不簡單 [^28] [^29]。
百分位點也常用於定義 *服務級別目標*SLO*服務級別協議*SLA[^27]。例如,一個 SLO 可能要求:中位響應時間低於 200 毫秒、p99 低於 1 秒,並且至少 99.9% 的有效請求返回非錯誤響應。SLA 則是“未達成 SLO 時如何處理”的合同條款(例如客戶可獲賠償)。這是基本思路;但在實踐中,為 SLO/SLA 設計合理可用性指標並不容易 [^28] [^29]。
--------
> [!TIP] 計算百分位
> [!TIP] 計算百分位
如果你想將響應時間百分位數新增到服務的監控儀表板中,你需要持續有效地計算它們。例如,你可能希望保留過去 10 分鐘內請求的響應時間的滾動視窗。每分鐘,你計算該視窗中值的中位數和各種百分位數,並在圖表上繪製這些指標
如果你想在監控面板中展示響應時間百分位點,就需要持續且高效地計算它們。例如,維護“最近 10 分鐘請求響應時間”的滾動視窗,每分鐘計算一次該視窗內的中位數與各百分位點,並繪圖展示
最簡單的實現是在時間視窗內保留所有請求的響應時間列表,並每分鐘對該列表進行排序。如果這對你來說效率太低,有一些演算法可以以最小的 CPU 和記憶體成本計算百分位數的良好近似值。開源百分位數估計庫包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。
最簡單的實現是儲存視窗內全部請求的響應時間,並每分鐘排序一次。若效率不夠,可以用一些低 CPU/記憶體開銷的演算法來近似計算百分位點。常見開源庫包括 HdrHistogram、t-digest [^30] [^31]、OpenHistogram [^32] 和 DDSketch [^33]。
請注意,平均百分位數,例如,減少時間解析度或組合來自多臺機器的資料,在數學上是沒有意義的——聚合響應時間資料的正確方法是新增直方圖 [^34]。
要注意,“對百分位點再取平均”(例如降低時間解析度,或合併多機器資料)在數學上沒有意義。聚合響應時間資料的正確方式是聚合直方圖 [^34]。
--------
## 可靠性與容錯 {#sec_introduction_reliability}
每個人都對某物是否可靠或不可靠有直觀的想法。對於軟體,典型的期望包括:
每個人對“可靠”與“不可靠”都有直覺。對軟體而言,典型期望包括:
* 應用程式執行使用者期望的功能。
* 它可以容忍使用者犯錯誤或以意想不到的方式使用軟體。
* 在預期的負載和資料量下,其效能足以滿足所需的用例。
* 系統防止任何未經授權的訪問和濫用。
* 應用能完成使用者預期的功能。
* 能容忍使用者犯錯,或以意料之外的方式使用軟體。
* 在預期負載與資料規模下,效能足以支撐目標用例。
* 能防止未授權訪問與濫用。
如果所有這些加在一起意味著"正確工作",那麼我們可以將 *可靠性* 大致理解為"即使出現問題也能繼續正確工作"。為了更準確地說明出現問題,我們將區分 *故障* *失效* [^35] [^36] [^37]
如果把這些合起來稱為“正確工作”,那麼 *可靠性* 可以粗略理解為:即使出現問題,系統仍能持續正確工作。為了更精確地描述“出問題”,我們區分 *故障* *失效* [^35] [^36] [^37]
故障
: 故障是指系統的某個特定 *部分* 停止正確工作:例如,如果單個硬碟驅動器發生故障,或單臺機器崩潰,或外部服務(系統所依賴的)發生中斷。
: 指系統某個 *區域性元件* 停止正常工作:例如單個硬碟損壞、單臺機器宕機,或系統依賴的外部服務中斷。
失效
: 失效是指 *整個* 系統停止向用戶提供所需的服務換句話說當它不滿足服務級別目標SLO
: *整個系統* 無法繼續向用戶提供所需服務換言之系統未滿足服務級別目標SLO
故障和失效之間的區別可能會令人困惑,因為它們在不同層面上是同一件事。例如,如果硬碟驅動器停止工作,我們說硬碟驅動器已失效:如果系統僅由該一個硬碟驅動器組成,它已停止提供所需的服務。然而,如果你正在談論的系統包含許多硬碟驅動器,那麼從更大系統的角度來看,單個硬碟驅動器的失效只是一個故障,並且更大的系統可能能夠透過在另一個硬碟驅動器上擁有資料副本來容忍該故障。
“故障”與“失效”的區別容易混淆,因為它們本質上是同一件事在不同層級上的表述。比如一個硬碟壞了,對“硬碟這個系統”來說是失效;但對“由許多硬碟組成的更大系統”來說,它只是一個故障。更大系統若在其他硬碟上有副本,就可能容忍該故障。
### 容錯 {#id27}
如果系統在發生某些故障時仍繼續向用戶提供所需的服務,我們稱系統為 *容錯的*。如果系統不能容忍某個部分變得有故障,我們稱該部分為 *單點故障*SPOF因為該部分的故障會升級導致整個系統的失效。
例如,在社交網路案例研究中,可能發生的故障是在扇出過程中,參與更新物化時間線的機器崩潰或變得不可用。為了使這個過程容錯,我們需要確保另一臺機器可以接管這項任務,而不會錯過任何應該交付的帖子,也不會複製任何帖子。(這個想法被稱為 *精確一次語義*,我們將在 [待補充連結] 中詳細研究它。)
例如在社交網路案例中,扇出流程裡可能有機器崩潰或不可用,導致物化時間線更新中斷。若要讓該流程具備容錯性,就必須保證有其他機器可接管任務,同時既不漏投帖子,也不重複投遞。(這個思想稱為 *恰好一次語義*,我們會在 [“資料庫的端到端論證”](/tw/ch13#sec_future_end_to_end) 中詳細討論。)
容錯總是限於某些型別的某些數量的故障。例如,系統可能能夠容忍最多兩個硬碟驅動器同時故障,或最多三個節點中的一個崩潰。如果所有節點都崩潰,沒有什麼可以做的,這沒有意義容忍任何數量的故障。如果整個地球(及其上的所有伺服器)被黑洞吞噬,容忍該故障將需要在太空中進行網路託管——祝你獲得批准該預算專案的好運。
容錯能力總是“有邊界”的:它只針對某些型別、某個數量以內的故障。例如系統可能最多容忍 2 塊硬碟同時故障,或 3 個節點裡壞 1 個。若全部節點都崩潰,就無計可施,因此“容忍任意數量故障”並無意義。要是地球和上面的伺服器都被黑洞吞噬,那就只能去太空託管了,預算審批祝你好運。
反直覺地是,在這種容錯系統中,透過故意觸發故障來 *增加* 故障率是有意義的——例如,在沒有警告的情況下隨機殺死單個程序。這稱為 *故障注入*。許多關鍵錯誤實際上是由於錯誤處理不當造成的 [^38];透過故意引發故障,你確保容錯機制不斷得到鍛鍊和測試,這可以增加你對故障自然發生時將被正確處理的信心。*混沌工程* 是一門旨在透過故意注入故障等實驗來提高對容錯機制的信心的學科 [^39]。
反直覺的是,在這類系統裡,故意 *提高* 故障發生率反而有意義,例如無預警隨機殺死某個程序。這叫 *故障注入*。許多關鍵故障本質上是錯誤處理做得不夠好 [^38]。透過主動注入故障,可以持續演練並驗證容錯機制,提升對“真實故障發生時系統仍能正確處理”的信心。*混沌工程* 就是圍繞這類實驗建立起來的方法論 [^39]。
儘管我們通常更喜歡容忍故障而不是預防故障,但在預防比治療更好的情況下(例如,因為不存在治療方法)。安全問題就是這種情況:如果攻擊者已經破壞了系統並獲得了對敏感資料的訪問,該事件無法撤消。然而,本書主要涉及可以恢復的故障型別,如以下部分所述
儘管我們通常更傾向於“容忍故障”,而非“阻止故障”,但也有“預防優於補救”的場景(例如根本無法補救)。安全問題就是如此:若攻擊者已攻破系統並獲取敏感資料,事件本身無法撤銷。不過,本書主要討論的是可恢復的故障型別
### 硬體與軟體故障 {#sec_introduction_hardware_faults}
當我們想到系統失效的原因時,硬體故障很快就會浮現在腦海中:
* 大約 2-5% 的硬磁碟驅動器每年發生故障 [^40] [^41];在擁有 10,000 個磁碟的儲存叢集中,我們因此應該認為平均每天有一個磁碟故障。最近的資料表明磁碟變得更可靠,但故障率仍然很顯著 [^42]。
* 大約 0.5-1% 的固態硬碟SSD每年發生故障 [^43]。少量位錯誤會自動糾正 [^44],但不可糾正的錯誤大約每年每個驅動器發生一次,即使在相當新的驅動器中(即,經歷很少磨損);這個錯誤率高於硬磁碟驅動器 [^45]、[^46]。
* 機械硬碟每年故障率約為 2%5% [^40] [^41];在 10,000 盤位的儲存叢集中,平均每天約有 1 塊盤故障。近期資料表明磁碟可靠性在提升,但故障率仍不可忽視 [^42]。
* SSD 每年故障率約為 0.5%1% [^43]。少量位元錯誤可自動糾正 [^44],但不可糾正錯誤大約每盤每年一次,即使是磨損較輕的新盤也會出現;該錯誤率高於機械硬碟 [^45]、[^46]。
* 其他硬體元件如電源、RAID 控制器和記憶體模組也會發生故障,儘管頻率低於硬碟驅動器 [^47] [^48]。
* 大約千分之一的機器有一個 CPU 核心偶爾計算錯誤的結果,可能是製造缺陷 [^49] [^50] [^51]造成的。在某些情況下,錯誤的計算會導致崩潰,但在其他情況下,它會導致程式簡單地返回錯誤的結果。
* RAM 中的資料也可能被損壞要麼是由於宇宙射線等隨機事件要麼是由於永久性物理缺陷。即使使用糾錯碼ECC的記憶體也有超過 1% 的機器在給定年限內遇到不可糾正的錯誤,這通常會導致機器崩潰和受影響的記憶體模組需要更換 [^52]。此外,某些病態的記憶體訪問模式可能會以很高的機率翻轉位元位 [^53]。
* 整個資料中心可能變得不可用(例如,由於停電或網路配置錯誤)甚至被永久摧毀(例如,由於火災、洪水或地震 [^54])。太陽風暴,當太陽噴射大量帶電粒子時,會在長距離電線中感應出大電流,可能會損壞電網和海底網路電纜 [^55]。儘管這種大規模故障很少見,但如果服務不能容忍資料中心的丟失,它們的影響可能是災難性的 [^56]。
* 大約每 1000 臺機器裡就有 1 臺存在“偶發算錯結果”的 CPU 核心,可能由製造缺陷導致 [^49] [^50] [^51]。有時錯誤計算會直接導致崩潰;有時則只是悄悄返回錯誤結果。
* RAM 資料也可能損壞:要麼來自宇宙射線等隨機事件,要麼來自永久性物理缺陷。即便使用 ECC 記憶體,任意一年內仍有超過 1% 的機器會遇到不可糾正錯誤,通常表現為機器崩潰並需要更換受影響記憶體條 [^52]。此外,某些病態訪問模式還可能以較高機率觸發位元翻轉 [^53]。
* 整個資料中心也可能不可用(如停電、網路配置錯誤),甚至被永久摧毀(如火災、洪水、地震 [^54])。太陽風暴會在長距離導線中感應大電流,可能損壞電網和海底通訊電纜 [^55]。這類大規模故障雖罕見,但若服務無法容忍資料中心丟失,後果將極其嚴重 [^56]。
些事件足夠罕見,你在處理小型系統時通常不需要擔心它們,只要你能夠輕鬆地更換出現故障的硬體。然而,在大規模系統中,硬體故障發生得足夠頻繁,以至於它們成為正常系統執行的一部分。
類事件在小系統裡足夠罕見,通常不必過度擔心,只要能方便地更換故障硬體即可。但在大規模系統裡,硬體故障足夠頻繁,已經是“正常執行”的一部分。
#### 透過冗餘容忍硬體故障 {#tolerating-hardware-faults-through-redundancy}
@ -220,7 +222,7 @@ Akamai 最近的一項研究 [^24] 聲稱響應時間增加 100 毫秒將電子
當元件故障獨立時,冗餘最有效,即一個故障的發生不會改變另一個故障發生的可能性。然而,經驗表明,元件故障之間通常存在顯著的相關性 [^41] [^57] [^58];整個伺服器機架或整個資料中心的不可用仍然比我們預期的更頻繁地發生。
硬體冗餘增加了單臺機器的正常執行時間;然而,如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 中所討論的,使用分散式系統有一些優勢,例如能夠容忍一個數據中心的完全中斷。出於這個原因,雲系統傾向於較少關注單個機器的可靠性,而是旨在透過在軟體級別容忍故障節點來使服務高度可用。雲提供商使用 *可用區* 來識別哪些資源在物理上位於同一位置;同一地方的資源比地理上分離的資源更可能同時發生故障
硬體冗餘確實能提升單機可用時間;但正如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 所述,分散式系統還具備額外優勢,例如可容忍整個資料中心中斷。因此雲系統通常不再過分追求“單機極致可靠”,而是透過軟體層容忍節點故障來實現高可用。雲廠商使用 *可用區* 標識資源是否物理共址;同一可用區內資源比跨地域資源更容易同時失效
我們在本書中討論的容錯技術旨在容忍整個機器、機架或可用區的丟失。它們通常透過允許一個數據中心的機器在另一個數據中心的機器發生故障或變得不可達時接管來工作。我們將在 [第 6 章](/tw/ch6)、[第 10 章](/tw/ch10) 以及本書的其他各個地方討論這種容錯技術。
@ -228,198 +230,174 @@ Akamai 最近的一項研究 [^24] 聲稱響應時間增加 100 毫秒將電子
#### 軟體故障 {#software-faults}
儘管硬體故障可能是弱相關的,但它們大多仍然是獨立的:例如,如果一個磁碟發生故障,同一臺機器中的其他磁碟很可能在一段時間內還能正常工作。另一方面,軟體故障通常高度相關,因為許多節點執行相同的軟體並因此具有相同的錯誤是常見的 [^59] [^60]。這種故障比不相關的硬體故障更難預料,並且它們往往導致比硬體故障更多的系統失效 [^47]。例如:
儘管硬體故障可能存在弱相關,但整體上仍相對獨立:例如一塊盤壞了,同機其他盤往往還能再正常工作一段時間。相比之下,軟體故障常常高度相關,因為許多節點運行同一套軟體,也就共享同一批 bug [^59] [^60]。這類故障更難預判,也往往比“相互獨立的硬體故障”造成更多系統失效 [^47]。例如:
* 在特定情況下導致每個節點同時失效的軟體錯誤。例如2012 年 6 月 30 日,閏秒導致許多 Java 應用程式由於 Linux 核心中的錯誤而同時掛起 [^61]。由於韌體錯誤,某些型號的所有 SSD 在精確執行 32,768 小時(不到 4 年)後突然失效,使其上的資料無法恢復 [^62]。
* 使用某些共享、有限資源(如 CPU 時間、記憶體、磁碟空間、網路頻寬或執行緒)的失控程序 [^63]。例如,處理大請求時消耗過多記憶體的程序可能會被作業系統殺死。客戶端庫中的錯誤可能導致比預期更高的請求量 [^64]。
* 系統所依賴的服務變慢、無響應或開始返回損壞的響應。
* 不同系統之間的互動導致在隔離測試每個系統時不會發生的緊急行為 [^65]。
* 不同系統互動後出現“單系統隔離測試中看不到”的湧現行為 [^65]。
* 級聯故障,其中一個元件中的問題導致另一個元件過載和減速,這反過來又導致另一個元件崩潰 [^66] [^67]。
導致這些型別軟體故障的錯誤通常會潛伏很長時間,直到它們被一組不尋常的環境觸發。在這些情況下,軟體對其環境做出了某種假設——雖然該假設通常是正確的,但它最終由於某種原因不再成立 [^68] [^69]。
導致這類軟體故障的 bug 往往潛伏很久,直到一組不尋常條件把它觸發出來。這時才暴露出:軟體其實對執行環境做了某些假設,平時大多成立,但終有一天會因某種原因失效 [^68] [^69]。
軟體中的系統故障沒有快速解決方案。許多小事情可以幫助:仔細考慮系統中的假設和互動;徹底測試;程序隔離;允許程序崩潰和重新啟動;避免反饋迴圈,如重試風暴(參見 ["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable));測量、監控和分析生產中的系統行為。
軟體系統性故障沒有“速效藥”。但許多小措施都有效:認真審視系統假設與互動、充分測試、程序隔離、允許程序崩潰並重啟、避免反饋環路(如重試風暴,參見 ["當過載系統無法恢復時"](/tw/ch2#sidebar_metastable)),以及在生產環境持續度量、監控和分析系統行為。
### 人類與可靠性 {#id31}
人類設計和構建軟體系統,保持系統執行的操作員也是人類。與機器不同,人類不只是遵循規則;他們的優勢是創造性和適應性地完成工作。然而,這一特徵也導致了不可預測性,有時會導致失效的錯誤,即使本意是好的。例如,一項對大型網際網路服務的研究發現,操作員的配置更改是中斷的主要原因,而硬體故障(伺服器或網路)僅在 10-25% 的中斷中發揮作用 [^70]。
軟體系統由人設計、構建和運維。與機器不同,人不會只按規則執行;人的優勢在於創造性和適應性。但這也帶來不可預測性,即使本意是好的,也會犯導致失效的錯誤。例如,一項針對大型網際網路服務的研究發現:運維配置變更是中斷首因,而硬體故障(伺服器或網路)僅佔 10%25% [^70]。
人們很自然地傾向於將這類問題歸咎於“人為錯誤”,並希望透過更嚴格的程式和規則遵守來更好地控制人為行為從而解決問題。然而,將錯誤歸咎於人是適得其反的。我們所說的“人為錯誤”並非事件的真實原因,而是人們盡力工作時,社會技術系統中存在問題的徵兆 [^71]。通常,複雜系統具有緊急行為,元件之間的意外互動也可能導致故障 [^72]。
遇到這類問題,人們很容易歸咎於“人為錯誤”,並試圖透過更嚴格流程和更強規則約束來控制人。但“責怪個人”通常適得其反。所謂“人為錯誤”往往不是事故根因,而是社會技術系統本身存在問題的徵兆 [^71]。複雜系統裡,元件意外互動產生的湧現行為也常導致故障 [^72]。
各種技術措施可以幫助最小化人為錯誤的影響,包括徹底測試(手寫測試和對大量隨機輸入的 *屬性測試*[^38]、快速回滾配置更改的回滾機制、新程式碼的漸進部署、詳細和清晰的監控、用於診斷生產問題的可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems)),以及鼓勵"正確的事情"並阻止"錯誤的事情"的精心設計的介面
有多種技術手段可降低人為失誤的影響:充分測試(含手寫測試與大量隨機輸入的 *屬性測試*[^38]、可快速回滾配置變更的機制、新程式碼漸進發布、清晰細緻的監控、用於排查生產問題的可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems)),以及鼓勵“正確操作”並抑制“錯誤操作”的良好介面設計
然而,這些事情需要時間和金錢的投資,在日常業務的務實現實中,組織通常優先考慮創收活動而不是增加其抵禦錯誤的韌性措施。如果要在更多功能和更多測試之間做出選擇,許多組織會很自然地選擇功能。鑑於這種選擇,當可預防的錯誤不可避免地發生時,責怪犯錯誤的人是沒有意義的——問題在於組織的事項優先順序
但這些措施都需要時間和預算。在日常業務壓力下,組織往往優先投入“直接創收”活動,而非提升抗錯韌性的建設。若在“更多功能”和“更多測試”之間二選一,很多組織會自然選擇前者。既然如此,當可預防錯誤最終發生時,責怪個人並無意義,問題本質在於組織的優先順序選擇
越來越多的組織正在採用 *無責備事後分析* 的文化:事件發生後,鼓勵相關人員充分分享發生的事情的細節,而不用擔心懲罰,因為這允許組織中的其他人學習如何在未來防止類似的問題 [^73]發生。這個過程可能會發現需要改變業務優先順序、需要投資於被忽視的領域、需要改變相關人員的激勵措施,或者需要引起管理層注意的其他一些系統性問題。
越來越多組織在實踐 *無責備事後分析*:事故發生後,鼓勵參與者在不擔心懲罰的前提下完整覆盤細節,讓組織其他人也能學習如何避免類似問題 [^73]。這個過程常會揭示出:業務優先順序需要調整、某些長期被忽視的領域需要補投入、相關激勵機制需要改,或其他應由管理層關注的系統性問題。
作為一般原則,在調查事件時,你應該對簡單化的答案持懷疑態度。"鮑勃在部署該更改時應該更加小心"是沒有意義的,"我們必須用 Haskell 重寫後端"也一樣。相反,管理層應該藉此機會從每天與之合作的人的角度瞭解社會技術系統如何工作的細節,並根據這些反饋採取措施改進它 [^71]。
一般來說,調查事故時應警惕“過於簡單”的答案。“鮑勃部署時應更小心”沒有建設性,“我們必須用 Haskell 重寫後端”同樣不是。更可行的做法是:管理層藉機從一線人員視角理解社會技術系統的真實執行方式,並據此推動改進 [^71]。
--------
> [!TIP] 可靠性有多重要?
可靠性不僅僅適用於核電站和空中交通管制——更普通的應用程式也應該可靠地工作。業務應用程式中的錯誤會導致生產力損失(如果資料被不正確地報告,還會有法律風險),而電子商務網站的中斷會因收入損失和聲譽受損而產生巨大的成本
可靠性不只適用於核電站或空管系統,普通應用同樣需要可靠。企業軟體中的 bug 會造成生產力損失(若報表錯誤還會帶來法律風險);電商網站故障則會帶來直接收入損失和品牌傷害
在許多應用程式中,幾分鐘甚至幾小時的臨時中斷是可以容忍的 [^74],但永久資料丟失或損壞將是災難性的。考慮一位家長在你的照片應用程式中儲存他們孩子的所有照片和影片 [^75]。如果該資料庫突然損壞,他們會有什麼感覺?他們會知道如何從備份中恢復嗎
在許多應用裡,幾分鐘乃至幾小時的短暫中斷尚可容忍 [^74];但永久性資料丟失或損壞往往是災難性的。想象一位家長把孩子的全部照片和影片都存在你的相簿應用裡 [^75]。若資料庫突然損壞,他們會怎樣?又是否知道如何從備份恢復
作為不可靠軟體如何傷害人們的另一個例子可以參考郵局“Horizon”醜聞。在 1999 年至 2019 年期間,管理英國郵局分支機構的數百人因會計軟體顯示其賬戶財務漏洞而被判盜竊或欺詐罪。最終真相水落石出,許多這些財務漏洞是由於軟體中的錯誤,許多定罪已被推翻 [^76]。導致這一可能是英國曆史上最大的司法不公的是,英國法律假設計算機正確執行(因此,計算機產生的證據是可靠的),除非有相反的證據 [^77]。軟體工程師可能會嘲笑軟體可能無錯誤的想法,但這對那些因不可靠的計算機系統而被錯誤監禁、宣佈破產甚至自殺的人來說,這幾乎沒什麼安慰。
另一個“軟體不可靠傷害現實人群”的例子,是英國郵局 Horizon 醜聞。1999 到 2019 年間,數百名郵局網點負責人因會計系統顯示“賬目短缺”被判盜竊或欺詐。後來事實證明,許多“短缺”來自軟體缺陷,且大量判決已被推翻 [^76]。造成這場可能是英國史上最大司法不公的一個關鍵前提,是英國法律預設計算機正常執行(因此其證據可靠),除非有相反證據 [^77]。軟體工程師或許會覺得“軟體無 bug”很荒謬但這對那些因此被錯判入獄、破產乃至自殺的人來說毫無安慰。
在某些情況下,我們可能選擇犧牲可靠性以降低開發成本(例如,在為未經證實的市場開發原型產品時)——但我們應該非常清楚何時走捷徑並牢記潛在的後果。
在某些場景下,我們也許會有意犧牲部分可靠性來降低開發成本(例如做未驗證市場的原型產品)。但應明確知道自己在何處“走捷徑”,並充分評估其後果。
--------
## 可伸縮性 {#sec_introduction_scalability}
使系統今天可靠地工作,這並不意味著它將來必然會可靠地工作。降級的一個常見原因是負載增加:也許系統已經從 10,000 個併發使用者增長到 100,000 個併發使用者,或者從 100 萬增長到 1000 萬。也許它正在處理比以前大得多的資料量
便系統今天執行可靠,也不代表將來一定如此。效能退化的常見原因之一是負載增長:比如併發使用者從 1 萬漲到 10 萬,或從 100 萬漲到 1000 萬;也可能是處理的資料規模遠大於從前
*可伸縮性* 是我們用來描述系統應對負載增加能力的術語。有時,在討論可伸縮性時,人們會發表評論,如"你不是谷歌或亞馬遜。停止擔心規模,只使用關係資料庫。"這個格言是否適用於你取決於你正在構建的應用程式型別
*可伸縮性* 用來描述系統應對負載增長的能力。討論這個話題時,常有人說:“你又不是 Google/Amazon別擔心規模直接上關係資料庫。”這句話是否成立取決於你在做什麼型別的應用
如果你正在構建一個目前只有少數使用者的新產品,也許是在初創公司,首要的工程目標通常是保持系統儘可能簡單和靈活,以便你可以在瞭解更多關於客戶需求時輕鬆修改和調整產品的功能 [^78]。在這種環境中,擔心未來可能需要的假設規模是適得其反的:在最好的情況下,對可伸縮性的投資是浪費的努力和過早的最佳化;在最壞的情況下,它們會將你鎖定在不靈活的設計中,並使你的應用程式更難發展
如果你在做一個目前使用者很少的新產品(例如創業早期),首要工程目標通常是“儘可能簡單、儘可能靈活”,以便隨著對使用者需求理解加深而快速調整產品功能 [^78]。在這種環境下,過早擔心“未來也許會有”的規模往往適得其反:最好情況是白費功夫、過早最佳化;最壞情況是把自己鎖進僵化設計,反而阻礙演進
原因是可伸縮性不是一維標籤:說"X 是可伸縮的"或"Y 不伸縮"是沒有意義的。相反,討論可伸縮性意味著考慮諸如以下問題
原因在於可伸縮性不是一維標籤“X 可伸縮”或“Y 不可伸縮”這種說法本身意義不大。更有意義的問題是
* "如果系統以特定方式增長,我們有什麼選擇來應對增長?"
* "我們如何增加計算資源來處理額外的負載?"
* "基於當前的增長預測,我們何時會達到當前架構的極限?"
* “如果系統按某種方式增長,我們有哪些應對選項?”
* “我們如何增加計算資源來承載額外負載?”
* “按當前增長趨勢,現有架構何時會觸頂?”
如果你成功地使你的應用程式受歡迎,因此處理越來越多的負載,你將瞭解你的效能瓶頸在哪裡,因此你將知道需要沿著哪些維度進行伸縮。那時是開始擔心可伸縮性技術的時候
當你的產品真的做起來、負載持續上升時,你自然會看到瓶頸在哪裡,也就知道該沿哪些維度擴充套件。那時再系統性投入可伸縮性技術,通常更合適
### 描述負載 {#id33}
首先,我們需要簡潔地描述系統上的當前負載;只有這樣我們才能討論增長問題(如果我們的負載翻倍會發生什麼?)。通常這將是吞吐量的度量:例如,對服務的每秒請求數、每天到達多少千兆位元組的新資料,或每小時購物車結賬的數量。有時你關心某個變數數值的峰值,例如 ["案例研究:社交網路首頁時間線"](/tw/ch2#sec_introduction_twitter) 中同時線上使用者的數量
首先要簡明描述系統當前負載之後才能討論“增長會怎樣”例如負載翻倍會發生什麼。最常見的是吞吐量指標每秒請求數、每天新增資料量GB、每小時購物車結賬次數等。有時你關心的是峰值變數如 ["案例研究:社交網路首頁時間線"](/tw/ch2#sec_introduction_twitter) 裡的“同時線上使用者數”
通常還有其他負載統計特徵,會影響訪問模式,從而影響可擴充套件性要求。例如,你可能需要知道資料庫中的讀寫比率、快取的命中率或每個使用者的資料項數量(例如,社交網路案例研究中的粉絲數量)。也許平均情況對你很重要,或者也許你的瓶頸由少數極端情況主導。這一切都取決於你特定應用程式的細節。
此外還可能有其他統計特徵會影響訪問模式,進而影響可伸縮性要求。例如資料庫讀寫比、快取命中率、每使用者資料項數量(如社交網路裡的追隨者數)。有時平均情況最關鍵,有時瓶頸由少數極端情況主導,具體取決於你的應用細節。
一旦你描述了系統上的負載,你就可以調查當負載增加時會發生什麼。你可以從兩個方面來看待它
當負載被清楚描述後,就可以分析“負載增加時系統會怎樣”。可從兩個角度看
* 當你以某種方式增加負載並保持系統資源CPU、記憶體、網路頻寬等不變時系統的效能如何受到影響
* 當你以某種方式增加負載時,如果你想保持效能不變,你需要增加多少資源?
* 以某種方式增大負載、但保持資源CPU、記憶體、網路頻寬等不變時效能如何變化
* 若負載按某種方式增長、但你希望效能不變,需要增加多少資源?
通常我們的目標是在最小化執行系統成本的同時保持系統性能在 SLA 的要求範圍內(參見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。所需的計算資源越多,成本就越高。可能某些型別的硬體比其他型別更具成本效益,這些因素可能會隨著新型別硬體的出現而隨時間變化。
通常目標是:在儘量降低執行成本的同時,讓效能維持在 SLA 要求內(參見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。所需計算資源越多,成本越高。不同硬體的價效比不同,而且會隨著新硬體出現而變化。
如果你可以將資源翻倍以處理兩倍的負載,同時保持效能不變,我們說你有 *線性可伸縮性*,這被認為是好事。偶爾,由於規模經濟或峰值負載的更好分佈,可以用不到兩倍的資源處理兩倍的負載 [^79] [^80]。更可能的是,成本增長速度快於線性,並且效率低下可能有許多原因。例如,如果你有大量資料,那麼處理單個寫請求可能涉及比你有少量資料時更多的工作,即使請求的大小相同
如果資源翻倍後能承載兩倍負載且效能不變,這稱為 *線性可伸縮性*,通常是理想狀態。偶爾,藉助規模效應或峰值負載更均勻分佈,甚至可用不足兩倍資源處理兩倍負載 [^79] [^80]。但更常見的是成本增長快於線性,低效原因也很多。比如資料量增大後,即使請求大小相同,處理一次寫請求也可能比資料量小時更耗資源
### 共享記憶體、共享磁碟與無共享架構 {#sec_introduction_shared_nothing}
增加服務硬體資源的最簡單方法是將其移動到更強大的機器。單個 CPU 核心不再變得顯著更快,但你可以購買一臺機器(或租用雲實例)具有更多 CPU 核心、更多 RAM 和更多磁碟空間。這種方法稱為 *縱向伸縮**向上擴充套件*
增加服務硬體資源的最簡單方式,是遷移到更強的機器。雖然單核 CPU 不再明顯提速,但你仍可購買(或租用)擁有更多 CPU 核心、更多 RAM、更多磁碟的例項。這叫 *縱向伸縮*scaling up
你可以透過使用多個程序或執行緒在單臺機器上獲得並行性。屬於同一程序的所有執行緒都可以訪問相同的 RAM因此這種方法也稱為 *共享記憶體架構*。共享記憶體方法的問題是成本增長速度快於線性:具有兩倍硬體資源的高階機器通常成本遠遠超過兩倍。由於瓶頸,兩倍大小的機器通常只能處理不到兩倍的負載
在單機上,你可以透過多程序/多執行緒獲得並行性。同一程序內執行緒共享同一塊 RAM因此這也叫 *共享記憶體架構*。問題是它的成本常常“超線性增長”:硬體資源翻倍的高階機器,價格往往遠超兩倍;且受限於瓶頸,效能提升通常又達不到兩倍
另一種方法是 *共享磁碟架構*,它使用幾臺具有獨立 CPU 和 RAM 的機器,但將資料儲存在機器之間共享的磁碟陣列上,這些機器透過快速網路連線:*網路附加儲存*NAS*儲存區域網路*SAN。這種架構傳統上用於本地資料倉庫工作負載但爭用和鎖定的開銷限制了共享磁碟方法的可伸縮性 [^81]。
另一種方案是 *共享磁碟架構*:多臺機器各有獨立 CPU 和 RAM但共享同一組磁碟陣列透過高速網路連線NAS 或 SAN。該架構傳統上用於本地資料倉庫場景但爭用與鎖開銷限制了其可伸縮性 [^81]。
相比之下,*無共享架構* [^82]也稱為 *橫向伸縮**向外擴充套件*)已經獲得了很大的流行。在這種方法中,我們使用具有多個節點的分散式系統,每個節點都有自己的 CPU、RAM 和磁碟。節點之間的任何協調都在軟體級別透過傳統網路完成。
相比之下,*無共享架構* [^82]*橫向伸縮*、scaling out已廣泛流行。這種方案使用多節點分散式系統每個節點擁有自己的 CPU、RAM 和磁碟;節點間協作透過常規網路在軟體層完成。
無共享的優點是它有線性伸縮的潛力,它可以使用提供最佳價效比的任何硬體(特別是在雲中),它可以隨著負載的增加或減少更容易地調整其硬體資源,並且它可以透過在多個數據中心和地區分佈系統來實現更大的容錯。缺點是它需要顯式分片(參見 [第 7 章](/tw/ch7)),並且它會產生分散式系統的所有複雜性([第 9 章](/tw/ch9))。
無共享的優勢在於:具備線性伸縮潛力、可靈活選用高性價比硬體(尤其在雲上)、更容易隨負載增減調整資源,並可透過跨多個數據中心/地域部署提升容錯。代價是:需要顯式分片(見 [第 7 章](/tw/ch7)),並承擔分散式系統的全部複雜性(見 [第 9 章](/tw/ch9))。
一些雲原生資料庫系統為儲存和事務執行使用單獨的服務(參見 ["儲存與計算分離"](/tw/ch1#sec_introduction_storage_compute)多個計算節點共享對同一儲存服務的訪問。這個模型與共享磁碟架構有一些相似之處但它避免了舊系統的可伸縮性問題它不是提供檔案系統NAS或塊裝置SAN抽象而是儲存服務提供專門為資料庫特定需求設計的 API [^83]。
一些雲原生資料庫把“儲存”和“事務執行”拆成獨立服務(參見 ["儲存與計算分離"](/tw/ch1#sec_introduction_storage_compute)由多個計算節點共享同一儲存服務。這種模式與共享磁碟有相似性,但規避了老系統的可伸縮瓶頸:它不暴露 NAS/SAN 那種檔案系統或塊裝置抽象,而是提供面向資料庫場景定製的儲存 API [^83]。
### 可伸縮性原則 {#id35}
在大規模執行的系統架構通常對應用程式高度特定——沒有通用的、一刀切的可伸縮架構(非正式地稱為 *萬金油*)。例如,設計用於處理每秒 100,000 個請求(每個 1 kB 大小)的系統與設計用於每分鐘 3 個請求(每個 2 GB 大小的系統看起來非常不同——即使兩個系統具有相同的資料吞吐量100 MB/秒)
能夠大規模執行的系統架構,通常高度依賴具體應用,不存在通用“一招鮮”的可伸縮架構(俗稱 *萬金油*)。例如:面向“每秒 10 萬次請求、每次 1 kB”的系統與面向“每分鐘 3 次請求、每次 2 GB”的系統形態會完全不同儘管二者資料吞吐量都約為 100 MB/s
此外,適合一個負載級別的架構不太可能應對 10 倍的負載。如果你正在開發快速增長的服務,因此很可能你需要在每個數量級的負載增加時重新考慮你的架構。由於應用程式的需求可能會演變,通常不值得提前規劃超過一個數量級的未來伸縮需求。
此外,適合某一級負載的架構,通常難以直接承受 10 倍負載。若你在做高速增長服務,幾乎每跨一個數量級都要重新審視架構。考慮到業務需求本身也會變化,提前規劃超過一個數量級的未來伸縮需求,往往不划算
可伸縮性的一個良好通用原則是將系統分解為可以在很大程度上相互獨立執行的較小元件。這是微服務背後的基本原則(參見 ["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices))、分片([第 7 章](/tw/ch7))、流處理([待補充連結])和無共享架構。然而,挑戰在於知道在哪裡劃分應該在一起的事物和應該分開的事物之間的界限。微服務的設計指南可以在其他書籍中找到 [^84],我們在 [第 7 章](/tw/ch7) 中討論無共享系統的分片
可伸縮性的一個通用原則,是把系統拆分成儘量可獨立執行的小元件。這也是微服務(參見 ["微服務與無伺服器"](/tw/ch1#sec_introduction_microservices))、分片([第 7 章](/tw/ch7))、流處理([第 12 章](/tw/ch12#ch_stream))和無共享架構的共同基礎。難點在於:哪裡該拆,哪裡該合。微服務設計可參考其他書籍 [^84];無共享系統的分片問題我們會在 [第 7 章](/tw/ch7) 討論
另一個好原則是不要讓事情變得比必要的更複雜。如果單機資料庫可以完成工作,它可能比複雜的分散式設定更可取。自動伸縮系統(根據需求自動新增或刪除資源)很酷,但如果你的負載相當可預測,手動伸縮的系統可能會有更少的操作意外(參見 ["操作:自動或手動再平衡"](/tw/ch7#sec_sharding_operations))。具有五個服務的系統比具有五十個服務的系統更簡單。良好的架構通常涉及多種方案的務實混合。
另一個好原則是:不要把系統做得比必要更複雜。若單機資料庫足夠,就往往優於複雜分散式方案。自動伸縮(按需求自動加減資源)很吸引人,但若負載相對可預測,手動伸縮可能帶來更少運維意外(參見 ["操作:自動或手動再平衡"](/tw/ch7#sec_sharding_operations))。5 個服務的系統通常比 50 個服務更簡單。好架構往往是多種方案的務實組合。
## 可維護性 {#sec_introduction_maintainability}
軟體不會磨損或遭受材料老化,因此它不會像機械物體那樣損壞。但應用程式的需求經常變化,軟體執行在變化的環境中(例如其依賴項和底層平臺),並且它有需要修復的錯誤
軟體不會像機械裝置那樣磨損或材料疲勞,但應用需求會變化,軟體所處環境(依賴項、底層平臺)也會變化,程式碼中還會持續暴露需要修復的缺陷
人們普遍認為,軟體的大部分成本不在其初始開發中,而在其持續維護中——修復錯誤、保持其系統執行、調查故障、將其適應新平臺、為新用例修改它、償還技術債務和新增新功能 [^85] [^86]。
業界普遍認同:軟體成本的大頭不在初始開發,而在後續維護,包括修 bug、保障系統穩定執行、排查故障、適配新平臺、支援新場景、償還技術債以及持續交付新功能 [^85] [^86]。
然而,維護也很困難。如果系統已成功執行很長時間,它可能使用如今很少有工程師理解的過時技術(如大型機和 COBOL 程式碼);隨著人員的離開,關於系統如何以及為何以某種特定方式設計的制度性知識可能已經丟失了;可能需要修復其他人的錯誤。此外,計算機系統通常與它支援的人類組織交織在一起,這意味著此類 *遺留* 系統的維護既是人的問題,也是技術問題 [^87]。
然而維護並不容易。一個長期執行成功的系統,可能仍依賴今天少有人熟悉的舊技術(如大型機和 COBOL隨著人員流動系統為何如此設計的組織記憶也可能丟失維護者往往還要修復前人留下的問題。更重要的是計算機系統通常與其支撐的組織流程深度耦合這使得 *遺留* 系統維護既是技術問題,也是人員與組織問題 [^87]。
如果我們今天建立的每個系統都足夠有價值以長期生存,它有一天將成為遺留系統。為了最小化需要維護我們軟體的未來幾代人的痛苦,我們應該在設計時考慮維護問題。儘管我們不能總是預測哪些決定可能會在未來造成維護難題,但在本書中,我們將注意幾個廣泛適用的原則:
如果今天構建的系統足夠有價值並長期存活,它終有一天會變成遺留系統。為減少後繼維護者的痛苦,我們應在設計階段就考慮維護性。雖然難以準確預判哪些決策會在未來埋雷,但本書會強調幾條廣泛適用的原則:
可運維性Operability
: 使組織容易保持系統平穩執行。
: 讓組織能夠更容易地保持系統平穩執行。
簡單性Simplicity
: 透過使用易於理解、一致的模式和結構來實施它,並避免不必要的複雜性,使新工程師容易理解系統。
: 採用易理解且一致的模式與結構,避免不必要複雜性,讓新工程師也能快速理解系統。
可演化性Evolvability
: 使工程師將來容易對系統進行更改,隨著需求變化而適應和擴充套件它以用於未預料的使用場景。
: 讓工程師在未來能更容易修改系統,使其隨著需求變化而持續適配並擴充套件到未預料場景。
### 可運維性:讓運維更輕鬆 {#id37}
我們之前在 ["雲時代的運維"](/tw/ch1#sec_introduction_operations) 中討論了運維的角色,我們看到人類流程對於可靠運維至少與軟體工具一樣重要。
事實上,有人提出 “良好的運維通常可以解決糟糕(或不完整)軟體的侷限性,但再好的軟體碰上糟糕的運維也難以可靠地執行” [^60]。
我們在 ["雲時代的運維"](/tw/ch1#sec_introduction_operations) 已討論過運維角色:可靠執行不僅依賴工具,人類流程同樣關鍵。甚至有人指出:“好的運維常能繞過糟糕(或不完整)軟體的侷限;但再好的軟體,碰上糟糕運維也難以可靠執行” [^60]。
在由數千臺機器組成的大規模系統中,手動維護將是不合理地昂貴的,自動化是必不可少的。然而,自動化可能是一把雙刃劍:
總會有邊際場景(如罕見的故障場景)需要運維團隊的手動干預。由於無法自動處理的情況是最複雜的問題,更大的自動化需要一個 **更** 熟練的運維團隊來解決這些問題 [^88]。
在由成千上萬臺機器組成的大規模系統中,純手工維護成本不可接受,自動化必不可少。但自動化也是雙刃劍:總會有邊緣場景(如罕見故障)需要運維團隊人工介入。並且“自動化處理不了”的往往恰恰最複雜,因此自動化越深,越需要 **更** 高水平的運維團隊來兜底 [^88]。
此外,如果自動化系統出錯,通常比依賴操作員手動執行某些操作的系統更難排除故障。出於這個原因,更多的自動化並不總是對可操作性更好。
然而,一定程度的自動化很重要,最佳點將取決於你特定應用程式和組織的細節。
另外,一旦自動化系統本身出錯,往往比“部分依賴人工操作”的系統更難排查。因此自動化並非越多越好。合理自動化程度取決於你所在應用與組織的具體條件。
良好的可操作性意味著使常規任務變得容易,使運維團隊能夠將精力集中在高價值活動上。
資料系統可以做各種事情來使常規任務變得容易,包括 [^89]
良好的可運維性意味著把日常任務做簡單,讓運維團隊把精力投入到高價值工作。資料系統可以透過多種方式達成這一點 [^89]
* 允許監控工具檢查系統的關鍵指標,並支援可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems))以深入瞭解系統的執行時行為。各種商業和開源工具可以在這方面提供幫助 [^90]。
* 避免對單個機器的依賴(允許在系統整體持續不間斷執行的同時關閉機器進行維護)
* 提供良好的文件和易於理解的操作模型("如果我做 XY 將會發生"
* 提供良好的預設行為,但也給管理員在需要時覆蓋預設值的自由
* 在適當的地方自我修復,但也在需要時給管理員手動控制系統狀態
* 表現出可預測的行為,最小化意外
* 讓監控工具能獲取關鍵指標,並支援可觀測性工具(參見 ["分散式系統的問題"](/tw/ch1#sec_introduction_dist_sys_problems))以洞察執行時行為。相關商業/開源工具都很多 [^90]。
* 避免依賴單機(系統整體不停機的前提下允許下線機器維護)。
* 提供完善文件和易理解的操作模型(“我做 X會發生 Y”
* 提供良好預設值,同時允許管理員在需要時覆蓋預設行為。
* 適當支援自愈,同時在必要時保留管理員對系統狀態的手動控制權。
* 行為可預測,儘量減少“驚喜”。
### 簡單性:管理複雜度 {#id38}
小型軟體專案可以有令人愉悅的、簡單而富有表現力的程式碼,但隨著專案變大,它們通常變得非常複雜且難以理解。
這種複雜性減慢了需要在系統上工作的每個人的效率,進一步增加了維護成本。陷入複雜性的軟體專案有時被描述為 *大泥團* [^91]。
小型專案往往能保持簡潔、優雅、富有表達力;但專案變大後,程式碼常會迅速變複雜且難理解。這種複雜性會拖慢所有參與者效率,進一步抬高維護成本。陷入這種狀態的軟體專案常被稱為 *大泥團* [^91]。
當複雜性使維護困難時,預算和時間表經常超支。在複雜軟體中,進行更改時引入錯誤的風險也更大:
當系統對開發人員來說更難理解和推理時,隱藏的假設、意外的後果和意外的互動更容易被忽視 [^69]。
相反,降低複雜性極大地提高了軟體的可維護性,因此簡單性應該是我們構建的系統的關鍵目標。
當複雜性讓維護變難時,預算和進度常常失控。在複雜軟體裡,變更時引入缺陷的風險也更高:系統越難理解和推理,隱藏假設、非預期後果和意外互動就越容易被忽略 [^69]。反過來,降低複雜性能顯著提升可維護性,因此“追求簡單”應是系統設計核心目標之一。
簡單系統更容易理解,因此我們應該嘗試以儘可能簡單的方式解決給定問題。不幸的是,這說起來容易做起來難。
某物是否簡單通常是主觀的品味問題,因為沒有客觀的簡單性標準 [^92]。例如,一個系統可能在簡單介面後面隱藏複雜的實現,
而另一個系統可能有一個向用戶公開更多內部細節的簡單實現——哪一個更簡單?
簡單系統更容易理解,因此我們應儘可能用最簡單方式解決問題。但“簡單”知易行難。什麼叫簡單,往往帶有主觀判斷,因為不存在絕對客觀的簡單性標準 [^92]。例如,一個系統可能“介面簡單但實現複雜”,另一個可能“實現簡單但暴露更多內部細節”,到底誰更簡單,並不總有標準答案。
推理複雜性的一種嘗試是將其分為兩類,**本質複雜性** 和 **偶然複雜性** [^93]。
這個想法是,本質複雜性是應用程式問題域中固有的,而偶然複雜性僅由於我們工具的限制而產生。
不幸的是,這種區別也有缺陷,因為本質和偶然之間的邊界隨著我們工具的發展而變化 [^94]。
一種常見分析方法是把複雜性分成兩類:**本質複雜性** 與 **偶然複雜性** [^93]。前者源於業務問題本身,後者源於工具與實現限制。但這種劃分也並不完美,因為隨著工具演進,“本質”和“偶然”的邊界會移動 [^94]。
我們管理複雜性的最佳工具之一是 **抽象**。良好的抽象可以在乾淨、易於理解的外觀後面隱藏大量實現細節。良好的抽象也可以用於各種不同的應用程式。
這種重用不僅比多次重新實現類似的東西更有效,而且還能提高軟體質量,因為抽象元件中的質量改進使所有使用它的應用程式受益。
管理複雜度最重要的工具之一是 **抽象**。好的抽象能在清晰外觀後隱藏大量實現細節,也能被多種場景複用。這種複用不僅比反覆重寫更高效,也能提升質量,因為抽象元件一旦改進,所有依賴它的應用都會受益。
例如高階程式語言是隱藏機器碼、CPU 暫存器和系統呼叫的抽象。SQL 是一種隱藏磁碟和記憶體中的複雜資料結構、來自其他客戶端的併發請求以及崩潰後的不一致性的抽象。
當然,在用高階語言程式設計時,我們仍在使用機器碼;我們只是不 *直接* 使用它,因為程式語言抽象使我們不必考慮它。
例如高階語言是對機器碼、CPU 暫存器和系統呼叫的抽象。SQL 則抽象了磁碟/記憶體中的複雜資料結構、來自其他客戶端的併發請求,以及崩潰後的不一致狀態。用高階語言程式設計時,我們仍然在“使用機器碼”,但不再 *直接* 面對它,因為語言抽象替我們遮蔽了細節。
應用程式程式碼的抽象,旨在降低其複雜性,可以使用諸如 *設計模式* [^95] 和 *領域驅動設計*DDD[^96] 等方法建立。
本書不是關於此類特定於應用程式的抽象,而是關於你可以在其上構建應用程式的通用抽象,例如資料庫事務、索引和事件日誌。如果你想使用像 DDD 這樣的技術,你可以在本書中描述的基礎之上實現它們。
應用程式碼層面的抽象,常藉助 *設計模式* [^95]、*領域驅動設計*DDD[^96] 等方法來構建。本書重點不在這類應用專用抽象,而在你可以拿來構建應用的通用抽象,例如資料庫事務、索引、事件日誌等。若你想採用 DDD 等方法,也可以建立在本書介紹的基礎能力之上。
### 可演化性:讓變化更容易 {#sec_introduction_evolvability}
你的系統需求將保持不變的可能性極小。它們更可能處於不斷變化中:
你學習新事實、以前未預料的用例出現、業務優先順序發生變化、使用者請求新功能、
新平臺取代舊平臺、法律或監管要求發生變化、系統增長迫使架構變化等。
系統需求永遠不變的機率極低。更常見的是持續變化:你會發現新事實,出現此前未預期用例,業務優先順序會調整,使用者會提出新功能,新平臺會替換舊平臺,法律與監管會變化,系統增長也會倒逼架構調整。
在組織流程方面,*敏捷* 工作模式為適應變化提供了框架。敏捷社群還開發了在頻繁變化的環境中開發軟體時有用的技術工具和流程,
例如測試驅動開發TDD和重構。在本書中我們探尋在由具有不同特徵的幾個不同應用程式或服務組成的系統級別增加敏捷性的方法。
在組織層面,*敏捷* 方法為適應變化提供了框架敏捷社群也發展出多種適用於高變化環境的技術與流程如測試驅動開發TDD和重構。本書關注的是如何在“由多個不同應用/服務組成的系統層級”提升這種敏捷能力。
你可以修改資料系統並使其適應不斷變化的需求的容易程度與其簡單性及其抽象密切相關:松耦合、簡單的系統通常比緊耦合、複雜的系統更容易修改。
由於這是一個如此重要的概念,我們將使用一個不同的詞來指代資料系統級別的敏捷性:*可演化性* [^97]。
資料系統對變化的適應難易度,與其簡單性和抽象質量高度相關:松耦合、簡單系統通常比緊耦合、複雜系統更容易修改。由於這一點極其重要,我們把“資料系統層面的敏捷性”單獨稱為 *可演化性* [^97]。
使大型系統中的變化困難的一個主要因素是某些操作不可逆,因此需要非常謹慎地採取該操作 [^98]。
例如,假設你正在從一個數據庫遷移到另一個數據庫:如果在新資料庫出現問題時無法切換回舊系統,風險就會高得多,而如果你可以輕鬆返回。最小化不可逆性提高了靈活性。
大型系統中讓變更困難的一個關鍵因素,是某些操作不可逆,因此執行時必須極其謹慎 [^98]。例如從一個數據庫遷移到另一個:若新庫出問題後無法回切,風險就遠高於可隨時回退。儘量減少不可逆操作,能顯著提升系統靈活性。
## 總結 {#summary}
在本章中,我們研究了幾個非功能性需求的例子:效能、可靠性、可伸縮性和可維護性。
透過這些主題,我們還遇到了我們在本書其餘部分需要的原則和術語。我們從社交網路中首頁時間線如何實現的案例研究開始,這說明了在規模擴大時出現的一些挑戰。
本章討論了幾類核心非功能性需求:效能、可靠性、可伸縮性與可維護性。圍繞這些主題,我們也建立了貫穿全書的一組概念與術語。章節從“社交網路首頁時間線”案例切入,直觀展示了系統在規模增長時會遇到的現實挑戰。
我們討論了如何衡量效能(例如,使用響應時間百分位數)、系統上的負載(例如,使用吞吐量指標),以及它們如何在 SLA 中使用。
可伸縮性是一個密切相關的概念:即,在負載增長時確保效能保持不變。我們看到了可伸縮性的一些通用性原則,例如將任務分解為可以獨立執行的較小部分,我們將在以下章節中深入研究可伸縮性技術的技術細節。
我們討論了如何衡量效能(例如響應時間百分位點)、如何描述系統負載(例如吞吐量指標),以及這些指標如何進入 SLA。與之緊密相關的是可伸縮性當負載增長時如何保持效能不退化。我們也給出了若干通用原則例如將任務拆解為可獨立執行的小元件。後續章節會深入展開相關技術細節。
為了實現可靠性,你可以使用容錯技術,即使某個元件(例如,磁碟、機器或其他服務)出現故障,系統也可以繼續提供其服務。
我們看到了可能發生的硬體故障的例子,並將它們與軟體故障區分開來,軟體故障可能更難處理,因為它們通常是強相關的。
實現可靠性的另一個方面是建立對人類犯錯誤的韌性,我們看到無責備事後分析作為從事件中學習的技術。
為實現可靠性,可以使用容錯機制,使系統在部分元件(如磁碟、機器或外部服務)故障時仍能持續提供服務。我們區分了硬體故障與軟體故障,並指出軟體故障常更難處理,因為它們往往高度相關。可靠性的另一面是“對人為失誤的韌性”,其中 *無責備事後分析* 是重要學習機制。
最後,我們研究了可維護性的幾個方面,包括支援運維團隊的工作、管理複雜性以及隨著時間的推移使應用程式功能易於演化。
實現這些目標沒有簡單的答案,但有一件事可以幫助,那就是使用提供有用抽象的易於理解的構建塊來構建應用程式。本書的其餘部分將涵蓋一系列在實踐中被證明有價值的構建塊。
最後,我們討論了可維護性的多個維度:支援運維工作、管理複雜度、提升系統可演化性。實現這些目標沒有銀彈,但一個普遍有效的做法是:用清晰、可理解、具備良好抽象的構件來搭建系統。接下來全書會介紹一系列在實踐中證明有效的構件。
### 參考 {#參考}
### 參考文獻
[^1]: Mike Cvet. [How We Learned to Stop Worrying and Love Fan-In at Twitter](https://www.youtube.com/watch?v=WEgCjwyXvwc). At *QCon San Francisco*, December 2016.
[^2]: Raffi Krikorian. [Timelines at Scale](https://www.infoq.com/presentations/Twitter-Timeline-Scalability/). At *QCon San Francisco*, November 2012. Archived at [perma.cc/V9G5-KLYK](https://perma.cc/V9G5-KLYK)

View file

@ -167,7 +167,7 @@ SELECT users.*, regions.region_name
WHERE users.id = 251;
```
文件資料庫可以儲存正規化和反正規化的資料,但它們通常與反正規化相關聯 —— 部分是因為 JSON 資料模型使得儲存額外的反正規化欄位變得容易,部分是因為許多文件資料庫中對連線的弱支援使得正規化不方便。一些文件資料庫根本不支援連線,因此你必須在應用程式程式碼中執行它們 —— 也就是說,你首先獲取包含 ID 的文件,然後執行第二個查詢將該 ID 解析為另一個文件。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 運算執行連線:
文件資料庫可以儲存正規化和反正規化的資料,但它們通常與反正規化相關聯 —— 部分是因為 JSON 資料模型使得儲存額外的反正規化欄位變得容易,部分是因為許多文件資料庫中對連線的弱支援使得正規化不方便。一些文件資料庫根本不支援連線,因此你必須在應用程式程式碼中執行它們 —— 也就是說,你首先獲取包含 ID 的文件,然後執行第二個查詢將該 ID 解析為另一個文件。在 MongoDB 中,也可以使用聚合管道中的 `$lookup` 運算執行連線:
```mongodb-json
db.users.aggregate([
@ -190,9 +190,9 @@ db.users.aggregate([
* 在反正規化表示中,我們會在每個人的個人資料中包含標誌的影像 URL這使得 JSON 文件自包含,但如果我們需要更改標誌,就會產生麻煩,因為我們現在需要找到舊 URL 的所有出現並更新它們 [^9]。
* 在正規化表示中,我們將建立一個代表組織或學校的實體,並在該實體上儲存其名稱、標誌 URL 以及可能的其他屬性(描述、新聞提要等)一次。然後,每個提到該組織的簡歷都會簡單地引用其 ID更新標誌很容易。
作為一般原則,正規化資料通常寫入更快(因為只有一個副本),但查詢更慢(因為它需要連線);反正規化資料通常讀取更快(連線更少),但寫入更昂貴(更多副本要更新,使用更多磁碟空間)。你可能會發現將反正規化視為派生資料的一種形式很有幫助(["記錄系統派生資料"](/tw/ch1#sec_introduction_derived)),因為你需要設定一個過程來更新資料的冗餘副本。
作為一般原則,正規化資料通常寫入更快(因為只有一個副本),但查詢更慢(因為它需要連線);反正規化資料通常讀取更快(連線更少),但寫入更昂貴(更多副本要更新,使用更多磁碟空間)。你可能會發現將反正規化視為派生資料的一種形式很有幫助(["記錄系統派生資料"](/tw/ch1#sec_introduction_derived)),因為你需要設定一個過程來更新資料的冗餘副本。
除了執行所有這些更新的成本之外,如果程序在進行更新的過程中崩潰,你還需要考慮資料庫的一致性。提供原子事務的資料庫(參見 ["原子性"](/tw/ch8#sec_transactions_acid_atomicity))使保持一致性變得更容易,但並非所有資料庫都在多個文件之間提供原子性。透過流處理確保一致性也是可能的,我們將在 [待補充連結] 中討論。
除了執行所有這些更新的成本之外,如果程序在進行更新的過程中崩潰,你還需要考慮資料庫的一致性。提供原子事務的資料庫(參見 ["原子性"](/tw/ch8#sec_transactions_acid_atomicity))使保持一致性變得更容易,但並非所有資料庫都在多個文件之間提供原子性。透過流處理確保一致性也是可能的,我們將在 ["保持系統同步"](/tw/ch12#sec_stream_sync) 中討論。
正規化往往更適合 OLTP 系統,其中讀取和更新都需要快速;分析系統通常使用反正規化資料表現更好,因為它們批次執行更新,只讀查詢的效能是主要關注點。此外,在中小規模的系統中,正規化資料模型通常是最好的,因為你不必擔心保持資料的多個副本相互一致,執行連線的成本是可以接受的。然而,在非常大規模的系統中,連線的成本可能會成為問題。
@ -200,7 +200,7 @@ db.users.aggregate([
在 ["案例研究:社交網路首頁時間線"](/tw/ch2#sec_introduction_twitter) 中,我們比較了正規化表示([圖 2-1](/tw/ch2#fig_twitter_relational))和反正規化表示(預計算的物化時間線):這裡,`posts` 和 `follows` 之間的連線太昂貴了,物化時間線是該連線結果的快取。將新帖子插入關注者時間線的扇出過程是我們保持反正規化表示一致的方式。
然而X前 Twitter的物化時間線實現實際上並不儲存每個帖子的實際文字每個條目實際上只儲存帖子 ID、釋出者的使用者 ID以及一些額外的資訊來識別轉發和回覆 [^11]。換句話說,它是(大約)以下查詢的預計算結果:
然而X前 Twitter的物化時間線實現實際上並不儲存每個帖子的實際文字每個條目實際上只儲存帖子 ID、釋出者的使用者 ID以及一些額外的資訊來識別轉發和回覆 [^11]。換句話說,它大致是以下查詢的預計算結果:
```sql
SELECT posts.id, posts.sender_id
@ -211,11 +211,11 @@ SELECT posts.id, posts.sender_id
LIMIT 1000
```
這意味著每當讀取時間線時,服務仍然需要執行兩個連線:透過 ID 查詢帖子以獲取實際的帖子內容(以及點贊數和回覆數等統計資訊),並透過 ID 查詢傳送者的個人資料(以獲取他們的使用者名稱、個人資料圖片和其他詳細資訊)。這個透過 ID 查詢人類可讀資訊的過程稱為 *hydrating* ID本質上是在應用程式程式碼中執行的連線 [^11]。
這意味著每當讀取時間線時,服務仍然需要執行兩個連線:透過 ID 查詢帖子以獲取實際的帖子內容(以及點贊數和回覆數等統計資訊),並透過 ID 查詢傳送者的個人資料(以獲取他們的使用者名稱、個人資料圖片和其他詳細資訊)。這個將 ID 補全為人類可讀資訊的過程稱為 *hydrating* ID本質上是在應用程式程式碼中執行的連線 [^11]。
在預計算時間線中僅儲存 ID 的原因是它們引用的資料變化很快:熱門帖子的點贊數和回覆數可能每秒變化多次,一些使用者定期更改他們的使用者名稱或個人資料照片。由於時間線在檢視時應該顯示最新的點贊數和個人資料圖片,因此將此資訊反正規化到物化時間線中是沒有意義的。此外,這種反正規化會顯著增加儲存成本。
這個例子表明,在讀取資料時必須執行連線並不像有時聲稱的那樣,是建立高效能、可擴充套件服務的障礙。Hydrating 帖子 ID 和使用者 ID 實際上是一個相當容易擴充套件的操作,因為它可以很好地並行化,並且成本不取決於你關注的戶數量或你擁有的關注者數量。
這個例子表明,在讀取資料時必須執行連線並不像有時聲稱的那樣,是建立高效能、可擴充套件服務的障礙。`hydrating` 帖子 ID 和使用者 ID 實際上是一個相當容易擴充套件的操作,因為它可以很好地並行化,並且成本不取決於你關注的戶數量或你擁有的關注者數量。
如果你需要決定是否在應用程式中反正規化某些內容,社交網路案例研究表明選擇並不是立即顯而易見的:最可擴充套件的方法可能涉及反正規化某些內容並保持其他內容正規化。你必須仔細考慮資訊更改的頻率以及讀寫成本(這可能由異常值主導,例如在典型社交網路的情況下擁有許多關注/關注者的使用者)。正規化和反正規化本質上並不好或壞 —— 它們只是在讀寫效能以及實施工作量方面的權衡。
@ -340,7 +340,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
XML 資料庫通常使用 XQuery 和 XPath 查詢,它們旨在允許複雜的查詢,包括跨多個文件的連線,並將其結果格式化為 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 為 JSON 提供了等效於 XPath 的功能。
MongoDB 的聚合管道,我們在 ["正規化、反正規化與連線"](/tw/ch3#sec_datamodels_normalization) 中看到了其用於連線的 `$lookup` 運算,是 JSON 文件集合查詢語言的一個例子。
MongoDB 的聚合管道,我們在 ["正規化、反正規化與連線"](/tw/ch3#sec_datamodels_normalization) 中看到了其用於連線的 `$lookup` 運算,是 JSON 文件集合查詢語言的一個例子。
讓我們看另一個例子來感受這種語言 —— 這次是聚合,這對分析特別需要。想象你是一名海洋生物學家,每次你在海洋中看到動物時,你都會向資料庫新增一條觀察記錄。現在你想生成一份報告,說明你每個月看到了多少條鯊魚。在 PostgreSQL 中,你可能會這樣表達該查詢:
@ -373,9 +373,9 @@ db.observations.aggregate([
#### 文件和關係資料庫的融合 {#convergence-of-document-and-relational-databases}
文件資料庫和關係資料庫最初是非常不同的資料管理方法,但隨著時間的推移,它們變得更加相似 [^31]。關係資料庫增加了對 JSON 型別和查詢運算的支援,以及索引文件內屬性的能力。一些文件資料庫(如 MongoDB、Couchbase 和 RethinkDB增加了對連線、二級索引和宣告式查詢語言的支援。
文件資料庫和關係資料庫最初是非常不同的資料管理方法,但隨著時間的推移,它們變得更加相似 [^31]。關係資料庫增加了對 JSON 型別和查詢運算的支援,以及索引文件內屬性的能力。一些文件資料庫(如 MongoDB、Couchbase 和 RethinkDB增加了對連線、二級索引和宣告式查詢語言的支援。
模型的這種融合對應用程式開發人員來說是個好訊息,因為當你可以在同一個資料庫中組合兩者時,關係模型和文件模型效果最好。許多文件資料庫需要對其他文件的關係式引用,許多關係資料庫在模式靈活性有益的部分。關係-文件混合是一個強大的組合。
模型的這種融合對應用程式開發人員來說是個好訊息,因為當你可以在同一個資料庫中組合兩者時,關係模型和文件模型效果最好。許多文件資料庫需要對其他文件進行關係式引用,許多關係資料庫也有一些場景更適合模式靈活性。關係-文件混合是一個強大的組合。
--------
@ -531,7 +531,7 @@ RETURN person.name
在我們的示例中,這發生在 Cypher 查詢中的 `() -[:WITHIN*0..]-> ()` 模式中。一個人的 `LIVES_IN` 邊可能指向任何型別的位置街道、城市、區district、地區region、州等。一個城市可能在`WITHIN`)某個地區,該地區在(`WITHIN`)某個州,該州在(`WITHIN`)某個國家,等等。`LIVES_IN` 邊可能直接指向你要查詢的位置頂點,或者它可能在位置層次結構中相距幾個級別。
在 Cypher 中,`:WITHIN*0..` 非常簡潔地表達了這個事實:它意味著"跟隨 `WITHIN` 邊,零次或多次"。它就像正則表示式中的 `*` 運算
在 Cypher 中,`:WITHIN*0..` 非常簡潔地表達了這個事實:它意味著"跟隨 `WITHIN` 邊,零次或多次"。它就像正則表示式中的 `*` 運算
自 SQL:1999 以來,查詢中可變長度遍歷路徑的想法可以使用稱為 *遞迴公用表表達式*`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](/tw/ch3#fig_graph_sql_query) 顯示了相同的查詢 —— 查詢從美國移民到歐洲的人的姓名 —— 使用此技術在 SQL 中表達。然而,與 Cypher 相比,語法非常笨拙。
@ -781,7 +781,7 @@ Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小
在 [示例 3-12](/tw/ch3#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](/tw/ch3#fig_datalog_naive) 所示):
@ -803,7 +803,7 @@ Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小
GraphQL 是一種查詢語言從設計上講它比我們在本章中看到的其他查詢語言限制性更強。GraphQL 的目的是允許在使用者裝置上執行的客戶端軟體(如移動應用程式或 JavaScript Web 應用程式前端)請求具有特定結構的 JSON 文件其中包含渲染其使用者介面所需的欄位。GraphQL 介面允許開發人員快速更改客戶端程式碼中的查詢,而無需更改伺服器端 API。
GraphQL 的靈活性是有代價的。採用 GraphQL 的組織通常需要工具將 GraphQL 查詢轉換為對內部服務的請求,這些服務通常使用 REST 或 gRPC參見 [第 5 章](/tw/ch5#ch_encoding))。授權、速率限制和效能挑戰是額外的關注點 [^61]。GraphQL 的查詢語言也受到限制,因為 GraphQL 來自不受信任的來源。該語言不允許任何可能執行成本高昂的操作否則使用者可能透過執行大量昂貴的查詢對伺服器執行拒絕服務攻擊。特別是GraphQL 不允許遞迴查詢(與 Cypher、SPARQL、SQL 或 Datalog 不同),並且不允許任意搜尋條件,如"查詢在美國出生並現在居住在歐洲的人"(除非服務所有者特別選擇提供此類搜尋功能)。
GraphQL 的靈活性是有代價的。採用 GraphQL 的組織通常需要工具將 GraphQL 查詢轉換為對內部服務的請求,這些服務通常使用 REST 或 gRPC參見 [第 5 章](/tw/ch5#ch_encoding))。授權、速率限制和效能挑戰是額外的關注點 [^61]。GraphQL 的查詢語言也受到限制,因為 GraphQL 查詢來自不受信任的來源。該語言不允許任何可能執行成本高昂的操作否則使用者可能透過執行大量昂貴的查詢對伺服器執行拒絕服務攻擊。特別是GraphQL 不允許遞迴查詢(與 Cypher、SPARQL、SQL 或 Datalog 不同),並且不允許任意搜尋條件,如"查詢在美國出生並現在居住在歐洲的人"(除非服務所有者特別選擇提供此類搜尋功能)。
儘管如此GraphQL 還是很有用的。[示例 3-13](/tw/ch3#fig_graphql_query) 顯示了如何使用 GraphQL 實現 Discord 或 Slack 等群聊應用程式。查詢請求使用者有權訪問的所有頻道,包括頻道名稱和每個頻道中的 50 條最新訊息。對於每條訊息,它請求時間戳、訊息內容以及訊息傳送者的姓名和個人資料圖片 URL。此外如果訊息是對另一條訊息的回覆查詢還會請求傳送者姓名和它所回覆的訊息內容可能以較小的字型呈現在回覆上方以提供一些上下文
@ -873,17 +873,17 @@ query ChatApp {
在我們迄今為止討論的所有資料模型中,資料以與寫入相同的形式被查詢 —— 無論是 JSON 文件、表中的行,還是圖中的頂點和邊。然而,在複雜的應用程式中,有時很難找到一種能夠滿足所有不同查詢和呈現資料方式的單一資料表示。在這種情況下,以一種形式寫入資料,然後從中派生出針對不同型別讀取最佳化的多種表示形式可能是有益的。
我們之前在 ["記錄系統派生資料"](/tw/ch1#sec_introduction_derived) 中看到了這個想法ETL參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))就是這種派生過程的一個例子。現在我們將進一步深入這個想法。如果我們無論如何都要從一種資料表示派生出另一種,我們可以選擇分別針對寫入和讀取最佳化的不同表示。如果你只想為寫入最佳化資料建模,而不關心高效查詢,你會如何建模?
我們之前在 ["記錄系統派生資料"](/tw/ch1#sec_introduction_derived) 中看到了這個想法ETL參見 ["資料倉庫"](/tw/ch1#sec_introduction_dwh))就是這種派生過程的一個例子。現在我們將進一步深入這個想法。如果我們無論如何都要從一種資料表示派生出另一種,我們可以選擇分別針對寫入和讀取最佳化的不同表示。如果你只想為寫入最佳化資料建模,而不關心高效查詢,你會如何建模?
也許寫入資料的最簡單、最快速和最具表現力的方式是 *事件日誌*:每次你想寫入一些資料時,你將其編碼為自包含的字串(可能是 JSON包括時間戳然後將其追加到事件序列中。此日誌中的事件是 *不可變的*:你永遠不會更改或刪除它們,你只會向日志追加更多事件(這可能會取代早期事件)。事件可以包含任意屬性。
[圖 3-8](/tw/ch3#fig_event_sourcing) 顯示了一個可能來自會議管理系統的示例。會議可能是一個複雜的業務領域:不僅個人參與者可以註冊並用信用卡付款,公司也可以批次訂購座位,透過發票付款,然後再將座位分配給個人。一些座位可能為演講者、贊助商、志願者助手等保留。預訂也可能被取消,與此同時,會議組織者可能透過將其移至不同的房間來更改活動的容量。在所有這些情況發生時,簡單地計算可用座位數量就成為一個具有挑戰性的查詢。
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="圖 3-8. 使用不可變事件日誌作為真相,並從中派生物化檢視。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="圖 3-8. 使用不可變事件日誌作為真相來源(權威資料來源),並從中派生物化檢視。" class="w-full my-4" >}}
在 [圖 3-8](/tw/ch3#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參與者進行和取消註冊)首先被儲存為事件。每當事件追加到日誌時,幾個 *物化檢視*(也稱為 *投影**讀模型*)也會更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為列印參與者徽章的印表機生成檔案。
使用事件作為真相,並將每個狀態變化表達為事件的想法被稱為 *事件溯源* [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則稱為 *命令查詢責任分離CQRS* [^64]。這些術語起源於領域驅動設計DDD社群儘管類似的想法已經存在很長時間了例如 *狀態機複製*(參見 ["使用共享日誌"](/tw/ch10#sec_consistency_smr))。
使用事件作為真相來源(權威資料來源),並將每個狀態變化表達為事件的想法被稱為 *事件溯源* [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則稱為 *命令查詢責任分離CQRS* [^64]。這些術語起源於領域驅動設計DDD社群儘管類似的想法已經存在很長時間了例如 *狀態機複製*(參見 ["使用共享日誌"](/tw/ch10#sec_consistency_smr))。
當用戶的請求進來時,它被稱為 *命令*,首先需要驗證。只有在命令已執行並確定有效(例如,請求的預訂有足夠的可用座位)後,它才成為事實,相應的事件被新增到日誌中。因此,事件日誌應該只包含有效事件,構建物化檢視的事件日誌消費者不允許拒絕事件。
@ -906,7 +906,7 @@ query ChatApp {
* 事件不可變的要求會在事件包含使用者的個人資料時產生問題,因為使用者可能行使他們的權利(例如,根據 GDPR請求刪除他們的資料。如果事件日誌是基於每個使用者的你可以刪除該使用者的整個日誌但如果你的事件日誌包含與多個使用者相關的事件這就不起作用了。你可以嘗試將個人資料儲存在實際事件之外或者使用金鑰對其進行加密你可以稍後選擇刪除該金鑰但這也使得在需要時更難重新計算派生狀態。
* 如果存在外部可見的副作用,重新處理事件需要小心 —— 例如,你可能不希望每次重建物化檢視時都重新發送確認電子郵件。
你可以在任何資料庫之上實現事件溯源,但也有一些專門設計來支援這種模式的系統,例如 EventStoreDB、MartenDB基於 PostgreSQL和 Axon Framework。你還可以使用訊息代理如 Apache Kafka來儲存事件日誌流處理器可以使物化檢視保持最新我們將在 [待補充連結] 中返回這些主題。
你可以在任何資料庫之上實現事件溯源,但也有一些專門設計來支援這種模式的系統,例如 EventStoreDB、MartenDB基於 PostgreSQL和 Axon Framework。你還可以使用訊息代理如 Apache Kafka來儲存事件日誌流處理器可以使物化檢視保持最新我們將在 ["資料變更捕獲與事件溯源"](/tw/ch12#sec_stream_event_sourcing) 中回到這些主題。
唯一重要的要求是事件儲存系統必須保證所有物化檢視以與它們在日誌中出現的完全相同的順序處理事件;正如我們將在 [第 10 章](/tw/ch10#ch_consistency) 中看到的,這在分散式系統中並不總是容易實現。
@ -917,7 +917,7 @@ query ChatApp {
資料框是 R 語言、Python 的 Pandas 庫、Apache Spark、ArcticDB、Dask 和其他系統支援的資料模型。它們是資料科學家為訓練機器學習模型準備資料的流行工具,但它們也廣泛用於資料探索、統計資料分析、資料視覺化和類似目的。
乍一看,資料框類似於關係資料庫中的表或電子表格。它支援對資料框內容執行批次操作的類關係運算:例如,將函式應用於所有行、基於某些條件過濾行、按某些列對行進行分組並聚合其他列,以及基於某個鍵將一個數據框中的行與另一個數據框連線(關係資料庫稱為 *連線* 的操作在資料框上通常稱為 *合併*)。
乍一看,資料框類似於關係資料庫中的表或電子表格。它支援對資料框內容執行批次操作的類關係運算:例如,將函式應用於所有行、基於某些條件過濾行、按某些列對行進行分組並聚合其他列,以及基於某個鍵將一個數據框中的行與另一個數據框連線(關係資料庫稱為 *連線* 的操作在資料框上通常稱為 *合併*)。
資料框通常不是透過宣告式查詢(如 SQL而是透過一系列修改其結構和內容的命令來操作的。這符合資料科學家的典型工作流程他們逐步"整理"資料,使其成為能夠找到他們所提問題答案的形式。這些操作通常在資料科學家的資料集私有副本上進行,通常在他們的本地機器上,儘管最終結果可能與其他使用者共享。

View file

@ -4,6 +4,8 @@ weight: 104
breadcrumbs: false
---
<a id="ch_storage"></a>
![](/map/ch03.png)
> *生活的苦惱之一是,每個人對事物的命名都有些偏差。這讓我們理解世界變得比本該有的樣子困難一些,要是命名方式不同就好了。計算機的主要功能並不是傳統意義上的計算,比如算術運算。[……] 它們主要是歸檔系統。*
@ -19,7 +21,7 @@ breadcrumbs: false
特別是針對事務型工作負載OLTP最佳化的儲存引擎和針對分析型工作負載最佳化的儲存引擎之間存在巨大差異我們在 ["分析型與事務型系統"](/tw/ch1#sec_introduction_analytics) 中介紹了這種區別)。本章首先研究兩種用於 OLTP 的儲存引擎家族:寫入不可變資料檔案的 *日誌結構* 儲存引擎,以及像 *B 樹* 這樣就地更新資料的儲存引擎。這些結構既用於鍵值儲存,也用於二級索引。
隨後在 ["分析型資料儲存"](#sec_storage_analytics) 中,我們將討論一系列針對分析最佳化的儲存引擎;在 ["多維索引與全文索引"](#sec_storage_multidimensional) 中,我們將簡要介紹用於更高階查詢(如文字檢索)的索引。
隨後在 ["分析型資料儲存"](/tw/ch4#sec_storage_analytics) 中,我們將討論一系列針對分析最佳化的儲存引擎;在 ["多維索引與全文索引"](/tw/ch4#sec_storage_multidimensional) 中,我們將簡要介紹用於更高階查詢(如文字檢索)的索引。
## OLTP 系統的儲存與索引 {#sec_storage_oltp}
@ -39,7 +41,7 @@ db_get () {
這兩個函式實現了一個鍵值儲存。你可以呼叫 `db_set key value`,它將在資料庫中儲存 `key``value`。鍵和值可以是(幾乎)任何你喜歡的內容 —— 例如,值可以是一個 JSON 文件。然後你可以呼叫 `db_get key`,它會查詢與該特定鍵關聯的最新值並返回它。
它確實能工作
麻雀雖小,五臟俱全
```bash
$ db_set 12 '{"name":"London","attractions":["Big Ben","London Eye"]}'
@ -85,7 +87,7 @@ $ cat database
### 日誌結構儲存 {#sec_storage_log_structured}
首先,讓我們假設你想繼續將資料儲存在 `db_set` 寫入的僅追加檔案中,你只是想加快讀取速度。一種方法是在記憶體中保留一個雜湊對映,其中每個鍵都對映到檔案中可以找到該鍵最新值的位元組偏移量,如 [圖 4-1](#fig_storage_csv_hash_index) 所示。
首先,讓我們假設你想繼續將資料儲存在 `db_set` 寫入的僅追加檔案中,你只是想加快讀取速度。一種方法是在記憶體中保留一個雜湊對映,其中每個鍵都對映到檔案中可以找到該鍵最新值的位元組偏移量,如 [圖 4-1](/tw/ch4#fig_storage_csv_hash_index) 所示。
{{< figure src="/fig/ddia_0401.png" id="fig_storage_csv_hash_index" caption="圖 4-1. 以類似 CSV 格式儲存鍵值對日誌,使用記憶體雜湊對映建立索引。" class="w-full my-4" >}}
@ -100,15 +102,15 @@ $ 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](/tw/ch4#fig_storage_sstable_index) 所示。這種檔案格式也儲存鍵值對,但它確保它們按鍵排序,每個鍵在檔案中只出現一次。
{{< figure src="/fig/ddia_0402.png" id="fig_storage_sstable_index" caption="圖 4-2. 帶有稀疏索引的 SSTable允許查詢跳轉到正確的塊。" class="w-full my-4" >}}
現在你不需要在記憶體中保留所有鍵:你可以將 SSTable 中的鍵值對分組為幾千位元組的 *塊*,然後在索引中儲存每個塊的第一個鍵。這種只儲存部分鍵的索引稱為 *稀疏* 索引。這個索引儲存在 SSTable 的單獨部分,例如使用不可變 B 樹、字典樹或其他允許查詢快速查詢特定鍵的資料結構 [^4]。
例如,在 [圖 4-2](#fig_storage_sstable_index) 中,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。現在假設你要查詢鍵 `handiwork`,它沒有出現在稀疏索引中。由於排序,你知道 `handiwork` 必須出現在 `handbag``handsome` 之間。這意味著你可以尋找到 `handbag` 的偏移量,然後從那裡掃描檔案,直到找到 `handiwork`(或沒有,如果該鍵不在檔案中)。幾千位元組的塊可以非常快速地掃描。
例如,在 [圖 4-2](/tw/ch4#fig_storage_sstable_index) 中,一個塊的第一個鍵是 `handbag`,下一個塊的第一個鍵是 `handsome`。現在假設你要查詢鍵 `handiwork`,它沒有出現在稀疏索引中。由於排序,你知道 `handiwork` 必須出現在 `handbag``handsome` 之間。這意味著你可以尋找到 `handbag` 的偏移量,然後從那裡掃描檔案,直到找到 `handiwork`(或沒有,如果該鍵不在檔案中)。幾千位元組的塊可以非常快速地掃描。
此外,每個記錄塊都可以壓縮(在 [圖 4-2](#fig_storage_sstable_index) 中用陰影區域表示)。除了節省磁碟空間外,壓縮還減少了 I/O 頻寬使用,代價是使用更多一點的 CPU 時間。
此外,每個記錄塊都可以壓縮(在 [圖 4-2](/tw/ch4#fig_storage_sstable_index) 中用陰影區域表示)。除了節省磁碟空間外,壓縮還減少了 I/O 頻寬使用,代價是使用更多一點的 CPU 時間。
#### 構建和合並 SSTable {#constructing-and-merging-sstables}
@ -121,7 +123,7 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
3. 為了讀取某個鍵的值,首先嘗試在記憶體表和最新的磁碟段中找到該鍵。如果沒有找到,就在下一個較舊的段中查詢,依此類推,直到找到鍵或到達最舊的段。如果鍵沒有出現在任何段中,則它不存在於資料庫中。
4. 不時地在後臺執行合併和壓實過程,以合併段檔案並丟棄被覆蓋或刪除的值。
合併段的工作方式類似於 *歸併排序* 演算法 [^5]。該過程如 [圖 4-3](#fig_storage_sstable_merging) 所示:並排開始讀取輸入檔案,檢視每個檔案中的第一個鍵,將最低的鍵(根據排序順序)複製到輸出檔案,然後重複。如果同一個鍵出現在多個輸入檔案中,只保留較新的值。這會產生一個新的合併段檔案,也按鍵排序,每個鍵只有一個值,並且它使用最少的記憶體,因為我們可以一次遍歷一個鍵的 SSTable。
合併段的工作方式類似於 *歸併排序* 演算法 [^5]。該過程如 [圖 4-3](/tw/ch4#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" >}}
@ -139,15 +141,17 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
具有不可變段檔案也簡化了崩潰恢復:如果在寫出記憶體表或合併段時發生崩潰,資料庫可以刪除未完成的 SSTable 並重新開始。將寫入持久化到記憶體表的日誌如果在寫入記錄的過程中發生崩潰,或者磁碟已滿,可能包含不完整的記錄;這些通常透過在日誌中包含校驗和來檢測,並丟棄損壞或不完整的日誌條目。我們將在 [第 8 章](/tw/ch8#ch_transactions) 中更多地討論永續性和崩潰恢復。
<a id="sec_storage_bloom_filter"></a>
#### 布隆過濾器 {#bloom-filters}
使用 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 的其餘部分相比,布隆過濾器通常很小。
[圖 4-4](/tw/ch4#fig_storage_bloom) 顯示了一個包含兩個鍵和 16 位的布隆過濾器示例(實際上,它會包含更多的鍵和更多的位)。對於 SSTable 中的每個鍵,我們計算一個雜湊函式,產生一組數字,然後將其解釋為位陣列的索引 [^14]。我們將對應於這些索引的位設定為 1其餘保持為 0。例如`handbag` 雜湊為數字 (2, 9, 4),所以我們將第 2、9 和 4 位設定為 1。然後將點陣圖與鍵的稀疏索引一起儲存為 SSTable 的一部分。這需要一點額外的空間,但與 SSTable 的其餘部分相比,布隆過濾器通常很小。
{{< figure src="/fig/ddia_0404.png" id="fig_storage_bloom" caption="圖 4-4. 布隆過濾器提供了一種快速的機率檢查,用於判斷特定鍵是否存在於特定 SSTable 中。" class="w-full my-4" >}}
當我們想知道一個鍵是否出現在 SSTable 中時,我們像以前一樣計算該鍵的相同雜湊,並檢查這些索引處的位。例如,在 [圖 4-4](#fig_storage_bloom) 中,我們查詢鍵 `handheld`,它雜湊為 (6, 11, 2)。其中一個位是 1即第 2 位),而另外兩個是 0。這些檢查可以使用所有 CPU 都支援的位運算非常快速地進行。
當我們想知道一個鍵是否出現在 SSTable 中時,我們像以前一樣計算該鍵的相同雜湊,並檢查這些索引處的位。例如,在 [圖 4-4](/tw/ch4#fig_storage_bloom) 中,我們查詢鍵 `handheld`,它雜湊為 (6, 11, 2)。其中一個位是 1即第 2 位),而另外兩個是 0。這些檢查可以使用所有 CPU 都支援的位運算非常快速地進行。
如果至少有一個位是 0我們知道該鍵肯定不在 SSTable 中。如果查詢中的位都是 1那麼該鍵很可能在 SSTable 中,但也有可能是巧合,所有這些位都被其他鍵設定為 1。這種看起來鍵存在但實際上不存在的情況稱為 *假陽性**false positive*)。
@ -170,10 +174,12 @@ SSTable 檔案格式在讀取方面比僅追加日誌更好,但它使寫入更
作為經驗法則,如果你主要有寫入而讀取很少,分層壓實表現更好,而如果你的工作負載以讀取為主,分級壓實表現更好。如果你頻繁寫入少量鍵,而很少寫入大量鍵,那麼分級壓實也可能有優勢 [^18]。
儘管有許多細微之處,但 LSM 樹的基本思想 —— 保持在後臺合併的 SSTable 級聯 —— 簡單而有效。我們將在 ["比較 B 樹與 LSM 樹"](#sec_storage_btree_lsm_comparison) 中更詳細地討論它們的效能特徵。
儘管有許多細微之處,但 LSM 樹的基本思想 —— 保持在後臺合併的 SSTable 級聯 —— 簡單而有效。我們將在 ["比較 B 樹與 LSM 樹"](/tw/ch4#sec_storage_btree_lsm_comparison) 中更詳細地討論它們的效能特徵。
--------
<a id="sidebar_embedded"></a>
> [!TIP] 嵌入式儲存引擎
許多資料庫作為接受網路查詢的服務執行,但也有 *嵌入式* 資料庫不公開網路 API。相反它們是在與應用程式程式碼相同的程序中執行的庫通常讀取和寫入本地磁碟上的檔案你透過正常的函式呼叫與它們互動。嵌入式儲存引擎的例子包括 RocksDB、SQLite、LMDB、DuckDB 和 KùzuDB [^19]。
@ -194,21 +200,21 @@ B 樹於 1970 年引入 [^21],不到 10 年後就被稱為"無處不在"[^22]
我們之前看到的日誌結構索引將資料庫分解為可變大小的 *段*通常為幾兆位元組或更大寫入一次後就不可變。相比之下B 樹將資料庫分解為固定大小的 *塊**頁*,並可能就地覆蓋頁。頁傳統上大小為 4 KiB但 PostgreSQL 現在預設使用 8 KiBMySQL 預設使用 16 KiB。
每個頁都可以使用頁號來標識,這允許一個頁引用另一個頁 —— 類似於指標,但在磁碟上而不是在記憶體中。如果所有頁都儲存在同一個檔案中,將頁號乘以頁大小就給我們檔案中頁所在位置的位元組偏移量。我們可以使用這些頁引用來構建頁樹,如 [圖 4-5](#fig_storage_b_tree) 所示。
每個頁都可以使用頁號來標識,這允許一個頁引用另一個頁 —— 類似於指標,但在磁碟上而不是在記憶體中。如果所有頁都儲存在同一個檔案中,將頁號乘以頁大小就給我們檔案中頁所在位置的位元組偏移量。我們可以使用這些頁引用來構建頁樹,如 [圖 4-5](/tw/ch4#fig_storage_b_tree) 所示。
{{< figure src="/fig/ddia_0405.png" id="fig_storage_b_tree" caption="圖 4-5. 使用 B 樹索引查詢鍵 251。從根頁開始我們首先跟隨引用到鍵 200300 的頁,然後是鍵 250270 的頁。" class="w-full my-4" >}}
一個頁被指定為 B 樹的 *根*;每當你想在索引中查詢一個鍵時,你就從這裡開始。該頁包含幾個鍵和對子頁的引用。每個子頁負責一個連續的鍵範圍,引用之間的鍵指示這些範圍之間的邊界在哪裡。(這種結構有時稱為 B+ 樹,但我們不需要將其與其他 B 樹變體區分開來。)
在 [圖 4-5](#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(*葉頁*),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。
在 [圖 4-5](/tw/ch4#fig_storage_b_tree) 的例子中,我們正在查詢鍵 251所以我們知道我們需要跟隨邊界 200 和 300 之間的頁引用。這將我們帶到一個看起來相似的頁,該頁進一步將 200300 範圍分解為子範圍。最終我們到達包含單個鍵的頁(*葉頁*),該頁要麼內聯包含每個鍵的值,要麼包含對可以找到值的頁的引用。
B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [圖 4-5](#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。
B 樹的一個頁中對子頁的引用數稱為 *分支因子*。例如,在 [圖 4-5](/tw/ch4#fig_storage_b_tree) 中,分支因子為六。實際上,分支因子取決於儲存頁引用和範圍邊界所需的空間量,但通常為幾百。
如果你想更新 B 樹中現有鍵的值,你搜索包含該鍵的葉頁,並用包含新值的版本覆蓋磁碟上的該頁。如果你想新增一個新鍵,你需要找到其範圍包含新鍵的頁並將其新增到該頁。如果頁中沒有足夠的空閒空間來容納新鍵,則頁被分成兩個半滿的頁,並更新父頁以說明鍵範圍的新細分。
{{< figure src="/fig/ddia_0406.png" id="fig_storage_b_tree_split" caption="圖 4-6. 透過在邊界鍵 337 上分割頁來增長 B 樹。父頁被更新以引用兩個子頁。" class="w-full my-4" >}}
在 [圖 4-6](#fig_storage_b_tree_split) 的例子中,我們想插入鍵 334但範圍 333345 的頁已經滿了。因此,我們將其分成範圍 333337包括新鍵的頁和 337344 的頁。我們還必須更新父頁以引用兩個子頁,它們之間的邊界值為 337。如果父頁沒有足夠的空間容納新引用它也可能需要被分割分割可以一直持續到樹的根。當根被分割時我們在它上面建立一個新根。刪除鍵可能需要合併節點更複雜 [^5]。
在 [圖 4-6](/tw/ch4#fig_storage_b_tree_split) 的例子中,我們想插入鍵 334但範圍 333345 的頁已經滿了。因此,我們將其分成範圍 333337包括新鍵的頁和 337344 的頁。我們還必須更新父頁以引用兩個子頁,它們之間的邊界值為 337。如果父頁沒有足夠的空間容納新引用它也可能需要被分割分割可以一直持續到樹的根。當根被分割時我們在它上面建立一個新根。刪除鍵可能需要合併節點更複雜 [^5]。
這個演算法確保樹保持 *平衡*:具有 *n* 個鍵的 B 樹始終具有 *O*(log *n*) 的深度。大多數資料庫可以適合三或四層深的 B 樹,所以你不需要跟隨許多頁引用來找到你要查詢的頁。(具有 500 分支因子的 4 KiB 頁的四層樹可以儲存多達 250 TB。
@ -249,13 +255,13 @@ B 樹的基本底層寫操作是用新資料覆蓋磁碟上的頁。假設覆蓋
使用 B 樹時,如果應用程式寫入的鍵分散在整個鍵空間中,生成的磁碟操作也會隨機分散,因為儲存引擎需要覆蓋的頁可能位於磁碟的任何位置。另一方面,日誌結構儲存引擎一次寫入整個段檔案(無論是寫出記憶體表還是壓實現有段),這比 B 樹中的頁大得多。
許多小的、分散的寫入模式(如 B 樹中的)稱為 *隨機寫入*,而較少的大寫入模式(如 LSM 樹中的)稱為 *順序寫入*。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟HDD上特別大在今天大多數資料庫使用的固態硬碟SSD差異較小但仍然明顯參見 ["SSD 上的順序與隨機寫入"](#sidebar_sequential))。
許多小的、分散的寫入模式(如 B 樹中的)稱為 *隨機寫入*,而較少的大寫入模式(如 LSM 樹中的)稱為 *順序寫入*。磁碟通常具有比隨機寫入更高的順序寫入吞吐量,這意味著日誌結構儲存引擎通常可以在相同硬體上處理比 B 樹更高的寫入吞吐量。這種差異在旋轉磁碟硬碟HDD上特別大在今天大多數資料庫使用的固態硬碟SSD差異較小但仍然明顯參見 ["SSD 上的順序與隨機寫入"](/tw/ch4#sidebar_sequential))。
--------
> [!TIP] SSD 上的順序與隨機寫入
在旋轉磁碟硬碟HDD順序寫入比隨機寫入快得多隨機寫入必須機械地將磁頭移動到新位置並等待碟片的正確部分經過磁頭下方這需要幾毫秒 —— 在計算時間尺度上是永恆的。然而SSD固態硬碟包括 NVMe非易失性記憶體快速,即連線到 PCI Express 匯流排的快閃記憶體)現在已經在許多用例中超越了 HDD它們不受這種機械限制。
在旋轉磁碟硬碟HDD順序寫入比隨機寫入快得多隨機寫入必須機械地將磁頭移動到新位置並等待碟片的正確部分經過磁頭下方這需要幾毫秒 —— 在計算時間尺度上是永恆的。然而SSD固態硬碟包括 NVMeNon-Volatile Memory Express即連線到 PCI Express 匯流排的快閃記憶體)現在已經在許多場景中超越了 HDD它們不受這種機械限制。
儘管如此SSD 對順序寫入的吞吐量也高於隨機寫入。原因是快閃記憶體可以一次讀取或寫入一頁(通常為 4 KiB但只能一次擦除一個塊通常為 512 KiB。塊中的某些頁可能包含有效資料而其他頁可能包含不再需要的資料。在擦除塊之前控制器必須首先將包含有效資料的頁移動到其他塊中這個過程稱為 *垃圾回收*GC[^33]。
@ -283,7 +289,7 @@ B 樹索引必須至少寫入每條資料兩次:一次寫入預寫日誌,一
B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了大量鍵,資料庫檔案可能包含許多 B 樹不再使用的頁。對 B 樹的後續新增可以使用這些空閒頁,但它們不能輕易地返回給作業系統,因為它們在檔案的中間,所以它們仍然佔用檔案系統上的空間。因此,資料庫需要一個後臺過程來移動頁以更好地放置它們,例如 PostgreSQL 中的真空過程 [^25]。
碎片化在 LSM 樹中不太成問題,因為壓實過程無論如何都會定期重寫資料檔案,而且 SSTable 沒有未使用空間的頁。此外SSTable 中的鍵值對塊可以更好地壓縮,因此通常比 B 樹在磁碟上產生更小的檔案。被覆蓋的鍵和值繼續消耗空間,直到它們被壓實刪除,但使用分級壓即時,這種開銷相當低 [^40] [^41]。分層壓實(參見 ["壓實策略"](#sec_storage_lsm_compaction))使用更多的磁碟空間,特別是在壓實期間臨時使用。
碎片化在 LSM 樹中不太成問題,因為壓實過程無論如何都會定期重寫資料檔案,而且 SSTable 沒有未使用空間的頁。此外SSTable 中的鍵值對塊可以更好地壓縮,因此通常比 B 樹在磁碟上產生更小的檔案。被覆蓋的鍵和值繼續消耗空間,直到它們被壓實刪除,但使用分級壓即時,這種開銷相當低 [^40] [^41]。分層壓實(參見 ["壓實策略"](/tw/ch4#sec_storage_lsm_compaction))使用更多的磁碟空間,特別是在壓實期間臨時使用。
在磁碟上有一些資料的多個副本也可能是一個問題,當你需要刪除一些資料,並確信它真的已被刪除(也許是為了遵守資料保護法規)。例如,在大多數 LSM 儲存引擎中,已刪除的記錄可能仍然存在於較高級別中,直到代表刪除的墓碑透過所有壓實級別傳播,這可能需要很長時間。專門的儲存引擎設計可以更快地傳播刪除 [^42]。
@ -306,7 +312,7 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了
* 或者值可以是對實際資料的引用要麼是相關行的主鍵InnoDB 對二級索引這樣做),要麼是對磁碟上位置的直接引用。在後一種情況下,儲存行的地方稱為 *堆檔案*它以無特定順序儲存資料它可能是僅追加的或者它可能跟蹤已刪除的行以便稍後用新資料覆蓋它們。例如Postgres 使用堆檔案方法 [^44]。
* 兩者之間的折中是 *覆蓋索引**包含列的索引*,它在索引中儲存表的 *某些* 列,除了在堆上或主鍵聚簇索引中儲存完整行 [^45]。這允許僅使用索引來回答某些查詢,而無需解析主鍵或檢視堆檔案(在這種情況下,索引被稱為 *覆蓋* 查詢)。這可以使某些查詢更快,但資料的重複意味著索引使用更多的磁碟空間並減慢寫入速度。
到目前為止討論的索引只將單個鍵對映到值。如果你需要同時查詢表的多個列(或文件中的多個欄位),請參見 ["多維索引與全文索引"](#sec_storage_multidimensional)。
到目前為止討論的索引只將單個鍵對映到值。如果你需要同時查詢表的多個列(或文件中的多個欄位),請參見 ["多維索引與全文索引"](/tw/ch4#sec_storage_multidimensional)。
當更新值而不更改鍵時,堆檔案方法可以允許記錄就地覆蓋,前提是新值不大於舊值。如果新值更大,情況會更複雜,因為它可能需要移動到堆中有足夠空間的新位置。在這種情況下,要麼所有索引都需要更新以指向記錄的新堆位置,要麼在舊堆位置留下轉發指標 [^2]。
@ -314,7 +320,7 @@ B 樹可能會隨著時間的推移變得 *碎片化*:例如,如果刪除了
本章到目前為止討論的資料結構都是對磁碟限制的回應。與主記憶體相比,磁碟很難處理。對於磁碟和 SSD如果你想在讀取和寫入上獲得良好的效能磁碟上的資料需要仔細布局。然而我們容忍這種尷尬因為磁碟有兩個顯著的優勢它們是持久的如果斷電其內容不會丟失並且它們每千兆位元組的成本比 RAM 低。
隨著 RAM 變得更便宜,每千兆位元組成本的論點被侵蝕。許多資料集根本不是那麼大,因此將它們完全保留在記憶體中是完全可行的,可能分佈在幾臺機器上。這導致了 *記憶體資料庫* 的發展。
隨著 RAM 變得更便宜,按每 GB 計價的成本優勢正在減弱。許多資料集根本沒有那麼大,因此將它們完全保留在記憶體中是完全可行的,甚至可以分佈在幾臺機器上。這導致了 *記憶體資料庫* 的發展。
一些記憶體鍵值儲存,例如 Memcached僅用於快取如果機器重新啟動資料丟失是可以接受的。但其他記憶體資料庫旨在實現永續性這可以透過特殊硬體例如電池供電的 RAM、將更改日誌寫入磁碟、將定期快照寫入磁碟或將記憶體狀態複製到其他機器來實現。
@ -331,7 +337,7 @@ Redis 和 Couchbase 透過非同步寫入磁碟提供弱永續性。
## 分析型資料儲存 {#sec_storage_analytics}
資料倉庫的資料模型最常見的是關係型,因為 SQL 通常非常適合分析查詢。有許多圖形資料分析工具可以生成 SQL 查詢、視覺化結果,並允許分析師探索資料(透過 *下鑽**切片切塊* 等操作)。
資料倉庫的資料模型最常見的是關係型,因為 SQL 通常非常適合分析查詢。有許多圖形資料分析工具可以生成 SQL 查詢、視覺化結果,並允許分析師探索資料(透過 *下鑽**切片切塊* 等操作)。
表面上,資料倉庫和關係型 OLTP 資料庫看起來很相似,因為它們都有 SQL 查詢介面。然而,系統的內部可能看起來完全不同,因為它們針對非常不同的查詢模式進行了最佳化。許多資料庫供應商現在專注於支援事務處理或分析工作負載,但不是兩者兼而有之。
@ -343,16 +349,16 @@ Teradata、Vertica 和 SAP HANA 等資料倉庫供應商既銷售商業許可下
雲資料倉庫往往與其他雲服務更好地整合,並且更具彈性。例如,許多雲倉庫支援自動日誌攝取,並提供與資料處理框架(如 Google Cloud 的 Dataflow 或 Amazon Web Services 的 Kinesis的輕鬆整合。這些倉庫也更具彈性因為它們將查詢計算與儲存層解耦 [^54]。資料持久儲存在物件儲存而不是本地磁碟上,這使得可以獨立調整儲存容量和查詢的計算資源,正如我們之前在 ["雲原生系統架構"](/tw/ch1#sec_introduction_cloud_native) 中看到的。
Apache Hive、Trino 和 Apache Spark 等開源資料倉庫也隨著雲的發展而發展。隨著分析資料儲存轉移到物件儲存上的資料湖,開源倉庫已經開始分解 [^55]。以下元件以前整合在單個系統(如 Apache Hive現在通常作為單獨的元件實現
Apache Hive、Trino 和 Apache Spark 等開源資料倉庫也隨著雲的發展而發展。隨著分析資料儲存轉移到物件儲存上的資料湖,開源倉庫也開始解耦拆分 [^55]。以下元件以前整合在單個系統(如 Apache Hive現在通常作為單獨的元件實現
查詢引擎
: Trino、Apache DataFusion 和 Presto 等查詢引擎解析 SQL 查詢,將其最佳化為執行計劃,並針對資料執行它們。執行通常需要並行、分散式資料處理任務。一些查詢引擎提供內建任務執行,而其他選擇使用第三方執行框架,如 Apache Spark 或 Apache Flink。
: Trino、Apache DataFusion 和 Presto 等查詢引擎解析 SQL 查詢,將其最佳化為執行計劃,並在資料上執行這些計劃。執行通常需要並行、分散式的資料處理任務。一些查詢引擎提供內建任務執行,而有些則選擇使用第三方執行框架,如 Apache Spark 或 Apache Flink。
儲存格式
: 儲存格式確定表的行如何編碼為檔案中的位元組,然後通常儲存在物件儲存或分散式檔案系統中 [^12]。然後查詢引擎可以訪問這些資料,但使用資料湖的其他應用程式也可以訪問。此類儲存格式的示例包括 Parquet、ORC、Lance 或 Nimble我們將在下一節中看到更多關於它們的內容。
表格式
: 以 Apache Parquet 和類似儲存格式編寫的檔案一旦寫通常是不可變的。為了支援行插入和刪除,使用 Apache Iceberg 或 Databricks 的 Delta 格式等表格式。表格式指定定義哪些檔案構成表以及表模式的檔案格式。此類格式還提供高階功能,例如時間旅行(查詢表在以前時間點的能力)、垃圾回收,甚至事務。
: 以 Apache Parquet 和類似儲存格式編寫的檔案一旦寫通常是不可變的。為了支援行插入和刪除,通常會使用 Apache Iceberg 或 Databricks Delta 等表格式。表格式規定了哪些檔案構成一張表,以及表模式的定義格式。此類格式還提供高階功能,例如時間旅行(查詢表在過去某個時間點狀態的能力)、垃圾回收,甚至事務。
資料目錄
: 就像表格式定義哪些檔案構成表一樣資料目錄定義哪些表組成資料庫。目錄用於建立、重新命名和刪除表。與儲存和表格式不同Snowflake 的 Polaris 和 Databricks 的 Unity Catalog 等資料目錄通常作為可以使用 REST 介面查詢的獨立服務執行。Apache Iceberg 也提供目錄,可以在客戶端內執行或作為單獨的程序執行。查詢引擎在讀取和寫入表時使用目錄資訊。傳統上,目錄和查詢引擎已經整合,但將它們解耦使資料發現和資料治理系統(在 ["資料系統、法律和社會"](/tw/ch1#sec_introduction_compliance) 中討論)也能夠訪問目錄的元資料。
@ -361,7 +367,7 @@ Apache Hive、Trino 和 Apache Spark 等開源資料倉庫也隨著雲的發展
如 ["星型和雪花型:分析模式"](/tw/ch3#sec_datamodels_analytics) 中所討論的,資料倉庫按照慣例通常使用帶有大型事實表的關係模式,該表包含對維度表的外部索引鍵引用。如果你的事實表中有數萬億行和數 PB 的資料,有效地儲存和查詢它們就成為一個具有挑戰性的問題。維度表通常要小得多(數百萬行),因此在本節中我們將重點關注事實的儲存。
儘管事實表通常有超過 100 列,但典型的資料倉庫查詢一次只訪問其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查詢)[^52]。以 [示例 4-1](#fig_storage_analytics_query) 中的查詢為例它訪問大量行2024 日曆年期間每次有人購買水果或糖果的情況),但它只需要訪問 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查詢忽略所有其他列。
儘管事實表通常有超過 100 列,但典型的資料倉庫查詢一次只訪問其中的 4 或 5 列(分析很少需要 `"SELECT *"` 查詢)[^52]。以 [示例 4-1](/tw/ch4#fig_storage_analytics_query) 中的查詢為例它訪問大量行2024 日曆年期間每次有人購買水果或糖果的情況),但它只需要訪問 `fact_sales` 表的三列:`date_key`、`product_sk` 和 `quantity`。查詢忽略所有其他列。
{{< figure id="fig_storage_analytics_query" title="示例 4-1. 分析人們是否更傾向於購買新鮮水果或糖果,取決於星期幾" class="w-full my-4" >}}
@ -381,11 +387,11 @@ GROUP BY
我們如何高效地執行這個查詢?
在大多數 OLTP 資料庫中,儲存是以 *面向行* 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。
在大多數 OLTP 資料庫中,儲存是以 *面向行* 的方式佈局的:表中一行的所有值彼此相鄰儲存。文件資料庫類似:整個文件通常作為一個連續的位元組序列儲存。你可以在 [圖 4-1](/tw/ch4#fig_storage_csv_hash_index) 的 CSV 示例中看到這一點。
為了處理像 [示例 4-1](#fig_storage_analytics_query) 這樣的查詢,你可能在 `fact_sales.date_key` 和/或 `fact_sales.product_sk` 上有索引,告訴儲存引擎在哪裡找到特定日期或特定產品的所有銷售。但是,面向行的儲存引擎仍然需要將所有這些行(每行包含超過 100 個屬性)從磁碟載入到記憶體中,解析它們,並過濾掉不符合所需條件的行。這可能需要很長時間。
為了處理像 [示例 4-1](/tw/ch4#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](/tw/ch4#fig_column_store) 使用 [圖 3-5](/tw/ch3#fig_dwh_schema) 中事實表的擴充套件版本展示了這一原理。
--------
@ -406,13 +412,13 @@ GROUP BY
除了只從磁碟載入查詢所需的那些列之外,我們還可以透過壓縮資料進一步減少對磁碟吞吐量和網路頻寬的需求。幸運的是,面向列的儲存通常非常適合壓縮。
看看 [圖 4-7](#fig_column_store) 中每列的值序列:它們看起來經常重複,這是壓縮的良好跡象。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是 *點陣圖編碼*,如 [圖 4-8](#fig_bitmap_index) 所示。
看看 [圖 4-7](/tw/ch4#fig_column_store) 中每列的值序列:它們看起來經常重複,這是壓縮的良好跡象。根據列中的資料,可以使用不同的壓縮技術。在資料倉庫中特別有效的一種技術是 *點陣圖編碼*,如 [圖 4-8](/tw/ch4#fig_bitmap_index) 所示。
{{< figure src="/fig/ddia_0408.png" id="fig_bitmap_index" caption="圖 4-8. 單列的壓縮、點陣圖索引儲存。" class="w-full my-4" >}}
通常,列中不同值的數量與行數相比很小(例如,零售商可能有數十億條銷售交易,但只有 100,000 種不同的產品)。我們現在可以將具有 *n* 個不同值的列轉換為 *n* 個單獨的點陣圖:每個不同值一個位圖,每行一位。如果該行具有該值,則該位為 1否則為 0。
一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 *稀疏* 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](#fig_bitmap_index) 底部所示。諸如 *咆哮點陣圖**roaring bitmaps*)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。
一種選擇是使用每行一位來儲存這些點陣圖。然而,這些點陣圖通常包含大量零(我們說它們是 *稀疏* 的)。在這種情況下,點陣圖可以另外進行遊程編碼:計算連續零或一的數量並存儲該數字,如 [圖 4-8](/tw/ch4#fig_bitmap_index) 底部所示。諸如 *咆哮點陣圖**roaring bitmaps*)之類的技術在兩種位圖表示之間切換,使用最緊湊的表示 [^73]。這可以使列的編碼非常高效。
像這樣的點陣圖索引非常適合資料倉庫中常見的查詢型別。例如:
@ -439,9 +445,9 @@ GROUP BY
相反,資料需要一次排序整行,即使它是按列儲存的。資料庫管理員可以使用他們對常見查詢的瞭解來選擇表應按哪些列排序。例如,如果查詢經常針對日期範圍(例如上個月),則將 `date_key` 作為第一個排序鍵可能是有意義的。然後查詢可以只掃描上個月的行,這將比掃描所有行快得多。
第二列可以確定在第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是 [圖 4-7](#fig_column_store) 中的第一個排序鍵,那麼 `product_sk` 作為第二個排序鍵可能是有意義的,這樣同一天同一產品的所有銷售都在儲存中分組在一起。這將有助於需要在某個日期範圍內按產品分組或過濾銷售的查詢。
第二列可以確定在第一列中具有相同值的任何行的排序順序。例如,如果 `date_key` 是 [圖 4-7](/tw/ch4#fig_column_store) 中的第一個排序鍵,那麼 `product_sk` 作為第二個排序鍵可能是有意義的,這樣同一天同一產品的所有銷售都在儲存中分組在一起。這將有助於需要在某個日期範圍內按產品分組或過濾銷售的查詢。
排序順序的另一個優點是它可以幫助壓縮列。如果主排序列沒有許多不同的值,那麼排序後,它將有很長的序列,其中相同的值在一行中重複多次。簡單的遊程編碼,就像我們在 [圖 4-8](#fig_bitmap_index) 中用於點陣圖的那樣,可以將該列壓縮到幾千位元組 —— 即使表有數十億行。
排序順序的另一個優點是它可以幫助壓縮列。如果主排序列沒有許多不同的值,那麼排序後,它將有很長的序列,其中相同的值在一行中重複多次。簡單的遊程編碼,就像我們在 [圖 4-8](/tw/ch4#fig_bitmap_index) 中用於點陣圖的那樣,可以將該列壓縮到幾千位元組 —— 即使表有數十億行。
該壓縮效果在第一個排序鍵上最強。第二和第三個排序鍵將更加混亂,因此不會有如此長的重複值執行。排序優先順序較低的列基本上以隨機順序出現,因此它們可能不會壓縮得那麼好。但是,讓前幾列排序仍然是整體上的勝利。
@ -460,7 +466,7 @@ GROUP BY
用於分析的複雜 SQL 查詢被分解為由多個階段組成的 *查詢計劃*,稱為 *運算元*,這些運算元可能分佈在多臺機器上以並行執行。查詢規劃器可以透過選擇使用哪些運算元、以何種順序執行它們以及在哪裡執行每個運算元來執行大量最佳化。
在每個運算元內,查詢引擎需要對列中的值執行各種操作,例如查詢值在特定值集中的所有行(可能作為連線的一部分),或檢查值是否大於 15。它還需要檢視同一行的幾列例如查詢產品是香蕉且商店是特定感興趣商店的所有銷售交易。
在每個運算元內,查詢引擎需要對列中的值執行各種操作,例如查詢值在特定值集中的所有行(可能作為連線的一部分),或檢查值是否大於 15。它還需要檢視同一行的幾列例如查詢產品是香蕉且門店是某個特定目標門店的所有銷售交易。
對於需要掃描數百萬行的資料倉庫查詢,我們不僅需要擔心它們需要從磁碟讀取的資料量,還需要擔心執行複雜運算元所需的 CPU 時間。最簡單的運算元型別就像程式語言的直譯器:在遍歷每一行時,它檢查表示查詢的資料結構,以找出需要對哪些列執行哪些比較或計算。不幸的是,這對許多分析目的來說太慢了。高效查詢執行的兩種替代方法已經出現 [^77]
@ -470,7 +476,7 @@ GROUP BY
向量化處理
: 查詢被解釋,而不是編譯,但透過批次處理列中的許多值而不是逐行迭代來提高速度。一組固定的預定義運算元內建在資料庫中;我們可以向它們傳遞引數並獲得一批結果 [^50] [^75]。
例如,我們可以將 `product_sk` 列和"香蕉"的 ID 傳遞給相等運算元,並獲得一個位圖(輸入列中每個值一位,如果是香蕉則為 1然後我們可以將 `store_sk` 列和感興趣商店的 ID 傳遞給相同的相等運算元,並獲得另一個位圖;然後我們可以將兩個點陣圖傳遞給"按位 AND"運算元,如 [圖 4-9](#fig_bitmap_and) 所示。結果將是一個位圖,包含特定商店中所有香蕉銷售的 1。
例如,我們可以將 `product_sk` 列和"香蕉"的 ID 傳遞給相等運算元,並獲得一個位圖(輸入列中每個值一位,如果是香蕉則為 1然後我們可以將 `store_sk` 列和感興趣商店的 ID 傳遞給相同的相等運算元,並獲得另一個位圖;然後我們可以將兩個點陣圖傳遞給"按位 AND"運算元,如 [圖 4-9](/tw/ch4#fig_bitmap_and) 所示。結果將是一個位圖,包含特定商店中所有香蕉銷售的 1。
{{< figure src="/fig/ddia_0409.png" id="fig_bitmap_and" caption="圖 4-9. 兩個點陣圖之間的按位 AND 適合向量化。" class="w-full my-4" >}}
@ -481,23 +487,23 @@ GROUP BY
* 利用並行性例如多執行緒和單指令多資料SIMD指令 [^79] [^80],以及
* 直接對壓縮資料進行操作,而無需將其解碼為單獨的記憶體表示,這可以節省記憶體分配和複製成本。
### 物化檢視與多維資料集 {#sec_storage_materialized_views}
### 物化檢視與資料立方體 {#sec_storage_materialized_views}
我們之前在 ["物化和更新時間線"](/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](/tw/ch4#fig_data_cube) 顯示了一個示例。
{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="圖 4-10. 多維資料集的兩個維度,透過求和聚合資料。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0410.png" id="fig_data_cube" caption="圖 4-10. 資料立方體的兩個維度,透過求和聚合資料。" class="w-full my-4" >}}
現在假設每個事實只有兩個維度表的外部索引鍵 —— 在 [圖 4-10](#fig_data_cube) 中,這些是 `date_key``product_sk`。你現在可以繪製一個二維表,日期沿著一個軸,產品沿著另一個軸。每個單元格包含具有該日期-產品組合的所有事實的屬性(例如 `net_price`)的聚合(例如 `SUM`)。然後,你可以沿著每行或列應用相同的聚合,並獲得已減少一個維度的摘要(不管日期的產品銷售,或不管產品的日期銷售)。
現在假設每個事實只有兩個維度表的外部索引鍵 —— 在 [圖 4-10](/tw/ch4#fig_data_cube) 中,這些是 `date_key``product_sk`。你現在可以繪製一個二維表,日期沿著一個軸,產品沿著另一個軸。每個單元格包含具有該日期-產品組合的所有事實的屬性(例如 `net_price`)的聚合(例如 `SUM`)。然後,你可以沿著每行或列應用相同的聚合,並獲得已減少一個維度的摘要(不管日期的產品銷售,或不管產品的日期銷售)。
一般來說,事實通常有兩個以上的維度。在 [圖 3-5](/tw/ch3#fig_dwh_schema) 中有五個維度:日期、產品、商店、促銷和客戶。很難想象五維超立方體會是什麼樣子,但原理保持不變:每個單元格包含特定日期-產品-商店-促銷-客戶組合的銷售。然後可以沿著每個維度重複彙總這些值。
物化多維資料集的優點是某些查詢變得非常快,因為它們已經有效地預先計算了。例如,如果你想知道昨天每個商店的總銷售額,你只需要檢視適當維度的總計 —— 不需要掃描數百萬行。
物化資料立方體的優點是某些查詢會變得非常快,因為結果已經被預先計算好了。例如,如果你想知道昨天每個商店的總銷售額,你只需要檢視相應維度上的彙總值 —— 不需要掃描數百萬行。
缺點是多維資料集沒有與查詢原始資料相同的靈活性。例如,沒有辦法計算成本超過 100 美元的商品的銷售比例,因為價格不是維度之一。因此,大多數資料倉庫儘可能多地保留原始資料,並僅將聚合(如多維資料集)用作某些查詢的效能提升
缺點是資料立方體不像直接查詢原始資料那樣靈活。例如,沒有辦法計算售價超過 100 美元的商品銷售佔比,因為價格並不是其中一個維度。因此,大多數資料倉庫都會盡可能保留原始資料,只把這類聚合(如資料立方體)當作特定查詢的效能加速手段
## 多維索引與全文索引 {#sec_storage_multidimensional}
@ -523,29 +529,29 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
全文檢索允許你透過可能出現在文字中任何位置的關鍵字搜尋文字文件集合(網頁、產品描述等)[^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](/tw/ch4#fig_bitmap_index):詞項 *x* 的點陣圖中的第 *n* 位是 1如果 ID 為 *n* 的文件包含詞項 *x* [^89]。
查詢包含詞項 *x**y* 的所有文件現在類似於搜尋匹配兩個條件的行的向量化資料倉庫查詢([圖 4-9](#fig_bitmap_and)):載入詞項 *x**y* 的兩個點陣圖並計算它們的按位 AND。即使點陣圖是遊程編碼的這也可以非常高效地完成。
查詢包含詞項 *x**y* 的所有文件現在類似於搜尋匹配兩個條件的行的向量化資料倉庫查詢([圖 4-9](/tw/ch4#fig_bitmap_and)):載入詞項 *x**y* 的兩個點陣圖並計算它們的按位 AND。即使點陣圖是遊程編碼的這也可以非常高效地完成。
例如Elasticsearch 和 Solr 使用的全文索引引擎 Lucene 就是這樣工作的 [^90]。它將詞項到倒排列表的對映儲存在類似 SSTable 的排序檔案中,這些檔案使用我們在本章前面看到的相同日誌結構方法在後臺合併 [^91]。PostgreSQL 的 GIN 索引型別也使用倒排列表來支援全文檢索和 JSON 文件內的索引 [^92] [^93]。
除了將文字分解為單詞,另一種選擇是查詢長度為 *n* 的所有子字串,稱為 *n* 元語法。例如,字串 `"hello"` 的三元語法(*n* = 3`"hel"`、`"ell"` 和 `"llo"`。如果我們為所有三元語法構建倒排索引,我們可以搜尋至少三個字元長的任意子字串的文件。三元語法索引甚至允許在搜尋查詢中使用正則表示式;缺點是它們相當大 [^94]。
除了將文字分解為單詞,另一種選擇是查詢長度為 *n* 的所有子字串,稱為 *n-gram**n 元語法*。例如,字串 `"hello"` 的三元語法(*n* = 3`"hel"`、`"ell"` 和 `"llo"`。如果我們為所有三元語法構建倒排索引,我們可以搜尋任意至少三個字元長的子字串。三元語法索引甚至允許在搜尋查詢中使用正則表示式;缺點是它們相當大 [^94]。
為了處理文件或查詢中的拼寫錯誤Lucene 能夠在一定編輯距離內搜尋文字中的單詞(編輯距離為 1 意味著已新增、刪除或替換了一個字母)[^95]。它透過將詞項集儲存為字元上的有限狀態自動機(類似於 *字典樹* [^96])並將其轉換為 *萊文斯坦自動機* 來實現,該自動機支援在給定編輯距離內高效搜尋單詞 [^97]。
### 向量嵌入 {#id92}
語義搜尋超越了同義詞和拼寫錯誤,試圖理解文件概念和使用者意圖。例如,如果你的幫助頁面包含標題為"取消訂閱"的頁面,使用者在搜尋"如何關閉我的帳戶"或"終止合同"時仍應能夠找到該頁面,即使它們使用完全不同的單詞,但在含義上很接近。
語義搜尋超越了同義詞和拼寫錯誤,試圖理解文件概念和使用者意圖。例如,如果你的幫助頁面中有一個標題為“取消訂閱”的頁面,使用者在搜尋“如何關閉我的賬戶”或“終止合同”時,仍應能找到這個頁面,即使查詢詞完全不同,但語義非常接近。
為了理解文件的語義 —— 它的含義 —— 語義搜尋索引使用嵌入模型將文件轉換為浮點值向量,稱為 *向量嵌入*。向量表示多維空間中的一個點,每個浮點值表示文件沿著一個維度軸的位置。嵌入模型生成的向量嵌入在(這個多維空間中)彼此接近,當嵌入的輸入文件在語義上相似時。
--------
> [!NOTE]
> 我們在 ["查詢執行:編譯與向量化"](#sec_storage_vectorized) 中看到了術語 *向量化處理*。語義搜尋中的向量有不同的含義。在向量化處理中,向量指的是可以用特別最佳化的程式碼處理的一批位。在嵌入模型中,向量是表示多維空間中位置的浮點數列表。
> 我們在 ["查詢執行:編譯與向量化"](/tw/ch4#sec_storage_vectorized) 中看到了術語 *向量化處理*。語義搜尋中的向量有不同的含義。在向量化處理中,向量指的是可以用特別最佳化的程式碼處理的一批位。在嵌入模型中,向量是表示多維空間中位置的浮點數列表。
--------
@ -566,7 +572,7 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
: 向量空間被聚類為向量的分割槽(稱為 *質心*以減少必須比較的向量數量。IVF 索引比平面索引更快,但只能給出近似結果:即使查詢和文件彼此接近,它們也可能落入不同的分割槽。對 IVF 索引的查詢首先定義 *探針*,這只是要檢查的分割槽數。使用更多探針的查詢將更準確,但會更慢,因為必須比較更多向量。
分層可導航小世界HNSW
: HNSW 索引維護向量空間的多個層,如 [圖 4-11](#fig_vector_hnsw) 所示。每一層都表示為一個圖,其中節點表示向量,邊表示與附近向量的接近度。查詢首先在最頂層定位最近的向量,該層具有少量節點。然後查詢移動到下面一層的同一節點,並跟隨該層中的邊,該層連線更密集,尋找更接近查詢向量的向量。該過程繼續直到到達最後一層。與 IVF 索引一樣HNSW 索引是近似的。
: HNSW 索引維護向量空間的多個層,如 [圖 4-11](/tw/ch4#fig_vector_hnsw) 所示。每一層都表示為一個圖,其中節點表示向量,邊表示與附近向量的接近度。查詢首先在最頂層定位最近的向量,該層具有少量節點。然後查詢移動到下面一層的同一節點,並跟隨該層中的邊,該層連線更密集,尋找更接近查詢向量的向量。該過程繼續直到到達最後一層。與 IVF 索引一樣HNSW 索引是近似的。
{{< figure src="/fig/ddia_0411.png" id="fig_vector_hnsw" caption="圖 4-11. 在 HNSW 索引中搜索最接近給定查詢向量的資料庫條目。" class="w-full my-4" >}}
@ -589,9 +595,9 @@ SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
然後我們查看了可以同時搜尋多個條件的索引:多維索引(如 R 樹)可以同時按緯度和經度搜索地圖上的點,全文檢索索引可以搜尋出現在同一文字中的多個關鍵字。最後,向量資料庫用於文字文件和其他媒體的語義搜尋;它們使用具有大量維度的向量,並透過比較向量相似性來查詢相似文件。
作為應用程式開發人員,如果你掌握了有關儲存引擎內部的這些知識,你就能更好地知道哪種工具最適合你的特定應用程式。如果你需要調整資料庫的調優引數,這種理解使你能夠想象更高或更低的值可能產生什麼影響。
作為應用開發者,如果你掌握了這些關於儲存引擎內部機制的知識,就能更好地判斷哪種工具最適合你的具體應用。如果你需要調整資料庫的調優引數,這種理解也能幫助你預判引數調高或調低可能帶來的影響。
儘管本章不能讓你成為調優任何特定儲存引擎的專家,但它希望為你提供了足夠的詞彙和想法,使你能夠理解你選擇的資料庫的文件。
儘管本章不能讓你成為調優某個特定儲存引擎的專家,但它希望已經為你提供了足夠的術語和思路,使你能夠讀懂所選資料庫的文件。

View file

@ -5,6 +5,8 @@ math: true
breadcrumbs: false
---
<a id="ch_encoding"></a>
![](/map/ch04.png)
> *萬物流轉,無物常駐。*
@ -65,7 +67,7 @@ breadcrumbs: false
這些編碼庫非常方便,因為它們允許用最少的額外程式碼儲存和恢復記憶體物件。然而,它們也有許多深層次的問題:
* 編碼通常與特定的程式語言繫結,用另一種語言讀取資料非常困難。如果你以這種編碼儲存或傳輸資料,你就將自己承諾於當前的程式語言,可能很長時間,並且排除了與其他組織(可能使用不同語言)的系統整合。
* 編碼通常與特定程式語言繫結,在另一種語言中讀取會非常困難。如果你以這種編碼儲存或傳輸資料,就等於在相當長時間內把自己繫結在當前程式語言上,也排除了與其他組織(可能使用不同語言)的系統整合。
* 為了以相同的物件型別恢復資料,解碼過程需要能夠例項化任意類。這經常是安全問題的來源 [^1]:如果攻擊者可以讓你的應用程式解碼任意位元組序列,他們可以例項化任意類,這反過來通常允許他們做可怕的事情,例如遠端執行任意程式碼 [^2] [^3]。
* 在這些庫中,資料版本控制通常是事後考慮的:由於它們旨在快速輕鬆地編碼資料,因此它們經常忽略向前和向後相容性的不便問題 [^4]。
* 效率(編碼或解碼所需的 CPU 時間以及編碼結構的大小通常也是事後考慮的。例如Java 的內建序列化因其糟糕的效能和臃腫的編碼而臭名昭著 [^5]。
@ -262,7 +264,7 @@ record Person {
答案取決於 Avro 的使用環境。舉幾個例子:
包含大量記錄的大檔案
: Avro 的一個常見用途是儲存包含數百萬條記錄的大檔案,所有記錄都使用相同的模式編碼。(我們將在 [Link to Come] 中討論這種情況。在這種情況下該檔案的寫入者可以在檔案開頭只包含一次寫入者模式。Avro 指定了一種檔案格式(物件容器檔案)來執行此操作。
: Avro 的一個常見用途是儲存包含數百萬條記錄的大檔案,所有記錄都使用相同的模式編碼。(我們將在 [第 11 章](/tw/ch11#ch_batch) 討論這種情況。在這種情況下該檔案的寫入者可以在檔案開頭只包含一次寫入者模式。Avro 指定了一種檔案格式(物件容器檔案)來執行此操作。
具有單獨寫入記錄的資料庫
: 在資料庫中,不同的記錄可能在不同的時間點使用不同的寫入者模式編寫——你不能假定所有記錄都具有相同的模式。最簡單的解決方案是在每個編碼記錄的開頭包含一個版本號,並在資料庫中保留模式版本列表。讀取者可以獲取記錄,提取版本號,然後從資料庫中獲取該版本號的寫入者模式。使用該寫入者模式,它可以解碼記錄的其餘部分。
@ -286,7 +288,7 @@ record Person {
### 模式的優點 {#sec_encoding_schemas}
正如我們所見Protocol Buffers 和 Avro 都使用模式來描述二進位制編碼格式。它們的模式語言比 XML 模式或 JSON 模式簡單得多,後者支援更詳細的驗證規則(例如,"此欄位的字串值必須與此正則表示式匹配"或"此欄位的整數值必須在 0 到 100 之間")。由於 Protocol Buffers 和 Avro 更簡單實現和使用,它們已經發展到支援相當廣泛的程式語言。
正如我們所見Protocol Buffers 和 Avro 都使用模式來描述二進位制編碼格式。它們的模式語言比 XML 模式或 JSON 模式簡單得多,後者支援更詳細的驗證規則(例如,"此欄位的字串值必須與此正則表示式匹配"或"此欄位的整數值必須在 0 到 100 之間")。由於 Protocol Buffers 和 Avro 在實現和使用上都更簡單,它們已經發展到支援相當廣泛的程式語言。
這些編碼所基於的想法絕不是新的。例如,它們與 ASN.1 有很多共同之處ASN.1 是 1984 年首次標準化的模式定義語言 [^23] [^24]。它用於定義各種網路協議其二進位制編碼DER仍用於編碼 SSL 證書X.509),例如 [^25]。ASN.1 支援使用標籤號的模式演化,類似於 Protocol Buffers [^26]。然而,它也非常複雜且文件記錄不佳,因此 ASN.1 可能不是新應用程式的好選擇。
@ -340,7 +342,7 @@ record Person {
由於資料轉儲是一次性寫入的,此後是不可變的,因此像 Avro 物件容器檔案這樣的格式非常適合。這也是將資料編碼為分析友好的列式格式(如 Parquet的好機會參見 ["列壓縮"](/tw/ch4#sec_storage_column_compression))。
在 [Link to Come] 中,我們將更多地討論如何使用歸檔儲存中的資料。
在 [第 11 章](/tw/ch11#ch_batch) 中,我們將更多地討論如何使用歸檔儲存中的資料。
### 流經服務的資料流REST 與 RPC {#sec_encoding_dataflow_rpc}
@ -423,7 +425,7 @@ Web 服務只是透過網路進行 API 請求的一長串技術的最新化身
* 本地函式呼叫是可預測的,要麼成功要麼失敗,僅取決於你控制的引數。網路請求是不可預測的:由於網路問題,請求或響應可能會丟失,或者遠端機器可能速度慢或不可用,而這些問題完全超出了你的控制。網路問題很常見,因此你必須預料到它們,例如透過重試失敗的請求。
* 本地函式呼叫要麼返回結果,要麼丟擲異常,要麼永不返回(因為它進入無限迴圈或程序崩潰)。網路請求有另一種可能的結果:它可能由於 *超時* 而沒有返回結果。在這種情況下,你根本不知道發生了什麼:如果你沒有從遠端服務獲得響應,你無法知道請求是否透過。(我們在 [第 9 章](/tw/ch9#ch_distributed) 中更詳細地討論了這個問題。)
* 如果你重試失敗的網路請求,可能會發生前一個請求實際上已經透過,只是響應丟失了。在這種情況下,重試將導致操作執行多次,除非你在協議中構建去重機制(*冪等性*[^40]。本地函式呼叫沒有這個問題。(我們在 [Link to Come] 中更詳細地討論了冪等性。)
* 如果你重試失敗的網路請求,可能會發生前一個請求實際上已經成功,只是響應丟失了。在這種情況下,重試將導致操作執行多次,除非你在協議中構建去重機制(*冪等性*[^40]。本地函式呼叫沒有這個問題。(我們在 [“冪等性”](/tw/ch12#sec_stream_idempotence) 中更詳細地討論冪等性。)
* 每次呼叫本地函式時,通常需要大約相同的時間來執行。網路請求比函式呼叫慢得多,其延遲也變化很大:在良好的時候,它可能在不到一毫秒內完成,但當網路擁塞或遠端服務過載時,執行完全相同的操作可能需要許多秒。
* 當你呼叫本地函式時,你可以有效地將引用(指標)傳遞給本地記憶體中的物件。當你發出網路請求時,所有這些引數都需要編碼為可以透過網路傳送的位元組序列。如果引數是不可變的原語,如數字或短字串,那沒問題,但對於更大量的資料和可變物件,它很快就會出現問題。
* 客戶端和服務可能以不同的程式語言實現,因此 RPC 框架必須將資料型別從一種語言轉換為另一種語言。這可能會變得很醜陋,因為並非所有語言都具有相同的型別——例如,回想一下 JavaScript 處理大於 2⁵³ 的數字的問題(參見 ["JSON、XML 及其二進位制變體"](/tw/ch5#sec_encoding_json))。單一語言編寫的單個程序中不存在此問題。
@ -488,7 +490,7 @@ RPC 方案的向後和向前相容性屬性繼承自它使用的任何編碼:
持久化執行框架已成為構建需要事務性的基於服務的架構的流行方式。在我們的支付示例中,我們希望每筆付款都恰好處理一次。工作流執行期間的故障可能導致信用卡扣費,但沒有相應的銀行賬戶存款。在基於服務的架構中,我們不能簡單地將兩個任務包裝在資料庫事務中。此外,我們可能正在與我們控制有限的第三方支付閘道器進行互動。
持久化執行框架是為工作流提供 *精確一次語義* 的一種方式。如果任務失敗,框架將重新執行該任務,但會跳過任務在失敗之前成功完成的任何 RPC 呼叫或狀態更改。相反,框架將假裝進行呼叫,但實際上將返回先前呼叫的結果。這是可能的,因為持久化執行框架將所有 RPC 和狀態更改記錄到持久儲存(如預寫日誌)[^45] [^46]。[示例 5-5](/tw/ch5#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。
持久化執行框架是為工作流提供 *恰好一次語義* 的一種方式。如果任務失敗,框架將重新執行該任務,但會跳過任務在失敗之前成功完成的任何 RPC 呼叫或狀態更改。相反,框架將假裝進行呼叫,但實際上將返回先前呼叫的結果。這是可能的,因為持久化執行框架將所有 RPC 和狀態更改記錄到持久儲存(如預寫日誌)[^45] [^46]。[示例 5-5](/tw/ch5#fig_temporal_workflow) 顯示了使用 Temporal 支援持久化執行的工作流定義示例。
{{< figure id="fig_temporal_workflow" title="示例 5-5. [圖 5-7](/tw/ch5#fig_encoding_workflow) 中支付工作流的 Temporal 工作流定義片段。" class="w-full my-4" >}}
@ -514,7 +516,7 @@ class PaymentWorkflow:
像 Temporal 這樣的框架並非沒有挑戰。外部服務(例如我們示例中的第三方支付閘道器)仍必須提供冪等 API。開發人員必須記住為這些 API 使用唯一 ID 以防止重複執行 [^47]。由於持久化執行框架按順序記錄每個 RPC 呼叫,因此它期望後續執行以相同的順序進行相同的 RPC 呼叫。這使得程式碼更改變得脆弱:你可能僅透過重新排序函式呼叫就引入未定義的行為 [^48]。與其修改現有工作流的程式碼,不如單獨部署新版本的程式碼更安全,以便現有工作流呼叫的重新執行繼續使用舊版本,只有新呼叫使用新程式碼 [^49]。
同樣,由於持久化執行框架期望以確定性方式重放所有程式碼(相同的輸入產生相同的輸出),因此隨機數生成器或系統時鐘等非確定性程式碼會產生問題 [^48]。框架通常提供此類庫函式的自己的確定性實現,但你必須記住使用它們。在某些情況下,例如 Temporal 的 workflowcheck 工具,框架提供靜態分析工具來確定是否引入了非確定性行為。
同樣,由於持久化執行框架期望以確定性方式重放所有程式碼(相同的輸入產生相同的輸出),因此隨機數生成器或系統時鐘等非確定性程式碼會產生問題 [^48]。框架通常會為這類庫函式提供自己的確定性實現,但你必須記得使用它們。在某些情況下,例如 Temporal 的 workflowcheck 工具,框架還會提供靜態分析工具來判斷是否引入了非確定性行為。
--------
@ -539,7 +541,7 @@ class PaymentWorkflow:
#### 訊息代理 {#message-brokers}
過去,訊息代理的格局由 TIBCO、IBM WebSphere 和 webMethods 等公司的商業企業軟體主導,然後開源實現(如 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka變得流行。最近雲服務如 Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub也獲得了採用。我們將在 [Link to Come] 中更詳細地比較它們。
過去,訊息代理的格局由 TIBCO、IBM WebSphere 和 webMethods 等公司的商業企業軟體主導,然後開源實現(如 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka變得流行。最近雲服務如 Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub也獲得了採用。我們將在 [“訊息系統”](/tw/ch12#sec_stream_messaging) 中更詳細地比較它們。
詳細的傳遞語義因實現和配置而異,但通常,最常使用兩種訊息分發模式:

View file

@ -6,11 +6,11 @@ breadcrumbs: false
![](/map/ch05.png)
> *出錯的事物與不可能出錯的事物之間的主要區別在於,當不可能出錯的事物出錯時,通常會發現它幾乎不可能查詢或修復。*
> *可能出錯的東西和“不可能”出錯的東西之間,最大的區別在於:後者一旦出錯,往往幾乎無從下手,也難以修復。*
>
> Douglas Adams《基本無害》1992
> 道格拉斯·亞當斯《基本無害》1992
**複製** 指的是透過網路連線的多臺機器上儲存相同資料的副本。如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 中所討論的,你可能出於以下幾個原因想要複製資料:
**複製** 指的是在透過網路連線的多臺機器上保留相同資料的副本。如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 中所討論的,你可能出於以下幾個原因希望複製資料:
* 使資料在地理上更接近使用者(從而減少訪問延遲)
* 即使系統的部分元件出現故障,也能讓系統繼續工作(從而提高可用性)
@ -18,7 +18,7 @@ breadcrumbs: false
本章假設你的資料集足夠小,每臺機器都可以儲存整個資料集的副本。在 [第 7 章](/tw/ch7#ch_sharding) 中,我們將放寬這一假設,討論單臺機器無法容納的、過大資料集的 **分片****分割槽**)。在後續章節中,我們將討論複製資料系統中可能發生的各種故障,以及如何處理它們。
如果需要複製的資料不會隨時間變化,那麼複製就很簡單:只需要將資料複製到每個節點一次就大功告成。處理複製的所有困難都在於處理複製資料的 **變更**,這也是本章的主題。我們將討論三種複製節點間變更的演算法族**單主**、**多主** 和 **無主** 複製。幾乎所有分散式資料庫都使用這三種方法之一。它們各有利弊,我們將詳細研究。
如果需要複製的資料不會隨時間變化,那麼複製就很簡單:只需要將資料複製到每個節點一次就大功告成。處理複製的所有困難都在於處理複製資料的 **變更**,這也是本章的主題。我們將討論三類在節點之間複製變更的演算法**單主**、**多主** 和 **無主** 複製。幾乎所有分散式資料庫都使用這三種方法之一。它們各有利弊,我們將詳細研究。
複製需要考慮許多權衡:例如,是使用同步還是非同步複製,以及如何處理失敗的副本。這些通常是資料庫中的配置選項,儘管不同資料庫的細節有所不同,但許多不同實現的通用原則是相似的。我們將在本章中討論這些選擇的後果。
@ -40,22 +40,22 @@ breadcrumbs: false
儲存資料庫副本的每個節點稱為 **副本**。有了多個副本,不可避免地會出現一個問題:我們如何確保所有資料最終都出現在所有副本上?
每次寫入資料庫都需要由每個副本處理;否則,副本將不再包含相同的資料。最常見的解決方案稱為 **基於主節點的複製**、**主備複製** 或 **主動/被動複製**。它的工作原理如下(見 [圖 6-1](/tw/ch6#fig_replication_leader_follower)
每次寫入資料庫都需要由每個副本處理;否則,副本將不再包含相同的資料。最常見的解決方案稱為 **基於領導者的複製**、**主備複製** 或 **主動/被動複製**。它的工作原理如下(見 [圖 6-1](/tw/ch6#fig_replication_leader_follower)
1. 其中一個副本被指定為 **主節點**(也稱為 **主庫****源** [^2])。當客戶端想要寫入資料庫時,他們必須將請求傳送給主節點,主節點首先將新資料寫入其本地儲存。
2. 其他副本稱為 **從節點****只讀副本**、**從庫** 或 **熱備**)。每當主節點將新資料寫入其本地儲存時,它也會將資料變更作為 **複製日誌****變更流** 的一部分發送給所有從節點。每個從節點從主節點獲取日誌,並透過按照與主節點處理相同的順序應用所有寫入來相應地更新其本地資料庫副本。
3. 當客戶端想要從資料庫讀取時,它可以查詢主節點或任何從節點。然而,只有主節點接受寫入(從客戶端的角度來看,從節點是隻讀的)。
1. 其中一個副本被指定為 **領導者**(也稱為 **主庫****源** [^2])。當客戶端想要寫入資料庫時,他們必須將請求傳送給領導者,領導者首先將新資料寫入其本地儲存。
2. 其他副本稱為 **追隨者****只讀副本**、**從庫** 或 **熱備**)。每當領導者將新資料寫入其本地儲存時,它也會將資料變更作為 **複製日誌****變更流** 的一部分發送給所有追隨者。每個追隨者從領導者獲取日誌,並透過按照與領導者處理相同的順序應用所有寫入來相應地更新其本地資料庫副本。
3. 當客戶端想要從資料庫讀取時,它可以查詢領導者或任何追隨者。然而,只有領導者接受寫入(從客戶端的角度來看,追隨者是隻讀的)。
{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="圖 6-1. 單主複製將所有寫入定向到指定的主節點,該主節點向從副本傳送變更流。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0601.png" id="fig_replication_leader_follower" caption="圖 6-1. 單主複製將所有寫入定向到指定的領導者,該領導者向追隨者傳送變更流。" class="w-full my-4" >}}
如果資料庫是分片的(見 [第 7 章](/tw/ch7#ch_sharding)),每個分片都有一個主節點。不同的分片可能在不同的節點上有其主節點,但每個分片仍必須有一個主節點。在 ["多主複製"](/tw/ch6#sec_replication_multi_leader) 中,我們將討論一種替代模型,其中系統可能同時為同一分片擁有多個主節點
如果資料庫是分片的(見 [第 7 章](/tw/ch7#ch_sharding)),每個分片都有一個領導者。不同的分片可能在不同的節點上有其領導者,但每個分片仍必須有一個領導者。在 ["多主複製"](/tw/ch6#sec_replication_multi_leader) 中,我們將討論一種替代模型,其中系統可能同時為同一分片擁有多個領導者
單主複製被廣泛使用。它是許多關係資料庫的內建功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性組 [^4]。它也用於一些文件資料庫,如 MongoDB 和 DynamoDB [^5],訊息代理如 Kafka複製塊裝置如 DRBD以及一些網路檔案系統。許多共識演算法如 Raft也基於單個主節點,用於 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁佇列(以及其他)中的複製,並在舊主節點失敗時自動選舉新主節點(我們將在 [第 10 章](/tw/ch10#ch_consistency) 中更詳細地討論共識)。
單主複製被廣泛使用。它是許多關係資料庫的內建功能,如 PostgreSQL、MySQL、Oracle Data Guard [^3] 和 SQL Server 的 Always On 可用性組 [^4]。它也用於一些文件資料庫,如 MongoDB 和 DynamoDB [^5],訊息代理如 Kafka複製塊裝置如 DRBD以及一些網路檔案系統。許多共識演算法如 Raft也基於單個領導者,用於 CockroachDB [^6]、TiDB [^7]、etcd 和 RabbitMQ 仲裁佇列(以及其他)中的複製,並在舊領導者失敗時自動選舉新領導者(我們將在 [第 10 章](/tw/ch10#ch_consistency) 中更詳細地討論共識)。
--------
> [!NOTE]
> 在較舊的文件中,你可能會看到術語 **主從複製**。它與基於主節點的複製含義相同,但應該避免使用該術語,因為它被廣泛認為是冒犯性的 [^8]。
> 在較舊的文件中,你可能會看到術語 **主從複製**。它與基於領導者的複製含義相同,但應該避免使用該術語,因為它被廣泛認為是冒犯性的 [^8]。
--------
@ -63,40 +63,40 @@ breadcrumbs: false
複製系統的一個重要細節是複製是 **同步** 發生還是 **非同步** 發生。(在關係資料庫中,這通常是一個可配置選項;其他系統通常硬編碼為其中之一。)
想想 [圖 6-1](/tw/ch6#fig_replication_leader_follower) 中發生的情況,一個網站使用者更新他們的個人資料圖片。在某個時間點,客戶端向主節點發送更新請求;不久之後,主節點收到了它。在某個時間點,主節點將資料變更轉發給從節點。最終,主節點通知客戶端更新成功。[圖 6-2](/tw/ch6#fig_replication_sync_replication) 顯示了時序可能的工作方式。
想想 [圖 6-1](/tw/ch6#fig_replication_leader_follower) 中發生的情況,一個網站使用者更新他們的個人資料圖片。在某個時間點,客戶端向領導者傳送更新請求;不久之後,領導者收到了它。在某個時間點,領導者將資料變更轉發給追隨者。最終,領導者通知客戶端更新成功。[圖 6-2](/tw/ch6#fig_replication_sync_replication) 顯示了時序可能的工作方式。
{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="圖 6-2. 基於主節點的複製,帶有一個同步和一個非同步從節點。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0602.png" id="fig_replication_sync_replication" caption="圖 6-2. 基於領導者的複製,帶有一個同步和一個非同步追隨者。" class="w-full my-4" >}}
在 [圖 6-2](/tw/ch6#fig_replication_sync_replication) 的示例中,對從節點 1 的複製是 **同步的**:主節點等待從節點 1 確認它已收到寫入,然後才向用戶報告成功,並使寫入對其他客戶端可見。對從節點 2 的複製是 **非同步的**:主節點發送訊息,但不等待從節點的響應。
在 [圖 6-2](/tw/ch6#fig_replication_sync_replication) 的示例中,對追隨者 1 的複製是 **同步的**:領導者等待追隨者 1 確認它已收到寫入,然後才向用戶報告成功,並使寫入對其他客戶端可見。對追隨者 2 的複製是 **非同步的**:領導者傳送訊息,但不等待追隨者的響應。
圖中顯示,從節點 2 處理訊息之前有相當大的延遲。通常,複製相當快:大多數資料庫系統在不到一秒的時間內將變更應用到從節點。然而,不能保證需要多長時間。在某些情況下,從節點可能落後主節點幾分鐘或更長時間;例如,如果從節點正在從故障中恢復,如果系統正在接近最大容量執行,或者如果節點之間存在網路問題。
圖中顯示,追隨者 2 處理訊息之前有相當大的延遲。通常,複製相當快:大多數資料庫系統在不到一秒的時間內將變更應用到追隨者。然而,不能保證需要多長時間。在某些情況下,追隨者可能落後領導者幾分鐘或更長時間;例如,如果追隨者正在從故障中恢復,如果系統正在接近最大容量執行,或者如果節點之間存在網路問題。
同步複製的優點是從節點保證擁有與主節點一致的最新資料副本。如果主節點突然失敗,我們可以確信資料仍然在從節點上可用。缺點是,如果同步從節點沒有響應(因為它已崩潰,或存在網路故障,或任何其他原因),寫入就無法處理。主節點必須阻塞所有寫入並等待同步副本再次可用。
同步複製的優點是追隨者保證擁有與領導者一致的最新資料副本。如果領導者突然失敗,我們可以確信資料仍然在追隨者上可用。缺點是,如果同步追隨者沒有響應(因為它已崩潰,或存在網路故障,或任何其他原因),寫入就無法處理。領導者必須阻塞所有寫入並等待同步副本再次可用。
因此,將所有從節點都設為同步是不切實際的:任何一個節點的中斷都會導致整個系統停止。實際上,如果資料庫提供同步複製,通常意味著 **一個** 從節點是同步的,其他的是非同步的。如果同步從節點變得不可用或緩慢,非同步從節點之一將變為同步。這保證了你至少在兩個節點上擁有最新的資料副本:主節點和一個同步從節點。這種配置有時也稱為 **半同步**
因此,將所有追隨者都設為同步是不切實際的:任何一個節點的中斷都會導致整個系統停止。實際上,如果資料庫提供同步複製,通常意味著 **一個** 追隨者是同步的,其他的是非同步的。如果同步追隨者變得不可用或緩慢,非同步追隨者之一將變為同步。這保證了你至少在兩個節點上擁有最新的資料副本:領導者和一個同步追隨者。這種配置有時也稱為 **半同步**
在某些系統中,**多數**(例如,包括主節點在內的 5 個副本中的 3 個)副本被同步更新,其餘少數是非同步的。這是 **仲裁** 的一個例子,我們將在 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition) 中進一步討論。多數仲裁通常用於使用共識協議進行自動主節點選舉的系統中,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中回到這個話題。
在某些系統中,**多數**(例如,包括領導者在內的 5 個副本中的 3 個)副本被同步更新,其餘少數是非同步的。這是 **仲裁** 的一個例子,我們將在 ["讀寫仲裁"](/tw/ch6#sec_replication_quorum_condition) 中進一步討論。多數仲裁通常用於使用共識協議進行自動領導者選舉的系統中,我們將在 [第 10 章](/tw/ch10#ch_consistency) 中回到這個話題。
有時,基於主節點的複製被配置為完全非同步。在這種情況下,如果主節點失敗且無法恢復,任何尚未複製到從節點的寫入都會丟失。這意味著即使已向客戶端確認,寫入也不能保證持久。然而,完全非同步配置的優點是主節點可以繼續處理寫入,即使所有從節點都已落後。
有時,基於領導者的複製被配置為完全非同步。在這種情況下,如果領導者失敗且無法恢復,任何尚未複製到追隨者的寫入都會丟失。這意味著即使已向客戶端確認,寫入也不能保證持久。然而,完全非同步配置的優點是領導者可以繼續處理寫入,即使所有追隨者都已落後。
弱化永續性可能聽起來像是一個糟糕的權衡,但非同步複製仍然被廣泛使用,特別是如果有許多從節點或者它們在地理上分佈廣泛 [^9]。我們將在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中回到這個問題。
弱化永續性可能聽起來像是一個糟糕的權衡,但非同步複製仍然被廣泛使用,特別是如果有許多追隨者或者它們在地理上分佈廣泛 [^9]。我們將在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中回到這個問題。
### 設定新的副本 {#sec_replication_new_replica}
不時地,你需要設定新的從節點——也許是為了增加副本的數量,或者替換失敗的節點。如何確保新的從節點擁有主節點資料的準確副本?
不時地,你需要設定新的追隨者——也許是為了增加副本的數量,或者替換失敗的節點。如何確保新的追隨者擁有領導者資料的準確副本?
簡單地將資料檔案從一個節點複製到另一個節點通常是不夠的:客戶端不斷向資料庫寫入,資料總是在變化,所以標準檔案複製會在不同的時間點看到資料庫的不同部分。結果可能沒有任何意義。
你可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但這將違揹我們的高可用性目標。幸運的是,設定從節點通常可以在不停機的情況下完成。從概念上講,過程如下所示:
你可以透過鎖定資料庫(使其不可用於寫入)來使磁碟上的檔案保持一致,但這將違揹我們的高可用性目標。幸運的是,設定追隨者通常可以在不停機的情況下完成。從概念上講,過程如下所示:
1. 在某個時間點獲取主節點資料庫的一致快照——如果可能,不鎖定整個資料庫。大多數資料庫都有此功能,因為備份也需要它。在某些情況下,需要第三方工具,例如用於 MySQL 的 Percona XtraBackup。
2. 將快照複製到新的從節點
3. 從節點連線到主節點並請求自快照拍攝以來發生的所有資料變更。這要求快照與主節點複製日誌中的確切位置相關聯。該位置有各種名稱例如PostgreSQL 稱之為 **日誌序列號**MySQL 有兩種機制,**binlog 位點** 和 **全域性事務識別符號**GTID
4. 當從節點處理了自快照以來的資料變更積壓後,我們說它已經 **追上進度**。它現在可以繼續處理主節點發生的資料變更。
1. 在某個時間點獲取領導者資料庫的一致快照——如果可能,不鎖定整個資料庫。大多數資料庫都有此功能,因為備份也需要它。在某些情況下,需要第三方工具,例如用於 MySQL 的 Percona XtraBackup。
2. 將快照複製到新的追隨者
3. 追隨者連線到領導者並請求自快照拍攝以來發生的所有資料變更。這要求快照與領導者複製日誌中的確切位置相關聯。該位置有各種名稱例如PostgreSQL 稱之為 **日誌序列號**MySQL 有兩種機制,**binlog 位點** 和 **全域性事務識別符號**GTID
4. 當追隨者處理了自快照以來的資料變更積壓後,我們說它已經 **追上進度**。它現在可以繼續處理領導者發生的資料變更。
設定從節點的實際步驟因資料庫而異。在某些系統中,該過程是完全自動化的,而在其他系統中,它可能是需要管理員手動執行的有些神秘的多步驟工作流程。
設定追隨者的實際步驟因資料庫而異。在某些系統中,該過程是完全自動化的,而在其他系統中,它可能是需要管理員手動執行的有些神秘的多步驟工作流程。
你也可以將複製日誌歸檔到物件儲存;連同物件儲存中整個資料庫的定期快照,這是實現資料庫備份和災難恢復的好方法。你還可以透過從物件儲存下載這些檔案來執行設定新從節點的步驟 1 和 2。例如WAL-G 為 PostgreSQL、MySQL 和 SQL Server 執行此操作Litestream 為 SQLite 執行等效操作。
你也可以將複製日誌歸檔到物件儲存;連同物件儲存中整個資料庫的定期快照,這是實現資料庫備份和災難恢復的好方法。你還可以透過從物件儲存下載這些檔案來執行設定新追隨者的步驟 1 和 2。例如WAL-G 為 PostgreSQL、MySQL 和 SQL Server 執行此操作Litestream 為 SQLite 執行等效操作。
--------
@ -121,53 +121,53 @@ breadcrumbs: false
系統中的任何節點都可能發生故障,可能是由於故障意外發生,但同樣可能是由於計劃維護(例如,重新啟動機器以安裝核心安全補丁)。能夠在不停機的情況下重新啟動單個節點對於操作和維護來說是一個很大的優勢。因此,我們的目標是儘管單個節點發生故障,但保持整個系統執行,並儘可能減小節點中斷的影響。
如何透過基於主節點的複製實現高可用性?
如何透過基於領導者的複製實現高可用性?
#### 從節點故障:追趕恢復 {#follower-failure-catch-up-recovery}
#### 追隨者故障:追趕恢復 {#follower-failure-catch-up-recovery}
在其本地磁碟上,每個從節點保留從主節點接收的資料變更日誌。如果從節點崩潰並重新啟動,或者如果主節點和從節點之間的網路暫時中斷,從節點可以很容易地恢復:從其日誌中,它知道在故障發生之前處理的最後一個事務。因此,從節點可以連線到主節點並請求在從節點斷開連線期間發生的所有資料變更。當它應用了這些變更後,它就趕上了主節點,可以像以前一樣繼續接收資料變更流。
在其本地磁碟上,每個追隨者保留從領導者接收的資料變更日誌。如果追隨者崩潰並重新啟動,或者如果領導者和追隨者之間的網路暫時中斷,追隨者可以很容易地恢復:從其日誌中,它知道在故障發生之前處理的最後一個事務。因此,追隨者可以連線到領導者並請求在追隨者斷開連線期間發生的所有資料變更。當它應用了這些變更後,它就趕上了領導者,可以像以前一樣繼續接收資料變更流。
儘管從節點恢復在概念上很簡單,但在效能方面可能具有挑戰性:如果資料庫具有高寫入吞吐量,或者如果從節點已離線很長時間,可能有很多寫入需要趕上。在進行這種追趕時,恢復的從節點和主節點(需要將寫入積壓傳送到從節點)都會有高負載。
儘管追隨者恢復在概念上很簡單,但在效能方面可能具有挑戰性:如果資料庫具有高寫入吞吐量,或者如果追隨者已離線很長時間,可能有很多寫入需要趕上。在進行這種追趕時,恢復的追隨者和領導者(需要將寫入積壓傳送到追隨者)都會有高負載。
一旦所有從節點都確認已處理了日誌,主節點就可以刪除其寫入日誌,但如果從節點長時間不可用,主節點面臨選擇:要麼保留日誌直到從節點恢復並趕上(冒著主節點磁碟空間耗盡的風險),要麼刪除不可用從節點尚未確認的日誌(在這種情況下,從節點無法從日誌中恢復,並且在它回來時必須從備份中恢復)。
一旦所有追隨者都確認已處理了日誌,領導者就可以刪除其寫入日誌,但如果追隨者長時間不可用,領導者面臨選擇:要麼保留日誌直到追隨者恢復並趕上(冒著領導者磁碟空間耗盡的風險),要麼刪除不可用追隨者尚未確認的日誌(在這種情況下,追隨者無法從日誌中恢復,並且在它回來時必須從備份中恢復)。
#### 領導者故障:故障轉移 {#leader-failure-failover}
處理主節點故障更加棘手:其中一個從節點需要被提升為新的主節點,客戶端需要重新配置以將其寫入傳送到新的主節點,其他從節點需要開始從新的主節點消費資料變更。這個過程稱為 **故障轉移**
處理領導者故障更加棘手:其中一個追隨者需要被提升為新的領導者,客戶端需要重新配置以將其寫入傳送到新的領導者,其他追隨者需要開始從新的領導者消費資料變更。這個過程稱為 **故障轉移**
故障轉移可以手動發生(管理員收到主節點失敗的通知並採取必要步驟來建立新的主節點)或自動發生。自動故障轉移過程通常包括以下步驟:
故障轉移可以手動發生(管理員收到領導者失敗的通知並採取必要步驟來建立新的領導者)或自動發生。自動故障轉移過程通常包括以下步驟:
1. **確定主節點已失敗。** 可能會出現許多問題:崩潰、停電、網路問題等。沒有萬無一失的方法來檢測出了什麼問題,所以大多數系統只是使用超時:節點經常相互反彈訊息,如果節點在一段時間內沒有響應——比如 30 秒——它被認為已死。(如果主節點被故意關閉以進行計劃維護,這不適用。)
2. **選擇新的主節點。** 這可以透過選舉過程完成(其中主節點由剩餘副本的多數選擇),或者新的主節點可以由先前建立的 **控制器節點** 任命 [^13]。領導的最佳候選者通常是具有來自舊主節點的最新資料變更的副本(以最小化任何資料丟失)。讓所有節點就新主節點達成一致是一個共識問題,在 [第 10 章](/tw/ch10#ch_consistency) 詳細討論。
3. **重新配置系統以使用新的主節點。** 客戶端現在需要將其寫入請求傳送到新的主節點(我們在 ["請求路由"](/tw/ch7#sec_sharding_routing) 中討論這個問題)。如果舊的主節點恢復,它可能仍然認為自己是主節點,沒有意識到其他副本已經迫使它下臺。系統需要確保舊的主節點成為從節點並識別新的主節點
1. **確定領導者已失效。** 可能會出現許多問題:崩潰、停電、網路故障等。沒有萬無一失的方法能準確判斷發生了什麼,所以大多數系統只是依賴超時:節點之間會頻繁來回傳送訊息,如果某個節點在一段時間內(例如 30 秒)沒有響應,就認為它已經失效。(如果是計劃維護而主動下線領導者,則不適用。)
2. **選擇新的領導者。** 這可以透過選舉過程完成(由剩餘副本中的多數選出領導者),也可以由預先設定的 **控制器節點** 任命 [^13]。最適合擔任領導者的通常是那個擁有舊領導者最新資料變更的副本(以儘量減少資料丟失)。讓所有節點就新領導者達成一致是一個共識問題,我們會在 [第 10 章](/tw/ch10#ch_consistency) 詳細討論。
3. **將系統重新配置為使用新的領導者。** 客戶端現在需要把寫請求傳送到新領導者(我們在 ["請求路由"](/tw/ch7#sec_sharding_routing) 中討論這個問題)。如果舊領導者恢復,它可能仍然以為自己是領導者,並不知道其他副本已經讓它下臺。系統需要確保舊領導者降級為追隨者,並識別新的領導者
故障轉移充滿了可能出錯的事情:
* 如果使用非同步複製,新的主節點可能在失敗之前沒有收到來自舊主節點的所有寫入。如果前主節點在選擇了新主節點後重新加入叢集,那些寫入應該怎麼辦?新的主節點可能同時收到了衝突的寫入。最常見的解決方案是簡單地丟棄舊主節點未複製的寫入,這意味著你認為已提交的寫入實際上並不持久。
* 如果資料庫之外的其他儲存系統需要與資料庫內容協調,丟棄寫入尤其危險。例如,在 GitHub 的一次事故中 [^14],一個過時的 MySQL 從節點被提升為主節點。資料庫使用自增計數器為新行分配主鍵,但由於新主節點的計數器落後於舊主節點,它重用了舊主節點先前分配的一些主鍵。這些主鍵也在 Redis 儲存中使用,因此主鍵的重用導致 MySQL 和 Redis 之間的不一致,這導致一些私人資料被錯誤地披露給錯誤的使用者。
* 在某些故障場景中(見 [第 9 章](/tw/ch9#ch_distributed)),可能會發生兩個節點都認為自己是主節點的情況。這種情況稱為 **腦裂**,這是危險的:如果兩個主節點都接受寫入,並且沒有解決衝突的過程(見 ["多主複製"](/tw/ch6#sec_replication_multi_leader)),資料很可能會丟失或損壞。作為安全措施,一些系統在檢測到兩個主節點時有一種機制來關閉一個節點。然而,如果這種機制設計不當,你最終可能會關閉兩個節點 [^15]。此外,當檢測到腦裂並關閉舊節點時,可能為時已晚,資料已經損壞。
* 在宣佈主節點死亡之前,正確的超時是什麼?更長的超時意味著在主節點失敗的情況下恢復時間更長。然而,如果超時太短,可能會有不必要的故障轉移。例如,臨時負載峰值可能導致節點的響應時間增加到超時以上,或者網路故障可能導致資料包延遲。如果系統已經在高負載或網路問題上掙扎,不必要的故障轉移可能會使情況變得更糟,而不是更好。
* 如果使用非同步複製,新的領導者可能在失敗之前沒有收到來自舊領導者的所有寫入。如果前領導者在選擇了新領導者後重新加入叢集,那些寫入應該怎麼辦?新的領導者可能同時收到了衝突的寫入。最常見的解決方案是簡單地丟棄舊領導者未複製的寫入,這意味著你認為已提交的寫入實際上並不持久。
* 如果資料庫之外的其他儲存系統需要與資料庫內容協調,丟棄寫入尤其危險。例如,在 GitHub 的一次事故中 [^14],一個過時的 MySQL 追隨者被提升為領導者。資料庫使用自增計數器為新行分配主鍵,但由於新領導者的計數器落後於舊領導者,它重用了舊領導者先前分配的一些主鍵。這些主鍵也在 Redis 儲存中使用,因此主鍵的重用導致 MySQL 和 Redis 之間的不一致,這導致一些私人資料被錯誤地披露給錯誤的使用者。
* 在某些故障場景中(見 [第 9 章](/tw/ch9#ch_distributed)),可能會發生兩個節點都認為自己是領導者的情況。這種情況稱為 **腦裂**,這是危險的:如果兩個領導者都接受寫入,並且沒有解決衝突的過程(見 ["多主複製"](/tw/ch6#sec_replication_multi_leader)),資料很可能會丟失或損壞。作為安全措施,一些系統在檢測到兩個領導者時有一種機制來關閉一個節點。然而,如果這種機制設計不當,你最終可能會關閉兩個節點 [^15]。此外,當檢測到腦裂並關閉舊節點時,可能為時已晚,資料已經損壞。
* 在宣佈領導者死亡之前,正確的超時是什麼?更長的超時意味著在領導者失敗的情況下恢復時間更長。然而,如果超時太短,可能會有不必要的故障轉移。例如,臨時負載峰值可能導致節點的響應時間增加到超時以上,或者網路故障可能導致資料包延遲。如果系統已經在高負載或網路問題上掙扎,不必要的故障轉移可能會使情況變得更糟,而不是更好。
--------
> [!NOTE]
> 透過限制或關閉舊主節點來防止腦裂被稱為 **柵欄機制**,或者更強調地說,**向頭部開槍**STONITH。我們將在 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 中更詳細地討論柵欄機制。
> 透過限制或關閉舊領導者來防止腦裂,被稱為 **柵欄機制**fencing或者更直白地說**爆彼之頭**STONITH。我們將在 ["分散式鎖和租約"](/tw/ch9#sec_distributed_lock_fencing) 中更詳細地討論柵欄機制。
--------
這些問題沒有簡單的解決方案。因此,一些運維團隊更喜歡手動執行故障轉移,即使軟體支援自動故障轉移。
故障轉移最重要的是選擇一個最新的從節點作為新的主節點——如果使用同步或半同步複製,這將是舊主節點在確認寫入之前等待的從節點。使用非同步複製,你可以選擇具有最大日誌序列號的從節點。這最小化了故障轉移期間丟失的資料量:丟失幾分之一秒的寫入可能是可以容忍的,但選擇落後幾天的從節點可能是災難性的。
故障轉移最重要的是選擇一個最新的追隨者作為新的領導者——如果使用同步或半同步複製,這將是舊領導者在確認寫入之前等待的追隨者。使用非同步複製,你可以選擇具有最大日誌序列號的追隨者。這最小化了故障轉移期間丟失的資料量:丟失幾分之一秒的寫入可能是可以容忍的,但選擇落後幾天的追隨者可能是災難性的。
這些問題——節點故障;不可靠的網路;以及圍繞副本一致性、永續性、可用性和延遲的權衡——實際上是分散式系統中的基本問題。在 [第 9 章](/tw/ch9#ch_distributed) 和 [第 10 章](/tw/ch10#ch_consistency) 中,我們將更深入地討論它們。
### 複製日誌的實現 {#sec_replication_implementation}
基於主節點的複製在底層是如何工作的?讓我們簡要地看看實踐中使用的幾種不同的複製方法。
基於領導者的複製在底層是如何工作的?讓我們簡要地看看實踐中使用的幾種不同的複製方法。
#### 基於語句的複製 {#statement-based-replication}
在最簡單的情況下,主節點記錄它執行的每個寫入請求(**語句**)並將該語句日誌傳送給其從節點。對於關係資料庫,這意味著每個 `INSERT`、`UPDATE` 或 `DELETE` 語句都被轉發到從節點,每個從節點解析並執行該 SQL 語句,就像它是從客戶端接收的一樣。
在最簡單的情況下,領導者記錄它執行的每個寫入請求(**語句**)並將該語句日誌傳送給其追隨者。對於關係資料庫,這意味著每個 `INSERT`、`UPDATE` 或 `DELETE` 語句都被轉發到追隨者,每個追隨者解析並執行該 SQL 語句,就像它是從客戶端接收的一樣。
雖然這聽起來合理,但這種複製方法可能會出現各種問題:
@ -175,17 +175,17 @@ breadcrumbs: false
* 如果語句使用自增列,或者如果它們依賴於資料庫中的現有資料(例如,`UPDATE … WHERE <某條件>`),它們必須在每個副本上以完全相同的順序執行,否則它們可能會產生不同的效果。當有多個併發執行的事務時,這可能會受到限制。
* 具有副作用的語句(例如,觸發器、儲存過程、使用者定義的函式)可能會導致每個副本上發生不同的副作用,除非副作用是絕對確定的。
可以解決這些問題——例如,主節點可以在記錄語句時用固定的返回值替換任何非確定性函式呼叫,以便從節點都獲得相同的值。以固定順序執行確定性語句的想法類似於我們之前在 ["事件溯源與 CQRS"](/tw/ch3#sec_datamodels_events) 中討論的事件溯源模型。這種方法也稱為 **狀態機複製**,我們將在 ["使用共享日誌"](/tw/ch10#sec_consistency_smr) 中討論其背後的理論。
可以解決這些問題——例如,領導者可以在記錄語句時用固定的返回值替換任何非確定性函式呼叫,以便追隨者都獲得相同的值。以固定順序執行確定性語句的想法類似於我們之前在 ["事件溯源與 CQRS"](/tw/ch3#sec_datamodels_events) 中討論的事件溯源模型。這種方法也稱為 **狀態機複製**,我們將在 ["使用共享日誌"](/tw/ch10#sec_consistency_smr) 中討論其背後的理論。
基於語句的複製在 MySQL 5.1 版本之前使用。它今天有時仍在使用因為它相當緊湊但預設情況下如果語句中有任何非確定性MySQL 現在會切換到基於行的複製稍後討論。VoltDB 使用基於語句的複製,並透過要求事務是確定性的來使其安全 [^16]。然而,確定性在實踐中很難保證,因此許多資料庫更喜歡其他複製方法。
#### 預寫日誌WAL傳輸 {#write-ahead-log-wal-shipping}
在 [第 4 章](/tw/ch4#ch_storage) 中,我們看到預寫日誌是使 B 樹儲存引擎健壯所必需的:每個修改首先寫入 WAL以便在崩潰後可以將樹恢復到一致狀態。由於 WAL 包含將索引和堆恢復到一致狀態所需的所有資訊,我們可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟外,主節點還透過網路將其傳送給其從節點。當從節點處理此日誌時,它構建了與主節點上找到的完全相同的檔案副本。
在 [第 4 章](/tw/ch4#ch_storage) 中,我們看到預寫日誌是使 B 樹儲存引擎健壯所必需的:每個修改首先寫入 WAL以便在崩潰後可以將樹恢復到一致狀態。由於 WAL 包含將索引和堆恢復到一致狀態所需的所有資訊,我們可以使用完全相同的日誌在另一個節點上構建副本:除了將日誌寫入磁碟外,領導者還透過網路將其傳送給其追隨者。當追隨者處理此日誌時,它構建了與領導者上找到的完全相同的檔案副本。
此複製方法在 PostgreSQL 和 Oracle 等中使用 [^17] [^18]。主要缺點是日誌在非常低的級別描述資料WAL 包含哪些位元組在哪些磁碟塊中被更改的詳細資訊。這使得複製與儲存引擎緊密耦合。如果資料庫從一個版本更改其儲存格式到另一個版本,通常不可能在主節點和從節點上執行不同版本的資料庫軟體。
此複製方法在 PostgreSQL 和 Oracle 等中使用 [^17] [^18]。主要缺點是日誌在非常低的級別描述資料WAL 包含哪些位元組在哪些磁碟塊中被更改的詳細資訊。這使得複製與儲存引擎緊密耦合。如果資料庫從一個版本更改其儲存格式到另一個版本,通常不可能在領導者和追隨者上執行不同版本的資料庫軟體。
這可能看起來像是一個小的實現細節,但它可能會產生很大的操作影響。如果複製協議允許從節點使用比主節點更新的軟體版本,你可以透過首先升級從節點然後執行故障轉移以使其中一個升級的節點成為新的主節點來執行資料庫軟體的零停機升級。如果複製協議不允許此版本不匹配(如 WAL 傳輸的情況),此類升級需要停機。
這可能看起來像是一個小的實現細節,但它可能會產生很大的操作影響。如果複製協議允許追隨者使用比領導者更新的軟體版本,你可以透過首先升級追隨者然後執行故障轉移以使其中一個升級的節點成為新的領導者來執行資料庫軟體的零停機升級。如果複製協議不允許此版本不匹配(如 WAL 傳輸的情況),此類升級需要停機。
#### 邏輯(基於行)日誌複製 {#logical-row-based-log-replication}
@ -199,35 +199,35 @@ breadcrumbs: false
修改多行的事務會生成多個這樣的日誌記錄後跟指示事務已提交的記錄。MySQL 除了 WAL 之外還保留一個單獨的邏輯複製日誌,稱為 **binlog**當配置為使用基於行的複製時。PostgreSQL 透過將物理 WAL 解碼為行插入/更新/刪除事件來實現邏輯複製 [^19]。
由於邏輯日誌與儲存引擎內部解耦,因此可以更容易地保持向後相容,允許主節點和從節點執行不同版本的資料庫軟體。這反過來又可以以最少的停機時間升級到新版本 [^20]。
由於邏輯日誌與儲存引擎內部解耦,因此可以更容易地保持向後相容,允許領導者和追隨者執行不同版本的資料庫軟體。這反過來又可以以最少的停機時間升級到新版本 [^20]。
邏輯日誌格式也更容易供外部應用程式解析。如果你想將資料庫的內容傳送到外部系統(例如用於離線分析的資料倉庫),或者構建自定義索引和快取 [^21],這方面很有用。這種技術稱為 **變更資料捕獲**,我們將在 [Link to Come] 中回到它。
邏輯日誌格式也更容易被外部應用解析。如果你想把資料庫內容傳送到外部系統(例如用於離線分析的資料倉庫),或者構建自定義索引和快取 [^21],這一點會很有用。這種技術稱為 **資料變更捕獲**,我們將在 ["資料變更捕獲"](/tw/ch12#sec_stream_cdc) 一節再回到它。
## 複製延遲的問題 {#sec_replication_lag}
能夠容忍節點故障只是想要複製的一個原因。如 ["分散式與單節點系統"](/tw/ch1#sec_introduction_distributed) 中所述,其他原因是可伸縮性(處理比單臺機器能夠處理的更多請求)和延遲(將副本在地理上放置得更接近使用者)。
基於主節點的複製要求所有寫入都透過單個節點,但只讀查詢可以轉到任何副本。對於主要由讀取和只有少量寫入組成的工作負載(這通常是線上服務的情況),有一個有吸引力的選擇:建立許多從節點,並將讀取請求分佈在這些從節點上。這減輕了主節點的負載,並允許附近的副本提供讀取請求。
基於領導者的複製要求所有寫入都透過單個節點,但只讀查詢可以轉到任何副本。對於主要由讀取和只有少量寫入組成的工作負載(這通常是線上服務的情況),有一個有吸引力的選擇:建立許多追隨者,並將讀取請求分佈在這些追隨者上。這減輕了領導者的負載,並允許附近的副本提供讀取請求。
在這種 **讀擴充套件** 架構中,你可以透過新增更多從節點來簡單地增加服務只讀請求的容量。然而,這種方法只有在使用非同步複製時才現實可行——如果你試圖同步複製到所有從節點,單個節點故障或網路中斷將使整個系統無法寫入。而且你擁有的節點越多,其中一個節點宕機的可能性就越大,因此完全同步的配置將非常不可靠。
在這種 **讀擴充套件** 架構中,你可以透過新增更多追隨者來簡單地增加服務只讀請求的容量。然而,這種方法只有在使用非同步複製時才現實可行——如果你試圖同步複製到所有追隨者,單個節點故障或網路中斷將使整個系統無法寫入。而且你擁有的節點越多,其中一個節點宕機的可能性就越大,因此完全同步的配置將非常不可靠。
不幸的是,如果應用程式從 **非同步** 從節點讀取,如果從節點已落後,它可能會看到過時的資訊。這導致資料庫中出現明顯的不一致:如果你同時在主節點和從節點上執行相同的查詢,你可能會得到不同的結果,因為並非所有寫入都已反映在從節點中。這種不一致只是一種臨時狀態——如果你停止向資料庫寫入並等待一段時間,從節點最終將趕上並與主節點保持一致。因此,這種效果被稱為 **最終一致性** [^22]。
不幸的是,如果應用程式從 **非同步** 追隨者讀取,如果追隨者已落後,它可能會看到過時的資訊。這導致資料庫中出現明顯的不一致:如果你同時在領導者和追隨者上執行相同的查詢,你可能會得到不同的結果,因為並非所有寫入都已反映在追隨者中。這種不一致只是一種臨時狀態——如果你停止向資料庫寫入並等待一段時間,追隨者最終將趕上並與領導者保持一致。因此,這種效果被稱為 **最終一致性** [^22]。
--------
> [!NOTE]
> 術語 **最終一致性** 由 Douglas Terry 等人創造 [^23],由 Werner Vogels 推廣 [^24],併成為許多 NoSQL 專案的戰鬥口號。然而,不僅 NoSQL 資料庫是最終一致的:非同步複製的關係資料庫中的從節點具有相同的特徵。
> 術語 **最終一致性** 由 Douglas Terry 等人創造 [^23],由 Werner Vogels 推廣 [^24],併成為許多 NoSQL 專案的戰鬥口號。然而,不僅 NoSQL 資料庫是最終一致的:非同步複製的關係資料庫中的追隨者具有相同的特徵。
--------
術語"最終"是故意模糊的:一般來說,副本可以落後多遠沒有限制。在正常操作中,寫入發生在主節點上並反映在從節點上之間的延遲——**複製延遲**——可能只是幾分之一秒,在實踐中不會被注意到。然而,如果系統在接近容量執行或網路中存在問題,延遲可以輕易增加到幾秒甚至幾分鐘。
術語"最終"是故意模糊的:一般來說,副本可以落後多遠沒有限制。在正常操作中,寫入發生在領導者上並反映在追隨者上之間的延遲——**複製延遲**——可能只是幾分之一秒,在實踐中不會被注意到。然而,如果系統在接近容量執行或網路中存在問題,延遲可以輕易增加到幾秒甚至幾分鐘。
當延遲如此之大時,它引入的不一致不僅僅是一個理論問題,而是應用程式的真正問題。在本節中,我們將重點介紹複製延遲時可能發生的三個問題示例。我們還將概述解決它們的一些方法。
### 讀己之寫 {#sec_replication_ryw}
許多應用程式讓使用者提交一些資料,然後檢視他們提交的內容。這可能是客戶資料庫中的記錄,或討論執行緒上的評論,或其他類似的東西。提交新資料時,必須將其傳送到主節點,但當用戶檢視資料時,可以從從節點讀取。如果資料經常被檢視但只是偶爾被寫入,這尤其合適。
許多應用程式讓使用者提交一些資料,然後檢視他們提交的內容。這可能是客戶資料庫中的記錄,或討論執行緒上的評論,或其他類似的東西。提交新資料時,必須將其傳送到領導者,但當用戶檢視資料時,可以從追隨者讀取。如果資料經常被檢視但只是偶爾被寫入,這尤其合適。
使用非同步複製,存在一個問題,如 [圖 6-3](/tw/ch6#fig_replication_read_your_writes) 所示:如果使用者在寫入後不久檢視資料,新資料可能尚未到達副本。對使用者來說,看起來他們提交的資料丟失了,所以他們自然會不高興。
@ -235,39 +235,39 @@ breadcrumbs: false
在這種情況下,我們需要 **寫後讀一致性**,也稱為 **讀己之寫一致性** [^23]。這是一種保證,如果使用者重新載入頁面,他們將始終看到他們自己提交的任何更新。它不對其他使用者做出承諾:其他使用者的更新可能直到稍後才可見。然而,它向用戶保證他們自己的輸入已正確儲存。
我們如何在基於主節點的複製系統中實現寫後讀一致性?有各種可能的技術。提及其中幾個
我們如何在基於領導者的複製系統中實現寫後讀一致性?有各種可能的技術。下面舉幾個例子
* 當讀取使用者可能已修改的內容時,從主節點或同步更新的從節點讀取;否則,從非同步更新的從節點讀取。這要求你有某種方法知道某物是否可能已被修改,而無需實際查詢它。例如,社交網路上的使用者個人資料資訊通常只能由個人資料的所有者編輯,而不能由其他任何人編輯。因此,一個簡單的規則是:始終從主節點讀取使用者自己的個人資料,從從節點讀取任何其他使用者的個人資料。
* 如果應用程式中的大多數東西都可能被使用者編輯,那種方法將不會有效,因為大多數東西都必須從主節點讀取(否定了讀擴充套件的好處)。在這種情況下,可以使用其他標準來決定是否從主節點讀取。例如,你可以跟蹤上次更新的時間,並在上次更新後的一分鐘內,使所有讀取都來自主節點 [^25]。你還可以監控從節點上的複製延遲,並防止在落後主節點超過一分鐘的任何從節點上進行查詢。
* 當讀取使用者可能已修改的內容時,從領導者或同步更新的追隨者讀取;否則,從非同步更新的追隨者讀取。這要求你有某種方法知道某物是否可能已被修改,而無需實際查詢它。例如,社交網路上的使用者個人資料資訊通常只能由個人資料的所有者編輯,而不能由其他任何人編輯。因此,一個簡單的規則是:始終從領導者讀取使用者自己的個人資料,從追隨者讀取任何其他使用者的個人資料。
* 如果應用程式中的大多數東西都可能被使用者編輯,那種方法將不會有效,因為大多數東西都必須從領導者讀取(否定了讀擴充套件的好處)。在這種情況下,可以使用其他標準來決定是否從領導者讀取。例如,你可以跟蹤上次更新的時間,並在上次更新後的一分鐘內,使所有讀取都來自領導者 [^25]。你還可以監控追隨者上的複製延遲,並防止在落後領導者超過一分鐘的任何追隨者上進行查詢。
* 客戶端可以記住其最近寫入的時間戳——然後系統可以確保為該使用者提供任何讀取的副本至少反映該時間戳之前的更新。如果副本不夠最新,則可以由另一個副本處理讀取,或者查詢可以等待直到副本趕上 [^26]。時間戳可以是 **邏輯時間戳**(指示寫入順序的東西,例如日誌序列號)或實際系統時鐘(在這種情況下,時鐘同步變得至關重要;見 ["不可靠的時鐘"](/tw/ch9#sec_distributed_clocks))。
* 如果你的副本分佈在各個地區(為了地理上接近使用者或為了可用性),還有額外的複雜性。任何需要由主節點提供的請求都必須路由到包含主節點的地區。
* 如果你的副本分佈在各個地區(為了地理上接近使用者或為了可用性),還有額外的複雜性。任何需要由領導者提供的請求都必須路由到包含領導者的地區。
當同一使用者從多個裝置訪問你的服務時,會出現另一個複雜情況,例如桌面網路瀏覽器和移動應用程式。在這種情況下,你可能希望提供 **跨裝置** 寫後讀一致性:如果使用者在一個裝置上輸入一些資訊,然後在另一個裝置上檢視它,他們應該看到他們剛剛輸入的資訊。
在這種情況下,需要考慮一些額外的問題:
* 需要記住使用者上次更新的時間戳的方法變得更加困難,因為在一個裝置上執行的程式碼不知道在另一個裝置上發生了什麼更新。此元資料將需要集中化。
* 如果你的副本分佈在不同的地區,則無法保證來自不同裝置的連線將路由到同一地區。(例如,如果使用者的臺式計算機使用家庭寬頻連線,而他們的移動裝置使用蜂窩資料網路,則裝置的網路路由可能完全不同。)如果你的方法需要從主節點讀取,你可能首先需要將來自使用者所有裝置的請求路由到同一地區。
* 如果你的副本分佈在不同的地區,則無法保證來自不同裝置的連線將路由到同一地區。(例如,如果使用者的臺式計算機使用家庭寬頻連線,而他們的移動裝置使用蜂窩資料網路,則裝置的網路路由可能完全不同。)如果你的方法需要從領導者讀取,你可能首先需要將來自使用者所有裝置的請求路由到同一地區。
--------
> ![TIP] 地區和可用區
> [!TIP] 地區和可用區
>
> 我們使用術語 **地區** 來指代單個地理位置中的一個或多個數據中心。雲提供商在同一地理區域中定位多個數據中心。每個資料中心被稱為 **可用區** 或簡稱 **區域**。因此,單個雲區域由多個區域組成。每個區域是位於獨立物理設施中的獨立資料中心,具有自己的電源、冷卻等
> 我們**地區**region來指代一個地理位置中的一組資料中心。雲服務提供商通常會在同一地區部署多個數據中心每個資料中心稱為 **可用區**availability zone簡稱 AZ。因此一個地區由多個可用區組成每個可用區都是獨立的物理設施具有自己的供電、製冷等基礎設施
>
> 同一地區的區域透過非常高速的網路連線連線。延遲足夠低,以至於大多數分散式系統可以在同一地區的多個區域中執行節點,就好像它們在單個區域中一樣。多區域配置允許分散式系統在一個區域離線的區域中斷中倖存,但它們不能防止所有區域不可用的區域中斷。為了在區域中斷中倖存,分散式系統必須部署在多個地區,這可能導致更高的延遲、更低的吞吐量和增加的雲網絡賬單。我們將在 ["多主複製拓撲"](/tw/ch6#sec_replication_topologies) 中更多地討論這些權衡。現在,只要知道當我們說地區時,我們指的是單個地理位置中的區域/資料中心集合。
> 同一地區內各可用區通常透過高速網路互聯,延遲足夠低,因此大多數分散式系統可以把同一地區內的多個可用區近似看作一個機房。多可用區部署可以抵禦單個可用區故障,但無法抵禦整個地區不可用。要應對地區級中斷,系統必須跨多個地區部署,這通常會帶來更高延遲、更低吞吐和更高的雲網絡費用。我們將在 ["多主複製拓撲"](/tw/ch6#sec_replication_topologies) 中進一步討論這些權衡。這裡你只需記住:本書所說的“地區”,是同一地理位置內多個可用區(資料中心)的集合。
--------
### 單調讀 {#sec_replication_monotonic_reads}
從非同步從節點讀取時可能發生的第二個異常示例是,使用者可能會看到事物 **在時間上倒退**
從非同步追隨者讀取時可能發生的第二個異常示例是,使用者可能會看到事物 **在時間上倒退**
如果使用者從不同的副本進行多次讀取,就可能發生這種情況。例如,[圖 6-4](/tw/ch6#fig_replication_monotonic_reads) 顯示使用者 2345 進行相同的查詢兩次,首先到延遲很小的從節點,然後到延遲更大的從節點。(如果使用者重新整理網頁,並且每個請求都路由到隨機伺服器,這種情況很可能發生。)第一個查詢返回使用者 1234 最近新增的評論,但第二個查詢沒有返回任何內容,因為滯後的從節點尚未獲取該寫入。實際上,第二個查詢觀察到的系統狀態比第一個查詢更早的時間點。如果第一個查詢沒有返回任何內容,這不會那麼糟糕,因為使用者 2345 可能不知道使用者 1234 最近添加了評論。然而,如果使用者 2345 首先看到使用者 1234 的評論出現,然後又看到它消失,這對使用者 2345 來說非常令人困惑。
如果使用者從不同的副本進行多次讀取,就可能發生這種情況。例如,[圖 6-4](/tw/ch6#fig_replication_monotonic_reads) 顯示使用者 2345 進行相同的查詢兩次,首先到延遲很小的追隨者,然後到延遲更大的追隨者。(如果使用者重新整理網頁,並且每個請求都路由到隨機伺服器,這種情況很可能發生。)第一個查詢返回使用者 1234 最近新增的評論,但第二個查詢沒有返回任何內容,因為滯後的追隨者尚未獲取該寫入。實際上,第二個查詢觀察到的系統狀態比第一個查詢更早的時間點。如果第一個查詢沒有返回任何內容,這不會那麼糟糕,因為使用者 2345 可能不知道使用者 1234 最近添加了評論。然而,如果使用者 2345 首先看到使用者 1234 的評論出現,然後又看到它消失,這對使用者 2345 來說非常令人困惑。
{{< figure src="/fig/ddia_0604.png" id="fig_replication_monotonic_reads" caption="圖 6-4. 使用者首先從新鮮副本讀取,然後從陳舊副本讀取。時間似乎倒退了。為了防止這種異常,我們需要單調讀。" class="w-full my-4" >}}
**單調讀** [^22] 是一種保證這種異常不會發生的保證。它是比強一致性更弱的保證,但比最終一致性更強的保證。當你讀取資料時,你可能會看到一個舊值;單調讀只意味著如果一個使用者按順序進行多次讀取,他們不會看到時間倒退——即,在之前讀取較新資料後,他們不會讀取較舊的資料
**單調讀** [^22] 是一種保證這類異常不會發生的會話保證。它比強一致性弱,但比最終一致性強。當你讀取資料時,仍可能看到舊值;單調讀只保證同一使用者按順序進行多次讀取時,不會出現“時間倒退”——也就是先讀到新值,後又讀到更舊的值
實現單調讀的一種方法是確保每個使用者始終從同一副本進行讀取(不同的使用者可以從不同的副本讀取)。例如,可以基於使用者 ID 的雜湊選擇副本,而不是隨機選擇。然而,如果該副本失敗,使用者的查詢將需要重新路由到另一個副本。
@ -283,7 +283,7 @@ Cake 夫人
這兩個句子之間存在因果依賴關係Cake 夫人聽到了 Poons 先生的問題並回答了它。
現在,想象第三個人透過從節點聽這個對話。Cake 夫人說的話透過延遲很小的從節點,但 Poons 先生說的話有更長的複製延遲(見 [圖 6-5](/tw/ch6#fig_replication_consistent_prefix))。這個觀察者會聽到以下內容:
現在,想象第三個人透過追隨者聽這個對話。Cake 夫人說的話透過延遲很小的追隨者,但 Poons 先生說的話有更長的複製延遲(見 [圖 6-5](/tw/ch6#fig_replication_consistent_prefix))。這個觀察者會聽到以下內容:
Cake 夫人
: 通常大約十秒鐘Poons 先生。
@ -299,13 +299,13 @@ Poons 先生
這是分片(分割槽)資料庫中的一個特殊問題,我們將在 [第 7 章](/tw/ch7#ch_sharding) 中討論。如果資料庫始終以相同的順序應用寫入,讀取始終會看到一致的字首,因此這種異常不會發生。然而,在許多分散式資料庫中,不同的分片獨立執行,因此沒有全域性的寫入順序:當用戶從資料庫讀取時,他們可能會看到資料庫的某些部分處於較舊狀態,而某些部分處於較新狀態。
一種解決方案是確保任何因果相關的寫入都寫入同一分片——但在某些應用程式中,這無法有效完成。還有一些演算法明確跟蹤因果依賴關係,這是我們將在 [""先發生"關係與併發"](/tw/ch6#sec_replication_happens_before) 中回到的主題。
一種解決方案是確保任何因果相關的寫入都寫入同一分片——但在某些應用程式中,這無法有效完成。還有一些演算法明確跟蹤因果依賴關係,這是我們將在 ["先發生關係與併發"](/tw/ch6#sec_replication_happens_before) 中回到的主題。
### 複製延遲的解決方案 {#id131}
在使用最終一致系統時,值得思考如果複製延遲增加到幾分鐘甚至幾小時,應用程式的行為如何。如果答案是"沒問題",那很好。然而,如果結果對使用者來說是糟糕的體驗,那麼設計系統以提供更強的保證(如寫後讀)很重要。明明是非同步複製,卻假裝它是同步的,這為日後出問題埋下了隱患
在使用最終一致系統時,值得思考:如果複製延遲上升到幾分鐘甚至幾小時,應用程式會如何表現。如果答案是“沒問題”,那很好;但如果這會造成糟糕的使用者體驗,就應當設計系統提供更強的保證(如寫後讀一致性)。把非同步複製當作同步複製來假設,往往會在系統承壓時暴露問題
如前所述,應用程式可以提供比底層資料庫更強的保證——例如,透過在主節點或同步更新的從節點上執行某些型別的讀取。然而,在應用程式程式碼中處理這些問題很複雜且容易出錯。
如前所述,應用程式可以提供比底層資料庫更強的保證——例如,透過在領導者或同步更新的追隨者上執行某些型別的讀取。然而,在應用程式程式碼中處理這些問題很複雜且容易出錯。
對於應用程式開發人員來說,最簡單的程式設計模型是選擇一個為副本提供強一致性保證的資料庫,例如線性一致性(見 [第 10 章](/tw/ch10#ch_consistency))和 ACID 事務(見 [第 8 章](/tw/ch8#ch_transactions))。這允許你大部分忽略複製帶來的挑戰,並將資料庫視為只有一個節點。在 2010 年代初期,**NoSQL** 運動推廣了這樣的觀點,即這些功能限制了可伸縮性,大規模系統必須接受最終一致性。
@ -317,43 +317,43 @@ Poons 先生
## 多主複製 {#sec_replication_multi_leader}
到目前為止,本章中我們只考慮了使用單個主節點的複製架構。儘管這是一種常見的方法,但還有一些有趣的替代方案。
到目前為止,本章中我們只考慮了使用單個領導者的複製架構。儘管這是一種常見的方法,但還有一些有趣的替代方案。
單主複製有一個主要缺點:所有寫入都必須透過一個主節點。如果由於任何原因無法連線到主節點,例如你和主節點之間的網路中斷,你就無法寫入資料庫。
單主複製有一個主要缺點:所有寫入都必須透過一個領導者。如果由於任何原因無法連線到領導者,例如你和領導者之間的網路中斷,你就無法寫入資料庫。
單主複製模型的自然擴充套件是允許多個節點接受寫入。複製仍然以相同的方式進行:每個處理寫入的節點必須將該資料變更轉發給所有其他節點。我們稱之為 **多主** 配置(也稱為 **主動/主動****雙向** 複製)。在這種設定中,每個主節點同時充當其他主節點的從節點
單主複製模型的自然擴充套件是允許多個節點接受寫入。複製仍然以相同的方式進行:每個處理寫入的節點必須將該資料變更轉發給所有其他節點。我們稱之為 **多主** 配置(也稱為 **主動/主動****雙向** 複製)。在這種設定中,每個領導者同時充當其他領導者的追隨者
與單主複製一樣,可以選擇使其同步或非同步。假設你有兩個主節點*A* 和 *B*,你正在嘗試寫入 *A*。如果寫入從 *A* 同步複製到 *B*,並且兩個節點之間的網路中斷,你就無法寫入 *A* 直到網路恢復。同步多主複製因此給你一個非常類似於單主複製的模型,即如果你讓 *B* 成為主節點*A* 只是將任何寫入請求轉發給 *B* 執行。
與單主複製一樣,可以選擇使其同步或非同步。假設你有兩個領導者*A* 和 *B*,你正在嘗試寫入 *A*。如果寫入從 *A* 同步複製到 *B*,並且兩個節點之間的網路中斷,你就無法寫入 *A* 直到網路恢復。同步多主複製因此給你一個非常類似於單主複製的模型,即如果你讓 *B* 成為領導者*A* 只是將任何寫入請求轉發給 *B* 執行。
因此,我們不會進一步討論同步多主複製,而只是將其視為等同於單主複製。本節的其餘部分專注於非同步多主複製,其中任何主節點都可以處理寫入,即使其與其他主節點的連線中斷。
因此,我們不會進一步討論同步多主複製,而只是將其視為等同於單主複製。本節的其餘部分專注於非同步多主複製,其中任何領導者都可以處理寫入,即使其與其他領導者的連線中斷。
### 跨地域執行 {#sec_replication_multi_dc}
在單個地區內使用多主設定很少有意義,因為好處很少超過增加的複雜性。然而,在某些情況下,這種配置是合理的。
想象你有一個數據庫,在幾個不同的地區有副本(也許是為了能夠容忍整個地區的故障,或者是為了更接近你的使用者)。這被稱為 **地理分散式**、**地域分散式** 或 **地域複製** 設定。使用單主複製,主節點必須在 **一個** 地區,所有寫入都必須透過該地區。
想象你有一個數據庫,在幾個不同的地區有副本(也許是為了能夠容忍整個地區的故障,或者是為了更接近你的使用者)。這被稱為 **地理分散式**、**地域分散式** 或 **地域複製** 設定。使用單主複製,領導者必須在 **一個** 地區,所有寫入都必須透過該地區。
在多主配置中,你可以在 **每個** 地區都有一個主節點。[圖 6-6](/tw/ch6#fig_replication_multi_dc) 顯示了這種架構可能的樣子。在每個地區內,使用常規的主從複製(從節點可能在與主節點不同的可用區中);在地區之間,每個地區的主節點將其變更復制到其他地區的主節點
在多主配置中,你可以在 **每個** 地區都部署一個領導者。[圖 6-6](/tw/ch6#fig_replication_multi_dc) 展示了這種架構:在每個地區內使用常規單主複製(追隨者可能位於與領導者不同的可用區);在地區之間,每個地區的領導者把變更復制給其他地區的領導者
{{< figure src="/fig/ddia_0606.png" id="fig_replication_multi_dc" caption="圖 6-6. 跨多個地區的多主複製。" class="w-full my-4" >}}
讓我們比較單主和多主配置在多地區部署中的表現:
效能
: 在單主配置中,每次寫入都必須透過網際網路到擁有主節點的地區。這可能會給寫入增加顯著的延遲,並可能違背首先擁有多個地區的目的。在多主配置中,每次寫入都可以在本地地區處理,並非同步複製到其他地區。因此,跨地區網路延遲對使用者是隱藏的,這意味著感知效能可能更好。
: 在單主配置中,每次寫入都必須透過網際網路到擁有領導者的地區。這可能會給寫入增加顯著的延遲,並可能違背首先擁有多個地區的目的。在多主配置中,每次寫入都可以在本地地區處理,並非同步複製到其他地區。因此,跨地區網路延遲對使用者是隱藏的,這意味著感知效能可能更好。
地區故障容忍
: 在單主配置中,如果擁有主節點的地區變得不可用,故障轉移可以將另一個地區的從節點提升為主節點。在多主配置中,每個地區可以獨立於其他地區繼續執行,並在離線地區恢復上線時趕上覆制。
: 在單主配置中,如果擁有領導者的地區變得不可用,故障轉移可以將另一個地區的追隨者提升為領導者。在多主配置中,每個地區可以獨立於其他地區繼續執行,並在離線地區恢復上線時趕上覆制。
網路問題容忍
: 即使有專用連線,地區之間的流量也可能比同一地區內或單個區域內的流量更不可靠。單主配置對這種跨地區鏈路中的問題非常敏感,因為當一個地區的客戶端想要寫入另一個地區的主節點時,它必須透過該鏈路傳送其請求並等待響應才能完成。
: 即使有專用連線,地區之間的流量也可能比同一地區內或單個區域內的流量更不可靠。單主配置對這種跨地區鏈路中的問題非常敏感,因為當一個地區的客戶端想要寫入另一個地區的領導者時,它必須透過該鏈路傳送其請求並等待響應才能完成。
具有非同步複製的多主配置可以更好地容忍網路問題:在臨時網路中斷期間,每個地區的主節點可以繼續獨立處理寫入。
具有非同步複製的多主配置可以更好地容忍網路問題:在臨時網路中斷期間,每個地區的領導者可以繼續獨立處理寫入。
一致性
: 單主系統可以提供強一致性保證,例如可序列化事務,我們將在 [第 8 章](/tw/ch8#ch_transactions) 中討論。多主系統的最大缺點是它們能夠實現的一致性要弱得多。例如,你不能保證銀行賬戶不會變成負數或使用者名稱是唯一的:不同的主節點總是可能處理單獨沒問題的寫入(從賬戶中支付一些錢,註冊特定使用者名稱),但當與另一個主節點上的另一個寫入結合時違反了約束。
: 單主系統可以提供強一致性保證,例如可序列化事務,我們將在 [第 8 章](/tw/ch8#ch_transactions) 中討論。多主系統的最大缺點是它們能夠實現的一致性要弱得多。例如,你不能保證銀行賬戶不會變成負數或使用者名稱是唯一的:不同的領導者總是可能處理單獨沒問題的寫入(從賬戶中支付一些錢,註冊特定使用者名稱),但當與另一個領導者上的另一個寫入結合時違反了約束。
這只是分散式系統的基本限制 [^28]。如果你需要強制執行此類約束,因此你最好使用單主系統。然而,正如我們將在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中看到的,多主系統仍然可以實現在不需要此類約束的廣泛應用程式中有用的一致性屬性。
這只是分散式系統的基本限制 [^28]。如果你必須強制執行這類約束,通常應選擇單主系統。不過,正如我們將在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中看到的,多主系統在不需要這類約束的廣泛應用裡,仍然可以提供有用的一致性屬性。
多主複製不如單主複製常見,但許多資料庫仍然支援它,包括 MySQL、Oracle、SQL Server 和 YugabyteDB。在某些情況下它是一個外部附加功能例如在 Redis Enterprise、EDB Postgres Distributed 和 pglogical 中 [^29]。
@ -361,11 +361,11 @@ Poons 先生
#### 多主複製拓撲 {#sec_replication_topologies}
**複製拓撲** 描述了寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個主節點,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,只有一種合理的拓撲:主節點 1 必須將其所有寫入傳送到主節點 2反之亦然。有了兩個以上的主節點,各種不同的拓撲是可能的。[圖 6-7](/tw/ch6#fig_replication_topologies) 中說明了一些示例。
**複製拓撲** 描述了寫入從一個節點傳播到另一個節點的通訊路徑。如果你有兩個領導者,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,只有一種合理的拓撲:領導者 1 必須將其所有寫入傳送到領導者 2反之亦然。有了兩個以上的領導者,各種不同的拓撲是可能的。[圖 6-7](/tw/ch6#fig_replication_topologies) 中說明了一些示例。
{{< figure src="/fig/ddia_0607.png" id="fig_replication_topologies" caption="圖 6-7. 可以設定多主複製的三個示例拓撲。" class="w-full my-4" >}}
最通用的拓撲是 **全對全**,如 [圖 6-7](/tw/ch6#fig_replication_topologies)(c) 所示,其中每個主節點將其寫入傳送到每個其他主節點。然而,也使用更受限制的拓撲:例如 **環形拓撲**,其中每個節點從一個節點接收寫入並將這些寫入(加上其自己的任何寫入)轉發到另一個節點。另一種流行的拓撲具有 **星形** 形狀:一個指定的根節點將寫入轉發到所有其他節點。星形拓撲可以推廣到樹形。
最通用的拓撲是 **全對全**,如 [圖 6-7](/tw/ch6#fig_replication_topologies)(c) 所示,其中每個領導者將其寫入傳送到每個其他領導者。然而,也使用更受限制的拓撲:例如 **環形拓撲**,其中每個節點從一個節點接收寫入並將這些寫入(加上其自己的任何寫入)轉發到另一個節點。另一種流行的拓撲具有 **星形** 形狀:一個指定的根節點將寫入轉發到所有其他節點。星形拓撲可以推廣到樹形。
--------
@ -384,9 +384,9 @@ Poons 先生
{{< figure src="/fig/ddia_0608.png" id="fig_replication_causality" caption="圖 6-8. 使用多主複製,寫入可能以錯誤的順序到達某些副本。" class="w-full my-4" >}}
在 [圖 6-8](/tw/ch6#fig_replication_causality) 中,客戶端 A 在主節點 1 上向表中插入一行,客戶端 B 在主節點 3 上更新該行。然而,主節點 2 可能以不同的順序接收寫入:它可能首先接收更新(從其角度來看,這是對資料庫中不存在的行的更新),然後才接收相應的插入(應該在更新之前)。
在 [圖 6-8](/tw/ch6#fig_replication_causality) 中,客戶端 A 在領導者 1 上向表中插入一行,客戶端 B 在領導者 3 上更新該行。然而,領導者 2 可能以不同的順序接收寫入:它可能首先接收更新(從其角度來看,這是對資料庫中不存在的行的更新),然後才接收相應的插入(應該在更新之前)。
這是一個因果關係問題,類似於我們在 ["一致字首讀"](/tw/ch6#sec_replication_consistent_prefix) 中看到的問題:更新依賴於先前的插入,因此我們需要確保所有節點首先處理插入,然後處理更新。簡單地為每個寫入附加時間戳是不夠的,因為時鐘不能被信任足夠同步以在主節點 2 上正確排序這些事件(見 [第 9 章](/tw/ch9#ch_distributed))。
這是一個因果關係問題,類似於我們在 ["一致字首讀"](/tw/ch6#sec_replication_consistent_prefix) 中看到的問題:更新依賴於先前的插入,因此我們需要確保所有節點首先處理插入,然後處理更新。簡單地為每個寫入附加時間戳是不夠的,因為時鐘不能被信任足夠同步以在領導者 2 上正確排序這些事件(見 [第 9 章](/tw/ch9#ch_distributed))。
為了正確排序這些事件,可以使用一種稱為 **版本向量** 的技術,我們將在本章後面討論(見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent))。然而,許多多主複製系統不使用良好的技術來排序更新,使它們容易受到像 [圖 6-8](/tw/ch6#fig_replication_causality) 中的問題的影響。如果你使用多主複製,值得了解這些問題,仔細閱讀文件,並徹底測試你的資料庫,以確保它真正提供你認為它具有的保證。
@ -396,7 +396,7 @@ Poons 先生
例如,考慮你的手機、筆記型電腦和其他裝置上的日曆應用程式。你需要能夠隨時檢視你的會議(進行讀取請求)並輸入新會議(進行寫入請求),無論你的裝置當前是否有網際網路連線。如果你在離線時進行任何更改,它們需要在裝置下次上線時與伺服器和你的其他裝置同步。
在這種情況下,每個裝置都有一個充當主節點的本地資料庫副本(它接受寫入請求),並且在你所有裝置上的日曆副本之間有一個非同步多主複製過程(同步)。複製延遲可能是幾小時甚至幾天,具體取決於你何時有可用的網際網路訪問
在這種情況下,每個裝置都擁有一個充當領導者的本地資料庫副本(可接受寫入),並在你所有裝置上的日曆副本之間執行非同步多主複製流程(即同步過程)。複製延遲可能是幾小時甚至幾天,具體取決於你何時能連上網際網路
從架構的角度來看,這種設定與地區之間的多主複製非常相似,達到了極端:每個裝置是一個"地區",它們之間的網路連線極其不可靠。
@ -428,37 +428,37 @@ Poons 先生
### 處理寫入衝突 {#sec_replication_write_conflicts}
多主複製的最大問題——無論是在地域分散式伺服器端資料庫中還是在終端使用者裝置上的本地優先同步引擎中——是不同主節點上的併發寫入可能導致需要解決的衝突。
多主複製的最大問題——無論是在地域分散式伺服器端資料庫中還是在終端使用者裝置上的本地優先同步引擎中——是不同領導者上的併發寫入可能導致需要解決的衝突。
例如,考慮一個維基頁面同時被兩個使用者編輯,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 所示。使用者 1 將頁面標題從 A 更改為 B使用者 2 獨立地將標題從 A 更改為 C。每個使用者的更改成功應用於其本地主節點。然而,當更改非同步複製時,檢測到衝突。這個問題在單主資料庫中不會發生。
例如,考慮一個維基頁面同時被兩個使用者編輯,如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 所示。使用者 1 將頁面標題從 A 更改為 B使用者 2 獨立地將標題從 A 更改為 C。每個使用者的更改成功應用於其本地領導者。然而,當更改非同步複製時,檢測到衝突。這個問題在單主資料庫中不會發生。
{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="圖 6-9. 兩個主節點併發更新同一記錄導致的寫入衝突。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0609.png" id="fig_replication_write_conflict" caption="圖 6-9. 兩個領導者併發更新同一記錄導致的寫入衝突。" class="w-full my-4" >}}
> [!NOTE]
> 我們說 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的兩個寫入是 **併發的**,因為在最初進行寫入時,兩者都不"知道"另一個。寫入是否真的在同一時間發生並不重要;實際上,如果寫入是在離線時進行的,它們實際上可能相隔一段時間。重要的是一個寫入是否發生在另一個寫入已經生效的狀態下
> 我們說 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的兩個寫入是 **併發的**,因為在最初進行寫入時,兩者都不“知道”對方。寫入是否真的在同一時刻發生並不重要;實際上,如果寫入發生在離線狀態,它們在物理時間上可能相隔很久。關鍵在於:一個寫入是否發生在另一個寫入已經生效的狀態之上
在 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent) 中,我們將解決資料庫如何確定兩個寫入是否併發的問題。現在我們假設我們可以檢測衝突,並且我們想找出解決它們的最佳方法。
#### 衝突避免 {#conflict-avoidance}
衝突的一種策略是首先避免它們發生。例如,如果應用程式可以確保特定記錄的所有寫入都透過同一主節點,那麼即使整個資料庫是多主的,也不會發生衝突。這種方法在同步引擎客戶端離線更新的情況下是不可能的,但在地域複製的伺服器系統中有時是可能的 [^30]。
衝突的一種策略是首先避免它們發生。例如,如果應用程式可以確保特定記錄的所有寫入都透過同一領導者,那麼即使整個資料庫是多主的,也不會發生衝突。這種方法在同步引擎客戶端離線更新的情況下是不可能的,但在地域複製的伺服器系統中有時是可能的 [^30]。
例如,在一個使用者只能編輯自己資料的應用程式中,你可以確保來自特定使用者的請求始終路由到同一地區,並使用該地區的主節點進行讀寫。不同的使用者可能有不同的"主"地區(可能基於與使用者的地理接近程度選擇),但從任何一個使用者的角度來看,配置本質上是單主的。
例如,在一個使用者只能編輯自己資料的應用程式中,你可以確保來自特定使用者的請求始終路由到同一地區,並使用該地區的領導者進行讀寫。不同的使用者可能有不同的"主"地區(可能基於與使用者的地理接近程度選擇),但從任何一個使用者的角度來看,配置本質上是單主的。
然而,有時你可能想要更改記錄的指定主節點——也許是因為一個地區不可用,你需要將流量重新路由到另一個地區,或者也許是因為使用者已經移動到不同的位置,現在更接近不同的地區。現在存在風險,即使用者在指定主節點更改正在進行時執行寫入,導致必須使用下面的方法之一解決的衝突。因此,如果你允許更改主節點,衝突避免就會失效。
然而,有時你可能想要更改記錄的指定領導者——也許是因為一個地區不可用,你需要將流量重新路由到另一個地區,或者也許是因為使用者已經移動到不同的位置,現在更接近不同的地區。現在存在風險,即使用者在指定領導者更改正在進行時執行寫入,導致必須使用下面的方法之一解決的衝突。因此,如果你允許更改領導者,衝突避免就會失效。
衝突避免的另一個例子:想象你想要插入新記錄並基於自增計數器為它們生成唯一 ID。如果你有兩個主節點,你可以設定它們,使得一個主節點只生成奇數,另一個只生成偶數。這樣你可以確保兩個主節點不會同時為不同的記錄分配相同的 ID。我們將在 ["ID 生成器和邏輯時鐘"](/tw/ch10#sec_consistency_logical) 中討論其他 ID 分配方案。
衝突避免的另一個例子:想象你想要插入新記錄並基於自增計數器為它們生成唯一 ID。如果你有兩個領導者,你可以設定它們,使得一個領導者只生成奇數,另一個只生成偶數。這樣你可以確保兩個領導者不會同時為不同的記錄分配相同的 ID。我們將在 ["ID 生成器和邏輯時鐘"](/tw/ch10#sec_consistency_logical) 中討論其他 ID 分配方案。
#### 最後寫入勝(丟棄併發寫入) {#sec_replication_lww}
#### 最後寫入勝(丟棄併發寫入) {#sec_replication_lww}
如果無法避免衝突,解決它們的最簡單方法是為每個寫入附加時間戳,並始終使用具有最大時間戳的值。例如,在 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,假設使用者 1 的寫入時間戳大於使用者 2 的寫入時間戳。在這種情況下,兩個主節點都將確定頁面的新標題應該是 B並丟棄將其設定為 C 的寫入。如果寫入巧合地具有相同的時間戳,可以透過比較值來選擇獲勝者(例如,在字串的情況下,取字母表中較早的那個)。
如果無法避免衝突,解決它們的最簡單方法是為每個寫入附加時間戳,並始終使用具有最大時間戳的值。例如,在 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中,假設使用者 1 的寫入時間戳大於使用者 2 的寫入時間戳。在這種情況下,兩個領導者都將確定頁面的新標題應該是 B並丟棄將其設定為 C 的寫入。如果寫入巧合地具有相同的時間戳,可以透過比較值來選擇獲勝者(例如,在字串的情況下,取字母表中較早的那個)。
這種方法稱為 **最後寫入勝**LWW因為具有最大時間戳的寫入可以被認為是"最後"的。然而,這個術語是誤導性的,因為當兩個寫入像 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中那樣併發時,哪個更舊,哪個更新是未定義的,因此併發寫入的時間戳順序本質上是隨機的。
這種方法稱為 **最後寫入**LWW因為具有最大時間戳的寫入可以被認為是"最後"的。然而,這個術語是誤導性的,因為當兩個寫入像 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中那樣併發時,哪個更舊,哪個更新是未定義的,因此併發寫入的時間戳順序本質上是隨機的。
因此LWW 的真正含義是:當同一記錄在不同的主節點上併發寫入時,其中一個寫入被隨機選擇為獲勝者,其他寫入被靜默丟棄,即使它們在各自的主節點上成功處理。這實現了最終所有副本都處於一致狀態的目標,但代價是資料丟失。
因此LWW 的真正含義是:當同一記錄在不同的領導者上併發寫入時,其中一個寫入被隨機選擇為獲勝者,其他寫入被靜默丟棄,即使它們在各自的領導者上成功處理。這實現了最終所有副本都處於一致狀態的目標,但代價是資料丟失。
如果你可以避免衝突——例如,透過只插入具有唯一鍵(如 UUID的記錄而從不更新它們——那麼 LWW 沒有問題。但是,如果你更新現有記錄,或者如果不同的主節點可能插入具有相同鍵的記錄,那麼你必須決定丟失的更新對你的應用程式是否是個問題。如果丟失的更新是不可接受的,你需要使用下面描述的衝突解決方法之一。
如果你可以避免衝突——例如,透過只插入具有唯一鍵(如 UUID的記錄而從不更新它們——那麼 LWW 沒有問題。但是,如果你更新現有記錄,或者如果不同的領導者可能插入具有相同鍵的記錄,那麼你必須決定丟失的更新對你的應用程式是否是個問題。如果丟失的更新是不可接受的,你需要使用下面描述的衝突解決方法之一。
LWW 的另一個問題是,如果使用即時時鐘(例如 Unix 時間戳)作為寫入的時間戳,系統對時鐘同步變得非常敏感。如果一個節點的時鐘領先於其他節點,並且你嘗試覆蓋該節點寫入的值,你的寫入可能會被忽略,因為它可能具有較低的時間戳,即使它明顯發生得更晚。這個問題可以透過使用 **邏輯時鐘** 來解決,我們將在 ["ID 生成器和邏輯時鐘"](/tw/ch10#sec_consistency_logical) 中討論。
@ -466,13 +466,13 @@ LWW 的另一個問題是,如果使用即時時鐘(例如 Unix 時間戳)
如果隨機丟棄你的一些寫入是不可取的,下一個選擇是手動解決衝突。你可能熟悉 Git 和其他版本控制系統中的手動衝突解決:如果兩個不同分支上的提交編輯同一檔案的相同行,並且你嘗試合併這些分支,你將得到一個需要在合併完成之前解決的合併衝突。
在資料庫中,衝突停止整個複製過程直到人類解決它是不切實際的。相反,資料庫通常儲存給定記錄的所有併發寫入值——例如,[圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的 B 和 C。這些值有時稱為 **兄弟節點**。下次查詢該記錄時,資料庫返回 **所有** 這些值,而不僅僅是最新的值。然後,你可以以任何你想要的方式解決這些值,無論是在應用程式程式碼中自動(例如,你可以將 B 和 C 連線成"B/C"),還是透過詢問使用者。然後,你將新值寫回資料庫以解決衝突。
在資料庫裡,讓衝突阻塞整個複製流程、直到人工處理,通常並不現實。更常見的是,資料庫會保留某條記錄的所有併發寫入值——例如 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 中的 B 和 C。這些值有時稱為 **兄弟**。下次查詢該記錄時,資料庫會返回 **所有** 這些值,而不只是最新值。隨後你可以按需要解決這些值:要麼在應用程式碼裡自動處理(例如把 B 和 C 合併成 "B/C"),要麼讓使用者參與處理;最後再把新值寫回資料庫以消解衝突。
這種衝突解決方法在某些系統中使用,例如 CouchDB。然而它也存在許多問題
* 資料庫的 API 發生變化:例如,以前維基頁面的標題只是一個字串,現在它變成了一組字串,通常包含一個元素,但如果有衝突,有時可能包含多個元素。這可能使應用程式程式碼中的資料難以處理。
* 要求使用者手動合併兄弟節點是很多工作,無論是對應用程式開發人員(需要構建衝突解決的使用者介面)還是對使用者(可能對他們被要求做什麼以及為什麼感到困惑)。在許多情況下,自動合併比打擾使用者更好
* 如果不仔細進行,自動合併兄弟節點可能會導致令人驚訝的行為。例如,亞馬遜的購物車曾經允許併發更新,然後透過保留出現在任何兄弟節點中的所有購物車專案(即,取購物車的集合並集)來合併。這意味著如果客戶在一個兄弟節點中從購物車中刪除了一個專案,但另一個兄弟節點仍然包含該舊專案,刪除的專案會意外地重新出現在客戶的購物車中 [^45]。[圖 6-10](/tw/ch6#fig_replication_amazon_anomaly) 顯示了一個示例,其中裝置 1 從購物車中刪除 Book併發地裝置 2 刪除 DVD但合併衝突後兩個專案都重新出現
* 要求使用者手動合併兄弟,會帶來很大負擔:開發者需要構建衝突解決介面,使用者也可能不明白自己為何要做這件事。在很多場景下,自動合併比打擾使用者更合適
* 如果不夠謹慎,自動合併兄弟也可能產生反直覺行為。例如,亞馬遜購物車曾允許併發更新,並用“並集”策略合併(保留出現在任一兄弟中的所有商品)。這意味著:若使用者在一個兄弟裡刪除了某商品,但另一個兄弟仍保留它,該商品會“復活”回購物車 [^45]。[圖 6-10](/tw/ch6#fig_replication_amazon_anomaly) 就是一個例子:裝置 1 刪除 Book裝置 2 併發刪除 DVD衝突合併後兩個商品都回來了
* 如果多個節點觀察到衝突並併發解決它,衝突解決過程本身可能會引入新的衝突。這些解決方案甚至可能不一致:例如,如果你不小心一致地排序它們,一個節點可能將 B 和 C 合併為"B/C",另一個可能將它們合併為"C/B"。當"B/C"和"C/B"之間的衝突被合併時,它可能導致"B/C/C/B"或類似令人驚訝的東西。
{{< figure src="/fig/ddia_0610.png" id="fig_replication_amazon_anomaly" caption="圖 6-10. 亞馬遜購物車異常的示例:如果購物車上的衝突透過取並集合並,刪除的專案可能會重新出現。" class="w-full my-4" >}}
@ -484,9 +484,9 @@ LWW 的另一個問題是,如果使用即時時鐘(例如 Unix 時間戳)
LWW 是衝突解決演算法的一個簡單示例。已經為不同型別的資料開發了更複雜的合併演算法,目標是儘可能保留所有更新的預期效果,從而避免資料丟失:
* 如果資料是文字(例如,維基頁面的標題或正文),我們可以檢測從一個版本到下一個版本插入或刪除了哪些字元。合併的結果然後保留在任何兄弟節點中進行的所有插入和刪除。如果使用者併發地在同一位置插入文字,可以確定性地排序,以便所有節點獲得相同的合併結果。
* 如果資料是文字(例如維基頁面標題或正文),我們可以檢測每次版本演進中的字元插入和刪除。合併結果會保留任一兄弟中的所有插入和刪除。如果多個使用者併發在同一位置插入文字,還可以用確定性順序來排序,以確保所有節點得到同樣的合併結果。
* 如果資料是專案集合(像待辦事項列表那樣有序,或像購物車那樣無序),我們可以透過跟蹤插入和刪除類似於文字來合併它。為了避免 [圖 6-10](/tw/ch6#fig_replication_amazon_anomaly) 中的購物車問題,演算法跟蹤 Book 和 DVD 被刪除的事實,因此合併的結果是 Cart = {Soap}。
* 如果資料是表示可以遞增或遞減的計數器的整數(例如,社交媒體帖子上的點贊數),合併演算法可以告訴每個兄弟節點上發生了多少次遞增和遞減,並正確地將它們相加,以便結果不會重複計數也不會丟棄更新。
* 如果資料是可增可減的整數計數器(例如社交媒體帖子的點贊數),合併演算法可以統計每個兄弟上的遞增和遞減次數,並正確求和,既不重複計數,也不丟更新。
* 如果資料是鍵值對映,我們可以透過將其他衝突解決演算法之一應用於該鍵下的值來合併對同一鍵的更新。對不同鍵的更新可以相互獨立處理。
衝突解決的可能性是有限的。例如,如果你想強制一個列表不包含超過五個專案,並且多個使用者併發地向列表新增專案,使得總共有五個以上,你唯一的選擇是丟棄一些專案。儘管如此,自動衝突解決足以構建許多有用的應用程式。如果你從想要構建協作離線優先或本地優先應用程式的要求開始,那麼衝突解決是不可避免的,自動化它通常是最好的方法。
@ -515,16 +515,16 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
某些型別的衝突是顯而易見的。在 [圖 6-9](/tw/ch6#fig_replication_write_conflict) 的示例中,兩個寫入併發修改了同一記錄中的同一欄位,將其設定為兩個不同的值。毫無疑問,這是一個衝突。
其他型別的衝突可能更難以檢測。例如,考慮一個會議室預訂系統:它跟蹤哪個房間由哪組人在什麼時間預訂。此應用程式需要確保每個房間在任何時間只由一組人預訂(即,同一房間不得有任何重疊的預訂)。在這種情況下,如果為同一房間同時建立兩個不同的預訂,可能會出現衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩個預訂是在兩個不同的主節點上進行的,也可能會發生衝突。
其他型別的衝突可能更難以檢測。例如,考慮一個會議室預訂系統:它跟蹤哪個房間由哪組人在什麼時間預訂。此應用程式需要確保每個房間在任何時間只由一組人預訂(即,同一房間不得有任何重疊的預訂)。在這種情況下,如果為同一房間同時建立兩個不同的預訂,可能會出現衝突。即使應用程式在允許使用者進行預訂之前檢查可用性,如果兩個預訂是在兩個不同的領導者上進行的,也可能會發生衝突。
沒有快速現成的答案,但在以下章節中,我們將追蹤通向對這個問題的良好理解的路徑。我們將在 [第 8 章](/tw/ch8#ch_transactions) 中看到更多衝突的例子,並在 [Link to Come] 中討論在複製系統中檢測和解決衝突的可伸縮方法。
沒有現成的快速答案,不過在後續章節中,我們會逐步建立對這個問題的理解。我們將在 [第 8 章](/tw/ch8#ch_transactions) 看到更多衝突案例,並在 ["透過事件順序捕獲因果關係"](/tw/ch13#sec_future_capture_causality) 中討論在複製系統裡可伸縮地檢測和解決衝突的方法。
## 無主複製 {#sec_replication_leaderless}
到目前為止,我們在本章中討論的複製方法——單主和多主複製——都基於這樣的想法:客戶端向一個節點(主節點)傳送寫入請求,資料庫系統負責將該寫入複製到其他副本。主節點確定寫入應該處理的順序,從節點以相同的順序應用主節點的寫入。
到目前為止,我們在本章中討論的複製方法——單主和多主複製——都基於這樣的想法:客戶端向一個節點(領導者)傳送寫入請求,資料庫系統負責將該寫入複製到其他副本。領導者確定寫入應該處理的順序,追隨者以相同的順序應用領導者的寫入。
一些資料儲存系統採用不同的方法,放棄主節點的概念,並允許任何副本直接接受來自客戶端的寫入。一些最早的複製資料系統是無主的 [^1] [^50],但在關係資料庫主導的時代,這個想法基本上被遺忘了。在亞馬遜於 2007 年將其用於其內部 **Dynamo** 系統後,它再次成為資料庫的時尚架構 [^45]。Riak、Cassandra 和 ScyllaDB 是受 Dynamo 啟發的具有無主複製模型的開源資料儲存,因此這種資料庫也被稱為 **Dynamo 風格**
一些資料儲存系統採用不同的方法,放棄領導者的概念,並允許任何副本直接接受來自客戶端的寫入。一些最早的複製資料系統是無主的 [^1] [^50],但在關係資料庫主導的時代,這個想法基本上被遺忘了。在亞馬遜於 2007 年將其用於其內部 **Dynamo** 系統後,它再次成為資料庫的時尚架構 [^45]。Riak、Cassandra 和 ScyllaDB 是受 Dynamo 啟發的具有無主複製模型的開源資料儲存,因此這種資料庫也被稱為 **Dynamo 風格**
--------
@ -533,7 +533,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
--------
在某些無主實現中,客戶端直接將其寫入傳送到多個副本,而在其他實現中,協調器節點代表客戶端執行此操作。然而,與主節點資料庫不同,該協調器不強制執行特定的寫入順序。正如我們將看到的,這種設計差異對資料庫的使用方式產生了深遠的影響。
在某些無主實現中,客戶端直接將其寫入傳送到多個副本,而在其他實現中,協調器節點代表客戶端執行此操作。然而,與領導者資料庫不同,該協調器不強制執行特定的寫入順序。正如我們將看到的,這種設計差異對資料庫的使用方式產生了深遠的影響。
### 當節點故障時寫入資料庫 {#id287}
@ -548,20 +548,20 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
為了解決這個問題,當客戶端從資料庫讀取時,它不只是將其請求傳送到一個副本:**讀取請求也並行傳送到多個節點**。客戶端可能會從不同的節點獲得不同的響應;例如,從一個節點獲得最新值,從另一個節點獲得陳舊值。
為了區分哪些響應是最新的,哪些是過時的,寫入的每個值都需要用版本號或時間戳標記,類似於我們在 ["最後寫入勝(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的。當客戶端收到對讀取的多個值響應時,它使用具有最大時間戳的值(即使該值僅由一個副本返回,而其他幾個副本返回較舊的值)。有關更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。
為了區分哪些響應是最新的,哪些是過時的,寫入的每個值都需要用版本號或時間戳標記,類似於我們在 ["最後寫入(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的。當客戶端收到對讀取的多個值響應時,它使用具有最大時間戳的值(即使該值僅由一個副本返回,而其他幾個副本返回較舊的值)。有關更多詳細資訊,請參見 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent)。
#### 追趕錯過的寫入 {#sec_replication_read_repair}
複製系統應確保最終所有資料都複製到每個副本。在不可用節點恢復上線後,它如何趕上它錯過的寫入?在 Dynamo 風格的資料儲存中使用了幾種機制:
讀修復
: 當客戶端並行從多個節點進行讀取時,它可以檢測任何陳舊響應。例如,在 [圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 中,使用者 2345 從副本 3 獲得版本 6 值,從副本 1 和 2 獲得版本 7 值。客戶端看到副本 3 有陳舊值,並將較新的值寫回該副本。這種方法適用於經常讀取的值。
: 當客戶端並行從多個節點讀取時,它可以檢測任何陳舊響應。例如,在 [圖 6-12](/tw/ch6#fig_replication_quorum_node_outage) 中,使用者 2345 從副本 3 獲得版本 6 的值,從副本 1 和 2 獲得版本 7 的值。客戶端發現副本 3 陳舊後,會把較新的值寫回該副本。這種方法適用於經常讀取的值。
提示移交
: 如果一個副本不可用,另一個副本可能會以 **提示** 的形式代表其儲存寫入。當應該接收這些寫入的副本恢復時,儲存提示的副本將它們傳送到恢復的副本,然後刪除提示。這個 **移交** 過程有助於使副本保持最新,即使對於從未讀取的值也是如此,因此不由讀修復處理。
反熵
: 此外,還有一個後臺程序定期查詢副本之間資料的差異,並將任何缺失的資料從一個副本複製到另一個。與基於主節點的複製中的複製日誌不同,這個 **反熵程序** 不以任何特定順序複製寫入,並且在複製資料之前可能會有顯著的延遲。
: 此外,還有一個後臺程序定期查詢副本之間資料的差異,並將任何缺失的資料從一個副本複製到另一個。與基於領導者的複製中的複製日誌不同,這個 **反熵程序** 不以任何特定順序複製寫入,並且在複製資料之前可能會有顯著的延遲。
#### 讀寫仲裁 {#sec_replication_quorum_condition}
@ -610,7 +610,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
* 在重新平衡正在進行時,其中一些資料從一個節點移動到另一個節點(見 [第 7 章](/tw/ch7#ch_sharding)),節點可能對哪些節點應該持有特定值的 *n* 個副本有不一致的檢視。這可能導致讀取和寫入仲裁不再重疊。
* 如果讀取與寫入操作併發,讀取可能會或可能不會看到併發寫入的值。特別是,一次讀取可能看到新值,而後續讀取看到舊值,正如我們將在 ["線性一致性與仲裁"](/tw/ch10#sec_consistency_quorum_linearizable) 中看到的。
* 如果寫入在某些副本上成功但在其他副本上失敗(例如,因為某些節點上的磁碟已滿),並且總體上在少於 *w* 個副本上成功,它不會在成功的副本上回滾。這意味著如果寫入被報告為失敗,後續讀取可能會或可能不會返回該寫入的值 [^52]。
* 如果資料庫使用即時時鐘的時間戳來確定哪個寫入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一個具有更快時鐘的節點已寫入同一鍵,寫入可能會被靜默丟棄——我們之前在 ["最後寫入勝(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的問題。我們將在 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying) 中更詳細地討論這一點。
* 如果資料庫使用即時時鐘的時間戳來確定哪個寫入更新(如 Cassandra 和 ScyllaDB 所做的),如果另一個具有更快時鐘的節點已寫入同一鍵,寫入可能會被靜默丟棄——我們之前在 ["最後寫入(丟棄併發寫入)"](/tw/ch6#sec_replication_lww) 中看到的問題。我們將在 ["依賴同步時鐘"](/tw/ch9#sec_distributed_clocks_relying) 中更詳細地討論這一點。
* 如果兩個寫入併發發生,其中一個可能首先在一個副本上處理,另一個可能首先在另一個副本上處理。這導致衝突,類似於我們在多主複製中看到的(見 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts))。我們將在 ["檢測併發寫入"](/tw/ch6#sec_replication_concurrent) 中回到這個主題。
因此儘管仲裁似乎保證讀取返回最新寫入的值但實際上並不那麼簡單。Dynamo 風格的資料庫通常針對可以容忍最終一致性的用例進行了最佳化。引數 *w**r* 允許你調整讀取陳舊值的機率 [^53],但明智的做法是不要將它們視為絕對保證。
@ -619,24 +619,24 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
從操作角度來看,監控你的資料庫是否返回最新結果很重要。即使你的應用程式可以容忍陳舊讀取,你也需要了解複製的健康狀況。如果它明顯落後,它應該提醒你,以便你可以調查原因(例如,網路中的問題或過載的節點)。
對於基於主節點的複製,資料庫通常公開復制延遲的指標,你可以將其輸入到監控系統。這是可能的,因為寫入以相同的順序應用於主節點和從節點,每個節點在複製日誌中都有一個位置(它在本地應用的寫入數)。透過從主節點的當前位置減去從節點的當前位置,你可以測量複製延遲的量。
對於基於領導者的複製,資料庫通常公開復制延遲的指標,你可以將其輸入到監控系統。這是可能的,因為寫入以相同的順序應用於領導者和追隨者,每個節點在複製日誌中都有一個位置(它在本地應用的寫入數)。透過從領導者的當前位置減去追隨者的當前位置,你可以測量複製延遲的量。
然而,在具有無主複製的系統中,沒有固定的寫入應用順序,這使得監控更加困難。副本為移交儲存的提示數量可以是系統健康的一個度量,但很難有用地解釋 [^54]。最終一致性是一個故意模糊的保證,但為了可操作性,能夠量化"最終"很重要。
### 單主與無主複製的效能 {#sec_replication_leaderless_perf}
基於單個主節點的複製系統可以提供在無主系統中難以或不可能實現的強一致性保證。然而,正如我們在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中看到的,如果你在非同步更新的從節點上進行讀取,基於主節點的複製系統中的讀取也可能返回陳舊值。
基於單個領導者的複製系統可以提供在無主系統中難以或不可能實現的強一致性保證。然而,正如我們在 ["複製延遲的問題"](/tw/ch6#sec_replication_lag) 中看到的,如果你在非同步更新的追隨者上進行讀取,基於領導者的複製系統中的讀取也可能返回陳舊值。
主節點讀取確保最新響應,但它存在效能問題:
領導者讀取確保最新響應,但它存在效能問題:
* 讀取吞吐量受主節點處理請求能力的限制(與讀擴充套件相反,讀擴充套件將讀取分佈在可能返回陳舊值的非同步更新副本上)。
* 如果主節點失敗,你必須等待檢測到故障,並在繼續處理請求之前完成故障轉移。即使故障轉移過程非常快,使用者也會因為臨時增加的響應時間而注意到它;如果故障轉移需要很長時間,系統在其持續時間內不可用。
* 系統對主節點上的效能問題非常敏感:如果主節點響應緩慢,例如由於過載或某些資源爭用,增加的響應時間也會立即影響使用者。
* 讀取吞吐量受領導者處理請求能力的限制(與讀擴充套件相反,讀擴充套件將讀取分佈在可能返回陳舊值的非同步更新副本上)。
* 如果領導者失敗,你必須等待檢測到故障,並在繼續處理請求之前完成故障轉移。即使故障轉移過程非常快,使用者也會因為臨時增加的響應時間而注意到它;如果故障轉移需要很長時間,系統在其持續時間內不可用。
* 系統對領導者上的效能問題非常敏感:如果領導者響應緩慢,例如由於過載或某些資源爭用,增加的響應時間也會立即影響使用者。
無主架構的一大優勢是它對此類問題更有彈性。因為沒有故障轉移,並且請求無論如何都並行傳送到多個副本,一個副本變慢或不可用對響應時間的影響很小:客戶端只是使用響應更快的其他副本的響應。使用最快的響應稱為 **請求對沖**,它可以顯著減少尾部延遲 [^55]
無主架構的一大優勢是它對此類問題更有彈性。因為沒有故障轉移,而且請求本來就是並行發往多個副本,所以某個副本變慢或不可用對響應時間影響較小:客戶端只需採用更快副本的響應即可。利用最快響應的做法稱為 **請求對沖**,它可以顯著降低尾部延遲 [^55]
從根本上說,無主系統的彈性來自於它不區分正常情況和故障情況的事實。這在處理所謂的 **灰色故障** 時特別有用,其中節點沒有完全宕機,但以降級狀態執行,處理請求異常緩慢 [^56],或者當節點只是過載時(例如,如果節點已離線一段時間,透過提示移交恢復可能會導致大量額外負載)。基於主節點的系統必須決定情況是否足夠糟糕以保證故障轉移(這本身可能會導致進一步的中斷),而在無主系統中,這個問題甚至不會出現。
從根本上說,無主系統的彈性來自於它不區分正常情況和故障情況的事實。這在處理所謂的 **灰色故障** 時特別有用,其中節點沒有完全宕機,但以降級狀態執行,處理請求異常緩慢 [^56],或者當節點只是過載時(例如,如果節點已離線一段時間,透過提示移交恢復可能會導致大量額外負載)。基於領導者的系統必須決定情況是否足夠糟糕以保證故障轉移(這本身可能會導致進一步的中斷),而在無主系統中,這個問題甚至不會出現。
也就是說,無主系統也可能有效能問題:
@ -644,7 +644,7 @@ OT 最常用於文字的即時協作編輯,例如在 Google Docs 中 [^32]
* 你擁有的副本越多,你的仲裁就越大,在請求完成之前你必須等待的響應就越多。即使你只等待最快的 *r**w* 個副本響應,即使你並行發出請求,更大的 *r**w* 增加了你遇到慢副本的機會,增加了總體響應時間(見 ["響應時間指標的應用"](/tw/ch2#sec_introduction_slo_sla))。
* 大規模網路中斷使客戶端與大量副本斷開連線可能使形成仲裁變得不可能。一些無主資料庫提供了一個配置選項允許任何可訪問的副本接受寫入即使它不是該鍵的通常副本之一Riak 和 Dynamo 稱之為 **寬鬆仲裁** [^45]Cassandra 和 ScyllaDB 稱之為 **一致性級別 ANY**)。不能保證後續讀取會看到寫入的值,但根據應用程式,它可能仍然比寫入失敗更好。
多主複製可以提供比無主複製更大的網路中斷彈性,因為讀取和寫入只需要與一個主節點通訊,該主節點可以與客戶端位於同一位置。然而,由於一個主節點上的寫入非同步傳播到其他主節點,讀取可能任意過時。仲裁讀取和寫入提供了一種折衷:良好的容錯性,同時也有很高的可能性讀取最新資料。
多主複製可以提供比無主複製更大的網路中斷彈性,因為讀取和寫入只需要與一個領導者通訊,該領導者可以與客戶端位於同一位置。然而,由於一個領導者上的寫入非同步傳播到其他領導者,讀取可能任意過時。仲裁讀取和寫入提供了一種折衷:良好的容錯性,同時也有很高的可能性讀取最新資料。
#### 多地區操作 {#multi-region-operation}
@ -669,9 +669,9 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
如果每個節點在接收到來自客戶端的寫入請求時只是覆蓋鍵的值,節點將變得永久不一致,如 [圖 6-14](/tw/ch6#fig_replication_concurrency) 中的最終 *get* 請求所示:節點 2 認為 *X* 的最終值是 B而其他節點認為值是 A。
為了最終保持一致,副本應該收斂到相同的值。為此,我們可以使用我們之前在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中討論的任何衝突解決機制,例如最後寫入勝(由 Cassandra 和 ScyllaDB 使用)、手動解決或 CRDT在 ["CRDT 與操作變換"](/tw/ch6#sec_replication_crdts) 中描述,並由 Riak 使用)。
為了最終保持一致,副本應該收斂到相同的值。為此,我們可以使用我們之前在 ["處理寫入衝突"](/tw/ch6#sec_replication_write_conflicts) 中討論的任何衝突解決機制,例如最後寫入勝(由 Cassandra 和 ScyllaDB 使用)、手動解決或 CRDT在 ["CRDT 與操作變換"](/tw/ch6#sec_replication_crdts) 中描述,並由 Riak 使用)。
最後寫入勝很容易實現:每個寫入都標有時間戳,具有更高時間戳的值總是覆蓋具有較低時間戳的值。然而,時間戳不會告訴你兩個值是否實際上衝突(即,它們是併發寫入的)或不衝突(它們是一個接一個寫入的)。如果你想顯式解決衝突,系統需要更加小心地檢測併發寫入。
最後寫入勝很容易實現:每個寫入都標有時間戳,具有更高時間戳的值總是覆蓋具有較低時間戳的值。然而,時間戳不會告訴你兩個值是否實際上衝突(即,它們是併發寫入的)或不衝突(它們是一個接一個寫入的)。如果你想顯式解決衝突,系統需要更加小心地檢測併發寫入。
#### "先發生"關係與併發 {#sec_replication_happens_before}
@ -686,7 +686,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
--------
> ![TIP] 併發、時間和相對論
> [!TIP] 併發、時間和相對論
>
> 似乎兩個操作如果"同時"發生,應該稱為併發——但實際上,它們是否真的在時間上重疊並不重要。由於分散式系統中的時鐘問題,實際上很難判斷兩件事是否恰好在同一時間發生——我們將在 [第 9 章](/tw/ch9#ch_distributed) 中更詳細地討論這個問題。
>
@ -700,10 +700,10 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
讓我們看一個確定兩個操作是否併發或一個先發生於另一個的演算法。為了簡單起見,讓我們從只有一個副本的資料庫開始。一旦我們弄清楚如何在單個副本上執行此操作,我們就可以將該方法推廣到具有多個副本的無主資料庫。
[圖 6-15](/tw/ch6#fig_replication_causality_single) 顯示了兩個客戶端併發地向同一購物車新增專案。(如果這個例子讓你覺得太無聊,想象一下兩個空中交通管制員併發地向他們正在跟蹤的扇區新增飛機。)最初,購物車是空的。客戶端之間向資料庫進行了五次寫入:
[圖 6-15](/tw/ch6#fig_replication_causality_single) 顯示了兩個客戶端併發地向同一購物車新增專案。(如果這個例子讓你覺得太無聊,想象一下兩個空中交通管制員併發地向他們正在跟蹤的扇區新增飛機。)最初,購物車是空的。兩個客戶端總共向資料庫發起了五次寫入:
1. 客戶端 1 將 `milk` 新增到購物車。這是對該鍵的第一次寫入,因此伺服器成功儲存它併為其分配版本 1。伺服器還將值連同版本號一起回顯給客戶端。
2. 客戶端 2 將 `eggs` 新增到購物車,不知道客戶端 1 併發地添加了 `milk`(客戶端 2 認為它的 `eggs` 是購物車中的唯一專案)。伺服器為此寫入分配版本 2並將 `eggs``milk` 儲存為兩個單獨的值(兄弟節點)。然後,它將 **兩個** 值連同版本號 2 一起返回給客戶端。
2. 客戶端 2 將 `eggs` 新增到購物車,不知道客戶端 1 併發地添加了 `milk`(客戶端 2 認為它的 `eggs` 是購物車中的唯一專案)。伺服器為此寫入分配版本 2並將 `eggs``milk` 儲存為兩個單獨的值(兄弟)。然後,它將 **兩個** 值連同版本號 2 一起返回給客戶端。
3. 客戶端 1不知道客戶端 2 的寫入,想要將 `flour` 新增到購物車,因此它認為當前購物車內容應該是 `[milk, flour]`。它將此值連同伺服器之前給客戶端 1 的版本號 1 一起傳送到伺服器。伺服器可以從版本號判斷 `[milk, flour]` 的寫入取代了 `[milk]` 的先前值,但它與 `[eggs]` 併發。因此,伺服器將版本 3 分配給 `[milk, flour]`,覆蓋版本 1 值 `[milk]`,但保留版本 2 值 `[eggs]` 並將兩個剩餘值返回給客戶端。
4. 同時,客戶端 2 想要將 `ham` 新增到購物車,不知道客戶端 1 剛剛添加了 `flour`。客戶端 2 在上次響應中從伺服器接收了兩個值 `[milk]``[eggs]`,因此客戶端現在合併這些值並新增 `ham` 以形成新值 `[eggs, milk, ham]`。它將該值連同先前的版本號 2 一起傳送到伺服器。伺服器檢測到版本 2 覆蓋 `[eggs]` 但與 `[milk, flour]` 併發,因此兩個剩餘值是版本 3 的 `[milk, flour]` 和版本 4 的 `[eggs, milk, ham]`
5. 最後,客戶端 1 想要新增 `bacon`。它之前從伺服器接收了版本 3 的 `[milk, flour]``[eggs]`,因此它合併這些,新增 `bacon`,並將最終值 `[milk, flour, eggs, bacon]` 連同版本號 3 一起傳送到伺服器。這覆蓋了 `[milk, flour]`(注意 `[eggs]` 已經在上一步中被覆蓋)但與 `[eggs, milk, ham]` 併發,因此伺服器保留這兩個併發值。
@ -719,23 +719,23 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
請注意,伺服器可以透過檢視版本號來確定兩個操作是否併發——它不需要解釋值本身(因此值可以是任何資料結構)。演算法的工作原理如下:
* 伺服器為每個鍵維護一個版本號,每次寫入該鍵時遞增版本號,並將新版本號與寫入的值一起儲存。
* 當客戶端讀取鍵時,伺服器返回所有兄弟節點,即所有未被覆蓋的值,以及最新的版本號。客戶端必須在寫入之前讀取鍵。
* 當客戶端寫入鍵時,它必須包含來自先前讀取的版本號,並且必須合併它在先前讀取中收到的所有值,例如使用 CRDT 或透過詢問使用者。寫入請求的響應就像讀取一樣,返回所有兄弟節點,這允許我們像購物車示例中那樣連結多個寫入。
* 當客戶端讀取鍵時,伺服器返回所有兄弟,即所有未被覆蓋的值,以及最新的版本號。客戶端必須在寫入之前讀取鍵。
* 當客戶端寫入鍵時,它必須包含來自先前讀取的版本號,並且必須合併它在先前讀取中收到的所有值,例如使用 CRDT 或透過詢問使用者。寫入請求的響應就像讀取一樣,返回所有兄弟,這允許我們像購物車示例中那樣連結多個寫入。
* 當伺服器接收到具有特定版本號的寫入時,它可以覆蓋具有該版本號或更低版本號的所有值(因為它知道它們已合併到新值中),但它必須保留具有更高版本號的所有值(因為這些值與傳入寫入併發)。
當寫入包含來自先前讀取的版本號時,這告訴我們寫入基於哪個先前狀態。如果你在不包含版本號的情況下進行寫入,它與所有其他寫入併發,因此它不會覆蓋任何內容——它只會作為後續讀取的值之一返回。
#### 版本向量 {#version-vectors}
[圖 6-15](/tw/ch6#fig_replication_causality_single) 中的示例僅使用了單個副本。當有多個副本但沒有主節點時,演算法如何變化?
[圖 6-15](/tw/ch6#fig_replication_causality_single) 中的示例只使用了單個副本。當存在多個副本、且沒有領導者時,演算法如何變化?
[圖 6-15](/tw/ch6#fig_replication_causality_single) 使用單個版本號來捕獲操作之間的依賴關係,但當有多個副本併發接受寫入時,這是不夠的。相反,我們需要使用 **每個副本** 以及每個鍵的版本號。每個副本在處理寫入時遞增其自己的版本號,並且還跟蹤它從其他每個副本看到的版本號。此資訊指示要覆蓋哪些值以及保留哪些值作為兄弟節點
[圖 6-15](/tw/ch6#fig_replication_causality_single) 使用單個版本號來捕獲操作間依賴關係,但當多個副本併發接受寫入時,這還不夠。我們需要為 **每個副本**、每個鍵分別維護版本號。每個副本在處理寫入時遞增自己的版本號,並追蹤從其他副本看到的版本號。這些資訊決定了哪些值該被覆蓋,哪些值要作為兄弟保留
來自所有副本的版本號集合稱為 **版本向量** [^58]。正在使用此想法的幾個變體,但最有趣的可能是 **點化版本向量** [^59] [^60],它在 Riak 2.0 中使用 [^61] [^62]。我們不會詳細介紹,但它的工作方式與我們在購物車示例中看到的非常相似。
來自所有副本的版本號集合稱為 **版本向量** [^58]。這一思想有若干變體,其中較有代表性的是 **點版本向量** [^59] [^60]Riak 2.0 使用了它 [^61] [^62]。這裡不展開細節,它的工作方式與前面的購物車示例非常相似。
像 [圖 6-15](/tw/ch6#fig_replication_causality_single) 中的版本號一樣版本向量在讀取值時從資料庫副本傳送到客戶端並且在隨後寫入值時需要傳送回資料庫。Riak 將版本向量編碼為它稱為 **因果上下文** 的字串。)版本向量允許資料庫區分覆蓋和併發寫入
和 [圖 6-15](/tw/ch6#fig_replication_causality_single) 裡的版本號一樣版本向量會在讀取時由資料庫副本返回給客戶端並在後續寫入時再由客戶端帶回資料庫。Riak 把版本向量編碼成一個字串,稱為 **因果上下文**。)版本向量讓資料庫能夠區分“覆蓋寫入”和“併發寫入”
版本向量還確保從一個副本讀取然後寫回另一個副本是安全的。這樣做可能會導致建立兄弟節點,但只要正確合併兄弟節點,就不會丟失資料。
版本向量還保證了“從一個副本讀取,再寫回另一個副本”是安全的。這樣做可能會產生兄弟,但只要正確合併兄弟,就不會丟失資料。
--------
@ -766,17 +766,17 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
我們討論了三種主要的複製方法:
**單主複製**
: 客戶端將所有寫入傳送到單個節點(主節點),該節點將資料變更事件流傳送到其他副本(從節點)。讀取可以在任何副本上執行,但從從節點讀取可能是陳舊的。
: 客戶端將所有寫入傳送到單個節點(領導者),該節點將資料變更事件流傳送到其他副本(追隨者)。讀取可以在任何副本上執行,但從追隨者讀取可能是陳舊的。
**多主複製**
: 客戶端將每個寫入傳送到幾個主節點之一,任何主節點都可以接受寫入。主節點相互發送資料變更事件流,併發送到任何從節點
: 客戶端將每個寫入傳送到幾個領導者之一,任何領導者都可以接受寫入。領導者相互發送資料變更事件流,併發送到任何追隨者
**無主複製**
: 客戶端將每個寫入傳送到多個節點,並行從多個節點讀取,以檢測和糾正具有陳舊資料的節點。
每種方法都有優缺點。單主複製很受歡迎,因為它相當容易理解,並且提供強一致性。多主和無主複製在存在故障節點、網路中斷和延遲峰值時可以更加健壯——代價是需要衝突解決並提供較弱的一致性保證。
複製可以是同步的或非同步的,這對系統在出現故障時的行為有深遠的影響。儘管非同步複製在系統平穩執行時可能很快,但重要的是要弄清楚當複製延遲增加和伺服器失敗時會發生什麼。如果主節點失敗並且你將非同步更新的從節點提升為新的主節點,最近提交的資料可能會丟失。
複製可以是同步的或非同步的,這對系統在出現故障時的行為有深遠的影響。儘管非同步複製在系統平穩執行時可能很快,但重要的是要弄清楚當複製延遲增加和伺服器失敗時會發生什麼。如果領導者失敗並且你將非同步更新的追隨者提升為新的領導者,最近提交的資料可能會丟失。
我們研究了複製延遲可能導致的一些奇怪效果,並討論了一些有助於決定應用程式在複製延遲下應如何表現的一致性模型:
@ -789,7 +789,7 @@ Riak 將客戶端和資料庫節點之間的所有通訊保持在一個地區本
**一致字首讀**
: 使用者應該看到處於因果意義狀態的資料:例如,按正確順序看到問題及其回覆。
最後,我們討論了多主和無主複製如何確保所有副本最終收斂到一致狀態:透過使用版本向量或類似演算法來檢測哪些寫入是併發的,並透過使用衝突解決演算法(如 CRDT來合併併發寫入的值。最後寫入勝和手動衝突解決也是可能的。
最後,我們討論了多主和無主複製如何確保所有副本最終收斂到一致狀態:透過使用版本向量或類似演算法來檢測哪些寫入是併發的,並透過使用衝突解決演算法(如 CRDT來合併併發寫入的值。最後寫入勝和手動衝突解決也是可能的。
本章假設每個副本都儲存整個資料庫的完整副本,這對於大型資料集是不現實的。在下一章中,我們將研究 **分片**,它允許每臺機器只儲存資料的子集。

View file

@ -4,6 +4,8 @@ weight: 207
breadcrumbs: false
---
<a id="ch_sharding"></a>
![](/map/ch06.png)
> *顯然,我們必須跳出順序計算機指令的窠臼。我們必須敘述定義、提供優先順序和資料描述。我們必須敘述關係,而不是過程。*
@ -19,9 +21,9 @@ breadcrumbs: false
分片通常與複製結合使用,以便每個分片的副本儲存在多個節點上。這意味著,即使每條記錄屬於恰好一個分片,它仍然可以儲存在多個不同的節點上以提供容錯能力。
一個節點可能儲存多個分片。如果使用單主複製模型,分片和複製的組合可能看起來像 [圖 7-1](/tw/ch7#fig_sharding_replicas),例如。每個分片的主節點被分配給一個節點,其從節點被分配給其他節點。每個節點可能是某些分片的主節點,同時是其他分片的從節點
一個節點可能儲存多個分片。例如,如果使用單領導者複製模型,分片與複製的組合可能如 [圖 7-1](/tw/ch7#fig_sharding_replicas) 所示。每個分片的領導者被分配到一個節點,追隨者被分配到其他節點。每個節點可能是某些分片的領導者,同時又是其他分片的追隨者,但每個分片仍然只有一個領導者
{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="圖 7-1. 結合複製和分片:每個節點充當某些分片的主節點,同時充當其他分片的從節點。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0701.png" id="fig_sharding_replicas" caption="圖 7-1. 複製與分片結合使用:每個節點對某些分片充當領導者,對另一些分片充當追隨者。" class="w-full my-4" >}}
我們在 [第 6 章](/tw/ch6#ch_replication) 中討論的關於資料庫複製的所有內容同樣適用於分片的複製。由於分片方案的選擇大部分獨立於複製方案的選擇,為了簡單起見,我們將在本章中忽略複製。
@ -53,7 +55,7 @@ breadcrumbs: false
分片的另一個問題是寫入可能需要更新多個不同分片中的相關記錄。雖然單節點上的事務相當常見(見 [第 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}
@ -62,10 +64,10 @@ breadcrumbs: false
有時分片用於實現多租戶系統:要麼每個租戶被分配一個單獨的分片,要麼多個小租戶可能被分組到一個更大的分片中。這些分片可能是物理上分離的資料庫(我們之前在 ["嵌入式儲存引擎"](/tw/ch4#sidebar_embedded) 中提到過),或者是更大邏輯資料庫的可單獨管理部分 [^7]。使用分片實現多租戶有幾個優點:
資源隔離
: 如果一個租戶執行計算密集型操作,如果它們在不同的分片上執行,其他租戶的效能受影響的可能性較小。
: 如果某個租戶執行計算密集型操作,而它與其他租戶執行在不同分片上,那麼其他租戶效能受影響的可能性更小。
許可權隔離
: 如果你的訪問控制邏輯中存在錯誤,如果這些租戶的資料集彼此物理分離儲存,你意外地給一個租戶訪問另一個租戶資料的可能性較小
: 如果訪問控制邏輯有漏洞,而租戶資料集又是彼此物理隔離儲存的,那麼誤將一個租戶的資料暴露給另一個租戶的機率會更低
基於單元的架構
: 你不僅可以在資料儲存級別應用分片,還可以為執行應用程式程式碼的服務應用分片。在 *基於單元的架構* 中,特定租戶集的服務和儲存被分組到一個自包含的 *單元* 中,不同的單元被設定為可以在很大程度上彼此獨立執行。這種方法提供了 *故障隔離*:即,一個單元中的故障僅限於該單元,其他單元中的租戶不受影響 [^8]。
@ -86,7 +88,7 @@ breadcrumbs: false
* 它假設每個單獨的租戶都足夠小,可以適應單個節點。如果情況並非如此,並且你有一個對於一臺機器來說太大的租戶,你將需要在單個租戶內額外執行分片,這將我們帶回到為可伸縮性進行分片的主題 [^12]。
* 如果你有許多小租戶,那麼為每個租戶建立單獨的分片可能會產生太多開銷。你可以將幾個小租戶組合到一個更大的分片中,但隨後你會遇到如何在租戶增長時將其從一個分片移動到另一個分片的問題。
* 如果你需要支援跨多個租戶連線資料的功能,如果你需要跨多個分片連線資料,這些功能將變得更難實現
* 如果你需要支援跨多個租戶關聯資料的功能,那麼在必須跨多個分片做連線時,實現難度會顯著增加
@ -96,7 +98,7 @@ breadcrumbs: false
我們進行分片的目標是將資料和查詢負載均勻地分佈在各節點上。如果每個節點承擔公平的份額那麼理論上——10 個節點應該能夠處理 10 倍的資料量和 10 倍單個節點的讀寫吞吐量(忽略複製)。此外,如果我們新增或刪除節點,我們希望能夠 *再平衡* 負載,使其在新增時均勻分佈在 11 個節點上(或刪除時在剩餘的 9 個節點上)。
如果分片不公平,使得某些分片比其他分片有更多的資料或查詢,我們稱之為 *傾斜*。傾斜的存在使分片的效果大打折扣。在極端情況下,所有負載可能最終集中在一個分片上,因此 10 個節點中有 9 個處於空閒狀態,你的瓶頸是單個繁忙的節點。具有不成比例高負載的分片稱為 *熱分片**熱點*。如果有一個鍵具有特別高的負載(例如,社交網路中的名人),我們稱之為 *熱鍵*
如果分片不公平,使得某些分片比其他分片承載更多資料或查詢,我們稱之為 *偏斜*。偏斜會顯著削弱分片效果。在極端情況下,所有負載都可能集中在一個分片上,導致 10 個節點中有 9 個處於空閒狀態,而瓶頸落在那一個繁忙節點上。負載明顯高於其他分片的分片稱為 *熱分片**熱點*。如果某個鍵的負載特別高(例如社交網路中的名人),我們稱之為 *熱鍵*
因此,我們需要一種演算法,它以記錄的分割槽鍵作為輸入,並告訴我們該記錄在哪個分片中。在鍵值儲存中,分割槽鍵通常是鍵,或鍵的第一部分。在關係模型中,分割槽鍵可能是表的某一列(不一定是其主鍵)。該演算法需要能夠進行再平衡以緩解熱點。
@ -136,7 +138,7 @@ breadcrumbs: false
鍵範圍分片在你希望具有相鄰(但不同)分割槽鍵的記錄被分組到同一個分片中時很有用;例如,如果是時間戳,這可能就是這種情況。如果你不關心分割槽鍵是否彼此接近(例如,如果它們是多租戶應用程式中的租戶 ID一種常見方法是先對分割槽鍵進行雜湊然後將其對映到分片。
一個好的雜湊函式接受傾斜的資料並使其均勻分佈。假設你有一個 32 位雜湊函式,它接受一個字串。每當你給它一個新字串時,它返回一個介於 0 和 2³² 1 之間的看似隨機的數字。即使輸入字串非常相似,它們的雜湊值也會均勻分佈在該數字範圍內(但相同輸入總是產生相同輸出)。
一個好的雜湊函式可以把偏斜的資料變得更均勻。假設你有一個 32 位雜湊函式,輸入是字串。每當給它一個新字串,它都會返回一個看似隨機、介於 0 和 2³² 1 之間的數字。即使輸入字串非常相似,它們的雜湊值也會在這個範圍內均勻分佈(但相同輸入總是產生相同輸出)。
出於分片目的雜湊函式不需要是密碼學強度的例如MongoDB 使用 MD5而 Cassandra 和 ScyllaDB 使用 Murmur3。許多程式語言都內建了簡單的雜湊函式因為它們用於雜湊表但它們可能不適合分片例如在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash` 中,相同的鍵在不同的程序中可能有不同的雜湊值,使它們不適合分片 [^16]。
@ -166,7 +168,7 @@ breadcrumbs: false
如果你發現最初配置的分片數量是錯誤的——例如,如果你已經達到需要比分片更多節點的規模——那麼需要進行昂貴的重新分片操作。它需要分割每個分片並將其寫入新檔案,在此過程中使用大量額外的磁碟空間。一些系統不允許在併發寫入資料庫時進行重新分片,這使得在沒有停機時間的情況下更改分片數量變得困難。
如果資料集的總大小高度可變(例如,如果它開始很小但可能隨時間增長得更大),選擇正確的分片數量是困難的。由於每個分片包含總資料的固定部分,每個分片的大小與叢集中的總資料量成比例增長。如果分片非常大,再平衡和從節點故障恢復會變得昂貴。但如果分片太小,它們會產生太多開銷。當分片大小"恰到好處"時可以實現最佳效能,既不太大也不太小,如果分片數量固定但資料集大小變化,這可能很難實現
如果資料集總大小高度可變(例如起初很小,但會隨時間顯著增長),選擇合適的分片數量就很困難。由於每個分片包含總資料中的固定比例,每個分片的大小會隨叢集總資料量按比例增長。如果分片很大,再平衡和節點故障恢復都會很昂貴;但如果分片太小,又會產生過多管理開銷。最佳效能通常出現在分片大小“恰到好處”時,但在分片數量固定、資料規模又持續變化的情況下,這很難做到
#### 按雜湊範圍分片 {#sharding-by-hash-range}
@ -205,15 +207,15 @@ breadcrumbs: false
Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始定義 [^20],但也提出了其他幾種一致性雜湊演算法 [^21],如 *最高隨機權重*,也稱為 *會合雜湊* [^22],以及 *跳躍一致性雜湊* [^23]。使用 Cassandra 的演算法,如果新增一個節點,少量現有分片會被分割成子範圍;另一方面,使用會合和跳躍一致性雜湊,新節點被分配之前分散在所有其他節點中的單個鍵。哪種更可取取決於應用程式。
### 斜的工作負載與緩解熱點 {#sec_sharding_skew}
### 斜的工作負載與緩解熱點 {#sec_sharding_skew}
一致性雜湊確保鍵在節點間均勻分佈,但這並不意味著實際負載是均勻分佈的。如果工作負載高度傾斜——即某些分割槽鍵下的資料量遠大於其他鍵,或者對某些鍵的請求率遠高於其他鍵——你仍然可能最終導致某些伺服器過載,而其他伺服器幾乎處於空閒狀態
一致性雜湊保證鍵在節點間大致均勻分佈,但這並不等於實際負載也均勻分佈。如果工作負載高度偏斜,即某些分割槽鍵下的資料量遠大於其他鍵,或某些鍵的請求速率遠高於其他鍵,那麼你仍可能出現部分伺服器過載、其他伺服器幾乎空閒的情況
例如,在社交媒體網站上,擁有數百萬粉絲的名人使用者在做某事時可能會引起活動風暴 [^24]。這個事件可能導致對同一個鍵的大量讀寫(其中分割槽鍵可能是名人的使用者 ID或者人們正在評論的動作的 ID
在這種情況下,需要更靈活的分片策略 [^25] [^26]。基於鍵範圍(或雜湊範圍)定義分片的系統使得可以將單個熱鍵放在自己的分片中,甚至可能為其分配專用機器 [^27]。
也可以在應用程式級別補償傾斜。例如,如果已知一個鍵非常熱,一個簡單的技術是在鍵的開頭或結尾新增一個隨機數。僅僅一個兩位數的十進位制隨機數就會將對該鍵的寫入均勻分佈在 100 個不同的鍵上,允許這些鍵分佈到不同的分片。
也可以在應用層補償偏斜。例如,如果已知某個鍵非常熱,一個簡單方法是在鍵的前後附加隨機數。僅用兩位十進位制隨機數,就可以把對該鍵的寫入均勻打散到 100 個不同鍵上,從而將它們分佈到不同分片。
然而,將寫入分散到不同的鍵之後,任何讀取現在都必須做額外的工作,因為它們必須從所有 100 個鍵讀取資料並將其組合。對熱鍵每個分片的讀取量沒有減少;只有寫入負載被分割。這種技術還需要額外的記賬:只對少數熱鍵附加隨機數是有意義的;對於寫入吞吐量低的絕大多數鍵,這將是不必要的開銷。因此,你還需要某種方法來跟蹤哪些鍵正在被分割,以及將常規鍵轉換為特殊管理的熱鍵的過程。
@ -221,7 +223,7 @@ Cassandra 和 ScyllaDB 使用的分片演算法類似於一致性雜湊的原始
一些系統特別是為大規模設計的雲服務有自動處理熱分片的方法例如Amazon 稱之為 *熱管理* [^28] 或 *自適應容量* [^17]。這些系統如何工作的細節超出了本書的範圍。
### 運維:自動/手動再衡 {#sec_sharding_operations}
### 運維:自動/手動再衡 {#sec_sharding_operations}
關於再平衡有一個我們已經忽略的重要問題:分片的分割和再平衡是自動發生還是手動發生?
@ -267,7 +269,7 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
當使用路由層或向隨機節點發送請求時,客戶端仍然需要找到要連線的 IP 地址。這些不像分片到節點的分配那樣快速變化,因此通常使用 DNS 就足夠了。
這個關於請求路由的討論集中在查詢單個鍵的分片,這對於分片 OLTP 資料庫最相關。分析資料庫通常也使用分片,但它們通常有非常不同型別的查詢執行:查詢通常需要並行聚合和連線來自許多不同分片的資料,而不是在單個分片中執行。我們將在 [連結待定] 中討論這種並行查詢執行的技術。
上面對請求路由的討論,主要關注如何為單個鍵找到對應分片,這對分片 OLTP 資料庫最相關。分析型資料庫通常也使用分片,但其查詢執行模型很不一樣:查詢往往需要並行聚合並連線來自多個分片的資料,而不是在單個分片內執行。我們將在 ["JOIN 和 GROUP BY"](/tw/ch11#sec_batch_join) 中討論這類並行查詢執行技術。
## 分片與二級索引 {#sec_sharding_secondary_indexes}
@ -275,17 +277,17 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
如果涉及二級索引,情況會變得更加複雜(另見 ["多列和二級索引"](/tw/ch4#sec_storage_index_multicolumn))。二級索引通常不唯一地標識記錄,而是一種搜尋特定值出現的方法:查詢使用者 `123` 的所有操作、查詢包含單詞 `hogwash` 的所有文章、查詢顏色為 `red` 的所有汽車等。
鍵值儲存通常沒有二級索引,但它們是關係資料庫的基礎,在文件資料庫中也很常見,它們是 Solr 和 Elasticsearch 等搜尋引擎的 *存在理由*。二級索引的問題是它們不能整齊地對映到分片。有兩種主要方法來使用二級索引對資料庫進行分片:本地索引和全域性索引。
鍵值儲存通常沒有二級索引;但在關係資料庫中,二級索引是基礎能力,在文件資料庫中也很常見,而且它們正是 Solr、Elasticsearch 等全文檢索引擎的 *立身之本*。二級索引的難點在於,它們不能整齊地對映到分片。帶二級索引的分片資料庫主要有兩種做法:本地索引與全域性索引。
### 本地二級索引 {#id166}
例如,假設你正在運營一個出售二手車的網站(如 [圖 7-9](/tw/ch7#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" >}}
> [!WARN] 警告
> [!WARNING] 警告
如果你的資料庫只支援鍵值模型,你可能會嘗試透過在應用程式程式碼中建立從值到文件 ID 的對映來自己實現二級索引。如果你走這條路,你需要格外小心,確保你的索引與底層資料保持一致。競態條件和間歇性寫入失敗(其中某些更改已儲存但其他更改未儲存)很容易導致資料不同步——見 ["多物件事務的需求"](/tw/ch8#sec_transactions_need)。
@ -297,7 +299,7 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
但是,如果你想要所有結果並且事先不知道它們的分割槽鍵,你需要將查詢傳送到所有分片,並組合你收到的結果,因為匹配的記錄可能分散在所有分片中。在 [圖 7-9](/tw/ch7#fig_sharding_local_secondary) 中,紅色汽車出現在分片 0 和分片 1 中。
這種查詢分片資料庫的方法有時稱為 *分散/聚集*,它可能使二級索引上的讀取查詢相當昂貴。即使並行查詢分片,分散/聚集也容易導致尾部延遲放大(見 ["響應時間指標的使用"](/tw/ch2#sec_introduction_slo_sla))。它還限制了應用程式的可伸縮性:新增更多分片讓你儲存更多資料,但如果每個分片無論如何都必須處理每個查詢,它不會增加你的查詢吞吐量
這種查詢分片資料庫的方法有時稱為 *分散/收集*scatter/gather它可能使二級索引讀取變得相當昂貴。即使並行查詢各分片分散/收集也容易導致尾部延遲放大(見 ["響應時間指標的使用"](/tw/ch2#sec_introduction_slo_sla))。它還會限制應用的可伸縮性:增加分片可以提升可儲存資料量,但若每個查詢仍需所有分片參與,查詢吞吐量並不會隨分片數增加而提升
儘管如此,本地二級索引被廣泛使用 [^31]例如MongoDB、Riak、Cassandra [^32]、Elasticsearch [^33]、SolrCloud 和 VoltDB [^34] 都使用本地二級索引。
@ -313,13 +315,13 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
全域性索引使用詞項作為分割槽鍵,因此當你查詢特定詞項或值時,你可以找出需要查詢哪個分片。和以前一樣,分片可以包含連續的詞項範圍(如 [圖 7-10](/tw/ch7#fig_sharding_global_secondary)),或者你可以基於詞項的雜湊將詞項分配給分片。
全域性索引的優點是具有單個條件的查詢(如 *color = red*)只需要從單個分片讀取以獲取釋出列表。但是,如果你想獲取記錄而不僅僅是 ID你仍然必須從負責這些 ID 的所有分片中讀取。
全域性索引的優點是,只有一個查詢條件時(如 *color = red*),只需從一個分片讀取即可獲得倒排列表。但如果你不僅要 ID還要取回完整記錄仍然必須去負責這些 ID 的各個分片讀取。
如果你有多個搜尋條件或詞項(例如,搜尋某種顏色和某種製造商的汽車,或搜尋同一文字中出現的多個單詞),很可能這些詞項將被分配給不同的分片。要計算兩個條件的邏輯 AND系統需要找到兩個釋出列表中都出現的所有 ID。如果釋出列表很短這沒問題但如果它們很長透過網路傳送它們來計算它們的交集可能會很慢 [^30]。
如果你有多個搜尋條件或詞項(例如搜尋某種顏色且某個製造商的汽車,或搜尋同一文字中出現的多個單詞),這些詞項很可能會落在不同分片。要計算兩個條件的邏輯 AND系統需要找出同時出現在兩個倒排列表中的 ID。若倒排列表較短這沒問題但若很長把它們透過網路傳送後再算交集就可能很慢 [^30]。
全域性二級索引的另一個挑戰是寫入比本地索引更複雜,因為寫入單個記錄可能會影響索引的多個分片(文件中的每個詞項可能在不同的分片或不同的節點上)。這使得二級索引與底層資料保持同步更加困難。一種選擇是使用分散式事務來原子地更新儲存主記錄的分片及其二級索引(見 [第 8 章](/tw/ch8#ch_transactions))。
全域性二級索引被 CockroachDB、TiDB 和 YugabyteDB 使用DynamoDB 支援本地和全域性二級索引。在 DynamoDB 的情況下,寫入非同步反映在全域性索引中,因此從全域性索引讀取可能是陳舊的(類似於複製延遲,如 ["複製延遲的問題"](/tw/ch6#sec_replication_lag))。儘管如此,如果讀取吞吐量高於寫入吞吐量,並且釋出列表不太長,全域性索引是有用的
全域性二級索引被 CockroachDB、TiDB 和 YugabyteDB 使用DynamoDB 同時支援本地與全域性二級索引。在 DynamoDB 中,寫入會非同步反映到全域性索引,因此從全域性索引讀取到的結果可能是陳舊的(類似複製延遲,見 ["複製延遲的問題"](/tw/ch6#sec_replication_lag))。儘管如此,在讀吞吐量高於寫吞吐量且倒排列表不太長的場景下,全域性索引仍然很有價值
## 總結 {#summary}
@ -330,10 +332,13 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
我們討論了兩種主要的分片方法:
* *鍵範圍分片*,其中鍵是有序的,分片擁有從某個最小值到某個最大值的所有鍵。排序的優點是可以進行高效的範圍查詢,但如果應用程式經常訪問排序順序中彼此接近的鍵,則存在熱點風險。
**鍵範圍分片**
: 其中鍵是有序的,分片擁有從某個最小值到某個最大值的所有鍵。排序的優點是可以進行高效的範圍查詢,但如果應用程式經常訪問排序順序中彼此接近的鍵,則存在熱點風險。
在這種方法中,當分片變得太大時,通常透過將範圍分成兩個子範圍來動態重新平衡分片。
* *雜湊分片*,其中對每個鍵應用雜湊函式,分片擁有一個雜湊值範圍(或者可以使用另一種一致性雜湊演算法將雜湊對映到分片)。這種方法破壞了鍵的順序,使範圍查詢效率低下,但可能更均勻地分佈負載。
**雜湊分片**
: 其中對每個鍵應用雜湊函式,分片擁有一個雜湊值範圍(或者可以使用另一種一致性雜湊演算法將雜湊對映到分片)。這種方法破壞了鍵的順序,使範圍查詢效率低下,但可能更均勻地分佈負載。
當按雜湊分片時,通常預先建立固定數量的分片,為每個節點分配多個分片,並在新增或刪除節點時將整個分片從一個節點移動到另一個節點。像鍵範圍一樣分割分片也是可能的。
@ -341,17 +346,20 @@ Cassandra、ScyllaDB 和 Riak 採用不同的方法:它們在節點之間使
我們還討論了分片和二級索引之間的互動。二級索引也需要進行分片,有兩種方法:
* *本地二級索引*,其中二級索引與主鍵和值儲存在同一個分片中。這意味著寫入時只需要更新一個分片,但二級索引的查詢需要從所有分片讀取。
* *全域性二級索引*,它們基於索引值單獨分片。二級索引中的條目可能引用來自主鍵所有分片的記錄。寫入記錄時,可能需要更新多個二級索引分片;但是,可以從單個分片提供釋出列表的讀取(獲取實際記錄仍需要從多個分片讀取)
**本地二級索引**
: 其中二級索引與主鍵和值儲存在同一個分片中。這意味著寫入時只需要更新一個分片,但二級索引的查詢需要從所有分片讀取
最後,我們討論了將查詢路由到適當分片的技術,以及協調服務通常用於跟蹤分片到節點的分配的方式。
**全域性二級索引**
: 它們基於索引值單獨分片。二級索引中的條目可能引用來自主鍵所有分片的記錄。寫入記錄時,可能需要更新多個二級索引分片;但讀取倒排列表時,可以由單個分片提供(獲取實際記錄仍需從多個分片讀取)。
按設計,每個分片主要獨立執行——這就是允許分片資料庫擴充套件到多臺機器的原因。但是,需要寫入多個分片的操作可能會有問題:例如,如果對一個分片的寫入成功,但對另一個分片的寫入失敗,會發生什麼?我們將在以下章節中解決該問題。
最後,我們討論了將查詢路由到正確分片的技術,以及如何藉助協調服務維護分片到節點的分配資訊。
按設計,每個分片大體獨立執行,這正是分片資料庫能夠擴充套件到多臺機器的原因。然而,凡是需要同時寫多個分片的操作都會變得棘手:例如,一個分片寫入成功、另一個分片寫入失敗時會發生什麼?這個問題將在後續章節中討論。
### References
### 參考
[^1]: Claire Giordano. [Understanding partitioning and sharding in Postgres and Citus](https://www.citusdata.com/blog/2023/08/04/understanding-partitioning-and-sharding-in-postgres-and-citus/). *citusdata.com*, August 2023. Archived at [perma.cc/8BTK-8959](https://perma.cc/8BTK-8959)
[^2]: Brandur Leach. [Partitioning in Postgres, 2022 edition](https://brandur.org/fragments/postgres-partitioning-2022). *brandur.org*, October 2022. Archived at [perma.cc/Z5LE-6AKX](https://perma.cc/Z5LE-6AKX)

View file

@ -5,6 +5,8 @@ math: true
breadcrumbs: false
---
<a id="ch_transactions"></a>
![](/map/ch07.png)
> *有些作者聲稱,支援通用的兩階段提交代價太大,會帶來效能與可用性的問題。我們認為,讓程式設計師來處理過度使用事務導致的效能問題,總比缺少事務程式設計好得多。*
@ -40,7 +42,7 @@ breadcrumbs: false
在 2000 年代後期非關係NoSQL資料庫開始流行起來。它們旨在透過提供新的資料模型選擇參見[第 3 章](/tw/ch3#ch_datamodels)),以及預設包含複製([第 6 章](/tw/ch6#ch_replication))和分片([第 7 章](/tw/ch7#ch_sharding))來改進關係型資料庫的現狀。事務是這一運動的主要犧牲品:許多這一代資料庫完全放棄了事務,或者重新定義了這個詞,用來描述比以前理解的更弱的保證集。
圍繞 NoSQL 分散式資料庫的炒作導致了一種流行的信念,即事務從根本上是不可擴充套件的,任何大規模系統都必須放棄事務以保持良好的效能和高可用性。最近,這種信念被證明是錯誤的。所謂的"NewSQL"資料庫,如 CockroachDB[^5]、TiDB[^6]、Spanner[^7]、FoundationDB[^8] 和 Yugabyte 已經證明,事務系統可以擴充套件到大資料量和高吞吐量。這些系統將分片與共識協議([第 10 章](/tw/ch10#ch_consistency))相結合,以大規模提供強 ACID 保證。
圍繞 NoSQL 分散式資料庫的炒作導致了一種流行的信念,即事務從根本上不可伸縮,任何大規模系統都必須放棄事務以保持良好的效能和高可用性。最近,這種信念被證明是錯誤的。所謂 "NewSQL" 資料庫,如 CockroachDB[^5]、TiDB[^6]、Spanner[^7]、FoundationDB[^8] 和 YugabyteDB 已經證明,事務系統同樣可以具備很強的可伸縮性,並支援大資料量與高吞吐量。這些系統將分片與共識協議([第 10 章](/tw/ch10#ch_consistency))結合,在大規模下提供強 ACID 保證。
然而,這並不意味著每個系統都必須是事務型的:與任何其他技術設計選擇一樣,事務有優點也有侷限性。為了理解這些權衡,讓我們深入瞭解事務可以提供的保證的細節——無論是在正常操作中還是在各種極端(但現實)的情況下。
@ -71,9 +73,9 @@ breadcrumbs: false
*一致性*這個詞被嚴重濫用:
* 在[第 6 章](/tw/ch6#ch_replication)中,我們討論了*副本一致性*和非同步複製系統中出現的*最終一致性*問題(參見["複製延遲的問題"](/tw/ch6#sec_replication_lag))。
* 資料庫的*一致快照*例如用於備份是整個資料庫在某一時刻存在的快照。更準確地說它與先發生關係happens-before relation一致參見[""先發生"關係和併發"](/tw/ch6#sec_replication_happens_before)):也就是說,如果快照包含在特定時間寫入的值,那麼它也反映了在該值寫入之前發生的所有寫入。
* 資料庫的*一致快照*例如用於備份是整個資料庫在某一時刻存在的快照。更準確地說它與先發生關係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))。
* 在 CAP定理中參見[第 10 章](/tw/ch10#ch_consistency)*一致性*一詞用於表示*線性一致性*(參見["線性一致性"](/tw/ch10#sec_consistency_linearizability))。
* 在 ACID 的上下文中,*一致性*是指應用程式特定的資料庫處於"良好狀態"的概念。
不幸的是,同一個詞至少有五種不同的含義。
@ -107,6 +109,8 @@ ACID 意義上的*隔離性*意味著同時執行的事務彼此隔離:它們
--------
<a id="sidebar_transactions_durability"></a>
> [!TIP] 複製與永續性
歷史上,永續性意味著寫入歸檔磁帶。然後它被理解為寫入磁碟或 SSD。最近它已經適應為意味著複製。哪種實現更好
@ -189,13 +193,13 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
* 在文件資料模型中,需要一起更新的欄位通常在同一文件內,它被視為單個物件——更新單個文件時不需要多物件事務。然而,缺乏連線功能的文件資料庫也鼓勵反正規化(參見["何時使用哪種模型"](/tw/ch3#sec_datamodels_document_summary))。當需要更新反正規化資訊時,如[圖 8-2](/tw/ch8#fig_transactions_read_uncommitted) 的示例,你需要一次更新多個文件。事務在這種情況下非常有用,可以防止反正規化資料失去同步。
* 在具有二級索引的資料庫中(幾乎除了純鍵值儲存之外的所有資料庫),每次更改值時都需要更新索引。從事務的角度來看,這些索引是不同的資料庫物件:例如,如果沒有事務隔離,記錄可能出現在一個索引中但不在另一個索引中,因為對第二個索引的更新尚未發生(參見["分片和二級索引"](/tw/ch7#sec_sharding_secondary_indexes))。
這些應用程式仍然可以在沒有事務的情況下實現。然而,沒有原子性的錯誤處理變得更加複雜,缺乏隔離性可能導致併發問題。我們將在["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)中討論這些問題,並在[待補充連結]中探索替代方法。
這些應用程式仍然可以在沒有事務的情況下實現。然而,沒有原子性的錯誤處理變得更加複雜,缺乏隔離性可能導致併發問題。我們將在["弱隔離級別"](/tw/ch8#sec_transactions_isolation_levels)中討論這些問題,並在["派生資料與分散式事務"](/tw/ch13#sec_future_derived_vs_transactions)中探索替代方法。
#### 處理錯誤和中止 {#handling-errors-and-aborts}
事務的一個關鍵特性是如果發生錯誤它可以被中止並安全地重試。ACID 資料庫基於這樣的哲學:如果資料庫有違反其原子性、隔離性或永續性保證的危險,它寧願完全放棄事務,也不允許它保持半完成狀態。
然而,並非所有系統都遵循這種哲學。特別是,具有無領導者複製的資料儲存(參見["無領導者複製"](/tw/ch6#sec_replication_leaderless))更多地基於"盡力而為"的基礎工作,可以總結為"資料庫將盡其所能,如果遇到錯誤,它不會撤消已經完成的操作"——因此,從錯誤中恢復是應用程式的責任。
然而,並非所有系統都遵循這種哲學。特別是,具有無主(無領導者複製的資料儲存(參見["無主(無領導者複製"](/tw/ch6#sec_replication_leaderless))更多地基於"盡力而為"的基礎工作,可以總結為"資料庫將盡其所能,如果遇到錯誤,它不會撤消已經完成的操作"——因此,從錯誤中恢復是應用程式的責任。
錯誤不可避免地會發生但許多軟體開發人員更願意只考慮快樂路徑而不是錯誤處理的複雜性。例如流行的物件關係對映ORM框架如 Rails 的 ActiveRecord 和 Django不會重試中止的事務——錯誤通常導致異常冒泡到堆疊中因此任何使用者輸入都被丟棄使用者收到錯誤訊息。這是一種遺憾因為中止的全部意義是啟用安全重試。
@ -288,12 +292,12 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
然而,使用這個隔離級別時,仍然有很多方式可能出現併發錯誤。例如,[圖 8-6](/tw/ch8#fig_transactions_item_many_preceders) 說明了讀已提交可能發生的問題。
{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="圖 8-6. 讀偏斜Aaliyah 觀察到資料庫處於不一致狀態。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0806.png" id="fig_transactions_item_many_preceders" caption="圖 8-6. 讀取偏差Aaliyah 觀察到資料庫處於不一致狀態。" class="w-full my-4" >}}
假設 Aaliyah 在銀行有 1,000 美元的儲蓄,分成兩個賬戶,每個 500 美元。現在一筆事務從她的一個賬戶轉賬 100 美元到另一個賬戶。如果她不幸在該事務處理的同時檢視她的賬戶餘額列表,她可能會看到一個賬戶餘額在收款到達之前(餘額為 500 美元),另一個賬戶在轉出之後(新余額為 400 美元)。對 Aaliyah 來說,現在她的賬戶總共只有 900 美元——似乎 100 美元憑空消失了。
這種異常稱為*讀偏斜*,它是*不可重複讀*的一個例子:如果 Aaliyah 在事務結束時再次讀取賬戶 1 的餘額她會看到與之前查詢中看到的不同的值600 美元)。讀偏斜在讀已提交隔離下被認為是可接受的Aaliyah 看到的賬戶餘額確實是在她讀取它們時已提交的。
這種異常稱為*讀取偏差*,它是*不可重複讀*的一個例子:如果 Aaliyah 在事務結束時再次讀取賬戶 1 的餘額她會看到與之前查詢中看到的不同的值600 美元)。讀取偏差在讀已提交隔離下被認為是可接受的Aaliyah 看到的賬戶餘額確實是在她讀取它們時已提交的。
--------
@ -351,6 +355,8 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
長時間執行的事務可能會長時間繼續使用快照,繼續讀取(從其他事務的角度來看)早已被覆蓋或刪除的值。透過永遠不更新原地的值,而是在每次更改值時插入新版本,資料庫可以提供一致的快照,同時只產生很小的開銷。
<a id="sec_transactions_snapshot_indexes"></a>
#### 索引與快照隔離 {#indexes-and-snapshot-isolation}
索引如何在多版本資料庫中工作?最常見的方法是每個索引條目指向與該條目匹配的行的一個版本(最舊或最新版本)。每個行版本可能包含對下一個最舊或下一個最新版本的引用。使用索引的查詢必須迭代行以找到可見的行,並且值與查詢要查詢的內容匹配。當垃圾收集刪除不再對任何事務可見的舊行版本時,相應的索引條目也可以被刪除。
@ -455,15 +461,15 @@ UPDATE wiki_pages SET content = 'new content'
在複製資料庫中(參見[第 6 章](/tw/ch6#ch_replication)),防止丟失的更新具有另一個維度:由於它們在多個節點上有資料副本,並且資料可能在不同節點上併發修改,因此需要採取一些額外的步驟來防止丟失的更新。
鎖和條件寫入操作假設有一個最新的資料副本。然而,具有多領導者或無領導者複製的資料庫通常允許多個寫入併發發生並非同步複製它們,因此它們不能保證有一個最新的資料副本。因此,基於鎖或條件寫入的技術在此上下文中不適用。(我們將在["線性一致性"](/tw/ch10#sec_consistency_linearizability)中更詳細地重新討論這個問題。)
鎖和條件寫入操作假設有一個最新的資料副本。然而,具有多領導者或無主(無領導者複製的資料庫通常允許多個寫入併發發生並非同步複製它們,因此它們不能保證有一個最新的資料副本。因此,基於鎖或條件寫入的技術在此上下文中不適用。(我們將在["線性一致性"](/tw/ch10#sec_consistency_linearizability)中更詳細地重新討論這個問題。)
相反,如["處理衝突寫入"](/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}
### 寫偏與幻讀 {#sec_transactions_write_skew}
在前面的部分中,我們看到了*髒寫*和*丟失更新*,這是當不同事務併發嘗試寫入相同物件時可能發生的兩種競態條件。為了避免資料損壞,需要防止這些競態條件——要麼由資料庫自動防止,要麼透過使用鎖或原子寫操作等手動保護措施。
@ -473,21 +479,21 @@ UPDATE wiki_pages SET content = 'new content'
現在想象 Aaliyah 和 Bryce 是特定班次的兩位值班醫生。兩人都感覺不舒服,所以他們都決定請假。不幸的是,他們碰巧大約在同一時間點選了下班的按鈕。接下來發生的事情如[圖 8-8](/tw/ch8#fig_transactions_write_skew) 所示。
{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="圖 8-8. 寫偏導致應用程式錯誤的示例。" class="w-full my-4" >}}
{{< figure src="/fig/ddia_0808.png" id="fig_transactions_write_skew" caption="圖 8-8. 寫偏導致應用程式錯誤的示例。" class="w-full my-4" >}}
在每個事務中,你的應用程式首先檢查當前是否有兩個或更多醫生在值班;如果是,它假設一個醫生下班是安全的。由於資料庫使用快照隔離,兩個檢查都返回 `2`因此兩個事務都繼續到下一階段。Aaliyah 更新她自己的記錄讓自己下班Bryce 同樣更新他自己的記錄。兩個事務都提交,現在沒有醫生值班。你至少有一個醫生值班的要求被違反了。
#### 描述寫偏斜 {#characterizing-write-skew}
#### 寫偏差的特徵 {#characterizing-write-skew}
這種異常稱為*寫偏*[^36]。它既不是髒寫也不是丟失的更新,因為兩個事務正在更新兩個不同的物件(分別是 Aaliyah 和 Bryce 的值班記錄)。這裡發生衝突不太明顯,但這絕對是一個競態條件:如果兩個事務一個接一個地執行,第二個醫生將被阻止下班。異常行為只有在事務併發執行時才可能。
這種異常稱為*寫偏*[^36]。它既不是髒寫也不是丟失的更新,因為兩個事務正在更新兩個不同的物件(分別是 Aaliyah 和 Bryce 的值班記錄)。這裡發生衝突不太明顯,但這絕對是一個競態條件:如果兩個事務一個接一個地執行,第二個醫生將被阻止下班。異常行為只有在事務併發執行時才可能。
你可以將寫偏視為丟失更新問題的概括。如果兩個事務讀取相同的物件,然後更新其中一些物件(不同的事務可能更新不同的物件),就會發生寫偏。在不同事務更新同一物件的特殊情況下,你會得到髒寫或丟失更新異常(取決於時機)。
你可以將寫偏視為丟失更新問題的概括。如果兩個事務讀取相同的物件,然後更新其中一些物件(不同的事務可能更新不同的物件),就會發生寫偏。在不同事務更新同一物件的特殊情況下,你會得到髒寫或丟失更新異常(取決於時機)。
我們看到有各種不同的方法可以防止丟失的更新。對於寫偏,我們的選擇更受限制:
我們看到有各種不同的方法可以防止丟失的更新。對於寫偏,我們的選擇更受限制:
* 原子單物件操作沒有幫助,因為涉及多個物件。
* 不幸的是,你在某些快照隔離實現中發現的丟失更新的自動檢測也沒有幫助:寫偏在 PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化或 SQL Server 的快照隔離級別中不會自動檢測到[^29]。自動防止寫偏需要真正的可序列化隔離(參見["可序列化"](/tw/ch8#sec_transactions_serializability))。
* 不幸的是,你在某些快照隔離實現中發現的丟失更新的自動檢測也沒有幫助:寫偏在 PostgreSQL 的可重複讀、MySQL/InnoDB 的可重複讀、Oracle 的可序列化或 SQL Server 的快照隔離級別中不會自動檢測到[^29]。自動防止寫偏需要真正的可序列化隔離(參見["可序列化"](/tw/ch8#sec_transactions_serializability))。
* 某些資料庫允許你配置約束,然後由資料庫強制執行(例如,唯一性、外部索引鍵約束或對特定值的限制)。但是,為了指定至少有一個醫生必須值班,你需要一個涉及多個物件的約束。大多數資料庫沒有對此類約束的內建支援,但你可能能夠使用觸發器或物化檢視實現它們,如["一致性"](/tw/ch8#sec_transactions_acid_consistency)中所討論的[^12]。
* 如果你不能使用可序列化隔離級別,在這種情況下,第二好的選擇可能是顯式鎖定事務所依賴的行。在醫生示例中,你可以編寫如下內容:
@ -508,9 +514,9 @@ UPDATE wiki_pages SET content = 'new content'
❶:和以前一樣,`FOR UPDATE` 告訴資料庫鎖定此查詢返回的所有行。
#### 更多寫偏斜的例子 {#more-examples-of-write-skew}
#### 寫偏差的更多例子 {#more-examples-of-write-skew}
寫偏起初可能看起來是一個深奧的問題,但一旦你意識到它,你可能會注意到更多可能發生的情況。以下是更多示例:
寫偏起初可能看起來是一個深奧的問題,但一旦你意識到它,你可能會注意到更多可能發生的情況。以下是更多示例:
會議室預訂系統
: 假設你想強制同一會議室在同一時間不能有兩個預訂[^55]。當有人想要預訂時,你首先檢查是否有任何衝突的預訂(即,具有重疊時間範圍的同一房間的預訂),如果沒有找到,你就建立會議(參見[例 8-2](/tw/ch8#fig_transactions_meeting_rooms))。
@ -535,15 +541,15 @@ UPDATE wiki_pages SET content = 'new content'
不幸的是,快照隔離不會阻止另一個使用者併發插入衝突的會議。為了保證你不會出現排程衝突,你再次需要可序列化隔離。
多人遊戲
: 在[例 8-1](/tw/ch8#fig_transactions_select_for_update) 中,我們使用鎖來防止丟失的更新(即,確保兩個玩家不能同時移動同一個棋子)。但是,鎖不會阻止玩家將兩個不同的棋子移動到棋盤上的同一位置,或者可能做出違反遊戲規則的其他移動。根據你要執行的規則型別,你可能能夠使用唯一約束,但否則你很容易受到寫偏的影響。
: 在[例 8-1](/tw/ch8#fig_transactions_select_for_update) 中,我們使用鎖來防止丟失的更新(即,確保兩個玩家不能同時移動同一個棋子)。但是,鎖不會阻止玩家將兩個不同的棋子移動到棋盤上的同一位置,或者可能做出違反遊戲規則的其他移動。根據你要執行的規則型別,你可能能夠使用唯一約束,但否則你很容易受到寫偏的影響。
宣告使用者名稱
: 在每個使用者都有唯一使用者名稱的網站上,兩個使用者可能同時嘗試使用相同的使用者名稱建立賬戶。你可以使用事務來檢查名稱是否被佔用,如果沒有,使用該名稱建立賬戶。但是,就像前面的例子一樣,這在快照隔離下是不安全的。幸運的是,唯一約束在這裡是一個簡單的解決方案(嘗試註冊使用者名稱的第二個事務將由於違反約束而被中止)。
防止重複消費
: 允許使用者花錢或積分的服務需要檢查使用者不會花費超過他們擁有的。你可以透過在使用者賬戶中插入暫定支出專案,列出賬戶中的所有專案,並檢查總和是否為正來實現這一點。有了寫偏,可能會發生兩個支出專案併發插入,它們一起導致餘額變為負數,但沒有任何事務注意到另一個。
: 允許使用者花錢或積分的服務需要檢查使用者不會花費超過他們擁有的。你可以透過在使用者賬戶中插入暫定支出專案,列出賬戶中的所有專案,並檢查總和是否為正來實現這一點。有了寫偏,可能會發生兩個支出專案併發插入,它們一起導致餘額變為負數,但沒有任何事務注意到另一個。
#### 導致寫偏的幻讀 {#sec_transactions_phantom}
#### 導致寫偏的幻讀 {#sec_transactions_phantom}
所有這些例子都遵循類似的模式:
@ -555,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 +579,7 @@ UPDATE wiki_pages SET content = 'new content'
## 可序列化 {#sec_transactions_serializability}
在本章中,我們已經看到了幾個容易出現競態條件的事務示例。某些競態條件被讀已提交和快照隔離級別所防止,但其他的則沒有。我們遇到了一些特別棘手的寫偏和幻讀示例。這是一個令人沮喪的情況:
在本章中,我們已經看到了幾個容易出現競態條件的事務示例。某些競態條件被讀已提交和快照隔離級別所防止,但其他的則沒有。我們遇到了一些特別棘手的寫偏和幻讀示例。這是一個令人沮喪的情況:
* 隔離級別很難理解,並且在不同資料庫中的實現不一致(例如,"可重複讀"的含義差異很大)。
* 如果你檢視你的應用程式程式碼,很難判斷在特定隔離級別下執行是否安全——特別是在大型應用程式中,你可能不知道所有可能併發發生的事情。
@ -676,7 +682,7 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個
* 如果事務 A 已讀取物件而事務 B 想要寫入該物件B 必須等到 A 提交或中止後才能繼續。(這確保 B 不能在 A 背後意外地更改物件。)
* 如果事務 A 已寫入物件而事務 B 想要讀取該物件B 必須等到 A 提交或中止後才能繼續。(像[圖 8-4](/tw/ch8#fig_transactions_read_committed) 中那樣讀取物件的舊版本在 2PL 下是不可接受的。)
在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*(參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏
在 2PL 中,寫入者不僅阻塞其他寫入者;它們還阻塞讀者,反之亦然。快照隔離有這樣的口號:*讀者永遠不會阻塞寫者,寫者永遠不會阻塞讀者*(參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl)),這捕捉了快照隔離和兩階段鎖定之間的關鍵區別。另一方面,因為 2PL 提供可序列化,它可以防止早期討論的所有競態條件,包括丟失的更新和寫偏
#### 兩階段鎖定的實現 {#implementation-of-two-phase-locking}
@ -705,7 +711,7 @@ VoltDB 還使用儲存過程進行復制:它不是將事務的寫入從一個
#### 謂詞鎖 {#predicate-locks}
在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏的幻讀"](/tw/ch8#sec_transactions_phantom)中,我們討論了*幻讀*的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。
在前面的鎖描述中,我們掩蓋了一個微妙但重要的細節。在["導致寫偏的幻讀"](/tw/ch8#sec_transactions_phantom)中,我們討論了*幻讀*的問題——即一個事務改變另一個事務的搜尋查詢結果。具有可序列化隔離的資料庫必須防止幻讀。
在會議室預訂示例中,這意味著如果一個事務已經搜尋了某個時間視窗內某個房間的現有預訂(參見[例 8-2](/tw/ch8#fig_transactions_meeting_rooms)),另一個事務不允許併發插入或更新同一房間和時間範圍的另一個預訂。(併發插入其他房間的預訂,或同一房間不影響擬議預訂的不同時間的預訂是可以的。)
@ -723,7 +729,7 @@ SELECT * FROM bookings
* 如果事務 A 想要讀取匹配某些條件的物件,就像在該 `SELECT` 查詢中一樣,它必須在查詢條件上獲取共享模式謂詞鎖。如果另一個事務 B 當前對匹配這些條件的任何物件具有獨佔鎖A 必須等到 B 釋放其鎖後才允許進行查詢。
* 如果事務 A 想要插入、更新或刪除任何物件,它必須首先檢查舊值或新值是否匹配任何現有的謂詞鎖。如果存在事務 B 持有的匹配謂詞鎖,則 A 必須等到 B 提交或中止後才能繼續。
這裡的關鍵思想是,謂詞鎖甚至適用於資料庫中尚不存在但將來可能新增的物件(幻讀)。如果兩階段鎖定包括謂詞鎖,資料庫將防止所有形式的寫偏和其他競態條件,因此其隔離變為可序列化。
這裡的關鍵思想是,謂詞鎖甚至適用於資料庫中尚不存在但將來可能新增的物件(幻讀)。如果兩階段鎖定包括謂詞鎖,資料庫將防止所有形式的寫偏和其他競態條件,因此其隔離變為可序列化。
#### 索引範圍鎖 {#sec_transactions_2pl_range}
@ -738,13 +744,13 @@ SELECT * FROM bookings
無論哪種方式,搜尋條件的近似都附加到其中一個索引。現在,如果另一個事務想要插入、更新或刪除同一房間和/或重疊時間段的預訂,它將必須更新索引的相同部分。在這樣做的過程中,它將遇到共享鎖,並被迫等到鎖被釋放。
這提供了對幻讀和寫偏的有效保護。索引範圍鎖不如謂詞鎖精確(它們可能鎖定比嚴格維護可序列化所需的更大範圍的物件),但由於它們的開銷要低得多,它們是一個很好的折衷。
這提供了對幻讀和寫偏的有效保護。索引範圍鎖不如謂詞鎖精確(它們可能鎖定比嚴格維護可序列化所需的更大範圍的物件),但由於它們的開銷要低得多,它們是一個很好的折衷。
如果沒有合適的索引可以附加範圍鎖,資料庫可以退回到整個表的共享鎖。這對效能不利,因為它將阻止所有其他事務寫入表,但這是一個安全的後備位置。
### 可序列化快照隔離SSI {#sec_transactions_ssi}
本章描繪了資料庫併發控制的黯淡畫面。一方面,我們有效能不佳(兩階段鎖定)或擴充套件性不佳(序列執行)的可序列化實現。另一方面,我們有效能良好但容易出現各種競態條件(丟失的更新、寫偏、幻讀等)的弱隔離級別。可序列化隔離和良好效能從根本上是對立的嗎?
本章描繪了資料庫併發控制的黯淡畫面。一方面,我們有效能不佳(兩階段鎖定)或可伸縮性不佳(序列執行)的可序列化實現。另一方面,我們有效能良好但容易出現各種競態條件(丟失的更新、寫偏、幻讀等)的弱隔離級別。可序列化隔離和良好效能從根本上是對立的嗎?
似乎不是:一種稱為*可序列化快照隔離*SSI的演算法提供完全可序列化與快照隔離相比只有很小的效能損失。SSI 相對較新:它於 2008 年首次描述[^53] [^65]。
@ -766,7 +772,7 @@ SELECT * FROM bookings
#### 基於過時前提的決策 {#decisions-based-on-an-outdated-premise}
當我們之前討論快照隔離中的寫偏斜時(參見["寫偏斜與幻讀"](/tw/ch8#sec_transactions_write_skew)),我們觀察到一個反覆出現的模式:事務從資料庫讀取一些資料,檢查查詢結果,並根據它看到的結果決定採取某些行動(寫入資料庫)。但是,在快照隔離下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在此期間被修改。
當我們之前討論快照隔離中的寫偏差時(參見["寫偏差與幻讀"](/tw/ch8#sec_transactions_write_skew)),我們觀察到一個反覆出現的模式:事務從資料庫讀取一些資料,檢查查詢結果,並根據它看到的結果決定採取某些行動(寫入資料庫)。但是,在快照隔離下,原始查詢的結果在事務提交時可能不再是最新的,因為資料可能在此期間被修改。
換句話說,事務基於*前提*(事務開始時為真的事實,例如,"當前有兩名醫生值班")採取行動。後來,當事務想要提交時,原始資料可能已更改——前提可能不再為真。
@ -781,14 +787,14 @@ SELECT * FROM bookings
回想一下快照隔離通常由多版本併發控制MVCC參見["多版本併發控制MVCC"](/tw/ch8#sec_transactions_snapshot_impl))實現。當事務從 MVCC 資料庫中的一致快照讀取時,它會忽略在拍攝快照時尚未提交的任何其他事務所做的寫入。
在[圖 8-10](/tw/ch8#fig_transactions_detect_mvcc) 中,事務 43 看到 Aaliyah 的 `on_call = true`,因為事務 42修改了 Aaliyah 的值班狀態)未提交。但是,當事務 43 想要提交時,事務 42 已經提交。這意味著從一致快照讀取時被忽略的寫入現在已生效,事務 43 的前提不再為真。當寫入者插入以前不存在的資料時,事情變得更加複雜(參見["導致寫偏的幻讀"](/tw/ch8#sec_transactions_phantom))。我們將在["檢測影響先前讀取的寫入"](/tw/ch8#sec_detecting_writes_affect_reads)中討論為 SSI 檢測幻寫。
在[圖 8-10](/tw/ch8#fig_transactions_detect_mvcc) 中,事務 43 看到 Aaliyah 的 `on_call = true`,因為事務 42修改了 Aaliyah 的值班狀態)未提交。但是,當事務 43 想要提交時,事務 42 已經提交。這意味著從一致快照讀取時被忽略的寫入現在已生效,事務 43 的前提不再為真。當寫入者插入以前不存在的資料時,事情變得更加複雜(參見["導致寫偏的幻讀"](/tw/ch8#sec_transactions_phantom))。我們將在["檢測影響先前讀取的寫入"](/tw/ch8#sec_detecting_writes_affect_reads)中討論為 SSI 檢測幻寫。
{{< figure src="/fig/ddia_0810.png" id="fig_transactions_detect_mvcc" caption="圖 8-10. 檢測事務何時從 MVCC 快照讀取過時值。" class="w-full my-4" >}}
為了防止這種異常,資料庫需要跟蹤事務由於 MVCC 可見性規則而忽略另一個事務的寫入的時間。當事務想要提交時,資料庫會檢查是否有任何被忽略的寫入現在已經提交。如果是,事務必須被中止。
為什麼要等到提交?為什麼不在檢測到陳舊讀取時立即中止事務 43好吧如果事務 43 是隻讀事務,它就不需要被中止,因為沒有寫偏的風險。在事務 43 進行讀取時,資料庫還不知道該事務是否稍後會執行寫入。此外,事務 42 可能還會中止,或者在事務 43 提交時可能仍未提交因此讀取可能最終不是陳舊的。透過避免不必要的中止SSI 保留了快照隔離對從一致快照進行長時間執行讀取的支援。
為什麼要等到提交?為什麼不在檢測到陳舊讀取時立即中止事務 43好吧如果事務 43 是隻讀事務,它就不需要被中止,因為沒有寫偏的風險。在事務 43 進行讀取時,資料庫還不知道該事務是否稍後會執行寫入。此外,事務 42 可能還會中止,或者在事務 43 提交時可能仍未提交因此讀取可能最終不是陳舊的。透過避免不必要的中止SSI 保留了快照隔離對從一致快照進行長時間執行讀取的支援。
#### 檢測影響先前讀取的寫入 {#sec_detecting_writes_affect_reads}
@ -920,15 +926,15 @@ SELECT * FROM bookings
資料庫內部事務不必與任何其他系統相容,因此它們可以使用任何協議並應用特定於該特定技術的最佳化。因此,資料庫內部分散式事務通常可以很好地工作。另一方面,跨異構技術的事務更具挑戰性。
#### 精確一次訊息處理 {#sec_transactions_exactly_once}
#### 恰好一次訊息處理 {#sec_transactions_exactly_once}
異構分散式事務允許以強大的方式整合各種系統。例如,當且僅當處理訊息的資料庫事務成功提交時,來自訊息佇列的訊息才能被確認為已處理。這是透過在單個事務中原子地提交訊息確認和資料庫寫入來實現的。有了分散式事務支援,即使訊息代理和資料庫是在不同機器上執行的兩種不相關的技術,這也是可能的。
如果訊息傳遞或資料庫事務失敗,兩者都會中止,因此訊息代理可以稍後安全地重新傳遞訊息。因此,透過原子地提交訊息及其處理的副作用,我們可以確保訊息被*有效地*精確處理一次,即使在成功之前需要幾次重試。中止會丟棄部分完成事務的任何副作用。這被稱為*精確一次語義*。
如果訊息傳遞或資料庫事務失敗,兩者都會中止,因此訊息代理可以稍後安全地重新傳遞訊息。因此,透過原子地提交訊息及其處理的副作用,我們可以確保訊息在效果上*恰好*處理一次,即使在成功之前需要幾次重試。中止會丟棄部分完成事務的任何副作用。這被稱為*恰好一次語義*。
但是,只有當受事務影響的所有系統都能夠使用相同的原子提交協議時,這種分散式事務才有可能。例如,假設處理訊息的副作用是傳送電子郵件,而電子郵件伺服器不支援兩階段提交:如果訊息處理失敗並重試,可能會發生電子郵件被傳送兩次或更多次。但是,如果處理訊息的所有副作用在事務中止時都會回滾,那麼處理步驟可以安全地重試,就好像什麼都沒有發生一樣。
我們將在本章後面回到精確一次語義的主題。讓我們首先看看允許此類異構分散式事務的原子提交協議。
我們將在本章後面回到恰好一次語義的主題。讓我們首先看看允許此類異構分散式事務的原子提交協議。
#### XA 事務 {#xa-transactions}
@ -972,7 +978,7 @@ XA 假設你的應用程式使用網路驅動程式或客戶端庫與參與者
另一個問題是,由於 XA 需要與各種資料系統相容,它必然是最低公分母。例如,它無法檢測跨不同系統的死鎖(因為這需要系統交換有關每個事務正在等待的鎖的資訊的標準化協議),並且它不適用於 SSI參見["可序列化快照隔離SSI"](/tw/ch8#sec_transactions_ssi)),因為這需要跨不同系統識別衝突的協議。
這些問題在某種程度上是跨異構技術執行事務所固有的。但是,保持幾個異構資料系統彼此一致仍然是一個真實而重要的問題,因此我們需要為其找到不同的解決方案。這可以做到,我們將在下一節和[待補充連結]中看到。
這些問題在某種程度上是跨異構技術執行事務所固有的。但是,保持幾個異構資料系統彼此一致仍然是一個真實而重要的問題,因此我們需要為其找到不同的解決方案。這可以做到,我們將在下一節和["派生資料與分散式事務"](/tw/ch13#sec_future_derived_vs_transactions)中看到。
### 資料庫內部的分散式事務 {#sec_transactions_internal}
@ -991,11 +997,11 @@ XA 的最大問題可以透過以下方式解決:
為分散式事務提供的隔離級別取決於系統,但跨分片的快照隔離和可序列化快照隔離都是可能的。有關其工作原理的詳細資訊,請參見本章末尾引用的論文。
#### 再談精確一次訊息處理 {#exactly-once-message-processing-revisited}
#### 再談恰好一次訊息處理 {#exactly-once-message-processing-revisited}
我們在["精確一次訊息處理"](/tw/ch8#sec_transactions_exactly_once)中看到,分散式事務的一個重要用例是確保某些操作精確生效一次,即使在處理過程中發生崩潰並且需要重試處理。如果你可以跨訊息代理和資料庫原子地提交事務,則當且僅當成功處理訊息並且從處理過程產生的資料庫寫入被提交時,你可以向代理確認訊息。
我們在["恰好一次訊息處理"](/tw/ch8#sec_transactions_exactly_once)中看到,分散式事務的一個重要用例是確保某些操作恰好生效一次,即使在處理過程中發生崩潰並且需要重試處理。如果你可以跨訊息代理和資料庫原子地提交事務,則當且僅當成功處理訊息並且從處理過程產生的資料庫寫入被提交時,你可以向代理確認訊息。
但是,你實際上不需要這樣的分散式事務來實現精確一次語義。另一種方法如下,它只需要資料庫中的事務:
但是,你實際上不需要這樣的分散式事務來實現恰好一次語義。另一種方法如下,它只需要資料庫中的事務:
1. 假設每條訊息都有唯一的 ID並且在資料庫中有一個已處理訊息 ID 的表。當你開始從代理處理訊息時,你在資料庫上開始一個新事務,並檢查訊息 ID。如果資料庫中已經存在相同的訊息 ID你知道它已經被處理因此你可以向代理確認訊息並丟棄它。
2. 如果訊息 ID 尚未在資料庫中,你將其新增到表中。然後你處理訊息,這可能會導致在同一事務中對資料庫進行額外的寫入。完成處理訊息後,你提交資料庫上的事務。
@ -1004,7 +1010,7 @@ XA 的最大問題可以透過以下方式解決:
如果訊息處理器在提交資料庫事務之前崩潰,事務將被中止,訊息代理將重試處理。如果它在提交後但在向代理確認訊息之前崩潰,它也將重試處理,但重試將在資料庫中看到訊息 ID 並丟棄它。如果它在確認訊息後但在從資料庫中刪除訊息 ID 之前崩潰,你將有一個舊的訊息 ID 留下,除了佔用一點儲存空間外不會造成任何傷害。如果在資料庫事務中止之前發生重試(如果訊息處理器和資料庫之間的通訊中斷,這可能會發生),訊息 ID 表上的唯一性約束應該防止兩個併發事務插入相同的訊息 ID。
因此,實現精確一次處理只需要資料庫中的事務——跨資料庫和訊息代理的原子性對於此用例不是必需的。在資料庫中記錄訊息 ID 使訊息處理*冪等*,因此可以安全地重試訊息處理而不會重複其副作用。流處理框架(如 Kafka Streams中使用類似的方法來實現精確一次語義,我們將在[待補充連結]中看到。
因此,實現恰好一次處理只需要資料庫中的事務——跨資料庫和訊息代理的原子性對於此用例不是必需的。在資料庫中記錄訊息 ID 使訊息處理具備*冪等*,因此可以安全地重試訊息處理而不會重複其副作用。流處理框架(如 Kafka Streams中使用類似的方法來實現恰好一次語義,我們將在["容錯"](/tw/ch12#sec_stream_fault_tolerance)中看到。
但是,資料庫內的內部分散式事務對於此類模式的可伸縮性仍然有用:例如,它們將允許訊息 ID 儲存在一個分片上,而訊息處理更新的主資料儲存在其他分片上,並確保跨這些分片的事務提交的原子性。
@ -1022,7 +1028,7 @@ XA 的最大問題可以透過以下方式解決:
表 8-1. 各種隔離級別可能發生的異常總結
| 隔離級別 | 髒讀 | 讀偏斜 | 幻讀 | 丟失更新 | 寫偏斜 |
| 隔離級別 | 髒讀 | 讀取偏差 | 幻讀 | 丟失更新 | 寫偏差 |
|------|------|------|------|-------|------|
| 讀未提交 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 讀已提交 | ✓ 防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
@ -1035,17 +1041,17 @@ XA 的最大問題可以透過以下方式解決:
髒寫
: 一個客戶端覆蓋另一個客戶端已寫入但尚未提交的資料。幾乎所有事務實現都防止髒寫。
偏斜
: 客戶端在不同時間點看到資料庫的不同部分。某些讀偏斜的情況也稱為*不可重複讀*。這個問題最常透過快照隔離來防止,它允許事務從對應於特定時間點的一致快照讀取。它通常使用*多版本併發控制*MVCC實現。
取偏差
: 客戶端在不同時間點看到資料庫的不同部分。某些讀取偏差的情況也稱為*不可重複讀*。這個問題最常透過快照隔離來防止,它允許事務從對應於特定時間點的一致快照讀取。它通常使用*多版本併發控制*MVCC實現。
丟失更新
: 兩個客戶端併發執行讀-修改-寫迴圈。一個覆蓋另一個的寫入而不合並其更改,因此資料丟失。某些快照隔離的實現會自動防止此異常,而其他實現需要手動鎖(`SELECT FOR UPDATE`)。
寫偏
寫偏
: 事務讀取某些內容,根據它看到的值做出決定,並將決定寫入資料庫。但是,在進行寫入時,決策的前提不再為真。只有可序列化隔離才能防止此異常。
幻讀
: 事務讀取匹配某些搜尋條件的物件。另一個客戶端進行影響該搜尋結果的寫入。快照隔離防止直接的幻讀,但寫偏上下文中的幻讀需要特殊處理,例如索引範圍鎖。
: 事務讀取匹配某些搜尋條件的物件。另一個客戶端進行影響該搜尋結果的寫入。快照隔離防止直接的幻讀,但寫偏上下文中的幻讀需要特殊處理,例如索引範圍鎖。
弱隔離級別可以防止某些異常,但讓你(應用程式開發人員)手動處理其他異常(例如,使用顯式鎖定)。只有可序列化隔離可以防止所有這些問題。我們討論了實現可序列化事務的三種不同方法:
@ -1058,13 +1064,13 @@ XA 的最大問題可以透過以下方式解決:
可序列化快照隔離SSI
: 一種相對較新的演算法,避免了前面方法的大部分缺點。它使用樂觀方法,允許事務在不阻塞的情況下進行。當事務想要提交時,它會被檢查,如果執行不可序列化,它將被中止。
最後,我們研究了當事務分佈在多個節點上時如何實現原子性,使用兩階段提交。如果這些節點都執行相同的資料庫軟體,分散式事務可以很好地工作,但跨不同儲存技術(使用 XA 事務2PC 是有問題的:它對協調器和驅動事務的應用程式程式碼中的故障非常敏感,並且與併發控制機制的互動很差。幸運的是,冪等性可以確保精確一次語義,而無需跨不同儲存技術的原子提交,我們將在後面的章節中看到更多相關內容。
最後,我們研究了當事務分佈在多個節點上時如何實現原子性,使用兩階段提交。如果這些節點都執行相同的資料庫軟體,分散式事務可以很好地工作,但跨不同儲存技術(使用 XA 事務2PC 是有問題的:它對協調器和驅動事務的應用程式程式碼中的故障非常敏感,並且與併發控制機制的互動很差。幸運的是,冪等性可以確保恰好一次語義,而無需跨不同儲存技術的原子提交,我們將在後面的章節中看到更多相關內容。
本章中的示例使用了關係資料模型。但是,如["多物件事務的需求"](/tw/ch8#sec_transactions_need)中所討論的,無論使用哪種資料模型,事務都是有價值的資料庫功能。
## 參考
### 參考
[^1]: Steven J. Murdoch. [What went wrong with Horizon: learning from the Post Office Trial](https://www.benthamsgaze.org/2021/07/15/what-went-wrong-with-horizon-learning-from-the-post-office-trial/). *benthamsgaze.org*, July 2021. Archived at [perma.cc/CNM4-553F](https://perma.cc/CNM4-553F)

View file

@ -4,9 +4,11 @@ weight: 209
breadcrumbs: false
---
<a id="ch_distributed"></a>
![](/map/ch08.png)
> *它們是有趣的東西,意外。在你遇到它們之前,你永遠不會遇到它們。*
> *意外這東西挺有意思:你沒碰上之前,它就從來不會發生。*
>
> A.A. 米爾恩《小熊維尼和老灰驢的家》1928
@ -75,11 +77,11 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
那麼,如果 TCP 提供 "可靠性",這是否意味著我們不再需要擔心網路不可靠?不幸的是不是。如果在某個超時時間內沒有收到確認,它會認為資料包一定已經丟失,但 TCP 也無法判斷是出站資料包還是確認丟失了。儘管 TCP 可以重新發送資料包但它不能保證新資料包也會透過。如果網線被拔掉TCP 不能為你重新插上它。最終在可配置的超時後TCP 放棄並嚮應用程式發出錯誤訊號。
如果 TCP 連線因錯誤而關閉 —— 也許是因為遠端節點崩潰了,或者是因為網路被中斷了 —— 你不幸地無法知道遠端節點實際處理了多少資料 [^6]。即使 TCP 確認資料包已交付,這僅意味著遠端節點上的作業系統核心收到了它,但應用程式可能在處理該資料之前就崩潰了。如果你想確保請求成功,你需要來自應用程式本身的積極響應 [^7]。
如果 TCP 連線因錯誤而關閉 —— 也許是因為遠端節點崩潰了,或者是因為網路被中斷了 —— 你不幸地無法知道遠端節點實際處理了多少資料 [^6]。即使 TCP 確認資料包已交付,這僅意味著遠端節點上的作業系統核心收到了它,但應用程式可能在處理該資料之前就崩潰了。如果你想確保請求成功,你需要應用層返回明確的成功響應 [^7]。
儘管如此TCP 非常有用,因為它提供了一種方便的方式來發送和接收太大而無法裝入一個數據包的訊息。一旦建立了 TCP 連線你還可以使用它來發送多個請求和響應。這通常是透過首先發送一個標頭來完成的該標頭以位元組為單位指示後續訊息的長度然後是實際訊息。HTTP 和許多 RPC 協議(見 ["透過服務的資料流REST 和 RPC"](/tw/ch5#sec_encoding_dataflow_rpc))就是這樣工作的。
### 網路故障的實踐 {#sec_distributed_network_faults}
### 實踐中的網路故障 {#sec_distributed_network_faults}
我們已經建立計算機網路幾十年了 —— 人們可能希望到現在我們已經弄清楚如何使它們可靠。不幸的是,我們還沒有成功。有一些系統研究和大量軼事證據表明,網路問題可能出人意料地常見,即使在由一家公司運營的受控環境(如資料中心)中也是如此 [^8]
@ -108,7 +110,7 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
許多系統需要自動檢測故障節點。例如:
* 負載均衡器需要停止向已死亡的節點發送請求(即,將其 *移出輪轉*)。
* 負載均衡器需要停止向已死亡的節點發送請求(即,將其 *從輪詢池中摘除*)。
* 在具有單主複製的分散式資料庫中,如果主節點失效,其中一個從節點需要被提升為新的主節點(見 ["處理節點中斷"](/tw/ch6#sec_replication_failover))。
不幸的是,網路的不確定性使得很難判斷節點是否正常工作。在某些特定情況下,你可能會得到一些明確告訴你某事不工作的反饋:
@ -134,6 +136,8 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
不幸的是,我們使用的大多數系統都沒有這些保證:非同步網路具有 *無界延遲*(即,它們嘗試儘快交付資料包,但資料包到達所需的時間沒有上限),大多數伺服器實現無法保證它們可以在某個最大時間內處理請求(見 ["響應時間保證"](/tw/ch9#sec_distributed_clocks_realtime))。對於故障檢測,系統大部分時間快速執行是不夠的:如果你的超時很低,往返時間的瞬時峰值就足以使系統失去平衡。
<a id="sec_distributed_congestion"></a>
#### 網路擁塞和排隊 {#network-congestion-and-queueing}
開車時,道路網路上的行駛時間通常因交通擁堵而變化最大。同樣,計算機網路上資料包延遲的可變性最常是由於排隊 [^27]
@ -149,6 +153,8 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
--------
<a id="sidebar_distributed_tcp_udp"></a>
> [!TIP] TCP 與 UDP
>
> 一些對延遲敏感的應用程式,如視訊會議和 IP 語音VoIP使用 UDP 而不是 TCP。這是可靠性和延遲可變性之間的權衡由於 UDP 不執行流量控制並且不重傳丟失的資料包,它避免了網路延遲可變的一些原因(儘管它仍然容易受到交換機佇列和排程延遲的影響)。
@ -189,6 +195,8 @@ TCP 通常被描述為提供 "可靠" 的交付,從某種意義上說,它檢
--------
<a id="sidebar_distributed_latency_utilization"></a>
> [!TIP] 延遲和資源利用率
>
> 更一般地說,你可以將可變延遲視為動態資源分割槽的結果。
@ -417,7 +425,7 @@ while (true) {
## 知識、真相謊言 {#sec_distributed_truth}
## 知識、真相謊言 {#sec_distributed_truth}
到目前為止,在本章中,我們已經探討了分散式系統與在單臺計算機上執行的程式的不同之處:沒有共享記憶體,只有透過不可靠的網路進行訊息傳遞,具有可變延遲,系統可能會遭受部分失效、不可靠的時鐘和處理暫停。
@ -471,7 +479,7 @@ while (true) {
術語 *殭屍* 有時用於描述尚未發現失去租約的前租約持有者,並且仍在充當當前租約持有者。由於我們不能完全排除殭屍,我們必須確保它們不能以腦裂的形式造成任何損害。這被稱為 *隔離* 殭屍。
一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM甚至物理關閉機器 [^87]。這種方法被稱為 *向對方節點頭部開槍* 或 STONITH。不幸的是,它存在一些問題:它不能防範像 [圖 9-5](/tw/ch9#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。
一些系統試圖透過關閉殭屍來隔離它們,例如透過斷開它們與網路的連線 [^9]、透過雲提供商的管理介面關閉 VM甚至物理關閉機器 [^87]。這種方法被稱為 *對端節點爆頭*STONITH。不幸的是,它存在一些問題:它不能防範像 [圖 9-5](/tw/ch9#fig_distributed_lease_delay) 中那樣的大網路延遲;可能會發生所有節點相互關閉的情況 [^19];到檢測到殭屍並關閉它時,可能已經太晚了,資料可能已經被損壞。
一個更強大的隔離解決方案,可以防範殭屍和延遲請求,如 [圖 9-6](/tw/ch9#fig_distributed_fencing) 所示。
@ -487,7 +495,7 @@ while (true) {
--------
在 [圖 9-6](/tw/ch9#fig_distributed_fencing) 中,客戶端 1 獲得帶有令牌 33 的租約,但隨後進入長時間暫停,租約過期。客戶端 2 獲得帶有令牌 34 的租約(數字總是增加),然後將其寫請求傳送到儲存服務,包括令牌 34。稍後客戶端 1 恢復生機並將其寫入傳送到儲存服務,包括其令牌值 33。然而儲存服務記得它已經處理了具有更高令牌編號34的寫入因此它拒絕帶有令牌 33 的請求。剛剛獲得租約的客戶端必須立即向儲存服務進行寫入,一旦該寫入完成,任何殭屍都被隔離了。
在 [圖 9-6](/tw/ch9#fig_distributed_fencing) 中,客戶端 1 獲得帶有令牌 33 的租約,但隨後進入長時間暫停,租約過期。客戶端 2 獲得帶有令牌 34 的租約(數字總是增加),然後將其寫請求傳送到儲存服務,包括令牌 34。稍後客戶端 1 恢復執行並將其寫入傳送到儲存服務,包括其令牌值 33。然而儲存服務記得它已經處理了具有更高令牌編號34的寫入因此它拒絕帶有令牌 33 的請求。剛剛獲得租約的客戶端必須立即向儲存服務進行寫入,一旦該寫入完成,任何殭屍都被隔離了。
如果 ZooKeeper 是你的鎖服務,你可以使用事務 ID `zxid` 或節點版本 `cversion` 作為隔離令牌 [^85]。使用 etcd修訂號與租約 ID 一起起著類似的作用 [^89]。Hazelcast 中的 FencedLock API 明確生成隔離令牌 [^90]。
@ -539,6 +547,8 @@ Web 應用程式確實需要預期客戶端在終端使用者控制下的任意
同樣,如果協議可以保護我們免受漏洞、安全妥協和惡意攻擊,那將是很有吸引力的。不幸的是,這也不現實:在大多數系統中,如果攻擊者可以破壞一個節點,他們可能可以破壞所有節點,因為它們可能執行相同的軟體。因此,傳統機制(身份驗證、訪問控制、加密、防火牆等)仍然是防範攻擊者的主要保護。
<a id="sec_distributed_weak_lying"></a>
#### 弱形式的謊言 {#weak-forms-of-lying}
儘管我們假設節點通常是誠實的,但向軟體新增防範弱形式 "謊言" 的機制可能是值得的 —— 例如,由於硬體問題、軟體錯誤和配置錯誤導致的無效訊息。這種保護機制不是完全的拜占庭容錯,因為它們無法抵禦堅定的對手,但它們仍然是朝著更好可靠性邁出的簡單而務實的步驟。例如:
@ -671,7 +681,7 @@ DST 要求模擬器能夠控制所有非確定性來源,例如網路延遲。
DST 提供了超越可重放性的幾個優勢。Antithesis 等工具試圖透過在發現不太常見的行為時將測試執行分支為多個子執行來探索應用程式程式碼中的許多不同程式碼路徑。由於確定性測試通常使用模擬時鐘和網路呼叫因此此類測試可以比掛鐘時間執行得更快。例如TigerBeetle 的時間抽象允許模擬模擬網路延遲和超時,而實際上不需要觸發超時的全部時間長度。這些技術允許模擬器更快地探索更多程式碼路徑。
# 確定性的力量
#### 確定性的力量 {#sidebar_distributed_determinism}
非確定性是我們在本章中討論的所有分散式系統挑戰的核心:併發性、網路延遲、程序暫停、時鐘跳躍和崩潰都以不可預測的方式發生,從系統的一次執行到下一次執行都不同。相反,如果你能使系統確定性,那可以極大地簡化事情。
@ -691,7 +701,7 @@ DST 提供了超越可重放性的幾個優勢。Antithesis 等工具試圖透
* 節點的時鐘可能與其他節點嚴重不同步(儘管你盡最大努力設定了 NTP它可能會突然向前或向後跳躍而依賴它是危險的因為你很可能沒有一個好的時鐘置信區間度量。
* 程序可能在其執行的任何時刻暫停相當長的時間,被其他節點宣告死亡,然後再次恢復活動而沒有意識到它曾暫停。
這種 *部分失* 可能發生的事實是分散式系統的決定性特徵。每當軟體嘗試做任何涉及其他節點的事情時,都有可能偶爾失敗、隨機變慢或根本沒有響應(並最終超時)。在分散式系統中,我們嘗試將對部分失的容忍構建到軟體中,這樣即使某些組成部分出現故障,整個系統也可以繼續執行。
這種 *部分失* 可能發生的事實是分散式系統的決定性特徵。每當軟體嘗試做任何涉及其他節點的事情時,都有可能偶爾失敗、隨機變慢或根本沒有響應(並最終超時)。在分散式系統中,我們嘗試將對部分失的容忍構建到軟體中,這樣即使某些組成部分出現故障,整個系統也可以繼續執行。
要容忍故障,第一步是 *檢測* 它們但即使這樣也很困難。大多數系統沒有準確的機制來檢測節點是否已失敗因此大多數分散式演算法依賴超時來確定遠端節點是否仍然可用。然而超時無法區分網路和節點故障可變的網路延遲有時會導致節點被錯誤地懷疑崩潰。處理跛行節點limping nodes更加困難這些節點正在響應但速度太慢而無法做任何有用的事情。

View file

@ -37,27 +37,35 @@ breadcrumbs: false
## 章節概述
我們將從 [第十一章](/tw/ch11) 開始,研究例如 MapReduce 這樣 **面向批處理batch-oriented** 的資料流系統。對於建設大規模資料系統,我們將看到,它們提供了優秀的工具和思想。
[第十二章](/tw/ch12) 將把這些思想應用到 **流式資料data streams** 中,使我們能用更低的延遲完成同樣的任務。[第十三章](/tw/ch13) 將對本書進行總結,探討如何使用這些工具來構建可靠,可伸縮和可維護的應用
[第十二章](/tw/ch12) 將把這些思想應用到 **流式資料data streams** 中,使我們能用更低的延遲完成同樣的任務。[第十三章](/tw/ch13) 將探討如何使用這些工具來構建可靠、可伸縮和可維護的應用。[第十四章](/ch14) 將以倫理、隱私與社會影響為主題,為全書收束
## [第十一章:批處理](/tw/ch11)
- [使用Unix工具的批處理](/tw/ch11#使用unix工具的批處理)
- [MapReduce和分散式檔案系統](/tw/ch11#mapreduce和分散式檔案系統)
- [MapReduce之後](/tw/ch11#mapreduce之後)
- [本章小結](/tw/ch11#本章小結)
- [參考文獻](/tw/ch11#參考文獻)
## 索引
## [第十二章:流處理](/tw/ch12)
- [傳遞事件流](/tw/ch12#傳遞事件流)
- [資料庫與流](/tw/ch12#資料庫與流)
- [流處理](/tw/ch12#流處理)
- [本章小結](/tw/ch12#本章小結)
- [參考文獻](/tw/ch12#參考文獻)
## [11. 批處理](/tw/ch11)
- [使用 Unix 工具的批處理](/tw/ch11#sec_batch_unix)
- [分散式系統中的批處理](/tw/ch11#sec_batch_distributed)
- [批處理模型](/tw/ch11#id431)
- [批處理用例](/tw/ch11#sec_batch_output)
- [本章小結](/tw/ch11#id292)
- [參考文獻](/tw/ch11#references)
## [第十三章:資料系統的未來](/tw/ch13)
- [資料整合](/tw/ch13#資料整合)
- [分拆資料庫](/tw/ch13#分拆資料庫)
- [將事情做正確](/tw/ch13#將事情做正確)
- [做正確的事情](/tw/ch13#做正確的事情)
- [本章小結](/tw/ch13#本章小結)
- [參考文獻](/tw/ch13#參考文獻)
## [12. 流處理](/tw/ch12)
- [傳遞事件流](/tw/ch12#sec_stream_transmit)
- [資料庫與流](/tw/ch12#sec_stream_databases)
- [流處理](/tw/ch12#sec_stream_processing)
- [本章小結](/tw/ch12#id332)
- [參考文獻](/tw/ch12#references)
## [13. 流式系統的哲學](/tw/ch13)
- [資料整合](/tw/ch13#sec_future_integration)
- [分拆資料庫](/tw/ch13#sec_future_unbundling)
- [追求正確性](/tw/ch13#sec_future_correctness)
- [本章小結](/tw/ch13#id367)
- [參考文獻](/tw/ch13#references)
## [14. 將事情做正確](/ch14)
- [預測分析](/ch14#id369)
- [隱私與追蹤](/ch14#id373)
- [總結](/ch14#id594)
- [參考文獻](/ch14#references)

View file

@ -86,27 +86,33 @@ breadcrumbs: false
- [共識](/tw/ch10#sec_consistency_consensus)
- [總結](/tw/ch10#summary)
## [第十一章:批處理](/tw/ch11)
- [使用Unix工具的批處理](/tw/ch11#使用unix工具的批處理)
- [MapReduce和分散式檔案系統](/tw/ch11#mapreduce和分散式檔案系統)
- [MapReduce之後](/tw/ch11#mapreduce之後)
- [本章小結](/tw/ch11#本章小結)
- [參考文獻](/tw/ch11#參考文獻)
## [11. 批處理](/tw/ch11)
- [使用 Unix 工具的批處理](/tw/ch11#sec_batch_unix)
- [分散式系統中的批處理](/tw/ch11#sec_batch_distributed)
- [批處理模型](/tw/ch11#id431)
- [批處理用例](/tw/ch11#sec_batch_output)
- [本章小結](/tw/ch11#id292)
- [參考文獻](/tw/ch11#references)
## [第十二章:流處理](/tw/ch12)
- [傳遞事件流](/tw/ch12#傳遞事件流)
- [資料庫與流](/tw/ch12#資料庫與流)
- [流處理](/tw/ch12#流處理)
- [本章小結](/tw/ch12#本章小結)
- [參考文獻](/tw/ch12#參考文獻)
## [12. 流處理](/tw/ch12)
- [傳遞事件流](/tw/ch12#sec_stream_transmit)
- [資料庫與流](/tw/ch12#sec_stream_databases)
- [流處理](/tw/ch12#sec_stream_processing)
- [本章小結](/tw/ch12#id332)
- [參考文獻](/tw/ch12#references)
## [第十三章:資料系統的未來](/tw/ch13)
- [資料整合](/tw/ch13#資料整合)
- [分拆資料庫](/tw/ch13#分拆資料庫)
- [將事情做正確](/tw/ch13#將事情做正確)
- [做正確的事情](/tw/ch13#做正確的事情)
- [本章小結](/tw/ch13#本章小結)
- [參考文獻](/tw/ch13#參考文獻)
## [13. 流式系統的哲學](/tw/ch13)
- [資料整合](/tw/ch13#sec_future_integration)
- [分拆資料庫](/tw/ch13#sec_future_unbundling)
- [追求正確性](/tw/ch13#sec_future_correctness)
- [本章小結](/tw/ch13#id367)
- [參考文獻](/tw/ch13#references)
## [14. 將事情做正確](/ch14)
- [預測分析](/ch14#id369)
- [隱私與追蹤](/ch14#id373)
- [總結](/ch14#id594)
- [參考文獻](/ch14#references)
## [術語表](/tw/glossary)

View file

@ -113,6 +113,22 @@ PostgreSQL 專家,資料庫老司機,雲計算泥石流。
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [386](https://github.com/Vonng/ddia/pull/386) | [@uncle-lv](https://github.com/uncle-lv) | ch2: 最佳化一處翻譯 |
| [384](https://github.com/Vonng/ddia/pull/384) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 最佳化中文文件的措辭和表達 |
| [383](https://github.com/Vonng/ddia/pull/383) | [@PanggNOTlovebean](https://github.com/PanggNOTlovebean) | docs: 修正 ch4 中的術語和表達錯誤 |
| [382](https://github.com/Vonng/ddia/pull/382) | [@uncle-lv](https://github.com/uncle-lv) | ch1: 最佳化一處翻譯 |
| [381](https://github.com/Vonng/ddia/pull/381) | [@Max-Tortoise](https://github.com/Max-Tortoise) | ch4: 修正一處術語不完整問題 |
| [377](https://github.com/Vonng/ddia/pull/377) | [@huang06](https://github.com/huang06) | 最佳化翻譯術語 |
| [375](https://github.com/Vonng/ddia/issues/375) | [@z-soulx](https://github.com/z-soulx) | 對於是否100%全中文翻譯的必要性討論?個人-沒必要100%特別是“名詞”有原單詞更加適合it人員 |
| [371](https://github.com/Vonng/ddia/pull/371) | [@lewiszlw](https://github.com/lewiszlw) | CPU core -> CPU 核心 |
| [369](https://github.com/Vonng/ddia/pull/369) | [@bbwang-gl](https://github.com/bbwang-gl) | ch7: 可序列化快照隔離檢測一個事務何時修改另一個事務的讀取 |
| [368](https://github.com/Vonng/ddia/pull/368) | [@yhao3](https://github.com/yhao3) | 更新 zh-tw.py 與 zh-tw 內容 |
| [367](https://github.com/Vonng/ddia/pull/367) | [@yhao3](https://github.com/yhao3) | 修正拼寫、格式和標點問題 |
| [366](https://github.com/Vonng/ddia/pull/366) | [@yangshangde](https://github.com/yangshangde) | ch8: 將“電源失敗”改為“電源失效” |
| [365](https://github.com/Vonng/ddia/pull/365) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化“儲存與計算分離”相關翻譯 |
| [364](https://github.com/Vonng/ddia/issues/364) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化“儲存與計算分離”相關翻譯 |
| [363](https://github.com/Vonng/ddia/pull/363) | [@xyohn](https://github.com/xyohn) | #362: 最佳化一處翻譯 |
| [362](https://github.com/Vonng/ddia/issues/362) | [@xyohn](https://github.com/xyohn) | ch1: 最佳化一處翻譯 |
| [359](https://github.com/Vonng/ddia/pull/359) | [@c25423](https://github.com/c25423) | ch10: 修正一處拼寫錯誤 |
| [358](https://github.com/Vonng/ddia/pull/358) | [@lewiszlw](https://github.com/lewiszlw) | ch4: 修正一處拼寫錯誤 |
| [356](https://github.com/Vonng/ddia/pull/356) | [@lewiszlw](https://github.com/lewiszlw) | ch2: 修正一處標點錯誤 |

View file

@ -62,10 +62,20 @@ markup:
table: true # 启用 Markdown 表格
taskList: true # 启用任务列表 [ ] / [x]
typographer: true # 智能排版(引号、破折号等)
passthrough:
enable: true # 允许将数学定界符透传给 Hextra 的数学渲染器
delimiters:
block:
- ['\[', '\]']
- ['$$', '$$']
inline:
- ['\(', '\)']
parser:
attribute: true # 允许在标题后写 {#id .class key=val},用于显式锚点
autoHeadingID: true # 为标题自动生成 ID手写 {#id} 会覆盖自动生成)
autoHeadingIDType: github # 自动 ID 规则github / blackfriday / none
renderer:
unsafe: true # 允许 Markdown 中的原生 HTML如 <a>、<details>)按原样渲染
tableOfContents:
startLevel: 2 # ToC 从 h2 开始
endLevel: 4 # ToC 到 h4 结束