Postgres в стартапе: как не уронить базу в первый год
Знаете, что объединяет большинство стартапов, у которых «внезапно» падает прод? Postgres. Не потому что Postgres плохой — он отличный. Просто в стартапе все бегут, и никто не закладывает время на то, чтобы разобраться, как эта база работает на уровне, большем чем «создал модельку, добавил миграцию, поехали».
Автор статьи — Александр Белэнжер из Hatchet — написал внутренний документ для своей команды, где собрал два года боёв с Postgres. Подход здесь не академический, а практический: конкретные грабли и способы их обойти. Разберёмся по порядку.
Первое, что ломается: ты не понимаешь, как postgres ищет данные
У Postgres при SELECT-запросе по сути есть два пути: найти нужную строку по индексу или прочитать всю таблицу целиком. На маленьких таблицах разницу почти не видно, но на больших — запрос внезапно начинает тормозить.
ПОИСК СТРОКИ В POSTGRES
────────────────────────
Таблица с 1М строк
│
├─ По индексу (btree): O(log n) ≈ 20 операций ✓ Быстро
│
├─ Seq scan: Читаем все 1М строк ✗ Медленно
│
└─ Правило: Ставь индекс на фильтры
в WHERE и ORDER BY
Индекс — это отдельная структура данных, обычно btree, которая хранит отсортированные значения и ссылки на строки. Поиск по btree занимает примерно log(n) операций, где n — количество строк. Seq scan — это чтение всех строк подряд.
Схема данных: потратьте время заранее
После деплоя схему данных тяжелее всего менять. Поэтому при проектировании важно заранее понять, какие таблицы будут часто читаться, какие — часто писаться, по каким полям будет фильтрация и какие колонки будут обновляться чаще всего.
- Используйте identity-колонки или встроенные UUID для первичных ключей
- Всегда ставьте
timestamptzвместоtimestamp - Всегда добавляйте первичный ключ
- Каскадные внешние ключи — ок для небольших таблиц, но на больших объёмах с ними нужно быть аккуратнее
Автор честно отмечает: нормализация бывает в конфликте с удобством и скоростью разработки. Иногда проще положить часть данных в 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 есть несколько правил, которые часто игнорируют до первого инцидента. Главное — не держать транзакции открытыми дольше необходимого и не делать внутри них внешние вызовы.
- Держите транзакции короткими — не ходите в внешние сервисы внутри транзакции
- Блокируйте только нужные строки — каждый
UPDATEберёт блокировку до коммита - Не делайте просто
CREATE INDEXна большой таблице — это может заблокировать вставки и обновления
Для больших таблиц используйте CREATE INDEX CONCURRENTLY: индекс создаётся без жёсткой блокировки, но дольше.
Миграции: главный источник простоя
Хорошее владение миграциями — это техническое преимущество. Безопасная стратегия почти всегда одна: сначала добавить новое, потом переключить код, и только после этого удалить старое.
- Делайте миграции добавляющими
- Никогда не удаляйте колонку в той же миграции, где перестаёте её использовать
Управление соединениями: утечка как худший враг
Postgres создаёт процесс для каждого соединения. Если соединений слишком много, память и ресурсы начинают сгорать очень быстро. Поэтому нужен пул соединений: приложение держит меньше соединений к пулу, а пул распределяет их по запросам.
БЕЗ ПУЛА VS С ПУЛОМ СОЕДИНЕНИЙ ─────────────────────────────── Без пула: С пулом (PgBouncer): App → Postgres App → PgBouncer → Postgres │ │ ├─ 100 соединений = 100 процессов ├─ 50 соединений = 50 процессов └─ 1000 соединений = 1000 проц. └─ 1000 запросов через 50 конн.
Автовакуум: фоновый уборщик, который незаметно убивает базу
Postgres не удаляет строки физически сразу — он помечает их как «мёртвые». Автовакуум периодически чистит этот мусор. На высоконагруженных таблицах стандартные настройки часто слишком консервативны, и база начинает раздуваться, занимать лишний диск и тормозить.
- Настройте
autovacuumпод каждую таблицу - Следите за размером таблиц и индексов
- Регулярно проверяйте, что автовакуум действительно работает
FOR UPDATE SKIP LOCKED: паттерн для очередей
Если воркеры берут задачи из таблицы, обычный SELECT ... FOR UPDATE может создать взаимную блокировку. SKIP LOCKED позволяет пропустить уже заблокированную строку и взять следующую, что делает паттерн удобным для очередей.
Партиционирование: разделяй и властвуй
Когда таблица становится очень большой, партиционирование помогает разбить её на части по ключу, например по дате. Тогда запросы, которые фильтруют по этому ключу, работают только с нужным партишеном, а не со всей таблицей.
Выводы
- Индексы — не опция, а базовая защита от медленных запросов
- Транзакции должны быть короткими, а миграции — добавляющими
- Пул соединений и автовакуум нужны с самого начала, а не после инцидента
- Когда данные растут, заранее думайте о партиционировании и составных индексах
Главная мысль простая: Postgres не прощает невнимательность, но прощает ошибки, если вы понимаете, как он работает. Разница между упавшей базой в 3 часа ночи и просто медленным запросом — несколько часов изучения базы и правильные решения с первой недели.
Практический совет: поставьте себе pg_stat_statements и раз в месяц смотрите топ-10 медленных запросов. Это займёт немного времени, но сэкономит целые выходные.
Ссылки
- The startup’s Postgres survival guide — оригинальная статья Александра Белэнжера из Hatchet
- PgBouncer — пул соединений для Postgres
- Supavisor — альтернатива пулу соединений
- pg_stat_statements — расширение для анализа медленных запросов
Дмитрий Полухин — продуктовый дизайнер. Пишу про разработку, AI и дизайн интерфейсов. Обо мне, контакты и профили.