Привет! Меня зовут Тимур Исламгулов. Я преподаю в МФТИ и веду вебинары по PostgreSQL на курсе «Аналитик данных» в Нетологии. За эти годы я насмотрелся, как разработчики поднимают лишнюю инфраструктуру там, где хватило бы самой базы, — об этом и поговорим.

Возможно, вы уже уверенно используете PostgreSQL каждый день и храните там пользователей, заказы, платежи, статусы, события, настройки. А потом всё как-то резко разрастается: внезапно появляются Redis для очереди, Python-код для дедупликации, Go-цикл для расчёта соседних событий, отдельный поисковик для простого поиска по статьям и самодельная блокировка через таблицу locks.

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

Тимур Исламгулов

Знает, сколько кода можно не писать, если знать PostgreSQL

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

  1. FOR UPDATE SKIP LOCKED — очередь задач без внешнего брокера.

  2. Оконные функции — аналитика и дедупликация без циклов в коде.

  3. DISTINCT ON — первая или последняя строка в каждой группе без громоздких подзапросов.

  4. Generated columns — вычисляемые поля вместо триггеров и ручной синхронизации.

  5. VACUUM и autovacuum — почему таблица пухнет, хотя вы удаляете данные.

  6. LATERAL JOIN — зависимый подзапрос для каждой строки.

  7. Recursive CTE — обход деревьев и иерархий одним запросом.

  8. Advisory locks — координация воркеров и cron-задач на уровне базы.

  9. Full-text search — встроенный поиск по тексту, которого часто хватает без Elasticsearch.

  10. Partial indexes — маленькие и быстрые индексы по тем строкам, которые реально нужны.

Цель статьи — чтобы вы за один вечер нашли хотя бы одну фичу, которую унесли бы завтра в рабочий проект.

1. FOR UPDATE SKIP LOCKED (PostgreSQL 9.5) или очередь задач без Redis и Celery

Бэкендеры часто ставят Redis, Celery, RabbitMQ или отдельный job-сервис даже для простой очереди, где надо взять задачу, обработать и пометить как выполненную. И мы знаем, что для высокой нагрузки, сложной маршрутизации и delayed jobs это нормально. Но если очередь живёт где-то рядом с данными приложения, а нагрузка вполне умеренная, PostgreSQL может справиться сам («я сам решу-у-у-у-у-у-у»).

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

Допустим, у нас есть фоновая обработка email-уведомлений. Несколько Python- или Go-воркеров читают таблицу jobs, забирают первые pending-задачи и переводят их в processing.

DROP TABLE IF EXISTS jobs;

CREATE TABLE jobs (
	id          bigserial PRIMARY KEY,
	payload     jsonb NOT NULL,
	status      text NOT NULL DEFAULT 'pending',
	run_at      timestamptz NOT NULL DEFAULT now(),
	created_at  timestamptz NOT NULL DEFAULT now(),
	locked_at   timestamptz
);

INSERT INTO jobs (payload)
SELECT jsonb_build_object('email', 'user' || n || '@example.com')
FROM generate_series(1, 10) AS n;

BEGIN;

