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
Và đặ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
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ến — X-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 | Có |
| Đổi tên trường | Có |
| Đổi kiểu dữ liệu | Có |
| Làm một tham số thành bắt buộc | 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(...);
}
Deprecation và Sunset 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.