Aggregate functions — COUNT/SUM + FILTER + STRING_AGG/JSON_AGG
5 aggregate cơ bản + 3 advanced (STRING_AGG/ARRAY_AGG/JSON_AGG). FILTER clause thay 5 query CASE WHEN bằng 1 line. Pitfall AVG int → numeric, SUM empty → NULL.
Dashboard TaskFlow cần report theo project: tổng số task, task đã xong, task trễ deadline, số ngày hoàn thành trung bình, và danh sách người được giao đang active. Cách đơn giản nhất: 5 query riêng, mỗi query GROUP BY project. Kết quả không atomic — snapshot mỗi query tại một thời điểm khác nhau, có thể inconsistent nếu data thay đổi giữa chừng. Và 5 round-trip database thay vì 1.
Bài này map 5 aggregate cốt lõi (COUNT, SUM, AVG, MIN, MAX), 3 advanced aggregate (STRING_AGG, ARRAY_AGG, JSON_AGG), và FILTER clause — cho phép thu gọn 5 query CASE WHEN thành 1 line duy nhất với atomic snapshot.
1. Analogy — Báo cáo Excel per region
Hãy hình dung aggregate như bảng báo cáo Excel pivot: bạn có danh sách đơn hàng từng vùng, và pivot table tóm tắt per region thành tổng doanh thu, số đơn, và list tên khách. Mỗi region từ nhiều row trở thành một dòng tổng kết.
SQL aggregate function làm đúng việc đó — nhận một group (nhiều row) và trả về một giá trị duy nhất đại diện cho cả nhóm.
| Pivot Excel | SQL aggregate | Kết quả |
|---|---|---|
| Đếm số đơn | COUNT(*) | Số row trong group |
| Tổng doanh thu | SUM(amount) | Tổng numeric |
| Doanh thu trung bình | AVG(amount) | Trung bình numeric |
| Đơn cũ nhất | MIN(created_at) | Giá trị nhỏ nhất |
| Đơn mới nhất | MAX(created_at) | Giá trị lớn nhất |
Aggregate function nhận nhiều row, trả về 1 giá trị. Khi có GROUP BY, mỗi group chạy aggregate riêng. Không có GROUP BY, toàn bộ bảng là 1 group duy nhất.
2. 5 core aggregate — COUNT, SUM, AVG, MIN, MAX
SELECT
status,
COUNT(*) AS row_count, -- dem moi row ke ca NULL
COUNT(assignee_id) AS assigned_count, -- skip NULL
COUNT(DISTINCT assignee_id) AS unique_assignees, -- distinct value only
SUM(EXTRACT(DAY FROM now() - created_at)) AS sum_age_days,
AVG(EXTRACT(DAY FROM now() - created_at)) AS avg_age_days,
MIN(created_at) AS oldest,
MAX(created_at) AS newest
FROM tasks
GROUP BY status;
Các quirk quan trọng:
COUNT(*)đếm mọi row kể cả NULL.COUNT(col)bỏ qua row cócol IS NULL— hai giá trị này khác nhau khi có NULL trong column.COUNT(DISTINCT col)đếm giá trị phân biệt. Bài học này sẽ đề cập pitfall performance ở section 6.AVG(int_col)trong PostgreSQL trả về kiểunumeric, không phảiint— bài này giải thích pitfall ở section 5.SUMtrên empty group trả vềNULLkhông phải0— cũng là pitfall section 5.
3. STRING_AGG / ARRAY_AGG / JSON_AGG — gom rows thành single value
Ba aggregate này không trả về số — chúng gom nhiều row thành một string, array, hoặc JSON array. Đặc biệt hữu ích khi cần trả về parent kèm danh sách children trong một row duy nhất (REST API single-call pattern).
-- STRING_AGG: concat row trong group voi separator
SELECT
project_id,
STRING_AGG(title, ' | ' ORDER BY created_at) AS task_titles
FROM tasks
GROUP BY project_id;
-- ARRAY_AGG: gom row thanh PostgreSQL array
SELECT
project_id,
ARRAY_AGG(id ORDER BY created_at) AS task_ids
FROM tasks
GROUP BY project_id;
-- JSON_AGG: gom row thanh JSON array (huu ich tra API)
SELECT
project_id,
JSON_AGG(
JSON_BUILD_OBJECT('id', id, 'title', title)
ORDER BY created_at
) AS tasks_json
FROM tasks
GROUP BY project_id;
JSON_AGG kết hợp JSON_BUILD_OBJECT trả về một JSON array of objects — đúng định dạng mà nhiều REST API trả về. Thay vì query tasks riêng rồi join trong application code, một query duy nhất trả về project kèm toàn bộ tasks dạng JSON.
Cross-vendor:
- MySQL dùng
GROUP_CONCAT(col SEPARATOR ', ')thaySTRING_AGG. ARRAY_AGGvàJSON_AGGlà PostgreSQL-specific. MySQL 5.7+ cóJSON_ARRAYAGGtương đươngJSON_AGG.- PostgreSQL còn có
JSONB_AGG(binary JSON, indexable) — Module 9 của khoá này đi sâu về JSONB.
4. FILTER clause — conditional aggregate
FILTER là cú pháp ANSI SQL chuẩn, PostgreSQL hỗ trợ đầy đủ. Thay vì viết 5 query riêng hay 5 CASE WHEN, một query với nhiều FILTER clause làm tất cả trong một lần scan.
-- WITHOUT FILTER (verbose, kho doc)
SELECT
project_id,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done_count,
SUM(CASE WHEN status = 'doing' THEN 1 ELSE 0 END) AS doing_count,
SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) AS todo_count
FROM tasks
GROUP BY project_id;
-- WITH FILTER (gon, de doc, same performance)
SELECT
project_id,
COUNT(*) FILTER (WHERE status = 'done') AS done_count,
COUNT(*) FILTER (WHERE status = 'doing') AS doing_count,
COUNT(*) FILTER (WHERE status = 'todo') AS todo_count
FROM tasks
GROUP BY project_id;
FILTER hoạt động với mọi aggregate, không chỉ COUNT:
SELECT
region,
SUM(amount) FILTER (WHERE category = 'hardware') AS hardware_revenue,
AVG(rating) FILTER (WHERE created_at > now() - INTERVAL '30 days') AS recent_avg_rating,
STRING_AGG(tag, ', ' ORDER BY tag)
FILTER (WHERE tag IS NOT NULL) AS tags
FROM orders
GROUP BY region;
Cross-vendor: PostgreSQL hỗ trợ FILTER. SQLite từ phiên bản 3.30+. MySQL không có FILTER native — dùng SUM(CASE WHEN ...) làm fallback.
5. Pitfall — AVG integer truncation + SUM empty trả NULL
-- PITFALL 1: AVG column integer
-- PostgreSQL: AVG(int) tra ve numeric, KHONG phai int
SELECT AVG(score) FROM ratings;
-- Ket qua: 4.333333333333333... (numeric)
-- Neu can round: ROUND(AVG(score)::numeric, 2) hoac ROUND(AVG(score), 2)
-- MySQL: ket qua phu thuoc column type va version
-- An toan: luon ROUND() hoac cast explicit khi dung AVG
-- PITFALL 2: SUM tren empty group tra NULL khong phai 0
SELECT SUM(amount) FROM payments WHERE user_id = 9999;
-- User khong co payment nao -> ket qua: NULL
-- WRONG assumption: "0 payment = 0 tong" -- SQL khong suy luan nhu vay
-- Fix: COALESCE wrap SUM
SELECT COALESCE(SUM(amount), 0) AS total_paid
FROM payments
WHERE user_id = 9999;
SQL không tự suy ra "không có row" nghĩa là "tổng bằng 0". SUM trên tập rỗng trả về NULL — phản ánh ngữ nghĩa "không có dữ liệu để tổng hợp". Nếu logic cần 0 thay NULL, luôn wrap: COALESCE(SUM(col), 0). Tương tự với AVG, MAX, MIN trên empty set. COUNT(*) là ngoại lệ duy nhất — trả về 0 khi không có row.
6. Pitfall — COUNT DISTINCT chậm trên large group
-- CHAM tren large table (ví du 10M row)
SELECT project_id, COUNT(DISTINCT assignee_id)
FROM tasks
GROUP BY project_id;
-- Internal: moi group build hash set cua assignee_id roi dem
-- O(N log N) hoac O(N) per group tuy implementation
-- Alternative khi cardinality thap: subquery 2 stage
-- Distinct truoc, COUNT sau -- planner co them lua chon plan
SELECT project_id, COUNT(*) AS unique_assignees
FROM (
SELECT DISTINCT project_id, assignee_id FROM tasks
) sub
GROUP BY project_id;
Với cardinality rất cao (ví dụ đếm distinct user_id trên bảng hàng triệu row), có thể dùng HyperLogLog approximate aggregate — extension postgresql-hll. Module 9 của khoá này đề cập khi nói về approximate query và advanced indexing.
7. Applied — TaskFlow project analytics một query
-- Per project: total + done + active + overdue + avg completion days + active assignees
SELECT
p.id,
p.name,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE t.status = 'done') AS done,
COUNT(*) FILTER (WHERE t.status IN ('todo','doing')) AS active,
COUNT(*) FILTER (WHERE t.due_at < now()
AND t.status != 'done') AS overdue,
ROUND(
AVG(EXTRACT(DAY FROM (t.updated_at - t.created_at)))
FILTER (WHERE t.status = 'done'),
1
) AS avg_complete_days,
STRING_AGG(DISTINCT u.name, ', ' ORDER BY u.name)
FILTER (WHERE t.status IN ('todo','doing')) AS active_assignees
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN users u ON t.assignee_id = u.id
GROUP BY p.id, p.name
ORDER BY p.name;
id | name | total | done | active | overdue | avg_complete_days | active_assignees
---+-----------+-------+------+--------+---------+-------------------+------------------
1 | OLHub | 32 | 28 | 4 | 1 | 5.2 | An, Binh, Cuong
2 | Marketing | 18 | 12 | 6 | 0 | 3.8 | Binh, Em
Một query thay 5 query — một lần scan table, một atomic snapshot tại cùng một thời điểm. Module 7 của khoá này (query planner) giải thích cách planner chọn HashAggregate vs SortAggregate cho GROUP BY và tại sao LEFT JOIN + aggregate ảnh hưởng plan.
8. Deep Dive — Aggregate functions
- PostgreSQL Documentation 9.21 "Aggregate Functions" — danh sách đầy đủ mọi aggregate PG hỗ trợ, FILTER syntax, ORDER BY bên trong aggregate, và WITHIN GROUP cho ordered-set aggregate (PERCENTILE_CONT, MODE).
- PostgreSQL Documentation 4.2.7 "Aggregate Expressions" — formal grammar, ALL/DISTINCT trong aggregate, WITHIN GROUP clause, và filter_clause spec.
- Modern SQL — FILTER clause — Markus Winand giải thích cross-vendor support, so sánh với CASE WHEN workaround, và use case thực tế. Đọc miễn phí trên web.
Ghi chú: PG docs cho catalog chính xác và ngữ nghĩa chuẩn; Modern SQL cho intuition về FILTER và context cross-vendor.
Liên kết khoá học khác
- Java — bài 9.3 map / filter / reduce —
Stream.reduce()vàCollectors.summarizing*()tương ứng SQL aggregate, khi nào nên xử lý ở app vs DB.
9. Tóm tắt
- 5 core:
COUNT/SUM/AVG/MIN/MAX— aggregate nhận group, trả 1 giá trị. - 3 advanced:
STRING_AGG(concat với separator),ARRAY_AGG(PG array),JSON_AGG(JSON array) — gom rows thành single structured value. - FILTER clause:
COUNT(*) FILTER (WHERE ...)— ANSI SQL chuẩn, thay thếSUM(CASE WHEN ...)bằng cú pháp gọn hơn, cùng hiệu năng. - Pitfall:
AVG(int)trảnumerickhông phảiint— cast hoặc ROUND explicit khi cần.SUMempty group trảNULLkhông phải0— wrapCOALESCE(SUM(col), 0). COUNT(DISTINCT col)chậm trên large group — subquery 2 stage hoặc HyperLogLog approximate là alternative.JSON_AGGkết hợpJSON_BUILD_OBJECTcho REST API single-call pattern — một query trả parent kèm children dạng JSON.- Cross-vendor:
STRING_AGG(PG) vsGROUP_CONCAT(MySQL); FILTER (PG/SQLite 3.30+), MySQL fallbackSUM(CASE WHEN ...). - Forward: Module 9 của khoá này (JSONB deep + JSON aggregate), Module 7 của khoá này (planner HashAggregate vs SortAggregate).
10. Tự kiểm tra
Q1FILTER clause cleaner hơn SUM(CASE WHEN ...) về mặt readability. Còn về performance — hai cách có khác nhau không? Tại sao?▸
Về performance, hai cách thực chất tương đương. PostgreSQL planner compile cả COUNT(*) FILTER (WHERE status = 'done') và SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) thành cùng một execution plan — một lần scan, evaluate predicate per row, cộng dồn.
Sự khác biệt là readability và intent clarity: FILTER tách biệt rõ điều kiện khỏi phép tính aggregate, trong khi CASE WHEN trộn lẫn logic điều kiện vào biểu thức tổng hợp. Khi có 5 conditional aggregate song song, FILTER làm code gọn hơn đáng kể và ít lỗi đánh máy hơn.
FILTER còn hoạt động tự nhiên với mọi aggregate — AVG(...) FILTER (...), STRING_AGG(...) FILTER (...) — trong khi CASE WHEN phải được biến thể khác nhau cho từng loại aggregate.
Q2Phân biệt COUNT(*), COUNT(col), COUNT(DISTINCT col). Cho ví dụ TaskFlow cụ thể cho mỗi trường hợp — khi nào ba cái này cho kết quả khác nhau?▸
COUNT(*) — đếm mọi row trong group, kể cả NULL ở bất kỳ column nào. Ví dụ: COUNT(*) trên bảng tasks của một project đếm tổng số task bao gồm task chưa được giao (assignee_id IS NULL).
COUNT(assignee_id) — đếm row có assignee_id IS NOT NULL, bỏ qua NULL. Ví dụ: đếm task đã được giao người — task chưa có assignee không được tính.
COUNT(DISTINCT assignee_id) — đếm số giá trị phân biệt khác NULL. Ví dụ: đếm bao nhiêu người khác nhau đang có task trong project — An được giao 5 task chỉ đếm là 1.
Ba cái cho kết quả khác nhau khi: có row với assignee_id IS NULL (COUNT(*) khác COUNT(col)), hoặc một người được giao nhiều task (COUNT(col) khác COUNT(DISTINCT col)).
Q3SUM(amount) trả NULL khi không có row nào khớp WHERE. Vì sao SQL thiết kế như vậy thay vì trả 0? Có 2 cách handle — nêu cả hai.▸
SQL thiết kế aggregate trên tập rỗng trả NULL vì NULL có ngữ nghĩa "không có thông tin" — khác với "tổng bằng 0". Nếu user_id 9999 không có payment nào, không thể biết tổng payment của họ là 0 hay là "chưa có dữ liệu". NULL phản ánh trung thực trạng thái "không có gì để tính". Trả 0 sẽ đánh đồng "không có payment" với "có payment nhưng tổng bằng 0" — hai trường hợp có ý nghĩa khác nhau.
Cách 1: COALESCE — đơn giản nhất, đổi NULL thành 0 tại query level:
SELECT COALESCE(SUM(amount), 0) AS total FROM payments WHERE user_id = 9999;Cách 2: Subquery hoặc LEFT JOIN với default — giữ nguyên NULL trong aggregate, handle ở application layer hoặc dùng COALESCE ở tầng SELECT bên ngoài. Hữu ích khi cần phân biệt "tổng 0" và "không có dữ liệu" trong business logic.
Q4JSON_AGG vs ARRAY_AGG vs STRING_AGG — khi nào dùng cái nào? Decision criteria.▸
STRING_AGG(col, sep) — khi consumer là người đọc hoặc hệ thống cần plain text. Ví dụ: hiển thị danh sách tag 'sql, postgres, aggregate', ghi CSV, hoặc log. Output là string đơn giản, không structured.
ARRAY_AGG(col) — khi consumer là PostgreSQL (array có thể dùng tiếp trong query với ANY, unnest, array indexing), hoặc khi cần danh sách ID để xử lý phía application dạng native array. PostgreSQL-specific.
JSON_AGG(JSON_BUILD_OBJECT(...)) — khi consumer là REST API hoặc frontend cần structured data. Output là JSON array of objects, deserialize trực tiếp ở client. Thay thế được N+1 query pattern: lấy parents rồi per-parent query children.
Decision criteria: cần structured object → JSON_AGG; cần PostgreSQL array → ARRAY_AGG; cần string thuần → STRING_AGG; cần portable cross-vendor → STRING_AGG (GROUP_CONCAT fallback MySQL).
Q5COUNT(DISTINCT user_id) chậm trên bảng 10M row. Nêu 2 alternative và tradeoff của mỗi cách.▸
Alternative 1: Subquery 2 stage
SELECT project_id, COUNT(*) AS unique_users
FROM (
SELECT DISTINCT project_id, user_id FROM tasks
) sub
GROUP BY project_id;Ưu: kết quả chính xác 100%, không cần extension. Planner có thêm lựa chọn plan — đôi khi HashAggregate trên subquery nhanh hơn COUNT DISTINCT trực tiếp. Nhược: vẫn O(N) scan, lợi ích phụ thuộc planner version và data distribution. Không phải luôn nhanh hơn.
Alternative 2: HyperLogLog approximate (postgresql-hll extension)
-- Cai extension truoc: CREATE EXTENSION hll;
SELECT project_id, hll_cardinality(hll_add_agg(hll_hash_integer(user_id)))
FROM tasks
GROUP BY project_id;Ưu: O(N) với constant memory per group, cực nhanh trên large dataset, sai số thường dưới 1%. Nhược: kết quả xấp xỉ không chính xác tuyệt đối — không dùng cho billing, audit, hoặc bất kỳ use case cần exact count. Cần cài extension. Module 9 của khoá này đề cập chi tiết hơn.
Bài tiếp theo: Window functions intro — OVER + PARTITION BY
Bài này có giúp bạn hiểu bản chất không?