SQLподзапросыкоррелированный подзапрос

Коррелированный подзапрос в SQL: что это, когда нужен и как ускорить

2026-07-11 11 мин

Коррелированный подзапрос — это подзапрос, который ссылается на колонку из внешнего запроса и поэтому выполняется заново для каждой его строки. Обычный подзапрос считается один раз и подставляет результат как константу; коррелированный привязан к текущей строке снаружи и без неё вообще не имеет смысла. Отсюда и его сила (можно сравнивать строку с её же «соседями»), и его главная беда — на больших таблицах он легко превращается в скрытый цикл на миллионы итераций.

Я много лет пишу SQL для аналитики и вижу, как коллеги спотыкаются именно здесь: код выглядит понятно, отдаёт правильный результат на тестовой выборке, а в проде запрос висит минуту. Давай разберём, где эта штука уместна, а где её пора переписать.

Чем коррелированный подзапрос отличается от обычного?

Обычный (некоррелированный) подзапрос — это самостоятельный запрос. Его можно скопировать, выполнить отдельно, и он вернёт результат. База считает его один раз, кэширует значение и дальше работает с ним как с числом.

-- Обычный подзапрос: средняя зарплата по ВСЕЙ компании считается один раз
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);

Тут SELECT AVG(salary) FROM employees не зависит ни от чего снаружи. Выполни его отдельно — получишь, скажем, 85 000. Дальше внешний запрос просто сравнивает каждую зарплату с этим числом.

Коррелированный подзапрос так вырвать нельзя. Он ссылается на алиас внешней таблицы:

-- Коррелированный: средняя считается ЗАНОВО для отдела каждого сотрудника
SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id   -- вот эта строка делает подзапрос коррелированным
);

Строка e2.department_id = e.department_id тянет e.department_id из внешнего запроса. Скопируй этот подзапрос отдельно — база скажет, что не знает такого e. Логически он выполняется так: берём первого сотрудника, узнаём его отдел, считаем среднее по этому отделу, сравниваем; берём второго — считаем среднее заново; и так по кругу для каждой строки. Отсюда и название: подзапрос коррелирует (связан) с внешним запросом.

Как найти зарплату выше средней по отделу?

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

SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary > (
    SELECT AVG(e2.salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
);

Обрати внимание: обычным подзапросом это не решить в одну строку, потому что для каждого сотрудника «среднее» — своё, оно зависит от его отдела. Именно связь с внешней строкой позволяет сравнивать человека не со всей компанией, а с его конкретной командой.

Тот же приём работает для «свежее последнего события», «дороже медианы по категории», «раньше первого платежа пользователя». Везде, где нужно сравнить строку с агрегатом её же группы, коррелированный подзапрос читается интуитивно. Прогнать похожую задачу на реальных данных удобно в SQL-тренажёре — там сразу видно план и время выполнения.

Как проверить наличие хотя бы одного заказа через EXISTS?

Второй сценарий, где коррелированный подзапрос почти незаменим по читаемости, — проверка «а есть ли вообще хоть одна связанная строка». Для этого есть EXISTS.

-- Пользователи, у которых есть хотя бы один заказ
SELECT u.id, u.email
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);
EXISTS возвращает TRUE, как только находит первую подходящую строку, и на этом останавливается — ему не важно, сколько там заказов, один или тысяча. Внутри принято писать SELECT 1, потому что конкретные колонки не нужны, важен сам факт наличия строки.

Зеркальный случай — найти пользователей без единого заказа. Тут NOT EXISTS:

-- Зарегистрировались, но ни разу не заказали
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Это частый кусок аналитики: посчитать «спящих» пользователей, отвалившихся после регистрации, аккаунты без платежей. Логика «есть связь / нет связи» напрямую ложится на EXISTS / NOT EXISTS, и читается такой запрос почти как русский текст. Похожие разборы воронки и активации я собрал в практических кейсах, а базовые метрики вроде DAU как раз строятся на таких проверках наличия событий.

Почему коррелированный подзапрос бывает медленным?

Потому что в наивном исполнении это вложенный цикл. Внешний запрос перебирает строки, и для каждой запускает подзапрос заново. Если во внешней таблице миллион строк, а подзапрос каждый раз сканирует таблицу заказов — ты получаешь миллион сканов. Формально сложность превращается в «строки снаружи умножить на стоимость подзапроса», и это растёт очень быстро.

Практический маркер: запрос отлично работает на тестовой базе в тысячу строк и намертво встаёт на проде. На малой выборке миллион итераций проходит за миллисекунды, а на реальных данных это уже часы работы CPU.

Три вещи, которые чаще всего спасают:

Важная оговорка: современные оптимизаторы (PostgreSQL, например) часто сами превращают коррелированный подзапрос в полу-соединение и выполняют его эффективно. Так что «коррелированный = медленный» — это не закон, а повод посмотреть в EXPLAIN. Иногда переписывать нечего, планировщик уже всё сделал за тебя.

Как переписать коррелированный подзапрос на JOIN?

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

-- Было: WHERE EXISTS (...)
-- Стало: полу-соединение через JOIN + дедупликация
SELECT u.id, u.email
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
GROUP BY (или DISTINCT) тут нужен, потому что JOIN размножит пользователя по числу его заказов, а нам нужен каждый один раз. Это отличие важно помнить: EXISTS не создаёт дубликатов, а JOIN — создаёт, и их приходится схлопывать.

Случай «пользователи без заказов» переписывается через LEFT JOIN с проверкой на NULL — это стандартный антипаттерн-в-хорошем-смысле, который спрашивают почти на каждом собеседовании:

SELECT u.id, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.user_id IS NULL;   -- заказ не нашёлся → строка справа пустая
LEFT JOIN подтягивает заказы, а если их нет — правая часть заполняется NULL. Фильтр WHERE o.user_id IS NULL оставляет ровно тех, у кого связи не нашлось. Разобрать разницу между NOT EXISTS, NOT IN и LEFT JOIN ... IS NULL (особенно как NOT IN ломается на NULL) стоит отдельно — это любимая ловушка интервьюеров, её удобно потренировать на задачах по SQL и в подборке вопросов с собеседований.

Когда лучше использовать оконную функцию вместо подзапроса?

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

SELECT name, department_id, salary
FROM (
    SELECT name,
           department_id,
           salary,
           AVG(salary) OVER (PARTITION BY department_id) AS dept_avg
    FROM employees
) t
WHERE salary > dept_avg;
AVG(salary) OVER (PARTITION BY department_id) — это и есть «среднее по отделу текущей строки», но посчитанное за один проход по таблице, а не N раз. На больших данных разница в скорости бывает драматической. Плюс окно легко расширить: добавить RANK(), чтобы взять топ-3 по зарплате в отделе, или SUM(...) OVER (...) для нарастающего итога — коррелированным подзапросом такое пишется мучительно.

Ещё частый кейс — «последний заказ каждого пользователя». Через коррелированный подзапрос в SELECT:

SELECT u.id,
       (SELECT MAX(o.created_at)
        FROM orders o
        WHERE o.user_id = u.id) AS last_order_at
FROM users u;

Через окно — за один проход и с возможностью вытащить не только дату, но и любые поля этого заказа:

SELECT user_id, created_at AS last_order_at, amount
FROM (
    SELECT user_id, created_at, amount,
           ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
) t
WHERE rn = 1;

Оконные функции — тот навык, который отделяет джуна от мидла в аналитике. Системный разбор от PARTITION BY до рамок окна есть в курсе по SQL, а короткие формулировки синтаксиса удобно подсматривать в SQL-справочнике.

Нужно ли вообще избегать коррелированных подзапросов?

Нет. Это не «плохой SQL», которого надо стыдиться. У него есть законные ниши, где он читается лучше любой альтернативы:

Мой рабочий порядок такой: пишу как думаю (часто это коррелированный подзапрос), проверяю результат, а дальше смотрю план. Если запрос в пайплайне и таблицы большие — переписываю на JOIN или окно. Если это разовая выгрузка — оставляю. Правило «сначала правильно, потом быстро» тут работает буквально.

Что спрашивают на собеседовании и что тренировать?

Три вещи всплывают почти в каждом интервью на аналитика. Первое — объяснить своими словами разницу между обычным и коррелированным подзапросом (ключевое: коррелированный ссылается на внешнюю строку и выполняется для каждой). Второе — написать «зарплату выше средней по отделу» и следом переписать её на оконную функцию. Третье — найти строки без пары (NOT EXISTS против LEFT JOIN ... IS NULL против NOT IN, и почему последний коварен с NULL).

Если хочешь довести это до автоматизма, а не просто «понимать в теории»:

Коррелированный подзапрос — не магия и не зло. Это способ сказать «для каждой строки посмотри на её окружение». Понимаешь, когда он читается лучше, а когда пора звать JOIN или окно, — и половина каверзных SQL-вопросов на собеседовании перестаёт быть каверзными.

Хочешь разобрать оконные функции, воронки и когорты системно, с проверкой решений и разбором типичных ошибок — это открывается в Pro вместе со всеми SQL- и Python-задачами и безлимитным AI-собесом. Начать можно с бесплатных задач, а дальше решить, нужен ли полный доступ.

Отработай SQL на практике
545 SQL-задач с автопроверкой в браузере — первые открыты без регистрации.
SQL-тренажёр →