Szybkie API w Go: od routera po profilowanie i ograniczanie zużycia pamięci

0
16
Rate this post

Krótki brief potrzeb i pytań przed startem

Zanim cokolwiek wybierzesz, odpowiedz na kilka praktycznych pytań. To przyspieszy decyzje i uchroni przed przedwczesną optymalizacją.

  • Jaki jest profil ruchu: krótkie, częste zapytania, czy długie strumienie/duże pliki?
  • API ma być minimalistyczne czy bogate w middleware (autoryzacja, logowanie, CORS, rate limiting)?
  • Wąskie gardło to CPU, sieć, baza danych czy serializacja JSON?
  • Jakie cele: niskie p99 opóźnienia, wysoki throughput, ograniczony budżet pamięci, a może przewidywalność GC?
  • Czy wdrożenie będzie za LB (HTTP/2, HTTP/3), czy bezpośrednio na publicznym porcie?
  • Jak będziesz profilować i mierzyć (pprof, tracing, load-test z hey/wrk/k6)?

Te odpowiedzi wrócą za chwilę jako kryteria wyboru routera, enkodera JSON, middleware i ustawień GC. Co może pójść nie tak? Najczęściej: nieograniczone odczyty ciała żądania, wycieki goroutines przez nieuwagę z contextem, nieprzemyślane logowanie stringów i alokacje w gorących ścieżkach.

Wybór routera HTTP: warianty i kompromisy

Router decyduje o składaniu ścieżek, parsowaniu parametrów i architekturze middleware. W Go realny wybór to: czyste net/http, chi, lekki framework jak gin lub echo, ewentualnie fiber. Każdy ma inny rozkład kosztów CPU/pamięci, ergonomię i ekosystem.

net/http + własny multiplexer

Plusy: zero zbędnych zależności, maksymalna kontrola, pełna zgodność ze standardem. Świetne do API, gdzie liczy się przewidywalny latency i mało warstw.

Minusy: ręczne dokładanie funkcji (CORS, recover, logging), brak wygodnych grup tras i nazwanych parametrów. Każdy middleware to dodatkowy kod.

Kiedy: mikroserwis o jasnej strukturze, minimalizm i prędkość, kontrolowane środowisko (np. wewnętrzna brama API).

package main

import (
  "log"
  "net/http"
  "time"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    // Unikaj fmt.Fprintf w hot path
    _, _ = w.Write([]byte("ok"))
  })
  srv := &http.Server{
    Addr:              ":8080",
    Handler:           logging(recoverMw(mux)),
    ReadHeaderTimeout: 2 * time.Second,
    ReadTimeout:       5 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    8 << 10, // 8KB
  }
  log.Fatal(srv.ListenAndServe())
}

func logging(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    next.ServeHTTP(w, r)
    // Użyj lekkiego loggera w produkcji (np. zerolog/slog)
    log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
  })
}

func recoverMw(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    defer func() {
      if rec := recover(); rec != nil {
        http.Error(w, "internal", http.StatusInternalServerError)
      }
    }()
    next.ServeHTTP(w, r)
  })
}

chi: idiomatyczny router na net/http

github.com/go-chi/chi to lekki router oparty o net/http z drzewem tras i sensownym ekosystemem middleware. Świetnie skaluje się w rozbudowanych API, bo wspiera grupowanie i wzorce.

Plusy: elastyczne middleware, grouping, parametry w ścieżkach bez regułek regex, kompatybilny z context.Context. Kosztowo bardzo blisko stdlib.

Minusy: odrobinę większy narzut niż czyste ServeMux w mikroskopijnych projektach; wymaga poznania idiomów chi.

Kiedy: średnie i duże API, kiedy chcesz zachować kompatybilność z idiomami Go i mieć przewidywalny koszt.

r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer)
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
  id := chi.URLParam(r, "id")
  w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  _, _ = w.Write([]byte(id))
})

gin, echo, fiber: szybkie frameworki z opiniami

Te frameworki integrują router, kontekst i bogaty zestaw dodatków.

  • gin: popularny, szybki, z własnym kontekstem, wygodne wiązanie JSON. Dobry kompromis między szybkością a ergonomią.
  • echo: minimalistyczny i bardzo szybki, uważany za lżejszy w narzucaniu decyzji architektonicznych.
  • fiber: inspirowany Express.js, oparty na fasthttp. Bardzo wysoka przepustowość, ale mniejsza zgodność z net/http, co wpływa na integracje.

Plusy: spójny kontekst żądań, gotowe middleware, szybkie prototypowanie.

JSON i serializacja: trzy ścieżki i ich koszty

Serializacja to częsty wąskie gardło przy API. Tu decyzja naprawdę zależy od ruchu i budżetu pamięci. W praktyce masz trzy sensowne warianty:

  • encoding/json (stdlib) – stabilny, bez zależności, w nowszych Go całkiem szybki. Najmniej zaskoczeń, dobre p99 gdy nie tworzysz lawiny alokacji.
  • Szybsze implementacje runtime (np. goccy/go-json, jsoniter, sonic) – zysk CPU, czasem niższe alokacje, ale dodatkowa biblioteka i potencjalne różnice w zgodności z edge-case’ami.
  • Kodogeneratory (np. easyjson) – minimalne alokacje i najwyższa przewidywalność, kosztem procesu generowania i mniej elastycznego modelu danych.

Różnice w praktyce: stdlib bywa wystarczający dla większości API, jeśli parsujesz do struct (nie do map[string]any) i nie czytasz całego ciała naraz. Szybsze runtime’y pomagają, gdy JSON jest duży albo konwersja w pętli jest gorąca. Kodogeneracja to dobry wybór w wewnętrznych protokołach i hot-pathach, gdzie format jest stały.

Kiedy który wariant:

  • encoding/json: start, rozważne API, nacisk na prostotę i bezpieczeństwo aktualizacji.
  • Runtime szybsze: długie listy/strumienie, „fan-out” endpointy, presja na CPU.
  • Kodogeneracja: stabilne kontrakty, mikroserwisy niskobudżetowe pamięciowo, potrzeba deterministycznych alokacji.
// Bezpieczne i oszczędne dekodowanie: limit + streaming
func handleCreate(w http.ResponseWriter, r *http.Request) {
  // Twardy limit ciała, żeby nie zalać pamięci
  const maxBody = 1 << 20 // 1MB
  r.Body = http.MaxBytesReader(w, r.Body, maxBody)
  defer r.Body.Close()

  dec := json.NewDecoder(r.Body)
  dec.DisallowUnknownFields()

  var in CreateUser
  if err := dec.Decode(&in); err != nil {
    http.Error(w, "bad json", http.StatusBadRequest)
    return
  }
  // Upewnij się, że nic nie zostało po JSON-ie (np. „,,” na końcu)
  if dec.More() {
    http.Error(w, "trailing data", http.StatusBadRequest)
    return
  }

  // Odpowiedź: bufory z puli, bez zbędnych alokacji
  buf := bufPool.Get().(*bytes.Buffer)
  buf.Reset()
  defer bufPool.Put(buf)

  enc := json.NewEncoder(buf)
  enc.SetEscapeHTML(false)

  out := CreateUserResp{ID: "u_123"}
  if err := enc.Encode(out); err != nil {
    http.Error(w, "encode err", http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json")
  w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
  w.WriteHeader(http.StatusCreated)
  _, _ = w.Write(buf.Bytes())
}

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

Rekomendacja: zacznij od encoding/json + strumieniowania i limitów, mierz pprof. Jeśli CPU jest gorące w serializacji – rozważ szybszy runtime na wybranych endpointach. Po stabilizacji kontraktów – tam, gdzie to krytyczne – dołóż kodogenerację.

Odczyt i zapis ciał: limity, strumienie, bufory

Najwięcej kłopotów sprawiają czytania „na pałę”: io.ReadAll na 50 MB i po serwerze. Lepiej: limitować, strumieniować, a dla uploadów – spływać na dysk.

  • Limit ciałahttp.MaxBytesReader na wejściu; czytelne 413, gdy klient przesadzi.
  • Strumień zamiast buforajson.Decoder, io.Copy(…), io.LimitedReader.
  • Uploady – unikaj r.ParseMultipartForm z dużym limitem; użyj MultipartReader i spisuj plik prosto na dysk.
  • Profilowanie i diagnoza: pprof, trace oraz profilowanie ciągłe

    Bez danych łatwo optymalizować nie to, co trzeba. Dla Go masz trzy rozsądne ścieżki zbierania sygnałów. Każda rozwiązuje inny problem i ma inny koszt operacyjny.

    WariantKiedyPlusyMinusy
    pprof on-demand (CPU/heap/block/mutex)Reprodukowalny problem, chwilowe skoki CPU/allocWbudowane w Go, szybki start, szczegółowe profileWymaga dostępu do endpointu i próbki w czasie problemu
    runtime/traceDiagnoza opóźnień goroutines, schedulera, kanałówWidok zależności i zdarzeń, „dlaczego” a nie tylko „ile”Cięższe, nie włączaj długo w produkcji
    Profilowanie ciągłe (agent/serwis)Środowiska produkcyjne, rzadkie błędy, regresjeHistoryczne trendy, porównania wersji, mała ingerencjaDodatkowy komponent, czasem skromniejsza rozdzielczość

    Bezpieczne uruchomienie pprof: trzymaj na osobnym porcie, tylko na localhost lub za uwierzytelnieniem. W praktyce sprawdza się oddzielny listener:

import (
  "log"
  "net/http"
  _ "net/http/pprof"
)

func startDebug() {
  mux := http.NewServeMux()
  // Rejestruje /debug/pprof/*
  mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP)

  srv := &http.Server{Addr: "127.0.0.1:6060", Handler: mux}
  go func() { log.Println(srv.ListenAndServe()) }()
}
# Profil CPU przez 30s i interaktywne przeglądanie
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=30

# Profil sterty (alokacje + żywe obiekty)
go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/heap

Skalpel do atrybucji kosztów: etykiety pprof. Pozwalają przypisać CPU/mem do endpointu lub klienta, gdy ruch miesza się w pulach.

import "runtime/pprof"

func withLabel(ctx context.Context, key, val string, fn func(ctx context.Context)) {
  pprof.Do(ctx, pprof.Labels(key, val), fn)
}

// W handlerze:
withLabel(r.Context(), "endpoint", "/reports/export", func(ctx context.Context) {
  // praca hot-path
})

Rekomendacja: utrzymuj włączony lekki endpoint pprof na prywatnym porcie i rób zrzuty w oknach problemów; trace uruchamiaj tylko punktowo. Gdy środowisk i wersji przybywa, włącz profilowanie ciągłe i patrz na trendy p99 CPU/heap między releasami.

GC, limity pamięci i kontenery: trzy taktyki, różne efekty

Garbage Collector bywa jak termostat: ustawisz zbyt agresywnie – przegrzejesz CPU, zbyt luźno – rozdmuchasz RAM. Do dyspozycji są trzy praktyczne dźwignie.

  • Domyślny GC (GOGC=100) – rozsądny balans pamięć/CPU dla większości API. Zostaw, jeśli nie widzisz wzrostów ponad budżet RAM i p99 jest stabilne.
  • Regulacja GOGC – wyższy GOGC => rzadziej GC, mniejszy CPU kosztem większego zużycia pamięci; niższy GOGC => ciaśniej w pamięci, ale GC częściej pracuje.
  • Limit procesu: GOMEMLIMIT – „sufit” pamięci dla runtime; GC będzie częściej sprzątał, aby utrzymać się pod limitem. Dobre w kontenerach, aby uniknąć gwałtownego OOM.
# Twardy limit w środowisku (np. w kontenerze z 512MB)
GOMEMLIMIT=450MiB

# Lub dynamicznie w kodzie:
import "runtime/debug"

func tune() {
  debug.SetGCPercent(125)                 // GOGC=125
  debug.SetMemoryLimit(450 * 1024 * 1024) // ~450MB
}

Różnice i pułapki:

  • Obniżenie GOGC na IO-bound API, które ma sporo peaków alokacji w krótkich oknach, pomoże utrzymać budżet RAM, ale jeśli przesadzisz, GC zacznie „mielić” i p99 skoczy.
  • GOMEMLIMIT ustaw odrobinę niżej niż limit kontenera – zostaw zapas na sterty C, mapowania plików i bufory kernelowe.
  • Duże, krótkie bufory? Zamiast „dokręcać” GC, wyeliminuj je strumieniowaniem i limitami wejścia – to zwykle tańsze niż walka z odśmiecaniem.

Kiedy który wariant:

  • Domyślne ustawienia: start projektu i stabilne obciążenie bez dropów.
  • GOGC w górę: CPU wąskie gardło, a RAM jest; GOGC w dół: planowo trzymasz ciasny budżet RAM.
  • GOMEMLIMIT: środowiska kontenerowe, w których każda „wycieczka” ponad limit kończy się OOM Kill.

Rekomendacja: najpierw usuń źródła alokacji w hot-path (bufory, mapy, konkatenacje stringów), potem lekko podkręć GOGC lub ustaw GOMEMLIMIT. Każdą zmianę potwierdź profilem heap przed/po pod presją ruchu.

Router i trasy: stdlib, chi, httprouter czy fasthttp?

Router jest jak skrzyżowanie: im prostsza organizacja ruchu, tym mniejsze straty. Różnice w wydajności między popularnymi opcjami bywają mniejsze niż wpływ Twoich middleware’ów, ale decyzja przekłada się na ergonomię i koszty alokacji.

WariantKiedyPlusyMinusy
net/http + ServeMuxMałe API, minimum zależnościZero zewnętrznych paczek, przewidywalneBrak wygodnych parametrów ścieżek i grup middleware
chiCodzienne API: grupy, middleware, wersjonowanieLekkie, idiomatyczne, bogaty ekosystem rozszerzeńParametry w kontekście – zwracaj uwagę na hot-path
httprouterProste, szybkie trasy z parametramiNiskie narzuty, parametry bez nadmiarowych alokacjiMniej „cukru” do middleware, mniej elastyczne niż chi
fasthttpSpecjalne przypadki o ekstremalnych wymaganiachBardzo niskie opóźnienia, kontrola nad buforamiNie jest kompatybilne z net/http, inne API, brak HTTP/2 po stronie serwera

Dla kogo który wariant:

  • ServeMux – gdy zależy ci na prostocie i pełnej kontroli, a ścieżek jest kilka.
  • chi – domyślny wybór do produkcyjnych API: elastyczność bez ciężaru.
  • httprouter – jeśli chcesz minimalnego routera i piszesz własną, wąską warstwę middleware.
  • fasthttp – tylko jeśli akceptujesz inny ekosystem i świadomie rezygnujesz z net/http (np. dedicated proxy/edge).

Rada wyboru: startuj na chi; gdy potrzebujesz jeszcze mniej narzutu i masz niewiele middleware – spróbuj httprouter. ServeMux zostaw do prostych serwisów i endpointów zdrowotnych. fasthttp tylko dla wyspecjalizowanych brzegów.

// Minimalny szkielet z chi: grupy, middleware, parametry
import (
  "net/http"
  "github.com/go-chi/chi/v5"
  "github.com/go-chi/chi/v5/middleware"
)

func router() http.Handler {
  r := chi.NewRouter()
  r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)

  r.Route("/v1", func(r chi.Router) {
    r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
      id := chi.URLParam(r, "id")
      // hot-path: nie twórz zbędnych stringów/JSON-ów; napisz prosto
      w.Header().Set("Content-Type", "text/plain; charset=utf-8")
      _, _ = w.Write([]byte(id))
    })
  })
  return r
}

Kontrola współbieżności w handlerach: bez limitów, semafor czy kolejka

„Goroutine na żądanie” działa świetnie, dopóki backend (DB, dysk, zewnętrzne API) nie zacznie się dusić. Trzy realne warianty kontroli presji:

  • Bez limitów – najprościej; ryzyko lawiny do wrażliwych zależności.
  • Semafor (bounded concurrency) – stały limit równoległości na zasób lub endpoint; backpressure natychmiast po zajęciu puli.
  • Kolejka/worker-pool – odsprzęga przyjęcie żądania od pracy; wyrównuje szczyty kosztem opóźnień.

Plusy/minusy:

Plusy/minusy i pułapki wdrożeniowe

WariantPlusyMinusy
Bez limitówNajniższy narzut, minimalne opóźnienia przy małym ruchuBrak ochrony zależności; lawina żądań = skok p99/p999 i OOM w szczycie
SemaforProsty, przewidywalny, szybkie „backpressure” bez kolejek„Twardy sufit” przepustowości; część żądań trzeba odrzucić lub opóźnić
Kolejka/worker-poolWyrównuje piki, izoluje kosztowne zadania, czytelna kontrola budżetuDodatkowe opóźnienia w kolejce, ryzyko przepełnienia i złożoność obsługi time-outów

