Skip to content

SQL Шпаргалка

Стандартный язык для управления и запросов к реляционным базам данных.

01

SELECT и основы запросов

SELECT, WHERE и ORDER BY

SELECT извлекает строки из одной или нескольких таблиц. Всегда указывайте столбцы явно вместо * для производительности и ясности (изменения схемы не сломают ваше приложение). WHERE фильтрует строки перед группировкой. ORDER BY сортирует результаты (ASC по умолчанию, DESC по убыванию). LIMIT/OFFSET реализуют пагинацию — для больших наборов данных предпочитайте пагинацию по ключу (WHERE id > last_id).

sql
-- basic query: select specific columns
SELECT id, name, email
FROM users
WHERE age >= 18 AND status = 'active'
ORDER BY name ASC, created_at DESC
LIMIT 10 OFFSET 0;

-- select all columns (avoid in production)
SELECT * FROM products;

-- column aliases with AS
SELECT name AS product_name, price * 1.1 AS price_with_tax
FROM products;

DISTINCT и алиасы

DISTINCT удаляет повторяющиеся строки из набора результатов. Он работает со всей строкой, а не с отдельными столбцами — SELECT DISTINCT city, country возвращает уникальные пары city+country. Алиасы таблиц (u, o) сокращают запросы и требуются при соединении таблицы с самой собой. Алиасы столбцов переименовывают выходные столбцы для читаемости.

sql
-- unique values only
SELECT DISTINCT country FROM users;
SELECT DISTINCT city, country FROM users;  -- unique combos

-- table aliases (essential for joins)
SELECT u.name, o.total
FROM users AS u
JOIN orders AS o ON u.id = o.user_id;

-- column alias (AS is optional)
SELECT name product_name, COUNT(*) count
FROM products
GROUP BY name;

Фильтрация: BETWEEN, IN, IS NULL

BETWEEN включающий с обоих концов. IN соответствует любому значению в списке или подзапросе. NULL требует IS NULL / IS NOT NULL (нельзя использовать = NULL). Будьте осторожны с NOT IN и подзапросами — если подзапрос возвращает любой NULL, NOT IN не возвращает строк вообще. Используйте NOT EXISTS вместо него, который корректно обрабатывает NULL и часто быстрее.

sql
SELECT * FROM products
WHERE price BETWEEN 10 AND 100        -- inclusive range
  AND category IN ('tech', 'home')    -- match any value
  AND stock IS NOT NULL               -- exclude NULLs
  AND discount IS NULL;               -- only NULLs

-- NOT IN with NULL caveat: returns nothing if subquery has NULL!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);  -- risky if NULLs

-- safer: NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

LIKE и сопоставление шаблонов

LIKE использует % (ноль или более символов) и _ (ровно один символ) как маски. LIKE чувствителен к регистру в большинстве баз данных, кроме MySQL (без учёта регистра по умолчанию). Используйте ILIKE в PostgreSQL для сопоставления без учёта регистра. Для сложных шаблонов используйте регулярные выражения (~ в PostgreSQL, REGEXP в MySQL). LIKE с ведущим % не может использовать индексы — рассмотрите полнотекстовый поиск для производительности.

sql
-- LIKE: basic pattern matching
SELECT * FROM users WHERE name LIKE 'A%';     -- starts with A
SELECT * FROM users WHERE name LIKE '%son';    -- ends with son
SELECT * FROM users WHERE name LIKE '%a%';     -- contains a
SELECT * FROM users WHERE name LIKE '_a%';     -- second char is a

-- ILIKE (PostgreSQL): case-insensitive
SELECT * FROM users WHERE name ILIKE 'a%';

-- SIMILAR TO (PostgreSQL): regex-like
SELECT * FROM users WHERE name SIMILAR TO '[AB]%';

-- full regex (PostgreSQL)
SELECT * FROM users WHERE name ~ '^A[a-z]+$';

Выражения CASE

CASE — это if-then-else в SQL, вычисляется для каждой строки. Может появляться в SELECT, WHERE, ORDER BY и HAVING. Шаблон 'pivot' (SUM с CASE) преобразует строки в столбцы — полезно для отчётности. CASE возвращает NULL, если ни один WHEN не совпал и нет ELSE. Всегда включайте ELSE для предсказуемых результатов.

sql
-- conditional logic in queries
SELECT
  name,
  price,
  CASE
    WHEN price < 10 THEN 'cheap'
    WHEN price < 50 THEN 'moderate'
    WHEN price < 100 THEN 'expensive'
    ELSE 'luxury'
  END AS price_category
FROM products;

-- CASE in aggregation (pivot table)
SELECT
  category,
  COUNT(*) AS total,
  SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count,
  SUM(CASE WHEN status = 'inactive' THEN 1 ELSE 0 END) AS inactive_count
FROM products
GROUP BY category;
02

JOIN'ы

INNER JOIN

INNER JOIN возвращает только строки, имеющие совпадения в обеих таблицах. JOIN — сокращение для INNER JOIN. Для запросов к нескольким таблицам соединяйте их пошагово. ON указывает условие соединения; USING(column) — сокращение, когда обе таблицы имеют одинаковое имя столбца. Внутренние соединения исключают несовпадающие строки с обеих сторон.

sql
-- only matching rows from both tables
SELECT u.name, o.total, o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.total > 100
ORDER BY o.total DESC;

-- multiple joins
SELECT u.name, o.total, p.product_name
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;

-- USING (when column names match)
SELECT * FROM users
JOIN profiles ON users.id = profiles.user_id;

LEFT JOIN (LEFT OUTER JOIN)

LEFT JOIN возвращает ВСЕ строки из левой таблицы с NULL для несовпадающих правых строк. Это необходимо для запросов 'включить всё'. Шаблон anti-join (WHERE right.id IS NULL) находит строки в левой таблице без совпадения в правой — полезно для 'пользователи, которые не заказывали'. COUNT(right.id) подсчитывает непустые значения, поэтому возвращает 0 для пользователей без заказов.

sql
-- all users, with their orders (NULL if no orders)
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.name;

-- find users with NO orders (anti-join pattern)
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

-- count orders per user (including zero)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

RIGHT и FULL OUTER JOIN

RIGHT JOIN возвращает все строки правой таблицы; эквивалентно замене таблиц и использованию LEFT JOIN (что более читаемо). FULL OUTER JOIN возвращает все строки из обеих таблиц с NULL там, где нет совпадения — полезно для выверки данных. MySQL не поддерживает FULL OUTER JOIN напрямую; эмулируйте его через LEFT JOIN UNION RIGHT JOIN.

sql
-- RIGHT JOIN: all rows from right table
SELECT u.name, o.total
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
-- returns all orders, even orphaned ones (user_id = NULL)

-- FULL OUTER JOIN: all rows from both tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- returns all users AND all orders, matching where possible

-- Note: RIGHT JOIN is rarely used (just swap tables and use LEFT)
-- FULL OUTER JOIN is useful for finding mismatches between tables
SELECT u.name, o.total
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
WHERE u.id IS NULL OR o.user_id IS NULL;

CROSS JOIN и Self Join

CROSS JOIN создаёт декартово произведение — каждая строка A в паре с каждой строкой B. Используйте для генерации комбинаций (размеры × цвета). Self join (соединение таблицы с самой собой) распространён для иерархических данных (сотрудник-менеджер), поиска дубликатов или сравнения строк в одной таблице. Всегда используйте алиасы таблиц в self join, чтобы различать две 'копии'.

sql
-- CROSS JOIN: Cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- produces all size+color combinations

-- Self join: join a table to itself
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- self join for hierarchical data
SELECT c.name AS child, p.name AS parent
FROM categories c
JOIN categories p ON c.parent_id = p.id;

-- self join to find duplicates
SELECT a.id, a.email, b.id AS dup_id
FROM users a
JOIN users b ON a.email = b.email AND a.id < b.id;

NATURAL JOIN и сводка типов JOIN

NATURAL JOIN автоматически соединяет по столбцам с одинаковыми именами — удобно, но опасно, так как изменения схемы могут незаметно изменить поведение соединения. Избегайте его в продакшене. LATERAL join позволяют подзапросу ссылаться на столбцы из внешнего запроса — мощно для запросов 'топ N в группе'. Синтаксис с запятой (FROM a, b) эквивалентен CROSS JOIN.

sql
-- NATURAL JOIN: joins on all matching column names
-- (rarely recommended — implicit, fragile)
SELECT * FROM users NATURAL JOIN profiles;
-- joins on any column that exists in BOTH tables

-- Summary of join types:
-- INNER JOIN : matching rows only
-- LEFT JOIN  : all left + matching right
-- RIGHT JOIN : all right + matching left
-- FULL JOIN  : all from both sides
-- CROSS JOIN : Cartesian product
-- SELF JOIN  : table joined to itself

-- LATERAL JOIN (PostgreSQL): subquery can reference outer query
SELECT u.name, recent.*
FROM users u,
LATERAL (
  SELECT * FROM orders o
  WHERE o.user_id = u.id
  ORDER BY o.created_at DESC
  LIMIT 3
) recent;
03

GROUP BY и агрегация

GROUP BY и HAVING

GROUP BY сворачивает строки в группы, по одной строке на группу. Агрегатные функции (COUNT, SUM, AVG, MIN, MAX) работают с каждой группой. WHERE фильтрует отдельные строки ДО группировки; HAVING фильтрует группы ПОСЛЕ агрегации. Неагрегированные столбцы в SELECT должны присутствовать в GROUP BY (стандартный SQL). MySQL лоялен, но непредсказуем — всегда включайте все неагрегированные столбцы в GROUP BY.

sql
-- aggregate per group
SELECT category, COUNT(*) AS cnt, AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 5
ORDER BY cnt DESC;

-- HAVING filters groups (after aggregation)
-- WHERE filters rows (before aggregation)
SELECT dept, AVG(salary) AS avg_sal
FROM employees
WHERE status = 'active'       -- filter rows first
GROUP BY dept
HAVING AVG(salary) > 50000;   -- then filter groups

Агрегатные функции

COUNT(*) подсчитывает все строки, включая NULL; COUNT(column) подсчитывает только непустые значения. COUNT(DISTINCT col) подсчитывает уникальные значения. SUM/AVG игнорируют NULL. AVG = SUM/COUNT(непустые), поэтому NULL влияют на среднее. STRING_AGG (PostgreSQL) / GROUP_CONCAT (MySQL) объединяют строки в группе. BOOL_OR/BOOL_AND возвращают true, если любое/все значения true.

sql
SELECT
  COUNT(*) AS total_rows,           -- counts all rows
  COUNT(email) AS emails_filled,    -- counts non-NULL emails
  COUNT(DISTINCT country) AS countries,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order,
  MIN(amount) AS smallest_order,
  MAX(amount) AS largest_order,
  -- string aggregation (PostgreSQL)
  STRING_AGG(name, ', ') AS all_names,
  -- boolean aggregation
  BOOL_OR(is_active) AS any_active,
  BOOL_AND(is_active) AS all_active
FROM orders;

GROUP BY по нескольким столбцам

Группировка по нескольким столбцам создаёт иерархию групп. WITH ROLLUP добавляет строки подытогов и общего итога (NULL в сгруппированном столбце). GROUPING SETS позволяют указать, какие комбинации группировки вы хотите — более гибко, чем ROLLUP. CUBE генерирует все возможные комбинации группировки. Это необходимо для отчётности и OLAP-запросов.

sql
-- multi-level grouping
SELECT
  EXTRACT(YEAR FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  category,
  COUNT(*) AS cnt,
  SUM(total) AS revenue
FROM orders
GROUP BY yr, mon, category
ORDER BY yr DESC, mon DESC, cnt DESC;

-- GROUP BY with ROLLUP (subtotals + grand total)
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category WITH ROLLUP;
-- last row has NULL category = grand total

-- GROUPING SETS (PostgreSQL): specify multiple groupings
SELECT category, status, COUNT(*)
FROM products
GROUP BY GROUPING SETS ((category, status), (category), ());

HAVING vs WHERE

Ключевое различие: WHERE фильтрует отдельные строки до агрегации (не может использовать SUM, COUNT и т.д.), тогда как HAVING фильтрует группы после агрегации (может использовать агрегатные функции). Используйте WHERE для раннего сокращения данных (лучше производительность), затем HAVING для фильтрации агрегированных результатов. Оба могут быть в одном запросе — сначала WHERE, затем GROUP BY, затем HAVING.

sql
-- WHERE: filters rows BEFORE grouping
-- Cannot use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- OK: filter on raw column
GROUP BY category;

-- HAVING: filters groups AFTER grouping
-- Can use aggregates
SELECT category, COUNT(*) AS cnt
FROM products
GROUP BY category
HAVING COUNT(*) > 5       -- OK: filter on aggregate
   AND AVG(price) > 20;   -- OK: multiple aggregate filters

-- combining both
SELECT category, COUNT(*) AS cnt
FROM products
WHERE price > 10          -- filter rows
GROUP BY category         -- group
HAVING COUNT(*) > 5;      -- filter groups

Агрегация по дате/времени

Усечение дат необходимо для отчётности временных рядов. DATE(col) извлекает только дату; EXTRACT/TIME_PART получает конкретные компоненты (год, месяц, час). TO_CHAR форматирует даты для группировки и отображения. Для анализа временных рядов рассмотрите DATE_TRUNC('month', col), который сохраняет тип timestamp. Индексируйте столбцы дат для производительности на больших таблицах.

sql
-- group by date parts
SELECT
  DATE(created_at) AS order_date,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY DATE(created_at)
ORDER BY order_date DESC;

-- group by hour
SELECT
  EXTRACT(HOUR FROM created_at) AS hr,
  COUNT(*) AS cnt
FROM orders
WHERE created_at >= CURRENT_DATE
GROUP BY hr
ORDER BY hr;

-- monthly revenue trend
SELECT
  TO_CHAR(created_at, 'YYYY-MM') AS month,
  SUM(total) AS revenue,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY month
ORDER BY month;
04

Подзапросы и CTE

Скалярные и столбцовые подзапросы

Скалярные подзапросы возвращают одно значение и могут использоваться везде, где ожидается значение. Столбцовые подзапросы возвращают один столбец и используются с IN, ANY, ALL. Подзапросы в SELECT (коррелированные) выполняются один раз для каждой внешней строки — могут быть медленными на больших данных. Рассмотрите переписывание как JOIN с GROUP BY для лучшей производительности.

sql
-- scalar subquery (returns single value)
SELECT name, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);

-- column subquery (returns one column, multiple rows)
SELECT name
FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 100);

-- subquery in SELECT
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;

Коррелированные подзапросы и EXISTS

Коррелированные подзапросы ссылаются на внешний запрос и выполняются один раз для каждой внешней строки — потенциально медленно. EXISTS/NOT EXISTS эффективны, так как они короткозамкнуты (останавливаются при первом совпадении). NOT EXISTS — предпочтительный способ найти 'строки без совпадающих строк' — он корректно обрабатывает NULL и часто быстрее, чем NOT IN. База данных может оптимизировать коррелированные подзапросы в join'ы.

sql
-- correlated: subquery references outer query
SELECT u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.total > 1000
);

-- NOT EXISTS: users without any orders
SELECT u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- correlated subquery in SELECT (runs per row)
SELECT
  u.name,
  (SELECT MAX(o.total)
   FROM orders o
   WHERE o.user_id = u.id) AS max_order
FROM users u;

Табличные выражения (CTE)

CTE (конструкция WITH) создают именованные временные наборы результатов, делая сложные запросы читаемыми. В отличие от подзапросов, CTE могут ссылаться несколько раз и читаются сверху вниз. В большинстве баз данных CTE инлайнятся (оптимизация происходит на уровне запроса). PostgreSQL 12+ поддерживает подсказки MATERIALIZED/NOT MATERIALIZED. CTE также необходимы для рекурсивных запросов.

sql
-- CTE: named temporary result set
WITH active_users AS (
  SELECT id, name FROM users WHERE status = 'active'
),
user_orders AS (
  SELECT user_id, COUNT(*) AS cnt, SUM(total) AS revenue
  FROM orders
  GROUP BY user_id
)
SELECT au.name, uo.cnt, uo.revenue
FROM active_users au
LEFT JOIN user_orders uo ON au.id = uo.user_id
ORDER BY uo.revenue DESC NULLS LAST;

-- CTEs improve readability for complex queries
-- They are NOT materialized (just syntactic sugar) in most DBs
-- (PostgreSQL 12+ can materialize with MATERIALIZED keyword)

Рекурсивные CTE

Рекурсивные CTE ссылаются на самих себя, обеспечивая обход деревьев/графов и генерацию последовательностей. Структура: базовый случай UNION ALL рекурсивный случай. Рекурсивный случай ссылается на CTE и должен завершаться (добавьте WHERE для предотвращения бесконечных циклов). Распространённые применения: организационные диаграммы, деревья категорий, графы зависимостей, последовательности дат. В разных базах данных немного разный синтаксис — проверьте документацию вашей СУБД.

sql
-- hierarchical data: org chart
WITH RECURSIVE org_tree AS (
  -- base case: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive case: direct reports
  SELECT e.id, e.name, e.manager_id, ot.level + 1
  FROM employees e
  JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name FROM org_tree ORDER BY level, name;

-- generate a series of dates
WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS d
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'
)
SELECT d FROM dates;

-- factorial
WITH RECURSIVE fact(n, result) AS (
  SELECT 1, 1
  UNION ALL
  SELECT n + 1, result * (n + 1) FROM fact WHERE n < 10
)
SELECT * FROM fact;

Операторы подзапросов: ANY, ALL

ANY и ALL сравнивают значение с набором результатов подзапроса. > ANY означает 'больше хотя бы одного'. > ALL означает 'больше каждого'. = ANY эквивалентно IN. <> ALL эквивалентно NOT IN, но обрабатывает NULL безопаснее. Эти операторы используются реже, чем IN/EXISTS, но могут выразить некоторые запросы более естественно.

sql
-- ANY: greater than ANY of the values (= at least one)
SELECT * FROM products
WHERE price > ANY (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds at least one tech product's price

-- ALL: greater than ALL values (= every one)
SELECT * FROM products
WHERE price > ALL (
  SELECT price FROM products WHERE category = 'tech'
);
-- true if price exceeds every tech product's price

-- = ANY is equivalent to IN
SELECT * FROM users
WHERE id = ANY (SELECT user_id FROM orders);

-- <> ALL is equivalent to NOT IN (but NULL-safe)
SELECT * FROM users
WHERE id <> ALL (SELECT user_id FROM orders WHERE total < 0);
05

Оконные функции

ROW_NUMBER, RANK, DENSE_RANK

ROW_NUMBER присваивает уникальные последовательные номера (1, 2, 3...). RANK даёт одинаковый ранг связям, но пропускает последующие номера (1, 1, 3). DENSE_RANK даёт одинаковый ранг связям без пропусков (1, 1, 2). PARTITION BY делит строки на группы; функция сбрасывается для каждого раздела. Шаблон 'топ N в группе' (ROW_NUMBER + WHERE rn <= N) крайне распространён в аналитике.

sql
SELECT
  name,
  salary,
  dept,
  ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
  RANK() OVER (ORDER BY salary DESC) AS rank,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees;

-- top 3 earners per department
SELECT * FROM (
  SELECT
    name,
    dept,
    salary,
    ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

LAG и LEAD

LAG обращается к значению предыдущей строки; LEAD — к значению будущей строки. Оба принимают опциональное смещение (по умолчанию 1) и значение по умолчанию (по умолчанию NULL). Существенно для анализа временных рядов: изменения день-ко-дню, скользящие сравнения, обнаружение разрывов. NULLIF предотвращает деление на ноль в процентных вычислениях. Всегда указывайте ORDER BY в конструкции OVER для детерминированных результатов.

sql
-- compare each row to previous/next
SELECT
  date,
  revenue,
  LAG(revenue) OVER (ORDER BY date) AS prev_day,
  LEAD(revenue) OVER (ORDER BY date) AS next_day,
  revenue - LAG(revenue) OVER (ORDER BY date) AS daily_change,
  ROUND(
    (revenue - LAG(revenue) OVER (ORDER BY date)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY date), 0),
    2
  ) AS pct_change
FROM daily_sales
ORDER BY date;

-- LAG with offset and default
SELECT
  date,
  revenue,
  LAG(revenue, 7) OVER (ORDER BY date) AS revenue_7_days_ago
FROM daily_sales;

Нарастающие итоги и скользящие средние

Оконные фреймы определяют, над какими строками работает функция. ROWS BETWEEN использует физические смещения строк; RANGE использует логические диапазоны значений (лучше для пробелов в датах). UNBOUNDED PRECEDING означает 'с самого начала'. Нарастающие итоги (кумулятивный SUM) и скользящие средние — самые распространённые аналитические шаблоны. Без фрейма агрегатные оконные функции используют значение по умолчанию: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

sql
-- cumulative sum (running total)
SELECT
  date,
  revenue,
  SUM(revenue) OVER (ORDER BY date) AS running_total,
  SUM(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS last_7_days_sum,
  AVG(revenue) OVER (ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM daily_sales
ORDER BY date;

-- window frame options:
-- ROWS BETWEEN n PRECEDING AND n FOLLOWING  -- physical rows
-- RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW -- logical range
-- ROWS UNBOUNDED PRECEDING = from start to current
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows

NTILE и PERCENT_RANK

NTILE(n) делит упорядоченные строки на n примерно равных групп (квартили, децили, перцентили). PERCENT_RANK даёт относительный ранг (от 0 до 1). CUME_DIST даёт кумулятивное распределение. FIRST_VALUE/LAST_VALUE возвращают значения из первой/последней строки фрейма — обратите внимание, что LAST_VALUE требует явного фрейма (UNBOUNDED FOLLOWING), так как фрейм по умолчанию заканчивается на текущей строке.

sql
-- divide into quartiles
SELECT
  name,
  salary,
  NTILE(4) OVER (ORDER BY salary DESC) AS quartile,
  PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
  CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;

-- first/last value in a partition
SELECT
  dept,
  name,
  salary,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner,
  LAST_VALUE(name) OVER (
    PARTITION BY dept ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS lowest_earner
FROM employees;

Оконные агрегатные функции

Оконные агрегаты (SUM, AVG, COUNT и т.д. с OVER) вычисляют агрегатные значения БЕЗ сворачивания строк — каждая строка получает присоединённый агрегат. Это ключевое отличие от GROUP BY: вы сохраняете все детальные строки, одновременно видя сводку. Идеально для сравнения индивидуальных значений со средними по группе, вычисления процентов и добавления контекстных столбцов к детальным отчётам.

sql
-- aggregates over windows (no row collapse!)
SELECT
  name,
  dept,
  salary,
  -- compare to department average
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
  salary - AVG(salary) OVER (PARTITION BY dept) AS diff_from_avg,
  -- percentage of department total
  salary * 100.0 / SUM(salary) OVER (PARTITION BY dept) AS pct_of_dept,
  -- count per department
  COUNT(*) OVER (PARTITION BY dept) AS dept_size
FROM employees
ORDER BY dept, salary DESC;

-- key advantage: aggregates without GROUP BY
-- every row is preserved, with the aggregate value attached
06

DDL: таблицы и схема

CREATE TABLE и типы данных

CREATE TABLE определяет схему. SERIAL (PostgreSQL) / AUTO_INCREMENT (MySQL) автоматически генерирует ID. VARCHAR(n) имеет предел; TEXT не ограничен. DECIMAL(p,s) точный (используйте для денег!), FLOAT приблизительный. CHECK-ограничения обеспечивают бизнес-правила. DEFAULT предоставляет значения, когда не указано. JSONB (PostgreSQL) обеспечивает индексируемые JSON-запросы. Всегда используйте TIMESTAMP WITH TIME ZONE для меток времени, охватывающих часовые пояса.

sql
CREATE TABLE users (
  id          SERIAL PRIMARY KEY,          -- auto-increment (PostgreSQL)
  -- MySQL: id INT AUTO_INCREMENT PRIMARY KEY
  username    VARCHAR(50) UNIQUE NOT NULL,
  email       VARCHAR(255) UNIQUE NOT NULL,
  password    VARCHAR(255) NOT NULL,
  age         INT CHECK (age >= 0 AND age <= 150),
  salary      DECIMAL(10, 2) DEFAULT 0.00, -- precision, scale
  bio         TEXT,                        -- unlimited length
  avatar      BYTEA,                       -- binary (PostgreSQL)
  metadata    JSONB,                       -- JSON (PostgreSQL)
  status      VARCHAR(20) DEFAULT 'active',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- common types:
-- INT, BIGINT, SMALLINT, DECIMAL(p,s), NUMERIC
-- VARCHAR(n), CHAR(n), TEXT
-- DATE, TIME, TIMESTAMP, INTERVAL
-- BOOLEAN, UUID, JSON/JSONB, BYTEA/BLOB

Ограничения: PRIMARY, FOREIGN, UNIQUE, CHECK

Ограничения обеспечивают целостность данных на уровне базы данных. PRIMARY KEY уникально идентифицирует строки (подразумевает NOT NULL + UNIQUE). FOREIGN KEY поддерживает ссылочную целостность — ON DELETE CASCADE удаляет дочерние записи при удалении родителя. UNIQUE предотвращает дубликаты. CHECK обеспечивает пользовательские правила. Определение ограничений в базе данных (не только в коде приложения) обеспечивает целостность независимо от способа доступа к данным.

sql
CREATE TABLE orders (
  id          SERIAL PRIMARY KEY,
  user_id     INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  total       DECIMAL(10,2) NOT NULL CHECK (total >= 0),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

  -- table-level constraints
  CONSTRAINT valid_status CHECK (status IN ('pending','paid','shipped','cancelled')),
  CONSTRAINT unique_user_order UNIQUE (user_id, created_at)
);

-- foreign key actions:
-- ON DELETE CASCADE  : delete child rows when parent deleted
-- ON DELETE SET NULL : set FK to NULL (column must be nullable)
-- ON DELETE RESTRICT : prevent parent deletion (default)
-- ON UPDATE CASCADE  : update FK when parent PK changes

-- add constraint to existing table
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);

ALTER TABLE

ALTER TABLE изменяет структуру существующей таблицы. Добавление столбцов со значениями по умолчанию обычно быстро (PostgreSQL 11+ не переписывает таблицу). Удаление столбцов может заблокировать таблицу. Изменение типов столбцов может потребовать полной перезаписи таблицы и может завершиться неудачей, если данные не преобразуются. Всегда тестируйте миграции схемы на копии. Используйте инструменты миграции (Flyway, Alembic, Rails migrations) для версионных изменений схемы.

sql
-- add column
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN verified BOOLEAN DEFAULT false;

-- drop column
ALTER TABLE users DROP COLUMN avatar;

-- rename column/table
ALTER TABLE users RENAME COLUMN username TO login;
ALTER TABLE users RENAME TO accounts;

-- change column type
ALTER TABLE users ALTER COLUMN age TYPE BIGINT;
-- MySQL: ALTER TABLE users MODIFY COLUMN age BIGINT;

-- add/drop constraints
ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email);
ALTER TABLE users DROP CONSTRAINT email_unique;

-- set default
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';

DROP, TRUNCATE и индексы

DROP TABLE полностью удаляет таблицу; TRUNCATE очищает её, но сохраняет структуру (гораздо быстрее, чем DELETE, сбрасывает идентичность). Индексы ускоряют запросы, но замедляют запись — индексируйте стратегически. Композитные индексы работают слева направо: idx(a,b,c) помогает WHERE a=?, WHERE a=? AND b=?, но НЕ WHERE b=?. GIN-индексы обеспечивают полнотекстовый поиск. Частичные индексы экономят место, индексируя только совпадающие строки.

sql
-- DROP: permanently remove table (structure + data)
DROP TABLE IF EXISTS old_logs CASCADE;
-- CASCADE drops dependent objects (views, FKs)

-- TRUNCATE: remove all data, keep structure (faster than DELETE)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY;  -- reset SERIAL counter
TRUNCATE TABLE orders, order_items CASCADE;  -- multiple tables

-- CREATE INDEX for query performance
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_username ON users(username);
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_products_name ON products USING gin(to_tsvector('english', name));

-- partial index (PostgreSQL)
CREATE INDEX idx_active_users ON users(last_login)
WHERE status = 'active';

-- DROP INDEX
DROP INDEX IF EXISTS idx_users_email;

Представления и материализованные представления

Представления — сохранённые запросы, действующие как виртуальные таблицы — они запускают нижележащий запрос каждый раз. Используйте представления для упрощения сложных запросов, обеспечения безопасности (доступ на уровне столбцов) и предоставления стабильных API. Материализованные представления хранят фактические результаты — быстрее запрашивать, но требуют обновления. Используйте материализованные представления для дорогих агрегаций, не требующих данных в реальном времени. CONCURRENTLY обновляет без блокировки (PostgreSQL).

sql
-- VIEW: stored query (virtual table, runs on access)
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';

SELECT * FROM active_users WHERE name LIKE 'A%';

-- updatable view (simple views can be INSERTed/UPDATEd)
CREATE VIEW user_summary AS
SELECT id, name, email, age FROM users;

-- MATERIALIZED VIEW: stored result (must refresh)
CREATE MATERIALIZED VIEW monthly_stats AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS orders,
  SUM(total) AS revenue
FROM orders
GROUP BY month;

REFRESH MATERIALIZED VIEW monthly_stats;
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; -- no lock

-- drop
DROP VIEW IF EXISTS active_users;
DROP MATERIALIZED VIEW IF EXISTS monthly_stats;
07

DML: Insert, Update, Delete

INSERT

INSERT добавляет строки. Несколько VALUES в одном операторе эффективнее, чем отдельные вставки. INSERT...SELECT копирует данные между таблицами. RETURNING (PostgreSQL/Oracle) извлекает автогенерируемые значения (например, SERIAL ids) за один проход — необходимо для кода приложения. Используйте DEFAULT VALUES для вставки строки со всеми значениями по умолчанию. Всегда указывайте имена столбцов, чтобы сделать код устойчивым к изменениям схемы.

sql
-- single row
INSERT INTO users (name, email, age)
VALUES ('Alice', '[email protected]', 30);

-- multiple rows
INSERT INTO users (name, email) VALUES
  ('Bob', '[email protected]'),
  ('Carol', '[email protected]'),
  ('Dave', '[email protected]');

-- INSERT ... SELECT (copy data between tables)
INSERT INTO archive_users (name, email, deleted_at)
SELECT name, email, NOW()
FROM users
WHERE status = 'deleted';

-- INSERT with RETURNING (PostgreSQL)
INSERT INTO users (name, email)
VALUES ('Eve', '[email protected]')
RETURNING id, created_at;  -- returns the generated id

-- DEFAULT values
INSERT INTO users DEFAULT VALUES;

UPDATE

UPDATE изменяет существующие строки. ВСЕГДА включайте предложение WHERE, если только не собираетесь обновить каждую строку. Предложение FROM (PostgreSQL) позволяет join'ы в обновлениях. RETURNING показывает, какие строки были изменены. Используйте транзакции для многошаговых обновлений, чтобы можно было выполнить ROLLBACK, если что-то пойдёт не так. Распространённая ошибка — забыть WHERE; рассмотрите запуск SELECT с тем же WHERE сначала для проверки затронутых строк.

sql
-- basic update
UPDATE users
SET age = 31, status = 'verified', updated_at = NOW()
WHERE id = 1;

-- update based on another table
UPDATE products p
SET price = p.price * 1.1
FROM categories c
WHERE p.category_id = c.id AND c.name = 'electronics';

-- update with subquery
UPDATE users
SET status = 'premium'
WHERE id IN (
  SELECT user_id FROM orders
  GROUP BY user_id HAVING SUM(total) > 1000
);

-- UPDATE with RETURNING (PostgreSQL)
UPDATE users SET status = 'inactive'
WHERE last_login < '2023-01-01'
RETURNING id, name;

-- WARNING: UPDATE without WHERE affects ALL rows!

DELETE и TRUNCATE

DELETE удаляет строки по одной (логируется, можно откатить, медленнее). TRUNCATE удаляет все строки сразу (минимальное логирование, намного быстрее, сбрасывает автоинкремент, нельзя откатить в некоторых БД). Для аудиторских следов используйте мягкие удаления (метка времени deleted_at) вместо жёстких удалений. Всегда используйте WHERE с DELETE. Учитывайте ограничения внешнего ключа — ON DELETE CASCADE обрабатывает дочерние строки автоматически.

sql
-- delete specific rows
DELETE FROM users WHERE status = 'inactive';

-- delete with subquery
DELETE FROM orders
WHERE user_id IN (
  SELECT id FROM users WHERE status = 'deleted'
);

-- delete with RETURNING (PostgreSQL)
DELETE FROM users
WHERE last_login < '2020-01-01'
RETURNING id, name;

-- delete all rows (slow, logged, can be rolled back)
DELETE FROM logs;

-- TRUNCATE (fast, minimal logging, resets identity)
TRUNCATE TABLE logs;
TRUNCATE TABLE logs RESTART IDENTITY CASCADE;

-- soft delete pattern (preferred for audit)
UPDATE users SET deleted_at = NOW() WHERE id = 1;
SELECT * FROM users WHERE deleted_at IS NULL; -- active users

UPSERT (INSERT ... ON CONFLICT)

UPSERT (update or insert) атомарно обрабатывает конфликты дублирующих ключей. PostgreSQL использует ON CONFLICT (column) DO UPDATE/DO NOTHING. MySQL использует ON DUPLICATE KEY UPDATE. EXCLUDED (PostgreSQL) / VALUES() (MySQL) относится к предлагаемым значениям вставки. Это необходимо для идемпотентных операций и избегания состояний гонки. Без upsert потребовался бы SELECT-затем-INSERT/UPDATE, что подвержено состояниям гонки.

sql
-- PostgreSQL: ON CONFLICT (upsert)
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id)
DO UPDATE SET email = EXCLUDED.email, updated_at = NOW()
RETURNING *;

-- DO NOTHING on conflict
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON CONFLICT (id) DO NOTHING;

-- MySQL: ON DUPLICATE KEY UPDATE
INSERT INTO users (id, name, email)
VALUES (1, 'Alice', '[email protected]')
ON DUPLICATE KEY UPDATE email = VALUES(email);

-- SQLite: ON CONFLICT
INSERT INTO users (id, name)
VALUES (1, 'Alice')
ON CONFLICT(id) DO UPDATE SET name = excluded.name;

Оператор MERGE

MERGE (он же UPSERT на стероидах) объединяет INSERT, UPDATE и DELETE в одном атомарном операторе на основе совпадения строк. Это самый эффективный способ синхронизации данных между источниками. WHEN MATCHED запускает UPDATE/DELETE для существующих строк; WHEN NOT MATCHED запускает INSERT для новых строк. Доступен в SQL Server, Oracle, PostgreSQL 15+ и DB2. MySQL не поддерживает MERGE — используйте INSERT...ON DUPLICATE KEY.

sql
-- MERGE: conditional insert/update/delete in one statement
-- (SQL Server, Oracle, PostgreSQL 15+)
MERGE INTO products AS target
USING (VALUES
  (1, 'Widget', 9.99),
  (2, 'Gadget', 19.99),
  (3, 'Gizmo', 29.99)
) AS source (id, name, price)
ON target.id = source.id
WHEN MATCHED THEN
  UPDATE SET name = source.name, price = source.price
WHEN NOT MATCHED THEN
  INSERT (id, name, price) VALUES (source.id, source.name, source.price)
WHEN MATCHED AND source.price < 0 THEN
  DELETE;

-- useful for:
-- - syncing data from external sources
-- - bulk upsert with conditional logic
-- - ETL operations
08

Транзакции и ACID

BEGIN, COMMIT, ROLLBACK

Транзакции группируют операции в атомарную единицу — все успешно (COMMIT) или все неудачно (ROLLBACK). Это 'A' в ACID. BEGIN/START TRANSACTION начинает транзакцию. SAVEPOINT создаёт именованную точку отката в транзакции — можно откатиться к ней без прерывания всей транзакции. Всегда делайте commit или rollback — оставление открытой транзакции удерживает блокировки и может вызвать взаимные блокировки.

sql
-- basic transaction
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- both updates succeed or both fail (atomicity)

-- rollback on error
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 50.00);
  -- oops, something went wrong
  ROLLBACK;
-- the insert is undone

-- transaction with savepoints
BEGIN;
  INSERT INTO logs (msg) VALUES ('step 1');
  SAVEPOINT my_savepoint;
  INSERT INTO logs (msg) VALUES ('step 2');
  ROLLBACK TO my_savepoint;  -- undo step 2, keep step 1
  INSERT INTO logs (msg) VALUES ('step 3');
COMMIT;  -- commits step 1 and step 3

Уровни изоляции

Уровни изоляции балансируют согласованность и конкурентность. READ COMMITTED (по умолчанию в PostgreSQL/Oracle) предотвращает грязное чтение, но допускает неповторяющееся чтение. REPEATABLE READ предотвращает неповторяющееся чтение, но допускает фантомное чтение. SERIALIZABLE предотвращает все аномалии, но снижает конкурентность. Более высокая изоляция = больше блокировок = меньше конкурентности. Выбирайте самый низкий уровень, отвечающий вашим требованиям корректности.

sql
-- set isolation level for transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
  -- can see committed data from other transactions
  SELECT balance FROM accounts WHERE id = 1;
COMMIT;

-- isolation levels (from weakest to strongest):
-- READ UNCOMMITTED: can read uncommitted (dirty) data
-- READ COMMITTED: only committed data (PostgreSQL default)
-- REPEATABLE READ: same query returns same results within txn
-- SERIALIZABLE: transactions appear to run sequentially

-- PostgreSQL: set per transaction
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- MySQL: set per session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- check current level
SHOW TRANSACTION ISOLATION LEVEL;

Блокировки и SELECT FOR UPDATE

SELECT FOR UPDATE блокирует строки, чтобы другие транзакции не могли их изменять до вашего commit. Это реализует пессимистическое управление конкурентностью. SKIP LOCKED необходим для очередей задач — несколько воркеров могут брать задачи без блокировки друг друга. NOWAIT завершается неудачей быстро вместо ожидания. Используйте блокировки умеренно — они снижают конкурентность и могут вызвать взаимные блокировки. Предпочитайте оптимистическую конкурентность (столбцы версий) для большинства случаев.

sql
-- pessimistic locking: lock rows for update
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- row is locked; other transactions must wait
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;  -- lock released

-- NOWAIT: don't wait if locked, error immediately
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: skip locked rows (useful for job queues)
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

-- SHARE LOCK: allow reads but prevent updates
SELECT * FROM products WHERE id = 1 FOR SHARE;

Взаимные блокировки и обработка ошибок

Взаимные блокировки возникают, когда две транзакции удерживают блокировки, нужные друг другу. База данных обнаруживает взаимные блокировки и прерывает одну транзакцию (жертву). Предотвратите взаимные блокировки, приобретая блокировки в согласованном порядке во всех транзакциях. Всегда будьте готовы повторить транзакции, завершившиеся неудачей из-за взаимных блокировок или ошибок сериализации. Держите транзакции короткими для снижения конкуренции за блокировки. Код приложения должен перехватывать SQLSTATE 40P01 (взаимная блокировка) и повторять попытку.

sql
-- deadlock example:
-- Transaction A:
BEGIN;
  UPDATE accounts SET balance = balance - 50 WHERE id = 1; -- locks row 1
  UPDATE accounts SET balance = balance + 50 WHERE id = 2; -- waits for row 2

-- Transaction B (concurrent):
BEGIN;
  UPDATE accounts SET balance = balance - 30 WHERE id = 2; -- locks row 2
  UPDATE accounts SET balance = balance + 30 WHERE id = 1; -- waits for row 1
-- DEADLOCK! Database detects and kills one transaction

-- prevention: always lock in consistent order
-- Transaction A and B both lock id=1 first, then id=2

-- PostgreSQL: error codes for handling
-- 40P01: deadlock_detected
-- 40001: serialization_failure
-- 40P02: transaction_integrity_constraint_violation

-- retry pattern (pseudocode):
-- for attempt in range(3):
--     try:
--         BEGIN; ... COMMIT; break
--     except deadlock:
--         ROLLBACK; continue

Свойства ACID

ACID — основа надёжных транзакций базы данных. Атомарность: все операции в транзакции успешны или неудачны вместе. Согласованность: транзакции переводят базу данных из одного корректного состояния в другое (ограничения обеспечиваются). Изолированность: конкурентные транзакции не мешают друг другу (управляется уровнем изоляции). Долговечность: после commit данные переживают сбои (достигается через журналирование с упреждающей записью). Базы данных NoSQL часто жертвуют некоторыми свойствами ACID ради масштабируемости.

sql
-- ACID guarantees for transactions:

-- A: Atomicity (all or nothing)
BEGIN;
  INSERT INTO orders (id, total) VALUES (1, 100);
  INSERT INTO order_items (order_id, product_id) VALUES (1, 5);
  -- if either fails, both are rolled back
COMMIT;

-- C: Consistency (valid state to valid state)
-- constraints are checked at commit
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative
  CHECK (balance >= 0);
-- a transaction that would make balance negative fails

-- I: Isolation (concurrent transactions don't interfere)
-- controlled by isolation level (see previous section)

-- D: Durability (committed data survives crashes)
-- achieved via WAL (Write-Ahead Logging) + fsync
-- synchronous_commit = on (default) ensures durability
09

Индексы, представления и хранимые процедуры

Типы и стратегии индексов

B-tree индексы (по умолчанию) обрабатывают запросы равенства (=) и диапазона (<, >, BETWEEN). Композитные индексы следуют правилу крайнего левого префикса — индекс (a,b,c) помогает WHERE a=?, WHERE a=? AND b=?, но не WHERE b=?. Частичные индексы экономят место, индексируя только подмножество. Индексы выражений обеспечивают индексируемые запросы по функциям (LOWER, вычисляемые столбцы). Используйте EXPLAIN ANALYZE для проверки использования индексов — неиспользуемый индекс тратит место и замедляет запись.

sql
-- B-tree index (default): equality and range queries
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_date ON orders(created_at);

-- composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- helps: WHERE user_id = 1
-- helps: WHERE user_id = 1 AND status = 'paid'
-- does NOT help: WHERE status = 'paid' (leftmost prefix rule)

-- partial index: smaller, faster for common filters
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- expression index
CREATE INDEX idx_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- unique index
CREATE UNIQUE INDEX idx_unique_email ON users(email);

-- EXPLAIN: see if index is used
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

Хранимые процедуры и функции

Функции возвращают значения и могут использоваться в SELECT; процедуры выполняют действия и вызываются с CALL. Хранимые процедуры инкапсулируют бизнес-логику в базе данных — снижая сетевые обращения и централизуя логику. Однако они могут усложнить масштабирование (логика разделена между приложением и БД) и зависят от конкретной БД. Используйте их для операций, интенсивных по данным, которые выигрывают от близости к данным. PostgreSQL использует PL/pgSQL; MySQL использует собственный процедурный SQL.

sql
-- PostgreSQL function
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id INT)
RETURNS TABLE(order_id INT, total DECIMAL) AS $$
BEGIN
  RETURN QUERY
  SELECT id, total FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

-- call function
SELECT * FROM get_user_orders(1);

-- PostgreSQL procedure (can manage transactions, PostgreSQL 11+)
CREATE PROCEDURE transfer_money(
  from_id INT, to_id INT, amount DECIMAL
) LANGUAGE plpgsql AS $$
BEGIN
  UPDATE accounts SET balance = balance - amount WHERE id = from_id;
  UPDATE accounts SET balance = balance + amount WHERE id = to_id;
  COMMIT;
END;
$$;

CALL transfer_money(1, 2, 100.00);

-- MySQL stored procedure
DELIMITER //
CREATE PROCEDURE GetActiveUsers()
BEGIN
  SELECT * FROM users WHERE status = 'active';
END //
DELIMITER ;
CALL GetActiveUsers();

Триггеры

Триггеры выполняются автоматически при изменениях данных. BEFORE-триггеры могут изменять входящие данные (например, устанавливать метки времени, проверять). AFTER-триггеры выполняют побочные эффекты (например, журналирование аудита, денормализацию). Используйте триггеры умеренно — они скрыты от кода приложения, что усложняет отладку. Распространённые применения: аудиторские следы, вычисляемые столбцы, обеспечение сложных ограничений и синхронизация денормализованных данных. Всегда чётко документируйте триггеры.

sql
-- PostgreSQL trigger: audit log on update
CREATE OR REPLACE FUNCTION audit_user_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO user_audit (user_id, old_name, new_name, changed_at)
  VALUES (OLD.id, OLD.name, NEW.name, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_user_audit
AFTER UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_change();

-- trigger timing: BEFORE / AFTER / INSTEAD OF
-- trigger events: INSERT / UPDATE / DELETE / TRUNCATE
-- granularity: FOR EACH ROW / FOR EACH STATEMENT

-- MySQL trigger
CREATE TRIGGER before_user_insert
BEFORE INSERT ON users
FOR EACH ROW
SET NEW.created_at = NOW();

-- drop trigger
DROP TRIGGER IF EXISTS trg_user_audit ON users;

Операции JSON (PostgreSQL)

JSONB (PostgreSQL) хранит JSON в бинарном формате, обеспечивая индексацию и эффективные запросы. -> возвращает JSON, ->> возвращает текст. @> проверяет вхождение (содержит ли JSON это?). GIN-индексы делают JSON-запросы быстрыми. Используйте JSON-столбцы для гибких/полуструктурированных данных (журналы событий, ответы API, конфигурация), сохраняя реляционные данные в обычных столбцах. JSONB предпочтительнее JSON (быстрее, индексируемый, без дублирующих ключей).

sql
-- JSONB columns (PostgreSQL)
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB NOT NULL
);

INSERT INTO events (data) VALUES
  ('{"type": "click", "user": {"id": 1, "name": "Alice"}, "tags": ["web", "mobile"]}');

-- extract fields (-> for JSON, ->> for text)
SELECT data->'type' AS type,           -- "click" (JSON)
       data->'user'->>'name' AS name,  -- Alice (text)
       data->'tags'->0 AS first_tag    -- "web"
FROM events;

-- filter by JSON field
SELECT * FROM events WHERE data->>'type' = 'click';
SELECT * FROM events WHERE data @> '{"type": "click"}';  -- containment

-- GIN index for JSON queries
CREATE INDEX idx_events_data ON events USING gin(data);
SELECT * FROM events WHERE data @> '{"user": {"id": 1}}';

-- modify JSON
UPDATE events SET data = jsonb_set(data, '{user,name}', '"Bob"');

Полнотекстовый поиск

Полнотекстовый поиск обеспечивает запросы на естественном языке (стемминг, ранжирование, стоп-слова). to_tsvector преобразует текст в токены для поиска; to_tsquery создаёт поисковый запрос; @@ сопоставляет. ts_rank оценивает результаты; ts_headline подсвечивает совпадения. GIN-индексы делают это быстрым. Для крупномасштабного поиска рассмотрите специализированные движки (Elasticsearch, Solr), но полнотекстовый поиск PostgreSQL превосходен для умеренных наборов данных и избегает сложности инфраструктуры.

sql
-- PostgreSQL full-text search
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  body TEXT
);

-- create a full-text search index
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));

-- search with ranking
SELECT
  title,
  ts_rank(to_tsvector('english', body), query) AS rank,
  ts_headline('english', body, query) AS snippet
FROM articles, to_tsquery('english', 'database & performance') query
WHERE to_tsvector('english', title || ' ' || body) @@ query
ORDER BY rank DESC
LIMIT 10;

-- simplified with generated column
ALTER TABLE articles ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search_vec ON articles USING gin(search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('database');
10

Производительность и оптимизация запросов

EXPLAIN и планы запросов

EXPLAIN показывает план запроса — как база данных выполнит ваш запрос. EXPLAIN ANALYZE фактически запускает его и показывает реальные тайминги. Ищите Sequential Scan на больших таблицах (добавьте индексы), дорогие Sort (добавьте индексы) и несоответствия оценок строк (запустите ANALYZE для обновления статистики). Числа стоимости относительные, а не абсолютные. Понимание планов запросов — навык №1 для настройки производительности SQL.

sql
-- EXPLAIN: show query plan without running
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE: run the query and show actual timing
EXPLAIN ANALYZE SELECT * FROM users WHERE email = '[email protected]';

-- EXPLAIN ANALYZE with buffers (I/O stats)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users
JOIN orders ON users.id = orders.user_id;

-- key things to look for in the plan:
-- Seq Scan: full table scan (bad for large tables — add index)
-- Index Scan: using an index (good)
-- Bitmap Index Scan: index + heap lookup (good for many rows)
-- Hash Join: builds hash table (good for large joins)
-- Nested Loop: good for small result sets
-- Sort: explicit sort (consider index to avoid)
-- cost: estimated cost (first row, all rows)
-- rows: estimated vs actual rows (big mismatch = stale stats)

Распространённые ошибки производительности

Sargability (Search Argument Able) означает, что база данных может использовать индексы. Функции на столбцах (DATE(col), UPPER(col)) препятствуют использованию индексов — перепишите как запросы диапазона или используйте индексы выражений. SELECT * тратит I/O и предотвращает покрывающие индексы. OFFSET-пагинация имеет сложность O(n) — используйте пагинацию по ключу (WHERE id > last_id) для O(1). Большие пакетные операции должны быть разбиты на части для избежания долгих блокировок и отставания репликации.

sql
-- 1. Sargability: avoid functions on indexed columns
-- BAD: function prevents index usage
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- GOOD: range query uses index
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';

-- 2. Avoid SELECT * (more I/O, prevents covering indexes)
-- BAD
SELECT * FROM users WHERE status = 'active';
-- GOOD
SELECT id, name, email FROM users WHERE status = 'active';

-- 3. Use LIMIT with ORDER BY for pagination
-- BAD: loads all rows
SELECT * FROM products ORDER BY id;
-- GOOD: keyset pagination
SELECT * FROM products WHERE id > 1000 ORDER BY id LIMIT 20;

-- 4. Batch large updates
-- BAD: one giant transaction
DELETE FROM logs WHERE date < '2023-01-01';
-- GOOD: batch in chunks
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 10000;
DELETE FROM logs WHERE date < '2023-01-01' AND id <= 20000;

UNION, INTERSECT и EXCEPT

Операции над множествами объединяют наборы результатов. UNION удаляет дубликаты (дорогая сортировка); UNION ALL сохраняет их (быстрее — предпочитайте, когда знаете, что дубликатов нет или они нужны). INTERSECT возвращает строки в обоих. EXCEPT возвращает строки в первом, но не во втором. Все требуют совместимых типов столбцов. UNION ALL может заменить сложные условия OR и часто работает лучше, так как может использовать разные индексы для каждой ветки.

sql
-- UNION: combine results, remove duplicates
SELECT name FROM customers
UNION
SELECT name FROM suppliers;
-- UNION ALL: faster, keeps duplicates
SELECT name FROM customers
UNION ALL
SELECT name FROM suppliers;

-- INTERSECT: rows in BOTH results
SELECT product_id FROM sales_2023
INTERSECT
SELECT product_id FROM sales_2024;

-- EXCEPT (MINUS in Oracle): rows in first but not second
SELECT product_id FROM all_products
EXCEPT
SELECT product_id FROM discontinued_products;

-- rules:
-- - same number of columns
-- - compatible types
-- - column names come from first query
-- - UNION is often faster than OR conditions

Советы для конкретных баз данных

VACUUM (PostgreSQL) освобождает место от удалённых строк (MVCC оставляет 'мёртвые кортежи'). ANALYZE обновляет статистику таблицы для планировщика запросов — запустите после массовых загрузок. OPTIMIZE TABLE (MySQL) дефрагментирует таблицы. Явно индексируйте внешние ключи (PostgreSQL не делает это автоматически). Мониторьте использование индексов с pg_stat_user_indexes и удаляйте неиспользуемые. Пул соединений (PgBouncer, ProxySQL) необходим для высоконагруженных приложений — открытие соединений дорого.

sql
-- PostgreSQL: VACUUM to reclaim space
VACUUM ANALYZE users;  -- update stats, reclaim dead rows
VACUUM FULL users;     -- rewrites table (locks, but reclaims all space)

-- PostgreSQL: ANALYZE to update statistics
ANALYZE users;  -- helps query planner make better decisions

-- MySQL: OPTIMIZE TABLE
OPTIMIZE TABLE users;

-- Common indexing rules across databases:
-- 1. Index foreign keys (not automatic in all DBs)
-- 2. Index columns used in WHERE, JOIN, ORDER BY, GROUP BY
-- 3. Composite indexes: high selectivity column first
-- 4. Don't over-index (slows writes, uses disk)
-- 5. Drop unused indexes (check with pg_stat_user_indexes)

-- Connection pooling (application level):
-- - Use PgBouncer (PostgreSQL) or ProxySQL (MySQL)
-- - Reuse connections instead of reconnecting per request
-- - Set appropriate pool size (not too high!)

Типы данных и обработка NULL

NULL представляет неизвестные/отсутствующие данные, а не ноль или пустоту. Сравнения с NULL всегда дают NULL (unknown), что ложно в WHERE. Используйте IS NULL / IS NOT NULL для проверки. COALESCE предоставляет значения по умолчанию. NULLIF преобразует определённые значения в NULL (полезно для деления на ноль). Агрегаты пропускают NULL — COUNT(col) подсчитывает непустые, COUNT(*) подсчитывает все строки. В LEFT JOIN используйте COUNT(right_table.col) для получения 0 для несовпадающих строк.

sql
-- NULL is not zero or empty string — it's "unknown"
SELECT NULL = NULL;   -- NULL (not true!)
SELECT NULL IS NULL;  -- true
SELECT NULL <> 1;     -- NULL (unknown)

-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, first_name, 'Anonymous') FROM users;

-- NULLIF: return NULL if two values are equal
SELECT NULLIF(score, 0) FROM tests;  -- NULL instead of 0
-- useful for avoiding division by zero:
SELECT total / NULLIF(count, 0) FROM stats;

-- aggregate functions ignore NULL
SELECT AVG(score) FROM tests;  -- avg of non-NULL scores only
SELECT COUNT(score) FROM tests;  -- count of non-NULL
SELECT COUNT(*) FROM tests;  -- count of all rows

-- use LEFT JOIN + COUNT carefully
SELECT u.name, COUNT(o.id) AS orders  -- COUNT(o.id) = 0 for no orders
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id;
11

Рекурсивные CTE

Базовая структура рекурсивного CTE

Рекурсивный CTE ссылается на самого себя для генерации иерархических или последовательных данных. Он состоит из двух частей, соединённых UNION ALL: якорный запрос (базовый случай/отправная точка) и рекурсивный запрос (который ссылается на CTE и добавляет к результату). Рекурсия продолжается, пока рекурсивный запрос не вернёт ни одной строки. Используйте рекурсивные CTE для обхода деревьев (организационные диаграммы, файловые системы), генерации последовательностей и поиска путей в графах. Всегда включайте условие завершения в WHERE для предотвращения бесконечных циклов.

sql
-- Recursive CTE: a CTE that references itself
-- Three parts: anchor, UNION ALL, recursive member
WITH RECURSIVE countdown(n) AS (
    -- Anchor: starting point
    SELECT 1 AS n
    UNION ALL
    -- Recursive member: references the CTE itself
    SELECT n + 1 FROM countdown WHERE n < 10
)
SELECT n FROM countdown;
-- Result: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

-- PostgreSQL uses WITH RECURSIVE
-- SQL Server/MySQL 8+: WITH (RECURSIVE optional in MySQL)
-- SQLite: WITH RECURSIVE

Иерархические данные (орг-диаграмма)

Рекурсивные CTE превосходно справляются с обходом иерархических данных, таких как орг-диаграммы, деревья категорий или файловые системы. Якорь выбирает корневой узел; рекурсивный член соединяет таблицу с CTE по родительско-дочернему отношению (manager_id = id). Добавление столбца depth отслеживает, насколько глубоко находится каждая строка, а столбец path (конкатенация строк) показывает полную цепочку предков. Это заменяет необходимость в нескольких self-join или рекурсии на стороне приложения. CAST на path предотвращает ошибки типов во время рекурсии.

sql
-- Employee hierarchy with manager relationships
CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    manager_id INT REFERENCES employees(id)
);

-- Find all direct and indirect reports of employee 1 (CEO)
WITH RECURSIVE org_chain AS (
    -- Anchor: the starting employee
    SELECT id, name, manager_id, 0 AS depth, CAST(name AS VARCHAR(500)) AS path
    FROM employees WHERE id = 1
    UNION ALL
    -- Recursive: find employees whose manager is in the chain
    SELECT e.id, e.name, e.manager_id, oc.depth + 1,
           CAST(oc.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees e
    JOIN org_chain oc ON e.manager_id = oc.id
)
SELECT id, name, depth, path FROM org_chain
ORDER BY depth, name;

-- depth shows hierarchy level, path shows the management chain

Генерация последовательностей и дат

Рекурсивные CTE могут генерировать последовательности и диапазоны дат — полезно для заполнения пробелов в отчётах временных рядов. Генерируя все даты в диапазоне и LEFT JOIN к вашим данным, вы обеспечиваете появление каждой даты в выводе, даже когда нет записей. Это распространённый шаблон для дашбордов и диаграмм. В PostgreSQL также есть generate_series() как более простая альтернатива. Всегда устанавливайте условие завершения (WHERE n < 100) для предотвращения бесконечной рекурсии. Некоторые базы данных ограничивают глубину рекурсии (например, 100 по умолчанию в MySQL через cte_max_recursion_depth).

sql
-- Generate a series of numbers
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Generate a date range (all days in January 2024)
WITH RECURSIVE dates(d) AS (
    SELECT DATE '2024-01-01'
    UNION ALL
    SELECT d + INTERVAL '1 day' FROM dates
    WHERE d < DATE '2024-01-31'
)
SELECT d FROM dates;

-- Generate a time series for gap-filling in reports
-- (ensures every date appears even if no data exists)
SELECT d.date, COALESCE(SUM(o.amount), 0) AS daily_total
FROM dates d
LEFT JOIN orders o ON o.order_date = d.date
GROUP BY d.date ORDER BY d.date;

Поиск путей в графе (BFS)

Рекурсивные CTE могут выполнять поиск в ширину (BFS) по графовым структурам. Якорь находит рёбра от начального узла; рекурсивный член расширяет пути, соединяя рёбра с конечной точкой текущего пути. Предотвращение циклов критично в циклических графах — проверяйте, что узел назначения ещё не в пути (используя LIKE или строковый поиск). Ограничение hops — страховка против бесконечной рекурсии. Этот подход работает для поиска маршрутов, разрешения зависимостей и анализа сетей. Для взвешенных кратчайших путей рассмотрите алгоритм Дейкстры в коде приложения.

sql
-- Find all paths in a directed graph
CREATE TABLE edges (src VARCHAR(10), dst VARCHAR(10));

-- Find all reachable nodes from 'A' with the path taken
WITH RECURSIVE paths AS (
    -- Anchor: start from node A
    SELECT src, dst, CAST(src || '->' || dst AS VARCHAR(1000)) AS path,
           1 AS hops
    FROM edges WHERE src = 'A'
    UNION ALL
    -- Recursive: extend the path
    SELECT p.src, e.dst,
           CAST(p.path || '->' || e.dst AS VARCHAR(1000)),
           p.hops + 1
    FROM paths p
    JOIN edges e ON p.dst = e.src
    WHERE p.hops < 10  -- prevent infinite loops in cyclic graphs
      AND p.path NOT LIKE '%' || e.dst || '%'
)
SELECT DISTINCT path, hops FROM paths ORDER BY hops;

-- Cycle prevention: check the path doesn't already contain the node

Факториал и агрегация с рекурсией

Рекурсивные CTE могут выполнять математические вычисления, такие как факториалы, перенося состояние (n, fact) через каждую итерацию. Якорь устанавливает базовый случай (0! = 1), а рекурсивный член вычисляет следующее значение из предыдущего. Нарастающие итоги также можно вычислять так, хотя оконные функции (SUM(amount) OVER (ORDER BY id)) более эффективны и идиоматичны для кумулятивных агрегатов. Рекурсивные CTE для вычислений в основном образовательные — используйте их, когда оконные функции или процедурный код не могут выразить логику. Каждый уровень рекурсии добавляет строку, поэтому набор результатов растёт с глубиной.

sql
-- Compute factorial using recursive CTE
WITH RECURSIVE factorial(n, fact) AS (
    -- Anchor: 0! = 1
    SELECT 0, 1
    UNION ALL
    -- Recursive: n! = n * (n-1)!
    SELECT n + 1, fact * (n + 1) FROM factorial WHERE n < 10
)
SELECT n, fact FROM factorial;

-- Running accumulation: cumulative sum
WITH RECURSIVE running_total AS (
    SELECT id, amount, amount AS cumulative
    FROM transactions WHERE id = 1
    UNION ALL
    SELECT t.id, t.amount, rt.cumulative + t.amount
    FROM transactions t
    JOIN running_total rt ON t.id = rt.id + 1
)
SELECT * FROM running_total ORDER BY id;

-- Note: window functions (SUM OVER) are usually better for this
12

PIVOT и UNPIVOT

PIVOT (строки в столбцы)

PIVOT преобразует строки в столбцы — идеально для кросс-таблиц, где нужны категории в качестве заголовков столбцов. Список IN указывает, какие значения становятся столбцами. SQL Server и Oracle имеют собственный синтаксис PIVOT. Внутренний запрос предоставляет исходные данные, а PIVOT применяет агрегат (SUM, AVG, COUNT) для каждой группы столбцов. Это эквивалентно условной агрегации, но более читаемо для широких pivot'ов. Используйте PIVOT, когда у вас есть фиксированный, известный набор значений для pivot'а.

sql
-- Convert rows to columns (cross-tabulation)
-- Source: sales data with rows per quarter
CREATE TABLE sales (quarter VARCHAR(10), region VARCHAR(50), amount DECIMAL(10,2));

-- SQL Server PIVOT syntax:
SELECT region, [Q1], [Q2], [Q3], [Q4]
FROM (
    SELECT quarter, region, amount FROM sales
) AS src
PIVOT (
    SUM(amount) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS pvt;

-- Result:
-- region  | Q1    | Q2    | Q3    | Q4
-- North   | 1000  | 1500  | 1200  | 1800
-- South   | 800   | 1100  | 900   | 1300

Условная агрегация (универсальный PIVOT)

Условная агрегация (SUM + CASE) — это универсальная техника pivot'а, работающая в любой SQL-базе данных. Каждое выражение CASE фильтрует по одной категории, а SUM агрегирует совпадающие значения. Это часто быстрее, чем PIVOT, и более гибко. ELSE 0 гарантирует, что несовпадающие строки вносят ноль. Функция crosstab() в PostgreSQL (из расширения tablefunc) более лаконична, но требует фиксированных выходных столбцов. Используйте условную агрегацию, когда нужна кросс-БД совместимость или синтаксис PIVOT недоступен.

sql
-- Works in ALL databases (MySQL, PostgreSQL, SQLite, etc.)
SELECT
    region,
    SUM(CASE WHEN quarter = 'Q1' THEN amount ELSE 0 END) AS Q1,
    SUM(CASE WHEN quarter = 'Q2' THEN amount ELSE 0 END) AS Q2,
    SUM(CASE WHEN quarter = 'Q3' THEN amount ELSE 0 END) AS Q3,
    SUM(CASE WHEN quarter = 'Q4' THEN amount ELSE 0 END) AS Q4,
    SUM(amount) AS total
FROM sales
GROUP BY region
ORDER BY region;

-- PostgreSQL-specific: crosstab() from tablefunc extension
-- SELECT * FROM crosstab('SELECT region, quarter, amount FROM sales ORDER BY 1,2')
-- AS ct(region VARCHAR, Q1 DECIMAL, Q2 DECIMAL, Q3 DECIMAL, Q4 DECIMAL);

Динамический PIVOT (динамический SQL)

Динамический SQL строит строку запроса во время выполнения, когда столбцы pivot'а неизвестны заранее (например, pivot по месяцам, когда месяцы различаются). Процесс: запрос уникальных значений, построение списка столбцов, конструирование оператора PIVOT и выполнение с sp_executesql (SQL Server) или PREPARE/EXECUTE (MySQL). Всегда очищайте с помощью QUOTENAME() или quote_ident() для предотвращения SQL-инъекций. Динамический SQL мощный, но добавляет сложность и риски безопасности — используйте умеренно и предпочитайте фиксированные pivot'ы, когда возможно. Pivot на стороне приложения часто более безопасная альтернатива.

sql
-- When pivot columns are unknown at write time, use dynamic SQL
-- SQL Server example:
DECLARE @cols AS NVARCHAR(MAX),
        @query AS NVARCHAR(MAX);

-- Build the column list dynamically
SELECT @cols = STUFF((
    SELECT DISTINCT ',' + QUOTENAME(quarter)
    FROM sales FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)'), 1, 1, '');

-- Build and execute the pivot query
SET @query = 'SELECT region, ' + @cols + '
FROM (SELECT quarter, region, amount FROM sales) x
PIVOT (SUM(amount) FOR quarter IN (' + @cols + ')) p';

EXEC sp_executesql @query;

-- WARNING: dynamic SQL is vulnerable to SQL injection
-- — always use QUOTENAME() to sanitize column names

UNPIVOT (столбцы в строки)

UNPIVOT обращает PIVOT вспять — он преобразует столбцы в строки. Это полезно для нормализации денормализованных данных, преобразования широких файлов импорта в длинный формат или подготовки данных для диаграмм. SQL Server имеет собственный синтаксис UNPIVOT. Подход UNION ALL работает везде: каждый SELECT извлекает один столбец и помечает его фиксированным значением. UNION ALL (не UNION) сохраняет дубликаты и быстрее. UNPIVOT распространён в ETL-конвейерах, когда исходные данные приходят в формате таблицы (широкие), но должны храниться нормализованно (длинные).

sql
-- Convert columns back to rows
-- Source: wide table with Q1-Q4 columns
-- Target: narrow table with quarter/amount rows

-- SQL Server UNPIVOT:
SELECT region, quarter, amount
FROM quarterly_sales
UNPIVOT (
    amount FOR quarter IN (Q1, Q2, Q3, Q4)
) AS unpvt;

-- Universal UNION ALL approach (all databases):
SELECT region, 'Q1' AS quarter, Q1 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q2' AS quarter, Q2 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q3' AS quarter, Q3 AS amount FROM quarterly_sales
UNION ALL
SELECT region, 'Q4' AS quarter, Q4 AS amount FROM quarterly_sales;

-- Result: one row per region/quarter combination

Практический пример отчёта с pivot

Этот реальный отчёт с pivot объединяет месячную детализацию со сравнением год-к-году в одном запросе. Условная агрегация (SUM + CASE) создаёт как месячные столбцы, так и годовые итоги. Столбец yoy_change вычисляет разницу инлайн. HAVING отфильтровывает продукты без продаж. Этот шаблон распространён в BI-дашбордах и финансовых отчётах. Функция EXTRACT работает в большинстве баз данных (используйте DATEPART в SQL Server, strftime в SQLite). Для действительно динамических столбцов комбинируйте с динамическим SQL или обрабатывайте pivot на уровне приложения.

sql
-- Monthly sales pivot with year-over-year comparison
SELECT
    product_name,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 1  THEN amount ELSE 0 END) AS jan,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 2  THEN amount ELSE 0 END) AS feb,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 3  THEN amount ELSE 0 END) AS mar,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 4  THEN amount ELSE 0 END) AS apr,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 5  THEN amount ELSE 0 END) AS may,
    SUM(CASE WHEN EXTRACT(MONTH FROM order_date) = 6  THEN amount ELSE 0 END) AS jun,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) AS total_2024,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS total_2023,
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2024 THEN amount ELSE 0 END) -
    SUM(CASE WHEN EXTRACT(YEAR  FROM order_date) = 2023 THEN amount ELSE 0 END) AS yoy_change
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE order_date BETWEEN '2023-01-01' AND '2024-12-31'
GROUP BY product_name
HAVING SUM(amount) > 0
ORDER BY total_2024 DESC;
14

Триггеры

Основы триггеров (AFTER/BEFORE)

Триггеры — код на уровне базы данных, который выполняется автоматически при изменении данных. AFTER-триггеры логируют или распространяют изменения (не могут изменять NEW). BEFORE-триггеры проверяют или преобразуют данные перед записью (могут изменять NEW). FOR EACH ROW срабатывает один раз для каждой затронутой строки; FOR EACH STATEMENT — один раз для оператора. Используйте триггеры для журналирования аудита, обеспечения сложных ограничений и автообновления производных столбцов. Избегайте триггеров для бизнес-логики — они скрыты, трудны для отладки и могут вызывать каскадные эффекты. В разных базах данных разный синтаксис триггеров; PostgreSQL использует функции как тела триггеров.

sql
-- A trigger fires automatically on INSERT/UPDATE/DELETE
-- MySQL syntax:
DELIMITER //
CREATE TRIGGER audit_log
AFTER INSERT ON employees
FOR EACH ROW
BEGIN
    INSERT INTO audit_table (table_name, action, row_id, changed_at)
    VALUES ('employees', 'INSERT', NEW.id, NOW());
END //
DELIMITER ;

-- BEFORE triggers can modify the NEW values:
CREATE TRIGGER validate_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
    IF NEW.email NOT LIKE '%@%.%' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid email';
    END IF;
END //

-- PostgreSQL uses CREATE FUNCTION + CREATE TRIGGER:
-- CREATE TRIGGER audit AFTER INSERT ON employees
-- FOR EACH ROW EXECUTE FUNCTION audit_func();

Триггер журналирования аудита

Аудит-триггеры фиксируют каждое изменение данных для соответствия требованиям и отладки. Аудит-таблица хранит тип действия, старые и новые значения, кто внёс изменение (CURRENT_USER) и когда (CURRENT_TIMESTAMP). Нужны отдельные триггеры для INSERT, UPDATE и DELETE. OLD ссылается на значения до изменения (доступно в UPDATE/DELETE), NEW — на значения после изменения (доступно в INSERT/UPDATE). Аудит-таблицы растут бесконечно — разделяйте по датам или архивируйте старые данные. Этот шаблон удовлетворяет требованиям SOX, HIPAA и GDPR по отслеживанию изменений данных.

sql
-- Track all changes to a critical table
CREATE TABLE employee_audit (
    audit_id INT AUTO_INCREMENT PRIMARY KEY,
    action VARCHAR(10),       -- INSERT, UPDATE, DELETE
    employee_id INT,
    old_name VARCHAR(100),
    new_name VARCHAR(100),
    old_salary DECIMAL(10,2),
    new_salary DECIMAL(10,2),
    changed_by VARCHAR(50) DEFAULT CURRENT_USER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TRIGGER trg_audit_insert
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, new_name, new_salary)
VALUES ('INSERT', NEW.id, NEW.name, NEW.salary);

CREATE TRIGGER trg_audit_update
AFTER UPDATE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, new_name, old_salary, new_salary)
VALUES ('UPDATE', NEW.id, OLD.name, NEW.name, OLD.salary, NEW.salary);

CREATE TRIGGER trg_audit_delete
AFTER DELETE ON employees
FOR EACH ROW
INSERT INTO employee_audit (action, employee_id, old_name, old_salary)
VALUES ('DELETE', OLD.id, OLD.name, OLD.salary);

Триггер вычисляемого/производного столбца

Триггеры могут автоматически вычислять производные столбцы, обеспечивая согласованность без кода приложения. BEFORE INSERT/UPDATE-триггеры устанавливают NEW.final_price на основе других столбцов. Однако современные базы данных поддерживают GENERATED (вычисляемые) столбцы нативно — они всегда корректны, не могут быть вручную переопределены и могут быть индексированы. Предпочитайте GENERATED-столбцы триггерам для вычисляемых значений. Используйте триггеры только когда вычисление включает внешние данные, условную логику или межтабличные зависимости, которые GENERATED-столбцы не могут обработать. Помните, что триггеры добавляют накладные расходы к каждой операции записи.

sql
-- Auto-update a derived column when source data changes
CREATE TABLE products (
    id INT PRIMARY KEY,
    price DECIMAL(10,2),
    discount_percent DECIMAL(5,2),
    final_price DECIMAL(10,2)  -- computed: price * (1 - discount/100)
);

-- BEFORE INSERT: compute final_price
CREATE TRIGGER calc_final_price_insert
BEFORE INSERT ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- BEFORE UPDATE: recompute if price or discount changes
CREATE TRIGGER calc_final_price_update
BEFORE UPDATE ON products
FOR EACH ROW
SET NEW.final_price = NEW.price * (1 - NEW.discount_percent / 100);

-- Alternative: use GENERATED columns (MySQL 5.7+, PostgreSQL 12+)
-- final_price DECIMAL(10,2) GENERATED ALWAYS AS
--     (price * (1 - discount_percent / 100)) STORED

Предотвращение удалений триггерами

Триггеры могут обеспечивать правила защиты данных, которые CHECK-ограничения не могут выразить. BEFORE DELETE-триггеры могут полностью блокировать удаления (используя SIGNAL/RAISE) или реализовывать мягкие удаления (помечая записи как удалённые вместо их удаления). SIGNAL SQLSTATE '45000' — способ MySQL вызвать пользовательскую ошибку. PostgreSQL использует RAISE EXCEPTION. Это полезно для защиты справочных данных, предотвращения удаления родительских записей с дочерними или реализации неизменяемых аудиторских следов. Будьте осторожны: триггеры, предотвращающие операции, могут удивить разработчиков — чётко документируйте их и рассмотрите проверки на уровне приложения.

sql
-- Prevent deletion of critical records
CREATE TRIGGER prevent_delete_admin
BEFORE DELETE ON users
FOR EACH ROW
BEGIN
    IF OLD.role = 'admin' THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Cannot delete admin users';
    END IF;
END //

-- Soft delete instead of hard delete
CREATE TRIGGER soft_delete
BEFORE DELETE ON articles
FOR EACH ROW
BEGIN
    -- Prevent actual deletion, mark as deleted instead
    INSERT INTO articles (id, title, body, deleted_at)
    VALUES (OLD.id, OLD.title, OLD.body, NOW())
    ON DUPLICATE KEY UPDATE deleted_at = NOW();
    -- Still need to prevent the DELETE — use SIGNAL or a flag
END //

-- PostgreSQL: raise exception in trigger function
-- IF OLD.role = 'admin' THEN
--     RAISE EXCEPTION 'Cannot delete admin users';
-- END IF;

Управление триггерами и отладка

Управление триггерами необходимо для поддержки. SHOW TRIGGERS (MySQL) и представления information_schema перечисляют все триггеры. Удаляйте триггеры с помощью DROP TRIGGER IF EXISTS. Временное отключение триггеров полезно для массовых загрузок данных (которые иначе вызвали бы дорогое журналирование аудита для каждой строки). PostgreSQL использует ALTER TABLE ... DISABLE/ENABLE TRIGGER; SQL Server использует DISABLE/ENABLE TRIGGER. Всегда включайте триггеры обратно после обслуживания. Отладка триггеров сложна — они выполняются молча. Добавьте журналирование в отладочную таблицу или сначала протестируйте логику триггера изолированно. Чрезмерные триггеры создают скрытую сложность и проблемы производительности.

sql
-- View existing triggers
-- MySQL:
SHOW TRIGGERS;
SELECT * FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = 'mydb';

-- PostgreSQL:
SELECT tgname, tgrelid::regclass, tgtype FROM pg_trigger;

-- SQL Server:
SELECT name, type_desc FROM sys.triggers WHERE parent_id = OBJECT_ID('employees');

-- Drop a trigger
DROP TRIGGER IF EXISTS audit_log;  -- MySQL
DROP TRIGGER IF EXISTS audit_log ON employees;  -- PostgreSQL

-- Disable/enable triggers (PostgreSQL)
ALTER TABLE employees DISABLE TRIGGER ALL;
ALTER TABLE employees ENABLE TRIGGER ALL;

-- Disable for bulk operations (SQL Server)
DISABLE TRIGGER trg_audit ON employees;
-- ... bulk operations ...
ENABLE TRIGGER trg_audit ON employees;
15

Пользовательские функции (UDF)

Скалярные функции (возвращают одно значение)

Скалярные UDF возвращают одно значение и могут использоваться в SELECT, WHERE и вычисляемых столбцах. DETERMINIC означает, что выход зависит только от входов (включает кэширование). READS SQL DATA объявляет, что функция читает из таблиц. UDF инкапсулируют переиспользуемую логику (скидки, форматирование, вычисления) для согласованности между запросами. Однако скалярные UDF в SQL Server могут вызывать проблемы производительности (построчное выполнение) — используйте встраиваемые табличные функции или вычисляемые столбцы, когда возможно. MySQL 8.0+ лучше оптимизирует детерминированные функции. Всегда документируйте назначение и параметры функции.

sql
-- MySQL: a function that returns one value
CREATE FUNCTION calculate_discount(
    price DECIMAL(10,2),
    customer_tier VARCHAR(20)
) RETURNS DECIMAL(10,2)
DETERMINISTIC
READS SQL DATA
BEGIN
    DECLARE discount_rate DECIMAL(5,2);
    SET discount_rate = CASE customer_tier
        WHEN 'gold'   THEN 0.20
        WHEN 'silver' THEN 0.10
        WHEN 'bronze' THEN 0.05
        ELSE 0.00
    END;
    RETURN price * (1 - discount_rate);
END //

-- Usage in queries:
SELECT name, price, calculate_discount(price, tier) AS final_price
FROM orders;

-- DETERMINISTIC: same inputs always give same output (cacheable)
-- READS SQL DATA: function reads but doesn't modify tables

Табличные функции (возвращают строки)

Табличные функции (TVF) возвращают набор результатов (строки), который можно запросить как таблицу. Встраиваемые TVF (SQL Server) так же быстры, как представления — оптимизатор запросов инлайнит их. Многооператорные TVF материализуют результаты во временную таблицу сначала, что может быть медленнее. Функции PostgreSQL, возвращающие TABLE или SETOF, эквивалентны. TVF — параметризованные представления — используйте их, когда нужно представление с параметрами. Они отлично подходят для инкапсуляции сложных JOIN и фильтров. Предпочитайте встраиваемые TVF многооператорным для производительности. В PostgreSQL также рассмотрите параметризованные представления с предложениями WHERE.

sql
-- SQL Server: Inline table-valued function (fast, like a view)
CREATE FUNCTION fn_OrdersByCustomer(@cust_id INT)
RETURNS TABLE
AS
RETURN (
    SELECT o.id, o.order_date, o.total
    FROM orders o
    WHERE o.customer_id = @cust_id
);
-- Usage: SELECT * FROM fn_OrdersByCustomer(42);

-- PostgreSQL: function returning a table
CREATE OR REPLACE FUNCTION get_orders_by_customer(cust_id INT)
RETURNS TABLE(order_id INT, order_date DATE, total DECIMAL) AS $$
    SELECT id, order_date, total
    FROM orders
    WHERE customer_id = cust_id;
$$ LANGUAGE SQL;

-- Usage:
SELECT * FROM get_orders_by_customer(42);

-- Multi-statement TVF (SQL Server) — slower, materializes result
-- CREATE FUNCTION fn_ComplexReport(@date DATE)
-- RETURNS @result TABLE (...)
-- AS BEGIN ... INSERT INTO @result ... RETURN END

Функции обработки строк

Пользовательские строковые функции инкапсулируют логику обработки текста, которую встроенные функции не покрывают. Функция get_first_name использует LOCATE и SUBSTRING для извлечения первого слова. Функция make_slug цепочкой LOWER, REPLACE и REGEXP_REPLACE создаёт URL-дружественные слаги. Помечайте их DETERMINISTIC, так как одинаковый вход всегда даёт одинаковый выход. Строковые функции в SQL зависят от базы данных — PostgreSQL имеет split_part(), MySQL имеет SUBSTRING_INDEX(). Создание UDF стандартизирует поведение в приложении. Учтите, что сложная обработка строк в SQL часто чище в коде приложения.

sql
-- Create a function to split full name into parts
CREATE FUNCTION get_first_name(full_name VARCHAR(200))
RETURNS VARCHAR(100)
DETERMINISTIC
BEGIN
    DECLARE space_pos INT;
    SET space_pos = LOCATE(' ', full_name);
    IF space_pos > 0 THEN
        RETURN SUBSTRING(full_name, 1, space_pos - 1);
    ELSE
        RETURN full_name;
    END IF;
END //

-- Function to generate slug from a title
CREATE FUNCTION make_slug(title VARCHAR(500))
RETURNS VARCHAR(500)
DETERMINISTIC
BEGIN
    DECLARE slug VARCHAR(500);
    SET slug = LOWER(title);
    SET slug = REPLACE(slug, ' ', '-');
    SET slug = REGEXP_REPLACE(slug, '[^a-z0-9-]', '');
    RETURN slug;
END //

-- Usage:
SELECT get_first_name('John Doe Smith') AS first_name;  -- John
SELECT make_slug('Hello World! 2024') AS slug;           -- hello-world-2024

Агрегатные функции (пользовательские)

Пользовательские агрегатные функции позволяют определять новую логику агрегации помимо SUM, AVG, COUNT. CREATE AGGREGATE в PostgreSQL требует функцию перехода состояния (SFUNC, вызывается для каждой строки) и финальную функцию (FINALFUNC, вызывается один раз в конце). Этот пример вычисляет среднее геометрическое (корень n-й степени из произведения). Пользовательские агрегаты мощны для статистических, финансовых или специфичных для предметной области вычислений. Состояние накапливается по строкам; финальная функция вычисляет результат. MySQL и SQL Server не поддерживают пользовательские агрегаты напрямую — используйте хранимые процедуры или вычисления на стороне приложения.

sql
-- PostgreSQL: custom aggregate function
-- Step 1: Define a state transition function
CREATE OR REPLACE FUNCTION geom_mean_state(state numeric[], val numeric)
RETURNS numeric[] AS $$
BEGIN
    IF val IS NULL THEN RETURN state; END IF;
    IF state IS NULL THEN
        RETURN ARRAY[val];
    ELSE
        RETURN state || val;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Step 2: Define the final function
CREATE OR REPLACE FUNCTION geom_mean_final(vals numeric[])
RETURNS numeric AS $$
BEGIN
    IF vals IS NULL OR array_length(vals, 1) IS NULL THEN
        RETURN NULL;
    END IF;
    RETURN exp(avg(ln(v)) FROM unnest(vals) AS v);
END;
$$ LANGUAGE plpgsql;

-- Step 3: Create the aggregate
CREATE AGGREGATE geometric_mean(numeric) (
    SFUNC = geom_mean_state,
    STYPE = numeric[],
    FINALFUNC = geom_mean_final,
    INITCOND = '{}'
);

-- Usage:
SELECT category, geometric_mean(price) FROM products GROUP BY category;

Функция vs хранимая процедура

Функции и хранимые процедуры служат разным целям. Функции возвращают значение и могут быть встроены в SELECT/WHERE — они должны быть детерминированными (без побочных эффектов в большинстве баз данных). Хранимые процедуры могут изменять данные, управлять транзакциями и возвращать несколько наборов результатов — но не могут использоваться внутри запросов (вызываются через CALL/EXEC). Используйте функции для вычислений и извлечения данных; процедуры для многошаговых операций (переводы, пакетная обработка, ETL). Функции компонуемы; процедуры императивны. В PostgreSQL функции могут делать почти всё, что могут процедуры (включая изменение данных), размывая различие.

sql
-- FUNCTIONS: return values, usable in queries, no side effects
CREATE FUNCTION get_full_name(fname VARCHAR, lname VARCHAR)
RETURNS VARCHAR(200)
DETERMINISTIC
RETURN CONCAT(fname, ' ', lname);

-- Can be used in SELECT:
SELECT get_full_name(first, last) FROM users;  -- OK
SELECT * FROM users WHERE get_full_name(first, last) LIKE 'J%';  -- OK

-- STORED PROCEDURES: can modify data, use transactions, return result sets
CREATE PROCEDURE transfer_funds(
    IN from_acct INT, IN to_acct INT, IN amount DECIMAL(10,2)
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;
    UPDATE accounts SET balance = balance - amount WHERE id = from_acct;
    UPDATE accounts SET balance = balance + amount WHERE id = to_acct;
    INSERT INTO transfers (from_id, to_id, amount) VALUES (from_acct, to_acct, amount);
    COMMIT;
END //

-- Call procedure (can't use in SELECT):
CALL transfer_funds(1, 2, 100.00);
16

Проектирование баз данных и нормализация

Первая нормальная форма (1NF)

Первая нормальная форма требует атомарных значений — каждая ячейка содержит один элемент данных, а не списки или массивы. Значения, разделённые запятыми в столбце, нарушают 1NF, так как нельзя запросить, индексировать или обновить отдельные элементы. Решение: создайте одну строку на элемент (с композитным первичным ключом) или разделите в отдельную детальную таблицу. 1NF также требует первичный ключ для уникальной идентификации каждой строки. Нарушение 1NF делает запросы вроде 'найти все заказы, содержащие мышь' требующими строкового парсинга — медленно и подвержено ошибкам. Всегда начинайте с соответствия 1NF.

sql
-- 1NF: each column contains atomic (indivisible) values
--       no repeating groups, each row is unique

-- VIOLATION: comma-separated values in one column
CREATE TABLE bad_orders (
    id INT,
    customer_name VARCHAR(100),
    products VARCHAR(500)  -- "laptop, mouse, keyboard" — BAD!
);

-- 1NF COMPLIANT: separate row per product
CREATE TABLE orders (
    order_id INT,
    customer_name VARCHAR(100),
    product_name VARCHAR(100),  -- one product per row
    PRIMARY KEY (order_id, product_name)  -- composite key for uniqueness
);

-- Better: normalize further with separate tables
CREATE TABLE orders (order_id INT PRIMARY KEY, customer_name VARCHAR(100));
CREATE TABLE order_items (
    order_id INT REFERENCES orders(order_id),
    product_name VARCHAR(100),
    quantity INT,
    PRIMARY KEY (order_id, product_name)
);

Вторая и третья нормальные формы (2NF, 3NF)

2NF устраняет частичные зависимости — каждый неключевой столбец должен зависеть от ВСЕГО первичного ключа, а не только от его части. Это важно только с композитными ключами. 3NF устраняет транзитивные зависимости — неключевые столбцы должны зависеть только от первичного ключа, а не от других неключевых столбцов. Например, customer_name зависит от customer_id, который зависит от order_id (транзитивно). Нормализация снижает избыточность данных (храните каждый факт один раз) и аномалии (обновляйте имя клиента в одном месте, а не в каждом заказе). Большинство практических баз данных стремятся к 3NF или BCNF.

sql
-- 2NF: 1NF + no partial dependencies (non-key attrs depend on FULL key)
-- Problem: order_items has composite key (order_id, product_id)
-- but product_name depends only on product_id (partial dependency)

-- VIOLATION of 2NF:
CREATE TABLE bad_order_items (
    order_id INT,
    product_id INT,
    product_name VARCHAR(100),  -- depends on product_id only!
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 2NF COMPLIANT: move product_name to products table
CREATE TABLE products (
    product_id INT PRIMARY KEY,
    product_name VARCHAR(100)
);
CREATE TABLE order_items (
    order_id INT,
    product_id INT REFERENCES products(product_id),
    quantity INT,
    PRIMARY KEY (order_id, product_id)
);

-- 3NF: 2NF + no transitive dependencies
-- (non-key attrs don't depend on other non-key attrs)
-- Problem: orders table has customer_name that depends on customer_id
-- Solution: separate customers table (see above)

Денормализация (когда нарушать правила)

Денормализация намеренно нарушает нормальные формы для улучшения производительности чтения за счёт сложности записи и хранения. В нормализованных базах данных извлечение полного заказа требует 4 JOIN — дорого для высоконагруженных дашбордов. Денормализованные таблицы предварительно соединяют и вычисляют данные для быстрого чтения. Компромисс: записи должны обновлять несколько мест (риск несогласованности), а хранение увеличивается. Используйте денормализацию для систем с интенсивным чтением (аналитика, отчётность, хранилища данных). Материализованные представления обеспечивают управляемую денормализацию — база данных обрабатывает обновление. OLTP-системы должны оставаться нормализованными; OLAP-системы обычно денормализованы (схемы звезда/снежинка).

sql
-- Denormalization: intentionally adding redundancy for performance
-- Normalized (3NF): requires JOINs to get full order info
SELECT o.order_id, c.name, p.product_name, oi.quantity, p.price
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.id;

-- Denormalized: store redundant data for read speed
CREATE TABLE order_summary (
    order_id INT PRIMARY KEY,
    customer_id INT,
    customer_name VARCHAR(100),    -- redundant (also in customers)
    customer_email VARCHAR(200),   -- redundant
    total_amount DECIMAL(10,2),    -- pre-calculated
    item_count INT,                -- pre-calculated
    order_date TIMESTAMP
);

-- Trade-off: faster reads, slower writes, risk of inconsistency
-- Use for: reporting tables, read-heavy dashboards, data warehouses

-- Materialized views are a managed form of denormalization:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, region, SUM(amount) AS total FROM sales GROUP BY date, region;
REFRESH MATERIALIZED VIEW sales_summary;

Первичные ключи, внешние ключи и ограничения

Ограничения обеспечивают целостность данных на уровне базы данных. PRIMARY KEY уникально идентифицирует строки и создаёт кластерный индекс. UNIQUE предотвращает дубликаты (допускает несколько NULL в большинстве баз данных). CHECK обеспечивает пользовательские правила (salary > 0). FOREIGN KEY поддерживает ссылочную целостность — ON DELETE SET NULL/CASCADE/RESTRICT контролирует, что происходит при удалении родительской строки. ON UPDATE CASCADE распространяет изменения PK на FK. Ограничения — последняя линия защиты от плохих данных — даже если в коде приложения есть ошибки, база данных отклоняет некорректные данные. Всегда определяйте ограничения; они — документация и обеспечение вместе.

sql
CREATE TABLE departments (
    dept_id INT PRIMARY KEY AUTO_INCREMENT,
    dept_name VARCHAR(100) NOT NULL UNIQUE,
    budget DECIMAL(12,2) CHECK (budget >= 0),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE employees (
    emp_id INT PRIMARY KEY AUTO_INCREMENT,
    emp_name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE,
    dept_id INT,
    salary DECIMAL(10,2) CHECK (salary > 0 AND salary < 1000000),
    hire_date DATE NOT NULL,
    manager_id INT REFERENCES employees(emp_id),  -- self-reference
    FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
        ON DELETE SET NULL      -- don't delete dept if employees exist
        ON UPDATE CASCADE,      -- update FK if dept_id changes
    INDEX idx_dept (dept_id),
    INDEX idx_email (email)
);

-- Constraint types:
-- PRIMARY KEY: unique + not null (clustered index)
-- UNIQUE: no duplicates (allows NULLs)
-- NOT NULL: required field
-- CHECK: custom condition
-- FOREIGN KEY: referential integrity
-- DEFAULT: value when not specified

Стратегия индексирования

Индексы значительно ускоряют чтение, но замедляют запись (каждый индекс должен обновляться при INSERT/UPDATE/DELETE). B-tree индексы поддерживают равенство, диапазон и сортировку. Композитные индексы следуют правилу крайнего левого префикса — можно использовать (a, b) для запросов по a или a+b, но не по b отдельно. Покрывающие индексы (предложение INCLUDE) хранят дополнительные столбцы, чтобы запрос не касался таблицы — крайне быстро. Частичные индексы индексируют только подмножество строк, экономя место. Мониторьте использование индексов (pg_stat_user_indexes в PostgreSQL) и удаляйте неиспользуемые. Хорошее правило: индексируйте внешние ключи и столбцы в WHERE/JOIN. Чрезмерное индексирование вредит производительности записи и тратит хранилище.

sql
-- B-tree index (default): good for =, <, >, BETWEEN, ORDER BY
CREATE INDEX idx_last_name ON employees(last_name);

-- Composite index: order matters! (leftmost prefix rule)
CREATE INDEX idx_dept_salary ON employees(dept_id, salary);
-- Usable for: WHERE dept_id = 5
-- Usable for: WHERE dept_id = 5 AND salary > 50000
-- NOT usable for: WHERE salary > 50000 (skips dept_id)

-- Covering index: includes all columns a query needs
CREATE INDEX idx_covering ON orders(customer_id, order_date)
    INCLUDE (total_amount, status);
-- Query can be satisfied from index alone (no table lookup)

-- Partial/partial index: index only matching rows
CREATE INDEX idx_active_users ON users(last_login)
    WHERE active = true;  -- PostgreSQL

-- Don't over-index: every index slows writes
-- Index columns used in: WHERE, JOIN, ORDER BY, GROUP BY
-- Drop unused indexes: SELECT * FROM pg_stat_user_indexes;
17

EXPLAIN и оптимизация запросов

Чтение вывода EXPLAIN

EXPLAIN показывает, как база данных выполняет запрос — какие индексы используются, как таблицы соединяются и сколько строк проверяется. EXPLAIN ANALYZE (PostgreSQL) или EXPLAIN с выполнением (MySQL 8.0+) фактически запускает запрос и показывает реальные тайминги. Ищите: Seq Scan / ALL (полное сканирование таблицы — плохо для больших таблиц), Index Scan (хорошо), оценку строк (высокая = дорого). 'Using filesort' или 'Using temporary' в MySQL указывает на дополнительную работу. Если EXPLAIN показывает полное сканирование таблицы на большой таблице, нужен индекс. Всегда выполняйте EXPLAIN перед оптимизацией — не угадывайте.

sql
-- EXPLAIN shows the query execution plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;

-- EXPLAIN ANALYZE actually runs the query (PostgreSQL)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- PostgreSQL output:
-- Index Scan using idx_customer on orders  (cost=0.29..8.31 rows=1 width=74)
--   Index Cond: (customer_id = 42)
--   Execution Time: 0.042 ms

-- Key columns to check:
-- - type: scan method (const > eq_ref > ref > range > index > ALL)
-- - rows: estimated rows examined (lower is better)
-- - key: which index is used (NULL = no index, bad!)
-- - Extra: "Using filesort" or "Using temporary" = warning signs

-- MySQL: EXPLAIN FORMAT=JSON for detailed output
-- SQL Server: SET SHOWPLAN_TEXT ON; or Actual Execution Plan in SSMS

Распространённые проблемы производительности

Несколько распространённых шаблонов препятствуют использованию индексов и вызывают полное сканирование таблицы. Функции на индексированных столбцах (YEAR(date), UPPER(name)) препятствуют использованию индексов — перепишите как условия диапазона. Ведущие wildcards в LIKE ('%pattern') не могут использовать B-tree индексы — используйте полнотекстовый поиск. SELECT * тратит пропускную способность и препятствует оптимизации покрывающего индекса. Неявные преобразования типов (сравнение строкового столбца с целым) могут отключить индексы. Условия OR иногда менее эффективны, чем IN. Всегда проверяйте с EXPLAIN, что ваши индексы фактически используются — неиспользуемый индекс — wasted storage и накладные расходы на запись.

sql
-- 1. Missing index → full table scan
-- Bad: SELECT * FROM orders WHERE customer_id = 42; (no index)
-- Fix: CREATE INDEX idx_customer ON orders(customer_id);

-- 2. Index not used due to function on column
-- Bad: WHERE YEAR(order_date) = 2024  (function prevents index use)
-- Good: WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'

-- 3. SELECT * instead of specific columns
-- Bad: SELECT * FROM large_table WHERE id = 1;
-- Good: SELECT id, name, email FROM large_table WHERE id = 1;

-- 4. OR conditions preventing index use
-- Bad: WHERE dept = 'A' OR dept = 'B' OR dept = 'C'
-- Good: WHERE dept IN ('A', 'B', 'C')

-- 5. LIKE with leading wildcard
-- Bad: WHERE name LIKE '%son'  (can't use index)
-- OK:  WHERE name LIKE 'John%'  (can use index)

-- 6. Implicit type conversion
-- Bad: WHERE string_column = 123  (converts to string, skips index)
-- Good: WHERE string_column = '123'

Оптимизация JOIN

Оптимизация JOIN критична для запросов к нескольким таблицам. Убедитесь, что столбцы соединения (обычно внешние ключи) индексированы — неиндексированные join'ы вызывают вложенные циклы сканирования (O(n*m)). Оптимизатор запросов обычно выбирает лучший порядок join, но вы можете помочь, фильтруя рано (концептуально WHERE перед JOIN). INNER JOIN быстрее OUTER JOIN, когда не нужны несовпадающие строки. EXISTS часто эффективнее IN для коррелированных подзапросов, так как он короткозамкнут на первом совпадении. Избегайте join'а таблиц, которые не нужны — каждый join умножает работу. Для сложных отчётов рассмотрите материализованные представления или предварительно агрегированные сводные таблицы.

sql
-- Join order matters: smallest table first (optimizer usually handles this)
-- Ensure join columns are indexed (usually foreign keys)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Use INNER JOIN when you don't need unmatched rows (faster than OUTER)
SELECT c.name, COUNT(o.order_id) AS order_count
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- Avoid joining unnecessary tables — fetch details lazily if needed
-- Bad: join 5 tables when you only need 2 columns
-- Good: split into simpler queries or use a covering index

-- EXISTS vs IN for subqueries
-- EXISTS is often faster for large subquery results:
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- IN is better for small lists:
SELECT * FROM orders WHERE customer_id IN (1, 2, 3);

Оптимизация пагинации

Пагинация на основе OFFSET (LIMIT 10 OFFSET 10000) имеет сложность O(n) — база данных должна сканировать и отбросить все пропущенные строки, делая глубокие страницы крайне медленными. Пагинация по ключу (курсором) использует WHERE last_value < cursor для прямого поиска — O(1) независимо от глубины страницы. Это требует индекса на столбце сортировки. Для связей (одинаковая метка времени) используйте композитный курсор (created_at, id). Избегайте COUNT(*) для общего количества на больших таблицах — он сканирует всю таблицу. Используйте приблизительные подсчёты (pg_class.reltuples в PostgreSQL) или вообще не показывайте общее количество (бесконечная прокрутка). Пагинация по ключу — стандарт для высокопроизводительных API.

sql
-- Bad: OFFSET pagination (slow for large offsets)
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10000;
-- Must scan and discard 10000 rows — gets slower as you page deeper

-- Good: Keyset (cursor) pagination using WHERE
SELECT * FROM orders
WHERE created_at < '2024-01-15 10:30:00'  -- last seen value
ORDER BY created_at DESC
LIMIT 10;
-- Uses index efficiently — constant time regardless of page depth

-- For composite keyset pagination (handles ties):
SELECT * FROM orders
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Count total (expensive on large tables — avoid if possible)
-- Instead of COUNT(*), use an approximate count:
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';

Переписывание запросов и чек-лист оптимизации

Оптимизация запросов — итеративный процесс: EXPLAIN, идентификация узких мест, переписывание, повтор. Ключевые техники: заменяйте IN-подзапросы на JOIN (часто быстрее), используйте UNION ALL вместо UNION (пропускает сортировку дедупликации), пакетные INSERT (1 запрос против 1000), используйте подготовленные операторы (кэширует план запроса). CTE улучшают читаемость, но в старых версиях PostgreSQL они материализуются (не могут быть оптимизированы) — PostgreSQL 12+ инлайнит их. Поддерживайте актуальную статистику таблиц (ANALYZE), чтобы планировщик принимал хорошие решения. Золотое правило: измеряйте с EXPLAIN ANALYZE, не угадывайте. Что быстро на одной базе/версии, может быть медленным на другой.

sql
-- 1. Replace subqueries with JOINs when possible
-- Slow: SELECT * FROM orders WHERE customer_id IN
--       (SELECT id FROM customers WHERE active = true);
-- Fast: SELECT o.* FROM orders o
--       JOIN customers c ON o.customer_id = c.id WHERE c.active = true;

-- 2. Use UNION ALL instead of UNION (avoids dedup sort)
SELECT 'A' UNION ALL SELECT 'B';  -- fast
SELECT 'A' UNION SELECT 'B';       -- sorts to remove duplicates

-- 3. Batch operations instead of row-by-row
-- Slow: 1000 individual INSERTs
-- Fast: INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c'), ...;

-- 4. Use CTEs for readability, but know they may not optimize well
-- (PostgreSQL 12+ inlines CTEs; older versions materialize them)

-- 5. Avoid SELECT DISTINCT when you can use GROUP BY or EXISTS
-- 6. Use prepared statements for repeated queries (plan caching)
PREPARE get_user AS SELECT * FROM users WHERE id = $1;
EXECUTE get_user(42);

-- 7. Analyze tables for up-to-date statistics
ANALYZE orders;  -- PostgreSQL: updates planner statistics
18

Сравнение NoSQL и SQL

SQL vs NoSQL: когда что использовать

Выбор SQL vs NoSQL зависит от модели данных, требований к согласованности и масштаба. Базы данных SQL обеспечивают схему, поддерживают ACID-транзакции и превосходны в сложных запросах с JOIN — идеальны для финансовых систем и любых приложений, где целостность данных критична. Базы данных NoSQL торгуют согласованностью ради масштабируемости и гибкости: документные хранилища (MongoDB) для эволюционирующих схем, key-value хранилища (Redis) для кэширования, column-family (Cassandra) для массивной пропускной способности записи и графовые базы данных (Neo4j) для данных с большим количеством связей. Современные базы данных SQL теперь поддерживают JSON, полнотекстовый поиск и масштабирование, снижая потребность в NoSQL во многих случаях.

sql
-- SQL (Relational): structured, consistent, queryable
-- Best for: financial systems, e-commerce, CRM, any ACID-requiring app
-- Examples: PostgreSQL, MySQL, SQL Server, Oracle

-- NoSQL: flexible schema, horizontal scaling, specific data models
-- Document: MongoDB, CouchDB — JSON-like documents, flexible schema
-- Key-Value: Redis, DynamoDB — fast lookups, caching, sessions
-- Column-Family: Cassandra, HBase — wide-column, time-series, write-heavy
-- Graph: Neo4j, ArangoDB — relationships, social networks, recommendations

-- Decision factors:
-- 1. Data structure: fixed → SQL, evolving/varied → NoSQL
-- 2. Consistency: strict ACID → SQL, eventual consistency OK → NoSQL
-- 3. Scale: vertical (bigger server) → SQL, horizontal (more servers) → NoSQL
-- 4. Queries: complex JOINs → SQL, simple lookups → NoSQL
-- 5. Team expertise: SQL is universal, NoSQL varies

-- Many modern databases blur the line:
-- PostgreSQL: JSON columns, full-text search, pub/sub
-- MongoDB: transactions (multi-document ACID since 4.0)

Шаблоны документного хранилища (в стиле MongoDB)

Документные хранилища встраивают связанные данные в один документ, а не нормализуют по таблицам. Это устраняет JOIN для шаблонов доступа с интенсивным чтением, но дублирует данные (информация о клиенте в каждом заказе). Встраивание работает, когда данные запрашиваются вместе и имеют ограниченный размер. Для неограниченных отношений (клиент с тысячами заказов) используйте ссылки (храните customer_id, извлекайте отдельно). Столбцы JSONB в PostgreSQL дают гибкость документного хранилища в реляционной базе — вы получаете ACID-транзакции, индексацию (GIN) и SQL-запросы к JSON. Этот гибридный подход становится всё более популярным, снижая потребность в отдельной NoSQL-базе.

sql
-- In SQL, you'd normalize this into 3 tables:
-- customers, orders, order_items

-- In a document store (MongoDB), you might embed everything:
-- (pseudo-code, not SQL)
// db.orders.insertOne({
//   _id: 1,
//   customer: { name: "Alice", email: "[email protected]" },
//   items: [
//     { product: "Laptop", price: 999, qty: 1 },
//     { product: "Mouse", price: 25, qty: 2 }
//   ],
//   total: 1049,
//   status: "shipped",
//   created_at: ISODate("2024-01-15")
// })

-- SQL equivalent with JSON column (PostgreSQL):
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    data JSONB NOT NULL  -- stores the entire document
);
INSERT INTO orders (data) VALUES ('{
    "customer": {"name": "Alice", "email": "[email protected]"},
    "items": [{"product": "Laptop", "price": 999, "qty": 1}],
    "total": 999
}');

-- Query JSON data:
SELECT data->'customer'->>'name' AS name FROM orders
WHERE data->'total' > 500;

Шаблоны key-value хранилища (в стиле Redis)

Key-value хранилища вроде Redis превосходны в сверхбыстрых lookup'ах (субмиллисекунда), так как данные живут в памяти. Распространённые применения: кэширование дорогих результатов запросов, хранение сессий (с истечением TTL), счётчики в реальном времени (атомарный INCR) и таблицы лидеров (sorted set'ы). Структуры данных Redis (list'ы, set'ы, sorted set'ы, hash'и) выходят за рамки простого key-value. Компромисс: данные в памяти (ограничено RAM) и персистентность опциональна. Используйте Redis как слой кэша перед SQL — шаблоны write-through или cache-aside. Для данных сессий автоматическое истечение Redis (TTL) идеально. Базы данных SQL могут эмулировать кэширование через таблицу кэша, но не могут сравниться со скоростью Redis для горячих данных.

sql
-- Redis is an in-memory key-value store — not SQL
-- Common patterns (Redis commands, not SQL):

-- Caching: store expensive query results
// SET user:42:profile '{"name":"Alice","age":30}' EX 3600
// GET user:42:profile  -- returns cached data, expires in 1 hour

-- Session storage: fast, ephemeral
// SET session:abc123 '{"user_id":42}' EX 1800  -- 30 min TTL

-- Counters & leaderboards:
// INCR page:home:views           -- atomic counter
// ZADD leaderboard 1500 "alice"  -- sorted set for rankings
// ZREVRANGE leaderboard 0 9      -- top 10 players

-- SQL equivalent for caching (materialized/precomputed):
CREATE TABLE cache (
    cache_key VARCHAR(200) PRIMARY KEY,
    cache_value TEXT,
    expires_at TIMESTAMP,
    INDEX idx_expires (expires_at)
);
-- Periodically: DELETE FROM cache WHERE expires_at < NOW();

-- When to use Redis vs SQL cache:
-- Redis: sub-millisecond reads, data structures (sets, sorted sets)
-- SQL: when you need ACID or already have a database connection

Полиглотная персистентность (смешивание баз данных)

Полиглотная персистентность использует разные базы данных для разных потребностей в данных в одном приложении. PostgreSQL обрабатывает транзакции, Redis — кэширование, Elasticsearch — поиск, S3 — файлы. Задача — сохранять данные согласованными между хранилищами — решение — событийно-ориентированная архитектура: пишите в основную базу данных (источник истины), затем асинхронно распространяйте изменения в другие хранилища через Change Data Capture (CDC) или очереди сообщений (Kafka, RabbitMQ). Это обеспечивает eventual consistency — чтение из вторичных хранилищ может немного отставать. Преимущество: каждое хранилище оптимизировано для своей нагрузки. Цена: операционная сложность. Начните с одной SQL-базы; добавляйте специализированные хранилища только при явных ограничениях производительности.

sql
-- Modern applications often use MULTIPLE database types:
-- Each data store handles what it does best

-- Typical architecture:
-- 1. PostgreSQL: core transactional data (users, orders, payments)
--    ACID guarantees, complex queries, foreign keys
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR UNIQUE);
CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id));

-- 2. Redis: session storage, caching, rate limiting
--    Fast in-memory access, auto-expiring keys

-- 3. Elasticsearch: full-text search, log analytics
--    Inverted index, faceted search, aggregations

-- 4. S3/Object storage: files, images, backups
--    Cheap, unlimited, HTTP-accessible

-- 5. TimescaleDB/InfluxDB: time-series metrics
--    Optimized for timestamped data, downsampling

-- Challenge: data consistency across stores
-- Solution: event-driven architecture (CDC, message queues)
--   1. Write to PostgreSQL (source of truth)
--   2. Publish event to Kafka
--   3. Consumers update Redis cache, Elasticsearch index, etc.
--   4. eventual consistency — reads may be slightly stale

Модели согласованности ACID vs BASE

ACID (Атомарность, Согласованность, Изолированность, Долговечность) гарантирует строгую согласованность — транзакции все-или-ничего, и данные всегда удовлетворяют ограничениям. Это необходимо для финансовых систем, где частичные обновления вызвали бы ошибки. BASE (Basically Available, Soft state, Eventually consistent) торгует немедленной согласованностью за доступность и толерантность к разделению — данные могут быть временно несогласованными, но сходятся со временем. Теорема CAP утверждает, что нельзя иметь все три (Согласованность, Доступность, Толерантность к разделению) одновременно при сетевых разделениях. Базы данных SQL приоритизируют C+A (один узел) или C+P (распределённые). Многие базы данных NoSQL приоритизируют A+P (Cassandra, DynamoDB). Выбирайте ACID, когда критична корректность; BASE, когда доступность и масштаб важнее.

sql
-- ACID (SQL databases):
-- Atomicity: all operations in a transaction succeed or fail together
-- Consistency: data always satisfies constraints (FK, CHECK, etc.)
-- Isolation: concurrent transactions don't interfere
-- Durability: committed data survives crashes

-- SQL transaction example:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If either fails, ROLLBACK undoes both
COMMIT;  -- both updates are permanent

-- BASE (NoSQL databases):
-- Basically Available: system responds (may be stale)
-- Soft state: state changes without input (eventual consistency)
-- Eventually consistent: data converges over time

-- NoSQL trade-off: higher availability & partition tolerance
-- at the cost of immediate consistency

-- CAP Theorem: in a network partition, choose:
-- - CP (consistency): refuse writes (some NoSQL: HBase, MongoDB)
-- - AP (availability): accept writes, reconcile later (Cassandra, DynamoDB)

-- SQL databases are typically CA (consistent + available, no partition tolerance
-- in single-node setups; distributed SQL like CockroachDB adds P)
19

CTE и рекурсивные CTE

Базовый CTE

CTE (Common Table Expression) — временный именованный набор результатов. Улучшает читаемость, разбивая сложные запросы. Несколько CTE могут быть объединены запятыми. CTE действительны только для одного оператора.

sql
WITH high_earners AS (
  SELECT * FROM employees WHERE salary > 80000
), by_dept AS (
  SELECT department, COUNT(*) AS cnt FROM high_earners GROUP BY department
)
SELECT * FROM by_dept ORDER BY cnt DESC;

Рекурсивный CTE

Рекурсивные CTE ссылаются на самих себя. Якорь — базовый случай. UNION ALL соединяет с рекурсивной частью. Используется для иерархических данных: орг-диаграммы, файловые системы, обход графов. Должно быть условие завершения.

sql
WITH RECURSIVE org_chart AS (
  -- Anchor: top-level managers
  SELECT id, name, manager_id, 1 AS level
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  -- Recursive: subordinates
  SELECT e.id, e.name, e.manager_id, oc.level + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart;

Фибоначчи с CTE

Рекурсивные CTE могут генерировать последовательности. Якорь предоставляет первое значение. Каждая итерация вычисляет следующее. Предложение WHERE предотвращает бесконечную рекурсию. Полезно для математических последовательностей.

sql
WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10
)
SELECT n, a FROM fib;
-- Result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Обход дерева

Стройте пути путём конкатенации имён в каждой рекурсии. CAST гарантирует, что столбец path достаточно широкий. Полезно для хлебных крошек, файловых путей и иерархий категорий. ORDER BY path сортирует иерархически.

sql
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, CAST(name AS VARCHAR(1000)) AS path
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.path || ' > ' || c.name
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, path FROM category_tree ORDER BY path;

CTE vs подзапрос

CTE улучшают читаемость и могут ссылаться несколько раз. Подзапросы встроенные и не могут быть переиспользованы. CTE не всегда материализуются; оптимизатор может инлайнить их. Используйте CTE для ясности.

sql
-- CTE: more readable, can be referenced multiple times
WITH active_users AS (SELECT * FROM users WHERE active = 1)
SELECT * FROM active_users WHERE age > 18
UNION ALL
SELECT * FROM active_users WHERE age <= 18;
-- Subquery: inline, cannot be reused
SELECT * FROM (SELECT * FROM users WHERE active = 1) au WHERE au.age > 18;
20

Углублённое изучение индексов

B-Tree индекс

B-Tree — тип индекса по умолчанию. Композитные индексы следуют правилу крайнего левого префикса: запрос может использовать индекс, если фильтрует по ведущим столбцам. Упорядочивайте столбцы по селективности и шаблонам запросов.

sql
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(last_name, first_name, age);
-- Composite index: useful for
-- WHERE last_name = 'Smith' AND first_name = 'John'
-- WHERE last_name = 'Smith' (leftmost prefix)

Частичный индекс

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

sql
CREATE INDEX idx_active_users ON users(last_login) WHERE active = 1;
-- Only indexes active users, saving space
-- Useful when queries always filter on active = 1

Покрывающий индекс

Покрывающий индекс включает все столбцы, необходимые запросу, обеспечивая index-only scan. PostgreSQL использует INCLUDE для неключевых столбцов. Значительно ускоряет SELECT-запросы, избегая обращений к таблице.

sql
-- PostgreSQL: INCLUDE clause
CREATE INDEX idx_users_covering ON users(last_name) INCLUDE (first_name, email);
-- The query is "covered" if all columns are in the index:
SELECT first_name, email FROM users WHERE last_name = 'Smith';
-- No table lookup needed (index-only scan)

Типы индексов

Разные типы индексов служат разным целям. B-Tree для общего использования. Hash только для равенства. GIN для полнотекстового поиска и JSON. GiST для геометрических данных. Выбирайте на основе шаблонов запросов.

sql
-- B-Tree: default, good for equality and range
CREATE INDEX idx_btree ON users(email);
-- Hash: equality only (PostgreSQL)
CREATE INDEX idx_hash ON users(email) USING HASH;
-- GIN: full-text search, arrays, JSON
CREATE INDEX idx_gin ON docs USING GIN (tsv);
-- GiST: geometric, range types
CREATE INDEX idx_gist ON places USING GIST (location);

Обслуживание индексов

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

sql
-- Check index usage (PostgreSQL)
SELECT * FROM pg_stat_user_indexes;
-- Find unused indexes
SELECT relname, indexrelname FROM pg_stat_user_indexes WHERE idx_scan = 0;
-- Rebuild fragmented index
REINDEX INDEX idx_users_email;
-- Analyze for query planner
ANALYZE users;
21

Транзакции

Свойства ACID

ACID: Атомарность (всё или ничего), Согласованность (корректное состояние), Изолированность (конкурентные транзакции не мешают друг другу), Долговечность (зафиксированные данные сохраняются). BEGIN начинает, COMMIT сохраняет, ROLLBACK отменяет.

sql
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Or: ROLLBACK to undo

Точки сохранения

Точки сохранения создают частичные точки отката в транзакции. ROLLBACK TO отменяет до точки сохранения без завершения транзакции. Полезно для обработки ошибок в многошаговых операциях без перезапуска.

sql
BEGIN;
INSERT INTO orders VALUES (1);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2);
-- Oops, rollback to savepoint
ROLLBACK TO sp1;
-- Only order 1 is inserted
INSERT INTO orders VALUES (3);
COMMIT;

Уровни изоляции

Уровни изоляции балансируют согласованность и производительность. READ COMMITTED (по умолчанию) предотвращает грязное чтение. REPEATABLE READ предотвращает неповторяющееся чтение. SERIALIZABLE предотвращает фантомное чтение, но самый медленный.

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Levels (increasing isolation):
-- READ UNCOMMITTED: dirty reads
-- READ COMMITTED: no dirty reads (default)
-- REPEATABLE READ: no non-repeatable reads
-- SERIALIZABLE: full isolation

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

Взаимные блокировки возникают, когда транзакции удерживают блокировки, нужные друг другу. Базы данных обнаруживают взаимные блокировки и прерывают одну транзакцию. Предотвращайте доступом к таблицам в согласованном порядке. Держите транзакции короткими.

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
UPDATE accounts SET balance = 0 WHERE id = 2;  -- Waits
-- Transaction 2
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 2;
UPDATE accounts SET balance = 0 WHERE id = 1;  -- Waits
-- Deadlock! Database aborts one transaction

Оптимистическая блокировка

Оптимистическая блокировка предполагает, что конфликты редки. Столбец версии отслеживает изменения. Если UPDATE затрагивает 0 строк, данные были изменены другой транзакцией. Повторите или уведомите пользователя. Избегает долгого удержания блокировок.

sql
-- Add version column
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- Update with version check
UPDATE products SET price = 100, version = version + 1
WHERE id = 1 AND version = 5;
-- If 0 rows affected, someone else updated first
22

JSON в SQL

PostgreSQL JSONB

JSONB хранит JSON в бинарном формате, обеспечивая индексацию и быстрые запросы. ->> извлекает как текст, -> извлекает как JSON. JSONB предпочтительнее JSON для запросов. Используйте GIN-индексы для столбцов JSONB.

sql
CREATE TABLE events (id SERIAL, data JSONB);
INSERT INTO events (data) VALUES ('{"user": "alice", "action": "login"}');
SELECT data->>'user' AS user_name FROM events;
SELECT * FROM events WHERE data->>'action' = 'login';

JSON-запросы

-> навигирует по JSON, ->> возвращает текст. @> проверяет вхождение. jsonb_set обновляет вложенные значения. jsonb_object_keys возвращает ключи верхнего уровня. Эти операторы обеспечивают мощные JSON-запросы.

sql
SELECT data->'address'->'city' AS city FROM users;
SELECT * FROM users WHERE data @> '{"role": "admin"}';
SELECT jsonb_object_keys(data) FROM users;
-- Update JSON
UPDATE users SET data = jsonb_set(data, '{last_login}', '"2024-01-01"');

JSON-агрегация

json_agg агрегирует строки в JSON-массив. json_build_object конструирует JSON-объекты из столбцов. Полезно для генерации ответов API напрямую из SQL. Объединяет реляционные и документные данные.

sql
SELECT department,
  json_agg(json_build_object('name', name, 'salary', salary)) AS employees
FROM employees
GROUP BY department;
-- Result: {"department": "Eng", "employees": [{"name": "Alice", "salary": 90000}, ...]}

MySQL JSON

MySQL использует синтаксис $.path для JSON. JSON_EXTRACT получает значения, JSON_SET обновляет. ->> — сокращение для JSON_EXTRACT с текстовым результатом. MySQL JSON проверяется при вставке.

sql
CREATE TABLE config (id INT, settings JSON);
INSERT INTO config VALUES (1, '{"theme": "dark", "lang": "en"}');
SELECT settings->>'$.theme' FROM config;
SELECT * FROM config WHERE JSON_EXTRACT(settings, '$.lang') = 'en';
-- Update
UPDATE config SET settings = JSON_SET(settings, '$.theme', 'light');

JSON-индексы

GIN-индексы на JSONB обеспечивают быстрый запрос любого ключа. Индексы выражений на конкретных путях меньше и быстрее для целенаправленных запросов. Индексируйте часто запрашиваемые JSON-пути для производительности.

sql
-- PostgreSQL GIN index on JSONB
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Index specific path
CREATE INDEX idx_events_user ON events ((data->>'user'));
-- MySQL functional index
CREATE INDEX idx_theme ON config ((CAST(settings->>'$.theme' AS CHAR(50))));
23

Настройка производительности

EXPLAIN ANALYZE

EXPLAIN показывает план запроса; ANALYZE выполняет его с таймингом. Seq Scan указывает на отсутствующий индекс. Index Scan идеален. Ищите высокие числа стоимости и медленные операции. Всегда EXPLAIN перед оптимизацией.

sql
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';
-- Shows: scan type, cost, rows, actual time
-- Seq Scan: full table scan (slow)
-- Index Scan: uses index (fast)
-- Bitmap Heap Scan: index + table lookup

Оптимизация запросов

Выбирайте только нужные столбцы для снижения I/O. Избегайте функций на индексированных столбцах (non-sargable). Sargable (Search Argument Able) запросы могут использовать индексы. Используйте условия диапазона вместо функций.

sql
-- BAD: SELECT * fetches all columns
SELECT * FROM users;
-- GOOD: select only needed columns
SELECT id, name FROM users;
-- BAD: function on indexed column
WHERE YEAR(created_at) = 2024
-- GOOD: sargable
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'

Оптимизация JOIN

Индексируйте все столбцы соединения. Оптимизатор выбирает порядок join на основе статистики. INNER JOIN обычно самый быстрый. Избегайте join на выражениях. Для больших наборов данных рассмотрите денормализацию или материализованные представления.

sql
-- Use INNER JOIN for required relationships
SELECT u.name, o.total FROM users u
INNER JOIN orders o ON u.id = o.user_id;
-- Index join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- For large joins, ensure both columns are indexed

Пагинация

OFFSET-пагинация имеет сложность O(n) — она сканирует все пропущенные строки. Пагинация по ключу (курсором) — O(1) — использует индекс. Используйте кортежное сравнение для стабильной сортировки. Гораздо быстрее для глубокой пагинации.

sql
-- BAD: OFFSET scans all skipped rows
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- GOOD: keyset pagination
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- Stable pagination with cursor
SELECT * FROM users WHERE (created_at, id) > ('2024-01-01', 100) ORDER BY created_at, id LIMIT 10;

Материализованные представления

Материализованные представления физически хранят результаты запроса. Быстрее, чем представления для дорогих агрегаций. REFRESH обновляет данные (конкурентно с опцией CONCURRENTLY). Индексируйте их для быстрых запросов.

sql
CREATE MATERIALIZED VIEW sales_summary AS
SELECT product_id, SUM(quantity) AS total, AVG(price) AS avg_price
FROM sales GROUP BY product_id;
-- Refresh periodically
REFRESH MATERIALIZED VIEW sales_summary;
-- Create index on materialized view
CREATE INDEX ON sales_summary (total);
24

Продвинутые JOIN

Self Join

Self join запрашивает таблицу против самой себя. Используйте алиасы для различения. Распространено для иерархических данных (сотрудник-менеджер) и поиска пар. Трюк a.id < b.id избегает дубликатов пар.

sql
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- Find pairs in same department
SELECT a.name, b.name FROM employees a, employees b
WHERE a.department = b.department AND a.id < b.id;

Cross Join

CROSS JOIN создаёт декартово произведение: каждая строка A комбинируется с каждой строкой B. Полезно для генерации комбинаций. Будьте осторожны: может создавать огромные наборы результатов. Часто используется неявно с синтаксисом запятой.

sql
-- Cartesian product: every combination
SELECT s.size, c.color FROM sizes s CROSS JOIN colors c;
-- Generate all size/color combinations
-- Useful for generating test data or matrices

FULL OUTER JOIN

FULL OUTER JOIN возвращает все строки из обеих таблиц. NULL заполняют несовпадающие стороны. Полезно для поиска несовпадающих записей в обоих направлениях. Не поддерживается в MySQL (эмулируйте через UNION LEFT и RIGHT join).

sql
SELECT u.name, o.order_id
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
-- Returns all users and all orders
-- NULLs where there is no match

Anti-Join

Anti-join находит строки в A, которые не совпадают с B. NOT EXISTS обычно самый ясный и часто самый быстрый. LEFT JOIN с IS NULL — альтернатива. Используйте для поиска отсутствующих связей.

sql
-- Users who have never ordered
SELECT u.* FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: LEFT JOIN ... WHERE IS NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;

Semi-Join

Semi-join возвращает строки из A, которые совпадают хотя бы с одной строкой в B. EXISTS эффективен, так как останавливается на первом совпадении. IN эквивалентен, но может работать иначе. Используйте EXISTS для коррелированных подзапросов.

sql
-- Users who have at least one order
SELECT u.* FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Alternative: IN
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
25

Распространённые подводные камни

Сравнения с NULL

NULL — это неизвестность, а не значение. = NULL всегда возвращает NULL (трактуется как false). Используйте IS NULL и IS NOT NULL. NULL распространяется через арифметику. Используйте COALESCE для предоставления значений по умолчанию.

sql
-- NULL = NULL is NULL (not true!)
SELECT * FROM users WHERE phone = NULL;  -- Returns nothing
SELECT * FROM users WHERE phone IS NULL;  -- Correct
SELECT * FROM users WHERE phone IS NOT NULL;
-- NULL in arithmetic yields NULL
SELECT 5 + NULL;  -- NULL

SQL-инъекции

SQL-инъекции позволяют злоумышленникам выполнять произвольный SQL. Никогда не конкатенируйте пользовательский ввод в запросы. Всегда используйте параметризованные запросы/подготовленные операторы. Проверяйте и очищайте все входные данные. Используйте привязку параметров ORM.

sql
-- BAD: string concatenation
query = "SELECT * FROM users WHERE name = '" + input + "'"
-- GOOD: parameterized queries
SELECT * FROM users WHERE name = ?;
-- PostgreSQL: $1
-- MySQL: ?
-- Always use parameters, never concatenate

Подводные камни GROUP BY

При использовании GROUP BY все неагрегированные столбцы в SELECT должны быть в GROUP BY. Иначе результат неоднозначен. MySQL допускает это (возвращает произвольное значение), но это некорректно. Всегда следуйте стандарту.

sql
-- BAD: non-aggregated column not in GROUP BY
SELECT department, name, COUNT(*) FROM employees GROUP BY department;
-- Error: which name to show?
-- GOOD: aggregate or include in GROUP BY
SELECT department, COUNT(*) FROM employees GROUP BY department;
SELECT department, MAX(name) FROM employees GROUP BY department;

Плавающая точка

FLOAT и DOUBLE — приблизительные типы. Используйте DECIMAL/NUMERIC для точной точности (деньги, измерения). DECIMAL(10,2) допускает 10 цифр с 2 после десятичной точки. Никогда не используйте FLOAT для финансовых данных.

sql
-- Floating point precision issues
SELECT 0.1 + 0.2;  -- 0.30000000000000004
-- Use DECIMAL for money
CREATE TABLE accounts (balance DECIMAL(10, 2));
SELECT 0.10 + 0.20;  -- 0.30 (exact)

Неявное преобразование типов

Неявное преобразование типов может отключить индексы и вызвать полное сканирование таблицы. Всегда сравнивайте совпадающие типы. При необходимости явно приводите. Проверяйте типы столбцов и обеспечивайте совпадение параметров запроса.

sql
-- BAD: comparing string to number
SELECT * FROM users WHERE phone = 1234567890;
-- May cause full table scan due to type conversion
-- GOOD: compare same types
SELECT * FROM users WHERE phone = '1234567890';
-- Or cast explicitly
SELECT * FROM users WHERE CAST(phone AS BIGINT) = 1234567890;

Связанные сниппеты SQL

Copy-paste ready code for common tasks.

SELECT с WHERE и ORDER BY

Фильтрация, сортировка и ограничение строк с помощью SELECT, WHERE и ORDER BY в SQL.

JOIN-запросы

Многотабличные запросы на соединение.

Подзапросы

Вложенные запросы.

Оконные функции

Ранжирующие и агрегатные оконные функции.

Агрегатные функции

GROUP BY и HAVING.

CTE

Общие табличные выражения.

Рекурсивные запросы

Запрос иерархических данных с рекурсивным CTE.

Индексы

Создание и управление индексами.

Транзакции

Управление транзакциями и уровни изоляции.

Хранимые процедуры

Создание хранимых процедур и функций.

Триггеры

Автоматически выполняющиеся триггеры.

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

Создание и управление представлениями.

Материализованные представления

Материализованные представления и обновление.

Секционированные таблицы

Стратегии секционирования таблиц.

Резервное копирование и восстановление

Резервное копирование, импорт и экспорт данных.

Оптимизация производительности

Анализ и оптимизация производительности запросов.

Операции JSON

Операции PostgreSQL JSON/JSONB.

Полнотекстовый поиск

Полнотекстовый поиск PostgreSQL.

Pivot/Unpivot

PIVOT и Crosstab.

Запросы дат

Операции с датой и временем.

Запросы пагинации

LIMIT/OFFSET и пагинация курсором.

Was this helpful?