SQL & Database — Thực chiến PostgreSQL/Window patterns — RANK + LAG/LEAD + running total + moving average
~22 phútJoin, aggregation & window lượt xem

Window patterns — RANK + LAG/LEAD + running total + moving average

4 pattern phổ biến nhất analytics. ROW_NUMBER vs RANK vs DENSE_RANK gap. LAG/LEAD diff with previous. Running total + 7-day moving average với frame ROWS BETWEEN.

Module 3 bài 6 đã giới thiệu window function: khái niệm cửa sổ, cú pháp OVER, và tại sao window không collapse row như GROUP BY. Bây giờ là lúc đi sâu — 4 pattern xuất hiện trong 80% bài toán analytics SQL thực chiến.

Mỗi pattern có function riêng và frame clause để kiểm soát "window size": ranking để tìm top N per group, LAG/LEAD để so sánh kỳ trước với kỳ này, running total để đếm dồn từ đầu năm, moving average để làm mịn fluctuation theo 7 ngày gần nhất. Biết 4 cái này bạn giải quyết phần lớn yêu cầu báo cáo trong TaskFlow mà không cần subquery phức tạp hay join nhiều tầng.

1. Analogy — 4 loại nhìn xung quanh

Tưởng tượng bạn là thí sinh trong kỳ thi cuối kỳ, ngồi trong phòng thi cùng 30 người khác:

  • Ranking: Nhìn điểm số cả phòng rồi biết mình xếp thứ mấy. Hai người cùng điểm — giám khảo quyết định đồng hạng hay tách.
  • LAG/LEAD: So bài thi này với bài thi kỳ trước của chính mình — tiến bộ hay thụt lùi bao nhiêu điểm?
  • Running total: Ban tổ chức đếm dồn số thí sinh hoàn thành bài từ đầu giờ đến hiện tại — số tích lũy tăng dần theo thời gian.
  • Moving average: Điểm trung bình 7 buổi kiểm tra gần nhất của một thí sinh — làm mịn biến động ngắn hạn.
Kỳ thiSQL Analytics
Xếp hạng trong phòng thiRanking per group (ROW_NUMBER, RANK, DENSE_RANK)
So với kỳ trước của mìnhLAG/LEAD: period-over-period diff
Đếm dồn số thí sinh xong bàiRunning total: SUM OVER UNBOUNDED PRECEDING
Trung bình 7 buổi gần nhấtMoving average: AVG OVER ROWS BETWEEN 6 PRECEDING
💡 Cách nhớ

4 pattern = 4 cách "nhìn": nhìn cả nhóm (rank), nhìn về quá khứ (lag), nhìn từ đầu đến giờ (running total), nhìn 7 ngày gần nhất (moving avg). Frame clause quyết định phạm vi nhìn.

2. Pattern 1 — Ranking: ROW_NUMBER vs RANK vs DENSE_RANK

Cho 4 score [100, 95, 95, 90] — hai score bằng nhau ở vị trí 2 và 3:

scoreROW_NUMBERRANKDENSE_RANK
100111
95222
95322
90443
  • ROW_NUMBER: luôn unique 1, 2, 3, 4 — không có tie, tie break tùy ý (thứ tự vật lý hoặc thêm ORDER BY phụ).
  • RANK: tie cùng số, gap sau tie — 1, 2, 2, 4 (skip 3 vì hai người cùng hạng 2).
  • DENSE_RANK: tie cùng số, không gap1, 2, 2, 3.
-- Top 3 user co nhieu task done nhat per project
SELECT
  project_id,
  user_id,
  done_count,
  ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY done_count DESC) AS rn,
  RANK()       OVER (PARTITION BY project_id ORDER BY done_count DESC) AS rk,
  DENSE_RANK() OVER (PARTITION BY project_id ORDER BY done_count DESC) AS drk
FROM (
  SELECT
    project_id,
    assignee_id AS user_id,
    COUNT(*) FILTER (WHERE status = 'done') AS done_count
  FROM tasks
  GROUP BY project_id, assignee_id
) sub;

Khi nào dùng cái nào:

Tình huốngNên dùngLý do
Lấy đúng 1 row per group (top-1, không tie expected)ROW_NUMBERUnique pick, tránh duplicate
Leaderboard Olympic (tie → cùng huy chương, skip số tiếp)RANKGap sau tie phản ánh đúng "hạng Olympic"
Xếp loại học tập (tie → cùng loại, không skip)DENSE_RANKKhông gap giữ thứ tự liên tục

3. Pattern 2 — LAG/LEAD: so sánh với row trước/sau

-- "Diff time-to-completion giua task lien tiep cua moi user"
SELECT
  assignee_id,
  id,
  EXTRACT(DAY FROM (updated_at - created_at))                          AS days_taken,
  LAG(EXTRACT(DAY FROM (updated_at - created_at)))
    OVER (PARTITION BY assignee_id ORDER BY created_at)                AS prev_days,
  EXTRACT(DAY FROM (updated_at - created_at))
  - LAG(EXTRACT(DAY FROM (updated_at - created_at)))
      OVER (PARTITION BY assignee_id ORDER BY created_at)             AS diff_vs_prev
FROM tasks
WHERE status = 'done';
  • LAG(col, n, default) — giá trị của row cách n row trước trong window (mặc định n=1, default=NULL).
  • LEAD(col, n, default) — giá trị của row cách n row sau.

Row đầu tiên của mỗi partition không có row trước — LAG trả NULL. Dùng COALESCE(LAG(...), 0) nếu muốn default.

Use case phổ biến:

  • Time-between-events: khoảng cách giữa hai commit, hai lần login, hai task done.
  • Period-over-period diff: revenue tháng này so tháng trước, active user tuần này so tuần trước.
  • Detect transition: status trước là gì, status hiện tại là gì (LAG(status) — phát hiện chuyển in_progress sang done).

4. Pattern 3 — Running total: cumulative SUM với frame

-- "Cumulative task done per day (running total)"
SELECT
  date_trunc('day', updated_at) AS day,
  COUNT(*)                       AS daily_done,
  SUM(COUNT(*)) OVER (
    ORDER BY date_trunc('day', updated_at)
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  )                              AS cumulative_done
FROM tasks
WHERE status = 'done'
GROUP BY date_trunc('day', updated_at)
ORDER BY day;

Sample output:

day        | daily_done | cumulative_done
-----------+------------+-----------------
2026-05-01 |          5 |               5
2026-05-02 |          3 |               8
2026-05-03 |          7 |              15
2026-05-04 |          2 |              17

SUM(...) OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) = "tổng từ row đầu tiên đến row hiện tại theo ORDER BY". Mỗi row nhận tổng tích lũy tính đến ngày đó.

Lưu ý: SUM(COUNT(*)) là aggregate-over-aggregate — GROUP BY tính COUNT(*) per day trước, window function SUM cộng dồn giá trị đó theo ORDER BY ngày.

5. Pattern 4 — Moving average: frame N preceding rows

-- "7-day moving average task completion (smooth trend)"
SELECT
  day,
  daily_done,
  AVG(daily_done) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS ma_7d
FROM (
  SELECT
    date_trunc('day', updated_at) AS day,
    COUNT(*)                       AS daily_done
  FROM tasks
  WHERE status = 'done'
  GROUP BY 1
) sub;

Frame ROWS BETWEEN 6 PRECEDING AND CURRENT ROW = window 7 row gần nhất (6 row trước + current row). AVG tính trên tối đa 7 row — nếu chưa đủ 7 row (đầu dataset), PostgreSQL tính trên số row hiện có.

Moving average làm mịn fluctuation ngắn hạn — ngày có spike đột biến ít ảnh hưởng hơn trend 7 ngày. Dùng trong dashboard để hiển thị trend completion ổn định hơn daily bar chart.

6. Frame clause — ROWS vs RANGE vs GROUPS

3 frame mode trong PostgreSQL 11+:

ModeÝ nghĩaVí dụ
ROWSOffset vật lý theo số rowROWS BETWEEN 6 PRECEDING AND CURRENT ROW = 7 row vật lý
RANGEOffset theo giá trị của ORDER BY columnRANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW = mọi row trong 7 ngày trước
GROUPSOffset theo nhóm peer (cùng ORDER BY value)GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
-- ROWS: luon dung 6 row vat ly ke truoc, bat ke khoang cach ngay
SUM(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- RANGE: moi row trong 7 ngay truoc theo value time
SUM(x) OVER (ORDER BY date RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW)

Phân biệt quan trọng khi data có gap (ngày không có row): ROWS BETWEEN 6 PRECEDING lấy đúng 6 row vật lý (có thể trải dài nhiều tuần nếu data thưa), RANGE BETWEEN INTERVAL '7 days' PRECEDING gắn vào giá trị thời gian thực — đúng semantic "7 ngày lịch" dù data có gap.

7. Pitfall — frame default thay đổi với ORDER BY

Pitfall — frame default khác nhau khi có vs không có ORDER BY

PostgreSQL thay đổi frame default tùy có ORDER BY trong OVER hay không — dễ gây bug silent (query chạy, kết quả sai).

-- KHONG ORDER BY: frame default = BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-- -> tinh tong toan partition, moi row tra ve cung gia tri
SELECT id, SUM(x) OVER (PARTITION BY p) FROM t;

-- CO ORDER BY: frame default = BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- -> tinh running total, moi row tra ve tong tich luy den row do
SELECT id, SUM(x) OVER (PARTITION BY p ORDER BY id) FROM t;

Hai query trông gần giống nhau nhưng kết quả khác nhau hoàn toàn. Bug phổ biến: thêm ORDER BY vào window vì muốn sort, vô tình biến "tổng toàn partition" thành "running total".

Defensive practice: luôn khai báo explicit frame clause khi dùng aggregate window function — đừng để implicit default quyết định behavior.

8. Pattern 5 (bonus) — Gap-and-island: streak liên tục

Gap-and-island là kỹ thuật xác định chuỗi liên tiếp bị gián đoạn bởi gap. Pattern: day - ROW_NUMBER() * INTERVAL '1 day' ra cùng group_key nếu các ngày liên tục.

-- "Streak ngay co task done lien tiep cua moi user"
WITH daily AS (
  SELECT
    assignee_id,
    date_trunc('day', updated_at) AS day,
    COUNT(*)                       AS daily_done
  FROM tasks
  WHERE status = 'done'
  GROUP BY 1, 2
),
gap_island AS (
  SELECT
    assignee_id,
    day,
    daily_done,
    day - (ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY day))::int
          * INTERVAL '1 day'                                           AS group_key
  FROM daily
)
SELECT
  assignee_id,
  MIN(day)   AS streak_start,
  MAX(day)   AS streak_end,
  COUNT(*)   AS streak_length
FROM gap_island
GROUP BY assignee_id, group_key
ORDER BY streak_length DESC
LIMIT 5;

Tại sao hoạt động: nếu day liên tục (2026-05-01, 02, 03) và ROW_NUMBER tăng tương ứng (1, 2, 3), hiệu day - rn * 1 day ra cùng hằng số (2026-04-30). Khi có gap (2026-05-05 sau 05-03, skip 05-04), ROW_NUMBER tiếp tục tăng nhưng day nhảy — group_key ra giá trị khác. Group by group_key → mỗi streak thành 1 nhóm.

9. Applied — TaskFlow analytics dashboard

-- "Top 3 closer per month + diff vs previous month"
WITH monthly_per_user AS (
  SELECT
    date_trunc('month', updated_at) AS month,
    assignee_id,
    COUNT(*)                         AS done_count
  FROM tasks
  WHERE status = 'done'
  GROUP BY 1, 2
),
ranked AS (
  SELECT
    month,
    assignee_id,
    done_count,
    DENSE_RANK() OVER (PARTITION BY month ORDER BY done_count DESC)    AS rk,
    LAG(done_count) OVER (PARTITION BY assignee_id ORDER BY month)     AS prev_month_done
  FROM monthly_per_user
)
SELECT
  month,
  assignee_id,
  done_count,
  rk,
  prev_month_done,
  done_count - COALESCE(prev_month_done, 0) AS month_over_month
FROM ranked
WHERE rk <= 3
ORDER BY month DESC, rk;

Query kết hợp DENSE_RANK (top 3 per month, không gap nếu tie) và LAG (diff so tháng trước của cùng user). Filter rk <= 3 ở outer query vì window result không thấy được ở WHERE trực tiếp.

10. Deep Dive — Window patterns

📚 Deep Dive — Window patterns
  • PostgreSQL Documentation 9.22 "Window Functions" — catalog chính thức mọi window function: ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE, NTILE.
  • PostgreSQL Documentation 4.2.8 "Window Function Calls" — frame clause grammar đầy đủ: ROWS/RANGE/GROUPS BETWEEN ... AND ..., EXCLUDE, FILTER, WINDOW named clause.
  • Itzik Ben-Gan — "T-SQL Window Functions: For SQL Server and Azure SQL" — sách classic về window pattern. Viết cho SQL Server nhưng concept (frame clause, gap-and-island, islands-with-counts) áp dụng trực tiếp cho PostgreSQL.

Ghi chú: Đọc PG docs cho catalog và syntax chính xác. Đọc Ben-Gan khi cần master frame clause và gap-and-island — chapter gap-and-island là canonical reference cho kỹ thuật này.

11. Tóm tắt

  • ROW_NUMBER trả unique rank (không tie); RANK tie → cùng số + gap sau; DENSE_RANK tie → cùng số + không gap.
  • LAG(col, n, default) lấy giá trị n row trước trong window — dùng cho period-over-period diff, time-between-events, status transition.
  • Running total: SUM(...) OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) — tổng tích lũy từ row đầu đến row hiện tại.
  • Moving average: AVG(...) OVER (ORDER BY ... ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) — trung bình 7 row gần nhất.
  • Frame mode ROWS đếm row vật lý, RANGE gắn theo value của ORDER BY column — khác nhau khi data có gap.
  • Frame default thay đổi tuỳ có ORDER BY hay không — luôn khai báo explicit frame clause để tránh bug silent.
  • Gap-and-island: day - ROW_NUMBER() * INTERVAL '1 day' ra cùng group_key cho chuỗi ngày liên tiếp — group by group_key để tìm streak.
  • Xem thêm Module 3 bài 8 của khoá này (mini-challenge kết hợp toàn bộ pattern) và Module 8 của khoá này (advanced window frame với CTE).

12. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao ROW_NUMBER, RANK, DENSE_RANK trả kết quả khác nhau với cùng score tied? Khi nào dùng cái nào?

ROW_NUMBER: gán số tuần tự unique cho mỗi row — tie break tùy ý theo thứ tự vật lý hoặc ORDER BY phụ. Kết quả: 1, 2, 3, 4 kể cả khi hai score bằng nhau. Dùng khi cần đúng 1 row per group (top-1 pick) và không muốn duplicate.

RANK: row tie nhận cùng số, nhưng rank tiếp theo bị skip (gap). Hai người cùng hạng 2 → hạng tiếp là 4, không phải 3. Dùng cho Olympic medal, leaderboard cạnh tranh nơi gap phản ánh đúng "vị trí thực".

DENSE_RANK: row tie nhận cùng số, không skip — 1, 2, 2, 3. Dùng cho xếp loại học tập, category ranking nơi muốn thứ tự liên tục không nhảy số.

Q2
Phân biệt LAG(col) vs correlated subquery WHERE created_at < current.created_at ORDER BY created_at DESC LIMIT 1. Cái nào nhanh hơn và tại sao?

LAG nhanh hơn đáng kể với bảng lớn. Engine tính window function qua 1 lần scan bảng, giữ trạng thái nội bộ (running value buffer) khi di chuyển qua partition — chi phí O(n log n) cho sort + O(n) cho scan.

Correlated subquery chạy lại cho mỗi row — nếu bảng có 10,000 row, subquery chạy 10,000 lần. Ngay cả với index trên created_at, tổng cost thường O(n log n) per row, tổng O(n² log n).

Ngoài performance, LAG đọc rõ ràng hơn: "giá trị của row trước trong partition này" — intent tường minh, không cần đọc WHERE condition để hiểu logic.

Q3
Running total SUM(x) OVER (ORDER BY date) không có frame explicit — output thế nào? Edge case nào cần chú ý?

