Полное руководство по SQL для администраторов PostgreSQL

Контекст: медицинская система МедИнфо и проблемы производительности

Компания «МедИнфо» разрабатывает информационную систему для сети из 45 клиник. База данных PostgreSQL 15 содержит 280 таблиц, 12 миллионов записей пациентов, 85 миллионов записей приёмов и назначений. Ежедневно система обрабатывает 200 000 запросов от 3 000 врачей и администраторов.

Клиент обратился к нам с критической проблемой: формирование отчёта по истории лечения пациента занимало до 45 секунд. Врач вынужден был ждать почти минуту, чтобы увидеть полную картину заболеваний. Ночные аналитические отчёты для руководства клиник не успевали завершиться до начала рабочего дня.

Наша команда ITFresh провела глубокий аудит SQL-запросов и архитектуры базы данных. В этом руководстве мы расскажем о найденных проблемах, применённых решениях и ключевых знаниях SQL, которые необходимы каждому администратору PostgreSQL. Подробнее — на itfresh.ru.

Нормализация: когда она помогает, а когда мешает

Первое, что мы проверили — структуру таблиц. База данных «МедИнфо» была нормализована до 3NF (третья нормальная форма), что является стандартной практикой. Однако в некоторых случаях избыточная нормализация создавала проблемы производительности.

Нормальные формы — это правила декомпозиции таблиц для устранения логической избыточности данных:

  • 1NF — атомарность: каждая ячейка содержит одно значение, нет повторяющихся групп
  • 2NF — полная функциональная зависимость: каждый неключевой атрибут зависит от всего первичного ключа
  • 3NF — отсутствие транзитивных зависимостей: неключевые атрибуты не зависят друг от друга
  • BCNF (Бойса-Кодда) — каждая функциональная зависимость определяется суперключом

Проблема: 7 JOIN-ов для одного запроса

Запрос карточки пациента требовал соединения 7 таблиц:

-- Исходный запрос: 45 секунд на пациенте с длинной историей
SELECT
    p.last_name, p.first_name, p.birth_date,
    v.visit_date, v.complaint,
    d.diagnosis_code, d.diagnosis_name,
    doc.last_name AS doctor_name, doc.specialization,
    pr.prescription_text, pr.dosage,
    dept.department_name,
    ins.insurance_number, ins.company_name
FROM patients p
    JOIN visits v ON v.patient_id = p.id
    JOIN diagnoses d ON d.visit_id = v.id
    JOIN doctors doc ON doc.id = v.doctor_id
    JOIN prescriptions pr ON pr.visit_id = v.id
    JOIN departments dept ON dept.id = doc.department_id
    JOIN insurance ins ON ins.patient_id = p.id
WHERE p.id = 12345
ORDER BY v.visit_date DESC;

Мы применили стратегическую денормализацию: добавили материализованное представление для часто запрашиваемых данных карточки:

-- Материализованное представление для быстрого доступа
CREATE MATERIALIZED VIEW mv_patient_card AS
SELECT
    p.id AS patient_id,
    p.last_name || ' ' || p.first_name AS patient_name,
    p.birth_date,
    v.id AS visit_id,
    v.visit_date,
    v.complaint,
    d.diagnosis_code,
    d.diagnosis_name,
    doc.last_name AS doctor_name,
    doc.specialization,
    dept.department_name,
    ins.insurance_number
FROM patients p
    JOIN visits v ON v.patient_id = p.id
    LEFT JOIN diagnoses d ON d.visit_id = v.id
    JOIN doctors doc ON doc.id = v.doctor_id
    JOIN departments dept ON dept.id = doc.department_id
    LEFT JOIN insurance ins ON ins.patient_id = p.id;

-- Индекс на материализованном представлении
CREATE UNIQUE INDEX idx_mv_patient_card
    ON mv_patient_card (patient_id, visit_id);

-- Автоматическое обновление каждые 5 минут
SELECT cron.schedule('refresh_patient_card',
    '*/5 * * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_patient_card');

Время запроса карточки сократилось с 45 секунд до 120 мс.

Транзакции и ACID: предотвращение потери данных

