Базы данных

Атомарное добавление в JSONB массив с UPDATE ... RETURNING в PostgreSQL

Поведение UPDATE ... RETURNING при параллельных операциях с JSONB массивами в PostgreSQL. Гарантии атомарности и блокировки строк.

5 ответов 1 просмотр

Атомарное добавление в массив JSONB в PostgreSQL с UPDATE … RETURNING – могут ли параллельные обновления появиться в возвращаемой строке?

Я реализую атомарную операцию добавления в столбец массива jsonb в PostgreSQL и хочу понять поведение параллелизма UPDATE ... RETURNING.

В моей таблице есть:

sql
"Thumbnails" jsonb NOT NULL DEFAULT '[]'::jsonb

Пример содержимого:

json
[
 {"Url": "thumb1"},
 {"Url": "thumb2"}
]

Я добавляю новый миниатюрный образец с помощью:

sql
UPDATE "Media"
SET "Thumbnails" = "Thumbnails" || $1::jsonb
WHERE "ContainerName" = $2 AND "BlobName" = $3
RETURNING *

Где $1 - это JSON-объект, например:

json
{"Url":"thumb3"}

Это вызывается из EF Core с использованием FromSqlInterpolated, и возвращаемая строка отображается обратно в сущность.

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

Запрос A: добавление {"Url":"thumb1"}
Запрос B: добавление {"Url":"thumb2"}

Я ожидаю, что блокировка строк PostgreSQL сериализует их:

  1. A получает блокировку строки
  2. A обновляет [][thumb1]
  3. A возвращает [thumb1]
  4. B ожидает
  5. B обновляет [thumb1][thumb1, thumb2]
  6. B возвращает [thumb1, thumb2]

Мой главный вопрос:

Может ли результат RETURNING запроса A уже включать миниатюрный образец, добавленный запросом B (например, [thumb1, thumb2])?

Другими словами, всегда ли RETURNING отражает состояние строки сразу после выполнения этого конкретного оператора, или он может включать изменения из другой транзакции, выполненной во время выполнения?

Я провел стресс-тесты с ~1000 одновременными запросами, и первый запрос всегда возвращал только свой собственный миниатюрный образец. Я хочу знать, гарантировано ли это PostgreSQL, или просто результатом тайминга.

Атомарное добавление в массив JSONB с помощью UPDATE ... RETURNING в PostgreSQL гарантирует, что возвращаемая строка всегда будет отражать состояние после выполнения конкретного оператора обновления, без включения изменений из параллельных транзакций. Блокировка строк и механизм MVCC (Multi-Version Concurrency Control) обеспечивают сериализацию одновременных обновлений одной и той же строки, предотвращая “утечку” изменений из других запросов в результат RETURNING. Ваш стресс-тест с ~1000 одновременными запросами подтверждает это поведение, которое является фундаментальной гарантией PostgreSQL, а не случайным результатом тайминга.


Содержание


Принцип работы UPDATE … RETURNING в PostgreSQL

Оператор UPDATE ... RETURNING в PostgreSQL возвращает значения столбцов после выполнения конкретного оператора обновления в рамках текущей транзакции. Это ключевое отличие от некоторых других СУБД, где результат может включать изменения из параллельных транзакций. В PostgreSQL RETURNING всегда отражает состояние строки непосредственно после выполнения вашего UPDATE, без учёта изменений, которые могут вноситься другими транзакциями одновременно.

Когда вы выполняете UPDATE ... RETURNING, PostgreSQL создает новую версию строки с вашими изменениями и возвращает именно эти новые значения. Никакие другие параллельные транзакции не могут изменить эту версию строки до завершения вашей транзакции. Это гарантирует, что результат RETURNING будет содержать только изменения, сделанные вашим конкретным оператором обновления.

В вашем случае с добавлением миниатюр в массив JSONB, оператор UPDATE ... RETURNING вернет массив с добавленным именно вашим запросом элементом, без учёта миниатюр, которые добавляются параллельными запросами к той же строке.


Атомарное добавление элементов в массив JSONB

Работа с массивами JSONB в PostgreSQL имеет несколько важных особенностей, особенно в контексте параллельных операций. Ваш оператор UPDATE "Media" SET "Thumbnails" = "Thumbnails" || $1::jsonb WHERE "ContainerName" = $2 AND "BlobName" = $3 RETURNING * является атомарным для конкретной строки, которую он обновляет.

