golang

Kafka реально быстрая, но я возьму Postgres

  • вторник, 18 ноября 2025 г. в 00:00:10
https://habr.com/ru/articles/967000/

Команда Go for Devs подготовила перевод статьи о том, почему большинству проектов не нужна Kafka, «веб-масштабные» очереди и зоопарк из пяти баз данных. Автор на бенчмарках показывает, как далеко можно уехать на одном Postgres — и заодно разбирает, почему карго-культ масштабирования и «инфраструктура ради резюме» только мешают делать работу.


Мне кажется, в мире технологий есть два лагеря.

1.Первый — те, кто гонится за модными словами.

Этот лагерь выбирает всё популярное, не задумываясь, уместно ли это вообще. Они легко поддаются на любые обещания из маркетинговых презентаций — «real-time», «бесконечное масштабирование», «передовой уровень», «cloud-native», «serverless», «zero-trust», «AI-powered» и так далее.

Особенно ярко это видно в мире Kafka: Streaming Lakehouse™️, архитектура Kappa™️, Streaming AI Agents1.
Это явление иногда называют «разработкой ради резюме». Ещё это может быть разновидностью карго-культа про масштабирование. Современные практики только подталкивают к такому поведению. Консультанты продвигают «инновационные архитектуры», нашпигованные технологиями от вендоров, ссылаясь на свои «аналитические» отчёты². На собеседованиях по системному дизайну от вас ждут архитектуры уровня Google, которые неизбежно в сто раз масштабнее, чем когда-либо понадобится компании, куда вы вообще-то собеседуетесь. Рост по карьерной лестнице вознаграждает тех, кто переносит всё на очередной модный стек Hot New Stack™️, а не тех, ��то умеет работать эффективно.

2.Второй лагерь выбирает здравый смысл

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

Исторически казалось, что Лагерь 1 увереннее доминирует — и по численности, и по громкости. Сегодня же ощущается, что маятник начинает понемногу смещаться назад. По крайней мере, чуть-чуть. Две недавние тенденции работают в пользу Лагеря 2:

Тренд 1 — движение “Small Data”. Люди начинают понимать две вещи: их данные на самом деле не такие уж большие, а их компьютеры — наоборот, становятся огромными. В AWS можно арендовать машину с 128 ядрами и 4 ТБ RAM. А этим летом AMD выпустила CPU с 192 ядрами. Этого должно хватить кому угодно.3

Тренд 2 — Ренессанс Postgres. Эта сфера переживает взрывной рост и привлекает огромные инвестиции. За последние два года фраза «Просто используйте Postgres (для всего)» стала невероятно популярной. Основная идея в том, что не стоит усложнять архитектуру новой технологией, если в этом нет реальной необходимости, а один лишь Postgres вполне хорошо закрывает большинство задач. Сегодня Postgres конкурирует со специализированными решениями вроде:

  • Elasticsearch (тот же функционал обеспечивается через tsvector/tsquery в Postgres)

  • MongoDB (jsonb)

  • Redis (CREATE UNLOGGED TABLE)

  • AI Vector Databases (pgvector, pgai)

  • Snowflake / OLAP (pg_lake, pg_duckdb, pg_mooncake)

и… Kafka (об этом и есть эта статья).

Здесь не утверждается, что Postgres полностью равен по возможностям каждому из этих специализированных решений. Утверждается другое: он закрывает 80%+ их сценариев, требуя при этом всего 20% усилий на разработку. (Принцип Парето)

Если объединить оба тренда, привлекательность такого подхода становится очевидной. Postgres — это проверенная, всем знакомая система, которая проста, масштабируема и надёжна. Сочетая его с современным мощным железом, очень быстро начинаешь понимать: в большинстве случаев вам вовсе не нужны ультрасовременные, высоко оптимизированные и сложные распределённые системы, чтобы справляться с масштабами вашей организации.

Несмотря на то что я сам скорее предвзят в пользу Kafka, я, в целом, согласен с этим подходом. Kafka, как и Postgres, — стабильная, зрелая, проверенная боем система с сильным сообществом. Она ещё и масштабируется куда дальше. И всё же, я не думаю, что Kafka — правильный выбор во многих случаях. Очень часто я вижу, как её внедряют там, где она совершенно неуместна.

Нагрузка в 500 КБ/с не должна использовать Kafka. В техмире существует карго-культ масштабирования, когда все стремятся выбрать «самую лучшую» технологию для задачи — но в итоге упускают главное. «Самое лучшее» решение очень часто определяется не технически, а практически. Адриано приводит безупречный аргумент в пользу простых технологий в своём блоге PG as Queue (2023), который, собственно, и вдохновил меня написать эту статью.

Хватит предыстории. В этой статье мы сделаем три простые вещи:

  1. Померяем, как далеко Postgres может масштабироваться для pub/sub-сообщений — см. # PG as a Pub/Sub.

  2. Померяем, как далеко Postgres может масштабироваться как очередь — см. # PG as a Queue.

  3. Кратко разберём, когда Postgres действительно подходит для этих сценариев — см. # Should You Use Postgres?

Я не ставлю целью сделать исчерпывающую и глубокую оценку. Бенчмарки — это полный хаос. Моя задача — дать разумные точки данных, вокруг которых можно начать обсуждение.

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

Краткое резюме результатов

Если хотите сразу перейти к выводам — вот они:

Результаты бенчмарка

Pub-Sub результаты

Конфигурация

✍️ Запись

📖 Чтение

🔭 Сквозная задержка (p99)

Примечания

1× c7i.xlarge

4.8 MiB/s

5036 msg/s

24.6 MiB/s

25 183 msg/s (фан-аут 5×)

60 ms

~60% CPU; 4 партиции

3× c7i.xlarge (с репликацией)

4.9 MiB/s

5015 msg/s

24.5 MiB/s

25 073 msg/s (фан-аут 5×)

186 ms

~65% CPU; меж-AZ репликация RF≈2.5; 4 партиции

1× c7i.24xlarge

238 MiB/s

243 000 msg/s

1.16 GiB/s

1 200 000 msg/s (фан-аут 5×)

853 ms

~10% CPU (практически простаивает); 30 партиций

Результаты очереди

Конфигурация

📬 Пропускная способность (чтение + запись)

🔭 Сквозная задержка (p99)

Примечания

1× c7i.xlarge

2.81 MiB/s

2885 msg/s

17.7 ms

~60% CPU; узкое место — клиент чтения

3× c7i.xlarge (с репликацией)

2.34 MiB/s

2397 msg/s

920 ms ⚠️

задержка репликации завысила e2e

1× c7i.24xlarge

19.7 MiB/s

20 144 msg/s

930 ms ⚠️

~50% CPU; узкое место — одна таблица

Обязательно прочитайте хотя бы последний раздел статьи, где мы пофилософствуем — # Should You Use Postgres?

PG как Pub/Sub

Существует десятки статей о том, как использовать Postgres как очередь, но, что интересно, я ни разу не встречал, чтобы его использовали как систему pub-sub-сообщений.

Сначала коротко о различиях, потому что эти два подхода очень часто путают:

  1. Очереди предназначены для взаимодействия «точка-точка».
    Их обычно применяют для асинхронных фоновых задач: воркер-приложения (клиенты) получают задачу из очереди — например, отправить e-mail или пуш-уведомление. Событие читается один раз — и на этом всё. Сообщение сразу удаляется (popped) из очереди после обработки. Очереди не обеспечивают строгую гарантированную упорядоченность.

  2. Pub-sub отличается от очередей тем, что предназначен для схемы «один-ко-многим».
    Это автоматически ведёт к большому фан-ауту чтения: один и тот же месседж нужен сразу нескольким клиентам. Хорошие pub-sub-системы разводят читателей и писателей, храня данные на диске. Благодаря этому им не нужен максимальный лимит глубины очереди — в отличие от in-memory-очередей, которым приходится ограничивать размер, чтобы не вылететь по OOM. Кроме того, существует общее ожидание строгого порядка: события должны читаться в том же порядке, в котором они попали в систему.

Главным конкурентом Postgres в этой области является Kafka, которая сегодня является стандартом pub-sub. Существуют и другие альтернативы (в основном проприетарные).

Kafka использует структуру данных Log для хранения сообщений. В моём бенчмарке вы увидите, что я по сути воссоздаю лог на примитивах Postgres.

Для сценариев pub-sub у Postgres, насколько я вижу, нет популярных библиотек, поэтому мне пришлось написать всё самому. Я выбрал рабочий процесс, вдохновлённый Kafka. Он выглядит так:

  1. Писатели отправляют батчи сообщений в рамках одного statement¹⁰
    (INSERT INTO). Каждая транзакция вставляет один батч и пишет в одну таблицу topicpartition¹¹.

  2. Каждый писатель “прикреплён” к одной таблице, но в сумме все писатели работают с несколькими таблицами.

  3. Каждое сообщение получает уникальный монотонно возрастающий offset. В специальной таблице log_counter одна строка хранит последний offset для каждой таблицы topicpartition.

  4. Транзакции записи атомарно обновляют и данные topicpartition, и строку в log_counter. Это гарантирует согласованное отслеживание offset’ов при одновременной работе нескольких писателей.

  5. Читатели периодически спрашивают новые сообщения. Они последовательно читают таблицы topicpartition, начиная с минимального offset и двигаясь вверх.

  6. Читатели разделены на consumer groups. Каждая группа читает данные отдельно и независимо, продвигаясь по таблицам topicpartition.

  7. В каждой группе есть по одному читателю на каждую таблицу topicpartition.

  8. Читатели хранят свой прогресс в таблице consumer_offsets, где одна строка соответствует паре topicpartition, группа.

  9. Каждый читатель в рамках одной транзакции:

    1. обновляет последний обработанный offset (тем самым “забирая” записи),

    2. выбирает нужные строки,

    3. и обрабатывает их.

    Это даёт семантику, близкую к Kafka:
    — без пропусков,
    — монотонно возрастающие offset’ы,
    — гарантии at-least-once / at-most-once.

Конкретно в это�� тесте используется at-least-once, но выбор стратегии не влияет на результаты бенчмарка.

Настройка Pub-Sub

Таблица

