Làm chủ SQL Window Functions: Hướng dẫn thực tế về ROW_NUMBER, RANK, LEAD và LAG

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Đó là 2:15 sáng ngày thứ Ba. PagerDuty liên tục gửi cảnh báo vì dịch vụ thanh toán (billing) phát sinh lỗi đối soát trên 1.400 tài khoản đang hoạt động. Một lỗi từ hàng đợi upstream đã gửi trùng lặp webhook thanh toán, dẫn đến việc người dùng bị trừ tiền hai lần. Lead kỹ sư cần hai thông tin trước khi đợt thanh toán (payout) lúc 6:00 sáng bắt đầu: một danh sách giao dịch sạch đã được khử trùng lặp và khoảng chênh lệch thời gian tính bằng mili-giây giữa các lượt gọi trùng lặp.

Nếu dùng self-join phức tạp hay subquery lồng nhau, CPU của replica sẽ bị đẩy lên 100% và mất nhiều phút để xử lý xong. Các SQL window function tiêu chuẩn giải quyết gọn gàng bài toán này chỉ với một lần quét (single scan) qua 10 triệu dòng dữ liệu.

Khắc phục nhanh: Xử lý dữ liệu trùng lặp trong 5 phút

Khi các lệnh gọi webhook trùng lặp tràn vào cơ sở dữ liệu, bạn cần giữ lại bản ghi sớm nhất cho mỗi lượt thanh toán. Kết hợp ROW_NUMBER() với Common Table Expression (CTE) là cách xử lý tối ưu và rõ ràng nhất.

Hãy xem qua dữ liệu mới nhận trong bảng payment_events:

-- Kiểm tra các bản ghi bị trùng lặp
SELECT id, account_id, transaction_id, amount, created_at
FROM payment_events
WHERE created_at >= NOW() - INTERVAL '6 hours'
ORDER BY transaction_id, created_at ASC
LIMIT 5;

Để lọc ra webhook gốc và loại bỏ các bản ghi trùng lặp, hãy phân vùng (partition) các hàng theo transaction_id và sắp xếp theo thời gian thêm vào:

WITH ranked_payments AS (
    SELECT 
        id,
        account_id,
        transaction_id,
        amount,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY transaction_id 
            ORDER BY created_at ASC
        ) AS event_sequence
    FROM payment_events
    WHERE created_at >= NOW() - INTERVAL '6 hours'
)
SELECT id, account_id, transaction_id, amount, created_at
FROM ranked_payments
WHERE event_sequence = 1;

Mệnh đề GROUP BY thông thường sẽ gộp tất cả các hàng khớp thành một dòng tổng hợp duy nhất. Ngược lại, window function tính toán các giá trị trên từng phân vùng cụ thể nhưng vẫn giữ nguyên từng hàng riêng lẻ trong kết quả trả về.

Cơ chế cốt lõi: Mệnh đề OVER() thực sự hoạt động như thế nào

Mọi window function đều dựa trên mệnh đề OVER(). Mệnh đề này hướng dẫn database engine cách chia nhỏ, sắp xếp và đóng khung (frame) dữ liệu trước khi tính toán kết quả.

FUNCTION() OVER (
    PARTITION BY column_name
    ORDER BY sort_column
    [window_frame]
)
  • PARTITION BY: Chia các hàng của bạn thành các nhóm riêng biệt. Phép tính sẽ được đặt lại (reset) mỗi khi chuyển sang một phân vùng mới.
  • ORDER BY: Kiểm soát thứ tự chính xác mà các hàng được xử lý bên trong mỗi phân vùng.
  • Window Frame: Thiết lập một ranh giới trượt (sliding boundary) gồm các hàng (chẳng hạn như hàng hiện tại và 2 hàng liền trước) cho các phép tính cuộn.

So sánh ROW_NUMBER vs. RANK vs. DENSE_RANK

Việc chọn sai hàm xếp hạng trong quá trình xử lý sự cố có thể che giấu các bất thường quan trọng hoặc tạo ra các thứ hạng trùng nhau ở nơi bạn không ngờ tới. Dưới đây là cách chúng hoạt động khi đánh giá giao dịch của khách hàng:

SELECT 
    account_id,
    amount,
    ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
    RANK()       OVER (ORDER BY amount DESC) AS rnk,
    DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM account_transactions
WHERE transaction_date = CURRENT_DATE;
Account ID Amount ROW_NUMBER() RANK() DENSE_RANK()
ACC-101 $500 1 1 1
ACC-102 $500 2 1 1
ACC-103 $300 3 3 (bỏ qua 2) 2 (không bị ngắt)
ACC-104 $150 4 4 3

Hãy dùng ROW_NUMBER() khi bạn cần đảm bảo các số nguyên là duy nhất để phân trang hoặc khử trùng lặp. Chọn RANK() khi các giá trị giống nhau có cùng thứ hạng và bạn muốn thứ hạng tiếp theo phản ánh khoảng cách bị nhảy cóc. Dùng DENSE_RANK() khi các vị trí đồng hạng dùng chung một số thứ tự mà không bỏ qua bất kỳ thứ hạng nào.

Tính toán chênh lệch thời gian với LEAD và LAG

