Как PostgreSQL может заменить половину инфраструктуры без лишних сервисов
PostgreSQL умеет выполнять задачи, для которых обычно поднимают отдельные сервисы: очереди задач, блокировки, полнотекстовый поиск и аналитику. При правильной настройке база данных способна заменить половину инфраструктуры, снижая сложность и риски отказов.
Почему PostgreSQL может заменить половину инфраструктуры
В типичном бэкенде рано или поздно появляются отдельные сервисы и таблицы для очередей, блокировок, поиска по текстам, дедупликации и расчётов. Их поднимают как инфраструктурную надстройку: Redis для очередей, Python-скрипты для фильтрации дублей, Go-циклы для расчёта интервалов между статусами заказов, Elasticsearch для поиска, а ещё самодельная таблица locks для координации воркеров.
Почти всё это PostgreSQL уже умеет делать сам — атомарно, транзакционно, с индексами и без лишних прыжков по сети между приложением и базой. Десять таких фич часто остаются за кадром, хотя могли бы сократить половину инфраструктуры.
Очереди без брокера: FOR UPDATE SKIP LOCKED
Механизм FOR UPDATE SKIP LOCKED (появился в PostgreSQL 9.5) позволяет нескольким воркерам безопасно брать задачи из таблицы, не блокируя друг друга. Воркер, который первым захватил строку, забирает задачу в статус running; остальные пропускают занятую строку и берут следующую. Внешний брокер не нужен — достаточно схемы с колонками queue, priority, status, run_at, unique_key и индексами по ним.
Такой подход требует идемпотентных обработчиков: если задача не выполнилась с первого раза, система должна суметь повторить её без дублей. Для этого вводят экспоненциальные задержки между попытками — например, 10 секунд, 20, 40, до часа. В результате таблица задач становится меньше по объёму, а система — надёжнее: нет риска, что Redis откажет в самый неподходящий момент.
Оконные функции: аналитика без циклов в коде
Если нужно посчитать время между статусами заказа, проще оставить расчёт в SQL:
SELECT order_id, status, created_at,
created_at - LAG(created_at) OVER (PARTITION BY order_id ORDER BY created_at)
FROM order_events;
Это быстрее, чем вытаскивать все события в код и крутить циклы в Python. Но на больших таблицах сортировка в оконных функциях может стать узким местом. План выполнения покажет Seq Scan → Sort → WindowAgg. Чтобы ускорить, добавляют индекс по (order_id, created_at).
Первая строка в группе: DISTINCT ON вместо подзапросов
Когда нужен последний платёж для каждого пользователя, пишут:
SELECT DISTINCT ON (user_id) *
FROM payments
ORDER BY user_id, created_at DESC;
Это проще и быстрее, чем бороться с ORM, которая не всегда дружит с группировкой по первой строке. Ещё один плюс: индекс по (user_id, created_at DESC) ускоряет запрос, но мало кто его добавляет.
Вычисляемые колонки: GENERATED ALWAYS AS вместо триггеров
Вместо триггеров или ручной синхронизации можно завести колонку, которая обновляется сама:
ALTER TABLE orders
ADD COLUMN status_calculated
GENERATED ALWAYS AS (
CASE
WHEN paid_at IS NOT NULL THEN 'paid'
WHEN shipped_at IS NOT NULL THEN 'shipped'
ELSE 'pending'
END
) STORED;
Такая колонка решает проблему рассинхронизации, но не все ORM поддерживают generated columns — и тогда приходится думать, как обойтись без них.
Почему таблица «пухнет»: VACUUM и настройки autovacuum
После удалений и обновлений в таблице остаются мёртвые строки — «призраки», которые давят на диск и тормозят запросы. PostgreSQL чистит их сам, но autovacuum может подвесить базу, если не настроен. Ключевые параметры:
autovacuum_vacuum_scale_factor— доля таблицы, после которой запускается очистка;maintenance_work_mem— сколько памяти можно потратить на перестройку индексов.
В продакшене autovacuum нередко становится источником блокировок — и тогда приходится вручную запускать VACUUM в окно низкой нагрузки.
LATERAL JOIN: зависимый подзапрос без тормозов
Если нужно для каждого пользователя достать последний заказ, пишут:
SELECT u.*, o.*
FROM users u,
LATERAL (
SELECT * FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) o;
Это быстрее, чем два отдельных запроса, но только если есть индекс по user_id. На больших наборах данных без индекса LATERAL JOIN превращается в кошмар.
Advisory locks: блокировки, которые не блокируют строки
Если нужно гарантировать, что в кластере запустится только одна cron-задача или миграция, используют advisory locks:
SELECT pg_advisory_xact_lock(12345);
-- ... критическая секция ...
-- Блокировка снимается автоматически при завершении транзакции
Проблема в том, что если приложение падает, блокировка может остаться — и её придётся чистить вручную. Поэтому числовые ключи обычно делают «смещёнными», например 1000000 + user_id, чтобы не пересекаться с другими системами.
Встроенный поиск: когда Elasticsearch — это перебор
Встроенный полнотекстовый поиск по tsvector и tsquery решает большинство задач без внешнего индекса. Пример ранжирования:
SELECT title, ts_rank_cd(search_vector, query) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('руководство')
ORDER BY rank DESC;
Это достаточно для простого поиска. Elasticsearch понадобится только при сложных токенизаторах или горизонтальном масштабировании.
Частичные индексы: маленькие индексы для реальных нужд
Если нужен индекс только для активных заказов, создают частичный:
CREATE INDEX idx_orders_active ON orders (user_id)
WHERE status IN ('paid', 'shipped');
Он меньше и быстрее полного индекса, но работает только если условие покрывает все запросы. Проверить это можно через EXPLAIN: если индекс не используется, значит, условие не совпадает с запросом.
Граница применимости
PostgreSQL не заменит всё. В высоконагруженных очередях, например, RudderStack использует пакетную обработку и уплотнение блоков — иначе база не выдерживает нагрузки. А для геоданных или временных рядов нужны расширения вроде PostGIS или TimescaleDB.
Но если перестать думать о базе как о простом хранилище, половина инфраструктуры может исчезнуть.