О разделе

База данных — единственный коллаборатор приложения, который пишет данные надолго: упасть может всё что угодно, но в реляционной БД останется следствие сегодняшней работы. Поэтому стоимость ошибки в БД-коде выше стоимости ошибки в любом другом слое: неверный индекс деградирует все читающие запросы, неверная транзакция теряет данные, неверный EXPLAIN заставляет масштабировать «железом» то, что лечится правкой одного запроса.

Что разбирается в разделе:
  • Структуры данных под капотом индексов и почему B-Tree победил;
  • Как читать EXPLAIN ANALYZE и видеть, на каком шаге запрос «ломается»;
  • Уровни изоляции: что реально происходит при Read Committed, Repeatable Read, Serializable;
  • MVCC и почему чтения в Postgres не блокируют записи;
  • Окна и CTE как замена самописных циклов в коде;
  • Различия MySQL и PostgreSQL, влияющие на дизайн схемы и запросов;
  • PDO: prepared statements, типизация, безопасность от инъекций.
Пререквизиты
  • Базовый SQL: SELECT/INSERT/UPDATE/DELETE, WHERE, GROUP BY, ORDER BY;
  • KB_1 раздел про типы данных PHP — для понимания касстов в PDO;
  • Понимание основ HTTP-запросов в Laravel.
Карта раздела
БлокЧто узнаешь
ОсновыРеляционная модель, нормализация vs денормализация, JOIN-ы и их семантика
ПроизводительностьИндексы (типы, leftmost, covering), чтение EXPLAIN, борьба с N+1
ТранзакцииACID и уровни изоляции, MVCC, блокировки, deadlock'и
ПродвинутоОкна, CTE, отличия MySQL/Postgres, PDO в PHP
ПрактикаСквозная оптимизация запроса: от 4 секунд до 40 мс
Типы баз данных: SQL / NoSQL / OLTP / OLAP
Развеиваем путаницу

Часто путают три ортогональных признака: модель данных (реляционная / документная / key-value / графовая), ориентация хранения (строчная / колоночная) и назначение (OLTP оперативные транзакции / OLAP аналитика). Это независимые оси — одна БД может быть «реляционная + строчная + OLTP» (PostgreSQL), другая «реляционная + колоночная + OLAP» (ClickHouse), третья «документная + строчная + OLTP» (MongoDB).

Реляционная ≠ key-value. Key-value — это NoSQL-модель, где на один ключ приходится одно значение (например, user:1 → "{name:Alice}"). Реляционная модель — таблицы со строками и столбцами + связи через foreign key + SQL как язык запросов. У них разные назначения.
Ось 1: Модель данных
МодельКак хранитКтоКогда применять
Реляционная (SQL) Таблицы + строки + связи FK. Строгая схема. PostgreSQL, MySQL, SQLite, MariaDB, Oracle, SQL Server По умолчанию. Транзакции, связанные данные, отчёты.
Документная (NoSQL) JSON/BSON документы в коллекциях. Схема гибкая. MongoDB, CouchDB, Firestore Данные с изменчивой структурой, вложенные объекты без JOIN.
Key-Value (NoSQL) Ключ → значение (обычно строка/JSON). Никакой схемы. Redis, DynamoDB, Memcached, Riak Кеш, сессии, счётчики, rate limiting, очереди.
Column-family (NoSQL) Разреженные таблицы с миллионами колонок, sharding. Cassandra, HBase, ScyllaDB Огромные объёмы, писать чаще чем читать (логи, метрики).
Graph (NoSQL) Узлы + рёбра со свойствами. Обход графа. Neo4j, ArangoDB, TigerGraph, AWS Neptune Соцсети, рекомендации, fraud detection.
Time-series Оптимизирована под append-only с timestamp. InfluxDB, TimescaleDB, Prometheus Метрики, IoT, финансовые тики.
Search-engine Инвертированные индексы для полнотекстового поиска. Elasticsearch, Meilisearch, OpenSearch Поиск, аналитика логов, автодополнение.
Vector Многомерные векторы для similarity search. Pinecone, Weaviate, Milvus, pgvector Semantic search, embeddings, RAG для LLM.
Ось 2: Ориентация хранения — строчная vs колоночная

Даже среди реляционных БД (одна и та же модель — таблицы) физическое хранение может быть разным. Это не про модель данных, а про то, как байты лежат на диске.

Row-oriented (строчная) — MySQL, PostgreSQL

Одна строка лежит на диске подряд: [id=1|name='Alice'|email='a@x.kz'|age=25]. Идеально когда нужны все поля одной строки: SELECT * FROM users WHERE id=1.

Column-oriented (колоночная) — ClickHouse, Redshift, BigQuery, Snowflake, DuckDB

Каждая колонка хранится отдельно: [1,2,3,...] для id, ['Alice','Bob','Cena',...] для name. Идеально для агрегаций по одному-двум полям через миллионы строк: SELECT AVG(age) FROM users — читает только колонку age, минуя все остальные.

АспектRow-orientedColumn-oriented
Чтение SELECT * одной строки1 seekN seeks (по одному на колонку)
Агрегация SUM/AVG одной колонкиЧитает ВСЕ колонкиЧитает только нужную
INSERT одной строкиБыстроМедленнее (запись в N файлов)
UPDATE / DELETEНормДорого (некоторые вообще запрещают)
СжатиеХуже (разные типы вперемешку)Отличное (одинаковые данные подряд)
Индексы нужны часто?Да, многоРеже — скан быстрый
Типичная задачаOLTP (транзакции)OLAP (аналитика)
ClickHouse — реляционная колоночная БД. Умеет SQL с JOIN, GROUP BY, но заточена под аналитику. Для веб-приложения (заказы, юзеры) она плохой выбор — INSERT одной строки в 100 раз медленнее MySQL. Используй для: аналитики логов, метрик, dashboard'ов с миллиардами строк.
Ось 3: Назначение — OLTP vs OLAP
OLTP (Online Transaction Processing)OLAP (Online Analytical Processing)
Что делаетОперативные транзакции: заказы, юзеры, платежиАналитика: отчёты, dashboards, BI
Запись / чтениеМного мелких транзакций (create order)Мало запросов, но огромных (SUM за год)
Модель данныхНормализованная (3NF)Денормализованная (star / snowflake schema)
ОбычноRow-oriented (MySQL, PostgreSQL)Column-oriented (ClickHouse, Redshift)
ИндексыМного, частоМало (полный скан быстрый)
ПользователиСайт, приложениеАналитики, менеджеры, BI-tools
ПримерINSERT INTO orders VALUES(...)SELECT month, SUM(amount) FROM orders GROUP BY month
На практике часто держат обе. OLTP-база (PostgreSQL) — production. Ночью данные ETL-скриптом переливают в OLAP (ClickHouse / Snowflake). Аналитики смотрят отчёты в OLAP без нагрузки на прод.
Специальные категории

NewSQL — распределенная реляционная

CockroachDB, YugabyteDB, TiDB, Google Spanner. Реляционная модель + SQL + ACID + горизонтальное масштабирование (шардирование). Появились когда MySQL/PostgreSQL стали упираться в один сервер. Дороже и сложнее классических.

Embedded — в процесс приложения

SQLite, LevelDB, RocksDB, DuckDB. Живут в файле рядом с приложением, без сетевого сервера. SQLite — самая распространённая БД в мире (каждый iPhone, Android, браузер). Идеально для локального dev, mobile, тестов, KB как у нас.

Гибриды — PostgreSQL как «универсал»

Современный PostgreSQL умеет одновременно: реляционные таблицы + JSONB (документная модель) + hstore (key-value) + full-text search + pgvector (векторный поиск) + PostGIS (гео). В 90% проектов PostgreSQL закрывает всё — не нужно 5 разных БД.

Правило выбора
ЗадачаЧто брать
Веб-приложение / e-commerce / любой стандартный проектPostgreSQL или MySQL
Кеш, сессии, счётчики, очередиRedis
Данные без чёткой схемы, вложенные документыMongoDB (или JSONB в PostgreSQL)
Аналитика на миллиарды строк, dashboardsClickHouse / BigQuery / Snowflake
Полнотекстовый поиск, автодополнениеElasticsearch / Meilisearch (или FTS в PG)
Соц.граф, рекомендацииNeo4j
Метрики, IoT, финансовые тикиInfluxDB / TimescaleDB
Semantic search для LLMpgvector / Pinecone / Weaviate
Mobile app, embedded, KB, dev-локалкаSQLite
Частый вопрос: «SQL vs NoSQL когда что». Правильный ответ — «зависит от паттерна доступа». NoSQL быстрее для конкретной модели, но реляционная гибче. Начинай с PostgreSQL — переходи на NoSQL только когда конкретный кейс требует.
Реляционная модель
Назначение

Реляционная модель, предложенная Эдгаром Коддом в 1970 году, — математически строгий способ описать данные через отношения (relations). Каждое отношение — это множество кортежей (tuples) одинаковой структуры (схемы). Этот фундамент определяет, почему SQL такой какой есть: декларативный язык запросов, работающий с множествами, а не с итерациями. Понимание модели позволяет читать SQL как алгебру, а не как набор инструкций.

Базовые понятия

Отношение, атрибуты, кортежи

Отношение в реляционной модели — математически множество кортежей с фиксированной схемой. На практике в SQL — таблица (хотя строго говоря, таблица в SQL допускает дубликаты, а отношение — нет). Атрибут — столбец. Кортеж — строка. Степень отношения — количество атрибутов; мощность — количество кортежей.

Ключи: primary, candidate, foreign, surrogate

Candidate key — минимальный набор атрибутов, однозначно идентифицирующий кортеж. Primary key — выбранный candidate key, на котором строится физическое размещение строк (clustered index в InnoDB). Surrogate key — искусственный (auto-increment id, UUID), не имеющий бизнес-смысла. Foreign key — ссылка на primary key другой таблицы.

Целостность: domain, entity, referential

Domain integrity — значение каждого атрибута соответствует допустимому домену (типу, ограничениям CHECK). Entity integrity — каждая строка уникально идентифицируема (NOT NULL на PK). Referential integrity — FK либо ссылается на существующую строку, либо NULL. Эти три уровня — контракт БД с приложением.

NULL — признак «неизвестно»

NULL в SQL имеет тройную логику: NULL = NULL возвращает не true, а NULL (unknown). Это источник тонких багов: WHERE x <> 5 не вернёт строки с x = NULL. Для проверки NULL используется IS NULL/IS NOT NULL. Агрегаты (COUNT, SUM) игнорируют NULL, кроме COUNT(*).

Практика: схема магазина
CREATE TABLE users (
    id          BIGINT      PRIMARY KEY AUTO_INCREMENT,
    email       VARCHAR(255) NOT NULL UNIQUE,
    created_at  TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id          BIGINT      PRIMARY KEY AUTO_INCREMENT,
    user_id     BIGINT      NOT NULL,
    status      VARCHAR(32)  NOT NULL,
    total_minor INT          NOT NULL CHECK (total_minor >= 0),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
);

Здесь видны все три типа целостности: NOT NULL и UNIQUE для entity, CHECK для domain, FOREIGN KEY для referential.

Особые случаи
1. Surrogate key как единственный. Таблица с одним id и без natural-уникальных ограничений позволяет вставить дубликаты бизнес-сущностей. Surrogate key полезен для индексации, но не отменяет необходимость уникальных ограничений на бизнес-атрибуты.
2. NULL в FK. NULL разрешён в FK по умолчанию — часто «временное» решение, превращающееся в постоянное и порождающее ветви IS NULL в каждом запросе.
3. ON DELETE CASCADE бездумно. Удобен для слабых сущностей (order_items при удалении orders), но опасен для сильных (удаление user'а сносит весь профиль, заказы, платежи).
4. UUID v4 как PK. Случайные значения приводят к фрагментации clustered B-Tree. Используйте UUIDv7 (упорядоченный по времени) либо BIGINT + UUID как secondary.
5. CHECK на MySQL до 8.0.16. В старых версиях CHECK парсился, но не применялся — ограничения молча игнорировались.
6. Денормализованные дубликаты без синхронизации. Часто «для скорости» копируют user_name в orders. Если имя меняется, в заказах остаётся старое.
7. Хранение денег как FLOAT. FLOAT приблизительный; 0.1 + 0.2 != 0.3. Деньги хранятся как INT в минорных единицах или DECIMAL(p, s).
8. ENUM для растущего списка. Добавить значение — ALTER TABLE с переписыванием. Используйте FK на справочную таблицу для растущих доменов.
Типы данных
Назначение

Выбор типа данных — первое архитектурное решение в схеме. Тип определяет диапазон значений, размер на диске, скорость операций, поведение в индексах и совместимость между СУБД. Неверный тип приводит к двум классам проблем: тихая потеря данных (округление FLOAT для денег, переполнение TINYINT) и деградация производительности (CHAR(255) вместо VARCHAR, BLOB в часто читаемых строках).

Целочисленные типы (MySQL)
ТипБайтSigned диапазонUnsigned диапазон
TINYINT1−128..1270..255
SMALLINT2−32 768..32 7670..65 535
MEDIUMINT3~−8.4M..8.4M0..16 777 215
INT4~−2.1B..2.1B0..4 294 967 295
BIGINT8~±9.2×10180..1.8×1019

Эмпирика: id BIGINT UNSIGNED для PK всегда; price_minor INT UNSIGNED для денег в копейках (хватит до ~$42M); quantity SMALLINT UNSIGNED для штук товара. Не используйте больше, чем нужно — на миллионе строк лишний байт = МБ. PostgreSQL: SMALLINT (2b), INTEGER (4b), BIGINT (8b), SERIAL/BIGSERIAL — auto-increment.

Деньги: DECIMAL, не FLOAT
-- Никогда. FLOAT/DOUBLE — приблизительные.
CREATE TABLE orders (total FLOAT);
-- SELECT 0.1 + 0.2; → 0.30000000000000004

-- INT в минорных единицах (центы/копейки)
CREATE TABLE orders (total_minor INT UNSIGNED);

-- DECIMAL(precision, scale) — точное хранение
CREATE TABLE products (price DECIMAL(10, 2)); -- до 99 999 999.99

Если обязаны сравнить FLOAT'ы — через эпсилон: ABS(a - b) < 0.0001, никогда a = b. Для финансовых сумм правило строгое — только DECIMAL или INT в минорных единицах.

Дата и время
ТипРазмерДиапазон / особенности
DATE3 байта1000-01-01 .. 9999-12-31
TIME3 байта−838:59:59 .. 838:59:59
DATETIME5 байт1000-01-01 .. 9999-12-31, без TZ (8 байт до MySQL 5.6.4)
TIMESTAMP4 байта1970-01-01 .. 2038-01-19, хранит UTC
YEAR1 байт1901..2155

Проблема 2038: 32-битный TIMESTAMP переполнится в январе 2038. Для долговременных дат (контракты, договоры) — DATETIME. PostgreSQL: TIMESTAMP WITH TIME ZONE (timestamptz) — хранит UTC и конвертирует в TZ клиента; TIMESTAMP WITHOUT TIME ZONE — «голое» значение.

Строки: CHAR vs VARCHAR vs TEXT
ТипХранениеКогда применять
CHAR(n)Фиксированная длина, дополняется пробеламиЗаведомо фиксированные строки: MD5 (CHAR(32)), bcrypt (CHAR(60)), коды стран (CHAR(2))
VARCHAR(n)Переменная + 1-2 байта оверхедаИмена, email, заголовки. Дефолт для большинства строк.
TINYTEXTДо 255 байтКороткие комментарии
TEXTДо 64 КБОписания, комментарии
MEDIUMTEXTДо 16 МБСтатьи, документация
LONGTEXTДо 4 ГБОгромные документы (редко в SQL, лучше S3)

TEXT-семейство хранится off-page (отдельно от строки) — полный JOIN/SELECT * становится медленнее. PostgreSQL: TEXT без ограничения длины как универсальный тип.

Специальные: JSON, ENUM, SET, ARRAY

JSON / JSONB

MySQL: JSON — бинарный, поддерживает частичные обновления (JSON_SET, JSON_REMOVE), валидирует синтаксис. Индексирование — через generated columns. PostgreSQL: JSONB — бинарный с GIN-индексированием (WHERE data @> '{"key":"x"}'). Применять для гибких атрибутов, не для частых WHERE.

ENUM (MySQL)

Одно из заранее заданного набора значений: status ENUM('pending', 'paid', 'cancelled'). 1-2 байта на хранение. Минус: расширение списка — ALTER TABLE с перезаписью. Для растущих доменов — справочная таблица + FK.

SET (MySQL)

Битовая маска нескольких значений: tags SET('php', 'mysql', 'redis'). До 64 значений, 1-8 байт. На практике редко — лучше отдельная таблица-связь.

ARRAY (PostgreSQL)

Нативные массивы: tags TEXT[], scores INT[]. Индексирование через GIN: WHERE tags @> ARRAY['php']. Удобно, но ломает переносимость на MySQL.

Сколько места занимает значение

Все типы делятся на две группы, и это деление важнее запоминания конкретных цифр: одни занимают место по факту данных, другие — всегда одинаково, независимо от содержимого.

Переменный размер — занимают по факту

ТипМаксимум
VARCHAR(n)n символов + 1–2 байта на длину
TINYTEXT255 байт
TEXT64 КБ
MEDIUMTEXT16 МБ
LONGTEXT4 ГБ
VARBINARY, BLOB и вариантыТо же самое, но для бинарных данных
JSONХранится в бинарном виде, лимит как у LONGTEXT
DECIMAL(p, s)Примерно 4 байта на каждые 9 цифр

Фиксированный размер — занимают одинаково при любом значении

ТипРазмер
TINYINT (он же BOOL)1 байт
SMALLINT2 байта
MEDIUMINT3 байта
INT4 байта
BIGINT8 байт
FLOAT / DOUBLE4 / 8 байт
DATE3 байта
TIME3 байта
TIMESTAMP4 байта
DATETIME5 байт
YEAR1 байт
CHAR(n)Всегда n символов — короткое значение добивается пробелами
Ключевое следствие. Число 1 в BIGINT занимает те же 8 байт, что и миллиард. Поэтому выбор разрядности — это решение про диапазон, а не про экономию на мелких значениях: на миллионе строк лишние 4 байта превращаются в 4 МБ, независимо от того, какие там цифры.

Два уточнения, которые часто упускают

Размер VARCHAR считается в символах, а место — в байтах. В utf8mb4 символ занимает до 4 байт, поэтому VARCHAR(255) может занять до 1020 байт данных. Это не теория: именно отсюда классическая ошибка Specified key was too long в старых MySQL, где лимит ключа был 767 байт — 255 × 4 в него не влезало. Лечилось Schema::defaultStringLength(191), потому что 191 × 4 = 764.
Префикс длины — 1 или 2 байта, а не всегда 1. Правило: 1 байт, если максимальная длина поля укладывается в 255 байт, иначе 2 байта. В utf8mb4 у VARCHAR(255) потолок 1020 байт, значит префикс двухбайтовый — и пустая строка займёт 2 байта, а не 1. Для VARCHAR(50) в той же кодировке (200 байт) префикс будет однобайтовым.

Лимит строки целиком

Помимо размеров отдельных колонок в MySQL есть ограничение на всю строку — 65 535 байт, общее на таблицу независимо от движка. TEXT и BLOB в этот лимит почти не входят: в строке хранится только указатель (обычно 9–12 байт), а данные лежат отдельно.

Поэтому таблица из трёх десятков VARCHAR(255) в utf8mb4 может упереться в лимит и не создаться, а таблица с десятком TEXT — нет.

В SQLite всё это не действует. Там динамическая типизация: длина в скобках не проверяется вообще, и в varchar(20) спокойно запишется строка любой длины. Тип колонки для SQLite — лишь «рекомендация сродства» (type affinity). Из-за этого схема, отлаженная на SQLite, может начать падать после переезда на MySQL: там ограничения настоящие.
Особые случаи
1. FLOAT для денег. Тихая потеря копеек. Только DECIMAL или INT в минорных единицах.
2. TIMESTAMP для будущих дат. Переполнение в 2038. DATETIME или BIGINT в Unix-секундах.
3. VARCHAR(255) по привычке. Если поле точно ≤ 32 символа — VARCHAR(32). На больших таблицах байты складываются.
4. BLOB/TEXT в часто читаемых строках. Off-page хранение замедляет SELECT *. Выносите большие поля в отдельную таблицу 1:1.
5. MySQL utf8 вместо utf8mb4. Старый utf8 — 3-байтный, не вмещает эмодзи и редкие символы. Всегда utf8mb4.
6. ENUM для статусов с авторазвитием. Каждое добавление статуса — ALTER на миллион строк. Используйте справочную таблицу.
7. PostgreSQL TIMESTAMP без TZ для UTC-данных. Используйте TIMESTAMPTZ — конвертация в TZ клиента автоматическая.
8. BIGINT для всего по умолчанию. 8 байт vs 4 (INT). На таблице в 100M строк это 400 МБ разницы только в PK + столько же на каждый FK.
DDL: CREATE, ALTER, DROP
Назначение

DDL (Data Definition Language) — команды управления схемой: создание, изменение, удаление таблиц и ограничений. На проде это самая опасная категория операций — ALTER на гигабайтной таблице может блокировать чтение на минуты, DROP TABLE — необратим. Понимание тонкостей DDL и стратегий безопасного применения отделяет производственный опыт от учебного.

CREATE TABLE с constraints
CREATE TABLE IF NOT EXISTS users (
    id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    email       VARCHAR(255)    NOT NULL UNIQUE,
    age         TINYINT UNSIGNED CHECK (age BETWEEN 0 AND 150),
    status      VARCHAR(32)     NOT NULL DEFAULT 'pending',
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT chk_status CHECK (status IN ('pending', 'active', 'banned'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CHECK в MySQL до 8.0.16 парсился, но не применялся — ограничения молча игнорировались. С 8.0.16+ — работают. PostgreSQL — работают всегда. Именованные constraints (CONSTRAINT chk_status) удобнее для последующего DROP/изменения.

CREATE TABLE AS SELECT (быстрый бэкап / снапшот)
-- Полная копия с данными
CREATE TABLE users_backup_20260523 AS SELECT * FROM users;

-- Только структура без данных
CREATE TABLE users_empty AS SELECT * FROM users WHERE 1=0;

-- Подмножество по условию (миграция данных)
CREATE TABLE archived_orders AS
  SELECT * FROM orders WHERE created_at < '2020-01-01';

Важно: CREATE TABLE AS SELECT копирует данные, но не копирует PK, индексы, FK, AUTO_INCREMENT. После создания нужно добавить вручную. PostgreSQL: CREATE TABLE ... (LIKE source INCLUDING ALL) — копирует и ограничения.

ALTER TABLE: безопасные и опасные операции
-- Добавить колонку
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL AFTER email;

-- Изменить тип колонки (потенциально долго)
ALTER TABLE orders MODIFY COLUMN total_minor BIGINT UNSIGNED NOT NULL;

-- Переименовать колонку (MySQL 8+)
ALTER TABLE users RENAME COLUMN name TO full_name;

-- Добавить индекс
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);

-- Добавить FK с именем
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;

Цена операций: до MySQL 8.0 большинство ALTER требовало полной перезаписи таблицы (создание копии, копирование строк, переименование). С 8.0+ многие операции instant: добавление колонки в конец, переименование колонки, добавление виртуальной колонки. Для DROP COLUMN, изменения типа — всё равно перезапись.

DESC / SHOW: интроспекция
-- MySQL
DESC users;                       -- структура таблицы
SHOW CREATE TABLE users;          -- полный CREATE
SHOW INDEX FROM users;            -- список индексов
SHOW TABLE STATUS LIKE 'users';     -- размер, кол-во строк, engine

-- PostgreSQL (psql)
\d users            -- структура + индексы + FK
\d+ users           -- расширенная
\di              -- все индексы
Особые случаи
1. ALTER TABLE на проде без online-tool. На таблице в 10M строк ALTER блокирует чтение/запись на десятки минут. Используйте pt-online-schema-change, gh-ost либо pg_repack.
2. CREATE TABLE AS SELECT без индексов. Копирует данные, но не PK/индексы/FK. Добавляйте отдельным ALTER после.
3. DROP TABLE без бэкапа. Необратимо. Перед удалением — RENAME в _archive на несколько дней, затем DROP.
4. ALTER в одной транзакции с DML на MySQL до 8.0. MySQL InnoDB < 8.0 неявно коммитит транзакцию перед DDL. Postgres — DDL транзакционный.
5. Default ENGINE=MyISAM. На MySQL до 5.5 дефолт — MyISAM (без транзакций, table-level locks). Явно: ENGINE=InnoDB.
6. Дефолтный collation утечкой регистра. utf8mb4_unicode_ci — case-insensitive; WHERE email = 'A' найдёт 'a'. Для строгого сравнения — utf8mb4_bin или COLLATE в WHERE.
7. Безымянный constraint. FOREIGN KEY ... REFERENCES ... без CONSTRAINT-имени — MySQL генерирует имя автоматически. Удалять/менять потом — нужно сначала найти имя через SHOW CREATE TABLE.
8. CHECK в MySQL < 8.0.16. Парсился без ошибки, но не применялся. Если поддерживаете старые БД — дублируйте проверки в приложении.
Нормализация и денормализация
Назначение

Нормализация — процесс декомпозиции таблиц для устранения аномалий обновления, вставки и удаления. Формы нормализации (1НФ, 2НФ, 3НФ, БКНФ) — последовательно более строгие критерии. Знание форм нужно не для механического применения, а для понимания, какую аномалию устраняет каждый уровень и в каких случаях осознанная денормализация оправдана.

Формы по нарастанию

1НФ — атомарность

Каждый атрибут содержит атомарное значение, без списков и вложенных структур. Колонка phones VARCHAR со значением "+7-700-1234, +7-700-5678" нарушает 1НФ — декомпозируется в отдельную таблицу user_phones.

2НФ — полная зависимость от PK

Каждый неключевой атрибут зависит от всего PK, а не от его части. Актуально при составном PK. Пример нарушения: order_items(order_id, sku, sku_name, quantity), где sku_name зависит только от sku.

3НФ — отсутствие транзитивных зависимостей

Ни один неключевой атрибут не зависит от другого неключевого. Нарушение: employees(id, department_id, department_name) — department_name зависит от department_id. 3НФ — практический потолок для OLTP-схем.

БКНФ — усиление 3НФ

Каждая нетривиальная функциональная зависимость имеет в левой части суперключ. Решает редкий случай множественных перекрывающихся candidate keys.

Когда денормализация оправдана
СценарийРешение
Read-heavy агрегат (users.orders_count)Денормализованный счётчик, обновляемый триггером или job'ом
Исторический срез (имя пользователя на момент заказа)Денормализованная копия в orders.user_name_snapshot
Tree/иерархия с частыми чтениямиMaterialized path или nested set
Аналитические витриныОтдельная схема (warehouse) с денормализацией под отчёты
Особые случаи
1. Денормализация «на всякий случай». Копия колонки нужна только когда измерена конкретная проблема и принято решение о её цене.
2. Тройная отрицательная логика. Колонка is_not_inactive BOOLEAN — читать невозможно. Используйте позитивные имена.
3. JSON-колонка как ленивая денормализация. Если поле часто фигурирует в WHERE/JOIN — вынесите его в отдельный столбец.
4. EAV (Entity-Attribute-Value). Схема превращает любой запрос в десяток self-JOIN'ов. Антипаттерн в общем случае.
5. Полиморфные FK без проверки. Колонка commentable_type + commentable_id без FK не имеет гарантий целостности на уровне БД.
6. Triggers как способ синхронизации. Триггеры легко проглядеть при чтении кода. Документируйте или используйте observers/events.
7. Хранение списков в TEXT через разделитель. tags = 'php,mysql' ломает поиск, индекс не работает.
8. Универсальная таблица «logs». Одна таблица для всех логов всех модулей быстро становится узким местом.
JOIN-ы: семантика и применение
Назначение

JOIN — операция комбинирования строк двух (и более) отношений на основе условия. Несмотря на единый синтаксис, разные виды JOIN-ов имеют принципиально разную семантику: один из них (INNER) фильтрует, другой (LEFT) дополняет, третий (CROSS) умножает. Неправильный JOIN — одна из самых частых причин ложных данных в отчётах.

Виды JOIN-ов

INNER JOIN

Возвращает только строки, для которых условие соединения истинно. Если в левой таблице есть пользователь без заказов — в результат он не попадёт. INNER JOIN — это фильтр.

LEFT (OUTER) JOIN

Возвращает все строки левой таблицы; для строк без соответствия в правой — правые колонки заполняются NULL. Используется, когда правая сторона — дополняющая информация.

RIGHT JOIN

Зеркальная версия LEFT. Практически не используется — переписывается как LEFT с перестановкой таблиц и читается понятнее.

FULL OUTER JOIN

Возвращает все строки обеих таблиц; для несоответствий с любой стороны — NULL. MySQL до 8.0 не поддерживает — обходят через UNION двух LEFT JOIN-ов.

CROSS JOIN

Декартово произведение — каждая строка левой комбинируется с каждой правой. Полезно для построения календарных таблиц через CROSS JOIN с числовой последовательностью.

SELF JOIN

Таблица соединяется сама с собой по алиасу. Применяется для иерархий (employees.manager_id => employees.id), сравнений пар строк.

ON vs WHERE: критичная разница для OUTER JOIN

Условие в ON применяется до JOIN-а — контролирует, какие строки соединяются. Условие в WHERE применяется после JOIN-а — фильтрует результат. Для INNER JOIN разницы нет; для OUTER — разница принципиальная.

-- WHERE на правую таблицу превращает LEFT JOIN в INNER
SELECT u.id, u.email, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';
-- Пользователи без заказов выпадут: их status = NULL, NULL = 'paid' → false

-- Условие в ON: LEFT JOIN сохранён
SELECT u.id, u.email, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';
-- Все пользователи в результате; у тех, без оплаченных заказов, order_id = NULL

Правило: условия на «правую» сторону OUTER JOIN всегда в ON, не в WHERE. Если условие надо в WHERE — вы фактически хотите INNER JOIN; запишите его явно.

Conditional JOINs: несколько условий в ON
-- Книги с рейтингом >= 4, доставленные в срок
SELECT b.title, o.quantity, d.delivery_status
FROM books b
JOIN orders o
  ON o.book_id = b.id
  AND o.quantity >= 2
JOIN deliveries d
  ON d.order_id = o.id
  AND d.delivery_status IN ('delivered', 'in-transit')
WHERE b.rating >= 4;

Несколько условий в ON через AND/OR/IN — нормальная практика. Это читается лучше, чем громоздкий WHERE на «склейку».

COALESCE и NULLIF

При OUTER JOIN правая сторона может быть NULL. COALESCE возвращает первое не-NULL значение — стандартный способ подставить дефолт.

SELECT u.id, u.name,
       COALESCE(SUM(o.total_minor), 0) AS total_spent,
       COALESCE(p.phone, 'не указан')  AS phone
FROM users u
LEFT JOIN orders   o ON o.user_id = u.id
LEFT JOIN profiles p ON p.user_id = u.id
GROUP BY u.id, u.name, p.phone;

-- NULLIF — наоборот: если первое = второму, вернуть NULL
-- Защита от деления на 0:
SELECT amount / NULLIF(quantity, 0) AS unit_price FROM items;
Практика: дублирование строк
-- Двойной JOIN ломает SUM: каждый платёж умножается на количество заказов
SELECT u.id, COUNT(o.id) AS orders_count, SUM(p.amount) AS total_paid
FROM      users    u
LEFT JOIN orders   o ON o.user_id = u.id
LEFT JOIN payments p ON p.user_id = u.id
GROUP BY u.id;
-- Агрегировать каждую сторону отдельно через подзапросы
SELECT u.id,
       COALESCE(o.orders_count, 0) AS orders_count,
       COALESCE(p.total_paid,  0) AS total_paid
FROM users u
LEFT JOIN (SELECT user_id, COUNT(*) AS orders_count FROM orders   GROUP BY user_id) o ON o.user_id = u.id
LEFT JOIN (SELECT user_id, SUM(amount) AS total_paid   FROM payments GROUP BY user_id) p ON p.user_id = u.id;

В первом запросе пользователь с 3 заказами и 5 платежами породит 15 строк, и каждый платёж попадёт в SUM трижды. Это математика декартова произведения, в которое мы сами загнали запрос.

Особые случаи
1. WHERE убивает LEFT JOIN. Условие WHERE right_table.col = 'X' отфильтрует строки, где правая часть NULL — превращая LEFT JOIN в INNER. Условия на правую таблицу выносите в ON.
2. NOT IN с NULL. Если подзапрос вернёт хотя бы один NULL, весь предикат становится UNKNOWN, и запрос вернёт пустоту. Используйте NOT EXISTS.
3. CROSS JOIN по ошибке. Забытое условие ON превращает запрос в декартово произведение.
4. JOIN с подзапросом без индекса. СУБД материализует подзапрос как временную таблицу без индексов; JOIN становится медленным.
5. Несовпадение типов в JOIN. BIGINT vs VARCHAR — неявное приведение типов на каждой строке, индекс не используется.
6. USING(col) vs ON. USING требует одинаковых имён столбцов в обеих таблицах и ломается при переименовании.
7. Самосоединение без алиасов. Каждое вхождение таблицы в JOIN требует уникального алиаса.
8. RIGHT JOIN в чужом коде. Обычно исторический артефакт; переписывание на LEFT JOIN делает запрос читаемее.
Индексы: типы, leftmost rule, covering
Назначение

Индекс — вспомогательная структура данных, позволяющая СУБД находить строки по значениям атрибутов без полного сканирования таблицы. Без индексов запрос WHERE email = ? на таблице в миллион строк требует прочитать миллион строк. С индексом — ~20 операций обращения к B-Tree. Понимание индексов отличает middle от junior и определяет, почему один запрос выполняется за 20 мс, а похожий — за 4 секунды.

Виды индексов

B-Tree (B+Tree)

Самый распространённый тип. Сбалансированное дерево с листьями, хранящими ключи и указатели. Поддерживает поиск по равенству, диапазон, сортировку, LIKE с фиксированным префиксом. Не работает для LIKE '%foo'.

Hash

Хеш-таблица: O(1) для поиска по равенству, не поддерживает диапазон и сортировку. MySQL InnoDB — adaptive hash index автоматически. PostgreSQL HASH index — редко используется.

GIN (Postgres)

Индекс по содержимому составных значений: массив, JSON, tsvector. Подходит для WHERE tags @> ARRAY['php'] или WHERE jsonb_col @> '{"status":"active"}'.

GiST / BRIN / SP-GiST (Postgres)

GiST — для геоданных и full-text. BRIN — для очень больших таблиц с естественным порядком (временные ряды): хранит min/max по блокам.

Partial index

Индекс с условием: CREATE INDEX ON orders(user_id) WHERE status = 'pending'. Хранит только строки, удовлетворяющие условию.

Covering index

Содержит все колонки, нужные запросу — и WHERE, и SELECT. СУБД отвечает только данными из индекса, не обращаясь к таблице. Postgres: INCLUDE, MySQL: широкий композит.

Leftmost prefix rule

Композитный индекс INDEX (a, b, c) используется при запросах, фильтрующих по a, (a, b), (a, b, c). Не работает для (b), (c), (b, c). Из этого следует основа дизайна композитных индексов: первой ставится колонка, по которой чаще всего идёт точная фильтрация.

CREATE INDEX idx ON orders (user_id, status, created_at);

-- Используется полностью
SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at;
-- Используется по первой колонке
SELECT * FROM orders WHERE user_id = 42;
-- НЕ используется: нет фильтра по user_id
SELECT * FROM orders WHERE status = 'paid';
Особые случаи
1. Индексы на каждую колонку. Каждый INSERT/UPDATE обновляет все индексы — запись на широких таблицах с 20 индексами становится в разы медленнее.
2. Низкоселективный индекс. Индекс по gender или status почти бесполезен. Эффективен только в композите с более селективной колонкой.
3. Функция от колонки убивает индекс. WHERE LOWER(email) = ? — индекс по email не используется. Решение: функциональный индекс.
4. Композит в неправильном порядке. Индекс (created_at, user_id) для WHERE user_id = ? не работает по leftmost rule.
5. Индекс по большому VARCHAR. Огромный, медленный. Используйте префиксный индекс или функциональный hash.
6. Дублирующиеся индексы. (a) и (a, b) — первый избыточен. Периодическая чистка экономит место и ускоряет запись.
7. Индекс на FK без подумать. InnoDB создаёт автоматически, Postgres — нет. Удаление родителя становится full scan'ом дочерней таблицы.
8. FORCE INDEX вместо понимания. Чинит симптом, скрывает причину. Через полгода изменения статистики сделают подсказку вредной.
Индексы в Laravel: миграции и диагностика
Назначение

Eloquent-миграции абстрагируют создание индексов под все три основных СУБД одним DSL. На практике почти все индексы в Laravel-проектах объявляются именно через Schema Builder. Параллельно нужно уметь читать индексы со стороны БД и находить неиспользуемые/дубликаты — чистка таких индексов даёт ускорение записи без потерь для чтения.

Создание индексов в миграциях
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('status', 32);
    $table->unsignedInteger('total_minor');
    $table->timestamps();

    // Одинокий индекс
    $table->index('status');

    // Композитный индекс
    $table->index(['user_id', 'created_at'], 'idx_orders_user_created');

    // Уникальный (одна колонка или композит)
    $table->unique(['user_id', 'external_ref']);

    // Full-text (MySQL)
    $table->fullText(['title', 'description']);
});

// Добавить индекс в существующую таблицу
Schema::table('orders', fn (Blueprint $t) =>
    $t->index('paid_at'));

// Удалить — по имени (всегда работает) или по колонкам
Schema::table('orders', function (Blueprint $t) {
    $t->dropIndex('idx_orders_user_created');   // по имени
    $t->dropIndex(['user_id', 'created_at']); // Laravel сгенерирует имя
    $t->dropUnique('orders_user_id_external_ref_unique');
    $t->dropForeign(['user_id']);
});
МетодЧто делает
$t->index($cols)Обычный B-Tree
$t->unique($cols)Уникальный + индекс одновременно
$t->fullText($cols)FULLTEXT (MySQL/PG)
$t->spatialIndex($col)Для геоданных (MySQL)
$t->foreignId('x')->constrained()FK + индекс автоматически
Диагностика: какие индексы есть, какие не используются

MySQL:

-- Все индексы таблицы
SHOW INDEX FROM orders;

-- Неиспользуемые индексы (требуется sys schema)
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'shop';

-- Дубликаты/избыточные
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = 'shop';

-- Размеры индексов
SELECT database_name, table_name, index_name,
       SUM(stat_value) * 16 AS size_kb
FROM mysql.innodb_index_stats
WHERE stat_name = 'size'
GROUP BY database_name, table_name, index_name;

PostgreSQL:

-- Все индексы таблицы
\d orders

-- Статистика использования
SELECT schemaname, tablename, indexname,
       idx_scan,                  -- сколько раз использовался
       idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan ASC; -- наверху — самые неиспользуемые

-- Размер индексов
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexname::regclass) DESC;
EXPLAIN через Laravel
// Включить лог всех запросов
DB::enableQueryLog();

User::where('email', 'a@example.com')->first();

dd(DB::getQueryLog());
// [['query' => 'select * from users where email = ?', 'bindings' => ['a@...'], 'time' => 0.3]]

// EXPLAIN для конкретного запроса
$plan = DB::select('EXPLAIN SELECT * FROM users WHERE email = ?', ['a@example.com']);
dump($plan);

// Получить SQL из Eloquent без выполнения
$sql = User::where('status', 'active')->toSql();
// "select * from `users` where `status` = ?"
Trade-off: read vs write
Тип проектаRead/WriteИндексы
E-commerce, блог, CMSЧтение >> записьМного индексов; читателю — всё нужное
Логирование, аналитика insert-onlyЗапись доминируетМинимум индексов: один PK + 1-2 для редких запросов
OLTP (заказы, платежи)~50/50Узкие точечные индексы под конкретные запросы
IoT, телеметрияWrite-heavyBRIN-индексы на timestamp + partitioning
Биржи, real-timeВысокая запись, частое чтение последнегоМинимальные индексы + специальные структуры (Redis sorted sets)

Эмпирика: добавление каждого индекса замедляет INSERT/UPDATE/DELETE на 5-15%. На таблице с 10 индексами запись в 2-3 раза медленнее, чем без индексов. Перед добавлением индекса задайте вопрос: «какой конкретный запрос он ускоряет?» Если ответа нет — индекс не нужен.

Особые случаи
1. foreignId()->constrained() и lock на ALTER. Создание FK на большой таблице блокирует обе. Делайте отдельной миграцией ночью либо используйте NOCHECK.
2. Миграция с index на проде. Создание индекса на 100M строк блокирует таблицу. CREATE INDEX CONCURRENTLY в Postgres или pt-osc для MySQL.
3. dropIndex по массиву колонок и переименование. Если индекс был создан с явным именем, удаление по колонкам не сработает. Удаляйте по имени.
4. down() миграции без обратного индекса. При откате индекс остаётся. Всегда зеркальный down().
5. DB::enableQueryLog на проде. Лог копится в памяти на каждом запросе. Включайте локально или временно с осторожностью.
6. fullText только в MyISAM до MySQL 5.6. Если получили ошибку — обновите MySQL или переходите на ElasticSearch/Meilisearch.
7. Удаление неиспользуемого индекса в спешке. Прежде чем удалять — проверьте, что idx_scan = 0 в течение месяца, а не часа. Сезонные запросы (отчёты раз в квартал) тоже используют индексы.
8. Дублирующиеся индексы из-за unique. $t->unique(['a']) создаёт уникальный индекс по a. $t->index('a') рядом — избыточный. Уникальный уже покрывает.
EXPLAIN: чтение плана запроса
Назначение

EXPLAIN показывает план выполнения запроса. EXPLAIN ANALYZE дополнительно реально исполняет запрос и показывает фактическое время и количество строк. Расхождение ожидаемого и фактического указывает на устаревшую статистику или неверные оценки.

Что искать в плане

Тип сканирования

MySQL: const < ref < range < index < ALL. Postgres: Index Scan, Index Only Scan, Bitmap Heap Scan, Seq Scan. Seq Scan на большой таблице — первый кандидат на индекс.

Количество строк

Расхождение оценки и фактического в 10+ раз указывает на устаревшую статистику — запустите ANALYZE table_name.

Использование индекса

MySQL key = NULL — индекс не используется. Postgres Filter — что фильтруется после индексного скана.

JOIN-стратегия

Nested Loop — быстрый для маленькой внутренней таблицы. Hash Join — для больших таблиц без индексов. Merge Join — обе стороны отсортированы.

Лишние операции

Using filesort, Sort в плане — СУБД сортирует на лету без подходящего индекса. Using temporary — материализация. На больших данных оба — красный флаг.

Иерархия type в MySQL (от худшего к лучшему)
typeЧто значитХорошо/плохо
ALLFull table scan — все строкиПлохо на больших таблицах
indexFull index scan — все строки индексаНе быстрее ALL
rangeДиапазон по индексу (BETWEEN, >, IN)Приемлемо
refПоиск по неуникальному индексу (несколько строк)Хорошо
eq_refПоиск по уникальному индексу для JOIN (одна строка)Отлично
constНайдена ровно одна строка через PK/UNIQUEИдеал
systemТаблица с одной строкойОсобый случай

Правило: если в плане type=ALL или type=index на большой таблице — вы ещё не закончили оптимизацию. Стремитесь к ref или лучше для всех «горячих» запросов.

Старый SQL-89 синтаксис vs ANSI JOIN
-- Старый стиль (SQL-89): запятая в FROM, условия в WHERE
SELECT * FROM users, orders
WHERE orders.user_id = users.id;

-- Современный (ANSI): явный JOIN
SELECT * FROM users
JOIN orders ON orders.user_id = users.id;

Опасность старого синтаксиса: забыл условие в WHERE → CROSS JOIN. Запрос FROM users, orders без WHERE на миллион пользователей и миллион заказов вернёт триллион строк. ANSI JOIN требует явный ON — забыть невозможно.

Практика: чтение плана
EXPLAIN ANALYZE
SELECT u.id, SUM(o.total_minor) AS total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2026-04-01'
GROUP BY u.id
ORDER BY total DESC
LIMIT 10;

-- Postgres plan (упрощённо):
Limit  (actual time=120.4..120.5)
  ->  Sort
        ->  HashAggregate
              ->  Hash Join
                    Hash Cond: (o.user_id = u.id)
                    ->  Index Scan using idx_orders_created_at on orders o
                    ->  Seq Scan on users u  ← полное сканирование users

План говорит: индекс по orders.created_at подобран, но затем для JOIN сделан Seq Scan по 100к users. Решение — убедиться, что users.id — PK (всегда индексирован), либо подсказать оптимизатору через SET enable_seqscan = off для сравнения.

Особые случаи
1. EXPLAIN без ANALYZE. Чистый EXPLAIN — оценка; ANALYZE — факт. На production-данных оценка может расходиться на порядки.
2. ANALYZE для UPDATE/DELETE. Реально выполнит UPDATE. Оберните в BEGIN; ... ROLLBACK;.
3. Устаревшая статистика. После массового импорта/удаления запустите ANALYZE table_name.
4. План отличается между prod и staging. Объёмы данных разные — оптимизатор выбирает разные планы. Анализируйте на реальных объёмах.
5. type=index в MySQL. Это full scan индекса, не быстрее type=ALL. Реально быстрые: const, ref, range.
6. Filter после Index Scan. СУБД нашла строки через индекс, но фильтрует по условию, которое индекс не покрывает. Рассмотрите расширение индекса.
7. Materialize в Postgres. Часто свидетельство неоптимального плана. Перепишите через JOIN.
8. План в production меняется со временем. Объёмы растут, оптимизатор переключается. Мониторьте slow query log.
Подзапросы: correlated vs uncorrelated
Назначение

Подзапрос — SELECT внутри SELECT. На вид одно и то же, но за этим скрыты два принципиально разных механизма: uncorrelated (независимый, выполняется один раз) и correlated (зависит от строки внешнего запроса, выполняется N раз). Непонимание разницы превращает безобидный запрос в N+1 на уровне SQL.

Два вида подзапросов

Uncorrelated — независимый

Подзапрос не ссылается на колонки внешнего. СУБД выполняет его один раз, результат используется во внешнем.

-- Один SELECT для подзапроса, один для внешнего
SELECT * FROM orders
WHERE user_id IN (
    SELECT id FROM users WHERE country = 'KZ'
);

Correlated — зависимый (опасный)

Подзапрос ссылается на колонку внешнего. СУБД выполняет его для каждой строки внешнего. На 1000 строк — 1001 SELECT.

-- N+1 на уровне SQL: SELECT users — 1, SELECT MAX — для каждого user
SELECT u.id, u.email,
       (SELECT MAX(created_at) FROM orders WHERE user_id = u.id) AS last_order
FROM users u;
Три способа оптимизации correlated

1. JOIN + GROUP BY — одна сортировка вместо N запросов:

SELECT u.id, u.email, MAX(o.created_at) AS last_order
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;

2. CTE с предварительной агрегацией — читается лучше:

WITH last_orders AS (
    SELECT user_id, MAX(created_at) AS ts
    FROM orders GROUP BY user_id
)
SELECT u.id, u.email, lo.ts AS last_order
FROM users u LEFT JOIN last_orders lo ON lo.user_id = u.id;

3. Window function — если нужна не только агрегация, а вся строка последнего заказа:

SELECT u.id, u.email, o.id AS last_order_id, o.total_minor
FROM users u
LEFT JOIN (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
) o ON o.user_id = u.id AND o.rn = 1;
EXISTS vs IN

Оба проверяют «есть ли совпадение», но реализованы по-разному. IN вычисляет полный список и сравнивает; EXISTS останавливается на первом совпадении.

-- IN: подзапрос материализует все user_id, потом проверяет членство
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders);

-- EXISTS: для каждой строки внешнего ищет хотя бы одно совпадение, выходит на первом
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id);

Современные оптимизаторы часто приводят эти запросы к одному плану, но есть два кейса, где разница остаётся:

  • NOT IN с NULL — ломается (см. JOIN-pitfalls); NOT EXISTS корректно;
  • Большой подзапрос — EXISTS часто эффективнее, поскольку не материализует весь список.
Цепочки CTE

CTE можно последовательно цепочкой — каждый шаг видит результат предыдущего. Это лучший способ записать многошаговую логику читаемо.

WITH
recent_orders AS (
    SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL '30 days'
),
user_totals AS (
    SELECT user_id, SUM(total_minor) AS total, COUNT(*) AS cnt
    FROM recent_orders GROUP BY user_id
),
ranked AS (
    SELECT *, RANK() OVER (ORDER BY total DESC) AS rk
    FROM user_totals
)
SELECT u.email, r.total, r.cnt, r.rk
FROM ranked r JOIN users u ON u.id = r.user_id
WHERE r.rk <= 10;

До Postgres 12 каждый CTE был «оптимизационным барьером» — план не оптимизировался сквозь него. С 12+ — inline по умолчанию; явная материализация через WITH foo AS MATERIALIZED (...).

Особые случаи
1. Correlated в SELECT. Самый частый случай: подзапрос в выводе столбца — почти всегда correlated. Переписывайте на JOIN.
2. NOT IN с NULL. Если подзапрос вернёт хотя бы один NULL, весь предикат становится UNKNOWN; внешний запрос вернёт пусто. NOT EXISTS работает корректно.
3. SELECT * FROM (...) AS sub WHERE rn = 1 — дёшево только с индексом. Window-функция требует сортировки внутри партиции. На больших данных без подходящего индекса — всё равно дорого.
4. CTE как замена View. CTE существует только в рамках одного запроса. Для повторно используемого — CREATE VIEW или MATERIALIZED VIEW.
5. Рекурсивный CTE без терминатора. Цикл в данных (категория → родитель → ... → она же) — бесконечная рекурсия. Защита: WHERE depth < 10.
6. WHERE col IN (SELECT ... ORDER BY ... LIMIT 10). Многие СУБД игнорируют LIMIT в подзапросе IN. Используйте JOIN с подзапросом.
7. Подзапрос в JOIN без индекса на соединяющую колонку. Материализуется во временную таблицу без индексов. На больших данных — full scan.
8. EXISTS в IF. IF EXISTS (SELECT ...) в PL/pgSQL/T-SQL — быстро. В обычном SELECT EXISTS используется как булев предикат WHERE.
N+1 и стратегии решения
Назначение

N+1 — антипаттерн, когда вместо одного запроса с JOIN-ом приложение делает 1 запрос на список родителей и N запросов на связанные данные. Типичный сценарий в Laravel: выборка 50 заказов и цикл с обращением к $order->user — 1 + 50 запросов. Каждый запрос — round-trip к БД, суммарно тысячи мс там, где могло быть 20.

Стратегии решения

Eager loading: with(...)

Eloquent делает 2 запроса: SELECT родителей, затем SELECT детей WHERE parent_id IN (...). Вместо N+1 — 2.

JOIN на уровне SQL

Один запрос с JOIN. Один round-trip, но при HasMany строки родителя дублируются.

Window functions для агрегатов

«Топ-N по группе» — одним запросом через ROW_NUMBER() OVER (PARTITION BY ...).

Денормализованный кеш

Агрегат (orders_count) на родителе. Чтение за одно обращение; запись дороже.

Практика: четыре подхода
// Классический N+1: 51 запрос
$orders = Order::where('status', 'paid')->limit(50)->get();
foreach ($orders as $order) {
    echo $order->user->name; // каждое обращение — SQL-запрос
}

// Eager loading: 2 запроса
$orders = Order::with('user')->where('status', 'paid')->limit(50)->get();

// Через JOIN (query builder): 1 запрос
$orders = DB::table('orders')
    ->join('users', 'users.id', 'orders.user_id')
    ->select('orders.*', 'users.name as user_name')
    ->get();
Особые случаи
1. N+1 в Resource/Transformer. Eloquent API Resource обращается к relationships — если не загружены, тихо генерирует N+1. Используйте Model::preventLazyLoading().
2. preventLazyLoading в проде. Включайте только в local/testing, либо handleLazyLoadingViolationUsing для логирования без падения.
3. Eager loading огромных relationships. Post::with('comments') на 100 постах с 200 комментариями — 20к в памяти. Используйте with(['comments' => fn ($q) => $q->limit(5)]).
4. JOIN вместо with без агрегации. JOIN дублирует строки родителя; для HasMany используйте with.
5. Eager loading не работает после Get. Используйте $orders->load('user') для lazy eager load.
6. Денормализованный счётчик без триггера. Между обновлениями — рассинхрон. Либо триггер, либо явное observer-обновление.
7. N+1 в GraphQL. Каждое поле в резолвере может породить запрос. Решение — DataLoader (батчинг + кеш на запрос).
8. withWhereHas. Метод в Laravel 9+ совмещает whereHas и with с одинаковым условием. Без него либо дублируете условие, либо получаете несогласованные данные.
ACID и уровни изоляции
Назначение

ACID — набор гарантий, которые транзакционная СУБД даёт приложению: Atomicity (всё или ничего), Consistency (БД переходит между корректными состояниями), Isolation (параллельные транзакции не «видят» промежуточных результатов друг друга), Durability (commit — навсегда). Самая нетривиальная буква — Isolation: за неё отвечают уровни изоляции.

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

READ UNCOMMITTED

Может читать незакоммиченные данные (dirty read). На практике почти не используется. Postgres не реализует — работает как READ COMMITTED.

READ COMMITTED

Видит только закоммиченные данные. Один SELECT внутри транзакции может возвращать разные результаты (non-repeatable read). Дефолт в Postgres.

REPEATABLE READ

Все чтения видят snapshot на момент начала транзакции. Phantom reads по стандарту допускаются, но Postgres исключает их через SSI. Дефолт в MySQL InnoDB.

SERIALIZABLE

Транзакции исполняются так, как если бы шли строго последовательно. В Postgres — через SSI; транзакции могут быть отменены с ошибкой could_not_serialize, требует retry-логики.

Практика: lost update
-- T1                              T2
BEGIN;                          BEGIN;
SELECT balance...               SELECT balance...
-- balance = 1000             -- balance = 1000
UPDATE SET balance=800;       UPDATE SET balance=900;
COMMIT;                          COMMIT;
-- T2 затёр T1: balance = 900 вместо 700

Решения:

  • Pessimistic locking: SELECT ... FOR UPDATE — блокировка строки до COMMIT.
  • Atomic UPDATE: UPDATE SET balance = balance - 200 WHERE balance >= 200.
  • Optimistic locking: колонка version, WHERE version = ?, retry при конфликте.
Особые случаи
1. Долгие транзакции. Удерживают блокировки и копят версии в MVCC. Postgres раздувается (bloat); MySQL копит row-level locks.
2. SERIALIZABLE без retry. Ошибка 40001 без retry — пользователь видит 500. Оборачивайте критичные транзакции в retry-цикл.
3. DB::transaction() в Laravel. Делает 1 попытку retry. Передавайте: DB::transaction($fn, 3).
4. Forgotten COMMIT. Открытая транзакция без COMMIT при exception оставляет блокировки. Используйте DB::transaction().
5. SELECT в транзакции REPEATABLE READ. Данные «замораживаются» до конца. Внешние изменения видны только после COMMIT.
6. DDL внутри транзакции. Postgres — транзакционно. MySQL InnoDB до 8.0 — неявно коммитит перед DDL.
7. Вложенные транзакции. Реализуются через savepoints. Внутренний ROLLBACK откатывает только до savepoint, внешний COMMIT может пройти.
8. Read replicas и изоляция. Реплика отстаёт на секунды. Чтение с реплики видит устаревшие данные даже на SERIALIZABLE.
Движки хранения и InnoDB-внутренности
Назначение

MySQL уникальна тем, что позволяет хранить разные таблицы в одной БД через разные «движки» (storage engines). У каждого — свои свойства: транзакции или нет, row-level или table-level locks, indexing, durability. Для современного приложения дефолт — InnoDB; понимание других движков нужно для редких специальных задач и для кругозора.

Основные движки MySQL
ДвижокТранзакцииLocksПрименение
InnoDBДа (ACID)Row-level + MVCCДефолт; всё, что требует надёжности
MyISAMНетTable-levelТолько legacy; до 5.5 был дефолтом, FULLTEXT
Memory (HEAP)НетTable-levelВременные таблицы в RAM; сессии (но Redis лучше)
ArchiveНетRow-levelWrite-only логи: сжатие, без UPDATE/DELETE
CSVНетTable-levelОбмен данными через CSV-файлы напрямую
BlackholeНет—Принимает INSERT и выкидывает; для репликации/тестов
InnoDB-внутренности

Buffer Pool

Главная кеш-структура InnoDB. Хранит в RAM страницы данных и индексов, недавно прочитанные/изменённые. Размер — innodb_buffer_pool_size, обычно 50-80% RAM сервера БД. Чем больше попадает в buffer pool, тем меньше I/O на диск.

Change Buffer

Оптимизация для записи: изменения в secondary-индексы (не PK) откладываются в специальный буфер, а не пишутся сразу на диск. Слияние происходит позже, в фоне, при чтении или периодически. Резко ускоряет INSERT/UPDATE на таблицах с многими индексами. Не работает для UNIQUE индексов (нужна немедленная проверка).

Doublewrite Buffer

Защита от «частичной записи» (torn page): перед записью страницы в основное место она сначала пишется в doublewrite-буфер. Если процесс упадёт посередине записи — при восстановлении страница берётся из doublewrite. Жертва: каждая запись делается дважды (но на быстром SSD это незаметно).

Redo log (WAL)

Write-ahead log: все изменения сначала пишутся в redo log, потом — в основные файлы. При краше БД проигрывает redo log с последнего checkpoint, восстанавливая закоммиченные транзакции. Размер — innodb_log_file_size, должен быть достаточным для буферизации хотя бы часа записи.

Undo log

Хранит предыдущие версии строк для MVCC и rollback. Активные транзакции читают свой snapshot через undo log. Если транзакция долгая — undo log растёт, БД распухает.

Практика: проверить, что используется InnoDB
-- Текущий дефолтный движок
SHOW VARIABLES LIKE 'default_storage_engine';

-- Движок конкретной таблицы
SELECT table_name, engine
FROM information_schema.tables
WHERE table_schema = 'shop';

-- Размер buffer pool и hit rate
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW STATUS LIKE 'Innodb_buffer_pool_reads%';
-- hit rate = 1 - (reads / read_requests); чем ближе к 1, тем лучше

-- Конвертация MyISAM → InnoDB
ALTER TABLE old_table ENGINE=InnoDB;
Особые случаи
1. MyISAM в новом проекте. Дефолт MySQL до 5.5; иногда в старых tutorials встречается. Никогда не используйте: нет транзакций, нет FK, table-level locks убивают concurrency.
2. Memory-таблица переживает рестарт? Нет. Содержимое теряется при рестарте mysqld. Только для временных данных.
3. innodb_buffer_pool_size мал. Если меньше размера активных данных — постоянные I/O на диск. На сервере с 32 GB RAM ставьте 16-24 GB.
4. Doublewrite отключён. innodb_doublewrite = 0 ускоряет запись, но при краше можно потерять данные. На critical-системах — никогда не отключайте.
5. Долгая транзакция и undo log. Транзакция, открытая на 5 минут, держит undo log для всех изменений вокруг. На write-heavy таблицах это распухает в десятки GB.
6. Конвертация без проверки FK. ALTER MyISAM → InnoDB на таблице, ссылающейся на не-InnoDB — FK не создадутся. Конвертируйте всю схему.
7. innodb_flush_log_at_trx_commit = 0. Логи сбрасываются на диск раз в секунду, не на каждый commit. Быстро, но при краше теряются последние секунды. Дефолт 1 — ACID-совместимо.
8. PostgreSQL не имеет «движков». Только heap-tables и Foreign Data Wrappers (FDW) для внешних источников. Не пытайтесь найти аналог MyISAM — PG monolithic by design.
Блокировки и MVCC
Назначение

Блокировки — механизм управления конкурентным доступом. MVCC (Multi-Version Concurrency Control) — альтернативный подход с версионированием. Современные СУБД используют гибрид: MVCC для чтений, row-level locks для записей. Понимание необходимо для диагностики deadlock'ов и проектирования схем без лишней конкуренции.

Виды блокировок

Shared (S) и Exclusive (X)

S-lock — для чтения, несколько одновременно. X-lock — для записи, исключает любые другие. SELECT FOR SHARE → S, SELECT FOR UPDATE → X.

Row-level vs Table-level

Row-level — высокая параллельность, дефолт в InnoDB и Postgres. Table-level — при ALTER TABLE и явных LOCK TABLES.

Intention locks

Перед взятием row-lock ставится intention lock на таблицу (IS/IX). Позволяет table-level операциям быстро узнать о существующих row-locks без перебора.

Gap и Next-Key locks (MySQL InnoDB)

Для предотвращения phantom reads на REPEATABLE READ. Блокировка «промежутков» между строками снижает параллельность INSERT.

MVCC

Каждое изменение создаёт новую версию строки с txid. Читающие транзакции видят только версии на момент начала. Postgres — физически; MySQL — через undo-log.

Deadlock

Две транзакции взаимно ждут блокировки друг друга. СУБД детектирует и отменяет одну с ошибкой (MySQL: 40001, Postgres: 40P01).

-- T1                          -- T2
UPDATE id=1;                    UPDATE id=2;
UPDATE id=2;                    UPDATE id=1;
-- T1 ждёт T2, T2 ждёт T1 → DEADLOCK

Решение — всегда брать блокировки в одном порядке (например, по возрастанию id).

Особые случаи
1. SELECT FOR UPDATE без транзакции. Снимается с COMMIT/ROLLBACK. Без транзакции (autocommit ON) защиты нет.
2. UPDATE по нескольким строкам в случайном порядке. Разный порядок захвата блокировок — deadlock. Используйте ORDER BY id.
3. Gap locks убивают параллельность INSERT. На REPEATABLE READ InnoDB ставит gap-locks при range-условии в SELECT FOR UPDATE. Переходите на READ COMMITTED для high-throughput.
4. MVCC bloat в Postgres. Долгие транзакции мешают autovacuum очистить старые версии. БД распухает.
5. SKIP LOCKED для очередей. Пропускает заблокированные строки. Идеально для воркеров очереди. MySQL 8+, Postgres 9.5+.
6. NOWAIT вместо ожидания. Мгновенно бросает ошибку. Лучше показать «занято», чем висеть 30 секунд.
7. Транзакция держит соединение. Длинные транзакции = занятые соединения из пула.
8. ALTER TABLE на проде. Требует table-lock. Используйте pt-online-schema-change, gh-ost.
Window-функции и CTE
Назначение

Оконные функции и CTE — декларативные конструкции, заменяющие самописные циклы и временные таблицы. Многие задачи «возьмём данные и пройдём в коде» решаются одним запросом — быстрее, меньше round-trip'ов. Поддержка: MySQL 8+, Postgres — полный включая рекурсивные CTE.

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

ROW_NUMBER()

Уникальный последовательный номер внутри партиции. Используется для «первый/последний на группу»: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1.

RANK() и DENSE_RANK()

RANK — с пропусками; DENSE_RANK — без пропусков.

LAG и LEAD

Значение из предыдущей/следующей строки в окне. Идеально для сравнения с предыдущим состоянием.

Aggregate over window

SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at) — накопительный итог. AVG(price) OVER (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) — скользящее среднее.

Практика: последний заказ каждого пользователя
SELECT user_id, id, created_at, total_minor
FROM (
    SELECT *,
           ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
) latest
WHERE rn = 1;

-- Рекурсивный CTE: иерархия категорий
WITH RECURSIVE tree AS (
    SELECT id, name, parent_id, 0 AS depth
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.parent_id, t.depth + 1
    FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;
Особые случаи
1. Window function в WHERE. Запрещено: вычисляются после WHERE. Используйте подзапрос или CTE с WHERE rn = 1.
2. CTE как оптимизационная граница. До Postgres 12 CTE были барьером. С 12+ — inline; явная материализация через WITH foo AS MATERIALIZED.
3. Рекурсивный CTE без терминатора. Бесконечная рекурсия при цикле в данных. Защита: WHERE depth < 10.
4. ORDER BY без ROWS BETWEEN. Дефолт — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (накопительный итог).
5. NULL в LAG/LEAD. На первой строке возвращает NULL. Указывайте default: LAG(value, 1, 0).
6. Window function на больших данных. Партиция требует сортировки. Партицируйте таблицу физически.
7. MySQL 5.7 и ниже. Window functions добавлены в 8. На старых — имитация через self-join, хрупкая.
8. CTE как замена View. CTE — только в одном запросе. Для повторно используемых — материализованное представление.
MySQL vs PostgreSQL vs SQL Server
Назначение

Три доминирующих СУБД в enterprise: MySQL, PostgreSQL, Microsoft SQL Server. На базовом SQL похожи; на уровне типов, продвинутых индексов, репликации и оптимизатора — разные. Понимание различий критично для интеграционных задач, миграций между СУБД, переносимых ORM-запросов и для senior-уровня.

Сводная таблица
АспектMySQL (InnoDB)PostgreSQLSQL Server
Дефолт изоляцияREPEATABLE READREAD COMMITTEDREAD COMMITTED
Auto-incrementAUTO_INCREMENTSERIAL / GENERATEDIDENTITY(1,1)
LimitLIMIT 10LIMIT 10TOP 10 / OFFSET FETCH
КонкатенацияCONCAT(a, b)a || ba + b / CONCAT
Текущее времяNOW()NOW() / CURRENT_TIMESTAMPGETDATE()
КавычкиОбратные `col`Двойные "col"Квадратные [col]
Clustered indexАвтоматически по PKHeap (CLUSTER явно)Автоматически по PK, либо явно CLUSTERED
JSONJSON, индекс через generated colsJSONB + GINJSON (как NVARCHAR + функции)
МассивыНетДа, нативноНет
ИндексыB-Tree, FULLTEXT, RTREEB-Tree, GIN, GiST, BRIN, Hash, SP-GiSTCLUSTERED, NONCLUSTERED, INCLUDE, filtered, FULLTEXT
Partial indexНет (workaround — generated cols)ДаДа (filtered index)
Covering indexШирокий композитINCLUDEINCLUDE
DDL транзакционноОграниченно (atomic DDL 8+)ДаДа
Параллельные запросыНетДа (9.6+)Да
RETURNINGНетДа: INSERT ... RETURNING idOUTPUT INSERTED.id
UPSERTON DUPLICATE KEY UPDATEON CONFLICT DO UPDATEMERGE
Online schema changept-osc, gh-ostpg_repack, CONCURRENTLYALTER TABLE ... WITH (ONLINE = ON)
Как Laravel абстрагирует диалекты

Eloquent и Query Builder генерируют разный SQL под разные драйверы. DB::table('users')->limit(10) на MySQL/PG превратится в LIMIT 10, на SQL Server — в SELECT TOP 10 или OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY. upsert() — соответствующий синтаксис каждой СУБД. Quoting идентификаторов автоматически: backtick/двойные/квадратные.

// Один код — три разных SQL под капотом
User::upsert(
    [['email' => 'a@example.com', 'name' => 'Anna']],
    ['email'],   // уникальный ключ
    ['name']     // что обновить при конфликте
);
// MySQL: INSERT INTO users (...) VALUES (...) ON DUPLICATE KEY UPDATE name = VALUES(name)
// PG:    INSERT INTO users (...) VALUES (...) ON CONFLICT (email) DO UPDATE SET name = excluded.name
// MSSQL: MERGE INTO users ...
RETURNING — killer-фича PostgreSQL для очередей и пулов

RETURNING — расширение SQL, которое позволяет INSERT/UPDATE/DELETE вернуть данные изменённых строк в том же ответе, как если бы вы сделали SELECT сразу. Поддерживается в PostgreSQL нативно, MySQL добавил похожее только в 8.0.21+ и только для DELETE (для UPDATE/INSERT — нет).

Обычный ответ UPDATE в MySQL/стандартном SQL — только количество затронутых строк (affected rows). Чтобы получить сами данные, нужно делать второй запрос — SELECT. RETURNING убирает эту двухшаговость.

Классический пример — «выдача ключа из пула»

Задача: в таблице stock_pool лежат ключи активации (Steam-keys, лицензионные коды). При заказе надо взять один свободный и намертво привязать к order_id. В условиях 1000 rps любой race condition = «два клиента получили один и тот же ключ».

-- PostgreSQL: один атомарный оператор
UPDATE stock_pool
   SET order_id = 'ord_1',
       issued_at = now()
 WHERE id = (
       SELECT id FROM stock_pool
        WHERE sku = 'KEY-GTA5' AND order_id IS NULL
        ORDER BY id
        LIMIT 1
        FOR UPDATE SKIP LOCKED
 )
RETURNING code;

Что этот запрос делает в один оператор:

  • Находит первый свободный ключ (order_id IS NULL) с блокировкой строки.
  • FOR UPDATE SKIP LOCKED — если 10 параллельных воркеров уже держат первые 10 ключей, перепрыгнет их и возьмёт 11-й.
  • Привязывает его к заказу (UPDATE ... SET).
  • Возвращает сам код (RETURNING code) — приложение получает готовое значение в ответе.
Что даёт RETURNING по сравнению с UPDATE+SELECT
АспектUPDATE + отдельный SELECT (MySQL)UPDATE ... RETURNING (PG)
АтомарностьМежду двумя запросами есть окно, где другой процесс может изменить строку → race condition, «не тот ключ»Захват, изменение и выборка — один оператор, гонки не бывает
Round-trip2 сетевых вызова к БД1 сетевой вызов
Синхронность с SKIP LOCKEDВторой SELECT должен повторно применять сложную логику — легко потерять «правильный» ключВозвращает именно ту строку, которая прошла блокировку в этом запросе
Латентность на 1000 rps+2×RTT на каждый заказ = десятки мс задержки1×RTT — вдвое быстрее
Другие типичные применения RETURNING
  • Получить сгенерированный ID при INSERT. INSERT INTO orders (...) VALUES (...) RETURNING id; — не нужно потом делать SELECT lastval() или currval().
  • Аудит удалений. DELETE FROM sessions WHERE expires_at < now() RETURNING id, user_id; — сразу знаешь, кого выкинул.
  • UPSERT с результатом. INSERT ... ON CONFLICT DO UPDATE ... RETURNING id, xmax = 0 AS inserted; — сразу видно, вставил ты новую строку или обновил старую.
  • Массовые операции с обратной связью. UPDATE ... WHERE ... RETURNING id; — вернёт ID всех затронутых строк, не нужен второй запрос.
MySQL-эквивалент: прямого RETURNING для UPDATE/INSERT нет. Обходные пути: LAST_INSERT_ID() для авто-инкремента после INSERT (только последний ID, не колонки); транзакция BEGIN; UPDATE ... ; SELECT ... FROM ... WHERE ...; COMMIT с блокировкой FOR UPDATE — работает, но многословнее и на high-throughput проектах менее удобно.
Laravel: прямой поддержки RETURNING в Query Builder нет — используйте DB::selectOne('UPDATE ... RETURNING code', [...]) либо DB::statement + PDO::fetchColumn. Для INSERT — DB::table(...)->insertGetId(...) работает и в MySQL (через LAST_INSERT_ID), и в PG (через RETURNING id) прозрачно.
Итог: в PostgreSQL RETURNING — не «удобство», а ключевой инструмент для реализации очередей, пулов, атомарной выдачи ресурсов. Он превращает потенциально небезопасную двухшаговую операцию в один надёжный атомарный запрос.
Partial index — путаница терминов MySQL vs PostgreSQL

Одно слово «partial index» — две совсем разные фичи в MySQL и PostgreSQL. Классический источник недопонимания и в переводимых статьях.

PostgreSQL: partial index = индекс по подмножеству строк (WHERE)

PostgreSQL позволяет индексировать не всю таблицу, а только строки, удовлетворяющие условию:

-- Индексируем только 'pending' задачи (обычно 0.1% от всех записей)
CREATE INDEX ON jobs (run_at) WHERE status = 'pending';

-- Индекс на активные заказы
CREATE INDEX ON orders (created_at) WHERE status IN ('paid', 'delivering');

-- Уникальность только для не-отменённых доставок
CREATE UNIQUE INDEX ON deliveries (order_id) WHERE status <> 'cancelled';

Что даёт:

  • Индекс физически меньше — только N строк из миллиона. Меньше RAM, дольше держится в кеше.
  • INSERT/UPDATE быстрее — индекс обновляется только для строк, попадающих под условие.
  • Запросы к «горячему подмножеству» — молниеносные (индекс маленький, всё в памяти).
  • Уникальность «с условием» — обычный UNIQUE не может выразить «уникально только для активных».
MySQL: partial index = префиксный индекс (первые N символов)

В MySQL термин «partial index» — это префикс колонки, а не подмножество строк. Индексируются все записи, но не целиком — только первые N символов длинного VARCHAR/TEXT:

-- Индексируем только первые 10 символов колонки 'name'
CREATE INDEX idx_name ON users (name(10));

-- Экономит место, если N символов уже дают достаточную селективность
CREATE INDEX idx_email ON users (email(20));
Сравнительная таблица
ХарактеристикаPostgreSQL «partial»MySQL «partial»
Что индексируетсяПодмножество строк, соответствующих WHEREВсе строки, но часть значения колонки (префикс)
СинтаксисWHERE condition в CREATE INDEX(column(N)) в CREATE INDEX
Основная цельУменьшить индекс + ускорить запросы к «горячему» подмножествуЭкономия места для длинных строк (VARCHAR/TEXT)
Поддержка условной уникальностиДа: UNIQUE ... WHERE ...Нет — префиксный индекс не про это
ПоддержкаС 1990-х, нативнаяУсловный WHERE-индекс — отсутствует, запрос на фичу отклонён
Как получить эффект PG partial index в MySQL
  • Generated columns. Создаёшь виртуальную колонку, равную полю только при условии, иначе NULL; индексируешь её. NULL-значения в B-tree занимают минимум места.
    ALTER TABLE jobs
      ADD COLUMN run_at_pending TIMESTAMP
        GENERATED ALWAYS AS (IF(status = 'pending', run_at, NULL)) VIRTUAL,
      ADD INDEX (run_at_pending);
  • Секционирование (partitioning). Таблицу делят на разделы по значению колонки (например, по статусу или дате), индекс есть только на нужном разделе.
  • Отдельная таблица для «горячих» строк. jobs + jobs_pending, куда триггер переносит записи при смене статуса. Радикально, но иногда лечит production.
Частый вопрос: если спросят «какие бывают partial indexes» — уточни, о какой СУБД речь. В контексте PostgreSQL это условные индексы, в MySQL — префиксные. Смешивать нельзя.
Мнемоника: в PG «partial» = «частичная выборка строк» (WHERE). В MySQL «partial» = «частичное значение колонки» (первые N символов). Одно слово, две разные оптимизации.
Почему выбрать PostgreSQL на новом проекте — аргументы «за»

Если новый Laravel-проект стартует с чистого листа и есть свобода выбора СУБД — PostgreSQL чаще всего выигрышный вариант. Не потому что «модно», а потому что для типичных бэкенд-задач (очереди, транзакции с побочками, атомарная выдача ресурсов, консистентность денежных операций) PG даёт из коробки несколько вещей, которых в MySQL просто нет.

Аргументация выбора: выбрать «MySQL, потому что я его знаю» — слабый сигнал. Выбрать «Postgres» и назвать 3 конкретных технических причины (RETURNING + SKIP LOCKED, предсказуемая изоляция, частичные индексы) — сильный.
1. Предсказуемая изоляция без «сюрпризов»

PostgreSQL по умолчанию — READ COMMITTED, поведение точно такое, как в учебнике: транзакция видит все закоммиченные изменения на момент каждого SELECT.

MySQL/InnoDB по умолчанию — REPEATABLE READ с нестандартной семантикой: обычный SELECT читает снапшот на момент начала транзакции, а SELECT ... FOR UPDATE — последнюю закоммиченную версию. Две строки подряд в одной транзакции видят разные данные:

-- MySQL, REPEATABLE READ. Между двумя SELECT кто-то закоммитил UPDATE.
BEGIN;
SELECT balance FROM accounts WHERE id = 1;             -- 100 (снапшот)
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 150 (последняя)
-- Классическая мина: логика решает по первому SELECT, обновление ушло по второму.

Для 90% CRUD это неважно. Для «ровно один раз под 50 параллельными запросами» — это источник багов, которые невозможно объяснить, глядя в свой код.

2. Отсутствие gap-локов при FOR UPDATE

InnoDB при SELECT ... FOR UPDATE блокирует не только строки, но и промежутки между ними (next-key locking) — чтобы защититься от «фантомов». Побочка: дедлоки там, где их логически быть не должно, особенно при выборке из очереди по условию (WHERE status = 'pending' LIMIT 10 FOR UPDATE).

В PostgreSQL — MVCC без gap-локов: блокируются только реально найденные строки. Меньше сюрпризов, проще отлаживать конкуренцию.

3. Отложенные ограничения (DEFERRABLE) — для журналов и денежных проводок

Классический случай: двойная запись в бухгалтерии. Каждая проводка = минимум две строки, и сумма проводки обязана быть нулём. Но во время вставки первой строки сумма ещё не ноль — ограничение упало бы, если проверяется построчно.

PostgreSQL умеет проверять ограничения в момент COMMIT, а не после каждой строки:

ALTER TABLE ledger_entries
  ADD CONSTRAINT balanced
  CHECK (...)
  DEFERRABLE INITIALLY DEFERRED;
-- Проверка сработает только на COMMIT

В MySQL такого механизма нет — пришлось бы городить триггеры или проверять в коде приложения.

4. Advisory locks — блокировка «по имени» без реальной строки

Классический кейс: «вебхук от платёжки пришёл раньше, чем создался заказ» — надо сериализовать обработчиков, но строки в БД ещё нет. PostgreSQL умеет транзакционные advisory locks — «именной» лок, который сам отпустится при COMMIT/ROLLBACK:

SELECT pg_advisory_xact_lock(hashtext('ord_1'));
-- Второй воркер с тем же 'ord_1' будет ждать первого
-- При commit/rollback лок освободится автоматически

В MySQL есть GET_LOCK('name'), но он привязан к сессии, а не к транзакции — при откате не освобождается, надо явно RELEASE_LOCK. Утечки локов после исключений — типичная беда.

5. INSERT IGNORE в MySQL — тихо опасен

INSERT IGNORE в MySQL глушит все ошибки, а не только нарушение уникальности: обрезанные строки, неверные типы, нарушение внешних ключей. Вставил мусор — молча получил 0 rows, а потом полдня ищешь, где данные пропали.

PostgreSQL ON CONFLICT (event_id) DO NOTHING реагирует только на конкретный конфликт по конкретному индексу. Всё остальное честно падает исключением. Для дедупликации платёжных событий, идемпотентности вебхуков — разница принципиальная.

6. Приятные мелочи
ФичаЧто даёт
Транзакционный DDLМиграция упала на 5-м шаге — откатились все 5. В MySQL DDL коммитится сам собой, и остаёшься с полусхемой (в prod — кошмар).
EXPLAIN (ANALYZE, BUFFERS)Реальное время по узлам, факт vs. оценка, попадания в кеш. Ловля bottleneck'ов на порядок точнее, чем в MySQL EXPLAIN.
JSONB + GIN-индексыСырые payload'ы вебхуков / ответы поставщиков лежат «как есть», индексируются нативно (WHERE data @> '{"status":"paid"}'). В MySQL — только через generated columns.
CHECK constraintsРаботают. В MySQL до 8.0.16 CHECK молча игнорировался — парсился и выбрасывался, будто его нет.
timestamptzРеально хранит таймзону + UTC-нормализация. MySQL DATETIME — «голая» строка без таймзоны, приложение должно всё разруливать само.
Массивы, ranges, custom-типыНативные, работают в WHERE/индексах. MySQL — нет вообще.
Что переносится без боли — шпаргалка

SQL, который ты знаешь по MySQL, переносится почти весь. Отличия в диалекте — минут на 30 чтения:

MySQLPostgreSQL
`col` (backticks)"col" (двойные кавычки, часто вообще не нужны)
AUTO_INCREMENTGENERATED ALWAYS AS IDENTITY / SERIAL
IFNULL(a, b)COALESCE(a, b) (тоже работает в MySQL)
CONCAT(a, b)a || b
INSERT IGNOREINSERT ... ON CONFLICT DO NOTHING
ON DUPLICATE KEY UPDATEON CONFLICT (col) DO UPDATE SET ...
DATETIMEtimestamptz
SHOW TABLES\dt в psql
LIMIT 10 OFFSET 5Так же
SELECT / JOIN / GROUP BY / оконные функцииИдентично (один стандарт)
Ставить ничего не надо — Docker
# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: gamer_market
      POSTGRES_PASSWORD: secret
    ports: ["5432:5432"]

Одна команда docker compose up -d — БД готова. Проверяющий делает то же самое одной командой.

В Laravel — драйвер pgsql вместо mysql, в .env: DB_CONNECTION=pgsql, DB_PORT=5432. PDO-код (prepare/execute/fetch) — не меняется. Eloquent абстрагирует диалект автоматически.

3 аргумента, которые прозвучат на защите архитектуры:
  1. RETURNING + FOR UPDATE SKIP LOCKED — атомарный захват из очереди/пула одним запросом.
  2. Предсказуемая изоляция READ COMMITTED без gap-локов — конкуренция ведёт себя так, как объясняешь.
  3. Частичные индексы + честный EXPLAIN ANALYZE — точечная оптимизация «горячего» подмножества данных.
Full-Text Search: внутри СУБД или специальный движок?
РешениеКогда подходит
MySQL FULLTEXTПростой поиск по тексту, нативно. Ограничения: только InnoDB 5.6+, ограниченная фильтрация, средние результаты ранжирования.
PostgreSQL tsvector + GINМощный нативный full-text: морфология, ранжирование, фасеты. Достаточно для большинства проектов.
SQL Server FULLTEXTЕсть, но требует настройки FT-сервиса. Уровень PG.
ElasticsearchБольшие объёмы (миллионы документов), сложные агрегации, faceted search, autocomplete. Отдельная инфраструктура.
Meilisearch / TypesenseМалые/средние проекты, нужна простота и скорость, typo-tolerance.
Manticore / SphinxАльтернатива ES с упором на скорость для текстового поиска.

Эмпирика: до 100к документов — нативный FULLTEXT/tsvector. От 100к до 10М — Meilisearch/Typesense. Свыше — Elasticsearch с отдельной командой эксплуатации.

Особые случаи
1. JSON-операции непереносимы. Синтаксис разный: MySQL JSON_EXTRACT(col, '$.path'); Postgres col->'path'.
2. Регистронезависимость. MySQL по умолчанию нечувствителен к регистру (utf8mb4_unicode_ci); Postgres — чувствителен.
3. UUID хранение. Postgres — нативный uuid. MySQL — CHAR(36) или BINARY(16).
4. BOOLEAN в MySQL. Синоним TINYINT(1); TRUE/FALSE = 1/0.
5. Default sort order. Без ORDER BY порядок не гарантирован. Никогда не полагайтесь.
6. Connection pooling. PgBouncer (Postgres), ProxySQL (MySQL) решают разные проблемы по-разному.
7. EXPLAIN формат. Совершенно разный синтаксис и колонки.
8. Online schema changes. MySQL: pt-online-schema-change, gh-ost. Postgres: pg_repack, ALTER TABLE с осторожностью.
Hints, Partitioning, Materialized Views, Stored Procedures
Назначение

Большинство оптимизаций решаются индексами и переписыванием запроса. Когда этого не хватает — в арсенале остаются продвинутые механизмы: подсказки оптимизатору, физическое партиционирование, материализованные представления, хранимые процедуры. Каждый имеет цену — либо в сложности кода, либо в стоимости поддержки. Использовать их нужно только когда обычные средства исчерпаны.

Database hints

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

-- MySQL: принудительный индекс
SELECT * FROM orders FORCE INDEX (idx_user_created)
WHERE user_id = 42;

-- MySQL: подсказка JOIN-стратегии
SELECT /*+ JOIN_ORDER(u, o) */ *
FROM users u JOIN orders o ON ...

-- SQL Server: тип JOIN, локи
SELECT * FROM orders WITH (NOLOCK)
OPTION (LOOP JOIN, MAXDOP 4);

-- PostgreSQL: hints — через расширение pg_hint_plan, не нативно
/*+ IndexScan(orders idx_user_id) */
SELECT * FROM orders WHERE user_id = 42;
Когда оправдано: аварийный случай, нужно срочно ускорить запрос на проде. Долгосрочно: устаревшая статистика (исправьте через ANALYZE), плохая селективность (исправьте индексом), плохая форма запроса (перепишите). Hints скрывают проблему — через полгода они становятся вредными.
Database statistics: ANALYZE и автообновление

Оптимизатор принимает решения на основании статистики: сколько строк в таблице, как распределены значения колонок, селективность индексов. Если статистика устарела (после массового импорта/удаления) — план может быть в разы хуже оптимального.

-- MySQL: обновить статистику таблицы
ANALYZE TABLE orders;

-- PostgreSQL: обновить статистику
ANALYZE orders;
-- VACUUM ANALYZE — очистка bloat + статистика
VACUUM ANALYZE orders;

-- SQL Server
UPDATE STATISTICS orders;
-- С полной выборкой:
UPDATE STATISTICS orders WITH FULLSCAN;

PostgreSQL запускает autovacuum автоматически — обычно настройка по умолчанию подходит. На write-heavy таблицах может отставать, нужно тюнить autovacuum_vacuum_scale_factor. MySQL обновляет статистику InnoDB лениво при изменении 10% таблицы.

Partitioning и Sharding

Partitioning — одна БД, физически разделённая таблица

Таблица бьётся на несколько частей по правилу: RANGE (по диапазону, типично по дате), LIST (по списку значений), HASH (равномерно). Каждая партиция — отдельный файл. Запрос с фильтром по ключу партиционирования читает только одну партицию (partition pruning).

-- MySQL: партиции по году
CREATE TABLE orders (
    id BIGINT, created_at DATETIME, ...
) PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p2026 VALUES LESS THAN (2027)
);

Удаление старых данных через DROP PARTITION — мгновенное (просто отвязывается файл), вместо DELETE на миллион строк.

Sharding — разные БД на разных серверах

Данные разнесены по нескольким независимым БД (shards). Запрос идёт в конкретный shard по ключу (например, user_id % shards_count). Цена: JOIN между shards становится сложным/невозможным, транзакции через shards требуют distributed transactions (или их избегания). Применяется на масштабах, где одна БД не вмещает данные или не вытягивает нагрузку.

Materialized Views

View — виртуальная таблица, запрос которой выполняется при каждом обращении. Materialized View — физически сохранённый результат запроса, обновляемый по расписанию. Идеален для тяжёлых аналитических агрегатов, читаемых часто, обновляемых редко.

-- PostgreSQL
CREATE MATERIALIZED VIEW daily_sales AS
SELECT DATE(created_at) AS day, SUM(total_minor) AS total
FROM orders WHERE status = 'paid' GROUP BY DATE(created_at);

-- Создать индекс на view (как на обычной таблице)
CREATE UNIQUE INDEX idx_daily_sales_day ON daily_sales (day);

-- Обновить — по расписанию (cron / Laravel Scheduler)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;

-- SQL Server (Indexed View)
CREATE VIEW daily_sales WITH SCHEMABINDING AS ...;
CREATE UNIQUE CLUSTERED INDEX idx ON daily_sales(day);

-- MySQL не имеет нативно — workaround через summary tables + триггеры/events
Stored Procedures

Хранимая процедура — код на процедурном расширении SQL (PL/pgSQL, T-SQL), живущий внутри БД. Преимущества: меньше round-trips, доступ к данным без передачи в приложение, можно вызывать из триггеров. Недостатки: язык БД (а не язык приложения), сложнее версионировать, тестировать, отлаживать.

-- PostgreSQL: процедура с транзакцией
CREATE OR REPLACE PROCEDURE transfer_funds(
    from_id BIGINT, to_id BIGINT, amount INT
)
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_funds(1, 2, 500);

Современная практика: избегать stored procedures в новых проектах. Логика принадлежит приложению, БД должна оставаться «хранилищем». Исключения: критичные операции с миллионами row-операций (где избегание round-trip даёт x10 выигрыш), legacy-проекты с уже сложившейся практикой.

UNION vs UNION ALL

UNION убирает дубликаты (требует сортировки/хеширования всего результата). UNION ALL — просто склеивает строки. На больших объёмах разница огромная: UNION ALL в 5-10 раз быстрее.

-- UNION без необходимости — лишний overhead
SELECT id FROM active_users
UNION
SELECT id FROM archived_users;
-- СУБД проверяет на дубли даже если они невозможны

-- UNION ALL — если дубли невозможны/неважны
SELECT id FROM active_users
UNION ALL
SELECT id FROM archived_users;
Особые случаи
1. FORCE INDEX как лекарство. Чинит сейчас, ломает через полгода (статистика изменилась). Сначала ANALYZE, потом — если не помогло — разбираться с дизайном индекса.
2. Партиционирование без partition pruning. Если ключ партиционирования не используется в WHERE, СУБД сканирует все партиции — хуже одной таблицы. Партиционировать только если ключ всегда в фильтре.
3. Materialized View и stale data. Между REFRESH данные могут устареть. Пользователь видит вчерашнее. Декларируйте SLA явно или используйте CONCURRENTLY + scheduled refresh каждые N минут.
4. Stored procedures как полная логика приложения. Сложно версионировать, тестировать, отлаживать; нельзя писать unit-тесты на PHP. Используйте только для критичных по производительности операций.
5. UNION вместо UNION ALL. Дефолтная привычка из туториалов. На больших данных — кратное замедление. Используйте ALL, если не нужна явная дедупликация.
6. ANALYZE на проде в час пик. Захватывает statistics-lock; на больших таблицах может затормозить запросы. На write-heavy — запускайте ночью или с лимитом sample rate.
7. Sharding без ясной стратегии. Резкое усложнение архитектуры. Сначала: вертикальное масштабирование, read replicas, материализованные views — всё это решает проблемы до объёмов в десятки TB.
8. WHERE YEAR(date) = 2024. Функция вокруг колонки убивает индекс. Перепишите как range: WHERE date BETWEEN '2024-01-01' AND '2024-12-31'.
PDO в PHP
Назначение

PDO — унифицированный интерфейс работы с реляционными БД, прозрачный между MySQL/PostgreSQL/SQLite/MSSQL. Laravel оборачивает PDO в Eloquent/Query Builder; знание PDO нужно для понимания, что происходит под капотом, безопасной обработки пограничных случаев и грамотных формулировок.

Базовые компоненты

Connection (PDO instance)

Объект подключения с DSN, credentials и опциями. Laravel держит один PDO-инстанс на connection.

Prepared statements

Запрос компилируется один раз с placeholder'ами, исполняется N раз. Безопасность от инъекций, кеширование плана, переиспользование. PHP поддерживает ? и :name.

Fetch modes

FETCH_ASSOC, FETCH_OBJ, FETCH_CLASS, FETCH_KEY_PAIR. Eloquent — кастомный fetch с гидрацией моделей.

Error mode

ATTR_ERRMODE: SILENT, WARNING, EXCEPTION. С PHP 8.0+ EXCEPTION — дефолт.

Практика: безопасный запрос
use PDO;

$pdo = new PDO('mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4', 'user', 'pass', [
    PDO::ATTR_ERRMODE             => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE  => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES    => false, // настоящие prepared, не эмулированные
]);

$stmt = $pdo->prepare('SELECT id, email FROM users WHERE status = :status LIMIT :limit');
$stmt->bindValue(':status', 'active', PDO::PARAM_STR);
$stmt->bindValue(':limit',  10,       PDO::PARAM_INT);
$stmt->execute();

foreach ($stmt as $row) {
    echo $row['email'];
}
Особые случаи
1. ATTR_EMULATE_PREPARES = true. PDO эмулирует prepared в PHP, посылая полный SQL. Защита сохраняется, но типы — всегда строки (LIMIT :n превращается в строку, MySQL ругается). Включайте false.
2. bindParam vs bindValue. bindParam привязывает ссылку. В цикле — баг «все строки одинаковые».
3. Положительные числа как строки. Параметр из $_GET — строка. Для LIMIT в MySQL: (int) $_GET['limit'] + PARAM_INT.
4. LIKE с пользовательским вводом. Prepared защищает от инъекций, не от метасимволов LIKE. Экранируйте % и _.
5. Имена таблиц не параметризируются. PDO привязывает только значения. Динамическая таблица — через whitelist.
6. Незакрытый cursor на MySQL. Следующий запрос бросит ошибку. $stmt->closeCursor() или buffered queries.
7. Утечка соединения. PDO-объект, держимый в синглтоне, удерживает соединение до конца процесса. В long-running — накапливается.
8. lastInsertId() и replication lag. На read replica только что вставленная строка не видна. Читайте из мастера.
Практика: оптимизация медленного запроса
Постановка

Аналитический запрос на дашборде «топ-10 пользователей по сумме платежей за последний месяц с количеством заказов» работает 4.2 секунды на проде. Таблицы: users (1.2 млн), orders (8 млн), payments (15 млн). Задача — довести время до < 50 мс без архитектурных изменений.

Шаг 1: исходный запрос (4.2 с)
SELECT u.id, u.email,
       COUNT(DISTINCT o.id)  AS orders_count,
       SUM(p.amount_minor)   AS total_paid
FROM      users    u
LEFT JOIN orders   o ON o.user_id = u.id
LEFT JOIN payments p ON p.user_id = u.id
WHERE p.created_at >= NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email
ORDER BY total_paid DESC
LIMIT 10;

Проблемы: (1) WHERE по p.created_at с LEFT JOIN превращает его в INNER; (2) двойной JOIN дублирует строки, COUNT(DISTINCT) и SUM работают на дублированном множестве; (3) полное сканирование 8M orders + 15M payments + 1.2M users.

Шаг 2: исправить семантику (1.8 с)
SELECT u.id, u.email,
       COALESCE(o.orders_count, 0) AS orders_count, p.total_paid
FROM users u
JOIN (SELECT user_id, SUM(amount_minor) AS total_paid
       FROM payments
       WHERE created_at >= NOW() - INTERVAL '30 days'
       GROUP BY user_id) p ON p.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS orders_count FROM orders GROUP BY user_id) o ON o.user_id = u.id
ORDER BY p.total_paid DESC
LIMIT 10;
Шаг 3: добавить индексы (180 мс)
CREATE INDEX idx_payments_created_user_amount
  ON payments (created_at, user_id) INCLUDE (amount_minor);
CREATE INDEX idx_orders_user_id ON orders (user_id);
Шаг 4: сузить выборку до топ-10 (40 мс)
WITH top_payers AS (
    SELECT user_id, SUM(amount_minor) AS total_paid
    FROM payments
    WHERE created_at >= NOW() - INTERVAL '30 days'
    GROUP BY user_id
    ORDER BY total_paid DESC
    LIMIT 10
)
SELECT u.id, u.email, tp.total_paid,
       (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS orders_count
FROM top_payers tp JOIN users u ON u.id = tp.user_id
ORDER BY tp.total_paid DESC;

CTE сужает множество до 10 user_id за один Index Scan по payments. Дальше — точечные lookup'ы по индексам. Итог: 4200 мс → 40 мс. Архитектура не менялась, кэш не добавлен.

Сводные подводные камни
Топ ошибок
1. SELECT * в продакшен-коде. Тянет все столбцы, ломает covering index, делает запрос хрупким к ALTER TABLE.
2. ORDER BY RAND(). Сортирует всю таблицу случайно. Используйте OFFSET random() или столбец с pre-generated random.
3. OFFSET для пагинации. OFFSET 100000 заставляет прочитать первые 100к и отбросить. Keyset pagination: WHERE id > last_id ORDER BY id LIMIT 10.
4. COUNT(*) на большой таблице. Postgres — full scan. Приближённо: pg_stat_user_tables.n_live_tup.
5. UPDATE без WHERE на проде. Включите SET SQL_SAFE_UPDATES = 1.
6. TRUNCATE в транзакции MySQL. Неявно коммитит; rollback не вернёт данные. На Postgres — транзакционный.
7. Hard-coded 'utf8'. MySQL utf8 — 3-байтная подделка. Всегда utf8mb4.
8. Долгие транзакции с пользовательским вводом. Открыли транзакцию, ждём ответа API — соединение и блокировки висят. Внешние вызовы — вне транзакции.
9. Игнорирование NULL в UNIQUE. Два NULL не равны — уникальный индекс не предотвращает дубли NULL. Partial index WHERE col IS NOT NULL.
10. JSON_EXTRACT в WHERE без индекса. Полное сканирование. Используйте GIN на JSONB.
11. ENUM с большим количеством значений. Расширение — ALTER с перезаписью. Используйте справочную таблицу + FK.
12. Replication lag в read-modify-write. Запись на мастер, чтение со slave — видим устаревшее. Читайте с мастера после критичных записей.
13. WHERE YEAR(col) = 2024 убивает индекс. Функция вокруг колонки делает индекс неприменимым. Перепишите как range: WHERE col BETWEEN '2024-01-01' AND '2024-12-31'.
14. LEFT JOIN + WHERE IS NOT NULL = INNER JOIN. Если условие на правой стороне идёт в WHERE — вы фактически отфильтровываете строки, где правая сторона NULL, превращая LEFT в INNER. Если хотели INNER — пишите явно.
15. Correlated subquery в SELECT. SELECT u.*, (SELECT MAX(...) FROM orders WHERE user_id = u.id) FROM users — N+1 на уровне SQL. Перепишите через JOIN + GROUP BY или CTE.
16. Забытое условие при старом SQL-89 синтаксисе. FROM users, orders без WHERE = CROSS JOIN на триллион строк. Используйте только ANSI JOIN с явным ON.
17. UNION вместо UNION ALL. UNION неявно делает DISTINCT — сортирует/хеширует весь результат. Если дубли невозможны/неважны — UNION ALL быстрее в разы.
18. FORCE INDEX как «решение». Маскирует устаревшую статистику или плохой дизайн индекса. Через полгода данные изменятся и hint станет вредным.
Вопросы для самопроверки (middle / senior)
Основы и индексы

1. Почему B-Tree по умолчанию?

B-Tree поддерживает поиск по равенству, диапазон и сортировку с одной структурой; Hash — только равенство. Большинство запросов комбинируют эти операции, поэтому B-Tree универсальнее.

2. Что такое leftmost prefix rule?

Композитный индекс (a, b, c) используется при фильтре по a, (a,b), (a,b,c). Только по b или c — не работает. Из этого: первой ставится колонка с точной фильтрацией; следующие — по убыванию селективности.

3. Что такое covering index?

Индекс содержит все столбцы, нужные запросу (и WHERE, и SELECT). СУБД отвечает только из индекса. Postgres — через INCLUDE; MySQL — широким композитом.

Транзакции

4. Чем отличаются READ COMMITTED и REPEATABLE READ?

При READ COMMITTED один SELECT может возвращать разные результаты (non-repeatable read). При REPEATABLE READ — snapshot на момент начала транзакции, non-repeatable невозможен. Postgres дополнительно исключает phantom reads на REPEATABLE READ.

5. Как избежать lost update?

Три подхода: (а) атомарный UPDATE с условием; (б) pessimistic locking через SELECT FOR UPDATE; (в) optimistic locking через version + retry при конфликте.

6. Что такое deadlock и как его избежать?

Две транзакции взаимно ждут блокировки друг друга. СУБД детектирует и отменяет одну. Профилактика: всегда захватывать блокировки в одном порядке, избегать долгих транзакций, использовать SKIP LOCKED для очередей.

Производительность

7. Как искать причину медленного запроса?

(1) включить slow query log; (2) EXPLAIN ANALYZE; (3) искать Seq Scan на больших таблицах, Filter после Index Scan, Sort/filesort; (4) проверить актуальность статистики (ANALYZE); (5) при расхождении оценки и факта — устаревшая статистика; (6) добавить индекс или переписать запрос.

8. Что такое N+1 и способы решения?

Один запрос на родителей + N запросов на детей. В Eloquent — eager loading (with('relation')): 2 запроса вместо N+1. Альтернативы: JOIN, денормализованные счётчики, window functions, GraphQL DataLoader.

9. Что покажет EXPLAIN для WHERE LOWER(email) = ??

Полное сканирование, даже если есть индекс по email. Функция вокруг колонки делает индекс неприменимым. Решения: функциональный индекс или нормализация регистра при вставке.

Продвинутое

10. JSONB в Postgres vs JSON в MySQL?

JSONB — бинарный с GIN-индексированием (WHERE data @> '{"status":"paid"}' работает за O(log n)). MySQL JSON — индексирование через generated columns. На больших JSON-запросах Postgres удобнее.

11. Когда window function вместо self-JOIN?

Когда нужно значение по соседним строкам в окне (предыдущая, накопительный итог, ранг). Window — одно сканирование; self-JOIN — квадратичная сложность. На тысячах строк разница на порядки.

12. Что такое prepared statement?

Запрос с placeholder'ами, компилируемый раз, исполняемый N раз. Безопасность от инъекций (значения не парсятся как SQL), кешируемый план. На MySQL включайте ATTR_EMULATE_PREPARES = false для настоящих серверных prepared.

13. UPSERT в MySQL vs Postgres?

MySQL: INSERT ... ON DUPLICATE KEY UPDATE. Postgres: INSERT ... ON CONFLICT (col) DO UPDATE SET col = EXCLUDED.col. Postgres требует указать колонку конфликта явно — точнее.

14. Что такое MVCC и почему чтения не блокируют записи?

Каждое изменение строки создаёт новую версию с txid. Читающие транзакции видят только версии на момент начала. SELECT никогда не ждёт UPDATE. Цена: bloat от старых версий, очищаемых через autovacuum.

15. Как сделать пагинацию на 100к-ю страницу без O(N)?

Keyset pagination: WHERE id > :last_id ORDER BY id LIMIT 10. СУБД делает Index Seek в нужное место. Минус: нельзя перепрыгивать на «страницу 500».

Внутренности и продвинутые механизмы

16. Чем correlated subquery отличается от uncorrelated?

Uncorrelated не ссылается на колонки внешнего запроса — выполняется один раз. Correlated ссылается — выполняется для каждой строки внешнего, что эквивалентно N+1 на уровне SQL. Лечение: переписать через JOIN + GROUP BY, CTE или window function.

17. Чем EXISTS отличается от IN и когда они дают разный результат?

EXISTS останавливается на первом совпадении; IN материализует полный список. Дают разный результат при NULL: NOT IN (...) с NULL в подзапросе вернёт пусто (UNKNOWN-логика); NOT EXISTS работает корректно. На больших подзапросах EXISTS обычно эффективнее.

18. Что такое buffer pool в InnoDB и почему его размер критичен?

Главный in-memory кеш страниц данных и индексов. Все чтения и записи сначала идут через него. Если активные данные не помещаются в buffer pool — постоянные I/O на диск. Дефолт innodb_buffer_pool_size = 128MB подходит только для разработки; в проде ставится 50-80% RAM сервера БД.

19. Когда применять partitioning и в чём отличие от sharding?

Partitioning — одна БД, физическое разделение таблицы на части по правилу (RANGE/LIST/HASH). Запрос с фильтром по ключу читает только нужную партицию (pruning). Хорош для time-series и архивации. Sharding — данные на разных серверах; ключ определяет, к какому шарду идти. Цена: сложность JOIN/транзакций между шардами. Sharding применяется когда одной БД не хватает.

20. Materialized View vs обычный View — практическая разница?

Обычный View — синоним сохранённого SELECT, выполняется при каждом обращении. Materialized View — физически сохранённый результат, обновляется по команде REFRESH. MV даёт мгновенный ответ на тяжёлые агрегаты (отчёты, дашборды), цена — данные могут быть устаревшими между обновлениями. Хорошая стратегия — schedule refresh каждые 5-15 минут или после критичных событий.

21. Почему стоит избегать stored procedures в современных проектах?

Логика приложения уезжает в БД — сложнее версионировать (нет git-аналога для процедур, кроме миграций), сложнее тестировать (нельзя обычный unit-test на PHP), сложнее отлаживать. Альтернативные языки (PL/pgSQL, T-SQL) ограничены. Исключение: критичные по производительности операции с миллионами row-операций, где избегание round-trip даёт x10 выигрыш.

22. Какая разница между SERIAL, AUTO_INCREMENT и IDENTITY?

Семантически одно — auto-incrementing PK. Реализации разные: PostgreSQL SERIAL создаёт sequence + default; MySQL AUTO_INCREMENT хранит next-value в metadata; SQL Server IDENTITY(1,1) — встроенное свойство колонки. Поведение при rollback: gap (PG/MySQL пропускают значения), что не критично, но удивляет джунов.