Trong sự cố lúc 2:00 sáng, chúng tôi cần biết các webhook trùng lặp đến cách nhau trong vòng 15 mili-giây (retry storm) hay cách nhau 60 giây (vòng lặp queue reprocessing). LAG() đọc các giá trị từ các hàng phía trước mà không cần self-join tốn kém.

SELECT 
    transaction_id,
    created_at,
    LAG(created_at, 1) OVER (
        PARTITION BY transaction_id 
        ORDER BY created_at ASC
    ) AS previous_event_time,
    ROUND(
        EXTRACT(EPOCH FROM (created_at - LAG(created_at, 1) OVER (
            PARTITION BY transaction_id 
            ORDER BY created_at ASC
        )))::numeric
    , 3) AS delta_seconds
FROM payment_events
WHERE created_at >= NOW() - INTERVAL '6 hours';

LAG() nhìn lại các bản ghi phía trước, trong khi LEAD() kiểm tra các hàng tiếp theo. Cả hai hàm này đều không thể thiếu khi theo dõi thời lượng phiên người dùng (session lengths), tỉ lệ rớt từng bước trong funnel và độ trễ ngẫu nhiên (jitter) khi thử lại API.

Mô hình thực tế trong Production: Running Total và Moving Average

Window frame cũng giúp biến các hàm tổng hợp thông thường như SUM(), AVG(), và COUNT() thành các chỉ số luỹ kế theo thời gian.

Theo dõi tổng dư nợ tài chính tích lũy

Để theo dõi tổng nghĩa vụ tài chính của tài khoản khi từng giao dịch được ghi nhận trong thời gian xảy ra sự cố, hãy tính tổng tích lũy (running total):

SELECT 
    account_id,
    created_at,
    amount,
    SUM(amount) OVER (
        PARTITION BY account_id
        ORDER BY created_at ASC
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_spent
FROM payment_events
WHERE created_at >= NOW() - INTERVAL '24 hours';

Làm mịn các đỉnh trễ với Moving Average

Khi khắc phục tình trạng gián đoạn mạng không liên tục, các đợt tăng vọt độ trễ cục bộ có thể làm sai lệch biểu đồ dữ liệu thô. Giá trị trung bình trượt trên 5 request sẽ làm nổi bật xu hướng thực tế:

SELECT 
    request_id,
    endpoint,
    response_time_ms,
    AVG(response_time_ms) OVER (
        PARTITION BY endpoint
        ORDER BY request_time ASC
        ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
    ) AS moving_avg_latency_ms
FROM api_access_logs
WHERE request_time >= NOW() - INTERVAL '1 hour';

Xuất kết quả truy vấn ra CSV và kiểm chứng các bản sửa lỗi trong môi trường staging cục bộ là quy trình chuẩn khi xử lý sự cố. Nếu cần chuyển đổi nhanh kết quả CSV sang JSON cho các bài test mock, hãy sử dụng toolcraft.app/vi/tools/data/csv-to-json. Công cụ này chạy hoàn toàn ở phía client trong trình duyệt, vì vậy không có dữ liệu giao dịch nhạy cảm nào bị gửi ra khỏi máy của bạn.

Quy tắc tối ưu hiệu năng cho hệ thống quy mô lớn

1. Đồng bộ Composite Index với định nghĩa Window

Các window function thực hiện sắp xếp và hash partitioning ngay trong bộ nhớ. Nếu truy vấn của bạn sử dụng PARTITION BY transaction_id ORDER BY created_at, hãy tạo một composite index bao gồm cả hai cột theo đúng thứ tự đó:

CREATE INDEX idx_payment_events_tx_created 
ON payment_events(transaction_id, created_at);

Khi có index này, PostgreSQL sẽ đọc dữ liệu đã sắp xếp trực tiếp từ ổ đĩa. Trên bảng có 12 triệu dòng, điều này giúp giảm thời gian thực thi từ 8,4 giây xuống chỉ còn 42 mili-giây.

2. Không đặt Window Function trong mệnh đề WHERE

SQL engine đánh giá mệnh đề WHERE trước khi tính toán các window function. Việc chạy WHERE ROW_NUMBER() OVER (...) = 1 sẽ gây ra lỗi cú pháp. Hãy bọc phép tính trong một CTE, hoặc sử dụng mệnh đề QUALIFY nếu engine của bạn hỗ trợ (chẳng hạn như Snowflake, BigQuery, Databricks):

-- Được hỗ trợ trên Snowflake, BigQuery và Databricks
SELECT id, transaction_id, created_at
FROM payment_events
QUALIFY ROW_NUMBER() OVER (PARTITION BY transaction_id ORDER BY created_at) = 1;

3. Giám sát work_mem và hiện tượng tràn đĩa (Disk Spill)

Chạy các window function trên tập dữ liệu lớn chưa được phân vùng có thể nhanh chóng vượt quá mức work_mem mặc định của PostgreSQL (thường chỉ 4MB). Khi điều đó xảy ra, PostgreSQL sẽ chuyển sang sắp xếp trên đĩa, hiển thị dưới dạng Sort Method: external merge Disk trong EXPLAIN ANALYZE. Hãy thêm các bộ lọc thời gian chặt chẽ trong mệnh đề WHERE hoặc tạm thời tăng work_mem cho session để giữ các tác vụ sắp xếp diễn ra hoàn toàn trên RAM.

Share: