軟體工程師職涯升級計畫啟動!立即預約職涯諮詢、履歷健檢或模擬面試👈,為您的加薪做好準備!
前言:為何資料模型如此重要?
你曾想過,網路上那些看似輕而易舉的操作,如瀏覽社群媒體動態、購物車結帳,或是查詢天氣資訊,背後是如何精準且快速地運作的嗎?答案就藏在「資料模型(Data Model)」與「查詢語言(Query Language)」的奧秘之中。
本章將帶你深入探索各種資料模型的原理與應用場景,從傳統的關聯式模型(Relational Model)到新興的文件模型(Document Model),以及處理複雜關係的圖形資料模型(Graph Data Model)。我們還將一窺各種查詢語言的魔力,了解它們如何讓我們高效地與資料庫「對話」。在下一章,我們將會更深入探討儲存引擎的運作方式,也就是這些資料模型是如何被實際實現的。
關聯式模型 vs. 文件模型:一場數據儲存的典範之爭
NoSQL 的崛起:打破傳統束縛
曾幾何時,關聯式資料庫(RDBMS)是資料儲存領域的絕對霸主。然而,隨著網際網路的爆炸式發展,傳統關聯式資料庫在某些方面開始顯現其局限性,這催生了 **NoSQL(Not Only SQL)**運動的興起。NoSQL 資料庫之所以受到青睞,主要有以下幾個原因:
- 規模化與高吞吐量需求: 隨著數據量的飛速增長和寫入請求的激增,傳統關聯式資料庫在擴展性上遇到了瓶頸。NoSQL 資料庫,如 MongoDB、Cassandra,旨在提供更好的水平擴展能力,以應對超大規模資料集或極高的寫入吞吐量。
- 實際案例: 想像一下 Facebook 每天數十億的按讚和貼文,如果都儲存在單一關聯式資料庫中,很快就會達到性能極限。NoSQL 資料庫的分布式特性使其能夠輕鬆應對這種規模的數據流量。
- 開源與彈性: 許多 NoSQL 解決方案是開源的,這降低了企業的營運成本,並提供了更高的客製化彈性。
- 更靈活的資料模型: 關聯式模型雖然結構嚴謹,但在處理某些特殊查詢操作時,會顯得力不從心。NoSQL 提供更多元的資料模型,以適應不同類型資料的儲存需求。
- 物件關係不匹配(Object-Relational Impedance Mismatch)的困擾: 在現代軟體開發中,物件導向程式語言(如 Java, Python, C#)是主流。當我們將物件的資料儲存到關聯式資料庫的表格中時,需要一個「笨拙的轉換層」來將物件結構映射到表格的行與列,反之亦然。這種模型間的「不一致性」被稱為阻抗不匹配。
- 補充知識:ORM(Object-Relational Mapping)
- 為了緩解物件關係阻抗不匹配問題,開發者們發明了 ORM 工具(如 Java 的 Hibernate、Python 的 SQLAlchemy)。ORM 框架能夠自動處理物件與關聯式資料庫之間的映射轉換,讓開發者可以直接操作物件,而無需編寫大量 SQL 語句。雖然 ORM 解決了一部分問題,但它本身也引入了學習曲線和潛在的性能問題。
LinkedIn 個人檔案:從多表關聯到單一文件
讓我們以一個實際的例子來感受這種差異:假設我們要表示一個 LinkedIn 個人檔案的資料。

在關聯式模式中,一個完整的個人檔案需要分解成多個表格:User、Positions、Education、ContactInfo 等等,並透過外來鍵(Foreign Key)彼此關聯。例如,一個使用者可能有「多個工作經驗」、「多個教育階段」和「任意數量的聯絡資訊」,這些都是一對多關係。
要取得一個完整的個人檔案,你需要執行多次查詢,或執行**多路連接(multiway JOIN)**操作,這不僅複雜,而且可能影響性能。
例 2-1:用 JSON 文件表示一個 LinkedIn 個人檔案
JSON
{
"user_id": 251,
"first_name": "Bill",
"last_name": "Gates",
"summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
"region_id": "us:91",
"industry_id": 131,
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{
"job_title": "Co-chair",
"organization": "Bill & Melinda Gates Foundation"
},
{
"job_title": "Co-founder, Chairman",
"organization": "Microsoft"
}
],
"education": [
{
"school_name": "Harvard University",
"start": 1973,
"end": 1975
},
{
"school_name": "Lakeside School, Seattle",
"start": null,
"end": null
}
],
"contact_info": {
"blog": "http://thegatesnotes.com",
"twitter": "http://twitter.com/BillGates"
}
}
對於像個人檔案這樣「自包含(self-contained)」的資料結構而言,JSON 文件表示顯得非常合適。文件導向資料庫(如 MongoDB、RethinkDB、CouchDB)原生支援這種模型。

