Bài 4 dạy GROUP BY — gộp nhiều hàng thành 1 hàng duy
nhất mỗi nhóm, mất hết chi tiết từng hàng gốc. Window function
(hàm cửa sổ) giải quyết đúng nhược điểm đó: tính toán dựa trên 1 "cửa sổ" các hàng liên quan, nhưng
giữ nguyên số hàng gốc — mỗi hàng vẫn là chính nó, chỉ có thêm 1 cột tính toán dựa
trên các hàng xung quanh. Bài này cũng quay lại giải đúng bài toán self-join phức tạp ở
Bài 3 ("khoảng cách giữa 2 đơn hàng liên tiếp") bằng
LAG() — gọn hơn hẳn.
1. OVER (PARTITION BY): Tính Toán Trên Cửa Sổ Mà Không Gộp Hàng
Cú pháp cơ bản: hàm_cửa_sổ() OVER (PARTITION BY cột_chia_nhóm ORDER BY cột_sắp_xếp).
PARTITION BY chia dữ liệu thành các nhóm (giống GROUP BY về mặt khái niệm),
nhưng thay vì gộp mỗi nhóm thành 1 hàng, hàm cửa sổ tính toán
cho từng hàng riêng lẻ trong phạm vi nhóm của nó.
Tình huống 1 — Xếp hạng khách hàng theo chi tiêu, TRONG TỪNG quốc gia:
WITH customer_spend AS (
SELECT c.customer_id, c.full_name, c.country, COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.full_name, c.country
)
SELECT full_name, country, total_spent,
RANK() OVER (PARTITION BY country ORDER BY total_spent DESC) AS rank_in_country
FROM customer_spend
ORDER BY country, rank_in_country;
Kết quả giữ nguyên đủ 12 hàng (12 khách hàng) — khác hẳn
GROUP BY country sẽ chỉ còn 6 hàng (6 quốc gia). Việt Nam có 7 khách, xếp hạng rõ ràng từ
1 (Nguyễn Minh Anh, 554.93) tới 7 (Vũ Đức Thắng, 37.98); các quốc gia chỉ có
đúng 1 khách (Úc, Singapore, Hàn Quốc...) đều hiển thị hạng 1 một cách hiển nhiên. Đây chính là điểm
mạnh cốt lõi của PARTITION BY: xếp hạng trong phạm vi từng nhóm, không
phải xếp hạng toàn cục — khách Việt Nam hạng 7 vẫn có thể chi tiêu nhiều hơn khách hạng 1 ở quốc gia
khác, và điều đó hoàn toàn không ảnh hưởng tới kết quả.
Tình huống 2 — Xếp hạng sản phẩm theo doanh thu, TRONG TỪNG danh mục:
WITH product_rev AS (
SELECT p.product_id, p.product_name, p.category, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
INNER JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_id, p.product_name, p.category
)
SELECT product_name, category, revenue,
RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank_in_category
FROM product_rev
ORDER BY category, rank_in_category;
Standing Desk hạng 1 trong Office (699.98),
27-inch Monitor hạng 1 trong Electronics (749.97) — mỗi danh mục có bảng xếp
hạng riêng, tính song song trong cùng 1 câu lệnh duy nhất.
2. ROW_NUMBER, RANK, DENSE_RANK: 3 Cách Đánh Số Khác Nhau Khi
Có Đồng Hạng
Cả 3 hàm đều đánh số thứ tự theo ORDER BY trong OVER(...), nhưng xử lý
đồng hạng (tie) hoàn toàn khác nhau. Dataset TechMart có sẵn 3 cặp đơn hàng đồng giá
trị — tình huống hoàn hảo để thấy khác biệt:
SELECT order_id, total_amount,
ROW_NUMBER() OVER (ORDER BY total_amount DESC) AS rn,
RANK() OVER (ORDER BY total_amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY total_amount DESC) AS drnk
FROM orders
ORDER BY total_amount DESC
LIMIT 8;
| order_id | total_amount | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| 8 | 349.99 | 1 | 1 | 1 |
| 18 | 349.99 | 2 | 1 | 1 |
| 12 | 295.98 | 3 | 3 | 2 |
| 15 | 259.98 | 4 | 4 | 3 |
2 đơn 8 và 18 đồng giá 349.99: ROW_NUMBER() vẫn cố
ép mỗi hàng 1 số duy nhất (1, 2 — thứ tự giữa 2 hàng đồng hạng phụ thuộc thứ tự vật
lý, không đáng tin cậy nếu không có tiêu chí phá thế hoà rõ ràng). RANK() cho cả 2 cùng
hạng 1, rồi nhảy cóc sang hạng 3 cho đơn tiếp theo (bỏ qua hạng 2 —
vì đã có 2 hàng chiếm hạng 1). DENSE_RANK() cũng cho cả 2 cùng hạng 1, nhưng đơn tiếp
theo nhận hạng 2 — không nhảy cóc, không bỏ số nào.
ROW_NUMBER() khi cần đúng 1 số duy nhất mỗi hàng (ví dụ phân trang, hoặc chọn "1
hàng đại diện mỗi nhóm" bằng WHERE rn = 1). Dùng RANK() khi thứ hạng cần
phản ánh đúng "có bao nhiêu người đứng trước tôi" (giống bảng xếp hạng thể thao — 2 người đồng hạng
nhất, người thứ 3 vẫn được gọi là "hạng 3", không phải "hạng 2"). Dùng DENSE_RANK() khi
cần đếm "có bao nhiêu mức giá trị khác nhau" mà không quan tâm có bao nhiêu hàng ở mỗi mức.
3. LAG/LEAD: Truy Cập Hàng Trước/Sau Trong Cùng Partition
LAG(cột) lấy giá trị cột ở hàng trước hàng hiện tại (theo
ORDER BY trong OVER); LEAD(cột) lấy hàng sau.
Đây chính là công cụ giải gọn bài toán "khoảng cách giữa 2 đơn liên tiếp" mà
Bài 3 phải dùng self-join phức tạp để giải:
SELECT customer_id, order_id, order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_date,
julianday(order_date) - julianday(
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)
) AS days_since_prev
FROM orders
WHERE customer_id IS NOT NULL
ORDER BY customer_id, order_date;
So với self-join ở Bài 3 (orders o1 INNER JOIN orders o2 ON o1.customer_id = o2.customer_id AND o1.order_id <
o2.order_id, rồi phải tự suy luận cặp nào thực sự "liên tiếp"), LAG() giải quyết
trực tiếp và chính xác — không cần suy luận, không sinh ra các cặp không liên tiếp
cần lọc bỏ. Đơn đầu tiên mỗi khách hàng có prev_order_date IS NULL (không có hàng trước
trong partition của nó) — hành vi tương tự biên của mảng.
Tình huống 2 — Dùng LEAD để so sánh với đơn tiếp theo: viết lại cùng bài
toán theo hướng ngược — mỗi đơn kèm ngày của đơn sau nó:
SELECT customer_id, order_id, order_date,
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_date
FROM orders
WHERE customer_id IS NOT NULL
ORDER BY customer_id, order_date;
Kết quả tương đương LAG() ở trên nhưng nhìn từ hướng ngược lại — đơn
cuối cùng mỗi khách có next_order_date IS NULL thay vì đơn đầu tiên.
Chọn LAG hay LEAD tuỳ câu hỏi kinh doanh: "đơn này cách đơn trước bao lâu"
dùng LAG, "khách có quay lại mua tiếp không, bao lâu sau" dùng LEAD.
4. Running Total & Moving Average: Frame Cửa Sổ (ROWS BETWEEN)
Mặc định, OVER (ORDER BY ...) tính trên khung "từ đầu partition tới hàng hiện tại" — đây
chính là công thức running total (tổng luỹ kế). Khai báo rõ ràng bằng
ROWS BETWEEN giúp kiểm soát chính xác khung tính toán:
SELECT order_id, order_date, total_amount,
SUM(total_amount) OVER (
ORDER BY order_date, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY order_date, order_id;
UNBOUNDED PRECEDING nghĩa là "từ hàng đầu tiên của toàn bộ tập kết quả",
CURRENT ROW là điểm dừng — mỗi hàng cộng dồn thêm giá trị của chính nó vào tổng các hàng
trước. Đơn đầu tiên (2025-11-05) có running total = 56.97 (chính nó); tới đơn thứ 8
(2026-01-14, 349.99) running total đã lên 1272.89.
Tình huống 2 — Moving average (trung bình trượt) 3 đơn gần nhất: thay vì cộng dồn
toàn bộ lịch sử, chỉ tính trung bình N hàng gần nhất — làm mượt biến động ngắn hạn để
thấy xu hướng dài hạn rõ hơn:
SELECT order_id, order_date, total_amount,
ROUND(AVG(total_amount) OVER (
ORDER BY order_date, order_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3
FROM orders
ORDER BY order_date, order_id;
2 PRECEDING AND CURRENT ROW nghĩa là "2 hàng trước cộng với hàng hiện tại" — đúng 3 hàng
(trừ 2 hàng đầu tiên của toàn bộ tập, nơi chưa đủ 2 hàng trước, SQLite tự động chỉ tính trung bình
trên số hàng thực sự có sẵn thay vì báo lỗi).
ROWS vs RANGE — khác biệt tinh vi khi có giá trị trùng
ROWS BETWEEN đếm theo số hàng vật lý — "2 hàng trước" luôn nghĩa là
đúng 2 hàng, bất kể giá trị. RANGE BETWEEN (cũng hợp lệ trong OVER) đếm
theo giá trị logic của cột ORDER BY — nếu nhiều hàng có cùng giá trị
order_date (ví dụ 2 đơn cùng ngày), RANGE coi chúng là "cùng 1 điểm" và
gộp cả cụm vào cùng khung tính toán, trong khi ROWS vẫn đếm riêng từng hàng. Với dữ
liệu ít trùng lặp giá trị ORDER BY như ở đây, 2 loại cho kết quả giống nhau — nhưng khi
ORDER BY có nhiều giá trị trùng (ví dụ nhiều đơn cùng ngày), lựa chọn sai giữa
ROWS/RANGE có thể cho kết quả running total khác hẳn ý định.
Bảng So Sánh: Window Function vs GROUP BY
| Tiêu chí | GROUP BY (Bài 4) |
Window Function (OVER) |
|---|---|---|
| Số hàng kết quả | 1 hàng/nhóm — gộp lại, mất chi tiết hàng gốc | Giữ nguyên số hàng gốc — mỗi hàng vẫn là chính nó |
| Có thể trộn cột thô + cột tổng hợp? | Không — chỉ cột nhóm hoặc hàm tổng hợp (xem cạm bẫy Bài 4) | Có — mọi cột gốc vẫn hiển thị song song cột window function |
| Lọc kết quả bằng ngưỡng tính toán | HAVING |
Không dùng WHERE/HAVING trực tiếp — phải bọc trong subquery/CTE rồi
lọc ở lớp ngoài
|
| Truy cập hàng trước/sau | Không thể (không có khái niệm thứ tự giữa các nhóm) | Có — LAG/LEAD |
Sân chơi tương tác: SQL Workbench Trên Dataset TechMart
Thử đổi PARTITION BY sang cột khác, hoặc đổi khung ROWS BETWEEN để thấy
running total/moving average thay đổi ngay lập tức.
customers
| Cột | Kiểu |
|---|---|
| customer_id | INTEGER PRIMARY KEY |
| full_name | TEXT NOT NULL |
| TEXT | |
| country | TEXT |
| signup_date | TEXT |
| is_active | INTEGER |
products
| Cột | Kiểu |
|---|---|
| product_id | INTEGER PRIMARY KEY |
| product_name | TEXT NOT NULL |
| category | TEXT |
| unit_price | REAL |
| stock_quantity | INTEGER |
orders
| Cột | Kiểu |
|---|---|
| order_id | INTEGER PRIMARY KEY |
| customer_id | INTEGER (NULL = khách vãng lai) |
| order_date | TEXT |
| status | TEXT |
| total_amount | REAL |
order_items
| Cột | Kiểu |
|---|---|
| order_item_id | INTEGER PRIMARY KEY |
| order_id | INTEGER |
| product_id | INTEGER |
| quantity | INTEGER |
| unit_price | REAL |
Đang tải...
⬇ Tải file schema + dữ liệu mẫu TechMart (.sql) — chạy
được trực tiếp bằng sqlite3 CLI hoặc DB Browser for SQLite trên máy bạn.
Trắc nghiệm ôn tập
Câu 1: Khác biệt cốt lõi giữa GROUP BY và window function (OVER) là gì?
Trắc nghiệm ôn tập
Câu 2: Với 2 đơn hàng đồng giá trị cao nhất, đơn tiếp theo (đứng thứ 3 khi sắp theo giá trị) sẽ nhận
số nào từ RANK()?
Trắc nghiệm ôn tập
Câu 3: Vì sao LAG() giải bài toán "khoảng cách giữa 2 đơn liên tiếp của cùng 1 khách"
(Bài 3) tốt hơn hẳn self-join?
Trắc nghiệm ôn tập
Câu 4: Trong truy vấn running total
SUM(total_amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW), UNBOUNDED PRECEDING có nghĩa là gì?
Trắc nghiệm ôn tập
Câu 5: Muốn lọc kết quả theo giá trị 1 window function (ví dụ "chỉ giữ hàng có
rank_in_country <= 3"), cách nào đúng?