Когда PostgreSQL упирается в запись, первым рефлексом часто оказывается шардирование: Citus, ручное разбиение в приложении или переезд на NoSQL. Цена у этого решения высокая — распределённые транзакции, сломанные внешние ключи между шардами, миграции схемы на десятках узлов. Подробнее о том, во что обходится шардирование, я писал отдельно.
Прежде чем туда идти, стоит выжать один primary. На современном железе и при осознанной настройке он тянет очень много. Ниже — рычаги по порядку убывания отдачи, с поправками к советам, которые кочуют из статьи в статью.
Поводом послужила статья How to Scale PostgreSQL to 100,000 Writes Per Second Without Sharding (Level Up Coding, 23 сентября 2026). Направление в ней верное, но есть фактические ошибки и пропущены последствия — ниже мой разбор, а не перевод.
Сначала о цифре в заголовке
«100 000 записей в секунду» — не константа PostgreSQL, а результат четырёх переменных: ширины строки, числа индексов, настроек надёжности и железа. Узкая строка с одним первичным ключом, асинхронный коммит и батчи на NVMe — и цифра достижима. Широкая строка с пятью индексами и синхронным коммитом — и до неё будет далеко на любом сервере.
Поэтому ни один совет ниже не даёт «×10». Каждый снимает конкретное узкое место, и какое из них у вас главное, показывает только замер.
1. Коммит: fsync, группировка и асинхронность
По умолчанию коммит ждёт, пока запись WAL физически окажется на диске. Это и есть гарантия durability.
Здесь первая поправка к популярной картине «100 000 коммитов = 100 000 fsync». Это не так: PostgreSQL группирует коммиты. Если несколько транзакций коммитятся одновременно, их записи WAL сбрасываются одним вызовом. Параллельные сессии делят один flush, поэтому пропускная способность растёт с числом клиентов, пока диск успевает.
Вторая поправка: ограничение здесь — латентность flush, а не IOPS. Цифры вроде «NVMe даёт 10–40 тысяч IOPS» устарели на порядок: современные NVMe выдают сотни тысяч и больше. Но flush — это ожидание подтверждения от контроллера, и решающим оказывается, есть ли у диска защита от потери питания (PLP). Серверный NVMe с PLP подтверждает сброс из кэша почти мгновенно; потребительский честно ждёт записи на флэш.
Если группировки мало, есть асинхронный коммит:
-- Только для конкретной транзакции, а не глобально:
BEGIN;
SET LOCAL synchronous_commit = off;
INSERT INTO event_logs ...;
COMMIT;При synchronous_commit = off коммит возвращается сразу после записи в буфер WAL, а на диск данные сбрасывает фоновый walwriter.
Что важно понимать точно:
- Целостность базы не страдает. Сбой не испортит данные и не нарушит консистентность — вы теряете только последние подтверждённые, но ещё не сброшенные транзакции.
- Окно потерь — до трёх
wal_writer_delay. По умолчанию параметр равен 200 мс (в исходной статье ошибочно указано 10 мс), то есть окно по умолчанию — до 600 мс. - Включайте точечно. Параметр можно задать на транзакцию, сессию или роль. Для телеметрии, событий и метрик потерять долю секунды при аварии допустимо; для платежей и заказов — нет. Глобальный
synchronous_commit = offна сервере со смешанной нагрузкой — ошибка, которую обнаруживают после первого падения.
2. Батчи вместо одиночных INSERT
Сто тысяч отдельных INSERT — это сто тысяч сетевых обменов, разборов запроса и транзакционных границ. Никакой пул соединений это не спасёт.
Многострочный INSERT. Накапливайте записи в приложении (скажем, 10 мс или 500 строк) и отправляйте одной командой:
INSERT INTO event_logs (device_id, event_type, payload, created_at)
VALUES
(101, 'ping', '{"temp": 22}', now()),
(102, 'alert', '{"temp": 85}', now()),
(103, 'ping', '{"temp": 21}', now());Накладные расходы на разбор и коммит делятся на всю пачку.
COPY. Для максимальной пропускной способности — протокол COPY через потоковые API драйвера (pgx.CopyFrom в Go, copy_expert/copy в psycopg, NpgsqlBinaryImporter в .NET):
COPY event_logs (device_id, event_type, payload, created_at)
FROM STDIN WITH (FORMAT binary);Поправка: COPY не пишет «прямо в страницы таблицы» в обход всего. Он идёт тем же путём вставки в кучу: WAL, обновление индексов, проверка ограничений, строковые триггеры — всё на месте. Выигрыш в другом — нет разбора и планирования на каждую строку и нет сетевого обмена на каждую строку. Этого хватает, чтобы на пакетной загрузке обгонять многострочный INSERT в разы, но магии здесь нет, и индексы по-прежнему обходятся в свою цену.
Там, где данные можно потерять целиком (промежуточные таблицы загрузки, пересчитываемые кэши), есть крайний вариант — UNLOGGED-таблицы: они не пишут WAL вообще. Цена — таблица очищается после сбоя и не реплицируется.
3. Индексы: каждый стоит записи
Каждый вторичный индекс — ещё одна запись на каждую вставленную строку. Таблица с пятью индексами превращает одну вставку в шесть обновлений страниц.
Для таблиц приёма данных правило простое: первичный ключ плюс один-два индекса, без которых реально не обойтись. Индекс «на всякий случай» на таблице с сотней тысяч вставок в секунду — это налог, который платится постоянно.
Отдельно про тип первичного ключа — пункт, которого в исходной статье нет, хотя на практике он один из решающих. Случайный UUIDv4 разбрасывает вставки по всему B-дереву: страницы индекса постоянно делятся, горячая часть индекса не помещается в память, а запись WAL растёт из-за полных образов страниц. Монотонные ключи — bigint GENERATED ALWAYS AS IDENTITY или UUIDv7 — вставляют в правый край дерева, и индекс ведёт себя на порядок лучше.
Про HOT и fillfactor
В популярных чек-листах рядом с вставками советуют снижать fillfactor, чтобы работали HOT-обновления:
ALTER TABLE active_sessions SET (fillfactor = 80);Совет правильный, но для другой нагрузки. HOT (heap-only tuple) — оптимизация UPDATE: новая версия строки ложится в ту же страницу, и вторичные индексы не трогаются, если изменённые колонки не индексированы. Для таблицы, в которую только вставляют, HOT не работает вовсе, а fillfactor < 100 просто оставляет пустое место в каждой странице. Для счётчиков, сессий и статусов — да. Для append-only телеметрии — нет.
4. WAL и чекпоинты
При интенсивной записи настройки по умолчанию вызывают частые чекпоинты, а каждый чекпоинт — это всплеск записи грязных страниц.
wal_buffers = 64MB
max_wal_size = 64GB
min_wal_size = 8GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
wal_compression = lz4Что здесь на самом деле работает:
max_wal_size. При значении по умолчанию (1 ГБ) интенсивная запись исчерпывает порог за секунды и вызывает принудительные чекпоинты. Увеличение — главный рычаг в этом разделе.- Реже чекпоинты — меньше WAL. Неочевидный эффект: после каждого чекпоинта первое изменение страницы пишет в WAL её полный образ (
full_page_writes). Чем реже чекпоинты, тем меньше таких образов и тем меньше объём WAL на ту же нагрузку. wal_compression(lz4 или zstd, с PostgreSQL 15) сжимает те самые полные образы страниц — дешёвый способ уменьшить объём WAL.checkpoint_completion_target = 0.9— значение по умолчанию с PostgreSQL 14. Прописывать его на современной версии не нужно.
И цена, о которой обычно не пишут: больше max_wal_size — дольше восстановление после сбоя, потому что при старте придётся проиграть больше WAL. Плюс под pg_wal нужно соответствующее место на диске.
5. Партиционирование
Декларативное партиционирование — не шардирование: всё живёт на одном сервере, просто одна логическая таблица разложена на несколько физических.
CREATE TABLE metric_streams (
id bigint GENERATED ALWAYS AS IDENTITY,
device_id int NOT NULL,
recorded_at timestamptz NOT NULL,
val double precision
) PARTITION BY RANGE (recorded_at);
CREATE TABLE metric_streams_2026_09_27 PARTITION OF metric_streams
FOR VALUES FROM ('2026-09-27') TO ('2026-09-28');Что это даёт для записи:
- Индексы текущей партиции маленькие и помещаются в
shared_buffers— вставки не упираются в чтение страниц индекса с диска. - Удаление старых данных —
DROP TABLEпартиции, мгновенно и без раздувания таблицы, вместоDELETE, который порождает мёртвые строки и работу для vacuum.
Практическая оговорка: партиции нужно создавать заранее. Вставка в диапазон, для которого партиции нет, завершится ошибкой (или уйдёт в партицию по умолчанию, из которой потом придётся выковыривать данные). Это работа для pg_partman или крона, а не для ручного процесса. О связке партиционирования с tablespaces на разных классах дисков — в Рецепте 7.
6. Железо и соединения
WAL на отдельный диск. Сброс WAL при коммите и запись чекпоинта не должны конкурировать за одно устройство. В CloudNativePG это отдельный walStorage — по замерам автора Рецепта 6, прирост 15–45%. Побочный эффект: база становится многотомной, и снапшоты томов нужно снимать согласованно — об этом пост про VolumeGroupSnapshot.
Пул соединений. PostgreSQL создаёт отдельный процесс на каждое соединение. Тысячи соединений — это переключения контекста и память, а не параллелизм. PgBouncer в режиме транзакций и несколько десятков реальных соединений к базе работают лучше, чем тысяча прямых. Подробнее — в разборе пулинга.
О чём молчат такие чек-листы
Настроить primary на сто тысяч записей в секунду — половина дела. Вторая половина — всё, что стоит за ним.
Репликация. Сто тысяч записей в секунду — это огромный поток WAL, который реплики должны принимать и проигрывать. Проигрывание на реплике однопоточное, и реплика может не успевать за primary, даже если сеть справляется. Отставание реплик — первое, что начинает болеть.
Архивирование WAL и бэкапы. Непрерывный бэкап должен успевать отправлять WAL в хранилище. Если архивирование отстаёт, pg_wal растёт, пока не кончится диск, — и тогда встаёт уже весь primary.
Autovacuum. Даже append-only таблицы нужно вакуумировать: для карты видимости (без неё не работают index-only scan) и для защиты от переполнения счётчика транзакций. С PostgreSQL 13 vacuum запускается и по числу вставок (autovacuum_vacuum_insert_threshold), но при таком потоке параметры почти наверняка придётся подкрутить. В PostgreSQL 19 приоритизацию autovacuum и вовсе переделали — подробнее здесь.
Восстановление. Вспомните про max_wal_size: большой объём WAL между чекпоинтами — это минуты проигрывания при старте после сбоя. Измерьте это время заранее, а не во время инцидента.
Порядок действий
- Измерьте, где узкое место: ожидания на
WALWrite/WALSync, частота чекпоинтов (checkpoints_reqпротивcheckpoints_timed), латентность диска. - Батчи или COPY вместо одиночных вставок — самая большая отдача при нулевом риске.
- Уберите лишние индексы и замените случайные UUID на монотонные ключи.
- Увеличьте
max_wal_sizeи включитеwal_compression. - Вынесите WAL на отдельный диск с защитой от потери питания.
- Асинхронный коммит — точечно, только для данных, потерю которых вы готовы пережить.
- Партиционируйте большие таблицы по времени и автоматизируйте создание партиций.
- Проверьте, что успевают реплики, архивирование WAL и autovacuum.
Шардирование остаётся честным ответом — но на вопрос, который стоит задавать после этого списка, а не вместо него.