Postgres ОБНОВЛЕНИЕ ... LIMIT 1

78

У меня есть база данных Postgres, которая содержит подробную информацию о кластерах серверов, таких как состояние сервера («активный», «резервный» и т. Д.). Активным серверам в любой момент может потребоваться переключение на резервный режим, и мне все равно, какой резервный режим используется в частности.

Я хочу, чтобы запрос к базе данных изменил статус резервного - ТОЛЬКО ОДИН - и вернул IP-адрес сервера, который должен быть использован. Выбор может быть произвольным: поскольку состояние сервера изменяется с запросом, не имеет значения, какой резервный режим выбран.

Можно ли ограничить мой запрос только одним обновлением?

Вот что у меня так далеко:

UPDATE server_info SET status = 'active' 
WHERE status = 'standby' [[LIMIT 1???]] 
RETURNING server_ip;

Постгресу это не нравится. Что я мог сделать по-другому?

vastlysuperiorman
источник
Просто выберите сервер в коде и добавьте его в качестве ограничения. Это также позволяет вам сначала проверять дополнительные условия (самые старые, самые новые, самые последние в живых, наименее загруженные, тот же постоянный ток, разные стойки, наименьшие ошибки). Большинство протоколов отработки отказа в любом случае требуют определенной формы детерминизма.
Eckes
@eckes Это интересная идея. В моем случае «выбор сервера в коде» означал бы сначала чтение списка доступных серверов из БД, а затем обновление записи. Поскольку многие экземпляры приложения могут выполнять это действие, возникает условие гонки, и требуется атомарная операция (или была 5 лет назад). Выбор не должен быть детерминированным.
vastlysuperiorman

Ответы:

126

Без одновременного доступа к записи

Материализация выбора в CTE и присоединение к нему в FROMпредложении UPDATE.

WITH cte AS (
   SELECT server_ip          -- pk column or any (set of) unique column(s)
   FROM   server_info
   WHERE  status = 'standby'
   LIMIT  1                  -- arbitrary pick (cheapest)
   )
UPDATE server_info s
SET    status = 'active' 
FROM   cte
WHERE  s.server_ip = cte.server_ip
RETURNING server_ip;

Изначально у меня был простой подзапрос, но он может обойтись без LIMITопределенных планов запросов, как указал Фейке :

Планировщик может выбрать создание плана, который выполняет вложенный цикл над LIMITingподзапросом, что приводит к более UPDATEsчем LIMIT, например:

 Update on buganalysis [...] rows=5
   ->  Nested Loop
         ->  Seq Scan on buganalysis
         ->  Subquery Scan on sub [...] loops=11
               ->  Limit [...] rows=2
                     ->  LockRows
                           ->  Sort
                                 ->  Seq Scan on buganalysis

Воспроизведение тестового примера

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

Или используйте слабо коррелированный подзапрос для простого случая сLIMIT 1. Проще, быстрее:

UPDATE server_info
SET    status = 'active' 
WHERE  server_ip = (
         SELECT server_ip
         FROM   server_info
         WHERE  status = 'standby'
         LIMIT  1
         )
RETURNING server_ip;

С одновременным доступом к записи

Предполагая уровень изоляции по умолчаниюREAD COMMITTED для всего этого. Более строгие уровни изоляции ( REPEATABLE READи SERIALIZABLE) могут по-прежнему приводить к ошибкам сериализации. Видеть:

При одновременной загрузке записи добавьте FOR UPDATE SKIP LOCKEDблокировку строки, чтобы избежать условий гонки. SKIP LOCKEDбыл добавлен в Postgres 9.5 , более старые версии см. ниже. Руководство:

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

UPDATE server_info
SET    status = 'active' 
WHERE  server_ip = (
         SELECT server_ip
         FROM   server_info
         WHERE  status = 'standby'
         LIMIT  1
         FOR    UPDATE SKIP LOCKED
         )
RETURNING server_ip;

Если не осталось соответствующей незаблокированной строки, в этом запросе ничего не происходит (строка не обновляется), и вы получите пустой результат. Для некритических операций это означает, что вы сделали.

