Go 1.27 tối ưu tái sử dụng kết nối HTTP/1: nhanh hơn gần 5 lần

Công nghệ14 tháng 10, 2026·5 phút đọc

Go 1.27 giới thiệu cơ chế tự động xả (drain) phần thân phản hồi HTTP/1 chưa đọc trong tối đa 50 ms hoặc 256 KB, giúp tái sử dụng kết nối hiệu quả hơn. Nhờ đó, các chương trình chỉ quan tâm mã trạng thái có thể nhanh hơn gần 5 lần so với Go 1.26. Đây là bước cải thiện đáng kể cho các ứng dụng Go gọi API liên tục.

Go 1.27 tối ưu tái sử dụng kết nối HTTP/1: nhanh hơn gần 5 lần

Go 1.27 tối ưu tái sử dụng kết nối HTTP/1: nhanh hơn gần 5 lần

Trong bài viết mới đây trên blog chính thức của Go, hai kỹ sư Nicholas Husin và Damien Neil đã trình bày một cải tiến quan trọng trong Go 1.27: cơ chế tái sử dụng kết nối HTTP/1 thông minh hơn, giúp các chương trình gọi API liên tục nhanh hơn đáng kể. Cụ thể, một hàm đơn giản chỉ lấy mã trạng thái HTTP chạy 100 lần có thể nhanh hơn gần 5 lần, từ khoảng 710 ms trên Go 1.26 xuống còn khoảng 150 ms trên Go 1.27.

Chi phí thiết lập kết nối HTTP

Để hiểu vì sao cải tiến này quan trọng, hãy nhìn vào những gì diễn ra khi thực hiện một yêu cầu HTTP. Gói net/http của Go hỗ trợ HTTP/1.0, HTTP/1.1 và HTTP/2, tất cả đều chạy trên TCP và thường là TLS khi giao tiếp với các endpoint HTTPS. Việc mở một kết nối mới bao gồm:

  • Bắt tay TCP (1 RTT): client gửi gói SYN, server trả lời SYN-ACK, client gửi ACK. Vì client có thể gửi thông điệp TLS đầu tiên cùng với ACK cuối, quá trình này tốn 1 vòng khứ hồi (RTT).
  • Bắt tay TLS (2 RTT): trao đổi khóa mã hóa và xác minh danh tính server; với TLS 1.2 tốn 2 RTT.
  • Yêu cầu HTTP: sau khi trao đổi khóa xong, yêu cầu HTTP mới có thể được gửi đi, tổng cộng 3 RTT.

Với các kết nối liên vùng, độ trễ một chiều thường đạt hoặc vượt 50 ms (100 ms RTT). So với 300 ms chỉ để thiết lập kết nối, bản thân yêu cầu HTTP có thể rất rẻ, chỉ tốn hơn 1 RTT khi băng thông dồi dào hoặc phản hồi nhỏ.

Chi phí thiết lập kết nối HTTPChi phí thiết lập kết nối HTTP

Tái sử dụng kết nối và vấn đề "thân phản hồi chưa đọc"

Để tránh trả chi phí thiết lập mỗi lần, HTTP client và server cố gắng tái sử dụng kết nối. Cơ chế này khác nhau theo phiên bản:

  • HTTP/1.0: dù không nằm trong tiêu chuẩn chính thức, client và server thường chọn tái sử dụng bằng header Connection: keep-alive.
  • HTTP/1.1: kết nối mặc định là liên tục, có thể tái sử dụng trừ khi một bên gửi Connection: close.
  • HTTP/2: nhiều yêu cầu và phản hồi có thể được ghép kênh (multiplex) trên một kết nối TCP.

Tuy nhiên, trong HTTP/1, các yêu cầu và phản hồi trên cùng một kết nối được gửi tuần tự. net/http.Get đọc và phân tích dòng trạng thái cùng header để tạo net/http.Response, rồi trả về ngay. Phần thân phản hồi vẫn chưa được đọc trên kết nối và chỉ được tiêu thụ khi người gọi đọc từ net/http.Response.Body.