В системе «МедИнфо» мы обнаружили несколько операций, выполнявшихся без транзакций. Например, запись нового приёма пациента состояла из 4 INSERT-ов в разные таблицы. При сбое на третьем шаге данные оставались в неконсистентном состоянии.

ACID — фундаментальные свойства транзакций:

  • Atomicity (Атомарность) — транзакция выполняется целиком или не выполняется вообще
  • Consistency (Согласованность) — база данных переходит из одного валидного состояния в другое
  • Isolation (Изолированность) — параллельные транзакции не влияют друг на друга
  • Durability (Долговечность) — после фиксации данные сохраняются даже при сбое

Правильная обработка транзакций

Мы обернули все многошаговые операции в транзакции с savepoints для частичного отката:

-- Запись нового приёма пациента (атомарная операция)
BEGIN;

-- Создаём визит
INSERT INTO visits (patient_id, doctor_id, visit_date, complaint)
VALUES (12345, 67, NOW(), 'Головная боль, повышенное давление')
RETURNING id INTO visit_id;

SAVEPOINT after_visit;

-- Добавляем диагноз
INSERT INTO diagnoses (visit_id, diagnosis_code, diagnosis_name)
VALUES (visit_id, 'I10', 'Эссенциальная гипертензия');

SAVEPOINT after_diagnosis;

-- Добавляем назначение
INSERT INTO prescriptions (visit_id, prescription_text, dosage, duration_days)
VALUES (visit_id, 'Амлодипин', '5 мг, 1 раз в день', 30);

-- Обновляем статистику врача
UPDATE doctor_stats
SET visits_today = visits_today + 1,
    last_visit_at = NOW()
WHERE doctor_id = 67;

COMMIT;

-- При ошибке на любом этапе:
-- ROLLBACK TO SAVEPOINT after_visit;  -- откатить до визита
-- или
-- ROLLBACK;  -- откатить всё

Уровни изоляции и конкурентный доступ

При одновременной работе 3 000 пользователей критически важен правильный уровень изоляции. PostgreSQL поддерживает 4 уровня:

УровеньГрязное чтениеНеповторяемое чтениеФантомное чтение
Read UncommittedВ PG = Read CommittedВозможноВозможно
Read Committed (default)НетВозможноВозможно
Repeatable ReadНетНетНет (в PG!)
SerializableНетНетНет

Важная особенность PostgreSQL: уровень Repeatable Read в PostgreSQL защищает от фантомных чтений (в отличие от стандарта SQL, где это не гарантируется). Это связано с реализацией MVCC (Multi-Version Concurrency Control).

Для отчётов «МедИнфо» мы использовали Repeatable Read, чтобы гарантировать консистентность данных во время длительного формирования:

-- Формирование ежедневного отчёта
BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT
    dept.department_name,
    COUNT(v.id) AS total_visits,
    COUNT(DISTINCT v.patient_id) AS unique_patients,
    AVG(EXTRACT(EPOCH FROM (v.end_time - v.visit_date))) AS avg_duration_sec
FROM visits v
    JOIN doctors doc ON doc.id = v.doctor_id
    JOIN departments dept ON dept.id = doc.department_id
WHERE v.visit_date >= CURRENT_DATE
GROUP BY dept.department_name
ORDER BY total_visits DESC;

COMMIT;

Блокировки: предотвращение deadlock в медицинской системе

Мы обнаружили в логах PostgreSQL регулярные deadlock-и — в среднем 15-20 в день. Причина: два врача одновременно обновляли данные одного и того же пациента (например, при переводе между отделениями).

-- Проблемный паттерн, вызывающий deadlock:
-- Транзакция 1 (врач A): обновляет пациента, потом визит
UPDATE patients SET status = 'transferred' WHERE id = 12345;  -- блокирует строку patients
UPDATE visits SET department_id = 5 WHERE patient_id = 12345;  -- ждёт строку visits

-- Транзакция 2 (врач B): обновляет визит, потом пациента
UPDATE visits SET notes = 'Перевод' WHERE patient_id = 12345;  -- блокирует строку visits
UPDATE patients SET current_dept = 5 WHERE id = 12345;  -- ждёт строку patients
-- DEADLOCK!

Решение: явные блокировки и единый порядок

Мы установили правило: все транзакции, затрагивающие несколько таблиц, блокируют строки в одном и том же порядке — сначала пациент, затем визиты:

-- Правильный паттерн: блокируем в фиксированном порядке
BEGIN;

-- Шаг 1: Явная блокировка строки пациента
SELECT id FROM patients WHERE id = 12345 FOR UPDATE;

-- Шаг 2: Блокировка связанных визитов
SELECT id FROM visits WHERE patient_id = 12345 FOR UPDATE;

-- Шаг 3: Обновления в любом порядке (строки уже заблокированы)
UPDATE patients SET status = 'transferred', current_dept = 5 WHERE id = 12345;
UPDATE visits SET department_id = 5, notes = 'Перевод' WHERE patient_id = 12345;

COMMIT;

Для мониторинга блокировок мы настроили алерт:

-- Запрос для обнаружения активных блокировок
SELECT
    blocked_locks.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocked_activity.query AS blocked_query,
    blocking_activity.query AS blocking_query,
    now() - blocked_activity.query_start AS blocked_duration
FROM pg_catalog.pg_locks blocked_locks
    JOIN pg_catalog.pg_stat_activity blocked_activity
        ON blocked_activity.pid = blocked_locks.pid
    JOIN pg_catalog.pg_locks blocking_locks
        ON blocking_locks.locktype = blocked_locks.locktype
        AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
        AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
        AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
        AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
        AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
        AND blocking_locks.pid != blocked_locks.pid
    JOIN pg_catalog.pg_stat_activity blocking_activity
        ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

После внедрения единого порядка блокировок количество deadlock-ов снизилось с 15-20 до 0 в день.

Индексы: стратегия для 85 миллионов записей

Аудит индексов показал две проблемы: критически важные запросы работали без индексов (Seq Scan по 85-миллионной таблице), а на других таблицах висели неиспользуемые индексы, замедляющие INSERT-ы.

Типы индексов в PostgreSQL

PostgreSQL поддерживает 6 типов индексов, каждый для своего сценария:

ТипПрименениеПример
B-treeРавенство, диапазоны, сортировка (90% случаев)WHERE date BETWEEN ... AND ...
HashТолько точное равенствоWHERE uuid = '...'
GiSTГеоданные, полнотекст, диапазоныWHERE location <@ box '...'
GINМассивы, JSONB, полнотекстовый поискWHERE tags @> '{кардиология}'
BRINОгромные таблицы с естественной сортировкойWHERE created_at > '2026-01-01' (лог-таблицы)
SP-GiSTИерархические данные, IP-адресаWHERE ip << inet '10.0.0.0/8'

Для таблицы визитов мы создали набор оптимальных индексов:

-- B-tree для поиска по пациенту и дате
CREATE INDEX CONCURRENTLY idx_visits_patient_date
    ON visits (patient_id, visit_date DESC);

-- Частичный индекс для активных визитов (10% данных)
CREATE INDEX CONCURRENTLY idx_visits_active
    ON visits (doctor_id, visit_date)
    WHERE status = 'in_progress';

-- BRIN для таблицы аудита (250M записей, хронологически упорядочена)
CREATE INDEX CONCURRENTLY idx_audit_created
    ON audit_log USING brin (created_at)
    WITH (pages_per_range = 32);

-- GIN для JSONB-поля с дополнительными данными визита
CREATE INDEX CONCURRENTLY idx_visits_extra_data
    ON visits USING gin (extra_data jsonb_path_ops);

Важно: ключевое слово CONCURRENTLY создаёт индекс без блокировки таблицы — критично для production базы, которая должна оставаться доступной.

Удаление неиспользуемых индексов

Мы обнаружили 34 неиспользуемых индекса, которые замедляли операции записи и занимали 12 ГБ дискового пространства:

-- Поиск неиспользуемых индексов
SELECT
    schemaname || '.' || relname AS table,
    indexrelname AS index,
    pg_size_pretty(pg_relation_size(i.indexrelid)) AS size,
    idx_scan AS times_used
FROM pg_stat_user_indexes ui
    JOIN pg_index i ON ui.indexrelid = i.indexrelid
WHERE idx_scan < 10  -- менее 10 использований
    AND NOT i.indisunique  -- не уникальный
    AND pg_relation_size(i.indexrelid) > 1048576  -- больше 1 МБ
ORDER BY pg_relation_size(i.indexrelid) DESC;

-- Удаление после подтверждения
DROP INDEX CONCURRENTLY idx_old_unused_index;

Удаление 34 неиспользуемых индексов ускорило INSERT-ы на 23% и освободило 12 ГБ дискового пространства.

EXPLAIN ANALYZE: как находить узкие места в запросах

Главный инструмент оптимизации — EXPLAIN ANALYZE, который показывает план выполнения запроса с реальными временными метриками:

-- Анализ проблемного запроса
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT
    p.last_name,
    COUNT(v.id) AS total_visits,
    MAX(v.visit_date) AS last_visit
FROM patients p
    JOIN visits v ON v.patient_id = p.id
WHERE v.visit_date >= '2025-01-01'
GROUP BY p.id
HAVING COUNT(v.id) > 5
ORDER BY total_visits DESC
LIMIT 100;

-- Результат ДО оптимизации:
Limit (actual time=12450.123..12450.145 rows=100)
  -> Sort (actual time=12450.120..12450.130 rows=100)
    -> HashAggregate (actual time=12380.456..12430.789 rows=8523)
      -> Hash Join (actual time=245.678..11890.123 rows=2340567)
        -> Seq Scan on visits (actual time=0.015..8934.567 rows=2340567)
             Filter: (visit_date >= '2025-01-01')
             Rows Removed by Filter: 82659433
        -> Hash (actual time=234.567..234.567 rows=12000000)
             Buffers: shared hit=123456
          -> Seq Scan on patients (actual time=0.012..189.345 rows=12000000)

Ключевой момент: Seq Scan по таблице visits с фильтрацией 82 миллионов строк. После создания индекса на (visit_date, patient_id):

-- Результат ПОСЛЕ индексации:
Limit (actual time=45.123..45.145 rows=100)
  -> Sort (actual time=45.120..45.130 rows=100)
    -> HashAggregate (actual time=43.456..44.789 rows=8523)
      -> Nested Loop (actual time=0.234..38.123 rows=234567)
        -> Index Scan using idx_visits_date_patient on visits
             (actual time=0.056..12.345 rows=234567)
             Index Cond: (visit_date >= '2025-01-01')
        -> Index Scan using patients_pkey on patients
             (actual time=0.001..0.001 rows=1)

Время выполнения сократилось с 12.4 секунд до 45 мс — ускорение в 275 раз.

Продвинутые техники: CTE, оконные функции, партиционирование

Для аналитических отчётов «МедИнфо» мы использовали продвинутые возможности PostgreSQL:

CTE и оконные функции для медицинской аналитики

Отчёт по эффективности врачей с ранжированием по отделениям:

-- CTE для подготовки данных
WITH doctor_metrics AS (
    SELECT
        doc.id AS doctor_id,
        doc.last_name,
        dept.department_name,
        COUNT(v.id) AS visit_count,
        AVG(EXTRACT(EPOCH FROM (v.end_time - v.visit_date)))/60 AS avg_duration_min,
        COUNT(DISTINCT v.patient_id) AS unique_patients
    FROM doctors doc
        JOIN visits v ON v.doctor_id = doc.id
        JOIN departments dept ON dept.id = doc.department_id
    WHERE v.visit_date >= date_trunc('month', CURRENT_DATE)
    GROUP BY doc.id, doc.last_name, dept.department_name
),
department_avg AS (
    SELECT
        department_name,
        AVG(visit_count) AS dept_avg_visits,
        AVG(avg_duration_min) AS dept_avg_duration
    FROM doctor_metrics
    GROUP BY department_name
)
-- Оконные функции для ранжирования
SELECT
    dm.last_name,
    dm.department_name,
    dm.visit_count,
    ROUND(dm.avg_duration_min::numeric, 1) AS avg_min,
    dm.unique_patients,
    RANK() OVER (
        PARTITION BY dm.department_name
        ORDER BY dm.visit_count DESC
    ) AS rank_in_dept,
    ROUND(
        (dm.visit_count - da.dept_avg_visits) / da.dept_avg_visits * 100
    , 1) AS pct_above_avg
