Bạn tải một tệp 100 MB, tới 90% thì rớt mạng. Phải tải lại từ đầu 90 MB đã có? Không, nếu server hỗ trợ Range request — cơ chế cho phép client xin đúng một khúc byte của tài nguyên thay vì cả tệp. Đây là nền của tải-tiếp (resume), tua video, và tải song song nhiều luồng. Bài này dựng một máy chủ hỗ trợ Range của riêng tôi, đo chính xác một lần resume tiết kiệm bao nhiêu, và vấp đúng một cái bẫy off-by-one kinh điển.

Xin một khúc byte, nhận 206

Cơ chế đơn giản. Client thêm một header nói nó muốn khúc byte nào:

Range: bytes=30000-

Nghĩa là "cho tôi từ byte thứ 30.000 tới hết". Nếu hỗ trợ, server không trả 200 với cả tệp, mà trả 206 Partial Content kèm header Content-Range cho biết đã gửi khúc nào trên tổng bao nhiêu:

HTTP/1.1 206 Partial Content
Content-Range: bytes 30000-99999/100000

Server cũng quảng cáo khả năng này bằng Accept-Ranges: bytes trong mọi phản hồi. Một chi tiết dễ vấp mà tôi sẽ quay lại: khoảng byte trong HTTP tính bao gồm cả hai đầu — bytes=0-99 là 100 byte (từ 0 tới 99), không phải 99.

Đo: resume và seek tiết kiệm bao nhiêu

Tôi dựng một tệp 100.000 byte và đo ba tình huống:

Yêu cầu Trạng thái Nhận về Khớp gốc
Tải hết (không Range) 200 OK 100.000 B —
Resume: bytes=30000- 206 70.000 B đúng
Seek: bytes=50000-50099 206 100 B đúng

Dòng thứ hai là resume: giả sử client đã tải được 30.000 byte rồi rớt mạng, nó xin bytes=30000- và chỉ nhận về 70.000 byte còn lại — né được 30 KB đã có. Với một tệp lớn thật, đây là khác biệt giữa "tải lại 90 MB" và "tải nốt 10 MB". Tôi kiểm cả nội dung: khúc nhận về trùng khớp từng byte với phần đuôi của tệp gốc.

Dòng thứ ba là seek: xin đúng một khúc 100 byte ở giữa (bytes=50000-50099) và chỉ nhận về 100 byte. Đây là cách trình phát video tua tới giữa phim mà không tải toàn bộ, và cách một số định dạng đọc phần header/index của tệp lớn mà không kéo về cả tệp. Client chỉ lấy đúng cái nó cần.

Nhưng có một điều kiện tiên quyết: server phải thật sự hỗ trợ Range. Tôi thử một server bỏ qua header Range:

Server không hỗ trợ Range, xin bytes=30000-:
  -> 200 OK, gửi lại NGUYÊN 100.000 byte

Nó lờ đi yêu cầu và gửi cả tệp với 200. Client tưởng mình resume nhưng thực ra tải lại từ đầu — resume "thành công" mà chẳng tiết kiệm gì. Đây là lý do client tử tế phải kiểm tra server trả 206 (không phải 200) trước khi tin là đã tải tiếp; thấy 200 nghĩa là server phớt lờ Range và bạn đang nhận cả tệp.

Cái bẫy off-by-one bao gồm/loại trừ

Đây là chỗ tôi vấp, và nó là một trong những off-by-one kinh điển nhất của HTTP. Khoảng byte trong Range bao gồm cả hai đầu: bytes=0-99 là 100 byte. Nhưng khi cắt khúc trong code, theo thói quen Python tôi viết body[start:end] — mà body[0:99] trong Python là 99 byte (loại trừ đầu cuối). Kết quả khi đo:

BUG (cắt body[start:end]):
  resume bytes=30000-  -> nhận 69.999 byte (thiếu 1)
  seek   bytes=50000-50099 -> nhận 99 byte (thiếu 1)

Mỗi khúc thiếu đúng một byte cuối. Tệ hơn, tôi tính Content-Length theo công thức đúng (end - start + 1, tức bao gồm), nên header ghi 70.000 trong khi thân chỉ có 69.999. Client tin Content-Length sẽ hoặc treo chờ mãi một byte không bao giờ tới, hoặc nhận về một tệp lệch một byte — hỏng âm thầm. Cách sửa là body[start:end+1] để bao gồm byte cuối, khớp đúng ngữ nghĩa HTTP. Cái khó chịu là code trông đúng hoàn toàn; chỉ khi đếm byte thật mới lòi ra.

Điều rút ra: quy ước "bao gồm hay loại trừ" là nguồn lỗi bất tận, và mỗi hệ thống chọn khác nhau. Range của HTTP bao gồm cả hai đầu; lát cắt của Python loại trừ đầu cuối; SQL BETWEEN bao gồm; nhiều API khác lại nửa-mở. Trộn lẫn hai quy ước là lệch một byte — và một byte lệch trong một tệp nhị phân là một tệp hỏng. Chỉ có đo đúng số byte nhận về so với bản gốc mới bắt được, chứ không phải nhìn code thấy "trông có vẻ đúng".

Resume an toàn: If-Range

Có một cạm bẫy đúng đắn ẩn trong resume: điều gì xảy ra nếu tệp trên server đổi giữa lúc bạn tải dở và lúc bạn tải tiếp? Nếu client mù quáng xin bytes=30000- của một tệp đã khác đi, nó sẽ ghép 30 KB đầu của bản cũ với 70 KB đuôi của bản mới — thành một tệp lai hỏng, mà không có lỗi nào báo.

