Skip to content

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

where-postgres-ends

Один и тот же сервис собран дважды: со специализированным инструментом (RabbitMQ, Kafka, MongoDB) и на чистом Postgres. Нагрузочные тесты сравнивают оба варианта, а для Redis код решили не писать вовсе, здесь только разбор чужих цифр и production-опыта. Тезис «замени всё на Postgres» проверен руками. Для каждой пары в тексте написано, что она приносит и во что обходится.

«Просто возьми Postgres вместо X» звучит из каждого второго доклада. Очереди на SKIP LOCKED, CDC вместо Kafka, jsonb вместо MongoDB. Меньше систем и правда означает меньше боли в эксплуатации, довод понятный. Про границы применимости доклады обычно молчат, хотя именно они решают, сработает ли совет в конкретном проекте. Поэтому каждая замена здесь получила рабочий код: пара модулей с одним REST-контрактом, прогнанная под нагрузкой в Gatling, и цифры, сверенные с чужими бенчмарками и production-постмортемами.

Карта проекта

Пара Модули (порты) Механика замены Статус
MongoDB vs jsonb mongodb-demo (8085) / no-mongodb-demo (8086) jsonb + GIN (jsonb_path_ops), partial-патчи через ||-merge код + цифры
RabbitMQ vs очередь в таблице rabbitmq-demo (8081) / no-rabbitmq-demo (8082) FOR UPDATE SKIP LOCKED + reconnect-safe LISTEN/NOTIFY код + цифры
Kafka vs логическая репликация kafka-demo (8083) / no-kafka-demo (8084) logical replication (wal2json): WAL и есть транспорт код + цифры
Redis vs Postgres нет модулей UNLOGGED-кэш / LISTEN/NOTIFY / advisory locks, не реализовано вердикт без кода

Методология тестов описана в load-tests/.

Быстрый старт

docker compose up -d        # Postgres (с wal2json), RabbitMQ, Kafka, MongoDB, Redis
gw :<module>:bootRun        # любой модуль из карты выше

Раздел «Как воспроизвести» в README нужной пары внутри load-tests/ описывает полный прогон нагрузочного сравнения.

Вердикты

MongoDB vs Postgres (jsonb)

Замена работает. Для документов до сотен килобайт Postgres не уступает Mongo. В поиске он её обгоняет. Риск сосредоточен в одном месте: в частой перезаписи больших документов, из-за TOAST. Цифры лежат в RESULTS.md, методология в README.md.

Фиксированные поля и гибкий профиль пользователя лежат в одной строке. Join'ы и ACID работают и для документной части, синхронизировать две базы друг с другом больше не нужно. Containment-поиск по JSON у Postgres быстрее: p95 33 мс против 48 у Mongo, и индексы честные с обеих сторон, GIN против wildcard.

Слабое место тоже находится быстро. Перезапись 512KB-значения обходится Postgres в 3.6–4.7 раза дороже патча соседней колонки. У Mongo тот же переход стоит 1.9–2.4 раза, а на 2MB p99 у Postgres хуже уже в 2.6 раза. Каждая такая мутация оставляет за собой WAL, мёртвые TOAST-чанки и работу autovacuum'у. Шардирование и гибкая индексация из коробки остаются у Mongo. Postgres однонодовый, дальше можно перейти на Citus. GIN (jsonb_path_ops) покрывает только containment. Прямого аналога aggregation pipeline, change streams и TTL здесь нет.

Граница: частично-гибкая схема с join'ами, ACID и документами до сотен килобайт, включая containment-поиск, подходит для профилей пользователей, карточек товаров с произвольными атрибутами, настроек. Редактор с автосохранением, растущие логи внутри JSON, sharded-write нагрузка уровня Mongo: тут нужно отдельное решение.

RabbitMQ vs Postgres (SKIP LOCKED)

Это сравнение касается очереди задач: сообщение достаётся ровно одному консьюмеру. Шина с fan-out и сложным routing сюда не входит, брокеру в этой роли всерьёз нечего противопоставить.

Замена работает на умеренной нагрузке. Суммарная латентность send→processed у обеих реализаций одинаковая. RabbitMQ выигрывает только доставку ждущему консьюмеру, и на то есть архитектурная причина: брокер сам приносит сообщение через push, Postgres-воркеру приходится проснуться по NOTIFY и сходить за сообщением отдельным запросом. Цифры собраны в RESULTS.md, методология в README.md.

Transactional outbox здесь достаётся бесплатно. Событие и состояние коммитятся одной транзакцией, а брокеру такое недоступно ни в какой конфигурации: между ним и базой всегда остаётся dual-write gap. Именно из-за этого Prequel мигрировали с RabbitMQ на Postgres за полдня работы и минус 580 строк кода. Одна система вместо двух, и паттерн давно не экзотика: Rails 8 сделал очередь в базе на SKIP LOCKED дефолтной (Solid Queue), а в других экосистемах то же самое делают Oban, River, GoodJob, pgmq. Латентность при этом не теряется: mean 3 мс, p99 9–10 мс у обеих реализаций, ноль ошибок на примерно ста тысячах запросов, запись держит паритет на всех перцентилях.

Доставка консьюмеру у Postgres медленнее на каждом перцентиле, и с ростом нагрузки разрыв не сужается. То же самое видно на чужих замерах при 5K msg/s: около 2.3 раза по латентности при равном throughput. Потолок комфортной работы лежит примерно на 5K msg/s, дальше начинается инженерия: батчинг, партиционирование. Обрыв случается уже в десятках тысяч msg/s. Есть и отдельная мина: NOTIFY в каждой транзакции сериализует коммиты глобальным локом, это разобрано в постмортеме Recall.ai, фикс появится только в PostgreSQL 19.

LISTEN в эксплуатации капризен. pgbouncer в transaction-режиме ломает его тихо: сама команда LISTEN отработает без ошибки, но подписка осядет на соединении, которое пулер тут же отдаст другому клиенту, при этом NOTIFY продолжит работать как ни в чём не бывало. Нужен session-режим или прямой коннект. Слушать можно только мастер, потому что NOTIFY живёт в памяти конкретного инстанса и через репликацию не проходит, так что при failover LISTEN-соединение приходится переносить руками.

С гарантиями сложнее: все цифры выше получены на «быстрых» конфигурациях обеих сторон, и у каждой стороны есть своя слабина. RabbitMQ подтверждает публикацию до fsync, поэтому при жёстком падении узла подтверждённое сообщение способно пропасть. Таблицы очереди у Postgres здесь unlogged: они не пишутся в WAL, очищаются при crash recovery, не видны на репликах и не попадают в бэкапы. «Надёжные» варианты прогнаны отдельно, цена надёжности расписана подробно. Quorum Queue у RabbitMQ разницы не показал, но только потому, что кластер однонодовый: Raft-кворум из одного узла сводится к тому же локальному fsync, который classic-очередь и так платит. Когда таблицы очереди перевели в logged, p99 полного пути send→processed вырос с 4 до 11 мс, втрое. Служебные записи поллера, claim строки и отметка о завершении раньше были бесплатными, теперь каждая ждёт fsync.

Граница: очередь задач с транзакционностью к бизнес-данным на умеренной нагрузке, это фоновые джобы веб-приложения: письма, экспорты, вебхуки, обработка платежа вместе с его записью. Fan-out или routing, насыщение, потолок throughput, надёжность уровня Quorum без самодельного outbox: тут нужен RabbitMQ.

Kafka vs Postgres (logical replication)

Заменяемый механизм: журнал событий. Продюсер дописывает в топик, консьюмер читает его со своей позицией, offset'ом. У Postgres роль журнала играет сам WAL: no-kafka-demo делает один insert в messages, wal2json декодирует этот insert из WAL в событие, а replication slot хранит позицию консьюмера, confirmed_flush_lsn, на стороне сервера. Ни топика, ни таблицы-очереди, ни второй системы здесь нет.

На сотнях msg/s латентность доставки у обеих сторон одинаковая, а запись у Kafka обходится дороже из-за dual-write. Дальше выбор решают семантика потребления и потолок производительности, и здесь запас у Kafka. Цифры в RESULTS.md, методология в README.md.

