В 2024-м в три ночи легла таблица events. Четыреста гигабайт. Индексы уже ничего не давали, запросы уходили в timeout, клиенты писали в саппорт. Двенадцать часов простоя, восемь тысяч долларов выручки, крупный договор на грани.
Партиции подняли базу за три часа. Этот приём можно было включить полгода назад. Я не включил, потому что «ещё не терабайт» звучало как план.
Восемь лет я собираю базы для финтеха, магазинов, SaaS и госпроектов. Пробовал MongoDB, Cassandra, DynamoDB. Возвращался к PostgreSQL. Не потому что он умеет всё. Потому что семь приёмов закрывают почти все ночные звонки, а остальное уже экзотика.
- JSONB, когда схема ещё плывёт
- Partial indexes, когда полный индекс жрёт диск
- Generated columns, когда одно и то же считают на каждом запросе
- Row-Level Security, когда
tenant_idзабывают в одном месте - Advisory locks, когда не хочется тащить Redis ради одной блокировки
- Materialized views, когда дашборд душит прод
- 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;Что смотреть:
- Execution Time, общее время выполнения (ваша главная метрика)
- Planning Time, время на построение плана (обычно < 1ms)
- Seq Scan vs Index Scan, полное сканирование таблицы или индекс
- Buffers: shared hit, данные из кеша (быстро)
- 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 ratio | shared_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=42Pro 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 Indexes | 95%+ данных не нужны в индексе | Данные распределены равномерно |
| 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: замер
Перестать гадать.
Задачи:
-
Установите pg_stat_statements (если ещё не установлен)
- Добавьте
shared_preload_libraries = 'pg_stat_statements'вpostgresql.conf - Рестарт PostgreSQL +
CREATE EXTENSION pg_stat_statements
- Добавьте
-
Найдите 10 самых медленных запросов вашего приложения
- Используйте запрос из секции "Как измерять эффективность"
- Запишите их execution time (это ваш baseline)
-
Научитесь читать EXPLAIN ANALYZE
- Возьмите самый медленный запрос
- Выполните
EXPLAIN (ANALYZE, BUFFERS) ... - Найдите Seq Scan, это кандидаты для индексов
-
Измерьте cache hit ratio
- Если < 90%, увеличьте
shared_buffers
- Если < 90%, увеличьте
Результат: Вы знаете, ГДЕ болит ваша база. Теперь можно лечить.
Неделя 2: partial indexes и generated columns
Цель: Получить первые результаты с минимальными усилиями.
Задачи:
-
Аудит индексов
- Найдите таблицы с soft delete (
deleted_at IS NULL) - Найдите таблицы с фильтрацией по статусу (
status = 'active') - Создайте partial indexes
- Найдите таблицы с soft delete (
-
Вычисляемые поля
- Найдите запросы с
LOWER(email),full_name = first || ' ' || last,DATE(created_at) - Создайте generated columns + индексы на них
- Найдите запросы с
-
Измерьте результат
- Повторите
EXPLAIN ANALYZEдля оптимизированных запросов - Запишите новые цифры (должно быть в 5-10 раз быстрее)
- Повторите
Ожидаемый результат: 30-50% запросов стали быстрее. Размер индексов уменьшился.
Неделя 3: JSONB или partitioning
Цель: Решить одну системную проблему.
Вариант A: Если у вас гибкая схема
-
Аудит NULL-значений
- Найдите таблицы, где > 50% колонок содержат NULL
- Кандидаты для миграции на JSONB
-
Пилот на dev
- Выберите одну таблицу
- Мигрируйте опциональные поля в JSONB
- Создайте GIN индекс
-
Измерьте
- Размер таблицы (должен уменьшиться)
- Скорость миграций (должна вырасти)
Вариант B: Если у вас большие таблицы (> 100GB)
-
Аудит размеров таблиц
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; -
Спланируйте партиционирование
- Выберите таблицу-кандидата
- Определите ключ партиционирования (обычно дата)
- Составьте план миграции
-
Автоматизация через pg_partman
- Установите расширение pg_partman
- Настройте автоматическое создание партиций
Ожидаемый результат: Одна большая проблема решена. Опыт для масштабирования на другие таблицы.
Неделя 4: Security и production-готовность
Цель: Закрепить результаты, повысить надёжность.
Задачи:
-
RLS для мультитенантных таблиц (если applicable)
- Аудит: найдите таблицы с
company_id,organization_id,tenant_id - Создайте политики RLS
- Протестируйте на dev
- Аудит: найдите таблицы с
-
Advisory locks для критичных операций
- Найдите места, где нужна эксклюзивность (обработка платежей, cron-задачи)
- Оберните в
pg_try_advisory_lock
-
Настройте мониторинг
- Подключите pg_stat_statements к Grafana/pganalyze/pghero
- Настройте алерты на медленные запросы (> 1s)
- Настройте алерты на cache hit ratio (< 90%)
-
Документируйте изменения
- Запишите "до/после" метрики
- Создайте 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 Documentation, официальная документация
- Use The Index, Luke, библия индексов
- PostgreSQL Wiki, реальные кейсы
Обучение 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_timeoutReload конфигурации (без рестарта):
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большой, много дисковых операций
Решения:
- Увеличить
shared_buffers(если RAM позволяет) - Проверить индексы (может быть Seq Scan вместо Index Scan)
- Партиционировать таблицу (если она огромная)
Таблицы с большим 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%, таблица раздута, нужен VACUUMlast_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 indexes | idx_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.
Диагностика через мониторинг:
-
Grafana: Spike в
query p95 latency(200ms → 8000ms) -
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 -
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. Почему?
-
Проверка индексов:
SELECT indexname, idx_scan FROM pg_stat_user_indexes WHERE tablename = 'listings' AND indexrelname LIKE '%status%';Результат:
idx_listings_statusимеетidx_scan = 0(не использовался!) -
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 алертов в день, команда начала игнорировать.
Решение:
-
Увеличили порог:
query p95 > 500ms(5x медленнее обычного) -
Добавили
forclause в Prometheus:- alert: PostgreSQLSlowQueries expr: pg_query_p95 > 500 for: 10m # Срабатывает только если проблема длится > 10 минут -
Разделили алерты по 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 можно не ждать трёх ночи.
Три состояния, которые я видел:
- Письмо от клиента, потом
tail -f. - Есть statements, место на диске и счётчик соединений.
- Алерт приходит раньше клиента, раз в неделю смотришь медленные запросы.
Третье стоит дешевле ночного простоя. Четыреста гигабайт events это подтвердили.