HTTP giải quyết bằng header If-Range. Khi resume, client gửi kèm dấu vân tay của bản nó đang có — If-Range: "<etag>" (chính cái ETag ở bài về đệm HTTP) hoặc một Last-Modified. Server so: nếu tài nguyên chưa đổi, nó trả 206 với khúc yêu cầu như thường; nếu đã đổi, nó bỏ qua Range và trả 200 với cả tệp mới, buộc client tải lại từ đầu thay vì ghép nhầm. Một dòng header biến resume từ "nhanh nhưng có thể hỏng" thành "nhanh và luôn đúng". Trình tải nghiêm túc nào cũng gửi If-Range khi tải tiếp.

Vì sao điều này quan trọng khi lập trình

Điều đầu tiên là tải xuống bền bỉ. Bất cứ khi nào bạn tải một tệp lớn qua một đường có thể đứt (mạng di động, đường xa, kết nối chập chờn), Range cho phép tải tiếp từ chỗ đứt thay vì bắt đầu lại. Trình quản lý tải, curl -C -, các thư viện tải tệp — tất cả dựa vào đúng cơ chế này. Nếu bạn tự viết luồng tải, kiểm Accept-Ranges: bytes và lưu số byte đã nhận để còn resume.

Điều thứ hai là media và đọc một phần. Trình phát video dùng Range để tua: nhảy tới phút thứ 30 nghĩa là xin đúng khúc byte tương ứng, không tải phần trước đó. Nhiều định dạng tệp lớn (video, kho lưu trữ, cơ sở dữ liệu nhúng) được thiết kế để đọc index ở một vị trí cố định — client xin đúng khúc index đó bằng Range rồi mới quyết định lấy tiếp phần nào. Đây là cách xử lý tệp hàng GB mà không kéo cả tệp về.

Điều cuối là tải song song. Một số trình tải chia tệp thành nhiều khúc và mở nhiều kết nối, mỗi kết nối xin một khúc bằng Range, rồi ghép lại — cách vắt kiệt băng thông trên đường có độ trễ cao (nối với bài về cửa sổ TCP: mỗi luồng có cửa sổ riêng, nhiều luồng lấp đường ống nhanh hơn một luồng). Gói lại bằng số: Range biến "tải lại cả tệp" thành "tải đúng khúc còn thiếu" — resume né được phần đã có (bytes=30000- chỉ lấy 70.000 B thay vì 100.000), seek lấy đúng một mẩu (100 B) — nhưng chỉ khi server trả 206 (không phải 200) và bạn tôn trọng quy ước byte bao gồm cả hai đầu (sai là mỗi khúc thiếu 1 byte). Một off-by-one ở đây không chỉ chậm, nó làm hỏng dữ liệu.

Cái bẫy "mỗi hệ thống chọn quy ước bao-gồm/loại-trừ khác nhau, trộn lẫn là lệch một" là một trong những nguồn bug âm thầm dai dẳng nhất trong lập trình, và biết nó cứu bạn khỏi vô số tệp hỏng lặng lẽ. Trong Java, đây đúng là chỗ String.substring(begin, end) loại trừ end (nửa-mở) trong khi List.subList cũng nửa-mở nhưng SQL BETWEEN qua JDBC lại bao gồm cả hai đầu — nối một truy vấn BETWEEN với một lát cắt substring mà không đổi quy ước là lệch một phần tử; và đây là lý do idiom Java chuẩn dùng nửa-mở [start, end) xuyên suốt để ít nhất nhất quán trong một hệ. Go giữ nguyên nửa-mở cho slice s[start:end] và tài liệu nhấn mạnh điều đó chính vì lẫn lộn là bug kinh điển. Cơ sở dữ liệu là ổ của off-by-one kiểu này: BETWEEN bao gồm cả hai đầu (nên BETWEEN '2024-01-01' AND '2024-01-31' bỏ sót dữ liệu ngày 31 có giờ khác 00:00, một bug báo cáo kinh điển — người ta chuyển sang >= đầu AND < đầu-tháng-sau nửa-mở để đúng), còn phân trang LIMIT/OFFSET thì phải cẩn thận biên. Mẫu số chung: "khoảng này gồm điểm cuối hay không" là một quyết định mà mọi API dữ liệu đều phải chọn, và các hệ khác nhau chọn khác nhau — nên mỗi khi một khoảng/chỉ số đi từ hệ này sang hệ kia (HTTP↔code, SQL↔ứng dụng, thư viện↔thư viện), phải hỏi rõ quy ước hai bên và chuyển đổi tường minh; và cách duy nhất chắc chắn là đếm/so kết quả thật ở biên, vì một off-by-one không bao giờ lộ ra khi đọc code mà chỉ hiện khi đúng một phần tử ở mép bị thiếu hoặc thừa.

Muốn tự thấy thì dùng curl xin một khúc byte và xem trạng thái: curl -r 0-99 -s -D - -o /dev/null https://mot-tep-lon. Cờ -r 0-99 gửi Range: bytes=0-99. Nếu server hỗ trợ, bạn thấy dòng HTTP/… 206 Partial Content và Content-Range: bytes 0-99/… — và đúng 100 byte (không phải 99). Nếu thấy 200 OK thì server không hỗ trợ Range, đang gửi cả tệp. Thử curl -C - -O https://mot-tep-lon để tải, ngắt giữa chừng bằng Ctrl-C, rồi chạy lại đúng lệnh đó: -C - bảo curl tải tiếp từ chỗ đứt bằng Range, và bạn sẽ thấy nó chỉ kéo về phần còn thiếu.