add v1 images

This commit is contained in:
Feng Ruohang 2025-08-10 18:26:22 +08:00
parent a89c4c8f87
commit ea527cdc8f
131 changed files with 10517 additions and 147 deletions

View File

@ -1,6 +1,8 @@
# 设计数据密集型应用(第二版) - 中文翻译版
[![Webite: ddia](https://img.shields.io/badge/在线阅读-ddia.vonng.com-slategray?style=flat)](https://ddia.vonng.com)
[![Webite: ddia](https://img.shields.io/badge/简体中文-第二版-slategray?style=flat)](https://ddia.vonng.com)
[![Webite: ddia](https://img.shields.io/badge/简体中文-第一版-slategray?style=flat)](https://ddia.vonng.com/v1)
[![Webite: ddia](https://img.shields.io/badge/繁体中文-第二版-slategray?style=flat)](https://ddia.vonng.com/tw)
[![GitHub Stars](https://img.shields.io/github/stars/Vonng/ddia?style=flat&logo=github&logoColor=black&color=slategray)](https://star-history.com/#Vonng/ddia&Date)
**作者** [Martin Kleppmann](https://martin.kleppmann.com)[《Designing Data-Intensive Applications 2nd Edition》](https://learning.oreilly.com/library/view/designing-data-intensive-applications/9781098119058/ch01.html) 英国剑桥大学分布式系统研究员,演讲者,博主和开源贡献者,软件工程师和企业家,曾在 LinkedIn 和 Rapportive 负责数据基础架构。
@ -11,7 +13,7 @@
**阅读**:访问 [https://ddia.vonng.com](https://ddia.vonng.com) 阅读本书在线版本,或使用 [hugo](https://gohugo.io/documentation/) / [hextra](https://imfing.github.io/hextra/zh-cn/) 主题自行构建。
**通知**DDIA [**第二版**](https://ddia2.vonng.com) 正在翻译中 ([`v2`](https://github.com/Vonng/ddia/tree/v2) 分支),欢迎加入并提出您的宝贵意见!
**通知**DDIA [**第二版**](https://ddia.vonng.com) 正在翻译中 ([`v2`](https://github.com/Vonng/ddia/tree/v2) 分支),欢迎加入并提出您的宝贵意见![点击此处阅览第一版](https://ddia.vonng.com/v1)。
---------

View File

@ -91,7 +91,7 @@ Replication can also help improve performance. We discuss replication in [Chapte
These are separate mechanisms, but they often go hand in hand, as illustrated in [Figure II-1](#fig_replication_partitioning).
{{< figure src="/fig/ddia_08.png" id="fig_replication_partitioning" caption="*Figure II-1. A database split into two partitions, with two replicas per partition." class="w-full my-4" >}}
{{< figure src="/v1/ddia_08.png" id="fig_replication_partitioning" caption="*Figure II-1. A database split into two partitions, with two replicas per partition." class="w-full my-4" >}}
With an understanding of those concepts, we can discuss the difficult trade-offs that you need to make in a distributed system.
Well discuss *transactions* in [Chapter 8](/en/ch8), as that will help you understand all the many things that can go wrong in a data system, and what you can do about them.

View File

@ -19,7 +19,7 @@ PostgreSQL 專家,資料庫老司機,雲計算泥石流。
**校訂** [@yingang](https://github.com/yingang) [繁體中文](/tw) **版本維護** by [@afunTW](https://github.com/afunTW) [完整貢獻者列表](/contrib)
> [!NOTE]
> **DDIA 第二版** 正在翻譯中 ([`v2`](https://github.com/Vonng/ddia/tree/v2) 分支),歡迎加入並提出您的寶貴意見!
> **DDIA 第二版** 正在翻譯中 ([`main`](https://github.com/Vonng/ddia/tree/main) 分支),歡迎加入並提出您的寶貴意見![點選此處閱覽第一版](/v1)。

View File

@ -2,6 +2,7 @@
title: "第十一章:批處理"
linkTitle: "11. 批處理"
weight: 311
math: true
breadcrumbs: false
---
@ -240,11 +241,11 @@ MapReduce 與 Unix 命令管道的主要區別在於MapReduce 可以在多臺
在分散式計算中可以使用標準的 Unix 工具作為 Mapper 和 Reducer【25】但更常見的是它們被實現為傳統程式語言的函式。在 Hadoop MapReduce 中Mapper 和 Reducer 都是實現特定介面的 Java 類。在 MongoDB 和 CouchDB 中Mapper 和 Reducer 都是 JavaScript 函式(請參閱 “[MapReduce 查詢](/tw/ch2#MapReduce查詢)”)。
[圖 10-1](/fig/ddia_1101.png) 顯示了 Hadoop MapReduce 作業中的資料流。其並行化基於分割槽(請參閱 [第六章](/tw/ch6)):作業的輸入通常是 HDFS 中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理 map 任務([圖 10-1](/fig/ddia_1101.png) 中的 m1m2 和 m3 標記)。
[圖 10-1](/v1/ddia_1001.png) 顯示了 Hadoop MapReduce 作業中的資料流。其並行化基於分割槽(請參閱 [第六章](/v1/ch6)):作業的輸入通常是 HDFS 中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理 map 任務([圖 10-1](/v1/ddia_1001.png) 中的 m1m2 和 m3 標記)。
每個輸入檔案的大小通常是數百兆位元組。MapReduce 排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個 Mapper只要該機器有足夠的備用 RAM 和 CPU 資源來執行 Mapper 任務【26】。這個原則被稱為 **將計算放在資料附近**【27】它節省了透過網路複製輸入檔案的開銷減少網路負載並增加區域性。
![](/fig/ddia_1101.png)
![](/v1/ddia_1001.png)
**圖 10-1 具有三個 Mapper 和三個 Reducer 的 MapReduce 任務**
@ -292,7 +293,7 @@ Hadoop 的各種高階工具(如 Pig 【30】、Hive 【31】、Cascading 【3
[圖 10-2](/fig/ddia_1102.png) 給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為 **活動事件**,即 activity events**點選流資料**,即 clickstream data右側是使用者資料庫。你可以將此示例看作是星型模式的一部分請參閱 “[星型和雪花型:分析的模式](/tw/ch3#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。
![](/fig/ddia_1102.png)
![](/v1/ddia_1002.png)
**圖 10-2 使用者行為日誌與使用者檔案的連線**
@ -306,7 +307,7 @@ Hadoop 的各種高階工具(如 Pig 【30】、Hive 【31】、Cascading 【3
#### 排序合併連線
回想一下Mapper 的目的是從每個輸入記錄中提取一對鍵值。在 [圖 10-2](/fig/ddia_1102.png) 的情況下,這個鍵就是使用者 ID一組 Mapper 會掃過活動事件(提取使用者 ID 作為鍵,活動事件作為值),而另一組 Mapper 將會掃過使用者資料庫(提取使用者 ID 作為鍵,使用者的出生日期作為值)。這個過程如 [圖 10-3](/fig/ddia_1103.png) 所示。
回想一下Mapper 的目的是從每個輸入記錄中提取一對鍵值。在 [圖 10-2](/v1/ddia_1002.png) 的情況下,這個鍵就是使用者 ID一組 Mapper 會掃過活動事件(提取使用者 ID 作為鍵,活動事件作為值),而另一組 Mapper 將會掃過使用者資料庫(提取使用者 ID 作為鍵,使用者的出生日期作為值)。這個過程如 [圖 10-3](/v1/ddia_1003.png) 所示。
![](/fig/ddia_1103.png)
@ -603,8 +604,7 @@ Spark、Flink 和 Tez 避免將中間狀態寫入 HDFS因此它們採取了
> 像 Spark、Flink 和 Tez 這樣的資料流引擎(請參閱 “[物化中間狀態](#物化中間狀態)”)通常將運算元作為 **有向無環圖DAG** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流** 被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂!
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。
我們在 [圖 3-7](/ch3/#fig_datalog_naive) 中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為 **傳遞閉包**,即 transitive closure
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在 [圖 2-6](/v1/ddia_0206.png) 中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為 **傳遞閉包**,即 transitive closure
可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種 “重複至完成” 的想法不能用普通的 MapReduce 來表示,因為它只掃過一趟資料。這種演算法因此經常以 **迭代** 的風格實現:

View File

@ -100,17 +100,15 @@ breadcrumbs: false
#### 多個消費者
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 11-1](/fig/ddia_1201.png) 所示:
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 11-1](/v1/ddia_1101.png) 所示:
* 負載均衡load balancing
負載均衡load balancing
: 每條訊息都被傳遞給消費者 **之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在 AMQP 中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在 JMS 中則稱之為 **共享訂閱**,即 shared subscription
每條訊息都被傳遞給消費者 **之一**,所以處理該主題下訊息的工作能被多個消費者共享。代理可以為消費者任意分配訊息。當處理訊息的代價高昂,希望能並行處理訊息時,此模式非常有用(在 AMQP 中,可以透過讓多個客戶端從同一個佇列中消費來實現負載均衡,而在 JMS 中則稱之為 **共享訂閱**,即 shared subscription
扇出fan-out
: 每條訊息都被傳遞給 **所有** 消費者。扇出允許幾個獨立的消費者各自 “收聽” 相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 JMS 中的主題訂閱與 AMQP 中的交叉繫結提供了這一功能)。
* 扇出fan-out
每條訊息都被傳遞給 **所有** 消費者。扇出允許幾個獨立的消費者各自 “收聽” 相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 JMS 中的主題訂閱與 AMQP 中的交叉繫結提供了這一功能)。
![](/fig/ddia_1201.png)
![](/v1/ddia_1101.png)
**圖 11-1 a負載平衡在消費者間共享消費主題b扇出將每條訊息傳遞給多個消費者。**
@ -122,9 +120,9 @@ breadcrumbs: false
如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。(請注意可能發生這樣的情況,訊息 **實際上是** 處理完畢的,但 **確認** 在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在 “[實踐中的分散式事務](/tw/ch9#實踐中的分散式事務)” 中所討論的那樣)
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在 [圖 11-2](/fig/ddia_1202.png) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1結果消費者 1 按照 m4m3m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在[圖 11-2](/v1/ddia_1102.png) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1結果消費者 1 按照 m4m3m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
![](/fig/ddia_1202.png)
![](/v1/ddia_1102.png)
**圖 11-2 在處理 m3 時消費者 2 崩潰,因此稍後重傳至消費者 1**
@ -150,9 +148,9 @@ breadcrumbs: false
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第六章](/tw/ch6) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 11-3](/fig/ddia_1203.png) 所示。
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**offset在 [圖 11-3](/fig/ddia_1203.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**offset在 [圖 11-3](/v1/ddia_1103.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
![](/fig/ddia_1203.png)
![](/v1/ddia_1103.png)
**圖 11-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案**
@ -228,9 +226,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是 **雙寫dual write**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 11-4](/fig/ddia_1204.png) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X客戶端 1 想要將值設定為 A客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫然後將其寫入到搜尋索引。因為運氣不好這些請求的時序是交錯的資料庫首先看到來自客戶端 1 的寫入將值設定為 A然後來自客戶端 2 的寫入將值設定為 B因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤這兩個系統現在也永久地不一致了。
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 11-4](/v1/ddia_1104.png) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X客戶端 1 想要將值設定為 A客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫然後將其寫入到搜尋索引。因為運氣不好這些請求的時序是交錯的資料庫首先看到來自客戶端 1 的寫入將值設定為 A然後來自客戶端 2 的寫入將值設定為 B因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤這兩個系統現在也永久地不一致了。
![](/fig/ddia_1204.png)
![](/v1/ddia_1104.png)
**圖 11-4 在資料庫中 X 首先被設定為 A然後被設定為 B而在搜尋索引處寫入以相反的順序到達**
@ -238,7 +236,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(請參閱 “[原子提交與兩階段提交](/tw/ch9#原子提交與兩階段提交)”)。
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 11-4](/fig/ddia_1204.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch5#多主複製)”)。
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 11-4](/v1/ddia_1104.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch5#多主複製)”)。
如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎?
@ -250,9 +248,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
最近,人們對 **變更資料捕獲change data capture, CDC** 越來越感興趣這是一種觀察寫入資料庫的所有資料變更並將其提取並轉換為可以複製到其他系統中的形式的過程。CDC 是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他派生資料系統只是變更流的消費者,如 [圖 11-5](/fig/ddia_1205.png) 所示。
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他衍生資料系統只是變更流的消費者,如 [圖 11-5](/v1/ddia_1105.png) 所示。
![](/fig/ddia_1205.png)
![](/v1/ddia_1105.png)
**圖 11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統**
@ -260,7 +258,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
我們可以將日誌消費者叫做 **派生資料系統**,正如在 [第三部分](/tw/part-iii) 的介紹中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是 **記錄系統** 資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在派生資料系統中,以便派生系統具有資料的準確副本。
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 11-2](/fig/ddia_1202.png) 的重新排序問題)。
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 11-2](/v1/ddia_1102.png) 的重新排序問題)。
資料庫觸發器可用來實現變更資料捕獲(請參閱 “[基於觸發器的複製](/tw/ch5#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如如何應對模式變更。
@ -280,7 +278,7 @@ LinkedIn 的 Databus【25】Facebook 的 Wormhole【26】和 Yahoo! 的 Sherp
如果你只能保留有限的歷史日誌,則每次要新增新的派生資料系統時,都需要做一次快照。但 **日誌壓縮log compaction** 提供了一個很好的備選方案。
我們之前在 “[雜湊索引](/tw/ch4#the-sstable-file-format)” 中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱 [圖 4-2](/ch4/#fig_storage_sstable_index) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
我們之前在 “[雜湊索引](/tw/ch4#雜湊索引)” 中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱 [圖 3-2](/v1/ddia_0302.png) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
在日誌結構儲存引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示該鍵被刪除並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入則先前的值將最終將被垃圾回收只有最新的值會保留下來。
@ -304,7 +302,7 @@ Kafka Connect【41】致力於將廣泛的資料庫系統的變更資料捕獲
與變更資料捕獲類似,事件溯源涉及到 **將所有對應用狀態的變更** 儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了一個不同的抽象層次上:
* 在變更資料捕獲中,應用以 **可變方式mutable way** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免 [圖 11-4](/fig/ddia_1204.png) 中的競態條件。寫入資料庫的應用不需要知道 CDC 的存在。
* 在變更資料捕獲中,應用以 **可變方式mutable way** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免 [圖 11-4](/v1/ddia_1104.png) 中的競態條件。寫入資料庫的應用不需要知道 CDC 的存在。
* 在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。在這種情況下,事件儲存是僅追加寫入的,更新與刪除是不鼓勵的或禁止的。事件被設計為旨在反映應用層面發生的事情,而不是底層的狀態變更。
事件溯源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件溯源使得應用隨時間演化更為容易,透過更容易理解事情發生的原因來幫助除錯的進行,並有利於防止應用 Bug請參閱 “[不可變事件的優點](#不可變事件的優點)”)。
@ -350,13 +348,13 @@ Kafka Connect【41】致力於將廣泛的資料庫系統的變更資料捕獲
無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌 —— **變化日誌changelog**,表示了隨時間演變的狀態。
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 11-6](/fig/ddia_1206.png) 所示【49,50,51】。這個比喻有一些侷限性例如狀態的二階導似乎沒有意義但這是考慮資料的一個實用出發點。
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 11-6](/v1/ddia_1106.png) 所示【49,50,51】。這個比喻有一些侷限性例如狀態的二階導似乎沒有意義但這是考慮資料的一個實用出發點。
$$
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt}
$$
![](/fig/ddia_1206.png)
![](/v1/ddia_1106.png)
**圖 11-6 應用當前狀態與事件流之間的關係**
@ -378,7 +376,7 @@ $$
#### 從同一事件日誌中派生多個檢視
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 11-5](/fig/ddia_1205.png)):例如,分析型資料庫 Druid 使用這種方式直接從 Kafka 攝取資料【55】Pistachio 是一個分散式的鍵值儲存,使用 Kafka 作為提交日誌【56】Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統如搜尋伺服器來說是很有意義的當系統要從分散式日誌中獲取輸入時亦然請參閱 “[保持系統同步](#保持系統同步)”)。
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 11-5](/v1/ddia_1105.png)):例如,分析型資料庫 Druid 使用這種方式直接從 Kafka 攝取資料【55】Pistachio 是一個分散式的鍵值儲存,使用 Kafka 作為提交日誌【56】Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統如搜尋伺服器來說是很有意義的當系統要從分散式日誌中獲取輸入時亦然請參閱 “[保持系統同步](#保持系統同步)”)。
新增從事件日誌到資料庫的顯式轉換能夠使應用更容易地隨時間演進如果你想要引入一個新功能以新的方式表示現有資料則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行複雜的模式遷移更容易。一旦不再需要舊的系統你可以簡單地關閉它並回收其資源【47,57】。
@ -417,7 +415,7 @@ $$
剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項:
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 11-5](/fig/ddia_1205.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#批處理工作流的輸出)” 中所討論的,它是寫入儲存系統的流等價物。
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 11-5](/v1/ddia_1105.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#批處理工作流的輸出)” 中所討論的,它是寫入儲存系統的流等價物。
2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可即時顯示的儀表板上。在這種情況下,人是流的最終消費者。
3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項 1 或 2
@ -510,9 +508,9 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
[^ii]: 感謝 Flink 社群的 Kostas Kloudas 提出這個比喻。
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 11-7](/fig/ddia_1207.png))。
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 11-7](/v1/ddia_1107.png))。
![](/fig/ddia_1207.png)
![](/v1/ddia_1107.png)
**圖 11-7 按處理時間分窗,會因為處理速率的變動引入人為因素**
@ -549,21 +547,17 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
當你知道如何確定一個事件的時間戳後下一步就是如何定義時間段的視窗。然後視窗就可以用於聚合例如事件計數或計算視窗內值的平均值。有幾種視窗很常用【79,83】
* 滾動視窗Tumbling Window
滾動視窗Tumbling Window
: 滾動視窗有著固定的長度,每個事件都僅能屬於一個視窗。例如,假設你有一個 1 分鐘的滾動視窗,則所有時間戳在 `10:03:00``10:03:59` 之間的事件會被分組到一個視窗中,`10:04:00` 和 `10:04:59` 之間的事件被分組到下一個視窗,依此類推。透過將每個事件時間戳四捨五入至最近的分鐘來確定它所屬的視窗,可以實現 1 分鐘的滾動視窗。
滾動視窗有著固定的長度,每個事件都僅能屬於一個視窗。例如,假設你有一個 1 分鐘的滾動視窗,則所有時間戳在 `10:03:00``10:03:59` 之間的事件會被分組到一個視窗中,`10:04:00` 和 `10:04:59` 之間的事件被分組到下一個視窗,依此類推。透過將每個事件時間戳四捨五入至最近的分鐘來確定它所屬的視窗,可以實現 1 分鐘的滾動視窗。
跳動視窗Hopping Window
: 跳動視窗也有著固定的長度,但允許視窗重疊以提供一些平滑。例如,一個帶有 1 分鐘跳躍步長的 5 分鐘視窗將包含 `10:03:00``10:07:59` 之間的事件,而下一個視窗將覆蓋 `10:04:00``10:08:59` 之間的事件,等等。透過首先計算 1 分鐘的滾動視窗tunmbling window然後在幾個相鄰視窗上進行聚合可以實現這種跳動視窗。
* 跳動視窗Hopping Window
滑動視窗Sliding Window
: 滑動視窗包含了彼此間距在特定時長內的所有事件。例如,一個 5 分鐘的滑動視窗應當覆蓋 `10:03:39``10:08:12` 的事件,因為它們相距不超過 5 分鐘(注意滾動視窗與步長 5 分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中,因為它們使用固定的邊界)。透過維護一個按時間排序的事件緩衝區,並不斷從視窗中移除過期的舊事件,可以實現滑動視窗。
跳動視窗也有著固定的長度,但允許視窗重疊以提供一些平滑。例如,一個帶有 1 分鐘跳躍步長的 5 分鐘視窗將包含 `10:03:00``10:07:59` 之間的事件,而下一個視窗將覆蓋 `10:04:00``10:08:59` 之間的事件,等等。透過首先計算 1 分鐘的滾動視窗tunmbling window然後在幾個相鄰視窗上進行聚合可以實現這種跳動視窗。
* 滑動視窗Sliding Window
滑動視窗包含了彼此間距在特定時長內的所有事件。例如,一個 5 分鐘的滑動視窗應當覆蓋 `10:03:39``10:08:12` 的事件,因為它們相距不超過 5 分鐘(注意滾動視窗與步長 5 分鐘的跳動視窗可能不會把這兩個事件分組到同一個視窗中,因為它們使用固定的邊界)。透過維護一個按時間排序的事件緩衝區,並不斷從視窗中移除過期的舊事件,可以實現滑動視窗。
* 會話視窗Session window
與其他視窗型別不同,會話視窗沒有固定的持續時間,而定義為:將同一使用者出現時間相近的所有事件分組在一起,而當用戶一段時間沒有活動時(例如,如果 30 分鐘內沒有事件)視窗結束。會話切分是網站分析的常見需求(請參閱 “[分組](/tw/ch11#分組)”)。
會話視窗Session window
: 與其他視窗型別不同,會話視窗沒有固定的持續時間,而定義為:將同一使用者出現時間相近的所有事件分組在一起,而當用戶一段時間沒有活動時(例如,如果 30 分鐘內沒有事件)視窗結束。會話切分是網站分析的常見需求(請參閱 “[分組](/v1/ch10#分組)”)。
### 流連線
@ -583,11 +577,11 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 流表連線(流擴充)
在 “[示例:使用者活動事件分析](/tw/ch11#示例:使用者活動事件分析)”([圖 10-2](/fig/ddia_1102.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充enriching** 活動事件。
在 “[示例:使用者活動事件分析](/v1/ch10#示例:使用者活動事件分析)”([圖 10-2](/v1/ddia_1002.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充enriching** 活動事件。
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者 ID並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在 “[示例:使用者活動事件分析](/tw/ch11#示例:使用者活動事件分析)” 一節中討論的此類遠端查詢可能會很慢並且有可能導致資料庫過載【75】。
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者 ID並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在 “[示例:使用者活動事件分析](/v1/ch10#示例:使用者活動事件分析)” 一節中討論的此類遠端查詢可能會很慢並且有可能導致資料庫過載【75】。
另一種方法是將資料庫副本載入到流處理器中,以便在本地進行查詢而無需網路往返。這種技術與我們在 “[Map 側連線](/tw/ch11#Map側連線)” 中討論的雜湊連線非常相似:如果資料庫的本地副本足夠小,則可以是記憶體中的散列表,比較大的話也可以是本地磁碟上的索引。
另一種方法是將資料庫副本載入到流處理器中,以便在本地進行查詢而無需網路往返。這種技術與我們在 “[Map 側連線](/v1/ch10#Map側連線)” 中討論的雜湊連線非常相似:如果資料庫的本地副本足夠小,則可以是記憶體中的散列表,比較大的話也可以是本地磁碟上的索引。
與批處理作業的區別在於,批處理作業使用資料庫的時間點快照作為輸入,而流處理器是長時間執行的,且資料庫的內容可能隨時間而改變,所以流處理器資料庫的本地副本需要保持更新。這個問題可以透過變更資料捕獲來解決:流處理器可以訂閱使用者檔案資料庫的更新日誌,如同活動事件流一樣。當增添或修改檔案時,流處理器會更新其本地副本。因此,我們有了兩個流之間的連線:活動事件和檔案更新。
@ -595,7 +589,7 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 表表連線(維護物化檢視)
我們在 “[描述負載](/tw/ch1#描述負載)” 中討論的推特時間線例子時說過,當用戶想要檢視他們的主頁時間線時,迭代使用者所關注人群的推文併合並它們是一個開銷巨大的操作。
我們在 “[描述負載](/v1/ch1#描述負載)” 中討論的推特時間線例子時說過,當用戶想要檢視他們的主頁時間線時,迭代使用者所關注人群的推文併合並它們是一個開銷巨大的操作。
相反,我們需要一個時間線快取:一種每個使用者的 “收件箱”,在傳送推文的時候寫入這些資訊,因而讀取時間線時只需要簡單地查詢即可。物化與維護這個快取需要處理以下事件:
@ -618,7 +612,7 @@ GROUP BY follows.follower_id
流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當底層的表發生變化時都會更新 [^iii]。
[^iii]: 如果你將流視作表的衍生物,如 [圖 11-6](/fig/ddia_1206.png) 所示而把一個連線看作是兩個表的乘法u·v那麼會發生一些有趣的事情物化連線的變化流遵循乘積法則(u·v)'= u'v + uv'。換句話說任何推文的變化量都與當前的關注聯絡在一起任何關注的變化量都與當前的推文相連線【49,50】。
[^iii]: 如果你將流視作表的衍生物,如 [圖 11-6](/v1/ddia_1106.png) 所示而把一個連線看作是兩個表的乘法u·v那麼會發生一些有趣的事情物化連線的變化流遵循乘積法則(u·v)'= u'v + uv'。換句話說任何推文的變化量都與當前的關注聯絡在一起任何關注的變化量都與當前的推文相連線【49,50】。
#### 連線的時間依賴性
@ -691,37 +685,32 @@ Storm 的 Trident 基於類似的想法來處理狀態【78】。依賴冪等性
我們花了一些時間比較兩種訊息代理:
* AMQP/JMS 風格的訊息代理
AMQP/JMS 風格的訊息代理
: 代理將單條訊息分配給消費者,消費者在成功處理單條訊息後確認訊息。訊息被確認後從代理中刪除。這種方法適合作為一種非同步形式的 RPC另請參閱 “[訊息傳遞中的資料流](/v1/ch4#訊息傳遞中的資料流)”),例如在任務佇列中,訊息處理的確切順序並不重要,而且訊息在處理完之後,不需要回頭重新讀取舊訊息。
代理將單條訊息分配給消費者,消費者在成功處理單條訊息後確認訊息。訊息被確認後從代理中刪除。這種方法適合作為一種非同步形式的 RPC另請參閱 “[訊息傳遞中的資料流](/tw/ch4#訊息傳遞中的資料流)”),例如在任務佇列中,訊息處理的確切順序並不重要,而且訊息在處理完之後,不需要回頭重新讀取舊訊息。
基於日誌的訊息代理
: 代理將一個分割槽中的所有訊息分配給同一個消費者節點,並始終以相同的順序傳遞訊息。並行是透過分割槽實現的,消費者透過存檔最近處理訊息的偏移量來跟蹤工作進度。訊息代理將訊息保留在磁碟上,因此如有必要的話,可以回跳並重新讀取舊訊息。
* 基於日誌的訊息代理
代理將一個分割槽中的所有訊息分配給同一個消費者節點,並始終以相同的順序傳遞訊息。並行是透過分割槽實現的,消費者透過存檔最近處理訊息的偏移量來跟蹤工作進度。訊息代理將訊息保留在磁碟上,因此如有必要的話,可以回跳並重新讀取舊訊息。
基於日誌的方法與資料庫中的複製日誌(請參閱 [第五章](/tw/ch5))和日誌結構儲存引擎(請參閱 [第三章](/tw/ch3))有相似之處。我們看到,這種方法對於消費輸入流,併產生衍生狀態或衍生輸出資料流的系統而言特別適用。
基於日誌的方法與資料庫中的複製日誌(請參閱 [第五章](/v1/ch5))和日誌結構儲存引擎(請參閱 [第三章](/v1/ch3))有相似之處。我們看到,這種方法對於消費輸入流,併產生衍生狀態或衍生輸出資料流的系統而言特別適用。
就流的來源而言,我們討論了幾種可能性:使用者活動事件,定期讀數的感測器,和 Feed 資料(例如,金融中的市場資料)能夠自然地表示為流。我們發現將資料庫寫入視作流也是很有用的:我們可以捕獲變更日誌 —— 即對資料庫所做的所有變更的歷史記錄 —— 隱式地透過變更資料捕獲,或顯式地透過事件溯源。日誌壓縮允許流也能保有資料庫內容的完整副本。
將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至派生系統,你能使諸如搜尋索引、快取以及分析系統這類派生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。
將資料庫表示為流為系統整合帶來了很多強大機遇。透過消費變更日誌並將其應用至衍生系統,你能使諸如搜尋索引、快取以及分析系統這類衍生資料系統不斷保持更新。你甚至能從頭開始,透過讀取從創世至今的所有變更日誌,為現有資料建立全新的檢視。
像流一樣維護狀態以及訊息重播的基礎設施,是在各種流處理框架中實現流連線和容錯的基礎。我們討論了流處理的幾種目的,包括搜尋事件模式(複雜事件處理),計算分窗聚合(流分析),以及保證生資料系統處於最新狀態(物化檢視)。
像流一樣維護狀態以及訊息重播的基礎設施,是在各種流處理框架中實現流連線和容錯的基礎。我們討論了流處理的幾種目的,包括搜尋事件模式(複雜事件處理),計算分窗聚合(流分析),以及保證生資料系統處於最新狀態(物化檢視)。
然後我們討論了在流處理中對時間進行推理的困難,包括處理時間與事件時間戳之間的區別,以及當你認為視窗已經完事之後,如何處理到達的掉隊事件的問題。
我們區分了流處理中可能出現的三種連線型別:
* 流流連線
流流連線
: 兩個輸入流都由活動事件組成,而連線運算元在某個時間視窗內搜尋相關的事件。例如,它可能會將同一個使用者 30 分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件,連線的兩側輸入可能實際上都是同一個流(**自連線**,即 self-join
兩個輸入流都由活動事件組成,而連線運算元在某個時間視窗內搜尋相關的事件。例如,它可能會將同一個使用者 30 分鐘內進行的兩個活動聯絡在一起。如果你想要找出一個流內的相關事件,連線的兩側輸入可能實際上都是同一個流(**自連線**,即 self-join
流表連線
: 一個輸入流由活動事件組成,另一個輸入流是資料庫變更日誌。變更日誌保證了資料庫的本地副本是最新的。對於每個活動事件,連線運算元將查詢資料庫,並輸出一個擴充套件的活動事件。
* 流表連線
一個輸入流由活動事件組成,另一個輸入流是資料庫變更日誌。變更日誌保證了資料庫的本地副本是最新的。對於每個活動事件,連線運算元將查詢資料庫,並輸出一個擴充套件的活動事件。
* 表表連線
兩個輸入流都是資料庫變更日誌。在這種情況下,一側的每一個變化都與另一側的最新狀態相連線。結果是兩表連線所得物化檢視的變更流。
表表連線
: 兩個輸入流都是資料庫變更日誌。在這種情況下,一側的每一個變化都與另一側的最新狀態相連線。結果是兩表連線所得物化檢視的變更流。
最後,我們討論了在流處理中實現容錯和恰好一次語義的技術。與批處理一樣,我們需要放棄任何失敗任務的部分輸出。然而由於流處理長時間執行並持續產生輸出,所以不能簡單地丟棄所有的輸出。相反,可以使用更細粒度的恢復機制,基於微批次、存檔點、事務或冪等寫入。

View File

@ -325,9 +325,9 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
### 觀察派生資料狀態
在抽象層面,上一節討論的資料流系統提供了建立派生資料集(例如搜尋索引、物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為 **寫路徑write path**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個派生資料集都會被更新,以適配寫入的資料。[圖 12-1](/fig/ddia_1301.png) 顯示了一個更新搜尋索引的例子。
在抽象層面,上一節討論的資料流系統提供了建立派生資料集(例如搜尋索引、物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為 **寫路徑write path**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個派生資料集都會被更新,以適配寫入的資料。[圖 12-1](/v1/ddia_1201.png) 顯示了一個更新搜尋索引的例子。
![](/fig/ddia_1301.png)
![](/v1/ddia_1201.png)
**圖 12-1 在搜尋索引中,寫(文件更新)遇上讀(查詢)**
@ -335,7 +335,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
總而言之,寫路徑和讀路徑涵蓋了資料的整個旅程,從收集資料開始,到使用資料結束(可能是由另一個人)。寫路徑是預計算過程的一部分 —— 即,一旦資料進入,即刻完成,無論是否有人需要看它。讀路徑是這個過程中只有當有人請求時才會發生的部分。如果你熟悉函數語言程式設計語言,則可能會注意到寫路徑類似於立即求值,讀路徑類似於惰性求值。
如 [圖 12-1](/fig/ddia_1301.png) 所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。
如 [圖 12-1](/v1/ddia_1201.png) 所示所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。
#### 物化檢視和快取

View File

@ -1,6 +1,7 @@
---
title: "2. 定義非功能性需求"
weight: 102
math: true
breadcrumbs: false
---

View File

@ -1,6 +1,7 @@
---
title: "5. 編碼與演化"
weight: 105
math: true
breadcrumbs: false
---

View File

@ -1,6 +1,7 @@
---
title: "8. 事務"
weight: 208
math: true
breadcrumbs: false
---

View File

@ -72,7 +72,7 @@ breadcrumbs: false
複製和分割槽是不同的機制,但它們經常同時使用。如 [圖 II-1](#fig_replication_partitioning) 所示。
{{< figure src="/fig/ddia_08.png" id="fig_replication_partitioning" caption="圖 II-1 一個數據庫切分為兩個分割槽,每個分割槽都有兩個副本" class="w-full my-4" >}}
{{< figure src="/v1/ddia_08.png" id="fig_replication_partitioning" caption="圖 II-1 一個數據庫切分為兩個分割槽,每個分割槽都有兩個副本" class="w-full my-4" >}}
理解了這些概念,就可以開始討論在分散式系統中需要做出的困難抉擇。[第八章](/tw/ch8) 將討論 **事務Transaction**,這對於瞭解資料系統中可能出現的各種問題,以及我們可以做些什麼很有幫助。

317
content/v1/_index.md Normal file
View File

@ -0,0 +1,317 @@
---
title: 设计数据密集型应用(第一版)
linkTitle: DDIA
cascade:
type: docs
breadcrumbs: false
---
**作者** [Martin Kleppmann](https://martin.kleppmann.com)[《Designing Data-Intensive Applications 2nd Edition》](https://learning.oreilly.com/library/view/designing-data-intensive-applications/9781098119058/ch01.html) 英国剑桥大学分布式系统研究员,演讲者,博主和开源贡献者,软件工程师和企业家,曾在 LinkedIn 和 Rapportive 负责数据基础架构。
**译者**[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
[**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](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)
> [!NOTE]
> DDIA [**第二版**](/zh) 正在翻译中 ([`content/v2`](https://github.com/Vonng/ddia/tree/main) 目录),欢迎加入并提出您的宝贵意见![点击此处阅览第二版](/zh)。
## 译序
> 不懂数据库的全栈工程师不是好架构师 —— 冯若航 / Vonng
现今尤其是在互联网领域大多数应用都属于数据密集型应用。本书从底层数据结构到顶层架构设计将数据系统设计中的精髓娓娓道来。其中的宝贵经验无论是对架构师、DBA、还是后端工程师、甚至产品经理都会有帮助。
这是一本理论结合实践的书,书中很多问题,译者在实际场景中都曾遇到过,读来让人击节扼腕。如果能早点读到这本书,该少走多少弯路啊!
这也是一本深入浅出的书,讲述概念的来龙去脉而不是卖弄定义,介绍事物发展演化历程而不是事实堆砌,将复杂的概念讲述的浅显易懂,但又直击本质不失深度。每章最后的引用质量非常好,是深入学习各个主题的绝佳索引。
本书为数据系统的设计、实现、与评价提供了很好的概念框架。读完并理解本书内容后,读者可以轻松看破大多数的技术忽悠,与技术砖家撕起来虎虎生风。
这是 2017 年译者读过最好的一本技术类书籍,这么好的书没有中文翻译,实在是遗憾。某不才,愿为先进技术文化的传播贡献一份力量。既可以深入学习有趣的技术主题,又可以锻炼中英文语言文字功底,何乐而不为?
## 前言
> 在我们的社会中,技术是一种强大的力量。数据、软件、通信可以用于坏的方面:不公平的阶级固化,损害公民权利,保护既得利益集团。但也可以用于好的方面:让底层人民发出自己的声音,让每个人都拥有机会,避免灾难。本书献给所有将技术用于善途的人们。
> 计算是一种流行文化,流行文化鄙视历史。流行文化关乎个体身份和参与感,但与合作无关。流行文化活在当下,也与过去和未来无关。我认为大部分(为了钱)编写代码的人就是这样的,他们不知道自己的文化来自哪里。
>
> —— 阿兰・凯接受 Dobb 博士的杂志采访时2012 年)
## 目录
### [序言](/preface)
### [第一部分:数据系统基础](/part-i)
* [第一章:可靠性、可伸缩性和可维护性](/v1/ch1)
* [第二章:数据模型与查询语言](/v1/ch2)
* [第三章:存储与检索](/v1/ch3)
* [第四章:编码与演化](/v1/ch4)
### [第二部分:分布式数据](/part-ii)
* [第五章:复制](/v1/ch5)
* [第六章:分区](/v1/ch6)
* [第七章:事务](/v1/ch7)
* [第八章:分布式系统的麻烦](/v1/ch8)
* [第九章:一致性与共识](/v1/ch9)
### [第三部分:衍生数据](/part-iii)
* [第十章:批处理](/v1/ch10)
* [第十一章:流处理](/v1/ch11)
* [第十二章:数据系统的未来](/v1/ch12)
### [术语表](/glossary)
### [后记](/colophon)
<br>
---------
## 法律声明
从原作者处得知,已经有简体中文的翻译计划,将于 2018 年末完成。[购买地址](https://search.jd.com/Search?keyword=设计数据密集型应用)
译者纯粹出于 **学习目的****个人兴趣** 翻译本书,不追求任何经济利益。
译者保留对此版本译文的署名权,其他权利以原作者和出版社的主张为准。
本译文只供学习研究参考之用,不得公开传播发行或用于商业用途。有能力阅读英文书籍者请购买正版支持。
---------
## 贡献
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
2. [第一章语法标点校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 与[第十章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by [@MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex)
4. 第一部分]前言ch2 校正 by [@jiajiadebug](https://github.com/Vonng/ddia/commits?author=jiajiadebug)
5. 词汇表、后记关于野猪的部分 by [@Chowss](https://github.com/Vonng/ddia/commits?author=Chowss)
6. 繁體中文版本与转换脚本 by [@afunTW](https://github.com/afunTW)
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
8. [感谢所有作出贡献,提出意见的朋友们](/contrib)
<details>
<summary><a href="https://github.com/Vonng/ddia/pulls">Pull Requests</a> & <a href="https://github.com/Vonng/ddia/issues">Issues</a></summary>
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [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: 修正一处标点错误 |
| [355](https://github.com/Vonng/ddia/pull/355) | [@DuroyGeorge](https://github.com/DuroyGeorge) | ch12: 修正一处格式错误 |
| [354](https://github.com/Vonng/ddia/pull/354) | [@justlorain](https://github.com/justlorain) | ch7: 修正一处参考链接 |
| [353](https://github.com/Vonng/ddia/pull/353) | [@fantasyczl](https://github.com/fantasyczl) | ch3&9: 修正两处引用错误 |
| [352](https://github.com/Vonng/ddia/pull/352) | [@fantasyczl](https://github.com/fantasyczl) | 支持输出为 EPUB 格式 |
| [349](https://github.com/Vonng/ddia/pull/349) | [@xiyihan0](https://github.com/xiyihan0) | ch1: 修正一处格式错误 |
| [348](https://github.com/Vonng/ddia/pull/348) | [@omegaatt36](https://github.com/omegaatt36) | ch3: 修正一处图像链接 |
| [346](https://github.com/Vonng/ddia/issues/346) | [@Vermouth1995](https://github.com/Vermouth1995) | ch1: 优化一处翻译 |
| [343](https://github.com/Vonng/ddia/pull/343) | [@kehao-chen](https://github.com/kehao-chen) | ch10: 优化一处翻译 |
| [341](https://github.com/Vonng/ddia/pull/341) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch3: 优化两处翻译 |
| [340](https://github.com/Vonng/ddia/pull/340) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch2: 优化多处翻译 |
| [338](https://github.com/Vonng/ddia/pull/338) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch1: 优化一处翻译 |
| [335](https://github.com/Vonng/ddia/pull/335) | [@kimi0230](https://github.com/kimi0230) | 修正一处繁体中文错误 |
| [334](https://github.com/Vonng/ddia/pull/334) | [@soulrrrrr](https://github.com/soulrrrrr) | ch2: 修正一处繁体中文错误 |
| [332](https://github.com/Vonng/ddia/pull/332) | [@justlorain](https://github.com/justlorain) | ch5: 修正一处翻译错误 |
| [331](https://github.com/Vonng/ddia/pull/331) | [@Lyianu](https://github.com/Lyianu) | ch9: 更正几处拼写错误 |
| [330](https://github.com/Vonng/ddia/pull/330) | [@Lyianu](https://github.com/Lyianu) | ch7: 优化一处翻译 |
| [329](https://github.com/Vonng/ddia/issues/329) | [@Lyianu](https://github.com/Lyianu) | ch6: 指出一处翻译错误 |
| [328](https://github.com/Vonng/ddia/pull/328) | [@justlorain](https://github.com/justlorain) | ch4: 更正一处翻译遗漏 |
| [326](https://github.com/Vonng/ddia/pull/326) | [@liangGTY](https://github.com/liangGTY) | ch1: 优化一处翻译 |
| [323](https://github.com/Vonng/ddia/pull/323) | [@marvin263](https://github.com/marvin263) | ch5: 优化一处翻译 |
| [322](https://github.com/Vonng/ddia/pull/322) | [@marvin263](https://github.com/marvin263) | ch8: 优化一处翻译 |
| [304](https://github.com/Vonng/ddia/pull/304) | [@spike014](https://github.com/spike014) | ch11: 优化一处翻译 |
| [298](https://github.com/Vonng/ddia/pull/298) | [@Makonike](https://github.com/Makonike) | ch11&12: 修正两处错误 |
| [284](https://github.com/Vonng/ddia/pull/284) | [@WAangzE](https://github.com/WAangzE) | ch4: 更正一处列表错误 |
| [283](https://github.com/Vonng/ddia/pull/283) | [@WAangzE](https://github.com/WAangzE) | ch3: 更正一处错别字 |
| [282](https://github.com/Vonng/ddia/pull/282) | [@WAangzE](https://github.com/WAangzE) | ch2: 更正一处公式问题 |
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@Sdot-Python](https://github.com/Sdot-Python) | ch7: 统一了 write skew 的翻译 |
| [271](https://github.com/Vonng/ddia/pull/271) | [@Makonike](https://github.com/Makonike) | ch6: 统一了 rebalancing 的翻译 |
| [270](https://github.com/Vonng/ddia/pull/270) | [@Ynjxsjmh](https://github.com/Ynjxsjmh) | ch7: 修正不一致的翻译 |
| [263](https://github.com/Vonng/ddia/pull/263) | [@zydmayday](https://github.com/zydmayday) | ch5: 修正译文中的重复单词 |
| [260](https://github.com/Vonng/ddia/pull/260) | [@haifeiWu](https://github.com/haifeiWu) | ch4: 修正部分不准确的翻译 |
| [258](https://github.com/Vonng/ddia/pull/258) | [@bestgrc](https://github.com/bestgrc) | ch3: 修正一处翻译错误 |
| [257](https://github.com/Vonng/ddia/pull/257) | [@UnderSam](https://github.com/UnderSam) | ch8: 修正一处拼写错误 |
| [256](https://github.com/Vonng/ddia/pull/256) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可串行化”相关内容的多处翻译不当 |
| [255](https://github.com/Vonng/ddia/pull/255) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可重复读”相关内容的多处翻译不当 |
| [253](https://github.com/Vonng/ddia/pull/253) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“读已提交”相关内容的多处翻译不当 |
| [246](https://github.com/Vonng/ddia/pull/246) | [@derekwu0101](https://github.com/derekwu0101) | ch3: 修正繁体中文的转译错误 |
| [245](https://github.com/Vonng/ddia/pull/245) | [@skyran1278](https://github.com/skyran1278) | ch12: 修正繁体中文的转译错误 |
| [244](https://github.com/Vonng/ddia/pull/244) | [@Axlgrep](https://github.com/Axlgrep) | ch9: 修正不通顺的翻译 |
| [242](https://github.com/Vonng/ddia/pull/242) | [@lynkeib](https://github.com/lynkeib) | ch9: 修正不通顺的翻译 |
| [241](https://github.com/Vonng/ddia/pull/241) | [@lynkeib](https://github.com/lynkeib) | ch8: 修正不正确的公式格式 |
| [240](https://github.com/Vonng/ddia/pull/240) | [@8da2k](https://github.com/8da2k) | ch9: 修正不通顺的翻译 |
| [239](https://github.com/Vonng/ddia/pull/239) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch7: 修正不一致的翻译 |
| [237](https://github.com/Vonng/ddia/pull/237) | [@zhangnew](https://github.com/zhangnew) | ch3: 修正错误的图片链接 |
| [229](https://github.com/Vonng/ddia/pull/229) | [@lis186](https://github.com/lis186) | 指出繁体中文的转译错误:复杂 |
| [226](https://github.com/Vonng/ddia/pull/226) | [@chroming](https://github.com/chroming) | ch1: 修正导航栏中的章节名称 |
| [220](https://github.com/Vonng/ddia/pull/220) | [@skyran1278](https://github.com/skyran1278) | ch9: 修正线性一致的繁体中文翻译 |
| [194](https://github.com/Vonng/ddia/pull/194) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正错误的翻译 |
| [193](https://github.com/Vonng/ddia/pull/193) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 优化译文 |
| [192](https://github.com/Vonng/ddia/pull/192) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正不一致和不通顺的翻译 |
| [190](https://github.com/Vonng/ddia/pull/190) | [@Pcrab](https://github.com/Pcrab) | ch1: 修正不准确的翻译 |
| [187](https://github.com/Vonng/ddia/pull/187) | [@narojay](https://github.com/narojay) | ch9: 修正生硬的翻译 |
| [186](https://github.com/Vonng/ddia/pull/186) | [@narojay](https://github.com/narojay) | ch8: 修正错别字 |
| [185](https://github.com/Vonng/ddia/issues/185) | [@8da2k](https://github.com/8da2k) | 指出小标题跳转的问题 |
| [184](https://github.com/Vonng/ddia/pull/184) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch10: 修正失效的网址 |
| [183](https://github.com/Vonng/ddia/pull/183) | [@OneSizeFitsQuorum](https://github.com/OneSizeFitsQuorum) | ch8: 修正错别字 |
| [182](https://github.com/Vonng/ddia/issues/182) | [@lroolle](https://github.com/lroolle) | 建议docsify的主题风格 |
| [181](https://github.com/Vonng/ddia/pull/181) | [@YunfengGao](https://github.com/YunfengGao) | ch2: 修正翻译错误 |
| [180](https://github.com/Vonng/ddia/pull/180) | [@skyran1278](https://github.com/skyran1278) | ch3: 指出繁体中文的转译错误 |
| [177](https://github.com/Vonng/ddia/pull/177) | [@exzhawk](https://github.com/exzhawk) | 支持 Github Pages 里的公式显示 |
| [176](https://github.com/Vonng/ddia/pull/176) | [@haifeiWu](https://github.com/haifeiWu) | ch2: 语义网相关翻译更正 |
| [175](https://github.com/Vonng/ddia/pull/175) | [@cwr31](https://github.com/cwr31) | ch7: 不变式相关翻译更正 |
| [174](https://github.com/Vonng/ddia/pull/174) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | README & preface: 更正不正确的中文用词和标点符号 |
| [173](https://github.com/Vonng/ddia/pull/173) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正不完整的翻译 |
| [171](https://github.com/Vonng/ddia/pull/171) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正重复的译文 |
| [169](https://github.com/Vonng/ddia/pull/169) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 更正不太通顺的翻译 |
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@DragonDriver](https://github.com/DragonDriver) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [153](https://github.com/Vonng/ddia/pull/153) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch9: 修正缩略图的错别字 |
| [152](https://github.com/Vonng/ddia/pull/152) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 除重->去重 |
| [151](https://github.com/Vonng/ddia/pull/151) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 修订sibling相关的翻译 |
| [147](https://github.com/Vonng/ddia/pull/147) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 更正一处不准确的翻译 |
| [145](https://github.com/Vonng/ddia/pull/145) | [@Hookey](https://github.com/Hookey) | 识别了当前简繁转译过程中处理不当的地方,暂通过转换脚本规避 |
| [144](https://github.com/Vonng/ddia/issues/144) | [@secret4233](https://github.com/secret4233) | ch7: 不翻译`next-key locking` |
| [143](https://github.com/Vonng/ddia/issues/143) | [@imcheney](https://github.com/imcheney) | ch3: 更新残留的机翻段落 |
| [142](https://github.com/Vonng/ddia/issues/142) | [@XIJINIAN](https://github.com/XIJINIAN) | 建议去除段首的制表符 |
| [141](https://github.com/Vonng/ddia/issues/141) | [@Flyraty](https://github.com/Flyraty) | ch5: 发现一处错误格式的章节引用 |
| [140](https://github.com/Vonng/ddia/pull/140) | [@Bowser1704](https://github.com/Bowser1704) | ch5: 修正章节Summary中多处不通顺的翻译 |
| [139](https://github.com/Vonng/ddia/pull/139) | [@Bowser1704](https://github.com/Bowser1704) | ch2&ch3: 修正多处不通顺的或错误的翻译 |
| [137](https://github.com/Vonng/ddia/pull/137) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch5&ch6: 优化多处不通顺的或错误的翻译 |
| [134](https://github.com/Vonng/ddia/pull/134) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch4: 优化多处不通顺的或错误的翻译 |
| [133](https://github.com/Vonng/ddia/pull/133) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化多处错误的或不通顺的翻译 |
| [132](https://github.com/Vonng/ddia/pull/132) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化一处容易产生歧义的翻译 |
| [131](https://github.com/Vonng/ddia/pull/131) | [@rwwg4](https://github.com/rwwg4) | ch6: 修正两处错误的翻译 |
| [129](https://github.com/Vonng/ddia/pull/129) | [@anaer](https://github.com/anaer) | ch4: 修正两处强调文本和四处代码变量名称 |
| [128](https://github.com/Vonng/ddia/pull/128) | [@meilin96](https://github.com/meilin96) | ch5: 修正一处错误的引用 |
| [126](https://github.com/Vonng/ddia/pull/126) | [@cwr31](https://github.com/cwr31) | ch10: 修正一处错误的翻译(功能 -> 函数) |
| [125](https://github.com/Vonng/ddia/pull/125) | [@dch1228](https://github.com/dch1228) | ch2: 优化 how best 的翻译(如何以最佳方式) |
| [123](https://github.com/Vonng/ddia/pull/123) | [@yingang](https://github.com/yingang) | translation updates (chapter 9, TOC in readme, glossary, etc.) |
| [121](https://github.com/Vonng/ddia/pull/121) | [@yingang](https://github.com/yingang) | translation updates (chapter 5 to chapter 8) |
| [120](https://github.com/Vonng/ddia/pull/120) | [@jiong-han](https://github.com/jiong-han) | Typo fix: 呲之以鼻 -> 嗤之以鼻 |
| [119](https://github.com/Vonng/ddia/pull/119) | [@cclauss](https://github.com/cclauss) | Streamline file operations in convert() |
| [118](https://github.com/Vonng/ddia/pull/118) | [@yingang](https://github.com/yingang) | translation updates (chapter 2 to chapter 4) |
| [117](https://github.com/Vonng/ddia/pull/117) | [@feeeei](https://github.com/feeeei) | 统一每章的标题格式 |
| [115](https://github.com/Vonng/ddia/pull/115) | [@NageNalock](https://github.com/NageNalock) | 第七章病句修改: 重复词语 |
| [114](https://github.com/Vonng/ddia/pull/114) | [@Sunt-ing](https://github.com/Sunt-ing) | Update README.md: correct the book name |
| [113](https://github.com/Vonng/ddia/pull/113) | [@lpxxn](https://github.com/lpxxn) | 修改语句 |
| [112](https://github.com/Vonng/ddia/pull/112) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [110](https://github.com/Vonng/ddia/pull/110) | [@lpxxn](https://github.com/lpxxn) | 读已写入数据 |
| [107](https://github.com/Vonng/ddia/pull/107) | [@abbychau](https://github.com/abbychau) | 單調鐘和好死还是赖活着 |
| [106](https://github.com/Vonng/ddia/pull/106) | [@enochii](https://github.com/enochii) | typo in ch2: fix braces typo |
| [105](https://github.com/Vonng/ddia/pull/105) | [@LiminCode](https://github.com/LiminCode) | Chronicle translation error |
| [104](https://github.com/Vonng/ddia/pull/104) | [@Sunt-ing](https://github.com/Sunt-ing) | several advice for better translation |
| [103](https://github.com/Vonng/ddia/pull/103) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in ch4: should be 完成 rather than 完全 |
| [102](https://github.com/Vonng/ddia/pull/102) | [@Sunt-ing](https://github.com/Sunt-ing) | ch4: better-translation: 扼杀 → 破坏 |
| [101](https://github.com/Vonng/ddia/pull/101) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in Ch4: should be "改变" rathr than "盖面" |
| [100](https://github.com/Vonng/ddia/pull/100) | [@LiminCode](https://github.com/LiminCode) | fix missing translation |
| [99 ](https://github.com/Vonng/ddia/pull/99) | [@mrdrivingduck](https://github.com/mrdrivingduck) | ch6: fix the word rebalancing |
| [98 ](https://github.com/Vonng/ddia/pull/98) | [@jacklightChen](https://github.com/jacklightChen) | fix ch7.md: fix wrong references |
| [97 ](https://github.com/Vonng/ddia/pull/97) | [@jenac](https://github.com/jenac) | 96 |
| [96 ](https://github.com/Vonng/ddia/pull/96) | [@PragmaTwice](https://github.com/PragmaTwice) | ch2: fix typo about 'may or may not be' |
| [95 ](https://github.com/Vonng/ddia/pull/95) | [@EvanMu96](https://github.com/EvanMu96) | fix translation of "the battle cry" in ch5 |
| [94 ](https://github.com/Vonng/ddia/pull/94) | [@kemingy](https://github.com/kemingy) | ch6: fix markdown and punctuations |
| [93 ](https://github.com/Vonng/ddia/pull/93) | [@kemingy](https://github.com/kemingy) | ch5: fix markdown and some typos |
| [92 ](https://github.com/Vonng/ddia/pull/92) | [@Gilbert1024](https://github.com/Gilbert1024) | Merge pull request #1 from Vonng/master |
| [88 ](https://github.com/Vonng/ddia/pull/88) | [@kemingy](https://github.com/kemingy) | fix typo for ch1, ch2, ch3, ch4 |
| [87 ](https://github.com/Vonng/ddia/pull/87) | [@wynn5a](https://github.com/wynn5a) | Update ch3.md |
| [86 ](https://github.com/Vonng/ddia/pull/86) | [@northmorn](https://github.com/northmorn) | Update ch1.md |
| [85 ](https://github.com/Vonng/ddia/pull/85) | [@sunbuhui](https://github.com/sunbuhui) | fix ch2.md: fix ch2 ambiguous translation |
| [84 ](https://github.com/Vonng/ddia/pull/84) | [@ganler](https://github.com/ganler) | Fix translation: use up |
| [83 ](https://github.com/Vonng/ddia/pull/83) | [@afunTW](https://github.com/afunTW) | Using OpenCC to convert from zh-cn to zh-tw |
| [82 ](https://github.com/Vonng/ddia/pull/82) | [@kangni](https://github.com/kangni) | fix gitbook url |
| [78 ](https://github.com/Vonng/ddia/pull/78) | [@hanyu2](https://github.com/hanyu2) | Fix unappropriated translation |
| [77 ](https://github.com/Vonng/ddia/pull/77) | [@Ozarklake](https://github.com/Ozarklake) | fix typo |
| [75 ](https://github.com/Vonng/ddia/pull/75) | [@2997ms](https://github.com/2997ms) | Fix typo |
| [74 ](https://github.com/Vonng/ddia/pull/74) | [@2997ms](https://github.com/2997ms) | Update ch9.md |
| [70 ](https://github.com/Vonng/ddia/pull/70) | [@2997ms](https://github.com/2997ms) | Update ch7.md |
| [67 ](https://github.com/Vonng/ddia/pull/67) | [@jiajiadebug](https://github.com/jiajiadebug) | fix issues in ch2 - ch9 and glossary |
| [66 ](https://github.com/Vonng/ddia/pull/66) | [@blindpirate](https://github.com/blindpirate) | Fix typo |
| [63 ](https://github.com/Vonng/ddia/pull/63) | [@haifeiWu](https://github.com/haifeiWu) | Update ch10.md |
| [62 ](https://github.com/Vonng/ddia/pull/62) | [@ych](https://github.com/ych) | fix ch1.md typesetting problem |
| [61 ](https://github.com/Vonng/ddia/pull/61) | [@xianlaioy](https://github.com/xianlaioy) | docs:钟-->种去掉ou |
| [60 ](https://github.com/Vonng/ddia/pull/60) | [@Zombo1296](https://github.com/Zombo1296) | 否则 -> 或者 |
| [59 ](https://github.com/Vonng/ddia/pull/59) | [@AlexanderMisel](https://github.com/AlexanderMisel) | 呼叫->调用,显着->显著 |
| [58 ](https://github.com/Vonng/ddia/pull/58) | [@ibyte2011](https://github.com/ibyte2011) | Update ch8.md |
| [55 ](https://github.com/Vonng/ddia/pull/55) | [@saintube](https://github.com/saintube) | ch8: 修改链接错误 |
| [54 ](https://github.com/Vonng/ddia/pull/54) | [@Panmax](https://github.com/Panmax) | Update ch2.md |
| [53 ](https://github.com/Vonng/ddia/pull/53) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [52 ](https://github.com/Vonng/ddia/pull/52) | [@hecenjie](https://github.com/hecenjie) | Update ch1.md |
| [51 ](https://github.com/Vonng/ddia/pull/51) | [@latavin243](https://github.com/latavin243) | fix 修正ch3 ch4几处翻译 |
| [50 ](https://github.com/Vonng/ddia/pull/50) | [@AlexZFX](https://github.com/AlexZFX) | 几个疏漏和格式错误 |
| [49 ](https://github.com/Vonng/ddia/pull/49) | [@haifeiWu](https://github.com/haifeiWu) | Update ch1.md |
| [48 ](https://github.com/Vonng/ddia/pull/48) | [@scaugrated](https://github.com/scaugrated) | fix typo |
| [47 ](https://github.com/Vonng/ddia/pull/47) | [@lzwill](https://github.com/lzwill) | Fixed typos in ch2 |
| [45 ](https://github.com/Vonng/ddia/pull/45) | [@zenuo](https://github.com/zenuo) | 删除一个多余的右括号 |
| [44 ](https://github.com/Vonng/ddia/pull/44) | [@akxxsb](https://github.com/akxxsb) | 修正第七章底部链接错误 |
| [43 ](https://github.com/Vonng/ddia/pull/43) | [@baijinping](https://github.com/baijinping) | "更假简单"->"更加简单" |
| [42 ](https://github.com/Vonng/ddia/pull/42) | [@tisonkun](https://github.com/tisonkun) | 修复 ch1 中的无序列表格式 |
| [38 ](https://github.com/Vonng/ddia/pull/38) | [@renjie-c](https://github.com/renjie-c) | 纠正多处的翻译小错误 |
| [37 ](https://github.com/Vonng/ddia/pull/37) | [@tankilo](https://github.com/tankilo) | fix translation mistakes in ch4.md |
| [36 ](https://github.com/Vonng/ddia/pull/36) | [@wwek](https://github.com/wwek) | 1.修复多个链接错误 2.名词优化修订 3.错误修订 |
| [35 ](https://github.com/Vonng/ddia/pull/35) | [@wwek](https://github.com/wwek) | fix ch7.md to ch8.md link error |
| [34 ](https://github.com/Vonng/ddia/pull/34) | [@wwek](https://github.com/wwek) | Merge pull request #1 from Vonng/master |
| [33 ](https://github.com/Vonng/ddia/pull/33) | [@wwek](https://github.com/wwek) | fix part-ii.md link error |
| [32 ](https://github.com/Vonng/ddia/pull/32) | [@JCYoky](https://github.com/JCYoky) | Update ch2.md |
| [31 ](https://github.com/Vonng/ddia/pull/31) | [@elsonLee](https://github.com/elsonLee) | Update ch7.md |
| [26 ](https://github.com/Vonng/ddia/pull/26) | [@yjhmelody](https://github.com/yjhmelody) | 修复一些明显错误 |
| [25 ](https://github.com/Vonng/ddia/pull/25) | [@lqbilbo](https://github.com/lqbilbo) | 修复链接错误 |
| [24 ](https://github.com/Vonng/ddia/pull/24) | [@artiship](https://github.com/artiship) | 修改词语顺序 |
| [23 ](https://github.com/Vonng/ddia/pull/23) | [@artiship](https://github.com/artiship) | 修正错别字 |
| [22 ](https://github.com/Vonng/ddia/pull/22) | [@artiship](https://github.com/artiship) | 纠正翻译错误 |
| [21 ](https://github.com/Vonng/ddia/pull/21) | [@zhtisi](https://github.com/zhtisi) | 修正目录和本章标题不符的情况 |
| [20 ](https://github.com/Vonng/ddia/pull/20) | [@rentiansheng](https://github.com/rentiansheng) | Update ch7.md |
| [19 ](https://github.com/Vonng/ddia/pull/19) | [@LHRchina](https://github.com/LHRchina) | 修复语句小bug |
| [16 ](https://github.com/Vonng/ddia/pull/16) | [@MuAlex](https://github.com/MuAlex) | Master |
| [15 ](https://github.com/Vonng/ddia/pull/15) | [@cg-zhou](https://github.com/cg-zhou) | Update translation progress |
| [14 ](https://github.com/Vonng/ddia/pull/14) | [@cg-zhou](https://github.com/cg-zhou) | Translate glossary |
| [13 ](https://github.com/Vonng/ddia/pull/13) | [@cg-zhou](https://github.com/cg-zhou) | 详细修改了后记中和印度野猪相关的描述 |
| [12 ](https://github.com/Vonng/ddia/pull/12) | [@ibyte2011](https://github.com/ibyte2011) | 修改了部分翻译 |
| [11 ](https://github.com/Vonng/ddia/pull/11) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 100% |
| [10 ](https://github.com/Vonng/ddia/pull/10) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 20% |
| [9 ](https://github.com/Vonng/ddia/pull/9) | [@jiajiadebug](https://github.com/jiajiadebug) | Preface, ch1, part-i translation minor fixes |
| [7 ](https://github.com/Vonng/ddia/pull/7) | [@MuAlex](https://github.com/MuAlex) | Ch6 translation pull request |
| [6 ](https://github.com/Vonng/ddia/pull/6) | [@MuAlex](https://github.com/MuAlex) | Ch6 change version1 |
| [5 ](https://github.com/Vonng/ddia/pull/5) | [@nevertiree](https://github.com/nevertiree) | Chapter 01语法微调 |
| [2 ](https://github.com/Vonng/ddia/pull/2) | [@seagullbird](https://github.com/seagullbird) | 序言初翻 |
</details><br />
---------
## 许可证
本项目采用 [CC-BY 4.0](https://github.com/Vonng/ddia/blob/master/LICENSE) 许可证,您可以在这里找到完整说明:
- [署名 4.0 协议国际版 CC BY 4.0 Deed](https://creativecommons.org/licenses/by/4.0/deed.zh-hans)
- [Attribution 4.0 International CC BY 4.0](https://creativecommons.org/licenses/by/4.0/deed.en)

400
content/v1/ch1.md Normal file
View File

@ -0,0 +1,400 @@
---
title: "第一章:可靠性、可伸缩性和可维护性"
linkTitle: "1. 可靠性、可伸缩性和可维护性"
weight: 101
breadcrumbs: false
---
![](/map/ch01.png)
> 互联网做得太棒了,以至于大多数人将它看作像太平洋这样的自然资源,而不是什么人工产物。上一次出现这种大规模且无差错的技术,你还记得是什么时候吗?
>
> —— [艾伦・凯](http://www.drdobbs.com/architecture-and-design/interview-with-alan-kay/240003442) 在接受 Dobb 博士杂志采访时说2012 年)
现今很多应用程序都是 **数据密集型data-intensive** 的,而非 **计算密集型compute-intensive** 的。因此 CPU 很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
数据密集型应用通常由标准组件构建而成,标准组件提供了很多通用的功能;例如,许多应用程序都需要:
- 存储数据,以便自己或其他应用程序之后能再次找到 *数据库,即 databases*
- 记住开销昂贵操作的结果,加快读取速度(*缓存,即 caches*
- 允许用户按关键字搜索数据,或以各种方式对数据进行过滤(*搜索索引,即 search indexes*
- 向其他进程发送消息,进行异步处理(*流处理,即 stream processing*
- 定期处理累积的大批量数据(*批处理,即 batch processing*
如果这些功能听上去平淡无奇,那是因为这些 **数据系统data system** 是非常成功的抽象:我们一直不假思索地使用它们并习以为常。绝大多数工程师不会幻想从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。
但现实没有这么简单。不同的应用有着不同的需求,因而数据库系统也是百花齐放,有着各式各样的特性。实现缓存有很多种手段,创建搜索索引也有好几种方法,诸如此类。因此在开发应用前,我们依然有必要先弄清楚最适合手头工作的工具和方法。而且当单个工具解决不了你的问题时,组合使用这些工具可能还是有些难度的。
本书将是一趟关于数据系统原理、实践与应用的旅程,并讲述了设计数据密集型应用的方法。我们将探索不同工具之间的共性与特性,以及各自的实现原理。
本章将从我们所要实现的基础目标开始:可靠、可伸缩、可维护的数据系统。我们将澄清这些词语的含义,概述考量这些目标的方法。并回顾一些后续章节所需的基础知识。在接下来的章节中我们将抽丝剥茧,研究设计数据密集型应用时可能遇到的设计决策。
## 关于数据系统的思考
我们通常认为,数据库、消息队列、缓存等工具分属于几个差异显著的类别。虽然数据库和消息队列表面上有一些相似性 —— 它们都会存储一段时间的数据 —— 但它们有迥然不同的访问模式,这意味着迥异的性能特征和实现手段。
那我们为什么要把这些东西放在 **数据系统data system** 的总称之下混为一谈呢?
近些年来出现了许多新的数据存储工具与数据处理工具。它们针对不同应用场景进行优化因此不再适合生硬地归入传统类别【1】。类别之间的界限变得越来越模糊例如数据存储可以被当成消息队列用Redis消息队列则带有类似数据库的持久保证Apache Kafka
其次,越来越多的应用程序有着各种严格而广泛的要求,单个工具不足以满足所有的数据处理和存储需求。取而代之的是,总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。
例如如果将缓存应用管理的缓存层Memcached 或同类产品)和全文搜索(全文搜索服务器,例如 Elasticsearch 或 Solr功能从主数据库剥离出来那么使缓存 / 索引与主数据库保持同步通常是应用代码的责任。[图 1-1](/v1/ddia_0101.png) 给出了这种架构可能的样子(细节将在后面的章节中详细介绍)。
![](/v1/ddia_0101.png)
**图 1-1 一个可能的组合使用多个组件的数据系统架构**
当你将多个工具组合在一起提供服务时,服务的接口或 **应用程序编程接口API, Application Programming Interface** 通常向客户端隐藏这些实现细节。现在,你基本上已经使用较小的通用组件创建了一个全新的、专用的数据系统。这个新的复合数据系统可能会提供特定的保证,例如:缓存在写入时会作废或更新,以便外部客户端获取一致的结果。现在你不仅是应用程序开发人员,还是数据系统设计人员了。
设计数据系统或服务时可能会遇到很多棘手的问题,例如:当系统出问题时,如何确保数据的正确性和完整性?当部分系统退化降级时,如何为客户提供始终如一的良好性能?当负载增加时,如何扩容应对?什么样的 API 才是好的 API
影响数据系统设计的因素很多,包括参与人员的技能和经验、历史遗留问题、系统路径依赖、交付时限、公司的风险容忍度、监管约束等,这些因素都需要具体问题具体分析。
本书着重讨论三个在大多数软件系统中都很重要的问题:
可靠性Reliability
: 系统在 **困境**adversity比如硬件故障、软件故障、人为错误中仍可正常工作正确完成功能并能达到期望的性能水准。请参阅 “[可靠性](#可靠性)”。
可伸缩性Scalability
: 有合理的办法应对系统的增长(数据量、流量、复杂性)。请参阅 “[可伸缩性](#可伸缩性)”。
可维护性Maintainability
: 许多不同的人(工程师、运维)在不同的生命周期,都能高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。请参阅 “[可维护性](#可维护性)”。
人们经常追求这些词汇,却没有清楚理解它们到底意味着什么。为了工程的严谨性,本章的剩余部分将探讨可靠性、可伸缩性和可维护性的含义。为实现这些目标而使用的各种技术,架构和算法将在后续的章节中研究。
## 可靠性
人们对于一个东西是否可靠,都有一个直观的想法。人们对可靠软件的典型期望包括:
* 应用程序表现出用户所期望的功能。
* 允许用户犯错,允许用户以出乎意料的方式使用软件。
* 在预期的负载和数据量下,性能满足要求。
* 系统能防止未经授权的访问和滥用。
如果所有这些在一起意味着 “正确工作”,那么可以把可靠性粗略理解为 “即使出现问题,也能继续正确工作”。
造成错误的原因叫做 **故障fault**,能预料并应对故障的系统特性可称为 **容错fault-tolerant****回弹性resilient**。“**容错**” 一词可能会产生误导,因为它暗示着系统可以容忍所有可能的错误,但在实际中这是不可能的。比方说,如果整个地球(及其上的所有服务器)都被黑洞吞噬了,想要容忍这种错误,需要把网络托管到太空中 —— 这种预算能不能批准就祝你好运了。所以在讨论容错时,只有谈论特定类型的错误才有意义。
注意 **故障fault** 不同于 **失效failure**【2】。**故障** 通常定义为系统的一部分状态偏离其标准,而 **失效** 则是系统作为一个整体停止向用户提供服务。故障的概率不可能降到零,因此最好设计容错机制以防因 **故障** 而导致 **失效**。本书中我们将介绍几种用不可靠的部件构建可靠系统的技术。
反直觉的是,在这类容错系统中,通过故意触发来 **提高** 故障率是有意义的例如在没有警告的情况下随机地杀死单个进程。许多高危漏洞实际上是由糟糕的错误处理导致的【3】因此我们可以通过故意引发故障来确保容错机制不断运行并接受考验从而提高故障自然发生时系统能正确处理的信心。Netflix 公司的 *Chaos Monkey*【4】就是这种方法的一个例子。
尽管比起 **阻止错误prevent error**,我们通常更倾向于 **容忍错误**。但也有 **预防胜于治疗** 的情况(比如不存在治疗方法时)。安全问题就属于这种情况。例如,如果攻击者破坏了系统,并获取了敏感数据,这种事是撤销不了的。但本书主要讨论的是可以恢复的故障种类,正如下面几节所述。
### 硬件故障
当想到系统失效的原因时,**硬件故障hardware faults** 总会第一个进入脑海。硬盘崩溃、内存出错、机房断电、有人拔错网线…… 任何与大型数据中心打过交道的人都会告诉你:一旦你拥有很多机器,这些事情 **总** 会发生!
据报道称,硬盘的 **平均无故障时间MTTF, mean time to failure** 约为 10 到 50 年【5】【6】。因此从数学期望上讲在拥有 10000 个磁盘的存储集群上,平均每天会有 1 个磁盘出故障。
为了减少系统的故障率,第一反应通常都是增加单个硬件的冗余度,例如:磁盘可以组建 RAID服务器可能有双路电源和热插拔 CPU数据中心可能有电池和柴油发电机作为后备电源某个组件挂掉时冗余组件可以立刻接管。这种方法虽然不能完全防止由硬件问题导致的系统失效但它简单易懂通常也足以让机器不间断运行很多年。
直到最近,硬件冗余对于大多数应用来说已经足够了,它使单台机器完全失效变得相当罕见。只要你能快速地把备份恢复到新机器上,故障停机时间对大多数应用而言都算不上灾难性的。只有少量高可用性至关重要的应用才会要求有多套硬件冗余。
但是随着数据量和应用计算需求的增加,越来越多的应用开始大量使用机器,这会相应地增加硬件故障率。此外,在类似亚马逊 AWSAmazon Web Services的一些云服务平台上虚拟机实例不可用却没有任何警告也是很常见的【7】因为云平台的设计就是优先考虑 **灵活性flexibility****弹性elasticity**[^i],而不是单机可靠性。
如果在硬件冗余的基础上进一步引入软件容错机制,那么系统在容忍整个(单台)机器故障的道路上就更进一步了。这样的系统也有运维上的便利,例如:如果需要重启机器(例如应用操作系统安全补丁),单服务器系统就需要计划停机。而允许机器失效的系统则可以一次修复一个节点,无需整个系统停机。
[^i]: 在 [应对负载的方法](#应对负载的方法) 一节定义
### 软件错误
我们通常认为硬件故障是随机的、相互独立的:一台机器的磁盘失效并不意味着另一台机器的磁盘也会失效。虽然大量硬件组件之间可能存在微弱的相关性(例如服务器机架的温度等共同的原因),但同时发生故障也是极为罕见的。
另一类错误是内部的 **系统性错误systematic error**【8】。这类错误难以预料而且因为是跨节点相关的所以比起不相关的硬件故障往往可能造成更多的 **系统失效**【5】。例子包括
* 接受特定的错误输入,便导致所有应用服务器实例崩溃的 BUG。例如 2012 年 6 月 30 日的闰秒,由于 Linux 内核中的一个错误【9】许多应用同时挂掉了。
* 失控进程会用尽一些共享资源,包括 CPU 时间、内存、磁盘空间或网络带宽。
* 系统依赖的服务变慢,没有响应,或者开始返回错误的响应。
* 级联故障一个组件中的小故障触发另一个组件中的故障进而触发更多的故障【10】。
导致这类软件故障的 BUG 通常会潜伏很长时间,直到被异常情况触发为止。这种情况意味着软件对其环境做出了某种假设 —— 虽然这种假设通常来说是正确的但由于某种原因最后不再成立了【11】。
虽然软件中的系统性故障没有速效药,但我们还是有很多小办法,例如:仔细考虑系统中的假设和交互;彻底的测试;进程隔离;允许进程崩溃并重启;测量、监控并分析生产环境中的系统行为。如果系统能够提供一些保证(例如在一个消息队列中,进入与发出的消息数量相等),那么系统就可以在运行时不断自检,并在出现 **差异discrepancy** 时报警【12】。
### 人为错误
设计并构建了软件系统的工程师是人类,维持系统运行的运维也是人类。即使他们怀有最大的善意,人类也是不可靠的。举个例子,一项关于大型互联网服务的研究发现,运维配置错误是导致服务中断的首要原因,而硬件故障(服务器或网络)仅导致了 10-25% 的服务中断【13】。
尽管人类不可靠,但怎么做才能让系统变得可靠?最好的系统会组合使用以下几种办法:
* 以最小化犯错机会的方式设计系统。例如精心设计的抽象、API 和管理后台使做对事情更容易,搞砸事情更困难。但如果接口限制太多,人们就会忽略它们的好处而想办法绕开。很难正确把握这种微妙的平衡。
* 将人们最容易犯错的地方与可能导致失效的地方 **解耦decouple**。特别是提供一个功能齐全的非生产环境 **沙箱sandbox**,使人们可以在不影响真实用户的情况下,使用真实数据安全地探索和实验。
* 在各个层次进行彻底的测试【3】从单元测试、全系统集成测试到手动测试。自动化测试易于理解已经被广泛使用特别适合用来覆盖正常情况中少见的 **边缘场景corner case**
* 允许从人为错误中简单快速地恢复,以最大限度地减少失效情况带来的影响。例如,快速回滚配置变更,分批发布新代码(以便任何意外错误只影响一小部分用户),并提供数据重算工具(以备旧的计算出错)。
* 配置详细和明确的监控,比如性能指标和错误率。在其他工程学科中这指的是 **遥测telemetry**(一旦火箭离开了地面,遥测技术对于跟踪发生的事情和理解失败是至关重要的)。监控可以向我们发出预警信号,并允许我们检查是否有任何地方违反了假设和约束。当出现问题时,指标数据对于问题诊断是非常宝贵的。
* 良好的管理实践与充分的培训 —— 一个复杂而重要的方面,但超出了本书的范围。
### 可靠性有多重要?
可靠性不仅仅是针对核电站和空中交通管制软件而言,我们也期望更多平凡的应用能可靠地运行。商务应用中的错误会导致生产力损失(也许数据报告不完整还会有法律风险),而电商网站的中断则可能会导致收入和声誉的巨大损失。
即使在 “非关键” 应用中我们也对用户负有责任。试想一位家长把所有的照片和孩子的视频储存在你的照片应用里【15】。如果数据库突然损坏他们会感觉如何他们可能会知道如何从备份恢复吗
在某些情况下,我们可能会选择牺牲可靠性来降低开发成本(例如为未经证实的市场开发产品原型)或运营成本(例如利润率极低的服务),但我们偷工减料时,应该清楚意识到自己在做什么。
## 可伸缩性
系统今天能可靠运行,并不意味未来也能可靠运行。服务 **降级degradation** 的一个常见原因是负载增加,例如:系统负载已经从一万个并发用户增长到十万个并发用户,或者从一百万增长到一千万。也许现在处理的数据量级要比过去大得多。
**可伸缩性Scalability** 是用来描述系统应对负载增长能力的术语。但是请注意,这不是贴在系统上的一维标签:说 “X 可伸缩” 或 “Y 不可伸缩” 是没有任何意义的。相反,讨论可伸缩性意味着考虑诸如 “如果系统以特定方式增长,有什么选项可以应对增长?” 和 “如何增加计算资源来处理额外的负载?” 等问题。
### 描述负载
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为 **负载参数load parameters** 的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向 Web 服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
为了使这个概念更加具体,我们以推特在 2012 年 11 月发布的数据【16】为例。推特的两个主要业务是
发布推文
: 用户可以向其粉丝发布新消息(平均 4.6k 请求 / 秒,峰值超过 12k 请求 / 秒)。
主页时间线
: 用户可以查阅他们关注的人发布的推文300k 请求 / 秒)。
处理每秒 12,000 次写入(发推文的速率峰值)还是很简单的。然而推特的伸缩性挑战并不是主要来自推特量,而是来自 **扇出fan-out**[^ii]—— 每个用户关注了很多人,也被很多人关注。
[^ii]: 扇出:从电子工程学中借用的术语,它描述了输入连接到另一个门输出的逻辑门数量。输出需要提供足够的电流来驱动所有连接的输入。在事务处理系统中,我们使用它来描述为了服务一个传入请求而需要执行其他服务的请求数量。
大体上讲,这一对操作有两种实现方式。
1. 发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如 [图 1-2](/v1/ddia_0102.png) 所示的关系型数据库中,可以编写这样的查询:
```sql
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
```
![](/v1/ddia_0102.png)
**图 1-2 推特主页时间线的关系型模式简单实现**
2. 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱([图 1-3](/v1/ddia_0103.png))。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。
![](/v1/ddia_0103.png)
**图 1-3 用于分发推特至关注者的数据流水线2012 年 11 月的负载参数【16】**
推特的第一个版本使用了方法 1但系统很难跟上主页时间线查询的负载。所以公司转向了方法 2方法 2 的效果更好,因为发推频率比查询主页时间线的频率几乎低了两个数量级,所以在这种情况下,最好在写入时做更多的工作,而在读取时做更少的工作。
然而方法 2 的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约 75 个关注者,所以每秒 4.6k 的发推写入,变成了对主页时间线缓存每秒 345k 的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过 3000 万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的 3000 万次写入!及时完成这种操作是一个巨大的挑战 —— 推特尝试在 5 秒内向粉丝发送推文。
在推特的例子中,每个用户粉丝数的分布(可能按这些用户的发推频率来加权)是探讨可伸缩性的一个关键负载参数,因为它决定了扇出负载。你的应用程序可能具有非常不同的特征,但可以采用相似的原则来考虑它的负载。
推特轶事的最终转折:现在已经稳健地实现了方法 2推特逐步转向了两种方法的混合。大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中。但是少数拥有海量粉丝的用户即名流会被排除在外。当用户读取主页时间线时分别地获取出该用户所关注的每位名流的推文再与用户的主页时间线缓存合并如方法 1 所示。这种混合方法能始终如一地提供良好性能。在 [第十二章](/v1/ch12) 中我们将重新讨论这个例子,这在覆盖更多技术层面之后。
### 描述性能
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看:
* 增加负载参数并保持系统资源CPU、内存、网络带宽等不变时系统性能将受到什么影响
* 增加负载参数并希望保持性能不变时,需要增加多少系统资源?
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
对于 Hadoop 这样的批处理系统,通常关心的是 **吞吐量throughput**,即每秒可以处理的记录数量,或者在特定规模数据集上运行作业的总时间 [^iii]。对于在线系统,通常更重要的是服务的 **响应时间response time**,即客户端发送请求到接收响应之间的时间。
[^iii]: 理想情况下,批量作业的运行时间是数据集的大小除以吞吐量。在实践中由于数据倾斜(数据不是均匀分布在每个工作进程中),需要等待最慢的任务完成,所以运行时间往往更长。
> #### 延迟和响应时间
>
> **延迟latency****响应时间response time** 经常用作同义词,但实际上它们并不一样。响应时间是客户所看到的,除了实际处理请求的时间( **服务时间service time** )之外,还包括网络延迟和排队延迟。延迟是某个请求等待处理的 **持续时长**,在此期间它处于 **休眠latent** 状态并等待服务【17】。
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值 **分布distribution**,而不是单个数值。
在 [图 1-4](/v1/ddia_0104.png) 中,每个灰条代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导致结果变化,例如:上下文切换到后台进程,网络数据包丢失与 TCP 重传垃圾收集暂停强制从磁盘读取的页面错误服务器机架中的震动【18】还有很多其他原因。
![](/v1/ddia_0104.png)
**图 1-4 展示了一个服务 100 次请求响应时间的均值与百分位数**
通常报表都会展示服务的平均响应时间。(严格来讲 “平均” 一词并不指代任何特定公式,但实际上它通常被理解为 **算术平均值arithmetic mean**:给定 n 个值,加起来除以 n )。然而如果你想知道 “**典型typical**” 响应时间,那么平均值并不是一个非常好的指标,因为它不能告诉你有多少用户实际上经历了这个延迟。
通常使用 **百分位点percentiles** 会更好。如果将响应时间列表按最快到最慢排序,那么 **中位数median** 就在正中间:举个例子,如果你的响应时间中位数是 200 毫秒,这意味着一半请求的返回时间少于 200 毫秒,另一半比这个要长。
如果想知道典型场景下用户需要等待多长时间,那么中位数是一个好的度量标准:一半用户请求的响应时间少于响应时间的中位数,另一半服务时间比中位数长。中位数也被称为第 50 百分位点,有时缩写为 p50。注意中位数是关于单个请求的如果用户同时发出几个请求在一个会话过程中或者由于一个页面中包含了多个资源则至少一个请求比中位数慢的概率远大于 50%。
为了弄清异常值有多糟糕,可以看看更高的百分位点,例如第 95、99 和 99.9 百分位点(缩写为 p95p99 和 p999。它们意味着 95%、99% 或 99.9% 的请求响应时间要比该阈值快,例如:如果第 95 百分位点响应时间是 1.5 秒,则意味着 100 个请求中的 95 个响应时间快于 1.5 秒,而 100 个请求中的 5 个响应时间超过 1.5 秒。如 [图 1-4](/v1/ddia_0104.png) 所示。
响应时间的高百分位点(也称为 **尾部延迟**,即 **tail latencies**)非常重要,因为它们直接影响用户的服务体验。例如亚马逊在描述内部服务的响应时间要求时是以 99.9 百分位点为准,即使它只影响一千个请求中的一个。这是因为请求响应最慢的客户往往也是数据最多的客户,也可以说是最有价值的客户 —— 因为他们掏钱了【19】。保证网站响应迅速对于保持客户的满意度非常重要亚马逊观察到响应时间增加 100 毫秒,销售量就减少 1%【20】而另一些报告说慢 1 秒钟会让客户满意度指标减少 16%【2122】。
另一方面,优化第 99.99 百分位点(一万个请求中最慢的一个)被认为太昂贵了,不能为亚马逊的目标带来足够好处。减小高百分位点处的响应时间相当困难,因为它很容易受到随机事件的影响,这超出了控制范围,而且效益也很小。
百分位点通常用于 **服务级别目标SLO, service level objectives****服务级别协议SLA, service level agreements**即定义服务预期性能和可用性的合同。SLA 可能会声明,如果服务响应时间的中位数小于 200 毫秒,且 99.9 百分位点低于 1 秒,则认为服务工作正常(如果响应时间更长,就认为服务不达标)。这些指标为客户设定了期望值,并允许客户在 SLA 未达标的情况下要求退款。
**排队延迟queueing delay** 通常占了高百分位点处响应时间的很大一部分。由于服务器只能并行处理少量的事务(如受其 CPU 核数的限制),所以只要有少量缓慢的请求就能阻碍后续请求的处理,这种效应有时被称为 **头部阻塞head-of-line blocking** 。即使后续请求在服务器上处理的非常迅速,由于需要等待先前请求完成,客户端最终看到的是缓慢的总体响应时间。因为存在这种效应,测量客户端的响应时间非常重要。
为测试系统的可伸缩性而人为产生负载时产生负载的客户端要独立于响应时间不断发送请求。如果客户端在发送下一个请求之前等待先前的请求完成这种行为会产生人为排队的效果使得测试时的队列比现实情况更短使测量结果产生偏差【23】。
> #### 实践中的百分位点
>
> 在多重调用的后端服务里,高百分位数变得特别重要。即使并行调用,最终用户请求仍然需要等待最慢的并行调用完成。如 [图 1-5](/v1/ddia_0105.png) 所示,只需要一个缓慢的调用就可以使整个最终用户请求变慢。即使只有一小部分后端调用速度较慢,如果最终用户请求需要多个后端调用,则获得较慢调用的机会也会增加,因此较高比例的最终用户请求速度会变慢(该效果称为尾部延迟放大,即 tail latency amplification【24】
>
> 如果你想将响应时间百分点添加到你的服务的监视仪表板则需要持续有效地计算它们。例如你可以使用滑动窗口来跟踪连续10分钟内的请求响应时间。每一分钟你都会计算出该窗口中的响应时间中值和各种百分数并将这些度量值绘制在图上。
>
> 简单的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对列表进行排序。如果对你来说效率太低,那么有一些算法能够以最小的 CPU 和内存成本如前向衰减【25】、t-digest【26】或 HdrHistogram 【27】来计算百分位数的近似值。请注意平均百分比例如减少时间分辨率或合并来自多台机器的数据在数学上没有意义 - 聚合响应时间数据的正确方法是添加直方图【28】。
![](/v1/ddia_0105.png)
**图 1-5 当一个请求需要多个后端请求时,单个后端慢请求就会拖慢整个终端用户的请求**
### 应对负载的方法
现在我们已经讨论了用于描述负载的参数和用于衡量性能的指标。可以开始认真讨论可伸缩性了:当负载参数增加时,如何保持良好的性能?
适应某个级别负载的架构不太可能应付 10 倍于此的负载。如果你正在开发一个快速增长的服务,那么每次负载发生数量级的增长时,你可能都需要重新考虑架构 —— 或者更频繁。
人们经常讨论 **纵向伸缩**scaling up也称为垂直伸缩即 vertical scaling转向更强大的机器**横向伸缩**scaling out也称为水平伸缩即 horizontal scaling将负载分布到多台小机器上之间的对立。跨多台机器分配负载也称为 “**无共享shared-nothing**” 架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向伸缩。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
有些系统是 **弹性elastic** 的,这意味着可以在检测到负载增加时自动增加计算资源,而其他系统则是手动伸缩(人工分析容量并决定向系统添加更多的机器)。如果负载 **极难预测highly unpredictable**,则弹性系统可能很有用,但手动伸缩系统更简单,并且意外操作可能会更少(请参阅 “[分区再平衡](/v1/ch6#分区再平衡)”)。
跨多台机器部署 **无状态服务stateless services** 非常简单,但将带状态的数据系统从单节点变为分布式配置则可能引入许多额外复杂度。出于这个原因,常识告诉我们应该将数据库放在单个节点上(纵向伸缩),直到伸缩成本或可用性需求迫使其改为分布式。
随着分布式系统的工具和抽象越来越好,至少对于某些类型的应用而言,这种常识可能会改变。可以预见分布式数据系统将成为未来的默认设置,即使对不处理大量数据或流量的场景也如此。本书的其余部分将介绍多种分布式数据系统,不仅讨论它们在可伸缩性方面的表现,还包括易用性和可维护性。
大规模的系统架构通常是应用特定的 —— 没有一招鲜吃遍天的通用可伸缩架构(不正式的叫法:**万金油magic scaling sauce** )。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度、响应时间要求、访问模式或者所有问题的大杂烩。
举个例子,用于处理每秒十万个请求(每个大小为 1 kB的系统与用于处理每分钟 3 个请求(每个大小为 2GB的系统看上去会非常不一样尽管两个系统有同样的数据吞吐量。
一个良好适配应用的可伸缩架构,是围绕着 **假设assumption** 建立的:哪些操作是常见的?哪些操作是罕见的?这就是所谓负载参数。如果假设最终是错误的,那么为伸缩所做的工程投入就白费了,最糟糕的是适得其反。在早期创业公司或非正式产品中,通常支持产品快速迭代的能力,要比可伸缩至未来的假想负载要重要的多。
尽管这些架构是应用程序特定的,但可伸缩的架构通常也是从通用的积木块搭建而成的,并以常见的模式排列。在本书中,我们将讨论这些构件和模式。
## 可维护性
众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债和添加新的功能。
不幸的是,许多从事软件系统行业的人不喜欢维护所谓的 **遗留legacy** 系统,—— 也许因为涉及修复其他人的错误、和过时的平台打交道,或者系统被迫使用于一些份外工作。每一个遗留系统都以自己的方式让人不爽,所以很难给出一个通用的建议来和它们打交道。
但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
可操作性Operability
: 便于运维团队保持系统平稳运行。
简单性Simplicity
: 从系统中消除尽可能多的 **复杂度complexity**,使新工程师也能轻松理解系统(注意这和用户接口的简单性不一样)。
可演化性evolvability
: 使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为 **可扩展性extensibility**、**可修改性modifiability** 或 **可塑性plasticity**
和之前提到的可靠性、可伸缩性一样,实现这些目标也没有简单的解决方案。不过我们会试着想象具有可操作性,简单性和可演化性的系统会是什么样子。
### 可操作性:人生苦短,关爱运维
有人认为,“良好的运维经常可以绕开垃圾(或不完整)软件的局限性,而再好的软件摊上垃圾运维也没法可靠运行”。尽管运维的某些方面可以,而且应该是自动化的,但在最初建立正确运作的自动化机制仍然取决于人。
运维团队对于保持软件系统顺利运行至关重要。一个优秀运维团队的典型职责如下或者更多【29】
* 监控系统的运行状况,并在服务状态不佳时快速恢复服务。
* 跟踪问题的原因,例如系统故障或性能下降。
* 及时更新软件和平台,比如安全补丁。
* 了解系统间的相互作用,以便在异常变更造成损失前进行规避。
* 预测未来的问题,并在问题出现之前加以解决(例如,容量规划)。
* 建立部署、配置、管理方面的良好实践,编写相应工具。
* 执行复杂的维护任务,例如将应用程序从一个平台迁移到另一个平台。
* 当配置变更时,维持系统的安全性。
* 定义工作流程,使运维操作可预测,并保持生产环境稳定。
* 铁打的营盘流水的兵,维持组织对系统的了解。
良好的可操作性意味着更轻松的日常工作,进而运维团队能专注于高价值的事情。数据系统可以通过各种方式使日常任务更轻松:
* 通过良好的监控,提供对系统内部状态和运行时行为的 **可见性visibility**
* 为自动化提供良好支持,将系统与标准化工具相集成。
* 避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护)。
* 提供良好的文档和易于理解的操作模型(“如果做 X会发生 Y”
* 提供良好的默认行为,但需要时也允许管理员自由覆盖默认值。
* 有条件时进行自我修复,但需要时也允许管理员手动控制系统状态。
* 行为可预测,最大限度减少意外。
### 简单性:管理复杂度
小型软件项目可以使用简单讨喜的、富表现力的代码,但随着项目越来越大,代码往往变得非常复杂,难以理解。这种复杂度拖慢了所有系统相关人员,进一步增加了维护成本。一个陷入复杂泥潭的软件项目有时被描述为 **烂泥潭a big ball of mud** 【30】。
**复杂度complexity** 有各种可能的症状,例如:状态空间激增、模块间紧密耦合、纠结的依赖关系、不一致的命名和术语、解决性能问题的 Hack、需要绕开的特例等等现在已经有很多关于这个话题的讨论【31,32,33】。
因为复杂度导致维护困难时,预算和时间安排通常会超支。在复杂的软件中进行变更,引入错误的风险也更大:当开发人员难以理解系统时,隐藏的假设、无意的后果和意外的交互就更容易被忽略。相反,降低复杂度能极大地提高软件的可维护性,因此简单性应该是构建系统的一个关键目标。
简化系统并不一定意味着减少功能;它也可以意味着消除 **额外的accidental** 的复杂度。Moseley 和 Marks【32】把 **额外复杂度** 定义为:由具体实现中涌现,而非(从用户视角看,系统所解决的)问题本身固有的复杂度。
用于消除 **额外复杂度** 的最好工具之一是 **抽象abstraction**。一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。
例如高级编程语言是一种抽象隐藏了机器码、CPU 寄存器和系统调用。SQL 也是一种抽象,隐藏了复杂的磁盘 / 内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有 **直接directly** 使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。
抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。
本书将紧盯那些允许我们将大型系统的部分提取为定义明确的、可重用的组件的优秀抽象。
### 可演化性:拥抱变化
系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:你了解了新的事实、出现意想不到的应用场景、业务优先级发生变化、用户要求新功能、新平台取代旧平台、法律或监管要求发生变化、系统增长迫使架构变化等。
在组织流程方面,**敏捷agile** 工作模式为适应变化提供了一个框架。敏捷社区还开发了对在频繁变化的环境中开发软件很有帮助的技术工具和模式,如 **测试驱动开发TDD, test-driven development****重构refactoring**
这些敏捷技术的大部分讨论都集中在相当小的规模(同一个应用中的几个代码文件)。本书将探索在更大数据系统层面上提高敏捷性的方法,可能由几个不同的应用或服务组成。例如,为了将装配主页时间线的方法从方法 1 变为方法 2你会如何 “重构” 推特的架构
修改数据系统并使其适应不断变化需求的容易程度,是与 **简单性****抽象性** 密切相关的:简单易懂的系统通常比复杂系统更容易修改。但由于这是一个非常重要的概念,我们将用一个不同的词来指代数据系统层面的敏捷性: **可演化性evolvability** 【34】。
## 本章小结
本章探讨了一些关于数据密集型应用的基本思考方式。这些原则将指导我们阅读本书的其余部分,那里将会深入技术细节。
一个应用必须满足各种需求才称得上有用。有一些 **功能需求**functional requirements即它应该做什么比如允许以各种方式存储检索搜索和处理数据以及一些 **非功能性需求**nonfunctional即通用属性例如安全性、可靠性、合规性、可伸缩性、兼容性和可维护性。在本章详细讨论了可靠性可伸缩性和可维护性。
**可靠性Reliability** 意味着即使发生故障,系统也能正常工作。故障可能发生在硬件(通常是随机的和不相关的)、软件(通常是系统性的 Bug很难处理和人类不可避免地时不时出错。**容错技术** 可以对终端用户隐藏某些类型的故障。
**可伸缩性Scalability** 意味着即使在负载增加的情况下也有保持性能的策略。为了讨论可伸缩性,我们首先需要定量描述负载和性能的方法。我们简要了解了推特主页时间线的例子,介绍描述负载的方法,并将响应时间百分位点作为衡量性能的一种方式。在可伸缩的系统中可以添加 **处理容量processing capacity** 以在高负载下保持可靠。
**可维护性Maintainability** 有许多方面,但实质上是关于工程师和运维团队的生活质量的。良好的抽象可以帮助降低复杂度,并使系统易于修改和适应新的应用场景。良好的可操作性意味着对系统的健康状态具有良好的可见性,并拥有有效的管理手段。
不幸的是,使应用可靠、可伸缩或可维护并不容易。但是某些模式和技术会不断重新出现在不同的应用中。在接下来的几章中,我们将看到一些数据系统的例子,并分析它们如何实现这些目标。
在本书后面的 [第三部分](/part-iii) 中,我们将看到一种模式:几个组件协同工作以构成一个完整的系统(如 [图 1-1](/v1/ddia_0101.png) 中的例子)
## 参考文献
1. Michael Stonebraker and Uğur Çetintemel: “['One Size Fits All': An Idea Whose Time Has Come and Gone](https://cs.brown.edu/~ugur/fits_all.pdf),” at *21st International Conference on Data Engineering* (ICDE), April 2005.
1. Walter L. Heimerdinger and Charles B. Weinstock: “[A Conceptual Framework for System Fault Tolerance](https://resources.sei.cmu.edu/asset_files/TechnicalReport/1992_005_001_16112.pdf),” Technical Report CMU/SEI-92-TR-033, Software Engineering Institute, Carnegie Mellon University, October 1992.
1. Ding Yuan, Yu Luo, Xin Zhuang, et al.: “[Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems](https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf),” at *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2014.
1. Yury Izrailevsky and Ariel Tseitlin: “[The Netflix Simian Army](https://netflixtechblog.com/the-netflix-simian-army-16e57fbab116),” *netflixtechblog.com*, July 19, 2011.
1. Daniel Ford, François Labelle, Florentina I. Popovici, et al.: “[Availability in Globally Distributed Storage Systems](http://research.google.com/pubs/archive/36737.pdf),” at *9th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2010.
1. Brian Beach: “[Hard Drive Reliability Update Sep 2014](https://www.backblaze.com/blog/hard-drive-reliability-update-september-2014/),” *backblaze.com*, September 23, 2014.
1. Laurie Voss: “[AWS: The Good, the Bad and the Ugly](https://web.archive.org/web/20160429075023/http://blog.awe.sm/2012/12/18/aws-the-good-the-bad-and-the-ugly/),” *blog.awe.sm*, December 18, 2012.
1. Haryadi S. Gunawi, Mingzhe Hao, Tanakorn Leesatapornwongsa, et al.: “[What Bugs Live in the Cloud?](http://ucare.cs.uchicago.edu/pdf/socc14-cbs.pdf),” at *5th ACM Symposium on Cloud Computing* (SoCC), November 2014. [doi:10.1145/2670979.2670986](http://dx.doi.org/10.1145/2670979.2670986)
1. Nelson Minar: “[Leap Second Crashes Half the Internet](http://www.somebits.com/weblog/tech/bad/leap-second-2012.html),” *somebits.com*, July 3, 2012.
1. Amazon Web Services: “[Summary of the Amazon EC2 and Amazon RDS Service Disruption in the US East Region](http://aws.amazon.com/message/65648/),” *aws.amazon.com*, April 29, 2011.
1. Richard I. Cook: “[How Complex Systems Fail](https://www.adaptivecapacitylabs.com/HowComplexSystemsFail.pdf),” Cognitive Technologies Laboratory, April 2000.
1. Jay Kreps: “[Getting Real About Distributed System Reliability](http://blog.empathybox.com/post/19574936361/getting-real-about-distributed-system-reliability),” *blog.empathybox.com*, March 19, 2012.
1. David Oppenheimer, Archana Ganapathi, and David A. Patterson: “[Why Do Internet Services Fail, and What Can Be Done About It?](http://static.usenix.org/legacy/events/usits03/tech/full_papers/oppenheimer/oppenheimer.pdf),” at *4th USENIX Symposium on Internet Technologies and Systems* (USITS), March 2003.
1. Nathan Marz: “[Principles of Software Engineering, Part 1](http://nathanmarz.com/blog/principles-of-software-engineering-part-1.html),” *nathanmarz.com*, April 2, 2013.
1. Michael Jurewitz: “[The Human Impact of Bugs](http://jury.me/blog/2013/3/14/the-human-impact-of-bugs),” *jury.me*, March 15, 2013.
1. Raffi Krikorian: “[Timelines at Scale](http://www.infoq.com/presentations/Twitter-Timeline-Scalability),” at *QCon San Francisco*, November 2012.
1. Martin Fowler: *Patterns of Enterprise Application Architecture*. Addison Wesley, 2002. ISBN: 978-0-321-12742-6
1. Kelly Sommers: “[After all that run around, what caused 500ms disk latency even when we replaced physical server?](https://twitter.com/kellabyte/status/532930540777635840)” *twitter.com*, November 13, 2014.
1. Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, et al.: “[Dynamo: Amazon's Highly Available Key-Value Store](http://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf),” at *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007.
1. Greg Linden: “[Make Data Useful](http://glinden.blogspot.co.uk/2006/12/slides-from-my-talk-at-stanford.html),” slides from presentation at Stanford University Data Mining class (CS345), December 2006.
1. Tammy Everts: “[The Real Cost of Slow Time vs Downtime](https://www.slideshare.net/Radware/radware-cmg2014-tammyevertsslowtimevsdowntime),” *slideshare.net*, November 5, 2014.
1. Jake Brutlag: “[Speed Matters](https://ai.googleblog.com/2009/06/speed-matters.html),” *ai.googleblog.com*, June 23, 2009.
1. Tyler Treat: “[Everything You Know About Latency Is Wrong](http://bravenewgeek.com/everything-you-know-about-latency-is-wrong/),” *bravenewgeek.com*, December 12, 2015.
1. Jeffrey Dean and Luiz André Barroso: “[The Tail at Scale](http://cacm.acm.org/magazines/2013/2/160173-the-tail-at-scale/fulltext),” *Communications of the ACM*, volume 56, number 2, pages 7480, February 2013. [doi:10.1145/2408776.2408794](http://dx.doi.org/10.1145/2408776.2408794)
1. Graham Cormode, Vladislav Shkapenyuk, Divesh Srivastava, and Bojian Xu: “[Forward Decay: A Practical Time Decay Model for Streaming Systems](http://dimacs.rutgers.edu/~graham/pubs/papers/fwddecay.pdf),” at *25th IEEE International Conference on Data Engineering* (ICDE), March 2009.
1. Ted Dunning and Otmar Ertl: “[Computing Extremely Accurate Quantiles Using t-Digests](https://github.com/tdunning/t-digest),” *github.com*, March 2014.
1. Gil Tene: “[HdrHistogram](http://www.hdrhistogram.org/),” *hdrhistogram.org*.
1. Baron Schwartz: “[Why Percentiles Dont Work the Way You Think](https://orangematter.solarwinds.com/2016/11/18/why-percentiles-dont-work-the-way-you-think/),” *solarwinds.com*, November 18, 2016.
1. James Hamilton: “[On Designing and Deploying Internet-Scale Services](https://www.usenix.org/legacy/events/lisa07/tech/full_papers/hamilton/hamilton.pdf),” at *21st Large Installation System Administration Conference* (LISA), November 2007.
1. Brian Foote and Joseph Yoder: “[Big Ball of Mud](http://www.laputan.org/pub/foote/mud.pdf),” at *4th Conference on Pattern Languages of Programs* (PLoP), September 1997.
1. Frederick P Brooks: “No Silver Bullet Essence and Accident in Software Engineering,” in *The Mythical Man-Month*, Anniversary edition, Addison-Wesley, 1995. ISBN: 978-0-201-83595-3
1. Ben Moseley and Peter Marks: “[Out of the Tar Pit](https://curtclifton.net/papers/MoseleyMarks06a.pdf),” at *BCS Software Practice Advancement* (SPA), 2006.
1. Rich Hickey: “[Simple Made Easy](http://www.infoq.com/presentations/Simple-Made-Easy),” at *Strange Loop*, September 2011.
1. Hongyu Pei Breivold, Ivica Crnkovic, and Peter J. Eriksson: “[Analyzing Software Evolvability](http://www.es.mdh.se/pdf_publications/1251.pdf),” at *32nd Annual IEEE International Computer Software and Applications Conference* (COMPSAC), July 2008. [doi:10.1109/COMPSAC.2008.50](http://dx.doi.org/10.1109/COMPSAC.2008.50)

796
content/v1/ch10.md Normal file
View File

@ -0,0 +1,796 @@
---
title: "第十章:批处理"
linkTitle: "10. 批处理"
weight: 310
breadcrumbs: false
---
![](/map/ch10.png)
> 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。
>
> —— 高德纳
在本书的前两部分中,我们讨论了很多关于 **请求****查询** 以及相应的 **响应****结果**。许多现有数据系统中都采用这种数据处理方式你发送请求指令一段时间后我们期望系统会给出一个结果。数据库、缓存、搜索索引、Web 服务器以及其他一些系统都以这种方式工作。
像这样的 **在线online** 系统,无论是浏览器请求页面还是调用远程 API 的服务,我们通常认为请求是由人类用户触发的,并且正在等待响应。他们不应该等太久,所以我们非常关注系统的响应时间(请参阅 “[描述性能](/v1/ch1#描述性能)”)。
Web 和越来越多的基于 HTTP/REST 的 API 使交互的请求 / 响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
服务(在线系统)
: 服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。
批处理系统(离线系统)
: 一个批处理系统有大量的输入数据,跑一个 **作业job** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
流处理系统(准实时系统)
: 流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时near-real-time****准在线nearline** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十一章](/v1/ch11) 讨论它。
正如我们将在本章中看到的那样批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如2004 年发布的批处理算法 Map-Reduce可能被过分热情地被称为 “造就 Google 大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用包括 Hadoop、CouchDB 和 MongoDB。
与多年前为数据仓库开发的并行处理系统【3,4】相比MapReduce 是一个相当低级别的编程模型,但它使得在商用硬件上能进行的处理规模迈上一个新的台阶。虽然 MapReduce 的重要性正在下降【5】但它仍然值得去理解因为它描绘了一幅关于批处理为什么有用以及如何做到有用的清晰图景。
实际上,批处理是一种非常古老的计算方式。早在可编程数字计算机诞生之前,打孔卡制表机(例如 1890 年美国人口普查【6】中使用的霍尔里斯机实现了半机械化的批处理形式从大量输入中汇总计算。Map-Reduce 与 1940 年代和 1950 年代广泛用于商业数据处理的机电 IBM 卡片分类机器有着惊人的相似之处【7】。正如我们所说历史总是在不断重复自己。
在本章中,我们将了解 MapReduce 和其他一些批处理算法和框架,并探索它们在现代数据系统中的作用。但首先我们将看看使用标准 Unix 工具的数据处理。即使你已经熟悉了它们Unix 的哲学也值得一读Unix 的思想和经验教训可以迁移到大规模、异构的分布式数据系统中。
## 使用Unix工具的批处理
我们从一个简单的例子开始。假设你有一台 Web 服务器,每次处理请求时都会在日志文件中附加一行。例如,使用 nginx 默认的访问日志格式,日志的一行可能如下所示:
```bash
216.58.210.78 - - [27/Feb/2015:17:55:11 +0000] "GET /css/typography.css HTTP/1.1"
200 3377 "http://martin.kleppmann.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_5)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.115 Safari/537.36"
```
(实际上这只是一行,分成多行只是为了便于阅读。)这一行中有很多信息。为了解释它,你需要了解日志格式的定义,如下所示:
```bash
$remote_addr - $remote_user [$time_local] "$request"
$status $body_bytes_sent "$http_referer" "$http_user_agent"
```
日志的这一行表明在 UTC 时间的 2015 年 2 月 27 日 17 点 55 分 11 秒,服务器从客户端 IP 地址 `216.58.210.78` 接收到对文件 `/css/typography.css` 的请求。用户没有认证,所以 `$remote_user` 被设置为连字符(`-`)。响应状态是 200即请求成功响应的大小是 3377 字节。网页浏览器是 Chrome 40它加载了这个文件是因为该文件在网址为 `http://martin.kleppmann.com/` 的页面中被引用到了。
### 简单日志分析
很多工具可以从这些日志文件生成关于网站流量的漂亮的报告,但为了练手,让我们使用基本的 Unix 功能创建自己的工具。例如,假设你想在你的网站上找到五个最受欢迎的网页。则可以在 Unix shell 中这样做:[^i]
[^i]: 有些人认为 `cat` 这里并没有必要,因为输入文件可以直接作为 awk 的参数。但这种写法让线性管道更为显眼。
```bash
cat /var/log/nginx/access.log | #1
awk '{print $7}' | #2
sort | #3
uniq -c | #4
sort -r -n | #5
head -n 5 #6
```
1. 读取日志文件
2. 将每一行按空格分割成不同的字段,每行只输出第七个字段,恰好是请求的 URL。在我们的例子中是 `/css/typography.css`
3. 按字母顺序排列请求的 URL 列表。如果某个 URL 被请求过 n 次,那么排序后,文件将包含连续重复出现 n 次的该 URL。
4. `uniq` 命令通过检查两个相邻的行是否相同来过滤掉输入中的重复行。`-c` 则表示还要输出一个计数器:对于每个不同的 URL它会报告输入中出现该 URL 的次数。
5. 第二种排序按每行起始处的数字(`-n`)排序,这是 URL 的请求次数。然后逆序(`-r`)返回结果,大的数字在前。
6. 最后,只输出前五行(`-n 5`),并丢弃其余的。该系列命令的输出如下所示:
```bash
4189 /favicon.ico
3631 /2013/05/24/improving-security-of-ssh-private-keys.html
2124 /2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html
1369 /
915 /css/typography.css
```
如果你不熟悉 Unix 工具,上面的命令行可能看起来有点吃力,但是它非常强大。它能在几秒钟内处理几 GB 的日志文件,并且你可以根据需要轻松修改命令。例如,如果要从报告中省略 CSS 文件,可以将 awk 参数更改为 `'$7 !~ /\.css$/ {print $7}'`, 如果想统计最多的客户端 IP 地址,可以把 awk 参数改为 `'{print $1}'`,等等。
我们不会在这里详细探索 Unix 工具,但是它非常值得学习。令人惊讶的是,使用 awk、sed、grep、sort、uniq 和 xargs 的组合可以在几分钟内完成许多数据分析并且它们的性能相当的好【8】。
#### 命令链与自定义程序
除了 Unix 命令链,你还可以写一个简单的程序来做同样的事情。例如在 Ruby 中,它可能看起来像这样:
```ruby
counts = Hash.new(0) # 1
File.open('/var/log/nginx/access.log') do |file|
file.each do |line|
url = line.split[6] # 2
counts[url] += 1 # 3
end
end
top5 = counts.map{|url, count| [count, url] }.sort.reverse[0...5] # 4
top5.each{|count, url| puts "#{count} #{url}" } # 5
```
1. `counts` 是一个存储计数器的哈希表,保存了每个 URL 被浏览的次数,默认为 0。
2. 逐行读取日志,抽取每行第七个被空格分隔的字段为 URL这里的数组索引是 6因为 Ruby 的数组索引从 0 开始计数)
3. 将日志当前行中 URL 对应的计数器值加一。
4. 按计数器值(降序)对哈希表内容进行排序,并取前五位。
5. 打印出前五个条目。
这个程序并不像 Unix 管道那样简洁,但是它的可读性很强,喜欢哪一种属于口味的问题。但两者除了表面上的差异之外,执行流程也有很大差异,如果你在大文件上运行此分析,则会变得明显。
#### 排序 VS 内存中的聚合
Ruby 脚本在内存中保存了一个 URL 的哈希表,将每个 URL 映射到它出现的次数。Unix 管道没有这样的哈希表,而是依赖于对 URL 列表的排序,在这个 URL 列表中,同一个 URL 的只是简单地重复出现。
哪种方法更好?这取决于你有多少个不同的 URL。对于大多数中小型网站你可能可以为所有不同网址提供一个计数器假设我们使用 1GB 内存)。在此例中,作业的 **工作集**working set即作业需要随机访问的内存大小仅取决于不同 URL 的数量:如果日志中只有单个 URL重复出现一百万次则散列表所需的空间表就只有一个 URL 加上一个计数器的大小。当工作集足够小时,内存散列表表现良好,甚至在性能较差的笔记本电脑上也可以正常工作。
另一方面,如果作业的工作集大于可用内存,则排序方法的优点是可以高效地使用磁盘。这与我们在 “[SSTables 和 LSM 树](/v1/ch3#SSTables和LSM树)” 中讨论过的原理是一样的:数据块可以在内存中排序并作为段文件写入磁盘,然后多个排序好的段可以合并为一个更大的排序文件。归并排序具有在磁盘上运行良好的顺序访问模式。(请记住,针对顺序 I/O 进行优化是 [第三章](/v1/ch3) 中反复出现的主题,相同的模式在此重现)
GNU CoreutilsLinux中的 `sort` 程序通过溢出至磁盘的方式来自动应对大于内存的数据集,并能同时使用多个 CPU 核进行并行排序【9】。这意味着我们之前看到的简单的 Unix 命令链很容易伸缩至大数据集,且不会耗尽内存。瓶颈可能是从磁盘读取输入文件的速度。
### Unix哲学
我们可以非常容易地使用前一个例子中的一系列命令来分析日志文件,这并非巧合:事实上,这实际上是 Unix 的关键设计思想之一,而且它直至今天也仍然令人讶异地重要。让我们更深入地研究一下,以便从 Unix 中借鉴一些想法【10】。
Unix 管道的发明者道格・麦克罗伊Doug McIlroy在 1964 年首先描述了这种情况【11】“我们需要一种类似园艺胶管的方式来拼接程序 —— 当我们需要将消息从一个程序传递另一个程序时直接接上去就行。I/O 应该也按照这种方式进行 ”。水管的类比仍然在生效,通过管道连接程序的想法成为了现在被称为 **Unix 哲学** 的一部分 —— 这一组设计原则在 Unix 用户与开发者之间流行起来,该哲学在 1978 年表述如下【12,13】
1. 让每个程序都做好一件事。要做一件新的工作,写一个新程序,而不是通过添加 “功能” 让老程序复杂化。
2. 期待每个程序的输出成为另一个程序的输入。不要将无关信息混入输出。避免使用严格的列数据或二进制输入格式。不要坚持交互式输入。
3. 设计和构建软件时,即使是操作系统,也让它们能够尽早地被试用,最好在几周内完成。不要犹豫,扔掉笨拙的部分,重建它们。
4. 优先使用工具来减轻编程任务,即使必须绕道去编写工具,且在用完后很可能要扔掉大部分。
这种方法 —— 自动化,快速原型设计,增量式迭代,对实验友好,将大型项目分解成可管理的块 —— 听起来非常像今天的敏捷开发和 DevOps 运动。奇怪的是,四十年来变化不大。
`sort` 工具是一个很好的例子。可以说它比大多数编程语言标准库中的实现(它们不会利用磁盘或使用多线程,即使这样做有很大好处)要更好。然而,单独使用 `sort` 几乎没什么用。它只能与其他 Unix 工具(如 `uniq`)结合使用。
`bash` 这样的 Unix shell 可以让我们轻松地将这些小程序组合成令人讶异的强大数据处理任务。尽管这些程序中有很多是由不同人群编写的但它们可以灵活地结合在一起。Unix 如何实现这种可组合性?
#### 统一的接口
如果你希望一个程序的输出成为另一个程序的输入,那意味着这些程序必须使用相同的数据格式 —— 换句话说,一个兼容的接口。如果你希望能够将任何程序的输出连接到任何程序的输入,那意味着所有程序必须使用相同的 I/O 接口。
在 Unix 中,这种接口是一个 **文件**file更准确地说是一个文件描述符。一个文件只是一串有序的字节序列。因为这是一个非常简单的接口所以可以使用相同的接口来表示许多不同的东西文件系统上的真实文件到另一个进程Unix 套接字stdinstdout的通信通道设备驱动程序比如 `/dev/audio``/dev/lp0`),表示 TCP 连接的套接字,等等。很容易将这些设计视为理所当然的,但实际上能让这些差异巨大的东西共享一个统一的接口是非常厉害的,这使得它们可以很容易地连接在一起 [^ii]。
[^ii]: 统一接口的另一个例子是 URL 和 HTTP这是 Web 的基石。一个 URL 标识一个网站上的一个特定的东西资源你可以链接到任何其他网站的任何网址。具有网络浏览器的用户因此可以通过跟随链接在网站之间无缝跳转即使服务器可能由完全不相关的组织维护。这个原则现在似乎非常明显但它却是网络取能取得今天成就的关键。之前的系统并不是那么统一例如在公告板系统BBS时代每个系统都有自己的电话号码和波特率配置。从一个 BBS 到另一个 BBS 的引用必须以电话号码和调制解调器设置的形式;用户将不得不挂断,拨打其他 BBS然后手动找到他们正在寻找的信息。直接链接到另一个 BBS 内的一些内容当时是不可能的。
按照惯例许多但不是全部Unix 程序将这个字节序列视为 ASCII 文本。我们的日志分析示例使用了这个事实:`awk`、`sort`、`uniq` 和 `head` 都将它们的输入文件视为由 `\n`换行符ASCII `0x0A`)字符分隔的记录列表。`\n` 的选择是任意的 —— 可以说ASCII 记录分隔符 `0x1E` 本来就是一个更好的选择因为它是为了这个目的而设计的【14】但是无论如何所有这些程序都使用相同的记录分隔符允许它们互操作。
每条记录即一行输入的解析则更加模糊。Unix 工具通常通过空白或制表符将行分割成字段,但也使用 CSV逗号分隔管道分隔和其他编码。即使像 `xargs` 这样一个相当简单的工具也有六个命令行选项,用于指定如何解析输入。
ASCII 文本的统一接口大多数时候都能工作,但它不是很优雅:我们的日志分析示例使用 `{print $7}` 来提取网址,这样可读性不是很好。在理想的世界中可能是 `{print $request_url}` 或类似的东西。我们稍后会回顾这个想法。
尽管几十年后还不够完美,但统一的 Unix 接口仍然是非常出色的设计。没有多少软件能像 Unix 工具一样交互组合的这么好:你不能通过自定义分析工具轻松地将电子邮件帐户的内容和在线购物历史记录以管道传送至电子表格中,并将结果发布到社交网络或维基。今天,像 Unix 工具一样流畅地运行程序是一种例外,而不是规范。
即使是具有 **相同数据模型** 的数据库,将数据从一种数据库导出再导入到另一种数据库也并不容易。缺乏整合导致了数据的 **巴尔干化**[^译注i]。
[^译注i]: **巴尔干化Balkanization** 是一个常带有贬义的地缘政治学术语,其定义为:一个国家或政区分裂成多个互相敌对的国家或政区的过程。
#### 逻辑与布线相分离
Unix 工具的另一个特点是使用标准输入(`stdin`)和标准输出(`stdout`)。如果你运行一个程序,而不指定任何其他的东西,标准输入来自键盘,标准输出指向屏幕。但是,你也可以从文件输入和 / 或将输出重定向到文件。管道允许你将一个进程的标准输出附加到另一个进程的标准输入(有个小内存缓冲区,而不需要将整个中间数据流写入磁盘)。
如果需要,程序仍然可以直接读取和写入文件,但 Unix 方法在程序不关心特定的文件路径、只使用标准输入和标准输出时效果最好。这允许 shell 用户以任何他们想要的方式连接输入和输出;该程序不知道或不关心输入来自哪里以及输出到哪里。(人们可以说这是一种 **松耦合loose coupling****晚期绑定late binding**【15】或 **控制反转inversion of control**【16】。将输入 / 输出布线与程序逻辑分开,可以将小工具组合成更大的系统。
你甚至可以编写自己的程序,并将它们与操作系统提供的工具组合在一起。你的程序只需要从标准输入读取输入,并将输出写入标准输出,它就可以加入数据处理的管道中。在日志分析示例中,你可以编写一个将 User-Agent 字符串转换为更灵敏的浏览器标识符,或者将 IP 地址转换为国家代码的工具,并将其插入管道。`sort` 程序并不关心它是否与操作系统的另一部分或者你写的程序通信。
但是,使用 `stdin``stdout` 能做的事情是有限的。需要多个输入或输出的程序虽然可能却非常棘手。你没法将程序的输出管道连接至网络连接中【17,18】[^iii] 。如果程序直接打开文件进行读取和写入,或者将另一个程序作为子进程启动,或者打开网络连接,那么 I/O 的布线就取决于程序本身了。它仍然可以被配置(例如通过命令行选项),但在 Shell 中对输入和输出进行布线的灵活性就少了。
[^iii]: 除了使用一个单独的工具,如 `netcat``curl`。Unix 起初试图将所有东西都表示为文件,但是 BSD 套接字 API 偏离了这个惯例【17】。研究用操作系统 Plan 9 和 Inferno 在使用文件方面更加一致:它们将 TCP 连接表示为 `/net/tcp` 中的文件【18】。
#### 透明度和实验
使 Unix 工具如此成功的部分原因是,它们使查看正在发生的事情变得非常容易:
- Unix 命令的输入文件通常被视为不可变的。这意味着你可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。
- 你可以在任何时候结束管道,将管道输出到 `less`,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。
- 你可以将一个流水线阶段的输出写入文件,并将该文件用作下一阶段的输入。这使你可以重新启动后面的阶段,而无需重新运行整个管道。
因此,与关系数据库的查询优化器相比,即使 Unix 工具非常简单,但仍然非常有用,特别是对于实验而言。
然而Unix 工具的最大局限在于它们只能在一台机器上运行 —— 而 Hadoop 这样的工具即应运而生。
## MapReduce和分布式文件系统
MapReduce 有点像 Unix 工具,但分布在数千台机器上。像 Unix 工具一样,它相当简单粗暴,但令人惊异地管用。一个 MapReduce 作业可以和一个 Unix 进程相类比:它接受一个或多个输入,并产生一个或多个输出。
和大多数 Unix 工具一样,运行 MapReduce 作业通常不会修改输入,除了生成输出外没有任何副作用。输出文件以连续的方式一次性写入(一旦写入文件,不会修改任何现有的文件部分)。
虽然 Unix 工具使用 `stdin``stdout` 作为输入和输出,但 MapReduce 作业在分布式文件系统上读写文件。在 Hadoop 的 MapReduce 实现中,该文件系统被称为 **HDFSHadoop 分布式文件系统)**,一个 Google 文件系统GFS的开源实现【19】。
除 HDFS 外,还有各种其他分布式文件系统,如 GlusterFS 和 Quantcast File SystemQFS【20】。诸如 Amazon S3、Azure Blob 存储和 OpenStack Swift【21】等对象存储服务在很多方面都是相似的 [^iv]。在本章中,我们将主要使用 HDFS 作为示例,但是这些原则适用于任何分布式文件系统。
[^iv]: 一个不同之处在于,对于 HDFS可以将计算任务安排在存储特定文件副本的计算机上运行而对象存储通常将存储和计算分开。如果网络带宽是一个瓶颈从本地磁盘读取有性能优势。但是请注意如果使用纠删码Erasure Coding则会丢失局部性因为来自多台机器的数据必须进行合并以重建原始文件【20】。
与网络连接存储NAS和存储区域网络SAN架构的共享磁盘方法相比HDFS 基于 **无共享** 原则(请参阅 [第二部分](/part-ii) 的介绍)。共享磁盘存储由集中式存储设备实现,通常使用定制硬件和专用网络基础设施(如光纤通道)。而另一方面,无共享方法不需要特殊的硬件,只需要通过传统数据中心网络连接的计算机。
HDFS 在每台机器上运行了一个守护进程,它对外暴露网络服务,允许其他节点访问存储在该机器上的文件(假设数据中心中的每台通用计算机都挂载着一些磁盘)。名为 **NameNode** 的中央服务器会跟踪哪个文件块存储在哪台机器上。因此HDFS 在概念上创建了一个大型文件系统,可以使用所有运行有守护进程的机器的磁盘。
为了容忍机器和磁盘故障,文件块被复制到多台机器上。复制可能意味着多个机器上的相同数据的多个副本,如 [第五章](/v1/ch5) 中所述,或者诸如 Reed-Solomon 码这样的纠删码方案它能以比完全复制更低的存储开销来支持恢复丢失的数据【20,22】。这些技术与 RAID 相似,后者可以在连接到同一台机器的多个磁盘上提供冗余;区别在于在分布式文件系统中,文件访问和复制是在传统的数据中心网络上完成的,没有特殊的硬件。
HDFS 的可伸缩性已经很不错了:在撰写本书时,最大的 HDFS 部署运行在上万台机器上,总存储容量达数百 PB【23】。如此大的规模已经变得可行因为使用商品硬件和开源软件的 HDFS 上的数据存储和访问成本远低于在专用存储设备上支持同等容量的成本【24】。
### MapReduce作业执行
MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS 等分布式文件系统中的大型数据集。理解它的最简单方法是参考 “[简单日志分析](#简单日志分析)” 中的 Web 服务器日志分析示例。MapReduce 中的数据处理模式与此示例非常相似:
1. 读取一组输入文件,并将其分解成 **记录records**。在 Web 服务器日志示例中,每条记录都是日志中的一行(即 `\n` 是记录分隔符)。
2. 调用 Mapper 函数从每条输入记录中提取一对键值。在前面的例子中Mapper 函数是 `awk '{print $7}'`:它提取 URL`$7`)作为键,并将值留空。
3. 按键排序所有的键值对。在日志的例子中,这由第一个 `sort` 命令完成。
4. 调用 Reducer 函数遍历排序后的键值对。如果同一个键出现多次排序使它们在列表中相邻所以很容易组合这些值而不必在内存中保留很多状态。在前面的例子中Reducer 是由 `uniq -c` 命令实现的,该命令使用相同的键来统计相邻记录的数量。
这四个步骤可以作为一个 MapReduce 作业执行。步骤 2Map和 4Reduce是你编写自定义数据处理代码的地方。步骤 1将文件分解成记录由输入格式解析器处理。步骤 3 中的排序步骤隐含在 MapReduce 中 —— 你不必编写它,因为 Mapper 的输出始终在送往 Reducer 之前进行排序。
要创建 MapReduce 作业你需要实现两个回调函数Mapper 和 Reducer其行为如下请参阅 “[MapReduce 查询](/v1/ch2#MapReduce查询)”):
Mapper
: Mapper 会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括 None。它不会保留从一个输入记录到下一个记录的任何状态因此每个记录都是独立处理的。
Reducer
: MapReduce 框架拉取由 Mapper 生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用 Reducer。Reducer 可以产生输出记录(例如相同 URL 的出现次数)。
在 Web 服务器日志的例子中,我们在第 5 步中有第二个 `sort` 命令,它按请求数对 URL 进行排序。在 MapReduce 中,如果你需要第二个排序阶段,则可以通过编写第二个 MapReduce 作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来Mapper 的作用是将数据放入一个适合排序的表单中,并且 Reducer 的作用是处理已排序的数据。
#### 分布式执行MapReduce
MapReduce 与 Unix 命令管道的主要区别在于MapReduce 可以在多台机器上并行执行计算而无需编写代码来显式处理并行问题。Mapper 和 Reducer 一次只能处理一条记录;它们不需要知道它们的输入来自哪里,或者输出去往什么地方,所以框架可以处理在机器之间移动数据的复杂性。
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】但更常见的是它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](/v1/ch2#MapReduce查询)”)。
[图 10-1](/v1/ddia_1001.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/v1/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/v1/ddia_1001.png) 中的 m1m2 和 m3 标记)。
每个输入文件的大小通常是数百兆字节。MapReduce 调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个 Mapper只要该机器有足够的备用 RAM 和 CPU 资源来运行 Mapper 任务【26】。这个原则被称为 **将计算放在数据附近**【27】它节省了通过网络复制输入文件的开销减少网络负载并增加局部性。
![](/v1/ddia_1001.png)
**图 10-1 具有三个 Mapper 和三个 Reducer 的 MapReduce 任务**
在大多数情况下,应该在 Mapper 任务中运行的应用代码在将要运行它的机器上还不存在,所以 MapReduce 框架首先将代码(例如 Java 程序中的 JAR 文件)复制到适当的机器。然后启动 Map 任务并开始读取输入文件,一次将一条记录传入 Mapper 回调函数。Mapper 的输出由键值对组成。
计算的 Reduce 端也被分区。虽然 Map 任务的数量由输入文件块的数量决定,但 Reducer 的任务的数量是由作业作者配置的(它可以不同于 Map 任务的数量)。为了确保具有相同键的所有键值对最终落在相同的 Reducer 处,框架使用键的散列值来确定哪个 Reduce 任务应该接收到特定的键值对(请参阅 “[根据键的散列分区](/v1/ch6#根据键的散列分区)”)。
键值对必须进行排序,但数据集可能太大,无法在单台机器上使用常规排序算法进行排序。相反,分类是分阶段进行的。首先每个 Map 任务都按照 Reducer 对输出进行分区。每个分区都被写入 Mapper 程序的本地磁盘,使用的技术与我们在 “[SSTables 与 LSM 树](/v1/ch3#SSTables和LSM树)” 中讨论的类似。
只要当 Mapper 读取完输入文件并写完排序后的输出文件MapReduce 调度器就会通知 Reducer 可以从该 Mapper 开始获取输出文件。Reducer 连接到每个 Mapper并下载自己相应分区的有序键值对文件。按 Reducer 分区,排序,从 Mapper 向 Reducer 复制分区数据,这一整个过程被称为 **混洗shuffle**【26】一个容易混淆的术语 —— 不像洗牌,在 MapReduce 中的混洗没有随机性)。
Reduce 任务从 Mapper 获取文件,并将它们合并在一起,并保留有序特性。因此,如果不同的 Mapper 生成了键相同的记录,则在 Reducer 的输入中,这些记录将会相邻。
Reducer 调用时会收到一个键和一个迭代器作为参数迭代器会顺序地扫过所有具有该键的记录因为在某些情况可能无法完全放入内存中。Reducer 可以使用任意逻辑来处理这些记录,并且可以生成任意数量的输出记录。这些输出记录会写入分布式文件系统上的文件中(通常是在跑 Reducer 的机器本地磁盘上留一份,并在其他机器上留几份副本)。
#### MapReduce工作流
单个 MapReduce 作业可以解决的问题范围很有限。以日志分析为例,单个 MapReduce 作业可以确定每个 URL 的页面浏览次数,但无法确定最常见的 URL因为这需要第二轮排序。
因此将 MapReduce 作业链接成为 **工作流workflow** 中是极为常见的例如一个作业的输出成为下一个作业的输入。Hadoop MapReduce 框架对工作流没有特殊支持,所以这个链是通过目录名隐式实现的:第一个作业必须将其输出配置为 HDFS 中的指定目录,第二个作业必须将其输入配置为从同一个目录。从 MapReduce 框架的角度来看,这是两个独立的作业。
因此,被链接的 MapReduce 作业并没有那么像 Unix 命令管道(它直接将一个进程的输出作为另一个进程的输入,仅用一个很小的内存缓冲区)。它更像是一系列命令,其中每个命令的输出写入临时文件,下一个命令从临时文件中读取。这种设计有利也有弊,我们将在 “[物化中间状态](#物化中间状态)” 中讨论。
只有当作业成功完成后批处理作业的输出才会被视为有效的MapReduce 会丢弃失败作业的部分输出)。因此,工作流中的一项作业只有在先前的作业 —— 即生产其输入的作业 —— 成功完成后才能开始。为了处理这些作业之间的依赖,有很多针对 Hadoop 的工作流调度器被开发出来,包括 Oozie、Azkaban、Luigi、Airflow 和 Pinball 【28】。
这些调度程序还具有管理功能,在维护大量批处理作业时非常有用。在构建推荐系统时,由 50 到 100 个 MapReduce 作业组成的工作流是常见的【29】。而在大型组织中许多不同的团队可能运行不同的作业来读取彼此的输出。工具支持对于管理这样复杂的数据流而言非常重要。
Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【32】、Crunch 【33】和 FlumeJava 【34】也能自动布线组装多个 MapReduce 阶段,生成合适的工作流。
### Reduce侧连接与分组
我们在 [第二章](/v1/ch2) 中讨论了数据模型和查询语言的连接,但是我们还没有深入探讨连接是如何实现的。现在是我们再次捡起这条线索的时候了。
在许多数据集中,一条记录与另一条记录存在关联是很常见的:关系模型中的 **外键**,文档模型中的 **文档引用** 或图模型中的 **边**。当你需要同时访问这一关联的两侧(持有引用的记录与被引用的记录)时,连接就是必须的。正如 [第二章](/v1/ch2) 所讨论的,非规范化可以减少对连接的需求,但通常无法将其完全移除 [^v]。
[^v]: 我们在本书中讨论的连接通常是等值连接,即最常见的连接类型,其中记录通过与其他记录在特定字段(例如 ID中具有 **相同值** 相关联。有些数据库支持更通用的连接类型,例如使用小于运算符而不是等号运算符,但是我们没有地方来讲这些东西。
在数据库中,如果执行只涉及少量记录的查询,数据库通常会使用 **索引** 来快速定位感兴趣的记录(请参阅 [第三章](/v1/ch3))。如果查询涉及到连接,则可能涉及到查找多个索引。然而 MapReduce 没有索引的概念 —— 至少在通常意义上没有。
当 MapReduce 作业被赋予一组文件作为输入时,它读取所有这些文件的全部内容;数据库会将这种操作称为 **全表扫描**。如果你只想读取少量的记录,则全表扫描与索引查询相比,代价非常高昂。但是在分析查询中(请参阅 “[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”),通常需要计算大量记录的聚合。在这种情况下,特别是如果能在多台机器上并行处理时,扫描整个输入可能是相当合理的事情。
当我们在批处理的语境中讨论连接时,我们指的是在数据集中解析某种关联的全量存在。例如我们假设一个作业是同时处理所有用户的数据,而非仅仅是为某个特定用户查找数据(而这能通过索引更高效地完成)。
#### 示例:用户活动事件分析
[图 10-2](/v1/ddia_1002.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events**点击流数据**,即 clickstream data右侧是用户数据库。你可以将此示例看作是星型模式的一部分请参阅 “[星型和雪花型:分析的模式](/v1/ch3#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
![](/v1/ddia_1002.png)
**图 10-2 用户行为日志与用户档案的连接**
分析任务可能需要将用户活动与用户档案信息相关联:例如,如果档案包含用户的年龄或出生日期,系统就可以确定哪些页面更受哪些年龄段的用户欢迎。然而活动事件仅包含用户 ID而没有包含完整的用户档案信息。在每个活动事件中嵌入这些档案信息很可能会非常浪费。因此活动事件需要与用户档案数据库相连接。
实现这一连接的最简单方法是,逐个遍历活动事件,并为每个遇到的用户 ID 查询用户数据库在远程服务器上。这是可能的但是它的性能可能会非常差处理吞吐量将受限于受数据库服务器的往返时间本地缓存的有效性很大程度上取决于数据的分布并行运行大量查询可能会轻易压垮数据库【35】。
为了在批处理过程中实现良好的吞吐量,计算必须(尽可能)限于单台机器上进行。为待处理的每条记录发起随机访问的网络请求实在是太慢了。而且,查询远程数据库意味着批处理作业变为 **非确定的nondeterministic**,因为远程数据库中的数据可能会改变。
因此,更好的方法是获取用户数据库的副本(例如,使用 ETL 进程从数据库备份中提取数据,请参阅 “[数据仓库](/v1/ch3#数据仓库)”),并将它和用户行为日志放入同一个分布式文件系统中。然后你可以将用户数据库存储在 HDFS 中的一组文件中,而用户活动记录存储在另一组文件中,并能用 MapReduce 将所有相关记录集中到同一个地方进行高效处理。
#### 排序合并连接
回想一下Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/v1/ddia_1002.png) 的情况下,这个键就是用户 ID一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/v1/ddia_1003.png) 所示。
![](/v1/ddia_1003.png)
**图 10-3 在用户 ID 上进行的 Reduce 端连接。如果输入数据集分区为多个文件,则每个分区都会被多个 Mapper 并行处理**
当 MapReduce 框架通过键对 Mapper 输出进行分区,然后对键值对进行排序时,效果是具有相同 ID 的所有活动事件和用户记录在 Reducer 输入中彼此相邻。Map-Reduce 作业甚至可以也让这些记录排序,使 Reducer 总能先看到来自用户数据库的记录,紧接着是按时间戳顺序排序的活动事件 —— 这种技术被称为 **二次排序secondary sort**【26】。
然后 Reducer 可以容易地执行实际的连接逻辑:每个用户 ID 都会被调用一次 Reducer 函数且因为二次排序第一个值应该是来自用户数据库的出生日期记录。Reducer 将出生日期存储在局部变量中,然后使用相同的用户 ID 遍历活动事件,输出 **已观看网址****观看者年龄** 的结果对。随后的 Map-Reduce 作业可以计算每个 URL 的查看者年龄分布,并按年龄段进行聚集。
由于 Reducer 一次处理一个特定用户 ID 的所有记录,因此一次只需要将一条用户记录保存在内存中,而不需要通过网络发出任何请求。这个算法被称为 **排序合并连接sort-merge join**,因为 Mapper 的输出是按键排序的,然后 Reducer 将来自连接两侧的有序记录列表合并在一起。
#### 把相关数据放在一起
在排序合并连接中Mapper 和排序过程确保了所有对特定用户 ID 执行连接操作的必须数据都被放在同一个地方:单次调用 Reducer 的地方。预先排好了所有需要的数据Reducer 可以是相当简单的单线程代码,能够以高吞吐量和与低内存开销扫过这些记录。
这种架构可以看做Mapper 将 “消息” 发送给 Reducer。当一个 Mapper 发出一个键值对时,这个键的作用就像值应该传递到的目标地址。即使键只是一个任意的字符串(不是像 IP 地址和端口号那样的实际的网络地址),它表现的就像一个地址:所有具有相同键的键值对将被传递到相同的目标(一次 Reducer 的调用)。
使用 MapReduce 编程模型能将计算的物理网络通信层面从正确的机器获取数据从应用逻辑中剥离出来获取数据后执行处理。这种分离与数据库的典型用法形成了鲜明对比从数据库中获取数据的请求经常出现在应用代码内部【36】。由于 MapReduce 处理了所有的网络通信因此它也避免了让应用代码去担心部分故障例如另一个节点的崩溃MapReduce 在不影响应用逻辑的情况下能透明地重试失败的任务。
#### 分组
除了连接之外,“把相关数据放在一起” 的另一种常见模式是,按某个键对记录分组(如 SQL 中的 GROUP BY 子句)。所有带有相同键的记录构成一个组,而下一步往往是在每个组内进行某种聚合操作,例如:
- 统计每个组中记录的数量(例如在统计 PV 的例子中,在 SQL 中表示为 `COUNT(*)` 聚合)
- 对某个特定字段求和SQL 中的 `SUM(fieldname)`
- 按某种分级函数取出排名前 k 条记录。
使用 MapReduce 实现这种分组操作的最简单方法是设置 Mapper以便它们生成的键值对使用所需的分组键。然后分区和排序过程将所有具有相同分区键的记录导向同一个 Reducer。因此在 MapReduce 之上实现分组和连接看上去非常相似。
分组的另一个常见用途是整理特定用户会话的所有活动事件,以找出用户进行的一系列操作(称为 **会话化sessionization**【37】。例如可以使用这种分析来确定显示新版网站的用户是否比那些显示旧版本的用户更有购买欲A/B 测试),或者计算某个营销活动是否值得。
如果你有多个 Web 服务器处理用户请求,则特定用户的活动事件很可能分散在各个不同的服务器的日志文件中。你可以通过使用会话 cookie用户 ID 或类似的标识符作为分组键,以将特定用户的所有活动事件放在一起来实现会话化,与此同时,不同用户的事件仍然散布在不同的分区中。
#### 处理偏斜
如果存在与单个键关联的大量数据,则 “将具有相同键的所有记录放到相同的位置” 这种模式就被破坏了。例如在社交网络中,大多数用户可能会与几百人有连接,但少数名人可能有数百万的追随者。这种不成比例的活动数据库记录被称为 **关键对象linchpin object**【38】或 **热键hot key**
在单个 Reducer 中收集与某个名人相关的所有活动(例如他们发布内容的回复)可能导致严重的 **偏斜**(也称为 **热点**,即 hot spot—— 也就是说,一个 Reducer 必须比其他 Reducer 处理更多的记录(请参阅 “[负载偏斜与热点消除](/v1/ch6#负载偏斜与热点消除)”)。由于 MapReduce 作业只有在所有 Mapper 和 Reducer 都完成时才完成,所有后续作业必须等待最慢的 Reducer 才能启动。
如果连接的输入存在热键可以使用一些算法进行补偿。例如Pig 中的 **偏斜连接skewed join** 方法首先运行一个抽样作业Sampling Job来确定哪些键是热键【39】。连接实际执行时Mapper 会将热键的关联记录 **随机**(相对于传统 MapReduce 基于键散列的确定性方法)发送到几个 Reducer 之一。对于另外一侧的连接输入,与热键相关的记录需要被复制到 **所有** 处理该键的 Reducer 上【40】。
这种技术将处理热键的工作分散到多个 Reducer 上,这样可以使其更好地并行化,代价是需要将连接另一侧的输入记录复制到多个 Reducer 上。Crunch 中的 **分片连接sharded join** 方法与之类似,但需要显式指定热键而不是使用抽样作业。这种技术也非常类似于我们在 “[负载偏斜与热点消除](/v1/ch6#负载偏斜与热点消除)” 中讨论的技术,使用随机化来缓解分区数据库中的热点。
Hive 的偏斜连接优化采取了另一种方法。它需要在表格元数据中显式指定热键,并将与这些键相关的记录单独存放,与其它文件分开。当在该表上执行连接时,对于热键,它会使用 Map 端连接(请参阅下一节)。
当按照热键进行分组并聚合时,可以将分组分两个阶段进行。第一个 MapReduce 阶段将记录发送到随机 Reducer以便每个 Reducer 只对热键的子集执行分组,为每个键输出一个更紧凑的中间聚合结果。然后第二个 MapReduce 作业将所有来自第一阶段 Reducer 的中间聚合结果合并为每个键一个值。
### Map侧连接
上一节描述的连接算法在 Reducer 中执行实际的连接逻辑,因此被称为 Reduce 侧连接。Mapper 扮演着预处理输入数据的角色:从每个输入记录中提取键值,将键值对分配给 Reducer 分区,并按键排序。
Reduce 侧方法的优点是不需要对输入数据做任何假设无论其属性和结构如何Mapper 都可以对其预处理以备连接。然而不利的一面是,排序,复制至 Reducer以及合并 Reducer 输入,所有这些操作可能开销巨大。当数据通过 MapReduce 阶段时数据可能需要落盘好几次取决于可用的内存缓冲区【37】。
另一方面,如果你 **能** 对输入数据作出某些假设,则通过使用所谓的 Map 侧连接来加快连接速度是可行的。这种方法使用了一个裁减掉 Reducer 与排序的 MapReduce 作业,每个 Mapper 只是简单地从分布式文件系统中读取一个输入文件块,然后将输出文件写入文件系统,仅此而已。
#### 广播散列连接
适用于执行 Map 端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个 Mapper 的内存中。
例如,假设在 [图 10-2](/v1/ddia_1002.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户 ID 唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。
参与连接的较大输入的每个文件块各有一个 Mapper在 [图 10-2](/v1/ddia_1002.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
这种简单有效的算法被称为 **广播散列连接broadcast hash join****广播** 一词反映了这样一个事实,每个连接较大输入端分区的 Mapper 都会将较小输入端数据集整个读入内存中(所以较小输入实际上 “广播” 到较大数据的所有分区上),**散列** 一词反映了它使用一个散列表。Pig名为 “**复制链接replicated join**”Hive“**MapJoin**”Cascading 和 Crunch 支持这种连接。它也被诸如 Impala 的数据仓库查询引擎使用【41】。
除了将较小的连接输入加载到内存散列表中另一种方法是将较小输入存储在本地磁盘上的只读索引中【42】。索引中经常使用的部分将保留在操作系统的页面缓存中因而这种方法可以提供与内存散列表几乎一样快的随机查找性能但实际上并不需要数据集能放入内存中。
#### 分区散列连接
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](/v1/ddia_1002.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区。例如Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个 Mapper 只需要从输入两端各读取一个分区就足够了。好处是每个 Mapper 都可以在内存散列表中少放点数据。
这种方法只有当连接两端输入有相同的分区数,且两侧的记录都是使用相同的键与相同的哈希函数做分区时才适用。如果输入是由之前执行过这种分组的 MapReduce 作业生成的,那么这可能是一个合理的假设。
分区散列连接在 Hive 中称为 **Map 侧桶连接bucketed map joins【37】**
#### Map侧合并连接
如果输入数据集不仅以相同的方式进行分区,而且还基于相同的键进行 **排序**,则可适用另一种 Map 侧连接的变体。在这种情况下,输入是否小到能放入内存并不重要,因为这时候 Mapper 同样可以执行归并操作(通常由 Reducer 执行)的归并操作:按键递增的顺序依次读取两个输入文件,将具有相同键的记录配对。
如果能进行 Map 侧合并连接,这通常意味着前一个 MapReduce 作业可能一开始就已经把输入数据做了分区并进行了排序。原则上这个连接就可以在前一个作业的 Reduce 阶段进行。但使用独立的仅 Map 作业有时也是合适的,例如,分好区且排好序的中间数据集可能还会用于其他目的。
#### MapReduce工作流与Map侧连接
当下游作业使用 MapReduce 连接的输出时,选择 Map 侧连接或 Reduce 侧连接会影响输出的结构。Reduce 侧连接的输出是按照 **连接键** 进行分区和排序的,而 Map 端连接的输出则按照与较大输入相同的方式进行分区和排序(因为无论是使用分区连接还是广播连接,连接较大输入端的每个文件块都会启动一个 Map 任务)。
如前所述Map 侧连接也对输入数据集的大小,有序性和分区方式做出了更多假设。在优化连接策略时,了解分布式文件系统中数据集的物理布局变得非常重要:仅仅知道编码格式和数据存储目录的名称是不够的;你还必须知道数据是按哪些键做的分区和排序,以及分区的数量。
在 Hadoop 生态系统中,这种关于数据集分区的元数据通常在 HCatalog 和 Hive Metastore 中维护【37】。
### 批处理工作流的输出
我们已经说了很多用于实现 MapReduce 工作流的算法,但却忽略了一个重要的问题:这些处理完成之后的最终结果是什么?我们最开始为什么要跑这些作业?
在数据库查询的场景中我们将事务处理OLTP与分析两种目的区分开来请参阅 “[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”。我们看到OLTP 查询通常根据键查找少量记录,使用索引,并将其呈现给用户(比如在网页上)。另一方面,分析查询通常会扫描大量记录,执行分组与聚合,输出通常有着报告的形式:显示某个指标随时间变化的图表,或按照某种排位取前 10 项,或将一些数字细化为子类。这种报告的消费者通常是需要做出商业决策的分析师或经理。
批处理放哪里合适?它不属于事务处理,也不是分析。它和分析比较接近,因为批处理通常会扫过输入数据集的绝大部分。然而 MapReduce 作业工作流与用于分析目的的 SQL 查询是不同的(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)”)。批处理过程的输出通常不是报表,而是一些其他类型的结构。
#### 建立搜索索引
Google 最初使用 MapReduce 是为其搜索引擎建立索引,其实现为由 5 到 10 个 MapReduce 作业组成的工作流【1】。虽然 Google 后来也不仅仅是为这个目的而使用 MapReduce 【43】但如果从构建搜索索引的角度来看更能帮助理解 MapReduce。直至今日Hadoop MapReduce 仍然是为 Lucene/Solr 构建索引的好方法【44】
我们在 “[全文搜索和模糊索引](/v1/ch3#全文搜索和模糊索引)” 中简要地了解了 Lucene 这样的全文搜索索引是如何工作的:它是一个文件(关键词字典),你可以在其中高效地查找特定关键字,并找到包含该关键字的所有文档 ID 列表(文章列表)。这是一种非常简化的看法 —— 实际上,搜索索引需要各种额外数据,以便根据相关性对搜索结果进行排名、纠正拼写错误、解析同义词等等 —— 但这个原则是成立的。
如果需要对一组固定文档执行全文搜索则批处理是一种构建索引的高效方法Mapper 根据需要对文档集合进行分区,每个 Reducer 构建该分区的索引,并将索引文件写入分布式文件系统。构建这样的文档分区索引(请参阅 “[分区与次级索引](/v1/ch6#分区与次级索引)”)并行处理效果拔群。
由于按关键字查询搜索索引是只读操作,因而这些索引文件一旦创建就是不可变的。
如果索引的文档集合发生更改,一种选择是定期重跑整个索引工作流,并在完成后用新的索引文件批量替换以前的索引文件。如果只有少量的文档发生了变化,这种方法的计算成本可能会很高。但它的优点是索引过程很容易理解:文档进,索引出。
另一个选择是,可以增量建立索引。如 [第三章](/v1/ch3) 中讨论的如果要在索引中添加删除或更新文档Lucene 会写新的段文件,并在后台异步合并压缩段文件。我们将在 [第十一章](/v1/ch11) 中看到更多这种增量处理。
#### 键值存储作为批处理输出
搜索索引只是批处理工作流可能输出的一个例子。批处理的另一个常见用途是构建机器学习系统例如分类器比如垃圾邮件过滤器异常检测图像识别与推荐系统例如你可能认识的人你可能感兴趣的产品或相关的搜索【29】
这些批处理作业的输出通常是某种数据库:例如,可以通过给定用户 ID 查询该用户推荐好友的数据库,或者可以通过产品 ID 查询相关产品的数据库【45】。
这些数据库需要被处理用户请求的 Web 应用所查询,而它们通常是独立于 Hadoop 基础设施的。那么批处理过程的输出如何回到 Web 应用可以查询的数据库中呢?
最直接的选择可能是,直接在 Mapper 或 Reducer 中使用你最爱的数据库的客户端库,并从批处理作业直接写入数据库服务器,一次写入一条记录。它能工作(假设你的防火墙规则允许从你的 Hadoop 环境直接访问你的生产数据库),但这并不是一个好主意,出于以下几个原因:
- 正如前面在连接的上下文中讨论的那样,为每条记录发起一个网络请求,要比批处理任务的正常吞吐量慢几个数量级。即使客户端库支持批处理,性能也可能很差。
- MapReduce 作业经常并行运行许多任务。如果所有 Mapper 或 Reducer 都同时写入相同的输出数据库并以批处理的预期速率工作那么该数据库很可能被轻易压垮其查询性能可能变差。这可能会导致系统其他部分的运行问题【35】。
- 通常情况下MapReduce 为作业输出提供了一个干净利落的 “全有或全无” 保证:如果作业成功,则结果就是每个任务恰好执行一次所产生的输出,即使某些任务失败且必须一路重试。如果整个作业失败,则不会生成输出。然而从作业内部写入外部系统,会产生外部可见的副作用,这种副作用是不能以这种方式被隐藏的。因此,你不得不去操心对其他系统可见的部分完成的作业结果,并需要理解 Hadoop 任务尝试与预测执行的复杂性。
更好的解决方案是在批处理作业 **内** 创建一个全新的数据库,并将其作为文件写入分布式文件系统中作业的输出目录,就像上节中的搜索索引一样。这些数据文件一旦写入就是不可变的,可以批量加载到处理只读查询的服务器中。不少键值存储都支持在 MapReduce 作业中构建数据库文件,包括 Voldemort 【46】、Terrapin 【47】、ElephantDB 【48】和 HBase 批量加载【49】。
构建这些数据库文件是 MapReduce 的一种好用法:使用 Mapper 提取出键并按该键排序已经完成了构建索引所必需的大量工作。由于这些键值存储大多都是只读的文件只能由批处理作业一次性写入然后就不可变所以数据结构非常简单。比如它们就不需要预写式日志WAL请参阅 “[让 B 树更可靠](/v1/ch3#让B树更可靠)”)。
将数据加载到 Voldemort 时服务器将继续用旧数据文件服务请求同时将新数据文件从分布式文件系统复制到服务器的本地磁盘。一旦复制完成服务器会自动将查询切换到新文件。如果在这个过程中出现任何问题它可以轻易回滚至旧文件因为它们仍然存在而且不可变【46】。
#### 批处理输出的哲学
本章前面讨论过的 Unix 哲学(“[Unix 哲学](#Unix哲学)”)鼓励以显式指明数据流的方式进行实验:程序读取输入并写入输出。在这一过程中,输入保持不变,任何先前的输出都被新输出完全替换,且没有其他副作用。这意味着你可以随心所欲地重新运行一个命令,略做改动或进行调试,而不会搅乱系统的状态。
MapReduce 作业的输出处理遵循同样的原理。通过将输入视为不可变且避免副作用(如写入外部数据库),批处理作业不仅实现了良好的性能,而且更容易维护:
- 如果在代码中引入了一个错误,而输出错误或损坏了,则可以简单地回滚到代码的先前版本,然后重新运行该作业,输出将重新被纠正。或者,甚至更简单,你可以将旧的输出保存在不同的目录中,然后切换回原来的目录。具有读写事务的数据库没有这个属性:如果你部署了错误的代码,将错误的数据写入数据库,那么回滚代码将无法修复数据库中的数据。(能够从错误代码中恢复的概念被称为 **人类容错human fault tolerance**【50】
- 由于回滚很容易,比起在错误意味着不可挽回的伤害的环境,功能开发进展能快很多。这种 **最小化不可逆性minimizing irreversibility** 的原则有利于敏捷软件开发【51】。
- 如果 Map 或 Reduce 任务失败MapReduce 框架将自动重新调度,并在同样的输入上再次运行它。如果失败是由代码中的错误造成的,那么它会不断崩溃,并最终导致作业在几次尝试之后失败。但是如果故障是由于临时问题导致的,那么故障就会被容忍。因为输入不可变,这种自动重试是安全的,而失败任务的输出会被 MapReduce 框架丢弃。
- 同一组文件可用作各种不同作业的输入,包括计算指标的监控作业并且评估作业的输出是否具有预期的性质(例如,将其与前一次运行的输出进行比较并测量差异) 。
- 与 Unix 工具类似MapReduce 作业将逻辑与布线(配置输入和输出目录)分离,这使得关注点分离,可以重用代码:一个团队可以专注实现一个做好一件事的作业;而其他团队可以决定何时何地运行这项作业。
在这些领域,在 Unix 上表现良好的设计原则似乎也适用于 Hadoop但 Unix 和 Hadoop 在某些方面也有所不同。例如,因为大多数 Unix 工具都假设输入输出是无类型文本文件,所以它们必须做大量的输入解析工作(本章开头的日志分析示例使用 `{print $7}` 来提取 URL。在 Hadoop 上可以通过使用更结构化的文件格式消除一些低价值的语法转换:比如 Avro请参阅 “[Avro](/v1/ch4#Avro)”)和 Parquet请参阅 “[列式存储](/v1/ch3#列式存储)”)经常使用,因为它们提供了基于模式的高效编码,并允许模式随时间推移而演进(见 [第四章](/v1/ch4))。
### Hadoop与分布式数据库的对比
正如我们所看到的Hadoop 有点像 Unix 的分布式版本,其中 HDFS 是文件系统,而 MapReduce 是 Unix 进程的怪异实现(总是在 Map 阶段和 Reduce 阶段运行 `sort` 工具)。我们了解了如何在这些原语的基础上实现各种连接和分组操作。
当 MapReduce 论文发表时【1】它从某种意义上来说 —— 并不新鲜。我们在前几节中讨论的所有处理和并行连接算法已经在十多年前所谓的 **大规模并行处理MPPmassively parallel processing** 数据库中实现了【3,40】。比如 Gamma database machine、Teradata 和 Tandem NonStop SQL 就是这方面的先驱【52】。
最大的区别是MPP 数据库专注于在一组机器上并行执行分析 SQL 查询,而 MapReduce 和分布式文件系统【19】的组合则更像是一个可以运行任意程序的通用操作系统。
#### 存储多样性
数据库要求你根据特定的模型(例如关系或文档)来构造数据,而分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写。它们可能是数据库记录的集合,但同样可以是文本、图像、视频、传感器读数、稀疏矩阵、特征向量、基因组序列或任何其他类型的数据。
说白了Hadoop 开放了将数据不加区分地转储到 HDFS 的可能性允许后续再研究如何进一步处理【53】。相比之下在将数据导入数据库专有存储格式之前MPP 数据库通常需要对数据和查询模式进行仔细的前期建模。
在纯粹主义者看来,这种仔细的建模和导入似乎是可取的,因为这意味着数据库的用户有更高质量的数据来处理。然而实践经验表明,简单地使数据快速可用 —— 即使它很古怪,难以使用,使用原始格式 —— 也通常要比事先决定理想数据模型要更有价值【54】。
这个想法与数据仓库类似(请参阅 “[数据仓库](/v1/ch3#数据仓库)”将大型组织的各个部分的数据集中在一起是很有价值的因为它可以跨越以前相互分离的数据集进行连接。MPP 数据库所要求的谨慎模式设计拖慢了集中式数据收集速度;以原始形式收集数据,稍后再操心模式的设计,能使数据收集速度加快(有时被称为 “**数据湖data lake**” 或 “**企业数据中心enterprise data hub**”【55】
不加区分的数据转储转移了解释数据的负担:数据集的生产者不再需要强制将其转化为标准格式,数据的解释成为消费者的问题(**读时模式** 方法【56】请参阅 “[文档模型中的模式灵活性](/v1/ch2#文档模型中的模式灵活性)”)。如果生产者和消费者是不同优先级的不同团队,这可能是一种优势。甚至可能不存在一个理想的数据模型,对于不同目的有不同的合适视角。以原始形式简单地转储数据,可以允许多种这样的转换。这种方法被称为 **寿司原则sushi principle**“原始数据更好”【57】。
因此Hadoop 经常被用于实现 ETL 过程(请参阅 “[数据仓库](/v1/ch3#数据仓库)”):事务处理系统中的数据以某种原始形式转储到分布式文件系统中,然后编写 MapReduce 作业来清理数据,将其转换为关系形式,并将其导入 MPP 数据仓库以进行分析。数据建模仍然在进行,但它在一个单独的步骤中进行,与数据收集相解耦。这种解耦是可行的,因为分布式文件系统支持以任何格式编码的数据。
#### 处理模型的多样性
MPP 数据库是单体的紧密集成的软件负责磁盘上的存储布局查询计划调度和执行。由于这些组件都可以针对数据库的特定需求进行调整和优化因此整个系统可以在其设计针对的查询类型上取得非常好的性能。而且SQL 查询语言允许以优雅的语法表达查询,而无需编写代码,可以在业务分析师使用的可视化工具(例如 Tableau中访问到。
另一方面,并非所有类型的处理都可以合理地表达为 SQL 查询。例如,如果要构建机器学习和推荐系统,或者使用相关性排名模型的全文搜索索引,或者执行图像分析,则很可能需要更一般的数据处理模型。这些类型的处理通常是特别针对特定应用的(例如机器学习的特征工程,机器翻译的自然语言模型,欺诈预测的风险评估函数),因此它们不可避免地需要编写代码,而不仅仅是查询。
MapReduce 使工程师能够轻松地在大型数据集上运行自己的代码。如果你有 HDFS 和 MapReduce那么你 **可以** 在它之上建立一个 SQL 查询执行引擎,事实上这正是 Hive 项目所做的【31】。但是你也可以编写许多其他形式的批处理这些批处理不必非要用 SQL 查询表示。
随后,人们发现 MapReduce 对于某些类型的处理而言局限性很大,表现很差,因此在 Hadoop 之上其他各种处理模型也被开发出来(我们将在 “[MapReduce 之后](#MapReduce之后)” 中看到其中一些。只有两种处理模型SQL 和 MapReduce还不够需要更多不同的模型而且由于 Hadoop 平台的开放性,实施一整套方法是可行的,而这在单体 MPP 数据库的范畴内是不可能的【58】。
至关重要的是,这些不同的处理模型都可以在共享的单个机器集群上运行,所有这些机器都可以访问分布式文件系统上的相同文件。在 Hadoop 方式中,不需要将数据导入到几个不同的专用系统中进行不同类型的处理:系统足够灵活,可以支持同一个集群内不同的工作负载。不需要移动数据,使得从数据中挖掘价值变得容易得多,也使采用新的处理模型容易的多。
Hadoop 生态系统包括随机访问的 OLTP 数据库,如 HBase请参阅 “[SSTables 和 LSM 树](/v1/ch3#SSTables和LSM树)”)和 MPP 风格的分析型数据库,如 Impala 【41】。HBase 与 Impala 都不使用 MapReduce但都使用 HDFS 进行存储。它们是迥异的数据访问与处理方法,但是它们可以共存,并被集成到同一个系统中。
#### 针对频繁故障设计
当比较 MapReduce 和 MPP 数据库时,两种不同的设计思路出现了:处理故障和使用内存与磁盘的方式。与在线系统相比,批处理对故障不太敏感,因为就算失败也不会立即影响到用户,而且它们总是能再次运行。
如果一个节点在执行查询时崩溃,大多数 MPP 数据库会中止整个查询并让用户重新提交查询或自动重新运行它【3】。由于查询通常最多运行几秒钟或几分钟所以这种错误处理的方法是可以接受的因为重试的代价不是太大。MPP 数据库还倾向于在内存中保留尽可能多的数据(例如,使用散列连接)以避免从磁盘读取的开销。
另一方面MapReduce 可以容忍单个 Map 或 Reduce 任务的失败,而不会影响作业的整体,通过以单个任务的粒度重试工作。它也会非常急切地将数据写入磁盘,一方面是为了容错,另一部分是因为假设数据集太大而不能适应内存。
MapReduce 方式更适用于较大的作业:要处理如此之多的数据并运行很长时间的作业,以至于在此过程中很可能至少遇到一个任务故障。在这种情况下,由于单个任务失败而重新运行整个作业将是非常浪费的。即使以单个任务的粒度进行恢复引入了使得无故障处理更慢的开销,但如果任务失败率足够高,这仍然是一种合理的权衡。
但是这些假设有多么现实呢?在大多数集群中,机器故障确实会发生,但是它们不是很频繁 —— 可能少到绝大多数作业都不会经历机器故障。为了容错,真的值得带来这么大的额外开销吗?
要了解 MapReduce 节约使用内存和在任务的层次进行恢复的原因,了解最初设计 MapReduce 的环境是很有帮助的。Google 有着混用的数据中心在线生产服务和离线批处理作业在同样机器上运行。每个任务都有一个通过容器强制执行的资源配给CPU 核心、RAM、磁盘空间等。每个任务也具有优先级如果优先级较高的任务需要更多的资源则可以终止抢占同一台机器上较低优先级的任务以释放资源。优先级还决定了计算资源的定价团队必须为他们使用的资源付费而优先级更高的进程花费更多【59】。
这种架构允许非生产(低优先级)计算资源被 **过量使用overcommitted**,因为系统知道必要时它可以回收资源。与分离生产和非生产任务的系统相比,过量使用资源可以更好地利用机器并提高效率。但由于 MapReduce 作业以低优先级运行,它们随时都有被抢占的风险,因为优先级较高的进程可能需要其资源。在高优先级进程拿走所需资源后,批量作业能有效地 “捡面包屑”,利用剩下的任何计算资源。
在谷歌,运行一个小时的 MapReduce 任务有大约有 5% 的风险被终止为了给更高优先级的进程挪地方。这一概率比硬件问题、机器重启或其他原因的概率高了一个数量级【59】。按照这种抢占率如果一个作业有 100 个任务,每个任务运行 10 分钟,那么至少有一个任务在完成之前被终止的风险大于 50%。
这就是 MapReduce 被设计为容忍频繁意外任务终止的原因:不是因为硬件很不可靠,而是因为任意终止进程的自由有利于提高计算集群中的资源利用率。
在开源的集群调度器中抢占的使用较少。YARN 的 CapacityScheduler 支持抢占以平衡不同队列的资源分配【58】但在编写本文时YARNMesos 或 Kubernetes 不支持通用的优先级抢占【60】。在任务不经常被终止的环境中MapReduce 的这一设计决策就没有多少意义了。在下一节中,我们将研究一些与 MapReduce 设计决策相异的替代方案。
## MapReduce之后
虽然 MapReduce 在 2000 年代后期变得非常流行,并受到大量的炒作,但它只是分布式系统的许多可能的编程模型之一。对于不同的数据量,数据结构和处理类型,其他工具可能更适合表示计算。
不管如何,我们在这一章花了大把时间来讨论 MapReduce因为它是一种有用的学习工具它是分布式文件系统的一种相当简单明晰的抽象。在这里**简单** 意味着我们能理解它在做什么,而不是意味着使用它很简单。恰恰相反:使用原始的 MapReduce API 来实现复杂的处理工作实际上是非常困难和费力的 —— 例如任意一种连接算法都需要你从头开始实现【37】。
针对直接使用 MapReduce 的困难,在 MapReduce 上有很多高级编程模型Pig、Hive、Cascading、Crunch被创造出来作为建立在 MapReduce 之上的抽象。如果你了解 MapReduce 的原理,那么它们学起来相当简单。而且它们的高级结构能显著简化许多常见批处理任务的实现。
但是MapReduce 执行模型本身也存在一些问题这些问题并没有通过增加另一个抽象层次而解决而对于某些类型的处理它表现得非常差劲。一方面MapReduce 非常稳健:你可以使用它在任务会频繁终止的多租户系统上处理几乎任意大量级的数据,并且仍然可以完成工作(虽然速度很慢)。另一方面,对于某些类型的处理而言,其他工具有时会快上几个数量级。
在本章的其余部分中,我们将介绍一些批处理方法。在 [第十一章](/v1/ch11) 我们将转向流处理,它可以看作是加速批处理的另一种方法。
### 物化中间状态
如前所述,每个 MapReduce 作业都独立于其他任何作业。作业与世界其他地方的主要连接点是分布式文件系统上的输入和输出目录。如果希望一个作业的输出成为第二个作业的输入,则需要将第二个作业的输入目录配置为第一个作业输出目录,且外部工作流调度程序必须在第一个作业完成后再启动第二个。
如果第一个作业的输出是要在组织内广泛发布的数据集,则这种配置是合理的。在这种情况下,你需要通过名称引用它,并将其重用为多个不同作业的输入(包括由其他团队开发的作业)。将数据发布到分布式文件系统中众所周知的位置能够带来 **松耦合**,这样作业就不需要知道是谁在提供输入或谁在消费输出(请参阅 “[逻辑与布线相分离](#逻辑与布线相分离)”)。
但在很多情况下,你知道一个作业的输出只能用作另一个作业的输入,这些作业由同一个团队维护。在这种情况下,分布式文件系统上的文件只是简单的 **中间状态intermediate state**:一种将数据从一个作业传递到下一个作业的方式。在一个用于构建推荐系统的,由 50 或 100 个 MapReduce 作业组成的复杂工作流中存在着很多这样的中间状态【29】。
将这个中间状态写入文件的过程称为 **物化materialization**。(在 “[聚合:数据立方体和物化视图](/v1/ch3#聚合:数据立方体和物化视图)” 中已经在物化视图的背景中遇到过这个术语。它意味着对某个操作的结果立即求值并写出来,而不是在请求时按需计算)
作为对照,本章开头的日志分析示例使用 Unix 管道将一个命令的输出与另一个命令的输入连接起来。管道并没有完全物化中间状态,而是只使用一个小的内存缓冲区,将输出增量地 **流stream** 向输入。
与 Unix 管道相比MapReduce 完全物化中间状态的方法存在不足之处:
- MapReduce 作业只有在前驱作业(生成其输入)中的所有任务都完成时才能启动,而由 Unix 管道连接的进程会同时启动,输出一旦生成就会被消费。不同机器上的数据偏斜或负载不均意味着一个作业往往会有一些掉队的任务,比其他任务要慢得多才能完成。必须等待至前驱作业的所有任务完成,拖慢了整个工作流程的执行。
- Mapper 通常是多余的:它们仅仅是读取刚刚由 Reducer 写入的同样文件为下一个阶段的分区和排序做准备。在许多情况下Mapper 代码可能是前驱 Reducer 的一部分:如果 Reducer 和 Mapper 的输出有着相同的分区与排序方式,那么 Reducer 就可以直接串在一起,而不用与 Mapper 相互交织。
- 将中间状态存储在分布式文件系统中意味着这些文件被复制到多个节点,对这些临时数据这么搞就比较过分了。
#### 数据流引擎
为了解决 MapReduce 的这些问题,几种用于分布式批处理的新执行引擎被开发出来,其中最著名的是 Spark 【61,62】Tez 【63,64】和 Flink 【65,66】。它们的设计方式有很多区别但有一个共同点把整个工作流作为单个作业来处理而不是把它分解为独立的子作业。
由于它们将工作流显式建模为数据从几个处理阶段穿过,所以这些系统被称为 **数据流引擎dataflow engines**。像 MapReduce 一样,它们在一条线上通过反复调用用户定义的函数来一次处理一条记录,它们通过输入分区来并行化载荷,它们通过网络将一个函数的输出复制到另一个函数的输入。
与 MapReduce 不同,这些函数不需要严格扮演交织的 Map 与 Reduce 的角色,而是可以以更灵活的方式进行组合。我们称这些函数为 **算子operators**,数据流引擎提供了几种不同的选项来将一个算子的输出连接到另一个算子的输入:
- 一种选项是对记录按键重新分区并排序,就像在 MapReduce 的混洗阶段一样(请参阅 “[分布式执行 MapReduce](#分布式执行MapReduce)”)。这种功能可以用于实现排序合并连接和分组,就像在 MapReduce 中一样。
- 另一种可能是接受多个输入,并以相同的方式进行分区,但跳过排序。当记录的分区重要但顺序无关紧要时,这省去了分区散列连接的工作,因为构建散列表还是会把顺序随机打乱。
- 对于广播散列连接,可以将一个算子的输出,发送到连接算子的所有分区。
这种类型的处理引擎是基于像 Dryad【67】和 Nephele【68】这样的研究系统与 MapReduce 模型相比,它有几个优点:
- 排序等昂贵的工作只需要在实际需要的地方执行,而不是默认地在每个 Map 和 Reduce 阶段之间出现。
- 没有不必要的 Map 任务,因为 Mapper 所做的工作通常可以合并到前面的 Reduce 算子中(因为 Mapper 不会更改数据集的分区)。
- 由于工作流中的所有连接和数据依赖都是显式声明的,因此调度程序能够总览全局,知道哪里需要哪些数据,因而能够利用局部性进行优化。例如,它可以尝试将消费某些数据的任务放在与生成这些数据的任务相同的机器上,从而数据可以通过共享内存缓冲区传输,而不必通过网络复制。
- 通常,算子间的中间状态足以保存在内存中或写入本地磁盘,这比写入 HDFS 需要更少的 I/O必须将其复制到多台机器并将每个副本写入磁盘。MapReduce 已经对 Mapper 的输出做了这种优化,但数据流引擎将这种思想推广至所有的中间状态。
- 算子可以在输入就绪后立即开始执行;后续阶段无需等待前驱阶段整个完成后再开始。
- 与 MapReduce为每个任务启动一个新的 JVM相比现有 Java 虚拟机JVM进程可以重用来运行新算子从而减少启动开销。
你可以使用数据流引擎执行与 MapReduce 工作流同样的计算,而且由于此处所述的优化,通常执行速度要明显快得多。既然算子是 Map 和 Reduce 的泛化那么相同的处理代码就可以在任一执行引擎上运行PigHive 或 Cascading 中实现的工作流可以无需修改代码,可以通过修改配置,简单地从 MapReduce 切换到 Tez 或 Spark【64】。
Tez 是一个相当薄的库,它依赖于 YARN shuffle 服务来实现节点间数据的实际复制【58】而 Spark 和 Flink 则是包含了独立网络通信层,调度器,及用户向 API 的大型框架。我们将简要讨论这些高级 API。
#### 容错
完全物化中间状态至分布式文件系统的一个优点是,它具有持久性,这使得 MapReduce 中的容错相当容易:如果一个任务失败,它可以在另一台机器上重新启动,并从文件系统重新读取相同的输入。
Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了不同的方法来容错如果一台机器发生故障并且该机器上的中间状态丢失则它会从其他仍然可用的数据重新计算在可行的情况下是先前的中间状态要么就只能是原始输入数据通常在 HDFS 上)。
为了实现这种重新计算,框架必须跟踪一个给定的数据是如何计算的 —— 使用了哪些输入分区?应用了哪些算子? Spark 使用 **弹性分布式数据集RDDResilient Distributed Dataset** 的抽象来跟踪数据的谱系【61】而 Flink 对算子状态存档允许恢复运行在执行过程中遇到错误的算子【66】。
在重新计算数据时,重要的是要知道计算是否是 **确定性的**:也就是说,给定相同的输入数据,算子是否始终产生相同的输出?如果一些丢失的数据已经发送给下游算子,这个问题就很重要。如果算子重新启动,重新计算的数据与原有的丢失数据不一致,下游算子很难解决新旧数据之间的矛盾。对于不确定性算子来说,解决方案通常是杀死下游算子,然后再重跑新数据。
为了避免这种级联故障,最好让算子具有确定性。但需要注意的是,非确定性行为很容易悄悄溜进来:例如,许多编程语言在迭代哈希表的元素时不能对顺序作出保证,许多概率和统计算法显式依赖于使用随机数,以及用到系统时钟或外部数据源,这些都是都不确定性的行为。为了能可靠地从故障中恢复,需要消除这种不确定性因素,例如使用固定的种子生成伪随机数。
通过重算数据来从故障中恢复并不总是正确的答案:如果中间状态数据要比源数据小得多,或者如果计算量非常大,那么将中间数据物化为文件可能要比重新计算廉价的多。
#### 关于物化的讨论
回到 Unix 的类比我们看到MapReduce 就像是将每个命令的输出写入临时文件,而数据流引擎看起来更像是 Unix 管道。尤其是 Flink 是基于管道执行的思想而建立的:也就是说,将算子的输出增量地传递给其他算子,不待输入完成便开始处理。
排序算子不可避免地需要消费全部的输入后才能生成任何输出,因为输入中最后一条输入记录可能具有最小的键,因此需要作为第一条记录输出。因此,任何需要排序的算子都需要至少暂时地累积状态。但是工作流的许多其他部分可以以流水线方式执行。
当作业完成时,它的输出需要持续到某个地方,以便用户可以找到并使用它 —— 很可能它会再次写入分布式文件系统。因此在使用数据流引擎时HDFS 上的物化数据集通常仍是作业的输入和最终输出。和 MapReduce 一样,输入是不可变的,输出被完全替换。比起 MapReduce 的改进是,你不用再自己去将中间状态写入文件系统了。
### 图与迭代处理
在 “[图数据模型](/v1/ch2#图数据模型)” 中,我们讨论了使用图来建模数据,并使用图查询语言来遍历图中的边与点。[第二章](/v1/ch2) 的讨论集中在 OLTP 风格的应用场景:快速执行查询来查找少量符合特定条件的顶点。
批处理上下文中的图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用(如推荐引擎)或排序系统中。例如,最着名的图形分析算法之一是 PageRank 【69】它试图根据链接到某个网页的其他网页来估计该网页的流行度。它作为配方的一部分用于确定网络搜索引擎呈现结果的顺序。
> 像 Spark、Flink 和 Tez 这样的数据流引擎(请参阅 “[物化中间状态](#物化中间状态)”)通常将算子作为 **有向无环图DAG** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流** 被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](/v1/ddia_0206.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种 “重复至完成” 的想法不能用普通的 MapReduce 来表示,因为它只扫过一趟数据。这种算法因此经常以 **迭代** 的风格实现:
1. 外部调度程序运行批处理来计算算法的一个步骤。
2. 当批处理过程完成时,调度器检查它是否完成(基于完成条件 —— 例如,没有更多的边要跟进,或者与上次迭代相比的变化低于某个阈值)。
3. 如果尚未完成,则调度程序返回到步骤 1 并运行另一轮批处理。
这种方法是有效的,但是用 MapReduce 实现它往往非常低效,因为 MapReduce 没有考虑算法的迭代性质:它总是读取整个输入数据集并产生一个全新的输出数据集,即使与上次迭代相比,改变的仅仅是图中的一小部分。
#### Pregel处理模型
针对图批处理的优化 —— **批量同步并行BSPBulk Synchronous Parallel** 计算模型【70】已经开始流行起来。其中Apache Giraph 【37】Spark 的 GraphX API 和 Flink 的 Gelly API 【71】实现了它。它也被称为 **Pregel** 模型,因为 Google 的 Pregel 论文推广了这种处理图的方法【72】。
回想一下在 MapReduce 中Mapper 在概念上向 Reducer 的特定调用 “发送消息”,因为框架将所有具有相同键的 Mapper 输出集中在一起。Pregel 背后有一个类似的想法:一个顶点可以向另一个顶点 “发送消息”,通常这些消息是沿着图的边发送的。
在每次迭代中,为每个顶点调用一个函数,将所有发送给它的消息传递给它 —— 就像调用 Reducer 一样。与 MapReduce 的不同之处在于,在 Pregel 模型中,顶点在一次迭代到下一次迭代的过程中会记住它的状态,所以这个函数只需要处理新的传入消息。如果图的某个部分没有被发送消息,那里就不需要做任何工作。
这与 Actor 模型有些相似(请参阅 “[分布式的 Actor 框架](/v1/ch4#分布式的Actor框架)”除了顶点状态和顶点之间的消息具有容错性和持久性且通信以固定的回合进行在每次迭代中框架递送上次迭代中发送的所有消息。Actor 通常没有这样的时序保证。
#### 容错
顶点只能通过消息传递进行通信(而不是直接相互查询)的事实有助于提高 Pregel 作业的性能,因为消息可以成批处理,且等待通信的次数也减少了。唯一的等待是在迭代之间:由于 Pregel 模型保证所有在一轮迭代中发送的消息都在下轮迭代中送达,所以在下一轮迭代开始前,先前的迭代必须完全完成,而所有的消息必须在网络上完成复制。
即使底层网络可能丢失、重复或任意延迟消息(请参阅 “[不可靠的网络](/v1/ch8#不可靠的网络)”Pregel 的实现能保证在后续迭代中消息在其目标顶点恰好处理一次。像 MapReduce 一样,框架能从故障中透明地恢复,以简化在 Pregel 上实现算法的编程模型。
这种容错是通过在迭代结束时定期存档所有顶点的状态来实现的即将其全部状态写入持久化存储。如果某个节点发生故障并且其内存中的状态丢失则最简单的解决方法是将整个图计算回滚到上一个存档点然后重启计算。如果算法是确定性的且消息记录在日志中那么也可以选择性地只恢复丢失的分区就像之前讨论过的数据流引擎【72】。
#### 并行执行
顶点不需要知道它在哪台物理机器上执行;当它向其他顶点发送消息时,它只是简单地将消息发往某个顶点 ID。图的分区取决于框架 —— 即,确定哪个顶点运行在哪台机器上,以及如何通过网络路由消息,以便它们到达正确的地方。
由于编程模型一次仅处理一个顶点(有时称为 “像顶点一样思考”),所以框架可以以任意方式对图分区。理想情况下如果顶点需要进行大量的通信,那么它们最好能被分区到同一台机器上。然而找到这样一种优化的分区方法是很困难的 —— 在实践中,图经常按照任意分配的顶点 ID 分区,而不会尝试将相关的顶点分组在一起。
因此,图算法通常会有很多跨机器通信的额外开销,而中间状态(节点之间发送的消息)往往比原始图大。通过网络发送消息的开销会显著拖慢分布式图算法的速度。
出于这个原因如果你的图可以放入一台计算机的内存中那么单机甚至可能是单线程算法很可能会超越分布式批处理【73,74】。图比内存大也没关系只要能放入单台计算机的磁盘使用 GraphChi 等框架进行单机处理是就一个可行的选择【75】。如果图太大不适合单机处理那么像 Pregel 这样的分布式方法是不可避免的。高效的并行图算法是一个进行中的研究领域【76】。
### 高级API和语言
自 MapReduce 开始流行的这几年以来,分布式批处理的执行引擎已经很成熟了。到目前为止,基础设施已经足够强大,能够存储和处理超过 10,000 台机器集群上的数 PB 的数据。由于在这种规模下物理执行批处理的问题已经被认为或多或少解决了,所以关注点已经转向其他领域:改进编程模型,提高处理效率,扩大这些技术可以解决的问题集。
如前所述Hive、Pig、Cascading 和 Crunch 等高级语言和 API 变得越来越流行,因为手写 MapReduce 作业实在是个苦力活。随着 Tez 的出现这些高级语言还有一个额外好处可以迁移到新的数据流执行引擎而无需重写作业代码。Spark 和 Flink 也有它们自己的高级数据流 API通常是从 FlumeJava 中获取的灵感【34】。
这些数据流 API 通常使用关系型构建块来表达一个计算:按某个字段连接数据集;按键对元组做分组;按某些条件过滤;并通过计数求和或其他函数来聚合元组。在内部,这些操作是使用本章前面讨论过的各种连接和分组算法来实现的。
除了少写代码的明显优势之外,这些高级接口还支持交互式用法,在这种交互式使用中,你可以在 Shell 中增量式编写分析代码,频繁运行来观察它做了什么。这种开发风格在探索数据集和试验处理方法时非常有用。这也让人联想到 Unix 哲学,我们在 “[Unix 哲学](#Unix哲学)” 中讨论过这个问题。
此外,这些高级接口不仅提高了人类的工作效率,也提高了机器层面的作业执行效率。
#### 向声明式查询语言的转变
与硬写执行连接的代码相比指定连接关系算子的优点是框架可以分析连接输入的属性并自动决定哪种上述连接算法最适合当前任务。Hive、Spark 和 Flink 都有基于代价的查询优化器可以做到这一点甚至可以改变连接顺序最小化中间状态的数量【66,77,78,79】。
连接算法的选择可以对批处理作业的性能产生巨大影响,而无需理解和记住本章中讨论的各种连接算法。如果连接是以 **声明式declarative** 的方式指定的,那这就这是可行的:应用只是简单地说明哪些连接是必需的,查询优化器决定如何最好地执行连接。我们以前在 “[数据查询语言](/v1/ch2#数据查询语言)” 中见过这个想法。
但 MapReduce 及其数据流后继者在其他方面,与 SQL 的完全声明式查询模型有很大区别。MapReduce 是围绕着回调函数的概念建立的对于每条记录或者一组记录调用一个用户定义的函数Mapper 或 Reducer并且该函数可以自由地调用任意代码来决定输出什么。这种方法的优点是可以基于大量已有库的生态系统创作解析、自然语言分析、图像分析以及运行数值或统计算法等。
自由运行任意代码,长期以来都是传统 MapReduce 批处理系统与 MPP 数据库的区别所在(请参阅 “[Hadoop 与分布式数据库的对比](#Hadoop与分布式数据库的对比)” 一节)。虽然数据库具有编写用户定义函数的功能,但是它们通常使用起来很麻烦,而且与大多数编程语言中广泛使用的程序包管理器和依赖管理系统兼容不佳(例如 Java 的 Maven、Javascript 的 npm 以及 Ruby 的 gems
然而数据流引擎已经发现,支持除连接之外的更多 **声明式特性** 还有其他的优势。例如,如果一个回调函数只包含一个简单的过滤条件,或者只是从一条记录中选择了一些字段,那么在为每条记录调用函数时会有相当大的额外 CPU 开销。如果以声明方式表示这些简单的过滤和映射操作,那么查询优化器可以利用列式存储布局(请参阅 “[列式存储](/v1/ch3#列式存储)”只从磁盘读取所需的列。Hive、Spark DataFrames 和 Impala 还使用了向量化执行(请参阅 “[内存带宽和矢量化处理](/v1/ch3#内存带宽和矢量化处理)”):在对 CPU 缓存友好的内部循环中迭代数据避免函数调用。Spark 生成 JVM 字节码【79】Impala 使用 LLVM 为这些内部循环生成本机代码【41】。
通过在高级 API 中引入声明式的部分,并使查询优化器可以在执行期间利用这些来做优化,批处理框架看起来越来越像 MPP 数据库了(并且能实现可与之媲美的性能)。同时,通过拥有运行任意代码和以任意格式读取数据的可扩展性,它们保持了灵活性的优势。
#### 专业化的不同领域
尽管能够运行任意代码的可扩展性是很有用的但是也有很多常见的例子不断重复着标准的处理模式。因而这些模式值得拥有自己的可重用通用构建模块实现。传统上MPP 数据库满足了商业智能分析和业务报表的需求,但这只是许多使用批处理的领域之一。
另一个越来越重要的领域是统计和数值算法它们是机器学习应用所需要的例如分类器和推荐系统。可重用的实现正在出现例如Mahout 在 MapReduce、Spark 和 Flink 之上实现了用于机器学习的各种算法,而 MADlib 在关系型 MPP 数据库Apache HAWQ中实现了类似的功能【54】。
空间算法也是有用的,例如 **k 近邻搜索k-nearest neighbors, kNN**【80】它在一些多维空间中搜索与给定项最近的项目 —— 这是一种相似性搜索。近似搜索对于基因组分析算法也很重要它们需要找到相似但不相同的字符串【81】。
批处理引擎正被用于分布式执行日益广泛的各领域算法。随着批处理系统获得各种内置功能以及高级声明式算子,且随着 MPP 数据库变得更加灵活和易于编程,两者开始看起来相似了:最终,它们都只是存储和处理数据的系统。
## 本章小结
在本章中,我们探索了批处理的主题。我们首先看到了诸如 awk、grep 和 sort 之类的 Unix 工具,然后我们看到了这些工具的设计理念是如何应用到 MapReduce 和更近的数据流引擎中的。一些设计原则包括:输入是不可变的,输出是为了作为另一个(仍未知的)程序的输入,而复杂的问题是通过编写 “做好一件事” 的小工具来解决的。
在 Unix 世界中,允许程序与程序组合的统一接口是文件与管道;在 MapReduce 中,该接口是一个分布式文件系统。我们看到数据流引擎添加了自己的管道式数据传输机制,以避免将中间状态物化至分布式文件系统,但作业的初始输入和最终输出通常仍是 HDFS。
分布式批处理框架需要解决的两个主要问题是:
分区
: 在 MapReduce 中Mapper 根据输入文件块进行分区。Mapper 的输出被重新分区、排序并合并到可配置数量的 Reducer 分区中。这一过程的目的是把所有的 **相关** 数据(例如带有相同键的所有记录)都放在同一个地方。
后 MapReduce 时代的数据流引擎若非必要会尽量避免排序,但它们也采取了大致类似的分区方法。
容错
: MapReduce 经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。数据流引擎更多地将中间状态保存在内存中,更少地物化中间状态,这意味着如果节点发生故障,则需要重算更多的数据。确定性算子减少了需要重算的数据量。
我们讨论了几种 MapReduce 的连接算法,其中大多数也在 MPP 数据库和数据流引擎内部使用。它们也很好地演示了分区算法是如何工作的:
排序合并连接
: 每个参与连接的输入都通过一个提取连接键的 Mapper。通过分区、排序和合并具有相同键的所有记录最终都会进入相同的 Reducer 调用。这个函数能输出连接好的记录。
广播散列连接
: 两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个 Mapper将输入小端的散列表加载到每个 Mapper 中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
分区散列连接
: 如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。
分布式批处理引擎有一个刻意限制的编程模型:回调函数(比如 Mapper 和 Reducer被假定是无状态的而且除了指定的输出外必须没有任何外部可见的副作用。这一限制允许框架在其抽象下隐藏一些困难的分布式系统问题当遇到崩溃和网络问题时任务可以安全地重试任何失败任务的输出都被丢弃。如果某个分区的多个任务成功则其中只有一个能使其输出实际可见。
得益于这个框架,你在批处理作业中的代码无需操心实现容错机制:框架可以保证作业的最终输出与没有发生错误的情况相同,虽然实际上也许不得不重试各种任务。比起在线服务一边处理用户请求一边将写入数据库作为处理请求的副作用,批处理提供的这种可靠性语义要强得多。
批处理作业的显著特点是,它读取一些输入数据并产生一些输出数据,但不修改输入 —— 换句话说,输出是从输入衍生出的。最关键的是,输入数据是 **有界的bounded**:它有一个已知的,固定的大小(例如,它包含一些时间点的日志文件或数据库内容的快照)。因为它是有界的,一个作业知道自己什么时候完成了整个输入的读取,所以一个工作在做完后,最终总是会完成的。
在下一章中,我们将转向流处理,其中的输入是 **无界的unbounded** —— 也就是说,你还有活儿要干,然而它的输入是永无止境的数据流。在这种情况下,作业永无完成之日。因为在任何时候都可能有更多的工作涌入。我们将看到,在某些方面上,流处理和批处理是相似的。但是关于无尽数据流的假设也对我们构建系统的方式产生了很多改变。
## 参考文献
1. Jeffrey Dean and Sanjay Ghemawat: “[MapReduce: Simplified Data Processing on Large Clusters](https://research.google/pubs/pub62/),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004.
1. Joel Spolsky: “[The Perils of JavaSchools](https://www.joelonsoftware.com/2005/12/29/the-perils-of-javaschools-2/),” *joelonsoftware.com*, December 29, 2005.
1. Shivnath Babu and Herodotos Herodotou: “[Massively Parallel Databases and MapReduce Systems](https://www.microsoft.com/en-us/research/wp-content/uploads/2013/11/db-mr-survey-final.pdf),” *Foundations and Trends in Databases*, volume 5, number 1, pages 1104, November 2013. [doi:10.1561/1900000036](http://dx.doi.org/10.1561/1900000036)
1. David J. DeWitt and Michael Stonebraker: “[MapReduce: A Major Step Backwards](https://homes.cs.washington.edu/~billhowe/mapreduce_a_major_step_backwards.html),” originally published at *databasecolumn.vertica.com*, January 17, 2008.
1. Henry Robinson: “[The Elephant Was a Trojan Horse: On the Death of Map-Reduce at Google](https://www.the-paper-trail.org/post/2014-06-25-the-elephant-was-a-trojan-horse-on-the-death-of-map-reduce-at-google/),” *the-paper-trail.org*, June 25, 2014.
1. “[The Hollerith Machine](https://www.census.gov/history/www/innovations/technology/the_hollerith_tabulator.html),” United States Census Bureau, *census.gov*.
1. “[IBM 82, 83, and 84 Sorters Reference Manual](https://bitsavers.org/pdf/ibm/punchedCard/Sorter/A24-1034-1_82-83-84_sorters.pdf),” Edition A24-1034-1, International Business Machines Corporation, July 1962.
1. Adam Drake: “[Command-Line Tools Can Be 235x Faster than Your Hadoop Cluster](https://adamdrake.com/command-line-tools-can-be-235x-faster-than-your-hadoop-cluster.html),” *aadrake.com*, January 25, 2014.
1. “[GNU Coreutils 8.23 Documentation](http://www.gnu.org/software/coreutils/manual/html_node/index.html),” Free Software Foundation, Inc., 2014.
1. Martin Kleppmann: “[Kafka, Samza, and the Unix Philosophy of Distributed Data](http://martin.kleppmann.com/2015/08/05/kafka-samza-unix-philosophy-distributed-data.html),” *martin.kleppmann.com*, August 5, 2015.
1. Doug McIlroy: [Internal Bell Labs memo](https://swtch.com/~rsc/thread/mdmpipe.pdf), October 1964. Cited in: Dennis M. Richie: “[Advice from Doug McIlroy](https://www.bell-labs.com/usr/dmr/www/mdmpipe.html),” *bell-labs.com*.
1. M. D. McIlroy, E. N. Pinson, and B. A. Tague: “[UNIX Time-Sharing System: Foreword](https://archive.org/details/bstj57-6-1899),” *The Bell System Technical Journal*, volume 57, number 6, pages 18991904, July 1978.
1. Eric S. Raymond: [*The Art of UNIX Programming*](http://www.catb.org/~esr/writings/taoup/html/). Addison-Wesley, 2003. ISBN: 978-0-13-142901-7
1. Ronald Duncan: “[Text File Formats ASCII Delimited Text Not CSV or TAB Delimited Text](https://ronaldduncan.wordpress.com/2009/10/31/text-file-formats-ascii-delimited-text-not-csv-or-tab-delimited-text/),” *ronaldduncan.wordpress.com*, October 31, 2009.
1. Alan Kay: “[Is 'Software Engineering' an Oxymoron?](http://tinlizzie.org/~takashi/IsSoftwareEngineeringAnOxymoron.pdf),” *tinlizzie.org*.
1. Martin Fowler: “[InversionOfControl](http://martinfowler.com/bliki/InversionOfControl.html),” *martinfowler.com*, June 26, 2005.
1. Daniel J. Bernstein: “[Two File Descriptors for Sockets](http://cr.yp.to/tcpip/twofd.html),” *cr.yp.to*.
1. Rob Pike and Dennis M. Ritchie: “[The Styx Architecture for Distributed Systems](http://doc.cat-v.org/inferno/4th_edition/styx),” *Bell Labs Technical Journal*, volume 4, number 2, pages 146152, April 1999.
1. Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung: “[The Google File System](http://research.google.com/archive/gfs-sosp2003.pdf),” at *19th ACM Symposium on Operating Systems Principles* (SOSP), October 2003. [doi:10.1145/945445.945450](http://dx.doi.org/10.1145/945445.945450)
1. Michael Ovsiannikov, Silvius Rus, Damian Reeves, et al.: “[The Quantcast File System](http://db.disi.unitn.eu/pages/VLDBProgram/pdf/industry/p808-ovsiannikov.pdf),” *Proceedings of the VLDB Endowment*, volume 6, number 11, pages 10921101, August 2013. [doi:10.14778/2536222.2536234](http://dx.doi.org/10.14778/2536222.2536234)
1. “[OpenStack Swift 2.6.1 Developer Documentation](http://docs.openstack.org/developer/swift/),” OpenStack Foundation, *docs.openstack.org*, March 2016.
1. Zhe Zhang, Andrew Wang, Kai Zheng, et al.: “[Introduction to HDFS Erasure Coding in Apache Hadoop](https://blog.cloudera.com/introduction-to-hdfs-erasure-coding-in-apache-hadoop/),” *blog.cloudera.com*, September 23, 2015.
1. Peter Cnudde: “[Hadoop Turns 10](https://web.archive.org/web/20190119112713/https://yahoohadoop.tumblr.com/post/138739227316/hadoop-turns-10),” *yahoohadoop.tumblr.com*, February 5, 2016.
1. Eric Baldeschwieler: “[Thinking About the HDFS vs. Other Storage Technologies](https://web.archive.org/web/20190529215115/http://hortonworks.com/blog/thinking-about-the-hdfs-vs-other-storage-technologies/),” *hortonworks.com*, July 25, 2012.
1. Brendan Gregg: “[Manta: Unix Meets Map Reduce](https://web.archive.org/web/20220125052545/http://dtrace.org/blogs/brendan/2013/06/25/manta-unix-meets-map-reduce/),” *dtrace.org*, June 25, 2013.
1. Tom White: *Hadoop: The Definitive Guide*, 4th edition. O'Reilly Media, 2015. ISBN: 978-1-491-90163-2
1. Jim N. Gray: “[Distributed Computing Economics](http://arxiv.org/pdf/cs/0403019.pdf),” Microsoft Research Tech Report MSR-TR-2003-24, March 2003.
1. Márton Trencséni: “[Luigi vs Airflow vs Pinball](http://bytepawn.com/luigi-airflow-pinball.html),” *bytepawn.com*, February 6, 2016.
1. Roshan Sumbaly, Jay Kreps, and Sam Shah: “[The 'Big Data' Ecosystem at LinkedIn](http://www.slideshare.net/s_shah/the-big-data-ecosystem-at-linkedin-23512853),” at *ACM International Conference on Management of Data* (SIGMOD), July 2013. [doi:10.1145/2463676.2463707](http://dx.doi.org/10.1145/2463676.2463707)
1. Alan F. Gates, Olga Natkovich, Shubham Chopra, et al.: “[Building a High-Level Dataflow System on Top of Map-Reduce: The Pig Experience](http://www.vldb.org/pvldb/vol2/vldb09-1074.pdf),” at *35th International Conference on Very Large Data Bases* (VLDB), August 2009.
1. Ashish Thusoo, Joydeep Sen Sarma, Namit Jain, et al.: “[Hive A Petabyte Scale Data Warehouse Using Hadoop](http://i.stanford.edu/~ragho/hive-icde2010.pdf),” at *26th IEEE International Conference on Data Engineering* (ICDE), March 2010. [doi:10.1109/ICDE.2010.5447738](http://dx.doi.org/10.1109/ICDE.2010.5447738)
1. “[Cascading 3.0 User Guide](https://web.archive.org/web/20231206195311/http://docs.cascading.org/cascading/3.0/userguide/),” Concurrent, Inc., *docs.cascading.org*, January 2016.
1. “[Apache Crunch User Guide](https://crunch.apache.org/user-guide.html),” Apache Software Foundation, *crunch.apache.org*.
1. Craig Chambers, Ashish Raniwala, Frances Perry, et al.: “[FlumeJava: Easy, Efficient Data-Parallel Pipelines](https://research.google.com/pubs/archive/35650.pdf),” at *31st ACM SIGPLAN Conference on Programming Language Design and Implementation* (PLDI), June 2010. [doi:10.1145/1806596.1806638](http://dx.doi.org/10.1145/1806596.1806638)
1. Jay Kreps: “[Why Local State is a Fundamental Primitive in Stream Processing](https://www.oreilly.com/ideas/why-local-state-is-a-fundamental-primitive-in-stream-processing),” *oreilly.com*, July 31, 2014.
1. Martin Kleppmann: “[Rethinking Caching in Web Apps](http://martin.kleppmann.com/2012/10/01/rethinking-caching-in-web-apps.html),” *martin.kleppmann.com*, October 1, 2012.
1. Mark Grover, Ted Malaska, Jonathan Seidman, and Gwen Shapira: *[Hadoop Application Architectures](http://shop.oreilly.com/product/0636920033196.do)*. O'Reilly Media, 2015. ISBN: 978-1-491-90004-8
1. Philippe Ajoux, Nathan Bronson, Sanjeev Kumar, et al.: “[Challenges to Adopting Stronger Consistency at Scale](https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-ajoux.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015.
1. Sriranjan Manjunath: “[Skewed Join](https://web.archive.org/web/20151228114742/https://wiki.apache.org/pig/PigSkewedJoinSpec),” *wiki.apache.org*, 2009.
1. David J. DeWitt, Jeffrey F. Naughton, Donovan A. Schneider, and S. Seshadri: “[Practical Skew Handling in Parallel Joins](http://www.vldb.org/conf/1992/P027.PDF),” at *18th International Conference on Very Large Data Bases* (VLDB), August 1992.
1. Marcel Kornacker, Alexander Behm, Victor Bittorf, et al.: “[Impala: A Modern, Open-Source SQL Engine for Hadoop](http://pandis.net/resources/cidr15impala.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015.
1. Matthieu Monsch: “[Open-Sourcing PalDB, a Lightweight Companion for Storing Side Data](https://engineering.linkedin.com/blog/2015/10/open-sourcing-paldb--a-lightweight-companion-for-storing-side-da),” *engineering.linkedin.com*, October 26, 2015.
1. Daniel Peng and Frank Dabek: “[Large-Scale Incremental Processing Using Distributed Transactions and Notifications](https://www.usenix.org/legacy/event/osdi10/tech/full_papers/Peng.pdf),” at *9th USENIX conference on Operating Systems Design and Implementation* (OSDI), October 2010.
1. “["Cloudera Search User Guide,"](http://www.cloudera.com/documentation/cdh/5-1-x/Search/Cloudera-Search-User-Guide/Cloudera-Search-User-Guide.html) Cloudera, Inc., September 2015.
1. Lili Wu, Sam Shah, Sean Choi, et al.: “[The Browsemaps: Collaborative Filtering at LinkedIn](http://ceur-ws.org/Vol-1271/Paper3.pdf),” at *6th Workshop on Recommender Systems and the Social Web* (RSWeb), October 2014.
1. Roshan Sumbaly, Jay Kreps, Lei Gao, et al.: “[Serving Large-Scale Batch Computed Data with Project Voldemort](http://static.usenix.org/events/fast12/tech/full_papers/Sumbaly.pdf),” at *10th USENIX Conference on File and Storage Technologies* (FAST), February 2012.
1. Varun Sharma: “[Open-Sourcing Terrapin: A Serving System for Batch Generated Data](https://web.archive.org/web/20170215032514/https://engineering.pinterest.com/blog/open-sourcing-terrapin-serving-system-batch-generated-data-0),” *engineering.pinterest.com*, September 14, 2015.
1. Nathan Marz: “[ElephantDB](http://www.slideshare.net/nathanmarz/elephantdb),” *slideshare.net*, May 30, 2011.
1. Jean-Daniel (JD) Cryans: “[How-to: Use HBase Bulk Loading, and Why](https://blog.cloudera.com/how-to-use-hbase-bulk-loading-and-why/),” *blog.cloudera.com*, September 27, 2013.
1. Nathan Marz: “[How to Beat the CAP Theorem](http://nathanmarz.com/blog/how-to-beat-the-cap-theorem.html),” *nathanmarz.com*, October 13, 2011.
1. Molly Bartlett Dishman and Martin Fowler: “[Agile Architecture](https://web.archive.org/web/20161130034721/http://conferences.oreilly.com/software-architecture/sa2015/public/schedule/detail/40388),” at *O'Reilly Software Architecture Conference*, March 2015.
1. David J. DeWitt and Jim N. Gray: “[Parallel Database Systems: The Future of High Performance Database Systems](http://www.cs.cmu.edu/~pavlo/courses/fall2013/static/papers/dewittgray92.pdf),” *Communications of the ACM*, volume 35, number 6, pages 8598, June 1992. [doi:10.1145/129888.129894](http://dx.doi.org/10.1145/129888.129894)
1. Jay Kreps: “[But the multi-tenancy thing is actually really really hard](https://twitter.com/jaykreps/status/528235702480142336),” tweetstorm, *twitter.com*, October 31, 2014.
1. Jeffrey Cohen, Brian Dolan, Mark Dunlap, et al.: “[MAD Skills: New Analysis Practices for Big Data](http://www.vldb.org/pvldb/vol2/vldb09-219.pdf),” *Proceedings of the VLDB Endowment*, volume 2, number 2, pages 14811492, August 2009. [doi:10.14778/1687553.1687576](http://dx.doi.org/10.14778/1687553.1687576)
1. Ignacio Terrizzano, Peter Schwarz, Mary Roth, and John E. Colino: “[Data Wrangling: The Challenging Journey from the Wild to the Lake](http://cidrdb.org/cidr2015/Papers/CIDR15_Paper2.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015.
1. Paige Roberts: “[To Schema on Read or to Schema on Write, That Is the Hadoop Data Lake Question](https://web.archive.org/web/20171105001306/http://adaptivesystemsinc.com/blog/to-schema-on-read-or-to-schema-on-write-that-is-the-hadoop-data-lake-question/),” *adaptivesystemsinc.com*, July 2, 2015.
1. Bobby Johnson and Joseph Adler: “[The Sushi Principle: Raw Data Is Better](https://web.archive.org/web/20161126104941/https://conferences.oreilly.com/strata/big-data-conference-ca-2015/public/schedule/detail/38737),” at *Strata+Hadoop World*, February 2015.
1. Vinod Kumar Vavilapalli, Arun C. Murthy, Chris Douglas, et al.: “[Apache Hadoop YARN: Yet Another Resource Negotiator](https://www.cs.cmu.edu/~garth/15719/papers/yarn.pdf),” at *4th ACM Symposium on Cloud Computing* (SoCC), October 2013. [doi:10.1145/2523616.2523633](http://dx.doi.org/10.1145/2523616.2523633)
1. Abhishek Verma, Luis Pedrosa, Madhukar Korupolu, et al.: “[Large-Scale Cluster Management at Google with Borg](http://research.google.com/pubs/pub43438.html),” at *10th European Conference on Computer Systems* (EuroSys), April 2015. [doi:10.1145/2741948.2741964](http://dx.doi.org/10.1145/2741948.2741964)
1. Malte Schwarzkopf: “[The Evolution of Cluster Scheduler Architectures](https://web.archive.org/web/20201109052657/http://www.firmament.io/blog/scheduler-architectures.html),” *firmament.io*, March 9, 2016.
1. Matei Zaharia, Mosharaf Chowdhury, Tathagata Das, et al.: “[Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing](https://www.usenix.org/system/files/conference/nsdi12/nsdi12-final138.pdf),” at *9th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), April 2012.
1. Holden Karau, Andy Konwinski, Patrick Wendell, and Matei Zaharia: *Learning Spark*. O'Reilly Media, 2015. ISBN: 978-1-449-35904-1
1. Bikas Saha and Hitesh Shah: “[Apache Tez: Accelerating Hadoop Query Processing](http://www.slideshare.net/Hadoop_Summit/w-1205phall1saha),” at *Hadoop Summit*, June 2014.
1. Bikas Saha, Hitesh Shah, Siddharth Seth, et al.: “[Apache Tez: A Unifying Framework for Modeling and Building Data Processing Applications](http://home.cse.ust.hk/~weiwa/teaching/Fall15-COMP6611B/reading_list/Tez.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2742790](http://dx.doi.org/10.1145/2723372.2742790)
1. Kostas Tzoumas: “[Apache Flink: API, Runtime, and Project Roadmap](http://www.slideshare.net/KostasTzoumas/apache-flink-api-runtime-and-project-roadmap),” *slideshare.net*, January 14, 2015.
1. Alexander Alexandrov, Rico Bergmann, Stephan Ewen, et al.: “[The Stratosphere Platform for Big Data Analytics](https://ssc.io/pdf/2014-VLDBJ_Stratosphere_Overview.pdf),” *The VLDB Journal*, volume 23, number 6, pages 939964, May 2014. [doi:10.1007/s00778-014-0357-y](http://dx.doi.org/10.1007/s00778-014-0357-y)
1. Michael Isard, Mihai Budiu, Yuan Yu, et al.: “[Dryad: Distributed Data-Parallel Programs from Sequential Building Blocks](https://www.microsoft.com/en-us/research/publication/dryad-distributed-data-parallel-programs-from-sequential-building-blocks/),” at *European Conference on Computer Systems* (EuroSys), March 2007. [doi:10.1145/1272996.1273005](http://dx.doi.org/10.1145/1272996.1273005)
1. Daniel Warneke and Odej Kao: “[Nephele: Efficient Parallel Data Processing in the Cloud](https://stratosphere2.dima.tu-berlin.de/assets/papers/Nephele_09.pdf),” at *2nd Workshop on Many-Task Computing on Grids and Supercomputers* (MTAGS), November 2009. [doi:10.1145/1646468.1646476](http://dx.doi.org/10.1145/1646468.1646476)
1. Lawrence Page, Sergey Brin, Rajeev Motwani, and Terry Winograd: “[The PageRank Citation Ranking: Bringing Order to the Web](https://web.archive.org/web/20230219170930/http://ilpubs.stanford.edu:8090/422/),” Stanford InfoLab Technical Report 422, 1999.
1. Leslie G. Valiant: “[A Bridging Model for Parallel Computation](http://dl.acm.org/citation.cfm?id=79181),” *Communications of the ACM*, volume 33, number 8, pages 103111, August 1990. [doi:10.1145/79173.79181](http://dx.doi.org/10.1145/79173.79181)
1. Stephan Ewen, Kostas Tzoumas, Moritz Kaufmann, and Volker Markl: “[Spinning Fast Iterative Data Flows](http://vldb.org/pvldb/vol5/p1268_stephanewen_vldb2012.pdf),” *Proceedings of the VLDB Endowment*, volume 5, number 11, pages 1268-1279, July 2012. [doi:10.14778/2350229.2350245](http://dx.doi.org/10.14778/2350229.2350245)
1. Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, et al.: “[Pregel: A System for Large-Scale Graph Processing](https://kowshik.github.io/JPregel/pregel_paper.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2010. [doi:10.1145/1807167.1807184](http://dx.doi.org/10.1145/1807167.1807184)
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015.
1. Ionel Gog, Malte Schwarzkopf, Natacha Crooks, et al.: “[Musketeer: All for One, One for All in Data Processing Systems](http://www.cl.cam.ac.uk/research/srg/netos/camsas/pubs/eurosys15-musketeer.pdf),” at *10th European Conference on Computer Systems* (EuroSys), April 2015. [doi:10.1145/2741948.2741968](http://dx.doi.org/10.1145/2741948.2741968)
1. Aapo Kyrola, Guy Blelloch, and Carlos Guestrin: “[GraphChi: Large-Scale Graph Computation on Just a PC](https://www.usenix.org/system/files/conference/osdi12/osdi12-final-126.pdf),” at *10th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2012.
1. Andrew Lenharth, Donald Nguyen, and Keshav Pingali: “[Parallel Graph Analytics](http://cacm.acm.org/magazines/2016/5/201591-parallel-graph-analytics/fulltext),” *Communications of the ACM*, volume 59, number 5, pages 7887, May 2016. [doi:10.1145/2901919](http://dx.doi.org/10.1145/2901919)
1. Fabian Hüske: “[Peeking into Apache Flink's Engine Room](http://flink.apache.org/news/2015/03/13/peeking-into-Apache-Flinks-Engine-Room.html),” *flink.apache.org*, March 13, 2015.
1. Mostafa Mokhtar: “[Hive 0.14 Cost Based Optimizer (CBO) Technical Overview](https://web.archive.org/web/20170607112708/http://hortonworks.com/blog/hive-0-14-cost-based-optimizer-cbo-technical-overview/),” *hortonworks.com*, March 2, 2015.
1. Michael Armbrust, Reynold S Xin, Cheng Lian, et al.: “[Spark SQL: Relational Data Processing in Spark](http://people.csail.mit.edu/matei/papers/2015/sigmod_spark_sql.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2742797](http://dx.doi.org/10.1145/2723372.2742797)
1. Daniel Blazevski: “[Planting Quadtrees for Apache Flink](https://blog.insightdatascience.com/planting-quadtrees-for-apache-flink-b396ebc80d35),” *insightdataengineering.com*, March 25, 2016.
1. Tom White: “[Genome Analysis Toolkit: Now Using Apache Spark for Data Processing](https://web.archive.org/web/20190215132904/http://blog.cloudera.com/blog/2016/04/genome-analysis-toolkit-now-using-apache-spark-for-data-processing/),” *blog.cloudera.com*, April 6, 2016.

817
content/v1/ch11.md Normal file
View File

@ -0,0 +1,817 @@
---
title: "第十一章:流处理"
linkTitle: "11. 流处理"
weight: 311
math: true
breadcrumbs: false
---
![](/map/ch11.png)
> 有效的复杂系统总是从简单的系统演化而来。反之亦然:从零设计的复杂系统没一个能有效工作的。
>
> —— 约翰・加尔Systemantics1975
在 [第十章](/v1/ch10) 中,我们讨论了批处理技术,它读取一组文件作为输入,并生成一组新的文件作为输出。输出是 **衍生数据derived data** 的一种形式;也就是说,如果需要,可以通过再次运行批处理过程来重新创建数据集。我们看到了如何使用这个简单而强大的想法来建立搜索索引、推荐系统、做分析等等。
然而,在 [第十章](/v1/ch10) 中仍然有一个很大的假设即输入是有界的即已知和有限的大小所以批处理知道它何时完成输入的读取。例如MapReduce 核心的排序操作必须读取其全部输入,然后才能开始生成输出:可能发生这种情况:最后一条输入记录具有最小的键,因此需要第一个被输出,所以提早开始输出是不可行的。
实际上,很多数据是 **无界限** 的,因为它随着时间的推移而逐渐到达:你的用户在昨天和今天产生了数据,明天他们将继续产生更多的数据。除非你停业,否则这个过程永远都不会结束,所以数据集从来就不会以任何有意义的方式 “完成”【1】。因此批处理程序必须将数据人为地分成固定时间段的数据块例如在每天结束时处理一天的数据或者在每小时结束时处理一小时的数据。
日常批处理中的问题是,输入的变更只会在一天之后的输出中反映出来,这对于许多急躁的用户来说太慢了。为了减少延迟,我们可以更频繁地运行处理 —— 比如说,在每秒钟的末尾 —— 或者甚至更连续一些,完全抛开固定的时间切片,当事件发生时就立即进行处理,这就是 **流处理stream processing** 背后的想法。
一般来说,“流” 是指随着时间的推移逐渐可用的数据。这个概念出现在很多地方Unix 的 stdin 和 stdout、编程语言惰性列表【2】、文件系统 API如 Java 的 `FileInputStream`、TCP 连接、通过互联网传送音频和视频等等。
在本章中,我们将把 **事件流event stream** 视为一种数据管理机制:无界限,增量处理,与上一章中的批量数据相对应。我们将首先讨论怎样表示、存储、通过网络传输流。在 “[数据库与流](#数据库与流)” 中,我们将研究流和数据库之间的关系。最后在 “[流处理](#流处理)” 中,我们将研究连续处理这些流的方法和工具,以及它们用于应用构建的方式。
## 传递事件流
在批处理领域,作业的输入和输出是文件(也许在分布式文件系统上)。流处理领域中的等价物看上去是什么样子的?
当输入是一个文件(一个字节序列),第一个处理步骤通常是将其解析为一系列记录。在流处理的上下文中,记录通常被叫做 **事件event** ,但它本质上是一样的:一个小的、自包含的、不可变的对象,包含某个时间点发生的某件事情的细节。一个事件通常包含一个来自日历时钟的时间戳,以指明事件发生的时间(请参阅 “[单调钟与日历时钟](/v1/ch8#单调钟与日历时钟)”)。
例如,发生的事件可能是用户采取的行动,例如查看页面或进行购买。它也可能来源于机器,例如对温度传感器或 CPU 利用率的周期性测量。在 “[使用 Unix 工具的批处理](/v1/ch10#使用Unix工具的批处理)” 的示例中Web 服务器日志的每一行都是一个事件。
事件可能被编码为文本字符串或 JSON或者某种二进制编码如 [第四章](/v1/ch4) 所述。这种编码允许你存储一个事件,例如将其追加到一个文件,将其插入关系表,或将其写入文档数据库。它还允许你通过网络将事件发送到另一个节点以进行处理。
在批处理中,文件被写入一次,然后可能被多个作业读取。类似地,在流处理术语中,一个事件由 **生产者producer** (也称为 **发布者publisher****发送者sender** )生成一次,然后可能由多个 **消费者consumer** **订阅者subscribers****接收者recipients** 进行处理【3】。在文件系统中文件名标识一组相关记录在流式系统中相关的事件通常被聚合为一个 **主题topic****流stream**
原则上讲,文件或数据库就足以连接生产者和消费者:生产者将其生成的每个事件写入数据存储,且每个消费者定期轮询数据存储,检查自上次运行以来新出现的事件。这实际上正是批处理在每天结束时处理当天数据时所做的事情。
但当我们想要进行低延迟的连续处理时,如果数据存储不是为这种用途专门设计的,那么轮询开销就会很大。轮询的越频繁,能返回新事件的请求比例就越低,而额外开销也就越高。相比之下,最好能在新事件出现时直接通知消费者。
数据库在传统上对这种通知机制支持的并不好,关系型数据库通常有 **触发器trigger** 它们可以对变化插入表中的一行作出反应但是它们的功能非常有限并且在数据库设计中有些后顾之忧【4,5】。相应的是已经开发了专门的工具来提供事件通知。
### 消息传递系统
向消费者通知新事件的常用方式是使用 **消息传递系统messaging system**:生产者发送包含事件的消息,然后将消息推送给消费者。我们之前在 “[消息传递中的数据流](/v1/ch4#消息传递中的数据流)” 中谈到了这些系统,但现在我们将详细介绍这些系统。
像生产者和消费者之间的 Unix 管道或 TCP 连接这样的直接信道是实现消息传递系统的简单方法。但是大多数消息传递系统都在这一基本模型上进行了扩展。特别的是Unix 管道和 TCP 将恰好一个发送者与恰好一个接收者连接,而一个消息传递系统允许多个生产者节点将消息发送到同一个主题,并允许多个消费者节点接收主题中的消息。
在这个 **发布 / 订阅** 模式中,不同的系统采取各种各样的方法,并没有针对所有目的的通用答案。为了区分这些系统,问一下这两个问题会特别有帮助:
1. **如果生产者发送消息的速度比消费者能够处理的速度快会发生什么?** 一般来说,有三种选择:系统可以丢掉消息,将消息放入缓冲队列,或使用 **背压**backpressure也称为 **流量控制**,即 flow control阻塞生产者以免其发送更多的消息。例如 Unix 管道和 TCP 就使用了背压:它们有一个固定大小的小缓冲区,如果填满,发送者会被阻塞,直到接收者从缓冲区中取出数据(请参阅 “[网络拥塞和排队](/v1/ch8#网络拥塞和排队)”)。
如果消息被缓存在队列中那么理解队列增长会发生什么是很重要的。当队列装不进内存时系统会崩溃吗还是将消息写入磁盘如果是这样磁盘访问又会如何影响消息传递系统的性能【6】
2. **如果节点崩溃或暂时脱机,会发生什么情况? —— 是否会有消息丢失?** 与数据库一样,持久性可能需要写入磁盘和 / 或复制的某种组合(请参阅 “[复制与持久性](/v1/ch7#复制与持久性)”),这是有代价的。如果你能接受有时消息会丢失,则可能在同一硬件上获得更高的吞吐量和更低的延迟。
是否可以接受消息丢失取决于应用。例如对于周期传输的传感器读数和指标偶尔丢失的数据点可能并不重要因为更新的值会在短时间内发出。但要注意如果大量的消息被丢弃可能无法立刻意识到指标已经不正确了【7】。如果你正在对事件计数那么它们能够可靠送达是更重要的因为每个丢失的消息都意味着使计数器的错误扩大。
我们在 [第十章](/v1/ch10) 中探讨的批处理系统的一个很好的特性是,它们提供了强大的可靠性保证:失败的任务会自动重试,失败任务的部分输出会自动丢弃。这意味着输出与没有发生故障一样,这有助于简化编程模型。在本章的后面,我们将研究如何在流处理的上下文中提供类似的保证。
#### 直接从生产者传递给消费者
许多消息传递系统使用生产者和消费者之间的直接网络通信,而不通过中间节点:
* UDP 组播广泛应用于金融行业例如股票市场其中低时延非常重要【8】。虽然 UDP 本身是不可靠的,但应用层的协议可以恢复丢失的数据包(生产者必须记住它发送的数据包,以便能按需重新发送数据包)。
* 无代理的消息库,如 ZeroMQ 【9】和 nanomsg 采取类似的方法,通过 TCP 或 IP 多播实现发布 / 订阅消息传递。
* StatsD 【10】和 Brubeck 【7】使用不可靠的 UDP 消息传递来收集网络中所有机器的指标并对其进行监控。(在 StatsD 协议中,只有接收到所有消息,才认为计数器指标是正确的;使用 UDP 将使得指标处在一种最佳近似状态【11】。另请参阅 “[TCP 与 UDP](/v1/ch8#TCP与UDP)”
* 如果消费者在网络上公开了服务,生产者可以直接发送 HTTP 或 RPC 请求(请参阅 “[服务中的数据流REST 与 RPC](/v1/ch4#服务中的数据流REST与RPC)”)将消息推送给使用者。这就是 webhooks 背后的想法【12】一种服务的回调 URL 被注册到另一个服务中,并且每当事件发生时都会向该 URL 发出请求。
尽管这些直接消息传递系统在设计它们的环境中运行良好,但是它们通常要求应用代码意识到消息丢失的可能性。它们的容错程度极为有限:即使协议检测到并重传在网络中丢失的数据包,它们通常也只是假设生产者和消费者始终在线。
如果消费者处于脱机状态,则可能会丢失其不可达时发送的消息。一些协议允许生产者重试失败的消息传递,但当生产者崩溃时,它可能会丢失消息缓冲区及其本应发送的消息,这种方法可能就没用了。
#### 消息代理
一种广泛使用的替代方法是通过 **消息代理**message broker也称为 **消息队列**,即 message queue发送消息消息代理实质上是一种针对处理消息流而优化的数据库。它作为服务器运行生产者和消费者作为客户端连接到服务器。生产者将消息写入代理消费者通过从代理那里读取来接收消息。
通过将数据集中在代理上,这些系统可以更容易地容忍来来去去的客户端(连接,断开连接和崩溃),而持久性问题则转移到代理的身上。一些消息代理只将消息保存在内存中,而另一些消息代理(取决于配置)将其写入磁盘,以便在代理崩溃的情况下不会丢失。针对缓慢的消费者,它们通常会允许无上限的排队(而不是丢弃消息或背压),尽管这种选择也可能取决于配置。
排队的结果是,消费者通常是 **异步asynchronous** 的:当生产者发送消息时,通常只会等待代理确认消息已经被缓存,而不等待消息被消费者处理。向消费者递送消息将发生在未来某个未定的时间点 —— 通常在几分之一秒之内,但有时当消息堆积时会显著延迟。
#### 消息代理与数据库的对比
有些消息代理甚至可以使用 XA 或 JTA 参与两阶段提交协议(请参阅 “[实践中的分布式事务](/v1/ch9#实践中的分布式事务)”)。这个功能与数据库在本质上非常相似,尽管消息代理和数据库之间仍存在实践上很重要的差异:
* 数据库通常保留数据直至显式删除,而大多数消息代理在消息成功递送给消费者时会自动删除消息。这样的消息代理不适合长期的数据存储。
* 由于它们很快就能删除消息,大多数消息代理都认为它们的工作集相当小 —— 即队列很短。如果代理需要缓冲很多消息比如因为消费者速度较慢如果内存装不下消息可能会溢出到磁盘每个消息需要更长的处理时间整体吞吐量可能会恶化【6】。
* 数据库通常支持次级索引和各种搜索数据的方式,而消息代理通常支持按照某种模式匹配主题,订阅其子集。虽然机制并不一样,但对于客户端选择想要了解的数据的一部分,都是基本的方式。
* 查询数据库时,结果通常基于某个时间点的数据快照;如果另一个客户端随后向数据库写入一些改变了查询结果的内容,则第一个客户端不会发现其先前结果现已过期(除非它重复查询或轮询变更)。相比之下,消息代理不支持任意查询,但是当数据发生变化时(即新消息可用时),它们会通知客户端。
这是关于消息代理的传统观点,它被封装在诸如 JMS 【14】和 AMQP 【15】的标准中并且被诸如 RabbitMQ、ActiveMQ、HornetQ、Qpid、TIBCO 企业消息服务、IBM MQ、Azure Service Bus 和 Google Cloud Pub/Sub 所实现 【16】。
#### 多个消费者
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/v1/ddia_1101.png) 所示:
负载均衡load balancing
: 每条消息都被传递给消费者 **之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在 AMQP 中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在 JMS 中则称之为 **共享订阅**,即 shared subscription
扇出fan-out
: 每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
![](/v1/ddia_1101.png)
**图 11-1 a负载平衡在消费者间共享消费主题b扇出将每条消息传递给多个消费者。**
两种模式可以组合使用:例如,两个独立的消费者组可以每组各订阅同一个主题,每一组都共同收到所有消息,但在每一组内部,每条消息仅由单个节点处理。
#### 确认与重新传递
消费者随时可能会崩溃,所以有一种可能的情况是:代理向消费者递送消息,但消费者没有处理,或者在消费者崩溃之前只进行了部分处理。为了确保消息不会丢失,消息代理使用 **确认acknowledgments**:客户端必须显式告知代理消息处理完毕的时间,以便代理能将消息从队列中移除。
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](/v1/ch9#实践中的分布式事务)” 中所讨论的那样)
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](/v1/ddia_1102.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
![](/v1/ddia_1102.png)
**图 11-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
即使消息代理试图保留消息的顺序(如 JMS 和 AMQP 标准所要求的),负载均衡与重传的组合也不可避免地导致消息被重新排序。为避免此问题,你可以让每个消费者使用单独的队列(即不使用负载均衡功能)。如果消息是完全独立的,则消息顺序重排并不是一个问题。但正如我们将在本章后续部分所述,如果消息之间存在因果依赖关系,这就是一个很重要的问题。
### 分区日志
通过网络发送数据包或向网络服务发送请求通常是短暂的操作,不会留下永久的痕迹。尽管可以永久记录(通过抓包与日志),但我们通常不这么做。即使是将消息持久地写入磁盘的消息代理,在送达给消费者之后也会很快删除消息,因为它们建立在短暂消息传递的思维方式上。
数据库和文件系统采用截然相反的方法论:至少在某人显式删除前,通常写入数据库或文件的所有内容都要被永久记录下来。
这种思维方式上的差异对创建衍生数据的方式有巨大影响。如 [第十章](/v1/ch10) 所述,批处理过程的一个关键特性是,你可以反复运行它们,试验处理步骤,不用担心损坏输入(因为输入是只读的)。而 AMQP/JMS 风格的消息传递并非如此:收到消息是具有破坏性的,因为确认可能导致消息从代理中被删除,因此你不能期望再次运行同一个消费者能得到相同的结果。
如果你将新的消费者添加到消息传递系统,通常只能接收到消费者注册之后开始发送的消息。先前的任何消息都随风而逝,一去不复返。作为对比,你可以随时为文件和数据库添加新的客户端,且能读取任意久远的数据(只要应用没有显式覆盖或删除这些数据)。
为什么我们不能把它俩杂交一下,既有数据库的持久存储方式,又有消息传递的低延迟通知?这就是 **基于日志的消息代理log-based message brokers** 背后的想法。
#### 使用日志进行消息存储
日志只是磁盘上简单的仅追加记录序列。我们先前在 [第三章](/v1/ch3) 中日志结构存储引擎和预写式日志的上下文中讨论了日志,在 [第五章](/v1/ch5) 复制的上下文里也讨论了它。
同样的结构可以用于实现消息代理生产者通过将消息追加到日志末尾来发送消息而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾则会等待新消息追加的通知。Unix 工具 `tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](/v1/ch6) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](/v1/ddia_1103.png) 所示。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 11-3](/v1/ddia_1103.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
![](/v1/ddia_1103.png)
**图 11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 DistributedLog 【20,21】都是基于日志的消息代理。Google Cloud Pub/Sub 在架构上类似,但对外暴露的是 JMS 风格的 API而不是日志抽象【16】。尽管这些消息代理将所有消息写入磁盘但通过跨多台机器分区每秒能够实现数百万条消息的吞吐量并通过复制消息来实现容错性【22,23】。
#### 日志与传统的消息传递相比
基于日志的方法天然支持扇出式消息传递,因为多个消费者可以独立读取日志,而不会相互影响 —— 读取消息不会将其从日志中删除。为了在一组消费者之间实现负载平衡,代理可以将整个分区分配给消费者组中的节点,而不是将单条消息分配给消费者客户端。
然后每个客户端将消费被指派分区中的 **所有** 消息。通常情况下,当一个用户被指派了一个日志分区时,它会以简单的单线程方式顺序地读取分区中的消息。这种粗粒度的负载均衡方法有一些缺点:
* 共享消费主题工作的节点数,最多为该主题中的日志分区数,因为同一个分区内的所有消息被递送到同一个节点 [^i]。
* 如果某条消息处理缓慢,则它会阻塞该分区中后续消息的处理(一种行首阻塞的形式;请参阅 “[描述性能](/v1/ch1#描述性能)”)。
因此在消息处理代价高昂希望逐条并行处理以及消息的顺序并没有那么重要的情况下JMS/AMQP 风格的消息代理是可取的。另一方面,在消息吞吐量很高,处理迅速,顺序很重要的情况下,基于日志的方法表现得非常好。
[^i]: 要设计一种负载均衡方案也是有可能的,在这种方案中,两个消费者通过读取全部消息来共享分区处理的工作,但是其中一个只考虑具有偶数偏移量的消息,而另一个消费者只处理奇数编号的偏移量。或者你可以将消息摊到一个线程池中来处理,但这种方法会使消费者偏移量管理变得复杂。一般来说,单线程处理单分区是合适的,可以通过增加更多分区来提高并行度。
#### 消费者偏移量
顺序消费一个分区使得判断消息是否已经被处理变得相当容易:所有偏移量小于消费者的当前偏移量的消息已经被处理,而具有更大偏移量的消息还没有被看到。因此,代理不需要跟踪确认每条消息,只需要定期记录消费者的偏移即可。这种方法减少了额外簿记开销,而且在批处理和流处理中采用这种方法有助于提高基于日志的系统的吞吐量。
实际上,这种偏移量与单领导者数据库复制中常见的日志序列号非常相似,我们在 “[设置新从库](/v1/ch5#设置新从库)” 中讨论了这种情况。在数据库复制中,日志序列号允许跟随者断开连接后,重新连接到领导者,并在不跳过任何写入的情况下恢复复制。这里原理完全相同:消息代理表现得像一个主库,而消费者就像一个从库。
如果消费者节点失效,则失效消费者的分区将指派给其他节点,并从最后记录的偏移量开始消费消息。如果消费者已经处理了后续的消息,但还没有记录它们的偏移量,那么重启后这些消息将被处理两次。我们将在本章后面讨论这个问题的处理方法。
#### 磁盘空间使用
如果只追加写入日志,则磁盘空间终究会耗尽。为了回收磁盘空间,日志实际上被分割成段,并不时地将旧段删除或移动到归档存储。(我们将在后面讨论一种更为复杂的磁盘空间释放方式)
这就意味着如果一个慢消费者跟不上消息产生的速率而落后得太多,它的消费偏移量指向了删除的段,那么它就会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区填满时会丢弃旧消息,它也被称为 **循环缓冲区circular buffer****环形缓冲区ring buffer**。不过由于缓冲区在磁盘上,因此缓冲区可能相当的大。
让我们做个简单计算。在撰写本文时,典型的大型硬盘容量为 6TB顺序写入吞吐量为 150MB/s。如果以最快的速度写消息则需要大约 11 个小时才能填满磁盘。因而磁盘可以缓冲 11 个小时的消息,之后它将开始覆盖旧的消息。即使使用多个磁盘和机器,这个比率也是一样的。实践中的部署很少能用满磁盘的写入带宽,所以通常可以保存一个几天甚至几周的日志缓冲区。
不管保留多长时间的消息日志的吞吐量或多或少保持不变因为无论如何每个消息都会被写入磁盘【18】。这种行为与默认将消息保存在内存中仅当队列太长时才写入磁盘的消息传递系统形成鲜明对比。当队列很短时这些系统非常快而当这些系统开始写入磁盘时就要慢的多所以吞吐量取决于保留的历史数量。
#### 当消费者跟不上生产者时
在 “[消息传递系统](#消息传递系统)” 中,如果消费者无法跟上生产者发送信息的速度时,我们讨论了三种选择:丢弃信息,进行缓冲或施加背压。在这种分类法里,基于日志的方法是缓冲的一种形式,具有很大但大小固定的缓冲区(受可用磁盘空间的限制)。
如果消费者远远落后,而所要求的信息比保留在磁盘上的信息还要旧,那么它将不能读取这些信息,所以代理实际上丢弃了比缓冲区容量更大的旧信息。你可以监控消费者落后日志头部的距离,如果落后太多就发出报警。由于缓冲区很大,因而有足够的时间让运维人员来修复慢消费者,并在消息开始丢失之前让其赶上。
即使消费者真的落后太多开始丢失消息,也只有那个消费者受到影响;它不会中断其他消费者的服务。这是一个巨大的运维优势:你可以实验性地消费生产日志,以进行开发,测试或调试,而不必担心会中断生产服务。当消费者关闭或崩溃时,会停止消耗资源,唯一剩下的只有消费者偏移量。
这种行为也与传统的消息代理形成了鲜明对比,在那种情况下,你需要小心地删除那些消费者已经关闭的队列 —— 否则那些队列就会累积不必要的消息,从其他仍活跃的消费者那里占走内存。
#### 重播旧消息
我们之前提到,使用 AMQP 和 JMS 风格的消息代理,处理和确认消息是一个破坏性的操作,因为它会导致消息在代理上被删除。另一方面,在基于日志的消息代理中,使用消息更像是从文件中读取数据:这是只读操作,不会更改日志。
除了消费者的任何输出之外,处理的唯一副作用是消费者偏移量的前进。但偏移量是在消费者的控制之下的,所以如果需要的话可以很容易地操纵:例如你可以用昨天的偏移量跑一个消费者副本,并将输出写到不同的位置,以便重新处理最近一天的消息。你可以使用各种不同的处理代码重复任意次。
这一方面使得基于日志的消息传递更像上一章的批处理其中衍生数据通过可重复的转换过程与输入数据显式分离。它允许进行更多的实验更容易从错误和漏洞中恢复使其成为在组织内集成数据流的良好工具【24】。
## 数据库与流
我们已经在消息代理和数据库之间进行了一些比较。尽管传统上它们被视为单独的工具类别,但是我们看到基于日志的消息代理已经成功地从数据库中获取灵感并将其应用于消息传递。我们也可以反过来:从消息传递和流中获取灵感,并将它们应用于数据库。
我们之前曾经说过,事件是某个时刻发生的事情的记录。发生的事情可能是用户操作(例如键入搜索查询)或读取传感器,但也可能是 **写入数据库**。某些东西被写入数据库的事实是可以被捕获、存储和处理的事件。这一观察结果表明,数据库和数据流之间的联系不仅仅是磁盘日志的物理存储 —— 而是更深层的联系。
事实上,复制日志(请参阅 “[复制日志的实现](/v1/ch5#复制日志的实现)”)是一个由数据库写入事件组成的流,由主库在处理事务时生成。从库将写入流应用到它们自己的数据库副本,从而最终得到相同数据的精确副本。复制日志中的事件描述发生的数据更改。
我们还在 “[全序广播](/v1/ch9#全序广播)” 中遇到了状态机复制原理,其中指出:如果每个事件代表对数据库的写入,并且每个副本按相同的顺序处理相同的事件,则副本将达到相同的最终状态 (假设事件处理是一个确定性的操作)。这是事件流的又一种场景!
在本节中,我们将首先看看异构数据系统中出现的一个问题,然后探讨如何通过将事件流的想法带入数据库来解决这个问题。
### 保持系统同步
正如我们在本书中所看到的,没有一个系统能够满足所有的数据存储、查询和处理需求。在实践中,大多数重要应用都需要组合使用几种不同的技术来满足所有的需求:例如,使用 OLTP 数据库来为用户请求提供服务,使用缓存来加速常见请求,使用全文索引来处理搜索查询,使用数据仓库用于分析。每一种技术都有自己的数据副本,并根据自己的目的进行存储方式的优化。
由于相同或相关的数据出现在了不同的地方,因此相互间需要保持同步:如果某个项目在数据库中被更新,它也应当在缓存、搜索索引和数据仓库中被更新。对于数据仓库,这种同步通常由 ETL 进程执行(请参阅 “[数据仓库](/v1/ch3#数据仓库)”),通常是先取得数据库的完整副本,然后执行转换,并批量加载到数据仓库中 —— 换句话说,批处理。我们在 “[批处理工作流的输出](/v1/ch10#批处理工作流的输出)” 中同样看到了如何使用批处理创建搜索索引、推荐系统和其他衍生数据系统。
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写dual write**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/v1/ddia_1104.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
![](/v1/ddia_1104.png)
**图 11-4 在数据库中 X 首先被设置为 A然后被设置为 B而在搜索索引处写入以相反的顺序到达**
除非有一些额外的并发检测机制,例如我们在 “[检测并发写入](/v1/ch5#检测并发写入)” 中讨论的版本向量,否则你甚至不会意识到发生了并发写入 —— 一个值将简单地以无提示方式覆盖另一个值。
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](/v1/ch9#原子提交与两阶段提交)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/v1/ddia_1104.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/v1/ch5#多主复制)”)。
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
### 变更数据捕获
大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的 API。客户端应该通过其数据模型和查询语言来查询数据库而不是解析复制日志并尝试从中提取数据。
数十年来,许多数据库根本没有记录在档的获取变更日志的方式。由于这个原因,捕获数据库中所有的变更,然后将其复制到其他存储技术(搜索索引、缓存或数据仓库)中是相当困难的。
最近,人们对 **变更数据捕获change data capture, CDC** 越来越感兴趣这是一种观察写入数据库的所有数据变更并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 11-5](/v1/ddia_1105.png) 所示。
![](/v1/ddia_1105.png)
**图 11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
#### 变更数据捕获的实现
我们可以将日志消费者叫做 **衍生数据系统**,正如在 [第三部分](/part-iii) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在衍生数据系统中,以便衍生系统具有数据的准确副本。
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/v1/ddia_1102.png) 的重新排序问题)。
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](/v1/ch5#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
LinkedIn 的 Databus【25】Facebook 的 Wormhole【26】和 Yahoo! 的 Sherpa【27】大规模地应用这个思路。Bottled Water 使用解码 WAL 的 API 实现了 PostgreSQL 的 CDC【28】Maxwell 和 Debezium 通过解析 binlog 对 MySQL 做了类似的事情【29,30,31】Mongoriver 读取 MongoDB oplog【32,33】而 GoldenGate 为 Oracle 提供类似的功能【34,35】。
类似于消息代理,变更数据捕获通常是异步的:记录数据库系统在提交变更之前不会等待消费者应用变更。这种设计具有的运维优势是,添加缓慢的消费者不会过度影响记录系统。不过,所有复制延迟可能有的问题在这里都可能出现(请参阅 “[复制延迟问题](/v1/ch5#复制延迟问题)”)。
#### 初始快照
如果你拥有 **所有** 对数据库进行变更的日志,则可以通过重播该日志,来重建数据库的完整状态。但是在许多情况下,永远保留所有更改会耗费太多磁盘空间,且重播过于费时,因此日志需要被截断。
例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最近变更的日志是不够的,因为这样会丢失最近未曾更新的项目。因此,如果你没有完整的历史日志,则需要从一个一致的快照开始,如先前的 “[设置新从库](/v1/ch5#设置新从库)” 中所述。
数据库的快照必须与变更日志中的已知位置或偏移量相对应,以便在处理完快照后知道从哪里开始应用变更。一些 CDC 工具集成了这种快照功能,而其他工具则把它留给你手动执行。
#### 日志压缩
如果你只能保留有限的历史日志,则每次要添加新的衍生数据系统时,都需要做一次快照。但 **日志压缩log compaction** 提供了一个很好的备选方案。
我们之前在 “[散列索引](/v1/ch3#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](/v1/ddia_0302.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
在日志结构存储引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示该键被删除并会在日志压缩过程中被移除。但只要键不被覆盖或删除它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入则先前的值将最终将被垃圾回收只有最新的值会保留下来。
在基于日志的消息代理与变更数据捕获的上下文中也适用相同的想法。如果 CDC 系统被配置为,每个变更都包含一个主键,且每个键的更新都替换了该键以前的值,那么只需要保留对键的最新写入就足够了。
现在,无论何时需要重建衍生数据系统(如搜索索引),你可以从压缩日志主题的零偏移量处启动新的消费者,然后依次扫描日志中的所有消息。日志能保证包含数据库中每个键的最新值(也可能是一些较旧的值)—— 换句话说,你可以使用它来获取数据库内容的完整副本,而无需从 CDC 源数据库取一个快照。
Apache Kafka 支持这种日志压缩功能。正如我们将在本章后面看到的,它允许消息代理被当成持久性存储使用,而不仅仅是用于临时消息。
#### 变更流的API支持
越来越多的数据库开始将变更流作为第一等的接口,而不像传统上要去做加装改造,或者费工夫逆向工程一个 CDC。例如RethinkDB 允许查询订阅通知当查询结果变更时获得通知【36】Firebase 【37】和 CouchDB 【38】基于变更流进行同步该变更流同样可用于应用。而 Meteor 使用 MongoDB oplog 订阅数据变更并改变了用户接口【39】。
VoltDB 允许事务以流的形式连续地从数据库中导出数据【40】。数据库将关系数据模型中的输出流表示为一个表事务可以向其中插入元组但不能查询。已提交事务按照提交顺序写入这个特殊表而流则由该表中的元组日志构成。外部消费者可以异步消费该日志并使用它来更新衍生数据系统。
Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获工具与 Kafka 集成。一旦变更事件进入 Kafka 中,它就可以用于更新衍生数据系统,比如搜索索引,也可以用于本章稍后讨论的流处理系统。
### 事件溯源
我们在这里讨论的想法和 **事件溯源Event Sourcing** 之间有一些相似之处,这是一个在 **领域驱动设计domain-driven design, DDD** 社区中折腾出来的技术。我们将简要讨论事件溯源,因为它包含了一些关于流处理系统的有用想法。
与变更数据捕获类似,事件溯源涉及到 **将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
* 在变更数据捕获中,应用以 **可变方式mutable way** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/v1/ddia_1104.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用 Bug请参阅 “[不可变事件的优点](#不可变事件的优点)”)。
例如,存储 “学生取消选课” 事件以中性的方式清楚地表达了单个行为的意图,而其副作用 “从登记表中删除了一个条目,而一条取消原因的记录被添加到学生反馈表” 则嵌入了很多有关稍后对数据的使用方式的假设。如果引入一个新的应用功能,例如 “将位置留给等待列表中的下一个人” —— 事件溯源方法允许将新的副作用轻松地从现有事件中脱开。
事件溯源类似于 **编年史chronicle** 数据模型【45】事件日志与星型模式中的事实表之间也存在相似之处请参阅 “[星型和雪花型:分析的模式](/v1/ch3#星型和雪花型:分析的模式)”) 。
诸如 Event Store【46】这样的专业数据库已经被开发出来供使用事件溯源的应用使用但总的来说这种方法独立于任何特定的工具。传统的数据库或基于日志的消息代理也可以用来构建这种风格的应用。
#### 从事件日志中派生出当前状态
事件日志本身并不是很有用,因为用户通常期望看到的是系统的当前状态,而不是变更历史。例如,在购物网站上,用户期望能看到他们购物车里的当前内容,而不是他们购物车所有变更的一个仅追加列表。
因此,使用事件溯源的应用需要拉取事件日志(表示 **写入** 系统的数据),并将其转换为适合向用户显示的应用状态(从系统 **读取** 数据的方式【47】。这种转换可以使用任意逻辑但它应当是确定性的以便能再次运行并从事件日志中衍生出相同的应用状态。
与变更数据捕获一样,重播事件日志允许让你重新构建系统的当前状态。不过,日志压缩需要采用不同的方式处理:
* 用于记录更新的 CDC 事件通常包含记录的 **完整新版本**,因此主键的当前值完全由该主键的最近事件确定,而日志压缩可以丢弃相同主键的先前事件。
* 另一方面,事件溯源在更高层次进行建模:事件通常表示用户操作的意图,而不是因为操作而发生的状态更新机制。在这种情况下,后面的事件通常不会覆盖先前的事件,所以你需要完整的历史事件来重新构建最终状态。这里进行同样的日志压缩是不可能的。
使用事件溯源的应用通常有一些机制,用于存储从事件日志中导出的当前状态快照,因此它们不需要重复处理完整的日志。然而这只是一种性能优化,用来加速读取,提高从崩溃中恢复的速度;真正的目的是系统能够永久存储所有原始事件,并在需要时重新处理完整的事件日志。我们将在 “[不变性的局限性](#不变性的局限性)” 中讨论这个假设。
#### 命令和事件
事件溯源的哲学是仔细区分 **事件event****命令command**【48】。当来自用户的请求刚到达时它一开始是一个命令在这个时间点上它仍然可能失败比如因为违反了一些完整性条件。应用必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受则它变为一个持久化且不可变的事件。
例如,如果用户试图注册特定用户名,或预定飞机或剧院的座位,则应用需要检查用户名或座位是否已被占用。(先前在 “[容错共识](/v1/ch9#容错共识)” 中讨论过这个例子)当检查成功时,应用可以生成一个事件,指示特定的用户名是由特定的用户 ID 注册的,或者座位已经预留给特定的顾客。
在事件生成的时刻,它就成为了 **事实fact**。即使客户稍后决定更改或取消预订,他们之前曾预定了某个特定座位的事实仍然成立,而更改或取消是之后添加的单独的事件。
事件流的消费者不允许拒绝事件:当消费者看到事件时,它已经成为日志中不可变的一部分,并且可能已经被其他消费者看到了。因此任何对命令的验证,都需要在它成为事件之前同步完成。例如,通过使用一个可以原子性地自动验证命令并发布事件的可串行事务。
或者,预订座位的用户请求可以拆分为两个事件:第一个是暂时预约,第二个是验证预约后的独立的确认事件(如 “[使用全序广播实现线性一致的存储](/v1/ch9#使用全序广播实现线性一致的存储)” 中所述) 。这种分割方式允许验证发生在一个异步的过程中。
### 状态、流和不变性
我们在 [第十章](/v1/ch10) 中看到,批处理因其输入文件不变性而受益良多,你可以在现有输入文件上运行实验性处理作业,而不用担心损坏它们。这种不变性原则也是使得事件溯源与变更数据捕获如此强大的原因。
我们通常将数据库视为应用程序当前状态的存储 —— 这种表示针对读取进行了优化,而且通常对于服务查询而言是最为方便的表示。状态的本质是,它会变化,所以数据库才会支持数据的增删改。这又该如何匹配不变性呢?
只要你的状态发生了变化,那么这个状态就是这段时间中事件修改的结果。例如,当前可用的座位列表是你已处理的预订所产生的结果,当前帐户余额是帐户中的借与贷的结果,而 Web 服务器的响应时间图,是所有已发生 Web 请求的独立响应时间的聚合结果。
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志changelog**,表示了随时间演变的状态。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/v1/ddia_1106.png) 所示【49,50,51】。这个比喻有一些局限性例如状态的二阶导似乎没有意义但这是考虑数据的一个实用出发点。
$$
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt}
$$
![](/v1/ddia_1106.png)
**图 11-6 应用当前状态与事件流之间的关系**
如果你持久存储了变更日志那么重现状态就非常简单。如果你认为事件日志是你的记录系统而所有的衍生状态都从它派生而来那么系统中的数据流动就容易理解的多。正如帕特・赫兰Pat Helland所说的【52】
> 事务日志记录了数据库的所有变更。高速追加是更改日志的唯一方法。从这个角度来看,数据库的内容其实是日志中记录最新值的缓存。日志才是真相,数据库是日志子集的缓存,这一缓存子集恰好来自日志中每条记录与索引值的最新值。
日志压缩(如 “[日志压缩](#日志压缩)” 中所述)是连接日志与数据库状态之间的桥梁:它只保留每条记录的最新版本,并丢弃被覆盖的版本。
#### 不可变事件的优点
数据库中的不变性是一个古老的概念。例如会计在几个世纪以来一直在财务记账中应用不变性。一笔交易发生时它被记录在一个仅追加写入的分类帐中实质上是描述货币、商品或服务转手的事件日志。账目比如利润、亏损、资产负债表是从分类账中的交易求和衍生而来【53】。
如果发生错误,会计师不会删除或更改分类帐中的错误交易 —— 而是添加另一笔交易以补偿错误例如退还一笔不正确的费用。不正确的交易将永远保留在分类帐中对于审计而言可能非常重要。如果从不正确的分类账衍生出的错误数字已经公布那么下一个会计周期的数字就会包括一个更正。这个过程在会计事务中是很常见的【54】。
尽管这种可审计性只在金融系统中尤其重要,但对于不受这种严格监管的许多其他系统,也是很有帮助的。如 “[批处理输出的哲学](/v1/ch10#批处理输出的哲学)” 中所讨论的,如果你意外地部署了将错误数据写入数据库的错误代码,当代码会破坏性地覆写数据时,恢复要困难得多。使用不可变事件的仅追加日志,诊断问题与故障恢复就要容易的多。
不可变的事件也包含了比当前状态更多的信息。例如在购物网站上顾客可以将物品添加到他们的购物车然后再将其移除。虽然从履行订单的角度第二个事件取消了第一个事件但对分析目的而言知道客户考虑过某个特定项而之后又反悔可能是很有用的。也许他们会选择在未来购买或者他们已经找到了替代品。这个信息被记录在事件日志中但对于移出购物车就删除记录的数据库而言这个信息在移出购物车时可能就丢失了【42】。
#### 从同一事件日志中派生多个视图
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/v1/ddia_1105.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统如搜索服务器来说是很有意义的当系统要从分布式日志中获取输入时亦然请参阅 “[保持系统同步](#保持系统同步)”)。
添加从事件日志到数据库的显式转换能够使应用更容易地随时间演进如果你想要引入一个新功能以新的方式表示现有数据则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统你可以简单地关闭它并回收其资源【47,57】。
如果你不需要担心如何查询与访问数据,那么存储数据通常是非常简单的。模式设计、索引和存储引擎的许多复杂性,都是希望支持某些特定查询和访问模式的结果(请参阅 [第三章](/v1/ch3))。出于这个原因,通过将数据写入的形式与读取形式相分离,并允许几个不同的读取视图,你能获得很大的灵活性。这个想法有时被称为 **命令查询责任分离command query responsibility segregation, CQRS**【42,58,59】。
数据库和模式设计的传统方法是基于这样一种谬论,数据必须以与查询相同的形式写入。如果可以将数据从针对写入优化的事件日志转换为针对读取优化的应用状态,那么有关规范化和非规范化的争论就变得无关紧要了(请参阅 “[多对一和多对多的关系](/v1/ch2#多对一和多对多的关系)”):在针对读取优化的视图中对数据进行非规范化是完全合理的,因为翻译过程提供了使其与事件日志保持一致的机制。
在 “[描述负载](/v1/ch1#描述负载)” 中,我们讨论了推特主页时间线,它是特定用户关注的人群所发推特的缓存(类似邮箱)。这是 **针对读取优化的状态** 的又一个例子:主页时间线是高度非规范化的,因为你的推文与你所有粉丝的时间线都构成了重复。然而,扇出服务保持了这种重复状态与新推特以及新关注关系的同步,从而保证了重复的可管理性。
#### 并发控制
事件溯源和变更数据捕获的最大缺点是,事件日志的消费者通常是异步的,所以可能会出现这样的情况:用户会写入日志,然后从日志衍生视图中读取,结果发现他的写入还没有反映在读取视图中。我们之前在 “[读己之写](/v1/ch5#读己之写)” 中讨论了这个问题以及可能的解决方案。
一种解决方案是将事件追加到日志时同步执行读取视图的更新。而将这些写入操作合并为一个原子单元需要 **事务**,所以要么将事件日志和读取视图保存在同一个存储系统中,要么就需要跨不同系统进行分布式事务。或者,你也可以使用在 “[使用全序广播实现线性一致的存储](/v1/ch9#使用全序广播实现线性一致的存储)” 中讨论的方法。
另一方面,从事件日志导出当前状态也简化了并发控制的某些部分。许多对于多对象事务的需求(请参阅 “[单对象和多对象操作](/v1/ch7#单对象和多对象操作)”)源于单个用户操作需要在多个不同的位置更改数据。通过事件溯源,你可以设计一个自包含的事件以表示一个用户操作。然后用户操作就只需要在一个地方进行单次写入操作 —— 即将事件附加到日志中 —— 这个还是很容易使原子化的。
如果事件日志与应用状态以相同的方式分区(例如,处理分区 3 中的客户事件只需要更新分区 3 中的应用状态),那么直接使用单线程日志消费者就不需要写入并发控制了。它从设计上一次只处理一个事件(请参阅 “[真的串行执行](/v1/ch7#真的串行执行)”。日志通过在分区中定义事件的序列顺序消除了并发性的不确定性【24】。如果一个事件触及多个状态分区那么需要做更多的工作我们将在 [第十二章](/v1/ch12) 讨论。
#### 不变性的局限性
许多不使用事件溯源模型的系统也还是依赖不可变性:各种数据库在内部使用不可变的数据结构或多版本数据来支持时间点快照(请参阅 “[索引和快照隔离](/v1/ch7#索引和快照隔离)” 。Git、Mercurial 和 Fossil 等版本控制系统也依靠不可变的数据来保存文件的版本历史记录。
永远保持所有变更的不变历史,在多大程度上是可行的?答案取决于数据集的流失率。一些工作负载主要是添加数据,很少更新或删除;它们很容易保持不变。其他工作负载在相对较小的数据集上有较高的更新 / 删除率在这些情况下不可变的历史可能增至难以接受的巨大碎片化可能成为一个问题压缩与垃圾收集的表现对于运维的稳健性变得至关重要【60,61】。
除了性能方面的原因外,也可能有出于管理方面的原因需要删除数据的情况,尽管这些数据都是不可变的。例如,隐私条例可能要求在用户关闭帐户后删除他们的个人信息,数据保护立法可能要求删除错误的信息,或者可能需要阻止敏感信息的意外泄露。
在这种情况下,仅仅在日志中添加另一个事件来指明先前的数据应该被视为删除是不够的 —— 你实际上是想改写历史并假装数据从一开始就没有写入。例如Datomic 管这个特性叫 **切除excision** 【62】而 Fossil 版本控制系统有一个类似的概念叫 **避免shunning** 【63】。
真正删除数据是非常非常困难的【64】因为副本可能存在于很多地方例如存储引擎文件系统和 SSD 通常会向一个新位置写入而不是原地覆盖旧数据【52】而备份通常是特意做成不可变的防止意外删除或损坏。删除操作更多的是指 “使取回数据更困难”,而不是指 “使取回数据不可能”。无论如何,有时你必须得尝试,正如我们在 “[立法与自律](/v1/ch12#立法与自律)” 中所看到的。
## 流处理
到目前为止,本章中我们已经讨论了流的来源(用户活动事件,传感器和写入数据库),我们讨论了流如何传输(直接通过消息传送,通过消息代理,通过事件日志)。
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/v1/ddia_1105.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/v1/ch10#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2
在本章的剩余部分中,我们将讨论选项 3处理流以产生其他衍生流。处理这样的流的代码片段被称为 **算子operator****作业job**。它与我们在 [第十章](/v1/ch10) 中讨论过的 Unix 进程和 MapReduce 作业密切相关,数据流的模式是相似的:一个流处理器以只读的方式使用输入流,并将其输出以仅追加的方式写入一个不同的位置。
流处理中的分区和并行化模式也非常类似于 [第十章](/v1/ch10) 中介绍的 MapReduce 和数据流引擎,因此我们不再重复这些主题。基本的 Map 操作(如转换和过滤记录)也是一样的。
与批量作业相比的一个关键区别是,流不会结束。这种差异会带来很多隐含的结果。正如本章开始部分所讨论的,排序对无界数据集没有意义,因此无法使用 **排序合并连接**(请参阅 “[Reduce 侧连接与分组](/v1/ch10#Reduce侧连接与分组)”)。容错机制也必须改变:对于已经运行了几分钟的批处理作业,可以简单地从头开始重启失败任务,但是对于已经运行数年的流作业,重启后从头开始跑可能并不是一个可行的选项。
### 流处理的应用
长期以来,流处理一直用于监控目的,如果某个事件发生,组织希望能得到警报。例如:
* 欺诈检测系统需要确定信用卡的使用模式是否有意外地变化,如果信用卡可能已被盗刷,则锁卡。
* 交易系统需要检查金融市场的价格变化,并根据指定的规则进行交易。
* 制造系统需要监控工厂中机器的状态,如果出现故障,可以快速定位问题。
* 军事和情报系统需要跟踪潜在侵略者的活动,并在出现袭击征兆时发出警报。
这些类型的应用需要非常精密复杂的模式匹配与相关检测。然而随着时代的进步,流处理的其他用途也开始出现。在本节中,我们将简要比较一下这些应用。
#### 复合事件处理
**复合事件处理complex event processing, CEP** 是 20 世纪 90 年代为分析事件流而开发出的一种方法尤其适用于需要搜索某些事件模式的应用【65,66】。与正则表达式允许你在字符串中搜索特定字符模式的方式类似CEP 允许你指定规则以在流中搜索某些事件模式。
CEP 系统通常使用高层次的声明式查询语言,比如 SQL或者图形用户界面来描述应该检测到的事件模式。这些查询被提交给处理引擎该引擎消费输入流并在内部维护一个执行所需匹配的状态机。当发现匹配时引擎发出一个 **复合事件**(即 complex eventCEP 因此得名并附有检测到的事件模式详情【67】。
在这些系统中查询和数据之间的关系与普通数据库相比是颠倒的。通常情况下数据库会持久存储数据并将查询视为临时的当查询进入时数据库搜索与查询匹配的数据然后在查询完成时丢掉查询。CEP 引擎反转了角色查询是长期存储的来自输入流的事件不断流过它们搜索匹配事件模式的查询【68】。
CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIBCO StreamBase 和 SQLstream。像 Samza 这样的分布式流处理组件,支持使用 SQL 在流上进行声明式查询【71】。
#### 流分析
使用流处理的另一个领域是对流进行分析。CEP 与流分析之间的边界是模糊的,但一般来说,分析往往对找出特定事件序列并不关心,而更关注大量事件上的聚合与统计指标 —— 例如:
* 测量某种类型事件的速率(每个时间间隔内发生的频率)
* 滚动计算一段时间窗口内某个值的平均值
* 将当前的统计值与先前的时间区间的值对比(例如,检测趋势,当指标与上周同比异常偏高或偏低时报警)
这些统计值通常是在固定时间区间内进行计算的,例如,你可能想知道在过去 5 分钟内服务每秒查询次数的均值,以及此时间段内响应时间的第 99 百分位点。在几分钟内取平均,能抹平秒和秒之间的无关波动,且仍然能向你展示流量模式的时间图景。聚合的时间间隔称为 **窗口window**,我们将在 “[时间推理](#时间推理)” 中更详细地讨论窗口。
流分析系统有时会使用概率算法,例如 Bloom filter我们在 “[性能优化](/v1/ch3#性能优化)” 中遇到过来管理成员资格HyperLogLog【72】用于基数估计以及各种百分比估计算法请参阅 “[实践中的百分位点](/v1/ch1#实践中的百分位点)”。概率算法产出近似的结果但比起精确算法的优点是内存使用要少得多。使用近似算法有时让人们觉得流处理系统总是有损的和不精确的但这是错误看法流处理并没有任何内在的近似性而概率算法只是一种优化【73】。
许多开源分布式流处理框架的设计都是针对分析设计的:例如 Apache Storm、Spark Streaming、Flink、Concord、Samza 和 Kafka Streams 【74】。托管服务包括 Google Cloud Dataflow 和 Azure Stream Analytics。
#### 维护物化视图
我们在 “[数据库与流](#数据库与流)” 中看到,数据库的变更流可以用于维护衍生数据系统(如缓存、搜索索引和数据仓库),并使其与源数据库保持最新。我们可以将这些示例视作维护 **物化视图materialized view** 的一种具体场景(请参阅 “[聚合:数据立方体和物化视图](/v1/ch3#聚合:数据立方体和物化视图)”在某个数据集上衍生出一个替代视图以便高效查询并在底层数据变更时更新视图【50】。
同样,在事件溯源中,应用程序的状态是通过应用事件日志来维护的;这里的应用程序状态也是一种物化视图。与流分析场景不同的是,仅考虑某个时间窗口内的事件通常是不够的:构建物化视图可能需要任意时间段内的 **所有** 事件,除了那些可能由日志压缩丢弃的过时事件(请参阅 “[日志压缩](#日志压缩)”)。实际上,你需要一个可以一直延伸到时间开端的窗口。
原则上讲,任何流处理组件都可以用于维护物化视图,尽管 “永远运行” 与一些面向分析的框架假设的 “主要在有限时间段窗口上运行” 背道而驰Samza 和 Kafka Streams 支持这种用法,建立在 Kafka 对日志压缩的支持上【75】。
#### 在流上搜索
除了允许搜索由多个事件构成模式的 CEP 外,有时也存在基于复杂标准(例如全文搜索查询)来搜索单个事件的需求。
例如,媒体监测服务可以订阅新闻文章 Feed 与来自媒体的播客搜索任何关于公司、产品或感兴趣的话题的新闻。这是通过预先构建一个搜索查询来完成的然后不断地将新闻项的流与该查询进行匹配。在一些网站上也有类似的功能例如当市场上出现符合其搜索条件的新房产时房地产网站的用户可以要求网站通知他们。Elasticsearch 的这种过滤器功能是实现这种流搜索的一种选择【76】。
传统的搜索引擎首先索引文件,然后在索引上跑查询。相比之下,搜索一个数据流则反了过来:查询被存储下来,文档从查询中流过,就像在 CEP 中一样。最简单的情况就是你可以为每个文档测试每个查询。但是如果你有大量查询这可能会变慢。为了优化这个过程可以像对文档一样为查询建立索引。因而收窄可能匹配的查询集合【77】。
#### 消息传递和RPC
在 “[消息传递中的数据流](/v1/ch4#消息传递中的数据流)” 中我们讨论过,消息传递系统可以作为 RPC 的替代方案,即作为一种服务间通信的机制,比如在 Actor 模型中所使用的那样。尽管这些系统也是基于消息和事件,但我们通常不会将其视作流处理组件:
* Actor 框架主要是管理模块通信的并发和分布式执行的一种机制,而流处理主要是一种数据管理技术。
* Actor 之间的交流往往是短暂的、一对一的;而事件日志则是持久的、多订阅者的。
* Actor 可以以任意方式进行通信(包括循环的请求 / 响应模式),但流处理通常配置在无环流水线中,其中每个流都是一个特定作业的输出,由良好定义的输入流中派生而来。
也就是说RPC 类系统与流处理之间有一些交叉领域。例如Apache Storm 有一个称为 **分布式 RPC** 的功能它允许将用户查询分散到一系列也处理事件流的节点上然后这些查询与来自输入流的事件交织而结果可以被汇总并发回给用户【78】另请参阅 “[多分区数据处理](/v1/ch12#多分区数据处理)”)。
也可以使用 Actor 框架来处理流。但是,很多这样的框架在崩溃时不能保证消息的传递,除非你实现了额外的重试逻辑,否则这种处理不是容错的。
### 时间推理
流处理通常需要与时间打交道,尤其是用于分析目的时候,会频繁使用时间窗口,例如 “过去五分钟的平均值”。“过去五分钟” 的含义看上去似乎是清晰而无歧义的,但不幸的是,这个概念非常棘手。
在批处理中过程中,大量的历史事件被快速地处理。如果需要按时间来分析,批处理器需要检查每个事件中嵌入的时间戳。读取运行批处理机器的系统时钟没有任何意义,因为处理运行的时间与事件实际发生的时间无关。
批处理可以在几分钟内读取一年的历史事件;在大多数情况下,感兴趣的时间线是历史中的一年,而不是处理中的几分钟。而且使用事件中的时间戳,使得处理是 **确定性** 的:在相同的输入上再次运行相同的处理过程会得到相同的结果(请参阅 “[容错](/v1/ch10#容错)”)。
另一方面,许多流处理框架使用处理机器上的本地系统时钟(**处理时间**,即 processing time来确定 **窗口windowing**【79】。这种方法的优点是简单如果事件创建与事件处理之间的延迟可以忽略不计那也是合理的。然而如果存在任何显著的处理延迟 —— 即,事件处理显著地晚于事件实际发生的时间,这种处理方式就失效了。
#### 事件时间与处理时间
很多原因都可能导致处理延迟:排队,网络故障(请参阅 “[不可靠的网络](/v1/ch8#不可靠的网络)”),性能问题导致消息代理 / 消息处理器出现争用,流消费者重启,从故障中恢复时重新处理过去的事件(请参阅 “[重播旧消息](#重播旧消息)”),或者在修复代码 BUG 之后。
而且,消息延迟还可能导致无法预测消息顺序。例如,假设用户首先发出一个 Web 请求(由 Web 服务器 A 处理),然后发出第二个请求(由服务器 B 处理。A 和 B 发出描述它们所处理请求的事件,但是 B 的事件在 A 的事件发生之前到达消息代理。现在,流处理器将首先看到 B 事件,然后看到 A 事件,即使它们实际上是以相反的顺序发生的。
有一个类比也许能帮助理解,“星球大战” 电影:第四集于 1977 年发行,第五集于 1980 年,第六集于 1983 年,紧随其后的是 1999 年的第一集2002 年的第二集,和 2005 年的第三集,以及 2015 年的第七集【80】[^ii]。如果你按照按照它们上映的顺序观看电影,你处理电影的顺序与它们叙事的顺序就是不一致的。(集数编号就像事件时间戳,而你观看电影的日期就是处理时间)作为人类,我们能够应对这种不连续性,但是流处理算法需要专门编写,以适应这种时序与顺序的问题。
[^ii]: 感谢 Flink 社区的 Kostas Kloudas 提出这个比喻。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/v1/ddia_1107.png))。
![](/v1/ddia_1107.png)
**图 11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
#### 知道什么时候准备好了
用事件时间来定义窗口的一个棘手的问题是,你永远也无法确定是不是已经收到了特定窗口的所有事件,还是说还有一些事件正在来的路上。
例如,假设你将事件分组为一分钟的窗口,以便统计每分钟的请求数。你已经计数了一些带有本小时内第 37 分钟时间戳的事件,时间流逝,现在进入的主要都是本小时内第 38 和第 39 分钟的事件。什么时候才能宣布你已经完成了第 37 分钟的窗口计数,并输出其计数器值?
在一段时间没有看到任何新的事件之后,你可以超时并宣布一个窗口已经就绪,但仍然可能发生这种情况:某些事件被缓冲在另一台机器上,由于网络中断而延迟。你需要能够处理这种在窗口宣告完成之后到达的 **滞留straggler** 事件。大体上你有两种选择【1】
1. 忽略这些滞留事件,因为在正常情况下它们可能只是事件中的一小部分。你可以将丢弃事件的数量作为一个监控指标,并在出现大量丢消息的情况时报警。
2. 发布一个 **更正correction**,一个包括滞留事件的更新窗口值。你可能还需要收回以前的输出。
在某些情况下,可以使用特殊的消息来指示 “从现在开始,不会有比 t 更早时间戳的消息了”消费者可以使用它来触发窗口【81】。但是如果不同机器上的多个生产者都在生成事件每个生产者都有自己的最小时间戳阈值则消费者需要分别跟踪每个生产者。在这种情况下添加和删除生产者都是比较棘手的。
#### 你用的是谁的时钟?
当事件可能在系统内多个地方进行缓冲时,为事件分配时间戳更加困难了。例如,考虑一个移动应用向服务器上报关于用量的事件。该应用可能会在设备处于脱机状态时被使用,在这种情况下,它将在设备本地缓冲事件,并在下一次互联网连接可用时向服务器上报这些事件(可能是几小时甚至几天)。对于这个流的任意消费者而言,它们就如延迟极大的滞留事件一样。
在这种情况下,事件上的事件戳实际上应当是用户交互发生的时间,取决于移动设备的本地时钟。然而用户控制的设备上的时钟通常是不可信的,因为它可能会被无意或故意设置成错误的时间(请参阅 “[时钟同步与准确性](/v1/ch8#时钟同步与准确性)”)。服务器收到事件的时间(取决于服务器的时钟)可能是更准确的,因为服务器在你的控制之下,但在描述用户交互方面意义不大。
要校正不正确的设备时钟一种方法是记录三个时间戳【82】
* 事件发生的时间,取决于设备时钟
* 事件发送往服务器的时间,取决于设备时钟
* 事件被服务器接收的时间,取决于服务器时钟
通过从第三个时间戳中减去第二个时间戳,可以估算设备时钟和服务器时钟之间的偏移(假设网络延迟与所需的时间戳精度相比可忽略不计)。然后可以将该偏移应用于事件时间戳,从而估计事件实际发生的真实时间(假设设备时钟偏移在事件发生时与送往服务器之间没有变化)。
这并不是流处理独有的问题,批处理有着完全一样的时间推理问题。只是在流处理的上下文中,我们更容易意识到时间的流逝。
#### 窗口的类型
当你知道如何确定一个事件的时间戳后下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合例如事件计数或计算窗口内值的平均值。有几种窗口很常用【79,83】
滚动窗口Tumbling Window
: 滚动窗口有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个 1 分钟的滚动窗口,则所有时间戳在 `10:03:00``10:03:59` 之间的事件会被分组到一个窗口中,`10:04:00` 和 `10:04:59` 之间的事件被分组到下一个窗口,依此类推。通过将每个事件时间戳四舍五入至最近的分钟来确定它所属的窗口,可以实现 1 分钟的滚动窗口。
跳动窗口Hopping Window
: 跳动窗口也有着固定的长度,但允许窗口重叠以提供一些平滑。例如,一个带有 1 分钟跳跃步长的 5 分钟窗口将包含 `10:03:00``10:07:59` 之间的事件,而下一个窗口将覆盖 `10:04:00``10:08:59` 之间的事件,等等。通过首先计算 1 分钟的滚动窗口tunmbling window然后在几个相邻窗口上进行聚合可以实现这种跳动窗口。
滑动窗口Sliding Window
: 滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个 5 分钟的滑动窗口应当覆盖 `10:03:39``10:08:12` 的事件,因为它们相距不超过 5 分钟(注意滚动窗口与步长 5 分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口。
会话窗口Session window
: 与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](/v1/ch10#分组)”)。
### 流连接
在 [第十章](/v1/ch10) 中,我们讨论了批处理作业如何通过键来连接数据集,以及这种连接是如何成为数据管道的重要组成部分的。由于流处理将数据管道泛化为对无限数据集进行增量处理,因此对流进行连接的需求也是完全相同的。
然而,新事件随时可能出现在一个流中,这使得流连接要比批处理连接更具挑战性。为了更好地理解情况,让我们先来区分三种不同类型的连接:**流 - 流** 连接,**流 - 表** 连接,与 **表 - 表** 连接【84】。我们将在下面的章节中通过例子来说明。
#### 流流连接(窗口连接)
假设你的网站上有搜索功能,而你想要找出搜索 URL 的近期趋势。每当有人键入搜索查询时,都会记录下一个包含查询与其返回结果的事件。每当有人点击其中一个搜索结果时,就会记录另一个记录点击事件。为了计算搜索结果中每个 URL 的点击率,你需要将搜索动作与点击动作的事件连在一起,这些事件通过相同的会话 ID 进行连接。广告系统中需要类似的分析【85】。
如果用户丢弃了搜索结果,点击可能永远不会发生,即使它出现了,搜索与点击之间的时间可能是高度可变的:在很多情况下,它可能是几秒钟,但也可能长达几天或几周(如果用户执行搜索,忘掉了这个浏览器页面,过了一段时间后重新回到这个浏览器页面上,并点击了一个结果)。由于可变的网络延迟,点击事件甚至可能先于搜索事件到达。你可以选择合适的连接窗口 —— 例如,如果点击与搜索之间的时间间隔在一小时内,你可能会选择连接两者。
请注意,在点击事件中嵌入搜索详情与事件连接并不一样:这样做的话,只有当用户点击了一个搜索结果时你才能知道,而那些没有点击的搜索就无能为力了。为了衡量搜索质量,你需要准确的点击率,为此搜索事件和点击事件两者都是必要的。
为了实现这种类型的连接,流处理器需要维护 **状态**:例如,按会话 ID 索引最近一小时内发生的所有事件。无论何时发生搜索事件或点击事件,都会被添加到合适的索引中,而流处理器也会检查另一个索引是否有具有相同会话 ID 的事件到达。如果有匹配事件就会发出一个表示搜索结果被点击的事件;如果搜索事件直到过期都没看见有匹配的点击事件,就会发出一个表示搜索结果未被点击的事件。
#### 流表连接(流扩充)
在 “[示例:用户活动事件分析](/v1/ch10#示例:用户活动事件分析)”([图 10-2](/v1/ddia_1002.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/v1/ch10#示例:用户活动事件分析)” 一节中讨论的此类远程查询可能会很慢并且有可能导致数据库过载【75】。
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](/v1/ch10#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
与批处理作业的区别在于,批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持更新。这个问题可以通过变更数据捕获来解决:流处理器可以订阅用户档案数据库的更新日志,如同活动事件流一样。当增添或修改档案时,流处理器会更新其本地副本。因此,我们有了两个流之间的连接:活动事件和档案更新。
流表连接实际上非常类似于流流连接;最大的区别在于对于表的变更日志流,连接使用了一个可以回溯到 “时间起点” 的窗口(概念上是无限的窗口),新版本的记录会覆盖更早的版本。对于输入的流,连接可能压根儿就没有维护任何窗口。
#### 表表连接(维护物化视图)
我们在 “[描述负载](/v1/ch1#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
相反,我们需要一个时间线缓存:一种每个用户的 “收件箱”,在发送推文的时候写入这些信息,因而读取时间线时只需要简单地查询即可。物化与维护这个缓存需要处理以下事件:
* 当用户 u 发送新的推文时,它将被添加到每个关注用户 u 的时间线上。
* 用户删除推文时,推文将从所有用户的时间表中删除。
* 当用户 $u_1$ 开始关注用户 $u_2$ 时,$u_2$ 最近的推文将被添加到 $u_1$ 的时间线上。
* 当用户 $u_1$ 取消关注用户 $u_2$ 时,$u_2$ 的推文将从 $u_1$ 的时间线中移除。
要在流处理器中实现这种缓存维护你需要推文事件流发送与删除和关注关系事件流关注与取消关注。流处理需要维护一个数据库包含每个用户的粉丝集合。以便知道当一条新推文到达时需要更新哪些时间线【86】。
观察这个流处理过程的另一种视角是:它维护了一个连接了两个表(推文与关注)的物化视图,如下所示:
```sql
SELECT follows.follower_id AS timeline_id,
array_agg(tweets.* ORDER BY tweets.timestamp DESC)
FROM tweets
JOIN follows ON follows.followee_id = tweets.sender_id
GROUP BY follows.follower_id
```
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新 [^iii]。
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/v1/ddia_1106.png) 所示而把一个连接看作是两个表的乘法u·v那么会发生一些有趣的事情物化连接的变化流遵循乘积法则(u·v)'= u'v + uv'。换句话说任何推文的变化量都与当前的关注联系在一起任何关注的变化量都与当前的推文相连接【49,50】。
#### 连接的时间依赖性
这里描述的三种连接(流流,流表,表表)有很多共通之处:它们都需要流处理器维护连接一侧的一些状态(搜索与点击事件,用户档案,关注列表),然后当连接另一侧的消息到达时查询该状态。
用于维护状态的事件顺序是很重要的(先关注然后取消关注,或者其他类似操作)。在分区日志中,单个分区内的事件顺序是保留下来的。但典型情况下是没有跨流或跨分区的顺序保证的。
这就产生了一个问题如果不同流中的事件发生在近似的时间范围内则应该按照什么样的顺序进行处理在流表连接的例子中如果用户更新了它们的档案哪些活动事件与旧档案连接在档案更新前处理哪些又与新档案连接在档案更新之后处理换句话说你需要对一些状态做连接如果状态会随着时间推移而变化那应当使用什么时间点来连接呢【45】
这种时序依赖可能出现在很多地方。例如销售东西需要对发票应用适当的税率,这取决于所处的国家 / 州,产品类型,销售日期(因为税率时不时会变化)。当连接销售额与税率表时,你可能期望的是使用销售时的税率参与连接。如果你正在重新处理历史数据,销售时的税率可能和现在的税率有所不同。
如果跨越流的事件顺序是未定的则连接会变为不确定性的【87】这意味着你在同样输入上重跑相同的作业未必会得到相同的结果当你重跑任务时输入流上的事件可能会以不同的方式交织。
在数据仓库中,这个问题被称为 **缓慢变化的维度slowly changing dimension, SCD**通常通过对特定版本的记录使用唯一的标识符来解决例如每当税率改变时都会获得一个新的标识符而发票在销售时会带有税率的标识符【88,89】。这种变化使连接变为确定性的但也会导致日志压缩无法进行表中所有的记录版本都需要保留。
### 容错
在本章的最后一节中,让我们看一看流处理是如何容错的。我们在 [第十章](/v1/ch10) 中看到,批处理框架可以很容易地容错:如果 MapReduce 作业中的任务失败,可以简单地在另一台机器上再次启动,并且丢弃失败任务的输出。这种透明的重试是可能的,因为输入文件是不可变的,每个任务都将其输出写入到 HDFS 上的独立文件中,而输出仅当任务成功完成后可见。
特别是,批处理容错方法可确保批处理作业的输出与没有出错的情况相同,即使实际上某些任务失败了。看起来好像每条输入记录都被处理了恰好一次 —— 没有记录被跳过,而且没有记录被处理两次。尽管重启任务意味着实际上可能会多次处理记录,但输出中的可见效果看上去就像只处理过一次。这个原则被称为 **恰好一次语义exactly-once semantics**,尽管 **等效一次effectively-once** 可能会是一个更写实的术语【90】。
在流处理中也出现了同样的容错问题,但是处理起来没有那么直观:等待某个任务完成之后再使其输出可见并不是一个可行选项,因为你永远无法处理完一个无限的流。
#### 微批量与存档点
一个解决方案是将流分解成小块,并像微型批处理一样处理每个块。这种方法被称为 **微批次microbatching**,它被用于 Spark Streaming 【91】。批次的大小通常约为 1 秒,这是对性能妥协的结果:较小的批次会导致更大的调度与协调开销,而较大的批次意味着流处理器结果可见之前的延迟要更长。
微批次也隐式提供了一个与批次大小相等的滚动窗口(按处理时间而不是事件时间戳分窗)。任何需要更大窗口的作业都需要显式地将状态从一个微批次转移到下一个微批次。
Apache Flink 则使用不同的方法它会定期生成状态的滚动存档点并将其写入持久存储【92,93】。如果流算子崩溃它可以从最近的存档点重启并丢弃从最近检查点到崩溃之间的所有输出。存档点会由消息流中的 **壁障barrier** 触发,类似于微批次之间的边界,但不会强制一个特定的窗口大小。
在流处理框架的范围内,微批次与存档点方法提供了与批处理一样的 **恰好一次语义**。但是,只要输出离开流处理器(例如,写入数据库,向外部消息代理发送消息,或发送电子邮件),框架就无法抛弃失败批次的输出了。在这种情况下,重启失败任务会导致外部副作用发生两次,只有微批次或存档点不足以阻止这一问题。
#### 原子提交再现
为了在出现故障时表现出恰好处理一次的样子,我们需要确保事件处理的所有输出和副作用 **当且仅当** 处理成功时才会生效。这些影响包括发送给下游算子或外部消息传递系统(包括电子邮件或推送通知)的任何消息,任何数据库写入,对算子状态的任何变更,以及对输入消息的任何确认(包括在基于日志的消息代理中将消费者偏移量前移)。
这些事情要么都原子地发生,要么都不发生,但是它们不应当失去同步。如果这种方法听起来很熟悉,那是因为我们在分布式事务和两阶段提交的上下文中讨论过它(请参阅 “[恰好一次的消息处理](/v1/ch9#恰好一次的消息处理)”)。
在 [第九章](/v1/ch9) 中,我们讨论了分布式事务传统实现中的问题(如 XA。然而在限制更为严苛的环境中也是有可能高效实现这种原子提交机制的。Google Cloud Dataflow【81,92】和 VoltDB 【94】中使用了这种方法Apache Kafka 有计划加入类似的功能【95,96】。与 XA 不同,这些实现不会尝试跨异构技术提供事务,而是通过在流处理框架中同时管理状态变更与消息传递来内化事务。事务协议的开销可以通过在单个事务中处理多个输入消息来分摊。
#### 幂等性
我们的目标是丢弃任何失败任务的部分输出,以便能安全地重试,而不会生效两次。分布式事务是实现这个目标的一种方式,而另一种方式是依赖 **幂等性idempotence**【97】。
幂等操作是多次重复执行与单次执行效果相同的操作。例如,将键值存储中的某个键设置为某个特定值是幂等的(再次写入该值,只是用同样的值替代),而递增一个计数器不是幂等的(再次执行递增意味着该值递增两次)。
即使一个操作不是天生幂等的,往往可以通过一些额外的元数据做成幂等的。例如,在使用来自 Kafka 的消息时,每条消息都有一个持久的、单调递增的偏移量。将值写入外部数据库时可以将这个偏移量带上,这样你就可以判断一条更新是不是已经执行过了,因而避免重复执行。
Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性意味着隐含了一些假设重启一个失败的任务必须以相同的顺序重播相同的消息基于日志的消息代理能做这些事处理必须是确定性的没有其他节点能同时更新相同的值【98,99】。
当从一个处理节点故障切换到另一个节点时,可能需要进行 **防护**fencing请参阅 “[领导者和锁](/v1/ch8#领导者和锁)”),以防止被假死节点干扰。尽管有这么多注意事项,幂等操作是一种实现 **恰好一次语义** 的有效方式,仅需很小的额外开销。
#### 失败后重建状态
任何需要状态的流处理 —— 例如,任何窗口聚合(例如计数器,平均值和直方图)以及任何用于连接的表和索引,都必须确保在失败之后能恢复其状态。
一种选择是将状态保存在远程数据存储中,并进行复制,然而正如在 “[流表连接(流扩充)](#流表连接(流扩充))” 中所述,每个消息都要查询远程数据库可能会很慢。另一种方法是在流处理器本地保存状态,并定期复制。然后当流处理器从故障中恢复时,新任务可以读取状态副本,恢复处理而不丢失数据。
例如Flink 定期捕获算子状态的快照,并将它们写入 HDFS 等持久存储中【92,93】。Samza 和 Kafka Streams 通过将状态变更发送到具有日志压缩功能的专用 Kafka 主题来复制状态变更这与变更数据捕获类似【84,100】。VoltDB 通过在多个节点上对每个输入消息进行冗余处理来复制状态(请参阅 “[真的串行执行](/v1/ch7#真的串行执行)”)。
在某些情况下,甚至可能都不需要复制状态,因为它可以从输入流重建。例如,如果状态是从相当短的窗口中聚合而成,则简单地重播该窗口中的输入事件可能是足够快的。如果状态是通过变更数据捕获来维护的数据库的本地副本,那么也可以从日志压缩的变更流中重建数据库(请参阅 “[日志压缩](#日志压缩)”)。
然而,所有这些权衡取决于底层基础架构的性能特征:在某些系统中,网络延迟可能低于磁盘访问延迟,网络带宽也可能与磁盘带宽相当。没有针对所有情况的普适理想权衡,随着存储和网络技术的发展,本地状态与远程状态的优点也可能会互换。
## 本章小结
在本章中,我们讨论了事件流,它们所服务的目的,以及如何处理它们。在某些方面,流处理非常类似于在 [第十章](/v1/ch10) 中讨论的批处理,不过是在无限的(永无止境的)流而不是固定大小的输入上持续进行。从这个角度来看,消息代理和事件日志可以视作文件系统的流式等价物。
我们花了一些时间比较两种消息代理:
AMQP/JMS 风格的消息代理
: 代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC另请参阅 “[消息传递中的数据流](/v1/ch4#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
基于日志的消息代理
: 代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
基于日志的方法与数据库中的复制日志(请参阅 [第五章](/v1/ch5))和日志结构存储引擎(请参阅 [第三章](/v1/ch3))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证衍生数据系统处于最新状态(物化视图)。
然后我们讨论了在流处理中对时间进行推理的困难,包括处理时间与事件时间戳之间的区别,以及当你认为窗口已经完事之后,如何处理到达的掉队事件的问题。
我们区分了流处理中可能出现的三种连接类型:
流流连接
: 两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户 30 分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(**自连接**,即 self-join
流表连接
: 一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。
表表连接
: 两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。
最后,我们讨论了在流处理中实现容错和恰好一次语义的技术。与批处理一样,我们需要放弃任何失败任务的部分输出。然而由于流处理长时间运行并持续产生输出,所以不能简单地丢弃所有的输出。相反,可以使用更细粒度的恢复机制,基于微批次、存档点、事务或幂等写入。
## 参考文献
1. Tyler Akidau, Robert Bradshaw, Craig Chambers, et al.: “[The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing](http://www.vldb.org/pvldb/vol8/p1792-Akidau.pdf),” *Proceedings of the VLDB Endowment*, volume 8, number 12, pages 17921803, August 2015. [doi:10.14778/2824032.2824076](http://dx.doi.org/10.14778/2824032.2824076)
1. Harold Abelson, Gerald Jay Sussman, and Julie Sussman: [*Structure and Interpretation of Computer Programs*](https://web.archive.org/web/20220807043536/https://mitpress.mit.edu/sites/default/files/sicp/index.html), 2nd edition. MIT Press, 1996. ISBN: 978-0-262-51087-5, available online at *mitpress.mit.edu*
1. Patrick Th. Eugster, Pascal A. Felber, Rachid Guerraoui, and Anne-Marie Kermarrec: “[The Many Faces of Publish/Subscribe](http://www.cs.ru.nl/~pieter/oss/manyfaces.pdf),” *ACM Computing Surveys*, volume 35, number 2, pages 114131, June 2003. [doi:10.1145/857076.857078](http://dx.doi.org/10.1145/857076.857078)
1. Joseph M. Hellerstein and Michael Stonebraker: [*Readings in Database Systems*](http://redbook.cs.berkeley.edu/), 4th edition. MIT Press, 2005. ISBN: 978-0-262-69314-1, available online at *redbook.cs.berkeley.edu*
1. Don Carney, Uğur Çetintemel, Mitch Cherniack, et al.: “[Monitoring Streams A New Class of Data Management Applications](http://www.vldb.org/conf/2002/S07P02.pdf),” at *28th International Conference on Very Large Data Bases* (VLDB), August 2002.
1. Matthew Sackman: “[Pushing Back](https://wellquite.org/posts/lshift/pushing_back/),” *lshift.net*, May 5, 2016.
1. Vicent Martí: “[Brubeck, a statsd-Compatible Metrics Aggregator](http://githubengineering.com/brubeck/),” *githubengineering.com*, June 15, 2015.
1. Seth Lowenberger: “[MoldUDP64 Protocol Specification V 1.00](http://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/moldudp64.pdf),” *nasdaqtrader.com*, July 2009.
1. Pieter Hintjens: [*ZeroMQ The Guide*](http://zguide.zeromq.org/page:all). O'Reilly Media, 2013. ISBN: 978-1-449-33404-8
1. Ian Malpass: “[Measure Anything, Measure Everything](https://codeascraft.com/2011/02/15/measure-anything-measure-everything/),” *codeascraft.com*, February 15, 2011.
1. Dieter Plaetinck: “[25 Graphite, Grafana and statsd Gotchas](https://grafana.com/blog/2016/03/03/25-graphite-grafana-and-statsd-gotchas/),” *grafana.com*, March 3, 2016.
1. Jeff Lindsay: “[Web Hooks to Revolutionize the Web](https://web.archive.org/web/20180928201955/http://progrium.com/blog/2007/05/03/web-hooks-to-revolutionize-the-web/),” *progrium.com*, May 3, 2007.
1. Jim N. Gray: “[Queues Are Databases](https://arxiv.org/pdf/cs/0701158.pdf),” Microsoft Research Technical Report MSR-TR-95-56, December 1995.
1. Mark Hapner, Rich Burridge, Rahul Sharma, et al.: “[JSR-343 Java Message Service (JMS) 2.0 Specification](https://jcp.org/en/jsr/detail?id=343),” *jms-spec.java.net*, March 2013.
1. Sanjay Aiyagari, Matthew Arrott, Mark Atwell, et al.: “[AMQP: Advanced Message Queuing Protocol Specification](http://www.rabbitmq.com/resources/specs/amqp0-9-1.pdf),” Version 0-9-1, November 2008.
1. “[Google Cloud Pub/Sub: A Google-Scale Messaging Service](https://cloud.google.com/pubsub/architecture),” *cloud.google.com*, 2016.
1. “[Apache Kafka 0.9 Documentation](http://kafka.apache.org/documentation.html),” *kafka.apache.org*, November 2015.
1. Jay Kreps, Neha Narkhede, and Jun Rao: “[Kafka: A Distributed Messaging System for Log Processing](https://www.microsoft.com/en-us/research/wp-content/uploads/2017/09/Kafka.pdf),” at *6th International Workshop on Networking Meets Databases* (NetDB), June 2011.
1. “[Amazon Kinesis Streams Developer Guide](http://docs.aws.amazon.com/streams/latest/dev/introduction.html),” *docs.aws.amazon.com*, April 2016.
1. Leigh Stewart and Sijie Guo: “[Building DistributedLog: Twitters High-Performance Replicated Log Service](https://blog.twitter.com/2015/building-distributedlog-twitter-s-high-performance-replicated-log-service),” *blog.twitter.com*, September 16, 2015.
1. “[DistributedLog Documentation](https://web.archive.org/web/20210517201308/https://bookkeeper.apache.org/distributedlog/docs/latest/),” Apache Software Foundation, *distributedlog.io*.
1. Jay Kreps: “[Benchmarking Apache Kafka: 2 Million Writes Per Second (On Three Cheap Machines)](https://engineering.linkedin.com/kafka/benchmarking-apache-kafka-2-million-writes-second-three-cheap-machines),” *engineering.linkedin.com*, April 27, 2014.
1. Kartik Paramasivam: “[How Were Improving and Advancing Kafka at LinkedIn](https://engineering.linkedin.com/apache-kafka/how-we_re-improving-and-advancing-kafka-linkedin),” *engineering.linkedin.com*, September 2, 2015.
1. Jay Kreps: “[The Log: What Every Software Engineer Should Know About Real-Time Data's Unifying Abstraction](http://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying),” *engineering.linkedin.com*, December 16, 2013.
1. Shirshanka Das, Chavdar Botev, Kapil Surlaker, et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at *3rd ACM Symposium on Cloud Computing* (SoCC), October 2012.
1. Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, et al.: “[Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-sharma.pdf),” at *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015.
1. P. P. S. Narayan: “[Sherpa Update](http://web.archive.org/web/20160801221400/https://developer.yahoo.com/blogs/ydn/sherpa-7992.html),” *developer.yahoo.com*, June 8, .
1. Martin Kleppmann: “[Bottled Water: Real-Time Integration of PostgreSQL and Kafka](http://martin.kleppmann.com/2015/04/23/bottled-water-real-time-postgresql-kafka.html),” *martin.kleppmann.com*, April 23, 2015.
1. Ben Osheroff: “[Introducing Maxwell, a mysql-to-kafka Binlog Processor](https://web.archive.org/web/20170208100334/https://developer.zendesk.com/blog/introducing-maxwell-a-mysql-to-kafka-binlog-processor),” *developer.zendesk.com*, August 20, 2015.
1. Randall Hauch: “[Debezium 0.2.1 Released](https://debezium.io/blog/2016/06/10/Debezium-0.2.1-Released/),” *debezium.io*, June 10, 2016.
1. Prem Santosh Udaya Shankar: “[Streaming MySQL Tables in Real-Time to Kafka](https://engineeringblog.yelp.com/2016/08/streaming-mysql-tables-in-real-time-to-kafka.html),” *engineeringblog.yelp.com*, August 1, 2016.
1. “[Mongoriver](https://github.com/stripe/mongoriver),” Stripe, Inc., *github.com*, September 2014.
1. Dan Harvey: “[Change Data Capture with Mongo + Kafka](http://www.slideshare.net/danharvey/change-data-capture-with-mongodb-and-kafka),” at *Hadoop Users Group UK*, August 2015.
1. “[Oracle GoldenGate 12c: Real-Time Access to Real-Time Information](https://web.archive.org/web/20160923105841/http://www.oracle.com/us/products/middleware/data-integration/oracle-goldengate-realtime-access-2031152.pdf),” Oracle White Paper, March 2015.
1. “[Oracle GoldenGate Fundamentals: How Oracle GoldenGate Works](https://www.youtube.com/watch?v=6H9NibIiPQE),” Oracle Corporation, *youtube.com*, November 2012.
1. Slava Akhmechet: “[Advancing the Realtime Web](http://rethinkdb.com/blog/realtime-web/),” *rethinkdb.com*, January 27, 2015.
1. “[Firebase Realtime Database Documentation](https://firebase.google.com/docs/database/),” Google, Inc., *firebase.google.com*, May 2016.
1. “[Apache CouchDB 1.6 Documentation](http://docs.couchdb.org/en/latest/),” *docs.couchdb.org*, 2014.
1. Matt DeBergalis: “[Meteor 0.7.0: Scalable Database Queries Using MongoDB Oplog Instead of Poll-and-Diff](https://web.archive.org/web/20160324055429/http://info.meteor.com/blog/meteor-070-scalable-database-queries-using-mongodb-oplog-instead-of-poll-and-diff),” *info.meteor.com*, December 17, 2013.
1. “[Chapter 15. Importing and Exporting Live Data](https://docs.voltdb.com/UsingVoltDB/ChapExport.php),” VoltDB 6.4 User Manual, *docs.voltdb.com*, June 2016.
1. Neha Narkhede: “[Announcing Kafka Connect: Building Large-Scale Low-Latency Data Pipelines](http://www.confluent.io/blog/announcing-kafka-connect-building-large-scale-low-latency-data-pipelines),” *confluent.io*, February 18, 2016.
1. Greg Young: “[CQRS and Event Sourcing](https://www.youtube.com/watch?v=JHGkaShoyNs),” at *Code on the Beach*, August 2014.
1. Martin Fowler: “[Event Sourcing](http://martinfowler.com/eaaDev/EventSourcing.html),” *martinfowler.com*, December 12, 2005.
1. Vaughn Vernon: [*Implementing Domain-Driven Design*](https://www.informit.com/store/implementing-domain-driven-design-9780321834577). Addison-Wesley Professional, 2013. ISBN: 978-0-321-83457-7
1. H. V. Jagadish, Inderpal Singh Mumick, and Abraham Silberschatz: “[View Maintenance Issues for the Chronicle Data Model](https://dl.acm.org/doi/10.1145/212433.220201),” at *14th ACM SIGACT-SIGMOD-SIGART Symposium on Principles of Database Systems* (PODS), May 1995. [doi:10.1145/212433.220201](http://dx.doi.org/10.1145/212433.220201)
1. “[Event Store 3.5.0 Documentation](http://docs.geteventstore.com/),” Event Store LLP, *docs.geteventstore.com*, February 2016.
1. Martin Kleppmann: [*Making Sense of Stream Processing*](http://www.oreilly.com/data/free/stream-processing.csp). Report, O'Reilly Media, May 2016.
1. Sander Mak: “[Event-Sourced Architectures with Akka](http://www.slideshare.net/SanderMak/eventsourced-architectures-with-akka),” at *JavaOne*, September 2014.
1. Julian Hyde: [personal communication](https://twitter.com/julianhyde/status/743374145006641153), June 2016.
1. Ashish Gupta and Inderpal Singh Mumick: *Materialized Views: Techniques, Implementations, and Applications*. MIT Press, 1999. ISBN: 978-0-262-57122-7
1. Timothy Griffin and Leonid Libkin: “[Incremental Maintenance of Views with Duplicates](http://homepages.inf.ed.ac.uk/libkin/papers/sigmod95.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1995. [doi:10.1145/223784.223849](http://dx.doi.org/10.1145/223784.223849)
1. Pat Helland: “[Immutability Changes Everything](http://cidrdb.org/cidr2015/Papers/CIDR15_Paper16.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015.
1. Martin Kleppmann: “[Accounting for Computer Scientists](http://martin.kleppmann.com/2011/03/07/accounting-for-computer-scientists.html),” *martin.kleppmann.com*, March 7, 2011.
1. Pat Helland: “[Accountants Don't Use Erasers](https://web.archive.org/web/20200220161036/https://blogs.msdn.microsoft.com/pathelland/2007/06/14/accountants-dont-use-erasers/),” *blogs.msdn.com*, June 14, 2007.
1. Fangjin Yang: “[Dogfooding with Druid, Samza, and Kafka: Metametrics at Metamarkets](https://metamarkets.com/2015/dogfooding-with-druid-samza-and-kafka-metametrics-at-metamarkets/),” *metamarkets.com*, June 3, 2015.
1. Gavin Li, Jianqiu Lv, and Hang Qi: “[Pistachio: Co-Locate the Data and Compute for Fastest Cloud Compute](https://web.archive.org/web/20181214032620/https://yahoohadoop.tumblr.com/post/116365275781/pistachio-co-locate-the-data-and-compute-for),” *yahoohadoop.tumblr.com*, April 13, 2015.
1. Kartik Paramasivam: “[Stream Processing Hard Problems Part 1: Killing Lambda](https://engineering.linkedin.com/blog/2016/06/stream-processing-hard-problems-part-1-killing-lambda),” *engineering.linkedin.com*, June 27, 2016.
1. Martin Fowler: “[CQRS](http://martinfowler.com/bliki/CQRS.html),” *martinfowler.com*, July 14, 2011.
1. Greg Young: “[CQRS Documents](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf),” *cqrs.files.wordpress.com*, November 2010.
1. Baron Schwartz: “[Immutability, MVCC, and Garbage Collection](https://web.archive.org/web/20161110094746/http://www.xaprb.com/blog/2013/12/28/immutability-mvcc-and-garbage-collection/),” *xaprb.com*, December 28, 2013.
1. Daniel Eloff, Slava Akhmechet, Jay Kreps, et al.: ["Re: Turning the Database Inside-out with Apache Samza](https://news.ycombinator.com/item?id=9145197)," Hacker News discussion, *news.ycombinator.com*, March 4, 2015.
1. “[Datomic Development Resources: Excision](http://docs.datomic.com/excision.html),” Cognitect, Inc., *docs.datomic.com*.
1. “[Fossil Documentation: Deleting Content from Fossil](http://fossil-scm.org/index.html/doc/trunk/www/shunning.wiki),” *fossil-scm.org*, 2016.
1. Jay Kreps: “[The irony of distributed systems is that data loss is really easy but deleting data is surprisingly hard,](https://twitter.com/jaykreps/status/582580836425330688)” *twitter.com*, March 30, 2015.
1. David C. Luckham: “[Whats the Difference Between ESP and CEP?](http://www.complexevents.com/2006/08/01/what%E2%80%99s-the-difference-between-esp-and-cep/),” *complexevents.com*, August 1, 2006.
1. Srinath Perera: “[How Is Stream Processing and Complex Event Processing (CEP) Different?](https://www.quora.com/How-is-stream-processing-and-complex-event-processing-CEP-different),” *quora.com*, December 3, 2015.
1. Arvind Arasu, Shivnath Babu, and Jennifer Widom: “[The CQL Continuous Query Language: Semantic Foundations and Query Execution](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/cql.pdf),” *The VLDB Journal*, volume 15, number 2, pages 121142, June 2006. [doi:10.1007/s00778-004-0147-z](http://dx.doi.org/10.1007/s00778-004-0147-z)
1. Julian Hyde: “[Data in Flight: How Streaming SQL Technology Can Help Solve the Web 2.0 Data Crunch](http://queue.acm.org/detail.cfm?id=1667562),” *ACM Queue*, volume 7, number 11, December 2009. [doi:10.1145/1661785.1667562](http://dx.doi.org/10.1145/1661785.1667562)
1. “[Esper Reference, Version 5.4.0](http://esper.espertech.com/release-5.4.0/esper-reference/html_single/index.html),” EsperTech, Inc., *espertech.com*, April 2016.
1. Zubair Nabi, Eric Bouillet, Andrew Bainbridge, and Chris Thomas: “[Of Streams and Storms](https://web.archive.org/web/20170711081434/https://developer.ibm.com/streamsdev/wp-content/uploads/sites/15/2014/04/Streams-and-Storm-April-2014-Final.pdf),” IBM technical report, *developer.ibm.com*, April 2014.
1. Milinda Pathirage, Julian Hyde, Yi Pan, and Beth Plale: “[SamzaSQL: Scalable Fast Data Management with Streaming SQL](https://github.com/milinda/samzasql-hpbdc2016/blob/master/samzasql-hpbdc2016.pdf),” at *IEEE International Workshop on High-Performance Big Data Computing* (HPBDC), May 2016. [doi:10.1109/IPDPSW.2016.141](http://dx.doi.org/10.1109/IPDPSW.2016.141)
1. Philippe Flajolet, Éric Fusy, Olivier Gandouet, and Frédéric Meunier: “[HyperLogLog: The Analysis of a Near-Optimal Cardinality Estimation Algorithm](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf),” at *Conference on Analysis of Algorithms* (AofA), June 2007.
1. Jay Kreps: “[Questioning the Lambda Architecture](https://www.oreilly.com/ideas/questioning-the-lambda-architecture),” *oreilly.com*, July 2, 2014.
1. Ian Hellström: “[An Overview of Apache Streaming Technologies](https://databaseline.bitbucket.io/an-overview-of-apache-streaming-technologies/),” *databaseline.bitbucket.io*, March 12, 2016.
1. Jay Kreps: “[Why Local State Is a Fundamental Primitive in Stream Processing](https://www.oreilly.com/ideas/why-local-state-is-a-fundamental-primitive-in-stream-processing),” *oreilly.com*, July 31, 2014.
1. Shay Banon: “[Percolator](https://www.elastic.co/blog/percolator),” *elastic.co*, February 8, 2011.
1. Alan Woodward and Martin Kleppmann: “[Real-Time Full-Text Search with Luwak and Samza](http://martin.kleppmann.com/2015/04/13/real-time-full-text-search-luwak-samza.html),” *martin.kleppmann.com*, April 13, 2015.
1. “[Apache Storm 2.1.0 Documentation](https://storm.apache.org/releases/2.1.0/index.html),” *storm.apache.org*, October 2019.
1. Tyler Akidau: “[The World Beyond Batch: Streaming 102](https://www.oreilly.com/ideas/the-world-beyond-batch-streaming-102),” *oreilly.com*, January 20, 2016.
1. Stephan Ewen: “[Streaming Analytics with Apache Flink](https://www.confluent.io/resources/kafka-summit-2016/advanced-streaming-analytics-apache-flink-apache-kafka/),” at *Kafka Summit*, April 2016.
1. Tyler Akidau, Alex Balikov, Kaya Bekiroğlu, et al.: “[MillWheel: Fault-Tolerant Stream Processing at Internet Scale](http://research.google.com/pubs/pub41378.html),” at *39th International Conference on Very Large Data Bases* (VLDB), August 2013.
1. Alex Dean: “[Improving Snowplow's Understanding of Time](https://snowplow.io/blog/improving-snowplows-understanding-of-time/),” *snowplowanalytics.com*, September 15, 2015.
1. “[Windowing (Azure Stream Analytics)](https://msdn.microsoft.com/en-us/library/azure/dn835019.aspx),” Microsoft Azure Reference, *msdn.microsoft.com*, April 2016.
1. “[State Management](http://samza.apache.org/learn/documentation/0.10/container/state-management.html),” Apache Samza 0.10 Documentation, *samza.apache.org*, December 2015.
1. Rajagopal Ananthanarayanan, Venkatesh Basker, Sumit Das, et al.: “[Photon: Fault-Tolerant and Scalable Joining of Continuous Data Streams](http://research.google.com/pubs/pub41318.html),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013. [doi:10.1145/2463676.2465272](http://dx.doi.org/10.1145/2463676.2465272)
1. Martin Kleppmann: “[Samza Newsfeed Demo](https://github.com/ept/newsfeed),” *github.com*, September 2014.
1. Ben Kirwin: “[Doing the Impossible: Exactly-Once Messaging Patterns in Kafka](http://ben.kirw.in/2014/11/28/kafka-patterns/),” *ben.kirw.in*, November 28, 2014.
1. Pat Helland: “[Data on the Outside Versus Data on the Inside](http://cidrdb.org/cidr2005/papers/P12.pdf),” at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005.
1. Ralph Kimball and Margy Ross: *The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling*, 3rd edition. John Wiley & Sons, 2013. ISBN: 978-1-118-53080-1
1. Viktor Klang: “[I'm coining the phrase 'effectively-once' for message processing with at-least-once + idempotent operations](https://twitter.com/viktorklang/status/789036133434978304),” *twitter.com*, October 20, 2016.
1. Matei Zaharia, Tathagata Das, Haoyuan Li, et al.: “[Discretized Streams: An Efficient and Fault-Tolerant Model for Stream Processing on Large Clusters](https://www.usenix.org/system/files/conference/hotcloud12/hotcloud12-final28.pdf),” at *4th USENIX Conference in Hot Topics in Cloud Computing* (HotCloud), June 2012.
1. Kostas Tzoumas, Stephan Ewen, and Robert Metzger: “[High-Throughput, Low-Latency, and Exactly-Once Stream Processing with Apache Flink](https://www.ververica.com/blog/high-throughput-low-latency-and-exactly-once-stream-processing-with-apache-flink),” *ververica.com*, August 5, 2015.
1. Paris Carbone, Gyula Fóra, Stephan Ewen, et al.: “[Lightweight Asynchronous Snapshots for Distributed Dataflows](http://arxiv.org/abs/1506.08603),” arXiv:1506.08603 &#91;cs.DC&#93;, June 29, 2015.
1. Ryan Betts and John Hugg: [*Fast Data: Smart and at Scale*](http://www.oreilly.com/data/free/fast-data-smart-and-at-scale.csp). Report, O'Reilly Media, October 2015.
1. Flavio Junqueira: “[Making Sense of Exactly-Once Semantics](https://web.archive.org/web/20160812172900/http://conferences.oreilly.com/strata/hadoop-big-data-eu/public/schedule/detail/49690),” at *Strata+Hadoop World London*, June 2016.
1. Jason Gustafson, Flavio Junqueira, Apurva Mehta, Sriram Subramanian, and Guozhang Wang: “[KIP-98 Exactly Once Delivery and Transactional Messaging](https://cwiki.apache.org/confluence/display/KAFKA/KIP-98+-+Exactly+Once+Delivery+and+Transactional+Messaging),” *cwiki.apache.org*, November 2016.
1. Pat Helland: “[Idempotence Is Not a Medical Condition](https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=4b6dda7fe75b51e1c543a87ca7b3b322fbf55614),” *Communications of the ACM*, volume 55, number 5, page 56, May 2012. [doi:10.1145/2160718.2160734](http://dx.doi.org/10.1145/2160718.2160734)
1. Jay Kreps: “[Re: Trying to Achieve Deterministic Behavior on Recovery/Rewind](http://mail-archives.apache.org/mod_mbox/samza-dev/201409.mbox/%3CCAOeJiJg%2Bc7Ei%3DgzCuOz30DD3G5Hm9yFY%3DUJ6SafdNUFbvRgorg%40mail.gmail.com%3E),” email to *samza-dev* mailing list, September 9, 2014.
1. E. N. (Mootaz) Elnozahy, Lorenzo Alvisi, Yi-Min Wang, and David B. Johnson: “[A Survey of Rollback-Recovery Protocols in Message-Passing Systems](http://www.cs.utexas.edu/~lorenzo/papers/SurveyFinal.pdf),” *ACM Computing Surveys*, volume 34, number 3, pages 375408, September 2002. [doi:10.1145/568522.568525](http://dx.doi.org/10.1145/568522.568525)
1. Adam Warski: “[Kafka Streams How Does It Fit the Stream Processing Landscape?](https://softwaremill.com/kafka-streams-how-does-it-fit-stream-landscape/),” *softwaremill.com*, June 1, 2016.

1006
content/v1/ch12.md Normal file

File diff suppressed because it is too large Load Diff

984
content/v1/ch2.md Normal file
View File

@ -0,0 +1,984 @@
---
title: "第二章:数据模型与查询语言"
linkTitle: "2. 数据模型与查询语言"
weight: 102
math: true
breadcrumbs: false
---
![](/map/ch02.png)
> 语言的边界就是思想的边界。
>
> —— 路德维奇・维特根斯坦《逻辑哲学》1922
数据模型可能是软件开发中最重要的部分了,因为它们的影响如此深远:不仅仅影响着软件的编写方式,而且影响着我们的 **解题思路**
多数应用使用层层叠加的数据模型构建。对于每层数据模型的关键问题是:它是如何用低一层数据模型来 **表示** 的?例如:
1. 作为一名应用开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流向、传感器等),并采用对象或数据结构,以及操控那些数据结构的 API 来进行建模。那些结构通常是特定于应用程序的。
2. 当要存储那些数据结构时,你可以利用通用数据模型来表示它们,如 JSON 或 XML 文档、关系数据库中的表或图模型。
3. 数据库软件的工程师选定如何以内存、磁盘或网络上的字节来表示 JSON / XML/ 关系 / 图数据。这类表示形式使数据有可能以各种方式来查询,搜索,操纵和处理。
4. 在更低的层次上,硬件工程师已经想出了使用电流、光脉冲、磁场或者其他东西来表示字节的方法。
一个复杂的应用程序可能会有更多的中间层次,比如基于 API 的 API不过基本思想仍然是一样的每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性。这些抽象允许不同的人群有效地协作例如数据库厂商的工程师和使用数据库的应用程序开发人员
数据模型种类繁多,每个数据模型都带有如何使用的设想。有些用法很容易,有些则不支持如此;有些操作运行很快,有些则表现很差;有些数据转换非常自然,有些则很麻烦。
掌握一个数据模型需要花费很多精力(想想关系数据建模有多少本书)。即便只使用一个数据模型,不用操心其内部工作机制,构建软件也是非常困难的。然而,因为数据模型对上层软件的功能(能做什么,不能做什么)有着至深的影响,所以选择一个适合的数据模型是非常重要的。
在本章中,我们将研究一系列用于数据存储和查询的通用数据模型(前面列表中的第 2 点)。特别地,我们将比较关系模型,文档模型和少量基于图形的数据模型。我们还将查看各种查询语言并比较它们的用例。在 [第三章](/v1/ch3) 中,我们将讨论存储引擎是如何工作的。也就是说,这些数据模型实际上是如何实现的(列表中的第 3 点)。
## 关系模型与文档模型
现在最著名的数据模型可能是 SQL。它基于 Edgar Codd 在 1970 年提出的关系模型【1】数据被组织成 **关系**SQL 中称作 **表**),其中每个关系是 **元组**SQL 中称作 **行**) 的无序集合。
关系模型曾是一个理论性的提议,当时很多人都怀疑是否能够有效实现它。然而到了 20 世纪 80 年代中期关系数据库管理系统RDBMSes和 SQL 已成为大多数人们存储和查询某些常规结构的数据的首选工具。关系数据库已经持续称霸了大约 25~30 年 —— 这对计算机史来说是极其漫长的时间。
关系数据库起源于商业数据处理,在 20 世纪 60 年代和 70 年代用大型计算机来执行。从今天的角度来看,那些用例显得很平常:典型的 **事务处理**(将销售或银行交易,航空公司预订,库存管理信息记录在库)和 **批处理**(客户发票,工资单,报告)。
当时的其他数据库迫使应用程序开发人员必须考虑数据库内部的数据表示形式。关系模型致力于将上述实现细节隐藏在更简洁的接口之后。
多年来,在数据存储和查询方面存在着许多相互竞争的方法。在 20 世纪 70 年代和 80 年代初网状模型network model和层次模型hierarchical model曾是主要的选择但关系模型relational model随后占据了主导地位。对象数据库在 20 世纪 80 年代末和 90 年代初来了又去。XML 数据库在二十一世纪初出现但只有小众采用过。关系模型的每个竞争者都在其时代产生了大量的炒作但从来没有持续【2】。
随着电脑越来越强大和互联,它们开始用于日益多样化的目的。关系数据库非常成功地被推广到业务数据处理的原始范围之外更为广泛的用例上。你今天在网上看到的大部分内容依旧是由关系数据库来提供支持,无论是在线发布、讨论、社交网络、电子商务、游戏、软件即服务生产力应用程序等内容。
### NoSQL 的诞生
现在 - 2010 年代NoSQL 开始了最新一轮尝试试图推翻关系模型的统治地位。“NoSQL” 这个名字让人遗憾,因为实际上它并没有涉及到任何特定的技术。最初它只是作为一个醒目的 Twitter 标签,用在 2009 年一个关于分布式,非关系数据库上的开源聚会上。无论如何,这个术语触动了某些神经,并迅速在网络创业社区内外传播开来。好些有趣的数据库系统现在都与 *#NoSQL* 标签相关联,并且 NoSQL 被追溯性地重新解释为 **不仅是 SQLNot Only SQL** 【4】。
采用 NoSQL 数据库的背后有几个驱动因素,其中包括:
* 需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
* 相比商业数据库产品,免费和开源软件更受偏爱
* 关系模型不能很好地支持一些特殊的查询操作
* 受挫于关系模型的限制性渴望一种更具多动态性与表现力的数据模型【5】
不同的应用程序有不同的需求,一个用例的最佳技术选择可能不同于另一个用例的最佳技术选择。因此,在可预见的未来,关系数据库似乎可能会继续与各种非关系数据库一起使用 - 这种想法有时也被称为 **混合持久化polyglot persistence**
### 对象关系不匹配
目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对 SQL 数据模型的普遍批评:如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为 **阻抗不匹配impedance mismatch**[^i]。
[^i]: 一个从电子学借用的术语。每个电路的输入和输出都有一定的阻抗(交流电阻)。当你将一个电路的输出连接到另一个电路的输入时,如果两个电路的输出和输入阻抗匹配,则连接上的功率传输将被最大化。阻抗不匹配会导致信号反射及其他问题。
像 ActiveRecord 和 Hibernate 这样的 **对象关系映射ORM object-relational mapping** 框架可以减少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。
![](/v1/ddia_0201.png)
**图 2-1 使用关系型模式来表示领英简介**
例如,[图 2-1](/v1/ddia_0201.png) 展示了如何在关系模式中表示简历(一个 LinkedIn 简介)。整个简介可以通过一个唯一的标识符 `user_id` 来标识。像 `first_name``last_name` 这样的字段每个用户只出现一次,所以可以在 User 表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
* 传统 SQL 模型SQL1999 之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对 User 表提供外键引用,如 [图 2-1](/v1/ddia_0201.png) 所示。
* 后续的 SQL 标准增加了对结构化数据类型和 XML 数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在 OracleIBM DB2MS SQL Server 和 PostgreSQL 中都有不同程度的支持【6,7】。JSON 数据类型也得到多个数据库的支持,包括 IBM DB2MySQL 和 PostgreSQL 【8】。
* 第三种选择是将职业,教育和联系信息编码为 JSON 或 XML 文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。
对于一个像简历这样自包含文档的数据结构而言JSON 表示是非常合适的:请参阅 [例 2-1]()。JSON 比 XML 更简单。面向文档的数据库(如 MongoDB 【9】RethinkDB 【10】CouchDB 【11】和 Espresso【12】支持这种数据模型。
**例 2-1. 用 JSON 文档表示一个 LinkedIn 简介**
```json
{
"user_id": 251,
"first_name": "Bill",
"last_name": "Gates",
"summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
"region_id": "us:91",
"industry_id": 131,
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{
"job_title": "Co-chair",
"organization": "Bill & Melinda Gates Foundation"
},
{
"job_title": "Co-founder, Chairman",
"organization": "Microsoft"
}
],
"education": [
{
"school_name": "Harvard University",
"start": 1973,
"end": 1975
},
{
"school_name": "Lakeside School, Seattle",
"start": null,
"end": null
}
],
"contact_info": {
"blog": "http://thegatesnotes.com",
"twitter": "http://twitter.com/BillGates"
}
}
```
有一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。不过,正如我们将在 [第四章](/v1/ch4) 中看到的那样JSON 作为数据编码格式也存在问题。无模式对 JSON 模型来说往往被认为是一个优势;我们将在 “[文档模型中的模式灵活性](#文档模型中的模式灵活性)” 中讨论这个问题。
JSON 表示比 [图 2-1](/v1/ddia_0201.png) 中的多表模式具有更好的 **局部性locality**。如果在前面的关系型示例中获取简介,那需要执行多个查询(通过 `user_id` 查询每个表),或者在 User 表与其下属表之间混乱地执行多路连接。而在 JSON 表示中,所有相关信息都在同一个地方,一个查询就足够了。
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而 JSON 表示使得这个树状结构变得明确(见 [图 2-2](/v1/ddia_0202.png))。
![](/v1/ddia_0202.png)
**图 2-2 一对多关系构建了一个树结构**
### 多对一和多对多的关系
在上一节的 [例 2-1]() 中,`region_id` 和 `industry_id` 是以 ID而不是纯字符串 “Greater Seattle Area” 和 “Philanthropy” 的形式给出的。为什么?
如果用户界面用一个自由文本字段来输入区域和行业,那么将他们存储为纯文本字符串是合理的。另一方式是给出地理区域和行业的标准化的列表,并让用户从下拉列表或自动填充器中进行选择,其优势如下:
* 各个简介之间样式和拼写统一
* 避免歧义(例如,如果有几个同名的城市)
* 易于更新 —— 名称只存储在一个地方,如果需要更改(例如,由于政治事件而改变城市名称),很容易进行全面更新。
* 本地化支持 —— 当网站翻译成其他语言时,标准化的列表可以被本地化,使得地区和行业可以使用用户的语言来显示
* 更好的搜索 —— 例如,搜索华盛顿州的慈善家就会匹配这份简介,因为地区列表可以编码记录西雅图在华盛顿这一事实(从 “Greater Seattle Area” 这个字符串中看不出来)
存储 ID 还是文本字符串,这是个 **副本duplication** 问题。当使用 ID 时对人类有意义的信息比如单词Philanthropy只存储在一处所有引用它的地方使用 IDID 只在数据库中有意义)。当直接存储文本时,对人类有意义的信息会复制在每处使用记录中。
使用 ID 的好处是ID 对人类没有任何意义因而永远不需要改变ID 可以保持不变,即使它标识的信息发生变化。任何对人类有意义的东西都可能需要在将来某个时候改变 —— 如果这些信息被复制,所有的冗余副本都需要更新。这会导致写入开销,也存在不一致的风险(一些副本被更新了,还有些副本没有被更新)。去除此类重复是数据库 **规范化normalization** 的关键思想。[^ii]
[^ii]: 关于关系模型的文献区分了几种不同的规范形式,但这些区别几乎没有实际意义。一个经验法则是,如果重复存储了可以存储在一个地方的值,则模式就不是 **规范化normalized** 的。
> 数据库管理员和开发人员喜欢争论规范化和非规范化,让我们暂时保留判断吧。在本书的 [第三部分](/part-iii),我们将回到这个话题,探讨系统的方法用以处理缓存,非规范化和衍生数据。
不幸的是,对这些数据进行规范化需要多对一的关系(许多人生活在一个特定的地区,许多人在一个特定的行业工作),这与文档模型不太吻合。在关系数据库中,通过 ID 来引用其他表中的行是正常的,因为连接很容易。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱 [^iii]。
[^iii]: 在撰写本文时RethinkDB 支持连接MongoDB 不支持连接,而 CouchDB 只支持预先声明的视图。
如果数据库本身不支持连接,则必须在应用程序代码中通过对数据库进行多个查询来模拟连接。(在这种情况中,地区和行业的列表可能很小,改动很少,应用程序可以简单地将其保存在内存中。不过,执行连接的工作从数据库被转移到应用程序代码上。)
此外,即便应用程序的最初版本适合无连接的文档模型,随着功能添加到应用程序中,数据会变得更加互联。例如,考虑一下对简历例子进行的一些修改:
组织和学校作为实体
: 在前面的描述中,`organization`(用户工作的公司)和 `school_name`(他们学习的地方)只是字符串。也许他们应该是对实体的引用呢?然后,每个组织、学校或大学都可以拥有自己的网页(标识、新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(请参阅 [图 2-3](/v1/ddia_0203.png),来自 LinkedIn 的一个例子)。
推荐
: 假设你想添加一个新的功能:一个用户可以为另一个用户写一个推荐。在用户的简历上显示推荐,并附上推荐用户的姓名和照片。如果推荐人更新他们的照片,那他们写的任何推荐都需要显示新的照片。因此,推荐应该拥有作者个人简介的引用。
![](/v1/ddia_0203.png)
**图 2-3 公司名不仅是字符串还是一个指向公司实体的链接LinkedIn 截图)**
[图 2-4](/v1/ddia_0204.png) 阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。
![](/v1/ddia_0204.png)
**图 2-4 使用多对多关系扩展简历**
### 文档数据库是否在重蹈覆辙?
在多对多的关系和连接已常规用在关系数据库时,文档数据库和 NoSQL 重启了辩论:如何以最佳方式在数据库中表示多对多关系。那场辩论可比 NoSQL 古老得多,事实上,最早可以追溯到计算机化数据库系统。
20 世纪 70 年代最受欢迎的业务数据处理数据库是 IBM 的信息管理系统IMS最初是为了阿波罗太空计划的库存管理而开发的并于 1968 年有了首次商业发布【13】。目前它仍在使用和维护运行在 IBM 大型机的 OS/390 上【14】。
IMS 的设计中使用了一个相当简单的数据模型,称为 **层次模型hierarchical model**,它与文档数据库使用的 JSON 模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树这很像 [图 2-2](/v1/ddia_0202.png) 的 JSON 结构。
同文档数据库一样IMS 能良好处理一对多的关系但是很难应对多对多的关系并且不支持连接。开发人员必须决定是否复制非规范化数据或手动解决从一个记录到另一个记录的引用。这些二十世纪六七十年代的问题与现在开发人员遇到的文档数据库问题非常相似【15】。
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是 **关系模型**relational model它变成了 SQL并统治了世界**网状模型**network model最初很受关注但最终变得冷门。这两个阵营之间的 “大辩论” 在 70 年代持续了很久时间【2】。
那两个模式解决的问题与当前的问题相关,因此值得简要回顾一下那场辩论。
#### 网状模型
网状模型由一个称为数据系统语言会议CODASYL的委员会进行了标准化并被数个不同的数据库厂商实现它也被称为 CODASYL 模型【16】。
CODASYL 模型是层次模型的推广。在层次模型的树结构中每条记录只有一个父节点在网络模式中每条记录可能有多个父节点。例如“Greater Seattle Area” 地区可能是一条记录,每个居住在该地区的用户都可以与之相关联。这允许对多对一和多对多的关系进行建模。
网状模型中记录之间的链接不是外键,而更像编程语言中的指针(同时仍然存储在磁盘上)。访问记录的唯一方法是跟随从根记录起沿这些链路所形成的路径。这被称为 **访问路径access path**
最简单的情况下,访问路径类似遍历链表:从列表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,数条不同的路径可以到达相同的记录,网状模型的程序员必须跟踪这些不同的访问路径。
CODASYL 中的查询是通过利用遍历记录列和跟随访问路径表在数据库中移动游标来执行的。如果记录有多个父结点(即多个来自其他记录的传入指针),则应用程序代码必须跟踪所有的各种关系。甚至 CODASYL 委员会成员也承认,这就像在 n 维数据空间中进行导航【17】。
尽管手动选择访问路径能够最有效地利用 20 世纪 70 年代非常有限的硬件功能(如磁带驱动器,其搜索速度非常慢),但这使得查询和更新数据库的代码变得复杂不灵活。无论是分层还是网状模型,如果你没有所需数据的路径,就会陷入困境。你可以改变访问路径,但是必须浏览大量手写数据库查询代码,并重写来处理新的访问路径。更改应用程序的数据模型是很难的。
#### 关系模型
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个 **关系(表)** 只是一个 **元组(行)** 的集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系 [^iv]。
[^iv]: 外键约束允许对修改进行限制,但对于关系模型这并不是必选项。即使有约束,外键连接在查询时执行,而在 CODASYL 中,连接在插入时高效完成。
在关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是 “访问路径”,但最大的区别在于它们是由查询优化器自动生成的,而不是由程序员生成,所以我们很少需要考虑它们。
如果想按新的方式查询数据,你可以声明一个新的索引,查询会自动使用最合适的那些索引。无需更改查询来利用新的索引(请参阅 “[数据查询语言](#数据查询语言)”)。关系模型因此使添加应用程序新功能变得更加容易。
关系数据库的查询优化器是复杂的已耗费了多年的研究和开发精力【18】。关系模型的一个关键洞察是只需构建一次查询优化器随后使用该数据库的所有应用程序都可以从中受益。如果你没有查询优化器的话那么为特定查询手动编写访问路径比编写通用优化器更容易 —— 不过从长期看通用解决方案更好。
#### 与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录([图 2-1](/v1/ddia_0201.png) 中的一对多关系,如 `positions``education` 和 `contact_info`),而不是在单独的表中。
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都被一个唯一的标识符引用,这个标识符在关系模型中被称为 **外键**,在文档模型中称为 **文档引用**【9】。该标识符在读取时通过连接或后续查询来解析。迄今为止文档数据库没有走 CODASYL 的老路。
### 关系型数据库与文档数据库在今日的对比
将关系数据库与文档数据库进行比较时,可以考虑许多方面的差异,包括它们的容错属性(请参阅 [第五章](/v1/ch5))和处理并发性(请参阅 [第七章](/v1/ch7))。本章将只关注数据模型中的差异。
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
#### 哪种数据模型更有助于简化应用代码?
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如 [图 2-1](/v1/ddia_0201.png) 中的 `positions`、`education` 和 `contact_info`)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说 “用户 251 的位置列表中的第二项”(很像层次模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。
文档数据库对连接的糟糕支持可能是个问题也可能不是问题这取决于应用程序。例如如果某分析型应用程序使用一个文档数据库来记录何时何地发生了何事那么多对多关系可能永远也用不上。【19】。
但如果你的应用程序确实会用到多对多关系那么文档模型就没有那么诱人了。尽管可以通过反规范化来消除对连接的需求但这需要应用程序代码来做额外的工作以确保数据一致性。尽管应用程序代码可以通过向数据库发出多个请求的方式来模拟连接但这也将复杂性转移到应用程序中而且通常也会比由数据库内的专用代码更慢。在这种情况下使用文档模型可能会导致更复杂的应用代码与更差的性能【15】。
我们没有办法说哪种数据模型更有助于简化应用代码,因为它取决于数据项之间的关系种类。对高度关联的数据而言,文档模型是极其糟糕的,关系模型是可以接受的,而选用图形模型(请参阅 “[图数据模型](#图数据模型)”)是最自然的。
#### 文档模型中的模式灵活性
大多数文档数据库以及关系数据库中的 JSON 支持都不会强制文档中的数据采用何种模式。关系数据库的 XML 支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到文档中,并且当读取时,客户端无法保证文档可能包含的字段。
文档数据库有时称为 **无模式schemaless**,但这具有误导性,因为读取数据的代码通常假定某种结构 —— 即存在隐式模式但不由数据库强制执行【20】。一个更精确的术语是 **读时模式**(即 schema-on-read数据的结构是隐含的只有在数据被读取时才被解释相应的是 **写时模式**(即 schema-on-write传统的关系数据库方法中模式明确且数据库确保所有的数据都符合其模式【21】。
读时模式类似于编程语言中的动态运行时类型检查而写时模式类似于静态编译时类型检查。就像静态和动态类型检查的相对优点具有很大的争议性一样【22】数据库中模式的强制性是一个具有争议的话题一般来说没有正确或错误的答案。
在应用程序想要改变其数据格式的情况下这些方法之间的区别尤其明显。例如假设你把每个用户的全名存储在一个字段中而现在想分别存储名字和姓氏【23】。在文档数据库中只需开始写入具有新字段的新文档并在应用程序中使用代码来处理读取旧文档的情况。例如
```go
if (user && user.name && !user.first_name) {
// Documents written before Dec 8, 2013 don't have first_name
user.first_name = user.name.split(" ")[0];
}
```
另一方面,在 “静态类型” 数据库模式中,通常会执行以下 **迁移migration** 操作:
```sql
ALTER TABLE users ADD COLUMN first_name text;
UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL
UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
```
模式变更的速度很慢,而且要求停运。它的这种坏名誉并不是完全应得的:大多数关系数据库系统可在几毫秒内执行 `ALTER TABLE` 语句。MySQL 是一个值得注意的例外,它执行 `ALTER TABLE` 时会复制整个表这可能意味着在更改一个大型表时会花费几分钟甚至几个小时的停机时间尽管存在各种工具来解决这个限制【24,25,26】。
大型表上运行 `UPDATE` 语句在任何数据库上都可能会很慢,因为每一行都需要重写。要是不可接受的话,应用程序可以将 `first_name` 设置为默认值 `NULL`,并在读取时再填充,就像使用文档数据库一样。
当由于某种原因(例如,数据是异构的)集合中的项目并不都具有相同的结构时,读时模式更具优势。例如,如果:
* 存在许多不同类型的对象,将每种类型的对象放在自己的表中是不现实的。
* 数据的结构由外部系统决定。你无法控制外部系统且它随时可能变化。
在上述情况下,模式的坏处远大于它的帮助,无模式文档可能是一个更加自然的数据模型。但是,要是所有记录都具有相同的结构,那么模式是记录并强制这种结构的有效机制。第四章将更详细地讨论模式和模式演化。
#### 查询的数据局部性
文档通常以单个连续字符串形式进行存储,编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON。如果应用程序经常需要访问整个文档例如将其渲染至网页那么存储局部性会带来性能优势。如果将数据分割到多个表中如 [图 2-1](/v1/ddia_0201.png) 所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
局部性仅仅适用于同时需要文档绝大部分内容的情况。即使只访问文档其中的一小部分数据库通常需要加载整个文档对于大型文档来说这种加载行为是很浪费的。更新文档时通常需要整个重写。只有不改变文档大小的修改才可以容易地原地执行。因此通常建议保持相对小的文档并避免增加文档大小的写入【9】。这些性能限制大大减少了文档数据库的实用场景。
值得指出的是为了局部性而分组集合相关数据的想法并不局限于文档模型。例如Google 的 Spanner 数据库在关系数据模型中提供了同样的局部性属性允许模式声明一个表的行应该交错嵌套在父表内【27】。Oracle 类似地允许使用一个称为 **多表索引集群表multi-table index cluster tables** 的类似特性【28】。Bigtable 数据模型(用于 Cassandra 和 HBase中的 **列族column-family** 概念与管理局部性的目的类似【29】。
在 [第三章](/v1/ch3) 将还会看到更多关于局部性的内容。
#### 文档和关系数据库的融合
自 2000 年代中期以来大多数关系数据库系统MySQL 除外)都已支持 XML。这包括对 XML 文档进行本地修改的功能,以及在 XML 文档中进行索引和查询的功能。这允许应用程序使用那种与文档数据库应当使用的非常类似的数据模型。
从 9.3 版本开始的 PostgreSQL 【8】从 5.7 版本开始的 MySQL 以及从版本 10.5 开始的 IBM DB2【30】也对 JSON 文档提供了类似的支持级别。鉴于用在 Web APIs 的 JSON 流行趋势,其他关系数据库很可能会跟随他们的脚步并添加 JSON 支持。
在文档数据库中RethinkDB 在其查询语言中支持类似关系的连接,一些 MongoDB 驱动程序可以自动解析数据库引用(有效地执行客户端连接,尽管这可能比在数据库中执行的连接慢,需要额外的网络往返,并且优化更少)。
随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充 [^v],如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。
关系模型和文档模型的混合是未来数据库一条很好的路线。
[^v]: Codd 对关系模型【1】的原始描述实际上允许在关系模式中与 JSON 文档非常相似。他称之为 **非简单域nonsimple domains**。这个想法是,一行中的值不一定是一个像数字或字符串一样的原始数据类型,也可以是一个嵌套的关系(表),因此可以把一个任意嵌套的树结构作为一个值,这很像 30 年后添加到 SQL 中的 JSON 或 XML 支持。
## 数据查询语言
当引入关系模型时关系模型包含了一种查询数据的新方法SQL 是一种 **声明式** 查询语言,而 IMS 和 CODASYL 使用 **命令式** 代码来查询数据库。那是什么意思?
许多常用的编程语言是命令式的。例如,给定一个动物物种的列表,返回列表中的鲨鱼可以这样写:
```js
function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
}
```
而在关系代数中,你可以这样写:
$$
sharks = \sigma_{family = "sharks"}(animals)
$$
其中 $\sigma$(希腊字母西格玛)是选择操作符,只返回符合 `family="shark"` 条件的动物。
定义 SQL 时,它紧密地遵循关系代数的结构:
```sql
SELECT * FROM animals WHERE family ='Sharks';
```
命令式语言告诉计算机以特定顺序执行某些操作。可以想象一下,逐行地遍历代码,评估条件,更新变量,并决定是否再循环一遍。
在声明式查询语言(如 SQL 或关系代数)中,你只需指定所需数据的模式 - 结果必须符合哪些条件,以及如何将数据转换(例如,排序,分组和集合) - 但不是如何实现这一目标。数据库系统的查询优化器决定使用哪些索引和哪些连接方法,以及以何种顺序执行查询的各个部分。
声明式查询语言是迷人的,因为它通常比命令式 API 更加简洁和容易。但更重要的是,它还隐藏了数据库引擎的实现细节,这使得数据库系统可以在无需对查询做任何更改的情况下进行性能提升。
例如,在本节开头所示的命令代码中,动物列表以特定顺序出现。如果数据库想要在后台回收未使用的磁盘空间,则可能需要移动记录,这会改变动物出现的顺序。数据库能否安全地执行,而不会中断查询?
SQL 示例不确保任何特定的顺序因此不在意顺序是否改变。但是如果查询用命令式的代码来写的话那么数据库就永远不可能确定代码是否依赖于排序。SQL 相当有限的功能性为数据库提供了更多自动优化的空间。
最后声明式语言往往适合并行执行。现在CPU 的速度通过核心core的增加变得更快而不是以比以前更高的时钟速度运行【31】。命令代码很难在多个核心和多个机器之间并行化因为它指定了指令必须以特定顺序执行。声明式语言更具有并行执行的潜力因为它们仅指定结果的模式而不指定用于确定结果的算法。在适当情况下数据库可以自由使用查询语言的并行实现【32】。
### Web 上的声明式查询
声明式查询语言的优势不仅限于数据库。为了说明这一点,让我们在一个完全不同的环境中比较声明式和命令式方法:一个 Web 浏览器。
假设你有一个关于海洋动物的网站。用户当前正在查看鲨鱼页面,因此你将当前所选的导航项目 “鲨鱼” 标记为当前选中项目。
```html
<ul>
<li class="selected">
<p>Sharks</p>
<ul>
<li>Great White Shark</li>
<li>Tiger Shark</li>
<li>Hammerhead Shark</li>
</ul>
</li>
<li><p>Whales</p>
<ul>
<li>Blue Whale</li>
<li>Humpback Whale</li>
<li>Fin Whale</li>
</ul>
</li>
</ul>
```
现在想让当前所选页面的标题具有一个蓝色的背景,以便在视觉上突出显示。使用 CSS 实现起来非常简单:
```css
li.selected > p {
background-color: blue;
}
```
这里的 CSS 选择器 `li.selected > p` 声明了我们想要应用蓝色样式的元素的模式:即其直接父元素是具有 CSS 类 `selected``<li>` 元素的所有 `<p>` 元素。示例中的元素 `<p>Sharks</p>` 匹配此模式,但 `<p>Whales</p>` 不匹配,因为其 `<li>` 父元素缺少 `class="selected"`
如果使用 XSL 而不是 CSS你可以做类似的事情
```xml
<xsl:template match="li[@class='selected']/p">
<fo:block background-color="blue">
<xsl:apply-templates/>
</fo:block>
</xsl:template>
```
这里的 XPath 表达式 `li[@class='selected']/p` 相当于上例中的 CSS 选择器 `li.selected > p`。CSS 和 XSL 的共同之处在于,它们都是用于指定文档样式的声明式语言。
想象一下,必须使用命令式方法的情况会是如何。在 Javascript 中,使用 **文档对象模型DOM** API其结果可能如下所示
```js
var liElements = document.getElementsByTagName("li");
for (var i = 0; i < liElements.length; i++) {
if (liElements[i].className === "selected") {
var children = liElements[i].childNodes;
for (var j = 0; j < children.length; j++) {
var child = children[j];
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
child.setAttribute("style", "background-color: blue");
}
}
}
}
```
这段 JavaScript 代码命令式地将元素设置为蓝色背景,但是代码看起来很糟糕。不仅比 CSS 和 XSL 等价物更长,更难理解,而且还有一些严重的问题:
* 如果选定的类被移除(例如,因为用户点击了不同的页面),即使代码重新运行,蓝色背景也不会被移除 - 因此该项目将保持突出显示,直到整个页面被重新加载。使用 CSS浏览器会自动检测 `li.selected > p` 规则何时不再适用,并在选定的类被移除后立即移除蓝色背景。
* 如果你想要利用新的 API例如 `document.getElementsByClassName("selected")` 甚至 `document.evaluate()`)来提高性能,则必须重写代码。另一方面,浏览器供应商可以在不破坏兼容性的情况下提高 CSS 和 XPath 的性能。
在 Web 浏览器中,使用声明式 CSS 样式比使用 JavaScript 命令式地操作样式要好得多。类似地,在数据库中,使用像 SQL 这样的声明式查询语言比使用命令式查询 API 要好得多 [^vi]。
[^vi]: IMS 和 CODASYL 都使用命令式 API。应用程序通常使用 COBOL 代码遍历数据库中的记录一次一条记录【2,16】。
### MapReduce查询
MapReduce 是一个由 Google 推广的编程模型用于在多台机器上批量处理大规模的数据【33】。一些 NoSQL 数据存储(包括 MongoDB 和 CouchDB支持有限形式的 MapReduce作为在多个文档中执行只读查询的机制。
关于 MapReduce 更详细的介绍在 [第十章](/v1/ch10)。现在我们只简要讨论一下 MongoDB 使用的模型。
MapReduce 既不是一个声明式的查询语言,也不是一个完全命令式的查询 API而是处于两者之间查询的逻辑用代码片段来表示这些代码片段会被处理框架重复性调用。它基于 `map`(也称为 `collect`)和 `reduce`(也称为 `fold``inject`)函数,两个函数存在于许多函数式编程语言中。
最好举例来解释 MapReduce 模型。假设你是一名海洋生物学家,每当你看到海洋中的动物时,你都会在数据库中添加一条观察记录。现在你想生成一个报告,说明你每月看到多少鲨鱼。
在 PostgreSQL 中,你可以像这样表述这个查询:
```sql
SELECT
date_trunc('month', observation_timestamp) AS observation_month,
sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
```
`date_trunc('month', timestamp)` 函数用于确定包含 `timestamp` 的日历月份,并返回代表该月份开始的另一个时间戳。换句话说,它将时间戳舍入成最近的月份。
这个查询首先过滤观察记录,以只显示鲨鱼家族的物种,然后根据它们发生的日历月份对观察记录果进行分组,最后将在该月的所有观察记录中看到的动物数目加起来。
同样的查询用 MongoDB 的 MapReduce 功能可以按如下来表述:
```js
db.observations.mapReduce(function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getMonth() + 1;
emit(year + "-" + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
},
{
query: {
family: "Sharks"
},
out: "monthlySharkReport"
});
```
* 可以声明式地指定一个只考虑鲨鱼种类的过滤器(这是 MongoDB 特定的 MapReduce 扩展)。
* 每个匹配查询的文档都会调用一次 JavaScript 函数 `map`,将 `this` 设置为文档对象。
* `map` 函数发出一个键(包括年份和月份的字符串,如 `"2013-12"``"2014-1"`)和一个值(该观察记录中的动物数量)。
* `map` 发出的键值对按键来分组。对于具有相同键(即,相同的月份和年份)的所有键值对,调用一次 `reduce` 函数。
* `reduce` 函数将特定月份内所有观测记录中的动物数量相加。
* 将最终的输出写入到 `monthlySharkReport` 集合中。
例如,假设 `observations` 集合包含这两个文档:
```json
{
observationTimestamp: Date.parse( "Mon, 25 Dec 1995 12:34:56 GMT"),
family: "Sharks",
species: "Carcharodon carcharias",
numAnimals: 3
}
{
observationTimestamp: Date.parse("Tue, 12 Dec 1995 16:17:18 GMT"),
family: "Sharks",
species: "Carcharias taurus",
numAnimals: 4
}
```
对每个文档都会调用一次 `map` 函数,结果将是 `emit("1995-12",3)``emit("1995-12",4)`。随后,以 `reduce("1995-12",[3,4])` 调用 `reduce` 函数,将返回 `7`
map 和 reduce 函数在功能上有所限制:它们必须是 **纯** 函数这意味着它们只使用传递给它们的数据作为输入它们不能执行额外的数据库查询也不能有任何副作用。这些限制允许数据库以任何顺序运行任何功能并在失败时重新运行它们。然而map 和 reduce 函数仍然是强大的:它们可以解析字符串、调用库函数、执行计算等等。
MapReduce 是一个相当底层的编程模型,用于计算机集群上的分布式执行。像 SQL 这样的更高级的查询语言可以用一系列的 MapReduce 操作来实现(见 [第十章](/v1/ch10)),但是也有很多不使用 MapReduce 的分布式 SQL 实现。須注意SQL 并没有限制它只能在单一机器上运行,而 MapReduce 也并没有垄断所有的分布式查询执行。
能够在查询中使用 JavaScript 代码是高级查询的一个重要特性,但这不限于 MapReduce一些 SQL 数据库也可以用 JavaScript 函数进行扩展【34】。
MapReduce 的一个可用性问题是,必须编写两个密切合作的 JavaScript 函数这通常比编写单个查询更困难。此外声明式查询语言为查询优化器提供了更多机会来提高查询的性能。基于这些原因MongoDB 2.2 添加了一种叫做 **聚合管道** 的声明式查询语言的支持【9】。用这种语言表述鲨鱼计数查询如下所示
```js
db.observations.aggregate([
{ $match: { family: "Sharks" } },
{ $group: {
_id: {
year: { $year: "$observationTimestamp" },
month: { $month: "$observationTimestamp" }
},
totalAnimals: { $sum: "$numAnimals" } }}
]);
```
聚合管道语言的表现力与(前述 PostgreSQL 例子的SQL 子集相当,但是它使用基于 JSON 的语法而不是 SQL 那种接近英文句式的语法这种差异也许只是口味问题。这个故事的寓意是NoSQL 系统可能会意外发现自己只是重新发明了一套经过乔装改扮的 SQL。
## 图数据模型
如我们之前所见,多对多关系是不同数据模型之间具有区别性的重要特征。如果你的应用程序大多数的关系是一对多关系(树状结构化数据),或者大多数记录之间不存在关系,那么使用文档模型是合适的。
但是,要是多对多关系在你的数据中很常见呢?关系模型可以处理多对多关系的简单情况,但是随着数据之间的连接变得更加复杂,将数据建模为图形显得更加自然。
一个图由两种对象组成:**顶点**vertices也称为 **节点**,即 nodes**实体**,即 entities**边**edges也称为 **关系**,即 relationships**弧**,即 arcs。多种数据可以被建模为一个图形。典型的例子包括
社交图谱
: 顶点是人,边指示哪些人彼此认识。
网络图谱
: 顶点是网页,边缘表示指向其他页面的 HTML 链接。
公路或铁路网络
: 顶点是交叉路口,边线代表它们之间的道路或铁路线。
可以将那些众所周知的算法运用到这些图上例如汽车导航系统搜索道路网络中两点之间的最短路径PageRank 可以用在网络图上来确定网页的流行程度,从而确定该网页在搜索结果中的排名。
在刚刚给出的例子中图中的所有顶点代表了相同类型的事物人、网页或交叉路口。不过图并不局限于这样的同类数据同样强大地是图提供了一种一致的方式用来在单个数据存储中存储完全不同类型的对象。例如Facebook 维护一个包含许多不同类型的顶点和边的单个图顶点表示人、地点、事件、签到和用户的评论边表示哪些人是好友、签到发生在哪里、谁评论了什么帖子、谁参与了什么事件等等【35】。
在本节中,我们将使用 [图 2-5](/v1/ddia_0205.png) 所示的示例。它可以从社交网络或系谱数据库中获得:它显示了两个人,来自爱达荷州的 Lucy 和来自法国 Beaune 的 Alain。他们已婚住在伦敦。
![](/v1/ddia_0205.png)
**图 2-5 图数据结构示例(框代表顶点,箭头代表边)**
有几种不同但相关的方法用来构建和查询图表中的数据。在本节中,我们将讨论属性图模型(由 Neo4jTitan 和 InfiniteGraph 实现和三元组存储triple-store模型由 Datomic、AllegroGraph 等实现。我们将查看图的三种声明式查询语言CypherSPARQL 和 Datalog。除此之外还有像 Gremlin 【36】这样的图形查询语言和像 Pregel 这样的图形处理框架(见 [第十章](/v1/ch10))。
### 属性图
在属性图模型中每个顶点vertex包括
* 唯一的标识符
* 一组出边outgoing edges
* 一组入边ingoing edges
* 一组属性(键值对)
每条边edge包括
* 唯一标识符
* 边的起点(**尾部顶点**,即 tail vertex
* 边的终点(**头部顶点**,即 head vertex
* 描述两个顶点之间关系类型的标签
* 一组属性(键值对)
可以将图存储看作由两个关系表组成:一个存储顶点,另一个存储边,如 [例 2-2]() 所示(该模式使用 PostgreSQL JSON 数据类型来存储每个顶点或每条边的属性)。头部和尾部顶点用来存储每条边;如果你想要一组顶点的输入或输出边,你可以分别通过 `head_vertex``tail_vertex` 来查询 `edges` 表。
**例 2-2 使用关系模式来表示属性图**
```sql
CREATE TABLE vertices (
vertex_id INTEGER PRIMARY KEY,
properties JSON
);
CREATE TABLE edges (
edge_id INTEGER PRIMARY KEY,
tail_vertex INTEGER REFERENCES vertices (vertex_id),
head_vertex INTEGER REFERENCES vertices (vertex_id),
label TEXT,
properties JSON
);
CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);
```
关于这个模型的一些重要方面是:
1. 任何顶点都可以有一条边连接到任何其他顶点。没有模式限制哪种事物可不可以关联。
2. 给定任何顶点,可以高效地找到它的入边和出边,从而遍历图,即沿着一系列顶点的路径前后移动(这就是为什么 [例 2-2]() 在 `tail_vertex``head_vertex` 列上都有索引的原因)。
3. 通过对不同类型的关系使用不同的标签,可以在一个图中存储几种不同的信息,同时仍然保持一个清晰的数据模型。
这些特性为数据建模提供了很大的灵活性,如 [图 2-5](/v1/ddia_0205.png) 所示。图中显示了一些传统关系模式难以表达的事情例如不同国家的不同地区结构法国有省和大区美国有县和州国中国的怪事先忽略主权国家和民族错综复杂的烂摊子不同的数据粒度Lucy 现在的住所记录具体到城市,而她的出生地点只是在一个州的级别)。
你可以想象该图还能延伸出许多关于 Lucy 和 Alain 的事实,或其他人的其他更多的事实。例如,你可以用它来表示食物过敏(为每个过敏源增加一个顶点,并增加人与过敏源之间的一条边来指示一种过敏情况),并链接到过敏源,每个过敏源具有一组顶点用来显示哪些食物含有哪些物质。然后,你可以写一个查询,找出每个人吃什么是安全的。图在可演化性方面是富有优势的:当你向应用程序添加功能时,可以轻松扩展图以适应程序数据结构的变化。
### Cypher 查询语言
Cypher 是属性图的声明式查询语言,为 Neo4j 图形数据库而发明【37】它是以电影 “黑客帝国” 中的一个角色来命名的而与密码学中的加密算法无关【38】
[例 2-3]() 显示了将 [图 2-5](/v1/ddia_0205.png) 的左边部分插入图形数据库的 Cypher 查询。你可以以类似的方式把图的剩余部分添加进去,但这里为了文章可閱读性而省略这部分的示例。每个顶点都有一个像 `USA``Idaho` 这样的符号名称,查询的其他部分可以使用这些名称在顶点之间创建边,使用箭头符号:`Idaho - [WITHIN] ->USA` 创建一条标记为 `WITHIN` 的边,`Idaho` 为尾节点,`USA` 为头节点。
**例 2-3 将图 2-5 中的数据子集表示为 Cypher 查询**
```cypher
CREATE
(NAmerica:Location {name:'North America', type:'continent'}),
(USA:Location {name:'United States', type:'country' }),
(Idaho:Location {name:'Idaho', type:'state' }),
(Lucy:Person {name:'Lucy' }),
(Idaho) -[:WITHIN]-> (USA) -[:WITHIN]-> (NAmerica),
(Lucy) -[:BORN_IN]-> (Idaho)
```
当 [图 2-5](/v1/ddia_0205.png) 的所有顶点和边被添加到数据库后,让我们提些有趣的问题:例如,找到所有从美国移民到欧洲的人的名字。更确切地说,这里我们想要找到符合下面条件的所有顶点,并且返回这些顶点的 `name` 属性:该顶点拥有一条连到美国任一位置的 `BORN_IN` 边,和一条连到欧洲的任一位置的 `LIVING_IN` 边。
[例 2-4]() 展示了如何在 Cypher 中表达这个查询。在 MATCH 子句中使用相同的箭头符号来查找图中的模式:`(person) -[:BORN_IN]-> ()` 可以匹配 `BORN_IN` 边的任意两个顶点。该边的尾节点被绑定了变量 `person`,头节点则未被绑定。
**例 2-4 查找所有从美国移民到欧洲的人的 Cypher 查询:**
```cypher
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name
```
查询按如下来解读:
> 找到满足以下两个条件的所有顶点(称之为 person 顶点):
> 1. `person` 顶点拥有一条到某个顶点的 `BORN_IN` 出边。从那个顶点开始,沿着一系列 `WITHIN` 出边最终到达一个类型为 `Location``name` 属性为 `United States` 的顶点。
>
> 2. `person` 顶点还拥有一条 `LIVES_IN` 出边。沿着这条边,可以通过一系列 `WITHIN` 出边最终到达一个类型为 `Location``name` 属性为 `Europe` 的顶点。
>
> 对于这样的 `Person` 顶点,返回其 `name` 属性。
执行这条查询可能会有几种可行的查询路径。这里给出的描述建议首先扫描数据库中的所有人,检查每个人的出生地和居住地,然后只返回符合条件的那些人。
等价地,也可以从两个 `Location` 顶点开始反向地查找。假如 `name` 属性上有索引,则可以高效地找到代表美国和欧洲的两个顶点。然后,沿着所有 `WITHIN` 入边,可以继续查找出所有在美国和欧洲的位置(州、地区、城市等)。最后,查找出那些可以由 `BORN_IN``LIVES_IN` 入边到那些位置顶点的人。
通常对于声明式查询语言来说,在编写查询语句时,不需要指定执行细节:查询优化程序会自动选择预测效率最高的策略,因此你可以专注于编写应用程序的其他部分。
### SQL 中的图查询
[例 2-2]() 指出,可以在关系数据库中表示图数据。但是,如果图数据已经以关系结构存储,我们是否也可以使用 SQL 查询它?
答案是肯定的,但有些困难。在关系数据库中,你通常会事先知道在查询中需要哪些连接。在图查询中,你可能需要在找到待查找的顶点之前,遍历可变数量的边。也就是说,连接的数量事先并不确定。
在我们的例子中,这发生在 Cypher 查询中的 `() -[:WITHIN*0..]-> ()` 规则中。一个人的 `LIVES_IN` 边可以指向任何类型的位置街道、城市、地区、国家等。一个城市可以在WITHIN一个地区内一个地区可以在WITHIN在一个州内一个州可以在WITHIN一个国家内等等。`LIVES_IN` 边可以直接指向正在查找的位置,或者一个在位置层次结构中隔了数层的位置。
在 Cypher 中,用 `WITHIN*0..` 非常简洁地表述了上述事实:“沿着 `WITHIN` 边,零次或多次”。它很像正则表达式中的 `*` 运算符。
自 SQL:1999查询可变长度遍历路径的思想可以使用称为 **递归公用表表达式**`WITH RECURSIVE` 语法)的东西来表示。[例 2-5]() 显示了同样的查询 - 查找从美国移民到欧洲的人的姓名 - 在 SQL 使用这种技术PostgreSQL、IBM DB2、Oracle 和 SQL Server 均支持)来表述。但是,与 Cypher 相比,其语法非常笨拙。
**例 2-5 与示例 2-4 同样的查询,在 SQL 中使用递归公用表表达式表示**
```sql
WITH RECURSIVE
-- in_usa 包含所有的美国境内的位置 ID
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'United States'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe 包含所有的欧洲境内的位置 ID
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'Europe'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within' ),
-- born_in_usa 包含了所有类型为 Person且出生在美国的顶点
born_in_usa(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in' ),
-- lives_in_europe 包含了所有类型为 Person且居住在欧洲的顶点。
lives_in_europe(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in')
SELECT vertices.properties ->> 'name'
FROM vertices
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;
```
* 首先,查找 `name` 属性为 `United States` 的顶点,将其作为 `in_usa` 顶点的集合的第一个元素。
* 从 `in_usa` 集合的顶点出发,沿着所有的 `with_in` 入边,将其尾顶点加入同一集合,不断递归直到所有 `with_in` 入边都被访问完毕。
* 同理,从 `name` 属性为 `Europe` 的顶点出发,建立 `in_europe` 顶点的集合。
* 对于 `in_usa` 集合中的每个顶点,根据 `born_in` 入边来查找出生在美国某个地方的人。
* 同样,对于 `in_europe` 集合中的每个顶点,根据 `lives_in` 入边来查找居住在欧洲的人。
* 最后,把在美国出生的人的集合与在欧洲居住的人的集合相交。
同一个查询,用某一个查询语言可以写成 4 行,而用另一个查询语言需要 29 行,这恰恰说明了不同的数据模型是为不同的应用场景而设计的。选择适合应用程序的数据模型非常重要。
### 三元组存储和 SPARQL
三元组存储模式大体上与属性图模型相同,用不同的词来描述相同的想法。不过仍然值得讨论,因为三元组存储有很多现成的工具和语言,这些工具和语言对于构建应用程序的工具箱可能是宝贵的补充。
在三元组存储中,所有信息都以非常简单的三部分表示形式存储(**主语****谓语****宾语**)。例如,三元组 **(吉姆, 喜欢, 香蕉)** 中,**吉姆** 是主语,**喜欢** 是谓语(动词),**香蕉** 是对象。
三元组的主语相当于图中的一个顶点。而宾语是下面两者之一:
1. 原始数据类型中的值,例如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上的属性的键和值。例如,`(lucy, age, 33)` 就像属性 `{“age”33}` 的顶点 lucy。
2. 图中的另一个顶点。在这种情况下,谓语是图中的一条边,主语是其尾部顶点,而宾语是其头部顶点。例如,在 `(lucy, marriedTo, alain)` 中主语和宾语 `lucy``alain` 都是顶点,并且谓语 `marriedTo` 是连接他们的边的标签。
[例 2-6]() 展示了与 [例 2-3]() 相同的数据,以称为 Turtle 的格式Notation3N3【39】的一个子集写成三元组。
**例 2-6 图 2-5 中的数据子集,表示为 Turtle 三元组**
```reStructuredText
@prefix : <urn:example:>.
_:lucy a :Person.
_:lucy :name "Lucy".
_:lucy :bornIn _:idaho.
_:idaho a :Location.
_:idaho :name "Idaho".
_:idaho :type "state".
_:idaho :within _:usa.
_:usa a :Location
_:usa :name "United States"
_:usa :type "country".
_:usa :within _:namerica.
_:namerica a :Location
_:namerica :name "North America"
_:namerica :type :"continent"
```
在这个例子中,图的顶点被写为:`_someName`。这个名字并不意味着这个文件以外的任何东西。它的存在只是帮助我们明确哪些三元组引用了同一顶点。当谓语表示边时,该宾语是一个顶点,如 `_:idaho :within _:usa.`。当谓语是一个属性时,该宾语是一个字符串,如 `_:usa :name"United States"`
一遍又一遍地重复相同的主语看起来相当重复,但幸运的是,可以使用分号来说明关于同一主语的多个事情。这使得 Turtle 格式相当不错,可读性强:请参阅 [例 2-7]()。
**例 2-7 一种相对例 2-6 写入数据的更为简洁的方法。**
```
@prefix : <urn:example:>.
_:lucy a :Person; :name "Lucy"; :bornIn _:idaho.
_:idaho a :Location; :name "Idaho"; :type "state"; :within _:usa
_:usa a :Loaction; :name "United States"; :type "country"; :within _:namerica.
_:namerica a :Location; :name "North America"; :type "continent".
```
#### 语义网
如果你深入了解关于三元组存储的信息,可能会陷入关于**语义网**的讨论漩涡中。三元组存储模型其实是完全独立于语义网存在的例如Datomic【40】作为一种三元组存储数据库 [^vii],从未被用于语义网中。但是,由于在很多人眼中这两者紧密相连,我们应该简要地讨论一下。
[^vii]: 从技术上讲Datomic 使用的是五元组而不是三元组,两个额外的字段是用于版本控制的元数据
从本质上讲,语义网是一个简单且合理的想法:网站已经将信息发布为文字和图片供人类阅读,为什么不将信息作为机器可读的数据也发布给计算机呢?(基于三元组模型的)**资源描述框架****RDF**【41】被用作不同网站以统一的格式发布数据的一种机制允许来自不同网站的数据自动合并成 **一个数据网络** —— 成为一种互联网范围内的 “通用语义网数据库”。
不幸的是,语义网在二十一世纪初被过度炒作,但到目前为止没有任何迹象表明已在实践中应用,这使得许多人嗤之以鼻。它还饱受眼花缭乱的缩略词、过于复杂的标准提案和狂妄自大的困扰。
然而,如果从过去的失败中汲取教训,语义网项目还是拥有很多优秀的成果。即使你没有兴趣在语义网上发布 RDF 数据,三元组这种模型也是一种好的应用程序内部数据模型。
#### RDF 数据模型
[例 2-7]() 中使用的 Turtle 语言是一种用于 RDF 数据的人类可读格式。有时候RDF 也可以以 XML 格式编写,不过完成同样的事情会相对啰嗦,请参阅 [例 2-8]()。Turtle/N3 是更可取的,因为它更容易阅读,像 Apache Jena 【42】这样的工具可以根据需要在不同的 RDF 格式之间进行自动转换。
**例 2-8 用 RDF/XML 语法表示例 2-7 的数据**
```xml
<rdf:RDF xmlns="urn:example:"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<Location rdf:nodeID="idaho">
<name>Idaho</name>
<type>state</type>
<within>
<Location rdf:nodeID="usa">
<name>United States</name>
<type>country</type>
<within>
<Location rdf:nodeID="namerica">
<name>North America</name>
<type>continent</type>
</Location>
</within>
</Location>
</within>
</Location>
<Person rdf:nodeID="lucy">
<name>Lucy</name>
<bornIn rdf:nodeID="idaho"/>
</Person>
</rdf:RDF>
```
RDF 有一些奇怪之处,因为它是为了在互联网上交换数据而设计的。三元组的主语,谓语和宾语通常是 URI。例如谓语可能是一个 URI`<http://my-company.com/namespace#within>``<http://my-company.com/namespace#lives_in>`,而不仅仅是 `WITHIN``LIVES_IN`。这个设计背后的原因为了让你能够把你的数据和其他人的数据结合起来,如果他们赋予单词 `within` 或者 `lives_in` 不同的含义,两者也不会冲突,因为它们的谓语实际上是 `<http://other.org/foo#within>``<http://other.org/foo#lives_in>`
从 RDF 的角度来看URL `<http://my-company.com/namespace>` 不一定需要能解析成什么东西,它只是一个命名空间。为避免与 `http://URL` 混淆,本节中的示例使用不可解析的 URI`urnexamplewithin`。幸运的是,你只需在文件顶部对这个前缀做一次声明,后续就不用再管了。
### SPARQL 查询语言
**SPARQL** 是一种用于三元组存储的面向 RDF 数据模型的查询语言【43】它是 SPARQL 协议和 RDF 查询语言的缩写,发音为 “sparkle”。SPARQL 早于 Cypher并且由于 Cypher 的模式匹配借鉴于 SPARQL这使得它们看起来非常相似【37】。
与之前相同的查询 —— 查找从美国移民到欧洲的人 —— 使用 SPARQL 比使用 Cypher 甚至更为简洁(请参阅 [例 2-9]())。
**例 2-9 与示例 2-4 相同的查询,用 SPARQL 表示**
```sparql
PREFIX : <urn:example:>
SELECT ?personName WHERE {
?person :name ?personName.
?person :bornIn / :within* / :name "United States".
?person :livesIn / :within* / :name "Europe".
}
```
结构非常相似。以下两个表达式是等价的SPARQL 中的变量以问号开头):
```
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (location) # Cypher
?person :bornIn / :within* ?location. # SPARQL
```
因为 RDF 不区分属性和边,而只是将它们作为谓语,所以可以使用相同的语法来匹配属性。在下面的表达式中,变量 `usa` 被绑定到任意 `name` 属性为字符串值 `"United States"` 的顶点:
```
(usa {name:'United States'}) # Cypher
?usa :name "United States". # SPARQL
```
SPARQL 是一种很好的查询语言 —— 尽管它构想的语义网从未实现,但它仍然是一种可用于应用程序内部的强大工具。
> #### 图形数据库与网状模型相比较
>
> 在 “[文档数据库是否在重蹈覆辙?](#文档数据库是否在重蹈覆辙?)” 中,我们讨论了 CODASYL 和关系模型如何竞相解决 IMS 中的多对多关系问题。乍一看CODASYL 的网状模型看起来与图模型相似。CODASYL 是否是图形数据库的第二个变种?
>
> 不,他们在几个重要方面有所不同:
>
> * 在 CODASYL 中,数据库有一个模式,用于指定哪种记录类型可以嵌套在其他记录类型中。在图形数据库中,不存在这样的限制:任何顶点都可以具有到其他任何顶点的边。这为应用程序适应不断变化的需求提供了更大的灵活性。
> * 在 CODASYL 中,达到特定记录的唯一方法是遍历其中的一个访问路径。在图形数据库中,可以通过其唯一 ID 直接引用任何顶点,也可以使用索引来查找具有特定值的顶点。
> * 在 CODASYL 中,记录的子项目是一个有序集合,所以数据库必须去管理它们的次序(这会影响存储布局),并且应用程序在插入新记录到数据库时必须关注新记录在这些集合中的位置。在图形数据库中,顶点和边是无序的(只能在查询时对结果进行排序)。
> * 在 CODASYL 中,所有查询都是命令式的,难以编写,并且很容易因架构变化而受到破坏。在图形数据库中,你可以在命令式代码中手写遍历过程,但大多数图形数据库都支持高级声明式查询,如 Cypher 或 SPARQL。
>
>
### 基础Datalog
**Datalog** 是比 SPARQL、Cypher 更古老的语言,在 20 世纪 80 年代被学者广泛研究【44,45,46】。它在软件工程师中不太知名但是它是重要的因为它为以后的查询语言提供了基础。
实践中Datalog 在有限的几个数据系统中使用:例如,它是 Datomic 【40】的查询语言Cascalog 【47】是一种用于查询 Hadoop 大数据集的 Datalog 实现 [^viii]。
[^viii]: Datomic 和 Cascalog 使用 Datalog 的 Clojure S 表达式语法。在下面的例子中使用了一个更容易阅读的 Prolog 语法,但两者没有任何功能差异。
Datalog 的数据模型类似于三元组模式,但进行了一点泛化。把三元组写成 **谓语****主语,宾语**),而不是写三元语(**主语,谓语,宾语**)。[例 2-10]() 显示了如何用 Datalog 写入我们的例子中的数据。
**例 2-10 用 Datalog 来表示图 2-5 中的数据子集**
```prolog
name(namerica, 'North America').
type(namerica, continent).
name(usa, 'United States').
type(usa, country).
within(usa, namerica).
name(idaho, 'Idaho').
type(idaho, state).
within(idaho, usa).
name(lucy, 'Lucy').
born_in(lucy, idaho).
```
既然已经定义了数据,我们可以像之前一样编写相同的查询,如 [例 2-11]() 所示。它看起来与 Cypher 或 SPARQL 的语法差异较大但请不要抗拒它。Datalog 是 Prolog 的一个子集,如果你是计算机科学专业的学生,可能已经见过 Prolog。
**例 2-11 与示例 2-4 相同的查询,用 Datalog 表示**
```
within_recursive(Location, Name) :- name(Location, Name). /* Rule 1 */
within_recursive(Location, Name) :- within(Location, Via), /* Rule 2 */
within_recursive(Via, Name).
migrated(Name, BornIn, LivingIn) :- name(Person, Name), /* Rule 3 */
born_in(Person, BornLoc),
within_recursive(BornLoc, BornIn),
lives_in(Person, LivingLoc),
within_recursive(LivingLoc, LivingIn).
?- migrated(Who, 'United States', 'Europe'). /* Who = 'Lucy'. */
```
Cypher 和 SPARQL 使用 SELECT 立即跳转,但是 Datalog 一次只进行一小步。我们定义 **规则**,以将新谓语告诉数据库:在这里,我们定义了两个新的谓语,`within_recursive` 和 `migrated`。这些谓语不是存储在数据库中的三元组中,而是从数据或其他规则派生而来的。规则可以引用其他规则,就像函数可以调用其他函数或者递归地调用自己一样。像这样,复杂的查询可以借由小的砖瓦构建起来。
在规则中,以大写字母开头的单词是变量,谓语则用 Cypher 和 SPARQL 的方式一样来匹配。例如,`name(Location, Name)` 通过变量绑定 `Location = namerica``Name ='North America'` 可以匹配三元组 `name(namerica, 'North America')`
要是系统可以在 `:-` 操作符的右侧找到与所有谓语的一个匹配,就运用该规则。当规则运用时,就好像通过 `:-` 的左侧将其添加到数据库(将变量替换成它们匹配的值)。
因此,一种可能的应用规则的方式是:
1. 数据库存在 `name (namerica, 'North America')`,故运用规则 1。它生成 `within_recursive (namerica, 'North America')`
2. 数据库存在 `within (usa, namerica)`,在上一步骤中生成 `within_recursive (namerica, 'North America')`,故运用规则 2。它会产生 `within_recursive (usa, 'North America')`
3. 数据库存在 `within (idaho, usa)`,在上一步生成 `within_recursive (usa, 'North America')`,故运用规则 2。它产生 `within_recursive (idaho, 'North America')`
通过重复应用规则 1 和 2`within_recursive` 谓语可以告诉我们在数据库中包含北美(或任何其他位置名称)的所有位置。这个过程如 [图 2-6](/v1/ddia_0206.png) 所示。
![](/v1/ddia_0206.png)
**图 2-6 使用示例 2-11 中的 Datalog 规则来确定爱达荷州在北美。**
现在规则 3 可以找到出生在某个地方 `BornIn` 的人,并住在某个地方 `LivingIn`。通过查询 `BornIn ='United States'``LivingIn ='Europe'`,并将此人作为变量 `Who`,让 Datalog 系统找出变量 `Who` 会出现哪些值。因此,最后得到了与早先的 Cypher 和 SPARQL 查询相同的答案。
相对于本章讨论的其他查询语言,我们需要采取不同的思维方式来思考 Datalog 方法,但这是一种非常强大的方法,因为规则可以在不同的查询中进行组合和重用。虽然对于简单的一次性查询,显得不太方便,但是它可以更好地处理数据很复杂的情况。
## 本章小结
数据模型是一个巨大的课题,在本章中,我们快速浏览了各种不同的模型。我们没有足够的篇幅来详述每个模型的细节,但是希望这个概述足以激起你的兴趣,以更多地了解最适合你的应用需求的模型。
在历史上,数据最开始被表示为一棵大树(层次数据模型),但是这不利于表示多对多的关系,所以发明了关系模型来解决这个问题。最近,开发人员发现一些应用程序也不适合采用关系模型。新的非关系型 “NoSQL” 数据存储分化为两个主要方向:
**文档数据库**
: 主要关注自我包含的数据文档,而且文档之间的关系非常稀少。
**图形数据库**
: 用于相反的场景:任意事物之间都可能存在潜在的关联。
这三种模型(文档,关系和图形)在今天都被广泛使用,并且在各自的领域都发挥很好。一个模型可以用另一个模型来模拟 —— 例如,图数据可以在关系数据库中表示 —— 但结果往往是糟糕的。这就是为什么我们有着针对不同目的的不同系统,而不是一个单一的万能解决方案。
文档数据库和图数据库有一个共同点,那就是它们通常不会将存储的数据强制约束为特定模式,这可以使应用程序更容易适应不断变化的需求。但是应用程序很可能仍会假定数据具有一定的结构;区别仅在于模式是**明确的**(写入时强制)还是**隐含的**(读取时处理)。
每个数据模型都具有各自的查询语言或框架我们讨论了几个例子SQLMapReduceMongoDB 的聚合管道CypherSPARQL 和 Datalog。我们也谈到了 CSS 和 XSL/XPath它们不是数据库查询语言而包含有趣的相似之处。
虽然我们已经覆盖了很多层面,但仍然有许多数据模型没有提到。举几个简单的例子:
* 使用基因组数据的研究人员通常需要执行 **序列相似性搜索**,这意味着需要一个很长的字符串(代表一个 DNA 序列),并在一个拥有类似但不完全相同的字符串的大型数据库中寻找匹配。这里所描述的数据库都不能处理这种用法,这就是为什么研究人员编写了像 GenBank 这样的专门的基因组数据库软件的原因【48】。
* 粒子物理学家数十年来一直在进行大数据类型的大规模数据分析像大型强子对撞机LHC这样的项目现在会处理数百 PB 的数据在这样的规模下需要定制解决方案来阻止硬件成本的失控【49】。
* **全文搜索** 可以说是一种经常与数据库一起使用的数据模型。信息检索是一个很大的专业课题,我们不会在本书中详细介绍,但是我们将在第三章和第三部分中介绍搜索索引。
让我们暂时将其放在一边。在 [下一章](/v1/ch3) 中,我们将讨论在 **实现** 本章描述的数据模型时会遇到的一些权衡。
## 参考文献
1. Edgar F. Codd: “[A Relational Model of Data for Large Shared Data Banks](https://www.seas.upenn.edu/~zives/03f/cis550/codd.pdf),” *Communications of the ACM*, volume 13, number 6, pages 377387, June 1970. [doi:10.1145/362384.362685](http://dx.doi.org/10.1145/362384.362685)
1. Michael Stonebraker and Joseph M. Hellerstein: “[What Goes Around Comes Around](http://mitpress2.mit.edu/books/chapters/0262693143chapm1.pdf),” in *Readings in Database Systems*, 4th edition, MIT Press, pages 241, 2005. ISBN: 978-0-262-69314-1
1. Pramod J. Sadalage and Martin Fowler: *NoSQL Distilled*. Addison-Wesley, August 2012. ISBN: 978-0-321-82662-6
1. Eric Evans: “[NoSQL: What's in a Name?](https://web.archive.org/web/20190623045155/http://blog.sym-link.com/2009/10/30/nosql_whats_in_a_name.html),” *blog.sym-link.com*, October 30, 2009.
1. James Phillips: “[Surprises in Our NoSQL Adoption Survey](http://blog.couchbase.com/nosql-adoption-survey-surprises),” *blog.couchbase.com*, February 8, 2012.
1. Michael Wagner: *SQL/XML:2006 Evaluierung der Standardkonformität ausgewählter Datenbanksysteme*. Diplomica Verlag, Hamburg, 2010. ISBN: 978-3-836-64609-3
1. “[XML Data (SQL Server)](https://docs.microsoft.com/en-us/sql/relational-databases/xml/xml-data-sql-server?view=sql-server-ver15),” SQL Server documentation, *docs.microsoft.com*, 2013.
1. “[PostgreSQL 9.3.1 Documentation](http://www.postgresql.org/docs/9.3/static/index.html),” The PostgreSQL Global Development Group, 2013.
1. “[The MongoDB 2.4 Manual](http://docs.mongodb.org/manual/),” MongoDB, Inc., 2013.
1. “[RethinkDB 1.11 Documentation](http://www.rethinkdb.com/docs/),” *rethinkdb.com*, 2013.
1. “[Apache CouchDB 1.6 Documentation](http://docs.couchdb.org/en/latest/),” *docs.couchdb.org*, 2014.
1. Lin Qiao, Kapil Surlaker, Shirshanka Das, et al.: “[On Brewing Fresh Espresso: LinkedIns Distributed Data Serving Platform](http://www.slideshare.net/amywtang/espresso-20952131),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013.
1. Rick Long, Mark Harrington, Robert Hain, and Geoff Nicholls: [*IMS Primer*](http://www.redbooks.ibm.com/redbooks/pdfs/sg245352.pdf). IBM Redbook SG24-5352-00, IBM International Technical Support Organization, January 2000.
1. Stephen D. Bartlett: “[IBMs IMS—Myths, Realities, and Opportunities](https://public.dhe.ibm.com/software/data/ims/pdf/TCG2013015LI.pdf),” The Clipper Group Navigator, TCG2013015LI, July 2013.
1. Sarah Mei: “[Why You Should Never Use MongoDB](http://www.sarahmei.com/blog/2013/11/11/why-you-should-never-use-mongodb/),” *sarahmei.com*, November 11, 2013.
1. J. S. Knowles and D. M. R. Bell: “The CODASYL Model,” in *Databases—Role and Structure: An Advanced Course*, edited by P. M. Stocker, P. M. D. Gray, and M. P. Atkinson, pages 1956, Cambridge University Press, 1984. ISBN: 978-0-521-25430-4
1. Charles W. Bachman: “[The Programmer as Navigator](http://dl.acm.org/citation.cfm?id=362534),” *Communications of the ACM*, volume 16, number 11, pages 653658, November 1973. [doi:10.1145/355611.362534](http://dx.doi.org/10.1145/355611.362534)
1. Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton: “[Architecture of a Database System](http://db.cs.berkeley.edu/papers/fntdb07-architecture.pdf),” *Foundations and Trends in Databases*, volume 1, number 2, pages 141259, November 2007. [doi:10.1561/1900000002](http://dx.doi.org/10.1561/1900000002)
1. Sandeep Parikh and Kelly Stirman: “[Schema Design for Time Series Data in MongoDB](http://blog.mongodb.org/post/65517193370/schema-design-for-time-series-data-in-mongodb),” *blog.mongodb.org*, October 30, 2013.
1. Martin Fowler: “[Schemaless Data Structures](http://martinfowler.com/articles/schemaless/),” *martinfowler.com*, January 7, 2013.
1. Amr Awadallah: “[Schema-on-Read vs. Schema-on-Write](http://www.slideshare.net/awadallah/schemaonread-vs-schemaonwrite),” at *Berkeley EECS RAD Lab Retreat*, Santa Cruz, CA, May 2009.
1. Martin Odersky: “[The Trouble with Types](http://www.infoq.com/presentations/data-types-issues),” at *Strange Loop*, September 2013.
1. Conrad Irwin: “[MongoDB—Confessions of a PostgreSQL Lover](https://speakerdeck.com/conradirwin/mongodb-confessions-of-a-postgresql-lover),” at *HTML5DevConf*, October 2013.
1. “[Percona Toolkit Documentation: pt-online-schema-change](http://www.percona.com/doc/percona-toolkit/2.2/pt-online-schema-change.html),” Percona Ireland Ltd., 2013.
1. Rany Keddo, Tobias Bielohlawek, and Tobias Schmidt: “[Large Hadron Migrator](https://github.com/soundcloud/lhm),” SoundCloud, 2013.
1. Shlomi Noach: “[gh-ost: GitHub's Online Schema Migration Tool for MySQL](http://githubengineering.com/gh-ost-github-s-online-migration-tool-for-mysql/),” *githubengineering.com*, August 1, 2016.
1. James C. Corbett, Jeffrey Dean, Michael Epstein, et al.: “[Spanner: Googles Globally-Distributed Database](https://research.google/pubs/pub39966/),” at *10th USENIX Symposium on Operating System Design and Implementation* (OSDI), October 2012.
1. Donald K. Burleson: “[Reduce I/O with Oracle Cluster Tables](https://web.archive.org/web/20231207233228/http://www.dba-oracle.com/oracle_tip_hash_index_cluster_table.htm),” *dba-oracle.com*.
1. Fay Chang, Jeffrey Dean, Sanjay Ghemawat, et al.: “[Bigtable: A Distributed Storage System for Structured Data](https://research.google/pubs/pub27898/),” at *7th USENIX Symposium on Operating System Design and Implementation* (OSDI), November 2006.
1. Bobbie J. Cochrane and Kathy A. McKnight: “[DB2 JSON Capabilities, Part 1: Introduction to DB2 JSON](https://web.archive.org/web/20180516203043/https://www.ibm.com/developerworks/data/library/techarticle/dm-1306nosqlforjson1/),” IBM developerWorks, June 20, 2013.
1. Herb Sutter: “[The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software](http://www.gotw.ca/publications/concurrency-ddj.htm),” *Dr. Dobb's Journal*, volume 30, number 3, pages 202-210, March 2005.
1. Joseph M. Hellerstein: “[The Declarative Imperative: Experiences and Conjectures in Distributed Logic](http://www.eecs.berkeley.edu/Pubs/TechRpts/2010/EECS-2010-90.pdf),” Electrical Engineering and Computer Sciences, University of California at Berkeley, Tech report UCB/EECS-2010-90, June 2010.
1. Jeffrey Dean and Sanjay Ghemawat: “[MapReduce: Simplified Data Processing on Large Clusters](https://research.google/pubs/pub62/),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004.
1. Craig Kerstiens: “[JavaScript in Your Postgres](https://blog.heroku.com/javascript_in_your_postgres),” *blog.heroku.com*, June 5, 2013.
1. Nathan Bronson, Zach Amsden, George Cabrera, et al.: “[TAO: Facebooks Distributed Data Store for the Social Graph](https://www.usenix.org/conference/atc13/technical-sessions/presentation/bronson),” at *USENIX Annual Technical Conference* (USENIX ATC), June 2013.
1. “[Apache TinkerPop3.2.3 Documentation](http://tinkerpop.apache.org/docs/3.2.3/reference/),” *tinkerpop.apache.org*, October 2016.
1. “[The Neo4j Manual v2.0.0](http://docs.neo4j.org/chunked/2.0.0/index.html),” Neo Technology, 2013.
1. Emil Eifrem: [Twitter correspondence](https://twitter.com/emileifrem/status/419107961512804352), January 3, 2014.
1. David Beckett and Tim Berners-Lee: “[Turtle Terse RDF Triple Language](http://www.w3.org/TeamSubmission/turtle/),” W3C Team Submission, March 28, 2011.
1. “[Datomic Development Resources](http://docs.datomic.com/),” Metadata Partners, LLC, 2013.
1. W3C RDF Working Group: “[Resource Description Framework (RDF)](http://www.w3.org/RDF/),” *w3.org*, 10 February 2004.
1. “[Apache Jena](http://jena.apache.org/),” Apache Software Foundation.
1. Steve Harris, Andy Seaborne, and Eric Prud'hommeaux: “[SPARQL 1.1 Query Language](http://www.w3.org/TR/sparql11-query/),” W3C Recommendation, March 2013.
1. Todd J. Green, Shan Shan Huang, Boon Thau Loo, and Wenchao Zhou: “[Datalog and Recursive Query Processing](http://blogs.evergreen.edu/sosw/files/2014/04/Green-Vol5-DBS-017.pdf),” *Foundations and Trends in Databases*, volume 5, number 2, pages 105195, November 2013. [doi:10.1561/1900000017](http://dx.doi.org/10.1561/1900000017)
1. Stefano Ceri, Georg Gottlob, and Letizia Tanca: “[What You Always Wanted to Know About Datalog (And Never Dared to Ask)](https://www.researchgate.net/profile/Letizia_Tanca/publication/3296132_What_you_always_wanted_to_know_about_Datalog_and_never_dared_to_ask/links/0fcfd50ca2d20473ca000000.pdf),” *IEEE Transactions on Knowledge and Data Engineering*, volume 1, number 1, pages 146166, March 1989. [doi:10.1109/69.43410](http://dx.doi.org/10.1109/69.43410)
1. Serge Abiteboul, Richard Hull, and Victor Vianu: [*Foundations of Databases*](http://webdam.inria.fr/Alice/). Addison-Wesley, 1995. ISBN: 978-0-201-53771-0, available online at *webdam.inria.fr/Alice*
1. Nathan Marz: “[Cascalog](https://github.com/nathanmarz/cascalog)," *github.com*.
1. Dennis A. Benson, Ilene Karsch-Mizrachi, David J. Lipman, et al.: “[GenBank](https://academic.oup.com/nar/article/36/suppl_1/D25/2507746),” *Nucleic Acids Research*, volume 36, Database issue, pages D25D30, December 2007. [doi:10.1093/nar/gkm929](http://dx.doi.org/10.1093/nar/gkm929)
1. Fons Rademakers: “[ROOT for Big Data Analysis](https://indico.cern.ch/event/246453/contributions/1566610/attachments/423154/587535/ROOT-BigData-Analysis-London-2013.pdf),” at *Workshop on the Future of Big Data Management*, London, UK, June 2013.

676
content/v1/ch3.md Normal file
View File

@ -0,0 +1,676 @@
---
title: "第三章:存储与检索"
linkTitle: "3. 存储与检索"
weight: 103
breadcrumbs: false
---
![](/map/ch03.png)
> 建立秩序,省却搜索
>
> —— 德国谚语
一个数据库在最基础的层次上需要完成两件事情:当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
在 [第二章](/v1/ch2) 中,我们讨论了数据模型和查询语言,即程序员将数据录入数据库的格式,以及再次要回数据的机制。在本章中我们会从数据库的视角来讨论同样的问题:数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。
作为程序员,为什么要关心数据库内部存储与检索的机理?你可能不会去从头开始实现自己的存储引擎,但是你 **确实** 需要从许多可用的存储引擎中选择一个合适的。而且为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么。
特别需要注意,针对 **事务性** 负载优化的和针对 **分析性** 负载优化的存储引擎之间存在巨大差异。稍后我们将在 “[事务处理还是分析?](#事务处理还是分析?)” 一节中探讨这一区别,并在 “[列式存储](#列式存储)” 中讨论一系列针对分析性负载而优化的存储引擎。
但首先,我们将从你可能已经很熟悉的两大类数据库(传统的关系型数据库和很多所谓的 “NoSQL” 数据库)中使用的 **存储引擎** 来开始本章的内容。我们将研究两大类存储引擎:**日志结构log-structured** 的存储引擎,以及 **面向页面page-oriented** 的存储引擎(例如 B 树)。
## 驱动数据库的数据结构
世界上最简单的数据库可以用两个 Bash 函数实现:
```bash
#!/bin/bash
db_set () {
echo "$1,$2" >> database
}
db_get () {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
```
这两个函数实现了键值存储的功能。执行 `db_set key value` 会将 **键key****值value** 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON 文档。然后调用 `db_get key` 会查找与该键关联的最新值并将其返回。
麻雀虽小,五脏俱全:
```bash
$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'
$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'
$ db_get 42
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
```
底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与 CSV 文件类似)。每次对 `db_set` 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 `db_get` 中使用了 `tail -n 1` )。
```bash
$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}'
$ db_get 42
{"name":"San Francisco","attractions":["Exploratorium"]}
$ cat database
123456,{"name":"London","attractions":["Big Ben","London Eye"]}
42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
42,{"name":"San Francisco","attractions":["Exploratorium"]}
```
`db_set` 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与 `db_set` 做的事情类似,许多数据库在内部使用了 **日志log**,也就是一个 **仅追加append-only** 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收硬盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。
> **日志log** 这个词通常指应用日志:即应用程序输出的描述正在发生的事情的文本。本书在更普遍的意义下使用 **日志** 这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,它可以使用二进制格式,并仅能由其他程序读取。
另一方面,如果这个数据库中有着大量记录,则这个 `db_get` 函数的性能会非常糟糕。每次你想查找一个键时,`db_get` 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 `O(n)` :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。
为了高效查找数据库中特定键的值,我们需要一个数据结构:**索引index**。本章将介绍一系列的索引结构,并在它们之间进行比较。索引背后的大致思想是通过保存一些额外的元数据作为路标来帮助你找到想要的数据。如果你想以几种不同的方式搜索同一份数据,那么你也许需要在数据的不同部分上建立多个索引。
索引是从主数据衍生的 **额外的additional** 结构。许多数据库允许添加与删除索引,这不会影响数据的内容,而只会影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。
这是存储系统中一个重要的权衡精心选择的索引加快了读查询的速度但是每个索引都会拖慢写入速度。因为这个原因数据库默认并不会索引所有的内容而需要你也就是程序员或数据库管理员DBA基于对应用的典型查询模式的了解来手动选择索引。你可以选择那些能为应用带来最大收益而且又不会引入超出必要开销的索引。
### 散列索引
让我们从 **键值数据key-value Data** 的索引开始。这不是你可以索引的唯一数据类型,但键值数据是很常见的。在引入更复杂的索引之前,它是重要的第一步。
键值存储与在大多数编程语言中可以找到的 **字典dictionary** 类型非常相似,通常字典都是用 **散列映射hash map****散列表hash table** 实现的。散列映射在许多算法教科书中都有描述【1,2】所以这里我们不会讨论它的工作细节。既然我们已经可以用散列映射来表示 **内存中** 的数据结构,为什么不使用它来索引 **硬盘上** 的数据呢?
假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样,那么最简单的索引策略就是:保留一个内存中的散列映射,其中每个键都映射到数据文件中的一个字节偏移量,指明了可以找到对应值的位置,如 [图 3-1](/v1/ddia_0301.png) 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,**寻找seek** 该位置并读取该值即可。
![](/v1/ddia_0301.png)
**图 3-1 以类 CSV 格式存储键值对的日志,并使用内存散列映射进行索引。**
听上去简单但这是一个可行的方法。现实中Bitcask 实际上就是这么做的Riak 中默认的存储引擎【3】。Bitcask 提供高性能的读取和写入操作,但要求所有的键必须能放入可用内存中,因为散列映射完全保留在内存中。而数据值可以使用比可用内存更多的空间,因为可以在硬盘上通过一次硬盘查找操作来加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何硬盘 I/O。
像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如键可能是某个猫咪视频的网址URL而值可能是该视频被播放的次数每次有人点击播放按钮时递增。在这种类型的工作负载中有很多写操作但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
到目前为止,我们只是在追加写入一个文件 —— 所以如何避免最终用完硬盘空间?一种好的解决方案是,将日志分为特定大小的 **段segment**,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 **压缩compaction**,如 [图 3-2](/v1/ddia_0302.png) 所示。这里的压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
![](/v1/ddia_0302.png)
**图 3-2 键值更新日志(统计猫咪视频的播放次数)的压缩,只保留每个键的最近值**
而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 [图 3-3](/v1/ddia_0303.png) 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
![](/v1/ddia_0303.png)
**图 3-3 同时执行压缩和分段合并**
每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近的段的散列映射;如果键不存在,我们就检查第二个最近的段,依此类推。合并过程将保持段的数量足够小,所以查找过程不需要检查太多的散列映射。
要让这个简单的想法在实际中能工作会涉及到大量的细节。简单来说,下面几点都是实现过程中需要认真考虑的问题:
* 文件格式
CSV 不是日志的最佳格式。使用二进制格式更快,更简单:首先以字节为单位对字符串的长度进行编码,然后是原始的字符串(不需要转义)。
* 删除记录
如果要删除一个键及其关联的值,则必须在数据文件中追加一个特殊的删除记录(逻辑删除,有时被称为墓碑,即 tombstone。当日志段被合并时合并过程会通过这个墓碑知道要将被删除键的所有历史值都丢弃掉。
* 崩溃恢复
如果数据库重新启动则内存散列映射将丢失。原则上你可以通过从头到尾读取整个段文件并记录下来每个键的最近值来恢复每个段的散列映射。但是如果段文件很大可能需要很长时间这会使服务的重启比较痛苦。Bitcask 通过将每个段的散列映射的快照存储在硬盘上来加速恢复,可以使散列映射更快地加载到内存中。
* 部分写入记录
数据库随时可能崩溃包括在将记录追加到日志的过程中。Bitcask 文件包含校验和,允许检测和忽略日志中的这些损坏部分。
* 并发控制
由于写操作是以严格的顺序追加到日志中的,所以常见的实现是只有一个写入线程。也因为数据文件段是仅追加的或者说是不可变的,所以它们可以被多个线程同时读取。
乍一看,仅追加日志似乎很浪费:为什么不直接在文件里更新,用新值覆盖旧值?仅追加的设计之所以是个好的设计,有如下几个原因:
* 追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 **固态硬盘SSD** 上也是好的选择【4】。我们将在“[比较 B 树和 LSM 树](#比较b树和lsm树)”中进一步讨论这个问题。
* 如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
* 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。
但是,散列表索引也有其局限性:
* 散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O而后者耗尽时想要再扩充是很昂贵的并且需要很烦琐的逻辑去解决散列冲突【5】。
* 范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
在下一节中,我们将看到一个没有这些限制的索引结构。
### SSTables和LSM树
在 [图 3-3](/v1/ddia_0303.png) 中,每个日志结构存储段都是一系列键值对。这些键值对按照它们写入的顺序排列,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。
现在我们可以对段文件的格式做一个简单的改变:要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,我们将稍后再回到这个问题。
我们把这个格式称为 **排序字符串表Sorted String Table**,简称 SSTable。我们还要求每个键只在每个合并的段文件中出现一次压缩过程已经保证。与使用散列索引的日志段相比SSTable 有几个大的优势:
1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。这种方法就像归并排序算法中使用的方法一样,如 [图 3-4](/v1/ddia_0304.png) 所示:你开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。
![](/v1/ddia_0304.png)
**图 3-4 合并几个 SSTable 段,只保留每个键的最新值**
如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值一定比另一个段中的所有值都更近(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。
2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。以 [图 3-5](/v1/ddia_0305.png) 为例:假设你正在内存中寻找键 `handiwork`,但是你不知道这个键在段文件中的确切偏移量。然而,你知道 `handbag``handsome` 的偏移,而且由于排序特性,你知道 `handiwork` 必须出现在这两者之间。这意味着你可以跳到 `handbag` 的偏移位置并从那里扫描,直到你找到 `handiwork`(或没找到,如果该文件中没有该键)。
![](/v1/ddia_0305.png)
**图 3-5 具有内存索引的 SSTable**
你仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完 [^i]。
[^i]: 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中的键和值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束以及后一条记录开始的地方)。
3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对因此可以将这些记录分组为块block并在将其写入硬盘之前对其进行压缩如 [图 3-5](/v1/ddia_0305.png) 中的阴影区域所示)[^译注i] 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对 I/O 带宽的使用。
[^译注i]: 这里的压缩是 compression不是前文的 compaction请注意区分。
#### 构建和维护SSTables
到目前为止还不错,但是如何让你的数据能够预先排好序呢?毕竟我们接收到的写入请求可能以任何顺序发生。
虽然在硬盘上维护有序结构也是可能的(请参阅 “[B 树](#B树)”),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或 AVL 树【2】。使用这些数据结构你可以按任何顺序插入键并按排序顺序读取它们。
现在我们可以让我们的存储引擎以如下方式工作:
* 有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为 **内存表memtable**
* 当 **内存表** 大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入硬盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的 SSTable 文件将成为数据库中最新的段。当该 SSTable 被写入硬盘时,新的写入可以在一个新的内存表实例上继续进行。
* 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的硬盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。
* 时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。
这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入硬盘)将丢失。为了避免这个问题,我们可以在硬盘上保存一个单独的日志,每个写入都会立即被追加到这个日志上,就像在前面的章节中所描述的那样。这个日志没有按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到 SSTable 时,相应的日志都可以被丢弃。
#### 用SSTables制作LSM树
这里描述的算法本质上是 LevelDB【6】和 RocksDB【7】这些键值存储引擎库所使用的技术这些存储引擎被设计嵌入到其他应用程序中。除此之外LevelDB 可以在 Riak 中用作 Bitcask 的替代品。在 Cassandra 和 HBase 中也使用了类似的存储引擎【8】而且他们都受到了 Google 的 Bigtable 论文【9】引入了术语 SSTable 和 memtable )的启发。
这种索引结构最早由 Patrick O'Neil 等人发明,且被命名为日志结构合并树(或 LSM 树【10】它是基于更早之前的日志结构文件系统【11】来构建的。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。
Lucene是一种全文搜索的索引引擎在 Elasticsearch 和 Solr 被使用它使用类似的方法来存储它的关键词词典【12,13】。全文索引比键值索引复杂得多但是基于类似的想法在搜索查询中由一个给定的单词找到提及单词的所有文档网页、产品描述等。这也是通过键值结构实现的其中键是 **单词term**,值是所有包含该单词的文档的 ID 列表(**postings list**)。在 Lucene 中,从词语到记录列表的这种映射保存在类似于 SSTable 的有序文件中并根据需要在后台执行合并【14】。
#### 性能优化
与往常一样要让存储引擎在实践中表现良好涉及到大量设计细节。例如当查找数据库中不存在的键时LSM 树算法可能会很慢你必须先检查内存表然后查看从最近的到最旧的所有的段可能还必须从硬盘读取每一个段文件然后才能确定这个键不存在。为了优化这种访问存储引擎通常使用额外的布隆过滤器Bloom filters【15】。布隆过滤器是一种节省内存的数据结构用于近似表达集合的内容它可以告诉你数据库中是否存在某个键从而为不存在的键节省掉许多不必要的硬盘读取操作。
还有一些不同的策略来确定 SSTables 被压缩和合并的顺序和时间。最常见的选择是 size-tiered 和 leveled compaction。LevelDB 和 RocksDB 使用 leveled compactionLevelDB 因此得名HBase 使用 size-tieredCassandra 同时支持这两种【16】。对于 sized-tiered较新和较小的 SSTables 相继被合并到较旧的和较大的 SSTable 中。对于 leveled compactionkey (按照分布范围)被拆分到较小的 SSTables而较旧的数据被移动到单独的层级level这使得压缩compaction能够更加增量地进行并且使用较少的硬盘空间。
即使有许多微妙的东西LSM 树的基本思想 —— 保存一系列在后台合并的 SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,你可以高效地执行范围查询(扫描所有从某个最小值到某个最大值之间的所有键),并且因为硬盘写入是连续的,所以 LSM 树可以支持非常高的写入吞吐量。
### B树
前面讨论的日志结构索引看起来已经相当可用了,但它们却不是最常见的索引类型。使用最广泛的索引结构和日志结构索引相当不同,它就是我们接下来要讨论的 B 树。
从 1970 年被引入【17】仅不到 10 年后就变得 “无处不在”【18】B 树很好地经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也会使用到 B 树。
像 SSTables 一样B 树保持按键排序的键值对这允许高效的键值查找和范围查询。但这也就是仅有的相似之处了B 树有着非常不同的设计理念。
我们前面看到的日志结构索引将数据库分解为可变大小的段通常是几兆字节或更大的大小并且总是按顺序写入段。相比之下B 树将数据库分解成固定大小的 **块block****分页page**,传统上大小为 4KB有时会更大并且一次只能读取或写入一个页面。这种设计更接近于底层硬件因为硬盘空间也是按固定大小的块来组织的。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 [图 3-6](/v1/ddia_0306.png) 所示。
![](/v1/ddia_0306.png)
**图 3-6 使用 B 树索引查找一个键**
一个页面会被指定为 B 树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,根页面上每两个引用之间的键,表示相邻子页面管理的键的范围(边界)。
在 [图 3-6](/v1/ddia_0306.png) 的例子中,我们正在寻找键 251 ,所以我们知道我们需要跟踪边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步将 200 到 300 的范围拆分到子范围。
最终我们将到达某个包含单个键的页面叶子页面leaf page该页面或者直接包含每个键的值或者包含了对可以找到值的页面的引用。
在 B 树的一个页面中对子页面的引用的数量称为 **分支因子branching factor**。例如,在 [图 3-6](/v1/ddia_0306.png) 中,分支因子是 6。在实践中分支因子的大小取决于存储页面引用和范围边界所需的空间但这个值通常是几百。
如果要更新 B 树中现有键的值,需要搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。如果你想添加一个新的键,你需要找到其范围能包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以反映新的键范围分区,如 [图 3-7](/v1/ddia_0307.png) 所示 [^ii]。
![](/v1/ddia_0307.png)
**图 3-7 通过分割页面来生长 B 树**
[^ii]: 向 B 树中插入一个新的键是相当符合直觉的但删除一个键同时保持树平衡就会牵扯很多其他东西了【2】。
这个算法可以确保树保持平衡:具有 n 个键的 B 树总是具有 $O (log n)$ 的深度。大多数数据库可以放入一个三到四层的 B 树,所以你不需要追踪多个页面引用来找到你正在查找的页面(分支因子为 500 的 4KB 页面的四层树可以存储多达 256TB 的数据)。
#### 让B树更可靠
B 树的基本底层写操作是用新数据覆写硬盘上的页面,并假定覆写不改变页面的位置:即,当页面被覆写时,对该页面的所有引用保持完整。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只追加到文件(并最终删除过时的文件),但从不修改文件中已有的内容。
你可以把覆写硬盘上的页面对应为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆写适当的扇区。在固态硬盘上,由于 SSD 必须一次擦除和重写相当大的存储芯片块所以会发生更复杂的事情【19】。
而且,一些操作需要覆写几个不同的页面。例如,如果因为插入导致页面过满而拆分页面,则需要写入新拆分的两个页面,并覆写其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在系列操作进行到一半时崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面没有被任何页面引用) 。
为了使数据库能处理异常崩溃的场景B 树实现通常会带有一个额外的硬盘数据结构:**预写式日志**WAL即 write-ahead log也称为 **重做日志**,即 redo log。这是一个仅追加的文件每个 B 树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使 B 树恢复到一致的状态【5,20】。
另外还有一个更新页面的复杂情况是,如果多个线程要同时访问 B 树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常是通过使用 **锁存器**latches轻量级锁保护树的数据结构来完成。日志结构化的方法在这方面更简单因为它们在后台进行所有的合并而不会干扰新接收到的查询并且能够时不时地将段文件切换为新的该切换是原子操作
#### B树的优化
由于 B 树已经存在了很久,所以并不奇怪这么多年下来有很多优化的设计被开发出来,仅举几例:
* 不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB使用写时复制方案【21】。经过修改的页面被写入到不同的位置并且还在树中创建了父页面的新版本以指向新的位置。这种方法对于并发控制也很有用我们将在 “[快照隔离和可重复读](/v1/ch7#快照隔离和可重复读)” 中看到。
* 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 [^iii]。
* 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每个页面的读取都需要执行一次硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次性重写一大段存储,所以它们更容易使顺序键在硬盘上连续存储。
* 额外的指针被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
* B 树的变体如 **分形树fractal trees**【22】借用了一些日志结构的思想来减少硬盘查找而且它们与分形无关
[^iii]: 这个变种有时被称为 B+ 树,但因为这个优化已被广泛使用,所以经常无法区分于其它的 B 树变种。
### 比较B树和LSM树
尽管 B 树实现通常比 LSM 树实现更成熟LSM 树由于其性能特征的关系,仍然引起了不少关注。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快【23】。LSM 树上的读取通常比较慢因为它们必须检查几种不同的数据结构和不同压缩Compaction层级的 SSTables。
然而,基准测试的结果通常和工作负载的细节相关。你需要用你特有的工作负载来测试系统,以便进行有效的比较。在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。
#### LSM树的优点
B 树索引中的每块数据都必须至少写入两次一次写入预先写入日志WAL一次写入树页面本身如果有分页还需要再写入一次。即使在该页面中只有几个字节发生了变化也需要接受写入整个页面的开销。有些存储引擎甚至会覆写同一个页面两次以免在电源故障的情况下页面未完整更新【24,25】。
由于反复压缩和合并 SSTables日志结构索引也会多次重写数据。这种影响 —— 在数据库的生命周期中每笔数据导致对硬盘的多次写入 —— 被称为 **写入放大write amplification**。使用固态硬盘的机器需要额外关注这点,固态硬盘的闪存寿命在覆写有限次数后就会耗尽。
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入硬盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入硬盘的次数越多,可用硬盘带宽内它能处理的每秒写入次数就越少。
进而LSM 树通常能够比 B 树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆写树中的几个页面【26】。这种差异在机械硬盘上尤其重要其顺序写入比随机写入要快得多。
LSM 树可以被压缩得更好,因此通常能比 B 树在硬盘上产生更小的文件。B 树存储引擎会由于碎片化fragmentation而留下一些未使用的硬盘空间当页面被拆分或某行不能放入现有页面时页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的,并且会通过定期重写 SSTables 以去除碎片所以它们具有较低的存储开销特别是当使用分层压缩leveled compaction时【27】。
在许多固态硬盘上固件内部使用了日志结构化算法以将随机写入转变为顺序写入底层存储芯片因此存储引擎写入模式的影响不太明显【19】。但是较低的写入放大率和减少的碎片仍然对固态硬盘更有利更紧凑地表示数据允许在可用的 I/O 带宽内处理更多的读取和写入请求。
#### LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,所以很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎在更高百分位的响应时间(请参阅 “[描述性能](/v1/ch1#描述性能)”)有时会相当长,而 B 树的行为则相对更具有可预测性【28】。
压缩的另一个问题出现在高写入吞吐量时:硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。
如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。通常情况下,即使压缩无法跟上,基于 SSTable 的存储引擎也不会限制传入写入的速率所以你需要进行明确的监控来检测这种情况【29,30】。
B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中这些锁可以直接附加到树上【5】。在 [第七章](/v1/ch7) 中,我们将更详细地讨论这一点。
B 树在数据库架构中是非常根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能在短期内消失。在新的数据库中,日志结构化索引变得越来越流行。没有简单易行的办法来判断哪种类型的存储引擎对你的使用场景更好,所以需要通过一些测试来得到相关经验。
### 其他索引结构
到目前为止,我们只讨论了键值索引,它们就像关系模型中的 **主键primary key** 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID引用该行 / 文档 / 顶点,索引就被用于解析这样的引用。
次级索引secondary indexes也很常见。在关系数据库中你可以使用 `CREATE INDEX` 命令在同一个表上创建多个次级索引而且这些索引通常对于有效地执行联接join而言至关重要。例如在 [第二章](/v1/ch2) 中的 [图 2-1](/v1/ddia_0201.png) 中,很可能在 `user_id` 列上有一个次级索引,以便你可以在每个表中找到属于同一用户的所有行。
次级索引可以很容易地从键值索引构建。次级索引主要的不同是键不是唯一的即可能有许多行文档顶点具有相同的键。这可以通过两种方式来解决将匹配行标识符的列表作为索引里的值就像全文索引中的记录列表或者通过向每个键添加行标识符来使键唯一。无论哪种方式B 树和日志结构索引都可以用作次级索引。
#### 将值存储在索引中
索引中的键是查询要搜索的内容,而其值可以是以下两种情况之一:它可以是实际的行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为 **堆文件heap file**,并且存储的数据没有特定的顺序(它可以是仅追加的,或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖)。堆文件方法很常见,因为它避免了在存在多个次级索引时对数据的复制:每个索引只引用堆文件中的一个位置,实际的数据都保存在一个地方。
在不更改键的情况下更新值时堆文件方法可以非常高效只要新值的字节数不大于旧值就可以覆盖该记录。如果新值更大情况会更复杂因为它可能需要移到堆中有足够空间的新位置。在这种情况下要么所有的索引都需要更新以指向记录的新堆位置或者在旧堆位置留下一个转发指针【5】。
在某些情况下从索引到堆文件的额外跳跃对读取来说性能损失太大因此可能希望将被索引的行直接存储在索引中。这被称为聚集索引clustered index。例如在 MySQL 的 InnoDB 存储引擎中表的主键总是一个聚集索引次级索引则引用主键而不是堆文件中的位置【31】。在 SQL Server 中可以为每个表指定一个聚集索引【32】。
**聚集索引**(在索引中存储所有的行数据)和 **非聚集索引**(仅在索引中存储对数据的引用)之间的折衷被称为 **覆盖索引covering index****包含列的索引index with included columns**其在索引内存储表的一部分列【33】。这允许通过单独使用索引来处理一些查询这种情况下可以说索引 **覆盖cover** 了查询【32】。
与任何类型的数据重复一样,聚集索引和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应看到任何因为使用副本而导致的不一致。
#### 多列索引
至今讨论的索引只是将一个键映射到一个值。如果我们需要同时查询一个表中的多个列(或文档中的多个字段),这显然是不够的。
最常见的多列索引被称为 **连接索引concatenated index** ,它通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。这就像一个老式的纸质电话簿,它提供了一个从(姓氏,名字)到电话号码的索引。由于排序顺序,索引可以用来查找所有具有特定姓氏的人,或所有具有特定姓氏 - 名字组合的人。但如果你想找到所有具有特定名字的人,这个索引是没有用的。
**多维索引multi-dimensional index** 是一种查询多个列的更一般的方法,这对于地理空间数据尤为重要。例如,餐厅搜索网站可能有一个数据库,其中包含每个餐厅的经度和纬度。当用户在地图上查看餐馆时,网站需要搜索用户正在查看的矩形地图区域内的所有餐馆。这需要一个二维范围查询,如下所示:
```sql
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
AND longitude > -0.1162 AND longitude < -0.1004;
```
一个标准的 B 树或者 LSM 树索引不能够高效地处理这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足两个条件。
一种选择是使用 **空间填充曲线space-filling curve** 将二维位置转换为单个数字,然后使用常规 B 树索引【34】。更普遍的是使用特殊化的空间索引例如 R 树。例如PostGIS 使用 PostgreSQL 的通用 GiST 工具【35】将地理空间索引实现为 R 树。这里我们没有足够的地方来描述 R 树,但是有大量的文献可供参考。
有趣的是,多维索引不仅可以用于地理位置。例如,在电子商务网站上可以使用建立在(红,绿,蓝)维度上的三维索引来搜索特定颜色范围内的产品,也可以在天气观测数据库中建立(日期,温度)的二维索引,以便有效地搜索 2013 年内的温度在 25 至 30°C 之间的所有观测资料。如果使用一维索引,你将不得不扫描 2013 年的所有记录(不管温度如何),然后通过温度进行过滤,或者反之亦然。二维索引可以同时通过时间戳和温度来收窄数据集。这个技术被 HyperDex 所使用【36】。
#### 全文搜索和模糊索引
到目前为止所讨论的所有索引都假定你有确切的数据,并允许你查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索**类似**的键,如拼写错误的单词。这种模糊的查询需要不同的技术。
例如全文搜索引擎通常允许搜索目标从一个单词扩展为包括该单词的同义词忽略单词的语法变体搜索在相同文档中的近义词并且支持各种其他取决于文本的语言分析功能。为了处理文档或查询中的拼写错误Lucene 能够在一定的编辑距离内搜索文本【37】编辑距离 1 意味着单词内发生了 1 个字母的添加、删除或替换)。
正如 “[用 SSTables 制作 LSM 树](#用SSTables制作LSM树)” 中所提到的Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询需要在排序文件中哪个偏移量查找键。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 【38】。这个自动机可以转换成 Levenshtein 自动机它支持在给定的编辑距离内有效地搜索单词【39】。
其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。更多详细信息请参阅信息检索教科书例如【40】。
#### 在内存中存储一切
本章到目前为止讨论的数据结构都是对硬盘限制的应对。与主内存相比,硬盘处理起来很麻烦。对于磁性硬盘和固态硬盘,如果要在读取和写入时获得良好性能,则需要仔细地布置硬盘上的数据。但是,我们能容忍这种麻烦,因为硬盘有两个显著的优点:它们是持久的(它们的内容在电源关闭时不会丢失),并且每 GB 的成本比 RAM 低。
随着 RAM 变得更便宜,每 GB 成本的论据被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,包括可能分布在多个机器上。这导致了内存数据库的发展。
某些内存中的键值存储(如 Memcached仅用于缓存在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性可以通过特殊的硬件例如电池供电的 RAM来实现也可以将更改日志写入硬盘还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。
内存数据库重新启动时,需要从硬盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入硬盘,它仍然是一个内存数据库,因为硬盘仅出于持久性目的进行日志追加,读取请求完全由内存来处理。写入硬盘同时还有运维上的好处:硬盘上的文件可以很容易地由外部程序进行备份、检查和分析。
诸如 VoltDB、MemSQL 和 Oracle TimesTen 等产品是具有关系模型的内存数据库供应商声称通过消除与管理硬盘上的数据结构相关的所有开销他们可以提供巨大的性能改进【41,42】。RAM Cloud 是一个开源的内存键值存储器具有持久性对内存和硬盘上的数据都使用日志结构化方法【43】。Redis 和 Couchbase 通过异步写入硬盘提供了较弱的持久性。
反直觉的是内存数据库的性能优势并不是因为它们不需要从硬盘读取的事实。只要有足够的内存即使是基于硬盘的存储引擎也可能永远不需要从硬盘读取因为操作系统在内存中缓存了最近使用的硬盘块。相反它们更快的原因在于省去了将内存数据结构编码为硬盘数据结构的开销【44】。
除了性能内存数据库的另一个有趣的地方是提供了难以用基于硬盘的索引实现的数据模型。例如Redis 为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
最近的研究表明内存数据库体系结构可以扩展到支持比可用内存更大的数据集而不必重新采用以硬盘为中心的体系结构【45】。所谓的 **反缓存anti-caching** 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到硬盘,并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以比操作系统更有效地管理内存,因为它可以按单个记录的粒度工作,而不是整个内存页面。尽管如此,这种方法仍然需要索引能完全放入内存中(就像本章开头的 Bitcask 例子)。
如果 **非易失性存储器non-volatile memory, NVM** 技术得到更广泛的应用可能还需要进一步改变存储引擎设计【46】。目前这是一个新的研究领域值得关注。
## 事务处理还是分析?
在早期的业务数据处理过程中,一次典型的数据库写入通常与一笔 *商业交易commercial transaction* 相对应:卖个货、向供应商下订单、支付员工工资等等。但随着数据库开始应用到那些不涉及到钱的领域,术语 **交易 / 事务transaction** 仍留了下来,用于指代一组读写操作构成的逻辑单元。
> 事务不一定具有 ACID原子性一致性隔离性和持久性属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。我们在 [第七章](/v1/ch7) 中讨论 ACID 属性,在 [第十章](/v1/ch10) 中讨论批处理。
即使数据库开始被用于许多不同类型的数据,比如博客文章的评论、游戏中的动作、地址簿中的联系人等等,基本的访问模式仍然类似于处理商业交易。应用程序通常使用索引通过某个键找少量记录。根据用户的输入来插入或更新记录。由于这些应用程序是交互式的,这种访问模式被称为 **在线事务处理OLTP, OnLine Transaction Processing**
但是,数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取几列,并计算汇总统计信息(如计数、总和或平均值),而不是将原始数据返回给用户。例如,如果你的数据是一个销售交易表,那么分析查询可能是:
* 一月份每个商店的总收入是多少?
* 在最近的推广活动中多卖了多少香蕉?
* 哪个牌子的婴儿食品最常与 X 品牌的尿布同时购买?
这些查询通常由业务分析师编写,并提供报告以帮助公司管理层做出更好的决策(商业智能)。为了将这种使用数据库的模式和事务处理区分开,它被称为 **在线分析处理OLAP, OnLine Analytic Processing**【47】[^iv]。OLTP 和 OLAP 之间的区别并不总是清晰的,但是一些典型的特征在 [表 3-1]() 中列出。
**表 3-1 比较事务处理和分析系统的特点**
| 属性 | 事务处理系统 OLTP | 分析系统 OLAP |
| :----------: | :--------------------------: | :----------------------: |
| 主要读取模式 | 查询少量记录,按键读取 | 在大批量记录上聚合 |
| 主要写入模式 | 随机访问,写入要求低延时 | 批量导入ETL或者事件流 |
| 主要用户 | 终端用户,通过 Web 应用 | 内部数据分析师,用于决策支持 |
| 处理的数据 | 数据的最新状态(当前时间点) | 随时间推移的历史事件 |
| 数据集尺寸 | GB ~ TB | TB ~ PB |
[^iv]: OLAP 中的首字母 Oonline的含义并不明确它可能是指查询并不是用来生成预定义好的报告的事实也可能是指分析师通常是交互式地使用 OLAP 系统来进行探索式的查询。
起初事务处理和分析查询使用了相同的数据库。SQL 在这方面已证明是非常灵活的:对于 OLTP 类型的查询以及 OLAP 类型的查询来说效果都很好。尽管如此,在二十世纪八十年代末和九十年代初期,企业有停止使用 OLTP 系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为 **数据仓库data warehouse**
### 数据仓库
一个企业可能有几十个不同的交易处理系统:面向终端客户的网站、控制实体商店的收银系统、仓库库存跟踪、车辆路线规划、供应链管理、员工管理等。这些系统中每一个都很复杂,需要专人维护,所以最终这些系统互相之间都是独立运行的。
这些 OLTP 系统往往对业务运作至关重要,因而通常会要求 **高可用****低延迟**。所以 DBA 会密切关注他们的 OLTP 数据库,他们通常不愿意让业务分析人员在 OLTP 数据库上运行临时的分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时在执行的事务的性能。
相比之下,数据仓库是一个独立的数据库,分析人员可以查询他们想要的内容而不影响 OLTP 操作【48】。数据仓库包含公司各种 OLTP 系统中所有的只读数据副本。从 OLTP 数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为 “**抽取 - 转换 - 加载ETL**”,如 [图 3-8](/v1/ddia_0308.png) 所示。
![](/v1/ddia_0308.png)
**图 3-8 ETL 至数据仓库的简化提纲**
几乎所有的大型企业都有数据仓库,但在小型企业中几乎闻所未闻。这可能是因为大多数小公司没有这么多不同的 OLTP 系统,大多数小公司只有少量的数据 —— 可以在传统的 SQL 数据库中查询,甚至可以在电子表格中分析。在一家大公司里,要做一些在一家小公司很简单的事情,需要很多繁重的工作。
使用单独的数据仓库,而不是直接查询 OLTP 系统进行分析的一大优势是数据仓库可针对分析类的访问模式进行优化。事实证明,本章前半部分讨论的索引算法对于 OLTP 来说工作得很好,但对于处理分析查询并不是很好。在本章的其余部分中,我们将研究为分析而优化的存储引擎。
#### OLTP数据库和数据仓库之间的分歧
数据仓库的数据模型通常是关系型的,因为 SQL 通常很适合分析查询。有许多图形数据分析工具可以生成 SQL 查询,可视化结果,并允许分析人员探索数据(通过下钻、切片和切块等操作)。
表面上,一个数据仓库和一个关系型 OLTP 数据库看起来很相似,因为它们都有一个 SQL 查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。现在许多数据库供应商都只是重点支持事务处理负载和分析工作负载这两者中的一个,而不是都支持。
一些数据库(例如 Microsoft SQL Server 和 SAP HANA支持在同一产品中进行事务处理和数据仓库。但是它们也正日益发展为两套独立的存储和查询引擎只是这些引擎正好可以通过一个通用的 SQL 接口访问【49,50,51】。
Teradata、Vertica、SAP HANA 和 ParAccel 等数据仓库供应商通常使用昂贵的商业许可证销售他们的系统。Amazon RedShift 是 ParAccel 的托管版本。最近,大量的开源 SQL-on-Hadoop 项目已经出现,它们还很年轻,但是正在与商业数据仓库系统竞争,包括 Apache Hive、Spark SQL、Cloudera Impala、Facebook Presto、Apache Tajo 和 Apache Drill【52,53】。其中一些基于了谷歌 Dremel 的想法【54】。
### 星型和雪花型:分析的模式
正如 [第二章](/v1/ch2) 所探讨的根据应用程序的需要在事务处理领域中使用了大量不同的数据模型。另一方面在分析型业务中数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用被称为星型模式也称为维度建模【55】
[图 3-9](/v1/ddia_0309.png) 中的示例模式显示了可能在食品零售商处找到的数据仓库。在模式的中心是一个所谓的事实表(在这个例子中,它被称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览或点击。
![](/v1/ddia_0309.png)
**图 3-9 用于数据仓库的星型模式的示例**
通常情况下,事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。像苹果、沃尔玛或 eBay 这样的大企业在其数据仓库中可能有几十 PB 的交易历史其中大部分保存在事实表中【56】。
事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(可以用来计算利润率)。事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。
例如,在 [图 3-9](/v1/ddia_0309.png) 中,其中一个维度是已售出的产品。`dim_product` 表中的每一行代表一种待售产品包括库存单位SKU、产品描述、品牌名称、类别、脂肪含量、包装尺寸等。`fact_sales` 表中的每一行都使用外键表明在特定交易中销售了什么产品。(简单起见,如果客户一次购买了几种不同的产品,则它们在事实表中被表示为单独的行)。
甚至日期和时间也通常使用维度表来表示,因为这允许对日期的附加信息(诸如公共假期)进行编码,从而允许区分假期和非假期的销售查询。
“星型模式” 这个名字来源于这样一个事实,即当我们对表之间的关系进行可视化时,事实表在中间,被维度表包围;与这些表的连接就像星星的光芒。
这个模板的变体被称为雪花模式,其中维度被进一步分解为子维度。例如,品牌和产品类别可能有单独的表格,并且 `dim_product` 表格中的每一行都可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表格中。雪花模式比星形模式更规范化但是星形模式通常是首选因为分析师使用它更简单【55】。
在典型的数据仓库中,表格通常非常宽:事实表通常有 100 列以上有时甚至有数百列【51】。维度表也可以是非常宽的因为它们包括了所有可能与分析相关的元数据 —— 例如,`dim_store` 表可以包括在每个商店提供哪些服务的细节、它是否具有店内面包房、店面面积、商店第一次开张的日期、最近一次改造的时间、离最近的高速公路的距离等等。
## 列式存储
如果事实表中有万亿行和数 PB 的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。维度表通常要小得多(数百万行),所以在本节中我们将主要关注事实表的存储。
尽管事实表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列( “`SELECT *`” 查询很少用于分析【51】。以 [例 3-1]() 中的查询为例:它访问了大量的行(在 2013 年中所有购买了水果或糖果的记录),但只需访问 `fact_sales` 表的三列:`date_key, product_sk, quantity`。该查询忽略了所有其他的列。
**例 3-1 分析人们是否更倾向于在一周的某一天购买新鲜水果或糖果**
```sql
SELECT
dim_date.weekday,
dim_product.category,
SUM(fact_sales.quantity) AS quantity_sold
FROM fact_sales
JOIN dim_date ON fact_sales.date_key = dim_date.date_key
JOIN dim_product ON fact_sales.product_sk = dim_product.product_sk
WHERE
dim_date.year = 2013 AND
dim_product.category IN ('Fresh fruit', 'Candy')
GROUP BY
dim_date.weekday, dim_product.category;
```
我们如何有效地执行这个查询?
在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。你可以在 [图 3-1](/v1/ddia_0301.png) 的 CSV 例子中看到这个。
为了处理像 [例 3-1]() 这样的查询,你可能在 `fact_sales.date_key`、`fact_sales.product_sk` 上有索引,它们告诉存储引擎在哪里查找特定日期或特定产品的所有销售情况。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过 100 个属性)从硬盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。这可能需要很长时间。
列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。这个原理如 [图 3-10](/v1/ddia_0310.png) 所示。
![](/v1/ddia_0310.png)
**图 3-10 按列存储关系型数据,而不是行**
> 列式存储在关系数据模型中是最容易理解的但它同样适用于非关系数据。例如Parquet【57】是一种列式存储格式支持基于 Google 的 Dremel 的文档数据模型【54】。
列式存储布局依赖于每个列文件包含相同顺序的行。因此,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第 23 项,并将它们放在一起形成表的第 23 行。
### 列压缩
除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩。
看看 [图 3-10](/v1/ddia_0310.png) 中每一列的值序列:它们通常看起来是相当重复的,这是压缩的好兆头。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如 [图 3-11](/v1/ddia_0311.png) 所示。
![](/v1/ddia_0311.png)
**图 3-11 压缩的位图索引存储布局**
通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1否则为 0。
如果 n 非常小(例如,国家 / 地区列可能有大约 200 个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果 n 更大大部分位图中将会有很多的零我们说它们是稀疏的。在这种情况下位图可以另外再进行游程编码run-length encoding一种无损数据压缩技术如 [图 3-11](fig3-11.png) 底部所示。这可以使列的编码非常紧凑。
这些位图索引非常适合数据仓库中常见的各种查询。例如:
```sql
WHERE product_sk IN306869
```
加载 `product_sk = 30`、`product_sk = 68` 和 `product_sk = 69` 这三个位图并计算三个位图的按位或OR这可以非常有效地完成。
```sql
WHERE product_sk = 31 AND store_sk = 3
```
加载 `product_sk = 31``store_sk = 3` 的位图并计算按位与AND。这是因为列按照相同的顺序包含行因此一列的位图中的第 k 位和另一列的位图中的第 k 位对应相同的行。
对于不同种类的数据也有各种不同的压缩方案但我们不会详细讨论它们请参阅【58】的概述。
> #### 列式存储和列族
>
> Cassandra 和 HBase 有一个列族column families的概念他们从 Bigtable 继承【9】。然而把它们称为列式column-oriented是非常具有误导性的在每个列族中它们将一行中的所有列与行键一起存储并且不使用列压缩。因此Bigtable 模型仍然主要是面向行的。
>
#### 内存带宽和矢量化处理
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析型数据库的开发人员还需要有效地利用内存到 CPU 缓存的带宽,避免 CPU 指令处理流水线中的分支预测错误和闲置等待,以及在现代 CPU 上使用单指令多数据SIMD指令来加速运算【59,60】。
除了减少需要从硬盘加载的数据量以外,列式存储布局也可以有效利用 CPU 周期。例如,查询引擎可以将一整块压缩好的列数据放进 CPU 的 L1 缓存中然后在紧密的循环即没有函数调用中遍历。相比于每条记录的处理都需要大量函数调用和条件判断的代码CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被同时放进容量有限的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理vectorized processing【58,49】。
### 列式存储中的排序顺序
在列式存储中,存储行的顺序并不关键。按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。但是,我们也可以选择按某种顺序来排列数据,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。
注意,对每列分别执行排序是没有意义的,因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下,才能重建出完整的行。
相反,数据的排序需要对一整行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 `date_key` 作为第一个排序键。这样查询优化器就可以只扫描近1个月范围的行了这比扫描所有行要快得多。
对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 `date_key` 是 [图 3-10](/v1/ddia_0310.png) 中的第一个排序关键字,那么 `product_sk` 可能是第二个排序关键字,以便同一天的同一产品的所有销售数据都被存储在相邻位置。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。
按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。一个简单的游程编码(就像我们用于 [图 3-11](/v1/ddia_0311.png) 中的位图一样)可以将该列压缩到几 KB —— 即使表中有数十亿行。
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以几乎随机的顺序出现,所以可能不会被压缩。但对前几列做排序在整体上仍然是有好处的。
#### 几个不同的排序顺序
对这个想法,有一个巧妙的扩展被 C-Store 发现,并在商业数据仓库 Vertica 中被采用【61,62】既然不同的查询受益于不同的排序顺序为什么不以几种不同的方式来存储相同的数据呢反正数据都需要做备份以防单点故障时丢失数据。因此你可以用不同排序方式来存储冗余数据以便在处理查询时调用最适合查询模式的版本。
在一个列式存储中有多个排序顺序有点类似于在一个面向行的存储中有多个次级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚集索引中),次级索引只包含指向匹配行的指针。在列式存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
### 写入列式存储
这些优化在数据仓库中是有意义的,因为其负载主要由分析人员运行的大型只读查询组成。列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。
使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须对所有列进行一致地更新。
幸运的是本章前面已经看到了一个很好的解决方案LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是 Vertica 所做的【62】。
查询操作需要检查硬盘上的列数据和内存中的最近写入,并将两者的结果合并起来。但是,查询优化器对用户隐藏了这个细节。从分析师的角度来看,通过插入、更新或删除操作进行修改的数据会立即反映在后续的查询中。
### 聚合:数据立方体和物化视图
并非所有数据仓库都需要采用列式存储传统的面向行的数据库和其他一些架构也被使用。然而列式存储可以显著加快专门的分析查询所以它正在迅速变得流行起来【51,63】。
数据仓库的另一个值得一提的方面是物化聚合materialized aggregates。如前所述数据仓库查询通常涉及一个聚合函数如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用那么每次都通过原始数据来处理可能太浪费了。为什么不将一些查询使用最频繁的计数或总和缓存起来
创建这种缓存的一种方式是物化视图Materialized View。在关系数据模型中它通常被定义为一个标准虚拟视图一个类似于表的对象其内容是一些查询的结果。不同的是物化视图是查询结果的实际副本会被写入硬盘而虚拟视图只是编写查询的一个捷径。从虚拟视图读取时SQL 引擎会将其展开到视图的底层查询中,然后再处理展开的查询。
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于使用场景)。
物化视图的常见特例称为数据立方体或 OLAP 立方【64】。它是按不同维度分组的聚合网格。[图 3-12](/v1/ddia_0312.png) 显示了一个例子。
![](/v1/ddia_0312.png)
**图 3-12 数据立方的两个维度,通过求和聚合**
想象一下,现在每个事实都只有两个维度表的外键 —— 在 [图 3-12](/v1/ddia_0312.png) 中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 `net_price`)的聚合(例如 `SUM`)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。
一般来说,事实往往有两个以上的维度。在图 3-9 中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。
物化数据立方体的优点是可以让某些查询变得非常快,因为它们已经被有效地预先计算了。例如,如果你想知道每个商店的总销售额,则只需查看合适维度的总计,而无需扫描数百万行的原始数据。
数据立方体的缺点是不具有查询原始数据的灵活性。例如,没有办法计算有多少比例的销售来自成本超过 100 美元的项目,因为价格不是其中的一个维度。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升手段。
## 本章小结
在本章中,我们试图深入了解数据库是如何处理存储和检索的。将数据存储在数据库中会发生什么?稍后再次查询数据时数据库会做什么?
在高层次上,我们看到存储引擎分为两大类:针对 **事务处理OLTP** 优化的存储引擎和针对 **在线分析OLAP** 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:
* OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
* 数据仓库和类似的分析系统会少见一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。
在 OLTP 这一边,我们能看到两派主流的存储引擎:
* 日志结构学派只允许追加到文件和删除过时的文件但不会更新已经写入的文件。Bitcask、SSTables、LSM 树、LevelDB、Cassandra、HBase、Lucene 等都属于这个类别。
* 就地更新学派将硬盘视为一组可以覆写的固定大小的页面。B 树是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。
日志结构的存储引擎是相对较新的技术。他们的主要想法是,通过系统性地将随机访问写入转换为硬盘上的顺序写入,由于硬盘驱动器和固态硬盘的性能特点,可以实现更高的写入吞吐量。
关于 OLTP我们最后还介绍了一些更复杂的索引结构以及针对所有数据都放在内存里而优化的数据库。
然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。
作为一名应用程序开发人员,如果你掌握了有关存储引擎内部的知识,那么你就能更好地了解哪种工具最适合你的特定应用程序。当你调整数据库的优化参数时,这种理解让你能够设想增减某个值会产生怎样的效果。
尽管本章不能让你成为一个特定存储引擎的调参专家,但它至少大概率使你有了足够的概念与词汇储备去读懂你所选择的数据库的文档。
## 参考文献
1. Alfred V. Aho, John E. Hopcroft, and Jeffrey D. Ullman: *Data Structures and Algorithms*. Addison-Wesley, 1983. ISBN: 978-0-201-00023-8
1. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein: *Introduction to Algorithms*, 3rd edition. MIT Press, 2009. ISBN: 978-0-262-53305-8
1. Justin Sheehy and David Smith: “[Bitcask: A Log-Structured Hash Table for Fast Key/Value Data](https://riak.com/assets/bitcask-intro.pdf),” Basho Technologies, April 2010.
1. Yinan Li, Bingsheng He, Robin Jun Yang, et al.: “[Tree Indexing on Solid State Drives](http://pages.cs.wisc.edu/~yinan/paper/fdtree_pvldb.pdf),” *Proceedings of the VLDB Endowment*, volume 3, number 1, pages 11951206, September 2010.
1. Goetz Graefe: “[Modern B-Tree Techniques](https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=0b19f413ffb5bc68b43f3bd05a97c282a7c6d6ab),” *Foundations and Trends in Databases*, volume 3, number 4, pages 203402, August 2011. [doi:10.1561/1900000028](http://dx.doi.org/10.1561/1900000028)
1. Jeffrey Dean and Sanjay Ghemawat: “[LevelDB Implementation Notes](https://github.com/google/leveldb/blob/master/doc/impl.md),” *github.com*.
1. Dhruba Borthakur: “[The History of RocksDB](https://rocksdb.blogspot.com/2013/11/the-history-of-rocksdb.html),” *rocksdb.blogspot.com*, November 24, 2013.
1. Matteo Bertozzi: “[Apache HBase I/O HFile](https://blog.cloudera.com/apache-hbase-i-o-hfile/),” *blog.cloudera.com*, June 29, 2012.
1. Fay Chang, Jeffrey Dean, Sanjay Ghemawat, et al.: “[Bigtable: A Distributed Storage System for Structured Data](https://research.google/pubs/pub27898/),” at *7th USENIX Symposium on Operating System Design and Implementation* (OSDI), November 2006.
1. Patrick O'Neil, Edward Cheng, Dieter Gawlick, and Elizabeth O'Neil: “[The Log-Structured Merge-Tree (LSM-Tree)](http://www.cs.umb.edu/~poneil/lsmtree.pdf),” *Acta Informatica*, volume 33, number 4, pages 351385, June 1996. [doi:10.1007/s002360050048](http://dx.doi.org/10.1007/s002360050048)
1. Mendel Rosenblum and John K. Ousterhout: “[The Design and Implementation of a Log-Structured File System](http://research.cs.wisc.edu/areas/os/Qual/papers/lfs.pdf),” *ACM Transactions on Computer Systems*, volume 10, number 1, pages 2652, February 1992. [doi:10.1145/146941.146943](http://dx.doi.org/10.1145/146941.146943)
1. Adrien Grand: “[What Is in a Lucene Index?](http://www.slideshare.net/lucenerevolution/what-is-inaluceneagrandfinal),” at *Lucene/Solr Revolution*, November 14, 2013.
1. Deepak Kandepet: “[Hacking Lucene—The Index Format](https://web.archive.org/web/20160316190830/http://hackerlabs.github.io/blog/2011/10/01/hacking-lucene-the-index-format/index.html),” *hackerlabs.github.io*, October 1, 2011.
1. Michael McCandless: “[Visualizing Lucene's Segment Merges](http://blog.mikemccandless.com/2011/02/visualizing-lucenes-segment-merges.html),” *blog.mikemccandless.com*, February 11, 2011.
1. Burton H. Bloom: “[Space/Time Trade-offs in Hash Coding with Allowable Errors](https://people.cs.umass.edu/~emery/classes/cmpsci691st/readings/Misc/p422-bloom.pdf),” *Communications of the ACM*, volume 13, number 7, pages 422426, July 1970. [doi:10.1145/362686.362692](http://dx.doi.org/10.1145/362686.362692)
1. “[Operating Cassandra: Compaction](https://cassandra.apache.org/doc/latest/operating/compaction/index.html),” Apache Cassandra Documentation v4.0, 2016.
1. Rudolf Bayer and Edward M. McCreight: “[Organization and Maintenance of Large Ordered Indices](https://apps.dtic.mil/sti/citations/AD0712079),” Boeing Scientific Research Laboratories, Mathematical and Information Sciences Laboratory, report no. 20, July 1970.
1. Douglas Comer: “[The Ubiquitous B-Tree](https://carlosproal.com/ir/papers/p121-comer.pdf),” *ACM Computing Surveys*, volume 11, number 2, pages 121137, June 1979. [doi:10.1145/356770.356776](http://dx.doi.org/10.1145/356770.356776)
1. Emmanuel Goossaert: “[Coding for SSDs](http://codecapsule.com/2014/02/12/coding-for-ssds-part-1-introduction-and-table-of-contents/),” *codecapsule.com*, February 12, 2014.
1. C. Mohan and Frank Levine: “[ARIES/IM: An Efficient and High Concurrency Index Management Method Using Write-Ahead Logging](http://www.ics.uci.edu/~cs223/papers/p371-mohan.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 1992. [doi:10.1145/130283.130338](http://dx.doi.org/10.1145/130283.130338)
1. Howard Chu: “[LDAP at Lightning Speed](https://buildstuff14.sched.com/event/08a1a368e272eb599a52e08b4c3c779d),” at *Build Stuff '14*, November 2014.
1. Bradley C. Kuszmaul: “[A Comparison of Fractal Trees to Log-Structured Merge (LSM) Trees](http://www.pandademo.com/wp-content/uploads/2017/12/A-Comparison-of-Fractal-Trees-to-Log-Structured-Merge-LSM-Trees.pdf),” *tokutek.com*, April 22, 2014.
1. Manos Athanassoulis, Michael S. Kester, Lukas M. Maas, et al.: “[Designing Access Methods: The RUM Conjecture](http://openproceedings.org/2016/conf/edbt/paper-12.pdf),” at *19th International Conference on Extending Database Technology* (EDBT), March 2016. [doi:10.5441/002/edbt.2016.42](http://dx.doi.org/10.5441/002/edbt.2016.42)
1. Peter Zaitsev: “[Innodb Double Write](https://www.percona.com/blog/2006/08/04/innodb-double-write/),” *percona.com*, August 4, 2006.
1. Tomas Vondra: “[On the Impact of Full-Page Writes](https://www.enterprisedb.com/blog/impact-full-page-writes),” *blog.2ndquadrant.com*, November 23, 2016.
1. Mark Callaghan: “[The Advantages of an LSM vs a B-Tree](http://smalldatum.blogspot.co.uk/2016/01/summary-of-advantages-of-lsm-vs-b-tree.html),” *smalldatum.blogspot.co.uk*, January 19, 2016.
1. Mark Callaghan: “[Choosing Between Efficiency and Performance with RocksDB](https://codemesh.io/codemesh2016/mark-callaghan),” at *Code Mesh*, November 4, 2016.
1. Michi Mutsuzaki: “[MySQL vs. LevelDB](https://github.com/m1ch1/mapkeeper/wiki/MySQL-vs.-LevelDB),” *github.com*, August 2011.
1. Benjamin Coverston, Jonathan Ellis, et al.: “[CASSANDRA-1608: Redesigned Compaction](https://issues.apache.org/jira/browse/CASSANDRA-1608), *issues.apache.org*, July 2011.
1. Igor Canadi, Siying Dong, and Mark Callaghan: “[RocksDB Tuning Guide](https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide),” *github.com*, 2016.
1. [*MySQL 5.7 Reference Manual*](http://dev.mysql.com/doc/refman/5.7/en/index.html). Oracle, 2014.
1. [*Books Online for SQL Server 2012*](https://learn.microsoft.com/en-us/previous-versions/sql/sql-server-2012/ms130214(v=sql.110)). Microsoft, 2012.
1. Joe Webb: “[Using Covering Indexes to Improve Query Performance](https://www.simple-talk.com/sql/learn-sql-server/using-covering-indexes-to-improve-query-performance/),” *simple-talk.com*, 29 September 2008.
1. Frank Ramsak, Volker Markl, Robert Fenk, et al.: “[Integrating the UB-Tree into a Database System Kernel](http://www.vldb.org/conf/2000/P263.pdf),” at *26th International Conference on Very Large Data Bases* (VLDB), September 2000.
1. The PostGIS Development Group: “[PostGIS 2.1.2dev Manual](http://postgis.net/docs/manual-2.1/),” *postgis.net*, 2014.
1. Robert Escriva, Bernard Wong, and Emin Gün Sirer: “[HyperDex: A Distributed, Searchable Key-Value Store](http://www.cs.princeton.edu/courses/archive/fall13/cos518/papers/hyperdex.pdf),” at *ACM SIGCOMM Conference*, August 2012. [doi:10.1145/2377677.2377681](http://dx.doi.org/10.1145/2377677.2377681)
1. Michael McCandless: “[Lucene's FuzzyQuery Is 100 Times Faster in 4.0](http://blog.mikemccandless.com/2011/03/lucenes-fuzzyquery-is-100-times-faster.html),” *blog.mikemccandless.com*, March 24, 2011.
1. Steffen Heinz, Justin Zobel, and Hugh E. Williams: “[Burst Tries: A Fast, Efficient Data Structure for String Keys](http://citeseer.ist.psu.edu/viewdoc/summary?doi=10.1.1.18.3499),” *ACM Transactions on Information Systems*, volume 20, number 2, pages 192223, April 2002. [doi:10.1145/506309.506312](http://dx.doi.org/10.1145/506309.506312)
1. Klaus U. Schulz and Stoyan Mihov: “[Fast String Correction with Levenshtein Automata](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.16.652),” *International Journal on Document Analysis and Recognition*, volume 5, number 1, pages 6785, November 2002. [doi:10.1007/s10032-002-0082-8](http://dx.doi.org/10.1007/s10032-002-0082-8)
1. Christopher D. Manning, Prabhakar Raghavan, and Hinrich Schütze: [*Introduction to Information Retrieval*](http://nlp.stanford.edu/IR-book/). Cambridge University Press, 2008. ISBN: 978-0-521-86571-5, available online at *nlp.stanford.edu/IR-book*
1. Michael Stonebraker, Samuel Madden, Daniel J. Abadi, et al.: “[The End of an Architectural Era (Its Time for a Complete Rewrite)](http://nms.csail.mit.edu/~stavros/pubs/hstore.pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007.
1. “[VoltDB Technical Overview White Paper](https://www.voltdb.com/files/voltdb-technical-overview/),” VoltDB, 2014.
1. Stephen M. Rumble, Ankita Kejriwal, and John K. Ousterhout: “[Log-Structured Memory for DRAM-Based Storage](https://www.usenix.org/system/files/conference/fast14/fast14-paper_rumble.pdf),” at *12th USENIX Conference on File and Storage Technologies* (FAST), February 2014.
1. Stavros Harizopoulos, Daniel J. Abadi, Samuel Madden, and Michael Stonebraker: “[OLTP Through the Looking Glass, and What We Found There](http://hstore.cs.brown.edu/papers/hstore-lookingglass.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2008. [doi:10.1145/1376616.1376713](http://dx.doi.org/10.1145/1376616.1376713)
1. Justin DeBrabant, Andrew Pavlo, Stephen Tu, et al.: “[Anti-Caching: A New Approach to Database Management System Architecture](http://www.vldb.org/pvldb/vol6/p1942-debrabant.pdf),” *Proceedings of the VLDB Endowment*, volume 6, number 14, pages 19421953, September 2013.
1. Joy Arulraj, Andrew Pavlo, and Subramanya R. Dulloor: “[Let's Talk About Storage & Recovery Methods for Non-Volatile Memory Database Systems](http://www.pdl.cmu.edu/PDL-FTP/NVM/storage.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2015. [doi:10.1145/2723372.2749441](http://dx.doi.org/10.1145/2723372.2749441)
1. Edgar F. Codd, S. B. Codd, and C. T. Salley: “[Providing OLAP to User-Analysts: An IT Mandate](https://pdfs.semanticscholar.org/a0bd/1491a54a4de428c5eef9b836ef6ee2915fe7.pdf),” E. F. Codd Associates, 1993.
1. Surajit Chaudhuri and Umeshwar Dayal: “[An Overview of Data Warehousing and OLAP Technology](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/sigrecord.pdf),” *ACM SIGMOD Record*, volume 26, number 1, pages 6574, March 1997. [doi:10.1145/248603.248616](http://dx.doi.org/10.1145/248603.248616)
1. Per-Åke Larson, Cipri Clinciu, Campbell Fraser, et al.: “[Enhancements to SQL Server Column Stores](http://research.microsoft.com/pubs/193599/Apollo3%20-%20Sigmod%202013%20-%20final.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013.
1. Franz Färber, Norman May, Wolfgang Lehner, et al.: “[The SAP HANA Database An Architecture Overview](http://sites.computer.org/debull/A12mar/hana.pdf),” *IEEE Data Engineering Bulletin*, volume 35, number 1, pages 2833, March 2012.
1. Michael Stonebraker: “[The Traditional RDBMS Wisdom Is (Almost Certainly) All Wrong](http://slideshot.epfl.ch/talks/166),” presentation at *EPFL*, May 2013.
1. Daniel J. Abadi: “[Classifying the SQL-on-Hadoop Solutions](https://web.archive.org/web/20150622074951/http://hadapt.com/blog/2013/10/02/classifying-the-sql-on-hadoop-solutions/),” *hadapt.com*, October 2, 2013.
1. Marcel Kornacker, Alexander Behm, Victor Bittorf, et al.: “[Impala: A Modern, Open-Source SQL Engine for Hadoop](http://pandis.net/resources/cidr15impala.pdf),” at *7th Biennial Conference on Innovative Data Systems Research* (CIDR), January 2015.
1. Sergey Melnik, Andrey Gubarev, Jing Jing Long, et al.: “[Dremel: Interactive Analysis of Web-Scale Datasets](https://research.google/pubs/pub36632/),” at *36th International Conference on Very Large Data Bases* (VLDB), pages 330339, September 2010.
1. Ralph Kimball and Margy Ross: *The Data Warehouse Toolkit: The Definitive Guide to Dimensional Modeling*, 3rd edition. John Wiley & Sons, July 2013. ISBN: 978-1-118-53080-1
1. Derrick Harris: “[Why Apple, eBay, and Walmart Have Some of the Biggest Data Warehouses Youve Ever Seen](https://web.archive.org/web/20221129085658/https://old.gigaom.com/2013/03/27/why-apple-ebay-and-walmart-have-some-of-the-biggest-data-warehouses-youve-ever-seen/),” *gigaom.com*, March 27, 2013.
1. Julien Le Dem: “[Dremel Made Simple with Parquet](https://blog.twitter.com/engineering/en_us/a/2013/dremel-made-simple-with-parquet.html),” *blog.twitter.com*, September 11, 2013.
1. Daniel J. Abadi, Peter Boncz, Stavros Harizopoulos, et al.: “[The Design and Implementation of Modern Column-Oriented Database Systems](http://cs-www.cs.yale.edu/homes/dna/papers/abadi-column-stores.pdf),” *Foundations and Trends in Databases*, volume 5, number 3, pages 197280, December 2013. [doi:10.1561/1900000024](http://dx.doi.org/10.1561/1900000024)
1. Peter Boncz, Marcin Zukowski, and Niels Nes: “[MonetDB/X100: Hyper-Pipelining Query Execution](http://cidrdb.org/cidr2005/papers/P19.pdf),” at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005.
1. Jingren Zhou and Kenneth A. Ross: “[Implementing Database Operations Using SIMD Instructions](http://www1.cs.columbia.edu/~kar/pubsk/simd.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), pages 145156, June 2002. [doi:10.1145/564691.564709](http://dx.doi.org/10.1145/564691.564709)
1. Michael Stonebraker, Daniel J. Abadi, Adam Batkin, et al.: “[C-Store: A Column-oriented DBMS](http://www.cs.umd.edu/~abadi/vldb.pdf),” at *31st International Conference on Very Large Data Bases* (VLDB), pages 553564, September 2005.
1. Andrew Lamb, Matt Fuller, Ramakrishna Varadarajan, et al.: “[The Vertica Analytic Database: C-Store 7 Years Later](http://vldb.org/pvldb/vol5/p1790_andrewlamb_vldb2012.pdf),” *Proceedings of the VLDB Endowment*, volume 5, number 12, pages 17901801, August 2012.
1. Julien Le Dem and Nong Li: “[Efficient Data Storage for Analytics with Apache Parquet 2.0](http://www.slideshare.net/julienledem/th-210pledem),” at *Hadoop Summit*, San Jose, June 2014.
1. Jim Gray, Surajit Chaudhuri, Adam Bosworth, et al.: “[Data Cube: A Relational Aggregation Operator Generalizing Group-By, Cross-Tab, and Sub-Totals](http://arxiv.org/pdf/cs/0701155.pdf),” *Data Mining and Knowledge Discovery*, volume 1, number 1, pages 2953, March 2007. [doi:10.1023/A:1009726021843](http://dx.doi.org/10.1023/A:1009726021843)

570
content/v1/ch4.md Normal file
View File

@ -0,0 +1,570 @@
---
title: "第四章:编码与演化"
linkTitle: "4. 编码与演化"
weight: 104
breadcrumbs: false
---
![](/map/ch04.png)
> 唯变所适
>
> —— 以弗所的赫拉克利特,为柏拉图所引(公元前 360 年)
应用程序不可避免地随时间而变化。新产品的推出,对需求的深入理解,或者商业环境的变化,总会伴随着 **功能feature** 的增增改改。[第一章](/v1/ch1) 介绍了 **可演化性evolvability** 的概念:应该尽力构建能灵活适应变化的系统(请参阅 “[可演化性:拥抱变化](/v1/ch1#可演化性:拥抱变化)”)。
在大多数情况下,修改应用程序的功能也意味着需要更改其存储的数据:可能需要使用新的字段或记录类型,或者以新方式展示现有数据。
我们在 [第二章](/v1/ch2) 讨论的数据模型有不同的方法来应对这种变化。关系数据库通常假定数据库中的所有数据都遵循一个模式:尽管可以更改该模式(通过模式迁移,即 `ALTER` 语句),但是在任何时间点都有且仅有一个正确的模式。相比之下,**读时模式**schema-on-read**无模式**,即 schemaless数据库不会强制一个模式因此数据库可以包含在不同时间写入的新老数据格式的混合请参阅 “[文档模型中的模式灵活性](/v1/ch2#文档模型中的模式灵活性)” )。
当数据 **格式format****模式schema** 发生变化时,通常需要对应用程序代码进行相应的更改(例如,为记录添加新字段,然后修改程序开始读写该字段)。但在大型应用程序中,代码变更通常不会立即完成:
* 对于 **服务端server-side** 应用程序,可能需要执行 **滚动升级 rolling upgrade** (也称为 **阶段发布staged rollout** ),一次将新版本部署到少数几个节点,检查新版本是否运行正常,然后逐渐部完所有的节点。这样无需中断服务即可部署新版本,为频繁发布提供了可行性,从而带来更好的可演化性。
* 对于 **客户端client-side** 应用程序,升不升级就要看用户的心情了。用户可能相当长一段时间里都不会去升级软件。
这意味着,新旧版本的代码,以及新旧数据格式可能会在系统中同时共处。系统想要继续顺利运行,就需要保持 **双向兼容性**
向后兼容 (backward compatibility)
: 新的代码可以读取由旧的代码写入的数据。
向前兼容 (forward compatibility)
: 旧的代码可以读取由新的代码写入的数据。
向后兼容性通常并不难实现:新代码的作者当然知道由旧代码使用的数据格式,因此可以显示地处理它(最简单的办法是,保留旧代码即可读取旧数据)。
向前兼容性可能会更棘手,因为旧版的程序需要忽略新版数据格式中新增的部分。
本章中将介绍几种编码数据的格式,包括 JSON、XML、Protocol Buffers、Thrift 和 Avro。尤其将关注这些格式如何应对模式变化以及它们如何对新旧代码数据需要共存的系统提供支持。然后将讨论如何使用这些格式进行数据存储和通信在 Web 服务中,**表述性状态传递REST** 和 **远程过程调用RPC**,以及 **消息传递系统**(如 Actor 和消息队列)。
## 编码数据的格式
程序通常(至少)使用两种形式的数据:
1. 在内存中,数据保存在对象、结构体、列表、数组、散列表、树等中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
2. 如果要将数据写入文件,或通过网络发送,则必须将其 **编码encode** 为某种自包含的字节序列例如JSON 文档)。由于每个进程都有自己独立的地址空间,一个进程中的指针对任何其他进程都没有意义,所以这个字节序列表示会与通常在内存中使用的数据结构完全不同。
> [!TIP] 除一些特殊情况外,例如某些内存映射文件或直接在压缩数据上操作(如 “[列压缩](/v1/ch3#列压缩)” 中所述)。
所以,需要在两种表示之间进行某种类型的翻译。从内存中表示到字节序列的转换称为 **编码Encoding** (也称为 **序列化serialization****编组marshalling**),反过来称为 **解码Decoding**[^ii]**解析Parsing****反序列化deserialization****反编组unmarshalling**)。
> [!TIP] 请注意,**编码encode** 与 **加密encryption** 无关。本书不讨论加密。
> [!WARNING] 术语冲突
> 不幸的是,在 [第七章](/v1/ch7) **事务Transaction** 的上下文里,**序列化Serialization** 这个术语也出现了,而且具有完全不同的含义。尽管序列化可能是更常见的术语,为了避免术语重载,本书中坚持使用 **编码Encoding** 表达此含义。
这是一个常见的问题,因而有许多库和编码格式可供选择。首先让我们概览一下。
### 语言特定的格式
许多编程语言都内建了将内存对象编码为字节序列的支持。例如Java 有 `java.io.Serializable` 【1】Ruby 有 `Marshal`【2】Python 有 `pickle`【3】等等。许多第三方库也存在例如 `Kryo for Java` 【4】。
这些编码库非常方便,可以用很少的额外代码实现内存对象的保存与恢复。但是它们也有一些深层次的问题:
* 这类编码通常与特定的编程语言深度绑定,其他语言很难读取这种数据。如果以这类编码存储或传输数据,那你就和这门语言绑死在一起了。并且很难将系统与其他组织的系统(可能用的是不同的语言)进行集成。
* 为了恢复相同对象类型的数据,解码过程需要 **实例化任意类** 的能力这通常是安全问题的一个来源【5】如果攻击者可以让应用程序解码任意的字节序列他们就能实例化任意的类这会允许他们做可怕的事情如远程执行任意代码【6,7】。
* 在这些库中,数据版本控制通常是事后才考虑的。因为它们旨在快速简便地对数据进行编码,所以往往忽略了向前和向后兼容性带来的麻烦问题。
* 效率(编码或解码所花费的 CPU 时间以及编码结构的大小往往也是事后才考虑的。例如Java 的内置序列化由于其糟糕的性能和臃肿的编码而臭名昭著【8】。
因此,除非临时使用,采用语言内置编码通常是一个坏主意。
### JSON、XML和二进制变体
当我们谈到可以被多种编程语言读写的标准编码时JSON 和 XML 是最显眼的角逐者。它们广为人知,广受支持,也 “广受憎恶”。XML 经常收到批评过于冗长与且过份复杂【9】。JSON 的流行则主要源于(通过成为 JavaScript 的一个子集Web 浏览器的内置支持,以及相对于 XML 的简单性。CSV 是另一种流行的与语言无关的格式,尽管其功能相对较弱。
JSONXML 和 CSV 属于文本格式,因此具有人类可读性(尽管它们的语法是一个热门争议话题)。除了表面的语法问题之外,它们也存在一些微妙的问题:
* **数字numbers** 编码有很多模糊之处。在 XML 和 CSV 中无法区分数字和碰巧由数字组成的字符串除了引用外部模式。JSON 虽然区分字符串与数字,但并不区分整数和浮点数,并且不能指定精度。
这在处理大数字时是个问题。例如大于 $2^{53}$ 的整数无法使用 IEEE 754 双精度浮点数精确表示,因此在使用浮点数(例如 JavaScript的语言进行分析时这些数字会变得不准确。Twitter 有一个关于大于 $2^{53}$ 的数字的例子,它使用 64 位整数来标识每条推文。Twitter API 返回的 JSON 包含了两个推特 ID一个是 JSON 数字,另一个是十进制字符串,以解决 JavaScript 程序中无法正确解析数字的问题【10】。
* JSON 和 XML 对 Unicode 字符串(即人类可读的文本)有很好的支持,但是它们不支持二进制数据(即不带 **字符编码character encoding** 的字节序列)。二进制串是很有用的功能,人们通过使用 Base64 将二进制数据编码为文本来绕过此限制。其特有的模式标识着这个值应当被解释为 Base64 编码的二进制数据。这种方案虽然管用,但比较 Hacky并且会增加三分之一的数据大小。
* XML 【11】和 JSON 【12】都有可选的模式支持。这些模式语言相当强大所以学习和实现起来都相当复杂。XML 模式的使用相当普遍,但许多基于 JSON 的工具才不会去折腾模式。对数据的正确解读(例如区分数值与二进制串)取决于模式中的信息,因此不使用 XML/JSON 模式的应用程序可能需要对相应的编码 / 解码逻辑进行硬编码。
* CSV 没有任何模式因此每行和每列的含义完全由应用程序自行定义。如果应用程序变更添加了新的行或列那么这种变更必须通过手工处理。CSV 也是一个相当模糊的格式如果一个值包含逗号或换行符会发生什么。尽管其转义规则已经被正式指定【13】但并不是所有的解析器都正确的实现了标准。
尽管存在这些缺陷,但 JSON、XML 和 CSV 对很多需求来说已经足够好了。它们很可能会继续流行下去,特别是作为数据交换格式来说(即将数据从一个组织发送到另一个组织)。在这种情况下,只要人们对格式是什么意见一致,格式有多美观或者效率有多高效就无所谓了。让不同的组织就这些东西达成一致的难度超过了绝大多数问题。
#### 二进制编码
对于仅在组织内部使用的数据,使用最小公约数式的编码格式压力较小。例如,可以选择更紧凑或更快的解析格式。虽然对小数据集来说,收益可以忽略不计;但一旦达到 TB 级别,数据格式的选型就会产生巨大的影响。
JSON 比 XML 简洁,但与二进制格式相比还是太占空间。这一事实导致大量二进制编码版本 JSONMessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML例如 WBXML 和 Fast Infoset的出现。这些格式已经在各种各样的领域中采用但是没有一个能像文本版 JSON 和 XML 那样被广泛采用。
这些格式中的一些扩展了一组数据类型(例如,区分整数和浮点数,或者增加对二进制字符串的支持),另一方面,它们没有改变 JSON / XML 的数据模型。特别是由于它们没有规定模式,所以它们需要在编码数据中包含所有的对象字段名称。也就是说,在 [例 4-1]() 中的 JSON 文档的二进制编码中,需要在某处包含字符串 `userName``favoriteNumber` 和 `interests`
**例 4-1 本章中用于展示二进制编码的示例记录**
```json
{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
```
我们来看一个 MessagePack 的例子,它是一个 JSON 的二进制编码。图 4-1 显示了如果使用 MessagePack 【14】对 [例 4-1]() 中的 JSON 文档进行编码,则得到的字节序列。前几个字节如下:
1. 第一个字节 `0x83` 表示接下来是 **3** 个字段(低四位 = `0x03`)的 **对象 object**(高四位 = `0x80`)。(如果想知道如果一个对象有 15 个以上的字段会发生什么情况,字段的数量塞不进 4 个 bit 里,那么它会用另一个不同的类型标识符,字段的数量被编码两个或四个字节)。
2. 第二个字节 `0xa8` 表示接下来是 **8** 字节长(低四位 = `0x08`)的字符串(高四位 = `0x0a`)。
3. 接下来八个字节是 ASCII 字符串形式的字段名称 `userName`。由于之前已经指明长度,不需要任何标记来标识字符串的结束位置(或者任何转义)。
4. 接下来的七个字节对前缀为 `0xa6` 的六个字母的字符串值 `Martin` 进行编码,依此类推。
二进制编码长度为 66 个字节,仅略小于文本 JSON 编码所取的 81 个字节(删除了空白)。所有的 JSON 的二进制编码在这方面是相似的。空间节省了一丁点(以及解析加速)是否能弥补可读性的损失,谁也说不准。
在下面的章节中,能达到比这好得多的结果,只用 32 个字节对相同的记录进行编码。
![](/v1/ddia_0401.png)
**图 4-1 使用 MessagePack 编码的记录(例 4-1**
### Thrift与Protocol Buffers
Apache Thrift 【15】和 Protocol Buffersprotobuf【16】是基于相同原理的二进制编码库。Protocol Buffers 最初是在 Google 开发的Thrift 最初是在 Facebook 开发的,并且都是在 2007~2008 开源的【17】。
Thrift 和 Protocol Buffers 都需要一个模式来编码任何数据。要在 Thrift 的 [例 4-1]() 中对数据进行编码,可以使用 Thrift **接口定义语言IDL** 来描述模式,如下所示:
```c
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
```
Protocol Buffers 的等效模式定义看起来非常相似:
```protobuf
message Person {
required string user_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
}
```
Thrift 和 Protocol Buffers 每一个都带有一个代码生成工具它采用了类似于这里所示的模式定义并且生成了以各种编程语言实现模式的类【18】。你的应用程序代码可以调用此生成的代码来对模式的记录进行编码或解码。
用这个模式编码的数据是什么样的令人困惑的是Thrift 有两种不同的二进制编码格式 [^iii],分别称为 BinaryProtocol 和 CompactProtocol。先来看看 BinaryProtocol。使用这种格式的编码来编码 [例 4-1]() 中的消息只需要 59 个字节,如 [图 4-2](/v1/ddia_0402.png) 所示【19】。
![](/v1/ddia_0402.png)
**图 4-2 使用 Thrift 二进制协议编码的记录**
[^iii]: 实际上Thrift 有三种二进制协议BinaryProtocol、CompactProtocol 和 DenseProtocol尽管 DenseProtocol 只支持 C ++ 实现所以不算作跨语言【18】。除此之外它还有两种不同的基于 JSON 的编码格式【19】。真逗
与 [图 4-1](/v1/ddia_0401.png) 类似,每个字段都有一个类型注释(用于指示它是一个字符串、整数、列表等),还可以根据需要指定长度(字符串的长度,列表中的项目数) 。出现在数据中的字符串 `(“Martin”, “daydreaming”, “hacking”)` 也被编码为 ASCII或者说UTF-8与之前类似。
与 [图 4-1](/v1/ddia_0401.png) 相比,最大的区别是没有字段名 `(userName, favoriteNumber, interests)`。相反,编码数据包含字段标签,它们是数字 `(1, 2 和 3)`。这些是模式定义中出现的数字。字段标记就像字段的别名 - 它们是说我们正在谈论的字段的一种紧凑的方式,而不必拼出字段名称。
Thrift CompactProtocol 编码在语义上等同于 BinaryProtocol但是如 [图 4-3](/v1/ddia_0403.png) 所示,它只将相同的信息打包成只有 34 个字节。它通过将字段类型和标签号打包到单个字节中,并使用可变长度整数来实现。数字 1337 不是使用全部八个字节,而是用两个字节编码,每个字节的最高位用来指示是否还有更多的字节。这意味着 - 64 到 63 之间的数字被编码为一个字节,-8192 和 8191 之间的数字以两个字节编码,等等。较大的数字使用更多的字节。
![](/v1/ddia_0403.png)
**图 4-3 使用 Thrift 压缩协议编码的记录**
最后Protocol Buffers只有一种二进制编码格式对相同的数据进行编码如 [图 4-4](/v1/ddia_0404.png) 所示。它的打包方式稍有不同,但与 Thrift 的 CompactProtocol 非常相似。Protobuf 将同样的记录塞进了 33 个字节中。
![](/v1/ddia_0404.png)
**图 4-4 使用 Protobuf 编码的记录**
需要注意的一个细节:在前面所示的模式中,每个字段被标记为必需或可选,但是这对字段如何编码没有任何影响(二进制数据中没有任何字段指示某字段是否必须)。区别在于,如果字段设置为 `required`,但未设置该字段,则所需的运行时检查将失败,这对于捕获错误非常有用。
#### 字段标签和模式演变
我们之前说过模式不可避免地需要随着时间而改变。我们称之为模式演变。Thrift 和 Protocol Buffers 如何处理模式更改,同时保持向后兼容性?
从示例中可以看出,编码的记录就是其编码字段的拼接。每个字段由其标签号码(样本模式中的数字 1,2,3标识并用数据类型例如字符串或整数注释。如果没有设置字段值则简单地从编码记录中省略。从中可以看到字段标记对编码数据的含义至关重要。你可以更改架构中字段的名称因为编码的数据永远不会引用字段名称但不能更改字段的标记因为这会使所有现有的编码数据无效。
你可以添加新的字段到架构,只要你给每个字段一个新的标签号码。如果旧的代码(不知道你添加的新的标签号码)试图读取新代码写入的数据,包括一个新的字段,其标签号码不能识别,它可以简单地忽略该字段。数据类型注释允许解析器确定需要跳过的字节数。这保持了向前兼容性:旧代码可以读取由新代码编写的记录。
向后兼容性呢?只要每个字段都有一个唯一的标签号码,新的代码总是可以读取旧的数据,因为标签号码仍然具有相同的含义。唯一的细节是,如果你添加一个新的字段,你不能设置为必需。如果你要添加一个字段并将其设置为必需,那么如果新代码读取旧代码写入的数据,则该检查将失败,因为旧代码不会写入你添加的新字段。因此,为了保持向后兼容性,在模式的初始部署之后 **添加的每个字段必须是可选的或具有默认值**
删除一个字段就像添加一个字段,只是这回要考虑的是向前兼容性。这意味着你只能删除可选的字段(必需字段永远不能删除),而且你不能再次使用相同的标签号码(因为你可能仍然有数据写在包含旧标签号码的地方,而该字段必须被新代码忽略)。
#### 数据类型和模式演变
如何改变字段的数据类型?这也许是可能的 —— 详细信息请查阅相关的文档 —— 但是有一个风险,值将失去精度或被截断。例如,假设你将一个 32 位的整数变成一个 64 位的整数。新代码可以轻松读取旧代码写入的数据,因为解析器可以用零填充任何缺失的位。但是,如果旧代码读取由新代码写入的数据,则旧代码仍使用 32 位变量来保存该值。如果解码的 64 位值不适合 32 位,则它将被截断。
Protobuf 的一个奇怪的细节是,它没有列表或数组数据类型,而是有一个字段的重复标记(`repeated`,这是除必需和可选之外的第三个选项)。如 [图 4-4](/v1/ddia_0404.png) 所示,重复字段的编码正如它所说的那样:同一个字段标记只是简单地出现在记录中。这具有很好的效果,可以将可选(单值)字段更改为重复(多值)字段。读取旧数据的新代码会看到一个包含零个或一个元素的列表(取决于该字段是否存在)。读取新数据的旧代码只能看到列表的最后一个元素。
Thrift 有一个专用的列表数据类型,它使用列表元素的数据类型进行参数化。这不允许 Protocol Buffers 所做的从单值到多值的演变,但是它具有支持嵌套列表的优点。
### Avro
Apache Avro 【20】是另一种二进制编码格式与 Protocol Buffers 和 Thrift 有着有趣的不同。它是作为 Hadoop 的一个子项目在 2009 年开始的,因为 Thrift 不适合 Hadoop 的用例【21】。
Avro 也使用模式来指定正在编码的数据的结构。它有两种模式语言一种Avro IDL用于人工编辑一种基于 JSON更易于机器读取。
我们用 Avro IDL 编写的示例模式可能如下所示:
```c
record Person {
string userName;
union { null, long } favoriteNumber = null;
array<string> interests;
}
```
等价的 JSON 表示:
```json
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}
```
首先,请注意模式中没有标签号码。如果我们使用这个模式编码我们的例子记录([例 4-1]()Avro 二进制编码只有 32 个字节长,这是我们所见过的所有编码中最紧凑的。编码字节序列的分解如 [图 4-5](/v1/ddia_0405.png) 所示。
如果你检查字节序列,你可以看到没有什么可以识别字段或其数据类型。编码只是由连在一起的值组成。一个字符串只是一个长度前缀,后跟 UTF-8 字节,但是在被包含的数据中没有任何内容告诉你它是一个字符串。它可以是一个整数,也可以是其他的整数。整数使用可变长度编码(与 Thrift 的 CompactProtocol 相同)进行编码。
![](/v1/ddia_0405.png)
**图 4-5 使用 Avro 编码的记录**
为了解析二进制数据你按照它们出现在模式中的顺序遍历这些字段并使用模式来告诉你每个字段的数据类型。这意味着如果读取数据的代码使用与写入数据的代码完全相同的模式才能正确解码二进制数据。Reader 和 Writer 之间的模式不匹配意味着错误地解码数据。
那么Avro 如何支持模式演变呢?
#### Writer模式与Reader模式
有了 Avro当应用程序想要编码一些数据将其写入文件或数据库、通过网络发送等它使用它知道的任何版本的模式编码数据例如模式可能被编译到应用程序中。这被称为 Writer 模式。
当一个应用程序想要解码一些数据(从一个文件或数据库读取数据、从网络接收数据等)时,它希望数据在某个模式中,这就是 Reader 模式。这是应用程序代码所依赖的模式,在应用程序的构建过程中,代码可能已经从该模式生成。
Avro 的关键思想是 Writer 模式和 Reader 模式不必是相同的 - 他们只需要兼容。当数据解码读取Avro 库通过并排查看 Writer 模式和 Reader 模式并将数据从 Writer 模式转换到 Reader 模式来解决差异。Avro 规范【20】确切地定义了这种解析的工作原理如 [图 4-6](/v1/ddia_0406.png) 所示。
例如,如果 Writer 模式和 Reader 模式的字段顺序不同,这是没有问题的,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在 Writer 模式中但不在 Reader 模式中的字段,则忽略它。如果读取数据的代码需要某个字段,但是 Writer 模式不包含该名称的字段,则使用在 Reader 模式中声明的默认值填充。
![](/v1/ddia_0406.png)
**图 4-6 一个 Avro Reader 解决读写模式的差异**
#### 模式演变规则
使用 Avro向前兼容性意味着你可以将新版本的模式作为 Writer并将旧版本的模式作为 Reader。相反向后兼容意味着你可以有一个作为 Reader 的新版本模式和作为 Writer 的旧版本模式。
为了保持兼容性,你只能添加或删除具有默认值的字段(我们的 Avro 模式中的字段 `favoriteNumber` 的默认值为 `null`)。例如,假设你添加了一个有默认值的字段,这个新的字段将存在于新模式而不是旧模式中。当使用新模式的 Reader 读取使用旧模式写入的记录时,将为缺少的字段填充默认值。
如果你要添加一个没有默认值的字段,新的 Reader 将无法读取旧 Writer 写的数据,所以你会破坏向后兼容性。如果你要删除没有默认值的字段,旧的 Reader 将无法读取新 Writer 写入的数据因此你会打破向前兼容性。在一些编程语言中null 是任何变量可以接受的默认值,但在 Avro 中并不是这样:如果要允许一个字段为 `null`,则必须使用联合类型。例如,`union {null, long, string} field;` 表示 field 可以是数字或字符串,也可以是 `null`。如果要将 null 作为默认值,则它必须是 union 的分支之一 [^iv]。这样的写法比默认情况下就允许任何变量是 `null` 显得更加冗长,但是通过明确什么可以和什么不可以是 `null`有助于防止出错【22】。
[^iv]: 确切地说,默认值必须是联合的第一个分支的类型,尽管这是 Avro 的特定限制,而不是联合类型的一般特征。
因此Avro 没有像 Protocol Buffers 和 Thrift 那样的 `optional``required` 标记(但它有联合类型和默认值)。
只要 Avro 可以支持相应的类型转换就可以改变字段的数据类型。更改字段的名称也是可能的但有点棘手Reader 模式可以包含字段名称的别名,所以它可以匹配旧 Writer 的模式字段名称与别名。这意味着更改字段名称是向后兼容的,但不能向前兼容。同样,向联合类型添加分支也是向后兼容的,但不能向前兼容。
#### 但Writer模式到底是什么
到目前为止我们一直跳过了一个重要的问题对于一段特定的编码数据Reader 如何知道其 Writer 模式?我们不能只将整个模式包括在每个记录中,因为模式可能比编码的数据大得多,从而使二进制编码节省的所有空间都是徒劳的。
答案取决于 Avro 使用的上下文。举几个例子:
有很多记录的大文件
: Avro 的一个常见用途 - 尤其是在 Hadoop 环境中 - 用于存储包含数百万条记录的大文件,所有记录都使用相同的模式进行编码(我们将在 [第十章](/v1/ch10) 讨论这种情况)。在这种情况下,该文件的作者可以在文件的开头只包含一次 Writer 模式。Avro 指定了一个文件格式(对象容器文件)来做到这一点。
支持独立写入的记录的数据库
: 在一个数据库中,不同的记录可能会在不同的时间点使用不同的 Writer 模式来写入 - 你不能假定所有的记录都有相同的模式。最简单的解决方案是在每个编码记录的开始处包含一个版本号并在数据库中保留一个模式版本列表。Reader 可以获取记录,提取版本号,然后从数据库中获取该版本号的 Writer 模式。使用该 Writer 模式,它可以解码记录的其余部分(例如 Espresso 【23】就是这样工作的
通过网络连接发送记录
: 当两个进程通过双向网络连接进行通信时他们可以在连接设置上协商模式版本然后在连接的生命周期中使用该模式。Avro RPC 协议(请参阅 “[服务中的数据流REST 与 RPC](#服务中的数据流REST与RPC)”)就是这样工作的。
具有模式版本的数据库在任何情况下都是非常有用的因为它充当文档并为你提供了检查模式兼容性的机会【24】。作为版本号你可以使用一个简单的递增整数或者你可以使用模式的散列。
#### 动态生成的模式
与 Protocol Buffers 和 Thrift 相比Avro 方法的一个优点是架构不包含任何标签号码。但为什么这很重要?在模式中保留一些数字有什么问题?
不同之处在于 Avro 对动态生成的模式更友善。例如假如你有一个关系数据库你想要把它的内容转储到一个文件中并且你想使用二进制格式来避免前面提到的文本格式JSONCSVSQL的问题。如果你使用 Avro你可以很容易地从关系模式生成一个 Avro 模式(在我们之前看到的 JSON 表示中),并使用该模式对数据库内容进行编码,并将其全部转储到 Avro 对象容器文件【25】中。你为每个数据库表生成一个记录模式每个列成为该记录中的一个字段。数据库中的列名称映射到 Avro 中的字段名称。
现在,如果数据库模式发生变化(例如,一个表中添加了一列,删除了一列),则可以从更新的数据库模式生成新的 Avro 模式,并在新的 Avro 模式中导出数据。数据导出过程不需要注意模式的改变 - 每次运行时都可以简单地进行模式转换。任何读取新数据文件的人都会看到记录的字段已经改变,但是由于字段是通过名字来标识的,所以更新的 Writer 模式仍然可以与旧的 Reader 模式匹配。
相比之下,如果你为此使用 Thrift 或 Protocol Buffers则字段标签可能必须手动分配每次数据库模式更改时管理员都必须手动更新从数据库列名到字段标签的映射这可能会自动化但模式生成器必须非常小心不要分配以前使用的字段标签。这种动态生成的模式根本不是 Thrift 或 Protocol Buffers 的设计目标,而是 Avro 的。
#### 代码生成和动态类型的语言
Thrift 和 Protobuf 依赖于代码生成:在定义了模式之后,可以使用你选择的编程语言生成实现此模式的代码。这在 Java、C++ 或 C# 等静态类型语言中很有用,因为它允许将高效的内存中的数据结构用于解码的数据,并且在编写访问数据结构的程序时允许在 IDE 中进行类型检查和自动补全。
在动态类型编程语言(如 JavaScript、Ruby 或 Python生成代码没有太多意义因为没有编译时类型检查器来满足。代码生成在这些语言中经常被忽视因为它们避免了显式的编译步骤。而且对于动态生成的模式例如从数据库表生成的 Avro 模式),代码生成对获取数据是一个不必要的障碍。
Avro 为静态类型编程语言提供了可选的代码生成功能,但是它也可以在不生成任何代码的情况下使用。如果你有一个对象容器文件(它嵌入了 Writer 模式),你可以简单地使用 Avro 库打开它,并以与查看 JSON 文件相同的方式查看数据。该文件是自描述的,因为它包含所有必要的元数据。
这个属性特别适用于动态类型的数据处理语言如 Apache Pig 【26】。在 Pig 中,你可以打开一些 Avro 文件,开始分析它们,并编写派生数据集以 Avro 格式输出文件,而无需考虑模式。
### 模式的优点
正如我们所看到的Protocol Buffers、Thrift 和 Avro 都使用模式来描述二进制编码格式。他们的模式语言比 XML 模式或者 JSON 模式简单得多,而后者支持更详细的验证规则(例如,“该字段的字符串值必须与该正则表达式匹配” 或 “该字段的整数值必须在 0 和 100 之间” )。由于 Protocol BuffersThrift 和 Avro 实现起来更简单,使用起来也更简单,所以它们已经发展到支持相当广泛的编程语言。
这些编码所基于的想法绝不是新的。例如,它们与 ASN.1 有很多相似之处,它是 1984 年首次被标准化的模式定义语言【27】。它被用来定义各种网络协议例如其二进制编码DER仍然被用于编码 SSL 证书X.509【28】。ASN.1 支持使用标签号码的模式演进,类似于 Protocol Buffers 和 Thrift 【29】。然而它也非常复杂而且没有好的配套文档所以 ASN.1 可能不是新应用程序的好选择。
许多数据系统也为其数据实现了某种专有的二进制编码。例如,大多数关系数据库都有一个网络协议,你可以通过该协议向数据库发送查询并获取响应。这些协议通常特定于特定的数据库,并且数据库供应商提供将来自数据库的网络协议的响应解码为内存数据结构的驱动程序(例如使用 ODBC 或 JDBC API
所以,我们可以看到,尽管 JSON、XML 和 CSV 等文本数据格式非常普遍,但基于模式的二进制编码也是一个可行的选择。他们有一些很好的属性:
* 它们可以比各种 “二进制 JSON” 变体更紧凑,因为它们可以省略编码数据中的字段名称。
* 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的(而手动维护的文档可能很容易偏离现实)。
* 维护一个模式的数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
* 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,因为它可以在编译时进行类型检查。
总而言之,模式演化保持了与 JSON 数据库提供的无模式 / 读时模式相同的灵活性(请参阅 “[文档模型中的模式灵活性](/v1/ch2#文档模型中的模式灵活性)”),同时还可以更好地保证你的数据并提供更好的工具。
## 数据流的类型
在本章的开始部分,我们曾经说过,无论何时你想要将某些数据发送到不共享内存的另一个进程,例如,只要你想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序列。然后我们讨论了做这个的各种不同的编码。
我们讨论了向前和向后的兼容性,这对于可演化性来说非常重要(通过允许你独立升级系统的不同部分,而不必一次改变所有内容,可以轻松地进行更改)。兼容性是编码数据的一个进程和解码它的另一个进程之间的一种关系。
这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:
* 通过数据库(请参阅 “[数据库中的数据流](#数据库中的数据流)”)
* 通过服务调用(请参阅 “[服务中的数据流REST 与 RPC](#服务中的数据流REST与RPC)”)
* 通过异步消息传递(请参阅 “[消息传递中的数据流](#消息传递中的数据流)”)
### 数据库中的数据流
在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,读者只是相同进程的后续版本 - 在这种情况下,你可以考虑将数据库中的内容存储为向未来的自我发送消息。
向后兼容性显然是必要的。否则你未来的自己将无法解码你以前写的东西。
一般来说,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是几个相同服务的实例(为了可伸缩性或容错性而并行运行)。无论哪种方式,在应用程序发生变化的环境中,访问数据库的某些进程可能会运行较新的代码,有些进程可能会运行较旧的代码,例如,因为新版本当前正在部署滚动升级,所以有些实例已经更新,而其他实例尚未更新。
这意味着数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容。
但是,还有一个额外的障碍。假设你将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的字段不变,即使它不能被解释。
前面讨论的编码格式支持未知字段的保存,但是有时候需要在应用程序层面保持谨慎,如图 4-7 所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
![](/v1/ddia_0407.png)
**图 4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。**
#### 在不同的时间写入不同的值
数据库通常允许任何时候更新任何值。这意味着在一个单一的数据库中,可能有一些值是五毫秒前写的,而一些值是五年前写的。
在部署应用程序的新版本时,也许用不了几分钟就可以将所有的旧版本替换为新版本(至少服务器端应用程序是这样的)。但数据库内容并非如此:对于五年前的数据来说,除非对其进行显式重写,否则它仍然会以原始编码形式存在。这种现象有时被概括为:数据的生命周期超出代码的生命周期。
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据 [^v]。读取旧行时对于磁盘上的编码数据缺少的任何列数据库将填充空值。LinkedIn 的文档数据库 Espresso 使用 Avro 存储,允许它使用 Avro 的模式演变规则【23】。
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
[^v]: 除了 MySQL即使并非真的必要它也经常会重写整个表正如 “[文档模型中的模式灵活性](/v1/ch2#文档模型中的模式灵活性)” 中所提到的。
#### 归档存储
也许你不时为数据库创建一个快照,例如备份或加载到数据仓库(请参阅 “[数据仓库](/v1/ch3#数据仓库)”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要拷贝数据,那么你可以对这个数据拷贝进行一致的编码。
由于数据转储是一次写入的,而且以后是不可变的,所以 Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如 Parquet请参阅 “[列压缩](/v1/ch3#列压缩)”)。
在 [第十章](/v1/ch10) 中,我们将详细讨论使用档案存储中的数据。
### 服务中的数据流REST与RPC
当你需要通过网络进行进程间的通讯时,安排该通信的方式有几种。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开 API并且客户端可以连接到服务器以向该 API 发出请求。服务器公开的 API 被称为服务。
Web 以这种方式工作客户Web 浏览器)向 Web 服务器发出请求,通过 GET 请求下载 HTML、CSS、JavaScript、图像等并通过 POST 请求提交数据到服务器。API 包含一组标准的协议和数据格式HTTP、URL、SSL/TLS、HTML 等)。由于网络浏览器、网络服务器和网站作者大多同意这些标准,你可以使用任何网络浏览器访问任何网站(至少在理论上!)。
Web 浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在 Web 浏览器内运行的客户端 JavaScript 应用程序可以使用 XMLHttpRequest 成为 HTTP 客户端(该技术被称为 Ajax 【30】。在这种情况下服务器的响应通常不是用于显示给人的 HTML而是便于客户端应用程序代码进一步处理的编码数据如 JSON。尽管 HTTP 可能被用作传输协议,但顶层实现的 API 是特定于应用程序的,客户端和服务器需要就该 API 的细节达成一致。
此外,服务器本身可以是另一个服务的客户端(例如,典型的 Web 应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 **面向服务的体系结构service-oriented architectureSOA**,最近被改进和更名为 **微服务架构**【31,32】。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在 [第二章](/v1/ch2) 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的 API它只允许由服务的业务逻辑应用程序代码预定的输入和输出【33】。这种限制提供了一定程度的封装服务能够对客户可以做什么和不可以做什么施加细粒度的限制。
面向服务 / 微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务 API 之间兼容 —— 这正是我们在本章所一直在谈论的。
#### Web服务
**当服务使用 HTTP 作为底层通信协议时,可称之为 Web 服务**。这可能是一个小错误,因为 Web 服务不仅在 Web 上使用,而且在几个不同的环境中使用。例如:
1. 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用 Ajax 的 JavaScript web 应用程序)通过 HTTP 向服务发出请求。这些请求通常通过公共互联网进行。
2. 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务 / 微服务架构的一部分。(支持这种用例的软件有时被称为 **中间件middleware**
3. 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共 API或用于共享访问用户数据的 OAuth。
有两种流行的 Web 服务方法REST 和 SOAP。他们在哲学方面几乎是截然相反的往往也是各自支持者之间的激烈辩论的主题 [^vi]。
[^vi]: 即使在每个阵营内也有很多争论。例如,**HATEOAS超媒体作为应用程序状态的引擎** 就经常引发讨论【35】。
REST 不是一个协议,而是一个基于 HTTP 原则的设计哲学【34,35】。它强调简单的数据格式使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制,身份验证和内容类型协商。与 SOAP 相比REST 已经越来越受欢迎至少在跨组织服务集成的背景下【36】并经常与微服务相关【31】。根据 REST 原则设计的 API 称为 RESTful。
相比之下SOAP 是用于制作网络 API 请求的基于 XML 的协议 [^vii]。虽然它最常用于 HTTP但其目的是独立于 HTTP并避免使用大多数 HTTP 功能。相反它带有庞大而复杂的多种相关标准Web 服务框架,称为 `WS-*`它们增加了各种功能【37】。
[^vii]: 尽管首字母缩写词相似SOAP 并不是 SOA 的要求。SOAP 是一种特殊的技术,而 SOA 是构建系统的一般方法。
SOAP Web 服务的 API 使用称为 Web 服务描述语言WSDL的基于 XML 的语言来描述。WSDL 支持代码生成,客户端可以使用本地类和方法调用(编码为 XML 消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(请参阅 “[代码生成和动态类型的语言](#代码生成和动态类型的语言)”)。
由于 WSDL 的设计不是人类可读的,而且由于 SOAP 消息通常因为过于复杂而无法手动构建,所以 SOAP 的用户在很大程度上依赖于工具支持,代码生成和 IDE【38】。对于 SOAP 供应商不支持的编程语言的用户来说,与 SOAP 服务的集成是困难的。
尽管 SOAP 及其各种扩展表面上是标准化的但是不同厂商的实现之间的互操作性往往会造成问题【39】。由于所有这些原因尽管许多大型企业仍然使用 SOAP但在大多数小公司中已经不再受到青睐。
REST 风格的 API 倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如 OpenAPI也称为 Swagger 【40】可用于描述 RESTful API 并生成文档。
#### 远程过程调用RPC的问题
Web 服务仅仅是通过网络进行 API 请求的一系列技术的最新版本其中许多技术受到了大量的炒作但是存在严重的问题。Enterprise JavaBeansEJB和 Java 的 **远程方法调用RMI** 仅限于 Java。**分布式组件对象模型DCOM** 仅限于 Microsoft 平台。**公共对象请求代理体系结构CORBA** 过于复杂不提供向后或向前兼容性【41】。
所有这些都是基于 **远程过程调用RPC** 的思想,该过程调用自 20 世纪 70 年代以来一直存在【42】。RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管 RPC 起初看起来很方便但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同
* 本地函数调用是可预测的,并且成功或失败仅取决于受你控制的参数。网络请求是不可预测的:请求或响应可能由于网络问题会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在你的控制范围之内。网络问题很常见,因此必须有所准备,例如重试失败的请求。
* 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它返回时可能没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过(我们将在 [第八章](/v1/ch8) 更详细地讨论这个问题)。
* 如果你重试失败的网络请求,可能会发生请求实际上已经完成,只是响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非你在协议中建立数据去重机制(**幂等性**,即 idempotence。本地函数调用时没有这样的问题。在 [第十一章](/v1/ch11) 更详细地讨论幂等性)
* 每次调用本地函数时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:好的时候它可能会在不到一毫秒的时间内完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间才能完成相同的操作。
* 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会出现问题。
* 客户端和服务可以用不同的编程语言实现,所以 RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会变得很丑陋,因为不是所有的语言都具有相同的类型 —— 例如回想一下 JavaScript 的数字大于 $2^{53}$ 的问题(请参阅 “[JSON、XML 和二进制变体](#JSON、XML和二进制变体)”)。用单一语言编写的单个进程中不存在此问题。
所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义因为这是一个根本不同的事情。REST 的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实(尽管这似乎并没有阻止人们在 REST 之上构建 RPC 库)。
#### RPC的当前方向
尽管有这样那样的问题RPC 不会消失。在本章提到的所有编码的基础上构建了各种 RPC 框架例如Thrift 和 Avro 带有 RPC 支持gRPC 是使用 Protocol Buffers 的 RPC 实现Finagle 也使用 ThriftRest.li 使用 JSON over HTTP。
这种新一代的 RPC 框架更加明确的是远程请求与本地函数调用不同。例如Finagle 和 Rest.li 使用 futurespromises来封装可能失败的异步操作。`Futures` 还可以简化需要并行发出多项服务并将其结果合并的情况【45】。gRPC 支持流其中一个调用不仅包括一个请求和一个响应还可以是随时间的一系列请求和响应【46】。
其中一些框架还提供服务发现,即允许客户端找出在哪个 IP 地址和端口号上可以找到特定的服务。我们将在 “[请求路由](/v1/ch6#请求路由)” 中回到这个主题。
使用二进制编码格式的自定义 RPC 协议可以实现比通用的 JSON over REST 更好的性能。但是RESTful API 还有其他一些显著的优点:方便实验和调试(只需使用 Web 浏览器或命令行工具 curl无需任何代码生成或软件安装即可向其请求能被所有主流的编程语言和平台所支持还有大量可用的工具服务器、缓存、负载平衡器、代理、防火墙、监控、调试工具、测试工具等的生态系统。
由于这些原因REST 似乎是公共 API 的主要风格。RPC 框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
#### 数据编码与RPC的演化
对于可演化性,重要的是可以独立更改和部署 RPC 客户端和服务器。与通过数据库流动的数据相比(如上一节所述),我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,你只需要在请求上具有向后兼容性,并且对响应具有向前兼容性。
RPC 方案的向后和向前兼容性属性是从它使用的编码方式中继承而来:
* Thrift、gRPCProtobuf和 Avro RPC 可以根据相应编码格式的兼容性规则进行演变。
* 在 SOAP 中,请求和响应是使用 XML 模式指定的。这些可以演变但有一些微妙的陷阱【47】。
* RESTful API 通常使用 JSON没有正式指定的模式用于响应以及用于请求的 JSON 或 URI 编码 / 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。
由于 RPC 经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务 API。
关于 API 版本化应该如何工作(即,客户端如何指示它想要使用哪个版本的 API没有一致意见【48】。对于 RESTful API常用的方法是在 URL 或 HTTP Accept 头中使用版本号。对于使用 API 密钥来标识特定客户端的服务,另一种选择是将客户端请求的 API 版本存储在服务器上并允许通过单独的管理界面更新该版本选项【49】。
### 消息传递中的数据流
我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了 REST 和 RPC其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应以及数据库一个进程写入编码数据另一个进程在将来再次读取
在最后一节中,我们将简要介绍一下 RPC 和数据库之间的异步消息传递系统。它们与 RPC 类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
与直接 RPC 相比,使用消息代理有几个优点:
* 如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
* 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
* 避免发件人需要知道收件人的 IP 地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
* 它允许将一条消息发送给多个收件人。
* 将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。
然而,与 RPC 相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
#### 消息代理
过去,**消息代理Message Broker** 主要是 TIBCO、IBM WebSphere 和 webMethods 等公司的商业软件的秀场。最近像 RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 这样的开源实现已经流行起来。我们将在 [第十一章](/v1/ch11) 中对它们进行更详细的比较。
详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给那个队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在 [第十一章](/v1/ch11) 中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求 / 响应数据流,类似于 RPC
消息代理通常不会执行任何特定的数据模型 —— 消息只是包含一些元数据的字节序列,因此你可以使用任何编码格式。如果编码是向后和向前兼容的,你可以灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序进行部署。
如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题([图 4-7](/v1/ddia_0407.png))。
#### 分布式的Actor框架
Actor 模型是单个进程中并发的编程模型。逻辑被封装在 actor 中,而不是直接处理线程(以及竞争条件、锁定和死锁的相关问题)。每个 actor 通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。不保证消息传送:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
在分布式 Actor 框架中,此编程模型用于跨多个节点伸缩应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
位置透明在 actor 模型中比在 RPC 中效果更好,因为 actor 模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用 actor 模型时,本地和远程通信之间的基本不匹配是较少的。
分布式的 Actor 框架实质上是将消息代理和 actor 编程模型集成到一个框架中。但是,如果要执行基于 actor 的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
三个流行的分布式 actor 框架处理消息编码如下:
* 默认情况下Akka 使用 Java 的内置序列化,不提供向前或向后兼容性。但是,你可以用类似 Protocol Buffers 的东西替代它从而获得滚动升级的能力【50】。
* Orleans 默认使用不支持滚动升级部署的自定义数据编码格式要部署新版本的应用程序你需要设置一个新的集群将流量从旧集群迁移到新集群然后关闭旧集群【51,52】。像 Akka 一样,可以使用自定义序列化插件。
* 在 Erlang OTP 中对记录模式进行更改是非常困难的尽管系统具有许多为高可用性设计的功能。滚动升级是可能的但需要仔细计划【53】。一个新的实验性的 `maps` 数据类型2014 年在 Erlang R17 中引入的类似于 JSON 的结构可能使得这个数据类型在未来更容易【54】。
## 本章小结
在本章中,我们研究了将数据结构转换为网络中的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是也影响了应用程序的体系结构和部署它们的选项。
特别是,许多服务需要支持滚动升级,其中新版本的服务逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布新版本的服务(从而鼓励在罕见的大型版本上频繁发布小型版本),并使部署风险降低(允许在影响大量用户之前检测并回滚有故障的版本)。这些属性对于可演化性,以及对应用程序进行更改的容易性都是非常有利的。
在滚动升级期间,或出于各种其他原因,我们必须假设不同的节点正在运行我们的应用程序代码的不同版本。因此,在系统周围流动的所有数据都是以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码是重要的。
我们讨论了几种数据编码格式及其兼容性属性:
* 编程语言特定的编码仅限于单一编程语言,并且往往无法提供向前和向后兼容性。
* JSON、XML 和 CSV 等文本格式非常普遍,其兼容性取决于你如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
* 像 Thrift、Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的向前和向后兼容性语义进行紧凑、高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:
* 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
* RPC 和 REST API客户端对请求进行编码服务器对请求进行解码并对响应进行编码客户端最终对响应进行解码
* 异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码
我们可以小心地得出这样的结论:向后/向前兼容性和滚动升级在某种程度上是可以实现的。愿你的应用程序的演变迅速、敏捷部署。
## 参考文献
1. “[Java Object Serialization Specification](http://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.html),” *docs.oracle.com*, 2010.
1. “[Ruby 2.2.0 API Documentation](http://ruby-doc.org/core-2.2.0/),” *ruby-doc.org*, Dec 2014.
1. “[The Python 3.4.3 Standard Library Reference Manual](https://docs.python.org/3/library/pickle.html),” *docs.python.org*, February 2015.
1. “[EsotericSoftware/kryo](https://github.com/EsotericSoftware/kryo),” *github.com*, October 2014.
1. “[CWE-502: Deserialization of Untrusted Data](http://cwe.mitre.org/data/definitions/502.html),” Common Weakness Enumeration, *cwe.mitre.org*, July 30, 2014.
1. Steve Breen: “[What Do WebLogic, WebSphere, JBoss, Jenkins, OpenNMS, and Your Application Have in Common? This Vulnerability](http://foxglovesecurity.com/2015/11/06/what-do-weblogic-websphere-jboss-jenkins-opennms-and-your-application-have-in-common-this-vulnerability/),” *foxglovesecurity.com*, November 6, 2015.
1. Patrick McKenzie: “[What the Rails Security Issue Means for Your Startup](http://www.kalzumeus.com/2013/01/31/what-the-rails-security-issue-means-for-your-startup/),” *kalzumeus.com*, January 31, 2013.
1. Eishay Smith: “[jvm-serializers wiki](https://github.com/eishay/jvm-serializers/wiki),” *github.com*, November 2014.
1. “[XML Is a Poor Copy of S-Expressions](http://c2.com/cgi/wiki?XmlIsaPoorCopyOfEssExpressions),” *c2.com* wiki.
1. Matt Harris: “[Snowflake: An Update and Some Very Important Information](https://groups.google.com/forum/#!topic/twitter-development-talk/ahbvo3VTIYI),” email to *Twitter Development Talk* mailing list, October 19, 2010.
1. Shudi (Sandy) Gao, C. M. Sperberg-McQueen, and Henry S. Thompson: “[XML Schema 1.1](http://www.w3.org/XML/Schema),” W3C Recommendation, May 2001.
1. Francis Galiegue, Kris Zyp, and Gary Court: “[JSON Schema](http://json-schema.org/),” IETF Internet-Draft, February 2013.
1. Yakov Shafranovich: “[RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files](https://tools.ietf.org/html/rfc4180),” October 2005.
1. “[MessagePack Specification](http://msgpack.org/),” *msgpack.org*.
1. Mark Slee, Aditya Agarwal, and Marc Kwiatkowski: “[Thrift: Scalable Cross-Language Services Implementation](http://thrift.apache.org/static/files/thrift-20070401.pdf),” Facebook technical report, April 2007.
1. “[Protocol Buffers Developer Guide](https://developers.google.com/protocol-buffers/docs/overview),” Google, Inc., *developers.google.com*.
1. Igor Anishchenko: “[Thrift vs Protocol Buffers vs Avro - Biased Comparison](http://www.slideshare.net/IgorAnishchenko/pb-vs-thrift-vs-avro),” *slideshare.net*, September 17, 2012.
1. “[A Matrix of the Features Each Individual Language Library Supports](http://wiki.apache.org/thrift/LibraryFeatures),” *wiki.apache.org*.
1. Martin Kleppmann: “[Schema Evolution in Avro, Protocol Buffers and Thrift](http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html),” *martin.kleppmann.com*, December 5, 2012.
1. “[Apache Avro 1.7.7 Documentation](http://avro.apache.org/docs/1.7.7/),” *avro.apache.org*, July 2014.
1. Doug Cutting, Chad Walters, Jim Kellerman, et al.: “[&#91;PROPOSAL&#93; New Subproject: Avro](http://mail-archives.apache.org/mod_mbox/hadoop-general/200904.mbox/%3C49D53694.1050906@apache.org%3E),” email thread on *hadoop-general* mailing list, *mail-archives.apache.org*, April 2009.
1. Tony Hoare: “[Null References: The Billion Dollar Mistake](http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare),” at *QCon London*, March 2009.
1. Aditya Auradkar and Tom Quiggle: “[Introducing Espresso—LinkedIn's Hot New Distributed Document Store](https://engineering.linkedin.com/espresso/introducing-espresso-linkedins-hot-new-distributed-document-store),” *engineering.linkedin.com*, January 21, 2015.
1. Jay Kreps: “[Putting Apache Kafka to Use: A Practical Guide to Building a Stream Data Platform (Part 2)](http://blog.confluent.io/2015/02/25/stream-data-platform-2/),” *blog.confluent.io*, February 25, 2015.
1. Gwen Shapira: “[The Problem of Managing Schemas](http://radar.oreilly.com/2014/11/the-problem-of-managing-schemas.html),” *radar.oreilly.com*, November 4, 2014.
1. “[Apache Pig 0.14.0 Documentation](http://pig.apache.org/docs/r0.14.0/),” *pig.apache.org*, November 2014.
1. John Larmouth: [*ASN.1 Complete*](http://www.oss.com/asn1/resources/books-whitepapers-pubs/larmouth-asn1-book.pdf). Morgan Kaufmann, 1999. ISBN: 978-0-122-33435-1
1. Russell Housley, Warwick Ford, Tim Polk, and David Solo: “[RFC 2459: Internet X.509 Public Key Infrastructure: Certificate and CRL Profile](https://www.ietf.org/rfc/rfc2459.txt),” IETF Network Working Group, Standards Track, January 1999.
1. Lev Walkin: “[Question: Extensibility and Dropping Fields](http://lionet.info/asn1c/blog/2010/09/21/question-extensibility-removing-fields/),” *lionet.info*, September 21, 2010.
1. Jesse James Garrett: “[Ajax: A New Approach to Web Applications](https://web.archive.org/web/20181231094556/https://www.adaptivepath.com/ideas/ajax-new-approach-web-applications/),” *adaptivepath.com*, February 18, 2005.
1. Sam Newman: *Building Microservices*. O'Reilly Media, 2015. ISBN: 978-1-491-95035-7
1. Chris Richardson: “[Microservices: Decomposing Applications for Deployability and Scalability](http://www.infoq.com/articles/microservices-intro),” *infoq.com*, May 25, 2014.
1. Pat Helland: “[Data on the Outside Versus Data on the Inside](http://cidrdb.org/cidr2005/papers/P12.pdf),” at *2nd Biennial Conference on Innovative Data Systems Research* (CIDR), January 2005.
1. Roy Thomas Fielding: “[Architectural Styles and the Design of Network-Based Software Architectures](https://www.ics.uci.edu/~fielding/pubs/dissertation/fielding_dissertation.pdf),” PhD Thesis, University of California, Irvine, 2000.
1. Roy Thomas Fielding: “[REST APIs Must Be Hypertext-Driven](http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven),” *roy.gbiv.com*, October 20 2008.
1. “[REST in Peace, SOAP](https://royal.pingdom.com/rest-in-peace-soap/),” *royal.pingdom.com*, October 15, 2010.
1. “[Web Services Standards as of Q1 2007](https://www.innoq.com/resources/ws-standards-poster/),” *innoq.com*, February 2007.
1. Pete Lacey: “[The S Stands for Simple](http://harmful.cat-v.org/software/xml/soap/simple),” *harmful.cat-v.org*, November 15, 2006.
1. Stefan Tilkov: “[Interview: Pete Lacey Criticizes Web Services](http://www.infoq.com/articles/pete-lacey-ws-criticism),” *infoq.com*, December 12, 2006.
1. “[OpenAPI Specification (fka Swagger RESTful API Documentation Specification) Version 2.0](http://swagger.io/specification/),” *swagger.io*, September 8, 2014.
1. Michi Henning: “[The Rise and Fall of CORBA](https://cacm.acm.org/magazines/2008/8/5336-the-rise-and-fall-of-corba/fulltext),” *Communications of the ACM*, volume 51, number 8, pages 5257, August 2008. [doi:10.1145/1378704.1378718](http://dx.doi.org/10.1145/1378704.1378718)
1. Andrew D. Birrell and Bruce Jay Nelson: “[Implementing Remote Procedure Calls](http://www.cs.princeton.edu/courses/archive/fall03/cs518/papers/rpc.pdf),” *ACM Transactions on Computer Systems* (TOCS), volume 2, number 1, pages 3959, February 1984. [doi:10.1145/2080.357392](http://dx.doi.org/10.1145/2080.357392)
1. Jim Waldo, Geoff Wyant, Ann Wollrath, and Sam Kendall: “[A Note on Distributed Computing](http://m.mirror.facebook.net/kde/devel/smli_tr-94-29.pdf),” Sun Microsystems Laboratories, Inc., Technical Report TR-94-29, November 1994.
1. Steve Vinoski: “[Convenience over Correctness](http://steve.vinoski.net/pdf/IEEE-Convenience_Over_Correctness.pdf),” *IEEE Internet Computing*, volume 12, number 4, pages 8992, July 2008. [doi:10.1109/MIC.2008.75](http://dx.doi.org/10.1109/MIC.2008.75)
1. Marius Eriksen: “[Your Server as a Function](http://monkey.org/~marius/funsrv.pdf),” at *7th Workshop on Programming Languages and Operating Systems* (PLOS), November 2013. [doi:10.1145/2525528.2525538](http://dx.doi.org/10.1145/2525528.2525538)
1. “[gRPC concepts](https://grpc.io/docs/guides/concepts/),” The Linux Foundation, *grpc.io*.
1. Aditya Narayan and Irina Singh: “[Designing and Versioning Compatible Web Services](https://web.archive.org/web/20141016000136/http://www.ibm.com/developerworks/websphere/library/techarticles/0705_narayan/0705_narayan.html),” *ibm.com*, March 28, 2007.
1. Troy Hunt: “[Your API Versioning Is Wrong, Which Is Why I Decided to Do It 3 Different Wrong Ways](http://www.troyhunt.com/2014/02/your-api-versioning-is-wrong-which-is.html),” *troyhunt.com*, February 10, 2014.
1. “[API Upgrades](https://stripe.com/docs/upgrades),” Stripe, Inc., April 2015.
1. Jonas Bonér: “[Upgrade in an Akka Cluster](http://grokbase.com/t/gg/akka-user/138wd8j9e3/upgrade-in-an-akka-cluster),” email to *akka-user* mailing list, *grokbase.com*, August 28, 2013.
1. Philip A. Bernstein, Sergey Bykov, Alan Geller, et al.: “[Orleans: Distributed Virtual Actors for Programmability and Scalability](https://www.microsoft.com/en-us/research/publication/orleans-distributed-virtual-actors-for-programmability-and-scalability/),” Microsoft Research Technical Report MSR-TR-2014-41, March 2014.
1. “[Microsoft Project Orleans Documentation](http://dotnet.github.io/orleans/),” Microsoft Research, *dotnet.github.io*, 2015.
1. David Mercer, Sean Hinde, Yinso Chen, and Richard A O'Keefe: “[beginner: Updating Data Structures](http://erlang.org/pipermail/erlang-questions/2007-October/030318.html),” email thread on *erlang-questions* mailing list, *erlang.com*, October 29, 2007.
1. Fred Hebert: “[Postscript: Maps](http://learnyousomeerlang.com/maps),” *learnyousomeerlang.com*, April 9, 2014.

821
content/v1/ch5.md Normal file
View File

@ -0,0 +1,821 @@
---
title: "第五章:复制"
linkTitle: "5. 复制"
weight: 205
breadcrumbs: false
math: true
---
![](/map/ch05.png)
> 与可能出错的东西比,“不可能”出错的东西最显著的特点就是:一旦真的出错,通常就彻底玩完了。
>
> —— 道格拉斯・亚当斯1992
复制意味着在通过网络连接的多台机器上保留相同数据的副本。正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,我们希望能复制数据,可能出于各种各样的原因:
* 使得数据与用户在地理上接近(从而减少延迟)
* 即使系统的一部分出现故障,系统也能继续工作(从而提高可用性)
* 伸缩可以接受读请求的机器数量(从而提高读取吞吐量)
本章将假设你的数据集非常小,每台机器都可以保存整个数据集的副本。在 [第六章](/v1/ch6) 中将放宽这个假设,讨论对单个机器来说太大的数据集的分割(分片)。在后面的章节中,我们将讨论复制数据系统中可能发生的各种故障,以及如何处理这些故障。
如果复制中的数据不会随时间而改变,那复制就很简单:将数据复制到每个节点一次就万事大吉。复制的困难之处在于处理复制数据的 **变更change**,这就是本章所要讲的。我们将讨论三种流行的变更复制算法:**单领导者single leader单主****多领导者multi leader多主** 和 **无领导者leaderless无主**。几乎所有分布式数据库都使用这三种方法之一。
在复制时需要进行许多权衡:例如,使用同步复制还是异步复制?如何处理失败的副本?这些通常是数据库中的配置选项,细节因数据库而异,但原理在许多不同的实现中都类似。本章会讨论这些决策的后果。
数据库的复制算得上是老生常谈了 ——70 年代研究得出的基本原则至今没有太大变化【1】因为网络的基本约束仍保持不变。然而在研究之外许多开发人员仍然假设一个数据库只有一个节点。分布式数据库变为主流只是最近发生的事。许多程序员都是这一领域的新手因此对于诸如 **最终一致性eventual consistency** 等问题存在许多误解。在 “[复制延迟问题](#复制延迟问题)” 一节,我们将更加精确地了解最终一致性,并讨论诸如 **读己之写read-your-writes****单调读monotonic read** 等内容。
## 领导者与追随者
存储了数据库拷贝的每个节点被称为 **副本replica** 。当存在多个副本时,会不可避免的出现一个问题:如何确保所有数据都落在了所有的副本上?
每一次向数据库的写入操作都需要传播到所有副本上,否则副本就会包含不一样的数据。最常见的解决方案被称为 **基于领导者的复制leader-based replication** (也称 **主动/被动active/passive** 复制或 **主/从master/slave** 复制),如 [图 5-1](#fig5-1.png) 所示。它的工作原理如下:
1. 其中一个副本被指定为 **领导者leader**,也称为 **主库master|primary** 。当客户端要向数据库写入时,它必须将请求发送给该 **领导者**,其会将新数据写入其本地存储。
2. 其他副本被称为 **追随者followers**,亦称为 **只读副本read replicas**、**从库slaves**、**备库( secondaries** 或 **热备hot-standby**[^i]。每当领导者将新数据写入本地存储时,它也会将数据变更发送给所有的追随者,称之为 **复制日志replication log****变更流change stream**。每个跟随者从领导者拉取日志,并相应更新其本地数据库副本,方法是按照与领导者相同的处理顺序来进行所有写入。
3. 当客户想要从数据库中读取数据时,它可以向领导者或任一追随者进行查询。但只有领导者才能接受写入操作(从客户端的角度来看从库都是只读的)。
[^i]: 不同的人对 **热hot**、**温warm** 和 **冷cold** 备份服务器有不同的定义。例如在 PostgreSQL 中,**热备hot standby** 指的是能接受客户端读请求的副本。而 **温备warm standby** 只是追随领导者,但不处理客户端的任何查询。就本书而言,这些差异并不重要。
![](/v1/ddia_0501.png)
**图 5-1 基于领导者的(主/从)复制**
这种复制模式是许多关系数据库的内置功能,如 PostgreSQL从 9.0 版本开始、MySQL、Oracle Data Guard【2】和 SQL Server 的 AlwaysOn 可用性组【3】。它也被用于一些非关系数据库包括 MongoDB、RethinkDB 和 Espresso【4】。最后基于领导者的复制并不仅限于数据库像 Kafka【5】和 RabbitMQ 高可用队列【6】这样的分布式消息代理也使用它。某些网络文件系统例如 DRBD 这样的块复制设备也与之类似。
### 同步复制与异步复制
复制系统的一个重要细节是:复制是 **同步synchronously** 发生的还是 **异步asynchronously** 发生的。(在关系型数据库中这通常是一个配置项,其他系统则通常硬编码为其中一个)。
想象一下 [图 5-1](fig5-1.png) 中发生的场景,即网站的用户更新他们的个人头像。在某个时间点,客户向主库发送更新请求;不久之后主库就收到了请求。在某个时间点,主库又会将数据变更转发给自己的从库。最终,主库通知客户更新成功。
[图 5-2](/v1/ddia_0502.png) 显示了系统各个组件之间的通信:用户客户端、主库和两个从库。时间从左向右流动。请求或响应消息用粗箭头表示。
![](/v1/ddia_0502.png)
**图 5-2 基于领导者的复制:一个同步从库和一个异步从库**
在 [图 5-2](/v1/ddia_0502.png) 的示例中,从库 1 的复制是同步的:在向用户报告写入成功并使结果对其他用户可见之前,主库需要等待从库 1 的确认,确保从库 1 已经收到写入操作。而从库 2 的复制是异步的:主库发送消息,但不等待该从库的响应。
在这幅图中,从库 2 处理消息前存在一个显著的延迟。通常情况下,复制的速度相当快:大多数数据库系统能在不到一秒内完成从库的同步,但它们不能提供复制用时的保证。有些情况下,从库可能落后主库几分钟或更久,例如:从库正在从故障中恢复,系统正在最大容量附近运行,或者当节点间存在网络问题时。
同步复制的优点是,从库能保证有与主库一致的最新数据副本。如果主库突然失效,我们可以确信这些数据仍然能在从库上找到。缺点是,如果同步从库没有响应(比如它已经崩溃,或者出现网络故障,或其它任何原因),主库就无法处理写入操作。主库必须阻止所有写入,并等待同步副本再次可用。
因此,将所有从库都设置为同步的是不切实际的:任何一个节点的中断都会导致整个系统停滞不前。实际上,如果在数据库上启用同步复制,通常意味着其中 **一个** 从库是同步的,而其他的从库则是异步的。如果该同步从库变得不可用或缓慢,则将一个异步从库改为同步运行。这保证你至少在两个节点上拥有最新的数据副本:主库和同步从库。这种配置有时也被称为 **半同步semi-synchronous**【7】。
通常情况下,基于领导者的复制都配置为完全异步。在这种情况下,如果主库失效且不可恢复,则任何尚未复制给从库的写入都会丢失。这意味着即使已经向客户端确认成功,写入也不能保证是 **持久Durable** 的。然而,一个完全异步的配置也有优点:即使所有的从库都落后了,主库也可以继续处理写入。
弱化的持久性可能听起来像是一个坏的折衷,但异步复制其实已经被广泛使用了,特别是在有很多从库的场景下,或者当从库在地理上分布很广的时候。我们将在讨论 “[复制延迟问题](#复制延迟问题)” 时回到这个问题。
> ### 关于复制的研究
>
> 对于异步复制系统而言,主库故障时会丢失数据可能是一个严重的问题,因此研究人员仍在研究不丢数据但仍能提供良好性能和可用性的复制方法。例如,**链式复制chain replication**【8,9】是同步复制的一种变体已经在一些系统如 Microsoft Azure Storage【10,11】中成功实现。
>
> 复制的一致性与 **共识**consensus使几个节点就某个值达成一致之间有着密切的联系[第九章](/v1/ch9) 将详细地探讨这一领域的理论。本章主要讨论实践中的数据库常用的简单复制形式。
>
### 设置新从库
有时候需要设置一个新的从库:也许是为了增加副本的数量,或替换失败的节点。如何确保新的从库拥有主库数据的精确副本?
简单地将数据文件从一个节点复制到另一个节点通常是不够的:客户端不断向数据库写入数据,数据总是在不断地变化,标准的文件复制会看到数据库的不同部分在不同的时间点的内容,其结果可能没有任何意义。
可以通过锁定数据库(使其不可用于写入)来使磁盘上的文件保持一致,但是这会违背高可用的目标。幸运的是,设置新从库通常并不需要停机。从概念上讲,其过程如下所示:
1. 在某个时刻获取主库的一致性快照(如果可能,不必锁定整个数据库)。大多数数据库都具有这个功能,因为它是备份必需的。对于某些场景,可能需要第三方工具,例如用于 MySQL 的 innobackupex【12】。
2. 将快照复制到新的从库节点。
3. 从库连接到主库,并拉取快照之后发生的所有数据变更。这要求快照与主库复制日志中的位置精确关联。该位置有不同的名称,例如 PostgreSQL 将其称为 **日志序列号log sequence numberLSN**MySQL 将其称为 **二进制日志坐标binlog coordinates**
4. 当从库处理完快照之后积累的数据变更,我们就说它 **赶上caught up** 了主库,现在它可以继续及时处理主库产生的数据变化了。
建立从库的实际步骤因数据库而异。在某些系统中,这个过程是完全自动化的,而在另外一些系统中,它可能是一个需要由管理员手动执行的、有点神秘的多步骤工作流。
### 处理节点宕机
系统中的任何节点都可能宕机,可能因为意外的故障,也可能由于计划内的维护(例如,重启机器以安装内核安全补丁)。对运维而言,能在系统不中断服务的情况下重启单个节点好处多多。我们的目标是,即使个别节点失效,也能保持整个系统运行,并尽可能控制节点停机带来的影响。
如何通过基于领导者的复制实现高可用?
#### 从库失效:追赶恢复
在其本地磁盘上,每个从库记录从主库收到的数据变更。如果从库崩溃并重新启动,或者,如果主库和从库之间的网络暂时中断,则比较容易恢复:从库可以从日志中知道,在发生故障之前处理的最后一个事务。因此,从库可以连接到主库,并请求在从库断开期间发生的所有数据变更。当应用完所有这些变更后,它就赶上了主库,并可以像以前一样继续接收数据变更流。
#### 主库失效:故障切换
主库失效处理起来相当棘手:其中一个从库需要被提升为新的主库,需要重新配置客户端,以将它们的写操作发送给新的主库,其他从库需要开始拉取来自新主库的数据变更。这个过程被称为 **故障切换failover**
故障切换可以手动进行(通知管理员主库挂了,并采取必要的步骤来创建新的主库)或自动进行。自动的故障切换过程通常由以下步骤组成:
1. 确认主库失效。有很多事情可能会出错:崩溃、停电、网络问题等等。没有万无一失的方法来检测出现了什么问题,所以大多数系统只是简单使用 **超时Timeout** :节点频繁地相互来回传递消息,如果一个节点在一段时间内(例如 30 秒)没有响应,就认为它挂了(因为计划内维护而故意关闭主库不算)。
2. 选择一个新的主库。这可以通过选举过程(主库由剩余副本以多数选举产生)来完成,或者可以由之前选定的 **控制器节点controller node** 来指定新的主库。主库的最佳人选通常是拥有旧主库最新数据副本的从库(以最小化数据损失)。让所有的节点同意一个新的领导者,是一个 **共识** 问题,将在 [第九章](/v1/ch9) 详细讨论。
3. 重新配置系统以启用新的主库。客户端现在需要将它们的写请求发送给新主库(将在 “[请求路由](/v1/ch6#请求路由)” 中讨论这个问题)。如果旧主库恢复,可能仍然认为自己是主库,而没有意识到其他副本已经让它失去领导权了。系统需要确保旧主库意识到新主库的存在,并成为一个从库。
故障切换的过程中有很多地方可能出错:
* 如果使用异步复制,则新主库可能没有收到老主库宕机前最后的写入操作。在选出新主库后,如果老主库重新加入集群,又该如何处理这些老主库尚未复制的写入?在此期间,新主库可能已经收到了与老主库尚未复制的写入相冲突的写入。最常见的解决方案是简单丢弃老主库未复制的写入,这很可能打破客户对于数据持久性的期望。
* 如果数据库需要和其他外部存储相协调,那么丢弃写入内容是极其危险的操作。例如在 GitHub 【13】的一场事故中一个过时的 MySQL 从库被提升为主库。数据库使用自增 ID 作为主键,因为新主库的计数器落后于老主库的计数器,所以新主库重新分配了一些已经被老主库分配掉的 ID 作为主键。这些主键也在 Redis 中使用,主键重用使得 MySQL 和 Redis 中的数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中。
* 发生某些故障时(见 [第八章](/v1/ch8))可能会出现两个节点都以为自己是主库的情况。这种情况称为 **脑裂split brain**,非常危险:如果两个主库都可以接受写操作,却没有冲突解决机制(请参阅 “[多主复制](#多主复制)”),那么数据就可能丢失或损坏。一些系统采取了安全防范措施:当检测到两个主库节点同时存在时会关闭其中一个节点 [^ii]但设计粗糙的机制可能最后会导致两个节点都被关闭【14】。
[^ii]: 这种机制称为 **屏障fencing**,或者更充满感情的术语是:**爆彼之头Shoot The Other Node In The Head, STONITH**。我们将在 “[领导者和锁](/v1/ch8#领导者和锁)” 中对屏障进行详细讨论。
* 主库被宣告死亡之前的正确超时应该怎么配置?在主库失效的情况下,超时时间越长意味着恢复时间也越长。但是如果超时设置太短,又可能会出现不必要的故障切换。例如,临时的负载峰值可能导致节点的响应时间增加到超出超时时间,或者网络故障也可能导致数据包延迟。如果系统已经处于高负载或网络问题的困扰之中,那么不必要的故障切换可能会让情况变得更糟糕。
这些问题没有简单的解决方案。因此,即使软件支持自动故障切换,不少运维团队还是更愿意手动执行故障切换。
节点故障、不可靠的网络、对副本一致性、持久性、可用性和延迟的权衡,这些问题实际上是分布式系统中的基本问题。[第八章](/v1/ch8) 和 [第九章](/v1/ch9) 将更深入地讨论它们。
### 复制日志的实现
基于领导者的复制在底层是如何工作的?实践中有好几种不同的复制方式,所以先简要地看一下。
#### 基于语句的复制
在最简单的情况下,主库记录下它执行的每个写入请求(**语句**,即 statement并将该语句日志发送给从库。对于关系数据库来说这意味着每个 `INSERT`、`UPDATE` 或 `DELETE` 语句都被转发给每个从库,每个从库解析并执行该 SQL 语句,就像直接从客户端收到一样。
虽然听上去很合理,但有很多问题会搞砸这种复制方式:
* 任何调用 **非确定性函数nondeterministic** 的语句,可能会在每个副本上生成不同的值。例如,使用 `NOW()` 获取当前日期时间,或使用 `RAND()` 获取一个随机数。
* 如果语句使用了 **自增列auto increment**,或者依赖于数据库中的现有数据(例如,`UPDATE ... WHERE <某些条件>`),则必须在每个副本上按照完全相同的顺序执行它们,否则可能会产生不同的效果。当有多个并发执行的事务时,这可能成为一个限制。
* 有副作用的语句(例如:触发器、存储过程、用户定义的函数)可能会在每个副本上产生不同的副作用,除非副作用是绝对确定性的。
的确有办法绕开这些问题 —— 例如,当语句被记录时,主库可以用固定的返回值替换掉任何不确定的函数调用,以便所有从库都能获得相同的值。但是由于边缘情况实在太多了,现在通常会选择其他的复制方法。
基于语句的复制在 5.1 版本前的 MySQL 中被使用到。因为它相当紧凑现在有时候也还在用。但现在在默认情况下如果语句中存在任何不确定性MySQL 会切换到基于行的复制稍后讨论。VoltDB 使用了基于语句的复制但要求事务必须是确定性的以此来保证安全【15】。
#### 传输预写式日志WAL
在 [第三章](/v1/ch3) 中,我们讨论了存储引擎如何在磁盘上表示数据,我们也发现了通常会将写操作追加到日志中:
* 对于日志结构存储引擎(请参阅 “[SSTables 和 LSM 树](/v1/ch3#SSTables和LSM树)”),日志是主要的存储位置。日志段在后台压缩,并进行垃圾回收。
* 对于覆写单个磁盘块的 [B 树](/v1/ch3#B树),每次修改都会先写入 **预写式日志Write Ahead Log, WAL**,以便崩溃后索引可以恢复到一个一致的状态。
在任何一种情况下,该日志都是包含了所有数据库写入的仅追加字节序列。可以使用完全相同的日志在另一个节点上构建副本:除了将日志写入磁盘之外,主库还可以通过网络将其发送给从库。
通过使用这个日志,从库可以构建一个与主库一模一样的数据结构拷贝。
这种复制方法在 PostgreSQL 和 Oracle 等一些产品中被使用到【16】。其主要缺点是日志记录的数据非常底层WAL 包含哪些磁盘块中的哪些字节发生了更改。这使复制与存储引擎紧密耦合。如果数据库将其存储格式从一个版本更改为另一个版本,通常不可能在主库和从库上运行不同版本的数据库软件。
看上去这可能只是一个小的实现细节,但却可能对运维产生巨大的影响。如果复制协议允许从库使用比主库更新的软件版本,则可以先升级从库,然后执行故障切换,使升级后的节点之一成为新的主库,从而允许数据库软件的零停机升级。如果复制协议不允许版本不匹配(传输 WAL 经常出现这种情况),则此类升级需要停机。
#### 逻辑日志复制(基于行)
另一种方法是对复制和存储引擎使用不同的日志格式这样可以将复制日志从存储引擎的内部实现中解耦出来。这种复制日志被称为逻辑日志logical log以将其与存储引擎的物理数据表示区分开来。
关系数据库的逻辑日志通常是以行的粒度来描述对数据库表的写入记录的序列:
* 对于插入的行,日志包含所有列的新值。
* 对于删除的行,日志包含足够的信息来唯一标识被删除的行,这通常是主键,但如果表上没有主键,则需要记录所有列的旧值。
* 对于更新的行,日志包含足够的信息来唯一标识被更新的行,以及所有列的新值(或至少所有已更改的列的新值)。
修改多行的事务会生成多条这样的日志记录后面跟着一条指明事务已经提交的记录。MySQL 的二进制日志当配置为使用基于行的复制时使用了这种方法【17】。
由于逻辑日志与存储引擎的内部实现是解耦的,系统可以更容易地做到向后兼容,从而使主库和从库能够运行不同版本的数据库软件,或者甚至不同的存储引擎。
对于外部应用程序来说逻辑日志格式也更容易解析。如果要将数据库的内容发送到外部系统例如复制到数据仓库进行离线分析或建立自定义索引和缓存【18】这一点会很有用。这种技术被称为 **数据变更捕获change data capture**[第十一章](/v1/ch11) 将重新讲到它。
#### 基于触发器的复制
到目前为止描述的复制方法是由数据库系统实现的,不涉及任何应用程序代码。在很多情况下,这就是你想要的。但在某些情况下需要更多的灵活性。例如,如果你只想复制数据的一个子集,或者想从一种数据库复制到另一种数据库,或者如果你需要冲突解决逻辑(请参阅 “[处理写入冲突](#处理写入冲突)”),则可能需要将复制操作上移到应用程序层。
一些工具,如 Oracle Golden Gate【19】可以通过读取数据库日志使得其他应用程序可以使用数据。另一种方法是使用许多关系数据库自带的功能触发器和存储过程。
触发器允许你将数据更改写入事务发生时自动执行的自定义应用程序代码注册在数据库系统中。触发器有机会将更改记录到一个单独的表中使用外部程序读取这个表再加上一些必要的业务逻辑就可以将数据变更复制到另一个系统去。例如Databus for Oracle【20】和 Bucardo for Postgres【21】就是这样工作的。
基于触发器的复制通常比其他复制方法具有更高的开销,并且比数据库内置的复制更容易出错,也有很多限制。然而由于其灵活性,它仍然是很有用的。
## 复制延迟问题
容忍节点故障只是需要复制的一个原因。正如在 [第二部分](/part-ii) 的介绍中提到的,其它原因还包括可伸缩性(处理比单个机器更多的请求)和延迟(让副本在地理位置上更接近用户)。
基于领导者的复制要求所有写入都由单个节点处理但只读查询可以由任何一个副本来处理。所以对于读多写少的场景Web 上的常见模式),一个有吸引力的选择是创建很多从库,并将读请求分散到所有的从库上去。这样能减小主库的负载,并允许由附近的副本来处理读请求。
在这种读伸缩read-scaling的体系结构中只需添加更多的从库就可以提高只读请求的服务容量。但是这种方法实际上只适用于异步复制 —— 如果尝试同步复制到所有从库,则单个节点故障或网络中断将导致整个系统都无法写入。而且节点越多越有可能出现个别节点宕机的情况,所以完全同步的配置将是非常不可靠的。
不幸的是,当应用程序从异步从库读取时,如果从库落后,它可能会看到过时的信息。这会导致数据库中出现明显的不一致:同时对主库和从库执行相同的查询,可能得到不同的结果,因为并非所有的写入都反映在从库中。这种不一致只是一个暂时的状态 —— 如果停止写入数据库并等待一段时间,从库最终会赶上并与主库保持一致。出于这个原因,这种效应被称为 **最终一致性eventual consistency**【22,23】。[^iii]
[^iii]: 道格拉斯・特里Douglas Terry等人【24】创造了最终一致性这个术语并经由 Werner Vogels【22】的推广成为了许多 NoSQL 项目的口号。然而,最终一致性并不只属于 NoSQL 数据库:关系型数据库中的异步复制从库也有相同的特性。
最终一致性中的 “最终” 一词有意进行了模糊化:总的来说,副本落后的程度是没有限制的。在正常的操作中,**复制延迟replication lag**,即写入主库到反映至从库之间的延迟,可能仅仅是几分之一秒,在实践中并不显眼。但如果系统在接近极限的情况下运行,或网络中存在问题时,延迟可以轻而易举地超过几秒,甚至达到几分钟。
因为滞后时间太长引入的不一致性,不仅仅是一个理论问题,更是应用设计中会遇到的真实问题。本节将重点介绍三个在复制延迟时可能发生的问题实例,并简述解决这些问题的一些方法。
### 读己之写
许多应用让用户提交一些数据,然后查看他们提交的内容。可能是用户数据库中的记录,也可能是对讨论主题的评论,或其他类似的内容。提交新数据时,必须将其发送给主库,但是当用户查看数据时,可以通过从库进行读取。如果数据经常被查看,但只是偶尔写入,这是非常合适的。
但对于异步复制,问题就来了。如 [图 5-3](fig5-3.png) 所示:如果用户在写入后马上就查看数据,则新数据可能尚未到达副本。对用户而言,看起来好像是刚提交的数据丢失了,所以他们不高兴是可以理解的。
![](/v1/ddia_0503.png)
**图 5-3 用户写入后从旧副本中读取数据。需要写后读 (read-after-write) 的一致性来防止这种异常**
在这种情况下,我们需要 **写后读一致性read-after-write consistency**,也称为 **读己之写一致性read-your-writes consistency**【24】。这是一个保证如果用户重新加载页面他们总会看到他们自己提交的任何更新。它不会对其他用户的写入做出承诺其他用户的更新可能稍等才会看到。它保证用户自己的输入已被正确保存。
如何在基于领导者的复制系统中实现写后读一致性?有各种可能的技术,这里说一些:
* 对于用户 **可能修改过** 的内容,总是从主库读取;这就要求得有办法不通过实际的查询就可以知道用户是否修改了某些东西。举个例子,社交网络上的用户个人资料信息通常只能由用户本人编辑,而不能由其他人编辑。因此一个简单的规则就是:总是从主库读取用户自己的档案,如果要读取其他用户的档案就去从库。
* 如果应用中的大部分内容都可能被用户编辑,那这种方法就没用了,因为大部分内容都必须从主库读取(读伸缩就没效果了)。在这种情况下可以使用其他标准来决定是否从主库读取。例如可以跟踪上次更新的时间,在上次更新后的一分钟内,从主库读。还可以监控从库的复制延迟,防止向任何滞后主库超过一分钟的从库发出查询。
* 客户端可以记住最近一次写入的时间戳,系统需要确保从库在处理该用户的读取请求时,该时间戳前的变更都已经传播到了本从库中。如果当前从库不够新,则可以从另一个从库读取,或者等待从库追赶上来。这里的时间戳可以是逻辑时间戳(表示写入顺序的东西,例如日志序列号)或实际的系统时钟(在这种情况下,时钟同步变得至关重要,请参阅 “[不可靠的时钟](/v1/ch8#不可靠的时钟)”)。
* 如果你的副本分布在多个数据中心(为了在地理上接近用户或者出于可用性目的),还会有额外的复杂性。任何需要由主库提供服务的请求都必须路由到包含该主库的数据中心。
另一种复杂的情况发生在同一位用户从多个设备(例如桌面浏览器和移动 APP请求服务的时候。这种情况下可能就需要提供跨设备的写后读一致性如果用户在一个设备上输入了一些信息然后在另一个设备上查看则应该看到他们刚输入的信息。
在这种情况下,还有一些需要考虑的问题:
* 记住用户上次更新时间戳的方法变得更加困难,因为一个设备上运行的程序不知道另一个设备上发生了什么。需要对这些元数据进行中心化的存储。
* 如果副本分布在不同的数据中心,很难保证来自不同设备的连接会路由到同一数据中心。(例如,用户的台式计算机使用家庭宽带连接,而移动设备使用蜂窝数据网络,则设备的网络路由可能完全不同)。如果你的方法需要读主库,可能首先需要把来自该用户所有设备的请求都路由到同一个数据中心。
### 单调读
在从异步从库读取时可能发生的异常的第二个例子是用户可能会遇到 **时光倒流moving backward in time**
如果用户从不同从库进行多次读取,就可能发生这种情况。例如,[图 5-4](/v1/ddia_0504.png) 显示了用户 2345 两次进行相同的查询,首先查询了一个延迟很小的从库,然后是一个延迟较大的从库(如果用户刷新网页时每个请求都被路由到一个随机的服务器,这种情况就很有可能发生)。第一个查询返回了最近由用户 1234 添加的评论,但是第二个查询不返回任何东西,因为滞后的从库还没有拉取到该写入内容。实际上可以认为第二个查询是在比第一个查询更早的时间点上观察系统。如果第一个查询没有返回任何内容,那问题并不大,因为用户 2345 可能不知道用户 1234 最近添加了评论。但如果用户 2345 先看见用户 1234 的评论,然后又看到它消失,这就会让人觉得非常困惑了。
![](/v1/ddia_0504.png)
**图 5-4 用户首先从新副本读取,然后从旧副本读取。时间看上去回退了。为了防止这种异常,我们需要单调的读取。**
**单调读monotonic reads**【23】可以保证这种异常不会发生。这是一个比 **强一致性strong consistency** 更弱,但比 **最终一致性eventual consistency** 更强的保证。当读取数据时,你可能会看到一个旧值;单调读仅意味着如果一个用户顺序地进行多次读取,则他们不会看到时间回退,也就是说,如果已经读取到较新的数据,后续的读取不会得到更旧的数据。
实现单调读的一种方式是确保每个用户总是从同一个副本进行读取(不同的用户可以从不同的副本读取)。例如,可以基于用户 ID 的散列来选择副本,而不是随机选择副本。但是,如果该副本出现故障,用户的查询将需要重新路由到另一个副本。
### 一致前缀读
第三个复制延迟异常的例子违反了因果律。想象一下 Poons 先生和 Cake 夫人之间的以下简短对话:
*Mr. Poons*
> Mrs. Cake你能看到多远的未来
*Mrs. Cake*
> 通常约十秒钟Mr. Poons.
这两句话之间有因果关系Cake 夫人听到了 Poons 先生的问题并回答了这个问题。
现在想象第三个人正在通过从库来听这个对话。Cake 夫人说的内容是从一个延迟很低的从库读取的,但 Poons 先生所说的内容,从库的延迟要大的多(见 [图 5-5](/v1/ddia_0505.png))。于是,这个观察者会听到以下内容:
*Mrs. Cake*
> 通常约十秒钟Mr. Poons.
*Mr. Poons*
> Mrs. Cake你能看到多远的未来
对于观察者来说,看起来好像 Cake 夫人在 Poons 先生提问前就回答了这个问题。这种超能力让人印象深刻但也会把人搞糊涂。【25】。
![](/v1/ddia_0505.png)
**图 5-5 如果某些分区的复制速度慢于其他分区,那么观察者可能会在看到问题之前先看到答案。**
要防止这种异常,需要另一种类型的保证:**一致前缀读consistent prefix reads**【23】。这个保证的意思是说如果一系列写入按某个顺序发生那么任何人读取这些写入时也会看见它们以同样的顺序出现。
这是 **分区partitioned****分片sharded** 数据库中的一个特殊问题,我们将在 [第六章](/v1/ch6) 中讨论分区数据库。如果数据库总是以相同的顺序应用写入,而读取总是看到一致的前缀,那么这种异常不会发生。但是在许多分布式数据库中,不同的分区独立运行,因此不存在 **全局的写入顺序**:当用户从数据库中读取数据时,可能会看到数据库的某些部分处于较旧的状态,而某些则处于较新的状态。
一种解决方案是,确保任何因果相关的写入都写入相同的分区,但在一些应用中可能无法高效地完成这种操作。还有一些显式跟踪因果依赖关系的算法,我们将在 “[“此前发生” 的关系和并发](#“此前发生”的关系和并发)” 一节中回到这个话题。
### 复制延迟的解决方案
在使用最终一致的系统时,如果复制延迟增加到几分钟甚至几小时,则应该考虑应用程序的行为。如果答案是 “没问题”,那很好。但如果结果对于用户来说是不好的体验,那么设计系统来提供更强的保证(例如 **写后读**)是很重要的。明明是异步复制却假设复制是同步的,这是很多麻烦的根源。
如前所述,应用程序可以提供比底层数据库更强有力的保证,例如通过主库进行某种读取。但在应用程序代码中处理这些问题是复杂的,容易出错。
如果应用程序开发人员不必担心微妙的复制问题,并可以信赖他们的数据库 “做了正确的事情”,那该多好呀。这就是 **事务transaction** 存在的原因:**数据库通过事务提供强大的保证**,所以应用程序可以更加简单。
单节点事务已经存在了很长时间。然而在走向分布式(复制和分区)数据库时,许多系统放弃了事务,声称事务在性能和可用性上的代价太高,并断言在可伸缩系统中最终一致性是不可避免的。这个叙述有一些道理,但过于简单了,本书其余部分将提出更为细致的观点。我们将在 [第七章](/v1/ch7) 和 [第九章](/v1/ch9) 回到事务的话题,并将在 [第三部分](/part-iii) 讨论一些替代机制。
## 多主复制
本章到目前为止,我们只考虑了使用单个主库的复制架构。虽然这是一种常见的方法,但还有其它一些有趣的选择。
基于领导者的复制有一个主要的缺点:只有一个主库,而且所有的写入都必须通过它 [^iv]。如果出于任何原因(例如和主库之间的网络连接中断)无法连接到主库,就无法向数据库写入。
[^iv]: 如果数据库被分区(见 [第六章](/v1/ch6)),每个分区都有一个主库。不同的分区的主库可能在不同的节点上,但是每个分区都必须有一个主库。
基于领导者的复制模型的自然延伸是允许多个节点接受写入。复制仍然以同样的方式发生:处理写入的每个节点都必须将该数据变更转发给所有其他节点。我们将其称之为 **多领导者配置**multi-leader configuration也称多主、多活复制即 master-master replication 或 active/active replication。在这种情况下每个主库同时是其他主库的从库。
### 多主复制的应用场景
在单个数据中心内部使用多个主库的配置没有太大意义,因为其导致的复杂性已经超过了能带来的好处。但在一些情况下,这种配置也是合理的。
#### 运维多个数据中心
假如你有一个数据库,副本分散在好几个不同的数据中心(可能会用来容忍单个数据中心的故障,或者为了在地理上更接近用户)。如果使用常规的基于领导者的复制设置,主库必须位于其中一个数据中心,且所有写入都必须经过该数据中心。
多主配置中可以在每个数据中心都有主库。[图 5-6](/v1/ddia_0506.png) 展示了这个架构。在每个数据中心内使用常规的主从复制;在数据中心之间,每个数据中心的主库都会将其更改复制到其他数据中心的主库中。
![](/v1/ddia_0506.png)
**图 5-6 跨多个数据中心的多主复制**
我们来比较一下在运维多个数据中心时,单主和多主的适应情况:
* 性能
在单主配置中,每个写入都必须穿过互联网,进入主库所在的数据中心。这可能会增加写入时间,并可能违背了设置多个数据中心的初心。在多主配置中,每个写操作都可以在本地数据中心进行处理,并与其他数据中心异步复制。因此,数据中心之间的网络延迟对用户来说是透明的,这意味着感觉到的性能可能会更好。
* 容忍数据中心停机
在单主配置中,如果主库所在的数据中心发生故障,故障切换必须使另一个数据中心里的从库成为主库。在多主配置中,每个数据中心可以独立于其他数据中心继续运行,并且当发生故障的数据中心归队时,复制会自动赶上。
* 容忍网络问题
数据中心之间的通信通常穿过公共互联网,这可能不如数据中心内的本地网络可靠。单主配置对数据中心之间的连接问题非常敏感,因为通过这个连接进行的写操作是同步的。采用异步复制功能的多主配置通常能更好地承受网络问题:临时的网络中断并不会妨碍正在处理的写入。
有些数据库默认情况下支持多主配置,但使用外部工具实现也很常见,例如用于 MySQL 的 Tungsten Replicator 【26】用于 PostgreSQL 的 BDR【27】以及用于 Oracle 的 GoldenGate 【19】。
尽管多主复制有这些优势,但也有一个很大的缺点:两个不同的数据中心可能会同时修改相同的数据,写冲突是必须解决的(如 [图 5-6](/v1/ddia_0506.png) 中的 “冲突解决conflict resolution。本书将在 “[处理写入冲突](#处理写入冲突)” 中详细讨论这个问题。
由于多主复制在许多数据库中都属于改装的功能所以常常存在微妙的配置缺陷且经常与其他数据库功能之间出现意外的反应。比如自增主键、触发器、完整性约束等都可能会有麻烦。因此多主复制往往被认为是危险的领域应尽可能避免【28】。
#### 需要离线操作的客户端
多主复制的另一种适用场景是:应用程序在断网之后仍然需要继续工作。
例如,考虑手机,笔记本电脑和其他设备上的日历应用。无论设备目前是否有互联网连接,你需要能随时查看你的会议(发出读取请求),输入新的会议(发出写入请求)。如果在离线状态下进行任何更改,则设备下次上线时,需要与服务器和其他设备同步。
在这种情况下,每个设备都有一个充当主库的本地数据库(它接受写请求),并且在所有设备上的日历副本之间同步时,存在异步的多主复制过程。复制延迟可能是几小时甚至几天,具体取决于何时可以访问互联网。
从架构的角度来看,这种设置实际上与数据中心之间的多主复制类似,每个设备都是一个 “数据中心”,而它们之间的网络连接是极度不可靠的。从历史上各类日历同步功能的破烂实现可以看出,想把多主复制用好是多么困难的一件事。
有一些工具旨在使这种多主配置更容易。例如CouchDB 就是为这种操作模式而设计的【29】。
#### 协同编辑
实时协作编辑应用程序允许多个人同时编辑文档。例如Etherpad 【30】和 Google Docs 【31】允许多人同时编辑文本文档或电子表格该算法在 “[自动冲突解决](#自动冲突解决)” 中简要讨论。我们通常不会将协作式编辑视为数据库复制问题但它与前面提到的离线编辑用例有许多相似之处。当一个用户编辑文档时所做的更改将立即应用到其本地副本Web 浏览器或客户端应用程序中的文档状态),并异步复制到服务器和编辑同一文档的任何其他用户。
如果要保证不会发生编辑冲突,则应用程序必须先取得文档的锁定,然后用户才能对其进行编辑。如果另一个用户想要编辑同一个文档,他们首先必须等到第一个用户提交修改并释放锁定。这种协作模式相当于主从复制模型下在主节点上执行事务操作。
但是为了加速协作你可能希望将更改的单位设置得非常小例如单次按键并避免锁定。这种方法允许多个用户同时进行编辑但同时也带来了多主复制的所有挑战包括需要解决冲突【32】。
### 处理写入冲突
多主复制的最大问题是可能发生写冲突,这意味着需要解决冲突。
例如,考虑一个由两个用户同时编辑的维基页面,如 [图 5-7](/v1/ddia_0507.png) 所示。用户 1 将页面的标题从 A 更改为 B并且用户 2 同时将标题从 A 更改为 C。每个用户的更改已成功应用到其本地主库。但当异步复制时会发现冲突【33】。单主数据库中不会出现此问题。
![](/v1/ddia_0507.png)
**图 5-7 两个主库同时更新同一记录引起的写入冲突**
#### 同步与异步冲突检测
在单主数据库中,第二个写入将被阻塞并等待第一个写入完成,或者中止第二个写入事务并强制用户重试。另一方面,在多主配置中,两个写入都是成功的,在稍后的某个时间点才能异步地检测到冲突。那时再来要求用户解决冲突可能为时已晚。
原则上,可以使冲突检测同步 - 即等待写入被复制到所有副本,然后再告诉用户写入成功。但是,通过这样做,你将失去多主复制的主要优点:允许每个副本独立地接受写入。如果你想要同步冲突检测,那么你可能不如直接使用单主复制。
#### 避免冲突
处理冲突的最简单的策略就是避免它们如果应用程序可以确保特定记录的所有写入都通过同一个主库那么冲突就不会发生。由于许多的多主复制实现在处理冲突时处理得相当不好避免冲突是一个经常被推荐的方法【34】。
例如,在一个用户可以编辑自己数据的应用程序中,可以确保来自特定用户的请求始终路由到同一数据中心,并使用该数据中心的主库进行读写。不同的用户可能有不同的 “主” 数据中心(可能根据用户的地理位置选择),但从任何一位用户的角度来看,本质上就是单主配置了。
但是,有时你可能需要更改被指定的主库 —— 可能是因为某个数据中心出现故障,你需要将流量重新路由到另一个数据中心,或者可能是因为用户已经迁移到另一个位置,现在更接近其它的数据中心。在这种情况下,冲突避免将失效,你必须处理不同主库同时写入的可能性。
#### 收敛至一致的状态
单主数据库按顺序进行写操作:如果同一个字段有多个更新,则最后一个写操作将决定该字段的最终值。
在多主配置中,没有明确的写入顺序,所以最终值应该是什么并不清楚。在 [图 5-7](/v1/ddia_0507.png) 中,在主库 1 中标题首先更新为 B 而后更新为 C在主库 2 中,首先更新为 C然后更新为 B。两种顺序都不比另一种“更正确”。
如果每个副本只是按照它看到写入的顺序写入,那么数据库最终将处于不一致的状态:最终值将是在主库 1 的 C 和主库 2 的 B。这是不可接受的每个复制方案都必须确保数据最终在所有副本中都是相同的。因此数据库必须以一种 **收敛convergent** 的方式解决冲突,这意味着所有副本必须在所有变更复制完成时收敛至一个相同的最终值。
实现冲突合并解决有多种途径:
* 给每个写入一个唯一的 ID例如时间戳、长随机数、UUID 或者键和值的哈希),挑选最高 ID 的写入作为胜利者,并丢弃其他写入。如果使用时间戳,这种技术被称为 **最后写入胜利LWW, last write wins**。虽然这种方法很流行但是很容易造成数据丢失【35】。我们将在本章末尾的 [检测并发写入](#检测并发写入) 一节更详细地讨论 LWW。
* 为每个副本分配一个唯一的 IDID 编号更高的写入具有更高的优先级。这种方法也意味着数据丢失。
* 以某种方式将这些值合并在一起 - 例如,按字母顺序排序,然后连接它们(在 [图 5-7](/v1/ddia_0507.png) 中,合并的标题可能类似于 “B/C”
* 用一种可保留所有信息的显式数据结构来记录冲突,并编写解决冲突的应用程序代码(也许通过提示用户的方式)。
#### 自定义冲突解决逻辑
解决冲突的最合适的方法可能取决于应用程序,大多数多主复制工具允许使用应用程序代码编写冲突解决逻辑。该代码可以在写入或读取时执行:
写时执行
: 只要数据库系统检测到复制更改日志中存在冲突就会调用冲突处理程序。例如Bucardo 允许你为此编写一段 Perl 代码。这个处理程序通常不能提示用户 —— 它在后台进程中运行,并且必须快速执行。
读时执行
: 当检测到冲突时,所有冲突写入被存储。下一次读取数据时,会将这些多个版本的数据返回给应用程序。应用程序可以提示用户或自动解决冲突,并将结果写回数据库。例如 CouchDB 就以这种方式工作。
请注意冲突解决通常适用于单行记录或单个文档的层面而不是整个事务【36】。因此如果你有一个事务会原子性地进行几次不同的写入请参阅 [第七章](/v1/ch7)),对于冲突解决而言,每个写入仍需分开单独考虑。
> #### 自动冲突解决
>
> 冲突解决规则可能很容易变得越来越复杂自定义代码可能也很容易出错。亚马逊是一个经常被引用的例子由于冲突解决处理程序而产生了令人意外的效果一段时间以来购物车上的冲突解决逻辑将保留添加到购物车的物品但不包括从购物车中移除的物品。因此顾客有时会看到物品重新出现在他们的购物车中即使他们之前已经被移走【37】。
>
> 已经有一些有趣的研究来自动解决由于数据修改引起的冲突。有几项研究值得一提:
>
> * **无冲突复制数据类型Conflict-free replicated datatypesCRDT**【32,38】是可以由多个用户同时编辑的集合、映射、有序列表、计数器等一系列数据结构它们以合理的方式自动解决冲突。一些 CRDT 已经在 Riak 2.0 中实现【39,40】。
> * **可合并的持久数据结构Mergeable persistent data structures**【41】显式跟踪历史记录类似于 Git 版本控制系统,并使用三向合并功能(而 CRDT 使用双向合并)。
> * **操作转换operational transformation**[42] 是 Etherpad 【30】和 Google Docs 【31】等协同编辑应用背后的冲突解决算法。它是专为有序列表的并发编辑而设计的例如构成文本文档的字符列表。
>
> 这些算法在数据库中的实现还很年轻,但很可能将来它们会被集成到更多的复制数据系统中。自动冲突解决方案可以使应用程序处理多主数据同步更为简单。
#### 什么是冲突?
有些冲突是显而易见的。在 [图 5-7](/v1/ddia_0507.png) 的例子中,两个写操作并发地修改了同一条记录中的同一个字段,并将其设置为两个不同的值。毫无疑问这是一个冲突。
其他类型的冲突可能更为微妙而难以发现。例如,考虑一个会议室预订系统:它记录谁订了哪个时间段的哪个房间。应用程序需要确保每个房间在任意时刻都只能被一组人进行预定(即不得有相同房间的重叠预订)。在这种情况下,如果为同一个房间同时创建两个不同的预订,则可能会发生冲突。即使应用程序在允许用户进行预订之前先检查会议室的可用性,如果两次预订是由两个不同的主库进行的,则仍然可能会有冲突。
虽然现在还没有一个现成的答案,但在接下来的章节中,我们将更好地了解这个问题。我们将在 [第七章](/v1/ch7) 中看到更多的冲突示例,在 [第十二章](/v1/ch12) 中我们将讨论用于检测和解决复制系统中冲突的可伸缩方法。
### 多主复制拓扑
**复制拓扑**replication topology用来描述写入操作从一个节点传播到另一个节点的通信路径。如果你有两个主库如 [图 5-7](/v1/ddia_0507.png) 所示,只有一个合理的拓扑结构:主库 1 必须把它所有的写入都发送到主库 2反之亦然。当有两个以上的主库多种不同的拓扑都是可能的。[图 5-8](/v1/ddia_0508.png) 举例说明了一些例子。
![](/v1/ddia_0508.png)
**图 5-8 三种可以在多主复制中使用的拓扑示例。**
最常见的拓扑是全部到全部all-to-all如 [图 5-8 (c)](/v1/ddia_0508.png)),其中每个主库都将其写入发送给其他所有的主库。然而,一些更受限的拓扑也会被使用到:例如,默认情况下 MySQL 仅支持 **环形拓扑circular topology**【34】其中每个节点都从一个节点接收写入并将这些写入加上自己的写入转发给另一个节点。另一种流行的拓扑结构具有星形的形状 [^v]:一个指定的根节点将写入转发给所有其他节点。星形拓扑可以推广到树。
[^v]: 不要与星型模式混淆(请参阅 “[星型和雪花型:分析的模式](/v1/ch3#星型和雪花型:分析的模式)”),其中描述了数据模型的结构,而不是节点之间的通信拓扑。
在环形和星形拓扑中写入可能需要在到达所有副本之前通过多个节点。因此节点需要转发从其他节点收到的数据更改。为了防止无限复制循环每个节点被赋予一个唯一的标识符并且在复制日志中每次写入都会使用其经过的所有节点的标识符进行标记【43】。当一个节点收到用自己的标识符标记的数据更改时该数据更改将被忽略因为节点知道它已经被处理过。
环形和星形拓扑的问题是,如果只有一个节点发生故障,则可能会中断其他节点之间的复制消息流,导致它们无法通信,除非节点被修复。拓扑结构可以重新配置为跳过发生故障的节点,但在大多数部署中,这种重新配置必须手动完成。更密集连接的拓扑结构(例如全部到全部)的容错性更好,因为它允许消息沿着不同的路径传播,可以避免单点故障。
另一方面,全部到全部的拓扑也可能有问题。特别是,一些网络链接可能比其他网络链接更快(例如由于网络拥塞),结果是一些复制消息可能 “超越” 其他复制消息,如 [图 5-9](/v1/ddia_0509.png) 所示。
![](/v1/ddia_0509.png)
**图 5-9 使用多主复制时,写入可能会以错误的顺序到达某些副本。**
在 [图 5-9](/v1/ddia_0509.png) 中,客户端 A 向主库 1 的表中插入一行,客户端 B 在主库 3 上更新该行。然而,主库 2 可以以不同的顺序接收写入:它可能先接收到更新(从它的角度来看,是对数据库中不存在的行的更新),稍后才接收到相应的插入(其应该在更新之前)。
这是一个因果关系的问题,类似于我们在 “[一致前缀读](#一致前缀读)” 中看到的:更新取决于先前的插入,所以我们需要确保所有节点先处理插入,然后再处理更新。仅仅在每一次写入时添加一个时间戳是不够的,因为时钟不可能被充分地同步,所以主库 2 就无法正确地对这些事件进行排序(见 [第八章](/v1/ch8))。
要正确排序这些事件,可以使用一种称为 **版本向量version vectors** 的技术,本章稍后将讨论这种技术(请参阅 “[检测并发写入](#检测并发写入)”。然而许多多主复制系统中的冲突检测技术实现得并不好。例如在撰写本文时PostgreSQL BDR 不提供写入的因果排序【27】而 Tungsten Replicator for MySQL 甚至都不做检测冲突【34】。
如果你正在使用基于多主复制的系统,那么你应该多了解这些问题,仔细阅读文档,并彻底测试你的数据库,以确保它确实提供了你想要的保证。
## 无主复制
我们在本章到目前为止所讨论的复制方法 —— 单主复制、多主复制 —— 都是这样的想法:客户端向一个主库发送写请求,而数据库系统负责将写入复制到其他副本。主库决定写入的顺序,而从库按相同顺序应用主库的写入。
一些数据存储系统采用不同的方法,放弃主库的概念,并允许任何副本直接接受来自客户端的写入。最早的一些的复制数据系统是 **无主的leaderless**【1,44】但是在关系数据库主导的时代这个想法几乎已被忘却。在亚马逊将其用于其内部的 Dynamo 系统 [^vi] 之后它再一次成为数据库的一种时尚架构【37】。RiakCassandra 和 Voldemort 是受 Dynamo 启发的无主复制模型的开源数据存储,所以这类数据库也被称为 *Dynamo 风格*
[^vi]: Dynamo 不适用于 Amazon 以外的用户。令人困惑的是AWS 提供了一个名为 DynamoDB 的托管数据库产品,它使用了完全不同的体系结构:它基于单主复制。
在一些无主复制的实现中,客户端直接将写入发送到几个副本中,而另一些情况下,由一个 **协调者coordinator** 节点代表客户端进行写入。但与主库数据库不同,协调者不执行特定的写入顺序。我们将会看到,这种设计上的差异对数据库的使用方式有着深远的影响。
### 当节点故障时写入数据库
假设你有一个带有三个副本的数据库,而其中一个副本目前不可用,或许正在重新启动以安装系统更新。在基于领导者的配置中,如果要继续处理写入,则可能需要执行故障切换(请参阅「[处理节点宕机](#处理节点宕机)」)。
另一方面,在无主配置中,不存在故障转移。[图 5-10](/v1/ddia_0510.png) 演示了会发生了什么事情:客户端(用户 1234并行发送写入到所有三个副本并且两个可用副本接受写入但是不可用副本错过了它。假设三个副本中的两个承认写入是足够的在用户 1234 已经收到两个确定的响应之后,我们认为写入成功。客户简单地忽略了其中一个副本错过了写入的事实。
![](/v1/ddia_0510.png)
**图 5-10 法定写入,法定读取,并在节点中断后读修复。**
现在想象一下,不可用的节点重新联机,客户端开始读取它。节点关闭期间发生的任何写入都不在该节点上。因此,如果你从该节点读取数据,则可能会从响应中拿到陈旧的(过时的)值。
为了解决这个问题,当一个客户端从数据库中读取数据时,它不仅仅把它的请求发送到一个副本:读请求将被并行地发送到多个节点。客户可能会从不同的节点获得不同的响应,即来自一个节点的最新值和来自另一个节点的陈旧值。版本号将被用于确定哪个值是更新的(请参阅 “[检测并发写入](#检测并发写入)”)。
#### 读修复和反熵
复制方案应确保最终将所有数据复制到每个副本。在一个不可用的节点重新联机之后,它如何赶上它错过的写入?
在 Dynamo 风格的数据存储中经常使用两种机制:
读修复Read repair
: 当客户端并行读取多个节点时,它可以检测到任何陈旧的响应。例如,在 [图 5-10](/v1/ddia_0510.png) 中,用户 2345 获得了来自副本 3 的版本 6 值和来自副本 1 和 2 的版本 7 值。客户端发现副本 3 具有陈旧值,并将新值写回到该副本。这种方法适用于读频繁的值。
反熵过程Anti-entropy process
: 此外,一些数据存储具有后台进程,该进程不断查找副本之间的数据差异,并将任何缺少的数据从一个副本复制到另一个副本。与基于领导者的复制中的复制日志不同,此反熵过程不会以任何特定的顺序复制写入,并且在复制数据之前可能会有显著的延迟。
并不是所有的系统都实现了这两种机制例如Voldemort 目前没有反熵过程。请注意,如果没有反熵过程,很少被读取的值可能会从某些副本中丢失,从而降低了持久性,因为只有在应用程序读取值时才执行读修复。
#### 读写的法定人数
在 [图 5-10](/v1/ddia_0510.png) 的示例中,我们认为即使仅在三个副本中的两个上进行处理,写入仍然是成功的。如果三个副本中只有一个接受了写入,会怎样?以此类推,究竟多少个副本完成才可以认为写入成功?
如果我们知道,每个成功的写操作意味着在三个副本中至少有两个出现,这意味着至多有一个副本可能是陈旧的。因此,如果我们从至少两个副本读取,我们可以确定至少有一个是最新的。如果第三个副本停机或响应速度缓慢,则读取仍可以继续返回最新值。
更一般地说,如果有 n 个副本,每个写入必须由 w 个节点确认才能被认为是成功的,并且我们必须至少为每个读取查询 r 个节点。(在我们的例子中,$n = 3w = 2r = 2$)。只要 $w + r > n$,我们可以预期在读取时能获得最新的值,因为 r 个读取中至少有一个节点是最新的。遵循这些 r 值和 w 值的读写称为 **法定人数quorum**[^vii] 的读和写【44】。你可以认为r 和 w 是有效读写所需的最低票数。
[^vii]: 有时候这种法定人数被称为严格的法定人数,其相对 “宽松的法定人数” 而言(见 “[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”)
在 Dynamo 风格的数据库中,参数 n、w 和 r 通常是可配置的。一个常见的选择是使 n 为奇数(通常为 3 或 5并设置 $w = r = (n + 1) / 2$(向上取整)。但是你可以根据需要更改数字。例如,写入次数较少且读取次数较多的工作负载可以从设置 $w = n$ 和 $r = 1$中受益。这会使得读取速度更快,但缺点是只要有一个不可用的节点就会导致所有的数据库写入都失败。
> 集群中可能有多于 n 个的节点(集群的机器数可能多于副本数目)。但是任何给定的值只能存储在 n 个节点上。这允许对数据集进行分区,从而可以支持比单个节点的存储能力更大的数据集。我们将在 [第六章](/v1/ch6) 继续讨论分区。
法定人数条件 $w + r > n$ 允许系统容忍不可用的节点,如下所示:
* 如果 $w < n$当节点不可用时我们仍然可以处理写入
* 如果 $r < n$当节点不可用时我们仍然可以处理读取
* 对于 $n = 3w = 2r = 2$,我们可以容忍一个不可用的节点。
* 对于 $n = 5w = 3r = 3$,我们可以容忍两个不可用的节点。这个案例如 [图 5-11](/v1/ddia_0511.png) 所示。
* 通常,读取和写入操作始终并行发送到所有 n 个副本。参数 w 和 r 决定我们等待多少个节点,即在我们认为读或写成功之前,有多少个节点需要报告成功。
![](/v1/ddia_0511.png)
**图 5-11 如果 $w + r > n$,读取 r 个副本,至少有一个副本必然包含了最近的成功写入。**
如果可用的节点少于所需的 w 或 r则写入或读取将返回错误。节点可能由于多种原因而不可用比如节点关闭异常崩溃电源关闭、操作执行过程中的错误由于磁盘已满而无法写入、客户端和服务器节点之间的网络中断或任何其他原因。我们只需要关心节点是否返回了成功的响应而不需要区分不同类型的错误。
### 法定人数一致性的局限性
如果你有 n 个副本,并且你选择了满足 $w + r > n$ 的 w 和 r你通常可以期望每次读取都能返回最近写入的值。情况就是这样因为你写入的节点集合和你读取的节点集合必然有重叠。也就是说你读取的节点中必然至少有一个节点具有最新值如 [图 5-11](/v1/ddia_0511.png) 所示)。
通常r 和 w 被选为多数(超过 $n/2$ )节点,因为这确保了 $w + r > n$,同时仍然容忍多达 $n/2$ 个节点故障。但是法定人数不一定必须是大多数重要的是读写使用的节点至少有一个节点的交集。其他法定人数的配置是可能的这使得分布式算法的设计有一定的灵活性【45】。
你也可以将 w 和 r 设置为较小的数字,以使 $w + r ≤ n$(即法定条件不满足)。在这种情况下,读取和写入操作仍将被发送到 n 个节点,但操作成功只需要少量的成功响应。
较小的 w 和 r 更有可能会读取到陈旧的数据,因为你的读取更有可能未包含具有最新值的节点。另一方面,这种配置允许更低的延迟和更高的可用性:如果存在网络中断,并且许多副本变得无法访问,则有更大的机会可以继续处理读取和写入。只有当可达副本的数量低于 w 或 r 时,数据库才变得不可写入或读取。
但是,即使在 $w + r > n$ 的情况下,也可能存在返回陈旧值的边缘情况。这取决于实现,但可能的情况包括:
* 如果使用宽松的法定人数(见 “[宽松的法定人数与提示移交](#宽松的法定人数与提示移交)”w 个写入和 r 个读取有可能落在完全不同的节点上,因此 r 节点和 w 之间不再保证有重叠节点【46】。
* 如果两个写入同时发生,不清楚哪一个先发生。在这种情况下,唯一安全的解决方案是合并并发写入(请参阅 “[处理写入冲突](#处理写入冲突)”。如果根据时间戳最后写入胜利挑选出一个胜者则写入可能由于时钟偏差【35】而丢失。我们将在 “[检测并发写入](#检测并发写入)” 继续讨论此话题。
* 如果写操作与读操作同时发生,写操作可能仅反映在某些副本上。在这种情况下,不确定读取返回的是旧值还是新值。
* 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于 w 个副本上写入成功。所以整体判定写入失败但整体写入失败并没有在写入成功的副本上回滚。这意味着一个写入虽然报告失败后续的读取仍然可能会读取这次失败写入的值【47】。
* 如果携带新值的节点发生故障,需要从其他带有旧值的副本进行恢复,则存储新值的副本数可能会低于 w从而打破法定人数条件。
* 即使一切工作正常,有时也会不幸地出现关于 **时序timing** 的边缘情况,我们将在 “[线性一致性和法定人数](/v1/ch9#线性一致性和法定人数)” 中看到这点。
因此尽管法定人数似乎保证读取返回最新的写入值但在实践中并不那么简单。Dynamo 风格的数据库通常针对可以忍受最终一致性的用例进行优化。你可以通过参数 w 和 r 来调整读取到陈旧值的概率,但把它们当成绝对的保证是不明智的。
尤其是,因为通常得不到 “[复制延迟问题](#复制延迟问题)” 中讨论的那些保证(读己之写,单调读,一致前缀读),前面提到的异常可能会发生在应用程序中。更强有力的保证通常需要 **事务****共识**。我们将在 [第七章](/v1/ch7) 和 [第九章](/v1/ch9) 回到这些话题。
#### 监控陈旧度
从运维的角度来看,监视你的数据库是否返回最新的结果是很重要的。即使应用可以容忍陈旧的读取,你也需要了解复制的健康状况。如果显著落后,它应该提醒你以便你可以调查原因(例如网络中的问题或过载的节点)。
对于基于领导者的复制,数据库通常会提供复制延迟的测量值,你可以将其提供给监视系统。这之所以能做到,是因为写入是按照相同的顺序应用于主库和从库,并且每个节点对应了复制日志中的一个位置(已经在本地应用的写入数量)。通过从主库的当前位置中减去从库的当前位置,你可以测量复制延迟的程度。
然而,在无主复制的系统中,没有固定的写入顺序,这使得监控变得更加困难。而且,如果数据库只使用读修复(没有反熵过程),那么对于一个值可能会有多陈旧其实是没有限制的 - 如果一个值很少被读取,那么由一个陈旧副本返回的值可能是古老的。
已经有一些关于衡量无主复制数据库中的复制陈旧度的研究,并根据参数 n、w 和 r 来预测陈旧读取的预期百分比【48】。不幸的是这还不是很常见的做法但是将陈旧测量值包含在数据库的标准度量集中是一件好事。虽然最终一致性是一种有意模糊的保证但是从可操作性角度来说能够量化 “最终” 也是很重要的。
### 宽松的法定人数与提示移交
合理配置的法定人数可以使数据库无需故障切换即可容忍个别节点的故障。它也可以容忍个别节点变慢,因为请求不必等待所有 n 个节点响应 —— 当 w 或 r 个节点响应时它们就可以返回。对于需要高可用、低延时、且能够容忍偶尔读到陈旧值的应用场景来说,这些特性使无主复制的数据库很有吸引力。
然而,法定人数(如迄今为止所描述的)并不像它们可能的那样具有容错性。网络中断可以很容易地将客户端从大量的数据库节点上切断。虽然这些节点是活着的,而其他客户端可能也能够连接到它们,但是从数据库节点切断的客户端来看,它们也可能已经死亡。在这种情况下,剩余的可用节点可能会少于 w 或 r因此客户端不再能达到法定人数。
在一个大型的集群中(节点数量明显多于 n 个),网络中断期间客户端可能仍能连接到一些数据库节点,但又不足以组成一个特定的法定人数。在这种情况下,数据库设计人员需要权衡一下:
* 对于所有无法达到 w 或 r 个节点法定人数的请求,是否返回错误是更好的?
* 或者我们是否应该接受写入,然后将它们写入一些可达的节点,但不在这些值通常所存在的 n 个节点上?
后者被认为是一个 **宽松的法定人数sloppy quorum**【37】写和读仍然需要 w 和 r 个成功的响应,但这些响应可能来自不在指定的 n 个 “主” 节点中的其它节点。就好比说,如果你把自己锁在房子外面了,你可能会去敲开邻居的门,问是否可以暂时呆在他们的沙发上。
一旦网络中断得到解决,一个节点代表另一个节点临时接受的任何写入都将被发送到适当的 “主” 节点。这就是所谓的 **提示移交hinted handoff**(一旦你再次找到你的房子的钥匙,你的邻居可以礼貌地要求你离开沙发回家)。
宽松的法定人数对写入可用性的提高特别有用:只要有任何 w 个节点可用,数据库就可以接受写入。然而,这意味着即使当 $w + r > n$ 时,也不能确保读取到某个键的最新值,因为最新的值可能已经临时写入了 n 之外的某些节点【47】。
因此,在传统意义上,宽松的法定人数实际上并不是法定人数。它只是一个持久性的保证,即数据已存储在某处的 w 个节点。但不能保证 r 个节点的读取能看到它,除非提示移交已经完成。
在所有常见的 Dynamo 实现中,宽松的法定人数是可选的。在 Riak 中,它们默认是启用的,而在 Cassandra 和 Voldemort 中它们默认是禁用的【46,49,50】。
#### 运维多个数据中心
我们先前讨论了跨数据中心复制,作为多主复制的用例(请参阅 “[多主复制](#多主复制)”)。其实无主复制也适用于多数据中心操作,既然它旨在容忍冲突的并发写入、网络中断和延迟尖峰。
Cassandra 和 Voldemort 在正常的无主模型中实现了他们的多数据中心支持:副本的数量 n 包括所有数据中心的节点你可以在配置中指定每个数据中心所拥有的副本的数量。无论数据中心如何每个来自客户端的写入都会发送到所有副本但客户端通常只等待来自其本地数据中心内的法定节点的确认从而不会受到跨数据中心链路延迟和中断的影响。对其他数据中心的高延迟写入通常被配置为异步执行尽管该配置仍有一定的灵活性【50,51】。
Riak 将客户端和数据库节点之间的所有通信保持在一个本地的数据中心,因此 n 描述了一个数据中心内的副本数量。数据库集群之间的跨数据中心复制在后台异步发生其风格类似于多主复制【52】。
### 检测并发写入
Dynamo 风格的数据库允许多个客户端同时写入相同的键Key这意味着即使使用严格的法定人数也会发生冲突。这种情况与多主复制相似请参阅 “[处理写入冲突](#处理写入冲突)”),但在 Dynamo 风格的数据库中,在 **读修复****提示移交** 期间也可能会产生冲突。
其问题在于,由于可变的网络延迟和部分节点的故障,事件可能以不同的顺序到达不同的节点。例如,[图 5-12](/v1/ddia_0512.png) 显示了两个客户机 A 和 B 同时写入三节点数据存储中的键 X
* 节点 1 接收来自 A 的写入,但由于暂时中断,未接收到来自 B 的写入。
* 节点 2 首先接收来自 A 的写入,然后接收来自 B 的写入。
* 节点 3 首先接收来自 B 的写入,然后从 A 写入。
![](/v1/ddia_0512.png)
**图 5-12 并发写入 Dynamo 风格的数据存储:没有明确定义的顺序。**
如果每个节点只要接收到来自客户端的写入请求就简单地覆写某个键值,那么节点就会永久地不一致,如 [图 5-12](/v1/ddia_0512.png) 中的最终获取请求所示:节点 2 认为 X 的最终值是 B而其他节点认为值是 A 。
为了最终达成一致,副本应该趋于相同的值。如何做到这一点?有人可能希望复制的数据库能够自动处理,但不幸的是,大多数的实现都很糟糕:如果你想避免丢失数据,你(应用程序开发人员)需要知道很多有关数据库冲突处理的内部信息。
在 “[处理写入冲突](#处理写入冲突)” 一节中已经简要介绍了一些解决冲突的技术。在总结本章之前,让我们来更详细地探讨这个问题。
#### 最后写入胜利(丢弃并发写入)
实现最终收敛的一种方法是声明每个副本只需要存储 **“最近”** 的值,并允许 **“更旧”** 的值被覆盖和抛弃。然后,只要我们有一种明确的方式来确定哪个写是 “最近的”,并且每个写入最终都被复制到每个副本,那么复制最终会收敛到相同的值。
正如 **“最近”** 的引号所表明的,这个想法其实颇具误导性。在 [图 5-12](/v1/ddia_0512.png) 的例子中,当客户端向数据库节点发送写入请求时,两个客户端都不知道另一个客户端,因此不清楚哪一个先发送请求。事实上,说这两种情况谁先发送请求是没有意义的:既然我们说写入是 **并发concurrent** 的,那么它们的顺序就是不确定的。
即使写入没有自然的排序,我们也可以强制进行排序。例如,可以为每个写入附加一个时间戳,然后挑选最大的时间戳作为 **“最近的”**,并丢弃具有较早时间戳的任何写入。这种冲突解决算法被称为 **最后写入胜利LWW, last write wins**,是 Cassandra 唯一支持的冲突解决方法【53】也是 Riak 中的一个可选特征【35】。
LWW 实现了最终收敛的目标,但以 **持久性** 为代价:如果同一个键有多个并发写入,即使它们反馈给客户端的结果都是成功的(因为它们被写入 w 个副本也只有一个写入将被保留而其他写入将被默默地丢弃。此外LWW 甚至可能会丢弃不是并发的写入,我们将在 “[有序事件的时间戳](/v1/ch8#有序事件的时间戳)” 中进行讨论。
在类似缓存的一些情况下写入丢失可能是可以接受的。但如果数据丢失不可接受LWW 是解决冲突的一个很烂的选择。
在数据库中使用 LWW 的唯一安全方法是确保一个键只写入一次然后视为不可变从而避免对同一个键进行并发更新。例如Cassandra 推荐使用的方法是使用 UUID 作为键从而为每个写操作提供一个唯一的键【53】。
#### “此前发生”的关系和并发
我们如何判断两个操作是否是并发的?为了建立一个直觉,让我们看看一些例子:
* 在 [图 5-9](fig5-9.png) 中两个写入不是并发的A 的插入发生在 B 的递增之前,因为 B 递增的值是 A 插入的值。换句话说B 的操作建立在 A 的操作上,所以 B 的操作必须后发生。我们也可以说 B **因果依赖causally dependent** 于 A。
* 另一方面,[图 5-12](fig5-12.png) 中的两个写入是并发的:当每个客户端启动操作时,它不知道另一个客户端也正在对同样的键执行操作。因此,操作之间不存在因果关系。
如果操作 B 了解操作 A或者依赖于 A或者以某种方式构建于操作 A 之上,则操作 A 在操作 B 之前发生happens before。一个操作是否在另一个操作之前发生是定义并发含义的关键。事实上我们可以简单地说如果两个操作中的任何一个都不在另一个之前发生两个操作都不知道对方那么这两个操作是并发的【54】。
因此,只要有两个操作 A 和 B就有三种可能性A 在 B 之前发生,或者 B 在 A 之前发生,或者 A 和 B 并发。我们需要的是一个算法来告诉我们两个操作是否是并发的。如果一个操作发生在另一个操作之前,则后面的操作应该覆盖前面的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
> #### 并发性、时间和相对性
>
> 如果两个操作 **“同时”** 发生,似乎应该称为并发 —— 但事实上,它们在字面时间上重叠与否并不重要。由于分布式系统中的时钟问题,现实中是很难判断两个事件是否是 **同时** 发生的,这个问题我们将在 [第八章](/v1/ch8) 中详细讨论。
>
> 为了定义并发性,确切的时间并不重要:如果两个操作都意识不到对方的存在,就称这两个操作 **并发**而不管它们实际发生的物理时间。人们有时把这个原理和物理学中的狭义相对论联系起来【54】该理论引入了信息不能比光速更快的思想。因此如果两个事件发生的时间差小于光通过它们之间的距离所需要的时间那么这两个事件不可能相互影响。
>
> 在计算机系统中,即使光速原则上允许一个操作影响另一个操作,但两个操作也可能是 **并发的**。例如,如果网络缓慢或中断,两个操作间可能会出现一段时间间隔,但仍然是并发的,因为网络问题阻止一个操作意识到另一个操作的存在。
#### 捕获"此前发生"关系
我们来看一个算法,它可以确定两个操作是否为并发的,还是一个在另一个之前。简单起见,我们从一个只有一个副本的数据库开始。一旦我们知道了如何在单个副本上完成这项工作,我们可以将该方法推广到具有多个副本的无主数据库。
[图 5-13](/v1/ddia_0513.png) 显示了两个客户端同时向同一购物车添加项目。(如果这样的例子让你觉得无趣,那么可以想象一下两个空中交通管制员同时把飞机添加到他们正在跟踪的区域。)最初,购物车是空的。然后客户端向数据库发出五次写入:
1. 客户端 1 将牛奶加入购物车。这是该键的第一次写入,服务器成功存储了它并为其分配版本号 1最后将值与版本号一起回送给客户端。
2. 客户端 2 将鸡蛋加入购物车,不知道客户端 1 同时添加了牛奶(客户端 2 认为它的鸡蛋是购物车中的唯一物品)。服务器为此写入分配版本号 2并将鸡蛋和牛奶存储为两个单独的值。然后它将这两个值 **都** 返回给客户端 2 ,并附上版本号 2。
3. 客户端 1 不知道客户端 2 的写入,想要将面粉加入购物车,因此认为当前的购物车内容应该是 [牛奶,面粉]。它将此值与服务器先前向客户端 1 提供的版本号 1 一起发送到服务器。服务器可以从版本号中知道 [牛奶,面粉] 的写入取代了 [牛奶] 的先前值,但与 [鸡蛋] 的值是 **并发** 的。因此,服务器将版本号 3 分配给 [牛奶,面粉],覆盖版本 1 的值 [牛奶],但保留版本 2 的值 [鸡蛋],并将所有的值返回给客户端 1。
4. 同时,客户端 2 想要加入火腿,不知道客户端 1 刚刚加了面粉。客户端 2 在最近一次响应中从服务器收到了两个值 [牛奶] 和 [鸡蛋],所以客户端 2 现在合并这些值,并添加火腿形成一个新的值 [鸡蛋,牛奶,火腿]。它将这个值发送到服务器,带着之前的版本号 2 。服务器检测到新值会覆盖版本 2 的值 [鸡蛋],但新值也会与版本 3 的值 [牛奶,面粉] **并发**,所以剩下的两个值是版本 3 的 [牛奶,面粉],和版本 4 的 [鸡蛋,牛奶,火腿]。
5. 最后,客户端 1 想要加培根。它之前从服务器接收到了版本 3 的 [牛奶,面粉] 和 [鸡蛋],所以它合并这些,添加培根,并将最终值 [牛奶,面粉,鸡蛋,培根] 连同版本号 3 发往服务器。这会覆盖版本 3 的值 [牛奶,面粉](请注意 [鸡蛋] 已经在上一步被覆盖),但与版本 4 的值 [鸡蛋,牛奶,火腿] 并发,所以服务器将保留这两个并发值。
![](/v1/ddia_0513.png)
**图 5-13 在同时编辑购物车时捕获两个客户端之间的因果关系。**
[图 5-13](/v1/ddia_0513.png) 中的操作之间的数据流如 [图 5-14](/v1/ddia_0514.png) 所示。箭头表示哪个操作发生在其他操作之前,意味着后面的操作知道或依赖于较早的操作。在这个例子中,客户端永远不会完全拿到服务器上的最新数据,因为总是有另一个操作同时进行。但是旧版本的值最终会被覆盖,并且不会丢失任何写入。
![](/v1/ddia_0514.png)
**图 5-14 图 5-13 中的因果依赖关系图。**
请注意,服务器可以只通过查看版本号来确定两个操作是否是并发的 —— 它不需要对值本身进行解释(因此该值可以是任何数据结构)。该算法的工作原理如下:
* 服务器为每个键维护一个版本号,每次写入该键时都递增版本号,并将新版本号与写入的值一起存储。
* 当客户端读取键时,服务器将返回所有未覆盖的值以及最新的版本号。客户端在写入前必须先读取。
* 当客户端写入键时,必须包含之前读取的版本号,并且必须将之前读取的所有值合并在一起(针对写入请求的响应可以像读取请求一样,返回所有当前值,这使得我们可以像购物车示例那样将多个写入串联起来)。
* 当服务器接收到具有特定版本号的写入时,它可以覆盖该版本号或更低版本的所有值(因为它知道它们已经被合并到新的值中),但是它必须用更高的版本号来保存所有值(因为这些值与正在进行的其它写入是并发的)。
当一个写入包含前一次读取的版本号时,它会告诉我们的写入是基于之前的哪一种状态。如果在不包含版本号的情况下进行写操作,则与所有其他写操作并发,因此它不会覆盖任何内容 —— 只会在随后的读取中作为其中一个值返回。
#### 合并并发写入的值
这种算法可以确保没有数据被无声地丢弃但不幸的是客户端需要做一些额外的工作客户端随后必须合并并发写入的值。Riak 称这些并发值为 **兄弟siblings**
合并并发值,本质上是与多主复制中的冲突解决问题相同,我们先前讨论过(请参阅 “[处理写入冲突](#处理写入冲突)”)。一个简单的方法是根据版本号或时间戳(最后写入胜利)来选择一个值,但这意味着丢失数据。所以,你可能需要在应用程序代码中额外做些更聪明的事情。
以购物车为例,一种合理的合并值的方法就是做并集。在 [图 5-14](/v1/ddia_0514.png) 中,最后的两个兄弟是 [牛奶,面粉,鸡蛋,培根] 和 [鸡蛋,牛奶,火腿]。注意牛奶和鸡蛋虽然同时出现在两个并发值里,但他们每个只被写过一次。合并的值可以是 [牛奶,面粉,鸡蛋,培根,火腿],不再有重复了。
然而,如果你想让人们也可以从他们的购物车中 **移除** 东西而不是仅仅添加东西那么把并发值做并集可能不会产生正确的结果如果你合并了两个客户端的购物车并且只在其中一个客户端里面移除了一个项目那么被移除的项目将会重新出现在这两个客户端的交集结果中【37】。为了防止这个问题要移除一个项目时不能简单地直接从数据库中删除相反系统必须留下一个具有适当版本号的标记以在兄弟合并时表明该项目已被移除。这种删除标记被称为 **墓碑tombstone**(我们上一次看到墓碑是在 “[散列索引”](/v1/ch3#散列索引) 章节的日志压缩部分)。
因为在应用程序代码中做兄弟合并是复杂且容易出错的,所以有一些数据结构被设计出来用于自动执行这种合并,比如在 “[自动冲突解决](#自动冲突解决)” 中讨论过的那些。举例来说Riak 的数据类型就支持使用称为 CRDT 【38,39,55】的能以合理方式自动进行兄弟合并的数据结构家族包括对保留删除的支持。
#### 版本向量
[图 5-13](/v1/ddia_0513.png) 中的示例只使用了一个副本。当有多个副本但又没有主库时,算法该如何修改?
[图 5-13](/v1/ddia_0513.png) 使用单个版本号来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。相反,除了对每个键,我们还需要对 **每个副本** 使用版本号。每个副本在处理写入时增加自己的版本号,并且跟踪从其他副本中看到的版本号。这个信息指出了要覆盖哪些并发值,以及要保留哪些并发值或兄弟值。
所有副本的版本号集合称为 **版本向量version vector**【56】。这个想法的一些变体正在被使用但最有趣的可能是在 Riak 2.0 【58,59】中使用的 **虚线版本向量dotted version vector**【57】。我们不会深入细节但是它的工作方式与我们在购物车示例中看到的非常相似。
与 [图 5-13](/v1/ddia_0513.png) 中的版本号一样当读取值时版本向量会从数据库副本发送到客户端并且随后写入值时需要将其发送回数据库。Riak 将版本向量编码为一个字符串,并称其为 **因果上下文**,即 causal context。版本向量允许数据库区分覆盖写入和并发写入。
另外,就像在单个副本中的情况一样,应用程序可能需要合并并发值。版本向量结构能够确保从一个副本读取并随后写回到另一个副本是安全的。这样做虽然可能会在其他副本上面创建数据,但只要能正确合并就不会丢失数据。
> #### 版本向量和向量时钟
>
> 版本向量有时也被称为向量时钟,即使它们不完全相同。其中的差别很微妙 —— 细节请参阅参考资料【57,60,61】。简而言之在比较副本的状态时版本向量才是正确的数据结构。
## 本章小结
在本章中,我们考察了复制的问题。复制可以用于几个目的:
高可用性
: 即使在一台机器(或多台机器,或整个数据中心)停机的情况下也能保持系统正常运行
断开连接的操作
: 允许应用程序在网络中断时继续工作
延迟
: 将数据放置在地理上距离用户较近的地方,以便用户能够更快地与其交互
可伸缩性
: 通过在副本上读,能够处理比单机更大的读取量
尽管是一个简单的目标 - 在几台机器上保留相同数据的副本,但复制却是一个非常棘手的问题。它需要仔细考虑并发和所有可能出错的事情,并处理这些故障的后果。至少,我们需要处理不可用的节点和网络中断(这还不包括更隐蔽的故障,例如由于软件错误导致的静默数据损坏)。
我们讨论了复制的三种主要方法:
单主复制
: 客户端将所有写入操作发送到单个节点(主库),该节点将数据更改事件流发送到其他副本(从库)。读取可以在任何副本上执行,但从库的读取结果可能是陈旧的。
多主复制
: 客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。
无主复制
: 客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
每种方法都有优点和缺点。单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点、网络中断和延迟峰值的情况下,多主复制和无主复制可以更加健壮,其代价是难以推理并且仅提供非常弱的一致性保证。
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。尽管在系统运行平稳时异步复制速度很快,但是要弄清楚在复制延迟增加和服务器故障时会发生什么,这一点很重要。如果主库失败后你将一个异步更新的从库提升为新的主库,那么最近提交的数据可能会丢失。
我们研究了一些可能由复制延迟引起的奇怪效应,我们也讨论了一些有助于决定应用程序在复制延迟时的行为的一致性模型:
读己之写一致性
: 用户应该总是能看到自己提交的数据。
单调读
: 用户在看到某个时间点的数据后,他们不应该再看到该数据在更早时间点的情况。
一致前缀读
: 用户应该看到数据处于一种具有因果意义的状态:例如,按正确的顺序看到一个问题和对应的回答。
最后,我们讨论了多主复制和无主复制方法所固有的并发问题:因为他们允许多个写入并发发生,这可能会导致冲突。我们研究了一个数据库可以使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否并发发生。我们还谈到了通过合并并发更新来解决冲突的方法。
在下一章中,我们将继续考察数据分布在多台机器间的另一种不同于 **复制** 的形式:将大数据集分割成 **分区**
## 参考文献
1. Bruce G. Lindsay, Patricia Griffiths Selinger, C. Galtieri, et al.: “[Notes on Distributed Databases](https://dominoweb.draco.res.ibm.com/reports/RJ2571.pdf),” IBM Research, Research Report RJ2571(33471), July 1979.
1. “[Oracle Active Data Guard Real-Time Data Protection and Availability](http://www.oracle.com/technetwork/database/availability/active-data-guard-wp-12c-1896127.pdf),” Oracle White Paper, June 2013.
1. “[AlwaysOn Availability Groups](http://msdn.microsoft.com/en-us/library/hh510230.aspx),” in *SQL Server Books Online*, Microsoft, 2012.
1. Lin Qiao, Kapil Surlaker, Shirshanka Das, et al.: “[On Brewing Fresh Espresso: LinkedIns Distributed Data Serving Platform](http://www.slideshare.net/amywtang/espresso-20952131),” at *ACM International Conference on Management of Data* (SIGMOD), June 2013.
1. Jun Rao: “[Intra-Cluster Replication for Apache Kafka](http://www.slideshare.net/junrao/kafka-replication-apachecon2013),” at *ApacheCon North America*, February 2013.
1. “[Highly Available Queues](https://www.rabbitmq.com/ha.html),” in *RabbitMQ Server Documentation*, Pivotal Software, Inc., 2014.
1. Yoshinori Matsunobu: “[Semi-Synchronous Replication at Facebook](http://yoshinorimatsunobu.blogspot.co.uk/2014/04/semi-synchronous-replication-at-facebook.html),” *yoshinorimatsunobu.blogspot.co.uk*, April 1, 2014.
1. Robbert van Renesse and Fred B. Schneider: “[Chain Replication for Supporting High Throughput and Availability](http://static.usenix.org/legacy/events/osdi04/tech/full_papers/renesse/renesse.pdf),” at *6th USENIX Symposium on Operating System Design and Implementation* (OSDI), December 2004.
1. Jeff Terrace and Michael J. Freedman: “[Object Storage on CRAQ: High-Throughput Chain Replication for Read-Mostly Workloads](https://www.usenix.org/legacy/event/usenix09/tech/full_papers/terrace/terrace.pdf),” at *USENIX Annual Technical Conference* (ATC), June 2009.
1. Brad Calder, Ju Wang, Aaron Ogus, et al.: “[Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency](http://sigops.org/sosp/sosp11/current/2011-Cascais/printable/11-calder.pdf),” at *23rd ACM Symposium on Operating Systems Principles* (SOSP), October 2011.
1. Andrew Wang: “[Windows Azure Storage](https://www.umbrant.com/2016/02/04/windows-azure-storage/),” *umbrant.com*, February 4, 2016.
1. “[Percona Xtrabackup - Documentation](https://www.percona.com/doc/percona-xtrabackup/2.1/index.html),” Percona LLC, 2014.
1. Jesse Newland: “[GitHub Availability This Week](https://github.com/blog/1261-github-availability-this-week),” *github.com*, September 14, 2012.
1. Mark Imbriaco: “[Downtime Last Saturday](https://github.com/blog/1364-downtime-last-saturday),” *github.com*, December 26, 2012.
1. John Hugg: “[All in with Determinism for Performance and Testing in Distributed Systems](https://www.youtube.com/watch?v=gJRj3vJL4wE),” at *Strange Loop*, September 2015.
1. Amit Kapila: “[WAL Internals of PostgreSQL](http://www.pgcon.org/2012/schedule/attachments/258_212_Internals%20Of%20PostgreSQL%20Wal.pdf),” at *PostgreSQL Conference* (PGCon), May 2012.
1. [*MySQL Documentation*](https://dev.mysql.com/doc/refman/en/binary-log.html). Oracle, 2025.
1. Yogeshwer Sharma, Philippe Ajoux, Petchean Ang, et al.: “[Wormhole: Reliable Pub-Sub to Support Geo-Replicated Internet Services](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-sharma.pdf),” at *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015.
1. “[Oracle GoldenGate 12c: Real-Time Access to Real-Time Information](https://web.archive.org/web/20200110231516/http://www.oracle.com/us/products/middleware/data-integration/oracle-goldengate-realtime-access-2031152.pdf),” Oracle White Paper, October 2013.
1. Shirshanka Das, Chavdar Botev, Kapil Surlaker, et al.: “[All Aboard the Databus!](http://www.socc2012.org/s18-das.pdf),” at *ACM Symposium on Cloud Computing* (SoCC), October 2012.
1. Greg Sabino Mullane: “[Version 5 of Bucardo Database Replication System](https://www.endpointdev.com/blog/2014/06/bucardo-5-multimaster-postgres-released/),” *blog.endpoint.com*, June 23, 2014.
1. Werner Vogels: “[Eventually Consistent](http://queue.acm.org/detail.cfm?id=1466448),” *ACM Queue*, volume 6, number 6, pages 1419, October 2008. [doi:10.1145/1466443.1466448](http://dx.doi.org/10.1145/1466443.1466448)
1. Douglas B. Terry: “[Replicated Data Consistency Explained Through Baseball](https://www.microsoft.com/en-us/research/publication/replicated-data-consistency-explained-through-baseball/),” Microsoft Research, Technical Report MSR-TR-2011-137, October 2011.
1. Douglas B. Terry, Alan J. Demers, Karin Petersen, et al.: “[Session Guarantees for Weakly Consistent Replicated Data](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.71.2269&rep=rep1&type=pdf),” at *3rd International Conference on Parallel and Distributed Information Systems* (PDIS), September 1994. [doi:10.1109/PDIS.1994.331722](http://dx.doi.org/10.1109/PDIS.1994.331722)
1. Terry Pratchett: *Reaper Man: A Discworld Novel*. Victor Gollancz, 1991. ISBN: 978-0-575-04979-6
1. “[Tungsten Replicator](https://github.com/holys/tungsten-replicator),” *github.com*.
1. “[BDR 0.10.0 Documentation](https://web.archive.org/web/20160728020040/http://bdr-project.org/docs/next/index.html),” The PostgreSQL Global Development Group, *bdr-project.org*, 2015.
1. Robert Hodges: “[If You *Must* Deploy Multi-Master Replication, Read This First](http://scale-out-blog.blogspot.co.uk/2012/04/if-you-must-deploy-multi-master.html),” *scale-out-blog.blogspot.co.uk*, March 30, 2012.
1. J. Chris Anderson, Jan Lehnardt, and Noah Slater: *CouchDB: The Definitive Guide*. O'Reilly Media, 2010. ISBN: 978-0-596-15589-6
1. AppJet, Inc.: “[Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad-lite/blob/e2ce9dc/doc/easysync/easysync-full-description.pdf),” *github.com*, March 26, 2011.
1. John Day-Richter: “[Whats Different About the New Google Docs: Making Collaboration Fast](https://drive.googleblog.com/2010/09/whats-different-about-new-google-docs.html),” *drive.googleblog.com*, September 23, 2010.
1. Martin Kleppmann and Alastair R. Beresford: “[A Conflict-Free Replicated JSON Datatype](http://arxiv.org/abs/1608.03960),” arXiv:1608.03960, August 13, 2016.
1. Frazer Clement: “[Eventual Consistency Detecting Conflicts](http://messagepassing.blogspot.co.uk/2011/10/eventual-consistency-detecting.html),” *messagepassing.blogspot.co.uk*, October 20, 2011.
1. Robert Hodges: “[State of the Art for MySQL Multi-Master Replication](https://web.archive.org/web/20161010052017/https://www.percona.com/live/mysql-conference-2013/sites/default/files/slides/mysql-multi-master-state-of-art-2013-04-24_0.pdf),” at *Percona Live: MySQL Conference & Expo*, April 2013.
1. John Daily: “[Clocks Are Bad, or, Welcome to the Wonderful World of Distributed Systems](https://riak.com/clocks-are-bad-or-welcome-to-distributed-systems/),” *riak.com*, November 12, 2013.
1. Riley Berton: “[Is Bi-Directional Replication (BDR) in Postgres Transactional?](https://web.archive.org/web/20211204170610/http://sdf.org/~riley/blog/2016/01/04/is-bi-directional-replication-bdr-in-postgres-transactional/),” *sdf.org*, January 4, 2016.
1. Giuseppe DeCandia, Deniz Hastorun, Madan Jampani, et al.: “[Dynamo: Amazon's Highly Available Key-Value Store](http://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf),” at *21st ACM Symposium on Operating Systems Principles* (SOSP), October 2007.
1. Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski: “[A Comprehensive Study of Convergent and Commutative Replicated Data Types](http://hal.inria.fr/inria-00555588/),” INRIA Research Report no. 7506, January 2011.
1. Sam Elliott: “[CRDTs: An UPDATE (or Maybe Just a PUT)](https://speakerdeck.com/lenary/crdts-an-update-or-just-a-put),” at *RICON West*, October 2013.
1. Russell Brown: “[A Bluffers Guide to CRDTs in Riak](https://gist.github.com/russelldb/f92f44bdfb619e089a4d),” *gist.github.com*, October 28, 2013.
1. Benjamin Farinier, Thomas Gazagnaire, and Anil Madhavapeddy: “[Mergeable Persistent Data Structures](http://gazagnaire.org/pub/FGM15.pdf),” at *26es Journées Francophones des Langages Applicatifs* (JFLA), January 2015.
1. Chengzheng Sun and Clarence Ellis: “[Operational Transformation in Real-Time Group Editors: Issues, Algorithms, and Achievements](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.53.933&rep=rep1&type=pdf),” at *ACM Conference on Computer Supported Cooperative Work* (CSCW), November 1998.
1. Lars Hofhansl: “[HBASE-7709: Infinite Loop Possible in Master/Master Replication](https://issues.apache.org/jira/browse/HBASE-7709),” *issues.apache.org*, January 29, 2013.
1. David K. Gifford: “[Weighted Voting for Replicated Data](https://www.cs.cmu.edu/~15-749/READINGS/required/availability/gifford79.pdf),” at *7th ACM Symposium on Operating Systems Principles* (SOSP), December 1979. [doi:10.1145/800215.806583](http://dx.doi.org/10.1145/800215.806583)
1. Heidi Howard, Dahlia Malkhi, and Alexander Spiegelman: “[Flexible Paxos: Quorum Intersection Revisited](https://arxiv.org/abs/1608.06696),” *arXiv:1608.06696*, August 24, 2016.
1. Joseph Blomstedt: “[Re: Absolute Consistency](https://web.archive.org/web/20190919171316/http://lists.basho.com:80/pipermail/riak-users_lists.basho.com/2012-January/007157.html),” email to *riak-users* mailing list, *lists.basho.com*, January 11, 2012.
1. Joseph Blomstedt: “[Bringing Consistency to Riak](https://vimeo.com/51973001),” at *RICON West*, October 2012.
1. Peter Bailis, Shivaram Venkataraman, Michael J. Franklin, et al.: “[Quantifying Eventual Consistency with PBS](http://www.bailis.org/papers/pbs-cacm2014.pdf),” *Communications of the ACM*, volume 57, number 8, pages 93102, August 2014. [doi:10.1145/2632792](http://dx.doi.org/10.1145/2632792)
1. Jonathan Ellis: “[Modern Hinted Handoff](http://www.datastax.com/dev/blog/modern-hinted-handoff),” *datastax.com*, December 11, 2012.
1. “[Project Voldemort Wiki](https://github.com/voldemort/voldemort/wiki),” *github.com*, 2013.
1. “[Apache Cassandra Documentation](https://cassandra.apache.org/doc/latest/),” Apache Software Foundation, *cassandra.apache.org*.
1. “[Riak Enterprise: Multi-Datacenter Replication](https://web.archive.org/web/20150513041837/http://basho.com/assets/MultiDatacenter_Replication.pdf).” Technical whitepaper, Basho Technologies, Inc., September 2014.
1. Jonathan Ellis: “[Why Cassandra Doesn't Need Vector Clocks](http://www.datastax.com/dev/blog/why-cassandra-doesnt-need-vector-clocks),” *datastax.com*, September 2, 2013.
1. Leslie Lamport: “[Time, Clocks, and the Ordering of Events in a Distributed System](https://www.microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system/),” *Communications of the ACM*, volume 21, number 7, pages 558565, July 1978. [doi:10.1145/359545.359563](http://dx.doi.org/10.1145/359545.359563)
1. Joel Jacobson: “[Riak 2.0: Data Types](https://web.archive.org/web/20160327135816/http://blog.joeljacobson.com/riak-2-0-data-types/),” *blog.joeljacobson.com*, March 23, 2014.
1. D. Stott Parker Jr., Gerald J. Popek, Gerard Rudisin, et al.: “[Detection of Mutual Inconsistency in Distributed Systems](https://web.archive.org/web/20170808212704/https://zoo.cs.yale.edu/classes/cs426/2013/bib/parker83detection.pdf),” *IEEE Transactions on Software Engineering*, volume 9, number 3, pages 240247, May 1983. [doi:10.1109/TSE.1983.236733](http://dx.doi.org/10.1109/TSE.1983.236733)
1. Nuno Preguiça, Carlos Baquero, Paulo Sérgio Almeida, et al.: “[Dotted Version Vectors: Logical Clocks for Optimistic Replication](http://arxiv.org/pdf/1011.5808v1.pdf),” arXiv:1011.5808, November 26, 2010.
1. Sean Cribbs: “[A Brief History of Time in Riak](https://speakerdeck.com/seancribbs/a-brief-history-of-time-in-riak),” at *RICON*, October 2014.
1. Russell Brown: “[Vector Clocks Revisited Part 2: Dotted Version Vectors](https://riak.com/posts/technical/vector-clocks-revisited-part-2-dotted-version-vectors/),” *basho.com*, November 10, 2015.
1. Carlos Baquero: “[Version Vectors Are Not Vector Clocks](https://haslab.wordpress.com/2011/07/08/version-vectors-are-not-vector-clocks/),” *haslab.wordpress.com*, July 8, 2011.
1. Reinhard Schwarz and Friedemann Mattern: “[Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail](http://dcg.ethz.ch/lectures/hs08/seminar/papers/mattern4.pdf),” *Distributed Computing*, volume 7, number 3, pages 149174, March 1994. [doi:10.1007/BF02277859](http://dx.doi.org/10.1007/BF02277859)

361
content/v1/ch6.md Normal file
View File

@ -0,0 +1,361 @@
---
linktitle: "第六章:分区"
linkTitle: "6. 分区"
weight: 206
breadcrumbs: false
---
![](/map/ch06.png)
> 我们必须跳出电脑指令序列的窠臼。叙述定义、描述元数据、梳理关系,而不是编写过程。
>
> —— Grace Murray Hopper未来的计算机及其管理1962
在 [第五章](/v1/ch5) 中,我们讨论了复制 —— 即数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行 **分区partitions**,也称为 **分片sharding**[^i]。
[^i]: 正如本章所讨论的,分区是一种有意将大型数据库分解成小型数据库的方式。它与 **网络分区network partitions, netsplits** 无关,这是节点之间网络故障的一种。我们将在 [第八章](/v1/ch8) 讨论这些错误。
> [!TIP] 术语澄清
>
> 上文中的 **分区partition**,在 MongoDBElasticsearch 和 Solr Cloud 中被称为 **分片shard**,在 HBase 中称之为 **区域Region**Bigtable 中则是 **表块tablet**Cassandra 和 Riak 中是 **虚节点vnode**Couchbase 中叫做 **虚桶vBucket**。但是 **分区partitioning** 是最约定俗成的叫法。
通常情况下,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区。有很多方法可以实现这一点,本章将进行深入讨论。实际上,每个分区都是自己的小型数据库,尽管数据库可能支持同时进行多个分区的操作。
分区主要是为了 **可伸缩性**。不同的分区可以放在不共享集群中的不同节点上(请参阅 [第二部分](/part-ii) 关于 [无共享架构](/part-ii#无共享架构) 的定义)。因此,大数据集可以分布在多个磁盘上,并且查询负载可以分布在多个处理器上。
对于在单个分区上运行的查询,每个节点可以独立执行对自己的查询,因此可以通过添加更多的节点来扩大查询吞吐量。大型,复杂的查询可能会跨越多个节点并行处理,尽管这也带来了新的困难。
分区数据库在 20 世纪 80 年代由 Teradata 和 NonStop SQL【1】等产品率先推出最近因为 NoSQL 数据库和基于 Hadoop 的数据仓库重新被关注。有些系统是为事务性工作设计的,有些系统则用于分析(请参阅 “[事务处理还是分析](/v1/ch3#事务处理还是分析?)”):这种差异会影响系统的运作方式,但是分区的基本原理均适用于这两种工作方式。
在本章中,我们将首先介绍分割大型数据集的不同方法,并观察索引如何与分区配合。然后我们将讨论 [分区再平衡rebalancing](#分区再平衡),如果想要添加或删除集群中的节点,则必须进行再平衡。最后,我们将概述数据库如何将请求路由到正确的分区并执行查询。
## 分区与复制
分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。
一个节点可能存储多个分区。如果使用主从复制模型,则分区和复制的组合如 [图 6-1](/v1/ddia_0601.png) 所示。每个分区领导者(主库)被分配给一个节点,追随者(从库)被分配给其他节点。每个节点可能是某些分区的主库,同时是其他分区的从库。
我们在 [第五章](/v1/ch5) 讨论的关于数据库复制的所有内容同样适用于分区的复制。大多数情况下,分区方案的选择与复制方案的选择是独立的,为简单起见,本章中将忽略复制。
![](/v1/ddia_0601.png)
**图 6-1 组合使用复制和分区:每个节点充当某些分区的主库,其他分区充当从库。**
## 键值数据的分区
假设你有大量数据并且想要分区,如何决定在哪些节点上存储哪些记录呢?
分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上 10 个节点应该能够处理 10 倍的数据量和 10 倍的单个节点的读写吞吐量(暂时忽略复制)。
如果分区是不公平的,一些分区比其他分区有更多的数据或查询,我们称之为 **偏斜skew**。数据偏斜的存在使分区效率下降很多。在极端的情况下,所有的负载可能压在一个分区上,其余 9 个节点空闲的,瓶颈落在这一个繁忙的节点上。不均衡导致的高负载的分区被称为 **热点hot spot**
避免热点最简单的方法是将记录随机分配给节点。这将在所有节点上平均分配数据,但是它有一个很大的缺点:当你试图读取一个特定的值时,你无法知道它在哪个节点上,所以你必须并行地查询所有的节点。
我们可以做得更好。现在假设你有一个简单的键值数据模型,其中你总是通过其主键访问记录。例如,在一本老式的纸质百科全书中,你可以通过标题来查找一个条目;由于所有条目按字母顺序排序,因此你可以快速找到你要查找的条目。
### 根据键的范围分区
一种分区的方法是为每个分区指定一块连续的键范围(从最小值到最大值),如纸质百科全书的卷([图 6-2](/v1/ddia_0602.png))。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。如果你还知道分区所在的节点,那么可以直接向相应的节点发出请求(对于百科全书而言,就像从书架上选取正确的书籍)。
![](/v1/ddia_0602.png)
**图 6-2 印刷版百科全书按照关键字范围进行分区**
键的范围不一定均匀分布,因为数据也很可能不均匀分布。例如在 [图 6-2](/v1/ddia_0602.png) 中,第 1 卷包含以 A 和 B 开头的单词,但第 12 卷则包含以 T、U、V、X、Y 和 Z 开头的单词。只是简单的规定每个卷包含两个字母会导致一些卷比其他卷大。为了均匀分配数据,分区边界需要依据数据调整。
分区边界可以由管理员手动选择,也可以由数据库自动选择(我们会在 “[分区再平衡](#分区再平衡)” 中更详细地讨论分区边界的选择。Bigtable 使用了这种分区策略,以及其开源等价物 HBase 【2, 3】、RethinkDB 和 2.4 版本之前的 MongoDB 【4】。
在每个分区中,我们可以按照一定的顺序保存键(请参阅 “[SSTables 和 LSM 树](/v1/ch3#SSTables和LSM树)”)。好处是进行范围扫描非常简单,你可以将键作为联合索引来处理,以便在一次查询中获取多个相关记录(请参阅 “[多列索引](/v1/ch3#多列索引)”)。例如,假设我们有一个程序来存储传感器网络的数据,其中主键是测量的时间戳(年月日时分秒)。范围扫描在这种情况下非常有用,因为我们可以轻松获取某个月份的所有数据。
然而Key Range 分区的缺点是某些特定的访问模式会导致热点。如果主键是时间戳则分区对应于时间范围例如给每天分配一个分区。不幸的是由于我们在测量发生时将数据从传感器写入数据库因此所有写入操作都会转到同一个分区即今天的分区这样分区可能会因写入而过载而其他分区则处于空闲状态【5】。
为了避免传感器数据库中的这个问题,需要使用除了时间戳以外的其他东西作为主键的第一个部分。例如,可以在每个时间戳前添加传感器名称,这样会首先按传感器名称,然后按时间进行分区。假设有多个传感器同时运行,写入负载将最终均匀分布在不同分区上。现在,当想要在一个时间范围内获取多个传感器的值时,你需要为每个传感器名称执行一个单独的范围查询。
### 根据键的散列分区
由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。
一个好的散列函数可以将偏斜的数据均匀分布。假设你有一个 32 位散列函数,无论何时给定一个新的字符串输入,它将返回一个 0 到 $2^{32}$ -1 之间的 “随机” 数。即使输入的字符串非常相似,它们的散列也会均匀分布在这个数字范围内。
出于分区的目的散列函数不需要多么强壮的加密算法例如Cassandra 和 MongoDB 使用 MD5Voldemort 使用 Fowler-Noll-Vo 函数。许多编程语言都有内置的简单哈希函数(它们用于散列表),但是它们可能不适合分区:例如,在 Java 的 `Object.hashCode()` 和 Ruby 的 `Object#hash`同一个键可能在不同的进程中有不同的哈希值【6】。
一旦你有一个合适的键散列函数,你可以为每个分区分配一个散列范围(而不是键的范围),每个通过哈希散列落在分区范围内的键将被存储在该分区中。如 [图 6-3](/v1/ddia_0603.png) 所示。
![](/v1/ddia_0603.png)
**图 6-3 按哈希键分区**
这种技术擅长在分区之间公平地分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为 **一致性哈希**,即 consistent hashing
> #### 一致性哈希
>
> 一致性哈希由 Karger 等人定义。【7】 用于跨互联网级别的缓存系统,例如 CDN 中,是一种能均匀分配负载的方法。它使用随机选择的 **分区边界partition boundaries** 来避免中央控制或分布式共识的需要。请注意,这里的一致性与复制一致性(请参阅 [第五章](/v1/ch5))或 ACID 一致性(请参阅 [第七章](/v1/ch7)无关而只是描述了一种再平衡rebalancing的特定方法。
>
> 正如我们将在 “[分区再平衡](#分区再平衡)” 中所看到的,这种特殊的方法对于数据库实际上并不是很好,所以在实际中很少使用(某些数据库的文档仍然会使用一致性哈希的说法,但是它往往是不准确的)。因为有可能产生混淆,所以最好避免使用一致性哈希这个术语,而只是把它称为 **散列分区hash partitioning**
不幸的是,通过使用键散列进行分区,我们失去了键范围分区的一个很好的属性:高效执行范围查询的能力。曾经相邻的键现在分散在所有分区中,所以它们之间的顺序就丢失了。在 MongoDB 中如果你使用了基于散列的分区模式则任何范围查询都必须发送到所有分区【4】。Riak【9】、Couchbase 【10】或 Voldemort 不支持主键上的范围查询。
Cassandra 采取了折衷的策略【11, 12, 13】。Cassandra 中的表可以使用由多个列组成的复合主键来声明。键中只有第一列会作为散列的依据,而其他列则被用作 Casssandra 的 SSTables 中排序数据的连接索引。尽管查询无法在复合主键的第一列中按范围扫表,但如果第一列已经指定了固定值,则可以对该键的其他列执行有效的范围扫描。
组合索引方法为一对多关系提供了一个优雅的数据模型。例如,在社交媒体网站上,一个用户可能会发布很多更新。如果更新的主键被选择为 `(user_id, update_timestamp)`,那么你可以有效地检索特定用户在某个时间间隔内按时间戳排序的所有更新。不同的用户可以存储在不同的分区上,对于每个用户,更新按时间戳顺序存储在单个分区上。
### 负载偏斜与热点消除
如前所述,哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。
这种场景也许并不常见但并非闻所未闻例如在社交媒体网站上一个拥有数百万追随者的名人用户在做某事时可能会引发一场风暴【14】。这个事件可能导致同一个键的大量写入键可能是名人的用户 ID或者人们正在评论的动作的 ID。哈希策略不起作用因为两个相同 ID 的哈希值仍然是相同的。
如今,大多数数据系统无法自动补偿这种高度偏斜的负载,因此应用程序有责任减少偏斜。例如,如果一个主键被认为是非常火爆的,一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为 100 种不同的主键,从而存储在不同的分区中。
然而,将主键进行分割之后,任何读取都必须要做额外的工作,因为他们必须从所有 100 个主键分布中读取数据并将其合并。此技术还需要额外的记录:只需要对少量热点附加随机数;对于写入吞吐量低的绝大多数主键来说是不必要的开销。因此,你还需要一些方法来跟踪哪些键需要被分割。
也许在将来,数据系统将能够自动检测和补偿偏斜的工作负载;但现在,你需要自己来权衡。
## 分区与次级索引
到目前为止,我们讨论的分区方案依赖于键值数据模型。如果只通过主键访问记录,我们可以从该键确定分区,并使用它来将读写请求路由到负责该键的分区。
如果涉及次级索引,情况会变得更加复杂(参考 “[其他索引结构](/v1/ch3#其他索引结构)”)。次级索引通常并不能唯一地标识记录,而是一种搜索记录中出现特定值的方式:查找用户 123 的所有操作、查找包含词语 `hogwash` 的所有文章、查找所有颜色为红色的车辆等等。
次级索引是关系型数据库的基础,并且在文档数据库中也很普遍。许多键值存储(如 HBase 和 Volde-mort为了减少实现的复杂度而放弃了次级索引但是一些如 Riak已经开始添加它们因为它们对于数据模型实在是太有用了。并且次级索引也是 Solr 和 Elasticsearch 等搜索服务器的基石。
次级索引的问题是它们不能整齐地映射到分区。有两种用次级索引对数据库进行分区的方法:**基于文档的分区document-based** 和 **基于关键词term-based的分区**
### 基于文档的次级索引进行分区
假设你正在经营一个销售二手车的网站(如 [图 6-4](/v1/ddia_0604.png) 所示)。每个列表都有一个唯一的 ID—— 称之为文档 ID—— 并且用文档 ID 对数据库进行分区(例如,分区 0 中的 ID 0 到 499分区 1 中的 ID 500 到 999 等)。
你想让用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的次级索引(文档数据库中这些是 **字段field**,关系数据库中这些是 **列column** )。如果你声明了索引,则数据库可以自动执行索引 [^ii]。例如,无论何时将红色汽车添加到数据库,数据库分区都会自动将其添加到索引条目 `color:red` 的文档 ID 列表中。
[^ii]: 如果数据库仅支持键值模型,则你可能会尝试在应用程序代码中创建从值到文档 ID 的映射来实现次级索引。如果沿着这条路线走下去,请万分小心,确保你的索引与底层数据保持一致。竞争条件和间歇性写入失败(其中一些更改已保存,但其他更改未保存)很容易导致数据不同步 - 请参阅 “[多对象事务的需求](/v1/ch7#多对象事务的需求)”。
![](/v1/ddia_0604.png)
**图 6-4 基于文档的次级索引进行分区**
在这种索引方法中,每个分区是完全独立的:每个分区维护自己的次级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。无论何时你需要写入数据库(添加,删除或更新文档),只需处理包含你正在编写的文档 ID 的分区即可。出于这个原因,**文档分区索引** 也被称为 **本地索引**(而不是将在下一节中描述的 **全局索引**)。
但是,从文档分区索引中读取需要注意:除非你对文档 ID 做了特别的处理,否则没有理由将所有具有特定颜色或特定品牌的汽车放在同一个分区中。在 [图 6-4](/v1/ddia_0604.png) 中,红色汽车出现在分区 0 和分区 1 中。因此,如果要搜索红色汽车,则需要将查询发送到所有分区,并合并所有返回的结果。
这种查询分区数据库的方法有时被称为 **分散 / 聚集scatter/gather**,并且可能会使次级索引上的读取查询相当昂贵。即使并行查询分区,分散 / 聚集也容易导致尾部延迟放大(请参阅 “[实践中的百分位点](/v1/ch1#实践中的百分位点)”。然而它被广泛使用MongoDBRiak 【15】Cassandra 【16】Elasticsearch 【17】SolrCloud 【18】和 VoltDB 【19】都使用文档分区次级索引。大多数数据库供应商建议你构建一个能从单个分区提供次级索引查询的分区方案但这并不总是可行尤其是当在单个查询中使用多个次级索引时例如同时需要按颜色和制造商查询
### 基于关键词(Term)的次级索引进行分区
我们可以构建一个覆盖所有分区数据的 **全局索引**,而不是给每个分区创建自己的次级索引(本地索引)。但是,我们不能只把这个索引存储在一个节点上,因为它可能会成为瓶颈,违背了分区的目的。全局索引也必须进行分区,但可以采用与主键不同的分区方式。
[图 6-5](/v1/ddia_0605.png) 描述了这可能是什么样子:来自所有分区的红色汽车在红色索引中,并且索引是分区的,首字母从 `a``r` 的颜色在分区 0 中,`s` 到 `z` 的在分区 1。汽车制造商的索引也与之类似分区边界在 `f``h` 之间)。
![](/v1/ddia_0605.png)
**图 6-5 基于关键词对次级索引进行分区**
我们将这种索引称为 **关键词分区term-partitioned**,因为我们寻找的关键词决定了索引的分区方式。例如,一个关键词可能是:`color:red`。**关键词Term** 这个名称来源于全文搜索索引(一种特殊的次级索引),指文档中出现的所有单词。
和之前一样,我们可以通过 **关键词** 本身或者它的散列进行索引分区。根据关键词本身来分区对于范围扫描非常有用(例如对于数值类的属性,像汽车的报价),而对关键词的哈希分区提供了负载均衡的能力。
关键词分区的全局索引优于文档分区索引的地方点是它可以使读取更有效率:不需要 **分散 / 收集** 所有分区,客户端只需要向包含关键词的分区发出请求。全局索引的缺点在于写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区(文档中的每个关键词可能位于不同的分区或者不同的节点上) 。
理想情况下,索引总是最新的,写入数据库的每个文档都会立即反映在索引中。但是,在关键词分区索引中,这需要跨分区的分布式事务,并不是所有数据库都支持(请参阅 [第七章](/v1/ch7) 和 [第九章](/v1/ch9))。
在实践中,对全局次级索引的更新通常是 **异步**也就是说如果在写入之后不久读取索引刚才所做的更改可能尚未反映在索引中。例如Amazon DynamoDB 声称在正常情况下其全局次级索引会在不到一秒的时间内更新但在基础架构出现故障的情况下可能会有延迟【20】。
全局关键词分区索引的其他用途包括 Riak 的搜索功能【21】和 Oracle 数据仓库它允许你在本地和全局索引之间进行选择【22】。我们将在 [第十二章](/v1/ch12) 中继续关键词分区次级索引实现的话题。
## 分区再平衡
随着时间的推移,数据库会有各种变化:
* 查询吞吐量增加,所以你想要添加更多的 CPU 来处理负载。
* 数据集大小增加,所以你想添加更多的磁盘和 RAM 来存储它。
* 机器出现故障,其他机器需要接管故障机器的责任。
所有这些更改都需要数据和请求从一个节点移动到另一个节点。将负载从集群中的一个节点向另一个节点移动的过程称为 **再平衡rebalancing**
无论使用哪种分区方案,再平衡通常都要满足一些最低要求:
* 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共享。
* 再平衡发生时,数据库应该继续接受读取和写入。
* 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘 I/O 负载。
### 再平衡策略
有几种不同的分区分配方法【23】让我们依次简要讨论一下。
#### 反面教材hash mod N
我们在前面说过([图 6-3](/v1/ddia_0603.png)),最好将可能的散列分成不同的范围,并将每个范围分配给一个分区(例如,如果 $0 ≤ hash(key)< b_0$则将键分配给分区 0如果 $b_0 hash(key) < b_1$则分配给分区 1
也许你想知道为什么我们不使用 ***取模mod***(许多编程语言中的 % 运算符)。例如,`hash(key) mod 10` 会返回一个介于 0 和 9 之间的数字(如果我们将散列写为十进制数,散列模 10 将是最后一个数字)。如果我们有 10 个节点,编号为 0 到 9这似乎是将每个键分配给一个节点的简单方法。
模 N$mod N$)方法的问题是,如果节点数量 N 发生变化,大多数键将需要从一个节点移动到另一个节点。例如,假设 $hash(key)=123456$。如果最初有 10 个节点,那么这个键一开始放在节点 6 上(因为 $123456\ mod\ 10 = 6$)。当你增长到 11 个节点时,键需要移动到节点 3$123456\ mod\ 11 = 3$),当你增长到 12 个节点时,需要移动到节点 0$123456\ mod\ 12 = 0$)。这种频繁的举动使得再平衡的成本过高。
我们需要一种只移动必需数据的方法。
#### 固定数量的分区
幸运的是,有一个相当简单的解决方案:创建比节点更多的分区,并为每个节点分配多个分区。例如,运行在 10 个节点的集群上的数据库可能会从一开始就被拆分为 1,000 个分区,因此大约有 100 个分区被分配给每个节点。
现在,如果一个节点被添加到集群中,新节点可以从当前每个节点中 **窃取** 一些分区,直到分区再次公平分配。这个过程如 [图 6-6](/v1/ddia_0606.png) 所示。如果从集群中删除一个节点,则会发生相反的情况。
只有分区在节点之间的移动。分区的数量不会改变,键所指定的分区也不会改变。唯一改变的是分区所在的节点。这种变更并不是即时的 — 在网络上传输大量的数据需要一些时间 — 所以在传输过程中,原有分区仍然会接受读写操作。
![](/v1/ddia_0606.png)
**图 6-6 将新节点添加到每个节点具有多个分区的数据库集群。**
原则上,你甚至可以解决集群中的硬件不匹配问题:通过为更强大的节点分配更多的分区,可以强制这些节点承载更多的负载。在 Riak 【15】、Elasticsearch 【24】、Couchbase 【10】和 Voldemort 【25】中使用了这种再平衡的方法。
在这种配置中,分区的数量通常在数据库第一次建立时确定,之后不会改变。虽然原则上可以分割和合并分区(请参阅下一节),但固定数量的分区在操作上更简单,因此许多固定分区数据库选择不实施分区分割。因此,一开始配置的分区数就是你可以拥有的最大节点数量,所以你需要选择足够多的分区以适应未来的增长。但是,每个分区也有管理开销,所以选择太大的数字会适得其反。
如果数据集的总大小难以预估(例如,可能它开始很小,但随着时间的推移会变得更大),选择正确的分区数是困难的。由于每个分区包含了总数据量固定比率的数据,因此每个分区的大小与集群中的数据总量成比例增长。如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。当分区大小 “恰到好处” 的时候才能获得很好的性能,如果分区数量固定,但数据量变动很大,则难以达到最佳性能。
#### 动态分区
对于使用键范围分区的数据库(请参阅 “[根据键的范围分区](#根据键的范围分区)”),具有固定边界的固定数量的分区将非常不便:如果边界设置错误,可能会导致所有数据都在一个分区中,而其他分区则为空。手动重新配置分区边界将非常繁琐。
出于这个原因,按键的范围进行分区的数据库(如 HBase 和 RethinkDB会动态创建分区。当分区增长到超过配置的大小时在 HBase 上,默认值是 10GB会被分成两个分区每个分区约占一半的数据【26】。与之相反如果大量数据被删除并且分区缩小到某个阈值以下则可以将其与相邻分区合并。此过程与 B 树顶层发生的过程类似(请参阅 “[B 树](/v1/ch3#B树)”)。
每个分区分配给一个节点,每个节点可以处理多个分区,就像固定数量的分区一样。大型分区拆分后,可以将其中的一半转移到另一个节点,以平衡负载。在 HBase 中,分区文件的传输通过 HDFS底层使用的分布式文件系统来实现【3】。
动态分区的一个优点是分区数量适应总数据量。如果只有少量的数据少量的分区就足够了所以开销很小如果有大量的数据每个分区的大小被限制在一个可配置的最大值【23】。
需要注意的是一个空的数据库从一个分区开始因为没有关于在哪里绘制分区边界的先验信息。数据集开始时很小直到达到第一个分区的分割点所有写入操作都必须由单个节点处理而其他节点则处于空闲状态。为了解决这个问题HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为 **预分割**,即 pre-splitting。在键范围分区的情况中预分割需要提前知道键是如何进行分配的【4,26】。
动态分区不仅适用于数据的范围分区,而且也适用于散列分区。从版本 2.4 开始MongoDB 同时支持范围和散列分区,并且都支持动态分割分区。
#### 按节点比例分区
通过动态分区,分区的数量与数据集的大小成正比,因为拆分和合并过程将每个分区的大小保持在固定的最小值和最大值之间。另一方面,对于固定数量的分区,每个分区的大小与数据集的大小成正比。在这两种情况下,分区的数量都与节点的数量无关。
Cassandra 和 Ketama 使用的第三种方法是使分区数与节点数成正比 —— 换句话说每个节点具有固定数量的分区【23,27,28】。在这种情况下每个分区的大小与数据集大小成比例地增长而节点数量保持不变但是当增加节点数时分区将再次变小。由于较大的数据量通常需要较大数量的节点进行存储因此这种方法也使每个分区的大小较为稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,然后占有这些拆分分区中每个分区的一半,同时将每个分区的另一半留在原地。随机化可能会产生不公平的分割,但是平均在更大数量的分区上时(在 Cassandra 中,默认情况下,每个节点有 256 个分区新节点最终从现有节点获得公平的负载份额。Cassandra 3.0 引入了另一种再平衡的算法来避免不公平的分割【29】。
随机选择分区边界要求使用基于散列的分区可以从散列函数产生的数字范围中挑选边界。实际上这种方法最符合一致性哈希的原始定义【7】请参阅 “[一致性哈希](#一致性哈希)”。最新的哈希函数可以在较低元数据开销的情况下达到类似的效果【8】。
### 运维:手动还是自动再平衡
关于再平衡有一个重要问题:自动还是手动进行?
在全自动再平衡系统自动决定何时将分区从一个节点移动到另一个节点无须人工干预和完全手动分区指派给节点由管理员明确配置仅在管理员明确重新配置时才会更改之间有一个权衡。例如Couchbase、Riak 和 Voldemort 会自动生成建议的分区分配,但需要管理员提交才能生效。
全自动再平衡可以很方便,因为正常维护的操作工作较少。然而,它可能是不可预测的。再平衡是一个昂贵的操作,因为它需要重新路由请求并将大量数据从一个节点移动到另一个节点。如果没有做好,这个过程可能会使网络或节点负载过重,降低其他请求的性能。
这种自动化与自动故障检测相结合可能十分危险。例如,假设一个节点过载,并且对请求的响应暂时很慢。其他节点得出结论:过载的节点已经死亡,并自动重新平衡集群,使负载离开它。这会对已经超负荷的节点,其他节点和网络造成额外的负载,从而使情况变得更糟,并可能导致级联失败。
出于这个原因,再平衡的过程中有人参与是一件好事。这比全自动的过程慢,但可以帮助防止运维意外。
## 请求路由
现在我们已经将数据集分割到多个机器上运行的多个节点上。但是仍然存在一个悬而未决的问题:当客户想要发出请求时,如何知道要连接哪个节点?随着分区的重新平衡,分区对节点的分配也发生变化。为了回答这个问题,需要有人知晓这些变化:如果我想读或写键 “foo”需要连接哪个 IP 地址和端口号?
这个问题可以概括为 **服务发现service discovery** 它不仅限于数据库。任何可通过网络访问的软件都有这个问题特别是如果它的目标是高可用性在多台机器上运行冗余配置。许多公司已经编写了自己的内部服务发现工具其中许多已经作为开源发布【30】。
概括来说,这个问题有几种不同的方案(如图 6-7 所示):
1. 允许客户联系任何节点(例如,通过 **循环策略的负载均衡**,即 Round-Robin Load Balancer。如果该节点恰巧拥有请求的分区则它可以直接处理该请求否则它将请求转发到适当的节点接收回复并传递给客户端。
2. 首先将所有来自客户端的请求发送到路由层,它决定了应该处理请求的节点,并相应地转发。此路由层本身不处理任何请求;它仅负责分区的负载均衡。
3. 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,而不需要任何中介。
以上所有情况中的关键问题是:作出路由决策的组件(可能是节点之一,还是路由层或客户端)如何了解分区 - 节点之间的分配关系变化?
![](/v1/ddia_0607.png)
**图 6-7 将请求路由到正确节点的三种不同方式。**
这是一个具有挑战性的问题,因为重要的是所有参与者都达成共识 - 否则请求将被发送到错误的节点,得不到正确的处理。在分布式系统中有达成共识的协议,但很难正确地实现(见 [第九章](/v1/ch9))。
许多分布式数据系统都依赖于一个独立的协调服务,比如 ZooKeeper 来跟踪集群元数据,如 [图 6-8](/v1/ddia_0608.png) 所示。每个节点在 ZooKeeper 中注册自己ZooKeeper 维护分区到节点的可靠映射。其他参与者(如路由层或分区感知客户端)可以在 ZooKeeper 中订阅此信息。只要分区分配发生了改变或者集群中添加或删除了一个节点ZooKeeper 就会通知路由层使路由信息保持最新状态。
![](/v1/ddia_0608.png)
**图 6-8 使用 ZooKeeper 跟踪分区分配给节点。**
例如LinkedIn的Espresso使用Helix 【31】进行集群管理依靠ZooKeeper实现了如[图6-8](/v1/ddia_0608.png)所示的路由层。HBase、SolrCloud和Kafka也使用ZooKeeper来跟踪分区分配。MongoDB具有类似的体系结构但它依赖于自己的**配置服务器config server** 实现和mongos守护进程作为路由层。
Cassandra 和 Riak 采取不同的方法:他们在节点之间使用 **流言协议gossip protocol** 来传播集群状态的变化。请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点([图 6-7](/v1/ddia_0607.png) 中的方法 1。这个模型在数据库节点中增加了更多的复杂性但是避免了对像 ZooKeeper 这样的外部协调服务的依赖。
Couchbase 不会自动进行再平衡,这简化了设计。通常情况下,它配置了一个名为 moxi 的路由层它会从集群节点了解路由变化【32】。
当使用路由层或向随机节点发送请求时,客户端仍然需要找到要连接的 IP 地址。这些地址并不像分区的节点分布变化的那么快,所以使用 DNS 通常就足够了。
### 执行并行查询
到目前为止,我们只关注读取或写入单个键的非常简单的查询(加上基于文档分区的次级索引场景下的分散 / 聚集查询)。这也是大多数 NoSQL 分布式数据存储所支持的访问层级。
然而,通常用于分析的 **大规模并行处理MPP, Massively parallel processing** 关系型数据库产品在其支持的查询类型方面要复杂得多。一个典型的数据仓库查询包含多个连接过滤分组和聚合操作。MPP 查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行。涉及扫描大规模数据集的查询特别受益于这种并行执行。
数据仓库查询的快速并行执行是一个专门的话题,由于分析有很重要的商业意义,可以带来很多利益。我们将在 [第十章](/v1/ch10) 讨论并行查询执行的一些技巧。有关并行数据库中使用的技术的更详细的概述请参阅参考文献【1,33】。
## 本章小结
在本章中,我们探讨了将大数据集划分成更小的子集的不同方法。数据量非常大的时候,在单台机器上存储和处理不再可行,而分区则十分必要。分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于你的数据的分区方案,并在将节点添加到集群或从集群删除时重新平衡分区。
我们讨论了两种主要的分区方法:
* 键范围分区
其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。
在这种方法中,当分区变得太大时,通常将分区分成两个子分区来动态地重新平衡分区。
* 散列分区
散列函数应用于每个键,分区拥有一定范围的散列。这种方法破坏了键的排序,使得范围查询效率低下,但可以更均匀地分配负载。
通过散列进行分区时,通常先提前创建固定数量的分区,为每个节点分配多个分区,并在添加或删除节点时将整个分区从一个节点移动到另一个节点。也可以使用动态分区。
两种方法搭配使用也是可行的,例如使用复合主键:使用键的一部分来标识分区,而使用另一部分作为排序顺序。
我们还讨论了分区和次级索引之间的相互作用。次级索引也需要分区,有两种方法:
* 基于文档分区(本地索引),其中次级索引存储在与主键和值相同的分区中。这意味着只有一个分区需要在写入时更新,但是读取次级索引需要在所有分区之间进行分散 / 收集。
* 基于关键词分区(全局索引),其中次级索引存在不同的分区中。次级索引中的条目可以包括来自主键的所有分区的记录。当文档写入时,需要更新多个分区中的次级索引;但是可以从单个分区中进行读取。
最后,我们讨论了将查询路由到适当的分区的技术,从简单的分区负载平衡到复杂的并行查询执行引擎。
按照设计,多数情况下每个分区是独立运行的 — 这就是分区数据库可以伸缩到多台机器的原因。但是,需要写入多个分区的操作结果可能难以预料:例如,如果写入一个分区成功,但另一个分区失败,会发生什么情况?我们将在下面的章节中讨论这个问题。
## 参考文献
1. David J. DeWitt and Jim N. Gray: “[Parallel Database Systems: The Future of High Performance Database Systems](http://www.cs.cmu.edu/~pavlo/courses/fall2013/static/papers/dewittgray92.pdf),” *Communications of the ACM*, volume 35, number 6, pages 8598, June 1992. [doi:10.1145/129888.129894](http://dx.doi.org/10.1145/129888.129894)
1. Lars George: “[HBase vs. BigTable Comparison](http://www.larsgeorge.com/2009/11/hbase-vs-bigtable-comparison.html),” *larsgeorge.com*, November 2009.
1. “[The Apache HBase Reference Guide](https://hbase.apache.org/book/book.html),” Apache Software Foundation, *hbase.apache.org*, 2014.
1. MongoDB, Inc.: “[New Hash-Based Sharding Feature in MongoDB 2.4](https://web.archive.org/web/20230610080235/https://www.mongodb.com/blog/post/new-hash-based-sharding-feature-in-mongodb-24),” *blog.mongodb.org*, April 10, 2013.
1. Ikai Lan: “[App Engine Datastore Tip: Monotonically Increasing Values Are Bad](http://ikaisays.com/2011/01/25/app-engine-datastore-tip-monotonically-increasing-values-are-bad/),” *ikaisays.com*, January 25, 2011.
1. Martin Kleppmann: “[Java's hashCode Is Not Safe for Distributed Systems](http://martin.kleppmann.com/2012/06/18/java-hashcode-unsafe-for-distributed-systems.html),” *martin.kleppmann.com*, June 18, 2012.
1. David Karger, Eric Lehman, Tom Leighton, et al.: “[Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web](https://www.akamai.com/site/en/documents/research-paper/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf),” at *29th Annual ACM Symposium on Theory of Computing* (STOC), pages 654663, 1997. [doi:10.1145/258533.258660](http://dx.doi.org/10.1145/258533.258660)
1. John Lamping and Eric Veach: “[A Fast, Minimal Memory, Consistent Hash Algorithm](http://arxiv.org/pdf/1406.2294.pdf),” *arxiv.org*, June 2014.
1. Eric Redmond: “[A Little Riak Book](https://web.archive.org/web/20160807123307/http://www.littleriakbook.com/),” Version 1.4.0, Basho Technologies, September 2013.
1. “[Couchbase 2.5 Administrator Guide](http://docs.couchbase.com/couchbase-manual-2.5/cb-admin/),” Couchbase, Inc., 2014.
1. Avinash Lakshman and Prashant Malik: “[Cassandra A Decentralized Structured Storage System](http://www.cs.cornell.edu/Projects/ladis2009/papers/Lakshman-ladis2009.PDF),” at *3rd ACM SIGOPS International Workshop on Large Scale Distributed Systems and Middleware* (LADIS), October 2009.
1. Jonathan Ellis: “[Facebooks Cassandra Paper, Annotated and Compared to Apache Cassandra 2.0](https://docs.datastax.com/en/articles/cassandra/cassandrathenandnow.html),” *docs.datastax.com*, September 12, 2013.
1. “[Introduction to Cassandra Query Language](https://docs.datastax.com/en/cql-oss/3.1/cql/cql_intro_c.html),” DataStax, Inc., 2014.
1. Samuel Axon: “[3% of Twitter's Servers Dedicated to Justin Bieber](https://web.archive.org/web/20201109041636/https://mashable.com/2010/09/07/justin-bieber-twitter/?europe=true),” *mashable.com*, September 7, 2010.
1. “[Riak KV Docs](https://docs.riak.com/riak/kv/latest/index.html),” *docs.riak.com*.
1. Richard Low: “[The Sweet Spot for Cassandra Secondary Indexing](https://web.archive.org/web/20190831132955/http://www.wentnet.com/blog/?p=77),” *wentnet.com*, October 21, 2013.
1. Zachary Tong: “[Customizing Your Document Routing](https://www.elastic.co/blog/customizing-your-document-routing/),” *elastic.co*, June 3, 2013.
1. “[Apache Solr Reference Guide](https://cwiki.apache.org/confluence/display/solr/Apache+Solr+Reference+Guide),” Apache Software Foundation, 2014.
1. Andrew Pavlo: “[H-Store Frequently Asked Questions](http://hstore.cs.brown.edu/documentation/faq/),” *hstore.cs.brown.edu*, October 2013.
1. “[Amazon DynamoDB Developer Guide](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/),” Amazon Web Services, Inc., 2014.
1. Rusty Klophaus: “[Difference Between 2I and Search](https://web.archive.org/web/20150926053350/http://lists.basho.com/pipermail/riak-users_lists.basho.com/2011-October/006220.html),” email to *riak-users* mailing list, *lists.basho.com*, October 25, 2011.
1. Donald K. Burleson: “[Object Partitioning in Oracle](http://www.dba-oracle.com/art_partit.htm),”*dba-oracle.com*, November 8, 2000.
1. Eric Evans: “[Rethinking Topology in Cassandra](http://www.slideshare.net/jericevans/virtual-nodes-rethinking-topology-in-cassandra),” at *ApacheCon Europe*, November 2012.
1. Rafał Kuć: “[Reroute API Explained](https://web.archive.org/web/20190706215750/http://elasticsearchserverbook.com/reroute-api-explained/),” *elasticsearchserverbook.com*, September 30, 2013.
1. “[Project Voldemort Documentation](https://web.archive.org/web/20250107145644/http://www.project-voldemort.com/voldemort/),” *project-voldemort.com*.
1. Enis Soztutar: “[Apache HBase Region Splitting and Merging](http://hortonworks.com/blog/apache-hbase-region-splitting-and-merging/),” *hortonworks.com*, February 1, 2013.
1. Brandon Williams: “[Virtual Nodes in Cassandra 1.2](http://www.datastax.com/dev/blog/virtual-nodes-in-cassandra-1-2),” *datastax.com*, December 4, 2012.
1. Richard Jones: “[libketama: Consistent Hashing Library for Memcached Clients](https://www.metabrew.com/article/libketama-consistent-hashing-algo-memcached-clients),” *metabrew.com*, April 10, 2007.
1. Branimir Lambov: “[New Token Allocation Algorithm in Cassandra 3.0](http://www.datastax.com/dev/blog/token-allocation-algorithm),” *datastax.com*, January 28, 2016.
1. Jason Wilder: “[Open-Source Service Discovery](http://jasonwilder.com/blog/2014/02/04/service-discovery-in-the-cloud/),” *jasonwilder.com*, February 2014.
1. Kishore Gopalakrishna, Shi Lu, Zhen Zhang, et al.: “[Untangling Cluster Management with Helix](http://www.socc2012.org/helix_onecol.pdf?attredirects=0),” at *ACM Symposium on Cloud Computing* (SoCC), October 2012. [doi:10.1145/2391229.2391248](http://dx.doi.org/10.1145/2391229.2391248)
1. “[Moxi 1.8 Manual](http://docs.couchbase.com/moxi-manual-1.8/),” Couchbase, Inc., 2014.
1. Shivnath Babu and Herodotos Herodotou: “[Massively Parallel Databases and MapReduce Systems](https://www.microsoft.com/en-us/research/wp-content/uploads/2013/11/db-mr-survey-final.pdf),” *Foundations and Trends in Databases*, volume 5, number 1, pages 1104, November 2013. [doi:10.1561/1900000036](http://dx.doi.org/10.1561/1900000036)

918
content/v1/ch7.md Normal file
View File

@ -0,0 +1,918 @@
---
title: "第七章:事务"
linkTitle: "7. 事务"
weight: 207
breadcrumbs: false
---
![](/map/ch07.png)
> 一些作者声称,支持通用的两阶段提交代价太大,会带来性能与可用性的问题。让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多。
>
> —— James Corbett 等人SpannerGoogle 的全球分布式数据库2012
在数据系统的残酷现实中,很多事情都可能出错:
- 数据库软件、硬件可能在任意时刻发生故障(包括写操作进行到一半时)。
- 应用程序可能在任意时刻崩溃(包括一系列操作的中间)。
- 网络中断可能会意外切断数据库与应用的连接,或数据库之间的连接。
- 多个客户端可能会同时写入数据库,覆盖彼此的更改。
- 客户端可能读取到无意义的数据,因为数据只更新了一部分。
- 客户端之间的竞争条件可能导致令人惊讶的错误。
为了实现可靠性,系统必须处理这些故障,确保它们不会导致整个系统的灾难性故障。但是实现容错机制工作量巨大。需要仔细考虑所有可能出错的事情,并进行大量的测试,以确保解决方案真正管用。
数十年来,**事务transaction** 一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作被视作单个操作来执行:整个事务要么成功 **提交**commit要么失败 **中止**abort**回滚**rollback。如果失败应用程序可以安全地重试。对于事务来说应用程序的错误处理变得简单多了因为它不用再担心部分失败的情况了即某些操作成功某些失败无论出于何种原因
和事务打交道时间长了,你可能会觉得它显而易见。但我们不应将其视为理所当然。事务不是天然存在的;它们是为了 **简化应用编程模型** 而创建的。通过使用事务,应用程序可以自由地忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(我们称之为 **安全保证**,即 safety guarantees
并不是所有的应用都需要事务,有时候弱化事务保证、或完全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)。一些安全属性也可以在没有事务的情况下实现。
怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。尽管乍看事务似乎很简单,但实际上有许多微妙但重要的细节在起作用。
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入 **并发控制** 的领域,讨论各种可能发生的竞争条件,以及数据库如何实现 **读已提交read committed****快照隔离snapshot isolation** 和 **可串行化serializability** 等隔离级别。
本章同时适用于单机数据库与分布式数据库;在 [第八章](/v1/ch8) 中将重点讨论仅出现在分布式系统中的特殊挑战。
## 事务的棘手概念
现今,几乎所有的关系型数据库和一些非关系数据库都支持 **事务**。其中大多数遵循 IBM System R第一个 SQL 数据库)在 1975 年引入的风格【1,2,3】。40 年里尽管一些实现细节发生了变化但总体思路大同小异MySQL、PostgreSQL、Oracle 和 SQL Server 等数据库中的事务支持与 System R 异乎寻常地相似。
2000 年以后非关系NoSQL数据库开始普及。它们的目标是在关系数据库的现状基础上通过提供新的数据模型选择请参阅 [第二章](/v1/ch2)并默认包含复制第五章和分区第六章来进一步提升。事务是这次运动的主要牺牲品这些新一代数据库中的许多数据库完全放弃了事务或者重新定义了这个词描述比以前所理解的更弱得多的一套保证【4】。
随着这种新型分布式数据库的炒作人们普遍认为事务是可伸缩性的对立面任何大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面数据库厂商有时将事务保证作为 “重要应用” 和 “有价值数据” 的基本要求。这两种观点都是 **纯粹的夸张**
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供保证的细节 —— 无论是在正常运行中还是在各种极端(但是现实存在)的情况下。
### ACID的含义
事务所提供的安全保证,通常由众所周知的首字母缩略词 ACID 来描述ACID 代表 **原子性Atomicity****一致性Consistency****隔离性Isolation** 和 **持久性Durability**。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出,旨在为数据库中的容错机制建立精确的术语。
但实际上,不同数据库的 ACID 实现并不相同。例如,我们将会看到,关于 **隔离性** 的含义就有许多含糊不清【8】。高层次上的想法很美好但魔鬼隐藏在细节里。今天当一个系统声称自己 “符合 ACID” 时实际上能期待的是什么保证并不清楚。不幸的是ACID 现在几乎已经变成了一个营销术语。
(不符合 ACID 标准的系统有时被称为 BASE它代表 **基本可用性Basically Available****软状态Soft State** 和 **最终一致性Eventual consistency**【9】这比 ACID 的定义更加模糊,似乎 BASE 的唯一合理的定义是 “不是 ACID”即它几乎可以代表任何你想要的东西。
让我们深入了解原子性,一致性,隔离性和持久性的定义,这可以让我们提炼出事务的思想。
#### 原子性
一般来说,原子是指不能分解成小部分的东西。这个词在计算机的不同领域中意味着相似但又微妙不同的东西。例如,在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。
相比之下ACID 的原子性并 **不** 是关于 **并发concurrent** 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在 [**隔离性**](#隔离性) 中。
ACID 的原子性描述了当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被 **中止abort**,应用程序可以确定它没有改变任何东西,所以可以安全地重试。
ACID 原子性的定义特征是:**能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。** 或许 **可中止性abortability** 是更好的术语,但本书将继续使用原子性,因为这是惯用词。
#### 一致性
一致性这个词被赋予太多含义:
* 在 [第五章](/v1/ch5) 中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题(请参阅 “[复制延迟问题](/v1/ch5#复制延迟问题)”)。
* [一致性哈希](/v1/ch6#一致性哈希) 是某些系统用于重新分区的一种分区方法。
* 在 [CAP 定理](/v1/ch9#CAP定理) 中,一致性一词用于表示 [线性一致性](/v1/ch9#线性一致性)。
* 在 ACID 的上下文中,**一致性** 是指数据库在应用程序的特定概念中处于 “良好状态”。
很不幸,这一个词就至少有四种不同的含义。
ACID 一致性的概念是,**对数据的一组特定约束必须始终成立**,即 **不变式invariants**。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变式的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变式总是满足的。
但是,一致性的这种概念取决于应用程序对不变式的理解,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变式的脏数据,数据库也无法阻止你(一些特定类型的不变式可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储)。
原子性、隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID [^i]。
[^i]: 乔・海勒斯坦Joe Hellerstein指出在 Härder 与 Reuter 的论文中“ACID 中的 C” 是被 “扔进去凑缩写单词的”【7】而且那时候大家都不怎么在乎一致性。
#### 隔离性
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到 **并发** 问题(**竞争条件**,即 race conditions
[图 7-1](/v1/ddia_0701.png) 是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库没有内建的自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图 7-1](/v1/ddia_0701.png) 中,因为发生了两次增长,计数器应该从 42 增至 44但由于竞态条件实际上只增至 43 。
ACID 意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为 **可串行化Serializability**这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当多个事务被提交时结果与它们串行运行一个接一个是一样的尽管实际上它们可能是并发运行的【10】。
![](/v1/ddia_0701.png)
**图 7-1 两个客户之间的竞争状态同时递增计数器**
然而实践中很少会使用可串行的隔离,因为它有性能损失。一些流行的数据库如 Oracle 11g甚至没有实现它。在 Oracle 中有一个名为 “可串行的” 隔离级别,但实际上它实现了一种叫做 **快照隔离snapshot isolation** 的功能,**这是一种比可串行化更弱的保证**【8,11】。我们将在 “[弱隔离级别](#弱隔离级别)” 中研究快照隔离和其他形式的隔离。
#### 持久性
数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。**持久性** 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。它通常还包括预写日志或类似的文件请参阅 “[让 B 树更可靠](/v1/ch3#让B树更可靠)”),以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
如 “[可靠性](/v1/ch1#可靠性)” 一节所述,**完美的持久性是不存在的** :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。
> #### 复制与持久性
>
> 在历史上,持久性意味着写入归档磁带。后来它被理解为写入磁盘或 SSD。再后来它又有了新的内涵即 “复制replication”。哪种实现更好一些
>
> 真相是,没有什么是完美的:
>
> * 如果你写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。
> * 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的 Bug可能会一次性摧毁所有副本请参阅「[可靠性](/v1/ch1#可靠性)」),任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。
> * 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失(请参阅「[处理节点宕机](/v1/ch5#处理节点宕机)」)。
> * 当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至 fsync 也不能保证正常工作【12】。硬盘固件可能有错误就像任何其他类型的软件一样【13,14】。
> * 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误并可能导致磁盘上的文件在崩溃后被损坏【15,16】。
> * 磁盘上的数据可能会在没有检测到的情况下逐渐损坏【17】。如果数据已损坏一段时间副本和最近的备份也可能损坏。这种情况下需要尝试从历史备份中恢复数据。
> * 一项关于固态硬盘的研究发现在运行的前四年中30% 到 80% 的硬盘会产生至少一个坏块【18】。相比固态硬盘磁盘的坏道率较低但完全失效的概率更高。
> * 如果 SSD 断电可能会在几周内开始丢失数据具体取决于温度【19】。
>
> 在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份 —— 它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的 “保证”。
### 单对象和多对象操作
回顾一下,在 ACID 中,原子性和隔离性描述了客户端在同一事务中执行多次写入时,数据库应该做的事情:
原子性
: 如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧 —— 通过提供 “**宁为玉碎不为瓦全all-or-nothing**” 的保证。
隔离性
: 同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。
这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要 **多对象事务multi-object transaction** 来保持多块数据同步。[图 7-2](/v1/ddia_0702.png) 展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量:
```sql
SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
```
但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。
在 [图 7-2](/v1/ddia_0702.png) 中,用户 2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生 [^ii]。隔离性可以避免这个问题:通过确保用户 2 要么同时看到新邮件和增长后的计数器,要么都看不到,而不是一个前后矛盾的中间结果。
[^ii]: 可以说邮件应用中的错误计数器并不是什么特别重要的问题。但换种方式来看,你可以把未读计数器换成客户账户余额,把邮件收发看成支付交易。
![](/v1/ddia_0702.png)
**图 7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。**
[图 7-3](/v1/ddia_0703.png) 说明了对原子性的需求:如果在事务过程中发生错误,邮箱和未读计数器的内容可能会失去同步。在原子事务中,如果对计数器的更新失败,事务将被中止,并且插入的电子邮件将被回滚。
![](/v1/ddia_0703.png)
**图 7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致**
多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,`BEGIN TRANSACTION` 和 `COMMIT` 语句之间的所有内容,被认为是同一事务的一部分.[^iii]
[^iii]: 这并不完美。如果 TCP 连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定 TCP 连接。后续再 “[数据库的端到端原则](/v1/ch12#数据库的端到端原则)” 一节将回到这个主题。
另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象 API例如某键值存储可能具有在一个操作中更新几个键的 multi-put 操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
#### 单对象写入
当单个对象发生改变时,原子性和隔离性也是适用的。例如,假设你正在向数据库写入一个 20 KB 的 JSON 文档:
- 如果在发送第一个 10 KB 之后网络连接中断,数据库是否存储了不可解析的 10KB JSON 片段?
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(请参阅 “[让 B 树更可靠](/v1/ch3#让B树更可靠)”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
一些数据库也提供更复杂的原子操作 [^iv],例如自增操作,这样就不再需要像 [图 7-1](/v1/ddia_0701.png) 那样的读取 - 修改 - 写入序列了。同样流行的是 **[比较和设置CAS, compare-and-set](#比较并设置CAS)** 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
[^iv]: 严格地说,**原子自增atomic increment** 这个术语在多线程编程的意义上使用了原子这个词。在 ACID 的情况下,它实际上应该被称为 **隔离的isolated** 的或 **可串行的serializable** 的增量。但这就太吹毛求疵了。
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(请参阅 “[防止丢失更新](#防止丢失更新)”。但它们不是通常意义上的事务。CAS 以及其他单一对象操作被称为 “轻量级事务”,甚至出于营销目的被称为 “ACID”【20,21,22】但是这个术语是误导性的。事务通常被理解为**将多个对象上的多个操作合并为一个执行单元的机制**。
#### 多对象事务的需求
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。[第九章](/v1/ch9) 将讨论分布式事务的实现。
但是我们是否需要多对象事务?**是否有可能只用键值数据模型和单对象操作来实现任何应用程序?**
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 —— 更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(请参阅 “[关系型数据库与文档数据库在今日的对比](/v1/ch2#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图 7-2](/v1/ddia_0702.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
* 在具有次级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在 “[弱隔离级别](#弱隔离级别)” 中讨论这些问题,并在 [第十二章](/v1/ch12) 中探讨其他方法。
#### 处理错误和中止
事务的一个关键特性是如果发生错误它可以中止并安全地重试。ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
然而并不是所有的系统都遵循这个哲学。特别是具有 [无主复制](/v1/ch5#无主复制) 的数据存储,主要是在 “尽力而为” 的基础上进行工作。可以概括为 “数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情” —— 所以,从错误中恢复是应用程序的责任。
错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像 Rails 的 ActiveRecord 和 Django 这样的 **对象关系映射ORM, object-relation Mapping** 框架不会重试中断的事务 —— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。
尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次 —— 除非你有一个额外的应用级去重机制。
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**两阶段提交2PC, two-phase commit** 可以提供帮助(“[原子提交与两阶段提交](/v1/ch9#原子提交与两阶段提交)” 中将讨论这个问题)。
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
## 弱隔离级别
如果两个事务不触及相同的数据,它们可以安全地 **并行parallel** 运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
并发 BUG 很难通过测试找到,因为这样的错误只有在特殊时序下才会触发。这样的时序问题可能非常少发生,通常很难重现 [^译注i]。并发性也很难推理,特别是在大型应用中,你不一定知道哪些其他代码正在访问数据库。在一次只有一个用户时,应用开发已经很麻烦了,有许多并发用户使得它更加困难,因为任何一个数据都可能随时改变。
[^译注i]: 轶事:偶然出现的瞬时错误有时称为 ***Heisenbug***,而确定性的问题对应地称为 ***Bohrbugs***
出于这个原因,数据库一直试图通过提供 **事务隔离transaction isolation** 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可串行的serializable** 隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。
实际上不幸的是:隔离并没有那么简单。**可串行的隔离** 会有性能损失许多数据库不愿意支付这个代价【8】。因此系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。这些隔离级别难以理解并且会导致微妙的错误但是它们仍然在实践中被使用【23】。
弱事务隔离级别导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】耗费了财务审计人员的调查【26】并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是 “如果你正在处理财务数据,请使用 ACID 数据库!” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是 “ACID”也使用弱隔离级别所以它们也不一定能防止这些错误的发生。
比起盲目地依赖工具,我们需要对存在的各种并发问题,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
在本节中,我们将看几个在实践中使用的弱(**非串行的**,即 nonserializable隔离级别并详细讨论哪种竞争条件可能发生也可能不发生以便你可以决定什么级别适合你的应用程序。一旦我们完成了这个工作我们将详细讨论可串行化请参阅 “[可串行化](#可串行化)”。我们讨论的隔离级别将是非正式的通过示例来进行。如果你需要严格的定义和分析它们的属性你可以在学术文献中找到它们【28,29,30】。
### 读已提交
最基本的事务隔离级别是 **读已提交Read Committed**[^v],它提供了两个保证:
1. 从数据库读时,只能看到已提交的数据(没有 **脏读**,即 dirty reads
2. 写入数据库时,只会覆盖已提交的数据(没有 **脏写**,即 dirty writes
我们来更详细地讨论这两个保证。
[^v]: 某些数据库支持甚至更弱的隔离级别,称为 **读未提交Read uncommitted**。它可以防止脏写,但不防止脏读。
#### 没有脏读
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做 **脏读dirty reads**【2】。
**读已提交** 隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如 [图 7-4](/v1/ddia_0704.png) 所示,用户 1 设置了 `x = 3`,但用户 2 的 `get x` 仍旧返回旧值 2 (当用户 1 尚未提交时)。
![](/v1/ddia_0704.png)
**图 7-4 没有脏读:用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。**
为什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在 [图 7-2](/v1/ddia_0702.png) 中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
- 如果事务中止,则所有写入操作都需要回滚(如 [图 7-3](/v1/ddia_0703.png) 所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
#### 没有脏写
如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前面的写入。
但是,如果先前的写入是尚未提交事务的一部分,使得后面的写入覆盖了一个尚未提交的值,这时会发生什么呢?这被称作 **脏写dirty write**【28】。在 **读已提交** 的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
通过防止脏写,这个隔离级别避免了一些并发问题:
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图 7-5](/v1/ddia_0705.png)以一个二手车销售网站为例Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在 [图 7-5](/v1/ddia_0705.png) 的情况下,销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了 Alice因为她成功更新了发票表。读已提交会防止这样的事故。
- 但是,读已提交并不能防止 [图 7-1](/v1/ddia_0701.png) 中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在 “[防止丢失更新](#防止丢失更新)” 中将讨论如何使这种计数器增量安全。
![](/v1/ddia_0705.png)
**图 7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起**
#### 实现读已提交
**读已提交** 是一个非常流行的隔离级别。这是 Oracle 11g、PostgreSQL、SQL Server 2012、MemSQL 和其他许多数据库的默认设置【8】。
最常见的情况是,数据库通过使用 **行锁row-level lock** 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这将确保在对象具有脏的、未提交的值时不会发生读取(因为在此期间,锁将由进行写入的事务持有)。
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会影响只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
出于这个原因,大多数数据库 [^vi] 使用 [图 7-4](/v1/ddia_0704.png) 的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。只有当新值提交后,事务才会切换到读取新值。
[^vi]: 在撰写本文时,唯一在读已提交隔离级别使用读锁的主流数据库是 IBM DB2 和使用 `read_committed_snapshot = off` 配置的 Microsoft SQL Server【23,36】。
### 快照隔离和可重复读
如果只从表面上看读已提交隔离级别,你可能就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 [图 7-6](/v1/ddia_0706.png) 说明了读已提交时可能发生的问题。
![](/v1/ddia_0706.png)
**图 7-6 读取偏差Alice 观察数据库处于不一致的状态**
Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新的余额为 400 美元)。对 Alice 来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。
这种异常被称为 **不可重复读nonrepeatable read****读取偏差read skew**:如果 Alice 在事务结束时再次读取账户 1 的余额她将看到与她之前的查询中看到的不同的值600 美元)。在读已提交的隔离条件下,**不可重复读** 被认为是可接受的Alice 看到的帐户余额确实在阅读时已经提交了。
> 不幸的是,术语 **偏差skew** 这个词是过载的:以前使用它是因为热点的不平衡工作量(请参阅 “[负载偏斜与热点消除](/v1/ch6#负载偏斜与热点消除)”),而这里偏差意味着异常的时序。
对于 Alice 的情况,这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面,她很可能会看到一致的帐户余额。但是有些情况下,不能容忍这种暂时的不一致:
备份
: 进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
分析查询和完整性检查
: 有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅 “[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
**快照隔离snapshot isolation**【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的 **一致快照consistent snapshot** 中读取 —— 也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。
快照隔离是一个流行的功能PostgreSQL、使用 InnoDB 引擎的 MySQL、Oracle、SQL Server 等都支持【23,31,32】。
#### 实现快照隔离
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅 “[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取则不需要加锁。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作,且两者间没有任何锁争用。
为了实现快照隔离,数据库使用了我们看到的用于防止 [图 7-4](/v1/ddia_0704.png) 中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 **多版本并发控制MVCC, multi-version concurrency control**
如果一个数据库只需要提供 **读已提交** 的隔离级别,而不提供 **快照隔离**,那么保留一个对象的两个版本就足够了:已提交的版本和被覆盖但尚未提交的版本。不过支持快照隔离的存储引擎通常也使用 MVCC 来实现 **读已提交** 隔离级别。一种典型的方法是 **读已提交** 为每个查询使用单独的快照,而 **快照隔离** 对整个事务使用相同的快照。
[图 7-7](/v1/ddia_0707.png) 说明了 PostgreSQL 如何实现基于 MVCC 的快照隔离【31】其他实现类似。当一个事务开始时它被赋予一个唯一的永远增长 [^vii] 的事务 ID`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。
[^vii]: 事实上,事务 ID 是 32 位整数,所以大约会在 40 亿次事务之后溢出。PostgreSQL 的 Vacuum 过程会清理老旧的事务 ID确保事务 ID 溢出(回卷)不会影响到数据。
![](/v1/ddia_0707.png)
**图 7-7 使用多版本对象实现快照隔离**
表中的每一行都有一个 `created_by` 字段,其中包含将该行插入到表中的的事务 ID。此外每行都有一个 `deleted_by` 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 `deleted_by` 字段设置为请求删除的事务的 ID 来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。[^译注ii]
[^译注ii]: 在 PostgreSQL 中,`created_by` 的实际名称为 `xmin``deleted_by` 的实际名称为 `xmax`
`UPDATE` 操作在内部翻译为 `DELETE``INSERT` 。例如,在 [图 7-7](/v1/ddia_0707.png) 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元改为 400 美元。实际上包含两条账户 2 的记录:余额为 \$500 的行被标记为 **被事务 13 删除**,余额为 \$400 的行 **由事务 13 创建**
#### 观察一致性快照的可见性规则
当一个事务从数据库中读取时,事务 ID 用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。工作如下:
1. 在每次事务开始时,数据库列出当时所有其他(尚未提交或尚未中止)的事务清单,即使之后提交了,这些事务已执行的任何写入也都会被忽略。
2. 被中止事务所执行的任何写入都将被忽略。
3. 由具有较晚事务 ID在当前事务开始之后开始的的事务所做的任何写入都被忽略而不管这些事务是否已经提交。
4. 所有其他写入,对应用都是可见的。
这些规则适用于创建和删除对象。在 [图 7-7](/v1/ddia_0707.png) 中,当事务 12 从账户 2 读取时,它会看到 \$500 的余额,因为 \$500 余额的删除是由事务 13 完成的(根据规则 3事务 12 看不到事务 13 执行的删除),且 400 美元记录的创建也是不可见的(按照相同的规则)。
换句话说,如果以下两个条件都成立,则可见一个对象:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从来不原地更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销。
#### 索引和快照隔离
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
在实践中许多实现细节决定了多版本并发控制的性能。例如如果同一对象的不同版本可以放入同一个页面中PostgreSQL 的优化可以避免更新索引【31】。
在 CouchDB、Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 [B 树](/v1/ch3#B树),但它们使用的是一种 **仅追加 / 写时拷贝append-only/copy-on-write** 的变体它们在更新时不覆盖树的页面而为每个修改页面创建一份副本。从父页面直到树根都会级联更新以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制并且保持不变【33,34,35】。
使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一棵新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
#### 可重复读与命名混淆
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在 Oracle 中称为 **可串行化Serializable** 的,在 PostgreSQL 和 MySQL 中称为 **可重复读repeatable read**【23】。
这种命名混淆的原因是 SQL 标准没有 **快照隔离** 的概念,因为标准是基于 System R 1975 年定义的隔离级别【2】那时候 **快照隔离** 尚未发明。相反,它定义了 **可重复读**表面上看起来与快照隔离很相似。PostgreSQL 和 MySQL 称其 **快照隔离** 级别为 **可重复读repeatable read**,因为这样符合标准要求,所以它们可以声称自己 “标准兼容”。
不幸的是SQL 标准对隔离级别的定义是有缺陷的 —— 模糊不精确并不像标准应有的样子独立于实现【28】。有几个数据库实现了可重复读但它们实际提供的保证存在很大的差异尽管表面上是标准化的【23】。在研究文献【29,30】中已经有了可重复读的正式定义但大多数的实现并不能满足这个正式定义。最后IBM DB2 使用 “可重复读” 来引用可串行化【8】。
结果,没有人真正知道 **可重复读** 的意思。
### 防止丢失更新
到目前为止已经讨论的 **读已提交****快照隔离** 级别,主要保证了 **只读事务在并发写入时** 可以看到什么。却忽略了两个事务并发写入的问题 —— 我们只讨论了脏写(请参阅 “[没有脏写](#没有脏写)”),一种特定类型的写 - 写冲突是可能出现的。
并发的写入事务之间还有其他几种有趣的冲突。其中最著名的是 **丢失更新lost update** 问题,如 [图 7-1](/v1/ddia_0701.png) 所示,以两个并发计数器增量为例。
如果应用从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入 **狠揍clobber** 了前面的写入)这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
这是一个普遍的问题,所以已经开发了各种解决方案。
#### 原子写
许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取 - 修改 - 写入序列的需要。如果你的代码可以用这些操作来表达,那这通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:
```sql
UPDATE counters SET value = value + 1 WHERE key = 'foo';
```
类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作Redis 提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如 wiki 页面的更新涉及到任意文本编辑 [^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。请参阅 “[自动冲突解决](/v1/ch5#自动冲突解决)”。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为 **游标稳定性cursor stability**【36,37】。另一个选择是简单地强制所有的原子操作在单一线程上执行。
不幸的是ORM 框架很容易意外地执行不安全的读取 - 修改 - 写入序列而不是使用数据库提供的原子操作【38】。如果你知道自己在做什么那当然不是问题但它经常产生那种很难测出来的微妙 Bug。
#### 显式锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个 **读取 - 修改 - 写入序列** 完成。
例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的棋子。在这种情况下,一个原子操作可能是不够的,因为应用程序还需要确保玩家的移动符合游戏规则,这可能涉及到一些不能合理地用数据库查询实现的逻辑。但你可以使用锁来防止两名玩家同时移动相同的棋子,如例 7-1 所示。
**例 7-1 显式锁定行以防止丢失更新**
```sql
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- 检查玩家的操作是否有效,然后更新先前 SELECT 返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
```
- `FOR UPDATE` 子句告诉数据库应该对该查询返回的所有行加锁。
这是有效的,但要做对,你需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。
#### 自动检测丢失的更新
原子操作和锁是通过强制 **读取 - 修改 - 写入序列** 按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其 **读取 - 修改 - 写入序列**
这种方法的一个优点是数据库可以结合快照隔离高效地执行此检查。事实上PostgreSQL 的可重复读Oracle 的可串行化和 SQL Server 的快照隔离级别都会自动检测到丢失更新并中止惹麻烦的事务。但是MySQL/InnoDB 的可重复读并不会检测 **丢失更新**【23】。一些作者【28,30】认为数据库必须能防止丢失更新才称得上是提供了 **快照隔离**所以在这个定义下MySQL 下不提供快照隔离。
丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。
#### 比较并设置CAS
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置**CAS, 即 Compare And Set先前在 “[单对象写入](#单对象写入)” 中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑后页面内容未发生改变时,才会更新成功:
```sql
-- 根据数据库的实现情况,这可能安全也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';
```
如果内容已经更改并且不再与 “旧内容” 相匹配,则此更新将不起作用,因此你需要检查更新是否生效,必要时重试。但是,如果数据库允许 `WHERE` 子句从旧快照中读取,则此语句可能无法防止丢失更新,因为即使发生了另一个并发写入,`WHERE` 条件也可能为真。在依赖数据库的 CAS 操作前要检查其是否安全。
#### 冲突解决和复制
在复制数据库中(请参阅 [第五章](/v1/ch5)),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
锁和 CAS 操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或 CAS 操作的技术不适用于这种情况(我们将在 “[线性一致性](/v1/ch9#线性一致性)” 中更详细地讨论这个问题)。
相反,如 “[检测并发写入](/v1/ch5#检测并发写入)” 一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。这是 Riak 2.0 数据类型背后的思想它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时Riak 自动将更新合并在一起以免丢失更新【39】。
另一方面最后写入胜利LWW的冲突解决方法很容易丢失更新如 “[最后写入胜利(丢弃并发写入)](/v1/ch5#最后写入胜利(丢弃并发写入))” 中所述。不幸的是LWW 是许多复制数据库中的默认方案。
### 写入偏差与幻读
前面的章节中,我们看到了 **脏写****丢失更新**,当不同的事务并发地尝试写入相同的对象时,会出现这两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止 —— 既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。
但是,并发写入间可能发生的竞争条件还没有完。在本节中,我们将看到一些更微妙的冲突例子。
首先想象一下这个例子你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命但底线是至少有一位医生在待命。医生可以放弃他们的班次例如如果他们自己生病了只要至少有一个同事在这一班中继续工作【40,41】。
现在想象一下Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。[图 7-8](/v1/ddia_0708.png) 说明了接下来的事情。
![](/v1/ddia_0708.png)
**图 7-8 写入偏差导致应用程序错误的示例**
在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
#### 写入偏差的特征
这种异常称为 **写入偏差**【28】。它既不是 **脏写**,也不是 **丢失更新**因为这两个事务正在更新两个不同的对象Alice 和 Bob 各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能发生。
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时序)。
我们已经看到,有各种不同的方法来防止丢失的更新。但对于写入偏差,我们的选择更受限制:
* 由于涉及多个对象,单对象的原子操作不起作用。
* 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在 PostgreSQL 的可重复读MySQL/InnoDB 的可重复读Oracle 可串行化或 SQL Server 的快照隔离级别中都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可串行化隔离请参阅 “[可串行化](#可串行化)”)。
* 某些数据库允许配置约束然后由数据库强制执行例如唯一性外键约束或特定值限制。但是为了指定至少有一名医生必须在线需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持但是你可以使用触发器或者物化视图来实现它们这取决于不同的数据库【42】。
* 如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
```sql
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = TRUE
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = FALSE
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;
```
* 和以前一样,`FOR UPDATE` 告诉数据库锁定返回的所有行以用于更新。
#### 写入偏差的更多例子
写入偏差乍看像是一个深奥的问题,但一旦意识到这一点,很容易会注意到它可能发生在更多场景下。以下是一些例子:
会议室预订系统
: 比如你想要规定不能在同一时间对同一个会议室进行多次的预订【43】。当有人想要预订时首先检查是否存在相互冲突的预订即预订时间范围重叠的同一房间如果没有找到则创建会议请参阅示例 7-2[^ix]。
[^ix]: 在 PostgreSQL 中,你可以使用范围类型优雅地执行此操作,但在其他数据库中并未得到广泛支持。
**例 7-2 会议室预订系统试图避免重复预订(在快照隔离下不安全)**
```sql
BEGIN TRANSACTION;
-- 检查所有现存的与 12:00~13:00 重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
-- 如果之前的查询返回 0
INSERT INTO bookings(room_id, start_time, end_time, user_id)
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
COMMIT;
```
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
多人游戏
: 在 [例 7-1]() 中我们使用一个锁来防止丢失更新也就是确保两个玩家不能同时移动同一个棋子。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置或者采取其他违反游戏规则的行为。取决于你正在执行的规则类型也许可以使用唯一约束unique constraint否则你很容易发生写入偏差。
抢注用户名
: 在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
防止双重开支
: 允许用户花钱或使用积分的服务需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点列出帐户中的所有项目并检查总和是否为正值【44】。在写入偏差场景下可能会发生两个支出项目同时插入一起导致余额变为负值但这两个事务都不会注意到另一个。
#### 导致写入偏差的幻读
所有这些例子都遵循类似的模式:
1. 一个 `SELECT` 查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)
2. 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。
在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(`SELECT FOR UPDATE`)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否 **不存在** 某些满足条件的行,写入会 **添加** 一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则 `SELECT FOR UPDATE` 锁不了任何东西。
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为 **幻读**【3】。快照隔离避免了只读查询中幻读但是在像我们讨论的例子那样的读写事务中幻读会导致特别棘手的写入偏差情况。
#### 物化冲突
如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。
现在,要创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
这种方法被称为 **物化冲突materializing conflicts**因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是弄清楚如何物化冲突可能很难也很容易出错并且让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因如果没有其他办法可以实现物化冲突应被视为最后的手段。在大多数情况下。**可串行化Serializable** 的隔离级别是更可取的。
## 可串行化
在本章中,已经看到了几个易于出现竞争条件的事务例子。**读已提交** 和 **快照隔离** 级别会阻止某些竞争条件,但不会阻止另一些。我们遇到了一些特别棘手的例子,**写入偏差** 和 **幻读**。这是一个可悲的情况:
- 隔离级别难以理解,并且在不同的数据库中实现的不一致(例如,“可重复读” 的含义天差地别)。
- 光检查应用代码很难判断在特定的隔离级别运行是否安全。特别是在大型应用程序中,你可能并不知道并发发生的所有事情。
- 没有检测竞争条件的好工具。原则上来说静态分析可能会有帮助【26】但研究中的技术还没法实际应用。并发问题的测试是很难的因为它们通常是非确定性的 —— 只有在倒霉的时序下才会出现问题。
这不是一个新问题,从 20 世纪 70 年代以来就一直是这样了当时首先引入了较弱的隔离级别【2】。一直以来研究人员的答案都很简单使用 **可串行化serializable** 的隔离级别!
**可串行化Serializability** 隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止 **所有** 可能的竞争条件。
但如果可串行化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可串行化的选项,以及它们如何执行。目前大多数提供可串行化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术:
- 字面意义上地串行顺序执行事务(请参阅 “[真的串行执行](#真的串行执行)”)
- **两阶段锁定2PL, two-phase locking**,几十年来唯一可行的选择(请参阅 “[两阶段锁定](#两阶段锁定)”)
- 乐观并发控制技术,例如 **可串行化快照隔离**serializable snapshot isolation请参阅 “[可串行化快照隔离](#可串行化快照隔离)”)
现在将主要在单节点数据库的背景下讨论这些技术;在 [第九章](/v1/ch9) 中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
### 真的串行执行
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测 / 防止事务间冲突的问题,由此产生的隔离,正是可串行化的定义。
尽管这似乎是一个明显的主意,但数据库设计人员只是在 2007 年左右才决定单线程循环执行事务是可行的【45】。如果多线程并发在过去的 30 年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行变为可能呢?
两个进展引发了这个反思:
- RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中(请参阅 “[在内存中存储一切](/v1/ch3#在内存中存储一切)”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
- 数据库设计人员意识到 OLTP 事务通常很短,而且只进行少量的读写操作(请参阅 “[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
串行执行事务的方法在 VoltDB/H-Store、Redis 和 Datomic 中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统性能更好因为它可以避免锁的协调开销。但是其吞吐量仅限于单个 CPU 核的吞吐量。为了充分利用单一线程,需要有与传统形式的事务不同的结构。
#### 在存储过程中封装事务
在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。
不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效完成这项工作,因此几乎所有的 OLTP 应用程序都避免在事务中等待交互式的用户输入,以此来保持事务的简短。在 Web 上,这意味着事务在同一个 HTTP 请求中被提交 —— 一个事务不会跨越多个请求。一个新的 HTTP 请求开始一个新的事务。
即使已经将人类从关键路径中排除,事务仍然以交互式的客户端 / 服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如 [图 7-9](/v1/ddia_0709.png) 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。
![](/v1/ddia_0709.png)
**图 7-9 交互式事务和存储过程之间的区别(使用图 7-8 的示例事务)**
#### 存储过程的优点和缺点
存储过程在关系型数据库中已经存在了一段时间了,自 1999 年以来它们一直是 SQL 标准SQL/PSM的一部分。出于各种原因它们的名声有点不太好
- 每个数据库厂商都有自己的存储过程语言Oracle 有 PL/SQLSQL Server 有 T-SQLPostgreSQL 有 PL/pgSQL等等。这些语言并没有跟上通用编程语言的发展所以从今天的角度来看它们看起来相当丑陋和陈旧而且缺乏大多数编程语言中能找到的库的生态系统。
- 在数据库中运行的代码难以管理:与应用服务器相比,它更难调试,更难以保持版本控制和部署,更难测试,并且难以集成到指标收集系统来进行监控。
- 数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或 CPU 时间)会比在应用服务器中相同的代码造成更多的麻烦。
但是这些问题都是可以克服的。现代的存储过程实现放弃了 PL/SQL而是使用现有的通用编程语言VoltDB 使用 Java 或 GroovyDatomic 使用 Java 或 Clojure而 Redis 使用 Lua。
**存储过程与内存存储**,使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O且避免了并发控制机制的开销它们可以在单个线程上实现相当好的吞吐量。
VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此 VoltDB 要求存储过程是 **确定性的**(在不同的节点上运行时,它们必须产生相同的结果)。举个例子,如果事务需要使用当前的日期和时间,则必须通过特殊的确定性 API 来实现。
#### 分区
顺序执行所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。
为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区(请参阅 [第六章](/v1/ch6)),在 VoltDB 中支持这样做。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的 CPU 核,事务吞吐量就可以与 CPU 核数保持线性伸缩【47】。
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。
由于跨分区事务具有额外的协调开销所以它们比单分区事务慢得多。VoltDB 报告的吞吐量大约是每秒 1000 个跨分区写入比单分区吞吐量低几个数量级并且不能通过增加更多的机器来增加吞吐量【49】。
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调(请参阅 “[分区与次级索引](/v1/ch6#分区与次级索引)”)。
#### 串行执行小结
在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。
- 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问这些磁盘中的数据,系统就会变得非常慢 [^x]。
- 写入吞吐量必须低到能在单个 CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
- 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为 **反缓存anti-caching**,正如前面在 “[在内存中存储一切](/v1/ch3#在内存中存储一切)” 中所述。
### 两阶段锁定
大约 30 年来,在数据库中只有一种广泛使用的串行化算法:**两阶段锁定2PLtwo-phase locking** [^xi]
[^xi]: 有时也称为 **严格两阶段锁定SS2PL, strong strict two-phase locking**,以便和其他 2PL 变体区分。
> #### 2PL不是2PC
>
> 请注意虽然两阶段锁定2PL听起来非常类似于两阶段提交2PC但它们是完全不同的东西。我们将在 [第九章](/v1/ch9) 讨论 2PC。
之前我们看到锁通常用于防止脏写(请参阅 “[没有脏写](#没有脏写)” 一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 **独占访问exclusive access** 权限:
- 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
- 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(像 [图 7-1](/v1/ddia_0701.png) 那样读取旧版本的对象在 2PL 下是不可接受的)。
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 **读不阻塞写,写也不阻塞读**(请参阅 “[实现快照隔离](#实现快照隔离)”),这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
#### 实现两阶段锁
2PL 用于 MySQLInnoDB和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别【23,36】。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 **共享模式shared mode****独占模式exclusive mode**。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做 **死锁Deadlock**。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
#### 两阶段锁定的性能
两阶段锁定的巨大缺点,以及 70 年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(请参阅 “[描述性能](/v1/ch1#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁,但在基于 2PL 实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
#### 谓词锁
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在 “[导致写入偏差的幻读](#导致写入偏差的幻读)” 中,我们讨论了 **幻读phantoms** 的问题。即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防止 **幻读**
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见 [例 7-2]()),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
如何实现这一点?从概念上讲,我们需要一个 **谓词锁predicate lock**【3】。它类似于前面描述的共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
```sql
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00';
```
谓词锁限制访问,如下所示:
- 如果事务 A 想要读取匹配某些条件的对象,就像在这个 `SELECT` 查询中那样,它必须获取查询条件上的 **共享谓词锁shared-mode predicate lock**。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。
- 如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
#### 索引范围锁
不幸的是谓词锁性能不佳:**如果活跃事务持有很多锁,检查匹配的锁会非常耗时。** 因此,大多数使用 2PL 的数据库实际上实现了索引范围锁index-range locking也称为 **next-key locking**这是一个简化的近似版谓词锁【41,50】。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段,或者锁定 12:00~13:00 时间段的所有房间(不只是 123 号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
在房间预订数据库中,你可能会在 `room_id` 列上有一个索引,并且 / 或者在 `start_time``end_time` 上有索引(否则前面的查询在大型数据库上的速度会非常慢):
- 假设你的索引位于 `room_id` 上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。
- 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将 12:00~13:00 时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入、更新或删除同一个房间和 / 或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
### 可串行化快照隔离
本章描绘了数据库中并发控制的黯淡画面。一方面我们实现了性能不好2PL或者伸缩性不好串行执行的可串行化隔离级别。另一方面我们有性能良好的弱隔离级别但容易出现各种竞争条件丢失更新、写入偏差、幻读等。串行化的隔离级别和高性能是从根本上相互矛盾的吗
也许不是:一个称为 **可串行化快照隔离SSI, serializable snapshot isolation** 的算法是非常有前途的。它提供了完整的可串行化隔离级别但与快照隔离相比只有很小的性能损失。SSI 是相当新的:它在 2008 年首次被描述【40】并且是 Michael Cahill 的博士论文【51】的主题。
今天SSI 既用于单节点数据库PostgreSQL9.1 以后的可串行化隔离级别也用于分布式数据库FoundationDB 使用类似的算法)。由于 SSI 与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
#### 悲观与乐观的并发控制
两阶段锁是一种所谓的 **悲观并发控制机制pessimistic** :它是基于这样的原则:如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有 “锁”。
相比之下,**串行化快照隔离** 是一种 **乐观optimistic** 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
乐观并发控制是一个古老的想法【52】其优点和缺点已经争论了很长时间【53】。如果存在很多 **争用**contention即很多事务试图访问相同的对象则表现不佳因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量来自重试事务的额外负载可能会使性能变差。
但是,如果有足够的空闲容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的性能要好。可交换的原子操作可以减少争用:例如,如果多个事务同时要增加一个计数器,那么应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要了,所以并发增量可以全部应用且不会有冲突。
顾名思义SSI 基于快照隔离 —— 也就是说,事务中的所有读取都是来自数据库的一致性快照(请参阅 “[快照隔离和可重复读取](#快照隔离和可重复读)”。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
#### 基于过时前提的决策
先前讨论了快照隔离中的写入偏差(请参阅 “[写入偏差与幻读](#写入偏差与幻读)”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
换句话说,事务基于一个 **前提premise** 采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)。之后当事务要提交时,原始数据可能已经改变 —— 前提可能不再成立。
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。换而言之,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
- 检测对旧 MVCC 对象版本的读取(读之前存在未提交的写入)
- 检测影响先前读取的写入(读之后发生写入)
#### 检测旧MVCC读取
回想一下快照隔离通常是通过多版本并发控制MVCC见 [图 7-10](/v1/ddia_0710.png))来实现的。当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在 [图 7-10](/v1/ddia_0710.png) 中,事务 43 认为 Alice 的 `on_call = true` ,因为事务 42修改 Alice 的待命状态)未被提交。然而,在事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真。
![](/v1/ddia_0710.png)
**图 7-10 检测事务何时从 MVCC 快照读取过时的值**
为了防止这种异常,数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务 43 ?因为如果事务 43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务 43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务 42 可能在事务 43 被提交的时候中止或者可能仍然未被提交因此读取可能终究不是陈旧的。通过避免不必要的中止SSI 保留了快照隔离从一致快照中长时间读取的能力。
#### 检测影响之前读取的写入
第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如 [图 7-11](/v1/ddia_0711.png) 所示。
![](/v1/ddia_0711.png)
**图 7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。**
在两阶段锁定的上下文中,我们讨论了索引范围锁(请参阅 “[索引范围锁](#索引范围锁)”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务。
在 [图 7-11](/v1/ddia_0711.png) 中,事务 42 和 43 都在班次 1234 查找值班医生。如果在 `shift_id` 上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但锁并不会阻塞事务直到其他读事务完成,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦。
在 [图 7-11](/v1/ddia_0711.png) 中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。
#### 可串行化快照隔离的性能
与往常一样,许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的 **粒度granularity**。如果数据库详细地跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。
在某些情况下事务可以读取被另一个事务覆盖的信息这取决于发生了什么有时可以证明执行结果无论如何都是可串行化的。PostgreSQL 使用这个理论来减少不必要的中止次数【11,41】。
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,波动更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
与串行执行相比,可串行化快照隔离并不局限于单个 CPU 核的吞吐量FoundationDB 将串行化冲突的检测分布在多台机器上允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据【54】。
中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短只读的长事务可能没问题。对于慢事务SSI 可能比两阶段锁定或串行执行更不敏感。
## 本章小结
事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。各式各样的错误被简化为一种简单情况:**事务中止transaction abort**,而应用需要的仅仅是重试。
在本章中介绍了很多问题,事务有助于防止这些问题发生。并非所有应用都易受此类问题影响:具有非常简单访问模式的应用(例如每次读写单条记录)可能无需事务管理。但是对于更复杂的访问模式,事务可以大大减少需要考虑的潜在错误情景数量。
如果没有事务处理,各种错误情况(进程崩溃、网络中断、停电、磁盘已满、意外并发等)意味着数据可能以各种方式变得不一致。例如,非规范化的数据可能很容易与源数据不同步。如果没有事务处理,就很难推断复杂的交互访问可能对数据库造成的影响。
本章深入讨论了 **并发控制** 的话题。我们讨论了几个广泛使用的隔离级别,特别是 **读已提交**、**快照隔离**(有时称为可重复读)和 **可串行化**。并通过研究竞争条件的各种例子,来描述这些隔离等级:
脏读
: 一个客户端读取到另一个客户端尚未提交的写入。**读已提交** 或更强的隔离级别可以防止脏读。
脏写
: 一个客户端覆盖写入了另一个客户端尚未提交的写入。几乎所有的事务实现都可以防止脏写。
读偏差(不可重复读)
: 在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。**快照隔离** 经常用于解决这个问题,它允许事务从一个特定时间点的一致性快照中读取数据。快照隔离通常使用 **多版本并发控制MVCC** 来实现。
丢失更新
: 两个客户端同时执行 **读取 - 修改 - 写入序列**。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(`SELECT FOR UPDATE`)。
写偏差
: 一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。
幻读
: 事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
弱隔离级别可以防止其中一些异常情况,但要求你,也就是应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。我们讨论了实现可串行化事务的三种不同方法:
字面意义上的串行执行
: 如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个 CPU 核上处理,这是一个简单而有效的选择。
两阶段锁定
: 数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
可串行化快照隔离SSI
: 一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。
本章中的示例主要是在关系数据模型的上下文中。但是,正如在 **“[多对象事务的需求](#多对象事务的需求)”** 中所讨论的,无论使用哪种数据模型,事务都是有价值的数据库功能。
本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,则引入了一系列新的困难挑战,我们将在接下来的两章中讨论。
## 参考文献
1. Donald D. Chamberlin, Morton M. Astrahan, Michael W. Blasgen, et al.: “[A History and Evaluation of System R](https://citeseerx.ist.psu.edu/pdf/ebb29a0ca16e04e7eeb6b606b22a9eadb3a9d531),” *Communications of the ACM*, volume 24, number 10, pages 632646, October 1981. [doi:10.1145/358769.358784](http://dx.doi.org/10.1145/358769.358784)
1. Jim N. Gray, Raymond A. Lorie, Gianfranco R. Putzolu, and Irving L. Traiger: “[Granularity of Locks and Degrees of Consistency in a Shared Data Base](https://citeseerx.ist.psu.edu/pdf/e127f0a6a912bb9150ecfe03c0ebf7fbc289a023),” in *Modelling in Data Base Management Systems: Proceedings of the IFIP Working Conference on Modelling in Data Base Management Systems*, edited by G. M. Nijssen, pages 364394, Elsevier/North Holland Publishing, 1976. Also in *Readings in Database Systems*, 4th edition, edited by Joseph M. Hellerstein and Michael Stonebraker, MIT Press, 2005. ISBN: 978-0-262-69314-1
1. Kapali P. Eswaran, Jim N. Gray, Raymond A. Lorie, and Irving L. Traiger: “[The Notions of Consistency and Predicate Locks in a Database System](http://research.microsoft.com/en-us/um/people/gray/papers/On%20the%20Notions%20of%20Consistency%20and%20Predicate%20Locks%20in%20a%20Database%20System%20CACM.pdf),” *Communications of the ACM*, volume 19, number 11, pages 624633, November 1976.
1. “[ACID Transactions Are Incredibly Helpful](http://web.archive.org/web/20150320053809/https://foundationdb.com/acid-claims),” FoundationDB, LLC, 2013.
1. John D. Cook: “[ACID Versus BASE for Database Transactions](http://www.johndcook.com/blog/2009/07/06/brewer-cap-theorem-base/),” *johndcook.com*, July 6, 2009.
1. Gavin Clarke: “[NoSQL's CAP Theorem Busters: We Don't Drop ACID](http://www.theregister.co.uk/2012/11/22/foundationdb_fear_of_cap_theorem/),” *theregister.co.uk*, November 22, 2012.
1. Theo Härder and Andreas Reuter: “[Principles of Transaction-Oriented Database Recovery](https://citeseerx.ist.psu.edu/pdf/11ef7c142295aeb1a28a0e714c91fc8d610c3047),” *ACM Computing Surveys*, volume 15, number 4, pages 287317, December 1983. [doi:10.1145/289.291](http://dx.doi.org/10.1145/289.291)
1. Peter Bailis, Alan Fekete, Ali Ghodsi, et al.: “[HAT, not CAP: Towards Highly Available Transactions](http://www.bailis.org/papers/hat-hotos2013.pdf),” at *14th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2013.
1. Armando Fox, Steven D. Gribble, Yatin Chawathe, et al.: “[Cluster-Based Scalable Network Services](https://people.eecs.berkeley.edu/~brewer/cs262b/TACC.pdf),” at *16th ACM Symposium on Operating Systems Principles* (SOSP), October 1997.
1. Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman: [*Concurrency Control and Recovery in Database Systems*](https://www.microsoft.com/en-us/research/people/philbe/book/). Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at *research.microsoft.com*.
1. Alan Fekete, Dimitrios Liarokapis, Elizabeth O'Neil, et al.: “[Making Snapshot Isolation Serializable](https://www.cse.iitb.ac.in/infolab/Data/Courses/CS632/2009/Papers/p492-fekete.pdf),” *ACM Transactions on Database Systems*, volume 30, number 2, pages 492528, June 2005. [doi:10.1145/1071610.1071615](http://dx.doi.org/10.1145/1071610.1071615)
1. Mai Zheng, Joseph Tucek, Feng Qin, and Mark Lillibridge: “[Understanding the Robustness of SSDs Under Power Fault](https://www.usenix.org/system/files/conference/fast13/fast13-final80.pdf),” at *11th USENIX Conference on File and Storage Technologies* (FAST), February 2013.
1. Laurie Denness: “[SSDs: A Gift and a Curse](https://laur.ie/blog/2015/06/ssds-a-gift-and-a-curse/),” *laur.ie*, June 2, 2015.
1. Adam Surak: “[When Solid State Drives Are Not That Solid](https://blog.algolia.com/when-solid-state-drives-are-not-that-solid/),” *blog.algolia.com*, June 15, 2015.
1. Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, et al.: “[All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications](http://research.cs.wisc.edu/wind/Publications/alice-osdi14.pdf),” at *11th USENIX Symposium on Operating Systems Design and Implementation* (OSDI), October 2014.
1. Chris Siebenmann: “[Unix's File Durability Problem](https://utcc.utoronto.ca/~cks/space/blog/unix/FileSyncProblem),” *utcc.utoronto.ca*, April 14, 2016.
1. Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, et al.: “[An Analysis of Data Corruption in the Storage Stack](http://research.cs.wisc.edu/adsl/Publications/corruption-fast08.pdf),” at *6th USENIX Conference on File and Storage Technologies* (FAST), February 2008.
1. Bianca Schroeder, Raghav Lagisetty, and Arif Merchant: “[Flash Reliability in Production: The Expected and the Unexpected](https://www.usenix.org/conference/fast16/technical-sessions/presentation/schroeder),” at *14th USENIX Conference on File and Storage Technologies* (FAST), February 2016.
1. Don Allison: “[SSD Storage Ignorance of Technology Is No Excuse](https://blog.korelogic.com/blog/2015/03/24),” *blog.korelogic.com*, March 24, 2015.
1. Dave Scherer: “[Those Are Not Transactions (Cassandra 2.0)](http://web.archive.org/web/20150526065247/http://blog.foundationdb.com/those-are-not-transactions-cassandra-2-0),” *blog.foundationdb.com*, September 6, 2013.
1. Kyle Kingsbury: “[Call Me Maybe: Cassandra](http://aphyr.com/posts/294-call-me-maybe-cassandra/),” *aphyr.com*, September 24, 2013.
1. “[ACID Support in Aerospike](https://web.archive.org/web/20170305002118/https://www.aerospike.com/docs/architecture/assets/AerospikeACIDSupport.pdf),” Aerospike, Inc., June 2014.
1. Martin Kleppmann: “[Hermitage: Testing the 'I' in ACID](http://martin.kleppmann.com/2014/11/25/hermitage-testing-the-i-in-acid.html),” *martin.kleppmann.com*, November 25, 2014.
1. Tristan D'Agosta: “[BTC Stolen from Poloniex](https://bitcointalk.org/index.php?topic=499580),” *bitcointalk.org*, March 4, 2014.
1. bitcointhief2: “[How I Stole Roughly 100 BTC from an Exchange and How I Could Have Stolen More!](http://www.reddit.com/r/Bitcoin/comments/1wtbiu/how_i_stole_roughly_100_btc_from_an_exchange_and/),” *reddit.com*, February 2, 2014.
1. Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan: “[Automating the Detection of Snapshot Isolation Anomalies](http://www.vldb.org/conf/2007/papers/industrial/p1263-jorwekar.pdf),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007.
1. Michael Melanson: “[Transactions: The Limits of Isolation](https://www.michaelmelanson.net/posts/transactions-the-limits-of-isolation/),” *michaelmelanson.net*, November 30, 2014.
1. Hal Berenson, Philip A. Bernstein, Jim N. Gray, et al.: “[A Critique of ANSI SQL Isolation Levels](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), May 1995.
1. Atul Adya: “[Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions](http://pmg.csail.mit.edu/papers/adya-phd.pdf),” PhD Thesis, Massachusetts Institute of Technology, March 1999.
1. Peter Bailis, Aaron Davidson, Alan Fekete, et al.: “[Highly Available Transactions: Virtues and Limitations (Extended Version)](http://arxiv.org/pdf/1302.0309.pdf),” at *40th International Conference on Very Large Data Bases* (VLDB), September 2014.
1. Bruce Momjian: “[MVCC Unmasked](http://momjian.us/main/presentations/internals.html#mvcc),” *momjian.us*, July 2014.
1. Annamalai Gurusami: “[Repeatable Read Isolation Level in InnoDB How Consistent Read View Works](https://web.archive.org/web/20161225080947/https://blogs.oracle.com/mysqlinnodb/entry/repeatable_read_isolation_level_in),” *blogs.oracle.com*, January 15, 2013.
1. Nikita Prokopov: “[Unofficial Guide to Datomic Internals](http://tonsky.me/blog/unofficial-guide-to-datomic-internals/),” *tonsky.me*, May 6, 2014.
1. Baron Schwartz: “[Immutability, MVCC, and Garbage Collection](https://web.archive.org/web/20220122020806/https://www.xaprb.com/blog/2013/12/28/immutability-mvcc-and-garbage-collection/),” *xaprb.com*, December 28, 2013.
1. J. Chris Anderson, Jan Lehnardt, and Noah Slater: *CouchDB: The Definitive Guide*. O'Reilly Media, 2010. ISBN: 978-0-596-15589-6
1. Rikdeb Mukherjee: “[Isolation in DB2 (Repeatable Read, Read Stability, Cursor Stability, Uncommitted Read) with Examples](http://mframes.blogspot.co.uk/2013/07/isolation-in-cursor.html),” *mframes.blogspot.co.uk*, July 4, 2013.
1. Steve Hilker: “[Cursor Stability (CS) IBM DB2 Community](https://web.archive.org/web/20150420001721/http://www.toadworld.com/platforms/ibmdb2/w/wiki/6661.cursor-stability-cs.aspx),” *toadworld.com*, March 14, 2013.
1. Nate Wiger: “[An Atomic Rant](https://nateware.com/2010/02/18/an-atomic-rant/),” *nateware.com*, February 18, 2010.
1. Joel Jacobson: “[Riak 2.0: Data Types](https://web.archive.org/web/20160327135816/http://blog.joeljacobson.com/riak-2-0-data-types/),” *blog.joeljacobson.com*, March 23, 2014.
1. Michael J. Cahill, Uwe Röhm, and Alan Fekete: “[Serializable Isolation for Snapshot Databases](https://web.archive.org/web/20200709144151/https://cs.nyu.edu/courses/Fall12/CSCI-GA.2434-001/p729-cahill.pdf),” at *ACM International Conference on Management of Data* (SIGMOD), June 2008. [doi:10.1145/1376616.1376690](http://dx.doi.org/10.1145/1376616.1376690)
1. Dan R. K. Ports and Kevin Grittner: “[Serializable Snapshot Isolation in PostgreSQL](http://drkp.net/papers/ssi-vldb12.pdf),” at *38th International Conference on Very Large Databases* (VLDB), August 2012.
1. Tony Andrews: “[Enforcing Complex Constraints in Oracle](http://tonyandrews.blogspot.co.uk/2004/10/enforcing-complex-constraints-in.html),” *tonyandrews.blogspot.co.uk*, October 15, 2004.
1. Douglas B. Terry, Marvin M. Theimer, Karin Petersen, et al.: “[Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System](https://citeseerx.ist.psu.edu/pdf/20c450f099b661c5a2dff3f348773a0d1af1b09b),” at *15th ACM Symposium on Operating Systems Principles* (SOSP), December 1995. [doi:10.1145/224056.224070](http://dx.doi.org/10.1145/224056.224070)
1. Gary Fredericks: “[Postgres Serializability Bug](https://github.com/gfredericks/pg-serializability-bug),” *github.com*, September 2015.
1. Michael Stonebraker, Samuel Madden, Daniel J. Abadi, et al.: “[The End of an Architectural Era (Its Time for a Complete Rewrite)](https://citeseerx.ist.psu.edu/pdf/775d54c66d271028a7d4dadf07cce6f918584cd3),” at *33rd International Conference on Very Large Data Bases* (VLDB), September 2007.
1. John Hugg: “[H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures](https://www.youtube.com/watch?v=hD5M4a1UVz8),” at *Data @Scale Boston*, November 2014.
1. Robert Kallman, Hideaki Kimura, Jonathan Natkins, et al.: “[H-Store: A High-Performance, Distributed Main Memory Transaction Processing System](http://www.vldb.org/pvldb/vol1/1454211.pdf),” *Proceedings of the VLDB Endowment*, volume 1, number 2, pages 14961499, August 2008.
1. Rich Hickey: “[The Architecture of Datomic](http://www.infoq.com/articles/Architecture-Datomic),” *infoq.com*, November 2, 2012.
1. John Hugg: “[Debunking Myths About the VoltDB In-Memory Database](https://dzone.com/articles/debunking-myths-about-voltdb),” *dzone.com*, May 28, 2014.
1. Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton: “[Architecture of a Database System](https://dsf.berkeley.edu/papers/fntdb07-architecture.pdf),” *Foundations and Trends in Databases*, volume 1, number 2, pages 141259, November 2007. [doi:10.1561/1900000002](http://dx.doi.org/10.1561/1900000002)
1. Michael J. Cahill: “[Serializable Isolation for Snapshot Databases](https://ses.library.usyd.edu.au/bitstream/handle/2123/5353/michael-cahill-2009-thesis.pdf),” PhD Thesis, University of Sydney, July 2009.
1. D. Z. Badal: “[Correctness of Concurrency Control and Implications in Distributed Databases](http://ieeexplore.ieee.org/abstract/document/762563/),” at *3rd International IEEE Computer Software and Applications Conference* (COMPSAC), November 1979.
1. Rakesh Agrawal, Michael J. Carey, and Miron Livny: “[Concurrency Control Performance Modeling: Alternatives and Implications](http://www.eecs.berkeley.edu/~brewer/cs262/ConcControl.pdf),” *ACM Transactions on Database Systems* (TODS), volume 12, number 4, pages 609654, December 1987. [doi:10.1145/32204.32220](http://dx.doi.org/10.1145/32204.32220)
1. Dave Rosenthal: “[Databases at 14.4MHz](http://web.archive.org/web/20150427041746/http://blog.foundationdb.com/databases-at-14.4mhz),” *blog.foundationdb.com*, December 10, 2014.

749
content/v1/ch8.md Normal file
View File

@ -0,0 +1,749 @@
---
title: "第八章:分布式系统的麻烦"
linkTitle: "8. 分布式系统的麻烦"
weight: 208
math: true
breadcrumbs: false
---
![](/map/ch08.png)
> 邂逅相遇
>
> 网络延迟
>
> 存之为吾
>
> 无食我数
>
> —— Kyle Kingsbury, Carly Rae Jepsen 《网络分区的危害》2013 年)[^译著1]
最近几章中反复出现的主题是,系统如何处理错误的事情。例如,我们讨论了 **副本故障切换**(“[处理节点中断](/v1/ch5#处理节点宕机)”),**复制延迟**(“[复制延迟问题](/v1/ch5#复制延迟问题)”)和事务控制(“[弱隔离级别](/v1/ch7#弱隔离级别)”)。当我们了解可能在实际系统中出现的各种边缘情况时,我们会更好地处理它们。
但是,尽管我们已经谈了很多错误,但之前几章仍然过于乐观。现实更加黑暗。我们现在将悲观主义最大化,假设任何可能出错的东西 **都会** 出错 [^i]。(经验丰富的系统运维会告诉你,这是一个合理的假设。如果你问得好,他们可能会一边治疗心理创伤一边告诉你一些可怕的故事)
[^i]: 除了一个例外:我们将假定故障是非拜占庭式的(请参阅 “[拜占庭故障](#拜占庭故障)”)。
使用分布式系统与在一台计算机上编写软件有着根本的区别主要的区别在于有许多新颖和刺激的方法可以使事情出错【1,2】。在这一章中我们将了解实践中出现的问题理解我们能够依赖和不可以依赖的东西。
最后,作为工程师,我们的任务是构建能够完成工作的系统(即满足用户期望的保证),尽管一切都出错了。在 [第九章](/v1/ch9) 中,我们将看看一些可以在分布式系统中提供这种保证的算法的例子。但首先,在本章中,我们必须了解我们面临的挑战。
本章对分布式系统中可能出现的问题进行彻底的悲观和沮丧的总结。我们将研究网络的问题(“[不可靠的网络](#不可靠的网络)”); 时钟和时序问题(“[不可靠的时钟](#不可靠的时钟)”); 我们将讨论他们可以避免的程度。所有这些问题的后果都是困惑的,所以我们将探索如何思考一个分布式系统的状态,以及如何推理发生的事情(“[知识、真相与谎言](#知识、真相与谎言)”)。
## 故障与部分失效
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。充满错误的软件可能会让人觉得电脑有时候也会有 “糟糕的一天”(这种问题通常是重新启动就恢复了),但这主要是软件写得不好的结果。
单个计算机上的软件没有根本性的不可靠原因:当硬件正常工作时,相同的操作总是产生相同的结果(这是确定性的)。如果存在硬件问题(例如,内存损坏或连接器松动),其后果通常是整个系统故障(例如,内核恐慌,“蓝屏死机”,启动失败)。装有良好软件的个人计算机通常要么功能完好,要么完全失效,而不是介于两者之间。
这是计算机设计中的一个有意的选择如果发生内部错误我们宁愿电脑完全崩溃而不是返回错误的结果因为错误的结果很难处理。因为计算机隐藏了模糊不清的物理实现并呈现出一个理想化的系统模型并以数学一样的完美的方式运作。CPU 指令总是做同样的事情;如果你将一些数据写入内存或磁盘,那么这些数据将保持不变,并且不会被随机破坏。从第一台数字计算机开始,*始终正确地计算* 这个设计目标贯穿始终【3】。
当你编写运行在多台计算机上的软件时情况有本质上的区别。在分布式系统中我们不再处于理想化的系统模型中我们别无选择只能面对现实世界的混乱现实。而在现实世界中各种各样的事情都可能会出现问题【4】如下面的轶事所述
> 在我有限的从业经历中,我已经和很多东西打过交道:单个 **数据中心DC** 中长期存在的网络分区,配电单元 PDU 故障,交换机故障,整个机架的意外重启,整个数据中心主干网络故障,整个数据中心的电源故障,以及一个低血糖的司机把他的福特皮卡撞在数据中心的 HVAC加热通风和空调系统上。而且我甚至不是一个运维。
>
> —— 柯达黑尔
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为 **部分失效partial failure**。难点在于部分失效是 **不确定性的nondeterministic**:如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
这种不确定性和部分失效的可能性使得分布式系统难以工作【5】。
### 云计算与超级计算机
关于如何构建大型计算系统有一系列的哲学:
* 一个极端是高性能计算HPC领域。具有数千个 CPU 的超级计算机通常用于计算密集型科学计算任务,如天气预报或分子动力学(模拟原子和分子的运动)。
* 另一个极端是 **云计算cloud computing**云计算并不是一个良好定义的概念【6】但通常与多租户数据中心连接 IP 网络(通常是以太网)的商用计算机,弹性 / 按需资源分配以及计量计费等相关联。
* 传统企业数据中心位于这两个极端之间。
不同的哲学会导致不同的故障处理方式。在超级计算机中作业通常会不时地将计算的状态存盘到持久存储中。如果一个节点出现故障通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后计算从上一个检查点重新开始【7,8】。因此超级计算机更像是一个单节点计算机而不是分布式系统通过让部分失败升级为完全失败来处理部分失败 —— 如果系统的任何部分发生故障,只是让所有的东西都崩溃(就像单台机器上的内核恐慌一样)。
在本书中,我们将重点放在实现互联网服务的系统上,这些系统通常与超级计算机看起来有很大不同:
* 许多与互联网有关的应用程序都是 **在线online** 的,因为它们需要能够随时以低延迟服务用户。使服务不可用(例如,停止集群以进行修复)是不可接受的。相比之下,像天气模拟这样的离线(批处理)工作可以停止并重新启动,影响相当小。
* 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和 **远程直接内存访问RDMA** 进行通信。另一方面,云服务中的节点是由商用机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
* 大型数据中心网络通常基于 IP 和以太网,以 CLOS 拓扑排列以提供更高的对分bisection带宽【9】。超级计算机通常使用专门的网络拓扑结构例如多维网格和 Torus 网络 【10】这为具有已知通信模式的 HPC 工作负载提供了更好的性能。
* 系统越大其组件之一就越有可能坏掉。随着时间的推移坏掉的东西得到修复新的东西又坏掉但是在一个有成千上万个节点的系统中有理由认为总是有一些东西是坏掉的【7】。当错误处理的策略只由简单放弃组成时一个大的系统最终会花费大量时间从错误中恢复而不是做有用的工作【8】。
* 如果系统可以容忍发生故障的节点,并继续保持整体工作状态,那么这对于运营和维护非常有用:例如,可以执行滚动升级(请参阅 [第四章](/v1/ch4)),一次重新启动一个节点,同时继续给用户提供不中断的服务。在云环境中,如果一台虚拟机运行不佳,可以杀死它并请求一台新的虚拟机(希望新的虚拟机速度更快)。
* 在地理位置分散的部署中(保持数据在地理位置上接近用户以减少访问延迟),通信很可能通过互联网进行,与本地网络相比,通信速度缓慢且不可靠。超级计算机通常假设它们的所有节点都靠近在一起。
如果要使分布式系统工作,就必须接受部分故障的可能性,并在软件中建立容错机制。换句话说,我们需要从不可靠的组件构建一个可靠的系统(正如 “[可靠性](/v1/ch1#可靠性)” 中所讨论的那样,没有完美的可靠性,所以我们需要理解我们可以实际承诺的极限)。
即使在只有少数节点的小型系统中,考虑部分故障也是很重要的。在一个小系统中,很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,你需要知道在发生故障的情况下,软件可能会表现出怎样的行为。
简单地假设缺陷很罕见并希望始终保持最好的状况是不明智的。考虑一系列可能的错误(甚至是不太可能的错误),并在测试环境中人为地创建这些情况来查看会发生什么是非常重要的。在分布式系统中,怀疑,悲观和偏执狂是值得的。
> #### 从不可靠的组件构建可靠的系统
>
> 你可能想知道这是否有意义 —— 直观地看来系统只能像其最不可靠的组件最薄弱的环节一样可靠。事实并非如此事实上从不太可靠的潜在基础构建更可靠的系统是计算机领域的一个古老思想【11】。例如
>
> * 纠错码允许数字数据在通信信道上准确传输偶尔会出现一些错误例如由于无线网络上的无线电干扰【12】。
> * **互联网协议Internet Protocol, IP** 不可靠可能丢弃、延迟、重复或重排数据包。传输控制协议Transmission Control Protocol, TCP在互联网协议IP之上提供了更可靠的传输层它确保丢失的数据包被重新传输消除重复并且数据包被重新组装成它们被发送的顺序。
>
> 虽然这个系统可以比它的底层部分更可靠但它的可靠性总是有限的。例如纠错码可以处理少量的单比特错误但是如果你的信号被干扰所淹没那么通过信道可以得到多少数据是有根本性的限制的【13】。TCP 可以隐藏数据包的丢失,重复和重新排序,但是它不能神奇地消除网络中的延迟。
>
> 虽然更可靠的高级系统并不完美,但它仍然有用,因为它处理了一些棘手的低级错误,所以其余的错误通常更容易推理和处理。我们将在 “[数据库的端到端原则](/v1/ch12#数据库的端到端原则)” 中进一步探讨这个问题。
## 不可靠的网络
正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,我们在本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径 —— 我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
**无共享** 并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
互联网和数据中心(通常是以太网)中的大多数内部网络都是 **异步分组网络asynchronous packet networks**。在这种网络中,一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。如果你发送请求并期待响应,则很多事情可能会出错(其中一些如 [图 8-1](/v1/ddia_0801.png) 所示):
1. 请求可能已经丢失(可能有人拔掉了网线)。
2. 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
3. 远程节点可能已经失效(可能是崩溃或关机)。
4. 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;请参阅 “[进程暂停](#进程暂停)”),但稍后会再次响应。
5. 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。
6. 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。
![](/v1/ddia_0801.png)
**图 8-1 如果发送请求并没有得到响应则无法区分a请求是否丢失b远程节点是否关闭c响应是否丢失。**
发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。这些问题在异步网络中难以区分:你所拥有的唯一信息是,你尚未收到响应。如果你向另一个节点发送请求并且没有收到响应,则不可能判断是什么原因。
处理这个问题的通常方法是 **超时Timeout**:在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
### 真实世界的网络故障
我们几十年来一直在建设计算机网络 —— 有人可能希望现在我们已经找出了使网络变得可靠的方法。但是现在似乎还没有成功。
有一些系统的研究和大量的轶事证据表明,即使在像一家公司运营的数据中心那样的受控环境中,网络问题也可能出乎意料地普遍。在一家中型数据中心进行的一项研究发现,每个月大约有 12 个网络故障其中一半断开一台机器一半断开整个机架【15】。另一项研究测量了架顶式交换机汇聚交换机和负载平衡器等组件的故障率【16】。它发现添加冗余网络设备不会像你所希望的那样减少故障因为它不能防范人为错误例如错误配置的交换机这是造成中断的主要原因。
诸如 EC2 之类的公有云服务因频繁的暂态网络故障而臭名昭著【14】管理良好的私有数据中心网络可能是更稳定的环境。尽管如此没有人不受网络问题的困扰例如交换机软件升级过程中的一个问题可能会引发网络拓扑重构在此期间网络数据包可能会延迟超过一分钟【17】。鲨鱼可能咬住海底电缆并损坏它们 【18】。其他令人惊讶的故障包括网络接口有时会丢弃所有入站数据包但是成功发送出站数据包 【19】仅仅因为网络链接在一个方向上工作并不能保证它也在相反的方向工作。
> #### 网络分区
>
> 当网络的一部分由于网络故障而被切断时,有时称为 **网络分区network partition****网络断裂netsplit**。在本书中,我们通常会坚持使用更一般的术语 **网络故障network fault**,以避免与 [第六章](/v1/ch6) 讨论的存储系统的分区(分片)相混淆。
即使网络故障在你的环境中非常罕见,故障可能发生的事实,意味着你的软件需要能够处理它们。无论何时通过网络进行通信,都可能会失败,这是无法避免的。
如果网络故障的错误处理没有定义与测试武断地讲各种错误可能都会发生例如即使网络恢复【20】集群可能会发生 **死锁**永久无法为请求提供服务甚至可能会删除所有的数据【21】。如果软件被置于意料之外的情况下它可能会做出出乎意料的事情。
处理网络故障并不意味着容忍它们:如果你的网络通常是相当可靠的,一个有效的方法可能是当你的网络遇到问题时,简单地向用户显示一条错误信息。但是,你确实需要知道你的软件如何应对网络问题,并确保系统能够从中恢复。有意识地触发网络问题并测试系统响应(这是 Chaos Monkey 背后的想法;请参阅 “[可靠性](/v1/ch1#可靠性)”)。
### 检测故障
许多系统需要自动检测故障节点。例如:
* 负载平衡器需要停止向已死亡的节点转发请求(从轮询列表移出,即 out of rotation
* 在单主复制功能的分布式数据库中,如果主库失效,则需要将从库之一升级为新主库(请参阅 “[处理节点宕机](/v1/ch5#处理节点宕机)”)。
不幸的是,网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,你可能会收到一些反馈信息,明确告诉你某些事情没有成功:
* 如果你可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送 FIN 或 RST 来关闭并重用 TCP 连接。但是如果节点在处理请求时发生崩溃则无法知道远程节点实际处理了多少数据【22】。
* 如果节点进程崩溃或被管理员杀死但节点的操作系统仍在运行则脚本可以通知其他节点有关该崩溃的信息以便另一个节点可以快速接管而无需等待超时到期。例如HBase 就是这么做的【23】。
* 如果你有权访问数据中心网络交换机的管理界面,则可以通过它们检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果你通过互联网连接,或者如果你处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
* 如果路由器确认你尝试连接的 IP 地址不可用,则可能会使用 ICMP 目标不可达数据包回复你。但是,路由器不具备神奇的故障检测能力 —— 它受到与网络其他参与者相同的限制。
关于远程节点关闭的快速反馈很有用,但是你不能指望它。即使 TCP 确认已经传送了一个数据包应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的你需要应用程序本身的正确响应【24】。
相反如果出了什么问题你可能会在堆栈的某个层次上得到一个错误响应但总的来说你必须假设你可能根本就得不到任何回应。你可以重试几次TCP 重试是透明的,但是你也可以在应用程序级别重试),等待超时过期,并且如果在超时时间内没有收到响应,则最终声明节点已经死亡。
### 超时与无穷的延迟
如果超时是检测故障的唯一可靠方法,那么超时应该等待多久?不幸的是没有简单的答案。
长时间的超时意味着长时间等待,直到一个节点被宣告死亡(在这段时间内,用户可能不得不等待,或者看到错误信息)。短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)。
过早地声明一个节点已经死了是有问题的:如果这个节点实际上是活着的,并且正在执行一些动作(例如,发送一封电子邮件),而另一个节点接管,那么这个动作可能会最终执行两次。我们将在 “[知识、真相与谎言](#知识、真相与谎言)” 以及 [第九章](/v1/ch9) 和 [第十一章](/v1/ch11) 中更详细地讨论这个问题。
当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致 **级联失效**(即 cascading failure表示在极端情况下所有节点都宣告对方死亡所有节点都将停止工作
设想一个虚构的系统,其网络可以保证数据包的最大延迟 —— 每个数据包要么在一段时间内传送,要么丢失,但是传递永远不会比 $d$ 更长。此外,假设你可以保证一个非故障节点总是在一段时间 $r$ 内处理一个请求。在这种情况下,你可以保证每个成功的请求在 $2d + r$ 时间内都能收到响应,如果你在此时间内没有收到响应,则知道网络或远程节点不工作。如果这是成立的,$2d + r$ 会是一个合理的超时设置。
不幸的是,我们所使用的大多数系统都没有这些保证:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求(请参阅 “[响应时间保证](#响应时间保证)”)。对于故障检测,即使系统大部分时间快速运行也是不够的:如果你的超时时间很短,往返时间只需要一个瞬时尖峰就可以使系统失衡。
#### 网络拥塞和排队
在驾驶汽车时由于交通拥堵道路交通网络的通行时间往往不尽相同。同样计算机网络上数据包延迟的可变性通常是由于排队【25】
* 如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路(如 [图 8-2](/v1/ddia_0802.png) 所示)。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽(这称为网络拥塞)。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
* 当数据包到达目标机器时,如果所有 CPU 内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。
* 在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用 CPU 内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器 【26】排队缓冲进一步增加了网络延迟的可变性。
* TCP 执行 **流量控制**flow control也称为 **拥塞避免**,即 congestion avoidance**背压**,即 backpressure其中节点会限制自己的发送速率以避免网络链路或接收节点过载【27】。这意味着甚至在数据进入网络之前在发送者处就需要进行额外的排队。
![](/v1/ddia_0802.png)
**图 8-2 如果有多台机器将网络流量发送到同一目的地,则其交换机队列可能会被填满。在这里,端口 1,2 和 4 都试图发送数据包到端口 3**
而且,如果 TCP 在某个超时时间内没有被确认(这是根据观察的往返时间计算的),则认为数据包丢失,丢失的数据包将自动重新发送。尽管应用程序没有看到数据包丢失和重新传输,但它看到了延迟(等待超时到期,然后等待重新传输的数据包得到确认)。
> #### TCP与UDP
>
> 一些对延迟敏感的应用程序,比如视频会议和 IP 语音VoIP使用了 UDP 而不是 TCP。这是在可靠性和和延迟变化之间的折衷由于 UDP 不执行流量控制并且不重传丢失的分组,所以避免了网络延迟变化的一些原因(尽管它仍然易受切换队列和调度延迟的影响)。
>
> 在延迟数据毫无价值的情况下UDP 是一个不错的选择。例如,在 VoIP 电话呼叫中,可能没有足够的时间重新发送丢失的数据包,并在扬声器上播放数据。在这种情况下,重发数据包没有意义 —— 应用程序必须使用静音填充丢失数据包的时隙(导致声音短暂中断),然后在数据流中继续。重试发生在人类层(“你能再说一遍吗?声音刚刚断了一会儿。”)。
所有这些因素都会造成网络延迟的变化。当系统接近其最大容量时,排队延迟的变化范围特别大:拥有足够备用容量的系统可以轻松排空队列,而在高利用率的系统中,很快就能积累很长的队列。
在公共云和多租户数据中心中,资源被许多客户共享:网络链接和交换机,甚至每个机器的网卡和 CPU在虚拟机上运行时。批处理工作负载如 MapReduce请参阅 [第十章](/v1/ch10)能够很容易使网络链接饱和。由于无法控制或了解其他客户对共享资源的使用情况如果附近的某个人嘈杂的邻居正在使用大量资源则网络延迟可能会发生剧烈变化【28,29】。
在这种环境下,你只能通过实验方式选择超时:在一段较长的时期内、在多台机器上测量网络往返时间的分布,以确定延迟的预期变化。然后,考虑到应用程序的特性,可以确定 **故障检测延迟****过早超时风险** 之间的适当折衷。
更好的一种做法是,系统不是使用配置的常量超时时间,而是连续测量响应时间及其变化(抖动),并根据观察到的响应时间分布自动调整超时时间。这可以通过 Phi Accrual 故障检测器【30】来完成该检测器在例如 Akka 和 Cassandra 【31】中使用。TCP 的超时重传机制也是以类似的方式工作【27】。
### 同步网络与异步网络
如果我们可以依靠网络来传递一些 **最大延迟固定** 的数据包,而不是丢弃数据包,那么分布式系统就会简单得多。为什么我们不能在硬件层面上解决这个问题,使网络可靠,使软件不必担心呢?
为了回答这个问题,将数据中心网络与非常可靠的传统固定电话网络(非蜂窝,非 VoIP进行比较是很有趣的延迟音频帧和掉话是非常罕见的。一个电话需要一个很低的端到端延迟以及足够的带宽来传输你声音的音频采样数据。在计算机网络中有类似的可靠性和可预测性不是很好吗
当你通过电话网络拨打电话时它会建立一个电路在两个呼叫者之间的整个路线上为呼叫分配一个固定的有保证的带宽量。这个电路会保持至通话结束【32】。例如ISDN 网络以每秒 4000 帧的固定速率运行。呼叫建立时,每个帧内(每个方向)分配 16 位空间。因此,在通话期间,每一方都保证能够每 250 微秒发送一个精确的 16 位音频数据【33,34】。
这种网络是同步的:即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的 16 位空间已经在网络的下一跳中保留了下来。而且由于没有排队,网络的最大端到端延迟是固定的。我们称之为 **有限延迟bounded delay**
#### 我们不能简单地使网络延迟可预测吗?
请注意,电话网络中的电路与 TCP 连接有很大不同:电路是固定数量的预留带宽,在电路建立时没有其他人可以使用,而 TCP 连接的数据包 **机会性地** 使用任何可用的网络带宽。你可以给 TCP 一个可变大小的数据块例如一个电子邮件或一个网页它会尽可能在最短的时间内传输它。TCP 连接空闲时,不使用任何带宽 [^ii]。
[^ii]: 除了偶尔的 keepalive 数据包,如果 TCP keepalive 被启用。
如果数据中心网络和互联网是电路交换网络,那么在建立电路时就可以建立一个受保证的最大往返时间。但是,它们并不能这样:以太网和 IP 是 **分组交换协议**,不得不忍受排队的折磨和因此导致的网络无限延迟,这些协议没有电路的概念。
为什么数据中心网络和互联网使用分组交换?答案是,它们针对 **突发流量bursty traffic** 进行了优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。另一方面,请求网页,发送电子邮件或传输文件没有任何特定的带宽要求 —— 我们只是希望它尽快完成。
如果想通过电路传输文件你得预测一个带宽分配。如果你猜的太低传输速度会不必要的太慢导致网络容量闲置。如果你猜的太高电路就无法建立因为如果无法保证其带宽分配网络不能建立电路。因此将电路用于突发数据传输会浪费网络容量并且使传输不必要地缓慢。相比之下TCP 动态调整数据传输速率以适应可用的网络容量。
已经有一些尝试去建立同时支持电路交换和分组交换的混合网络,比如 ATM [^iii]。InfiniBand 有一些相似之处【35】它在链路层实现了端到端的流量控制从而减少了在网络中排队的需要尽管它仍然可能因链路拥塞而受到延迟【36】。通过仔细使用 **服务质量**quality of service即 QoS数据包的优先级和调度**准入控制**admission control限速发送器可以在分组网络上模拟电路交换或提供统计上的 **有限延迟**【25,32】。
[^iii]: **异步传输模式Asynchronous Transfer Mode, ATM** 在 20 世纪 80 年代是以太网的竞争对手【32】但在电话网核心交换机之外并没有得到太多的采用。它与自动柜员机也称为自动取款机无关尽管共用一个缩写词。或许在一些平行的世界里互联网是基于像 ATM 这样的东西,因此它们的互联网视频通话可能比我们的更可靠,因为它们不会遭受包的丢失和延迟。
但是,目前在多租户数据中心和公共云或通过互联网 [^iv] 进行通信时,此类服务质量尚未启用。当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。因此,超时时间没有 “正确” 的值 —— 它需要通过实验来确定。
[^iv]: 互联网服务提供商之间的对等协议和通过 **BGP 网关协议BGP** 建立的路由,与 IP 协议相比,更接近于电路交换。在这个级别上,可以购买专用带宽。但是,互联网路由在网络级别运行,而不是主机之间的单独连接,而且运行时间要长得多。
> ### 延迟和资源利用
>
> 更一般地说,可以将 **延迟变化** 视为 **动态资源分区** 的结果。
>
> 假设两台电话交换机之间有一条线路,可以同时进行 10,000 个呼叫。通过此线路切换的每个电路都占用其中一个呼叫插槽。因此,你可以将线路视为可由多达 10,000 个并发用户共享的资源。资源以静态方式分配:即使你现在是线路上唯一的呼叫,并且所有其他 9,999 个插槽都未使用,你的电路仍将分配与线路充分利用时相同的固定数量的带宽。
>
> 相比之下,互联网动态分享网络带宽。发送者互相推挤和争夺,以让他们的数据包尽可能快地通过网络,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是它最大限度地利用了线路。线路固定成本,所以如果你更好地利用它,你通过线路发送的每个字节都会更便宜。
>
> CPU 也会出现类似的情况:如果你在多个线程间动态共享每个 CPU 内核,则一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是,与为每个线程分配静态数量的 CPU 周期相比,这会更好地利用硬件(请参阅 “[响应时间保证](#响应时间保证)”)。更好的硬件利用率也是使用虚拟机的重要动机。
>
> 如果资源是静态分区的(例如,专用硬件和专用带宽分配),则在某些环境中可以实现 **延迟保证**。但是,这是以降低利用率为代价的 —— 换句话说,它是更昂贵的。另一方面,动态资源分配的多租户提供了更好的利用率,所以它更便宜,但它具有可变延迟的缺点。
>
> 网络中的可变延迟不是一种自然规律,而只是成本 / 收益权衡的结果。
## 不可靠的时钟
时钟和时间很重要。应用程序以各种方式依赖于时钟来回答以下问题:
1. 这个请求是否超时了?
2. 这项服务的第 99 百分位响应时间是多少?
3. 在过去五分钟内,该服务平均每秒处理多少个查询?
4. 用户在我们的网站上花了多长时间?
5. 这篇文章在何时发布?
6. 在什么时间发送提醒邮件?
7. 这个缓存条目何时到期?
8. 日志文件中此错误消息的时间戳是什么?
[例 1-4](/v1/ch1) 测量了 **持续时间**durations例如请求发送与响应接收之间的时间间隔而 [例 5-8](/v1/ch5) 描述了 **时间点**point in time在特定日期和和特定时间发生的事件
在分布式系统中,时间是一件棘手的事情,因为通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。这个事实导致有时很难确定在涉及多台机器时发生事情的顺序。
而且,网络上的每台机器都有自己的时钟,这是一个实际的硬件设备:通常是石英晶体振荡器。这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。可以在一定程度上同步时钟:最常用的机制是 **网络时间协议NTP**它允许根据一组服务器报告的时间来调整计算机时钟【37】。服务器则从更精确的时间源如 GPS 接收机)获取时间。
### 单调钟与日历时钟
现代计算机至少有两种不同的时钟日历时钟time-of-day clock和单调钟monotonic clock。尽管它们都衡量时间但区分这两者很重要因为它们有不同的目的。
#### 日历时钟
日历时钟是你直观地了解时钟的依据:它根据某个日历(也称为 **挂钟时间**,即 wall-clock time返回当前日期和时间。例如Linux 上的 `clock_gettime(CLOCK_REALTIME)`[^v] 和 Java 中的 `System.currentTimeMillis()` 返回自 epochUTC 时间 1970 年 1 月 1 日午夜以来的秒数或毫秒根据公历Gregorian日历不包括闰秒。有些系统使用其他日期作为参考点。
[^v]: 虽然该时钟被称为实时时钟,但它与实时操作系统无关,如 “[响应时间保证](#响应时间保证)” 中所述。
日历时钟通常与 NTP 同步,这意味着来自一台机器的时间戳(理想情况下)与另一台机器上的时间戳相同。但是如下节所述,日历时钟也具有各种各样的奇特之处。特别是,如果本地时钟在 NTP 服务器之前太远则它可能会被强制重置看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实使日历时钟不能用于测量经过时间elapsed time【38】。
历史上的日历时钟还具有相当粗略的分辨率,例如,在较早的 Windows 系统上以 10 毫秒为单位前进【39】。在最近的系统中这已经不是一个问题了。
#### 单调钟
单调钟适用于测量持续时间时间间隔例如超时或服务的响应时间Linux 上的 `clock_gettime(CLOCK_MONOTONIC)`,和 Java 中的 `System.nanoTime()` 都是单调时钟。这个名字来源于他们保证总是往前走的事实(而日历时钟可以往回跳)。
你可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。但单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。
在具有多个 CPU 插槽的服务器上,每个 CPU 可能有一个单独的计时器,但不一定与其他 CPU 同步。操作系统会补偿所有的差异,并尝试向应用线程表现出单调钟的样子,即使这些线程被调度到不同的 CPU 上。当然明智的做法是不要太把这种单调性保证当回事【40】。
如果 NTP 协议检测到计算机的本地石英钟比 NTP 服务器要更快或更慢,则可以调整单调钟向前走的频率(这称为 **偏移skewing** 时钟。默认情况下NTP 允许时钟速率增加或减慢最高至 0.05%,但 NTP 不能使单调时钟向前或向后跳转。单调时钟的分辨率通常相当好:在大多数系统中,它们能在几微秒或更短的时间内测量时间间隔。
在分布式系统中,使用单调钟测量 **经过时间**elapsed time比如超时通常很好因为它不假定不同节点的时钟之间存在任何同步并且对测量的轻微不准确性不敏感。
### 时钟同步与准确性
单调钟不需要同步,但是日历时钟需要根据 NTP 服务器或其他外部时间源来设置才能有用。不幸的是,我们获取时钟的方法并不像你所希望的那样可靠或准确 —— 硬件时钟和 NTP 可能会变幻莫测。举几个例子:
* 计算机中的石英钟不够精确:它会 **漂移**drifts即运行速度快于或慢于预期。时钟漂移取决于机器的温度。Google 假设其服务器时钟漂移为 200 ppm百万分之一【41】相当于每 30 秒与服务器重新同步一次的时钟漂移为 6 毫秒,或者每天重新同步的时钟漂移为 17 秒。即使一切工作正常,此漂移也会限制可以达到的最佳准确度。
* 如果计算机的时钟与 NTP 服务器的时钟差别太大可能会拒绝同步或者本地时钟将被强制重置【37】。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
* 如果某个节点被 NTP 服务器的防火墙意外阻塞,有可能会持续一段时间都没有人会注意到。有证据表明,这在实践中确实发生过。
* NTP 同步只能和网络延迟一样好所以当你在拥有可变数据包延迟的拥塞网络上时NTP 同步的准确性会受到限制。一个实验表明当通过互联网同步时35 毫秒的最小误差是可以实现的,尽管偶尔的网络延迟峰值会导致大约一秒的误差。根据配置,较大的网络延迟会导致 NTP 客户端完全放弃。
* 一些 NTP 服务器是错误的或者配置错误的报告的时间可能相差几个小时【43,44】。还好 NTP 客户端非常健壮,因为他们会查询多个服务器并忽略异常值。无论如何,依赖于互联网上的陌生人所告诉你的时间来保证你的系统的正确性,这还挺让人担忧的。
* 闰秒导致一分钟可能有 59 秒或 61 秒这会打破一些在设计之时未考虑闰秒的系统的时序假设【45】。闰秒已经使许多大型系统崩溃的事实【38,46】说明了关于时钟的错误假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是让 NTP 服务器 “撒谎”,并在一天中逐渐执行闰秒调整(这被称为 **拖尾**,即 smearing【47,48】虽然实际的 NTP 服务器表现各异【49】。
* 在虚拟机中硬件时钟被虚拟化这对于需要精确计时的应用程序提出了额外的挑战【50】。当一个 CPU 核心在虚拟机之间共享时每个虚拟机都会暂停几十毫秒与此同时另一个虚拟机正在运行。从应用程序的角度来看这种停顿表现为时钟突然向前跳跃【26】。
* 如果你在没有完整控制权的设备(例如,移动设备或嵌入式设备)上运行软件,则可能完全不能信任该设备的硬件时钟。一些用户故意将其硬件时钟设置为不正确的日期和时间,例如,为了规避游戏中的时间限制,时钟可能会被设置到很远的过去或将来。
如果你足够在乎这件事并投入大量资源,就可以达到非常好的时钟精度。例如,针对金融机构的欧洲法规草案 MiFID II 要求所有高频率交易基金在 UTC 时间 100 微秒内同步时钟,以便调试 “闪崩” 等市场异常现象并帮助检测市场操纵【51】。
通过 GPS 接收机精确时间协议PTP【52】以及仔细的部署和监测可以实现这种精确度。然而这需要很多努力和专业知识而且有很多东西都会导致时钟同步错误。如果你的 NTP 守护进程配置错误,或者防火墙阻止了 NTP 通信,由漂移引起的时钟误差可能很快就会变大。
### 依赖同步时钟
时钟的问题在于,虽然它们看起来简单易用,但却具有令人惊讶的缺陷:一天可能不会有精确的 86,400 秒,**日历时钟** 可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
本章早些时候,我们讨论了网络丢包和任意延迟包的问题。尽管网络在大多数情况下表现良好,但软件的设计必须假定网络偶尔会出现故障,而软件必须正常处理这些故障。时钟也是如此:尽管大多数时间都工作得很好,但需要准备健壮的软件来处理不正确的时钟。
有一部分问题是,不正确的时钟很容易被视而不见。如果一台机器的 CPU 出现故障或者网络配置错误,很可能根本无法工作,所以很快就会被注意和修复。另一方面,如果它的石英时钟有缺陷,或者它的 NTP 客户端配置错误大部分事情似乎仍然可以正常工作即使它的时钟逐渐偏离现实。如果某个软件依赖于精确同步的时钟那么结果更可能是悄无声息的仅有微量的数据丢失而不是一次惊天动地的崩溃【53,54】。
因此,如果你使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移。时钟偏离其他时钟太远的节点应当被宣告死亡,并从集群中移除。这样的监控可以确保你在损失发生之前注意到破损的时钟。
#### 有序事件的时间戳
让我们考虑一个特别的情况,一件很有诱惑但也很危险的事情:依赖时钟,在多个节点上对事件进行排序。例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
[图 8-3](/v1/ddia_0803.png) 显示了在具有多主复制的数据库中对时钟的危险使用(该例子类似于 [图 5-9](/v1/ddia_0509.png))。客户端 A 在节点 1 上写入 `x = 1`;写入被复制到节点 3客户端 B 在节点 3 上增加 x我们现在有 `x = 2`);最后这两个写入都被复制到节点 2。
![](/v1/ddia_0803.png)
**图 8-3 客户端 B 的写入比客户端 A 的写入要晚,但是 B 的写入具有较早的时间戳。**
在 [图 8-3](/v1/ddia_0803.png) 中,当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。在这个例子中,时钟同步是非常好的:节点 1 和节点 3 之间的偏差小于 3ms这可能比你在实践中能预期的更好。
尽管如此,[图 8-3](/v1/ddia_0803.png) 中的时间戳却无法正确排列事件:写入 `x = 1` 的时间戳为 42.004 秒,但写入 `x = 2` 的时间戳为 42.003 秒,即使 `x = 2` 在稍后出现。当节点 2 接收到这两个事件时,会错误地推断出 `x = 1` 是最近的值,而丢弃写入 `x = 2`。效果上表现为,客户端 B 的增量操作会丢失。
这种冲突解决策略被称为 **最后写入胜利LWW**,它在多主复制和无主数据库(如 Cassandra 【53】和 Riak 【54】中被广泛使用请参阅 “[最后写入胜利(丢弃并发写入)](/v1/ch5#最后写入胜利(丢弃并发写入))” 一节)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变 LWW 的基本问题:
* 数据库写入可能会神秘地消失具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值直到节点之间的时钟偏差消逝【54,55】。此方案可能导致一定数量的数据被悄悄丢弃而未向应用报告任何错误。
* LWW 无法区分 **高频顺序写入**(在 [图 8-3](/v1/ddia_0803.png) 中,客户端 B 的增量操作 **一定** 发生在客户端 A 的写入之后)和 **真正并发写入**(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系(请参阅 “[检测并发写入](/v1/ch5#检测并发写入)”)。
* 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的 **决胜值**tiebreaker可以简单地是一个大随机数但这种方法也可能会导致违背因果关系【53】。
因此,尽管通过保留 “最近” 的值并放弃其他值来解决冲突是很诱惑人的,但是要注意,“最近” 的定义取决于本地的 **日历时钟**,这很可能是不正确的。即使用严格同步的 NTP 时钟,一个数据包也可能在时间戳 100 毫秒(根据发送者的时钟)时发送,并在时间戳 99 毫秒(根据接收者的时钟)处到达 —— 看起来好像数据包在发送之前已经到达,这是不可能的。
NTP 同步是否能足够准确,以至于这种不正确的排序不会发生?也许不能,因为 NTP 的同步精度本身,除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
所谓的 **逻辑时钟logic clock**【56,57】是基于递增计数器而不是振荡石英晶体对于排序事件来说是更安全的选择请参阅 “[检测并发写入](/v1/ch5#检测并发写入)”)。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的 **日历时钟****单调钟** 也被称为 **物理时钟physical clock**。我们将在 “[顺序保证](/v1/ch9#顺序保证)” 中来看顺序问题。
#### 时钟读数存在置信区间
你可能能够以微秒或甚至纳秒的精度读取机器的时钟。但即使可以得到如此细致的测量结果,这并不意味着这个值对于这样的精度实际上是准确的。实际上,大概率是不准确的 —— 如前所述,即使你每分钟与本地网络上的 NTP 服务器进行同步,几毫秒的时间漂移也很容易在不精确的石英时钟上发生。使用公共互联网上的 NTP 服务器,最好的准确度可能达到几十毫秒,而且当网络拥塞时,误差可能会超过 100 毫秒【57】。
因此,将时钟读数视为一个时间点是没有意义的 —— 它更像是一段时间范围:例如,一个系统可能以 95% 的置信度认为当前时间处于本分钟内的第 10.3 秒和 10.5 秒之间它可能没法比这更精确了【58】。如果我们只知道 ±100 毫秒的时间,那么时间戳中的微秒数字部分基本上是没有意义的。
不确定性界限可以根据你的时间源来计算。如果你的 GPS 接收器或原子(铯)时钟直接连接到你的计算机上,预期的错误范围由制造商告知。如果从服务器获得时间,则不确定性取决于自上次与服务器同步以来的石英钟漂移的期望值,加上 NTP 服务器的不确定性,再加上到服务器的网络往返时间(只是获取粗略近似值,并假设服务器是可信的)。
不幸的是,大多数系统不公开这种不确定性:例如,当调用 `clock_gettime()` 时,返回值不会告诉你时间戳的预期错误,所以你不知道其置信区间是 5 毫秒还是 5 年。
一个有趣的例外是 Spanner 中的 Google TrueTime API 【41】它明确地报告了本地时钟的置信区间。当你询问当前时间时你会得到两个值[最早,最晚],这是最早可能的时间戳和最晚可能的时间戳。在不确定性估计的基础上,时钟知道当前的实际时间落在该区间内。区间的宽度取决于自从本地石英钟最后与更精确的时钟源同步以来已经过了多长时间。
#### 全局快照的同步时钟
在 “[快照隔离和可重复读](/v1/ch7#快照隔离和可重复读)” 中,我们讨论了快照隔离,这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
快照隔离最常见的实现需要单调递增的事务 ID。如果写入比快照晚写入具有比快照更大的事务 ID则该写入对于快照事务是不可见的。在单节点数据库上一个简单的计数器就足以生成事务 ID。
但是当数据库分布在许多机器上,也许可能在多个数据中心中时,由于需要协调,(跨所有分区)全局单调递增的事务 ID 会很难生成。事务 ID 必须反映因果关系:如果事务 B 读取由事务 A 写入的值,则 B 必须具有比 A 更大的事务 ID否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下在分布式系统中创建事务 ID 成为一个难以处理的瓶颈 [^vi]。
[^vi]: 存在分布式序列号生成器,例如 Twitter 的雪花Snowflake其以可伸缩的方式例如通过将 ID 空间的块分配给不同节点)近似单调地增加唯一 ID。但是它们通常无法保证与因果关系一致的排序因为分配的 ID 块的时间范围比数据库读取和写入的时间范围要长。另请参阅 “[顺序保证](/v1/ch9#顺序保证)”。
我们可以使用同步时钟的时间戳作为事务 ID 吗?如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
Spanner 以这种方式实现跨数据中心的快照隔离【5960】。它使用 TrueTime API 报告的时钟置信区间,并基于以下观察结果:如果你有两个置信区间,每个置信区间包含最早和最晚可能的时间戳($A = [A_{earliest}, A_{latest}]$$B=[B_{earliest}, B_{latest}]$),这两个区间不重叠(即:$A_{earliest} <A_{latest} <B_{earliest} <B_{latest}$的话那么 B 肯定发生在 A 之后 这是毫无疑问的只有当区间重叠时我们才不确定 A B 发生的顺序
为了确保事务时间戳反映因果关系在提交读写事务之前Spanner 在提交读写事务时会故意等待置信区间长度的时间。通过这样它可以确保任何可能读取数据的事务处于足够晚的时间因此它们的置信区间不会重叠。为了保持尽可能短的等待时间Spanner 需要保持尽可能小的时钟不确定性为此Google 在每个数据中心都部署了一个 GPS 接收器或原子钟,这允许时钟同步到大约 7 毫秒以内【41】。
对分布式事务语义使用时钟同步是一个活跃的研究领域【57,61,62】。这些想法很有趣但是它们还没有在谷歌之外的主流数据库中实现。
### 进程暂停
让我们考虑在分布式系统中使用危险时钟的另一个例子。假设你有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
一种选择是领导者从其他节点获得一个 **租约lease**类似一个带超时的锁【63】。任一时刻只有一个节点可以持有租约 —— 因此,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。
如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
可以想象,请求处理循环看起来像这样:
```java
while (true) {
request = getIncomingRequest();
// 确保租约还剩下至少 10 秒
if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){
lease = lease.renew();
}
if (lease.isValid()) {
process(request);
}
}
```
这个代码有什么问题?首先,它依赖于同步时钟:租约到期时间由另一台机器设置(例如,当前时间加上 30 秒,计算到期时间),并将其与本地系统时钟进行比较。如果时钟不同步超过几秒,这段代码将开始做奇怪的事情。
其次,即使我们将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查 `System.currentTimeMillis()` 和实际执行请求 `process(request)` 中间的时间间隔非常短。通常情况下,这段代码运行得非常快,所以 10 秒的缓冲区已经足够确保 **租约** 在请求处理到一半时不会过期。
但是,如果程序执行中出现了意外的停顿呢?例如,想象一下,线程在 `lease.isValid()` 行周围停止 15 秒,然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 —— 到那个时候它可能已经做了一些不安全的处理请求。
假设一个线程可能会暂停很长时间,这是疯了吗?不幸的是,这种情况发生的原因有很多种:
* 许多编程语言运行时(如 Java 虚拟机都有一个垃圾收集器GC偶尔需要停止所有正在运行的线程。这些 “**停止所有处理stop-the-world**”GC 暂停有时会持续几分钟【64】甚至像 HotSpot JVM 的 CMS 这样的所谓的 “并行” 垃圾收集器也不能完全与应用程序代码并行运行它需要不时地停止所有处理【65】。尽管通常可以通过改变分配模式或调整 GC 设置来减少暂停【66】但是如果我们想要提供健壮的保证就必须假设最坏的情况发生。
* 在虚拟化环境中,可以 **挂起suspend** 虚拟机暂停执行所有进程并将内存内容保存到磁盘并恢复恢复内存内容并继续执行。这个暂停可以在进程执行的任何时候发生并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移而不需要重新启动在这种情况下暂停的长度取决于进程写入内存的速率【67】。
* 在最终用户的设备(如笔记本电脑)上,执行也可能被暂停并随意恢复,例如当用户关闭笔记本电脑的盖子时。
* 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下,在其他虚拟机中花费的 CPU 时间被称为 **窃取时间steal time**。如果机器处于沉重的负载下(即,如果等待运行的线程队列很长),暂停的线程再次运行可能需要一些时间。
* 如果应用程序执行同步磁盘访问,则线程可能暂停,等待缓慢的磁盘 I/O 操作完成【68】。在许多语言中即使代码没有包含文件访问磁盘访问也可能出乎意料地发生 —— 例如Java 类加载器在第一次使用时惰性加载类文件这可能在程序执行过程中随时发生。I/O 暂停和 GC 暂停甚至可能合谋组合它们的延迟【69】。如果磁盘实际上是一个网络文件系统或网络块设备如亚马逊的 EBSI/O 延迟进一步受到网络延迟变化的影响【29】。
* 如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致 **页面错误page fault**,要求将磁盘中的页面装入内存。当这个缓慢的 I/O 操作发生时,线程暂停。如果内存压力很高,则可能需要将另一个页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为 **抖动**,即 thrashing。为了避免这个问题通常在服务器机器上禁用页面调度如果你宁愿干掉一个进程来释放内存也不愿意冒抖动风险
* 可以通过发送 SIGSTOP 信号来暂停 Unix 进程,例如通过在 shell 中按下 Ctrl-Z。这个信号立即阻止进程继续执行更多的 CPU 周期,直到 SIGCONT 恢复为止,此时它将继续运行。即使你的环境通常不使用 SIGSTOP也可能由运维工程师意外发送。
所有这些事件都可以随时 **抢占preempt** 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量、信号量、原子计数器、无锁数据结构、阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
#### 响应时间保证
在许多编程语言和操作系统中,线程和进程可能暂停一段无限制的时间,正如讨论的那样。如果你足够努力,导致暂停的原因是 **可以** 消除的。
某些软件的运行环境要求很高,不能在特定时间内响应可能会导致严重的损失:控制飞机、火箭、机器人、汽车和其他物体的计算机必须对其传感器输入做出快速而可预测的响应。在这些系统中,软件必须有一个特定的 **截止时间deadline**,如果截止时间不满足,可能会导致整个系统的故障。这就是所谓的 **硬实时hard real-time** 系统。
> #### 实时是真的吗?
>
> 在嵌入式系统中,实时是指系统经过精心设计和测试,以满足所有情况下的特定时间保证。这个含义与 Web 上对实时术语的模糊使用相反,后者描述了服务器将数据推送到客户端以及没有严格的响应时间限制的流处理(见 [第十一章](/v1/ch11))。
例如,如果车载传感器检测到当前正在经历碰撞,你肯定不希望安全气囊释放系统因为 GC 暂停而延迟弹出。
在系统中提供 **实时保证** 需要各级软件栈的支持一个实时操作系统RTOS允许在指定的时间间隔内保证 CPU 时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给 GC 太多的负担);必须进行大量的测试和测量,以确保达到保证。
所有这些都需要大量额外的工作,严重限制了可以使用的编程语言、库和工具的范围(因为大多数语言和工具不提供实时保证)。由于这些原因,开发实时系统非常昂贵,并且它们通常用于安全关键的嵌入式设备。而且,“**实时**” 与 “**高性能**” 不一样 —— 事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切(另请参阅 “[延迟和资源利用](#延迟和资源利用)”)。
对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
#### 限制垃圾收集的影响
进程暂停的负面影响可以在不诉诸昂贵的实时调度保证的情况下得到缓解。语言运行时在计划垃圾回收时具有一定的灵活性,因为它们可以跟踪对象分配的速度和随着时间的推移剩余的空闲内存。
一个新兴的想法是将 GC 暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要 GC 暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行 GC。这个技巧向客户端隐藏了 GC 暂停并降低了响应时间的高百分比【70,71】。一些对延迟敏感的金融交易系统【72】使用这种方法。
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整 GC之前重新启动进程【65,73】。一次可以重新启动一个节点在计划重新启动之前流量可以从该节点移开就像 [第四章](/v1/ch4) 里描述的滚动升级一样。
这些措施不能完全阻止垃圾回收暂停,但可以有效地减少它们对应用的影响。
## 知识、真相与谎言
本章到目前为止,我们已经探索了分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。
如果你不习惯于分布式系统,那么这些问题的后果就会让人迷惑不解。网络中的一个节点无法确切地知道任何事情 —— 它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。
这些系统的讨论与哲学有关:在系统中什么是真什么是假?如果感知和测量的机制都是不可靠的,那么关于这些知识我们又能多么确定呢?软件系统应该遵循我们对物理世界所期望的法则,如因果关系吗?
幸运的是,我们不需要去搞清楚生命的意义。在分布式系统中,我们可以陈述关于行为(系统模型)的假设,并以满足这些假设的方式设计实际系统。算法可以被证明在某个系统模型中正确运行。这意味着即使底层系统模型提供了很少的保证,也可以实现可靠的行为。
但是,尽管可以使软件在不可靠的系统模型中表现良好,但这并不是可以直截了当实现的。在本章的其余部分中,我们将进一步探讨分布式系统中的知识和真相的概念,这将有助于我们思考我们可以做出的各种假设以及我们可能希望提供的保证。在 [第九章](/v1/ch9) 中,我们将着眼于分布式系统的一些例子,这些算法在特定的假设条件下提供了特定的保证。
### 真相由多数所定义
设想一个具有不对称故障的网络一个节点能够接收发送给它的所有消息但是来自该节点的任何传出消息被丢弃或延迟【19】。即使该节点运行良好并且正在接收来自其他节点的请求其他节点也无法听到其响应。经过一段时间后其他节点宣布它已经死亡因为他们没有听到节点的消息。这种情况就像梦魇一样**半断开semi-disconnected** 的节点被拖向墓地,敲打尖叫道 “我没死!” —— 但是由于没有人能听到它的尖叫,葬礼队伍继续以坚忍的决心继续行进。
在一个稍微不那么梦魇的场景中,半断开的节点可能会注意到它发送的消息没有被其他节点确认,因此意识到网络中必定存在故障。尽管如此,节点被其他节点错误地宣告为死亡,而半连接的节点对此无能为力。
第三种情况,想象一个正在经历长时间 **垃圾收集暂停stop-the-world GC Pause** 的节点,节点的所有线程被 GC 抢占并暂停一分钟因此没有请求被处理也没有响应被发送。其他节点等待重试不耐烦并最终宣布节点死亡并将其丢到灵车上。最后GC 完成节点的线程继续好像什么也没有发生。其他节点感到惊讶因为所谓的死亡节点突然从棺材中抬起头来身体健康开始和旁观者高兴地聊天。GC 后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
这些故事的寓意是,节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票(请参阅 “[读写的法定人数](/v1/ch5#读写的法定人数)”):决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。
最常见的法定人数是超过一半的绝对多数(尽管其他类型的法定人数也是可能的)。多数法定人数允许系统继续工作,如果单个节点发生故障(三个节点可以容忍单节点故障;五个节点可以容忍双节点故障)。系统仍然是安全的,因为在这个制度中只能有一个多数 —— 不能同时存在两个相互冲突的多数决定。当我们在 [第九章](/v1/ch9) 中讨论 **共识算法consensus algorithms** 时,我们将更详细地讨论法定人数的应用。
#### 领导者和锁
通常情况下,一些东西在一个系统中只能有一个。例如:
* 数据库分区的领导者只能有一个节点,以避免 **脑裂**(即 split brain请参阅 “[处理节点宕机](/v1/ch5#处理节点宕机)”)。
* 特定资源的锁或对象只允许一个事务 / 客户端持有,以防同时写入和损坏。
* 一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。
在分布式系统中实现这一点需要注意:即使一个节点认为它是 “**天选者the choosen one**”(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),但这并不一定意味着有法定人数的节点同意!一个节点可能以前是领导者,但是如果其他节点在此期间宣布它死亡(例如,由于网络中断或 GC 暂停),则它可能已被降级,且另一个领导者可能已经当选。
如果一个节点继续表现为 **天选者**,即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
例如,[图 8-4](/v1/ddia_0804.png) 显示了由于不正确的锁实现导致的数据损坏错误。这个错误不仅仅是理论上的HBase 曾经有这个问题【74,75】假设你要确保一个存储服务中的文件一次只能被一个客户访问因为如果多个客户试图对此写入该文件将被损坏。你尝试通过在访问文件之前要求客户端从锁定服务获取租约来实现此目的。
![](/v1/ddia_0804.png)
**图 8-4 分布式锁的实现不正确:客户端 1 认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件**
这个问题就是我们先前在 “[进程暂停](#进程暂停)” 中讨论过的一个例子:如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
#### 防护令牌
当使用锁或租约来保护对某些资源(如 [图 8-4](/v1/ddia_0804.png) 中的文件存储)的访问时,需要确保一个被误认为自己是 “天选者” 的节点不能扰乱系统的其它部分。实现这一目标的一个相当简单的技术就是 **防护fencing**,如 [图 8-5](/v1/ddia_0805.png) 所示
![](/v1/ddia_0805.png)
**图 8-5 只允许以增加防护令牌的顺序进行写操作,从而保证存储安全**
我们假设每次锁定服务器授予锁或租约时,它还会返回一个 **防护令牌fencing token**,这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
在 [图 8-5](/v1/ddia_0805.png) 中,客户端 1 以 33 的令牌获得租约,但随后进入一个长时间的停顿并且租约到期。客户端 2 以 34 的令牌(该数字总是增加)获取租约,然后将其写入请求发送到存储服务,包括 34 的令牌。稍后,客户端 1 恢复生机并将其写入存储服务,包括其令牌值 33。但是存储服务器会记住它已经处理了一个具有更高令牌编号34的写入因此它会拒绝带有令牌 33 的请求。
如果将 ZooKeeper 用作锁定服务,则可将事务标识 `zxid` 或节点版本 `cversion` 用作防护令牌。由于它们保证单调递增因此它们具有所需的属性【74】。
请注意,这种机制要求资源本身在检查令牌方面发挥积极作用,通过拒绝使用旧的令牌,而不是已经被处理的令牌来进行写操作 —— 仅仅依靠客户端检查自己的锁状态是不够的。对于不明确支持防护令牌的资源,可能仍然可以解决此限制(例如,在文件存储服务的情况下,可以将防护令牌包含在文件名中)。但是,为了避免在锁的保护之外处理请求,需要进行某种检查。
在服务器端检查一个令牌可能看起来像是一个缺点但这可以说是一件好事一个服务假定它的客户总是守规矩并不明智因为使用客户端的人与运行服务的人优先级非常不一样【76】。因此任何服务保护自己免受意外客户的滥用是一个好主意。
### 拜占庭故障
防护令牌可以检测和阻止无意中发生错误的节点(例如,因为它尚未发现其租约已过期)。但是,如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。
在本书中,我们假设节点是不可靠但诚实的:它们可能很慢或者从不响应(由于故障),并且它们的状态可能已经过时(由于 GC 暂停或网络延迟),但是我们假设如果节点它做出了回应,它正在说出 “真相”:尽其所知,它正在按照协议的规则扮演其角色。
如果存在节点可能 “撒谎”(发送任意错误或损坏的响应)的风险,则分布式系统的问题变得更困难了 —— 例如,如果节点可能声称其实际上没有收到特定的消息。这种行为被称为 **拜占庭故障Byzantine fault****在不信任的环境中达成共识的问题被称为拜占庭将军问题**【77】。
> ### 拜占庭将军问题
>
> 拜占庭将军问题是对所谓 “两将军问题” 的泛化【78】它想象两个将军需要就战斗计划达成一致的情况。由于他们在两个不同的地点建立了营地他们只能通过信使进行沟通信使有时会被延迟或丢失就像网络中的信息包一样。我们将在 [第九章](/v1/ch9) 讨论这个共识问题。
>
> 在这个问题的拜占庭版本里,有 n 位将军需要同意,他们的努力因为有一些叛徒在他们中间而受到阻碍。大多数的将军都是忠诚的,因而发出了真实的信息,但是叛徒可能会试图通过发送虚假或不真实的信息来欺骗和混淆他人(在试图保持未被发现的同时)。事先并不知道叛徒是谁。
>
> 拜占庭是后来成为君士坦丁堡的古希腊城市现在在土耳其的伊斯坦布尔。没有任何历史证据表明拜占庭将军比其他地方更容易出现诡计和阴谋。相反这个名字来源于拜占庭式的过度复杂官僚迂回等意义早在计算机之前就已经在政治中被使用了【79】。Lamport 想要选一个不会冒犯任何读者的国家他被告知将其称为阿尔巴尼亚将军问题并不是一个好主意【80】。
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为 **拜占庭容错Byzantine fault-tolerant** 的,这种担忧在某些特定情况下是有意义的:
* 在航空航天环境中,计算机内存或 CPU 寄存器中的数据可能被辐射破坏导致其以任意不可预知的方式响应其他节点。由于系统故障非常昂贵例如飞机撞毁和炸死船上所有人员或火箭与国际空间站相撞飞行控制系统必须容忍拜占庭故障【81,82】。
* 在多个参与组织的系统中一些参与者可能会试图欺骗或诈骗他人。在这种情况下节点仅仅信任另一个节点的消息是不安全的因为它们可能是出于恶意的目的而被发送的。例如像比特币和其他区块链一样的对等网络可以被认为是让互不信任的各方同意交易是否发生的一种方式而不依赖于中心机构central authority【83】。
然而在本书讨论的那些系统中我们通常可以安全地假设没有拜占庭式的错误。在你的数据中心里所有的节点都是由你的组织控制的所以他们可以信任辐射水平足够低内存损坏不是一个大问题。制作拜占庭容错系统的协议相当复杂【84】而容错嵌入式系统依赖于硬件层面的支持【81】。在大多数服务器端数据系统中部署拜占庭容错解决方案的成本使其变得不切实际。
Web 应用程序确实需要预期受终端用户控制的客户端(如 Web 浏览器)的任意和恶意行为。这就是为什么输入验证,数据清洗和输出转义如此重要:例如,防止 SQL 注入和跨站点脚本。然而,我们通常不在这里使用拜占庭容错协议,而只是让服务器有权决定是否允许客户端行为。但在没有这种中心机构的对等网络中,拜占庭容错更为重要。
软件中的一个错误bug可能被认为是拜占庭式的错误但是如果你将相同的软件部署到所有节点上那么拜占庭式的容错算法帮不到你。大多数拜占庭式容错算法要求超过三分之二的节点能够正常工作如果有四个节点最多只能有一个故障。要使用这种方法对付 bug你必须有四个独立的相同软件的实现并希望一个 bug 只出现在四个实现之一中。
同样,如果一个协议可以保护我们免受漏洞,安全渗透和恶意攻击,那么这将是有吸引力的。不幸的是,这也是不现实的:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
#### 弱谎言形式
尽管我们假设节点通常是诚实的,但值得向软件中添加防止 “撒谎” 弱形式的机制 —— 例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,因为它们不能抵挡决心坚定的对手,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
* 由于硬件问题或操作系统、驱动程序、路由器等中的错误,网络数据包有时会受到损坏。通常,损坏的数据包会被内建于 TCP 和 UDP 中的校验和所俘获但有时它们也会逃脱检测【85,86,87】 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
* 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。
* NTP 客户端可以配置多个服务器地址。同步时,客户端联系所有的服务器,估计它们的误差,并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题,一个配置错误的 NTP 服务器报告的时间会被当成特异值从同步中排除【37】。使用多个服务器使 NTP 更健壮(比起只用单个服务器来)。
### 系统模型与现实
已经有很多算法被设计以解决分布式系统问题 —— 例如,我们将在 [第九章](/v1/ch9) 讨论共识问题的解决方案。为了有用,这些算法需要容忍我们在本章中讨论的分布式系统的各种故障。
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的:
同步模型
: **同步模型synchronous model** 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限【88】。同步模型并不是大多数实际系统的现实模型因为如本章所讨论的无限延迟和暂停确实会发生。
部分同步模型
: **部分同步partial synchronous** 意味着一个系统在大多数情况下像一个同步系统一样运行但有时候会超出网络延迟进程暂停和时钟漂移的界限【88】。这是很多系统的现实模型大多数情况下网络和进程表现良好否则我们永远无法完成任何事情但是我们必须承认在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时网络延迟、暂停和时钟错误可能会变得相当大。
异步模型
: 在这个模型中,一个算法不允许对时序做任何假设 —— 事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时序问题,我们还要考虑 **节点失效**。三种最常见的节点系统模型是:
崩溃 - 停止故障
: 在 **崩溃停止crash-stop** 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失 —— 它永远不会回来。
崩溃 - 恢复故障
: 我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在 **崩溃 - 恢复crash-recovery** 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
拜占庭(任意)故障
: 节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
对于真实系统的建模,具有 **崩溃 - 恢复故障crash-recovery****部分同步模型partial synchronous** 通常是最有用的模型。分布式算法如何应对这种模型?
#### 算法的正确性
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌(请参阅 “[防护令牌](#防护令牌)”),我们可能要求算法具有以下属性:
唯一性uniqueness
: 没有两个防护令牌请求返回相同的值。
单调序列monotonic sequence
: 如果请求 $x$ 返回了令牌 $t_x$,并且请求 $y$ 返回了令牌 $t_y$,并且 $x$ 在 $y$ 开始之前已经完成,那么 $t_x < t_y$
可用性availability
: 请求防护令牌并且不会崩溃的节点,最终会收到响应。
如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
#### 安全性和活性
为了澄清这种情况,有必要区分两种不同的属性:**安全safety属性** 和 **活性liveness属性**。在刚刚给出的例子中,**唯一性** 和 **单调序列** 是安全属性,而 **可用性** 是活性属性。
这两种性质有什么区别?一个试金石就是,活性属性通常在定义中通常包括 “**最终**” 一词(是的,你猜对了 —— 最终一致性是一个活性属性【89】
安全通常被非正式地定义为:**没有坏事发生**,而活性通常就类似:**最终好事发生**。但是最好不要过多地阅读那些非正式的定义因为好与坏的含义是主观的。安全和活性的实际定义是精确的和数学的【90】
* 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销 —— 损失已经发生。
* 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求 **始终** 保持安全属性是常见的【88】。也就是说即使所有节点崩溃或者整个网络出现故障算法仍然必须确保它不会返回错误的结果即保证安全属性得到满足
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态 —— 即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。
#### 将系统模型映射到现实世界
安全属性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而,在实践中实施算法时,现实的混乱事实再一次地让你咬牙切齿,很明显系统模型是对现实的简化抽象。
例如,在崩溃 - 恢复crash-recovery模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是如果磁盘上的数据被破坏或者由于硬件错误或错误配置导致数据被清除会发生什么情况【91】如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器即使驱动器已正确连接到服务器那又会发生什么情况【92】
法定人数算法(请参阅 “[读写的法定人数](/v1/ch5#读写的法定人数)”)依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
算法的理论描述可以简单宣称一些事是不会发生的 —— 在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理 “假设上不可能” 情况的代码,即使代码可能就是 `printf("Sucks to be you")``exit(666)`实际上也就是留给运维来擦屁股【93】。这可以说是计算机科学和软件工程间的一个差异
这并不是说理论上抽象的系统模型是毫无价值的,恰恰相反。它们对于将实际系统的复杂性提取成一个个我们可以推理的可处理的错误类型是非常有帮助的,以便我们能够理解这个问题,并试图系统地解决这个问题。我们可以证明算法是正确的,通过表明它们的属性在某个系统模型中总是成立的。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。
## 本章小结
在本章中,我们讨论了分布式系统中可能发生的各种问题,包括:
* 当你尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否发送成功了。
* 节点的时钟可能会与其他节点显著不同步(尽管你尽最大努力设置 NTP它可能会突然跳转或跳回依靠它是很危险的因为你很可能没有好的方法来测量你的时钟的错误间隔。
* 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为停止所有处理的垃圾收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
这类 **部分失效partial failure** 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立 **部分失效** 的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。
为了容忍错误,第一步是 **检测** 它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠 **超时** 来确定远程节点是否仍然可用。但是,超时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误,千兆网卡可能突然下降到 1 Kb/s 的吞吐量【94】。这样一个 “跛行” 而不是死掉的节点可能比一个干净的失效节点更难处理。
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
如果你习惯于在理想化的数学完美的单机环境同一个操作总能确定地返回相同的结果中编写软件那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反如果能够在单台计算机上解决一个问题那么分布式系统工程师通常会认为这个问题是平凡的【5】现在单个计算机确实可以做很多事情【95】。如果你可以避免打开潘多拉的盒子把东西放在一台机器上那么通常是值得的。
但是,正如在 [第二部分](/part-ii) 的介绍中所讨论的那样,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择 **便宜而不可靠**,而不是 **昂贵和可靠**
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理 —— 至少在理论上是如此。(实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在 [下一章](/v1/ch9) 中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。
## 参考文献
1. Mark Cavage: “[Theres Just No Getting Around It: Youre Building a Distributed System](http://queue.acm.org/detail.cfm?id=2482856),” *ACM Queue*, volume 11, number 4, pages 80-89, April 2013. [doi:10.1145/2466486.2482856](http://dx.doi.org/10.1145/2466486.2482856)
1. Jay Kreps: “[Getting Real About Distributed System Reliability](http://blog.empathybox.com/post/19574936361/getting-real-about-distributed-system-reliability),” *blog.empathybox.com*, March 19, 2012.
1. Sydney Padua: *The Thrilling Adventures of Lovelace and Babbage: The (Mostly) True Story of the First Computer*. Particular Books, April 2015. ISBN: 978-0-141-98151-2
1. Coda Hale: “[You Cant Sacrifice Partition Tolerance](http://codahale.com/you-cant-sacrifice-partition-tolerance/),” *codahale.com*, October 7, 2010.
1. Jeff Hodges: “[Notes on Distributed Systems for Young Bloods](https://web.archive.org/web/20200218095605/https://www.somethingsimilar.com/2013/01/14/notes-on-distributed-systems-for-young-bloods/),” *somethingsimilar.com*, January 14, 2013.
1. Antonio Regalado: “[Who Coined 'Cloud Computing'?](https://www.technologyreview.com/2011/10/31/257406/who-coined-cloud-computing/),” *technologyreview.com*, October 31, 2011.
1. Luiz André Barroso, Jimmy Clidaras, and Urs Hölzle: “[The Datacenter as a Computer: An Introduction to the Design of Warehouse-Scale Machines, Second Edition](https://web.archive.org/web/20140404113735/http://www.morganclaypool.com/doi/abs/10.2200/S00516ED2V01Y201306CAC024),” *Synthesis Lectures on Computer Architecture*, volume 8, number 3, Morgan & Claypool Publishers, July 2013. [doi:10.2200/S00516ED2V01Y201306CAC024](http://dx.doi.org/10.2200/S00516ED2V01Y201306CAC024), ISBN: 978-1-627-05010-4
1. David Fiala, Frank Mueller, Christian Engelmann, et al.: “[Detection and Correction of Silent Data Corruption for Large-Scale High-Performance Computing](http://moss.csc.ncsu.edu/~mueller/ftp/pub/mueller/papers/sc12.pdf),” at *International Conference for High Performance Computing, Networking, Storage and Analysis* (SC12), November 2012.
1. Arjun Singh, Joon Ong, Amit Agarwal, et al.: “[Jupiter Rising: A Decade of Clos Topologies and Centralized Control in Googles Datacenter Network](http://conferences.sigcomm.org/sigcomm/2015/pdf/papers/p183.pdf),” at *Annual Conference of the ACM Special Interest Group on Data Communication* (SIGCOMM), August 2015. [doi:10.1145/2785956.2787508](http://dx.doi.org/10.1145/2785956.2787508)
1. Glenn K. Lockwood: “[Hadoop's Uncomfortable Fit in HPC](http://glennklockwood.blogspot.co.uk/2014/05/hadoops-uncomfortable-fit-in-hpc.html),” *glennklockwood.blogspot.co.uk*, May 16, 2014.
1. John von Neumann: “[Probabilistic Logics and the Synthesis of Reliable Organisms from Unreliable Components](https://personalpages.manchester.ac.uk/staff/nikolaos.kyparissas/uploads/VonNeumann1956.pdf),” in *Automata Studies (AM-34)*, edited by Claude E. Shannon and John McCarthy, Princeton University Press, 1956. ISBN: 978-0-691-07916-5
1. Richard W. Hamming: *The Art of Doing Science and Engineering*. Taylor & Francis, 1997. ISBN: 978-9-056-99500-3
1. Claude E. Shannon: “[A Mathematical Theory of Communication](http://cs.brynmawr.edu/Courses/cs380/fall2012/shannon1948.pdf),” *The Bell System Technical Journal*, volume 27, number 3, pages 379423 and 623656, July 1948.
1. Peter Bailis and Kyle Kingsbury: “[The Network Is Reliable](https://queue.acm.org/detail.cfm?id=2655736),” *ACM Queue*, volume 12, number 7, pages 48-55, July 2014. [doi:10.1145/2639988.2639988](http://dx.doi.org/10.1145/2639988.2639988)
1. Joshua B. Leners, Trinabh Gupta, Marcos K. Aguilera, and Michael Walfish: “[Taming Uncertainty in Distributed Systems with Help from the Network](http://www.cs.nyu.edu/~mwalfish/papers/albatross-eurosys15.pdf),” at *10th European Conference on Computer Systems* (EuroSys), April 2015. [doi:10.1145/2741948.2741976](http://dx.doi.org/10.1145/2741948.2741976)
1. Phillipa Gill, Navendu Jain, and Nachiappan Nagappan: “[Understanding Network Failures in Data Centers: Measurement, Analysis, and Implications](http://conferences.sigcomm.org/sigcomm/2011/papers/sigcomm/p350.pdf),” at *ACM SIGCOMM Conference*, August 2011. [doi:10.1145/2018436.2018477](http://dx.doi.org/10.1145/2018436.2018477)
1. Mark Imbriaco: “[Downtime Last Saturday](https://github.com/blog/1364-downtime-last-saturday),” *github.com*, December 26, 2012.
1. Will Oremus: “[The Global Internet Is Being Attacked by Sharks, Google Confirms](http://www.slate.com/blogs/future_tense/2014/08/15/shark_attacks_threaten_google_s_undersea_internet_cables_video.html),” *slate.com*, August 15, 2014.
1. Marc A. Donges: “[Re: bnx2 cards Intermittantly Going Offline](http://www.spinics.net/lists/netdev/msg210485.html),” Message to Linux *netdev* mailing list, *spinics.net*, September 13, 2012.
1. Kyle Kingsbury: “[Call Me Maybe: Elasticsearch](https://aphyr.com/posts/317-call-me-maybe-elasticsearch),” *aphyr.com*, June 15, 2014.
1. Salvatore Sanfilippo: “[A Few Arguments About Redis Sentinel Properties and Fail Scenarios](http://antirez.com/news/80),” *antirez.com*, October 21, 2014.
1. Bert Hubert: “[The Ultimate SO_LINGER Page, or: Why Is My TCP Not Reliable](http://blog.netherlabs.nl/articles/2009/01/18/the-ultimate-so_linger-page-or-why-is-my-tcp-not-reliable),” *blog.netherlabs.nl*, January 18, 2009.
1. Nicolas Liochon: “[CAP: If All You Have Is a Timeout, Everything Looks Like a Partition](http://blog.thislongrun.com/2015/05/CAP-theorem-partition-timeout-zookeeper.html),” *blog.thislongrun.com*, May 25, 2015.
1. Jerome H. Saltzer, David P. Reed, and David D. Clark: “[End-To-End Arguments in System Design](https://groups.csail.mit.edu/ana/Publications/PubPDFs/End-to-End%20Arguments%20in%20System%20Design.pdf),” *ACM Transactions on Computer Systems*, volume 2, number 4, pages 277288, November 1984. [doi:10.1145/357401.357402](http://dx.doi.org/10.1145/357401.357402)
1. Matthew P. Grosvenor, Malte Schwarzkopf, Ionel Gog, et al.: “[Queues Dont Matter When You Can JUMP Them!](https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-grosvenor_update.pdf),” at *12th USENIX Symposium on Networked Systems Design and Implementation* (NSDI), May 2015.
1. Guohui Wang and T. S. Eugene Ng: “[The Impact of Virtualization on Network Performance of Amazon EC2 Data Center](http://www.cs.rice.edu/~eugeneng/papers/INFOCOM10-ec2.pdf),” at *29th IEEE International Conference on Computer Communications* (INFOCOM), March 2010. [doi:10.1109/INFCOM.2010.5461931](http://dx.doi.org/10.1109/INFCOM.2010.5461931)
1. Van Jacobson: “[Congestion Avoidance and Control](http://www.cs.usask.ca/ftp/pub/discus/seminars2002-2003/p314-jacobson.pdf),” at *ACM Symposium on Communications Architectures and Protocols* (SIGCOMM), August 1988. [doi:10.1145/52324.52356](http://dx.doi.org/10.1145/52324.52356)
1. Brandon Philips: “[etcd: Distributed Locking and Service Discovery](https://www.youtube.com/watch?v=HJIjTTHWYnE),” at *Strange Loop*, September 2014.
1. Steve Newman: “[A Systematic Look at EC2 I/O](https://web.archive.org/web/20141211094156/http://blog.scalyr.com/2012/10/a-systematic-look-at-ec2-io/),” *blog.scalyr.com*, October 16, 2012.
1. Naohiro Hayashibara, Xavier Défago, Rami Yared, and Takuya Katayama: “[The ϕ Accrual Failure Detector](http://hdl.handle.net/10119/4784),” Japan Advanced Institute of Science and Technology, School of Information Science, Technical Report IS-RR-2004-010, May 2004.
1. Jeffrey Wang: “[Phi Accrual Failure Detector](http://ternarysearch.blogspot.co.uk/2013/08/phi-accrual-failure-detector.html),” *ternarysearch.blogspot.co.uk*, August 11, 2013.
1. Srinivasan Keshav: *An Engineering Approach to Computer Networking: ATM Networks, the Internet, and the Telephone Network*. Addison-Wesley Professional, May 1997. ISBN: 978-0-201-63442-6
1. Cisco, “[Integrated Services Digital Network](https://web.archive.org/web/20181229220921/http://docwiki.cisco.com/wiki/Integrated_Services_Digital_Network),” *docwiki.cisco.com*.
1. Othmar Kyas: *ATM Networks*. International Thomson Publishing, 1995. ISBN: 978-1-850-32128-6
1. “[InfiniBand FAQ](http://www.mellanox.com/related-docs/whitepapers/InfiniBandFAQ_FQ_100.pdf),” Mellanox Technologies, December 22, 2014.
1. Jose Renato Santos, Yoshio Turner, and G. (John) Janakiraman: “[End-to-End Congestion Control for InfiniBand](http://www.hpl.hp.com/techreports/2002/HPL-2002-359.pdf),” at *22nd Annual Joint Conference of the IEEE Computer and Communications Societies* (INFOCOM), April 2003. Also published by HP Laboratories Palo Alto, Tech Report HPL-2002-359. [doi:10.1109/INFCOM.2003.1208949](http://dx.doi.org/10.1109/INFCOM.2003.1208949)
1. Ulrich Windl, David Dalton, Marc Martinec, and Dale R. Worley: “[The NTP FAQ and HOWTO](http://www.ntp.org/ntpfaq/NTP-a-faq.htm),” *ntp.org*, November 2006.
1. John Graham-Cumming: “[How and why the leap second affected Cloudflare DNS](https://blog.cloudflare.com/how-and-why-the-leap-second-affected-cloudflare-dns/),” *blog.cloudflare.com*, January 1, 2017.
1. David Holmes: “[Inside the Hotspot VM: Clocks, Timers and Scheduling Events Part I Windows](https://web.archive.org/web/20160308031939/https://blogs.oracle.com/dholmes/entry/inside_the_hotspot_vm_clocks),” *blogs.oracle.com*, October 2, 2006.
1. Steve Loughran: “[Time on Multi-Core, Multi-Socket Servers](http://steveloughran.blogspot.co.uk/2015/09/time-on-multi-core-multi-socket-servers.html),” *steveloughran.blogspot.co.uk*, September 17, 2015.
1. James C. Corbett, Jeffrey Dean, Michael Epstein, et al.: “[Spanner: Googles Globally-Distributed Database](https://research.google/pubs/pub39966/),” at *10th USENIX Symposium on Operating System Design and Implementation* (OSDI), October 2012.
1. M. Caporaloni and R. Ambrosini: “[How Closely Can a Personal Computer Clock Track the UTC Timescale Via the Internet?](https://iopscience.iop.org/0143-0807/23/4/103/),” *European Journal of Physics*, volume 23, number 4, pages L17L21, June 2012. [doi:10.1088/0143-0807/23/4/103](http://dx.doi.org/10.1088/0143-0807/23/4/103)
1. Nelson Minar: “[A Survey of the NTP Network](http://alumni.media.mit.edu/~nelson/research/ntp-survey99/),” *alumni.media.mit.edu*, December 1999.
1. Viliam Holub: “[Synchronizing Clocks in a Cassandra Cluster Pt. 1 The Problem](https://blog.rapid7.com/2014/03/14/synchronizing-clocks-in-a-cassandra-cluster-pt-1-the-problem/),” *blog.rapid7.com*, March 14, 2014.
1. Poul-Henning Kamp: “[The One-Second War (What Time Will You Die?)](http://queue.acm.org/detail.cfm?id=1967009),” *ACM Queue*, volume 9, number 4, pages 4448, April 2011. [doi:10.1145/1966989.1967009](http://dx.doi.org/10.1145/1966989.1967009)
1. Nelson Minar: “[Leap Second Crashes Half the Internet](http://www.somebits.com/weblog/tech/bad/leap-second-2012.html),” *somebits.com*, July 3, 2012.
1. Christopher Pascoe: “[Time, Technology and Leaping Seconds](http://googleblog.blogspot.co.uk/2011/09/time-technology-and-leaping-seconds.html),” *googleblog.blogspot.co.uk*, September 15, 2011.
1. Mingxue Zhao and Jeff Barr: “[Look Before You Leap The Coming Leap Second and AWS](https://aws.amazon.com/blogs/aws/look-before-you-leap-the-coming-leap-second-and-aws/),” *aws.amazon.com*, May 18, 2015.
1. Darryl Veitch and Kanthaiah Vijayalayan: “[Network Timing and the 2015 Leap Second](https://tklab.feit.uts.edu.au/~darryl/Publications/LeapSecond_camera.pdf),” at *17th International Conference on Passive and Active Measurement* (PAM), April 2016. [doi:10.1007/978-3-319-30505-9_29](http://dx.doi.org/10.1007/978-3-319-30505-9_29)
1. “[Timekeeping in VMware Virtual Machines](https://www.vmware.com/content/dam/digitalmarketing/vmware/en/pdf/techpaper/Timekeeping-In-VirtualMachines.pdf),” Information Guide, VMware, Inc., December 2011.
1. “[MiFID II / MiFIR: Regulatory Technical and Implementing Standards Annex I (Draft)](https://www.esma.europa.eu/sites/default/files/library/2015/11/2015-esma-1464_annex_i_-_draft_rts_and_its_on_mifid_ii_and_mifir.pdf),” European Securities and Markets Authority, Report ESMA/2015/1464, September 2015.
1. Luke Bigum: “[Solving MiFID II Clock Synchronisation With Minimum Spend (Part 1)](https://web.archive.org/web/20170704030310/https://www.lmax.com/blog/staff-blogs/2015/11/27/solving-mifid-ii-clock-synchronisation-minimum-spend-part-1/),” *lmax.com*, November 27, 2015.
1. Kyle Kingsbury: “[Call Me Maybe: Cassandra](https://aphyr.com/posts/294-call-me-maybe-cassandra/),” *aphyr.com*, September 24, 2013.
1. John Daily: “[Clocks Are Bad, or, Welcome to the Wonderful World of Distributed Systems](https://riak.com/clocks-are-bad-or-welcome-to-distributed-systems/),” *riak.com*, November 12, 2013.
1. Kyle Kingsbury: “[The Trouble with Timestamps](https://aphyr.com/posts/299-the-trouble-with-timestamps),” *aphyr.com*, October 12, 2013.
1. Leslie Lamport: “[Time, Clocks, and the Ordering of Events in a Distributed System](https://www.microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system/),” *Communications of the ACM*, volume 21, number 7, pages 558565, July 1978. [doi:10.1145/359545.359563](http://dx.doi.org/10.1145/359545.359563)
1. Sandeep Kulkarni, Murat Demirbas, Deepak Madeppa, et al.: “[Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases](http://www.cse.buffalo.edu/tech-reports/2014-04.pdf),” State University of New York at Buffalo, Computer Science and Engineering Technical Report 2014-04, May 2014.
1. Justin Sheehy: “[There Is No Now: Problems With Simultaneity in Distributed Systems](https://queue.acm.org/detail.cfm?id=2745385),” *ACM Queue*, volume 13, number 3, pages 3641, March 2015. [doi:10.1145/2733108](http://dx.doi.org/10.1145/2733108)
1. Murat Demirbas: “[Spanner: Google's Globally-Distributed Database](http://muratbuffalo.blogspot.co.uk/2013/07/spanner-googles-globally-distributed_4.html),” *muratbuffalo.blogspot.co.uk*, July 4, 2013.
1. Dahlia Malkhi and Jean-Philippe Martin: “[Spanner's Concurrency Control](http://www.cs.cornell.edu/~ie53/publications/DC-col51-Sep13.pdf),” *ACM SIGACT News*, volume 44, number 3, pages 7377, September 2013. [doi:10.1145/2527748.2527767](http://dx.doi.org/10.1145/2527748.2527767)
1. Manuel Bravo, Nuno Diegues, Jingna Zeng, et al.: “[On the Use of Clocks to Enforce Consistency in the Cloud](http://sites.computer.org/debull/A15mar/p18.pdf),” *IEEE Data Engineering Bulletin*, volume 38, number 1, pages 1831, March 2015.
1. Spencer Kimball: “[Living Without Atomic Clocks](http://www.cockroachlabs.com/blog/living-without-atomic-clocks/),” *cockroachlabs.com*, February 17, 2016.
1. Cary G. Gray and David R. Cheriton: “[Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency](https://web.archive.org/web/20230325205928/http://web.stanford.edu/class/cs240/readings/89-leases.pdf),” at *12th ACM Symposium on Operating Systems Principles* (SOSP), December 1989. [doi:10.1145/74850.74870](http://dx.doi.org/10.1145/74850.74870)
1. Todd Lipcon: “[Avoiding Full GCs in Apache HBase with MemStore-Local Allocation Buffers: Part 1](https://web.archive.org/web/20121101040711/http://blog.cloudera.com/blog/2011/02/avoiding-full-gcs-in-hbase-with-memstore-local-allocation-buffers-part-1/),” *blog.cloudera.com*, February 24, 2011.
1. Martin Thompson: “[Java Garbage Collection Distilled](http://mechanical-sympathy.blogspot.co.uk/2013/07/java-garbage-collection-distilled.html),” *mechanical-sympathy.blogspot.co.uk*, July 16, 2013.
1. Alexey Ragozin: “[How to Tame Java GC Pauses? Surviving 16GiB Heap and Greater](https://dzone.com/articles/how-tame-java-gc-pauses),” *dzone.com*, June 28, 2011.
1. Christopher Clark, Keir Fraser, Steven Hand, et al.: “[Live Migration of Virtual Machines](http://www.cl.cam.ac.uk/research/srg/netos/papers/2005-nsdi-migration.pdf),” at *2nd USENIX Symposium on Symposium on Networked Systems Design & Implementation* (NSDI), May 2005.
1. Mike Shaver: “[fsyncers and Curveballs](https://web.archive.org/web/20220107141023/http://shaver.off.net/diary/2008/05/25/fsyncers-and-curveballs/),” *shaver.off.net*, May 25, 2008.
1. Zhenyun Zhuang and Cuong Tran: “[Eliminating Large JVM GC Pauses Caused by Background IO Traffic](https://engineering.linkedin.com/blog/2016/02/eliminating-large-jvm-gc-pauses-caused-by-background-io-traffic),” *engineering.linkedin.com*, February 10, 2016.
1. David Terei and Amit Levy: “[Blade: A Data Center Garbage Collector](http://arxiv.org/pdf/1504.02578.pdf),” arXiv:1504.02578, April 13, 2015.
1. Martin Maas, Tim Harris, Krste Asanović, and John Kubiatowicz: “[Trash Day: Coordinating Garbage Collection in Distributed Systems](https://timharris.uk/papers/2015-hotos.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015.
1. “[Predictable Low Latency](http://cdn2.hubspot.net/hubfs/1624455/Website_2016/content/White%20papers/Cinnober%20on%20GC%20pause%20free%20Java%20applications.pdf),” Cinnober Financial Technology AB, *cinnober.com*, November 24, 2013.
1. Martin Fowler: “[The LMAX Architecture](http://martinfowler.com/articles/lmax.html),” *martinfowler.com*, July 12, 2011.
1. Flavio P. Junqueira and Benjamin Reed: *ZooKeeper: Distributed Process Coordination*. O'Reilly Media, 2013. ISBN: 978-1-449-36130-3
1. Enis Söztutar: “[HBase and HDFS: Understanding Filesystem Usage in HBase](http://www.slideshare.net/enissoz/hbase-and-hdfs-understanding-filesystem-usage),” at *HBaseCon*, June 2013.
1. Caitie McCaffrey: “[Clients Are Jerks: AKA How Halo 4 DoSed the Services at Launch & How We Survived](https://web.archive.org/web/20230128065851/http://caitiem.com/2015/06/23/clients-are-jerks-aka-how-halo-4-dosed-the-services-at-launch-how-we-survived/),” *caitiem.com*, June 23, 2015.
1. Leslie Lamport, Robert Shostak, and Marshall Pease: “[The Byzantine Generals Problem](https://www.microsoft.com/en-us/research/publication/byzantine-generals-problem/),” *ACM Transactions on Programming Languages and Systems* (TOPLAS), volume 4, number 3, pages 382401, July 1982. [doi:10.1145/357172.357176](http://dx.doi.org/10.1145/357172.357176)
1. Jim N. Gray: “[Notes on Data Base Operating Systems](http://jimgray.azurewebsites.net/papers/dbos.pdf),” in *Operating Systems: An Advanced Course*, Lecture Notes in Computer Science, volume 60, edited by R. Bayer, R. M. Graham, and G. Seegmüller, pages 393481, Springer-Verlag, 1978. ISBN: 978-3-540-08755-7
1. Brian Palmer: “[How Complicated Was the Byzantine Empire?](http://www.slate.com/articles/news_and_politics/explainer/2011/10/the_byzantine_tax_code_how_complicated_was_byzantium_anyway_.html),” *slate.com*, October 20, 2011.
1. Leslie Lamport: “[My Writings](http://lamport.azurewebsites.net/pubs/pubs.html),” *lamport.azurewebsites.net*, December 16, 2014. This page can be found by searching the web for the 23-character string obtained by removing the hyphens from the string `allla-mport-spubso-ntheweb`.
1. John Rushby: “[Bus Architectures for Safety-Critical Embedded Systems](http://www.csl.sri.com/papers/emsoft01/emsoft01.pdf),” at *1st International Workshop on Embedded Software* (EMSOFT), October 2001.
1. Jake Edge: “[ELC: SpaceX Lessons Learned](http://lwn.net/Articles/540368/),” *lwn.net*, March 6, 2013.
1. Andrew Miller and Joseph J. LaViola, Jr.: “[Anonymous Byzantine Consensus from Moderately-Hard Puzzles: A Model for Bitcoin](http://nakamotoinstitute.org/static/docs/anonymous-byzantine-consensus.pdf),” University of Central Florida, Technical Report CS-TR-14-01, April 2014.
1. James Mickens: “[The Saddest Moment](https://www.usenix.org/system/files/login-logout_1305_mickens.pdf),” *USENIX ;login: logout*, May 2013.
1. Evan Gilman: “[The Discovery of Apache ZooKeepers Poison Packet](http://www.pagerduty.com/blog/the-discovery-of-apache-zookeepers-poison-packet/),” *pagerduty.com*, May 7, 2015.
1. Jonathan Stone and Craig Partridge: “[When the CRC and TCP Checksum Disagree](https://web.archive.org/web/20220818235232/https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.27.7611&rep=rep1&type=pdf),” at *ACM Conference on Applications, Technologies, Architectures, and Protocols for Computer Communication* (SIGCOMM), August 2000. [doi:10.1145/347059.347561](http://dx.doi.org/10.1145/347059.347561)
1. Evan Jones: “[How Both TCP and Ethernet Checksums Fail](http://www.evanjones.ca/tcp-and-ethernet-checksums-fail.html),” *evanjones.ca*, October 5, 2015.
1. Cynthia Dwork, Nancy Lynch, and Larry Stockmeyer: “[Consensus in the Presence of Partial Synchrony](https://dl.acm.org/doi/10.1145/42282.42283),” *Journal of the ACM*, volume 35, number 2, pages 288323, April 1988. [doi:10.1145/42282.42283](http://dx.doi.org/10.1145/42282.42283)
1. Peter Bailis and Ali Ghodsi: “[Eventual Consistency Today: Limitations, Extensions, and Beyond](http://queue.acm.org/detail.cfm?id=2462076),” *ACM Queue*, volume 11, number 3, pages 55-63, March 2013. [doi:10.1145/2460276.2462076](http://dx.doi.org/10.1145/2460276.2462076)
1. Bowen Alpern and Fred B. Schneider: “[Defining Liveness](https://www.cs.cornell.edu/fbs/publications/DefLiveness.pdf),” *Information Processing Letters*, volume 21, number 4, pages 181185, October 1985. [doi:10.1016/0020-0190(85)90056-0](http://dx.doi.org/10.1016/0020-0190(85)90056-0)
1. Flavio P. Junqueira: “[Dude, Wheres My Metadata?](https://web.archive.org/web/20230604215314/https://fpj.systems/2015/05/28/dude-wheres-my-metadata/),” *fpj.me*, May 28, 2015.
1. Scott Sanders: “[January 28th Incident Report](https://github.com/blog/2106-january-28th-incident-report),” *github.com*, February 3, 2016.
1. Jay Kreps: “[A Few Notes on Kafka and Jepsen](http://blog.empathybox.com/post/62279088548/a-few-notes-on-kafka-and-jepsen),” *blog.empathybox.com*, September 25, 2013.
1. Thanh Do, Mingzhe Hao, Tanakorn Leesatapornwongsa, et al.: “[Limplock: Understanding the Impact of Limpware on Scale-out Cloud Systems](http://ucare.cs.uchicago.edu/pdf/socc13-limplock.pdf),” at *4th ACM Symposium on Cloud Computing* (SoCC), October 2013. [doi:10.1145/2523616.2523627](http://dx.doi.org/10.1145/2523616.2523627)
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at *15th USENIX Workshop on Hot Topics in Operating Systems* (HotOS), May 2015.
[^译著1]: 原诗为Hey I just met you. The networks laggy. But heres my data. So store it maybe.Hey, 应改编自《Call Me Maybe》歌词I just met you, And this is crazy, But here's my number, So call me, maybe?

1036
content/v1/ch9.md Normal file

File diff suppressed because it is too large Load Diff

39
content/v1/colophon.md Normal file
View File

@ -0,0 +1,39 @@
---
title: 后记
weight: 600
breadcrumbs: false
---
## 关于作者
**Martin Kleppmann** 是英国剑桥大学分布式系统的研究员。此前他曾在互联网公司担任过软件工程师和企业家,其中包括 LinkedIn 和 Rapportive负责大规模数据基础架构。在这个过程中他以艰难的方式学习了一些东西他希望这本书能够让你避免重蹈覆辙。
Martin 是一位常规会议演讲者,博主和开源贡献者。他认为,每个人都应该有深刻的技术理念,深层次的理解能帮助我们开发出更好的软件。
![](http://martin.kleppmann.com/2017/03/ddia-poster.jpg)
## 关于译者
[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
PostgreSQL 发行版 [**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[国区活跃 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版译者,数据库/云计算 KOL。
## 后记
《设计数据密集型应用》封面上的动物是 **印度野猪Sus scrofa cristatus**,它是在印度、缅甸、尼泊尔、斯里兰卡和泰国发现的一种野猪的亚种。与欧洲野猪不同,它们有更高的背部鬃毛,没有体表绒毛,以及更大更直的头骨。
印度野猪有一头灰色或黑色的头发,脊背上有短而硬的毛。雄性有突出的犬齿(称为 T用来与对手战斗或抵御掠食者。雄性比雌性大这些物种平均肩高 33-35 英寸,体重 200-300 磅。他们的天敌包括熊、老虎和各种大型猫科动物。
这些动物夜行且杂食 —— 它们吃各种各样的东西,包括根、昆虫、腐肉、坚果、浆果和小动物。野猪经常因为破坏农作物的根被人们所熟知,他们造成大量的破坏,并被农民所敌视。他们每天需要摄入 4,000 ~ 4,500 卡路里的能量。野猪有发达的嗅觉,这有助于寻找地下植物和挖掘动物。然而,它们的视力很差。
野猪在人类文化中一直具有重要意义。在印度教传说中,野猪是毗湿奴神的化身。在古希腊的丧葬纪念碑中,它是一个勇敢失败者的象征(与胜利的狮子相反)。由于它的侵略,它被描绘在斯堪的纳维亚、日耳曼和盎格鲁撒克逊战士的盔甲和武器上。在中国十二生肖中,它象征着决心和急躁。
O'Reilly 封面上的许多动物都受到威胁,这些动物对世界都很重要。要了解有关如何提供帮助的更多信息,请访问 animals.oreilly.com。
封面图片来自 Shaw's Zoology。封面字体是 URW Typewriter 和 Guardian Sans。文字字体是 Adobe Minion Pro图中的字体是 Adobe Myriad Pro标题字体是 Adobe Myriad Condensed代码字体是 Dalton Maag 的 Ubuntu Mono。

232
content/v1/contrib.md Normal file
View File

@ -0,0 +1,232 @@
---
title: 贡献者
weight: 800
breadcrumbs: false
---
## 译者
[**冯若航**](https://vonng.com),网名 [@Vonng](https://github.com/Vonng)。
PostgreSQL 专家,数据库老司机,云计算泥石流。
[**Pigsty**](https://pgsty.com) 作者与创始人。
架构师DBA全栈工程师 @ TanTanAlibabaApple。
独立开源贡献者,[GitStar Ranking 585](https://gitstar-ranking.com/Vonng)[国区活跃 Top20](https://committers.top/china)。
[DDIA](https://ddia.pigsty.io) / [PG Internal](https://pgint.vonng.com) 中文版译者,公众号:《老冯云数》,数据库 KOL。
## 校订与维护
YinGang [@yingang](https://github.com/yingang) 对本书进行了全文校订,并持续维护。
## 繁体中文版本
[繁體中文](/tw) **版本维护** by [@afunTW](https://github.com/afunTW)
## 贡献列表
[GitHub 贡献者列表](https://github.com/Vonng/ddia/graphs/contributors)
0. 全文校订 by [@yingang](https://github.com/Vonng/ddia/commits?author=yingang)
1. [序言初翻修正](https://github.com/Vonng/ddia/commit/afb5edab55c62ed23474149f229677e3b42dfc2c) by [@seagullbird](https://github.com/Vonng/ddia/commits?author=seagullbird)
2. [第一章语法标点校正](https://github.com/Vonng/ddia/commit/973b12cd8f8fcdf4852f1eb1649ddd9d187e3644) by [@nevertiree](https://github.com/Vonng/ddia/commits?author=nevertiree)
3. [第六章部分校正](https://github.com/Vonng/ddia/commit/d4eb0852c0ec1e93c8aacc496c80b915bb1e6d48) 与[第十章的初翻](https://github.com/Vonng/ddia/commit/9de8dbd1bfe6fbb03b3bf6c1a1aa2291aed2490e) by [@MuAlex](https://github.com/Vonng/ddia/commits?author=MuAlex)
4. [第一部分](/part-i)前言,[ch2](/v1/ch2)校正 by [@jiajiadebug](https://github.com/Vonng/ddia/commits?author=jiajiadebug)
5. [词汇表](/glossary)、[后记](/colophon)关于野猪的部分 by [@Chowss](https://github.com/Vonng/ddia/commits?author=Chowss)
6. [繁體中文](https://github.com/Vonng/ddia/pulls)版本与转换脚本 by [@afunTW](https://github.com/afunTW)
7. 多处翻译修正 by [@songzhibin97](https://github.com/Vonng/ddia/commits?author=songzhibin97) [@MamaShip](https://github.com/Vonng/ddia/commits?author=MamaShip) [@FangYuan33](https://github.com/Vonng/ddia/commits?author=FangYuan33)
感谢所有提出意见,作出贡献的朋友们,您可以在这里找到所有贡献的 [Issue 列表](https://github.com/Vonng/ddia/issues) 与 [PR 列表](https://github.com/Vonng/ddia/pulls)
| ISSUE & Pull Requests | USER | Title |
|-------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------|
| [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: 修正一处标点错误 |
| [355](https://github.com/Vonng/ddia/pull/355) | [@DuroyGeorge](https://github.com/DuroyGeorge) | ch12: 修正一处格式错误 |
| [354](https://github.com/Vonng/ddia/pull/354) | [@justlorain](https://github.com/justlorain) | ch7: 修正一处参考链接 |
| [353](https://github.com/Vonng/ddia/pull/353) | [@fantasyczl](https://github.com/fantasyczl) | ch3&9: 修正两处引用错误 |
| [352](https://github.com/Vonng/ddia/pull/352) | [@fantasyczl](https://github.com/fantasyczl) | 支持输出为 EPUB 格式 |
| [349](https://github.com/Vonng/ddia/pull/349) | [@xiyihan0](https://github.com/xiyihan0) | ch1: 修正一处格式错误 |
| [348](https://github.com/Vonng/ddia/pull/348) | [@omegaatt36](https://github.com/omegaatt36) | ch3: 修正一处图像链接 |
| [346](https://github.com/Vonng/ddia/issues/346) | [@Vermouth1995](https://github.com/Vermouth1995) | ch1: 优化一处翻译 |
| [343](https://github.com/Vonng/ddia/pull/343) | [@kehao-chen](https://github.com/kehao-chen) | ch10: 优化一处翻译 |
| [341](https://github.com/Vonng/ddia/pull/341) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch3: 优化两处翻译 |
| [340](https://github.com/Vonng/ddia/pull/340) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch2: 优化多处翻译 |
| [338](https://github.com/Vonng/ddia/pull/338) | [@YKIsTheBest](https://github.com/YKIsTheBest) | ch1: 优化一处翻译 |
| [335](https://github.com/Vonng/ddia/pull/335) | [@kimi0230](https://github.com/kimi0230) | 修正一处繁体中文错误 |
| [334](https://github.com/Vonng/ddia/pull/334) | [@soulrrrrr](https://github.com/soulrrrrr) | ch2: 修正一处繁体中文错误 |
| [332](https://github.com/Vonng/ddia/pull/332) | [@justlorain](https://github.com/justlorain) | ch5: 修正一处翻译错误 |
| [331](https://github.com/Vonng/ddia/pull/331) | [@Lyianu](https://github.com/Lyianu) | ch9: 更正几处拼写错误 |
| [330](https://github.com/Vonng/ddia/pull/330) | [@Lyianu](https://github.com/Lyianu) | ch7: 优化一处翻译 |
| [329](https://github.com/Vonng/ddia/issues/329) | [@Lyianu](https://github.com/Lyianu) | ch6: 指出一处翻译错误 |
| [328](https://github.com/Vonng/ddia/pull/328) | [@justlorain](https://github.com/justlorain) | ch4: 更正一处翻译遗漏 |
| [326](https://github.com/Vonng/ddia/pull/326) | [@liangGTY](https://github.com/liangGTY) | ch1: 优化一处翻译 |
| [323](https://github.com/Vonng/ddia/pull/323) | [@marvin263](https://github.com/marvin263) | ch5: 优化一处翻译 |
| [322](https://github.com/Vonng/ddia/pull/322) | [@marvin263](https://github.com/marvin263) | ch8: 优化一处翻译 |
| [304](https://github.com/Vonng/ddia/pull/304) | [@spike014](https://github.com/spike014) | ch11: 优化一处翻译 |
| [298](https://github.com/Vonng/ddia/pull/298) | [@Makonike](https://github.com/Makonike) | ch11&12: 修正两处错误 |
| [284](https://github.com/Vonng/ddia/pull/284) | [@WAangzE](https://github.com/WAangzE) | ch4: 更正一处列表错误 |
| [283](https://github.com/Vonng/ddia/pull/283) | [@WAangzE](https://github.com/WAangzE) | ch3: 更正一处错别字 |
| [282](https://github.com/Vonng/ddia/pull/282) | [@WAangzE](https://github.com/WAangzE) | ch2: 更正一处公式问题 |
| [281](https://github.com/Vonng/ddia/pull/281) | [@lyuxi99](https://github.com/lyuxi99) | 更正多处内部链接错误 |
| [280](https://github.com/Vonng/ddia/pull/280) | [@lyuxi99](https://github.com/lyuxi99) | ch9: 更正内部链接错误 |
| [279](https://github.com/Vonng/ddia/issues/279) | [@codexvn](https://github.com/codexvn) | ch9: 指出公式在 GitHub Pages 显示的问题 |
| [278](https://github.com/Vonng/ddia/pull/278) | [@LJlkdskdjflsa](https://github.com/LJlkdskdjflsa) | 发现了繁体中文版本中的错误翻译 |
| [275](https://github.com/Vonng/ddia/pull/275) | [@117503445](https://github.com/117503445) | 更正 LICENSE 链接 |
| [274](https://github.com/Vonng/ddia/pull/274) | [@uncle-lv](https://github.com/uncle-lv) | ch7: 修正错别字 |
| [273](https://github.com/Vonng/ddia/pull/273) | [@Sdot-Python](https://github.com/Sdot-Python) | ch7: 统一了 write skew 的翻译 |
| [271](https://github.com/Vonng/ddia/pull/271) | [@Makonike](https://github.com/Makonike) | ch6: 统一了 rebalancing 的翻译 |
| [270](https://github.com/Vonng/ddia/pull/270) | [@Ynjxsjmh](https://github.com/Ynjxsjmh) | ch7: 修正不一致的翻译 |
| [263](https://github.com/Vonng/ddia/pull/263) | [@zydmayday](https://github.com/zydmayday) | ch5: 修正译文中的重复单词 |
| [260](https://github.com/Vonng/ddia/pull/260) | [@haifeiWu](https://github.com/haifeiWu) | ch4: 修正部分不准确的翻译 |
| [258](https://github.com/Vonng/ddia/pull/258) | [@bestgrc](https://github.com/bestgrc) | ch3: 修正一处翻译错误 |
| [257](https://github.com/Vonng/ddia/pull/257) | [@UnderSam](https://github.com/UnderSam) | ch8: 修正一处拼写错误 |
| [256](https://github.com/Vonng/ddia/pull/256) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可串行化”相关内容的多处翻译不当 |
| [255](https://github.com/Vonng/ddia/pull/255) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“可重复读”相关内容的多处翻译不当 |
| [253](https://github.com/Vonng/ddia/pull/253) | [@AlphaWang](https://github.com/AlphaWang) | ch7: 修正“读已提交”相关内容的多处翻译不当 |
| [246](https://github.com/Vonng/ddia/pull/246) | [@derekwu0101](https://github.com/derekwu0101) | ch3: 修正繁体中文的转译错误 |
| [245](https://github.com/Vonng/ddia/pull/245) | [@skyran1278](https://github.com/skyran1278) | ch12: 修正繁体中文的转译错误 |
| [244](https://github.com/Vonng/ddia/pull/244) | [@Axlgrep](https://github.com/Axlgrep) | ch9: 修正不通顺的翻译 |
| [242](https://github.com/Vonng/ddia/pull/242) | [@lynkeib](https://github.com/lynkeib) | ch9: 修正不通顺的翻译 |
| [241](https://github.com/Vonng/ddia/pull/241) | [@lynkeib](https://github.com/lynkeib) | ch8: 修正不正确的公式格式 |
| [240](https://github.com/Vonng/ddia/pull/240) | [@8da2k](https://github.com/8da2k) | ch9: 修正不通顺的翻译 |
| [239](https://github.com/Vonng/ddia/pull/239) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch7: 修正不一致的翻译 |
| [237](https://github.com/Vonng/ddia/pull/237) | [@zhangnew](https://github.com/zhangnew) | ch3: 修正错误的图片链接 |
| [229](https://github.com/Vonng/ddia/pull/229) | [@lis186](https://github.com/lis186) | 指出繁体中文的转译错误:复杂 |
| [226](https://github.com/Vonng/ddia/pull/226) | [@chroming](https://github.com/chroming) | ch1: 修正导航栏中的章节名称 |
| [220](https://github.com/Vonng/ddia/pull/220) | [@skyran1278](https://github.com/skyran1278) | ch9: 修正线性一致的繁体中文翻译 |
| [194](https://github.com/Vonng/ddia/pull/194) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正错误的翻译 |
| [193](https://github.com/Vonng/ddia/pull/193) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 优化译文 |
| [192](https://github.com/Vonng/ddia/pull/192) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | ch4: 修正不一致和不通顺的翻译 |
| [190](https://github.com/Vonng/ddia/pull/190) | [@Pcrab](https://github.com/Pcrab) | ch1: 修正不准确的翻译 |
| [187](https://github.com/Vonng/ddia/pull/187) | [@narojay](https://github.com/narojay) | ch9: 修正生硬的翻译 |
| [186](https://github.com/Vonng/ddia/pull/186) | [@narojay](https://github.com/narojay) | ch8: 修正错别字 |
| [185](https://github.com/Vonng/ddia/issues/185) | [@8da2k](https://github.com/8da2k) | 指出小标题跳转的问题 |
| [184](https://github.com/Vonng/ddia/pull/184) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch10: 修正失效的网址 |
| [183](https://github.com/Vonng/ddia/pull/183) | [@OneSizeFitsQuorum](https://github.com/OneSizeFitsQuorum) | ch8: 修正错别字 |
| [182](https://github.com/Vonng/ddia/issues/182) | [@lroolle](https://github.com/lroolle) | 建议docsify的主题风格 |
| [181](https://github.com/Vonng/ddia/pull/181) | [@YunfengGao](https://github.com/YunfengGao) | ch2: 修正翻译错误 |
| [180](https://github.com/Vonng/ddia/pull/180) | [@skyran1278](https://github.com/skyran1278) | ch3: 指出繁体中文的转译错误 |
| [177](https://github.com/Vonng/ddia/pull/177) | [@exzhawk](https://github.com/exzhawk) | 支持 Github Pages 里的公式显示 |
| [176](https://github.com/Vonng/ddia/pull/176) | [@haifeiWu](https://github.com/haifeiWu) | ch2: 语义网相关翻译更正 |
| [175](https://github.com/Vonng/ddia/pull/175) | [@cwr31](https://github.com/cwr31) | ch7: 不变式相关翻译更正 |
| [174](https://github.com/Vonng/ddia/pull/174) | [@BeBraveBeCurious](https://github.com/BeBraveBeCurious) | README & preface: 更正不正确的中文用词和标点符号 |
| [173](https://github.com/Vonng/ddia/pull/173) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正不完整的翻译 |
| [171](https://github.com/Vonng/ddia/pull/171) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 修正重复的译文 |
| [169](https://github.com/Vonng/ddia/pull/169) | [@ZvanYang](https://github.com/ZvanYang) | ch12: 更正不太通顺的翻译 |
| [166](https://github.com/Vonng/ddia/pull/166) | [@bp4m4h94](https://github.com/bp4m4h94) | ch1: 发现错误的文献索引 |
| [164](https://github.com/Vonng/ddia/pull/164) | [@DragonDriver](https://github.com/DragonDriver) | preface: 更正错误的标点符号 |
| [163](https://github.com/Vonng/ddia/pull/163) | [@llmmddCoder](https://github.com/llmmddCoder) | ch1: 更正错误字 |
| [160](https://github.com/Vonng/ddia/pull/160) | [@Zhayhp](https://github.com/Zhayhp) | ch2: 建议将 network model 翻译为网状模型 |
| [159](https://github.com/Vonng/ddia/pull/159) | [@1ess](https://github.com/1ess) | ch4: 更正错误字 |
| [157](https://github.com/Vonng/ddia/pull/157) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [155](https://github.com/Vonng/ddia/pull/155) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 更正不太通顺的翻译 |
| [153](https://github.com/Vonng/ddia/pull/153) | [@DavidZhiXing](https://github.com/DavidZhiXing) | ch9: 修正缩略图的错别字 |
| [152](https://github.com/Vonng/ddia/pull/152) | [@ZvanYang](https://github.com/ZvanYang) | ch7: 除重->去重 |
| [151](https://github.com/Vonng/ddia/pull/151) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 修订sibling相关的翻译 |
| [147](https://github.com/Vonng/ddia/pull/147) | [@ZvanYang](https://github.com/ZvanYang) | ch5: 更正一处不准确的翻译 |
| [145](https://github.com/Vonng/ddia/pull/145) | [@Hookey](https://github.com/Hookey) | 识别了当前简繁转译过程中处理不当的地方,暂通过转换脚本规避 |
| [144](https://github.com/Vonng/ddia/issues/144) | [@secret4233](https://github.com/secret4233) | ch7: 不翻译`next-key locking` |
| [143](https://github.com/Vonng/ddia/issues/143) | [@imcheney](https://github.com/imcheney) | ch3: 更新残留的机翻段落 |
| [142](https://github.com/Vonng/ddia/issues/142) | [@XIJINIAN](https://github.com/XIJINIAN) | 建议去除段首的制表符 |
| [141](https://github.com/Vonng/ddia/issues/141) | [@Flyraty](https://github.com/Flyraty) | ch5: 发现一处错误格式的章节引用 |
| [140](https://github.com/Vonng/ddia/pull/140) | [@Bowser1704](https://github.com/Bowser1704) | ch5: 修正章节Summary中多处不通顺的翻译 |
| [139](https://github.com/Vonng/ddia/pull/139) | [@Bowser1704](https://github.com/Bowser1704) | ch2&ch3: 修正多处不通顺的或错误的翻译 |
| [137](https://github.com/Vonng/ddia/pull/137) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch5&ch6: 优化多处不通顺的或错误的翻译 |
| [134](https://github.com/Vonng/ddia/pull/134) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch4: 优化多处不通顺的或错误的翻译 |
| [133](https://github.com/Vonng/ddia/pull/133) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化多处错误的或不通顺的翻译 |
| [132](https://github.com/Vonng/ddia/pull/132) | [@fuxuemingzhu](https://github.com/fuxuemingzhu) | ch3: 优化一处容易产生歧义的翻译 |
| [131](https://github.com/Vonng/ddia/pull/131) | [@rwwg4](https://github.com/rwwg4) | ch6: 修正两处错误的翻译 |
| [129](https://github.com/Vonng/ddia/pull/129) | [@anaer](https://github.com/anaer) | ch4: 修正两处强调文本和四处代码变量名称 |
| [128](https://github.com/Vonng/ddia/pull/128) | [@meilin96](https://github.com/meilin96) | ch5: 修正一处错误的引用 |
| [126](https://github.com/Vonng/ddia/pull/126) | [@cwr31](https://github.com/cwr31) | ch10: 修正一处错误的翻译(功能 -> 函数) |
| [125](https://github.com/Vonng/ddia/pull/125) | [@dch1228](https://github.com/dch1228) | ch2: 优化 how best 的翻译(如何以最佳方式) |
| [123](https://github.com/Vonng/ddia/pull/123) | [@yingang](https://github.com/yingang) | translation updates (chapter 9, TOC in readme, glossary, etc.) |
| [121](https://github.com/Vonng/ddia/pull/121) | [@yingang](https://github.com/yingang) | translation updates (chapter 5 to chapter 8) |
| [120](https://github.com/Vonng/ddia/pull/120) | [@jiong-han](https://github.com/jiong-han) | Typo fix: 呲之以鼻 -> 嗤之以鼻 |
| [119](https://github.com/Vonng/ddia/pull/119) | [@cclauss](https://github.com/cclauss) | Streamline file operations in convert() |
| [118](https://github.com/Vonng/ddia/pull/118) | [@yingang](https://github.com/yingang) | translation updates (chapter 2 to chapter 4) |
| [117](https://github.com/Vonng/ddia/pull/117) | [@feeeei](https://github.com/feeeei) | 统一每章的标题格式 |
| [115](https://github.com/Vonng/ddia/pull/115) | [@NageNalock](https://github.com/NageNalock) | 第七章病句修改: 重复词语 |
| [114](https://github.com/Vonng/ddia/pull/114) | [@Sunt-ing](https://github.com/Sunt-ing) | Update README.md: correct the book name |
| [113](https://github.com/Vonng/ddia/pull/113) | [@lpxxn](https://github.com/lpxxn) | 修改语句 |
| [112](https://github.com/Vonng/ddia/pull/112) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [110](https://github.com/Vonng/ddia/pull/110) | [@lpxxn](https://github.com/lpxxn) | 读已写入数据 |
| [107](https://github.com/Vonng/ddia/pull/107) | [@abbychau](https://github.com/abbychau) | 單調鐘和好死还是赖活着 |
| [106](https://github.com/Vonng/ddia/pull/106) | [@enochii](https://github.com/enochii) | typo in ch2: fix braces typo |
| [105](https://github.com/Vonng/ddia/pull/105) | [@LiminCode](https://github.com/LiminCode) | Chronicle translation error |
| [104](https://github.com/Vonng/ddia/pull/104) | [@Sunt-ing](https://github.com/Sunt-ing) | several advice for better translation |
| [103](https://github.com/Vonng/ddia/pull/103) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in ch4: should be 完成 rather than 完全 |
| [102](https://github.com/Vonng/ddia/pull/102) | [@Sunt-ing](https://github.com/Sunt-ing) | ch4: better-translation: 扼杀 → 破坏 |
| [101](https://github.com/Vonng/ddia/pull/101) | [@Sunt-ing](https://github.com/Sunt-ing) | typo in Ch4: should be "改变" rathr than "盖面" |
| [100](https://github.com/Vonng/ddia/pull/100) | [@LiminCode](https://github.com/LiminCode) | fix missing translation |
| [99 ](https://github.com/Vonng/ddia/pull/99) | [@mrdrivingduck](https://github.com/mrdrivingduck) | ch6: fix the word rebalancing |
| [98 ](https://github.com/Vonng/ddia/pull/98) | [@jacklightChen](https://github.com/jacklightChen) | fix ch7.md: fix wrong references |
| [97 ](https://github.com/Vonng/ddia/pull/97) | [@jenac](https://github.com/jenac) | 96 |
| [96 ](https://github.com/Vonng/ddia/pull/96) | [@PragmaTwice](https://github.com/PragmaTwice) | ch2: fix typo about 'may or may not be' |
| [95 ](https://github.com/Vonng/ddia/pull/95) | [@EvanMu96](https://github.com/EvanMu96) | fix translation of "the battle cry" in ch5 |
| [94 ](https://github.com/Vonng/ddia/pull/94) | [@kemingy](https://github.com/kemingy) | ch6: fix markdown and punctuations |
| [93 ](https://github.com/Vonng/ddia/pull/93) | [@kemingy](https://github.com/kemingy) | ch5: fix markdown and some typos |
| [92 ](https://github.com/Vonng/ddia/pull/92) | [@Gilbert1024](https://github.com/Gilbert1024) | Merge pull request #1 from Vonng/master |
| [88 ](https://github.com/Vonng/ddia/pull/88) | [@kemingy](https://github.com/kemingy) | fix typo for ch1, ch2, ch3, ch4 |
| [87 ](https://github.com/Vonng/ddia/pull/87) | [@wynn5a](https://github.com/wynn5a) | Update ch3.md |
| [86 ](https://github.com/Vonng/ddia/pull/86) | [@northmorn](https://github.com/northmorn) | Update ch1.md |
| [85 ](https://github.com/Vonng/ddia/pull/85) | [@sunbuhui](https://github.com/sunbuhui) | fix ch2.md: fix ch2 ambiguous translation |
| [84 ](https://github.com/Vonng/ddia/pull/84) | [@ganler](https://github.com/ganler) | Fix translation: use up |
| [83 ](https://github.com/Vonng/ddia/pull/83) | [@afunTW](https://github.com/afunTW) | Using OpenCC to convert from zh-cn to zh-tw |
| [82 ](https://github.com/Vonng/ddia/pull/82) | [@kangni](https://github.com/kangni) | fix gitbook url |
| [78 ](https://github.com/Vonng/ddia/pull/78) | [@hanyu2](https://github.com/hanyu2) | Fix unappropriated translation |
| [77 ](https://github.com/Vonng/ddia/pull/77) | [@Ozarklake](https://github.com/Ozarklake) | fix typo |
| [75 ](https://github.com/Vonng/ddia/pull/75) | [@2997ms](https://github.com/2997ms) | Fix typo |
| [74 ](https://github.com/Vonng/ddia/pull/74) | [@2997ms](https://github.com/2997ms) | Update ch9.md |
| [70 ](https://github.com/Vonng/ddia/pull/70) | [@2997ms](https://github.com/2997ms) | Update ch7.md |
| [67 ](https://github.com/Vonng/ddia/pull/67) | [@jiajiadebug](https://github.com/jiajiadebug) | fix issues in ch2 - ch9 and glossary |
| [66 ](https://github.com/Vonng/ddia/pull/66) | [@blindpirate](https://github.com/blindpirate) | Fix typo |
| [63 ](https://github.com/Vonng/ddia/pull/63) | [@haifeiWu](https://github.com/haifeiWu) | Update ch10.md |
| [62 ](https://github.com/Vonng/ddia/pull/62) | [@ych](https://github.com/ych) | fix ch1.md typesetting problem |
| [61 ](https://github.com/Vonng/ddia/pull/61) | [@xianlaioy](https://github.com/xianlaioy) | docs:钟-->种去掉ou |
| [60 ](https://github.com/Vonng/ddia/pull/60) | [@Zombo1296](https://github.com/Zombo1296) | 否则 -> 或者 |
| [59 ](https://github.com/Vonng/ddia/pull/59) | [@AlexanderMisel](https://github.com/AlexanderMisel) | 呼叫->调用,显着->显著 |
| [58 ](https://github.com/Vonng/ddia/pull/58) | [@ibyte2011](https://github.com/ibyte2011) | Update ch8.md |
| [55 ](https://github.com/Vonng/ddia/pull/55) | [@saintube](https://github.com/saintube) | ch8: 修改链接错误 |
| [54 ](https://github.com/Vonng/ddia/pull/54) | [@Panmax](https://github.com/Panmax) | Update ch2.md |
| [53 ](https://github.com/Vonng/ddia/pull/53) | [@ibyte2011](https://github.com/ibyte2011) | Update ch9.md |
| [52 ](https://github.com/Vonng/ddia/pull/52) | [@hecenjie](https://github.com/hecenjie) | Update ch1.md |
| [51 ](https://github.com/Vonng/ddia/pull/51) | [@latavin243](https://github.com/latavin243) | fix 修正ch3 ch4几处翻译 |
| [50 ](https://github.com/Vonng/ddia/pull/50) | [@AlexZFX](https://github.com/AlexZFX) | 几个疏漏和格式错误 |
| [49 ](https://github.com/Vonng/ddia/pull/49) | [@haifeiWu](https://github.com/haifeiWu) | Update ch1.md |
| [48 ](https://github.com/Vonng/ddia/pull/48) | [@scaugrated](https://github.com/scaugrated) | fix typo |
| [47 ](https://github.com/Vonng/ddia/pull/47) | [@lzwill](https://github.com/lzwill) | Fixed typos in ch2 |
| [45 ](https://github.com/Vonng/ddia/pull/45) | [@zenuo](https://github.com/zenuo) | 删除一个多余的右括号 |
| [44 ](https://github.com/Vonng/ddia/pull/44) | [@akxxsb](https://github.com/akxxsb) | 修正第七章底部链接错误 |
| [43 ](https://github.com/Vonng/ddia/pull/43) | [@baijinping](https://github.com/baijinping) | "更假简单"->"更加简单" |
| [42 ](https://github.com/Vonng/ddia/pull/42) | [@tisonkun](https://github.com/tisonkun) | 修复 ch1 中的无序列表格式 |
| [38 ](https://github.com/Vonng/ddia/pull/38) | [@renjie-c](https://github.com/renjie-c) | 纠正多处的翻译小错误 |
| [37 ](https://github.com/Vonng/ddia/pull/37) | [@tankilo](https://github.com/tankilo) | fix translation mistakes in ch4.md |
| [36 ](https://github.com/Vonng/ddia/pull/36) | [@wwek](https://github.com/wwek) | 1.修复多个链接错误 2.名词优化修订 3.错误修订 |
| [35 ](https://github.com/Vonng/ddia/pull/35) | [@wwek](https://github.com/wwek) | fix ch7.md to ch8.md link error |
| [34 ](https://github.com/Vonng/ddia/pull/34) | [@wwek](https://github.com/wwek) | Merge pull request #1 from Vonng/master |
| [33 ](https://github.com/Vonng/ddia/pull/33) | [@wwek](https://github.com/wwek) | fix part-ii.md link error |
| [32 ](https://github.com/Vonng/ddia/pull/32) | [@JCYoky](https://github.com/JCYoky) | Update ch2.md |
| [31 ](https://github.com/Vonng/ddia/pull/31) | [@elsonLee](https://github.com/elsonLee) | Update ch7.md |
| [26 ](https://github.com/Vonng/ddia/pull/26) | [@yjhmelody](https://github.com/yjhmelody) | 修复一些明显错误 |
| [25 ](https://github.com/Vonng/ddia/pull/25) | [@lqbilbo](https://github.com/lqbilbo) | 修复链接错误 |
| [24 ](https://github.com/Vonng/ddia/pull/24) | [@artiship](https://github.com/artiship) | 修改词语顺序 |
| [23 ](https://github.com/Vonng/ddia/pull/23) | [@artiship](https://github.com/artiship) | 修正错别字 |
| [22 ](https://github.com/Vonng/ddia/pull/22) | [@artiship](https://github.com/artiship) | 纠正翻译错误 |
| [21 ](https://github.com/Vonng/ddia/pull/21) | [@zhtisi](https://github.com/zhtisi) | 修正目录和本章标题不符的情况 |
| [20 ](https://github.com/Vonng/ddia/pull/20) | [@rentiansheng](https://github.com/rentiansheng) | Update ch7.md |
| [19 ](https://github.com/Vonng/ddia/pull/19) | [@LHRchina](https://github.com/LHRchina) | 修复语句小bug |
| [16 ](https://github.com/Vonng/ddia/pull/16) | [@MuAlex](https://github.com/MuAlex) | Master |
| [15 ](https://github.com/Vonng/ddia/pull/15) | [@cg-zhou](https://github.com/cg-zhou) | Update translation progress |
| [14 ](https://github.com/Vonng/ddia/pull/14) | [@cg-zhou](https://github.com/cg-zhou) | Translate glossary |
| [13 ](https://github.com/Vonng/ddia/pull/13) | [@cg-zhou](https://github.com/cg-zhou) | 详细修改了后记中和印度野猪相关的描述 |
| [12 ](https://github.com/Vonng/ddia/pull/12) | [@ibyte2011](https://github.com/ibyte2011) | 修改了部分翻译 |
| [11 ](https://github.com/Vonng/ddia/pull/11) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 100% |
| [10 ](https://github.com/Vonng/ddia/pull/10) | [@jiajiadebug](https://github.com/jiajiadebug) | ch2 20% |
| [9 ](https://github.com/Vonng/ddia/pull/9) | [@jiajiadebug](https://github.com/jiajiadebug) | Preface, ch1, part-i translation minor fixes |
| [7 ](https://github.com/Vonng/ddia/pull/7) | [@MuAlex](https://github.com/MuAlex) | Ch6 translation pull request |
| [6 ](https://github.com/Vonng/ddia/pull/6) | [@MuAlex](https://github.com/MuAlex) | Ch6 change version1 |
| [5 ](https://github.com/Vonng/ddia/pull/5) | [@nevertiree](https://github.com/nevertiree) | Chapter 01语法微调 |
| [2 ](https://github.com/Vonng/ddia/pull/2) | [@seagullbird](https://github.com/seagullbird) | 序言初翻 |

256
content/v1/glossary.md Normal file
View File

@ -0,0 +1,256 @@
---
title: 术语表
weight: 500
breadcrumbs: false
---
> 请注意,本术语表中的定义简短而简单,旨在传达核心思想,而非死扣完整细节。有关更多详细信息,请参阅正文中的参考资料。
## **异步asynchronous**
不等待某些事情完成(例如,将数据发送到网络中的另一个节点),并且不会假设要花多长时间。请参阅“[同步复制与异步复制](/v1/ch5#同步复制与异步复制)”、“[同步网络与异步网络](/v1/ch8#同步网络与异步网络)”以及“[系统模型与现实](/v1/ch8#系统模型与现实)”。
## **原子atomic**
在并发操作的上下文中:描述一个在单个时间点看起来生效的操作,所以另一个并发进程永远不会遇到处于“半完成”状态的操作。另见隔离。
在事务的上下文中:将一些写入操作分为一组,这组写入要么全部提交成功,要么遇到错误时全部回滚。请参阅“[原子性](/v1/ch7#原子性)”和“[原子提交与两阶段提交](/v1/ch9#原子提交与两阶段提交)”。
## **背压backpressure**
接收方接收数据速度较慢时,强制降低发送方的数据发送速度。也称为流量控制。请参阅“[消息传递系统](/v1/ch11#消息传递系统)”。
## **批处理batch process**
一种计算,它将一些固定的(通常是大的)数据集作为输入,并将其他一些数据作为输出,而不修改输入。见[第十章](/v1/ch10)。
## **边界bounded**
有一些已知的上限或大小。例如,网络延迟情况(请参阅“[超时与无穷的延迟](/v1/ch8#超时与无穷的延迟)”)和数据集(请参阅[第十一章](/v1/ch11)的介绍)。
## **拜占庭故障Byzantine fault**
表现异常的节点,这种异常可能以任意方式出现,例如向其他节点发送矛盾或恶意消息。请参阅“[拜占庭故障](/v1/ch8#拜占庭故障)”。
## **缓存cache**
一种组件,通过存储最近使用过的数据,加快未来对相同数据的读取速度。缓存中通常存放部分数据:因此,如果缓存中缺少某些数据,则必须从某些底层较慢的数据存储系统中,获取完整的数据副本。
## **CAP定理CAP theorem**
一个被广泛误解的理论结果,在实践中是没有用的。请参阅“[CAP定理](/v1/ch9#CAP定理)”。
## **因果关系causality**
事件之间的依赖关系,当一件事发生在另一件事情之前。例如,后面的事件是对早期事件的回应,或者依赖于更早的事件,或者应该根据先前的事件来理解。请参阅“[“此前发生”的关系和并发](/v1/ch5#“此前发生”的关系和并发)”和“[顺序与因果关系](/v1/ch9#顺序与因果关系)”。
## **共识consensus**
分布式计算的一个基本问题,就是让几个节点同意某些事情(例如,哪个节点应该是数据库集群的领导者)。问题比乍看起来要困难得多。请参阅“[容错共识](/v1/ch9#容错共识)”。
## **数据仓库data warehouse**
一个数据库其中来自几个不同的OLTP系统的数据已经被合并和准备用于分析目的。请参阅“[数据仓库](/v1/ch3#数据仓库)”。
## **声明式declarative**
描述某些东西应有的属性,但不知道如何实现它的确切步骤。在查询的上下文中,查询优化器采用声明性查询并决定如何最好地执行它。请参阅“[数据查询语言](/v1/ch2#数据查询语言)”。
## **非规范化denormalize**
为了加速读取,在标准数据集中引入一些冗余或重复数据,通常采用缓存或索引的形式。非规范化的值是一种预先计算的查询结果,像物化视图。请参阅“[单对象和多对象操作](/v1/ch7#单对象和多对象操作)”和“[从同一事件日志中派生多个视图](/v1/ch11#从同一事件日志中派生多个视图)”。
## **衍生数据derived data**
一种数据集,根据其他数据通过可重复运行的流程创建。必要时,你可以运行该流程再次创建衍生数据。衍生数据通常用于提高特定数据的读取速度。常见的衍生数据有索引、缓存和物化视图。请参阅[第三部分](/part-iii)的介绍。
## **确定性deterministic**
描述一个函数,如果给它相同的输入,则总是产生相同的输出。这意味着它不能依赖于随机数字、时间、网络通信或其他不可预测的事情。
## **分布式distributed**
在由网络连接的多个节点上运行。对于部分节点故障,具有容错性:系统的一部分发生故障时,其他部分仍可以正常工作,通常情况下,软件无需了解故障相关的确切情况。请参阅“[故障与部分失效](/v1/ch8#故障与部分失效)”。
## **持久durable**
以某种方式存储数据,即使发生各种故障,也不会丢失数据。请参阅“[持久性](/v1/ch7#持久性)”。
## **ETLExtract-Transform-Load**
提取-转换-加载Extract-Transform-Load。从源数据库中提取数据将其转换为更适合分析查询的形式并将其加载到数据仓库或批处理系统中的过程。请参阅“[数据仓库](/v1/ch3#数据仓库)”。
## **故障切换failover**
在具有单一领导者的系统中,故障切换是将领导角色从一个节点转移到另一个节点的过程。请参阅“[处理节点宕机](/v1/ch5#处理节点宕机)”。
## **容错fault-tolerant**
如果出现问题(例如,机器崩溃或网络连接失败),可以自动恢复。请参阅“[可靠性](/v1/ch1#可靠性)”。
## **流量控制flow control**
见背压backpressure
## **追随者follower**
一种数据副本,仅处理领导者或主库发出的数据变更,不直接接受来自客户端的任何写入。也称为备库、从库、只读副本或热备份。请参阅“[领导者与追随者](/v1/ch5#领导者与追随者)”。
## **全文检索full-text search**
通过任意关键字来搜索文本,通常具有附加特征,例如匹配类似的拼写词或同义词。全文索引是一种支持这种查询的次级索引。请参阅“[全文搜索和模糊索引](/v1/ch3#全文搜索和模糊索引)”。
## **图graph**
一种数据结构,由顶点(可以指向的东西,也称为节点或实体)和边(从一个顶点到另一个顶点的连接,也称为关系或弧)组成。请参阅“[图数据模型](/v1/ch2#图数据模型)”。
## **散列hash**
将输入转换为看起来像随机数值的函数。相同的输入会转换为相同的数值,不同的输入一般会转换为不同的数值,也可能转换为相同数值(也被称为冲突)。请参阅“[根据键的散列分区](/v1/ch6#根据键的散列分区)”。
## **幂等idempotent**
用于描述一种操作可以安全地重试执行,即执行多次的效果和执行一次的效果相同。请参阅“[幂等性](/v1/ch11#幂等性)”。
## **索引index**
一种数据结构。通过索引,你可以根据特定字段的值,在所有数据记录中进行高效检索。请参阅“[驱动数据库的数据结构](/v1/ch3#驱动数据库的数据结构)”。
## **隔离性isolation**
在事务上下文中,用于描述并发执行事务的互相干扰程度。串行运行具有最强的隔离性,不过其它程度的隔离也通常被使用。请参阅“[隔离性](/v1/ch7#隔离性)”。
## **连接join**
汇集有共同点的记录。在一个记录与另一个记录有关(外键,文档参考,图中的边)的情况下最常用,查询需要获取参考所指向的记录。请参阅“[多对一和多对多的关系](/v1/ch2#多对一和多对多的关系)”和“[Reduce侧连接与分组](/v1/ch10#Reduce侧连接与分组)”。
## **领导者leader**
当数据或服务被复制到多个节点时,领导者是被指定为可以接受数据变更的副本。领导者可以通过某些协议选举产生,也可以由管理员手动选择。领导者也被称为主库。请参阅“[领导者与追随者](/v1/ch5#领导者与追随者)”。
## **线性化linearizable**
表现为系统中只有一份通过原子操作更新的数据副本。请参阅“[线性一致性](/v1/ch9#线性一致性)”。
## **局部性locality**
一种性能优化方式,如果经常在相同的时间请求一些离散数据,把这些数据放到一个位置。请参阅“[查询的数据局部性](/v1/ch2#查询的数据局部性)”。
## **锁lock**
一种保证只有一个线程、节点或事务可以访问的机制,如果其它线程、节点或事务想访问相同元素,则必须等待锁被释放。请参阅“[两阶段锁定](/v1/ch7#两阶段锁定)”和“[领导者和锁](/v1/ch8#领导者和锁)”。
## **日志log**
日志是一个只能以追加方式写入的文件,用于存放数据。预写式日志用于在存储引擎崩溃时恢复数据(请参阅“[让B树更可靠](/v1/ch3#让B树更可靠)”);结构化日志存储引擎使用日志作为它的主要存储格式(请参阅“[SSTables和LSM树](/v1/ch3#SSTables和LSM树)”);复制型日志用于把写入从领导者复制到追随者(请参阅“[领导者与追随者](/v1/ch5#领导者与追随者)”);事件性日志可以表现为数据流(请参阅“[分区日志](/v1/ch11#分区日志)”)。
## **物化materialize**
急切地计算并写出结果,而不是在请求时计算。请参阅“[聚合:数据立方体和物化视图](/v1/ch3#聚合:数据立方体和物化视图)”和“[物化中间状态](/v1/ch10#物化中间状态)”。
## **节点node**
计算机上运行的一些软件的实例,通过网络与其他节点通信以完成某项任务。
## **规范化normalized**
以没有冗余或重复的方式进行结构化。在规范化数据库中,当某些数据发生变化时,你只需要在一个地方进行更改,而不是在许多不同的地方复制很多次。请参阅“[多对一和多对多的关系](/v1/ch2#多对一和多对多的关系)”。
## **OLAPOnline Analytic Processing**
在线分析处理。通过对大量记录进行聚合(例如,计数,总和,平均)来表征的访问模式。请参阅“[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”。
## **OLTPOnline Transaction Processing**
在线事务处理。访问模式的特点是快速查询,读取或写入少量记录,这些记录通常通过键索引。请参阅“[事务处理还是分析?](/v1/ch3#事务处理还是分析?)”。
## **分区partitioning**
将单机上的大型数据集或计算结果拆分为较小部分,并将其分布到多台机器上。也称为分片。见[第六章](/v1/ch6)。
## **百分位点percentile**
通过计算有多少值高于或低于某个阈值来衡量值分布的方法。例如某个时间段的第95个百分位响应时间是时间t则该时间段中95%的请求完成时间小于t5%的请求完成时间要比t长。请参阅“[描述性能](/v1/ch1#描述性能)”。
## **主键primary key**
唯一标识记录的值(通常是数字或字符串)。在许多应用程序中,主键由系统在创建记录时生成(例如,按顺序或随机); 它们通常不由用户设置。另请参阅次级索引。
## **法定人数quorum**
在操作完成之前,需要对操作进行投票的最少节点数量。请参阅“[读写的法定人数](/v1/ch5#读写的法定人数)”。
## **再平衡rebalance**
将数据或服务从一个节点移动到另一个节点以实现负载均衡。请参阅“[分区再平衡](/v1/ch6#分区再平衡)”。
## **复制replication**
在几个节点(副本)上保留相同数据的副本,以便在某些节点无法访问时,数据仍可访问。请参阅[第五章](/v1/ch5)。
## **模式schema**
一些数据结构的描述,包括其字段和数据类型。可以在数据生命周期的不同点检查某些数据是否符合模式(请参阅“[文档模型中的模式灵活性](/v1/ch2#文档模型中的模式灵活性)”),模式可以随时间变化(请参阅[第四章](/v1/ch4))。
## **次级索引secondary index**
与主要数据存储器一起维护的附加数据结构,使你可以高效地搜索与某种条件相匹配的记录。请参阅“[其他索引结构](/v1/ch3#其他索引结构)”和“[分区与次级索引](/v1/ch6#分区与次级索引)”。
## **可串行化serializable**
保证多个并发事务同时执行时,它们的行为与按顺序逐个执行事务相同。请参阅第七章的“[可串行化](/v1/ch7#可串行化)”。
## **无共享shared-nothing**
与共享内存或共享磁盘架构相比独立节点每个节点都有自己的CPU内存和磁盘通过传统网络连接。见[第二部分](/part-ii)的介绍。
## **偏斜skew**
各分区负载不平衡,例如某些分区有大量请求或数据,而其他分区则少得多。也被称为热点。请参阅“[负载偏斜与热点消除](/v1/ch6#负载偏斜与热点消除)”和“[处理偏斜](/v1/ch10#处理偏斜)”。
时间线异常导致事件以不期望的顺序出现。请参阅“[快照隔离和可重复读](/v1/ch7#快照隔离和可重复读)”中的关于读取偏差的讨论,“[写入偏差与幻读](/v1/ch7#写入偏差与幻读)”中的写入偏差以及“[有序事件的时间戳](/v1/ch8#有序事件的时间戳)”中的时钟偏斜。
## **脑裂split brain**
两个节点同时认为自己是领导者的情况,这种情况可能违反系统担保。请参阅“[处理节点宕机](/v1/ch5#处理节点宕机)”和“[真相由多数所定义](/v1/ch8#真相由多数所定义)”。
## **存储过程stored procedure**
一种对事务逻辑进行编码的方式,它可以完全在数据库服务器上执行,事务执行期间无需与客户端通信。请参阅“[真的串行执行](/v1/ch7#真的串行执行)”。
## **流处理stream process**
持续运行的计算。可以持续接收事件流作为输入,并得出一些输出。见[第十一章](/v1/ch11)。
## **同步synchronous**
异步的反义词。
## **记录系统system of record**
一个保存主要权威版本数据的系统,也被称为真相的来源。首先在这里写入数据变更,其他数据集可以从记录系统衍生。请参阅[第三部分](/part-iii)的介绍。
## **超时timeout**
检测故障的最简单方法之一,即在一段时间内观察是否缺乏响应。但是,不可能知道超时是由于远程节点的问题还是网络中的问题造成的。请参阅“[超时与无穷的延迟](/v1/ch8#超时与无穷的延迟)”。
## **全序total order**
一种比较事物的方法(例如时间戳),可以让你总是说出两件事中哪一件更大,哪件更小。总的来说,有些东西是无法比拟的(不能说哪个更大或更小)的顺序称为偏序。请参阅“[因果顺序不是全序的](/v1/ch9#因果顺序不是全序的)”。
## **事务transaction**
为了简化错误处理和并发问题,将几个读写操作分组到一个逻辑单元中。见[第七章](/v1/ch7)。
## **两阶段提交2PC, two-phase commit**
一种确保多个数据库节点全部提交或全部中止事务的算法。请参阅“[原子提交与两阶段提交](/v1/ch9#原子提交与两阶段提交)”。
## **两阶段锁定2PL, two-phase locking**
一种用于实现可串行化隔离的算法,该算法通过事务获取对其读取或写入的所有数据的锁,直到事务结束。请参阅“[两阶段锁定](/v1/ch7#两阶段锁定)”。
## **无边界unbounded**
没有任何已知的上限或大小。反义词是边界bounded

38
content/v1/part-i.md Normal file
View File

@ -0,0 +1,38 @@
---
title: 第一部分:数据系统基础
weight: 100
breadcrumbs: false
---
本书前四章介绍了数据系统底层的基础概念,无论是在单台机器上运行的单点数据系统,还是分布在多台机器上的分布式数据系统都适用。
1. [第一章](/v1/ch1) 将介绍本书使用的术语和方法。**可靠性,可伸缩性和可维护性** ,这些词汇到底意味着什么?如何实现这些目标?
2. [第二章](/v1/ch2) 将对几种不同的 **数据模型和查询语言** 进行比较。从程序员的角度看,这是数据库之间最明显的区别。不同的数据模型适用于不同的应用场景。
3. [第三章](/v1/ch3) 将深入 **存储引擎** 内部,研究数据库如何在磁盘上摆放数据。不同的存储引擎针对不同的负载进行优化,选择合适的存储引擎对系统性能有巨大影响。
4. [第四章](/v1/ch4) 将对几种不同的 **数据编码** 进行比较。特别研究了这些格式在应用需求经常变化、模式需要随时间演变的环境中表现如何。
第二部分将专门讨论在 **分布式数据系统** 中特有的问题。
## 索引
* [第一章:可靠性、可伸缩性和可维护性](/v1/ch1)
* [关于数据系统的思考](/v1/ch1#关于数据系统的思考)
* [可靠性](/v1/ch1#可靠性)
* [可伸缩性](/v1/ch1#可伸缩性)
* [可维护性](/v1/ch1#可维护性)
* [本章小结](/v1/ch1#本章小结)
* [第二章:数据模型与查询语言](/v1/ch2)
* [关系模型与文档模型](/v1/ch2#关系模型与文档模型)
* [数据查询语言](/v1/ch2#数据查询语言)
* [图数据模型](/v1/ch2#图数据模型)
* [本章小结](/v1/ch2#本章小结)
* [第三章:存储与检索](/v1/ch3)
* [驱动数据库的数据结构](/v1/ch3#驱动数据库的数据结构)
* [事务处理还是分析?](/v1/ch3#事务处理还是分析)
* [列式存储](/v1/ch3#列式存储)
* [本章小结](/v1/ch3#本章小结)
* [第四章:编码与演化](/v1/ch4)
* [编码数据的格式](/v1/ch4#编码数据的格式)
* [数据流的类型](/v1/ch4#数据流的类型)
* [本章小结](/v1/ch4#本章小结)

111
content/v1/part-ii.md Normal file
View File

@ -0,0 +1,111 @@
---
title: 第二部分:分布式数据
weight: 200
breadcrumbs: false
---
> 一个成功的技术,现实的优先级必须高于公关,你可以糊弄别人,但糊弄不了自然规律。
>
> —— 罗杰斯委员会报告1986
>
-------
在本书的 [第一部分](/part-i) 中,我们讨论了数据系统的各个方面,但仅限于数据存储在单台机器上的情况。现在我们到了 [第二部分](/part-ii),进入更高的层次,并提出一个问题:如果 **多台机器** 参与数据的存储和检索,会发生什么?
你可能会出于各种各样的原因,希望将数据库分布到多台机器上:
可伸缩性
: 如果你的数据量、读取负载、写入负载超出单台机器的处理能力,可以将负载分散到多台计算机上。
容错 / 高可用性
: 如果你的应用需要在单台机器(或多台机器,网络或整个数据中心)出现故障的情况下仍然能继续工作,则可使用多台机器,以提供冗余。一台故障时,另一台可以接管。
延迟
: 如果在世界各地都有用户,你也许会考虑在全球范围部署多个服务器,从而每个用户可以从地理上最近的数据中心获取服务,避免了等待网络数据包穿越半个世界。
## 伸缩至更高的载荷
如果你需要的只是伸缩至更高的 **载荷load**,最简单的方法就是购买更强大的机器(有时称为 **垂直伸缩**,即 vertical scaling**向上伸缩**,即 scale up。许多处理器内存和磁盘可以在同一个操作系统下相互连接快速的相互连接允许任意处理器访问内存或磁盘的任意部分。在这种 **共享内存架构shared-memory architecture** 中,所有的组件都可以看作一台单独的机器 [^i]。
[^i]: 在大型机中,尽管任意处理器都可以访问内存的任意部分,但总有一些内存区域与一些处理器更接近(称为 **非均匀内存访问nonuniform memory access, NUMA**【1】。为了有效利用这种架构特性需要对处理进行细分以便每个处理器主要访问临近的内存这意味着即使表面上看起来只有一台机器在运行**分区partitioning** 仍然是必要的。
共享内存方法的问题在于,成本增长速度快于线性增长:一台有着双倍处理器数量,双倍内存大小,双倍磁盘容量的机器,通常成本会远远超过原来的两倍。而且可能因为存在瓶颈,并不足以处理双倍的载荷。
共享内存架构可以提供有限的容错能力,高端机器可以使用热插拔的组件(不关机更换磁盘,内存模块,甚至处理器)—— 但它必然囿于单个地理位置的桎梏。
另一种方法是 **共享磁盘架构shared-disk architecture**,它使用多台具有独立处理器和内存的机器,但将数据存储在机器之间共享的磁盘阵列上,这些磁盘通过快速网络连接 [^ii]。这种架构用于某些数据仓库但竞争和锁定的开销限制了共享磁盘方法的可伸缩性【2】。
[^ii]: 网络附属存储Network Attached Storage, NAS或 **存储区网络Storage Area Network, SAN**
### 无共享架构
相比之下,**无共享架构**【3】shared-nothing architecture有时被称为 **水平伸缩**,即 horizontal scaling**向外伸缩**,即 scaling out已经相当普及。在这种架构中运行数据库软件的每台机器 / 虚拟机都称为 **节点node**。每个节点只使用各自的处理器,内存和磁盘。节点之间的任何协调,都是在软件层面使用传统网络实现的。
无共享系统不需要使用特殊的硬件,所以你可以用任意机器 —— 比如性价比最好的机器。你也许可以跨多个地理区域分布数据从而减少用户延迟,或者在损失一整个数据中心的情况下幸免于难。随着云端虚拟机部署的出现,即使是小公司,现在无需 Google 级别的运维,也可以实现异地分布式架构。
在这一部分里,我们将重点放在无共享架构上。它不见得是所有场景的最佳选择,但它是最需要你谨慎从事的架构。如果你的数据分布在多个节点上,你需要意识到这样一个分布式系统中约束和权衡 —— 数据库并不能魔术般地把这些东西隐藏起来。
虽然分布式无共享架构有许多优点,但它通常也会给应用带来额外的复杂度,有时也会限制你可用数据模型的表达力。在某些情况下,一个简单的单线程程序可以比一个拥有超过 100 个 CPU 核的集群表现得更好【4】。另一方面无共享系统可以非常强大。接下来的几章将详细讨论分布式数据会带来的问题。
### 复制 vs 分区
数据分布在多个节点上有两种常见的方式:
复制Replication
: 在几个不同的节点上保存数据的相同副本,可能放在不同的位置。复制提供了冗余:如果一些节点不可用,剩余的节点仍然可以提供数据服务。复制也有助于改善性能。[第五章](/v1/ch5) 将讨论复制。
分区 (Partitioning)
: 将一个大型数据库拆分成较小的子集(称为 **分区**,即 partitions从而不同的分区可以指派给不同的 **节点**nodes亦称 **分片**,即 sharding。[第六章](/v1/ch6) 将讨论分区。
复制和分区是不同的机制,但它们经常同时使用。如 [图 II-1](/v1/ddia_08.png) 所示。
![](/v1/ddia_08.png)
**图 II-1 一个数据库切分为两个分区,每个分区都有两个副本**
理解了这些概念,就可以开始讨论在分布式系统中需要做出的困难抉择。[第七章](/v1/ch7) 将讨论 **事务Transaction**,这对于了解数据系统中可能出现的各种问题,以及我们可以做些什么很有帮助。[第八章](/v1/ch8) 和 [第九章](/v1/ch9) 将讨论分布式系统的根本局限性。
在本书的 [第三部分](/part-iii) 中,将讨论如何将多个(可能是分布式的)数据存储集成为一个更大的系统,以满足复杂的应用需求。但首先,我们来聊聊分布式的数据。
## 索引
* [第五章:复制](/v1/ch5)
* [领导者与追随者](/v1/ch5#领导者与追随者)
* [复制延迟问题](/v1/ch5#复制延迟问题)
* [多主复制](/v1/ch5#多主复制)
* [无主复制](/v1/ch5#无主复制)
* [本章小结](/v1/ch5#本章小结)
* [第六章:分区](/v1/ch6)
* [分区与复制](/v1/ch6#分区与复制)
* [键值数据的分区](/v1/ch6#键值数据的分区)
* [分区与次级索引](/v1/ch6#分区与次级索引)
* [分区再平衡](/v1/ch6#分区再平衡)
* [请求路由](/v1/ch6#请求路由)
* [本章小结](/v1/ch6#本章小结)
* [第七章:事务](/v1/ch7)
* [事务的棘手概念](/v1/ch7#事务的棘手概念)
* [弱隔离级别](/v1/ch7#弱隔离级别)
* [可串行化](/v1/ch7#可串行化)
* [本章小结](/v1/ch7#本章小结)
* [第八章:分布式系统的麻烦](/v1/ch8)
* [故障与部分失效](/v1/ch8#故障与部分失效)
* [不可靠的网络](/v1/ch8#不可靠的网络)
* [不可靠的时钟](/v1/ch8#不可靠的时钟)
* [知识、真相与谎言](/v1/ch8#知识真相与谎言)
* [本章小结](/v1/ch8#本章小结)
* [第九章:一致性与共识](/v1/ch9)
* [一致性保证](/v1/ch9#一致性保证)
* [线性一致性](/v1/ch9#线性一致性)
* [顺序保证](/v1/ch9#顺序保证)
* [分布式事务与共识](/v1/ch9#分布式事务与共识)
* [本章小结](/v1/ch9#本章小结)
## 参考文献
1. Ulrich Drepper: “[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf),” akkadia.org, November 21, 2007.
1. Ben Stopford: “[Shared Nothing vs. Shared Disk Architectures: An Independent View](http://www.benstopford.com/2009/11/24/understanding-the-shared-nothing-architecture/),” benstopford.com, November 24, 2009.
1. Michael Stonebraker: “[The Case for Shared Nothing](http://db.cs.berkeley.edu/papers/hpts85-nothing.pdf),” IEEE Database EngineeringBulletin, volume 9, number 1, pages 49, March 1986.
1. Frank McSherry, Michael Isard, and Derek G. Murray: “[Scalability! But at What COST?](http://www.frankmcsherry.org/assets/COST.pdf),” at 15th USENIX Workshop on Hot Topics in Operating Systems (HotOS),May 2015.

55
content/v1/part-iii.md Normal file
View File

@ -0,0 +1,55 @@
---
title: 第三部分:衍生数据
weight: 300
breadcrumbs: false
---
在本书的 [第一部分](/part-i) 和 [第二部分](/part-ii) 中,我们自底向上地把所有关于分布式数据库的主要考量都过了一遍。从数据在磁盘上的布局,一直到出现故障时分布式系统一致性的局限。但所有的讨论都假定了应用中只用了一种数据库。
现实世界中的数据系统往往更为复杂。大型应用程序经常需要以多种方式访问和处理数据,没有一个数据库可以同时满足所有这些不同的需求。因此应用程序通常组合使用多种组件:数据存储、索引、缓存、分析系统等等,并实现在这些组件中移动数据的机制。
本书的最后一部分,会研究将多个不同数据系统(可能有着不同数据模型,并针对不同的访问模式进行优化)集成为一个协调一致的应用架构时,会遇到的问题。软件供应商经常会忽略这一方面的生态建设,并声称他们的产品能够满足你的所有需求。在现实世界中,集成不同的系统是实际应用中最重要的事情之一。
## 记录系统和衍生数据系统
从高层次上看,存储和处理数据的系统可以分为两大类:
* 记录系统System of record
**记录系统**,也被称为 **真相源source of truth**,持有数据的权威版本。当新的数据进入时(例如,用户输入)首先会记录在这里。每个事实正正好好表示一次(表示通常是 **正规化的**,即 normalized。如果其他系统和 **记录系统** 之间存在任何差异,那么记录系统中的值是正确的(根据定义)。
* 衍生数据系统Derived data systems
**衍生系统** 中的数据,通常是另一个系统中的现有数据以某种方式进行转换或处理的结果。如果丢失衍生数据,可以从原始来源重新创建。典型的例子是 **缓存cache**:如果数据在缓存中,就可以由缓存提供服务;如果缓存不包含所需数据,则降级由底层数据库提供。非规范化的值,索引和物化视图亦属此类。在推荐系统中,预测汇总数据通常衍生自用户日志。
从技术上讲,衍生数据是 **冗余的redundant**,因为它重复了已有的信息。但是衍生数据对于获得良好的只读查询性能通常是至关重要的。它通常是非规范化的。可以从单个源头衍生出多个不同的数据集,使你能从不同的 “视角” 洞察数据。
并不是所有的系统都在其架构中明确区分 **记录系统****衍生数据系统**,但是这是一种有用的区分方式,因为它明确了系统中的数据流:系统的哪一部分具有哪些输入和哪些输出,以及它们如何相互依赖。
大多数数据库,存储引擎和查询语言,本质上既不是记录系统也不是衍生系统。数据库只是一个工具:如何使用它取决于你自己。**记录系统和衍生数据系统之间的区别不在于工具,而在于应用程序中的使用方式。**
通过梳理数据的衍生关系,可以清楚地理解一个令人困惑的系统架构。这将贯穿本书的这一部分。
## 章节概述
我们将从 [第十章](/v1/ch10) 开始,研究例如 MapReduce 这样 **面向批处理batch-oriented** 的数据流系统。对于建设大规模数据系统,我们将看到,它们提供了优秀的工具和思想。[第十一章](/v1/ch11) 将把这些思想应用到 **流式数据data streams** 中,使我们能用更低的延迟完成同样的任务。[第十二章](/v1/ch12) 将对本书进行总结,探讨如何使用这些工具来构建可靠,可伸缩和可维护的应用。
## 索引
* [第十章:批处理](/v1/ch10)
* [使用Unix工具的批处理](/v1/ch10#使用Unix工具的批处理)
* [MapReduce和分布式文件系统](/v1/ch10#MapReduce和分布式文件系统)
* [MapReduce之后](/v1/ch10#MapReduce之后)
* [本章小结](/v1/ch10#本章小结)
* [第十一章:流处理](/v1/ch11)
* [传递事件流](/v1/ch11#传递事件流)
* [数据库与流](/v1/ch11#数据库与流)
* [流处理](/v1/ch11#流处理)
* [本章小结](/v1/ch11#本章小结)
* [第十二章:数据系统的未来](/v1/ch12)
* [数据集成](/v1/ch12#数据集成)
* [分拆数据库](/v1/ch12#分拆数据库)
* [将事情做正确](/v1/ch12#将事情做正确)
* [做正确的事情](/v1/ch12#做正确的事情)
* [本章小结](/v1/ch12#本章小结)

101
content/v1/preface.md Normal file
View File

@ -0,0 +1,101 @@
---
title: 序言
weight: 50
breadcrumbs: false
---
如果近几年从业于软件工程,特别是服务器端和后端系统开发,那么你很有可能已经被大量关于数据存储和处理的时髦词汇轰炸过了: NoSQL大数据Web-Scale分片最终一致性ACIDCAP 定理云服务MapReduce实时
在最近十年中,我们看到了很多有趣的进展,关于数据库,分布式系统,以及在此基础上构建应用程序的方式。这些进展有着各种各样的驱动力:
* 谷歌、雅虎、亚马逊、脸书、领英、微软和推特等互联网公司正在和巨大的流量 / 数据打交道,这迫使他们去创造能有效应对如此规模的新工具。
* 企业需要变得敏捷,需要低成本地检验假设,需要通过缩短开发周期和保持数据模型的灵活性,快速地响应新的市场洞察。
* 免费和开源软件变得非常成功,在许多环境中比商业软件和定制软件更受欢迎。
* 处理器主频几乎没有增长,但是多核处理器已经成为标配,网络也越来越快。这意味着并行化程度只增不减。
* 即使你在一个小团队中工作现在也可以构建分布在多台计算机甚至多个地理区域的系统这要归功于譬如亚马逊网络服务AWS等基础设施即服务IaaS概念的践行者。
* 许多服务都要求高可用,因停电或维护导致的服务不可用,变得越来越难以接受。
**数据密集型应用data-intensive applications** 正在通过使用这些技术进步来推动可能性的边界。一个应用被称为 **数据密集型** 的,如果 **数据是其主要挑战**(数据量,数据复杂度或数据变化速度)—— 与之相对的是 **计算密集型**,即处理器速度是其瓶颈。
帮助数据密集型应用存储和处理数据的工具与技术正迅速地适应这些变化。新型数据库系统“NoSQL”已经备受关注而消息队列缓存搜索索引批处理和流处理框架以及相关技术也非常重要。很多应用组合使用这些工具与技术。
这些生意盎然的时髦词汇体现出人们对新的可能性的热情,这是一件好事。但是作为软件工程师和架构师,如果要开发优秀的应用,我们还需要对各种层出不穷的技术及其利弊权衡有精准的技术理解。为了获得这种洞察,我们需要深挖时髦词汇背后的内容。
幸运的是,在技术迅速变化的背后总是存在一些持续成立的原则,无论你使用了特定工具的哪个版本。如果你理解了这些原则,就可以领会这些工具的适用场景,如何充分利用它们,以及如何避免其中的陷阱。这正是本书的初衷。
本书的目标是帮助你在飞速变化的数据处理和数据存储技术大观园中找到方向。本书并不是某个特定工具的教程,也不是一本充满枯燥理论的教科书。相反,我们将看到一些成功数据系统的样例:许多流行应用每天都要在生产中满足可伸缩性、性能、以及可靠性的要求,而这些技术构成了这些应用的基础。
我们将深入这些系统的内部,理清它们的关键算法,讨论背后的原则和它们必须做出的权衡。在这个过程中,我们将尝试寻找 **思考** 数据系统的有效方式 —— 不仅关于它们 **如何** 工作,还包括它们 **为什么** 以这种方式工作,以及哪些问题是我们需要问的。
阅读本书后,你能很好地决定哪种技术适合哪种用途,并了解如何将工具组合起来,为一个良好应用架构奠定基础。本书并不足以使你从头开始构建自己的数据库存储引擎,不过幸运的是这基本上很少有必要。你将获得对系统底层发生事情的敏锐直觉,这样你就有能力推理它们的行为,做出优秀的设计决策,并追踪任何可能出现的问题。
## 本书的目标读者
如果你开发的应用具有用于存储或处理数据的某种服务器 / 后端系统而且使用网络例如Web 应用、移动应用或连接到互联网的传感器),那么本书就是为你准备的。
本书是为软件工程师,软件架构师,以及喜欢写代码的技术经理准备的。如果你需要对所从事系统的架构做出决策 —— 例如你需要选择解决某个特定问题的工具,并找出如何最好地使用这些工具,那么这本书对你尤有价值。但即使你无法选择你的工具,本书仍将帮助你更好地了解所使用工具的长处和短处。
你应当具有一些开发 Web 应用或网络服务的经验,且应当熟悉关系型数据库和 SQL。任何你了解的非关系型数据库和其他与数据相关工具都会有所帮助但不是必需的。对常见网络协议如 TCP 和 HTTP 的大概理解是有帮助的。编程语言或框架的选择对阅读本书没有任何不同影响。
如果以下任意一条对你为真,你会发现这本书很有价值:
* 你想了解如何使数据系统可伸缩,例如,支持拥有数百万用户的 Web 或移动应用。
* 你需要提高应用程序的可用性(最大限度地减少停机时间),保持稳定运行。
* 你正在寻找使系统在长期运行过程易于维护的方法,即使系统规模增长,需求与技术也发生变化。
* 你对事物的运作方式有着天然的好奇心,并且希望知道一些主流网站和在线服务背后发生的事情。这本书打破了各种数据库和数据处理系统的内幕,探索这些系统设计中的智慧是非常有趣的。
有时在讨论可伸缩的数据系统时,人们会说:“你又不在谷歌或亚马逊,别操心可伸缩性了,直接上关系型数据库”。这个陈述有一定的道理:为了不必要的伸缩性而设计程序,不仅会浪费不必要的精力,并且可能会把你锁死在一个不灵活的设计中。实际上这是一种 “过早优化” 的形式。不过,选择合适的工具确实很重要,而不同的技术各有优缺点。我们将看到,关系数据库虽然很重要,但绝不是数据处理的终章。
## 本书涉及的领域
本书并不会尝试告诉读者如何安装或使用特定的软件包或 API因为已经有大量文档给出了详细的使用说明。相反我们会讨论数据系统的基础 —— 各种原则与利弊权衡,并探讨了不同产品所做出的不同设计决策。
在电子书中包含了在线资源全文的链接。所有链接在出版时都进行了验证,但不幸的是,由于网络的自然规律,链接往往会频繁地破损。如果你遇到链接断开的情况,或者正在阅读本书的打印副本,可以使用搜索引擎查找参考文献。对于学术论文,你可以在 Google 学术中搜索标题,查找可以公开获取的 PDF 文件。或者,你也可以在 https://github.com/ept/ddia-references 中找到所有的参考资料,我们在那儿维护最新的链接。
我们主要关注的是数据系统的 **架构architecture**,以及它们被集成到数据密集型应用中的方式。本书没有足够的空间覆盖部署、运维、安全、管理等领域 —— 这些都是复杂而重要的主题,仅仅在本书中用粗略的注解讨论这些对它们很不公平。每个领域都值得用单独的书去讲。
本书中描述的许多技术都被涵盖在 **大数据Big Data** 这个时髦词的范畴中。然而 “大数据” 这个术语被滥用,缺乏明确定义,以至于在严肃的工程讨论中没有用处。这本书使用歧义更小的术语,如 “单节点” 之于 “分布式系统”,或 “在线 / 交互式系统” 之于 “离线 / 批处理系统”。
本书对 **自由和开源软件FOSS** 有一定偏好,因为阅读、修改和执行源码是了解某事物详细工作原理的好方法。开放的平台也可以降低供应商垄断的风险。然而在适当的情况下,我们也会讨论专利软件(闭源软件,软件即服务 SaaS或一些在文献中描述过但未公开发行的公司内部软件
## 本书纲要
本书分为三部分:
1. 在 [第一部分](/part-i) 中,我们会讨论设计数据密集型应用所赖的基本思想。我们从 [第一章](/v1/ch1) 开始,讨论我们实际要达到的目标:可靠性、可伸缩性和可维护性;我们该如何思考这些概念;以及如何实现它们。在 [第二章](/v1/ch2) 中,我们比较了几种不同的数据模型和查询语言,看看它们如何适用于不同的场景。在 [第三章](/v1/ch3) 中将讨论存储引擎:数据库如何在磁盘上摆放数据,以便能高效地再次找到它。[第四章](/v1/ch4) 转向数据编码(序列化),以及随时间演化的模式。
2. 在 [第二部分](/part-ii) 中,我们从讨论存储在一台机器上的数据转向讨论分布在多台机器上的数据。这对于可伸缩性通常是必需的,但带来了各种独特的挑战。我们首先讨论复制([第五章](/v1/ch5))、分区 / 分片([第六章](/v1/ch6))和事务([第七章](/v1/ch7))。然后我们将探索关于分布式系统问题的更多细节([第八章](/v1/ch8)),以及在分布式系统中实现一致性与共识意味着什么([第九章](/v1/ch9))。
3. 在 [第三部分](/part-iii) 中,我们讨论那些从其他数据集衍生出一些数据集的系统。衍生数据经常出现在异构系统中:当没有单个数据库可以把所有事情都做的很好时,应用需要集成几种不同的数据库、缓存、索引等。在 [第十章](/v1/ch10) 中我们将从一种衍生数据的批处理方法开始,然后在此基础上建立在 [第十一章](/v1/ch11) 中讨论的流处理。最后,在 [第十二章](/v1/ch12) 中,我们将所有内容汇总,讨论在将来构建可靠、可伸缩和可维护的应用程序的方法。
## 参考文献与延伸阅读
本书中讨论的大部分内容已经在其它地方以某种形式出现过了 —— 会议演示文稿、研究论文、博客文章、代码、BUG 跟踪器、邮件列表以及工程习惯中。本书总结了不同来源资料中最重要的想法,并在文本中包含了指向原始文献的链接。如果你想更深入地探索一个领域,那么每章末尾的参考文献都是很好的资源,其中大部分可以免费在线获取。
## OReilly Safari
[Safari](http://oreilly.com/safari) (formerly Safari Books Online) is a membership-based training and reference platform for enterprise, government, educators, and individuals.
Members have access to thousands of books, training videos, Learning Paths, interac tive tutorials, and curated playlists from over 250 publishers, including OReilly Media, Harvard Business Review, Prentice Hall Professional, Addison-Wesley Pro fessional, Microsoft Press, Sams, Que, Peachpit Press, Adobe, Focal Press, Cisco Press, John Wiley & Sons, Syngress, Morgan Kaufmann, IBM Redbooks, Packt, Adobe Press, FT Press, Apress, Manning, New Riders, McGraw-Hill, Jones & Bartlett, and Course Technology, among others.
For more information, please visit http://oreilly.com/safari.
## 致谢
本书融合了学术研究和工业实践的经验,融合并系统化了大量其他人的想法与知识。在计算领域,我们往往会被各种新鲜花样所吸引,但我认为前人完成的工作中,有太多值得我们学习的地方了。本书有 800 多处引用:文章、博客、讲座、文档等,对我来说这些都是宝贵的学习资源。我非常感谢这些材料的作者分享他们的知识。
我也从与人交流中学到了很多东西,很多人花费了宝贵的时间与我讨论想法并耐心解释。特别感谢 Joe Adler, Ross Anderson, Peter Bailis, Márton Balassi, Alastair Beresford, Mark Callaghan, Mat Clayton, Patrick Collison, Sean Cribbs, Shirshanka Das, Niklas Ekström, Stephan Ewen, Alan Fekete, Gyula Fóra, Camille Fournier, Andres Freund, John Garbutt, Seth Gilbert, Tom Haggett, Pat Hel land, Joe Hellerstein, Jakob Homan, Heidi Howard, John Hugg, Julian Hyde, Conrad Irwin, Evan Jones, Flavio Junqueira, Jessica Kerr, Kyle Kingsbury, Jay Kreps, Carl Lerche, Nicolas Liochon, Steve Loughran, Lee Mallabone, Nathan Marz, Caitie McCaffrey, Josie McLellan, Christopher Meiklejohn, Ian Meyers, Neha Narkhede, Neha Narula, Cathy ONeil, Onora ONeill, Ludovic Orban, Zoran Perkov, Julia Powles, Chris Riccomini, Henry Robinson, David Rosenthal, Jennifer Rullmann, Matthew Sackman, Martin Scholl, Amit Sela, Gwen Shapira, Greg Spurrier, Sam Stokes, Ben Stopford, Tom Stuart, Diana Vasile, Rahul Vohra, Pete Warden, 以及 Brett Wooldridge.
更多人通过审阅草稿并提供反馈意见在本书的创作过程中做出了无价的贡献。我要特别感谢 Raul Agepati, Tyler Akidau, Mattias Andersson, Sasha Baranov, Veena Basavaraj, David Beyer, Jim Brikman, Paul Carey, Raul Castro Fernandez, Joseph Chow, Derek Elkins, Sam Elliott, Alexander Gallego, Mark Grover, Stu Halloway, Heidi Howard, Nicola Kleppmann, Stefan Kruppa, Bjorn Madsen, Sander Mak, Stefan Podkowinski, Phil Potter, Hamid Ramazani, Sam Stokes, 以及 Ben Summers。当然对于本书中的任何遗留错误或难以接受的见解我都承担全部责任。
为了帮助这本书落地,并且耐心地处理我缓慢的写作和不寻常的要求,我要对编辑 Marie BeaugureauMike LoukidesAnn Spencer 和 O'Reilly 的所有团队表示感谢。我要感谢 Rachel Head 帮我找到了合适的术语。我要感谢 Alastair BeresfordSusan GoodhueNeha Narkhede 和 Kevin Scott在其他工作事务之外给了我充分地创作时间和自由。
特别感谢 Shabbir Diwan 和 Edie Freedman他们非常用心地为各章配了地图。他们提出了不落俗套的灵感创作了这些地图美丽而引人入胜真是太棒了。
最后我要表达对家人和朋友们的爱,没有他们,我将无法走完这个将近四年的写作历程。你们是最棒的。

91
content/v1/toc.md Normal file
View File

@ -0,0 +1,91 @@
---
title: "目录"
linkTitle: "目录"
weight: 10
breadcrumbs: false
---
![](/title-v1.jpg)
## [序言](/preface)
## [第一部分:数据系统基础](/part-i)
### [第一章:可靠性、可伸缩性和可维护性](/v1/ch1)
* [关于数据系统的思考](/v1/ch1#关于数据系统的思考)
* [可靠性](/v1/ch1#可靠性)
* [可伸缩性](/v1/ch1#可伸缩性)
* [可维护性](/v1/ch1#可维护性)
* [本章小结](/v1/ch1#本章小结)
### [第二章:数据模型与查询语言](/v1/ch2)
* [关系模型与文档模型](/v1/ch2#关系模型与文档模型)
* [数据查询语言](/v1/ch2#数据查询语言)
* [图数据模型](/v1/ch2#图数据模型)
* [本章小结](/v1/ch2#本章小结)
### [第三章:存储与检索](/v1/ch3)
* [驱动数据库的数据结构](/v1/ch3#驱动数据库的数据结构)
* [事务处理还是分析?](/v1/ch3#事务处理还是分析)
* [列式存储](/v1/ch3#列式存储)
* [本章小结](/v1/ch3#本章小结)
### [第四章:编码与演化](/v1/ch4)
* [编码数据的格式](/v1/ch4#编码数据的格式)
* [数据流的类型](/v1/ch4#数据流的类型)
* [本章小结](/v1/ch4#本章小结)
## [第二部分:分布式数据](/part-ii)
### [第五章:复制](/v1/ch5)
* [领导者与追随者](/v1/ch5#领导者与追随者)
* [复制延迟问题](/v1/ch5#复制延迟问题)
* [多主复制](/v1/ch5#多主复制)
* [无主复制](/v1/ch5#无主复制)
* [本章小结](/v1/ch5#本章小结)
### [第六章:分区](/v1/ch6)
* [分区与复制](/v1/ch6#分区与复制)
* [键值数据的分区](/v1/ch6#键值数据的分区)
* [分区与次级索引](/v1/ch6#分区与次级索引)
* [分区再平衡](/v1/ch6#分区再平衡)
* [请求路由](/v1/ch6#请求路由)
* [本章小结](/v1/ch6#本章小结)
### [第七章:事务](/v1/ch7)
* [事务的棘手概念](/v1/ch7#事务的棘手概念)
* [弱隔离级别](/v1/ch7#弱隔离级别)
* [可串行化](/v1/ch7#可串行化)
* [本章小结](/v1/ch7#本章小结)
### [第八章:分布式系统的麻烦](/v1/ch8)
* [故障与部分失效](/v1/ch8#故障与部分失效)
* [不可靠的网络](/v1/ch8#不可靠的网络)
* [不可靠的时钟](/v1/ch8#不可靠的时钟)
* [知识、真相与谎言](/v1/ch8#知识真相与谎言)
* [本章小结](/v1/ch8#本章小结)
### [第九章:一致性与共识](/v1/ch9)
* [一致性保证](/v1/ch9#一致性保证)
* [线性一致性](/v1/ch9#线性一致性)
* [顺序保证](/v1/ch9#顺序保证)
* [分布式事务与共识](/v1/ch9#分布式事务与共识)
* [本章小结](/v1/ch9#本章小结)
## [第三部分:衍生数据](/part-iii)
### [第十章:批处理](/v1/ch10)
* [使用Unix工具的批处理](/v1/ch10#使用Unix工具的批处理)
* [MapReduce和分布式文件系统](/v1/ch10#MapReduce和分布式文件系统)
* [MapReduce之后](/v1/ch10#MapReduce之后)
* [本章小结](/v1/ch10#本章小结)
### [第十一章:流处理](/v1/ch11)
* [传递事件流](/v1/ch11#传递事件流)
* [数据库与流](/v1/ch11#数据库与流)
* [流处理](/v1/ch11#流处理)
* [本章小结](/v1/ch11#本章小结)
### [第十二章:数据系统的未来](/v1/ch12)
* [数据集成](/v1/ch12#数据集成)
* [分拆数据库](/v1/ch12#分拆数据库)
* [将事情做正确](/v1/ch12#将事情做正确)
* [做正确的事情](/v1/ch12#做正确的事情)
* [本章小结](/v1/ch12#本章小结)
### [术语表](/glossary)
### [后记](/colophon)

View File

@ -19,7 +19,7 @@ PostgreSQL 专家,数据库老司机,云计算泥石流。
**校订** [@yingang](https://github.com/yingang) [繁體中文](/tw) **版本维护** by [@afunTW](https://github.com/afunTW) [完整贡献者列表](/contrib)
> [!NOTE]
> **DDIA 第二版** 正在翻译中 ([`v2`](https://github.com/Vonng/ddia/tree/v2) 分支),欢迎加入并提出您的宝贵意见!
> **DDIA 第二版** 正在翻译中 ([`main`](https://github.com/Vonng/ddia/tree/main) 分支),欢迎加入并提出您的宝贵意见![点击此处阅览第一版](/v1)。

View File

@ -2,6 +2,7 @@
title: "第十一章:批处理"
linkTitle: "11. 批处理"
weight: 311
math: true
breadcrumbs: false
---
@ -240,11 +241,11 @@ MapReduce 与 Unix 命令管道的主要区别在于MapReduce 可以在多台
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】但更常见的是它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](/ch2#MapReduce查询)”)。
[图 10-1](/fig/ddia_1101.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/fig/ddia_1101.png) 中的 m1m2 和 m3 标记)。
[图 10-1](/v1/ddia_1001.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/v1/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/v1/ddia_1001.png) 中的 m1m2 和 m3 标记)。
每个输入文件的大小通常是数百兆字节。MapReduce 调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个 Mapper只要该机器有足够的备用 RAM 和 CPU 资源来运行 Mapper 任务【26】。这个原则被称为 **将计算放在数据附近**【27】它节省了通过网络复制输入文件的开销减少网络负载并增加局部性。
![](/fig/ddia_1101.png)
![](/v1/ddia_1001.png)
**图 10-1 具有三个 Mapper 和三个 Reducer 的 MapReduce 任务**
@ -292,7 +293,7 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
[图 10-2](/fig/ddia_1102.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events**点击流数据**,即 clickstream data右侧是用户数据库。你可以将此示例看作是星型模式的一部分请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
![](/fig/ddia_1102.png)
![](/v1/ddia_1002.png)
**图 10-2 用户行为日志与用户档案的连接**
@ -306,7 +307,7 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
#### 排序合并连接
回想一下Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/fig/ddia_1102.png) 的情况下,这个键就是用户 ID一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/fig/ddia_1103.png) 所示。
回想一下Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/v1/ddia_1002.png) 的情况下,这个键就是用户 ID一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/v1/ddia_1003.png) 所示。
![](/fig/ddia_1103.png)
@ -603,8 +604,7 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS因此它们采取了
> 像 Spark、Flink 和 Tez 这样的数据流引擎(请参阅 “[物化中间状态](#物化中间状态)”)通常将算子作为 **有向无环图DAG** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流** 被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。
我们在 [图 3-7](/ch3/#fig_datalog_naive) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](/v1/ddia_0206.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种 “重复至完成” 的想法不能用普通的 MapReduce 来表示,因为它只扫过一趟数据。这种算法因此经常以 **迭代** 的风格实现:

View File

@ -100,17 +100,15 @@ breadcrumbs: false
#### 多个消费者
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/fig/ddia_1201.png) 所示:
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/v1/ddia_1101.png) 所示:
* 负载均衡load balancing
负载均衡load balancing
: 每条消息都被传递给消费者 **之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在 AMQP 中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在 JMS 中则称之为 **共享订阅**,即 shared subscription
每条消息都被传递给消费者 **之一**,所以处理该主题下消息的工作能被多个消费者共享。代理可以为消费者任意分配消息。当处理消息的代价高昂,希望能并行处理消息时,此模式非常有用(在 AMQP 中,可以通过让多个客户端从同一个队列中消费来实现负载均衡,而在 JMS 中则称之为 **共享订阅**,即 shared subscription
扇出fan-out
: 每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
* 扇出fan-out
每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
![](/fig/ddia_1201.png)
![](/v1/ddia_1101.png)
**图 11-1 a负载平衡在消费者间共享消费主题b扇出将每条消息传递给多个消费者。**
@ -122,9 +120,9 @@ breadcrumbs: false
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](/ch9#实践中的分布式事务)” 中所讨论的那样)
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](/fig/ddia_1202.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在[图 11-2](/v1/ddia_1102.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1结果消费者 1 按照 m4m3m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
![](/fig/ddia_1202.png)
![](/v1/ddia_1102.png)
**图 11-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
@ -150,9 +148,9 @@ breadcrumbs: false
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](/ch6) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](/fig/ddia_1203.png) 所示。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 11-3](/fig/ddia_1203.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**offset在 [图 11-3](/v1/ddia_1103.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
![](/fig/ddia_1203.png)
![](/v1/ddia_1103.png)
**图 11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
@ -228,9 +226,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写dual write**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/fig/ddia_1204.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/v1/ddia_1104.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X客户端 1 想要将值设置为 A客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库然后将其写入到搜索索引。因为运气不好这些请求的时序是交错的数据库首先看到来自客户端 1 的写入将值设置为 A然后来自客户端 2 的写入将值设置为 B因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误这两个系统现在也永久地不一致了。
![](/fig/ddia_1204.png)
![](/v1/ddia_1104.png)
**图 11-4 在数据库中 X 首先被设置为 A然后被设置为 B而在搜索索引处写入以相反的顺序到达**
@ -238,7 +236,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/fig/ddia_1204.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch5#多主复制)”)。
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/v1/ddia_1104.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch5#多主复制)”)。
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
@ -250,9 +248,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
最近,人们对 **变更数据捕获change data capture, CDC** 越来越感兴趣这是一种观察写入数据库的所有数据变更并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 11-5](/fig/ddia_1205.png) 所示。
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他衍生数据系统只是变更流的消费者,如 [图 11-5](/v1/ddia_1105.png) 所示。
![](/fig/ddia_1205.png)
![](/v1/ddia_1105.png)
**图 11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
@ -260,7 +258,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
我们可以将日志消费者叫做 **派生数据系统**,正如在 [第三部分](/part-iii) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/fig/ddia_1202.png) 的重新排序问题)。
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/v1/ddia_1102.png) 的重新排序问题)。
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](/ch5#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
@ -280,7 +278,7 @@ LinkedIn 的 Databus【25】Facebook 的 Wormhole【26】和 Yahoo! 的 Sherp
如果你只能保留有限的历史日志,则每次要添加新的派生数据系统时,都需要做一次快照。但 **日志压缩log compaction** 提供了一个很好的备选方案。
我们之前在 “[散列索引](/ch4#the-sstable-file-format)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 4-2](/ch4/#fig_storage_sstable_index) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
我们之前在 “[散列索引](/ch4#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](/v1/ddia_0302.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
在日志结构存储引擎中,具有特殊值 NULL**墓碑**,即 tombstone的更新表示该键被删除并会在日志压缩过程中被移除。但只要键不被覆盖或删除它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入则先前的值将最终将被垃圾回收只有最新的值会保留下来。
@ -304,7 +302,7 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
与变更数据捕获类似,事件溯源涉及到 **将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
* 在变更数据捕获中,应用以 **可变方式mutable way** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/fig/ddia_1204.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
* 在变更数据捕获中,应用以 **可变方式mutable way** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/v1/ddia_1104.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用 Bug请参阅 “[不可变事件的优点](#不可变事件的优点)”)。
@ -350,13 +348,13 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志changelog**,表示了随时间演变的状态。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/fig/ddia_1206.png) 所示【49,50,51】。这个比喻有一些局限性例如状态的二阶导似乎没有意义但这是考虑数据的一个实用出发点。
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/v1/ddia_1106.png) 所示【49,50,51】。这个比喻有一些局限性例如状态的二阶导似乎没有意义但这是考虑数据的一个实用出发点。
$$
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
stream(t) = \frac{d\ state(t)}{dt}
$$
![](/fig/ddia_1206.png)
![](/v1/ddia_1106.png)
**图 11-6 应用当前状态与事件流之间的关系**
@ -378,7 +376,7 @@ $$
#### 从同一事件日志中派生多个视图
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/fig/ddia_1205.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统如搜索服务器来说是很有意义的当系统要从分布式日志中获取输入时亦然请参阅 “[保持系统同步](#保持系统同步)”)。
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/v1/ddia_1105.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统如搜索服务器来说是很有意义的当系统要从分布式日志中获取输入时亦然请参阅 “[保持系统同步](#保持系统同步)”)。
添加从事件日志到数据库的显式转换能够使应用更容易地随时间演进如果你想要引入一个新功能以新的方式表示现有数据则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统你可以简单地关闭它并回收其资源【47,57】。
@ -417,7 +415,7 @@ $$
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/fig/ddia_1205.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/v1/ddia_1105.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2
@ -510,9 +508,9 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
[^ii]: 感谢 Flink 社区的 Kostas Kloudas 提出这个比喻。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/fig/ddia_1207.png))。
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/v1/ddia_1107.png))。
![](/fig/ddia_1207.png)
![](/v1/ddia_1107.png)
**图 11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
@ -549,21 +547,17 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
当你知道如何确定一个事件的时间戳后下一步就是如何定义时间段的窗口。然后窗口就可以用于聚合例如事件计数或计算窗口内值的平均值。有几种窗口很常用【79,83】
* 滚动窗口Tumbling Window
滚动窗口Tumbling Window
: 滚动窗口有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个 1 分钟的滚动窗口,则所有时间戳在 `10:03:00``10:03:59` 之间的事件会被分组到一个窗口中,`10:04:00` 和 `10:04:59` 之间的事件被分组到下一个窗口,依此类推。通过将每个事件时间戳四舍五入至最近的分钟来确定它所属的窗口,可以实现 1 分钟的滚动窗口。
滚动窗口有着固定的长度,每个事件都仅能属于一个窗口。例如,假设你有一个 1 分钟的滚动窗口,则所有时间戳在 `10:03:00``10:03:59` 之间的事件会被分组到一个窗口中,`10:04:00` 和 `10:04:59` 之间的事件被分组到下一个窗口,依此类推。通过将每个事件时间戳四舍五入至最近的分钟来确定它所属的窗口,可以实现 1 分钟的滚动窗口。
跳动窗口Hopping Window
: 跳动窗口也有着固定的长度,但允许窗口重叠以提供一些平滑。例如,一个带有 1 分钟跳跃步长的 5 分钟窗口将包含 `10:03:00``10:07:59` 之间的事件,而下一个窗口将覆盖 `10:04:00``10:08:59` 之间的事件,等等。通过首先计算 1 分钟的滚动窗口tunmbling window然后在几个相邻窗口上进行聚合可以实现这种跳动窗口。
* 跳动窗口Hopping Window
滑动窗口Sliding Window
: 滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个 5 分钟的滑动窗口应当覆盖 `10:03:39``10:08:12` 的事件,因为它们相距不超过 5 分钟(注意滚动窗口与步长 5 分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口。
跳动窗口也有着固定的长度,但允许窗口重叠以提供一些平滑。例如,一个带有 1 分钟跳跃步长的 5 分钟窗口将包含 `10:03:00``10:07:59` 之间的事件,而下一个窗口将覆盖 `10:04:00``10:08:59` 之间的事件,等等。通过首先计算 1 分钟的滚动窗口tunmbling window然后在几个相邻窗口上进行聚合可以实现这种跳动窗口。
* 滑动窗口Sliding Window
滑动窗口包含了彼此间距在特定时长内的所有事件。例如,一个 5 分钟的滑动窗口应当覆盖 `10:03:39``10:08:12` 的事件,因为它们相距不超过 5 分钟(注意滚动窗口与步长 5 分钟的跳动窗口可能不会把这两个事件分组到同一个窗口中,因为它们使用固定的边界)。通过维护一个按时间排序的事件缓冲区,并不断从窗口中移除过期的旧事件,可以实现滑动窗口。
* 会话窗口Session window
与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](/ch11#分组)”)。
会话窗口Session window
: 与其他窗口类型不同,会话窗口没有固定的持续时间,而定义为:将同一用户出现时间相近的所有事件分组在一起,而当用户一段时间没有活动时(例如,如果 30 分钟内没有事件)窗口结束。会话切分是网站分析的常见需求(请参阅 “[分组](/v1/ch10#分组)”)。
### 流连接
@ -583,11 +577,11 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 流表连接(流扩充)
在 “[示例:用户活动事件分析](/ch11#示例:用户活动事件分析)”([图 10-2](/fig/ddia_1102.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
在 “[示例:用户活动事件分析](/v1/ch10#示例:用户活动事件分析)”([图 10-2](/v1/ddia_1002.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充enriching** 活动事件。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/ch11#示例:用户活动事件分析)” 一节中讨论的此类远程查询可能会很慢并且有可能导致数据库过载【75】。
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/v1/ch10#示例:用户活动事件分析)” 一节中讨论的此类远程查询可能会很慢并且有可能导致数据库过载【75】。
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](/ch11#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
另一种方法是将数据库副本加载到流处理器中,以便在本地进行查询而无需网络往返。这种技术与我们在 “[Map 侧连接](/v1/ch10#Map侧连接)” 中讨论的散列连接非常相似:如果数据库的本地副本足够小,则可以是内存中的散列表,比较大的话也可以是本地磁盘上的索引。
与批处理作业的区别在于,批处理作业使用数据库的时间点快照作为输入,而流处理器是长时间运行的,且数据库的内容可能随时间而改变,所以流处理器数据库的本地副本需要保持更新。这个问题可以通过变更数据捕获来解决:流处理器可以订阅用户档案数据库的更新日志,如同活动事件流一样。当增添或修改档案时,流处理器会更新其本地副本。因此,我们有了两个流之间的连接:活动事件和档案更新。
@ -595,7 +589,7 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
#### 表表连接(维护物化视图)
我们在 “[描述负载](/ch1#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
我们在 “[描述负载](/v1/ch1#描述负载)” 中讨论的推特时间线例子时说过,当用户想要查看他们的主页时间线时,迭代用户所关注人群的推文并合并它们是一个开销巨大的操作。
相反,我们需要一个时间线缓存:一种每个用户的 “收件箱”,在发送推文的时候写入这些信息,因而读取时间线时只需要简单地查询即可。物化与维护这个缓存需要处理以下事件:
@ -618,7 +612,7 @@ GROUP BY follows.follower_id
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新 [^iii]。
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/fig/ddia_1206.png) 所示而把一个连接看作是两个表的乘法u·v那么会发生一些有趣的事情物化连接的变化流遵循乘积法则(u·v)'= u'v + uv'。换句话说任何推文的变化量都与当前的关注联系在一起任何关注的变化量都与当前的推文相连接【49,50】。
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/v1/ddia_1106.png) 所示而把一个连接看作是两个表的乘法u·v那么会发生一些有趣的事情物化连接的变化流遵循乘积法则(u·v)'= u'v + uv'。换句话说任何推文的变化量都与当前的关注联系在一起任何关注的变化量都与当前的推文相连接【49,50】。
#### 连接的时间依赖性
@ -691,37 +685,32 @@ Storm 的 Trident 基于类似的想法来处理状态【78】。依赖幂等性
我们花了一些时间比较两种消息代理:
* AMQP/JMS 风格的消息代理
AMQP/JMS 风格的消息代理
: 代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC另请参阅 “[消息传递中的数据流](/v1/ch4#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC另请参阅 “[消息传递中的数据流](/ch4#消息传递中的数据流)”),例如在任务队列中,消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
基于日志的消息代理
: 代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
* 基于日志的消息代理
代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
基于日志的方法与数据库中的复制日志(请参阅 [第五章](/ch5))和日志结构存储引擎(请参阅 [第三章](/ch3))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
基于日志的方法与数据库中的复制日志(请参阅 [第五章](/v1/ch5))和日志结构存储引擎(请参阅 [第三章](/v1/ch3))有相似之处。我们看到,这种方法对于消费输入流,并产生衍生状态或衍生输出数据流的系统而言特别适用。
就流的来源而言,我们讨论了几种可能性:用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流。我们发现将数据库写入视作流也是很有用的:我们可以捕获变更日志 —— 即对数据库所做的所有变更的历史记录 —— 隐式地通过变更数据捕获,或显式地通过事件溯源。日志压缩允许流也能保有数据库内容的完整副本。
将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至派生系统,你能使诸如搜索索引、缓存以及分析系统这类派生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
将数据库表示为流为系统集成带来了很多强大机遇。通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证生数据系统处于最新状态(物化视图)。
像流一样维护状态以及消息重播的基础设施,是在各种流处理框架中实现流连接和容错的基础。我们讨论了流处理的几种目的,包括搜索事件模式(复杂事件处理),计算分窗聚合(流分析),以及保证生数据系统处于最新状态(物化视图)。
然后我们讨论了在流处理中对时间进行推理的困难,包括处理时间与事件时间戳之间的区别,以及当你认为窗口已经完事之后,如何处理到达的掉队事件的问题。
我们区分了流处理中可能出现的三种连接类型:
* 流流连接
流流连接
: 两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户 30 分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(**自连接**,即 self-join
两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户 30 分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(**自连接**,即 self-join
流表连接
: 一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。
* 流表连接
一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。
* 表表连接
两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。
表表连接
: 两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。
最后,我们讨论了在流处理中实现容错和恰好一次语义的技术。与批处理一样,我们需要放弃任何失败任务的部分输出。然而由于流处理长时间运行并持续产生输出,所以不能简单地丢弃所有的输出。相反,可以使用更细粒度的恢复机制,基于微批次、存档点、事务或幂等写入。

View File

@ -325,9 +325,9 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
### 观察派生数据状态
在抽象层面,上一节讨论的数据流系统提供了创建派生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径write path**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个派生数据集都会被更新,以适配写入的数据。[图 12-1](/fig/ddia_1301.png) 显示了一个更新搜索索引的例子。
在抽象层面,上一节讨论的数据流系统提供了创建派生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径write path**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个派生数据集都会被更新,以适配写入的数据。[图 12-1](/v1/ddia_1201.png) 显示了一个更新搜索索引的例子。
![](/fig/ddia_1301.png)
![](/v1/ddia_1201.png)
**图 12-1 在搜索索引中,写(文档更新)遇上读(查询)**
@ -335,7 +335,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
如 [图 12-1](/fig/ddia_1301.png) 所示,派生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
如 [图 12-1](/v1/ddia_1201.png) 所示所示,派生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
#### 物化视图和缓存

View File

@ -1,6 +1,7 @@
---
title: "2. 定义非功能性需求"
weight: 102
math: true
breadcrumbs: false
---

View File

@ -1,6 +1,7 @@
---
title: "5. 编码与演化"
weight: 105
math: true
breadcrumbs: false
---

View File

@ -1,6 +1,7 @@
---
title: "8. 事务"
weight: 208
math: true
breadcrumbs: false
---

View File

@ -72,7 +72,7 @@ breadcrumbs: false
复制和分区是不同的机制,但它们经常同时使用。如 [图 II-1](#fig_replication_partitioning) 所示。
{{< figure src="/fig/ddia_08.png" id="fig_replication_partitioning" caption="图 II-1 一个数据库切分为两个分区,每个分区都有两个副本" class="w-full my-4" >}}
{{< figure src="/v1/ddia_08.png" id="fig_replication_partitioning" caption="图 II-1 一个数据库切分为两个分区,每个分区都有两个副本" class="w-full my-4" >}}
理解了这些概念,就可以开始讨论在分布式系统中需要做出的困难抉择。[第八章](/ch8) 将讨论 **事务Transaction**,这对于了解数据系统中可能出现的各种问题,以及我们可以做些什么很有帮助。

View File

@ -35,6 +35,12 @@ languages:
contentDir: content/tw
weight: 3
title: 設計資料密集型應用(第二版)
v1:
languageName: 中文第一版
languageCode: v1
contentDir: content/v1
weight: 1
title: 设计数据密集型应用(第一版)
#en:
# languageName: English
# languageCode: en
@ -85,6 +91,10 @@ menu:
name: "繁體中文 ↗"
url: "/tw"
weight: 4
- identifier: chinese-1st-edition
name: "中文初版 ↗"
url: "/v1"
weight: 5
#- identifier: english
# name: "English ↗"
# url: "/en"

BIN
static/title-v1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
static/v1/ddia_0101.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/v1/ddia_0102.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
static/v1/ddia_0103.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
static/v1/ddia_0104.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
static/v1/ddia_0105.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
static/v1/ddia_0201.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
static/v1/ddia_0202.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
static/v1/ddia_0203.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
static/v1/ddia_0204.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/v1/ddia_0205.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/v1/ddia_0206.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/v1/ddia_0301.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
static/v1/ddia_0302.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
static/v1/ddia_0303.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/v1/ddia_0304.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
static/v1/ddia_0305.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/v1/ddia_0306.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
static/v1/ddia_0307.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/v1/ddia_0308.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/v1/ddia_0309.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
static/v1/ddia_0310.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
static/v1/ddia_0311.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
static/v1/ddia_0312.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/v1/ddia_0401.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
static/v1/ddia_0402.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
static/v1/ddia_0403.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
static/v1/ddia_0404.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
static/v1/ddia_0405.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
static/v1/ddia_0406.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
static/v1/ddia_0407.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
static/v1/ddia_0501.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
static/v1/ddia_0502.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
static/v1/ddia_0503.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
static/v1/ddia_0504.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/v1/ddia_0505.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
static/v1/ddia_0506.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
static/v1/ddia_0507.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/v1/ddia_0508.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/v1/ddia_0509.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
static/v1/ddia_0510.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
static/v1/ddia_0511.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
static/v1/ddia_0512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
static/v1/ddia_0513.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/v1/ddia_0514.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/v1/ddia_0601.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
static/v1/ddia_0602.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
static/v1/ddia_0603.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
static/v1/ddia_0604.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/v1/ddia_0605.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
static/v1/ddia_0606.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
static/v1/ddia_0607.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
static/v1/ddia_0608.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
static/v1/ddia_0701.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/v1/ddia_0702.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
static/v1/ddia_0703.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/v1/ddia_0704.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/v1/ddia_0705.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
static/v1/ddia_0706.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
static/v1/ddia_0707.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Some files were not shown because too many files have changed in this diff Show More