Chương 7. Thiết kế REST API
Viết một endpoint trả JSON thì ai cũng làm được. Thứ tôi muốn thấy ở ứng viên 2–4 năm là API mà team frontend, mobile hay đối tác tích hợp dùng không phải đoán: status code đúng nghĩa, lỗi có format thống nhất, retry không tạo đơn trùng, đổi version không làm gãy client cũ.
Người phỏng vấn muốn kiểm tra
- Đặt tên resource, chọn HTTP method và status code đúng ngữ nghĩa.
- Hiểu safe và idempotent, xử lý retry an toàn cho POST bằng idempotency key.
- Thiết kế phân trang, lọc, sắp xếp, versioning và format lỗi thống nhất.
- Biết tài liệu hóa API (OpenAPI) và bảo vệ API (validation, rate limiting).
- So sánh được REST với gRPC, GraphQL và biết khi nào chọn cái nào.
7.1 Nguyên tắc REST và đặt tên resource
Nhắc lại kiến thức
N2.5 đã giới thiệu HTTP là giao thức hỏi–đáp: request gồm method, đường dẫn, header, body; response gồm status code, header, body; và HTTP là stateless, server không nhớ gì giữa hai request. N2.6 so sánh REST với SOAP. Chương này dùng các khái niệm đó để thiết kế API cho người khác dùng.
REST là một kiến trúc (không phải giao thức) với các ràng buộc chính: client-server, stateless (mỗi request tự mang đủ thông tin, gồm cả credential), cacheable, uniform interface (resource định danh bằng URI, thao tác qua HTTP method chuẩn). Trong thực tế, HATEOAS ít khi được áp dụng đầy đủ.
Quy ước đặt tên:
- Danh từ số nhiều, không động từ:
GET /orders,POST /orders, không phảiPOST /createOrder. - Quan hệ cha con khi resource con không tồn tại độc lập:
GET /orders/{orderId}/items. Không lồng quá 2 cấp. - Chữ thường, gạch ngang:
/payment-methods. - Hành động không khớp CRUD:
POST /orders/{id}/cancellà chấp nhận được, miễn nhất quán toàn hệ thống.
Resource là "thứ" mà API quản lý (đơn hàng, khách hàng, item trong đơn), mỗi resource có một địa chỉ URI riêng; method HTTP cho biết làm gì với nó. Bảng dưới đối chiếu các endpoint hay gặp trong code review với cách đặt tên đúng:
| Thiết kế chưa tốt | Thiết kế tốt | Lý do |
|---|---|---|
GET /getAllOrders | GET /orders | Method đã nói hành động, URI chỉ cần danh từ |
POST /orders/delete/42 | DELETE /orders/42 | Dùng đúng method để client, cache, gateway hiểu ngữ nghĩa |
GET /order/42/Items | GET /orders/42/items | Số nhiều, chữ thường thống nhất |
GET /orders?customerId=7&orderId=42 | GET /orders/42 | Định danh một resource cụ thể nằm trên path, bộ lọc nằm ở query param |
POST /customers/7/orders/42/items/9/notes | POST /order-items/9/notes | Lồng quá sâu, khó dùng và khó đổi |
Stateless trong thực tế nghĩa là: request nào cũng mang đủ thông tin để server xử lý mà không cần nhớ request trước, điển hình là token trong header Authorization. Nhờ vậy, load balancer gửi request tới instance nào cũng được và scale ngang dễ dàng.
7.2 HTTP method, safe và idempotent
Safe: không thay đổi trạng thái server. Idempotent: gọi 1 lần hay N lần thì trạng thái cuối trên server như nhau (response có thể khác, ví dụ DELETE lần hai trả 404).
| Method | Mục đích | Safe | Idempotent |
|---|---|---|---|
| GET | Đọc resource | Có | Có |
| POST | Tạo mới, hoặc hành động không idempotent | Không | Không |
| PUT | Thay thế toàn bộ resource (hoặc tạo tại URI đã biết) | Không | Có |
| PATCH | Cập nhật một phần | Không | Không bắt buộc |
| DELETE | Xóa resource | Không | Có |
PUT vs PATCH: PUT gửi toàn bộ biểu diễn, field không gửi nghĩa là bị xóa/đặt mặc định. PATCH chỉ gửi phần thay đổi. Hai format chuẩn: JSON Merge Patch (RFC 7396, null nghĩa là xóa field) và JSON Patch (RFC 6902, danh sách thao tác add, replace, remove). Tự làm PATCH bằng DTO thì phải phân biệt "không gửi field" với "gửi null".
Gọi 1 lần và gọi 3 lần cho ra trạng thái gì
Cách dễ nhớ nhất về idempotent: tưởng tượng mạng chập chờn và client gửi cùng một request 3 lần. Nếu trạng thái trên server sau 3 lần giống hệt sau 1 lần, request đó idempotent. Giả sử ban đầu đã có đơn 42 và giỏ hàng 7 có item 9 với số lượng 2:
| Request | Sau 1 lần | Sau 3 lần | Idempotent? |
|---|---|---|---|
GET /orders/42 | Không đổi gì | Không đổi gì | Có (và safe) |
POST /orders body {"sku":"BOOK-1"} | Có thêm đơn 43 | Có thêm đơn 43, 44, 45 | Không |
PUT /orders/42/address body {"city":"Hà Nội"} | city = Hà Nội | city = Hà Nội | Có |
PATCH /carts/7/items/9 body {"quantity":3} | quantity = 3 | quantity = 3 | Có, vì đặt giá trị tuyệt đối |
PATCH /carts/7/items/9 body {"increment":1} | quantity = 3 | quantity = 5 | Không, vì cộng dồn |
DELETE /orders/42 | Đơn 42 bị xóa, trả 204 | Đơn 42 vẫn bị xóa, lần 2 và 3 trả 404 | Có |
Chương trình Java dưới đây mô phỏng một kho đơn hàng trong bộ nhớ để kiểm chứng ba dòng POST, PUT, DELETE của bảng trên:
import java.util.*;
public class IdempotencyDemo {
static Map<Integer, String> orders = new TreeMap<>(Map.of(42, "book"));
static int nextId = 43;
static int post(String item) { // POST /orders: server picks a new id every call
orders.put(nextId, item);
return nextId++;
}
static void put(int id, String item) { // PUT /orders/{id}: replace the resource at a known URI
orders.put(id, item);
}
static int delete(int id) { // DELETE /orders/{id}: returns the HTTP status
return orders.remove(id) != null ? 204 : 404;
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) post("pen");
System.out.println(orders); // prints: {42=book, 43=pen, 44=pen, 45=pen}
for (int i = 0; i < 3; i++) put(42, "notebook");
System.out.println(orders.get(42) + " " + orders.size()); // prints: notebook 4
List<Integer> codes = new ArrayList<>();
for (int i = 0; i < 3; i++) codes.add(delete(42));
System.out.println(codes + " " + orders.size()); // prints: [204, 404, 404] 3
}
}Giải thích: Gọi post 3 lần tạo 3 đơn mới vì mỗi lần server tự sinh id, nên POST không idempotent. Gọi put(42, ...) 3 lần vẫn chỉ có một đơn 42 mang giá trị notebook, tổng số đơn giữ nguyên 4. Gọi delete(42) 3 lần: trạng thái cuối giống nhau (đơn 42 không còn, còn 3 đơn), dù status code lần đầu là 204 còn các lần sau là 404. Idempotent nói về trạng thái trên server, không nói về response. Đây là lý do HTTP client, gateway và service mesh được phép tự retry GET, PUT, DELETE khi timeout, nhưng không được tự retry POST.
Bẫy thường gặp
Nhầm lẫn: dùng GET cho hành động thay đổi dữ liệu, ví dụ GET /orders/42/cancel để frontend gọi cho tiện. Vì sao sai: GET được coi là safe, nên trình duyệt có thể prefetch link, crawler có thể đi theo link, proxy có thể cache hoặc retry, và đơn bị hủy mà không ai bấm. Đúng: dùng POST /orders/42/cancel. Lưu ý safe nghĩa là client không yêu cầu thay đổi; server ghi log hay tăng bộ đếm lượt xem khi xử lý GET vẫn chấp nhận được.
7.3 Status code dùng đúng
Nhắc lại kiến thức
N2.5 chia status code thành năm nhóm: 1xx thông tin, 2xx thành công, 3xx chuyển hướng, 4xx lỗi do client, 5xx lỗi do server. Chữ số đầu tiên đã cho client biết phải làm gì tiếp theo, trước cả khi đọc body.
| Code | Khi nào dùng |
|---|---|
| 200 OK | Thành công, có body |
| 201 Created | Tạo mới thành công, kèm header Location trỏ tới resource mới |
| 202 Accepted | Đã nhận, xử lý bất đồng bộ sau |
| 204 No Content | Thành công, không có body (thường cho DELETE, PUT) |
| 400 Bad Request | Request sai cú pháp, JSON hỏng, tham số sai kiểu |
| 401 Unauthorized | Chưa xác thực hoặc token không hợp lệ |
| 403 Forbidden | Đã xác thực nhưng không có quyền |
| 404 Not Found | Resource không tồn tại (hoặc cố ý ẩn với người không có quyền) |
| 409 Conflict | Xung đột trạng thái: trùng dữ liệu unique, sai version, hủy đơn đã giao |
| 422 Unprocessable Content | Cú pháp đúng nhưng vi phạm quy tắc nghiệp vụ/validation |
| 429 Too Many Requests | Vượt rate limit, kèm Retry-After |
| 500 Internal Server Error | Lỗi không lường trước phía server |
| 503 Service Unavailable | Tạm thời không phục vụ được (quá tải, bảo trì), kèm Retry-After |
Validation lỗi trả 400 hay 422 là lựa chọn của team, miễn thống nhất. Không bao giờ trả 200 kèm {"success": false} cho lỗi: gateway, monitoring, retry logic của client đều dựa vào status code.
Ví dụ request/response cho từng nhóm mã
Nhóm 2xx, thành công. Tạo đơn trả 201 kèm Location, xóa trả 204 không có body:
POST /api/v1/orders HTTP/1.1
Content-Type: application/json
{"customerId": 1, "items": [{"sku": "SKU-123", "quantity": 1}]}
HTTP/1.1 201 Created
Location: /api/v1/orders/106
Content-Type: application/json
{"id": 106, "status": "NEW", "total": 90}
DELETE /api/v1/orders/106 HTTP/1.1
HTTP/1.1 204 No ContentNhóm 3xx, chuyển hướng hoặc dùng lại cache. Với REST API, mã hay gặp nhất là 304: client gửi lại ETag đã nhận ở lần trước, server thấy resource chưa đổi thì trả 304 không có body, client dùng bản đã cache:
GET /api/v1/products/9 HTTP/1.1
If-None-Match: "v7"
HTTP/1.1 304 Not Modified
ETag: "v7"Nhóm 4xx, lỗi do client. Client phải sửa request; gửi lại y nguyên sẽ lỗi y nguyên (trừ 429 và một số 409):
GET /api/v1/orders/106 HTTP/1.1
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
POST /api/v1/orders HTTP/1.1
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json
{"type": "about:blank", "title": "Too Many Requests", "status": 429,
"detail": "Quota of 100 requests per minute exceeded"}Nhóm 5xx, lỗi do server. Client không có lỗi; nếu request idempotent thì có thể retry sau một khoảng chờ:
GET /api/v1/orders/106 HTTP/1.1
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/problem+json
{"type": "about:blank", "title": "Service Unavailable", "status": 503,
"detail": "Scheduled maintenance until 02:00"}Giải thích: Trong các ví dụ trên, status code và header đã đủ để client tự động hóa phản ứng: 201 thì đọc Location để điều hướng tới đơn mới; 304 thì hiển thị bản cache; 401 thì chuyển sang màn hình đăng nhập hoặc làm mới token; 429 và 503 thì chờ đúng số giây trong Retry-After rồi thử lại. Hai mã 5xx hay gặp khác do gateway sinh ra: 502 Bad Gateway (service phía sau trả response hỏng hoặc chết) và 504 Gateway Timeout (service phía sau không trả lời kịp).
| Nhóm | Ai sai | Client nên làm gì |
|---|---|---|
| 2xx | Không ai | Dùng kết quả |
| 3xx | Không ai | Đi theo Location hoặc dùng bản cache |
| 4xx | Client | Sửa request; chỉ retry nguyên xi với 429 (sau Retry-After) |
| 5xx | Server hoặc hạ tầng | Retry có backoff nếu request idempotent hoặc có idempotency key |
7.4 Idempotency key cho POST thanh toán
Client gửi POST /payments bị timeout, không biết đã trừ tiền chưa; retry mù quáng có thể trừ hai lần. Giải pháp phổ biến: client sinh Idempotency-Key (UUID) cho mỗi ý định thanh toán, gửi trong header, retry dùng lại đúng key đó.
Phía server:
- Lưu key vào bảng có unique constraint (key + client id), kèm hash của request body và trạng thái
PROCESSING. Insert thất bại do trùng nghĩa là request này đã/đang được xử lý. - Xử lý nghiệp vụ, lưu response (status + body) vào bản ghi key, chuyển trạng thái
COMPLETED. - Request trùng key: nếu
COMPLETEDthì trả lại đúng response đã lưu; nếuPROCESSINGthì trả 409 để client thử lại sau; nếu cùng key nhưng body khác thì trả 422. - Đặt thời hạn lưu key (ví dụ 24 giờ).
Unique constraint ở DB là mấu chốt; kiểm tra "đã tồn tại chưa" bằng SELECT rồi INSERT sẽ lọt race condition.
Nhắc lại kiến thức
Mục 6.3 (phantom read, lost update) giải thích vì sao "SELECT kiểm tra rồi INSERT" không an toàn: hai transaction cùng SELECT, cùng thấy "chưa có", rồi cùng INSERT. Chỉ ràng buộc UNIQUE ở DB mới bảo đảm đúng một bên thắng.
Kịch bản mạng lỗi rồi client gửi lại
Bảng lưu key được thiết kế như sau (chạy được trên SQLite, PostgreSQL; MySQL dùng INSERT IGNORE thay cho ON CONFLICT DO NOTHING):
CREATE TABLE idempotency_keys (
client_id VARCHAR(50) NOT NULL,
idem_key VARCHAR(64) NOT NULL,
request_hash CHAR(64) NOT NULL, -- SHA-256 of the request body
status VARCHAR(20) NOT NULL, -- PROCESSING or COMPLETED
http_status INT, -- saved response, filled when COMPLETED
response TEXT,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (client_id, idem_key) -- the uniqueness that blocks duplicates
);
-- Every request starts with this insert; 1 row = first time, 0 rows = duplicate
INSERT INTO idempotency_keys (client_id, idem_key, request_hash, status, created_at)
VALUES ('shop-app', '7f3c9a2e', 'a1b2...', 'PROCESSING', CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;Kịch bản: app mobile thanh toán 100.000đ, request đầu tới server và được xử lý xong, nhưng response bị mất trên đường về do mạng 4G chập chờn.
| Bước | Client | Server | Bảng idempotency_keys |
|---|---|---|---|
| 1 | Sinh key 7f3c9a2e, gửi POST /payments kèm Idempotency-Key: 7f3c9a2e | INSERT key, được 1 dòng: lần đầu | PROCESSING |
| 2 | Đang chờ | Gọi cổng thanh toán, trừ tiền thành công, tạo payment 9001 | PROCESSING |
| 3 | Đang chờ | UPDATE lưu 201 và body {"paymentId":9001} | COMPLETED |
| 4 | Timeout sau 10 giây, không nhận được response | Response 201 bị mất trên mạng | COMPLETED |
| 5 | Retry, gửi lại đúng key cũ 7f3c9a2e và cùng body | INSERT được 0 dòng: trùng; đọc bản ghi thấy COMPLETED, hash khớp | Không đổi |
| 6 | Nhận 201 {"paymentId":9001} như lần đầu | Không gọi cổng thanh toán lần nữa | Không đổi |
Giải thích: Người dùng bị trừ tiền đúng một lần và app vẫn nhận được kết quả của lần thanh toán đó. Nếu retry tới khi bước 3 chưa xong (bản ghi còn PROCESSING), server trả 409 Conflict kèm Retry-After để client chờ rồi thử lại. Nếu client dùng lại key cũ cho một body khác (ví dụ số tiền 200.000đ), hash không khớp và server trả 422, vì đó là lỗi lập trình ở client. Tôi đã chạy khối SQL trên SQLite: lần INSERT đầu changes() trả 1, lần thứ hai với cùng key trả 0, còn INSERT không có ON CONFLICT thì báo UNIQUE constraint failed.
Chương trình dưới đây mô phỏng logic phía server bằng ConcurrentHashMap.putIfAbsent, đóng vai trò của unique constraint:
import java.util.concurrent.*;
public class IdempotencyKeyDemo {
record Saved(String body, String status, String response) {}
static final ConcurrentMap<String, Saved> keys = new ConcurrentHashMap<>(); // stands in for the UNIQUE table
static int charges = 0;
static String pay(String key, String body) {
Saved existing = keys.putIfAbsent(key, new Saved(body, "PROCESSING", null)); // atomic insert
if (existing != null) {
if (!existing.body().equals(body)) return "422 key reused with another body";
if (existing.status().equals("PROCESSING")) return "409 still processing";
return existing.response() + " (replayed)";
}
charges++; // the real charge happens only here
String response = "201 paymentId=" + (9000 + charges);
keys.put(key, new Saved(body, "COMPLETED", response));
return response;
}
public static void main(String[] args) {
System.out.println(pay("7f3c9a2e", "amount=100000")); // prints: 201 paymentId=9001
System.out.println(pay("7f3c9a2e", "amount=100000")); // prints: 201 paymentId=9001 (replayed)
System.out.println(pay("7f3c9a2e", "amount=200000")); // prints: 422 key reused with another body
System.out.println(pay("b41d0c77", "amount=100000")); // prints: 201 paymentId=9002
System.out.println("charges = " + charges); // prints: charges = 2
}
}Giải thích: Bốn lời gọi chỉ tạo 2 lần trừ tiền: lần retry với cùng key trả lại response đã lưu, lần dùng lại key với body khác bị từ chối, chỉ key mới b41d0c77 mới tạo thanh toán thứ hai. putIfAbsent là thao tác nguyên tử, giống INSERT đụng unique constraint: nếu hai thread gọi cùng lúc, chỉ một thread nhận null và được xử lý. Code thật lưu vào DB (để nhiều instance dùng chung), so sánh hash thay vì cả body, và đặt thời hạn cho key.
7.5 Phân trang, lọc, sắp xếp, versioning
Phân trang, lọc, sắp xếp qua query param: GET /orders?status=PAID&createdFrom=2026-09-01&page=0&size=20&sort=createdAt,desc. Spring Data Pageable hiểu sẵn page, size, sort. Luôn giới hạn size tối đa (ví dụ 100), whitelist field được sort, và có thứ tự mặc định ổn định. Page tốn thêm một câu COUNT; bảng lớn thì dùng Slice hoặc cursor (?cursor=... mã hóa giá trị keyset) như đã nói ở Chương 6.
Nhắc lại kiến thức
Mục 6.2 đã so sánh offset và keyset ở tầng SQL: offset phải đọc rồi bỏ các dòng phía trước nên càng về sau càng chậm; keyset nhớ giá trị của dòng cuối trang trước và seek thẳng tới đó nhờ index. Ở tầng API, hai cách này trở thành ?page= và ?cursor=.
Offset và keyset nhìn từ phía API
Dùng lại bảng orders mẫu của Chương 6 (đơn 101 tới 105, đơn 103 và 104 trùng created_at = 2026-09-02 08:00), mỗi trang 2 đơn, mới nhất trước.
| API request | SQL phía sau | Kết quả |
|---|---|---|
GET /orders?page=0&size=2 | ORDER BY created_at DESC, id DESC LIMIT 2 OFFSET 0 | 105, 104 |
GET /orders?page=1&size=2 | ... LIMIT 2 OFFSET 2 | 103, 102 |
GET /orders?size=2 | ORDER BY created_at DESC, id DESC LIMIT 3 | 105, 104, kèm nextCursor |
GET /orders?size=2&cursor=eyJj... | WHERE created_at < ? OR (created_at = ? AND id < ?) ... LIMIT 3 | 103, 102, kèm nextCursor |
Response của kiểu cursor trông như sau:
{
"items": [
{ "id": 105, "createdAt": "2026-09-03T07:30:00", "total": 80 },
{ "id": 104, "createdAt": "2026-09-02T08:00:00", "total": 500 }
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTAyVDA4OjAwIiwiaWQiOjEwNH0=",
"hasNext": true
}Giải thích: nextCursor là chuỗi Base64 của {"createdAt":"2026-09-02T08:00","id":104}, tức giá trị sắp xếp của dòng cuối trang. Client không cần hiểu bên trong, chỉ gửi lại nguyên văn để lấy trang sau. Server lấy size + 1 dòng (ở đây 3) để biết còn trang sau hay không mà không cần câu COUNT: có dòng thứ 3 thì hasNext = true và bỏ dòng đó khỏi items. Kiểu page hợp với màn hình admin cần nhảy trang và hiện tổng số trang; kiểu cursor hợp với infinite scroll trên mobile và API đồng bộ dữ liệu, vì không chậm dần và không lặp hay sót đơn khi có đơn mới chèn vào giữa hai lần gọi.
Versioning:
| Cách | Ví dụ | Nhận xét |
|---|---|---|
| URI | /api/v1/orders | Rõ ràng, dễ route ở gateway, dễ test; phổ biến nhất |
| Header | Accept: application/vnd.shop.v2+json | URI sạch, nhưng khó test bằng trình duyệt, khó cache |
| Query param | /orders?version=2 | Đơn giản nhưng dễ bị bỏ quên, ít dùng |
Quan trọng hơn là tránh phải tăng version: thêm field là tương thích ngược (client phải bỏ qua field lạ); đổi tên, xóa field, đổi kiểu mới là breaking change. Spring Framework 7 đã bổ sung hỗ trợ API versioning sẵn trong Spring MVC, còn với Spring Boot 3 cách phổ biến vẫn là prefix URI.
Ví dụ: một breaking change và cách chạy song song hai version
Breaking change là thay đổi làm client cũ đang chạy đúng bỗng hỏng. Ví dụ đội sản phẩm muốn hỗ trợ nhiều loại tiền tệ nên đổi total từ số sang object. Nếu sửa thẳng vào v1, app mobile cũ đọc total như số sẽ lỗi parse. Cách làm đúng là giữ v1, mở v2:
GET /api/v1/orders/104 HTTP/1.1
HTTP/1.1 200 OK
Deprecation: @1788220800
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Content-Type: application/json
{"id": 104, "status": "PAID", "total": 500}
GET /api/v2/orders/104 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{"id": 104, "status": "PAID", "total": {"amount": 500, "currency": "VND"}}Giải thích: v1 giữ nguyên format cũ để client cũ tiếp tục chạy, đồng thời gửi thêm hai header báo trước: Deprecation (chuẩn RFC 9745, giá trị là thời điểm dạng giây Unix, ở đây là 01/09/2026) cho biết version này đã bị đánh dấu ngừng phát triển, Sunset (RFC 8594) cho biết ngày dự kiến gỡ hẳn. v2 mang format mới. Server đếm traffic v1 theo từng client, liên hệ những client còn gọi, và chỉ gỡ v1 khi traffic về 0. Nếu yêu cầu chỉ là thêm thông tin tiền tệ, cách rẻ hơn nhiều là thêm field mới currency cạnh total trong v1, hoàn toàn tương thích ngược, không cần v2.
7.6 Format lỗi, validation, tài liệu hóa, rate limiting
Format lỗi thống nhất theo Problem Details (RFC 7807, nay được thay bằng RFC 9457), content type application/problem+json. Spring 6 hỗ trợ sẵn qua ProblemDetail (xem Chương 4).
Nhắc lại kiến thức
Mục 4.6 đã trình bày @RestControllerAdvice với @ExceptionHandler để gom xử lý lỗi của mọi controller về một chỗ và trả ProblemDetail. Mục này tập trung vào nội dung của response lỗi mà client nhận được.
Ví dụ dưới đây là body mà server trả về khi client gọi POST /api/v1/orders đặt 5 sản phẩm SKU-123 trong khi kho chỉ còn 2 (JSON không cho phép comment, nên ý nghĩa từng field được giải thích ngay sau block):
{
"type": "https://api.shop.vn/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Sản phẩm SKU-123 chỉ còn 2 trong kho",
"instance": "/api/v1/orders",
"traceId": "4bf92f3577b34da6",
"errors": [{ "field": "items[0].quantity", "message": "must be less than or equal to 2" }]
}Giải thích: Response đi kèm HTTP status 409 và header Content-Type: application/problem+json. Ý nghĩa từng field:
type: URI định danh loại lỗi, client dựa vào đây để rẽ nhánh xử lý (ví dụ hiện thông báo "hết hàng"); có thể trỏ tới trang tài liệu mô tả lỗi, nếu không có thì mặc định làabout:blank.title: tóm tắt ngắn, cố định cho mỗitype, không thay đổi giữa các lần lỗi.status: lặp lại HTTP status để tiện log và debug khi body bị tách khỏi response.detail: mô tả cụ thể cho lần lỗi này, có thể hiển thị cho người dùng.instance: URI của request gây lỗi.traceIdvàerrorslà extension field (chuẩn cho phép thêm field riêng):traceIdgiúp đội vận hành tìm log tương ứng,errorsliệt kê từng field sai để frontend tô đỏ đúng ô nhập. Không đưa stack trace, câu SQL hay tên class vào response.
Validation: định dạng kiểm tra bằng jakarta.validation trên DTO, nghiệp vụ kiểm tra ở service, không bind request vào entity, trả tất cả lỗi field trong một lần.
Sinh ProblemDetail trong Spring và thêm ví dụ response
Đoạn advice dưới đây sinh đúng response 409 ở ví dụ trên từ một exception nghiệp vụ, kèm các field mở rộng:
import java.net.URI;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiErrorHandler {
@ExceptionHandler(InsufficientStockException.class)
public ProblemDetail handleStock(InsufficientStockException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setType(URI.create("https://api.shop.vn/errors/insufficient-stock"));
pd.setTitle("Insufficient stock");
pd.setProperty("errors", List.of(Map.of( // extension field
"field", "items[0].quantity",
"message", "must be less than or equal to " + ex.available())));
return pd; // Spring sets status 409 and Content-Type: application/problem+json
}
}Giải thích: forStatusAndDetail đặt status và detail; instance được Spring tự điền bằng đường dẫn request nếu bạn không đặt; setProperty thêm field mở rộng và Jackson ghi chúng ngang hàng với các field chuẩn. traceId thường được thêm ở một chỗ chung (ví dụ lấy từ Micrometer Tracing) thay vì ở từng handler. InsufficientStockException là exception nghiệp vụ tự định nghĩa, có method available() trả số lượng còn trong kho.
Hai response lỗi khác mà client hay gặp, cùng một format:
POST /api/v1/orders HTTP/1.1
Content-Type: application/json
{"customerId": 1, "items": [{"sku": "", "quantity": 0}]}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type": "https://api.shop.vn/errors/validation", "title": "Validation failed", "status": 400,
"detail": "2 fields are invalid", "instance": "/api/v1/orders",
"errors": [{"field": "items[0].sku", "message": "must not be blank"},
{"field": "items[0].quantity", "message": "must be greater than 0"}]}
GET /api/v1/orders/999 HTTP/1.1
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type": "about:blank", "title": "Not Found", "status": 404,
"detail": "Order 999 not found", "instance": "/api/v1/orders/999"}Giải thích: Response 400 liệt kê mọi field sai trong một lần để frontend tô đỏ cả hai ô, thay vì bắt người dùng sửa từng lỗi rồi gửi lại. Response 404 dùng type là about:blank, nghĩa là lỗi không có ý nghĩa gì thêm ngoài chính status code, và khi đó title nên trùng tên chuẩn của status. Client viết một bộ xử lý lỗi duy nhất: đọc status để phân loại, đọc type để rẽ nhánh nghiệp vụ, hiển thị detail, và map errors vào form.
OpenAPI/Swagger: với Spring Boot 3 dùng springdoc-openapi (Springfox đã ngừng phát triển, không chạy với Boot 3). Code-first (sinh spec từ annotation) nhanh cho team nội bộ; contract-first (viết spec trước, sinh code) hợp khi nhiều team hoặc đối tác phát triển song song. Không mở Swagger UI công khai trên production nếu API không public.
Rate limiting: thuật toán phổ biến là token bucket (cho phép burst ngắn) và sliding window. Thường đặt ở API gateway (Spring Cloud Gateway với Redis, Nginx, Kong), trong ứng dụng có thể dùng Bucket4j. Giới hạn theo user/API key chứ không chỉ theo IP; vượt giới hạn trả 429 kèm Retry-After.
7.7 REST, gRPC, GraphQL và SOAP
| Tiêu chí | REST | gRPC | GraphQL |
|---|---|---|---|
| Giao thức, format | HTTP, thường JSON | HTTP/2, Protobuf nhị phân | HTTP, JSON, một endpoint |
| Hợp với | API public, CRUD, web/mobile | Giao tiếp nội bộ service-to-service, streaming | Client cần lấy dữ liệu linh hoạt, nhiều màn hình |
| Điểm mạnh | Đơn giản, cache HTTP, công cụ phong phú | Nhanh, contract chặt, sinh code nhiều ngôn ngữ | Tránh over/under-fetching |
| Điểm yếu | Over-fetching, nhiều round-trip | Khó gọi từ trình duyệt, khó debug bằng mắt | Khó cache, dễ sinh query nặng, N+1 ở resolver |
SOAP là giao thức dựa trên XML với WSDL làm contract và các chuẩn WS-* (như WS-Security); hiện chủ yếu còn gặp khi tích hợp hệ thống ngân hàng, bảo hiểm, cơ quan nhà nước đời cũ.
7.8 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): REST là gì? "Stateless" nghĩa là gì trong một dự án thật?
REST là một kiểu kiến trúc: dữ liệu được mô hình thành resource có URI, thao tác qua các method HTTP chuẩn, và server không giữ trạng thái phiên của client giữa các request. Trong dự án thật, stateless nghĩa là mỗi request tự mang theo mọi thứ cần thiết, điển hình là JWT trong header Authorization, thay vì dựa vào session lưu trong bộ nhớ của một instance. Hệ quả tốt: load balancer gửi request tới instance nào cũng được, thêm instance là scale ngay, instance chết không làm người dùng bị đăng xuất. Stateless không có nghĩa là không có dữ liệu: đơn hàng, giỏ hàng vẫn nằm trong DB; chỉ là server không nhớ "client này đang ở bước nào".
Câu hỏi (Cơ bản): Safe và idempotent khác nhau thế nào? Cho ví dụ.
Safe là không thay đổi trạng thái server; idempotent là gọi nhiều lần cho cùng trạng thái cuối như gọi một lần. Mọi method safe đều idempotent, nhưng không phải ngược lại. GET là safe và idempotent. DELETE không safe (nó xóa dữ liệu) nhưng idempotent: xóa đơn 42 ba lần thì đơn 42 vẫn chỉ "đã bị xóa". PUT cũng idempotent: đặt địa chỉ thành "Hà Nội" ba lần vẫn là "Hà Nội". POST không safe, không idempotent: gửi ba lần tạo ba đơn. Ý nghĩa thực tế: hạ tầng (HTTP client, gateway) được tự retry request idempotent khi timeout, còn POST thì không.
Câu hỏi đào sâu
"DELETE lần hai trả 404, vậy có còn idempotent không?" — Vẫn idempotent, vì định nghĩa nói về trạng thái trên server chứ không nói về response. Trạng thái sau lần 1 và lần 3 giống nhau: resource không tồn tại. Nhiều team chọn trả 204 cho cả lần sau để client đơn giản hơn; cả hai đều hợp lệ, miễn nhất quán.
Câu hỏi (Cơ bản): Tìm lỗi đặt tên trong danh sách endpoint dưới đây
GET /api/v1/getOrderList
POST /api/v1/Order/create
POST /api/v1/orders/42/delete
GET /api/v1/orders/42/updateStatus?status=CANCELLEDĐáp án:
GET /getOrderListnên làGET /api/v1/orders: URI là danh từ số nhiều, method đã nói hành động.POST /Order/createnên làPOST /api/v1/orders: bỏ động từ, dùng chữ thường số nhiều.POST /orders/42/deletenên làDELETE /api/v1/orders/42: có method chuẩn cho xóa thì dùng, để hạ tầng hiểu đây là thao tác idempotent.GET /orders/42/updateStatus?status=CANCELLEDlà lỗi nặng nhất: GET làm thay đổi dữ liệu, có thể bị prefetch, crawler hoặc retry kích hoạt. Nên làPOST /api/v1/orders/42/cancel(hành động nghiệp vụ có logic riêng), hoặcPATCH /api/v1/orders/42với body{"status": "CANCELLED"}nếu chỉ là đổi trạng thái đơn thuần.
Câu hỏi (Hiểu sâu): Vì sao POST không idempotent còn PUT thì có? Khi nào dùng PUT để tạo mới?
Vì với POST, server quyết định URI của resource mới, nên mỗi lần gọi tạo ra một resource khác; với PUT, client chỉ định sẵn URI, nên gọi lại chỉ ghi đè cùng một chỗ. POST /orders ba lần sinh đơn 43, 44, 45. PUT /users/7/avatar ba lần vẫn chỉ có một avatar ở đúng URI đó. PUT dùng để tạo mới khi client biết trước định danh, ví dụ PUT /carts/{cartId}/items/{sku} (mỗi SKU chỉ có một dòng trong giỏ) hoặc PUT /settings/notification. Lần đầu trả 201 Created, các lần sau trả 200 hoặc 204. Nhờ vậy client có thể retry thoải mái mà không cần idempotency key.
Câu hỏi (Cơ bản): PUT và PATCH khác nhau thế nào? Cái nào idempotent?
PUT thay thế toàn bộ resource nên gửi lại cùng request cho cùng kết quả: idempotent. PATCH cập nhật một phần, không được đảm bảo idempotent: {"op": "add"} vào mảng hay "tăng số lượng thêm 1" gọi hai lần sẽ khác một lần, còn JSON Merge Patch đặt giá trị cụ thể thì thực tế idempotent.
Bẫy thường gặp
Nói "POST dùng để tạo, PUT dùng để sửa" và dừng ở đó. Người phỏng vấn muốn nghe về ngữ nghĩa thay thế toàn bộ, idempotency, và cách xử lý field null khi PATCH.
Câu hỏi (Đọc code): Client gửi PATCH /customers/7 với body {"phone": null} để xóa số điện thoại. Chuyện gì xảy ra?
public record UpdateCustomerRequest(String name, String phone) {}
// inside CustomerService, running in a @Transactional method
void patch(Customer c, UpdateCustomerRequest req) {
if (req.name() != null) c.setName(req.name());
if (req.phone() != null) c.setPhone(req.phone()); // null is treated as "not sent"
}Đáp án:
Số điện thoại không bị xóa, API vẫn trả thành công. Jackson bind cả {} (không gửi field) lẫn {"phone": null} (gửi null) thành phone = null trong record, nên service không phân biệt được hai ý định, và điều kiện != null bỏ qua cả hai. Theo JSON Merge Patch (RFC 7396), null nghĩa là "xóa field", còn vắng mặt nghĩa là "giữ nguyên". Cách sửa: nhận body dạng JsonNode hoặc Map<String, Object> rồi kiểm tra containsKey("phone"); dùng kiểu bọc ba trạng thái như JsonNullable<String> (thư viện jackson-databind-nullable); hoặc thiết kế endpoint riêng DELETE /customers/7/phone. Viết test cho cả ba trường hợp: không gửi, gửi null, gửi giá trị.
Câu hỏi (Cơ bản): Tạo đơn thành công bạn trả status gì? Xóa thành công thì sao?
Tạo thành công trả 201 kèm header Location: /api/v1/orders/{id}, thường kèm body resource vừa tạo; xử lý bất đồng bộ thì 202 kèm URI tra trạng thái. Xóa thành công trả 204; xóa resource không tồn tại trả 404 hoặc 204 tùy quy ước, miễn nhất quán.
Câu hỏi (Cơ bản): Tìm kiếm đơn không có kết quả thì trả 404 hay 200? Vì sao không trả 200 kèm {"success": false} cho lỗi?
GET /orders?status=REFUNDED không có đơn nào thì trả 200 với danh sách rỗng; 404 dành cho khi resource cụ thể không tồn tại, như GET /orders/999. Danh sách rỗng là một kết quả hợp lệ của phép tìm kiếm, còn resource /orders vẫn tồn tại. Về lỗi: trả 200 kèm {"success": false} làm mọi tầng hạ tầng hiểu sai. Gateway và APM đếm tỉ lệ lỗi theo status code nên dashboard báo "0% lỗi" trong khi người dùng không thanh toán được; HTTP client của frontend đi vào nhánh thành công và phải tự kiểm tra thêm một cờ; cơ chế retry và circuit breaker không kích hoạt. Status code là hợp đồng mà mọi công cụ đều hiểu, body chỉ bổ sung chi tiết.
Câu hỏi (Cơ bản): 401 và 403 khác nhau thế nào? 400 và 422?
401: chưa xác thực hoặc token hỏng/hết hạn, client cần đăng nhập lại. 403: biết bạn là ai nhưng không có quyền. 400: request sai về cú pháp hoặc kiểu dữ liệu. 422: request hợp lệ về cú pháp nhưng không xử lý được về ngữ nghĩa (vi phạm validation, quy tắc nghiệp vụ).
Câu hỏi đào sâu
"User truy cập đơn hàng của người khác, trả 403 hay 404?" — Thường trả 404 để không tiết lộ đơn đó tồn tại. Đây là câu hỏi nối sang lỗ hổng IDOR ở Chương 5.
Câu hỏi (Đọc code): Controller dưới đây trả status gì khi tạo đơn thành công và khi không tìm thấy đơn? Sửa thế nào?
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
@PostMapping
public Order create(@RequestBody Order order) {
return service.create(order);
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> get(@PathVariable long id) {
Order o = service.findOrNull(id);
if (o == null) return ResponseEntity.ok(Map.of("success", false, "message", "not found"));
return ResponseEntity.ok(Map.of("success", true, "data", o));
}
}Đáp án:
Cả hai trường hợp đều trả 200, cả hai đều sai. Có bốn vấn đề:
createtrả 200 mặc định; đúng phải là 201 Created kèm headerLocation. Sửa:return ResponseEntity.created(URI.create("/api/v1/orders/" + saved.getId())).body(OrderResponse.from(saved));.createnhận và trả thẳng entityOrder: client gửi thêm"status":"PAID"hay"id"là được bind vào entity (mass assignment), và response lộ mọi field nội bộ. Dùng DTO request/response riêng, kèm@Valid.gettrả 200 cho trường hợp không tìm thấy; đúng phải là 404 với body ProblemDetail. Sửa: service némOrderNotFoundException,@RestControllerAdvicemap sang 404 (xem 4.6), controller chỉ còn một dòng.- Bọc dữ liệu trong
{"success": true, "data": ...}là thừa khi đã dùng status code đúng, và làm mọi client phải bóc thêm một lớp.
Câu hỏi (Đọc code): Ba request dưới đây đến server theo thứ tự. Server trả gì cho mỗi request và có bao nhiêu thanh toán được tạo?
10:00:00 POST /payments Idempotency-Key: k-77 body {"orderId": 104, "amount": 500}
10:00:00 (request 1 is still calling the payment gateway, row k-77 is PROCESSING)
10:00:02 POST /payments Idempotency-Key: k-77 body {"orderId": 104, "amount": 500}
10:00:09 (request 1 finished: 201, paymentId 9001, row k-77 is COMPLETED)
10:00:15 POST /payments Idempotency-Key: k-77 body {"orderId": 104, "amount": 900}Đáp án:
Chỉ một thanh toán (9001) được tạo. Từng request:
- Request 1: INSERT key
k-77thành công, xử lý và trả201vớipaymentId 9001. - Request 2 (10:00:02): INSERT trùng khóa, đọc thấy
PROCESSINGnên trả409 ConflictkèmRetry-After, không gọi cổng thanh toán. Client chờ rồi gửi lại thì sẽ nhận đúng201 9001đã lưu. - Request 3 (10:00:15): cùng key nhưng
amountkhác, hash body không khớp nên trả422. Đây là lỗi của client: một key chỉ gắn với một ý định thanh toán; muốn thanh toán số tiền khác thì sinh key mới.
Câu hỏi (Cơ bản): Khi nào trả 409, khi nào trả 422? Cho ví dụ cụ thể.
422 khi bản thân request vi phạm quy tắc, gửi lúc nào cũng sai; 409 khi request hợp lệ nhưng xung đột với trạng thái hiện tại của resource. Ví dụ 422: đặt hàng với quantity = -1, ngày giao hàng trước ngày đặt, email sai định dạng (nhiều team dùng 400 cho nhóm này, miễn nhất quán). Ví dụ 409: đăng ký email đã tồn tại, hủy đơn đã giao, cập nhật với version cũ, request trùng idempotency key đang xử lý. Mẹo phân biệt: nếu cùng request đó có thể thành công ở một thời điểm khác (khi trạng thái khác), đó là 409.
Câu hỏi (Thực chiến): Client gọi API thanh toán bị timeout rồi retry. Làm sao không trừ tiền hai lần?
Dùng idempotency key: client sinh key cho mỗi ý định thanh toán, gửi lại đúng key khi retry. Server lưu key với unique constraint, xử lý lần đầu, lưu response, các lần trùng key trả lại response cũ. Phía gọi sang cổng thanh toán bên thứ ba cũng truyền idempotency key nếu họ hỗ trợ, và có job đối soát cho giao dịch không rõ trạng thái.
Bẫy thường gặp
"Frontend disable nút bấm sau khi click." Chỉ chặn được double-click, không chặn được retry của HTTP client, gateway hay mạng mobile chập chờn.
Câu hỏi đào sâu
"Hai request cùng key đến đồng thời thì sao?" — Unique constraint đảm bảo chỉ một insert thành công; request còn lại thấy trạng thái PROCESSING và nhận 409, hoặc chờ một khoảng ngắn rồi trả kết quả đã lưu.
Câu hỏi (Hiểu sâu): Vì sao bảng idempotency key cần lưu hash của body và cần khóa gồm cả client id?
Hash body để phát hiện client dùng nhầm một key cho hai yêu cầu khác nhau; client id để key của các client khác nhau không đụng nhau. Nếu không lưu hash, request thứ hai dùng lại key cũ với số tiền khác sẽ lặng lẽ nhận lại response của lần đầu, client tưởng đã thanh toán số tiền mới. Key do client sinh (UUID) nên về lý thuyết hiếm trùng, nhưng client có thể dùng generator kém, hoặc cố tình gửi key của người khác để đọc response đã lưu của họ; đưa client_id (lấy từ token, không lấy từ body) vào khóa chính loại bỏ cả hai rủi ro.
Câu hỏi (Hiểu sâu): Nối kiến thức: vì sao "SELECT xem key đã có chưa, chưa có thì INSERT" không an toàn, dù đã đặt trong @Transactional?
Vì hai transaction chạy song song đều SELECT thấy "chưa có" trước khi bên nào INSERT, và isolation level mặc định không ngăn điều đó. Ở READ COMMITTED (PostgreSQL), mỗi SELECT chỉ thấy dữ liệu đã commit, bên kia chưa commit nên vô hình. Ở REPEATABLE READ (MySQL), snapshot còn cố định từ lần đọc đầu nên càng không thấy. Đây chính là dạng phantom: tập dòng thỏa điều kiện thay đổi sau khi đã kiểm tra (xem 6.3). @Transactional (4.5) chỉ gom các lệnh thành một đơn vị commit/rollback, không tuần tự hóa hai request. Giải pháp là để DB làm trọng tài: unique constraint trên (client_id, idem_key), INSERT trước; bên thua nhận lỗi duplicate key (Spring dịch thành DataIntegrityViolationException) hoặc ON CONFLICT DO NOTHING trả 0 dòng.
Câu hỏi (Hiểu sâu): ETag và If-Match giúp gì khi hai người cùng sửa một resource?
Chúng là optimistic lock ở tầng HTTP: client gửi kèm version đã đọc, server chỉ cập nhật nếu version chưa đổi, ngược lại trả 412 Precondition Failed. Luồng: GET /products/9 trả header ETag: "v7"; admin A và B cùng mở form. A gửi PUT /products/9 kèm If-Match: "v7", thành công, ETag thành "v8". B gửi PUT kèm If-Match: "v7", server thấy đã là "v8" nên trả 412, và B phải tải lại dữ liệu mới rồi sửa tiếp. Phía server, ETag thường lấy từ cột @Version của entity (xem 6.3), nên HTTP và DB dùng chung một cơ chế. Nếu không có cơ chế này, bản lưu của B ghi đè lặng lẽ thay đổi của A: lost update qua giao diện.
Câu hỏi (Đọc code): Với bảng orders mẫu ở Chương 6, hai lần gọi dưới đây trả những đơn nào?
GET /api/v1/orders?page=0&size=2&sort=createdAt,desc -> returns 105, 104
(another user creates order 106 at 2026-09-04 10:00)
GET /api/v1/orders?page=1&size=2&sort=createdAt,desc -> ?Đáp án:
Lần gọi thứ hai trả 104 và 103, nên đơn 104 xuất hiện hai lần trên màn hình. page=1&size=2 là OFFSET 2 LIMIT 2. Sau khi có đơn 106 mới nhất, thứ tự là 106, 105, 104, 103, 102, 101; bỏ 2 dòng đầu thì còn 104, 103. Ngược lại, nếu có đơn bị xóa ở trang 1, một đơn sẽ bị bỏ sót. Có thêm một lỗi tiềm ẩn: chỉ sort theo createdAt trong khi 103 và 104 trùng thời điểm, nên thứ tự giữa chúng không ổn định giữa các lần gọi; phải luôn thêm id làm cột sắp xếp phụ. Dùng cursor (nextCursor mã hóa createdAt và id của dòng cuối) thì lần gọi thứ hai trả đúng 103, 102.
Câu hỏi (Hiểu sâu): Spring Data trả Page và Slice khác nhau thế nào? Khi nào dùng cái nào?
Page chạy thêm một câu COUNT(*) để biết tổng số dòng và tổng số trang; Slice chỉ lấy size + 1 dòng để biết còn trang sau hay không. Với bảng vài triệu dòng có điều kiện lọc, câu COUNT có thể còn chậm hơn chính câu lấy dữ liệu, và chạy lại ở mỗi lần chuyển trang. Dùng Page khi giao diện thật sự cần "Trang 3 / 120" và dữ liệu nhỏ vừa; dùng Slice hoặc cursor cho infinite scroll, mobile, API đồng bộ. Một thỏa hiệp hay gặp: hiển thị tổng số ước lượng, hoặc chỉ đếm tới một ngưỡng ("hơn 1000 kết quả").
Câu hỏi (Hiểu sâu): Thay đổi nào là breaking change? Thêm một giá trị enum mới có phải không?
Breaking change là thay đổi làm client đang chạy đúng bị hỏng: xóa hoặc đổi tên field, đổi kiểu dữ liệu, thêm field bắt buộc vào request, đổi ý nghĩa của field hay status code, siết validation. Thêm field vào response và thêm endpoint mới là tương thích ngược, với điều kiện client bỏ qua field lạ (Jackson ở phía client cần FAIL_ON_UNKNOWN_PROPERTIES = false, Spring Boot đã tắt sẵn mặc định này). Thêm giá trị enum mới, ví dụ status: "PARTIALLY_REFUNDED", là vùng xám: về cú pháp là tương thích, nhưng app mobile dùng switch đầy đủ hoặc parse enum chặt sẽ crash. Cách phòng: thỏa thuận từ đầu rằng client phải có nhánh mặc định cho giá trị lạ, và ghi rõ trong tài liệu OpenAPI.
Câu hỏi (Hiểu sâu): Token bucket hoạt động thế nào? Cho ví dụ con số.
Mỗi client có một "xô" chứa tối đa N token, token được nạp lại với tốc độ cố định; mỗi request lấy một token, xô rỗng thì trả 429. Ví dụ xô 20 token, nạp 10 token mỗi giây. Client im lặng một lúc thì xô đầy 20, nên có thể bắn 20 request liền một lúc (burst) và đều qua. Request thứ 21 ngay sau đó bị 429 kèm Retry-After: 1. Sau đó client chỉ duy trì được trung bình 10 request mỗi giây. So với fixed window (đếm theo từng phút tròn), token bucket không bị hiện tượng dồn gấp đôi quota ở ranh giới hai phút. Với nhiều instance, trạng thái xô phải nằm ở chỗ dùng chung như Redis, nếu không mỗi instance cho qua một quota riêng.
Câu hỏi (Hiểu sâu): Bạn đánh version API thế nào? Khi nào phải lên v2?
Tôi dùng prefix URI /api/v1 vì đơn giản, dễ route và dễ test. Chỉ lên version khi có breaking change: xóa/đổi tên field, đổi kiểu, đổi ngữ nghĩa. Thêm field hay endpoint mới thì không cần. Khi có v2, chạy song song với v1, theo dõi traffic v1, thông báo lịch ngừng hỗ trợ, và chỉ gỡ v1 khi client đã chuyển hết.
Câu hỏi (Hiểu sâu): Khi nào bạn chọn gRPC hoặc GraphQL thay cho REST?
REST cho API public và giao tiếp với web/mobile vì đơn giản, cache được, ai cũng gọi được. gRPC cho giao tiếp nội bộ giữa các service cần độ trễ thấp, throughput cao, contract chặt, hoặc streaming. GraphQL khi có nhiều loại client cần các tập dữ liệu khác nhau từ cùng một nguồn, nhất là khi muốn giảm số round-trip của mobile, đổi lại phải xử lý giới hạn độ phức tạp query và N+1 trong resolver.
Câu hỏi (Thực chiến): Đối tác báo API tạo đơn đôi khi tạo hai đơn giống hệt nhau. Bạn điều tra và sửa thế nào?
Gần như chắc chắn là request bị gửi lặp (retry ở client, gateway, hoặc người dùng bấm hai lần) và API không có cơ chế chống trùng.
- Xác nhận: tìm các cặp đơn trùng, so thời gian tạo (thường cách nhau đúng bằng timeout của client, ví dụ 30 giây), so request id hoặc IP trong access log của gateway. Kiểm tra gateway hoặc HTTP client của đối tác có cấu hình retry cho POST không.
- Sửa phía server: hỗ trợ header
Idempotency-KeychoPOST /orders, lưu key với unique constraint như mục 7.4; hoặc dùng khóa nghiệp vụ tự nhiên nếu có, nhưUNIQUE (partner_id, partner_order_ref)để đơn trùng mã tham chiếu bị từ chối và trả lại đơn đã tạo. - Sửa phía hạ tầng: tắt retry tự động cho POST ở gateway, hoặc chỉ bật khi có idempotency key.
- Dọn dữ liệu: hủy các đơn trùng đã phát sinh theo quy trình nghiệp vụ, báo đối tác.
Câu hỏi đào sâu
"Nếu request đầu tiên xử lý lỗi giữa chừng thì key xử lý sao?" — Lỗi nghiệp vụ có kết quả xác định (ví dụ 422 hết hàng) thì lưu response đó như bình thường. Lỗi tạm thời (DB mất kết nối) thì xóa key hoặc chuyển về trạng thái cho phép thử lại, để client retry được. Bản ghi kẹt ở PROCESSING quá lâu (instance chết) cần job dọn theo thời gian.
Câu hỏi (Thực chiến): Endpoint xuất báo cáo doanh thu mất 2 phút và hay bị timeout ở gateway. Bạn thiết kế lại thế nào?
Chuyển sang xử lý bất đồng bộ: nhận yêu cầu thì trả 202 Accepted kèm URI tra trạng thái, xử lý ở nền, client hỏi lại hoặc được báo khi xong. Luồng cụ thể:
POST /api/v1/reportsvới tham số báo cáo, server tạo bản ghi jobPENDINGvà trả202 Accepted, headerLocation: /api/v1/reports/555.- Worker xử lý job (qua thread pool, queue hoặc scheduler), cập nhật
RUNNINGrồiDONEkèm đường dẫn file. - Client gọi
GET /api/v1/reports/555định kỳ, nhận{"status": "RUNNING"}, rồi{"status": "DONE", "downloadUrl": "..."}; hoặc server gửi webhook, email khi xong.
Cách này không giữ kết nối HTTP và thread của server trong 2 phút, gateway không timeout, và người dùng bấm lại cũng không chạy trùng nếu kèm idempotency key hoặc kiểm tra job đang chạy cùng tham số.
Câu hỏi (Thực chiến): Sau khi deploy, app mobile phiên bản cũ bị crash vì một field đổi tên. Bạn xử lý tình huống và phòng ngừa thế nào?
Xử lý ngay: rollback hoặc trả lại field cũ song song với field mới; phòng ngừa: coi response là hợp đồng và kiểm tra tương thích tự động. Trước mắt, tôi ưu tiên khôi phục cho client cũ nhanh nhất: rollback bản deploy, hoặc hotfix để response chứa cả customerName (cũ) lẫn fullName (mới). App mobile không thể buộc người dùng cập nhật ngay, nên field cũ phải sống cho tới khi thống kê cho thấy phiên bản app cũ không còn người dùng. Phòng ngừa: quy tắc "chỉ thêm, không đổi, không xóa" trong một version; so sánh spec OpenAPI giữa hai bản trong CI bằng công cụ phát hiện breaking change (ví dụ openapi-diff); contract test với client quan trọng; và khi thật sự cần đổi, mở /v2 như mục 7.5.
Câu hỏi (Thực chiến): API của bạn gọi cổng thanh toán bên thứ ba và bị timeout. Bạn trả gì cho client và xử lý giao dịch ra sao?
Timeout nghĩa là "không biết", không phải "thất bại": không được báo thất bại cho người dùng rồi để họ thanh toán lại. Trả trạng thái đang xử lý và đối soát cho ra kết quả thật. Cụ thể: lưu giao dịch với trạng thái PENDING trước khi gọi cổng, luôn gửi idempotency key (hoặc mã giao dịch của mình) sang cổng thanh toán; khi timeout, trả cho client 202 Accepted kèm URI tra trạng thái (hoặc 504 nếu API là đồng bộ, kèm hướng dẫn tra lại chứ không bảo thanh toán lại). Sau đó một job gọi API tra cứu giao dịch của cổng theo mã của mình để chốt SUCCESS hay FAILED, và nhận webhook từ cổng nếu có. Retry sang cổng chỉ an toàn khi cổng hỗ trợ idempotency key; nếu không, phải tra cứu trước khi gửi lại.
Câu hỏi (Đọc code): Response lỗi dưới đây có những vấn đề gì?
HTTP/1.1 200 OK
Content-Type: application/json
{"status": 404, "error": "org.hibernate.ObjectNotFoundException",
"message": "No row with the given identifier exists: [Order#999]",
"trace": "at org.hibernate.internal.SessionImpl.load(...)\n at com.shop.order..."}Đáp án:
Có bốn vấn đề: sai status code, sai content type, lộ chi tiết nội bộ, và không theo format lỗi thống nhất.
- HTTP status là 200 trong khi body nói 404: gateway, monitoring và client đều hiểu là thành công. Phải trả HTTP 404.
Content-Typenên làapplication/problem+jsonvà body theo RFC 9457 (type,title,status,detail,instance) như mục 7.6.- Tên class Hibernate, thông điệp nội bộ và stack trace lộ công nghệ, cấu trúc package và entity, giúp kẻ tấn công dò hệ thống (xem Chương 5). Stack trace chỉ nên nằm trong log server, response chỉ mang
traceIdđể tra log. detailnên là câu dành cho client, ví dụ "Order 999 not found". Trong Spring Boot, kiểm tra thêmserver.error.include-stacktracephải đểnever(mặc định) trên production.
7.9 Tóm tắt trọng tâm
- Resource là danh từ số nhiều trên URI, còn hành động do method HTTP thể hiện, vì client, cache và gateway đều dựa vào method để hiểu ngữ nghĩa của request.
- Stateless nghĩa là mỗi request tự mang đủ thông tin (thường là token), nhờ đó bất kỳ instance nào cũng xử lý được và scale ngang dễ dàng.
- Idempotent nói về trạng thái trên server sau nhiều lần gọi, không nói về response, nên DELETE lần hai trả 404 vẫn là idempotent.
- GET, PUT, DELETE được hạ tầng tự retry an toàn; POST thì không, vì mỗi lần gọi có thể tạo thêm một resource mới.
- Không bao giờ dùng GET để thay đổi dữ liệu, vì trình duyệt, crawler và proxy được phép tự gọi GET.
- Status code là hợp đồng với client và hạ tầng: 4xx là client phải sửa request, 5xx là lỗi phía server và có thể retry nếu request idempotent.
- Tạo mới trả 201 kèm
Location, xử lý bất đồng bộ trả 202 kèm URI tra trạng thái, không trả 200 kèm{"success": false}cho lỗi. - Idempotency key dựa vào unique constraint ở DB, vì kiểm tra bằng SELECT rồi INSERT luôn có khe hở race condition.
- Request trùng key trả lại đúng response đã lưu, đang xử lý thì trả 409, cùng key khác body thì trả 422.
- Cursor pagination nhanh ở mọi trang và không lặp hay sót dữ liệu khi có bản ghi mới, nên hợp với mobile và API đồng bộ; offset hợp với màn hình admin cần nhảy trang.
- Chỉ tăng version khi có breaking change; thêm field là tương thích ngược, còn v1 cũ phải chạy song song và báo trước bằng
Deprecation,Sunset. - Lỗi theo một format duy nhất là ProblemDetail (RFC 9457), liệt kê mọi field sai trong một lần và không bao giờ chứa stack trace.
- ETag với
If-Matchlà optimistic lock ở tầng HTTP, trả 412 khi resource đã bị người khác sửa.
Câu hỏi (Thực chiến): Một client gọi GET /orders?size=100000 làm DB và heap của service tăng vọt. Bạn phòng thủ thế nào?
Không bao giờ tin tham số phân trang từ client: đặt giới hạn size tối đa, whitelist field sort, và thêm rate limit theo client. Cụ thể:
- Giới hạn
size: Spring Data cóspring.data.web.pageable.max-page-size(mặc định 2000), nên hạ xuống mức hợp lý như 100; request vượt ngưỡng thì cắt về 100 hoặc trả 400 kèm thông báo rõ. - Whitelist sort: chỉ cho phép sort theo cột có index (
createdAt,id); sort theo cột tùy ý có thể gây full scan kèm filesort trên bảng lớn (xem 6.2). - Nhu cầu lấy toàn bộ dữ liệu thật sự (xuất Excel, đồng bộ) thì cung cấp endpoint riêng: cursor pagination, hoặc job bất đồng bộ trả 202 như câu hỏi về báo cáo ở trên.
- Rate limit theo API key để một client không chiếm hết tài nguyên, trả 429 kèm
Retry-After. - Đặt timeout cho query (
jakarta.persistence.query.timeouthoặcstatement_timeoutcủa PostgreSQL) để một query tệ không giữ connection mãi.