Мы привыкли, что индексы — это святое. Но под нагрузкой святыни иногда приходится пересматривать
Мы привыкли, что индексы — это святое. Но под нагрузкой святыни иногда приходится пересматривать

В мире СУБД общепринятая догма гласит: «Индексы ускоряют запросы». Но что, если в погоне за производительностью мы создали себе проблему? В этой статье, на практике показан парадоксальный сценарий, при котором удаление первичного ключа у таблицы pgbench_branch и последующее увеличение стоимости запроса привели к впечатляющему росту общей производительности PostgreSQL под нагрузкой. СУБД не так просты, как может показаться.

Цель эксперимента

Используя инструментарий на основе свободного расширения pg_expecto[1], в процессе нагрузочного тестирования[2],  оценить влияние удаление индекса первичного ключа в таблице на производительность СУБД.

Дополнительная информация и контакты по теме:
https://dzen.ru/kznalp
kznalp@yandex.ru

Конфигурация тестовой виртуальной машины

  • CPU: 8 ядер

  • RAM: 8GB

  • ОС: RED OS MUROM (7.3.4)

  • PostgreSQL: Postgres Pro (enterprise certified) 17.5.1 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 11.4.1 20230605 (Red Soft 11.4.0-1), 64-bit

  • Размер тестовой БД: 10GB

Конфигурационные параметры СУБД

shared_buffers = 1919MB
postgresql.auto.conf

track_io_timing = 'on'
listen_addresses = '0.0.0.0'
max_connections = '100'
logging_collector = 'on'
log_directory = '/log/pg_log'
log_destination = 'stderr'
log_rotation_size = '0'
log_rotation_age = '1d'
log_filename = 'postgresql-%u.log'
log_line_prefix = '%m| %d| %a| %u| %h| %p| %e| '
log_truncate_on_rotation = 'on'
log_checkpoints = 'on'
archive_mode = 'on'
archive_command = 'true'
archive_timeout = '30min'
checkpoint_timeout = '15min'
checkpoint_warning = '60'
checkpoint_completion_target = '0.9'
min_wal_size = '2GB'
max_wal_size = '8GB'
synchronous_commit = 'on'
wal_compression = 'on'
random_page_cost = '1.1'
effective_io_concurrency = '300'
wal_sender_timeout = '0'
autovacuum_naptime = '1s'
autovacuum_vacuum_scale_factor = '0.01'
autovacuum_analyze_scale_factor = '0.005'
autovacuum_vacuum_cost_delay = '2ms'
autovacuum_max_workers = '4'
autovacuum_work_mem = '256MB'
vacuum_cost_limit = '4000'
bgwriter_delay = '10ms'
bgwriter_lru_multiplier = '4'
bgwriter_lru_maxpages = '400'
max_locks_per_transaction = '256'
max_pred_locks_per_transaction = '256'
shared_buffers = '1919MB'
effective_cache_size = '5757MB'
work_mem = '14MB'
temp_buffers = '14MB'
maintenance_work_mem = '479MB'
max_worker_processes = '8'
max_parallel_workers = '8'
max_parallel_workers_per_gather = '4'
idle_in_transaction_session_timeout = '1h'
statement_timeout = '8h'
pg_stat_statements.track_utility = 'off'
max_parallel_maintenance_workers = '4'
hash_mem_multiplier = '2'
autovacuum_vacuum_insert_scale_factor = '0.01'
shared_preload_libraries = 'pg_stat_statements , pg_wait_sampling'
commit_delay = '1000'
log_autovacuum_min_duration = '0'
wipe_file_on_delete = 'on'
wipe_heaptuple_on_delete = 'on'
wipe_mem_on_free = 'on'
wipe_memctx_on_free = 'on'
wipe_xlog_on_free = 'on'
log_connections = 'on'
log_disconnections = 'on'

Используемые термины и определения

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

Сумма завершенных SQL операций и числа строк полученных или затронутых оператором за промежуток времени[7]

Методика расчета метрик оценки производительности и ожиданий СУБД

Корреляционный анализ ожиданий СУБД PostgreSQL[6]

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

Изменение нагрузки в ходе нагрузочного тестирования
Изменение нагрузки в ходе нагрузочного тестирования

План нагрузочного тестирования

Для проведения нагрузочного тестирования используются три сценария с разным распределением нагрузки:

· Select only (чтение данных): вес 0.5 (50%)
· Select + Update (чтение и обновление): вес 0.35 (35%)
· Insert only (добавление записей): вес 0.15 (15%)

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

PG_EXPECTO : Нагрузочное тестирование СУБД PostgreSQL[2]

Тестовая таблица pgbench_branches

Table "public.pgbench_branches"
  Column  |     Type      | Collation | Nullable | Default
----------+---------------+-----------+----------+---------
 bid      | integer       |           | not null |
 bbalance | integer       |           |          |
 filler   | character(88) |           |          |
Indexes:
    "pgbench_branches_pkey" PRIMARY KEY, btree (bid)
Referenced by:
    TABLE "pgbench_accounts" CONSTRAINT "pgbench_accounts_bid_fkey" FOREIGN KEY (bid) REFERENCES pgbench_branches(bid)
    TABLE "pgbench_history" CONSTRAINT "pgbench_history_bid_fkey" FOREIGN KEY (bid) REFERENCES pgbench_branches(bid)
    TABLE "pgbench_tellers" CONSTRAINT "pgbench_tellers_bid_fkey" FOREIGN KEY (bid) REFERENCES pgbench_branches(bid)

Планы выполнения тестовых запросов , в который участвует таблица pgbench_branches

Сценарий-1 "Select only" (для формирования плана, задан фактический параметр запроса)

Nested Loop (cost=0.84..5.28 rows=1 width=4)
-> Index Scan using pgbench_accounts_pkey on pgbench_accounts acc (cost=0.57..2.79 rows=1 width=4)
Index Cond: (aid = 1000)
-> Index Scan using pgbench_branches_pkey on pgbench_branches br (cost=0.28..2.49 rows=1 width=8)
Index Cond: (bid = acc.bid)

Сценарий-2 "Select + Update" (для формирования плана, задан фактический параметр запроса)

Update on pgbench_branches (cost=0.28..2.49 rows=0 width=0)
-> Index Scan using pgbench_branches_pkey on pgbench_branches (cost=0.28..2.49 rows=1 width=10)
Index Cond: (bid = 469)

Эксперимент - удаление ограничения первичного ключа в таблице pgbench_branches

ALTER TABLE pgbench_branches DROP CONSTRAINT pgbench_branches_pkey CASCADE

pgbench_db=# ALTER TABLE pgbench_branches DROP CONSTRAINT pgbench_branches_pkey CASCADE ;
NOTICE: удаление распространяется на ещё 3 объекта
DETAIL: удаление распространяется на объект ограничение pgbench_tellers_bid_fkey в отношении таблица pgbench_tellers
удаление распространяется на объект ограничение pgbench_accounts_bid_fkey в отношении таблица pgbench_accounts
удаление распространяется на объект ограничение pgbench_history_bid_fkey в отношении таблица pgbench_history

Изменение планов выполнения

Сценарий-1 "Select only" (для формирования плана, задан фактический параметр запроса)

Hash Join (cost=2.80..372.68 rows=1 width=4)
Hash Cond: (br.bid = acc.bid)
-> Seq Scan on pgbench_branches br (cost=0.00..366.78 rows=1178 width=8)
-> Hash (cost=2.79..2.79 rows=1 width=4)
-> Index Scan using pgbench_accounts_pkey on pgbench_accounts acc (cost=0.57..2.79 rows=1 width=4)
Index Cond: (aid = 1000)

Сценарий-2 "Select + Update" (для формирования плана, задан фактический параметр запроса)

Update on pgbench_branches (cost=0.00..369.73 rows=0 width=0)
-> Seq Scan on pgbench_branches (cost=0.00..369.73 rows=1 width=10)
Filter: (bid = 469)

Изменение производительности в ходе нагрузочного тестирования (Эксперимент-2) по сравнению с базовыми значениями (Эксперимент-1)

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

Для построения графиков используются отчеты по результатам нагрузочного тестирования [3][4]

Графики операционной скорости в ходе Эксперимента-1(SPEED-1) и Эксперимента-2(SPEED-2)
Графики операционной скорости в ходе Эксперимента-1(SPEED-1) и Эксперимента-2(SPEED-2)

