Перейти к содержимому

PostgreSQL для бэкенд-разработчиков: 7 паттернов, которые я использую каждый день

Константин Потапов
35 мин

Глубокий разбор практических PostgreSQL-паттернов: JSONB, partial indexes, generated columns, row-level security, advisory locks, materialized views и partitioning. Реальные кейсы, грабли и решения из production.

"База упала в 3 ночи. Таблица events разрослась до 400GB. Индексы не помогали. Запросы таймаутились. Клиенты в ярости."

Это реальная история из 2024 года. Мы потеряли 12 часов работы, $8k выручки и чуть не потеряли крупного клиента. Проблему решили за 3 часа партиционированием — паттерн, который мы могли применить ещё полгода назад.

Спойлер: PostgreSQL — это не просто "SQL с транзакциями". Это швейцарский нож с десятками продвинутых возможностей, которые 90% разработчиков никогда не используют. И зря.

Я провёл последние 8 лет, проектируя базы данных для финтеха, e-commerce, SaaS и gov-проектов. Перепробовал MongoDB, Cassandra, DynamoDB — и всегда возвращался к PostgreSQL. Потому что он умеет ВСЁ, если знать как.

В этой статье — 7 паттернов, которые я применяю в каждом production-проекте. Без теории из учебников — только практика, код и грабли, на которые я уже наступил за вас.

Почему именно эти паттерны

За 8 лет я работал с базами от 10GB до 3TB. От 100 запросов в секунду до 50k RPS. От монолитных Django-приложений до микросервисных архитектур.

Эти 7 паттернов решают 90% production-проблем:

  1. JSONB — гибкая схема без миграций
  2. Partial Indexes — индексы в 10 раз меньше
  3. Generated Columns — денормализация без костылей
  4. Row-Level Security — мультитенантность на уровне базы
  5. Advisory Locks — распределённые блокировки без Redis
  6. Materialized Views — аналитика без тормозов
  7. Partitioning — работа с терабайтами данных

Все примеры протестированы на PostgreSQL 15-17. Большинство паттернов работают с версии 12+, но я рекомендую использовать минимум PostgreSQL 15 для production.


Прежде чем оптимизировать: как измерять эффективность

Золотое правило оптимизации: никогда не применяйте паттерны вслепую. Сначала измерьте, потом оптимизируйте, затем измерьте снова.

EXPLAIN ANALYZE — ваш рентген запросов

EXPLAIN показывает, как PostgreSQL выполняет запрос. ANALYZE — выполняет его и показывает реальные цифры.

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders
WHERE user_id = 12345
  AND status = 'pending'
ORDER BY created_at DESC
LIMIT 10;

Что смотреть:

  1. Execution Time — общее время выполнения (ваша главная метрика)
  2. Planning Time — время на построение плана (обычно < 1ms)
  3. Seq Scan vs Index Scan — полное сканирование таблицы или индекс
  4. Buffers: shared hit — данные из кеша (быстро)
  5. Buffers: shared read — данные с диска (медленно)

Пример хорошего плана:

Index Scan using idx_orders_user_status on orders
  (cost=0.42..8.44 rows=1 width=120)
  (actual time=0.023..0.025 rows=3 loops=1)
  Buffers: shared hit=12
Planning Time: 0.156 ms
Execution Time: 0.052 ms

Пример плохого плана:

Seq Scan on orders
  (cost=0.00..185432.00 rows=50 width=120)
  (actual time=245.123..1840.456 rows=3 loops=1)
  Filter: ((user_id = 12345) AND (status = 'pending'))
  Rows Removed by Filter: 8945632
  Buffers: shared hit=45000 shared read=98000
Planning Time: 0.234 ms
Execution Time: 1840.678 ms

Видите разницу? 0.052ms vs 1840ms — в 36000 раз медленнее! Причина — Seq Scan вместо Index Scan.

Частая ошибка: смотреть только на cost. Cost — это оценка планировщика, а не реальное время. Всегда используйте ANALYZE для получения actual time.

pg_stat_statements — находим самые дорогие запросы

pg_stat_statements — это расширение PostgreSQL, которое собирает статистику по всем выполненным запросам.

Установка (один раз):

-- В postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
 
-- Рестарт PostgreSQL, затем:
CREATE EXTENSION pg_stat_statements;

Топ-10 самых медленных запросов:

SELECT
    substring(query, 1, 100) as short_query,
    calls,
    total_exec_time,
    mean_exec_time,
    max_exec_time,
    stddev_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

Топ-10 запросов по суммарному времени (находим "узкие места"):

SELECT
    substring(query, 1, 100) as short_query,
    calls,
    total_exec_time,
    mean_exec_time,
    (total_exec_time / sum(total_exec_time) OVER ()) * 100 AS percent_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Реальный пример из практики:

В 2024 году мы нашли через pg_stat_statements, что один запрос занимал 47% всего времени базы. Это был SELECT с 15 JOIN'ами без индексов. Добавили 3 индекса — нагрузка на базу упала в 2 раза.

Метрики, которые нужно отслеживать

МетрикаЧто показываетХорошее значение
Query execution time (p95)95-й перцентиль времени запроса< 50ms для OLTP
Cache hit ratioshared_buffers_hit / (hit + read)> 95%
Index usage% запросов использующих индексы> 90%
Sequential scansКоличество полных сканирований таблицМинимум
Temp filesСортировки/группировки на диске0 (должно быть в памяти)

Запрос для cache hit ratio:

SELECT
    sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) * 100 AS cache_hit_ratio
FROM pg_statio_user_tables;

Если cache hit ratio < 90% — увеличивайте shared_buffers в конфигурации.

Pro tip: Настройте автоматический мониторинг с pg_stat_statements + Grafana. Это позволит увидеть деградацию производительности ДО того, как клиенты начнут жаловаться.


Паттерн 1: JSONB — когда схема не может быть жёсткой

Проблема: гибкая схема без миграций

Представьте: вы строите e-commerce платформу. У вас есть продукты разных категорий: одежда, электроника, мебель. У каждой категории свои атрибуты:

  • Одежда: размер, цвет, материал, сезон
  • Электроника: диагональ, процессор, память, батарея
  • Мебель: материал, габариты, вес, стиль

Классический подход: создать таблицу product_attributes с колонками для ВСЕХ возможных атрибутов. Результат — 80% NULL-значений и миграция каждый раз, когда маркетинг придумывает новый атрибут.

JSONB подход:

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL,
    price NUMERIC(10,2) NOT NULL,
 
    -- Гибкие атрибуты в JSONB
    attributes JSONB NOT NULL DEFAULT '{}',
 
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- GIN индекс для быстрого поиска по JSONB
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
 
-- Индекс для конкретного поля (ещё быстрее)
CREATE INDEX idx_products_color ON products ((attributes->>'color'));

Примеры использования:

-- Вставка продукта с атрибутами
INSERT INTO products (name, category, price, attributes)
VALUES (
    'iPhone 15 Pro',
    'electronics',
    999.99,
    '{
        "brand": "Apple",
        "storage": "256GB",
        "color": "Natural Titanium",
        "screen": "6.1 inch",
        "processor": "A17 Pro"
    }'::jsonb
);
 
-- Поиск всех чёрных футболок размера M
SELECT id, name, price, attributes
FROM products
WHERE category = 'clothing'
  AND attributes @> '{"color": "black", "size": "M"}';
 
-- Обновление конкретного атрибута (не перезаписывая остальные)
UPDATE products
SET attributes = attributes || '{"discount": 15}'::jsonb
WHERE id = 42;
 
-- Удаление атрибута
UPDATE products
SET attributes = attributes - 'discount'
WHERE id = 42;
 
-- Поиск по вложенным полям
SELECT name, attributes->'specs'->>'processor' as cpu
FROM products
WHERE attributes->'specs'->>'processor' LIKE '%A17%';

Реальный кейс: экономия 2 недели разработки

Проект: SaaS для управления недвижимостью (2024)

Проблема: У каждого типа недвижимости (квартира, дом, коммерческое) свои поля. Изначально планировали 3 отдельные таблицы + джойны. Это 3 недели разработки + головная боль с поддержкой.

Решение: Одна таблица properties с JSONB-колонкой details. Миграция написана за 2 часа. Когда клиент попросил добавить "апартаменты" и "паркинги" — добавили за 15 минут без миграций.

Результат:

  • ⏱️ 2 недели сэкономленного времени
  • 🚀 Развёртывание новых типов недвижимости за минуты
  • 📊 Индексы по JSONB работают так же быстро, как по обычным колонкам

Когда НЕ использовать JSONB

Не используйте JSONB если:

  • Вам нужны foreign keys на атрибуты
  • Атрибуты критичны для бизнес-логики (лучше обычные колонки)
  • Вы делаете частые агрегации по этим полям (SUM, AVG)
  • Схема стабильна и не меняется

Используйте JSONB если:

  • Схема часто меняется
  • Много NULL-значений в обычных колонках
  • Разные сущности одного типа имеют разные атрибуты
  • Нужна гибкость без миграций

Частая ошибка: хранить всё в JSONB "на будущее". Не делайте так. Критичные для бизнеса поля (price, user_id, status) должны быть обычными колонками с типами и constraint'ами.

⚠️ Грабли и подводные камни

Грабли #1: Забыли про оператор содержания (@>)

-- ❌ МЕДЛЕННО: Поиск через ->> (не использует GIN индекс)
SELECT * FROM products
WHERE attributes->>'color' = 'red';
 
-- ✅ БЫСТРО: Поиск через @> (использует GIN индекс)
SELECT * FROM products
WHERE attributes @> '{"color": "red"}';

Почему: Оператор ->> возвращает text, индекс не используется. Оператор @> работает с JSONB напрямую — GIN индекс работает.

Грабли #2: Неправильный тип индекса

-- ❌ МЕДЛЕННО: B-tree индекс на JSONB колонке (почти бесполезен)
CREATE INDEX idx_bad ON products (attributes);
 
-- ✅ БЫСТРО: GIN индекс для поиска по содержимому
CREATE INDEX idx_good ON products USING GIN (attributes);
 
-- ✅ ЕЩЁ БЫСТРЕЕ: Индекс на конкретное поле
CREATE INDEX idx_best ON products ((attributes->>'color'));

Правило: GIN для поиска внутри JSONB, B-tree для конкретных полей.

Грабли #3: Агрегации по JSONB-полям (медленно)

-- ❌ ОЧЕНЬ МЕДЛЕННО: SUM по JSONB полю
SELECT SUM((attributes->>'price')::numeric) FROM products;
-- Не использует индексы, приводит тип для каждой строки
 
-- ✅ РЕШЕНИЕ: Вынести критичные поля в отдельные колонки
ALTER TABLE products ADD COLUMN price NUMERIC(10,2);
UPDATE products SET price = (attributes->>'price')::numeric;
-- Теперь SUM(price) работает быстро

Когда случилось: E-commerce проект (2024), отчёт по выручке выполнялся 45 секунд. Вынесли price и quantity в колонки — стало 0.3 секунды.

Грабли #4: JSONB bloat при частых UPDATE

-- ❌ ПЛОХО: Обновление одного поля перезаписывает весь JSONB
UPDATE products
SET attributes = attributes || '{"views": 1001}'::jsonb
WHERE id = 123;
-- При каждом UPDATE создаётся новая версия ВСЕГО JSONB объекта
 
-- ✅ РЕШЕНИЕ: Счётчики и часто меняющиеся поля — в отдельные колонки
ALTER TABLE products ADD COLUMN views INTEGER DEFAULT 0;
UPDATE products SET views = views + 1 WHERE id = 123;
-- UPDATE только одного INT — в 10 раз быстрее

Правило: Если поле обновляется часто (счётчики, timestamps) — НЕ храните в JSONB.

Грабли #5: NULL vs пустой объект vs отсутствие ключа

-- Это ТРИ РАЗНЫЕ вещи:
INSERT INTO products (attributes) VALUES (NULL);              -- NULL
INSERT INTO products (attributes) VALUES ('{}');              -- Пустой объект
INSERT INTO products (attributes) VALUES ('{"color": null}'); -- Ключ с null
 
-- Поиск работает по-разному:
WHERE attributes IS NULL               -- Только первый
WHERE attributes = '{}'                -- Только второй
WHERE attributes->>'color' IS NULL     -- Второй и третий
WHERE attributes ? 'color'             -- Только третий

Решение: Определите convention (например, всегда '{}', никогда NULL) и придерживайтесь.


Паттерн 2: Partial Indexes — индексы, которые не жрут место

Проблема: индексы занимают больше места, чем данные

Сценарий: у вас есть таблица orders с миллионами записей. 95% заказов — завершённые (status = 'completed'). Вам нужен быстрый поиск только по активным заказам (status IN ('pending', 'processing')).

Плохое решение:

-- Индекс на всю таблицу (95% мусора)
CREATE INDEX idx_orders_status ON orders (status);

Проблемы:

  • Индекс огромный (миллионы строк)
  • Slow inserts (каждый INSERT обновляет индекс)
  • Кеш забит ненужными данными

Правильное решение:

-- Partial index только для активных заказов (5% от таблицы)
CREATE INDEX idx_orders_active
ON orders (created_at, user_id)
WHERE status IN ('pending', 'processing');

Результат:

  • Индекс в 20 раз меньше
  • Вставки быстрее (меньше индексов обновлять)
  • Кеш используется эффективно

Реальные примеры

-- Индекс только для неудалённых записей (soft delete)
CREATE INDEX idx_users_active
ON users (email)
WHERE deleted_at IS NULL;
 
-- Индекс только для будущих событий
CREATE INDEX idx_events_upcoming
ON events (start_time)
WHERE start_time > NOW();
 
-- Индекс только для неоплаченных счетов
CREATE INDEX idx_invoices_unpaid
ON invoices (due_date, customer_id)
WHERE paid_at IS NULL;
 
-- Композитный partial index для сложных условий
CREATE INDEX idx_orders_priority
ON orders (created_at DESC)
WHERE status = 'processing'
  AND priority = 'high'
  AND assigned_to IS NULL;

Использование в запросах

-- PostgreSQL автоматически использует partial index
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
  AND user_id = 12345
ORDER BY created_at DESC
LIMIT 10;
 
-- Index Scan using idx_orders_active on orders
-- Buffers: shared hit=42

Pro tip: Partial indexes идеально подходят для "горячих" данных. У вас есть 10 миллионов заказов, но только 50k активных? Partial index даст 200x меньше размер и в разы быстрее поиск.

Реальный кейс: индекс с 40GB до 2GB

Проект: Логистическая платформа (2023)

Проблема: Таблица shipments с 50 миллионами записей. Индекс по status занимал 40GB. 98% грузов — доставленные (status = 'delivered'). Нам нужны только активные.

Решение:

-- Было
CREATE INDEX idx_shipments_status ON shipments (status);
-- Размер: 40GB
 
-- Стало
CREATE INDEX idx_shipments_active
ON shipments (status, estimated_delivery)
WHERE status IN ('pending', 'in_transit', 'out_for_delivery');
-- Размер: 2GB

Результат:

  • 💾 Освободили 38GB дискового пространства
  • ⚡ Запросы стали в 3 раза быстрее
  • 📈 Inserts ускорились на 40% (меньше индексов обновлять)

Паттерн 3: Generated Columns — денормализация без костылей

Проблема: вычисляемые поля нужны постоянно

Классическая задача: у вас есть таблица users с first_name и last_name. Вам часто нужно искать по полному имени.

Плохое решение #1: конкатенация в каждом запросе

SELECT * FROM users
WHERE LOWER(first_name || ' ' || last_name) LIKE '%john doe%';
-- Медленно, индекс не используется

Плохое решение #2: триггеры для обновления поля

-- Куча кода для триггера
-- Легко забыть обновить
-- Ещё один источник багов

Правильное решение: Generated Columns

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
 
    -- Generated column (автоматически вычисляется)
    full_name TEXT GENERATED ALWAYS AS (
        first_name || ' ' || last_name
    ) STORED,
 
    -- Можно индексировать!
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Индекс для быстрого поиска по полному имени
CREATE INDEX idx_users_full_name ON users USING GIN (
    to_tsvector('english', full_name)
);

Использование:

-- Полнотекстовый поиск работает мгновенно
SELECT id, full_name, email
FROM users
WHERE to_tsvector('english', full_name) @@ to_tsquery('english', 'john & doe');
 
-- Generated column обновляется автоматически
UPDATE users
SET first_name = 'Jane'
WHERE id = 42;
-- full_name автоматически станет "Jane Doe"

Реальные примеры

-- Вычисление возраста из даты рождения
CREATE TABLE employees (
    id BIGSERIAL PRIMARY KEY,
    birth_date DATE NOT NULL,
 
    age INTEGER GENERATED ALWAYS AS (
        EXTRACT(YEAR FROM AGE(birth_date))
    ) STORED
);
 
-- Автоматическое вычисление total для заказа
CREATE TABLE order_items (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    price NUMERIC(10,2) NOT NULL CHECK (price >= 0),
 
    total NUMERIC(10,2) GENERATED ALWAYS AS (
        quantity * price
    ) STORED
);
 
-- URL slug из заголовка
CREATE TABLE articles (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
 
    slug TEXT GENERATED ALWAYS AS (
        LOWER(
            REGEXP_REPLACE(
                REGEXP_REPLACE(title, '[^a-zA-Z0-9\s-]', '', 'g'),
                '\s+', '-', 'g'
            )
        )
    ) STORED UNIQUE
);
 
-- JSON поле для поиска (из нескольких полей)
CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    category TEXT NOT NULL,
 
    search_vector tsvector GENERATED ALWAYS AS (
        to_tsvector('english',
            COALESCE(name, '') || ' ' ||
            COALESCE(description, '') || ' ' ||
            COALESCE(category, '')
        )
    ) STORED
);
 
CREATE INDEX idx_products_search ON products USING GIN (search_vector);

STORED vs VIRTUAL

PostgreSQL поддерживает только STORED (вычисляется при INSERT/UPDATE и хранится на диске).

Плюсы STORED:

  • ✅ Быстрое чтение (значение уже вычислено)
  • ✅ Можно индексировать
  • ✅ Можно использовать в constraint'ах

Минусы STORED:

  • ❌ Занимает место на диске
  • ❌ Замедляет INSERT/UPDATE (нужно вычислить)

В MySQL и MariaDB есть VIRTUAL generated columns (вычисляются на лету при чтении). PostgreSQL пока не поддерживает, но это в roadmap.

Реальный кейс: полнотекстовый поиск за 5 мс

Проект: База знаний для техподдержки (2024)

Проблема: Поиск по статьям (title + body + tags) занимал 2-3 секунды. 50k статей, пользователи жалуются.

Решение:

ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('russian', COALESCE(title, '')), 'A') ||
    setweight(to_tsvector('russian', COALESCE(body, '')), 'B') ||
    setweight(to_tsvector('russian', COALESCE(array_to_string(tags, ' '), '')), 'C')
) STORED;
 
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

Результат:

  • ⚡ Поиск с 2-3 секунд до 5-8 мс
  • 🎯 Relevance ranking из коробки (title важнее body)
  • 🔧 Нулевая поддержка (всё автоматически)

Паттерн 4: Row-Level Security — мультитенантность на уровне базы

Проблема: каждый запрос нужно фильтровать по tenant_id

Типичный SaaS-сценарий: у вас есть компании (tenants), у каждой свои данные. В коде КАЖДЫЙ запрос должен содержать WHERE company_id = current_user_company.

Проблемы:

  • 🐛 Легко забыть фильтр → data leak
  • 🔒 Security through code (не через БД)
  • 📝 Дублирование кода в каждом query

Правильное решение: Row-Level Security (RLS)

-- Включаем RLS для таблицы
CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    company_id BIGINT NOT NULL,
    title TEXT NOT NULL,
    content TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
-- Включаем RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
-- Политика: пользователь видит только документы своей компании
CREATE POLICY documents_isolation_policy ON documents
    USING (company_id = current_setting('app.current_company_id')::BIGINT);

Использование в приложении:

# Python (psycopg2/psycopg3)
def get_db_connection(user):
    conn = psycopg2.connect(...)
    cursor = conn.cursor()
 
    # Устанавливаем company_id пользователя в сессии
    cursor.execute(
        "SET app.current_company_id = %s",
        (user.company_id,)
    )
 
    return conn
 
# Теперь все запросы автоматически фильтруются!
# Забыть WHERE company_id = ... НЕВОЗМОЖНО
cursor.execute("SELECT * FROM documents WHERE title LIKE %s", ('%report%',))
# PostgreSQL автоматически добавит: AND company_id = current_company_id

Продвинутые политики

-- Политика для SELECT (чтение)
CREATE POLICY documents_select_policy ON documents
    FOR SELECT
    USING (
        company_id = current_setting('app.current_company_id')::BIGINT
    );
 
-- Политика для INSERT (только в свою компанию)
CREATE POLICY documents_insert_policy ON documents
    FOR INSERT
    WITH CHECK (
        company_id = current_setting('app.current_company_id')::BIGINT
    );
 
-- Политика для UPDATE (редактировать только свои документы + только авторы)
CREATE POLICY documents_update_policy ON documents
    FOR UPDATE
    USING (
        company_id = current_setting('app.current_company_id')::BIGINT
        AND created_by = current_setting('app.current_user_id')::BIGINT
    );
 
-- Политика для DELETE (только админы компании могут удалять)
CREATE POLICY documents_delete_policy ON documents
    FOR DELETE
    USING (
        company_id = current_setting('app.current_company_id')::BIGINT
        AND current_setting('app.user_role') = 'admin'
    );
 
-- Суперпользователи обходят RLS
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
-- Теперь даже SUPERUSER не обойдёт политики (кроме таблиц без политик)

Реальный кейс: закрытие критичной уязвимости

Проект: B2B CRM-система (2023)

Проблема: Junior разработчик забыл добавить WHERE company_id = ... в один endpoint. Результат — клиент компании A увидел данные компании B. GDPR violation, штраф €10k.

Решение: Внедрили RLS на уровне базы.

-- Все таблицы с данными клиентов
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
 
-- Единая политика для всех таблиц
CREATE POLICY tenant_isolation_policy ON contacts
    USING (company_id = current_setting('app.current_company_id')::BIGINT);
 
-- То же для остальных таблиц

Результат:

  • 🔒 Невозможно забыть фильтрацию
  • 🛡️ Defense in depth (security на уровне БД)
  • ✅ Аудит показал: 0 уязвимостей за 2 года

Performance: RLS добавляет небольшой overhead (1-3%). Но security важнее. Если критично — используйте prepared statements с company_id в параметрах.

⚠️ Грабли и подводные камни

Грабли #1: Забыли установить company_id в сессии

-- ❌ ОШИБКА: current_setting вернёт NULL или ERROR
SELECT * FROM documents;
-- ERROR: unrecognized configuration parameter "app.current_company_id"
 
-- ✅ ПРАВИЛЬНО: Всегда устанавливайте параметр в начале сессии
SET app.current_company_id = 42;
-- Теперь RLS работает

Решение: Middleware в приложении должно ВСЕГДА устанавливать app.current_company_id сразу после подключения.

Грабли #2: SUPERUSER обходит RLS (даже если вы не хотели)

-- SUPERUSER видит ВСЁ, игнорируя политики RLS
-- Даже если вы подключились как postgres
 
-- ✅ РЕШЕНИЕ: FORCE ROW LEVEL SECURITY
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
-- Теперь даже SUPERUSER не обойдёт политики

Когда случилось: Миграции выполнялись под SUPERUSER и случайно создали документы не в той компании. Потеряли 3 часа на откат.

Грабли #3: Политики блокируют самих себя при INSERT/UPDATE

-- У вас есть политика:
CREATE POLICY select_policy ON documents FOR SELECT
    USING (company_id = current_setting('app.current_company_id')::BIGINT);
 
-- ❌ INSERT не работает!
INSERT INTO documents (company_id, title) VALUES (42, 'Test');
-- ERROR: new row violates row-level security policy
 
-- ✅ РЕШЕНИЕ: Нужна отдельная политика для INSERT
CREATE POLICY insert_policy ON documents FOR INSERT
    WITH CHECK (company_id = current_setting('app.current_company_id')::BIGINT);

Правило: Создавайте политики для КАЖДОЙ операции: SELECT, INSERT, UPDATE, DELETE.

Грабли #4: Performance деградация без индекса на ключе политики

-- У вас политика на company_id
CREATE POLICY isolation ON documents
    USING (company_id = current_setting('app.current_company_id')::BIGINT);
 
-- ❌ МЕДЛЕННО: Нет индекса на company_id
-- PostgreSQL делает Seq Scan + фильтрация
 
-- ✅ БЫСТРО: Индекс на company_id
CREATE INDEX idx_documents_company ON documents (company_id);
-- Теперь Index Scan

Метрика: В production без индекса: 200ms на запрос. С индексом: 5ms.

Грабли #5: RLS не работает при прямом изменении партиций

-- У вас партиционированная таблица с RLS
CREATE TABLE documents (...) PARTITION BY RANGE (created_at);
 
-- ❌ RLS НЕ РАБОТАЕТ на прямых запросах к партициям!
SELECT * FROM documents_2024_01;
-- Вернёт ВСЕ строки, игнорируя политики
 
-- ✅ ПРАВИЛЬНО: Запрашивайте родительскую таблицу
SELECT * FROM documents;
-- RLS применяется, затем partition pruning

Решение: Запрещайте прямой доступ к партициям через GRANT/REVOKE.


Паттерн 5: Advisory Locks — распределённые блокировки без Redis

Проблема: нужны блокировки между процессами

Сценарий: у вас 10 Celery workers обрабатывают задачи. Нужно гарантировать, что задача выполняется только одним worker'ом.

Плохое решение #1: Redis с SETNX

  • Ещё один сервис для поддержки
  • Что если Redis упадёт?
  • Проблема с TTL (задача долгая → lock протух)

Плохое решение #2: SELECT FOR UPDATE

  • Блокирует строку в таблице
  • Deadlock'и при сложной логике

Правильное решение: PostgreSQL Advisory Locks

-- Попытка получить эксклюзивную блокировку
SELECT pg_try_advisory_lock(12345);
-- Вернёт true, если блокировка получена
-- Вернёт false, если уже занята
 
-- Блокировка с ожиданием
SELECT pg_advisory_lock(12345);
-- Ждёт, пока блокировка освободится
 
-- Освобождение блокировки
SELECT pg_advisory_unlock(12345);
 
-- Автоматическое освобождение при закрытии соединения
-- (не нужно вручную unlock, если процесс упал)

Реальный пример: обработка платежей

import psycopg2
import hashlib
 
def process_payment_exclusively(payment_id):
    """
    Обрабатываем платёж только если никто другой его не обрабатывает
    """
    conn = psycopg2.connect(...)
    cursor = conn.cursor()
 
    # Генерируем числовой ID из payment_id (advisory lock принимает bigint)
    lock_id = int(hashlib.md5(f"payment:{payment_id}".encode()).hexdigest()[:15], 16)
 
    # Пытаемся получить блокировку
    cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
    acquired = cursor.fetchone()[0]
 
    if not acquired:
        print(f"Платёж {payment_id} уже обрабатывается другим worker'ом")
        return False
 
    try:
        # Обрабатываем платёж
        process_payment_logic(payment_id)
        conn.commit()
        return True
    finally:
        # Освобождаем блокировку
        cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
        conn.close()

Практические примеры

-- Блокировка по двум числам (вместо одного bigint)
SELECT pg_try_advisory_lock(user_id, task_id);
 
-- Shared lock (несколько процессов могут читать)
SELECT pg_try_advisory_lock_shared(12345);
 
-- Transaction-level lock (освобождается при COMMIT/ROLLBACK)
SELECT pg_try_advisory_xact_lock(12345);
 
-- Проверка существующих блокировок
SELECT
    locktype,
    objid,
    mode,
    granted,
    pid,
    pg_blocking_pids(pid) as blocking_pids
FROM pg_locks
WHERE locktype = 'advisory';

Когда использовать Advisory Locks

Используйте когда:

  • Распределённые cron-задачи (только один инстанс запускается)
  • Обработка очередей (гарантия уникальности)
  • Миграции данных (избежать concurrent updates)
  • Rate limiting (один пользователь → одна операция)

Не используйте когда:

  • Короткие операции (< 100ms) — overhead больше пользы
  • Высокая конкуренция (> 1000 workers за одну блокировку)
  • Нужен TTL (advisory locks не протухают автоматически)

Реальный кейс: дедупликация задач в очереди

Проект: Email-рассылка для e-commerce (2024)

Проблема: Celery иногда запускал одну задачу дважды (network glitch + retry). Результат — клиенты получали дубли писем.

Решение:

from celery import Task
import hashlib
 
class ExclusiveTask(Task):
    def __call__(self, *args, **kwargs):
        # Генерируем lock_id из имени задачи + аргументов
        task_hash = hashlib.md5(
            f"{self.name}:{args}:{kwargs}".encode()
        ).hexdigest()[:15]
        lock_id = int(task_hash, 16)
 
        with self.app.pool.acquire() as conn:
            cursor = conn.cursor()
            cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
 
            if not cursor.fetchone()[0]:
                self.app.log.warning(f"Задача {self.name} уже выполняется")
                return None
 
            try:
                return self.run(*args, **kwargs)
            finally:
                cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
 
@app.task(base=ExclusiveTask)
def send_email(user_id, template):
    # Гарантированно выполнится только один раз
    pass

Результат:

  • 🎯 100% дедупликация задач
  • 📉 Жалобы на дубли писем упали до нуля
  • 🔧 Не нужен Redis для блокировок

Паттерн 6: Materialized Views — аналитика без тормозов

Проблема: аналитические запросы убивают production базу

Сценарий: у вас есть дашборд с метриками: выручка по месяцам, топ товаров, активность пользователей. Запрос делает JOIN по 5 таблицам и GROUP BY. Выполняется 30 секунд. Пользователи открывают дашборд → база умирает.

Плохое решение #1: Кешировать в Redis

  • Сложная логика инвалидации
  • Что если Redis упадёт?
  • Нужно писать код для кеша

Плохое решение #2: Отдельная аналитическая база

  • Репликация, синхронизация, ETL
  • Ещё один сервис

Правильное решение: Materialized Views

-- Создаём materialized view с агрегированными данными
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
    DATE_TRUNC('month', created_at) as month,
    category,
    COUNT(*) as orders_count,
    SUM(total) as total_revenue,
    AVG(total) as avg_order_value,
    COUNT(DISTINCT user_id) as unique_customers
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at), category
ORDER BY month DESC, total_revenue DESC;
 
-- Индекс для быстрого доступа
CREATE INDEX idx_monthly_revenue_month ON monthly_revenue (month);
 
-- Обновление данных (можно через cron)
REFRESH MATERIALIZED VIEW monthly_revenue;
 
-- Concurrently (без блокировки чтения)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;

Использование:

-- Мгновенный запрос вместо 30 секунд
SELECT * FROM monthly_revenue
WHERE month >= '2024-01-01'
ORDER BY total_revenue DESC;
 
-- Execution time: 2ms (было 30s)

Продвинутые возможности

-- Materialized view с JOIN'ами
CREATE MATERIALIZED VIEW user_activity_summary AS
SELECT
    u.id,
    u.email,
    u.created_at as registered_at,
    COUNT(DISTINCT o.id) as total_orders,
    SUM(o.total) as lifetime_value,
    MAX(o.created_at) as last_order_date,
    COUNT(DISTINCT DATE(o.created_at)) as active_days
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
 
-- Для CONCURRENTLY нужен UNIQUE индекс
CREATE UNIQUE INDEX idx_user_activity_id
ON user_activity_summary (id);
 
-- Теперь можно обновлять без блокировки
REFRESH MATERIALIZED VIEW CONCURRENTLY user_activity_summary;

Автоматическое обновление

-- Функция для обновления
CREATE OR REPLACE FUNCTION refresh_all_matviews()
RETURNS void AS $$
BEGIN
    REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;
    REFRESH MATERIALIZED VIEW CONCURRENTLY user_activity_summary;
    RAISE NOTICE 'Materialized views refreshed at %', NOW();
END;
$$ LANGUAGE plpgsql;
 
-- Расписание через pg_cron (расширение)
-- Каждый час в :05 минут
SELECT cron.schedule('refresh-matviews', '5 * * * *', 'SELECT refresh_all_matviews()');
 
-- Или через внешний cron
-- 5 * * * * psql -d mydb -c "REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue"

Реальный кейс: дашборд CEO с 45s до 50ms

Проект: Fintech-стартап (2024)

Проблема: CEO хотел видеть метрики в реальном времени. Дашборд делал 12 тяжёлых запросов. Открытие страницы — 45 секунд. База под нагрузкой.

Решение:

-- 1. Materialized view для всех метрик
CREATE MATERIALIZED VIEW dashboard_metrics AS
SELECT
    'total_users'::text as metric,
    COUNT(*)::bigint as value,
    NULL::jsonb as details
FROM users
UNION ALL
SELECT
    'active_users_30d',
    COUNT(DISTINCT user_id),
    NULL
FROM events
WHERE created_at > NOW() - INTERVAL '30 days'
UNION ALL
SELECT
    'revenue_today',
    SUM(amount)::bigint,
    jsonb_build_object(
        'count', COUNT(*),
        'avg', AVG(amount)
    )
FROM payments
WHERE DATE(created_at) = CURRENT_DATE
-- ... ещё 9 метрик
;
 
-- 2. Обновление каждые 5 минут
SELECT cron.schedule(
    'refresh-dashboard',
    '*/5 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY dashboard_metrics'
);

Результат:

  • ⚡ Загрузка дашборда: 45s → 50ms
  • 📊 Данные "почти реальное время" (задержка 5 минут)
  • 🎯 Нагрузка на базу упала в 10 раз

CONCURRENTLY требует UNIQUE индекс на materialized view. Без него PostgreSQL заблокирует чтение на время обновления.


Паттерн 7: Partitioning — терабайты данных без боли

Проблема: таблица выросла до сотен гигабайт

Классическая история: таблица events растёт на 10GB в месяц. Через год — 120GB. Через 3 года — 400GB. Запросы тормозят. VACUUM занимает часы. Индексы огромные.

Правильное решение: Table Partitioning

PostgreSQL поддерживает несколько типов партиционирования:

  • RANGE — по диапазонам (даты, числа)
  • LIST — по списку значений (категории, статусы)
  • HASH — по хешу (равномерное распределение)

Range Partitioning (самый популярный)

-- Родительская таблица (партиционированная)
CREATE TABLE events (
    id BIGSERIAL,
    user_id BIGINT NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB,
    created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
 
-- Партиции по месяцам
CREATE TABLE events_2024_01 PARTITION OF events
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
 
CREATE TABLE events_2024_02 PARTITION OF events
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
 
CREATE TABLE events_2024_03 PARTITION OF events
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
 
-- Индексы создаются для каждой партиции
CREATE INDEX idx_events_user_id ON events (user_id);
CREATE INDEX idx_events_type ON events (event_type);
 
-- PostgreSQL автоматически создаст:
-- idx_events_user_id_2024_01, idx_events_user_id_2024_02, ...

Использование:

-- Запросы работают прозрачно
INSERT INTO events (user_id, event_type, payload, created_at)
VALUES (12345, 'page_view', '{"url": "/pricing"}', NOW());
-- PostgreSQL автоматически положит в правильную партицию
 
-- Partition Pruning (автоматическая оптимизация)
EXPLAIN ANALYZE
SELECT * FROM events
WHERE created_at >= '2024-03-01'
  AND created_at < '2024-04-01';
 
-- Seq Scan on events_2024_03
-- Прочитает ТОЛЬКО нужную партицию (не все 400GB!)

Автоматическое создание партиций

-- Функция для создания партиций на будущее
CREATE OR REPLACE FUNCTION create_monthly_partitions(
    table_name TEXT,
    start_date DATE,
    end_date DATE
)
RETURNS void AS $$
DECLARE
    partition_date DATE := start_date;
    partition_name TEXT;
    start_range TEXT;
    end_range TEXT;
BEGIN
    WHILE partition_date < end_date LOOP
        partition_name := table_name || '_' || TO_CHAR(partition_date, 'YYYY_MM');
        start_range := TO_CHAR(partition_date, 'YYYY-MM-DD');
        end_range := TO_CHAR(partition_date + INTERVAL '1 month', 'YYYY-MM-DD');
 
        EXECUTE format(
            'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
            partition_name,
            table_name,
            start_range,
            end_range
        );
 
        RAISE NOTICE 'Создана партиция: %', partition_name;
        partition_date := partition_date + INTERVAL '1 month';
    END LOOP;
END;
$$ LANGUAGE plpgsql;
 
-- Создаём партиции на год вперёд
SELECT create_monthly_partitions('events', '2025-01-01', '2026-01-01');

Архивирование старых данных

-- Отключаем старую партицию от родительской таблицы
ALTER TABLE events DETACH PARTITION events_2022_01;
 
-- Теперь это обычная таблица, можно:
-- 1. Сделать pg_dump и удалить
pg_dump -t events_2022_01 mydb > events_2022_01.sql
DROP TABLE events_2022_01;
 
-- 2. Переместить в отдельную схему (архив)
CREATE SCHEMA archive;
ALTER TABLE events_2022_01 SET SCHEMA archive;
 
-- 3. Переместить в другую базу данных

List Partitioning (по категориям)

CREATE TABLE products (
    id BIGSERIAL,
    name TEXT NOT NULL,
    category TEXT NOT NULL,
    price NUMERIC(10,2)
) PARTITION BY LIST (category);
 
CREATE TABLE products_electronics PARTITION OF products
    FOR VALUES IN ('laptop', 'phone', 'tablet');
 
CREATE TABLE products_clothing PARTITION OF products
    FOR VALUES IN ('shirt', 'pants', 'shoes');
 
CREATE TABLE products_other PARTITION OF products DEFAULT;
-- DEFAULT партиция для всех остальных значений

Hash Partitioning (равномерное распределение)

-- Полезно когда нет естественного ключа партиционирования
CREATE TABLE user_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id BIGINT NOT NULL,
    data JSONB
) PARTITION BY HASH (user_id);
 
-- 4 партиции (hash mod 4)
CREATE TABLE user_sessions_0 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
 
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
 
CREATE TABLE user_sessions_2 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
 
CREATE TABLE user_sessions_3 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Реальный кейс: спасение базы 3TB

Проект: IoT-платформа для телеметрии (2023)

Проблема: Таблица sensor_readings выросла до 3TB за 2 года. Запросы за последний месяц выполнялись 20-30 секунд. VACUUM FULL занимал 18 часов.

Решение:

-- 1. Создали новую партиционированную таблицу
CREATE TABLE sensor_readings_new (
    id BIGSERIAL,
    sensor_id BIGINT NOT NULL,
    value NUMERIC,
    recorded_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (recorded_at);
 
-- 2. Создали партиции по месяцам (за 3 года)
SELECT create_monthly_partitions('sensor_readings_new', '2021-01-01', '2024-01-01');
 
-- 3. Миграция данных (по частям, чтобы не убить БД)
-- Скрипт выполнялся 3 дня
DO $$
DECLARE
    start_date DATE := '2021-01-01';
    end_date DATE;
BEGIN
    WHILE start_date < '2024-01-01' LOOP
        end_date := start_date + INTERVAL '1 month';
 
        RAISE NOTICE 'Мигрируем данные за %', start_date;
 
        INSERT INTO sensor_readings_new
        SELECT * FROM sensor_readings
        WHERE recorded_at >= start_date AND recorded_at < end_date;
 
        COMMIT;
        PERFORM pg_sleep(60); -- Пауза 1 минута между батчами
 
        start_date := end_date;
    END LOOP;
END $$;
 
-- 4. Переключили таблицы
BEGIN;
ALTER TABLE sensor_readings RENAME TO sensor_readings_old;
ALTER TABLE sensor_readings_new RENAME TO sensor_readings;
COMMIT;
 
-- 5. Удалили старые данные (старше 1 года)
DROP TABLE sensor_readings_old_2021_01;
DROP TABLE sensor_readings_old_2021_02;
-- ...

Результат:

  • ⚡ Запросы за последний месяц: 30s → 200ms
  • 💾 Освободили 2TB (удалили старые партиции)
  • 🔧 VACUUM занимает 15 минут вместо 18 часов
  • 📈 Архивирование старых данных — одна SQL-команда

Pro tip: Начинайте партиционировать СРАЗУ, если знаете, что таблица будет большой. Миграция существующей таблицы на 500GB+ — это боль и downtime.

⚠️ Грабли и подводные камни

Партиционирование — мощный инструмент, но с ним легко наступить на грабли. Вот ошибки, которые стоили мне часов debugging'а:

Грабли #1: Забыли создать индексы на партициях

-- ❌ НЕПРАВИЛЬНО: Индекс только на родительской таблице
CREATE INDEX ONLY idx_events_user_id ON events (user_id);
-- Создаст индекс только на parent, но не на партициях!
 
-- ✅ ПРАВИЛЬНО: Индекс распространяется на все партиции
CREATE INDEX idx_events_user_id ON events (user_id);
-- PostgreSQL автоматически создаст индексы на всех существующих и будущих партициях

Почему это важно: Без индексов на партициях запросы будут делать Seq Scan даже с правильным partition pruning.

Грабли #2: INSERT без DEFAULT партиции

-- У вас есть партиции за 2024-2025 год
-- Кто-то пытается вставить запись за 2026 год:
INSERT INTO events (user_id, event_type, created_at)
VALUES (123, 'click', '2026-01-01');
-- ERROR: no partition of relation "events" found for row
 
-- ✅ Решение 1: DEFAULT партиция
CREATE TABLE events_default PARTITION OF events DEFAULT;
-- Все записи, не попадающие в явные партиции, попадут сюда
 
-- ✅ Решение 2: Триггер для автоматического создания партиций
-- Используйте pg_partman (расширение)

Почему это важно: Production падает в 3 ночи, когда кто-то пытается вставить данные "из будущего".

Грабли #3: Foreign Keys на/из партиционированных таблиц

-- ✅ FK ИЗ партиционированной таблицы — работает
CREATE TABLE events (
    id BIGSERIAL,
    user_id BIGINT REFERENCES users(id),  -- OK
    created_at TIMESTAMPTZ
) PARTITION BY RANGE (created_at);
 
-- ❌ FK НА партиционированную таблицу — НЕ работает (до PG 16)
CREATE TABLE notifications (
    id BIGSERIAL PRIMARY KEY,
    event_id BIGINT REFERENCES events(id)  -- ERROR в PG < 16
);

Решение: Обновитесь на PostgreSQL 16+ или используйте FK на конкретные партиции (боль).

Грабли #4: VACUUM и ANALYZE на партициях

-- ❌ НЕПРАВИЛЬНО: VACUUM только родительской таблицы
VACUUM events;  -- Не затронет партиции!
 
-- ✅ ПРАВИЛЬНО: VACUUM каждой партиции
VACUUM events_2024_01;
VACUUM events_2024_02;
-- Или через автоматизацию
 
-- Или включите autovacuum для каждой партиции
ALTER TABLE events_2024_01 SET (autovacuum_enabled = true);

Почему это важно: Без VACUUM партиции раздуваются (bloat), производительность падает.

Грабли #5: Неправильный ключ партиционирования в WHERE

-- У вас партиции по created_at
-- ❌ Запрос БЕЗ ключа партиционирования
SELECT * FROM events WHERE user_id = 123;
-- Сканирует ВСЕ партиции (медленно)
 
-- ✅ Запрос С ключом партиционирования
SELECT * FROM events
WHERE user_id = 123
  AND created_at >= '2024-01-01'
  AND created_at < '2024-02-01';
-- Сканирует только одну партицию (быстро)

Решение: Всегда включайте ключ партиционирования в WHERE, если возможно.

Грабли #6: Удаление большой партиции (блокировка таблицы)

-- ❌ МЕДЛЕННО и блокирует таблицу
DELETE FROM events WHERE created_at < '2022-01-01';
-- Если это 100GB данных — база встанет на часы
 
-- ✅ БЫСТРО: отключить и удалить партицию
ALTER TABLE events DETACH PARTITION events_2021_12;  -- мгновенно
DROP TABLE events_2021_12;  -- быстро, не блокирует родительскую таблицу

Почему это важно: DELETE из партиционированной таблицы = полный кошмар для production.


Когда применять каждый паттерн

ПаттернКогда использоватьНе использовать если
JSONBГибкая схема, частые изменения атрибутовКритичные поля, нужны foreign keys
Partial Indexes95%+ данных не нужны в индексеДанные распределены равномерно
Generated ColumnsЧасто вычисляемые поляРедкие вычисления, экономия места
RLSМультитенантность, sensitive dataВысокая производительность критична
Advisory LocksРаспределённые задачи, дедупликацияКороткие операции, нужен TTL
Materialized ViewsТяжёлая аналитика, дашбордыДанные меняются каждую секунду
PartitioningТаблицы > 100GB, архивированиеМаленькие таблицы, нет естественного ключа

Чек-лист: что проверить в вашем проекте

Пройдитесь по этому списку и найдёте как минимум 3-5 мест для оптимизации:

JSONB:

  • Есть ли таблицы с 10+ колонками, где 50%+ NULL?
  • Добавляете ли вы миграции каждый раз, когда меняется схема?
  • Используете ли EAV (Entity-Attribute-Value) паттерн?

Partial Indexes:

  • Есть ли индексы, где 90%+ строк имеют одно значение?
  • Используете ли soft delete (deleted_at IS NULL)?
  • Фильтруете ли постоянно по статусу (active, pending)?

Generated Columns:

  • Есть ли поля, которые вычисляются в каждом SELECT?
  • Используете ли триггеры для обновления вычисляемых полей?
  • Нужен ли полнотекстовый поиск?

RLS:

  • Есть ли мультитенантность (company_id, organization_id)?
  • Дублируете ли WHERE tenant_id = ... в каждом запросе?
  • Были ли инциденты с утечкой данных между клиентами?

Advisory Locks:

  • Используете ли Redis только для блокировок?
  • Есть ли проблема с дублированием задач в очереди?
  • Нужна ли эксклюзивность выполнения (cron, migrations)?

Materialized Views:

  • Есть ли запросы, которые выполняются > 5 секунд?
  • Кешируете ли агрегаты в Redis/Memcached?
  • Есть ли дашборды с тяжёлой аналитикой?

Partitioning:

  • Есть ли таблицы > 50GB?
  • Нужно ли архивировать старые данные?
  • Запросы фильтруются по дате/категории?

Заключение: PostgreSQL — это больше, чем просто SQL

Эти 7 паттернов я использую в 90% проектов. Они спасали production, экономили недели разработки и предотвращали инциденты.

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

  • NoSQL (JSONB)
  • Full-text search (tsvector)
  • Распределённые блокировки (advisory locks)
  • Мультитенантность (RLS)
  • Time-series данные (partitioning)
  • И ещё дюжину вещей, о которых я не написал

Roadmap освоения PostgreSQL: практический план на месяц

Не пытайтесь применить все паттерны сразу. Вот пошаговый план для внедрения в ваш проект.

Неделя 1: Фундамент — научитесь измерять

Цель: Перестать гадать, начать измерять.

Задачи:

  1. Установите pg_stat_statements (если ещё не установлен)

    • Добавьте shared_preload_libraries = 'pg_stat_statements' в postgresql.conf
    • Рестарт PostgreSQL + CREATE EXTENSION pg_stat_statements
  2. Найдите 10 самых медленных запросов вашего приложения

    • Используйте запрос из секции "Как измерять эффективность"
    • Запишите их execution time (это ваш baseline)
  3. Научитесь читать EXPLAIN ANALYZE

    • Возьмите самый медленный запрос
    • Выполните EXPLAIN (ANALYZE, BUFFERS) ...
    • Найдите Seq Scan — это кандидаты для индексов
  4. Измерьте cache hit ratio

    • Если < 90% — увеличьте shared_buffers

Результат: Вы знаете, ГДЕ болит ваша база. Теперь можно лечить.


Неделя 2: Быстрые wins — partial indexes и generated columns

Цель: Получить первые результаты с минимальными усилиями.

Задачи:

  1. Аудит индексов

    • Найдите таблицы с soft delete (deleted_at IS NULL)
    • Найдите таблицы с фильтрацией по статусу (status = 'active')
    • Создайте partial indexes
  2. Вычисляемые поля

    • Найдите запросы с LOWER(email), full_name = first || ' ' || last, DATE(created_at)
    • Создайте generated columns + индексы на них
  3. Измерьте результат

    • Повторите EXPLAIN ANALYZE для оптимизированных запросов
    • Запишите новые цифры (должно быть в 5-10 раз быстрее)

Ожидаемый результат: 30-50% запросов стали быстрее. Размер индексов уменьшился.


Неделя 3: Архитектурные паттерны — JSONB или partitioning

Цель: Решить одну системную проблему.

Вариант A: Если у вас гибкая схема

  1. Аудит NULL-значений

    • Найдите таблицы, где > 50% колонок содержат NULL
    • Кандидаты для миграции на JSONB
  2. Пилот на dev

    • Выберите одну таблицу
    • Мигрируйте опциональные поля в JSONB
    • Создайте GIN индекс
  3. Измерьте

    • Размер таблицы (должен уменьшиться)
    • Скорость миграций (должна вырасти)

Вариант B: Если у вас большие таблицы (> 100GB)

  1. Аудит размеров таблиц

    SELECT schemaname, tablename,
           pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename))
    FROM pg_tables
    ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
    LIMIT 10;
  2. Спланируйте партиционирование

    • Выберите таблицу-кандидата
    • Определите ключ партиционирования (обычно дата)
    • Составьте план миграции
  3. Автоматизация через pg_partman

    • Установите расширение pg_partman
    • Настройте автоматическое создание партиций

Ожидаемый результат: Одна большая проблема решена. Опыт для масштабирования на другие таблицы.


Неделя 4: Security и production-готовность

Цель: Закрепить результаты, повысить надёжность.

Задачи:

  1. RLS для мультитенантных таблиц (если applicable)

    • Аудит: найдите таблицы с company_id, organization_id, tenant_id
    • Создайте политики RLS
    • Протестируйте на dev
  2. Advisory locks для критичных операций

    • Найдите места, где нужна эксклюзивность (обработка платежей, cron-задачи)
    • Оберните в pg_try_advisory_lock
  3. Настройте мониторинг

    • Подключите pg_stat_statements к Grafana/pganalyze/pghero
    • Настройте алерты на медленные запросы (> 1s)
    • Настройте алерты на cache hit ratio (< 90%)
  4. Документируйте изменения

    • Запишите "до/после" метрики
    • Создайте runbook для команды

Ожидаемый результат: Production стабильнее, вы видите проблемы ДО клиентов.


Месяц 2 и дальше: Экосистема и глубина

Расширения PostgreSQL:

  • pg_cron — cron-задачи внутри PostgreSQL (обновление materialized views, очистка данных)
  • pg_partman — автоматическое управление партициями
  • pgvector — векторный поиск для AI/ML (если работаете с embeddings)
  • PostGIS — гео-данные (если работаете с картами/локациями)
  • TimescaleDB — расширение для time-series данных (если ваша нагрузка сильно временная)

Инструменты мониторинга:

  • pganalyze — SaaS для мониторинга PostgreSQL (платный, но мощный)
  • pghero — open-source dashboard для PostgreSQL
  • pgBadger — анализатор логов PostgreSQL

Продвинутые темы:

  • MVCC и транзакции — понимание isolation levels, SERIALIZABLE, SELECT FOR UPDATE
  • Репликация — streaming replication, логическая репликация для микросервисов
  • Connection pooling — PgBouncer, Odyssey (когда у вас > 100 соединений)
  • Vacuum tuning — когда стандартный autovacuum не справляется

Полезные ссылки:

Обучение PostgreSQL:

Если хотите глубже изучить PostgreSQL, рекомендую бесплатные курсы от Postgres Professional:

Для разработчиков приложений:

  • DEV1 — базовый курс разработки серверной части (функции, триггеры, процедуры)
  • DEV2 — расширенный курс (производительность, расширения, безопасность)
  • QPT — оптимизация запросов (EXPLAIN, индексы, статистика)

Для администраторов:

  • DBA1 — базовое администрирование
  • DBA2 — настройка и мониторинг
  • DBA3 — резервное копирование и репликация

Каждый курс содержит теорию (до 1 часа) и практические задания. Материалы предоставляются бесплатно — отличный способ прокачать знания без затрат.

Удачи в оптимизации! Если остались вопросы — пишите в комментарии или в Telegram.


P.S. Эта статья — часть серии про базы данных. Следующая статья будет про monitoring и alerting для PostgreSQL (pg_stat_statements, auto_explain, slow query log). Подписывайтесь, чтобы не пропустить!