В статье дан качественный тест, проявляющий проблему конкуренции за блокировки типа LockManager и было упомянуто, что проблема решена в 18 версии PostgreSQL без описания того, как решена. Эта статья закрывает пробел. Начиная с 18 версии, проблема конкуренции LWLock:LockManager при выполнении запросов к большому числу таблиц, индексов, секций ("отношений") устранена. Также даётся ответ на вопрос: если fastpath блокировки  хранятся отдельно для каждого процесса, как другие процессы проверяют наличие блокировок? В статье описано, почему блокировки по быстрому пути (fastpath) так эффективны и почему новшества в PostgreSQL, появившиеся в 18 так важны на практике.

При выполнении запроса SELECT к таблице серверный процесс блокирует не только саму таблицу, но и ВСЕ её индексы блокировкой уровня Access Share ещё на этапе планирования. Все эти блокировки помещаются в разделяемую память и защищаются блокировками LWLock. На компьютерах с большим числом ядер, обслуживающих множество простых запросов (например, поиск по первичному ключу), серверные процессы постоянно конкурируют за один и тот же раздел структуры блокировок, пытаясь получить легковесную блокировку (LWLock), которой защищена часть таблицы блокировок. Классическое узкое место.

Скрытый текст

Тип события LWLock, а LockManager - это событие ожидания чтения или обновления информации о НЕ-fastpath блокировках (то есть блокировок, получаемых по обычному пути). Названия легковесных блокировок - значение столбца wait_event для wait_event_type='LWLock' представления pg_stat_activity.

Что такое блокировки по быстрому пути описано в разделе "Fast Path Locking" файла lmgr/README исходного кода PostgreSQL

Вместо постоянного обращения к общей памяти, каждый серверный процесс получает собственный приватный массив для хранения ограниченного числа слабых блокировок (три типа блокировок: Access Share, Row Share, Row Exclusive).

В PostgreSQL 9.2-17 версий: приватный массив содержит ровно 16 слотов для каждого процесса, хранящиеся как массив в структуре PGPROC - описатель каждого процесса. Структуры PGPROC всех процессов находятся в общей памяти (т.е. доступной другим процессам). Структура каждого процесса защищена блокировкой LWLock по числу процессов (fpInfoLock).

Конкуренции нет, так как каждый процесс имеет свою блокировку.

Идентифицировать fastpath блокировки можно по значению в столбце fastpath представления pg_locks.

Слабые блокировки Access Share, Row Share, Row Exclusive на отношении не конфликтуют друг с другом. Типичные команды DML (SELECT, INSERT, UPDATE, DELETE) используют слабые блокировки, выполняются совместно и не мешают друг другу. А вот команды DDL и LOCK TABLE создают конфликты, поэтому блокировки для этих команд (сильные блокировки) не могут быть получены по быстрому пути.

Для синхронизации PostgreSQL поддерживает массив из 1024 целочисленных счетчиков (FastPathStrongRelationLocks), которые делят пространстов блокировок на разделы (секции). Каждый счетчик отслеживает, сколько сильных блокировок (Share, Share Row Exclusive, Exclusive, Access Exclusive) существует в этом разделе. При получении слабой блокировки: процесс получает fpInfoLock на свою часть PGPROC и проверяет, равен ли счетчик сильных блокировок нулю. Если да, то слабая блокировка использует быстрый путь. Если нет, то обычный путь и устанавливает блокировку через основную структуру памяти ("таблицу блокировок").

Так как слабые блокировки не конфликтуют друг с другом, а сильных нет, то при получении слабой блокировки процессам не нужно проверять блокировки, установленные по быстрому пути. Проверки нужны только при получении конфликтующей сильной блокировки, что случается редко (трудоемкость в том, что надо будет увеличить счетчик FastPathStrongRelationLocks, затем просканировать массив блокировок, полученных по быстрому пути каждого процесса,переместить все конфликтующие блокировки в общую таблицу блокировок).

С ростом популярности секционирования таблиц 16 слотов, выделяемых каждому процессу под fastpath блокировки, оказывается недостаточно. Запрос, обращающийся к секционированной таблице с несколькими индексами на секцию, быстро исчерпывает лимит, равный 16. При переполнении 16 слотов, блокировки вынуждены запрашиваться по обычному пути - через общую таблицу блокировок, конкуренцией за доступ к таблице блокировок, которая проявляется в событии ожидания LWLock:LockManager.

В 2023 году это стало реальной проблемой, многие пользователи PostgreSQL сталкивались с серьёзной конкуренцией LWLock:LockManager. Николай Самохвалов с коллегами провели несколько тестов, в которых изменили константу в исходном коде ядра PostgreSQL  FP_LOCK_SLOTS_PER_BACKEND и подтвердили, что увеличение значения снижает конкуренцию LWLock:LockManager в части случаев, поэтому Николай Самохвалов предложил сделать константу настраиваемой https://www.postgresql.org/message-id/flat/CAM527d-uDn5osa6QPKxHAC6srOfBH3M8iXUM%3DewqHV6n%3Dw1u8Q%40mail.gmail.com . Первым откликнулся Томаш Вондра, из постов которого стало ясно, что он изучал эту идею. Однако, просто так заменить константу переменной нельзя, это снизит производительность и нужно было придумать лучшее решение.

В Postgres 18 версии изменился способ хранения fastpath блокировок: теперь они хранятся в массивах переменного размера в отдельной разделяемой памяти (ссылка на них осуществляется через указатели из PGPROC). Это позволяет изменять размер массива, поэтому допустимое количество блокировок быстрого пути для бэкенда масштабируется в зависимости от параметра max_locks_per_transaction (по умолчанию 64 слота). Это один из самых сложных для полного понимания параметров и требует отдельного рассмотрения. Изменение этого параметра требует перезапуск экземпляра PostgreSQL.

Чтобы не быть голословным, рассмотрим несколько тестов. Эти тесты были проведены Денисом Морозовым из PostgresAI по запросу GitLab https://gitlab.com/gitlab-com/gl-infra/data-access/dbo/dbo-issue-tracker/-/issues/594#note_2786838563  (спасибо GitLab за то, что они выкладывают проблемы, с которыми столкнулись, в открытом доступе, что приносит огромную пользу разработчикам открытого програмного обеспечения, включая сообщество Postgres!).

Дальше приводится короткое описание проблемы.

На машине со 128 виртуальными ядрами создаем таблицы pgbench без секционирования:

pgbench -i -s 100

а затем выполнить серию тестов pgbench с параметром '--select-only' и большим числом сессий ('-c/-j 100'):

pgbench --select-only -c 100 -j 100 -T 120 -P 10 -rn

Параметр '--select-only' в pgbench выполняет поиск по первичному ключу - SELECT по таблице "pgbench_accounts".

Это делается в несколько итераций, и после каждой итерации создаём новый дополнительный индекс для "pgbench_accounts" - неважно, какой именно индекс; важно то, что не используется '-M prepared', поэтому мы знаем, что каждый запрос будет включать время планирования (планы не будут кэшироваться, так как не используются подготовленные запросы), и, следовательно, все индексы будут заблокированы с помощью AccessShareLock. Первая итерация начинается с блокировки двух отношений - самой таблицы и её единственного индекса по первичному ключу.

Таким образом, при наличии 14 дополнительных индексов общее число отношений, которые будут заблокированы, составит 1+1+14 = 16, и это помещается в стандартные слоты FP_LOCK_SLOTS_PER_BACKEND в PostgreSQL 17 (или в PostgreSQL 18, если значение max_locks_per_transaction уменьшается со значения по умолчанию 64 до 16, чтобы соответствовать PostgreSQL 17 версии.

Рассмотрим отдельно время планирования и время выполнения:

Время фазы выполнения запроса: PostgreSQL 18 версии - синяя линия, предыдущие версии - красная линия
Время фазы выполнения запроса: PostgreSQL 18 версии - синяя линия, предыдущие версии - красная линия
время планирования запроса: PostgreSQL 18 версии - синяя линия, предыдущие версии - красная линия
время планирования запроса: PostgreSQL 18 версии - синяя линия, предыдущие версии - красная линия

Что мы можем увидеть:

  • время выполнения запроса (execution time) стабильно и не зависит от числа индексов

  • что касается времени планирования, то при наличии до 14 дополнительных индексов мы наблюдаем лишь медленный рост задержки (блокировка каждого индекса отнимает у нас немного времени, поэтому можно сказать, что дополнительные индексы всегда немного замедляют выполнение запросов SELECT, если не используются подготовленные запросы; это само по себе интересное наблюдение).

  • когда у нас появляется 15 дополнительных индексов, при max_locks_per_transaction=16 в PG18 (или в PG17), это кардинально меняет ситуацию - мы видим явный признак резкого снижения производительности, скачки задержки на этапе планирования.

  • При этом, если мы оставим значение max_locks_per_transaction равным по умолчанию 64 или, как в этом случае, увеличим его до 128, мы сможем продолжать добавлять индексы без существенного влияния на время планирования.

Рассмотрим события ожидания:

Вот он, LWLock:LockManager
Вот он, LWLock:LockManager

Обратите внимание, как в PG 18 с max_locks_per_transaction=16 (верзняя картинка; PG 17 выглядел бы аналогично) наблюдаются массивные красные полосы ожиданий LWLock:LockManager при больших значениях индекса, в то время как в PG 18 с max_locks_per_transaction=128 (внизу) полосы остаются преимущественно зелеными (активное выполнение).

Но есть ли у увеличения с 16 до 64 слотов недостатки?

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

  • Накладные расходы на память: при max_locks_per_transaction=64 вы получаете 64 слота на каждый процесс. Каждому слоту требуется около 4 байт для OID плюс несколько битов в fpLockBits. Несколько сотен байт на каждый процесс. Кажется, это незначительно.

  • Получение сильной блокировки: вот где начинается самое интересное. Когда мы используем сильную блокировку (Access Exclusive для DDL, например, ALTER TABLE или DROP), процессу необходимо просканировать массив слотов fastpath блокировок каждого процесса экземпляра, чтобы переместить конфликтующие блокировки в общую таблицу блокировок. Становится ли это заметно медленнее, если, скажем, использовать 128 слотов вместо 16? На самом деле, если посмотреть на код (FastPathTransferRelationLocks), процесс сканирует только соответствующую хэшу отношения группу слотов (всего 16 слотов), а не весь массив! Таким образом, даже при 1024 группах = 16 384 слотах всего, он затрагивает только 16 слотов на каждый бэкенд. Умно сделано!

  • Хэш группы: отношения распределяются по группам по формуле (relid * 49157) % число групп (посмотреть код можно здесь). Могут ли relid иметь неудачные значения OID, и попасть в один раздел? Нет - умножение на простое число эффективно распределяет даже последовательные OID по разным разделам.

Спасибо Томасу Вондре за оптимизацию в Postgres 18! У него есть отличная презентация об этой работе: https://youtube.com/watch?v=iCmUhS9XYI0 .

Скрытый текст

Как описано начиная со слайда 229 http://dba1.ru/pmt/PT-Book.htm или страницы 237 http://o90376y9.beget.tech/pmt/PT-Book.pdf : увеличение FP_LOCK_SLOTS_PER_BACKEND с 16 до 64 приведет к увеличению структуры PGPROC в разделяемой памяти. PGPROC хранит состояние процесса. Структура одного процесса занимает 880 байт, что равно 14 cache lines (блоков данных), добавление 48 xid увеличит ее на 192 байта (3 cache lines). "Линии кэша" - блоки размером 64 байт, которыми передаются данные между кэшем процессора и памятью, содержит копию данных из основной памяти.

Поэтому проблема не могла быть решена в лоб, увеличением константы. Томас Вондра решил проблему более аккуратно.

Заключение

  • Fastpath блокировки позволяют избегать конкуренции за общую таблицу блокировок для наиблоее часто используемых команд, таких, как SELECT. PostgreSQL 17 и более ранние версии: 16 fastpath блокировок на процесс. PostgreSQL 18: меняется при изменении max_locks_per_transaction (по умолчанию 64).

  • Если у вас есть секционированные таблицы с индексами и вы видите ожидания LWLock:LockManager, обновление до PG 18 должно устанить рассмотренную в статье проблему конкуренции за блокировки.