Руководство по JOIN в MySQL с примерами

Разбираемся в MySQL JOIN без скучной теории. Вместо абстрактных примеров — две конкретные таблицы с «дырками» в данных, чтобы сразу было видно разницу между INNER, LEFT и RIGHT. Бонус: чек-лист по ускорению запросов, если ваш сайт тормозит. Всё, как вы любите — код, таблицы и никакой воды.

Введение

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

Но на сайте всё должно быть вместе. Когда пользователь заходит в личный кабинет, он хочет видеть: «Иван Петров» и рядом список его заказов. А менеджеру нужен отчёт: «Какой товар чаще всего заказывают вместе с iPhone?»

Чтобы эффективно использовать MySQL JOIN, нужно понимать, как именно соединяются таблицы.

В этом руководстве:

  • не будет воды про синтаксис (он простой);
  • будут три рабочих типа JOIN, которые покрывают 99% задач;
  • будут живые примеры с конкретными данными (можете повторять у себя);
  • будет чек-лист на случай, если ваш запрос тормозит — что смотреть и как чинить.

Поехали.

Быстрый старт: две таблицы, с которыми будем работать

Прежде чем писать запросы, давайте договоримся о данных. Все примеры в статье — на этих двух таблицах. Можете создать их у себя и повторять.

Таблица customers — клиенты

customer_idcustomer_nameemail
1Иван Петровivan@email.com
2Мария Соколоваmaria@email.com
3Петр Сидоровpetr@email.com
4Анна Ивановаanna@email.com

Таблица orders — заказы

order_idcustomer_idorder_dateamount
10112024-03-155500
10222024-03-163200
10312024-03-171200
10432024-03-188900
10552024-03-194300

Обратите внимание:

  • У Ивана Петрова (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_idcustomer_nameorder_idorder_dateamount
1Иван Петров1012024-03-155500
1Иван Петров1032024-03-171200
2Мария Соколова1022024-03-163200
3Петр Сидоров1042024-03-188900

Что важно заметить:

  1. Анна Иванова (customer_id = 4) в результате не появилась. У неё нет заказов — условие INNER JOIN не выполнилось, и она отсеялась.
  2. Заказ с customer_id = 5 тоже не попал в выборку. Этого клиента нет в таблице customers — значит, соединяться не с кем.
  3. Иван Петров — дважды. Потому что у него два заказа. 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_idcustomer_nameorder_idorder_dateamount
1Иван Петров1012024-03-155500
1Иван Петров1032024-03-171200
2Мария Соколова1022024-03-163200
3Петр Сидоров1042024-03-188900
4Анна ИвановаNULLNULLNULL

Что важно заметить:

  1. Анна Иванова наконец-то появилась! У неё нет заказов, поэтому order_id, order_date и amount — пустые (NULL). Именно так на сайте можно показать сообщение «У вас пока нет заказов».
  2. Заказ с customer_id = 5 по-прежнему не виден. Потому что он в правой таблице, а LEFT JOIN гарантирует наличие всех записей только из левой. Чтобы увидеть такие «потерянные» заказы, нужно было бы менять таблицы местами или использовать другой тип соединения.
  3. Иван Петров снова в двух экземплярах. 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_idcustomer_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_idcustomer_nameorder_idorder_dateamount
1Иван Петров1012024-03-155500
1Иван Петров1032024-03-171200
2Мария Соколова1022024-03-163200
3Петр Сидоров1042024-03-188900
NULLNULL1052024-03-194300

Что важно заметить:

  1. Появился заказ 105! Тот самый, у которого customer_id = 5 (несуществующий клиент). В левой таблице customers для него нет записи, поэтому customer_id и customer_nameNULL.
  2. Анна Иванова исчезла. Потому что у неё нет заказов, а правой таблице (orders) до неё дела нет. Она же не заказывала.
  3. Механика та же, что у 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Сколько строк пришлось просмотреть
ExtraUsing 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 тормозит

  1. Поставьте EXPLAIN перед запросом.
  2. Посмотрите на rows и type — где полные сканы таблиц?
  3. Проверьте индексы — создайте на полях соединения.
  4. Уберите SELECT * — оставьте только нужное.
  5. Проверьте логику LEFT JOIN — не убиваете ли вы его условием в WHERE.
  6. Перечитайте запрос — нет ли случайно декартова произведения.

В 95% случаев после этих шагов запрос начинает летать.

Шпаргалка: главное о JOIN

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

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

О чём: Многие думают, что SQL выполняется в том порядке, в котором написан: сначала SELECT, потом FROM, потом JOIN, потом WHERE. На самом деле всё иначе. Например, JOIN выполняется до SELECT, а WHERE может кардинально менять логику LEFT JOIN.

Кому читать: Если вы когда-нибудь писали запрос и не понимали, почему с WHERE он работает не так, как без него.

Ошибки в составлении SQL запросов и как их избежать

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

Кому читать: Всем, кто пишет SQL чаще раза в месяц. Гарантированно найдёте пару своих старых ошибок.

Если хочется практики

Создайте таблицы из начала статьи, введите данные и попробуйте:

  1. Написать запрос, который покажет клиентов, у которых нет заказов (мы это делали через LEFT JOIN ... WHERE ... IS NULL).
  2. Написать запрос, который покажет товары, которые никто никогда не заказывал (потребуется таблица products и order_items — её здесь не было, но логика та же).
  3. Посмотреть EXPLAIN на своих реальных запросах в рабочем проекте. Обычно там сразу видно, где забыли индекс.

Всё. Главное — запомнить:

  • INNER JOIN — только пересечение.
  • LEFT JOIN — все из левой, правые — как повезёт.
  • RIGHT JOIN — не нужен, переворачивайте таблицы и берите LEFT.
  • Если тормозит — сначала EXPLAIN, потом индексы, потом лишние колонки.

Успешных запросов 🚀

Дополнительные материалы

Предыдущая Статья

Конфигурация Middleware в Laravel 11

Следующая Статья

Исследование переноса текста и слов