โหมดมืด
บทที่ 2 — Pagination, Filter, Sort, Search
หลังจบบท คุณจะ:
- ออกแบบ pagination ที่ scale (offset + keyset)
- Filter API ที่ flexible + ไม่กลายเป็น "SQL injection"
- Sort multi-field + ranking
- Search ด้วย full-text + suggestion
- Cursor-based pagination สำหรับ feed
1. ทำไม Pagination สำคัญ
GET /users → return 10 ล้าน row → network ตาย + client crash
ต้องจำกัด — เลือก 1 ใน 3 strategy:
Pagination (อ่าน เพ-จิ-เน-ชั่น) = การแบ่งผลลัพธ์เป็น "หน้า" / Cursor (อ่าน เคอร์-เซอร์) = ตัวชี้ตำแหน่ง "ดึงจากตรงนี้ต่อ"
| Strategy | URL | ดี | ไม่ดี |
|---|---|---|---|
| Offset | ?page=2&size=20 | ง่าย, jump ไปหน้าไหนก็ได้ | ช้าเมื่อ deep page, data shift, ห้ามใช้กับ public API ที่ scale |
| Keyset (cursor) | ?after=12345 | เร็วเสมอ, ไม่มี data shift | ไป page ไหนก็ได้ไม่ได้, code ซับซ้อน |
| Cursor (opaque) | ?cursor=eyJpZCI6MTJ9 | hide internal, version-friendly | stateless ถ้าออกแบบเป็น encoded position; stateful ถ้าใช้ server-side cursor |
opaque (อ่าน โอ-เพ้ค) = ทึบ มองไม่เห็นข้างใน — cursor ที่ encode เป็น string ลึกลับ client ไม่ต้องเข้าใจ format
📊 Offset vs Cursor (ภาพรวม)
OFFSET (page=3, size=20):
DB ต้อง scan 40 row ทิ้ง แล้วเอา 20 row ถัดมา
[ row 1..20 ][ row 21..40 ][ ✅ row 41..60 ]
ทิ้ง ทิ้ง เอา
→ ยิ่ง page ลึก ยิ่งช้า
CURSOR (after=id_40):
DB ใช้ index หาตำแหน่ง id=40 แล้วเอา 20 row ถัดมา
↓ jump ด้วย index
[ row 1..40 .................. ][ ✅ row 41..60 ]
เอา
→ เร็วเท่ากันทุกหน้า (O(log N))2. Offset Pagination
วิธี paginate ที่คุ้นเคยและง่ายสุดคือ offset (page + size) — ข้ามไป N รายการแล้วเอามา M รายการ ดีตรงที่กระโดดหน้าได้ (ไปหน้า 5 เลย) และคำนวณ total/totalPages ได้ แต่มีจุดอ่อนเรื่อง performance และข้อมูลซ้ำ/หายเมื่อมีการเพิ่ม-ลบระหว่างหน้า (จะอธิบายต่อในหัวข้อ cursor):
http
GET /api/v1/users?page=2&size=20Response (สมมติ 1-indexed: page=1 คือหน้าแรก):
json
{
"data": [...20 users],
"meta": {
"page": 2,
"size": 20,
"total": 150,
"totalPages": 8,
"hasNext": true,
"hasPrev": true
}
}⚠️ ระวัง page indexing — Spring Data default ใช้ 0-indexed (
page=0คือหน้าแรก) ส่วน REST API ทั่วไปนิยม 1-indexed. ตัวอย่างข้างบนคือ 1-indexed (page=2 →hasPrev: trueเพราะ page=1 มีอยู่). เลือก convention เดียวแล้ว document ให้ชัด
Plain HTTP first (ไม่ใช่ Spring)
ก่อนจะดูโค้ด Spring — ในทุกภาษา URL ก็คือ ?page=...&size=.... backend แค่อ่าน 2 query param นี้ แปลงเป็น SQL LIMIT size OFFSET (page-1)*size เท่านั้น
Spring Boot Implementation
📘
Pageable/@PageableDefault/Page<T>= ของสำเร็จรูปของ Spring Data ที่ห่อpage,size,sortให้ — ถ้าไม่ใช่สาย Java/Spring อ่านเอา idea ว่า "Spring แปลง query param เป็น object ให้ controller ใช้" ก็พอ
java
@GetMapping
public Page<UserResponse> list(
@PageableDefault(size = 20) Pageable pageable
) {
return userRepo.findAll(pageable).map(UserResponse::from);
}Spring แปลง ?page=...&size=...&sort=... เป็น Pageable ให้
URL Convention
?page=0&size=20 # 0-indexed (Spring default)
?page=1&size=20 # 1-indexed
?limit=20&offset=40 # SQL-style
?per_page=20&page=2 # GitHub style→ ทีมเลือก + consistent
⚠️ คำแนะนำปี 2026 สำหรับ public API ที่ scale — offset pagination เป็น anti-pattern เพราะ deep page ช้ามากและข้อมูลซ้ำ/หายเมื่อมี insert/delete. API ใหม่ใน 2026 (Stripe
starting_after, GitHub v4 GraphQL cursor) → cursor-based เป็น default. offset เหมาะกับ admin UI / internal เท่านั้น
⚠️ Offset Problems
Problem 1: Deep Page = ช้า
sql
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;
-- DB ต้อง scan 1M + 20 row, ทิ้ง 1M⚠️ ถ้า column ที่
ORDER BYไม่มี index จะแย่ยิ่งกว่านั้น — DB ต้อง sort ทั้งตารางก่อน (O(N log N)) แล้วค่อย skip 1M row. แม้ใช้ offset น้อย ๆ ก็ช้า
Problem 2: Data Shift
GET /users?page=1 → users 1-20
[someone inserts new user]
GET /users?page=2 → users 20-39 ← user 20 ซ้ำ (shift)3. Keyset Pagination — Solution
ใช้ "last id" ของ page ก่อนเป็น cursor:
http
GET /users?limit=20 # first page
GET /users?limit=20&afterId=12345 # next page (after id 12345)sql
-- First page
SELECT * FROM users ORDER BY id LIMIT 20;
-- Next page
SELECT * FROM users WHERE id > 12345 ORDER BY id LIMIT 20;→ ใช้ index → O(log N) — เร็วเสมอ
O(log N) เป็นวิธีบอก "ความเร็วของอัลกอริทึม" (เรียกว่า Big-O) — แปลเป็นภาษาคนคือ "เร็วแม้ข้อมูลเยอะมาก" ต่อให้ข้อมูลโตเป็นล้านแถว ความช้าก็แทบไม่เพิ่ม (ต่างจาก offset ที่ยิ่ง deep page ยิ่งช้า)
เปรียบเทียบตัวเลข ที่ 1 ล้านแถว:
O(log N)≈ 20 ขั้น (log₂(1,000,000) ≈ 20) /O(N)= 1 ล้านขั้น — ต่างกัน 50,000 เท่า
Response
json
{
"data": [...20 users],
"meta": {
"nextCursor": "12365",
"hasNext": true
}
}Multiple Sort Field — Tuple Comparison (เทียบหลาย field พร้อมกัน)
WHERE (created_at, id) < (...)อ่านยังไง? มันคือการเทียบ "เป็นคู่" แบบ lexicographic (เรียงเหมือนพจนานุกรม) — เทียบcreated_atก่อน ถ้าเท่ากันค่อยใช้idตัดสิน เทียบเท่ากับ:sqlWHERE created_at < '2026-05-18 10:00' OR (created_at = '2026-05-18 10:00' AND id < 999)แต่ syntax tuple อ่านง่ายและ optimizer ใช้ index ได้ดีกว่า
💡 ทำไม
<(น้อยกว่า) ทั้งที่อยาก "ถัดไป"? เพราะเราเรียงแบบ DESC (ใหม่ → เก่า) — "หน้าถัดไป" คือ "เก่ากว่าแถวสุดท้ายของหน้านี้" = ค่า tuple น้อยกว่าเดินตามตัวอย่าง: หน้าแรกได้แถวล่าสุดจนถึง
(2026-05-18 10:00, 999)→ หน้าถัดไปต้องเริ่มจากแถวที่ created_at เก่ากว่า 10:00 (หรือ created_at เท่ากันแต่ id < 999)
sql
-- Sort by created_at DESC, id DESC (กัน duplicate)
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;
-- Next page — last row was (2026-05-18 10:00, 999)
SELECT * FROM posts
WHERE (created_at, id) < ('2026-05-18 10:00', 999)
ORDER BY created_at DESC, id DESC LIMIT 20;⚠️ DB compatibility — PostgreSQL รองรับ tuple comparison เต็มที่และ optimizer ใช้ composite index ได้ดี. MySQL/MariaDB รองรับ syntax แต่ optimizer อาจไม่เลือก index → ตรวจด้วย
EXPLAINทุกครั้ง
⚠️ อย่าใช้ UUID v4 เป็น cursor — UUID v4 ไม่มีลำดับเวลา (random) → tuple comparison ไม่ทำงาน. UUIDv7 (มี timestamp prefix, ordered) หรือ auto-increment id ใช้ได้
⚠️ Keyset Limitations
- ❌ Jump ไป page 50 ไม่ได้ — sequential เท่านั้น
- ❌ Total count ไม่ได้ (มัก) — แสดง "load more" แทน
- ⚠️ Sort field ต้อง unique หรือ + id เป็น tiebreaker
4. Opaque Cursor — Hide Implementation
🚀 โซนขั้นสูง — ข้ามได้ ถ้าเพิ่งเริ่ม: opaque cursor เป็นการต่อยอดจาก keyset ให้ "ซ่อน" รายละเอียดภายใน เริ่มต้นใช้ offset หรือ keyset ธรรมดาก่อนก็พอ แล้วค่อยกลับมาอ่านตอนต้องทำ feed จริงจัง
http
GET /users?cursor=eyJpZCI6MTIzNDV9cursor = base64-encoded JSON: {"id": 12345}
base64 (อ่าน เบส-ซิก-สตี้-โฟร์) = วิธีเข้ารหัสข้อความให้เป็นตัวอักษรที่ปลอดภัยใส่ใน URL ได้ — ไม่ใช่การเข้ารหัสลับ ใครก็ decode (ถอด) กลับมาดูได้ ดังนั้นห้ามใส่ข้อมูลลับลงไป มันแค่ทำให้ cursor ดู "ทึบ" (opaque) เพื่อซ่อน format ภายในจาก client เท่านั้น
ข้อดี:
- Hide internal — client ไม่เห็น id
- Version safe — เปลี่ยน format ได้
- Encode multiple field (cursor = encoded id + timestamp)
📘 Base64 ในตัวอย่างนี้ —
Base64.encodeแปลง JSON string เป็น string ที่ใส่ใน URL ได้ปลอดภัย (ไม่มี",{,}ที่ต้อง escape).Base64.decodeทำกลับด้าน. ใน Java จริงคือjava.util.Base64.getUrlEncoder()/getUrlDecoder()(ใช้-_แทน+/เพื่อปลอดภัยใน URL)
java
// เข้ารหัส (encode) — แปลง JSON เป็น cursor
String cursor = Base64.encode("{\"id\":12345,\"ts\":\"2026-05-18T10:00\"}");
// ถอดรหัส (decode) — แปลง cursor กลับเป็นข้อมูล
CursorData data = objectMapper.readValue(Base64.decode(cursor), CursorData.class);
return userRepo.findAfter(data.id(), data.ts(), 20);ใช้ใน: Facebook, Twitter, Stripe
5. Pagination Headers (Alternative)
แทนที่จะใส่ pagination meta ใน response body บาง API ใส่ใน HTTP header แทน — Link header (RFC 8288, เดิม RFC 5988) บอก URL หน้า prev/next/first/last และ X-Total-Count บอกจำนวนรวม ข้อดีคือ body มีแต่ data ล้วน (GitHub ใช้แบบนี้):
แทนที่ใส่ meta ใน body:
http
GET /users?page=2&size=20
Response:
HTTP/1.1 200 OK
Link: </users?page=1&size=20>; rel="prev",
</users?page=3&size=20>; rel="next",
</users?page=1&size=20>; rel="first",
</users?page=8&size=20>; rel="last"
X-Total-Count: 150📘
Linkheader format =<URL>; rel="ความสัมพันธ์", <URL>; rel="ความสัมพันธ์", ...— ใช้ comma คั่นหลายลิงก์, URL ครอบด้วย<...>, แต่ละลิงก์มีrel=บอกความสัมพันธ์ (prev, next, first, last)
GitHub ใช้ pattern นี้ — Link header ตาม RFC 8288 (Web Linking, obsoletes RFC 5988 ตั้งแต่ ต.ค. 2017)
6. Filtering
filtering ให้ client เลือกเฉพาะข้อมูลที่ต้องการผ่าน query parameter — ตั้งแต่ filter ง่าย ๆ (?status=active), range (?priceMin=), list (IN), boolean ไปจนถึง dynamic filter ด้วย Specification ⚠️ อย่าออกแบบ query DSL ซับซ้อนในURL ให้ใช้ parameter เฉพาะเจาะจงที่อ่านง่ายและปลอดภัยกว่า:
Simple Query Parameter
GET /users?status=active&role=adminjava
@GetMapping
public Page<UserResponse> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String role,
Pageable pageable
) {
return userRepo.findByStatusAndRole(status, role, pageable);
}Range Filter
GET /products?priceMin=100&priceMax=500
GET /orders?createdAfter=2026-01-01&createdBefore=2026-06-30List Filter (IN)
GET /users?status=active,pending,suspendedjava
@RequestParam(required = false) List<String> status
// ⚠️ Spring split "," ให้ "เฉพาะ" ตอนรับเป็น List<String> และ default Converter ทำงาน
// บางสถานการณ์ (custom Converter, encoding) ได้ ["active,pending"] แทน ["active","pending"]
// ปลอดภัยกว่า: ใช้ ?status=active&status=pending (ส่ง key ซ้ำ)⚠️ Multi-value query param มี 2 convention:
?status=a,b,c(comma-separated) — Spring split ให้ในกรณีส่วนใหญ่ แต่ encoding ผิดได้?status=a&status=b&status=c(key ซ้ำ) — มาตรฐาน HTTP, ทุก backend รองรับ, แนะนำ
Boolean
GET /products?inStock=true⚠️ Avoid Overcomplexity
❌ GET /users?filter=status:active,role:admin AND (age>18 OR isVerified:true)📘 DSL = Domain-Specific Language = ภาษาเล็ก ๆ ที่ออกแบบเฉพาะงาน (เช่น SQL = DSL สำหรับ query database). ตัวอย่างข้างบนคือ "query DSL" ใน URL — อ่านยาก, parse ยาก, เปิดช่อง injection ได้ถ้าไม่ระวัง
→ ออกแบบ specific query parameter แทน — readable + safe
Spring Boot Specification (dynamic filter)
🚀 โซนขั้นสูง — ข้ามได้ ถ้ายังไม่คุ้นกับ JPA: Specification คือวิธีสร้าง query แบบ "ประกอบเงื่อนไขตามที่ user ส่งมา" ของ Spring. lambda
(root, q, cb) -> ...คือ JPA Criteria API —rootแทน entity,cbแทน CriteriaBuilder ที่สร้างเงื่อนไขทีละชิ้น (cb.equal,cb.gt, ...). มือใหม่ใช้@RequestParamธรรมดา (แบบด้านบน) ไปก่อนได้
Pseudo-code เข้าใจง่ายกว่า:
filters = []
if (status != null) filters.add("status = " + status)
if (role != null) filters.add("role = " + role)
sql = "SELECT * FROM users WHERE " + AND(filters)โค้ดจริง Spring Specification:
java
public class UserSpec {
public static Specification<User> withStatus(String status) {
return (root, q, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<User> withRole(String role) {
return (root, q, cb) -> role == null ? null : cb.equal(root.get("role"), role);
}
}
@GetMapping
public Page<UserResponse> list(@RequestParam(required = false) String status, ...) {
Specification<User> spec = Specification.where(withStatus(status)).and(withRole(role));
return userRepo.findAll(spec, pageable).map(UserResponse::from);
}7. Sort
การจัดเรียงผลลัพธ์ทำผ่าน ?sort= — มีหลาย convention (-name แบบ Stripe, name,desc แบบ Spring) เลือกแบบเดียวให้ consistent ⚠️ จุดที่พลาดง่ายและเป็นช่องโหว่ security คือต้อง whitelist field ที่ sort ได้ ไม่งั้น user อาจ sort ด้วย column ภายใน (password_hash) จน leak ข้อมูล:
GET /users?sort=name # ascending
GET /users?sort=-name # descending (Stripe / JSON:API style)
GET /users?sort=-createdAt,name # multi-field: createdAt DESC, name ASC
GET /users?sort=name,asc # explicit (Spring style)
GET /users?sort=createdAt,desc&sort=name,asc # multiple (Spring)💡 convention ปี 2026 ที่นิยม —
?sort=-createdAt,name(ขีดหน้า = DESC, comma คั่นหลาย field) เพราะสั้น อ่านง่าย และ JSON:API spec ใช้แบบนี้
Spring Boot
GET /users?sort=name,asc&sort=age,descjava
public Page<UserResponse> list(@PageableDefault(sort = "createdAt") Pageable pageable) {
return ...;
}⚠️ Whitelist Sort Field
java
// ❌ ใครก็ sort ด้วย column ภายในได้ (password_hash, internal_notes)
findAll(pageable); // pageable มาจาก user input
// ✅ Whitelist
Set<String> ALLOWED_SORT = Set.of("name", "email", "createdAt");
for (Sort.Order order : pageable.getSort()) {
if (!ALLOWED_SORT.contains(order.getProperty())) {
throw new ValidationException("Invalid sort field");
}
}💡 หมายเหตุ threat model — Spring Data JPA map sort เป็น entity property (ไม่ใช่ SQL column ตรง ๆ) ดังนั้นการ sort ด้วย
password_hashจะ leak ค่า hash ออกมาตรง ๆ ก็ต่อเมื่อ entity field นั้นถูก expose ใน response. แต่แม้ไม่ leak ค่า, response timing (sort เร็ว/ช้า) ก็ใช้ enumerate ค่า hash ได้ — ดังนั้น whitelist อยู่ดี
8. Search
search ต่างจาก filter ตรงที่เป็นการค้นแบบ fuzzy/full-text ไม่ใช่ match แบบเป๊ะ — เริ่มจาก ?q= ค้นใน field ที่กำหนด (ILIKE) สำหรับงานเล็ก แล้วยกระดับเป็น full-text search (Postgres tsvector) หรือ Elasticsearch เมื่อต้องการ ranking/relevance:
Simple Query
GET /users?q=anna
GET /users?search=anna→ search ใน field ที่กำหนด (name, email)
java
@GetMapping
public Page<UserResponse> list(@RequestParam(required = false) String q, Pageable pageable) {
if (q != null) {
return userRepo.search(q, pageable); // SQL: WHERE name ILIKE %q% OR email ILIKE %q%
}
return userRepo.findAll(pageable);
}Full-Text Search (Postgres)
📘 Full-text search รายละเอียดอยู่ใน Database book บทที่ 7 — ตรงนี้ดูแค่ shape ของ query พอ (ไม่ต้องเข้าใจทุก operator)
sql
SELECT * FROM articles
-- @@ = ตัวดำเนินการ "ข้อความนี้ตรงกับคำค้นไหม" ของ Postgres
-- plainto_tsquery(...) = แปลงคำค้นของ user เป็น "คำค้นแบบ full-text" (tsquery)
WHERE search_vector @@ plainto_tsquery('english', :query)
-- ts_rank(...) = ให้คะแนนความเกี่ยวข้อง (relevance) แล้วเรียงผลที่ตรงที่สุดขึ้นก่อน
ORDER BY ts_rank(search_vector, plainto_tsquery('english', :query)) DESC;FTS (อ่าน เอฟ-ที-เอส, ย่อจาก Full-Text Search) = การค้นข้อความแบบ "ค้นในเนื้อหา" ที่ฉลาดกว่าการ match ตรง ๆ (รู้จักรากศัพท์ จัดอันดับความเกี่ยวข้องได้) — Postgres มีมาให้ในตัว
Search + Filter ผสม
GET /products?q=phone&category=electronics&priceMax=500→ search ใน name/description, filter โดย category + price
9. Field Selection (Sparse Fieldset)
ลด data ที่ส่งเมื่อ client ต้องการเฉพาะบางอย่าง:
GET /users?fields=id,name,emailjson
[
{ "id": 1, "name": "Anna", "email": "anna@x.com" },
...
]ใช้กับ:
- Mobile (bandwidth limit)
- List view ที่ใช้แค่ 2-3 field
Spring:
java
@GetMapping
public List<Map<String, Object>> list(@RequestParam(required = false) Set<String> fields) {
return users.stream().map(u -> selectFields(u, fields)).toList();
}10. Including Related Resources
GET /orders/123 → order without related
GET /orders/123?include=user,items → order + nested user + itemsjson
{
"id": 123,
"total": 100,
"user": { "id": 1, "name": "Anna" }, // included
"items": [{...}, {...}] // included
}ตัวอย่างใหญ่ ๆ — JSON:API spec มี standard:
GET /orders/123?include=user,items&fields[user]=name,email⚠️ bracket syntax
fields[user]=...ต้อง URL-encode เป็นfields%5Buser%5D=...ตามมาตรฐาน. Spring@RequestParamไม่ parse bracket ให้อัตโนมัติ — ต้องใช้ library JSON:API หรือ custom resolver. ใช้ syntax ง่ายกว่าเช่น?userFields=name,emailก็ได้
→ ดู jsonapi.org — มาตรฐานละเอียดมาก
11. ตัวอย่าง — Product Search API
มาดูว่าทุกอย่างในบท (pagination + filter + sort + search + projection) ประกอบกันใน endpoint เดียวยังไง — product search API ที่รวม query, filter หลายแบบ, sort, fields และ pagination เป็นตัวอย่างที่ก๊อปไปปรับใช้กับ list endpoint จริงได้:
http
GET /api/v1/products?
q=phone
&category=electronics
&priceMin=100&priceMax=500
&inStock=true
&brand=apple,samsung
&sort=-price
&fields=id,name,price,brand
&page=0&size=20Response:
json
{
"data": [
{ "id": 1, "name": "iPhone 15", "price": 999, "brand": "Apple" },
...
],
"meta": {
"page": 0,
"size": 20,
"total": 87,
"totalPages": 5
},
"links": {
"self": "/api/v1/products?q=phone&page=0&size=20",
"next": "/api/v1/products?q=phone&page=1&size=20"
}
}12. Cursor-based Feed (Infinite Scroll)
สำหรับ feed แบบ infinite scroll (Twitter/Facebook) offset pagination ใช้ไม่ได้ดี — ข้อมูลใหม่เข้ามาตลอดทำให้รายการซ้ำ/หาย cursor-based แก้ด้วยการชี้ "ตำแหน่ง" ด้วย before/after แทนเลขหน้า ทำให้ scroll ต่อเนื่องได้ถูกต้องแม้ข้อมูลเปลี่ยน และ performance ดีกว่าตอน dataset ใหญ่:
Twitter/Facebook style:
http
GET /api/v1/feed?limit=20
GET /api/v1/feed?limit=20&before=2026-05-18T10:00:00Z # older
GET /api/v1/feed?limit=20&after=2026-05-18T10:00:00Z # newer (poll)Response:
json
{
"data": [...],
"paging": {
"before": "2026-05-18T09:30:00Z", // ใช้ดึง older
"after": "2026-05-18T10:00:00Z", // ใช้ดึง newer
"hasOlder": true
}
}React Implementation (TanStack Query v5+)
📘 ถ้าไม่ใช่ React/TypeScript — อ่านเอาแค่ idea: client เก็บ
cursorของ page ก่อน (lastPage.paging.before) แล้วส่งต่อตอนขอ page ถัดไป. ทุก client library (axios, ky, plain fetch) ทำแบบเดียวกันได้
tsx
// requires TanStack Query v5+ (v4 มี signature ต่างกัน — ไม่มี initialPageParam)
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed({ before: pageParam, limit: 20 }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.paging.hasOlder
? lastPage.paging.before
: undefined,
});13. Performance Considerations
Total Count — แพง
sql
SELECT COUNT(*) FROM users WHERE status = 'active';
-- ต้อง scan ใหญ่ (Postgres)แก้:
- อย่า return total — แค่
hasNext - Approximate count (Postgres):sql
SELECT reltuples FROM pg_class WHERE relname = 'users';reltuples= ค่าประมาณจำนวนแถวที่ Postgres planner เก็บไว้ (update ตอนANALYZE) — เร็วกว่าCOUNT(*)หลายพันเท่า แต่ไม่ตรงเป๊ะ เหมาะแสดง "ประมาณ 10,000 ผลลัพธ์" - Cache count บ่อย ๆ
- Show "10,000+" ถ้าเกิน threshold
Cursor + Index
sql
-- Cursor pagination ต้องมี index
CREATE INDEX idx_users_id ON users(id);
CREATE INDEX idx_posts_created_id ON posts(created_at DESC, id DESC);Limit Max
java
@RequestParam(defaultValue = "20") int size
// ⚠️ check max
if (size > 100) size = 100;ป้องกัน ?size=10000000 → DB ตาย
14. URL Encoding
GET /users?q=hello world # ❌ space
GET /users?q=hello%20world # ✅ encoded
GET /users?q=hello+world # ✅ encoded (alternative)
GET /users?tags=red,blue # OK
GET /users?tags=red%2Cblue # encoded commaทุก library (axios, fetch) encode ให้อัตโนมัติ — แต่ตอน test manual ระวัง
15. ⚠️ Common Pitfalls
| ❌ | ✅ |
|---|---|
| ส่ง 100k row ใน 1 response | pagination เสมอ |
| Offset deep page → slow | keyset / cursor |
| ไม่ limit max size | server-side max (100/1000) |
| Sort field ไม่ whitelist | security risk |
| Filter ผ่าน free SQL string | predefined query param |
| Search ไม่ใช้ index | full-text index หรือ external (ES) |
| Total count ทุก request | hasNext แทน |
| ไม่มี response meta | client ไม่รู้จะ paginate ยังไง |
16. Standards เปรียบเทียบ
แทนที่จะคิด convention เอง มีมาตรฐานสำเร็จรูปให้ยืมแนวคิด — JSON:API (กำหนด format ละเอียด), GraphQL Relay Cursor (มาตรฐาน pagination ของ GraphQL) ดูไว้เพื่อ "ยืมของดี" หรือเลือกใช้ตามทั้งชุดเมื่อต้องการความเป็นมาตรฐาน:
JSON:API
GET /users?
page[number]=2
&page[size]=20
&filter[status]=active
&sort=-createdAt,name
&fields[user]=id,name
&include=ordersGraphQL Relay Cursor
graphql
query {
users(first: 20, after: "cursor123") {
edges { node { id, name }, cursor }
pageInfo { hasNextPage, endCursor }
}
}Stripe
GET /v1/customers?limit=10&starting_after=cus_xxx→ มีหลาย style — ทีมเลือก 1 + consistent
📚 Glossary มาตรฐาน
| คำย่อ / มาตรฐาน | ย่อมาจาก | คือ |
|---|---|---|
| JSON:API | — | spec มาตรฐานสำหรับ JSON REST API (jsonapi.org) — กำหนด format response, pagination (page[number]), filter (filter[status]), sparse fieldset (fields[type]) |
| Relay Cursor | — | spec ของ Facebook สำหรับ GraphQL pagination — ใช้ first/after/last/before + edges/pageInfo/endCursor |
| RFC 8288 | Web Linking | มาตรฐาน Link header (obsoletes RFC 5988) |
| RFC 9110 | HTTP Semantics | มาตรฐาน HTTP method, status code (รวม PUT/PATCH/idempotency) |
17. Checkpoint
🛠️ Checkpoint 2.1 — Pagination Compare
Implement 2 endpoint:
GET /api/posts?page=N&size=20(offset)GET /api/posts?after=ID&limit=20(keyset)
วัด:
- เวลาที่ page 1 vs page 1000
- DB CPU ใน EXPLAIN ANALYZE
🛠️ Checkpoint 2.2 — Multi-field Sort + Filter
Endpoint /products:
- filter: category, brand (multi), priceMin, priceMax, inStock
- sort: name | price | createdAt + asc/desc
- pagination
🛠️ Checkpoint 2.3 — Infinite Feed
สร้าง feed endpoint ที่:
- cursor-based pagination
- response มี
before+hasOlder - React ใช้
useInfiniteQuery
🛠️ Checkpoint 2.4 — Search
ทำ /products?q=phone:
- search ใน name + description (Postgres FTS)
- ranked by relevance
- highlight matched terms
18. สรุปบท
✅ Pagination มี 3 style: offset, keyset, opaque cursor
✅ Offset ง่ายแต่ช้าเมื่อ deep page — ใช้กับ admin UI ที่ page ไม่ลึก
✅ Keyset / cursor เร็วเสมอ — ใช้กับ feed + mobile
✅ Filter: simple query param > complex DSL — predefined whitelist
✅ Sort: multi-field + whitelist column
✅ Search: simple q= ก่อน, full-text เมื่อต้องการ relevance
✅ Field selection + include = สำหรับ optimize ของ mobile
✅ Limit max size + index sort/filter field — prevent DoS
✅ Headers (Link, X-Total-Count) = alternative ของ meta ใน body