SQL & Database — Thực chiến PostgreSQL/Mini-challenge: dashboard query TaskFlow — diagnose 3 bug từ M02 concepts
~25 phútTruy vấn cơ bản lượt xem

Mini-challenge: dashboard query TaskFlow — diagnose 3 bug từ M02 concepts

PM yêu cầu dashboard 'tasks active của user X tuần này, due ≤ 3 ngày, paginate 20'. Naive query có 3 bug từ M02.1-M02.6. Fix sang keyset + NULL handling + JOIN tease.

PM gửi Slack lúc 9h sáng: "Mình cần dashboard cho user — list tất cả task active tuần này, due trong 3 ngày tới, sắp theo priority, paginate 20 mỗi trang. Cần xong trước 2h chiều."

Bạn mở editor, 5 phút sau có query chạy được. Deploy lên staging. Test page 1: OK. Test page 5: chậm dần. PM nhắn lại: "Sao task của mình không thấy một đống vậy?" Và team design hỏi tại sao thiếu tên project.

Bài này không dạy concept mới — bài này build query từng bước, identify 3 bug cốt lõi từ M02.1 đến M02.6, fix từng cái, và tease forward sang 4 module tiếp theo. Đây là dạng bug bạn sẽ gặp trong production trước khi có ai kịp review code.

1. Spec chi tiết

Trước khi code, đọc spec kỹ. PM thường nói rất ngắn — nhiệm vụ của bạn là expand thành acceptance criteria đủ để viết query đúng.

FieldSpec
Inputuser_id = 5, cursor tuỳ chọn cho pagination
Filterstatus IN ('todo', 'doing') AND due_at từ now đến now cộng 3 ngày
Sortdue_at ASC NULLS LAST, tiebreaker id ASC
PaginationKeyset, 20 row mỗi trang
Output columnsid, title, status, due_at, days_to_due (computed), project_name

Lưu ý: project_name yêu cầu JOIN với bảng projects. Module 3 của khoá này mới dạy JOIN — phần này tạm dùng project_id làm placeholder.

2. Naive query — 3 bug ẩn

Đây là version viết nhanh trong 5 phút:

-- Naive attempt -- has 3 bugs
SELECT id, title, status, due_at,
       (due_at - now()) AS time_to_due
FROM tasks
WHERE assignee_id != 5          -- BUG 1: wrong operator
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at
LIMIT 20 OFFSET (2 - 1) * 20;  -- BUG 2: OFFSET pagination
-- BUG 3: project_name missing, needs JOIN with projects table

Query này chạy không báo lỗi. Page 1 trả về kết quả. Nhưng cả 3 bug đều là silent failure — không có error, chỉ có wrong data hoặc slow data.

3. Identify và fix 3 bug

Bug 1 — NULL trap và wrong operator (M02.2)

Symptom: PM nói "task của mình không thấy". Query trả về 0 row cho user 5.

Root cause: WHERE assignee_id != 5 là "không phải user 5" — ngược hoàn toàn với spec "của user 5". Operator sai. Ngoài ra, != 5 sẽ exclude cả row có assignee_id = NULL vì ba-logic: NULL != 5 cho kết quả UNKNOWN, không phải TRUE — nên row đó cũng bị loại khỏi kết quả (xem M02.2).

-- BEFORE (wrong): tra ve moi task KHONG phai cua user 5
WHERE assignee_id != 5

-- AFTER (correct): chi tra ve task cua user 5
WHERE assignee_id = 5

Khi nào != 5 đúng? Ví dụ: "Hiển thị tất cả task chưa được assign cho user 5 để manager phân công lại." Lúc đó assignee_id != 5 đúng về intent — nhưng vẫn phải handle NULL riêng nếu muốn include task chưa có ai assign:

-- Include unassigned tasks in "re-assign" view
WHERE (assignee_id != 5 OR assignee_id IS NULL)
💡 Cách nhớ

Mỗi khi viết != trong WHERE, hỏi: "Spec có muốn include NULL row không?" Nếu có, thêm OR col IS NULL. Nếu không, ghi comment giải thích intent để reviewer không nhầm.

Bug 2 — OFFSET pagination chậm dần (M02.3)

Symptom: Page 1 trả về trong 15ms. Page 5 mất 45ms. Page 100 mất 80ms. Với dashboard infinite scroll, user cuộn thêm là app chậm thêm.

Root cause: LIMIT 20 OFFSET (page - 1) * 20 buộc PostgreSQL đọc và skip toàn bộ row trước page hiện tại mỗi lần query. Page 100 = skip 1980 row. Page 1000 = skip 19980 row. Cost tăng tuyến tính (xem M02.3).

-- BEFORE (OFFSET): cost tang tuyen tinh, page 1000 skip 19980 rows
LIMIT 20 OFFSET (page - 1) * 20

-- AFTER (keyset): cursor = (due_at, id) cua row cuoi page truoc
-- Page 1: cursor = NULL (no previous row)
-- Page 2+: cursor = last row of previous page
AND (
  $cursor_due_at IS NULL
  OR (due_at, id) > ($cursor_due_at, $cursor_id)
)
ORDER BY due_at ASC NULLS LAST, id ASC
LIMIT 20

Keyset seek thẳng đến cursor row qua index — cost không đổi dù ở trang 1 hay trang 10000.

Pitfall — keyset và NULL cursor cho page 1

Page 1 không có cursor (không có row trước đó). Nếu bạn viết (due_at, id) > ($cursor_due_at, $cursor_id) mà không handle NULL cursor, page 1 trả về 0 row. Phải check $cursor_due_at IS NULL để bỏ qua điều kiện keyset khi cursor chưa có.

-- Handle page 1 (cursor = NULL) va page N+ (cursor co gia tri)
AND (
  $cursor_due_at IS NULL
  OR (due_at, id) > ($cursor_due_at, $cursor_id)
)

Bug 3 — Missing project_name, cần JOIN (Module 3 tease)

Symptom: Design team hỏi tại sao dashboard không hiển thị tên project. Spec yêu cầu column project_name nhưng bảng tasks chỉ có project_id.

Root cause: tasks table lưu foreign key project_id trỏ đến bảng projects. Để lấy project_name, cần JOIN hai bảng. JOIN chưa được dạy trong M02 — Module 3 của khoá này sẽ cover.

-- Tease: version sau khi hoc Module 3
SELECT t.id, t.title, t.status, t.due_at,
       p.name AS project_name  -- JOIN column from projects table
FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.assignee_id = 5
  -- ...rest of WHERE clause

Tạm thời, query trả về project_id làm placeholder. Client-side có thể dùng một lookup map nếu đã load project list. Đây là trade-off chấp nhận được khi chưa có JOIN.

4. Query đã fix — production-ready cho M02

Sau khi fix Bug 1 và Bug 2 (Bug 3 defer sang Module 3):

SELECT
  id,
  title,
  status,
  due_at,
  -- days_to_due: so ngay con lai, am neu da qua han
  EXTRACT(DAY FROM (due_at - now()))::int AS days_to_due,
  project_id  -- placeholder, Module 3 se JOIN ra project_name
FROM tasks
WHERE assignee_id = 5
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
  -- Keyset pagination: cursor truyen vao tu client
  -- $cursor_due_at = NULL cho page 1, timestamptz cho page 2+
  -- $cursor_id = 0 cho page 1, id cua row cuoi cho page 2+
  AND (
    $cursor_due_at IS NULL
    OR (due_at, id) > ($cursor_due_at, $cursor_id)
  )
ORDER BY due_at ASC NULLS LAST, id ASC
LIMIT 20;

Ghi chú: BETWEEN now() AND now() + INTERVAL '3 days' exclude row có due_at = NULLNULL BETWEEN x AND y cho UNKNOWN — row đó bị filter out. Đây là behavior đúng theo spec (chỉ lấy task có due date). Không cần thêm AND due_at IS NOT NULL riêng.

Forward link: Module 5 của khoá này sẽ phân tích composite index (assignee_id, status, due_at) cho query này — hiện tại nếu chưa có index, query vẫn đúng nhưng chậm trên dataset lớn.

5. Implementation — node-postgres

API route GET /api/dashboard/tasks?cursor=...:

// api/dashboard/tasks.ts
type CursorPayload = { dueAt: string; id: number };

async function getDashboardTasks(
  pool: Pool,
  userId: number,
  cursor: CursorPayload | null
) {
  const sql = `
    SELECT id, title, status, due_at,
           EXTRACT(DAY FROM (due_at - now()))::int AS days_to_due,
           project_id
    FROM tasks
    WHERE assignee_id = $1
      AND status IN ('todo', 'doing')
      AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
      AND ($2::timestamptz IS NULL OR (due_at, id) > ($2, $3))
    ORDER BY due_at ASC NULLS LAST, id ASC
    LIMIT 20
  `;

  // Page 1: cursor = null -> $2 = null, $3 = 0 (ignored when $2 is null)
  // Page N: cursor carries dueAt + id from last row of previous page
  const params = [userId, cursor?.dueAt ?? null, cursor?.id ?? 0];
  const result = await pool.query(sql, params);

  // Encode next cursor from last row
  const lastRow = result.rows[result.rows.length - 1];
  const nextCursor = lastRow
    ? Buffer.from(
        JSON.stringify({ dueAt: lastRow.due_at, id: lastRow.id })
      ).toString('base64')
    : null;

  return { items: result.rows, nextCursor };
}

Client decode cursor từ response, gửi lại trong request tiếp theo. Khi nextCursor = null, đã hết data.

6. Benchmark tự đo

Muốn thấy sự khác biệt? Seed data và đo trực tiếp trong psql:

-- Seed 100k tasks cho user 5 voi due date ngau nhien
INSERT INTO tasks (project_id, assignee_id, title, status, due_at)
SELECT
  (random() * 5 + 1)::int,
  5,
  'Task ' || gs,
  (ARRAY['todo', 'doing', 'done', 'archived'])[(random() * 3 + 1)::int],
  now() + (random() * 14 - 7) * INTERVAL '1 day'
FROM generate_series(1, 100000) AS gs;

-- Bat timing
\timing on

-- OFFSET page 1: skip 0 rows
SELECT * FROM tasks
WHERE assignee_id = 5 AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at NULLS LAST, id
LIMIT 20 OFFSET 0;
-- ~15ms

-- OFFSET page 100: skip 1980 rows
SELECT * FROM tasks
WHERE assignee_id = 5 AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at NULLS LAST, id
LIMIT 20 OFFSET 1980;
-- ~80ms (khoang 5x cham hon page 1)

-- Keyset page 100: seek thang den cursor, khong skip
-- (thay $cursor_due_at va $cursor_id bang gia tri thuc tu page 99)
SELECT * FROM tasks
WHERE assignee_id = 5 AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
  AND (due_at, id) > ('2025-05-06 10:00:00', 1980)
ORDER BY due_at NULLS LAST, id
LIMIT 20;
-- ~12ms (xap xi constant, khong phu thuoc page number)

7. Tease forward — 4 điều dashboard này cần thêm

Query đã đúng về logic, nhưng production-grade cần thêm:

Cần thêmModule
project_name — JOIN tasks với projectsModule 3 của khoá này — JOIN deep
Composite index (assignee_id, status, due_at) để query nhanh trên 1M rowModule 5 của khoá này — indexing strategy
EXPLAIN ANALYZE để verify Index Scan thay vì Seq ScanModule 7 của khoá này — query planner
Race condition khi 2 user cùng assign task, tránh double-assignModule 6 của khoá này — transactions và MVCC

Dashboard query đầy đủ sẽ touch 4 module. Bài này là entry point — bạn đã viết được query đúng logic, fix 2 bug silent, và biết chính xác cần học gì tiếp theo.

8. Deliverable

Sau khi hoàn thành bài này, bạn nên có 3 artifact:

dashboard_naive.sql — version gốc với 3 bug được comment rõ:

-- dashboard_naive.sql
-- Bug 1: assignee_id != 5 should be = 5
-- Bug 2: OFFSET pagination, replace with keyset
-- Bug 3: missing project_name, needs JOIN (Module 3)
SELECT id, title, status, due_at, (due_at - now()) AS time_to_due
FROM tasks
WHERE assignee_id != 5
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
ORDER BY due_at
LIMIT 20 OFFSET 0;

dashboard_fixed.sql — version đã fix Bug 1 và Bug 2, tease Bug 3:

-- dashboard_fixed.sql
-- Fixed: assignee_id = 5 (Bug 1)
-- Fixed: keyset pagination (Bug 2)
-- TODO (Module 3): JOIN projects p ON tasks.project_id = p.id -> project_name
SELECT id, title, status, due_at,
       EXTRACT(DAY FROM (due_at - now()))::int AS days_to_due,
       project_id  -- replace with p.name after Module 3
FROM tasks
WHERE assignee_id = 5
  AND status IN ('todo', 'doing')
  AND due_at BETWEEN now() AND now() + INTERVAL '3 days'
  AND ($cursor_due_at IS NULL OR (due_at, id) > ($cursor_due_at, $cursor_id))
ORDER BY due_at ASC NULLS LAST, id ASC
LIMIT 20;

Short note — map bug sang lesson:

BugLesson
!= 5 thay vì = 5 — wrong operator + NULL excludeM02.2 WHERE + NULL three-valued logic
OFFSET chậm tuyến tínhM02.3 ORDER BY + pagination
Missing project_name — thiếu JOINModule 3 — JOIN, aggregation, window

