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ła – http.MaxBytesReader na wejściu; czytelne 413, gdy klient przesadzi.
- Strumień zamiast bufora – json.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.
| Wariant | Kiedy | Plusy | Minusy |
|---|---|---|---|
| pprof on-demand (CPU/heap/block/mutex) | Reprodukowalny problem, chwilowe skoki CPU/alloc | Wbudowane w Go, szybki start, szczegółowe profile | Wymaga dostępu do endpointu i próbki w czasie problemu |
| runtime/trace | Diagnoza opóźnień goroutines, schedulera, kanałów | Widok 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, regresje | Historyczne trendy, porównania wersji, mała ingerencja | Dodatkowy 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.
| Wariant | Kiedy | Plusy | Minusy |
|---|---|---|---|
| net/http + ServeMux | Małe API, minimum zależności | Zero zewnętrznych paczek, przewidywalne | Brak wygodnych parametrów ścieżek i grup middleware |
| chi | Codzienne API: grupy, middleware, wersjonowanie | Lekkie, idiomatyczne, bogaty ekosystem rozszerzeń | Parametry w kontekście – zwracaj uwagę na hot-path |
| httprouter | Proste, szybkie trasy z parametrami | Niskie narzuty, parametry bez nadmiarowych alokacji | Mniej „cukru” do middleware, mniej elastyczne niż chi |
| fasthttp | Specjalne przypadki o ekstremalnych wymaganiach | Bardzo niskie opóźnienia, kontrola nad buforami | Nie 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
| Wariant | Plusy | Minusy |
|---|---|---|
| Bez limitów | Najniższy narzut, minimalne opóźnienia przy małym ruchu | Brak ochrony zależności; lawina żądań = skok p99/p999 i OOM w szczycie |
| Semafor | Prosty, przewidywalny, szybkie „backpressure” bez kolejek | „Twardy sufit” przepustowości; część żądań trzeba odrzucić lub opóźnić |
| Kolejka/worker-pool | Wyrównuje piki, izoluje kosztowne zadania, czytelna kontrola budżetu | Dodatkowe 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.