Khi có ORDER BY mà không khai báo frame, PostgreSQL dùng default RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — hành vi running total, mỗi row nhận tổng tích lũy.

Edge case quan trọng: default là RANGE, không phải ROWS. Nếu có nhiều row cùng giá trị date (tie), RANGE xem chúng là cùng peer group và tính tổng đến cuối peer group — tất cả row cùng ngày nhận cùng cumulative value (tổng bao gồm cả các row tie). Dùng ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW để tính tích lũy từng row vật lý một, cho kết quả "bậc thang" thay vì "nhảy".

Q4
Phân biệt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW vs RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW khi data có gap ngày?

ROWS BETWEEN 6 PRECEDING: lấy đúng 6 row vật lý trước current row, bất kể khoảng cách thời gian. Nếu data có gap (ngày 1, 2, 3 rồi nhảy đến ngày 10), moving average tính trên 7 ngày nhưng trải dài 10 ngày lịch — không đúng semantic "7 ngày liên tiếp".

RANGE BETWEEN INTERVAL '7 days' PRECEDING: lấy mọi row có date >= current_date - 7 days — đúng semantic "7 ngày lịch" dù data có gap. Ngày gap không có row, không có row nào trong window đó, không ảnh hưởng đến count.

Rule thực chiến: nếu muốn "N ngày gần nhất theo lịch", dùng RANGE BETWEEN INTERVAL. Nếu muốn "N data point gần nhất", dùng ROWS BETWEEN N PRECEDING.

Q5
Gap-and-island: vì sao day - ROW_NUMBER() * INTERVAL '1 day' cho cùng group_key với các ngày liên tiếp?

Với chuỗi ngày liên tiếp 2026-05-01, 02, 03ROW_NUMBER = 1, 2, 3:

  • Row 1: 2026-05-01 - 1 day = 2026-04-30
  • Row 2: 2026-05-02 - 2 days = 2026-04-30
  • Row 3: 2026-05-03 - 3 days = 2026-04-30

Mỗi ngày tăng 1 và ROW_NUMBER cũng tăng 1 → hiệu luôn là hằng số. Khi có gap (nhảy từ 05-03 đến 05-05), ngày tăng 2 nhưng ROW_NUMBER chỉ tăng 1 → hiệu thay đổi → group_key khác. Group by group_key tự nhiên tách streak.

Q6
Frame default thay đổi với ORDER BY gây bug phổ biến gì? Defensive practice là gì?

Bug phổ biến: dev thêm ORDER BY id vào window vì muốn kết quả có thứ tự nhất quán, vô tình biến SUM(x) OVER (PARTITION BY p) (tổng toàn partition, mỗi row cùng giá trị) thành SUM(x) OVER (PARTITION BY p ORDER BY id) (running total, mỗi row giá trị khác nhau). Query không báo lỗi — kết quả sai silent.

Defensive practice:

  • Luôn khai báo explicit frame khi dùng aggregate window: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING cho "tổng partition", ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW cho running total.
  • Không thêm ORDER BY vào window nếu không cần thứ tự — chỉ ORDER BY khi logic phụ thuộc vào thứ tự (ranking, lag/lead, running).
  • Test với dataset có tie hoặc nhiều row cùng ngày để verify behavior thực tế.
Q7
TaskFlow cần 'longest streak ngày có task done per user'. Phác thảo thuật toán gap-and-island và window function nào cần dùng?

Dùng gap-and-island 3 bước:

  1. Aggregate per day per user: GROUP BY assignee_id, date_trunc('day', updated_at) để có 1 row per (user, day) — loại duplicate trong ngày.
  2. Tính group_key: day - ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY day) * INTERVAL '1 day' — ngày liên tiếp cho cùng group_key, gap tạo group_key mới.
  3. Aggregate streak: GROUP BY assignee_id, group_key rồi COUNT(*) AS streak_length, MIN(day), MAX(day). Sort ORDER BY streak_length DESC.

Window function cần: ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY day) trong CTE. Không cần RANK hay LAG — gap-and-island chỉ cần ROW_NUMBER để tạo offset đều.

Bài tiếp theo: Mini-challenge: monthly top closer + completion rate

Bài này có giúp bạn hiểu bản chất không?