WITH picked AS (
	SELECT id
	FROM jobs
	WHERE status = 'pending'
  	AND run_at <= now()
	ORDER BY created_at
	LIMIT 3
	FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'processing',
	locked_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING id, payload, status;

COMMIT;

Такой запрос можно запускать из нескольких воркеров одновременно, и тогда каждый получит свой набор строк.

Главный плюс — атомарность. Мы не делаем SELECT, не думаем, забрал ли другой воркер эту строку, не пытаемся догнать состояние через UPDATE. База сама выдаёт свободные строки и блокирует их в рамках транзакции.

Но у подхода, конечно, есть и ложка дёгтя — ограничения. Таблица очереди будет часто обновляться, будет pendingprocessingdone, ретраи, ошибки, удаление старых записей. Значит, нужно следить за индексами и autovacuum. И да, это не Kafka, то есть если нужны сложные топики, гарантии доставки между сервисами, ретеншн-событий и replay, лучше брать отдельный брокер.

➡️ Документация: SELECT / FOR UPDATE / SKIP LOCKED.

2. Оконные функции как аналитика без циклов на сервере

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

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

Например, есть события заказов. Нужно показать историю заказов и время между соседними статусами.

DROP TABLE IF EXISTS order_events;

CREATE TABLE order_events (
	id          bigserial PRIMARY KEY,
	order_id    bigint NOT NULL,
	status      text NOT NULL,
	created_at  timestamptz NOT NULL
);

INSERT INTO order_events (order_id, status, created_at) VALUES
(1, 'created',  '2026-01-10 10:00+00'),
(1, 'paid', 	'2026-01-10 10:05+00'),
(1, 'packed',   '2026-01-10 10:40+00'),
(1, 'shipped',  '2026-01-10 11:10+00'),
(2, 'created',  '2026-01-11 09:00+00'),
(2, 'cancelled','2026-01-11 09:03+00');

EXPLAIN ANALYZE SELECT
	order_id,
	status,
	created_at,
	created_at - LAG(created_at) OVER (
    	PARTITION BY order_id
    	ORDER BY created_at
	) AS time_from_prev_status
FROM order_events
ORDER BY order_id, created_at;

Результат сразу пригоден для API, ведь тут сразу видим статус, дату и интервал с предыдущего шага.

Взглянув на тот же запрос через EXPLAIN ANALYZE, можно увидеть такую картину:

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

И видим уже чуть более приятную глазу картину — ну хотя бы цвет добавился. На плане видно типичную схему выполнения оконной функции: Seq ScanSortWindowAgg. Сначала PostgreSQL читает строки таблицы order_events, затем сортирует их по order_id, created_at, потому что именно такой порядок нужен для PARTITION BY order_id ORDER BY created_at. После этого узел WindowAgg вычисляет LAG() — берёт предыдущую дату внутри каждого заказа.

На маленьком наборе данных запрос выполняется быстро, то есть около 0,36 мс, сортировка занимает 25 КБ памяти. Но на большой таблице именно сортировка может стать дорогой частью плана. В таком случае стоит проверить индекс (order_id, created_at) и сравнить планы до и после через EXPLAIN ANALYZE.

Другой частый пример — дедупликация событий. Допустим, один и тот же статус заказа может прийти повторно: из-за ретрая webhook, повторной доставки события или сбоя на стороне внешнего сервиса. Тогда нам нужна последняя запись по фактическому ключу order_id + status.

SELECT *
FROM (
	SELECT
    	e.*,
    	ROW_NUMBER() OVER (
        	PARTITION BY order_id, status
        	ORDER BY created_at DESC, id DESC
    	) AS rn
	FROM order_events e
) t
WHERE rn = 1;

Нюанс: можно споткнуться на сортировке, ведь оконная функция почти всегда зависит от ORDER BY. Если порядок неполный, результат может быть нестабильным. Поэтому для одинаковых дат лучше добавлять вторичный ключ, например, ORDER BY created_at DESC, id DESC.

➡️ Документация: Window Functions.

3. DISTINCT ON как последняя строка в каждой группе

DISTINCT ON — одна из тех фич, о которых многие узнают случайно. Отметим, что она не входит в стандарт SQL, зато в PostgreSQL закрывает очень частую задачу, когда надо взять первую или последнюю строку в каждой группе.

Допустим, надо получить последнее событие по каждому заказу, последнюю сессию пользователя, свежую цену товара или актуальную запись профиля. Часто это пишут через подзапрос с MAX(created_at), потом JOIN обратно к таблице. Потом внезапно появляются две строки с одинаковой датой — и всё едет.

С DISTINCT ON запрос получается коротким.

DROP TABLE IF EXISTS user_sessions;

CREATE TABLE user_sessions (
	id          bigserial PRIMARY KEY,
	user_id     bigint NOT NULL,
	ip          inet NOT NULL,
	user_agent  text NOT NULL,
	created_at  timestamptz NOT NULL
);

INSERT INTO user_sessions (user_id, ip, user_agent, created_at) VALUES
(1, '10.0.0.1', 'Chrome',  '2026-02-01 10:00+00'),
(1, '10.0.0.2', 'Firefox', '2026-02-02 12:00+00'),
(2, '10.0.0.3', 'Safari',  '2026-02-01 09:00+00'),
(2, '10.0.0.4', 'Chrome',  '2026-02-03 15:00+00');

SELECT DISTINCT ON (user_id)
	user_id,
	ip,
	user_agent,
	created_at
FROM user_sessions
ORDER BY user_id, created_at DESC, id DESC;

Фраза «последняя сессия каждого пользователя» здесь буквально читается из запроса: группируем по user_id, а внутри каждой группы берём первую строку после сортировки.

Важный момент, что ORDER BY должен начинаться с тех же выражений, что и DISTINCT ON. Иначе PostgreSQL не поймёт, какую строку вы считаете первой внутри группы. Также всегда добавляйте детерминированный порядок и тогда, если у двух сессий одинаковый created_at, id DESC спасёт от случайной выдачи.

Применяем в API-эндпоинтах «список пользователей + последняя активность», «список заказов + последний статус», «товары + актуальная цена».

➡️ Документация: SELECT / DISTINCT ON.

4. Generated columns или вычисляемые поля без триггеров

Generated columns появились в PostgreSQL 12. Не стоит пугаться страшного названия: это просто столбцы, значение которых PostgreSQL вычисляет сам из других столбцов. Такой столбец нельзя заполнить руками при обычной вставке — здесь вы задаёте выражение, а база поддерживает значение автоматически.

Зачем, спрашивается, всё это бэкендеру? Например, нужно хранить email в том виде, в котором его ввёл пользователь, но искать по нормализованной версии. Или вычислять сумму строки заказа из цены и количества. Или держать простое derived-поле, которое раньше считалось в приложении и иногда рассинхронизировалось.

Представим, что пользователи вводят email с разным регистром и пробелами. Мы хотим сохранить исходное значение, но проверять уникальность по нормализованному.

DROP TABLE IF EXISTS app_users;

CREATE TABLE app_users (
	id          	bigserial PRIMARY KEY,
	email           text NOT NULL,
	normalized_email text GENERATED ALWAYS AS (
    	lower(trim(email))
	) STORED,
	created_at  	timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX app_users_normalized_email_uq
ON app_users (normalized_email);

INSERT INTO app_users (email) VALUES (' Alice@Example.COM ');
INSERT INTO app_users (email) VALUES ('alice@example.com');

Вторая вставка упадёт по уникальному индексу, потому что normalized_email в обоих случаях будет одинаковым.

Это лучше, чем помнить про lower(trim(email)) во всех местах приложения, и всяко лучше, чем поддерживать отдельный столбец руками. Меньше шансов, что один сервис записал правильно, второй забыл, а третий обновил только часть полей.

Но учтите, что выражение generated column должно быть безопасным с точки зрения вычисления из строки. Нельзя просто так обращаться к другим таблицам или делать недетерминированные вещи вроде now().

Ещё важно помнить про миграции. Например, если таблица большая, добавление вычисляемого stored-столбца может потребовать времени и аккуратного плана раскатки.

➡️ Документация: Generated Columns.

5. VACUUM и autovacuum: как правильно чистить базу

VACUUM обычно всплывает в курилках, в разговорах из серии «это к DBA». Но бэкендеры сами успешно создают ситуации, где без понимания VACUUM база начинает тормозить. Например, когда выполняются частые UPDATE, soft delete, очереди задач, массовые импорты, ретраи и таблицы статусов.

Вспомним, что PostgreSQL работает через MVCC и, когда строка обновляется или удаляется, старая версия не исчезает мгновенно. Она становится «мёртвой» и позже убирается vacuum-процессом. Это плата за конкурентность, ведь читающие транзакции не блокируют пишущие, но таблицы со временем накапливают мусор.

Ситуация: таблица jobs из первого раздела активно меняет статусы. Через месяц в ней всего 20 тысяч актуальных строк, но физически она занимает сотни мегабайт. Запросы стали медленнее, индексы раздулись, а DELETE старых задач почти не уменьшил размер таблицы на диске.

Первое, что стоит посмотреть:

SELECT
	relname,
	n_live_tup,
	n_dead_tup,
	last_vacuum,
	last_autovacuum,
	last_analyze,
	last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 10;

Если n_dead_tup растёт, а last_autovacuum давно не обновлялся, таблица просит внимания — и даже не всегда именно ручного VACUUM. Иногда достаточно настроить autovacuum точечно.

ALTER TABLE jobs SET (
	autovacuum_vacuum_scale_factor = 0.02,
	autovacuum_analyze_scale_factor = 0.01
);

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

Ручной запуск тоже возможен через VACUUM (ANALYZE) jobs;.

Но здесь можно и легко переусердствовать. Например, когда превращают VACUUM всей базы в cron «каждые пять минут». Autovacuum уже есть, и в большинстве случаев он справляется. В такой ситуации бэкендеру важнее понимать симптомы опасной ситуации, когда растёт n_dead_tup и ухудшаются планы запросов, таблица с очередью постоянно обновляется и старые транзакции мешают уборке.

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

Хорошая новость: в PostgreSQL 17 улучшили внутреннее управление памятью для VACUUM, из-за чего он может потреблять меньше памяти и работать быстрее на больших таблицах. Но это не отменяет мониторинг базового правила: если приложение постоянно обновляет строки, разработчик должен хотя бы иногда смотреть на статистику таблиц.

➡️ Документация: Routine Vacuuming.

6. LATERAL JOIN(PostgreSQL 9.3+): foreach внутри SQL

Обычный JOIN соединяет таблицы по условию, а вот с помощью LATERAL подзапрос справа может ссылаться на строку слева. Проще говоря, для каждой строки внешней таблицы PostgreSQL выполняет зависимый подзапрос. LATERAL появился в PostgreSQL 9.3.

Это полезно, когда нужно взять «топ N связанных записей» для каждой сущности. Например, если нужны для каждого пользователя три последних заказа, для каждого товара — две последние цены, или для каждого поста — последний комментарий. Без LATERAL такие задачи часто превращаются в оконные функции, вложенные подзапросы или обработку в приложении.

Допустим, надо показать пользователей и два их последних заказа.

DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;

CREATE TABLE customers (
	id    bigserial PRIMARY KEY,
	name  text NOT NULL
);

CREATE TABLE orders (
	id           bigserial PRIMARY KEY,
	customer_id  bigint NOT NULL REFERENCES customers(id),
	total        numeric(10, 2) NOT NULL,
	created_at   timestamptz NOT NULL
);

INSERT INTO customers (name) VALUES ('Alice'), ('Bob');

INSERT INTO orders (customer_id, total, created_at) VALUES
(1, 1200, '2026-03-01 10:00+00'),
(1,  800, '2026-03-02 11:00+00'),
(1, 2400, '2026-03-03 12:00+00'),
(2,  500, '2026-03-01 09:00+00'),
(2,  700, '2026-03-04 15:00+00');

SELECT
	c.id AS customer_id,
	c.name,
	o.id AS order_id,
	o.total,
	o.created_at
FROM customers c
LEFT JOIN LATERAL (
	SELECT id, total, created_at
	FROM orders
	WHERE customer_id = c.id
	ORDER BY created_at DESC
	LIMIT 2
) o ON true
ORDER BY c.id, o.created_at DESC;

Ключевая часть — WHERE customer_id = c.id внутри подзапроса. Подзапрос видит текущего пользователя и берёт только его заказы.

С индексом такой запрос может быть очень приятным:

CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at DESC);

Только LATERAL не нужно лепить везде. Если задача решается простым JOIN, лучше оставить простой JOIN. Но когда в голове появляется фраза «для каждой строки найди несколько связанных строк по своему условию», стоит вспомнить про этот оператор.

➡️ Документация: LATERAL Subqueries.

7. Recursive CTE: деревья и иерархии без серии запросов

Иерархии встречаются чаще, чем кажется. Это категории товаров и меню, оргструктура и комментарии с ответами, цепочки рефералов и зависимости задач. Частый бэкенд-подход — сначала получить корневой узел, потом его детей, а потом детей детей. В итоге приложение делает серию запросов или держит рекурсию в коде.

WITH RECURSIVE позволяет пройти дерево одним SQL-запросом.

Пример схемы:

Electronics
├── Phones
│   ├── Android
│   └── iOS
└── Laptops
	├── Ultrabooks
	└── Gaming

Recursive CTE начинает с корневой категории, затем на каждом шаге добавляет дочерние узлы, пока новые строки не закончатся.

Теперь код:

DROP TABLE IF EXISTS categories;

CREATE TABLE categories (
	id         bigserial PRIMARY KEY,
	parent_id  bigint REFERENCES categories(id),
	name       text NOT NULL
);

INSERT INTO categories (id, parent_id, name) VALUES
(1, NULL, 'Electronics'),
(2, 1,	'Phones'),
(3, 2,	'Android'),
(4, 2,	'iOS'),
(5, 1,	'Laptops'),
(6, 5,	'Ultrabooks'),
(7, 5,	'Gaming');

WITH RECURSIVE tree AS (
	SELECT
    	id,
    	parent_id,
    	name,
    	1 AS level,
    	name AS path
	FROM categories
	WHERE parent_id IS NULL

	UNION ALL

	SELECT
    	c.id,
    	c.parent_id,
    	c.name,
    	t.level + 1,
    	t.path || ' / ' || c.name
	FROM categories c
	JOIN tree t ON c.parent_id = t.id
)
SELECT id, level, path
FROM tree
ORDER BY path;

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

Бывает, что данные плохие: категория А ссылается на Б, а Б обратно на А. В таком случае рекурсия может пойти не туда. Для продакшена стоит добавлять такие меры защиты, как ограничение глубины, проверку пути, constraint-логику на запись. Ещё один момент — большие графы. Recursive CTE хорош для деревьев и умеренных иерархий, но не обязан заменять специализированные graph-хранилища.

➡️ Документация: WITH Queries / Recursive CTE.

8. Advisory locks как блокировки для приложения

Иногда нужно сделать так, чтобы только один процесс выполнял задачу. Например, пересчёт отчёта, импорт файла, отправку ежедневной рассылки или миграцию внешних данных. Часто для этого заводят Redis-lock, отдельную таблицу locks или флаг в настройках.

PostgreSQL даёт advisory locks — пользовательские блокировки, которые не привязаны к конкретной строке таблицы. В этом случае вы сами задаёте ключ блокировки, а база гарантирует, что два процесса не получат один и тот же lock одновременно.

Например, представим, что cron-задача daily_report запускается на нескольких инстансах приложения, но реально должна работать только на одном.

SELECT pg_try_advisory_lock(hashtext('daily_report')) AS acquired;

Если вернулось true, процесс получил lock и может работать. Если false, значит, другой процесс уже выполняет задачу.

После работы lock нужно отпустить:

SELECT pg_advisory_unlock(hashtext('daily_report'));

Для задач внутри транзакции удобнее transaction-level lock:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('recalculate_user_balance:42'));
-- здесь безопасно пересчитываем баланс пользователя 42
COMMIT;

Такой lock сам освободится при завершении транзакции, и это снижает риск забыть unlock.

Главная ловушка — session-level locks. Если взять pg_advisory_lock, а потом держать соединение в пуле, lock может жить дольше, чем вы думали. Для бэкенд-приложений с пулом соединений часто безопаснее брать pg_try_advisory_xact_lock или pg_advisory_xact_lock, когда задача укладывается в транзакцию.

Advisory locks не должны превращаться в скрытую бизнес-логику, которую никто не видит. Но для координации фоновых задач в рамках одной PostgreSQL-базы это очень даже неплохой инструмент.

➡️ Документация: Advisory Locks.

9. Full-text search

Elasticsearch, OpenSearch и Solr нужны не всегда. Если задача — искать по статьям, товарам, тикетам, FAQ или внутренней базе знаний, встроенного полнотекстового поиска PostgreSQL часто хватает надолго.

PostgreSQL умеет хранить нормализованное текстовое представление в tsvector, искать по tsquery, ранжировать результаты и строить GIN-индексы. Тут есть и поддержка разных конфигураций, включая русский язык.

Допустим, нужно искать статьи по заголовку и тексту.

DROP TABLE IF EXISTS articles;

CREATE TABLE articles (
	id          bigserial PRIMARY KEY,
	title       text NOT NULL,
	body        text NOT NULL,
	created_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO articles (title, body) VALUES
('Как ускорить PostgreSQL', 'Индексы, VACUUM и анализ планов запросов'),
('Очереди задач в базе', 'FOR UPDATE SKIP LOCKED помогает воркерам не брать одну задачу дважды'),
('Поиск в приложении', 'PostgreSQL умеет полнотекстовый поиск без отдельного сервиса');

CREATE INDEX articles_search_idx
ON articles
USING GIN (
	to_tsvector('russian', title || ' ' || body)
);

SELECT
	id,
	title,
	ts_rank(
    	to_tsvector('russian', title || ' ' || body),
    	plainto_tsquery('russian', 'поиск postgres')
	) AS rank
FROM articles
WHERE to_tsvector('russian', title || ' ' || body)
  	@@ plainto_tsquery('russian', 'поиск postgres')
ORDER BY rank DESC;

Для продакшена выражение to_tsvector(…) часто выносят в generated column, а индекс строят уже по ней. Так не придётся повторять выражение в каждом запросе.

ALTER TABLE articles
ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
	to_tsvector('russian', title || ' ' || body)
) STORED;

