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

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

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

Глубокий разбор практических 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 часа) и практические задания. Материалы предоставляются бесплатно — отличный способ прокачать знания без затрат.


Бонус-раздел: Monitoring и Alerting — видеть проблемы до клиентов

"База упала в субботу утром. Никто не знал. Клиенты узнали первыми."

Это случилось в 2022 году. Диск переполнился из-за неконтролируемого роста WAL-логов. База встала. У нас не было алертов на disk space. Потеряли 4 часа работы и доверие клиента.

Урок: Оптимизация запросов — это 50% работы. Остальные 50% — это мониторинг, который предупредит вас ДО того, как всё сломается.

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


Три уровня мониторинга PostgreSQL

Эффективный мониторинг работает на трёх уровнях:

1. Инфраструктурный уровень (system metrics)

  • Disk I/O, CPU, RAM, network
  • Disk space (данные + WAL)
  • Connection count
  • Replication lag (если есть реплики)

2. Уровень базы данных (database metrics)

  • Query performance (slow queries, p95/p99 latency)
  • Cache hit ratio
  • Table/index bloat
  • Transaction rate, commits/rollbacks

3. Уровень приложения (application metrics)

  • Ошибки подключения
  • Timeout'ы запросов
  • Queue depth (если есть)

Правило: Мониторьте все три уровня. Проблема может быть где угодно.


pg_stat_statements: глубокий анализ запросов

Установка и настройка

pg_stat_statements — это обязательное расширение для production. Без него вы летаете вслепую.

Шаг 1: Включение в postgresql.conf

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
 
# Настройки pg_stat_statements
pg_stat_statements.max = 10000              # Максимум записей (по умолчанию 5000)
pg_stat_statements.track = all               # Отслеживать все запросы (top/all)
pg_stat_statements.track_utility = on        # Включая DDL (CREATE, DROP, etc.)
pg_stat_statements.track_planning = on       # PostgreSQL 13+: время планирования
pg_stat_statements.save = on                 # Сохранять между перезапусками

Шаг 2: Рестарт PostgreSQL

sudo systemctl restart postgresql
# Или через pg_ctl
pg_ctl restart -D /var/lib/postgresql/data

Шаг 3: Создание расширения

-- Подключаемся к базе
\c mydb
 
-- Создаём расширение
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
 
-- Проверка
SELECT * FROM pg_stat_statements LIMIT 5;

Топ-10 самых медленных запросов (по среднему времени)

SELECT
    substring(query, 1, 80) as short_query,
    calls,
    ROUND(mean_exec_time::numeric, 2) as avg_ms,
    ROUND(total_exec_time::numeric, 2) as total_ms,
    ROUND((total_exec_time / sum(total_exec_time) OVER ()) * 100, 2) as percent_total,
    ROUND(stddev_exec_time::numeric, 2) as stddev_ms,
    ROUND(max_exec_time::numeric, 2) as max_ms
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

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

  • avg_ms — среднее время (главная метрика)
  • percent_total — процент от всего времени базы (находим bottleneck)
  • stddev_ms — разброс (если большой — запрос нестабилен)
  • calls — частота вызовов

Запросы, жрущие больше всего времени (total impact)

SELECT
    substring(query, 1, 80) as short_query,
    calls,
    ROUND(total_exec_time::numeric, 2) as total_ms,
    ROUND(mean_exec_time::numeric, 2) as avg_ms,
    ROUND((total_exec_time / sum(total_exec_time) OVER ()) * 100, 2) as percent_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Реальный кейс (2024):

Нашли запрос с percent_total = 62%. Это был SELECT с LIKE '%search%' без индекса. Добавили full-text search (tsvector + GIN) — нагрузка на базу упала в 3 раза.

Запросы с высокой дисперсией (нестабильные)

SELECT
    substring(query, 1, 80) as short_query,
    calls,
    ROUND(mean_exec_time::numeric, 2) as avg_ms,
    ROUND(stddev_exec_time::numeric, 2) as stddev_ms,
    ROUND((stddev_exec_time / NULLIF(mean_exec_time, 0)) * 100, 2) as cv_percent
FROM pg_stat_statements
WHERE calls > 100  -- Достаточно вызовов для статистики
ORDER BY stddev_exec_time DESC
LIMIT 10;

Почему важно: Если cv_percent > 50% — запрос нестабилен. Причины:

  • Холодный vs горячий кеш
  • Разные размеры данных (WHERE user_id = ...)
  • Изменение плана запроса (statistics устарели)

Решение: Проверьте статистику (ANALYZE), проверьте индексы.

Сброс статистики (для чистого замера)

-- Сброс всех данных pg_stat_statements
SELECT pg_stat_statements_reset();
 
-- Сброс для конкретного запроса (PostgreSQL 12+)
SELECT pg_stat_statements_reset(userid, dbid, queryid);

Частая ошибка: Смотрят только на mean_exec_time, игнорируя total_exec_time. Медленный запрос, вызываемый 1 раз в день, не проблема. Быстрый запрос, вызываемый 100k раз в час — это bottleneck.


auto_explain: автоматическое логирование медленных запросов

auto_explain — расширение, которое автоматически пишет EXPLAIN ANALYZE для медленных запросов в лог.

Установка и настройка

Шаг 1: postgresql.conf

# Включаем auto_explain
shared_preload_libraries = 'pg_stat_statements,auto_explain'
 
# Настройки auto_explain
auto_explain.log_min_duration = 1000         # Логировать запросы > 1 секунды (в мс)
auto_explain.log_analyze = on                # Выполнять ANALYZE (реальные цифры)
auto_explain.log_buffers = on                # Показывать shared buffers
auto_explain.log_timing = on                 # Показывать время каждого узла
auto_explain.log_triggers = on               # Включать триггеры
auto_explain.log_verbose = off               # VERBOSE режим (много вывода)
auto_explain.log_nested_statements = on      # Логировать вложенные запросы
auto_explain.log_format = text               # Формат: text/json/xml/yaml

Шаг 2: Рестарт PostgreSQL

sudo systemctl restart postgresql

Шаг 3: Проверка логов

# Логи обычно в /var/log/postgresql/
tail -f /var/log/postgresql/postgresql-15-main.log

Пример вывода в логе

2024-12-21 15:30:45 UTC LOG:  duration: 1234.567 ms  plan:
Query Text: SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10;
Limit  (cost=0.42..8.44 rows=10 width=120) (actual time=1205.123..1234.456 rows=10 loops=1)
  Buffers: shared hit=12000 read=45000
  ->  Index Scan Backward using orders_created_at_idx on orders  (cost=0.42..80000.00 rows=100000 width=120) (actual time=1205.120..1234.450 rows=10 loops=1)
        Filter: (user_id = 12345)
        Rows Removed by Filter: 8945620
        Buffers: shared hit=12000 read=45000
Planning Time: 0.234 ms
Execution Time: 1234.678 ms

Что видим:

  • Запрос выполнялся 1234ms (> порога 1000ms)
  • Index Scan, но фильтр убрал 8.9M строк (индекс неэффективен!)
  • shared read=45000 — много чтений с диска (плохой cache hit)

Решение: Создать композитный индекс (user_id, created_at) вместо отдельного на created_at.

Настройка для production (best practices)

# Production: логируем только очень медленные запросы
auto_explain.log_min_duration = 5000         # 5 секунд
 
# Включаем sample_rate для снижения нагрузки
auto_explain.sample_rate = 0.1               # Логировать 10% запросов (PostgreSQL 13+)
 
# Отключаем timing для снижения overhead
auto_explain.log_timing = off                # Меньше overhead на production

Почему sample_rate: На высоконагруженных системах auto_explain может создать overhead 5-10%. Sample rate снижает нагрузку, сохраняя статистическую значимость.

Pro tip: Для development ставьте auto_explain.log_min_duration = 100 (100ms). Это поможет находить потенциальные проблемы до production.


Slow Query Log: классический подход

PostgreSQL может логировать медленные запросы напрямую (без auto_explain).

Настройка в postgresql.conf

# Логирование медленных запросов
log_min_duration_statement = 1000            # Логировать запросы > 1 секунды
 
# Детализация логов
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_statement = 'none'                       # Не логировать все запросы
log_duration = off                           # Не логировать длительность всех запросов
log_checkpoints = on                         # Логировать checkpoints
log_connections = on                         # Логировать подключения
log_disconnections = on                      # Логировать отключения
log_lock_waits = on                          # Логировать блокировки > deadlock_timeout

Reload конфигурации (без рестарта):

SELECT pg_reload_conf();

Анализ логов с pgBadger

pgBadger — лучший инструмент для анализа PostgreSQL логов.

Установка:

# Ubuntu/Debian
sudo apt install pgbadger
 
# macOS
brew install pgbadger
 
# Или через CPAN
cpan install pgBadger

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

# Анализ лога за сегодня
pgbadger /var/log/postgresql/postgresql-15-main.log
 
# Анализ нескольких файлов
pgbadger /var/log/postgresql/postgresql-*.log -o report.html
 
# Инкрементальный анализ (для больших логов)
pgbadger --incremental --outdir /var/www/reports/ /var/log/postgresql/*.log

Что покажет pgBadger:

  • Top 10 самых медленных запросов
  • Top 10 запросов по частоте
  • Распределение времени запросов (histogram)
  • Queries по категориям (SELECT/INSERT/UPDATE/DELETE)
  • Lock waits
  • Temporary files
  • Checkpoints
  • Connections timeline

Реальный кейс (2023):

pgBadger показал, что 78% temp files создаются одним отчётом с ORDER BY на 5M строк. Увеличили work_mem с 4MB до 64MB — temp files исчезли, запрос ускорился в 10 раз.


pg_stat_user_tables и pg_stat_user_indexes: статистика использования

Неиспользуемые индексы (кандидаты на удаление)

SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0               -- Индекс никогда не использовался
  AND indexrelname NOT LIKE '%pkey'  -- Кроме primary keys
ORDER BY pg_relation_size(indexrelid) DESC;

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

Действие: Удалите их (но сначала проверьте через EXPLAIN на production-like данных).

-- Удаление неиспользуемого индекса
DROP INDEX CONCURRENTLY idx_unused;  -- CONCURRENTLY = без блокировки таблицы

Реальный кейс (2024):

Нашли 12 неиспользуемых индексов общим размером 4GB. Удалили — освободили место, ускорили INSERT/UPDATE на 15%.

Таблицы с плохим cache hit ratio

SELECT
    schemaname,
    tablename,
    heap_blks_read,
    heap_blks_hit,
    CASE
        WHEN heap_blks_hit + heap_blks_read = 0 THEN 0
        ELSE ROUND((heap_blks_hit::numeric / (heap_blks_hit + heap_blks_read)) * 100, 2)
    END as cache_hit_ratio
FROM pg_statio_user_tables
WHERE heap_blks_read > 0
ORDER BY cache_hit_ratio ASC, heap_blks_read DESC
LIMIT 20;

Что искать:

  • cache_hit_ratio < 90% — таблица часто читается с диска
  • heap_blks_read большой — много дисковых операций

Решения:

  1. Увеличить shared_buffers (если RAM позволяет)
  2. Проверить индексы (может быть Seq Scan вместо Index Scan)
  3. Партиционировать таблицу (если она огромная)

Таблицы с большим bloat (требуют VACUUM)

SELECT
    schemaname,
    tablename,
    n_live_tup,
    n_dead_tup,
    ROUND((n_dead_tup::numeric / NULLIF(n_live_tup, 0)) * 100, 2) as dead_ratio,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000  -- Больше 1000 мёртвых строк
ORDER BY dead_ratio DESC, n_dead_tup DESC
LIMIT 20;

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

  • dead_ratio > 20% — таблица раздута, нужен VACUUM
  • last_autovacuum IS NULL — autovacuum не работает (плохой знак!)

Решение:

-- Ручной VACUUM для конкретной таблицы
VACUUM ANALYZE table_name;
 
-- Или агрессивный VACUUM FULL (требует эксклюзивной блокировки)
VACUUM FULL table_name;  -- ОСТОРОЖНО: блокирует таблицу!

Алертинг: какие метрики отслеживать

Критичные алерты (срочно реагировать)

МетрикаПорогПочему критично
Disk space> 85% fullБаза упадёт при 100%
Connection count> 80% от maxНовые подключения будут отклонены
Replication lag> 60 секундРеплика отстаёт, данные несогласованы
Long transactions> 10 минутБлокирует VACUUM, создаёт bloat
Lock wait time> 30 секундDeadlock или contention
Cache hit ratio< 85%Слишком много дисковых операций
Query p95 latency> 1 секундаПользователи видят тормоза
Checkpoint frequency> 5 в минутуСлишком частые checkpoint — плохая настройка

Предупреждающие алерты (планировать действия)

МетрикаПорогДействие
Table bloat> 30%Запланировать VACUUM FULL
Index bloat> 50%Пересоздать индекс (REINDEX)
Unused indexesidx_scan = 0Рассмотреть удаление
Temp files> 100MB/часУвеличить work_mem
Sequential scansЧасто на больших таблицахДобавить индексы

Пример алертов для Prometheus + Alertmanager

groups:
  - name: postgresql
    interval: 30s
    rules:
      # Критичный: Disk space
      - alert: PostgreSQLDiskSpaceHigh
        expr: (node_filesystem_avail_bytes{mountpoint="/var/lib/postgresql"} / node_filesystem_size_bytes) < 0.15
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL disk space < 15% ({{ $value }}%)"
 
      # Критичный: Connection count
      - alert: PostgreSQLTooManyConnections
        expr: (pg_stat_database_numbackends / pg_settings_max_connections) > 0.8
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL connections > 80% of max"
 
      # Критичный: Replication lag
      - alert: PostgreSQLReplicationLag
        expr: pg_replication_lag > 60
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL replication lag > 60s ({{ $value }}s)"
 
      # Предупреждение: Cache hit ratio
      - alert: PostgreSQLLowCacheHitRatio
        expr: pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read) < 0.85
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL cache hit ratio < 85% ({{ $value }}%)"
 
      # Предупреждение: Long running queries
      - alert: PostgreSQLLongRunningQueries
        expr: pg_stat_activity_max_tx_duration > 600
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL query running > 10 minutes"

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

1. pganalyze (SaaS, платный)

Что умеет:

  • Автоматический сбор метрик (pg_stat_statements, logs)
  • Index Advisor (рекомендации по индексам)
  • Query Performance Insights
  • EXPLAIN visualization
  • Alerting из коробки

Цена: От $99/месяц за сервер

Плюсы:

  • ✅ Zero setup (подключил и работает)
  • ✅ Красивый UI
  • ✅ Исторические данные

Минусы:

  • ❌ Платный
  • ❌ Данные у третьей стороны

Когда использовать: Если нет времени настраивать мониторинг вручную и есть бюджет.

2. pghero (Open Source)

Установка (Docker):

docker run -p 8080:8080 -e DATABASE_URL=postgres://user:pass@host:5432/dbname ankane/pghero

Что умеет:

  • Dashboard с ключевыми метриками
  • Slow queries
  • Index usage
  • Table bloat
  • Query suggestions

Плюсы:

  • ✅ Open source
  • ✅ Простая установка
  • ✅ Красивый UI

Минусы:

  • ❌ Нет alerting'а (нужно интегрировать с Prometheus/Grafana)
  • ❌ Нет исторических данных по умолчанию

Когда использовать: Для быстрого аудита базы или как дополнение к Prometheus.

3. Prometheus + postgres_exporter + Grafana (Open Source)

Установка postgres_exporter:

# Docker
docker run -d \
  -p 9187:9187 \
  -e DATA_SOURCE_NAME="postgresql://user:password@localhost:5432/mydb?sslmode=disable" \
  prometheuscommunity/postgres-exporter
 
# Или через systemd
wget https://github.com/prometheus-community/postgres_exporter/releases/download/v0.15.0/postgres_exporter-0.15.0.linux-amd64.tar.gz
tar xvfz postgres_exporter-0.15.0.linux-amd64.tar.gz
sudo mv postgres_exporter /usr/local/bin/

Конфигурация Prometheus (prometheus.yml):

scrape_configs:
  - job_name: "postgresql"
    static_configs:
      - targets: ["localhost:9187"]

Grafana Dashboard:

Импортируйте готовый дашборд: PostgreSQL Database Dashboard

Плюсы:

  • ✅ Open source
  • ✅ Полный контроль
  • ✅ Гибкий alerting
  • ✅ Интеграция с другими метриками (system, application)

Минусы:

  • ❌ Сложная настройка
  • ❌ Требует поддержки инфраструктуры

Когда использовать: Для серьёзных production систем, где уже есть Prometheus/Grafana.


Реальный кейс: как мониторинг спас production

Проект: Marketplace для аренды недвижимости (2023)

Проблема:

В понедельник утром (9:00) пользователи начали жаловаться на тормоза. Страницы грузились 10-15 секунд вместо обычных 0.5s.

Диагностика через мониторинг:

  1. Grafana: Spike в query p95 latency (200ms → 8000ms)

  2. pg_stat_statements:

    SELECT substring(query, 1, 80), mean_exec_time
    FROM pg_stat_statements
    ORDER BY mean_exec_time DESC LIMIT 5;

    Результат: SELECT * FROM listings WHERE ... вырос с 50ms до 7000ms

  3. auto_explain logs:

    Seq Scan on listings  (cost=0.00..185432.00 rows=50 width=1200) (actual time=245.123..6840.456 rows=3 loops=1)
      Filter: ((status = 'active') AND (city = 'Moscow'))
      Rows Removed by Filter: 8945632
    

    Диагноз: Seq Scan вместо Index Scan. Почему?

  4. Проверка индексов:

    SELECT indexname, idx_scan FROM pg_stat_user_indexes
    WHERE tablename = 'listings' AND indexrelname LIKE '%status%';

    Результат: idx_listings_status имеет idx_scan = 0 (не использовался!)

  5. EXPLAIN на проблемном запросе:

    EXPLAIN ANALYZE
    SELECT * FROM listings WHERE status = 'active' AND city = 'Moscow';

    Plan показал: индекс игнорируется из-за устаревшей статистики (n_distinct неверный).

Решение:

-- Обновили статистику
ANALYZE listings;
 
-- Пересоздали индекс на всякий случай
REINDEX INDEX CONCURRENTLY idx_listings_status;

Результат:

  • ⏱️ Query latency: 7000ms → 35ms (в 200 раз быстрее)
  • 🎯 Проблема найдена и решена за 20 минут
  • 📊 Добавили алерт на query p95 > 1s

Без мониторинга: мы бы потратили часы на debugging. С мониторингом — 20 минут.


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

Грабли #1: pg_stat_statements не установлен на production

-- ❌ Это я узнал в момент инцидента
SELECT * FROM pg_stat_statements;
-- ERROR: extension "pg_stat_statements" is not available
 
-- ✅ Проверка ПЕРЕД тем, как случится проблема
SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements';
-- Если пусто — установите СЕЙЧАС

Когда случилось: Production проблема в 2022 году. Не могли найти медленный запрос, потому что pg_stat_statements не был установлен.

Решение: Всегда устанавливайте pg_stat_statements сразу после создания базы.

Грабли #2: auto_explain с слишком низким порогом убил production

# ❌ ОПАСНО: Логирует ВСЕ запросы > 10ms
auto_explain.log_min_duration = 10
 
# На высоконагруженной системе это создало:
# - 50GB логов за 2 часа
# - Переполнение диска
# - Падение базы

Решение:

# ✅ БЕЗОПАСНО: Логируем только реально медленные
auto_explain.log_min_duration = 5000         # 5 секунд для production
auto_explain.sample_rate = 0.1               # 10% запросов (PostgreSQL 13+)

Правило: На production начинайте с высокого порога (5-10 секунд), потом снижайте по необходимости.

Грабли #3: Мониторинг не показывал проблему, потому что запросы через connection pooler

Проблема:

  • У нас был PgBouncer в режиме transaction pooling
  • pg_stat_statements показывал user=pgbouncer для всех запросов
  • Невозможно понять, какое приложение генерирует медленные запросы

Решение:

# PgBouncer config
application_name_add_host = 1  # Добавляет hostname в application_name

И в приложении:

# Python (psycopg2)
conn = psycopg2.connect(
    "...",
    application_name="my-app-worker-1"  # Явно указываем имя приложения
)

Теперь в pg_stat_statements видно:

SELECT usename, application_name, query, calls
FROM pg_stat_activity;

Грабли #4: Алерты срабатывали слишком часто (alert fatigue)

Проблема:

Настроили алерт на query p95 > 100ms. Результат: 50 алертов в день, команда начала игнорировать.

Решение:

  1. Увеличили порог: query p95 > 500ms (5x медленнее обычного)

  2. Добавили for clause в Prometheus:

    - alert: PostgreSQLSlowQueries
      expr: pg_query_p95 > 500
      for: 10m # Срабатывает только если проблема длится > 10 минут
  3. Разделили алерты по severity:

    • critical — требует немедленного действия (disk full, replication lag)
    • warning — нужно посмотреть в течение дня (cache hit ratio, bloat)

Правило: Лучше 1 точный алерт, чем 100 ложных.

Грабли #5: Не мониторили WAL disk space

Проблема:

PostgreSQL хранит WAL-логи отдельно. У нас WAL был на отдельном диске (SSD для скорости). Диск переполнился → база встала.

Решение:

# Мониторинг WAL директории
df -h /var/lib/postgresql/15/main/pg_wal
 
# Алерт в Prometheus
node_filesystem_avail_bytes{mountpoint="/var/lib/postgresql/15/main/pg_wal"}

Best practice: Мониторьте disk space на ВСЕХ директориях PostgreSQL:

  • /var/lib/postgresql/ (данные)
  • .../pg_wal/ (WAL логи)
  • Tablespaces (если есть)

Чек-лист: минимальный мониторинг для production

Используйте этот чек-лист для аудита вашего мониторинга:

Базовые метрики (MUST HAVE):

  • pg_stat_statements установлен и настроен
  • Disk space monitored (данные + WAL)
  • Connection count monitored
  • Cache hit ratio > 90%
  • Query p95 latency < 500ms
  • Replication lag < 10s (если есть реплики)

Логирование (рекомендуется):

  • auto_explain включён (порог 5-10 секунд)
  • log_min_duration_statement = 1000
  • log_lock_waits = on
  • log_checkpoints = on

Алертинг (критичный):

  • Disk space > 85% → критичный алерт
  • Connection count > 80% max → критичный алерт
  • Replication lag > 60s → критичный алерт
  • Query p95 > 1s → предупреждение

Инструменты (выберите минимум один):

  • pghero (быстрый старт)
  • pganalyze (SaaS, платный, но удобный)
  • Prometheus + Grafana (для серьёзных систем)
  • pgBadger (анализ логов)

Процессы:

  • Weekly review медленных запросов (pg_stat_statements)
  • Monthly review неиспользуемых индексов
  • Quarterly review table/index bloat

Заключение: мониторинг — это страховка

Мониторинг PostgreSQL — это не "nice to have", это must have для любого production проекта.

Три уровня зрелости мониторинга:

Уровень 1: Реактивный (😰)

  • Узнаёте о проблемах от клиентов
  • Нет метрик, нет логов
  • Debugging занимает часы

Уровень 2: Базовый мониторинг (😌)

  • pg_stat_statements установлен
  • Disk space и connection count monitored
  • Находите проблемы за минуты

Уровень 3: Проактивный (😎)

  • Автоматические алерты на критичные метрики
  • Регулярный review медленных запросов
  • Видите проблемы ДО клиентов

Стремитесь к уровню 3.

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