Jak pisać szybsze API w Go: praktyczne wzorce i pułapki

0
49
4/5 - (1 vote)

Nawigacja:

Co w praktyce znaczy „szybkie API” w Go

Szybkie API w Go to nie tylko niskie czasy odpowiedzi na lokalnym laptopie. Chodzi o usługę, która utrzymuje stabilne opóźnienia i przepustowość przy realnym ruchu, nie zjada przesadnie zasobów i nie wymaga ciągłego „dmuchania” w infrastrukturę przy każdym wzroście użytkowników.

Różnica między „działa” a „działa szybko”

API, które „działa”, znaczy zazwyczaj: zwraca poprawne odpowiedzi, nie wywala się pod podstawowym obciążeniem i przechodzi smoke testy. API, które „działa szybko”, jest projektowane w odniesieniu do konkretnych metryk:

  • SLA / SLO – procent żądań, które muszą zmieścić się w określonym czasie (np. 99% żądań poniżej 200 ms).
  • Latency – czas od przyjścia żądania do wysłania odpowiedzi (p95, p99 ważniejsze niż średnia).
  • Throughput – liczba żądań na sekundę, które API może obsłużyć stabilnie.

„Szybko” bez kontekstu to pułapka. Dla wewnętrznego API raportowego 800 ms może być całkiem akceptowalne. Dla endpointu autoryzacji w aplikacji mobilnej 800 ms to gwóźdź do UX. Dopóki nie ma liczb, optymalizacja Go API opiera się na domysłach i wrażeniach.

Wydajność API jako kompromis biznesowy

Każda optymalizacja ma koszt – czas programistów, czas na testy, większą złożoność rozwiązania. Z drugiej strony stoi koszt infrastruktury i niezadowolenie użytkowników. Praktyczne pytanie nie brzmi: „czy da się szybciej?”, tylko: „czy opłaca się robić szybciej?”.

Typowy, opłacalny cel przy Go API to znalezienie punktu, w którym:

  • latency dla krytycznych endpointów stabilnie mieści się w założeniach biznesowych,
  • przy wzroście ruchu skalowanie poziome jest liniowe (więcej podów/instancji = proporcjonalnie więcej RPS),
  • koszt utrzymania klastra i baz danych nie rośnie szybciej niż przyrost realnych przychodów.

Go ma dobrą bazę wydajnościową „z pudełka”, ale źle zaprojektowane API potrafi tę przewagę kompletnie zmarnować: za ciężkie struktury, niekontrolowana współbieżność, brak limitów na zapytania do bazy – i z przewagi nic nie zostaje.

Najczęstsze bottlenecki: CPU, I/O, baza, sieć, GC

Najszybsza optymalizacja to ta, która trafia w faktyczne wąskie gardło. Dla Go API typowe źródła problemów to:

  • CPU – koszt JSON (encoding/json), parsowanie dużych payloadów, nieefektywne pętle, refleksja w frameworkach.
  • I/O – wolne dyski, logowanie synchroniczne, duże pliki obrabiane w handlerach.
  • Baza danych – za wolne zapytania, brak indeksów, zbyt mały pool połączeń, N+1 queries.
  • Sieć – wysokie RTT do usług zewnętrznych (np. płatności, CRM), brak cache po stronie API.
  • GC (garbage collector) – nadmierne alokacje w gorącej ścieżce, wiele krótkotrwałych obiektów.

Optymalizacja kodu Go pod CPU nie pomoże, jeśli 80% czasu i tak przepala się na zapytania do bazy lub zewnętrznego API. Z drugiej strony – jeśli baza jest szybka, a profil pokazuje, że 40% czasu to JSON marshaling i alokacje, wtedy praca w Go da realny zysk.

Dlaczego najpierw trzeba mierzyć, a potem optymalizować

Bez pomiarów kończy się na całym tygodniu dłubania w kodzie JSON czy wymianą routera na „szybszy”, podczas gdy największy zysk dawałby jeden indeks w bazie. Minimum sensownego procesu to:

  • proste benchmarki lokalne (pakiet testing – funkcje BenchmarkXxx),
  • profil CPU i pamięci (go test -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out),
  • monitoring w środowisku testowym: latency, RPS, błędy, saturacja CPU/RAM.

Profilowanie w Go jest tanie czasowo, a oszczędza nieporównanie więcej godzin na „optymalizacjach” w ciemno. Jednorazowe wpięcie prostego load testu (np. k6, vegeta) i profilera Go zwykle pokazuje od razu 1–2 miejsca, w które opłaca się wjechać z refaktorem.

Fundamenty szybkiego API w Go: projekt, nie hacki

Prosta architektura i jasne granice odpowiedzialności

Największy błąd, który winduje koszty optymalizacji, to zbyt skomplikowana architektura na starcie. Każda dodatkowa warstwa, każdy egzotyczny pattern to nie tylko więcej kodu – to też więcej pracy dla CPU, GC i programistów, gdy trzeba zacząć ciąć opóźnienia.

Przy Go API opłaca się stawiać na prosty, rozdzielony układ:

  • transport (HTTP, gRPC) – minimalny zestaw handlerów i middleware,
  • logika biznesowa – osobne serwisy / use case’y jako zwykłe funkcje / struktury,
  • dostęp do danych – repozytoria / adaptery do bazy i zewnętrznych API.

Handler HTTP powinien szybko:

  1. parsować request,
  2. wywołać konkretny use case,
  3. zserializować odpowiedź.

Gdy każda z tych części jest mała, testowalna i niezależna, optymalizacja pod wydajność jest znacznie tańsza – można np. zmienić sposób serializacji tylko w adapterze, bez dotykania handlerów i logiki biznesowej.

Przeciwieństwem tego podejścia są:

  • „God handler” robiący wszystko – walidację, logikę, zapytania do kilku baz, pisanie logów.
  • Globalny stan z różnymi mutacjami (globalne mapy, singletony z niejasnym cyklem życia).
  • Rozlane zależności, gdzie handler bezpośrednio woła kilkanaście serwisów i repozytoriów.

Na początku działa „w miarę”, ale gdy tylko trzeba przyspieszyć, każdy ruch wymaga dotknięcia wielu miejsc. To prosta droga do rosnących kosztów i regresji.

Świadome użycie narzędzi: net/http, routery i frameworki

Standardowa biblioteka Go, czyli net/http, jest wystarczająco szybka i stabilna dla zdecydowanej większości biznesowych API. Największą korzyścią z używania standardowego stosu nie jest nawet wydajność, ale:

  • mniej zależności,
  • łatwiejsze profilowanie,
  • lepsza przewidywalność zachowania.

Routery i frameworki najczęściej dodaje się dla wygody (routing, middlewares, binding JSON, walidacja). Pod kątem wydajności różnice są zwykle mniejsze niż koszt migracji i nauki zespołu. Przykładowe cechy popularnych opcji:

RozwiązanieCharakterystykaWpływ na wydajnośćKoszt wdrożenia
net/http + std muxStandard, zero zależnościBardzo dobre „z pudełka”Niski
chiLekki router, middlewareZwykle bardzo dobryNiski/średni
ginPopularny framework HTTPDobra, ale z overheademŚredni
echoRouter + middleware + bindingDobra, zależy od użyciaŚredni

Jeżeli celem jest najlepszy stosunek efekt / wysiłek, rozsądny scenariusz „na start” wygląda tak:

  • net/http + chi jako router,
  • brak ciężkich ORM-ów w gorących ścieżkach, tylko lekki SQL lub prosty query builder,
  • minimum „magii” (automatyczne bindery, refleksyjne walidacje) w hotspotach.

W razie potrzeby zawsze można punktowo wymienić fragmenty – np. wprowadzić inny router tylko dla kilku najczęściej używanych endpointów, zamiast przerabiać całe API.

Decyzje architektoniczne, które później mocno bolą

Niektóre wybory szybko się mszczą, gdy przychodzi ruch produkcyjny:

  • Globalny stan – globalne mapy, cache, singletony z mutacją. Problemy z race condition, trudne skalowanie poziome (więcej instancji = rozjechany stan), trudne profilowanie zużycia pamięci.
  • Ciasne sprzężenia – handler znający szczegóły baz, klientów HTTP itd. Taki kod jest trudny do kolejnych refaktorów i optymalizacji (np. zmiana bazy na cache + bazę).
  • „Wszystko w jednym handlerze” – brak wydzielonego use case / service, więc każda drobna optymalizacja kończy się grzebaniem w całym endpointzie.

Lepszą inwestycją jest na początku prosty podział pakietów i dedykowane interfejsy, niż później przepisywanie wszystkiego w pośpiechu, bo API nie wyrabia.

Konfiguracja i wykorzystanie net/http pod wysokie obciążenie

Kluczowe pola http.Server i ich wpływ

Domyślna konfiguracja http.Server jest poprawna dla małych projektów, ale przy większym obciążeniu kilka pól ma ogromny wpływ na stabilność i czas odpowiedzi:

  • ReadTimeout – maksymalny czas na odczyt całego żądania (nagłówki + body). Chroni przed wolnymi klientami i atakami typu slowloris.
  • WriteTimeout – maksymalny czas na wysłanie odpowiedzi. Chroni serwer przed wiszącymi połączeniami.
  • IdleTimeout – czas, przez jaki połączenie Keep-Alive może pozostać otwarte bez aktywności.
  • MaxHeaderBytes – limit rozmiaru nagłówków; zbyt duży zwiększa ryzyko ataków i zużycie pamięci.

Minimalna, praktyczna konfiguracja może wyglądać tak:

srv := &http.Server{
    Addr:           ":8080",
    Handler:        router, // np. chi
    ReadTimeout:    5 * time.Second,
    WriteTimeout:   10 * time.Second,
    IdleTimeout:    60 * time.Second,
    MaxHeaderBytes: 1 << 20, // 1MB
}

Takie ustawienia są tanie we wdrożeniu, a często ratują API przed „zatkaniem” przez wolne lub złośliwe połączenia, które blokują zasoby serwera.

Reużywanie http.Client i połączeń: Transport, Keep-Alive

Każde tworzenie nowego http.Client i pozostawianie go bez kontroli to potencjalne wycieki połączeń i pamięci. http.Client powinien być reużywany – najczęściej w formie jednego klienta na usługę zewnętrzną.

Kluczowa jest konfiguracja Transport dla tych klientów:

tr := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 10,
    IdleConnTimeout:     90 * time.Second,
    TLSHandshakeTimeout: 10 * time.Second,
}

client := &http.Client{
    Transport: tr,
    Timeout:   5 * time.Second, // globalny timeout na request
}

Taka konfiguracja pozwala utrzymać pulę połączeń (Keep-Alive), co przy dużej liczbie żądań do tego samego hosta znacząco redukuje koszty nawiązywania połączeń TCP/TLS. Jednorazowe ustawienie transportu często daje większy efekt niż długie dłubanie w samych handlerach.

