replace with the new images
@ -687,9 +687,9 @@ Lamport 時間戳擅長捕獲事物發生的順序,但它們有一些限制:
|
||||
|
||||
像 ZooKeeper 和 etcd 這樣的協調服務也是建立在共識演算法之上的。它們提供鎖、租約、故障檢測和變更通知功能,這些功能對於管理分散式應用程式的狀態很有用。如果你發現自己想要做那些可以歸約為共識的事情之一,並且你希望它是容錯的,建議使用協調服務。它不會保證你做對,但它可能會有所幫助。
|
||||
|
||||
共識演算法是複雜而微妙的,但它們得到了自 1980 年代以來發展起來的豐富理論體系的支援。這個理論使得構建能夠容忍我們在[第 9 章](/zh/ch9#ch_distributed)中討論的所有故障的系統成為可能,同時仍然確保你的資料不會損壞。這是一個了不起的成就,本章末尾的參考文獻展示了這項工作的一些亮點。
|
||||
共識演算法是複雜而微妙的,但它們得到了自 1980 年代以來發展起來的豐富理論體系的支援。這個理論使得構建能夠容忍我們在[第 9 章](/tw/ch9#ch_distributed)中討論的所有故障的系統成為可能,同時仍然確保你的資料不會損壞。這是一個了不起的成就,本章末尾的參考文獻展示了這項工作的一些亮點。
|
||||
|
||||
然而,共識並不總是正確的工具:在某些系統中,不需要它提供的強一致性屬性,使用較弱的一致性以獲得更高的可用性和更好的效能會更好。在這些情況下,通常使用無領導者或多領導者複製,這是我們之前在[第 6 章](/zh/ch6#ch_replication)中討論過的。我們在本章中討論的邏輯時鐘在那種情況下是有幫助的。
|
||||
然而,共識並不總是正確的工具:在某些系統中,不需要它提供的強一致性屬性,使用較弱的一致性以獲得更高的可用性和更好的效能會更好。在這些情況下,通常使用無領導者或多領導者複製,這是我們之前在[第 6 章](/tw/ch6#ch_replication)中討論過的。我們在本章中討論的邏輯時鐘在那種情況下是有幫助的。
|
||||
|
||||
### 參考文獻
|
||||
|
||||
|
@ -9,7 +9,7 @@ breadcrumbs: false
|
||||
當前頁面來自本書第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 帶有太強個人色彩的系統無法成功。當最初的設計完成並且相對穩定時,不同的人們以自己的方式進行測試,真正的考驗才開始。
|
||||
>
|
||||
@ -21,17 +21,14 @@ breadcrumbs: false
|
||||
|
||||
Web 和越來越多的基於 HTTP/REST 的 API 使互動的請求 / 響應風格變得如此普遍,以至於很容易將其視為理所當然。但我們應該記住,這不是構建系統的唯一方式,其他方法也有其優點。我們來看看三種不同型別的系統:
|
||||
|
||||
* 服務(線上系統)
|
||||
服務(線上系統)
|
||||
: 服務等待客戶的請求或指令到達。每收到一個,服務會試圖儘快處理它,併發回一個響應。響應時間通常是服務效能的主要衡量指標,可用性通常非常重要(如果客戶端無法訪問服務,使用者可能會收到錯誤訊息)。
|
||||
|
||||
服務等待客戶的請求或指令到達。每收到一個,服務會試圖儘快處理它,併發回一個響應。響應時間通常是服務效能的主要衡量指標,可用性通常非常重要(如果客戶端無法訪問服務,使用者可能會收到錯誤訊息)。
|
||||
批處理系統(離線系統)
|
||||
: 一個批處理系統有大量的輸入資料,跑一個 **作業(job)** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。
|
||||
|
||||
* 批處理系統(離線系統)
|
||||
|
||||
一個批處理系統有大量的輸入資料,跑一個 **作業(job)** 來處理它,並生成一些輸出資料,這往往需要一段時間(從幾分鐘到幾天),所以通常不會有使用者等待作業完成。相反,批次作業通常會定期執行(例如,每天一次)。批處理作業的主要效能衡量標準通常是吞吐量(處理特定大小的輸入所需的時間)。本章中討論的就是批處理。
|
||||
|
||||
* 流處理系統(準即時系統)
|
||||
|
||||
流處理介於線上和離線(批處理)之間,所以有時候被稱為 **準即時(near-real-time)** 或 **準線上(nearline)** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在 [第十二章](/tw/ch12) 討論它。
|
||||
流處理系統(準即時系統)
|
||||
: 流處理介於線上和離線(批處理)之間,所以有時候被稱為 **準即時(near-real-time)** 或 **準線上(nearline)** 處理。像批處理系統一樣,流處理消費輸入併產生輸出(並不需要響應請求)。但是,流式作業在事件發生後不久就會對事件進行操作,而批處理作業則需等待固定的一組輸入資料。這種差異使流處理系統比起批處理系統具有更低的延遲。由於流處理基於批處理,我們將在 [第十二章](/tw/ch12) 討論它。
|
||||
|
||||
正如我們將在本章中看到的那樣,批處理是構建可靠、可伸縮和可維護應用程式的重要組成部分。例如,2004 年釋出的批處理演算法 Map-Reduce(可能被過分熱情地)被稱為 “造就 Google 大規模可伸縮性的演算法”【2】。隨後在各種開源資料系統中得到應用,包括 Hadoop、CouchDB 和 MongoDB。
|
||||
|
||||
@ -54,7 +51,7 @@ 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"
|
||||
```
|
||||
@ -84,7 +81,7 @@ cat /var/log/nginx/access.log | #1
|
||||
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
|
||||
@ -229,13 +226,11 @@ MapReduce 是一個程式設計框架,你可以使用它編寫程式碼來處
|
||||
|
||||
要建立 MapReduce 作業,你需要實現兩個回撥函式,Mapper 和 Reducer,其行為如下(請參閱 “[MapReduce 查詢](/tw/ch2#MapReduce查詢)”):
|
||||
|
||||
* Mapper
|
||||
Mapper
|
||||
: Mapper 會在每條輸入記錄上呼叫一次,其工作是從輸入記錄中提取鍵值。對於每個輸入,它可以生成任意數量的鍵值對(包括 None)。它不會保留從一個輸入記錄到下一個記錄的任何狀態,因此每個記錄都是獨立處理的。
|
||||
|
||||
Mapper 會在每條輸入記錄上呼叫一次,其工作是從輸入記錄中提取鍵值。對於每個輸入,它可以生成任意數量的鍵值對(包括 None)。它不會保留從一個輸入記錄到下一個記錄的任何狀態,因此每個記錄都是獨立處理的。
|
||||
|
||||
* Reducer
|
||||
|
||||
MapReduce 框架拉取由 Mapper 生成的鍵值對,收集屬於同一個鍵的所有值,並在這組值上迭代呼叫 Reducer。Reducer 可以產生輸出記錄(例如相同 URL 的出現次數)。
|
||||
Reducer
|
||||
: MapReduce 框架拉取由 Mapper 生成的鍵值對,收集屬於同一個鍵的所有值,並在這組值上迭代呼叫 Reducer。Reducer 可以產生輸出記錄(例如相同 URL 的出現次數)。
|
||||
|
||||
在 Web 伺服器日誌的例子中,我們在第 5 步中有第二個 `sort` 命令,它按請求數對 URL 進行排序。在 MapReduce 中,如果你需要第二個排序階段,則可以透過編寫第二個 MapReduce 作業並將第一個作業的輸出用作第二個作業的輸入來實現它。這樣看來,Mapper 的作用是將資料放入一個適合排序的表單中,並且 Reducer 的作用是處理已排序的資料。
|
||||
|
||||
@ -245,11 +240,11 @@ MapReduce 與 Unix 命令管道的主要區別在於,MapReduce 可以在多臺
|
||||
|
||||
在分散式計算中可以使用標準的 Unix 工具作為 Mapper 和 Reducer【25】,但更常見的是,它們被實現為傳統程式語言的函式。在 Hadoop MapReduce 中,Mapper 和 Reducer 都是實現特定介面的 Java 類。在 MongoDB 和 CouchDB 中,Mapper 和 Reducer 都是 JavaScript 函式(請參閱 “[MapReduce 查詢](/tw/ch2#MapReduce查詢)”)。
|
||||
|
||||
[圖 10-1](/img/fig10-1.png) 顯示了 Hadoop MapReduce 作業中的資料流。其並行化基於分割槽(請參閱 [第六章](/tw/ch6)):作業的輸入通常是 HDFS 中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理 map 任務([圖 10-1](/img/fig10-1.png) 中的 m1,m2 和 m3 標記)。
|
||||
[圖 10-1](/fig/ddia_1101.png) 顯示了 Hadoop MapReduce 作業中的資料流。其並行化基於分割槽(請參閱 [第六章](/tw/ch6)):作業的輸入通常是 HDFS 中的一個目錄,輸入目錄中的每個檔案或檔案塊都被認為是一個單獨的分割槽,可以單獨處理 map 任務([圖 10-1](/fig/ddia_1101.png) 中的 m1,m2 和 m3 標記)。
|
||||
|
||||
每個輸入檔案的大小通常是數百兆位元組。MapReduce 排程器(圖中未顯示)試圖在其中一臺儲存輸入檔案副本的機器上執行每個 Mapper,只要該機器有足夠的備用 RAM 和 CPU 資源來執行 Mapper 任務【26】。這個原則被稱為 **將計算放在資料附近**【27】:它節省了透過網路複製輸入檔案的開銷,減少網路負載並增加區域性。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 10-1 具有三個 Mapper 和三個 Reducer 的 MapReduce 任務**
|
||||
|
||||
@ -295,9 +290,9 @@ Hadoop 的各種高階工具(如 Pig 【30】、Hive 【31】、Cascading 【3
|
||||
|
||||
#### 示例:使用者活動事件分析
|
||||
|
||||
[圖 10-2](/img/fig10-2.png) 給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為 **活動事件**,即 activity events,或 **點選流資料**,即 clickstream data),右側是使用者資料庫。你可以將此示例看作是星型模式的一部分(請參閱 “[星型和雪花型:分析的模式](/tw/ch3#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。
|
||||
[圖 10-2](/fig/ddia_1102.png) 給出了一個批處理作業中連線的典型例子。左側是事件日誌,描述登入使用者在網站上做的事情(稱為 **活動事件**,即 activity events,或 **點選流資料**,即 clickstream data),右側是使用者資料庫。你可以將此示例看作是星型模式的一部分(請參閱 “[星型和雪花型:分析的模式](/tw/ch3#星型和雪花型:分析的模式)”):事件日誌是事實表,使用者資料庫是其中的一個維度。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 10-2 使用者行為日誌與使用者檔案的連線**
|
||||
|
||||
@ -311,9 +306,9 @@ Hadoop 的各種高階工具(如 Pig 【30】、Hive 【31】、Cascading 【3
|
||||
|
||||
#### 排序合併連線
|
||||
|
||||
回想一下,Mapper 的目的是從每個輸入記錄中提取一對鍵值。在 [圖 10-2](/img/fig10-2.png) 的情況下,這個鍵就是使用者 ID:一組 Mapper 會掃過活動事件(提取使用者 ID 作為鍵,活動事件作為值),而另一組 Mapper 將會掃過使用者資料庫(提取使用者 ID 作為鍵,使用者的出生日期作為值)。這個過程如 [圖 10-3](/img/fig10-3.png) 所示。
|
||||
回想一下,Mapper 的目的是從每個輸入記錄中提取一對鍵值。在 [圖 10-2](/fig/ddia_1102.png) 的情況下,這個鍵就是使用者 ID:一組 Mapper 會掃過活動事件(提取使用者 ID 作為鍵,活動事件作為值),而另一組 Mapper 將會掃過使用者資料庫(提取使用者 ID 作為鍵,使用者的出生日期作為值)。這個過程如 [圖 10-3](/fig/ddia_1103.png) 所示。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 10-3 在使用者 ID 上進行的 Reduce 端連線。如果輸入資料集分割槽為多個檔案,則每個分割槽都會被多個 Mapper 並行處理**
|
||||
|
||||
@ -372,11 +367,11 @@ Reduce 側方法的優點是不需要對輸入資料做任何假設:無論其
|
||||
|
||||
適用於執行 Map 端連線的最簡單場景是大資料集與小資料集連線的情況。要點在於小資料集需要足夠小,以便可以將其全部載入到每個 Mapper 的記憶體中。
|
||||
|
||||
例如,假設在 [圖 10-2](/img/fig10-2.png) 的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當 Mapper 啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的散列表中。完成此操作後,Mapper 可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者 ID [^vi]。
|
||||
例如,假設在 [圖 10-2](/fig/ddia_1102.png) 的情況下,使用者資料庫小到足以放進記憶體中。在這種情況下,當 Mapper 啟動時,它可以首先將使用者資料庫從分散式檔案系統讀取到記憶體中的散列表中。完成此操作後,Mapper 可以掃描使用者活動事件,並簡單地在散列表中查詢每個事件的使用者 ID [^vi]。
|
||||
|
||||
[^vi]: 這個例子假定散列表中的每個鍵只有一個條目,這對使用者資料庫(使用者 ID 唯一標識一個使用者)可能是正確的。通常,雜湊表可能需要包含具有相同鍵的多個條目,而連線運算子將對每個鍵輸出所有的匹配。
|
||||
|
||||
參與連線的較大輸入的每個檔案塊各有一個 Mapper(在 [圖 10-2](/img/fig10-2.png) 的例子中活動事件是較大的輸入)。每個 Mapper 都會將較小輸入整個載入到記憶體中。
|
||||
參與連線的較大輸入的每個檔案塊各有一個 Mapper(在 [圖 10-2](/fig/ddia_1102.png) 的例子中活動事件是較大的輸入)。每個 Mapper 都會將較小輸入整個載入到記憶體中。
|
||||
|
||||
這種簡單有效的演算法被稱為 **廣播雜湊連線(broadcast hash join)**:**廣播** 一詞反映了這樣一個事實,每個連線較大輸入端分割槽的 Mapper 都會將較小輸入端資料集整個讀入記憶體中(所以較小輸入實際上 “廣播” 到較大資料的所有分割槽上),**雜湊** 一詞反映了它使用一個散列表。Pig(名為 “**複製連結(replicated join)**”),Hive(“**MapJoin**”),Cascading 和 Crunch 支援這種連線。它也被諸如 Impala 的資料倉庫查詢引擎使用【41】。
|
||||
|
||||
@ -384,7 +379,7 @@ Reduce 側方法的優點是不需要對輸入資料做任何假設:無論其
|
||||
|
||||
#### 分割槽雜湊連線
|
||||
|
||||
如果 Map 側連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在 [圖 10-2](/img/fig10-2.png) 的情況中,你可以根據使用者 ID 的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有 10 個分割槽)。例如,Mapper3 首先將所有具有以 3 結尾的 ID 的使用者載入到散列表中,然後掃描 ID 為 3 的每個使用者的所有活動事件。
|
||||
如果 Map 側連線的輸入以相同的方式進行分割槽,則雜湊連線方法可以獨立應用於每個分割槽。在 [圖 10-2](/fig/ddia_1102.png) 的情況中,你可以根據使用者 ID 的最後一位十進位制數字來對活動事件和使用者資料庫進行分割槽(因此連線兩側各有 10 個分割槽)。例如,Mapper3 首先將所有具有以 3 結尾的 ID 的使用者載入到散列表中,然後掃描 ID 為 3 的每個使用者的所有活動事件。
|
||||
|
||||
如果分割槽正確無誤,可以確定的是,所有你可能需要連線的記錄都落在同一個編號的分割槽中。因此每個 Mapper 只需要從輸入兩端各讀取一個分割槽就足夠了。好處是每個 Mapper 都可以在記憶體散列表中少放點資料。
|
||||
|
||||
@ -608,7 +603,8 @@ Spark、Flink 和 Tez 避免將中間狀態寫入 HDFS,因此它們採取了
|
||||
|
||||
> 像 Spark、Flink 和 Tez 這樣的資料流引擎(請參閱 “[物化中間狀態](#物化中間狀態)”)通常將運算元作為 **有向無環圖(DAG)** 的一部分安排在作業中。這與圖處理不一樣:在資料流引擎中,**從一個運算元到另一個運算元的資料流** 被構造成一個圖,而資料本身通常由關係型元組構成。在圖處理中,資料本身具有圖的形式。又一個不幸的命名混亂!
|
||||
|
||||
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。我們在 [圖 2-6](/img/fig2-6.png) 中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為 **傳遞閉包**,即 transitive closure)。
|
||||
許多圖演算法是透過一次遍歷一條邊來表示的,將一個頂點與近鄰的頂點連線起來,以傳播一些資訊,並不斷重複,直到滿足一些條件為止 —— 例如,直到沒有更多的邊要跟進,或直到一些指標收斂。
|
||||
我們在 [圖 3-7](/ch3/#fig_datalog_naive) 中看到一個例子,它透過重複跟進標明地點歸屬關係的邊,生成了資料庫中北美包含的所有地點列表(這種演算法被稱為 **傳遞閉包**,即 transitive closure)。
|
||||
|
||||
可以在分散式檔案系統中儲存圖(包含頂點和邊的列表的檔案),但是這種 “重複至完成” 的想法不能用普通的 MapReduce 來表示,因為它只掃過一趟資料。這種演算法因此經常以 **迭代** 的風格實現:
|
||||
|
||||
@ -692,30 +688,24 @@ Spark、Flink 和 Tez 避免將中間狀態寫入 HDFS,因此它們採取了
|
||||
|
||||
分散式批處理框架需要解決的兩個主要問題是:
|
||||
|
||||
* 分割槽
|
||||
|
||||
在 MapReduce 中,Mapper 根據輸入檔案塊進行分割槽。Mapper 的輸出被重新分割槽、排序併合併到可配置數量的 Reducer 分割槽中。這一過程的目的是把所有的 **相關** 資料(例如帶有相同鍵的所有記錄)都放在同一個地方。
|
||||
|
||||
分割槽
|
||||
: 在 MapReduce 中,Mapper 根據輸入檔案塊進行分割槽。Mapper 的輸出被重新分割槽、排序併合併到可配置數量的 Reducer 分割槽中。這一過程的目的是把所有的 **相關** 資料(例如帶有相同鍵的所有記錄)都放在同一個地方。
|
||||
後 MapReduce 時代的資料流引擎若非必要會盡量避免排序,但它們也採取了大致類似的分割槽方法。
|
||||
|
||||
* 容錯
|
||||
|
||||
MapReduce 經常寫入磁碟,這使得從單個失敗的任務恢復很輕鬆,無需重新啟動整個作業,但在無故障的情況下減慢了執行速度。資料流引擎更多地將中間狀態儲存在記憶體中,更少地物化中間狀態,這意味著如果節點發生故障,則需要重算更多的資料。確定性運算元減少了需要重算的資料量。
|
||||
容錯
|
||||
: MapReduce 經常寫入磁碟,這使得從單個失敗的任務恢復很輕鬆,無需重新啟動整個作業,但在無故障的情況下減慢了執行速度。資料流引擎更多地將中間狀態儲存在記憶體中,更少地物化中間狀態,這意味著如果節點發生故障,則需要重算更多的資料。確定性運算元減少了需要重算的資料量。
|
||||
|
||||
|
||||
我們討論了幾種 MapReduce 的連線演算法,其中大多數也在 MPP 資料庫和資料流引擎內部使用。它們也很好地演示了分割槽演算法是如何工作的:
|
||||
|
||||
* 排序合併連線
|
||||
排序合併連線
|
||||
: 每個參與連線的輸入都透過一個提取連線鍵的 Mapper。透過分割槽、排序和合並,具有相同鍵的所有記錄最終都會進入相同的 Reducer 呼叫。這個函式能輸出連線好的記錄。
|
||||
|
||||
每個參與連線的輸入都透過一個提取連線鍵的 Mapper。透過分割槽、排序和合並,具有相同鍵的所有記錄最終都會進入相同的 Reducer 呼叫。這個函式能輸出連線好的記錄。
|
||||
廣播雜湊連線
|
||||
: 兩個連線輸入之一很小,所以它並沒有分割槽,而且能被完全載入進一個雜湊表中。因此,你可以為連線輸入大端的每個分割槽啟動一個 Mapper,將輸入小端的散列表載入到每個 Mapper 中,然後掃描大端,一次一條記錄,併為每條記錄查詢散列表。
|
||||
|
||||
* 廣播雜湊連線
|
||||
|
||||
兩個連線輸入之一很小,所以它並沒有分割槽,而且能被完全載入進一個雜湊表中。因此,你可以為連線輸入大端的每個分割槽啟動一個 Mapper,將輸入小端的散列表載入到每個 Mapper 中,然後掃描大端,一次一條記錄,併為每條記錄查詢散列表。
|
||||
|
||||
* 分割槽雜湊連線
|
||||
|
||||
如果兩個連線輸入以相同的方式分割槽(使用相同的鍵,相同的雜湊函式和相同數量的分割槽),則可以獨立地對每個分割槽應用散列表方法。
|
||||
分割槽雜湊連線
|
||||
: 如果兩個連線輸入以相同的方式分割槽(使用相同的鍵,相同的雜湊函式和相同數量的分割槽),則可以獨立地對每個分割槽應用散列表方法。
|
||||
|
||||
分散式批處理引擎有一個刻意限制的程式設計模型:回撥函式(比如 Mapper 和 Reducer)被假定是無狀態的,而且除了指定的輸出外,必須沒有任何外部可見的副作用。這一限制允許框架在其抽象下隱藏一些困難的分散式系統問題:當遇到崩潰和網路問題時,任務可以安全地重試,任何失敗任務的輸出都被丟棄。如果某個分割槽的多個任務成功,則其中只有一個能使其輸出實際可見。
|
||||
|
||||
|
@ -9,7 +9,7 @@ breadcrumbs: false
|
||||
當前頁面來自本書第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 有效的複雜系統總是從簡單的系統演化而來。反之亦然:從零設計的複雜系統沒一個能有效工作的。
|
||||
>
|
||||
@ -100,7 +100,7 @@ breadcrumbs: false
|
||||
|
||||
#### 多個消費者
|
||||
|
||||
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 11-1](/img/fig11-1.png) 所示:
|
||||
當多個消費者從同一主題中讀取訊息時,有兩種主要的訊息傳遞模式,如 [圖 11-1](/fig/ddia_1201.png) 所示:
|
||||
|
||||
* 負載均衡(load balancing)
|
||||
|
||||
@ -110,7 +110,7 @@ breadcrumbs: false
|
||||
|
||||
每條訊息都被傳遞給 **所有** 消費者。扇出允許幾個獨立的消費者各自 “收聽” 相同的訊息廣播,而不會相互影響 —— 這個流處理中的概念對應批處理中多個不同批處理作業讀取同一份輸入檔案 (JMS 中的主題訂閱與 AMQP 中的交叉繫結提供了這一功能)。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-1 (a)負載平衡:在消費者間共享消費主題;(b)扇出:將每條訊息傳遞給多個消費者。**
|
||||
|
||||
@ -122,9 +122,9 @@ breadcrumbs: false
|
||||
|
||||
如果與客戶端的連線關閉,或者代理超出一段時間未收到確認,代理則認為訊息沒有被處理,因此它將訊息再遞送給另一個消費者。(請注意可能發生這樣的情況,訊息 **實際上是** 處理完畢的,但 **確認** 在網路中丟失了。需要一種原子提交協議才能處理這種情況,正如在 “[實踐中的分散式事務](/tw/ch9#實踐中的分散式事務)” 中所討論的那樣)
|
||||
|
||||
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在 [圖 11-2](/img/fig11-2.png) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1,結果消費者 1 按照 m4,m3,m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
|
||||
當與負載均衡相結合時,這種重傳行為對訊息的順序有種有趣的影響。在 [圖 11-2](/fig/ddia_1202.png) 中,消費者通常按照生產者傳送的順序處理訊息。然而消費者 2 在處理訊息 m3 時崩潰,與此同時消費者 1 正在處理訊息 m4。未確認的訊息 m3 隨後被重新發送給消費者 1,結果消費者 1 按照 m4,m3,m5 的順序處理訊息。因此 m3 和 m4 的交付順序與生產者 1 的傳送順序不同。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-2 在處理 m3 時消費者 2 崩潰,因此稍後重傳至消費者 1**
|
||||
|
||||
@ -148,11 +148,11 @@ breadcrumbs: false
|
||||
|
||||
同樣的結構可以用於實現訊息代理:生產者透過將訊息追加到日誌末尾來發送訊息,而消費者透過依次讀取日誌來接收訊息。如果消費者讀到日誌末尾,則會等待新訊息追加的通知。Unix 工具 `tail -f` 能監視檔案被追加寫入的資料,基本上就是這樣工作的。
|
||||
|
||||
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第六章](/tw/ch6) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 11-3](/img/fig11-3.png) 所示。
|
||||
為了伸縮超出單個磁碟所能提供的更高吞吐量,可以對日誌進行 **分割槽**(按 [第六章](/tw/ch6) 的定義)。不同的分割槽可以託管在不同的機器上,使得每個分割槽都有一份能獨立於其他分割槽進行讀寫的日誌。一個主題可以定義為一組攜帶相同型別訊息的分割槽。這種方法如 [圖 11-3](/fig/ddia_1203.png) 所示。
|
||||
|
||||
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**(offset,在 [圖 11-3](/img/fig11-3.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
|
||||
在每個分割槽內,代理為每個訊息分配一個單調遞增的序列號或 **偏移量**(offset,在 [圖 11-3](/fig/ddia_1203.png) 中,框中的數字是訊息偏移量)。這種序列號是有意義的,因為分割槽是僅追加寫入的,所以分割槽內的訊息是完全有序的。沒有跨不同分割槽的順序保證。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-3 生產者透過將訊息追加寫入主題分割槽檔案來發送訊息,消費者依次讀取這些檔案**
|
||||
|
||||
@ -228,9 +228,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
如果週期性的完整資料庫轉儲過於緩慢,有時會使用的替代方法是 **雙寫(dual write)**,其中應用程式碼在資料變更時明確寫入每個系統:例如,首先寫入資料庫,然後更新搜尋索引,然後使快取項失效(甚至同時執行這些寫入)。
|
||||
|
||||
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 11-4](/img/fig11-4.png) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X:客戶端 1 想要將值設定為 A,客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫,然後將其寫入到搜尋索引。因為運氣不好,這些請求的時序是交錯的:資料庫首先看到來自客戶端 1 的寫入將值設定為 A,然後來自客戶端 2 的寫入將值設定為 B,因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤,這兩個系統現在也永久地不一致了。
|
||||
但是,雙寫有一些嚴重的問題,其中一個是競爭條件,如 [圖 11-4](/fig/ddia_1204.png) 所示。在這個例子中,兩個客戶端同時想要更新一個專案 X:客戶端 1 想要將值設定為 A,客戶端 2 想要將其設定為 B。兩個客戶端首先將新值寫入資料庫,然後將其寫入到搜尋索引。因為運氣不好,這些請求的時序是交錯的:資料庫首先看到來自客戶端 1 的寫入將值設定為 A,然後來自客戶端 2 的寫入將值設定為 B,因此資料庫中的最終值為 B。搜尋索引首先看到來自客戶端 2 的寫入,然後是客戶端 1 的寫入,所以搜尋索引中的最終值是 A。即使沒發生錯誤,這兩個系統現在也永久地不一致了。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-4 在資料庫中 X 首先被設定為 A,然後被設定為 B,而在搜尋索引處,寫入以相反的順序到達**
|
||||
|
||||
@ -238,7 +238,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
雙重寫入的另一個問題是,其中一個寫入可能會失敗,而另一個成功。這是一個容錯問題,而不是一個併發問題,但也會造成兩個系統互相不一致的結果。確保它們要麼都成功要麼都失敗,是原子提交問題的一個例子,解決這個問題的代價是昂貴的(請參閱 “[原子提交與兩階段提交](/tw/ch9#原子提交與兩階段提交)”)。
|
||||
|
||||
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 11-4](/img/fig11-4.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch5#多主複製)”)。
|
||||
如果你只有一個單領導者複製的資料庫,那麼這個領導者決定了寫入順序,而狀態機複製方法可以在資料庫副本上工作。然而,在 [圖 11-4](/fig/ddia_1204.png) 中,沒有單個主庫:資料庫可能有一個領導者,搜尋索引也可能有一個領導者,但是兩者都不追隨對方,所以可能會發生衝突(請參閱 “[多主複製](/tw/ch5#多主複製)”)。
|
||||
|
||||
如果實際上只有一個領導者 —— 例如,資料庫 —— 而且我們能讓搜尋索引成為資料庫的追隨者,情況要好得多。但這在實踐中可能嗎?
|
||||
|
||||
@ -250,9 +250,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
最近,人們對 **變更資料捕獲(change data capture, CDC)** 越來越感興趣,這是一種觀察寫入資料庫的所有資料變更,並將其提取並轉換為可以複製到其他系統中的形式的過程。CDC 是非常有意思的,尤其是當變更能在被寫入後立刻用於流時。
|
||||
|
||||
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他派生資料系統只是變更流的消費者,如 [圖 11-5](/img/fig11-5.png) 所示。
|
||||
例如,你可以捕獲資料庫中的變更,並不斷將相同的變更應用至搜尋索引。如果變更日誌以相同的順序應用,則可以預期搜尋索引中的資料與資料庫中的資料是匹配的。搜尋索引和任何其他派生資料系統只是變更流的消費者,如 [圖 11-5](/fig/ddia_1205.png) 所示。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-5 將資料按順序寫入一個數據庫,然後按照相同的順序將這些更改應用到其他系統**
|
||||
|
||||
@ -260,7 +260,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
我們可以將日誌消費者叫做 **派生資料系統**,正如在 [第三部分](/tw/part-iii) 的介紹中所討論的:儲存在搜尋索引和資料倉庫中的資料,只是 **記錄系統** 資料的額外檢視。變更資料捕獲是一種機制,可確保對記錄系統所做的所有更改都反映在派生資料系統中,以便派生系統具有資料的準確副本。
|
||||
|
||||
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 11-2](/img/fig11-2.png) 的重新排序問題)。
|
||||
從本質上說,變更資料捕獲使得一個數據庫成為領導者(被捕獲變化的資料庫),並將其他元件變為追隨者。基於日誌的訊息代理非常適合從源資料庫傳輸變更事件,因為它保留了訊息的順序(避免了 [圖 11-2](/fig/ddia_1202.png) 的重新排序問題)。
|
||||
|
||||
資料庫觸發器可用來實現變更資料捕獲(請參閱 “[基於觸發器的複製](/tw/ch5#基於觸發器的複製)”),透過註冊觀察所有變更的觸發器,並將相應的變更項寫入變更日誌表中。但是它們往往是脆弱的,而且有顯著的效能開銷。解析複製日誌可能是一種更穩健的方法,但它也很有挑戰,例如如何應對模式變更。
|
||||
|
||||
@ -280,7 +280,7 @@ LinkedIn 的 Databus【25】,Facebook 的 Wormhole【26】和 Yahoo! 的 Sherp
|
||||
|
||||
如果你只能保留有限的歷史日誌,則每次要新增新的派生資料系統時,都需要做一次快照。但 **日誌壓縮(log compaction)** 提供了一個很好的備選方案。
|
||||
|
||||
我們之前在 “[雜湊索引](/tw/ch3#雜湊索引)” 中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱 [圖 3-2](/img/fig3-2.png) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
|
||||
我們之前在 “[雜湊索引](/tw/ch4#the-sstable-file-format)” 中關於日誌結構儲存引擎的上下文中討論了日誌壓縮(請參閱 [圖 4-2](/ch4/#fig_storage_sstable_index) 的示例)。原理很簡單:儲存引擎定期在日誌中查詢具有相同鍵的記錄,丟掉所有重複的內容,並只保留每個鍵的最新更新。這個壓縮與合併過程在後臺執行。
|
||||
|
||||
在日誌結構儲存引擎中,具有特殊值 NULL(**墓碑**,即 tombstone)的更新表示該鍵被刪除,並會在日誌壓縮過程中被移除。但只要鍵不被覆蓋或刪除,它就會永遠留在日誌中。這種壓縮日誌所需的磁碟空間僅取決於資料庫的當前內容,而不取決於資料庫中曾經發生的寫入次數。如果相同的鍵經常被覆蓋寫入,則先前的值將最終將被垃圾回收,只有最新的值會保留下來。
|
||||
|
||||
@ -304,7 +304,7 @@ Kafka Connect【41】致力於將廣泛的資料庫系統的變更資料捕獲
|
||||
|
||||
與變更資料捕獲類似,事件溯源涉及到 **將所有對應用狀態的變更** 儲存為變更事件日誌。最大的區別是事件溯源將這一想法應用到了一個不同的抽象層次上:
|
||||
|
||||
* 在變更資料捕獲中,應用以 **可變方式(mutable way)** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免 [圖 11-4](/img/fig11-4.png) 中的競態條件。寫入資料庫的應用不需要知道 CDC 的存在。
|
||||
* 在變更資料捕獲中,應用以 **可變方式(mutable way)** 使用資料庫,可以任意更新和刪除記錄。變更日誌是從資料庫的底層提取的(例如,透過解析複製日誌),從而確保從資料庫中提取的寫入順序與實際寫入的順序相匹配,從而避免 [圖 11-4](/fig/ddia_1204.png) 中的競態條件。寫入資料庫的應用不需要知道 CDC 的存在。
|
||||
* 在事件溯源中,應用邏輯顯式構建在寫入事件日誌的不可變事件之上。在這種情況下,事件儲存是僅追加寫入的,更新與刪除是不鼓勵的或禁止的。事件被設計為旨在反映應用層面發生的事情,而不是底層的狀態變更。
|
||||
|
||||
事件溯源是一種強大的資料建模技術:從應用的角度來看,將使用者的行為記錄為不可變的事件更有意義,而不是在可變資料庫中記錄這些行為的影響。事件溯源使得應用隨時間演化更為容易,透過更容易理解事情發生的原因來幫助除錯的進行,並有利於防止應用 Bug(請參閱 “[不可變事件的優點](#不可變事件的優點)”)。
|
||||
@ -350,13 +350,13 @@ Kafka Connect【41】致力於將廣泛的資料庫系統的變更資料捕獲
|
||||
|
||||
無論狀態如何變化,總是有一系列事件導致了這些變化。即使事情已經執行與回滾,這些事件出現是始終成立的。關鍵的想法是:可變的狀態與不可變事件的僅追加日誌相互之間並不矛盾:它們是一體兩面,互為陰陽的。所有變化的日誌 —— **變化日誌(changelog)**,表示了隨時間演變的狀態。
|
||||
|
||||
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 11-6](/img/fig11-6.png) 所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
|
||||
如果你傾向於數學表示,那麼你可能會說,應用狀態是事件流對時間求積分得到的結果,而變更流是狀態對時間求微分的結果,如 [圖 11-6](/fig/ddia_1206.png) 所示【49,50,51】。這個比喻有一些侷限性(例如,狀態的二階導似乎沒有意義),但這是考慮資料的一個實用出發點。
|
||||
$$
|
||||
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
|
||||
stream(t) = \frac{d\ state(t)}{dt}
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-6 應用當前狀態與事件流之間的關係**
|
||||
|
||||
@ -378,7 +378,7 @@ $$
|
||||
|
||||
#### 從同一事件日誌中派生多個檢視
|
||||
|
||||
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 11-5](/img/fig11-5.png)):例如,分析型資料庫 Druid 使用這種方式直接從 Kafka 攝取資料【55】,Pistachio 是一個分散式的鍵值儲存,使用 Kafka 作為提交日誌【56】,Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(請參閱 “[保持系統同步](#保持系統同步)”)。
|
||||
此外,透過從不變的事件日誌中分離出可變的狀態,你可以針對不同的讀取方式,從相同的事件日誌中衍生出幾種不同的表現形式。效果就像一個流的多個消費者一樣([圖 11-5](/fig/ddia_1205.png)):例如,分析型資料庫 Druid 使用這種方式直接從 Kafka 攝取資料【55】,Pistachio 是一個分散式的鍵值儲存,使用 Kafka 作為提交日誌【56】,Kafka Connect 能將來自 Kafka 的資料匯出到各種不同的資料庫與索引【41】。這對於許多其他儲存和索引系統(如搜尋伺服器)來說是很有意義的,當系統要從分散式日誌中獲取輸入時亦然(請參閱 “[保持系統同步](#保持系統同步)”)。
|
||||
|
||||
新增從事件日誌到資料庫的顯式轉換,能夠使應用更容易地隨時間演進:如果你想要引入一個新功能,以新的方式表示現有資料,則可以使用事件日誌來構建一個單獨的、針對新功能的讀取最佳化檢視,無需修改現有系統而與之共存。並行執行新舊系統通常比在現有系統中執行複雜的模式遷移更容易。一旦不再需要舊的系統,你可以簡單地關閉它並回收其資源【47,57】。
|
||||
|
||||
@ -417,7 +417,7 @@ $$
|
||||
|
||||
剩下的就是討論一下你可以用流做什麼 —— 也就是說,你可以處理它。一般來說,有三種選項:
|
||||
|
||||
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 11-5](/img/fig11-5.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#批處理工作流的輸出)” 中所討論的,它是寫入儲存系統的流等價物。
|
||||
1. 你可以將事件中的資料寫入資料庫、快取、搜尋索引或類似的儲存系統,然後能被其他客戶端查詢。如 [圖 11-5](/fig/ddia_1205.png) 所示,這是資料庫與系統其他部分所發生的變更保持同步的好方法 —— 特別是當流消費者是寫入資料庫的唯一客戶端時。如 “[批處理工作流的輸出](/tw/ch11#批處理工作流的輸出)” 中所討論的,它是寫入儲存系統的流等價物。
|
||||
2. 你能以某種方式將事件推送給使用者,例如傳送報警郵件或推送通知,或將事件流式傳輸到可即時顯示的儀表板上。在這種情況下,人是流的最終消費者。
|
||||
3. 你可以處理一個或多個輸入流,併產生一個或多個輸出流。流可能會經過由幾個這樣的處理階段組成的流水線,最後再輸出(選項 1 或 2)。
|
||||
|
||||
@ -510,9 +510,9 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
|
||||
|
||||
[^ii]: 感謝 Flink 社群的 Kostas Kloudas 提出這個比喻。
|
||||
|
||||
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 11-7](/img/fig11-7.png))。
|
||||
將事件時間和處理時間搞混會導致錯誤的資料。例如,假設你有一個流處理器用於測量請求速率(計算每秒請求數)。如果你重新部署流處理器,它可能會停止一分鐘,並在恢復之後處理積壓的事件。如果你按處理時間來衡量速率,那麼在處理積壓日誌時,請求速率看上去就像有一個異常的突發尖峰,而實際上請求速率是穩定的([圖 11-7](/fig/ddia_1207.png))。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 11-7 按處理時間分窗,會因為處理速率的變動引入人為因素**
|
||||
|
||||
@ -583,7 +583,7 @@ CEP 的實現包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
|
||||
|
||||
#### 流表連線(流擴充)
|
||||
|
||||
在 “[示例:使用者活動事件分析](/tw/ch11#示例:使用者活動事件分析)”([圖 10-2](/img/fig10-2.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充(enriching)** 活動事件。
|
||||
在 “[示例:使用者活動事件分析](/tw/ch11#示例:使用者活動事件分析)”([圖 10-2](/fig/ddia_1102.png))中,我們看到了連線兩個資料集的批處理作業示例:一組使用者活動事件和一個使用者檔案資料庫。將使用者活動事件視為流,並在流處理器中連續執行相同的連線是很自然的想法:輸入是包含使用者 ID 的活動事件流,而輸出還是活動事件流,但其中使用者 ID 已經被擴充套件為使用者的檔案資訊。這個過程有時被稱為使用資料庫的資訊來 **擴充(enriching)** 活動事件。
|
||||
|
||||
要執行此連線,流處理器需要一次處理一個活動事件,在資料庫中查詢事件的使用者 ID,並將檔案資訊新增到活動事件中。資料庫查詢可以透過查詢遠端資料庫來實現。但正如在 “[示例:使用者活動事件分析](/tw/ch11#示例:使用者活動事件分析)” 一節中討論的,此類遠端查詢可能會很慢,並且有可能導致資料庫過載【75】。
|
||||
|
||||
@ -618,7 +618,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
流連線直接對應於這個查詢中的表連線。時間線實際上是這個查詢結果的快取,每當底層的表發生變化時都會更新 [^iii]。
|
||||
|
||||
[^iii]: 如果你將流視作表的衍生物,如 [圖 11-6](/img/fig11-6.png) 所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'。換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。
|
||||
[^iii]: 如果你將流視作表的衍生物,如 [圖 11-6](/fig/ddia_1206.png) 所示,而把一個連線看作是兩個表的乘法u·v,那麼會發生一些有趣的事情:物化連線的變化流遵循乘積法則:(u·v)'= u'v + uv'。換句話說,任何推文的變化量都與當前的關注聯絡在一起,任何關注的變化量都與當前的推文相連線【49,50】。
|
||||
|
||||
#### 連線的時間依賴性
|
||||
|
||||
|
@ -10,7 +10,7 @@ breadcrumbs: false
|
||||
當前頁面來自本書第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 如果船長的終極目標是保護船隻,他應該永遠待在港口。
|
||||
>
|
||||
@ -48,7 +48,7 @@ breadcrumbs: false
|
||||
|
||||
例如,你可能會首先將資料寫入 **記錄系統** 資料庫,捕獲對該資料庫所做的變更(請參閱 “[變更資料捕獲](/tw/ch12#變更資料捕獲)”),然後將變更以相同的順序應用於搜尋索引。如果變更資料捕獲(CDC)是更新索引的唯一方式,則可以確定該索引完全派生自記錄系統,因此與其保持一致(除軟體錯誤外)。寫入資料庫是向該系統提供新輸入的唯一方式。
|
||||
|
||||
允許應用程式直接寫入搜尋索引和資料庫引入瞭如 [圖 11-4](/img/fig11-4.png) 所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。
|
||||
允許應用程式直接寫入搜尋索引和資料庫引入瞭如 [圖 11-4](/fig/ddia_1204.png) 所示的問題,其中兩個客戶端同時傳送衝突的寫入,且兩個儲存系統按不同順序處理它們。在這種情況下,既不是資料庫說了算,也不是搜尋索引說了算,所以它們做出了相反的決定,進入彼此間永續性的不一致狀態。
|
||||
|
||||
如果你可以透過單個系統來提供所有使用者輸入,從而決定所有寫入的排序,則透過按相同順序處理寫入,可以更容易地衍生出其他資料表示。這是狀態機複製方法的一個應用,我們在 “[全序廣播](/tw/ch9#全序廣播)” 中看到。無論你使用變更資料捕獲還是事件溯源日誌,都不如簡單的基於全序的決策原則更重要。
|
||||
|
||||
@ -325,9 +325,9 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
|
||||
|
||||
### 觀察派生資料狀態
|
||||
|
||||
在抽象層面,上一節討論的資料流系統提供了建立派生資料集(例如搜尋索引、物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為 **寫路徑(write path)**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個派生資料集都會被更新,以適配寫入的資料。[圖 12-1](/img/fig12-1.png) 顯示了一個更新搜尋索引的例子。
|
||||
在抽象層面,上一節討論的資料流系統提供了建立派生資料集(例如搜尋索引、物化檢視和預測模型)並使其保持更新的過程。我們將這個過程稱為 **寫路徑(write path)**:只要某些資訊被寫入系統,它可能會經歷批處理與流處理的多個階段,而最終每個派生資料集都會被更新,以適配寫入的資料。[圖 12-1](/fig/ddia_1301.png) 顯示了一個更新搜尋索引的例子。
|
||||
|
||||

|
||||

|
||||
|
||||
**圖 12-1 在搜尋索引中,寫(文件更新)遇上讀(查詢)**
|
||||
|
||||
@ -335,7 +335,7 @@ Unix 和關係資料庫以非常不同的哲學來處理資訊管理問題。Uni
|
||||
|
||||
總而言之,寫路徑和讀路徑涵蓋了資料的整個旅程,從收集資料開始,到使用資料結束(可能是由另一個人)。寫路徑是預計算過程的一部分 —— 即,一旦資料進入,即刻完成,無論是否有人需要看它。讀路徑是這個過程中只有當有人請求時才會發生的部分。如果你熟悉函數語言程式設計語言,則可能會注意到寫路徑類似於立即求值,讀路徑類似於惰性求值。
|
||||
|
||||
如 [圖 12-1](/img/fig12-1.png) 所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。
|
||||
如 [圖 12-1](/fig/ddia_1301.png) 所示,派生資料集是寫路徑和讀路徑相遇的地方。它代表了在寫入時需要完成的工作量與在讀取時需要完成的工作量之間的權衡。
|
||||
|
||||
#### 物化檢視和快取
|
||||
|
||||
@ -450,7 +450,7 @@ MPP 資料庫的內部查詢執行圖有著類似的特徵(請參閱 “[Hadoo
|
||||
|
||||
除了流處理之外,其他許多地方也需要抑制重複的模式。例如,TCP 使用了資料包上的序列號,以便接收方可以將它們正確排序,並確定網路上是否有資料包丟失或重複。在將資料交付應用前,TCP 協議棧會重新傳輸任何丟失的資料包,也會移除任何重複的資料包。
|
||||
|
||||
但是,這種重複抑制僅適用於單條 TCP 連線的場景中。假設 TCP 連線是一個客戶端與資料庫的連線,並且它正在執行 [例 12-1]() 中的事務。在許多資料庫中,事務是繫結在客戶端連線上的(如果客戶端傳送了多個查詢,資料庫就知道它們屬於同一個事務,因為它們是在同一個 TCP 連線上傳送的)。如果客戶端在傳送 `COMMIT` 之後並在從資料庫伺服器收到響應之前遇到網路中斷與連線超時,客戶端是不知道事務是否已經被提交的([圖 8-1](/img/fig8-1.png))。
|
||||
但是,這種重複抑制僅適用於單條 TCP 連線的場景中。假設 TCP 連線是一個客戶端與資料庫的連線,並且它正在執行 [例 12-1]() 中的事務。在許多資料庫中,事務是繫結在客戶端連線上的(如果客戶端傳送了多個查詢,資料庫就知道它們屬於同一個事務,因為它們是在同一個 TCP 連線上傳送的)。如果客戶端在傳送 `COMMIT` 之後並在從資料庫伺服器收到響應之前遇到網路中斷與連線超時,客戶端是不知道事務是否已經被提交的([圖 9-1](/tw/ch9#fig_distributed_network))。
|
||||
|
||||
**例 12-1 資金從一個賬戶到另一個賬戶的非冪等轉移**
|
||||
|
||||
|
@ -16,7 +16,7 @@ breadcrumbs: false
|
||||
|
||||
1. 作為應用程式開發人員,你觀察現實世界(裡面有人員、組織、貨物、行為、資金流動、感測器等),並用物件或資料結構以及操作這些資料結構的 API 來進行建模。這些結構通常是特定於你的應用程式的。
|
||||
2. 當你想要儲存這些資料結構時,你用通用的資料模型來表達它們,比如 JSON 或 XML 文件、關係資料庫中的表,或圖中的頂點和邊。這些資料模型是本章的主題。
|
||||
3. 構建資料庫軟體的工程師決定了如何用記憶體、磁碟或網路上的位元組來表示文件/關係/圖資料。這種表示方式可能允許資料以各種方式被查詢、搜尋、操作和處理。我們將在[第 4 章](/zh/ch4#ch_storage)中討論這些儲存引擎的設計。
|
||||
3. 構建資料庫軟體的工程師決定了如何用記憶體、磁碟或網路上的位元組來表示文件/關係/圖資料。這種表示方式可能允許資料以各種方式被查詢、搜尋、操作和處理。我們將在[第 4 章](/tw/ch4#ch_storage)中討論這些儲存引擎的設計。
|
||||
4. 在更低的層次上,硬體工程師已經想出瞭如何用電流、光脈衝、磁場等來表示位元組。
|
||||
|
||||
在複雜的應用程式中可能有更多的中間層,比如基於 API 構建的 API,但基本思想仍然相同:每一層透過提供一個清晰的資料模型來隱藏下層的複雜性。這些抽象使不同的人群——例如,資料庫供應商的工程師和使用其資料庫的應用程式開發人員——能夠有效地協作。
|
||||
@ -39,7 +39,7 @@ breadcrumbs: false
|
||||
|
||||
如今最知名的資料模型可能是 SQL,它基於 Edgar Codd 於 1970 年提出的關係模型 [^3]:資料被組織成 **關係**(SQL 中稱為 **表**),其中每個關係是 **元組**(SQL 中的 **行**)的無序集合。
|
||||
|
||||
關係模型最初是一個理論性的提議,當時許多人懷疑它是否能夠有效地實現。然而,到 20 世紀 80 年代中期,關係資料庫管理系統(RDBMS)和 SQL 已經成為大多數需要儲存和查詢某種規則結構資料的人的首選工具。幾十年後,許多資料管理用例仍然由關係資料主導——例如,商業分析(參見["星型和雪花型:分析模式"](/zh/ch3#sec_datamodels_analytics))。
|
||||
關係模型最初是一個理論性的提議,當時許多人懷疑它是否能夠有效地實現。然而,到 20 世紀 80 年代中期,關係資料庫管理系統(RDBMS)和 SQL 已經成為大多數需要儲存和查詢某種規則結構資料的人的首選工具。幾十年後,許多資料管理用例仍然由關係資料主導——例如,商業分析(參見["星型和雪花型:分析模式"](/tw/ch3#sec_datamodels_analytics))。
|
||||
|
||||
多年來,有許多競爭性的資料儲存和查詢方法。在 20 世紀 70 年代和 80 年代初,**網路模型** 和 **層次模型** 是主要的替代方案,但關係模型最終佔據了主導地位。物件資料庫在 20 世紀 80 年代末和 90 年代初出現又消失。XML 資料庫出現在 2000 年代初,但只在小眾領域被採用。關係模型的每個競爭者在其時代都產生了大量的炒作,但從未持續 [^4]。相反,SQL 已經發展到在其關係核心之外包含其他資料型別——例如,增加了對 XML、JSON 和圖資料的支援 [^5]。
|
||||
|
||||
@ -65,7 +65,7 @@ NoSQL 運動的一個持久影響是 **文件模型** 的普及,它通常將
|
||||
像 ActiveRecord 和 Hibernate 這樣的物件關係對映(ORM)框架減少了這個轉換層所需的樣板程式碼量,但它們經常受到批評 [^6]。一些常被提到的問題是:
|
||||
|
||||
* ORM 很複雜,不能完全隱藏兩個模型之間的差異,因此開發人員最終仍然需要考慮資料的關係表示和物件表示。
|
||||
* ORM 通常只用於 OLTP 應用程式開發(參見["描述事務處理和分析"](/zh/ch1#sec_introduction_oltp));為分析目的提供資料的資料工程師仍然需要使用底層的關係表示,因此在使用 ORM 時關係模式的設計仍然很重要。
|
||||
* ORM 通常只用於 OLTP 應用程式開發(參見["描述事務處理和分析"](/tw/ch1#sec_introduction_oltp));為分析目的提供資料的資料工程師仍然需要使用底層的關係表示,因此在使用 ORM 時關係模式的設計仍然很重要。
|
||||
* 許多 ORM 只適用於關係型 OLTP 資料庫。擁有多樣化資料系統(如搜尋引擎、圖資料庫和 NoSQL 系統)的組織可能會發現 ORM 支援不足。
|
||||
* 一些 ORM 會自動生成關係模式,但這些模式對於直接訪問關係資料的使用者來說可能很彆扭,而且在底層資料庫上可能效率低下。自定義 ORM 的模式和查詢生成可能很複雜,並且會抵消使用 ORM 的好處。
|
||||
* ORM 使得很容易意外地編寫低效的查詢,例如 **N+1 查詢問題** [^7]。例如,假設你想在頁面上顯示使用者評論列表,所以你執行一個查詢返回 *N* 條評論,每條評論包含其作者的 ID。要顯示評論作者的姓名,你需要在 users 表中查詢 ID。在手寫 SQL 中,你可能會在查詢中執行這個連線,並將作者姓名與每條評論一起返回,但使用 ORM 時,你可能最終會為每條 *N* 條評論在 users 表上進行單獨的查詢來查詢其作者,總共產生 *N*+1 個數據庫查詢,這比在資料庫中執行連線要慢。為了避免這個問題,你可能需要告訴 ORM 在獲取評論的同時獲取作者資訊。
|
||||
@ -78,13 +78,13 @@ NoSQL 運動的一個持久影響是 **文件模型** 的普及,它通常將
|
||||
|
||||
#### 用於一對多關係的文件資料模型 {#the-document-data-model-for-one-to-many-relationships}
|
||||
|
||||
並非所有資料都適合關係表示;讓我們看一個例子來探討關係模型的侷限性。[圖 3-1](/zh/ch3#fig_obama_relational) 說明了如何在關係模式中表達簡歷(LinkedIn 個人資料)。整個資料可以透過唯一識別符號 `user_id` 來識別。像 `first_name` 和 `last_name` 這樣的欄位每個使用者只出現一次,因此可以將它們建模為 `users` 表上的列。
|
||||
並非所有資料都適合關係表示;讓我們看一個例子來探討關係模型的侷限性。[圖 3-1](/tw/ch3#fig_obama_relational) 說明了如何在關係模式中表達簡歷(LinkedIn 個人資料)。整個資料可以透過唯一識別符號 `user_id` 來識別。像 `first_name` 和 `last_name` 這樣的欄位每個使用者只出現一次,因此可以將它們建模為 `users` 表上的列。
|
||||
|
||||
大多數人在職業生涯中有多份工作(職位),人們可能有不同數量的教育經歷和任意數量的聯絡資訊。表示這種 **一對多關係** 的一種方法是將職位、教育和聯絡資訊放在單獨的表中,並引用 `users` 表的外部索引鍵,如[圖 3-1](/zh/ch3#fig_obama_relational) 所示。
|
||||
大多數人在職業生涯中有多份工作(職位),人們可能有不同數量的教育經歷和任意數量的聯絡資訊。表示這種 **一對多關係** 的一種方法是將職位、教育和聯絡資訊放在單獨的表中,並引用 `users` 表的外部索引鍵,如[圖 3-1](/tw/ch3#fig_obama_relational) 所示。
|
||||
|
||||
{{< figure src="/fig/ddia_0301.png" id="fig_obama_relational" caption="圖 3-1. 使用關係模式表示 LinkedIn 個人資料。" class="w-full my-4" >}}
|
||||
|
||||
表示相同資訊的另一種方式(這可能更自然,更貼近應用程式程式碼中的物件結構)是作為 JSON 文件,如[示例 3-1](/zh/ch3#fig_obama_json) 所示。
|
||||
表示相同資訊的另一種方式(這可能更自然,更貼近應用程式程式碼中的物件結構)是作為 JSON 文件,如[示例 3-1](/tw/ch3#fig_obama_json) 所示。
|
||||
|
||||
{{< figure id="fig_obama_json" title="示例 3-1. 將 LinkedIn 個人資料表示為 JSON 文件" class="w-full my-4" >}}
|
||||
|
||||
@ -111,24 +111,24 @@ NoSQL 運動的一個持久影響是 **文件模型** 的普及,它通常將
|
||||
}
|
||||
```
|
||||
|
||||
一些開發人員認為 JSON 模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。然而,正如我們將在[第 5 章](/zh/ch5#ch_encoding)中看到的,JSON 作為資料編碼格式也存在問題。缺乏模式通常被認為是一個優勢;我們將在["文件模型中的模式靈活性"](/zh/ch3#sec_datamodels_schema_flexibility)中討論這一點。
|
||||
一些開發人員認為 JSON 模型減少了應用程式程式碼和儲存層之間的阻抗不匹配。然而,正如我們將在[第 5 章](/tw/ch5#ch_encoding)中看到的,JSON 作為資料編碼格式也存在問題。缺乏模式通常被認為是一個優勢;我們將在["文件模型中的模式靈活性"](/tw/ch3#sec_datamodels_schema_flexibility)中討論這一點。
|
||||
|
||||
與[圖 3-1](/zh/ch3#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 **區域性**(參見["讀寫的資料區域性"](/zh/ch3#sec_datamodels_document_locality))。如果你想在關係示例中獲取個人資料,你需要執行多個查詢(按 `user_id` 查詢每個表)或在 `users` 表及其從屬表之間執行混亂的多路連線 [^8]。在 JSON 表示中,所有相關資訊都在一個地方,使查詢既更快又更簡單。
|
||||
與[圖 3-1](/tw/ch3#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 **區域性**(參見["讀寫的資料區域性"](/tw/ch3#sec_datamodels_document_locality))。如果你想在關係示例中獲取個人資料,你需要執行多個查詢(按 `user_id` 查詢每個表)或在 `users` 表及其從屬表之間執行混亂的多路連線 [^8]。在 JSON 表示中,所有相關資訊都在一個地方,使查詢既更快又更簡單。
|
||||
|
||||
從使用者個人資料到使用者職位、教育歷史和聯絡資訊的一對多關係意味著資料中存在樹形結構,JSON 表示使這種樹形結構明確(參見[圖 3-2](/zh/ch3#fig_json_tree))。
|
||||
從使用者個人資料到使用者職位、教育歷史和聯絡資訊的一對多關係意味著資料中存在樹形結構,JSON 表示使這種樹形結構明確(參見[圖 3-2](/tw/ch3#fig_json_tree))。
|
||||
|
||||
{{< figure src="/fig/ddia_0302.png" id="fig_json_tree" caption="圖 3-2. 形成樹形結構的一對多關係。" class="w-full my-4" >}}
|
||||
|
||||
--------
|
||||
|
||||
> [!NOTE]
|
||||
> 這種關係有時被稱為 **一對少** 而不是 **一對多**,因為簡歷通常只有少量的職位 [^9] [^10]。在可能有大量相關項的情況下——比如名人社交媒體帖子上的評論,可能有數千條——將它們全部嵌入同一文件中可能太笨拙,因此[圖 3-1](/zh/ch3#fig_obama_relational) 中的關係方法更可取。
|
||||
> 這種關係有時被稱為 **一對少** 而不是 **一對多**,因為簡歷通常只有少量的職位 [^9] [^10]。在可能有大量相關項的情況下——比如名人社交媒體帖子上的評論,可能有數千條——將它們全部嵌入同一文件中可能太笨拙,因此[圖 3-1](/tw/ch3#fig_obama_relational) 中的關係方法更可取。
|
||||
|
||||
--------
|
||||
|
||||
### 規範化、反規範化與連線 {#sec_datamodels_normalization}
|
||||
|
||||
在前一節的[示例 3-1](/zh/ch3#fig_obama_json) 中,`region_id` 被給出為 ID,而不是純文字字串 `"Washington, DC, United States"`。為什麼?
|
||||
在前一節的[示例 3-1](/tw/ch3#fig_obama_json) 中,`region_id` 被給出為 ID,而不是純文字字串 `"Washington, DC, United States"`。為什麼?
|
||||
|
||||
如果使用者介面有一個用於輸入地區的自由文字欄位,將其儲存為純文字字串是有意義的。但是,擁有標準化的地理區域列表並讓使用者從下拉列表或自動完成中選擇是有優勢的:
|
||||
|
||||
@ -174,15 +174,15 @@ db.users.aggregate([
|
||||
* 在反規範化的表示中,我們會在每個人的個人資料中包含徽標的影像 URL;這使得 JSON 文件自包含,但如果我們需要更改徽標,就會很頭疼,因為我們現在需要找到舊 URL 的所有出現並更新它們 [^9]。
|
||||
* 在規範化的表示中,我們將建立一個代表組織或學校的實體,並在該實體上儲存其名稱、徽標 URL 以及可能的其他屬性(描述、新聞源等)一次。每個提到該組織的簡歷都只需引用其 ID,更新徽標很容易。
|
||||
|
||||
作為一般原則,規範化的資料通常寫入更快(因為只有一個副本),但查詢更慢(因為需要連線);反規範化的資料通常讀取更快(連線更少),但寫入更昂貴(更多副本需要更新,使用更多磁碟空間)。你可能會發現將反規範化視為派生資料的一種形式很有幫助(["記錄系統和派生資料"](/zh/ch1#sec_introduction_derived)),因為你需要設定一個過程來更新資料的冗餘副本。
|
||||
作為一般原則,規範化的資料通常寫入更快(因為只有一個副本),但查詢更慢(因為需要連線);反規範化的資料通常讀取更快(連線更少),但寫入更昂貴(更多副本需要更新,使用更多磁碟空間)。你可能會發現將反規範化視為派生資料的一種形式很有幫助(["記錄系統和派生資料"](/tw/ch1#sec_introduction_derived)),因為你需要設定一個過程來更新資料的冗餘副本。
|
||||
|
||||
除了執行所有這些更新的成本之外,你還需要考慮如果程序在進行更新的中途崩潰,資料庫的一致性。提供原子事務的資料庫(參見["原子性"](/zh/ch8#sec_transactions_acid_atomicity))使保持一致性變得更容易,但並非所有資料庫都提供跨多個文件的原子性。也可以透過流處理來確保一致性,我們將在 [連結待定] 中討論。
|
||||
除了執行所有這些更新的成本之外,你還需要考慮如果程序在進行更新的中途崩潰,資料庫的一致性。提供原子事務的資料庫(參見["原子性"](/tw/ch8#sec_transactions_acid_atomicity))使保持一致性變得更容易,但並非所有資料庫都提供跨多個文件的原子性。也可以透過流處理來確保一致性,我們將在 [連結待定] 中討論。
|
||||
|
||||
規範化往往更適合 OLTP 系統,其中讀取和更新都需要快速;分析系統通常使用反規範化資料表現更好,因為它們批次執行更新,只讀查詢的效能是主要關注點。此外,在中小規模的系統中,規範化的資料模型通常是最好的,因為你不必擔心保持資料的多個副本彼此一致,執行連線的成本是可以接受的。然而,在非常大規模的系統中,連線的成本可能會成為問題。
|
||||
|
||||
#### 社交網路案例研究中的反規範化 {#denormalization-in-the-social-networking-case-study}
|
||||
|
||||
在["案例研究:社交網路主頁時間線"](/zh/ch2#sec_introduction_twitter)中,我們比較了規範化表示([圖 2-1](/zh/ch2#fig_twitter_relational))和反規範化表示(預計算、物化的時間線):這裡,`posts` 和 `follows` 之間的連線太昂貴了,物化時間線是該連線結果的快取。將新帖子插入關注者時間線的扇出過程是我們保持反規範化表示一致的方式。
|
||||
在["案例研究:社交網路主頁時間線"](/tw/ch2#sec_introduction_twitter)中,我們比較了規範化表示([圖 2-1](/tw/ch2#fig_twitter_relational))和反規範化表示(預計算、物化的時間線):這裡,`posts` 和 `follows` 之間的連線太昂貴了,物化時間線是該連線結果的快取。將新帖子插入關注者時間線的扇出過程是我們保持反規範化表示一致的方式。
|
||||
|
||||
然而,X(前 Twitter)的物化時間線實現實際上並不儲存每個帖子的實際文字:每個條目實際上只儲存帖子 ID、釋出它的使用者的 ID,以及一些額外的資訊來識別轉發和回覆 [^11]。換句話說,它是(大約)以下查詢的預計算結果:
|
||||
|
||||
@ -205,13 +205,13 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
### 多對一與多對多關係 {#sec_datamodels_many_to_many}
|
||||
|
||||
雖然[圖 3-1](/zh/ch3#fig_obama_relational) 中的 `positions` 和 `education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),`region_id` 欄位是 **多對一** 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。
|
||||
雖然[圖 3-1](/tw/ch3#fig_obama_relational) 中的 `positions` 和 `education` 是一對多或一對少關係的例子(一份簡歷有多個職位,但每個職位只屬於一份簡歷),`region_id` 欄位是 **多對一** 關係的例子(許多人住在同一個地區,但我們假設每個人在任何時候只住在一個地區)。
|
||||
|
||||
如果我們為組織和學校引入實體,並從簡歷中透過 ID 引用它們,那麼我們也有 **多對多** 關係(一個人為幾個組織工作過,一個組織有幾個過去或現在的員工)。在關係模型中,這種關係通常表示為 **關聯表** 或 **連線表**,如[圖 3-3](/zh/ch3#fig_datamodels_m2m_rel) 所示:每個職位將一個使用者 ID 與一個組織 ID 關聯起來。
|
||||
如果我們為組織和學校引入實體,並從簡歷中透過 ID 引用它們,那麼我們也有 **多對多** 關係(一個人為幾個組織工作過,一個組織有幾個過去或現在的員工)。在關係模型中,這種關係通常表示為 **關聯表** 或 **連線表**,如[圖 3-3](/tw/ch3#fig_datamodels_m2m_rel) 所示:每個職位將一個使用者 ID 與一個組織 ID 關聯起來。
|
||||
|
||||
{{< figure src="/fig/ddia_0303.png" id="fig_datamodels_m2m_rel" caption="圖 3-3. 關係模型中的多對多關係。" class="w-full my-4" >}}
|
||||
|
||||
多對一和多對多關係不容易適應一個自包含的 JSON 文件;它們更適合規範化的表示。在文件模型中,一種可能的表示在[示例 3-2](/zh/ch3#fig_datamodels_m2m_json) 中給出,並在[圖 3-4](/zh/ch3#fig_datamodels_many_to_many) 中說明:每個虛線矩形內的資料可以分組到一個文件中,但與組織和學校的連結最好表示為對其他文件的引用。
|
||||
多對一和多對多關係不容易適應一個自包含的 JSON 文件;它們更適合規範化的表示。在文件模型中,一種可能的表示在[示例 3-2](/tw/ch3#fig_datamodels_m2m_json) 中給出,並在[圖 3-4](/tw/ch3#fig_datamodels_many_to_many) 中說明:每個虛線矩形內的資料可以分組到一個文件中,但與組織和學校的連結最好表示為對其他文件的引用。
|
||||
|
||||
{{< figure id="fig_datamodels_m2m_json" title="示例 3-2. 透過 ID 引用組織的簡歷。" class="w-full my-4" >}}
|
||||
|
||||
@ -232,15 +232,15 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
多對多關係通常需要在"兩個方向"上查詢:例如,查詢特定人員工作過的所有組織,以及查詢在特定組織工作過的所有人員。啟用此類查詢的一種方法是在兩側儲存 ID 引用,即簡歷包括該人工作過的每個組織的 ID,組織文件包括提到該組織的簡歷的 ID。這種表示是反規範化的,因為關係儲存在兩個地方,它們可能彼此不一致。
|
||||
|
||||
規範化的表示只在一個地方儲存關係,並依賴 **二級索引**(我們將在[第 4 章](/zh/ch4#ch_storage)中討論)來允許在兩個方向上有效地查詢關係。在[圖 3-3](/zh/ch3#fig_datamodels_m2m_rel) 的關係模式中,我們會告訴資料庫在 `positions` 表的 `user_id` 和 `org_id` 列上建立索引。
|
||||
規範化的表示只在一個地方儲存關係,並依賴 **二級索引**(我們將在[第 4 章](/tw/ch4#ch_storage)中討論)來允許在兩個方向上有效地查詢關係。在[圖 3-3](/tw/ch3#fig_datamodels_m2m_rel) 的關係模式中,我們會告訴資料庫在 `positions` 表的 `user_id` 和 `org_id` 列上建立索引。
|
||||
|
||||
在[示例 3-2](/zh/ch3#fig_datamodels_m2m_json) 的文件模型中,資料庫需要索引 `positions` 陣列內物件的 `org_id` 欄位。許多文件資料庫和支援 JSON 的關係資料庫能夠在文件內的值上建立這樣的索引。
|
||||
在[示例 3-2](/tw/ch3#fig_datamodels_m2m_json) 的文件模型中,資料庫需要索引 `positions` 陣列內物件的 `org_id` 欄位。許多文件資料庫和支援 JSON 的關係資料庫能夠在文件內的值上建立這樣的索引。
|
||||
|
||||
### 星型與雪花型:分析模式 {#sec_datamodels_analytics}
|
||||
|
||||
資料倉庫(參見["資料倉庫"](/zh/ch1#sec_introduction_dwh))通常是關係型的,資料倉庫中表結構有幾個廣泛使用的約定:**星型模式**、**雪花型模式**、**維度建模** [^12] 和 **一大表**(OBT)。這些結構針對業務分析師的需求進行了最佳化。ETL 過程將資料從作業系統轉換為此模式。
|
||||
資料倉庫(參見["資料倉庫"](/tw/ch1#sec_introduction_dwh))通常是關係型的,資料倉庫中表結構有幾個廣泛使用的約定:**星型模式**、**雪花型模式**、**維度建模** [^12] 和 **一大表**(OBT)。這些結構針對業務分析師的需求進行了最佳化。ETL 過程將資料從作業系統轉換為此模式。
|
||||
|
||||
[圖 3-5](/zh/ch3#fig_dwh_schema) 展示了一個可能在雜貨零售商資料倉庫中找到的星型模式示例。模式的中心是所謂的 **事實表**(在此示例中,它稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買產品)。如果我們分析的是網站流量而不是零售銷售,每一行可能代表使用者的頁面瀏覽或點選。
|
||||
[圖 3-5](/tw/ch3#fig_dwh_schema) 展示了一個可能在雜貨零售商資料倉庫中找到的星型模式示例。模式的中心是所謂的 **事實表**(在此示例中,它稱為 `fact_sales`)。事實表的每一行代表在特定時間發生的事件(這裡,每一行代表客戶購買產品)。如果我們分析的是網站流量而不是零售銷售,每一行可能代表使用者的頁面瀏覽或點選。
|
||||
|
||||
{{< figure src="/fig/ddia_0305.png" id="fig_dwh_schema" caption="圖 3-5. 用於資料倉庫的星型模式示例。" class="w-full my-4" >}}
|
||||
|
||||
@ -248,11 +248,11 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
事實表中的一些列是屬性,例如產品的銷售價格和從供應商處購買的成本(允許計算利潤率)。事實表中的其他列是對其他表的外部索引鍵引用,稱為 **維度表**。由於事實表中的每一行都代表一個事件,維度代表事件的 **誰**、**什麼**、**哪裡**、**何時**、**如何** 和 **為什麼**。
|
||||
|
||||
例如,在[圖 3-5](/zh/ch3#fig_dwh_schema) 中,其中一個維度是所售產品。`dim_product` 表中的每一行代表一種待售產品型別,包括其庫存單位(SKU)、描述、品牌名稱、類別、脂肪含量、包裝大小等。`fact_sales` 表中的每一行使用外部索引鍵來指示在該特定交易中銷售的產品。查詢通常涉及對多個維度表的多個連線。
|
||||
例如,在[圖 3-5](/tw/ch3#fig_dwh_schema) 中,其中一個維度是所售產品。`dim_product` 表中的每一行代表一種待售產品型別,包括其庫存單位(SKU)、描述、品牌名稱、類別、脂肪含量、包裝大小等。`fact_sales` 表中的每一行使用外部索引鍵來指示在該特定交易中銷售的產品。查詢通常涉及對多個維度表的多個連線。
|
||||
|
||||
甚至日期和時間通常使用維度表表示,因為這允許編碼有關日期的額外資訊(例如公共假期),允許查詢區分假期和非假期的銷售。
|
||||
|
||||
[圖 3-5](/zh/ch3#fig_dwh_schema) 是星型模式的一個例子。該名稱來自這樣一個事實:當表關係被視覺化時,事實表位於中間,被其維度表包圍;與這些表的連線就像星星的光芒。
|
||||
[圖 3-5](/tw/ch3#fig_dwh_schema) 是星型模式的一個例子。該名稱來自這樣一個事實:當表關係被視覺化時,事實表位於中間,被其維度表包圍;與這些表的連線就像星星的光芒。
|
||||
|
||||
這個模板的一個變體被稱為 **雪花型模式**,其中維度進一步分解為子維度。例如,品牌和產品類別可能有單獨的表,`dim_product` 表中的每一行可以將品牌和類別作為外部索引鍵引用,而不是將它們作為字串儲存在 `dim_product` 表中。雪花型模式比星型模式更規範化,但星型模式通常更受歡迎,因為它們對分析師來說更簡單 [^12]。
|
||||
|
||||
@ -268,7 +268,7 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
支援文件資料模型的主要論點是模式靈活性、由於區域性而獲得更好的效能,以及對於某些應用程式來說,它更接近應用程式使用的物件模型。關係模型透過提供對連線、多對一和多對多關係的更好支援來反駁。讓我們更詳細地研究這些論點。
|
||||
|
||||
如果應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 **分解** 為多個表的關係技術(如[圖 3-1](/zh/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要的複雜應用程式程式碼。
|
||||
如果應用程式中的資料具有類似文件的結構(即一對多關係的樹,通常一次載入整個樹),那麼使用文件模型可能是個好主意。將類似文件的結構 **分解** 為多個表的關係技術(如[圖 3-1](/tw/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能導致繁瑣的模式和不必要的複雜應用程式程式碼。
|
||||
|
||||
文件模型有侷限性:例如,你不能直接引用文件中的巢狀項,而是需要說類似"使用者 251 的職位列表中的第二項"。如果你確實需要引用巢狀項,關係方法效果更好,因為你可以透過其 ID 直接引用任何項。
|
||||
|
||||
@ -308,11 +308,11 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
|
||||
* 有許多不同型別的物件,將每種型別的物件放在自己的表中是不切實際的。
|
||||
* 資料的結構由你無法控制且可能隨時更改的外部系統確定。
|
||||
|
||||
在這樣的情況下,模式可能弊大於利,無模式文件可能是更自然的資料模型。但在所有記錄都應具有相同結構的情況下,模式是記錄和執行該結構的有用機制。我們將在[第 5 章](/zh/ch5#ch_encoding)中更詳細地討論模式和模式演化。
|
||||
在這樣的情況下,模式可能弊大於利,無模式文件可能是更自然的資料模型。但在所有記錄都應具有相同結構的情況下,模式是記錄和執行該結構的有用機制。我們將在[第 5 章](/tw/ch5#ch_encoding)中更詳細地討論模式和模式演化。
|
||||
|
||||
#### 讀寫的資料區域性 {#sec_datamodels_document_locality}
|
||||
|
||||
文件通常儲存為單個連續字串,編碼為 JSON、XML 或其二進位制變體(例如 MongoDB 的 BSON)。如果你的應用程式經常需要訪問整個文件(例如,在網頁上呈現它),這種 **儲存區域性** 具有效能優勢。如果資料分散在多個表中,如[圖 3-1](/zh/ch3#fig_obama_relational),則需要多個索引查詢來檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。
|
||||
文件通常儲存為單個連續字串,編碼為 JSON、XML 或其二進位制變體(例如 MongoDB 的 BSON)。如果你的應用程式經常需要訪問整個文件(例如,在網頁上呈現它),這種 **儲存區域性** 具有效能優勢。如果資料分散在多個表中,如[圖 3-1](/tw/ch3#fig_obama_relational),則需要多個索引查詢來檢索所有資料,這可能需要更多的磁碟尋道並花費更多時間。
|
||||
|
||||
區域性優勢僅在你同時需要文件的大部分時才適用。資料庫通常需要載入整個文件,如果你只需要訪問大文件的一小部分,這可能會浪費。在更新文件時,通常需要重寫整個文件。出於這些原因,通常建議你保持文件相當小,並避免對文件進行頻繁的小更新。
|
||||
|
||||
@ -324,7 +324,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
|
||||
|
||||
XML 資料庫通常使用 XQuery 和 XPath 查詢,它們旨在允許複雜查詢,包括跨多個文件的連線,並且還將其結果格式化為 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 為 JSON 提供了與 XPath 等效的功能。
|
||||
|
||||
MongoDB 的聚合管道(我們在["規範化、反規範化和連線"](/zh/ch3#sec_datamodels_normalization)中看到了其用於連線的 `$lookup` 運算子)是 JSON 文件集合查詢語言的一個例子。
|
||||
MongoDB 的聚合管道(我們在["規範化、反規範化和連線"](/tw/ch3#sec_datamodels_normalization)中看到了其用於連線的 `$lookup` 運算子)是 JSON 文件集合查詢語言的一個例子。
|
||||
|
||||
讓我們看另一個例子來感受這種語言——這次是聚合,這對於分析特別需要。想象你是一名海洋生物學家,每次在海洋中看到動物時,你都會向資料庫新增觀察記錄。現在你想生成一份報告,說明你每月看到了多少鯊魚。在 PostgreSQL 中,你可能會這樣表達該查詢:
|
||||
|
||||
@ -387,7 +387,7 @@ db.observations.aggregate([
|
||||
|
||||
眾所周知的演算法可以在這些圖上執行:例如,地圖導航應用程式在道路網路中搜索兩點之間的最短路徑,PageRank 可以用於網頁圖來確定網頁的受歡迎程度,從而確定其在搜尋結果中的排名 [^32]。
|
||||
|
||||
圖可以用幾種不同的方式表示。在 **鄰接列表** 模型中,每個頂點儲存其一條邊之外的鄰居頂點的 ID。或者,你可以使用 **鄰接矩陣**,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接列表適合圖遍歷,矩陣適合機器學習(參見["資料框、矩陣和陣列"](/zh/ch3#sec_datamodels_dataframes))。
|
||||
圖可以用幾種不同的方式表示。在 **鄰接列表** 模型中,每個頂點儲存其一條邊之外的鄰居頂點的 ID。或者,你可以使用 **鄰接矩陣**,這是一個二維陣列,其中每一行和每一列對應一個頂點,當行頂點和列頂點之間沒有邊時值為零,如果有邊則值為一。鄰接列表適合圖遍歷,矩陣適合機器學習(參見["資料框、矩陣和陣列"](/tw/ch3#sec_datamodels_dataframes))。
|
||||
|
||||
在剛才給出的例子中,圖中的所有頂點分別代表相同型別的事物(人、網頁或道路交叉口)。然而,圖不限於這種 **同質** 資料:圖的一個同樣強大的用途是提供一種一致的方式在單個數據庫中儲存完全不同型別的物件。例如:
|
||||
|
||||
@ -398,7 +398,7 @@ db.observations.aggregate([
|
||||
|
||||
我們還將研究四種圖查詢語言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 對查詢圖的支援。還存在其他圖查詢語言,如 Gremlin [^37],但這些將給我們一個代表性的概述。
|
||||
|
||||
為了說明這些不同的語言和模型,本節使用[圖 3-6](/zh/ch3#fig_datamodels_graph) 中顯示的圖作為執行示例。它可以取自社交網路或家譜資料庫:它顯示了兩個人,來自愛達荷州的 Lucy 和來自法國聖洛的 Alain。他們已婚並住在倫敦。每個人和每個位置都表示為頂點,它們之間的關係表示為邊。這個例子將幫助演示一些在圖資料庫中很容易但在其他模型中很困難的查詢。
|
||||
為了說明這些不同的語言和模型,本節使用[圖 3-6](/tw/ch3#fig_datamodels_graph) 中顯示的圖作為執行示例。它可以取自社交網路或家譜資料庫:它顯示了兩個人,來自愛達荷州的 Lucy 和來自法國聖洛的 Alain。他們已婚並住在倫敦。每個人和每個位置都表示為頂點,它們之間的關係表示為邊。這個例子將幫助演示一些在圖資料庫中很容易但在其他模型中很困難的查詢。
|
||||
|
||||
{{< figure src="/fig/ddia_0306.png" id="fig_datamodels_graph" caption="圖 3-6. 圖結構資料示例(框表示頂點,箭頭表示邊)。" class="w-full my-4" >}}
|
||||
|
||||
@ -420,7 +420,7 @@ db.observations.aggregate([
|
||||
* 描述兩個頂點之間關係型別的標籤
|
||||
* 屬性集合(鍵值對)
|
||||
|
||||
你可以將圖儲存視為由兩個關係表組成,一個用於頂點,一個用於邊,如[示例 3-3](/zh/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 資料型別來儲存每個頂點或邊的屬性)。每條邊都儲存頭頂點和尾頂點;如果你想要頂點的入邊或出邊集,可以分別透過 `head_vertex` 或 `tail_vertex` 查詢 `edges` 表。
|
||||
你可以將圖儲存視為由兩個關係表組成,一個用於頂點,一個用於邊,如[示例 3-3](/tw/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 資料型別來儲存每個頂點或邊的屬性)。每條邊都儲存頭頂點和尾頂點;如果你想要頂點的入邊或出邊集,可以分別透過 `head_vertex` 或 `tail_vertex` 查詢 `edges` 表。
|
||||
|
||||
{{< figure id="fig_graph_sql_schema" title="示例 3-3. 使用關係模式表示屬性圖" class="w-full my-4" >}}
|
||||
|
||||
@ -446,10 +446,10 @@ CREATE INDEX edges_heads ON edges (head_vertex);
|
||||
這個模型的一些重要方面是:
|
||||
|
||||
1. 任何頂點都可以有一條邊將其與任何其他頂點連線。沒有模式限制哪些事物可以或不能相關聯。
|
||||
2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 **遍歷** 圖——即透過頂點鏈跟隨路徑——向前和向後。(這就是為什麼[示例 3-3](/zh/ch3#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。)
|
||||
2. 給定任何頂點,你可以有效地找到其入邊和出邊,從而 **遍歷** 圖——即透過頂點鏈跟隨路徑——向前和向後。(這就是為什麼[示例 3-3](/tw/ch3#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。)
|
||||
3. 透過使用不同的標籤來表示不同型別的頂點和關係,你可以在單個圖中儲存幾種不同型別的資訊,同時仍然保持乾淨的資料模型。
|
||||
|
||||
edges 表就像我們在["多對一和多對多關係"](/zh/ch3#sec_datamodels_many_to_many)中看到的多對多關聯表/連線表,推廣到允許在同一表中儲存許多不同型別的關係。標籤和屬性上也可能有索引,允許有效地找到具有某些屬性的頂點或邊。
|
||||
edges 表就像我們在["多對一和多對多關係"](/tw/ch3#sec_datamodels_many_to_many)中看到的多對多關聯表/連線表,推廣到允許在同一表中儲存許多不同型別的關係。標籤和屬性上也可能有索引,允許有效地找到具有某些屬性的頂點或邊。
|
||||
|
||||
--------
|
||||
|
||||
@ -458,7 +458,7 @@ edges 表就像我們在["多對一和多對多關係"](/zh/ch3#sec_datamodels_m
|
||||
|
||||
--------
|
||||
|
||||
這些特性為資料建模提供了很大的靈活性,如[圖 3-6](/zh/ch3#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的事情,例如不同國家的不同地區結構(法國有 **省** 和 **大區**,而美國有 **縣** 和 **州**)、歷史的怪癖如國中之國(暫時忽略主權國家和民族的複雜性),以及資料的不同粒度(Lucy 的當前居住地被指定為城市,而她的出生地僅在州級別指定)。
|
||||
這些特性為資料建模提供了很大的靈活性,如[圖 3-6](/tw/ch3#fig_datamodels_graph) 所示。該圖顯示了一些在傳統關係模式中難以表達的事情,例如不同國家的不同地區結構(法國有 **省** 和 **大區**,而美國有 **縣** 和 **州**)、歷史的怪癖如國中之國(暫時忽略主權國家和民族的複雜性),以及資料的不同粒度(Lucy 的當前居住地被指定為城市,而她的出生地僅在州級別指定)。
|
||||
|
||||
你可以想象擴充套件圖以包括關於 Lucy 和 Alain 或其他人的許多其他事實。例如,你可以使用它來表示他們有哪些食物過敏(透過為每種過敏原引入一個頂點,以及人和過敏原之間的邊來表示過敏),並將過敏原與一組顯示哪些食物含有哪些物質的頂點連結。然後你可以編寫查詢來找出每個人吃什麼是安全的。圖有利於可演化性:當你嚮應用程式新增功能時,圖可以輕鬆擴充套件以適應應用程式資料結構的變化。
|
||||
|
||||
@ -466,9 +466,9 @@ edges 表就像我們在["多對一和多對多關係"](/zh/ch3#sec_datamodels_m
|
||||
|
||||
**Cypher** 是屬性圖的查詢語言,最初為 Neo4j 圖資料庫建立,後來發展為開放標準 **openCypher** [^38]。除了 Neo4j,Cypher 還得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中儲存)等的支援。它以電影《駭客帝國》中的角色命名,與密碼學中的密碼無關 [^39]。
|
||||
|
||||
[示例 3-4](/zh/ch3#fig_cypher_create) 顯示了將[圖 3-6](/zh/ch3#fig_datamodels_graph) 左側部分插入圖資料庫的 Cypher 查詢。圖的其餘部分可以類似地新增。每個頂點都被賦予一個像 `usa` 或 `idaho` 這樣的符號名稱。該名稱不儲存在資料庫中,而僅在查詢內部使用以在頂點之間建立邊,使用箭頭表示法:`(idaho) -[:WITHIN]-> (usa)` 建立一個標記為 `WITHIN` 的邊,`idaho` 作為尾節點,`usa` 作為頭節點。
|
||||
[示例 3-4](/tw/ch3#fig_cypher_create) 顯示了將[圖 3-6](/tw/ch3#fig_datamodels_graph) 左側部分插入圖資料庫的 Cypher 查詢。圖的其餘部分可以類似地新增。每個頂點都被賦予一個像 `usa` 或 `idaho` 這樣的符號名稱。該名稱不儲存在資料庫中,而僅在查詢內部使用以在頂點之間建立邊,使用箭頭表示法:`(idaho) -[:WITHIN]-> (usa)` 建立一個標記為 `WITHIN` 的邊,`idaho` 作為尾節點,`usa` 作為頭節點。
|
||||
|
||||
{{< figure id="fig_cypher_create" title="示例 3-4. [圖 3-6](/zh/ch3#fig_datamodels_graph) 中資料的子集,表示為 Cypher 查詢" class="w-full my-4" >}}
|
||||
{{< figure id="fig_cypher_create" title="示例 3-4. [圖 3-6](/tw/ch3#fig_datamodels_graph) 中資料的子集,表示為 Cypher 查詢" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
CREATE
|
||||
@ -480,9 +480,9 @@ CREATE
|
||||
(lucy) -[:BORN_IN]-> (idaho)
|
||||
```
|
||||
|
||||
當[圖 3-6](/zh/ch3#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,**查詢從美國移民到歐洲的所有人的姓名**。也就是說,找到所有具有 `BORN_IN` 邊指向美國境內某個位置,並且也有 `LIVING_IN` 邊指向歐洲境內某個位置的頂點,並返回每個這些頂點的 `name` 屬性。
|
||||
當[圖 3-6](/tw/ch3#fig_datamodels_graph) 的所有頂點和邊都新增到資料庫後,我們可以開始提出有趣的問題:例如,**查詢從美國移民到歐洲的所有人的姓名**。也就是說,找到所有具有 `BORN_IN` 邊指向美國境內某個位置,並且也有 `LIVING_IN` 邊指向歐洲境內某個位置的頂點,並返回每個這些頂點的 `name` 屬性。
|
||||
|
||||
[示例 3-5](/zh/ch3#fig_cypher_query) 顯示瞭如何在 Cypher 中表達該查詢。相同的箭頭表示法用於 `MATCH` 子句以在圖中查詢模式:`(person) -[:BORN_IN]-> ()` 匹配由標記為 `BORN_IN` 的邊相關的任意兩個頂點。該邊的尾頂點繫結到變數 `person`,頭頂點未命名。
|
||||
[示例 3-5](/tw/ch3#fig_cypher_query) 顯示瞭如何在 Cypher 中表達該查詢。相同的箭頭表示法用於 `MATCH` 子句以在圖中查詢模式:`(person) -[:BORN_IN]-> ()` 匹配由標記為 `BORN_IN` 的邊相關的任意兩個頂點。該邊的尾頂點繫結到變數 `person`,頭頂點未命名。
|
||||
|
||||
{{< figure id="fig_cypher_query" title="示例 3-5. Cypher 查詢查詢從美國移民到歐洲的人" class="w-full my-4" >}}
|
||||
|
||||
@ -508,7 +508,7 @@ RETURN person.name
|
||||
|
||||
### SQL 中的圖查詢 {#id58}
|
||||
|
||||
[示例 3-3](/zh/ch3#fig_graph_sql_schema) 建議圖資料可以在關係資料庫中表示。但是,如果我們將圖資料放入關係結構中,我們是否也可以使用 SQL 查詢它?
|
||||
[示例 3-3](/tw/ch3#fig_graph_sql_schema) 建議圖資料可以在關係資料庫中表示。但是,如果我們將圖資料放入關係結構中,我們是否也可以使用 SQL 查詢它?
|
||||
|
||||
答案是肯定的,但有一些困難。你在圖查詢中遍歷的每條邊實際上都是與 `edges` 表的連線。在關係資料庫中,你通常事先知道查詢中需要哪些連線。另一方面,在圖查詢中,你可能需要遍歷可變數量的邊才能找到你要查詢的頂點——也就是說,連線的數量不是事先固定的。
|
||||
|
||||
@ -516,9 +516,9 @@ RETURN person.name
|
||||
|
||||
在 Cypher 中,`:WITHIN*0..` 非常簡潔地表達了這一事實:它意味著"跟隨 `WITHIN` 邊,零次或多次"。它就像正則表示式中的 `*` 運算子。
|
||||
|
||||
自 SQL:1999 以來,這種查詢中可變長度遍歷路徑的想法可以使用稱為 **遞迴公用表表達式**(`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](/zh/ch3#fig_graph_sql_query) 顯示了相同的查詢——查詢從美國移民到歐洲的人的姓名——使用此技術用 SQL 表達。然而,與 Cypher 相比,語法非常笨拙。
|
||||
自 SQL:1999 以來,這種查詢中可變長度遍歷路徑的想法可以使用稱為 **遞迴公用表表達式**(`WITH RECURSIVE` 語法)的東西來表達。[示例 3-6](/tw/ch3#fig_graph_sql_query) 顯示了相同的查詢——查詢從美國移民到歐洲的人的姓名——使用此技術用 SQL 表達。然而,與 Cypher 相比,語法非常笨拙。
|
||||
|
||||
{{< figure id="fig_graph_sql_query" title="示例 3-6. 與[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查詢,使用遞迴公用表表達式用 SQL 編寫" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_sql_query" title="示例 3-6. 與[示例 3-5](/tw/ch3#fig_cypher_query) 相同的查詢,使用遞迴公用表表達式用 SQL 編寫" class="w-full my-4" >}}
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE
|
||||
@ -590,15 +590,15 @@ Oracle 對遞迴查詢有不同的 SQL 擴充套件,它稱之為 **分層** [^
|
||||
|
||||
三元組的主語相當於圖中的頂點。賓語是兩種事物之一:
|
||||
|
||||
1. 原始資料型別的值,如字串或數字。在這種情況下,三元組的謂語和賓語相當於主語頂點上屬性的鍵和值。使用[圖 3-6](/zh/ch3#fig_datamodels_graph) 中的例子,(*lucy*、*birthYear*、*1989*)就像具有屬性 `{"birthYear": 1989}` 的頂點 `lucy`。
|
||||
1. 原始資料型別的值,如字串或數字。在這種情況下,三元組的謂語和賓語相當於主語頂點上屬性的鍵和值。使用[圖 3-6](/tw/ch3#fig_datamodels_graph) 中的例子,(*lucy*、*birthYear*、*1989*)就像具有屬性 `{"birthYear": 1989}` 的頂點 `lucy`。
|
||||
2. 圖中的另一個頂點。在這種情況下,謂語是圖中的邊,主語是尾頂點,賓語是頭頂點。例如,在(*lucy*、*marriedTo*、*alain*)中,主語和賓語 *lucy* 和 *alain* 都是頂點,謂語 *marriedTo* 是連線它們的邊的標籤。
|
||||
|
||||
> [!NOTE]
|
||||
> 準確地說,提供類似三元組資料模型的資料庫通常需要在每個元組上儲存一些額外的元資料。例如,AWS Neptune 透過向每個三元組新增圖 ID 來使用四元組(4元組)[^46];Datomic 使用 5 元組,透過事務 ID 和表示刪除的布林值擴充套件每個三元組 [^47]。由於這些資料庫保留了上述解釋的基本 **主語-謂語-賓語** 結構,本書仍然稱它們為三元組儲存。
|
||||
|
||||
[示例 3-7](/zh/ch3#fig_graph_n3_triples) 顯示了與[示例 3-4](/zh/ch3#fig_cypher_create) 相同的資料,以稱為 **Turtle** 的格式編寫為三元組,它是 **Notation3**(**N3**)的子集 [^48]。
|
||||
[示例 3-7](/tw/ch3#fig_graph_n3_triples) 顯示了與[示例 3-4](/tw/ch3#fig_cypher_create) 相同的資料,以稱為 **Turtle** 的格式編寫為三元組,它是 **Notation3**(**N3**)的子集 [^48]。
|
||||
|
||||
{{< figure id="fig_graph_n3_triples" title="示例 3-7. [圖 3-6](/zh/ch3#fig_datamodels_graph) 中資料的子集,表示為 Turtle 三元組" class="w-full my-4" >}}
|
||||
{{< figure link="#fig_datamodels_graph" id="fig_graph_n3_triples" title="示例 3-7. 圖 3-6 中資料的子集,表示為 Turtle 三元組" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
@prefix : <urn:example:>.
|
||||
@ -620,9 +620,9 @@ _:namerica :type "continent".
|
||||
|
||||
在這個例子中,圖的頂點寫為 `_:someName`。該名稱在此檔案之外沒有任何意義;它的存在只是因為否則我們不會知道哪些三元組引用同一個頂點。當謂語表示邊時,賓語是頂點,如 `_:idaho :within _:usa`。當謂語是屬性時,賓語是字串文字,如 `_:usa :name "United States"`。
|
||||
|
||||
一遍又一遍地重複相同的主語相當重複,但幸運的是,你可以使用分號對同一主語說多件事。這使得 Turtle 格式相當可讀:參見[示例 3-8](/zh/ch3#fig_graph_n3_shorthand)。
|
||||
一遍又一遍地重複相同的主語相當重複,但幸運的是,你可以使用分號對同一主語說多件事。這使得 Turtle 格式相當可讀:參見[示例 3-8](/tw/ch3#fig_graph_n3_shorthand)。
|
||||
|
||||
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 編寫[示例 3-7](/zh/ch3#fig_graph_n3_triples) 中資料的更簡潔方式" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 編寫[示例 3-7](/tw/ch3#fig_graph_n3_triples) 中資料的更簡潔方式" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
@prefix : <urn:example:>.
|
||||
@ -644,9 +644,9 @@ _:namerica a :Location; :name "North America"; :type "continent".
|
||||
|
||||
#### RDF 資料模型 {#the-rdf-data-model}
|
||||
|
||||
我們在[示例 3-8](/zh/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 語言實際上是在 **資源描述框架**(RDF)[^55] 中編碼資料的一種方式,這是為語義網設計的資料模型。RDF 資料也可以用其他方式編碼,例如(更冗長地)在 XML 中,如[示例 3-9](/zh/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 這樣的工具可以在不同的 RDF 編碼之間自動轉換。
|
||||
我們在[示例 3-8](/tw/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 語言實際上是在 **資源描述框架**(RDF)[^55] 中編碼資料的一種方式,這是為語義網設計的資料模型。RDF 資料也可以用其他方式編碼,例如(更冗長地)在 XML 中,如[示例 3-9](/tw/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 這樣的工具可以在不同的 RDF 編碼之間自動轉換。
|
||||
|
||||
{{< figure id="fig_graph_rdf_xml" title="示例 3-9. [示例 3-8](/zh/ch3#fig_graph_n3_shorthand) 的資料,使用 RDF/XML 語法表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_rdf_xml" title="示例 3-9. [示例 3-8](/tw/ch3#fig_graph_n3_shorthand) 的資料,使用 RDF/XML 語法表示" class="w-full my-4" >}}
|
||||
|
||||
```xml
|
||||
<rdf:RDF xmlns="urn:example:"
|
||||
@ -684,9 +684,9 @@ URL `<http://my-company.com/namespace>` 不一定需要解析為任何內容—
|
||||
|
||||
**SPARQL** 是使用 RDF 資料模型的三元組儲存的查詢語言 [^56]。(它是 **SPARQL 協議和 RDF 查詢語言** 的縮寫,發音為"sparkle"。)它早於 Cypher,由於 Cypher 的模式匹配借鑑自 SPARQL,它們看起來非常相似。
|
||||
|
||||
與以前相同的查詢——查詢從美國搬到歐洲的人——在 SPARQL 中與在 Cypher 中一樣簡潔(參見[示例 3-10](/zh/ch3#fig_sparql_query))。
|
||||
與以前相同的查詢——查詢從美國搬到歐洲的人——在 SPARQL 中與在 Cypher 中一樣簡潔(參見[示例 3-10](/tw/ch3#fig_sparql_query))。
|
||||
|
||||
{{< figure id="fig_sparql_query" title="示例 3-10. 與[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查詢,用 SPARQL 表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_sparql_query" title="示例 3-10. 與[示例 3-5](/tw/ch3#fig_cypher_query) 相同的查詢,用 SPARQL 表示" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
PREFIX : <urn:example:>
|
||||
@ -724,9 +724,9 @@ Datalog 實際上基於關係資料模型而不是圖,但它出現在本書的
|
||||
|
||||
Datalog 資料庫的內容由 **事實** 組成,每個事實對應於關係表中的一行。例如,假設我們有一個包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美國是一個國家的事實可以寫為 `location(2, "United States", "country")`,其中 `2` 是美國的 ID。一般來說,語句 `table(val1, val2, …)` 意味著 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此類推。
|
||||
|
||||
[示例 3-11](/zh/ch3#fig_datalog_triples) 顯示瞭如何在 Datalog 中編寫[圖 3-6](/zh/ch3#fig_datamodels_graph) 左側的資料。圖的邊(`within`、`born_in` 和 `lives_in`)表示為兩列連線表。例如,Lucy 的 ID 為 100,愛達荷州的 ID 為 3,因此關係"Lucy 出生在愛達荷州"表示為 `born_in(100, 3)`。
|
||||
[示例 3-11](/tw/ch3#fig_datalog_triples) 顯示瞭如何在 Datalog 中編寫[圖 3-6](/tw/ch3#fig_datamodels_graph) 左側的資料。圖的邊(`within`、`born_in` 和 `lives_in`)表示為兩列連線表。例如,Lucy 的 ID 為 100,愛達荷州的 ID 為 3,因此關係"Lucy 出生在愛達荷州"表示為 `born_in(100, 3)`。
|
||||
|
||||
{{< figure id="fig_datalog_triples" title="示例 3-11. [圖 3-6](/zh/ch3#fig_datamodels_graph) 中資料的子集,表示為 Datalog 事實" class="w-full my-4" >}}
|
||||
{{< figure id="fig_datalog_triples" title="示例 3-11. [圖 3-6](/tw/ch3#fig_datamodels_graph) 中資料的子集,表示為 Datalog 事實" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
location(1, "North America", "continent").
|
||||
@ -740,9 +740,9 @@ person(100, "Lucy").
|
||||
born_in(100, 3). /* Lucy 出生在愛達荷州 */
|
||||
```
|
||||
|
||||
現在我們已經定義了資料,我們可以編寫與以前相同的查詢,如[示例 3-12](/zh/ch3#fig_datalog_query) 所示。它看起來與 Cypher 或 SPARQL 中的等效查詢有點不同,但不要讓它嚇到你。Datalog 是 Prolog 的子集,如果你學過計算機科學,你可能以前見過這種程式語言。
|
||||
現在我們已經定義了資料,我們可以編寫與以前相同的查詢,如[示例 3-12](/tw/ch3#fig_datalog_query) 所示。它看起來與 Cypher 或 SPARQL 中的等效查詢有點不同,但不要讓它嚇到你。Datalog 是 Prolog 的子集,如果你學過計算機科學,你可能以前見過這種程式語言。
|
||||
|
||||
{{< figure id="fig_datalog_query" title="示例 3-12. 與[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查詢,用 Datalog 表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_datalog_query" title="示例 3-12. 與[示例 3-5](/tw/ch3#fig_cypher_query) 相同的查詢,用 Datalog 表示" class="w-full my-4" >}}
|
||||
|
||||
```sql
|
||||
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 規則 1 */
|
||||
@ -762,11 +762,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 規則 4
|
||||
|
||||
Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小步。我們定義從底層事實派生新虛擬表的 **規則**。這些派生表就像(虛擬)SQL 檢視:它們不儲存在資料庫中,但你可以像查詢包含儲存事實的表一樣查詢它們。
|
||||
|
||||
在[示例 3-12](/zh/ch3#fig_datalog_query) 中,我們定義了三個派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虛擬表的名稱和列由每個規則的 `:-` 符號之前出現的內容定義。例如,`migrated(PName, BornIn, LivingIn)` 是一個具有三列的虛擬表:一個人的姓名、他們出生地的名稱和他們居住地的名稱。
|
||||
在[示例 3-12](/tw/ch3#fig_datalog_query) 中,我們定義了三個派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虛擬表的名稱和列由每個規則的 `:-` 符號之前出現的內容定義。例如,`migrated(PName, BornIn, LivingIn)` 是一個具有三列的虛擬表:一個人的姓名、他們出生地的名稱和他們居住地的名稱。
|
||||
|
||||
虛擬表的內容由規則的 `:-` 符號之後的部分定義,我們在其中嘗試在表中查詢與某種模式匹配的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,變數 `PersonID` 繫結到值 `100`,變數 `PName` 繫結到值 `"Lucy"`。如果系統可以為 `:-` 運算子右側的 **所有** 模式找到匹配,則規則適用。當規則適用時,就好像 `:-` 的左側被新增到資料庫中(變數被它們匹配的值替換)。
|
||||
|
||||
因此,應用規則的一種可能方式是(如[圖 3-7](/zh/ch3#fig_datalog_naive) 所示):
|
||||
因此,應用規則的一種可能方式是(如[圖 3-7](/tw/ch3#fig_datalog_naive) 所示):
|
||||
|
||||
1. `location(1, "North America", "continent")` 存在於資料庫中,因此規則 1 適用。它生成 `within_recursive(1, "North America")`。
|
||||
2. `within(2, 1)` 存在於資料庫中,前一步生成了 `within_recursive(1, "North America")`,因此規則 2 適用。它生成 `within_recursive(2, "North America")`。
|
||||
@ -774,21 +774,21 @@ Cypher 和 SPARQL 直接用 `SELECT` 開始,但 Datalog 一次只邁出一小
|
||||
|
||||
透過重複應用規則 1 和 2,`within_recursive` 虛擬表可以告訴我們資料庫中包含的北美(或任何其他位置)的所有位置。
|
||||
|
||||
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="圖 3-7. 使用[示例 3-12](/zh/ch3#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。" class="w-full my-4" >}}
|
||||
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="圖 3-7. 使用[示例 3-12](/tw/ch3#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。" class="w-full my-4" >}}
|
||||
|
||||
> 圖 3-7. 使用[示例 3-12](/zh/ch3#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。
|
||||
> 圖 3-7. 使用[示例 3-12](/tw/ch3#fig_datalog_query) 中的 Datalog 規則確定愛達荷州在北美。
|
||||
|
||||
現在規則 3 可以找到出生在某個位置 `BornIn` 並住在某個位置 `LivingIn` 的人。規則 4 呼叫規則 3,其中 `BornIn = 'United States'` 和 `LivingIn = 'Europe'`,並僅返回匹配搜尋的人的姓名。透過查詢虛擬 `us_to_europe` 表的內容,Datalog 系統最終得到與早期 Cypher 和 SPARQL 查詢相同的答案。
|
||||
|
||||
與本章討論的其他查詢語言相比,Datalog 方法需要不同的思維方式。它允許逐條規則地構建複雜查詢,一個規則引用其他規則,類似於你將程式碼分解為相互呼叫的函式的方式。就像函式可以遞迴一樣,Datalog 規則也可以呼叫自己,就像[示例 3-12](/zh/ch3#fig_datalog_query) 中的規則 2,它在 Datalog 查詢中啟用圖遍歷。
|
||||
與本章討論的其他查詢語言相比,Datalog 方法需要不同的思維方式。它允許逐條規則地構建複雜查詢,一個規則引用其他規則,類似於你將程式碼分解為相互呼叫的函式的方式。就像函式可以遞迴一樣,Datalog 規則也可以呼叫自己,就像[示例 3-12](/tw/ch3#fig_datalog_query) 中的規則 2,它在 Datalog 查詢中啟用圖遍歷。
|
||||
|
||||
### GraphQL {#id63}
|
||||
|
||||
GraphQL 是一種查詢語言,設計上比本章中看到的其他查詢語言更具限制性。GraphQL 的目的是允許在使用者裝置上執行的客戶端軟體(例如移動應用程式或 JavaScript Web 應用程式前端)請求具有特定結構的 JSON 文件,其中包含呈現其使用者介面所需的欄位。GraphQL 介面允許開發人員快速更改客戶端程式碼中的查詢,而無需更改伺服器端 API。
|
||||
|
||||
GraphQL 的靈活性是有代價的。採用 GraphQL 的組織通常需要工具將 GraphQL 查詢轉換為對內部服務的請求,這些服務通常使用 REST 或 gRPC(參見[第 5 章](/zh/ch5#ch_encoding))。授權、速率限制和效能挑戰是其他問題 [^61]。GraphQL 的查詢語言也受到限制,因為 GraphQL 來自不受信任的來源。該語言不允許任何可能執行成本高昂的內容,否則使用者可以透過執行大量昂貴的查詢對伺服器執行拒絕服務攻擊。特別是,GraphQL 不允許遞迴查詢(與 Cypher、SPARQL、SQL 或 Datalog 不同),並且它不允許任意搜尋條件,例如"查找出生在美國現在住在歐洲的人"(除非服務所有者特別選擇提供此類搜尋功能)。
|
||||
GraphQL 的靈活性是有代價的。採用 GraphQL 的組織通常需要工具將 GraphQL 查詢轉換為對內部服務的請求,這些服務通常使用 REST 或 gRPC(參見[第 5 章](/tw/ch5#ch_encoding))。授權、速率限制和效能挑戰是其他問題 [^61]。GraphQL 的查詢語言也受到限制,因為 GraphQL 來自不受信任的來源。該語言不允許任何可能執行成本高昂的內容,否則使用者可以透過執行大量昂貴的查詢對伺服器執行拒絕服務攻擊。特別是,GraphQL 不允許遞迴查詢(與 Cypher、SPARQL、SQL 或 Datalog 不同),並且它不允許任意搜尋條件,例如"查找出生在美國現在住在歐洲的人"(除非服務所有者特別選擇提供此類搜尋功能)。
|
||||
|
||||
儘管如此,GraphQL 還是很有用的。[示例 3-13](/zh/ch3#fig_graphql_query) 顯示瞭如何使用 GraphQL 實現群聊應用程式(如 Discord 或 Slack)。查詢請求使用者有權訪問的所有頻道,包括頻道名稱和每個頻道中最近的 50 條訊息。對於每條訊息,它請求時間戳、訊息內容以及訊息傳送者的姓名和頭像 URL。此外,如果訊息是對另一條訊息的回覆,查詢還請求傳送者姓名和它回覆的訊息內容(可能以較小的字型呈現在回覆上方,以提供一些上下文)。
|
||||
儘管如此,GraphQL 還是很有用的。[示例 3-13](/tw/ch3#fig_graphql_query) 顯示瞭如何使用 GraphQL 實現群聊應用程式(如 Discord 或 Slack)。查詢請求使用者有權訪問的所有頻道,包括頻道名稱和每個頻道中最近的 50 條訊息。對於每條訊息,它請求時間戳、訊息內容以及訊息傳送者的姓名和頭像 URL。此外,如果訊息是對另一條訊息的回覆,查詢還請求傳送者姓名和它回覆的訊息內容(可能以較小的字型呈現在回覆上方,以提供一些上下文)。
|
||||
|
||||
{{< figure id="fig_graphql_query" title="示例 3-13. 群聊應用程式的示例 GraphQL 查詢" class="w-full my-4" >}}
|
||||
|
||||
@ -814,9 +814,9 @@ query ChatApp {
|
||||
}
|
||||
```
|
||||
|
||||
[示例 3-14](/zh/ch3#fig_graphql_response) 顯示了對[示例 3-13](/zh/ch3#fig_graphql_query) 中查詢的響應可能是什麼樣子。響應是一個反映查詢結構的 JSON 文件:它包含恰好請求的那些屬性,不多也不少。這種方法的優點是伺服器不需要知道客戶端需要哪些屬性來呈現使用者介面;相反,客戶端可以簡單地請求它需要的內容。例如,此查詢不為 `replyTo` 訊息的傳送者請求頭像 URL,但如果使用者介面更改為新增該頭像,客戶端很容易將所需的 `imageUrl` 屬性新增到查詢中,而無需更改伺服器。
|
||||
[示例 3-14](/tw/ch3#fig_graphql_response) 顯示了對[示例 3-13](/tw/ch3#fig_graphql_query) 中查詢的響應可能是什麼樣子。響應是一個反映查詢結構的 JSON 文件:它包含恰好請求的那些屬性,不多也不少。這種方法的優點是伺服器不需要知道客戶端需要哪些屬性來呈現使用者介面;相反,客戶端可以簡單地請求它需要的內容。例如,此查詢不為 `replyTo` 訊息的傳送者請求頭像 URL,但如果使用者介面更改為新增該頭像,客戶端很容易將所需的 `imageUrl` 屬性新增到查詢中,而無需更改伺服器。
|
||||
|
||||
{{< figure id="fig_graphql_response" title="示例 3-14. 對[示例 3-13](/zh/ch3#fig_graphql_query) 中查詢的可能響應" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graphql_response" title="示例 3-14. 對[示例 3-13](/tw/ch3#fig_graphql_query) 中查詢的可能響應" class="w-full my-4" >}}
|
||||
|
||||
```json
|
||||
{
|
||||
@ -843,9 +843,9 @@ query ChatApp {
|
||||
...
|
||||
```
|
||||
|
||||
在[示例 3-14](/zh/ch3#fig_graphql_response) 中,訊息傳送者的姓名和影像 URL 直接嵌入在訊息物件中。如果同一使用者傳送多條訊息,此資訊會在每條訊息上重複。原則上,可以減少這種重複,但 GraphQL 做出了設計選擇,接受更大的響應大小,以便根據資料更簡單地呈現使用者介面。
|
||||
在[示例 3-14](/tw/ch3#fig_graphql_response) 中,訊息傳送者的姓名和影像 URL 直接嵌入在訊息物件中。如果同一使用者傳送多條訊息,此資訊會在每條訊息上重複。原則上,可以減少這種重複,但 GraphQL 做出了設計選擇,接受更大的響應大小,以便根據資料更簡單地呈現使用者介面。
|
||||
|
||||
`replyTo` 欄位類似:在[示例 3-14](/zh/ch3#fig_graphql_response) 中,第二條訊息是對第一條訊息的回覆,內容("Hey!…")和傳送者 Aaliyah 在 `replyTo` 下重複。可以改為返回被回覆訊息的 ID,但如果該 ID 不在返回的最近 50 條訊息中,客戶端將不得不向伺服器發出額外的請求。複製內容使處理資料變得更加簡單。
|
||||
`replyTo` 欄位類似:在[示例 3-14](/tw/ch3#fig_graphql_response) 中,第二條訊息是對第一條訊息的回覆,內容("Hey!…")和傳送者 Aaliyah 在 `replyTo` 下重複。可以改為返回被回覆訊息的 ID,但如果該 ID 不在返回的最近 50 條訊息中,客戶端將不得不向伺服器發出額外的請求。複製內容使處理資料變得更加簡單。
|
||||
|
||||
伺服器的資料庫可以以更規範化的形式儲存資料,並執行必要的連線來處理查詢。例如,伺服器可能儲存訊息以及傳送者的使用者 ID 和它回覆的訊息的 ID;當它收到像上面這樣的查詢時,伺服器會解析這些 ID 以找到它們引用的記錄。但是,客戶端只能要求伺服器執行 GraphQL 模式中明確提供的連線。
|
||||
|
||||
@ -856,23 +856,23 @@ query ChatApp {
|
||||
|
||||
在我們迄今為止討論的所有資料模型中,資料的查詢形式與寫入形式相同——無論是 JSON 文件、表中的行,還是圖中的頂點和邊。然而,在複雜的應用中,有時很難找到一種能夠滿足所有不同查詢和展示需求的單一資料表示。在這種情況下,以一種形式寫入資料,然後從中派生出針對不同型別讀取最佳化的多種表示,可能會很有益處。
|
||||
|
||||
我們之前在[「記錄系統和派生資料」](/zh/ch1#sec_introduction_derived)中看到過這個想法,ETL(參見[「資料倉庫」](/zh/ch1#sec_introduction_dwh))就是這種派生過程的一個例子。現在我們將進一步深入這個想法。如果我們無論如何都要從一種資料表示派生出另一種,我們可以選擇分別針對寫入和讀取最佳化的不同表示。如果你只想最佳化寫入,而不關心高效查詢,你會如何建模你的資料?
|
||||
我們之前在[「記錄系統和派生資料」](/tw/ch1#sec_introduction_derived)中看到過這個想法,ETL(參見[「資料倉庫」](/tw/ch1#sec_introduction_dwh))就是這種派生過程的一個例子。現在我們將進一步深入這個想法。如果我們無論如何都要從一種資料表示派生出另一種,我們可以選擇分別針對寫入和讀取最佳化的不同表示。如果你只想最佳化寫入,而不關心高效查詢,你會如何建模你的資料?
|
||||
|
||||
也許寫入資料最簡單、最快、最具表現力的方式是**事件日誌**:每次你想寫入一些資料時,你將其編碼為自包含的字串(可能是 JSON),包含時間戳,然後將其追加到事件序列中。這個日誌中的事件是**不可變的**:你永遠不會更改或刪除它們,你只會向日志追加更多事件(這些事件可能會取代之前的事件)。一個事件可以包含任意屬性。
|
||||
|
||||
[圖 3-8](/zh/ch3#fig_event_sourcing) 顯示了一個可能來自會議管理系統的示例。會議可能是一個複雜的業務領域:不僅個人參會者可以註冊並透過信用卡支付,公司也可以批次訂購座位,透過發票支付,然後稍後將座位分配給個人。一定數量的座位可能會為演講者、贊助商、志願者助手等預留。預訂也可能被取消,與此同時,會議組織者可能透過將活動移至不同的房間來改變活動的容量。在所有這些情況下,簡單地計算可用座位數就成為一個具有挑戰性的查詢。
|
||||
[圖 3-8](/tw/ch3#fig_event_sourcing) 顯示了一個可能來自會議管理系統的示例。會議可能是一個複雜的業務領域:不僅個人參會者可以註冊並透過信用卡支付,公司也可以批次訂購座位,透過發票支付,然後稍後將座位分配給個人。一定數量的座位可能會為演講者、贊助商、志願者助手等預留。預訂也可能被取消,與此同時,會議組織者可能透過將活動移至不同的房間來改變活動的容量。在所有這些情況下,簡單地計算可用座位數就成為一個具有挑戰性的查詢。
|
||||
|
||||
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="圖 3-8. 使用不可變事件日誌作為事實來源,並從中派生物化檢視。" class="w-full my-4" >}}
|
||||
|
||||
在[圖 3-8](/zh/ch3#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參會者進行和取消註冊)首先被儲存為事件。每當一個事件被追加到日誌時,幾個**物化檢視**(也稱為**投影**或**讀模型**)也會被更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為製作參會者徽章的印表機生成檔案。
|
||||
在[圖 3-8](/tw/ch3#fig_event_sourcing) 中,會議狀態的每個變化(例如組織者開放註冊,或參會者進行和取消註冊)首先被儲存為事件。每當一個事件被追加到日誌時,幾個**物化檢視**(也稱為**投影**或**讀模型**)也會被更新以反映該事件的影響。在會議示例中,可能有一個物化檢視收集與每個預訂狀態相關的所有資訊,另一個為會議組織者的儀表板計算圖表,第三個為製作參會者徽章的印表機生成檔案。
|
||||
|
||||
使用事件作為事實來源,並將每個狀態變化表示為事件的想法被稱為**事件溯源** [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則被稱為**命令查詢責任分離(CQRS)** [^64]。這些術語起源於領域驅動設計(DDD)社群,儘管類似的想法已經存在很長時間了,例如在**狀態機複製**中(參見[「使用共享日誌」](/zh/ch10#sec_consistency_smr))。
|
||||
使用事件作為事實來源,並將每個狀態變化表示為事件的想法被稱為**事件溯源** [^62] [^63]。維護單獨的讀最佳化表示並從寫最佳化表示派生它們的原則被稱為**命令查詢責任分離(CQRS)** [^64]。這些術語起源於領域驅動設計(DDD)社群,儘管類似的想法已經存在很長時間了,例如在**狀態機複製**中(參見[「使用共享日誌」](/tw/ch10#sec_consistency_smr))。
|
||||
|
||||
當用戶的請求進來時,它被稱為**命令**,首先需要被驗證。只有當命令被執行並被確定為有效(例如,有足夠的可用座位來滿足請求的預訂)後,它才成為事實,相應的事件被新增到日誌中。因此,事件日誌應該只包含有效事件,構建物化檢視的事件日誌消費者不允許拒絕事件。
|
||||
|
||||
在以事件溯源風格建模資料時,建議你使用過去時命名事件(例如,"座位已被預訂"),因為事件是記錄過去發生的事實。即使使用者後來決定更改或取消,他們曾經持有預訂的事實仍然是真實的,更改或取消是稍後新增的單獨事件。
|
||||
|
||||
事件溯源與[「星型和雪花型:分析模式」](/zh/ch3#sec_datamodels_analytics)中討論的星型模式事實表之間的相似之處在於,兩者都是過去發生的事件集合。然而,事實表中的行都具有相同的列集,而在事件溯源中可能有許多不同的事件型別,每種都有不同的屬性。此外,事實表是無序集合,而在事件溯源中事件的順序很重要:如果先進行預訂然後取消,以錯誤的順序處理這些事件是沒有意義的。
|
||||
事件溯源與[「星型和雪花型:分析模式」](/tw/ch3#sec_datamodels_analytics)中討論的星型模式事實表之間的相似之處在於,兩者都是過去發生的事件集合。然而,事實表中的行都具有相同的列集,而在事件溯源中可能有許多不同的事件型別,每種都有不同的屬性。此外,事實表是無序集合,而在事件溯源中事件的順序很重要:如果先進行預訂然後取消,以錯誤的順序處理這些事件是沒有意義的。
|
||||
|
||||
事件溯源和 CQRS 有幾個優點:
|
||||
|
||||
@ -880,7 +880,7 @@ query ChatApp {
|
||||
* 事件溯源的一個關鍵原則是物化檢視以可重現的方式從事件日誌派生:你應該始終能夠刪除物化檢視並透過使用相同的程式碼以相同的順序處理相同的事件來重新計算它們。如果檢視維護程式碼中有錯誤,你可以刪除檢視並使用新程式碼重新計算它。找到錯誤也更容易,因為你可以根據需要多次重新執行檢視維護程式碼並檢查其行為。
|
||||
* 你可以擁有多個針對應用程式所需特定查詢最佳化的物化檢視。它們可以儲存在與事件相同的資料庫中或不同的資料庫中,具體取決於你的需求。它們可以使用任何資料模型,並且可以為快速讀取進行反規範化。你甚至可以只將檢視儲存在記憶體中而避免持久化,只要在服務重啟時從事件日誌重新計算檢視是可以接受的。
|
||||
* 如果你決定以新的方式呈現現有資訊,很容易從現有事件日誌構建新的物化檢視。你還可以透過新增新型別的事件或向現有事件型別新增新屬性(任何舊事件保持不變)來演進系統以支援新功能。你還可以將新行為連結到現有事件(例如,當會議參會者取消時,他們的座位可以提供給等候名單上的下一個人)。
|
||||
* 如果事件被錯誤地寫入,你可以再次刪除它,然後你可以在沒有刪除的事件的情況下重建檢視。另一方面,在直接更新和刪除資料的資料庫中,已提交的事務通常很難逆轉。因此,事件溯源可以減少系統中不可逆操作的數量,使其更容易更改(參見[「可演化性:讓變更變得容易」](/zh/ch2#sec_introduction_evolvability))。
|
||||
* 如果事件被錯誤地寫入,你可以再次刪除它,然後你可以在沒有刪除的事件的情況下重建檢視。另一方面,在直接更新和刪除資料的資料庫中,已提交的事務通常很難逆轉。因此,事件溯源可以減少系統中不可逆操作的數量,使其更容易更改(參見[「可演化性:讓變更變得容易」](/tw/ch2#sec_introduction_evolvability))。
|
||||
* 事件日誌還可以作為系統中發生的所有事情的審計日誌,這在需要此類可審計性的受監管行業中很有價值。
|
||||
|
||||
然而,事件溯源和 CQRS 也有缺點:
|
||||
@ -891,12 +891,12 @@ query ChatApp {
|
||||
|
||||
你可以在任何資料庫之上實現事件溯源,但也有一些專門設計用於支援此模式的系統,例如 EventStoreDB、MartenDB(基於 PostgreSQL)和 Axon Framework。你還可以使用訊息代理(如 Apache Kafka)來儲存事件日誌,流處理器可以保持物化檢視是最新的;我們將在 [Link to Come] 中回到這些主題。
|
||||
|
||||
唯一重要的要求是事件儲存系統必須保證所有物化檢視以與它們在日誌中出現的完全相同的順序處理事件;正如我們將在[第 10 章](/zh/ch10#ch_consistency)中看到的,這在分散式系統中並不總是容易實現的。
|
||||
唯一重要的要求是事件儲存系統必須保證所有物化檢視以與它們在日誌中出現的完全相同的順序處理事件;正如我們將在[第 10 章](/tw/ch10#ch_consistency)中看到的,這在分散式系統中並不總是容易實現的。
|
||||
|
||||
|
||||
## 資料框、矩陣與陣列 {#sec_datamodels_dataframes}
|
||||
|
||||
本章到目前為止我們看到的資料模型通常既用於事務處理也用於分析目的(參見[「分析型與事務型系統」](/zh/ch1#sec_introduction_analytics))。還有一些資料模型你可能會在分析或科學背景下遇到,但很少出現在 OLTP 系統中:資料框和多維數值陣列(如矩陣)。
|
||||
本章到目前為止我們看到的資料模型通常既用於事務處理也用於分析目的(參見[「分析型與事務型系統」](/tw/ch1#sec_introduction_analytics))。還有一些資料模型你可能會在分析或科學背景下遇到,但很少出現在 OLTP 系統中:資料框和多維數值陣列(如矩陣)。
|
||||
|
||||
資料框是 R 語言、Python 的 Pandas 庫、Apache Spark、ArcticDB、Dask 和其他系統支援的資料模型。它們是資料科學家準備資料以訓練機器學習模型的流行工具,但它們也廣泛用於資料探索、統計資料分析、資料視覺化和類似目的。
|
||||
|
||||
@ -906,16 +906,16 @@ query ChatApp {
|
||||
|
||||
資料框 API 還提供了遠遠超出關係資料庫所提供的各種操作,資料模型的使用方式通常與典型的關係資料建模截然不同 [^65]。例如,資料框的一個常見用途是將資料從類關係表示轉換為矩陣或多維陣列表示,這是許多機器學習演算法期望的輸入形式。
|
||||
|
||||
[圖 3-9](/zh/ch3#fig_dataframe_to_matrix) 顯示了這種轉換的簡單示例。左邊我們有一個不同使用者如何對各種電影進行評分(1 到 5 分)的關係表,右邊資料已被轉換為矩陣,其中每列是一部電影,每行是一個使用者(類似於電子表格中的**透視表**)。矩陣是**稀疏的**,這意味著許多使用者-電影組合沒有資料,但這沒關係。這個矩陣可能有數千列,因此不太適合關係資料庫,但資料框和提供稀疏陣列的庫(如 Python 的 NumPy)可以輕鬆處理這樣的資料。
|
||||
[圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 顯示了這種轉換的簡單示例。左邊我們有一個不同使用者如何對各種電影進行評分(1 到 5 分)的關係表,右邊資料已被轉換為矩陣,其中每列是一部電影,每行是一個使用者(類似於電子表格中的**透視表**)。矩陣是**稀疏的**,這意味著許多使用者-電影組合沒有資料,但這沒關係。這個矩陣可能有數千列,因此不太適合關係資料庫,但資料框和提供稀疏陣列的庫(如 Python 的 NumPy)可以輕鬆處理這樣的資料。
|
||||
|
||||
{{< figure src="/fig/ddia_0309.png" id="fig_dataframe_to_matrix" title="圖 3-9. 將電影評分的關係資料庫轉換為矩陣表示。" class="w-full my-4" >}}
|
||||
|
||||
矩陣只能包含數字,各種技術用於將非數值資料轉換為矩陣中的數字。例如:
|
||||
|
||||
* 日期(在[圖 3-9](/zh/ch3#fig_dataframe_to_matrix) 的示例矩陣中被省略)可以縮放為某個合適範圍內的浮點數。
|
||||
* 日期(在[圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 的示例矩陣中被省略)可以縮放為某個合適範圍內的浮點數。
|
||||
* 對於只能取一小組固定值之一的列(例如,電影資料庫中電影的型別),通常使用**獨熱編碼**:我們為每個可能的值建立一列(一個用於"喜劇",一個用於"戲劇",一個用於"恐怖"等),對於代表電影的每一行,我們在與該電影型別對應的列中放置 1,在所有其他列中放置 0。這種表示也很容易推廣到適合多種型別的電影。
|
||||
|
||||
一旦資料以數字矩陣的形式存在,它就適合進行線性代數運算,這構成了許多機器學習演算法的基礎。例如,[圖 3-9](/zh/ch3#fig_dataframe_to_matrix) 中的資料可能是推薦使用者可能喜歡的電影的系統的一部分。資料框足夠靈活,允許資料從關係形式逐漸演變為矩陣表示,同時讓資料科學家控制最適合實現資料分析或模型訓練過程目標的表示。
|
||||
一旦資料以數字矩陣的形式存在,它就適合進行線性代數運算,這構成了許多機器學習演算法的基礎。例如,[圖 3-9](/tw/ch3#fig_dataframe_to_matrix) 中的資料可能是推薦使用者可能喜歡的電影的系統的一部分。資料框足夠靈活,允許資料從關係形式逐漸演變為矩陣表示,同時讓資料科學家控制最適合實現資料分析或模型訓練過程目標的表示。
|
||||
|
||||
還有一些資料庫(如 TileDB [^66])專門儲存大型多維數值陣列;它們被稱為**陣列資料庫**,最常用於科學資料集,如地理空間測量(規則間隔網格上的柵格資料)、醫學成像或天文望遠鏡的觀測 [^67]。資料框也在金融行業用於表示**時間序列資料**,如資產價格和交易隨時間的變化 [^68]。
|
||||
|
||||
@ -933,7 +933,7 @@ query ChatApp {
|
||||
|
||||
因此,為每個資料模型開發了各種專門的資料庫,提供針對特定模型最佳化的查詢語言和儲存引擎。然而,資料庫也有一種趨勢是透過新增對其他資料模型的支援來擴充套件到相鄰的領域:例如,關係資料庫已經以 JSON 列的形式添加了對文件資料的支援,文件資料庫已經添加了類似關係的連線,SQL 中對圖資料的支援也在逐漸改善。
|
||||
|
||||
我們討論的另一個模型是**事件溯源**,它將資料表示為不可變事件的僅追加日誌,這對於在複雜業務領域中建模活動可能是有利的。僅追加日誌很適合寫入資料(正如我們將在[第 4 章](/zh/ch4#ch_storage)中看到的);為了支援高效查詢,事件日誌透過 CQRS 轉換為讀最佳化的物化檢視。
|
||||
我們討論的另一個模型是**事件溯源**,它將資料表示為不可變事件的僅追加日誌,這對於在複雜業務領域中建模活動可能是有利的。僅追加日誌很適合寫入資料(正如我們將在[第 4 章](/tw/ch4#ch_storage)中看到的);為了支援高效查詢,事件日誌透過 CQRS 轉換為讀最佳化的物化檢視。
|
||||
|
||||
非關係資料模型的一個共同點是,它們通常不會對儲存的資料強制執行模式,這可以使應用程式更容易適應不斷變化的需求。然而,你的應用程式很可能仍然假設資料具有某種結構;這只是一個問題,即模式是顯式的(在寫入時強制執行)還是隱式的(在讀取時假設)。
|
||||
|
||||
@ -941,7 +941,7 @@ query ChatApp {
|
||||
|
||||
* 研究基因組資料的研究人員經常需要執行**序列相似性搜尋**,這意味著取一個很長的字串(代表 DNA 分子)並將其與大型字串資料庫進行匹配,這些字串相似但不相同。這裡描述的資料庫都不能處理這種用法,這就是為什麼研究人員編寫了專門的基因組資料庫軟體,如 GenBank [^69]。
|
||||
* 許多金融系統使用帶有複式記賬的**賬本**作為其資料模型。這種型別的資料可以在關係資料庫中表示,但也有像 TigerBeetle 這樣專門針對這種資料模型的資料庫。加密貨幣和區塊鏈通常基於分散式賬本,它們也將價值轉移內建到其資料模型中。
|
||||
* **全文搜尋**可以說是一種經常與資料庫一起使用的資料模型。資訊檢索是一個大型的專門主題,我們不會在本書中詳細介紹,但我們將在[「全文搜尋」](/zh/ch4#sec_storage_full_text)中涉及搜尋索引和向量搜尋。
|
||||
* **全文搜尋**可以說是一種經常與資料庫一起使用的資料模型。資訊檢索是一個大型的專門主題,我們不會在本書中詳細介紹,但我們將在[「全文搜尋」](/tw/ch4#sec_storage_full_text)中涉及搜尋索引和向量搜尋。
|
||||
|
||||
我們現在必須就此打住。在下一章中,我們將討論在**實現**本章描述的資料模型時涉及的一些權衡。
|
||||
|
||||
|
@ -687,9 +687,9 @@ Lamport 时间戳擅长捕获事物发生的顺序,但它们有一些限制:
|
||||
|
||||
像 ZooKeeper 和 etcd 这样的协调服务也是建立在共识算法之上的。它们提供锁、租约、故障检测和变更通知功能,这些功能对于管理分布式应用程序的状态很有用。如果你发现自己想要做那些可以归约为共识的事情之一,并且你希望它是容错的,建议使用协调服务。它不会保证你做对,但它可能会有所帮助。
|
||||
|
||||
共识算法是复杂而微妙的,但它们得到了自 1980 年代以来发展起来的丰富理论体系的支持。这个理论使得构建能够容忍我们在[第 9 章](/zh/ch9#ch_distributed)中讨论的所有故障的系统成为可能,同时仍然确保你的数据不会损坏。这是一个了不起的成就,本章末尾的参考文献展示了这项工作的一些亮点。
|
||||
共识算法是复杂而微妙的,但它们得到了自 1980 年代以来发展起来的丰富理论体系的支持。这个理论使得构建能够容忍我们在[第 9 章](/ch9#ch_distributed)中讨论的所有故障的系统成为可能,同时仍然确保你的数据不会损坏。这是一个了不起的成就,本章末尾的参考文献展示了这项工作的一些亮点。
|
||||
|
||||
然而,共识并不总是正确的工具:在某些系统中,不需要它提供的强一致性属性,使用较弱的一致性以获得更高的可用性和更好的性能会更好。在这些情况下,通常使用无领导者或多领导者复制,这是我们之前在[第 6 章](/zh/ch6#ch_replication)中讨论过的。我们在本章中讨论的逻辑时钟在那种情况下是有帮助的。
|
||||
然而,共识并不总是正确的工具:在某些系统中,不需要它提供的强一致性属性,使用较弱的一致性以获得更高的可用性和更好的性能会更好。在这些情况下,通常使用无领导者或多领导者复制,这是我们之前在[第 6 章](/ch6#ch_replication)中讨论过的。我们在本章中讨论的逻辑时钟在那种情况下是有帮助的。
|
||||
|
||||
### 参考文献
|
||||
|
||||
|
@ -9,7 +9,7 @@ breadcrumbs: false
|
||||
当前页面来自本书第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 带有太强个人色彩的系统无法成功。当最初的设计完成并且相对稳定时,不同的人们以自己的方式进行测试,真正的考验才开始。
|
||||
>
|
||||
@ -21,17 +21,14 @@ breadcrumbs: false
|
||||
|
||||
Web 和越来越多的基于 HTTP/REST 的 API 使交互的请求 / 响应风格变得如此普遍,以至于很容易将其视为理所当然。但我们应该记住,这不是构建系统的唯一方式,其他方法也有其优点。我们来看看三种不同类型的系统:
|
||||
|
||||
* 服务(在线系统)
|
||||
服务(在线系统)
|
||||
: 服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。
|
||||
|
||||
服务等待客户的请求或指令到达。每收到一个,服务会试图尽快处理它,并发回一个响应。响应时间通常是服务性能的主要衡量指标,可用性通常非常重要(如果客户端无法访问服务,用户可能会收到错误消息)。
|
||||
批处理系统(离线系统)
|
||||
: 一个批处理系统有大量的输入数据,跑一个 **作业(job)** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
|
||||
|
||||
* 批处理系统(离线系统)
|
||||
|
||||
一个批处理系统有大量的输入数据,跑一个 **作业(job)** 来处理它,并生成一些输出数据,这往往需要一段时间(从几分钟到几天),所以通常不会有用户等待作业完成。相反,批量作业通常会定期运行(例如,每天一次)。批处理作业的主要性能衡量标准通常是吞吐量(处理特定大小的输入所需的时间)。本章中讨论的就是批处理。
|
||||
|
||||
* 流处理系统(准实时系统)
|
||||
|
||||
流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时(near-real-time)** 或 **准在线(nearline)** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十二章](/ch12) 讨论它。
|
||||
流处理系统(准实时系统)
|
||||
: 流处理介于在线和离线(批处理)之间,所以有时候被称为 **准实时(near-real-time)** 或 **准在线(nearline)** 处理。像批处理系统一样,流处理消费输入并产生输出(并不需要响应请求)。但是,流式作业在事件发生后不久就会对事件进行操作,而批处理作业则需等待固定的一组输入数据。这种差异使流处理系统比起批处理系统具有更低的延迟。由于流处理基于批处理,我们将在 [第十二章](/ch12) 讨论它。
|
||||
|
||||
正如我们将在本章中看到的那样,批处理是构建可靠、可伸缩和可维护应用程序的重要组成部分。例如,2004 年发布的批处理算法 Map-Reduce(可能被过分热情地)被称为 “造就 Google 大规模可伸缩性的算法”【2】。随后在各种开源数据系统中得到应用,包括 Hadoop、CouchDB 和 MongoDB。
|
||||
|
||||
@ -54,7 +51,7 @@ 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"
|
||||
```
|
||||
@ -84,7 +81,7 @@ cat /var/log/nginx/access.log | #1
|
||||
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
|
||||
@ -229,13 +226,11 @@ MapReduce 是一个编程框架,你可以使用它编写代码来处理 HDFS
|
||||
|
||||
要创建 MapReduce 作业,你需要实现两个回调函数,Mapper 和 Reducer,其行为如下(请参阅 “[MapReduce 查询](/ch2#MapReduce查询)”):
|
||||
|
||||
* Mapper
|
||||
Mapper
|
||||
: Mapper 会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括 None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
|
||||
Mapper 会在每条输入记录上调用一次,其工作是从输入记录中提取键值。对于每个输入,它可以生成任意数量的键值对(包括 None)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。
|
||||
|
||||
* Reducer
|
||||
|
||||
MapReduce 框架拉取由 Mapper 生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用 Reducer。Reducer 可以产生输出记录(例如相同 URL 的出现次数)。
|
||||
Reducer
|
||||
: MapReduce 框架拉取由 Mapper 生成的键值对,收集属于同一个键的所有值,并在这组值上迭代调用 Reducer。Reducer 可以产生输出记录(例如相同 URL 的出现次数)。
|
||||
|
||||
在 Web 服务器日志的例子中,我们在第 5 步中有第二个 `sort` 命令,它按请求数对 URL 进行排序。在 MapReduce 中,如果你需要第二个排序阶段,则可以通过编写第二个 MapReduce 作业并将第一个作业的输出用作第二个作业的输入来实现它。这样看来,Mapper 的作用是将数据放入一个适合排序的表单中,并且 Reducer 的作用是处理已排序的数据。
|
||||
|
||||
@ -245,11 +240,11 @@ MapReduce 与 Unix 命令管道的主要区别在于,MapReduce 可以在多台
|
||||
|
||||
在分布式计算中可以使用标准的 Unix 工具作为 Mapper 和 Reducer【25】,但更常见的是,它们被实现为传统编程语言的函数。在 Hadoop MapReduce 中,Mapper 和 Reducer 都是实现特定接口的 Java 类。在 MongoDB 和 CouchDB 中,Mapper 和 Reducer 都是 JavaScript 函数(请参阅 “[MapReduce 查询](/ch2#MapReduce查询)”)。
|
||||
|
||||
[图 10-1](/img/fig10-1.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/img/fig10-1.png) 中的 m1,m2 和 m3 标记)。
|
||||
[图 10-1](/fig/ddia_1101.png) 显示了 Hadoop MapReduce 作业中的数据流。其并行化基于分区(请参阅 [第六章](/ch6)):作业的输入通常是 HDFS 中的一个目录,输入目录中的每个文件或文件块都被认为是一个单独的分区,可以单独处理 map 任务([图 10-1](/fig/ddia_1101.png) 中的 m1,m2 和 m3 标记)。
|
||||
|
||||
每个输入文件的大小通常是数百兆字节。MapReduce 调度器(图中未显示)试图在其中一台存储输入文件副本的机器上运行每个 Mapper,只要该机器有足够的备用 RAM 和 CPU 资源来运行 Mapper 任务【26】。这个原则被称为 **将计算放在数据附近**【27】:它节省了通过网络复制输入文件的开销,减少网络负载并增加局部性。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 10-1 具有三个 Mapper 和三个 Reducer 的 MapReduce 任务**
|
||||
|
||||
@ -295,9 +290,9 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
|
||||
|
||||
#### 示例:用户活动事件分析
|
||||
|
||||
[图 10-2](/img/fig10-2.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events,或 **点击流数据**,即 clickstream data),右侧是用户数据库。你可以将此示例看作是星型模式的一部分(请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
|
||||
[图 10-2](/fig/ddia_1102.png) 给出了一个批处理作业中连接的典型例子。左侧是事件日志,描述登录用户在网站上做的事情(称为 **活动事件**,即 activity events,或 **点击流数据**,即 clickstream data),右侧是用户数据库。你可以将此示例看作是星型模式的一部分(请参阅 “[星型和雪花型:分析的模式](/ch3#星型和雪花型:分析的模式)”):事件日志是事实表,用户数据库是其中的一个维度。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 10-2 用户行为日志与用户档案的连接**
|
||||
|
||||
@ -311,9 +306,9 @@ Hadoop 的各种高级工具(如 Pig 【30】、Hive 【31】、Cascading 【3
|
||||
|
||||
#### 排序合并连接
|
||||
|
||||
回想一下,Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/img/fig10-2.png) 的情况下,这个键就是用户 ID:一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/img/fig10-3.png) 所示。
|
||||
回想一下,Mapper 的目的是从每个输入记录中提取一对键值。在 [图 10-2](/fig/ddia_1102.png) 的情况下,这个键就是用户 ID:一组 Mapper 会扫过活动事件(提取用户 ID 作为键,活动事件作为值),而另一组 Mapper 将会扫过用户数据库(提取用户 ID 作为键,用户的出生日期作为值)。这个过程如 [图 10-3](/fig/ddia_1103.png) 所示。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 10-3 在用户 ID 上进行的 Reduce 端连接。如果输入数据集分区为多个文件,则每个分区都会被多个 Mapper 并行处理**
|
||||
|
||||
@ -372,11 +367,11 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
|
||||
|
||||
适用于执行 Map 端连接的最简单场景是大数据集与小数据集连接的情况。要点在于小数据集需要足够小,以便可以将其全部加载到每个 Mapper 的内存中。
|
||||
|
||||
例如,假设在 [图 10-2](/img/fig10-2.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后,Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
|
||||
例如,假设在 [图 10-2](/fig/ddia_1102.png) 的情况下,用户数据库小到足以放进内存中。在这种情况下,当 Mapper 启动时,它可以首先将用户数据库从分布式文件系统读取到内存中的散列表中。完成此操作后,Mapper 可以扫描用户活动事件,并简单地在散列表中查找每个事件的用户 ID [^vi]。
|
||||
|
||||
[^vi]: 这个例子假定散列表中的每个键只有一个条目,这对用户数据库(用户 ID 唯一标识一个用户)可能是正确的。通常,哈希表可能需要包含具有相同键的多个条目,而连接运算符将对每个键输出所有的匹配。
|
||||
|
||||
参与连接的较大输入的每个文件块各有一个 Mapper(在 [图 10-2](/img/fig10-2.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
|
||||
参与连接的较大输入的每个文件块各有一个 Mapper(在 [图 10-2](/fig/ddia_1102.png) 的例子中活动事件是较大的输入)。每个 Mapper 都会将较小输入整个加载到内存中。
|
||||
|
||||
这种简单有效的算法被称为 **广播散列连接(broadcast hash join)**:**广播** 一词反映了这样一个事实,每个连接较大输入端分区的 Mapper 都会将较小输入端数据集整个读入内存中(所以较小输入实际上 “广播” 到较大数据的所有分区上),**散列** 一词反映了它使用一个散列表。Pig(名为 “**复制链接(replicated join)**”),Hive(“**MapJoin**”),Cascading 和 Crunch 支持这种连接。它也被诸如 Impala 的数据仓库查询引擎使用【41】。
|
||||
|
||||
@ -384,7 +379,7 @@ Reduce 侧方法的优点是不需要对输入数据做任何假设:无论其
|
||||
|
||||
#### 分区散列连接
|
||||
|
||||
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](/img/fig10-2.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区)。例如,Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
|
||||
如果 Map 侧连接的输入以相同的方式进行分区,则散列连接方法可以独立应用于每个分区。在 [图 10-2](/fig/ddia_1102.png) 的情况中,你可以根据用户 ID 的最后一位十进制数字来对活动事件和用户数据库进行分区(因此连接两侧各有 10 个分区)。例如,Mapper3 首先将所有具有以 3 结尾的 ID 的用户加载到散列表中,然后扫描 ID 为 3 的每个用户的所有活动事件。
|
||||
|
||||
如果分区正确无误,可以确定的是,所有你可能需要连接的记录都落在同一个编号的分区中。因此每个 Mapper 只需要从输入两端各读取一个分区就足够了。好处是每个 Mapper 都可以在内存散列表中少放点数据。
|
||||
|
||||
@ -608,7 +603,8 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS,因此它们采取了
|
||||
|
||||
> 像 Spark、Flink 和 Tez 这样的数据流引擎(请参阅 “[物化中间状态](#物化中间状态)”)通常将算子作为 **有向无环图(DAG)** 的一部分安排在作业中。这与图处理不一样:在数据流引擎中,**从一个算子到另一个算子的数据流** 被构造成一个图,而数据本身通常由关系型元组构成。在图处理中,数据本身具有图的形式。又一个不幸的命名混乱!
|
||||
|
||||
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。我们在 [图 2-6](/img/fig2-6.png) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure)。
|
||||
许多图算法是通过一次遍历一条边来表示的,将一个顶点与近邻的顶点连接起来,以传播一些信息,并不断重复,直到满足一些条件为止 —— 例如,直到没有更多的边要跟进,或直到一些指标收敛。
|
||||
我们在 [图 3-7](/ch3/#fig_datalog_naive) 中看到一个例子,它通过重复跟进标明地点归属关系的边,生成了数据库中北美包含的所有地点列表(这种算法被称为 **传递闭包**,即 transitive closure)。
|
||||
|
||||
可以在分布式文件系统中存储图(包含顶点和边的列表的文件),但是这种 “重复至完成” 的想法不能用普通的 MapReduce 来表示,因为它只扫过一趟数据。这种算法因此经常以 **迭代** 的风格实现:
|
||||
|
||||
@ -692,30 +688,24 @@ Spark、Flink 和 Tez 避免将中间状态写入 HDFS,因此它们采取了
|
||||
|
||||
分布式批处理框架需要解决的两个主要问题是:
|
||||
|
||||
* 分区
|
||||
|
||||
在 MapReduce 中,Mapper 根据输入文件块进行分区。Mapper 的输出被重新分区、排序并合并到可配置数量的 Reducer 分区中。这一过程的目的是把所有的 **相关** 数据(例如带有相同键的所有记录)都放在同一个地方。
|
||||
|
||||
分区
|
||||
: 在 MapReduce 中,Mapper 根据输入文件块进行分区。Mapper 的输出被重新分区、排序并合并到可配置数量的 Reducer 分区中。这一过程的目的是把所有的 **相关** 数据(例如带有相同键的所有记录)都放在同一个地方。
|
||||
后 MapReduce 时代的数据流引擎若非必要会尽量避免排序,但它们也采取了大致类似的分区方法。
|
||||
|
||||
* 容错
|
||||
|
||||
MapReduce 经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。数据流引擎更多地将中间状态保存在内存中,更少地物化中间状态,这意味着如果节点发生故障,则需要重算更多的数据。确定性算子减少了需要重算的数据量。
|
||||
容错
|
||||
: MapReduce 经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。数据流引擎更多地将中间状态保存在内存中,更少地物化中间状态,这意味着如果节点发生故障,则需要重算更多的数据。确定性算子减少了需要重算的数据量。
|
||||
|
||||
|
||||
我们讨论了几种 MapReduce 的连接算法,其中大多数也在 MPP 数据库和数据流引擎内部使用。它们也很好地演示了分区算法是如何工作的:
|
||||
|
||||
* 排序合并连接
|
||||
排序合并连接
|
||||
: 每个参与连接的输入都通过一个提取连接键的 Mapper。通过分区、排序和合并,具有相同键的所有记录最终都会进入相同的 Reducer 调用。这个函数能输出连接好的记录。
|
||||
|
||||
每个参与连接的输入都通过一个提取连接键的 Mapper。通过分区、排序和合并,具有相同键的所有记录最终都会进入相同的 Reducer 调用。这个函数能输出连接好的记录。
|
||||
广播散列连接
|
||||
: 两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个 Mapper,将输入小端的散列表加载到每个 Mapper 中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
|
||||
|
||||
* 广播散列连接
|
||||
|
||||
两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个 Mapper,将输入小端的散列表加载到每个 Mapper 中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
|
||||
|
||||
* 分区散列连接
|
||||
|
||||
如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。
|
||||
分区散列连接
|
||||
: 如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。
|
||||
|
||||
分布式批处理引擎有一个刻意限制的编程模型:回调函数(比如 Mapper 和 Reducer)被假定是无状态的,而且除了指定的输出外,必须没有任何外部可见的副作用。这一限制允许框架在其抽象下隐藏一些困难的分布式系统问题:当遇到崩溃和网络问题时,任务可以安全地重试,任何失败任务的输出都被丢弃。如果某个分区的多个任务成功,则其中只有一个能使其输出实际可见。
|
||||
|
||||
|
@ -9,7 +9,7 @@ breadcrumbs: false
|
||||
当前页面来自本书第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 有效的复杂系统总是从简单的系统演化而来。反之亦然:从零设计的复杂系统没一个能有效工作的。
|
||||
>
|
||||
@ -100,7 +100,7 @@ breadcrumbs: false
|
||||
|
||||
#### 多个消费者
|
||||
|
||||
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/img/fig11-1.png) 所示:
|
||||
当多个消费者从同一主题中读取消息时,有两种主要的消息传递模式,如 [图 11-1](/fig/ddia_1201.png) 所示:
|
||||
|
||||
* 负载均衡(load balancing)
|
||||
|
||||
@ -110,7 +110,7 @@ breadcrumbs: false
|
||||
|
||||
每条消息都被传递给 **所有** 消费者。扇出允许几个独立的消费者各自 “收听” 相同的消息广播,而不会相互影响 —— 这个流处理中的概念对应批处理中多个不同批处理作业读取同一份输入文件 (JMS 中的主题订阅与 AMQP 中的交叉绑定提供了这一功能)。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-1 (a)负载平衡:在消费者间共享消费主题;(b)扇出:将每条消息传递给多个消费者。**
|
||||
|
||||
@ -122,9 +122,9 @@ breadcrumbs: false
|
||||
|
||||
如果与客户端的连接关闭,或者代理超出一段时间未收到确认,代理则认为消息没有被处理,因此它将消息再递送给另一个消费者。(请注意可能发生这样的情况,消息 **实际上是** 处理完毕的,但 **确认** 在网络中丢失了。需要一种原子提交协议才能处理这种情况,正如在 “[实践中的分布式事务](/ch9#实践中的分布式事务)” 中所讨论的那样)
|
||||
|
||||
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](/img/fig11-2.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1,结果消费者 1 按照 m4,m3,m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
|
||||
当与负载均衡相结合时,这种重传行为对消息的顺序有种有趣的影响。在 [图 11-2](/fig/ddia_1202.png) 中,消费者通常按照生产者发送的顺序处理消息。然而消费者 2 在处理消息 m3 时崩溃,与此同时消费者 1 正在处理消息 m4。未确认的消息 m3 随后被重新发送给消费者 1,结果消费者 1 按照 m4,m3,m5 的顺序处理消息。因此 m3 和 m4 的交付顺序与生产者 1 的发送顺序不同。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-2 在处理 m3 时消费者 2 崩溃,因此稍后重传至消费者 1**
|
||||
|
||||
@ -148,11 +148,11 @@ breadcrumbs: false
|
||||
|
||||
同样的结构可以用于实现消息代理:生产者通过将消息追加到日志末尾来发送消息,而消费者通过依次读取日志来接收消息。如果消费者读到日志末尾,则会等待新消息追加的通知。Unix 工具 `tail -f` 能监视文件被追加写入的数据,基本上就是这样工作的。
|
||||
|
||||
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](/ch6) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](/img/fig11-3.png) 所示。
|
||||
为了伸缩超出单个磁盘所能提供的更高吞吐量,可以对日志进行 **分区**(按 [第六章](/ch6) 的定义)。不同的分区可以托管在不同的机器上,使得每个分区都有一份能独立于其他分区进行读写的日志。一个主题可以定义为一组携带相同类型消息的分区。这种方法如 [图 11-3](/fig/ddia_1203.png) 所示。
|
||||
|
||||
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**(offset,在 [图 11-3](/img/fig11-3.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
|
||||
在每个分区内,代理为每个消息分配一个单调递增的序列号或 **偏移量**(offset,在 [图 11-3](/fig/ddia_1203.png) 中,框中的数字是消息偏移量)。这种序列号是有意义的,因为分区是仅追加写入的,所以分区内的消息是完全有序的。没有跨不同分区的顺序保证。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-3 生产者通过将消息追加写入主题分区文件来发送消息,消费者依次读取这些文件**
|
||||
|
||||
@ -228,9 +228,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
如果周期性的完整数据库转储过于缓慢,有时会使用的替代方法是 **双写(dual write)**,其中应用代码在数据变更时明确写入每个系统:例如,首先写入数据库,然后更新搜索索引,然后使缓存项失效(甚至同时执行这些写入)。
|
||||
|
||||
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/img/fig11-4.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X:客户端 1 想要将值设置为 A,客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端 1 的写入将值设置为 A,然后来自客户端 2 的写入将值设置为 B,因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误,这两个系统现在也永久地不一致了。
|
||||
但是,双写有一些严重的问题,其中一个是竞争条件,如 [图 11-4](/fig/ddia_1204.png) 所示。在这个例子中,两个客户端同时想要更新一个项目 X:客户端 1 想要将值设置为 A,客户端 2 想要将其设置为 B。两个客户端首先将新值写入数据库,然后将其写入到搜索索引。因为运气不好,这些请求的时序是交错的:数据库首先看到来自客户端 1 的写入将值设置为 A,然后来自客户端 2 的写入将值设置为 B,因此数据库中的最终值为 B。搜索索引首先看到来自客户端 2 的写入,然后是客户端 1 的写入,所以搜索索引中的最终值是 A。即使没发生错误,这两个系统现在也永久地不一致了。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-4 在数据库中 X 首先被设置为 A,然后被设置为 B,而在搜索索引处,写入以相反的顺序到达**
|
||||
|
||||
@ -238,7 +238,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
双重写入的另一个问题是,其中一个写入可能会失败,而另一个成功。这是一个容错问题,而不是一个并发问题,但也会造成两个系统互相不一致的结果。确保它们要么都成功要么都失败,是原子提交问题的一个例子,解决这个问题的代价是昂贵的(请参阅 “[原子提交与两阶段提交](/ch9#原子提交与两阶段提交)”)。
|
||||
|
||||
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/img/fig11-4.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch5#多主复制)”)。
|
||||
如果你只有一个单领导者复制的数据库,那么这个领导者决定了写入顺序,而状态机复制方法可以在数据库副本上工作。然而,在 [图 11-4](/fig/ddia_1204.png) 中,没有单个主库:数据库可能有一个领导者,搜索索引也可能有一个领导者,但是两者都不追随对方,所以可能会发生冲突(请参阅 “[多主复制](/ch5#多主复制)”)。
|
||||
|
||||
如果实际上只有一个领导者 —— 例如,数据库 —— 而且我们能让搜索索引成为数据库的追随者,情况要好得多。但这在实践中可能吗?
|
||||
|
||||
@ -250,9 +250,9 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
最近,人们对 **变更数据捕获(change data capture, CDC)** 越来越感兴趣,这是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。CDC 是非常有意思的,尤其是当变更能在被写入后立刻用于流时。
|
||||
|
||||
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 11-5](/img/fig11-5.png) 所示。
|
||||
例如,你可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引。如果变更日志以相同的顺序应用,则可以预期搜索索引中的数据与数据库中的数据是匹配的。搜索索引和任何其他派生数据系统只是变更流的消费者,如 [图 11-5](/fig/ddia_1205.png) 所示。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-5 将数据按顺序写入一个数据库,然后按照相同的顺序将这些更改应用到其他系统**
|
||||
|
||||
@ -260,7 +260,7 @@ Apache Kafka 【17,18】、Amazon Kinesis Streams 【19】和 Twitter 的 Distri
|
||||
|
||||
我们可以将日志消费者叫做 **派生数据系统**,正如在 [第三部分](/part-iii) 的介绍中所讨论的:存储在搜索索引和数据仓库中的数据,只是 **记录系统** 数据的额外视图。变更数据捕获是一种机制,可确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本。
|
||||
|
||||
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/img/fig11-2.png) 的重新排序问题)。
|
||||
从本质上说,变更数据捕获使得一个数据库成为领导者(被捕获变化的数据库),并将其他组件变为追随者。基于日志的消息代理非常适合从源数据库传输变更事件,因为它保留了消息的顺序(避免了 [图 11-2](/fig/ddia_1202.png) 的重新排序问题)。
|
||||
|
||||
数据库触发器可用来实现变更数据捕获(请参阅 “[基于触发器的复制](/ch5#基于触发器的复制)”),通过注册观察所有变更的触发器,并将相应的变更项写入变更日志表中。但是它们往往是脆弱的,而且有显著的性能开销。解析复制日志可能是一种更稳健的方法,但它也很有挑战,例如如何应对模式变更。
|
||||
|
||||
@ -280,7 +280,7 @@ LinkedIn 的 Databus【25】,Facebook 的 Wormhole【26】和 Yahoo! 的 Sherp
|
||||
|
||||
如果你只能保留有限的历史日志,则每次要添加新的派生数据系统时,都需要做一次快照。但 **日志压缩(log compaction)** 提供了一个很好的备选方案。
|
||||
|
||||
我们之前在 “[散列索引](/ch3#散列索引)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 3-2](/img/fig3-2.png) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
|
||||
我们之前在 “[散列索引](/ch4#the-sstable-file-format)” 中关于日志结构存储引擎的上下文中讨论了日志压缩(请参阅 [图 4-2](/ch4/#fig_storage_sstable_index) 的示例)。原理很简单:存储引擎定期在日志中查找具有相同键的记录,丢掉所有重复的内容,并只保留每个键的最新更新。这个压缩与合并过程在后台运行。
|
||||
|
||||
在日志结构存储引擎中,具有特殊值 NULL(**墓碑**,即 tombstone)的更新表示该键被删除,并会在日志压缩过程中被移除。但只要键不被覆盖或删除,它就会永远留在日志中。这种压缩日志所需的磁盘空间仅取决于数据库的当前内容,而不取决于数据库中曾经发生的写入次数。如果相同的键经常被覆盖写入,则先前的值将最终将被垃圾回收,只有最新的值会保留下来。
|
||||
|
||||
@ -304,7 +304,7 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
|
||||
|
||||
与变更数据捕获类似,事件溯源涉及到 **将所有对应用状态的变更** 存储为变更事件日志。最大的区别是事件溯源将这一想法应用到了一个不同的抽象层次上:
|
||||
|
||||
* 在变更数据捕获中,应用以 **可变方式(mutable way)** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/img/fig11-4.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
|
||||
* 在变更数据捕获中,应用以 **可变方式(mutable way)** 使用数据库,可以任意更新和删除记录。变更日志是从数据库的底层提取的(例如,通过解析复制日志),从而确保从数据库中提取的写入顺序与实际写入的顺序相匹配,从而避免 [图 11-4](/fig/ddia_1204.png) 中的竞态条件。写入数据库的应用不需要知道 CDC 的存在。
|
||||
* 在事件溯源中,应用逻辑显式构建在写入事件日志的不可变事件之上。在这种情况下,事件存储是仅追加写入的,更新与删除是不鼓励的或禁止的。事件被设计为旨在反映应用层面发生的事情,而不是底层的状态变更。
|
||||
|
||||
事件溯源是一种强大的数据建模技术:从应用的角度来看,将用户的行为记录为不可变的事件更有意义,而不是在可变数据库中记录这些行为的影响。事件溯源使得应用随时间演化更为容易,通过更容易理解事情发生的原因来帮助调试的进行,并有利于防止应用 Bug(请参阅 “[不可变事件的优点](#不可变事件的优点)”)。
|
||||
@ -350,13 +350,13 @@ Kafka Connect【41】致力于将广泛的数据库系统的变更数据捕获
|
||||
|
||||
无论状态如何变化,总是有一系列事件导致了这些变化。即使事情已经执行与回滚,这些事件出现是始终成立的。关键的想法是:可变的状态与不可变事件的仅追加日志相互之间并不矛盾:它们是一体两面,互为阴阳的。所有变化的日志 —— **变化日志(changelog)**,表示了随时间演变的状态。
|
||||
|
||||
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/img/fig11-6.png) 所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
|
||||
如果你倾向于数学表示,那么你可能会说,应用状态是事件流对时间求积分得到的结果,而变更流是状态对时间求微分的结果,如 [图 11-6](/fig/ddia_1206.png) 所示【49,50,51】。这个比喻有一些局限性(例如,状态的二阶导似乎没有意义),但这是考虑数据的一个实用出发点。
|
||||
$$
|
||||
state(now) = \int_{t=0}^{now}{stream(t) \ dt} \\
|
||||
stream(t) = \frac{d\ state(t)}{dt}
|
||||
$$
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-6 应用当前状态与事件流之间的关系**
|
||||
|
||||
@ -378,7 +378,7 @@ $$
|
||||
|
||||
#### 从同一事件日志中派生多个视图
|
||||
|
||||
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/img/fig11-5.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】,Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】,Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(请参阅 “[保持系统同步](#保持系统同步)”)。
|
||||
此外,通过从不变的事件日志中分离出可变的状态,你可以针对不同的读取方式,从相同的事件日志中衍生出几种不同的表现形式。效果就像一个流的多个消费者一样([图 11-5](/fig/ddia_1205.png)):例如,分析型数据库 Druid 使用这种方式直接从 Kafka 摄取数据【55】,Pistachio 是一个分布式的键值存储,使用 Kafka 作为提交日志【56】,Kafka Connect 能将来自 Kafka 的数据导出到各种不同的数据库与索引【41】。这对于许多其他存储和索引系统(如搜索服务器)来说是很有意义的,当系统要从分布式日志中获取输入时亦然(请参阅 “[保持系统同步](#保持系统同步)”)。
|
||||
|
||||
添加从事件日志到数据库的显式转换,能够使应用更容易地随时间演进:如果你想要引入一个新功能,以新的方式表示现有数据,则可以使用事件日志来构建一个单独的、针对新功能的读取优化视图,无需修改现有系统而与之共存。并行运行新旧系统通常比在现有系统中执行复杂的模式迁移更容易。一旦不再需要旧的系统,你可以简单地关闭它并回收其资源【47,57】。
|
||||
|
||||
@ -417,7 +417,7 @@ $$
|
||||
|
||||
剩下的就是讨论一下你可以用流做什么 —— 也就是说,你可以处理它。一般来说,有三种选项:
|
||||
|
||||
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/img/fig11-5.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
|
||||
1. 你可以将事件中的数据写入数据库、缓存、搜索索引或类似的存储系统,然后能被其他客户端查询。如 [图 11-5](/fig/ddia_1205.png) 所示,这是数据库与系统其他部分所发生的变更保持同步的好方法 —— 特别是当流消费者是写入数据库的唯一客户端时。如 “[批处理工作流的输出](/ch11#批处理工作流的输出)” 中所讨论的,它是写入存储系统的流等价物。
|
||||
2. 你能以某种方式将事件推送给用户,例如发送报警邮件或推送通知,或将事件流式传输到可实时显示的仪表板上。在这种情况下,人是流的最终消费者。
|
||||
3. 你可以处理一个或多个输入流,并产生一个或多个输出流。流可能会经过由几个这样的处理阶段组成的流水线,最后再输出(选项 1 或 2)。
|
||||
|
||||
@ -510,9 +510,9 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
|
||||
|
||||
[^ii]: 感谢 Flink 社区的 Kostas Kloudas 提出这个比喻。
|
||||
|
||||
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/img/fig11-7.png))。
|
||||
将事件时间和处理时间搞混会导致错误的数据。例如,假设你有一个流处理器用于测量请求速率(计算每秒请求数)。如果你重新部署流处理器,它可能会停止一分钟,并在恢复之后处理积压的事件。如果你按处理时间来衡量速率,那么在处理积压日志时,请求速率看上去就像有一个异常的突发尖峰,而实际上请求速率是稳定的([图 11-7](/fig/ddia_1207.png))。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 11-7 按处理时间分窗,会因为处理速率的变动引入人为因素**
|
||||
|
||||
@ -583,7 +583,7 @@ CEP 的实现包括 Esper【69】、IBM InfoSphere Streams【70】、Apama、TIB
|
||||
|
||||
#### 流表连接(流扩充)
|
||||
|
||||
在 “[示例:用户活动事件分析](/ch11#示例:用户活动事件分析)”([图 10-2](/img/fig10-2.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充(enriching)** 活动事件。
|
||||
在 “[示例:用户活动事件分析](/ch11#示例:用户活动事件分析)”([图 10-2](/fig/ddia_1102.png))中,我们看到了连接两个数据集的批处理作业示例:一组用户活动事件和一个用户档案数据库。将用户活动事件视为流,并在流处理器中连续执行相同的连接是很自然的想法:输入是包含用户 ID 的活动事件流,而输出还是活动事件流,但其中用户 ID 已经被扩展为用户的档案信息。这个过程有时被称为使用数据库的信息来 **扩充(enriching)** 活动事件。
|
||||
|
||||
要执行此连接,流处理器需要一次处理一个活动事件,在数据库中查找事件的用户 ID,并将档案信息添加到活动事件中。数据库查询可以通过查询远程数据库来实现。但正如在 “[示例:用户活动事件分析](/ch11#示例:用户活动事件分析)” 一节中讨论的,此类远程查询可能会很慢,并且有可能导致数据库过载【75】。
|
||||
|
||||
@ -618,7 +618,7 @@ GROUP BY follows.follower_id
|
||||
|
||||
流连接直接对应于这个查询中的表连接。时间线实际上是这个查询结果的缓存,每当底层的表发生变化时都会更新 [^iii]。
|
||||
|
||||
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/img/fig11-6.png) 所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'。换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。
|
||||
[^iii]: 如果你将流视作表的衍生物,如 [图 11-6](/fig/ddia_1206.png) 所示,而把一个连接看作是两个表的乘法u·v,那么会发生一些有趣的事情:物化连接的变化流遵循乘积法则:(u·v)'= u'v + uv'。换句话说,任何推文的变化量都与当前的关注联系在一起,任何关注的变化量都与当前的推文相连接【49,50】。
|
||||
|
||||
#### 连接的时间依赖性
|
||||
|
||||
|
@ -10,7 +10,7 @@ breadcrumbs: false
|
||||
当前页面来自本书第一版,第二版尚不可用
|
||||
{{< /callout >}}
|
||||
|
||||

|
||||

|
||||
|
||||
> 如果船长的终极目标是保护船只,他应该永远待在港口。
|
||||
>
|
||||
@ -48,7 +48,7 @@ breadcrumbs: false
|
||||
|
||||
例如,你可能会首先将数据写入 **记录系统** 数据库,捕获对该数据库所做的变更(请参阅 “[变更数据捕获](/ch12#变更数据捕获)”),然后将变更以相同的顺序应用于搜索索引。如果变更数据捕获(CDC)是更新索引的唯一方式,则可以确定该索引完全派生自记录系统,因此与其保持一致(除软件错误外)。写入数据库是向该系统提供新输入的唯一方式。
|
||||
|
||||
允许应用程序直接写入搜索索引和数据库引入了如 [图 11-4](/img/fig11-4.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
|
||||
允许应用程序直接写入搜索索引和数据库引入了如 [图 11-4](/fig/ddia_1204.png) 所示的问题,其中两个客户端同时发送冲突的写入,且两个存储系统按不同顺序处理它们。在这种情况下,既不是数据库说了算,也不是搜索索引说了算,所以它们做出了相反的决定,进入彼此间持久性的不一致状态。
|
||||
|
||||
如果你可以通过单个系统来提供所有用户输入,从而决定所有写入的排序,则通过按相同顺序处理写入,可以更容易地衍生出其他数据表示。这是状态机复制方法的一个应用,我们在 “[全序广播](/ch9#全序广播)” 中看到。无论你使用变更数据捕获还是事件溯源日志,都不如简单的基于全序的决策原则更重要。
|
||||
|
||||
@ -325,9 +325,9 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
|
||||
|
||||
### 观察派生数据状态
|
||||
|
||||
在抽象层面,上一节讨论的数据流系统提供了创建派生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个派生数据集都会被更新,以适配写入的数据。[图 12-1](/img/fig12-1.png) 显示了一个更新搜索索引的例子。
|
||||
在抽象层面,上一节讨论的数据流系统提供了创建派生数据集(例如搜索索引、物化视图和预测模型)并使其保持更新的过程。我们将这个过程称为 **写路径(write path)**:只要某些信息被写入系统,它可能会经历批处理与流处理的多个阶段,而最终每个派生数据集都会被更新,以适配写入的数据。[图 12-1](/fig/ddia_1301.png) 显示了一个更新搜索索引的例子。
|
||||
|
||||

|
||||

|
||||
|
||||
**图 12-1 在搜索索引中,写(文档更新)遇上读(查询)**
|
||||
|
||||
@ -335,7 +335,7 @@ Unix 和关系数据库以非常不同的哲学来处理信息管理问题。Uni
|
||||
|
||||
总而言之,写路径和读路径涵盖了数据的整个旅程,从收集数据开始,到使用数据结束(可能是由另一个人)。写路径是预计算过程的一部分 —— 即,一旦数据进入,即刻完成,无论是否有人需要看它。读路径是这个过程中只有当有人请求时才会发生的部分。如果你熟悉函数式编程语言,则可能会注意到写路径类似于立即求值,读路径类似于惰性求值。
|
||||
|
||||
如 [图 12-1](/img/fig12-1.png) 所示,派生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
|
||||
如 [图 12-1](/fig/ddia_1301.png) 所示,派生数据集是写路径和读路径相遇的地方。它代表了在写入时需要完成的工作量与在读取时需要完成的工作量之间的权衡。
|
||||
|
||||
#### 物化视图和缓存
|
||||
|
||||
@ -450,7 +450,7 @@ MPP 数据库的内部查询执行图有着类似的特征(请参阅 “[Hadoo
|
||||
|
||||
除了流处理之外,其他许多地方也需要抑制重复的模式。例如,TCP 使用了数据包上的序列号,以便接收方可以将它们正确排序,并确定网络上是否有数据包丢失或重复。在将数据交付应用前,TCP 协议栈会重新传输任何丢失的数据包,也会移除任何重复的数据包。
|
||||
|
||||
但是,这种重复抑制仅适用于单条 TCP 连接的场景中。假设 TCP 连接是一个客户端与数据库的连接,并且它正在执行 [例 12-1]() 中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个 TCP 连接上发送的)。如果客户端在发送 `COMMIT` 之后并在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图 8-1](/img/fig8-1.png))。
|
||||
但是,这种重复抑制仅适用于单条 TCP 连接的场景中。假设 TCP 连接是一个客户端与数据库的连接,并且它正在执行 [例 12-1]() 中的事务。在许多数据库中,事务是绑定在客户端连接上的(如果客户端发送了多个查询,数据库就知道它们属于同一个事务,因为它们是在同一个 TCP 连接上发送的)。如果客户端在发送 `COMMIT` 之后并在从数据库服务器收到响应之前遇到网络中断与连接超时,客户端是不知道事务是否已经被提交的([图 9-1](/ch9#fig_distributed_network))。
|
||||
|
||||
**例 12-1 资金从一个账户到另一个账户的非幂等转移**
|
||||
|
||||
|
@ -16,7 +16,7 @@ breadcrumbs: false
|
||||
|
||||
1. 作为应用程序开发人员,你观察现实世界(里面有人员、组织、货物、行为、资金流动、传感器等),并用对象或数据结构以及操作这些数据结构的 API 来进行建模。这些结构通常是特定于你的应用程序的。
|
||||
2. 当你想要存储这些数据结构时,你用通用的数据模型来表达它们,比如 JSON 或 XML 文档、关系数据库中的表,或图中的顶点和边。这些数据模型是本章的主题。
|
||||
3. 构建数据库软件的工程师决定了如何用内存、磁盘或网络上的字节来表示文档/关系/图数据。这种表示方式可能允许数据以各种方式被查询、搜索、操作和处理。我们将在[第 4 章](/zh/ch4#ch_storage)中讨论这些存储引擎的设计。
|
||||
3. 构建数据库软件的工程师决定了如何用内存、磁盘或网络上的字节来表示文档/关系/图数据。这种表示方式可能允许数据以各种方式被查询、搜索、操作和处理。我们将在[第 4 章](/ch4#ch_storage)中讨论这些存储引擎的设计。
|
||||
4. 在更低的层次上,硬件工程师已经想出了如何用电流、光脉冲、磁场等来表示字节。
|
||||
|
||||
在复杂的应用程序中可能有更多的中间层,比如基于 API 构建的 API,但基本思想仍然相同:每一层通过提供一个清晰的数据模型来隐藏下层的复杂性。这些抽象使不同的人群——例如,数据库供应商的工程师和使用其数据库的应用程序开发人员——能够有效地协作。
|
||||
@ -39,7 +39,7 @@ breadcrumbs: false
|
||||
|
||||
如今最知名的数据模型可能是 SQL,它基于 Edgar Codd 于 1970 年提出的关系模型 [^3]:数据被组织成 **关系**(SQL 中称为 **表**),其中每个关系是 **元组**(SQL 中的 **行**)的无序集合。
|
||||
|
||||
关系模型最初是一个理论性的提议,当时许多人怀疑它是否能够有效地实现。然而,到 20 世纪 80 年代中期,关系数据库管理系统(RDBMS)和 SQL 已经成为大多数需要存储和查询某种规则结构数据的人的首选工具。几十年后,许多数据管理用例仍然由关系数据主导——例如,商业分析(参见["星型和雪花型:分析模式"](/zh/ch3#sec_datamodels_analytics))。
|
||||
关系模型最初是一个理论性的提议,当时许多人怀疑它是否能够有效地实现。然而,到 20 世纪 80 年代中期,关系数据库管理系统(RDBMS)和 SQL 已经成为大多数需要存储和查询某种规则结构数据的人的首选工具。几十年后,许多数据管理用例仍然由关系数据主导——例如,商业分析(参见["星型和雪花型:分析模式"](/ch3#sec_datamodels_analytics))。
|
||||
|
||||
多年来,有许多竞争性的数据存储和查询方法。在 20 世纪 70 年代和 80 年代初,**网络模型** 和 **层次模型** 是主要的替代方案,但关系模型最终占据了主导地位。对象数据库在 20 世纪 80 年代末和 90 年代初出现又消失。XML 数据库出现在 2000 年代初,但只在小众领域被采用。关系模型的每个竞争者在其时代都产生了大量的炒作,但从未持续 [^4]。相反,SQL 已经发展到在其关系核心之外包含其他数据类型——例如,增加了对 XML、JSON 和图数据的支持 [^5]。
|
||||
|
||||
@ -65,7 +65,7 @@ NoSQL 运动的一个持久影响是 **文档模型** 的普及,它通常将
|
||||
像 ActiveRecord 和 Hibernate 这样的对象关系映射(ORM)框架减少了这个转换层所需的样板代码量,但它们经常受到批评 [^6]。一些常被提到的问题是:
|
||||
|
||||
* ORM 很复杂,不能完全隐藏两个模型之间的差异,因此开发人员最终仍然需要考虑数据的关系表示和对象表示。
|
||||
* ORM 通常只用于 OLTP 应用程序开发(参见["描述事务处理和分析"](/zh/ch1#sec_introduction_oltp));为分析目的提供数据的数据工程师仍然需要使用底层的关系表示,因此在使用 ORM 时关系模式的设计仍然很重要。
|
||||
* ORM 通常只用于 OLTP 应用程序开发(参见["描述事务处理和分析"](/ch1#sec_introduction_oltp));为分析目的提供数据的数据工程师仍然需要使用底层的关系表示,因此在使用 ORM 时关系模式的设计仍然很重要。
|
||||
* 许多 ORM 只适用于关系型 OLTP 数据库。拥有多样化数据系统(如搜索引擎、图数据库和 NoSQL 系统)的组织可能会发现 ORM 支持不足。
|
||||
* 一些 ORM 会自动生成关系模式,但这些模式对于直接访问关系数据的用户来说可能很别扭,而且在底层数据库上可能效率低下。自定义 ORM 的模式和查询生成可能很复杂,并且会抵消使用 ORM 的好处。
|
||||
* ORM 使得很容易意外地编写低效的查询,例如 **N+1 查询问题** [^7]。例如,假设你想在页面上显示用户评论列表,所以你执行一个查询返回 *N* 条评论,每条评论包含其作者的 ID。要显示评论作者的姓名,你需要在 users 表中查找 ID。在手写 SQL 中,你可能会在查询中执行这个连接,并将作者姓名与每条评论一起返回,但使用 ORM 时,你可能最终会为每条 *N* 条评论在 users 表上进行单独的查询来查找其作者,总共产生 *N*+1 个数据库查询,这比在数据库中执行连接要慢。为了避免这个问题,你可能需要告诉 ORM 在获取评论的同时获取作者信息。
|
||||
@ -78,13 +78,13 @@ NoSQL 运动的一个持久影响是 **文档模型** 的普及,它通常将
|
||||
|
||||
#### 用于一对多关系的文档数据模型 {#the-document-data-model-for-one-to-many-relationships}
|
||||
|
||||
并非所有数据都适合关系表示;让我们看一个例子来探讨关系模型的局限性。[图 3-1](/zh/ch3#fig_obama_relational) 说明了如何在关系模式中表达简历(LinkedIn 个人资料)。整个资料可以通过唯一标识符 `user_id` 来识别。像 `first_name` 和 `last_name` 这样的字段每个用户只出现一次,因此可以将它们建模为 `users` 表上的列。
|
||||
并非所有数据都适合关系表示;让我们看一个例子来探讨关系模型的局限性。[图 3-1](/ch3#fig_obama_relational) 说明了如何在关系模式中表达简历(LinkedIn 个人资料)。整个资料可以通过唯一标识符 `user_id` 来识别。像 `first_name` 和 `last_name` 这样的字段每个用户只出现一次,因此可以将它们建模为 `users` 表上的列。
|
||||
|
||||
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 **一对多关系** 的一种方法是将职位、教育和联系信息放在单独的表中,并引用 `users` 表的外键,如[图 3-1](/zh/ch3#fig_obama_relational) 所示。
|
||||
大多数人在职业生涯中有多份工作(职位),人们可能有不同数量的教育经历和任意数量的联系信息。表示这种 **一对多关系** 的一种方法是将职位、教育和联系信息放在单独的表中,并引用 `users` 表的外键,如[图 3-1](/ch3#fig_obama_relational) 所示。
|
||||
|
||||
{{< figure src="/fig/ddia_0301.png" id="fig_obama_relational" caption="图 3-1. 使用关系模式表示 LinkedIn 个人资料。" class="w-full my-4" >}}
|
||||
|
||||
表示相同信息的另一种方式(这可能更自然,更贴近应用程序代码中的对象结构)是作为 JSON 文档,如[示例 3-1](/zh/ch3#fig_obama_json) 所示。
|
||||
表示相同信息的另一种方式(这可能更自然,更贴近应用程序代码中的对象结构)是作为 JSON 文档,如[示例 3-1](/ch3#fig_obama_json) 所示。
|
||||
|
||||
{{< figure id="fig_obama_json" title="示例 3-1. 将 LinkedIn 个人资料表示为 JSON 文档" class="w-full my-4" >}}
|
||||
|
||||
@ -111,24 +111,24 @@ NoSQL 运动的一个持久影响是 **文档模型** 的普及,它通常将
|
||||
}
|
||||
```
|
||||
|
||||
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在[第 5 章](/zh/ch5#ch_encoding)中看到的,JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在["文档模型中的模式灵活性"](/zh/ch3#sec_datamodels_schema_flexibility)中讨论这一点。
|
||||
一些开发人员认为 JSON 模型减少了应用程序代码和存储层之间的阻抗不匹配。然而,正如我们将在[第 5 章](/ch5#ch_encoding)中看到的,JSON 作为数据编码格式也存在问题。缺乏模式通常被认为是一个优势;我们将在["文档模型中的模式灵活性"](/ch3#sec_datamodels_schema_flexibility)中讨论这一点。
|
||||
|
||||
与[图 3-1](/zh/ch3#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 **局部性**(参见["读写的数据局部性"](/zh/ch3#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(按 `user_id` 查询每个表)或在 `users` 表及其从属表之间执行混乱的多路连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
|
||||
与[图 3-1](/ch3#fig_obama_relational) 中的多表模式相比,JSON 表示具有更好的 **局部性**(参见["读写的数据局部性"](/ch3#sec_datamodels_document_locality))。如果你想在关系示例中获取个人资料,你需要执行多个查询(按 `user_id` 查询每个表)或在 `users` 表及其从属表之间执行混乱的多路连接 [^8]。在 JSON 表示中,所有相关信息都在一个地方,使查询既更快又更简单。
|
||||
|
||||
从用户个人资料到用户职位、教育历史和联系信息的一对多关系意味着数据中存在树形结构,JSON 表示使这种树形结构明确(参见[图 3-2](/zh/ch3#fig_json_tree))。
|
||||
从用户个人资料到用户职位、教育历史和联系信息的一对多关系意味着数据中存在树形结构,JSON 表示使这种树形结构明确(参见[图 3-2](/ch3#fig_json_tree))。
|
||||
|
||||
{{< figure src="/fig/ddia_0302.png" id="fig_json_tree" caption="图 3-2. 形成树形结构的一对多关系。" class="w-full my-4" >}}
|
||||
|
||||
--------
|
||||
|
||||
> [!NOTE]
|
||||
> 这种关系有时被称为 **一对少** 而不是 **一对多**,因为简历通常只有少量的职位 [^9] [^10]。在可能有大量相关项的情况下——比如名人社交媒体帖子上的评论,可能有数千条——将它们全部嵌入同一文档中可能太笨拙,因此[图 3-1](/zh/ch3#fig_obama_relational) 中的关系方法更可取。
|
||||
> 这种关系有时被称为 **一对少** 而不是 **一对多**,因为简历通常只有少量的职位 [^9] [^10]。在可能有大量相关项的情况下——比如名人社交媒体帖子上的评论,可能有数千条——将它们全部嵌入同一文档中可能太笨拙,因此[图 3-1](/ch3#fig_obama_relational) 中的关系方法更可取。
|
||||
|
||||
--------
|
||||
|
||||
### 规范化、反规范化与连接 {#sec_datamodels_normalization}
|
||||
|
||||
在前一节的[示例 3-1](/zh/ch3#fig_obama_json) 中,`region_id` 被给出为 ID,而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
|
||||
在前一节的[示例 3-1](/ch3#fig_obama_json) 中,`region_id` 被给出为 ID,而不是纯文本字符串 `"Washington, DC, United States"`。为什么?
|
||||
|
||||
如果用户界面有一个用于输入地区的自由文本字段,将其存储为纯文本字符串是有意义的。但是,拥有标准化的地理区域列表并让用户从下拉列表或自动完成中选择是有优势的:
|
||||
|
||||
@ -174,15 +174,15 @@ db.users.aggregate([
|
||||
* 在反规范化的表示中,我们会在每个人的个人资料中包含徽标的图像 URL;这使得 JSON 文档自包含,但如果我们需要更改徽标,就会很头疼,因为我们现在需要找到旧 URL 的所有出现并更新它们 [^9]。
|
||||
* 在规范化的表示中,我们将创建一个代表组织或学校的实体,并在该实体上存储其名称、徽标 URL 以及可能的其他属性(描述、新闻源等)一次。每个提到该组织的简历都只需引用其 ID,更新徽标很容易。
|
||||
|
||||
作为一般原则,规范化的数据通常写入更快(因为只有一个副本),但查询更慢(因为需要连接);反规范化的数据通常读取更快(连接更少),但写入更昂贵(更多副本需要更新,使用更多磁盘空间)。你可能会发现将反规范化视为派生数据的一种形式很有帮助(["记录系统和派生数据"](/zh/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。
|
||||
作为一般原则,规范化的数据通常写入更快(因为只有一个副本),但查询更慢(因为需要连接);反规范化的数据通常读取更快(连接更少),但写入更昂贵(更多副本需要更新,使用更多磁盘空间)。你可能会发现将反规范化视为派生数据的一种形式很有帮助(["记录系统和派生数据"](/ch1#sec_introduction_derived)),因为你需要设置一个过程来更新数据的冗余副本。
|
||||
|
||||
除了执行所有这些更新的成本之外,你还需要考虑如果进程在进行更新的中途崩溃,数据库的一致性。提供原子事务的数据库(参见["原子性"](/zh/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都提供跨多个文档的原子性。也可以通过流处理来确保一致性,我们将在 [链接待定] 中讨论。
|
||||
除了执行所有这些更新的成本之外,你还需要考虑如果进程在进行更新的中途崩溃,数据库的一致性。提供原子事务的数据库(参见["原子性"](/ch8#sec_transactions_acid_atomicity))使保持一致性变得更容易,但并非所有数据库都提供跨多个文档的原子性。也可以通过流处理来确保一致性,我们将在 [链接待定] 中讨论。
|
||||
|
||||
规范化往往更适合 OLTP 系统,其中读取和更新都需要快速;分析系统通常使用反规范化数据表现更好,因为它们批量执行更新,只读查询的性能是主要关注点。此外,在中小规模的系统中,规范化的数据模型通常是最好的,因为你不必担心保持数据的多个副本彼此一致,执行连接的成本是可以接受的。然而,在非常大规模的系统中,连接的成本可能会成为问题。
|
||||
|
||||
#### 社交网络案例研究中的反规范化 {#denormalization-in-the-social-networking-case-study}
|
||||
|
||||
在["案例研究:社交网络主页时间线"](/zh/ch2#sec_introduction_twitter)中,我们比较了规范化表示([图 2-1](/zh/ch2#fig_twitter_relational))和反规范化表示(预计算、物化的时间线):这里,`posts` 和 `follows` 之间的连接太昂贵了,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。
|
||||
在["案例研究:社交网络主页时间线"](/ch2#sec_introduction_twitter)中,我们比较了规范化表示([图 2-1](/ch2#fig_twitter_relational))和反规范化表示(预计算、物化的时间线):这里,`posts` 和 `follows` 之间的连接太昂贵了,物化时间线是该连接结果的缓存。将新帖子插入关注者时间线的扇出过程是我们保持反规范化表示一致的方式。
|
||||
|
||||
然而,X(前 Twitter)的物化时间线实现实际上并不存储每个帖子的实际文本:每个条目实际上只存储帖子 ID、发布它的用户的 ID,以及一些额外的信息来识别转发和回复 [^11]。换句话说,它是(大约)以下查询的预计算结果:
|
||||
|
||||
@ -205,13 +205,13 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
### 多对一与多对多关系 {#sec_datamodels_many_to_many}
|
||||
|
||||
虽然[图 3-1](/zh/ch3#fig_obama_relational) 中的 `positions` 和 `education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),`region_id` 字段是 **多对一** 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
|
||||
虽然[图 3-1](/ch3#fig_obama_relational) 中的 `positions` 和 `education` 是一对多或一对少关系的例子(一份简历有多个职位,但每个职位只属于一份简历),`region_id` 字段是 **多对一** 关系的例子(许多人住在同一个地区,但我们假设每个人在任何时候只住在一个地区)。
|
||||
|
||||
如果我们为组织和学校引入实体,并从简历中通过 ID 引用它们,那么我们也有 **多对多** 关系(一个人为几个组织工作过,一个组织有几个过去或现在的员工)。在关系模型中,这种关系通常表示为 **关联表** 或 **连接表**,如[图 3-3](/zh/ch3#fig_datamodels_m2m_rel) 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。
|
||||
如果我们为组织和学校引入实体,并从简历中通过 ID 引用它们,那么我们也有 **多对多** 关系(一个人为几个组织工作过,一个组织有几个过去或现在的员工)。在关系模型中,这种关系通常表示为 **关联表** 或 **连接表**,如[图 3-3](/ch3#fig_datamodels_m2m_rel) 所示:每个职位将一个用户 ID 与一个组织 ID 关联起来。
|
||||
|
||||
{{< figure src="/fig/ddia_0303.png" id="fig_datamodels_m2m_rel" caption="图 3-3. 关系模型中的多对多关系。" class="w-full my-4" >}}
|
||||
|
||||
多对一和多对多关系不容易适应一个自包含的 JSON 文档;它们更适合规范化的表示。在文档模型中,一种可能的表示在[示例 3-2](/zh/ch3#fig_datamodels_m2m_json) 中给出,并在[图 3-4](/zh/ch3#fig_datamodels_many_to_many) 中说明:每个虚线矩形内的数据可以分组到一个文档中,但与组织和学校的链接最好表示为对其他文档的引用。
|
||||
多对一和多对多关系不容易适应一个自包含的 JSON 文档;它们更适合规范化的表示。在文档模型中,一种可能的表示在[示例 3-2](/ch3#fig_datamodels_m2m_json) 中给出,并在[图 3-4](/ch3#fig_datamodels_many_to_many) 中说明:每个虚线矩形内的数据可以分组到一个文档中,但与组织和学校的链接最好表示为对其他文档的引用。
|
||||
|
||||
{{< figure id="fig_datamodels_m2m_json" title="示例 3-2. 通过 ID 引用组织的简历。" class="w-full my-4" >}}
|
||||
|
||||
@ -232,15 +232,15 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
多对多关系通常需要在"两个方向"上查询:例如,查找特定人员工作过的所有组织,以及查找在特定组织工作过的所有人员。启用此类查询的一种方法是在两侧存储 ID 引用,即简历包括该人工作过的每个组织的 ID,组织文档包括提到该组织的简历的 ID。这种表示是反规范化的,因为关系存储在两个地方,它们可能彼此不一致。
|
||||
|
||||
规范化的表示只在一个地方存储关系,并依赖 **二级索引**(我们将在[第 4 章](/zh/ch4#ch_storage)中讨论)来允许在两个方向上有效地查询关系。在[图 3-3](/zh/ch3#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id` 和 `org_id` 列上创建索引。
|
||||
规范化的表示只在一个地方存储关系,并依赖 **二级索引**(我们将在[第 4 章](/ch4#ch_storage)中讨论)来允许在两个方向上有效地查询关系。在[图 3-3](/ch3#fig_datamodels_m2m_rel) 的关系模式中,我们会告诉数据库在 `positions` 表的 `user_id` 和 `org_id` 列上创建索引。
|
||||
|
||||
在[示例 3-2](/zh/ch3#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和支持 JSON 的关系数据库能够在文档内的值上创建这样的索引。
|
||||
在[示例 3-2](/ch3#fig_datamodels_m2m_json) 的文档模型中,数据库需要索引 `positions` 数组内对象的 `org_id` 字段。许多文档数据库和支持 JSON 的关系数据库能够在文档内的值上创建这样的索引。
|
||||
|
||||
### 星型与雪花型:分析模式 {#sec_datamodels_analytics}
|
||||
|
||||
数据仓库(参见["数据仓库"](/zh/ch1#sec_introduction_dwh))通常是关系型的,数据仓库中表结构有几个广泛使用的约定:**星型模式**、**雪花型模式**、**维度建模** [^12] 和 **一大表**(OBT)。这些结构针对业务分析师的需求进行了优化。ETL 过程将数据从操作系统转换为此模式。
|
||||
数据仓库(参见["数据仓库"](/ch1#sec_introduction_dwh))通常是关系型的,数据仓库中表结构有几个广泛使用的约定:**星型模式**、**雪花型模式**、**维度建模** [^12] 和 **一大表**(OBT)。这些结构针对业务分析师的需求进行了优化。ETL 过程将数据从操作系统转换为此模式。
|
||||
|
||||
[图 3-5](/zh/ch3#fig_dwh_schema) 展示了一个可能在杂货零售商数据仓库中找到的星型模式示例。模式的中心是所谓的 **事实表**(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面浏览或点击。
|
||||
[图 3-5](/ch3#fig_dwh_schema) 展示了一个可能在杂货零售商数据仓库中找到的星型模式示例。模式的中心是所谓的 **事实表**(在此示例中,它称为 `fact_sales`)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买产品)。如果我们分析的是网站流量而不是零售销售,每一行可能代表用户的页面浏览或点击。
|
||||
|
||||
{{< figure src="/fig/ddia_0305.png" id="fig_dwh_schema" caption="图 3-5. 用于数据仓库的星型模式示例。" class="w-full my-4" >}}
|
||||
|
||||
@ -248,11 +248,11 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
事实表中的一些列是属性,例如产品的销售价格和从供应商处购买的成本(允许计算利润率)。事实表中的其他列是对其他表的外键引用,称为 **维度表**。由于事实表中的每一行都代表一个事件,维度代表事件的 **谁**、**什么**、**哪里**、**何时**、**如何** 和 **为什么**。
|
||||
|
||||
例如,在[图 3-5](/zh/ch3#fig_dwh_schema) 中,其中一个维度是所售产品。`dim_product` 表中的每一行代表一种待售产品类型,包括其库存单位(SKU)、描述、品牌名称、类别、脂肪含量、包装大小等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中销售的产品。查询通常涉及对多个维度表的多个连接。
|
||||
例如,在[图 3-5](/ch3#fig_dwh_schema) 中,其中一个维度是所售产品。`dim_product` 表中的每一行代表一种待售产品类型,包括其库存单位(SKU)、描述、品牌名称、类别、脂肪含量、包装大小等。`fact_sales` 表中的每一行使用外键来指示在该特定交易中销售的产品。查询通常涉及对多个维度表的多个连接。
|
||||
|
||||
甚至日期和时间通常使用维度表表示,因为这允许编码有关日期的额外信息(例如公共假期),允许查询区分假期和非假期的销售。
|
||||
|
||||
[图 3-5](/zh/ch3#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;与这些表的连接就像星星的光芒。
|
||||
[图 3-5](/ch3#fig_dwh_schema) 是星型模式的一个例子。该名称来自这样一个事实:当表关系被可视化时,事实表位于中间,被其维度表包围;与这些表的连接就像星星的光芒。
|
||||
|
||||
这个模板的一个变体被称为 **雪花型模式**,其中维度进一步分解为子维度。例如,品牌和产品类别可能有单独的表,`dim_product` 表中的每一行可以将品牌和类别作为外键引用,而不是将它们作为字符串存储在 `dim_product` 表中。雪花型模式比星型模式更规范化,但星型模式通常更受欢迎,因为它们对分析师来说更简单 [^12]。
|
||||
|
||||
@ -268,7 +268,7 @@ SELECT posts.id, posts.sender_id
|
||||
|
||||
支持文档数据模型的主要论点是模式灵活性、由于局部性而获得更好的性能,以及对于某些应用程序来说,它更接近应用程序使用的对象模型。关系模型通过提供对连接、多对一和多对多关系的更好支持来反驳。让我们更详细地研究这些论点。
|
||||
|
||||
如果应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 **分解** 为多个表的关系技术(如[图 3-1](/zh/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要的复杂应用程序代码。
|
||||
如果应用程序中的数据具有类似文档的结构(即一对多关系的树,通常一次加载整个树),那么使用文档模型可能是个好主意。将类似文档的结构 **分解** 为多个表的关系技术(如[图 3-1](/ch3#fig_obama_relational) 中的 `positions`、`education` 和 `contact_info`)可能导致繁琐的模式和不必要的复杂应用程序代码。
|
||||
|
||||
文档模型有局限性:例如,你不能直接引用文档中的嵌套项,而是需要说类似"用户 251 的职位列表中的第二项"。如果你确实需要引用嵌套项,关系方法效果更好,因为你可以通过其 ID 直接引用任何项。
|
||||
|
||||
@ -308,11 +308,11 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
|
||||
* 有许多不同类型的对象,将每种类型的对象放在自己的表中是不切实际的。
|
||||
* 数据的结构由你无法控制且可能随时更改的外部系统确定。
|
||||
|
||||
在这样的情况下,模式可能弊大于利,无模式文档可能是更自然的数据模型。但在所有记录都应具有相同结构的情况下,模式是记录和执行该结构的有用机制。我们将在[第 5 章](/zh/ch5#ch_encoding)中更详细地讨论模式和模式演化。
|
||||
在这样的情况下,模式可能弊大于利,无模式文档可能是更自然的数据模型。但在所有记录都应具有相同结构的情况下,模式是记录和执行该结构的有用机制。我们将在[第 5 章](/ch5#ch_encoding)中更详细地讨论模式和模式演化。
|
||||
|
||||
#### 读写的数据局部性 {#sec_datamodels_document_locality}
|
||||
|
||||
文档通常存储为单个连续字符串,编码为 JSON、XML 或其二进制变体(例如 MongoDB 的 BSON)。如果你的应用程序经常需要访问整个文档(例如,在网页上呈现它),这种 **存储局部性** 具有性能优势。如果数据分散在多个表中,如[图 3-1](/zh/ch3#fig_obama_relational),则需要多个索引查找来检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
|
||||
文档通常存储为单个连续字符串,编码为 JSON、XML 或其二进制变体(例如 MongoDB 的 BSON)。如果你的应用程序经常需要访问整个文档(例如,在网页上呈现它),这种 **存储局部性** 具有性能优势。如果数据分散在多个表中,如[图 3-1](/ch3#fig_obama_relational),则需要多个索引查找来检索所有数据,这可能需要更多的磁盘寻道并花费更多时间。
|
||||
|
||||
局部性优势仅在你同时需要文档的大部分时才适用。数据库通常需要加载整个文档,如果你只需要访问大文档的一小部分,这可能会浪费。在更新文档时,通常需要重写整个文档。出于这些原因,通常建议你保持文档相当小,并避免对文档进行频繁的小更新。
|
||||
|
||||
@ -324,7 +324,7 @@ UPDATE users SET first_name = substring_index(name, ' ', 1); -- MySQL
|
||||
|
||||
XML 数据库通常使用 XQuery 和 XPath 查询,它们旨在允许复杂查询,包括跨多个文档的连接,并且还将其结果格式化为 XML [^28]。JSON Pointer [^29] 和 JSONPath [^30] 为 JSON 提供了与 XPath 等效的功能。
|
||||
|
||||
MongoDB 的聚合管道(我们在["规范化、反规范化和连接"](/zh/ch3#sec_datamodels_normalization)中看到了其用于连接的 `$lookup` 操作符)是 JSON 文档集合查询语言的一个例子。
|
||||
MongoDB 的聚合管道(我们在["规范化、反规范化和连接"](/ch3#sec_datamodels_normalization)中看到了其用于连接的 `$lookup` 操作符)是 JSON 文档集合查询语言的一个例子。
|
||||
|
||||
让我们看另一个例子来感受这种语言——这次是聚合,这对于分析特别需要。想象你是一名海洋生物学家,每次在海洋中看到动物时,你都会向数据库添加观察记录。现在你想生成一份报告,说明你每月看到了多少鲨鱼。在 PostgreSQL 中,你可能会这样表达该查询:
|
||||
|
||||
@ -387,7 +387,7 @@ db.observations.aggregate([
|
||||
|
||||
众所周知的算法可以在这些图上运行:例如,地图导航应用程序在道路网络中搜索两点之间的最短路径,PageRank 可以用于网页图来确定网页的受欢迎程度,从而确定其在搜索结果中的排名 [^32]。
|
||||
|
||||
图可以用几种不同的方式表示。在 **邻接列表** 模型中,每个顶点存储其一条边之外的邻居顶点的 ID。或者,你可以使用 **邻接矩阵**,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接列表适合图遍历,矩阵适合机器学习(参见["数据框、矩阵和数组"](/zh/ch3#sec_datamodels_dataframes))。
|
||||
图可以用几种不同的方式表示。在 **邻接列表** 模型中,每个顶点存储其一条边之外的邻居顶点的 ID。或者,你可以使用 **邻接矩阵**,这是一个二维数组,其中每一行和每一列对应一个顶点,当行顶点和列顶点之间没有边时值为零,如果有边则值为一。邻接列表适合图遍历,矩阵适合机器学习(参见["数据框、矩阵和数组"](/ch3#sec_datamodels_dataframes))。
|
||||
|
||||
在刚才给出的例子中,图中的所有顶点分别代表相同类型的事物(人、网页或道路交叉口)。然而,图不限于这种 **同质** 数据:图的一个同样强大的用途是提供一种一致的方式在单个数据库中存储完全不同类型的对象。例如:
|
||||
|
||||
@ -398,7 +398,7 @@ db.observations.aggregate([
|
||||
|
||||
我们还将研究四种图查询语言(Cypher、SPARQL、Datalog 和 GraphQL),以及 SQL 对查询图的支持。还存在其他图查询语言,如 Gremlin [^37],但这些将给我们一个代表性的概述。
|
||||
|
||||
为了说明这些不同的语言和模型,本节使用[图 3-6](/zh/ch3#fig_datamodels_graph) 中显示的图作为运行示例。它可以取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点,它们之间的关系表示为边。这个例子将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
|
||||
为了说明这些不同的语言和模型,本节使用[图 3-6](/ch3#fig_datamodels_graph) 中显示的图作为运行示例。它可以取自社交网络或家谱数据库:它显示了两个人,来自爱达荷州的 Lucy 和来自法国圣洛的 Alain。他们已婚并住在伦敦。每个人和每个位置都表示为顶点,它们之间的关系表示为边。这个例子将帮助演示一些在图数据库中很容易但在其他模型中很困难的查询。
|
||||
|
||||
{{< figure src="/fig/ddia_0306.png" id="fig_datamodels_graph" caption="图 3-6. 图结构数据示例(框表示顶点,箭头表示边)。" class="w-full my-4" >}}
|
||||
|
||||
@ -420,7 +420,7 @@ db.observations.aggregate([
|
||||
* 描述两个顶点之间关系类型的标签
|
||||
* 属性集合(键值对)
|
||||
|
||||
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如[示例 3-3](/zh/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 数据类型来存储每个顶点或边的属性)。每条边都存储头顶点和尾顶点;如果你想要顶点的入边或出边集,可以分别通过 `head_vertex` 或 `tail_vertex` 查询 `edges` 表。
|
||||
你可以将图存储视为由两个关系表组成,一个用于顶点,一个用于边,如[示例 3-3](/ch3#fig_graph_sql_schema) 所示(此模式使用 PostgreSQL `jsonb` 数据类型来存储每个顶点或边的属性)。每条边都存储头顶点和尾顶点;如果你想要顶点的入边或出边集,可以分别通过 `head_vertex` 或 `tail_vertex` 查询 `edges` 表。
|
||||
|
||||
{{< figure id="fig_graph_sql_schema" title="示例 3-3. 使用关系模式表示属性图" class="w-full my-4" >}}
|
||||
|
||||
@ -446,10 +446,10 @@ CREATE INDEX edges_heads ON edges (head_vertex);
|
||||
这个模型的一些重要方面是:
|
||||
|
||||
1. 任何顶点都可以有一条边将其与任何其他顶点连接。没有模式限制哪些事物可以或不能相关联。
|
||||
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 **遍历** 图——即通过顶点链跟随路径——向前和向后。(这就是为什么[示例 3-3](/zh/ch3#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。)
|
||||
2. 给定任何顶点,你可以有效地找到其入边和出边,从而 **遍历** 图——即通过顶点链跟随路径——向前和向后。(这就是为什么[示例 3-3](/ch3#fig_graph_sql_schema) 在 `tail_vertex` 和 `head_vertex` 列上都有索引。)
|
||||
3. 通过使用不同的标签来表示不同类型的顶点和关系,你可以在单个图中存储几种不同类型的信息,同时仍然保持干净的数据模型。
|
||||
|
||||
edges 表就像我们在["多对一和多对多关系"](/zh/ch3#sec_datamodels_many_to_many)中看到的多对多关联表/连接表,推广到允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
|
||||
edges 表就像我们在["多对一和多对多关系"](/ch3#sec_datamodels_many_to_many)中看到的多对多关联表/连接表,推广到允许在同一表中存储许多不同类型的关系。标签和属性上也可能有索引,允许有效地找到具有某些属性的顶点或边。
|
||||
|
||||
--------
|
||||
|
||||
@ -458,7 +458,7 @@ edges 表就像我们在["多对一和多对多关系"](/zh/ch3#sec_datamodels_m
|
||||
|
||||
--------
|
||||
|
||||
这些特性为数据建模提供了很大的灵活性,如[图 3-6](/zh/ch3#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的事情,例如不同国家的不同地区结构(法国有 **省** 和 **大区**,而美国有 **县** 和 **州**)、历史的怪癖如国中之国(暂时忽略主权国家和民族的复杂性),以及数据的不同粒度(Lucy 的当前居住地被指定为城市,而她的出生地仅在州级别指定)。
|
||||
这些特性为数据建模提供了很大的灵活性,如[图 3-6](/ch3#fig_datamodels_graph) 所示。该图显示了一些在传统关系模式中难以表达的事情,例如不同国家的不同地区结构(法国有 **省** 和 **大区**,而美国有 **县** 和 **州**)、历史的怪癖如国中之国(暂时忽略主权国家和民族的复杂性),以及数据的不同粒度(Lucy 的当前居住地被指定为城市,而她的出生地仅在州级别指定)。
|
||||
|
||||
你可以想象扩展图以包括关于 Lucy 和 Alain 或其他人的许多其他事实。例如,你可以使用它来表示他们有哪些食物过敏(通过为每种过敏原引入一个顶点,以及人和过敏原之间的边来表示过敏),并将过敏原与一组显示哪些食物含有哪些物质的顶点链接。然后你可以编写查询来找出每个人吃什么是安全的。图有利于可演化性:当你向应用程序添加功能时,图可以轻松扩展以适应应用程序数据结构的变化。
|
||||
|
||||
@ -466,9 +466,9 @@ edges 表就像我们在["多对一和多对多关系"](/zh/ch3#sec_datamodels_m
|
||||
|
||||
**Cypher** 是属性图的查询语言,最初为 Neo4j 图数据库创建,后来发展为开放标准 **openCypher** [^38]。除了 Neo4j,Cypher 还得到 Memgraph、KùzuDB [^35]、Amazon Neptune、Apache AGE(在 PostgreSQL 中存储)等的支持。它以电影《黑客帝国》中的角色命名,与密码学中的密码无关 [^39]。
|
||||
|
||||
[示例 3-4](/zh/ch3#fig_cypher_create) 显示了将[图 3-6](/zh/ch3#fig_datamodels_graph) 左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个像 `usa` 或 `idaho` 这样的符号名称。该名称不存储在数据库中,而仅在查询内部使用以在顶点之间创建边,使用箭头表示法:`(idaho) -[:WITHIN]-> (usa)` 创建一个标记为 `WITHIN` 的边,`idaho` 作为尾节点,`usa` 作为头节点。
|
||||
[示例 3-4](/ch3#fig_cypher_create) 显示了将[图 3-6](/ch3#fig_datamodels_graph) 左侧部分插入图数据库的 Cypher 查询。图的其余部分可以类似地添加。每个顶点都被赋予一个像 `usa` 或 `idaho` 这样的符号名称。该名称不存储在数据库中,而仅在查询内部使用以在顶点之间创建边,使用箭头表示法:`(idaho) -[:WITHIN]-> (usa)` 创建一个标记为 `WITHIN` 的边,`idaho` 作为尾节点,`usa` 作为头节点。
|
||||
|
||||
{{< figure id="fig_cypher_create" title="示例 3-4. [图 3-6](/zh/ch3#fig_datamodels_graph) 中数据的子集,表示为 Cypher 查询" class="w-full my-4" >}}
|
||||
{{< figure id="fig_cypher_create" title="示例 3-4. [图 3-6](/ch3#fig_datamodels_graph) 中数据的子集,表示为 Cypher 查询" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
CREATE
|
||||
@ -480,9 +480,9 @@ CREATE
|
||||
(lucy) -[:BORN_IN]-> (idaho)
|
||||
```
|
||||
|
||||
当[图 3-6](/zh/ch3#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,**查找从美国移民到欧洲的所有人的姓名**。也就是说,找到所有具有 `BORN_IN` 边指向美国境内某个位置,并且也有 `LIVING_IN` 边指向欧洲境内某个位置的顶点,并返回每个这些顶点的 `name` 属性。
|
||||
当[图 3-6](/ch3#fig_datamodels_graph) 的所有顶点和边都添加到数据库后,我们可以开始提出有趣的问题:例如,**查找从美国移民到欧洲的所有人的姓名**。也就是说,找到所有具有 `BORN_IN` 边指向美国境内某个位置,并且也有 `LIVING_IN` 边指向欧洲境内某个位置的顶点,并返回每个这些顶点的 `name` 属性。
|
||||
|
||||
[示例 3-5](/zh/ch3#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头表示法用于 `MATCH` 子句以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。
|
||||
[示例 3-5](/ch3#fig_cypher_query) 显示了如何在 Cypher 中表达该查询。相同的箭头表示法用于 `MATCH` 子句以在图中查找模式:`(person) -[:BORN_IN]-> ()` 匹配由标记为 `BORN_IN` 的边相关的任意两个顶点。该边的尾顶点绑定到变量 `person`,头顶点未命名。
|
||||
|
||||
{{< figure id="fig_cypher_query" title="示例 3-5. Cypher 查询查找从美国移民到欧洲的人" class="w-full my-4" >}}
|
||||
|
||||
@ -508,7 +508,7 @@ RETURN person.name
|
||||
|
||||
### SQL 中的图查询 {#id58}
|
||||
|
||||
[示例 3-3](/zh/ch3#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但是,如果我们将图数据放入关系结构中,我们是否也可以使用 SQL 查询它?
|
||||
[示例 3-3](/ch3#fig_graph_sql_schema) 建议图数据可以在关系数据库中表示。但是,如果我们将图数据放入关系结构中,我们是否也可以使用 SQL 查询它?
|
||||
|
||||
答案是肯定的,但有一些困难。你在图查询中遍历的每条边实际上都是与 `edges` 表的连接。在关系数据库中,你通常事先知道查询中需要哪些连接。另一方面,在图查询中,你可能需要遍历可变数量的边才能找到你要查找的顶点——也就是说,连接的数量不是事先固定的。
|
||||
|
||||
@ -516,9 +516,9 @@ RETURN person.name
|
||||
|
||||
在 Cypher 中,`:WITHIN*0..` 非常简洁地表达了这一事实:它意味着"跟随 `WITHIN` 边,零次或多次"。它就像正则表达式中的 `*` 运算符。
|
||||
|
||||
自 SQL:1999 以来,这种查询中可变长度遍历路径的想法可以使用称为 **递归公用表表达式**(`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/zh/ch3#fig_graph_sql_query) 显示了相同的查询——查找从美国移民到欧洲的人的姓名——使用此技术用 SQL 表达。然而,与 Cypher 相比,语法非常笨拙。
|
||||
自 SQL:1999 以来,这种查询中可变长度遍历路径的想法可以使用称为 **递归公用表表达式**(`WITH RECURSIVE` 语法)的东西来表达。[示例 3-6](/ch3#fig_graph_sql_query) 显示了相同的查询——查找从美国移民到欧洲的人的姓名——使用此技术用 SQL 表达。然而,与 Cypher 相比,语法非常笨拙。
|
||||
|
||||
{{< figure id="fig_graph_sql_query" title="示例 3-6. 与[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查询,使用递归公用表表达式用 SQL 编写" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_sql_query" title="示例 3-6. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,使用递归公用表表达式用 SQL 编写" class="w-full my-4" >}}
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE
|
||||
@ -590,15 +590,15 @@ Oracle 对递归查询有不同的 SQL 扩展,它称之为 **分层** [^41]。
|
||||
|
||||
三元组的主语相当于图中的顶点。宾语是两种事物之一:
|
||||
|
||||
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上属性的键和值。使用[图 3-6](/zh/ch3#fig_datamodels_graph) 中的例子,(*lucy*、*birthYear*、*1989*)就像具有属性 `{"birthYear": 1989}` 的顶点 `lucy`。
|
||||
1. 原始数据类型的值,如字符串或数字。在这种情况下,三元组的谓语和宾语相当于主语顶点上属性的键和值。使用[图 3-6](/ch3#fig_datamodels_graph) 中的例子,(*lucy*、*birthYear*、*1989*)就像具有属性 `{"birthYear": 1989}` 的顶点 `lucy`。
|
||||
2. 图中的另一个顶点。在这种情况下,谓语是图中的边,主语是尾顶点,宾语是头顶点。例如,在(*lucy*、*marriedTo*、*alain*)中,主语和宾语 *lucy* 和 *alain* 都是顶点,谓语 *marriedTo* 是连接它们的边的标签。
|
||||
|
||||
> [!NOTE]
|
||||
> 准确地说,提供类似三元组数据模型的数据库通常需要在每个元组上存储一些额外的元数据。例如,AWS Neptune 通过向每个三元组添加图 ID 来使用四元组(4元组)[^46];Datomic 使用 5 元组,通过事务 ID 和表示删除的布尔值扩展每个三元组 [^47]。由于这些数据库保留了上述解释的基本 **主语-谓语-宾语** 结构,本书仍然称它们为三元组存储。
|
||||
|
||||
[示例 3-7](/zh/ch3#fig_graph_n3_triples) 显示了与[示例 3-4](/zh/ch3#fig_cypher_create) 相同的数据,以称为 **Turtle** 的格式编写为三元组,它是 **Notation3**(**N3**)的子集 [^48]。
|
||||
[示例 3-7](/ch3#fig_graph_n3_triples) 显示了与[示例 3-4](/ch3#fig_cypher_create) 相同的数据,以称为 **Turtle** 的格式编写为三元组,它是 **Notation3**(**N3**)的子集 [^48]。
|
||||
|
||||
{{< figure id="fig_graph_n3_triples" title="示例 3-7. [图 3-6](/zh/ch3#fig_datamodels_graph) 中数据的子集,表示为 Turtle 三元组" class="w-full my-4" >}}
|
||||
{{< figure link="#fig_datamodels_graph" id="fig_graph_n3_triples" title="示例 3-7. 图 3-6 中数据的子集,表示为 Turtle 三元组" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
@prefix : <urn:example:>.
|
||||
@ -620,9 +620,9 @@ _:namerica :type "continent".
|
||||
|
||||
在这个例子中,图的顶点写为 `_:someName`。该名称在此文件之外没有任何意义;它的存在只是因为否则我们不会知道哪些三元组引用同一个顶点。当谓语表示边时,宾语是顶点,如 `_:idaho :within _:usa`。当谓语是属性时,宾语是字符串文字,如 `_:usa :name "United States"`。
|
||||
|
||||
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号对同一主语说多件事。这使得 Turtle 格式相当可读:参见[示例 3-8](/zh/ch3#fig_graph_n3_shorthand)。
|
||||
一遍又一遍地重复相同的主语相当重复,但幸运的是,你可以使用分号对同一主语说多件事。这使得 Turtle 格式相当可读:参见[示例 3-8](/ch3#fig_graph_n3_shorthand)。
|
||||
|
||||
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 编写[示例 3-7](/zh/ch3#fig_graph_n3_triples) 中数据的更简洁方式" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_n3_shorthand" title="示例 3-8. 编写[示例 3-7](/ch3#fig_graph_n3_triples) 中数据的更简洁方式" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
@prefix : <urn:example:>.
|
||||
@ -644,9 +644,9 @@ _:namerica a :Location; :name "North America"; :type "continent".
|
||||
|
||||
#### RDF 数据模型 {#the-rdf-data-model}
|
||||
|
||||
我们在[示例 3-8](/zh/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 **资源描述框架**(RDF)[^55] 中编码数据的一种方式,这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)在 XML 中,如[示例 3-9](/zh/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 这样的工具可以在不同的 RDF 编码之间自动转换。
|
||||
我们在[示例 3-8](/ch3#fig_graph_n3_shorthand) 中使用的 Turtle 语言实际上是在 **资源描述框架**(RDF)[^55] 中编码数据的一种方式,这是为语义网设计的数据模型。RDF 数据也可以用其他方式编码,例如(更冗长地)在 XML 中,如[示例 3-9](/ch3#fig_graph_rdf_xml) 所示。像 Apache Jena 这样的工具可以在不同的 RDF 编码之间自动转换。
|
||||
|
||||
{{< figure id="fig_graph_rdf_xml" title="示例 3-9. [示例 3-8](/zh/ch3#fig_graph_n3_shorthand) 的数据,使用 RDF/XML 语法表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graph_rdf_xml" title="示例 3-9. [示例 3-8](/ch3#fig_graph_n3_shorthand) 的数据,使用 RDF/XML 语法表示" class="w-full my-4" >}}
|
||||
|
||||
```xml
|
||||
<rdf:RDF xmlns="urn:example:"
|
||||
@ -684,9 +684,9 @@ URL `<http://my-company.com/namespace>` 不一定需要解析为任何内容—
|
||||
|
||||
**SPARQL** 是使用 RDF 数据模型的三元组存储的查询语言 [^56]。(它是 **SPARQL 协议和 RDF 查询语言** 的缩写,发音为"sparkle"。)它早于 Cypher,由于 Cypher 的模式匹配借鉴自 SPARQL,它们看起来非常相似。
|
||||
|
||||
与以前相同的查询——查找从美国搬到欧洲的人——在 SPARQL 中与在 Cypher 中一样简洁(参见[示例 3-10](/zh/ch3#fig_sparql_query))。
|
||||
与以前相同的查询——查找从美国搬到欧洲的人——在 SPARQL 中与在 Cypher 中一样简洁(参见[示例 3-10](/ch3#fig_sparql_query))。
|
||||
|
||||
{{< figure id="fig_sparql_query" title="示例 3-10. 与[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查询,用 SPARQL 表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_sparql_query" title="示例 3-10. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 SPARQL 表示" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
PREFIX : <urn:example:>
|
||||
@ -724,9 +724,9 @@ Datalog 实际上基于关系数据模型而不是图,但它出现在本书的
|
||||
|
||||
Datalog 数据库的内容由 **事实** 组成,每个事实对应于关系表中的一行。例如,假设我们有一个包含位置的表 *location*,它有三列:*ID*、*name* 和 *type*。美国是一个国家的事实可以写为 `location(2, "United States", "country")`,其中 `2` 是美国的 ID。一般来说,语句 `table(val1, val2, …)` 意味着 `table` 包含一行,其中第一列包含 `val1`,第二列包含 `val2`,依此类推。
|
||||
|
||||
[示例 3-11](/zh/ch3#fig_datalog_triples) 显示了如何在 Datalog 中编写[图 3-6](/zh/ch3#fig_datamodels_graph) 左侧的数据。图的边(`within`、`born_in` 和 `lives_in`)表示为两列连接表。例如,Lucy 的 ID 为 100,爱达荷州的 ID 为 3,因此关系"Lucy 出生在爱达荷州"表示为 `born_in(100, 3)`。
|
||||
[示例 3-11](/ch3#fig_datalog_triples) 显示了如何在 Datalog 中编写[图 3-6](/ch3#fig_datamodels_graph) 左侧的数据。图的边(`within`、`born_in` 和 `lives_in`)表示为两列连接表。例如,Lucy 的 ID 为 100,爱达荷州的 ID 为 3,因此关系"Lucy 出生在爱达荷州"表示为 `born_in(100, 3)`。
|
||||
|
||||
{{< figure id="fig_datalog_triples" title="示例 3-11. [图 3-6](/zh/ch3#fig_datamodels_graph) 中数据的子集,表示为 Datalog 事实" class="w-full my-4" >}}
|
||||
{{< figure id="fig_datalog_triples" title="示例 3-11. [图 3-6](/ch3#fig_datamodels_graph) 中数据的子集,表示为 Datalog 事实" class="w-full my-4" >}}
|
||||
|
||||
```
|
||||
location(1, "North America", "continent").
|
||||
@ -740,9 +740,9 @@ person(100, "Lucy").
|
||||
born_in(100, 3). /* Lucy 出生在爱达荷州 */
|
||||
```
|
||||
|
||||
现在我们已经定义了数据,我们可以编写与以前相同的查询,如[示例 3-12](/zh/ch3#fig_datalog_query) 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有点不同,但不要让它吓到你。Datalog 是 Prolog 的子集,如果你学过计算机科学,你可能以前见过这种编程语言。
|
||||
现在我们已经定义了数据,我们可以编写与以前相同的查询,如[示例 3-12](/ch3#fig_datalog_query) 所示。它看起来与 Cypher 或 SPARQL 中的等效查询有点不同,但不要让它吓到你。Datalog 是 Prolog 的子集,如果你学过计算机科学,你可能以前见过这种编程语言。
|
||||
|
||||
{{< figure id="fig_datalog_query" title="示例 3-12. 与[示例 3-5](/zh/ch3#fig_cypher_query) 相同的查询,用 Datalog 表示" class="w-full my-4" >}}
|
||||
{{< figure id="fig_datalog_query" title="示例 3-12. 与[示例 3-5](/ch3#fig_cypher_query) 相同的查询,用 Datalog 表示" class="w-full my-4" >}}
|
||||
|
||||
```sql
|
||||
within_recursive(LocID, PlaceName) :- location(LocID, PlaceName, _). /* 规则 1 */
|
||||
@ -762,11 +762,11 @@ us_to_europe(Person) :- migrated(Person, "United States", "Europe"). /* 规则 4
|
||||
|
||||
Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小步。我们定义从底层事实派生新虚拟表的 **规则**。这些派生表就像(虚拟)SQL 视图:它们不存储在数据库中,但你可以像查询包含存储事实的表一样查询它们。
|
||||
|
||||
在[示例 3-12](/zh/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
|
||||
在[示例 3-12](/ch3#fig_datalog_query) 中,我们定义了三个派生表:`within_recursive`、`migrated` 和 `us_to_europe`。虚拟表的名称和列由每个规则的 `:-` 符号之前出现的内容定义。例如,`migrated(PName, BornIn, LivingIn)` 是一个具有三列的虚拟表:一个人的姓名、他们出生地的名称和他们居住地的名称。
|
||||
|
||||
虚拟表的内容由规则的 `:-` 符号之后的部分定义,我们在其中尝试在表中查找与某种模式匹配的行。例如,`person(PersonID, PName)` 匹配行 `person(100, "Lucy")`,变量 `PersonID` 绑定到值 `100`,变量 `PName` 绑定到值 `"Lucy"`。如果系统可以为 `:-` 运算符右侧的 **所有** 模式找到匹配,则规则适用。当规则适用时,就好像 `:-` 的左侧被添加到数据库中(变量被它们匹配的值替换)。
|
||||
|
||||
因此,应用规则的一种可能方式是(如[图 3-7](/zh/ch3#fig_datalog_naive) 所示):
|
||||
因此,应用规则的一种可能方式是(如[图 3-7](/ch3#fig_datalog_naive) 所示):
|
||||
|
||||
1. `location(1, "North America", "continent")` 存在于数据库中,因此规则 1 适用。它生成 `within_recursive(1, "North America")`。
|
||||
2. `within(2, 1)` 存在于数据库中,前一步生成了 `within_recursive(1, "North America")`,因此规则 2 适用。它生成 `within_recursive(2, "North America")`。
|
||||
@ -774,21 +774,21 @@ Cypher 和 SPARQL 直接用 `SELECT` 开始,但 Datalog 一次只迈出一小
|
||||
|
||||
通过重复应用规则 1 和 2,`within_recursive` 虚拟表可以告诉我们数据库中包含的北美(或任何其他位置)的所有位置。
|
||||
|
||||
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="图 3-7. 使用[示例 3-12](/zh/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。" class="w-full my-4" >}}
|
||||
{{< figure link="#fig_datalog_query" src="/fig/ddia_0307.png" id="fig_datalog_naive" title="图 3-7. 使用[示例 3-12](/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。" class="w-full my-4" >}}
|
||||
|
||||
> 图 3-7. 使用[示例 3-12](/zh/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。
|
||||
> 图 3-7. 使用[示例 3-12](/ch3#fig_datalog_query) 中的 Datalog 规则确定爱达荷州在北美。
|
||||
|
||||
现在规则 3 可以找到出生在某个位置 `BornIn` 并住在某个位置 `LivingIn` 的人。规则 4 调用规则 3,其中 `BornIn = 'United States'` 和 `LivingIn = 'Europe'`,并仅返回匹配搜索的人的姓名。通过查询虚拟 `us_to_europe` 表的内容,Datalog 系统最终得到与早期 Cypher 和 SPARQL 查询相同的答案。
|
||||
|
||||
与本章讨论的其他查询语言相比,Datalog 方法需要不同的思维方式。它允许逐条规则地构建复杂查询,一个规则引用其他规则,类似于你将代码分解为相互调用的函数的方式。就像函数可以递归一样,Datalog 规则也可以调用自己,就像[示例 3-12](/zh/ch3#fig_datalog_query) 中的规则 2,它在 Datalog 查询中启用图遍历。
|
||||
与本章讨论的其他查询语言相比,Datalog 方法需要不同的思维方式。它允许逐条规则地构建复杂查询,一个规则引用其他规则,类似于你将代码分解为相互调用的函数的方式。就像函数可以递归一样,Datalog 规则也可以调用自己,就像[示例 3-12](/ch3#fig_datalog_query) 中的规则 2,它在 Datalog 查询中启用图遍历。
|
||||
|
||||
### GraphQL {#id63}
|
||||
|
||||
GraphQL 是一种查询语言,设计上比本章中看到的其他查询语言更具限制性。GraphQL 的目的是允许在用户设备上运行的客户端软件(例如移动应用程序或 JavaScript Web 应用程序前端)请求具有特定结构的 JSON 文档,其中包含呈现其用户界面所需的字段。GraphQL 接口允许开发人员快速更改客户端代码中的查询,而无需更改服务器端 API。
|
||||
|
||||
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC(参见[第 5 章](/zh/ch5#ch_encoding))。授权、速率限制和性能挑战是其他问题 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 来自不受信任的来源。该语言不允许任何可能执行成本高昂的内容,否则用户可以通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是,GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且它不允许任意搜索条件,例如"查找出生在美国现在住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。
|
||||
GraphQL 的灵活性是有代价的。采用 GraphQL 的组织通常需要工具将 GraphQL 查询转换为对内部服务的请求,这些服务通常使用 REST 或 gRPC(参见[第 5 章](/ch5#ch_encoding))。授权、速率限制和性能挑战是其他问题 [^61]。GraphQL 的查询语言也受到限制,因为 GraphQL 来自不受信任的来源。该语言不允许任何可能执行成本高昂的内容,否则用户可以通过运行大量昂贵的查询对服务器执行拒绝服务攻击。特别是,GraphQL 不允许递归查询(与 Cypher、SPARQL、SQL 或 Datalog 不同),并且它不允许任意搜索条件,例如"查找出生在美国现在住在欧洲的人"(除非服务所有者特别选择提供此类搜索功能)。
|
||||
|
||||
尽管如此,GraphQL 还是很有用的。[示例 3-13](/zh/ch3#fig_graphql_query) 显示了如何使用 GraphQL 实现群聊应用程序(如 Discord 或 Slack)。查询请求用户有权访问的所有频道,包括频道名称和每个频道中最近的 50 条消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和头像 URL。此外,如果消息是对另一条消息的回复,查询还请求发送者姓名和它回复的消息内容(可能以较小的字体呈现在回复上方,以提供一些上下文)。
|
||||
尽管如此,GraphQL 还是很有用的。[示例 3-13](/ch3#fig_graphql_query) 显示了如何使用 GraphQL 实现群聊应用程序(如 Discord 或 Slack)。查询请求用户有权访问的所有频道,包括频道名称和每个频道中最近的 50 条消息。对于每条消息,它请求时间戳、消息内容以及消息发送者的姓名和头像 URL。此外,如果消息是对另一条消息的回复,查询还请求发送者姓名和它回复的消息内容(可能以较小的字体呈现在回复上方,以提供一些上下文)。
|
||||
|
||||
{{< figure id="fig_graphql_query" title="示例 3-13. 群聊应用程序的示例 GraphQL 查询" class="w-full my-4" >}}
|
||||
|
||||
@ -814,9 +814,9 @@ query ChatApp {
|
||||
}
|
||||
```
|
||||
|
||||
[示例 3-14](/zh/ch3#fig_graphql_response) 显示了对[示例 3-13](/zh/ch3#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它包含恰好请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来呈现用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不为 `replyTo` 消息的发送者请求头像 URL,但如果用户界面更改为添加该头像,客户端很容易将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
|
||||
[示例 3-14](/ch3#fig_graphql_response) 显示了对[示例 3-13](/ch3#fig_graphql_query) 中查询的响应可能是什么样子。响应是一个反映查询结构的 JSON 文档:它包含恰好请求的那些属性,不多也不少。这种方法的优点是服务器不需要知道客户端需要哪些属性来呈现用户界面;相反,客户端可以简单地请求它需要的内容。例如,此查询不为 `replyTo` 消息的发送者请求头像 URL,但如果用户界面更改为添加该头像,客户端很容易将所需的 `imageUrl` 属性添加到查询中,而无需更改服务器。
|
||||
|
||||
{{< figure id="fig_graphql_response" title="示例 3-14. 对[示例 3-13](/zh/ch3#fig_graphql_query) 中查询的可能响应" class="w-full my-4" >}}
|
||||
{{< figure id="fig_graphql_response" title="示例 3-14. 对[示例 3-13](/ch3#fig_graphql_query) 中查询的可能响应" class="w-full my-4" >}}
|
||||
|
||||
```json
|
||||
{
|
||||
@ -843,9 +843,9 @@ query ChatApp {
|
||||
...
|
||||
```
|
||||
|
||||
在[示例 3-14](/zh/ch3#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了设计选择,接受更大的响应大小,以便根据数据更简单地呈现用户界面。
|
||||
在[示例 3-14](/ch3#fig_graphql_response) 中,消息发送者的姓名和图像 URL 直接嵌入在消息对象中。如果同一用户发送多条消息,此信息会在每条消息上重复。原则上,可以减少这种重复,但 GraphQL 做出了设计选择,接受更大的响应大小,以便根据数据更简单地呈现用户界面。
|
||||
|
||||
`replyTo` 字段类似:在[示例 3-14](/zh/ch3#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID,但如果该 ID 不在返回的最近 50 条消息中,客户端将不得不向服务器发出额外的请求。复制内容使处理数据变得更加简单。
|
||||
`replyTo` 字段类似:在[示例 3-14](/ch3#fig_graphql_response) 中,第二条消息是对第一条消息的回复,内容("Hey!…")和发送者 Aaliyah 在 `replyTo` 下重复。可以改为返回被回复消息的 ID,但如果该 ID 不在返回的最近 50 条消息中,客户端将不得不向服务器发出额外的请求。复制内容使处理数据变得更加简单。
|
||||
|
||||
服务器的数据库可以以更规范化的形式存储数据,并执行必要的连接来处理查询。例如,服务器可能存储消息以及发送者的用户 ID 和它回复的消息的 ID;当它收到像上面这样的查询时,服务器会解析这些 ID 以找到它们引用的记录。但是,客户端只能要求服务器执行 GraphQL 模式中明确提供的连接。
|
||||
|
||||
@ -856,23 +856,23 @@ query ChatApp {
|
||||
|
||||
在我们迄今为止讨论的所有数据模型中,数据的查询形式与写入形式相同——无论是 JSON 文档、表中的行,还是图中的顶点和边。然而,在复杂的应用中,有时很难找到一种能够满足所有不同查询和展示需求的单一数据表示。在这种情况下,以一种形式写入数据,然后从中派生出针对不同类型读取优化的多种表示,可能会很有益处。
|
||||
|
||||
我们之前在[「记录系统和派生数据」](/zh/ch1#sec_introduction_derived)中看到过这个想法,ETL(参见[「数据仓库」](/zh/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想优化写入,而不关心高效查询,你会如何建模你的数据?
|
||||
我们之前在[「记录系统和派生数据」](/ch1#sec_introduction_derived)中看到过这个想法,ETL(参见[「数据仓库」](/ch1#sec_introduction_dwh))就是这种派生过程的一个例子。现在我们将进一步深入这个想法。如果我们无论如何都要从一种数据表示派生出另一种,我们可以选择分别针对写入和读取优化的不同表示。如果你只想优化写入,而不关心高效查询,你会如何建模你的数据?
|
||||
|
||||
也许写入数据最简单、最快、最具表现力的方式是**事件日志**:每次你想写入一些数据时,你将其编码为自包含的字符串(可能是 JSON),包含时间戳,然后将其追加到事件序列中。这个日志中的事件是**不可变的**:你永远不会更改或删除它们,你只会向日志追加更多事件(这些事件可能会取代之前的事件)。一个事件可以包含任意属性。
|
||||
|
||||
[图 3-8](/zh/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参会者可以注册并通过信用卡支付,公司也可以批量订购座位,通过发票支付,然后稍后将座位分配给个人。一定数量的座位可能会为演讲者、赞助商、志愿者助手等预留。预订也可能被取消,与此同时,会议组织者可能通过将活动移至不同的房间来改变活动的容量。在所有这些情况下,简单地计算可用座位数就成为一个具有挑战性的查询。
|
||||
[图 3-8](/ch3#fig_event_sourcing) 显示了一个可能来自会议管理系统的示例。会议可能是一个复杂的业务领域:不仅个人参会者可以注册并通过信用卡支付,公司也可以批量订购座位,通过发票支付,然后稍后将座位分配给个人。一定数量的座位可能会为演讲者、赞助商、志愿者助手等预留。预订也可能被取消,与此同时,会议组织者可能通过将活动移至不同的房间来改变活动的容量。在所有这些情况下,简单地计算可用座位数就成为一个具有挑战性的查询。
|
||||
|
||||
{{< figure src="/fig/ddia_0308.png" id="fig_event_sourcing" title="图 3-8. 使用不可变事件日志作为事实来源,并从中派生物化视图。" class="w-full my-4" >}}
|
||||
|
||||
在[图 3-8](/zh/ch3#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参会者进行和取消注册)首先被存储为事件。每当一个事件被追加到日志时,几个**物化视图**(也称为**投影**或**读模型**)也会被更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为制作参会者徽章的打印机生成文件。
|
||||
在[图 3-8](/ch3#fig_event_sourcing) 中,会议状态的每个变化(例如组织者开放注册,或参会者进行和取消注册)首先被存储为事件。每当一个事件被追加到日志时,几个**物化视图**(也称为**投影**或**读模型**)也会被更新以反映该事件的影响。在会议示例中,可能有一个物化视图收集与每个预订状态相关的所有信息,另一个为会议组织者的仪表板计算图表,第三个为制作参会者徽章的打印机生成文件。
|
||||
|
||||
使用事件作为事实来源,并将每个状态变化表示为事件的想法被称为**事件溯源** [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则被称为**命令查询责任分离(CQRS)** [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如在**状态机复制**中(参见[「使用共享日志」](/zh/ch10#sec_consistency_smr))。
|
||||
使用事件作为事实来源,并将每个状态变化表示为事件的想法被称为**事件溯源** [^62] [^63]。维护单独的读优化表示并从写优化表示派生它们的原则被称为**命令查询责任分离(CQRS)** [^64]。这些术语起源于领域驱动设计(DDD)社区,尽管类似的想法已经存在很长时间了,例如在**状态机复制**中(参见[「使用共享日志」](/ch10#sec_consistency_smr))。
|
||||
|
||||
当用户的请求进来时,它被称为**命令**,首先需要被验证。只有当命令被执行并被确定为有效(例如,有足够的可用座位来满足请求的预订)后,它才成为事实,相应的事件被添加到日志中。因此,事件日志应该只包含有效事件,构建物化视图的事件日志消费者不允许拒绝事件。
|
||||
|
||||
在以事件溯源风格建模数据时,建议你使用过去时命名事件(例如,"座位已被预订"),因为事件是记录过去发生的事实。即使用户后来决定更改或取消,他们曾经持有预订的事实仍然是真实的,更改或取消是稍后添加的单独事件。
|
||||
|
||||
事件溯源与[「星型和雪花型:分析模式」](/zh/ch3#sec_datamodels_analytics)中讨论的星型模式事实表之间的相似之处在于,两者都是过去发生的事件集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件是没有意义的。
|
||||
事件溯源与[「星型和雪花型:分析模式」](/ch3#sec_datamodels_analytics)中讨论的星型模式事实表之间的相似之处在于,两者都是过去发生的事件集合。然而,事实表中的行都具有相同的列集,而在事件溯源中可能有许多不同的事件类型,每种都有不同的属性。此外,事实表是无序集合,而在事件溯源中事件的顺序很重要:如果先进行预订然后取消,以错误的顺序处理这些事件是没有意义的。
|
||||
|
||||
事件溯源和 CQRS 有几个优点:
|
||||
|
||||
@ -880,7 +880,7 @@ query ChatApp {
|
||||
* 事件溯源的一个关键原则是物化视图以可重现的方式从事件日志派生:你应该始终能够删除物化视图并通过使用相同的代码以相同的顺序处理相同的事件来重新计算它们。如果视图维护代码中有错误,你可以删除视图并使用新代码重新计算它。找到错误也更容易,因为你可以根据需要多次重新运行视图维护代码并检查其行为。
|
||||
* 你可以拥有多个针对应用程序所需特定查询优化的物化视图。它们可以存储在与事件相同的数据库中或不同的数据库中,具体取决于你的需求。它们可以使用任何数据模型,并且可以为快速读取进行反规范化。你甚至可以只将视图保存在内存中而避免持久化,只要在服务重启时从事件日志重新计算视图是可以接受的。
|
||||
* 如果你决定以新的方式呈现现有信息,很容易从现有事件日志构建新的物化视图。你还可以通过添加新类型的事件或向现有事件类型添加新属性(任何旧事件保持不变)来演进系统以支持新功能。你还可以将新行为链接到现有事件(例如,当会议参会者取消时,他们的座位可以提供给等候名单上的下一个人)。
|
||||
* 如果事件被错误地写入,你可以再次删除它,然后你可以在没有删除的事件的情况下重建视图。另一方面,在直接更新和删除数据的数据库中,已提交的事务通常很难逆转。因此,事件溯源可以减少系统中不可逆操作的数量,使其更容易更改(参见[「可演化性:让变更变得容易」](/zh/ch2#sec_introduction_evolvability))。
|
||||
* 如果事件被错误地写入,你可以再次删除它,然后你可以在没有删除的事件的情况下重建视图。另一方面,在直接更新和删除数据的数据库中,已提交的事务通常很难逆转。因此,事件溯源可以减少系统中不可逆操作的数量,使其更容易更改(参见[「可演化性:让变更变得容易」](/ch2#sec_introduction_evolvability))。
|
||||
* 事件日志还可以作为系统中发生的所有事情的审计日志,这在需要此类可审计性的受监管行业中很有价值。
|
||||
|
||||
然而,事件溯源和 CQRS 也有缺点:
|
||||
@ -891,12 +891,12 @@ query ChatApp {
|
||||
|
||||
你可以在任何数据库之上实现事件溯源,但也有一些专门设计用于支持此模式的系统,例如 EventStoreDB、MartenDB(基于 PostgreSQL)和 Axon Framework。你还可以使用消息代理(如 Apache Kafka)来存储事件日志,流处理器可以保持物化视图是最新的;我们将在 [Link to Come] 中回到这些主题。
|
||||
|
||||
唯一重要的要求是事件存储系统必须保证所有物化视图以与它们在日志中出现的完全相同的顺序处理事件;正如我们将在[第 10 章](/zh/ch10#ch_consistency)中看到的,这在分布式系统中并不总是容易实现的。
|
||||
唯一重要的要求是事件存储系统必须保证所有物化视图以与它们在日志中出现的完全相同的顺序处理事件;正如我们将在[第 10 章](/ch10#ch_consistency)中看到的,这在分布式系统中并不总是容易实现的。
|
||||
|
||||
|
||||
## 数据框、矩阵与数组 {#sec_datamodels_dataframes}
|
||||
|
||||
本章到目前为止我们看到的数据模型通常既用于事务处理也用于分析目的(参见[「分析型与事务型系统」](/zh/ch1#sec_introduction_analytics))。还有一些数据模型你可能会在分析或科学背景下遇到,但很少出现在 OLTP 系统中:数据框和多维数值数组(如矩阵)。
|
||||
本章到目前为止我们看到的数据模型通常既用于事务处理也用于分析目的(参见[「分析型与事务型系统」](/ch1#sec_introduction_analytics))。还有一些数据模型你可能会在分析或科学背景下遇到,但很少出现在 OLTP 系统中:数据框和多维数值数组(如矩阵)。
|
||||
|
||||
数据框是 R 语言、Python 的 Pandas 库、Apache Spark、ArcticDB、Dask 和其他系统支持的数据模型。它们是数据科学家准备数据以训练机器学习模型的流行工具,但它们也广泛用于数据探索、统计数据分析、数据可视化和类似目的。
|
||||
|
||||
@ -906,16 +906,16 @@ query ChatApp {
|
||||
|
||||
数据框 API 还提供了远远超出关系数据库所提供的各种操作,数据模型的使用方式通常与典型的关系数据建模截然不同 [^65]。例如,数据框的一个常见用途是将数据从类关系表示转换为矩阵或多维数组表示,这是许多机器学习算法期望的输入形式。
|
||||
|
||||
[图 3-9](/zh/ch3#fig_dataframe_to_matrix) 显示了这种转换的简单示例。左边我们有一个不同用户如何对各种电影进行评分(1 到 5 分)的关系表,右边数据已被转换为矩阵,其中每列是一部电影,每行是一个用户(类似于电子表格中的**透视表**)。矩阵是**稀疏的**,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有数千列,因此不太适合关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy)可以轻松处理这样的数据。
|
||||
[图 3-9](/ch3#fig_dataframe_to_matrix) 显示了这种转换的简单示例。左边我们有一个不同用户如何对各种电影进行评分(1 到 5 分)的关系表,右边数据已被转换为矩阵,其中每列是一部电影,每行是一个用户(类似于电子表格中的**透视表**)。矩阵是**稀疏的**,这意味着许多用户-电影组合没有数据,但这没关系。这个矩阵可能有数千列,因此不太适合关系数据库,但数据框和提供稀疏数组的库(如 Python 的 NumPy)可以轻松处理这样的数据。
|
||||
|
||||
{{< figure src="/fig/ddia_0309.png" id="fig_dataframe_to_matrix" title="图 3-9. 将电影评分的关系数据库转换为矩阵表示。" class="w-full my-4" >}}
|
||||
|
||||
矩阵只能包含数字,各种技术用于将非数值数据转换为矩阵中的数字。例如:
|
||||
|
||||
* 日期(在[图 3-9](/zh/ch3#fig_dataframe_to_matrix) 的示例矩阵中被省略)可以缩放为某个合适范围内的浮点数。
|
||||
* 日期(在[图 3-9](/ch3#fig_dataframe_to_matrix) 的示例矩阵中被省略)可以缩放为某个合适范围内的浮点数。
|
||||
* 对于只能取一小组固定值之一的列(例如,电影数据库中电影的类型),通常使用**独热编码**:我们为每个可能的值创建一列(一个用于"喜剧",一个用于"戏剧",一个用于"恐怖"等),对于代表电影的每一行,我们在与该电影类型对应的列中放置 1,在所有其他列中放置 0。这种表示也很容易推广到适合多种类型的电影。
|
||||
|
||||
一旦数据以数字矩阵的形式存在,它就适合进行线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](/zh/ch3#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影的系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
|
||||
一旦数据以数字矩阵的形式存在,它就适合进行线性代数运算,这构成了许多机器学习算法的基础。例如,[图 3-9](/ch3#fig_dataframe_to_matrix) 中的数据可能是推荐用户可能喜欢的电影的系统的一部分。数据框足够灵活,允许数据从关系形式逐渐演变为矩阵表示,同时让数据科学家控制最适合实现数据分析或模型训练过程目标的表示。
|
||||
|
||||
还有一些数据库(如 TileDB [^66])专门存储大型多维数值数组;它们被称为**数组数据库**,最常用于科学数据集,如地理空间测量(规则间隔网格上的栅格数据)、医学成像或天文望远镜的观测 [^67]。数据框也在金融行业用于表示**时间序列数据**,如资产价格和交易随时间的变化 [^68]。
|
||||
|
||||
@ -933,7 +933,7 @@ query ChatApp {
|
||||
|
||||
因此,为每个数据模型开发了各种专门的数据库,提供针对特定模型优化的查询语言和存储引擎。然而,数据库也有一种趋势是通过添加对其他数据模型的支持来扩展到相邻的领域:例如,关系数据库已经以 JSON 列的形式添加了对文档数据的支持,文档数据库已经添加了类似关系的连接,SQL 中对图数据的支持也在逐渐改善。
|
||||
|
||||
我们讨论的另一个模型是**事件溯源**,它将数据表示为不可变事件的仅追加日志,这对于在复杂业务领域中建模活动可能是有利的。仅追加日志很适合写入数据(正如我们将在[第 4 章](/zh/ch4#ch_storage)中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。
|
||||
我们讨论的另一个模型是**事件溯源**,它将数据表示为不可变事件的仅追加日志,这对于在复杂业务领域中建模活动可能是有利的。仅追加日志很适合写入数据(正如我们将在[第 4 章](/ch4#ch_storage)中看到的);为了支持高效查询,事件日志通过 CQRS 转换为读优化的物化视图。
|
||||
|
||||
非关系数据模型的一个共同点是,它们通常不会对存储的数据强制执行模式,这可以使应用程序更容易适应不断变化的需求。然而,你的应用程序很可能仍然假设数据具有某种结构;这只是一个问题,即模式是显式的(在写入时强制执行)还是隐式的(在读取时假设)。
|
||||
|
||||
@ -941,7 +941,7 @@ query ChatApp {
|
||||
|
||||
* 研究基因组数据的研究人员经常需要执行**序列相似性搜索**,这意味着取一个很长的字符串(代表 DNA 分子)并将其与大型字符串数据库进行匹配,这些字符串相似但不相同。这里描述的数据库都不能处理这种用法,这就是为什么研究人员编写了专门的基因组数据库软件,如 GenBank [^69]。
|
||||
* 许多金融系统使用带有复式记账的**账本**作为其数据模型。这种类型的数据可以在关系数据库中表示,但也有像 TigerBeetle 这样专门针对这种数据模型的数据库。加密货币和区块链通常基于分布式账本,它们也将价值转移内置到其数据模型中。
|
||||
* **全文搜索**可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专门主题,我们不会在本书中详细介绍,但我们将在[「全文搜索」](/zh/ch4#sec_storage_full_text)中涉及搜索索引和向量搜索。
|
||||
* **全文搜索**可以说是一种经常与数据库一起使用的数据模型。信息检索是一个大型的专门主题,我们不会在本书中详细介绍,但我们将在[「全文搜索」](/ch4#sec_storage_full_text)中涉及搜索索引和向量搜索。
|
||||
|
||||
我们现在必须就此打住。在下一章中,我们将讨论在**实现**本章描述的数据模型时涉及的一些权衡。
|
||||
|
||||
|
BIN
static/fig/ddia_0101.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
static/fig/ddia_0102.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
static/fig/ddia_0201.png
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
static/fig/ddia_0202.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
static/fig/ddia_0203.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
static/fig/ddia_0204.png
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
static/fig/ddia_0205.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
static/fig/ddia_0206.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
static/fig/ddia_0301.png
Normal file
After Width: | Height: | Size: 178 KiB |
BIN
static/fig/ddia_0302.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
static/fig/ddia_0303.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
static/fig/ddia_0304.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
static/fig/ddia_0305.png
Normal file
After Width: | Height: | Size: 204 KiB |
BIN
static/fig/ddia_0306.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
static/fig/ddia_0307.png
Normal file
After Width: | Height: | Size: 75 KiB |
BIN
static/fig/ddia_0308.png
Normal file
After Width: | Height: | Size: 189 KiB |
BIN
static/fig/ddia_0309.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
static/fig/ddia_0401.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
static/fig/ddia_0402.png
Normal file
After Width: | Height: | Size: 116 KiB |
BIN
static/fig/ddia_0403.png
Normal file
After Width: | Height: | Size: 128 KiB |
BIN
static/fig/ddia_0404.png
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
static/fig/ddia_0405.png
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
static/fig/ddia_0406.png
Normal file
After Width: | Height: | Size: 97 KiB |
BIN
static/fig/ddia_0407.png
Normal file
After Width: | Height: | Size: 128 KiB |
BIN
static/fig/ddia_0408.png
Normal file
After Width: | Height: | Size: 136 KiB |
BIN
static/fig/ddia_0409.png
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
static/fig/ddia_0410.png
Normal file
After Width: | Height: | Size: 43 KiB |
BIN
static/fig/ddia_0411.png
Normal file
After Width: | Height: | Size: 227 KiB |
BIN
static/fig/ddia_0501.png
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
static/fig/ddia_0502.png
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
static/fig/ddia_0503.png
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
static/fig/ddia_0504.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
static/fig/ddia_0505.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
static/fig/ddia_0506.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
static/fig/ddia_0507.png
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
static/fig/ddia_0601.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
static/fig/ddia_0602.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
static/fig/ddia_0603.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
static/fig/ddia_0604.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/fig/ddia_0605.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
static/fig/ddia_0606.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
static/fig/ddia_0607.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
static/fig/ddia_0608.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
static/fig/ddia_0609.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/fig/ddia_0610.png
Normal file
After Width: | Height: | Size: 97 KiB |
BIN
static/fig/ddia_0611.png
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
static/fig/ddia_0612.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
static/fig/ddia_0613.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
static/fig/ddia_0614.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
static/fig/ddia_0615.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
static/fig/ddia_0616.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
static/fig/ddia_0701.png
Normal file
After Width: | Height: | Size: 122 KiB |
BIN
static/fig/ddia_0702.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/fig/ddia_0703.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
static/fig/ddia_0704.png
Normal file
After Width: | Height: | Size: 103 KiB |
BIN
static/fig/ddia_0705.png
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
static/fig/ddia_0706.png
Normal file
After Width: | Height: | Size: 103 KiB |
BIN
static/fig/ddia_0707.png
Normal file
After Width: | Height: | Size: 91 KiB |
BIN
static/fig/ddia_0708.png
Normal file
After Width: | Height: | Size: 147 KiB |
BIN
static/fig/ddia_0709.png
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
static/fig/ddia_0710.png
Normal file
After Width: | Height: | Size: 121 KiB |
BIN
static/fig/ddia_08.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
static/fig/ddia_0801.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
static/fig/ddia_0802.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
static/fig/ddia_0803.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/fig/ddia_0804.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/fig/ddia_0805.png
Normal file
After Width: | Height: | Size: 106 KiB |
BIN
static/fig/ddia_0806.png
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
static/fig/ddia_0807.png
Normal file
After Width: | Height: | Size: 178 KiB |
BIN
static/fig/ddia_0808.png
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
static/fig/ddia_0809.png
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
static/fig/ddia_0810.png
Normal file
After Width: | Height: | Size: 151 KiB |
BIN
static/fig/ddia_0811.png
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
static/fig/ddia_0812.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
static/fig/ddia_0813.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
static/fig/ddia_0814.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/fig/ddia_0901.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
static/fig/ddia_0902.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
static/fig/ddia_0903.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
static/fig/ddia_0904.png
Normal file
After Width: | Height: | Size: 95 KiB |
BIN
static/fig/ddia_0905.png
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
static/fig/ddia_0906.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
static/fig/ddia_0907.png
Normal file
After Width: | Height: | Size: 149 KiB |
BIN
static/fig/ddia_1001.png
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
static/fig/ddia_1002.png
Normal file
After Width: | Height: | Size: 9.7 KiB |
BIN
static/fig/ddia_1003.png
Normal file
After Width: | Height: | Size: 8.7 KiB |
BIN
static/fig/ddia_1004.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
static/fig/ddia_1005.png
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
static/fig/ddia_1006.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/fig/ddia_1007.png
Normal file
After Width: | Height: | Size: 82 KiB |