這種 JSON 表示相比多表模式具有更好的資料區域性(locality)。所有相關資訊都儲存在同一個地方,一次查詢即可獲取完整的個人檔案,大大簡化了應用程式的程式碼,減少了阻抗不匹配的問題。
多對一與多對多關係:文件模型的挑戰
雖然文件模型在處理一對多關係時表現出色,但在處理多對一和多對多關係時,卻顯得力不從心。
以 LinkedIn 個人檔案為例:

- Region 和 Industry:
region_id和industry_id以 ID 而非純文字形式呈現。這是因為如果一個地區或行業名稱需要更改,只需更新一處即可,避免了資料冗餘和不一致的風險。這種「規範化(Normalization)」是關聯式資料庫的核心思想。 - 補充知識:資料庫規範化
- 規範化是設計關聯式資料庫時的一系列規則,旨在減少數據冗餘、提高數據完整性和一致性。它將大型表格分解成小型、互相關聯的表格,通常分為多個「範式(Normal Form)」。雖然規範化減少了數據冗餘,但查詢時需要更多的連接操作,這可能會增加查詢複雜性和性能開銷。
- 組織與學校作為實體: 想像一下,如果我們希望每個公司或學校都有自己的頁面,顯示其標誌、新聞等資訊。這意味著在個人檔案中提到的「組織」和「學校」不應只是簡單的字串,而應該是對實體的引用。這便引入了多對多關係:一個組織可以被多個使用者引用,一個使用者也可以在多個組織工作或多所學校學習。