CREATE TABLE log_counter (
  id           INT PRIMARY KEY, -- topicpartition table name id
  next_offset  BIGINT NOT NULL  -- next offset to assign
);
 
for i in NUM_PARTITIONS:
  CREATE TABLE topicpartition%d (
    id          BIGSERIAL PRIMARY KEY,
    -- strictly increasing offset (indexed by UNIQUE)
    c_offset    BIGINT UNIQUE NOT NULL,
    payload     BYTEA NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
  );
  INSERT INTO log_counter(id, next_offset) VALUES (%d, 1);
 
CREATE TABLE consumer_offsets (
  group_id     TEXT NOT NULL,     -- consumer group identifier
  -- topic-partition id (matches log_counter.id / topicpartitionN)
  topic_id     INT  NOT NULL,
  -- next offset the consumer group should claim
  next_offset  BIGINT NOT NULL DEFAULT 1,
  PRIMARY KEY (group_id, topic_id)
);

Запись

Бенчмарк запускает N горутин-писателей. Они моделируют клиентские приложения-записывальщики. Каждая горутина в цикле атомарно вставляет $BATCH_SIZE записей и одновременно обновляет последний offset:

WITH reserve AS (
  UPDATE log_counter
  SET next_offset = next_offset + $1
  WHERE id = $3::int
  RETURNING (next_offset - $1) AS first_off
)
 
INSERT INTO topicpartition%d(c_offset, payload)
SELECT r.first_off + p.ord - 1, p.payload
FROM reserve r,
     unnest($2::bytea[]) WITH ORDINALITY AS p(payload, ord);

Чтение

Бенчмарк также запускает N горутин-читателей. Каждый читатель закреплён за определённой consumer group и партицией. Вся группа в совокупности читает все партиции, но каждый отдельный читатель в группе работает только с одной партицией за раз.

Читатель в цикле:

  1. открывает транзакцию,

  2. оптимистично «забирает» $BATCH_SIZE записей (продвигая offset дальше них),

  3. выбирает эти записи,

  4. обрабатывает их.

Если всё прошло успешно, транзакция коммитится, и тем самым продвигается offset группы.

Это pull-подход (как у Kafka), а не push-подход.
Если у читателя нет новых записей, которые можно опросить, он делает небольшую паузу.

Сначала читатель открывает транзакцию:

BEGIN TRANSACTION

Затем «забирает» offset’ы:

WITH counter_tip AS (
  SELECT (next_offset - 1) AS highest_committed_offset
  FROM log_counter
  WHERE id = $3::int -- partition id
),
 
-- select & lock the particular group<->topic_partition<->offset pair
to_claim AS (
  SELECT
    c.group_id,
    c.next_offset AS n0, -- old start offset pointer before update
    -- takes the min of the batch size
    -- or the current offset delta w.r.t the tip of the log
    LEAST(
      $2::bigint, -- BATCH_SIZE
      GREATEST(0,
        (SELECT highest_committed_offset FROM counter_tip) - c.next_offset + 1)
    ) AS delta
  FROM consumer_offsets c
  WHERE c.group_id = $1::text AND c.topic_id = $3::int
  FOR UPDATE
),
 
-- atomically select + update the offset
upd AS (
  UPDATE consumer_offsets c
  SET next_offset = c.next_offset + t.delta
  FROM to_claim t
  WHERE c.group_id = t.group_id AND c.topic_id = $3::int
  RETURNING
    t.n0 AS claimed_start_offset, -- start = the old next_offset
    (c.next_offset - 1) AS claimed_end_offset -- end   = new pointer - 1
)
 
SELECT claimed_start_offset, claimed_end_offset
FROM upd;

После этого выбирает соответствующие записи:

SELECT c_offset, payload, created_at
  FROM topicpartition%d
  WHERE c_offset BETWEEN $1 AND $2
  ORDER BY c_offset

И наконец, данные обрабатываются бизнес-логикой (в бенчмарке — no-op), после чего транзакция закрывается:

COMMIT;

Если вы думаете: «почему не NOTIFY/LISTEN?» — моё понимание такое: эта возможность является всего лишь оптимизацией и на неё нельзя полноценно полагаться, поэтому polling всё равно нужен¹². Исходя из этого я просто повторил относительно простой дизайн Kafka.

Результаты Pub-Sub

Полный код и детальные результаты опубликованы на GitHub: stanislavkozlovski/pg-queue-pubsub-benchmark.
Я прогнал три конфигурации — одиночный инстанс на 4 vCPU, трёхнодовый кластер с репликацией на 4 vCPU и одиночный инстанс на 96 vCPU. Ниже — сводные результаты по каждой из них.

Одиночная нода, 4 vCPU

Результаты — это среднее значение трёх двухминутных прогонов. [ссылка на полные результаты]

Конфигурация:

  • Postgres-сервер на c7i.xlarge с диском 25GB gp3 9000 IOPS (EBS)

  • почти дефолтные настройки Postgres (включены synchronous_commit, fsync)

  • autovacuum_analyze_scale_factor = 0.05 также установлен на партиционных таблицах (неясно, дало ли это эффект)

  • полезная нагрузка каждой строки — 1 KiB (1024 байта)

  • 4 таблицы topicpartition

  • 10 писателей (в среднем по 2 пишущих клиента на партицию)

  • фан-аут чтения ×5 через 5 consumer groups

  • всего 20 клиентов-читалок (по 4 на группу)

  • размер батча записи: 100 записей

  • размер батча чтения: 200 записей

