Автор из Hatchet обобщил два года боёв с Postgres в один внутренний документ — и выложил его публично. Всё начинается с базы: схемы менять тяжелее всего после деплоя, поэтому лучше строить их итеративно. Правила простые: identity-колонки (чуть быстрее bigserial) или встроенные UUID, всегда timestamptz, всегда первичные ключи, внешние ключи с каскадным удалением для таблиц с низким объёмом.
SELECT в Postgres либо находит одну строку за log(n) по индексу (по умолчанию btree), либо делает последовательное сканирование — seq scan. Индексы — это просто ещё одна таблица, оптимизированная под поиск. Первый медленный запрос обычно — список по большой таблице; тут помогает compound index, где ORDER BY колонки ставятся последними.
Для успешных записей нужно: держать транзакции короткими, не ходить во внешние сервисы внутри транзакции, блокировать минимум строк. CREATE INDEX без CONCURRENTLY блокирует все записи — всегда используйте CREATE INDEX CONCURRENTLY. Миграции старайтесь делать аддитивными и в транзакции; операции с ALTER TABLE заслуживают второго взгляда.
Соединения дорогие (CPU, память), поэтому pgbouncer — отличная вещь. Если внешний пулер недоступен, используйте in-memory, как pgxpool в Go у Hatchet.
Когда простых индексов не хватает, в дело вступает query planner — “leaky abstraction”, похожий на LLM. Он опирается на статистику таблиц, которую собирает ANALYZE. Если запрос медленный — EXPLAIN ANALYZE в JSON, затем визуализация на explain.dalibo.com. Иногда planner всё равно выбирает seq scan, потому что индексный поиск может быть дороже из-за чтения heap.
Для высокой пропускной способности — батчинг: pgx оправляет SendBatch; это даёт ~10× прироста. Autovacuum чистит dead tuples — старые версии строк после обновлений/удалений. Если он не успевает, грозит transaction id wraparound с длительным простоем. Блоат таблиц лечат pg_repack (в Postgres 19 ожидается REPACK ... CONCURRENTLY), блоат индексов — REINDEX INDEX CONCURRENTLY.
Из продвинутого: SELECT ... FOR UPDATE резервирует строки для транзакции — полезно в очередях и управлении лизами. Партиционирование по таймстемпам или хэшам ускоряет авто-вакуум и мгновенно удаляет старые данные (просто дропнуть партицию). Наконец, миграция больших таблиц: не делать это в одной транзакции — долгая блокировка ломает autovacuum. Решение — батчевый backfill с триггерами, чтобы новые записи параллельно шли в новую таблицу.