- PVSM.RU - https://www.pvsm.ru -

Фантастические advisory locks, и где они обитают

В PostgreSQL существует очень удобный механизм рекомендательных блокировок [1], они же — advisory locks. Мы в «Тензоре» используем их во многих местах системы, но мало кто детально понимает, как конкретно они работают, и какие проблемы можно получить при неправильном обращении.

Фантастические advisory locks, и где они обитают - 1

Еще почитать о блокировках

Для общего расширения кругозора, рекомендую ознакомиться с циклом статей Егора Рогова [2], о механике блокировок в PostgreSQL [3].

Преимущества рекомендательных блокировок

Принципиальное отличие этого механизма от «обычных» блокировок [4] уровня таблицы/страницы/записи заключается в наличии нескольких ключевых возможностей.

Блокировка произвольного идентификатора

«Обычные» блокировки в PG всегда привязаны к конкретному объекту БД (таблице, записи, странице данных) и процессу, обслуживающему соединение. Advisory locks — тоже к процессу, но вместо реального объекта — абстрактный идентификатор, который можно задать как (bigint) или как (integer, integer).

Кстати, привязка каждой блокировки к процессу означает, что «прибив» его через pg_terminate_backend(pid) или корректно завершив соединение с клиентской стороны, можно избавиться от всех наложенных им блокировок.

CAS-проверка возможности захвата блокировки

CAS — это Compare-and-Set [5], то есть проверка возможности захвата и сам захват блокировки проходят как одна атомарная операция, и между ними заведомо никто не может «вклиниться».

То есть если вы сначала делаете проверочный запрос к pg_locks [6], смотрите на результат, потом решаете накладывать или нет блокировку, то вам никто не гарантирует, что между этими операциями никто не успеет занять нужный вам объект. А вот если пользуетесь pg_try_advisory_lock [7] — то вы эту блокировку сразу или получите, или функция просто вернет FALSE.

Не-захват без исключений и ожиданий

«Обычные» блокировки существуют в модели «Если уж ты попросил блокировку — то жди. Если не захотел ждать (NOWAIT, statement_timeout, lock_timeout) — вот тебе исключение». Этот подход сильно мешает внутри транзакции, потому что тогда приходится или реализовывать блок BEGIN-EXCEPTION-END для обработки, или откатывать (ROLLBACK) транзакцию.

Единственный способ избежать такого поведения — использовать конструкцию SELECT ... SKIP LOCKED, появившуюся с версии 9.5. К сожалению, при таком способе варианты «вообще не было, что блокировать» и «было, но уже заблокировано» становятся неразличимы.

Рекомендательные же блокировки, вызываемые try-функциями, просто возвращают TRUE/FALSE.

Не путайте pg_advisory_lock и pg_try_advisory_lock — первая функция таки будет ждать, пока не получит блокировку, а вторая — просто сразу вернет FALSE при невозможности захвата «прямо сейчас».

Блокировки в рамках транзакции и за ними

Как я упомянул выше, блокировки объектов «привязаны» к процессу и существуют только в рамках выполнения текущей транзакции в нем. Даже просто так наложить — не удастся:

LOCK TABLE tbl;
-- ERROR:  LOCK TABLE can only be used in transaction blocks

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

SELECT pg_advisory_lock(1);
SELECT * FROM pg_locks WHERE pid = pg_backend_pid() AND locktype = 'advisory';

-[ RECORD 1 ]------+--------------
locktype           | advisory
database           | 263911484
relation           |
page               |
tuple              |
virtualxid         |
transactionid      |
classid            | 0 <-- аргумент int4 #1 или верхняя половина int8
objid              | 1 <-- аргумент int4 #2 или нижняя половина int8
objsubid           | 1
virtualtransaction | 416/475768
pid                | 29264
mode               | ExclusiveLock
granted            | t
fastpath           | f

Но уже с версии 9.1 появились xact-версии advisory-функций [7] позволяющие реализовать поведение «обычных» блокировок, автоматически снимающихся при завершении наложившей их транзакции.

Примеры использования в СБИС

Собственно, как и любая другая блокировка, advisory служат для обеспечения единственности обработки какого-то ресурса. У нас такими ресурсами обычно выступают или таблица целиком или конкретная запись таблицы, которую по каким-то причинам не хочется «лочить жестко».

Монопроцессность работы worker'а

Если необходимость обработки каких-то данных в базе инициируется внешним событием, но многопроцессная обработка избыточна или может привести к состоянию гонки [8], разумно сделать так, чтобы работать на этих данных мог всего лишь один процесс одновременно.

Для этого попытаемся наложить блокировку с идентификатором таблицы в качестве первого параметра и ID конкретной прикладной обработки — в качестве второго:

SELECT pg_try_advisory_lock(
  'processed_table'::regclass::oid
, -1 -- ключ типа worker'а
);

Если нам вернулось FALSE, то кто-то другой уже держит «такую» блокировку, и конкретно этому процессу делать ничего не надо, а лучше всего просто тихо завершиться. Так работает, например, процесс динамического расчета себестоимости товаров на складе [9], изолированно для каждой отдельной клиентской схемы.

Параллельная обработка очереди

Теперь задача «от обратного» — мы хотим, чтобы задачи в некоторой таблице-очереди обрабатывались максимально быстро, многопоточно, отказоустойчиво, да еще и с разных бизнес-логик (ну, не хватает нам мощности одной) — например, как это делают наш оператор по передаче электронной отчетности [10] в госорганы или сервис ОФД [11].

Поскольку «обрабатывающие» БЛ — разные серверы, то никакой mutex уже не «повесишь». Выделять некий особый раздающий задачи процесс-координатор — небезопасно, «сдохни» он — и все встанет. Вот так и получается, что эффективнее всего распределять задачи прямо на уровне БД, и такой способ существует — модель была в незапамятные времена честно подсмотрена у Дмитрия Котерова [12] и потом творчески доработана.

В этом случае мы накладываем блокировку на ID таблицы и PK конкретной записи:

SELECT
  *
FROM
  queue_table
WHERE
  pg_try_advisory_lock('queue_table'::regclass::oid, pk_id)
ORDER BY
  pk_id
LIMIT 1;

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

Впрочем, если PK состоит не из (integer), а из (integer, integer) (как в том же расчете себестоимости, например), можно накладывать блокировку прямо на эту пару — вряд ли пересечение с «конкурентом» возникнет.

Важно! Не забываем периодически правильно обслуживать свою таблицу-очередь [13]!

Монопольная обработка документа

Применяется у нас повсеместно в решениях для документооборота [14]. Ведь в распределенной web-системе один и тот же документ одновременно может открываться на просмотр разными пользователями, а вот обрабатываться (изменять свое состояние и т.п.) в каждый момент времени — только кем-то одним.

Традиционные проблемы

Куда же без них! Почти все сводятся к одному: не разлочили то, что залочили.

Мультиналожение одной advisory-блокировки

RTFM [7], как говорится:

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

Наложили слишком много блокировок сразу

Фантастические advisory locks, и где они обитают - 2

Тысячи их! Снова читаем мануал [7]:

И рекомендательные, и обычные блокировки сохраняются в области общей памяти, размер которой определяется параметрами конфигурации max_locks_per_transaction и max_connections. Важно, чтобы этой памяти было достаточно, так как в противном случае сервер не сможет выдать никакую блокировку. Таким образом, число рекомендуемых блокировок, которые может выдать сервер, ограничивается обычно десятками или сотнями тысяч в зависимости от конфигурации сервера.

В общем, если возникает ситуация, когда вам хочется наложить несколько тысяч advisory locks (даже если вы их все корректно снимаете потом) — сильно-сильно подумайте, куда вы побежите, когда сервер «встанет колом».

Утечки при фильтрации записей

Вот берем предыдущий запрос и добавляем безобидное условие типа проверки на четность ID — AND pk_id % 2 = 0. Проверены будут оба условия для каждой записи! В результате, pg_try_advisory_lock выполнилась, блокировка наложилась, а потом запись отфильтровалась по проверке четности.

Фантастические advisory locks, и где они обитают - 3

Или вариант из мануала:

SELECT pg_advisory_lock(id) FROM foo WHERE id = 12345; -- ok
SELECT pg_advisory_lock(id) FROM foo WHERE id > 12345 LIMIT 100; -- опасно!

Все — блокировка осталась, а мы об этом и не в курсе. Лечится правильными запросами, в худшем случае — pg_advisory_unlock_all.

Ой, перепутал!

Классика жанра…

Перепутать pg_try_advisory_lock и pg_advisory_lock, и удивляться, почему долго работает. А потому что не-try-версия — ждет.

Перепутать pg_try_advisory_lock и pg_try_advisory_xact_lock, и удивляться, куда пропала блокировка — а она «кончилась» вместе с транзакцией. А транзакция из одного того запроса и состояла, потому что нигде «явно» не объявлялась, ага.

Работа через pgbouncer

Это отдельный источник боли для многих, когда ради производительности работа с БД идет через pgbouncer в transaction mode.

Это означает, что две ваши соседние транзакции, выполняющиеся на одном и том же соединении «с БД» (которое на самом деле идет через pgbouncer), могут оказаться на выполнении в разных «физических» соединениях на стороне базы. А блокировки у них — свои у каждого…

Фантастические advisory locks, и где они обитают - 4

Вариантов тут немного:

  • или переходите на работу через прямое соединение с БД
  • или придумывайте алгоритм, чтобы все advisory-блокировки были только в рамках транзакции (xact)

На этом пока все.

Автор: Kilor

Источник [15]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/postgresql/346558

Ссылки в тексте:

[1] механизм рекомендательных блокировок: https://postgrespro.ru/docs/postgresql/12/explicit-locking#ADVISORY-LOCKS

[2] Егора Рогова: https://habr.com/ru/users/erogov/posts/

[3] о механике блокировок в PostgreSQL: https://habr.com/ru/company/postgrespro/blog/465263/

[4] «обычных» блокировок: https://postgrespro.ru/docs/postgresql/12/explicit-locking

[5] Compare-and-Set: https://ru.wikipedia.org/wiki/%D0%A1%D1%80%D0%B0%D0%B2%D0%BD%D0%B5%D0%BD%D0%B8%D0%B5_%D1%81_%D0%BE%D0%B1%D0%BC%D0%B5%D0%BD%D0%BE%D0%BC

[6] pg_locks: https://postgrespro.ru/docs/postgresql/12/view-pg-locks

[7] pg_try_advisory_lock: https://postgrespro.ru/docs/postgresql/12/functions-admin#FUNCTIONS-ADVISORY-LOCKS

[8] состоянию гонки: https://ru.wikipedia.org/wiki/%D0%A1%D0%BE%D1%81%D1%82%D0%BE%D1%8F%D0%BD%D0%B8%D0%B5_%D0%B3%D0%BE%D0%BD%D0%BA%D0%B8

[9] расчета себестоимости товаров на складе: https://sbis.ru/inventory

[10] передаче электронной отчетности: https://sbis.ru/ereport

[11] сервис ОФД: https://sbis.ru/ofd

[12] Дмитрия Котерова: http://dklab.ru/chicken/nablas/53.html

[13] правильно обслуживать свою таблицу-очередь: https://habr.com/post/481866/

[14] решениях для документооборота: https://sbis.ru/edo

[15] Источник: https://habr.com/ru/post/488024/?utm_campaign=488024&utm_source=habrahabr&utm_medium=rss