Тем не менее, параллельные транзакции могут иметь заблокированные строки, но затем не завершить обновление ( ROLLBACKили по другим причинам). Чтобы быть уверенным, запустите финальную проверку:

SELECT NOT EXISTS (
   SELECT 1
   FROM   server_info
   WHERE  status = 'standby'
   );

SELECTтакже видит заблокированные строки. Если не возвращается true, одна или несколько строк все еще обрабатываются, и транзакции могут быть откатаны. (Или тем временем были добавлены новые строки.) Подождите немного, затем выполните цикл двух шагов: ( UPDATEпока вы не вернете строку; SELECT...), пока не получите true.

Связанный:

Без SKIP LOCKEDв PostgreSQL 9.4 или старше

UPDATE server_info
SET    status = 'active' 
WHERE  server_ip = (
         SELECT server_ip
         FROM   server_info
         WHERE  status = 'standby'
         LIMIT  1
         FOR    UPDATE
         )
RETURNING server_ip;

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

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

Если первый зафиксирован, WHEREусловие переоценивается, и если оно TRUEбольше statusне изменяется ( изменилось), CTE (несколько неожиданно) не возвращает строки. Ничего не произошло. Это желаемое поведение, когда все транзакции хотят обновить одну и ту же строку .
Но не тогда , когда каждая транзакция хочет обновить на следующую строку . А поскольку мы просто хотим обновить произвольную (или случайную ) строку , ждать вообще не имеет смысла.

Мы можем разблокировать ситуацию с помощью консультативных блокировок :

UPDATE server_info
SET    status = 'active' 
WHERE  server_ip = (
         SELECT server_ip
         FROM   server_info
         WHERE  status = 'standby'
         AND    pg_try_advisory_xact_lock(id)
         LIMIT  1
         FOR    UPDATE
         )
RETURNING server_ip;

Таким образом, следующая не заблокированная строка будет обновлена. Каждая транзакция получает новую строку для работы. Мне помогала чешская Postgres Wiki для этого трюка.

idбыть любым уникальным bigintстолбцом (или любым типом с неявным приведением типа int4или int2).

Если консультативные блокировки используются для нескольких таблиц в вашей базе данных одновременно, устраните неоднозначность с pg_try_advisory_xact_lock(tableoid::int, id)- idбыть уникальным integerздесь.
Поскольку tableoidэто bigintколичество, оно теоретически может переполниться integer. Если вы достаточно параноик, используйте (tableoid::bigint % 2147483648)::intвместо этого - оставив теоретическое «столкновение хешей» для действительно параноиков ...

Кроме того, Postgres имеет право тестировать WHEREусловия в любом порядке. Он может тестировать pg_try_advisory_xact_lock()и получать блокировку раньше status = 'standby' , что может привести к дополнительным консультативным блокировкам на несвязанных строках, где status = 'standby'это не так. Связанный вопрос по SO:

Как правило, вы можете просто игнорировать это. Чтобы гарантировать блокировку только подходящих строк, вы можете вложить предикаты в CTE, как описано выше, или подзапрос с OFFSET 0хаком (предотвращает встраивание) . Пример:

Или (дешевле для последовательных сканирований) вложите условия в CASEутверждение вроде:

WHERE  CASE WHEN status = 'standby' THEN pg_try_advisory_xact_lock(id) END

ОднакоCASE трюк будет также держать Postgres использовать индекс на status. Если такой индекс доступен, для начала вам не нужно дополнительное вложение: в проверке индекса будут блокироваться только подходящие строки.

Поскольку вы не можете быть уверены, что индекс используется в каждом вызове, вы можете просто:

WHERE  status = 'standby'
AND    CASE WHEN status = 'standby' THEN pg_try_advisory_xact_lock(id) END

CASEЛогически лишний, но это сервера обсуждаемая цели.

Если команда является частью длинной транзакции, рассмотрите блокировки на уровне сеанса, которые могут быть (и должны быть) сняты вручную. Таким образом, вы можете разблокировать, как только закончите с заблокированным рядом: pg_try_advisory_lock()иpg_advisory_unlock() . Руководство:

После получения на уровне сеанса консультативная блокировка удерживается до тех пор, пока явно не будет снята или сеанс не завершится.

Связанный:

Эрвин Брандштеттер
источник