Привет, 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. Всегда проверяйте, какой план выполнения строит ваша СУБД для ваших конкретных данных.


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

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


  1. tbl
    12.11.2025 21:48

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

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


  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?