Введение
В базе данных интернет-магазина информация разложена по полочкам: в одной таблице — клиенты, в другой — их заказы, в третьей — товары. Это правильно, так данные не дублируются и не противоречат друг другу.
Но на сайте всё должно быть вместе. Когда пользователь заходит в личный кабинет, он хочет видеть: «Иван Петров» и рядом список его заказов. А менеджеру нужен отчёт: «Какой товар чаще всего заказывают вместе с iPhone?»
Чтобы эффективно использовать MySQL JOIN, нужно понимать, как именно соединяются таблицы.
В этом руководстве:
- не будет воды про синтаксис (он простой);
- будут три рабочих типа
JOIN, которые покрывают 99% задач; - будут живые примеры с конкретными данными (можете повторять у себя);
- будет чек-лист на случай, если ваш запрос тормозит — что смотреть и как чинить.
Поехали.
Быстрый старт: две таблицы, с которыми будем работать
Прежде чем писать запросы, давайте договоримся о данных. Все примеры в статье — на этих двух таблицах. Можете создать их у себя и повторять.
Таблица customers — клиенты
| customer_id | customer_name | |
|---|---|---|
| 1 | Иван Петров | ivan@email.com |
| 2 | Мария Соколова | maria@email.com |
| 3 | Петр Сидоров | petr@email.com |
| 4 | Анна Иванова | anna@email.com |
Таблица orders — заказы
| order_id | customer_id | order_date | amount |
|---|---|---|---|
| 101 | 1 | 2024-03-15 | 5500 |
| 102 | 2 | 2024-03-16 | 3200 |
| 103 | 1 | 2024-03-17 | 1200 |
| 104 | 3 | 2024-03-18 | 8900 |
| 105 | 5 | 2024-03-19 | 4300 |
Обратите внимание:
- У Ивана Петрова (
customer_id = 1) — два заказа. - У Марии Соколовой (
customer_id = 2) — один заказ. - У Петра Сидорова (
customer_id = 3) — один заказ. - У Анны Ивановой (
customer_id = 4) — ноль заказов. - В таблице заказов есть запись с
customer_id = 5— это заказ клиента, которого нет в таблицеcustomers(например, он удалил аккаунт, но заказ остался).
Эти «дырки» специально здесь, чтобы было наглядно видно, как работают разные типы MySQL JOIN.
INNER JOIN
Когда использовать: Вам нужны только те записи, которые есть в обеих таблицах. Например, показать клиентов, которые уже сделали заказы (и сами заказы). Те, кто ничего не купил — не интересуют.
Запрос:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_date,
o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;Как это работает:
- Берём каждого клиента из левой таблицы (
customers). - Ищем ему соответствие в правой таблице (
orders) по полюcustomer_id. - Если нашли — отдаём строку с данными из обеих таблиц.
- Если не нашли — пропускаем клиента.
Результат:
| customer_id | customer_name | order_id | order_date | amount |
|---|---|---|---|---|
| 1 | Иван Петров | 101 | 2024-03-15 | 5500 |
| 1 | Иван Петров | 103 | 2024-03-17 | 1200 |
| 2 | Мария Соколова | 102 | 2024-03-16 | 3200 |
| 3 | Петр Сидоров | 104 | 2024-03-18 | 8900 |
Что важно заметить:
- Анна Иванова (
customer_id = 4) в результате не появилась. У неё нет заказов — условиеINNER JOINне выполнилось, и она отсеялась. - Заказ с
customer_id = 5тоже не попал в выборку. Этого клиента нет в таблицеcustomers— значит, соединяться не с кем. - Иван Петров — дважды. Потому что у него два заказа.
INNER JOINне уникализирует записи, он просто размножает строки левой таблицы под каждое совпадение в правой.
LEFT JOIN
Когда использовать: Нужны все записи из левой таблицы (той, что после FROM), независимо от того, есть ли для них совпадения в правой. Если совпадения нет — в колонках правой таблицы будет NULL.
Классический сценарий: показать всех клиентов, включая тех, кто ещё ничего не заказал.
Запрос:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_date,
o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;Как это работает:
- Берём каждого клиента из левой таблицы (
customers). - Пытаемся найти ему заказы в правой таблице (
orders). - Если нашли — добавляем данные заказа (как в
INNER JOIN). - Если не нашли — всё равно возвращаем клиента, а в колонках заказов ставим
NULL.
Результат:
| customer_id | customer_name | order_id | order_date | amount |
|---|---|---|---|---|
| 1 | Иван Петров | 101 | 2024-03-15 | 5500 |
| 1 | Иван Петров | 103 | 2024-03-17 | 1200 |
| 2 | Мария Соколова | 102 | 2024-03-16 | 3200 |
| 3 | Петр Сидоров | 104 | 2024-03-18 | 8900 |
| 4 | Анна Иванова | NULL | NULL | NULL |
Что важно заметить:
- Анна Иванова наконец-то появилась! У неё нет заказов, поэтому
order_id,order_dateиamount— пустые (NULL). Именно так на сайте можно показать сообщение «У вас пока нет заказов». - Заказ с
customer_id = 5по-прежнему не виден. Потому что он в правой таблице, аLEFT JOINгарантирует наличие всех записей только из левой. Чтобы увидеть такие «потерянные» заказы, нужно было бы менять таблицы местами или использовать другой тип соединения. - Иван Петров снова в двух экземплярах.
LEFT JOINне меняет механику размножения строк: сколько заказов у клиента — столько раз он появится в выдаче.
Важный нюанс: если нужны только те, у кого нет совпадений
Иногда нужно найти именно «пустых» клиентов — тех, кто зарегистрировался, но ничего не купил. Для этого добавляем условие WHERE ... IS NULL:
SELECT
c.customer_id,
c.customer_name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;Результат:
| customer_id | customer_name |
|---|---|
| 4 | Анна Иванова |
Это работает потому, что у клиентов без заказов поле order_id (которое мы взяли из правой таблицы) гарантированно будет NULL. Такая конструкция часто используется для проверки целостности данных или рассылок «давно не заходили».
RIGHT JOIN
Когда использовать: Честно? Почти никогда. RIGHT JOIN работает ровно как LEFT JOIN, только «главная» таблица — правая (та, что после JOIN). Левая таблица подтягивается к ней по мере возможности.
Если в LEFT JOIN мы говорили: «Верни всех клиентов и добавь к ним заказы, если есть», то RIGHT JOIN сказал бы: «Верни все заказы и добавь к ним клиентов, если найдёшь».
Запрос:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_date,
o.amount
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id;Как это работает:
- Берём каждый заказ из правой таблицы (
orders). - Пытаемся найти для него клиента в левой таблице (
customers). - Если нашли — добавляем имя и email.
- Если не нашли — всё равно возвращаем заказ, а в колонках клиента ставим
NULL.
Результат:
| customer_id | customer_name | order_id | order_date | amount |
|---|---|---|---|---|
| 1 | Иван Петров | 101 | 2024-03-15 | 5500 |
| 1 | Иван Петров | 103 | 2024-03-17 | 1200 |
| 2 | Мария Соколова | 102 | 2024-03-16 | 3200 |
| 3 | Петр Сидоров | 104 | 2024-03-18 | 8900 |
NULL | NULL | 105 | 2024-03-19 | 4300 |
Что важно заметить:
- Появился заказ 105! Тот самый, у которого
customer_id = 5(несуществующий клиент). В левой таблицеcustomersдля него нет записи, поэтомуcustomer_idиcustomer_name—NULL. - Анна Иванова исчезла. Потому что у неё нет заказов, а правой таблице (
orders) до неё дела нет. Она же не заказывала. - Механика та же, что у
LEFT JOIN, только с другой стороны.
Так зачем он нужен?
RIGHT JOIN существует в MySQL для двух целей:
- Симметрия. В некоторых СУБД (например, PostgreSQL, SQL Server) он есть, и люди иногда пишут запросы с ним.
- Удобство чтения. Иногда логика запроса понятнее, если «главная» таблица стоит справа. Например, когда отчёт строится вокруг заказов, а клиенты — как дополнительная информация.
Но есть нюанс.
Любой запрос с RIGHT JOIN можно переписать через LEFT JOIN, просто поменяв таблицы местами:
-- То же самое, что RIGHT JOIN выше
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_date,
o.amount
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id;Результат будет идентичным, но читается понятнее: «Берём все заказы и добавляем к ним клиентов».
Реальные сценарии: теория на практике
Теория соединений — это хорошо, но давайте посмотрим, как те же самые JOIN работают в реальных задачах веб-разработки. Все примеры — из жизни типичного интернет-магазина.
Профиль пользователя с историей заказов
Задача: На странице личного кабинета показать данные пользователя и список всех его заказов.
Данные:
- Таблица
users— имя, email, дата регистрации - Таблица
orders— заказы пользователя
Запрос:
SELECT
u.name,
u.email,
u.registered_at,
o.order_id,
o.order_date,
o.status,
o.total
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id = 123;Почему LEFT JOIN, а не INNER JOIN?
Потому что пользователь мог зарегистрироваться, но ещё ничего не заказать. Мы всё равно хотим показать его данные и пустой блок «У вас пока нет заказов», а не ошибку 404.
Результат в коде (псевдокод):
Иван Петров, ivan@email.com, зарегистрирован 10.01.2024
Заказы:
- №101 от 15.03.2024, статус: доставлен, сумма: 5500
- №103 от 17.03.2024, статус: в пути, сумма: 1200Если бы использовали INNER JOIN, пользователь без заказов просто не попал бы в выборку — пришлось бы делать два отдельных запроса.
Каталог товаров с фильтрацией по категориям и брендам
Задача: Показать товары только определённой категории и только одного бренда.
Данные:
- Таблица
products— название, цена,category_id,brand_id - Таблица
categories— название категории - Таблица
brands— название бренда
Запрос:
SELECT
p.product_id,
p.name,
p.price,
c.name AS category,
b.name AS brand
FROM products p
INNER JOIN categories c ON p.category_id = c.category_id
INNER JOIN brands b ON p.brand_id = b.brand_id
WHERE c.name = 'Электроника'
AND b.name = 'Samsung';Почему INNER JOIN?
Нам нужны только те товары, у которых есть и категория, и бренд. Если у товара проставлена несуществующая категория (проблемы с целостностью данных) — показывать его в каталоге нельзя, он всё равно не найдётся по фильтру.
Что произойдёт:
- Соединяем товары с категориями — отсеиваются товары без категории или с неверным
category_id - Соединяем результат с брендами — отсеиваются товары без бренда
- Фильтруем по названиям — остаётся только то, что нужно
Товары и отзывы (с опциональным автором)
Задача: На странице товара показать все отзывы, а если пользователь указал имя — показать имя автора.
Данные:
- Таблица
products— информация о товаре - Таблица
reviews— текст отзыва, оценка,user_id(может бытьNULLдля анонимных) - Таблица
users— имена пользователей (если зарегистрированы)
Запрос:
SELECT
p.name AS product_name,
r.review_text,
r.rating,
r.created_at,
u.name AS author_name
FROM products p
INNER JOIN reviews r ON p.product_id = r.product_id
LEFT JOIN users u ON r.user_id = u.user_id
WHERE p.product_id = 456
ORDER BY r.created_at DESC;Почему комбинация INNER JOIN + LEFT JOIN:
INNER JOIN с отзывами — показываем только те товары, на которые есть отзывы (или, если нужно показать товар даже без отзывов, здесь был бы LEFT JOIN)
LEFT JOIN с пользователями — если отзыв оставил незарегистрированный пользователь (user_id = NULL) или аккаунт удалён, в поле author_name будет NULL. Мы на стороне фронтенда покажем «Аноним».
Результат в коде:
Товар: Смартфон Samsung Galaxy S23
Отзывы:
★★★★★ Отличный телефон! — Иван Петров (15.03.2024)
★★★★☆ Батарея слабовата — Аноним (10.03.2024)Кратко: как выбирать тип соединения в реальных задачах
| Сценарий | Какой JOIN | Почему |
|---|---|---|
| Показать заказы только тех клиентов, у которых они есть | INNER JOIN | Клиенты без заказов не нужны |
| Показать всех клиентов и их заказы (если есть) | LEFT JOIN | Клиенты — главное, заказы — опционально |
| Показать все заказы и привязанных к ним клиентов (если есть) | LEFT JOIN (или RIGHT JOIN с перестановкой) | Заказы — главное, клиенты — опционально |
| Собрать данные из трёх таблиц, где все связи обязательны | несколько INNER JOIN | Все части пазла должны быть на месте |
| Собрать данные, где часть связей может отсутствовать | комбинация INNER + LEFT | Обязательные данные тянем через INNER, опциональные — через LEFT |
Почему JOIN тормозит (и как это чинить)
Вы написали запрос с JOIN, но страница грузится вечность. Знакомая ситуация? Хорошая новость: в 90% случаев причина одна из трёх, и её легко найти.
Вот чек-лист самодиагностики. Пройдите по пунктам — и либо почините запрос, либо поймёте, куда копать дальше.
1. Забыли индекс
Как понять: MySQL без индекса вынужден сканировать таблицу целиком (полный скан), даже если нужна одна строка.
Как проверить:
Выполните перед своим запросом слово EXPLAIN:
EXPLAIN SELECT c.customer_name, o.order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;Смотрите в колонку rows — там примерное количество строк, которые MySQL пришлось проверить. Если в таблице 10 000 клиентов, а rows показывает 10 000 — это плохо. Если 10 000, а показывает 5 — отлично, индекс работает.
Что делать:
Создать индекс на поле, по которому идёт соединение:
CREATE INDEX idx_orders_customer_id ON orders(customer_id);2. Случайно сделали декартово произведение
Как понять: В таблицах по 100 строк, а запрос выполняется подозрительно долго. При выборке вернулось 10 000 строк (или больше).
Как проверить: Посмотрите на запрос — есть ли в нём условие ON или WHERE, которое связывает таблицы?
Вот так — плохо (декартово произведение):
SELECT * FROM customers, orders; -- Нет условия связкиВот так — тоже плохо, потому что условие связки переехало в WHERE, но забыто для одной из таблиц:
SELECT *
FROM customers c, orders o, products p
WHERE c.customer_id = o.customer_id; -- products ни с чем не связанЧто делать:
Всегда указывайте условие соединения для каждой пары таблиц. Либо через ON в явных JOIN, либо через WHERE, если пишете старый синтаксис (но лучше так не делать).
3. Тянете лишние данные
Как понять: Запрос возвращает 10 колонок, а на странице используются только 3.
Как проверить:
Посмотрите на SELECT *. Звёздочка — главный враг производительности. Она тащит из базы все поля, даже огромные текстовые описания или бинарные данные.
Что делать:
Перечислите только те колонки, которые реально нужны:
-- Плохо
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;
-- Хорошо
SELECT o.order_id, o.order_date, c.customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;4. Неправильно поняли разницу между ON и WHERE
Это частая ошибка именно с LEFT JOIN.
Как понять: Вы используете LEFT JOIN, чтобы показать всех клиентов, но клиенты без заказов почему-то не показываются.
Как проверить: Посмотрите, не стоит ли в WHERE условие на поле из правой таблицы.
Что делать: Если условие относится к правой таблице, но вы хотите сохранить все строки из левой — оставляйте его в ON. В WHERE оно превратит LEFT JOIN в INNER JOIN.
❌ Так клиенты без заказов пропадут:
SELECT c.customer_name, o.order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 1000; -- Для клиентов без заказов amount = NULL, условие не сработает✅ А так — останутся:
SELECT c.customer_name, o.order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
AND o.amount > 1000; -- Фильтр применяется только к правой таблице5. Не посмотрели в EXPLAIN
EXPLAIN — это рентген вашего запроса. Если вы ещё не подружились с ним — сейчас самое время.
Что смотреть в выводе EXPLAIN:
| Колонка | На что обращать внимание |
|---|---|
type | Должно быть ref, eq_ref или range. ALL — плохо (полный скан таблицы) |
possible_keys | Какие индексы MySQL могла бы использовать |
key | Какой индекс реально использован |
rows | Сколько строк пришлось просмотреть |
Extra | Using where — ок, Using temporary или Using filesort — возможно, стоит оптимизировать |
Пример хорошего EXPLAIN:
+----+-------------+-----------+--------+---------------+--------------+---------+---------------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-----------+--------+---------------+--------------+---------+---------------------+------+-------+
| 1 | SIMPLE | customers | ALL | PRIMARY | NULL | NULL | NULL | 4 | |
| 1 | SIMPLE | orders | ref | idx_customer | idx_customer | 5 | test.customers.id | 2 | |
+----+-------------+-----------+--------+---------------+--------------+---------+---------------------+------+-------+По таблице customers пришлось пройти все 4 строки (но это нормально, она маленькая), а по orders индекс сработал — просмотрено всего 2 строки на каждого клиента.
Кратко: алгоритм действий, если JOIN тормозит
- Поставьте
EXPLAINперед запросом. - Посмотрите на
rowsиtype— где полные сканы таблиц? - Проверьте индексы — создайте на полях соединения.
- Уберите
SELECT *— оставьте только нужное. - Проверьте логику
LEFT JOIN— не убиваете ли вы его условием вWHERE. - Перечитайте запрос — нет ли случайно декартова произведения.
В 95% случаев после этих шагов запрос начинает летать.
Шпаргалка: главное о JOIN
Мы разобрали три основных JOIN, посмотрели на реальных данных, научились диагностировать тормоза. Если хотите углубиться — вот две статьи с того же сайта, которые логично читать следуюшими.
Понимание порядка выполнения SQL запроса
О чём: Многие думают, что SQL выполняется в том порядке, в котором написан: сначала SELECT, потом FROM, потом JOIN, потом WHERE. На самом деле всё иначе. Например, JOIN выполняется до SELECT, а WHERE может кардинально менять логику LEFT JOIN.
Кому читать: Если вы когда-нибудь писали запрос и не понимали, почему с WHERE он работает не так, как без него.
Ошибки в составлении SQL запросов и как их избежать
О чём: Пять типичных граблей, на которые наступают даже опытные разработчики. Про NULL, про неявное приведение типов, про подзапросы, которые убивают производительность.
Кому читать: Всем, кто пишет SQL чаще раза в месяц. Гарантированно найдёте пару своих старых ошибок.
Если хочется практики
Создайте таблицы из начала статьи, введите данные и попробуйте:
- Написать запрос, который покажет клиентов, у которых нет заказов (мы это делали через
LEFT JOIN ... WHERE ... IS NULL). - Написать запрос, который покажет товары, которые никто никогда не заказывал (потребуется таблица
productsиorder_items— её здесь не было, но логика та же). - Посмотреть
EXPLAINна своих реальных запросах в рабочем проекте. Обычно там сразу видно, где забыли индекс.
Всё. Главное — запомнить:
INNER JOIN— только пересечение.LEFT JOIN— все из левой, правые — как повезёт.RIGHT JOIN— не нужен, переворачивайте таблицы и беритеLEFT.- Если тормозит — сначала
EXPLAIN, потом индексы, потом лишние колонки.
Успешных запросов 🚀