Распространённые ошибки параллельного доступа

Предпосылки: уровни изоляции, блокировки, паттерны. Примеры используют Ruby/ActiveRecord — синтаксис интуитивно понятен, но для полного понимания полезно знание основ SQL.

Практические паттерны | Очереди задач

Код прошёл code review, транзакции на месте, блокировки расставлены — и всё равно в production теряются данные, сервис зависает под нагрузкой или таблицы разбухают за неделю. Каждая проблема ниже — следствие конкретного заблуждения о том, как взаимодействуют MVCC, блокировки и уровни изоляции.

FOR UPDATE «на всякий случай»

Разработчик узнал про lost update и решил перестраховаться: добавил FOR UPDATE везде, где читает данные — даже если потом их не изменяет.

User.lock("FOR UPDATE").find(5)  # просто показываем профиль

FOR UPDATE — блокировка строки до конца транзакции. Другие транзакции, которые хотят изменить эту строку, будут ждать. Если данные не меняются — блокировка держит очередь без причины. При 100 запросах в секунду к одному профилю — это 100 транзакций, сериализованных вместо параллельных: рост p99 latency и давление на пул соединений.

SERIALIZABLE без retry

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

ActiveRecord::Base.transaction(isolation: :serializable) do
  account = Account.find(5)
  account.update(balance: account.balance - 100)
end
# SerializationFailure не обработан!

REPEATABLE READ и SERIALIZABLE работают по оптимистичному принципу: они не предотвращают конфликты, а обнаруживают их. При конфликте транзакция откатывается с ошибкой 40001 (serialization_failure). Если приложение не ловит эту ошибку и не повторяет — операция тихо пропадает. Деньги не списались, но код продолжает, как будто всё прошло. В логах ошибки нет — транзакция откатилась корректно с точки зрения PostgreSQL, но приложение этого не заметило.

Непроверённый affected rows

Retry-логика усложняет код. Атомарный SQL на READ COMMITTED не требует retry — PostgreSQL сам перечитывает строку при конфликте. Но у этого подхода свой подводный камень.

User.where("id = 5 AND balance >= 100").update_all("balance = balance - 100")
# Не проверили, сколько строк обновилось!

При конфликте PostgreSQL на READ COMMITTED перечитает строку и проверит WHERE заново. Если balance уже < 100 — UPDATE завершится успешно, но обновит 0 строк. Деньги не списались, а код продолжает обработку заказа, как будто оплата прошла. Проверка affected_rows == 0 — обязательна.

Блокировки в разном порядке

Предыдущие три ошибки касались одной строки. Когда операция затрагивает несколько строк — появляется новый класс проблем. Сервис обрабатывает переводы между аккаунтами. Один воркер переводит от Alice к Bob (блокирует Alice, потом Bob), другой — от Bob к Alice (блокирует Bob, потом Alice).

T1: lock(alice), lock(bob)
T2: lock(bob), lock(alice)  → DEADLOCK

Каждый UPDATE неявно берёт блокировку строки. Если две транзакции обновляют одни и те же строки в разном порядке — deadlock неизбежен при достаточной нагрузке. PostgreSQL обнаружит цикл через deadlock_timeout (по умолчанию 1 секунда) и откатит одну транзакцию — это потерянная работа и непредсказуемый скачок latency. Решение: сортировать ресурсы перед блокировкой, например по id — всегда сначала меньший.

REPEATABLE READ ≠ эксклюзивный доступ

Deadlock — следствие блокировок. Может показаться, что snapshot-изоляция избавляет от проблем с блокировками, но это заблуждение.

transaction(isolation: :repeatable_read) do
  user = User.find(5)
  sleep(10)  # другая транзакция меняет user и коммитит
  user.update(...)  # → SerializationFailure
end

REPEATABLE READ не блокирует данные. Другие транзакции свободно читают и пишут. REPEATABLE READ гарантирует согласованный snapshot и ошибку при попытке записать в строку, изменённую после этого snapshot.

Это обнаружение конфликта, не предотвращение. Для эксклюзивного доступа нужен FOR UPDATE.

Долгая транзакция на REPEATABLE READ

REPEATABLE READ держит snapshot на всю транзакцию. Для коротких транзакций это незаметно. Для длинных — последствия выходят за пределы самой транзакции.

transaction(isolation: :repeatable_read) do
  generate_huge_report  # 30 минут
end

VACUUM удаляет dead tuples — старые версии строк. Но он не может удалить версию, если хоть одна транзакция ещё может её видеть через свой snapshot.

Транзакция на REPEATABLE READ держит snapshot 30 минут. Все версии строк, существовавшие на момент старта, должны оставаться — вдруг транзакция их прочитает. VACUUM видит: «есть активный snapshot от 30 минут назад» — и не трогает старые версии.

За 30 минут накопились тысячи dead tuples. VACUUM не может их удалить. Таблица разбухает (bloat). После завершения транзакции VACUUM уберёт мусор, но таблица уже выросла — место не освобождается автоматически.

Для длинных read-only операций лучше READ COMMITTED: каждый оператор создаёт свежий snapshot, старые версии не держатся. Или выносить аналитику на реплику.

Долгий SELECT блокирует миграцию

Длинные транзакции мешают не только VACUUM — они держат табличные блокировки, которые каскадно блокируют DDL и все последующие запросы. Дежурный инженер запускает миграцию, а на таблице работает длинный аналитический SELECT.

SELECT * FROM huge_table;  -- 10 минут, держит ACCESS SHARE
ALTER TABLE huge_table ADD COLUMN ...;  -- требует ACCESS EXCLUSIVE, ждёт

ACCESS SHARE и ACCESS EXCLUSIVE несовместимы — миграция ждёт.

Но проблема глубже: ALTER TABLE в ожидании блокирует все последующие запросы к таблице — даже SELECT. PostgreSQL ставит их в очередь за ALTER TABLE. Десять минут ожидания → десять минут, когда приложение не может читать таблицу. Один медленный запрос и одна миграция превращаются в полную недоступность таблицы.

Решения: SET lock_timeout = '5s' на DDL-операциях (миграция откатится, а не заблокирует всё), CREATE INDEX CONCURRENTLY вместо CREATE INDEX, вынос аналитики на реплику, idle_in_transaction_session_timeout для защиты от зависших транзакций.

Ошибки параллельного доступа проявляются в рантайме. Но есть и более фундаментальная проблема: каждый UPDATE создаёт dead tuples, и без регулярного VACUUM таблица разбухает.

Sources


Практические паттерны | Очереди задач