Tutorial

Go 1.27 Memperbaiki HTTP/1 Connection Reuse, 100 Request Turun dari 710 ms ke 150 ms

Go 1.27 Memperbaiki HTTP/1 Connection Reuse, 100 Request Turun dari 710 ms ke 150 ms

Go 1.27 membuat net/http memakai ulang koneksi HTTP/1 yang sebelumnya dibuang, dan efeknya terukur: 100 panggilan http.Get ke index.golang.org lewat HTTP/1 turun dari sekitar 710 milidetik di Go 1.26 menjadi sekitar 150 milidetik di Go 1.27, atau hampir 5 kali lebih cepat. Perubahan teknisnya adalah transport yang kini menguras body respons yang tidak dibaca secara asinkron, maksimal 50 milidetik atau 256 KB. Penjelasan resminya ditulis Nicholas Husin dan Damien Neil di blog Go pada 14 Oktober 2026.

TL;DR

  • Masalahnya bukan HTTP-nya, melainkan body respons yang tidak dibaca sampai habis. Di HTTP/1 koneksi hanya bisa dipakai ulang setelah respons selesai, jadi body yang menggantung membuat koneksi dibuang.
  • Biaya koneksi baru mahal: TCP handshake 1 RTT ditambah TLS 1.2 2 RTT, total 3 RTT sebelum request pertama, sementara request-nya sendiri sedikit lebih dari 1 RTT.
  • Gejala klasiknya ada di kode yang hanya peduli status code, dan pada decoder JSON yang berhenti tepat setelah kurung kurawal penutup sehingga satu byte newline membuat koneksi tidak reusable.
  • Sejak Go 1.27, Transport menguras body yang belum dibaca di latar belakang dengan batas 50 milidetik atau 256 KB, dan melewatkan draining kalau respons berbawa Connection: close.
  • HTTP/2 tidak punya masalah ini karena stream dimultiplex di satu koneksi, sehingga meniru trik draining untuk HTTP/2 hanya menambah kode yang tidak perlu.

Kenapa 100 request bisa makan 710 milidetik?

Karena hampir semuanya habis untuk membangun koneksi ulang, bukan untuk menunggu server. Fungsi getStatus yang jadi contoh di blog Go memanggil http.Get, defer resp.Body.Close, lalu mengembalikan status code. Body tidak pernah dibaca, karena http.Get mengembalikan kontrol segera setelah status line dan header diparsing menjadi net/http.Response, sementara body masih tertinggal di koneksi dan baru dikonsumsi ketika pemanggil membaca Response.Body. Sampai Go 1.26, ketika Close dipanggil dan net/http melihat masih ada body yang belum dibaca, koneksi dianggap tidak bisa dipakai ulang sehingga dibuang, dan request berikutnya harus memulai tiga round trip dari awal. Untuk benchmark terhadap index.golang.org yang tersebar global dan mendukung TLS 1.3, handshake memang bisa selesai dalam 1 RTT, jadi totalnya lebih murah daripada TLS 1.2, tetapi tetap mahal ketika jumlah requestnya 100.

Blog itu memberi gambaran biaya yang berguna untuk menghitung ulang arsitektur internal. Di komunikasi lintas region, one-way latency sering mencapai atau melebihi 50 milidetik, artinya 100 milidetik per RTT, dan setup koneksi bisa menempel sekitar 300 milidetik. Bandingkan dengan request yang benar-benar dilayani server, yang cuma sedikit lebih dari 1 RTT ketika bandwidth lapang atau responsnya kecil. Dalam hitungan itu, membuang koneksi karena satu byte yang tidak dibaca adalah kerugian yang sepenuhnya bisa dihindari.

Apa yang sebenarnya berubah di Go 1.27?

net/http.Transport sekarang melakukan otomatis semua yang dulu harus ditulis tangan: ia menguras body HTTP/1 yang belum dibaca secara asinkron, dengan batas waktu 50 milidetik dan batas volume 256 KB, lalu menutup body. Transport juga menambah satu optimasi yang masuk akal, yaitu melewati draining sama sekali ketika respons membawa header Connection: close, karena pada koneksi yang akan ditutup pekerjaan menguras byte adalah kerja sia-sia. Tim Go menyebut perilaku ini masih bisa berkembang di versi mendatang kalau ada penyempurnaan lain yang terlihat. Konsekuensi praktisnya untuk penulis kode: rekomendasi resmi sekarang adalah menutup body begitu tidak dipakai lagi, tanpa ritual tambahan, dan membiarkan net/http mengurus kompleksitasnya.

