SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 14 phút đọc
ROW_NUMBER, RANK và DENSE_RANK trong SQL khác nhau thế nào khi có giá trị bằng nhau

ROW_NUMBER(), RANK() và DENSE_RANK() đều đánh số các dòng theo thứ tự bạn đặt trong OVER (ORDER BY ...). Chúng cho kết quả giống hệt nhau cho đến khi có hai dòng bằng nhau. Khi có giá trị bằng nhau, ROW_NUMBER vẫn phát số duy nhất (1, 2, 3, 4), RANK cho các dòng bằng nhau cùng một số rồi nhảy cóc (1, 2, 2, 4), còn DENSE_RANK cho cùng một số mà không nhảy cóc (1, 2, 2, 3). Trong thực tế: ROW_NUMBER cho “đúng N dòng” và khử trùng, RANK cho bảng xếp hạng mà các dòng đồng hạng chia nhau một vị trí, DENSE_RANK cho “giá trị lớn thứ N”.
Mọi ví dụ đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto. Truy vấn dùng cú pháp PostgreSQL và chạy trên DuckDB, động cơ của phòng học SQL trên Nomto; mọi bảng kết quả đều là kết quả chạy thật. Bức tranh rộng hơn về OVER, khung cửa sổ, tổng lũy kế và LAG có trong bài hàm cửa sổ trong SQL; bài này chỉ nói về các hàm xếp hạng.
Khác biệt gói gọn trong một bảng
Bảng xếp hạng thành viên khách hàng thân thiết theo số chặng bay họ đã bay. Nhiều thành viên có cùng số chặng, và đó chính là chỗ ba hàm rẽ sang ba hướng:
WITH m AS (
SELECT tm.member_id, COUNT(*) AS flights
FROM ticket_members tm
JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
GROUP BY tm.member_id
)
SELECT member_id, flights,
ROW_NUMBER() OVER (ORDER BY flights DESC, member_id) AS row_num,
RANK() OVER (ORDER BY flights DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY flights DESC) AS dense_rnk
FROM m
ORDER BY row_num
LIMIT 11;
| member_id | flights | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| 12589 | 33 | 1 | 1 | 1 |
| 2969 | 32 | 2 | 2 | 2 |
| 1659 | 31 | 3 | 3 | 3 |
| 8943 | 31 | 4 | 3 | 3 |
| 37704 | 31 | 5 | 3 | 3 |
| 39173 | 31 | 6 | 3 | 3 |
| 9128 | 30 | 7 | 7 | 4 |
| 15845 | 30 | 8 | 7 | 4 |
| 35285 | 30 | 9 | 7 | 4 |
| 38397 | 30 | 10 | 7 | 4 |
| 6702 | 29 | 11 | 11 | 5 |
Đọc từng cột:
- ROW_NUMBER đếm từ 1 đến 11, bất kể có bằng nhau hay không. Bốn thành viên có 31 chặng nhận 3, 4, 5 và 6 chỉ nhờ cột phá hòa
member_idtrongORDER BYcủa nó. - RANK cho cả bốn người hạng 3, rồi nhảy lên 7: các vị trí 4, 5 và 6 đã bị nhóm đồng hạng “chiếm”. Đó là vị trí theo kiểu “có bao nhiêu dòng đứng trước tôi, cộng một”.
- DENSE_RANK cũng cho họ số 3, rồi tiếp tục với 4. Nó đánh số các giá trị khác nhau: 33, 32, 31, 30, 29 là số chặng lớn thứ nhất đến thứ năm.
Gói gọn trong một câu: RANK đếm dòng, DENSE_RANK đếm giá trị khác nhau, ROW_NUMBER bỏ qua chuyện bằng nhau.
“Top 5” trả về bao nhiêu dòng với mỗi hàm
Vì xử lý dòng bằng nhau khác nhau, cùng một bộ lọc <= 5 trả về số dòng khác nhau với mỗi hàm:
WITH m AS (
SELECT tm.member_id, COUNT(*) AS flights
FROM ticket_members tm
JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
GROUP BY tm.member_id
), r AS (
SELECT member_id, flights,
ROW_NUMBER() OVER (ORDER BY flights DESC, member_id) AS row_num,
RANK() OVER (ORDER BY flights DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY flights DESC) AS dense_rnk
FROM m
)
SELECT COUNT(*) FILTER (WHERE row_num <= 5) AS by_row_number,
COUNT(*) FILTER (WHERE rnk <= 5) AS by_rank,
COUNT(*) FILTER (WHERE dense_rnk <= 5) AS by_dense_rank
FROM r;
| by_row_number | by_rank | by_dense_rank |
|---|---|---|
| 5 | 6 | 14 |
ROW_NUMBER <= 5luôn trả về đúng 5 dòng, và âm thầm bỏ một trong bốn thành viên cùng có 31 chặng.RANK <= 5trả về 6: tất cả những ai có vị trí từ 5 trở lên, nên cả bốn người đồng hạng 3 đều có mặt.DENSE_RANK <= 5trả về 14: tất cả những ai có một trong năm mức số chặng cao nhất (33, 32, 31, 30 hoặc 29).
Không hàm nào sai; chúng trả lời những câu hỏi khác nhau. Hãy xác định câu hỏi trước khi chọn hàm.
Top N mỗi nhóm với PARTITION BY
Thêm PARTITION BY thì việc đánh số bắt đầu lại trong từng nhóm. Top 3 thành viên theo sân bay nhà của họ, cho hai sân bay, cả ba hàm đặt cạnh nhau:
WITH m AS (
SELECT lm.home_airport, tm.member_id, COUNT(*) AS flights
FROM ticket_members tm
JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
JOIN loyalty_members lm ON lm.member_id = tm.member_id
WHERE lm.home_airport IN ('VKO', 'OVB')
GROUP BY lm.home_airport, tm.member_id
), r AS (
SELECT home_airport, member_id, flights,
ROW_NUMBER() OVER (PARTITION BY home_airport ORDER BY flights DESC, member_id) AS rn,
RANK() OVER (PARTITION BY home_airport ORDER BY flights DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY home_airport ORDER BY flights DESC) AS dense_rnk
FROM m
)
SELECT * FROM r
WHERE rnk <= 3
ORDER BY home_airport, rn;
| home_airport | member_id | flights | rn | rnk | dense_rnk |
|---|---|---|---|---|---|
| OVB | 39502 | 28 | 1 | 1 | 1 |
| OVB | 9835 | 25 | 2 | 2 | 2 |
| OVB | 4544 | 24 | 3 | 3 | 3 |
| OVB | 27427 | 24 | 4 | 3 | 3 |
| OVB | 38226 | 24 | 5 | 3 | 3 |
| OVB | 39050 | 24 | 6 | 3 | 3 |
| VKO | 39173 | 31 | 1 | 1 | 1 |
| VKO | 6702 | 29 | 2 | 2 | 2 |
| VKO | 17160 | 29 | 3 | 2 | 2 |
| VKO | 32434 | 29 | 4 | 2 | 2 |
Lọc theo rnk <= 3 trả về sáu dòng cho Novosibirsk (bốn người đồng hạng 3) và bốn dòng cho Vnukovo (ba người đồng hạng 2). Với rn <= 3, bạn sẽ nhận đúng ba dòng mỗi sân bay, và ai trong số những người đồng hạng lọt vào danh sách chỉ phụ thuộc vào cột phá hòa. Với danh sách trao giải, như thế là không công bằng; với một widget “hiện ba ví dụ mỗi sân bay”, đó lại đúng là điều bạn cần.
Hai quy tắc áp dụng cho mọi truy vấn top N:
- Lọc ở bên ngoài.
WHERE rnk <= 3không thể nằm trong cùng câuSELECTtính rarnk, vìWHEREchạy trước hàm cửa sổ. Hãy tính hạng trong một CTE hoặc truy vấn con, rồi lọc ở truy vấn bên ngoài. DuckDB, Snowflake, BigQuery và Databricks còn chấp nhận mệnh đềQUALIFY rnk <= 3; PostgreSQL, MySQL và SQL Server thì không. - Chia nhóm theo nhóm, sắp xếp theo chỉ số.
PARTITION BY home_airport ORDER BY flights DESCnghĩa là “trong từng sân bay, theo số chặng”. QuênPARTITION BYthì bạn xếp hạng trên toàn bảng.
Khử trùng: giữ một dòng cho mỗi khóa bằng ROW_NUMBER
Khi loại bỏ dòng trùng, chuyện bằng nhau chính là trọng tâm: các dòng trùng bằng nhau ở mọi cột, và bạn muốn giữ đúng một. Đó là việc của ROW_NUMBER, không bao giờ là RANK, vì RANK sẽ đánh cả hai bản sao là 1 và giữ cả hai.
Bảng app_events nhận một số sự kiện hai lần: cùng client, cùng sự kiện, cùng event_time, khác received_at. Đánh số các bản sao, bản đến sớm nhất trước:
SELECT event_id, event_name, event_time, received_at,
ROW_NUMBER() OVER (PARTITION BY client_id, event_name, event_time
ORDER BY received_at, event_id) AS rn
FROM app_events
WHERE client_id = '000034d98b23' AND event_time < '2026-07-05 02:00'
ORDER BY event_time, rn;
| event_id | event_name | event_time | received_at | rn |
|---|---|---|---|---|
| 131068 | app_open | 2026-07-05 01:02:07 | 2026-07-05 01:02:07 | 1 |
| 131081 | search | 2026-07-05 01:02:31 | 2026-07-05 01:02:31 | 1 |
| 131116 | search | 2026-07-05 01:02:31 | 2026-07-05 01:03:56 | 2 |
| 131085 | view_offer | 2026-07-05 01:02:41 | 2026-07-05 01:02:42 | 1 |
| 131096 | select_fare | 2026-07-05 01:03:32 | 2026-07-05 01:03:34 | 1 |
Sự kiện search đến hai lần, cách nhau 85 giây; bản sao nhận rn = 2. Giữ rn = 1 trên toàn bảng:
WITH ranked AS (
SELECT ROW_NUMBER() OVER (PARTITION BY client_id, event_name, event_time
ORDER BY received_at, event_id) AS rn
FROM app_events
)
SELECT COUNT(*) AS all_rows,
COUNT(*) FILTER (WHERE rn = 1) AS kept,
COUNT(*) FILTER (WHERE rn > 1) AS duplicates
FROM ranked;
| all_rows | kept | duplicates |
|---|---|---|
| 1494723 | 1484034 | 10689 |
Các cột trong PARTITION BY định nghĩa thế nào là “cùng một dòng”; ORDER BY quyết định bản sao nào được giữ (ở đây là bản đến đầu tiên; ORDER BY received_at DESC sẽ giữ bản mới nhất). Thêm một cột duy nhất như event_id ở cuối để lựa chọn lặp lại được. Các cách khác để tìm và xóa dòng trùng được so sánh trong bài tìm dữ liệu trùng lặp trong SQL.
Giá trị lớn thứ N: DENSE_RANK
“Tìm giá vé cao thứ hai” là câu hỏi phỏng vấn kinh điển, và đó là chỗ RANK âm thầm thất bại. Rất nhiều vé có cùng một mức giá:
WITH p AS (
SELECT amount,
RANK() OVER (ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM ticket_flights
)
SELECT amount, MIN(rnk) AS rnk, MIN(dense_rnk) AS dense_rnk, COUNT(*) AS tickets
FROM p
WHERE dense_rnk <= 4
GROUP BY amount
ORDER BY amount DESC;
| amount | rnk | dense_rnk | tickets |
|---|---|---|---|
| 2033.00 | 1 | 1 | 427 |
| 2013.00 | 428 | 2 | 70 |
| 1998.00 | 498 | 3 | 691 |
| 1993.00 | 1189 | 4 | 856 |
427 vé có giá cao nhất $2.033, nên RANK gán cho mức giá tiếp theo, $2.013, hạng 428. Truy vấn với WHERE rnk = 2 trả về không dòng nào; WHERE dense_rnk = 2 trả về $2.013, đúng đáp án. ROW_NUMBER() = 2 lại trả về $2.033, vì dòng thứ hai chỉ là một vé khác cũng có giá cao nhất.
WITH p AS (
SELECT amount, DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM ticket_flights
)
SELECT DISTINCT amount FROM p WHERE dense_rnk = 2;
| amount |
|---|
| 2013.00 |
Phiên bản quen thuộc hơn ở các buổi phỏng vấn data analyst là “lương cao thứ hai trong bảng employees”, và lời giải giống hệt. Thêm nhiều câu hỏi cùng kiểu trong bài câu hỏi phỏng vấn SQL.
Nên dùng hàm nào
| Nhiệm vụ | Hàm | Vì sao |
|---|---|---|
| Đúng N dòng, top N mỗi nhóm với số lượng cố định | ROW_NUMBER |
Số duy nhất; dòng bằng nhau được cắt theo cột phá hòa |
| Khử trùng, dòng mới nhất cho mỗi khóa | ROW_NUMBER |
Các bản sao bằng nhau, và bạn cần đúng một dòng với rn = 1 |
| Phân trang theo thứ tự ổn định | ROW_NUMBER |
Mỗi dòng có vị trí riêng |
| Bảng xếp hạng, thứ hạng thi đấu | RANK |
Đồng hạng chia nhau một vị trí, vị trí kế tiếp bị bỏ qua (1, 2, 2, 4) |
| Top N bao gồm mọi người đồng hạng ở mốc cắt | RANK |
rnk <= N giữ mọi dòng đồng hạng ở vị trí N |
| Giá trị lớn hoặc nhỏ thứ N | DENSE_RANK |
Đếm giá trị khác nhau, không có khoảng trống |
| Top N giá trị khác nhau (ba mức giá cao nhất) | DENSE_RANK |
dense_rnk <= 3 trả về mọi dòng có một trong các giá trị đó |
Thêm một quy tắc nữa: với ROW_NUMBER, luôn kết thúc ORDER BY bằng một cột duy nhất. Nếu thứ tự sắp xếp có giá trị bằng nhau, lần chạy sau cơ sở dữ liệu có thể đánh số các dòng bằng nhau khác đi, và “top 5” hay bảng đã khử trùng của bạn thay đổi dù dữ liệu không đổi. RANK và DENSE_RANK không cần điều này, vì các dòng bằng nhau đằng nào cũng nhận cùng một số.
NTILE, PERCENT_RANK và CUME_DIST
Ba hàm xếp hạng nữa đáng biết:
NTILE(n)chia các dòng đã sắp xếp thànhnnhóm có kích thước (gần như) bằng nhau: tứ phân vị, thập phân vị.PERCENT_RANK()bằng(rank - 1) / (rows - 1): 0 cho dòng đầu tiên, 1 cho dòng cuối cùng.CUME_DIST()là tỷ lệ các dòng có giá trị nhỏ hơn hoặc bằng giá trị hiện tại.
Trên chín mẫu máy bay, sắp xếp theo tầm bay:
SELECT model, range,
NTILE(4) OVER (ORDER BY range DESC) AS quartile,
ROUND(PERCENT_RANK() OVER (ORDER BY range), 3) AS pct_rank,
ROUND(CUME_DIST() OVER (ORDER BY range), 3) AS cume_dist
FROM aircrafts
ORDER BY range DESC;
| model | range | quartile | pct_rank | cume_dist |
|---|---|---|---|---|
| Boeing 777-300 | 11100 | 1 | 1 | 1 |
| Boeing 767-300 | 7900 | 1 | 0.875 | 0.889 |
| Airbus A319-100 | 6700 | 1 | 0.75 | 0.778 |
| Airbus A320-200 | 5700 | 2 | 0.625 | 0.667 |
| Airbus A321-200 | 5600 | 2 | 0.5 | 0.556 |
| Boeing 737-300 | 4200 | 3 | 0.375 | 0.444 |
| Sukhoi Superjet-100 | 3000 | 3 | 0.25 | 0.333 |
| Bombardier CRJ-200 | 2700 | 4 | 0.125 | 0.222 |
| Cessna 208 Caravan | 1200 | 4 | 0 | 0.111 |
Chín dòng không chia đều cho bốn, nên nhóm đầu tiên nhận thêm một dòng (3, 2, 2, 2).
NTILE có cùng điểm mù với ROW_NUMBER: nó bỏ qua chuyện bằng nhau. Chia tất cả thành viên thành mười nhóm theo số chặng bay:
WITH m AS (
SELECT tm.member_id, COUNT(*) AS flights
FROM ticket_members tm
JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
GROUP BY tm.member_id
), t AS (
SELECT member_id, flights,
NTILE(10) OVER (ORDER BY flights DESC, member_id) AS decile
FROM m
)
SELECT decile, COUNT(*) AS members, MIN(flights) AS min_flights, MAX(flights) AS max_flights
FROM t
GROUP BY decile
ORDER BY decile;
| decile | members | min_flights | max_flights |
|---|---|---|---|
| 1 | 4158 | 8 | 33 |
| 2 | 4158 | 6 | 8 |
| 3 | 4158 | 6 | 6 |
| 4 | 4158 | 4 | 6 |
| 5 | 4157 | 4 | 4 |
| 6 | 4157 | 4 | 4 |
| 7 | 4157 | 3 | 4 |
| 8 | 4157 | 2 | 3 |
| 9 | 4157 | 2 | 2 |
| 10 | 4157 | 1 | 2 |
Các nhóm có kích thước bằng nhau, nhưng thành viên có 8 chặng rơi vào cả nhóm 1 lẫn nhóm 2, còn thành viên có 4 chặng bị trải ra bốn nhóm. Nếu “top 10%” không được phép tách các giá trị bằng nhau, hãy dùng PERCENT_RANK() <= 0.1 hoặc CUME_DIST(), vì chúng cho các dòng bằng nhau cùng một giá trị.
Lưu ý về các hệ quản trị
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK và CUME_DIST là SQL chuẩn và hoạt động giống nhau trong PostgreSQL, DuckDB, SQL Server (hai hàm cuối từ bản 2012), Oracle, BigQuery và Snowflake. MySQL có chúng từ bản 8.0; MySQL 5.7 hoàn toàn không có hàm cửa sổ, điều cần lưu ý vì nhiều hệ thống cũ ở các công ty vẫn chạy MySQL 5.7. SQL Server bắt buộc có ORDER BY trong OVER cho tất cả các hàm này, kể cả ROW_NUMBER; PostgreSQL và DuckDB chấp nhận ROW_NUMBER() OVER (), nhưng khi đó việc đánh số là tùy ý.
Câu hỏi thường gặp
ROW_NUMBER, RANK và DENSE_RANK khác nhau thế nào?
Chúng chỉ khác nhau khi có giá trị bằng nhau. ROW_NUMBER cho mỗi dòng một số duy nhất (1, 2, 3, 4). RANK cho các dòng bằng nhau cùng một số và bỏ qua các số tiếp theo (1, 2, 2, 4). DENSE_RANK cho các dòng bằng nhau cùng một số mà không bỏ số nào (1, 2, 2, 3).
Nên dùng hàm nào cho top N mỗi nhóm?
ROW_NUMBER nếu mỗi nhóm phải trả về đúng N dòng. RANK nếu mọi người đồng hạng ở vị trí thứ N đều phải có mặt, khi đó mỗi nhóm có thể trả về hơn N dòng. Trong cả hai trường hợp, hãy tính số thứ tự trong một CTE và lọc ở truy vấn bên ngoài.
Vì sao RANK bỏ qua một số số?
Vì RANK bằng số dòng đứng trước dòng hiện tại cộng một. Nếu bốn dòng đồng hạng 3, dòng tiếp theo có sáu dòng đứng trước nó, nên nó xếp thứ 7. DENSE_RANK thì đếm các giá trị khác nhau, nên dòng tiếp theo xếp thứ 4.
Làm sao lấy giá trị lớn thứ hai trong SQL?
Dùng DENSE_RANK() OVER (ORDER BY value DESC) và giữ dense_rnk = 2. RANK có thể không trả về gì khi giá trị lớn nhất có ở nhiều dòng, còn ROW_NUMBER có thể trả lại chính giá trị lớn nhất.
ROW_NUMBER có cho kết quả cố định không?
Chỉ khi ORDER BY trong OVER là duy nhất. Nếu thứ tự có giá trị bằng nhau, cơ sở dữ liệu có thể đánh số các dòng đó khác nhau giữa các lần chạy. Hãy thêm một cột duy nhất, chẳng hạn id, làm khóa sắp xếp cuối cùng.
Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文