SQL & Database — Thực chiến PostgreSQL/ORDER BY + pagination — vì sao OFFSET 100k chậm 200 lần
~18 phútTruy vấn cơ bản lượt xem

ORDER BY + pagination — vì sao OFFSET 100k chậm 200 lần

OFFSET cost tỷ lệ skip rows. Keyset pagination O(1). NULL ordering. Stable sort cần tiebreaker. Pattern infinite scroll dashboard.

Dashboard TaskFlow hiển thị danh sách task theo thời gian tạo. Page 1 trả về trong 12ms. Page 100 (OFFSET 1000 LIMIT 10): 250ms. Page 5000 (OFFSET 50000): 4 giây. Schema không thay đổi, không có thêm query nào khác, chỉ số trang tăng dần — nhưng app ngày càng chậm khi người dùng cuộn thêm. Vì sao OFFSET càng cao càng chậm, và keyset pagination vẫn chỉ vài ms dù đang ở page 10000?

Bài này giải thích cơ chế OFFSET cost, NULL ordering trap, stable sort tiebreaker, và pattern keyset pagination cho production infinite scroll.

1. Analogy — OFFSET vs keyset

Hình dung thư viện có một cuốn sách dày 500.000 trang, được đánh số trang liên tục. Bạn muốn đọc 10 trang bắt đầu từ trang 100.001.

Cách A (OFFSET): Mở từ trang 1, đếm và lật qua 100.000 trang, rồi đọc 10 trang tiếp theo. Trang 200.001 thì lật qua 200.000 trang. Càng đọc sâu, càng mất công lật.

Cách B (keyset): Bạn đã đánh dấu trang 100.000 bằng bookmark. Mở thẳng bookmark đó, đọc 10 trang tiếp theo. Không cần lật bất kỳ trang nào trước đó — dù bookmark ở trang 1 hay trang 499.000, chi phí như nhau.

Thư việnSQL pagination
Lật qua 100k trangSkip 100k rows với OFFSET
Đọc 10 trang sau đóFetch 10 rows với LIMIT
Càng sâu càng tốn công lậtOFFSET cost tăng tuyến tính
Bookmark trang bất kỳCursor (last seen row value)
Mở thẳng bookmarkIndex seek đến cursor row
Chi phí không đổi dù bookmark ở đâuKeyset: O(log N) bất kể page
💡 Cách nhớ

OFFSET = đọc sách từ đầu rồi skip. Keyset = mở thẳng bookmark. Thư viện với sách 500k trang — bookmark luôn thắng.

2. ORDER BY cú pháp + NULL ordering

Cú pháp đầy đủ và các pattern thường gặp:

-- Single column, ascending (default)
SELECT * FROM tasks
ORDER BY created_at;       -- ASC la default

-- Multi-column sort: priority truoc, sau do due_at, sau do tiebreaker id
SELECT * FROM tasks
ORDER BY status, due_at NULLS LAST, id;

-- Explicit NULL position (portable cross-engine)
SELECT * FROM tasks
ORDER BY due_at ASC NULLS LAST,     -- NULL xuat hien cuoi
         created_at DESC NULLS FIRST;  -- NULL xuat hien dau

NULL ordering mặc định của PostgreSQL:

DirectionPG defaultÝ nghĩa
ASCNULLS LASTNULL xếp sau mọi giá trị
DESCNULLS FIRSTNULL xếp trước mọi giá trị

PG default thường là điều bạn muốn — ASC NULLS LAST đặt "chưa có hạn" sau task có hạn, DESC NULLS FIRST đặt "chưa xác định" trước. Nhưng để code portable sang MySQL/SQLite và intent rõ ràng hơn, nên viết explicit NULLS FIRST / NULLS LAST.

Stable sort và tiebreaker:

Khi nhiều row có cùng giá trị ở column sort, thứ tự trả về là nondeterministic — PostgreSQL không cam kết thứ tự giữa các row tied. Hai lần chạy cùng query có thể trả về thứ tự khác nhau. Fix: thêm column unique làm tiebreaker cuối cùng.

-- Khong stable: cac task cung status, due_at co the doi thu tu giua 2 query
SELECT * FROM tasks
ORDER BY status, due_at NULLS LAST;

-- Stable: id la primary key, unique, dam bao thu tu quyet dinh
SELECT * FROM tasks
ORDER BY status, due_at NULLS LAST, id;

3. OFFSET cơ chế — vì sao chậm

PostgreSQL executor chạy LIMIT 10 OFFSET 100000 theo ba bước:

  1. Scan + sort rows theo ORDER BY clause.
  2. Đếm và skip 100.000 rows đầu tiên.
  3. Return 10 rows tiếp theo.

Bước 2 là vấn đề: database phải thực sự đọc và xử lý 100.000 rows đó, chỉ để bỏ đi. Cost tỷ lệ thuận với OFFSET + LIMIT. Gấp đôi offset = gấp đôi thời gian.

-- Bang tasks voi 5 trieu row
EXPLAIN ANALYZE
SELECT * FROM tasks
ORDER BY created_at DESC
LIMIT 10 OFFSET 100000;
-- Plan: Limit -> Sort -> Seq Scan
-- actual time: ~850ms (phai xu ly 100010 rows)

EXPLAIN ANALYZE
SELECT * FROM tasks
ORDER BY created_at DESC
LIMIT 10 OFFSET 1000000;
-- actual time: ~8500ms (10x worse -- 10x offset = 10x cost)

Nếu column sort có index: database dùng index scan thay vì seq scan + sort, nhưng vẫn phải traverse 100k index entries để đếm và skip — chậm hơn linear với offset.

Module 7 của khoá này sẽ đi sâu vào cách đọc EXPLAIN plan và nhận biết node nào gây chậm.

4. Keyset pagination — O(1) per page

Thay vì "skip N rows đầu tiên", keyset pagination dùng giá trị của row cuối cùng đã thấy làm cursor — chỉ lấy rows đứng sau cursor đó.

-- Page 1: khong co cursor
SELECT id, title, created_at
FROM tasks
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Tra ve 10 rows, row cuoi co: created_at = '2026-05-04 10:00:00', id = 9991

-- Page 2: dung (created_at, id) cua row cuoi lam cursor
SELECT id, title, created_at
FROM tasks
WHERE (created_at, id) < ('2026-05-04 10:00:00', 9991)
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- Plan: Limit -> Index Scan Backward on (created_at DESC, id DESC)
-- actual time: ~4ms -- bat ke page nao

Với B-tree composite index trên (created_at DESC, id DESC), database thực hiện index seek trực tiếp đến cursor thay vì scan từ đầu. Cost là O(log N) cho seek + O(LIMIT) để fetch — bất kể page nào.

Tradeoff keyset vs OFFSET:

KeysetOFFSET
Cost per pageO(log N) — hằng sốO(offset + limit) — tuyến tính
Scale với deep pageTốtXấu
Jump to page N trực tiếpKhông hỗ trợHỗ trợ
Consistency khi insert mớiRow mới không shift pageRow mới có thể shift toàn bộ page
Cursor encodingCần (base64 JSON)Chỉ cần số trang
Phù hợpInfinite scroll, feedAdmin panel jump-to-page

5. Tuple comparison — cú pháp (a, b) < (x, y)

PostgreSQL (và ANSI SQL) hỗ trợ tuple comparison — so sánh nhiều column cùng lúc theo lexicographic order:

-- Tuong duong: created_at < '2026-05-04' OR (created_at = '2026-05-04' AND id < 9991)
WHERE (created_at, id) < ('2026-05-04 10:00:00', 9991)

Cú pháp này ngắn gọn hơn điều kiện AND/OR tương đương, và PostgreSQL query planner nhận biết được để tối ưu với composite B-tree index.

Pitfall — tuple comparison và NULL

Nếu cursor column có thể chứa NULL, tuple comparison (a, b) < (x, y) không hoạt động đúng — NULL không so sánh được bằng toán tử thông thường. Khi due_at có NULL và bạn dùng nó làm cursor column, phải tách điều kiện hoặc dùng COALESCE để thay NULL bằng sentinel value. Module 5 của khoá này sẽ phân tích composite index và NULL handling chi tiết hơn.

6. Pitfall — pagination không có ORDER BY

-- SAI: page 1 va page 2 co the overlap hoac miss rows
SELECT id, title FROM tasks LIMIT 10 OFFSET 0;
SELECT id, title FROM tasks LIMIT 10 OFFSET 10;

-- PostgreSQL khong cam ket thu tu khi khong co ORDER BY.
-- Planner co the chon seq scan, index scan, hoac bitmap scan
-- tuy thuoc vao statistics va plan cache -- thu tu row co the doi.

-- DUNG: luon co ORDER BY
SELECT id, title FROM tasks ORDER BY id LIMIT 10 OFFSET 0;
SELECT id, title FROM tasks ORDER BY id LIMIT 10 OFFSET 10;
Pitfall — LIMIT/OFFSET không có ORDER BY là nondeterministic

Không có ORDER BY, PostgreSQL tự do chọn bất kỳ thứ tự nào phụ thuộc vào execution plan. Giữa hai query liên tiếp, nếu plan thay đổi (do autovacuum, statistics update, plan cache miss), page 2 có thể chứa rows đã hiển thị ở page 1, hoặc miss một số rows hoàn toàn. Luôn thêm ORDER BY khi dùng LIMIT/OFFSET.

7. Khi nào OFFSET vẫn ổn

OFFSET không phải luôn sai — context quyết định:

Tình huốngNên dùngLý do
Public infinite scroll feedKeysetOFFSET chậm ở deep page, data shift khi insert mới
Admin panel jump-to-page, table nhỏ dưới 10k rowOFFSET ổnTable nhỏ, OFFSET cost chấp nhận được
Real-time feed (Twitter-style)Keyset bắt buộcInsert liên tục làm OFFSET shift mạnh
Internal report, 1-2 người dùngOFFSET ổnTraffic thấp, page thường nhỏ
Page 1-10 trên bất kỳ tableOFFSET ổnOffset nhỏ, cost không đáng kể
Analytics dashboard phân trang sâuKeysetOFFSET 50k+ row = vài giây per query

Pattern quyết định nhanh: nếu user có thể scroll/page sâu tùy ý, hoặc nếu data liên tục insert mới — chọn keyset. Nếu dataset nhỏ và UI có jump-to-page cố định — OFFSET ổn.

8. Applied — TaskFlow infinite scroll

Frontend hiển thị task feed 20 task/lần, scroll tự động load thêm. API design:

// Request 1 -- page dau tien
GET /api/tasks?limit=20

// Response
{
  "items": [...],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wNCAxMDowMDowMCIsImlkIjo5OTkxfQ=="
}

// Request 2 -- dung cursor tu response truoc
GET /api/tasks?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0wNCAxMDowMDowMCIsImlkIjo5OTkxfQ==

Backend node-postgres:

// Decode cursor tu base64 JSON
const cursor = req.query.cursor
  ? JSON.parse(Buffer.from(String(req.query.cursor), 'base64').toString())
  : null;

