Блог инженера

History is written by its contributors

Go Runtime: как GC влияет на P99 latency в высоконагруженных API

2025-12-27 время чтения 10 мин Go Performance

У вас отличный API с медианной задержкой 5ms, но P99 внезапно подскакивает до 500ms? Клиенты жалуются на периодические зависания? Добро пожаловать в мир Go Garbage Collector и его влияния на tail latency.

В этой статье разберем реальную проблему: как 10ms GC пауза превращается в 500ms задержку для пользователей, и что делать, чтобы держать P99 latency под контролем.

Проблема: отличный P50, ужасный P99

Реальный кейс из production

API метрики до оптимизации:
P50:  4ms  ✅
P90:  12ms ✅
P95:  45ms ⚠️
P99:  520ms ❌
P99.9: 2.1s  💥

Что происходит:

  • 50% запросов обрабатываются за 4ms - отлично
  • 90% за 12ms - хорошо
  • Но 1% пользователей ждут полсекунды
  • И 0.1% ждут 2 секунды!

Почему это критично:

При 100,000 RPS:
- 1,000 запросов/сек получают 500ms задержку
- 100 запросов/сек ждут 2+ секунды
- За минуту 60,000 плохих запросов

Проблема? Garbage Collector паузы.

Как работает Go GC

Concurrent Mark-Sweep

Go использует concurrent garbage collector с фазами:

1. Mark Setup (STW)    - ~50-200μs
(остановка всего приложения)

2. Concurrent Mark     - основное время
(работает параллельно с приложением)

3. Mark Termination (STW) - ~50-500μs
(снова остановка)

4. Sweep (concurrent)  - фоновая очистка

Stop-The-World (STW) фазы - это и есть источник latency spikes.

Когда запускается GC

// GC запускается когда аллоцировано вдвое больше памяти
// чем осталось после последнего GC

// Пример:
// После GC осталось: 1GB
// GC запустится когда: heap достигнет 2GB
// Это контролируется GOGC (по умолчанию 100)

Проблема в высоконагруженных API:

// При 100k RPS и 1KB на запрос:
// 100,000 req/s * 1KB = ~100MB/s аллокаций

// С GOGC=100 и 1GB после последнего GC:
// GC запустится через ~10 секунд
// Накопится много мусора
// GC пауза будет длинной

Диагностика проблемы

Шаг 1: Включить GC логирование

# Экспортировать переменную окружения
export GODEBUG=gctrace=1

# Запустить приложение
./your-app

Вывод GC trace:

gc 1 @0.004s 2%: 0.018+1.3+0.076 ms clock, 0.14+0.35/1.2/3.0+0.61 ms cpu, 4->4->3 MB, 5 MB goal, 8 P
gc 2 @0.015s 3%: 0.021+2.1+0.095 ms clock, 0.17+0.42/2.0/5.2+0.76 ms cpu, 5->6->4 MB, 6 MB goal, 8 P
gc 3 @0.045s 4%: 0.025+15.2+0.12 ms clock, 0.20+0.68/14.8/42.1+0.99 ms cpu, 7->9->6 MB, 8 MB goal, 8 P
                    ^^^^
                    Mark phase - влияет на latency!

Расшифровка важных частей:

gc 3 @0.045s 4%: 0.025+15.2+0.12 ms clock
                  ^^^^^  ^^^^  ^^^^^
                  STW    Mark  STW
                  setup  phase term

Шаг 2: Измерить реальное влияние

package main

import (
    "fmt"
    "runtime"
    "runtime/debug"
    "time"
)

type LatencyTracker struct {
    samples []time.Duration
}

func (lt *LatencyTracker) Track(d time.Duration) {
    lt.samples = append(lt.samples, d)
}

func (lt *LatencyTracker) Percentile(p float64) time.Duration {
    if len(lt.samples) == 0 {
        return 0
    }

    sort.Slice(lt.samples, func(i, j int) bool {
        return lt.samples[i] < lt.samples[j]
    })

    idx := int(float64(len(lt.samples)) * p / 100.0)
    if idx >= len(lt.samples) {
        idx = len(lt.samples) - 1
    }

    return lt.samples[idx]
}

func benchmarkWithGC() {
    tracker := &LatencyTracker{}

    // Симулировать нагрузку
    for i := 0; i < 100000; i++ {
        start := time.Now()

        // Симулировать обработку запроса с аллокациями
        data := make([]byte, 1024)
        _ = processRequest(data)

        elapsed := time.Since(start)
        tracker.Track(elapsed)

        // Каждые 1000 запросов - вывести статистику
        if i > 0 && i%1000 == 0 {
            var m runtime.MemStats
            runtime.ReadMemStats(&m)

            fmt.Printf("Request %d - Heap: %d MB, P99: %v\n",
                i,
                m.HeapAlloc/1024/1024,
                tracker.Percentile(99),
            )
        }
    }

    fmt.Printf("\nFinal stats:\n")
    fmt.Printf("P50:  %v\n", tracker.Percentile(50))
    fmt.Printf("P90:  %v\n", tracker.Percentile(90))
    fmt.Printf("P99:  %v\n", tracker.Percentile(99))
    fmt.Printf("P99.9: %v\n", tracker.Percentile(99.9))
}

func processRequest(data []byte) []byte {
    // Создаем временные объекты (давление на GC)
    temp := make([]byte, len(data)*2)
    copy(temp, data)
    return temp[:len(data)]
}

Шаг 3: Профилирование

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

func main() {
    // Включить pprof endpoints
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()

    // Ваше приложение
    runApp()
}

Анализ аллокаций:

# Собрать heap profile
curl http://localhost:6060/debug/pprof/heap > heap.prof

# Анализировать
go tool pprof heap.prof

# В pprof консоли
(pprof) top10
(pprof) list functionName
(pprof) web  # визуализация

Решение 1: Тюнинг GOGC

Понимание GOGC

// GOGC контролирует агрессивность GC
// По умолчанию GOGC=100

// GOGC=100: GC запускается когда heap вырос на 100%
// Live heap: 1GB -> GC при 2GB

// GOGC=200: GC запускается когда heap вырос на 200%
// Live heap: 1GB -> GC при 3GB

// GOGC=50: GC запускается когда heap вырос на 50%
// Live heap: 1GB -> GC при 1.5GB

Стратегия: увеличить GOGC

package main

import (
    "os"
    "runtime/debug"
)

func init() {
    // Вариант 1: через environment variable
    // export GOGC=200

    // Вариант 2: программно
    debug.SetGCPercent(200)
}

func main() {
    // Ваше приложение
}

Эффект:

До (GOGC=100):
- GC каждые 10 секунд
- Пауза 15ms
- P99: 520ms

После (GOGC=200):
- GC каждые 20 секунд
- Пауза 25ms (больше, но реже!)
- P99: 180ms ✅

Trade-off:

  • ✅ Меньше частота GC → меньше spike’ов
  • ❌ Больше используется памяти
  • ❌ Когда GC случается, пауза дольше

Золотая середина

// Для высоконагруженных API хороший старт:
debug.SetGCPercent(200) // или даже 300

// Мониторить:
// 1. Memory usage (не должно привести к OOM)
// 2. P99 latency (должно улучшиться)
// 3. GC pause duration (будет дольше, но реже)

Решение 2: GOMEMLIMIT (Go 1.19+)

Мягкий лимит памяти

package main

import (
    "runtime/debug"
)

func init() {
    // Установить мягкий лимит памяти
    // Приложение может использовать до 8GB
    debug.SetMemoryLimit(8 * 1024 * 1024 * 1024) // 8GB
}

Как это работает:

Без GOMEMLIMIT:
- GC работает по формуле GOGC
- Может использовать сколько угодно памяти
- В контейнере может привести к OOM

С GOMEMLIMIT=8GB:
- GC становится более агрессивным при приближении к лимиту
- Защищает от OOM в Kubernetes
- Лучше предсказуемость

Kubernetes интеграция

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
spec:
  template:
    spec:
      containers:
      - name: app
        image: my-api:latest
        env:
        # Установить GOMEMLIMIT ~90% от memory limit
        - name: GOMEMLIMIT
          value: "7200MiB"  # 90% от 8GB
        - name: GOGC
          value: "200"

        resources:
          requests:
            memory: "8Gi"
            cpu: "2000m"
          limits:
            memory: "8Gi"
            cpu: "4000m"

Почему 90% от limit:

  • 10% запас для не-heap память (stacks, mmap и т.д.)
  • Защита от edge cases
  • Безопасность при спайках

Решение 3: Ballast Memory

Техника для стабильного GC

package main

import (
    "fmt"
    "runtime"
)

func init() {
    // Создать большой ballast slice
    // Это "обманывает" GC, делая паузы более предсказуемыми
    ballast := make([]byte, 2*1024*1024*1024) // 2GB

    runtime.KeepAlive(ballast)

    fmt.Printf("Ballast allocated: %d GB\n", len(ballast)/1024/1024/1024)
}

func main() {
    // Ваше приложение
}

Как работает:

Без ballast:
- Live heap: 100MB
- GC target: 200MB (GOGC=100)
- Много частых GC циклов

С ballast 2GB:
- Live heap: 2.1GB (2GB ballast + 100MB данные)
- GC target: 4.2GB
- Меньше циклов GC
- Более стабильные паузы

Важно:

// Ballast должен быть ОГРОМНЫМ slice, но он не использует
// реальную память благодаря virtual memory
// Это просто резервирование адресного пространства

// Проверить реальное использование:
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Actual heap: %d MB\n", m.HeapAlloc/1024/1024)

Решение 4: Уменьшить аллокации

Найти горячие точки

// Запустить с alloc profiling
go test -bench=. -benchmem -memprofile=mem.prof

// Анализировать
go tool pprof mem.prof

Техника 1: sync.Pool для переиспользования

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

// До: создаем новый buffer каждый раз
func processDataBad(data []byte) []byte {
    buf := new(bytes.Buffer)
    buf.Write(data)
    // ... обработка
    return buf.Bytes()
}

// После: переиспользуем buffers
func processDataGood(data []byte) []byte {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()

    buf.Write(data)
    // ... обработка
    return buf.Bytes()
}

Техника 2: Предаллокация слайсов

// До: много реаллокаций
func collectDataBad() []Item {
    var items []Item  // capacity = 0

    for i := 0; i < 1000; i++ {
        items = append(items, getItem(i))
        // append вызовет reallocation много раз
    }
    return items
}

// После: одна аллокация
func collectDataGood() []Item {
    items := make([]Item, 0, 1000)  // предаллокация

    for i := 0; i < 1000; i++ {
        items = append(items, getItem(i))
        // reallocation не происходит
    }
    return items
}

Техника 3: Избегать string concatenation

// До: много аллокаций
func buildStringBad(parts []string) string {
    result := ""
    for _, part := range parts {
        result += part  // каждая конкатенация = новая строка!
    }
    return result
}

// После: одна аллокация
func buildStringGood(parts []string) string {
    var builder strings.Builder
    builder.Grow(estimateSize(parts))  // предаллокация

    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String()
}

Решение 5: Оптимизация структур данных

Пример: JSON API response

// До: много мелких аллокаций
type UserResponseBad struct {
    ID       int               `json:"id"`
    Name     *string           `json:"name"`      // указатель!
    Email    *string           `json:"email"`     // указатель!
    Tags     []string          `json:"tags"`
    Metadata map[string]string `json:"metadata"`  // map аллоцируется
}

// После: меньше аллокаций
type UserResponseGood struct {
    ID       int               `json:"id"`
    Name     string            `json:"name"`      // значение
    Email    string            `json:"email"`     // значение
    Tags     [8]string         `json:"tags"`      // array вместо slice
    Metadata [16]KeyValue      `json:"metadata"`  // array вместо map
}

type KeyValue struct {
    Key   string
    Value string
}

Результат:

UserResponseBad:
- 1 аллокация для struct
- 2 аллокации для string указателей
- 1 аллокация для slice
- 1 аллокация для map
= 5+ аллокаций на объект

UserResponseGood:
- 1 аллокация для всей struct
= 1 аллокация на объект

При 100k RPS: 500k vs 100k аллокаций/сек

Production кейс: снижение P99 с 500ms до 50ms

Исходная ситуация

Сервис: REST API для рекомендаций
RPS: 80,000
Memory: 4GB
Pods: 20

Метрики ДО оптимизации:
P50:  5ms
P95:  38ms
P99:  520ms  ❌
P99.9: 1.8s  💥

GC паузы: 10-50ms каждые 15 секунд

Шаг 1: Диагностика

# Включили GC trace
export GODEBUG=gctrace=1

# Результат:
gc 145 @45.123s 4%: 0.12+42.3+0.18 ms clock
                          ^^^^
                          42ms mark phase!

Шаг 2: Применили оптимизации

// 1. Увеличили GOGC
debug.SetGCPercent(300)

// 2. Установили GOMEMLIMIT
debug.SetMemoryLimit(3.6 * 1024 * 1024 * 1024) // 3.6GB (90% от 4GB)

// 3. Добавили ballast
ballast := make([]byte, 1*1024*1024*1024) // 1GB
runtime.KeepAlive(ballast)

// 4. Оптимизировали hot path с sync.Pool
var responsePool = sync.Pool{
    New: func() interface{} {
        return &RecommendationResponse{
            Items: make([]Item, 0, 100),
        }
    },
}

Шаг 3: Результат

