LIMIT 10 會限制結果數量,但要找到十個不重複的值,可能需要數千筆輸入資料列。本指南將說明如何診斷這些工作,並透過 Mac 的 Terminal 使用合成文章主題比較兩種方法。
為什麼十筆結果可能需要數千筆資料列
假設一個文章資料表有 100,000 筆資料列,涵蓋 100 個主題,每個主題有 1,000 篇文章。你的任務是依字母順序取得前十個不同的主題名稱。查詢必須移除重複名稱、維持主題順序,並回傳十筆結果。
PostgreSQL 將 DISTINCT 定義為移除重複的輸出資料列,將 LIMIT 定義為限制回傳結果。這些語意不會對底層掃描設定十筆資料列的上限。規劃器會在選擇計畫時考量限制,因此實際工作量取決於選用的執行策略。SELECT
假設有一個已排序的資料流,其中先有 1,000 筆 topic-001,接著是 1,000 筆 topic-002,依此類推。串流式重複資料移除作業必須讀取前九個完整群組,以及第十個群組的第一筆資料列,才能看到第十個不同的主題:
所需輸入資料列 = (9 × 1,000) + 1 = 9,001
回傳的不重複結果 = 10
這是針對特定串流策略的示意計算,並非實際量測的 PostgreSQL 計畫。群組大小不均會改變計算結果:即使後續群組都很小,第一個主題群組非常大時,仍可能主導工作量。
相符的 B-tree 索引可以提供已排序的輸入,省去明確排序。PostgreSQL 文件指出,搭配 LIMIT 的索引 ORDER BY 可以提早取得資料,但移除重複項目還有另一項需求:必須有足夠多不同的值到達 limit 節點。索引可以省去排序工作,卻仍可能留下大量重複值處理工作。索引與 ORDER BY
閱讀計畫,找出哪些操作消耗了資料列
對實際查詢和參數值使用 EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)。ANALYZE 會執行敘述並回報實際資料列數;BUFFERS 則回報區塊活動。下方的完整範例會在合成資料上執行這項診斷。EXPLAIN
以下列出兩種實用的計畫模式。這些是示意形狀,並非擷取的輸出,也不保證你的安裝環境會選用這些計畫。
有序串流路徑:
Limit
Unique
Index Only Scan 或 Index Scan
彙總路徑:
Limit
Sort
HashAggregate
Seq Scan
在有序路徑中,比較掃描輸出與 Unique 和 Limit 的輸出。差距很大表示輸入含有大量重複值。在彙總路徑中,彙總作業必須先讀取其輸入以建立群組,之後才能完成最後的排序與限制。PostgreSQL 的計畫文件說明了掃描如何將資料傳給排序和彙總節點。使用 EXPLAIN
閱讀實際資料列數時,也要查看 loops:節點重複執行時,資料列數是每次執行的平均值。估算該節點的總輸出量時,還要乘以迴圈次數。也要檢查排序下方的輸入;排序輸出的資料列數很少,不能證明其輸入也很少。使用 EXPLAIN
請分開看待三種量測值:節點輸出的資料列數、區塊活動,以及經過時間。緩衝區計數描述的是區塊而非邏輯資料列,父節點的緩衝區總數也包含其子節點。暫存資料表會使用本機緩衝區;排序等操作使用的暫存工作檔則會以 temp 區塊呈現。EXPLAIN
在 Mac 上重現比較
使用目前的 Node.js LTS 版本、npm,以及現有的本機 PostgreSQL 資料庫。本操作說明以 PostgreSQL 18 文件作為基準。程式會印出伺服器版本,方便你參照該版本的文件解讀結果。node-postgres 文件說明如何使用 npm install pg 安裝套件,以及如何支援目前和 LTS 版 Node.js。node-postgres
在 Terminal 中建立工作目錄並安裝驅動程式:
mkdir distinct-row-demo
cd distinct-row-demo
npm init -y
npm install pg
將以下完整程式儲存為 distinct-demo.mjs。它會在暫存資料表中建立合成文章資料列,測量一般查詢在新增索引前後的表現,並比較明確的後繼值搜尋查詢。程式全程使用同一個 Client,讓每個查詢都在相同的資料庫工作階段中執行。
連線設定取自一般的 PostgreSQL 環境變數。執行程式前,請先為現有的本機資料庫設定這些變數。node-postgres 文件說明了以環境變數建立連線,以及將查詢值與 SQL 文字分開傳入的方式。連線、查詢
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client({ connectionTimeoutMillis: 5000 });
const limit = 10;
const distinctSql = `
SELECT DISTINCT topic FROM demo_articles
ORDER BY topic LIMIT $1::integer
`;
const seekSql = `
WITH RECURSIVE topics(topic, step) AS (
(SELECT topic, 1 FROM demo_articles ORDER BY topic LIMIT 1)
UNION ALL
SELECT n.topic, t.step + 1
FROM topics AS t
CROSS JOIN LATERAL (
SELECT a.topic FROM demo_articles AS a
WHERE a.topic > t.topic ORDER BY a.topic LIMIT 1
) AS n
WHERE t.step < $1::integer
)
SELECT topic FROM topics ORDER BY topic
`;
async function showPlan(label, sql) {
const plan = await client.query(
`EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) ${sql}`, [limit]
);
console.log(`\n${label}`);
console.log(plan.rows.map(row => row['QUERY PLAN']).join('\n'));
}
try {
await client.connect();
const version = await client.query('SHOW server_version');
console.log('PostgreSQL', version.rows[0].server_version);
await client.query(`
CREATE TEMP TABLE demo_articles (
article_id integer NOT NULL,
topic text COLLATE "C" NOT NULL,
title text NOT NULL
)
`);
await client.query(`
INSERT INTO demo_articles (article_id, topic, title)
SELECT n,
'topic-' || lpad((((n - 1) / 1000) + 1)::text, 3, '0'),
'Sample article ' || n::text
FROM generate_series(1, 100000) AS s(n)
`);
await client.query('ANALYZE demo_articles');
await showPlan('Before index', distinctSql);
await client.query(
'CREATE INDEX demo_articles_topic_idx ON demo_articles (topic)'
);
await client.query('ANALYZE demo_articles');
await showPlan('After index', distinctSql);
await showPlan('Successor seeks', seekSql);
const ordinary = await client.query(distinctSql, [limit]);
const seeking = await client.query(seekSql, [limit]);
assert.deepEqual(seeking.rows, ordinary.rows);
assert.equal(ordinary.rows.length, limit);
console.log(ordinary.rows.map(row => row.topic));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
await client.end();
}
資料產生器使用 PostgreSQL 文件記載的 generate_series 函式產生 1 到 100,000 的整數。算術運算會將每連續 1,000 篇文章指派給同一個主題。ANALYZE 會提供規劃器選擇計畫時使用的資料表統計資料。集合返回函式、ANALYZE
使用現有本機資料庫的名稱執行程式。例如,若資料庫名稱是 postgres:
PGHOST=localhost PGPORT=5432 PGDATABASE=postgres node distinct-demo.mjs
預期的主題名稱會從 topic-001 到 topic-010;斷言會檢查結果筆數,以及兩個查詢的結果是否一致。本文已檢查 JavaScript 語法及 9,001 筆輸入的串流計算。研究環境無法執行資料庫,因此本文刻意省略執行時間和擷取的 PostgreSQL 計畫。
為什麼僅索引掃描仍可能需要大量工作
Index Only Scan 表示查詢可以從索引取得所需欄位值。這不代表每個不重複結果只需讀取一筆索引項目。即使很少存取資料表本身,也要檢查其實際資料列數。
存取堆積資料是另一項考量。PostgreSQL 會檢查可見性對應表;若對應頁面未標示為全可見,仍可能存取堆積元組。因此,即使使用僅索引計畫,新插入的示範資料列仍可能產生堆積資料擷取。請一併查看 Heap Fetches 和掃描資料列數。僅索引掃描與涵蓋索引
使用 INCLUDE 新增額外負載欄位,可以讓僅索引掃描取得更多值,但不會改變所需的不重複主題數量。依據已確認的堆積存取工作選擇索引涵蓋欄位;重複值處理量則要另外測量。僅索引掃描與涵蓋索引
PostgreSQL 18 的跳躍掃描會自動解決這個問題嗎?
PostgreSQL 18 新增了 B-tree 跳躍掃描支援。文件說明,其用途是在某些多欄索引搜尋中,讓後續欄位上的條件引導系統重複搜尋較前面欄位的值。此功能是在第 18 版推出;不要假設 PostgreSQL 17 或更早版本也有此功能。版本 18、多欄索引
自動從一個不同的前導索引鍵跳到下一個,通常稱為鬆散索引掃描。PostgreSQL 專案 Wiki 區分了這種操作與以述詞為導向的索引跳躍掃描,並說明如何使用遞迴 SQL 模擬。因此,PostgreSQL 18 跳躍掃描功能的公告,並不能證明單欄 DISTINCT 查詢會自動執行鬆散掃描。鬆散索引掃描
程式中的後繼值搜尋查詢改編自該模擬方式:先找出最小主題,再反覆尋找大於前一個主題的最小主題。遞迴步驟會在取得十筆結果或找不到後繼值時停止。有合適索引時,每次搜尋都能跳過前一個重複值群組。請比較實際計畫和執行時間,不要先假設會更快。鬆散索引掃描
此範例明確要求主題不得為 null、排序方向為升冪、使用宣告的 C 排序規則,且限制值必須為正數。若限制值為零,遞迴起始列仍會回傳,因此通用實作必須另外處理零值。可為 null 的主題需要明確處理,因為大於比較不會列舉 null 值。如果新增文章篩選條件,初始搜尋和每次後繼搜尋都必須套用相同條件;否則改寫後的查詢可能會回傳不同的集合。
根據證據決定下一步
收集計畫後,使用此決策指南。它會區分排序索引能省去的工作,以及重複主題值造成的工作。
| 觀察結果 | 接下來要調查的事項 |
|---|---|
| 掃描結果進入彙總,接著進行最後排序 | 將現有計畫與符合主題排序方式的 B-tree 索引比較。 |
| 有序掃描為十個主題輸出數千筆資料列 | 測量重複值群組大小,並比較明確的後繼值搜尋。 |
| 僅索引掃描回報大量堆積資料擷取 | 分開調查可見性與堆積存取工作,不要與重複值數量混為一談。 |
| 估算值與實際資料列數差異很大 | 使用 ANALYZE 更新統計資料,然後再次測量。 |
| 已索引的一般查詢成本很低 | 保留較簡單的查詢,除非其他已測量的需求足以支持改寫。 |
避免在套用 DISTINCT 之前,將 LIMIT 10 移到子查詢中,先選出十篇原始文章。依照範例中的排序,這十篇文章全都屬於 topic-001,因此改寫後的查詢只會回傳一個主題,而不是十個。這會改變任務內容。
也要精確定義不重複鍵。將 article_id 加入所選欄位後,每篇文章都會成為不同的輸出資料列,使主題去重失效。如果任務改為每個主題選出一篇代表文章,PostgreSQL 的 DISTINCT ON 支援此操作,但其運算式必須與排序運算式最左側的項目相符。它本身不保證掃描的資料列會更少。SELECT
要取得前十個不重複的文章主題,先比較掃描實際輸出的資料列數與十筆回傳結果。接著測試相符的索引;如果重複值群組造成主要負擔,再測試有限度的後繼值搜尋查詢。選擇符合資料特性的實測方法,同時維持所需的主題集合和排序。
來源
- SELECT — PostgreSQL 全球開發團隊。
- 索引與 ORDER BY — PostgreSQL 全球開發團隊。
- 使用 EXPLAIN — PostgreSQL 全球開發團隊。
- EXPLAIN — PostgreSQL 全球開發團隊。
- node-postgres — node-postgres。
- 連線 — node-postgres。
- 查詢 — node-postgres。
- 集合返回函式 — PostgreSQL 全球開發團隊。
- ANALYZE — PostgreSQL 全球開發團隊。
- 僅索引掃描與涵蓋索引 — PostgreSQL 全球開發團隊。
- 版本 18 — PostgreSQL 全球開發團隊。
- 多欄索引 — PostgreSQL 全球開發團隊。
- 鬆散索引掃描 — PostgreSQL Wiki。