CREATE INDEX articles_search_vector_idx
ON articles
USING GIN (search_vector);

После этого запрос станет чище:

SELECT id, title
FROM articles
WHERE search_vector @@ plainto_tsquery('russian', 'поиск postgres');

Но как же понять, когда использовать внутренний инструмент, а когда внешний? Если нужны сложные синонимы, typo-tolerance, подсказки при вводе, распределённый индекс, богатая аналитика поиска или отдельный relevance pipeline, нужен внешний поисковик. Но для многих бэкенд-задач вполне неплох и внутренний поиск.

➡️ Документация: Full Text Search.

10. Partial indexes как по нужным строкам

Вспомним, что обычный индекс строится по всей таблице. Но нередко приложение ходит в такие активные строки, как неудалённые записи, неоплаченные счета, открытые тикеты, pending-задачи или опубликованные статьи.

Partial index же строится только по строкам, которые подходят под условие — соответственно, он меньше, дешевле в поддержке и быстрее прогревается в памяти.

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

DROP TABLE IF EXISTS shop_orders;

CREATE TABLE shop_orders (
	id          bigserial PRIMARY KEY,
	user_id     bigint NOT NULL,
	status      text NOT NULL,
	total       numeric(10, 2) NOT NULL,
	created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX shop_orders_open_user_idx
ON shop_orders (user_id, created_at DESC)
WHERE status IN ('new', 'paid', 'processing');

Теперь запрос по открытым заказам пользователя может пойти в небольшой индекс:

SELECT id, status, total, created_at
FROM shop_orders
WHERE user_id = 42
  AND status IN ('new', 'paid', 'processing')
ORDER BY created_at DESC
LIMIT 20;

Другой классический пример — soft delete.

CREATE INDEX shop_orders_active_created_idx
ON shop_orders (created_at DESC)
WHERE status <> 'deleted';

Partial indexes особенно хороши, когда «горячая» часть таблицы мала относительно всей таблицы. Например, 95% заказов уже закрыты, а приложение постоянно ищет 5% активных.

Но не всё так идеально: условие запроса должно совпадать с predicate-логикой индекса настолько, чтобы планировщик мог это доказать. Если индекс создан с WHERE deleted_at IS NULL, а приложение пишет хитрые условия через функцию или неявную логику, индекс могут не взять. Поэтому partial indexes лучше проектировать под реальные запросы, а не на всякий случай.

➡️ Документация: Partial Indexes.

Что попробовать прямо сейчас

Не пытайтесь внедрить все десять фич сразу. На этой неделе выберите одну точку в проекте, где PostgreSQL уже может помочь без новой инфраструктуры. Самый безопасный старт — посмотреть на VACUUM и autovacuum: это диагностическая работа, а не переписывание бизнес-логики.

Мини-чек-лист на 30–40 минут:

  1. Найдите таблицу, где часто бывают UPDATE или DELETE: очередь задач, статусы заказов, webhook-события, soft delete, временные токены.

  2. Посмотрите n_live_tup, n_dead_tup, last_autovacuum и last_autoanalyze в pg_stat_user_tables.

  3. Если n_dead_tup растёт, а autovacuum давно не приходил, проверьте, нет ли длинных транзакций и не слишком ли мягкие настройки autovacuum для этой таблицы.

  4. Не запускайте VACUUM FULL в продакшене «просто попробовать»: он переписывает таблицу и берёт тяжёлую блокировку.

  5. Если таблица горячая, обсудите точечные настройки autovacuum_vacuum_scale_factor и autovacuum_analyze_scale_factor, а не глобальный тюнинг всей базы.

SELECT
	schemaname,
	relname,
	n_live_tup,
	n_dead_tup,
	last_autovacuum,
	last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 20;

➡️ Официальная документация по VACUUM.

Надеюсь, в вашей работе пригодится как минимум одна из этих полезных фич!


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

Или можно сразу сделать решительный шаг к переменам, карьерному росту и повышению дохода с профессиональным обучением:

Комментарии (0)