PerilakuGo 1.26 ke bawahGo 1.27 ke atas
Body belum dibaca saat CloseKoneksi dibuang, request berikutnya membangun koneksi baruBody dikuras di latar belakang sampai 50 milidetik atau 256 KB, koneksi disimpan untuk dipakai ulang
Respons dengan Connection: closeDraining manual masih sering dilakukanDraining dilewati karena koneksi memang akan ditutup
Boilerplate drainAndCloseBodyDitulis sendiri oleh tiap timTidak diperlukan untuk kasus normal
Waktu 100 request HTTP/1 ke index.golang.orgSekitar 710 milidetikSekitar 150 milidetik

Kapan pola ini diam-diam muncul di kode produksi?

Dua bentuk yang paling sering lolos review. Bentuk pertama adalah klien yang hanya peduli status code, misalnya untuk mengecek endpoint health atau mengirim webhook lalu melihat 200 atau 4xx. Endpoint yang dikunjunginya bisa mengirim body berisi info detail yang tidak pernah dipakai, seperti objek JSON success dan message. Connection reuse gagal, dan degradasinya sunyi karena secara fungsional program tetap benar. Bentuk kedua lebih halus, yaitu program yang membaca body tetapi berhenti tepat sebelum akhir. Decoder dari encoding/json berhenti begitu melihat kurung kurawal penutup objek, sehingga kalau server mengirim newline setelahnya, satu byte itu tetap belum dibaca dan koneksi tidak bisa dipakai ulang sampai byte itu dikonsumsi. Koneksi yang sehat akhirnya dibuang hanya karena kurang satu byte.

Mesin di baliknya berkaitan dengan cara HTTP/1 menata respons. Sebuah koneksi HTTP/1 adalah antrean byte yang ketat: respons berikutnya tidak akan muncul sebelum respons sebelumnya selesai, dan batas akhir ditentukan header Content-Length atau Transfer-Encoding. Karena itu klien harus menelan sisa body sampai ke batas tersebut supaya koneksi bisa dipakai untuk request berikutnya. HTTP/2 menata hal ini secara berbeda karena stream of frames dimultiplex di satu koneksi, sehingga body yang belum dibaca di satu stream tidak memblokir stream lain untuk berbagi koneksi.

Bagaimana cara menulis klien yang tetap cepat di Go 1.26?

Dengan menguras body sebelum menutup, tetapi versi paling naifnya punya jebakan sendiri. Pola yang paling umum beredar adalah menyalin body ke io.Discard lalu menutupnya, dan itu bekerja untuk respons kecil. Untuk respons besar, membangun koneksi baru bisa lebih cepat daripada mengunduh seluruh body hanya untuk dibuang, sehingga volumenya perlu dibatasi.

func drainAndCloseBody(body io.ReadCloser) {
    drainDone := make(chan struct{}, 1)
    go func() {
        io.CopyN(io.Discard, body, 256<<10)
        drainDone <- struct{}{}
    }()
    go func() {
        select {
        case <-drainDone:
        case <-time.After(50 * time.Millisecond):
        }
        body.Close()
    }()
}

Tiga lapisan pada kode itu mewakili tiga kesalahan yang berbeda. Batas 256 KB menjawab body besar. Batas 50 milidetik menjawab server yang meneteskan body pelan, dan ketika timeout tercapai, body.Close akan memutus io.CopyN sehingga goroutine tidak bocor. Menaruh draining di goroutine kedua menjawab kebutuhan untuk tidak memblokir caller: draining diizinkan balapan dengan request berikutnya, dan kalau draining selesai lebih dulu koneksi ikut tersimpan ke pool idle untuk dipakai nanti. Blog Go mencatat bahwa seiring meluasnya praktik ini, beberapa nuansanya sering hilang, dan salah satu kesalahpahaman yang jadi adalah mencoba menguras body untuk koneksi HTTP/2 yang sebenarnya tidak membutuhkan.

Bagaimana bedanya HTTP/1.0, HTTP/1.1, dan HTTP/2 di soal reuse?

Aturannya berbeda per versi, dan itu sebabnya perbaikan ini menyasar HTTP/1 saja. Pada HTTP/1.0, reuse sebenarnya tidak bagian dari spesifikasi resmi, tetapi klien dan server lazim masuk secara sukarela dengan mengirim header Connection: keep-alive, dan pemakaian berlanjut sampai salah satu ujung menghilangkan header itu atau mengirim Connection: close. Pada HTTP/1.1 koneksi persisten adalah default, sehingga bisa dipakai ulang untuk request dan respons berikutnya kecuali salah satu sisi menyatakan sebaliknya lewat Connection: close. Pada HTTP/2, banyak request dan respons dimultiplex di satu koneksi TCP. Yang persis sama di HTTP/1.0 dan HTTP/1.1 adalah urutan di wire: beberapa request dan respons pada koneksi yang sama dikirim berurutan, sehingga respons berikutnya hanya bisa ditemukan setelah body sebelumnya dibaca sampai batas yang ditentukan Content-Length atau Transfer-Encoding.

