SQL и базы данных
Реляционная модель, индексы, EXPLAIN, транзакции и MVCC, оконные функции, отличия MySQL и PostgreSQL, паттерны запросов. Глубокий разбор middle/senior уровня с примерами и подводными камнями каждого механизма.
База данных — единственный коллаборатор приложения, который пишет данные надолго: упасть может всё что угодно, но в реляционной БД останется следствие сегодняшней работы. Поэтому стоимость ошибки в БД-коде выше стоимости ошибки в любом другом слое: неверный индекс деградирует все читающие запросы, неверная транзакция теряет данные, неверный 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 мс |
Часто путают три ортогональных признака: модель данных (реляционная / документная / key-value / графовая), ориентация хранения (строчная / колоночная) и назначение (OLTP оперативные транзакции / OLAP аналитика). Это независимые оси — одна БД может быть «реляционная + строчная + OLTP» (PostgreSQL), другая «реляционная + колоночная + OLAP» (ClickHouse), третья «документная + строчная + OLTP» (MongoDB).
user:1 → "{name:Alice}"). Реляционная модель — таблицы со строками и столбцами + связи через foreign key + SQL как язык запросов. У них разные назначения.| Модель | Как хранит | Кто | Когда применять |
|---|---|---|---|
| Реляционная (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. |
Даже среди реляционных БД (одна и та же модель — таблицы) физическое хранение может быть разным. Это не про модель данных, а про то, как байты лежат на диске.
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-oriented | Column-oriented |
|---|---|---|
Чтение SELECT * одной строки | 1 seek | N seeks (по одному на колонку) |
Агрегация SUM/AVG одной колонки | Читает ВСЕ колонки | Читает только нужную |
| INSERT одной строки | Быстро | Медленнее (запись в N файлов) |
| UPDATE / DELETE | Норм | Дорого (некоторые вообще запрещают) |
| Сжатие | Хуже (разные типы вперемешку) | Отличное (одинаковые данные подряд) |
| Индексы нужны часто? | Да, много | Реже — скан быстрый |
| Типичная задача | OLTP (транзакции) | 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 |
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) |
| Аналитика на миллиарды строк, dashboards | ClickHouse / BigQuery / Snowflake |
| Полнотекстовый поиск, автодополнение | Elasticsearch / Meilisearch (или FTS в PG) |
| Соц.граф, рекомендации | Neo4j |
| Метрики, IoT, финансовые тики | InfluxDB / TimescaleDB |
| Semantic search для LLM | pgvector / Pinecone / Weaviate |
| Mobile app, embedded, KB, dev-локалка | SQLite |
Реляционная модель, предложенная Эдгаром Коддом в 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.
IS NULL в каждом запросе.ON DELETE CASCADE бездумно. Удобен для слабых сущностей (order_items при удалении orders), но опасен для сильных (удаление user'а сносит весь профиль, заказы, платежи).CHECK на MySQL до 8.0.16. В старых версиях CHECK парсился, но не применялся — ограничения молча игнорировались.user_name в orders. Если имя меняется, в заказах остаётся старое.FLOAT приблизительный; 0.1 + 0.2 != 0.3. Деньги хранятся как INT в минорных единицах или DECIMAL(p, s).ENUM для растущего списка. Добавить значение — ALTER TABLE с переписыванием. Используйте FK на справочную таблицу для растущих доменов.Выбор типа данных — первое архитектурное решение в схеме. Тип определяет диапазон значений, размер на диске, скорость операций, поведение в индексах и совместимость между СУБД. Неверный тип приводит к двум классам проблем: тихая потеря данных (округление FLOAT для денег, переполнение TINYINT) и деградация производительности (CHAR(255) вместо VARCHAR, BLOB в часто читаемых строках).
| Тип | Байт | Signed диапазон | Unsigned диапазон |
|---|---|---|---|
TINYINT | 1 | −128..127 | 0..255 |
SMALLINT | 2 | −32 768..32 767 | 0..65 535 |
MEDIUMINT | 3 | ~−8.4M..8.4M | 0..16 777 215 |
INT | 4 | ~−2.1B..2.1B | 0..4 294 967 295 |
BIGINT | 8 | ~±9.2×1018 | 0..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.
-- Никогда. 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 в минорных единицах.
| Тип | Размер | Диапазон / особенности |
|---|---|---|
DATE | 3 байта | 1000-01-01 .. 9999-12-31 |
TIME | 3 байта | −838:59:59 .. 838:59:59 |
DATETIME | 5 байт | 1000-01-01 .. 9999-12-31, без TZ (8 байт до MySQL 5.6.4) |
TIMESTAMP | 4 байта | 1970-01-01 .. 2038-01-19, хранит UTC |
YEAR | 1 байт | 1901..2155 |
Проблема 2038: 32-битный TIMESTAMP переполнится в январе 2038. Для долговременных дат (контракты, договоры) — DATETIME. PostgreSQL: TIMESTAMP WITH TIME ZONE (timestamptz) — хранит UTC и конвертирует в TZ клиента; TIMESTAMP WITHOUT TIME ZONE — «голое» значение.
| Тип | Хранение | Когда применять |
|---|---|---|
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 / 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 байта на длину |
TINYTEXT | 255 байт |
TEXT | 64 КБ |
MEDIUMTEXT | 16 МБ |
LONGTEXT | 4 ГБ |
VARBINARY, BLOB и варианты | То же самое, но для бинарных данных |
JSON | Хранится в бинарном виде, лимит как у LONGTEXT |
DECIMAL(p, s) | Примерно 4 байта на каждые 9 цифр |
Фиксированный размер — занимают одинаково при любом значении
| Тип | Размер |
|---|---|
TINYINT (он же BOOL) | 1 байт |
SMALLINT | 2 байта |
MEDIUMINT | 3 байта |
INT | 4 байта |
BIGINT | 8 байт |
FLOAT / DOUBLE | 4 / 8 байт |
DATE | 3 байта |
TIME | 3 байта |
TIMESTAMP | 4 байта |
DATETIME | 5 байт |
YEAR | 1 байт |
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.utf8mb4 у VARCHAR(255) потолок 1020 байт, значит префикс двухбайтовый — и пустая строка займёт 2 байта, а не 1. Для VARCHAR(50) в той же кодировке (200 байт) префикс будет однобайтовым.Лимит строки целиком
Помимо размеров отдельных колонок в MySQL есть ограничение на всю строку — 65 535 байт, общее на таблицу независимо от движка. TEXT и BLOB в этот лимит почти не входят: в строке хранится только указатель (обычно 9–12 байт), а данные лежат отдельно.
Поэтому таблица из трёх десятков VARCHAR(255) в utf8mb4 может упереться в лимит и не создаться, а таблица с десятком TEXT — нет.
varchar(20) спокойно запишется строка любой длины. Тип колонки для SQLite — лишь «рекомендация сродства» (type affinity). Из-за этого схема, отлаженная на SQLite, может начать падать после переезда на MySQL: там ограничения настоящие.FLOAT для денег. Тихая потеря копеек. Только DECIMAL или INT в минорных единицах.TIMESTAMP для будущих дат. Переполнение в 2038. DATETIME или BIGINT в Unix-секундах.VARCHAR(255) по привычке. Если поле точно ≤ 32 символа — VARCHAR(32). На больших таблицах байты складываются.BLOB/TEXT в часто читаемых строках. Off-page хранение замедляет SELECT *. Выносите большие поля в отдельную таблицу 1:1.utf8 вместо utf8mb4. Старый utf8 — 3-байтный, не вмещает эмодзи и редкие символы. Всегда utf8mb4.ENUM для статусов с авторазвитием. Каждое добавление статуса — ALTER на миллион строк. Используйте справочную таблицу.TIMESTAMP без TZ для UTC-данных. Используйте TIMESTAMPTZ — конвертация в TZ клиента автоматическая.BIGINT для всего по умолчанию. 8 байт vs 4 (INT). На таблице в 100M строк это 400 МБ разницы только в PK + столько же на каждый FK.DDL (Data Definition Language) — команды управления схемой: создание, изменение, удаление таблиц и ограничений. На проде это самая опасная категория операций — ALTER на гигабайтной таблице может блокировать чтение на минуты, DROP TABLE — необратим. Понимание тонкостей DDL и стратегий безопасного применения отделяет производственный опыт от учебного.
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 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 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, изменения типа — всё равно перезапись.
-- 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 -- все индексы
ALTER TABLE на проде без online-tool. На таблице в 10M строк ALTER блокирует чтение/запись на десятки минут. Используйте pt-online-schema-change, gh-ost либо pg_repack.CREATE TABLE AS SELECT без индексов. Копирует данные, но не PK/индексы/FK. Добавляйте отдельным ALTER после.DROP TABLE без бэкапа. Необратимо. Перед удалением — RENAME в _archive на несколько дней, затем DROP.ALTER в одной транзакции с DML на MySQL до 8.0. MySQL InnoDB < 8.0 неявно коммитит транзакцию перед DDL. Postgres — DDL транзакционный.ENGINE=MyISAM. На MySQL до 5.5 дефолт — MyISAM (без транзакций, table-level locks). Явно: ENGINE=InnoDB.utf8mb4_unicode_ci — case-insensitive; WHERE email = 'A' найдёт 'a'. Для строгого сравнения — utf8mb4_bin или COLLATE в WHERE.FOREIGN KEY ... REFERENCES ... без CONSTRAINT-имени — MySQL генерирует имя автоматически. Удалять/менять потом — нужно сначала найти имя через SHOW CREATE TABLE.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) с денормализацией под отчёты |
is_not_inactive BOOLEAN — читать невозможно. Используйте позитивные имена.commentable_type + commentable_id без FK не имеет гарантий целостности на уровне БД.tags = 'php,mysql' ломает поиск, индекс не работает.JOIN — операция комбинирования строк двух (и более) отношений на основе условия. Несмотря на единый синтаксис, разные виды JOIN-ов имеют принципиально разную семантику: один из них (INNER) фильтрует, другой (LEFT) дополняет, третий (CROSS) умножает. Неправильный 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 применяется до 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; запишите его явно.
-- Книги с рейтингом >= 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 на «склейку».
При 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 трижды. Это математика декартова произведения, в которое мы сами загнали запрос.
WHERE убивает LEFT JOIN. Условие WHERE right_table.col = 'X' отфильтрует строки, где правая часть NULL — превращая LEFT JOIN в INNER. Условия на правую таблицу выносите в ON.NOT IN с NULL. Если подзапрос вернёт хотя бы один NULL, весь предикат становится UNKNOWN, и запрос вернёт пустоту. Используйте NOT EXISTS.ON превращает запрос в декартово произведение.USING(col) vs ON. USING требует одинаковых имён столбцов в обеих таблицах и ломается при переименовании.Индекс — вспомогательная структура данных, позволяющая СУБД находить строки по значениям атрибутов без полного сканирования таблицы. Без индексов запрос 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: широкий композит.
Композитный индекс 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';
gender или status почти бесполезен. Эффективен только в композите с более селективной колонкой.WHERE LOWER(email) = ? — индекс по email не используется. Решение: функциональный индекс.(created_at, user_id) для WHERE user_id = ? не работает по leftmost rule.(a) и (a, b) — первый избыточен. Периодическая чистка экономит место и ускоряет запись.FORCE INDEX вместо понимания. Чинит симптом, скрывает причину. Через полгода изменения статистики сделают подсказку вредной.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;
// Включить лог всех запросов
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` = ?"
| Тип проекта | Read/Write | Индексы |
|---|---|---|
| E-commerce, блог, CMS | Чтение >> запись | Много индексов; читателю — всё нужное |
| Логирование, аналитика insert-only | Запись доминирует | Минимум индексов: один PK + 1-2 для редких запросов |
| OLTP (заказы, платежи) | ~50/50 | Узкие точечные индексы под конкретные запросы |
| IoT, телеметрия | Write-heavy | BRIN-индексы на timestamp + partitioning |
| Биржи, real-time | Высокая запись, частое чтение последнего | Минимальные индексы + специальные структуры (Redis sorted sets) |
Эмпирика: добавление каждого индекса замедляет INSERT/UPDATE/DELETE на 5-15%. На таблице с 10 индексами запись в 2-3 раза медленнее, чем без индексов. Перед добавлением индекса задайте вопрос: «какой конкретный запрос он ускоряет?» Если ответа нет — индекс не нужен.
foreignId()->constrained() и lock на ALTER. Создание FK на большой таблице блокирует обе. Делайте отдельной миграцией ночью либо используйте NOCHECK.index на проде. Создание индекса на 100M строк блокирует таблицу. CREATE INDEX CONCURRENTLY в Postgres или pt-osc для MySQL.dropIndex по массиву колонок и переименование. Если индекс был создан с явным именем, удаление по колонкам не сработает. Удаляйте по имени.down() миграции без обратного индекса. При откате индекс остаётся. Всегда зеркальный down().DB::enableQueryLog на проде. Лог копится в памяти на каждом запросе. Включайте локально или временно с осторожностью.fullText только в MyISAM до MySQL 5.6. Если получили ошибку — обновите MySQL или переходите на ElasticSearch/Meilisearch.idx_scan = 0 в течение месяца, а не часа. Сезонные запросы (отчёты раз в квартал) тоже используют индексы.unique. $t->unique(['a']) создаёт уникальный индекс по a. $t->index('a') рядом — избыточный. Уникальный уже покрывает.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 | Что значит | Хорошо/плохо |
|---|---|---|
ALL | Full table scan — все строки | Плохо на больших таблицах |
index | Full index scan — все строки индекса | Не быстрее ALL |
range | Диапазон по индексу (BETWEEN, >, IN) | Приемлемо |
ref | Поиск по неуникальному индексу (несколько строк) | Хорошо |
eq_ref | Поиск по уникальному индексу для JOIN (одна строка) | Отлично |
const | Найдена ровно одна строка через PK/UNIQUE | Идеал |
system | Таблица с одной строкой | Особый случай |
Правило: если в плане type=ALL или type=index на большой таблице — вы ещё не закончили оптимизацию. Стремитесь к ref или лучше для всех «горячих» запросов.
-- Старый стиль (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 для сравнения.
EXPLAIN — оценка; ANALYZE — факт. На production-данных оценка может расходиться на порядки.BEGIN; ... ROLLBACK;.ANALYZE table_name.type=index в MySQL. Это full scan индекса, не быстрее type=ALL. Реально быстрые: const, ref, range.Materialize в Postgres. Часто свидетельство неоптимального плана. Перепишите через JOIN.Подзапрос — 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;
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;
Оба проверяют «есть ли совпадение», но реализованы по-разному. 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 можно последовательно цепочкой — каждый шаг видит результат предыдущего. Это лучший способ записать многошаговую логику читаемо.
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 (...).
NOT IN с NULL. Если подзапрос вернёт хотя бы один NULL, весь предикат становится UNKNOWN; внешний запрос вернёт пусто. NOT EXISTS работает корректно.SELECT * FROM (...) AS sub WHERE rn = 1 — дёшево только с индексом. Window-функция требует сортировки внутри партиции. На больших данных без подходящего индекса — всё равно дорого.CREATE VIEW или MATERIALIZED VIEW.WHERE depth < 10.WHERE col IN (SELECT ... ORDER BY ... LIMIT 10). Многие СУБД игнорируют LIMIT в подзапросе IN. Используйте JOIN с подзапросом.IF EXISTS (SELECT ...) в PL/pgSQL/T-SQL — быстро. В обычном SELECT EXISTS используется как булев предикат WHERE.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();
Model::preventLazyLoading().preventLazyLoading в проде. Включайте только в local/testing, либо handleLazyLoadingViolationUsing для логирования без падения.Post::with('comments') на 100 постах с 200 комментариями — 20к в памяти. Используйте with(['comments' => fn ($q) => $q->limit(5)]).$orders->load('user') для lazy eager load.withWhereHas. Метод в Laravel 9+ совмещает whereHas и with с одинаковым условием. Без него либо дублируете условие, либо получаете несогласованные данные.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-логики.
-- 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 при конфликте.
40001 без retry — пользователь видит 500. Оборачивайте критичные транзакции в retry-цикл.DB::transaction() в Laravel. Делает 1 попытку retry. Передавайте: DB::transaction($fn, 3).DB::transaction().MySQL уникальна тем, что позволяет хранить разные таблицы в одной БД через разные «движки» (storage engines). У каждого — свои свойства: транзакции или нет, row-level или table-level locks, indexing, durability. Для современного приложения дефолт — InnoDB; понимание других движков нужно для редких специальных задач и для кругозора.
| Движок | Транзакции | Locks | Применение |
|---|---|---|---|
| InnoDB | Да (ACID) | Row-level + MVCC | Дефолт; всё, что требует надёжности |
| MyISAM | Нет | Table-level | Только legacy; до 5.5 был дефолтом, FULLTEXT |
| Memory (HEAP) | Нет | Table-level | Временные таблицы в RAM; сессии (но Redis лучше) |
| Archive | Нет | Row-level | Write-only логи: сжатие, без UPDATE/DELETE |
| CSV | Нет | Table-level | Обмен данными через CSV-файлы напрямую |
| Blackhole | Нет | — | Принимает INSERT и выкидывает; для репликации/тестов |
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 растёт, БД распухает.
-- Текущий дефолтный движок
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;
innodb_buffer_pool_size мал. Если меньше размера активных данных — постоянные I/O на диск. На сервере с 32 GB RAM ставьте 16-24 GB.innodb_doublewrite = 0 ускоряет запись, но при краше можно потерять данные. На critical-системах — никогда не отключайте.innodb_flush_log_at_trx_commit = 0. Логи сбрасываются на диск раз в секунду, не на каждый commit. Быстро, но при краше теряются последние секунды. Дефолт 1 — ACID-совместимо.Блокировки — механизм управления конкурентным доступом. 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.
Две транзакции взаимно ждут блокировки друг друга. СУБД детектирует и отменяет одну с ошибкой (MySQL: 40001, Postgres: 40P01).
-- T1 -- T2
UPDATE id=1; UPDATE id=2;
UPDATE id=2; UPDATE id=1;
-- T1 ждёт T2, T2 ждёт T1 → DEADLOCK
Решение — всегда брать блокировки в одном порядке (например, по возрастанию id).
SELECT FOR UPDATE без транзакции. Снимается с COMMIT/ROLLBACK. Без транзакции (autocommit ON) защиты нет.UPDATE по нескольким строкам в случайном порядке. Разный порядок захвата блокировок — deadlock. Используйте ORDER BY id.SKIP LOCKED для очередей. Пропускает заблокированные строки. Идеально для воркеров очереди. MySQL 8+, Postgres 9.5+.NOWAIT вместо ожидания. Мгновенно бросает ошибку. Лучше показать «занято», чем висеть 30 секунд.pt-online-schema-change, gh-ost.Оконные функции и 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;
WHERE rn = 1.WITH foo AS MATERIALIZED.WHERE depth < 10.ORDER BY без ROWS BETWEEN. Дефолт — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (накопительный итог).LAG(value, 1, 0).Три доминирующих СУБД в enterprise: MySQL, PostgreSQL, Microsoft SQL Server. На базовом SQL похожи; на уровне типов, продвинутых индексов, репликации и оптимизатора — разные. Понимание различий критично для интеграционных задач, миграций между СУБД, переносимых ORM-запросов и для senior-уровня.
| Аспект | MySQL (InnoDB) | PostgreSQL | SQL Server |
|---|---|---|---|
| Дефолт изоляция | REPEATABLE READ | READ COMMITTED | READ COMMITTED |
| Auto-increment | AUTO_INCREMENT | SERIAL / GENERATED | IDENTITY(1,1) |
| Limit | LIMIT 10 | LIMIT 10 | TOP 10 / OFFSET FETCH |
| Конкатенация | CONCAT(a, b) | a || b | a + b / CONCAT |
| Текущее время | NOW() | NOW() / CURRENT_TIMESTAMP | GETDATE() |
| Кавычки | Обратные `col` | Двойные "col" | Квадратные [col] |
| Clustered index | Автоматически по PK | Heap (CLUSTER явно) | Автоматически по PK, либо явно CLUSTERED |
| JSON | JSON, индекс через generated cols | JSONB + GIN | JSON (как NVARCHAR + функции) |
| Массивы | Нет | Да, нативно | Нет |
| Индексы | B-Tree, FULLTEXT, RTREE | B-Tree, GIN, GiST, BRIN, Hash, SP-GiST | CLUSTERED, NONCLUSTERED, INCLUDE, filtered, FULLTEXT |
| Partial index | Нет (workaround — generated cols) | Да | Да (filtered index) |
| Covering index | Широкий композит | INCLUDE | INCLUDE |
| DDL транзакционно | Ограниченно (atomic DDL 8+) | Да | Да |
| Параллельные запросы | Нет | Да (9.6+) | Да |
| RETURNING | Нет | Да: INSERT ... RETURNING id | OUTPUT INSERTED.id |
| UPSERT | ON DUPLICATE KEY UPDATE | ON CONFLICT DO UPDATE | MERGE |
| Online schema change | pt-osc, gh-ost | pg_repack, CONCURRENTLY | ALTER TABLE ... WITH (ONLINE = ON) |
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-trip | 2 сетевых вызова к БД | 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 всех затронутых строк, не нужен второй запрос.
LAST_INSERT_ID() для авто-инкремента после INSERT (только последний ID, не колонки); транзакция BEGIN; UPDATE ... ; SELECT ... FROM ... WHERE ...; COMMIT с блокировкой FOR UPDATE — работает, но многословнее и на high-throughput проектах менее удобно.
RETURNING в Query Builder нет — используйте DB::selectOne('UPDATE ... RETURNING code', [...]) либо DB::statement + PDO::fetchColumn. Для INSERT — DB::table(...)->insertGetId(...) работает и в MySQL (через LAST_INSERT_ID), и в PG (через RETURNING id) прозрачно.RETURNING — не «удобство», а ключевой инструмент для реализации очередей, пулов, атомарной выдачи ресурсов. Он превращает потенциально небезопасную двухшаговую операцию в один надёжный атомарный запрос.
Одно слово «partial index» — две совсем разные фичи в MySQL и PostgreSQL. Классический источник недопонимания и в переводимых статьях.
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 символов длинного 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-индекс — отсутствует, запрос на фичу отклонён |
- 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.
Если новый Laravel-проект стартует с чистого листа и есть свобода выбора СУБД — PostgreSQL чаще всего выигрышный вариант. Не потому что «модно», а потому что для типичных бэкенд-задач (очереди, транзакции с побочками, атомарная выдача ресурсов, консистентность денежных операций) PG даёт из коробки несколько вещей, которых в MySQL просто нет.
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 параллельными запросами» — это источник багов, которые невозможно объяснить, глядя в свой код.
FOR UPDATEInnoDB при SELECT ... FOR UPDATE блокирует не только строки, но и промежутки между ними (next-key locking) — чтобы защититься от «фантомов». Побочка: дедлоки там, где их логически быть не должно, особенно при выборке из очереди по условию (WHERE status = 'pending' LIMIT 10 FOR UPDATE).
В PostgreSQL — MVCC без gap-локов: блокируются только реально найденные строки. Меньше сюрпризов, проще отлаживать конкуренцию.
DEFERRABLE) — для журналов и денежных проводокКлассический случай: двойная запись в бухгалтерии. Каждая проводка = минимум две строки, и сумма проводки обязана быть нулём. Но во время вставки первой строки сумма ещё не ноль — ограничение упало бы, если проверяется построчно.
PostgreSQL умеет проверять ограничения в момент COMMIT, а не после каждой строки:
ALTER TABLE ledger_entries
ADD CONSTRAINT balanced
CHECK (...)
DEFERRABLE INITIALLY DEFERRED;
-- Проверка сработает только на COMMIT
В MySQL такого механизма нет — пришлось бы городить триггеры или проверять в коде приложения.
Классический кейс: «вебхук от платёжки пришёл раньше, чем создался заказ» — надо сериализовать обработчиков, но строки в БД ещё нет. PostgreSQL умеет транзакционные advisory locks — «именной» лок, который сам отпустится при COMMIT/ROLLBACK:
SELECT pg_advisory_xact_lock(hashtext('ord_1'));
-- Второй воркер с тем же 'ord_1' будет ждать первого
-- При commit/rollback лок освободится автоматически
В MySQL есть GET_LOCK('name'), но он привязан к сессии, а не к транзакции — при откате не освобождается, надо явно RELEASE_LOCK. Утечки локов после исключений — типичная беда.
INSERT IGNORE в MySQL — тихо опасенINSERT IGNORE в MySQL глушит все ошибки, а не только нарушение уникальности: обрезанные строки, неверные типы, нарушение внешних ключей. Вставил мусор — молча получил 0 rows, а потом полдня ищешь, где данные пропали.
PostgreSQL ON CONFLICT (event_id) DO NOTHING реагирует только на конкретный конфликт по конкретному индексу. Всё остальное честно падает исключением. Для дедупликации платёжных событий, идемпотентности вебхуков — разница принципиальная.
| Фича | Что даёт |
|---|---|
| Транзакционный 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 чтения:
| MySQL | PostgreSQL |
|---|---|
`col` (backticks) | "col" (двойные кавычки, часто вообще не нужны) |
AUTO_INCREMENT | GENERATED ALWAYS AS IDENTITY / SERIAL |
IFNULL(a, b) | COALESCE(a, b) (тоже работает в MySQL) |
CONCAT(a, b) | a || b |
INSERT IGNORE | INSERT ... ON CONFLICT DO NOTHING |
ON DUPLICATE KEY UPDATE | ON CONFLICT (col) DO UPDATE SET ... |
DATETIME | timestamptz |
SHOW TABLES | \dt в psql |
LIMIT 10 OFFSET 5 | Так же |
| SELECT / JOIN / GROUP BY / оконные функции | Идентично (один стандарт) |
# 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 абстрагирует диалект автоматически.
RETURNING+FOR UPDATE SKIP LOCKED— атомарный захват из очереди/пула одним запросом.- Предсказуемая изоляция
READ COMMITTEDбез gap-локов — конкуренция ведёт себя так, как объясняешь. - Частичные индексы + честный
EXPLAIN ANALYZE— точечная оптимизация «горячего» подмножества данных.
| Решение | Когда подходит |
|---|---|
| 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 с отдельной командой эксплуатации.
JSON_EXTRACT(col, '$.path'); Postgres col->'path'.utf8mb4_unicode_ci); Postgres — чувствителен.uuid. MySQL — CHAR(36) или BINARY(16).BOOLEAN в MySQL. Синоним TINYINT(1); TRUE/FALSE = 1/0.ORDER BY порядок не гарантирован. Никогда не полагайтесь.EXPLAIN формат. Совершенно разный синтаксис и колонки.pt-online-schema-change, gh-ost. Postgres: pg_repack, ALTER TABLE с осторожностью.Большинство оптимизаций решаются индексами и переписыванием запроса. Когда этого не хватает — в арсенале остаются продвинутые механизмы: подсказки оптимизатору, физическое партиционирование, материализованные представления, хранимые процедуры. Каждый имеет цену — либо в сложности кода, либо в стоимости поддержки. Использовать их нужно только когда обычные средства исчерпаны.
Подсказки оптимизатору о том, как выполнять запрос. Помогают, когда оптимизатор выбирает плохой план из-за устаревшей статистики или необычного распределения данных.
-- 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 скрывают проблему — через полгода они становятся вредными.Оптимизатор принимает решения на основании статистики: сколько строк в таблице, как распределены значения колонок, селективность индексов. Если статистика устарела (после массового импорта/удаления) — план может быть в разы хуже оптимального.
-- 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 — одна БД, физически разделённая таблица
Таблица бьётся на несколько частей по правилу: 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 (или их избегания). Применяется на масштабах, где одна БД не вмещает данные или не вытягивает нагрузку.
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
Хранимая процедура — код на процедурном расширении 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 убирает дубликаты (требует сортировки/хеширования всего результата). 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;
FORCE INDEX как лекарство. Чинит сейчас, ломает через полгода (статистика изменилась). Сначала ANALYZE, потом — если не помогло — разбираться с дизайном индекса.CONCURRENTLY + scheduled refresh каждые N минут.UNION вместо UNION ALL. Дефолтная привычка из туториалов. На больших данных — кратное замедление. Используйте ALL, если не нужна явная дедупликация.ANALYZE на проде в час пик. Захватывает statistics-lock; на больших таблицах может затормозить запросы. На write-heavy — запускайте ночью или с лимитом sample rate.WHERE YEAR(date) = 2024. Функция вокруг колонки убивает индекс. Перепишите как range: WHERE date BETWEEN '2024-01-01' AND '2024-12-31'.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'];
}
ATTR_EMULATE_PREPARES = true. PDO эмулирует prepared в PHP, посылая полный SQL. Защита сохраняется, но типы — всегда строки (LIMIT :n превращается в строку, MySQL ругается). Включайте false.bindParam vs bindValue. bindParam привязывает ссылку. В цикле — баг «все строки одинаковые».$_GET — строка. Для LIMIT в MySQL: (int) $_GET['limit'] + PARAM_INT.LIKE с пользовательским вводом. Prepared защищает от инъекций, не от метасимволов LIKE. Экранируйте % и _.$stmt->closeCursor() или buffered queries.lastInsertId() и replication lag. На read replica только что вставленная строка не видна. Читайте из мастера.Аналитический запрос на дашборде «топ-10 пользователей по сумме платежей за последний месяц с количеством заказов» работает 4.2 секунды на проде. Таблицы: users (1.2 млн), orders (8 млн), payments (15 млн). Задача — довести время до < 50 мс без архитектурных изменений.
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.
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;
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);
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 мс. Архитектура не менялась, кэш не добавлен.
SELECT * в продакшен-коде. Тянет все столбцы, ломает covering index, делает запрос хрупким к ALTER TABLE.OFFSET random() или столбец с pre-generated random.OFFSET 100000 заставляет прочитать первые 100к и отбросить. Keyset pagination: WHERE id > last_id ORDER BY id LIMIT 10.COUNT(*) на большой таблице. Postgres — full scan. Приближённо: pg_stat_user_tables.n_live_tup.UPDATE без WHERE на проде. Включите SET SQL_SAFE_UPDATES = 1.TRUNCATE в транзакции MySQL. Неявно коммитит; rollback не вернёт данные. На Postgres — транзакционный.'utf8'. MySQL utf8 — 3-байтная подделка. Всегда utf8mb4.WHERE col IS NOT NULL.JSON_EXTRACT в WHERE без индекса. Полное сканирование. Используйте GIN на JSONB.ENUM с большим количеством значений. Расширение — ALTER с перезаписью. Используйте справочную таблицу + FK.WHERE YEAR(col) = 2024 убивает индекс. Функция вокруг колонки делает индекс неприменимым. Перепишите как range: WHERE col BETWEEN '2024-01-01' AND '2024-12-31'.LEFT JOIN + WHERE IS NOT NULL = INNER JOIN. Если условие на правой стороне идёт в WHERE — вы фактически отфильтровываете строки, где правая сторона NULL, превращая LEFT в INNER. Если хотели INNER — пишите явно.SELECT u.*, (SELECT MAX(...) FROM orders WHERE user_id = u.id) FROM users — N+1 на уровне SQL. Перепишите через JOIN + GROUP BY или CTE.FROM users, orders без WHERE = CROSS JOIN на триллион строк. Используйте только ANSI JOIN с явным ON.UNION вместо UNION ALL. UNION неявно делает DISTINCT — сортирует/хеширует весь результат. Если дубли невозможны/неважны — UNION ALL быстрее в разы.FORCE INDEX как «решение». Маскирует устаревшую статистику или плохой дизайн индекса. Через полгода данные изменятся и hint станет вредным.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 пропускают значения), что не критично, но удивляет джунов.