add homework

This commit is contained in:
dmezentsev
2026-05-24 11:32:46 +03:00
parent b6e36cafda
commit b654a6eac0
9 changed files with 1951 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
-- Задача 1. Пользователи с заказами
SELECT id AS user_id
FROM dmezentsev.users
WHERE id IN (SELECT user_id
FROM dmezentsev.orders
)
ORDER BY name;
/* - Чем такой подход отличается от варианта с JOIN + DISTINCT по users?
В случае с JOIN + DISTINCT мы бы сперва соединили таблицы, получили юзеров,
которые делали заказы, а потом SELECT DISTINCT отобрали бы id этих юзеров
При подзапросе мы получаем условный список юзеров с заказами, и фильтрацией
отбираем их id в другой таблице
- В каком случае IN (подзапрос) может быть более читаемым, чем JOIN?
В случае, когда нужны поля первой таблицы, а подзапрос только как фильтр.
Меньше строк кода, джойна другой таблицы, подзапрос выигрывает по читаемости
*/
-- Задача 2. Пользователи без заказов (NOT EXISTS)
SELECT id,
name,
email
FROM dmezentsev.users u
WHERE NOT EXISTS (SELECT 1
FROM dmezentsev.orders o
WHERE o.user_id = u.id);
/* - Почему для этой задачи NOT EXISTS предпочтительнее,
чем NOT IN (SELECT user_id ...)?
Подзапрос бы выдал список user_id, по которым надо пройтись и найти соответствие
в другой таблице. EXISTS / NOT EXISTS ищет первое совпадение по user_id и потом
сразу переходит к другому user_id, тем самым сокращая время выполнения запроса
- Как бизнес может использовать список таких пользователей?
Может попробовать замотивировать их к первой покупке выдачей промокода/скидки/
специального предложения
*/
-- Задача 3. Сколько заказов у каждого пользователя (подзапрос в SELECT)
SELECT u.name,
(SELECT count(*)
FROM dmezentsev.orders o
WHERE o.user_id = u.id) AS orders_count
FROM dmezentsev.users u
ORDER BY orders_count DESC, name;
/* - Почему подзапрос в SELECT не может возвращать несколько строк?
Потому что для каждого u.name он возвращает только одну строку - число заказов.
Если попытаться вернуть больше строк, то будет ошибка выполнения запроса
- Как изменится сложность запроса, если пользователей станет, например, 1 млн?
Внешне запрос останется таким же, увеличится лишь время выполнения запроса
*/
-- Задача 4. Сумма выручки по каждому пользователю (подзапрос или CTE)
SELECT u.name,
t.total_revenue
FROM (SELECT o.user_id,
SUM(oi.quantity * oi.price_at_purchase) AS total_revenue
FROM dmezentsev.orders o
JOIN dmezentsev.order_items oi ON oi.order_id = o.id
GROUP BY o.user_id) AS t
JOIN dmezentsev.users u ON t.user_id = u.id
ORDER BY total_revenue DESC;
/* - Как проще читать такой запрос: когда шаг «посчитать выручку по пользователям»
вынесен отдельно, или когда всё написано в одном SELECT?
Подзапросы читаются проще, логически понятны шаги получения нужных строк сперва
в подзапросе, а потом в основном запросе
- Если эту «выручку по пользователям» придётся использовать в другом отчёте,
как избежать дублирования кода?
В таком случае, лучше подзапрос вынести в отдельный CTE, чтобы потом
переиспользовать в коде
*/
-- Задача 5. Топ‑3 товара по выручке (CTE)
WITH products_total AS (
SELECT product_id,
SUM(quantity * price_at_purchase) AS product_revenue
FROM dmezentsev.order_items
GROUP BY product_id
)
SELECT p.name,
p.category,
pt.product_revenue
FROM dmezentsev.products p
JOIN products_total pt ON p.id = pt.product_id
ORDER BY product_revenue DESC
LIMIT 3;
/* - Почему удобно сначала считать агрегаты по товарам,
а потом уже подтягивать имена/категории?
Код становится более читаемым из-за разделения ответственности:
агрегат отдельно в CTE, основной запрос для предоставления всех данных отдельно
- В чём отличие подхода через CTE от простого `GROUP BY` в одном `SELECT`?
GROUP BY сделает запрос сложнее для понимания, в него нужно будет добавлять
поля для агрегации. CTE разделяет всю логику, делая код понятнее, к тому же
CTE можно переиспользовать
*/
-- Задача 6. Активные пользователи с высокой суммой заказов
WITH users_total AS (
SELECT o.user_id,
SUM(quantity * price_at_purchase) AS total_revenue
FROM dmezentsev.orders o
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
GROUP BY o.user_id
)
SELECT u.name,
u.email,
ut.total_revenue
FROM dmezentsev.users u
JOIN users_total ut ON u.id = ut.user_id
WHERE u.is_active
AND total_revenue > 1000;
/* - Как бы ты назвал этот сегмент пользователей на языке продукта
(VIP, high‑spenders и т.п.)?
Low - эконом
Medium - основные
High - премиум
VIP - топ
Допустим, что этот сегмент пользователей был бы Medium
- Что можно делать с таким сегментом: какие типы акций/коммуникаций?
Medium сегмент - это лояльные клиенты, которые регулярно покупают,
но сумма покупок не дотягивает до High
Я бы предложил для такого сегмента введение накопительной системы,
дающей право на получение дополнительных скидок, введение системы
раннего доступа к закрытым распродажам
*/
-- Задача 7. «Подозрительно маленькие» оплаченные заказы
WITH orders_total AS (
SELECT oi.order_id,
SUM(oi.quantity * oi.price_at_purchase) AS order_revenue
FROM dmezentsev.order_items oi
INNER JOIN dmezentsev.orders o ON oi.order_id = o.id
WHERE o.status = 'paid'
GROUP BY oi.order_id
)
SELECT ot.order_id,
o.order_date,
ot.order_revenue
FROM orders_total ot
JOIN dmezentsev.orders o ON ot.order_id = o.id
WHERE ot.order_revenue < 100;
/* - Какие реальные причины могут быть у таких заказов
(ошибки, тесты, злоупотребления промокодами)?
Предположу, что причины могут быть разного вида:
Технические:
- Ошибка округления валют
- Использование нескольких промокодов одновременно
- Двойное списание бонусов
- Ошибки интеграции с платежными системами
Человеческий фактор:
- Ручное редактирование заказа
- На каком шаге выгоднее фильтровать по статусу paid:
внутри подзапроса или снаружи, и почему?
Выгоднее фильровать внутри подзапроса, чтобы в подзапросе группировать
уже отфильтрованные данные. И уже в основном запросе джойнить, а меньше
строк для джойна ускорят запрос
*/
+375
View File
@@ -0,0 +1,375 @@
### Задача 1 — Читаем план
#### Требования:
Выполни этот запрос:
```sql
EXPLAIN
SELECT name, email
FROM users
WHERE id = 1;
```
**1. Какой тип сканирования использует база?**
Index Scan (индексное сканирование)
**2. Что означает строка `Index Cond: (id = 1)`?**
Index Cond (Index Condition) — это условие, которое применяется на уровне индекса (id = 1)
**3. Почему база использует индекс, а не читает всю таблицу?**
База выбирает индекс вместо полного сканирования таблицы, потому что id — это первичный ключ с индексом, что делает Index Scan значительно быстрее и дешевле, чем Seq Scan
### Задача 2 — Seq Scan vs Index Scan
#### Требования:
Выполни оба запроса с EXPLAIN:
```sql
-- Запрос А
EXPLAIN
SELECT * FROM users WHERE id = 2;
-- Запрос Б
EXPLAIN
SELECT * FROM users WHERE age > 25;
```
**1. Почему в первом запросе используется Index Scan, а во втором может быть Seq Scan (если нет индекса на age)?**
- Первый запрос (id = 2) использует Index Scan, потому что на id есть индекс первичного ключа.
- Второй запрос (age > 25) использует Seq Scan, потому что на age нет индекса
### Задача 3 — Замеряем время
#### Требования:
Выполни запрос с EXPLAIN ANALYZE:
```sql
EXPLAIN ANALYZE
SELECT name, price
FROM products
WHERE category = 'Электроника';
```
**1. Запиши Execution Time**
Execution Time: 0.699 ms
**2. Определи тип сканирования (Seq Scan или Index Scan)**
Seq Scan
**3. Сколько строк вернул запрос?**
rows=3
### Задача 4 — До и после индекса
#### Требования:
**Шаг 1:** Выполни запрос без индекса:
```sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE country = 'Россия';
```
Запиши **Execution Time** и тип сканирования.
Execution Time: 76.281 ms
Seq Scan
**Шаг 2:** Создай индекс:
```sql
CREATE INDEX idx_country ON users(country);
```
**Шаг 3:** Снова выполни тот же запрос с EXPLAIN ANALYZE.
**1. Изменился ли тип сканирования?**
Тип сканирования не изменился (слишком мало строк в таблице, чтобы оптимизатор использовал индекс)
**2. Стал ли запрос быстрее? На сколько?**
Execution Time: 0.018 ms
Время выполнения запроса после создания индекса стало гораздо меньше, на ~76 ms
**3. Почему индекс помог?**
Индекс поможет, потому что:
- Уменьшит количество читаемых данных - читать только страницы с нужными значениями
- Использует структуру B-Tree
- Позволяет найти строки без сканирования всей таблицы
### Задача 5 — Анализируем JOIN
#### Требования: выполни запрос:
```sql
EXPLAIN ANALYZE
SELECT u.name, o.order_date, o.status
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.country = 'Россия';
```
**1. Какой тип JOIN использует база (Hash Join, Nested Loop, Merge Join)?**
Hash Join, потому что таблицы маленькие, нет индексов — построить хеш-таблицу и просканировать
правую таблицу дешевле всего.
**2. С какой таблицы база начала чтение?**
orders
**3. Сколько времени занял весь запрос?**
Execution Time: 0.083 ms
### Задача 6 — Почему медленно?
#### Дан запрос:
```sql
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE LOWER(email) = 'alexey@mail.ru';
```
**1. Выполни запрос и посмотри на план**
Seq Scan on users (cost=0.00..1.15 rows=1 width=467) (actual time=0.012..0.012 rows=0 loops=1)
Filter: (lower((email)::text) = 'alexey@mail.ru'::text)
Rows Removed by Filter: 10
Planning Time: 0.429 ms
Execution Time: 0.026 ms
**2. Использует ли база индекс?**
План показывает Seq Scan, а не Index Scan.
**3. Почему НЕТ? (даже если индекс на email есть)**
Применение функции LOWER() к колонке
**4. Как исправить запрос, чтобы индекс заработал?**
- Создать индекс с учетом применения функции LOWER()
```sql
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
```
- Использовать ILIKE
- Хранить нормализованный email
### Задача 7 — Функции убивают индексы
#### Сравни два запроса:
```sql
-- Запрос А
EXPLAIN ANALYZE
SELECT * FROM users WHERE age + 5 > 30;
-- Запрос Б
EXPLAIN ANALYZE
SELECT * FROM users WHERE age > 25;
```
**1. Какой запрос работает быстрее?**
Запрос Б работает быстрее: 0.016 ms против 0.043 ms
**2. Почему первый запрос НЕ может использовать индекс на `age`?**
Потому что к колонке age применена математическая операция (age + 5), индекс не используется
**3. Как правильно переписать запрос А?**
```sql
SELECT * FROM users WHERE age > 25;
```
### Задача 8 — Диагностика медленного запроса
#### Дан запрос:
```sql
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE EXTRACT(YEAR FROM u.created_at) = 2026
GROUP BY u.name;
```
**1. Почему функция `EXTRACT()` — плохая идея?**
Потому что EXTRACT(YEAR FROM created_at) — это функция, применённая к колонке,
индекс на created_at работать не будет.
**2. Как переписать условие WHERE без функции?**
```sql
WHERE u.created_at >= '2026-01-01' AND u.created_at < '2027-01-01'
```
**3. Будет ли использоваться индекс на `created_at` в текущем виде?**
Индекс на created_at НЕ БУДЕТ использоваться
### Задача 9 — SELECT * против конкретных колонок
#### Сравни два запроса:
```sql
-- Запрос А
EXPLAIN ANALYZE
SELECT * FROM products WHERE price > 50000;
-- Запрос Б
EXPLAIN ANALYZE
SELECT name, price FROM products WHERE price > 50000;
```
**1. Какой запрос работает быстрее?**
Запрос Б работает быстрее (0.017 ms против 0.051 ms)
**2. Почему второй может быть эффективнее (даже если тип сканирования одинаковый)?**
Меньше данных нужно прочитать с диска (две колонки против всех колонок таблицы)
**3. В каких ситуациях разница будет особенно заметна?**
- Большое количество строк
- Медленные диски (HDD)
- Высокая нагрузка (тысячи запросов в секунду)
### Задача 10 — Оптимизируем JOIN с условием
#### Дан запрос:
```sql
EXPLAIN ANALYZE
SELECT u.name, p.name AS product_name, oi.quantity
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.country = 'Россия' AND p.category = 'Электроника';
```
**1. Выполни запрос и посмотри на план**
Nested Loop (cost=12.66..41.04 rows=1 width=440) (actual time=34.552..34.556 rows=0 loops=1)
Join Filter: (u.id = o.user_id)
Rows Removed by Join Filter: 5
-> Seq Scan on users u (cost=0.00..1.12 rows=1 width=226) (actual time=0.012..0.015 rows=1 loops=1)
Filter: ((country)::text = 'RU'::text)
Rows Removed by Filter: 9
-> Nested Loop (cost=12.66..39.84 rows=6 width=230) (actual time=34.510..34.536 rows=5 loops=1)
-> Hash Join (cost=12.51..38.61 rows=6 width=230) (actual time=34.094..34.105 rows=5 loops=1)
Hash Cond: (oi.product_id = p.id)
-> Seq Scan on order_items oi (cost=0.00..22.70 rows=1270 width=20) (actual time=33.943..33.945 rows=10 loops=1)
-> Hash (cost=12.50..12.50 rows=1 width=226) (actual time=0.047..0.048 rows=3 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
-> Seq Scan on products p (cost=0.00..12.50 rows=1 width=226) (actual time=0.022..0.027 rows=3 loops=1)
Filter: ((category)::text = 'Электроника'::text)
Rows Removed by Filter: 4
-> Index Scan using orders_pkey on orders o (cost=0.15..0.21 rows=1 width=16) (actual time=0.084..0.084 rows=1 loops=5)
Index Cond: (id = oi.order_id)
Planning Time: 0.278 ms
Execution Time: 34.691 ms
**2. В каком порядке база читает таблицы?**
Порядок чтения таблиц:
1. users
2. products
3. order_items
4. Hash Join
5. orders
6. Nested Loop.
**3. Сколько строк проходит через каждый JOIN?**
1. users (1)
2. products (3)
3. order_items (10)
4. Hash Join (5)
5. orders (5)
6. Nested Loop (0)
**4. Можно ли оптимизировать? (Подсказка: подумай про индексы на `country` и `category`)**
- Создать индексы: idx_users_country, idx_products_category
- Обновить статистику: ANALYZE всех таблиц
### Задача 11 — Найди и исправь
#### Представь, что тебе дали код коллеги:
```sql
SELECT *
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE LOWER(u.name) LIKE '%иван%'
AND EXTRACT(MONTH FROM o.order_date) = 1
AND u.age + 10 > 35;
```
**1. Выполни этот запрос с EXPLAIN ANALYZE**
Nested Loop (cost=0.00..22.35 rows=1 width=549) (actual time=0.034..0.035 rows=0 loops=1)
Join Filter: (u.id = o.user_id)
-> Seq Scan on users u (cost=0.00..1.20 rows=1 width=467) (actual time=0.033..0.033 rows=0 loops=1)
Filter: ((lower((name)::text) ~~ '%иван%'::text) AND ((age + 10) > 35))
Rows Removed by Filter: 10
-> Seq Scan on orders o (cost=0.00..21.10 rows=4 width=82) (never executed)
Filter: (EXTRACT(month FROM order_date) = '1'::numeric)
Planning Time: 0.235 ms
Execution Time: 0.045 ms
**2. Найди **ВСЕ** проблемы, которые мешают оптимизации**
- SELECT * - выбор всех колонок
- LOWER(u.name) — функция на колонке, не используется индекс на name
- LIKE '%иван%' — процент в начале означает "искать подстроку в любой позиции"
- EXTRACT(MONTH FROM o.order_date) = 1 — функция на колонке
- u.age + 10 > 35 — расчеты на колонке
**3. Перепиши запрос правильно**
```sql
SELECT u.id, u.name, u.age, u.email, u.country, o.id AS order_id, o.order_date, o.status
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.name ILIKE '%иван%'
AND o.order_date >= '2025-01-01'
AND o.order_date < '2025-02-01'
AND u.age > 25;
```
**4. Сравни Execution Time до и после**
На малых данных разница незначительна:
0.036 ms против 0.045ms
**5. Объясни, почему твой вариант лучше**
- Перечисление нужных полей в SELECT
- Регистронезависимая фильтрация ILIKE
- Убраны функции и расчеты по колонкам, не позволяющие использовать индексы
Все это в совокупности на больших таблицах ускорит время выполнения запроса
+88
View File
@@ -0,0 +1,88 @@
### Задание 1. Чтение и чтение
#### Требования:
- Используй таблицу `users` из примера (Alice и Bob).
- Сформулируй, что делают две одновременные сессии, каждая выполняет **`SELECT * FROM users WHERE id = 1;`**.
Обе сессии читают данные пользователя Alice (id = 1) из таблицы users. Поскольку это обычные запросы на чтение, они не блокируют друг друга.
**1. Почему оба запроса могут выполниться сразу, без ожидания?**
Эти запросы выполняются параллельно, не дожидаясь друг друга, и мгновенно возвращают результат
### Задание 2. Два `UPDATE` одного пользователя
#### Требования:
- Сценарий: две сессии одновременно пытаются поменять email у Alice (**`id = 1`**).
- Одна меняет на **`alice.new@example.com`**, другая — на **`alice.marketing@example.com`**.
- Опиши, что будет делать вторая сессия, пока первая транзакция не закончилась.
1. Сессия 1 начинает транзакцию и выполняет UPDATE. PostgreSQL захватывает блокировку строки для id = 1.
2. Сессия 2 пытается выполнить свой UPDATE, не может выполнить UPDATE сразу — она должна ждать.
3. Сессия 2 переходит в состояние ожидания, пока сессия 1 не завершит свою транзакцию
4. Сессия 1 завершает транзакцию
5. Сессия 2 выполняет свой UPDATE уже поверх нового состояния строки.
**1. Почему нельзя разрешить обоим UPDATE пройти одновременно?**
Нельзя разрешить обоим UPDATE пройти одновременно, потому что это приведёт к потерянному обновлению, нарушению целостности данных
**2. Какой email в итоге останется у Alice и почему порядок COMMIT важен?**
Итоговый email зависит от порядка COMMIT — побеждает последняя зафиксированная транзакция
Порядок COMMIT определяет, какое значение останется.
### Задание 3. `SELECT ... FOR UPDATE`
#### Требования:
- Сценарий:
В Сессии 1 выполняется **`BEGIN; SELECT * FROM users WHERE id = 2 FOR UPDATE;`** и пользователь Bob «забронирован» для изменений.
- Придумай, что может сделать Сессия 2:
- что будет с `SELECT * FROM users WHERE id = 2;`
Выполняется сразу — возвращает текущие данные Bob, не дожидаясь Сессии 1
- что будет с `UPDATE users SET country = 'FR' WHERE id = 2;`
Сессия 2 ждёт — блокируется до тех пор, пока Сессия 1 не выполнит COMMIT или ROLLBACK
**1. Почему **`SELECT`** без `FOR UPDATE` не блокируется, а `UPDATE` ждёт?**
Чтение (без FOR UPDATE) — не изменяет данные
**2. В каких задачах интернет‑магазина `SELECT ... FOR UPDATE` помогает избежать ошибок?**
- Резервирование товара при оформлении заказа: без FOR UPDATE могло бы случиться, что два покупателя одновременно видят последний товар в наличии, оба оформляют заказ, и товар продаётся дважды
- Применение персональной скидки или купона: иначе юзер мог бы отправить два параллельных запроса и использовать один купон дважды
- Изменение цены товара во время оформления корзины: не допустит, когда юзер положил товар в корзину по одной цене, а к моменту оплаты цена выросла
### Задание 4. Дедлок на двух пользователях
#### Требования:
- Возьми сценарий из теории:
- Сессия 1 сначала обновляет `country` у Alice, потом пытается обновить Bob;
- Сессия 2 сначала обновляет Bob, потом пытается обновить Alice.
**1. Объясни своими словами, почему возникает дедлок и что сделает база с одной из транзакций.**
Каждая транзакция ждёт ресурс, который удерживает другая транзакция. Ни одна из них не может продолжить выполнение.
Когда deadlock обнаружен, база откатывает (ROLLBACK) одну из транзакций (ту, которая выполняется меньше или которая привела к дедлоку), чтобы разрешить конфликт
**2. Как можно переписать код, чтобы дедлок не возникал *(подсказка: порядок обновления строк)*?**
```sql
BEGIN;
UPDATE users SET country = 'CA' WHERE id = 1;
UPDATE users SET country = 'CA' WHERE id = 2;
COMMIT;
```
**3. Почему для интернет‑магазина это важно, если, например, массово меняют country многим пользователям скриптом?**
Для интернет-магазина это критично при массовых обновлениях — без единого порядка тысячи дедлоков парализуют работу системы, вызывая откаты, падение производительности и несогласованные данные.
+307
View File
@@ -0,0 +1,307 @@
### Задача 1 — Когда какой метод?
| **Ситуация** | **Какой JOIN выберет база?** | **Почему?** |
| --- | --- | --- |
| 10 пользователей, 1 млн заказов, есть индекс на orders.user_id | Nested Loop | Маленькая левая таблица (10 строк) + индекс на правой |
| 100 000 пользователей, 100 000 заказов, нет индексов | Hash Join | Обе таблицы средние, нет индексов |
| Обе таблицы отсортированы по ключу, есть индексы | Merge Join | Данные уже отсортированы — можно пройтись за один проход |
| Маленькая таблица (5 строк) соединяется с большой (1 млн строк), нет индекса | Hash Join | Hash Join просканирует большую таблицу 1 раз |
### Задача 2 — Размер таблиц имеет значение
#### Требования:
Создай тестовые данные:
```sql
-- Маленькая таблица (10 строк)
CREATE TABLE small_users AS
SELECT generate_series(1, 10) AS id, 'User ' || generate_series(1, 10) AS name;
-- Большая таблица (100 000 строк)
CREATE TABLE big_orders AS
SELECT
generate_series(1, 100000) AS id,
(random() * 9 + 1)::int AS user_id,
CURRENT_DATE - (random() * 365)::int AS order_date;
```
Теперь выполни:
```sql
-- Вариант 1: Без индекса
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM small_users u
JOIN big_orders o ON u.id = o.user_id
GROUP BY u.name;
-- Вариант 2: С индексом
CREATE INDEX idx_big_orders_user_id ON big_orders(user_id);
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM small_users u
JOIN big_orders o ON u.id = o.user_id
GROUP BY u.name;
```
**1. Какой метод `JOIN` используется в каждом случае?**
- Без индекса — Hash Join (строит хеш по маленькой таблице, сканирует большую)
- С индексом — Nested Loop (для каждого пользователя делает Index Scan в таблице заказов)
**2. Почему с индексом база может выбрать `Nested Loop`?**
Потому что левая таблица (small_users) очень маленькая (10 строк), а индекс на big_orders.user_id позволяет мгновенно находить заказы каждого пользователя
**3. Насколько быстрее стало с индексом?**
С индексом в 10-20 раз быстрее (~2.5 мс против ~45 мс). Index Scan читает только нужные строки (не все 100 000 заказов)
### Задача 3 — Влияние сортировки
#### Требования:
Выполни три варианта:
```sql
-- Вариант 1: Без ORDER BY
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id;
-- Вариант 2: С ORDER BY по ключу соединения
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.user_id;
-- Вариант 3: С ORDER BY по другому полю
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id
ORDER BY o.order_date;
```
**1. Изменился ли тип `JOIN` во втором варианте?**
Тип JOIN НЕ ИЗМЕНИЛСЯ — во всех вариантах используется Hash Join
**2. Появился ли `Merge Join`?**
Merge Join НЕ ПОЯВИЛСЯ — для Merge Join нужен индекс на orders.user_id, чтобы заказы были отсортированы по ключу соединения.
**3. Почему `ORDER BY` может повлиять на выбор `JOIN`?**
ORDER BY влияет на выбор JOIN ТОЛЬКО при наличии индексов на ключах соединения. Если бы индекс на orders.user_id существовал, ORDER BY по ключу соединения привёл бы к появлению Merge Join, который использует отсортированные данные
### Задача 4 — Найди узкое место
#### Требования:
Дан медленный запрос:
```sql
SELECT
u.name,
p.name AS product_name,
oi.quantity,
o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.country = 'Россия';
```
**1. Выполни с `EXPLAIN ANALYZE`**
**2. Посмотри, какие типы `JOIN` используются**
Все JOIN — Hash Join
**3. Есть ли `Seq Scan`? Если да — где?**
Да, есть Seq Scan на всех таблицах (таблицы очень маленькие (7-10 строк), и PostgreSQL считает, что читать всю таблицу последовательно дешевле, чем использовать индекс)
**4. Предложи, какие индексы нужно создать**
```sql
CREATE INDEX idx_users_country ON users(country);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
```
- для фильтрации по стране
- для ускорения поиска заказов конкретного пользователя
- для ускорения поиска позиций заказа
- для ускорения поиска товара в позициях
**5. Создай индексы и проверь, изменились ли типы `JOIN`**
После создания индексов (на больших данных) типы JOIN изменятся с Hash Join на Nested Loop, а сканирование users станет Index Scan.
### Задача 5 — Многоступенчатый JOIN
```sql
EXPLAIN ANALYZE
SELECT
u.name,
COUNT(DISTINCT o.id) AS orders_count,
COUNT(DISTINCT oi.product_id) AS products_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE u.country = 'Россия'
GROUP BY u.name;
```
**1. Посмотри на план выполнения**
**2. Какие методы `JOIN` используются на каждом уровне?**
План выполнения показывает: оба JOIN — Hash Left Join, все таблицы читаются через Seq Scan (так как данные маленькие).
**3. Почему для первого `JOIN` один метод, а для второго — другой?**
Оба JOIN используют Hash Left Join, потому что нет индексов на внешних ключах, а таблицы маленькие, чтобы построить хеш-таблицы в памяти.
**4. Сколько строк проходит через каждый `JOIN`?**
1. После фильтра country = 'RU': users — 1 строка
2. После первого LEFT JOIN (users и orders) — 1 строка
3. После второго LEFT JOIN (orders и order_items) — 1 строка (у заказа 1 позиция)
4. Результат: 1 строка с агрегатами (orders_count=1, products_count=1)
### Задача 6 — `LEFT JOIN` vs `INNER JOIN`
#### Требования:
Сравни два запроса:
```sql
-- Вариант A: INNER JOIN
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Вариант B: LEFT JOIN
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
```
**1. Одинаковый ли тип физического JOIN используется?**
Нет, типы JOIN разные: INNER JOIN использует Hash Join, LEFT JOIN использует Hash Left Join
**2. Есть ли разница в Execution Time?**
Небольшая, LEFT JOIN медленнее на ~0.003 мс
**3. Почему LEFT JOIN может быть медленнее?**
- Должен сохранять ВСЕ строки из левой таблицы (даже без соответствия) и добавлять NULL
- Возвращает больше строк
### Задача 7 — Порядок таблиц в JOIN
#### Требования:
Сравни:
```sql
-- Вариант 1: users JOIN orders
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id;
-- Вариант 2: orders JOIN users
EXPLAIN ANALYZE
SELECT u.name, o.order_date
FROM orders o
JOIN users u ON o.user_id = u.id;
```
**1. Изменился ли тип `JOIN`?**
Тип JOIN НЕ ИЗМЕНИЛСЯ — в обоих случаях используется Hash Join
**2. Изменился ли порядок чтения таблиц в плане?**
Порядок чтения таблиц в плане НЕ ИЗМЕНИЛСЯ — оптимизатор в обоих случаях начал с orders (меньшей таблицы)
**3. Одинаковое ли `Execution Time`?**
Execution Time ОДИНАКОВОЕ, потому что планы идентичны
**4. Почему оптимизатор может выбрать разный порядок?**
Оптимизатор может выбрать разный порядок на основе COST: учитывает размер таблиц, фильтрацию, наличие индексов и тип JOIN
### Задача 8 — Спровоцируй Merge Join
#### Требования:
Напиши запрос, который **гарантированно** использует Merge Join.
**Подсказки:**
- Используй ORDER BY
- Убедись, что есть индексы на ключи соединения
- Обе таблицы должны быть достаточно большими
Создал таблицы на 100 000 + строк с помощью ИИ, не стал сюда копировать
```sql
EXPLAIN
SELECT u.id, u.name, o.id, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.user_id;
```
**Фишка в том, что Merge Join будет использоваться, если:**
- Индекс на users.id (первичный ключ — есть)
- Индекс на orders.user_id (нужно создать)
- ORDER BY u.id, o.user_id — совпадает с ключами JOIN
- Достаточно большой размер таблиц
### Задача 9 — Когда Nested Loop лучше Hash Join?
#### Требования:
Придумай ситуацию (размер таблиц, наличие индексов, условия), когда:
**1. Nested Loop будет быстрее Hash Join**
- Левая таблица очень маленькая (1-100 строк), Nested Loop сделает мало итераций
- Есть индекс на правой таблице по ключу JOIN
- Правая таблица очень большая (миллионы строк)
**2. Hash Join будет быстрее Nested Loop**
- Обе таблицы большие (тысячи+ строк)
- Нет индекса на правой таблице
- Достаточно памяти для хеш-таблицы
**3. Создай тестовые данные и проверь свою гипотезу!**
Создавал с помощью ИИ таблицы и принудительно отключал у оптимизатора варианты физического джойна (то есть сперва обе таблицы через Nested Loop, потом через Hash Join, затем так же другие две таблицы), вывел результаты в табличку:
| Сценарий | Тип JOIN | Время |
|----------|----------|-------|
| маленькая + большая + индекс | Nested Loop | ~180 ms |
| маленькая + большая + индекс | Hash Join | ~400 ms |
| большая + большая + нет индекса | Hash Join | ~85 ms |
| большая + большая + нет индекса | Nested Loop | ~12500 ms |
Гипотезы подтвердились: Nested Loop хорош для 'маленькая + большая + индекс', Hash Join хорош для 'большая + большая + нет индекса'. Но лучше всё-таки не отключать оптимизатору вариант выбора физического джойна)))
+251
View File
@@ -0,0 +1,251 @@
### Задача 1 — Одинаковые ли запросы?
#### Требования:
- Напиши, будут ли эти два запроса давать одинаковый результат? Объясни почему.
```sql
-- Вариант А
SELECT name, age
FROM users
WHERE country = 'RU' AND age > 25;
-- Вариант Б
SELECT name, age
FROM users
WHERE age > 25 AND country = 'RU';
```
Оба запроса дадут абсолютно одинаковый результат. В обоих случаях будут выбраны те пользователи, у которых одновременно country = 'RU' И age > 25
**1. Какой из них будет работать быстрее?**
Они будут работать с одинаковой скоростью — оптимизатор запросов сам анализирует условия и выбирает оптимальный порядок выполнения, игнорируя порядок в тексте запроса.
### Задача 2 — JOIN в разном порядке
#### Требования:
Напиши два варианта запроса: сначала пользователи JOIN заказы, потом наоборот.
**1. Будут ли результаты одинаковыми?**
Если бы использовался INNER JOIN: результаты были бы идентичными
**2. Твой вариант 1**
```sql
SELECT u.id, u.name, o.id AS order_id, o.order_date, o.status
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id;
```
**3. Твой вариант 2**
```sql
SELECT u.id, u.name, o.id AS order_id, o.order_date, o.status
FROM dmezentsev.orders o
LEFT JOIN dmezentsev.users u ON o.user_id = u.id;
```
**4. Какой таблицей оптимизатор начнёт чаще всего? Почему?**
Оптимизатор PostgreSQL начинает с меньшей таблицы, потому что это снижает стоимость JOIN-операции
### Задача 3 — Первое знакомство с EXPLAIN
#### Требования:
Выполни команду:
```sql
EXPLAIN ANALYZE
SELECT name, email
FROM users
WHERE country = 'RU';
```
**1. Какой тип сканирования использует база (Seq Scan, Index Scan)?**
Seq Scan (последовательное сканирование) — база будет читать всю таблицу users подряд, одну строку за другой, проверяя условие country = 'RU'.
**2. Сколько строк она прочитала?**
База прочитала все 10 строк таблицы, проверила каждую, но вернула только 1.
**3. Сколько времени это заняло?**
0.025–0.050 ms
### Задача 4 — Сравни два плана
#### Требования:
Выполни оба запроса с EXPLAIN ANALYZE:
```sql
-- Запрос А
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE country = 'RU';
-- Запрос Б
EXPLAIN ANALYZE
SELECT name, email
FROM users
WHERE country = 'RU';
```
**1. Почему второй запрос может быть быстрее первого?**
Второй запрос (SELECT name, email) может быть быстрее первого (SELECT *), потому что читает меньше данных (только две колонки вместо всех).
### Задача 5 — JOIN с EXPLAIN
#### Требования:
Посмотри план выполнения JOIN:
```sql
EXPLAIN ANALYZE
SELECT u.name, o.id, o.order_date
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.country = 'RU';
```
**1. С какой таблицы начала читать база? Почему именно с неё?**
База начнёт читать с таблицы users, потому что у неё есть фильтр WHERE country = 'RU', который сильно уменьшает количество строк
### Задача 6 — Найди узкое место
#### Требования:
У тебя есть запрос:
```sql
SELECT u.name, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.country = 'Россия'
AND LOWER(u.name) LIKE '%алекс%'
GROUP BY u.name;
```
**1. Найди как минимум 2 проблемы, которые замедляют запрос. Как их исправить?**
- `LOWER(u.name)` — функция на колонке, которая также блокирует использование индекса и требует вычислений для каждой строки
- `LIKE '%алекс%'` не использует индекс, требуя полного сканирования
**Исправить:**
- использовать `ILIKE` без `LOWER`
- хранить уже нормализованные данные
### Задача 7 — Оптимизируй JOIN
#### Требования:
Дан запрос:
```sql
SELECT u.name, p.name AS product_name, oi.quantity
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.country = 'Россия';
```
**1. В каком порядке база будет читать таблицы? Почему? Можно ли как-то ускорить этот запрос?**
База начнёт читать с таблицы users, применяя фильтр country = 'Россия' ('RU'), потому что это сильно уменьшает количество строк.
**Ускорить можно:**
- создать индексы
- использовать материализованные представления
### Задача 8 — Сравни подходы
#### Требования:
Какой запрос будет работать быстрее и почему?
```sql
-- Вариант А
SELECT name, price
FROM products
WHERE category = 'Электроника'
AND price > 50000;
-- Вариант Б
SELECT *
FROM products
WHERE category = 'Электроника'
AND price > 50000;
```
**1. Запусти оба с EXPLAIN ANALYZE и сравни время выполнения.**
Вариант А будет быстрее, меньше данных нужно прочитать
- `SELECT name, price` читает 2 колонки (name VARCHAR(100), price DECIMAL)
- `SELECT *` читает все 5 колонок (id, name, category, price, in_stock)
### Задача 9 — Найди и исправь
#### Требования:
Представь, что тебе дали код коллеги:
```sql
SELECT *
FROM users
WHERE EXTRACT(YEAR FROM created_at) = 2026
AND LOWER(country) = 'россия'
AND age + 10 > 35;
```
**1. Найди ВСЕ проблемы в этом запросе**
- `EXTRACT(YEAR FROM created_at)` делает невозможным использование индекса
- `LOWER(country)` — функция на колонке, блокирующая использование индекса
- Вычисление в условии `age + 10 > 35`
**2. Перепиши его правильно**
```sql
SELECT id, name, email, country, age, created_at, is_active
FROM dmezentsev.users
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
AND country = 'RU'
AND age > 25;
```
**3. Объясни, почему твой вариант лучше**
Он лучше, потому что может использовать индексы, не выполняет лишних вычислений, возвращает только нужные колонки и фактически найдёт данные (с правильным значением страны).
### Задача 10 — Исследуй реальные данные
#### Требования:
Создай таблицу с 10 000 записей пользователей (можно сгенерировать через функцию `generate_series`).
Сравни время выполнения:
```sql
-- Запрос 1
SELECT * FROM users WHERE age > 25;
-- Запрос 2
SELECT name, email FROM users WHERE age > 25;
```
**1. Используй EXPLAIN ANALYZE и посмотри, насколько второй запрос быстрее первого на большом объёме данных.**
Второй запрос (SELECT name, email) будет быстрее на 30-50% на 10 000 записях, потому что читает меньше данных (только 2 колонки вместо всех), ну и поэтому передаёт меньше байт по сети
+328
View File
@@ -0,0 +1,328 @@
### Задача 1 — Запрос с группировкой
```sql
SELECT country, COUNT(*) AS users_count
FROM users
WHERE is_active = true
GROUP BY country
HAVING COUNT(*) > 1
ORDER BY users_count DESC;
```
**1. Когда применится `WHERE` — до или после `GROUP BY`?**
WHERE применяется ДО GROUP BY
**2. Когда применится `HAVING` — до или после `GROUP BY`?**
HAVING применяется ПОСЛЕ GROUP BY
**3. Можно ли использовать алиас `users_count` в `HAVING`?**
HAVING выполняется до SELECT. Алиас users_count создаётся только на этапе SELECT. Поэтому на момент выполнения HAVING алиас ещё не существует.
**4. Можно ли использовать алиас `users_count` в `ORDER BY`?**
ORDER BY выполняется после SELECT. На этом этапе алиас users_count уже создан и доступен для использования.
### Задача 2 — JOIN с фильтрацией
```sql
SELECT u.name, o.order_date, o.status
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.country = 'Россия' AND o.status = 'completed'
ORDER BY o.order_date DESC;
```
**1. Опиши порядок выполнения своими словами (как будто объясняешь другу-новичку).**
Порядок выполнения:
База сначала фильтрует пользователей по стране (WHERE u.country = 'Россия'), оставляя только пользователей из России. Затем соединяет (JOIN) этих пользователей с таблицей заказов, находя их заказы. После этого фильтрует заказы по статусу (WHERE o.status = 'completed'). Затем выбирает нужные колонки (SELECT): имя, дату заказа и статус заказа, и сортирует (ORDER BY).
### Задача 3 — Найди ошибку
Дан запрос:
```sql
SELECT country, AVG(age) AS avg_age
FROM users
GROUP BY country
HAVING country = 'Россия';
```
**1. Запрос работает, но написан **неэффективно**. Почему?**
HAVING используется для фильтрации по колонке country, которая не является агрегированной. Для фильтрации по обычным колонкам (не результатам агрегации) предназначен WHERE.
**2. Перепиши его правильно, используя WHERE.**
```sql
SELECT country, AVG(age) AS avg_age
FROM users
WHERE country = 'Россия'
GROUP BY country;
```
**3. Объясни, почему твой вариант быстрее.**
Потому что WHERE применяется до группировки: база сначала отфильтровывает только нужные строки (страна 'RU'), затем группирует и вычисляет средний возраст только для них. Это уменьшает объём данных для группировки, база может использовать индекс на country и не делает лишних вычислений
### Задача 4 — Исправь запрос
```sql
SELECT category, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING category = 'Электроника' AND AVG(price) > 20000;
```
**1. Перепиши запрос так, чтобы часть условий была в `WHERE`.**
```sql
SELECT category, AVG(price) AS avg_price
FROM products
WHERE category = 'Электроника'
GROUP BY category
HAVING AVG(price) > 20000;
```
**2. Объясни, почему это быстрее.**
Это быстрее, потому что WHERE отсекает ненужные категории до группировки
**3. Нарисуй схему: сколько строк будет обработано в каждом случае.**
Исходный запрос обрабатывает 4 группы (7 строк) — потом 4 вычисления средней цены по каждой категории.
Исправленный запрос фильтрует по условию — обрабатывает только 1 группу (3 строки) — 1 вычисление средней цены
### Задача 5 — Исправь все ошибки
```sql
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE users_count > 1
GROUP BY country
ORDER BY country;
```
**1. Найди ошибку и исправь запрос.**
В запросе используется WHERE users_count > 1, но алиас users_count создаётся в SELECT, который выполняется после WHERE. Кроме того, WHERE не может фильтровать по агрегатным функциям COUNT(*)
```sql
SELECT country, COUNT(*) AS users_count
FROM users
GROUP BY country
HAVING COUNT(*) > 1
ORDER BY country;
```
### Задача 6 — Полный цикл выполнения
```sql
SELECT
u.country,
COUNT(o.id) AS orders_count,
AVG(o.id) AS avg_order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.is_active = true
GROUP BY u.country
HAVING COUNT(o.id) > 1
ORDER BY orders_count DESC
LIMIT 3;
```
**1. Опиши ПОЛНЫЙ порядок выполнения (все 7 шагов) и объясни, что происходит на каждом.**
- **FROM** — читаем таблицу users и готовим LEFT JOIN с orders
- **JOIN** — соединяем с заказами, у пользователей без заказов — NULL
- **WHERE** — фильтруем активных пользователей (is_active = true)
- **GROUP BY** — группируем по стране
- **HAVING** — оставляем только группы с количеством заказов > 1
- **SELECT** — вычисляем COUNT(o.id) и AVG(o.id) для каждой группы
- **ORDER BY** — сортируем по убыванию orders_count
- **LIMIT** — берём первые 3 строки
### Задача 7 — Оптимизация
Сравни два запроса:
```sql
-- Запрос А
SELECT category, COUNT(*)
FROM products
GROUP BY category
HAVING category = 'Электроника';
-- Запрос Б
SELECT category, COUNT(*)
FROM products
WHERE category = 'Электроника'
GROUP BY category;
```
**1. Какой запрос быстрее? Почему?**
Запрос Б быстрее. Причина: фильтр WHERE category = 'Электроника' применяется до группировки
**2. Нарисуй таблицу: на каком шаге сколько строк обрабатывается в каждом запросе.**
| Показатель | Запрос А | Запрос Б |
|------------|----------|----------|
| Прочитано строк | 7 | 7 |
| Создано групп | 4 | 1 |
| Вычислено COUNT | 4 раза | 1 раз |
**3. Выполни оба с `EXPLAIN ANALYZE` и сравни `Execution Time`.**
У запроса А Rows Removed by Filter: 3 после группировки, у запроса Б фильтр применяется при сканировании таблицы
### Задача 8 — Многоэтапная фильтрация
```sql
SELECT
u.name,
COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.country = 'Россия'
AND u.age > 25
AND o.status = 'completed'
GROUP BY u.name
HAVING COUNT(o.id) >= 2
ORDER BY orders_count DESC;
```
**1. Когда применяются условия `WHERE`?**
Условия WHERE применяются до группировки — фильтруют отдельные строки (пользователей и заказы) перед тем, как они попадут в GROUP BY
**2. Когда применяется условие `HAVING`?**
Условие HAVING применяется после группировки — фильтрует уже сформированные группы (пользователей) по результату агрегации (COUNT(o.id)).
**3. В чём разница между фильтрацией по `o.status` в `WHERE` и по `COUNT(o.id)` в `HAVING`?**
WHERE o.status = 'completed' фильтрует отдельные заказы до группировки и (из-за LEFT JOIN) отсекает пользователей без подходящих заказов. HAVING COUNT(o.id) >= 2 фильтрует пользователей после группировки по количеству их заказов.
### Задача 9 — JOIN и ORDER BY
```sql
SELECT
u.name,
p.name AS product_name,
p.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.country = 'Россия'
ORDER BY p.price DESC
LIMIT 5;
```
**1. На каком этапе применяется `ORDER BY`?**
ORDER BY применяется на предпоследнем этапе — после всех JOIN, WHERE и SELECT, но до LIMIT.
**2. Почему `LIMIT` идёт после `ORDER BY`, а не до?**
LIMIT идёт после ORDER BY, потому что должен взять первые N строк уже отсортированного результата.
**3. Что будет, если поменять их местами (технически невозможно, но подумай концептуально)?**
Концептуально при LIMIT, а потом ORDER BY сначала взяли бы случайные строки (в физическом порядке), а потом их отсортировали бы
### Задача 10 — Напиши запрос с нуля
Нужно получить:
- Список категорий товаров
- Количество товаров в каждой категории
- Среднюю цену товаров в категории
- Только те категории, где средняя цена > 15000
- Только те категории, где товаров >= 2
- Отсортировать по средней цене (от большей к меньшей)
- Взять только топ-3
**1. Напиши запрос**
```sql
SELECT
category,
COUNT(*) AS products_count,
AVG(price) AS avg_price
FROM dmezentsev.products
GROUP BY category
HAVING AVG(price) > 15000
AND COUNT(*) >= 2
ORDER BY avg_price DESC
LIMIT 3;
```
**2. Опиши порядок выполнения**
- **FROM** — читаем таблицу products (7 строк)
- **GROUP BY** — группируем по category (4 группы)
- **HAVING** — применяем оба условия (фильтруем группы)
- **SELECT** — вычисляем COUNT(*) и AVG(price)
- **ORDER BY** — сортируем по avg_price DESC
- **LIMIT** — берём первые 3 строки
**3. Объясни, почему использовал `WHERE` или `HAVING` для каждого условия**
В этом запросе нет условий WHERE, потому что нет фильтрации по отдельным строкам. Оба условия работают с результатами агрегации (AVG(price), COUNT(*)), поэтому фильтруются уже в HAVING
**4. Выполни с `EXPLAIN ANALYZE` и посмотри план**
```
Seq Scan on products -- Читаем все 7 строк таблицы
HashAggregate -- Группируем по category (4 группы)
Filter: (avg(price) > 15000 AND count(*) >= 2) -- Применяем HAVING
Rows Removed by Filter: 3 -- 3 группы отброшены
Sort -- Сортируем результат по avg_price DESC
Limit -- Ограничиваем 3 строками
```
### Задача 11 — Подзапросы и порядок
```sql
SELECT
u.name,
u.age,
(SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.id) AS orders_count
FROM users u
WHERE u.country = 'Россия'
ORDER BY orders_count DESC;
```
**1. Когда выполняется подзапрос в `SELECT`?**
Подзапрос в SELECT выполняется для каждой строки после WHERE u.country = 'Россия', но до ORDER BY
**2. Можно ли использовать алиас `orders_count` в `WHERE`?**
Алиас orders_count нельзя использовать в WHERE, потому что WHERE выполняется до SELECT
**3. Можно ли использовать его в `ORDER BY`?**
Алиас orders_count можно использовать в ORDER BY, так как ORDER BY выполняется после SELECT.
**4. Перепиши этот запрос через `JOIN`. Какой вариант быстрее? Проверь с `EXPLAIN ANALYZE`!**
```sql
SELECT u.name, u.age, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.country = 'RU'
GROUP BY u.id, u.name, u.age
ORDER BY orders_count DESC;
```
На маленьких данных разница незаметна. Предположу, что на больших таблицах JOIN быстрее, потому что подзапрос выполняется для каждого пользователя, а JOIN — за один проход.
+68
View File
@@ -0,0 +1,68 @@
### Задание 1. Оформление заказа
#### Требования:
- Используй таблицы пет‑проекта: `users`, `orders`, `order_items`, `products`.
- Заказ: пользователь покупает два разных товара.
- Сформулируй транзакцию, которая за один раз:
- создаёт запись в `orders`;
- добавляет все позиции в `order_items`;
- уменьшает остаток товаров в `products`.
**1. Что в данных пойдёт не так, если часть шагов выполнится, а часть нет?**
- Создан заказ, но не добавлены товары: заказ без позиций. Пользователь думает, что оформил заказ, а товары не зарезервированы.
- Добавлены товары в order_items, но не создан заказ: записи о товарах висят без привязки к заказу
**2. Почему оформление заказа — типичный пример «либо всё, либо ничего»?**
Оформление заказа требует атомарности, потому что заказ, товары и складские остатки должны оставаться согласованными — иначе бизнес-логика нарушается (деньги списаны, а товара нет, или товар уехал, а заказ не оформлен).
### Задание 2. Ошибка посередине
#### Требования:
- Сценарий:
- заказ создали;
- одну позицию заказа добавили;
- на второй позиции выяснилось, что товара нет на складе.
- Сформулируй, что должна сделать транзакция в такой ситуации (что должно сохраниться, а что откатиться).
При оформлении заказа из двух позиций, когда на второй позиции выясняется отсутствие товара на складе, транзакция должна полностью откатить все изменения (выполнить ROLLBACK).
**1. Какие «битые» заказы можно получить без отката (`ROLLBACK`)?**
- Заказ без позиций — запись в orders есть, но в order_items пусто.
- Заказ с частичным наполнением — первая позиция добавлена, вторая нет. Заказ существует, но неполный.
- Заказ с некорректным статусом — статус created, но часть операций не выполнена.
- Заказ с нарушенной суммой — если считается общая сумма заказа по order_items, она не будет соответствовать ожидаемой (вторая позиция отсутствует, но пользователь мог уже оплатить полную стоимость).
**2. Как такие ошибки повлияют на отчёты и витрины по выручке?**
- Завышение количества заказов
- Искажение среднего чека
- Неверный расчет выручки
### Задание 3. Один `UPDATE` как транзакция
#### Требования:
- Возьми простое действие в магазине:
- смена `email` пользователя в `users`;
- изменение цены товара в `products`.
- Объясни, почему даже такое действие должно выполняться как маленькая транзакция.
Даже одиночный UPDATE выполняется как атомарная транзакция, что гарантирует «либо всё, либо ничего»
**1. Что может случиться, если во время изменения цены пропадёт соединение с базой?**
- Соединение потеряно до начала выполнения:
Команда не дошла до сервера — изменения не произошли.
- Соединение потеряно в процессе выполнения:
Транзакция автоматически откатывается (ROLLBACK).
- Соединение потеряно после успешного выполнения:
Транзакция уже зафиксирована (COMMIT выполнен).
**2. Почему хорошо, что база сама откатывает незавершённые изменения?**
Это хорошо, потому что защищает от повреждения данных, упрощает разработку и гарантирует соблюдение ограничений целостности без ручного вмешательства.
+130
View File
@@ -0,0 +1,130 @@
-- Задание 1. Витрина выручки по категориям
/* Требования:
- Используй таблицы: `products`, `orders`, `order_items`.
- Учитывай только оплаченные заказы (`status = 'paid'`).
- Создай `MATERIALIZED VIEW` `mv_category_revenue` со столбцами:
- `category`
- `total_revenue` — суммарная выручка по категории (`quantity * price_at_purchase`)
- Отсортируй результат по `total_revenue` по убыванию при выборке.
*/
CREATE MATERIALIZED VIEW mv_category_revenue AS
SELECT p.category,
SUM(oi.quantity * oi.price_at_purchase) AS total_revenue
FROM dmezentsev.products p
JOIN dmezentsev.orders_items oi ON p.id = oi.product_id
JOIN dmezentsev.orders o ON oi.order_id = o.id
WHERE o.status = 'paid'
ORDER BY total_revenue DESC
/* 1. Чем удобно иметь готовую витрину по категориям, вместо того чтобы каждый раз писать JOIN по трём таблицам?
- Производительность — данные уже агрегированы и лежат в одной материализованной таблице. Вместо сканирования
трёх таблиц и группировки, запрос читает готовую таблицу.
- Простота запросов — аналитик пишет SELECT * FROM mv_category_revenue, не вникая в схему и условия фильтрации.
*/
-- Задание 2. Витрина активности пользователей
/* Требования:
- Используй таблицы: `users`, `orders`.
- Считай только оплаченные заказы.
- Создай MATERIALIZED VIEW **`mv_user_activity`** со столбцами:
- `user_id`
- `user_name`
- `orders_count` — сколько оплаченных заказов сделал пользователь
- В выборке покажи только тех, у кого `orders_count > 0`, отсортируй по `orders_count` по убыванию.
*/
CREATE MATERIALIZED VIEW mv_user_activity AS
SELECT
u.user_id,
u.user_name,
COUNT(o.order_id) AS orders_count
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.status = 'paid'
GROUP BY u.user_id, u.user_name
HAVING COUNT(o.order_id) > 0
ORDER BY orders_count DESC;
/* 1. Как по такой витрине найти «самых лояльных» клиентов?
SELECT * FROM mv_user_activity LIMIT 3;
(Предположил ради примера, что самые лояльные - это топ-3 клиента)
2. Что будет, если считать активность не по всем заказам, а только за последние 30 дней
— как изменится смысл витрины?
Смысл витрины изменится на отслеживание текущей (недавней) активности, а не за весь период времени в данных
*/
-- Задание 3. Витрина выручки по дням
/* Требования:
- Используй таблицы: `orders`, `order_items`.
- Учитывай только оплаченные заказы.
- Создай `MATERIALIZED VIEW` **`mv_daily_revenue`** со столбцами:
- `order_date::date` как `sales_date`
- `daily_revenue` — суммарная выручка за день
- При выборке отсортируй по `sales_date` по возрастанию.
*/
CREATE MATERIALIZED VIEW mv_daily_revenue AS
SELECT
o.order_date::date AS sales_date,
SUM(oi.quantity * oi.price_at_purchase) AS daily_revenue
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.status = 'paid'
GROUP BY o.order_date::date
ORDER BY sales_date ASC;
/* 1. Как по такой витрине заметить «просадки» и «пики» продаж?
- Построить линейный график зависимости daily_revenue от sales_date
- Использовать оконные функции для сравнения со скользящим средним (например, за 7 дней)
- Вычислить оконной функцией LAG и разницу с предыдущим днём, чтобы увидеть изменения.
2. Что быстрее: считать выручку «на лету» из сырых таблиц или читать из `mv_daily_revenue` ? Почему?
Чтение из mv_daily_revenue быстрее, потому что:
- В mv_daily_revenue меньше строк, так как данные агрегированы. Сырые таблицы могут содержать
миллионы записей — каждый день приходится заново агрегировать все данные.
*/
-- Задание 4. Обновление витрин
/* Требования:
- Для любой одной из созданных витрин (на выбор ученика) написать команду обновления с `REFRESH MATERIALIZED VIEW ...`.
*/
REFRESH MATERIALIZED VIEW mv_daily_revenue;
/* - Придумать расписание обновления витрины для двух сценариев:
- интернет‑магазин с несколькими заказами в день;
- крупный магазин с тысячами заказов в час.
Интернет‑магазин с несколькими заказами в день — 1 раз в сутки, ночью
За день накапливается мало новых данных, достаточно обновляться раз в сутки
Крупный магазин с тысячами заказов в час — каждые 30 минут
Обновление за 30 минут успевает до следующего цикла
1. Чем грозит слишком редкий `REFRESH` для отчётов (какие ошибки могут появиться в анализе)?
- Устаревшие данные — угроза применения ошибочных мер.
- Пропуск аномалий — если витрина обновляется редко, аномалии можно не заметить до следующего REFRESH
2. Почему нельзя обновлять витрины «каждую секунду», даже если хочется всегда свежие данные?
- Ресурсы БД — каждый REFRESH блокирует чтение и перестраивает всю витрину заново
- Проблемы с блокировками — если кто-то читает витрину во время REFRESH, запрос встанет в очередь или получит ошибку.
*/
@@ -0,0 +1,209 @@
-- Задача 1. Нумерация заказов пользователя
SELECT u.id AS user_id,
u.name AS user_name,
o.id AS order_id,
o.order_date,
ROW_NUMBER() OVER (
PARTITION BY u.id
ORDER BY o.order_date
) AS order_number_for_user
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id;
/* - В чём отличие такой нумерации от простой сортировки 
ORDER BY order_date без оконной функции?
В случае с оконной функцией идет сортировка по order_date в рамках одного окна
(по пользователю), и каждому заказу пользователя присваивается свой порядковый
номер. При простой сортировке без оконной функции по дате сортировались бы
все заказы
*/
-- Задача 2. Накопительная выручка по пользователю
WITH order_totals AS (
SELECT o.user_id,
u.name AS user_name,
o.id AS order_id,
o.order_date,
SUM(oi.quantity * oi.price_at_purchase) AS order_revenue
FROM dmezentsev.orders o
JOIN dmezentsev.users u ON o.user_id = u.id
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
GROUP BY o.user_id, u.name, o.id, o.order_date
)
SELECT user_id,
user_name,
order_id,
order_date,
order_revenue,
SUM(order_revenue) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_revenue_for_user
FROM order_totals;
/* - Почему для накопительной суммы важно задавать рамку окна
(от «самого первого» до «текущего»), а не оставлять значения по умолчанию?
По умолчанию RANGE BETWEEN ... Если у пользователя два заказа
в один и тот же день, то накопительная сумма посчитается так:
1 января 100 руб Накопительная сумма: 300
1 января 200 руб Накопительная сумма: 300
Важно задавать явно ROWS BETWEEN..., чтобы накопительная сумма считалась
правильно:
1 января 100 руб Накопительная сумма: 100
1 января 200 руб Накопительная сумма: 300
- Как по такой таблице понять, что пользователь начал тратить больше?
Если накопительная сумма растет, то это указывает на то, что
пользователь с течением времени тратит больше
*/
-- Задача 3. Рейтинг пользователей по выручке
WITH user_revenue AS (
SELECT u.id AS user_id,
u.name AS user_name,
sum(oi.quantity * oi.price_at_purchase) AS total_revenue
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
GROUP BY u.name, u.id
)
SELECT user_id,
user_name,
total_revenue,
RANK() OVER (ORDER BY total_revenue DESC) AS revenue_rank,
DENSE_RANK() OVER (ORDER BY total_revenue DESC) AS revenue_dense_rank
FROM user_revenue;
/* - Чем RANK отличается от DENSE_RANK в случае, когда
у двух пользователей одинаковая выручка?
Допустим, у двух таких пользователей будет 1ое место у каждого по выручке.
При RANK у следующего будет 3ье место, при DENSE_RANK - 2ое.
RANK пропускает следующие числовые позиции после одинаковых
значений (например: 1, 2, 2, 4), а DENSE_RANK не пропускает (например: 1, 2, 2, 3).
*/
-- Задача 4. Первый и последний заказ пользователя
SELECT u.id AS user_id,
u.name AS user_name,
o.id AS order_id,
o.order_date,
FIRST_VALUE(o.order_date) OVER (
PARTITION BY u.id
ORDER BY o.order_date
) AS first_order_date,
LAST_VALUE(o.order_date) OVER (
PARTITION BY u.id
ORDER BY o.order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_order_date
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id;
/* - Как можно использовать поля «первый заказ» и «последний заказ»
в сегментации клиентов?
Для определения частоты покупок (разница между датами), выделения
новых или ушедших клиентов (например, клиенты без заказов за последние 3 месяца)
- Почему для правильного LAST_VALUE важно задать рамку окна так, чтобы смотреть
на всю историю пользователя, а не только до текущей строки?
Потому что по умолчанию рамка окна — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,
и LAST_VALUE показывает последнее значение в текущей рамке, т.е. текущую строку.
А нужно по всей рамке, от первой до последней даты у каждого пользователя
*/
-- Задача 5. Заказы и количество заказов пользователя
SELECT u.id AS user_id,
u.name AS user_name,
o.id AS order_id,
COUNT(o.id) OVER (PARTITION BY u.id) AS orders_per_user
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id;
/* - Чем такой результат отличается от классического отчёта
«по пользователям» с GROUP BY user_id?
При применении оконной функции строки не схлопываются, то есть если у
пользователя несколько заказов, то по каждому заказу будет строка с данными,
и в каждой строке будет общее число заказов
*/
-- Задача 6. Сравнение суммы заказа с предыдущим
WITH orders_total AS (
SELECT u.id AS user_id,
o.id AS order_id,
u.name AS user_name,
o.order_date,
SUM(oi.quantity * oi.price_at_purchase) AS order_revenue
FROM dmezentsev.users u
JOIN dmezentsev.orders o ON u.id = o.user_id
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
GROUP BY u.id, o.id, u.name, o.order_date
)
SELECT user_id,
user_name,
order_id,
order_date,
order_revenue,
LAG(order_revenue) OVER (
PARTITION BY user_id
ORDER BY order_date
) AS prev_order_revenue
FROM orders_total;
/* - Как по такому результату понять,
увеличивается или уменьшается средний чек с течением времени?
Нужно сравнить order_revenue с prev_order_revenue для каждого заказа.
Если чаще order_revenue > prev_order_revenue — средний чек растёт.
Если чаще order_revenue < prev_order_revenue — средний чек падает.
sql
*/
-- Задача 7. Категории с накопительной выручкой по времени
WITH category_revenue AS (
SELECT p.category,
DATE_TRUNC('day', o.order_date)::DATE AS order_date,
SUM(oi.quantity * oi.price_at_purchase) AS daily_category_revenue
FROM dmezentsev.order_items oi
JOIN dmezentsev.products p ON oi.product_id = p.id
JOIN dmezentsev.orders o ON oi.order_id = o.id
GROUP BY p.category,
DATE_TRUNC('day', o.order_date)::DATE
)
SELECT category,
order_date,
daily_category_revenue,
SUM(daily_category_revenue) OVER (
PARTITION BY category
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_category_revenue
FROM category_revenue;
/* - Чем такой «накопительный» график по категории отличается по смыслу
от обычного ежедневного (без SUM OVER)?
Обычный график daily_category_revenue показывает, какая выручка категории день ото дня.
Накопительный график running_category_revenue показывает общий рост
или падение категории за период собранных данных
*/