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.
Một lần tôi đo hớ: 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.
Bài học đo lường: 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
Hệ quả đầ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.
Hệ quả 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ề.
Hệ quả thứ ba 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). Con số mang theo: 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ó, seek lấy đúng một mẩu — nhưng chỉ khi server trả 206 và bạn tôn trọng quy ước byte bao gồm cả hai đầu. Một off-by-one ở đây không chỉ chậm, nó làm hỏng dữ liệu.
Thử ba mươi giây
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.