GROUP BY là một trong những câu lệnh dễ viết nhất và khó đoán chi phí nhất. Phần này đo lượng bộ nhớ nó thật sự dùng, chuyện gì xảy ra khi không đủ, và tại sao cho thêm bộ nhớ lại gần như không giúp gì.

Số nhóm, work_mem, hai chiến lược gộp, và ảnh hưởng của chỉ mục

Bảng đo

5.000.000 dòng, 403 MB, hai cột gộp nhóm với độ phân tán rất khác nhau:

create table sk(id bigserial primary key, nhom int, nhom_to int, gt numeric(12,2), t text);
-- nhom    : 1.000.000 giá trị phân biệt
-- nhom_to :       100 giá trị phân biệt

work_mem để mặc định 4 MB.

Số nhóm quyết định tất cả

Truy vấn Thời gian Bộ nhớ Ghi đĩa Số lô
group by nhom_to (100 nhóm) 233,91 ms 80 kB 0 1
group by nhom (1.000.000 nhóm) 1.614,33 ms 8.209 kB 191.800 kB 65

Cùng một bảng, cùng số dòng đọc vào, chênh nhau gần 7 lần.

HashAggregate giữ một dòng cho mỗi nhóm trong bảng băm. Với 100 nhóm thì bảng băm chiếm 80 kB và mọi thứ nằm gọn trong bộ nhớ. Với một triệu nhóm, bảng băm không vừa 4 MB, nên PostgreSQL chia dữ liệu thành 65 lô và ghi phần chưa tới lượt ra đĩa tạm.

Điểm quan trọng: chi phí bộ nhớ của GROUP BY không phụ thuộc số dòng đầu vào, nó phụ thuộc số nhóm đầu ra. Gộp 100 triệu dòng thành 50 nhóm rẻ hơn gộp 5 triệu dòng thành một triệu nhóm.

Cho thêm bộ nhớ gần như không giúp gì

Đây là kết quả tôi không chờ đợi.

work_mem Thời gian Bộ nhớ đỉnh Ghi đĩa Số lô
4 MB 1.815 ms 8.209 kB 191.800 kB 65
16 MB 1.823 ms 32.913 kB 145.968 kB 17
64 MB 1.934 ms 131.121 kB 107.312 kB 5
128 MB 1.879 ms 262.193 kB 61.000 kB 5
256 MB 1.765 ms 434.193 kB 0 1

Gấp 64 lần work_mem, đổi lại nhanh hơn 3%. Và bảng băm chiếm 434 MB RAM thật cho một truy vấn.

Lý do là "ghi ra đĩa" ở đây gần như miễn phí: dữ liệu tạm vừa được ghi xong thì vẫn nằm trong bộ nhớ đệm của hệ điều hành, nên lần đọc lại không chạm đĩa vật lý. Cột Disk Usage trong EXPLAIN đo lượng dữ liệu đi qua tệp tạm, không đo số lần thật sự đọc từ đĩa quay.

Điều này lặp lại kết luận đã gặp ở phần 29 với Hash Join: nâng work_mem để tránh chia lô là lời khuyên phổ biến nhưng nó chỉ đáng khi máy chủ đang thật sự thiếu bộ nhớ đệm. Nâng mù quáng vừa tốn RAM vừa có rủi ro — work_memcho mỗi nút của mỗi truy vấn, nên 256 MB nhân với vài chục kết nối là đủ làm hết bộ nhớ máy chủ.

Sắp xếp thắng băm

PostgreSQL có hai chiến lược gộp nhóm: HashAggregate dựng bảng băm, GroupAggregate sắp xếp trước rồi gộp các dòng liền kề. Bộ tối ưu chọn HashAggregate ở đây. Ép chạy cả hai, lặp ba lần:

work_mem Chiến lược Ba lần đo
4 MB HashAggregate 1.575 / 1.550 / 1.564 ms
4 MB GroupAggregate 1.403 / 1.415 / 1.415 ms
256 MB HashAggregate 1.623 / 1.686 / 1.702 ms
256 MB GroupAggregate 1.334 / 1.356 / 1.359 ms

GroupAggregate nhanh hơn ở cả hai mức — 10% ở 4 MB và 20% ở 256 MB. Ba lần đo có độ lệch rất nhỏ nên đây không phải nhiễu.

HashAggregate chậm đi khi có thêm bộ nhớ: 1.550 ms ở 4 MB thành 1.686 ms ở 256 MB. Cấp phát và ghi vào một bảng băm 434 MB tốn hơn phần tiết kiệm được từ việc bỏ 65 lô.

Đây là lần thứ hai trong sê-ri này mô hình chi phí chọn không phải phương án nhanh nhất — lần đầu là Merge Join ở phần 29. Cả hai lần đều theo cùng một hướng: mô hình đánh giá thao tác tuần tự (đọc chỉ mục theo thứ tự, sắp xếp) đắt hơn thực tế trên phần cứng hiện đại.

Cách xử lý thực dụng: nếu bạn có một truy vấn báo cáo chạy hàng ngày và EXPLAIN ANALYZE cho thấy HashAggregate với nhiều lô, hãy thử set enable_hashagg = off và đo. Nếu nhanh hơn thì bạn vừa tìm được 20% miễn phí.

DISTINCTGROUP BY là cùng một thứ