Событие и состояние здесь одна запись. У kafka-demo каждый POST означает insert в Postgres плюс блокирующую публикацию в топик: p99 16 мс против 5–7, два независимых шага и dual-write gap между ними. У no-kafka-demo транспортом служит WAL уже сделанного insert'а, transactional outbox со своей таблицей-очередью здесь просто не нужен. Доставка при этом идентична: wait-for-completion держит p95 не выше 2 мс с обеих сторон на 100, 150 и 200 msg/s. Обе стороны событийные, поэтому разрыва push против poll, как в RabbitMQ-паре, здесь попросту нет.

Семантику потребления Kafka теряем целиком: независимые consumer-группы с собственным offset'ом по одному топику, партиционирование для параллельного чтения, retention и replay истории, экосистема Kafka Streams и Connect. У Postgres каждый читатель работает через отдельный replication slot, и отстающий слот раздувает WAL для всех остальных. Слот отдаёт только то, что случилось после его создания, реплея истории тут нет.

Запас по потолку тоже теряем. Один партишен Kafka принимает десятки мегабайт в секунду «слепого» append'а байтов, логическое декодирование одного слота ограничено 5–15 тысячами событий в секунду. Наблюдаемый предел одного task Debezium составляет около 7 тысяч, это построчный разбор WAL с сериализацией, и чужие цифры с этим согласуются. На сотнях msg/s разница не видна. За тысячами событий в секунду декодирование упрётся первым.

Слот требует эксплуатации, которую Kafka даёт бесплатно из коробки. Отстающий или мёртвый консьюмер копит WAL без ограничения, max_slot_wal_keep_size по умолчанию равен -1. Поставить лимит значит согласиться, что отставший слот со временем инвалидируется (wal_removed), и поток придётся пересоздавать заново. Мониторинг и alerting на состояния слотов ложится на приложение или DBA, у Kafka retention и очистка лога штатные.

synchronous_commit=off доставку ломает. walsender отдаёт консьюмеру только тот WAL, что уже сброшен на диск, поэтому ускорение записи оборачивается доставкой с p50 около 160 мс: сброс идёт по расписанию WAL-writer'а, а не по факту коммита. Пока меряется только запись, этот эффект не виден вовсе.

Граница: на нагрузке до тысяч событий в секунду и с одним консьюмером на поток no-kafka-demo не проигрывает по латентности и экономит систему в эксплуатации, это фоновые события уровня одного сервиса, аудит-лог, локальный CDC. Партиционирование, несколько независимых consumer-групп на один поток, replay, экосистема Kafka Streams и Connect, ожидаемый рост на порядки: тут нужна Kafka.

Redis vs Postgres

Единственная пара без собственного модуля: вердикт понятен и без своего стенда, поэтому здесь только чужие цифры и production-опыт. Заменяемый механизм: in-memory key-value по сети, GET/SET по ключу с TTL и eviction, плюс pub/sub, структуры данных, распределённые локи. У Postgres формально есть аналог каждой роли (UNLOGGED-таблица, LISTEN/NOTIFY, advisory locks), но главного свойства не хватает, данных, которые живут в памяти. Даже unlogged-таблица пишется на диск, экономя лишь запись в WAL, и конкурирует за shared_buffers со страницами рабочих таблиц.

Замены здесь нет. Postgres медленнее Redis на каждом типе операций и дороже по CPU. На умеренной нагрузке сетевой кэш вообще часто оказывается лишним: его бьёт кэш в памяти самого приложения, а источником инвалидации остаётся Postgres.

По скорости Postgres проигрывает везде. Lookup по ключу показывает p50 около 0.1 мс у Redis, у Postgres около 0.65, это подтверждает сравнение pgbench и redis-benchmark. В замере за общим HTTP-сервером Redis впереди на чтении, записи и смешанном профиле, при этом тратит около двух третей ядра там, где Postgres выедает оба выделенных. UNLOGGED ускоряет только запись, примерно втрое против logged по данным Crunchy Data, чтение при этом не меняется.