FROM doctor_metrics dm
    JOIN department_avg da ON da.department_name = dm.department_name
ORDER BY dm.department_name, rank_in_dept;

Партиционирование таблицы визитов

Таблица visits с 85 миллионами записей — кандидат на партиционирование по дате:

-- Создание партиционированной таблицы
CREATE TABLE visits_partitioned (
    id BIGSERIAL,
    patient_id BIGINT NOT NULL,
    doctor_id INT NOT NULL,
    visit_date TIMESTAMPTZ NOT NULL,
    complaint TEXT,
    status VARCHAR(20) DEFAULT 'scheduled',
    end_time TIMESTAMPTZ,
    extra_data JSONB
) PARTITION BY RANGE (visit_date);

-- Автоматическое создание партиций по месяцам
CREATE TABLE visits_2026_01 PARTITION OF visits_partitioned
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE visits_2026_02 PARTITION OF visits_partitioned
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE visits_2026_03 PARTITION OF visits_partitioned
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- Индексы создаются для каждой партиции автоматически
CREATE INDEX ON visits_partitioned (patient_id, visit_date DESC);
CREATE INDEX ON visits_partitioned (doctor_id) WHERE status = 'in_progress';

Партиционирование дало двойную выгоду: запросы за текущий месяц сканируют только одну партицию (2-3 миллиона строк вместо 85 миллионов), а удаление старых данных — мгновенная операция DROP TABLE visits_2024_01 вместо длительного DELETE.

Результаты оптимизации базы данных МедИнфо

За 4 недели работы наша команда из 2 DBA-специалистов провела полную оптимизацию PostgreSQL для системы «МедИнфо»:

МетрикаДо оптимизацииПосле оптимизации
Загрузка карточки пациента45 секунд120 мс
Ежедневный аналитический отчёт4 часа 20 мин18 минут
Deadlock-и в день15-200
P99 латентность запросов8.5 секунд450 мс
Размер индексов89 ГБ62 ГБ
Среднее время INSERT12 мс3.2 мс
Средняя нагрузка CPU78%31%

Ключевые уроки:

  • Денормализация через материализованные представления — мощный инструмент для read-heavy нагрузок
  • Неиспользуемые индексы замедляют запись и занимают место — регулярный аудит обязателен
  • EXPLAIN ANALYZE — единственный способ понять реальное поведение запроса, не доверяйте интуиции
  • Партиционирование по дате — стандартное решение для растущих таблиц с временной привязкой
  • Единый порядок блокировок полностью устраняет deadlock-и

Часто задаваемые вопросы

Денормализация оправдана, когда запросы на чтение многократно превышают запросы на запись, и JOIN-ы из-за нормализации создают bottleneck. В медицинских и аналитических системах это типичная ситуация. Используйте материализованные представления — они позволяют денормализовать данные без изменения основной структуры.
B-tree — для 90% случаев (равенство, диапазоны, сортировка). GIN — для JSONB и массивов. BRIN — для огромных таблиц с естественной хронологической сортировкой (логи, аудит). GiST — для геоданных и полнотекстового поиска. Hash — только для точного равенства по одному столбцу.
EXPLAIN показывает предполагаемый план выполнения без реального запуска запроса. EXPLAIN ANALYZE выполняет запрос и показывает реальные временные метрики. Всегда используйте ANALYZE для диагностики — планировщик может ошибаться в оценках кардинальности.
Установите фиксированный порядок блокировки ресурсов во всех транзакциях. Если все транзакции блокируют таблицы A, B, C строго в этом порядке, deadlock невозможен. Используйте SELECT ... FOR UPDATE для явного контроля и настройте мониторинг через pg_locks.
Рекомендуется при размере таблицы более 10-50 миллионов строк, особенно если запросы имеют естественный фильтр (дата, регион, тип). Партиционирование по дате — наиболее частый сценарий. Это ускоряет запросы за конкретный период и упрощает удаление старых данных.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#postgresql sql#оптимизация запросов postgresql#индексы postgresql#транзакции acid#explain analyze#sql администрирование#postgresql блокировки#нормализация бд
Комментарии 0

Оставить комментарий

загрузка...