Limitowanie połączeń do bazy i usług zewnętrznych

Każde API w Go ma „za plecami” inne systemy: bazy danych, cache, kolejki, bramki płatności. Nawet jeśli samo API jest szybkie, brak limitów na połączenia potrafi zabić cały system. Praktyczne zasady:

  • sql.DB – konfiguruj puli połączeń:
    • SetMaxOpenConns – maksymalna liczba otwartych połączeń,
    • SetMaxIdleConns – ile połączeń może zostać w idle,
    • SetConnMaxLifetime – maksymalny czas życia połączenia (rotacja).
  • Klienci HTTP – limity maksymalnych równoległych żądań (np. przez semafor lub worker pool).

Prosty semafor na liczbę równoległych zapytań do usługi zewnętrznej potrafi uratować system przed kaskadowym przeciążeniem:

Ochrona backendów: timeouts, context i przerwanie pracy

Jeżeli API ma być szybkie „od przodu”, musi być też agresywne „od tyłu”: żądania do bazy, cache czy zewnętrznych HTTP nie mogą wisieć bez limitu. Sprint po milisekundy w handlerze nic nie da, gdy zapytanie do bazy trwa sekundy.

Podstawowe narzędzie to context.Context z deadline lub timeoutem. Na poziomie handlera:

func (h *OrderHandler) Get(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
    defer cancel()

    order, err := h.svc.GetOrder(ctx, orderID)
    if err != nil {
        // mapowanie błędu na kod HTTP
        return
    }

    // zapis odpowiedzi...
}

W środku stosu ctx musi realnie coś wyłączać:

  • zapytania SQL przez QueryContext/ExecContext,
  • klient HTTP z req.WithContext(ctx),
  • operacje w cache lub kolejce, które honorują context.

Bez tego context staje się tylko „dodatkowym argumentem w funkcji”, a nie mechanizmem, który ucina kosztowne operacje i zwalnia zasoby.

W praktyce bardzo szybko wychodzi, że różne use case’y potrzebują innych limitów – odczyt statusu zamówienia może mieć timeout 100–200 ms, a generowanie raportu już kilka sekund. Zamiast jednego globalnego limitu, taniej jest zdefiniować kilka profili:

  • FastPath – agresywny timeout dla odczytów HTTP,
  • Background – dłuższe, ale rzadsze operacje (np. sync z zewnętrzną usługą),
  • Critical – np. płatności, z większym marginesem czasu, ale dobrze zmonitorowane.

Te profile można trzymać w jednym miejscu (np. mały pakiet timeouts), żeby później łatwo je korygować na podstawie realnych danych z produkcji zamiast przepisywać dziesiątki handlerów.

Backpressure: nie przyjmuj pracy, której nie możesz obsłużyć

Brak kontroli nad liczbą równoległych operacji I/O kończy się lawiną timeoutów i restartów. Lepiej czasem szybciej odpowiedzieć „503, spróbuj później”, niż przyjąć każde żądanie, które i tak nie zostanie obsłużone w rozsądnym czasie.

Minimalny wariant to semafor na poziomie najbardziej kosztownego zasobu, np. bramki płatności:

type PaymentClient struct {
    httpClient *http.Client
    sem        chan struct{}
}

func NewPaymentClient(httpClient *http.Client, maxConcurrent int) *PaymentClient {
    return &PaymentClient{
        httpClient: httpClient,
        sem:        make(chan struct{}, maxConcurrent),
    }
}

func (c *PaymentClient) Charge(ctx context.Context, req *ChargeRequest) (*ChargeResponse, error) {
    select {
    case c.sem <- struct{}{}:
        defer func() { <-c.sem }()
    case <-ctx.Done():
        return nil, ctx.Err()
    }

    // wykonaj call HTTP...
}

Na start to kilka linijek kodu, a efekt jest wymierny: API nie „zabije” bramki płatności, tylko ograniczy liczbę aktywnych połączeń. Gdy ruch rośnie, wystarczy skorygować konfigurację, a nie przepisywać cały moduł integracji.

Kolorowy kod źródłowy wyświetlony na ekranie monitora
Źródło: Pexels | Autor: Markus Spiske

Projektowanie handlerów: szybka ścieżka żądania

Cienki handler, gruba logika – ale dobrze ułożona

Szybkie API to przede wszystkim szybka „ścieżka gorąca”: od przyjęcia requestu do wysłania odpowiedzi dzieje się jak najmniej „magii”. Handler powinien być nudny i przewidywalny:

  1. parsowanie prostych danych (path, query, body),
  2. przekazanie ich do jednego use case / serwisu,
  3. mapowanie wyniku na HTTP (status, nagłówki, body).

Dodatkowe efekty uboczne – logi, metryki, trace – lepiej wynieść do middleware’ów lub helperów, które nie mieszają się z logiką domenową.

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

    idStr := chi.URLParam(r, "id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    profile, err := h.svc.GetProfile(ctx, id)
    if err != nil {
        h.writeError(w, err)
        return
    }

    writeJSON(w, http.StatusOK, profile)
}

Z takiego handlera łatwo wyrzucać zbędne alokacje, zmieniać serializację, dołożyć cache. Gdy w środku jest kilkadziesiąt linijek warunków, logów, walidacji i kilku calli HTTP, każda optymalizacja kończy się bolesnym refaktorem.

