Оптимизация CPU для обработки 1 миллиона RPS на Go-сервисе

Исходная ситуация: 200K RPS и потолок CPU

В феврале 2026 года к нам обратилась команда АдПлатформа — ad-tech сервис показа рекламных баннеров. Инфраструктура: 666 инстансов Go-сервиса, каждый на 7 vCPU / 7.5 GB RAM. Бизнес-задача — обслуживать 1 000 000 RPS (запросов рекламных аукционов в секунду) при P99 латентности < 50 мс.

Текущая ситуация: 200K RPS при 85% утилизации CPU. При попытке вырасти до 300K RPS латентность P99 взлетала до 200+ мс, SLA нарушался, рекламодатели теряли деньги. Просто добавить серверов было нельзя — бюджет на инфраструктуру уже составлял 40% выручки.

Нам нужно было увеличить пропускную способность в 5 раз без пропорционального роста серверов. Начали с профилирования.

Профилирование: perf, pprof и flamegraphs

Оптимизировать без данных — стрелять вслепую. Мы начали с трёх уровней профилирования:

# Уровень 1: системное профилирование через perf
# Запускаем perf на Go-процессе в течение 30 секунд
perf record -F 99 -p $(pgrep adserver) -g -- sleep 30
perf report --stdio | head -40

# Результат (топ функций по CPU):
# 31.2%  adserver  [.] runtime.mallocgc
# 18.7%  adserver  [.] encoding/json.(*encodeState).marshal
# 11.4%  adserver  [.] runtime.gcBgMarkWorker
#  8.3%  adserver  [.] net/http.(*conn).readRequest
#  5.1%  adserver  [.] runtime.scanobject

Три главных пожирателя CPU: аллокации памяти (31.2%), JSON-сериализация (18.7%) и сборщик мусора (11.4% + 5.1% на сканировании объектов). Суммарно GC и аллокации забирали 48% процессорного времени.

# Уровень 2: Go pprof — встроенный профайлер
# Добавляем эндпоинт в сервис
import _ "net/http/pprof"

# Собираем CPU профиль за 30 секунд
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30

# Профиль аллокаций
go tool pprof -http=:8082 http://localhost:6060/debug/pprof/heap
# Уровень 3: Flamegraph для визуализации
# Генерируем flamegraph из perf-данных
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg

# Или через pprof напрямую:
go tool pprof -raw -output=cpu.prof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -svg cpu.prof > flamegraph.svg

Flamegraph наглядно показал: широкие полосы runtime.mallocgc и encoding/json — это наши основные цели для оптимизации. Каждый рекламный аукцион создавал около 340 аллокаций и сериализовал ответ в JSON размером 2-4 KB.

Go Runtime тюнинг: GOMAXPROCS и сборщик мусора

Первая волна оптимизации — настройка рантайма Go без изменения кода приложения:

# GOMAXPROCS — количество потоков ОС для горутин
# По умолчанию = количество ядер. На 7 vCPU в Kubernetes
# стоит использовать automaxprocs от Uber:
import _ "go.uber.org/automaxprocs"

# automaxprocs определяет реальные лимиты из cgroup
# и устанавливает GOMAXPROCS соответственно.
# В нашем случае: GOMAXPROCS=7 вместо 64 (число ядер хоста)

Без automaxprocs Go видел все 64 ядра физического хоста и создавал 64 потока, хотя CPU лимит в Kubernetes был 7 ядер. Это приводило к чрезмерному context switching и потерям на планировщике.

# Тюнинг GC — самый мощный рычаг
# По умолчанию GOGC=100 (GC запускается при удвоении heap)

# Для нашего сервиса с 7.5 GB RAM установили:
export GOGC=400
export GOMEMLIMIT=6GiB

# GOGC=400 означает: GC запускается, когда heap вырастает в 5 раз
# от live data. Это снижает частоту GC с ~50 раз/сек до ~8 раз/сек
# GOMEMLIMIT=6GiB (Go 1.19+) — жёсткий лимит, GC запустится
# принудительно при приближении к 6 GB
# Мониторинг GC через runtime/metrics
import "runtime/metrics"

func collectGCMetrics() {
    // Ключевые метрики для наблюдения:
    samples := []metrics.Sample{
        {Name: "/gc/cycles/total:gc-cycles"},
        {Name: "/gc/pauses/total:seconds"},
        {Name: "/memory/classes/heap/objects:bytes"},
        {Name: "/gc/heap/goal:bytes"},
    }
    metrics.Read(samples)
    
    // Публикуем в Prometheus
    gcCycles.Set(float64(samples[0].Value.Uint64()))
    gcPauseTotal.Set(samples[1].Value.Float64())
    heapObjects.Set(float64(samples[2].Value.Uint64()))
    heapGoal.Set(float64(samples[3].Value.Uint64()))
}

Результат первой волны: CPU-утилизация GC упала с 16.5% до 4.2%. Пропускная способность выросла с 200K до 320K RPS на том же железе.

Протокол: protobuf вместо JSON

JSON-сериализация съедала 18.7% CPU. Каждый аукцион: десериализация входящего запроса (bid request) и сериализация ответа (bid response). Мы перешли на Protocol Buffers:

// auction.proto — описание структур
syntax = "proto3";

package adplatform;

message BidRequest {
    string request_id = 1;
    string publisher_id = 2;
    repeated Impression impressions = 3;
    UserInfo user = 4;
    DeviceInfo device = 5;
}

message Impression {
    string id = 1;
    string ad_slot_id = 2;
    int32 width = 3;
    int32 height = 4;
    double floor_price = 5;
}

message BidResponse {
    string request_id = 1;
    repeated Bid bids = 2;
}

message Bid {
    string impression_id = 1;
    double price = 2;
    string ad_markup = 3;
    string advertiser_id = 4;
}
// Сравнение производительности сериализации
// Бенчмарк на типичном bid response (2 KB payload)

func BenchmarkJSONMarshal(b *testing.B) {
    resp := createTypicalBidResponse()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        json.Marshal(resp)  // ~3200 ns/op, 2048 B/op, 34 allocs/op
    }
}

func BenchmarkProtoMarshal(b *testing.B) {
    resp := createTypicalBidResponse()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        proto.Marshal(resp)  // ~380 ns/op, 512 B/op, 1 allocs/op
    }
}

// protobuf в 8.4 раза быстрее и в 34 раза меньше аллокаций

Дополнительно мы использовали vtprotobuf — генератор optimized кода для protobuf, который применяет pool аллокаций и zero-copy десериализацию:

# Установка vtprotobuf
go install github.com/planetscale/vtprotobuf/cmd/protoc-gen-go-vtproto@latest

# Генерация с vtprotobuf
protoc --go_out=. --go-vtproto_out=. --go-vtproto_opt=features=marshal+unmarshal+size auction.proto

С vtprotobuf сериализация ускорилась ещё в 2 раза по сравнению со стандартным protobuf. Суммарная экономия CPU на сериализации: с 18.7% до 1.8%.

Connection pooling и batch processing

Каждый рекламный аукцион делал 3-5 запросов: к Redis (профиль пользователя), к ClickHouse (история показов), к другим микросервисам (бюджеты рекламодателей). Без пулинга каждый запрос создавал новое TCP-соединение:

// Connection pool для Redis — уменьшаем накладные расходы
import "github.com/redis/go-redis/v9"

rdb := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{"redis-1:6379", "redis-2:6379", "redis-3:6379"},
    
    // Pool settings — ключевые для высокой нагрузки
    PoolSize:     500,          // 500 соединений на инстанс
    MinIdleConns: 100,          // минимум 100 горячих соединений
    PoolTimeout:  2 * time.Second,
    
    // Таймауты
    DialTimeout:  1 * time.Second,
    ReadTimeout:  500 * time.Millisecond,
    WriteTimeout: 500 * time.Millisecond,
    
    // Pipeline для батчинга команд
    MaxRetries: 2,
})
// Batch processing — собираем несколько аукционов в один запрос к БД
type AuctionBatcher struct {
    mu       sync.Mutex
    batch    []*AuctionRequest
    timer    *time.Timer
    maxSize  int
    maxWait  time.Duration
    resultCh map[string]chan *AuctionResult
}

func NewAuctionBatcher() *AuctionBatcher {
    return &AuctionBatcher{
        maxSize:  50,                    // максимум 50 запросов в батче
        maxWait:  2 * time.Millisecond,  // или ждём максимум 2 мс
        resultCh: make(map[string]chan *AuctionResult),
    }
}

func (b *AuctionBatcher) Add(req *AuctionRequest) *AuctionResult {
    ch := make(chan *AuctionResult, 1)
    b.mu.Lock()
    b.batch = append(b.batch, req)
    b.resultCh[req.ID] = ch
    
    if len(b.batch) >= b.maxSize {
        b.flush()
    } else if len(b.batch) == 1 {
        b.timer = time.AfterFunc(b.maxWait, func() {
            b.mu.Lock()
            b.flush()
            b.mu.Unlock()
        })
    }
    b.mu.Unlock()
    
    return <-ch  // ждём результат
}

func (b *AuctionBatcher) flush() {
    // Один запрос к ClickHouse вместо 50 отдельных
    // SELECT * FROM impressions WHERE user_id IN (...50 user IDs...)
    batch := b.batch
    b.batch = nil
    go b.processBatch(batch)
}

Батчинг запросов к ClickHouse дал колоссальный эффект: вместо 50 отдельных SELECT мы делали один запрос с IN-клаузой. Латентность обращения к ClickHouse упала с 5 мс * 50 = 250 мс до 8 мс на весь батч. CPU-экономия за счёт пулинга и батчинга — ещё 12%.

CPU pinning, NUMA awareness и kernel-уровень

Когда приложение выжато на уровне кода, остаётся оптимизация на уровне ОС и железа. На наших серверах с двумя NUMA-нодами (2x Intel Xeon, 32 ядра на ноду) неправильное размещение процессов стоило 15-20% производительности:

# Проверяем NUMA-топологию сервера
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0-31
# node 0 size: 64 GB
# node 1 cpus: 32-63
# node 1 size: 64 GB
# node distances:
# node   0   1
#   0:  10  21
#   1:  21  10

# Расстояние между нодами 21 vs 10 — cross-NUMA доступ в 2.1 раза медленнее
# CPU pinning — привязываем Go-процесс к конкретным ядрам одной NUMA-ноды
# Вместо разброса по 64 ядрам — 7 ядер на одной ноде
taskset -c 0-6 ./adserver

# Или через cgroup в Kubernetes:
# resources:
#   limits:
#     cpu: "7"
#   requests:
#     cpu: "7"
# С kubelet параметром --cpu-manager-policy=static
# Kubernetes гарантирует exclusive CPU cores
# NUMA-aware запуск — память и CPU на одной ноде
numactl --cpunodebind=0 --membind=0 ./adserver

# Проверяем, что процесс действительно на одной NUMA-ноде
numastat -p $(pgrep adserver)
# Per-node process memory usage (in MBs)
# PID    Node 0  Node 1
# 12345  6144.2    12.1   ← почти вся память на Node 0
# Kernel-уровень оптимизации — sysctl
# /etc/sysctl.d/99-adplatform.conf

# Увеличиваем буферы сети
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# TCP Fast Open
net.ipv4.tcp_fastopen = 3

# Переиспользование TIME_WAIT соединений
net.ipv4.tcp_tw_reuse = 1

# Размер буферов
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216

# Применяем
sysctl -p /etc/sysctl.d/99-adplatform.conf

Для самых горячих путей мы рассматривали DPDK (Data Plane Development Kit) — обход ядра для сетевых пакетов. Однако DPDK требует выделения NIC из ядра и не совместим с Kubernetes-сетевой моделью. Вместо этого применили io_uring для асинхронного I/O и SO_REUSEPORT для балансировки по ядрам:

// SO_REUSEPORT — каждое ядро обрабатывает свою очередь соединений
listener, err := net.ListenConfig{
    Control: func(network, address string, c syscall.RawConn) error {
        return c.Control(func(fd uintptr) {
            syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET,
                unix.SO_REUSEPORT, 1)
        })
    },
}.Listen(ctx, "tcp", ":8080")

Результаты: от 200K до 1M RPS

Суммарные результаты оптимизации на тех же 666 инстансах:

ОптимизацияCPU-экономияRPS прирост
GOMAXPROCS + automaxprocs8%200K → 240K
GC tuning (GOGC=400, GOMEMLIMIT)12%240K → 340K
Protobuf + vtprotobuf17%340K → 520K
Connection pooling6%520K → 600K
Batch processing12%600K → 780K
CPU pinning + NUMA15%780K → 950K
Kernel tuning + SO_REUSEPORT5%950K → 1.05M

Итого: 5.25x прирост при нулевом увеличении инфраструктуры. P99 латентность снизилась с 120 мс до 32 мс. Бюджет на серверы остался прежним, а рекламодатели получили более быстрые аукционы.

Ключевой урок для клиентов itfresh.ru: профилируйте перед оптимизацией. Без flamegraph мы бы начали с сетевого тюнинга (5% эффекта) вместо GC и сериализации (29% эффекта).

Часто задаваемые вопросы

С профилирования. Запустите pprof на CPU и heap, сгенерируйте flamegraph. В 80% случаев основные потери — это GC (аллокации) и сериализация (JSON). Оптимизируйте самые широкие полосы на flamegraph, не угадывайте.
Нет универсального значения. Начните с GOGC=200 и GOMEMLIMIT=70% от доступной памяти. Мониторьте частоту GC через runtime/metrics. Увеличивайте GOGC, пока частота GC не станет приемлемой (менее 10 раз в секунду), но следите, чтобы heap не приближался к GOMEMLIMIT.
Когда сериализация занимает больше 10% CPU по flamegraph. Для API между своими микросервисами protobuf почти всегда лучше: в 8-10 раз быстрее, компактнее на проводе, строгая типизация. Для публичных REST API JSON остаётся стандартом — но можно предлагать protobuf как опцию.
NUMA (Non-Uniform Memory Access) означает, что каждый процессор имеет свою локальную память. Доступ к памяти другого процессора в 2-3 раза медленнее. Если Go-процесс разбросан по обеим нодам, половина обращений к памяти будет медленной. Привязка через numactl --cpunodebind=0 --membind=0 гарантирует локальный доступ.
В большинстве случаев нет. DPDK требует выделения сетевого интерфейса из ядра, несовместим с Kubernetes-сетью и усложняет деплой. Для Go-сервисов лучше комбинация SO_REUSEPORT + kernel tuning + io_uring. DPDK оправдан только для специализированных сетевых приложений (DPI, балансировщики, файрволлы).

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#cpu оптимизация go#1 миллион rps#perf flamegraph#gomaxprocs настройка#gc tuning golang#protobuf vs json#cpu pinning numa#connection pooling
Комментарии 0

Оставить комментарий

загрузка...