Postgres в стартапе: как не уронить базу в первый год

24.07.2026 · 5 мин

Знаете, что объединяет большинство стартапов, у которых «внезапно» падает прод? Postgres. Не потому что Postgres плохой — он отличный. Просто в стартапе все бегут, и никто не закладывает время на то, чтобы разобраться, как эта база работает на уровне, большем чем «создал модельку, добавил миграцию, поехали».

Автор статьи — Александр Белэнжер из Hatchet — написал внутренний документ для своей команды, где собрал два года боёв с Postgres. Подход здесь не академический, а практический: конкретные грабли и способы их обойти. Разберёмся по порядку.

Первое, что ломается: ты не понимаешь, как postgres ищет данные

У Postgres при SELECT-запросе по сути есть два пути: найти нужную строку по индексу или прочитать всю таблицу целиком. На маленьких таблицах разницу почти не видно, но на больших — запрос внезапно начинает тормозить.

ПОИСК СТРОКИ В POSTGRES
────────────────────────
Таблица с 1М строк
│
├─ По индексу (btree):  O(log n) ≈ 20 операций    ✓ Быстро
│
├─ Seq scan:            Читаем все 1М строк         ✗ Медленно
│
└─ Правило:             Ставь индекс на фильтры
                        в WHERE и ORDER BY
Два пути Postgres: индекс или чтение всей таблицы

Индекс — это отдельная структура данных, обычно btree, которая хранит отсортированные значения и ссылки на строки. Поиск по btree занимает примерно log(n) операций, где n — количество строк. Seq scan — это чтение всех строк подряд.

Схема данных: потратьте время заранее

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

Автор честно отмечает: нормализация бывает в конфликте с удобством и скоростью разработки. Иногда проще положить часть данных в jsonb и не усложнять модель раньше времени.

Составные индексы: ORDER BY должен совпадать

Типичная боль — запрос списка с фильтрацией и сортировкой. Если у вас миллионы событий, под такой запрос нужен составной индекс. Колонки из WHERE идут первыми, колонки из ORDER BY — последними, а направление сортировки тоже важно учитывать.

SELECT * FROM events 
WHERE user_id = 123 
ORDER BY created_at DESC 
LIMIT 50;
CREATE INDEX idx_events_user_created 
ON events (user_id, created_at DESC);

Записи: держите транзакции короткими

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

Для больших таблиц используйте CREATE INDEX CONCURRENTLY: индекс создаётся без жёсткой блокировки, но дольше.

Миграции: главный источник простоя

Хорошее владение миграциями — это техническое преимущество. Безопасная стратегия почти всегда одна: сначала добавить новое, потом переключить код, и только после этого удалить старое.

Управление соединениями: утечка как худший враг

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

БЕЗ ПУЛА VS С ПУЛОМ СОЕДИНЕНИЙ
───────────────────────────────
Без пула:                         С пулом (PgBouncer):
App → Postgres                    App → PgBouncer → Postgres
│                                  │
├─ 100 соединений = 100 процессов  ├─ 50 соединений = 50 процессов
└─ 1000 соединений = 1000 проц.    └─ 1000 запросов через 50 конн.
Пул соединений: один процесс на много запросов

Автовакуум: фоновый уборщик, который незаметно убивает базу

Postgres не удаляет строки физически сразу — он помечает их как «мёртвые». Автовакуум периодически чистит этот мусор. На высоконагруженных таблицах стандартные настройки часто слишком консервативны, и база начинает раздуваться, занимать лишний диск и тормозить.

FOR UPDATE SKIP LOCKED: паттерн для очередей

Если воркеры берут задачи из таблицы, обычный SELECT ... FOR UPDATE может создать взаимную блокировку. SKIP LOCKED позволяет пропустить уже заблокированную строку и взять следующую, что делает паттерн удобным для очередей.

Партиционирование: разделяй и властвуй

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

Выводы

Главная мысль простая: Postgres не прощает невнимательность, но прощает ошибки, если вы понимаете, как он работает. Разница между упавшей базой в 3 часа ночи и просто медленным запросом — несколько часов изучения базы и правильные решения с первой недели.

Практический совет: поставьте себе pg_stat_statements и раз в месяц смотрите топ-10 медленных запросов. Это займёт немного времени, но сэкономит целые выходные.

Ссылки

Дмитрий Полухин — продуктовый дизайнер. Пишу про разработку, AI и дизайн интерфейсов. Обо мне, контакты и профили.