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ì.
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_mem là cho 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.
Và 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í.
DISTINCT và GROUP 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:
Batches— lớn hơn 1 nghĩa là đang chia lô. Không phải vấn đề, nhưng đáng biết.Disk Usage— so vớiPeak Memory Usage. Nếu ghi đĩa gấp nhiều lần bộ nhớ, số nhóm đang rất lớn.Planned Partitionsso vớiBatchesthậ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 IN và ANTI 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.