連接的類型與方法#

連接(join)是 SQL 語言的關鍵特性,是其威力與彈性的基礎。列集合(不論直接取自表格或來自其他操作的結果)永遠成對連接。

連接的類型#

內連接(inner join,INNER JOIN 或簡寫 JOIN):包含兩個集合中滿足特定連接條件的列配對。連接條件把一個集合的某些欄位與另一集合的某些欄位結合,所有涉及的欄位構成連接鍵

  • 若連接條件要求兩集合的連接鍵相等,就稱為等值連接(equi-join)——這是最常見的類型
  • 笛卡兒積CROSS JOIN)包含兩集合所有可能的列配對,是「條件恆為真」的內連接特例

外連接(outer join):

  • 左外連接LEFT OUTER JOINLEFT JOIN)在內連接結果之外,額外納入左集合中在右集合找不到匹配的列(對應的右側欄位填入 NULL
  • 右外連接RIGHT JOIN)同理,只是集合對調
  • 全外連接FULL JOIN)包含左外與右外連接,兩側找不到匹配的列都加入

反連接與半連接

  • 半連接(semi-join)很像內連接,但只包含左集合中在右集合有匹配的列(即使有多個匹配,該列也只納入一次)
  • 反連接(anti-join)包含某集合中在另一集合沒有匹配的列

SQL 語言沒有明確的半連接與反連接,但可用 EXISTSNOT EXISTS 之類的謂詞達到相同效果。

連接方法#

PostgreSQL 提供三種連接方法(實作 SQL 連接邏輯操作的演算法):

  • 巢狀迴圈連接(nested loop join)
  • 雜湊連接(hash join)
  • 合併連接(merge join)

這些基本演算法常有針對特定連接類型量身打造的變體,儘管它們可能只支援其中部分類型。

例如巢狀迴圈支援內連接(計畫中的 Nested Loop 節點)與左外連接Nested Loop Left Join 節點),但不能用於全連接

同一演算法的某些變體也能被其他操作(如聚合)使用。不同的連接方法在不同條件下表現最佳,選出最具成本效益者是規劃器的工作。

巢狀迴圈連接#

基本演算法如下:外層迴圈走訪第一個集合(外集合)的所有列;對其中每一列,巢狀迴圈走訪第二個集合(內集合)的列以找出滿足連接條件者。每找到一對就立刻作為查詢結果的一部分回傳。

這個演算法存取內集合的次數 = 外集合的列數。因此巢狀迴圈連接的效率取決於幾個因素:

  • 外集合的基數
  • 是否有能有效率取得內集合列的存取方法
  • 對內集合相同列的重複存取

笛卡兒積#

不論集合中有多少列,巢狀迴圈連接都是求笛卡兒積最有效率的方式

=> EXPLAIN SELECT * FROM aircrafts_data a1
  CROSS JOIN aircrafts_data a2
WHERE a2.range > 5000;
 Nested Loop (cost=0.00..2.78 rows=45 width=144)
   > Seq Scan on aircrafts_data a1          -- 外集合
       (cost=0.00..1.09 rows=9 width=72)
   > Materialize (cost=0.00..1.14 rows=5 width=72)
       > Seq Scan on aircrafts_data a2      -- 內集合
           (cost=0.00..1.11 rows=5 width=72)
           Filter: (range > 5000)

Materialize 節點回傳從子節點收到的列,同時把它們存下來供後續使用:

  • 列在記憶體中累積,直到總大小達到 work_mem,之後 PostgreSQL 開始把它們溢寫到磁碟上的暫存檔
  • 再次被存取時,該節點直接讀取累積的列,不呼叫子節點

因此執行器可以避免重新掃描整張表,只讀取滿足條件的列

一般的等值連接也可能產生類似的計畫:

=> EXPLAIN SELECT * FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.ticket_no = '0005432000284';
 Nested Loop (cost=0.99..25.05 rows=3 width=136)
   > Index Scan using tickets_pkey on tickets t ...
   > Index Scan using ticket_flights_pkey on ticket_flights tf ...
       Index Cond: (ticket_no = '0005432000284'::bpchar)

規劃器辨識出兩個值相等後,把連接條件 tf.ticket_no = t.ticket_no 換成 tf.ticket_no = 常數實質上把等值連接化約成了笛卡兒積

