CMU15-445 數(shù)據(jù)庫系統(tǒng)播客:數(shù)據(jù)庫系統(tǒng) Join 算法與原理
在關(guān)系型數(shù)據(jù)庫系統(tǒng)中, 連接(Join)操作是核心且至關(guān)重要的一環(huán) 。數(shù)據(jù)庫通過 規(guī)范化(Normalization) 將數(shù)據(jù)拆分到不同表中以減少信息冗余。然而,當(dāng)我們需要獲取跨多個表的綜合信息時,就必須使用 Join 操作,將被拆分的表重新組合,以 重建數(shù)據(jù)間的邏輯關(guān)系,確保信息完整 。
連接操作的核心概念
在深入探討具體算法之前,我們首先需要理解評估和執(zhí)行連接操作的一些基本原則。
連接的評估基準(zhǔn):I/O 成本
一個查詢在執(zhí)行時會被數(shù)據(jù)庫的查詢優(yōu)化器轉(zhuǎn)換成一個“查詢計劃樹”。這個樹的葉子節(jié)點代表對數(shù)據(jù)表的訪問,而中間節(jié)點則是各種操作符,Join 就是其中最重要的一種。
評估 Join 算法優(yōu)劣的主要標(biāo)準(zhǔn)是其磁盤 I/O 成本 。因為磁盤的讀寫速度遠(yuǎn)慢于內(nèi)存,I/O 操作往往是數(shù)據(jù)庫性能的最大瓶頸。為了量化分析,我們通常設(shè)定如下變量:
- 表 R:占用 M 個磁盤頁(Page),包含 m 條元組(Tuple)。
- 表 S:占用 N 個磁盤頁,包含 n 條元組。
在后續(xù)的成本分析中,我們主要關(guān)注 Join 操作本身的 I/O 開銷,而不計算最終結(jié)果輸出的開銷,因為后者對于所有算法來說都是相同的。
外層表與內(nèi)層表的選擇:為何“小表驅(qū)動大表”?
在執(zhí)行 Join 時,查詢計劃會指定一個 外層表(Outer Table,或稱驅(qū)動表) 和一個 內(nèi)層表(Inner Table,或稱被驅(qū)動表) 。在查詢計劃樹中,左側(cè)輸入通常是外層表,右側(cè)是內(nèi)層表。
一個核心的優(yōu)化原則是: 盡可能選擇小表作為外層表 。這里的“小”通常指占用磁盤頁(Blocks/Pages)較少,或者在某些算法中指元組數(shù)量(Rows)較少。
為什么這個選擇如此重要?我們可以通過一些基礎(chǔ)的 Join 算法 I/O 公式來直觀理解:
對于塊嵌套循環(huán)連接(Block Nested Loop Join) ,其 I/O 成本公式為:外層表 I/O + (外層表塊數(shù) × 內(nèi)層表 I/O)。
- 大表 R (1000頁) 作外層,小表 S (500頁) 作內(nèi)層 :成本為
M + (M × N) = 1000 + (1000 × 500) = 501,000次 I/O。 - 小表 S (500頁) 作外層,大表 R (1000頁) 作內(nèi)層 :成本為
N + (N × M) = 500 + (500 × 1000) = 500,500次 I/O。 在此算法中,總 I/O 差別不大,但外層表越小,外層循環(huán)次數(shù)越少。
對于索引嵌套循環(huán)連接(Index Nested Loop Join) ,其成本與外層表的 元組數(shù)量 密切相關(guān)。其 I/O 成本公式為:外層表 I/O + (外層表元組數(shù) × 內(nèi)層表索引查找成本)。
- 大表 R (m=100,000) 作外層 :成本為
M + (m × C) = 1000 + (100,000 × C)。 - 小表 S (n=40,000) 作外層 :成本為
N + (n × C) = 500 + (40,000 × C)。
其中 C 是單次索引查找的成本。顯然,外層表的元組數(shù)越少,對內(nèi)層表的索引“探測”次數(shù)就越少,總 I/O 成本也顯著降低。
結(jié)論 :外層表扮演著“驅(qū)動”的角色,它的每一行(或每一塊)都會觸發(fā)一次對內(nèi)層表的操作。選擇小表作為外層表,可以有效減少驅(qū)動次數(shù),從而大幅降低對內(nèi)層表的掃描或查找總成本,這是 Join 優(yōu)化的一個基礎(chǔ)出發(fā)點。
連接的輸出方式:物化 vs. 記錄 ID
連接操作符找到匹配的元組后,如何將結(jié)果傳遞給上層操作符?主要有兩種方式:
數(shù)據(jù)物化 (Materialization) :將匹配元組中所有需要的屬性值復(fù)制出來,形成一個全新的輸出元組。
- 優(yōu)點 :后續(xù)操作符無需再訪問原始表,因為所有信息都已備齊。
- 缺點 :如果涉及的屬性很多,生成的元組會很“寬”,占用大量內(nèi)存且復(fù)制成本高。
延遲物化 (Late Materialization) :只輸出連接鍵和匹配元組的記錄 ID(Tuple ID)。
- 優(yōu)點 :輸出的中間結(jié)果非常小,節(jié)省內(nèi)存。這在 列式存儲 數(shù)據(jù)庫中尤其高效,因為它避免了提前讀取查詢最終不需要的列。
- 缺點 :當(dāng)上層操作符需要其他屬性時,必須根據(jù)記錄 ID 回溯到原始數(shù)據(jù)頁去獲取,這可能引入額外的隨機 I/O。如果連接的選擇性很低(即匹配的元組很多),回溯成本可能會非常高昂。
三大主流連接算法
數(shù)據(jù)庫系統(tǒng)主要實現(xiàn)了三類連接算法:嵌套循環(huán)連接、排序合并連接和哈希連接。
嵌套循環(huán)連接 (Nested Loop Join)
這是最基礎(chǔ)、最直觀的連接算法,其思想類似于編程中的嵌套 for 循環(huán)。
簡單嵌套循環(huán) (Simple/Stupid Nested Loop Join)
對于外層表的 每一條元組 ,遍歷內(nèi)層表的 所有元組 進(jìn)行比較。這種方式性能極差,因為會導(dǎo)致對內(nèi)層表的重復(fù)全盤掃描。