Среднее увеличение операционной скорости в эксперименте-2 составило ~20%

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

Графики ожиданий СУБД в ходе Эксперимента-1(WAITINGS-1) и Эксперимента-2(WAITINGS-2)
Графики ожиданий СУБД в ходе Эксперимента-1(WAITINGS-1) и Эксперимента-2(WAITINGS-2)

Характерные события ожидания (wait_event)

Для формирования таблиц используются отчеты по результатам нагрузочного тестирования [5]

Итог эксперимента

Для данной виртуальной машины, при данном характере и сценарии нагрузки , операционная скорость в ходе Эксперимента-2 увеличилась в среднем на 20%.

В ходе Эксперимента-2 кардинально снизилась доля событий ожиданий CheckpointerComm и кардинально увеличилась доля событий ожиданий BufferContent и BufferMapping.

CheckpointerComm

Ожидание при управлении запросами fsync.

BufferContent

Ожидание при обращении к странице данных в памяти.

BufferMapping

Ожидание при связывании блока данных с буфером в пуле буферов.

Таблица 27.12. События ожидания, относящиеся к типу LWLock[8]

Ссылки на используемые материалы:

1.PG_EXPECTO : Статистический анализ производительности СУБД PostgreSQL

2.PG_EXPECTO : Нагрузочное тестирование СУБД PostgreSQL

3.PG_EXPECTO : Показатели производительности и ожиданий СУБД в ходе нагрузочного тестирования

4.PG_EXPECTO : Построение графиков производительности и ожиданий по результатам нагрузочного тестирования СУБД

5.PG_EXPECTO : Диаграмма Парето по событиям ожидания СУБД

6.Корреляционный анализ ожиданий СУБД PostgreSQL - презентация по докладу, не попавшему на конференцию PGConf.СПб 2025

7.Словарь терминов , используемых при корреляционном анализе.

8.Таблица 27.12. События ожидания, относящиеся к типу LWLock


P.S. Мнение нейросети о причинах роста производительности СУБД в данном кейсе

? Почему это работает для pgbench_branches

Конкретный сценарий pgbench

В стандартном TPC-B-подобном сценарии pgbench:

  • Таблица pgbench_branches содержит очень мало строк (по умолчанию = scale factor)

  • При scale=685 - всего 685 строк в таблице

  • Основная нагрузка - частые операции UPDATE на эту таблицу

Источник повышения производительности

-- Стандартная операция pgbench, которая выполняется часто:
UPDATE pgbench_branches SET bbalance = bbalance + 123 WHERE bid = 1;

Без индекса:

  • PostgreSQL выполняет последовательное сканирование маленькой таблицы

  • Нет затрат на обновление структуры B-дерева индекса

  • Меньше операций ввода-вывода

С индексом:

  • Каждый UPDATE требует обновления индекса

  • Дополнительные блокировки и конфликты

  • Больше нагрузки на WAL

⚙️ Технические детали

Ресурсы ВМ (8 CPU, 8GB RAM)

  • При 8GB RAM таблица размером 685 строк полностью помещается в памяти

  • Последовательное сканирование такой маленькой таблицы практически бесплатно

  • 8 CPU позволяют эффективно обрабатывать высокую конкуренцию

? Ожидаемые результаты

Увеличение TPS (Transactions Per Second)

  • Без PK: Выше TPS из-за уменьшения накладных расходов

  • С PK: Ниже TPS, но гарантирована целостность данных

Снижение нагрузки

  • Меньше операций ввода-вывода

Подтверждено в ходе эксперимента: Количество ожиданий DataFileRead снизилось на 13%

  • Снижение использования CPU

  • Уменьшение конфликтов блокировок

Подтверждено в ходе эксперимента: Количество ожиданий relation снизилось на 100%

? Вывод

Для данного конкретного случая (scale=685, 8 CPU, 8GB RAM) удаление индекса и PK с pgbench_branches может дать прирост производительности 15-25% в TPS за счет:

Подтверждено в ходе эксперимента: Производительность СУБД увеличилась на 20%

  1. Устранения накладных расходов на обновление индекса

  2. Уменьшения конфликтов блокировок при высокой конкуренции

  3. Оптимизации использования ограниченных ресурсов RAM

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


  1. baldr
    22.10.2025 14:37

    Ох, тяжело читать это всё. Ничего не понятно пока не перейдёшь по ссылкам на Дзен чтобы, хотя бы, сами запросы увидеть.

    А сколько строк в каждой таблице? А план запроса - это что - просто "EXPLAIN", а не "EXPLAIN ANALYZE"?

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

    Во втором сценарии только один из 4 запросов использует branches.

    В третьем сценарии branches вообще не используется, вы вставляете в pgbench_history.

    В каждой процедуре вы сначала отдельными запросами вычисляете минимум-максимум из таблицы, а потом ещё отдельно запрос в саму таблицу. И эта процедура у вас дёргается как нагрузка? Мда... Что за ерунда происходит вообще?

    И какой же вывод вы делаете из всего этого? Лучше потому что что? Так может удалить первичные ключи из pgbench_accounts и остальных таблиц тоже?


  1. ytatichno
    22.10.2025 14:37

    И какой вывод? Почему мы получаем такие результаты? 8 ядер cpu это я типа в DNS пришел или что?

    Для статьи тут как будто не хватает размышлений (вступление и предпосылки, описание попыток разобраться, выводы и советы).

    Для сухих замеров тут тоже много чего не хватает. Собственно подробностей (самих запросов, описания железа, других сценариев).


  1. Akina
    22.10.2025 14:37

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

    А статью разместить нужно, даже необходимо - причём срочно.


  1. SunchessD
    22.10.2025 14:37

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


    1. Akina
      22.10.2025 14:37

      Более интересным был бы вопрос, какие изменения произойдут, если первичный индекс заменить на вторичный уникальный при явном NOT NULL для всех используемых в нём полей. Вот тогда, наверное, и можно было бы получить ответ на вопрос, когда же ПЕРВИЧНЫЙ ключ становится узким местом?


  1. OlegIct
    22.10.2025 14:37

    По ссылке 7 «Операционная скорость - сумма завершенных SQL операций и числа строк полученных или затронутых оператором за промежуток времени». Без индекса при полном сканировании «затрагиваются» все строки таблицы, операционная скорость взлетает до небес, а реального эффекта TPS нет. Складывать число запросов с числом затронутых строк (мягко и тепло говоря) оригинально.

    Не нашёл что такое «точка наблюдения».


    1. pg_expecto Автор
      22.10.2025 14:37

      Операционная скорость - сумма завершенных SQL операций и числа строк полученных или затронутых оператором за промежуток времени

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

      Как было указано выше, для расчета операционной скорости необходимы следующие исходные данные:

      1) Количество выполненных запросов за отрезок времени.

      2) Количество обработанных или изменённых строк за отрезок времени.

      [11] pgpro_stats

      Для получения необходимых для расчетов данных используются представления расширения pgpro_stats:

      1)Представление pgpro_stats_statements

      Статистика, собираемая модулем, выдаётся через представление с именем pgpro_stats_statements. Это представление содержит отдельные строки для каждой комбинации идентификатора базы данных, идентификатора пользователя и идентификатора запроса.[1]

      2)Представление pgpro_stats_totals

      Агрегированная статистика, собранная модулем, выдаётся через представление pgpro_stats_totals. Это представление содержит отдельные строки для каждого отдельного объекта БД[2]

      Используемые столбцы:

      · calls Счётчик выполнений данного оператора

      · rows Общее число строк, полученных или затронутых оператором

      Данные собираются по СУБД в целом (pgpro_stats_totals) и по каждому SQL (pgpro_stats_statements) в отдельности.

      Периодичность сбора = 1 минута.

      Источник: https://dzen.ru/a/aGYjGIt_KDOjmf35

      Таким образом, для расчета операционной скорости используется терминология из описания представления pgpro_stats :

      calls Счётчик выполнений данного оператора

      rows Общее число строк, полученных или затронутых оператором

      Postgres Pro Enterprise : Документация: 15: G.5. pgpro_stats : Компания Postgres Professional

      Хотя , в расширении pg_expecto не используется pgpro_stats( в отличии от комплекса pg_hazel), терминология принципиально не отличается.

      Точка наблюдения

      Периодичность сбора данных для статистического анализа (медианное сглаживание и агрегация) = 1 минута. Таким образом - Точка наблюдения это номер минуты в ходе теста.