Все уроки
ДанныеСредний12 мин чтенияВ моём стеке

PostgreSQL

JSONB, CTE, строгие ограничения, MVCC, row-level security и Prisma в продакшене.

  • PostgreSQL 17
  • JSONB
  • Prisma
  • Row-level security
Шаг 1/6
Запрос: найти пользователя с id = 42. Индекса нет, поэтому база читает таблицу строку за строкой. Уже потеет.

За 30 секунд

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

Почему я это использую

  • Я использую PostgreSQL для сложных продуктов, потому что строгие ограничения, транзакционный DDL и богатый SQL позволяют базе защищать целостность данных, а не просто хранить их.
  • Мне нравится, что JSONB даёт гибкие поля там, где они нужны, не отказываясь от реляционного моделирования для всего остального.
  • Я выбрал её вместе с Prisma для крупного мультитенантного SaaS, где строго типизированная схема и SQL-миграции, прошедшие ревью, помогают управлять большим доменом.
  • Её возможности для мультитенантности, такие как row-level security и частичные индексы, дают вторую линию защиты помимо кода приложения.

Как это работает

01Почему Postgres: возможности, которые важны

PostgreSQL близко следует стандарту SQL и добавляет к нему очень многое. Ограничения строгие: CHECK, внешние ключи, проверку которых можно отложить до коммита, и ограничения EXCLUDE, которые могут запретить пересекающиеся временные диапазоны, например двойные бронирования. DDL транзакционный, так что неудачная миграция полностью откатывается. Общие табличные выражения (запросы WITH), включая рекурсивные для деревьев, сохраняют сложные запросы читаемыми. Оконные функции, upsert (INSERT ... ON CONFLICT), RETURNING, массивы, диапазоны, enum и генерируемые столбцы — всё встроено. Расширения добавляют целые возможности: pg_trgm для нечёткого поиска, PostGIS для карт, pgvector для эмбеддингов, pg_stat_statements для статистики запросов. PostgreSQL 18 (сентябрь 2025) добавил асинхронный I/O и нативную функцию uuidv7().