在關聯式資料庫中,透過 ID 引用其他表格中的行是常態,因為**連接(JOIN)**操作是其核心功能。但在文件資料庫中,對連接的支援通常較弱,這就成為一個難題。
文件資料庫的局限性與資料區域性
文件資料庫雖然強調資料區域性,但這僅適用於同時需要文件絕大部分內容的情況。
- 更新成本: 當更新文件時,通常需要重寫整個文件,即使只修改其中一小部分。這對於大型文件來說是低效率的。
- 性能限制: 為了優化性能,通常建議文件保持相對較小,並避免會增加文件大小的寫入操作。這些限制大大減少了文件資料庫的實用場景。
- 實際案例: 假設你將一個大型的客戶訂單明細(包含數百萬條商品)儲存在一個文件中。如果只是想修改其中一條商品的數量,文件資料庫可能需要讀取整個文件,修改後再寫入整個文件,這將非常耗時且佔用大量資源。
文件模型中的模式靈活性:優勢與挑戰
大多數文件資料庫並不強制文件中資料的模式(Schema),這使得它們常被稱為「無模式(schemaless)」。這意味著你可以向文件中添加任意的鍵值對,而無需預先定義。
然而,這具有誤導性。程式碼在讀取資料時,通常會假定某種結構,這是一種隱式模式(Implicit Schema),而不是由資料庫強制執行的。更精確的術語是「讀時模式(Schema-on-Read)」,相對於傳統關聯式資料庫的「寫時模式(Schema-on-Write)」。
讀時模式 vs. 寫時模式:程式設計語言的類比
- 讀時模式: 類似於程式語言中的動態(執行時)型別檢查。你可以在任何時候改變資料的結構,程式碼在讀取時再適應這種變化。
JavaScript
if (user && user.name && !user.first_name) { // Documents written before Dec 8, 2013 don't have first_name user.first_name = user.name.split(" ")[0]; user.last_name = user.name.split(" ")[1]; // 假設只拆分兩部分 } - 優勢: 靈活性高,快速適應需求變化。
- 挑戰: 可能導致資料不一致性,需要應用程式層的程式碼來處理舊數據格式。
- 實際案例: 假設你將用戶的全名儲存為一個欄位
user.name。現在你決定將其拆分為first_name和last_name。在文件資料庫中,你可以直接開始寫入包含新欄位的新文件。對於舊文件,你的應用程式程式碼會檢查user.name是否存在,如果不存在且first_name不存在,則根據user.name分割並填充first_name和last_name。
- 寫時模式: 類似於程式語言中的靜態(編譯時)型別檢查。你需要在資料庫中明確定義模式,資料庫會確保所有數據都符合該模式。
SQL
ALTER TABLE users ADD COLUMN first_name text; ALTER TABLE users ADD COLUMN last_name text; UPDATE users SET first_name = split_part(name, ' ', 1); -- PostgreSQL UPDATE users SET last_name = split_part(name, ' ', 2); -- PostgreSQL (假設名和姓用空格分隔)
儘管某些關係資料庫的ALTER TABLE語句執行很快,但對於大型表,UPDATE語句仍然會很慢。這也促使了許多關聯式資料庫開始支援 JSON 類型,試圖結合兩者的優點。 - 優勢: 數據完整性強,一致性高。
- 挑戰: 模式變更(
ALTER TABLE)可能導致停機或耗時。 - 實際案例: 在關聯式資料庫中,要實現上述姓名拆分,你需要執行模式遷移操作:
資料庫融合:殊途同歸的趨勢
隨著時間的推移,關聯式資料庫和文件資料庫似乎正變得越來越相似。許多關聯式資料庫開始支援 JSON 類型,允許在表中儲存半結構化數據,並提供相關的查詢功能。而一些文件資料庫也開始提供更強大的聚合框架或有限的連接能力。
這種融合趨勢是積極的,它意味著開發者可以根據具體需求,靈活選擇最適合的資料模型特性。未來的資料庫很可能朝著混合模型的方向發展,同時提供類似文件的儲存能力和關聯式查詢的強大功能。
資料查詢語言:與數據對話的藝術
資料查詢語言是我們與資料庫互動的橋樑,它們允許我們從資料中提取、分析和轉換資訊。
宣告式查詢語言的魅力
許多常用的程式語言是**命令式(Imperative)**的,你必須一步步指定如何完成任務。例如,在 JavaScript 中遍歷列表來查找鯊魚:
JavaScript
function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
}
而像 **SQL(Structured Query Language)這樣的宣告式查詢語言(Declarative Query Language)**則不同。你只需指定「想要什麼」(資料的模式和轉換方式),而無需關心「如何實現」。資料庫的查詢最佳化器會自動決定最佳的執行策略。
SQL
SELECT * FROM animals WHERE family ='Sharks';
網站開發中的宣告式查詢:CSS 的啟示
宣告式查詢的優勢不僅限於資料庫。在 Web 開發中,使用宣告式 CSS 樣式比使用 JavaScript **命令式地操作 DOM(Document Object Model)**要高效得多。
想像一下,要將頁面中選定項目的標題背景設為藍色。
使用 CSS(宣告式):
CSS
li.selected > p {
background-color: blue;
}
使用 JavaScript(命令式):
JavaScript
var liElements = document.getElementsByTagName("li");
for (var i = 0; i < liElements.length; i++) {
if (liElements[i].className === "selected") {
var children = liElements[i].childNodes;
for (var j = 0; j < children.length; j++) {
var child = children[j];
if (child.nodeType === Node.ELEMENT_NODE && child.tagName === "P") {
child.setAttribute("style", "background-color: blue");
}
}
}
}
顯然,CSS 的宣告式方法更簡潔、易於維護且性能更好。同理,在資料庫中,SQL 等宣告式查詢語言也帶來了類似的優勢。
MapReduce:大數據的函式式處理模型
除了 SQL,還有其他重要的查詢範式,例如大數據領域常用的 MapReduce。MapReduce 是一種函式式程式設計模型,由兩個核心函式組成:map 和 reduce。
例:統計每月鯊魚數量
在 PostgreSQL 中,這個查詢可以這樣表達:
SQL
SELECT
date_trunc('month', observation_timestamp) AS observation_month,
sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
同樣的查詢,用 MongoDB 的 MapReduce 功能可以這樣表達:
JavaScript
db.observations.mapReduce(function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getMonth() + 1;
emit(year + "-" + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
},
{
query: {
family: "Sharks"
},
out: "monthlySharkReport"
});
- Map 函式: 對每個文件執行一次,發出鍵值對。例如,對於一條鯊魚觀察記錄,它可能發出
("1995-12", 3)和("1995-12", 4)。 - Reduce 函式: 接收同一個鍵的所有值列表,並對其進行匯總。例如,對於鍵
("1995-12"),它會接收值列表[3, 4]並返回總和7。
Map 和 Reduce 函式必須是「純函式(Pure Function)」,這意味著它們只依賴於輸入數據,不能執行額外的資料庫查詢或產生副作用。這種限制允許資料庫以任何順序執行這些函式,並在失敗時重新執行,這對於分散式系統的容錯性至關重要。
圖形資料模型:探索複雜關係的利器
當資料項之間存在複雜且多樣的多對多關係時,**圖形資料模型(Graph Data Model)**是最佳選擇。圖由兩種物件組成:
- 頂點(Vertices): 也稱為節點(Nodes)或實體(Entities),代表資料中的獨立個體,例如:人、地點、公司、事件等。
- 邊(Edges): 也稱為關係(Relationships)或弧(Arcs),代表頂點之間的連接或互動,例如:朋友關係、居住地、工作於、出生地等。

