Практические паттерны параллельного доступа

Предпосылки: уровни изоляции, блокировки, DML (UPDATE, DELETE).

Блокировки | Распространённые ошибки

Уровни изоляции и блокировки — это механизмы. Выбор конкретного механизма зависит от того, какую операцию выполняет приложение и какие данные должны остаться согласованными. Рассмотрим, как этот выбор работает на примере платёжного сервиса интернет-магазина.

Когда защита не нужна

Самый простой случай: записываемое значение не зависит от прочитанного. Администратор блокирует пользователя: UPDATE users SET status = 'blocked' WHERE id = 5. Даже если параллельная транзакция изменила другие поля этой строки, наш UPDATE просто запишет своё значение. READ COMMITTED (дефолт) достаточен — зависимости read → write нет.

Атомарный SQL: защита внутри одного оператора

Покупатель оплачивает заказ — нужно списать 100 рублей с баланса. Если вся логика помещается в один SQL-оператор, READ COMMITTED справляется:

UPDATE accounts SET balance = balance - 100 WHERE id = 5;

При конфликте PostgreSQL перечитывает строку и пересчитывает выражение с актуальным значением. Если balance был 200, параллельная транзакция изменила его на 150, PostgreSQL вычислит 150 - 100 = 50. Аналогично работают stock = stock - 1, likes_count = likes_count + 1 — любая формула внутри одного UPDATE.

Это самый простой и надёжный путь: нет retry-логики, нет явных блокировок, нет ошибок сериализации.

Read-Modify-Write в приложении: начало проблем

Но бизнес-логика редко помещается в один UPDATE. Допустим, перед списанием нужно проверить: хватает ли средств, не превышен ли лимит, нет ли ограничений на аккаунте. Разработчик выносит логику в код:

account = Account.find(5)              # SELECT → balance = 200
if account.balance >= 100 && !account.restricted?
  account.update(balance: account.balance - 100)  # UPDATE SET balance = 100
end

В тестах это работает. В production при 10 параллельных запросах к одному аккаунту оба прочитают balance = 200, оба вычислят 100, оба запишут 100. Вместо 0 на балансе осталось 100 — это lost update.

READ COMMITTED не спасёт: каждый оператор видит последнее закоммиченное значение, но между SELECT и UPDATE прошло время, и прочитанное значение устарело. Нужен один из двух подходов.

Пессимистичный подход: FOR UPDATE

Название «пессимистичный» — не оценка качества, а описание предположения: мы пессимистично предполагаем, что конфликт вероятен и защищаемся заранее, блокируя данные до начала работы. Цена платится всегда, даже если конфликта не было бы.

Блокируем строку при чтении — другие транзакции ждут в очереди:

ActiveRecord::Base.transaction do
  account = Account.lock("FOR UPDATE").find(5)  # блокировка до COMMIT
  if account.balance >= 100 && !account.restricted?
    account.update(balance: account.balance - 100)
  end
end

Каждая транзакция ждёт своей очереди, но каждая завершается с первой попытки. Нет retry-логики, результат предсказуем.

Оптимистичный подход: REPEATABLE READ + retry

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

Работаем со snapshot, не блокируя никого. Если при записи PostgreSQL обнаружит, что строка изменилась другой транзакцией (невидимой в нашем snapshot) — ошибка сериализации. Приложение ловит и повторяет:

def with_retry(isolation:, max_attempts: 3)
  attempts = 0
  begin
    ActiveRecord::Base.transaction(isolation: isolation) { yield }
  rescue ActiveRecord::SerializationFailure
    attempts += 1
    retry if attempts < max_attempts
    raise
  end
end
 
with_retry(isolation: :repeatable_read) do
  account = Account.find(5)
  account.update(balance: account.balance - 100) if account.balance >= 100
end

Транзакции не блокируют друг друга до момента записи. Цена — при конфликте вся работа транзакции теряется и повторяется. Retry с exponential backoff — общий паттерн надёжности; здесь он применяется к ошибкам сериализации.

Hot spot: когда подход критичен

Баланс популярного мерчанта. 100 транзакций в секунду списывают и начисляют деньги одному аккаунту.

При оптимистичном подходе: T1, T2, T3 одновременно читают balance=1000. T1 коммитит → balance=900. T2 получает ошибку сериализации → retry → читает 900 → коммитит 800. T3 получает ошибку → retry → снова ошибка → retry → … Каскад откатов, latency растёт экспоненциально, пропускная способность падает.

При пессимистичном подходе: T1 берёт FOR UPDATE, T2 и T3 ждут. T1 коммитит — T2 работает — T3 работает. Каждая транзакция ждёт, но каждая завершается с первой попытки. Пропускная способность предсказуема.

Для hot spot FOR UPDATE лучше: очередь эффективнее каскада откатов.

Длинные транзакции: когда оптимистичный подход выигрывает

Генерация месячного отчёта: читаем 100 000 строк, агрегируем, записываем результат в одну строку. FOR UPDATE на 100 000 строк заблокирует их на всё время отчёта — другие транзакции не смогут обновлять эти данные, пропускная способность системы упадёт.

REPEATABLE READ работает со snapshot — никого не блокирует. Другие транзакции свободно читают и пишут. Если кто-то изменил ту одну строку результата — получим ошибку и один retry. Для длинных read-heavy транзакций оптимистичный подход лучше.

КритерийFOR UPDATE (пессимистичный)REPEATABLE READ (оптимистичный)
Конфликты частые (hot spot)Лучше: очередьКаскад откатов
Конфликты редкиеЛишние блокировкиЛучше: большинство без отката
Транзакция короткаяЛучшеПриемлемо
Транзакция длиннаяБлокирует другихЛучше: snapshot
Читаем много, пишем малоБлокируем лишнееЛучше
Нужна гарантия успехаВсегда успешно*Требует retry
Простота кодаНет retry-логикиТребует try/catch

* При условии отсутствия deadlock.

Выбор между FOR UPDATE и REPEATABLE READ предполагает, что инвариант затрагивает одну строку. Когда инвариант связывает несколько строк, нужен другой механизм.

Cross-row инварианты: когда нужен SERIALIZABLE

В больнице минимум один врач должен дежурить. Два врача одновременно снимаются с дежурства:

-- T1:
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- видит 2
UPDATE doctors SET on_call = false WHERE id = 1;     -- "останется 1"
 
-- T2 (параллельно):
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- видит 2
UPDATE doctors SET on_call = false WHERE id = 2;     -- "останется 1"
 
-- Результат: 0 дежурных. Инвариант нарушен.

REPEATABLE READ не поможет: он ловит конфликты только когда две транзакции пишут одну строку. Здесь строки разные (id=1 и id=2), конфликта записи нет. Это write skew — аномалия, при которой каждая транзакция видит корректный snapshot, но результат их совместного действия нарушает инвариант.

SERIALIZABLE отслеживает зависимости через SIREAD locks: «T1 читала данные, которые T2 изменила» + «T2 читала данные, которые T1 изменила» = цикл зависимостей → одна из транзакций откатывается.

Та же проблема возникает с INSERT. Инвариант ограничивает количество строк — две транзакции проверяют и вставляют новые:

-- Инвариант: очередь ≤ 10 человек
-- T1:
SELECT COUNT(*) FROM queue;  -- видит 9
INSERT INTO queue (...);     -- "ещё можно"
 
-- T2 (параллельно):
SELECT COUNT(*) FROM queue;  -- видит 9
INSERT INTO queue (...);     -- "ещё можно"
 
-- Результат: 11 строк. Инвариант нарушен.

INSERT создаёт новые строки — конфликта с существующими нет. REPEATABLE READ не поможет. Это write skew с фантомами.

Аналогично с DELETE. Инвариант требует минимальное количество строк — две транзакции проверяют и удаляют разные:

-- Инвариант: минимум 3 товара на складе
-- T1:
SELECT COUNT(*) FROM inventory WHERE product = 'X';  -- видит 4
DELETE FROM inventory WHERE id = 101;                -- "останется 3"
 
-- T2 (параллельно):
SELECT COUNT(*) FROM inventory WHERE product = 'X';  -- видит 4
DELETE FROM inventory WHERE id = 102;                -- "останется 3"
 
-- Результат: 2 товара. Инвариант нарушен.

Разные строки — REPEATABLE READ не ловит. Во всех трёх случаях (UPDATE разных строк, INSERT, DELETE) решение одно: либо SERIALIZABLE, либо FOR UPDATE на всех связанных строках перед проверкой.

Цена повышения уровня изоляции

УровеньПамятьCPUРиск откатовВлияние на VACUUM
READ COMMITTEDМинимум (snapshot на оператор)МинимумНизкийМинимум
REPEATABLE READSnapshot на транзакциюВышеПри конфликте записиLong-running мешают
SERIALIZABLESnapshot + SIREAD locksОтслеживание зависимостейПри любом опасном циклеТо же + память на locks

Ключевой эффект REPEATABLE READ и SERIALIZABLE на инфраструктуру — влияние на VACUUM. Эти уровни держат snapshot на всю транзакцию. Если транзакция длится 30 минут — все версии строк, существовавшие на момент старта, должны оставаться: VACUUM не может удалить dead tuples, видимые этому snapshot. Результат — таблицы разбухают (bloat), производительность падает.

Как выбрать

Первый вопрос: помещается ли логика в атомарный SQL? Если да — UPDATE ... SET x = x - 100 WHERE condition, READ COMMITTED. Это проще всего.

Если нет: затрагивает ли инвариант несколько строк? Если да — SERIALIZABLE или FOR UPDATE на всех связанных строках.

Если инвариант на одной строке: конфликты частые (hot spot)? → FOR UPDATE. Конфликты редкие? → REPEATABLE READ + retry. Транзакция длинная и read-heavy? → REPEATABLE READ, чтобы не блокировать систему. Если retry-логика неприемлема (legacy-код, сложная оркестрация) — FOR UPDATE надёжнее: каждая транзакция завершается с первой попытки.

Паттерны описывают правильные подходы. Распространённые ошибки показывают, что бывает, когда эти подходы игнорируются или применяются неверно.

Сводка

Что защищает от чего

АномалияREAD COMMITTED+ FOR UPDATEREPEATABLE READSERIALIZABLE
Dirty read
Non-repeatable read
Phantom read
Lost update✗*
Write skew✓**

* READ COMMITTED защищает при атомарном SQL (balance = balance - 100), не защищает при read-modify-write в приложении.

** FOR UPDATE защищает от write skew, только если заблокировать все связанные строки.

Паттерн → минимальный уровень

ПаттернПримерМинимальный уровень
Независимые операцииSET status = 'blocked'READ COMMITTED
Read-modify-write в SQLSET balance = balance - 100READ COMMITTED
Read-modify-write в приложенииRuby: read → compute → UPDATEREPEATABLE READ (или FOR UPDATE)
Проверка + запись (одна строка)if balance >= 100 then UPDATEREPEATABLE READ (или FOR UPDATE)
Cross-row + UPDATE разных строкДежурные врачиSERIALIZABLE (или FOR UPDATE на всех)
Cross-row + INSERTОграничение очередиSERIALIZABLE
Cross-row + DELETEМинимальный запасSERIALIZABLE

Sources


Блокировки | Распространённые ошибки