Truy vấn Thời gian Bộ nhớ Ghi đĩa Số lô
select distinct nhom from sk 1.230 ms 10.385 kB 113.120 kB 81
select nhom from sk group by nhom 1.196 ms 10.385 kB 113.120 kB 81

Bộ nhớ và số lô trùng khít tới từng kilobyte. Hai cách viết, một kế hoạch. Chọn cái nào là chuyện đọc cho dễ hiểu, không phải chuyện hiệu năng.

Nhưng count(distinct) thì khác hẳn:

Truy vấn Thời gian Ghi đĩa
select count(distinct nhom) from sk 905 ms 0
select count(*) from (select nhom from sk group by nhom) t 1.322 ms 113.120 kB

Có một lời khuyên rất phổ biến: "count(distinct) chậm, hãy viết lại thành count(*) trên một truy vấn con GROUP BY". Trên phép đo này, làm theo lời khuyên đó chậm thêm 46%.

Lý do: count(distinct) không dùng được HashAggregate, nó sắp xếp bằng trộn ngoài. Và như phần trên đã đo, sắp xếp ở đây nhanh hơn băm. Lời khuyên kia sinh ra từ thời HashAggregate chưa biết chia lô — trước PostgreSQL 13, một GROUP BY vượt work_mem sẽ vẫn cố nhét vào bộ nhớ và làm máy chủ hết RAM. Bây giờ nó chia lô đàng hoàng, và tương quan đã đảo ngược.

Đây là dạng lời khuyên đáng ngờ nhất: đúng ở thời điểm nó được viết ra, sai bây giờ, và không có gì trong nó nói cho bạn biết nó phụ thuộc phiên bản.

Đòn bẩy mạnh nhất: chỉ mục

Trước Sau khi có chỉ mục trên nhom
group by nhom 1.196 ms 585 ms, bộ nhớ 0, đĩa 0
select distinct nhom 1.230 ms 465 ms, bộ nhớ 0, đĩa 0

Nhanh hơn hai lần, và quan trọng hơn: bộ nhớ về 0, ghi đĩa về 0.

Chỉ mục B-tree đã cho sẵn thứ tự, nên PostgreSQL chuyển sang GroupAggregate mà không cần bước sắp xếp nào. Nó chỉ việc đi dọc chỉ mục và gộp các khoá giống nhau liền kề.

So với việc nâng work_mem lên 256 MB để được 3%, một chỉ mục cho 2× tốc độ và bỏ hẳn nhu cầu bộ nhớ. Với truy vấn gộp nhóm chạy thường xuyên trên cùng một cột, chỉ mục gần như luôn là lựa chọn đúng.

Đổi lại là chi phí ghi — phần 19 đã đo: sáu chỉ mục thừa làm việc chèn chậm 4,6 lần. Nên chỉ dựng khi truy vấn gộp nhóm đó thật sự chạy thường xuyên.

DISTINCT ON đắt hơn vẻ ngoài

select distinct on (nhom_to) nhom_to, gt from sk order by nhom_to, gt desc;
--  3.434,96 ms,  sắp xếp trộn ngoài 102.696 kB

Chậm hơn group by nhom_to (234 ms) gần 15 lần, dù chỉ trả về 100 dòng.

DISTINCT ON buộc phải sắp xếp toàn bộ 5 triệu dòng theo (nhom_to, gt desc) để biết dòng nào đứng đầu mỗi nhóm. Nó không dùng được HashAggregate vì nó cần thứ tự.

Phần 31 đã đo cùng bài toán này bằng LATERAL và ra 78 ms. Quy tắc vẫn thế: khi số nhóm nhỏ và có chỉ mục phù hợp, LATERAL là lựa chọn nhanh nhất cho "lấy dòng đầu mỗi nhóm".

Bảng chọn nhanh

Tình huống Nên
Ít nhóm (dưới vài nghìn) GROUP BY bình thường, không cần lo gì
Rất nhiều nhóm, chạy thường xuyên Dựng chỉ mục trên cột gộp nhóm
Rất nhiều nhóm, chạy một lần Để nó chia lô, đừng nâng work_mem
EXPLAIN cho thấy nhiều lô và chậm Thử set enable_hashagg = off rồi đo
Đếm giá trị phân biệt count(distinct) — đừng viết lại thành truy vấn con
Lấy dòng đầu mỗi nhóm LATERAL nếu có chỉ mục, không thì DISTINCT ON

Thử ba mươi giây

explain (analyze, buffers) select <cột>, count(*) from <bảng> group by <cột>;

Ba thứ cần nhìn trong nút HashAggregate:

  1. Batches — lớn hơn 1 nghĩa là đang chia lô. Không phải vấn đề, nhưng đáng biết.
  2. Disk Usage — so với Peak Memory Usage. Nếu ghi đĩa gấp nhiều lần bộ nhớ, số nhóm đang rất lớn.
  3. Planned Partitions so với Batches thật — chênh nhiều nghĩa là ước lượng số nhóm sai, và CREATE STATISTICS (phần 20) có thể giúp.

Và luôn thử chỉ mục trước khi nghĩ tới work_mem: 2× tốc độ với 0 bộ nhớ so với 3% tốc độ với 434 MB.

Phần sau đo EXISTS, IN, NOT INANTI JOIN — bốn cách viết cùng một câu hỏi, và một cái bẫy NULL làm câu trả lời sai chứ không chỉ chậm.