Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng sếp bạn — Giám đốc Tăng trưởng của một sàn thương mại điện tử như Tiki — bước vào và hỏi: "Cho anh xem 3 sản phẩm bán chạy nhất trong mỗi ngành hàng tháng vừa rồi." Nghe đơn giản, nhưng nếu bạn chỉ biết GROUP BY và ORDER BY thì câu hỏi này khiến bạn khá lúng túng. GROUP BY sẽ gom mỗi ngành hàng thành một dòng — bạn mất luôn chi tiết từng sản phẩm. ORDER BY ... LIMIT 3 chỉ cho ra top 3 của toàn bộ bảng, không phải top 3 trong từng nhóm.
Đây chính là khoảnh khắc mà một BA (Business Analyst) "lên trình". Câu trả lời nằm ở window functions — nhóm hàm cực mạnh mà rất nhiều người làm dữ liệu né tránh vì nghĩ rằng nó "khó". Thực ra, một khi hiểu đúng tư duy, bạn sẽ thấy nó còn dễ hơn viết subquery lồng nhau ba bốn tầng.
Trong bài này, chúng ta tập trung vào ba hàm xếp hạng (ranking) nền tảng nhất: ROW_NUMBER, RANK, và DENSE_RANK. Đây là cánh cửa vào toàn bộ thế giới window functions mà bạn sẽ khai thác sâu hơn ở các bài sau (LAG/LEAD, running total, frame nâng cao). Nắm chắc ba hàm này, bạn sẽ giải được hàng loạt bài toán "top N theo nhóm", "khử trùng lặp", và "xếp hạng" mà trước đây phải vật lộn.
Khái niệm cốt lõi
Window function là gì?
Window function tính một giá trị cho mỗi dòng (row), dựa trên một "cửa sổ" (window) gồm các dòng có liên quan đến dòng đó. Điểm mấu chốt — và cũng là điều khiến nó khác hẳn GROUP BY:
> Window function KHÔNG gom nhóm các dòng lại. Bảng kết quả vẫn giữ nguyên số dòng như ban đầu; mỗi dòng chỉ được thêm một cột giá trị tính toán.
So sánh nhanh để bạn thấy rõ:
GROUP BY: 1000 dòng giao dịch → gom thành 5 dòng (mỗi ngành hàng một dòng tổng). Mất chi tiết.- Window function: 1000 dòng giao dịch → vẫn 1000 dòng, nhưng mỗi dòng có thêm cột "thứ hạng trong ngành hàng của nó". Giữ chi tiết.
Cú pháp chung
Mọi window function đều có cấu trúc giống nhau:
ten_ham() OVER (
PARTITION BY cot_chia_nhom
ORDER BY cot_sap_xep
)
Mệnh đề OVER (...) chính là thứ định nghĩa "cửa sổ". Bên trong:
PARTITION BY— chia dữ liệu thành các phân vùng (giống nhưGROUP BYnhưng không gộp dòng). Hàm sẽ tính lại từ đầu cho mỗi phân vùng. Nếu bỏ qua, toàn bảng được coi là một phân vùng duy nhất.ORDER BY— sắp xếp các dòng bên trong mỗi phân vùng. Với hàm ranking, đây là yếu tố quyết định ai hạng 1, ai hạng 2.
Ba hàm ranking và sự khác biệt
Cả ba đều gán "thứ hạng" cho từng dòng theo thứ tự ORDER BY, nhưng cách xử lý giá trị bằng nhau (ties) khác nhau — và đây là điểm hay bị sai nhất:
ROW_NUMBER()— Đánh số thứ tự liên tục 1, 2, 3, 4... Không bao giờ trùng. Nếu hai dòng có giá trị bằng nhau, nó vẫn gán hai số khác nhau một cách "tùy ý" (theo thứ tự nội bộ). Dùng khi bạn cần một số định danh duy nhất cho mỗi dòng.
RANK()— Khi có ties, các dòng bằng nhau nhận cùng thứ hạng, sau đó nhảy cóc (skip) số tiếp theo. Ví dụ: nếu hai người cùng hạng 1, người tiếp theo là hạng 3 (không có hạng 2). Giống thứ hạng trong thể thao: hai người đồng giải Nhất thì không có giải Nhì.
DENSE_RANK()— Khi có ties, các dòng bằng nhau nhận cùng thứ hạng, nhưng không nhảy cóc. Hai người cùng hạng 1 thì người tiếp theo là hạng 2. "Dense" = đặc, không có khoảng trống.
| Điểm | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 90 | 3 | 3 | 2 |
| 80 | 4 | 4 | 3 |
RANK cho hạng 3 (vì đã có 2 người ở trên), còn DENSE_RANK cho hạng 2 (vì chỉ có 1 mức điểm ở trên). Sự khác biệt này quyết định kết quả phân tích của bạn đúng hay sai.Tình huống thực tế
Tình huống 1: Top 3 sản phẩm mỗi ngành hàng tại Tiki
Quay lại câu hỏi của sếp ở đầu bài. Giả sử Tiki có bảng monthly_product_sales:
| product_id | category | revenue |
|---|---|---|
| 101 | Điện thoại | 2,400,000,000 |
| 102 | Điện thoại | 1,800,000,000 |
| 103 | Sách | 320,000,000 |
| ... | ... | ... |
SELECT category, product_id, revenue, rn
FROM (
SELECT
category,
product_id,
revenue,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS rn
FROM monthly_product_sales
) ranked
WHERE rn <= 3;
Diễn giải: PARTITION BY category chia dữ liệu thành từng ngành hàng. Trong mỗi ngành, ORDER BY revenue DESC xếp sản phẩm doanh thu cao lên trước, rồi ROW_NUMBER() gán 1, 2, 3, 4... Cuối cùng ta lọc rn <= 3.
Lưu ý quan trọng: không thể viết WHERE rn <= 3 trực tiếp ở câu truy vấn ngoài cùng cùng cấp với hàm window — phải bọc trong subquery (hoặc CTE — bạn sẽ học ở bài 16). Lý do: SQL xử lý WHERE trước khi tính window function, nên lúc lọc, cột rn chưa tồn tại.
Bài học rút ra: "Top N theo nhóm" gần như luôn là ROW_NUMBER() + PARTITION BY + lọc trong subquery. Đây là pattern bạn sẽ dùng đi dùng lại suốt sự nghiệp.
Tình huống 2: Bảng xếp hạng tài xế của Be — chọn RANK hay DENSE_RANK?
Một ứng dụng gọi xe như Be muốn vinh danh tài xế theo số chuyến hoàn thành trong tuần. Giả sử top tài xế ở khu vực TP.HCM có số chuyến: 88, 88, 85, 85, 80.
Nếu phòng marketing muốn làm bảng "Hạng 1 – Hạng 2 – Hạng 3" để trao thưởng, họ phải chọn đúng hàm:
SELECT
driver_name,
trips,
RANK() OVER (ORDER BY trips DESC) AS rank_thethao,
DENSE_RANK() OVER (ORDER BY trips DESC) AS rank_dac
FROM weekly_driver_trips
WHERE region = 'HCM';
Kết quả:
| Tài xế | Chuyến | rank_thethao (RANK) | rank_dac (DENSE_RANK) |
|---|---|---|---|
| Tuấn | 88 | 1 | 1 |
| Hùng | 88 | 1 | 1 |
| Minh | 85 | 3 | 2 |
| Lan | 85 | 3 | 2 |
| Phúc | 80 | 5 | 3 |
DENSE_RANK() <= 3 — sẽ bao gồm cả Phúc. Nhưng nếu quy định "trao cho những người trong top 3 vị trí" thì dùng RANK() <= 3 — Phúc (hạng 5) bị loại.Bài học rút ra: chọn RANK hay DENSE_RANK không phải chuyện kỹ thuật, mà là chuyện hiểu đúng định nghĩa nghiệp vụ. Một BA giỏi luôn hỏi lại: "Top 3 là top 3 người, hay top 3 mức điểm?" trước khi viết câu query. Hỏi sai một chữ, kết quả thưởng sai cả chục triệu đồng.
Tình huống 3: Khử trùng lặp giao dịch tại ví MoMo
Đội kỹ thuật của một ví điện tử như MoMo phát hiện bảng transactions bị log trùng do lỗi retry: cùng một transaction_id xuất hiện nhiều dòng, chỉ khác created_at vài mili-giây. BA cần lấy ra bản ghi mới nhất duy nhất cho mỗi giao dịch để đối soát.
ROW_NUMBER() là công cụ kinh điển cho việc này:
SELECT *
FROM (
SELECT
t.*,
ROW_NUMBER() OVER (
PARTITION BY transaction_id
ORDER BY created_at DESC
) AS rn
FROM transactions t
) dedup
WHERE rn = 1;
PARTITION BY transaction_id gom các bản trùng về chung một phân vùng; ORDER BY created_at DESC đẩy bản mới nhất lên đầu; rn = 1 giữ lại đúng một dòng duy nhất cho mỗi giao dịch. Toàn bộ bản trùng bị loại bỏ một cách sạch sẽ.
Bài học rút ra: ROW_NUMBER() không chỉ để "xếp hạng" mà còn là vũ khí số một để khử trùng lặp — một bài toán data quality mà BA gặp gần như hằng ngày khi làm việc với dữ liệu thật. Ở đây bắt buộc dùng ROW_NUMBER chứ không phải RANK/DENSE_RANK, vì ta cần đúng một dòng; nếu hai bản trùng có cùng created_at, RANK sẽ cho cả hai cùng hạng 1 và bạn vẫn còn trùng.
Hướng dẫn từng bước
Khi gặp một bài toán xếp hạng, hãy đi theo trình tự tư duy này:
- Xác định "trong phạm vi nào tôi xếp hạng?" → đây là
PARTITION BY. Xếp hạng trong từng ngành hàng?PARTITION BY category. Xếp hạng trên toàn bảng? BỏPARTITION BY.
- Xác định "xếp theo tiêu chí gì, tăng hay giảm?" → đây là
ORDER BYbên trongOVER. Doanh thu cao nhất lên đầu thìORDER BY revenue DESC.
- Chọn đúng hàm theo cách xử lý ties:
ROW_NUMBER()
- Cần thứ hạng kiểu thể thao, có nhảy cóc → RANK()
- Cần thứ hạng theo mức giá trị, không nhảy cóc → DENSE_RANK()- Bọc trong subquery (hoặc CTE) nếu cần lọc theo thứ hạng. Vì
WHEREchạy trước window function, bạn không thể lọcrn <= 3cùng cấp. Hãy tính ở tầng trong, lọc ở tầng ngoài.
- Kiểm tra lại với dữ liệu có ties. Cố tình tìm vài dòng giá trị bằng nhau và xem hàm hành xử có đúng kỳ vọng nghiệp vụ không.
Lỗi thường gặp & mẹo
- Quên
ORDER BYtrongOVER. Với hàm ranking, thiếuORDER BYthì thứ hạng vô nghĩa (mọi dòng có thể nhận cùng hạng hoặc thứ tự ngẫu nhiên).ORDER BYbên trongOVERkhác vớiORDER BYcuối câu lệnh — cái trongOVERquyết định cách đánh hạng, cái cuối câu quyết định cách hiển thị kết quả.
- Cố lọc
WHERE rn <= 3cùng cấp với window function. Sẽ báo lỗi "column rn does not exist". Luôn nhớ: bọc subquery/CTE rồi mới lọc.
- Nhầm
RANKvớiDENSE_RANKkhi có ties. Đây là lỗi âm thầm nguy hiểm nhất — query chạy được, không báo lỗi, nhưng con số sai. Nếu dữ liệu không có giá trị trùng, ba hàm cho kết quả y hệt nhau, khiến bạn tưởng dùng hàm nào cũng được. Đến khi dữ liệu thật có ties, kết quả lệch. Luôn test với ties.
- Dùng
ROW_NUMBERcho bài toán "tất cả những ai đạt mức cao nhất". Nếu có hai sản phẩm cùng doanh thu top 1 mà bạn dùngROW_NUMBER() = 1, bạn chỉ lấy được một trong hai. Trường hợp này cầnRANK()hoặcDENSE_RANK().
- Mẹo PARTITION BY nhiều cột. Bạn có thể phân vùng theo nhiều cột:
PARTITION BY category, region— xếp hạng top sản phẩm trong từng ngành hàng và từng vùng cùng lúc.
- Mẹo tie-breaker. Để
ROW_NUMBERcho kết quả ổn định (deterministic), thêm cột phụ vàoORDER BY, ví dụORDER BY revenue DESC, product_id ASC. Khi doanh thu bằng nhau, sản phẩm có id nhỏ hơn luôn đứng trước — chạy lại nhiều lần vẫn ra kết quả giống nhau.
Bài tập thực hành
Giả sử bạn có bảng orders của một shop trên Shopee với các cột: order_id, customer_id, order_date, amount, city.
- Cơ bản: Viết query gán số thứ tự cho mỗi đơn hàng của từng khách hàng theo
order_datetăng dần (đơn đầu tiên của khách = 1). Gợi ý:ROW_NUMBER()+PARTITION BY customer_id.
- Top N theo nhóm: Lấy ra 2 đơn hàng có giá trị
amountcao nhất trong mỗi thành phố. Nhớ bọc subquery để lọc.
- Phân biệt hàm: Với mỗi thành phố, xếp hạng các đơn theo
amountgiảm dần bằng cảRANK()vàDENSE_RANK()trong cùng một câu query. Tạo ra (hoặc tìm) một trường hợp có hai đơn cùngamountvà giải thích vì sao hai cột thứ hạng cho số khác nhau.
- Khử trùng lặp: Giả sử bảng có một số
order_idbị lặp. Viết query giữ lại đúng một dòng mới nhất cho mỗiorder_id(dựa trên một cột thời gian giả địnhupdated_at).
- Nâng cao (tư duy nghiệp vụ): Sếp muốn biết "khách hàng nào là khách chi tiêu cao nhất ở mỗi thành phố". Hãy quyết định: nếu hai khách cùng chi tiêu top 1, bạn muốn lấy cả hai hay chỉ một? Chọn hàm phù hợp và giải thích lý do nghiệp vụ.
Tóm tắt
- Window function tính giá trị cho từng dòng dựa trên "cửa sổ" các dòng liên quan, giữ nguyên số dòng — khác hẳn
GROUP BYvốn gom nhóm và làm mất chi tiết. - Cú pháp chung:
ham() OVER (PARTITION BY ... ORDER BY ...).PARTITION BYchia phân vùng,ORDER BY(bên trongOVER) quyết định cách đánh hạng. ROW_NUMBER()— số liên tục, không trùng, lý tưởng cho top N và khử trùng lặp.RANK()— cùng hạng khi bằng nhau, có nhảy cóc (kiểu thể thao).DENSE_RANK()— cùng hạng khi bằng nhau, không nhảy cóc (theo mức giá trị).- Khi cả ba cho kết quả giống nhau là vì dữ liệu không có ties — đừng để điều đó đánh lừa; luôn test với giá trị trùng.
- Muốn lọc theo thứ hạng (
rn <= 3), phải bọc trong subquery hoặc CTE vìWHEREchạy trước window function. - Với BA, chọn đúng hàm là chuyện hiểu đúng nghiệp vụ ("top 3 người hay top 3 mức?") chứ không thuần kỹ thuật.
OVER sẽ giúp bạn tính running total, so sánh kỳ trước kỳ sau, và nhiều phép phân tích nâng cao khác.