// Build query dua vao cursor
const { rows } = cursor
  ? await pool.query(
      `SELECT id, title, created_at FROM tasks
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [cursor.created_at, cursor.id, limit]
    )
  : await pool.query(
      `SELECT id, title, created_at FROM tasks
       ORDER BY created_at DESC, id DESC
       LIMIT $1`,
      [limit]
    );

// Encode cursor tu row cuoi cung
const lastRow = rows[rows.length - 1];
const nextCursor = lastRow
  ? Buffer.from(JSON.stringify({ created_at: lastRow.created_at, id: lastRow.id })).toString('base64')
  : null;

Index cần thiết cho keyset query trên:

-- Composite index phai match thu tu ORDER BY
CREATE INDEX idx_tasks_created_at_id ON tasks (created_at DESC, id DESC);

9. Deep Dive — Pagination performance

📚 Deep Dive — Pagination performance

Ghi chú: Use The Index Luke cho intuition + perf rationale. PG docs cho cú pháp chính xác và behavior đảm bảo. Đọc theo thứ tự đó.

Liên kết khoá học khác

10. Tóm tắt

  • ORDER BY cần explicit NULL position (NULLS FIRST / NULLS LAST) để portable và intent rõ ràng — PG default khác MySQL/SQLite.
  • Stable sort cần tiebreaker — thêm column unique (, id) ở cuối ORDER BY để tránh nondeterministic order khi có tied rows.
  • OFFSET cost = O(offset + limit) — database phải đọc và bỏ toàn bộ rows trước cursor. Tăng tuyến tính, không scale với deep page.
  • Keyset pagination = O(log N) + O(LIMIT) — seek trực tiếp đến cursor qua B-tree index. Cost không đổi dù page 1 hay page 10000.
  • Tuple comparison (a, b) < (x, y) là cú pháp keyset chuẩn — ngắn hơn AND/OR tương đương và planner tối ưu được với composite index.
  • LIMIT/OFFSET không có ORDER BY = nondeterministic — page overlap hoặc miss rows, tùy execution plan.
  • Keyset không hỗ trợ jump-to-page trực tiếp — dùng OFFSET khi cần tính năng đó và table đủ nhỏ.
  • Forward links: Module 5 của khoá này (composite B-tree index cho keyset) + Module 7 (EXPLAIN ANALYZE đọc OFFSET cost).

11. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao OFFSET 100000 chậm hơn OFFSET 100 khoảng 1000 lần — cơ chế executor diễn ra thế nào?

PostgreSQL executor xử lý LIMIT 10 OFFSET N theo ba bước: scan + sort rows theo ORDER BY, đếm và skip N rows đầu, trả về 10 rows tiếp. Bước skip không phải seek — database phải thực sự đọc và xử lý N rows đó để đếm đủ, sau đó bỏ đi. Cost = O(N + LIMIT). OFFSET 100000 phải xử lý 100.010 rows; OFFSET 100 chỉ xử lý 110 rows — chênh nhau khoảng 1000 lần. Ngay cả với index scan, database vẫn phải traverse đủ N index entries trước khi bắt đầu collect rows.

Q2
Phân biệt khi nào dùng keyset pagination thay vì OFFSET, và khi nào OFFSET vẫn chấp nhận được. Cho 2 ví dụ cụ thể cho mỗi loại.

Dùng keyset khi:

  • Public infinite scroll feed — TaskFlow task list người dùng scroll xuống tùy ý, có thể page 5000+. OFFSET gây 4+ giây ở deep page.
  • Real-time feed Twitter-style — tasks liên tục được insert mới. OFFSET shift toàn bộ page khi có insert, keyset không bị ảnh hưởng.

OFFSET vẫn ổn khi:

  • Admin panel "Quản lý task" với jump-to-page và table nhỏ dưới 10k row — OFFSET cost chấp nhận được, UI cần nhảy thẳng trang.
  • Internal report chạy 1-2 lần ngày, data tĩnh, ít người dùng — traffic thấp và page thường nhỏ.
Q3
Bạn dùng keyset với `WHERE (created_at, id) < ($1, $2)`. Cursor column `created_at` có thể NULL — vấn đề gì xảy ra? Cách handle?

Tuple comparison (a, b) < (x, y) không hoạt động đúng khi column có NULL — trong SQL, bất kỳ so sánh nào với NULL đều trả về NULL (không phải TRUE/FALSE), nên rows có created_at IS NULL không bao giờ vượt qua điều kiện WHERE. Tùy vào NULLS LAST/FIRST, các NULL row có thể bị miss hoàn toàn.

Cách fix: dùng COALESCE thay NULL bằng sentinel value hợp lý với sort direction (vd COALESCE(created_at, '9999-12-31') cho ASC NULLS LAST), hoặc tách điều kiện thành hai branch: một cho cursor không NULL, một cho cursor NULL. Module 5 của khoá này phân tích composite index và NULL handling chi tiết hơn.

Q4
Bạn chạy `SELECT id, title FROM tasks LIMIT 10 OFFSET 10` hai lần liên tiếp mà không có ORDER BY. Kết quả có thể khác nhau dù không có insert/update giữa hai lần chạy — vì sao?

Không có ORDER BY, PostgreSQL tự do chọn bất kỳ execution plan nào: seq scan, index scan, bitmap heap scan — tùy thuộc vào statistics, plan cache, và trạng thái internal. Thứ tự rows trả về phụ thuộc vào plan được chọn. Giữa hai lần chạy, planner có thể chọn plan khác (do autovacuum cập nhật statistics, hay cache invalidation) → thứ tự rows thay đổi → OFFSET 10 cắt tại vị trí khác → page 2 khác page 2 lần trước. Đây là lý do tài liệu PG ghi rõ: "rows are delivered in an unspecified order unless ORDER BY is used".

Q5
Frontend infinite scroll Twitter-style cho TaskFlow. Backend nên dùng pagination kiểu nào? Vì sao? Cần index gì?

Keyset pagination — hai lý do chính:

  • Consistency: task mới liên tục được insert. OFFSET shift toàn bộ page mỗi khi có insert — người dùng thấy row trùng hoặc miss row. Keyset dùng cursor cố định, insert mới không ảnh hưởng đến trang đang xem.
  • Performance: infinite scroll có thể xuống page rất sâu. OFFSET 50000 tốn vài giây; keyset luôn O(log N) bất kể page.

Index cần thiết:

CREATE INDEX idx_tasks_created_at_id
ON tasks (created_at DESC, id DESC);

Composite index phải match thứ tự ORDER BY trong keyset query. Planner dùng index seek trực tiếp đến cursor thay vì scan toàn table.

Bài tiếp theo: DISTINCT vs GROUP BY — khi nào dùng cái nào

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