6. JOIN. INNER, LEFT, RIGHT
«Нормализация данных — это искусство разбирать вещи на части так, чтобы они не могли противоречить друг другу. А операция JOIN — это наука собирать их воедино, не спалив при этом процессор».
Декартово проклятие: Почему базы данных нормализуют
В наивной идеальной картине мира разработчик хранил бы все данные системы в одной исполинской плоской таблице. Однако в реальном мире такой подход немедленно приводит к катастрофической избыточности дискового пространства и аномалиям изменения данных: если адрес клиента продублирован в миллионе строк его заказов, опечатка или сбой при обновлении приведут к тому, что база данных станет внутренне противоречивой.
Чтобы гарантировать целостность, реляционная теория Эдгара Кодда предписывает декомпозировать сущности на независимые нормализованные отношения (таблицы users, orders, products).
Операция JOIN (Соединение) — это фундаментальный механизм реляционной алгебры, который динамически собирает нормализованные данные обратно воедино в момент выполнения запроса.
Анатомия соединений: INNER, LEFT и RIGHT
В популярных учебниках операции JOIN часто визуализируют с помощью диаграмм Эйлера-Венна (пересекающихся кругов). Для бэкенд-инженера это крайне вредная и вводящая в заблуждение аналогия. Круги Венна описывают операции над плоскими множествами (UNION, INTERSECT), но совершенно не отражают природу умножения кортежей. Опираясь на круги, невозможно понять, почему при соединении таблицы с 10 пользователями и таблицы с 50 заказами на выходе получается 50 строк, а не 10.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph IJ["INNER JOIN (Строгое пересечение)"]
direction LR
U1["User 1"] --- O1["Order 101"]
U2["User 2"] --- O2["Order 102"]
U3["User 3 (нет заказов)"] -.-x NO1["Исключен"]
end
subgraph LJ["LEFT JOIN (Левая таблица неприкосновенна)"]
direction LR
LU1["User 1"] --- LO1["Order 101"]
LU2["User 2"] --- LO2["Order 102"]
LU3["User 3"] --- LNULL["NULL, NULL"]
end
1. INNER JOIN (Внутреннее соединение)
INNER JOIN оставляет в результирующем потоке только те пары кортежей, для которых предикат соединения в условии ON вычислился в строгое TRUE.
Если у пользователя нет ни одного заказа, он будет полностью исключен из итоговой выборки:
SELECT u.id, u.email, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
2. LEFT JOIN (Левое внешнее соединение)
LEFT JOIN (или LEFT OUTER JOIN) гарантирует, что каждая строка из «левой» таблицы (указанной первой в секции FROM) обязательно попадет в результирующий набор.
Если для конкретной левой строки в правой таблице не нашлось ни одного соответствия по условию ON, СУБД все равно вернет левую строку, заполнив все колонки правой таблицы значениями NULL:
SELECT u.id, u.email, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
Mechanical Sympathy: Алгоритмы выполнения JOIN
Декларативный SQL не сообщает СУБД, каким именно алгоритмом процессор должен сопоставить миллионы строк. Выбор физической стратегии возложен на стоимостной оптимизатор (CBO).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Запрос с JOIN"]:::entry --> Optimizer["Оптимизатор СУБД"]
Optimizer -->|"Нет индексов, малый объем"| NL["Nested Loop<br>Вложенные циклы"]
Optimizer -->|"Нет индексов, большой объем"| HJ["Hash JOIN<br>Хэш-таблицы в RAM"]
Optimizer -->|"Есть B-Tree индексы / Сортировка"| MJ["Merge JOIN<br>Слияние массивов"]
Разберем три фундаментальных алгоритма соединения (глубокий разбор профилирования и настройки оптимизатора мы продолжим в статье 14. Оптимизация JOIN):
1. Nested Loop (Вложенные циклы)
Самый прямолинейный алгоритм. Процессор берет первую строку из внешней (левой) таблицы и последовательно перебирает всю внутреннюю (правую) таблицу в поисках совпадений. Затем берет вторую строку и повторяет сканирование.
- Алгоритмическая сложность:
O(N * M). - Если обе таблицы велики и не имеют индексов, такой алгоритм мгновенно сжигает все ядра CPU сервера базы данных.
- Index Nested Loop: Критическое ускорение! Если по внешнему ключу правой таблицы (
o.user_id) создан B-Tree индекс, процессор для каждой строки левой таблицы выполняет не полный перебор, а логарифмический спуск по дереву индекса. Сложность снижается доO(N * log M). Это самый быстрый метод соединения небольших наборов строк в OLTP-системах.
2. Hash Join (Хеширование)
Если необходимо соединить два крупных набора данных, для которых нет подходящих индексов, вложенные циклы неприменимы. На помощь приходит двухфазный Hash Join:
- Фаза построения (Build Phase): СУБД выбирает меньшую из двух таблиц и строит в оперативной памяти (в пределах лимита
work_mem) компактную хэш-таблицу, где ключом выступает колонка соединения (user_id). - Фаза зондирования (Probe Phase): СУБД последовательно сканирует вторую (большую) таблицу потоком, за время
O(1)вычисляя хэш ключа и проверяя наличие совпадений в оперативной хэш-таблице.
- Алгоритмическая сложность:
O(N + M). - Mechanical Sympathy: Алгоритм экономит процессорные такты, но требует существенного объема RAM. Если хэш-таблица не помещается в
work_mem, СУБД сбрасывает сегменты на диск (Batching / Hybrid Hash Join), что резко снижает скорость.
3. Merge Join (Слияние)
Если оба набора данных уже отсортированы по ключу соединения (например, считываются напрямую из B-Tree индексов или предварительно отсортированы):
- Процессор устанавливает два указателя на начало каждого списка и продвигает их параллельно, сопоставляя одинаковые ключи (наподобие работы застежки-молнии).
- Алгоритмическая сложность:
O(N + M). - Mechanical Sympathy: Требует минимального объема памяти, не создает промежуточных хэш-таблиц и великолепно утилизирует последовательный доступ к кэш-линиям CPU. Идеален для массивных аналитических выборок.
Фатальные ловушки разработчиков (Gotchas)
Ловушка 1: Непреднамеренный INNER JOIN
Самая распространенная ошибка при проектировании SQL-запросов, регулярно приводящая к багам в проде. Разработчик хочет получить список всех зарегистрированных пользователей, прикрепив к ним активные заказы.
❌ Ошибка: Фильтрация правой таблицы в предложении WHERE
SELECT u.email, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'active';
Что происходит под капотом:
- Фаза
LEFT JOINчестно присоединяет заказы. Для пользователей без заказов генерируется виртуальный кортеж, где полеo.statusравноNULL. - Затем управление передается фазе
WHERE. Для пользователей без заказов вычисляется выражениеNULL = 'active'. - По законам трехзначной логики SQL (см. 12. Работа с NULL в SQL) результат равен
UNKNOWN. - Предложение
WHEREбезжалостно отбрасывает все строки с результатомUNKNOWN. - Итог: Ваш
LEFT JOINнезаметно и гарантированно превратился вINNER JOIN! Все пользователи без заказов исчезли из ответа.
✅ Корректное решение: Перенос предиката в блок ON
SELECT u.email, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'active';
Условие в секции ON оценивается непосредственно в момент соединения: фильтруются только строки правой таблицы. Если у пользователя нет активных заказов, он останется в результирующей выборке с полями NULL.
Ловушка 2: Дублирование данных (Data Bloat) и Сеть
Рассмотрим типичный запрос связи «Один ко многим» ($1:M$), где у одного активного покупателя есть 1 000 совершенных заказов:
SELECT u.id, u.email, u.avatar_url, o.id, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
СУБД возвращает строго плоскую двумерную таблицу. Это фундаментальное ограничение реляционного протокола.
В результате текстовые поля родителя (email, тяжелый бинарный или строковый avatar_url) будут продублированы по сети ровно 1 000 раз — для каждого отдельного заказа.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
DB["PostgreSQL / MySQL"]:::data -->|"1000 строк<br>email дублируется x1000<br>avatar_url дублируется x1000"| Net["Сетевой стек / TCP"]
Net --> Go["Go App: database/sql"]
Go --> Heap["Куча (Heap)<br>Массовые аллокации строк<br>Нагрузка на GC"]
Вы на ровном месте забиваете пропускную способность сетевого адаптера и вынуждаете сборщик мусора Go (GC) аллоцировать и десериализовать тысячи идентичных дубликатов строк в куче.
В высоконагруженных системах для связей $1:M$ часто выгоднее выполнить два изолированных параллельных запроса (один для пользователей, а второй для заказов через оператор IN (...) со списком ID):
SELECT id, email, avatar_url FROM users WHERE id IN (...);SELECT user_id, id, amount FROM orders WHERE user_id IN (...);
чем гонять мегабайты дублирующегося сетевого трафика через плоскийJOIN.
JOIN в контексте Go
Стандартный пакет database/sql спроектирован в духе минимализма Unix: он оперирует плоским курсором *sql.Rows и ничего не знает о вложенных структурах. Маппинг плоского соединения в древовидные сущности ложится на плечи бэкенд-инженера.
package repository
import (
"context"
"database/sql"
"fmt"
)
type Order struct {
ID int64
Amount float64
}
type User struct {
ID int64
Email string
Orders []Order
}
func GetUsersWithOrders(ctx context.Context, db *sql.DB) ([]*User, error) {
const query = `
SELECT u.id, u.email, o.id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.id
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query join: %w", err)
}
defer rows.Close()
// Хэш-таблица для дедупликации родителей и сбора заказов
usersMap := make(map[int64]*User)
var orderedUsers []*User
for rows.Next() {
var (
uID int64
uEmail string
oID sql.NullInt64 // Заказа может не быть из-за LEFT JOIN
oAmount sql.NullFloat64 // Поле может быть NULL
)
if err := rows.Scan(&uID, &uEmail, &oID, &oAmount); err != nil {
return nil, fmt.Errorf("scan join row: %w", err)
}
user, exists := usersMap[uID]
if !exists {
user = &User{
ID: uID,
Email: uEmail,
Orders: make([]Order, 0),
}
usersMap[uID] = user
orderedUsers = append(orderedUsers, user)
}
// Если заказ физически существует (не NULL)
if oID.Valid {
user.Orders = append(user.Orders, Order{
ID: oID.Int64,
Amount: oAmount.Float64,
})
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration: %w", err)
}
return orderedUsers, nil
}
Итог
- JOIN — инструмент восстановления связей нормализованных данных, фундаментально восходящий к Декартову произведению.
- Используйте
INNER JOINдля строгого пересечения множеств иLEFT JOINдля гарантированного сохранения ведущей сущности.RIGHT JOINисключите из своего лексикона. - Физические алгоритмы соединения — Nested Loop, Hash Join и Merge Join. Наличие индекса по внешнему ключу (
Foreign Key) — абсолютная аксиома для производительногоIndex Nested Loop. - Предикаты фильтрации правой таблицы при
LEFT JOINобязаны находиться в блокеON, иначе запрос вырождается вINNER JOIN. - Помните о цене денормализации сетевого трафика при плоском соединении $1:M$.
- В Go используйте
sql.Null*типы для полей nullable-таблиц и защищайте сервисы от скрытой проблемы N+1 при использовании ORM.
Зачастую бэкенду вовсе не требуется передавать миллионы строк связанных таблиц по сети. Бизнес-логику интересует сводный итог: суммарный оборот, средний чек или количество транзакций. О том, как делегировать вычисления ядру СУБД с минимальным расходом ресурсов, мы поговорим в следующей статье: 7. GROUP BY и агрегатные функции.