Thiết Kế API Chuyên Nghiệp: REST, Versioning, Idempotency, Rate Limiting và Pagination
Thiết Kế API Chuyên Nghiệp: REST, Versioning, Idempotency, Rate Limiting và Pagination
Chào mừng bạn đến với bài viết chuyên sâu về thiết kế API. Nếu bạn đã từng làm việc với các hệ thống lớn, chắc hẳn bạn hiểu rằng một API được thiết kế tốt không chỉ đơn thuần là “nó chạy được”. Một API tồi có thể làm sập ứng dụng mobile, gây ra giao dịch trùng lặp (double charge), hoặc khiến hàng ngàn thiết bị không thể hoạt động chỉ vì một thay đổi nhỏ ở backend.
Bài viết này sẽ đi sâu vào các khía cạnh quan trọng nhất của thiết kế API hiện đại thông qua một tình huống thực tế tại ngân hàng X Bank. Chúng ta sẽ cùng nhau mổ xẻ các vấn đề, hiểu cơ chế hoạt động (Mental Model), xem xét code triển khai cấp Production và cuối cùng là những sai lầm chết người cần tránh.
1. Bài Toán Thực Tế: Cơn Ác Mộng API Của X Bank
Hãy tưởng tượng bạn là Tech Lead tại X Bank, nơi vừa phát hành ứng dụng Mobile Banking mới. Chỉ sau vài ngày, đội ngũ vận hành nhận được hàng loạt than phiền:
Vấn đề 1: Sập App vì Quá Nhiều Dữ Liệu
API /api/transactions trả về 50.000 giao dịch trong 10 giây cho một tài khoản. Điện thoại user hết RAM, app crash. Backend không hề có cơ chế Pagination hay Filter.
Vấn đề 2: Trừ Tiền 2 Lần Vì Mạng Chập Chờn
User thực hiện lệnh chuyển tiền POST /api/transfer. Do sóng yếu, request timeout sau 30 giây. User bấm “Thử lại”. Server thực chất đã xử lý thành công lần đầu và trừ tiền, lần thứ hai lại trừ tiếp. Không có cơ chế Idempotency.
Vấn đề 3: Thêm Một Trường, Sập Cả Hệ Thống API v1 trả về:
{ "amount": 100.5 }
API v2 thêm trường currency:
{ "amount": 100.5, "currency": "VND" }
Ứng dụng mobile cũ (v1) parse JSON theo đúng thứ tự trường, gặp currency là crash. Hàng ngàn thiết bị không thể cập nhật app ngay lập tức (do chính sách phê duyệt của doanh nghiệp). Phiên bản API không tương thích ngược.
Vấn đề 4: Một Merchant “Phá” Hết Tất Cả API lấy số dư bị giới hạn Rate Limit 10 req/giây trên toàn cục. Một Merchant bị lỗi loop gọi API liên tục, chiếm hết quota, các Merchant khác bị chậm hoặc chặn. Không có Rate Limit theo từng user riêng biệt.
Những vấn đề trên không phải là chuyện hiếm gặp. Để giải quyết chúng, chúng ta cần hiểu rõ “Mental Model” đằng sau một API chuẩn mực.
2. Mô Hình Tư Duy (Mental Model)
REST Constraints
REST (Representational State Transfer) không phải là “API dùng HTTP” là xong. Nó là một tập hợp các ràng buộc kiến trúc:
- Client-Server: Tách biệt hoàn toàn UI và Database.
- Stateless: Mỗi request phải chứa đủ thông tin để xử lý, server không lưu session.
- Cacheable: Response phải được đánh dấu có cache được hay không.
- Uniform Interface: Tài nguyên xác định qua URI, thao tác qua HTTP Methods.
- Layered System: Client không biết mình đang nói chuyện với server thật hay proxy.
Ngữ Nghĩa HTTP Methods
Đây là bảng “cửu chương” mà mọi Senior Developer phải thuộc lòng:
| Method | An Toàn (Safe) | Luỹ Đẳng (Idempotent) | Mục Đích |
|---|---|---|---|
| GET | ✅ Có | ✅ Có | Lấy dữ liệu, không thay đổi state. |
| POST | ❌ Không | ❌ Không | Tạo mới tài nguyên hoặc thực thi hành động có tác dụng phụ. |
| PUT | ❌ Không | ✅ Có | Thay thế toàn bộ tài nguyên. |
| PATCH | ❌ Không | ❌ Không | Cập nhật một phần tài nguyên. |
| DELETE | ❌ Không | ✅ Có | Xóa tài nguyên. |
Lưu ý quan trọng: POST là KHÔNG Idempotent. Nếu gọi POST 2 lần, bạn có thể tạo ra 2 tài nguyên hoặc 2 giao dịch. Đây là gốc rễ của Vấn đề 2 ở trên.
Idempotency Key Pattern
Để biến POST thành Idempotent (an toàn khi retry), chúng ta dùng Idempotency Key.
Luồng hoạt động:
- Client tự sinh một UUID ngẫu nhiên.
- Client gửi kèm HTTP Header:
Idempotency-Key: uuid-abc-123 - Server nhận request, kiểm tra xem key này đã tồn tại trong cache (ví dụ Redis) chưa.
- Nếu chưa: Xử lý logic (trừ tiền), lưu kết quả với key =
uuid-abc-123. - Nếu có rồi: Bỏ qua logic xử lý, trả về kết quả đã lưu trước đó.
Kết quả: Dù Client có timeout và gửi lại bao nhiêu lần, tiền chỉ bị trừ một lần duy nhất.
Chiến Lược Phân Trang (Pagination)
Với 50.000 bản ghi, chúng ta không thể trả về hết.
-
Offset Pagination: Dùng
LIMITvàOFFSETtrong SQL.- Ưu: Nhảy đến trang thứ N dễ dàng.
- Nhược: Rất chậm với dữ liệu lớn (Database vẫn phải đếm qua N bản ghi đầu tiên). Không ổn định: Khi có dữ liệu mới chèn vào, kết quả trang 2 và trang 3 sẽ bị lệch (trùng lặp dữ liệu).
-
Cursor Pagination: Dùng một con trỏ (thường là timestamp hoặc ID của bản ghi cuối cùng).
- Ưu: Hiệu năng O(1) bất kể bạn đang ở trang 1 hay trang 1 triệu. Ổn định tuyệt đối khi có insert mới.
- Nhược: Không thể nhảy cóc đến trang 100. Chỉ đi được Next và Previous.
Trong thực tế Production: Cursor Pagination là lựa chọn bắt buộc cho các API dạng Feed (tin tức, lịch sử giao dịch).
Chiến Lược Versioning
Làm sao để vừa ra mắt tính năng mới vừa không làm sập app cũ?
- URI Path:
/api/v1/accountsvà/api/v2/accounts(Rõ ràng, dễ dùng nhất). - Header:
Accept: application/vnd.xbank.v2+json(RESTful chuẩn chỉnh nhưng client dễ quên).
Rate Limiting Algorithms
- Token Bucket: Cho phép “nợ” (burst). Tốt cho traffic thông thường.
- Fixed Window: Reset theo phút chẵn. Dễ bị “lố” ở ranh giới (59s + 1s = 120 request trong 2 giây).
- Sliding Window: Chính xác nhất, đếm ngược 60 giây liên tục.
3. Triển Khai Cấp Production
Lý thuyết là vậy, giờ chúng ta xem code Java/Spring thực tế để giải quyết các vấn đề trên.
3.1. REST Controller Chuẩn Mực Với Idempotency
@RestController
@RequestMapping("/api/v2/transfers")
public class TransferController {
private final TransferMoneyUseCase transferUseCase;
@PostMapping
public ResponseEntity<TransferResponse> initiateTransfer(
@RequestBody TransferRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
try {
String transferId = transferUseCase.execute(request, idempotencyKey);
TransferResponse response = new TransferResponse(transferId, "PROCESSING", ...);
// 202 Accepted: Đã nhận lệnh, đang xử lý bất đồng bộ
// Location Header: URL để client kiểm tra trạng thái sau
return ResponseEntity.accepted()
.location(URI.create("/api/v2/transfers/" + transferId))
.body(response);
} catch (InsufficientBalanceException e) {
// 400 Bad Request: Lỗi nghiệp vụ từ client
return ResponseEntity.badRequest().body(new ErrorResponse("INSUFFICIENT_BALANCE", e.getMessage()));
} catch (DuplicateTransferException e) {
// Idempotency: Trả về kết quả cũ đã lưu
return ResponseEntity.ok().body(e.getPreviousResponse());
} catch (Exception e) {
// 500 Internal Server Error: Lỗi hệ thống
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Unexpected error"));
}
}
}
3.2. Xử Lý Idempotency Key Với Redis
@Service
public class TransferMoneyUseCase {
private final Jedis redis;
private final AccountRepository accountRepository;
public String execute(TransferRequest request, String idempotencyKey) {
String cacheKey = "transfer:" + idempotencyKey;
String cachedResult = redis.get(cacheKey);
// Nếu đã xử lý request này trước đó -> trả về kết quả cũ
if (cachedResult != null) {
throw new DuplicateTransferException("Duplicate request", cachedResult);
}
// Logic chuyển tiền nghiệp vụ
Account from = accountRepository.findById(...);
Account to = accountRepository.findById(...);
from.debit(amount);
to.credit(amount);
accountRepository.save(from);
accountRepository.save(to);
String transferId = UUID.randomUUID().toString();
// Lưu kết quả vào Redis, đặt TTL 24h để tiết kiệm bộ nhớ
redis.setex(cacheKey, 86400, transferId);
return transferId;
}
}
3.3. Cursor Pagination Với HATEOAS
Để giải quyết bài toán 50.000 giao dịch, đây là cách triển khai an toàn và hiệu quả:
@GetMapping("/accounts/{accountId}/transactions")
public ResponseEntity<PaginatedTransactionResponse> getTransactions(
@PathVariable String accountId,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String cursor) {
// Lấy limit + 1 bản ghi để biết còn dữ liệu không
List<TransactionDto> results = repository.findByAccountAfterCursor(accountId, cursor, limit + 1);
boolean hasMore = results.size() > limit;
if (hasMore) {
results = results.subList(0, limit);
}
String nextCursor = results.isEmpty() ? cursor : results.get(results.size() - 1).getId();
// Bổ sung HATEOAS links
PaginationLinks links = new PaginationLinks(
"/api/v2/accounts/" + accountId + "/transactions?cursor=" + cursor,
hasMore ? "/api/v2/accounts/" + accountId + "/transactions?cursor=" + nextCursor : null,
null
);
return ResponseEntity.ok(new PaginatedTransactionResponse(results, nextCursor, hasMore, links));
}
3.4. Rate Limiting Với Sliding Window (Redis Sorted Set)
public boolean isAllowed(String userId, int limit, long windowSeconds) {
String key = "rate_limit:" + userId;
long now = System.currentTimeMillis() / 1000;
long windowStart = now - windowSeconds;
// Xóa các request cũ hơn khoảng thời gian giới hạn
redis.zremrangeByScore(key, 0, windowStart);
// Đếm số request trong cửa sổ hiện tại
long requestCount = redis.zcard(key);
if (requestCount < limit) {
// Thêm request hiện tại vào sorted set với score = timestamp
redis.zadd(key, now, UUID.randomUUID().toString());
redis.expire(key, (int) windowSeconds + 10);
return true;
}
return false;
}
4. Trade-offs và Anti-Patterns Chết Người
Nên Dùng Gì Khi Nào?
| Mối Quan Tâm | Chiến Lược Khuyên Dùng | Đánh Đổi |
|---|---|---|
| Pagination | Cursor-based (99% trường hợp) | Không nhảy trang được. |
| Pagination | Offset | Dùng cho bảng admin ít dữ liệu. |
| Versioning | URI Path (/v1, /v2) | Dễ nhất cho Mobile Dev. |
| Rate Limit | Sliding Window (Redis) | Chính xác nhất, cần Redis. |
| Idempotency | Idempotency-Key Header | Bắt buộc cho POST/PUT/PATCH quan trọng. |
5 Anti-Patterns Cần Tránh Bằng Mọi Giá
-
Dùng GET Để Thay Đổi Trạng Thái
@GetMapping("/transfer") // ❌ TUYỆT ĐỐI KHÔNG LÀM VẬYHậu quả: Trình duyệt cache lại link, Google Bot crawl là chuyển tiền, bị tấn công CSRF cực dễ.
-
Trả Về Toàn Bộ Dữ Liệu Không Pagination Hậu quả: OOM Killer trên Server, App Client crash, tốn băng thông không cần thiết.
-
Phơi Bày Database ID Tăng Dần Trên URL
GET /accounts/123/transactions/456Hậu quả: Hacker dễ dàng quét ID từ 1 đến 1000 để đánh cắp dữ liệu. Luôn dùng UUID hoặc Hash ID. -
Format Lỗi Không Đồng Nhất Chỗ thì
{ "error": "..." }, chỗ thì{ "code": 123 }. Hậu quả: Client phải viết 3-4 hàm parse lỗi khác nhau, dễ miss bug. -
Thay Đổi Kiểu Dữ Liệu (Breaking Change) Trong Minor Version
v2.1:amounttừnumberthànhstring. Hậu quả: App Mobile cũ đang dùngv2.xbất ngờ crash. Hãy tạov3nếu cần đổi kiểu dữ liệu.
5. Kết Luận và Checklist Dành Cho Bạn
Thiết kế API không khó nếu chúng ta tuân thủ các nguyên tắc trên. Một API tốt là một API dễ đoán (predictable), an toàn khi retry (idempotent), và tương thích ngược (backward compatible).
Trước khi merge code API mới, hãy tự hỏi bản thân những câu sau (không cần nhìn tài liệu):
- HTTP Methods: Tại sao không dùng GET cho thao tác chuyển tiền? (Đáp án: Vì GET an toàn và idempotent, browser có thể prefetch gây ra chuyển tiền hàng loạt).
- Pagination: Nếu có 10 triệu giao dịch, bạn dùng Offset hay Cursor? Client có nhảy tới trang 100 được không?
- Idempotency Key: Key được lưu ở đâu? Nếu request đến trễ sau 25 tiếng thì sao? (Đáp án: Lưu ở Redis với TTL. Sau khi key hết hạn, request sẽ bị coi là mới, cần cân nhắc logic nghiệp vụ để tránh trùng lặp).
- Rate Limiting: Làm sao để phân biệt giới hạn của user A và user B? (Dùng key
rate_limit:userId). - Versioning: Muốn thêm một trường bắt buộc mới vào response, bạn làm gì? (Tạo
/api/v2mới, giữ nguyên/api/v1).
Hy vọng bài viết này sẽ là “cẩm nang” giúp bạn tự tin hơn khi thiết kế những API phục vụ hàng triệu người dùng.