sql
-- recursive CTE: all subcategories of category 1
WITH RECURSIVE tree AS (
  SELECT id, parent_id, name FROM categories WHERE id = 1
  UNION ALL
  SELECT c.id, c.parent_id, c.name
  FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
 
-- forbid overlapping bookings for the same room
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (room_id WITH =, period WITH &&);

02JSONB правильно

JSONB хранит JSON в разобранном бинарном формате, который можно индексировать и запрашивать. Операторы вроде -> и ->> читают поля, @> проверяет вхождение, а jsonb_path_query поддерживает JSONPath. Это идеально для действительно переменных данных: атрибутов товаров, которые отличаются по категориям, payload'ов интеграций, настроек или блоков CMS. И плохо подходит для данных, по которым вы часто фильтруете, делаете join или накладываете ограничения; им место в настоящих столбцах с типами и внешними ключами. Практичный паттерн — держать основные поля реляционными, а длинный хвост класть в столбец JSONB с индексом GIN. Если какое-то поле JSON становится важным, превратите его в генерируемый или настоящий столбец. Помните, что обновление любой части значения JSONB перезаписывает всё значение целиком.

sql
ALTER TABLE products ADD COLUMN attrs jsonb NOT NULL DEFAULT '{}';
CREATE INDEX products_attrs_gin ON products USING gin (attrs jsonb_path_ops);
 
-- all products with colour "black" and size "M"
SELECT id, name
FROM products
WHERE attrs @> '{"colour": "black", "size": "M"}';

03Индексы: B-tree, GIN и другие

B-tree — индекс по умолчанию, он работает с равенством, диапазонами и сортировкой; используйте его для id, внешних ключей, дат и большинства столбцов. GIN (generalised inverted index) индексирует элементы внутри значения, поэтому подходит для проверки вхождения в JSONB, массивов и полнотекстового поиска; он больше и медленнее обновляется, чем B-tree. GiST поддерживает диапазоны, геометрию и exclusion constraints. BRIN крошечный и работает для огромных, естественно упорядоченных таблиц вроде логов. Ещё в Postgres есть частичные индексы (только строки, подходящие под условие WHERE, например активные), индексы по выражениям (по lower(email)) и столбцы INCLUDE для покрывающих индексов. На живых таблицах стройте индексы через CREATE INDEX CONCURRENTLY, а планы проверяйте через EXPLAIN (ANALYZE, BUFFERS).

  • B-tree: равенство, диапазоны, ORDER BY.
  • GIN: JSONB, массивы, полнотекстовый и триграммный поиск.
  • GiST: диапазоны, геометрия, exclusion constraints.
  • Частичные индексы и индексы по выражениям: меньше и точнее.

04MVCC, VACUUM и соединения

PostgreSQL реализует MVCC (многоверсионное управление конкурентным доступом), никогда не обновляя строки на месте. UPDATE записывает новую версию строки и помечает старую как мёртвую; каждая транзакция видит версии, которые были видимы в момент создания её снимка. Читатели никогда не блокируют писателей. Цена — уборка: мёртвые строки остаются, пока VACUUM их не удалит, и autovacuum делает это в фоне. Если autovacuum отстаёт, таблицы раздуваются, а запросы замедляются; долгие транзакции ухудшают ситуацию, потому что мешают уборке. VACUUM также предотвращает wraparound идентификаторов транзакций, поэтому никогда его не отключайте. Уровень изоляции по умолчанию — READ COMMITTED. Каждое соединение — отдельный серверный процесс, поэтому используйте пулер вроде PgBouncer в режиме transaction, когда подключается много экземпляров приложения или serverless-функций.

  • Следите за мёртвыми кортежами и autovacuum в pg_stat_user_tables.
  • Избегайте долгих сессий в состоянии idle-in-transaction; задайте idle_in_transaction_session_timeout.
  • Используйте пул соединений; сотни прямых соединений впустую тратят память.

05Row-level security для мультитенантности

Row-level security (RLS) позволяет базе фильтровать строки для каждого запроса. Вы включаете её на таблице и пишете политику, например «строка видна, только если её company_id равен текущей настройке тенанта». Приложение выставляет эту настройку в начале каждой транзакции, и тогда даже запрос, в котором забыли WHERE, не увидит данные другого тенанта. Важные детали: владельцы таблиц и суперпользователи обходят RLS, если не использовать FORCE ROW LEVEL SECURITY и не подключаться под отдельной ролью, которая не является владельцем; при пуле соединений выставляйте тенанта через set_config(..., true), чтобы настройка была локальной для транзакции; и проиндексируйте столбец тенанта, потому что теперь каждый запрос по нему фильтруется. RLS лучше всего работает как второй слой поверх явных фильтров по тенанту в приложении.

sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON orders
  USING (company_id = current_setting('app.company_id')::uuid)
  WITH CHECK (company_id = current_setting('app.company_id')::uuid);
 
-- per request, inside a transaction:
BEGIN;
SELECT set_config('app.company_id', '0193a1b2-0000-7000-8000-000000000001', true);
SELECT id, status FROM orders;   -- only this tenant's rows
COMMIT;

06Prisma с PostgreSQL

Prisma — ORM для TypeScript: вы описываете модели в schema.prisma, а она генерирует полностью типизированный клиент и SQL-миграции. В разработке prisma migrate dev сравнивает схему с базой, пишет файл миграции и применяет его. В CI и продакшене запускайте prisma migrate deploy, который применяет только закоммиченные миграции и никогда не сбрасывает данные. Используйте --create-only, чтобы сгенерировать миграцию и отредактировать SQL перед применением, — так добавляют то, что схема Prisma не может полностью выразить, например политики RLS, ограничения CHECK и exclusion или CREATE INDEX CONCURRENTLY. Client extensions могут добавлять сквозные правила, например автоматически ограничивать каждый запрос тенантом. Prisma 7 перешла на клиент без Rust с driver adapters и файлом prisma.config.ts, так что при переходе с версии 6 сверьтесь с руководством по обновлению.

prisma
model Order {
  id         String   @id @default(uuid(7)) @db.Uuid
  companyId  String   @db.Uuid
  status     String
  totalMinor BigInt
  createdAt  DateTime @default(now()) @db.Timestamptz
  items      OrderItem[]
 
  @@index([companyId, createdAt(sort: Desc)])
}
 
// npx prisma migrate dev --name add_orders --create-only
// review/edit SQL, then: npx prisma migrate dev
// production:            npx prisma migrate deploy

07Бэкапы и Postgres vs MySQL

pg_dump делает логический бэкап одной базы; custom-формат (-Fc) сжат и позволяет pg_restore восстанавливать выборочно и параллельно. Для больших систем и восстановления на момент времени сочетайте физический базовый бэкап (pg_basebackup) с непрерывным архивированием WAL, в идеале через инструмент вроде pgBackRest или WAL-G, который берёт на себя хранение по сроку, сжатие и хранение вне площадки. Регулярно проверяйте восстановление. Выбор между Postgres и MySQL: обе отлично подходят для стандартных веб-приложений. Я склоняюсь к Postgres для сложных доменов, строгой целостности данных, JSONB, продвинутого SQL, мультитенантности с RLS или когда нужны расширения вроде PostGIS или pgvector. Я остаюсь на MySQL или MariaDB, когда хостинг, команда или экосистема фреймворка построены вокруг неё, а нагрузка — простые чтения и записи.

bash
# logical backup, compressed custom format
pg_dump -Fc -U backup_user -h localhost -d shop -f shop-$(date +%F).dump
 
# restore into a fresh database with 4 parallel jobs
createdb -U postgres shop_restore_test
pg_restore -U postgres -d shop_restore_test -j 4 shop-2026-11-05.dump

Когда применять

  • Сложные домены с множеством связей и строгими правилами целостности.
  • Мультитенантный SaaS, где RLS и частичные индексы помогают с изоляцией.
  • Смешанные данные: реляционное ядро плюс JSONB, полнотекстовый, геопространственный или векторный поиск.
  • TypeScript-бэкенды с Prisma, Drizzle или другой типизированной ORM.

Когда не стоит

  • Хостинги или команды, полностью построенные вокруг MySQL, где переход добавляет риск без явной пользы.
  • Нагрузки с огромным количеством короткоживущих соединений и без пулера.
  • Чистая аналитика на очень больших наборах данных, где лучше подходит колоночное хранилище.

Альтернативы

  • MySQL / MariaDB

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

  • Managed Postgres (RDS, Neon, Supabase)

    Бэкапы, реплики и пул соединений берёт на себя провайдер; Supabase ещё и открывает RLS напрямую клиентам.

  • SQLite

    Встраиваемая база без эксплуатации; идеальна для локальных, офлайн- и edge-сценариев.

Лучшие практики

  1. Позвольте базе обеспечивать правила через ограничения, включая CHECK и EXCLUDE.
  2. Используйте timestamptz для моментов времени и NUMERIC или bigint в минорных единицах для денег.
  3. Используйте JSONB только для переменных данных, с индексом GIN, если вы по ним запрашиваете.
  4. Создавайте индексы CONCURRENTLY на живых таблицах и задавайте lock timeout в миграциях.
  5. Следите, чтобы autovacuum был здоров, и избегайте долгих транзакций.
  6. Используйте пулер соединений и держите настройки тенанта локальными для транзакции.
  7. Запускайте prisma migrate deploy в продакшене и проверяйте сгенерированный SQL.

Частые ошибки

  • Складывать основные реляционные данные в один большой столбец JSONB.
  • Считать, что RLS вас защищает, подключаясь при этом как владелец таблицы или суперпользователь.
  • Запускать prisma migrate dev или db push на продакшене.
  • Игнорировать предупреждения autovacuum о раздувании и wraparound.
  • Открывать сотни прямых соединений из serverless-функций без пула.

Глубже

08Чтение планов: EXPLAIN, статистика и оценки

Планировщик выбирает план по оценкам количества строк, и большинство плохих планов происходит от плохих оценок. EXPLAIN (ANALYZE, BUFFERS) выполняет запрос и показывает для каждого узла оценку строк против фактических строк, время и сколько страниц пришло из shared buffers (hit), а сколько с диска (read); начиная с PostgreSQL 18 BUFFERS включается автоматически вместе с ANALYZE. Когда оценки расходятся в десять раз и больше, планировщик может выбрать nested loop там, где нужен был hash join. Оценки берутся из статистики, которую собирает ANALYZE (его запускает autovacuum), но она предполагает, что колонки независимы. Для коррелированных колонок, например города и почтового индекса или тенанта и статуса, создавайте extended statistics, чтобы планировщик знал о связи. Для колонок с перекосом поднимайте statistics target на этой колонке. Prepared statements добавляют нюанс: после пяти выполнений Postgres может переключиться на generic plan, который игнорирует значения параметров, и это плохо, когда у одного тенанта миллионы строк, а у других десять; plan_cache_mode позволяет это контролировать.

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE company_id = $1 AND status = 'open';
 
-- planner thinks company and status are independent; teach it otherwise
CREATE STATISTICS st_orders_company_status (dependencies, mcv)
  ON company_id, status FROM orders;
ALTER TABLE orders ALTER COLUMN company_id SET STATISTICS 1000;
ANALYZE orders;
 
-- top queries by total time (needs pg_stat_statements)
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

09Блокировки и DDL без простоя

Большинство форм ALTER TABLE берут блокировку ACCESS EXCLUSIVE, которая конфликтует со всем, включая обычные SELECT. Сама блокировка может держаться всего миллисекунды, но настоящая опасность — очередь: если длинная транзакция держит любую блокировку на таблице, ALTER ждёт, а каждый запрос, пришедший после него, встаёт в очередь за ALTER. Приложение замирает, хотя формально ничего не медленное. Решение — задавать в миграциях lock_timeout в несколько секунд и повторять, чтобы заблокированный ALTER сдавался, а не останавливал трафик. Знайте, какие операции дешёвые. Добавление колонки с константным значением по умолчанию начиная с PostgreSQL 11 меняет только метаданные. Добавление foreign key или CHECK-ограничения можно разделить: добавить его с NOT VALID, что быстро, а затем выполнить VALIDATE CONSTRAINT, который сканирует таблицу с более слабой блокировкой, разрешающей чтение и запись. SET NOT NULL может пропустить сканирование таблицы, если уже есть валидированный CHECK (col IS NOT NULL). Смена типа колонки обычно переписывает таблицу, поэтому лучше новая колонка и пакетный backfill.

sql
SET lock_timeout = '5s';
 
-- foreign key without blocking writes for the whole scan
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;
 
-- NOT NULL without a long exclusive scan
ALTER TABLE orders ADD CONSTRAINT orders_currency_nn CHECK (currency IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn;
ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;   -- uses the check, no scan
ALTER TABLE orders DROP CONSTRAINT orders_currency_nn;

10Режимы пула соединений и их побочные эффекты

Каждое соединение PostgreSQL — это серверный процесс со своей памятью, а память для сортировок и хэшей (work_mem) выделяется на каждую операцию, поэтому один сложный запрос может использовать её в несколько раз больше. Сотни прямых соединений, таким образом, тратят память и добавляют конкуренцию; обычно оптимальны несколько десятков активных соединений. PgBouncer в режиме transaction позволяет тысячам клиентов делить маленький пул, выдавая серверное соединение только на время транзакции. Это ломает всё, что опирается на состояние сессии между транзакциями: SET без LOCAL, advisory locks уровня сессии, LISTEN и NOTIFY, временные таблицы и, в старых версиях PgBouncer, серверные prepared statements (версия 1.21 добавила поддержку prepared statements на уровне протокола через max_prepared_statements). Именно поэтому настройки тенанта для row-level security должны быть локальными для транзакции. Миграции схемы запускайте через прямое соединение, а не через transaction pooler, потому что инструменты миграций, включая Prisma Migrate, опираются на advisory locks уровня сессии. Serverless-платформы делают пул обязательным, ведь каждый экземпляр функции открывает свои соединения.

  • Размер пула выбирайте по ядрам и скорости хранилища, а не по числу экземпляров приложения.
  • С transaction pooler используйте внутри транзакций set_config(name, value, true) или SET LOCAL.
  • Держите отдельную строку прямого подключения для миграций и обслуживания.

11Репликация, failover и мажорные обновления

Streaming replication передаёт журнал упреждающей записи (WAL) на standby-серверы, которые его проигрывают, и даёт побайтно идентичную копию для чтений и failover. С synchronous_standby_names и synchronous_commit можно требовать, чтобы standby подтверждал каждый коммит: задержка в обмен на нулевую потерю данных при failover. Сам Postgres не выбирает новый primary; проверки здоровья, повышение и fencing берут на себя инструменты вроде Patroni или managed-сервис. Логическая репликация передаёт изменения строк по таблицам, а не байты WAL, поэтому работает между мажорными версиями и может копировать выбранные таблицы — это стандартный путь для обновлений и миграций почти без простоя; она не реплицирует DDL и значения sequence, так что их вы обрабатываете сами. Replication slots заставляют primary хранить WAL, пока потребитель его не получит; брошенный slot заполняет диск, поэтому задайте max_slot_wal_keep_size и настройте алерт на неактивные slots. Для обновления на месте pg_upgrade --link занимает минуты даже для больших баз, а начиная с PostgreSQL 18 сохраняет статистику планировщика, так что производительность нормальная сразу после обновления.

sql
-- is any slot holding back WAL?
SELECT slot_name, slot_type, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
 
-- replication lag per standby (run on the primary)
SELECT application_name, state, replay_lag FROM pg_stat_replication;

Реальные ситуации

01

Двухсекундная миграция заморозила приложение на две минуты

Проблема

Миграция добавляла колонку через ALTER TABLE orders. Обычно это занимает миллисекунды, но на этот раз каждый запрос к orders висел, пока деплой не упал по таймауту.

Как решить

ALTER ждал блокировку ACCESS EXCLUSIVE за долгой транзакцией, а все последующие запросы встали в очередь за ALTER. Подтвердите через pg_stat_activity и pg_blocking_pids(): ищите сессии в состоянии «idle in transaction» или долгий отчётный запрос. Восстановитесь, отменив блокирующего через pg_cancel_backend() или завершив его через pg_terminate_backend(). Чтобы не повторилось: запускайте миграции с SET lock_timeout = '5s' и повторяйте с backoff, задайте idle_in_transaction_session_timeout, чтобы забытые сессии закрывались сами, держите отчётные запросы на реплике и настройте алерт на транзакции старше нескольких минут. Разбивайте рискованные изменения на шаги NOT VALID и VALIDATE, чтобы долгие сканирования никогда не держали сильных блокировок.

02

Диск primary заполняется без видимой причины

Проблема

Использование диска на primary растёт на гигабайты в день, хотя размеры таблиц стабильны. Каталог pg_wal огромный.

Как решить

Проверьте pg_replication_slots на неактивный slot: удалённая реплика или остановившийся CDC-коннектор оставляют slot, который заставляет primary хранить каждый сегмент WAL с того момента. Если потребитель ушёл навсегда, удалите slot через pg_drop_replication_slot(). Проверьте также, что архивирование WAL проходит успешно, ведь неудачные запуски archive_command тоже удерживают WAL; pg_stat_archiver показывает ошибки. Затем задайте max_slot_wal_keep_size, чтобы застрявший slot инвалидировался раньше, чем заполнится диск, и добавьте алерты на неактивные slots и заполнение диска выше 80 процентов. Если с WAL всё в порядке, посмотрите на bloat таблиц и индексов из-за длинных транзакций, мешающих vacuum, по счётчикам мёртвых кортежей в pg_stat_user_tables.

03

Запрос быстрый на staging и медленный в продакшене

Проблема

Запрос дашборда тенанта занимает 20 мс на staging, но 4 секунды для одного крупного клиента в продакшене. Та же схема, те же индексы.

Как решить

Сравните EXPLAIN (ANALYZE, BUFFERS) для этого конкретного тенанта в продакшене и на staging. Типичные находки: оценки строк сильно ошибаются, потому что company_id и status коррелируют, или данные перекошены так, что одному тенанту принадлежит большинство строк; и prepared statement, переключившийся на generic plan, настроенный под маленьких тенантов. Исправьте оценки через CREATE STATISTICS на коррелированных колонках, повышенный statistics target для колонки тенанта и свежий ANALYZE. Если проблема в generic plans, задайте plan_cache_mode = force_custom_plan для этой роли или пути запроса. Добавьте составной индекс, начинающийся с company_id, который соответствует фильтру и сортировке. Держите auto_explain включённым для медленных запросов, чтобы в будущих случаях планы уже были в логах.

Шпаргалка

Топ запросов по суммарному времени
SELECT calls, total_exec_time, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
Реальный план с деталями I/O
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
Заставить DDL сдаваться, а не ждать в очереди
SET lock_timeout = '5s';
Кто заблокирован и кем
SELECT pid, pg_blocking_pids(pid), state, query FROM pg_stat_activity WHERE cardinality(pg_blocking_pids(pid)) > 0;
Остановить запрос или сессию
SELECT pg_cancel_backend(pid); / SELECT pg_terminate_backend(pid);
Добавить ограничение без долгой блокировки
ALTER TABLE t ADD CONSTRAINT c ... NOT VALID; ALTER TABLE t VALIDATE CONSTRAINT c;
Рассказать планировщику о коррелированных колонках
CREATE STATISTICS s ON city, zip FROM addresses; ANALYZE addresses;
Самые большие таблицы вместе с индексами
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;
Закрывать забытые открытые транзакции
ALTER ROLE app SET idle_in_transaction_session_timeout = '60s';
Ограничить WAL, удерживаемый replication slots
max_slot_wal_keep_size = '50GB'

В моих реальных проектах

  • Я был одним из руководителей разработки мультитенантного POS SaaS для ресторанов на NestJS, Prisma 6 и PostgreSQL со 181 моделью Prisma в схеме примерно на 4,800 строк.
  • В этой системе изоляция тенантов обеспечивается дважды: явный company id в каждом запросе плюс расширение Prisma, которое выбрасывает ошибку, если scope отсутствует, с исключениями из allow-list и тест-кейсом «чужой тенант» в тестах каждого модуля.
  • SaaS работает на выделенном сервере с Docker Compose, PostgreSQL, Redis и воркерами BullMQ, со скриптами бэкапов и runbook'ами рядом со стеком observability.
  • Его офлайн-хаб для филиалов использует локальную базу SQLite с версионированными миграциями, закреплёнными по контрольной сумме, так что филиалы продолжают продавать, когда пропадает интернет.

Вопросы и ответы

Q1Почему вы бы выбрали PostgreSQL, а не MySQL?

Для сложных доменов я ценю её корректность и выразительность. Ограничения богаче, включая CHECK, откладываемые внешние ключи и exclusion constraints для вещей вроде пересекающихся бронирований. DDL транзакционный, поэтому неудачные миграции чисто откатываются. JSONB с индексами GIN хорошо справляется с гибкими данными, а CTE, оконные функции и upsert делают сложные запросы чистыми. Row-level security, частичные индексы и индексы по выражениям, а также расширения вроде PostGIS, pg_trgm и pgvector добавляют возможности без ещё одной системы. MySQL по-прежнему отлична для более простых веб-нагрузок и шире поддерживается shared-хостингом, так что стандартный Laravel-сайт на существующей MySQL-инфраструктуре я бы не стал переводить просто ради перехода.

Q2Объясните MVCC в PostgreSQL и почему важен VACUUM.

MVCC означает, что каждая транзакция видит согласованный снимок данных без блокировки читателей. Postgres добивается этого, храня несколько версий строки: UPDATE вставляет новую версию и помечает старую как мёртвую, а видимость определяется по идентификаторам транзакций, хранящимся в каждой версии. В результате чтения никогда не блокируют записи. Цена в том, что мёртвые версии остаются на диске, пока VACUUM не освободит место. Autovacuum делает это автоматически, а также замораживает старые строки, чтобы предотвратить wraparound идентификаторов транзакций. Если он отстаёт — часто из-за долгих сессий или сессий в состоянии idle-in-transaction, — таблицы раздуваются и производительность падает, поэтому я за ним слежу.

Q3Когда вы используете индекс GIN вместо B-tree?

Когда значение содержит много частей, по которым идёт поиск. B-tree индексирует значение столбца целиком в отсортированном порядке, что идеально для равенства, диапазонов и сортировки по скалярам вроде id, дат и email. Индекс GIN инвертированный: он сопоставляет каждый элемент внутри значения со строками, которые его содержат, поэтому подходит для запросов на вхождение в JSONB через @>, пересечения массивов, полнотекстового поиска по tsvector и нечёткого триграммного поиска через pg_trgm. Индексы GIN больше и медленнее обновляются, поэтому я добавляю их только к столбцам, которые реально так запрашиваются, а для JSONB часто использую jsonb_path_ops — он меньше, когда нужна только проверка вхождения.

Q4Как работает row-level security и какие в ней подводные камни?

Вы включаете RLS на таблице и задаёте политики: USING — какие строки можно читать, WITH CHECK — какие строки можно записывать. Политики обычно сравнивают столбец тенанта с настройкой сессии, которую приложение выставляет на каждую транзакцию. Подводные камни: суперпользователи и роли с BYPASSRLS игнорируют политики, владельцы таблиц тоже, если не задан FORCE ROW LEVEL SECURITY, поэтому приложение должно подключаться под ролью, которая не является владельцем. С пулерами вроде PgBouncer настройки должны быть локальными для транзакции, иначе настройка одного тенанта может утечь в следующий запрос. Политики добавляют фильтр к каждому запросу, поэтому столбцу тенанта нужны индексы. Я рассматриваю RLS как эшелонированную защиту, а не как замену проверкам на уровне приложения.

Q5Как безопасно применять миграции Prisma в продакшене?

Миграции создаются в разработке через prisma migrate dev, проходят ревью как SQL в pull request и коммитятся. Продакшен и staging всегда запускают только prisma migrate deploy, обычно как шаг релиза перед стартом нового кода, и он применяет ожидающие миграции, ничего не сбрасывая. Для рискованных изменений я использую --create-only и редактирую SQL: добавляю столбцы как nullable, заполняю данные пачками, создаю индексы конкурентно — это должно выполняться вне транзакции — и добавляю ограничения, которые Prisma не умеет выражать, например политики RLS или частичные уникальные индексы. Я никогда не использую db push или migrate reset вне локальной разработки, а деструктивные изменения выходят в более позднем релизе, чем код, который перестаёт использовать столбец.

Q6Как вы делаете бэкапы PostgreSQL?

Зависит от размера и целей восстановления. Для маленьких и средних баз ночной pg_dump в custom-формате прост и позволяет восстанавливать отдельные таблицы через pg_restore. Для продакшен-систем, которым нужно восстановление на момент времени, я использую физический базовый бэкап плюс непрерывное архивирование WAL под управлением инструмента вроде pgBackRest или WAL-G, который берёт на себя сжатие, шифрование, хранение по сроку и хранение вне площадки. Managed-сервисы делают это за вас. Каким бы ни был метод, бэкапы должны храниться вне сервера базы данных, а я планирую регулярные тестовые восстановления в отдельный экземпляр и замеряю, сколько они длятся, потому что это и есть то время восстановления, которое бизнес получит на самом деле.

Вопросы уровня senior

S1Как бы вы обновили нагруженную базу PostgreSQL до новой мажорной версии с минимальным простоем?

Есть два основных пути. pg_upgrade с --link простой и быстрый, обычно минуты, потому что переиспользует файлы данных, но во время работы база недоступна, а откат после запуска нового кластера означает восстановление из бэкапа, поэтому я делаю свежий бэкап и сначала репетирую на копии. Начиная с PostgreSQL 18 статистика планировщика переживает обновление, и это убирает медленный период, который раньше за ним следовал. Для почти нулевого простоя я использую логическую репликацию: поднимаю новый кластер на новой версии, копирую схему, подписываюсь на все таблицы, жду, пока он догонит, и переключаю приложение во время короткой паузы записи. Компромиссы — больше работы и пробелы, которые нужно закрыть самому: DDL не реплицируется, sequence нужно продвинуть вручную до переключения, а таблицам нужны первичные ключи или replica identity. Managed-сервисы часто оборачивают второй подход в blue-green deployments.

S2Postgres может быть очередью, поисковым движком и векторным хранилищем. Когда им быть не стоит?

Использовать Postgres для смежных задач часто правильно: это убирает лишнюю систему из эксплуатации и держит транзакции в одном месте. Очередь задач на SELECT ... FOR UPDATE SKIP LOCKED хорошо работает до тысяч задач в секунду, а задача ставится в очередь атомарно вместе с бизнес-изменением. Полнотекстовый поиск с tsvector и pg_trgm покрывает поиск по сайту с фильтрами; на отдельный движок я перехожу, когда нужны устойчивость к опечаткам, тонкая настройка релевантности, много фасетов или много языков на масштабе. pgvector справляется с миллионами эмбеддингов на индексах HNSW, особенно когда векторы нужно фильтровать по тенанту или правам доступа. Специализированные инструменты выигрывают, когда масштаб или возможности нагрузки становятся главными: очень высоконагруженные потоки событий, аналитика по миллиардам строк (колоночные хранилища) или поиск как ключевой продукт. Проверка простая: начинает ли дополнительная нагрузка мешать главной работе primary — транзакционной надёжности.

S3Как вы проектируете PostgreSQL под высокую скорость записи, например приём событий или телеметрии?

Сначала уменьшаю работу на строку. Пакетные вставки через многострочный INSERT или COPY вместо одной транзакции на событие. Минимум индексов на горячей таблице, ведь каждый индекс умножает стоимость записи; BRIN-индекс по timestamp крошечный и подходит для append-only данных. Партиционирование по времени, чтобы индексы оставались маленькими, а retention был дешёвым удалением партиции вместо DELETE и vacuum. Для таблиц с частыми обновлениями пониженный fillfactor оставляет место для HOT-обновлений, которые не трогают индексы. Настройка чекпоинтов с большим max_wal_size, чтобы сервер не сбрасывал данные постоянно. Для данных, которые переживут потерю последних мгновений при сбое, synchronous_commit = off на уровне сессии даёт большое ускорение без риска повреждения; unlogged-таблицы идут дальше, но очищаются после сбоя. Если сырой объём продолжает расти, я агрегирую данные в rollup-таблицы или переношу аналитику в колоночное хранилище.

Глоссарий

WAL
Write-ahead log: каждое изменение сначала пишется сюда; используется для восстановления после сбоя, репликации и point-in-time restore.
ACCESS EXCLUSIVE lock
Самая сильная блокировка таблицы; блокирует все чтения и записи и берётся большинством форм ALTER TABLE.
Очередь блокировок
Ожидающие запросы блокировок по порядку; ожидающий ALTER заставляет ждать и все последующие запросы к таблице.
NOT VALID constraint
Ограничение, которое проверяется только для новых строк и валидируется позже с более слабой блокировкой, не мешающей трафику.
Extended statistics
Статистика планировщика по нескольким колонкам, создаваемая через CREATE STATISTICS, чтобы исправить оценки для коррелированных данных.
Generic plan
Кэшированный план prepared statement, игнорирующий фактические значения параметров; может быть плохим для перекошенных данных.
Replication slot
Маркер, заставляющий primary хранить WAL, пока конкретный потребитель его не получит.
Логическая репликация
Репликация изменений строк по таблицам, а не байтов WAL; работает между мажорными версиями.
Patroni
Open-source инструмент для высокой доступности PostgreSQL: выбор лидера, failover и настройка реплик.
pg_upgrade
Инструмент мажорного обновления на месте; с --link переиспользует файлы данных и завершается быстро.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.