Detail lain yang membuat perilaku lama terasa tidak adil adalah bahwa batas waktu draining itu ada di klien, bukan di server. Server boleh mengirim header lengkap dan body kecil, tetapi selama klien tidak menghabiskan body sampai batas, koneksi tetap tidak dianggap bersih. Karena itu blog Go menekankan bahwa jebakannya sunyi: tidak ada error, tidak ada panic, hanya throughput yang turun dan koneksi yang menumpuk di sisi server. Cara tercepat untuk menyadari masalahnya adalah membandingkan jumlah koneksi yang dibuat dengan jumlah request yang dikirim pada rentang waktu yang sama.

Apakah upgrade ke Go 1.27 cukup tanpa menyentuh kode?

Untuk jalur klien standar, ya, dan itu poin utamanya. Versi stabil terbaru di jalur rilis saat artikel ini ditulis adalah go1.27.2, diterbitkan 2 Oktober 2026, dan status tiap rilis dicatat di halaman release history resmi, sehingga perbaikan ini sudah ada di kanal stabil dan bukan hanya di tip development. Yang tetap perlu dicek adalah kode yang menulis drainAndCloseBody sendiri: fungsi itu tidak lagi memberi manfaat di atas behavior Transport, dan pada kasus tertentu justru membuat caller menunggu pekerjaan yang kini bisa ditangani pustaka. Kodenya bisa dihapus dan diganti defer resp.Body.Close yang sederhana. Kalau programmu memakai custom Transport, periksa batas dan Timeout-nya, karena perubahan ini berada di lapisan reuse, bukan di konfigurasi dial.

Perubahan ini juga bukan sesuatu yang perlu di-backport manual ke kode bisnis. Kalau timmu menjaga banyak layanan kecil, cara paling murah memetakannya adalah menaikkan toolchain lebih dulu, mengukur ulang jumlah koneksi, baru menghapus boilerplate draining yang tertinggal di repo. Rilis dan tambalan tiap jalur pemeliharaan dapat dipantau di repositori Go, dan perilaku Transport di dokumentasi standar.

Satu hal yang tidak berubah: upgrade tidak memperbaiki pola membaca body yang salah untuk alasan fungsional. Body tetap harus ditutup agar koneksinya kembali ke pool, dan respons yang benar-benar besar tetap harus dibaca atau dibatasi dengan sadar. Perbaikan ini menghilangkan hukuman diam-diam untuk kode yang simpel, bukan menghapus aturan HTTP.

FAQ

Apakah efek 5 kali lebih cepat itu berlaku untuk semua program?

Tidak. Angka 710 milidetik ke 150 milidetik berasal dari benchmark 100 request kecil ke index.golang.org dengan HTTP/1 dipaksa lewat GODEBUG=http2client=0. Program yang body-nya sudah dibaca penuh, atau yang memakai HTTP/2, tidak akan melihat lonjakan sebesar itu.

Bagaimana memastikan connection reuse benar-benar terjadi?

Observasi jumlah koneksi yang terbentuk, misalnya dari metrik dokumentasi package net/http atau tcpdump, lalu bandingkan sebelum dan sesudah. Kalau request berulang masih menghasilkan handshake TCP dan TLS baru, ada body yang tidak selesai dibaca atau server yang menutup koneksi lebih dulu.

Perlu menguras body untuk HTTP/2 juga?

Tidak. HTTP/2 memultiplex stream di satu koneksi, sehingga body yang belum dibaca di satu stream tidak menghalangi stream lain memakai koneksi itu. Meniru trik draining untuk HTTP/2 hanya menambah kode yang tidak perlu.

Apa yang terjadi kalau server mengirim respons sangat besar?

Transport membatasi usaha draining pada 256 KB dan 50 milidetik, jadi klien tidak akan menghabiskan waktu panjang untuk menelan body raksasa yang tidak dipakai. Setelah salah satu batas tersentuh, body ditutup dan koneksi tidak dipaksa kembali ke pool.

💬 Komentar (0)

Belum ada komentar. Jadilah yang pertama! 💬

Komentar akan muncul setelah moderasi.