按引用表中相關行數排序
假設有兩個表:
使用者
id [pk] | name --------+--------- 1 | Alice 2 | Bob 3 | Charlie 4 | Dan
電子郵件
id | user_id | email ----+---------+------- 1 | 1 | a.1 2 | 1 | a.2 3 | 2 | a.3 4 | 2 | b.1 5 | 2 | a.4 6 | 2 | a.5 7 | 3 | b.2 8 | 3 | a.6
通過一個查詢,我想檢索:
- 使用者的 id 和名稱
- 使用者的電子郵件計數
- 使用者的電子郵件及其 ID
我希望輸出按電子郵件數量降序排列並過濾,僅包括以“a”開頭的電子郵件。沒有電子郵件的使用者也應包括在內 - 將他們的電子郵件計數視為
0
。有我的查詢:
SELECT users.id AS user_id, users.name AS name, emails.id AS email_id, emails.email AS email, count(emails.id) OVER (PARTITION BY users.id) as n_emails FROM users LEFT JOIN emails on users.id = emails.user_id WHERE emails.email LIKE 'a' || '%%' ORDER BY n_emails DESC;
而(預期的)結果,看起來不錯:
user_id | name | email_id | email | n_emails ---------+---------+----------+-------+---------- 2 | Bob | 6 | a.5 | 3 2 | Bob | 5 | a.4 | 3 2 | Bob | 3 | a.3 | 3 1 | Alice | 2 | a.2 | 2 1 | Alice | 1 | a.1 | 2 3 | Charlie | 8 | a.6 | 1
很明顯,這是一個簡單而小例子,而實際數據集可能足夠大,所以我想使用
LIMIT
/OFFSET
進行分頁。例如,我想獲取第一對使用者(不僅僅是行):-- previous query ... LIMIT 2 OFFSET 0;
而且……失敗。我只有關於 Bob 的不完整資訊:
user_id | name | email_id | email | n_emails ---------+------+----------+-------+---------- 2 | Bob | 6 | a.5 | 3 2 | Bob | 5 | a.4 | 3
因此問題是:如何將限制/偏移應用於對象,在這種情況下,使用者(邏輯實體,而不是行)?
我找到了這樣的解決方案:添加
dense_rank()
users.id 然後按排名過濾:SELECT * FROM ( SELECT users.id AS user_id, users.name AS name, emails.id AS email_id, emails.email AS email, count(emails.id) OVER (PARTITION BY users.id) as n_emails, dense_rank() OVER (ORDER BY users.id) as n_user FROM users LEFT JOIN emails on users.id = emails.user_id WHERE emails.email LIKE 'a' || '%%' ORDER BY n_emails DESC ) AS sq WHERE sq.n_user <= 2; -- here it is
輸出看起來不錯:
user_id | name | email_id | email | n_emails | n_user ---------+-------+----------+-------+----------+-------- 2 | Bob | 6 | a.5 | 3 | 2 2 | Bob | 5 | a.4 | 3 | 2 2 | Bob | 3 | a.3 | 3 | 2 1 | Alice | 2 | a.2 | 2 | 1 1 | Alice | 1 | a.1 | 2 | 1
但是如果您查看查詢計劃,您會發現最昂貴的步驟是子查詢掃描和排序。AFAIK 不可能在子查詢或 CTE 上建立索引,因此它將始終對 n_user 進行序列掃描/過濾,並且查詢將在大數據集上執行很長時間。
我看到的另一個解決方案是進行兩個查詢:
- 使用子查詢僅檢索過濾和排序數據集的使用者 ID 和電子郵件數量;
- 將第一個子查詢加入使用者和電子郵件
查詢是:
SELECT users.id AS user_id, users.name, emails.id AS email_id, emails.email, sq.n_emails FROM (SELECT users.id, count(emails.id) AS n_emails FROM users LEFT JOIN emails ON users.id = emails.user_id WHERE emails.email LIKE 'a' || '%%' GROUP BY users.id ORDER BY n_emails DESC LIMIT 2 OFFSET 0 -- here it is ) AS sq JOIN users ON users.id = sq.id LEFT JOIN emails ON emails.user_id = users.id WHERE emails.email LIKE 'a' || '%%' ORDER BY sq.n_emails DESC;
這似乎要快得多。但它看起來不是一個好的解決方案,因為我必須複製完全相同的查詢(
SELECT...FROM
部分除外),事實上,一個查詢執行兩次。有沒有更好的解決方案?
排除沒有電子郵件的使用者
假設我們只想要實際擁有電子郵件的使用者。沒有電子郵件的使用者將被忽略。我最初採用這個假設的原因是您的所有查詢都已經這樣做了:
LEFT JOIN emails on users.id = emails.user_id WHERE emails.email LIKE 'a' || '%%'
通過添加
WHERE
條件,emails.email
您可以有效地將您的內容轉換LEFT JOIN
為純文字[INNER] JOIN
並排除沒有電子郵件的使用者。詳細解釋:重寫第二個查詢
您的第二個查詢不像宣傳的那樣工作,結果不是“按電子郵件數量降序”。您必須將結果嵌套
count()
在另一個 CTE 或子查詢中並dense_rank()
在其上執行。您不能在同一查詢級別中嵌套視窗函式。SELECT u.name, e2.* FROM ( SELECT *, dense_rank() OVER (ORDER BY n_emails, users.id) AS rnk FROM ( SELECT user_id, id AS e_id, e_mail , count(*) OVER (PARTITION BY user_id) AS n_emails FROM emails WHERE email LIKE 'a' || '%' -- one % is enough ) e1 ) e2 JOIN users u ON u.id = e2.user_id WHERE rnk < 3 ORDER BY rnk;
如果謂詞具有足夠的選擇性(僅選擇所有電子郵件的一小部分),這應該是最快的。行排序不同的兩個視窗函式也有其價格。
- 一個要點是
emails
僅執行子查詢 - 如果初步假設成立,這是可能的。第三次查詢改進
另一方面,如果謂詞
WHERE e.email LIKE 'a' || '%'
不是很有選擇性,那麼您的第三次查詢可能會更快,即使它從表中讀取兩次 - 但第二次只需要行。還改進了:SELECT e.user_id, u.name, e.id AS e_id, e.e_mail, sq.n_emails FROM ( SELECT user_id, count(*) AS n_emails FROM emails WHERE email LIKE 'a' || '%' GROUP BY user_id ORDER BY count(*) DESC, user_id -- break ties LIMIT 2 OFFSET 0 ) sq JOIN emails e USING (user_id) JOIN users u ON u.id = e.user_id WHERE e.email LIKE 'a' || '%' ORDER BY sq.n_emails DESC;
包括沒有電子郵件的使用者
您可以再次在內部查詢中包含 users 表,類似於您之前的內容。但是您必須將電子郵件過濾器拉入加入條件!
SELECT u.name, e2.* FROM ( SELECT *, dense_rank() OVER (ORDER BY n_emails, users.id) AS rnk FROM ( SELECT u.id AS user_id, u.name, e.id AS e_id , count(e.user_id) OVER (PARTITION BY u.id) AS n_emails FROM users u LEFT JOIN emails e ON e.user_id = u.id AND e.email LIKE 'a' || '%' -- !!! ) e1 ) e2 WHERE rnk < 3 ORDER BY rnk;
這會貴一點。
由於您首先檢索電子郵件最多的使用者,因此沒有電子郵件的使用者很少會出現在結果中。要優化性能,您可以使用
UNION ALL
withLIMIT
:( -- parentheses required SELECT u.name, e2.user_id, e2.e_id, e2.e_mail, e2.n_emails FROM ( SELECT *, dense_rank() OVER (ORDER BY n_emails, users.id) AS rnk FROM ( SELECT user_id, id AS e_id, e_mail , count(*) OVER (PARTITION BY user_id) AS n_emails FROM emails WHERE email LIKE 'a' || '%' -- one % is enough ) e1 ) e2 JOIN users u ON u.id = e2.user_id WHERE rnk < 3 -- adapt to paging! ORDER BY rnk ) UNION ALL ( SELECT u.name, u.user_id, NULL AS e_id, NULL AS e_mail, 0 AS n_emails FROM users u LEFT JOIN emails e ON e.user_id = u.id AND e.email LIKE 'a' || '%' WHERE e.e.user_id IS NULL ) OFFSET 0 -- adapt to paging! LIMIT 2 -- adapt to paging!
詳細解釋:
MATERIALIZED VIEW
我會考慮實現結果有兩個原因:
- 後續查詢要快得多。
- 您不必對移動的目標進行操作。您談到分頁,如果使用者在頁面之間收到新電子郵件,您的整個排序順序可能沒有實際意義。
從不帶( ) 的第二個查詢建構一個 MV ,然後返回第一頁等。這是一個政策問題,當您再次刷新 MV 時。
LIMIT
REFRESH MATERIALIZED VIEW