Практические паттерны параллельного доступа
Предпосылки: уровни изоляции, блокировки, 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 READ | Snapshot на транзакцию | Выше | При конфликте записи | Long-running мешают |
| SERIALIZABLE | Snapshot + 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 UPDATE | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| 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 в SQL | SET balance = balance - 100 | READ COMMITTED |
| Read-modify-write в приложении | Ruby: read → compute → UPDATE | REPEATABLE READ (или FOR UPDATE) |
| Проверка + запись (одна строка) | if balance >= 100 then UPDATE | REPEATABLE READ (или FOR UPDATE) |
| Cross-row + UPDATE разных строк | Дежурные врачи | SERIALIZABLE (или FOR UPDATE на всех) |
| Cross-row + INSERT | Ограничение очереди | SERIALIZABLE |
| Cross-row + DELETE | Минимальный запас | SERIALIZABLE |
Sources
- PostgreSQL Documentation (пример: v16): Transaction Isolation и Explicit Locking (
SELECT ... FOR UPDATE). https://www.postgresql.org/docs/16/transaction-iso.html, https://www.postgresql.org/docs/16/explicit-locking.html