Почему ваш Go‑сервис ломается под 1000 RPS и как найти узкое место за полчаса
- вторник, 12 мая 2026 г. в 00:00:16
Go‑сервис на малых нагрузках работает идеально. Горутины дешёвые, GC быстрый, net/http из коробки тянет приличный трафик. Разработчик прогоняет функциональные тесты, видит зелёное, деплоит. Приходят 1000 RPS, и latency p99 взлетает с 50ms до 5 секунд, в логах начинают мелькать таймауты, а в Grafana рисуется красивая кривая деградации.
Для нагрузочного тестирования 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 в десятки раз больше, где‑то есть ресурс, который при конкурентном доступе деградирует.
По дефолту 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 постоянно на максимуме, вы на пределе.
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 уйдут.
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‑запросе без таймаута.
Если сервис аллоцирует много короткоживущих объектов (десериализация 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% аллокаций.
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 растёт раньше остальных, обычно и есть узкое место.
Когда сервис деградирует под нагрузкой, проверяйте по списку:
Прогоните vegeta на 100 → 300 → 500 → 1000 RPS, найдите точку деградации
db.Stats() — WaitCount растёт? Пул мал
ss -tn state time-wait — TIME_WAIT много? HTTP‑клиент не переиспользует соединения
runtime.NumGoroutine() — растёт и не падает? Утечка
GODEBUG=gctrace=1 — GC каждые 10ms? Слишком много аллокаций
Таймауты на сервере и клиенте есть? Если нет, один зависший запрос тянет всё
На каждом шаге: исправил, прогнали нагрузку, сравнили p99. Упал — нашли причину. Не упал — следующий пункт.

Если смотреть шире, такие проверки нужны не только для тушения пожаров. Они помогают понять, где в архитектуре появляются хрупкие места: слишком тесная связка с базой, отсутствие backpressure, неограниченные горутины, downstream без таймаутов, сервисы без нормальной наблюдаемости. В микросервисной системе всё это быстро перестаёт быть локальной проблемой одного endpoint«а.»
Поэтому при разработке сервисов на Go важно думать не только о коде обработчика, но и о поведении всей цепочки под нагрузкой. Эти темы — от взаимодействия сервисов до отказоустойчивости и observability — подробно разбираются в рамках курса «Микросервисы на Go», где Go рассматривается уже как инструмент для построения production‑систем, а не просто быстрых HTTP‑сервисов.
А если хочется зайти в тему с архитектурной стороны, начните с бесплатного демо-урока 19 мая в 20:00 — «Грамотная декомпозиция монолита: когда микросервисы не нужны». На нем можно будет познакомиться с преподавателем-практиком, посмотреть на формат обучения и задать вопросы по декомпозиции, архитектурным решениям и курсу. Записаться