Bài cuối của chặng web. Hai quyết định mà sửa về sau rất tốn: phân trang và phiên bản.

Phân trang bằng offset

Cách quen thuộc nhất:

  GET /don?trang=2&cong=3&trangThai=MOI

  {"noiDung":[{"id":12,...},{"id":14,...},{"id":16,...}],
   "trang":2,"tongSo":500,"tongTrang":167}

Spring Data cho sẵn:

@GetMapping("/don")
Page<DonDto> ds(@PageableDefault(size = 20, sort = "ngayTao") Pageable p) {
    return repo.findAll(p).map(DonDto::tu);
}

Pageable tự đọc ?page=0&size=20&sort=ngayTao,desc. Page mang theo tổng số và tổng số trang.

Hai vấn đề, và cả hai chỉ lộ ra khi dữ liệu lớn:

Trang sâu rất đắt. OFFSET 100000 LIMIT 20 buộc CSDL đọc và bỏ đi một trăm nghìn dòng trước khi lấy hai mươi dòng bạn cần. Trang 1 nhanh, trang 5000 là truy vấn đắt nhất hệ thống — và nó thường do một con bot gọi.

Page chạy thêm một truy vấn COUNT(*). Trên bảng lớn có điều kiện lọc, câu đếm này có khi tốn hơn câu lấy dữ liệu. Không cần tổng số thì dùng Slice, nó chỉ hỏi thêm một dòng để biết còn trang sau không.

Phân trang bằng con trỏ

  GET /don-contro?sau=4&cong=3

  {"noiDung":[{"id":5,...},{"id":6,...},{"id":7,...}],"conTroTiep":7}
SELECT * FROM don WHERE id > :sau ORDER BY id LIMIT :cong

Với chỉ mục trên id, truy vấn này nhanh như nhau ở trang 1 và trang 5000 — CSDL nhảy thẳng tới vị trí, không đọc bỏ gì.

Nó còn giải quyết vấn đề thứ hai của offset: dữ liệu chèn vào giữa lúc bạn đang lật trang. Với offset, một bản ghi mới ở đầu làm mọi thứ dịch đi một chỗ, và bạn thấy lại một bản ghi ở trang sau. Với con trỏ, điều đó không xảy ra.

Đánh đổi: không nhảy tới trang bất kỳ được, và không có tổng số trang. Nên:

Dùng offset khi Dùng con trỏ khi
Giao diện quản trị có số trang Cuộn vô tận
Dữ liệu nhỏ (dưới vài nghìn) Bảng lớn
Cần biết tổng số API cho di động
Xuất dữ liệu, đồng bộ

Con trỏ nên sắp theo khoá duy nhất và không đổi. Sắp theo ngayTao thì hai bản ghi cùng giây sẽ làm lệch kết quả — dùng cặp (ngayTao, id):

WHERE (ngay_tao, id) < (:ngay, :id) ORDER BY ngay_tao DESC, id DESC

đặt trần cho size. ?size=1000000 là một request đủ để làm hết bộ nhớ — đúng bài học ở bài 60 sê-ri Java, nơi một request 44 byte làm ứng dụng hết bộ nhớ. Kiểm trước khi truy vấn:

int cong = Math.min(p.getPageSize(), 100);

Đánh phiên bản: bốn cách

Trong URL/api/v1/don, /api/v2/don:

@RestController @RequestMapping("/api/v1/don") class DonV1 { }
@RestController @RequestMapping("/api/v2/don") class DonV2 { }

Rõ ràng, thấy được trong log, dán được vào trình duyệt, cache dễ. Bị chê là "không thuần REST" vì cùng một tài nguyên có hai URL. Tôi vẫn khuyên cách này — nó là cách duy nhất mà mọi người trong đội hiểu ngay.

Bằng header Accept:

@GetMapping(value="/v", produces="application/vnd.cty.v1+json") String v1(){ }
@GetMapping(value="/v", produces="application/vnd.cty.v2+json") String v2(){ }
  Accept: application/vnd.cty.v1+json  -> "phien ban 1"
  Accept: application/vnd.cty.v2+json  -> "phien ban 2"
  Accept: application/json             -> 406
Dòng thứ ba là cái bẫy. Client không khai phiên bản nhận về 406 Not Acceptable, không phải bản mặc định. Nghĩa là mọi client hiện có gãy ngay khi bạn chuyển sang cách này — kể cả những client bạn không biết là tồn tại.

Muốn có mặc định thì phải thêm một mapping nữa cho application/json và cho nó trỏ vào v1. Nhớ điều đó trước khi triển khai, không phải sau.

Header tuỳ biếnX-API-Version: 2. Gọn, nhưng vô hình với bộ đệm và khó thử bằng trình duyệt.

Tham số truy vấn?version=2. Dễ nhất để thử, nhưng làm bẩn mọi URL và dễ bị quên.

Cách tốt nhất: đừng cần phiên bản

Phần lớn thay đổi API không cần bản mới:

Thay đổi Có phá không
Thêm trường vào phản hồi Không — client bỏ qua trường lạ (bài 15)
Thêm tham số tuỳ chọn Không
Thêm endpoint Không
Bỏ trường
Đổi tên trường
Đổi kiểu dữ liệu
Làm một tham số thành bắt buộc
Thu hẹp khoảng giá trị hợp lệ Có — và im lặng

Dòng đầu là lý do bài 15 quan trọng: Spring Boot tắt FAIL_ON_UNKNOWN_PROPERTIES, nên thêm trường là an toàn. Nếu client của bạn bật nó lên, thêm trường sẽ phá — nên đừng bật với API bên ngoài.

Dòng cuối là loại phá tệ nhất: không có lỗi biên dịch, không có 4xx, chỉ có một tỷ lệ nhỏ request bắt đầu bị từ chối và không ai biết vì sao.

Chiến lược của tôi: thêm thì tự do, bỏ thì đánh dấu ngừng dùng rồi chờ. Chỉ lên phiên bản mới khi thật sự phải phá.

Ngừng dùng cho đàng hoàng

Khi phải bỏ, có ba việc:

@Deprecated
@GetMapping("/api/v1/don")
ResponseEntity<?> cu() {
    return ResponseEntity.ok()
        .header("Deprecation", "true")
        .header("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT")
        .header("Link", "</api/v2/don>; rel=\"successor-version\"")
        .body(...);
}

DeprecationSunset là header chuẩn — công cụ giám sát đọc được.

Đo xem còn ai dùng. Một chỉ số đếm theo phiên bản, và bạn biết chính xác lúc nào tắt được thay vì đoán.

Báo trước, bằng thời gian thật. Với API nội bộ, một tháng. Với API công khai, sáu tháng tới một năm.

Thử ba mươi giây

time curl -s "http://localhost:8080/api/don?page=0&size=20" -o /dev/null
time curl -s "http://localhost:8080/api/don?page=5000&size=20" -o /dev/null

Trang 5000 chậm hơn nhiều lần nghĩa là bạn đang dùng offset trên bảng lớn — và một con bot lật trang tuần tự đủ để làm chậm cả hệ thống. Đó là lúc chuyển sang con trỏ.

Chặng web khép lại ở đây. Ngày mai bắt đầu chặng dữ liệu: Spring Data JPA — bắt đầu từ những gì nó làm hộ bạn và những gì nó âm thầm làm sau lưng.