История вопроса.
Совещательные блокировки впервые появились в PostgreSQL 8.2 для предоставления легковесных, примитивов синхронизации на уровне приложений, которые работают вне системы видимости кортежей MVCC. Они были разработаны для таких рабочих процессов, как обработка очередей и идемпотентный ввод, где блокировка на уровне таблиц была бы семантически неподходящей или запретительной с точки зрения производительности. В отличие от блокировок на уровне строк, которые связаны с конкретными кортежами таблицы и записываются в системный столбец xmax, совещательные блокировки полностью находятся внутри менеджера блокировок общей памяти, предлагая механизм для управления доступом к абстрактным ресурсам без создания мертвых кортежей или трафика WAL.
Проблема.
Вpipelineах идемпотентного ввода с высокой параллельностью обеспечение уникальности бизнес-ключей (например, внешних UUID) с помощью традиционного INSERT ... ON CONFLICT или SELECT FOR UPDATE создает серьезные узкие места. Подходы на уровне строк требуют записи в кучу для установки битов блокировки, что раздувает таблицы, ускоряет давление на VACUUM и вызывает горячие точки в уникальных индексах во время разрешения конфликтов. Задача состоит в том, чтобы обеспечить взаимное исключение для логических сущностей, таких как хэшированный бизнес-ключ, не затрагивая уровень хранения, при этом гарантируя, что сбои блокировки не утекут в постоянные пулы соединений.
Решение.
Критическая особенность заключается в том, что совещательные блокировки хранятся исключительно в хэш-таблице LOCKTAG в общей памяти, используя LOCKMETHOD_ADVISORY, и поэтому никогда не изменяют страницы основного отношения. Используя pg_advisory_xact_lock(hashtext(business_key)), приложение получает мьютекс с областью действия транзакции, который автоматически освобождается при COMMIT или ROLLBACK, предотвращая утечку блокировки, связанную с совещательной блокировкой уровня сессии pg_advisory_lock. Этот подход устраняет разрастание таблиц и конкуренцию индексов, поскольку блокировка существует только как легковесная запись в памяти, как показано ниже:
BEGIN; -- Получите блокировку, привязанную к транзакции, на хэшированном бизнес-ключе SELECT pg_advisory_xact_lock(hashtext('a1b2c3d4')); -- Безопасно вставить; нет конкуренции уникального индекса, если другая сессия удерживает блокировку INSERT INTO events (business_key, payload) VALUES ('a1b2c3d4', '{"event":"click"}') ON CONFLICT (business_key) DO NOTHING; COMMIT;
Команда платформы данных в компании-телеметрии нуждалась в гарантии обработки точно один раз для 50,000 событий в секунду, которые поступали из Kafka в PostgreSQL, где каждое событие несло сгенерированный клиентом UUID, который служил ключом идемпотентности. Первоначальные тесты нагрузки с использованием INSERT ... ON CONFLICT DO NOTHING на уникальном столбце UUID вызвали серьезные задержки из-за конкуренции спин-блокировок на уникальном индексе B-tree и быстро накапливающегося разрастания из-за неудач обновлений HOT. Скорость генерации WAL удвоилась в пиковые часы, угрожая задержкой репликации и вместимости хранения.
Одним из предлагаемых решений было предварительно проверять существование ключа с помощью SELECT * FROM events WHERE business_key = $1 FOR UPDATE, а затем вставлять только в случае, если результат был пустым. Хотя это предотвращало дубликаты, оно вынуждало каждого писателя получать блокировку строки как на существующей строке, так и на строке резервирования, создавая огромную горячую точку на страницах таблицы резервирования. Подход породил значительное разрастание таблиц, требующее выполнения VACUUM для восстановления мертвых кортежей каждые пятнадцать минут, и не мог предотвратить условия гонки между проверкой и вставкой без удержания блокировки на всю продолжительность транзакции, что сильно ограничивало пропускную способность.
Архитектурная команда предложила переместить координацию в внешний кеш Redis, используя операции SETNX для защиты вставок. Это позволило устранить разрастание базы данных и снизить нагрузку на PostgreSQL, но привело к критическим способам сбоя: сетевые разделения между кластером Redis и базой данных могли позволить дублирующим вставкам, когда блокировка Redis истекала, но транзакция PostgreSQL еще не была зафиксирована. Более того, поддержание согласованности между двумя распределенными системами добавило операционную сложность и потребовало внедрения Redlock или аналогичных алгоритмов, увеличивая задержку примерно на 5 миллисекунд за операцию.
Выбранный дизайн использовал встроенные совещательные блокировки PostgreSQL через pg_advisory_xact_lock(hashtext(business_key)), получая блокировку, привязанную к транзакции, на хэшированный UUID перед попыткой вставить. Поскольку эти блокировки существуют только в общей памяти и не затрагивают кучу, они не налагают никаких затрат на хранение и автоматически освобождаются при завершении транзакции, предотвращая наблюдаемую утечку блокировки с блокировками уровня сессии. Чтобы избежать нез.detectable deadlocks, прикладной уровень сортировал все UUID в каждой партии по их хэшированному целочисленному значению перед получением блокировок, обеспечивая глобальный протокол упорядочивания среди всех параллельных рабочих.
Совещательные блокировки были выбраны, поскольку они обеспечивали наименьшую задержку (приобретение менее миллисекунды) и нулевые побочные эффекты на чтение, обеспечивая строгую корректность без внешних зависимостей. В отличие от подхода с Redis, срок действия блокировки был привязан к транзакции базы данных, гарантируя атомарность между получением блокировки и фиксацией вставки. В отличие от SELECT FOR UPDATE, не было создано разрастания таблиц, и в отличие от сырого ON CONFLICT, уникальный индекс никогда не испытывал стресса от конфликтующих параллельных вставок, поскольку сериализация происходила до доступа к кучи.
После развертывания pipeline ввода поддерживал 80,000 событий в секунду с p99 задержкой менее 10 миллисекунд, по сравнению с предыдущими скачками в 200 мс в пиковые моменты. Разрастание таблиц уменьшилось до незначительных уровней, позволяя autovacuum работать только в часы низкой нагрузки, а объем WAL снизился на 40%, значительно снизив затраты на архивное хранение и задержку реплики. Система поддерживала семантику точно один раз через множество перезагрузок базы данных и изменения пула соединений без единственного дублированного события или таймаута, вызванного взаимной блокировкой.
Почему использование pg_advisory_lock (с областью действия сессии) вместо pg_advisory_xact_lock рискует истощением пула соединений и дублированием ввода в архитектуре с высокой производительностью труда?
Кандидаты часто не осознают, что pg_advisory_lock сохраняется до явного разблокирования или разъединения сессии, даже если транзакция прервана. В пуленом окружении, где рабочие переиспользуют соединения с длительным сроком службы, логическая ошибка или исключение, которое обходит вызов разблокировки, оставляет блокировку удерживаемой неопределенно, вызывая ожидание других рабочих, обрабатывающих тот же бизнес-ключ. Вместо этого следует использовать pg_advisory_xact_lock, поскольку он привязывает срок действия блокировки к границе транзакции, обеспечивая автоматическое освобождение при ROLLBACK и предотвращая утечку мьютекса, которая иначе истощила бы пул рабочих и приостанавливала pipeline ввода.
Как отсутствие гарантии общего порядка при получении нескольких совещательных блокировок приводит к незаметным взаимным блокировкам, и какой конкретный паттерн приложения устраняет эту опасность?
В отличие от взаимных блокировок на уровне строк, которые обрабатываются детектором deadlock_timeout в PostgreSQL путем завершения жертвы транзакции, взаимные блокировки совещательных блокировок невидимы для движка, поскольку они происходят в определенных пользователем пространствах имен. Если Работник A блокирует ресурс X, а затем Y, в то время как Работник B блокирует Y, а затем X, обе сессии ожидают неопределенно без ошибки. Обязательный паттерн заключается в том, чтобы сортировать все идентификаторы ресурсов (например, значения hashtext(uuid)) в строгом монотонном порядке (по возрастанию или убыванию) по всему приложению перед выдачей любых запросов на блокировку. Это глобальное упорядочение обеспечивает то, что графы ожидания остаются ациклическими, делая круговые зависимости невозможными и устраняя риск незаметных зависаний.
Какое ограничение общей памяти ограничивает количество совещательных блокировок, которые одна транзакция может удерживать, и как превышение max_locks_per_transaction проявляется по сравнению с истощением блокировок на уровне строк?
Многие кандидаты предполагают, что совещательные блокировки бесконечны, но они потребляют записи в общей таблице блокировок, управляющей параметром конфигурации max_locks_per_transaction (по умолчанию 64). Удерживание более блокировок, чем этот предел, в одной транзакции вызывает ERROR: out of shared memory (SQLSTATE 53200), немедленно прерывая транзакцию. Это контрастирует с блокировками на уровне строк, превышение которых обычно вызывает повышение блокировки или ожидания в зависимости от lock_timeout, но не истощает фиксированный пул общей памяти. Смягчение включает группировку операций в более мелкие под-транзакции или агрегацию нескольких логических ресурсов под одним ключом совещательной блокировки с помощью составного хэширования, а не попытку заблокировать тысячи отдельных ключей одновременно.