SQL Практика



SQL Практика 3
ответы Java

Практика 3 — Оконные функции, DML, Индексы, EXPLAIN
Постарайся решить задачи самостоятельно, не подсматривай в ответы
Оконные функции — Ранжирование
Задача 1: Найти вторую по величине зарплату
-- Способ 1: OFFSET
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;

-- Способ 2: DENSE_RANK (учитывает дубликаты)
SELECT salary FROM (
   SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
   FROM employees
) t WHERE rnk = 2;
Задача 2: N-я по величине зарплата
-- Для N = 5:
SELECT salary FROM (
   SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
   FROM employees
) t WHERE rnk = 5;
Задача 3: Топ-3 сотрудника по зарплате в каждом отделе
SELECT * FROM (
   SELECT
       e.*,
       d.name AS department_name,
       ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
   FROM employees e
   JOIN departments d ON e.department_id = d.id
) ranked
WHERE rn <= 3;

-- С DENSE_RANK (если нужны все с топ-3 зарплатами):
SELECT * FROM (
   SELECT e.*, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
   FROM employees e
) ranked WHERE rnk <= 3;
Задача 4: Ранжирование продаж сотрудников
SELECT
   e.name,
   SUM(o.amount) AS total_sales,
   RANK() OVER (ORDER BY SUM(o.amount) DESC) AS sales_rank
FROM employees e
JOIN orders o ON e.id = o.employee_id
GROUP BY e.id, e.name
ORDER BY sales_rank;
Оконные функции — Накопительные значения
Задача 5: Накопительная сумма заказов по дням
SELECT
   day::date,
   SUM(daily_total) OVER (ORDER BY day) AS running_total
FROM (
   SELECT
       date_trunc('day', order_date) AS day,
       SUM(amount) AS daily_total
   FROM orders
   GROUP BY date_trunc('day', order_date)
) daily;
Почему нужна группировка: order_date — это TIMESTAMP, в один день может быть несколько заказов. Без предварительной агрегации по дню мы получим накопительную сумму по строкам, а не по дням.
Задача 6: Накопительная сумма по каждому клиенту
SELECT * FROM (
   SELECT
       e.*,
       d.name AS department_name,
       ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
   FROM employees e
   JOIN departments d ON e.department_id = d.id
) ranked
WHERE rn <= 3;

-- С DENSE_RANK (если нужны все с топ-3 зарплатами):
SELECT * FROM (
   SELECT e.*, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
   FROM employees e
) ranked WHERE rnk <= 3;
Почему ROWS, а не RANGE: По умолчанию используется RANGE, при котором все строки с одинаковой датой попадают в одну группу (peer group) и получают одинаковую итоговую сумму. ROWS считает строго построчно — каждая строка прибавляет только свой amount, даже если дата совпадает с другой строкой.
Задача 7: Процент от общего по отделам
SELECT
   d.name AS department,
   COUNT(*) AS emp_count,
   ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct
FROM employees e
JOIN departments d ON e.department_id = d.id
GROUP BY d.id, d.name
ORDER BY pct DESC;
Оконные функции — LAG/LEAD
Задача 8: Сравнение с предыдущим заказом
SELECT
   customer_id,
   order_date,
   amount,
   LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_amount,
   amount - LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS diff
FROM orders;
Задача 9: Процентный рост по сравнению с предыдущим
SELECT
   customer_id,
   order_date,
   amount,
   LAG(amount) OVER w AS prev_amount,
   ROUND(100.0 * (amount - LAG(amount) OVER w) / NULLIF(LAG(amount) OVER w, 0), 2) AS pct_change
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY order_date);
Задача 10: Дни между заказами клиента
SELECT
   customer_id,
   order_date,
   LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order,
   order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS days_between
FROM orders;
Оконные функции — Скользящее среднее
Задача 11: Скользящее среднее за 3 дня
⚠️ Важно: ROWS vs RANGE — критичное различие!
-- ❌ НЕПРАВИЛЬНО: Это среднее по 3 ЗАПИСЯМ, не по 3 ДНЯМ!
SELECT
   order_date::date AS day,
   SUM(amount) AS daily_total,
   AVG(SUM(amount)) OVER (
       ORDER BY order_date::date
       ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
   ) AS moving_avg_3_rows  -- Это НЕ 3 дня!
FROM orders
GROUP BY order_date::date;

-- ✅ ПРАВИЛЬНО: Заполняем пропуски через generate_series
WITH all_days AS (
   SELECT generate_series(
       (SELECT MIN(order_date::date) FROM orders),
       (SELECT MAX(order_date::date) FROM orders),
       '1 day'::interval
   )::date AS day
),
daily_totals AS (
   SELECT order_date::date AS day, SUM(amount) AS daily_total
   FROM orders
   GROUP BY order_date::date
)
SELECT
   d.day,
   COALESCE(t.daily_total, 0) AS daily_total,
   AVG(COALESCE(t.daily_total, 0)) OVER (
       ORDER BY d.day
       ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
   ) AS moving_avg_3_days
FROM all_days d
LEFT JOIN daily_totals t ON d.day = t.day
ORDER BY d.day;
Задача 12: Streak — последовательные дни активности
Классическая задача на собеседованиях!
-- Идея: если вычесть номер строки из даты, последовательные дни дадут одинаковый grp
WITH unique_days AS (
   SELECT DISTINCT user_id, activity_date::date AS day
   FROM user_activity
),
numbered AS (
   SELECT
       user_id,
       day,
       day - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day))::int AS grp
   FROM unique_days
)
SELECT
   user_id,
   MIN(day) AS streak_start,
   MAX(day) AS streak_end,
   COUNT(*) AS streak_length
FROM numbered
GROUP BY user_id, grp
HAVING COUNT(*) >= 3
ORDER BY streak_length DESC, user_id;
Задача 13: Найти gaps в последовательности
WITH ordered AS (
   SELECT id, LEAD(id) OVER (ORDER BY id) AS next_id
   FROM orders
)
SELECT id + 1 AS gap_start, next_id - 1 AS gap_end
FROM ordered
WHERE next_id - id > 1;
DML операции
Задача 14: Добавить нового пользователя
INSERT INTO users (name, email, age, city)
VALUES ('Иван Петров', 'ivan@mail.ru', 30, 'Москва');
Задача 15: Добавить несколько товаров одним запросом
INSERT INTO products (name, category, price, stock) VALUES
   ('Телефон', 'Электроника', 50000, 100),
   ('Ноутбук', 'Электроника', 80000, 50),
   ('Футболка', 'Одежда', 2000, 200);
Задача 16: Увеличить цены на 10% в категории «Электроника»
UPDATE products
SET price = price * 1.10
WHERE category = 'Электроника';
Задача 17: UPDATE с JOIN — увеличить зарплату сотрудников из отдела IT на 10%
UPDATE employees e
SET salary = salary * 1.10
FROM departments d
WHERE e.department_id = d.id AND d.name = 'IT';
Задача 18: DELETE с подзапросом — удалить заказы заблокированных пользователей
DELETE FROM orders
WHERE user_id IN (
   SELECT id FROM users WHERE status = 'blocked'
);

-- Или с USING:
DELETE FROM orders o
USING users u
WHERE o.user_id = u.id AND u.status = 'blocked';
Задача 19: UPSERT — вставка или обновление
INSERT INTO products (name, category, price, stock)
VALUES ('Phone', 'Электроника', 50000, 100)
ON CONFLICT (name) DO UPDATE SET
   price = EXCLUDED.price,
   stock = products.stock + EXCLUDED.stock;

-- Примечание: для работы ON CONFLICT нужен UNIQUE constraint на name:
-- ALTER TABLE products ADD CONSTRAINT uq_products_name UNIQUE (name);
Задача 20: Мягкое удаление (soft delete)
-- Вместо DELETE:
UPDATE users SET deleted_at = NOW() WHERE id = 123;

-- VIEW для активных:
CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;
Задача 21: Почему индекс не используется?
CREATE INDEX idx_email ON users(email);
SELECT * FROM users WHERE LOWER(email) = 'test@test.com';
Решение Индекс по email, а поиск по LOWER (email). Нужен функциональный индекс:
CREATE INDEX idx_email_lower ON users(LOWER(email));
Задача 22: Оптимизация LIKE '%text%'
Решение B-Tree не работает с wildcard в начале. Решение — pg_trgm:
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON users USING gin(name gin_trgm_ops);

SELECT * FROM users WHERE name LIKE '%John%';  -- теперь быстро
Задача 23: Какой индекс создать?
Условие
SELECT * FROM orders WHERE customer_id = 123 AND status = 'pending' ORDER BY order_date DESC;
Решение
-- Составной индекс в правильном порядке:
CREATE INDEX idx_orders ON orders(customer_id, status, order_date DESC);

-- Или покрывающий:
CREATE INDEX idx_orders ON orders(customer_id, status, order_date DESC) INCLUDE (amount);
Задача 24: Оптимизировать запрос с функцией на колонке
Условие
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
Решение
-- Создать функциональный индекс:
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- Или нормализовать данные (триггер):
CREATE FUNCTION normalize_email() RETURNS TRIGGER AS $$
BEGIN
   NEW.email = LOWER(NEW.email);
   RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tr_normalize_email
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION normalize_email();
Задача 25: Оптимизировать пагинацию с большим OFFSET
Условие
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
 Решение Keyset Pagination:
-- Первая страница:
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;

-- Следующие страницы (передаём последние значения):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Индекс:
CREATE INDEX idx_orders_pagination ON orders(created_at DESC, id DESC);
DML операции
Задача 26: Интерпретировать план запроса
Условие
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 123;

-- Результат:
-- Seq Scan on orders  (cost=0.00..1520.00 rows=50) (actual time=0.020..15.432 rows=47 loops=1)
--   Filter: (customer_id = 123)
--   Rows Removed by Filter: 9953
Решение
Анализ:

  • Seq Scan — полное сканирование таблицы (нет индекса!)
  • cost=0.00.1520.00 — оценка стоимости
  • rows=50 — PostgreSQL ожидал 50 строк
  • actual rows=47 — реально вернул 47
  • Rows Removed by Filter: 9953 — отфильтровано 9953 строк

Вывод: Нужен индекс на customer_id:
CREATE INDEX idx_orders_customer ON orders(customer_id);
Задача 27: Почему Index Scan, а не Index Only Scan?
Условие
EXPLAIN ANALYZE
SELECT customer_id, amount FROM orders WHERE customer_id = 123;

-- Index Scan using idx_orders_customer on orders
--   Heap Fetches: 47
Проблема: amount нет в индексе, поэтому PostgreSQL обращается к таблице (Heap Fetches: 47).

Решение — покрывающий индекс:
CREATE INDEX idx_orders_covering ON orders(customer_id) INCLUDE (amount);

-- Теперь будет:
-- Index Only Scan using idx_orders_covering
--   Heap Fetches: 0  -- не обращается к таблице!
Задача 28: Найти проблему в Nested Loop
Условие
-- Nested Loop  (actual time=0.050..45000.000 rows=1000 loops=1)
--   ->  Seq Scan on orders o  (actual time=0.010..10.000 rows=1000 loops=1)
--   ->  Index Scan on users u  (actual time=0.040..44.000 rows=1 loops=1000)
Проблема:

  • Nested Loop выполняется 1000 раз (loops=1000)
  • Index Scan на users занимает 44ms x 1000 = 44 секунды
Решение:
-- Проверить статистику:
ANALYZE orders;
ANALYZE users;

-- Увеличить work_mem для Hash Join:
SET work_mem = '256MB';
Задача 29: Диагностика Sort с disk spill
Условие
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders ORDER BY amount DESC LIMIT 100;

-- Sort Method: external merge  Disk: 15000kB
Проблема: Сортировка не поместилась в память и ушла на диск.

Решения:

  1. Увеличить work_mem:
SET work_mem = '64MB';
-- Sort Method: quicksort  Memory: 25000kB
2. Индекс для сортировки:
CREATE INDEX idx_orders_amount ON orders(amount DESC);
-- Index Scan Backward — вообще без Sort!
Задача 30: Bitmap Index Scan vs Index Scan
Условие
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status IN ('pending', 'processing', 'shipped');

-- Bitmap Heap Scan on orders
--   Recheck Cond: (status = ANY (...))
--   Heap Blocks: exact=2000
--   ->  Bitmap Index Scan on idx_orders_status
Решение
Почему Bitmap, а не Index Scan:
  • Много строк разбросаны по разным страницам
  • Bitmap собирает все нужные страницы, сортирует и читает последовательно
  • Эффективнее чем много random reads

Оптимизация:
SET work_mem = '64MB';  -- больше памяти для bitmap
Генерация данных
Задача 31: Создать календарь на месяц с продажами
WITH all_days AS (
   SELECT generate_series(
       '2024-01-01'::date,
       '2024-01-31'::date,
       '1 day'::interval
   )::date AS day
),
daily_sales AS (
   SELECT DATE(created_at) AS day, SUM(amount) AS total, COUNT(*) AS orders
   FROM orders
   WHERE created_at >= '2024-01-01' AND created_at < '2024-02-01'
   GROUP BY DATE(created_at)
)
SELECT
   d.day,
   TO_CHAR(d.day, 'Day') AS day_name,
   COALESCE(s.orders, 0) AS orders,
   COALESCE(s.total, 0) AS total
FROM all_days d
LEFT JOIN daily_sales s ON d.day = s.day
ORDER BY d.day;
Задача 32: Найти дни без заказов за последний месяц
WITH all_days AS (
   SELECT generate_series(
       CURRENT_DATE - INTERVAL '30 days',
       CURRENT_DATE,
       '1 day'::interval
   )::date AS day
)
SELECT d.day
FROM all_days d
LEFT JOIN orders o ON DATE(o.created_at) = d.day
WHERE o.id IS NULL
ORDER BY d.day;
Шпаргалка

Задача

Ключевой приём

Топ-N в группе

ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)

N-я по величине

DENSE_RANK() или OFFSET N-1

Накопительная сумма

SUM() OVER (ORDER BY ...)

Сравнение с предыдущим

LAG() / LEAD()

Процент от общего

COUNT() / SUM(COUNT()) OVER ()

Streak

date - ROW_NUMBER() группирует

Функция в WHERE

Функциональный индекс

Большой OFFSET

Keyset pagination

LIKE '%text%'

pg_trgm + GIN

Скользящее среднее

ROWS BETWEEN N PRECEDING (+ generate_series!)

Заполнение пропусков

generate_series + LEFT JOIN

Частые ошибки на собеседованиях
  1. Чем ROWS отличается от RANGE в оконных функциях?
  2. Как найти топ-N записей в каждой группе?
  3. Почему Index Scan, а не Index Only Scan?
  4. Как оптимизировать LIKE '%pattern%'?
  5. Что такое Heap Fetches в EXPLAIN?