Go‑сервис на малых нагрузках работает идеально. Горутины дешёвые, GC быстрый, net/http из коробки тянет приличный трафик. Разработчик прогоняет функциональные тесты, видит зелёное, деплоит. Приходят 1000 RPS, и latency p99 взлетает с 50ms до 5 секунд, в логах начинают мелькать таймауты, а в Grafana рисуется красивая кривая деградации.

Инструменты: vegeta и wrk2

Для нагрузочного тестирования Go‑сервисов используем два инструмента.

vegeta написан на Go, понимает гошные паттерны, выводит результаты в удобном формате:

go install github.com/tsenart/vegeta@latest

echo "GET http://localhost:8080/api/orders" | \
  vegeta attack -rate=500/s -duration=30s | \
  vegeta report

wrk2 — форк wrk с фиксированной частотой запросов. Обычный wrk отправляет запросы настолько быстро, насколько может: если сервер замедлился, wrk тоже замедляется, и вы не видите реальную деградацию. wrk2 продолжает слать с заданной частотой, и если сервер не успевает, это видно по latency:

wrk2 -t2 -c10 -d30s -R1000 http://localhost:8080/api/orders

Начинайте с малого: 100 RPS, потом 300, потом 500, потом 1000. На каждом шаге смотрите на три вещи.

Что смотреть в результатах

После прогона vegeta выдаёт что‑то такое:

Requests      [total, rate, throughput]  30000, 1000.03, 987.21
Duration      [total, attack, wait]     30.412s, 29.999s, 412.912ms
Latencies     [min, mean, 50, 90, 95, 99, max]
              1.2ms, 45.3ms, 12.1ms, 89.4ms, 234.5ms, 2134.1ms, 5312.7ms
Status Codes  [code:count]  200:29847  503:112  0:41
  • p50 vs p99. p50 = 12ms, p99 = 2134ms. Медианный запрос быстрый, но каждый сотый обрабатывается в 175 раз дольше. При 1000 RPS это 10 человек в секунду, которые ждут по две секунды.

  • throughput vs rate. Просили 1000 RPS, throughput 987. 13 запросов в секунду теряются. Сервис на пределе.

  • Status codes. 112 ошибок 503, 41 ошибка с кодом 0 (таймаут, сервер не ответил). 0.5% ошибок за 30 секунд — тысячи в час на реальном трафике.

Разрыв между p50 и p99 — главный индикатор. Если p50 и p99 близки, сервис стабилен. Если p99 в десятки раз больше, где‑то есть ресурс, который при конкурентном доступе деградирует.

Причина 1: пул коннектов к базе данных

По дефолту sql.DB в Go не ограничивает количество открытых соединений (MaxOpenConns = 0) и держит всего 2 idle‑соединения. При 1000 RPS каждый запрос может открыть новое соединение к базе (TCP handshake + TLS + аутентификация), Postgres захлёбывается от количества процессов, p99 взлетает.

db, _ := sql.Open("postgres", connStr)

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(3 * time.Minute)

Мониторьте пул:

func reportDBStats(db *sql.DB) {
    ticker := time.NewTicker(10 * time.Second)
    for range ticker.C {
        stats := db.Stats()
        log.Printf("db: open=%d inuse=%d idle=%d wait=%d wait_dur=%s",
            stats.OpenConnections, stats.InUse, stats.Idle,
            stats.WaitCount, stats.WaitDuration)
    }
}

Если WaitCount растёт, пул мал. Если InUse постоянно на максимуме, вы на пределе.

Причина 2: HTTP‑клиент с дефолтами

http.DefaultClient использует DefaultTransport с MaxIdleConnsPerHost = 2. Два. При 1000 RPS к одному downstream вы постоянно открываете и закрываете TCP‑соединения.

var paymentClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   100,
        MaxConnsPerHost:       100,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:  5 * time.Second,
    },
}

Используйте один клиент на весь сервис. Новый http.Client = новый пул соединений = все старые выброшены.

Проверка:

watch -n1 "ss -tn state time-wait | grep :8081 | wc -l"

Сотни TIME_WAIT — соединения создаются и закрываются вместо переиспользования. После настройки MaxIdleConnsPerHost TIME_WAIT уйдут.

Причина 3: горутины без ограничений

net/http стартует горутину на каждый запрос. При 1000 RPS и обработке по 100ms живёт ~100 горутин. Если обработка замедлилась до секунды, горутин уже 1000, каждая держит стек, буферы, соединения. Дальше цепная реакция: больше горутин, больше памяти, чаще GC, медленнее, ещё больше горутин и OOM.

var sem = make(chan struct{}, 200)

func rateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        select {
        case sem <- struct{}{}:
            defer func() { <-sem }()
            next.ServeHTTP(w, r)
        default:
            http.Error(w, "too many requests", http.StatusServiceUnavailable)
        }
    })
}

Мониторинг:

func reportGoroutines() {
    ticker := time.NewTicker(10 * time.Second)
    for range ticker.C {
        log.Printf("goroutines: %d", runtime.NumGoroutine())
    }
}

Если число растёт и не возвращается к baseline после спада нагрузки — утечка. Обычно это горутина, заблокированная на чтении из канала или на HTTP‑запросе без таймаута.

Причина 4: GC под нагрузкой

Если сервис аллоцирует много короткоживущих объектов (десериализация JSON, создание буферов), частота GC растёт и на p99 видны всплески.

GODEBUG=gctrace=1 ./myservice 2>&1 | head -20

GC каждые 10–20ms — слишком много аллокаций. Решения:

var bufPool = sync.Pool{
    New: func() any { return make([]byte, 0, 4096) },
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().([]byte)
    buf = buf[:0]
    defer bufPool.Put(buf)
    // используем buf вместо нового слайса
}

И быстрая десериализация через json‑iter вместо encoding/json:

import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary

Профилирование аллокаций:

go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap

Обычно топ-3 функции отвечают за 80% аллокаций.

Причина 5: отсутствие таймаутов

http.Server по умолчанию не имеет таймаутов. Медленный клиент держит соединение бесконечно.

server := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
}

На стороне downstream — context с таймаутом:

func handleOrders(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
    defer cancel()

    rows, err := db.QueryContext(ctx, "SELECT * FROM orders WHERE user_id=$1", uid)
    if err != nil {
        http.Error(w, "timeout", http.StatusGatewayTimeout)
        return
    }
    
    req, _ := http.NewRequestWithContext(ctx, "GET", "http://inventory/check", nil)
    resp, err := paymentClient.Do(req)
    // ...
}

Без таймаутов один зависший downstream тянет за собой весь сервис: горутины копятся, пул забивается, новые запросы встают в очередь.

Тестирование цепочки сервисов

Для цепочки нужны метрики на каждом сервисе:

var requestDuration = promauto.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
    },
    []string{"method", "path", "status"},
)

var activeRequests = promauto.NewGauge(prometheus.GaugeOpts{
    Name: "http_active_requests",
})

Создаёте нагрузку на точку входа и в Grafana смотрите, какой сервис деградирует первым. Тот, у которого http_request_duration_seconds растёт раньше остальных, обычно и есть узкое место.

Порядок действий

Когда сервис деградирует под нагрузкой, проверяйте по списку:

  1. Прогоните vegeta на 100 → 300 → 500 → 1000 RPS, найдите точку деградации

  2. db.Stats() — WaitCount растёт? Пул мал

  3. ss -tn state time-wait — TIME_WAIT много? HTTP‑клиент не переиспользует соединения

  4. runtime.NumGoroutine() — растёт и не падает? Утечка

  5. GODEBUG=gctrace=1 — GC каждые 10ms? Слишком много аллокаций

  6. Таймауты на сервере и клиенте есть? Если нет, один зависший запрос тянет всё

На каждом шаге: исправил, прогнали нагрузку, сравнили p99. Упал — нашли причину. Не упал — следующий пункт.

Если смотреть шире, такие проверки нужны не только для тушения пожаров. Они помогают понять, где в архитектуре появляются хрупкие места: слишком тесная связка с базой, отсутствие backpressure, неограниченные горутины, downstream без таймаутов, сервисы без нормальной наблюдаемости. В микросервисной системе всё это быстро перестаёт быть локальной проблемой одного endpoint«а.»

Поэтому при разработке сервисов на Go важно думать не только о коде обработчика, но и о поведении всей цепочки под нагрузкой. Эти темы — от взаимодействия сервисов до отказоустойчивости и observability — подробно разбираются в рамках курса «Микросервисы на Go», где Go рассматривается уже как инструмент для построения production‑систем, а не просто быстрых HTTP‑сервисов.

А если хочется зайти в тему с архитектурной стороны, начните с бесплатного демо-урока 19 мая в 20:00 — «Грамотная декомпозиция монолита: когда микросервисы не нужны». На нем можно будет познакомиться с преподавателем-практиком, посмотреть на формат обучения и задать вопросы по декомпозиции, архитектурным решениям и курсу. Записаться