Skip to content

Thiết Kế API Chuyên Nghiệp: REST, Versioning, Idempotency, Rate Limiting và Pagination

By Nhân Nguyễn on Apr 20, 2026

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:

  1. Client-Server: Tách biệt hoàn toàn UI và Database.
  2. Stateless: Mỗi request phải chứa đủ thông tin để xử lý, server không lưu session.
  3. Cacheable: Response phải được đánh dấu có cache được hay không.
  4. Uniform Interface: Tài nguyên xác định qua URI, thao tác qua HTTP Methods.
  5. 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:

MethodAn Toàn (Safe)Luỹ Đẳng (Idempotent)Mục Đích
GET✅ Có✅ CóLấy dữ liệu, không thay đổi state.
POST❌ KhôngKhôngTạ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ôngCậ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:

  1. Client tự sinh một UUID ngẫu nhiên.
  2. Client gửi kèm HTTP Header: Idempotency-Key: uuid-abc-123
  3. Server nhận request, kiểm tra xem key này đã tồn tại trong cache (ví dụ Redis) chưa.
  4. Nếu chưa: Xử lý logic (trừ tiền), lưu kết quả với key = uuid-abc-123.
  5. 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.

  1. Offset Pagination: Dùng LIMITOFFSET trong 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).
  2. 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ũ?

  1. URI Path: /api/v1/accounts/api/v2/accounts (Rõ ràng, dễ dùng nhất).
  2. 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âmChiến Lược Khuyên DùngĐánh Đổi
PaginationCursor-based (99% trường hợp)Không nhảy trang được.
PaginationOffsetDùng cho bảng admin ít dữ liệu.
VersioningURI Path (/v1, /v2)Dễ nhất cho Mobile Dev.
Rate LimitSliding Window (Redis)Chính xác nhất, cần Redis.
IdempotencyIdempotency-Key HeaderBắt buộc cho POST/PUT/PATCH quan trọng.

5 Anti-Patterns Cần Tránh Bằng Mọi Giá

  1. Dùng GET Để Thay Đổi Trạng Thái

    @GetMapping("/transfer") // ❌ TUYỆT ĐỐI KHÔNG LÀM VẬY

    Hậ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ễ.

  2. 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.

  3. Phơi Bày Database ID Tăng Dần Trên URL GET /accounts/123/transactions/456 Hậ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.

  4. 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.

  5. Thay Đổi Kiểu Dữ Liệu (Breaking Change) Trong Minor Version v2.1: amount từ number thành string. Hậu quả: App Mobile cũ đang dùng v2.x bất ngờ crash. Hãy tạo v3 nế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):

  1. 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).
  2. 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?
  3. 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).
  4. 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).
  5. 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/v2 mớ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.

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.