golang

Один сервис — четыре стека: практический бенчмарк с SLO по p99 и Docker/JMeter

  • пятница, 19 декабря 2025 г. в 00:00:07
https://habr.com/ru/companies/domclick/articles/970104/

В этой статье представлено сравнение четырёх реализаций одного и того же сервиса поверх PostgreSQL:

  1. Spring MVC + JDBC

  2. Spring WebFlux + R2DBC

  3. Ktor + JDBC

  4. Go + pgx

Все сервисы крутятся в Docker с одинаковыми ресурсными лимитами и прогоняются через один и тот же JMeter-план. Для каждого стека определяется максимальный RPS при соблюдении SLO по p99-латентности.

Разберём подробно:

  • Как устроен стенд (docker-compose, Postgres, Prometheus, Grafana, JMeter)

  • Как реализованы сервисы и в чём различия моделей конкурентности

  • Методологию нагрузочного тестирования и расчёта RPS@p99≤SLA

  • Как реально ведут себя блокирующий стек, реактивный стек и Go под честной нагрузкой

Введение

Если открыть любой холивар на тему «какой стек быстрее», то почти всегда всплывают странные бенчмарки:

  • hello world

  • Синтетические роуты без баз данных

  • Микросервисы без метрик

  • Нагрузка с ноутбука разработчика и выводы уровня «я запустил на своём маке — Go в 3 раза быстрее»

Читать такое забавно, но пользы мало, поскольку слишком много скрытых допущений, непонятные условия, нет ни SLO, ни честного анализа того, во что именно упирается система — в фреймворк, базу данных или железо.

В этой статье я пошёл от обратного:

  • Взял максимально приземлённый сценарий

  • Реализовал один и тот же сервис поверх PostgreSQL на четырёх стеках

  • Прогнал их под одной и той же нагрузкой с измеримым SLO по p99

HTTP-API у всех сервисов одинаковый:

  • GET /status/{id}

  • GET /status?ids=...

  • POST /status

  • PUT /status/{id}

Сервисы смотрят в одну и ту же базу bench с одинаковой схемой. Все они:

  • Запускаются через общий docker-compose

  • Имеют одинаковые лимиты по процессору и памяти

  • Получают одинаковый профиль нагрузки через JMeter

Разница только в стеке:

  • Spring MVC + JDBC

  • Spring WebFlux + R2DBC

  • Ktor + JDBC

  • Go + pgx

Нагрузка подаётся контролируемо. Я задаю профиль RPS с полками и смотрю:

  • Выдерживается ли SLO по p99

  • Какой RPS можно считать устойчивым

  • Сколько CPU и RAM тратит каждый сервис

  • Какая доля ошибок на полке

Главные вопросы заключаются в следующем:

  • Выигрывает ли реактивный стек у классического Spring MVC в таком сценарии

  • Насколько Go оказывается «легче» по ресурсам на единицу полезного RPS

  • Как сильно влияет не выбор языка или фреймворка, а аккуратный тюнинг пулов, таймаутов, настроек баз данных и окружения

Описание стенда

Стенд предельно простой. В нём нет Kubernetes и прочей экзотики, только Docker и docker-compose.

Общая схема

На одном хосте запускается docker-compose, включающий:

  • Четыре тестируемых сервиса

  • Один PostgreSQL

  • Prometheus

  • Grafana

  • Отдельный контейнер с JMeter

Схема трафика:

  • JMeter → сервис (HTTP) → PostgreSQL

  • Параллельно Prometheus ходит в сервисы за метриками → Grafana рисует графики → отдельный скрипт забирает агрегаты из JMeter и Prometheus

Все сервисы реализуют один и тот же HTTP-API поверх одной и той же базы данных bench. Различия только в стеке и модели I/O.

Сервис

Технологический стек

Порт

Драйвер базы данных / Пул соединений

Лимиты CPU/RAM (per container)

go‑pgx

Go 1.25, net/http, goroutines

8080

pgx (native), pgxpool

2CPU/2gb

spring‑mvc

Java 17, Spring Boot MVC (Servlet/Tomcat, blocking IO)

8080

PostgreSQL JDBC (org.postgresql.Driver) + HikariCP

2CPU/2gb

webflux

Java 17, Spring WebFlux (reactive, Netty)

8080

R2DBC PostgreSQL (io.r2dbc:r2dbc-postgresql) + r2dbc-pool

2CPU/2gb

ktor-jdbc

Kotlin, Ktor (CIO/Netty), blocking JDBC

8080

PostgreSQL JDBC + HikariCP

2CPU/2gb

docker-compose и ресурсы

Фрагмент docker-compose.yml (упрощённо):

Код
version: "3.9"

services:
  postgres:
  image: postgres:16
  environment:
    POSTGRES_DB: bench
    POSTGRES_USER: bench
    POSTGRES_PASSWORD: bench
  ports:
    - "5432:5432"
  cpus: "2.0"
  mem_limit: 2g
  volumes:
    - ./sql:/docker-entrypoint-initdb.d
  command: [
    "postgres",
    "-c", "max_connections=300",
    "-c", "shared_buffers=512MB",
    "-c", "work_mem=8MB",
    "-c", "effective_cache_size=1536MB",
    "-c", "maintenance_work_mem=128MB",
    "-c", "synchronous_commit=on",
    "-c", "shared_preload_libraries=pg_stat_statements",
    "-c", "track_activity_query_size=32768"
  ]
  ulimits:
    nofile:
      soft: 1048576
      hard: 1048576

  svc-spring-mvc-jdbc:
    build:
      context: ./spring-mvc-jdbc-bench
      dockerfile: Dockerfile
    image: spring-mvc-jdbc-bench:latest
    environment: &dbenv
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: bench
      DB_USER: bench
      DB_PASSWORD: bench
    depends_on: [postgres]
    ports:
      - "8083:8080"
    cpus: "2.0"
    mem_limit: 1g

  svc-spring-webflux-r2dbc:
    build:
      context: ./spring-webflux-r2dbc-bench
      dockerfile: Dockerfile
    image: spring-webflux-r2dbc-bench:latest
    environment: *dbenv
    depends_on: [postgres]
    ports:
      - "8082:8080"
    cpus: "2.0"
    mem_limit: 1g

  svc-ktor-jdbc:
    build:
      context: ./bench-ktor-jdbc
      dockerfile: Dockerfile
    image: bench-ktor-jdbc:latest
    environment:
      <<: *dbenv
      HTTP_PORT: "8080"
      HTTP_HOST: "0.0.0.0"
    depends_on: [postgres]
    ports:
      - "8081:8080"
    cpus: "2.0"
    mem_limit: 1g

  svc-go-pgx:
    build:
      context: ./go-pgx-bench
      dockerfile: Dockerfile
    image: go-pgx-bench:latest
    environment: *dbenv
    depends_on: [postgres]
    ports:
      - "8084:8080"
    cpus: "2.0"
    mem_limit: 1g

Для всех четырёх сервисов условия одинаковые:

  • 2 vCPU и 1 Гб RAM (ограничения Docker)

  • Одинаковые переменные окружения для доступа к базе данных (DB_HOST=postgres, DB_NAME=bench и т.д.)

  • На хосте сервисы доступны по следующим портам:

    • Spring MVC + JDBC: localhost:8083

    • Spring WebFlux + R2DBC: localhost:8082

    • Ktor + JDBC: localhost:8081

    • Go + pgx: localhost:8084

Мониторинг и нагрузка:

Код
  prometheus:
    image: prom/prometheus:v2.55.0
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
    cpus: "0.5"
    mem_limit: 512m

  grafana:
    image: grafana/grafana:11.2.0
    ports:
      - "3000:3000"
    cpus: "0.5"
    mem_limit: 512m

    jmeter:
    cpus: "4.0"
    mem_limit: 1g
    build:
      context: .
      dockerfile: Dockerfile.jmeter
    volumes:
      - ./perf:/tests
    working_dir: /tests
    command: >
      -Jhttpclient4.retrycount=1
      -Jhttpclient4.validate_after_inactivity=2000
      -Jhttpclient4.time_to_live=180000
      -Jhttp.maxConnections=2000
      -n -t plan2.jmx -l results.jtl -e -o report

    sysctls:
      - net.ipv4.ip_local_port_range=1024 65535
      - net.ipv4.tcp_fin_timeout=15
      - net.ipv4.tcp_tw_reuse=1
      - net.core.somaxconn=65535
      - net.ipv4.tcp_max_syn_backlog=65535

    ulimits:
      nofile:
        soft: 1048576
        hard: 1048576

Prometheus и Grafana живут довольно скромно (по 0,5 vCPU и 512 Мб RAM). JMeter я считаю частью нагрузочной машины и отдельно по процессору и памяти не ограничиваю.

Версии стеков

Для честного сравнения я использовал актуальные на момент проведения эксперимента версии языков и фреймворков.

Java и Spring:

  • JDK 21

  • Spring Boot 3.3.3

  • Spring MVC сервис: spring-boot-starter-web + spring-boot-starter-jdbc + HikariCP

  • Spring WebFlux сервис: spring-boot-starter-webflux + spring-boot-starter-data-r2dbc + r2dbc-postgresql 1.0.7.RELEASE и r2dbc-pool 1.0.2.RELEASE

Kotlin и Ktor:

  • Kotlin 2.0.21

  • Ktor 3.0.1 (Netty server)

  • HikariCP 5.1.0 + PostgreSQL JDBC 42.7.4

  • Журналирование через Logback 1.5.7

  • Метрики через Micrometer 1.13.3 + Prometheus registry

Go и chi + pgx:

  • Go 1.25 (как в go.mod)

  • HTTP-роутинг — chi v5.0.12

  • Драйвер базы данных — pgx v5.6.0 (через пул)

  • Метрики — prometheus/client_golang v1.19.1

Базы данных и мониторинг:

  • PostgreSQL 16 (официальный Docker-образ)

  • Prometheus 2.55.0

  • Grafana 11.2.0

  • Нагрузка — JMeter в отдельном Docker-контейнере (подробности ниже)

Во всех случаях сервисы экспортируют метрики в формате Prometheus, включая CPU и память процесса.

Схема базы данных

Под нагрузкой у нас не hello world, а реальные запросы в базу данных. При запуске Postgres выполняется SQL-скрипт:

create extension if not exists pgcrypto;

create table if not exists status (
  id         uuid primary key,
  status     smallint not null,
  updated_at timestamptz not null default now()
);

create index if not exists status_updated_at_idx on status(updated_at);

insert into status (id, status)
select gen_random_uuid(), (random() * 10)::int2
from generate_series(1, 1000000);

То есть:

  • Одна таблица status с полями:

    • id — UUID, первичный ключ

    • status — небольшой числовой статус (smallint)

    • updated_at — время обновления (по умолчанию now())

  • Индекс по updated_at, чтобы не упираться в full scan в сценариях, где он может пригодиться

  • Перед запуском тестов генерируется 1 млн записей, поэтому кеш базы данных и диска участвуют в игре по-настоящему

Все сервисы работают по одной и той же схеме и с теми же данными.

Хост стенда

Важно: JMeter, сервисы и PostgreSQL работают на одном физическом хосте (через Docker). Это ограничение эксперимента. Для снижения влияния генератора нагрузки контейнер JMeter ограничен по ресурсам.

Технические характеристики стенда:

Параметр

Значение

CPU

Ryzen 5 5600x

RAM

32 Гб DDR4 3200 МГц

Диск

SATA SSD 500 Гб

ОС, ядро

Windows 11

Сервисы и их реализация

Во всех четырёх сервисах домен один и тот же: таблица status(id uuid, status smallint, updated_at timestamptz), простой CRUD + батч-чтение. Отличаются лишь стек и модель работы с I/O.

Чтобы не утонуть в листингах, ниже я приведу только фрагменты, демонстрирующие общий подход. Полный код всех реализаций лежит в репозитории (см. раздел про воспроизводимость).

Spring MVC + JDBC: классика на блокирующих потоках

Стек:

  • Spring Boot 3.3.3

  • spring-boot-starter-web (Tomcat)

  • spring-boot-starter-jdbc + HikariCP

  • PostgreSQL JDBC 42.7.4

  • Micrometer + Prometheus

Модель и DTO:

public record StatusDto(UUID id, short status, Instant updatedAt) {}
public record CreateStatusRequest(UUID id, short status) {}
public record UpdateStatusRequest(short status) {}

Репозиторий на JdbcTemplate.

Все операции синхронные, поверх Hikari-пула (максимум 64 соединения):

@Repository
public class StatusRepository {

    private final JdbcTemplate jdbc;

    private static final RowMapper<StatusDto> ROW_MAPPER = (rs, rowNum) ->
        new StatusDto(
            UUID.fromString(rs.getString("id")),
            rs.getShort("status"),
            rs.getTimestamp("updated_at").toInstant()
        );

    public Optional<StatusDto> findById(UUID id) {
        try {
            StatusDto dto = jdbc.queryForObject(
                "SELECT id, status, updated_at FROM status WHERE id = ?",
                ROW_MAPPER,
                id
            );
            return Optional.ofNullable(dto);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    public List<StatusDto> findByIds(List<UUID> ids) {
        if (ids == null || ids.isEmpty()) return List.of();

        String placeholders = IntStream.range(0, ids.size())
            .mapToObj(i -> "?")
            .collect(Collectors.joining(","));

        String sql = "SELECT id, status, updated_at FROM status WHERE id IN (" + placeholders + ")";

        return jdbc.query(con -> {
            PreparedStatement ps = con.prepareStatement(sql);
            for (int i = 0; i < ids.size(); i++) {
                ps.setObject(i + 1, ids.get(i));
            }
            return ps;
        }, ROW_MAPPER);
    }
}

Контроллер:

@RestController
@RequestMapping("/status")
public class StatusController {

    private final StatusService service;

    public StatusController(StatusService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public ResponseEntity<StatusDto> getById(@PathVariable UUID id) {
        return service.findById(id)
            .map(ResponseEntity::ok)
            .orElseThrow(() -> new NotFoundException(
                "Status with id %s not found".formatted(id)
            ));
    }

    @GetMapping
    public ResponseEntity<List<StatusDto>> getByIds(@RequestParam("ids") String idsCsv) {
        List<UUID> ids = Arrays.stream(idsCsv.split(","))
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .map(UUID::fromString)
            .toList();

        if (ids.size() > 50) {
            return ResponseEntity.badRequest().build();
        }

        return ResponseEntity.ok(service.findByIds(ids));
    }

    @PostMapping
    public ResponseEntity<StatusDto> create(@RequestBody CreateStatusRequest req) {
        StatusDto created = service.create(req);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<StatusDto> update(@PathVariable UUID id,
                                            @RequestBody UpdateStatusRequest req) {
        return ResponseEntity.ok(service.update(id, req));
    }
}

Модель конкурентности: каждый HTTP-запрос обслуживается блокирующим потоком Tomcat. Когда запрос уходит в базу данных, поток ждёт ответа. Распараллеливание ограничено пулом потоков сервера и пулом соединений Hikari.

Остальные реализации (очень кратко)

В остальных сервисах доменная логика и SQL максимально совпадают с этим примером, меняется только стек:

  • Spring WebFlux + R2DBC

    • Реактивный HTTP-слой поверх Netty

    • Неблокирующий драйвер базы данных (R2DBC)

    • Работа через Flux/Mono и реактивный r2dbc-pool

  • Ktor + JDBC

    • Ktor 3 + корутины

    • Блокирующий JDBC вынесен в отдельный пул потоков

    • HTTP-слой не блокируется на запросах к базе данных, но сами операции баз данных синхронные

  • Go + pgx

    • Go-сервис с маршрутизацией на chi

    • Пул соединений pgxpool

    • Каждая горутина делает блокирующий запрос в базу данных, но горутины очень дешёвые

Полный код для всех четырёх реализаций лежит в репозитории (см. раздел 9).

Коротко о различиях подходов

Если сильно упростить, то конкуренция выглядит так:

  • Spring MVC + JDBC. Классическая модель: «один поток на активный запрос». Проще всего для понимания и отладки, но каждый блокирующий I/O занимает поток. При высоком RPS упираемся либо в пул потоков Tomcat, либо в пул соединений.

  • Spring WebFlux + R2DBC. Реактивный стек поверх Netty и неблокирующего драйвера базы данных. Потоков мало, запросов много, их мультиплексируют Reactor и event loop. Потенциально позволяет выжать больше RPS при тех же ресурсах, но любое случайное блокирующее место портит всю картину.

  • Ktor + JDBC. Корутины + блокирующая база данных. HTTP-слой выглядит реактивным, но настоящая блокировка происходит в JDBC. За счёт вынесения базы данных в отдельный пул потоков мы защищаем event loop Ktor, но в целом остаёмся в парадигме «фиксированное число рабочих потоков, каждый из которых ждёт базу данных».

  • Go + pgx. Горутины + пул pgx. Вызовы к базе данных блокирующие, но сами «потоки» очень дешёвые. Это позволяет держать тысячи конкурентных запросов на нескольких OS-потоках. Код при этом выглядит как обычный последовательный.

В следующих разделах рассмотрим, как эти различия отражаются на реальных значениях RPS, задержках и потреблении CPU/RAM.

Методология нагрузочного тестирования (JMeter)

Наша задача — найти максимальный устойчивый RPS при выполнении SLO по p99 для каждого сценария и каждого сервиса. Ниже показано, как я это делаю на JMeter и что именно считаю.

Количество транзакций на каждый сервис
Количество транзакций на каждый сервис

Почему именно JMeter

Я остановился на JMeter по нескольким причинам:

  • JMeter легко контейнеризируется и запускается через docker compose run без локальной установки

  • Есть CLI-режим и HTML-отчёт: -n -t plan.jmx -l results.jtl -e -o report

  • Поддерживает нужные плагины:

    • jpgc-casutg — Ultimate Thread Group (управление пулом потоков)

    • jpgc-tst — Variable Throughput Timer (профиль RPS)

    • bzm-random-csv — Random CSV Data Set (случайные ID/пакеты ID)

  • Хорошо параметризуется через -J... (хост/порт и т.п.)

Профиль трафика: «нарастающая нагрузка»

Для того чтобы приблизиться к подходу «управление RPS», используется связка:

  • Ultimate Thread Group — большой пул потоков (например, до 2 тыс.), чтобы у драйвера были ресурсы «додавить» любой RPS

  • Variable Throughput Timer (TST) — формирует ступени нагрузки в RPS

Пример профиля:

  • Разгон 0 → ~1500 RPS (10 минут)

  • Полка ~1500 RPS (20 минут)

  • Разгон ~1500 → ~3000 RPS (10 минут)

  • Полка ~3000 RPS (20 минут)

  • Далее аналогично для более высоких плато

Длинные полки нужны для того, чтобы p99 успевал стабилизироваться.

Комбинация сценариев и данные

Комбинация сценариев собирается через SwitchController с весами:

  • 70% — GET /status/{id}

  • 20% — GET /status?ids=...

  • 5% — POST /status

  • 5% — PUT /status/{id}

Данные:

  • Random CSV Data Set с:

    • Одиночными status_id (для GET /{id} и PUT)

    • Пачками ids_csv (для GET ?ids=...), готовые списки через ;

HTTP-клиент:

  • Keep-alive включён

  • HttpClient4

  • Базовый тюнинг через -J

-Jhttpclient4.retrycount=1
-Jhttpclient4.validate_after_inactivity=2000
-Jhttpclient4.time_to_live=180000
-Jhttp.maxConnections=2000

Чтобы измерять «чистую» задержку запросов, вокруг каждого из них стоит Transaction Controller, агрегирующий тайминги без учёта таймеров.

Как подбирается максимум (RPS@p99≤ SLO)

Для каждой длинной полки профиля я беру фактический Throughput (Transactions/s) из отчёта JMeter и проверяю три условия одновременно:

  • p99 ≤ SLO для этой ручки/сценария

  • Error% < 1%

  • Стабильность RPS: средний Throughput за полку не «плывёт» более чем на ±5%

Полка считается валидной, если длительность окна составляет не менее десяти минут. При анализе я дополнительно отбрасываю первые две минуты (прогрев) и последние 30 секунд «полки», чтобы не ловить переходные процессы на входе и выходе нагрузки.

RPS@p99≤SLO — это фактический средний Throughput на последней валидной полке.

Scenario

SLO p99 (ms)

Service

RPS@p99≤SLO

p95 (ms)

p99 (ms)

Error %

CPU % avg

RAM MB avg

mixed

350

go-pgx

2915

~24

~35

0,24

~95—100

~70

mixed

350

ktor-jdbc

3000

~74

~114

0

~60

~60

mixed

350

spring-webflux-r2dbc

3000

~61

~83

0

~60—65

~230

mixed

350

spring-mvc-jdbc

0

~140

~411

0

~50—55

~300

Примечание: значение 0 в колонке RPS@p99≤SLO означает, что ни одна из полок текущего профиля не удовлетворила SLO по p99 при Error% &lt; 1%. Это не значит, что сервис не сможет выдержать меньший RPS, просто в рамках этого эксперимента я не подбирал отдельный профиль с более низкими полками.

Как я интерпретирую p90/p99

  • p95 — ранняя деградация под ростом RPS

  • p99 — хвост (именно его я использую для проверки SLO)

Все задержки — это полная длительность HTTP-запроса на стороне JMeter (от отправки до получения ответа). Таймеры и паузы профиля не входят в измерение, так как вокруг запросов используется Transaction Controller.

Решения принимаю по p99, p90 даёт как контекст и профиль деградации.

Порог ошибок

Критерий валидной полки:

  • Error % < 1 %

  • Минимальное количество повторов на клиенте (-Jhttpclient4.retrycount=1), чтобы не «подкрашивать» картину

Прогрев и окно анализа

Для каждой полки длиной 20 минут я анализирую только центральную часть окна:

  • Первые две минуты — это прогрев, который не учитывается в расчёте p90/p99

  • Последние 30 секунд также исключаются, чтобы не ловить переход нагрузки на следующую ступень

Это позволяет сгладить влияние кратковременных скачков на стыках ступеней профиля.

CPU и RAM (Prometheus, окно прогона)

После каждой полки я знаю start/end окна. Процессор и память считаю как среднее по этому окну, а не мгновенный пик.

Примеры выражений (в Grafana):

CPU, % ядра:

100 * process_cpu_usage{service="$service"} * 2

или

100 * rate(process_cpu_seconds_total{service="$service"}[$__rate_interval])

Память, MiB:

Для JVM-сервисов:

sum by (service)(
  jvm_memory_used_bytes{service="$service", area="heap"}
) / 1024 / 1024

Для Go:

go_memstats_alloc_bytes{service="$service"} / 1024 / 1024

В отчёт записывается CPU до десятых, RAM — до целых мегабайт.

Как запускаю прогоны

Каждый сервис гоняю отдельно, подставляя хост через -JHOST.

Логика PowerShell-обвязки:

$ErrorActionPreference = "Stop"
$outDir = ".\perf"

$targets = @(
    @{ name = "webflux-r2dbc"; service = "svc-spring-webflux-r2dbc"; host = "svc-spring-webflux-r2dbc" },
    @{ name = "go-pgx";        service = "svc-go-pgx";              host = "svc-go-pgx" },
    @{ name = "spring-mvc";    service = "svc-spring-mvc-jdbc";     host = "svc-spring-mvc-jdbc" },
    @{ name = "ktor-jdbc";     service = "svc-ktor-jdbc";           host = "svc-ktor-jdbc" }
)

docker compose stop `
  svc-spring-webflux-r2dbc `
  svc-go-pgx `
  svc-spring-mvc-jdbc `
  svc-ktor-jdbc | Out-Null

foreach ($t in $targets) {
    $name = $t.name
    $svc  = $t.service
    $targetHost = $t.host

    docker compose up -d $svc
    Start-Sleep -Seconds 20

    $reportDir = Join-Path $outDir "report-$name"
    if (Test-Path $reportDir) { Remove-Item $reportDir -Recurse -Force }

    $jtl    = Join-Path $outDir "results-$name.jtl"
    $report = $reportDir

    docker compose run --rm jmeter `
      -JHOST=$targetHost -JPORT=8080 `
      -n -t plan2.jmx `
      -l $jtl -e -o $report

    docker compose stop $svc
    Start-Sleep -Seconds 10
}

Идея заключается в следующем:

  1. Останавливаю все сервисы

  2. В каждой итерации поднимаю только один сервис

  3. Прогреваю (20 секунд)

  4. Гоняю JMeter с нужным HOST/PORT

  5. Сохраняю .jtl и HTML-отчёт в отдельные файлы/папки

  6. Гашу сервис и перехожу к следующему

Сам plan2.jmx содержит профиль нагрузки, микс ручек, датасеты и т.д., поэтому PowerShell-скрипт отвечает только за смену цели и раздельные отчёты.

Ограничения и честность эксперимента

Чтобы сравнение было максимально честным, я придерживался нескольких правил:

  • Одна база данных и одна схема. Все сервисы стучатся в один и тот же PostgreSQL 16 и одну таблицу status с одинаковыми данными.

  • Одинаковая функциональность API. Никаких оптимизаций в стиле «в одной реализации кеш в памяти, в другой — прямой запрос в базу данных». Логика конечных точек идентична по смыслу.

  • Единые лимиты по ресурсам. Каждый сервис получает ровно 2 vCPU и 1 Гб RAM. Нет поблажек для Go или наказаний для JVM.

  • Одинаковые настройки пулов подключений. Пул подключений к PostgreSQL во всех сервисах ограничен 64 штуками:

    • Spring Boot: HikariCP с maximumPoolSize = 64

    • Ktor: HikariCP, те же 64 подключения

    • Go: пул pgx на 64 активных подключения

  • Схожие настройки HTTP-слоя. Нет искусственных ограничений вроде «десять потоков у Spring и 1 тыс. воркеров у Go». Везде разумное количество рабочих потоков и горутин под заданные CPU.

  • Единая методология нагрузочного прогона. Для каждого сервиса:

    • Одинаковый набор сценариев

    • Одинаковая длительность и параметры ramp-up

    • Одинаковый способ снятия CPU/RAM через Prometheus

Эти условия не делают эксперимент идеально научным, но заметно уменьшают количество скрытых переменных и позволяют честно обсуждать, почему тот или иной стек ведёт себя именно так, как показали числа.

Результаты

Ниже сравню четыре реализации (Spring MVC + JDBC, Spring WebFlux + R2DBC, Ktor + JDBC, Go + pgx) в смешанном сценарии с двумя «рабочими» плато нагрузки: примерно 1500 RPS и около 3000 RPS (каждое держалось 20 минут).

График Java r2dbc сервиса p90/p99
График Java r2dbc сервиса p90/p99
График Java spring mvc сервиса p90/p99
График Java spring mvc сервиса p90/p99
График Ktor jdbc сервиса p90/p99
График Ktor jdbc сервиса p90/p99
График Go сервиса p90/p99
График Go сервиса p90/p99

Поведение latency при росте RPS

Go (pgx):

  • На плато ~1500 RPS сервис ведёт себя ровно: p99 держится низко и почти не «пилообразит»

  • При выходе на ~3000 RPS хвосты растут, но умеренно: p99 кратковременно подскакивает и возвращается обратно

  • CPU растёт ступенькой и дальше стоит стабильно, без полок ожидания — сама Go-часть не «задыхается», пики p99 почти наверняка приходят из базы данных/сети/пула

Ktor (JDBC):

  • До ~1500 RPS хвосты спокойные

  • На переходе к ~3000 RPS появляется регулярная деградация p99 (всплески в сотни миллисекунд), но без ухода в секунды

  • Графики похожи на «CPU-лимит с нормальной деградацией»: latency растёт предсказуемо, без катастроф

Spring WebFlux (R2DBC):

  • Пока нагрузка около 1500 RPS, p99 остаётся приемлемым

  • На ~3000 RPS p99 начинает дёргаться заметно сильнее, появляются высокие всплески

  • В отличие от Ktor, хвосты тесно коррелируют с ростом heap и «пилой» GC: значимая часть деградации идёт от выделения памяти и реактивной прослойки

Spring MVC (JDBC):

  • Самая заметная деградация

  • Уже при росте к ~1500 RPS p99 резко уходит в секунды, всплески частые и крупные, график «нервный»

  • Это типичный профиль блокирующего стека: при высокой конкуренции растёт очередь потоков, а хвосты улетают на ожидания

Где начинается резкий рост p99

  • Go: резкий рост начинается только на верхнем плато (~3000 RPS) и выражен как короткие всплески

  • Ktor: рост p99 появляется на переходе к ~3000 RPS, но остаётся в субсекундном диапазоне

  • WebFlux: p99 заметно «ломается» на верхнем плато, всплески сильнее, чем у Ktor

  • Spring MVC: p99 срывается раньше всех и резче — верхнее плато фактически превращается в режим «постоянного хвоста»

Процессор и память: во что упирается каждый сервис

Перед началом:

Go (pgx)

Процессор Gо-сервиса
Процессор Gо-сервиса

Процессор: две стабильные ступени — около 60% на меньшем плато и примерно 130—135% — на большем (из 200% возможных). Запас по CPU остаётся.

Память Go-сервиса
Память Go-сервиса

Память: низкая и стабильная (десятки мегабайт), без давления GC.

Вывод: основной лимит — не потребление процессора Go-кодом, а внешние факторы — Postgres, пул, сеть. Go тянет нагрузку наиболее «чисто».

Ktor (JDBC)

Процессор Ktor-сервиса
Процессор Ktor-сервиса

CPU: ~60—80% на ~1500 RPS и ~160—180 % на ~3000 RPS.

Память Ktor-сервиса
Память Ktor-сервиса

Память: очень низкая для JVM, порядка десятков мегабайтов heap.

Вывод: на верхнем плато сервис почти упирается в потребление процессора приложением, а не в память или GC. База данных влияет, но вторично — деградация ровная.

Spring WebFlux (R2DBC)

Процессор Spring webflux-сервиса
Процессор Spring webflux-сервиса

Процессор: нагрузка чувствительна к аллокациям, хвосты растут синхронно с поведением heap.

Память Spring webflux-сервиса
Память Spring webflux-сервиса

Память: heap растёт от десятков до нескольких сотен мегабайтов, выраженная «пила» и большие сбросы GC.

Вывод: верхнее плато упирается в память/GC и CPU реактивного стека. Это не чистый DB-лимит — заметная доля затрат в приложении.

Spring MVC (JDBC)

Процессор MVC-сервиса
Процессор MVC-сервиса

CPU: под нагрузкой выходит на ~130—150% и держится.

Память Spring MVC-сервиса
Память Spring MVC-сервиса

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

Задержка: p99 уходит в секунды.

Вывод: картина блокировок и ожиданий пула базы данных/потоков. Потребление процессора не упирается в потолок, значит основная «стена» — PostgreSQL/connection pool + блокирующий стек.

Ключевые выводы по результатам

  • Latency быстрее всего растёт у Spring MVC + JDBC. При росте RPS хвосты «ломаются» раньше и сильнее остальных, p99 стабильно уходит в секунды на верхнем плато.

  • WebFlux и Ktor близки по p90, но расходятся по p99. На верхнем плато WebFlux чаще попадает в зоны GC/heap-всплесков, из-за чего хвосты заметнее, чем у Ktor.

  • Go даёт лучший профиль по ресурсам и стабильности хвоста. Самое низкое потребление процессора при той же нагрузке и минимальное потребление памяти без заметного GC-давления. Но даже в Go хвосты на верхнем плато упираются в PostgreSQL, а не в язык (гипотеза).

  • Ktor — самый «экономный JVM-вариант». Heap стабильно маленькая, GC не мешает, деградация на верхнем плато предсказуемая. Основное ограничение — потребление процессора приложением.

  • В смешанном сценарии «потолок» чаще общий — база данных. На верхних RPS даже самые быстрые рантаймы (Go/Ktor) дают всплески p99, что указывает на внешний лимит (Postgres/пулы/I/O) (гипотеза).

Разбор причин и интерпретация

Здесь я не стану добавлять новые измерения, а аккуратно интерпретирую уже полученные числа. Важно разделять:

  • Факты числа из отчётов JMeter (p50/p90/p99, Throughput, Error%) и усреднённые за окно полки метрики CPU/RAM из Prometheus

  • Гипотезы интерпретации вроде «упёрлись в баз данных/пул/GC», основанные на косвенных признаках.

Отдельное глубокое профилирование PostgreSQL в этой статье отсутствует, поэтому любые выводы о причинах деградации я помечаю как гипотезы.

Что можно считать фактами

По результатам прогонов видно:

  • При одинаковой схеме базы данных и одной и той же нагрузке профили p90/p99 у стеков сильно различаются

  • Spring MVC начинает резко деградировать по p99 раньше остальных

  • У WebFlux заметно более «шумный» профиль heap и выраженная «пила» GC на высоких RPS

  • У Ktor heap маленькая и стабильная, деградация p99 больше похожа на упор в CPU

  • У Go потребление процессора и памяти растут ступенчато и довольно предсказуемо, хвосты появляются ближе к верхней границе RPS

Всё это видно по графикам latency, CPU и RAM на полках.

Гипотезы: накладные расходы и модель конкурентности

Здесь приведу аккуратные гипотезы, почему профили выглядят именно так.

Spring MVC + JDBC:

  • Блокирующая модель «поток на запрос» в сочетании с ограниченным пулом соединений к базе данных приводит к очередям:

    • Если потоков больше, чем соединений, то часть запросов ждёт свободное подключение

    • При высоком RPS очереди растут, а p99 улетает

  • Дополнительные накладные расходы JVM и фреймворка (аллоцируемые объекты, фильтры, обработка исключений) усиливают эффект, когда система уже близка к насыщению

Spring WebFlux + R2DBC:

  • Реактивный стек позволяет обслуживать больше одновременных запросов при том же количестве потоков

  • Ценой этого становятся:

    • Более сложный пайплайн (операторы Reactor, цепочки сигналов)

    • Дополнительные аллокации для реактивных структур

    • Чувствительность к любым блокирующим точкам (если они случайно попали в код)

  • На верхнем плато это проявляется в виде роста heap и более агрессивной работы GC, а значит — всплесков p99

Ktor + JDBC:

  • HTTP-слой на корутинах отделён от блокирующей базы данных:

    • Запросы парсятся и маршрутизируются неблокирующим образом

    • Сами обращения к базе данных выполняются в отдельном пуле потоков

  • При правильно подобранных размерах пулов такая схема даёт:

    • Низкую и стабильную heap

    • Более плавный рост p99, пока CPU и база данных не выходят на насыщение

  • В итоге профиль выглядит так: «упираемся в вычисления и базу данных, а не в GC и инфраструктурный оверхед» (гипотеза)

Go + pgx:

  • Горутины дешевле потоков JVM, переключения между ними легче

  • Модель программирования остаётся простой: линейный код с if/for, блокирующий вызов в базу данных, никаких реактивных цепочек

  • Пока пул соединений к базе данных справляется, накладные расходы рантайма низкие — это видно по стабильному потреблению процессора и памяти

Гипотеза: именно из-за низких накладных расходов язык/рантайм отходит на второй план, а доминирующим ограничением становится сама база данных.

GC, аллокации и переключения контекста

С точки зрения профиля:

  • Там, где heap растёт ступеньками и регулярно «сбрасывается» (WebFlux, MVC), p99 больше страдает от GC и аллокаций

  • Там, где heap почти не меняется (Ktor, Go), деградация p99 больше похожа на упор в CPU/базу данных и очереди запросов (гипотеза)

При этом:

  • Попытка «просто добавить потоки» в блокирующих стеках быстро приводит к тому, что мы тратим время на переключения контекста и ожидание пула, а не на реальные операции

  • В реактивном стеке добавление конкуренции без контроля пула R2DBC и таймаутов может привести к хаотичным хвостам p99

  • В Go увеличение количества горутин без учёта лимитов пула соединений и настроек Postgres даёт ту же картину очередей, только в другом месте

Ограничения эксперимента

Важно понимать, что эксперимент ограничен конкретным стендом:

  • Нет экстремального тюнинга GC, TCP-стека, Postgres

  • Все сервисы живут в Docker с одинаковыми лимитами CPU/RAM

  • Используется одна конкретная схема базы данных и один профиль нагрузки

  • Сервисы прогоняются последовательно, а не при одновременной конкуренции за базу данных

Отдельно про повторяемость. В этой статье я опираюсь на один прогон на каждый стек с достаточно длинными полками. На практике при повторных прогонах возможен разброс результатов по RPS и p99 в пределах нескольких процентов (гипотеза, основанная на типовом поведении подобных стендов). Если нужен строго статистический вывод, то стоит прогнать хотя бы один из сервисов два—три раза и оценить разброс метрик.

Поэтому результаты нельзя переносить один в один на любые другие системы, но они показывают адекватную картину поведения разных подходов при равных условиях.

Выводы и рекомендации

Здесь описаны аккуратные выводы по итогам замеров всех четырёх реализаций при одинаковых лимитах в Docker и поиске максимального RPS при соблюдении p99-SLO. Я опираюсь на сводные таблицы и длительные полки в JMeter-сценарии.

Где Go (pgx) реально оправдывает ожидания

  • Низкие базовые накладные расходы: на «здоровых» уровнях нагрузки Go даёт лучшие p90 и очень хороший p99 за счёт дешёвых горутин и лёгкого рантайма

  • Экономия ресурсов: как правило, минимальное потребление памяти и умеренное потребление процессора при том же трафике

  • Предсказуемость до насыщения базы данных: пока коэффициент загрузки пула/базы данных далёк от единицы, профиль латентности у Go самый «чистый»

При агрессивном росте RPS (когда очередь к базе данных уже формируется) p99 у Go может «ломаться» резко. Это не «недостаток Go», а следствие теории очередей: как только пул или база данных стала узким местом, хвост растёт гиперболически у любого стека, просто в Go этот переход видно контрастнее.

Где реактивный стек (Spring WebFlux + R2DBC) даёт преимущество или почти не нужен

Где даёт преимущество:

  • I/O-bound сценарии с большим количеством одновременных «висящих» запросов

  • Ситуации, где важна низкая p90 при умеренной конкуренции и аккуратном back-pressure

  • Когда нужно масштабировать именно количество одновременных операций, а не потоков

Где почти не нужен:

  • Чисто сценарии CPU/DB-bound с короткими транзакциями, когда задержка доминирует над ожиданием базы данных

  • Команды без опыта Reactor/R2DBC — стоимость владения стеком может перевесить выигрыш

Риск: при приближении к насыщению пула p99 резко растёт из-за очередей внутри r2dbc-pool и конкуренции в event loop. Нужен аккуратный тюнинг размеров пула, таймаутов acquire/validation и мониторинг back-pressure.

Где обычный Spring MVC + JDBC более чем достаточен

  • Простая, понятная модель: поток-на-запрос + Hikari — предсказуемое поведение, зрелая экосистема

  • DB-bound сервисы: если основное время тратится в базе данных, то MVC ничем не хуже: ключевое — согласовать max-threads и размер пула соединений

  • Команды с опытом JVM: быстрее выйти в эксплуатацию и держать SLO без усложнения реактивностью

Риск: завышенный max-threads при маленьком пуле базы данных → лишние переключения контекста и очереди на Hikari → скачок p99. Здесь дисциплина настройки важнее выбора фреймворка.

Репозиторий и воспроизводимость

Ссылки на репозиторий

Код сервисов:

  • Go + pgx: services/go-pgx/

  • Spring MVC + JDBC: services/spring-mvc-jdbc/

  • Spring WebFlux + R2DBC: services/spring-webflux-r2dbc/

  • Ktor + JDBC: services/ktor-jdbc/

Инфраструктура:

  • docker-compose.yml — Postgres, сервисы, (опционально) Prometheus/Grafana, JMeter

  • perf/Dockerfile.jmeter — образ JMeter с плагинами

Нагрузка (JMeter):

  • Тест-план: perf/plan2.jmx

  • Датасеты (эти файлы нужно сгенерировать самому на основе данных, которые сгенерировал PostgresSQL при инициализации):

    • perf/csv_data_set/status_ids.csv

    • perf/csv_data_set/status_ids_batches_10.csv

Скрипты запуска/автоматизации. Запускаем все сервисы: bench-all.ps (если используете).

Репозиторий: https://github.com/Kozhanov-V/rps-slo-postgres-benchmarks

Требования

  • Docker или Docker Desktop с docker compose

  • Свободный порт 8080 на хосте (или правки в docker-compose.yml)

  • Опционально: Prometheus + Grafana, если хотите сверять CPU, RAM и метрики

Быстрый запуск (TL; DR)

# 1) Клонируем
git clone <REPO_URL>
cd <repo-root>

# 2) Собираем образ JMeter (внутри плагины: jpgc-casutg, jpgc-tst, bzm-random-csv)
docker compose build jmeter

# 3) Поднимаем БД (и, при желании, мониторинг)
docker compose up -d postgres   # + prometheus grafana (если есть в compose)

# 4) Запускаем один сервис (пример: WebFlux)
docker compose up -d svc-spring-webflux-r2dbc
sleep 20   # даем сервису прогреться

# 5) Гоним JMeter на этот сервис
docker compose run --rm jmeter \
  -JHOST=svc-spring-webflux-r2dbc -JPORT=8080 \
  -n -t plan2.jmx \
  -l results-webflux-r2dbc.jtl -e -o report-webflux-r2dbc

# 6) Смотрим отчет JMeter
# откройте perf/report-webflux-r2dbc/index.html в браузере

Повторяем шаги с четвёртого по шестой для остальных сервисов, меняя -JHOST:

# Go + pgx
docker compose up -d svc-go-pgx && sleep 20
docker compose run --rm jmeter \
  -JHOST=svc-go-pgx -JPORT=8080 \
  -n -t plan2.jmx \
  -l results-go-pgx.jtl -e -o report-go-pgx
docker compose stop svc-go-pgx

# Spring MVC + JDBC
docker compose up -d svc-spring-mvc-jdbc && sleep 20
docker compose run --rm jmeter \
  -JHOST=svc-spring-mvc-jdbc -JPORT=8080 \
  -n -t plan2.jmx \
  -l results-spring-mvc.jtl -e -o report-spring-mvc
docker compose stop svc-spring-mvc-jdbc

# Ktor + JDBC
docker compose up -d svc-ktor-jdbc && sleep 20
docker compose run --rm jmeter \
  -JHOST=svc-ktor-jdbc -JPORT=8080 \
  -n -t plan2.jmx \
  -l results-ktor-jdbc.jtl -e -o report-ktor-jdbc
docker compose stop svc-ktor-jdbc

Чтобы на запускать каждый скрипт по очереди, можно запустить скрипт из корня папки:

powershell -ExecutionPolicy Bypass -File .\bench-all.ps1

Где искать результаты:

  • JTL: perf/results-*.jtl

  • HTML-отчёты: perf/report-*/index.html

Как устроен тест-план JMeter

  • План: perf/plan2.jmx

  • Генератор нагрузки: jp@gc Ultimate Thread Group + Variable Throughput Timer — профиль: плавный разгон до полки, удержание полки, затем следующий разгон

  • Распределение трафика (SwitchController):

    • GetById — 70%

    • GetByIds — 20%

    • CreateStatus — 5%

    • UpdateStatus — 5%

  • Датасеты: status_ids.csv и status_ids_batches_10.csv (случайная выборка, бесконечная прокрутка)

Изменить нагрузочный профиль можно:

  • В Variable Throughput Timer (start_rps, end_rps, duration_sec)

  • Количество потоков — в Ultimate Thread Group

  • Вес сценариев — в выражении Groovy внутри SwitchController

Параметры честности и сравнимости

Для всех сервисов стоит выровнять пулы соединений к базе данных:

  • Spring MVC (Hikari): maximumPoolSize

  • WebFlux (r2dbc-pool): maxSize в spring.r2dbc.url

  • Go (pgx pool): DB_MAX_CONNS, DB_MIN_CONNS

  • Ktor/Hikari: maximumPoolSize и аналоги

На уровне HTTP-стека:

  • MVC (Tomcat) — max-threads согласовать с пулом базы данных

  • WebFlux — ограничить конкуренцию через размеры пула R2DBC и таймауты acquire

  • Go — синхронизировать лимиты горутин и пула соединений с возможностями Postgres

Рабочую нагрузку разумно считать на ~70% от найденного потолка по p99-SLO.

Метрики в Prometheus и Grafana (опционально)

Если в docker-compose.yml добавлены Prometheus и Grafana, то можно смотреть средние CPU/RAM за полку. Примеры:

CPU, % для JVM-сервисов:

100 * process_cpu_usage{service="$service"} * 2

Умножаем на два, так как выделили два процессора.

CPU, % для Go сервиса

100 * process_cpu_usage{service="$service"} * 2

Тут уже нет необходимости умножать.

Память, MiB (JVM heap):

sum by (service)(
  jvm_memory_used_bytes{service="$service", area="heap"}
) / 1024 / 1024

Память, MiB (Go Alloc):

go_memstats_alloc_bytes{service="$service"} / 1024 / 1024

Подставьте имя сервиса в $service (например, svc-go-pgx).

Полная репликация эксперимента (шаги)

  1. Клонировать репозиторий и собрать образы: git clone <REPO_URL> && cd <repo> && docker compose build.

  2. Поднять Postgres: docker compose up -d postgres.

  3. Создать файл csv_data_set/status_ids_batches_10.csv в формате:
    "id1, id2, ..., id10
    id11,.. id20 и тд"

  4. Создать файл csv_data_set/status_ids.csv в формате:
    "id1,
    id2,
    ...
    idn"

  5. Выровнять параметры пулов базы данных во всех сервисах (env или YAML/HOCON).

  6. Прогреть Postgres (опционально) коротким «smoke»-тестом JMeter.

  7. Пройтись по всем сервисам: поднять сервис → подождать 15—30 сек → прогнать JMeter → остановить сервис.

  8. Собрать результаты:

    Смотреть perf/report-*/index.html.

    При необходимости — Grafana-дашборд за окна полок.

  9. Построить сводную таблицу p90/p99, ошибок, CPU/RAM, RPS@p99≤SLO — и сравнить стеки так же, как в статье.