TTL, eviction и структуры данных здесь самодельные. Вместо EXPIRE приходится держать колонку expires_at и чистить её через pg_cron, а sorted sets, битмапы и rate-limiter'ы штатной замены вообще не имеют. UNLOGGED-кэш к тому же живёт только на мастере: в WAL не пишется, на реплики не попадает, очищается при crash recovery.

Крупные значения ситуацию только ухудшают. Чем больше значение на ключ, тем хуже Postgres себя чувствует, тот же TOAST-хвост уже измерен в MongoDB-паре на документах 512KB–2MB.

Сильный ход здесь: кэш в памяти самого приложения. Микросекундное преимущество Redis всё равно съедает сетевой round-trip, это показывает CYBERTEC. In-process кэш быстрее любого сетевого и заодно защищает от cache stampede: параллельные промахи по одному ключу считаются один раз, Redis же о совпадении запросов ничего не знает. Главную проблему такого кэша, вопрос «когда сбрасывать», решает сам Postgres, сигналом через LISTEN/NOTIFY или логическую репликацию, причём вторая ловит и массовый UPDATE ... WHERE без ручного pg_notify в каждом пишущем месте.

Production-опыт очерчивает границу с обеих сторон. Миграция с Redis на Postgres-кэш на нагрузке около тысячи чтений в день подняла тёплое чтение с 3 до 5 мс, разница не видна на фоне 130-миллисекундного ответа, зато пропала целая система вместе с её обрывами соединений. Обратный маршрут уже встречался в RabbitMQ-паре: Recall.ai ушли с LISTEN/NOTIFY на Redis pub/sub, когда NOTIFY начал сериализовать коммиты.

Граница: read-mostly справочники и кэш результатов дорогих запросов на умеренной нагрузке остаются за Postgres, память приложения с инвалидацией из него самого, а если кэш должен быть общим для инстансов и переживать деплой, подойдёт UNLOGGED-таблица. Sub-ms SLA, интенсивная запись по произвольным ключам, значения в мегабайты, TTL, eviction и структуры данных из коробки, pub/sub на десятки тысяч событий в секунду: тут нужен Redis или его открытый форк Valkey.

Рамка решения: Postgres или отдельный сервис

Из всех пар складываются пять вопросов, по порядку важности. Ответ «да» на каждый означает, что Postgres справится. Первое «нет» называет инструмент, который нужен вместо него.

  1. Семантики Postgres хватает? Его механизмы узкие по замыслу: SKIP LOCKED отдаёт сообщение ровно одному консьюмеру, replication slot держит одного читателя без replay истории, LISTEN/NOTIFY не хранит уведомления для отключившихся, GIN (jsonb_path_ops) покрывает только containment-поиск. Если нужен fan-out (событие «заказ оформлен» уходит письмам, складу и аналитике, каждому своя копия), сложный routing, независимые consumer-группы, replay или межсервисный обмен без общей базы, ответ «нет» при любой нагрузке.
  2. Какая нагрузка сейчас и через два года? Очередь держит до ~5K msg/s комфортно без тюнинга, десятки тысяч уже требуют инженерии (DBOS дожали до 30K). Поток событий укладывается в 5–15K событий в секунду на слот логического декодирования. Документы годятся до сотен килобайт, без частых перезаписей больших значений. Кликстрим, IoT-телеметрия и sharded-write уровня Mongo сюда не помещаются.
  3. Маленькая ли ops-команда? При инфраструктуре на двух-трёх человек моноинструмент экономит огромное количество времени. При команде за десять человек находится кому держать специализированный стек, и преимущество размывается.
  4. Транзакционная целостность важнее задержки? Событие и состояние одной транзакцией получаются только внутри одной системы, это умеет Postgres и не умеет ни один брокер. Если критична именно sub-ms задержка, нужен Redis.
  5. Postgres уже есть в стеке? Тогда новый use case обходится почти бесплатно. Поднимать Postgres ради одной-единственной замены смысла обычно нет.

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

Стек

Spring Boot 4.1 · Kotlin 2.3 · Java 25 · Gradle (multi-module, Kotlin DSL) · Flyway · Gatling (Kotlin DSL) · Docker Compose · Python (uv + matplotlib) для извлечения чисел и графиков.

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages