楔子
作為一款 OLAP 型的數據庫,它的查詢功能可謂是重中之重,而且我相信大家在絕大部分時間都在使用它的查詢功能,事實上,在日常運轉的過程中,數據查詢也是 ClickHouse 的主要工作之一。ClickHouse 完全使用 SQL 作為查詢語言,能夠以 SELECT 查詢語句的形式從數據庫中選取數據,這也是它具備流行潛質的重要原因。雖然 ClickHouse 擁有優秀的查詢性能,但是我們也不能濫用查詢,掌握 ClickHouse 所支持的各種查詢子句,並選擇合理的查詢形式是很有必要的。使用不恰當的 SQL 語句進行查詢不僅會帶來低性能,還可能導致不可預知的系統錯誤。
雖然在上面的示例中,我們已經見識過一些查詢語句的用法,但那些都是為了演示效果簡化后的代碼,與真正的生產環境中的代碼相差較大。例如在絕大部分場景中,都應該避免使用 SELECT * 來查詢數據,因為通配符 * 對於采用列式存儲的 ClickHouse 而言沒有任何好處。假如面對一張擁有數百個列字段的數據表,下面這兩條 SELECT 語句的性能可能會相差 100 倍之多:
SELECT * FROM table;
SELECT col FROM table;
使用通配符 * 和按列查詢相比,性能可能相差 100 倍,另外 ClickHouse 對於 SQL 語句的解析是大小寫敏感的,這意味着 SELECT a 和 SELECT A 表示的語義是不相同的,但關鍵字大小寫不敏感,不過還是建議遵循規范使用大寫。此外 ClickHouse 的類型也大小寫敏感,比如:UInt8 不可以寫成 uint8,String 不可以寫成 string;還有大部分函數也是大小寫敏感,這些函數都是 ClickHouse 獨有的,或者說你在其它關系型數據庫中見不到的,但是像 min、max、length、sum、count 等等這些在其它關系型庫中也能看到的函數,在 ClickHouse 中則是大小寫不敏感的。
下面介紹 ClickHouse 的查詢語法,ClickHouse 支持的查詢子句和我們平常使用的關系型數據庫非常類似,但是在此基礎上又提供了很多新的功能,我們來看一下。
雖然 ClickHouse 的查詢是重中之重,但畢竟采用的是 SQL 語法,因此像什么如何起別名、比較運算符、條件運算符等等,這些比較基礎的內容就不說了。
WITH 子句
ClickHouse 支持 CTE(Common Table Expression,公共表表達式),以增強查詢語句的表達。例如下面的函數嵌套:
SELECT pow(pow(2, 2), 3);
/*
┌─pow(pow(2, 2), 3)─┐
│ 64 │
└───────────────────┘
*/
在改用 CTE 的形式后,可以極大地提高語句的可讀性和可維護性,簡化后的語句如下所示:
WITH pow(2, 2) AS a SELECT pow(a, 3);
/*
┌─pow(a, 3)─┐
│ 64 │
└───────────┘
*/
在 ClickHouse 中的 CTE 通過 WITH 子句表示,而語法格式顯然很簡單,想象一下編程語言中的變量定義,一般都類似於:
var = 表達式
在后續的的代碼編寫中,可以使用 var 來代替相應的表達式,而在 ClickHouse 中也是同理:
WITH 表達式 AS var
通過 WITH,我們即可在查詢中使用 var 來代替表達式,而根據表達式的不同,可以有以下幾種用法:
1. 表達式為常量
此時相當於為常量起了一個有意義的名字,這些名字能夠在后續的查詢子句中被直接訪問。例如下面示例中的 start,被直接用在緊接着的 WHERE 子句中:
WITH 10 AS start
SELECT number FROM system.numbers -- 這是一張系統表
WHERE number > start LIMIT 5
/*
┌─number─┐
│ 11 │
│ 12 │
│ 13 │
│ 14 │
│ 15 │
└────────┘
*/
如果沒有 WITH 子句,那么直接把 start 換成 10 即可,只不過通過 WITH 我們給 10 這個常量起了一個有意義的名字。當然常量不僅是整數,字符串、浮點數、甚至數組都是可以的。
2. 表達式為函數調用
感覺此時就類似於替換,例如在下面的示例中,對 data_uncompressed_bytes 使用聚合函數求和后,又緊接着在 SELECT 子句中對其進行了格式化處理。
WITH SUM(data_uncompressed_bytes) AS bytes
SELECT database, formatReadableSize(bytes) AS format
FROM system.columns
GROUP BY database
ORDER BY bytes DESC
/*
┌─database─┬─format───┐
│ system │ 5.32 GiB │
│ default │ 0.00 B │
└──────────┴──────────┘
*/
如果不使用 WITH 子句,那么 SELECT 里面出現的就是 formatReadableSize(SUM(data_uncompressed_bytes)),這樣讀起來不是很方便,所以使用 WITH 子句將里面的聚合函數調用起一個名字叫 bytes,那么后面的查詢直接使用 bytes 即可。
3. 表達式為子查詢
表達式也可以是一個子查詢,例如在下面的示例中,借助子查詢可以得出各 database 未壓縮數據大小與數據總和大小的比例的排名:
-- SELECT sum(data_uncompressed_bytes) FROM system.columns 會得到一個數值
-- 因此本質上和表達式為常量是類似的,只不過多了一個計算的過程
WITH (SELECT sum(data_uncompressed_bytes) FROM system.columns) AS total_bytes
SELECT database,
(sum(data_uncompressed_bytes) / total_bytes) * 100 AS database_disk_usage
FROM system.columns
GROUP BY database
ORDER BY database_disk_usage DESC
/*
┌─database─┬─database_disk_usage─┐
│ system │ 100 │
│ default │ 0 │
└──────────┴─────────────────────┘
*/
使用 WITH 子句時有一點需要特別注意,表達式只能返回的數據不能超過 1 行,否則會拋出異常。我們舉個栗子:
這里的 WITH AS 就類似於編程語言中的變量賦值,但你不可能讓一個變量指代多個值,如果想這么做,那么就將這些值放在一個容器(列表、集合、字典等等)里面。同理,如果 WITH 的表達式返回了多行數據,那么可以將其變成一個數組:
-- 這里見到了一個函數 groupArray,我們可以把它當成是普通的聚合函數來理解
-- 類似於 sum,sum 是對同一組的元素進行求和,groupArray 是將同一組的元素組合成數組
WITH (SELECT groupArray(number) FROM numbers(10)) AS arr SELECT arr;
/*
┌─arr───────────────────┐
│ [0,1,2,3,4,5,6,7,8,9] │
└───────────────────────┘
*/
顯然此時就沒問題了,並且相比關系型數據庫,ClickHouse 在行列轉換的時候尤為方便。
4. 在子查詢中重復使用WITH
在子查詢中可以嵌套使用 WITH 子句,例如在下面的示例中,在計算出各 database 未壓縮數據大小與數據總和的比例之后,又進行了取整函數的調用:
WITH round(database_disk_usage) AS database_disk_usage_v1
SELECT database, database_disk_usage, database_disk_usage_v1
FROM (
-- 嵌套
WITH (SELECT sum(data_uncompressed_bytes) FROM system.columns) AS total_bytes
SELECT database, (sum(data_uncompressed_bytes) / total_bytes) * 100 AS database_disk_usage
FROM system.columns GROUP BY database ORDER BY database_disk_usage DESC
)
雖然看起來有點復雜,但是不難理解,不考慮 WITH 的話,那么就是一個嵌套子查詢:SELECT ... FROM (SELECT ... FROM),只不過兩個 SELECT 里面的 database_disk_usage_v1、total_bytes 是用 WITH 聲明的變量。
而且我們看到如果子查詢是作為一張表使用的,那么在關系型數據庫中應該起一個別名,但在 ClickHouse 可以不用。
總的來說,WITH 子句就相當於起一個別名,如果你看某個表達式長得不順眼,那么就可以使用 WITH 將它替換掉,就這么簡單。而且一個 WITH 子句是可以為多個表達式起別名的,舉個栗子:
WITH 1 AS a, 2 AS b SELECT a + b;
/*
┌─plus(a, b)─┐
│ 3 │
└────────────┘
*/
其它關系型數據庫的 WITH 子句
ClickHouse 的 WITH 語句和其它的關系型數據庫還是有很大差別的,比如 PostgreSQL。如果 PostgreSQL 的話,那么 AS 之后的結果是會被當成是一張表,舉個例子:
-- 假設存在一張表叫 girls, 如果是 PostgreSQL 的話
WITH tmp AS (
SELECT * FROM girls WHERE id < 100
)
SELECT * FROM tmp WHERE age > 20;
-- 這么做的話, 在 PostgreSQL 中是完全正確的做法,此時的 tmp 就是 table 中 id 小於 100 的記錄組成的結果集
-- 並且它可以作為一張臨時表來使用
-- 我們這個示例比較簡單, 但是當子查詢比較復雜的時候, 通過將子查詢當做一張臨時表, 可以使查詢邏輯更加清晰
-- 並且查詢結束之后, 這張臨時表也就不存在了
-- 上面這段代碼和下面都是等價的
SELECT * FROM girls WHERE id < 100 AND age > 20;
SELECT * FROM (SELECT * FROM girls WHERE id < 100) tmp WHERE age > 20;
所以 ClickHouse 的 WITH 中的表達式必須只能有一行,它就等價於為某個復雜的表達式起一個別名,不可以放在 FROM 后面作為臨時表來使用。而 PostgreSQL 的 WITH 中的表達式沒有任何限制,可以返回任何數據,行數不限,並且可以當成臨時表放在 FROM 后面。除此之外,ClickHouse 和 PostgreSQL 的 WITH 語句還有一處不同,那就是 ClickHouse 中別名在 AS 后面,而 PostgreSQL 中別名在 AS 前面。
注意:WITH 中的表達式如果是子查詢,那么會提前計算好,在使用別名的時候使用的是已經計算好的結果。
問題來了,既然行數有限制,那列數有沒有限制呢?我們試一下就知道了,首先創建一張 Memory 數據表,內容如下:
SELECT * FROM women
/*
┌─id─┬─name─────┬─age─┐
│ 1 │ 古明地覺 │ 17 │
│ 2 │ 芙蘭朵露 │ 144 │
│ 3 │ 琪露諾 │ 58 │
└────┴──────────┴─────┘
*/
然后我們來進行查詢:
同理通過 WITH,我們還可以實現為字段起別名的效果,舉個栗子:
不過這種做法顯然沒有太大意義,除非字段名太長了,想起一個短點兒的。
以上就是 WITH 子句,非常簡單,核心就一句話:給表達式起別名,后續使用別名來對表達式進行替換。
FROM 子句
FROM 子句表示從何處讀取數據,目前支持如下 3 種形式。
1. 從數據表中取數
SELECT name FROM people
2. 從子查詢中取數
SELECT max_id FROM
(SELECT max(id) AS max_id FROM people)
-- 在其它關系型數據庫中, 如果子查詢作為一張表來使用, 那么必須要起一個別名
-- 但是在 ClickHouse 中不需要, 個人覺得這是個不錯的決定,因為起別名我們又不用
3. 從表函數中取數
SELECT number FROM numbers(N) -- 會返回 0 到 N - 1
另外 FROM 關鍵字可以省略,我們在介紹 WITH 子句的時候多次省略 FROM,因為 SELECT 后面是標量,此時會從虛擬表中取值。在 ClickHouse 中,並沒有數據庫中常見的 DUAL 虛擬表,取而代之的是 system.one。舉個栗子:
-- 等價於 SELECT 1, 2, 3 FROM system.one,
SELECT 1, 2, 3;
/*
┌─1─┬─2─┬─3─┐
│ 1 │ 2 │ 3 │
└───┴───┴───┘
*/
SELECT 1 + 2, 2 * 3; -- 此時可以當成計算器來使用
/*
┌─plus(1, 2)─┬─multiply(2, 3)─┐
│ 3 │ 6 │
└────────────┴────────────────┘
*/
SELECT [1, 2, 3], 'KOMEIJI SATORI';
/*
┌─[1, 2, 3]─┬─'KOMEIJI SATORI'─┐
│ [1,2,3] │ KOMEIJI SATORI │
└───────────┴──────────────────┘
*/
ARRAY JOIN 子句
ARRAY JOIN 子句允許在數據表的內部,與數組或嵌套類型的字段進行 JOIN 操作,從而將一行數組展開為多行。接下來讓我們看看它的基礎用法,首先新建一張包含 Array 數組字段的測試表:
CREATE TABLE t1 (
title String,
value Array(UInt8)
) ENGINE = Memory();
-- 然后寫入數據
INSERT INTO t1 VALUES ('food', [1, 2, 3]), ('fruit', [3, 4]), ('meat', []);
-- 查詢
SELECT * FROM t1;
/*
┌─title─┬─value───┐
│ food │ [1,2,3] │
│ fruit │ [3,4] │
│ meat │ [] │
└───────┴─────────┘
*/
在一條 SELECT 語句中,只能存在一個 ARRAY JOIN(使用子查詢除外),目前支持 INNER 和 LEFT 兩種 JOIN 策略:
INNER ARRAY JOIN
ARRAY JOIN 在默認情況下使用的是 INNER JOIN 策略,例如下面的語句:
SELECT title, value FROM t1 ARRAY JOIN value;
/*
┌─title─┬─value─┐
│ food │ 1 │
│ food │ 2 │
│ food │ 3 │
│ fruit │ 3 │
│ fruit │ 4 │
└───────┴───────┘
*/
從查詢結果可以發現,最終的數據基於 value 數組被展開成了多行,並且排除掉了空數組,同時會自動和其它字段進行組合(相當於按行合並)。在使用 ARRAY JOIN 時,如果還想訪問展開前的數組字段,那么只需為原有的數組字段添加一個別名即可,例如:
-- 如果不給 ARRAY JOIN 后面的 value 起一個別名,那么 value 就是展開后的結果
-- 如果給 ARRAY JOIN 后面的 value 起一個別名 val,那么 value 就還是展開前的數組字段
-- 而 val 才是展開后的結果,所以再反過來,讓 val 出現在 SELECT 中即可
SELECT title, value, val FROM t1 ARRAY JOIN value AS val;
/*
┌─title─┬─value───┬─val─┐
│ food │ [1,2,3] │ 1 │
│ food │ [1,2,3] │ 2 │
│ food │ [1,2,3] │ 3 │
│ fruit │ [3,4] │ 3 │
│ fruit │ [3,4] │ 4 │
└───────┴─────────┴─────┘
*/
我們看到 ClickHouse 的確是當之無愧的最強 OLAP 數據庫,不單單是速度快,最重要的是,它提供的查詢語法也很方便。如果你用過 Hive 的話,會發現這里特別像里面的 lateral view explode 語法。
LEFT ARRAY JOIN
ARRAY JOIN 子句支持 LEFT 連接策略,例如執行下面的語句:
SELECT title, value, val FROM t1 LEFT ARRAY JOIN value AS val;
/*
┌─title─┬─value───┬─val─┐
│ food │ [1,2,3] │ 1 │
│ food │ [1,2,3] │ 2 │
│ food │ [1,2,3] │ 3 │
│ fruit │ [3,4] │ 3 │
│ fruit │ [3,4] │ 4 │
│ meat │ [] │ 0 │
└───────┴─────────┴─────┘
*/
在改為 LEFT 連接查詢后,可以發現,在 INNER JOIN 中被排除掉的空數組出現在了返回的結果集中。但此時的 val 是零值,所以 LEFT ARRAY JOIN 個人覺得不是很常用,一般都是用 ARRAY JOIN。
關於數組的一些騷操作
在關系型數據庫里面我們一般都不太喜歡用數組,但是在 ClickHouse 中數組會用的非常多,並且操作起來非常簡單。ClickHouse 里面提供了非常多的函數,用好了的話,就相當於分布式的 pandas。下面就先來看一下關於數組的一些函數,這里先介紹一部分,提前感受一下 ClickHouse 的強大,首先我們創建一張新表,並寫入測試數據:
SELECT * FROM t2;
/*
┌─────────dt─┬─cash───────┐
│ 2020-01-01 │ [10,10,10] │
│ 2020-01-02 │ [20,20,20] │
│ 2020-01-01 │ [10,10,10] │
│ 2020-01-02 │ [20,20] │
│ 2020-01-03 │ [] │
│ 2020-01-03 │ [30,30,30] │
└────────────┴────────────┘
*/
groupArray
這個函數已經出現過一次了,我們說它是把多行數據合並成一個數組,相當於是聚合函數的一種。
SELECT dt, groupArray(cash) FROM t2 GROUP BY dt;
/*
┌─────────dt─┬─groupArray(cash)────────┐
│ 2020-01-01 │ [[10,10,10],[10,10,10]] │
│ 2020-01-02 │ [[20,20,20],[20,20]] │
│ 2020-01-03 │ [[],[30,30,30]] │
└────────────┴─────────────────────────┘
*/
我們看到 groupArray 就等同於類似 count、sum 這樣的聚合函數,將同一組的數據組合成一個新的數組。由於本來的元素就是數組,所以這里就是數組嵌套數組。
除了 groupArray 之外,還有一個 groupUniqArray,在組合的時候會對元素進行去重:
SELECT dt, groupUniqArray(cash) FROM t2 GROUP BY dt;
/*
┌─────────dt─┬─groupUniqArray(cash)─┐
│ 2020-01-01 │ [[10,10,10]] │
│ 2020-01-02 │ [[20,20],[20,20,20]] │
│ 2020-01-03 │ [[],[30,30,30]] │
└────────────┴──────────────────────┘
*/
我們看到 '2020-01-01' 這行數據被去重了。
arrayFlatten
從名字上應該能猜出來,直接看例子就明白了。
SELECT dt,
groupArray(cash),
arrayFlatten(groupArray(cash)) FROM t2 GROUP BY dt;
我們在 groupArray(cash) 基礎上又調用了 arrayFlatten:
相信該函數的作用顯而易見的,就是將多個嵌套數組扁平化,另外這里的查詢語句還可以美化一下:
-- 使用 WITH 子句,提前將 groupArray(cash) 起一個別名
WITH groupArray(cash) AS group_cash
SELECT dt,
group_cash,
arrayFlatten(group_cash) FROM t2 GROUP BY dt;
-- 或者這么做
SELECT dt,
groupArray(cash) AS group_cash,
arrayFlatten(group_cash) FROM t2 GROUP BY dt;
-- 我們看到即使是在 SELECT 里面起的別名也是可以被使用的
-- 另外順序也沒有限制,比如下面的做法也是合法的
SELECT dt,
arrayFlatten(group_cash),
groupArray(cash) AS group_cash FROM t2 GROUP BY dt;
splitByChar
將字符串按照指定字符分割成數組:
SELECT splitByChar('^', 'komeiji^koishi');
/*
┌─splitByChar('^', 'komeiji^koishi')─┐
│ ['komeiji','koishi'] │
└────────────────────────────────────┘
*/
arrayJoin
該函數和 ARRAY JOIN 子句的作用非常類似:
arrayMap
對數組中的每一個元素都以相同的規則進行映射:
-- arrayMap(x -> x * 2, value) 表示將 value 中的每一個元素都乘以 2,然后返回一個新數組
-- 而 mapV 就是變換過后的新數組,直接拿來用即可
SELECT title, arrayMap(x -> x * 2, value) AS mapV, v
FROM t1 LEFT ARRAY JOIN mapV as v
/*
┌─title─┬─mapV────┬─v─┐
│ food │ [2,4,6] │ 2 │
│ food │ [2,4,6] │ 4 │
│ food │ [2,4,6] │ 6 │
│ fruit │ [6,8] │ 6 │
│ fruit │ [6,8] │ 8 │
│ meat │ [] │ 0 │
└───────┴─────────┴───┘
*/
-- 另外展開的字段也可以不止一個
SELECT title,
arrayMap(x -> x * 2, value) AS mapV, v,
value, v_1
FROM t1 LEFT ARRAY JOIN mapV as v, value AS v_1
/*
┌─title─┬─mapV────┬─v─┬─value───┬─v_1─┐
│ food │ [2,4,6] │ 2 │ [1,2,3] │ 1 │
│ food │ [2,4,6] │ 4 │ [1,2,3] │ 2 │
│ food │ [2,4,6] │ 6 │ [1,2,3] │ 3 │
│ fruit │ [6,8] │ 6 │ [3,4] │ 3 │
│ fruit │ [6,8] │ 8 │ [3,4] │ 4 │
│ meat │ [] │ 0 │ [] │ 0 │
└───────┴─────────┴───┴─────────┴─────┘
*/
嵌套類型
在前面介紹數據定義時曾介紹過,嵌套數據類型的本質是數組,所以 ARRAY JOIN 也支持嵌套數據類型。接下來繼續用一組示例說明,首先新建一張包含嵌套類型的測試表:
CREATE TABLE t3(
title String,
nested Nested
(
v1 UInt32,
v2 UInt64
)
) ENGINE = Log();
-- 接着寫入測試數據
-- 在寫入嵌套數據類型時,記得同一行數據中各個數組的長度需要對齊,而對多行數據之間的數組長度沒有限制
INSERT INTO t3
VALUES ('food', [1, 2, 3], [10, 20, 30]),
('fruit', [4, 5], [40, 50]),
('meat', [], [])
對嵌套類型數據的訪問,ARRAY JOIN 既可以直接使用字段列名:
也可以使用點訪問符的形式:
-- nested 只有 v1 和 v2
-- 所以 ARRAY JOIN nested.v1, nested.v2 等價於 ARRAY JOIN nested
SELECT title, nested.v1, nested.v2 FROM t3 ARRAY JOIN nested.v1, nested.v2
/*
┌─title─┬─nested.v1─┬─nested.v2─┐
│ food │ 1 │ 10 │
│ food │ 2 │ 20 │
│ food │ 3 │ 30 │
│ fruit │ 4 │ 40 │
│ fruit │ 5 │ 50 │
└───────┴───────────┴───────────┘
*/
嵌套類型也支持 ARRAY JOIN 部分嵌套字段:
SELECT title, nested.v1, nested.v2 FROM t3 ARRAY JOIN nested.v1
/*
┌─title─┬─nested.v1─┬─nested.v2──┐
│ food │ 1 │ [10,20,30] │
│ food │ 2 │ [10,20,30] │
│ food │ 3 │ [10,20,30] │
│ fruit │ 4 │ [40,50] │
│ fruit │ 5 │ [40,50] │
└───────┴───────────┴────────────┘
*/
可以看到,在這種情形下,只有被 ARRAY JOIN 的數組才會展開。
在查詢嵌套類型時也能夠通過別名的形式訪問原始數組:
SELECT title,
nested.v1, nested.v2,
n.v1, n.v2
from t3 ARRAY JOIN nested AS n;
/*
┌─title─┬─nested.v1─┬─nested.v2──┬─n.v1─┬─n.v2─┐
│ food │ [1,2,3] │ [10,20,30] │ 1 │ 10 │
│ food │ [1,2,3] │ [10,20,30] │ 2 │ 20 │
│ food │ [1,2,3] │ [10,20,30] │ 3 │ 30 │
│ fruit │ [4,5] │ [40,50] │ 4 │ 40 │
│ fruit │ [4,5] │ [40,50] │ 5 │ 50 │
└───────┴───────────┴────────────┴──────┴──────┘
*/
-- 所以 ARRAY JOIN nested 后面如果沒有 AS,那么這個 nested.v1 和 nest.v2 就是展開后的值
-- 如果 ARRAY JOIN nested AS n,起了一個別名,那么 nested.v1 和 nest.v2 就是展開前值,也就是數組本身
-- 而 n.v1 和 n.v2 才是展開后的值
-- 另外 ARRAY JOIN nested AS n,這個 n 可以不使用
SELECT title,
nested.v1, nested.v2
from t3 ARRAY JOIN nested AS n;
/*
┌─title─┬─nested.v1─┬─nested.v2──┐
│ food │ [1,2,3] │ [10,20,30] │
│ food │ [1,2,3] │ [10,20,30] │
│ food │ [1,2,3] │ [10,20,30] │
│ fruit │ [4,5] │ [40,50] │
│ fruit │ [4,5] │ [40,50] │
└───────┴───────────┴────────────┘
*/
JOIN 子句
JOIN 子句可以對左右兩張表的數據進行連接,這是最常用的查詢子句之一,它的語法包含連接精度和連接類型兩部分。目前 ClickHouse 支持的 JOIN 子句形式如圖所示:
由上圖可知,連接精度分為 ALL、ANY 和 ASOF 三種(准確的說是五種,還有 SEMI 和 ANTI,這兩個過會再提),而連接類型也可分為外連接、內連接和交叉連接三種。
除此之外,JOIN 查詢還可以根據其執行策略被划分為本地查詢和遠程查詢。關於遠程查詢的內容放在后續章節進行說明,這里着重講解本地查詢。
連接精度
連接精度決定了 JOIN 查詢在連接數據時所使用的策略,目前支持 ALL、ANY 和 ASOF 三種類型。如果不主動聲明,則默認是 ALL。可以通過 join_default_strictness 配置參數修改默認的連接精度類型。
那么這個連接精度指的是啥呢?舉個栗子就明白了。
SELECT * FROM tbl_1;
/*
┌─id─┬─code1─┬─count─┐
│ 1 │ A001 │ 30 │
│ 2 │ A002 │ 28 │
│ 3 │ A003 │ 32 │
└────┴───────┴───────┘
*/
SELECT * FROM tbl_2;
/*
┌─id─┬─code2─┬─count─┐
│ 1 │ B001 │ 35 │
│ 1 │ B001 │ 29 │
│ 3 │ B003 │ 31 │
│ 4 │ B004 │ 38 │
└────┴───────┴───────┘
*/
以上是用於測試的表數據,下面進行測試:
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 AS t1
ALL INNER JOIN tbl_2 AS t2
ON t1.id = t2.id;
/*
┌─id─┬─code1─┬─code2─┐
│ 1 │ A001 │ B001 │
│ 1 │ A001 │ B001 │
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
*/
-- 一切正常,跟一般的關系型數據庫是類似的,但如果將 ALL 改成 ANY
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 AS t1
ANY INNER JOIN tbl_2 AS t2
ON t1.id = t2.id;
/*
┌─id─┬─code1─┬─code2─┐
│ 1 │ A001 │ B001 │
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
*/
所以結論很清晰了,如果左表內的一行數據,在右表中有多行數據與之連接匹配,那么當連接精度為 ALL,會返回右表中全部連接的數據;當連接精度為 ANY,會僅返回右表中第一行連接的數據
這就是連接精度,沒什么好稀奇的,不過在關系型數據庫中則沒有連接精度的概念,因為當出現這種情況,只有一個策略,那就是直接返回右表中匹配的全部數據。而在 ClickHouse 中,給了我們自由選擇的權利。
除了 ALL 和 ANY 之外還有一個 ASOF,它是做什么的呢?首先無論 ALL 還是 ANY,在連接的時候必須是等值連接。比如上面的 t1.id = t2.id,如果改成 t1.id >= t2.id 就是錯誤的,如果是多個連接條件,那么這些連接條件都必須是等值連接。但 ASOF 表示模糊連接,也就是它允許你在等值連接的后面加上一個非等值連接,舉個栗子:
SELECT t1.id, t1.code1, t2.code2, t1.count AS count1, t2.count AS count2
FROM tbl_1 AS t1
ALL INNER JOIN tbl_2 AS t2
ON t1.id = t2.id;
/*
┌─id─┬─code1─┬─code2─┬─count1─┬─count2─┐
│ 1 │ A001 │ B001 │ 30 │ 35 │
│ 1 │ A001 │ B001 │ 30 │ 29 │
│ 3 │ A003 │ B003 │ 32 │ 31 │
└────┴───────┴───────┴────────┴────────┘
*/
SELECT t1.id, t1.code1, t2.code2, t1.count AS count1, t2.count AS count2
FROM tbl_1 AS t1
ASOF INNER JOIN tbl_2 AS t2
ON t1.id = t2.id AND t1.count > t2.count;
/*
┌─id─┬─code1─┬─code2─┬─count1─┬─count2─┐
│ 1 │ A001 │ B001 │ 30 │ 29 │
│ 3 │ A003 │ B003 │ 32 │ 31 │
└────┴───────┴───────┴────────┴────────┘
*/
SELECT t1.id, t1.code1, t2.code2, t1.count AS count1, t2.count AS count2
FROM tbl_1 AS t1
ASOF INNER JOIN tbl_2 AS t2
ON t1.id = t2.id AND t1.count < t2.count;
/*
┌─id─┬─code1─┬─code2─┬─count1─┬─count2─┐
│ 1 │ A001 │ B001 │ 30 │ 35 │
└────┴───────┴───────┴────────┴────────┘
*/
所以結論很清晰,如果連接精度為 ALL 或者 ANY,那么所有的連接條件必須為等值連接,如果出現了非等值連接則報錯。而這兩者的唯一區別就在於:
ALL:如果右表有多條數據匹配,返回所有的匹配的數據
ANY:如果右表有多條數據匹配,返回第一條匹配的數據
如果連接精度為 ASOF,那么允許在等值連接條件后面追加一個非等值連接,所以上面的 t1.id = t2.id 是等值連接,t1.count > t2.count 是非等值連接。但需要注意的是:使用非等值連接時,這個非等值可以是 >、>=、<、<=,但不能是 !=;並且對於 ASOF 而言,連接條件必須是等值連接和非等值連接的組合,兩者缺一不可。
對於 ASOF 而言,如果右表中有多行數據匹配,只會返回第一行。
連接類型
連接類型就比較簡單了,這個和關系型數據庫是完全類似的。
-- 省略連接精度,默認為 ALL
-- 左連接
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 t1 LEFT JOIN tbl_2 t2
USING(id); -- 等價於 t1.id = t2.id
/*
┌─id─┬─code1─┬─code2─┐
│ 1 │ A001 │ B001 │
│ 1 │ A001 │ B001 │
│ 2 │ A002 │ │
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
*/
-- 右連接
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 t1 RIGHT JOIN tbl_2 t2
USING(id);
/*
┌─id─┬─code1─┬─code2─┐
│ 1 │ A001 │ B001 │
│ 1 │ A001 │ B001 │
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
┌─id─┬─code1─┬─code2─┐
│ 4 │ │ B004 │
└────┴───────┴───────┘
*/
-- 全連接
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 t1 FULL JOIN tbl_2 t2
USING(id);
/*
┌─id─┬─code1─┬─code2─┐
│ 1 │ A001 │ B001 │
│ 1 │ A001 │ B001 │
│ 2 │ A002 │ │
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
┌─id─┬─code1─┬─code2─┐
│ 4 │ │ B004 │
└────┴───────┴───────┘
*/
和關系型數據庫類似,但有一點區別,就是當沒有與之匹配的記錄時,會使用對應類型的空值進行補全,而不是 Null。這里沒有指定連接精度,默認為 ALL,此外 LEFT / RIGHT / FULL JOIN 后面都可以加上一個 OUTER,不過也可以不加。最后是交叉連接,交叉連接直接會去笛卡爾積,不需要任何的連接條件。
SEMI 和 ANTI
我們之前說連接精度不止 ALL、ANY、ASOF 三種,還有 SEMI 和 ANTI,只不過這兩個比較特殊,因為它們只能用在 LEFT JOIN 和 RIGHT JOIN 上面,所以我們單獨介紹。
t1 SEMI LEFT JOIN t2 USING(id):遍歷 t1 中的 id,如果存在於 t2 中,則輸出
t1 SEMI RIGHT JOIN t2 USING(id):遍歷 t2 中的 id,如果存在於 t1 中,則輸出
t1 ANTI LEFT JOIN t2 USING(id):遍歷 t1 中的 id,如果不存在於 t2 中,則輸出
t1 ANTI RIGHT JOIN t2 USING(id):遍歷 t2 中的 id,如果不存在於 t1 中,則輸出
我們舉個栗子:
ANTI 則與之類似,只不過它的策略是不出現才輸出,可以自己嘗試一下。另外可能有人發現,這個 SEMI 的功能貌似有些重復了,因為我們使用 ALL 和 ANY 完全可以取代。其實如果你用過 hive 的話,會發現 SEMI LEFT JOIN 和 ANTI LEFT JOIN 是 IN/EXISTS 的一種更加高效的實現:
-- 這種子查詢應該非常常見了,查詢一張表,而過濾的條件該表的某個字段的取值要出現在另一張表的某個字段中
SELECT id, code1 FROM tbl_1 WHERE id in (SELECT id FROM tbl_2);
/*
┌─id─┬─code1─┐
│ 1 │ A001 │
│ 3 │ A003 │
└────┴───────┘
*/
-- 而通過 SEMI LEFT JOIN 的話,效率會更高一些
SELECT t1.id, t1.code1 FROM tbl_1 t1
SEMI LEFT JOIN tbl_2 t2 USING(id)
/*
┌─id─┬─code1─┐
│ 1 │ A001 │
│ 3 │ A003 │
└────┴───────┘
*/
-- ANTI 則是為了 NOT IN/EXISTS
兩者的輸出是一致的,所以 SEMI / ANTI LEFT JOIN 是為了 IN/EXISTS 這類場景而設計的,至於 SEMI RIGHT JOIN、ANTI RIGHT JOIN 就用的不是很多了。
Hive 里有一個 LEFT SEMI JOIN,單詞順序調換了一下,用途是類似的,不過它的局限性要比 ClickHouse 中的 SEMI LEFT JOIN 大很多。
-- Hive,這個 t2.xxx 只能出現在 ON 子句中用於連接,不可用在其它地方
t1 LEFT SEMI JOIN t2 ON t1.id = t2.id
-- ClickHouse,t2.xxx 除了可以出現在 ON 子句中,可以出現在 SELECT 子句中,WHERE 子句中
t1 SEMI LEFT JOIN t2 ON t1.id = t2.id
-- 舉個栗子:
SELECT t1.id, t1.code1, t2.code2
FROM tbl_1 t1 SEMI
LEFT JOIN tbl_2 t2
USING(id) where t2.code2 = 'B003'
/*
┌─id─┬─code1─┬─code2─┐
│ 3 │ A003 │ B003 │
└────┴───────┴───────┘
*/
另外 Hive 里面只有 LEFT SEMI JOIN,沒有其它的,但 ClickHouse 的選擇就多了很多。
多表連接
在進行多張數據表的連接查詢時,ClickHouse 會將它們轉為兩兩連接的形式。我們首先再創建一張表:
然后對三張測試表進行連接查詢:
SELECT t1.id, t1.code1, t2.code2, t3.code3
FROM tbl_1 AS t1 INNER JOIN tbl_2 AS t2 ON t1.id = t2.id
LEFT JOIN tbl_3 AS t3 ON t1.id = t3.id
在執行上述查詢時,tbl_1 和 tbl_2 會先進行內連接,之后再將它們的結果集合 tbl_3 進行左連接。
另外 ClickHouse 也支持關聯查詢的語法,只不過會自動轉成指定的連接查詢,舉個栗子:
-- 關聯查詢,如果沒有 WHERE,那么三張表會做笛卡爾積
SELECT t1.id, t1.code1, t2.code2, t3.code3
FROM tbl_1 t1, tbl_2 t2, tbl_3 t3
WHERE t1.id = t2.id AND t1.id = t3.id
/*
┌─t1.id─┬─t1.code1─┬─t2.code2─┬─t3.code3─┐
│ 3 │ A003 │ B003 │ C003 │
└───────┴──────────┴──────────┴──────────┘
*/
以上就是關聯查詢,雖然也能實現,不過還是不推薦這種做法,因為此時連接條件和過濾條件都寫在了 WHERE 子句里面,看起來會比較混亂。所以更推薦連接查詢(ClickHouse 會自動轉化),也就是 JOIN ON 的形式,此時 ON 后面寫連接條件,而數據過濾條件寫 WHERE 里面(當然我們這里不需要過濾)。
SELECT t1.id, t1.code1, t2.code2, t3.code3
FROM tbl_1 t1 INNER JOIN tbl_2 t2 ON t1.id = t2.id
INNER JOIN tbl_3 t3 ON t1.id = t3.id
/*
┌─t1.id─┬─t1.code1─┬─t2.code2─┬─t3.code3─┐
│ 3 │ A003 │ B003 │ C003 │
└───────┴──────────┴──────────┴──────────┘
*/
注意事項
最后,還有兩個關於 JOIN 查詢的注意事項。
1. 關於性能
最后,還有兩個關於 JOIN 查詢的注意事項。為了能夠優化 JOIN 查詢性能,首先應該遵循左大右小的原則,即數據量小的表要放在右側。這是因為在執行 JOIN 查詢時,無論使用的是哪種連接方式,右表都會被全部加載到內存中與左表進行比較。
其次,JOIN 查詢目前沒有緩存的支持,這意味着每一次 JOIN 查詢,即便是連續執行相同的 SQL,也都會生成一次全新的執行計划。如果應用程序會大量使用 JOIN 查詢,則需要進一步考慮借助上層應用側的緩存服務或使用 JOIN 表引擎來改善性能。
最后,如果是在大量維度屬性補全的查詢場景中,則建議使用字典代替 JOIN 查詢。因為在進行多表的連接查詢時,查詢會轉換成兩兩連接的形式,而這種滾雪球式的查詢很可能帶來性能問題。
2. 空值策略
在之前的介紹中,連接查詢的空值(那些未被連接的數據)是由默認值填充的,這與其他數據庫所采取的策略不同(由Null 填充)。連接查詢的空值策略通過 join_use_nulls 參數指定的,默認為 0。當參數值為 0 時,空值由數據類型的默認值填充;而當參數值為 1 時,空值由 Null 填充。
WHERE 與 PREWHERE 子句
WHERE 子句基於條件表達式來實現數據過濾,如果過濾條件恰好是主鍵字段,則能夠進一步借助索引過濾數據區間,從而加速查詢,所以 WHERE 子句是一條查詢語句能否啟用索引的判斷依據,前提是表引擎支持索引特性。
除了 WHERE,ClickHouse 還支持全新的 PREWHERE 子句,PREWHERE 目前只能用於 MegeTee 系列的表引擎,它可以看作對是 WHERE 的一種優化,其作用與 WHERE 相同,均是用來過濾數據。但它們的不同之處在於。使用 PREWHERE 時,首先只會讀取 PREWHERE 指定的列字段數據,用於數據過濾的條件判斷。待數據過濾之后再讀取 SELECT 聲明的列字段以補全其余屬性。所以在一些場合下,PREWHERE 相比 WHERE 而言,處理的數據量更少,性能更高。
既然 WHERE 子句性能更優,那么是否需要將所有的 WHERE 子句都替換成 PREWHERE 子句呢?其實大可不必,因為 ClickHouse 實現了自我優化的功能,會在條件合適的情況下將 WHERE 替換為 PREWHERE。如果想開啟這項特性,只需要將 optimize_move_to_prewhere 設置為 1 即可,當然默認就為 1,即開啟狀態。
但凡事也有例外,以下情形不會自動優化:
1)使用了常量表達式:
SELECT id, code FROM tbl WHERE 1 = 1
2)使用了默認值為 ALIAS 類型的字段:
-- 假設 code 的默認值類型是 ALIAS
SELECT id, code FROM tbl WHERE code = 'A000'
3)包含了 arrayJoin、globalIn、globalNotIn 或者 indexHint 查詢的:
SELECT title, nested.v1, nested.v2 FROM tbl ARRAY JOIN nested WHERE nested.v1 = 1
4)SELECT 查詢的列字段和 WHERE 謂詞相同:
SELECT v3 FROM tbl WHERE v3 = 1
5)使用了主鍵字段:
SELECT id FROM tbl WHERE id = 'A000'
雖然在上述情形中 ClickHouse 不會自動將謂詞移動到 PREWHERE,但仍然可以主動使用 PREWHERE。以主鍵字段為例,當使用 PREWHERE 進行主鍵查詢時,首先會通過稀疏索引過濾數據區間(index_granularity 粒度),接着會讀取 PREWHERE 指定的條件列進一步過濾,這樣一來就有可能截掉數據區間的尾巴,從而返回低於 index_granularity 粒度的數據范圍。但即便如此,相比其他場合移動謂詞所帶來的性能提升,這類效果還是比較有限的,所以目前 ClickHouse 在這類場合下仍然保持不移動的處理方式。
GROUP BY 子句
GROUP BY 又稱聚合查詢,是最常用的子句之一,它是讓 ClickHouse 最凸顯卓越性能的地方。在 GROUP BY 后聲明的表達式,通常稱為聚合鍵或者 Key,數據會按照聚合鍵進行聚合。ClickHouse 的聚合查詢中,和關系型數據庫也是類似的。
-- 只有聚合函數,可以省略 GROUP BY
SELECT sum(data_compressed_bytes) AS compressed,
sum(data_uncompressed_bytes) AS undata_compressed_bytes
FROM system.parts;
-- SELECT 子句中的字段要么出現在 GROUP BY 子句中,要么出現在聚合函數中
SELECT table, count() FROM system.parts GROUP BY table;
-- 錯誤的語法,rows 既沒有出現在 GROUP BY 中,也沒有出現在聚合函數中
SELECT table, count(), rows() FROM system.parts GROUP BY table;
如果聚合鍵對應的列包含 Null 值,那么所有的 Null 會被歸為同一組。
我們看到所有的 Null 被分為了一組,但是注意:count(字段) 不會把 Null 計算在內,所以直接 count() 就行。
比較簡單,但除了上述特性之外,聚合查詢還能配合 WITH ROLLUP、WITH CUBE、WITH TOTALS 三種修飾符獲取額外的匯總信息。
WITH ROLLUP
測試數據如下:
以上是普通的 GROUP BY,沒什么難的,然后看看它和 WITH ROLLUP 搭配會有什么效果:
我們注意到,多了四條數據,上面三條,就是按照 product、channel 匯總之后,再單獨按 product 匯總,而此時會給對應的 channel 設為零值(這里是空字符串,關系型數據庫中為 Null)。同理最后一條數據是全量匯總,不需要指定 product 和 channel,所以顯示為 product 和 channel 都顯示為零值。我們看到這就相當於按照 product 單獨聚合然后再自動拼接在上面了,排好序,並且自動將 channel 賦值為零值,同理最后一條數據也是如此。當然我們也可以寫多個語句,然后通過 UNION 也能實現上面的效果,有興趣可以自己試一下。但是 ClickHouse 提供了 WITH ROLLUP 這個非常方便的功能,我們就要利用好它。
GROUP BY 子句加上 WITH ROLLUP 選項時,首先按照全部的分組字段進行分組匯總;然后從右往左依次去掉一個分組字段再進行分組匯總,被去掉的字段顯示為零值;最后,將所有的數據進行一次匯總,所有的分組字段都顯示為零值。
WITH CUBE
CUBE 代表立方體,它用於對分組字段進行各種可能的組合,能夠產生多維度的交叉統計結果,CUBE 通常用於數據倉庫中的交叉報表分析。
從以上結果可以看出,CUBE 返回了更多的分組數據,其中不僅包含了 ROLLUP 匯總的結果,還包含了相當於按照 channel 進行聚合的記錄。因此隨着分組字段的增加,CUBE 產生的組合將會呈指數級增長。
WITH TOTALS
WITH TOTALS 反而是最簡單的,只包含一個全局匯總的結果。
HAVING 子句
HAVING 子句要和 GROUP BY 子句同時出現,不能單獨使用,它能夠在聚合計算之后實現數據的二次過濾。
對於上面的栗子,使用 WHERE 比使用 HAVING 的效率更高,因為 WHERE 等同於使用了謂詞下推,在聚合之前就減少了數據過濾,從而減少了后續聚合時需要處理的數據量。
所以使用 HAVING 進行過濾,那么應該是對聚合之后的結果進行過濾。如果不是聚合之后的,那么使用 WHERE 就好,舉個栗子:
因為 WHERE 的優先級大於 GROUP BY,所以如果按照聚合值進行統計,那么就必須要借助於 HAVING。
ORDER BY 子句
ORDER BY子句通過聲明排序鍵來指定查詢數據返回的順序,通過先前的介紹我們知道,在 MergeTree 表引擎中也有 ORDER BY 參數用於指定排序鍵,那么這兩者有何不同呢?在 MergeTree 中指定 ORDER BY 后,數據在各個分區內會按照其定義的規則排序,這是一種分區內的局部排序。如果在查詢時數據跨越了多個分區,則它們的返回順序是無法預知的,每一次查詢返回的順序都有可能不同。在這種情況下,如果需要數據總是能夠按照期望的順序范圍,就需要借助 ORDER BY 子句來指定全局順序。
ORDER BY 在使用時可以定義多個排序鍵,每個排序鍵后需緊跟 ASC(升序)或 DESC(降序)來確定排列順序。如若不寫,則默認為 ASC。例如下面的兩條語句即是等價的:
SELECT * FROM tbl ORDER BY v1 ASC, v2 DESC;
SELECT * FROM tbl ORDER BY v1, v2 DESC;
數據首先會按照 v1 升序,如果 v1 字段中出現了相同的值,那么再按照 v2 降序。
然后是 Null 值的排序,目前 ClickHouse 有 Null 值最后和 Null 值最前兩種策略,可以通過如下進行設置:
1. NULLS LAST
Null 值排在最后,無論升序還是降序,這也是默認的行為。
value -> NaN -> Null
2. NULLS FIRST
Null 值排在最后,無論升序還是降序。
NULL -> NaN -> value
經過測試不難發現,對於 NaN 而言,它總是跟在 Null 的身邊。
LIMIT BY 子句
LIMIT BY 子句和大家常見的 LIMIT 有所不同,它運行於 ORDER BY 之后和 LIMIT 之前,它能夠按照指定分組,最多返回前 n 行數據(少於 n 行則按照實際數量返回),常用於 TOP N 的查詢場景。LIMIT BY 語法規則如下:
LIMIT n BY express
個人覺得這個 LIMIT BY 非常強大,我們舉個栗子:
當然聚合之后沒有排序,我們還可以排一下序:
SELECT
product,
channel,
sum(amount) AS amount
FROM sales_data
GROUP BY
product,
channel
ORDER BY amount ASC
LIMIT 1 BY channel
此時會選擇每個渠道對應的金額最高的數據,當然我們也可以 LIMIT 多條數據、也可以 BY 多個字段。這個功能可以說是非常常用了,我們平時使用的 LIMIT,一般是全局排序之后選擇前 N 條數據,而這里的 LIMIT BY 是按照指定的字段分組,然后每一組選擇前 N 條數據。
LIMIT BY 會從上往下在每個組中選擇指定條數的數據,因此使用 LIMIT BY 應該同時指定 ORDER BY,否則拿出的數據沒有太大意義,除非數據本身就是有序的。
當然 LIMIT BY 也可以指定偏移量,因為不一定從一條開始選擇,而指定偏移量有兩種方式:
LIMIT N OFFSET M BY ...
LIMIT M, N BY ...
LIMIT 子句
LIMIT 子句用於返回指定的前 N 行數據,常用於分頁場景,它的三種語法形式如下:
LIMIT N
LIMIT N OFFSET M
LIMIT M, N
用法和 LIMIT BY 中的 LIMIT 一致,如果把 LIMIT BY 中的 BY 去掉,那么就變成了 LIMIT。比較簡單,這里用一張圖來介紹一下 LIMIT 和 LIMIT BY 之前的關系:
比較簡單,但是在使用 LIMIT 的時候需要注意一點,如果數據跨越了多個分區,那么在沒有使用 ORDER BY 指定全局順序的情況下,每次 LIMIT 查詢所返回的數據可能有所不同。如果對返回的數據的順序比較敏感,則應搭配 ORDER BY 一起使用。
SELECT 和 DISTINCT 子句
SELECT 子句決定了一次查詢語句最終能返回哪些字段或表達式,與直觀感受不同,雖然 SELECT 位於 SQL 語句的起始位置,但它的執行的順序卻排在了上面介紹的所有子句的后面。在其它子句執行之后,SELECT 會將選取的字段或表達式作用於每行數據之上,如果使用 * 通配符,則會返回所有字段。但正如開篇所言,大多數情況下都不建議這么做,因為對於一款列式存儲數據庫而言,這絕對是劣勢而不是優勢(我們這里在學習的過程就不算了)。
在選擇列字段時,ClickHouse 還為特定場景提供了一種基於正則查詢的形式,例如下面會選擇以 n 開頭和包含字母 p 的字段:
SELECT COLUMNS('^n'), COLUMNS('p') FROM system.databases
DISTINCT 子句能夠去除重復數據,使用場景也很廣泛,很多人經常會拿它和 GROUP BY 進行對比:
雖然順序不同,但顯然結果集的內容是一致的,那么這兩者之間有什么區別呢?如果觀察它們的執行計划(后面會說)不難發現,DISTINCT 子句的執行計划會更加簡單,與此同時,DISTINCT 也能夠和 GROUP BY 搭配使用,所以它們是互補而不是互斥的關系。
另外,如果使用了 LIMIT 且沒有 ORDER BY 子句,那么 DISTINCT 在滿足條件時能夠立即結束查詢。假設我只需要去重之后的前三條數據,那么 GROUP BY 會對全體數據進行分組,然后再選擇前三條;而 DISTINCT 在去重時發現已經有三條了,於是直接返回,后面的數據就不需要看了,因為看了也沒意義,LIMIT 決定了只返回三條。
兩個查詢返回的結果集不一樣,這是因為 GROUP BY 和 DISTINCT 處理數據的順序不同。一開始我們就看到了,如果沒有 LIMIT,那么兩個結果集順序不同,但內容是一樣的,只是這里加了 LIMIT,所以相當於選擇了相同內容的不同部分。
如果有 ORDER BY,那么會先執行 DISTINCT,再執行 ORDER BY。並且對於 Null 而言,如果有多個 Null,那么 DISTINCT 之后只會保留一個 Null。
UNION ALL 子句
UNION ALL 子句能夠聯合左右兩邊的兩組子查詢,將結果一並返回。在一次查詢中可以聲明多次 UNION ALL 以便聯合多組查詢,但 UNION ALL 不能直接使用其他子句(例如 ORDER BY、LIMIT 等),這些子句只能在它聯合的子查詢中使用。
SELECT name, v1 FROM union_v1
UNION ALL
SELECT title, v1 FROM union_v1
對於 UNION ALL 兩側的子查詢有以下幾點信息:首先,列字段的數量必須相同;其次,列字段的數據類型必須相同或相兼容;最后,列字段的名稱可以不同,查詢結果中的列名會以左邊的子查詢為准。
對於聯合查詢還有一點要說明,目前 ClickHouse 只支持 UNION ALL 子句,如果想得到 UNION DISTINCT 子句的效果,可以使用嵌套查詢來變相實現,例如:
SELECT DISTINCT name FROM
(
SELECT name, v1 FROM union_v1
UNION ALL
SELECT title, v1 FROM union_v1
)
SAMPLE 子句
SAMPLE 子句能夠實現數據采樣的功能,使查詢僅返回采樣數據而不是全部數據,從面有效減少查詢負載。SAMPLE 子句的采樣機制是一種冪等設計,也就是說在數據不發生變化的情況下,使用相同的采樣規則總是能等返回相同的數據,所以這項特性非常適合在那些可以接受近似查詢結果的場合使用。例如在數據量十分巨大的情況下,對查詢時效性的要求大於准確性時就可以嘗試使用 SAMPLE 子句。
SAMPLE 子句只能用於 MergeTree 系列引擎的數據表,並且要求在 CREATE TABLE 時聲明 SAMPLE BY 表達式,例如:
CREATE TABLE hits_v1 (
CounterID UInt64,
EventDate Date,
UserID UInt64
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(EventDate)
ORDER BY (CounterID, intHash32(UserID))
-- SAMPLE BY 聲明的表達式必須要包含在主鍵的聲明中
SAMPLE BY intHash32(UserID)
SAMPLE BY 表示 hits_v1內的數據,可以按照 intHash32(UserID) 分布后的結果采樣查詢。但需要注意:SAMPLE BY 所聲明的表達式必須同時包含在主鍵的聲明內,並且選擇的字段必須是 Int 類型,如果不是 ClickHouse 在建表的時候也不會報錯,但查詢的時候會出異常。
SAMPLE 子句目前支持如下 3 種用法:
1. SAMPLE factor
SAMPLE factor 表示按因子系數采樣,其中 factor 表示采樣因子,它的取值支持0~1 之間的小數。如果 factor 設置為 0 或者 1,則效果等同於不進行數據采樣。
SELECT CounterID FROM hits_v1 SAMPLE 0.1
factor 也支持使用十進制的形式表述。
SELECT CounterID FROM hits_v1 SAMPLE 1 / 10
如果在進行統計查詢時,為了得到最終的近似結果,需要將得到的直接結果乘以采樣系數。例如想按照 0.1 的因子采樣數據,則需要將統計結果放大 10 倍。
SELECT count() * 10 FROM hits_v1 SAMPLE 0.1
一種更為優雅的方法是借助虛擬字段 _sample_factor 來獲取采樣系數,並以此代替硬編碼的形式,_sample_factor 可以發那會當前查詢所對應的采樣系數。
SELECT count() * any(_sample_factor) FROM hits_v1 SAMPLE 0.1
2. SAMPLE rows
SAMPLE rows 表示按樣本數量采樣,其中 rows 表示至少采樣多少行數據,它的取值必須是大於 1 的整數。如果 rows 的取值大於表內數據的總行數,則效果等於 rows = 1,也就是不使用采樣。
比如我們采樣 10000 行數據:
SELECT count() FROM hits_v1 SAMPLE 10000;
雖然我們采樣 10000 行,但是不一定就返回 10000 行,因為數據采樣是一個近似范圍,這是由於采樣數據的最小粒度由 index_granularity 索引粒度所決定的。由此可知,設置一個小於索引粒度或者較小的 rows 沒有什么意義,應該設置一個比較大的值。另外,同樣可以使用虛擬字段 _sample_factor 來獲取當前查詢對應的采樣系數。
4. SAMPLE factor OFFSET n
SAMPLE factor OFFSET n 表示按因子系數和偏移量采樣,其中 factor 表示采樣因子,n 表示偏移多少數據后才開始采樣,它們兩個的取值都是 0~1 之間的小數。例如下面的語句表示偏移量為 0.5 並按 0.4 的系數采樣:
SELECT CounterID FROM hits_v1 SAMPEL 0.4 OFFSET 0.5
上述查詢會從數據的二分之一處開始,按照 0.4 的系數采樣數據:
如果在計算 OFFSET 偏移量后,按照 SAMPLE 比例采樣出現了溢出,則數據會被自動截斷。
當然這種做法也支持虛擬字段。
小結
以上就是 ClickHouse 關於查詢方面的內容,可以肯定的是內容絕對不止這些,因為和關系型數據庫重疊的部分這里自動省略或者一筆帶過了,比如空值如何處理(nullif、coalesce)、IN 查詢、LIKE 查詢、什么是子查詢、CASE WHEN 語句等等等等。如果大部分的關系型數據庫都支持的語法,那么在 ClickHouse 中基本也是支持的。所以個人覺得有 MySQL 相關經驗的話,至少在 ClickHouse 的查詢方面,絕對是非常好上手的,沒事多寫一寫就行。
接下來我會介紹 ClickHouse 中關於操作數組的函數,到時候也會刻意地融入更多的語法(這里介紹的,和沒有介紹的)。因為 ClickHouse 中提供了大量的函數,通過這些函數 ClickHouse 在處理數據就能夠變得所向披靡,但我們不可能一下全說完,這里就先拿數組開刀,因為它相對更復雜一些。