Kiedy który wariant ma sens:

  • Bez limitów – tylko dla endpointów lekkich i idempotentnych, które nie dotykają wrażliwych backendów.
  • Semafor – gdy zależność ma twarde limity (np. pula DB, zewnętrzne API) i lepiej odrzucić część ruchu niż dławić wszystkich.
  • Kolejka – gdy liczy się wygładzenie krótkich pików, a akceptujesz nieco wyższe p99 w zamian za stabilność throughputu.

Rekomendacja wyboru: zacznij od semafora per zasób (DB, cache, zewnętrzne API). Jeśli ruch pikuję w krótkich oknach, dodaj małą kolejkę przed pracą właściwą. Bez limitów – tylko świadomie i lokalnie.

Semafor per-endpoint lub per-zasób: prosta blokada z sygnałem „zajęte”

import (
  "net/http"
  "time"
  "golang.org/x/sync/semaphore"
)

var dbSem = semaphore.NewWeighted(32) // limit równoległości do DB

func handleUser(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()

  // Krótki limit czekania na wejście do sekcji krytycznej:
  waitCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
  defer cancel()

  if err := dbSem.Acquire(waitCtx, 1); err != nil {
    // Zajęte lub kontekst anulowany – sygnalizuj backpressure.
    w.Header().Set("Retry-After", "1") // podpowiedź dla klienta i retry logic
    http.Error(w, "busy", http.StatusTooManyRequests) // 429 albo 503 – wg polityki
    return
  }
  defer dbSem.Release(1)

  // ... praca z DB/zasobem ...
  w.WriteHeader(http.StatusOK)
  _, _ = w.Write([]byte("ok"))
}
  • Jeśli endpoint ma różne profile kosztu, rozdziel limity: osobny semafor do DB, osobny do zewnętrznego API; nie mieszaj wszystkiego w jeden kubeł.
  • TryAcquire (bez czekania) jest dobre, gdy wolisz od razu odbijać żądania zamiast je blokować.

Mała kolejka + worker-pool: wygładzanie pików bez zalewania backendu

type result struct {
  data []byte
  err  error
}

type task struct {
  ctx   context.Context
  input string
  out   chan result
}

var (
  queue   = make(chan task, 128) // twardy limit długości kolejki
  workers = 16                   // równoległość pracy właściwej
)

func startWorkers() {
  for i := 0; i < workers; i++ {
    go func() {
      for t := range queue {
        // Szanuj anulowanie:
        select {
        case <-t.ctx.Done():
          continue
        default:
        }
        // ... kosztowna praca (DB, CPU, I/O) ...
        // Zwróć wynik nie blokując goroutine gdy klient odszedł:
        select {
        case t.out <- result{data: []byte("ok")}:
        default:
        }
      }
    }()
  }
}

