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

PostgreSQL: семь приёмов, которые я открываю в три ночи

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

Таблица events выросла до 400 ГБ. Индексы не спасли. Партиции сделали за три часа то, что надо было сделать полгода назад. Дальше JSONB, RLS, advisory locks и остальное, чем я пользуюсь.

Оглавление

В 2024-м в три ночи легла таблица events. Четыреста гигабайт. Индексы уже ничего не давали, запросы уходили в timeout, клиенты писали в саппорт. Двенадцать часов простоя, восемь тысяч долларов выручки, крупный договор на грани.

Партиции подняли базу за три часа. Этот приём можно было включить полгода назад. Я не включил, потому что «ещё не терабайт» звучало как план.

Восемь лет я собираю базы для финтеха, магазинов, SaaS и госпроектов. Пробовал MongoDB, Cassandra, DynamoDB. Возвращался к PostgreSQL. Не потому что он умеет всё. Потому что семь приёмов закрывают почти все ночные звонки, а остальное уже экзотика.

  1. JSONB, когда схема ещё плывёт
  2. Partial indexes, когда полный индекс жрёт диск
  3. Generated columns, когда одно и то же считают на каждом запросе
  4. Row-Level Security, когда tenant_id забывают в одном месте
  5. Advisory locks, когда не хочется тащить Redis ради одной блокировки
  6. Materialized views, когда дашборд душит прод
  7. Partitioning, когда таблица уже не таблица, а архив

Примеры гонял на PostgreSQL 15-17. Большая часть живёт с 12-й. На прод я ставлю не ниже 15-й.

Сначала замер

Паттерн вслепую это гадание. Сначала EXPLAIN ANALYZE, потом правка, потом снова замер.

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 минут без миграций.

Результат:

Две недели сэкономленного времени, развёртывание новых типов недвижимости за минуты, индексы по 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 забывают в одном месте

Проблема: каждый запрос нужно фильтровать по 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 на уровне БД, аудит показал 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?
  • Нужно ли архивировать старые данные?
  • Запросы фильтруются по дате/категории?

Что из этого брать первым

Эти семь приёмов закрывают почти все ночные звонки. Остальное, JSONB как документная база, полнотекст, RLS, advisory locks, партиции, уже разобрано выше. Всё сразу включать не надо. Сначала замер.

Месяц, если база уже болит

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


Мониторинг, пока клиент ещё не написал

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

Это случилось в 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

Страховка, не украшение

Без pg_stat_statements о медленном запросе сообщает клиент. С ним видно топ по времени до письма в саппорт. С алертами на диск, соединения и WAL можно не ждать трёх ночи.

Три состояния, которые я видел:

  1. Письмо от клиента, потом tail -f.
  2. Есть statements, место на диске и счётчик соединений.
  3. Алерт приходит раньше клиента, раз в неделю смотришь медленные запросы.

Третье стоит дешевле ночного простоя. Четыреста гигабайт events это подтвердили.