Tin tức

COUNT(DISTINCT) trong Postgres âm thầm vô hiệu hóa truy vấn song song — và cách khắc phục

Một từ khóa DISTINCT khiến toàn bộ câu lệnh trong PostgreSQL tắt truy vấn song song, buộc phải sắp xếp tuần tự toàn bộ bảng. Viết lại bằng GROUP BY giúp khôi phục tính song song.

August 6, 2026· 6 min read· Nguồn: boringSQL | Supercharge your SQL & PostgreSQL powers
COUNT(DISTINCT) trong Postgres âm thầm vô hiệu hóa truy vấn song song — và cách khắc phục

Mọi khối lượng công việc phân tích đều có truy vấn này: SELECT count(DISTINCT user_id) FROM events;. Trông có vẻ là việc rẻ nhất có thể — đếm số người dùng duy nhất. Trên một máy có nhiều lõi rảnh, bạn sẽ mong Postgres tung vài worker song song vào, giống như nó vẫn làm với hầu hết các lần quét lớn. Nhưng không. Một từ khóa đó, DISTINCT, tắt truy vấn song song cho toàn bộ câu lệnh, và bảng càng lớn thì chi phí càng cao. Không có cài đặt hay chỉ mục nào thay đổi được điều đó; lý do nằm ở cách aggregate phải thực thi.

Schema

Mười triệu sự kiện, khoảng năm mươi nghìn người dùng duy nhất, một vài quốc gia. Không có gì bất thường. Với max_parallel_workers_per_gather nâng lên 4 và work_mem là 64MB, không có sự thiếu hụt tài nguyên nào để đổ lỗi cho các plan dưới đây.

Hai phép đếm, hai plan khác nhau

Bắt đầu với count(*) đơn giản, không có gì để loại trùng. Plan cho thấy một Finalize Aggregate trên một Gather với bốn worker được khởi động, mỗi worker chạy một Partial Aggregate trên một Parallel Seq Scan. Bốn worker cộng với leader mỗi bên quét phần của mình và giữ một bộ đếm tăng dần, rồi leader cộng năm bộ đếm một phần lại với nhau ở cuối.

Giờ thêm một từ: count(DISTINCT user_id). Plan sụp đổ thành một Aggregate tuần tự trên một Sort của toàn bộ mười triệu dòng theo user_id, tràn 115MB ra đĩa. Không có Gather, không có Partial Aggregate, không có quét song song. Một lõi, toàn bộ bảng, cộng thêm I/O đĩa mà count(*) song song không bao giờ đụng tới.

Tại sao planner không thể tách nó ra

Sort là cách Postgres tính DISTINCT bên trong một aggregate: sắp xếp các giá trị và các giá trị bằng nhau liền kề sẽ gộp lại. Bảng băm là lựa chọn khác, nhưng đường dẫn DISTINCT-aggregate kinh điển là sort. Dù cách nào cũng phải thấy mọi giá trị ở một nơi, đó chính là toàn bộ vấn đề.

Aggregation song song trong Postgres hoạt động theo hai nửa. Mỗi worker chạy một Partial Aggregate để xây dựng transition state, một bản tóm tắt nhỏ đang chạy về các dòng nó đã thấy. Với count, state đó chỉ là một con số. Leader sau đó chạy một Finalize Aggregate để gộp các state một phần đó bằng combine function của aggregate. Combine function của count cộng các bộ đếm một phần. sum, avg, min, max đều có một cái.

count(DISTINCT user_id) không có bước combine dùng được. Để gộp kết quả của hai worker thành một đếm distinct toàn cục chính xác, leader sẽ cần biết người dùng nào mỗi worker đã thấy, vì một người dùng xuất hiện trong phần của worker 1 và lại xuất hiện trong phần của worker 2 phải được đếm một lần, không phải hai lần. Một bộ đếm một phần của các giá trị distinct không thể kết hợp được; bạn sẽ phải chuyển toàn bộ tập giá trị distinct từ mọi worker và hợp nhất chúng. Lúc đó bạn đã chuyển toàn bộ dữ liệu về một nơi, đúng thứ mà aggregation song song tồn tại để tránh.

Một aggregate mang DISTINCT (hoặc một ORDER BY bên trong) do đó không thể chạy ở chế độ một phần, planner không thể đặt một Partial Aggregate dưới một Gather, và không có partial aggregate để nuôi thì quét song song chẳng mua được gì. Toàn bộ plan sụp xuống tuần tự. Điều này đúng trên PostgreSQL 17.10, 18.4, và 19beta1 — partial aggregation vẫn không bao phủ các aggregate distinct và có thứ tự trên bất kỳ phiên bản nào.