Результаты:

  • скорость записи: 5036 msg/s

  • пропускная способность записи: 4.8 MiB/s

  • задержка записи: 38.7 ms p99 / 6.2 ms p95

  • скорость чтения: 25 183 msg/s

  • пропускная способность чтения: 24.6 MiB/s

  • задержка чтения: 27.3 ms p99 (варьировалась 8.9–47 ms), 4.67 ms p95

  • сквозная задержка: 60 ms p99 / 10.6 ms p95

  • загрузка CPU сервера держалась около 60%

  • диск показывал ~1200 операций записи/с, iostat отображал 46 MiB/s

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

Одиночная нода / 3 узла (4 vCPU)

Теперь — конфигурация с репликацией, чтобы точнее приблизиться к гарантиям надёжности и доступности Kafka.

Результаты — среднее двух прогонов по 5 минут. [ссылка на полные результаты]

Конфигурация:

  • c7i.xlarge с дисками 25GB gp3 9000 IOPS (EBS)

  • каждая нода в отдельной зоне доступности (us-east-1a, us-east-1b, us-east-1c)

  • одна синхронная реплика и одна потенциальная¹⁴

  • несколько кастомных настроек Postgres:
    wal_compression,
    max_worker_processes,
    max_parallel_workers,
    max_parallel_workers_per_gather,
    и, конечно, hot_standby

  • autovacuum_analyze_scale_factor = 0.05 также включён на партиционных таблицах (неясно, есть ли эффект)

  • полезная нагрузка на строку: 1 KiB

  • 4 таблицы topicpartition

  • 10 писателей (в среднем 2 на партицию)

  • фан-аут чтения ×5 через 5 consumer groups

  • читатели ходят только на primary¹⁵; сами читатели находятся в той же AZ, что и primary

  • всего 20 читателей (по 4 на группу)

  • размер батча записи: 100 записей

  • размер батча чтения: 200 записей

Результаты:

  • скорость записи: 5015 msg/s

  • пропускная способность записи: 4.9 MiB/s

  • задержка записи: 153.45 ms p99 / 6.8 ms p95

  • скорость чтения: 25 073 msg/s

  • пропускная способность чтения: 24.5 MiB/s

  • задержка чтения: 57 ms p99 / 4.91 ms p95

  • сквозная задержка: 186 ms p99 / 12 ms p95

  • загрузка CPU: около 65%

  • диск: ~1200 операций записи/с, iostat показывал 46 MiB/s

Эти результаты впечатляют! Пропускная способность вообще не изменилась. Задержка выросла, но не критично. p99 сквозная задержка увеличилась примерно втрое (с 60 мс до 185 мс), но p95 почти не сдвинулась — с 10.6 мс до 12 мс.

Это показывает, что простой трёхузловой кластер Postgres вполне уверенно выдерживает типичную для Kafka нагрузку — 5 MB/s входящего трафика и 25 MB/s исходящего. И делает это, между прочим, по очень низкой цене: всего $11,514 в год¹⁶.

Обычно можно ожидать, что Postgres станет дороже Kafka при определённом масштабе, просто потому что он не был спроектирован под такой тип нагрузки. Но здесь это не так. Запускать Kafka самостоятельно стоит столько же. Прогонять такую же нагрузку через вендора Kafka — обойдётся минимум в $50,000 в год. 🤯

Кстати, в Kafka обычно применяется клиентское сжатие данных. Если предположить, что размер сообщения — 5 KB, и клиенты дают стандартное сжатие 4×¹⁷, то Postgres на самом деле обрабатывает 20 MB/s входящего и 100 MB/s исходящего трафика.

Одиночная нода, 96 vCPU

Ну что ж, посмотрим, насколько далеко можно разогнать Postgres.

Результаты — среднее трёх прогонов по 2 минуты. [ссылка на полные результаты]

Конфигурация

  • Postgres на c7i.24xlarge (96 vCPU, 192 GiB RAM)

  • диск: 250GB io2, 12 000 IOPS (EBS)

  • изменённые настройки Postgres (включены huge_pages, остальные параметры масштабированы под машину)

  • сохранены fsync и synchronous_commit для надёжности

  • autovacuum_analyze_scale_factor = 0.05 также на партиционных таблицах

  • полезная нагрузка строки: 1 KiB

  • 30 таблиц topicpartition

  • 100 писателей (~3.33 писателя на партицию)

  • фан-аут чтения: ×5

  • 150 читателей (по 5 в каждой группе)

  • размер batch записи: 200

  • размер batch чтения: 200

