Hình dung đọc một cuốn sách dày. Có hai cách đánh dấu chỗ đang đọc, và chúng khác nhau nhiều hơn bạn tưởng — đó chính là offset so với con trỏ. Bài cuối của chặng web bàn 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 — "lật tới trang thứ N" bằng cách đếm từ trang đầu:

  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 — y như phải lật qua từng trang từ đầu sách chỉ để tới trang 5000. 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ỏ

Thay vì đếm, hãy kẹp một cái bookmark đúng chỗ bạn dừng:

  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ì. Cái kẹp sách mở ra đúng dòng, không cần lật lại từ đầu.

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 — ai đó chèn trang vào đầu sách thì số trang của bạn lệch hết. Với con trỏ, cái kẹp vẫn nằm đúng dòng, đ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
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ế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 — thêm một phụ lục vào cuối sách thì không phải in lại ấn bản:

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.

Nếu chỉ thử một thứ sau bài này, hãy bấm giờ hai trang trong 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ỏ.

Mẫu số chung

Hai quyết định trong bài này lặp lại ở mọi hệ thống có API. Một: định vị bằng "đếm từ đầu" không co giãn và không ổn định; định vị bằng "cái kẹp trên một khoá bất biến" thì được cả hai. OFFSET sâu là cái bẫy ở cả PostgreSQL lẫn MySQL, nên mọi API lớn — Twitter, GitHub, Stripe — đều phân trang bằng con trỏ; cùng ý tưởng với search_after của Elasticsearch và truy vấn khoảng của MongoDB. Bạn đánh đổi khả năng "nhảy tới trang N bất kỳ" để lấy truy vấn nhanh đều và kết quả không xô lệch khi có ghi xen vào.

Hai: bản phiên bản rẻ nhất là bản bạn không phải cắt. Thay đổi cộng thêm (thêm trường, thêm tham số tuỳ chọn, thêm endpoint) tương thích ngược ở mọi nơi — miễn là bên đọc bỏ qua cái nó không biết; đó chính là "nguyên tắc bền bỉ" (hãy khoan dung với cái nhận vào) mà protobuf và tiến hoá schema của Avro xây cả thiết kế quanh nó. Thay đổi phá vỡ (bỏ, đổi tên, đổi kiểu, thu hẹp khoảng hợp lệ) mới cần phiên bản mới và một lối thoát ngừng-dùng tử tế (Deprecation/Sunset) — và kiểu phá im lặng (thu hẹp khoảng giá trị) là tệ nhất, cùng họ với mọi lần siết chặt hợp đồng mà không báo. Sợi chỉ chung: thiết kế để tiến hoá — bên đọc khoan dung, thay đổi cộng thêm — và khi buộc phải phá thì đo xem còn ai ở bản cũ rồi cho một hạn chót thật, đừng đoán.

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.