Метрики ПОСЛЕ оптимизации:
P50:  4ms   (было 5ms)
P95:  22ms  (было 38ms)P99:  48ms  (было 520ms) ✅✅✅
P99.9: 120ms (было 1.8s) ✅✅✅

GC паузы: 15-30ms каждые 45 секунд

Memory: 5GB (было 4GB)
Cost: +25% памяти
Benefit: P99 улучшился в 10 раз!

ROI расчет

До:
- 1% запросов (800/sec) с latency 500ms+
- Потеря конверсии на медленных запросах: ~30%
- Упущенная выручка: ~$50k/месяц

После:
- Все запросы < 100ms
- Дополнительные затраты на память: +$500/месяц
- ROI: 100x

Мониторинг и алертинг

Prometheus метрики

package main

import (
    "runtime"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    gcDuration = promauto.NewHistogram(prometheus.HistogramOpts{
        Name: "go_gc_duration_seconds",
        Help: "GC pause duration",
        Buckets: []float64{0.0001, 0.001, 0.01, 0.1, 1},
    })

    heapAlloc = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "go_heap_alloc_bytes",
        Help: "Heap memory allocated",
    })

    numGC = promauto.NewCounter(prometheus.CounterOpts{
        Name: "go_gc_total",
        Help: "Total number of GC runs",
    })
)

func collectGCMetrics() {
    var stats runtime.MemStats
    runtime.ReadMemStats(&stats)

    heapAlloc.Set(float64(stats.HeapAlloc))
    numGC.Add(float64(stats.NumGC))
    gcDuration.Observe(float64(stats.PauseNs[(stats.NumGC+255)%256]) / 1e9)
}

Grafana Dashboard

# P99 latency
histogram_quantile(0.99,
  rate(http_request_duration_seconds_bucket[5m])
)

# GC pause P99
histogram_quantile(0.99,
  rate(go_gc_duration_seconds_bucket[5m])
)

# Heap usage
go_heap_alloc_bytes / 1024 / 1024

# GC frequency
rate(go_gc_total[5m])

Алерты

groups:
- name: go_gc_alerts
  rules:
  - alert: HighP99Latency
    expr: |
      histogram_quantile(0.99,
        rate(http_request_duration_seconds_bucket[5m])
      ) > 0.1
    for: 5m
    annotations:
      summary: "P99 latency > 100ms"

  - alert: FrequentGC
    expr: rate(go_gc_total[5m]) > 2
    for: 10m
    annotations:
      summary: "GC running more than 2 times per second"

  - alert: LongGCPause
    expr: |
      histogram_quantile(0.99,
        rate(go_gc_duration_seconds_bucket[5m])
      ) > 0.05
    for: 5m
    annotations:
      summary: "GC pause P99 > 50ms"

Чеклист оптимизации

Быстрые победы (< 1 день)

  • Включить GODEBUG=gctrace=1 и измерить базовую линию
  • Установить GOGC=200 и измерить эффект
  • Добавить GOMEMLIMIT в Kubernetes deployment
  • Настроить Prometheus метрики для GC

Средний эффорт (1 неделя)

  • Профилировать аллокации с pprof
  • Добавить sync.Pool для hot paths
  • Оптимизировать структуры данных
  • Предаллоцировать слайсы известных размеров

Большой эффорт (2-4 недели)

  • Имплементировать ballast memory
  • Переписать критичные части для zero-allocation
  • Оптимизировать JSON serialization
  • Рассмотреть object pooling для всех типов

Заключение

Go Garbage Collector - это мощный инструмент, но он может убить P99 latency в высоконагруженных системах. Ключевые выводы:

Главные проблемы:

  • Stop-The-World паузы создают latency spikes
  • По умолчанию GC оптимизирован для пропускной способности, а не latency
  • В high-throughput системах GC запускается часто

Решения:

  1. GOGC=200-300 - меньше частота GC
  2. GOMEMLIMIT - защита от OOM и предсказуемость
  3. Ballast memory - стабильные GC паузы
  4. Меньше аллокаций - меньше работы для GC
  5. sync.Pool - переиспользование объектов

Результаты:

  • P99 latency: 500ms → 50ms (10x улучшение)
  • Cost: +20-30% памяти
  • ROI: огромный для business-critical API

Золотое правило:

Измеряй, оптимизируй, мониторь. GC tuning - это баланс между memory, latency и throughput. Начинай с GOGC и GOMEMLIMIT, затем углубляйся в оптимизацию аллокаций.

Дополнительные материалы


Удалось победить GC в production? Поделитесь своими кейсами и метриками!

comments powered by Disqus