Результаты

  • скорость записи: 243 000 msg/s

  • пропускная способность записи: 238 MiB/s

  • задержка записи: 138 ms p99 / 47 ms p95

  • скорость чтения: 1 200 000 msg/s

  • пропускная способность чтения: 1.16 GiB/s

  • задержка чтения: 24.6 ms p99

  • сквозная задержка: 853 ms p99 / 242 ms p95 / 23.4 ms p50

  • загрузка CPU: всего ~10% (почти бездействует)

  • Узкое место: упёрлись в скорость записи на таблицу. Похоже, что тест не мог писать быстрее 8 MiB/s (≈8000 msg/s) в каждую table при текущем дизайне. Я не стал давить дальше, но пока пишу эти строки — интересно, насколько всё-таки можно было бы ещё разогнаться? Чтения масштабировались почти бесплатно.
    Добавить больше consumer groups — никакой проблемы: я пробовал ×10 фан-аут, и CPU оставался низким. Я не стал включать эти результаты, потому что это уже слишком нереалистичный сценарий.

240 MiB/s входящего и 1.16 GiB/s исходящего трафика — это очень впечатляюще!
Машина на 96 vCPU была с огромным запасом — она могла бы сделать ещё больше, или можно было взять сервер проще. Но всё же, на таком масштабе я бы рассматривал выделенный кластер Kafka. Kafka на этом уровне сможет существенно сэкономить деньги, потому что эффективнее работает с межзонным трафиком — особенно благодаря таким возможностям, как Diskless Kafka.

Итоги тестов Pub-Sub

Сводная таблица по всем трём тестам доступна здесь → 👉 stanislavkozlovski/pg-queue-pubsub-benchmark

Эти тесты показывают, что на небольших масштабах Postgres вполне конкурентоспособен с Kafka.

Вы, возможно, заметили, что ни один из тестов не был особенно длительным. Насколько я понимаю, ценность долгих прогонов в том, чтобы проверить работу vacuum в Postgres, ведь он может негативно влиять на производительность. В случае pub-sub это не актуально, потому что таблицы — append-only, vacuum их практически не трогает. Вторая причина коротких тестов — банально не раздувать бюджет и не тратить слишком много времени¹⁸.

Как бы то ни было, идеальных бенчмарков не существует.
Моя цель была не в том, чтобы неоспоримо доказать $MY_CLAIM.
Я хотел показать, что возможности Postgres — выше, чем многие привыкли думать, и запустить обсуждение. Честно говоря, я и сам не ожидал получить такие хорошие показатели, особенно в части pub-sub.

PG как очередь

В Postgres очередь можно реализовать с помощью SELECT FOR UPDATE SKIP LOCKED.
Эта команда выбирает незаблокированную строку и блокирует её. Уже заблокированные строки она пропускает. Так достигается взаимное исключение — воркер не может получить задачи, которые уже «забрали» другие воркеры.

У Postgres есть очень популярная библиотека pgmq, предлагающая аккуратный Queue API.
Но чтобы упростить эксперимент и лучше понять, как работает процесс от начала до конца, я решил написать свою собственную очередь. Базовая реализация оказалась довольно простой. Рабочий процесс такой:

  1. добавить задачу (INSERT)

  2. забрать задачу (SELECT FOR UPDATE SKIP LOCKED)

  3. обработать задачу (любая ваша бизнес-логика)

  4. пометить задачу как выполненную (обновить поле UPDATE или удалить задачу и вставить её в отдельную таблицу)

В этой нише Postgres конкурирует с RabbitMQ, AWS SQS, NATS, Redis¹⁹ и частично Kafka²⁰.

Настройка очереди

Таблица

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

CREATE TABLE queue (
  id BIGSERIAL PRIMARY KEY,
  payload BYTEA NOT NULL,
	created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
 
CREATE TABLE queue_archive (
  id BIGINT,
  payload BYTEA NOT NULL,
  created_at TIMESTAMP NOT NULL, -- ts the event was originally created at
  processed_at TIMESTAMP NOT NULL DEFAULT NOW() -- ts the event was processed at
)

Запись

Мы снова запускаем N горутин-писателей. Каждая из них просто работает в цикле и последовательно вставляет один случайный элемент в таблицу:

INSERT INTO queue (payload) VALUES ($1)

Каждая транзакция вставляет ровно одно сообщение — и на больших масштабах это, конечно, довольно неэффективно.

Чтение

Мы также запускаем M горутин-читателей. Каждый читатель в цикле забирает и обрабатывает одно сообщение. Обработка выполняется внутри транзакции базы данных:

BEGIN;
 
SELECT id, payload, created_at
  FROM queue
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1;
 
-- Your business code "processes" the message. In the benchmark, it's a no-op.
 
DELETE FROM queue WHERE id = $1;
 
INSERT INTO queue_archive (id, payload, created_at, processed_at)
  VALUES ($1,$2,$3,NOW());
 
COMMIT;

Как и раньше, каждый читатель обрабатывает только одно сообщение за транзакцию.

Результаты очереди

Я снова прогнал те же три конфигурации: одиночная нода на 4 vCPU, трёхнодовая конфигурация с репликацией на 4 vCPU и одиночная нода на 96 vCPU. Ниже — сводные результаты по каждой.

Одиночная нода, 4 vCPU

Результаты — среднее двух прогонов по 15 минут. Я также запустил три прогона по 2 минуты — показатели совпали. [ссылка на полные результаты]

Конфигурация:

  • Postgres на c7i.xlarge

  • диск: 25GB gp3 9000 IOPS (EBS)

  • полностью дефолтные настройки Postgres²¹

  • полезная нагрузка строки: 1 KiB

  • 10 клиентов-писателей

  • 15 клиентов-читателей

Результаты:

  • 2885 msg/s

  • пропускная способность: 2.81 MiB/s

  • задержка записи: 2.46 ms p99

  • задержка чтения: 4.2 ms p99

  • сквозная задержка⁵: 17.72 ms p99

  • загрузка CPU ~ 60%

Вот что, как мне показалось, Postgres даётся хуже всего — большое количество клиентских подключений. Узким местом здесь оказались клиенты-читалки. Каждый из них физически не мог читать больше примерно 192 сообщений в секунду из-за медианной задержки чтения и последовательного характера обработки.

Увеличение числа клиентов действительно повышало пропускную способность, но нарушало мой целевой лимит в ~60% CPU. Попытка запустить 50 писателей и 50 читателей дала около 4000 msg/s без роста глубины очереди, но нагрузила CPU сервера на 100%. Я хотел, чтобы бенчмарк отражал реалистичное продовое использование, а не то, что удаётся выжать из машины на пределе. Эта проблема легко решается использованием connection pooler (это стандартная часть любого прод-окружения Postgres) или просто более мощной машиной.

Есть ещё один нюанс: нагрузка могла выдерживать значительно больше записи, чем чтения. Если бы я не ограничивал бенчмарк, он бы писал со скоростью 12 000 msg/s, а читал только 2800 msg/s. В духе упрощения процесса я не углублялся в дальнейшее расследование, а просто зажал скорость записи, чтобы понять, при каком значении удастся стабилизировать соотношение 1:1 между чтением и записью.

Одиночная нода / 3 узла (4 vCPU)

Один тест длиной 10 минут. [ссылка на полные результаты]

Конфигурация

  • c7i.xlarge с диском 25GB gp3 9000 IOPS (EBS)

  • каждая нода — в своей AZ (us-east-1a / 1b / 1c)

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

  • несколько кастомных параметров Postgres:wal_compression, max_worker_processes,max_parallel_workers,max_parallel_workers_per_gather, и, конечно, hot_standby

  • полезная нагрузка строки: 1 KiB

  • 10 клиентов-писателей

  • 15 клиентов-читателей

  • читатели ходят только на primary и находятся в той же AZ

Результаты

  • 2397 msg/s

  • пропускная способность: 2.34 MiB/s

  • задержка записи: 3.3 ms p99

  • задержка чтения: 7.6 ms p99

  • сквозная задержка: 920 ms p99 ⚠️ / 536 ms p95 / 7 ms p50

  • загрузка CPU: ~ 60%

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

Одиночная нода, 96 vCPU

Среднее трёх прогонов по 2 минуты. [ссылка на полные результаты]

Конфигурация:

  • Postgres на c7i.24xlarge

  • диск: 250GB io2, 12 000 IOPS (EBS)

  • изменённые настройки Postgres (huge_pages включён; остальные параметры масштабированы под машину)

  • сохранены fsync и synchronous_commit для надёжности

  • полезная нагрузка строки: 1 KiB

  • 100 клиентов-писателей

  • 200 клиентов-читателей

Результаты:

  • скорость обработки сообщений: 20 144 msg/s

  • пропускная способность: 19.67 MiB/s

  • задержка записи: 9.42 ms p99

  • задержка чтения: 22.6 ms p99

  • сквозная задержка: 930 ms p99 ⚠️ / 709 ms p95 / 12.6 ms p50

  • загрузка CPU: 40–60%

Этот прогон получился не слишком впечатляющим.
На таком масштабе в подходе с очередью на одной таблице явно проявляется какое-то узкое место, но я не стал разбираться глубже.

В целом, это и не нужно: в реальных условиях никто не станет гонять 20 000 msg/s через одну-единственную очередь.
В реальном продакшене таких очередей всегда несколько.

Скорее всего, машина с 96 vCPU масштабировалась бы значительно дальше, если бы мы запускали нагрузку сразу по нескольким раздельным queue-таблицам.

Итоги тестов очереди

Сводная таблица по всем трём тестам доступна здесь → 👉 stanislavkozlovski/pg-queue-pubsub-benchmark

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

Как я уже говорил, за последние два года лозунг Just Use Postgres стал по-настоящему мейнстримом. История звёздочек библиотеки pgmq отлично отражает этот тренд:

Стоит ли вам использовать Postgres?

В большинстве случаев — да. Всегда начинайте с Postgres, пока реальные ограничения не докажут обратное.

Kafka, конечно, лучше оптимизирована под pub-sub.
Системы очередей, разумеется, лучше оптимизированы под очереди.

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

гоночный болид «Формулы-1» оптимизирован для скорости, но на работу я всё равно езжу на обычном седане. Мне куда комфортнее управлять седаном, чем F1-машиной.

(честно, вы только посмотрите на этот их руль)

Седан Postgres даёт массу удобств, которых у болида Kafka просто нет:

  • можно отлаживать сообщения обычным SQL;

  • можно удалять, менять порядок или править сообщения прямо на месте;

  • можно делать join pub-sub-данных с обычными таблицами;

  • можно без труда выбирать нужные данные богатыми SQL-запросами
    (ID=54, name="John", cost>1000).

Отказываться от всех этих удобств ради того, чтобы ваша F1-машина разгонялась до 378 км/ч, — оправданная жертва. Но делать это, если вы собираетесь ездить 25 км/ч, — уже чистое самоистязание.

Дональд Кнут ещё в 1974 году предупреждал: преждевременная оптимизация — корень всех зол. Разворачивать Kafka на небольших нагрузках — именно преждевременная оптимизация. Смысл этой статьи как раз в том, чтобы показать: планка того, что считается «небольшой нагрузкой», сильно выросла по сравнению с тем, что многие помнят. Теперь это вполне может означать многие мегабайты в секунду.

Мы переживаем Ренессанс Postgres не просто так: Postgres очень часто оказывается более чем достаточным. Современные NVMe и дешёвая память позволяют ему масштабироваться до совершенно безумных величин.

Какой же есть альтернативный путь?

Кастомные решения на всё подряд?

Наивные инженеры склонны тянуть в проект специализированную технологию при малейшем намёке на необходимость:

  • Нужен кеш? Конечно, Redis!

  • Поиск? Разворачиваем Elasticsearch!

  • Аналитика офлайн? BigQuery или Snowflake — так работали аналитики на прошлой работе.

  • Нет схем? Тогда нам нужен NoSQL, например MongoDB.

  • Нужно что-то посчитать в S3? Запускаем Spark!

Хороший инженер смотрит на картину шире.

  • Даст ли эта новая технология заметный прирост?

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

  • Пользователи вообще заметят разницу?

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

Главная проблема — организационные издержки.

Организационные издержки включают в себя необходимость:

  • внедрить новую систему и разобраться, как она работает;

  • изучить все её нюансы, параметры, конфигурации;

  • настроить мониторинг;

  • наладить процессы деплоя и обновлений;

  • накопить операционную экспертизу по её сопровождению;

  • написать runbook’и;

  • протестировать её поведение;

  • отлаживать и дебажить;

  • интегрироваться с её клиентами и API;

  • работать с её UI и инструментами;

  • следить за её экосистемой и обновлениями.

И всё это — лишь ради того, чтобы добавить в инфраструктуру ещё один компонент.

Все эти вещи — настоящие организационные затраты, и на то, чтобы всё выстроить правильно, могут уйти месяцы, даже если сама система несложная (а многие как раз очень сложные).

Да, управляемые SaaS-решения снимают часть этой нагрузки за счёт денег, но не убирают её полностью.
И пока вы не доросли до масштаба, где эта технология действительно необходима, вы просто платите дополнительные {финансовые, организационные} издержки без какого-либо ощутимого выигрыша.

Если вы можете решить задачу с помощью технологии, за организационные издержки которой вы уже заплатили (например, Postgres), то преждевременное внедрение чего-то ещё — это очевидный анти-паттерн.

Вам не нужны технологии уровня веб-гигантов, если у вас нет проблем уровня веб-гигантов.

MVI (лучшая альтернатива)

На мой взгляд, куда более здравый подход — искать минимально жизнеспособную инфраструктуру (MVI): строить настолько маленькую систему, насколько это возможно, при этом продолжая приносить реальную пользу.

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

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

  • знакомая = у вашей организации уже есть опыт, runbook’и, операционные процессы, мониторинг, UI и т. д.;

  • решать с её помощью реальную задачу;

  • использовать минимальный набор возможностей;

  • чем меньше фич вы задействуете, тем проще вам будет потом уйти с этой инфраструктуры (например, если окажется, что вы слишком завязаны на конкретного вендора).

Дополнительный плюс, если выбранная технология:

  • широко распространена, так что найти хороших инженеров под неё не составляет труда
    (Postgres — галочка)

  • обладает сильным и растущим сетевым эффектом
    (Postgres — галочка)

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

К сожалению, человеческая природа работает против этого. Так же как стартапы страдают от раздувания MVP («ещё одна фича!»), инфраструктурные команды страдают от раздувания MVI («ещё одна система!»).

Почему мы вообще так делаем?

Я не претендую на то, чтобы точно разобрать всю цепочку причин, но моё предположение такое:

  1. эпоха нулевых процентных ставок дала огромное количество спекулятивных денег, которые инвестировали в любые компании, способные быстро расти;

  2. множество вирусных интернет-сервисов росли с такой скоростью, что старая инфраструктура стремительно устаревала;

  3. это вызвало новую волну ZIRP-инвестиций — в специализированные компании по работе с данными
    (золотая лихорадка — продавай лопаты!); некоторые из них буквально выросли изнутри тех самых быстрорастущих компаний;

  4. каждый хорошо профинансированный вендор инфраструктуры был финансово мотивирован активно проповедовать своё решение и заставлять вас его внедрять, даже если оно вам не нужно
    (Everyone is Talking Their Book — каждый расхваливает то, что выгодно ему); у них были огромные бюджеты на маркетинг — и они ими пользовались;

  5. начали появляться инновационные инфраструктурные системы. Это было захватывающе — инженеры легко «ведутся» на такие штуки;

  6. сформировалась веб-масштабная мода / карго-культ: всем казалось, что нужно уметь масштабироваться от нуля до миллионов RPS, потому что приложение может «взорваться» �� любой момент;

  7. появилась тенденция копировать решения самых успешных цифровых компаний (Amazon, Google, Uber и т. д.);

  8. тренд стал самоподдерживающимся пророчеством: эти технологии стали желанными навыками в резюме;

    • вопросы на системный дизайн переписали так, чтобы проверять знания именно этих систем;

    • внутри организаций инженеры — сознательно или нет — продвигали проекты, которые выглядят «круто» и улучшают их резюме;

Эта тенденция продолжает набирать обороты, потому что нет равнозначной противодействующей силы, которой было бы достаточно выгодно продвигать противоположный подход. Даже инженеры внутри компании, которые вроде бы должны быть заинтересованы в простоте, имеют мощные стимулы тянуть систему к усложнению. Это помогает их карьере — даёт проект, которым можно «стрелять» на следующем повышении, и делает резюме привлекательнее (крутая технология, красивая история) для следующего прыжка по работе. Плюс… это просто интереснее.

И именно поэтому, как мне кажется, наша индустрия далеко не всегда выбирает самое простое решение.

А в большинстве случаев этим простейшим доступным решением является Postgres.

Но это же не масштабируется!

Я уже хочу заканчивать эту статью, но пропустить один из самых частых контраргументов я не могу — аргумент «оно не будет масштабироваться».

Он обычно звучит так:
«Сегодня любое приложение может внезапно стать вирусным; такие всплески трафика крайне ценны для бизнеса, поэтому мы должны заранее агрессивно проектировать систему так, чтобы она выдерживала любые пики нагрузки».

У меня есть три возражения против такой логики:

1. Postgres масштабируется

По состоянию на 2025 год OpenAI до сих пор использует неразшардированную архитектуру Postgres с одним-единственным primary-инстансом для записей²².

OpenAI — это эталон компании, которая росла взрывными, вирусными темпами. Они удерживают рекорд по самому быстрому достижению отметки в 100 миллионов пользователей.

Бохан Чжан, инженер инфраструктурной команды OpenAI и со-основатель OtterTune (сервиса для тюнинга Postgres), говорит об этом так²³:

«В OpenAI мы используем неразшардированную архитектуру — один писатель и несколько читателей. Это показывает, что PostgreSQL способен прекрасно масштабироваться под огромные read-нагрузки».

«Главная мысль моего доклада была в том, что если у вас не слишком тяжёлая запись, то вы можете масштабировать Postgres до очень высокого уровня по чтению, просто используя реплики — и всё это с одним единственным мастером! Именно это и нужно проговаривать вслух, потому что это покрывает подавляющее большинство приложений».

«Postgres, вероятно, сейчас — решение по умолчанию для разработчиков. Вы можете использовать Postgres очень долго. Если вы делаете стартап с преобладанием чтения — просто начинайте с Postgres. Если когда-то упрётесь в проблемы масштабируемости — увеличьте размер инстанса. Его можно масштабировать до действительно больших уровней. А если в будущем база станет узким местом — поздравляю. Значит, вы построили успешный стартап. Это хорошая проблема».

Несмотря на их стремительный рост — уже более 800 миллионов пользователей — OpenAI так и не перешла на распределённую, «веб-масштабную» базу данных. Если они этого не сделали… то почему это вдруг нужно вашему проекту, который пока даже не доказал свою жизнеспособность?

2. У вас гораздо больше времени на масштабирование, чем вы думаете

Допустим, существует разумный принцип: проектировать и тестировать систему примерно на 10× от вашего текущего масштаба.

Вот сколько лет стабильного роста потребуется, чтобы вырасти в 10 раз, при разных годовых темпах роста:

Годовой рост

Лет до увеличения масштаба в 10 раз

10 %

24.16 года

25 %

10.32 года

50 %

5.68 года

75 %

4.11 года

100 %

3.32 года

150 %

2.51 года

200 %

2.10 года

Большинство компаний никогда не испытывают подобного устойчивого роста.
А если у вас будет такая динамика — это, опять же, великолепная проблема.

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

3. Это избыточный дизайн

В идеальном мире вы бы проектировали систему сразу под будущий масштаб и все возможные проблемы, которые могут возникнуть через 10 лет.

Но в реальном мире у вас есть ограниченные ресурсы, поэтому вы должны решать самые насущные задачи с максимальным ROI.

Пользователь snej на lobste.rs сформулировал это блестяще:

Планировать инфраструктуру «на случай, если вдруг придётся выдержать вирусный взрыв» — это как купить огромный гитарный усилитель Marshall в качестве первого комбика, потому что вдруг вашу гаражную группу пригласят играть разогревом у Coldplay.

Именно так выглядит преждевременное стремление к «веб-масштабным» решениям.

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

А когда рано или поздно проявятся узкие места — по возможностям или по масштабу — вы начинаете разбирать их по одному и на каждом шаге задаёте вопрос: «Пора ли перейти на специализированное решение?»

Вывод

Просто используйте Postgres — пока он не начнёт вам мешать.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!