Один сервис — четыре стека: практический бенчмарк с SLO по p99 и Docker/JMeter
- пятница, 19 декабря 2025 г. в 00:00:07
В этой статье представлено сравнение четырёх реализаций одного и того же сервиса поверх PostgreSQL:
Spring MVC + JDBC
Spring WebFlux + R2DBC
Ktor + JDBC
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, | 8080 | pgx (native), | 2CPU/2gb |
spring‑mvc | Java 17, Spring Boot MVC (Servlet/Tomcat, blocking IO) | 8080 | PostgreSQL JDBC ( | 2CPU/2gb |
webflux | Java 17, Spring WebFlux (reactive, Netty) | 8080 | R2DBC PostgreSQL ( | 2CPU/2gb |
ktor-jdbc | Kotlin, Ktor (CIO/Netty), blocking JDBC | 8080 | PostgreSQL JDBC + HikariCP | 2CPU/2gb |
Фрагмент 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: 1048576Prometheus и 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 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.
Наша задача — найти максимальный устойчивый RPS при выполнении SLO по p99 для каждого сценария и каждого сервиса. Ниже показано, как я это делаю на 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, агрегирующий тайминги без учёта таймеров.
Для каждой длинной полки профиля я беру фактический 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% < 1%. Это не значит, что сервис не сможет выдержать меньший RPS, просто в рамках этого эксперимента я не подбирал отдельный профиль с более низкими полками.
p95 — ранняя деградация под ростом RPS
p99 — хвост (именно его я использую для проверки SLO)
Все задержки — это полная длительность HTTP-запроса на стороне JMeter (от отправки до получения ответа). Таймеры и паузы профиля не входят в измерение, так как вокруг запросов используется Transaction Controller.
Решения принимаю по p99, p90 даёт как контекст и профиль деградации.
Критерий валидной полки:
Error % < 1 %
Минимальное количество повторов на клиенте (-Jhttpclient4.retrycount=1), чтобы не «подкрашивать» картину
Для каждой полки длиной 20 минут я анализирую только центральную часть окна:
Первые две минуты — это прогрев, который не учитывается в расчёте p90/p99
Последние 30 секунд также исключаются, чтобы не ловить переход нагрузки на следующую ступень
Это позволяет сгладить влияние кратковременных скачков на стыках ступеней профиля.
После каждой полки я знаю 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
}Идея заключается в следующем:
Останавливаю все сервисы
В каждой итерации поднимаю только один сервис
Прогреваю (20 секунд)
Гоняю JMeter с нужным HOST/PORT
Сохраняю .jtl и HTML-отчёт в отдельные файлы/папки
Гашу сервис и перехожу к следующему
Сам 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 минут).




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 резко уходит в секунды, всплески частые и крупные, график «нервный»
Это типичный профиль блокирующего стека: при высокой конкуренции растёт очередь потоков, а хвосты улетают на ожидания
Go: резкий рост начинается только на верхнем плато (~3000 RPS) и выражен как короткие всплески
Ktor: рост p99 появляется на переходе к ~3000 RPS, но остаётся в субсекундном диапазоне
WebFlux: p99 заметно «ломается» на верхнем плато, всплески сильнее, чем у Ktor
Spring MVC: p99 срывается раньше всех и резче — верхнее плато фактически превращается в режим «постоянного хвоста»
Перед началом:
Go (pgx)

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

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

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

Память: очень низкая для JVM, порядка десятков мегабайтов heap.
Вывод: на верхнем плато сервис почти упирается в потребление процессора приложением, а не в память или GC. База данных влияет, но вторично — деградация ровная.
Spring WebFlux (R2DBC)

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

Память: heap растёт от десятков до нескольких сотен мегабайтов, выраженная «пила» и большие сбросы GC.
Вывод: верхнее плато упирается в память/GC и CPU реактивного стека. Это не чистый DB-лимит — заметная доля затрат в приложении.
Spring MVC (JDBC)

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

Память: высокая и очень шумная — от сотен почти до полугигабайта, сильная аллокационная нагрузка.
Задержка: 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, блокирующий вызов в базу данных, никаких реактивных цепочек
Пока пул соединений к базе данных справляется, накладные расходы рантайма низкие — это видно по стабильному потреблению процессора и памяти
Гипотеза: именно из-за низких накладных расходов язык/рантайм отходит на второй план, а доминирующим ограничением становится сама база данных.
С точки зрения профиля:
Там, где 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 даёт лучшие p90 и очень хороший p99 за счёт дешёвых горутин и лёгкого рантайма
Экономия ресурсов: как правило, минимальное потребление памяти и умеренное потребление процессора при том же трафике
Предсказуемость до насыщения базы данных: пока коэффициент загрузки пула/базы данных далёк от единицы, профиль латентности у Go самый «чистый»
При агрессивном росте RPS (когда очередь к базе данных уже формируется) p99 у Go может «ломаться» резко. Это не «недостаток Go», а следствие теории очередей: как только пул или база данных стала узким местом, хвост растёт гиперболически у любого стека, просто в Go этот переход видно контрастнее.
Где даёт преимущество:
I/O-bound сценарии с большим количеством одновременных «висящих» запросов
Ситуации, где важна низкая p90 при умеренной конкуренции и аккуратном back-pressure
Когда нужно масштабировать именно количество одновременных операций, а не потоков
Где почти не нужен:
Чисто сценарии CPU/DB-bound с короткими транзакциями, когда задержка доминирует над ожиданием базы данных
Команды без опыта Reactor/R2DBC — стоимость владения стеком может перевесить выигрыш
Риск: при приближении к насыщению пула p99 резко растёт из-за очередей внутри r2dbc-pool и конкуренции в event loop. Нужен аккуратный тюнинг размеров пула, таймаутов acquire/validation и мониторинг back-pressure.
Простая, понятная модель: поток-на-запрос + 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 и метрики
# 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
План: 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.
Если в 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).
Клонировать репозиторий и собрать образы: git clone <REPO_URL> && cd <repo> && docker compose build.
Поднять Postgres: docker compose up -d postgres.
Создать файл csv_data_set/status_ids_batches_10.csv в формате:
"id1, id2, ..., id10
id11,.. id20 и тд"
Создать файл csv_data_set/status_ids.csv в формате:
"id1,
id2,
...
idn"
Выровнять параметры пулов базы данных во всех сервисах (env или YAML/HOCON).
Прогреть Postgres (опционально) коротким «smoke»-тестом JMeter.
Пройтись по всем сервисам: поднять сервис → подождать 15—30 сек → прогнать JMeter → остановить сервис.
Собрать результаты:
Смотреть perf/report-*/index.html.
При необходимости — Grafana-дашборд за окна полок.
Построить сводную таблицу p90/p99, ошибок, CPU/RAM, RPS@p99≤SLO — и сравнить стеки так же, как в статье.