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.
flowchart LR G1["Nhom project 1\n(nhieu dong tasks)"] --> A1["COUNT(*) = 32\nSUM(hours) = 128\nAVG = 4.0"] G2["Nhom project 2\n(nhieu dong tasks)"] --> A2["COUNT(*) = 18\nSUM(hours) = 54\nAVG = 3.0"] G3["Nhom project 3\n(nhieu dong tasks)"] --> A3["COUNT(*) = 8\nSUM(hours) = 16\nAVG = 2.0"]
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:
STRING_AGG(col, sep)— PostgreSQL, SQL Server 2017+, SQLite 3.44+. MySQL dùngGROUP_CONCAT(col SEPARATOR ', '). Oracle 19c+ dùngLISTAGG(col, ', ') WITHIN GROUP (ORDER BY col).ARRAY_AGG— PostgreSQL-specific (trả về array native). Các engine khác không có tương đương trực tiếp.JSON_AGG— PostgreSQL. MySQL 5.7+ cóJSON_ARRAYAGG. SQL Server cóFOR JSON AUTO. Oracle 12c+ cóJSON_ARRAYAGG.- PostgreSQL còn có
JSONB_AGG(binary JSON, indexable) — chỉ PostgreSQL. Module 9 của khoá này đề cập khi nói về JSON storage.
STRING_AGG là cách portable nhất vì có mặt trên nhiều engine. Khi cần cross-vendor, ưu tiên STRING_AGG; khi locked-in PostgreSQL thì ARRAY_AGG/JSON_AGG tiện hơn cho downstream xử lý.
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
-- Ket qua kieu du lieu phu thuoc engine:
-- PostgreSQL: AVG(int) tra ve numeric (floating point)
-- MySQL: AVG(int) tra ve double
-- SQL Server: AVG(int) tra ve int (truncated!) -- nguy hiem nhat
SELECT AVG(score) FROM ratings;
-- An toan: luon ROUND() hoac cast explicit khi dung AVG
-- PostgreSQL: ROUND(AVG(score), 2)
-- SQL Server: ROUND(AVG(CAST(score AS FLOAT)), 2)
-- 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 — thuật toán xấp xỉ cardinality với sai số dưới 1%, dùng constant memory per group thay vì O(N). Mỗi engine có cách hỗ trợ khác nhau: PostgreSQL qua extension pg_hll; ClickHouse có uniq(); BigQuery có APPROX_COUNT_DISTINCT() chuẩn. Module 9 của khoá này đề cập khi nói về approximate query.
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 < CURRENT_TIMESTAMP
AND t.status != 'done') AS overdue,
-- CURRENT_TIMESTAMP: ANSI SQL; PG also accepts now()
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
-- STRING_AGG: PG/SQL Server 2017+/SQLite 3.44+; MySQL fallback: GROUP_CONCAT(DISTINCT u.name ORDER BY u.name SEPARATOR ', ')
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
- Modern SQL — FILTER clause — Markus Winand giải thích cross-vendor support cho FILTER, so sánh với CASE WHEN workaround, và use case thực tế. Agnostic, áp dụng mọi RDBMS.
- SQL Standard ISO/IEC 9075 — Aggregate Functions — định nghĩa chuẩn ANSI SQL cho COUNT/SUM/AVG/MIN/MAX, FILTER clause, ALL/DISTINCT trong aggregate, và WITHIN GROUP.
- Use The Index, Luke — "Aggregate Functions" — ảnh hưởng của aggregate lên index usage và query plan; agnostic về engine.
Ghi chú: Modern SQL cho intuition cross-vendor về FILTER và aggregate; ISO spec cho nguồn gốc chuẩn; Use The Index Luke cho performance implications.
Liên kết khoá học khác
- Java — 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 (PostgreSQL; các engine khác có tương đương riêng).- Cross-vendor:
STRING_AGG(PG/SQL Server/SQLite) vsGROUP_CONCAT(MySQL) vsLISTAGG(Oracle); FILTER (ANSI SQL, PG/SQLite 3.30+), MySQL fallbackSUM(CASE WHEN ...). - Forward: Module 9 của khoá này (JSON storage & aggregate), Module 8 của khoá này (query execution — 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
Thuật toán xấp xỉ cardinality với sai số thường dưới 1%, dùng constant memory per group thay vì O(N). Mỗi engine hỗ trợ khác nhau: PostgreSQL qua extension pg_hll; BigQuery có APPROX_COUNT_DISTINCT(); ClickHouse có uniq(). Ưu: cực nhanh trên large dataset. 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. 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?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên