Раздел 11 — Индексы, транзакции, представления, оптимизация

Как ускорить запросы и как не испортить данные при параллельной работе

Прогресс курса Раздел 11 из 13

Что вы освоите в этом разделе

4 академических часа: 1 час теории, 3 часа практики. Раздел плотный: каждая из четырёх тем заслуживает отдельного курса, здесь даётся рабочий минимум.
01

Индексы

Индекс — вспомогательная структура, позволяющая находить строки, не перебирая таблицу. Основной тип — B-дерево: поиск за O(log N) вместо O(N). На миллионе строк это разница между двадцатью обращениями и миллионом.

-- Обычный индекс
CREATE INDEX idx_posts_community_id ON posts (community_id);

-- Составной: порядок столбцов имеет значение
CREATE INDEX idx_likes_user_date ON likes (user_id, liked_at);

-- Уникальный
CREATE UNIQUE INDEX uq_posts_community_title ON posts (community_id, title);

-- Частичный: индексируется только часть строк
CREATE INDEX idx_posts_active ON posts (created_at)
    WHERE deleted_at IS NULL;

-- Функциональный: индекс по выражению
CREATE INDEX idx_users_name_upper ON users (upper(name));

-- Построение без блокировки записи — на боевой базе только так
CREATE INDEX CONCURRENTLY idx_views_user_id ON views (user_id);
Порядок столбцов в составном индексе. Индекс по (user_id, liked_at) работает для условий по user_id и для пары user_id + liked_at, но не работает для условия только по liked_at. Правило левого префикса: используются столбцы слева направо без пропусков. Первым ставят тот, по которому чаще идёт точный отбор.

Что индексировать

  • Все внешние ключи. PostgreSQL не создаёт их автоматически, а без индекса и соединение по ключу, и проверка при удалении родителя идут полным сканированием.
  • Столбцы, по которым часто идёт отбор в WHERE.
  • Столбцы в ORDER BY, если сортировка стабильна и объём велик.
  • Столбцы с высокой селективностью — много различных значений.
Индекс — не бесплатный. Он занимает место, иногда сравнимое с таблицей, и замедляет каждую вставку, изменение и удаление: обновить нужно и таблицу, и все её индексы. Таблица с десятью индексами пишется в разы медленнее, чем с двумя. Неиспользуемые индексы ищутся запросом к pg_stat_user_indexes по idx_scan = 0 (раздел 6) и подлежат удалению.

Почему индекс не используется

Типичные причины

  • Функция над столбцом: WHERE upper(name) = 'ANNA' не задействует индекс по name. Нужен функциональный индекс по тому же выражению.
  • Приведение типа: сравнение varchar-столбца с числом заставляет приводить каждую строку.
  • Шаблон без якоря: LIKE '%текст%' — обычный индекс бесполезен, нужен триграммный.
  • Низкая селективность: если условию удовлетворяет треть таблицы, последовательное чтение дешевле.
  • Маленькая таблица: до нескольких сотен строк перебор быстрее обращения к индексу. Именно поэтому на учебной базе планы почти всегда показывают Seq Scan.
  • Устаревшая статистика: планировщик оценивает по данным ANALYZE; после массовой загрузки его нужно запустить.
02

Чтение плана выполнения

-- Только план, запрос не выполняется
EXPLAIN SELECT * FROM posts WHERE community_id = 1;

-- Выполнить и показать реальные числа — основной рабочий вариант
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM posts WHERE community_id = 1;

-- Проверить, помог бы индекс: временно запретить полное сканирование
SET enable_seqscan = off;
EXPLAIN ANALYZE SELECT * FROM posts WHERE community_id = 1;
SET enable_seqscan = on;
Узел планаЧто означает
Seq Scanперебор всей таблицы — нормально для маленьких, тревожно для больших
Index Scanпоиск по индексу, затем чтение строк из таблицы
Index Only Scanвсё нужное есть в индексе, таблица не читается — самый быстрый вариант
Bitmap Heap Scanпо индексу строится карта страниц, затем они читаются пачкой
Nested Loopсоединение перебором — хорошо на малых объёмах
Hash Joinсоединение через хеш-таблицу — обычный выбор для больших наборов
Merge Joinсоединение слиянием отсортированных наборов
Sortсортировка; external merge Disk в выводе означает нехватку work_mem
На что смотреть в первую очередь. Сравните rows= в оценке планировщика и actual rows=. Расхождение в десятки раз означает, что план построен на неверных предположениях, — и обычно лечится командой ANALYZE. Второе: ищите самый долгий узел, а не самый верхний. Третье: Rows Removed by Filter с большим числом означает, что сервер прочитал и выбросил много строк — кандидат на индекс.
03

Представления

Представление (VIEW) — сохранённый запрос, к которому обращаются как к таблице. Данных не хранит: при каждом обращении выполняется исходный запрос.

CREATE VIEW v_user_activity AS
SELECT u.id, u.name, u.city,
       (SELECT count(*) FROM likes l WHERE l.user_id = u.id) AS likes_count,
       (SELECT count(*) FROM views v WHERE v.user_id = u.id) AS views_count
FROM   users u;

SELECT * FROM v_user_activity WHERE likes_count > 2;

Зачем нужны представления

  • Разграничение доступа. Аналитику выдаётся право на представление без чувствительных столбцов, а не на таблицу.
  • Сокрытие сложности. Соединение шести таблиц пишется один раз.
  • Совместимость. Таблицу переименовали — на старое имя создали представление, и старый код продолжает работать.
  • Мягкое удаление. Представление отдаёт только живые строки, и фильтр не забудут (раздел 8).

Материализованное представление хранит результат физически. Обращение мгновенно, но данные устаревают до следующего обновления.

CREATE MATERIALIZED VIEW mv_community_stats AS
SELECT c.id, c.name,
       count(p.id) AS posts_count,
       round(avg(p.score)) AS avg_score
FROM   communities c
LEFT JOIN posts p ON p.community_id = c.id
GROUP BY c.id, c.name;

-- Обновление: блокирует чтение на время пересчёта
REFRESH MATERIALIZED VIEW mv_community_stats;

-- Обновление без блокировки чтения — требует уникального индекса
CREATE UNIQUE INDEX uq_mv_community_stats ON mv_community_stats (id);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_community_stats;
Обычное представление не ускоряет ничего — это подстановка текста запроса. Материализованное ускоряет, но показывает данные на момент последнего обновления. Выбор всегда компромисс между свежестью и скоростью, и он должен быть осознанным: дашборд с суточной актуальностью — хорошо, остаток товара на складе — недопустимо.
04

Транзакции

BEGIN;
    UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
    UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;

-- Точки сохранения: откат части транзакции
BEGIN;
    INSERT INTO posts (community_id, title, score) VALUES (1, 'первый', 100);
    SAVEPOINT sp1;
    INSERT INTO posts (community_id, title, score) VALUES (99, 'ошибка', 100);
    -- нарушение внешнего ключа
    ROLLBACK TO sp1;      -- откатили только вторую вставку
    INSERT INTO posts (community_id, title, score) VALUES (2, 'второй', 200);
COMMIT;                  -- сохранились первая и третья
Без явного BEGIN каждая команда — отдельная транзакция (режим автофиксации). Особенность PostgreSQL: после ошибки внутри транзакции она переходит в состояние отказа, и все последующие команды отвергаются до ROLLBACK. Точка сохранения — единственный способ продолжить работу после ошибки, не откатывая всё.
05

Аномалии и уровни изоляции

Когда транзакции работают параллельно, возможны четыре типа проблем.

АномалияВ чём состоит
Грязное чтениетранзакция видит незафиксированные изменения другой
Неповторяющееся чтениеповторный SELECT той же строки даёт другое значение
Фантомное чтениеповторный SELECT по условию возвращает новые строки
Потерянное обновлениедве транзакции читают значение, обе пишут — одно изменение исчезает
Уровень изоляцииГрязноеНеповторяющеесяФантомное
READ UNCOMMITTEDнет*возможновозможно
READ COMMITTED (по умолчанию)нетвозможновозможно
REPEATABLE READнетнетнет*
SERIALIZABLEнетнетнет
Две звёздочки — особенности PostgreSQL. READ UNCOMMITTED в нём отсутствует: запрос этого уровня работает как READ COMMITTED, грязное чтение невозможно в принципе благодаря многоверсионности. А REPEATABLE READ здесь строже стандарта — фантомы он тоже исключает, потому что реализован через снимок данных.
-- Задать уровень для транзакции
BEGIN ISOLATION LEVEL REPEATABLE READ;
    SELECT sum(balance) FROM accounts;
    -- любой повтор в этой транзакции даст то же число
COMMIT;
На уровнях REPEATABLE READ и SERIALIZABLE транзакция может быть аварийно прервана сервером с ошибкой сериализации, даже если ничего не нарушала. Это нормальный режим работы: приложение обязано ловить такую ошибку и повторять транзакцию. Код без повтора на этих уровнях будет периодически падать без видимой причины.
06

Блокировки и взаимные блокировки

Потерянное обновление — самая коварная аномалия: данные молча теряются, ошибки нет. Возникает при схеме «прочитал, посчитал в приложении, записал».

-- Транзакция A            Транзакция B
-- SELECT balance → 1000
--                         SELECT balance → 1000
-- UPDATE SET balance=900
--                         UPDATE SET balance=1200
-- Итог 1200: списание A потеряно

Три способа не потерять обновление:

-- 1. Считать в самой базе — атомарно, чтения в приложении нет вообще
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- 2. Пессимистичная блокировка: строка занята до конца транзакции
BEGIN;
    SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
    -- расчёт в приложении
    UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;

-- 3. Оптимистичная блокировка: версия строки
UPDATE accounts SET balance = 900, version = version + 1
WHERE  id = 1 AND version = 5;
-- 0 изменённых строк = кто-то опередил, читаем заново и повторяем

Взаимная блокировка (deadlock) — две транзакции ждут ресурсы друг друга. PostgreSQL обнаруживает это автоматически и прерывает одну из них.

-- Транзакция A            Транзакция B
-- UPDATE строку 1
--                         UPDATE строку 2
-- UPDATE строку 2  ← ждёт B
--                         UPDATE строку 1  ← ждёт A
-- ERROR: deadlock detected
Главная профилактика взаимных блокировок — единый порядок обращения к ресурсам. Если все транзакции изменяют строки в порядке возрастания идентификатора, цикл ожидания не возникнет никогда. Дополнительно: держите транзакции короткими и не делайте внутри них сетевых вызовов — транзакция, ждущая ответа внешнего сервиса, блокирует строки всё это время.

Ключевые выводы раздела

Запомните главное

  • Индексы на внешние ключи создаются вручную — PostgreSQL их не добавляет
  • Составной индекс работает по левому префиксу; порядок столбцов важен
  • Каждый индекс замедляет запись и занимает место
  • Функция над столбцом, LIKE '%x%' и низкая селективность отключают индекс
  • В плане сравнивайте rows и actual rows: расхождение лечится ANALYZE
  • Обычное представление ничего не ускоряет; материализованное ускоряет, но данные устаревают
  • REFRESH ... CONCURRENTLY требует уникального индекса
  • После ошибки транзакция отвергает команды до ROLLBACK; спасает SAVEPOINT
  • В PostgreSQL нет грязного чтения, а REPEATABLE READ строже стандарта
  • На высоких уровнях изоляции приложение обязано повторять прерванные транзакции
  • Потерянное обновление лечится расчётом в базе, FOR UPDATE или версией строки
  • От взаимных блокировок защищает единый порядок обращения к строкам
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 10: JOIN и группировка Раздел 12: Другие СУБД