Мэдээ

Коддоо ганц ч мөр өөрчлөхгүйгээр 5 дахин хурдсав: Go 1.27-ийн HTTP/1 connection reuse-ийн том шинэчлэл

TOGTOKHTOGTOKH·2026 оны аравдугаар сарын 14·2 үзсэн·
Коддоо ганц ч мөр өөрчлөхгүйгээр 5 дахин хурдсав: Go 1.27-ийн HTTP/1 connection reuse-ийн том шинэчлэл

Go хэлний хамгийн том давуу талуудын нэг бол нэмэлт external сан ашиглах шаардлагагүйгээр өндөр ачаалал даах сүлжээний серверийг шууд net/http стандарт сангаар босгох боломж байдаг. Гэвч Go хөгжүүлэгч бүрийн дор хаяж нэг удаа гаргадаг, системд нь "дуугүй саатал" (silent performance degradation) үүсгэдэг нэгэн нарийн логик сүүлийн 10 гаруй жилийн турш оршсоор ирсэн юм.

Өнөөдөр Go-ийн үндсэн хөгжүүлэгчид болох Nicholas Husin, Damien Neil нар The Go Blog дээр уг асуудлыг Go 1.27 хувилбарт бүрэн шийдвэрлэж, кодонд нэг ч өөрчлөлт оруулахгүйгээр HTTP/1 хүсэлтүүдийн хурдыг бараг 5 дахин нэмэгдүүлсэн тухай нийтэллээ.

Энэхүү шинэчлэл яагаад чухал болох, бидний өдөр тутмын backend код дээр юу өөрчлөгдсөнийг нарийвчлан задлан шинжилье.


Олон жилийн "дуугүй урхи": resp.Body.Close() хийсэн ч яагаад холболт салж байв?

Бид ихэнхдээ ямар нэг гадаад API эсвэл дотоод микросервис рүү хүсэлт илгээхдээ дараах нийтлэг кодыг бичдэг:

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
}

Бидний зорилго зөвхөн HTTP статус код (жишээ нь 200 OK) авах байсан гэж бодъё. Хүсэлт дуусаад defer resp.Body.Close() дуудагдаж байгаа тул ямар нэг санах ойн алдагдалгүй, нөөц бүрэн чөлөөлөгдсөн гэж тооцдог.

Гэвч энд HTTP/1 протоколын бодит ажиллагааны маш том зөрчил нуугдаж байсан юм.

HTTP/1 (1.0 ба 1.1)-д нэг TCP холболтоор хүсэлт, хариунууд дараалсан байдлаар (sequential stream) дамждаг. Хэрэв сервер статус кодоос гадна багахан ч гэсэн JSON body (жишээ нь {"success": true}) илгээсэн бол, Go client уг body-г эцэс хүртэл нь (EOF хүртэл) уншилгүйгээр шууд resp.Body.Close() дуудах үед net/http.Transport тухайн холболтыг дахин ашиглах боломжгүй гэж үзээд шууд тасалдаг байв! Учир нь TCP socket дээр уншигдаагүй үлдсэн өгөгдөл байхад дараагийн HTTP хүсэлтийг тэр сувгаар илгээх боломжгүй байдагтай холбоотой.

Үнэ төлбөр нь юу байсан бэ? Handshake-ийн өндөр өртөг

Холболтыг дахин ашиглаж (keep-alive) чадахгүй бол дараагийн хүсэлт болгонд дараах шат дамжлагуудыг шинээр эхлүүлэх хэрэгтэй болно:

  1. TCP 3-way Handshake (1 RTT): SYN $\rightarrow$ SYN-ACK $\rightarrow$ ACK.
  2. TLS Handshake (1-2 RTT): Түлхүүр солилцох, сертификат баталгаажуулах зэрэгт TLS 1.2 дээр 2 RTT, TLS 1.3 дээр 1 RTT зарцуулна.
  3. HTTP хүсэлт өөрөө: Эцэст нь бодит өгөгдлийг дамжуулна.

Хэрэв дата төв хоорондын эсвэл бүс хоорондын (cross-region) сүлжээний RTT 50-100ms байдаг гэж үзвэл холболт үүсгэхэд л 150-300ms хугацаа зүгээр л салхинд хийсдэг.