9. Tóm tắt

  • 3 bug của naive query: wrong operator != 5 (M02.2), OFFSET pagination chậm tuyến tính (M02.3), thiếu JOIN để lấy project_name (Module 3).
  • != 5 trong WHERE exclude cả row có assignee_id = NULL do three-valued logic — silent fail, không báo lỗi.
  • Naive OFFSET page 100 chậm gấp 5 lần page 1; keyset với cursor giữ constant time bất kể trang số mấy.
  • Keyset page 1 cần handle cursor = NULL bằng OR $cursor_due_at IS NULL — không có clause này, page 1 trả về 0 row.
  • BETWEEN now() AND now() + INTERVAL '3 days' tự exclude NULL due_at — không cần thêm IS NOT NULL.
  • node-postgres encode cursor bằng base64 JSON {'{'}dueAt, id{'}'} để client opaque với implementation detail.
  • Dashboard production-ready cần thêm 4 module: M03 JOIN, M05 index, M06 race condition, M07 EXPLAIN.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao keyset với NULLS LAST cần handle NULL cursor riêng cho page 1? Nếu bỏ qua, query trả về gì?

Keyset condition là (due_at, id) > ($cursor_due_at, $cursor_id). Page 1 không có row trước đó nên cursor chưa có giá trị — client gửi cursor = null. Nếu bỏ qua NULL check, PostgreSQL evaluate (due_at, id) > (NULL, 0) — so sánh với NULL cho UNKNOWN, không phải TRUE. WHERE chỉ pass row khi predicate là TRUE, nên page 1 trả về 0 row dù table có data.

Fix bằng cách thêm $cursor_due_at IS NULL làm short-circuit: khi cursor chưa có, bỏ qua toàn bộ keyset condition và trả về 20 row đầu tiên theo ORDER BY. Đây chính là NULL three-valued logic từ M02.2 áp dụng trong keyset pattern.

Q2
Phân biệt assignee_id != 5assignee_id = 5 trong context TaskFlow. Cho ví dụ business case nào dùng != đúng, và khi đó cần handle NULL thế nào.

assignee_id = 5: chỉ trả về task được gán cho user 5 — dùng cho "my tasks" dashboard. NULL row bị exclude tự động (không phải bug trong context này).

assignee_id != 5: trả về task không phải của user 5 — nhưng exclude cả NULL row vì NULL != 5 là UNKNOWN. Business case đúng: manager xem tất cả task của team trừ task mình đang handle.

Khi dùng != mà muốn include unassigned tasks (NULL), phải viết tường minh:

WHERE (assignee_id != 5 OR assignee_id IS NULL)

Rule: mỗi khi có !=, hỏi spec: NULL row nên include hay exclude? Ghi comment intent vào query.

Q3
Dashboard có 50k task của user. Page 1 đo được 12ms, page 50 đo được 80ms với OFFSET. Khi nào đáng migrate sang keyset? Liệt kê ít nhất 2 điều kiện.

Đáng migrate khi đáp ứng ít nhất 2 trong số các điều kiện sau:

  • Latency degradation đã thấy được: nếu page 50 chậm hơn page 1 hơn 3-5 lần và user thực sự scroll đến đó, latency tăng là bug UX thật, không phải lý thuyết.
  • Infinite scroll hoặc cursor-based client: nếu app đã dùng "Load more" thay vì "Go to page 47", client đã có cơ chế nhận cursor — migration cost thấp.
  • Dataset dự kiến tăng trưởng: 50k task hôm nay, 500k task trong 6 tháng. OFFSET sẽ tệ hơn 10 lần khi data tăng 10 lần.
  • Không có jump-to-page requirement: keyset không hỗ trợ nhảy thẳng đến "page 47" — nếu UX cần pagination số thì OFFSET đúng hơn.

Nếu user chỉ xem vài trang đầu và table nhỏ hơn 10k row, 80ms chưa đủ đau để justify refactor. Đo trước, optimize sau.

Q4
JOIN với bảng projects chưa học ở M02. Bạn placeholder thế nào để API hoạt động ngay mà client vẫn hiểu sẽ enhance ở Module 3?

Hai cách placeholder thực tế:

Option 1 — Trả về project_id thay project_name: query giữ nguyên, response có {"project_id": 3}. Client dùng lookup map (đã load project list riêng) để hiển thị tên. Ưu: không cần thay đổi API contract khi thêm JOIN sau. Nhược: client cần extra data để render.

Option 2 — Trả về project_name: null tạm thời: query thêm null AS project_name, response có field đúng tên nhưng giá trị null. Client render "—" hoặc skeleton. API contract ổn định, dễ replace null bằng JOIN value sau. Đây là pattern phổ biến cho incremental rollout.

SELECT id, title, status, due_at,
       null::text AS project_name -- TODO: JOIN projects in Module 3
FROM tasks ...

Ghi comment -- TODO: JOIN projects in Module 3 trong code để reviewer biết đây là intentional placeholder, không phải forgot.

Bài tiếp theo: Module 3 — Join, aggregation và window functions

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