塊嵌套循環(huán) (Block Nested Loop Join)
這是對簡單版本的巨大改進(jìn)。算法按 塊(Page/Block) 進(jìn)行循環(huán),而不是按元組。對于外層表的 每一塊 ,遍歷內(nèi)層表的 所有塊 。

索引嵌套循環(huán) (Index Nested Loop Join)
如果內(nèi)層表的連接鍵上存在 索引 ,系統(tǒng)就可以利用該索引來避免全表掃描。對于外層表的每一條元組,使用其連接鍵值去 探測 (Probe) 內(nèi)層表的索引,快速定位匹配的元組。
- 適用場景 : 在 OLTP (聯(lián)機事務(wù)處理) 場景中極為常見,因為外鍵列上通常建有索引。
- 動態(tài)索引 : 即使預(yù)先沒有索引,如果優(yōu)化器判斷創(chuàng)建臨時索引的成本低于全表掃描的成本,它甚至可以在查詢執(zhí)行期間 動態(tài)創(chuàng)建索引 ,查詢結(jié)束后再丟棄。

排序合并連接 (Sort-Merge Join)
此算法通過預(yù)先排序來優(yōu)化連接過程,分為兩個階段:
- 排序階段 (Sort Phase) :如果數(shù)據(jù)尚未排序,使用外部歸并排序(當(dāng)數(shù)據(jù)大于內(nèi)存時)或快速排序(當(dāng)數(shù)據(jù)可載入內(nèi)存時)分別對兩個表按連接鍵進(jìn)行排序。
- 合并階段 (Merge Phase) :使用兩個指針(游標(biāo))同步地掃描兩個已排序的表。
- 比較指針處的鍵值,移動鍵值較小的那個指針。
- 如果鍵值相等,則找到一個匹配,輸出結(jié)果。此時,需要特別處理 重復(fù)鍵 。例如,當(dāng)內(nèi)層表有多個與外層表當(dāng)前元組匹配的鍵時,需要固定外層表指針,移動內(nèi)層表指針直到不匹配。如果外層表下一條元組的鍵值仍然相同,內(nèi)層表的指針需要 回溯 到重復(fù)鍵的起始位置,以確保所有組合都被找到。

優(yōu)勢場景 :
- 數(shù)據(jù)已序 :當(dāng)一個或兩個表已經(jīng)按連接鍵排序(例如,連接鍵上存在聚簇索引),可以免去排序成本。
- 結(jié)果需排序 :當(dāng)查詢包含
ORDER BY子句,且排序鍵恰好是連接鍵時,此算法可以直接產(chǎn)出有序結(jié)果,避免了額外的排序操作,實現(xiàn)“一舉兩得”。
- 最壞情況 :如果所有元組的連接鍵都相同,合并階段會退化成類似嵌套循環(huán)的行為,性能急劇下降。但這種情況在現(xiàn)實中極為罕見。
哈希連接 (Hash Join)
哈希連接是現(xiàn)代數(shù)據(jù)庫系統(tǒng)中最快、最主流的連接算法,它利用哈希函數(shù)“分而治之”。如果兩個元組的連接鍵相等,那么它們的哈希值也必然相等。因此,我們只需在哈希到同一位置的元組“桶”內(nèi)進(jìn)行比較即可。
基本哈希連接算法
- 構(gòu)建階段 (Build Phase) :選擇 外層表(通常是小表) ,掃描它并使用哈希函數(shù)
h1在內(nèi)存中構(gòu)建一個 哈希表 。哈希表的鍵是連接鍵的哈希值,值通常是元組本身或其引用。 - 探測階段 (Probe Phase) :掃描 內(nèi)層表 ,對每一條元組的連接鍵計算相同的哈希值,然后去哈希表中查找(探測)。如果找到,再比較原始連接鍵值以確認(rèn)精確匹配(處理哈希沖突)。
優(yōu)化:布隆過濾器 (Bloom Filter)
在構(gòu)建哈希表的同時,可以額外構(gòu)建一個 布隆過濾器 。這是一種高效的概率型數(shù)據(jù)結(jié)構(gòu)(本質(zhì)是一個位圖),用于快速判斷一個元素 是否可能 在一個集合中。
- 特性 :它 絕不會漏報 (False Negative,如果它說“無”,就一定沒有),但 可能誤報 (False Positive,如果它說“有”,可能實際沒有)。它體積小、速度快,可以完全放入 CPU 緩存。
- 工作方式 :在探測內(nèi)層表時,先用布隆過濾器檢查。如果過濾器返回“無”,則該元組一定不匹配,可直接跳過,避免了訪問內(nèi)存或磁盤中的哈希表。這被稱為 旁路信息傳遞 (Sideways Information Passing) 。
Grace 哈希連接 (處理大數(shù)據(jù))
當(dāng)外層表太大,無法在內(nèi)存中為其完整構(gòu)建一個哈希表時,基本哈希連接就會因頻繁的磁盤交換而失效。 Grace 哈希連接 (或稱分區(qū)哈希連接)解決了這個問題。
- 分區(qū)階段 (Partition Phase) :使用一個哈希函數(shù)
h1,將 兩個表 都散列成多個 分區(qū)(Partitions) ,并寫入磁盤。h1的選擇要保證同一連接鍵的元組一定進(jìn)入相同的分區(qū)對(例如,R表的分區(qū)i和 S表的分區(qū)i)。 - 連接階段 (Join Phase) :依次將每一對分區(qū)(如 R 的分區(qū)1 和 S 的分區(qū)1)加載到內(nèi)存中。因為每個分區(qū)都足夠小,可以為其構(gòu)建內(nèi)存哈希表,然后在該分區(qū)內(nèi)部執(zhí)行基本的哈希連接。
- 遞歸分區(qū) :如果分區(qū)后某個分區(qū)依然過大,無法載入內(nèi)存,系統(tǒng)會對該分區(qū) 再次使用一個新的哈希函數(shù)
h2進(jìn)行遞歸分區(qū) ,直到所有子分區(qū)都足夠小。此方法巧妙地將隨機 I/O 轉(zhuǎn)換為了高效的順序 I/O。

算法選擇與總結(jié)
現(xiàn)代數(shù)據(jù)庫的查詢優(yōu)化器內(nèi)置了 成本估算模型 ,它會綜合考慮表大小、數(shù)據(jù)分布特征、內(nèi)存可用性、索引情況和查詢需求(如是否需要排序)等因素,為每個查詢動態(tài)選擇最優(yōu)的 Join 算法。
下表總結(jié)了我們示例中不同算法的性能對比:
算法類型 | 理論 I/O 成本公式 | 示例 I/O 次數(shù) | 示例耗時 (0.1ms/IO) |
簡單嵌套循環(huán) | ~50,000,000 | ~1.4 小時 | |
塊嵌套循環(huán) | 501,000 | ~50 秒 | |
索引嵌套循環(huán) | 201,000 | ~20 秒 | |
排序合并連接 | 7,500 | 0.75 秒 | |
Grace 哈希連接 | 4,500 | 0.45 秒 |
(注:示例基于 R 表 M=1000頁, m=100k元組; S 表 N=500頁, n=40k元組;索引查找成本 C=2 IO)
核心結(jié)論
- 在大多數(shù)情況下, 哈希連接是性能最佳的選擇 ,尤其對于大規(guī)模數(shù)據(jù)的等值連接。
- 排序合并連接 在特定場景下有奇效:當(dāng)數(shù)據(jù) 已排序 或查詢結(jié)果 需要按連接鍵排序 時,它可能是最優(yōu)選。此外,對于 不等值連接 (如
>或<)和范圍連接,排序合并連接通常優(yōu)于哈希連接。 - 索引嵌套循環(huán)連接 是 OLTP 輕量級查詢的首選,特別是當(dāng)外層表結(jié)果集很小且內(nèi)層表有可用索引時。
一個優(yōu)秀的數(shù)據(jù)庫系統(tǒng)會智能地運用這些算法。這正是 SQL 這種聲明式語言的強大之處:用戶只需描述“需要什么”,而無需關(guān)心“如何獲取”,底層的數(shù)據(jù)庫系統(tǒng)會負(fù)責(zé)選擇最高效的路徑來執(zhí)行查詢。這些經(jīng)典的算法思想不僅在單機數(shù)據(jù)庫中至關(guān)重要,在分布式數(shù)據(jù)庫中也同樣適用,只是將磁盤 I/O 成本替換為了開銷更高的網(wǎng)絡(luò) I/O 成本。





