Go-ийн багийн хийсэн туршилтаар index.golang.org руу дээрх функцээр 100 удаа дараалсан хүсэлт илгээхэд:

  • Go 1.26 дээр: Дунджаар ~710 миллисекунд
  • Go 1.27 дээр: Дунджаар ~150 миллисекунд зарцуулжээ.

Ямар ч логик алгоритм өөрчлөгдөөгүй байтал ердөө холболт хадгалагдсанаар бараг 5 дахин хурдан болсон байна.


Өмнө нь хөгжүүлэгчид үүнийг яаж тойрч гардаг байсан бэ?

Туршлагатай Go инженерүүд уг асуудлыг сайн мэддэг байсан бөгөөд холболтоо амьд үлдээхийн тулд response body-г заавал io.Discard руу шахан унших нэмэлт мөр код бичдэг байв:

// Өмнө нь заавал бичих шаардлагатай байсан boiler-plate
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()

Гэвч Go хэл сурч буй олон зуун мянган шинэ хөгжүүлэгч, эсвэл яаруу ажилласан багийн гишүүд үүнийг мартсанаас болж микросервисүүдийн хоорондох latency үл ойлгогдох байдлаар өсдөг байлаа. Түүнчлэн, том хэмжээний body уншиж байгаад хагас дутуу уншиж зогссон тохиолдолд ч энэ алдаа гардаг байв.


Go 1.27-ийн ухаалаг шийдэл: Автомат бөгөөд Асинхрон Drain

Go 1.27-оос эхлэн net/http.Transport нь хөгжүүлэгчийг заавал гараар io.Copy(io.Discard, ...) хийхийг шаардахаа больсон байна.

Үүнийг дараах зарчмаар шийдвэрлэжээ:

  1. Асинхрон цэвэрлэгээ (Async Drain): Хэрэглэгч resp.Body.Close() дуудах үед хэрэв цаана нь уншигдаагүй өгөгдөл үлдсэн байвал Transport түвшинд арын процесст (goroutine) автоматаар уншиж дуусгана.
  2. Бодит хязгаарлалт (Guards): Хэрэв серверийн хариу хэт аварга том байвал бесконечно уншиж сүлжээний bandwidth үрэхгүйн тулд дээд тал нь 50 миллисекунд эсвэл 256 KB хүртэлх хэмжээний үлдсэн өгөгдлийг л drain хийнэ. Энэ нь нийтлэг REST API-уудын хэрэгцээг 99% хангадаг.
  3. Ухаалаг алгасалт (Connection: close check): Хэрэв серверийн response header дээр Connection: close гэж заасан байвал холболт цаашид дахин ашиглагдахгүй нь тодорхой тул ямар нэг илүүц drain хийлгүй шууд socket-ийг хаана.

Эдгээр сайжруулалтын ачаар хөгжүүлэгч дээр дурдсан boilerplate кодыг гараар санах шаардлагагүй болж, байгалийн энгийн бичиглэл нь автоматаар хамгийн хурдан бүтээмжтэй ажиллах нөхцөл бүрдлээ.


Микросервис болон Backend архитекторуудад үзүүлэх нөлөө

Орчин үед олон нийтийн вэб дээр HTTP/2 болон HTTP/3 өргөн тархсан хэдий ч байгууллагуудын дотоод дэд бүтэц, Kubernetes кластер доторх сервис хоорондын харилцаа (Service-to-Service) ихэнхдээ энгийн unencrypted HTTP/1.1 протоколоор явагддаг.

Нэг хэрэглэгчийн хүсэлт ирэхэд ард нь 10 өөр сервис рүү жижиг GET хүсэлт илгээдэг архитектуртай бол Go 1.27 руу upgrade хийх төдийд л холболтын setup зардал үгүй болж, p95 болон p99 latency мэдэгдэхүйц буурах юм.

Go-ийн баг хэлний синтакст элдэв илүүц зүйл нэмэхээс зайлсхийж, стандарт сангийн суурь архитектурыг төгөлдөржүүлэх замаар "zero-code-change performance gain" авчирдгаа дахин нэг удаа баталлаа.


Эх сурвалж: The Go Programming Language Blog (go.dev/blog/http-connection-reuse)

Сэтгэгдэл

Ачаалж байна...