func handleHeavy(w http.ResponseWriter, r *http.Request) {
  ctx := r.Context()
  out := make(chan result, 1)
  t := task{ctx: ctx, input: "x", out: out}

  // Polityka push: drop-najmłodsze (non-blocking) lub poczekaj z timeoutem
  select {
  case queue <- t:
  default:
    w.Header().Set("Retry-After", "2")
    http.Error(w, "queue full", http.StatusServiceUnavailable)
    return
  }

  // Czekaj na wynik, ale z limitem SLO, nie „wiecznie”
  select {
  case res := <-out:
    if res.err != nil {
      http.Error(w, res.err.Error(), http.StatusInternalServerError)
      return
    }
    _, _ = w.Write(res.data)
  case <-ctx.Done():
    // Klient rozłączył się lub minął deadline
    http.Error(w, "canceled", http.

...StatusRequestTimeout)
    return
  }
}
  • Gdy wąskim gardłem jest backend, trzymaj kolejkę małą (setki, nie tysiące). Duże bufory tylko maskują problem i windują zużycie pamięci w szczytach.
  • Obsłuż „ciche” odejście klienta: zanim wyślesz wynik do kanału, sprawdź, czy kontekst nie został anulowany.
  • Przemyśl politykę zrzutu: drop-tail (odrzucaj najnowsze) bywa lepsze niż zamrażanie całej usługi.

Timeouty end-to-end i anulowanie pracy

Bez twardych budżetów czasu łatwo napompować kolejkę i pamięć. Lepiej szybko przyznać: „nie zdążę” niż trzymać klientów w półotwartym połączeniu.

Ustal budżet czasu na całej ścieżce żądania

Timeout nie może kończyć się na handlerze. Jeżeli serwer czeka na nagłówki w nieskończoność albo transport klienta żyje „wiecznie”, to nawet doskonały kod handlera nie uratuje puli połączeń i pamięci.

// Konfiguracja serwera: twarde timeouty I/O i rozsądne limity
srv := &http.Server{
  Addr:              ":8080",
  Handler:           router(),
  ReadHeaderTimeout: 2 * time.Second,     // nagłówki muszą przyjść szybko
  ReadTimeout:       10 * time.Second,    // całość requestu (dla ciał)
  WriteTimeout:      10 * time.Second,    // zapis odpowiedzi
  IdleTimeout:       60 * time.Second,    // keep-alive
  MaxHeaderBytes:    1 << 20,             // 1 MiB nagłówków to i tak sporo
}
log.Fatal(srv.ListenAndServe())
  • ReadHeaderTimeout jest kluczowy przeciw powolnym atakom nagłówkami. Zbyt niski przy dużych JWT/ciężkich nagłówkach może ucinać legalny ruch – zweryfikuj ślady.
  • WriteTimeout wycina „wiszące” odpowiedzi, ale nie nadaje się do długich streamów SSE/WebSocketów. „Specjalne” endpointy wyciągnij do osobnego serwera/instancji z inną polityką.

Timeout w handlerze: http.TimeoutHandler czy context.WithTimeout?

http.TimeoutHandler potrafi „zabezpieczyć” endpoint krótkim kodem, lecz pozostawia pracę w tle, jeśli nie respektujesz kontekstu. Bez aktywnego sprawdzania ctx.Done() koszt dalej się realizuje.

Poprzedni artykułDom inteligentny – nowoczesne rozwiązanie dla komfortowego i bezpiecznego życia
Krzysztof Górski
Krzysztof Górski pisze o cyberbezpieczeństwie: od higieny haseł i MFA po analizę podatności i twarde ustawienia systemów. Stawia na odpowiedzialne podejście: opisuje zagrożenia bez sensacji, a porady opiera na sprawdzonych standardach i aktualnych komunikatach producentów. Lubi rozkładać ataki na czynniki pierwsze, pokazując wektory, skutki i realne metody obrony. W testach narzędzi bezpieczeństwa zwraca uwagę na fałszywe alarmy, ergonomię i wpływ na wydajność.