成本估算#

基數:笛卡兒積的基數估算為兩個資料集基數的乘積(3 = 1 × 3)。

啟動成本 = 所有子節點啟動成本之和。

總成本包含:

  • 取得外集合所有列的成本
  • 首次取得內集合所有列的成本(期間進行物化)
  • 內集合列的重複取得成本 × (N − 1)(N 為外集合列數)
  • 每個待回傳列的處理成本(cpu_tuple_cost

第一個例子(無重複掃描):

=> SELECT 0.43 + 0.56 AS startup_cost,
  round((8.45 + 16.57 + 3 * 0.01)::numeric, 2) AS total_cost;
 startup_cost | total_cost
−−−−−−−−−−−−−−+−−−−−−−−−−−−
         0.99 |      25.05

笛卡兒積的例子(Materialize 重複掃描 8 次,每次估算 0.0125):

=> SELECT 0.00 + 0.00 AS startup_cost,
  round((1.09 + (1.14 + 8 * 0.0125) + 45 * 0.01)::numeric, 2) AS total_cost;
 startup_cost | total_cost
−−−−−−−−−−−−−−+−−−−−−−−−−−−
         0.00 |       2.78

計畫中顯示的是 Materialize 首次呼叫的成本後續呼叫的成本不會列出

參數化連接#

更常見的、不化約為笛卡兒積的例子:

=> EXPLAIN SELECT * FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.book_ref = '03A76D';
 Nested Loop (cost=0.99..45.68 rows=6 width=136)
   > Index Scan using tickets_book_ref_idx on tickets t
       (cost=0.43..12.46 rows=2 width=104)
       Index Cond: (book_ref = '03A76D'::bpchar)
   > Index Scan using ticket_flights_pkey on ticket_flights tf
       (cost=0.56..16.58 rows=3 width=32)
       Index Cond: (ticket_no = t.ticket_no)

這裡 Nested Loop 走訪外集合(tickets)的列,並為每一列搜尋內集合的對應列,把票號 t.ticket_no 作為參數傳入條件。內層節點(Index Scan)被呼叫時,面對的是 ticket_no = 常數 這個條件。

連接基數的估算#

連接選擇性是兩集合笛卡兒積中連接後留存的比例。顯然必須排除兩集合中連接鍵含 NULL 的列,因為等值條件對它們永不成立。

估算基數 = 笛卡兒積基數(兩集合基數的乘積)× 選擇性

有外鍵時:由於被連接的表格由外鍵相連,選擇性估算依賴「子表格的每一列在父表格中恰有一個匹配列」這項事實。因此選擇性取為「外鍵所參照表格大小的倒數」

=> SELECT round(2 * tf.reltuples * (1.0 / t.reltuples)) AS rows
FROM pg_class t, pg_class tf
WHERE t.relname = 'tickets' AND tf.relname = 'ticket_flights';
 rows
−−−−−−
     6

無外鍵時:選擇性取為特定連接條件的估算選擇性。假設值分佈均勻時,等值連接的通用公式為:

min( 1/nd₁ , 1/nd₂ )

其中 nd₁、nd₂ 分別是兩集合中連接鍵的相異值數量。本例算出的結果與外鍵版本一致。

規劃器會盡可能精修這個基準估算。目前它無法使用直方圖,但若兩張表格的連接鍵都蒐集了 MCV 清單,它就會納入考量:出現在清單中的列其選擇性能被更準確估算,只有剩下的列才需依賴均勻分佈的計算。

一般而言,定義了外鍵,連接選擇性的估算就可能更準確。對複合連接鍵尤其如此——這種情況下選擇性經常被大幅低估。

用 EXPLAIN ANALYZE 觀察迴圈#

=> EXPLAIN (analyze, timing off, summary off) SELECT * ...
 Nested Loop (cost=0.99..45.68 rows=6 width=136)
   (actual rows=8 loops=1)
   > Index Scan using tickets_book_ref_idx on tickets t
       (cost=0.43..12.46 rows=2 width=104) (actual rows=2 loops=1)
   > Index Scan using ticket_flights_pkey on ticket_flights tf
       (cost=0.56..16.58 rows=3 width=32) (actual rows=4 loops=2)

外集合含兩列(actual rows=2),估算正確。因此 Index Scan 被執行了兩次(loops=2),每次平均選出四列(actual rows=4),總共找到 8 列。

若啟用 TIMING,PostgreSQL 顯示的也是平均值,就像列數一樣。要得到總執行時間,必須把這個值乘以迭代次數(loops)。

另外要注意:在某些平台上啟用 timing 的輸出可能顯著拖慢查詢執行。

列快取(Memoization)#

若內集合被以相同的參數值反覆掃描(因而得到相同結果),快取這個集合的列可能很划算

這種快取由 Memoize 節點執行(PostgreSQL 14 起)。它與 Materialize 相似,但專為參數化連接設計,實作複雜得多:

MaterializeMemoize
儲存內容單純物化子節點回傳的所有確保不同參數值回傳的列分開保存
溢位處理開始把列溢寫到磁碟全部保留在記憶體中(否則快取就沒有意義了)
=> EXPLAIN SELECT * FROM flights f
  JOIN aircrafts_data a ON f.aircraft_code = a.aircraft_code
WHERE f.flight_no = 'PG0003';
 Nested Loop (cost=5.44..387.10 rows=113 width=135)
   > Bitmap Heap Scan on flights f ...
   > Memoize (cost=0.15..0.27 rows=1 width=72)
       Cache Key: f.aircraft_code
       Cache Mode: logical
       > Index Scan using aircrafts_pkey on aircrafts_data a ...

運作機制#

  • 用於存放快取列的記憶體大小為 work_mem × hash_mem_multiplier(預設 4 MB × 1.0)
  • 如第二個參數名稱所示,快取列存放在雜湊表(開放定址)中。雜湊鍵(計畫中的 Cache Key)就是參數值(多個參數時為多個值)
  • 所有雜湊鍵被串成一個清單:一端視為冷端(含長時間未使用的鍵),另一端為熱端(存放最近使用的鍵)

命中:若傳入的參數值對應已快取的列,這些列就直接傳給父節點(Nested Loop),不檢查子節點;使用過的雜湊鍵被移到熱端。

未命中:Memoize 從子節點拉取列、快取後傳給上層節點,對應的雜湊鍵也變熱。

淘汰:新資料填滿可用記憶體時,對應冷端鍵的列被淘汰。這個淘汰演算法與緩衝快取所用的不同,但目的相同。

某些參數值可能有多到塞不進配置記憶體的匹配列,即使其他列都已被淘汰。這類參數會被跳過——只快取部分列毫無意義,因為下次呼叫仍得從子節點取得全部列。

成本估算與診斷#

顯然只有在 Memoize 比其子節點便宜時使用它才有意義。每次後續 Memoize 掃描的成本取決於預期的快取存取樣態與可用於快取的記憶體大小,而算出的值高度依賴「內集合掃描中相異參數值數量」的準確估算

=> EXPLAIN (analyze, costs off, timing off, summary off) SELECT * ...
   > Memoize (actual rows=1 loops=113)
       Cache Key: f.aircraft_code
       Cache Mode: logical
       Hits: 112 Misses: 1 Evictions: 0 Overflows: 0 Memory Usage: 1kB

此查詢選出同一航線、由特定機型執飛的航班,因此所有對 Memoize 的呼叫都使用相同的雜湊鍵:第一列必須從表格取得(Misses: 1),後續全部命中快取(Hits: 112),整個操作只用 1 kB 記憶體。

另兩個顯示值——淘汰次數Evictions)與快取溢位次數Overflows,無法快取某參數集合的全部列)——若數字很大,代表配置的快取太小,可能肇因於相異參數值數量的估算不準確。此時使用 Memoize 節點可能相當昂貴。

極端情況下,可關閉 enable_memoize 參數(預設 on)禁止規劃器使用快取

外連接#

巢狀迴圈連接可用於執行左外連接

=> EXPLAIN SELECT *
FROM ticket_flights tf
  LEFT JOIN boarding_passes bp ON bp.ticket_no = tf.ticket_no
                              AND bp.flight_id = tf.flight_id
WHERE tf.ticket_no = '0005434026720';
 Nested Loop Left Join (cost=1.12..33.35 rows=3 width=57)
   Join Filter: ((bp.ticket_no = tf.ticket_no) AND (bp.flight_id = tf.flight_id))
   > Index Scan using ticket_flights_pkey on ticket_flights tf ...
   > Materialize ...

此處規劃器選了帶過濾器的非參數化連接:它對內集合執行完全相同的掃描(因此該集合藏在 Materialize 節點後),並回傳滿足 Join Filter 的列。

外連接的基數估算方式與內連接相同,只是算出的估算會與外集合的基數比較,取較大者作為最終結果

換言之:外連接絕不會減少列數(但可能增加)。

規劃器可能為內連接與外連接選出不同的計畫。即使這個簡單的例子,強制使用巢狀迴圈連接的內連接版本也會有不同的 Join Filter(只剩 tf.flight_id = bp.flight_id)。

總成本的細微差異,源於外連接還必須檢查票號,才能在外集合找不到匹配時得到正確結果。

全連接因同樣理由也不被支援。

反連接與半連接#

反連接與半連接的相似之處在於:對第一(外)集合的每一列,只需在第二(內)集合找到一個匹配列就夠了

反連接只回傳在第二集合中沒有匹配的第一集合列:執行器一找到第二集合中的第一個匹配列,就能離開目前迴圈——對應的第一集合列必須被排除在結果之外。

反連接可用來計算 NOT EXISTS 謂詞:

=> EXPLAIN SELECT * FROM aircrafts a
WHERE NOT EXISTS (
   SELECT * FROM seats s WHERE s.aircraft_code = a.aircraft_code
);
 Nested Loop Anti Join (cost=0.28..4.65 rows=1 width=40)
   > Seq Scan on aircrafts_data ml ...
   > Index Only Scan using seats_pkey on seats s ...

不用 NOT EXISTS 的等價查詢(LEFT JOIN ... WHERE s.aircraft_code IS NULL)會得到完全相同的計畫

半連接回傳第一集合中至少有一個匹配的列(同樣不必再檢查其他匹配——結果已經確定)。半連接可用來計算 EXISTS 謂詞(Nested Loop Semi Join 節點)。

半連接與反連接的計畫都顯示 seats 表格的基本列數估算(rows=149),儘管實際上只需取出其中一列。實際查詢執行當然會在取得第一列後停止EXPLAIN ANALYZE 顯示 actual rows=1 loops=9)。

估算方式

  • 半連接的選擇性以一般方式估算,只是內集合的基數取為 1
  • 反連接的估算選擇性則是用 1 減去,就像取否定一樣
  • 成本估算反映了「第二集合的掃描一找到第一個匹配列就停止」這項事實

非等值連接#

巢狀迴圈演算法允許依任何連接條件連接列集合。

顯然,若內集合是建有索引的基表、且連接條件使用的運算子屬於該索引的運算子類別,對內集合的存取就能相當有效率。

總是可以透過「計算被某條件過濾的列的笛卡兒積」來完成連接——此時條件可以完全任意。

例如選出彼此距離接近的機場配對:

=> CREATE EXTENSION earthdistance CASCADE;
=> EXPLAIN (costs off) SELECT *
FROM airports a1
  JOIN airports a2 ON a1.airport_code != a2.airport_code
                  AND a1.coordinates <@> a2.coordinates < 100;
 Nested Loop
   Join Filter: ((ml.airport_code <> ml_1.airport_code) AND
   ((ml.coordinates <@> ml_1.coordinates) < '100'::double precisi...
   > Seq Scan on airports_data ml
   > Materialize
       > Seq Scan on airports_data ml_1

平行模式#

巢狀迴圈連接可參與平行計畫執行。

=> EXPLAIN (costs off) SELECT t.passenger_name
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE f.flight_id = 12345;
 Nested Loop
   > Index Only Scan using flights_flight_id_status_idx on fligh...
       Index Cond: (flight_id = 12345)
   > Gather
       Workers Planned: 2
       > Nested Loop
           > Parallel Seq Scan on ticket_flights tf
               Filter: (flight_id = 12345)
           > Index Scan using tickets_pkey on tickets t
               Index Cond: (ticket_no = tf.ticket_no)
  • 上層的巢狀迴圈連接是循序執行的。外集合只由 flights 表格中依唯一鍵取得的一列構成,因此即使內側列數龐大,使用巢狀迴圈仍屬合理
  • 內集合以平行計畫取得:每個工作者掃描自己分到的那份 ticket_flights 列,並用巢狀迴圈演算法與 tickets 連接