Chuyển hướng (redirect) là thứ xảy ra thầm lặng: bạn gõ một địa chỉ, và trước khi thấy trang, trình duyệt lặng lẽ đi qua một hoặc vài địa chỉ trung gian. Mỗi bước trung gian đó không miễn phí — nó là một vòng khứ hồi mạng phụ trước khi nội dung thật kịp về. Bài này dựng một máy chủ có các chuỗi chuyển hướng của riêng tôi, đo chính xác mỗi 301/302 thêm bao nhiêu vòng, và chỉ ra vì sao con số đó phụ thuộc vào một điều tôi suýt bỏ qua.

Cách chuyển hướng hoạt động

Khi client xin một địa chỉ A và server muốn nó đi chỗ khác, server trả về một mã 3xx (thường là 301 hoặc 302) kèm header Location: B. Client đọc header đó rồi tự gửi một request mới tới B. Nội dung thật chỉ về sau bước thứ hai này — nên mỗi lần chuyển hướng chèn thêm một chuyến đi-về mạng.

Khác biệt quan trọng giữa hai mã: 301 (Moved Permanently) nói "địa chỉ này đã dời hẳn", nên trình duyệt được phép nhớ và lần sau đi thẳng tới B, bỏ qua A. 302 (Found / tạm thời) nói "tạm thời dùng B thôi", nên trình duyệt không nhớ — lần nào cũng phải qua A trước. Sự khác biệt này không thấy ở lần đầu, nhưng lộ rõ ở những lần sau.

Đo: mỗi chuyển hướng thêm một vòng

Tôi đo trên đường có RTT giả lập ~60 ms, dùng lại một kết nối keep-alive (như một client tử tế nói chuyện với cùng một host):

Tình huống Số 3xx Thời gian
Trực tiếp /final (không 3xx) 0 ~120 ms
1 chuyển hướng (dùng lại kết nối) 1 ~185 ms
Chuỗi 3 chuyển hướng 3 ~300 ms

Con số rất đều: mỗi chuyển hướng thêm khoảng một vòng khứ hồi (~60 ms). Từ 0 lên 1 chuyển hướng, thời gian tăng ~65 ms; chuỗi 3 chuyển hướng cộng thêm ~180 ms, tức ~60 ms mỗi bước. Một chuỗi http://old.com → https://old.com → https://new.com → trang thật, ba bước, là ba vòng khứ hồi phụ chồng lên trước khi người dùng thấy gì.

Và đây là cái lợi của 301, đo được ở lần thứ hai:

/r301 lần 2 (đã cache) -> 0 chuyển hướng, ~120 ms
/r302 lần 2 (không cache) -> vẫn 1 chuyển hướng, ~185 ms

Vì trình duyệt nhớ 301, lần thứ hai nó đi thẳng tới đích như thể chưa từng có chuyển hướng. Còn 302, không được nhớ, nên lần nào cũng trả cái vòng đó. Với một địa chỉ được truy cập triệu lần, chọn 301 hay 302 là chọn có nhân cái vòng khứ hồi đó lên triệu lần hay không.

Không phải chuyển hướng nào cũng rẻ như nhau

Đo tới đây, tôi định chốt gọn: "một chuyển hướng bằng một vòng khứ hồi". Nhưng phép đo trên có một giả định ẩn mà tôi suýt quên nói: nó dùng lại kết nối. Cả A và B đều trên cùng một host, nên client giữ nguyên kết nối TCP đã mở và chỉ gửi thêm một request — đúng một vòng.

Tôi đo lại với client mở một kết nối mới cho mỗi bước (mô phỏng chuyển hướng sang host khác, nơi kết nối cũ không dùng lại được):

1 chuyển hướng, kết nối MỚI: ~250 ms  (không phải 185)
chuỗi 3, kết nối MỚI mỗi hop: ~500 ms  (không phải 300)

Mỗi chuyển hướng giờ tốn khoảng hai vòng thay vì một, vì mỗi bước phải bắt tay TCP lại từ đầu. Và điều này rất thực tế: phần lớn chuyển hướng đáng kể trong đời là đổi host — old.com sang new.com, www sang không-www, một link rút gọn sang đích thật, một luồng đăng nhập OAuth nhảy qua vài tên miền. Mỗi lần đổi host không chỉ là một request mới, mà là một phân giải DNS mới, một bắt tay TCP mới, và nếu là HTTPS thì cả bắt tay TLS mới — tức hai tới bốn vòng khứ hồi cho mỗi hop, không phải một.

Điều rút ra: một con số "trung bình" che giấu điều kiện tạo ra nó. "Một chuyển hướng = một vòng" đúng cho chuyển hướng cùng host với kết nối tái dùng, nhưng sai gấp mấy lần cho chuyển hướng đổi host. Tôi suýt viết một kết luận gọn gàng mà bỏ mất chính cái yếu tố quyết định — kết nối có được dùng lại không. Khi đo một chi phí, phải hỏi rõ nó được đo trong điều kiện nào, và điều kiện đó có khớp với thực tế mình quan tâm không.

301/302 có thể đổi method; 307/308 thì không

Có một cái bẫy nữa với chuyển hướng, không phải về tốc độ mà về tính đúng. Theo lịch sử và cách trình duyệt cư xử thực tế, khi gặp 301 hoặc 302 cho một request POST, client thường đổi method thành GET cho bước tiếp theo và bỏ luôn thân request. Với một chuyển hướng trang web bình thường thì vô hại, nhưng với một POST mang dữ liệu — gửi form, gọi API tạo tài nguyên — thì dữ liệu biến mất trên đường chuyển hướng, và lời gọi âm thầm hỏng.

HTTP về sau thêm hai mã để cứu chuyện này: 307 (Temporary Redirect) và 308 (Permanent Redirect) giữ nguyên method và thân. Nếu bạn cần chuyển hướng một POST mà không mất dữ liệu, dùng 307/308 chứ không phải 302/301. Đây là kiểu lỗi chỉ lộ ra với các request có thân đi qua một chuyển hướng — dễ bỏ sót khi chỉ thử bằng trình duyệt với các GET.

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

Điều đầu tiên: cắt ngắn chuỗi chuyển hướng. Mỗi hop là độ trễ thuần túy mà người dùng cảm nhận trước khi thấy nội dung. Một cấu hình phổ biến vô tình xếp chồng nhiều hop — http://www.site.com → https://www.site.com → https://site.com là ba request cho một lần truy cập. Gộp lại thành một chuyển hướng thẳng tới đích cuối (http://www.site.com → https://site.com một bước) cắt được các vòng thừa.

Điều thứ hai: dùng 301 cho cái gì vĩnh viễn, 302 cho cái gì thật sự tạm. 301 được cache nên lần sau miễn phí, và các công cụ tìm kiếm cũng chuyển "uy tín" của URL cũ sang URL mới — quan trọng cho SEO. Dùng nhầm 302 cho một chuyển hướng vĩnh viễn là bắt mọi khách trả lại cái vòng khứ hồi mỗi lần, mãi mãi. Ngược lại, dùng 301 cho một chuyển hướng thật ra chỉ tạm thời sẽ khiến trình duyệt "kẹt" ở đích cũ khó gỡ.

Điều cuối: đặt chuyển hướng càng gần người dùng càng tốt. Một chuyển hướng xử lý ở tầng CDN hay máy chủ biên trả về ngay sau một vòng ngắn; một chuyển hướng phải đi hết vào ứng dụng backend rồi mới trả 3xx thì cái vòng đó dài hơn nhiều. Gói lại bằng số: mỗi chuyển hướng thêm ít nhất một vòng khứ hồi (~60 ms, 120 → 185 ms), và thêm hẳn một bắt tay đầy đủ nếu đổi host (~250 ms, và chuỗi 3 hop lên ~500 ms); 301 được cache nên chỉ trả giá một lần (lần 2 về 120 ms), 302 thì trả mỗi lần (vẫn 185 ms). Chuyển hướng là công cụ cần thiết, nhưng mỗi cái là một khoản độ trễ có thật — đếm chúng, rút ngắn chúng, và chọn đúng mã.

Cái phản xạ "mỗi bước gián tiếp là một vòng phụ, và cache biến vòng lặp lại thành miễn phí" áp thẳng vào rất nhiều chỗ trong lập trình chứ không riêng HTTP redirect. Trong Java/Spring, một client Feign hay RestTemplate mặc định đi theo redirect nên một API trả 302 lặng lẽ nhân đôi độ trễ mỗi lời gọi — và cái bẫy 302-biến-POST-thành-GET là bug production kinh điển khi gọi API bằng POST qua một endpoint có redirect; giống hệt, một tra cứu qua nhiều tầng cache (Caffeine → Redis → DB) mà tầng gần miss thì mỗi tầng là một "hop" cộng độ trễ, và 301-được-cache đúng là mô hình của một cache hit làm biến mất các hop sau. Go để lộ điều này rõ trong http.Client.CheckRedirect — bạn kiểm soát được có theo redirect không và tối đa mấy hop, đúng vì mỗi hop là chi phí thật cần đếm. Cơ sở dữ liệu có phiên bản riêng: một chuỗi VIEW gọi VIEW gọi bảng, hay một tầng foreign-key lookup nối tiếp, là các "hop" logic — và một materialized view (được "cache" như 301) cắt các hop đó xuống một lần tính. Mẫu số chung: mỗi lớp gián tiếp — redirect, cache tier, view, proxy hop — thêm một vòng chi phí khi phải đi qua nó, và thứ cứu bạn luôn là làm cho lần đi qua lặp lại trở nên miễn phí (cache, 301, materialize) hoặc cắt bớt số lớp; nên khi một thao tác chậm bất ngờ, câu hỏi đáng hỏi là "nó âm thầm đi qua bao nhiêu hop gián tiếp trước khi tới đích thật", vì mỗi hop là một vòng bạn có thể đang trả mà không biết.

Muốn tự thấy thì chạy curl -sIL https://mot-trang-web 2>&1 | grep -iE 'HTTP/|location' — cờ -L bảo curl đi theo chuyển hướng, và bạn sẽ thấy từng bước: mỗi dòng HTTP/… 301 hay 302 kèm một location: là một hop, một vòng khứ hồi. Đếm số dòng đó là đếm số chuyển hướng trước khi tới đích. Thêm -w 'tong: %{time_total}s, so hop: %{num_redirects}\n' -o /dev/null để curl in thẳng tổng thời gian và số lần chuyển hướng — thử trên một link rút gọn (thường nhiều hop, đổi host) so với một trang thẳng để thấy khác biệt bài này đo.