這個例子展示了兩個人的關係網絡,包括他們的出生地、居住地以及彼此的關係。
屬性圖:描述豐富的關係細節
在屬性圖模型(Property Graph Model)中,每個頂點和邊都可以擁有自己的屬性(Properties)(鍵值對),這使得圖形能夠儲存更豐富的細節。
- 頂點的屬性:
- 唯一的識別符號
- 一組出邊(outgoing edges)
- 一組入邊(ingoing edges)
- 一組屬性(鍵值對,例如:
{name: 'Lucy', type: 'Person'})
- 邊的屬性:
- 唯一的識別符號
- 邊的起點(尾部頂點,
tail vertex) - 邊的終點(頭部頂點,
head vertex) - 描述兩個頂點之間關係型別的標籤(Label)(例如:
BORN_IN,LIVES_IN,WITHIN) - 一組屬性(鍵值對,例如:
{startDate: '2020-01-01'})
例 2–2:使用關聯式模式表示屬性圖(SQL Schema)
儘管圖模型是描述關係的最佳方式,但你仍然可以使用關聯式資料庫來實現它,只是相對笨拙:
SQL
CREATE TABLE vertices (
vertex_id INTEGER PRIMARY KEY,
properties JSON -- 儲存頂點屬性
);
CREATE TABLE edges (
edge_id INTEGER PRIMARY KEY,
tail_vertex INTEGER REFERENCES vertices (vertex_id),
head_vertex INTEGER REFERENCES vertices (vertex_id),
label TEXT, -- 邊的型別
properties JSON -- 儲存邊屬性
);
CREATE INDEX edges_tails ON edges (tail_vertex);
CREATE INDEX edges_heads ON edges (head_vertex);
這個模式需要兩個表:一個用於儲存所有頂點,另一個用於儲存所有邊。通過在 tail_vertex 和 head_vertex 上建立索引,可以高效地遍歷圖。
Cypher:直觀的圖查詢語言
Cypher 是圖形資料庫 Neo4j 所使用的宣告式查詢語言,它以其直觀的圖形匹配語法而聞名。
例 2–3:將圖 2–5 中的資料子集表示為 Cypher 查詢
Cypher
CREATE
(NAmerica:Location {name:'North America', type:'continent'}),
(USA:Location {name:'United States', type:'country' }),
(Idaho:Location {name:'Idaho', type:'state' }),
(Lucy:Person {name:'Lucy' }),
(Idaho) -[:WITHIN]-> (USA) -[:WITHIN]-> (NAmerica),
(Lucy) -[:BORN_IN]-> (Idaho)
案例:查詢從美國移民到歐洲的人
想像一個情境:我們要找出所有「從美國出生,但居住在歐洲」的人。
例 2–4:查詢所有從美國移民到歐洲的人的 Cypher 查詢:
Cypher
MATCH
(person) -[:BORN_IN]-> () -[:WITHIN*0..]-> (us:Location {name:'United States'}),
(person) -[:LIVES_IN]-> () -[:WITHIN*0..]-> (eu:Location {name:'Europe'})
RETURN person.name
這個查詢的解讀如下:
- 找到一個**人(person)**節點。
- 這個人有一個
:BORN_IN的關係連到一個未命名的節點()。 - 從這個未命名的節點開始,透過零個或多個
:WITHIN關係,最終到達一個名為us的Location節點,其name屬性為'United States'。 - 同時,這個人還有一個
:LIVES_IN的關係連到另一個未命名的節點()。 - 從這個未命名的節點開始,透過零個或多個
:WITHIN關係,最終到達一個名為eu的Location節點,其name屬性為'Europe'。 - 返回符合這些條件的
person節點的name屬性。
補充知識:路徑遍歷
Cypher 中的 [:WITHIN*0..] 表示「零個或多個」WITHIN 關係,這允許我們表達任意長度的路徑遍歷,非常適合尋找間接關係。
SQL 中的圖查詢:遞迴 CTE 的應用
雖然 SQL 並非為圖查詢設計,但自 SQL:1999 標準引入了遞迴公用表表達式(WITH RECURSIVE)語法後,也能夠實現可變長度路徑遍歷。然而,相較於 Cypher,其語法會顯得非常笨拙和冗長。
例 2–5:與範例 2–4 同樣的查詢,在 SQL 中使用遞迴公用表表達式表示
SQL
WITH RECURSIVE
-- in_usa 包含所有的美國境內的位置 ID
in_usa(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'United States'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'within'
),
-- in_europe 包含所有的歐洲境內的位置 ID
in_europe(vertex_id) AS (
SELECT vertex_id FROM vertices WHERE properties ->> 'name' = 'Europe'
UNION
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'within' ),
-- born_in_usa 包含了所有型別為 Person,且出生在美國的頂點
born_in_usa(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_usa ON edges.head_vertex = in_usa.vertex_id
WHERE edges.label = 'born_in' ),
-- lives_in_europe 包含了所有型別為 Person,且居住在歐洲的頂點。
lives_in_europe(vertex_id) AS (
SELECT edges.tail_vertex FROM edges
JOIN in_europe ON edges.head_vertex = in_europe.vertex_id
WHERE edges.label = 'lives_in')
SELECT vertices.properties ->> 'name'
FROM vertices
JOIN born_in_usa ON vertices.vertex_id = born_in_usa.vertex_id
JOIN lives_in_europe ON vertices.vertex_id = lives_in_europe.vertex_id;
透過比較,我們可以清楚地看到,圖查詢語言在表達圖形遍歷和模式匹配方面具有天然的優勢和更高的表達力。
三元組儲存和 SPARQL:語意網的基石
**三元組儲存(Triple Store)是另一種圖形資料模型,它將所有資訊以簡單的「主語-謂語-賓語(Subject-Predicate-Object)」三部分形式儲存,例如 (吉姆, 喜歡, 香蕉)。這種模型是語意網(Semantic Web)**的基礎。
例 2–6:圖 2–5 中的資料子集,表示為 Turtle 三元組
Code snippet
@prefix : <urn:example:>.
_:lucy a :Person.
_:lucy :name "Lucy".
_:lucy :bornIn _:idaho.
_:idaho a :Location.
_:idaho :name "Idaho".
_:idaho :type "state".
_:idaho :within _:usa.
_:usa a :Location
_:usa :name "United States"
_:usa :type "country".
_:usa :within _:namerica.
_:namerica a :Location
_:namerica :name "North America"
_:namerica :type :"continent"
SPARQL 是用於查詢三元組儲存的標準查詢語言,它在表達圖形遍歷方面同樣非常簡潔。
Datalog:查詢語言的學術基礎
Datalog 是一種比 SPARQL 和 Cypher 更早出現的查詢語言,在 20 世紀 80 年代被廣泛研究。它在軟體工程師中可能不太知名,但它為後來的許多查詢語言提供了重要的理論基礎。Datalog 的資料模型類似於三元組模式,但更為通用,將三元組寫為 謂語(主語, 賓語)。
例 2–10:用 Datalog 來表示圖 2–5 中的資料子集
Code snippet
name(namerica, 'North America').
type(namerica, continent).
name(usa, 'United States').
type(usa, country).
within(usa, namerica).
name(idaho, 'Idaho').
type(idaho, state).
within(idaho, usa).
name(lucy, 'Lucy').
born_in(lucy, idaho).
本章總結:選擇最適合的工具
在本章中,我們深入探討了各種通用的資料模型及其對應的查詢語言。從結構嚴謹的關聯式模型到靈活多變的文件模型,再到專為處理複雜關係而生的圖形資料模型,每種模型都有其獨特的優勢和適用場景。
我們還比較了多種查詢語言:
- SQL: 關聯式資料庫的標準,適用於結構化數據的複雜查詢和連接。
- MapReduce: 大數據處理的函式式範式,適用於批次處理和聚合。
- MongoDB 的聚合管道(Aggregation Pipeline): 提供了比 MapReduce 更為靈活和強大的聚合操作。
- Cypher 和 SPARQL: 專為圖形資料庫設計,擅長表達複雜的關係遍歷和模式匹配。
- Datalog: 作為許多現代查詢語言的理論基礎,提供了邏輯程式設計的查詢視角。
甚至,我們還從網頁開發的角度借鑑了 CSS 和 XSL/XPath 等非資料庫查詢語言,以闡述「宣告式編程」的強大與簡潔。
沒有一種資料模型或查詢語言是萬能的。 最好的選擇取決於你的應用程式需求、資料的性質以及你對資料一致性、擴展性、查詢複雜度等方面的權衡。理解這些不同工具的優缺點,將幫助你做出明智的技術決策,為你的應用程式設計出最優的資料架構。
思考與討論:
- 在你的專案中,你曾遇到過哪些資料模型選擇上的挑戰?你是如何解決的?
- 對於處理半結構化數據,你會更傾向於使用支援 JSON 的關聯式資料庫,還是純粹的文件資料庫?為什麼?
- 隨著資料庫技術的發展,你認為未來資料模型的演變趨勢會是如何?