Начальный ~45 мин чтения

Как работает СУБД и зачем SQL

Урок 1 из 10 в курсе SQL с нуля: полный курс

Как работает СУБД и зачем SQL

СУБД (система управления реляционными базами данных) — это не просто хранилище файлов, а сложная программная система, обеспечивающая надёжное хранение, эффективный поиск, целостность данных и одновременный доступ тысяч клиентов. SQL — декларативный язык: вы описываете *что* хотите получить, а не *как* это сделать — СУБД сама выбирает оптимальный алгоритм выполнения. PostgreSQL — самая мощная открытая СУБД в мире, de-facto стандарт для backend-разработки в 2024 году: поддерживает ACID, JSON, полнотекстовый поиск, оконные функции, партиционирование и десятки других возможностей из коробки. Понимание того, как PostgreSQL обрабатывает запросы внутри, напрямую определяет способность разработчика писать быстрый и надёжный код.

Почему это важно: Когда запрос на продакшене внезапно начинает работать 10 секунд вместо 10 миллисекунд — единственный способ разобраться это понять, как СУБД обрабатывает запрос. Без этого понимания разработчик действует наугад: добавляет случайные индексы, копирует советы из StackOverflow, не понимая почему это помогает или не помогает. Знание архитектуры объясняет, почему одинаковый по смыслу запрос может выполняться 10мс или 10 секунд; почему UPDATE без WHERE уничтожает все данные; почему первый запрос медленнее повторного (кеш); почему PostgreSQL в разработке на SQLite даёт неожиданные сюрпризы в продакшене. Это фундамент, без которого все остальные знания SQL будут поверхностными.

Главная идея

SQL — это декларативный язык: ты пишешь что хочешь (SELECT все заказы пользователя с суммой больше 1000), а PostgreSQL сам решает как это сделать — использовать ли индекс, в каком порядке объединять таблицы, стоит ли читать с диска или из кеша. Это фундаментально отличает SQL от императивного кода на Python или Java, где ты сам пишешь каждый шаг. Путь запроса от приложения до результата: клиент отправляет SQL-строку по TCP → Parser разбирает синтаксис и строит дерево разбора → Analyzer проверяет существование таблиц/столбцов → Rewriter применяет правила (например, разворачивает представления) → Planner собирает несколько возможных планов, оценивает стоимость каждого и выбирает наименьшую → Executor выполняет выбранный план и возвращает строки. На каждом шаге могут быть узкие места: недействительная статистика заставит Planner выбрать плохой план; отсутствие индекса вынудит Executor прочитать все 10 миллионов строк.

