trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 10 - Rede e HTTP6 min

Ciclo de uma Requisição

Perguntas-guia
  • Enumere tudo entre digitar a URL e ver o pixel: DNS, TCP, TLS, requisição, resposta, render.
  • Em qual dessas etapas costuma morar a latência que o usuário sente?
  • Quantos round-trips uma conexão HTTPS nova custa antes do primeiro byte útil?
  • O que keep-alive e connection pooling economizam exatamente?

Conceito

Entre digitar a URL e ver o pixel, a sequência completa:

# Etapa Custo típico
1 parse da URL — esquema, host, porta, caminho ~0
2 DNS — nome → IP (com cache em vários níveis) 0 (cache) a 100 ms
3 TCP handshake — SYN, SYN-ACK, ACK 1 RTT
4 TLS handshake — negociação, certificado, chaves 1 RTT (1.3) ou 2 (1.2)
5 envio da requisição 0,5 RTT
6 trabalho do servidor — sua aplicação e o banco variável
7 resposta trafega proporcional ao tamanho
8 parse e render no cliente variável
Onde mora a latência que o usuário sente

Quase sempre nos round trips, não no seu código.

Uma conexão HTTPS nova custa, antes do primeiro byte útil: DNS + TCP (1 RTT) + TLS (1 RTT em 1.3) ≈ 3 idas e voltas.

Com 50 ms de RTT — que é um valor normal para intercontinental — são 150 ms gastos antes de o servidor ver a requisição. Nenhuma otimização de query resolve isso.

O que keep-alive e connection pooling economizam: exatamente as etapas 2, 3 e 4. Reusar uma conexão elimina DNS, TCP e TLS — a requisição seguinte começa direto na etapa 5. É a otimização de rede de maior retorno, e é gratuita.

HTTP/2 multiplexa vários fluxos na mesma conexão TCP, eliminando o head-of-line blocking no nível HTTP (e o hack de abrir 6 conexões por domínio). HTTP/3 (sobre QUIC, que é UDP) elimina o blocking também no transporte, e funde o handshake TLS com o de conexão — 1 RTT total, ou 0 na retomada.

Na prática

Medido com net/http/httptrace contra um servidor TLS local (RTT ≈ 0, então os números isolam o custo de CPU de cada fase, não a rede):

TLS: versao=TLS 1.3 cifra=TLS_AES_128_GCM_SHA256

fases da requisicao:
  TCP connect:        223µs
  TLS handshake:      3.889ms      <- 17x o custo do TCP connect
  espera do servidor: 20.833ms
  total:              25.078ms

segunda requisicao (keep-alive): 20.51ms   <- economizou ~4.1ms

Duas leituras:

  1. O handshake TLS custa 17× o TCP connect mesmo em loopback, porque é criptografia de chave pública (3. TLS). Em rede real, somam-se os RTTs.
  2. Keep-alive economizou exatamente o setup (25,1 ms → 20,5 ms). Os 20,5 ms restantes são o trabalho simulado do servidor — irredutível por rede.

Em Go

httptrace te dá gancho para cada fase, e é a ferramenta certa quando alguém diz "a API está lenta" e você precisa saber onde:

trace := &httptrace.ClientTrace{
	DNSDone:              func(httptrace.DNSDoneInfo) { /* ... */ },
	ConnectDone:          func(net, addr string, err error) { /* ... */ },
	TLSHandshakeDone:     func(tls.ConnectionState, error) { /* ... */ },
	GotFirstResponseByte: func() { /* TTFB */ },
	GotConn:              func(i httptrace.GotConnInfo) { /* i.Reused! */ },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

GotConnInfo.Reused responde diretamente "essa requisição reusou conexão?" — é como você prova que o pool está funcionando.

Os dois erros de http.Client que mais custam em produção Go

1. http.Client sem timeout nunca desiste. O zero value não tem prazo algum:

resp, err := http.Get(url) // pode pendurar para sempre

Um servidor que aceita a conexão e não responde segura sua goroutine e sua conexão indefinidamente. Sempre:

cli := &http.Client{Timeout: 10 * time.Second}

2. MaxIdleConnsPerHost é 2 por padrão. Com concorrência alta contra um único host, o pool guarda só duas conexões ociosas e descarta as demais — então você paga TCP+TLS de novo constantemente. Em serviço que fala com outro serviço, isto é o gargalo escondido mais comum:

t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 100
t.MaxIdleConnsPerHost = 100
t.IdleConnTimeout = 90 * time.Second
cli := &http.Client{Transport: t, Timeout: 10 * time.Second}

E a regra que devolve a conexão ao pool: você precisa ler o corpo até o fim e fechá-lo. Fechar sem drenar descarta a conexão:

defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // drena para permitir reuso

Reuse o http.Client. Criar um por requisição cria um Transport por requisição, e portanto um pool novo e vazio — anulando o keep-alive inteiro. Um cliente por dependência, guardado em campo de struct.

Respostas às perguntas-guia

1. Enumere tudo entre digitar a URL e ver o pixel.

Parse da URL → DNS → TCP handshake → TLS handshake → requisição → trabalho do servidor → resposta → parse/render. A tabela acima com os custos.

2. Em qual dessas etapas costuma morar a latência que o usuário sente?

Nos round trips de estabelecimento de conexão, e no trabalho do servidor quando há 7. Problema N+1 no banco. Raramente na transferência.

Em Go: httptrace distingue as duas causas — se GotFirstResponseByte demora e o handshake foi rápido, o problema é o servidor.

3. Quantos round-trips uma conexão HTTPS nova custa antes do primeiro byte útil?

DNS (0 se em cache) + TCP (1) + TLS (1 em TLS 1.3, 2 em 1.2) + envio (0,5) ≈ 3 idas e voltas. Com RTT de 50 ms, 150 ms antes de o servidor ver a requisição.

4. O que keep-alive e connection pooling economizam exatamente?

DNS, TCP handshake e TLS handshake. Medido: 4,1 ms em loopback; em rede real, 2 a 3 RTTs.

Em Go: e só funciona se você reusar o http.Client, drenar o corpo e ajustar MaxIdleConnsPerHost.

Trade-offs

Do conceito:

  • Keep-alive compra RTTs e cobra conexões abertas (recursos no servidor e no balanceador).
  • HTTP/2 compra multiplexação e cobra que uma perda de pacote afete todos os fluxos daquela conexão TCP — problema que o HTTP/3 resolve.
  • Pool grande compra latência e cobra memória e limite de conexões do backend.

Em Go:

  • http.DefaultClient compra conveniência e não tem timeout: use só em script.
  • Pool ajustado compra throughput e cobra conexões ociosas.
  • httptrace custa algumas linhas e é a única forma de responder "onde está a latência" sem adivinhar.

Exemplo prático

package main

import (
	"crypto/tls"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"time"
)

func main() {
	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(20 * time.Millisecond) // trabalho simulado do servidor
		fmt.Fprint(w, "ok")
	}))
	defer srv.Close()

	cliente := srv.Client() // confia no certificado do servidor de teste
	cliente.Timeout = 5 * time.Second

	medir := func(rotulo string) {
		var t0, tConn, tTLS, tFirst time.Time
		var reusou bool
		t0 = time.Now()
		trace := &httptrace.ClientTrace{
			GotConn:              func(i httptrace.GotConnInfo) { reusou = i.Reused },
			ConnectDone:          func(_, _ string, _ error) { tConn = time.Now() },
			TLSHandshakeDone:     func(cs tls.ConnectionState, _ error) { tTLS = time.Now() },
			GotFirstResponseByte: func() { tFirst = time.Now() },
		}
		req, _ := http.NewRequest("GET", srv.URL, nil)
		req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
		resp, err := cliente.Do(req)
		if err != nil {
			fmt.Println("erro:", err)
			return
		}
		io.Copy(io.Discard, resp.Body) // DRENA: sem isto a conexão não volta ao pool
		resp.Body.Close()

		fmt.Printf("%-22s total=%-11v reusou_conexao=%v\n",
			rotulo, time.Since(t0).Round(time.Microsecond), reusou)
		if !reusou {
			fmt.Printf("    TCP=%v TLS=%v espera_servidor=%v\n",
				tConn.Sub(t0).Round(time.Microsecond),
				tTLS.Sub(tConn).Round(time.Microsecond),
				tFirst.Sub(tTLS).Round(time.Microsecond))
		}
	}

	medir("1a (conexão nova)")
	medir("2a (keep-alive)")
	medir("3a (keep-alive)")

	// o erro: cliente novo por requisição => pool novo e vazio => sem keep-alive
	fmt.Println("\ncliente NOVO por requisição (anula o pool):")
	for i := 1; i <= 2; i++ {
		t := srv.Client().Transport
		c := &http.Client{Transport: t, Timeout: 5 * time.Second}
		t0 := time.Now()
		var reusou bool
		req, _ := http.NewRequest("GET", srv.URL, nil)
		req = req.WithContext(httptrace.WithClientTrace(req.Context(),
			&httptrace.ClientTrace{GotConn: func(i httptrace.GotConnInfo) { reusou = i.Reused }}))
		r, err := c.Do(req)
		if err == nil {
			io.Copy(io.Discard, r.Body)
			r.Body.Close()
		}
		fmt.Printf("  req %d: total=%-11v reusou=%v\n", i,
			time.Since(t0).Round(time.Microsecond), reusou)
	}
}

Relacionado


Parte de Semana 10 - Rede e HTTP · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas