- Модуль 0: Фундамент реляционных баз и SQL
- Тема 0.1. Реляционная теория и Моделирование
- Тема 0.2. Базовый SQL (DML & DDL)
- Тема 0.3. Соединения (JOINs) и Теория множеств
- Модуль 1: PostgreSQL — Инструментарий разработчика
- Модуль 2: Внутреннее устройство (Internals)
- Модуль 3: Продвинутый SQL и Аналитика
- Тема 3.1. Индексы (Indexing)
- Тема 3.2. Window Functions (Оконные функции)
- Тема 3.3. CTE (Common Table Expressions)
- Модуль 4: Java Integration & Transactions
- Модуль 5: Оптимизация производительности (Query Tuning)
- Модуль 6: Администрирование и Архитектура (Expert)
=========================================
Прежде чем написать первую строчку кода, нужно понять, как "думает" реляционная база данных (RDBMS). В отличие от написания кода на Java, где мы оперируем объектами и классами, здесь мы работаем с множествами и математической логикой.
Реляционная модель (от англ. relation — отношение) представляет данные в виде двумерных таблиц.
- Отношение (Relation): В обиходе мы называем это Таблицей. Это структура данных, состоящая из заголовка (названия колонок) и тела (набор строк).
- Аналогия с Java: Это как
List<User>, где сама концепция списка пользователей — это таблица.
- Аналогия с Java: Это как
- Кортеж (Tuple): Это Строка (Row) или Запись. Каждый кортеж представляет собой один конкретный экземпляр объекта.
- Аналогия с Java: Это один объект
new User(...).
- Аналогия с Java: Это один объект
- Атрибут (Attribute): Это Столбец (Column). Характеристика, описывающая сущность (имя, возраст, цена). У каждого атрибута есть Домен (Domain) — допустимый диапазон значений (или Тип данных).
Это чертеж вашей базы данных. Проектирование всегда начинается с карандаша и бумаги (или инструментов вроде draw.io).
Существует три основных вида связей между таблицами:
- Один к Одному (1:1):
- Одна запись в таблице А соответствует одной записи в таблице Б.
- Пример: Гражданин и Паспорт. У одного гражданина один действующий паспорт.
- На практике: Часто такие таблицы объединяют в одну, если нет жесткой необходимости их разделять (например, для безопасности).
- Один ко Многим (1:M): — Самая частая связь.
- Одна запись в таблице А связана с множеством записей в таблице Б.
- Пример: Пользователь и Заказы. Один
Userможет сделать многоOrders, но конкретныйOrderпринадлежит только одномуUser. - Реализация: ID родителя (User) кладется в таблицу ребенка (Order) как внешний ключ.
- Многие ко Многим (M:M):
- Записи из таблицы А могут быть связаны со множеством записей таблицы Б и наоборот.
- Пример: Студенты и Курсы. Один студент ходит на много курсов. На один курс ходит много студентов.
- Важно: В реляционных базах прямая связь M:M невозможна. Она всегда реализуется через третью (связующую) таблицу (Junction Table), превращаясь в две связи 1:M.
Нормализация — это процесс организации данных для уменьшения избыточности (дублей) и проблем с целостностью.
- 1НФ (Первая нормальная форма) — Атомарность:
- В одной ячейке должно быть только одно значение.
- Ошибка: В поле
phone_numbersхранить "89991112233, 89994445566". - Решение: Вынести телефоны в отдельную таблицу или сделать две строки.
- 2НФ и 3НФ (Нормализация ключей и зависимостей):
- Суть: Данные в строке должны зависеть только от первичного ключа этой строки (Primary Key), а не от других полей.
- Пример ошибки: В таблице
Ordersхранить поля:order_id,product_name,supplier_company,supplier_city. - Проблема: Поле
supplier_cityзависит отsupplier_company, а не отorder_id(заказа). Если поставщик переедет, нам придется обновлять тысячи строк заказов. - Решение (3НФ): Вынести поставщика в отдельную таблицу
Suppliers.
- BCNF (Нормальная форма Бойса-Кодда):
- Более строгая версия 3НФ. "Каждый детерминант должен быть потенциальным ключом". На старте достаточно понимать 3НФ: "В каждой таблице должна лежать информация только об одной конкретной сущности".
Это правила, которые база данных принудительно проверяет перед сохранением данных. Это ваша защита от "мусора" в данных.
Рассмотрим их на примере SQL-кода создания таблицы (DDL).
-- Пример таблицы "Пользователи"
CREATE TABLE users (
-- PK (Primary Key): Первичный ключ.
-- Гарантирует, что каждая строка уникальна и не равна NULL.
-- Обычно это суррогатный id (число).
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
-- NOT NULL: Запрещает сохранять пустоту. У юзера обязательно должно быть имя.
username TEXT NOT NULL,
-- UNIQUE: Гарантирует уникальность значений во всей колонке.
-- Два пользователя не могут иметь одинаковый email.
email TEXT NOT NULL UNIQUE,
-- CHECK: Произвольное условие проверки.
-- База выдаст ошибку, если попытаться сохранить возраст меньше 0.
age INTEGER CHECK (age >= 0),
-- DEFAULT: Значение по умолчанию, если оно не передано при вставке.
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Пример таблицы "Заказы" для демонстрации FK
CREATE TABLE orders (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
-- FK (Foreign Key): Внешний ключ.
-- Ссылка на таблицу users.
-- Гарантирует, что мы не можем создать заказ для несуществующего пользователя.
-- ON DELETE CASCADE означает: если удалим юзера, удалятся и все его заказы.
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
order_number TEXT NOT NULL
);
Краткий итог 0.1:
- Мыслим таблицами.
- Сначала рисуем схему (ER-диаграмму).
- Разбиваем данные так, чтобы не было дубликатов (Нормализация).
- Навешиваем "замки" (Constraints), чтобы данные были чистыми.
SQL (Structured Query Language) делится на несколько смысловых групп. Две самые главные, с которыми вы будете работать 99% времени — это DDL и DML.
Эти команды меняют схему базы данных: создают таблицы, меняют типы колонок, удаляют объекты. Это "строительные работы".
-- 1. CREATE: Создание новой таблицы
-- Мы создаем таблицу товаров (products)
CREATE TABLE products (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, -- Уникальный номер
name TEXT NOT NULL, -- Название товара
price NUMERIC(10, 2) NOT NULL CHECK (price > 0), -- Цена (10 цифр всего, 2 после запятой)
category TEXT DEFAULT 'General', -- Категория по умолчанию
is_active BOOLEAN DEFAULT true -- В наличии ли товар
);
-- 2. ALTER: Изменение существующей таблицы
-- Представим, что мы забыли добавить описание товара.
-- Добавляем новую колонку:
ALTER TABLE products ADD COLUMN description TEXT;
-- Или решили, что название может быть пустым (плохая идея, но для примера):
ALTER TABLE products ALTER COLUMN name DROP NOT NULL;
-- 3. DROP: Удаление таблицы
-- ВНИМАНИЕ: Это удаляет таблицу целиком вместе со всеми данными безвозвратно.
-- DROP TABLE products;
Эти команды работают с наполнением таблиц. Это то, что называют CRUD (Create, Read, Update, Delete).
-- Вставка одной строки
-- Указываем колонки, в которые кладем данные. ID генерируется сам.
INSERT INTO products (name, price, category)
VALUES ('Ноутбук XPS 15', 1500.00, 'Electronics');
-- Вставка нескольких строк сразу (Bulk Insert) - это эффективнее
INSERT INTO products (name, price, category, description)
VALUES
('Мышь Logitech', 25.50, 'Electronics', 'Беспроводная мышь'),
('Кофе зерновой', 15.00, 'Food', '100% Арабика'),
('Стол офисный', 120.00, 'Furniture', 'Деревянный стол');
Критически важно: Всегда используйте WHERE, иначе обновите все строки в таблице.
-- Повышаем цену на конкретный товар (по ID)
UPDATE products
SET price = 1600.00
WHERE id = 1;
-- Обновляем несколько полей сразу для категории 'Food'
UPDATE products
SET is_active = false,
description = 'Товар временно недоступен'
WHERE category = 'Food';
Также требует осторожности с WHERE.
-- Удаляем конкретный товар
DELETE FROM products
WHERE id = 4;
-- Удаляем все товары дешевле 10 условных единиц
DELETE FROM products
WHERE price < 10.00;
Самая мощная часть SQL. SELECT не меняет данные, он только возвращает результат.
-- Базовый SELECT: Выбрать всё (*) из таблицы
SELECT * FROM products;
-- Хорошая практика: Перечислять конкретные колонки (экономит трафик сети)
SELECT name, price FROM products;
Оператор WHERE отсеивает строки, которые не подходят под условие.
-- 1. Точное совпадение и сравнение
SELECT * FROM products
WHERE category = 'Electronics' AND price > 1000;
-- 2. IN: Проверка на вхождение в список
-- Найти товары, которые либо Еда, либо Мебель
SELECT * FROM products
WHERE category IN ('Food', 'Furniture');
-- 3. BETWEEN: Диапазон (включительно)
-- Товары с ценой от 100 до 500
SELECT * FROM products
WHERE price BETWEEN 100 AND 500;
-- 4. LIKE: Поиск по шаблону (строки)
-- % означает "любое количество любых символов"
-- _ означает "ровно один любой символ"
-- Найти товары, начинающиеся на "Ноут" (Ноутбук, Ноуты...)
SELECT * FROM products
WHERE name LIKE 'Ноут%';
-- Найти товары, где в названии есть слово "Phone" (iPhone, Smartphone...)
-- ILIKE в PostgreSQL делает поиск нечувствительным к регистру (Phone, phone)
SELECT * FROM products
WHERE name ILIKE '%phone%';
В реальных приложениях мы редко выгружаем миллион строк сразу. Мы показываем их страницами.
Если не указать ORDER BY, база данных может вернуть строки в случайном порядке.
-- Сортировка по цене от дешевых к дорогим (ASC - по возрастанию, default)
SELECT * FROM products
ORDER BY price ASC;
-- Сортировка по категории (А-Я), а внутри категории — по цене убывания (DESC)
SELECT * FROM products
ORDER BY category ASC, price DESC;
LIMIT: Сколько строк вернуть.OFFSET: Сколько строк пропустить с начала.
-- Задача: Показать "Страницу №2", если на странице по 10 товаров.
-- Значит, нужно пропустить первые 10 и взять следующие 10.
SELECT * FROM products
ORDER BY id -- Всегда сортируйте при пагинации, иначе результаты могут "скакать"
LIMIT 10 -- Размер страницы
OFFSET 10; -- Пропустить (Номер страницы - 1) * Размер страницы
Резюме 0.2:
- DDL (
CREATE) строит "скелет" данных. - DML (
INSERT,UPDATE,DELETE) меняет "мясо" данных. SELECT— основной инструмент чтения.- Без
WHEREоперацииUPDATEиDELETEуничтожают данные. - Без
ORDER BYпорядок строк не гарантирован.
В нормализованной базе данных (как мы обсудили в теме 0.1) данные разбросаны по разным таблицам. Пользователи отдельно, заказы отдельно, товары отдельно.
Чтобы собрать их обратно в единую осмысленную строку для отчета или API, используется механизм JOIN (соединение). Это операция, которая сопоставляет строки одной таблицы со строками другой на основе условия (обычно: Primary Key = Foreign Key).
Для примеров используем две абстрактные таблицы:
- Users (id, name) — список пользователей.
- Orders (id, user_id, amount) — заказы, ссылающиеся на пользователей.
Самый частый вид соединения. Возвращает только те строки, для которых нашлось совпадение в обеих таблицах.
- Логика: "Покажи мне пользователей И их заказы".
- Результат: Если у пользователя нет заказов, он не попадет в выборку. Если есть "потерянный" заказ без пользователя, он тоже не попадет.
SELECT
u.name,
o.amount
FROM users u -- Таблица 1 (Левая)
INNER JOIN orders o -- Таблица 2 (Правая)
ON u.id = o.user_id; -- Условие связи (PK = FK)
Возвращает все строки из ЛЕВОЙ таблицы (та, что написана после FROM), и сопоставленные строки из правой. Если совпадения нет, поля правой таблицы будут заполнены NULL.
- Логика: "Покажи мне ВСЕХ пользователей, и их заказы, если они есть".
- Результат: Юзеры без заказов будут в списке, но в колонке
amountбудетNULL.
SELECT
u.name,
o.amount
FROM users u -- Левая таблица (Главная для этого запроса)
LEFT JOIN orders o -- Правая таблица
ON u.id = o.user_id;
-- Лайфхак: Как найти пользователей, у которых НЕТ заказов?
-- WHERE o.id IS NULL
- RIGHT JOIN: Зеркальное отражение
LEFT JOIN. Берет всё из правой таблицы. Используется крайне редко, так как проще поменять таблицы местами и сделатьLEFT JOIN. - FULL OUTER JOIN: Объединение левого и правого. Показывает всё: пары (где совпало), левые без пары и правые без пары. Используется для сверки данных или сложных отчетов.
Соединяет каждую строку левой таблицы с каждой строкой правой таблицы. Условие ON не требуется.
- Математика: Если в таблице A 100 строк, а в B 100 строк, результат будет 10,000 строк.
- Опасность: Может "положить" базу, если таблицы большие.
- Применение: Генерация матриц. Например, есть таблица
Colors(Red, Blue) иSizes(S, M, L). CROSS JOIN создаст все комбинации вариантов товара.
Если JOIN соединяет таблицы горизонтально (добавляет новые колонки к строке), то операции множеств соединяют результаты запросов вертикально (добавляют новые строки).
Важное условие: количество и типы колонок в запросах должны совпадать.
Склеивает результаты двух SELECT-запросов.
- UNION: Склеивает и удаляет дубликаты (это требует ресурсов на сортировку).
- UNION ALL: Просто склеивает всё подряд. Быстрее, так как не ищет дубли.
-- Пример: Получить список всех email'ов из таблиц Клиентов и Сотрудников
SELECT email FROM clients
UNION ALL
SELECT email FROM employees;
Возвращает строки, которые есть и в первом, и во втором наборе. (Аналог retainAll в Java).
Возвращает строки, которые есть в первом наборе, но отсутствуют во втором. (Аналог removeAll в Java). В Oracle этот оператор называется MINUS.
Задача: Спроектировать ER-схему для приложения "Список рецептов". Сущности:
Category(Категория: Супы, Десерты). Связь с рецептами 1:M.Recipe(Рецепт).Ingredient(Продукт: Соль, Курица).Recipe_Ingredient(Связь M:M: В одном рецепте много ингредиентов, один ингредиент во многих рецептах).
Вот SQL-код, реализующий эту схему с учетом всех знаний из Модуля 0:
-- 1. Таблица Категорий (Справочник)
CREATE TABLE categories (
id SERIAL PRIMARY KEY, -- SERIAL - это сокращение для автоинкремента integer
name TEXT NOT NULL UNIQUE
);
-- 2. Таблица Рецептов
CREATE TABLE recipes (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
title TEXT NOT NULL,
instructions TEXT, -- Инструкция может быть длинной
-- Связь с категорией (1:M)
-- Если категорию удалят, поле станет NULL (SET NULL), чтобы рецепт не исчез
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 3. Таблица Ингредиентов (Справочник продуктов)
CREATE TABLE ingredients (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name TEXT NOT NULL UNIQUE, -- Нельзя создать две "Соли"
calories_per_100g INTEGER CHECK (calories_per_100g >= 0)
);
-- 4. Связующая таблица (M:M)
-- Она соединяет Рецепт и Ингредиент, плюс хранит контекст (количество)
CREATE TABLE recipe_ingredients (
recipe_id BIGINT REFERENCES recipes(id) ON DELETE CASCADE,
ingredient_id BIGINT REFERENCES ingredients(id) ON DELETE RESTRICT,
amount TEXT NOT NULL, -- Например, "200 гр" или "1 шт"
-- Составной первичный ключ (Composite Primary Key)
-- Гарантирует, что один ингредиент не добавится в один рецепт дважды
PRIMARY KEY (recipe_id, ingredient_id)
);
Разбор решения:
- Нормализация: Ингредиенты вынесены отдельно. Если "Курица" переименуется в "Цыпленок", это изменится во всех рецептах.
- Типы данных: Используем
TEXTдля строк (стандарт Postgres),BIGINTдля ID (чтобы не переполнилось). - Constraints:
UNIQUEдля имен,CHECKдля калорий,PKдля связок. - FK Actions:
ON DELETE CASCADEдля связки ингредиентов означает: если удалим рецепт, связи удалятся сами. НоON DELETE RESTRICTв ингредиентах не даст удалить "Соль", пока она используется хотя бы в одном рецепте.
==================================================
Мы переходим от теории "сферической базы данных в вакууме" к конкретному инструменту — PostgreSQL. Это мощная объектно-реляционная СУБД, и она дает вам инструменты, которых нет в стандарте SQL.
Выбор типа данных в Postgres — это вопрос не только семантики, но и производительности.
В Java у вас есть int, long, double, BigDecimal. В Postgres есть их прямые аналоги, но с нюансами.
integer(4 байта) иbigint(8 байт):- Стандартные целочисленные типы.
- Совет: Если есть малейший шанс, что id перевалит за 2 млрд, используйте
bigint. В современном мире 4 байта экономии не стоят боли от переполненияinteger(Integer Overflow).
numeric/decimal:- Это число произвольной точности. Аналог
BigDecimalв Java. - Оно хранит числа точно, без погрешностей округления.
- Применение: ДЕНЬГИ. Никогда не храните деньги в
double. - Минус: Медленнее в расчетах, чем
integerилиreal.
- Это число произвольной точности. Аналог
realиdouble precision:- Числа с плавающей точкой (IEEE 754).
- Применение: Координаты, физические измерения, где не важна точность до копейки, но важна скорость.
CREATE TABLE financials (
id BIGINT PRIMARY KEY,
-- numeric(15, 2) означает: всего 15 цифр, из них 2 после запятой.
-- Это идеально для валют.
salary NUMERIC(15, 2),
-- А вот это плохо для денег, так как 100.1 + 200.2 может дать 300.300000000004
bad_salary DOUBLE PRECISION
);
Если вы пришли из MySQL или старых учебников, вы привыкли писать VARCHAR(255). В PostgreSQL это не имеет смысла.
text: Строка произвольной длины.varchar(n): Строка ограниченной длины.- Внутреннее устройство: В Postgres
textиvarcharпод капотом работают абсолютно одинаково. Нет никакой разницы в производительности. Ограничение длиныvarchar(n)— это просто дополнительная проверка (constraint), которая даже добавляет крошечный оверхед. - Совет: Используйте
textвезде, кроме случаев, когда ограничение длины — это бизнес-требование (например, код страны из 2 букв).
Есть два основных типа времени:
timestamp(timestamp without time zone): Просто "фотография" времени на часах (например, "2023-10-05 10:00"). Оно ничего не знает о часовых поясах.timestamptz(timestamp with time zone): Время с учетом часового пояса.
Золотое правило: Всегда используйте timestamptz.
- Как это работает: Postgres не хранит таймзону внутри поля.
- Когда вы сохраняете данные в
timestamptz, Postgres конвертирует их в UTC и сохраняет. - Когда вы читаете данные, Postgres конвертирует UTC обратно в таймзону вашего соединения (клиента/сервера).
- Когда вы сохраняете данные в
- Почему
timestampопасен: Если сервер в Москве, а клиент в Лондоне, использованиеtimestampбез зоны приведет к путанице на 3 часа, которую невозможно будет разгрести.
Postgres позволяет хранить JSON-документы и эффективно искать по ним. Существует два типа: json и jsonb.
json: Хранит данные просто как текст. Медленно парсится каждый раз.jsonb(binary): Парсит JSON при вставке, удаляет пробелы, сортирует ключи и хранит в бинарном формате. Поддерживает индексы.
Аналогия: Это как Map<String, Object> в Java, который можно сохранить прямо в базу.
CREATE TABLE logs (
id BIGINT PRIMARY KEY,
payload JSONB -- Храним всё что угодно
);
-- Пример вставки
INSERT INTO logs (id, payload)
VALUES (1, '{"service": "auth", "status": "error", "details": {"code": 500}}');
-- Пример выборки: Найти все логи, где сервис = auth
-- Оператор ->> возвращает текст
SELECT * FROM logs WHERE payload->>'service' = 'auth';
Вы можете хранить списки прямо в одной ячейке. Это нарушает 1НФ (атомарность), но иногда это очень удобно и прагматично (например, список тегов к статье).
CREATE TABLE books (
id BIGINT PRIMARY KEY,
title TEXT,
tags TEXT[] -- Массив текстовых строк
);
INSERT INTO books (id, title, tags)
VALUES (1, 'Learning Java', ARRAY['programming', 'java', 'backend']);
-- Поиск: Найти книги, где есть тег 'java'
-- Оператор @> означает "содержит"
SELECT * FROM books WHERE tags @> ARRAY['java'];
Резюме 1.1:
- Деньги храним в
NUMERIC. - Строки храним в
TEXT. - Время всегда в
TIMESTAMPTZ(хранится как UTC). - Для гибких данных берем
JSONB(неJSON!). - Для простых списков можно использовать массивы
TEXT[], не создавая лишних таблиц, но осторожно.
SQL — это не просто язык запросов, это полноценный язык обработки данных. Часто эффективнее трансформировать данные прямо в базе (где они лежат), чем тащить миллионы "сырых" строк в Java-приложение и обрабатывать их в памяти.
В Postgres работа со строками очень похожа на Java, но синтаксис лаконичнее.
- Конкатенация: Вместо функции
concat(), в Postgres чаще используют оператор||.
Нюанс: Если хоть одна частьSELECT 'Mr. ' || first_name || ' ' || last_name AS full_name FROM users;NULL, результат всего выражения||будетNULL. Если нужно этого избежать, используйтеconcat()— он игнорирует NULL-ы. - Регулярные выражения (Regex): Postgres имеет мощную встроенную поддержку regex.
LIKE— простейшие шаблоны (%,_).~— оператор для POSIX regex (чувствителен к регистру).~*— то же самое, но не чувствительно к регистру.
-- Найти email'ы в домене gmail.com или yahoo.com (регистронезависимо) SELECT email FROM users WHERE email ~* '@(gmail|yahoo)\.com$'; - Форматирование:
substring(),lower(),upper(),initcap()(делает Первая Буква Заглавной).
Это "суперсила" PostgreSQL. Аналитические запросы (отчеты) строятся именно на этом.
- Арифметика дат: В Postgres можно просто прибавлять и отнимать интервалы.
-- Получить дату дедлайна (сейчас + 7 дней) SELECT now() + INTERVAL '7 days'; -- Кто зарегистрировался за последний месяц? SELECT * FROM users WHERE created_at > (now() - INTERVAL '1 month'); date_trunc(Усечение даты): Самая важная функция для построения графиков и отчетов. Она "округляет" время вниз до заданной точности (год, месяц, день, час). Аналог в Java:LocalDateTime.truncatedTo(ChronoUnit.HOURS).-- Округляем до начала месяца (2023-10-15 14:30 -> 2023-10-01 00:00) -- Это нужно, чтобы сгруппировать продажи по месяцам. SELECT date_trunc('month', created_at) AS sale_month, count(*) FROM orders GROUP BY sale_month;
NULL в SQL — это "отсутствие значения". Любая математическая операция с NULL дает NULL (5 + NULL = NULL).
COALESCE(val1, val2, ...): Возвращает первый не-NULL аргумент. Аналог в Java:Optional.ofNullable(val).orElse(default).-- Если у пользователя нет nickname, вывести "Anonymous" SELECT username, COALESCE(nickname, 'Anonymous') FROM users;NULLIF(val1, val2): ВозвращаетNULL, если аргументы равны. Иначе возвращает первый аргумент. Классический пример: Защита от деления на ноль.-- Если count = 0, NULLIF вернет NULL. -- Число / NULL даст NULL (а не ошибку "division by zero"). SELECT total_sum / NULLIF(count, 0) as average FROM stats;
Это аналог if-else или switch прямо внутри SQL запроса. Позволяет трансформировать данные на лету.
SELECT
name,
price,
CASE
WHEN price = 0 THEN 'Free'
WHEN price < 100 THEN 'Cheap'
WHEN price < 500 THEN 'Medium'
ELSE 'Expensive'
END AS price_category
FROM products;
Пример использования в агрегации (Pivot): Допустим, мы хотим посчитать одним запросом, сколько у нас активных и заблокированных юзеров.
SELECT
count(*) AS total_users,
-- Если условие верно, считаем 1, иначе NULL (count игнорирует NULL)
count(CASE WHEN status = 'active' THEN 1 END) AS active_count,
count(CASE WHEN status = 'banned' THEN 1 END) AS banned_count
FROM users;
Резюме 1.2:
- Используйте
||для склейки строк и~*для мощного поиска. - Используйте арифметику с
INTERVALи функциюdate_truncдля работы со временем. - Всегда обрабатывайте потенциальные NULL через
COALESCE. - Используйте
CASE, чтобы перенести бизнес-логику категоризации данных в базу (это быстрее, чем тащить все объекты в Java и перебирать их в цикле).
Если SELECT ... WHERE позволяет нам найти иголку в стоге сена, то группировка и агрегация позволяют нам взвесить этот стог, измерить его объем и понять, из чего он состоит. Это основа любой аналитики.
Представьте, что у вас есть таблица sales (продажи) с миллионом строк. Мы хотим узнать выручку по каждому магазину.
Оператор GROUP BY берет все строки и раскладывает их по "корзинам" (buckets) на основе значения указанной колонки. После этого база данных схлопывает каждую корзину в одну итоговую строку.
Важное правило: Если вы используете GROUP BY, то в SELECT вы можете писать только:
- Колонки, по которым группируете.
- Агрегатные функции (сумма, счетчик и т.д.). Остальные поля (например, id конкретной продажи) недоступны, так как они потеряли индивидуальность при схлопывании.
-- Считаем общую выручку и количество продаж для каждого магазина
SELECT
store_id, -- Колонка группировки
SUM(amount) as total_revenue, -- Агрегатная функция
COUNT(*) as sales_count -- Агрегатная функция
FROM sales
GROUP BY store_id;
Это классический вопрос на собеседованиях и частая ошибка новичков.
WHERE: Фильтрует строки ДО группировки. ("Не учитывай в расчетах продажи меньше 100 рублей").HAVING: Фильтрует результаты ПОСЛЕ группировки. ("Покажи мне только те магазины, у которых общая выручка больше миллиона").
SELECT
category,
AVG(price) as avg_price
FROM products
WHERE is_active = true -- 1. Сначала отсеиваем неактивные товары
GROUP BY category -- 2. Потом группируем по категориям
HAVING AVG(price) > 500; -- 3. В конце оставляем только дорогие категории
COUNT(*): Считает количество строк (включая NULL).COUNT(column): Считает количество строк, где вcolumnне NULL.SUM(column): Сумма.AVG(column): Среднее значение.MIN(column)/MAX(column): Минимум и максимум.
Postgres предлагает мощные функции, которые позволяют собирать данные в списки или строки прямо в запросе. Это очень полезно, чтобы не делать N+1 запросов в Java.
Допустим, нам нужно получить список имен сотрудников для каждого отдела через запятую.
SELECT
department_id,
-- Аргументы: поле, разделитель
string_agg(employee_name, ', ') as team_list
FROM employees
GROUP BY department_id;
-- Результат:
-- 1 | "Иванов, Петров, Сидоров"
-- 2 | "Смитт, Джонсон"
Собирает значения в реальный массив Postgres. Это удобнее для обработки в Java (JDBC вернет это как java.sql.Array).
SELECT
order_id,
array_agg(product_id) as product_ids -- Получаем массив ID товаров в заказе
FROM order_items
GROUP BY order_id;
-- Результат:
-- 105 | {12, 45, 99}
-- 106 | {10, 10}
Резюме 1.3:
GROUP BYсоздает группы строк.WHEREфильтрует исходные данные (сырье),HAVINGфильтрует результат (агрегат).- Используйте
string_aggиarray_agg, чтобы денормализовать данные "на лету" и получать удобные структуры для бэкенда.
===========================================
Здесь мы перестаем относиться к базе как к черному ящику. Понимание внутренностей — это ключ к ответу на вопрос: "Почему мой запрос тормозит, хотя я добавил индексы?".
PostgreSQL устроен иначе, чем большинство Java-приложений (которые являются многопоточными, multithreaded). Postgres использует многопроцессную модель (multiprocess).
Когда вы запускаете PostgreSQL, стартует не один процесс, а целая "команда".
- Postmaster (Main Process):
- Это "босс". Он слушает порт (обычно 5432).
- Он не обрабатывает запросы сам.
- Его задача: принять соединение, породить (fork) новый рабочий процесс и передать клиента ему.
- Backend Processes (Workers):
- На каждое соединение клиента создается отдельный процесс ОС (postgres: user db host...).
- Аналогия: Это как CGI-скрипты в старом вебе. В отличие от Java (где на каждый запрос выделяется легкий поток/Thread), здесь выделяется тяжелый процесс.
- Последствие: Создание соединения в Postgres — это дорого. Это занимает время и память (минимум 2-3 МБ на процесс, даже если он простаивает).
- Вывод: Именно поэтому в Java обязательно нужен Connection Pool (HikariCP). Нельзя открывать соединение на каждый чих.
Память в Postgres делится на две большие зоны: Общая (Shared) и Локальная (Local).
Эта память выделяется при старте сервера и доступна всем процессам сразу.
- Shared Buffers:
- Это главный кэш базы данных.
- Здесь хранятся "страницы" (pages) данных — блоки по 8 КБ, считанные с диска.
- Когда вы делаете
SELECT, Postgres сначала ищет данные здесь. Если нашел (Hit) — супер. Если нет (Miss) — идет на диск. - Настройка: Обычно ставят 25-40% от RAM сервера. Не больше, потому что есть еще кэш ОС.
- WAL Buffers:
- Буфер для записи журнала транзакций (о нем в теме 2.4). Данные сначала пишутся сюда, а потом сбрасываются на диск.
Эта память выделяется каждому бэкенд-процессу индивидуально.
- work_mem:
- Память для сортировок (
ORDER BY) и хэш-таблиц (JOIN,GROUP BY). - Опасность: Этот лимит применяется на каждую операцию внутри одного запроса.
- Пример: Если
work_mem = 10MB, а в запросе 3 сортировки и 2 джоина, один запрос может съесть10 * 5 = 50MB. Умножьте на 100 активных пользователей — получите OOM (Out Of Memory) и падение сервера.
- Память для сортировок (
- temp_buffers:
- Память для временных таблиц.
Postgres очень "хитрый". Он не пытается кэшировать всё сам, как Oracle. Он полагается на ядро Linux.
- Если данные не поместились в
shared_buffers, они могут остаться в свободной памяти Linux (Page Cache). Чтение оттуда тоже очень быстрое. - Поэтому мы не отдаем всю память под
shared_buffers, а оставляем "воздух" для ОС.
Postgres хранит данные страницами по 8 КБ (8192 байта). Что делать, если вы хотите сохранить в поле TEXT статью на 1 МБ? Она физически не влезет в страницу.
Тут вступает в дело механизм TOAST:
- Если строка превышает ~2 КБ, Postgres нарезает длинное поле на кусочки.
- Эти кусочки сжимаются (алгоритм LZ4 или PGLZ).
- И они сохраняются в отдельную скрытую системную таблицу (TOAST-таблицу).
- В основной таблице остается лишь маленький "указатель" на эти данные.
Почему это важно знать:
SELECT *— зло. Если вы выбираете тяжелое полеdescription, Postgres вынужден лезть в TOAST-таблицу, распаковывать данные и отдавать их вам. Если вам нужно толькоidиtitle, не трогайте тяжелые поля — база будет летать, так как будет читать только основную таблицу.
Резюме 2.1:
- Postgres тяжелый на подъем: процессы вместо потоков. Нужен Connection Pool.
shared_buffers— общий кэш.work_mem— память для работы запроса. Будьте осторожны с ее увеличением.- Длинные тексты хранятся отдельно (TOAST) и сжимаются. Не тяните их без нужды.
Это, пожалуй, самая важная тема для понимания того, как Postgres ведет себя под нагрузкой.
MVCC (Управление параллелизмом через многоверсионность) — это механизм, который позволяет множеству транзакций работать с одними и теми же данными одновременно, не блокируя друг друга.
Главный принцип: "Читатели не блокируют писателей, а писатели не блокируют читателей". В старых базах данных, если кто-то обновлял таблицу, остальные ждали. В Postgres, если вы обновляете строку, я все равно могу прочитать её "старую" версию.
В PostgreSQL (в отличие от некоторых других БД) строки неизменяемы (immutable). Вы не можете просто взять и переписать байты на диске поверх старых.
- INSERT: Все просто. Создается новая версия строки (tuple).
- DELETE: Строка не удаляется физически. На нее просто вешается "бирка": "Эта строка мертва начиная с транзакции N". Но байты остаются на диске.
- UPDATE: Это самое интересное. Для Postgres UPDATE = DELETE + INSERT.
- Старая версия строки помечается как "мертвая".
- Создается абсолютно новая версия строки с новыми данными.
Последствие: Если вы обновили 1 миллион строк, Postgres записал на диск 1 миллион новых строк, а старый миллион остался лежать мертвым грузом, занимая место.
У каждой строки в каждой таблице есть скрытые служебные поля. Вы их не видите в SELECT *, но можете запросить явно.
SELECT id, name, xmin, xmax, ctid FROM users;
xmin(Transaction ID Min): ID транзакции, которая создала эту версию строки.xmax(Transaction ID Max): ID транзакции, которая удалила (или обновила) эту версию строки.- Если
xmax = 0, значит строка жива и актуальна. - Если
xmaxзаполнено, значит строка удалена (или обновлена), но, возможно, еще видна старым транзакциям.
- Если
ctid: Физический адрес строки:(номер_блока_на_диске, номер_строки_в_блоке). Например(0, 1).- Важно: При UPDATE
ctidменяется, так как новая версия строки ложится в новое место.
- Важно: При UPDATE
Пример логики видимости: Представьте, что я начал транзакцию №100. Я делаю запрос. Postgres сканирует таблицу и решает, показывать ли мне строку, по правилам:
xmin< 100? (Строка создана в прошлом?) — Да.xmaxпуст ИЛИxmax> 100? (Строка еще не удалена или удалена в будущем?) — Да. Вывод: Я вижу строку.
Это классическая жалоба: "Почему в таблице на 10 млн строк SELECT count(*) FROM table выполняется 5 секунд? В MyISAM это было мгновенно!"
- Причина: Из-за MVCC Postgres не знает точного числа "живых" строк заранее.
- В таблице может лежать 20 млн физических записей: 10 млн живых и 10 млн мертвых (после вчерашнего апдейта).
- Более того, для транзакции А количество строк может быть 10 млн, а для транзакции Б (которая началась раньше) — 10.5 млн.
- Итог: Postgres вынужден каждый раз сканировать таблицу (или индекс) и проверять
xmin/xmaxдля каждой строки: "Видна ли эта строка текущему пользователю?".
Так как UPDATE и DELETE не освобождают место сразу, таблица со временем наполняется "мертвыми душами" (dead tuples).
- Это явление называется Bloat.
- Если у вас таблица занимает 10 ГБ, но полезных данных там на 1 ГБ, а 9 ГБ — это мертвые версии строк, значит ваш Bloat = 90%.
- База начинает тормозить, потому что чтобы прочитать 1 полезную строку, ей приходится считывать с диска 9 мусорных.
Для борьбы с этим существует VACUUM.
Резюме 2.2:
- Postgres никогда не перезаписывает данные на месте.
- UPDATE порождает новую версию строки.
- Старые версии остаются на диске, пока их не почистят.
COUNT(*)всегда пересчитывает строки вживую (Full Scan), чтобы соблюсти изоляцию транзакций.
Если MVCC оставляет за собой "мусор" (мертвые версии строк), то VACUUM — это уборщик, который этот мусор вычищает. Без него база данных рано или поздно заполнит всё место на диске и остановится.
Команда VACUUM (без параметров) делает следующее:
- Проходит по страницам таблицы.
- Находит мертвые версии строк (которые уже никому не видны).
- Помечает место, которое они занимали, как "свободное для повторного использования" (Free Space Map).
Важный нюанс: Обычный VACUUM не уменьшает размер файла на диске!
- Аналогия: Представьте швейцарский сыр. Вакуум выедает дырки (освобождает место внутри). Размер куска сыра остается тем же, но теперь новые данные можно заливать в эти дырки.
- Плюс: Он работает без блокировок. Ваше приложение может спокойно читать и писать в таблицу, пока идет вакуум.
Это "ядерное оружие".
- Создает новую копию таблицы с нуля.
- Переносит туда только живые данные, плотно упаковывая их.
- Удаляет старую таблицу.
- Результат: Размер файла на диске уменьшается до минимума.
Опасность:
- Exclusive Lock: Он блокирует таблицу намертво. Никто не может даже прочитать данные, пока процесс не закончится.
- Совет: В продакшене используйте его только в крайних случаях и только во время технического обслуживания (downtime). Вместо него часто используют утилиту
pg_repack(сторонний тул), который делает то же самое, но без блокировок.
Чтобы VACUUM не сканировал каждый раз всю таблицу (гигабайты данных), Postgres поддерживает специальную структуру — Visibility Map. Это битовая карта, где для каждой страницы данных (8 КБ) стоит флажок:
- "На этой странице нет мертвых строк".
- "На этой странице есть мусор".
Вакуум смотрит на карту и пропускает "чистые" страницы. Это ускоряет процесс в разы. Также эта карта позволяет делать Index Only Scan (когда Postgres берет данные прямо из индекса, не заглядывая в таблицу, потому что знает, что все строки там точно "живые").
Это самый страшный кошмар администратора Postgres.
- Идентификатор транзакции (
xmin/xmax) — это 32-битное число. - Максимальное значение: ~4 миллиарда.
- Проблема: Когда счетчик доходит до конца, он сбрасывается в 0 (закольцовывается).
- Катастрофа: Если это произойдет, база решит, что все ваши старые данные (с ID 100, 200...) были созданы "в будущем" (так как сейчас ID снова 0), и скроет их. Данные исчезнут.
Защита (Freezing): Вакуум выполняет еще одну критическую задачу — Freezing (Заморозка). Он находит старые строки и меняет их ID на специальный "замороженный" ID. Это говорит базе: "Эта строка настолько старая, что она видна всем всегда, независимо от текущего счетчика".
Если автовакуум сломается и счетчик приблизится к критической отметке, Postgres аварийно выключится и не даст запуститься, пока вы не сделаете вакуум вручную в однопользовательском режиме.
Autovacuum — это фоновый процесс (демон), который сам просыпается и запускает VACUUM для таблиц, где накопилось много мусора.
Стандартная проблема: Настройки по умолчанию слишком "ленивые" для высоконагруженных систем.
autovacuum_vacuum_scale_factor = 0.2(20%).- Значение: Вакуум придет, когда изменится 20% таблицы.
- Ситуация: У вас таблица на 100 ГБ. Вакуум придет только когда накопится 20 ГБ мусора. Удалять 20 ГБ мусора за раз — это очень долго и нагружает диск.
Best Practice (Тюнинг): Для больших таблиц настройки делают более агрессивными, чтобы убирать мусор чаще, но мелкими порциями.
-- Пример настройки для конкретной большой таблицы
ALTER TABLE big_data_table SET (
-- Запускать вакуум, когда изменится 1% данных (а не 20%)
autovacuum_vacuum_scale_factor = 0.01,
-- ИЛИ запускать каждые 100 000 изменений (независимо от процента)
autovacuum_vacuum_threshold = 100000
);
Резюме 2.3:
- VACUUM нужен для очистки мусора (после MVCC) и защиты от переполнения счетчика транзакций.
- Стандартный вакуум не возвращает место ОС, а оставляет его для будущих вставок.
- VACUUM FULL блокирует всё — избегайте его.
- Для больших таблиц Autovacuum нужно настраивать агрессивнее, чтобы он не приходил слишком поздно.
Это финальная часть "фундамента" Postgres. WAL (Журнал предзаписи) — это механизм, обеспечивающий букву D (Durability) в аббревиатуре ACID. Он гарантирует, что даже если сервер выдернут из розетки, подтвержденные транзакции не пропадут.
Представьте, что изменение данных в базе (random I/O) — это поиск нужной страницы в толстой книге и исправление буквы карандашом. Это медленно. Запись в конец журнала (sequential I/O) — это просто дописывание строки на листочке. Это очень быстро.
Золотое правило WAL:
PostgreSQL никогда не меняет страницу данных на диске, пока не запишет информацию об этом изменении в WAL-лог и не сбросит его на диск.
Когда вы делаете COMMIT транзакции, происходит следующее:
- Изменение в памяти: Изменения применяются к страницам в
shared_buffers. Эти страницы становятся "грязными" (dirty pages). На диск они пока не пишутся. - Запись в WAL: Описание изменения ("В блоке №100 по адресу 5 записать число 42") пишется в WAL-буфер.
- fsync (Сброс на диск): WAL-буфер принудительно сбрасывается в файл на диске (
fsync). Только после успешного завершения этой операции Postgres говорит клиенту: "Транзакция успешна".
Почему это эффективно: Мы подменяем множество медленных произвольных записей (в разные файлы таблиц и индексов) одной быстрой последовательной записью в WAL-файл.
Если бы мы только писали в WAL, журнал рос бы бесконечно, а восстановление занимало бы годы. Чтобы этого не случилось, существует процесс Checkpointer.
- Что делает: Раз в заданное время (по умолчанию 5 минут или при накоплении объема WAL) он просыпается и принудительно записывает все "грязные" страницы из
shared_buffersв реальные файлы таблиц. - Смысл: После чекпоинта старые записи в WAL больше не нужны (данные уже надежно лежат в таблицах). Старые сегменты WAL можно удалять или переиспользовать.
Настройка (postgresql.conf): В нагруженных системах дефолтные настройки чекпоинта часто становятся причиной "фризов" (просадок производительности), так как диск внезапно нагружается записью гигабайтов данных.
- Совет: Увеличивают
max_wal_sizeиcheckpoint_timeout, чтобы чекпоинты происходили реже, но были более "размазаны" по времени.
Что происходит, если питание отключилось через секунду после коммита, но до чекпоинта?
- Данные в
shared_buffers(и в файлах таблиц) потеряны или неактуальны. - Но запись об изменении сохранилась в WAL на диске.
При следующем старте Postgres видит, что был сбой (некорректное завершение):
- Он читает WAL с момента последнего успешного чекпоинта.
- Он проигрывает (Replay) все изменения снова.
- База данных приходит в состояние, которое было ровно в момент сбоя. Ни один подтвержденный байт не потерян.
Дополнительная роль WAL: Помимо надежности, WAL используется для Репликации (Тема 6.2). Мы просто передаем WAL-файлы на другой сервер, и он проигрывает их у себя, становясь точной копией мастера.
=====================================
Мы переходим к тому, что отличает просто работающую базу от быстрой базы. В этом модуле мы будем учиться не просто доставать данные, а доставать их эффективно.
Индекс — это избыточная структура данных, которая ускоряет чтение (SELECT), но замедляет запись (INSERT, UPDATE, DELETE), так как индекс тоже нужно обновлять.
В 95% случаев, когда вы пишете CREATE INDEX, создается B-Tree. Это сбалансированное дерево поиска.
- Как это работает: Данные отсортированы. Корневой узел указывает на ветви, ветви — на листья. Листья содержат ссылку на физическую строку в таблице (
ctid). - Сложность: Поиск одной записи занимает
O(log N). Даже в таблице на миллиард строк нужно всего 4-5 "прыжков", чтобы найти данные. - Для чего подходит:
- Равенство (
WHERE id = 5) - Диапазоны (
WHERE age BETWEEN 18 AND 30,WHERE salary > 5000) - Сортировка (
ORDER BY date) — база просто читает индекс по порядку, ей не нужно сортировать данные в памяти (Sort).
- Равенство (
Когда вы делаете EXPLAIN, вы видите, как именно Postgres решил доставать данные.
- Seq Scan (Sequential Scan):
- Читает всю таблицу подряд с диска.
- Когда выбирается: Если таблица маленькая или если вы запрашиваете большую часть таблицы (например, > 20% строк). В таких случаях читать подряд быстрее, чем скакать по индексу.
- Index Scan:
- Идет в индекс -> Находит адрес строки -> Идет в таблицу за данными.
- Проблема: Это Random I/O (случайное чтение). Если нужно достать много строк, диск может "захлебнуться".
- Bitmap Heap Scan:
- Компромисс. Сначала идет в индекс -> Собирает все адреса строк в битовую карту (в памяти) -> Сортирует их по физическому расположению -> Читает таблицу последовательно.
- Index Only Scan:
- Святой Грааль производительности.
- База берет данные прямо из индекса и даже не заглядывает в таблицу (heap).
- Условие: Все запрашиваемые колонки должны быть в индексе + карта видимости (Visibility Map) должна подтвердить, что строки "живые".
Вы можете создать индекс сразу по нескольким колонкам.
CREATE INDEX idx_users_last_first ON users (last_name, first_name);
Правило левого префикса: Индекс работает как телефонная книга, отсортированная сначала по фамилии, потом по имени.
WHERE last_name = 'Ivanov'— Работает (ищем по фамилии).WHERE last_name = 'Ivanov' AND first_name = 'Ivan'— Работает (еще эффективнее).WHERE first_name = 'Ivan'— НЕ РАБОТАЕТ. Вы не можете искать в телефонной книге по имени, не зная фамилии. Индекс не будет использоваться.
Полезно, если вы часто ищете без учета регистра, но не хотите хранить дублирующие данные.
-- Индекс хранит уже результат функции lower(email)
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- Запрос будет использовать индекс:
SELECT * FROM users WHERE lower(email) = 'dmitry@example.com';
Экономит место и ускоряет поиск, если вас интересует только часть данных.
-- Индексируем только активных пользователей
-- Этот индекс будет крошечным и очень быстрым
CREATE INDEX idx_active_users ON users (created_at) WHERE status = 'active';
-- Запрос должен содержать то же условие WHERE:
SELECT * FROM users WHERE status = 'active' AND created_at > '2025-01-01';
В Postgres есть индексы, которых нет в других БД (например, в MySQL).
Это инвертированный индекс. Как предметный указатель в конце книги (Слово -> Список страниц).
- Применение:
- JSONB: Поиск ключей внутри JSON-документа.
- Массивы: Поиск элемента в массиве.
- Полнотекстовый поиск: Поиск слов в тексте.
-- Индекс для поиска внутри JSONB
CREATE INDEX idx_logs_payload ON logs USING GIN (payload);
-- Теперь этот запрос будет мгновенным (а не полным перебором):
SELECT * FROM logs WHERE payload @> '{"service": "auth"}';
Используется для специфических данных, где нельзя просто сказать "больше" или "меньше".
- Применение: Геометрия (PostGIS, "найти точки в радиусе 5 км"), полнотекстовый поиск.
Индекс для Big Data.
- Идея: Вместо того чтобы хранить позицию каждой строки (как B-Tree), он хранит минимальное и максимальное значение для группы страниц (блока).
- Размер: BRIN-индекс занимает килобайты, даже если таблица весит терабайты.
- Применение: Огромные таблицы логов или временных рядов, где данные пишутся последовательно (время всегда растет).
-- Индекс для таблицы логов на 100 ТБ
CREATE INDEX idx_audit_date ON audit_logs USING BRIN (created_at);
Резюме 3.1:
- B-Tree — дефолтный выбор. Помните про левый префикс в составных индексах.
- Смотрите
EXPLAIN: стремитесь к Index Scan или Index Only Scan, избегайте Seq Scan на больших таблицах. - Используйте Partial Index, чтобы не индексировать мусор.
- Для JSONB и массивов используйте GIN.
- Для огромных исторических таблиц — BRIN.
Оконные функции — это одна из самых мощных возможностей SQL, появившаяся в стандарте SQL:2003.
Главное отличие от GROUP BY:
GROUP BY"схлопывает" строки. Если у вас было 100 продаж, и вы сгруппировали их по месяцам, вы получите 12 строк (по одной на месяц). Детали отдельных продаж теряются.- Оконные функции не меняют количество строк. Если было 100 продаж, останется 100 строк. Но к каждой строке добавляется новая колонка с вычисленным значением, которое зависит от "соседей" (окна).
Магия происходит внутри конструкции OVER.
SELECT
column_name,
FUNCTION_NAME() OVER (
PARTITION BY ... -- Как делить данные на группы ("окна")
ORDER BY ... -- Как сортировать данные внутри окна
)
FROM table_name;
Классическая задача: "Найти топ-3 зарплаты в каждом отделе". Без оконных функций это требует сложных подзапросов.
row_number(): Просто нумерует строки по порядку (1, 2, 3, 4...).rank(): Дает одинаковым значениям одинаковый ранг, но делает пропуски (1, 1, 3...).dense_rank(): Дает одинаковым значениям одинаковый ранг без пропусков (1, 1, 2...).
SELECT
employee_name,
department,
salary,
-- Нумеруем сотрудников внутри КАЖДОГО департамента по убыванию зарплаты
row_number() OVER (PARTITION BY department ORDER BY salary DESC) as rn
FROM employees;
-- Чтобы оставить только топ-3, этот запрос нужно обернуть в CTE (подзапрос),
-- так как нельзя использовать оконные функции в WHERE.
Функции LAG (предыдущий) и LEAD (следующий) позволяют обращаться к соседним строкам. Задача: Сравнить продажи текущего месяца с предыдущим и посчитать разницу.
SELECT
month,
sales_amount,
-- LAG берет значение sales_amount из предыдущей (1) строки
LAG(sales_amount, 1) OVER (ORDER BY month) as prev_month_sales,
-- Считаем рост/падение прямо тут
sales_amount - LAG(sales_amount, 1) OVER (ORDER BY month) as diff
FROM monthly_sales;
Мы можем использовать обычные агрегатные функции (SUM, AVG, COUNT) как оконные. Если добавить ORDER BY внутри OVER, агрегат становится нарастающим.
Задача: Показать, как рос баланс счета транзакция за транзакцией.
SELECT
transaction_date,
amount,
-- Суммирует amount от начала времен до ТЕКУЩЕЙ строки
SUM(amount) OVER (ORDER BY transaction_date) as running_balance
FROM transactions;
Если добавить PARTITION BY user_id, то баланс будет считаться отдельно для каждого пользователя.
Используется для сглаживания графиков. Можно задать рамки окна вручную: ROWS BETWEEN ....
SELECT
date,
price,
-- Средняя цена за текущий день и 2 предыдущих (3-day moving average)
AVG(price) OVER (
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) as smooth_price
FROM stock_prices;
Резюме 3.2:
- Оконные функции добавляют аналитику к строкам, не уменьшая их количество.
PARTITION BY— делит данные на независимые куски (какGROUP BY, но внутри окна).ORDER BY— определяет порядок обработки строк внутри окна (важно дляrow_number,lead,running total).- Используйте
row_number()для топов и пагинации. - Используйте
LAG/LEADдля поиска изменений (дельты).
CTE (обобщенные табличные выражения), или конструкция WITH — это способ задать временный именованный результат, который существует только в рамках одного запроса.
Если вы когда-нибудь писали запрос с тремя уровнями вложенности (SELECT ... FROM (SELECT ... FROM (SELECT ...))), вы знаете, что такой код невозможно читать и отлаживать. CTE превращает эту "лапшу" в стройный список логических шагов.
Представьте CTE как объявление переменных или временных функций в коде.
Плохой стиль (Вложенные подзапросы):
SELECT avg(total)
FROM (
SELECT user_id, sum(amount) as total
FROM orders
WHERE created_at > '2023-01-01'
GROUP BY user_id
) as user_totals;
Хороший стиль (CTE):
WITH user_totals AS (
-- Шаг 1: Считаем сумму для каждого юзера
SELECT user_id, sum(amount) as total
FROM orders
WHERE created_at > '2023-01-01'
GROUP BY user_id
)
-- Шаг 2: Считаем среднее по полученным данным
SELECT avg(total) FROM user_totals;
Плюс: Вы можете ссылаться на user_totals несколько раз в основном запросе (например, сравнить конкретного юзера со средним), не дублируя код подзапроса.
Это "киллер-фича" для работы с иерархическими данными:
- Деревья категорий (Электроника -> Телефоны -> Смартфоны).
- Организационная структура (Директор -> Начальник отдела -> Сотрудник).
- Ветки комментариев (Reddit style).
Рекурсивный запрос состоит из двух частей, объединенных UNION ALL:
- Anchor member (Якорь): Стартовая точка (корневые узлы).
- Recursive member (Рекурсивная часть): Запрос, который ссылается на само CTE и находит "детей".
Пример: Построение дерева сотрудников (Кто кому подчиняется) Предположим, у нас есть таблица employees с полями id, name, manager_id.
WITH RECURSIVE subordinates AS (
-- 1. Якорь: Находим "Биг Босса" (у него нет начальника)
SELECT
id,
name,
manager_id,
1 as level, -- Уровень иерархии
name::text as path -- Путь (хлебные крошки)
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 2. Рекурсия: Ищем тех, чей начальник уже найден на предыдущем шаге
SELECT
e.id,
e.name,
e.manager_id,
s.level + 1, -- Увеличиваем уровень
s.path || ' -> ' || e.name
FROM employees e
INNER JOIN subordinates s ON e.manager_id = s.id -- JOIN с самим CTE
)
SELECT * FROM subordinates;
До версии Postgres 12 CTE всегда были "забором" для оптимизатора (Optimization Fence). База данных обязательно выполняла CTE полностью, сохраняла результат в память, и только потом выполняла основной запрос. Это было безопасно, но иногда медленно (не работали индексы из основного запроса).
В современных версиях Postgres (12+) оптимизатор умный: он может "встроить" (inline) код CTE в основной запрос, как будто это обычный подзапрос, если это выгоднее.
Вы можете управлять этим поведением вручную:
WITH temp_data AS NOT MATERIALIZED (...)— заставь базу встроить код (как макрос).WITH temp_data AS MATERIALIZED (...)— заставь базу выполнить это один раз и запомнить результат. Полезно, если CTE очень тяжелый, а вы обращаетесь к нему много раз.
=========================================
Как Senior Java разработчик, вы наверняка используете @Transactional в Spring. Но чтобы понимать, почему иногда возникают OptimisticLockException, дедлоки или данные обновляются не так, как ожидалось, нужно понимать физику транзакций в базе.
Транзакция — это единица работы. Либо всё, либо ничего.
- Atomicity (Атомарность): Транзакция не может быть выполнена частично. Если упал сервер или вылетела ошибка на последнем
UPDATE, откатятся (ROLLBACK) абсолютно все изменения этой транзакции. - Consistency (Согласованность): Транзакция переводит базу из одного валидного состояния в другое. Все
Constraints(Foreign Keys, NOT NULL, CHECK) должны быть соблюдены. - Isolation (Изоляция): Влияние параллельных транзакций друг на друга. Об этом ниже подробно.
- Durability (Долговечность): Если база сказала "ОК" (Commit), данные сохранены на диске (благодаря WAL), даже если через миллисекунду сервер сгорит.
Чтобы понять уровни изоляции, нужно сначала понять проблемы, которые они решают.
- Dirty Read (Грязное чтение): Транзакция А читает данные, которые изменила транзакция Б, но Б еще не сделала COMMIT.
- Риск: Б делает ROLLBACK, а А уже работает с неверными данными.
- В Postgres: Невозможно. Postgres архитектурно не умеет отдавать "грязные" данные (даже на уровне Read Uncommitted).
- Non-repeatable Read (Неповторяющееся чтение): Транзакция А читает строку (SELECT salary WHERE id=1). Транзакция Б меняет эту строку и комитит. А снова читает строку и видит другое значение.
- Phantom Read (Фантомное чтение): Транзакция А читает набор строк (SELECT * WHERE salary > 100). Б добавляет новую строку с salary=200. А снова делает тот же запрос и видит новую строку ("фантома"), которой не было раньше.
- Отличие от Non-repeatable: Там меняется содержимое существующей строки, здесь меняется количество строк в выборке.
- Serialization Anomaly: Результат выполнения параллельных транзакций не соответствует никакому варианту их последовательного выполнения.
В Spring вы настраиваете это через @Transactional(isolation = Isolation.X).
- Как работает: Каждый запрос (Statement) внутри транзакции видит снимок данных на момент начала этого конкретного запроса.
- Эффект:
- Dirty Read: Нет.
- Non-repeatable Read: Возможно. Если вы сделаете два одинаковых SELECT внутри одной транзакции, между ними данные могут измениться.
- Phantom Read: Возможно.
- Когда использовать: В 90% случаев. Это стандарт.
- Как работает: Вся транзакция видит снимок данных на момент начала первого запроса в этой транзакции.
- Эффект: Вы работаете в "замороженном времени". Даже если другие меняют данные, вы видите старую версию.
- Особенность Postgres: В стандарте SQL этот уровень допускает Фантомы. В PostgreSQL реализация
Repeatable Readпредотвращает и Фантомы тоже. Это строже стандарта. - Ошибка сериализации: Если две транзакции
Repeatable Readпопытаются обновить одну и ту же строку, вторая упадет с ошибкой:ERROR: could not serialize access due to concurrent update. Приложение должно поймать это и повторить транзакцию (Retry).
- Как работает: Эмуляция того, что транзакции выполняются строго по очереди (одна за другой), хотя физически они идут параллельно.
- Цена: Используются специальные "предикатные блокировки" (SIReadLock). Очень высокий шанс получить ошибку сериализации и необходимость ретраев.
- Когда использовать: Только для критически важных финансовых операций, где несогласованность недопустима ни при каких обстоятельствах. Сильно бьет по производительности.
Это то, с чем вы столкнетесь в Java. Два менеджера одновременно редактируют товар.
- Менеджер А читает:
Количество = 10. - Менеджер Б читает:
Количество = 10. - А продает 2 шт, пишет
8. Commit. - Б продает 1 шт, пишет
9. Commit.
Итог: На складе 7 товаров (10 - 2 - 1). В базе записано 9. Продажа А "потерялась".
Решения:
- Атомарный апдейт:
UPDATE goods SET count = count - 2 ...(Работает на Read Committed). - Pessimistic Locking (SELECT FOR UPDATE): Блокируем строку на чтении (см. тему 4.2).
- Optimistic Locking (Java level): Поле
@Versionв Hibernate. При сохранении проверяется, не изменилась ли версия.
Управление конкурентным доступом в Postgres строится не только на MVCC (которое позволяет читать, не блокируя писателей), но и на системе блокировок, которая предотвращает хаос при одновременной записи.
Эти блокировки затрагивают всю таблицу целиком.
- Явные: Вы можете вызвать их командой
LOCK TABLE users ..., но в разработке приложений это делается крайне редко. - Неявные: Postgres сам накладывает их при выполнении команд.
- ACCESS SHARE: Накладывается при
SELECT. Совместима почти со всем, кроме удаления таблицы. - ROW EXCLUSIVE: Накладывается при
UPDATE,DELETE,INSERT. Позволяет другим читать таблицу и даже менять другие строки, но запрещает менять структуру таблицы (DDL). - ACCESS EXCLUSIVE: Самая жесткая блокировка. Накладывается при
ALTER TABLE,DROP TABLE,TRUNCATE,VACUUM FULL.- Эффект: Никто не может ни читать, ни писать в таблицу, пока транзакция не завершится.
- Опасность: Если вы запустите миграцию
ALTER TABLE users ADD COLUMN...на проде в час пик, всё приложение "встанет", ожидая доступа к таблицеusers. (В Postgres 11+ добавление колонки с дефолтным значением оптимизировано и блокирует таблицу лишь на доли секунды, но старые версии или сложныеALTERмогут быть долгими).
- ACCESS SHARE: Накладывается при
Это то, с чем мы работаем чаще всего для предотвращения состояния гонки (Race Condition).
Представьте ситуацию: вы читаете баланс пользователя, проверяете, хватает ли денег, и списываете их. Если между чтением и записью параллельная транзакция тоже спишет деньги, баланс уйдет в минус.
Решение: Пессимистическая блокировка.
BEGIN;
-- Мы не просто читаем строку, мы "захватываем" её.
-- Если другая транзакция попытается изменить эту строку ИЛИ сделать SELECT FOR UPDATE,
-- она "повиснет" и будет ждать, пока мы не сделаем COMMIT или ROLLBACK.
SELECT balance FROM wallets WHERE user_id = 1 FOR UPDATE;
-- ... тут логика приложения на Java (проверка if balance > 100) ...
UPDATE wallets SET balance = balance - 100 WHERE user_id = 1;
COMMIT; -- Тут блокировка снимается автоматически.
Вариации:
FOR SHARE: Блокирует изменение строки другими, но позволяет другим читать её.NOWAIT: "Попытайся заблокировать. Если занято — не жди, а сразу падай с ошибкой".SKIP LOCKED: "Дай мне те строки, которые никем не заняты". Идеально для реализации очередей задач на базе SQL (несколько воркеров разбирают задачи, не конфликтуя).
Классическая проблема: "Два барана на мосту".
- Транзакция А блокирует строку 1.
- Транзакция Б блокирует строку 2.
- Транзакция А пытается заблокировать строку 2 (и ждет Б).
- Транзакция Б пытается заблокировать строку 1 (и ждет А).
Итог: Они будут ждать друг друга вечно.
Postgres имеет детектор дедлоков. Он просыпается (по умолчанию через 1 секунду ожидания), видит кольцевую зависимость и убивает одну из транзакций с ошибкой deadlock detected, чтобы спасти вторую.
Как избегать:
- Порядок блокировок: Всегда блокируйте ресурсы в одном и том же порядке (например, сортируйте ID перед обновлением пачки записей). Если все идут от меньшего ID к большему, дедлок невозможен.
- Минимизация времени: Держите транзакции короткими. Не делайте HTTP-вызовы или сложные вычисления внутри открытой транзакции с блокировками.
Это уникальная фича Postgres. Это блокировки "понарошку", которые не привязаны к физическим таблицам или строкам. Вы блокируете абстрактное число.
- Сценарий: У вас есть распределенное Java-приложение (10 инстансов). Вам нужно, чтобы тяжелый отчет генерировал только один инстанс одновременно.
- Решение:
Это работает быстрее и проще, чем Redis или ZooKeeper, если у вас уже есть Postgres.-- Пытаемся захватить "виртуальный замок" с ID 12345 -- Вернет true, если захватили. false, если занято. SELECT pg_try_advisory_lock(12345);
Резюме 4.2:
- Осторожно с
ALTER TABLE— это эксклюзивная блокировка. - Используйте
SELECT FOR UPDATEдля защиты финансовых операций (Pessimistic Locking). - Сортируйте ID при массовых обновлениях, чтобы избежать Deadlocks.
- Используйте
SKIP LOCKEDдля очередей иAdvisory Locksдля синхронизации распределенных процессов.
Это тема, где архитектура базы данных встречается с реальностью Java-приложения.
Как мы обсуждали в Теме 2.1, PostgreSQL использует процессную модель.
Когда ваше Java-приложение делает DriverManager.getConnection():
- Сеть: TCP 3-way handshake (установка соединения).
- Postgres: Postmaster делает
fork()(клонирует процесс) — это операция ОС, она не мгновенная. - Память: Новый процесс выделяет себе память (Kernel memory + shared buffers mapping).
- Аутентификация: Проверка пароля, SSL handshake.
- Инициализация: Загрузка кешей, настроек сессии.
Итог: Создание соединения может занимать от 10 до 100+ мс. Если делать это на каждый HTTP-запрос (который должен отвечать за 50 мс), приложение будет работать отвратительно медленно.
В мире Java стандартом де-факто является HikariCP (он встроен в Spring Boot по умолчанию).
Принцип работы: HikariCP создает, например, 10 соединений на старте и держит их открытыми вечно.
- Приложение: "Дай соединение!" -> Hikari: "Вот тебе готовое из коробки".
- Приложение: "Я всё (close)" -> Hikari: "Ок, я не закрываю его реально, а просто кладу обратно в коробку".
Best Practices настройки HikariCP:
maximumPoolSize: Самая важная настройка.- Миф: "Чем больше, тем лучше. Поставлю 100, чтобы всем хватило".
- Реальность: Процессор базы имеет ограниченное число ядер. Если у вас 4 ядра и 100 активных соединений, они будут драться за процессорное время (Context Switching), и производительность упадет.
- Формула:
Количество ядер * 2 + эффективное число шпинделей (дисков). - Совет: Для большинства нагрузок пул размером 10-20 работает быстрее, чем пул 100.
connectionTimeout: Сколько ждать свободного соединения, если пул пуст.- Ставьте разумное значение (например, 2-5 секунд), чтобы быстро падать (Fail Fast), если база легла, а не висеть 30 секунд.
leakDetectionThreshold: Позволяет найти места в коде, где вы забыли закрыть соединение (connection leak).
Иногда пула в Java недостаточно.
- Проблема: У вас микросервисная архитектура. 50 микросервисов, каждый запущен в 20 экземплярах (Kubernetes pods).
- Математика: 50 * 20 = 1000 подов. Если каждый под откроет хотя бы 10 соединений (Hikari), к базе придет 10,000 соединений. Postgres умрет от оверхеда на переключение контекста процессов, даже если запросов мало.
Решение: Ставим между сервисами и базой PgBouncer. Это легкий прокси. Он может держать 10,000 входящих соединений от Java, но использовать всего 100 реальных соединений к Postgres.
Режимы работы PgBouncer:
- Session Pooling (По умолчанию):
- Клиент получает соединение на всё время жизни сессии (пока не отключится).
- Плюс: Поддерживает все фичи SQL (временные таблицы, SET variables).
- Минус: Не решает проблему 10,000 висящих клиентов, если они не закрывают соединения.
- Transaction Pooling (Агрессивный):
- Клиент получает реальное соединение к базе только на время транзакции (
BEGIN...COMMIT). - Как только транзакция завершена, соединение отбирается и отдается другому клиенту, даже если Java-клиент думает, что соединение еще открыто.
- Эффект: Можно обслужить 10,000 клиентов с помощью 50 соединений к базе.
- Ограничения: Нельзя использовать
Prepared Statements(в старых версиях), сессионные переменные (SET my_var = 1), временные таблицы, так как следующая транзакция может уйти в другое соединение.
- Клиент получает реальное соединение к базе только на время транзакции (
=======================================================
Добро пожаловать в "высшую лигу". Здесь мы перестаем гадать, почему запрос медленный, и начинаем читать мысли планировщика (Query Planner).
Команда EXPLAIN — это рентген для вашего SQL-запроса. Она показывает План выполнения (Query Plan) — пошаговую инструкцию, которую составила база данных для получения результата.
Есть два основных режима работы:
EXPLAIN(Теоретический):- База не выполняет запрос.
- Она только прикидывает, как бы она его выполняла.
- Плюс: Мгновенно. Безопасно для
DELETE/UPDATE. - Минус: Показывает только догадки (estimates), которые могут отличаться от реальности.
EXPLAIN ANALYZE(Практический):- База реально выполняет запрос.
- Замеряет точное время на каждом этапе.
- Плюс: Показывает правду.
- Минус: Запрос нагружает базу.
- ОПАСНОСТЬ: Если вы напишете
EXPLAIN ANALYZE DELETE ..., данные реально удалятся! - Лайфхак: Оборачивайте в транзакцию с откатом:
BEGIN; EXPLAIN ANALYZE DELETE FROM users WHERE id < 100; ROLLBACK; -- Данные спасены
EXPLAIN (ANALYZE, BUFFERS)(Профессиональный):- Показывает не только время, но и сколько страниц (блоков по 8 КБ) было прочитано из памяти (
shared hit) и сколько с диска (read). Это лучший метрика нагрузки.
- Показывает не только время, но и сколько страниц (блоков по 8 КБ) было прочитано из памяти (
План — это дерево узлов (Nodes). Читать его нужно снизу вверх и изнутри наружу.
Пример вывода:
QUERY PLAN
------------------------------------------------------------------------------------------------
Aggregate (cost=45.50..45.51 rows=1 width=8) (actual time=0.045..0.046 rows=1 loops=1)
-> Hash Join (cost=15.00..40.00 rows=1000 width=4) (actual time=0.020..0.035 rows=950 loops=1)
Hash Cond: (orders.user_id = users.id)
-> Seq Scan on orders (cost=0.00..20.00 rows=2000 width=8) (...)
-> Hash (cost=10.00..10.00 rows=500 width=4) (...)
-> Seq Scan on users (cost=0.00..10.00 rows=500 width=4) (...)
Как это читать (снизу вверх):
- Seq Scan on users: Сначала база просканировала таблицу юзеров.
- Hash: Построила из них Хеш-таблицу (в памяти).
- Seq Scan on orders: Просканировала таблицу заказов.
- Hash Join: Соединила заказы с хеш-таблицей юзеров.
- Aggregate: Посчитала итоговый результат.
В каждой строке плана вы видите цифры. Понимать их — критически важно.
Это абстрактные "попугаи", в которых планировщик оценивает сложность.
0.00(Startup Cost): Сколько "попугаев" нужно, чтобы получить первую строку. ДляSeq Scanэто 0 (начать читать можно сразу). ДляSortэто много (нужно сначала все отсортировать, прежде чем отдать первую строку).20.00(Total Cost): Сколько стоит получить все строки.- Из чего складывается: Чтение страницы с диска стоит (по умолчанию) 1.0, обработка строки процессором — 0.01 и т.д.
Это прогноз планировщика: "Я думаю, что этот узел вернет 1000 строк".
Появляется только в EXPLAIN ANALYZE.
- Время в миллисекундах.
- Первое число: время до получения первой строки.
- Второе число: время до получения последней строки.
Это факт: сколько строк реально вернул узел.
Сравнивайте Estimated Rows (прогноз) и Actual Rows (факт).
- Если цифры совпадают (1000 vs 950): Планировщик молодец, статистика актуальна, он выбрал оптимальный алгоритм.
- Если цифры расходятся в разы (1 vs 10000): Это катастрофа.
- Планировщик думал, что строк будет мало, и выбрал алгоритм
Nested Loop(хорош для малого числа данных). - А строк оказалось 10 тысяч.
Nested Loopзахлебнулся. - Причина: Устаревшая статистика.
- Лечение: Запустить
ANALYZE table_name.
- Планировщик думал, что строк будет мало, и выбрал алгоритм
Резюме 5.1:
- Всегда смотрите план, если запрос тормозит.
EXPLAIN— быстро,EXPLAIN ANALYZE— точно (но выполняет запрос!).- Используйте
BUFFERS, чтобы видеть нагрузку на память/диск. - Главный враг производительности — разница между rows (estimate) и rows (actual).
Откуда планировщик (Query Planner) знает, что в таблице users ровно 1000 строк, где country = 'Russia', не читая саму таблицу? Он смотрит в статистику.
Статистика — это метаданные о распределении значений в ваших колонках. Это "шпаргалка", по которой планировщик строит математическую модель стоимости запроса.
Когда вы пишете WHERE age > 25, планировщик не бежит проверять данные. Он делает вероятностную оценку:
- Смотрит в статистику колонки
age. - Видит гистограмму распределения.
- Считает: "Ага, значения от 25 до 100 составляют примерно 30% от всех данных".
- Умножает 30% на общее число строк в таблице.
- Получает
rows estimate.
Если статистика врет (устарела), планировщик примет неверное решение (например, выберет Seq Scan вместо индекса), и запрос будет работать в 100 раз медленнее.
Сырые данные хранятся в системной таблице pg_statistic, но она нечитаема для людей. Для нас есть удобное представление pg_stats.
Вы можете сами посмотреть, что база знает о ваших данных:
SELECT
tablename,
attname AS column_name,
null_frac, -- Доля NULL значений (например, 0.1 = 10% пустых)
n_distinct, -- Количество уникальных значений
most_common_vals, -- Топ самых частых значений (для частых элементов)
most_common_freqs, -- Частота этих значений
histogram_bounds -- Границы корзин гистограммы (для диапазонов > <)
FROM pg_stats
WHERE tablename = 'orders';
Важные метрики:
- n_distinct: Если число отрицательное (например, -0.5), это значит "50% строк уникальны". Если положительное (например, 5), это значит "всего 5 уникальных вариантов" (как enum: Male, Female, Other...).
Эта команда заставляет Postgres пересчитать статистику. Она не читает всю таблицу (это было бы долго). Она берет случайную выборку (сэмплирование) — обычно 30 000 строк (настраивается), и по ним строит картину мира.
- Autovacuum: Обычно запускает
ANALYZEавтоматически после изменения определенного % данных. - Ручной запуск: Обязательно запускайте
ANALYZE table_name, если вы:- Только что залили много данных (Bulk Insert).
- Создали индекс (по индексу тоже собирается статистика).
- Видите в
EXPLAIN, чтоrows estimateсильно врет.
Это классическая ловушка, где стандартная статистика ломается.
Представьте запрос:
SELECT * FROM locations WHERE city = 'Paris' AND country = 'France';
Планировщик считает вероятности независимо:
- Вероятность
city = 'Paris'= 0.001 (1 из 1000). - Вероятность
country = 'France'= 0.1 (1 из 10). - Итоговая вероятность (как в школе):
0.001 * 0.1 = 0.0001.
Реальность: Если город — Париж, то страна — обязательно Франция. Эти колонки жестко связаны (коррелируют). Реальная вероятность должна быть равна вероятности самого Парижа (0.001). Итог: Планировщик занизил ожидаемое число строк в 10 раз.
Решение (Extended Statistics): В новых версиях Postgres (10+) можно научить базу видеть эту связь:
-- Создаем расширенную статистику на зависимость колонок
CREATE STATISTICS st_city_country (dependencies) ON city, country FROM locations;
-- Собираем данные
ANALYZE locations;
Теперь планировщик поймет, что эти колонки идут парой, и оценка rows будет точной.
Резюме 5.2:
- Планировщик слеп без статистики.
- Проверяйте
pg_stats, чтобы понять, видит ли база перекосы в данных (data skew). - Если залили данные — сделайте
ANALYZE. - Если колонки логически связаны (Модель машины + Марка, Город + Страна), используйте
CREATE STATISTICS.
Вы написали SELECT * FROM A JOIN B ON A.id = B.id. Но как именно база данных физически соединит эти данные? Postgres выбирает один из трех алгоритмов, основываясь на стоимости и статистике. Понимание этих алгоритмов — ключ к пониманию того, почему запрос "висит".
Самый простой и интуитивный алгоритм.
- Как работает:
for (Row outer : tableA) { // Внешний цикл for (Row inner : tableB) { // Внутренний цикл if (outer.id == inner.id) { emit(outer, inner); } } } - Оптимизация: Если на внутренней таблице (
tableB) есть индекс, то вместо полного перебора внутренний цикл превращается в быстрый поиск по индексу (Index Scan). - Когда эффективен:
- Когда одна из таблиц очень маленькая (или после фильтрации
WHEREосталось мало строк). - Это основной алгоритм для быстрых OLTP-запросов (например, найти юзера и его профиль).
- Когда одна из таблиц очень маленькая (или после фильтрации
- Проблема: Если обе таблицы большие (10k * 10k), это приведет к 100 миллионам операций. Процессор "сгорит".
Алгоритм для обработки больших массивов данных без индексов.
-
Как работает:
-
Build Phase: База берет меньшую таблицу и целиком строит из нее Хеш-таблицу в оперативной памяти (
work_mem). Ключ хеша — поле соединения (id). - Probe Phase: База сканирует большую таблицу, для каждой строки считает хеш и проверяет, есть ли совпадение в хеш-таблице.
-
Build Phase: База берет меньшую таблицу и целиком строит из нее Хеш-таблицу в оперативной памяти (
-
Сложность:
$O\left(N+M\right)$ . Линейная зависимость! Это намного быстрее квадратичной сложности Nested Loop на больших объемах. -
Требования:
- Работает только для условий равенства (
ON a.id = b.id). Не работает для>,<. - Требует памяти. Если хеш-таблица не влезает в
work_mem, она начинает сбрасываться на диск (temp files), и производительность резко падает.
- Работает только для условий равенства (
- Когда эффективен: Тяжелая аналитика, отчеты, отсутствие индексов.
Алгоритм "застежки-молнии".
- Как работает:
- Сначала сортирует обе таблицы по ключу соединения (если они еще не отсортированы индексом).
- Ставит указатели на начало обоих списков.
- Двигает указатели вниз, сопоставляя значения. Как только значения совпали — соединяет.
- Когда эффективен:
- Когда таблицы уже отсортированы (например, соединяем по
Primary Keyили по полю, где есть B-Tree индекс). В этом случае это самый быстрый алгоритм, не требующий памяти под хеш-таблицу. - Для условий диапазона (range joins).
- Когда таблицы уже отсортированы (например, соединяем по
Задача: Взять "Дневник снов", сгенерировать 1 млн записей, написать запрос поиска по тексту сна и оптимизировать его с помощью GIN-индекса.
Выполните этот скрипт у себя (или проанализируйте его здесь), чтобы увидеть разницу в 1000 раз.
1. Подготовка данных (1 млн строк)
-- Создаем таблицу
CREATE TABLE dreams (
id SERIAL PRIMARY KEY,
dream_text TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Генерируем 1 000 000 записей (это займет пару секунд)
-- Используем md5 для генерации случайного "мусора" в тексте
INSERT INTO dreams (dream_text)
SELECT
'I had a dream about ' || md5(random()::text) || ' and then I saw a ' ||
CASE WHEN random() < 0.001 THEN 'unicorn' ELSE 'cat' END -- "Единорог" будет редким словом
FROM generate_series(1, 1000000);
2. Медленный поиск (Seq Scan) Пытаемся найти сны про "единорога" с помощью стандартного LIKE.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM dreams WHERE dream_text LIKE '%unicorn%';
- Результат:
- Seq Scan: База читает 1 млн строк с диска.
- Execution Time: ~200-500 мс (зависит от железа).
- Buffers: Прочитано ~15 000 страниц.
3. Оптимизация (Full Text Search + GIN) LIKE '%...%' (с процентом в начале) нельзя ускорить обычным B-Tree индексом. Нам нужен полнотекстовый поиск.
-- Добавляем колонку tsvector (лексемы слов)
ALTER TABLE dreams ADD COLUMN search_vector tsvector;
-- Заполняем её (превращаем текст в вектор)
UPDATE dreams SET search_vector = to_tsvector('english', dream_text);
-- Создаем GIN индекс
CREATE INDEX idx_dreams_search ON dreams USING GIN(search_vector);
-- АНАЛИЗИРУЕМ (обновляем статистику)
ANALYZE dreams;
4. Быстрый поиск (Bitmap Heap Scan) Используем оператор @@ (match) вместо LIKE.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM dreams
WHERE search_vector @@ to_tsquery('english', 'unicorn');
- Результат:
- Bitmap Heap Scan (через GIN индекс).
- Execution Time: ~0.5 - 2 мс.
- Ускорение: В ~200-500 раз!
==================================================
Мы выходим на уровень System Design. Как Senior-разработчик, вы не обязаны быть DBA, но вы обязаны понимать, как обеспечить отказоустойчивость (HA) и масштабируемость вашей системы, чтобы не совершить архитектурных ошибок еще на этапе проектирования.
"Из коробки" PostgreSQL настроен крайне консервативно, чтобы запуститься даже на микроволновке. Для продакшена эти настройки (в файле postgresql.conf) нужно менять обязательно.
shared_buffers:- Сколько памяти Postgres забирает себе под кэш страниц при старте.
- Рекомендация: 25% - 40% от RAM.
- Почему не 100%? Postgres полагается на кэш файловой системы (OS Cache). Если вы отдадите базе всё, ОС начнет свопить, и производительность умрет.
work_mem:- Лимит памяти на одну операцию (сортировку, хеш-таблицу) внутри запроса.
- Ловушка: Это значение умножается на количество параллельных операций.
- Пример:
work_mem = 50MB. Пришло 100 юзеров, каждый делает сложный запрос с 2 сортировками. Потребление:100 * 2 * 50MB = 10GB. OOM Killer убьет базу. - Рекомендация: Начинайте с 4MB-16MB. Поднимайте только для конкретных сессий/юзеров.
maintenance_work_mem:- Память для служебных операций (VACUUM, CREATE INDEX).
- Рекомендация: Ставьте больше (например, 1GB), чтобы индексы строились быстрее и вакуум работал эффективнее.
random_page_cost:- Стоимость случайного чтения. По умолчанию
4.0(наследие эры HDD, где головке диска нужно время на перемещение). - Для SSD: Обязательно меняйте на
1.1. Это скажет планировщику: "Случайное чтение (Index Scan) почти так же дешево, как последовательное (Seq Scan)". База начнет чаще использовать индексы.
- Стоимость случайного чтения. По умолчанию
effective_io_concurrency:- Сколько параллельных запросов к диску может делать система. Для SSD ставьте 200+.
Чтобы видеть медленные запросы, обязательно включите расширение pg_stat_statements. В postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
После этого вы сможете делать SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC и видеть топ самых тяжелых запросов в системе.
Один сервер — это точка отказа (SPOF). Если он сгорит, бизнес встанет.
Это стандарт индустрии.
- Master (Primary): Принимает запись (
INSERT/UPDATE) и чтение. Генерирует WAL-лог. - Slave (Standby/Replica): Подключается к мастеру, получает поток WAL-записей и применяет их у себя.
- Режим: Replica всегда находится в режиме Read-Only. Вы можете перенаправить на нее аналитические запросы (отчеты), чтобы разгрузить мастер.
- Асинхронная (Default):
- Мастер подтверждает транзакцию (
COMMIT), как только записал её на свой диск. В фоне данные летят на реплику. - Риск: Если мастер сгорит, последние миллисекунды данных могут не успеть долететь до реплики.
- Плюс: Максимальная скорость.
- Мастер подтверждает транзакцию (
- Синхронная (
synchronous_commit = on):- Мастер ждет, пока реплика скажет: "Я получила и записала данные". Только потом отдает "ОК" клиенту.
- Риск: Если реплика упала или сеть лагает, весь мастер встает колом на запись.
- Плюс: RPO = 0 (Zero Data Loss).
Postgres сам не умеет автоматически переключать мастера. Если мастер упал, кто-то должен сказать реплике: "Теперь ты главный". Для этого используют Patroni (на базе etcd/Consul).
- Это "робот-админ", который следит за здоровьем кластера.
- Если мастер исчез, Patroni проводит выборы, назначает новую реплику мастером и переписывает DNS/Config, чтобы приложение подключилось к новому лидеру.
Позволяет реплицировать не весь сервер (байты), а конкретные таблицы (данные).
- Use Case: ETL, переливка данных в аналитическое хранилище, синхронизация между микросервисами (CDC — Change Data Capture).
"Репликация — это не бэкап". Если вы сделаете DROP TABLE на мастере, он мгновенно реплицируется и удалит таблицу на слейве.
- Создает текстовый файл с SQL-командами (
CREATE TABLE... INSERT...). - Плюс: Можно восстановить одну таблицу; можно перенести данные между разными ОС или мажорными версиями Postgres.
- Минус: Очень медленное восстановление (нужно заново прогнать все инсерты и построить индексы). На базах 100GB+ практически неприменимо для Disaster Recovery.
- Копирует файлы базы данных побитово.
- Плюс: Быстрое восстановление (просто скопировал файлы и запустил).
- Инструмент: pgBackRest — золотой стандарт. Умеет делать инкрементальные бэкапы (копировать только то, что изменилось), сжимать данные и лить их в S3.
Машина времени. Позволяет восстановить состояние базы на любую секунду в прошлом.
- Как работает: Берем последний полный физический бэкап (например, ночной) + накатываем поверх него архив WAL-логов до нужной временной метки (14:59:59).
- Сценарий: Разработчик случайно удалил важные данные, но заметил это через час. Откатываемся на час назад.
Что делать, если в одной таблице orders стало 100 миллиардов строк и 10 ТБ данных? Индексы перестают влезать в память, вакуум работает вечность.
Разбиение одной логической таблицы на несколько физических кусков на одном сервере.
- Декларативное партиционирование: Вы говорите базе: "Дели таблицу
ordersпо дате".orders_2023_01orders_2023_02
- Partition Pruning: Если вы делаете запрос
WHERE date = '2023-01-15', планировщик поймет, что нужно читать только таблицуorders_2023_01, игнорируя остальные терабайты данных. - Удобство администрирования: Чтобы удалить старые данные, не нужно делать долгий
DELETE(который плодит мусор). Можно просто сделатьDROP TABLE orders_2020(мгновенно освобождает место).
Разбиение данных по разным физическим серверам. PostgreSQL "из коробки" не умеет делать это прозрачно (как MongoDB).
- Citus: Расширение, превращающее Postgres в распределенную базу данных.
- Application-side sharding: Вы сами в Java-коде решаете: "User ID 1-1000 идут на сервер А, 1001-2000 на сервер Б". Это сложно поддерживать, но дает бесконечную масштабируемость.
Что мы охватили:
- Фундамент: Нормализация, SQL, Constraints.
- Инструменты: Типы данных, JSONB, Агрегация.
- Внутренности: MVCC, WAL, VACUUM, Buffer Cache.
- Аналитика: Индексы (B-Tree, GIN), Оконные функции, EXPLAIN.
- Java + БД: Транзакции, Изоляция, Connection Pooling.
- Expert: Настройка сервера, Репликация, Партиционирование.