Память решает: как ROW_NUMBER обогнал Deferred Join
Память решает: как ROW_NUMBER обогнал Deferred Join

Предисловие

В мире высоконагруженных баз данных выбор метода пагинации может стать решающим фактором для производительности системы. Эксперимент, проведённый с двумя подходами — классическим ROW_NUMBER и отложенным соединением (Deferred Join) — показал, что даже архитектурно более совершенный метод не гарантирует победы без тонкой настройки СУБД. Исследование раскрывает, как правильная конфигурация памяти PostgreSQL перевесила преимущества Deferred Join и позволила ROW_NUMBER добиться превосходства на параллельной нагрузке .


Начало экспериментов с методом пагинации ROW_NUMBER

Пагинация в PostgreSQL: ROW_NUMBER убивает производительность / Хабр

Вопрос нейросети

tables.txt
     relname     | n_live_tup
-----------------+------------
 segments        |   20598447
 tickets         |   15575748
(12 rows)
indexes.sql
-- Создаем покрывающий индекс для запроса ROW_NUMBER()
CREATE INDEX idx_tickets_covering ON bookings.tickets (ticket_no) INCLUDE (book_ref, passenger_name);

-- Для сравнения, создаем индекс для deferred join (если еще нет)
CREATE INDEX idx_tickets_ticket_no ON bookings.tickets (ticket_no); --УЖЕ БЫЛ 

demo=# \d bookings.tickets
               Unlogged table "bookings.tickets"
     Column     |     Type     | Collation | Nullable | Default
----------------+--------------+-----------+----------+---------
 ticket_no      | text         |           | not null |
 book_ref       | character(6) |           | not null |
 passenger_id   | text         |           | not null |
 passenger_name | text         |           | not null |
 outbound       | boolean      |           | not null |
Indexes:
    "tickets_pkey" PRIMARY KEY, btree (ticket_no)
    "idx_tickets_book_ref" btree (book_ref)
    "idx_tickets_covering" btree (ticket_no) INCLUDE (book_ref, passenger_name)
    "tickets_book_ref_passenger_id_outbound_key" UNIQUE CONSTRAINT, btree (book_ref, passenger_id, outbound)
Foreign-key constraints:
    "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
Referenced by:
    TABLE "segments" CONSTRAINT "segments_ticket_no_fkey" FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no)


-- Индекс для ускорения фильтрации
CREATE INDEX idx_segments_ticket_fare ON bookings.segments (ticket_no, fare_conditions);
demo=# \d bookings.segments
                Unlogged table "bookings.segments"
     Column      |     Type      | Collation | Nullable | Default
-----------------+---------------+-----------+----------+---------
 ticket_no       | text          |           | not null |
 flight_id       | integer       |           | not null |
 fare_conditions | text          |           | not null |
 price           | numeric(10,2) |           | not null |
Indexes:
    "segments_pkey" PRIMARY KEY, btree (ticket_no, flight_id)
    "idx_segments_flight_id" btree (flight_id)
    "idx_segments_flight_id_fare_conditions" btree (flight_id, fare_conditions)
    "idx_segments_ticket_fare" btree (ticket_no, fare_conditions)
    "idx_segments_ticket_no" btree (ticket_no)
    "segments_flight_id_idx" btree (flight_id)
Check constraints:
    "segments_fare_conditions_check" CHECK (fare_conditions = ANY (ARRAY['Economy'::text, 'Comfort'::text, 'Business'::text]))
    "segments_price_check" CHECK (price >= 0::numeric)
Foreign-key constraints:
    "segments_flight_id_fkey" FOREIGN KEY (flight_id) REFERENCES flights(flight_id)
    "segments_ticket_no_fkey" FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no)
Referenced by:
    TABLE "boarding_passes" CONSTRAINT "boarding_passes_ticket_no_flight_id_fkey" FOREIGN KEY (ticket_no, flight_id) REFERENCES segments(ticket_no, flight_id)
plan-1.sql
--ROW_NUMBER()
EXPLAIN (ANALYZE, COSTS , SUMMARY )
WITH filtered_tickets AS (
    SELECT ticket_no, book_ref, passenger_name
    FROM bookings.tickets t
    WHERE EXISTS (
        SELECT 1 FROM bookings.segments s 
        WHERE s.ticket_no = t.ticket_no 
        AND s.fare_conditions = 'Business'
    )
),
page_info AS (
    SELECT floor(random() * ((SELECT COUNT(*) FROM filtered_tickets) / 100)) as random_page
),
numbered_rows AS (
    SELECT ticket_no, book_ref, passenger_name,
           ROW_NUMBER() OVER (ORDER BY ticket_no) AS rn
    FROM filtered_tickets
)
SELECT ticket_no, book_ref, passenger_name
FROM numbered_rows
CROSS JOIN page_info
WHERE rn > (random_page * 100) AND rn <= (random_page * 100 + 100)
ORDER BY rn;

 Sort  (cost=1228291.85..1228923.73 rows=252751 width=100) (actual time=12830.169..12858.743 rows=100 loops=1)
   Sort Key: (row_number() OVER (?))
   Sort Method: quicksort  Memory: 31kB
   CTE filtered_tickets
     ->  Gather  (cost=245668.76..689978.88 rows=2274755 width=34) (actual time=2330.866..3900.461 rows=2158585 loops=1)
           Workers Planned: 4
           Workers Launched: 4
           ->  Parallel Hash Semi Join  (cost=244668.76..461503.38 rows=568689 width=34) (actual time=2315.696..4300.950 rows=431717 loops=5)
                 Hash Cond: (t.ticket_no = s.ticket_no)
                 ->  Parallel Seq Scan on tickets t  (cost=0.00..200286.37 rows=3893937 width=34) (actual time=0.033..542.310 rows=3115150 loops=5)
                 ->  Parallel Hash  (cost=237560.15..237560.15 rows=568689 width=14) (actual time=2306.370..2306.372 rows=450149 loops=5)
                       Buckets: 4194304  Batches: 1  Memory Usage: 138464kB
                       ->  Parallel Seq Scan on segments s  (cost=0.00..237560.15 rows=568689 width=14) (actual time=0.892..2010.351 rows=450149 loops=5)
                             Filter: (fare_conditions = 'Business'::text)
                             Rows Removed by Filter: 3669541
   CTE page_info
     ->  Result  (cost=51182.00..51182.02 rows=1 width=8) (actual time=4722.841..4722.844 rows=1 loops=1)
           InitPlan 2
             ->  Aggregate  (cost=51181.99..51182.00 rows=1 width=8) (actual time=4722.777..4722.778 rows=1 loops=1)
                   ->  CTE Scan on filtered_tickets filtered_tickets_1  (cost=0.00..45495.10 rows=2274755 width=0) (actual time=2330.871..4569.074 rows=2158585 loops=1)
   ->  Nested Loop  (cost=353988.41..456352.38 rows=252751 width=100) (actual time=11697.811..12830.111 rows=100 loops=1)
         Join Filter: ((((row_number() OVER (?)))::double precision > (page_info.random_page * '100'::double precision)) AND (((row_number() OVER (?)))::double precision <= ((page_info.random_page * '100'::double precision) + '100'::doub
le precision)))
         Rows Removed by Join Filter: 2158485
         ->  CTE Scan on page_info  (cost=0.00..0.02 rows=1 width=8) (actual time=4722.844..4722.848 rows=1 loops=1)
         ->  WindowAgg  (cost=353988.41..393796.60 rows=2274755 width=100) (actual time=6805.951..7852.082 rows=2158585 loops=1)
               ->  Sort  (cost=353988.39..359675.28 rows=2274755 width=92) (actual time=6805.933..7109.981 rows=2158585 loops=1)
                     Sort Key: filtered_tickets.ticket_no
                     Sort Method: external sort  Disk: 102128kB
                     ->  CTE Scan on filtered_tickets  (cost=0.00..45495.10 rows=2274755 width=92) (actual time=0.069..340.965 rows=2158585 loops=1)
 Planning Time: 15.340 ms
 Execution Time: 12899.583 ms
(31 rows)
plan-2.sql
--Deffered Join
EXPLAIN (ANALYZE, COSTS , SUMMARY )
WITH filtered_tickets AS (
    SELECT ticket_no, book_ref, passenger_name
    FROM bookings.tickets t
    WHERE EXISTS (
        SELECT 1 FROM bookings.segments s 
        WHERE s.ticket_no = t.ticket_no 
        AND s.fare_conditions = 'Business'
    )
),
page_info AS (
    SELECT floor(random() * ((SELECT COUNT(*) FROM filtered_tickets) / 100)) * 100 as random_offset
)
SELECT t.ticket_no, t.book_ref, t.passenger_name
FROM filtered_tickets t
CROSS JOIN page_info
INNER JOIN (
    SELECT ticket_no
    FROM filtered_tickets
    ORDER BY ticket_no
    OFFSET (SELECT random_offset FROM page_info) LIMIT 100
) AS sub ON t.ticket_no = sub.ticket_no
ORDER BY t.ticket_no;

 Sort  (cost=1113319.73..1113319.98 rows=100 width=92) (actual time=12122.320..12152.383 rows=100 loops=1)
   Sort Key: t.ticket_no
   Sort Method: quicksort  Memory: 30kB
   CTE filtered_tickets
     ->  Gather  (cost=245668.76..689978.88 rows=2274755 width=34) (actual time=1930.285..3476.659 rows=2158585 loops=1)
           Workers Planned: 4
           Workers Launched: 4
           ->  Parallel Hash Semi Join  (cost=244668.76..461503.38 rows=568689 width=34) (actual time=1916.383..3873.233 rows=431717 loops=5)
                 Hash Cond: (t_1.ticket_no = s.ticket_no)
                 ->  Parallel Seq Scan on tickets t_1  (cost=0.00..200286.37 rows=3893937 width=34) (actual time=0.025..479.551 rows=3115150 loops=5)
                 ->  Parallel Hash  (cost=237560.15..237560.15 rows=568689 width=14) (actual time=1907.371..1907.372 rows=450149 loops=5)
                       Buckets: 4194304  Batches: 1  Memory Usage: 138432kB
                       ->  Parallel Seq Scan on segments s  (cost=0.00..237560.15 rows=568689 width=14) (actual time=0.777..1594.418 rows=450149 loops=5)
                             Filter: (fare_conditions = 'Business'::text)
                             Rows Removed by Filter: 3669541
   CTE page_info
     ->  Result  (cost=51182.00..51182.02 rows=1 width=8) (actual time=4298.997..4299.000 rows=1 loops=1)
           InitPlan 2
             ->  Aggregate  (cost=51181.99..51182.00 rows=1 width=8) (actual time=4298.985..4298.987 rows=1 loops=1)
                   ->  CTE Scan on filtered_tickets filtered_tickets_1  (cost=0.00..45495.10 rows=2274755 width=0) (actual time=1930.288..4144.594 rows=2158585 loops=1)
   ->  Nested Loop  (cost=318128.05..372155.50 rows=100 width=92) (actual time=12073.605..12122.078 rows=100 loops=1)
         ->  CTE Scan on page_info  (cost=0.00..0.02 rows=1 width=0) (actual time=4299.001..4299.003 rows=1 loops=1)
         ->  Hash Join  (cost=318128.05..372154.48 rows=100 width=92) (actual time=7774.597..7823.051 rows=100 loops=1)
               Hash Cond: (t.ticket_no = filtered_tickets.ticket_no)
               ->  CTE Scan on filtered_tickets t  (cost=0.00..45495.10 rows=2274755 width=92) (actual time=0.083..326.449 rows=2158585 loops=1)
               ->  Hash  (cost=318126.80..318126.80 rows=100 width=32) (actual time=7185.320..7185.324 rows=100 loops=1)
                     Buckets: 1024  Batches: 1  Memory Usage: 13kB
                     ->  Limit  (cost=318126.55..318126.80 rows=100 width=32) (actual time=7185.247..7185.274 rows=100 loops=1)
                           InitPlan 4
                             ->  CTE Scan on page_info page_info_1  (cost=0.00..0.02 rows=1 width=8) (actual time=0.001..0.003 rows=1 loops=1)
                           ->  Sort  (cost=317557.84..323244.73 rows=2274755 width=32) (actual time=7070.748..7155.317 rows=639900 loops=1)
                                 Sort Key: filtered_tickets.ticket_no
                                 Sort Method: external sort  Disk: 46472kB
                                 ->  CTE Scan on filtered_tickets  (cost=0.00..45495.10 rows=2274755 width=32) (actual time=0.002..401.503 rows=2158585 loops=1)
 Planning Time: 0.961 ms
 Execution Time: 12189.914 ms
(36 rows)

Используя информацию о размерах таблиц, схеме баз данных и тестовых запросах, дай прогноз и обоснование - какой тестовый запрос покажет большую производительность для нагрузочного тестирования с параллельной нагрузкой от 5 до 22 сессий для СУБД PostgreSQL 17 c ресурсами CPU=8 RAM=8GB

Прогноз:
Наибольшую производительность при параллельной нагрузке (5–22 сессии) покажет запрос plan-1 (ROW_NUMBER()) после оптимизации индексами.

Прогноз нейросети
Прогноз нейросети

DeepSeek : прогноз ROW_NUMBER vs Deffered Join при оптимальном сценарии для ROW_NUMBER | Postgres DBA | Дзен

Экспериментальная проверка

Прогноз нейросети — Когда теория проигрывает практике или почему ROW_NUMBER() не стал королём пагинации PostgreSQL / Хабр

Анализ ошибочного прогноза нейросети

Вопрос нейросети

Почему не оправдался прогноз нейросети о большей эффективности метода ROW_NUMBER для заданных условий ?

Ответ нейросети

5. Условия, при которых ROW_NUMBER() действительно был бы эффективнее:

Сценарий 2: Адекватный размер work_mem

Если бы work_mem был ≥ 256MB для сортировки в памяти

Когда теория проигрывает практике: почему ROW_NUMBER() не стал королём пагинации PostgreSQL | Postgres DBA | Дзен

Вопрос нейросети

какие значение work_mem необходимо установить для получения преимущества метода row_number при максимальной нагрузке 22 параллельные сессии при общем размере RAM=8GB ?

Ответ нейросети

-- Для получения преимущества ROW_NUMBER():SET work_mem = '180MB';SET max_parallel_workers_per_gather = 1;SET max_parallel_workers = 16;

-- В postgresql.conf:
work_mem = 180MB
max_parallel_workers_per_gather = 1
max_parallel_workers = 16
max_worker_processes = 16
shared_buffers = 2GB
effective_cache_size = 6GB

Когда теория проигрывает практике: почему ROW_NUMBER() не стал королём пагинации PostgreSQL | Postgres DBA | Дзен

Задача

Экспериментальная проверка рекомендации нейросети по оптимизации метода пагинации ROW_NUMBER

Методология исследования

Тестовая среда и инструменты:

Нагрузка на СУБД

Изменение конфигурационных параметров СУБД

ALTER SYSTEM SET work_mem = '180MB';
ALTER SYSTEM SET max_parallel_workers_per_gather = 1;
ALTER SYSTEM SET max_parallel_workers = 16;
ALTER SYSTEM SET max_worker_processes = 16 ;
ALTER SYSTEM SET shared_buffers = '2GB';
ALTER SYSTEM effective_cache_size = '6GB';

Производительность и ожидания СУБД в ходе нагрузочного тестирования

Операционная скорость

График изменения операционной скорости в ходе нагрузочного тестирования
График изменения операционной скорости в ходе нагрузочного тестирования
График изменения относительной разницы операционной скорости при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования
График изменения относительной разницы операционной скорости при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования

Результат

Среднее превышение операционной скорости , при использовании метода ROW_NUMBER составило 12.59%

Ожидания СУБД

График изменения ожиданий СУБД в ходе нагрузочного тестирования
График изменения ожиданий СУБД в ходе нагрузочного тестирования
График изменения относительной разницы ожиданий СУБД при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования
График изменения относительной разницы ожиданий СУБД при использовании метода DIFFERED JOIN по сравнению с методом ROW_NUMBER в ходе нагрузочного тестирования

Результат

Среднее снижение ожиданий СУБД, при использовании метода ROW_NUMBER составило 18.06%

Тестовый запрос и план выполнения с использованием ROW_NUMBER
--ROW_NUMBER()
EXPLAIN (ANALYZE, COSTS , SUMMARY )
WITH filtered_tickets AS (
    SELECT ticket_no, book_ref, passenger_name
    FROM bookings.tickets t
    WHERE EXISTS (
        SELECT 1 FROM bookings.segments s 
        WHERE s.ticket_no = t.ticket_no 
        AND s.fare_conditions = 'Business'
    )
),
page_info AS (
    SELECT floor(random() * ((SELECT COUNT(*) FROM filtered_tickets) / 100)) as random_page
),
numbered_rows AS (
    SELECT ticket_no, book_ref, passenger_name,
           ROW_NUMBER() OVER (ORDER BY ticket_no) AS rn
    FROM filtered_tickets
)
SELECT ticket_no, book_ref, passenger_name
FROM numbered_rows
CROSS JOIN page_info
WHERE rn > (random_page * 100) AND rn <= (random_page * 100 + 100)
ORDER BY rn;

 Sort  (cost=1223612.75..1224238.52 rows=250309 width=100) (actual time=12525.009..12525.024 rows=100 loops=1)
   Sort Key: (row_number() OVER (?))
   Sort Method: quicksort  Memory: 31kB
   CTE filtered_tickets
     ->  Merge Semi Join  (cost=1.12..698694.07 rows=2252784 width=34) (actual time=0.067..9304.741 rows=2158585 loops=1)
           Merge Cond: (t.ticket_no = s.ticket_no)
           ->  Index Only Scan using idx_tickets_covering on tickets t  (cost=0.56..353511.60 rows=15575748 width=34) (actual time=0.024..3368.547 rows=15575748 loops=1)
                 Heap Fetches: 120058
           ->  Index Only Scan using idx_segments_ticket_fare on segments s  (cost=0.56..278084.36 rows=2252784 width=14) (actual time=0.036..1903.512 rows=2250743 loops=1)
                 Index Cond: (fare_conditions = 'Business'::text)
                 Heap Fetches: 35563
   CTE page_info
     ->  Result  (cost=50687.65..50687.67 rows=1 width=8) (actual time=10552.416..10552.418 rows=1 loops=1)
           InitPlan 2
             ->  Aggregate  (cost=50687.64..50687.65 rows=1 width=8) (actual time=10552.361..10552.362 rows=1 loops=1)
                   ->  CTE Scan on filtered_tickets filtered_tickets_1  (cost=0.00..45055.68 rows=2252784 width=0) (actual time=0.069..10364.987 rows=2158585 loops=1)
   ->  Nested Loop  (cost=350411.33..451786.61 rows=250309 width=100) (actual time=11599.226..12524.909 rows=100 loops=1)
         Join Filter: ((((row_number() OVER (?)))::double precision > (page_info.random_page * '100'::double precision)) AND (((row_number() OVER (?)))::double precision <= ((page_info.random_page * '100'::double precision) + '100'::doub
le precision)))
         Rows Removed by Join Filter: 2158485
         ->  CTE Scan on page_info  (cost=0.00..0.02 rows=1 width=8) (actual time=10552.418..10552.420 rows=1 loops=1)
         ->  WindowAgg  (cost=350411.33..389835.03 rows=2252784 width=100) (actual time=850.669..1723.660 rows=2158585 loops=1)
               ->  Sort  (cost=350411.31..356043.27 rows=2252784 width=92) (actual time=850.647..1001.073 rows=2158585 loops=1)
                     Sort Key: filtered_tickets.ticket_no
                     Sort Method: quicksort  Memory: 170828kB
                     ->  CTE Scan on filtered_tickets  (cost=0.00..45055.68 rows=2252784 width=92) (actual time=0.001..242.030 rows=2158585 loops=1)
 Planning Time: 3.135 ms
 Execution Time: 12650.181 ms
(27 rows)

Тестовый запрос и план выполнения с использованием DIFFERED JOIN
--Deffered Join
EXPLAIN (ANALYZE, COSTS , SUMMARY )
WITH filtered_tickets AS (
    SELECT ticket_no, book_ref, passenger_name
    FROM bookings.tickets t
    WHERE EXISTS (
        SELECT 1 FROM bookings.segments s 
        WHERE s.ticket_no = t.ticket_no 
        AND s.fare_conditions = 'Business'
    )
),
page_info AS (
    SELECT floor(random() * ((SELECT COUNT(*) FROM filtered_tickets) / 100)) * 100 as random_offset
)
SELECT t.ticket_no, t.book_ref, t.passenger_name
FROM filtered_tickets t
CROSS JOIN page_info
INNER JOIN (
    SELECT ticket_no
    FROM filtered_tickets
    ORDER BY ticket_no
    OFFSET (SELECT random_offset FROM page_info) LIMIT 100
) AS sub ON t.ticket_no = sub.ticket_no
ORDER BY t.ticket_no;

 Sort  (cost=1086216.73..1086216.98 rows=100 width=92) (actual time=16100.696..16100.714 rows=100 loops=1)
   Sort Key: t.ticket_no
   Sort Method: quicksort  Memory: 30kB
   CTE filtered_tickets
     ->  Merge Semi Join  (cost=1.12..698694.07 rows=2252784 width=34) (actual time=0.059..8756.097 rows=2158585 loops=1)
           Merge Cond: (t_1.ticket_no = s.ticket_no)
           ->  Index Only Scan using idx_tickets_covering on tickets t_1  (cost=0.56..353511.60 rows=15575748 width=34) (actual time=0.019..3135.857 rows=15575748 loops=1)
                 Heap Fetches: 120058
           ->  Index Only Scan using idx_segments_ticket_fare on segments s  (cost=0.56..278084.36 rows=2252784 width=14) (actual time=0.034..1746.359 rows=2250743 loops=1)
                 Index Cond: (fare_conditions = 'Business'::text)
                 Heap Fetches: 35563
   CTE page_info
     ->  Result  (cost=50687.65..50687.68 rows=1 width=8) (actual time=9966.901..9966.903 rows=1 loops=1)
           InitPlan 2
             ->  Aggregate  (cost=50687.64..50687.65 rows=1 width=8) (actual time=9966.889..9966.890 rows=1 loops=1)
                   ->  CTE Scan on filtered_tickets filtered_tickets_1  (cost=0.00..45055.68 rows=2252784 width=0) (actual time=0.061..9801.079 rows=2158585 loops=1)
   ->  Nested Loop  (cost=283326.03..336831.67 rows=100 width=92) (actual time=15724.580..16100.565 rows=100 loops=1)
         ->  CTE Scan on page_info  (cost=0.00..0.02 rows=1 width=0) (actual time=9966.903..9966.904 rows=1 loops=1)
         ->  Hash Join  (cost=283326.03..336830.65 rows=100 width=92) (actual time=5757.670..6133.639 rows=100 loops=1)
               Hash Cond: (t.ticket_no = filtered_tickets.ticket_no)
               ->  CTE Scan on filtered_tickets t  (cost=0.00..45055.68 rows=2252784 width=92) (actual time=0.001..241.766 rows=2158585 loops=1)
               ->  Hash  (cost=283324.78..283324.78 rows=100 width=32) (actual time=5575.336..5575.340 rows=100 loops=1)
                     Buckets: 1024  Batches: 1  Memory Usage: 13kB
                     ->  Limit  (cost=283324.53..283324.78 rows=100 width=32) (actual time=5575.273..5575.298 rows=100 loops=1)
                           InitPlan 4
                             ->  CTE Scan on page_info page_info_1  (cost=0.00..0.02 rows=1 width=8) (actual time=0.001..0.003 rows=1 loops=1)
                           ->  Sort  (cost=282761.31..288393.27 rows=2252784 width=32) (actual time=5494.743..5543.951 rows=704900 loops=1)
                                 Sort Key: filtered_tickets.ticket_no
                                 Sort Method: top-N heapsort  Memory: 15082kB
                                 ->  CTE Scan on filtered_tickets  (cost=0.00..45055.68 rows=2252784 width=32) (actual time=0.001..312.698 rows=2158585 loops=1)
 Planning Time: 0.981 ms
 Execution Tim

Характерные особенности тестовых запросов и планов выполнения

Как и в предыдущих статьях, для анализа результатов pg_expecto - используется нейросеть.

1. Общая цель запросов

Оба запроса решают задачу случайной пагинации (выбор случайной "страницы" из 100 строк) для отфильтрованных данных (билеты с бизнес-классом).

2. Основные различия в подходах

Первый запрос (ROW_NUMBER()):

  • Использует оконную функцию ROW_NUMBER() для нумерации всех строк

  • Фильтрует по диапазону номеров строк после нумерации

  • Ключевая проблема: Выполняет полную сортировку и нумерацию всех 2.15 млн строк

Второй запрос (Deferred Join):

  • Использует отложенное соединение (deferred join)

  • Сначала выбирает только ticket_no с помощью OFFSET/LIMIT

  • Затем соединяет остальные данные по отобранным ключам

  • Преимущество: Сортирует только ключи, а не все данные

3. Производительность

Первый запрос:

  • Время выполнения: 12.65 секунд

  • Основные затраты:
    Merge Semi Join: 9.3 секунды
    Сортировка 2.15 млн строк: 850-1000 мс (quicksort, 170 МБ памяти)
    WindowAgg (оконная функция): 1723 мс

Второй запрос:

  • Время выполнения: ~16.1 секунды (неполные данные в выводе)

  • Основные затраты:
    Merge Semi Join: 8.75 секунды
    Сортировка для OFFSET: 5.5 секунды (top-N heapsort, 15 МБ памяти)
    Hash Join: 5575 мс

4. Критические различия в планах

Сортировка:

  1. Первый запрос: Сортирует все столбцы (ticket_no, book_ref, passenger_name) - 92 байта на строку

  2. Второй запрос: Сортирует только ticket_no - 32 байта на строку

Использование памяти:

  1. Первый: 170 МБ для сортировки всех данных

  2. Второй: 15 МБ для сортировки только ключей

Фильтрация:

  1. Первый: Сначала нумерует все строки, затем фильтрует 100

  2. Второй: Сначала выбирает 100 ключей, затем соединяет данные

5. Проблемы обоих подходов

  1. Общая проблема: floor(random() COUNT()/100) требует полного сканирования для подсчета строк

  2. Производительность OFFSET: Во втором запросе OFFSET должен "пропустить" много строк (случайная страница * 100)

6. Вывод

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

Характерные особенности производительности СУБД

1. Производительность

ROW_NUMBER подход:

  • Начальная производительность: 60

  • Стабильная после 45 итерации: 96

  • Максимум: 96 (после адаптации)

  • Прирост: 60 → 96 (+60%)

Deferred Join подход:

  • Начальная производительность: 52

  • Стабильная после 55 итерации: 84

  • Максимум: 84

  • Прирост: 52 → 84 (+61.5%)

Вывод: ROW_NUMBER показывает на 14.3% выше абсолютную производительность (96 vs 84).

2. Динамика адаптации

ROW_NUMBER:

  • Быстрый рост до 96 за 55 итераций

  • Более резкие скачки производительности

  • Ранняя стабилизация (с 55 итерации)

Deferred Join:

  • Более плавный рост до 84

  • Дольше адаптируется (до 55 итерации)

  • Стабильнее на низких нагрузках

3. Ожидания и блокировки

ROW_NUMBER:

  • TIMEOUT появляются с итерации 46 (при нагрузке 10 соединений)

  • LWLOCK стабилизируются на уровне 3-6

  • WAITINGS: 4-7

Deferred Join:

  • IO ожидания появляются с итерации 73 (при нагрузке 15+ соединений)

  • LWLOCK: 4-8 (выше, чем у ROW_NUMBER)

  • WAITINGS: до 21 пикового значения

  • Более выраженные скачки в ожиданиях (74 итерация: 21 ожидание)

4. Корреляция с нагрузкой

  • Нагрузка растет от 5 до 22 соединений

  • Критические точки:
    10 соединений (итерация 46): ROW_NUMBER начинает показывать TIMEOUT
    15 соединений (итерация 73): Deferred Join показывает IO ожидания
    18+ соединений: оба подхода стабилизируются на максимальных значениях ожиданий

5. Ключевые различия в поведении

ROW_NUMBER:

  1. Выше пиковая производительность (96 vs 84)

  2. Раннее появление TIMEOUT (с 10 соединений)

  3. Меньше LWLOCK в среднем (3-6 vs 4-8)

  4. Более предсказуемые паттерны ожиданий

Deferred Join:

  1. Лучше масштабируется при низких нагрузках

  2. Более стабильные WAITINGS (кроме пиков)

  3. Появление IO ожиданий вместо TIMEOUT

  4. Сильнее страдает от LWLOCK

6. Анализ проблемных точек

ROW_NUMBER:

  • Проблема: TIMEOUT при 10+ соединениях

  • Причина: WindowAgg + сортировка больших данных конкурируют за ресурсы

  • Симптом: Конкуренция за CPU/память

Deferred Join:

  • Проблема: IO ожидания при 15+ соединениях

  • Причина: Hash Join + сортировка для OFFSET требуют disk I/O

  • Симптом: Конкуренция за disk I/O и LWLOCK

7. Общий вывод

  1. ROW_NUMBER лучше для:
    Максимальной производительности (14.3% выше)
    Систем с достаточными CPU/памятью
    Сценариев с <10 конкурентных соединений

  2. Deferred Join лучше для:
    Систем с ограничениями по памяти
    Сценариев с предсказуемыми IO паттернами
    Приложений, чувствительных к TIMEOUT

  3. Оба подхода требуют оптимизации при >10 конкурентных соединений

  4. Критическая нагрузка: 10-15 соединений (точка деградации производительности)

Метрики производительности инфраструктуры

1. Использование CPU

ROW_NUMBER:

  • Начало: 61% user, 3% system, 36% idle

  • Критическая точка (итерация 50): резкий переход на 95-96% user, 4% system, 0% idle

  • Стабильная нагрузка: 96% user, 4% system, 0% idle

  • Вывод: Полная загрузка CPU с преобладанием пользовательского времени

Deferred Join:

  • Начало: 61% user, 2% system, 36% idle

  • Критическая точка (итерация 52): переход на 97% user, 3% system, 0% idle

  • Стабильная нагрузка: 97% user, 3% system, 0% idle

  • Вывод: Аналогичная полная загрузка, но меньше системного времени

СравнениеDeferred Join показывает на 1% выше user CPU и на 1% ниже system CPU.

2. Использование памяти

ROW_NUMBER:

  • Свободная память: 1791 → 2753 KB (+53% рост)

  • Буферы: 47 → 6 KB (-87% снижение)

  • Кэш: 4955 → 2751 KB (-44% снижение)

  • Своп: 70 → 67 KB (минимальное изменение)

  • Вывод: Активное использование кэша и буферов

Deferred Join:

  • Свободная память: 4567 → 2207 KB (-51% снижение)

  • Буферы: 18 → 6 KB (-67% снижение)

  • Кэш: 2211 → 2728 KB (+23% рост)

  • Своп: 69 → 73 KB (незначительный рост)

  • Вывод: Увеличение использования кэша, снижение свободной памяти

СравнениеROW_NUMBER активнее использует кэш в начале, Deferred Join наращивает кэш в процессе.

3. Ввод-вывод (IO)

ROW_NUMBER:

  • io_bo: 52 → 58 (+11.5% рост)

  • Пиковые значения: до 59

  • Вывод: Умеренный рост IO при увеличении нагрузки

Deferred Join:

  • io_bo: 59 → 65 (+10% рост)

  • Пиковые значения: до 68

  • Вывод: Более высокий базовый уровень IO

Сравнение: Deferred Join имеет на 10-15% выше активность IO, что соответствует ожиданиям из предыдущего анализа (IO ожидания).

4. Системные метрики

ROW_NUMBER:

  • Прерывания (system_in): 5687 → 8279 (+45% рост)

  • Переключения контекста (system_cs): 657 → 1053 (+60% рост)

  • Процессы в run queue (procs_r): 6 → 15 (+150% рост)

Deferred Join:

  • Прерывания (system_in): 5652 → 8274 (+46% рост)

  • Переключения контекста (system_cs): 652 → 1063 (+63% рост)

  • Процессы в run queue (procs_r): 5 → 15 (+200% рост)

Сравнение: Оба подхода показывают схожий рост системных метрик, Deferred Join имеет немного больше переключений контекста.

5. Критические точки перехода

ROW_NUMBER:

  • Итерация 50 (нагрузка 10): переход на 0% idle CPU

  • Сопровождается: Ростом procs_r до 9

  • Симптом: Резкое изменение паттерна использования памяти

Deferred Join:

  • Итерация 52 (нагрузка 10): переход на 0% idle CPU

  • Сопровождается: Ростом procs_r до 9

  • Симптом: Снижение свободной памяти до 4000 KB

6. Корреляция с нагрузкой

  • 5-8 соединений: Оба подхода работают с простоями CPU (36% idle)

  • 10 соединений: Критическая точка (0% idle)

  • 12-15 соединений: Стабилизация на максимальной нагрузке

  • 18-22 соединений: Дальнейший рост системных метрик

7. Ключевые различия по метрикам

ROW_NUMBER преимущества:

  1. Меньше IO операций (58 vs 68)

  2. Лучшее использование памяти (растущая свободная память)

  3. Более стабильный кэш (меньше колебаний)

Deferred Join преимущества:

  1. Меньше системного времени CPU (3% vs 4%)

  2. Более предсказуемое использование кэша (постепенный рост)

  3. Меньше начальных буферов (более эффективное использование)

8. Проблемные паттерны

ROW_NUMBER:

  • Проблема: Резкое падение кэша с 4955 до 2751

  • Причина: Оконные функции требуют больших рабочих наборов

  • Риск: Потенциальные page faults при недостатке памяти

Deferred Join:

  • Проблема: Высокий базовый уровень IO (59 vs 52)

  • Причина: Hash Join и сортировка для OFFSET

  • Риск: Disk I/O bottlenecks при высокой нагрузке

9. Общие выводы по метрикам

  1. Оба подхода CPU-bound: 0% idle при нагрузке ≥10 соединений

  2. Deferred Join более IO-intensive: на 10-15% выше IO активность

  3. ROW_NUMBER более memory-intensive: активнее использует кэш и буферы

  4. Критическая нагрузка: 10 соединений для обоих подходов

  5. Масштабирование: Deferred Join лучше сохраняет кэш, но требует больше IO

Общий итог

Выбор между подходами зависит от конфигурации системы -

  • ROW_NUMBER для CPU-обеспеченных архитектур

  • Deferred Join для систем с хорошим IO.

P.S. Классика, в качестве итога цикла экспериментов по пагинации

Паттерн "ROW_NUMBER" смог обойти по производительности паттерн "Differed Join".

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