Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Window functions cơ bản: ROW_NUMBER, RANK, DENSE_RANK

SQL and Data Analysis cho BA Bài 18/60

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 BYORDER 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 BY như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.
Bảng minh họa với điểm số 100, 100, 90, 80:

ĐiểmROW_NUMBERRANKDENSE_RANK
100111
100211
90332
80443
Hãy nhìn kỹ dòng điểm 90: 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_idcategoryrevenue
101Điện thoại2,400,000,000
102Điện thoại1,800,000,000
103Sách320,000,000
.........
Câu hỏi "top 3 mỗi ngành hàng" được giải gọn gàng như sau:

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ếnrank_thethao (RANK)rank_dac (DENSE_RANK)
Tuấn8811
Hùng8811
Minh8532
Lan8532
Phúc8053
Nếu chương trình thưởng quy định "trao quà cho 3 mức thành tích cao nhất" (tức 3 tier thưởng khác nhau), bạn dùng 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 BY bên trong OVER. Doanh thu cao nhất lên đầu thì ORDER BY revenue DESC.
  • Chọn đúng hàm theo cách xử lý ties:
- Cần số duy nhất / khử trùng / lấy đúng 1 dòng → 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.WHERE chạy trước window function, bạn không thể lọc rn <= 3 cù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 BY trong OVER. Với hàm ranking, thiếu ORDER BY thì 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 BY bên trong OVER khác với ORDER BY cuối câu lệnh — cái trong OVER quyế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 <= 3 cù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 RANK với DENSE_RANK khi 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_NUMBER cho 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ùng ROW_NUMBER() = 1, bạn chỉ lấy được một trong hai. Trường hợp này cần RANK() hoặc DENSE_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 từng vùng cùng lúc.
  • Mẹo tie-breaker. Để ROW_NUMBER cho kết quả ổn định (deterministic), thêm cột phụ vào ORDER 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_date tă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ị amount cao 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 amount giảm dần bằng cả RANK()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ùng amount và 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_id bị lặp. Viết query giữ lại đúng một dòng mới nhất cho mỗi order_id (dựa trên một cột thời gian giả định updated_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ụ.
Hãy tự viết, chạy thử trên môi trường SQL của bạn, rồi đối chiếu kết quả với suy luận thủ công trên vài dòng dữ liệu nhỏ.

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 BY vốn gom nhóm và làm mất chi tiết.
  • Cú pháp chung: ham() OVER (PARTITION BY ... ORDER BY ...). PARTITION BY chia phân vùng, ORDER BY (bên trong OVER) 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ì WHERE chạ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.
Nắm vững ba hàm ranking này, bạn đã có nền tảng vững để bước sang các window function mạnh hơn ở những bài kế tiếp — nơi cùng một mệnh đề 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.