Walidacja wejścia bez refleksji i nadmiarowej „magii”

Automatyczne bindery z refleksją są wygodne na start, ale szybko okazują się droższe niż prosty kod „na piechotę” w gorących endpointach. Rozsądne podejście:

  • używać binderów/frameworka dla mniej krytycznych ścieżek,
  • w hotspotach napisać ręczne parsowanie JSON / query, bez refleksji i tagów.
type CreateUserRequest struct {
    Email string
    Name  string
}

func decodeCreateUser(r *http.Request) (CreateUserRequest, error) {
    var req CreateUserRequest

    // ręczne dekodowanie JSON
    defer r.Body.Close()
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    if err := dec.Decode(&req); err != nil {
        return req, err
    }

    // prosta walidacja
    if req.Email == "" {
        return req, errors.New("email is required")
    }

    return req, nil
}

Bez refleksji, bez generowania kodu, bez pośredników. Jeśli taki endpoint obsługuje większość ruchu w systemie, oszczędność CPU i GC bardzo szybko się sumuje.

Unikanie zbędnych alokacji w handlerach

W handlerach często pojawiają się drobne śmieci, które GC musi później sprzątać: tymczasowe slice’y, mapy tworzone przy każdym żądaniu, budowanie stringów przez konkatenację. Kilka prostych nawyków:

  • rezygnacja z map dla małych, stałych zestawów wartości – lepiej użyć switch lub tablicy,
  • prefiksowanie slice’ów z oczekiwanym rozmiarem: make([]T, 0, n),
  • zamiast fmt.Sprintf w gorącej ścieżce – prostszy strconv.Append* lub bytes.Buffer, jeśli naprawdę potrzeba.

Większość zysków nie pochodzi z egzotycznych sztuczek, tylko z pozbycia się oczywistych alokacji w często wykonywanym kodzie.

JSON, kodowanie i unikanie kosztownych alokacji

Standardowy encoding/json vs alternatywne biblioteki

encoding/json jest wygodne, stabilne i wystarczające dla wielu API. Ma jednak dwa problemy pod dużym obciążeniem:

  • używa refleksji przy dekodowaniu,
  • generuje sporo alokacji przy dużych strukturach i głębokim zagnieżdżeniu.

Przed zmianą biblioteki warto policzyć, ile rzeczywiście kosztuje JSON. Proste podejście:

  1. profil CPU i heap przy typowym ruchu,
  2. sprawdzenie, ile czasu i pamięci zajmują funkcje z pakietu encoding/json,
  3. jeśli to nie jest top 3–5 hotspotów – nie ruszać, skupić się na I/O.

Gdy JSON jednak staje się głównym wąskim gardłem, rozsądny jest wybór jednej alternatywy i użycie jej tylko tam, gdzie potrzeba – np. w 2–3 endpointach obsługujących większość ruchu.

Manualne kodowanie JSON w gorących ścieżkach

Nawet z encoding/json można sporo ugrać bez wymiany biblioteki. Pierwsza rzecz to reużycie json.Encoder / json.Decoder i unikanie pośrednich buforów. Prosty helper do zapisu:

var jsonContentType = []byte("application/json; charset=utf-8")

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", string(jsonContentType))
    w.WriteHeader(status)

    enc := json.NewEncoder(w)
    // enc.SetEscapeHTML(false) // jeśli bezpieczeństwo na to pozwala
    _ = enc.Encode(v)
}

Jeżeli zależy nam na maksymalnej kontroli nad alokacjami w odpowiedzi, dla naprawdę krytycznych endpointów można ręcznie budować JSON na []byte lub bytes.Buffer – szczególnie, gdy struktura odpowiedzi jest stała i prosta.

func writeHealth(w http.ResponseWriter) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusOK)
    // stały JSON, zero alokacji po stronie serializacji
    _, _ = w.Write([]byte(`{"status":"ok"}`))
}

Takie ręczne podejście opłaca się w endpointach typu healthcheck czy proste listy, gdzie odpowiedź jest bardzo powtarzalna i często wywoływana.

Bufory, sync.Pool i reużywanie struktur

Najwięcej śmieci generuje tworzenie nowych obiektów przy każdym requestcie. Tam, gdzie struktury są duże i często używane, opłaca się je reużywać przez sync.Pool:

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

func writeJSONWithBuffer(w http.ResponseWriter, status int, v any) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    enc := json.NewEncoder(buf)
    _ = enc.Encode(v)

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _, _ = w.Write(buf.Bytes())
}

Ten wzorzec ma sens tylko wtedy, gdy faktycznie wysyłamy duże odpowiedzi lub mamy bardzo wysokie QPS. Dla małych API dodawanie sync.Pool to nadmiarowy koszt poznawczy i potencjalne źródło subtelnych błędów.

Świadome korzystanie z tagów JSON i typów

Tagi JSON to nie tylko mapowanie nazw pól. Każdy typ ma koszt podczas serializacji. Kilka praktycznych wskazówek:

  • nie nadużywać interface{} w strukturach odpowiedzi – generuje to dodatkowe sprawdzanie typów i alokacje,
  • dla pól wymaganych lepiej mieć jawne typy niż wskaźniki (np. int zamiast *int),
  • oddzielić modele domenowe od „view models” dla API, aby JSON nie musiał odzwierciedlać całej złożoności wewnątrz systemu.

Prosty adapter domena → DTO często obniża koszt serializacji, bo struktura JSON staje się płytsza, ma mniej pól opcjonalnych i mniej typów pośrednich.

Współbieżność w Go: goroutines, kanały i praca z I/O

Goroutines są tanie, ale nie darmowe

Tworzenie goroutines za każdym razem „bo tak wygodniej” szybko kończy się problemami z pamięcią i debugowaniem. Opłaca się założyć kilka prostych reguł:

  • nie tworzyć goroutine w każdym helperze – lepiej mieć wyraźne miejsca, gdzie praca jest rozdzielana,
  • monitorować liczbę aktywnych goroutines (np. przez pprof) już przy testach obciążeniowych,
  • nie używać kanałów do wszystkiego – w wielu miejscach prostszy jest mutex lub zwykła funkcja.

Typowy błąd: w handlerze równoleglimy kilka zapytań do bazy, każde w osobnej goroutine, bez limitu. Przy większym ruchu liczba równoległych zapytań wystrzeliwuje i baza pada, mimo że pojedyncze żądanie jest szybkie.

Równoległe I/O tylko tam, gdzie naprawdę się opłaca

Jeśli endpoint musi zrobić kilka niezależnych zapytań HTTP lub SQL, równoległość ma sens – ale powinna być kontrolowana. Dobrym kompromisem jest mała, lokalna grupa waitgroup + semafor:

func (s *OrderService) GetFullOrder(ctx context.Context, id int64) (*FullOrder, error) {
    var wg sync.WaitGroup
    errCh := make(chan error, 2)

    type result[T any] struct {
        v   T
        err error
    }

    var (
        orderRes result[*Order]
        itemsRes result[]*Item
    )

    wg.Add(2)

    go func() {
        defer wg.Done()
        order, err := s.repo.GetOrder(ctx, id)
        orderRes = result[*Order]{v: order, err: err}
    }()

    go func() {
        defer wg.Done()
        items, err := s.repo.ListItems(ctx, id)
        itemsRes = result[]*Item{v: items, err: err}
    }()

    wg.Wait()
    close(errCh)

    if orderRes.err != nil {
        return nil, orderRes.err
    }
    if itemsRes.err != nil {
        return nil, itemsRes.err
    }

    return &FullOrder{
        Order: orderRes.v,
        Items: itemsRes.v,
    }, nil
}

W tym przykładzie równoległość ma sens, bo zapytania są do tego samego backendu, ale krótkie, a endpoint jest wąsko używany. Gdyby był to główny endpoint systemu, potrzebne byłyby dodatkowe limity i monitoring, żeby nie przewalić bazy tysiącami równoległych zapytań.

Kanały jako API wewnętrzne – z umiarem

Kanały są dobre do koordynacji pracy workerów lub kolejek, ale kiepskie jako ogólny sposób przekazywania danych między każdą warstwą aplikacji. Każde chan to potencjalne:

  • blokady, gdy nikt nie odbiera danych,
  • wycieki goroutines, gdy nadawca nie dostanie sygnału o anulowaniu,
  • trudne do odtworzenia deadlocki pod obciążeniem.

Ostrożne łączenie goroutines z context.Context

Przy API każde żądanie ma swój cykl życia i to context.Context jest jego źródłem prawdy. Goroutine, która o tym „zapomina”, często przeżywa request i robi pracę, której nikt już nie potrzebuje. Kilka prostych zasad mocno ogranicza wycieki:

  • każda goroutine związana z obsługą requestu musi znać ctx i go respektować,
  • zanim odpalimy goroutine, zastanawiamy się, co ma się stać przy anulowaniu – czy kończy pracę, czy ma dokończyć „za wszelką cenę”,
  • jeśli goroutine ma pracować po zakończeniu requestu (np. logowanie, metryki), warto użyć osobnego, kontrolowanego workera lub kolejki.

Bezpieczny wzorzec dla krótkich prac związanych z I/O wygląda tak:

func fetchWithCtx(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
    // powiązanie requestu HTTP z kontekstem API
    r := req.Clone(ctx)

    type result struct {
        resp *http.Response
        err  error
    }

    ch := make(chan result, 1)

    go func() {
        resp, err := client.Do(r)
        ch <- result{resp: resp, err: err}
    }()

    select {
    case <-ctx.Done():
        // kontekst został anulowany, zamykamy się
        return nil, ctx.Err()
    case res := <-ch:
        return res.resp, res.err
    }
}

Czy taki wrapper jest „ładny”? Niekoniecznie. Ale pozwala mieć jasną ścieżkę anulowania i uniknąć wiszących goroutines, które nadal blokują połączenia HTTP, gdy klient od dawna się rozłączył.

Workery i kolejki do droższych zadań

Przy API szybko pojawia się lista zadań, których nie chcemy wykonywać w krytycznej ścieżce: wysyłka maili, generowanie PDF-ów, ciężkie raporty. Zamiast odpalać goroutine z handlera i trzymać kciuki, lepiej wydzielić prosty, stabilny „backend” w tym samym procesie – klasyczny wzorzec worker-pool.

type Job struct {
    UserID int64
    Type   string
    Payload []byte
}

type WorkerPool struct {
    jobs chan Job
    wg   sync.WaitGroup
}

func NewWorkerPool(n int, buf int) *WorkerPool {
    p := &WorkerPool{
        jobs: make(chan Job, buf),
    }

    p.wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer p.wg.Done()
            for job := range p.jobs {
                handleJob(job)
            }
        }()
    }
    return p
}

func (p *WorkerPool) Submit(job Job) bool {
    select {
    case p.jobs <- job:
        return true
    default:
        // kolejka pełna – decyzja biznesowa: zrzucić czy zablokować?
        return false
    }
}

func (p *WorkerPool) Stop() {
    close(p.jobs)
    p.wg.Wait()
}

W handlerze sprowadza się to do prostego wywołania:

func (h *Handler) SendWelcomeEmail(w http.ResponseWriter, r *http.Request) {
    // ... walidacja żądania ...

    ok := h.workerPool.Submit(Job{
        UserID: userID,
        Type:   "welcome_email",
        Payload: payload,
    })

    if !ok {
        http.Error(w, "service busy", http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusAccepted)
}

Takie podejście daje dwa bonusy: czas odpowiedzi nie zależy od SMTP ani zewnętrznych usług, a zużycie CPU/RAM na dodatkowe zadania jest ograniczone stałą liczbą workerów i rozmiarem kolejki.

Unikanie „kanałozy” w API

Najtaniej jest tam, gdzie nie ma czego debuggować. Kanały są świetne jako prymityw synchronizacji, ale kiepskie jako uniwersalny sposób komunikacji między każdą warstwą. W wielu miejscach proste wywołanie funkcji plus mutex wypada szybciej, czytelniej i stabilniej.

Przykład typowego „przekombinowania”:

// zły kierunek dla kodu biznesowego
func (s *Service) Process(ctx context.Context, in Input) (<-chan Output, <-chan error) {
    outCh := make(chan Output)
    errCh := make(chan error, 1)

    go func() {
        defer close(outCh)
        defer close(errCh)

        // dużo logiki... z selectami, timeoutami itd.
    }()

    return outCh, errCh
}

Ten sam przypadek zwykle da się sprowadzić do prostego wywołania z kontekstem i ewentualnie lokalnego zrównoleglenia w środku, niewidocznego dla handlera:

func (s *Service) Process(ctx context.Context, in Input) (Output, error) {
    // wewnątrz można użyć goroutines + waitgroup, ale API pozostaje proste
}

W API liczy się przewidywalność – prostsze sygnatury, mniej kanałów przekazywanych dookoła, mniej miejsc, w których trzeba śledzić, kto kogo blokuje.

Zarządzanie pamięcią i garbage collector w realnym API

Co GC naprawdę robi z twoim API

Garbage collector w Go daje luksus braku ręcznego zwalniania pamięci, ale nie jest magiczny. Pod dużym ruchem widać jego wpływ na latencję i przepustowość. Najważniejsze z punktu widzenia API są trzy rzeczy:

  • częstotliwość cykli GC – zależna od ilości alokacji i tempa „śmiecenia”,
  • czas trwania pauz – zwykle milisekundy, ale widoczne na wykresach p95/p99,
  • wielkość heapu – wpływa na cache CPU, zużycie RAM i koszty utrzymania.

Go udostępnia proste mechanizmy kontroli, ale najpierw potrzeba danych. Na starcie wystarczy:

  • włączyć eksport metryk runtime (np. przez Prometheusa),
  • pod obciążeniem obserwować: gc_pause_ns, heap_inuse_bytes, mallocs_total,
  • uruchomić go tool pprof na profilu heap i zobaczyć, kto robi najwięcej alokacji.

Dopiero wtedy ma sens „kręcenie gałkami” typu GOGC czy agresywne reużywanie buforów.

GOGC – prosty, ale groźny suwak

GOGC steruje, jak bardzo może urosnąć heap między kolejnymi cyklami GC. Domyślne 100 oznacza, że gdy ilość używanej pamięci od czasu ostatniego GC urośnie 2×, uruchomi się kolejny cykl. Zmniejszenie tej wartości:

  • obniża zużycie pamięci,
  • zwiększa częstotliwość GC i koszt CPU.

Zwiększenie:

  • zmniejsza częstotliwość GC,
  • pozwala heapowi mocniej urosnąć (więcej RAM, większy presja na cache).

Bez pomiarów łatwo przesadzić. Przykładowy, rozsądny eksperyment:

  1. uruchomić test obciążeniowy na wersji referencyjnej (GOGC domyślne), zapisać metryki,
  2. ustawić GOGC=50, powtórzyć test, porównać: CPU, GC pause, heap, latencję,
  3. ustawić GOGC=200, powtórzyć test, zobaczyć, czy rośnie tylko zużycie RAM, czy również lag p95/p99.

Dla API, które działa na stosunkowo małych maszynach, częściej opłaca się pozostać blisko wartości domyślnej i zamiast tego ograniczyć alokacje w gorących ścieżkach.

Unikanie alokacji na heapie, gdy wystarczy stos

Go sam decyduje, czy coś trafi na stos, czy na heap, na podstawie analizy ucieczki (escape analysis). W praktyce kilka wzorców sprzyja przenoszeniu rzeczy na heap:

  • zwracanie wskaźników do lokalnych zmiennych,
  • przechowywanie funkcji jako wartości, które „łapią” dużo danych z zewnątrz (closure),
  • używanie interfejsów tam, gdzie konkretny typ byłby prostszy.

Dobry, tani nawyk: regularnie uruchamiać go build -gcflags="-m" lub go test -c -gcflags=-m i obserwować linie typu „escapes to heap” przy gorących funkcjach. Często jedna zmiana sygnatury eliminuje sporo śmieci.

// mniej korzystne:
func newUserDTO(u *User) *UserDTO {
    dto := &UserDTO{
        ID:   u.ID,
        Name: u.Name,
    }
    return dto // dto ucieka na heap
}

// tańsza wersja, jeśli nie potrzebujemy wskaźnika:
func newUserDTO(u *User) UserDTO {
    return UserDTO{
        ID:   u.ID,
        Name: u.Name,
    }
}

W wielu miejscach można spokojnie operować wartościami zamiast wskaźników – zwłaszcza dla małych struktur przekazywanych między warstwami logiki, a nie gigantycznych blobów binarnych.

Reużywanie buforów – tylko tam, gdzie się spina

sync.Pool i ręczne utrzymywanie buforów potrafi podciąć zużycie pamięci i GC, ale jest to dług techniczny z odsetkami. Każdy taki „sprytny” fragment trzeba zrozumieć, utrzymać i monitorować. Zanim pojawi się sync.Pool w kodzie API, dobrze zadać kilka pytań:

  • czy ten obszar pamięci jest naprawdę duży (tysiące bajtów, nie kilka ints)?
  • czy kod jest w gorącej ścieżce – obsługuje większość ruchu?
  • czy da się jednoznacznie określić zakres życia bufora? (brak „wypuszczania” referencji na zewnątrz)

Typowy przykład, który ma sens: duży bufor do odczytu request body lub odpowiedzi z zewnętrznego serwisu, używany w wielu handlerach:

var readBufPool = sync.Pool{
    New: func() any {
        // 64KB na start – zależnie od typowego rozmiaru payloadu
        b := make([]byte, 64*1024)
        return &b
    },
}

func readBodyFast(r io.Reader) ([]byte, error) {
    bufPtr := readBufPool.Get().(*[]byte)
    buf := *bufPtr
    defer readBufPool.Put(bufPtr)

    n, err := r.Read(buf)
    if err != nil && err != io.EOF {
        return nil, err
    }

    // UWAGA: kopiujemy do nowego slice'a dopasowanego rozmiarem,
    // żeby nie wypuszczać dużego bufora dalej w świat.
    out := make([]byte, n)
    copy(out, buf[:n])
    return out, nil
}

Ten wzorzec łączy dwa cele: mniejszą liczbę dużych alokacji oraz zatrzymanie dużych buforów wewnątrz modułu. Koszt kopiowania jest w wielu przypadkach niższy niż późniejsze koszty GC i fragmentacja pamięci.

Uważne obchodzenie się z globalnym stanem

Globalne mapy cache, rejestry, wspólne bufory – w dłuższej perspektywie kosztują więcej, niż się wydaje. Przy API, które ma rosnąć, dobrze mieć jasny plan:

  • jak długo dane żyją w cache,
  • czy rozmiar mapy jest ograniczony,
  • czy dostęp do globalnego stanu nie powoduje nadmiernych alokacji (np. przez klucze typu string budowane ad-hoc).

Jeśli cache ma być tani, nie musi być „idealny”. Prosty LRU/TTL albo gotowa biblioteka typu ristretto bywa tańsza niż własna, rozrastająca się mapa pilnowana ręcznie. Oszczędza to czas na debugowanie znikających wpisów, wyścigów danych i nadmiarowego GC z powodu tysięcy małych obiektów, które już dawno nie są potrzebne.

Struktury zoptymalizowane pod odczyt

Większość API ma dużo więcej odczytów niż zapisów: konfiguracje, listy słownikowe, mapowania kodów błędów, uprawnienia. Zamiast trzymać wszystko w strukturach projektowanych „pod modyfikację”, można wybrać prostsze, niemutowalne formy:

  • tablice i slice’y tylko do odczytu – inicjalizowane na starcie, potem używane wszędzie,
  • mapy, które nigdy nie są modyfikowane po uruchomieniu – brak potrzeby mutexów, brak dodatkowych alokacji,
  • prekompilowane struktury (np. regexy, plany zapytań), które nie są odświeżane w locie.

W praktyce oznacza to np. wyrzucenie dynamicznej mapy konfiguracyjnej ładowanej przy każdym requestcie na rzecz prostego structa ustawianego raz przy starcie procesu. Jeden konkretny typ, zero konwersji i parsowania na gorąco, mniej śmieci dla GC.

Testy obciążeniowe jako „profil pamięci”

Nawet najlepsze teorie przegrywają z rzeczywistością środowiska produkcyjnego. Zamiast zgadywać, które optymalizacje pamięciowe coś dadzą, warto zbudować tani, powtarzalny scenariusz:

  1. wybrać 2–3 najważniejsze endpointy (największy ruch + największe payloady),
  2. napisać prosty skrypt do obciążenia (np. vegeta, k6) z realistycznymi danymi,
  3. włączyć pprof (CPU + heap) i zebrać profile przy kilku poziomach QPS,
  4. z każdą optymalizacją zawsze powtarzać test i sprawdzać, czy zysk jest realny.

Bez takiego minimalnego procesu bardzo łatwo spędzić tydzień na polerowaniu buforów w logowaniu, podczas gdy 90% czasu API zjadają powolne zapytania SQL bez indeksów albo źle ustawiony limit połączeń do bazy.

Najczęściej zadawane pytania (FAQ)

Jakie metryki są najważniejsze przy optymalizacji API w Go?

Przy API liczą się konkretne liczby, a nie ogólne wrażenie „jest szybkie”. Podstawowy zestaw to: SLA/SLO (np. 99% żądań poniżej 200 ms), latency liczone jako p95/p99, a nie średnia, oraz throughput, czyli stabilna liczba żądań na sekundę.

W praktyce dobrze jest rozdzielić endpointy krytyczne (np. logowanie, płatności) od reszty i dla nich mieć ostrzejsze wymagania. Dla raportów czy paneli administracyjnych wyższe opóźnienia bywają akceptowalne, więc nie ma sensu inwestować w drogie optymalizacje tam, gdzie biznes na tym prawie nie zyskuje.

Od czego zacząć przyspieszanie API w Go: od kodu, bazy czy infrastruktury?

Zanim cokolwiek zmienisz, trzeba zmierzyć, gdzie faktycznie jest wąskie gardło. Najprostszy, tani proces to: lokalne benchmarki (testing.B), profil CPU i pamięci (go test -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out) oraz prosty test obciążeniowy (np. k6, vegeta) na środowisku testowym.

Dopiero z profilera widać, czy główny problem to CPU (np. JSON), I/O (logi, pliki), baza (wolne zapytania, brak indeksów), sieć (zewnętrzne API) czy GC (za dużo alokacji). Optymalizacja „na czuja” zwykle marnuje czas – często jeden indeks w bazie daje więcej niż tydzień grzebania w handlerach.

Czy net/http w Go jest wystarczająco szybkie, czy lepiej od razu brać framework?

Dla większości biznesowych API net/http i standardowy mux są więcej niż wystarczające. Dają bardzo dobrą wydajność „z pudełka”, mało zależności i łatwe profilowanie. To zwykle najlepszy stosunek efektu do wysiłku na start.

Jeśli potrzebujesz wygodniejszego routingu i middleware, rozsądnym kompromisem są lekkie routery jak chi. Cięższe frameworki (gin, echo) dają dodatkową wygodę (binding, walidacje), ale niosą ze sobą overhead i koszt nauki/migracji. Bardziej opłaca się zacząć prosto i tylko w hotspotach sięgnąć po „magiczniejsze” narzędzia.

Jak projektować strukturę API w Go, żeby łatwo je potem przyspieszać?

Najtaniej optymalizuje się proste rzeczy. Dobry układ to wyraźny podział na: transport (HTTP/gRPC, handler + middleware), logikę biznesową (use case’y/serwisy) i warstwę dostępu do danych (repozytoria, adaptery na bazę i zewnętrzne API). Handler ma głównie parsować request, zawołać use case i zwrócić odpowiedź.

Drogo wychodzi podejście typu „God handler”, globalny stan i ciasne sprzężenia z bazą i klientami HTTP. Wtedy każda mała zmiana wydajnościowa oznacza ruszanie kilku warstw na raz. Lepsza jest prosta architektura i kilka interfejsów na brzegach niż „sprytne” rozwiązania, które później blokują refaktor.

Jakie są najczęstsze bottlenecki w API w Go i jak je szybko namierzyć?

Najczęściej wąskie gardło leży poza samym Go: baza danych (brak indeksów, N+1 queries, zbyt mały pool połączeń), sieć (wysokie RTT do zewnętrznych usług), I/O (synchronizacja logów, praca na dużych plikach). W kodzie Go często bolą: koszt JSON (encoding/json), refleksyjne frameworki oraz nadmierne alokacje wywołujące GC.

Praktyczny schemat: uruchom test obciążeniowy, obserwuj metryki (CPU, RAM, latency, RPS, błędy), a potem odpal profiler Go. Jeśli CPU jest nisko, a czas zjada baza – optymalizujesz zapytania. Jeśli CPU wysoko, a profil pokazuje JSON i alokacje – inwestujesz w lżejszą serializację, pooling, ograniczanie tworzenia obiektów w gorącej ścieżce.

Jak podejść do kompromisu między wydajnością API a kosztem infrastruktury i developmentu?

Kluczowe jest pytanie „czy opłaca się robić szybciej?”, a nie „czy da się szybciej?”. Czas zespołu jest drogi, tak samo jak nadmiarowa infrastruktura. Sensowny cel to stan, w którym kluczowe endpointy mieszczą się w SLA, skalowanie poziome jest w miarę liniowe, a koszt klastra i baz nie rośnie szybciej niż przychody.

Strategia budżetowa: najpierw łap „tanie” zyski (indeksy w bazie, zmiana kilku zapytań, redukcja zbędnego logowania, proste usprawnienia JSON), dopiero później rozważ większe inwestycje (przepisanie warstwy, wymiana frameworka, nowe technologie). Dzięki temu nie przepalasz tygodni na optymalizacje, które biznesowo niewiele wnoszą.

Poprzedni artykułOd pomysłu do pierwszych 100 użytkowników: sprawdzone kanały pozyskania
Następny artykułCzy blockchain znajdzie sens poza krypto? Przykłady, które już działają
Monika Sadowski
Monika Sadowski śledzi chmurę, SaaS i świat startupów, ale zawsze filtruje nowości przez pryzmat praktyki i kosztów. Analizuje architektury, modele rozliczeń oraz ryzyka vendor lock-in, pokazując, jak podejmować decyzje technologiczne w firmie. W tekstach łączy perspektywę produktu i inżynierii: opisuje, co działa w skali, jak planować migracje i jak budować procesy zgodne z wymaganiami bezpieczeństwa. Korzysta z dokumentacji dostawców, raportów branżowych i doświadczeń z wdrożeń, dbając o precyzyjne definicje i uczciwe porównania. Jej celem jest ułatwienie czytelnikom wyboru rozwiązań na lata.