Cách khung HTTP/1 truyền dữ liệuCách khung HTTP/1 truyền dữ liệu

Điều này dẫn đến một vấn đề tinh tế: nếu lập trình viên không đọc hết thân phản hồi trước khi đóng, net/http sẽ thấy còn dữ liệu chưa đọc, kết luận không thể tái sử dụng kết nối và bỏ nó đi. Hậu quả là chương trình liên tục phải tạo kết nối mới, âm thầm làm giảm hiệu năng.

Ví dụ điển hình là đoạn mã sau:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

Chương trình chỉ quan tâm mã trạng thái, nhưng API có thể trả về thân phản hồi như {"success": true, "message": "update processed"} khiến việc tái sử dụng kết nối thất bại. Ngay cả khi có đọc thân phản hồi, chương trình vẫn có thể gặp vấn đề nếu dừng chỉ một byte trước khi hết — chẳng hạn encoding/json.Decoder dừng ngay khi thấy dấu } đóng của đối tượng JSON, để lại ký tự xuống dòng chưa đọc.

Cách khắc phục thủ công trước đây

Trước Go 1.27, nhiều lập trình viên Go đã tự tìm cách xử lý bằng việc xả (drain) thân phản hồi trước khi đóng:

func drainAndCloseBody(body io.ReadCloser) {
    io.Copy(io.Discard, body) // Xả thân phản hồi trước khi đóng.
    body.Close()
}

Cách này hoạt động với phản hồi nhỏ, nhưng nếu thân phản hồi lớn, việc tạo kết nối TCP mới có thể nhanh hơn tải và bỏ đi phần còn lại. Giải pháp tốt hơn là giới hạn số byte xả và giới hạn thời gian:

func drainAndCloseBody(body io.ReadCloser) {
    drainDone := make(chan struct{}, 1)
    go func() {
        io.CopyN(io.Discard, body, 256<<10) // Chỉ xả tối đa 256 KB.
        drainDone <- struct{}{}
    }()
    // Chỉ dành tối đa 50 ms để xả thân phản hồi.
    select {
    case <-drainDone:
    case <-time.After(50 * time.Millisecond):
    }
    body.Close()
}

Đáng tiếc, việc xả thân phản hồi an toàn phức tạp hơn tưởng tượng. Khi thực hành này lan rộng, nhiều hiểu lầm xuất hiện, chẳng hạn cố xả thân phản hồi cho kết nối HTTP/2 — điều hoàn toàn không cần thiết vì HTTP/2 ghép kênh các luồng khung trên một kết nối.

Go 1.27 giải quyết tận gốc

Bắt đầu từ Go 1.27, net/http.Transport sẽ tự động làm mọi thứ mà trước đây lập trình viên phải tự viết: nó xả bất đồng bộ thân phản hồi HTTP/1 chưa đọc trong tối đa 50 ms hoặc 256 KB. Ngoài ra còn có các tối ưu bổ sung, ví dụ khi phản hồi chứa header Connection: close, việc xả thân phản hồi chắc chắn là vô ích nên Go sẽ bỏ qua.

Go là về sự đơn giản. Hãy để mã của bạn sạch sẽ và đơn giản, còn net/http sẽ lo phần phức tạp thay bạn.

Khuyến nghị mới cho lập trình viên rất đơn giản: chỉ cần đóng thân phản hồi khi không cần nữa, như đoạn mã ban đầu. Cơ chế này có thể tiếp tục phát triển trong các phiên bản Go tương lai khi có thêm cải tiến mới.

Với các lập trình viên Go tại Việt Nam đang xây dựng các dịch vụ gọi API liên tục, microservice hoặc crawler, cải tiến này đặc biệt hữu ích vì giúp giảm độ trễ và tài nguyên mạng mà không cần sửa mã. Đây là một trong những tối ưu "âm thầm" nhưng có tác động lớn đến hiệu năng thực tế.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