Оператор || для JSONB массивов в PostgreSQL является атомарным в рамках одной транзакции. Когда вы добавляете новый элемент в массив JSONB, PostgreSQL создает новую версию строки с обновленным массивом. Важно понимать, что операция || выполняется полностью до возврата управления из оператора UPDATE, поэтому RETURNING всегда увидит массив с добавленным элементом.

Вот как это работает на уровне PostgreSQL:

  1. PostgreSQL создает новую версию строки
  2. Выполняется операция || (конкатенация массивов)
  3. Новая версия строки записывается
  4. RETURNING возвращает значения из этой новой версии

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


Параллельные обновления и блокировка строк

Самый важный аспект вашего вопроса касается параллельных обновлений. Когда вы выполняете UPDATE в PostgreSQL, он автоматически захватывает RowExclusiveLock на целевой строке. Эта блокировка предотвращает одновременные обновления одной и той же строки другими транзакциями.

Вот пошагово, что произойдет с вашими параллельными запросами:

Запрос A: добавление {"Url":"thumb1"}

  1. A получает блокировку строки (RowExclusiveLock)
  2. A выполняет SET "Thumbnails" = "Thumbnails" || '{"Url":"thumb1"}'
  3. A создает новую версию строки: [{"Url":"thumb1"}]
  4. A возвращает эту новую версию через RETURNING *
  5. A продолжает работу (может выполнить коммит или откат)

Запрос B: добавление {"Url":"thumb2"}

  1. B пытается получить блокировку строки, но она уже захвачена A
  2. B ожидает освобождения блокировки
  3. После завершения A, B получает блокировку
  4. B выполняет SET "Thumbnails" = "Thumbnails" || '{"Url":"thumb2"}' на основе версии, которая была актуальной на момент захвата блокировки
  5. B создает новую версию строки: [{"Url":"thumb1"}, {"Url":"thumb2"}]
  6. B возвращает эту новую версию через RETURNING *

Ключевой момент: никогда не произойдет ситуация, когда результат A включит изменения из B. Каждый запрос видит и изменяет только ту версию строки, которая актуальна на момент его выполнения, и RETURNING всегда возвращает состояние после выполнения конкретного оператора UPDATE.

Механизм MVCC (Multi-Version Concurrency Control) в PostgreSQL дополнительно гарантирует, что параллельные транзакции не могут видеть промежуточные состояния данных. Каждая операция UPDATE создает новую версию строки, и другие транзакции работают либо со старой версией, либо с новой, но никогда не с “промежуточным” состоянием.


Уровни изоляции транзакций и их влияние на RETURNING

PostgreSQL поддерживает несколько уровней изоляции транзакций, но даже при самом низком уровне (“Read Committed”) поведение RETURNING остается одинаковым. Это связано с тем, что RETURNING является частью оператора UPDATE, а не отдельным оператором чтения.

Давайте рассмотрим, как разные уровни изоляции могут влиять на ваши операции:

Read Committed (по умолчанию):

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

Repeatable Read:

  • Повторяющиеся операторы чтения в одной транзакции видят одинаковые данные
  • Операторы UPDATE все равно создают новые версии строк, и RETURNING будет работать аналогично

Serializable:

  • Самый строгий уровень изоляции, предотвращая аномалии сериализации
  • Но не влияет на базовое поведение RETURNING, которое и так сериализовано на уровне строк

Важно понимать, что RETURNING работает несколько иначе, чем обычные операторы чтения. Он возвращает данные из новой версии строки, созданной текущим оператором UPDATE, поэтому его поведение предсказуемо и не зависит от уровня изоляции транзакции.


Практические рекомендации по работе с параллельными обновлениями

Основываясь на поведении PostgreSQL при параллельных обновлениях, вот несколько практических рекомендаций для вашей реализации атомарного добавления в массив JSONB:

  1. Доверяйте механизму блокировок: Не реализуйте дополнительные механизмы контроля параллелизма, если только у вас нет специфических требований. PostgreSQL уже обеспечивает необходимую сериализацию операций.

  2. Используйте RETURNING для получения обновленных данных: Ваш подход с RETURNING * является оптимальным для получения обновленной строки сразу после операции.

  3. Рассмотрите использование upsert-операций: Если у вас могут быть конфликты при вставке новых элементов, подумайте о более сложной логике с использованием INSERT ... ON CONFLICT.

  4. Мониторируйте производительность: При очень высокой параллельности (тысячи запросов в секунду) возможно возникновение конкуренции за блокировки. В таких случаях может потребоваться оптимизация схемы или приложения.

  5. Используйте индексы для WHERE-условий: Убедитесь, что по полям, используемым в WHERE (в вашем случае ContainerName и BlobName), есть индексы для минимизации времени блокировки.

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

  • Использовании отдельной таблицы для миниатюр с отношением “один-ко-многим”
  • Пакетной обработке обновлений
  • Использовании специализированных структур данных, если это оправдано бизнес-логикой
  1. Тестируйте сценарии высокой параллельности: Ваш стресс-тест с ~1000 запросами — хорошая практика. Продолжайте такие тесты, особенно при изменении схемы или логики приложения.

  2. Обрабатывайте возможные исключения: Хотя блокировки должны предотвращать конфликты, всегда пишите код, готовый к обработке исключений, связанных с параллелизмом (хотя в вашем случае это маловероятно).


Источники

  1. PostgreSQL Documentation - UPDATE — Официальная документация по оператору UPDATE с описанием RETURNING: https://www.postgresql.org/docs/current/sql-update.html
  2. PostgreSQL Documentation - Explicit Locking — Информация о механизме блокировки строк при UPDATE: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
  3. PostgreSQL Documentation - MVCC — Принципы работы Multi-Version Concurrency Control в PostgreSQL: https://www.postgresql.org/docs/current/mvcc.html
  4. PostgreSQL Documentation - Transaction Isolation — Уровни изоляции транзакций и их влияние на параллельные операции: https://www.postgresql.org/docs/current/transaction-iso.html

Заключение

Ваше понимание поведения UPDATE ... RETURNING в PostgreSQL абсолютно верно. Оператор RETURNING всегда будет возвращать состояние строки после выполнения конкретного оператора обновления, без включения изменений из параллельных транзакций. Это гарантируется механизмом блокировки строк и MVCC (Multi-Version Concurrency Control) в PostgreSQL.

Ваш стресс-тест с ~1000 одновременными запросами подтверждает это поведение, но важно понимать, что это не просто удачное совпадение тайминга, а фундаментальная гарантия системы. PostgreSQL специально спроектирован так, чтобы предотвращать подобные “утечки” данных между параллельными транзакциями.

В вашем сценарии с атомарным добавлением в массив JSONB можно полностью полагаться на то, что каждый запрос получит в RETURNING только те изменения, которые он сам внёс. Механизм блокировки гарантирует, что два запроса не смогут одновременно изменять одну и ту же строку, и каждый получит ожидаемый результат в правильной последовательности.

Таким образом, ваша реализация является корректной и надежной для работы с параллельными обновлениями массивов JSONB в PostgreSQL.

P

Оператор RETURNING в PostgreSQL возвращает значения столбцов после выполнения конкретного оператора UPDATE в рамках текущей транзакции. Это означает, что результат RETURNING не будет содержать изменения, внесённые другими транзакциями, которые выполняются параллельно, пока текущий UPDATE не завершится. В документации указано, что RETURNING возвращает новые (post-update) значения, вычисленные после выполнения UPDATE, что гарантирует, что возвращаемые данные отражают состояние строки после выполнения конкретного оператора.

P

UPDATE … RETURNING возвращает состояние строки после выполнения конкретного оператора UPDATE. В документации указано, что UPDATE (а также DELETE, INSERT, MERGE) захватывает RowExclusiveLock на целевой таблице. Row-level блокировка блокирует другие concurrent-updates той же строки до завершения транзакции, поэтому второй запрос не может изменить строку, пока первый не завершится. Следовательно, результат RETURNING всегда отражает состояние строки после выполнения текущего UPDATE и не содержит изменений, внесённых параллельной транзакцией. Таким образом, параллельные обновления не «появятся» в возвращаемой строке.

P

В документации PostgreSQL рассматривается модель изоляции транзакций, но не конкретный сценарий с RETURNING. Однако, общие принципы MVCC (Multi-Version Concurrency Control) гарантируют, что каждая транзакция видит последовательное состояние данных. Хотя документация не содержит прямого ответа на вопрос о том, может ли RETURNING включать изменения из параллельных транзакций, модель изоляции обеспечивает, что результат RETURNING будет отражать состояние данных после выполнения конкретного оператора UPDATE.

P

PostgreSQL использует модель MVCC (Multi-Version Concurrency Control), которая обеспечивает изоляцию транзакций без необходимости блокировки чтения. При выполнении UPDATE создается новая версия строки, и старая версия остается доступной для других транзакций. Это означает, что параллельные транзакции не могут видеть промежуточные состояния изменений. Оператор RETURNING возвращает значения из новой версии строки, созданной текущей транзакцией, что гарантирует, что возвращаемые данные не будут содержать изменения из других параллельных транзакций.

Авторы
Проверено модерацией
НейроОтветы
Модерация