Почему Postgres — и почему это сложнее, чем кажется

PostgreSQL давно занял место базы данных по умолчанию для большинства стартапов. И на то есть веские причины: PostgreSQL является наиболее востребованной базой данных среди профессиональных разработчиков третий год подряд — её используют 55,6% специалистов по данным опроса Stack Overflow 2025 года. OpenAI обслуживает 800 миллионов пользователей ChatGPT на PostgreSQL, а Notion разделяет весь свой бэкенд на 480 баз данных PostgreSQL.

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

Большинство стартапов совершают критические ошибки на этапе MVP: проектируют схемы, которые не масштабируются, пропускают индексы, оставляют дыры в безопасности и выбирают неподходящих хостинг-провайдеров. Когда они достигают 1000 пользователей — или того хуже, 10 000 — проблемы нарастают как снежный ком: медленные запросы, исчерпание пула соединений, повреждение данных и утечки.

Эта статья — практическое руководство по выживанию: что нужно знать и сделать, прежде чем PostgreSQL сломает ваш стартап.


1. Индексы: не только «добавь индекс»

До работы над Hatchet знание Postgres у многих разработчиков сводилось к одному правилу: «если запрос медленный — добавь индекс». Этот гайд начинается именно с этой точки, предполагая знакомство с основами SQL, строками, таблицами и тем, что такое индекс.

Но правда в том, что индексы — это не просто «добавить и забыть». Вот ключевые принципы:

Составные индексы и ORDER BY

В сложных случаях хорошее правило — столбцы ORDER BY должны быть последними в индексе, а их порядок должен совпадать с ORDER BY. Postgres умеет сканировать B-tree в обоих направлениях, поэтому DESC иногда не имеет значения — но для составных индексов это хорошая практика.

-- Пример: запрос с фильтром и сортировкой
SELECT id, name, created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;

-- Правильный составной индекс:
CREATE INDEX idx_orders_user_created
  ON orders (user_id, created_at DESC);

Когда создавать индексы — осторожно с блокировками

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

-- Никогда не делайте так в продакшне на живой таблице:
CREATE INDEX idx_orders_status ON orders (status);

-- Всегда используйте CONCURRENTLY:
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
⚠ Важно
Команда CREATE INDEX без CONCURRENTLY блокирует таблицу на запись на всё время построения индекса. На больших таблицах это могут быть минуты простоя. Всегда используйте CONCURRENTLY в продакшне.

UUID vs. SERIAL: что выбрать для первичного ключа

Правильный дизайн схемы предотвращает проблемы с целостностью данных и делает запросы эффективными с первого дня. В 2025 году стоит рассмотреть UUID v7 для лучшей локальности индексов и улучшенной производительности вставок при сохранении преимуществ UUID.

Тип ключаПлюсыМинусы
SERIAL / BIGSERIALПростота, компактность, быстрые вставкиПредсказуемость ID (риск безопасности)
UUID v4Глобальная уникальностьФрагментация индекса, медленные вставки
UUID v7Уникальность + временной порядокЧуть сложнее генерация

2. Транзакции и блокировки: где прячется настоящая боль

Большинство проблем в продакшн-PostgreSQL связаны не с медленными запросами, а с неправильным управлением транзакциями.

Держите транзакции короткими. Не делайте запросы к внешним сервисам внутри транзакции без веских причин. Будьте осторожны со строками, которые блокируете на запись — блокируйте только то, что нужно.

Каждый раз, когда вы обновляете строку, вы берёте на неё блокировку на короткое время до завершения транзакции.

-- ПЛОХО: длинная транзакция с внешним вызовом
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  -- ... здесь идёт HTTP-запрос к платёжной системе (секунды!) ...
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- ХОРОШО: внешние вызовы вне транзакции
-- Сначала делаем HTTP-запрос, потом атомарно обновляем
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
💡 Правило коротких транзакций
Правило большого пальца: транзакция должна занимать миллисекунды, а не секунды. Если внутри транзакции есть сетевой вызов, HTTP-запрос или ожидание пользовательского ввода — вы строите мину замедленного действия.

Дедлоки и как их избежать

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

Главное правило: всегда обращайтесь к ресурсам в одном и том же порядке во всех транзакциях.

-- Транзакция A и транзакция B должны обновлять счета в одном порядке
-- Всегда: сначала меньший ID, потом больший
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(1, 2);
  UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(1, 2);
COMMIT;

3. Пул соединений: PgBouncer как обязательный элемент

PostgreSQL использует модель «один процесс на соединение» с многоверсионным управлением параллельным доступом (MVCC). Каждое клиентское соединение порождает собственный серверный процесс, и они все разделяют память через несколько критических компонентов.

Это означает, что 500 одновременных соединений = 500 процессов на сервере. При нагрузке это быстро приводит к исчерпанию памяти.

Для решения этой проблемы используйте инструменты вроде PgBouncer или Pgpool-II для эффективного управления соединениями с базой данных.


graph LR
    A[Приложение
100+ коннектов] --> B[PgBouncer
Пул соединений] B --> C[PostgreSQL
10-20 реальных коннектов] B --> D[Режим: Transaction
Session / Statement] style B fill:#f0a500,color:#fff style C fill:#336791,color:#fff

Три режима PgBouncer:

РежимОписаниеКогда использовать
sessionСоединение закреплено за клиентом на сессиюРедко — неэффективен
transactionСоединение выдаётся только на время транзакцииРекомендован для большинства случаев
statementСоединение выдаётся на один запросТолько для простых read-only случаев

