Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu REST resource modeling: noun-based URL, hierarchy, action vs resource.
- Phân biệt idempotency vs safe method: GET/HEAD safe, PUT/DELETE idempotent, POST không.
- Áp dụng ETag header +
If-None-Matchcho conditional GET (cache). - Hiểu PUT vs PATCH semantic và áp dụng đúng cho Shop API.
- Phân biệt offset pagination vs cursor pagination — trade-off và use case.
- Versioning strategy: URL path (
/v1) vs header (Accept: application/vnd.shop.v1+json). - Nắm roadmap Group 7 (B61-B75): full CRUD pattern cho 6 resource Shop API.
REST Resource Modeling — Noun-Based URL
Nguyên tắc cốt lõi REST: resource (tài nguyên) là danh từ — vật được tham chiếu, không phải hành động. HTTP method là động từ tác động lên resource đó.
- URL đúng REST:
/products,/orders,/users(đều là danh từ số nhiều). - URL sai (verb trong URL):
/getProducts,/createOrder,/listAllUsers— đây là kiểu RPC trá hình REST, mất khả năng tận dụng HTTP method semantic và cache layer.
Phân loại URL chuẩn theo Fielding Dissertation 2000 và pattern industry (GitHub API, Stripe API):
- Collection — tập hợp resource, dùng dạng số nhiều:
/products. - Item — instance cụ thể, gắn identifier:
/products/{slug}(Shop API dùngslugcho human-friendly URL). - Sub-resource — resource thuộc về resource cha:
/users/{id}/orders(đơn hàng của user cụ thể).
URL structure Shop API lock vĩnh viễn cho 6 resource chính:
/api/v1/products GET list, POST create
/api/v1/products/{slug} GET, PATCH, DELETE
/api/v1/orders GET list, POST create
/api/v1/orders/{id} GET, PATCH (limited: cancel)
/api/v1/orders/{id}/cancel POST (action verb non-CRUD)
/api/v1/users/me GET self
/api/v1/users/{id}/orders GET orders của user
/api/v1/cart GET, POST add item, DELETE clear
/api/v1/cart/items/{id} PATCH qty, DELETE remove
/api/v1/categories GET tree, POST create (admin)
Hierarchy depth max 2 level. URL kiểu /users/{id}/orders/{order_id}/items/{item_id}/reviews đẩy người đọc và route table vào nightmare. Khi nested cần sâu hơn 2, flatten về root: /orders/{id} truy cập trực tiếp đơn hàng theo ID, không cần biết owner.
Action verb cho non-CRUD endpoint. Có những thao tác không phải CRUD đơn thuần — chuyển state, kích hoạt job, trigger workflow. REST puristic muốn ép vào noun (kiểu POST /cancellations với body {"order_id": ...}), nhưng pattern industry chấp nhận action verb với POST:
POST /api/v1/orders/{id}/cancel # state machine action
POST /api/v1/products/{slug}/restore # admin un-delete
POST /api/v1/checkout # process workflow
POST /api/v1/payments/{id}/refund # state machine action
Pattern lock cho Shop API: /<resource>/{id}/<action> với HTTP method POST (vì action thay đổi state, không idempotent theo nghĩa CRUD). GitHub API dùng cùng pattern (POST /repos/{owner}/{repo}/pulls/{number}/merge), Stripe cũng vậy (POST /v1/refunds nhưng cũng có dạng POST /v1/charges/{id}/capture).
Idempotency Vs Safe Method
HTTP method có 2 đặc tính độc lập theo RFC 9110 section 9.2.1 và 9.2.2:
- Safe — method chỉ đọc, không thay đổi state server (read-only). Client tự do retry, prefetch, hoặc gọi trước khi user click. Áp dụng: GET, HEAD, OPTIONS.
- Idempotent — gọi N lần (N ≥ 1) tạo ra cùng kết quả state với gọi đúng 1 lần. Client retry an toàn khi network timeout. Áp dụng: GET, HEAD, OPTIONS, PUT, DELETE.
Bảng tổng hợp:
Method | Safe | Idempotent | Mô tả
GET | YES | YES | Read resource
HEAD | YES | YES | Read metadata only (no body)
POST | NO | NO | Create new (mỗi POST = 1 resource mới)
PUT | NO | YES | Replace toàn bộ (PUT 2 lần = state cuối giống PUT 1 lần)
PATCH | NO | depends | Partial update — idempotent KHI absolute value
DELETE | NO | YES | Delete (DELETE 2 lần — lần 2 trả 404 cũng OK)
PATCH idempotent tùy nội dung body:
- Idempotent KHI dùng absolute value:
PATCH /products/iphone-15 {"stock": 5}— gọi 2 lần stock vẫn là 5. - KHÔNG idempotent KHI dùng delta relative:
PATCH /products/iphone-15 {"stock_delta": -1}— gọi 2 lần stock giảm 2 thay vì 1.
Lock decision Shop API: PATCH dùng absolute value, KHÔNG dùng delta. Lý do: client retry trên network timeout (3G/4G hay rớt, tower-http retry layer reattempt) phải an toàn — delta gọi 2 lần là bug nhân đôi.
Stock decrement trong order creation là use case ngoại lệ — nhưng phía server xử lý bên trong transaction (B54 create_order_atomic) chứ không expose qua PATCH endpoint cho client. Client không bao giờ gửi delta qua HTTP.
Idempotency-Key header — pattern Stripe-introduced để biến POST (vốn không idempotent) thành retry-safe:
POST /api/v1/orders HTTP/1.1
Host: shop.local
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"items": [{"product_id": 1, "quantity": 2}]}
Cơ chế: server lưu key + response trong cache (Redis, TTL 24 giờ). Request lần 2 cùng key trả response cached cũ — không tạo order trùng. Áp dụng cho POST /orders và POST /payments (Shop API lock, deep B66) chính là cách Stripe và Square xử lý retry trên flaky network ([Stripe Idempotent Requests docs](https://stripe.com/docs/api/idempotent_requests)).
Idempotency-Key flow:
1. Client tạo UUID v4, gửi với POST /orders
2. Server check Redis: idem:<key>
- Hit → trả response cũ, KHÔNG tạo order mới
- Miss → xử lý bình thường, lưu (key, response) vào Redis TTL 24h
3. Network timeout → client retry cùng key → server trả response cũ
Bảng idempotency_keys đã có trong project spec (Group 1 plan) — phục vụ pattern này từ B66.
PUT Vs PATCH Semantic
Cả 2 đều update resource, nhưng semantic khác hẳn:
- PUT — replace toàn bộ: client gửi full representation của resource, server lưu giống y client gửi. Field thiếu trong body → reset về default hoặc trả 422. Use case: import full resource, admin restore exact state, sync 2 chiều.
- PATCH — partial update: client gửi chỉ field cần update, server merge với current state trong DB. Field thiếu → giữ nguyên. Use case: user UI update vài field (đổi tên sản phẩm, đổi giá).
So sánh chi tiết:
Operation | PUT | PATCH (B42 lock)
Body | Full resource | Partial fields only
Missing field | Reset hoặc 422 | Giữ nguyên DB (no-op)
Null field | Set NULL field | Set NULL (Some(None) B42)
Idempotent | YES | YES (absolute) / NO (delta)
Risk overwrite | High (toàn bộ resource)| Low (chỉ field gửi)
Shop API decision | KHÔNG dùng | DÙNG (B42 + B53 lock)
Lock decision Shop API:
- PATCH cho user-facing update 95% case — UI form admin/user gửi chỉ field thay đổi, server merge. Pattern đã implement B42 (
UpdateProductDtodouble-OptionOption<Option<T>>) và B53 (update_productvớiCOALESCEcho NOT NULL +CASE WHEN $booleancho nullable). - PUT KHÔNG dùng — quá over-permissive cho REST API. Client UI vô tình quên field price khi gửi PUT → giá reset về 0, lỗi production silent. PATCH bảo vệ semantic merge.
- Exception: admin import/restore từ JSON backup có thể dùng PUT (rất hiếm, sẽ deep ở G14 admin endpoints).
Một ví dụ thực tế phân biệt: client muốn xóa description của sản phẩm (set NULL). Với PATCH, body {"description": null} hợp lệ — handler nhận Some(None) qua double-Option và set NULL trong DB. Với PUT, client phải gửi nguyên resource thiếu key description hoặc set null — semantic mơ hồ, dễ confuse.
Kết luận: PATCH là default cho Shop API. Không cần endpoint PUT trừ tính năng admin restore từ backup file (sẽ note khi xuất hiện, không trước).
ETag Header + If-None-Match — Conditional GET
Vấn đề: client cache resource sau lần GET đầu, nhưng vẫn phải gọi lại để check resource có đổi không. Mỗi request tốn bandwidth dù body không đổi. ETag (entity tag) là cơ chế HTTP-native giải quyết: server gửi tag identifier cho version hiện tại, client gửi lại tag khi GET lần sau, server check, nếu trùng trả 304 Not Modified không body — save bandwidth toàn bộ payload.
Flow theo RFC 9110 section 8.8.3:
# Request 1 — fresh fetch
GET /api/v1/products/iphone-15 HTTP/1.1
Host: shop.local
# Response 1
HTTP/1.1 200 OK
ETag: W/"1718438400"
Content-Type: application/json
Cache-Control: public, max-age=300
{"slug": "iphone-15", "name": "iPhone 15", "price": "25000000", ...}
# Request 2 — sau khi client cache, kèm ETag cũ
GET /api/v1/products/iphone-15 HTTP/1.1
Host: shop.local
If-None-Match: W/"1718438400"
# Response 2 — không thay đổi
HTTP/1.1 304 Not Modified
ETag: W/"1718438400"
(no body — save bandwidth toàn bộ JSON)
2 cách generate ETag:
- Strong ETag — hash content (SHA256 truncate 16 bytes hoặc tương đương). Chính xác nhưng tốn CPU mỗi response: phải serialize JSON, hash, so sánh.
- Weak ETag — prefix
W/, thường là timestamp hoặc version counter:W/"<updated_at_unix>". Chấp nhận false negative rất ít (nếu update trong cùng giây cùng resource — gần như không gặp trong workload Shop API).
Lock decision Shop API:
- Weak ETag
W/"<updated_at_unix>"— dùng cộtupdated_atđã có sẵn ở mọi entity (B51 schema lock), convert sang Unix timestamp. - Áp dụng cho GET single resource (item endpoint) —
GET /api/v1/products/{slug},GET /api/v1/orders/{id},GET /api/v1/users/me. - KHÔNG áp dụng cho list endpoint — danh sách thay đổi liên tục (sản phẩm mới insert, sản phẩm cũ update), ETag list nhanh stale + tốn CPU compute hash toàn bộ collection.
Helper extractor preview (deep ở B62):
// File: crates/shop-api/src/extractors/if_none_match.rs (B62)
use axum::{extract::FromRequestParts, http::request::Parts};
pub struct IfNoneMatch(pub Option<String>);
impl<S> FromRequestParts<S> for IfNoneMatch
where S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let value = parts
.headers
.get("if-none-match")
.and_then(|v| v.to_str().ok())
.map(String::from);
Ok(Self(value))
}
}
Handler pattern (preview B62):
// File: crates/shop-api/src/routes/products.rs (B62 extend)
pub async fn get_product(
Path(slug): Path<String>,
IfNoneMatch(client_etag): IfNoneMatch,
State(state): State<AppState>,
) -> Result<Response, AppError> {
let product = products::find_by_slug(&state.db, &slug)
.await?
.ok_or(AppError::NotFound)?;
let server_etag = format!(r#"W/"{}""#, product.updated_at.timestamp());
if client_etag.as_deref() == Some(&server_etag) {
return Ok(StatusCode::NOT_MODIFIED.into_response());
}
let mut response = Json(ProductResponseDto::from(product)).into_response();
response.headers_mut().insert("etag", server_etag.parse().unwrap());
Ok(response)
}
Lợi ích thực tế: GET catalog endpoint (product detail) tốc độ load second-visit user gần như instant — browser thấy 304 không cần parse body, hit cache. Áp dụng đúng pattern này tiết kiệm 60-80% bandwidth cho traffic catalog.
Pagination: Offset Vs Cursor
List endpoint trả về tập lớn cần chia trang. 2 chiến lược chính, mỗi cái có sweet spot riêng.
Offset pagination — ?page=2&per_page=20:
- SQL:
SELECT ... LIMIT 20 OFFSET 20. - Pros: đơn giản, intuitive cho user (jump tới trang 5 bằng 1 click), URL bookmark-able.
- Cons:
- Performance giảm với offset lớn — Postgres phải scan rồi skip N row. OFFSET 1000000 trên catalog 10 triệu row tốn vài giây.
- Inconsistent khi data thay đổi giữa 2 trang — sản phẩm mới insert đẩy mọi row xuống 1 vị trí, user thấy duplicate row hoặc miss row khi cuộn.
Cursor pagination — ?cursor=abc123&limit=20:
- SQL:
WHERE created_at < $cursor ORDER BY created_at DESC LIMIT 20(keyset query). - Pros:
- Performance constant — Postgres index seek thay scan, O(log n) bất kể vị trí trang.
- Stable khi data thay đổi — cursor là điểm neo theo timestamp, row mới insert không ảnh hưởng vị trí hiện tại.
- Cons:
- KHÔNG jump tới trang random (không có khái niệm "trang 5" trong cursor world).
- Cursor opaque với user — token encode (timestamp + id) bằng base64.
Cursor encode format ví dụ:
cursor = base64(timestamp + "_" + id)
# Original: 2026-06-15T10:00:00Z_123
# Encoded: MjAyNi0wNi0xNVQxMDowMDowMFpfMTIz
# Client gửi tiếp cho trang sau:
GET /api/v1/products/export?cursor=MjAyNi0wNi0xNVQxMDowMDowMFpfMTIz
Lock decision Shop API:
- Offset cho list endpoint user-facing (95% case) — UI catalog, admin dashboard có pagination control 1/2/3 truyền thống. Đã lock B23 với
Paginationstruct +ProductListResponseenvelope. - Cursor cho NDJSON export (data lớn, stable iter) — đã lock B49 với
export_products_ndjson. Client là backend tool (ETL, analytics), không cần jump random trang. - Cursor cho admin event stream (audit log, notification feed) — append-only data, cursor là pattern tự nhiên.
So sánh quick:
Tiêu chí | Offset | Cursor (keyset)
URL pattern | ?page=N&per_page | ?cursor=opaque&limit
SQL | LIMIT/OFFSET | WHERE col < cursor LIMIT
Performance N lớn | Slow (scan+skip) | Fast (index seek)
Stability concurrent | Drift duplicate | Stable (point neo)
Jump random trang | YES | NO
Bookmark URL | YES | YES (opaque token)
Shop API use case | UI catalog (95%) | Export + stream (5%)
Pattern chung: 95% case dùng offset (lock B23 page + per_page max 100), cursor chỉ cho special endpoint (NDJSON export, admin stream).
API Versioning Strategy
API public buộc phải versioning vì client (mobile app, third-party integration) không thể cập nhật ngay khi server thay đổi breaking. 3 cách công nghiệp:
- URL path —
/api/v1/products(Shop API lock B7).- Pros: visible trong URL, cache-friendly (CDN cache theo path), dễ debug bằng browser, dễ test bằng curl.
- Cons: URL phình to khi nhiều version song song.
- Accept header (vendor MIME) —
Accept: application/vnd.shop.v1+json.- Pros: URL clean, semantic versioning rõ ràng.
- Cons: khó debug (không thấy version trong URL), CDN cache phải kèm
Vary: Accept, tooling client phức tạp.
- Query param —
?version=1.- Pros: dễ test.
- Cons: dirty URL, không industry standard, dễ bị client quên gửi.
Lock decision Shop API: URL path /api/v1 (lock B7 continued + B24 router nest). Mỗi version có một router riêng, dễ maintain song song. GitHub API, Stripe API, Twilio API đều dùng pattern này.
Breaking change strategy:
- Minor change — thêm optional field, thêm endpoint mới: KHÔNG bump version. Client cũ vẫn hoạt động.
- Breaking change — bỏ field, đổi kiểu dữ liệu, đổi semantic: bump major version (
/api/v2). - Maintain
v1+v2song song trong N tháng (deprecation period — Shop API mặc định 6 tháng, có thể extend tùy đối tác).
Báo trước breaking change qua Sunset header (IETF draft deprecation-header):
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://shop.local/api/v2/products>; rel="successor-version"
{"items": [...]}
Client SDK đọc Deprecation: true và Sunset để cảnh báo developer migrate. Pattern lock cho Shop API khi rollout breaking change tương lai.
Pitfall thường gặp: tách /api/v1 và /api/v2 chia sẻ chung handler, dùng if version == "v2" trong code. Pattern này nhanh chóng biến thành spaghetti — Shop API lock route nest riêng từng version (Router::new().nest("/api/v1", v1_router).nest("/api/v2", v2_router)), code path tách biệt hoàn toàn.
Roadmap Group 7 (B61-B75)
Group 7 cover 15 bài (B61-B75) hoàn thiện CRUD đầy đủ cho 6 resource Shop API production-ready:
- B61 (bài này) — CRUD overview + resource modeling + idempotency + ETag + pagination + versioning.
- B62 — Products CRUD hoàn chỉnh: ETag header GET single + If-None-Match 304 + soft delete column.
- B63 — Products schema extend: variants (size/color), images, inventory tracking, audit log.
- B64 — Products endpoint full + admin restore endpoint + 2 helper extractor (
IfNoneMatch,SoftDeleteScope). - B65 — Soft delete pattern hoàn chỉnh:
deleted_atcolumn + query filterWHERE deleted_at IS NULL+ admin un-delete via POST/restore. - B66 — POST
/api/v1/ordersvớicreate_order_atomic+ Idempotency-Key Redis cache + retry strategy + handler đầy đủ. - B67 — GET
/api/v1/orders+ filter status/user + reuseProductFiltertemplate lock B59. - B68 — PATCH
/api/v1/orders/{id}(cancel state machine) + POST/api/v1/orders/{id}/cancelaction endpoint. - B69 — Cart endpoints: GET
/api/v1/cart+ POST/api/v1/cartadd item + DELETE clear. - B70 — POST
/api/v1/cart/items/{id}update qty + DELETE remove item + cart expiration policy. - B71 — POST
/api/v1/users/register+ POST/api/v1/users/login(preview, deep G11 + G12). - B72 — GET
/api/v1/users/me+ PATCH update profile + GET/api/v1/users/{id}/orders. - B73 — Service layer abstraction (
ProductServicetrait trongshop-core) + repository pattern + dependency injection. - B74 — Domain error pattern:
OrderError,CartError,UserErrorunified mapping quaimpl From<DomainError> for AppError. - B75 — End-to-end CRUD reuse pattern: macro
#[derive(Crud)]hoặc generic handler factory (advanced topic, optional).
Sau Group 7, Shop API có 6 resource production-ready với CRUD đầy đủ, tổng cộng ~25 endpoint thực tế phục vụ user flow: browse catalog → add to cart → checkout → track order → user profile.
State workspace sau B61 KHÔNG đổi — bài này conceptual overview, foundation cho B62-B75 implement code thật từng resource.
Tổng Kết
- REST resource modeling: noun URL plural, hierarchy max 2 level, action verb POST cho non-CRUD.
- Idempotency vs Safe: GET/HEAD safe + idempotent; PUT/DELETE idempotent; POST không idempotent.
- PATCH idempotent KHI absolute value (B42 lock continued), KHÔNG khi delta.
- Idempotency-Key header lock cho POST /orders + POST /payments (B66 deep, Stripe-style).
- Shop API decision PATCH > PUT: PATCH cho user-facing 95%, PUT KHÔNG dùng (over-permissive).
- ETag weak
W/"<updated_at_unix>"+ 304 Not Modified cho GET single resource (không cho list). - Pagination: offset cho UI (95% case lock B23), cursor cho NDJSON export (B49 lock) + admin stream.
- API versioning URL path
/api/v1lock B7 continued + nested router B24. - Breaking change: bump major version +
Sunsetheader + deprecation period N tháng. - 15 bài Group 7 roadmap cho 6 resource: products + orders + carts + users + categories + payments.
- File path lock: extend
crates/shop-api/src/routes/cho mỗi resource (B62+).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- REST resource URL design: tại sao
/products(số nhiều) thay/product(số ít)? Cho ví dụ pitfall khi sai convention. - Idempotent vs Safe — phân biệt 2 đặc tính. POST có thể idempotent không? Pattern Idempotency-Key giải quyết vấn đề gì?
- PATCH với delta
{"stock_delta": -1}không idempotent. Cho ví dụ scenario lỗi retry. Shop API quy ước thay thế là gì? - ETag weak vs strong — trade-off và Shop API chọn cái nào? Lý do.
- Pagination offset vs cursor — performance khác nhau ra sao khi data 1 triệu row? Use case mỗi loại trong Shop API.
Đáp án
- URL số nhiều
/productsthay số ít/product+ pitfall: convention REST coi resource là collection các instance, plural form thể hiện rõ "đây là tập hợp" trong khi singular form gợi ý "một resource cụ thể". URL/productschứa nhiều product items,/products/{slug}chỉ một item. Nếu dùng singular/product, ý nghĩa mơ hồ: là 1 product? là 1 product cụ thể? thiếu identifier? Pitfall thực tế: API dùngGET /producttrả list — developer mới đọc URL không biết được semantic phải đọc docs hoặc thử request. Kế đến URL/product/{id}mâu thuẫn với/productkhông identifier — 2 endpoint khác semantic chia sẻ prefix singular, dễ confuse routing. Industry standard (REST API design guidelines GitHub, Stripe, Twilio) đều plural. Ngoại lệ:/medùng singular vì semantic "tôi" duy nhất per session — Shop API dùng/api/v1/users/mechứ không/api/v1/users/self. Pattern chung lock vĩnh viễn: plural cho mọi resource có nhiều instance (products/orders/users/categories/carts), singular cho special endpoint định danh implicit (/me, /cart vì cart per-user duy nhất). Pitfall khác: trộn plural và singular trong cùng API —/products+/order/{id}+/users/me→ inconsistency làm SDK auto-generate fail và developer phải nhớ từng case. Quy tắc thực thi: PR review reject URL singular trừ/mevà/cart. - Idempotent vs Safe + POST idempotent + Idempotency-Key: 2 đặc tính độc lập HTTP method theo RFC 9110. Safe = method chỉ đọc, không thay đổi state server (read-only) — client tự do retry, prefetch trước user click, browser preload không gây side effect. Áp dụng: GET, HEAD, OPTIONS. Idempotent = gọi N lần (N ≥ 1) tạo ra state cuối giống gọi đúng 1 lần — client retry an toàn khi network timeout, không lo duplicate effect. Áp dụng: GET, HEAD, OPTIONS, PUT, DELETE. Lưu ý: safe ⇒ idempotent (read-only thì không đổi state, gọi N lần state vẫn nguyên), nhưng idempotent ⇏ safe (DELETE idempotent nhưng KHÔNG safe vì đổi state). POST có idempotent không? Theo định nghĩa RFC 9110: POST KHÔNG idempotent — mỗi POST tạo resource mới. POST /orders 2 lần tạo 2 order. Nhưng application-level có thể làm POST trở thành idempotent thông qua Idempotency-Key header (pattern Stripe-introduced 2014): client tạo UUID v4 unique per logical operation, gửi kèm POST. Server lưu
(key, response)trong cache Redis (TTL 24h). Request lần 2 cùng key → server thấy key đã tồn tại → trả response cached cũ KHÔNG tạo resource mới. Vấn đề Idempotency-Key giải quyết: (a) Network timeout retry — client gửi POST /orders, network rớt giữa chừng, không biết server đã xử lý hay chưa. Retry không kèm key → tạo 2 order. Retry kèm key → safe; (b) Mobile flaky network — 3G/4G hay rớt, app tự retry built-in HTTP library (Retrofit Android, URLSession iOS) — Idempotency-Key đảm bảo retry không gây duplicate; (c) Webhook delivery — Stripe webhook retry exponential backoff khi server trả 5xx, Idempotency-Key đảm bảo handler không xử lý event 2 lần. Shop API lock Idempotency-Key cho POST /orders (B66) và POST /payments — pattern chuẩn Stripe-style production retry safe. - PATCH delta không idempotent + scenario lỗi + Shop API quy ước: PATCH idempotent tùy nội dung body. Body absolute value như
{"stock": 5}idempotent vì gọi N lần stock vẫn là 5. Body delta relative như{"stock_delta": -1}KHÔNG idempotent vì gọi 2 lần stock giảm 2 thay 1, gọi 3 lần giảm 3, ... Scenario lỗi retry concrete: (a) Admin gửi PATCH /products/iphone-15 với body{"stock_delta": -1}để decrement stock từ 10 → 9 sau khi bán 1 chiếc; (b) Server nhận request, xử lý xong, DB stock = 9, chuẩn bị trả response; (c) Network rớt giữa lúc gửi response — client KHÔNG nhận được 200, hiện loading spinner timeout; (d) HTTP library client tự động retry (axios default retry hoặc Retrofit retry policy); (e) Server nhận request thứ 2 cùng body delta -1, xử lý lại, DB stock giờ = 8 (sai — đáng lẽ vẫn 9); (f) Admin reload page thấy stock = 8 thay vì 9 → nghĩ "có ai mua thêm 1 chiếc khi mình đang ngồi cập nhật?" → confusion. Vấn đề bản chất: delta là instruction "tác động lên state hiện tại", retry không có cách phân biệt "lần này có phải lần đầu không". Shop API quy ước thay thế: luôn dùng absolute value. PATCH body kiểu{"stock": 9}— gọi 2 lần stock vẫn 9, retry safe tự động. Nếu UI admin muốn "decrement 1", client phải đọc stock hiện tại = 10, tính 10 - 1 = 9, gửi{"stock": 9}. Pattern này lock vĩnh viễn cho mọi PATCH endpoint Shop API. Stock decrement trong order creation (B54create_order_atomic) là use case ngoại lệ — nhưng server xử lý bên trong transaction vớiFOR UPDATEpessimistic row lock, KHÔNG expose qua HTTP. Idempotency-Key bảo vệ order creation thêm 1 lớp (B66). Generalize: bất cứ thao tác relative (delta, increment, decrement, append) không expose qua HTTP PATCH — luôn absolute value. - ETag weak vs strong + Shop API chọn + lý do: ETag (entity tag) là identifier version của resource gửi qua HTTP header. Strong ETag — không có prefix, thường là hash content (SHA256 truncate, MD5 hoặc tương đương). Pros: chính xác tuyệt đối, 2 response cùng ETag chắc chắn cùng content byte-by-byte (kể cả thứ tự key JSON, whitespace). Cons: tốn CPU mỗi response — phải serialize JSON, compute hash, ghép vào header, mỗi request thêm overhead vài microsecond. Use case strong: API trả file binary (PDF, image) nơi content immutable và hash xác định. Weak ETag — prefix
W/theo RFC 9110, formatW/"<identifier>". Identifier thường là timestamp (W/"1718438400") hoặc version counter (W/"v42"). Pros: rẻ CPU — chỉ cần đọc cộtupdated_atđã có sẵn, convert sang Unix timestamp; không cần serialize hay hash. Cons: chấp nhận false negative rất hiếm — nếu resource update 2 lần trong cùng giây (cùngupdated_attimestamp giây) sẽ trả ETag giống nhau dù content đổi → client nghĩ cache còn valid trong khi server data đã đổi. Shop API chọn Weak ETagW/"<updated_at_unix>"vì 3 lý do: (a) Cộtupdated_atđã có sẵn ở mọi entity từ B51 schema lock (products, orders, payments, users, ...). Không cần thêm logic compute, chỉ format string; (b) Workload Shop API không có update tần suất cao — product mỗi ngày update vài lần (admin đổi giá, đổi stock), không phải update mỗi giây. False negative gần như không gặp; (c) CPU rẻ cho catalog endpoint heavy traffic — GET /products/{slug} là endpoint hot nhất, mỗi user vào product detail page request 1 lần. Strong ETag hash JSON full response trên mỗi request tốn CPU không đáng cho gain marginal. Workload payment hay audit log có thể cân nhắc strong ETag tương lai nếu cần precision tuyệt đối — nhưng Shop API hiện tại weak ETag đủ tốt. Implementation deep ở B62: helper extractorIfNoneMatch+ handler checkformat!(r#"W/"{}""#, product.updated_at.timestamp()). - Offset vs cursor 1 triệu row + use case Shop API: Offset pagination với
LIMIT 20 OFFSET 980000trên bảng 1 triệu row, Postgres phải scan từ row 1 (theo ORDER BY index), đếm tới row 980000, skip rồi mới lấy 20 row tiếp. Index B-tree giúp scan tuần tự nhanh nhưng vẫn phải đi qua 980000 entry — thời gian execute tỷ lệ tuyến tính với offset. Real benchmark: OFFSET 1000 ~ 2ms, OFFSET 100000 ~ 200ms, OFFSET 1000000 ~ 2 giây trên PostgreSQL 17 catalog 1M row với index DESC created_at. Bottleneck thực sự xuất hiện khi user request trang cuối (50000) hoặc khi pre-fetch many pages. Cursor pagination vớiWHERE created_at < '2026-06-15 10:00:00' AND id < 123 ORDER BY created_at DESC, id DESC LIMIT 20— Postgres dùng B-tree index seek tới điểm neo (cursor), lấy 20 row tiếp sau, không cần scan. Performance O(log n) bất kể vị trí trang. Real benchmark: cursor query luôn ~2ms dù đang ở "trang đầu" hay "trang cuối" trong dataset 1M row. Stability concurrent insert: offset có vấn đề drift — khi user đang xem trang 1, sản phẩm mới insert đẩy mọi row xuống 1 vị trí, sang trang 2 user thấy row cuối trang 1 lặp lại (vì giờ row đó nằm vị trí 21). Cursor không gặp vì điểm neo cố định theo (timestamp, id). Use case Shop API: (a) Offset 95% case — UI catalog public (GET /api/v1/products) admin dashboard có pagination control 1/2/3 truyền thống, user thường chỉ xem vài trang đầu (offset nhỏ → fast), URL bookmark-able dễ share. Lock B23 vớiPaginationstruct +ProductListResponseenvelope chứa{items, total, page, per_page, total_pages}. Per_page max 100 anti-DoS lock B59 continued; (b) Cursor 5% case — NDJSON export (GET /api/v1/products/export.ndjson) lock B49 — client là ETL tool, cần stream toàn bộ catalog ổn định, không cần jump random trang. Lock cursor encode base64(timestamp + "_" + id). Admin event stream (audit log feed, notification feed) cũng dùng cursor vì append-only data + pattern read tuần tự tự nhiên; (c) Khi nào convert từ offset sang cursor: bảng > 100k row + endpoint heavy traffic + user thường xem trang cuối (vd activity log latest). Trigger: monitoring thấy p99 latency endpoint list tăng theo offset → switch sang cursor. Mặc định bắt đầu với offset (đơn giản hơn) — chỉ optimize khi profile thấy bottleneck thực sự.
Bài Tiếp Theo
Bài 62: Products CRUD Hoàn Chỉnh — ETag + Soft Delete — bổ sung products endpoint: ETag header GET single + If-None-Match 304, soft delete với deleted_at column, restore endpoint admin, audit log trigger, áp Shop API full CRUD products pattern lock.