debug_parallel_query xác nhận đây không phải là ước tính chi phí tình cờ ưu tiên thực thi tuần tự. Đặt thành on, nó ép một plan song song ở bất cứ đâu hợp lệ. Kết quả cho thấy một Gather với Workers Planned: 1Single Copy: true: một tiến trình chạy toàn bộ plan, gồm cả sort, và nút Gather chỉ tồn tại để đưa đầu ra của nó qua cơ chế song song của executor. Không có gì về aggregate, sort, hay scan thực sự được chia cho các worker.

Đáng chú ý, FILTER không gặp vấn đề này. count(*) FILTER (WHERE country='US') có hình dạng song song giống hệt count(*) thường, vì FILTER chỉ quyết định dòng nào mỗi worker gộp vào bộ đếm một phần của nó.

Một DISTINCT đầu độc cả câu lệnh

Chi phí không giới hạn ở aggregate distinct. Nó giới hạn ở nút aggregation dùng chung một query block với nó. Đặt một aggregate song song hóa hoàn hảo cạnh một aggregate distinct trong cùng một SELECTcả hai đều mất tính song song, vì một nút Aggregate tính cả hai và nó chỉ có thể chạy theo một cách. Một aggregate trong một subquery hoặc CTE riêng là một nút khác và không bị ảnh hưởng.

Ví dụ, SELECT sum(amount), count(DISTINCT user_id) FROM events;sum(amount) nếu đứng riêng sẽ chạy trên bốn worker. Dùng chung một SELECT với một count(DISTINCT) kéo nó xuống cùng một sort tuần tự.

Cách viết lại: đẩy DISTINCT vào một GROUP BY

Thực hiện loại trùng bằng một phép toán Postgres có thể song song hóa, một GROUP BY, rồi đếm các nhóm sau đó:

SELECT count(*) FROM (SELECT user_id FROM events GROUP BY user_id) s;

Điều này cho phép mỗi worker loại trùng phần của mình song song, rồi leader gộp các tập nhóm một phần lại. Đánh đổi là planner không phải lúc nào cũng chứng minh được cách viết lại là tương đương — đặc biệt khi cột distinct có thể null hoặc khi bạn cần đếm distinct theo từng nhóm — nên nó có thể không tự chọn plan này.

Trường hợp khó hơn là đếm distinct theo nhóm, như SELECT country, count(DISTINCT user_id) FROM events GROUP BY country;. Ở đây DISTINCT nằm trong một nhóm, và cùng một sort tuần tự áp dụng trong mỗi nhóm. Cách viết lại phức tạp hơn: bạn cần loại trùng các cặp (user_id, country) trước, rồi nhóm theo country. Đó là một truy vấn hai bước mà planner sẽ không tự suy ra cho bạn.

Khi nào thực sự cần quan tâm

Nếu bảng của bạn vừa bộ nhớ và truy vấn chạy trong mili giây, tất cả điều này chẳng quan trọng. Nhưng với các bảng phân tích lớn, một count(DISTINCT) tràn ra đĩa có thể chậm hơn một bậc độ lớn so với phương án song song. Trước khi với tới một COUNT(DISTINCT), hãy kiểm tra plan. Nếu bạn thấy một Sort tuần tự trên một bảng lớn, cân nhắc cách viết lại bằng GROUP BY — hoặc chuyển aggregate distinct vào subquery riêng để nó không đầu độc tính song song của phần còn lại trong truy vấn.

Một từ khóa DISTINCT tắt truy vấn song song cho toàn bộ câu lệnh, và bảng càng lớn thì chi phí càng cao.
Ban biên tập Manul X
Hành vi song song của các biến thể aggregate trong PostgreSQL
So sánh nhanh
Mẫu truy vấnWorker song songHình dạng plan
count(*)4 + leaderPartial Aggregate → Gather → Finalize Aggregate
count(DISTINCT col)0Sort tuần tự → Aggregate
count(*) FILTER (WHERE ...)4 + leaderPartial Aggregate → Gather → Finalize Aggregate
sum(col) + count(DISTINCT col)0Sort tuần tự → Aggregate (cả hai mất tính song song)
count(*) FROM (SELECT col FROM t GROUP BY col) s4 + leaderPartial Aggregate → Gather → Finalize Aggregate