Атомарное ОБНОВЛЕНИЕ .. ВЫБРАТЬ в Postgres

Я создаю своего рода механизм очередей. Есть строки данных, которые нужно обработать, и флаг состояния. Я использую предложение update .. returning для управления этим:

UPDATE stuff
SET computed = 'working'
WHERE id = (SELECT id from STUFF WHERE computed IS NULL LIMIT 1)
RETURNING * 

Является ли вложенная часть выбора такой же блокировкой, как и обновление, или у меня здесь состояние гонки? Если да, то должен ли внутренний выбор быть select for update?


person kolosy    schedule 18.07.2012    source источник
comment
Если вы пытаетесь создать очередь сообщений в sql, и это достаточно большой объем задач, я предполагаю, что вы в конечном итоге захотите удалить строки для завершенных заданий. Они наполнят ваши индексы, поэтому не забывайте проводить вакуумный анализ всякий раз, когда вы очищаете завершенные задачи, иначе ваша производительность упадет. Возможно, вы захотите просто использовать фактическую очередь сообщений (rabbitmq, zeromq, activemq и т. Д.).   -  person nairbv    schedule 30.06.2014


Ответы (2)


Хотя предложение Эрвина, возможно, является простейшим способом добиться правильного поведения (при условии, что вы повторно попытаетесь выполнить транзакцию, если вы получите исключение с SQLSTATE из 40001), приложения с очередями по своей природе, как правило, лучше работают с блокировкой запросов. для возможности занять очередь в очереди, чем с реализацией SERIALIZABLE транзакций PostgreSQL, которая допускает более высокий уровень параллелизма и несколько более «оптимистична» в отношении шансов столкновения.

Пример запроса в вопросе в его нынешнем виде на уровне изоляции транзакции READ COMMITTED по умолчанию позволит двум (или более) одновременным соединениям для обоих «требовать» одну и ту же строку из очереди. Что произойдет, вот что:

  • T1 запускается и доходит до блокировки строки в фазе UPDATE.
  • T2 перекрывает T1 по времени выполнения и пытается обновить эту строку. Он блокирует ожидающие COMMIT или ROLLBACK T1.
  • T1 фиксируется, успешно «заявив» строку.
  • T2 пытается обновить строку, обнаруживает, что T1 уже есть, ищет новую версию строки, обнаруживает, что она по-прежнему удовлетворяет критериям выбора (это просто совпадения id), а также "заявляет" строку.

Его можно изменить для правильной работы (если вы используете версию PostgreSQL, которая позволяет использовать предложение FOR UPDATE в подзапросе). Просто добавьте FOR UPDATE в конец подзапроса, который выбирает идентификатор, и это произойдет:

  • T1 запускается и теперь блокирует строку перед выбором идентификатора.
  • T2 перекрывает T1 по времени выполнения и блокируется при попытке выбрать идентификатор, ожидая COMMIT или ROLLBACK T1.
  • T1 фиксируется, успешно «заявив» строку.
  • К тому времени, когда T2 сможет прочитать строку, чтобы увидеть идентификатор, он увидит, что он был заявлен, и найдет следующий доступный идентификатор.

На уровне изоляции транзакции REPEATABLE READ или SERIALIZABLE конфликт записи вызовет ошибку, которую вы можете поймать и определить, что это сбой сериализации на основе SQLSTATE, и повторить попытку.

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

person kgrittn    schedule 19.07.2012
comment
Может ли добавление избыточного предложения WHERE AND computed IS NULL к внешнему UPDATE этот конкретный запрос вести себя должным образом? Или T2 просто окажется пустым после того, как T1 аннулирует выбранную строку на полпути к операции? - person Erwin Brandstetter; 20.07.2012
comment
В READ COMMITTED это предотвратило бы двойное присвоение, но в случае перекрывающихся транзакций вернуло бы пустой набор результатов, даже если бы были доступны другие строки. На более строгих уровнях изоляции это не будет иметь никакого значения, но в любом случае может быть хорошим тоном. - person kgrittn; 20.07.2012
comment
Еще раз спасибо. На основе этого я разработал альтернативное решение. - person Erwin Brandstetter; 20.07.2012
comment
Обратите внимание, что приложения, использующие эту стратегию, должны иметь возможность обнаруживать, когда кто-то запросил строку, а затем отказал или иным образом не смог выполнить работу. - person Craig Ringer; 22.10.2014
comment
@CraigRinger хорошо замечает. Там, где мне нужно было сделать что-то похожее на это, я включил столбцы для идентификатора who потребовал строку и when. Это позволяет удалить оставленные претензии и проанализировать причину. - person kgrittn; 08.11.2014
comment
@kgrittn Не могли бы вы пояснить ответ и написать полученный запрос? Вы говорите: просто добавьте это в подзапрос, который выбирает идентификатор, и я понятия не имею, что это такое. Код легче понять, чем говорить. Спасибо! - person Nowaker; 26.06.2016
comment
@Nowaker, я переформулировал, а не привел пример, который был почти точно таким же, как оригинал - мне казалось, что люди потратят слишком много времени на то, чтобы посмотреть, в чем разница. Субъективно, я знаю, но мне это показалось чище. - person kgrittn; 04.08.2016
comment
@kgrittn нужно ли заключить инструкцию в BEGIN ... COMMIT, чтобы удерживать блокировку FOR UPDATE? есть некоторые сообщения о том, что иначе он не работает: github.com/collectiveidea/delayed_job_active_record/pull/79 - person jtomson; 20.12.2016
comment
@jtomson, большинство блокировок снимается в конце транзакции, которая их захватывает (за исключением некоторых рекомендательных блокировок). Без блока _1 _ / _ 2_ (или эквивалента, использующего другой синтаксис или включающую функцию) каждый оператор создает свою собственную транзакцию, которая автоматически фиксируется или откатывается после завершения оператора. - person kgrittn; 05.03.2017
comment
@kgrittn, когда вы говорите, что на уровне изоляции транзакции REPEATABLE READ или SERIALIZABLE конфликт записи вызовет ошибку, вы имеете в виду, что возникнет конфликт записи , если не используется FOR UPDATE, верно? - person Andy; 16.09.2018
comment
@Andy FOR UPDATE сам по себе не предотвратит сбой сериализации, но если вы воспользуетесь примером Эрвина (для 9.5 и новее), который использует FOR UPDATE SKIP LOCKED с уровнем изоляции REPEATABLE READ, то вы получите именно то, что хотите, без сбоя сериализации. Есть некоторые идеи, чтобы получить аналогичную производительность для SERIALIZABLE транзакций, если обновление не имеет предложения ORDER BY и включает LIMIT, но это пока только разговоры. - person kgrittn; 21.09.2018

Если вы единственный пользователь, запрос будет в порядке. В частности, внутри самого запроса (между внешним запросом и подзапросом) нет состояния гонки или взаимоблокировки. Я цитирую руководство здесь:

Однако транзакция никогда не конфликтует сама с собой.

Для одновременного использования вопрос может быть более сложным. Вы будете в безопасности, выбрав SERIALIZABLE режим транзакции :

BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
RETURNING * 
COMMIT;

Вам необходимо подготовиться к сбоям сериализации и повторить запрос в таком случае.

Но я не совсем уверен, не перебор ли это. Я попрошу @kgrittn зайти .. он эксперт в параллелизме и сериализуемых транзакциях ..

И он это сделал. :)


Лучшее из обоих миров

Выполните запрос в режиме транзакции по умолчанию READ COMMITTED.

Для Postgres 9.5 или новее используйте FOR UPDATE SKIP LOCKED. Видеть:

Для более старых версий перепроверьте условие computed IS NULL явно во внешнем UPDATE:

UPDATE stuff
SET    computed = 'working'
WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
AND   computed IS NULL;

Как сообщил @kgrittn в комментарии к своему ответу, этот запрос может оказаться пустым, ничего не сделав, в (маловероятном) случае, если он будет связан с параллельной транзакцией.

Следовательно, он будет работать так же, как первый вариант в режиме транзакции SERIALIZABLE, вам придется повторить попытку - только без потери производительности.

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

Если это не имеет значения (как в вашем случае), на этом все готово.
Если это так, для полной уверенности начните еще один запрос с явная блокировка после получения пустой результат. Если он окажется пустым, все готово. Если нет, продолжайте.
В plpgsql это могло бы выглядеть так:

LOOP
   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE SKIP LOCKED);  -- pg 9.5+
   -- WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL LIMIT 1)
   -- AND    computed IS NULL; -- pg 9.4-

   CONTINUE WHEN FOUND;  -- continue outside loop, may be a nested loop

   UPDATE stuff
   SET    computed = 'working'
   WHERE  id = (SELECT id FROM stuff WHERE computed IS NULL
                LIMIT 1 FOR UPDATE);

   EXIT WHEN NOT FOUND;  -- exit function (end)
END LOOP;

Это должно дать вам лучшее из обоих миров: производительность и надежность.

person Erwin Brandstetter    schedule 18.07.2012
comment
SERIALIZABLE транзакции действительно вызовут правильное поведение, но отзывы о новой реализации его реализации в PostgreSQL 9.1 (и более поздних версиях) предполагают, что приложения с очередями - это наихудший сценарий. Хотя этот метод (вызов Serializable Snapshot Isolation или SSI) намного быстрее, чем использование блокирующих блокировок для большинства рабочих нагрузок, у меня есть отчет о 20% снижении производительности для одного конкретного приложения очередей, и репортер (основной участник PostgreSQL) смог намеренно хуже инженера. Итак, вы можете попробовать это, но может оказаться, что явная блокировка работает лучше. - person kgrittn; 20.07.2012
comment
Я не решаюсь делать какие-либо заявления о производительности без ее фактического тестирования, но я бы ожидал незначительно лучшей производительности (вероятно, достаточно малой, чтобы ее было трудно измерить), просто начав с UPDATE во втором блоке. Есть оптимизации для повторной блокировки соединения с уже заблокированной строкой, и я не думаю, что оно заблокируется, если только вы не окажетесь в ситуации, когда вам все равно понадобится повторная попытка. - person kgrittn; 20.07.2012
comment
в моем конкретном случае в этом нет необходимости. когда процесс достигает конца очереди (насколько это возможно), он засыпает 10 минут и пытается снова. если запись попадает в следующее окно, это не проблема. спасибо за компиляцию. - person kolosy; 20.07.2012
comment
@kolosy: Простая версия с повторяющимся AND computed IS NULL во внешнем запросе должна быть оптимальным решением для такого случая. - person Erwin Brandstetter; 20.07.2012