An SQL query walks into a bar and sees two tables. He walks up to them and asks ‘Can I join you?’
一個 SQL 查詢走進酒吧看到兩張桌子(table)。它他走到他們面前問“我可以和你們坐在一起(join)嗎?”😜
SQL 連接查詢(JOIN)可以同時獲取多個表中的關聯數據。例如,查看某個完整的訂單數據時,可能需要從產品表、用戶表、用戶訂單表、以及訂單明細表中獲取相關的信息。
不過,今天我們要討論的是數據庫內部如何利用算法實現連接查詢。通常實現連接查詢的算法有三種:Nested Loop Join、Hash Join 以及 Sort Merge Join。本文涉及到的數據庫包括 MySQL、Oracle、SQL Server、PostgreSQL 以及 SQLite,首先給出結論:
Join 實現算法 | MySQL | Oracle | SQL Server | PostgreSQL | SQLite |
---|---|---|---|---|---|
Nested Loop Join | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
Hash Join | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
Sort Merge Join | ❌ | ✔️ | ✔️ | ✔️ | ❌ |
📝關於各種內、外連接的查詢方式和語法可以參考這篇文章。
接下來針對三種算法進行具體的分析。
嵌套循環連接
嵌套循環連接(Nested Loop Join)是一種最基本的連接實現算法。它先從外部表(驅動表)中獲取滿足條件的數據,然後爲每一行數據遍歷一次內部表(被驅動表),獲取所有匹配的數據。下圖演示了嵌套循環連接的執行過程(圖片來源於bertwagner):
Nested Loop Join 類似於編程語言中的嵌套 for 循環;當然,數據庫在實現時會進行各種優化,例如通過索引提高掃描速度。
我們可以通過執行計劃查看 JOIN 的實現方式,先看 MySQL 中的以下示例(示例表來自這裏):
-- MySQL
explain analyze
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id)
where d.department_name = 'IT';
-> Nested loop inner join (cost=7.38 rows=24) (actual time=0.080..0.102 rows=5 loops=1)
-> Filter: (d.department_name = 'IT') (cost=2.95 rows=3) (actual time=0.043..0.061 rows=1 loops=1)
-> Table scan on d (cost=2.95 rows=27) (actual time=0.036..0.050 rows=27 loops=1)
-> Index lookup on e using emp_department_ix (department_id=d.department_id) (cost=1.08 rows=9) (actual time=0.035..0.038 rows=5 loops=1)
對於以上查詢,MySQL 選擇了使用 Nested loop inner join 算法;departments 是驅動表,循環 1 次返回 1 行數據;employees 是被驅動表,使用索引進行遍歷,然後回表查找表中的數據,循環了 1 次(因爲 departments 返回了 1 條記錄)。實際上 MySQL 對這個嵌套循環連接進行了優化,採用的是 Index Nested Loop Join 算法,在內層循環中掃描索引 emp_department_ix 而不是數據表,從而提高效率。
📝關於各種數據庫中執行計劃的查看方法,可以參考這篇文章。
下面是該語句在 Oracle 中的執行計劃:
-- Oracle
EXPLAIN PLAN FOR
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id)
where d.department_name = 'IT';
SELECT * FROM TABLE(DBMS_XPLAN.display);
PLAN_TABLE_OUTPUT |
--------------------------------------------------------------------------------------------------|
Plan hash value: 1021246405 |
|
--------------------------------------------------------------------------------------------------|
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time ||
--------------------------------------------------------------------------------------------------|
| 0 | SELECT STATEMENT | | 10 | 380 | 4 (0)| 00:00:01 ||
| 1 | NESTED LOOPS | | 10 | 380 | 4 (0)| 00:00:01 ||
| 2 | NESTED LOOPS | | 10 | 380 | 4 (0)| 00:00:01 ||
|* 3 | TABLE ACCESS FULL | DEPARTMENTS | 1 | 16 | 3 (0)| 00:00:01 ||
|* 4 | INDEX RANGE SCAN | EMP_DEPARTMENT_IX | 10 | | 0 (0)| 00:00:01 ||
| 5 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 10 | 220 | 1 (0)| 00:00:01 ||
--------------------------------------------------------------------------------------------------|
|
Predicate Information (identified by operation id): |
--------------------------------------------------- |
|
3 - filter("D"."DEPARTMENT_NAME"='IT') |
4 - access("E"."DEPARTMENT_ID"="D"."DEPARTMENT_ID") |
|
Note |
----- |
- this is an adaptive plan |
Oracle 也是選擇了 departments 表作爲,然後通過索引(EMP_DEPARTMENT_IX)範圍掃描進行遍歷找出滿足連接條件的索引值和 ROWID,最後通過遍歷這些索引 ROWID 獲取 employees 中的數據。
SQL Server 的執行計劃和 Oracle 幾乎完全一致:
-- SQL Server
SET STATISTICS PROFILE ON
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id)
where d.department_name = 'IT';
SET STATISTICS PROFILE OFF
Rows|Executes|StmtText |StmtId|NodeId|Parent|PhysicalOp |LogicalOp |Argument |DefinedValues |EstimateRows|EstimateIO|EstimateCPU|AvgRowSize|TotalSubtreeCost|OutputList |Warnings|Type |Parallel|EstimateExecutions|
----|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------|------|------|--------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------|------------|----------|-----------|----------|----------------|----------------------------------------------------------------------|--------|--------|--------|------------------|
5| 1|select e.first_name,e.last_name,e.salary,d.department_name¶from employees e¶join departments d on (e.department_id = d.department_id)¶where d.department_name = 'IT' | 1| 1| 0| | | | | 8.83333302| | | | 0.01430937| | |SELECT | 0| |
5| 1| |--Nested Loops(Inner Join, OUTER REFERENCES:([e].[employee_id])) | 1| 2| 1|Nested Loops |Inner Join |OUTER REFERENCES:([e].[employee_id]) | | 8.83333302| 0| 0.00003692| 52| 0.01430937|[e].[first_name], [e].[last_name], [e].[salary], [d].[department_name]| |PLAN_ROW| 0| 1|
5| 1| |--Nested Loops(Inner Join, OUTER REFERENCES:([d].[department_id])) | 1| 3| 2|Nested Loops |Inner Join |OUTER REFERENCES:([d].[department_id]) | | 8.83333302| 0| 0.00003692| 25| 0.0066533|[e].[employee_id], [d].[department_name] | |PLAN_ROW| 0| 1|
1| 1| | |--Clustered Index Scan(OBJECT:([hrdb].[dbo].[departments].[dept_id_pk] AS [d]), WHERE:([hrdb].[dbo].[departments].[department_name] as [d].[department_name]='IT')) | 1| 4| 3|Clustered Index Scan|Clustered Index Scan|OBJECT:([hrdb].[dbo].[departments].[dept_id_pk] AS [d]), WHERE:([hrdb].[dbo].[departments].[department_name] as [d].[department_name]='IT') |[d].[department_id], [d].[department_name] | 1| 0.003125| 0.0001867| 25| 0.0033117|[d].[department_id], [d].[department_name] | |PLAN_ROW| 0| 1|
5| 1| | |--Index Seek(OBJECT:([hrdb].[dbo].[employees].[emp_department_ix] AS [e]), SEEK:([e].[department_id]=[hrdb].[dbo].[departments].[department_id] as [d].[department_id]) ORDERED FORWARD)| 1| 5| 3|Index Seek |Index Seek |OBJECT:([hrdb].[dbo].[employees].[emp_department_ix] AS [e]), SEEK:([e].[department_id]=[hrdb].[dbo].[departments].[department_id] as [d].[department_id]) ORDERED FORWARD|[e].[employee_id] | 8.83333302| 0.003125| 0.00016672| 11| 0.00329172|[e].[employee_id] | |PLAN_ROW| 0| 1|
5| 5| |--Clustered Index Seek(OBJECT:([hrdb].[dbo].[employees].[emp_emp_id_pk] AS [e]), SEEK:([e].[employee_id]=[hrdb].[dbo].[employees].[employee_id] as [e].[employee_id]) LOOKUP ORDERED FORWARD)| 1| 7| 2|Clustered Index Seek|Clustered Index Seek|OBJECT:([hrdb].[dbo].[employees].[emp_emp_id_pk] AS [e]), SEEK:([e].[employee_id]=[hrdb].[dbo].[employees].[employee_id] as [e].[employee_id]) LOOKUP ORDERED FORWARD |[e].[first_name], [e].[last_name], [e].[salary]| 1| 0.003125| 0.0001581| 40| 0.00761915|[e].[first_name], [e].[last_name], [e].[salary] | |PLAN_ROW| 0| 8.83333302|
對於該語句,PostgreSQL 與其他數據庫的實現算法都不相同:
-- PostgreSQL
explain analyze
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id)
where d.department_name = 'IT';
QUERY PLAN |
------------------------------------------------------------------------------------------------------------------|
Hash Join (cost=1.35..4.75 rows=4 width=29) (actual time=0.073..0.310 rows=5 loops=1) |
Hash Cond: (e.department_id = d.department_id) |
-> Seq Scan on employees e (cost=0.00..3.07 rows=107 width=22) (actual time=0.022..0.064 rows=107 loops=1) |
-> Hash (cost=1.34..1.34 rows=1 width=15) (actual time=0.032..0.032 rows=1 loops=1) |
Buckets: 1024 Batches: 1 Memory Usage: 9kB |
-> Seq Scan on departments d (cost=0.00..1.34 rows=1 width=15) (actual time=0.016..0.028 rows=1 loops=1)|
Filter: ((department_name)::text = 'IT'::text) |
Rows Removed by Filter: 26 |
Planning Time: 0.502 ms |
Execution Time: 0.362 ms |
當然,PostgreSQL 支持 Nested Loop Join,只是在這裏它認爲 Hash Join 是更好的實現方式。關於 Hash Join 的介紹可以參考下文。
📝如果我們使用
set enable_hashjoin=off;
禁用 PostgreSQL 中的哈希連接,可以看到以上示例的執行計劃變成了嵌套循環連接。測試之後記得執行set enable_hashjoin=on;
啓用哈希連接。
最後是 SQLite 中的執行計劃:
-- SQLite
explain query plan
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id)
where d.department_name = 'IT';
id|parent|notused|detail |
--|------|-------|---------------------------------------------------------------------------|
4| 0| 0|SCAN TABLE departments AS d |
8| 0| 0|SEARCH TABLE employees AS e USING INDEX emp_department_ix (department_id=?)|
SQLite 目前只實現了 Nested Loop Join,這裏也是將 departments 選擇爲驅動表。
對於驅動表返回少量數據集的情況,嵌套循環連接通常可以獲得很好的性能;如果被驅動表的連接字段上存在索引,性能會更好。
一般情況下,數據庫可以自行判斷哪個表作爲驅動表;如果發現執行計劃選擇了錯誤的驅動表,首先應該考慮統計信息是否正確;許多數據庫支持使用優化器提示(hint)指定連接查詢中表的順序,建議謹慎使用。
哈希連接
哈希連接(Hash Join)使用其中一個表中滿足條件的記錄創建哈希表,然後掃描另一個表進行匹配。哈希連接的執行過程如下圖所示:
許多數據庫都支持哈希連接實現,MySQL 8.0.18 也加入了哈希連接,例如:
-- MySQL
explain analyze
select e.first_name,e.last_name,e.salary,d.first_name
from employees e
join employees d on (e.salary = d.salary);
-> Inner hash join (d.salary = e.salary) (cost=1156.11 rows=1145) (actual time=0.582..1.006 rows=271 loops=1)
-> Table scan on d (cost=0.01 rows=107) (actual time=0.100..0.246 rows=107 loops=1)
-> Hash
-> Table scan on e (cost=10.95 rows=107) (actual time=0.199..0.271 rows=107 loops=1)
在上面的查詢中,我們使用 salary 字段連接兩個 employees 表;由於該字段沒有索引,MySQL 選擇了 Inner hash join。通常來說,優化器會選擇兩者中的小表或者數據源建立哈希表。
對於上面的示例,Oracle、SQL Server 以及 PostgreSQL 都選擇了哈希連接的方式;SQLite 不支持哈希連接,仍然使用嵌套循環連接。
哈希連接是執行大數據集連接時的常用方式,但是它不支持範圍連接條件(t1.col < t2.col1)。對於哈希連接而言,不需要基於連接字段創建索引,因爲它不會利用索引進行連接。當然,爲WHERE
條件中的字段創建索引總是可以優化性能。
哈希連接使用內存構建哈希表,但是如果數據量太大,需要使用磁盤臨時表。在SELECT
中選擇更少的字段也可以提高哈希連接的性能,因爲哈希表中存儲了所有需要的字段。
排序合併連接
排序合併連接(Sort Merge Join)先將兩個數據源按照連接字段進行排序(Sort),然後合併兩個已經排序的集合,返回滿足連接條件的結果。排序合併連接的執行過程如下圖所示:
以下是 Oracle 中的一個排序合併連接的示例:
-- Oracle
EXPLAIN PLAN FOR
select e.first_name,e.last_name,e.salary,d.department_name
from employees e
join departments d on (e.department_id = d.department_id);
SELECT * FROM TABLE(DBMS_XPLAN.display);
PLAN_TABLE_OUTPUT |
--------------------------------------------------------------------------------------------|
Plan hash value: 1343509718 |
|
--------------------------------------------------------------------------------------------|
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)| Time ||
--------------------------------------------------------------------------------------------|
| 0 | SELECT STATEMENT | | 106 | 4028 | 6 (17)| 00:00:01 ||
| 1 | MERGE JOIN | | 106 | 4028 | 6 (17)| 00:00:01 ||
| 2 | TABLE ACCESS BY INDEX ROWID| DEPARTMENTS | 27 | 432 | 2 (0)| 00:00:01 ||
| 3 | INDEX FULL SCAN | DEPT_ID_PK | 27 | | 1 (0)| 00:00:01 ||
|* 4 | SORT JOIN | | 107 | 2354 | 4 (25)| 00:00:01 ||
| 5 | TABLE ACCESS FULL | EMPLOYEES | 107 | 2354 | 3 (0)| 00:00:01 ||
--------------------------------------------------------------------------------------------|
|
Predicate Information (identified by operation id): |
--------------------------------------------------- |
|
4 - access("E"."DEPARTMENT_ID"="D"."DEPARTMENT_ID") |
filter("E"."DEPARTMENT_ID"="D"."DEPARTMENT_ID") |
查詢首先按照索引 DEPT_ID_PK 的順序獲取 departments表中的數據,同時掃描 employees 表並且按照 department_id 列排序;然後依次比較合併這兩個數據集。
對於以上語句,並不是所有數據庫都會選擇排序合併連接;MySQL 和 SQLite 沒有實現排序合併連接,選擇的是嵌套循環連接;SQL Server 也選擇了嵌套循環連接,可以使用inner merge join
強制使用排序合併連接;PostgreSQL 使用了哈希連接,可以使用set enable_hashjoin=off;
禁用哈希連接,此時將會使用排序合併連接。
排序合併連接一般用在兩張表中沒有索引,並且數據已經排好序的情況。雖然這種方式執行速度很快,但大數情況下數據沒有排序,因此性能不如哈希連接。
總結
我們討論了數據庫實現連接查詢的三種算法:Nested Loop Join、Hash Join 以及 Sort Merge Join。瞭解這些算法的原理和優缺點可以幫助我們優化連接查詢語句的性能。
如果覺得文章對你有用,請不要白嫖!歡迎關注❤️、評論📝、點贊👍!