Hình dung gọi một API như một cú điện thoại, trên đường dây tốn công thiết lập — quay số cộng bắt tay bảo mật chính là TCP cộng TLS. Nên Go giữ sẵn một bể đường dây đang mở và dùng lại chúng. Nhưng một đường dây chỉ trở về bể nếu bạn để đầu kia nói hết rồi gác máy cho đàng hoàng; cắt ngang giữa chừng là đường dây đó bỏ đi, cú gọi sau phải quay số lại từ đầu. Gọi HTTP trong Go trông đơn giản tới mức dễ làm sai, và bài này về ba chỗ đó.
http.DefaultClient không có hạn chờ
http.DefaultClient.Timeout = 0s <<< KHÔNG có hạn chờ
http.Get(url) dùng DefaultClient, và Timeout bằng 0 nghĩa là chờ vĩnh viễn — quay số mà không có đồng hồ "chờ lâu quá thì thôi".
Một dịch vụ bên kia treo là goroutine của bạn treo theo, mãi mãi. Với dịch vụ có tải, đó là cách rò rỉ goroutine nhanh nhất — bài 38 đã nói.
var client = &http.Client{Timeout: 10 * time.Second}
Timeout này bao gồm toàn bộ: kết nối, gửi, chờ, đọc hết body. Đây là hàng rào cuối cùng.
Kiểm soát mịn hơn thì dùng context:
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
Luôn dùng NewRequestWithContext thay vì http.Get trong mã thật — nó cho phép huỷ theo request cha, và đó là điều bài 29 nói về lan truyền huỷ.
Phải đọc hết body
đọc hết body : 8 ms
không đọc hết : 26 ms
Ba lần chênh lệch cho 200 request.
Nguyên nhân: Go chỉ trả kết nối về bể để tái dùng khi body đã đọc hết và đóng. Bỏ dở phần thân là Go phải đóng cả kết nối TCP — vứt bỏ đường dây — và request sau phải bắt tay lại từ đầu.
Mẫu đúng:
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
// ... đọc bình thường ...
// nếu bỏ qua phần còn lại:
io.Copy(io.Discard, resp.Body)
defer resp.Body.Close() là bắt buộc trong mọi trường hợp — không đóng là rò rỉ file descriptor, và sau vài giờ bạn nhận too many open files.
Thứ tự cũng quan trọng: kiểm err trước, vì khi có lỗi thì resp là nil và resp.Body.Close() sẽ panic.
Lỗi HTTP không phải lỗi Go
resp, err := client.Do(req)
if err != nil { return err } // chỉ lỗi MẠNG
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("máy chủ trả %d", resp.StatusCode)
}
err chỉ khác nil khi không hoàn thành được lời gọi: không phân giải được tên miền, không kết nối được, hết giờ. Máy chủ trả 404 hay 500 đều là lời gọi thành công — đường dây thông, đầu kia bắt máy và nói "bên tôi đóng cửa". Cú gọi thành, câu trả lời mới là xấu.
Đây đúng bài học ở bài 64 sê-ri Java, và nó gây ra cùng loại lỗi: đưa thân của một trang lỗi HTML vào bộ phân tích JSON.
Cấu hình Transport cho dịch vụ thật
Transport mặc định ổn cho công cụ dòng lệnh, nhưng dịch vụ gọi nhiều nên chỉnh:
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20, // mặc định chỉ 2
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
}
MaxIdleConnsPerHost mặc định là 2. Với dịch vụ gọi liên tục một backend, con số đó làm phần lớn request phải bắt tay TCP lại — cùng vấn đề với phép đo ở đầu bài, chỉ ở quy mô lớn hơn.
Tạo Client một lần và dùng lại. Nó an toàn với goroutine. Tạo mới mỗi lần gọi là mất hết bể kết nối, và đó là lỗi hiệu năng phổ biến.
Thử lại
Thư viện chuẩn không có cơ chế thử lại. Tự viết thì nhớ ba điều:
Chỉ thử lại thao tác idempotent — GET, PUT, DELETE. Thử lại POST có thể tạo hai đơn hàng.
Lùi theo cấp số nhân cộng nhiễu ngẫu nhiên, nếu không mọi client cùng thử lại một lúc.
Đọc và đóng body trước khi thử lại, nếu không mỗi lần thử là một kết nối rò rỉ.
for lan := 0; lan < 3; lan++ {
resp, err := client.Do(req)
if err == nil && resp.StatusCode < 500 { return resp, nil }
if resp != nil { io.Copy(io.Discard, resp.Body); resp.Body.Close() }
time.Sleep(time.Duration(1<<lan) * 100 * time.Millisecond)
}
Chú ý: req.Body chỉ đọc được một lần. Thử lại request có thân thì phải dùng req.GetBody.
Nếu chỉ soi một thứ trong ba mươi giây, tìm mọi lời gọi dùng client mặc định:
grep -rn 'http.Get\|http.Post\|http.DefaultClient' --include='*.go' .
Mỗi kết quả là một lời gọi không có hạn chờ. Trong dịch vụ chạy lâu, đó là chỗ sẽ treo vào ngày backend kia gặp sự cố — và nó sẽ kéo dịch vụ của bạn chết theo.
Mẫu số chung
Một kết nối mạng tốn công thiết lập (bắt tay TCP cộng TLS), nên mọi client HTTP đều gộp và dùng lại kết nối — keep-alive. Điều đó kéo theo hai hệ quả giống nhau ở mọi ngôn ngữ. Một: phải dùng lại một client/session duy nhất vì nó chính là cái bể — HttpClient của .NET tạo mới mỗi lần gọi thì cạn socket (cái bẫy nổi tiếng), requests.Session của Python dùng lại kết nối còn requests.get trần thì không, Java và Node đều có agent/pool. Hai: phải tiêu thụ hết mỗi response thì kết nối mới về được bể — chính là chuyện đọc-hết-body và con số gấp ba ở trên.
Điều thứ hai, và là cái bẫy tôi thấy nhiều người dính: "lời gọi hoàn thành" không phải "máy chủ hài lòng". Thành công ở tầng vận chuyển (byte đi-về trọn vẹn) và trạng thái ở tầng ứng dụng (200 hay 500) là hai sự thật tách rời — và nhiều client biến nó thành bẫy bằng cách không báo lỗi với 4xx/5xx: Go trả err = nil, fetch của JavaScript không reject, requests của Python phải gọi raise_for_status mới ném. Nên bạn phải tự kiểm mã trạng thái, nếu không là đưa một trang lỗi vào bộ phân tích JSON. Sợi chỉ chung: tạo một client và dùng chung (nó sở hữu cái bể), luôn đặt hạn chờ và luôn đọc-cạn-rồi-đóng body, và đừng bao giờ coi "không có lỗi" là "đã xong" — hãy kiểm mã trạng thái, vì chạm được tới một máy chủ đang trả 500 vẫn là một vòng đi-về thành công tới một câu trả lời tệ.
Ngày mai: middleware và router — tự viết middleware trong bảy dòng.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau dùng http.Get để gọi một API nội bộ đang gặp sự cố và không phản hồi. Điều gì sẽ xảy ra với goroutine gọi hàm này, và tại sao?
resp, err := http.Get("http://dich-vu-noi-bo/api/du-lieu")
Đáp án
http.Get dùng http.DefaultClient, có Timeout = 0, nghĩa là chờ vĩnh viễn — không có đồng hồ "chờ lâu quá thì thôi". Nếu dịch vụ bên kia treo, goroutine gọi hàm này sẽ treo theo mãi mãi, không bao giờ trả về lỗi lẫn kết quả. Đây là cách rò rỉ goroutine nhanh nhất theo bài viết.
Bài 2 (sửa lỗi). Đoạn mã sau kiểm tra lỗi sai thứ tự, có thể gây panic. Tìm lỗi và sửa:
resp, err := client.Do(req)
defer resp.Body.Close()
if err != nil {
return err
}
Đáp án
Khi err != nil, resp là nil, nên gọi resp.Body.Close() trước khi kiểm err sẽ panic vì truy cập trường của con trỏ nil. Phải kiểm err trước:
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
Bài 3 (vận dụng). Viết một http.Client dùng chung cho một dịch vụ gọi liên tục tới cùng một backend, khắc phục vấn đề MaxIdleConnsPerHost mặc định chỉ là 2 khiến phần lớn request phải bắt tay TCP lại. Đặt timeout tổng 10 giây.
Đáp án
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
}
Điểm mấu chốt: tạo client này một lần và dùng lại cho mọi lời gọi (nó an toàn với goroutine) — tạo mới mỗi lần gọi sẽ mất hết bể kết nối đã gộp.
Bài 4 (bẫy/đánh đổi). Đoạn mã sau gọi API và chỉ kiểm err mà không kiểm mã trạng thái HTTP. Vì sao đây là lỗi, và nó có thể gây hậu quả cụ thể gì khi backend trả về trang lỗi HTML 500?
resp, err := client.Do(req)
if err != nil {
return nil, err
}
var ketQua KetQua
json.NewDecoder(resp.Body).Decode(&ketQua)
return &ketQua, nil
Đáp án
err chỉ khác nil khi lời gọi mạng không hoàn thành (không phân giải được DNS, không kết nối được, hết giờ). Máy chủ trả 404 hay 500 vẫn là một lời gọi HTTP thành công ở tầng vận chuyển — err vẫn là nil. Nếu không kiểm resp.StatusCode, đoạn mã trên sẽ đưa thân của một trang lỗi HTML (không phải JSON) vào json.NewDecoder(...).Decode(...), khiến việc giải mã JSON thất bại với lỗi khó hiểu, hoặc tệ hơn là giải mã "thành công" một cách sai lệch. Phải kiểm resp.StatusCode != http.StatusOK trước khi decode.
Bài 5 (đọc hiểu số liệu). Bài viết đo được đọc hết body mất 8ms/request còn không đọc hết mất 26ms/request cho 200 request. Giải thích cơ chế đứng sau chênh lệch gấp ba lần này, và nêu đoạn mã đúng để tránh nó khi muốn bỏ qua phần còn lại của body.
Đáp án
Go chỉ trả kết nối TCP về bể (pool) để tái sử dụng khi body đã được đọc hết và đóng. Nếu bỏ dở phần thân, Go buộc phải đóng hẳn kết nối TCP đó, và request tiếp theo phải bắt tay TCP (và TLS nếu có) lại từ đầu — tốn công hơn nhiều so với tái dùng kết nối có sẵn. Đoạn mã đúng khi muốn bỏ qua phần còn lại:
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)