На стадии от seed до Series A (до 100 ГБ данных) хорошо настроенный одиночный экземпляр PostgreSQL с PgBouncer справляется со всем. Именно здесь большинство стартапов живут годами. Не нужно усложнять на этом этапе.

ℹ Типичные настройки PgBouncer
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3

4. Партиционирование и масштабирование: когда и как

«Правильный выбор стратегии масштабирования покупает годы роста. Неправильный — тратит месяцы инженерного времени впустую.»

Вопрос для растущей команды не в том, способен ли PostgreSQL справиться с нагрузкой, а в том, какая стратегия масштабирования подходит для конкретного узкого места. Неправильный выбор тратит месяцы инженерных усилий. Правильный — даёт годы роста.

Когда партиционировать таблицы

Начинайте партиционирование, когда отдельная таблица превышает 100 миллионов строк или 50 ГБ.

Партиционирование ускоряет обслуживание: VACUUM работает на отдельных партициях, а не на всей таблице. Удаление старых данных становится мгновенным — DROP TABLE events_2024_01 удаляет целую партицию без построчного удаления.

-- Партиционирование таблицы событий по месяцам
CREATE TABLE events (
  id        BIGSERIAL,
  user_id   BIGINT NOT NULL,
  event_at  TIMESTAMPTZ NOT NULL,
  payload   JSONB
) PARTITION BY RANGE (event_at);

-- Создание партиций
CREATE TABLE events_2025_01
  PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE events_2025_02
  PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Индекс на каждой партиции автоматически
CREATE INDEX ON events (user_id, event_at DESC);

Путь масштабирования PostgreSQL от стартапа до enterprise


timeline
    title Эволюция PostgreSQL-архитектуры
    Seed / MVP : Один сервер PostgreSQL
               : PgBouncer для пула соединений
               : Базовые индексы и мониторинг
    Series A : Read-реплики для чтения
             : Партиционирование больших таблиц
             : Регулярный VACUUM и автовакуум
    Series B+ : Шардирование или Citus
              : Отдельный сервер для аналитики
              : Streaming replication + HA

На стадии Series B и роста (100 ГБ — 1 ТБ) добавляйте 2–5 реплик для чтения при read-heavy нагрузке.

💡 Золотое правило масштабирования
Совет для тех, кто видел десятки безнадёжно переусложнённых решений: не думайте о сложном и держите базу данных простой. Фокусируйтесь на приложении, а не на базе данных — до тех пор, пока проблема реально не возникнет.

5. Безопасность и принцип наименьших привилегий

Безопасность базы данных — тема, которую стартапы откладывают на потом. И зря.

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

SQL-стандарт определяет систему привилегий: каждый объект в Postgres (таблица, строка и т.д.) имеет разные привилегии — SELECT, UPDATE, TRUNCATE, REFERENCES, TRIGGER и другие. Вы предоставляете привилегии пользователям командой GRANT.

-- Создаём роль с минимальными правами для API
CREATE ROLE api_user LOGIN PASSWORD 'strong_password';

-- Только чтение на таблицу пользователей
GRANT SELECT ON users TO api_user;

-- Только вставка и чтение на таблицу заказов
GRANT SELECT, INSERT ON orders TO api_user;

-- Явно запрещаем удаление
REVOKE DELETE ON orders FROM api_user;

Row Level Security (RLS)

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

-- Включаем RLS на таблице
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Политика: пользователь видит только свои заказы
CREATE POLICY orders_isolation ON orders
  USING (user_id = current_setting('app.current_user_id')::bigint);

Мониторинг и резервное копирование

Автоматизируйте резервное копирование, обеспечьте надёжный мониторинг и алертинг. PostgreSQL поддерживает комплексное аварийное восстановление, включая восстановление на момент времени (PITR) и кросс-региональную репликацию.

Компонент защитыИнструментЧастота
Полный бэкапpg_dump / pgBackRestЕжедневно
WAL-архивированиеpgBackRest / BarmanНепрерывно
МониторингpgBadger, Prometheus + pg_exporterПостоянно
Проверка бэкаповТестовое восстановлениеЕженедельно
📝 Проверяйте бэкапы
Бэкап, который никогда не проверялся на восстановление — это не бэкап. Настройте автоматическое тестовое восстановление раз в неделю в изолированной среде. Вы будете удивлены, как часто это выявляет проблемы.

Заключение: что делать прямо сейчас

PostgreSQL — невероятно мощный инструмент, который при правильном использовании масштабируется от MVP до сотен миллионов пользователей. Ключевые темы для управления им: дизайн схемы, оптимизация запросов, управление соединениями и продвинутые возможности вроде партиционирования и планировщика запросов. Правильный подход может значительно повысить эффективность управления базой данных, снизить затраты и улучшить использование ресурсов.

Вот чеклист для стартапа на каждом этапе:

  • Индексы: составные, CONCURRENTLY в продакшне, UUID v7 для новых проектов
  • Транзакции: короткие, без внешних вызовов внутри, соблюдение порядка блокировок
  • Соединения: PgBouncer в режиме transaction обязателен с первого дня
  • Партиционирование: начиная со 100М строк или 50 ГБ на таблицу
  • Безопасность: принцип наименьших привилегий + RLS где нужно
  • Бэкапы: автоматические + еженедельная проверка восстановления
  • Мониторинг: slow query log, автовакуум, использование соединений

С объёмами данных, растущими на 47% год к году, настройка базы данных правильно с первого дня — это не просто хорошая практика, это выживание. База данных — фундамент вашего приложения. Ошибитесь в начале — и будете платить за это вечно.