В современном мире у одного бекенд-приложения обычно запущено больше одного экземпляра, хотя бы из соображений надёжности. А это значит, что для синхронизации их действий надо что-то придумывать, какое-то внешнее решение: мьютексов или, к примеру, гошных каналов внутри приложения уже недостаточно.
К счастью, во многих случаях в проекте уже есть какая-нибудь база данных, которую можно использовать для этих целей. СУБД сама управляет блокировками, и многие проблемы решаются сами, "под капотом". Например, если два инстанса попытаются обновить одну и ту же строку в таблице, то эта строка не превратится в кашу. СУБД автоматически возьмет нужный лок, и тот, кто пришёл вторым, просто будет ждать, пока этот лок не будет снят.
Проблема в том, что такая автоматика с принудительными локами подходит не для всех случаев. Например, вы массово обрабатываете файлы, предполагая, что никакой файл не будет обрабатываться одновременно двумя приложениями сразу, но при этом не хотите создавать для синхронизации полноценную таблицу в БД. В проектах Каруны такие задачи возникают довольно регулярно.
Для решения подобных проблем в PostgreSQL есть так называемые необязательные блокировки (advisory locks), т.е. локи, которые берутся, исходя из логики приложения, а не автоматики хранения/выдачи данных в БД.
Сессионные локи
Сразу скажу, что под сессией здесь мы понимаем tcp-соединение к PostgreSQL, не к pgBouncer, это важно. Чуть ниже расскажу об этом подробнее.
Итак, сессионные advisory locks. Если две программы в разных сессиях одновременно сделают
-- Берём лок, используя некое число (BigInt)
SELECT pg_advisory_lock(123);
то первая продолжит работать, а вторая подвиснет, ожидая, пока первая отпустит лок с помощью команды
-- Снимаем лок (с тем же числом)
SELECT pg_advisory_unlock(123);
Что это за число такое в скобках? Ну, это любое число, которое вам нравится — главное, чтобы в одной и той же ситуации две программы использовали одно и то же. На самом деле, на практике часто хочется ставить лок не на число, а на некую строку. Например, при обработке файлов это могло бы быть имя файла. Но функция требует именно bigint, поэтому часто используют такой жутковатый на вид способ: строку преобразовывают в md5, а потом md5 представляют как шестнадцатеричное число, которое кастуют к bigint. В итоге можно залочиться на вполне конкретное имя файла, заданное в виде строки
SELECT pg_advisory_lock(
('x' || md5('/path/to/my/file.txt'))::bit(64)::bigint
);
Есть ещё одна полезная функция. Допустим, вам нужно сделать какую-то тяжёлую операцию (например, аналитический отчёт раз в сутки в 00:00) ровно один раз. Тогда вам нет смысла подвешивать второй инстанс ожиданием. Вам нужно просто проверить: если лока нет, то взять его, а если уже есть — то просто выйти. Для этого обычно используют функцию pg_try_advisory_lock
SELECT pg_try_advisory_lock(
('x' || md5('create complicated report'))::bit(64)::bigint
);
Эта функция вернёт false, если лок уже взят в другой сессии. А если не взят, то берёт лок и возвращает true. Снимается он точно так же (pg_advisory_unlock
)
Сессии и pgbouncer
Если вы новичок в Postgresql, то лучше бы вам сразу узнать про Postgres очень плохую вещь: на каждое соединение он создаёт настоящий процесс ОС. Поэтому в любой маломальски нагруженной системе используется тот или иной пулер соединений (connection pooler). Один из самых распространенных вариантов — это pgBouncer. PgBouncer держит несколько постоянных соединений (сессий) к СУБД, а клиент, подключенный к пулеру, получает то одно из них, то другое — то, которое сейчас больше никем не используется.
PgBouncer может работать в двух режимах: сессионный и транзакционный. Сессионный режим логически почти не отличается от прямого подключения к базе данных, так как пока приложение подключено к pgBouncer, pgBouncer ему предоставляет одно и то же соединение к Postgres. А когда отключается — соединение возвращается в пул.
В транзакционном же режиме pgbouncer'а на каждую транзакцию будет подсовываться разная сессия.
Т.е. вы делаете SELECT — получаете соединение номер 1.
Потом делаете BEGIN; несколько запросов; COMMIT; — получаете соединение, допустим, 15.
Потом делаете ещё один селект — получаете соединение номер 12, а может, и номер 1 — как повезёт. Что свободно на данный момент, то и получите.
Транзакционный режим очень эффективен и используется в хайлоаде, потому что соединения не простаивают без дела, а тут же возвращаются в пул.
Однако в таком режиме работа с pg_advisory_lock() невозможна: вы просто не знаете, на какое соединение попадёт ваш запрос — на то же самое или на другое. А ведь лок работает только на разных сессиях (соединениях). Причём в дев окружении, при тестировании функциональности, это может случайно сработать правильно. Но при выкатке в прод и реальной нагрузке сработает другая вероятность, и появится очень странное, плохо диагностируемое, подглючивающее поведение.
Транзакционные локи
Однако, advisory локи бывают и другого вида — для работы с транзакциями (т.е. подходят для работы с pgbouncer в любом режиме). Они берутся внутри транзакции с помощью pg_advisory_xact_lock()
/pg_try_advisory_xact_lock()
, а отпускаются автоматически при коммите или откате транзакции.
Пример:
Стартуем транзакцию
BEGIN;
Пробуем взять лок
SELECT pg_try_advisory_xact_lock(
('x' || md5('create daily 00:00 report'))::bit(64)::bigint
);
Если этот запрос вернул false, то завершаем транзакцию и не делаем больше ничего. Если вернул true, то делаем что-то полезное, например, запрос на долго вычисляемый ежедневный отчёт на 10 джойнов и 100500 условий, который мы бы не хотели выполнять два раза одновременно.
SELECT ...
JOIN ...
JOIN ...
....
WHERE
...
Отпускаем транзакционный адвизори лок, просто завершив транзакцию:
COMMIT;
Такие *_advisory_xact_lock
хорошо работают с pgBouncer, запущенном в транзакционном режиме, однако и тут тоже не надо забывать об одном нюансе.
Не стоит использовать такой лок, например, для часовой обработки видео, если обработчиков много: всё это время транзакция будет висеть, а значит, и соединение к базе — тоже. В этом случае несколько таких видосов просто сожрут все соединения от pgBouncer к базе, и всё остальное замрёт в ожидании соединений.
Еще нюансы
-
Точно так же, как и в других языках и системах, постгресовые advisory locks бывают двух типов: exclusive и shared. Если вы не понимаете разницу, просто загуглите.
-
Если в одной сессии (имеется в виду коннект к самой базе, а не к пулеру) брать сессионный advisory lock с одним и тем же числом два раза, то и отпускать лок нужно будет дважды.
-
Посмотреть, какие адвизори локи взяты, можно с помощью команды
SELECT objid, "mode" FROM pg_locks where locktype = 'advisory';
где objid — это число, которое вы указали в скобочках, когда брали лок, а mode — ExclusiveLock или ShareLock.
-
Все блокировки хранятся в памяти, поэтому после перезагрузки сервера ничего не сохранится.
Официальная документация:
общее описание
список функций
Комментарии (14)
Kilor
12.07.2022 13:56В итоге можно залочиться на вполне конкретное имя файла, заданное в виде строки
Если уж у нас и так есть база, то обычно проще заложиться на имя какой-то таблицы, а не файла (пример отсюда):
SELECT pg_try_advisory_lock( 'processed_table'::regclass::oid , -1 -- ключ типа worker'а );
varanio Автор
12.07.2022 23:23Это как? вот у меня есть 100500 файлов. Таблиц может вообще нет. Как мне закладываться на имя таблицы?
Kilor
12.07.2022 23:31Обычно подобную обработку на advlock с монопольным захватом ресурса делают на PG, когда им является уже существующая таблица/запись. Ну и таблицу-то найти можно всегда - хотя бы и системную pg_class.
А вот зачем использовать блокировки на БД для обработки каких-то файлов, если "таблиц вообще нет" - вопрос.
fireSparrow
13.07.2022 11:50+1Обычно подобную обработку на advlock с монопольным захватом ресурса делают на PG, когда им является уже существующая таблица/запись.
Не обязательно. Можно исполььзовать advlock для монопольного доступа вообще к любому ресурсу, в том числе находящемуся вне базы.
Главное, чтобы этому ресурсу можно было сопоставить некое уникальное название/идентификатор, которое и используется для взятия лока.Kilor
13.07.2022 11:59Конечно, можно, но - зачем? То есть СУБД это явно не наилучший механизм для абстрактной синхронизации каких-то процессов. И если это ее единственное применение на проекте, то это странно.
fireSparrow
13.07.2022 16:26+1Если постгрес только для локов использовать — то действительно странно.
Но если постгресс уже всё равно есть для данных, то почему бы и локи не сделать через него? Понятно, что в этом случае какие-то таблицы всё-таки будут, но они могут никак не соотноситься с локами.Kilor
13.07.2022 16:53Ну, я про это и написал - что если у нас уже есть данные в какой-то таблице, и мы для разруливания их обработки используем advlock, то проще закладываться на имя этой таблицы в качестве одного из пары ID рекомендательной блокировки.
Ну или пару
(tableoid::oid, PK::integer)
, если нам нужна блокировка не на всю таблицу, а до конкретной записи - например, при конкурентной обработке очередей.
ggo
13.07.2022 09:37В качестве альтернативы стоит упомянуть Zookeeper, который и был рожден для управления распределенной конкурентностью.
nikolai-averin
13.07.2022 21:52PgBouncer может работать в двух режимах: сессионный и транзакционный.
В 3 режимах - есть ещё statement.
RekGRpth
для хеширования текста есть встроенная функция hashtext, только почему-то не в документации
varanio Автор
круто, не знал. Правда, недокументированные функции страшновато использовать
Kilor
Официальное мнение по этому вопросу звучит примерно так: "не надо лазать, куда не надо".
Что не мешает желающим использовать множество внутренних hash-функций, чтобы получить дополнительные +40% производительности.
varanio Автор
Что, если взять все эти функции и сделать из них расширение