Как это выглядит на практике

  1. Представь: у тебя есть Django-приложение и таблица orders с 5 миллионами строк. Пользователь открывает свой профиль — приложение делает запрос: SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 10. Как именно PostgreSQL его выполняет?
  2. Шаг 1 — Подключение. Django открывает TCP-соединение к PostgreSQL (обычно localhost:5432). PostgreSQL fork'ает новый OS-процесс специально для этого соединения. Этот процесс будет обслуживать только тебя до закрытия соединения.
  3. Шаг 2 — Парсинг. Parser читает SQL-строку как текст и проверяет синтаксис. Если написать SELEKT вместо SELECT — получишь ошибку именно здесь, до обращения к данным. Parser строит AST (Abstract Syntax Tree) — дерево разбора запроса.
  4. Шаг 3 — Анализ. Analyzer проверяет, существует ли таблица orders, есть ли столбцы user_id и created_at, имеет ли текущий пользователь право читать эту таблицу. Если orders не существует — ошибка relation 'orders' does not exist.
  5. Шаг 4 — Планирование. Planner — самый умный компонент. Он знает статистику: сколько строк в таблице (n_live_tup), как распределены значения user_id, есть ли индекс на user_id. Если индекс есть — план будет 'Index Scan on orders using idx_orders_user_id'. Если нет — 'Seq Scan on orders' — последовательный перебор всех 5 миллионов строк.
  6. Шаг 5 — Выполнение без индекса. Без индекса Executor читает страницы данных последовательно: страница 1 (содержит строки 1-200), страница 2 (строки 201-400)... и так все 5 миллионов строк. Для каждой проверяет: user_id = 12345? Находит нужные строки, сортирует, берёт 10. Время: 3-5 секунд.
  7. Шаг 6 — Выполнение с индексом. С B-tree индексом на user_id Executor делает Index Scan: по B-tree находит все указатели на строки с user_id = 12345 (допустим, 47 строк), читает только эти страницы. Время: 5-10 миллисекунд. Разница в 300-1000 раз!
  8. Шаг 7 — Кеш. Если эти же страницы уже в shared buffers (кеш PostgreSQL в RAM) — диск не читается вообще. Первый запрос читает с диска, последующие — из RAM. Вот почему первый запрос после запуска сервера медленнее.
  9. Шаг 8 — Результат. Executor формирует набор строк (result set) и отправляет их клиенту по TCP. Django получает Python-объекты, ORM превращает строки в модели. Пользователь видит свои заказы.
  10. Теперь другой сценарий: вы решаете добавить возможность поиска заказов по тексту комментария. WHERE comment LIKE '%возврат%'. Даже с индексом на comment — запрос будет медленным: паттерн начинается с %, индекс B-tree не работает. Нужен GIN-индекс на tsvector или pg_trgm. Именно это понимание приходит из знания архитектуры.
  11. Важная деталь: PostgreSQL хранит данные в файлах на диске как набор 8KB-страниц. Каждая страница содержит несколько строк (tuples). При UPDATE PostgreSQL НЕ перезаписывает строку на месте — он создаёт новую версию строки в другой странице и помечает старую как мёртвую (dead tuple). Это MVCC — механизм многоверсионного параллелизма.
  12. Следствие MVCC: со временем в таблице накапливаются мёртвые строки (например, после миллиона UPDATE). Это называется table bloat — таблица физически занимает больше места, чем живых данных. Autovacuum периодически очищает мёртвые строки. Без autovacuum таблица будет только расти, а запросы замедлятся.

Примеры кода

Жизненный цикл запроса — видим план через EXPLAIN

-- Смотрим что PostgreSQL собирается делать (без выполнения)
EXPLAIN
SELECT id, total_amount, created_at
FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10;

-- Типичный вывод БЕЗ индекса:
-- Limit  (cost=185432.00..185432.03 rows=10)
--   ->  Sort  (cost=185432.00..185443.50)
--         Sort Key: created_at DESC
--         ->  Seq Scan on orders  (cost=0.00..185182.00 rows=100000)
--               Filter: (user_id = 12345)

-- Типичный вывод С индексом:
-- Limit  (cost=0.56..12.34 rows=10)
--   ->  Index Scan using idx_orders_user_id on orders
--         Index Cond: (user_id = 12345)

EXPLAIN показывает план выполнения без реального запуска запроса. cost=0.00..185432 — оценочная стоимость в условных единицах. Seq Scan означает перебор всей таблицы. Index Scan — использование индекса. Разница в стоимости: 185432 против 12 — в 15000 раз.

EXPLAIN ANALYZE — реальное время выполнения

-- ANALYZE выполняет запрос и показывает реальное время
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, total_amount, created_at
FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 10;

-- Вывод с индексом:
-- Limit  (actual time=0.123..0.145 rows=10 loops=1)
--   ->  Index Scan ... (actual time=0.120..0.140 rows=10 loops=1)
--         Buffers: shared hit=15 read=2
-- Planning Time: 0.234 ms
-- Execution Time: 0.178 ms

actual time=X..Y показывает реальное время (мс): X — время до первой строки, Y — до последней. Buffers: shared hit=15 — прочитано 15 страниц из кеша, read=2 — 2 страницы с диска. Если read >> hit — нужен больший shared_buffers или данные редко запрашиваются.

Смотрим активные запросы и находим медленные

-- Все запросы дольше 1 секунды
SELECT
  pid,
  now() - query_start AS duration,
  state,
  left(query, 100) AS query_preview
FROM pg_stat_activity
WHERE state = 'active'
  AND query_start < now() - interval '1 second'
  AND pid != pg_backend_pid()
ORDER BY duration DESC;

-- Статистика по таблицам: живые и мёртвые строки
SELECT
  relname,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup * 100.0 / NULLIF(n_live_tup, 0), 1) AS dead_pct,
  last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

pg_stat_activity — реалтайм мониторинг активных запросов. pg_stat_user_tables показывает накопленные мёртвые строки. dead_pct > 10% сигнализирует о bloat — autovacuum не успевает за темпом изменений.

Что происходит под капотом

  • PostgreSQL хранит данные в файлах как набор страниц по 8KB (heap pages). Каждая страница имеет заголовок, массив указателей на строки (line pointer array) и сами строки (tuples). Строка не может занимать больше одной страницы — большие данные (TEXT > ~2KB) хранятся отдельно в системе TOAST (The Oversized Attribute Storage Technique) с автоматическим сжатием.
  • Shared buffers — кеш в общей памяти, доступный всем backend-процессам одновременно. При обращении к странице PostgreSQL сначала ищет её в shared buffers; при промахе (cache miss) читает с диска и кладёт в кеш. Дефолтное значение 128MB — катастрофически мало для продакшена; рекомендуется 25% RAM. При размере БД, помещающейся в shared buffers полностью, повторные запросы не трогают диск вообще.
  • Query planner собирает статистику о данных через ANALYZE: гистограммы распределения значений, количество уникальных значений (n_distinct), корреляцию физического порядка строк с логическим. На основе статистики он оценивает selectivity (какой % строк вернёт условие WHERE user_id = X) и выбирает план с наименьшей оценочной стоимостью. Устаревшая статистика (не было ANALYZE после массовых вставок) приводит к плохим планам.
  • WAL (Write-Ahead Log) — журнал изменений. Правило WAL: любое изменение данных должно быть сначала записано в WAL и сброшено на диск, и только потом страница данных считается изменённой. При crash PostgreSQL смотрит последний checkpoint и воспроизводит WAL-записи после него — данные восстанавливаются гарантированно. WAL также используется для потоковой репликации: standby-сервер получает WAL-записи и применяет их.
  • MVCC (Multi-Version Concurrency Control) позволяет транзакциям видеть консистентный снимок данных, не блокируя друг друга. При UPDATE PostgreSQL создаёт новую версию строки (с новым xmin — ID транзакции, создавшей её) и помечает старую версию xmax = ID текущей транзакции. Читающие транзакции видят версию, которая была актуальна на момент начала их снимка (snapshot). Это значит, что читатели не блокируют писателей и наоборот — ключевое преимущество перед lock-based СУБД.
  • Autovacuum daemon запускается автоматически и периодически выполняет VACUUM для таблиц с накопившимися мёртвыми строками. VACUUM не освобождает место на диске (для этого VACUUM FULL), но помечает пространство мёртвых строк как доступное для новых данных. VACUUM ANALYZE обновляет статистику для планировщика. Для больших активных таблиц нужно снижать autovacuum_vacuum_scale_factor — иначе autovacuum срабатывает слишком редко.
  • Планировщик выбирает алгоритм JOIN: Nested Loop (эффективен если внутренняя таблица маленькая или есть индекс), Hash Join (строит хеш-таблицу из меньшей стороны в work_mem и проходит по большей — хорош для больших таблиц без индексов), Merge Join (обе стороны уже отсортированы или можно отсортировать). Выбор алгоритма зависит от размеров таблиц, наличия индексов и параметра work_mem.
  • Транзакционный ID (xid) — 32-битное число, увеличивающееся с каждой транзакцией. PostgreSQL имеет механизм защиты от wraparound (переполнения xid после 2 миллиардов транзакций): старые строки периодически 'замораживаются' (frozen) — их xmin заменяется специальным значением FrozenTransactionId. Без регулярного VACUUM база данных через ~2 млрд транзакций откажет с критической ошибкой wraparound.
  • Индексы в PostgreSQL — отдельные структуры данных (B-tree, GIN, GiST, BRIN, Hash), физически отдельные от таблицы. При INSERT/UPDATE/DELETE PostgreSQL обновляет все индексы таблицы — это стоимость, которую платишь за быстрые SELECT. Поэтому 20 индексов на таблице с интенсивной записью могут замедлить вставки в 5-10 раз.
  • PostgreSQL логирует медленные запросы при log_min_duration_statement = '1000' (мс). Файл postgresql.log хранит все запросы дольше порога. pgBadger — инструмент парсинга этого лога с красивыми отчётами. Для продакшена обязательно настрой логирование медленных запросов — иначе ты слепой в момент инцидента.

Типичные ошибки и заблуждения

  • «SQL — это просто язык запросов, не нужно понимать, как работает СУБД». Это распространённое и дорогостоящее заблуждение. Один неоптимальный запрос может положить продакшен-сервер. Разработчик, понимающий работу планировщика, способен за 5 минут найти проблему через EXPLAIN и добавить нужный индекс. Разработчик без этого понимания будет разбираться часами.
  • «PostgreSQL всегда медленнее файлов на диске». Файлы — неструктурированное хранилище. Найти один запись среди 10 миллионов в файле — это O(n), нужно читать всё. PostgreSQL с B-tree индексом находит запись за O(log n) — примерно 23 операции для 10 миллионов записей. Плюс shared buffers кеширует горячие страницы в RAM. СУБД быстрее файлов для структурированных данных почти всегда.
  • «Чем проще SQL-запрос, тем он быстрее». SELECT * FROM users — простейший запрос, но без WHERE на таблице из 100 миллионов строк он вернёт несколько гигабайт данных и займёт минуты. SELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.created_at > '2024-01-01' GROUP BY u.id — сложный запрос, но с нужными индексами работает за миллисекунды.
  • «ORM (Hibernate, ActiveRecord, SQLAlchemy) избавляет от необходимости знать SQL». ORM генерирует SQL, который выполняет PostgreSQL. Если ORM генерирует плохой SQL (N+1 запросов, SELECT * вместо конкретных столбцов, отсутствие limit) — база страдает независимо от того, знаешь ты SQL или нет. Умение читать SQL, который генерирует ORM (ActiveRecord logger, Django DEBUG=True) — обязательный навык.
  • «SQLite в разработке, PostgreSQL в продакшене — нормально, SQL одинаковый». SQLite и PostgreSQL имеют разные: типы данных (в SQLite нет строгой типизации), поведение NULL, поддержку ограничений (SQLite по умолчанию игнорирует foreign keys), планировщик, индексы. Запросы, работающие корректно на SQLite, могут вести себя иначе на PostgreSQL и наоборот. Используй PostgreSQL даже в разработке.
  • «Если приложение работает, значит запросы нормальные». Проблемы производительности часто незаметны при малом объёме данных. Запрос за 5мс на 1000 строк займёт 5 секунд на 1 миллионе строк при линейном росте. Всегда профилируй запросы на данных, близких к продакшену по объёму.
  • «Транзакции нужны только при работе с деньгами». Транзакции обеспечивают атомарность любых связанных операций. Создание пользователя + его профиля + начальных настроек — если это три отдельных INSERT без транзакции, и второй падает — в БД будет пользователь без профиля. Транзакции нужны всегда, когда несколько операций должны быть либо все, либо ни одной.

Ключевые выводы

  • SQL декларативен: описываешь что хочешь получить, а не как — планировщик СУБД выбирает алгоритм выполнения на основе статистики и индексов.
  • Путь запроса: Parser → Analyzer → Planner → Executor. Planner — самый важный шаг, именно здесь решается, будет ли запрос работать 5мс или 5 секунд.
  • EXPLAIN показывает план запроса; EXPLAIN ANALYZE выполняет запрос и показывает реальное время и буферы — это главный инструмент оптимизации.
  • MVCC обеспечивает параллельный доступ без блокировок между читателями и писателями, но создаёт мёртвые строки (dead tuples) которые нужно чистить через autovacuum.
  • Shared buffers — кеш страниц данных в RAM; для продакшена рекомендуется 25% RAM; cache hit ratio > 95% для горячих таблиц.
  • WAL гарантирует durability (D в ACID) и является основой репликации и point-in-time recovery.
  • Используй PostgreSQL во всех окружениях (dev, staging, prod) — различия в поведении СУБД порождают баги, которые воспроизводятся только в продакшене.

Термины урока

СУБД (RDBMS): программная система для хранения, управления и получения структурированных данных с гарантиями ACID и поддержкой SQL.
SQL (Structured Query Language): декларативный язык запросов, стандартизированный ANSI/ISO; каждая СУБД добавляет свои расширения поверх стандарта.
Query planner (планировщик): компонент PostgreSQL, который оценивает несколько возможных планов выполнения и выбирает наименее дорогостоящий на основе статистики.
Shared buffers: область общей памяти (shared memory), используемая как кеш страниц данных; все backend-процессы читают и пишут через неё.
WAL (Write-Ahead Log): журнал изменений; все изменения записываются в WAL до применения к страницам данных, что гарантирует восстановление после сбоя.
MVCC (Multi-Version Concurrency Control): механизм хранения нескольких версий строки для обеспечения изолированных снимков данных без блокировок между читателями и писателями.
Dead tuple (мёртвая строка): устаревшая версия строки после UPDATE или DELETE, помеченная как невидимая; занимает место до очистки VACUUM.
Autovacuum: фоновый процесс PostgreSQL, автоматически запускающий VACUUM и ANALYZE для таблиц с накопившимися мёртвыми строками.
EXPLAIN / EXPLAIN ANALYZE: команды для просмотра плана выполнения запроса; ANALYZE дополнительно выполняет запрос и показывает реальное время и использование буферов.
Table bloat: физическое увеличение размера таблицы из-за накопления мёртвых строк; замедляет sequential scan и увеличивает I/O.

Связь с работой backend-разработчика

PostgreSQL — правильный выбор по умолчанию для нового backend-проекта. Это бесплатная, открытая и невероятно мощная СУБД: JSONB с индексами для полусхематичных данных, полнотекстовый поиск, оконные функции, партиционирование, логическая репликация, расширения (PostGIS для геоданных, pgvector для ML-эмбеддингов) — всё это из коробки без доплаты. Используй PostgreSQL начиная с первого дня разработки, даже локально — не SQLite. Разница в поведении между SQLite и PostgreSQL регулярно порождает баги, которые обнаруживаются только в продакшене. Два инструмента, которые должны быть открыты всегда: EXPLAIN ANALYZE (для разбора медленных запросов) и pg_stat_activity (для мониторинга в реальном времени). Через месяц работы с этими инструментами ты начнёшь интуитивно чувствовать, будет ли запрос быстрым или нет, ещё до его выполнения.

Мини-разбор реальной ситуации

Стартап строит SaaS-продукт. В разработке — SQLite, в продакшене — PostgreSQL. Всё работает на 100 пользователях. При масштабировании до 10 000 пользователей страница 'История заказов' начинает тормозить — 8 секунд. Команда в панике. Разработчик без знания SQL говорит: 'Нужно добавить кеш (Redis)'. Старший разработчик открывает EXPLAIN ANALYZE на запросе страницы и видит: Seq Scan on orders (cost=0.00..284000.00 rows=2400000). Весь Sequential Scan — 2.4 миллиона строк без индекса. Причина: в SQLite запрос работал быстро — таблица помещалась в памяти. В PostgreSQL с реальными данными — полный перебор. Решение заняло 30 секунд: CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id). Результат: 8000мс → 12мс. Урок: понимание EXPLAIN и архитектуры СУБД превращает многочасовое расследование в 30-секундное исправление.

Что запомнить

  • EXPLAIN ANALYZE — первое, что открываешь при медленном запросе; ищи Seq Scan на больших таблицах и большие значения actual time.
  • PostgreSQL везде: dev, staging, prod — никогда не используй другую СУБД в разработке для системы, которая поедет на PostgreSQL.
  • Планировщик работает на статистике: после массовых вставок выполняй ANALYZE таблицы, иначе планировщик будет выбирать неоптимальные планы.
  • MVCC создаёт мёртвые строки — следи за n_dead_tup в pg_stat_user_tables и убеждайся что autovacuum работает.
  • Shared buffers = 25% RAM в продакшене; дефолтные 128MB — для тестовых стендов.
  • Каждое соединение = OS-процесс; больше 100 прямых соединений — используй PgBouncer.

Итог

СУБД — сложная система с оптимизатором, кешем, журналом и механизмом многоверсионности. Понимание архитектуры PostgreSQL на базовом уровне — не академическое знание, а практический инструмент для ежедневной работы: диагностики медленных запросов, настройки производительности и правильного проектирования схем. Начни работать с EXPLAIN ANALYZE сегодня — через неделю ты будешь удивляться, как мог без него обходиться.