Привет, Habr!

Каждый, кто работает с SQL, рано или поздно усваивает "золотое правило": "Никогда не используй коррелированные подзапросы в SELECT!". Нам говорят, что это верный путь к "проблеме N+1": для каждой строки внешнего запроса будет выполняться один внутренний, что убивает производительность. Вместо этого нам советуют использовать JOIN.

Теоретически, JOIN — это "правильный" реляционный, пакетный (set-based) способ. Он должен быть быстрее.

Но так ли это на самом деле? Я решил проверить это на практике, и результаты, которые я получил на четырех разных СУБД, оказались... интересными.

Тестовый стенд: Клиенты и Заказы

Чтобы тест был честным, нам нужна простая и понятная схема.

  1. customers: Маленькая таблица клиентов. (25 записей)

    -- Таблица клиентов
    CREATE TABLE customers (
        customer_id INT PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    
  2. orders: Таблица побольше с заказами, связанная с клиентами. (1000 записей)

    -- Таблица заказов
    CREATE TABLE orders (
        order_id INT PRIMARY KEY,
        customer_id INT,
        order_date DATETIME,
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );
    

Задача: Получить всех клиентов и посчитать, сколько заказов сделал каждый из них.

Два способа решения

Теперь давайте напишем два запроса для решения нашей задачи.

Вариант 1: LEFT JOIN ... GROUP BY (Теоретически "Правильный")

Мы используем LEFT JOIN, чтобы гарантированно получить всех клиентов, даже тех, у кого 0 заказов.

SELECT 
    c.customer_id, 
    COUNT(o.order_id) AS orders_count
FROM 
    customers c
LEFT JOIN 
    orders o ON c.customer_id = o.customer_id
GROUP BY 
    c.customer_id;
  • Теория: Это быстрая, пакетная операция. База данных должна один раз соединить таблицы, а затем один раз сгруппировать результат для подсчета.

Вариант 2: Коррелированный подзапрос (Теоретически "Плохой")

Этот запрос буквально следует логике "для каждого клиента посчитай его заказы".

SELECT 
    c.customer_id, 
    (SELECT COUNT(o.order_id) 
     FROM orders o 
     WHERE o.customer_id = c.customer_id) AS orders_count
FROM 
    customers c;
  • Теория: Это медленная, построчная (row-by-row) операция. Она должна выполнить 1 запрос к customers и еще 25 (по числу клиентов) отдельных запросов к orders. Всего 26 запросов. Звучит как кошмар N+1.

Теоретический вывод

Победитель: JOIN. Он должен быть быстрее, потому что выполняет одну сложную, но оптимизированную операцию, вместо 26 более простых.

...верно?

Практический тест: "Песочницы" к бою!

Теория — это хорошо, но давайте посмотрим, что на самом деле решат оптимизаторы.

Я запустил оба запроса на четырех разных СУБД. Вы можете не просто посмотреть на время и, что важнее, изучить план выполнения.

Как "увидеть" план выполнения?

Перед каждым запросом нужно добавить "волшебное" слово. Это наш "рентген", который покажет, что на самом деле делает база данных.

  • Для MySQL 8.0+ и PostgreSQL: EXPLAIN ANALYZE (выполняет запрос и показывает реальное время и план)

  • Для SQLite: EXPLAIN QUERY PLAN (показывает высокоуровневый план)

  • Для Oracle: EXPLAIN PLAN FOR ... (сохраняет план, который потом нужно посмотреть отдельным запросом.).

Ниже приведены мои результаты тестирования и ссылки на "песочницы" где вы можете воспроизвести мои запросы, и если интересно провести свои исследования.


1. MySQL 8.0

Ссылка на MySQL sandbox

  • Создаём таблицы:

    -- Create the customers table
    CREATE TABLE customers (
        customer_id INT AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    
    -- Insert 25 random customer records
    INSERT INTO customers (name) VALUES
    ('Alice Smith'), ('Bob Johnson'), ('Charlie Brown'), ('Diana Prince'), ('Ethan Hunt'),
    ('Fiona Gallagher'), ('George Miller'), ('Hannah Abbott'), ('Ian Fleming'), ('Jane Austen'),
    ('Kevin Spacey'), ('Laura Croft'), ('Michael Jordan'), ('Nancy Drew'), ('Oliver Twist'),
    ('Penelope Cruz'), ('Quentin Tarantino'), ('Rachel Green'), ('Steve Rogers'), ('Tina Turner'),
    ('Ursula K. Le Guin'), ('Victor Hugo'), ('Wendy Darling'), ('Xavier Roberts'), ('Yvonne Craig');
    
    -- Create the orders table
    CREATE TABLE orders (
        order_id INT AUTO_INCREMENT PRIMARY KEY,
        customer_id INT,
        order_date DATETIME, 
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );
    
    -- Generate 1000 rows fro orders table
    INSERT INTO orders (customer_id)
    WITH RECURSIVE data_rows (n) AS (
        SELECT 1
        UNION ALL
        SELECT n + 1 FROM data_rows WHERE n < 1000 
    )
    SELECT FLOOR(RAND() * 25)+1
    FROM data_rows;
    
  • Выполняем запросы и измеряем время выполнения Команды SET profiling = 1; в начале и SHOW PROFILES; в конце позволяют получить время выподнения запросов.

    SET profiling = 1;
    
    SELECT 
        customers.customer_id, 
        COUNT(order_id) AS orders_count
    FROM customers 
    LEFT JOIN orders 
        ON customers.customer_id = orders.customer_id
    GROUP BY customers.customer_id;
    
    SELECT 
        customers.customer_id, 
        (
            SELECT COUNT(order_id) 
            FROM orders 
            WHERE orders.customer_id = customers.customer_id
        ) AS orders_count
    FROM customers;
    
    
    SHOW PROFILES;
    
    |----------|------------|--------------------------------------------------------------------------------------------------------------------------------------|
    | Query_ID | Duration   | Query                                                                                                                                |
    |----------|------------|--------------------------------------------------------------------------------------------------------------------------------------|
    | 1        | 0.01632825 | SELECT customers.customer_id, COUNT(order_id) AS orders_count                                                                        |
    |          |            | FROM customers                                                                                                                       |
    |          |            | JOIN orders ON customers.customer_id = orders.customer_id                                                                            |
    |          |            | GROUP BY customer_id                                                                                                                 |
    | 2        | 0.01408775 | SELECT customers.customer_id, (SELECT COUNT(order_id) FROM orders WHERE orders.customer_id = customers.customer_id) AS orders_count  |
    |          |            | FROM customers                                                                                                                       |
    

Я выполнял тест несколько раз и стабильно получал лучший результат для варианта с подзапросом.

  • Анализируем планы запросов:

    EXPLAIN ANALYZE -- План для JOIN
    SELECT c.customer_id, COUNT(o.order_id) AS orders_count
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id;
    
    |-----------------------------------------------------------------------------------------------------------------------------------------------------------|
    | EXPLAIN                                                                                                                                                   |
    |-----------------------------------------------------------------------------------------------------------------------------------------------------------|
    | -> Group aggregate: count(o.order_id)  (cost=14 rows=25) (actual time=0.0701..0.347 rows=25 loops=1)                                                      |
    |     -> Nested loop left join  (cost=11.5 rows=25) (actual time=0.0407..0.302 rows=1000 loops=1)                                                           |
    |         -> Covering index scan on c using PRIMARY  (cost=2.75 rows=25) (actual time=0.0243..0.0269 rows=25 loops=1)                                       |
    |         -> Covering index lookup on o using customer_id (customer_id=c.customer_id)  (cost=0.254 rows=1) (actual time=0.00206..0.00817 rows=40 loops=25)  |
    |  
    
    EXPLAIN ANALYZE -- План для Подзапроса
    SELECT c.customer_id, 
        (SELECT COUNT(o.order_id) 
         FROM orders o 
         WHERE o.customer_id = c.customer_id) AS orders_count
    FROM customers c;
    
    |---------------------------------------------------------------------------------------------------------------------------------------------------------|
    | EXPLAIN                                                                                                                                                 |
    |---------------------------------------------------------------------------------------------------------------------------------------------------------|
    | -> Covering index scan on c using PRIMARY  (cost=2.75 rows=25) (actual time=0.0233..0.0272 rows=25 loops=1)                                             |
    | -> Select #2 (subquery in projection; dependent)                                                                                                        |
    |     -> Aggregate: count(o.order_id)  (cost=0.45 rows=1) (actual time=0.0161..0.0161 rows=1 loops=25)                                                    |
    |         -> Covering index lookup on o using customer_id (customer_id=c.customer_id)  (cost=0.35 rows=1) (actual time=0.00343..0.0137 rows=40 loops=25)  |
    |                                                                                                                                                         |
    
  • Делаем выводы:
    В результате теста среднее время выполнения коррелированного подзапроса оказалось меньше, чем у запроса с JOIN.
    Почему?

    Ответ кроется в плане выполнения и соотношении размеров таблиц (25 против 1000).

    • В первом запросе БД вынуждена объединить все 25 клиентов со всеми их 1000 заказами. Это требует больше памяти и процессорного времени. Только после этого она может применить GROUP BY.

    • Во втором запросе БД выполняет внешний цикл по 25 клиентам (Covering index scan on c). Для каждого клиента она выполняет подзапрос. Благодаря индексу на orders.customer_id, этот внутренний цикл молниеносно находит и подсчитывает (агрегирует) только заказы одного конкретного клиента, немедленно возвращая результат (rows=1).
      Этот подход "сначала агрегировать, потом объединить" оказался чрезвычайно эффективным благодаря наличию покрывающего индекса (Covering index lookup on o using customer_id), который позволяет посчитать заказы, не обращаясь к основной таблице данных (orders).
      Выполнить 25 сверхбыстрых поисков по индексу (это не N+1 Table Scan!) оказалось в разы дешевле, чем один сложный Hash Join с последующим GROUP BY.

2. Oracle 23c

Ссылка на Oracle Playground

  • Создаём таблицы:

    -- Create the customers table
    CREATE TABLE customers (
        customer_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    -- Insert 25 random customer records
    INSERT INTO customers (name) VALUES
    ('Alice Smith'), ('Bob Johnson'), ('Charlie Brown'), ('Diana Prince'), ('Ethan Hunt'),
    ('Fiona Gallagher'), ('George Miller'), ('Hannah Abbott'), ('Ian Fleming'), ('Jane Austen'),
    ('Kevin Spacey'), ('Laura Croft'), ('Michael Jordan'), ('Nancy Drew'), ('Oliver Twist'),
    ('Penelope Cruz'), ('Quentin Tarantino'), ('Rachel Green'), ('Steve Rogers'), ('Tina Turner'),
    ('Ursula K. Le Guin'), ('Victor Hugo'), ('Wendy Darling'), ('Xavier Roberts'), ('Yvonne Craig');
    
    -- Create the orders table
    CREATE TABLE orders (
        order_id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
        customer_id INT,
        order_date TIMESTAMP,
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );
    
    INSERT INTO orders (customer_id, order_date)
    SELECT
        FLOOR(DBMS_RANDOM.VALUE * 25) + 1,
        SYSTIMESTAMP - NUMTODSINTERVAL(FLOOR(DBMS_RANDOM.VALUE * 365), 'DAY')
    FROM
        (SELECT LEVEL AS n FROM dual CONNECT BY LEVEL <= 1000);
    
  • Выполняем запросы и измеряем время выполнения

    SELECT 
        customers.customer_id, 
        COUNT(order_id) AS orders_count
    FROM customers 
    LEFT JOIN orders 
        ON customers.customer_id = orders.customer_id
    GROUP BY customers.customer_id;
    
    SELECT 
        customers.customer_id, 
        (
            SELECT COUNT(order_id) 
            FROM orders 
            WHERE orders.customer_id = customers.customer_id
        ) AS orders_count
    FROM customers;
    
    SELECT
        sql_text,
        (elapsed_time / 1000 / executions) AS "Average time (ms)"
    FROM
        V$SQLAREA
    WHERE
        executions > 0 AND
        UPPER(sql_text) LIKE 'SELECT CUSTOMERS%';
    
    -----------------------------------------------------------------------------------------------|-------------------|
    | SQL_TEXT                                                                                     | Average time (ms) |
    |----------------------------------------------------------------------------------------------|-------------------|
    | SELECT customers.customer_id, COUNT(order_id) AS orders_count FROM customers  LEFT JOIN ...  | 14.964            |
    | SELECT customers.customer_id, customers.name, (SELECT COUNT(order_id) FROM orders WHERE ...  | 2.411             |
    
  • Анализируем планы запросов:

    -- Шаг 1: Генерируем план для JOIN
    EXPLAIN PLAN FOR
    SELECT c.customer_id, COUNT(o.order_id) AS orders_count
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id;
    
    -- Шаг 2: Смотрим результат
    SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
    
    
    -- Шаг 3: Генерируем план для Подзапроса
    EXPLAIN PLAN FOR
    SELECT c.customer_id, 
        (SELECT COUNT(o.order_id) 
        FROM orders o 
        WHERE o.customer_id = c.customer_id) AS orders_count
    FROM customers c;
    
    -- Шаг 4: Смотрим результат
    SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
    
    |-----------------------------------------------------------------------------------|
    | PLAN_TABLE_OUTPUT                                                                 |
    |-----------------------------------------------------------------------------------|
    |                                                                                   |
    | --------------------------------------------------------------------------------- |
    | | Id  | Operation           | Name      | Rows  | Bytes | Cost (%CPU)| Time     | |
    | --------------------------------------------------------------------------------- |
    | |   0 | SELECT STATEMENT    |           |  1000 | 39000 |     6  (17)| 00:00:01 | |
    | |   1 |  HASH GROUP BY      |           |  1000 | 39000 |     6  (17)| 00:00:01 | |
    | |*  2 |   HASH JOIN OUTER   |           |  1000 | 39000 |     5   (0)| 00:00:01 | |
    | |   3 |    TABLE ACCESS FULL| CUSTOMERS |    25 |   325 |     2   (0)| 00:00:01 | |
    | |   4 |    TABLE ACCESS FULL| ORDERS    |  1000 | 26000 |     3   (0)| 00:00:01 | |
    | --------------------------------------------------------------------------------- |
    |                                                                                  |
    | Predicate Information (identified by operation id):                              |
    | ---------------------------------------------------                              |
    |                                                                                  |
    |    2 - access("C"."CUSTOMER_ID"="O"."CUSTOMER_ID"(+))                            |
    
    
    |----------------------------------------------------------------------------------|
    | PLAN_TABLE_OUTPUT                                                                |
    |----------------------------------------------------------------------------------|
    |                                                                                  |
    | -------------------------------------------------------------------------------- |
    | | Id  | Operation          | Name      | Rows  | Bytes | Cost (%CPU)| Time     | |
    | -------------------------------------------------------------------------------- |
    | |   0 | SELECT STATEMENT   |           |    25 |   325 |     5   (0)| 00:00:01 | |
    | |   1 |  SORT AGGREGATE    |           |     1 |    13 |            |          | |
    | |* 2 |   TABLE ACCESS FULL| ORDERS    |    10 |   130 |     3   (0)| 00:00:01 | |
    | |   3 |  TABLE ACCESS FULL | CUSTOMERS |    25 |   325 |     2   (0)| 00:00:01 | |
    | -------------------------------------------------------------------------------- |
    |                                                                                  |
    | Predicate Information (identified by operation id):                              |
    | ---------------------------------------------------                              |
    |                                                                                  |
    |    2 - filter("O"."CUSTOMER_ID"=:B1)                                             |
    |                                                                                  |
    
  • Делаем выводы:

    • В этом тесте Oracle 23c быстрее выполнял коррелированный подзапрос: ~2.41 ms против ~14.96 ms для варианта с JOIN.

    • JOIN + GROUP BY выбрал план Hash Join + Hash Group By с полными сканами, что на малом объёме данных даёт лишние накладные.

    • Подзапрос подсказал более дешёвый путь: агрегирование по заказам для каждого клиента без внешнего GROUP BY; на небольшом наборе это чаще оборачивается Nested Loop/индексным доступом и выигрывает.

    • Критично наличие индекса по orders(customer_id): с индексом — быстрые lookups; без индекса Oracle склонен к full scan и преимущество исчезает.

    • Итог: в Oracle выбор плана (NL vs Hash) важнее формы запроса. На больших объёмах Hash Join/GROUP BY обычно победит. Всегда проверяйте EXPLAIN PLAN/DBMS_XPLAN.

3. PostgreSQL 16

Ссылка на PostgreSQL тест

  • Создаём таблицы:

    -- Create the customers table
    CREATE TABLE customers (
        customer_id SERIAL PRIMARY KEY,
        name VARCHAR(255) NOT NULL
    );
    
    -- Insert 25 random customer records
    INSERT INTO customers (name) VALUES
    ('Alice Smith'), ('Bob Johnson'), ('Charlie Brown'), ('Diana Prince'), ('Ethan Hunt'),
    ('Fiona Gallagher'), ('George Miller'), ('Hannah Abbott'), ('Ian Fleming'), ('Jane Austen'),
    ('Kevin Spacey'), ('Laura Croft'), ('Michael Jordan'), ('Nancy Drew'), ('Oliver Twist'),
    ('Penelope Cruz'), ('Quentin Tarantino'), ('Rachel Green'), ('Steve Rogers'), ('Tina Turner'),
    ('Ursula K. Le Guin'), ('Victor Hugo'), ('Wendy Darling'), ('Xavier Roberts'), ('Yvonne Craig');
    
    -- Create the orders table
    CREATE TABLE orders (
        order_id SERIAL PRIMARY KEY,
        customer_id INT,
        order_date TIMESTAMP,
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );
    
    INSERT INTO orders (customer_id)
    WITH RECURSIVE  data_rows (n) AS (
        SELECT 1
        UNION ALL
        SELECT n + 1 FROM data_rows WHERE n < 1000
    )
    SELECT FLOOR(RANDOM() * 25) + 1
    FROM data_rows;
    
    -- Create index on orders table
    CREATE INDEX idx_orders_customer_id ON orders (customer_id);
    
    
  • Выполняем запросы и измеряем время выполнения

    EXPLAIN ANALYZE 
    SELECT 
        c.customer_id, COUNT(o.order_id) AS orders_count
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id;
    
    |---------------------------------------------------------------------------------------------------------------------------|
    | QUERY PLAN                                                                                                                |
    |---------------------------------------------------------------------------------------------------------------------------|
    | HashAggregate  (cost=55.86..57.26 rows=140 width=12) (actual time=0.317..0.320 rows=25 loops=1)                           |
    |   Group Key: c.customer_id                                                                                                |
    |   Batches: 1  Memory Usage: 40kB                                                                                          |
    |   ->  Hash Right Join  (cost=13.15..46.61 rows=1850 width=8) (actual time=0.050..0.227 rows=1000 loops=1)                 |
    |         Hash Cond: (o.customer_id = c.customer_id)                                                                        |
    |         ->  Seq Scan on orders o  (cost=0.00..28.50 rows=1850 width=8) (actual time=0.007..0.066 rows=1000 loops=1)       |
    |         ->  Hash  (cost=11.40..11.40 rows=140 width=4) (actual time=0.019..0.019 rows=25 loops=1)                         |
    |               Buckets: 1024  Batches: 1  Memory Usage: 9kB                                                                |
    |               ->  Seq Scan on customers c  (cost=0.00..11.40 rows=140 width=4) (actual time=0.004..0.006 rows=25 loops=1) |
    | Planning Time: 0.295 ms                                                                                                   |
    | Execution Time: 0.415 ms  
    
    EXPLAIN ANALYZE
    SELECT c.customer_id, 
        (SELECT COUNT(o.order_id) 
        FROM orders o 
        WHERE o.customer_id = c.customer_id) AS orders_count
    FROM customers c;
    
    |-----------------------------------------------------------------------------------------------------------------------------------------------|
    | QUERY PLAN                                                                                                                                    |
    |-----------------------------------------------------------------------------------------------------------------------------------------------|
    | Seq Scan on customers c  (cost=0.00..2083.03 rows=140 width=12) (actual time=0.063..0.752 rows=25 loops=1)                                    |
    |   SubPlan 1                                                                                                                                   |
    |     ->  Aggregate  (cost=14.79..14.80 rows=1 width=8) (actual time=0.029..0.029 rows=1 loops=25)                                              |
    |           ->  Bitmap Heap Scan on orders o  (cost=4.22..14.76 rows=9 width=4) (actual time=0.014..0.019 rows=40 loops=25)                     |
    |                 Recheck Cond: (customer_id = c.customer_id)                                                                                   |
    |                 Heap Blocks: exact=125                                                                                                        |
    |                 ->  Bitmap Index Scan on idx_orders_customer_id  (cost=0.00..4.22 rows=9 width=0) (actual time=0.012..0.012 rows=40 loops=25) |
    |                       Index Cond: (customer_id = c.customer_id)                                                                               |
    | Planning Time: 0.060 ms                                                                                                                       |
    | Execution Time: 0.803 ms  
    
  • Выводы:

    • В этом тесте PostgreSQL 16 быстрее выполнил вариант с JOIN + GROUP BY: ~0.415 ms против ~0.803 ms для коррелированного подзапроса.

    • План JOIN: Hash Right Join + HashAggregate с одним проходом по таблицам — меньше итераций и накладных, чем у подзапроса.

    • План подзапроса: 25 запусков под-плана с Bitmap Scan по orders (классический N+1-эффект), поэтому медленнее.

    • Вывод: в PostgreSQL коррелированные подзапросы легко деградируют в N+1; предпочитайте set-based JOIN и проверяйте планы через EXPLAIN ANALYZE.

4. SQLite 3.45

Ссылка на SQLite тест

  • Создаём таблицы:

    -- Create the customers table
    CREATE TABLE customers (
        customer_id INTEGER PRIMARY KEY AUTOINCREMENT,
        name VARCHAR(255) NOT NULL
    );
    
    -- Insert 100 random customer records
    -- We'll use a combination of common names to simulate random data
    INSERT INTO customers (name) VALUES
    ('Alice Smith'), ('Bob Johnson'), ('Charlie Brown'), ('Diana Prince'), ('Ethan Hunt'),
    ('Fiona Gallagher'), ('George Miller'), ('Hannah Abbott'), ('Ian Fleming'), ('Jane Austen'),
    ('Kevin Spacey'), ('Laura Croft'), ('Michael Jordan'), ('Nancy Drew'), ('Oliver Twist'),
    ('Penelope Cruz'), ('Quentin Tarantino'), ('Rachel Green'), ('Steve Rogers'), ('Tina Turner'),
    ('Ursula K. Le Guin'), ('Victor Hugo'), ('Wendy Darling'), ('Xavier Roberts'), ('Yvonne Craig');
    
    -- Create the orders table
    CREATE TABLE orders (
        order_id INTEGER PRIMARY KEY AUTOINCREMENT,
        customer_id INT,
        order_date DATETIME,
        FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );
    -- Create index
    CREATE INDEX idx_orders_customer ON orders(customer_id);
    
    
    INSERT INTO orders (customer_id)
    WITH RECURSIVE data_rowa (n) AS (
        SELECT 1
        UNION ALL
        SELECT n + 1 FROM data_rowa WHERE n < 1000 
    )
    SELECT (ABS(RANDOM()) % 25) + 1
    FROM data_rowa;
    
  • Запросы для анализа:

    CREATE TEMP TABLE IF NOT EXISTS _benchmark (
        start_time REAL
    );
    
    -- Очищаем таблицу и записываем текущее время
    DELETE FROM _benchmark;
    INSERT INTO _benchmark (start_time) VALUES (STRFTIME('%f', 'now'));
    
    
    SELECT 
        customers.customer_id, 
        COUNT(order_id) AS orders_count
    FROM customers 
    JOIN orders ON customers.customer_id = orders.customer_id
    GROUP BY customers.customer_id;
    
    SELECT
        (STRFTIME('%f', 'now') - start_time) * 1000 AS 'ExecutionTime_ms'
    FROM
        _benchmark;
    
    |------------------|
    | ExecutionTime_ms |
    |------------------|
    | 0.99999999999767 |
    
    DELETE FROM _benchmark;
    INSERT INTO _benchmark (start_time) VALUES (STRFTIME('%f', 'now'));
    
    SELECT 
        customers.customer_id, 
        (SELECT COUNT(order_id) FROM orders WHERE orders.customer_id = customers.customer_id) AS orders_count
    FROM customers;
    
    SELECT
        (STRFTIME('%f', 'now') - start_time) * 1000 AS 'ExecutionTime_ms'
    FROM
        _benchmark;
    
    |------------------|
    | ExecutionTime_ms |
    |------------------|
    | 1.0000000000012  |
    
  • Анализ плана запросов:

    EXPLAIN QUERY PLAN
    SELECT 
        c.customer_id, 
        COUNT(o.order_id) AS orders_count
    FROM customers c
    LEFT JOIN orders o ON c.customer_id = o.customer_id
    GROUP BY c.customer_id;
    
    |----|--------|---------|-----------------------------------------------------------------------------|
    | id | parent | notused | detail                                                                      |
    |----|--------|---------|-----------------------------------------------------------------------------|
    | 7  | 0      | 216     | SCAN c                                                                      |
    | 9  | 0      | 55      | SEARCH o USING COVERING INDEX idx_orders_customer (customer_id=?) LEFT-JOIN |
    
    
    EXPLAIN QUERY PLAN
    SELECT c.customer_id, 
        (SELECT COUNT(o.order_id) 
        FROM orders o 
        WHERE o.customer_id = c.customer_id) AS orders_count
    FROM customers c;
    
    |----|--------|---------|-------------------------------------------------------------------|
    | id | parent | notused | detail                                                            |
    |----|--------|---------|-------------------------------------------------------------------|
    | 2  | 0      | 216     | SCAN c                                                            |
    | 6  | 0      | 0       | CORRELATED SCALAR SUBQUERY 1                                      |
    | 11 | 6      | 55      | SEARCH o USING COVERING INDEX idx_orders_customer (customer_id=?) |
    
  • Делаем выводы:

    • SQLite 3: замер выполнялся без встроенного профайлера, поэтому использован прием с временной таблицей _benchmark — в начале запроса фиксируется STRFTIME('%f','now'), после выполнения вычисляется разница (в миллисекундах).

    • Методика:

      1. очистка и вставка времени старта; 2) запуск тестового запроса; 3) второй SELECT с расчетом (STRFTIME('%f','now') - start_time) * 1000; 4) несколько прогревочных прогонов, затем фиксация среднего.

    • В SQLite 3 оба запроса компилируются в практически одинаковый план: SCAN по customers + SEARCH по orders через покрывающий индекс; выраженного эффекта N+1 нет.

    • EXPLAIN QUERY PLAN показывает схожие шаги и стоимость; фактическое время выполнения находится в пределах погрешности измерения (~1 ms) и совпадает.

    • Поэтому выбор между LEFT JOIN + GROUP BY и коррелированным подзапросом имеет смысл делать по семантике и читаемости — на данном датасете производственной разницы нет.

    • Рекомендация: проверяйте планы через EXPLAIN QUERY PLAN и замеряйте время несколькими прогонами с «прогревом» кэша.


Результаты и Анализ: Почему теория дала сбой

1. MySQL и Oracle: Подзапрос побеждает!

Именно так. СУБД, которые считаются промышленными гигантами, выполнили "плохой" запрос с подзапросом значительно быстрее, чем "хороший" запрос с JOIN.

Почему?

Ответ кроется в плане выполнения и соотношении размеров таблиц (25 против 1000).

  • Запрос 1 (JOIN) "подсказал" оптимизатору использовать план Hash Join и Hash Aggregate. Он честно сканировал обе таблицы, строил хэш-таблицы в памяти, соединял их, а затем снова хэшировал для GROUP BY. Для таких маленьких данных накладные расходы на всю эту "тяжелую" машинерию оказались выше.

  • Запрос 2 (Подзапрос) "подсказал" другой, более простой план: Nested Loop (Вложенный цикл).

    1. Взять таблицу customers (всего 25 строк).

    2. Для каждой из 25 строк выполнить поиск по индексу в orders (наш FOREIGN KEY уже проиндексирован).

Выполнить 25 сверхбыстрых поисков по индексу (в плане EXPLAIN вы увидите что-то вроде UNIQUE_SUBQUERY или INDEX RANGE SCAN) оказалось в разы дешевле, чем один сложный Hash Join с последующим GROUP BY. Коррелированный подзапрос в данном случае был не "проблемой N+1", а идеальной подсказкой для оптимизатора.

2. PostgreSQL: JOIN побеждает!

Здесь теория подтвердилась.

  • PostgreSQL: Оптимизатор PostgreSQL, вероятно, самый умный из всех. Он посмотрел на оба запроса и понял, что для такой маленькой таблицы customers план Nested Loop будет эффективным в обоих случаях. EXPLAIN ANALYZE показывает, что он выбрал план Nested Loop для обоих запросов. JOIN оказался на микросекунды быстрее, так как его синтаксис был "чище" и оптимизатору не пришлось ничего "переписывать".

3. SQLite: дружеская ничья!

- В SQLite 3 оба запроса (JOIN и коррелированный подзапрос) показали одинаковый план выполнения и идентичное время выполнения (~1 ms). 
- EXPLAIN QUERY PLAN для обоих запросов демонстрирует SCAN по таблице `customers` и SEARCH по таблице `orders` через покрывающий индекс. 
- Выраженного эффекта N+1 не наблюдается, так как оптимизатор SQLite эффективно обрабатывает оба варианта.
- Выбор между JOIN и коррелированным подзапросом в SQLite можно делать исходя из читаемости и семантики, а не производительности.

Вывод: Нет никакой "серебряной пули"

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

Наше исследование показывает, что лучший запрос всегда зависит от трех вещей:

  1. Движка СУБД: Насколько умен его оптимизатор? (PostgreSQL "переписал" наш плохой запрос, а MySQL — нет, но выполнил его буквально, и это оказалось быстрее).

  2. Набора данных: Каково соотношение размеров таблиц? (Наш подзапрос выиграл только потому, что внешняя таблица была крошечной).

  3. Наличия индексов: (Победа подзапроса на MySQL/Oracle была бы невозможна без индекса по customer_id в таблице orders).

Главный урок: Не гадайте. Тестируйте.

Ваш лучший друг — это не "золотые правила", а команда EXPLAIN. Всегда проверяйте, какой план выполнения строит ваша СУБД для ваших конкретных данных.


Текст подготовлен на основе реального исследования, проведенного проведённого мной лично. В результате тестов никто не пострадал.

Комментарии (25)


  1. tbl
    12.11.2025 21:48

    Интересно, что бы победило, если бы в конце не надо было делать group by, а посчитать агрегат без ключа группировки?

    Т. к. в этом случае, если иметь данные в RAM, то аналог join (flatMap) побеждает практически всегда. Это проверял для котлиновских Sequence, джавовых stream, итераторов в rust, LINQ-запросов в C#: лучше сначала умножить две коллекции через flatMap и строить агрегат, чем агрегировать по субколлекциям, а затем считать общий total. Хотя, казалось бы, данные в субколлекциях лежат близко в памяти, и должны лучше попадать в cache-line, плюс векторизация должна отработать.


    1. Akina
      12.11.2025 21:48

      Интересно, что бы победило, если бы в конце не надо было делать group by, а посчитать агрегат без ключа группировки?

      Какая-то не очень определённая фраза. Ведь в исследуемом запросе в выходном наборе есть как неагрегированное, так и агрегированное поле, и просто выбросить оттуда GROUP BY не получится, а использование оконной функции даст разные выходные наборы.

      Не могли бы вы привести точный текст SQL-запроса, о котором говорите?


      1. tbl
        12.11.2025 21:48

        В терминах ваших запросов будет так, первый вариант:

        SELECT COUNT(o.order_id) AS orders_count
        FROM customers c
        LEFT JOIN orders o ON c.customer_id = o.customer_id
        WHERE c.region = ?;

        Второй вариант:

        SELECT SUM(t.orders_count) AS orders_count 
        FROM (
          SELECT (
            SELECT COUNT(o.order_id) AS cnt
            FROM orders o
            WHERE o.customer_id = c.customer_id) AS orders_count
          FROM customers c
          WHERE c.region = ?
        ) AS t;


  1. gleb_l
    12.11.2025 21:48

    Это побеждает в основном in vitro, так же, как и exists в предикате. Чуть посложнее основной запрос и/или корррелированный - и нет никакой гарантии, что вся конструкция не свалится в O(N^2).

    Вывод - если вы можете решить задачу без подзапроса/outer apply (что собственно практически одно и то же) - делайте надежно через inner/left join с подзапросом. Если не можете (лень, или селективность предиката основного запроса выше, чем селективность подзапроса) - делайте коррелированным - но тогда уж лучше - outer apply. В нем хоть можно получить несколько полей за один раз, да и протянуть из него значение в основной предикат (последнее нужно юзать с пониманием).


  1. Rend
    12.11.2025 21:48

    Вопросы.

    1. А как поведёт этот запрос не при 25 клиентах и 100 записях, а при 25000 клиентах и 100000 записях? А если ещё устроить фрагментацию таблиц/индексов?

    2. Почему в сравнении нет Microsoft SQL Server?


    1. savostin
      12.11.2025 21:48

      Тоже всегда интересовало почему в синтетических тестах берут ничтожно малое количество записей и сравнивают микросекунды. Точность измерения и затраты на другие действия соизмеримы с основными затратами и не понятно как влияют.


      1. Akina
        12.11.2025 21:48

        Да в общем-то никто не запрещает попробовать то же, но с другим количеством записей. Вот, например, полностью настраиваемый код для MySQL.


    1. rozhnev Автор
      12.11.2025 21:48

      Отличный комментарий. Я буду рад если вы проведете такие тесты и опубликуете результаты


      1. riky
        12.11.2025 21:48

        в общем попробовал на реальной БД mariadb, разница небольшая но есть.
        orders (50k rows / 54cols / 100+mb) + users (8k rows)
        join: 1.7ms
        select/select: 2.6ms


        1. rozhnev Автор
          12.11.2025 21:48

          Что и требовалось доказать. На больших наборах данных JOIN выигрывает, а на малых - подзапрос. Суть статьи в том что нужно выбирать подвод в зависимости от ситуации. А еще возможно переписывать запросы при росте базы данных.


    1. riky
      12.11.2025 21:48

      Вот тоже такие цифры хотел. А так 1000 записей по 16 байт на строку, всего 16кб, несколько страниц памяти всего. Помимо количества строк бы ещё количество полей больше и строки разной длины...


  1. pg_expecto
    12.11.2025 21:48

    Вопросы и ремарки к "3. PostgreSQL 16"

    "В этом тесте PostgreSQL 16 быстрее выполнил вариант с JOIN + GROUP BY: ~0.415 ms против ~0.803 ms для коррелированного подзапроса."
    Вопросы :

    1. Какой размер тестовой выборки? Сколько итераций было выполнено ?

    2. Как менялась нагрузка ?

    "Не гадайте. Тестируйте."
    В самую точку. Осталось уточнить - как тестировать .

    "Ваш лучший друг — это не "золотые правила", а команда EXPLAIN. "
    Очень важный вопрос - вы точно уверены , и почему, в том, что стоимость запроса определяет производительность ?

    Проще говоря - никогда не сталкивались с ситуацией - стоимость запроса стала меньше , а производительность в ходе нагрузочного тестирования уменьшилась.
    Если теоретически - является ли снижение стоимости запроса необходимым и достаточным условием роста производительности СУБД ?

    В любом случае , спасибо за статью и тему ближайшего эксперимента. Поставил в план работ. По итогам - будет статья и анализ производительности в ходе нагрузочного тестирования .


    1. shurutov
      12.11.2025 21:48

      Какой размер тестовой выборки? Сколько итераций было выполнено ?

      Да уж... Начало статьи:

      customers: Маленькая таблица клиентов. (25 записей)
      orders: Таблица побольше с заказами, связанная с клиентами. (1000 записей)


  1. unkas42
    12.11.2025 21:48

    Автор, разрешите поинтересоваться в целях повышения образованности, что за шифровки на КДПВ?
    MYSSL, PostsGEQUL, SQUITE.


    1. rozhnev Автор
      12.11.2025 21:48

      это ИИ сгенерировал


    1. savostin
      12.11.2025 21:48

      AI судя по всему


  1. pkokoshnikov
    12.11.2025 21:48

    N+1 это другая проблема. Это когда клиент сначала получает данные по всем клиентам, а потом по каждому клиенту делает запросы заказов и считает их. Эта проблема часто в ORM встречается при кривом использовании. В этом случае выполняется 1 + N запрос к БД

    В вашем кейсе всё зависит от планировщика. Причём в комментариях правильно заметили, что результат сильно будет зависеть от кол-ва данных. Планировщик БД может просто не использовать индекс например если данных мало, дешевле например fullscan будет сделать. Поэтому план будет отличаться в зависимости от объёма данных.


    1. rozhnev Автор
      12.11.2025 21:48

      Именно это я и хотел показать, что для разных наборов данных эффективность запросов будет разной. Целью статьи было развенчать миф о том что JOIN всегда лучше чем SUBQUERY.


      1. Akina
        12.11.2025 21:48

        Целью статьи было развенчать миф о том что JOIN всегда лучше чем SUBQUERY.

        Ну вообще вопрос сродни тому, что выгоднее - использовать индекс или сканировать таблицу,- в зависимости от статистики данных. И суть, и подход, и даже в каком-то смысле результат как-то схожи.


  1. nApTu3aH_nn
    12.11.2025 21:48

    Скажу за Оракл (для других СУБД, думаю, тоже корректно).

    Во-первых, запрос с джойном может быть написан по-другому, чтобы группировка делалась раньше, до объединения:

    SELECT
    customers.customer_id,
    orders_count
    FROM customers
    LEFT JOIN (select customer_id, COUNT(order_id) AS orders_count from orders GROUP BY customer_id) orders
    ON customers.customer_id = orders.customer_id
    ;

    и в этом случае время выполнения такое же, как в варианте с коррелированным подзапросом.

    Во-вторых, в выводах по Ораклу Вы пишете про Nested Loop, но в рассматриваемом примере его нет (естественно, ведь нет и индекса по orders(customer_id), при создании foreign key в Оракле индекс не создается автоматически), а есть тот факт, что умный Оракл преобразует Ваш коррелированный подзапрос примерно к тому варианту запроса с джойном, который я написал в пункте выше.


    1. nApTu3aH_nn
      12.11.2025 21:48

      Почитал еще...

      PostgreSQL: Оптимизатор PostgreSQL, вероятно, самый умный из всех. Он посмотрел на оба запроса и понял, что для такой маленькой таблицы customers план Nested Loop будет эффективным в обоих случаях. EXPLAIN ANALYZE показывает, что он выбрал план Nested Loop для обоих запросов.

      ГДЕ?? Для варианта с джойном Вы так же, как и с подзапросом в Оракле, увидели несуществующий Nested Loop.

      Главный вывод из статьи: автор не умеет читать планы и притягивает за уши выводы. Эпик фейл.


  1. Tzimie
    12.11.2025 21:48

    Вообще то "теоретически" SQL декларативный язык, он говорит ЧТО делать но не говорит КАК. Наличие subquery не говорит о том, что надо делать под запросы на каждую запись.


    1. Akina
      12.11.2025 21:48

      он говорит ЧТО делать но не говорит КАК

      Однако по-разному формулируя на языке SQL "ЧТО" (при сохранении логики задания, конечно), можно получить разные "КАК".


      1. Tzimie
        12.11.2025 21:48

        Но чем лучше оптимизатор, тем дальше execution plan может быть от его синтаксической формулировки.


  1. pg_expecto
    12.11.2025 21:48

    Главный урок: Не гадайте. Тестируйте.

    Ваш лучший друг — это не "золотые правила", а команда EXPLAIN. Всегда проверяйте, какой план выполнения строит ваша СУБД для ваших конкретных данных.

    Дополню - "Всегда проверяйте нагрузочным тестированием производительность СУБД после оптимизации отдельного запроса. Результат может быть неожиданным"

    Ваш лучший друг - инструмент статистического анализа результатов нагрузочного тестирования.

    https://dzen.ru/a/aRXfgpKD9TZMqRAY

    Операционная скорость в ходе нагрузочного тестирования для сценария-1(join) и сценария-2(subquery)
    Операционная скорость в ходе нагрузочного тестирования для сценария-1(join) и сценария-2(subquery)